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
This commit is contained in:
秦奇 2026-07-29 14:59:27 +08:00
parent 7836c919bf
commit bb86d57ff2
4 changed files with 58 additions and 7 deletions

View file

@ -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 }],

View file

@ -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', () => {

View file

@ -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) {

View file

@ -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,