From bb86d57ff239f5a1909a6fa8e12b16af7db80c0b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A7=A6=E5=A5=87?= Date: Wed, 29 Jul 2026 14:59:27 +0800 Subject: [PATCH] fix(cli): reserve Ctrl+Shift+C for terminal copy, not quit/clear Ctrl+Shift+C is the standard terminal copy shortcut. The QUIT and CLEAR_INPUT bindings matched it because they only constrained ctrl and left shift unspecified, which the matcher ignores. In Kitty- protocol terminals the escape-hatch check also caught Ctrl+Shift+C (ESC[99;6u) since it tested ctrl+name without excluding shift. Add shift: false to both bindings and !key.shift to the escape-hatch condition so Ctrl+Shift+C passes through to the terminal for copy. Plain Ctrl+C (shift undefined or false) is unaffected. Fixes #8006 --- packages/cli/src/config/keyBindings.ts | 4 +- .../src/ui/contexts/KeypressContext.test.tsx | 40 +++++++++++++++++++ .../cli/src/ui/contexts/KeypressContext.tsx | 2 +- packages/cli/src/ui/keyMatchers.test.ts | 19 +++++++-- 4 files changed, 58 insertions(+), 7 deletions(-) diff --git a/packages/cli/src/config/keyBindings.ts b/packages/cli/src/config/keyBindings.ts index 833fa02e74..11bee10168 100644 --- a/packages/cli/src/config/keyBindings.ts +++ b/packages/cli/src/config/keyBindings.ts @@ -136,7 +136,7 @@ export const defaultKeyBindings: KeyBindingConfig = { // Text deletion [Command.KILL_LINE_RIGHT]: [{ key: 'k', ctrl: true }], [Command.KILL_LINE_LEFT]: [{ key: 'u', ctrl: true }], - [Command.CLEAR_INPUT]: [{ key: 'c', ctrl: true }], + [Command.CLEAR_INPUT]: [{ key: 'c', ctrl: true, shift: false }], // Added command (meta/alt/option) for mac compatibility [Command.DELETE_WORD_BACKWARD]: [ { key: 'backspace', ctrl: true }, @@ -243,7 +243,7 @@ export const defaultKeyBindings: KeyBindingConfig = { // App level bindings [Command.TOGGLE_TOOL_DESCRIPTIONS]: [{ key: 't', ctrl: true }], [Command.TOGGLE_IDE_CONTEXT_DETAIL]: [{ key: 'g', ctrl: true }], - [Command.QUIT]: [{ key: 'c', ctrl: true }], + [Command.QUIT]: [{ key: 'c', ctrl: true, shift: false }], [Command.EXIT]: [{ key: 'd', ctrl: true }], [Command.SHOW_MORE_LINES]: [{ key: 's', ctrl: true }], [Command.RETRY_LAST]: [{ key: 'y', ctrl: true }], diff --git a/packages/cli/src/ui/contexts/KeypressContext.test.tsx b/packages/cli/src/ui/contexts/KeypressContext.test.tsx index d78c4b50b5..0c03a04188 100644 --- a/packages/cli/src/ui/contexts/KeypressContext.test.tsx +++ b/packages/cli/src/ui/contexts/KeypressContext.test.tsx @@ -830,6 +830,46 @@ describe('KeypressContext - Kitty Protocol', () => { }), ); }); + + it('should not treat Kitty Ctrl+Shift+C as the Ctrl+C escape hatch', async () => { + const keyHandler = vi.fn(); + const { result } = renderHook(() => useKeypressContext(), { wrapper }); + act(() => result.current.subscribe(keyHandler)); + + // Modifier 6 is Ctrl+Shift + act(() => { + stdin.sendKittySequence(`\x1b[99;6u`); + }); + + expect(keyHandler).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'c', + ctrl: true, + shift: true, + kittyProtocol: true, + }), + ); + }); + + it('should still treat Kitty Ctrl+C as the escape hatch', async () => { + const keyHandler = vi.fn(); + const { result } = renderHook(() => useKeypressContext(), { wrapper }); + act(() => result.current.subscribe(keyHandler)); + + // Modifier 5 is Ctrl + act(() => { + stdin.sendKittySequence(`\x1b[99;5u`); + }); + + expect(keyHandler).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'c', + ctrl: true, + shift: false, + kittyProtocol: true, + }), + ); + }); }); describe('paste mode', () => { diff --git a/packages/cli/src/ui/contexts/KeypressContext.tsx b/packages/cli/src/ui/contexts/KeypressContext.tsx index e747ae0a65..ba819a9573 100644 --- a/packages/cli/src/ui/contexts/KeypressContext.tsx +++ b/packages/cli/src/ui/contexts/KeypressContext.tsx @@ -843,7 +843,7 @@ export function KeypressProvider({ // including Ctrl+C itself — and the user has no way to recover // without killing the terminal. const isCtrlCKey = - (key.ctrl && key.name === 'c') || + (key.ctrl && key.name === 'c' && !key.shift) || key.sequence === `${ESC}${KITTY_CTRL_C}`; if (isCtrlCKey) { if (isPaste || pasteChunks.length > 0) { diff --git a/packages/cli/src/ui/keyMatchers.test.ts b/packages/cli/src/ui/keyMatchers.test.ts index 6932dca55b..fc4b75daae 100644 --- a/packages/cli/src/ui/keyMatchers.test.ts +++ b/packages/cli/src/ui/keyMatchers.test.ts @@ -29,7 +29,8 @@ describe('keyMatchers', () => { [Command.END]: (key: Key) => key.ctrl && key.name === 'e', [Command.KILL_LINE_RIGHT]: (key: Key) => key.ctrl && key.name === 'k', [Command.KILL_LINE_LEFT]: (key: Key) => key.ctrl && key.name === 'u', - [Command.CLEAR_INPUT]: (key: Key) => key.ctrl && key.name === 'c', + [Command.CLEAR_INPUT]: (key: Key) => + key.ctrl && key.name === 'c' && !key.shift, [Command.DELETE_WORD_BACKWARD]: (key: Key) => ((key.ctrl || key.meta) && key.name === 'backspace') || key.sequence === '\x1f', @@ -66,7 +67,7 @@ describe('keyMatchers', () => { key.ctrl && key.name === 't', [Command.TOGGLE_IDE_CONTEXT_DETAIL]: (key: Key) => key.ctrl && key.name === 'g', - [Command.QUIT]: (key: Key) => key.ctrl && key.name === 'c', + [Command.QUIT]: (key: Key) => key.ctrl && key.name === 'c' && !key.shift, [Command.EXIT]: (key: Key) => key.ctrl && key.name === 'd', [Command.SHOW_MORE_LINES]: (key: Key) => key.ctrl && key.name === 's', [Command.RETRY_LAST]: (key: Key) => key.ctrl && key.name === 'y', @@ -150,7 +151,12 @@ describe('keyMatchers', () => { { command: Command.CLEAR_INPUT, positive: [createKey('c', { ctrl: true })], - negative: [createKey('c'), createKey('k', { ctrl: true })], + negative: [ + createKey('c'), + createKey('k', { ctrl: true }), + // Ctrl+Shift+C is the terminal copy shortcut — must not clear input + createKey('c', { ctrl: true, shift: true }), + ], }, { command: Command.DELETE_WORD_BACKWARD, @@ -321,7 +327,12 @@ describe('keyMatchers', () => { { command: Command.QUIT, positive: [createKey('c', { ctrl: true })], - negative: [createKey('c'), createKey('d', { ctrl: true })], + negative: [ + createKey('c'), + createKey('d', { ctrl: true }), + // Ctrl+Shift+C is the terminal copy shortcut — must not quit + createKey('c', { ctrl: true, shift: true }), + ], }, { command: Command.EXIT,