From c5b3f992db2e57679d39018eebdb2717bc53c56e Mon Sep 17 00:00:00 2001 From: nrslib <38722970+nrslib@users.noreply.github.com> Date: Sun, 8 Feb 2026 07:14:55 +0900 Subject: [PATCH] =?UTF-8?q?=E5=AF=BE=E8=A9=B1=E5=85=A5=E5=8A=9B=E6=99=82Ct?= =?UTF-8?q?rl+C=E3=81=8C=E8=81=9E=E3=81=8F=E3=82=88=E3=81=86=E3=81=AB?= =?UTF-8?q?=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/__tests__/interactive.test.ts | 48 +++++++++++++++++++++++++ src/features/interactive/interactive.ts | 45 +++++++++++++++++++++++ 2 files changed, 93 insertions(+) diff --git a/src/__tests__/interactive.test.ts b/src/__tests__/interactive.test.ts index 0c87b20..61a8593 100644 --- a/src/__tests__/interactive.test.ts +++ b/src/__tests__/interactive.test.ts @@ -548,6 +548,18 @@ describe('interactiveMode', () => { expect(result.action).toBe('cancel'); }); + it('should handle Ctrl+C (Kitty CSI-u) to cancel input', async () => { + // Given: Ctrl+C reported as Kitty keyboard protocol sequence + setupRawStdin(['\x1B[99;5u']); + setupMockProvider([]); + + // When + const result = await interactiveMode('/project'); + + // Then: should cancel + expect(result.action).toBe('cancel'); + }); + it('should handle Ctrl+D to cancel input', async () => { // Given: Ctrl+D during input setupRawStdin(['\x04']); @@ -560,6 +572,42 @@ describe('interactiveMode', () => { expect(result.action).toBe('cancel'); }); + it('should handle Ctrl+D (Kitty CSI-u) to cancel input', async () => { + // Given: Ctrl+D reported as Kitty keyboard protocol sequence + setupRawStdin(['\x1B[100;5u']); + setupMockProvider([]); + + // When + const result = await interactiveMode('/project'); + + // Then: should cancel + expect(result.action).toBe('cancel'); + }); + + it('should handle Ctrl+C (xterm modifyOtherKeys) to cancel input', async () => { + // Given: Ctrl+C reported as xterm modifyOtherKeys sequence + setupRawStdin(['\x1B[27;5;99~']); + setupMockProvider([]); + + // When + const result = await interactiveMode('/project'); + + // Then: should cancel + expect(result.action).toBe('cancel'); + }); + + it('should handle Ctrl+D (xterm modifyOtherKeys) to cancel input', async () => { + // Given: Ctrl+D reported as xterm modifyOtherKeys sequence + setupRawStdin(['\x1B[27;5;100~']); + setupMockProvider([]); + + // When + const result = await interactiveMode('/project'); + + // Then: should cancel + expect(result.action).toBe('cancel'); + }); + it('should ignore arrow keys in normal mode', async () => { // Given: text with arrow keys interspersed (arrows are ignored) setupRawStdin([ diff --git a/src/features/interactive/interactive.ts b/src/features/interactive/interactive.ts index a870b87..fb2347a 100644 --- a/src/features/interactive/interactive.ts +++ b/src/features/interactive/interactive.ts @@ -190,6 +190,45 @@ const ESC_SHIFT_ENTER = '[13;2u'; type InputState = 'normal' | 'paste'; +/** + * Decode Kitty CSI-u key sequence into a control character. + * Example: "[99;5u" (Ctrl+C) -> "\x03" + */ +function decodeCtrlKey(rest: string): { ch: string; consumed: number } | null { + // Kitty CSI-u: [codepoint;modifiersu + const kittyMatch = rest.match(/^\[(\d+);(\d+)u/); + if (kittyMatch) { + const codepoint = Number.parseInt(kittyMatch[1]!, 10); + const modifiers = Number.parseInt(kittyMatch[2]!, 10); + // Kitty modifiers are 1-based; Ctrl bit is 4 in 0-based flags. + const ctrlPressed = (((modifiers - 1) & 4) !== 0); + if (!ctrlPressed) return null; + + const key = String.fromCodePoint(codepoint); + if (!/^[A-Za-z]$/.test(key)) return null; + + const upper = key.toUpperCase(); + const controlCode = upper.charCodeAt(0) & 0x1f; + return { ch: String.fromCharCode(controlCode), consumed: kittyMatch[0].length }; + } + + // xterm modifyOtherKeys: [27;modifiers;codepoint~ + const xtermMatch = rest.match(/^\[27;(\d+);(\d+)~/); + if (!xtermMatch) return null; + + const modifiers = Number.parseInt(xtermMatch[1]!, 10); + const codepoint = Number.parseInt(xtermMatch[2]!, 10); + const ctrlPressed = (((modifiers - 1) & 4) !== 0); + if (!ctrlPressed) return null; + + const key = String.fromCodePoint(codepoint); + if (!/^[A-Za-z]$/.test(key)) return null; + + const upper = key.toUpperCase(); + const controlCode = upper.charCodeAt(0) & 0x1f; + return { ch: String.fromCharCode(controlCode), consumed: xtermMatch[0].length }; +} + /** * Parse raw stdin data and process each character/sequence. * @@ -229,6 +268,12 @@ function parseInputData( i += 1 + ESC_SHIFT_ENTER.length; continue; } + const ctrlKey = decodeCtrlKey(rest); + if (ctrlKey) { + callbacks.onChar(ctrlKey.ch); + i += 1 + ctrlKey.consumed; + continue; + } // Arrow keys and other CSI sequences: skip \x1B[ + letter/params if (rest.startsWith('[')) { const csiMatch = rest.match(/^\[[0-9;]*[A-Za-z~]/);