diff --git a/.changeset/quiet-terminal-input.md b/.changeset/quiet-terminal-input.md new file mode 100644 index 0000000000..39672b69d9 --- /dev/null +++ b/.changeset/quiet-terminal-input.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Prevent terminal status responses from interrupting active turns. diff --git a/packages/pi-tui/src/tui.ts b/packages/pi-tui/src/tui.ts index e4556a8f47..9a67673a23 100644 --- a/packages/pi-tui/src/tui.ts +++ b/packages/pi-tui/src/tui.ts @@ -26,6 +26,9 @@ import { } from "./utils.ts"; const KITTY_SEQUENCE_PREFIX = "\x1b_G"; +const TERMINAL_STATUS_RESPONSE_PATTERN = + /^(?:\x1b\[\?[\d;]*c|\x1b(?:P.*\x1b\\|_.*\x1b\\))$/s; +const OSC_STATUS_RESPONSE_PATTERN = /^\x1b\].*(?:\x07|\x1b\\)$/s; /** Shared empty id list for non-image lines in the per-line image-id cache. */ const EMPTY_IMAGE_IDS: readonly number[] = []; @@ -788,6 +791,11 @@ export class TUI extends Container { if (this.consumeTerminalColorSchemeReport(data)) { return; } + // Terminal query replies are control traffic, not keyboard input. Consume + // known replies before global listeners can interpret them as user intent. + if (this.consumeCellSizeResponse(data) || TERMINAL_STATUS_RESPONSE_PATTERN.test(data)) { + return; + } if (this.inputListeners.size > 0) { let current = data; @@ -805,9 +813,9 @@ export class TUI extends Container { } data = current; } - - // Consume terminal cell size responses without blocking unrelated input. - if (this.consumeCellSizeResponse(data)) { + // OSC reports may be consumed by application listeners (for example, + // live theme tracking), but must never fall through to focused input. + if (OSC_STATUS_RESPONSE_PATTERN.test(data)) { return; } diff --git a/packages/pi-tui/test/terminal-colors.test.ts b/packages/pi-tui/test/terminal-colors.test.ts index d777e06171..19f234ee3d 100644 --- a/packages/pi-tui/test/terminal-colors.test.ts +++ b/packages/pi-tui/test/terminal-colors.test.ts @@ -121,6 +121,94 @@ describe("parseTerminalColorSchemeReport", () => { }); describe("TUI.queryTerminalBackgroundColor", () => { + it("filters non-OSC terminal status responses before application dispatch", () => { + const terminal = new TestTerminal(); + const tui = new TUI(terminal); + const component = new InputRecorder(); + const listenerInputs: string[] = []; + tui.addChild(component); + tui.setFocus(component); + tui.addInputListener((data) => { + listenerInputs.push(data); + return undefined; + }); + tui.start(); + try { + terminal.sendInput("\x1b[6;18;8t"); + terminal.sendInput("\x1b[?1;2;4c"); + terminal.sendInput("\x1bP>|XTerm(390)\x1b\\"); + terminal.sendInput("\x1b_Gi=1;OK\x1b\\"); + + assert.deepStrictEqual(listenerInputs, []); + assert.deepStrictEqual(component.inputs, []); + } finally { + tui.stop(); + } + }); + + it("lets protocol listeners consume OSC status before focused input", () => { + const terminal = new TestTerminal(); + const tui = new TUI(terminal); + const component = new InputRecorder(); + const oscStatusResponse = "\x1b]11;rgb:1515/1919/1e1e\x1b\\"; + let observedStatus: string | undefined; + tui.addChild(component); + tui.setFocus(component); + tui.addInputListener((data) => { + if (data !== oscStatusResponse) return undefined; + observedStatus = data; + return { consume: true }; + }); + tui.start(); + try { + terminal.sendInput(oscStatusResponse); + + assert.equal(observedStatus, oscStatusResponse); + assert.deepStrictEqual(component.inputs, []); + } finally { + tui.stop(); + } + }); + + it("filters unhandled OSC status before focused input", () => { + const terminal = new TestTerminal(); + const tui = new TUI(terminal); + const component = new InputRecorder(); + tui.addChild(component); + tui.setFocus(component); + tui.start(); + try { + terminal.sendInput("\x1b]11;rgb:1515/1919/1e1e\x1b\\"); + + assert.deepStrictEqual(component.inputs, []); + } finally { + tui.stop(); + } + }); + + it("preserves keyboard and mouse input after filtering terminal status responses", () => { + const terminal = new TestTerminal(); + const tui = new TUI(terminal); + const component = new InputRecorder(); + const listenerInputs: string[] = []; + tui.addChild(component); + tui.setFocus(component); + tui.addInputListener((data) => { + listenerInputs.push(data); + return undefined; + }); + tui.start(); + try { + const inputSequences = ["a", "\x1b[<0;10;5M", "\x1b[97u"]; + for (const input of inputSequences) terminal.sendInput(input); + + assert.deepStrictEqual(listenerInputs, inputSequences); + assert.deepStrictEqual(component.inputs, inputSequences); + } finally { + tui.stop(); + } + }); + it("writes OSC 11 query and resolves with the parsed RGB reply", async () => { const terminal = new TestTerminal(); const tui = new TUI(terminal);