From 95028759a977d64d7a1c8bb280203cc6355ab817 Mon Sep 17 00:00:00 2001 From: Wooseong Kim Date: Thu, 1 Oct 2026 20:49:07 +0900 Subject: [PATCH 1/8] fix(terminal): encode Ctrl+C when kitty keyboard mode is on terminal send --interrupt wrote a bare ETX. A TUI that enabled the kitty keyboard protocol ignores that byte, so the CLI reported success while the turn kept running. When the PTY's headless model has kitty flags, write CSI 99;5u. Flags of 0 still send ETX. Fixes #17665 --- .../headless-emulator-kitty-flags.test.ts | 13 +++++++ src/main/daemon/headless-emulator.ts | 7 +++- ...ca-runtime-controller-knows-pty-is-live.ts | 7 +++- .../orca-runtime-fit-override-listeners.ts | 5 +++ src/main/runtime/orca-runtime-runtime-id.ts | 3 +- .../runtime/runtime-terminal-writer.test.ts | 37 +++++++++++++++++++ src/main/runtime/runtime-terminal-writer.ts | 9 ++++- src/main/runtime/terminal-send-payload.ts | 16 +++++--- src/shared/terminal-interrupt-bytes.test.ts | 17 +++++++++ src/shared/terminal-interrupt-bytes.ts | 16 ++++++++ 10 files changed, 118 insertions(+), 12 deletions(-) create mode 100644 src/main/daemon/headless-emulator-kitty-flags.test.ts create mode 100644 src/main/runtime/runtime-terminal-writer.test.ts create mode 100644 src/shared/terminal-interrupt-bytes.test.ts create mode 100644 src/shared/terminal-interrupt-bytes.ts 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..a5f880a1cdfb --- /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()).toBeGreaterThan(0) + }) +}) 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/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-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-runtime-id.ts b/src/main/runtime/orca-runtime-runtime-id.ts index bc31a614cb94..69ff545ac038 100644 --- a/src/main/runtime/orca-runtime-runtime-id.ts +++ b/src/main/runtime/orca-runtime-runtime-id.ts @@ -335,7 +335,8 @@ export class OrcaRuntimeWithRuntimeId { protected readonly terminalWriter = new RuntimeTerminalWriter( (ptyId, data, inputKind) => this.ptyController?.write(ptyId, data, inputKind) ?? false, (ptyId) => this.getPtyWriteHostPlatform(ptyId), - (ptyId) => this.getPtyAgent(ptyId) + (ptyId) => this.getPtyAgent(ptyId), + (ptyId) => this.readPtyKittyKeyboardFlags(ptyId) ) // Why one source: every tui-idle site must read the same evidence, or they rank one pane differently. 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..4ddf71a5f227 --- /dev/null +++ b/src/main/runtime/runtime-terminal-writer.test.ts @@ -0,0 +1,37 @@ +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 kitty Ctrl+C sequence when that protocol is active', async () => { + const writes: string[] = [] + const writer = new RuntimeTerminalWriter( + (_ptyId, data) => { + writes.push(data) + return true + }, + () => 'darwin', + () => null, + () => 31 + ) + + 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..05564d85f9ef 100644 --- a/src/main/runtime/runtime-terminal-writer.ts +++ b/src/main/runtime/runtime-terminal-writer.ts @@ -1,6 +1,7 @@ import { resolveAgentPromptSubmitDelayForAgent } from '../../shared/agent-prompt-injection' import type { TerminalAgent } from '../../shared/terminal-agent' import { iterateTerminalInputChunks } from '../../shared/terminal-input' +import { terminalInterruptBytes } from '../../shared/terminal-interrupt-bytes' import type { TerminalInputKind } from '../../shared/terminal-input-kind' export type RuntimeTerminalWriteOptions = { @@ -17,7 +18,8 @@ export class RuntimeTerminalWriter { private readonly write: (ptyId: string, data: string, inputKind: TerminalInputKind) => boolean, private readonly getWriteHostPlatform: (ptyId: string) => NodeJS.Platform = () => process.platform, - private readonly getAgent: (ptyId: string) => TerminalAgent | null = () => null + private readonly getAgent: (ptyId: string) => TerminalAgent | null = () => null, + private readonly getKittyKeyboardFlags: (ptyId: string) => number = () => 0 ) {} async writeAction( @@ -34,7 +36,10 @@ export class RuntimeTerminalWriter { await this.writeChunks(ptyId, text, options) } if (hasSuffix) { - const suffix = (action.enter ? '\r' : '') + (action.interrupt ? '\x03' : '') + const interrupt = action.interrupt + ? terminalInterruptBytes(this.getKittyKeyboardFlags(ptyId)) + : '' + const suffix = (action.enter ? '\r' : '') + interrupt 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..dd3c1cc7c1dc 100644 --- a/src/main/runtime/terminal-send-payload.ts +++ b/src/main/runtime/terminal-send-payload.ts @@ -2,12 +2,16 @@ 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 { +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 +20,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/shared/terminal-interrupt-bytes.test.ts b/src/shared/terminal-interrupt-bytes.test.ts new file mode 100644 index 000000000000..b3db5bb7143d --- /dev/null +++ b/src/shared/terminal-interrupt-bytes.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest' +import { + TERMINAL_INTERRUPT_ETX, + TERMINAL_INTERRUPT_KITTY_CTRL_C, + 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 for an active kitty keyboard protocol', () => { + expect(terminalInterruptBytes(1)).toBe(TERMINAL_INTERRUPT_KITTY_CTRL_C) + expect(terminalInterruptBytes(31)).toBe('\x1b[99;5u') + }) +}) diff --git a/src/shared/terminal-interrupt-bytes.ts b/src/shared/terminal-interrupt-bytes.ts new file mode 100644 index 000000000000..b29b7e3d5f86 --- /dev/null +++ b/src/shared/terminal-interrupt-bytes.ts @@ -0,0 +1,16 @@ +/** 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' + +/** + * Interrupt bytes for one PTY write. + * `kittyKeyboardFlags === 0` keeps ETX so a plain shell is unchanged. + */ +export function terminalInterruptBytes(kittyKeyboardFlags: number): string { + return kittyKeyboardFlags > 0 ? TERMINAL_INTERRUPT_KITTY_CTRL_C : TERMINAL_INTERRUPT_ETX +} From 8fac3b3a54343dfacd1aec77472470b3178b2910 Mon Sep 17 00:00:00 2001 From: Wooseong Kim Date: Thu, 1 Oct 2026 21:29:45 +0900 Subject: [PATCH 2/8] fix(terminal): keep kitty interrupt mode across a replaced headless model A namespace change rebuilt the headless terminal from a provider snapshot and dropped the kitty keyboard flags, so a later interrupt sent ETX. The replacement now reapplies those flags, and the writer sends the interrupt bytes already chosen for that send. --- .../daemon/headless-emulator-kitty-flags.test.ts | 2 +- src/main/runtime/headless-seed-ownership.test.ts | 9 +++++++++ ...-runtime-create-pty-headless-terminal-state.ts | 8 ++++++++ src/main/runtime/orca-runtime-runtime-id.ts | 3 +-- src/main/runtime/runtime-terminal-writer.test.ts | 15 +++++---------- src/main/runtime/runtime-terminal-writer.ts | 11 ++++------- 6 files changed, 28 insertions(+), 20 deletions(-) diff --git a/src/main/daemon/headless-emulator-kitty-flags.test.ts b/src/main/daemon/headless-emulator-kitty-flags.test.ts index a5f880a1cdfb..1a141e03d466 100644 --- a/src/main/daemon/headless-emulator-kitty-flags.test.ts +++ b/src/main/daemon/headless-emulator-kitty-flags.test.ts @@ -8,6 +8,6 @@ describe('HeadlessEmulator kitty keyboard flags', () => { expect(emulator.kittyKeyboardFlags()).toBe(0) await emulator.applyKittyKeyboardFlags(1) - expect(emulator.kittyKeyboardFlags()).toBeGreaterThan(0) + expect(emulator.kittyKeyboardFlags()).toBe(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-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-runtime-id.ts b/src/main/runtime/orca-runtime-runtime-id.ts index 69ff545ac038..bc31a614cb94 100644 --- a/src/main/runtime/orca-runtime-runtime-id.ts +++ b/src/main/runtime/orca-runtime-runtime-id.ts @@ -335,8 +335,7 @@ export class OrcaRuntimeWithRuntimeId { protected readonly terminalWriter = new RuntimeTerminalWriter( (ptyId, data, inputKind) => this.ptyController?.write(ptyId, data, inputKind) ?? false, (ptyId) => this.getPtyWriteHostPlatform(ptyId), - (ptyId) => this.getPtyAgent(ptyId), - (ptyId) => this.readPtyKittyKeyboardFlags(ptyId) + (ptyId) => this.getPtyAgent(ptyId) ) // Why one source: every tui-idle site must read the same evidence, or they rank one pane differently. diff --git a/src/main/runtime/runtime-terminal-writer.test.ts b/src/main/runtime/runtime-terminal-writer.test.ts index 4ddf71a5f227..99d234ef3caa 100644 --- a/src/main/runtime/runtime-terminal-writer.test.ts +++ b/src/main/runtime/runtime-terminal-writer.test.ts @@ -16,17 +16,12 @@ describe('RuntimeTerminalWriter interrupt bytes', () => { expect(writes).toEqual(['\x03']) }) - it('writes the kitty Ctrl+C sequence when that protocol is active', async () => { + 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 - }, - () => 'darwin', - () => null, - () => 31 - ) + 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' diff --git a/src/main/runtime/runtime-terminal-writer.ts b/src/main/runtime/runtime-terminal-writer.ts index 05564d85f9ef..129e13df6f13 100644 --- a/src/main/runtime/runtime-terminal-writer.ts +++ b/src/main/runtime/runtime-terminal-writer.ts @@ -1,7 +1,6 @@ import { resolveAgentPromptSubmitDelayForAgent } from '../../shared/agent-prompt-injection' import type { TerminalAgent } from '../../shared/terminal-agent' import { iterateTerminalInputChunks } from '../../shared/terminal-input' -import { terminalInterruptBytes } from '../../shared/terminal-interrupt-bytes' import type { TerminalInputKind } from '../../shared/terminal-input-kind' export type RuntimeTerminalWriteOptions = { @@ -18,8 +17,7 @@ export class RuntimeTerminalWriter { private readonly write: (ptyId: string, data: string, inputKind: TerminalInputKind) => boolean, private readonly getWriteHostPlatform: (ptyId: string) => NodeJS.Platform = () => process.platform, - private readonly getAgent: (ptyId: string) => TerminalAgent | null = () => null, - private readonly getKittyKeyboardFlags: (ptyId: string) => number = () => 0 + private readonly getAgent: (ptyId: string) => TerminalAgent | null = () => null ) {} async writeAction( @@ -36,10 +34,9 @@ export class RuntimeTerminalWriter { await this.writeChunks(ptyId, text, options) } if (hasSuffix) { - const interrupt = action.interrupt - ? terminalInterruptBytes(this.getKittyKeyboardFlags(ptyId)) - : '' - const suffix = (action.enter ? '\r' : '') + interrupt + // 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. From 73db700f5ccd224c6e3b784385b6ffa5308782ba Mon Sep 17 00:00:00 2001 From: Wooseong Kim Date: Thu, 1 Oct 2026 21:52:15 +0900 Subject: [PATCH 3/8] fix(terminal): send ETX when kitty mode only reports alternate keys Flag 4 does not change legacy Ctrl+C encoding, so an interrupt in that mode was sending CSI 99;5u instead of ETX. --- src/shared/terminal-interrupt-bytes.test.ts | 8 +++++++- src/shared/terminal-interrupt-bytes.ts | 13 +++++++++++-- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/src/shared/terminal-interrupt-bytes.test.ts b/src/shared/terminal-interrupt-bytes.test.ts index b3db5bb7143d..961d6a47d021 100644 --- a/src/shared/terminal-interrupt-bytes.test.ts +++ b/src/shared/terminal-interrupt-bytes.test.ts @@ -10,8 +10,14 @@ describe('terminalInterruptBytes', () => { expect(terminalInterruptBytes(0)).toBe(TERMINAL_INTERRUPT_ETX) }) - it('encodes Ctrl+C for an active kitty keyboard protocol', () => { + 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) + }) }) diff --git a/src/shared/terminal-interrupt-bytes.ts b/src/shared/terminal-interrupt-bytes.ts index b29b7e3d5f86..b14f9d998263 100644 --- a/src/shared/terminal-interrupt-bytes.ts +++ b/src/shared/terminal-interrupt-bytes.ts @@ -7,10 +7,19 @@ export const TERMINAL_INTERRUPT_ETX = '\x03' */ export const TERMINAL_INTERRUPT_KITTY_CTRL_C = '\x1b[99;5u' +/** + * 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. - * `kittyKeyboardFlags === 0` keeps ETX so a plain shell is unchanged. + * A plain shell, and a TUI that only asked for alternate keys, still get ETX. */ export function terminalInterruptBytes(kittyKeyboardFlags: number): string { - return kittyKeyboardFlags > 0 ? TERMINAL_INTERRUPT_KITTY_CTRL_C : TERMINAL_INTERRUPT_ETX + return (kittyKeyboardFlags & KITTY_CTRL_C_ESCAPE_FLAGS) !== 0 + ? TERMINAL_INTERRUPT_KITTY_CTRL_C + : TERMINAL_INTERRUPT_ETX } From 3c1a0f39a78708468ac497d07f1cc10741aff547 Mon Sep 17 00:00:00 2001 From: Wooseong Kim Date: Thu, 1 Oct 2026 22:19:08 +0900 Subject: [PATCH 4/8] fix(terminal): reapply kitty flags when a renderer buffer seeds the model A mounted renderer snapshot omits kitty pushes. The provider replacement path already restores the flags carried beside the payload. Renderer hydration now does the same, so an interrupt still encodes Ctrl+C. --- .../runtime/headless-hydration-ownership.test.ts | 16 ++++++++++++++++ ...ntime-maybe-hydrate-headless-from-renderer.ts | 10 ++++++++++ 2 files changed, 26 insertions(+) diff --git a/src/main/runtime/headless-hydration-ownership.test.ts b/src/main/runtime/headless-hydration-ownership.test.ts index 675a57bf70b1..c89e198ba645 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,18 @@ 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('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/orca-runtime-maybe-hydrate-headless-from-renderer.ts b/src/main/runtime/orca-runtime-maybe-hydrate-headless-from-renderer.ts index 40706f4730f1..908cc20d7a01 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 @@ -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) From c3b9dfa9a96cb125ff6b8e447c6fe404064abca1 Mon Sep 17 00:00:00 2001 From: Wooseong Kim Date: Thu, 1 Oct 2026 22:37:51 +0900 Subject: [PATCH 5/8] fix(terminal): apply kitty flags from an empty renderer buffer A blank serialized buffer can still carry the keyboard flags beside the payload. Hydration used to return before applying them, so a fresh emulator stayed at 0 and an interrupt sent ETX. --- .../runtime/headless-hydration-ownership.test.ts | 13 +++++++++++++ ...untime-maybe-hydrate-headless-from-renderer.ts | 15 ++++++++++----- 2 files changed, 23 insertions(+), 5 deletions(-) diff --git a/src/main/runtime/headless-hydration-ownership.test.ts b/src/main/runtime/headless-hydration-ownership.test.ts index c89e198ba645..3eb5ec54996f 100644 --- a/src/main/runtime/headless-hydration-ownership.test.ts +++ b/src/main/runtime/headless-hydration-ownership.test.ts @@ -42,6 +42,19 @@ it('reapplies kitty keyboard flags proven by the renderer buffer', async () => { ) }) +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/orca-runtime-maybe-hydrate-headless-from-renderer.ts b/src/main/runtime/orca-runtime-maybe-hydrate-headless-from-renderer.ts index 908cc20d7a01..2abfc0112bbf 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 @@ -63,11 +63,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) From 2bdf1980987855b84edbbf37374745309224704f Mon Sep 17 00:00:00 2001 From: Wooseong Kim Date: Sun, 4 Oct 2026 20:55:42 +0900 Subject: [PATCH 6/8] fix(terminal): route renderer Ctrl+C through kitty-aware encoder --- .../orca-runtime-maybe-hydrate-headless-from-renderer.ts | 7 +------ src/main/runtime/terminal-send-payload.ts | 1 + .../components/terminal-pane/terminal-pane-pane-input.ts | 8 ++++++-- .../terminal-pane/xterm-bypass-policy-interrupt.test.ts | 9 ++++++--- .../src/components/terminal-pane/xterm-bypass-policy.ts | 7 ++++++- 5 files changed, 20 insertions(+), 12 deletions(-) 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 2abfc0112bbf..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 diff --git a/src/main/runtime/terminal-send-payload.ts b/src/main/runtime/terminal-send-payload.ts index dd3c1cc7c1dc..8b2605d2367a 100644 --- a/src/main/runtime/terminal-send-payload.ts +++ b/src/main/runtime/terminal-send-payload.ts @@ -4,6 +4,7 @@ import { } from '../../shared/terminal-input' import { terminalInterruptBytes } from '../../shared/terminal-interrupt-bytes' +/** Builds terminal.send bytes using the PTY's current kitty flags for Ctrl+C. */ export function buildTerminalSendPayload( action: { text?: string 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..cb17926c1726 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', From 0749f541b5450859a191f4e798171abcfb249fed Mon Sep 17 00:00:00 2001 From: Wooseong Kim Date: Sun, 4 Oct 2026 21:48:24 +0900 Subject: [PATCH 7/8] fix(terminal): track kitty Ctrl+C input intent --- ...pty-connection-interrupt-inference.test.ts | 56 +++++++++++++++++++ .../pty-connection/interrupt-input-intent.ts | 8 ++- .../terminal-pane/xterm-bypass-policy.ts | 4 +- src/shared/terminal-interrupt-bytes.test.ts | 14 +++++ src/shared/terminal-interrupt-bytes.ts | 5 ++ 5 files changed, 82 insertions(+), 5 deletions(-) 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/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/xterm-bypass-policy.ts b/src/renderer/src/components/terminal-pane/xterm-bypass-policy.ts index cb17926c1726..65a48eb663e7 100644 --- a/src/renderer/src/components/terminal-pane/xterm-bypass-policy.ts +++ b/src/renderer/src/components/terminal-pane/xterm-bypass-policy.ts @@ -305,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 index 961d6a47d021..ea03e8c3fd9d 100644 --- a/src/shared/terminal-interrupt-bytes.test.ts +++ b/src/shared/terminal-interrupt-bytes.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest' import { TERMINAL_INTERRUPT_ETX, TERMINAL_INTERRUPT_KITTY_CTRL_C, + isTerminalInterruptInput, terminalInterruptBytes } from './terminal-interrupt-bytes' @@ -21,3 +22,16 @@ describe('terminalInterruptBytes', () => { 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) + }) +}) diff --git a/src/shared/terminal-interrupt-bytes.ts b/src/shared/terminal-interrupt-bytes.ts index b14f9d998263..814508debfd3 100644 --- a/src/shared/terminal-interrupt-bytes.ts +++ b/src/shared/terminal-interrupt-bytes.ts @@ -7,6 +7,11 @@ export const TERMINAL_INTERRUPT_ETX = '\x03' */ 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 +} + /** * 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, From 17d74cab9d26ca914e49f351d27db6e5b902ef62 Mon Sep 17 00:00:00 2001 From: Wooseong Kim Date: Mon, 5 Oct 2026 00:03:54 +0900 Subject: [PATCH 8/8] fix(terminal): recognize Kitty Ctrl+C in downstream input --- ...connection-shell-command-inference.test.ts | 72 +++++++ .../pty-connection/shell-command-inference.ts | 181 +++++++++++------- .../terminal-input-quarantine.test.ts | 19 +- .../terminal-input-quarantine.ts | 7 +- src/shared/terminal-interrupt-bytes.test.ts | 13 ++ src/shared/terminal-interrupt-bytes.ts | 5 + 6 files changed, 229 insertions(+), 68 deletions(-) create mode 100644 src/renderer/src/components/terminal-pane/pty-connection-shell-command-inference.test.ts 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/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/shared/terminal-interrupt-bytes.test.ts b/src/shared/terminal-interrupt-bytes.test.ts index ea03e8c3fd9d..eb367e93045f 100644 --- a/src/shared/terminal-interrupt-bytes.test.ts +++ b/src/shared/terminal-interrupt-bytes.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest' import { TERMINAL_INTERRUPT_ETX, TERMINAL_INTERRUPT_KITTY_CTRL_C, + containsTerminalInterruptInput, isTerminalInterruptInput, terminalInterruptBytes } from './terminal-interrupt-bytes' @@ -35,3 +36,15 @@ describe('isTerminalInterruptInput', () => { 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 index 814508debfd3..ca799db49217 100644 --- a/src/shared/terminal-interrupt-bytes.ts +++ b/src/shared/terminal-interrupt-bytes.ts @@ -12,6 +12,11 @@ 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,