This commit is contained in:
Jacob Richman
2026-02-05 10:54:46 -08:00
committed by GitHub
parent 5d04a01b06
commit 258643dec4
10 changed files with 275 additions and 143 deletions
+2 -2
View File
@@ -343,11 +343,11 @@ please see the dedicated [Custom Commands documentation](./custom-commands.md).
These shortcuts apply directly to the input prompt for text manipulation. These shortcuts apply directly to the input prompt for text manipulation.
- **Undo:** - **Undo:**
- **Keyboard shortcut:** Press **Cmd+z** or **Alt+z** to undo the last action - **Keyboard shortcut:** Press **Alt+z** or **Cmd+z** to undo the last action
in the input prompt. in the input prompt.
- **Redo:** - **Redo:**
- **Keyboard shortcut:** Press **Shift+Cmd+Z** or **Shift+Alt+Z** to redo the - **Keyboard shortcut:** Press **Shift+Alt+Z** or **Shift+Cmd+Z** to redo the
last undone action in the input prompt. last undone action in the input prompt.
## At commands (`@`) ## At commands (`@`)
+5 -1
View File
@@ -141,6 +141,7 @@ import { LoginWithGoogleRestartDialog } from './auth/LoginWithGoogleRestartDialo
import { NewAgentsChoice } from './components/NewAgentsNotification.js'; import { NewAgentsChoice } from './components/NewAgentsNotification.js';
import { isSlashCommand } from './utils/commandUtils.js'; import { isSlashCommand } from './utils/commandUtils.js';
import { useTerminalTheme } from './hooks/useTerminalTheme.js'; import { useTerminalTheme } from './hooks/useTerminalTheme.js';
import { isITerm2 } from './utils/terminalUtils.js';
function isToolExecuting(pendingHistoryItems: HistoryItemWithoutId[]) { function isToolExecuting(pendingHistoryItems: HistoryItemWithoutId[]) {
return pendingHistoryItems.some((item) => { return pendingHistoryItems.some((item) => {
@@ -1472,7 +1473,10 @@ Logging in with Google... Restarting Gemini CLI to continue.
setShowErrorDetails((prev) => !prev); setShowErrorDetails((prev) => !prev);
return true; return true;
} else if (keyMatchers[Command.SUSPEND_APP](key)) { } else if (keyMatchers[Command.SUSPEND_APP](key)) {
handleWarning('Undo has been moved to Cmd + Z or Alt/Opt + Z'); const undoMessage = isITerm2()
? 'Undo has been moved to Option + Z'
: 'Undo has been moved to Alt/Option + Z or Cmd + Z';
handleWarning(undoMessage);
return true; return true;
} else if (keyMatchers[Command.SHOW_FULL_TODOS](key)) { } else if (keyMatchers[Command.SHOW_FULL_TODOS](key)) {
setShowFullTodos((prev) => !prev); setShowFullTodos((prev) => !prev);
+2 -2
View File
@@ -110,8 +110,8 @@ export const INFORMATIVE_TIPS = [
'Delete from the cursor to the end of the line with Ctrl+K…', 'Delete from the cursor to the end of the line with Ctrl+K…',
'Clear the entire input prompt with a double-press of Esc…', 'Clear the entire input prompt with a double-press of Esc…',
'Paste from your clipboard with Ctrl+V…', 'Paste from your clipboard with Ctrl+V…',
'Undo text edits in the input with Cmd+Z or Alt+Z…', 'Undo text edits in the input with Alt+Z or Cmd+Z…',
'Redo undone text edits with Shift+Cmd+Z or Shift+Alt+Z…', 'Redo undone text edits with Shift+Alt+Z or Shift+Cmd+Z…',
'Open the current prompt in an external editor with Ctrl+X…', 'Open the current prompt in an external editor with Ctrl+X…',
'In menus, move up/down with k/j or the arrow keys…', 'In menus, move up/down with k/j or the arrow keys…',
'In menus, select an item by typing its number…', 'In menus, select an item by typing its number…',
@@ -821,65 +821,72 @@ describe('KeypressContext', () => {
// Terminals to test // Terminals to test
const terminals = ['iTerm2', 'Ghostty', 'MacTerminal', 'VSCodeTerminal']; const terminals = ['iTerm2', 'Ghostty', 'MacTerminal', 'VSCodeTerminal'];
// Key mappings: letter -> [keycode, accented character] // Key mappings: letter -> [keycode, accented character, shift]
const keys: Record<string, [number, string]> = { const keys: Record<string, [number, string, boolean]> = {
b: [98, '\u222B'], b: [98, '\u222B', false],
f: [102, '\u0192'], f: [102, '\u0192', false],
m: [109, '\u00B5'], m: [109, '\u00B5', false],
z: [122, '\u03A9', false],
Z: [122, '\u00B8', true],
}; };
it.each( it.each(
terminals.flatMap((terminal) => terminals.flatMap((terminal) =>
Object.entries(keys).map(([key, [keycode, accentedChar]]) => { Object.entries(keys).map(
if (terminal === 'Ghostty') { ([key, [keycode, accentedChar, shiftValue]]) => {
// Ghostty uses kitty protocol sequences if (terminal === 'Ghostty') {
return { // Ghostty uses kitty protocol sequences
terminal, // Modifier 3 is Alt, 4 is Shift+Alt
key, const modifier = shiftValue ? 4 : 3;
chunk: `\x1b[${keycode};3u`, return {
expected: { terminal,
name: key, key,
shift: false, chunk: `\x1b[${keycode};${modifier}u`,
alt: true, expected: {
ctrl: false, name: key.toLowerCase(),
cmd: false, shift: shiftValue,
}, alt: true,
}; ctrl: false,
} else if (terminal === 'MacTerminal') { cmd: false,
// Mac Terminal sends ESC + letter },
return { };
terminal, } else if (terminal === 'MacTerminal') {
key, // Mac Terminal sends ESC + letter
kitty: false, const chunk = shiftValue
chunk: `\x1b${key}`, ? `\x1b${key.toUpperCase()}`
expected: { : `\x1b${key.toLowerCase()}`;
sequence: `\x1b${key}`, return {
name: key, terminal,
shift: false, key,
alt: true, kitty: false,
ctrl: false, chunk,
cmd: false, expected: {
}, sequence: chunk,
}; name: key.toLowerCase(),
} else { shift: shiftValue,
// iTerm2 and VSCode send accented characters (å, ø, µ) alt: true,
// Note: µ (mu) is sent with alt:false on iTerm2/VSCode but ctrl: false,
// gets converted to m with alt:true cmd: false,
return { },
terminal, };
key, } else {
chunk: accentedChar, // iTerm2 and VSCode send accented characters (å, ø, µ, Ω, ¸)
expected: { return {
name: key, terminal,
shift: false, key,
alt: true, // Always expect alt:true after conversion chunk: accentedChar,
ctrl: false, expected: {
cmd: false, name: key.toLowerCase(),
sequence: accentedChar, shift: shiftValue,
}, alt: true, // Always expect alt:true after conversion
}; ctrl: false,
} cmd: false,
}), sequence: accentedChar,
},
};
}
},
),
), ),
)( )(
'should handle Alt+$key in $terminal', 'should handle Alt+$key in $terminal',
@@ -1302,4 +1309,57 @@ describe('KeypressContext', () => {
} }
}); });
}); });
describe('Greek support', () => {
afterEach(() => {
vi.unstubAllEnvs();
});
it.each([
{
lang: 'en_US.UTF-8',
expected: { name: 'z', alt: true, insertable: false },
desc: 'non-Greek locale (Option+z)',
},
{
lang: 'el_GR.UTF-8',
expected: { name: '', insertable: true },
desc: 'Greek LANG',
},
{
lcAll: 'el_GR.UTF-8',
expected: { name: '', insertable: true },
desc: 'Greek LC_ALL',
},
{
lang: 'en_US.UTF-8',
lcAll: 'el_GR.UTF-8',
expected: { name: '', insertable: true },
desc: 'LC_ALL overriding non-Greek LANG',
},
{
lang: 'el_GR.UTF-8',
char: '\u00B8',
expected: { name: 'z', alt: true, shift: true },
desc: 'Cedilla (\u00B8) in Greek locale (should be Option+Shift+z)',
},
])(
'should handle $char correctly in $desc',
async ({ lang, lcAll, char = '\u03A9', expected }) => {
if (lang) vi.stubEnv('LANG', lang);
if (lcAll) vi.stubEnv('LC_ALL', lcAll);
const { keyHandler } = setupKeypressTest();
act(() => stdin.write(char));
expect(keyHandler).toHaveBeenCalledWith(
expect.objectContaining({
...expected,
sequence: char,
}),
);
},
);
});
}); });
@@ -130,6 +130,8 @@ const MAC_ALT_KEY_CHARACTER_MAP: Record<string, string> = {
'\u222B': 'b', // "∫" back one word '\u222B': 'b', // "∫" back one word
'\u0192': 'f', // "ƒ" forward one word '\u0192': 'f', // "ƒ" forward one word
'\u00B5': 'm', // "µ" toggle markup view '\u00B5': 'm', // "µ" toggle markup view
'\u03A9': 'z', // "Ω" Option+z
'\u00B8': 'Z', // "¸" Option+Shift+z
}; };
function nonKeyboardEventFilter( function nonKeyboardEventFilter(
@@ -305,6 +307,10 @@ function createDataListener(keypressHandler: KeypressHandler) {
function* emitKeys( function* emitKeys(
keypressHandler: KeypressHandler, keypressHandler: KeypressHandler,
): Generator<void, void, string> { ): Generator<void, void, string> {
const lang = process.env['LANG'] || '';
const lcAll = process.env['LC_ALL'] || '';
const isGreek = lang.startsWith('el') || lcAll.startsWith('el');
while (true) { while (true) {
let ch = yield; let ch = yield;
let sequence = ch; let sequence = ch;
@@ -574,8 +580,15 @@ function* emitKeys(
} else if (MAC_ALT_KEY_CHARACTER_MAP[ch]) { } else if (MAC_ALT_KEY_CHARACTER_MAP[ch]) {
// Note: we do this even if we are not on Mac, because mac users may // Note: we do this even if we are not on Mac, because mac users may
// remotely connect to non-Mac systems. // remotely connect to non-Mac systems.
name = MAC_ALT_KEY_CHARACTER_MAP[ch]; // We skip this mapping for Greek users to avoid blocking the Omega character.
alt = true; if (isGreek && ch === '\u03A9') {
insertable = true;
} else {
const mapped = MAC_ALT_KEY_CHARACTER_MAP[ch];
name = mapped.toLowerCase();
shift = mapped !== name;
alt = true;
}
} else if (sequence === `${ESC}${ESC}`) { } else if (sequence === `${ESC}${ESC}`) {
// Double escape // Double escape
name = 'escape'; name = 'escape';
@@ -2,6 +2,38 @@
exports[`terminalSetup > configureVSCodeStyle > should create new keybindings file if none exists 1`] = ` exports[`terminalSetup > configureVSCodeStyle > should create new keybindings file if none exists 1`] = `
[ [
{
"args": {
"text": "",
},
"command": "workbench.action.terminal.sendSequence",
"key": "shift+alt+z",
"when": "terminalFocus",
},
{
"args": {
"text": "",
},
"command": "workbench.action.terminal.sendSequence",
"key": "shift+cmd+z",
"when": "terminalFocus",
},
{
"args": {
"text": "",
},
"command": "workbench.action.terminal.sendSequence",
"key": "alt+z",
"when": "terminalFocus",
},
{
"args": {
"text": "",
},
"command": "workbench.action.terminal.sendSequence",
"key": "cmd+z",
"when": "terminalFocus",
},
{ {
"args": { "args": {
"text": "\\ "text": "\\
@@ -129,7 +129,7 @@ describe('terminalSetup', () => {
expect(result.success).toBe(true); expect(result.success).toBe(true);
const writtenContent = JSON.parse(mocks.writeFile.mock.calls[0][1]); const writtenContent = JSON.parse(mocks.writeFile.mock.calls[0][1]);
expect(writtenContent).toHaveLength(2); // Shift+Enter and Ctrl+Enter expect(writtenContent).toHaveLength(6); // Shift+Enter, Ctrl+Enter, Cmd+Z, Alt+Z, Shift+Cmd+Z, Shift+Alt+Z
}); });
it('should not modify if bindings already exist', async () => { it('should not modify if bindings already exist', async () => {
@@ -145,6 +145,26 @@ describe('terminalSetup', () => {
command: 'workbench.action.terminal.sendSequence', command: 'workbench.action.terminal.sendSequence',
args: { text: VSCODE_SHIFT_ENTER_SEQUENCE }, args: { text: VSCODE_SHIFT_ENTER_SEQUENCE },
}, },
{
key: 'cmd+z',
command: 'workbench.action.terminal.sendSequence',
args: { text: '\u001b[122;9u' },
},
{
key: 'alt+z',
command: 'workbench.action.terminal.sendSequence',
args: { text: '\u001b[122;3u' },
},
{
key: 'shift+cmd+z',
command: 'workbench.action.terminal.sendSequence',
args: { text: '\u001b[122;10u' },
},
{
key: 'shift+alt+z',
command: 'workbench.action.terminal.sendSequence',
args: { text: '\u001b[122;4u' },
},
]; ];
mocks.readFile.mockResolvedValue(JSON.stringify(existingBindings)); mocks.readFile.mockResolvedValue(JSON.stringify(existingBindings));
+82 -71
View File
@@ -204,94 +204,105 @@ async function configureVSCodeStyle(
// File doesn't exist, will create new one // File doesn't exist, will create new one
} }
const shiftEnterBinding = { const targetBindings = [
key: 'shift+enter', {
command: 'workbench.action.terminal.sendSequence', key: 'shift+enter',
when: 'terminalFocus', command: 'workbench.action.terminal.sendSequence',
args: { text: VSCODE_SHIFT_ENTER_SEQUENCE }, when: 'terminalFocus',
}; args: { text: VSCODE_SHIFT_ENTER_SEQUENCE },
},
{
key: 'ctrl+enter',
command: 'workbench.action.terminal.sendSequence',
when: 'terminalFocus',
args: { text: VSCODE_SHIFT_ENTER_SEQUENCE },
},
{
key: 'cmd+z',
command: 'workbench.action.terminal.sendSequence',
when: 'terminalFocus',
args: { text: '\u001b[122;9u' },
},
{
key: 'alt+z',
command: 'workbench.action.terminal.sendSequence',
when: 'terminalFocus',
args: { text: '\u001b[122;3u' },
},
{
key: 'shift+cmd+z',
command: 'workbench.action.terminal.sendSequence',
when: 'terminalFocus',
args: { text: '\u001b[122;10u' },
},
{
key: 'shift+alt+z',
command: 'workbench.action.terminal.sendSequence',
when: 'terminalFocus',
args: { text: '\u001b[122;4u' },
},
];
const ctrlEnterBinding = { const results = targetBindings.map((target) => {
key: 'ctrl+enter', const hasOurBinding = keybindings.some((kb) => {
command: 'workbench.action.terminal.sendSequence', const binding = kb as {
when: 'terminalFocus', command?: string;
args: { text: VSCODE_SHIFT_ENTER_SEQUENCE }, args?: { text?: string };
}; key?: string;
};
return (
binding.key === target.key &&
binding.command === target.command &&
binding.args?.text === target.args.text
);
});
// Check if our specific bindings already exist const existingBinding = keybindings.find((kb) => {
const hasOurShiftEnter = keybindings.some((kb) => { const binding = kb as { key?: string };
const binding = kb as { return binding.key === target.key;
command?: string; });
args?: { text?: string };
key?: string; return {
target,
hasOurBinding,
conflict: !!existingBinding && !hasOurBinding,
conflictMessage: `- ${target.key.charAt(0).toUpperCase() + target.key.slice(1)} binding already exists`,
}; };
return (
binding.key === 'shift+enter' &&
binding.command === 'workbench.action.terminal.sendSequence' &&
binding.args?.text === '\\\r\n'
);
}); });
const hasOurCtrlEnter = keybindings.some((kb) => { if (results.every((r) => r.hasOurBinding)) {
const binding = kb as {
command?: string;
args?: { text?: string };
key?: string;
};
return (
binding.key === 'ctrl+enter' &&
binding.command === 'workbench.action.terminal.sendSequence' &&
binding.args?.text === '\\\r\n'
);
});
if (hasOurShiftEnter && hasOurCtrlEnter) {
return { return {
success: true, success: true,
message: `${terminalName} keybindings already configured.`, message: `${terminalName} keybindings already configured.`,
}; };
} }
// Check if ANY shift+enter or ctrl+enter bindings already exist (that are NOT ours) const conflicts = results.filter((r) => r.conflict);
const existingShiftEnter = keybindings.find((kb) => { if (conflicts.length > 0) {
const binding = kb as { key?: string }; return {
return binding.key === 'shift+enter'; success: false,
}); message:
`Existing keybindings detected. Will not modify to avoid conflicts.\n` +
const existingCtrlEnter = keybindings.find((kb) => { conflicts.map((c) => c.conflictMessage).join('\n') +
const binding = kb as { key?: string }; '\n' +
return binding.key === 'ctrl+enter'; `Please check and modify manually if needed: ${keybindingsFile}`,
}); };
if (existingShiftEnter || existingCtrlEnter) {
const messages: string[] = [];
// Only report conflict if it's not our binding (though we checked above, partial matches might exist)
if (existingShiftEnter && !hasOurShiftEnter) {
messages.push(`- Shift+Enter binding already exists`);
}
if (existingCtrlEnter && !hasOurCtrlEnter) {
messages.push(`- Ctrl+Enter binding already exists`);
}
if (messages.length > 0) {
return {
success: false,
message:
`Existing keybindings detected. Will not modify to avoid conflicts.\n` +
messages.join('\n') +
'\n' +
`Please check and modify manually if needed: ${keybindingsFile}`,
};
}
} }
if (!hasOurShiftEnter) keybindings.unshift(shiftEnterBinding); for (const { hasOurBinding, target } of results) {
if (!hasOurCtrlEnter) keybindings.unshift(ctrlEnterBinding); if (!hasOurBinding) {
keybindings.unshift(target);
}
}
await fs.writeFile(keybindingsFile, JSON.stringify(keybindings, null, 4)); await fs.writeFile(keybindingsFile, JSON.stringify(keybindings, null, 4));
return { return {
success: true, success: true,
message: `Added Shift+Enter and Ctrl+Enter keybindings to ${terminalName}.\nModified: ${keybindingsFile}`, message: `Added ${targetBindings
.map((b) => b.key.charAt(0).toUpperCase() + b.key.slice(1))
.join(
', ',
)} keybindings to ${terminalName}.\nModified: ${keybindingsFile}`,
requiresRestart: true, requiresRestart: true,
}; };
} catch (error) { } catch (error) {
@@ -10,7 +10,6 @@ import { isITerm2, resetITerm2Cache } from './terminalUtils.js';
describe('terminalUtils', () => { describe('terminalUtils', () => {
beforeEach(() => { beforeEach(() => {
vi.stubEnv('TERM_PROGRAM', ''); vi.stubEnv('TERM_PROGRAM', '');
vi.stubEnv('ITERM_SESSION_ID', '');
resetITerm2Cache(); resetITerm2Cache();
}); });
@@ -24,11 +23,6 @@ describe('terminalUtils', () => {
expect(isITerm2()).toBe(true); expect(isITerm2()).toBe(true);
}); });
it('should detect iTerm2 via ITERM_SESSION_ID', () => {
vi.stubEnv('ITERM_SESSION_ID', 'w0t0p0:6789...');
expect(isITerm2()).toBe(true);
});
it('should return false if not iTerm2', () => { it('should return false if not iTerm2', () => {
vi.stubEnv('TERM_PROGRAM', 'vscode'); vi.stubEnv('TERM_PROGRAM', 'vscode');
expect(isITerm2()).toBe(false); expect(isITerm2()).toBe(false);
+1 -3
View File
@@ -31,9 +31,7 @@ export function isITerm2(): boolean {
return cachedIsITerm2; return cachedIsITerm2;
} }
cachedIsITerm2 = cachedIsITerm2 = process.env['TERM_PROGRAM'] === 'iTerm.app';
process.env['TERM_PROGRAM'] === 'iTerm.app' ||
!!process.env['ITERM_SESSION_ID'];
return cachedIsITerm2; return cachedIsITerm2;
} }