diff --git a/src/main/daemon/headless-emulator-kitty-flags.test.ts b/src/main/daemon/headless-emulator-kitty-flags.test.ts new file mode 100644 index 000000000000..1a141e03d466 --- /dev/null +++ b/src/main/daemon/headless-emulator-kitty-flags.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from 'vitest' +import { HeadlessEmulator } from './headless-emulator' + +describe('HeadlessEmulator kitty keyboard flags', () => { + it('stays at zero until the PTY pushes the protocol', async () => { + const emulator = new HeadlessEmulator({ cols: 80, rows: 24 }) + + expect(emulator.kittyKeyboardFlags()).toBe(0) + await emulator.applyKittyKeyboardFlags(1) + + expect(emulator.kittyKeyboardFlags()).toBe(1) + }) +}) diff --git a/src/main/daemon/headless-emulator.ts b/src/main/daemon/headless-emulator.ts index fa1cb054ee3d..58698635f809 100644 --- a/src/main/daemon/headless-emulator.ts +++ b/src/main/daemon/headless-emulator.ts @@ -10,7 +10,7 @@ import { import { advancePartialEscapeTail } from '../../shared/terminal-partial-escape-tail' import type { TerminalViewAttributes } from '../../shared/terminal-view-attributes' import { collectHeadlessOscLinkRanges } from './headless-osc-link-ranges' -import { readTerminalModes } from './headless-emulator-modes' +import { readKittyKeyboardFlags, readTerminalModes } from './headless-emulator-modes' import { buildRehydrateSequences } from './terminal-mode-rehydrate-sequences' import { TerminalOscCwdTitleScanner } from './terminal-osc-cwd-title-scanner' import { buildFrameRestoreSnapshotFields } from './terminal-frame-restore-sequences' @@ -146,6 +146,11 @@ export class HeadlessEmulator { this.viewAttributeResponder?.clearColorOverrides() } + /** Flags the hidden xterm parsed from this PTY's output. `0` means the kitty keyboard protocol is off. */ + kittyKeyboardFlags(): number { + return readKittyKeyboardFlags(this.terminal) + } + /** Re-seeds snapshot kitty flags via the live-push parse, routed unflagged so it can never answer a query (terminal-query-authority.md). */ applyKittyKeyboardFlags(flags: number): Promise { if (!Number.isInteger(flags) || flags <= 0) { diff --git a/src/main/runtime/headless-hydration-ownership.test.ts b/src/main/runtime/headless-hydration-ownership.test.ts index 675a57bf70b1..3eb5ec54996f 100644 --- a/src/main/runtime/headless-hydration-ownership.test.ts +++ b/src/main/runtime/headless-hydration-ownership.test.ts @@ -1,4 +1,8 @@ import { expect, it, vi } from 'vitest' +import { + TERMINAL_INTERRUPT_KITTY_CTRL_C, + terminalInterruptBytes +} from '../../shared/terminal-interrupt-bytes' import { deferred, makeDeferred } from './orca-runtime-test-fixtures.spec' import { createHydrationRuntime, @@ -26,6 +30,31 @@ function prepare() { return { runtime, snapshot, serialize } } +it('reapplies kitty keyboard flags proven by the renderer buffer', async () => { + const { runtime, snapshot } = prepare() + runtime.onPtyData(PTY_ID, 'LIVE', 1) + const current = runtime.model() + snapshot.resolve({ ...RETIRED_SNAPSHOT, seq: 4, kittyKeyboardFlags: 1 }) + await current.writeChain + expect(current.emulator.kittyKeyboardFlags()).toBe(1) + expect(terminalInterruptBytes(current.emulator.kittyKeyboardFlags())).toBe( + TERMINAL_INTERRUPT_KITTY_CTRL_C + ) +}) + +it('applies kitty keyboard flags when the renderer buffer is empty', async () => { + const { runtime, snapshot } = prepare() + runtime.onPtyData(PTY_ID, 'LIVE', 1) + const current = runtime.model() + snapshot.resolve({ ...RETIRED_SNAPSHOT, data: '', seq: 4, kittyKeyboardFlags: 1 }) + await current.writeChain + expect(current.emulator.kittyKeyboardFlags()).toBe(1) + expect(terminalInterruptBytes(current.emulator.kittyKeyboardFlags())).toBe( + TERMINAL_INTERRUPT_KITTY_CTRL_C + ) + expect(current.emulator.getVisibleLines().join('\n')).not.toContain('RETIRED-SEED') +}) + it('does not start renderer hydration after the model retires before its callback', async () => { const { runtime, snapshot, serialize } = prepare() runtime.onPtyData(PTY_ID, 'queued-live', 1) diff --git a/src/main/runtime/headless-seed-ownership.test.ts b/src/main/runtime/headless-seed-ownership.test.ts index 42fe9c91660d..80cb3324c04b 100644 --- a/src/main/runtime/headless-seed-ownership.test.ts +++ b/src/main/runtime/headless-seed-ownership.test.ts @@ -93,6 +93,15 @@ it('preserves current seed metadata and ordered live output', async () => { expect(snapshot.modes.kittyKeyboardFlags).toBe(3) }) +it('restores kitty keyboard flags from the provider snapshot after replacement', async () => { + const { runtime, snapshot } = prepareProvider() + runtime.replaceExecutionContext() + const model = runtime.model() + snapshot.resolve({ ...PROVIDER_SNAPSHOT, kittyKeyboardFlags: 1 }) + await model.writeChain + expect(runtime.model().emulator.kittyKeyboardFlags()).toBe(1) +}) + it('does not acquire a provider snapshot for a model retired before its callback', async () => { const { runtime, snapshot, serialize } = prepareProvider() runtime.replaceExecutionContext() diff --git a/src/main/runtime/orca-runtime-controller-knows-pty-is-live.ts b/src/main/runtime/orca-runtime-controller-knows-pty-is-live.ts index 3c9c8b7eab94..b5d330f9eae1 100644 --- a/src/main/runtime/orca-runtime-controller-knows-pty-is-live.ts +++ b/src/main/runtime/orca-runtime-controller-knows-pty-is-live.ts @@ -112,7 +112,10 @@ export class OrcaRuntimeWithControllerKnowsPtyIsLive extends OrcaRuntimeWithReso if (!pty.pty.connected) { throw new Error('terminal_not_writable') } - const payload = buildTerminalSendPayload(action) + const payload = buildTerminalSendPayload( + action, + this.readPtyKittyKeyboardFlags(pty.pty.ptyId) + ) if (payload === null) { throw new Error('invalid_terminal_send') } @@ -129,7 +132,7 @@ export class OrcaRuntimeWithControllerKnowsPtyIsLive extends OrcaRuntimeWithReso if (!leaf.writable || !leaf.ptyId) { throw new Error('terminal_not_writable') } - const payload = buildTerminalSendPayload(action) + const payload = buildTerminalSendPayload(action, this.readPtyKittyKeyboardFlags(leaf.ptyId)) if (payload === null) { throw new Error('invalid_terminal_send') } diff --git a/src/main/runtime/orca-runtime-create-pty-headless-terminal-state.ts b/src/main/runtime/orca-runtime-create-pty-headless-terminal-state.ts index d417fc7434f9..834d9f0fecf7 100644 --- a/src/main/runtime/orca-runtime-create-pty-headless-terminal-state.ts +++ b/src/main/runtime/orca-runtime-create-pty-headless-terminal-state.ts @@ -130,6 +130,14 @@ export class OrcaRuntimeWithCreatePtyHeadlessTerminalState extends OrcaRuntimeWi if (this.headlessTerminals.get(ptyId) !== state) { return } + // The provider snapshot omits kitty pushes, same as a renderer rehydrate. + // Reapply the flags after the seed so a later interrupt still encodes Ctrl+C. + if (typeof snapshot.kittyKeyboardFlags === 'number') { + await state.emulator.applyKittyKeyboardFlags(snapshot.kittyKeyboardFlags) + if (this.headlessTerminals.get(ptyId) !== state) { + return + } + } if (snapshot.cwd !== undefined) { state.emulator.setCwd(snapshot.cwd) if (!this.terminalCwdByPtyId.has(ptyId) && snapshot.cwd?.trim()) { diff --git a/src/main/runtime/orca-runtime-fit-override-listeners.ts b/src/main/runtime/orca-runtime-fit-override-listeners.ts index 5ef17ce4c2ea..0a4a27ee0be9 100644 --- a/src/main/runtime/orca-runtime-fit-override-listeners.ts +++ b/src/main/runtime/orca-runtime-fit-override-listeners.ts @@ -45,6 +45,11 @@ export class OrcaRuntimeWithFitOverrideListeners extends OrcaRuntimeWithStopRequ protected headlessTerminals = new Map() + /** `0` when this PTY has no headless model yet, so an interrupt stays a bare ETX. */ + protected readPtyKittyKeyboardFlags(ptyId: string): number { + return this.headlessTerminals.get(ptyId)?.emulator.kittyKeyboardFlags() ?? 0 + } + protected ptyOutputSequenceById = new Map() protected providerSequenceInitializedPtys = new Set() diff --git a/src/main/runtime/orca-runtime-maybe-hydrate-headless-from-renderer.ts b/src/main/runtime/orca-runtime-maybe-hydrate-headless-from-renderer.ts index 40706f4730f1..5b9ec432b28c 100644 --- a/src/main/runtime/orca-runtime-maybe-hydrate-headless-from-renderer.ts +++ b/src/main/runtime/orca-runtime-maybe-hydrate-headless-from-renderer.ts @@ -7,12 +7,7 @@ import { detectAgentStatusFromTitle, normalizeTerminalTitle } from '../../shared import { shouldModelAnswerHiddenPtyQueries } from './terminal-model-query-authority' export class OrcaRuntimeWithMaybeHydrateHeadlessFromRenderer extends OrcaRuntimeWithSerializeMainTerminalBuffer { - // Why: hydrate the runtime headless emulator from the desktop renderer's - // xterm buffer on the first onPtyData byte after a PTY is taken over by a - // pane. Eager-state pattern matches seedHeadlessTerminal: headlessTerminals - // is populated synchronously so concurrent live writes from - // trackHeadlessTerminalData chain after the seed via the same writeChain. - // See docs/mobile-prefer-renderer-scrollback.md. + /** Hydrates a taken-over PTY from the renderer buffer before live writes use its headless emulator. */ protected maybeHydrateHeadlessFromRenderer(ptyId: string): void { if (this.headlessHydrationState.has(ptyId)) { return @@ -63,11 +58,16 @@ export class OrcaRuntimeWithMaybeHydrateHeadlessFromRenderer extends OrcaRuntime const rendered = await controller.serializeBuffer!(ptyId, { scrollbackRows: MOBILE_SUBSCRIBE_SCROLLBACK_ROWS }) - if ( - this.headlessTerminals.get(ptyId) !== state || - !rendered || - rendered.data.length === 0 - ) { + if (this.headlessTerminals.get(ptyId) !== state || !rendered) { + return + } + // Why: a blank renderer buffer can still carry kitty flags beside the + // payload. Returning on empty data left the new emulator at 0, so an + // interrupt sent ETX while the TUI was in the protocol. + if (rendered.data.length === 0) { + if (typeof rendered.kittyKeyboardFlags === 'number') { + await state.emulator.applyKittyKeyboardFlags(rendered.kittyKeyboardFlags) + } return } this.recordOsc7MetadataForPty(ptyId, rendered.data) @@ -82,6 +82,16 @@ export class OrcaRuntimeWithMaybeHydrateHeadlessFromRenderer extends OrcaRuntime if (this.headlessTerminals.get(ptyId) !== state) { return } + // Why AFTER the seed write: a renderer buffer omits kitty pushes, same as a + // provider snapshot. The proven flags ride beside the payload. Without this + // reapply, a model seeded from the mounted renderer reports 0 and an + // interrupt falls back to ETX while the TUI is still in the protocol. + if (typeof rendered.kittyKeyboardFlags === 'number') { + await state.emulator.applyKittyKeyboardFlags(rendered.kittyKeyboardFlags) + if (this.headlessTerminals.get(ptyId) !== state) { + return + } + } const ptyDims = this.getTerminalSize(ptyId) if (ptyDims && (ptyDims.cols !== rendered.cols || ptyDims.rows !== rendered.rows)) { state.emulator.resize(ptyDims.cols, ptyDims.rows) diff --git a/src/main/runtime/runtime-terminal-writer.test.ts b/src/main/runtime/runtime-terminal-writer.test.ts new file mode 100644 index 000000000000..99d234ef3caa --- /dev/null +++ b/src/main/runtime/runtime-terminal-writer.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest' +import { TERMINAL_INTERRUPT_KITTY_CTRL_C } from '../../shared/terminal-interrupt-bytes' +import { RuntimeTerminalWriter } from './runtime-terminal-writer' + +describe('RuntimeTerminalWriter interrupt bytes', () => { + it('writes a bare ETX when the PTY has no kitty keyboard flags', async () => { + const writes: string[] = [] + const writer = new RuntimeTerminalWriter((ptyId, data) => { + expect(ptyId).toBe('pty-1') + writes.push(data) + return true + }) + + await writer.writeAction('pty-1', { interrupt: true }, '\x03', { inputKind: 'driving' }) + + expect(writes).toEqual(['\x03']) + }) + + it('writes the interrupt bytes already chosen for the payload', async () => { + const writes: string[] = [] + const writer = new RuntimeTerminalWriter((_ptyId, data) => { + writes.push(data) + return true + }) + + await writer.writeAction('pty-1', { interrupt: true }, TERMINAL_INTERRUPT_KITTY_CTRL_C, { + inputKind: 'driving' + }) + + expect(writes).toEqual([TERMINAL_INTERRUPT_KITTY_CTRL_C]) + }) +}) diff --git a/src/main/runtime/runtime-terminal-writer.ts b/src/main/runtime/runtime-terminal-writer.ts index 8c308d22b281..129e13df6f13 100644 --- a/src/main/runtime/runtime-terminal-writer.ts +++ b/src/main/runtime/runtime-terminal-writer.ts @@ -34,7 +34,9 @@ export class RuntimeTerminalWriter { await this.writeChunks(ptyId, text, options) } if (hasSuffix) { - const suffix = (action.enter ? '\r' : '') + (action.interrupt ? '\x03' : '') + // The payload already chose the interrupt bytes. Reading the flags again + // after the write delay can send a different sequence than the one counted. + const suffix = payload.slice(text.length) if (text) { // Why: same hazard as the agent-prompt path -- Enter must not overtake text the // execution host is still ingesting, and a flat 500 ms cannot cover 16 MB. diff --git a/src/main/runtime/terminal-send-payload.ts b/src/main/runtime/terminal-send-payload.ts index feddd5bdefc5..8b2605d2367a 100644 --- a/src/main/runtime/terminal-send-payload.ts +++ b/src/main/runtime/terminal-send-payload.ts @@ -2,12 +2,17 @@ import { isTerminalInputTooLargeWithYield, TERMINAL_INPUT_TOO_LARGE_ERROR } from '../../shared/terminal-input' +import { terminalInterruptBytes } from '../../shared/terminal-interrupt-bytes' -export function buildTerminalSendPayload(action: { - text?: string - enter?: boolean - interrupt?: boolean -}): string | null { +/** Builds terminal.send bytes using the PTY's current kitty flags for Ctrl+C. */ +export function buildTerminalSendPayload( + action: { + text?: string + enter?: boolean + interrupt?: boolean + }, + kittyKeyboardFlags = 0 +): string | null { let payload = '' if (typeof action.text === 'string' && action.text.length > 0) { payload += action.text @@ -16,7 +21,7 @@ export function buildTerminalSendPayload(action: { payload += '\r' } if (action.interrupt) { - payload += '\x03' + payload += terminalInterruptBytes(kittyKeyboardFlags) } return payload.length > 0 ? payload : null } diff --git a/src/renderer/src/components/terminal-pane/pty-connection-interrupt-inference.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-interrupt-inference.test.ts index abe8e7d34491..455718c001db 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-interrupt-inference.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-interrupt-inference.test.ts @@ -422,6 +422,62 @@ describe('connectPanePty', () => { }) }) + it('infers Kitty Ctrl+C from a non-Latin key path without captured intent', async () => { + const { connectPanePty } = await import('./pty-connection') + const { pasteTerminalText } = await import('./terminal-bracketed-paste') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + vi.useFakeTimers() + vi.setSystemTime(1_100) + const paneKey = makePaneKey('tab-1', LEAF_1) + mockStoreState.agentStatusByPaneKey[paneKey] = { + state: 'working', + prompt: 'stop from a non-Latin keyboard layout', + updatedAt: 1_000, + stateStartedAt: 900, + agentType: 'custom-agent', + paneKey, + terminalTitle: 'Codex working', + stateHistory: [] + } + const terminalTarget = createKeyboardEventTarget() + const pane = createPane(1) + ;(pane.terminal as { element?: unknown }).element = terminalTarget.target + pane.terminal.modes.bracketedPasteMode = true + const observedIgnoreValues: (boolean | undefined)[] = [] + pane.terminal.paste.mockImplementation(() => { + observedIgnoreValues.push(pane.terminal.options.ignoreBracketedPasteMode) + }) + let onDataHandler: ((data: string) => void) | null = null + pane.terminal.onData = vi.fn(((handler: (data: string) => void) => { + onDataHandler = handler + return { dispose: vi.fn() } + }) as typeof pane.terminal.onData) + + connectPanePty(pane as never, createManager(1) as never, createDeps() as never) + if (!onDataHandler) { + throw new Error('expected onData handler to be registered') + } + terminalTarget.dispatch(keyEvent({ key: 'ㅊ', code: 'KeyC', ctrlKey: true })) + ;(onDataHandler as unknown as (data: string) => void)('\x1b[99;5u') + await flushAsyncTicks() + vi.advanceTimersByTime(500) + await flushAsyncTicks() + pasteTerminalText(pane.terminal as never, 'a69ce28e1d092e0c8825cd1a109ac36409962bc1') + + expect(transport.sendInputAccepted).toHaveBeenCalledWith('\x1b[99;5u', 'query-reply') + expect(window.api.agentStatus.inferInterrupt).toHaveBeenCalledWith({ + paneKey, + baselineUpdatedAt: 1_000, + baselineStateStartedAt: 900, + baselinePrompt: 'stop from a non-Latin keyboard layout', + baselineAgentType: 'custom-agent', + intent: 'ctrl-c' + }) + expect(observedIgnoreValues).toEqual([true]) + expect(pane.terminal.options.ignoreBracketedPasteMode).toBe(false) + }) + it('marks bracketed paste as stale after acknowledged Ctrl+C input', async () => { const { connectPanePty } = await import('./pty-connection') const { pasteTerminalText } = await import('./terminal-bracketed-paste') diff --git a/src/renderer/src/components/terminal-pane/pty-connection-shell-command-inference.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-shell-command-inference.test.ts new file mode 100644 index 000000000000..4cbbdda7472d --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection-shell-command-inference.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it, vi } from 'vitest' +import { + TERMINAL_INTERRUPT_ETX, + TERMINAL_INTERRUPT_KITTY_CTRL_C +} from '../../../../shared/terminal-interrupt-bytes' +import { observeAcceptedShellCommandInput } from './pty-connection/shell-command-inference' + +function buildState() { + const requestReconfirmation = vi.fn() + const state = { + commandInferredPaneAgent: null, + requestKnownWindowsShiftEnterReconfirmation: requestReconfirmation, + hasFreshPaneAgentSurface: vi.fn(() => false), + shellCommandInferenceSuspendedUntilCommandEnd: false, + pendingShellCommandLine: '', + pendingShellCommandCursor: 0, + resetPendingShellCommandLine: vi.fn(), + rememberCommandInferredPaneAgent: vi.fn(), + deletePendingShellCommandCharacter: vi.fn(), + deletePendingShellCommandWord: vi.fn(), + consumeShellCommandCsiSequence: vi.fn(() => null), + appendPendingShellCommandInput: vi.fn((text: string) => { + state.pendingShellCommandLine = + state.pendingShellCommandLine.slice(0, state.pendingShellCommandCursor) + + text + + state.pendingShellCommandLine.slice(state.pendingShellCommandCursor) + state.pendingShellCommandCursor += text.length + }) + } + state.resetPendingShellCommandLine = vi.fn(() => { + state.pendingShellCommandLine = '' + state.pendingShellCommandCursor = 0 + }) + + return { state, requestReconfirmation } +} + +describe('shell command inference Ctrl+C handling', () => { + it.each([ + ['ETX', TERMINAL_INTERRUPT_ETX], + ['Kitty CSI-u', TERMINAL_INTERRUPT_KITTY_CTRL_C] + ])('clears the partial command and requests reconfirmation for %s', (_label, interrupt) => { + const { state, requestReconfirmation } = buildState() + + state.pendingShellCommandLine = 'claude --resume' + state.pendingShellCommandCursor = state.pendingShellCommandLine.length + observeAcceptedShellCommandInput(state, `claude --resume${interrupt}`) + + expect(state.pendingShellCommandLine).toBe('') + expect(state.pendingShellCommandCursor).toBe(0) + expect(requestReconfirmation).toHaveBeenCalledExactlyOnceWith() + }) + + it.each([ + ['ETX', TERMINAL_INTERRUPT_ETX], + ['Kitty CSI-u', TERMINAL_INTERRUPT_KITTY_CTRL_C] + ])( + 'cancels suspended inference when %s is embedded in an accepted chunk', + (_label, interrupt) => { + const { state, requestReconfirmation } = buildState() + + state.pendingShellCommandLine = 'partial command' + state.pendingShellCommandCursor = state.pendingShellCommandLine.length + state.shellCommandInferenceSuspendedUntilCommandEnd = true + observeAcceptedShellCommandInput(state, `partial command${interrupt}`) + + expect(state.shellCommandInferenceSuspendedUntilCommandEnd).toBe(false) + expect(state.pendingShellCommandLine).toBe('') + expect(requestReconfirmation).toHaveBeenCalledExactlyOnceWith() + } + ) +}) diff --git a/src/renderer/src/components/terminal-pane/pty-connection/interrupt-input-intent.ts b/src/renderer/src/components/terminal-pane/pty-connection/interrupt-input-intent.ts index 9a6147ab6499..8753b5da409b 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/interrupt-input-intent.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/interrupt-input-intent.ts @@ -4,6 +4,7 @@ import type { AgentStatusEntry } from '../../../../../shared/agent-status-types' import { createAgentInterruptInference } from '../agent-interrupt-inference' import { createAgentQuestionAnsweredInference } from '../agent-question-answered-inference' import type { AgentInterruptInputIntent } from '../../../../../shared/agent-interrupt-intent' +import { isTerminalInterruptInput } from '../../../../../shared/terminal-interrupt-bytes' import { markTerminalBracketedPasteInterrupted } from '../terminal-bracketed-paste' import { REATTACH_IDLE_AGENT_CURSOR_RESET_DELAY_MS } from './foreground-output-scan' @@ -121,11 +122,12 @@ export function installInterruptInputIntent(session: ConnectPanePtySession): voi } session.inputMatchesIntent = (intent: AgentInterruptInputIntent, data: string): boolean => { return ( - (intent === 'plain-escape' && data === '\x1b') || (intent === 'ctrl-c' && data === '\x03') + (intent === 'plain-escape' && data === '\x1b') || + (intent === 'ctrl-c' && isTerminalInterruptInput(data)) ) } session.inferIntentFromExactTerminalInput = (data: string): AgentInterruptInputIntent | null => { - if (data === '\x03') { + if (isTerminalInterruptInput(data)) { return 'ctrl-c' } if (data === '\x1b') { @@ -146,7 +148,7 @@ export function installInterruptInputIntent(session: ConnectPanePtySession): voi data: string, intent: AgentInterruptInputIntent | null = null ): void => { - if (intent === 'ctrl-c' || data === '\x03') { + if (intent === 'ctrl-c' || isTerminalInterruptInput(data)) { markTerminalBracketedPasteInterrupted(session.pane.terminal) } // Why: every delivered-input path funnels through here, so this is where a diff --git a/src/renderer/src/components/terminal-pane/pty-connection/shell-command-inference.ts b/src/renderer/src/components/terminal-pane/pty-connection/shell-command-inference.ts index 83cc0a0301bd..c6546b7da5ec 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/shell-command-inference.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/shell-command-inference.ts @@ -2,6 +2,10 @@ import { detectAgentStatusFromTitle } from '@/lib/agent-status' import { useAppStore } from '@/store' import type { AgentType } from '../../../../../shared/agent-status-types' import { AGENT_INTERRUPT_SETTLE_MS } from '../../../../../shared/agent-interrupt-intent' +import { + containsTerminalInterruptInput, + TERMINAL_INTERRUPT_KITTY_CTRL_C +} from '../../../../../shared/terminal-interrupt-bytes' import { resolvePaneAgentOwner } from '../../../../../shared/pane-agent-owner' import { MANUAL_AGENT_COMMAND_MAX_CHARS } from './pty-connect-limits' @@ -13,86 +17,131 @@ import { import type { ConnectPanePtySession } from './connect-pane-pty-session' -export function installShellCommandInference(session: ConnectPanePtySession): void { - session.observeAcceptedShellCommandInput = (data: string): void => { - if ( - data.includes('\r') || - data.includes('\n') || - data.includes('\x03') || - data.includes('\x04') - ) { - // Why: shells without OSC 133 give no command/exit boundary. An accepted - // submit or interrupt revokes stale trusted Shift+Enter routing and confirms once. - session.requestKnownWindowsShiftEnterReconfirmation() - } - if (session.commandInferredPaneAgent) { - return - } - // Why: bytes typed inside a live agent TUI are prompt text, not shell - // commands, even if they spell another agent binary name. - if (session.hasFreshPaneAgentSurface()) { +type ShellCommandInputState = { + commandInferredPaneAgent: unknown + requestKnownWindowsShiftEnterReconfirmation: () => void + hasFreshPaneAgentSurface: () => boolean + shellCommandInferenceSuspendedUntilCommandEnd: boolean + resetPendingShellCommandLine: () => void + rememberCommandInferredPaneAgent: () => void + deletePendingShellCommandCharacter: () => void + deletePendingShellCommandWord: () => void + consumeShellCommandCsiSequence: (data: string, index: number) => number | null + appendPendingShellCommandInput: (text: string) => void +} + +export function observeAcceptedShellCommandInput( + session: ShellCommandInputState, + data: string +): void { + if ( + data.includes('\r') || + data.includes('\n') || + containsTerminalInterruptInput(data) || + data.includes('\x04') + ) { + // Why: shells without OSC 133 give no command/exit boundary. An accepted + // submit or interrupt revokes stale trusted Shift+Enter routing and confirms once. + session.requestKnownWindowsShiftEnterReconfirmation() + } + if (session.commandInferredPaneAgent) { + return + } + // Why: bytes typed inside a live agent TUI are prompt text, not shell + // commands, even if they spell another agent binary name. + if (session.hasFreshPaneAgentSurface()) { + session.resetPendingShellCommandLine() + return + } + if (session.shellCommandInferenceSuspendedUntilCommandEnd) { + if (containsTerminalInterruptInput(data) || data.includes('\x15')) { + session.shellCommandInferenceSuspendedUntilCommandEnd = false session.resetPendingShellCommandLine() - return } - if (session.shellCommandInferenceSuspendedUntilCommandEnd) { - if (data.includes('\x03') || data.includes('\x15')) { - session.shellCommandInferenceSuspendedUntilCommandEnd = false - session.resetPendingShellCommandLine() - } - if (data.includes('\r') || data.includes('\n')) { - session.shellCommandInferenceSuspendedUntilCommandEnd = false + if (data.includes('\r') || data.includes('\n')) { + session.shellCommandInferenceSuspendedUntilCommandEnd = false + } + return + } + if (data.length > MANUAL_AGENT_COMMAND_MAX_CHARS) { + session.resetPendingShellCommandLine() + session.shellCommandInferenceSuspendedUntilCommandEnd = + !data.includes('\r') && !data.includes('\n') + return + } + for (let index = 0; index < data.length; index += 1) { + const char = data[index]! + if (char === '\r' || char === '\n') { + session.shellCommandInferenceSuspendedUntilCommandEnd = false + session.rememberCommandInferredPaneAgent() + if (session.commandInferredPaneAgent) { + return } - return + continue + } + if (char === '\x7f' || char === '\b') { + session.deletePendingShellCommandCharacter() + continue + } + if (char === '\x17') { + session.deletePendingShellCommandWord() + continue } - if (data.length > MANUAL_AGENT_COMMAND_MAX_CHARS) { + if (char === '\x03' || char === '\x15') { session.resetPendingShellCommandLine() - session.shellCommandInferenceSuspendedUntilCommandEnd = - !data.includes('\r') && !data.includes('\n') - return + continue } - for (let index = 0; index < data.length; index += 1) { - const char = data[index]! - if (char === '\r' || char === '\n') { - session.shellCommandInferenceSuspendedUntilCommandEnd = false - session.rememberCommandInferredPaneAgent() - if (session.commandInferredPaneAgent) { - return - } - continue - } - if (char === '\x7f' || char === '\b') { - session.deletePendingShellCommandCharacter() - continue - } - if (char === '\x17') { - session.deletePendingShellCommandWord() - continue - } - if (char === '\x03' || char === '\x15') { - session.resetPendingShellCommandLine() - continue - } - if (char === '\x1b') { - const nextIndex = session.consumeShellCommandCsiSequence(data, index) - if (nextIndex !== null) { - index = nextIndex - 1 - continue - } + if (char === '\x1b') { + if (data.startsWith(TERMINAL_INTERRUPT_KITTY_CTRL_C, index)) { session.resetPendingShellCommandLine() + index += TERMINAL_INTERRUPT_KITTY_CTRL_C.length - 1 continue } - if (char < ' ') { - session.resetPendingShellCommandLine() + const nextIndex = session.consumeShellCommandCsiSequence(data, index) + if (nextIndex !== null) { + index = nextIndex - 1 continue } - if (char >= ' ') { - session.appendPendingShellCommandInput(char) - if (session.shellCommandInferenceSuspendedUntilCommandEnd) { - return - } + session.resetPendingShellCommandLine() + continue + } + if (char < ' ') { + session.resetPendingShellCommandLine() + continue + } + if (char >= ' ') { + session.appendPendingShellCommandInput(char) + if (session.shellCommandInferenceSuspendedUntilCommandEnd) { + return } } } +} + +export function installShellCommandInference(session: ConnectPanePtySession): void { + const inputState: ShellCommandInputState = { + get commandInferredPaneAgent() { + return session.commandInferredPaneAgent + }, + requestKnownWindowsShiftEnterReconfirmation: () => + session.requestKnownWindowsShiftEnterReconfirmation(), + hasFreshPaneAgentSurface: () => session.hasFreshPaneAgentSurface(), + get shellCommandInferenceSuspendedUntilCommandEnd() { + return session.shellCommandInferenceSuspendedUntilCommandEnd + }, + set shellCommandInferenceSuspendedUntilCommandEnd(value: boolean) { + session.shellCommandInferenceSuspendedUntilCommandEnd = value + }, + resetPendingShellCommandLine: () => session.resetPendingShellCommandLine(), + rememberCommandInferredPaneAgent: () => session.rememberCommandInferredPaneAgent(), + deletePendingShellCommandCharacter: () => session.deletePendingShellCommandCharacter(), + deletePendingShellCommandWord: () => session.deletePendingShellCommandWord(), + consumeShellCommandCsiSequence: (data: string, index: number) => + session.consumeShellCommandCsiSequence(data, index), + appendPendingShellCommandInput: (text: string) => session.appendPendingShellCommandInput(text) + } + session.observeAcceptedShellCommandInput = (data: string): void => + observeAcceptedShellCommandInput(inputState, data) /** * Resolves the authoritative owner agent type for this pane, checking tab launch, * pane startup, typed command ownership, and store state configuration. diff --git a/src/renderer/src/components/terminal-pane/terminal-input-quarantine.test.ts b/src/renderer/src/components/terminal-pane/terminal-input-quarantine.test.ts index 63679bec8f1c..dbd45a8fa97e 100644 --- a/src/renderer/src/components/terminal-pane/terminal-input-quarantine.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-input-quarantine.test.ts @@ -1,4 +1,8 @@ import { describe, it, expect, beforeEach } from 'vitest' +import { + TERMINAL_INTERRUPT_ETX, + TERMINAL_INTERRUPT_KITTY_CTRL_C +} from '../../../../shared/terminal-interrupt-bytes' import { _resetTerminalInputQuarantineForTests, armTerminalInputQuarantine, @@ -43,7 +47,8 @@ describe('terminal input quarantine', () => { it.each([ ['carriage return', '\r'], ['newline', '\n'], - ['ctrl-c', '\x03'] + ['ctrl-c', TERMINAL_INTERRUPT_ETX], + ['kitty ctrl-c', TERMINAL_INTERRUPT_KITTY_CTRL_C] ])('treats %s as the line terminator', (_label, terminator) => { armTerminalInputQuarantine(TAB, 0) expect(shouldDropQuarantinedTerminalInput(TAB, terminator, REATTACH_MS)).toBe(true) @@ -56,6 +61,18 @@ describe('terminal input quarantine', () => { expect(isTerminalInputQuarantined(TAB)).toBe(false) }) + it('recognizes Kitty Ctrl+C inside the surviving tail chunk', () => { + armTerminalInputQuarantine(TAB, 0) + expect( + shouldDropQuarantinedTerminalInput( + TAB, + `partial line${TERMINAL_INTERRUPT_KITTY_CTRL_C}`, + REATTACH_MS + ) + ).toBe(true) + expect(isTerminalInputQuarantined(TAB)).toBe(false) + }) + it('releases on an idle gap once a quarantined byte has been seen', () => { armTerminalInputQuarantine(TAB, 0) // Burst tail, no Enter — the user was still mid-line when the endpoint died. diff --git a/src/renderer/src/components/terminal-pane/terminal-input-quarantine.ts b/src/renderer/src/components/terminal-pane/terminal-input-quarantine.ts index 416227702c11..f248af21927c 100644 --- a/src/renderer/src/components/terminal-pane/terminal-input-quarantine.ts +++ b/src/renderer/src/components/terminal-pane/terminal-input-quarantine.ts @@ -11,6 +11,11 @@ // destroys the xterm that was being typed into, and the successor pane is the // one that receives the tail. +import { + TERMINAL_INTERRUPT_ETX, + TERMINAL_INTERRUPT_KITTY_CTRL_C +} from '../../../../shared/terminal-interrupt-bytes' + /** Absolute cap from arming. A safety valve only — input must never wedge, * even if the terminator never arrives and the pane keeps receiving data. */ const QUARANTINE_MAX_MS = 5_000 @@ -21,7 +26,7 @@ const QUARANTINE_IDLE_MS = 700 /** CR/LF submit the line; Ctrl-C abandons it. Dropping the terminator itself is * the point — it is the byte that would have submitted the mangled line. */ -const LINE_TERMINATORS = ['\r', '\n', '\x03'] +const LINE_TERMINATORS = ['\r', '\n', TERMINAL_INTERRUPT_ETX, TERMINAL_INTERRUPT_KITTY_CTRL_C] type QuarantineEntry = { armedAt: number diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-pane-input.ts b/src/renderer/src/components/terminal-pane/terminal-pane-pane-input.ts index 32ee26390299..f714085be581 100644 --- a/src/renderer/src/components/terminal-pane/terminal-pane-pane-input.ts +++ b/src/renderer/src/components/terminal-pane/terminal-pane-pane-input.ts @@ -26,7 +26,7 @@ import { shouldSuppressTerminalImeKeyboardEvent, shouldSuppressTerminalInterruptKeyup, shouldSuppressTerminalModifierKeyboardEvent, - TERMINAL_INTERRUPT_INPUT + resolveTerminalInterruptKeyboardInput } from './xterm-bypass-policy' import { markTerminalPinnedViewport } from '@/lib/pane-manager/terminal-scroll-intent' import { syncTerminalScrollIntentSoon } from '@/lib/pane-manager/terminal-scroll-intent-settle' @@ -139,7 +139,11 @@ export function installTerminalPaneInputHandling(context: PaneInputContext): voi ) { if (event.type === 'keydown') { pendingTerminalInterruptKeyup = true - pane.terminal.input(TERMINAL_INTERRUPT_INPUT) + pane.terminal.input( + resolveTerminalInterruptKeyboardInput( + paneKittyKeyboardModesRef.current.get(pane.id)?.flags ?? 0 + ) + ) } else { pendingTerminalInterruptKeyup = false } diff --git a/src/renderer/src/components/terminal-pane/xterm-bypass-policy-interrupt.test.ts b/src/renderer/src/components/terminal-pane/xterm-bypass-policy-interrupt.test.ts index bf7e6c75beed..eb52e7944683 100644 --- a/src/renderer/src/components/terminal-pane/xterm-bypass-policy-interrupt.test.ts +++ b/src/renderer/src/components/terminal-pane/xterm-bypass-policy-interrupt.test.ts @@ -2,9 +2,9 @@ import { afterEach, describe, expect, it } from 'vitest' import { _setLayoutMapForTests } from '../../lib/keyboard-layout/layout-base-character' import { shouldHandleTerminalInterruptKeyboardEvent, + resolveTerminalInterruptKeyboardInput, shouldSuppressTerminalInterruptKeyup, shouldSuppressTerminalModifierKeyboardEvent, - TERMINAL_INTERRUPT_INPUT, type XtermBypassEvent } from './xterm-bypass-policy' @@ -26,8 +26,11 @@ function event(overrides: Partial): XtermBypassEvent { afterEach(() => _setLayoutMapForTests(null)) describe('shouldHandleTerminalInterruptKeyboardEvent', () => { - it('exports the ETX byte used for terminal interrupts', () => { - expect(TERMINAL_INTERRUPT_INPUT).toBe('\x03') + it('uses shared kitty-aware Ctrl+C bytes for the renderer keyboard path', () => { + expect(resolveTerminalInterruptKeyboardInput(0)).toBe('\x03') + expect(resolveTerminalInterruptKeyboardInput(1)).toBe('\x1b[99;5u') + expect(resolveTerminalInterruptKeyboardInput(8)).toBe('\x1b[99;5u') + expect(resolveTerminalInterruptKeyboardInput(4)).toBe('\x03') }) it('handles macOS Ctrl+C as terminal interrupt even with a selection', () => { diff --git a/src/renderer/src/components/terminal-pane/xterm-bypass-policy.ts b/src/renderer/src/components/terminal-pane/xterm-bypass-policy.ts index 977a1731e1ed..65a48eb663e7 100644 --- a/src/renderer/src/components/terminal-pane/xterm-bypass-policy.ts +++ b/src/renderer/src/components/terminal-pane/xterm-bypass-policy.ts @@ -1,4 +1,5 @@ import { keybindingMatchesInput } from '../../../../shared/keybindings' +import { terminalInterruptBytes } from '../../../../shared/terminal-interrupt-bytes' import { isHangulJamoKeyText } from './hangul-jamo-key' import { getLayoutBaseCharacterForCode } from '../../lib/keyboard-layout/layout-base-character' import { @@ -73,7 +74,11 @@ export type XtermImeKeyboardOptions = { isLinux: boolean } -export const TERMINAL_INTERRUPT_INPUT = '\x03' +/** Encodes Ctrl+C using the shared bytes for the pane's current kitty flags. */ +export function resolveTerminalInterruptKeyboardInput(kittyKeyboardFlags: number): string { + return terminalInterruptBytes(kittyKeyboardFlags) +} + const TERMINAL_MODIFIER_KEYS = new Set(['Alt', 'AltGraph', 'Control', 'Meta', 'Shift']) const TERMINAL_IME_OWNED_KEYS = new Set([ 'ArrowDown', @@ -300,8 +305,8 @@ export function isAppOwnedCopyChord( } /** - * Decide whether plain Ctrl+C should bypass xterm's kitty CSI-u encoder and - * be sent as ETX through Terminal.input() instead. + * Decide whether plain Ctrl+C should bypass xterm's key encoder so the shared + * interrupt encoder can select ETX or Kitty CSI-u for the pane's negotiated flags. */ export function shouldHandleTerminalInterruptKeyboardEvent( event: XtermBypassEvent, diff --git a/src/shared/terminal-interrupt-bytes.test.ts b/src/shared/terminal-interrupt-bytes.test.ts new file mode 100644 index 000000000000..eb367e93045f --- /dev/null +++ b/src/shared/terminal-interrupt-bytes.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from 'vitest' +import { + TERMINAL_INTERRUPT_ETX, + TERMINAL_INTERRUPT_KITTY_CTRL_C, + containsTerminalInterruptInput, + isTerminalInterruptInput, + terminalInterruptBytes +} from './terminal-interrupt-bytes' + +describe('terminalInterruptBytes', () => { + it('keeps a bare ETX when the kitty keyboard protocol is off', () => { + expect(terminalInterruptBytes(0)).toBe(TERMINAL_INTERRUPT_ETX) + }) + + it('encodes Ctrl+C when disambiguation or report-all-keys is on', () => { + expect(terminalInterruptBytes(1)).toBe(TERMINAL_INTERRUPT_KITTY_CTRL_C) + expect(terminalInterruptBytes(8)).toBe(TERMINAL_INTERRUPT_KITTY_CTRL_C) + expect(terminalInterruptBytes(31)).toBe('\x1b[99;5u') + }) + + it('keeps a bare ETX when only alternate-key reporting is on', () => { + expect(terminalInterruptBytes(4)).toBe(TERMINAL_INTERRUPT_ETX) + expect(terminalInterruptBytes(2 | 4 | 16)).toBe(TERMINAL_INTERRUPT_ETX) + }) +}) + +describe('isTerminalInterruptInput', () => { + it('recognizes both ETX and Kitty CSI-u as Ctrl+C', () => { + expect(isTerminalInterruptInput(TERMINAL_INTERRUPT_ETX)).toBe(true) + expect(isTerminalInterruptInput(TERMINAL_INTERRUPT_KITTY_CTRL_C)).toBe(true) + }) + + it('does not classify other terminal input as Ctrl+C', () => { + expect(isTerminalInterruptInput('\x1b')).toBe(false) + expect(isTerminalInterruptInput('\x1b[99;1u')).toBe(false) + expect(isTerminalInterruptInput('c')).toBe(false) + }) +}) + +describe('containsTerminalInterruptInput', () => { + it('finds either Ctrl+C encoding inside an accepted input chunk', () => { + expect(containsTerminalInterruptInput(`partial${TERMINAL_INTERRUPT_ETX}`)).toBe(true) + expect(containsTerminalInterruptInput(`partial${TERMINAL_INTERRUPT_KITTY_CTRL_C}`)).toBe(true) + }) + + it('does not classify ordinary or unrelated Kitty input as Ctrl+C', () => { + expect(containsTerminalInterruptInput('partial command')).toBe(false) + expect(containsTerminalInterruptInput('\x1b[99;1u')).toBe(false) + }) +}) diff --git a/src/shared/terminal-interrupt-bytes.ts b/src/shared/terminal-interrupt-bytes.ts new file mode 100644 index 000000000000..ca799db49217 --- /dev/null +++ b/src/shared/terminal-interrupt-bytes.ts @@ -0,0 +1,35 @@ +/** Bare ETX. Shells and TUIs that are not in the kitty keyboard protocol treat this as Ctrl+C. */ +export const TERMINAL_INTERRUPT_ETX = '\x03' + +/** + * Kitty keyboard protocol encoding of Ctrl+C (`CSI 99 ; 5 u`). + * A TUI that enabled the protocol does not treat a bare ETX as Ctrl+C (#17665). + */ +export const TERMINAL_INTERRUPT_KITTY_CTRL_C = '\x1b[99;5u' + +/** Both wire encodings carry the same Ctrl+C intent back through the renderer input path. */ +export function isTerminalInterruptInput(data: string): boolean { + return data === TERMINAL_INTERRUPT_ETX || data === TERMINAL_INTERRUPT_KITTY_CTRL_C +} + +/** True when an accepted input chunk contains either Ctrl+C wire encoding. */ +export function containsTerminalInterruptInput(data: string): boolean { + return data.includes(TERMINAL_INTERRUPT_ETX) || data.includes(TERMINAL_INTERRUPT_KITTY_CTRL_C) +} + +/** + * Progressive-enhancement bits that change Ctrl+C from a legacy ETX into CSI u. + * Alternate-key reporting (4) only annotates sequences that are already encoded, + * so `CSI >4u` must still interrupt with ETX. + */ +const KITTY_CTRL_C_ESCAPE_FLAGS = 1 | 8 + +/** + * Interrupt bytes for one PTY write. + * A plain shell, and a TUI that only asked for alternate keys, still get ETX. + */ +export function terminalInterruptBytes(kittyKeyboardFlags: number): string { + return (kittyKeyboardFlags & KITTY_CTRL_C_ESCAPE_FLAGS) !== 0 + ? TERMINAL_INTERRUPT_KITTY_CTRL_C + : TERMINAL_INTERRUPT_ETX +}