Support command/ctrl/alt backspace correctly (#17175)

This commit is contained in:
Tommaso Sciortino
2026-01-21 10:13:26 -08:00
committed by GitHub
parent e894871afc
commit f190b87223
27 changed files with 487 additions and 298 deletions

View File

@@ -19,14 +19,14 @@ available combinations.
| Action | Keys |
| ------------------------------------------- | ------------------------------------------------------------ |
| Move the cursor to the start of the line. | `Ctrl + A`<br />`Home (no Ctrl, no Shift)` |
| Move the cursor to the end of the line. | `Ctrl + E`<br />`End (no Ctrl, no Shift)` |
| Move the cursor up one line. | `Up Arrow (no Ctrl, no Cmd)` |
| Move the cursor down one line. | `Down Arrow (no Ctrl, no Cmd)` |
| Move the cursor one character to the left. | `Left Arrow (no Ctrl, no Cmd)`<br />`Ctrl + B` |
| Move the cursor one character to the right. | `Right Arrow (no Ctrl, no Cmd)`<br />`Ctrl + F` |
| Move the cursor one word to the left. | `Ctrl + Left Arrow`<br />`Cmd + Left Arrow`<br />`Cmd + B` |
| Move the cursor one word to the right. | `Ctrl + Right Arrow`<br />`Cmd + Right Arrow`<br />`Cmd + F` |
| Move the cursor to the start of the line. | `Ctrl + A`<br />`Home (no Shift, Ctrl)` |
| Move the cursor to the end of the line. | `Ctrl + E`<br />`End (no Shift, Ctrl)` |
| Move the cursor up one line. | `Up Arrow (no Shift, Alt, Ctrl, Cmd)` |
| Move the cursor down one line. | `Down Arrow (no Shift, Alt, Ctrl, Cmd)` |
| Move the cursor one character to the left. | `Left Arrow (no Shift, Alt, Ctrl, Cmd)`<br />`Ctrl + B` |
| Move the cursor one character to the right. | `Right Arrow (no Shift, Alt, Ctrl, Cmd)`<br />`Ctrl + F` |
| Move the cursor one word to the left. | `Ctrl + Left Arrow`<br />`Alt + Left Arrow`<br />`Alt + B` |
| Move the cursor one word to the right. | `Ctrl + Right Arrow`<br />`Alt + Right Arrow`<br />`Alt + F` |
#### Editing
@@ -35,12 +35,12 @@ available combinations.
| Delete from the cursor to the end of the line. | `Ctrl + K` |
| Delete from the cursor to the start of the line. | `Ctrl + U` |
| Clear all text in the input field. | `Ctrl + C` |
| Delete the previous word. | `Ctrl + Backspace`<br />`Cmd + Backspace`<br />`Ctrl + W` |
| Delete the next word. | `Ctrl + Delete`<br />`Cmd + Delete` |
| Delete the previous word. | `Ctrl + Backspace`<br />`Alt + Backspace`<br />`Ctrl + W` |
| Delete the next word. | `Ctrl + Delete`<br />`Alt + Delete` |
| Delete the character to the left. | `Backspace`<br />`Ctrl + H` |
| Delete the character to the right. | `Delete`<br />`Ctrl + D` |
| Undo the most recent text edit. | `Ctrl + Z (no Shift)` |
| Redo the most recent undone text edit. | `Ctrl + Shift + Z` |
| Redo the most recent undone text edit. | `Shift + Ctrl + Z` |
#### Scrolling
@@ -84,12 +84,12 @@ available combinations.
#### Text Input
| Action | Keys |
| ---------------------------------------------- | ---------------------------------------------------------------------- |
| Submit the current prompt. | `Enter (no Ctrl, no Shift, no Cmd)` |
| Insert a newline without submitting. | `Ctrl + Enter`<br />`Cmd + Enter`<br />`Shift + Enter`<br />`Ctrl + J` |
| Open the current prompt in an external editor. | `Ctrl + X` |
| Paste from the clipboard. | `Ctrl + V`<br />`Cmd + V` |
| Action | Keys |
| ---------------------------------------------- | ----------------------------------------------------------------------------------------- |
| Submit the current prompt. | `Enter (no Shift, Alt, Ctrl, Cmd)` |
| Insert a newline without submitting. | `Ctrl + Enter`<br />`Cmd + Enter`<br />`Alt + Enter`<br />`Shift + Enter`<br />`Ctrl + J` |
| Open the current prompt in an external editor. | `Ctrl + X` |
| Paste from the clipboard. | `Ctrl + V`<br />`Cmd + V`<br />`Alt + V` |
#### App Controls
@@ -98,7 +98,7 @@ available combinations.
| Toggle detailed error information. | `F12` |
| Toggle the full TODO list. | `Ctrl + T` |
| Show IDE context details. | `Ctrl + G` |
| Toggle Markdown rendering. | `Cmd + M` |
| Toggle Markdown rendering. | `Alt + M` |
| Toggle copy mode when in alternate buffer mode. | `Ctrl + S` |
| Toggle YOLO (auto-approval) mode for tool calls. | `Ctrl + Y` |
| Cycle through approval modes: default (prompt), auto_edit (auto-approve edits), and plan (read-only). | `Shift + Tab` |

View File

@@ -33,14 +33,17 @@ describe('keyBindings config', () => {
expect(binding.key.length).toBeGreaterThan(0);
// Modifier properties should be boolean or undefined
if (binding.ctrl !== undefined) {
expect(typeof binding.ctrl).toBe('boolean');
}
if (binding.shift !== undefined) {
expect(typeof binding.shift).toBe('boolean');
}
if (binding.command !== undefined) {
expect(typeof binding.command).toBe('boolean');
if (binding.alt !== undefined) {
expect(typeof binding.alt).toBe('boolean');
}
if (binding.ctrl !== undefined) {
expect(typeof binding.ctrl).toBe('boolean');
}
if (binding.cmd !== undefined) {
expect(typeof binding.cmd).toBe('boolean');
}
}
}

View File

@@ -90,12 +90,14 @@ export enum Command {
export interface KeyBinding {
/** The key name (e.g., 'a', 'return', 'tab', 'escape') */
key: string;
/** Control key requirement: true=must be pressed, false=must not be pressed, undefined=ignore */
ctrl?: boolean;
/** Shift key requirement: true=must be pressed, false=must not be pressed, undefined=ignore */
shift?: boolean;
/** Command/meta key requirement: true=must be pressed, false=must not be pressed, undefined=ignore */
command?: boolean;
/** Alt/Option key requirement: true=must be pressed, false=must not be pressed, undefined=ignore */
alt?: boolean;
/** Control key requirement: true=must be pressed, false=must not be pressed, undefined=ignore */
ctrl?: boolean;
/** Command/Windows/Super key requirement: true=must be pressed, false=must not be pressed, undefined=ignore */
cmd?: boolean;
}
/**
@@ -119,51 +121,54 @@ export const defaultKeyBindings: KeyBindingConfig = {
// Cursor Movement
[Command.HOME]: [
{ key: 'a', ctrl: true },
{ key: 'home', ctrl: false, shift: false },
{ key: 'home', shift: false, ctrl: false },
],
[Command.END]: [
{ key: 'e', ctrl: true },
{ key: 'end', ctrl: false, shift: false },
{ key: 'end', shift: false, ctrl: false },
],
[Command.MOVE_UP]: [
{ key: 'up', shift: false, alt: false, ctrl: false, cmd: false },
],
[Command.MOVE_DOWN]: [
{ key: 'down', shift: false, alt: false, ctrl: false, cmd: false },
],
[Command.MOVE_UP]: [{ key: 'up', ctrl: false, command: false }],
[Command.MOVE_DOWN]: [{ key: 'down', ctrl: false, command: false }],
[Command.MOVE_LEFT]: [
{ key: 'left', ctrl: false, command: false },
{ key: 'left', shift: false, alt: false, ctrl: false, cmd: false },
{ key: 'b', ctrl: true },
],
[Command.MOVE_RIGHT]: [
{ key: 'right', ctrl: false, command: false },
{ key: 'right', shift: false, alt: false, ctrl: false, cmd: false },
{ key: 'f', ctrl: true },
],
[Command.MOVE_WORD_LEFT]: [
{ key: 'left', ctrl: true },
{ key: 'left', command: true },
{ key: 'b', command: true },
{ key: 'left', alt: true },
{ key: 'b', alt: true },
],
[Command.MOVE_WORD_RIGHT]: [
{ key: 'right', ctrl: true },
{ key: 'right', command: true },
{ key: 'f', command: true },
{ key: 'right', alt: true },
{ key: 'f', alt: true },
],
// Editing
[Command.KILL_LINE_RIGHT]: [{ key: 'k', ctrl: true }],
[Command.KILL_LINE_LEFT]: [{ key: 'u', ctrl: true }],
[Command.CLEAR_INPUT]: [{ key: 'c', ctrl: true }],
// Added command (meta/alt/option) for mac compatibility
[Command.DELETE_WORD_BACKWARD]: [
{ key: 'backspace', ctrl: true },
{ key: 'backspace', command: true },
{ key: 'backspace', alt: true },
{ key: 'w', ctrl: true },
],
[Command.DELETE_WORD_FORWARD]: [
{ key: 'delete', ctrl: true },
{ key: 'delete', command: true },
{ key: 'delete', alt: true },
],
[Command.DELETE_CHAR_LEFT]: [{ key: 'backspace' }, { key: 'h', ctrl: true }],
[Command.DELETE_CHAR_RIGHT]: [{ key: 'delete' }, { key: 'd', ctrl: true }],
[Command.UNDO]: [{ key: 'z', ctrl: true, shift: false }],
[Command.REDO]: [{ key: 'z', ctrl: true, shift: true }],
[Command.UNDO]: [{ key: 'z', shift: false, ctrl: true }],
[Command.REDO]: [{ key: 'z', shift: true, ctrl: true }],
// Scrolling
[Command.SCROLL_UP]: [{ key: 'up', shift: true }],
@@ -180,10 +185,9 @@ export const defaultKeyBindings: KeyBindingConfig = {
[Command.PAGE_DOWN]: [{ key: 'pagedown' }],
// History & Search
[Command.HISTORY_UP]: [{ key: 'p', ctrl: true, shift: false }],
[Command.HISTORY_DOWN]: [{ key: 'n', ctrl: true, shift: false }],
[Command.HISTORY_UP]: [{ key: 'p', shift: false, ctrl: true }],
[Command.HISTORY_DOWN]: [{ key: 'n', shift: false, ctrl: true }],
[Command.REVERSE_SEARCH]: [{ key: 'r', ctrl: true }],
// Note: original logic ONLY checked ctrl=false, ignored meta/shift/paste
[Command.SUBMIT_REVERSE_SEARCH]: [{ key: 'return', ctrl: false }],
[Command.ACCEPT_SUGGESTION_REVERSE_SEARCH]: [{ key: 'tab' }],
@@ -203,14 +207,13 @@ export const defaultKeyBindings: KeyBindingConfig = {
// Suggestions & Completions
[Command.ACCEPT_SUGGESTION]: [{ key: 'tab' }, { key: 'return', ctrl: false }],
// Completion navigation (arrow or Ctrl+P/N)
[Command.COMPLETION_UP]: [
{ key: 'up', shift: false },
{ key: 'p', ctrl: true, shift: false },
{ key: 'p', shift: false, ctrl: true },
],
[Command.COMPLETION_DOWN]: [
{ key: 'down', shift: false },
{ key: 'n', ctrl: true, shift: false },
{ key: 'n', shift: false, ctrl: true },
],
[Command.EXPAND_SUGGESTION]: [{ key: 'right' }],
[Command.COLLAPSE_SUGGESTION]: [{ key: 'left' }],
@@ -220,30 +223,31 @@ export const defaultKeyBindings: KeyBindingConfig = {
[Command.SUBMIT]: [
{
key: 'return',
ctrl: false,
command: false,
shift: false,
alt: false,
ctrl: false,
cmd: false,
},
],
// Split into multiple data-driven bindings
// Now also includes shift+enter for multi-line input
[Command.NEWLINE]: [
{ key: 'return', ctrl: true },
{ key: 'return', command: true },
{ key: 'return', cmd: true },
{ key: 'return', alt: true },
{ key: 'return', shift: true },
{ key: 'j', ctrl: true },
],
[Command.OPEN_EXTERNAL_EDITOR]: [{ key: 'x', ctrl: true }],
[Command.PASTE_CLIPBOARD]: [
{ key: 'v', ctrl: true },
{ key: 'v', command: true },
{ key: 'v', cmd: true },
{ key: 'v', alt: true },
],
// App Controls
[Command.SHOW_ERROR_DETAILS]: [{ key: 'f12' }],
[Command.SHOW_FULL_TODOS]: [{ key: 't', ctrl: true }],
[Command.SHOW_IDE_CONTEXT_DETAIL]: [{ key: 'g', ctrl: true }],
[Command.TOGGLE_MARKDOWN]: [{ key: 'm', command: true }],
[Command.TOGGLE_MARKDOWN]: [{ key: 'm', alt: true }],
[Command.TOGGLE_COPY_MODE]: [{ key: 's', ctrl: true }],
[Command.TOGGLE_YOLO]: [{ key: 'y', ctrl: true }],
[Command.CYCLE_APPROVAL_MODE]: [{ key: 'tab', shift: true }],

View File

@@ -1660,9 +1660,10 @@ describe('AppContainer State Management', () => {
act(() => {
handleGlobalKeypress({
name: 'c',
ctrl: false,
meta: false,
shift: false,
alt: false,
ctrl: false,
cmd: false,
...key,
} as Key);
});
@@ -1870,9 +1871,10 @@ describe('AppContainer State Management', () => {
act(() => {
handleGlobalKeypress({
name: 's',
ctrl: true,
meta: false,
shift: false,
alt: false,
ctrl: true,
cmd: false,
insertable: false,
sequence: '\x13',
});
@@ -1896,9 +1898,10 @@ describe('AppContainer State Management', () => {
act(() => {
handleGlobalKeypress({
name: 's',
ctrl: true,
meta: false,
shift: false,
alt: false,
ctrl: true,
cmd: false,
insertable: false,
sequence: '\x13',
});
@@ -1910,9 +1913,10 @@ describe('AppContainer State Management', () => {
act(() => {
handleGlobalKeypress({
name: 'any', // Any key should exit copy mode
ctrl: false,
meta: false,
shift: false,
alt: false,
ctrl: false,
cmd: false,
insertable: true,
sequence: 'a',
});
@@ -1930,9 +1934,10 @@ describe('AppContainer State Management', () => {
act(() => {
handleGlobalKeypress({
name: 's',
ctrl: true,
meta: false,
shift: false,
alt: false,
ctrl: true,
cmd: false,
insertable: false,
sequence: '\x13',
});
@@ -1945,9 +1950,10 @@ describe('AppContainer State Management', () => {
act(() => {
handleGlobalKeypress({
name: 'a',
ctrl: false,
meta: false,
shift: false,
alt: false,
ctrl: false,
cmd: false,
insertable: true,
sequence: 'a',
});

View File

@@ -108,10 +108,10 @@ describe('ApiAuthDialog', () => {
keypressHandler({
name: keyName,
sequence,
ctrl: false,
meta: false,
shift: false,
ctrl: false,
cmd: false,
sequence,
});
expect(expectedCall).toHaveBeenCalledWith(...args);
@@ -137,9 +137,9 @@ describe('ApiAuthDialog', () => {
await keypressHandler({
name: 'c',
ctrl: true,
meta: false,
shift: false,
ctrl: true,
cmd: false,
});
expect(clearApiKey).toHaveBeenCalled();

View File

@@ -48,10 +48,10 @@ describe('LoginWithGoogleRestartDialog', () => {
keypressHandler({
name: 'escape',
sequence: '\u001b',
ctrl: false,
meta: false,
shift: false,
ctrl: false,
cmd: false,
sequence: '\u001b',
});
expect(onDismiss).toHaveBeenCalledTimes(1);
@@ -67,10 +67,10 @@ describe('LoginWithGoogleRestartDialog', () => {
keypressHandler({
name: keyName,
sequence: keyName,
ctrl: false,
meta: false,
shift: false,
ctrl: false,
cmd: false,
sequence: keyName,
});
// Advance timers to trigger the setTimeout callback

View File

@@ -851,8 +851,9 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
completion.promptCompletion.text &&
key.sequence &&
key.sequence.length === 1 &&
!key.alt &&
!key.ctrl &&
!key.meta
!key.cmd
) {
completion.promptCompletion.clear();
setExpandedSuggestionIndex(-1);

View File

@@ -91,9 +91,10 @@ describe('MultiFolderTrustDialog', () => {
await act(async () => {
keypressCallback({
name: 'escape',
ctrl: false,
meta: false,
shift: false,
alt: false,
ctrl: false,
cmd: false,
sequence: '',
insertable: false,
});

View File

@@ -93,10 +93,10 @@ const createMockConfig = (overrides: Partial<Config> = {}): Config =>
const triggerKey = (
partialKey: Partial<{
name: string;
ctrl: boolean;
meta: boolean;
shift: boolean;
paste: boolean;
alt: boolean;
ctrl: boolean;
cmd: boolean;
insertable: boolean;
sequence: string;
}>,
@@ -108,9 +108,10 @@ const triggerKey = (
const key = {
name: '',
ctrl: false,
meta: false,
shift: false,
alt: false,
ctrl: false,
cmd: false,
insertable: false,
sequence: '',
...partialKey,
@@ -263,7 +264,13 @@ describe('SessionBrowser component', () => {
// Type the query "query".
for (const ch of ['q', 'u', 'e', 'r', 'y']) {
triggerKey({ sequence: ch, name: ch, ctrl: false, meta: false });
triggerKey({
sequence: ch,
name: ch,
alt: false,
ctrl: false,
cmd: false,
});
}
await waitFor(() => {

View File

@@ -781,9 +781,10 @@ export const useSessionBrowserInput = (
state.setScrollOffset(0);
} else if (
key.sequence &&
key.sequence.length === 1 &&
!key.alt &&
!key.ctrl &&
!key.meta &&
key.sequence.length === 1
!key.cmd
) {
state.setSearchQuery((prev) => prev + key.sequence);
state.setActiveIndex(0);

View File

@@ -53,7 +53,14 @@ describe('ShellInputPrompt', () => {
const handler = mockUseKeypress.mock.calls[0][0];
// Simulate keypress
handler({ name, sequence, ctrl: false, shift: false, meta: false });
handler({
name,
shift: false,
alt: false,
ctrl: false,
cmd: false,
sequence,
});
expect(mockWriteToPty).toHaveBeenCalledWith(1, sequence);
});
@@ -66,7 +73,7 @@ describe('ShellInputPrompt', () => {
const handler = mockUseKeypress.mock.calls[0][0];
handler({ name: key, ctrl: true, shift: true, meta: false });
handler({ name: key, shift: true, alt: false, ctrl: true, cmd: false });
expect(mockScrollPty).toHaveBeenCalledWith(1, direction);
});
@@ -78,10 +85,11 @@ describe('ShellInputPrompt', () => {
handler({
name: 'a',
sequence: 'a',
ctrl: false,
shift: false,
meta: false,
alt: false,
ctrl: false,
cmd: false,
sequence: 'a',
});
expect(mockWriteToPty).not.toHaveBeenCalled();
@@ -94,10 +102,11 @@ describe('ShellInputPrompt', () => {
handler({
name: 'a',
sequence: 'a',
ctrl: false,
shift: false,
meta: false,
alt: false,
ctrl: false,
cmd: false,
sequence: 'a',
});
expect(mockWriteToPty).not.toHaveBeenCalled();

View File

@@ -151,18 +151,20 @@ describe('TextInput', () => {
keypressHandler({
name: 'a',
sequence: 'a',
ctrl: false,
meta: false,
shift: false,
alt: false,
ctrl: false,
cmd: false,
sequence: 'a',
});
expect(mockBuffer.handleInput).toHaveBeenCalledWith({
name: 'a',
sequence: 'a',
ctrl: false,
meta: false,
shift: false,
alt: false,
ctrl: false,
cmd: false,
sequence: 'a',
});
expect(mockBuffer.text).toBe('a');
});
@@ -176,18 +178,20 @@ describe('TextInput', () => {
keypressHandler({
name: 'backspace',
sequence: '',
ctrl: false,
meta: false,
shift: false,
alt: false,
ctrl: false,
cmd: false,
sequence: '',
});
expect(mockBuffer.handleInput).toHaveBeenCalledWith({
name: 'backspace',
sequence: '',
ctrl: false,
meta: false,
shift: false,
alt: false,
ctrl: false,
cmd: false,
sequence: '',
});
expect(mockBuffer.text).toBe('tes');
});
@@ -201,10 +205,11 @@ describe('TextInput', () => {
keypressHandler({
name: 'left',
sequence: '',
ctrl: false,
meta: false,
shift: false,
alt: false,
ctrl: false,
cmd: false,
sequence: '',
});
// Cursor moves from end to before 't'
@@ -221,10 +226,11 @@ describe('TextInput', () => {
keypressHandler({
name: 'right',
sequence: '',
ctrl: false,
meta: false,
shift: false,
alt: false,
ctrl: false,
cmd: false,
sequence: '',
});
expect(mockBuffer.visualCursor[1]).toBe(3);
@@ -239,10 +245,11 @@ describe('TextInput', () => {
keypressHandler({
name: 'return',
sequence: '',
ctrl: false,
meta: false,
shift: false,
alt: false,
ctrl: false,
cmd: false,
sequence: '',
});
expect(onSubmit).toHaveBeenCalledWith('test');
@@ -257,10 +264,11 @@ describe('TextInput', () => {
keypressHandler({
name: 'escape',
sequence: '',
ctrl: false,
meta: false,
shift: false,
alt: false,
ctrl: false,
cmd: false,
sequence: '',
});
await vi.runAllTimersAsync();

View File

@@ -1059,9 +1059,10 @@ describe('useTextBuffer', () => {
act(() =>
result.current.handleInput({
name: 'h',
ctrl: false,
meta: false,
shift: false,
alt: false,
ctrl: false,
cmd: false,
insertable: true,
sequence: 'h',
}),
@@ -1069,9 +1070,10 @@ describe('useTextBuffer', () => {
act(() =>
result.current.handleInput({
name: 'i',
ctrl: false,
meta: false,
shift: false,
alt: false,
ctrl: false,
cmd: false,
insertable: true,
sequence: 'i',
}),
@@ -1086,9 +1088,10 @@ describe('useTextBuffer', () => {
act(() =>
result.current.handleInput({
name: 'return',
ctrl: false,
meta: false,
shift: false,
alt: false,
ctrl: false,
cmd: false,
insertable: true,
sequence: '\r',
}),
@@ -1103,9 +1106,10 @@ describe('useTextBuffer', () => {
act(() =>
result.current.handleInput({
name: 'j',
ctrl: true,
meta: false,
shift: false,
alt: false,
ctrl: true,
cmd: false,
insertable: false,
sequence: '\n',
}),
@@ -1120,9 +1124,10 @@ describe('useTextBuffer', () => {
act(() =>
result.current.handleInput({
name: 'tab',
ctrl: false,
meta: false,
shift: false,
alt: false,
ctrl: false,
cmd: false,
insertable: false,
sequence: '\t',
}),
@@ -1137,9 +1142,10 @@ describe('useTextBuffer', () => {
act(() =>
result.current.handleInput({
name: 'tab',
ctrl: false,
meta: false,
shift: true,
alt: false,
ctrl: false,
cmd: false,
insertable: false,
sequence: '\u001b[9;2u',
}),
@@ -1159,9 +1165,10 @@ describe('useTextBuffer', () => {
act(() =>
result.current.handleInput({
name: 'backspace',
ctrl: false,
meta: false,
shift: false,
alt: false,
ctrl: false,
cmd: false,
insertable: false,
sequence: '\x7f',
}),
@@ -1183,25 +1190,28 @@ describe('useTextBuffer', () => {
act(() => {
result.current.handleInput({
name: 'backspace',
ctrl: false,
meta: false,
shift: false,
alt: false,
ctrl: false,
cmd: false,
insertable: false,
sequence: '\x7f',
});
result.current.handleInput({
name: 'backspace',
ctrl: false,
meta: false,
shift: false,
alt: false,
ctrl: false,
cmd: false,
insertable: false,
sequence: '\x7f',
});
result.current.handleInput({
name: 'backspace',
ctrl: false,
meta: false,
shift: false,
alt: false,
ctrl: false,
cmd: false,
insertable: false,
sequence: '\x7f',
});
@@ -1258,24 +1268,26 @@ describe('useTextBuffer', () => {
act(() =>
result.current.handleInput({
name: 'left',
ctrl: false,
meta: false,
shift: false,
alt: false,
ctrl: false,
cmd: false,
insertable: false,
sequence: '\x1b[D',
}),
); // cursor [0,1]
);
expect(getBufferState(result).cursor).toEqual([0, 1]);
act(() =>
result.current.handleInput({
name: 'right',
ctrl: false,
meta: false,
shift: false,
alt: false,
ctrl: false,
cmd: false,
insertable: false,
sequence: '\x1b[C',
}),
); // cursor [0,2]
);
expect(getBufferState(result).cursor).toEqual([0, 2]);
});
@@ -1288,9 +1300,10 @@ describe('useTextBuffer', () => {
act(() =>
result.current.handleInput({
name: '',
ctrl: false,
meta: false,
shift: false,
alt: false,
ctrl: false,
cmd: false,
insertable: true,
sequence: textWithAnsi,
}),
@@ -1305,9 +1318,10 @@ describe('useTextBuffer', () => {
act(() =>
result.current.handleInput({
name: 'return',
ctrl: false,
meta: false,
shift: true,
alt: false,
ctrl: false,
cmd: false,
insertable: true,
sequence: '\r',
}),
@@ -1509,13 +1523,13 @@ Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots
describe('Input Sanitization', () => {
const createInput = (sequence: string) => ({
name: '',
ctrl: false,
meta: false,
shift: false,
alt: false,
ctrl: false,
cmd: false,
insertable: true,
sequence,
});
it.each([
{
input: '\x1B[31mHello\x1B[0m \x1B[32mWorld\x1B[0m',
@@ -1567,9 +1581,10 @@ Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots
act(() =>
result.current.handleInput({
name: '',
ctrl: false,
meta: false,
shift: false,
alt: false,
ctrl: false,
cmd: false,
insertable: true,
sequence: largeTextWithUnsafe,
}),
@@ -1601,9 +1616,10 @@ Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots
act(() =>
result.current.handleInput({
name: '',
ctrl: false,
meta: false,
shift: false,
alt: false,
ctrl: false,
cmd: false,
insertable: true,
sequence: largeTextWithAnsi,
}),
@@ -1625,9 +1641,10 @@ Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots
act(() =>
result.current.handleInput({
name: '',
ctrl: false,
meta: false,
shift: false,
alt: false,
ctrl: false,
cmd: false,
insertable: true,
sequence: emojis,
}),
@@ -1816,9 +1833,10 @@ Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots
act(() =>
result.current.handleInput({
name: 'return',
ctrl: false,
meta: false,
shift: false,
alt: false,
ctrl: false,
cmd: false,
insertable: true,
sequence: '\r',
}),
@@ -1837,9 +1855,10 @@ Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots
act(() =>
result.current.handleInput({
name: 'f1',
ctrl: false,
meta: false,
shift: false,
alt: false,
ctrl: false,
cmd: false,
insertable: false,
sequence: '\u001bOP',
}),

View File

@@ -91,7 +91,7 @@ export const INFORMATIVE_TIPS = [
'See full, untruncated responses with Ctrl+S…',
'Toggle auto-approval (YOLO mode) for all tools with Ctrl+Y…',
'Cycle through approval modes (Default, Plan, Auto-Edit) with Shift+Tab…',
'Toggle Markdown rendering (raw markdown mode) with Option+M…',
'Toggle Markdown rendering (raw markdown mode) with Alt+M…',
'Toggle shell mode by typing ! in an empty prompt…',
'Insert a newline with a backslash (\\) followed by Enter…',
'Navigate your prompt history with the Up and Down arrows…',

View File

@@ -101,9 +101,9 @@ describe('KeypressContext', () => {
expect(keyHandler).toHaveBeenCalledWith(
expect.objectContaining({
name: 'return',
ctrl: false,
meta: false,
shift: false,
ctrl: false,
cmd: false,
}),
);
});
@@ -116,9 +116,9 @@ describe('KeypressContext', () => {
expect(keyHandler).toHaveBeenCalledWith(
expect.objectContaining({
name: 'return',
ctrl: false,
meta: false,
shift: true,
ctrl: false,
cmd: false,
}),
);
});
@@ -127,17 +127,17 @@ describe('KeypressContext', () => {
{
modifier: 'Shift',
sequence: '\x1b[57414;2u',
expected: { ctrl: false, meta: false, shift: true },
expected: { shift: true, ctrl: false, cmd: false },
},
{
modifier: 'Ctrl',
sequence: '\x1b[57414;5u',
expected: { ctrl: true, meta: false, shift: false },
expected: { shift: false, ctrl: true, cmd: false },
},
{
modifier: 'Alt',
sequence: '\x1b[57414;3u',
expected: { ctrl: false, meta: true, shift: false },
expected: { shift: false, alt: true, ctrl: false, cmd: false },
},
])(
'should handle numpad enter with $modifier modifier',
@@ -163,9 +163,9 @@ describe('KeypressContext', () => {
expect(keyHandler).toHaveBeenCalledWith(
expect.objectContaining({
name: 'j',
ctrl: true,
meta: false,
shift: false,
ctrl: true,
cmd: false,
}),
);
});
@@ -178,9 +178,10 @@ describe('KeypressContext', () => {
expect(keyHandler).toHaveBeenCalledWith(
expect.objectContaining({
name: 'return',
ctrl: false,
meta: true,
shift: false,
alt: true,
ctrl: false,
cmd: false,
}),
);
});
@@ -202,7 +203,13 @@ describe('KeypressContext', () => {
act(() => stdin.write('a'));
expect(keyHandler).toHaveBeenLastCalledWith(
expect.objectContaining({ name: 'a' }),
expect.objectContaining({
name: 'a',
shift: false,
alt: false,
ctrl: false,
cmd: false,
}),
);
act(() => stdin.write('\r'));
@@ -212,6 +219,10 @@ describe('KeypressContext', () => {
name: 'return',
sequence: '\r',
insertable: true,
shift: false,
alt: false,
ctrl: false,
cmd: false,
}),
);
});
@@ -228,6 +239,10 @@ describe('KeypressContext', () => {
expect(keyHandler).toHaveBeenLastCalledWith(
expect.objectContaining({
name: 'return',
shift: false,
alt: false,
ctrl: false,
cmd: false,
}),
);
});
@@ -245,6 +260,10 @@ describe('KeypressContext', () => {
expect(keyHandler).toHaveBeenCalledWith(
expect.objectContaining({
name: 'escape',
shift: false,
alt: false,
ctrl: false,
cmd: false,
}),
);
});
@@ -266,11 +285,21 @@ describe('KeypressContext', () => {
expect(keyHandler).toHaveBeenNthCalledWith(
1,
expect.objectContaining({ name: 'escape', meta: true }),
expect.objectContaining({
name: 'escape',
shift: false,
alt: true,
cmd: false,
}),
);
expect(keyHandler).toHaveBeenNthCalledWith(
2,
expect.objectContaining({ name: 'escape', meta: true }),
expect.objectContaining({
name: 'escape',
shift: false,
alt: true,
cmd: false,
}),
);
});
});
@@ -296,7 +325,9 @@ describe('KeypressContext', () => {
expect(keyHandler).toHaveBeenCalledWith(
expect.objectContaining({
name: 'escape',
meta: true,
shift: false,
alt: true,
cmd: false,
}),
);
});
@@ -318,17 +349,17 @@ describe('KeypressContext', () => {
{
name: 'Backspace',
inputSequence: '\x1b[127u',
expected: { name: 'backspace', meta: false },
expected: { name: 'backspace', alt: false, cmd: false },
},
{
name: 'Option+Backspace',
name: 'Alt+Backspace',
inputSequence: '\x1b[127;3u',
expected: { name: 'backspace', meta: true },
expected: { name: 'backspace', alt: true, cmd: false },
},
{
name: 'Ctrl+Backspace',
inputSequence: '\x1b[127;5u',
expected: { name: 'backspace', ctrl: true },
expected: { name: 'backspace', alt: false, ctrl: true, cmd: false },
},
{
name: 'Shift+Space',
@@ -612,14 +643,17 @@ describe('KeypressContext', () => {
{ sequence: `\x1b[27;5;9~`, expected: { name: 'tab', ctrl: true } },
{
sequence: `\x1b[27;6;9~`,
expected: { name: 'tab', ctrl: true, shift: true },
expected: { name: 'tab', shift: true, ctrl: true },
},
// XTerm Function Key
{ sequence: `\x1b[1;129A`, expected: { name: 'up' } },
{ sequence: `\x1b[1;2H`, expected: { name: 'home', shift: true } },
{ sequence: `\x1b[1;5F`, expected: { name: 'end', ctrl: true } },
{ sequence: `\x1b[1;1P`, expected: { name: 'f1' } },
{ sequence: `\x1b[1;3Q`, expected: { name: 'f2', meta: true } },
{
sequence: `\x1b[1;3Q`,
expected: { name: 'f2', alt: true, cmd: false },
},
// Tilde Function Keys
{ sequence: `\x1b[3~`, expected: { name: 'delete' } },
{ sequence: `\x1b[5~`, expected: { name: 'pageup' } },
@@ -637,33 +671,75 @@ describe('KeypressContext', () => {
// Legacy Arrows
{
sequence: `\x1b[A`,
expected: { name: 'up', ctrl: false, meta: false, shift: false },
expected: {
name: 'up',
shift: false,
alt: false,
ctrl: false,
cmd: false,
},
},
{
sequence: `\x1b[B`,
expected: { name: 'down', ctrl: false, meta: false, shift: false },
expected: {
name: 'down',
shift: false,
alt: false,
ctrl: false,
cmd: false,
},
},
{
sequence: `\x1b[C`,
expected: { name: 'right', ctrl: false, meta: false, shift: false },
expected: {
name: 'right',
shift: false,
alt: false,
ctrl: false,
cmd: false,
},
},
{
sequence: `\x1b[D`,
expected: { name: 'left', ctrl: false, meta: false, shift: false },
expected: {
name: 'left',
shift: false,
alt: false,
ctrl: false,
cmd: false,
},
},
// Legacy Home/End
{
sequence: `\x1b[H`,
expected: { name: 'home', ctrl: false, meta: false, shift: false },
expected: {
name: 'home',
shift: false,
alt: false,
ctrl: false,
cmd: false,
},
},
{
sequence: `\x1b[F`,
expected: { name: 'end', ctrl: false, meta: false, shift: false },
expected: {
name: 'end',
shift: false,
alt: false,
ctrl: false,
cmd: false,
},
},
{
sequence: `\x1b[5H`,
expected: { name: 'home', ctrl: true, meta: false, shift: false },
expected: {
name: 'home',
shift: false,
alt: false,
ctrl: true,
cmd: false,
},
},
])(
'should recognize sequence "$sequence" as $expected.name',
@@ -690,11 +766,23 @@ describe('KeypressContext', () => {
expect(keyHandler).toHaveBeenNthCalledWith(
1,
expect.objectContaining({ name: 'delete' }),
expect.objectContaining({
name: 'delete',
shift: false,
alt: false,
ctrl: false,
cmd: false,
}),
);
expect(keyHandler).toHaveBeenNthCalledWith(
2,
expect.objectContaining({ name: 'delete' }),
expect.objectContaining({
name: 'delete',
shift: false,
alt: false,
ctrl: false,
cmd: false,
}),
);
});
@@ -751,9 +839,10 @@ describe('KeypressContext', () => {
chunk: `\x1b[${keycode};3u`,
expected: {
name: key,
ctrl: false,
meta: true,
shift: false,
alt: true,
ctrl: false,
cmd: false,
},
};
} else if (terminal === 'MacTerminal') {
@@ -766,24 +855,26 @@ describe('KeypressContext', () => {
expected: {
sequence: `\x1b${key}`,
name: key,
ctrl: false,
meta: true,
shift: false,
alt: true,
ctrl: false,
cmd: false,
},
};
} else {
// iTerm2 and VSCode send accented characters (å, ø, µ)
// Note: µ (mu) is sent with meta:false on iTerm2/VSCode but
// gets converted to m with meta:true
// Note: µ (mu) is sent with alt:false on iTerm2/VSCode but
// gets converted to m with alt:true
return {
terminal,
key,
chunk: accentedChar,
expected: {
name: key,
ctrl: false,
meta: true, // Always expect meta:true after conversion
shift: false,
alt: true, // Always expect alt:true after conversion
ctrl: false,
cmd: false,
sequence: accentedChar,
},
};
@@ -825,7 +916,10 @@ describe('KeypressContext', () => {
expect(keyHandler).toHaveBeenCalledWith(
expect.objectContaining({
sequence: '\\',
meta: false,
shift: false,
alt: false,
ctrl: false,
cmd: false,
}),
);
});
@@ -858,6 +952,10 @@ describe('KeypressContext', () => {
expect.objectContaining({
name: 'undefined',
sequence: INCOMPLETE_KITTY_SEQUENCE,
shift: false,
alt: false,
ctrl: false,
cmd: false,
}),
);
});
@@ -876,6 +974,10 @@ describe('KeypressContext', () => {
expect(keyHandler).toHaveBeenCalledWith(
expect.objectContaining({
sequence: '\x1b[m',
shift: false,
alt: false,
ctrl: false,
cmd: false,
}),
);
});
@@ -1048,6 +1150,10 @@ describe('KeypressContext', () => {
expect.objectContaining({
name: 'a',
sequence: 'a',
shift: false,
alt: false,
ctrl: false,
cmd: false,
}),
);
});
@@ -1162,7 +1268,14 @@ describe('KeypressContext', () => {
});
expect(keyHandler).toHaveBeenCalledWith(
expect.objectContaining({ name: 'f12', sequence: '\u001b[24~' }),
expect.objectContaining({
name: 'f12',
sequence: '\u001b[24~',
shift: false,
alt: false,
ctrl: false,
cmd: false,
}),
);
});
});

View File

@@ -251,9 +251,10 @@ function bufferPaste(keypressHandler: KeypressHandler): KeypressHandler {
if (buffer.length > 0) {
keypressHandler({
name: 'paste',
ctrl: false,
meta: false,
shift: false,
alt: false,
ctrl: false,
cmd: false,
insertable: true,
sequence: buffer,
});
@@ -300,9 +301,10 @@ function* emitKeys(
let escaped = false;
let name = undefined;
let ctrl = false;
let meta = false;
let shift = false;
let alt = false;
let ctrl = false;
let cmd = false;
let code = undefined;
let insertable = false;
@@ -353,9 +355,10 @@ function* emitKeys(
const decoded = Buffer.from(base64Data, 'base64').toString('utf-8');
keypressHandler({
name: 'paste',
ctrl: false,
meta: false,
shift: false,
alt: false,
ctrl: false,
cmd: false,
insertable: true,
sequence: decoded,
});
@@ -490,9 +493,10 @@ function* emitKeys(
}
// Parse the key modifier
ctrl = !!(modifier & 4);
meta = !!(modifier & 10); // use 10 to catch both alt (2) and meta (8).
shift = !!(modifier & 1);
alt = !!(modifier & 2);
ctrl = !!(modifier & 4);
cmd = !!(modifier & 8);
const keyInfo = KEY_INFO_MAP[code];
if (keyInfo) {
@@ -503,13 +507,16 @@ function* emitKeys(
if (keyInfo.ctrl) {
ctrl = true;
}
if (name === 'space' && !ctrl && !meta) {
if (name === 'space' && !ctrl && !cmd && !alt) {
sequence = ' ';
insertable = true;
}
} else {
name = 'undefined';
if ((ctrl || meta) && (code.endsWith('u') || code.endsWith('~'))) {
if (
(ctrl || cmd || alt) &&
(code.endsWith('u') || code.endsWith('~'))
) {
// CSI-u or tilde-coded functional keys: ESC [ <code> ; <mods> (u|~)
const codeNumber = parseInt(code.slice(1, -1), 10);
if (
@@ -523,26 +530,26 @@ function* emitKeys(
} else if (ch === '\r') {
// carriage return
name = 'return';
meta = escaped;
alt = escaped;
} else if (escaped && ch === '\n') {
// Alt+Enter (linefeed), should be consistent with carriage return
name = 'return';
meta = escaped;
alt = escaped;
} else if (ch === '\t') {
// tab
name = 'tab';
meta = escaped;
alt = escaped;
} else if (ch === '\b' || ch === '\x7f') {
// backspace or ctrl+h
name = 'backspace';
meta = escaped;
alt = escaped;
} else if (ch === ESC) {
// escape key
name = 'escape';
meta = escaped;
alt = escaped;
} else if (ch === ' ') {
name = 'space';
meta = escaped;
alt = escaped;
insertable = true;
} else if (!escaped && ch <= '\x1a') {
// ctrl+letter
@@ -552,29 +559,30 @@ function* emitKeys(
// Letter, number, shift+letter
name = ch.toLowerCase();
shift = /^[A-Z]$/.exec(ch) !== null;
meta = escaped;
alt = escaped;
insertable = true;
} else if (MAC_ALT_KEY_CHARACTER_MAP[ch] && process.platform === 'darwin') {
name = MAC_ALT_KEY_CHARACTER_MAP[ch];
meta = true;
alt = true;
} else if (sequence === `${ESC}${ESC}`) {
// Double escape
name = 'escape';
meta = true;
alt = true;
// Emit first escape key here, then continue processing
keypressHandler({
name: 'escape',
ctrl,
meta,
shift,
alt,
ctrl,
cmd,
insertable: false,
sequence: ESC,
});
} else if (escaped) {
// Escape sequence timeout
name = ch.length ? undefined : 'escape';
meta = true;
alt = true;
} else {
// Any other character is considered printable.
insertable = true;
@@ -586,9 +594,10 @@ function* emitKeys(
) {
keypressHandler({
name: name || '',
ctrl,
meta,
shift,
alt,
ctrl,
cmd,
insertable,
sequence,
});
@@ -599,9 +608,10 @@ function* emitKeys(
export interface Key {
name: string;
ctrl: boolean;
meta: boolean;
shift: boolean;
alt: boolean;
ctrl: boolean;
cmd: boolean; // Command/Windows/Super key
insertable: boolean;
sequence: string;
}

View File

@@ -139,63 +139,63 @@ describe('MouseContext', () => {
sequence: '\x1b[<0;10;20M',
expected: {
name: 'left-press',
shift: false,
ctrl: false,
meta: false,
shift: false,
},
},
{
sequence: '\x1b[<0;10;20m',
expected: {
name: 'left-release',
shift: false,
ctrl: false,
meta: false,
shift: false,
},
},
{
sequence: '\x1b[<2;10;20M',
expected: {
name: 'right-press',
shift: false,
ctrl: false,
meta: false,
shift: false,
},
},
{
sequence: '\x1b[<1;10;20M',
expected: {
name: 'middle-press',
shift: false,
ctrl: false,
meta: false,
shift: false,
},
},
{
sequence: '\x1b[<64;10;20M',
expected: {
name: 'scroll-up',
shift: false,
ctrl: false,
meta: false,
shift: false,
},
},
{
sequence: '\x1b[<65;10;20M',
expected: {
name: 'scroll-down',
shift: false,
ctrl: false,
meta: false,
shift: false,
},
},
{
sequence: '\x1b[<32;10;20M',
expected: {
name: 'move',
shift: false,
ctrl: false,
meta: false,
shift: false,
},
},
{
@@ -208,7 +208,7 @@ describe('MouseContext', () => {
}, // Alt + left press
{
sequence: '\x1b[<20;10;20M',
expected: { name: 'left-press', ctrl: true, shift: true },
expected: { name: 'left-press', shift: true, ctrl: true },
}, // Ctrl + Shift + left press
{
sequence: '\x1b[<68;10;20M',

View File

@@ -69,7 +69,7 @@ export function keyToAnsi(key: Key): string | null {
}
// If it's a simple character, return it.
if (!key.ctrl && !key.meta && key.sequence) {
if (!key.ctrl && !key.cmd && key.sequence) {
return key.sequence;
}

View File

@@ -314,8 +314,8 @@ describe('useApprovalModeIndicator', () => {
act(() => {
capturedUseKeypressHandler({
name: 'a',
ctrl: true,
shift: true,
ctrl: true,
} as Key);
});
expect(mockConfigInstance.setApprovalMode).not.toHaveBeenCalled();

View File

@@ -114,7 +114,13 @@ describe(`useKeypress`, () => {
const key = { name: 'return', sequence: '\x1B\r' };
act(() => stdin.write(key.sequence));
expect(onKeypress).toHaveBeenCalledWith(
expect.objectContaining({ ...key, meta: true }),
expect.objectContaining({
...key,
shift: false,
alt: true,
ctrl: false,
cmd: false,
}),
);
});
@@ -140,9 +146,10 @@ describe(`useKeypress`, () => {
expect(onKeypress).toHaveBeenCalledTimes(1);
expect(onKeypress).toHaveBeenCalledWith({
name: 'paste',
ctrl: false,
meta: false,
shift: false,
alt: false,
ctrl: false,
cmd: false,
insertable: true,
sequence: pasteText,
});

View File

@@ -59,7 +59,8 @@ describe('useSelectionList', () => {
name,
sequence,
ctrl: options.ctrl ?? false,
meta: false,
cmd: false,
alt: false,
shift: options.shift ?? false,
insertable: false,
};
@@ -328,7 +329,8 @@ describe('useSelectionList', () => {
name,
sequence: name,
ctrl: false,
meta: false,
cmd: false,
alt: false,
shift: false,
insertable: true,
};
@@ -377,7 +379,8 @@ describe('useSelectionList', () => {
name,
sequence: name,
ctrl: false,
meta: false,
cmd: false,
alt: false,
shift: false,
insertable: false,
};

View File

@@ -36,9 +36,10 @@ vi.mock('../contexts/VimModeContext.js', () => ({
const createKey = (partial: Partial<Key>): Key => ({
name: partial.name || '',
sequence: partial.sequence || '',
ctrl: partial.ctrl || false,
meta: partial.meta || false,
shift: partial.shift || false,
alt: partial.alt || false,
ctrl: partial.ctrl || false,
cmd: partial.cmd || false,
insertable: partial.insertable || false,
...partial,
});

View File

@@ -280,8 +280,9 @@ export function useVim(buffer: TextBuffer, onSubmit?: (value: string) => void) {
// Special handling for Enter key to allow command submission (lower priority than completion)
if (
normalizedKey.name === 'return' &&
!normalizedKey.alt &&
!normalizedKey.ctrl &&
!normalizedKey.meta
!normalizedKey.cmd
) {
if (buffer.text.trim() && onSubmit) {
// Handle command submission directly
@@ -309,9 +310,10 @@ export function useVim(buffer: TextBuffer, onSubmit?: (value: string) => void) {
(key: Key): Key => ({
name: key.name || '',
sequence: key.sequence || '',
ctrl: key.ctrl || false,
meta: key.meta || false,
shift: key.shift || false,
alt: key.alt || false,
ctrl: key.ctrl || false,
cmd: key.cmd || false,
insertable: key.insertable || false,
}),
[],

View File

@@ -13,9 +13,10 @@ import type { Key } from './hooks/useKeypress.js';
describe('keyMatchers', () => {
const createKey = (name: string, mods: Partial<Key> = {}): Key => ({
name,
ctrl: false,
meta: false,
shift: false,
alt: false,
ctrl: false,
cmd: false,
insertable: false,
sequence: name,
...mods,
@@ -70,8 +71,8 @@ describe('keyMatchers', () => {
command: Command.MOVE_WORD_LEFT,
positive: [
createKey('left', { ctrl: true }),
createKey('left', { meta: true }),
createKey('b', { meta: true }),
createKey('left', { alt: true }),
createKey('b', { alt: true }),
],
negative: [createKey('left'), createKey('b', { ctrl: true })],
},
@@ -79,8 +80,8 @@ describe('keyMatchers', () => {
command: Command.MOVE_WORD_RIGHT,
positive: [
createKey('right', { ctrl: true }),
createKey('right', { meta: true }),
createKey('f', { meta: true }),
createKey('right', { alt: true }),
createKey('f', { alt: true }),
],
negative: [createKey('right'), createKey('f', { ctrl: true })],
},
@@ -115,7 +116,7 @@ describe('keyMatchers', () => {
command: Command.DELETE_WORD_BACKWARD,
positive: [
createKey('backspace', { ctrl: true }),
createKey('backspace', { meta: true }),
createKey('backspace', { alt: true }),
createKey('w', { ctrl: true }),
],
negative: [createKey('backspace'), createKey('delete', { ctrl: true })],
@@ -124,19 +125,19 @@ describe('keyMatchers', () => {
command: Command.DELETE_WORD_FORWARD,
positive: [
createKey('delete', { ctrl: true }),
createKey('delete', { meta: true }),
createKey('delete', { alt: true }),
],
negative: [createKey('delete'), createKey('backspace', { ctrl: true })],
},
{
command: Command.UNDO,
positive: [createKey('z', { ctrl: true, shift: false })],
negative: [createKey('z'), createKey('z', { ctrl: true, shift: true })],
positive: [createKey('z', { shift: false, ctrl: true })],
negative: [createKey('z'), createKey('z', { shift: true, ctrl: true })],
},
{
command: Command.REDO,
positive: [createKey('z', { ctrl: true, shift: true })],
negative: [createKey('z'), createKey('z', { ctrl: true, shift: false })],
positive: [createKey('z', { shift: true, ctrl: true })],
negative: [createKey('z'), createKey('z', { shift: false, ctrl: true })],
},
// Screen control
@@ -243,14 +244,16 @@ describe('keyMatchers', () => {
positive: [createKey('return')],
negative: [
createKey('return', { ctrl: true }),
createKey('return', { meta: true }),
createKey('return', { cmd: true }),
createKey('return', { alt: true }),
],
},
{
command: Command.NEWLINE,
positive: [
createKey('return', { ctrl: true }),
createKey('return', { meta: true }),
createKey('return', { cmd: true }),
createKey('return', { alt: true }),
],
negative: [createKey('return'), createKey('n')],
},
@@ -285,13 +288,13 @@ describe('keyMatchers', () => {
},
{
command: Command.TOGGLE_MARKDOWN,
positive: [createKey('m', { meta: true })],
positive: [createKey('m', { alt: true })],
negative: [createKey('m'), createKey('m', { shift: true })],
},
{
command: Command.TOGGLE_COPY_MODE,
positive: [createKey('s', { ctrl: true })],
negative: [createKey('s'), createKey('s', { meta: true })],
negative: [createKey('s'), createKey('s', { alt: true })],
},
{
command: Command.QUIT,
@@ -333,7 +336,7 @@ describe('keyMatchers', () => {
{
command: Command.TOGGLE_YOLO,
positive: [createKey('y', { ctrl: true })],
negative: [createKey('y'), createKey('y', { meta: true })],
negative: [createKey('y'), createKey('y', { alt: true })],
},
{
command: Command.CYCLE_APPROVAL_MODE,
@@ -401,13 +404,13 @@ describe('keyMatchers', () => {
...defaultKeyBindings,
[Command.QUIT]: [
{ key: 'q', ctrl: true },
{ key: 'q', command: true },
{ key: 'q', alt: true },
],
};
const matchers = createKeyMatchers(config);
expect(matchers[Command.QUIT](createKey('q', { ctrl: true }))).toBe(true);
expect(matchers[Command.QUIT](createKey('q', { meta: true }))).toBe(true);
expect(matchers[Command.QUIT](createKey('q', { alt: true }))).toBe(true);
});
});

View File

@@ -13,28 +13,17 @@ import { Command, defaultKeyBindings } from '../config/keyBindings.js';
* Pure data-driven matching logic
*/
function matchKeyBinding(keyBinding: KeyBinding, key: Key): boolean {
if (keyBinding.key !== key.name) {
return false;
}
// Check modifiers - follow original logic:
// undefined = ignore this modifier (original behavior)
// true = modifier must be pressed
// false = modifier must NOT be pressed
if (keyBinding.ctrl !== undefined && key.ctrl !== keyBinding.ctrl) {
return false;
}
if (keyBinding.shift !== undefined && key.shift !== keyBinding.shift) {
return false;
}
if (keyBinding.command !== undefined && key.meta !== keyBinding.command) {
return false;
}
return true;
return (
keyBinding.key === key.name &&
(keyBinding.shift === undefined || key.shift === keyBinding.shift) &&
(keyBinding.alt === undefined || key.alt === keyBinding.alt) &&
(keyBinding.ctrl === undefined || key.ctrl === keyBinding.ctrl) &&
(keyBinding.cmd === undefined || key.cmd === keyBinding.cmd)
);
}
/**

View File

@@ -154,9 +154,10 @@ function formatBindings(bindings: readonly KeyBinding[]): string[] {
function formatBinding(binding: KeyBinding): string {
const modifiers: string[] = [];
if (binding.ctrl) modifiers.push('Ctrl');
if (binding.command) modifiers.push('Cmd');
if (binding.shift) modifiers.push('Shift');
if (binding.alt) modifiers.push('Alt');
if (binding.ctrl) modifiers.push('Ctrl');
if (binding.cmd) modifiers.push('Cmd');
const keyName = formatKeyName(binding.key);
if (!keyName) {
@@ -167,12 +168,13 @@ function formatBinding(binding: KeyBinding): string {
let combo = segments.join(' + ');
const restrictions: string[] = [];
if (binding.ctrl === false) restrictions.push('no Ctrl');
if (binding.shift === false) restrictions.push('no Shift');
if (binding.command === false) restrictions.push('no Cmd');
if (binding.shift === false) restrictions.push('Shift');
if (binding.alt === false) restrictions.push('Alt');
if (binding.ctrl === false) restrictions.push('Ctrl');
if (binding.cmd === false) restrictions.push('Cmd');
if (restrictions.length > 0) {
combo = `${combo} (${restrictions.join(', ')})`;
combo = `${combo} (no ${restrictions.join(', ')})`;
}
return combo ? `\`${combo}\`` : '';

View File

@@ -36,7 +36,7 @@ describe('generate-keybindings-doc', () => {
},
{
description: 'Submit with Enter if no modifiers are held.',
bindings: [{ key: 'return', ctrl: false, shift: false }],
bindings: [{ key: 'return', shift: false, ctrl: false }],
},
],
},
@@ -47,7 +47,7 @@ describe('generate-keybindings-doc', () => {
description: 'Move up through results.',
bindings: [
{ key: 'up', shift: false },
{ key: 'p', ctrl: true, shift: false },
{ key: 'p', shift: false, ctrl: true },
],
},
],
@@ -59,7 +59,7 @@ describe('generate-keybindings-doc', () => {
expect(markdown).toContain('Trigger custom action.');
expect(markdown).toContain('`Ctrl + X`');
expect(markdown).toContain('Submit with Enter if no modifiers are held.');
expect(markdown).toContain('`Enter (no Ctrl, no Shift)`');
expect(markdown).toContain('`Enter (no Shift, Ctrl)`');
expect(markdown).toContain('#### Navigation');
expect(markdown).toContain('Move up through results.');
expect(markdown).toContain('`Up Arrow (no Shift)`');