From 258643dec40112d843b6d8dcb0dc80a2a42929ca Mon Sep 17 00:00:00 2001 From: Jacob Richman Date: Thu, 5 Feb 2026 10:54:46 -0800 Subject: [PATCH] undo (#18147) --- docs/cli/commands.md | 4 +- packages/cli/src/ui/AppContainer.tsx | 6 +- packages/cli/src/ui/constants/tips.ts | 4 +- .../src/ui/contexts/KeypressContext.test.tsx | 170 ++++++++++++------ .../cli/src/ui/contexts/KeypressContext.tsx | 17 +- .../__snapshots__/terminalSetup.test.ts.snap | 32 ++++ .../cli/src/ui/utils/terminalSetup.test.ts | 22 ++- packages/cli/src/ui/utils/terminalSetup.ts | 153 ++++++++-------- .../cli/src/ui/utils/terminalUtils.test.ts | 6 - packages/cli/src/ui/utils/terminalUtils.ts | 4 +- 10 files changed, 275 insertions(+), 143 deletions(-) diff --git a/docs/cli/commands.md b/docs/cli/commands.md index c2f4aa4189..5dec6fb5db 100644 --- a/docs/cli/commands.md +++ b/docs/cli/commands.md @@ -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. - **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. - **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. ## At commands (`@`) diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 7c10569902..305cedc97f 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -141,6 +141,7 @@ import { LoginWithGoogleRestartDialog } from './auth/LoginWithGoogleRestartDialo import { NewAgentsChoice } from './components/NewAgentsNotification.js'; import { isSlashCommand } from './utils/commandUtils.js'; import { useTerminalTheme } from './hooks/useTerminalTheme.js'; +import { isITerm2 } from './utils/terminalUtils.js'; function isToolExecuting(pendingHistoryItems: HistoryItemWithoutId[]) { return pendingHistoryItems.some((item) => { @@ -1472,7 +1473,10 @@ Logging in with Google... Restarting Gemini CLI to continue. setShowErrorDetails((prev) => !prev); return true; } 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; } else if (keyMatchers[Command.SHOW_FULL_TODOS](key)) { setShowFullTodos((prev) => !prev); diff --git a/packages/cli/src/ui/constants/tips.ts b/packages/cli/src/ui/constants/tips.ts index 772966ad77..949322e22c 100644 --- a/packages/cli/src/ui/constants/tips.ts +++ b/packages/cli/src/ui/constants/tips.ts @@ -110,8 +110,8 @@ export const INFORMATIVE_TIPS = [ 'Delete from the cursor to the end of the line with Ctrl+K…', 'Clear the entire input prompt with a double-press of Esc…', 'Paste from your clipboard with Ctrl+V…', - 'Undo text edits in the input with Cmd+Z or Alt+Z…', - 'Redo undone text edits with Shift+Cmd+Z or Shift+Alt+Z…', + 'Undo text edits in the input with Alt+Z or Cmd+Z…', + 'Redo undone text edits with Shift+Alt+Z or Shift+Cmd+Z…', '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, select an item by typing its number…', diff --git a/packages/cli/src/ui/contexts/KeypressContext.test.tsx b/packages/cli/src/ui/contexts/KeypressContext.test.tsx index 0386dda7c8..16e3a42a37 100644 --- a/packages/cli/src/ui/contexts/KeypressContext.test.tsx +++ b/packages/cli/src/ui/contexts/KeypressContext.test.tsx @@ -821,65 +821,72 @@ describe('KeypressContext', () => { // Terminals to test const terminals = ['iTerm2', 'Ghostty', 'MacTerminal', 'VSCodeTerminal']; - // Key mappings: letter -> [keycode, accented character] - const keys: Record = { - b: [98, '\u222B'], - f: [102, '\u0192'], - m: [109, '\u00B5'], + // Key mappings: letter -> [keycode, accented character, shift] + const keys: Record = { + b: [98, '\u222B', false], + f: [102, '\u0192', false], + m: [109, '\u00B5', false], + z: [122, '\u03A9', false], + Z: [122, '\u00B8', true], }; it.each( terminals.flatMap((terminal) => - Object.entries(keys).map(([key, [keycode, accentedChar]]) => { - if (terminal === 'Ghostty') { - // Ghostty uses kitty protocol sequences - return { - terminal, - key, - chunk: `\x1b[${keycode};3u`, - expected: { - name: key, - shift: false, - alt: true, - ctrl: false, - cmd: false, - }, - }; - } else if (terminal === 'MacTerminal') { - // Mac Terminal sends ESC + letter - return { - terminal, - key, - kitty: false, - chunk: `\x1b${key}`, - expected: { - sequence: `\x1b${key}`, - name: key, - shift: false, - alt: true, - ctrl: false, - cmd: false, - }, - }; - } else { - // iTerm2 and VSCode send accented characters (å, ø, µ) - // 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, - shift: false, - alt: true, // Always expect alt:true after conversion - ctrl: false, - cmd: false, - sequence: accentedChar, - }, - }; - } - }), + Object.entries(keys).map( + ([key, [keycode, accentedChar, shiftValue]]) => { + if (terminal === 'Ghostty') { + // Ghostty uses kitty protocol sequences + // Modifier 3 is Alt, 4 is Shift+Alt + const modifier = shiftValue ? 4 : 3; + return { + terminal, + key, + chunk: `\x1b[${keycode};${modifier}u`, + expected: { + name: key.toLowerCase(), + shift: shiftValue, + alt: true, + ctrl: false, + cmd: false, + }, + }; + } else if (terminal === 'MacTerminal') { + // Mac Terminal sends ESC + letter + const chunk = shiftValue + ? `\x1b${key.toUpperCase()}` + : `\x1b${key.toLowerCase()}`; + return { + terminal, + key, + kitty: false, + chunk, + expected: { + sequence: chunk, + name: key.toLowerCase(), + shift: shiftValue, + alt: true, + ctrl: false, + cmd: false, + }, + }; + } else { + // iTerm2 and VSCode send accented characters (å, ø, µ, Ω, ¸) + return { + terminal, + key, + chunk: accentedChar, + expected: { + name: key.toLowerCase(), + shift: shiftValue, + alt: true, // Always expect alt:true after conversion + ctrl: false, + cmd: false, + sequence: accentedChar, + }, + }; + } + }, + ), ), )( '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, + }), + ); + }, + ); + }); }); diff --git a/packages/cli/src/ui/contexts/KeypressContext.tsx b/packages/cli/src/ui/contexts/KeypressContext.tsx index 91c4eb3493..f64f47dcad 100644 --- a/packages/cli/src/ui/contexts/KeypressContext.tsx +++ b/packages/cli/src/ui/contexts/KeypressContext.tsx @@ -130,6 +130,8 @@ const MAC_ALT_KEY_CHARACTER_MAP: Record = { '\u222B': 'b', // "∫" back one word '\u0192': 'f', // "ƒ" forward one word '\u00B5': 'm', // "µ" toggle markup view + '\u03A9': 'z', // "Ω" Option+z + '\u00B8': 'Z', // "¸" Option+Shift+z }; function nonKeyboardEventFilter( @@ -305,6 +307,10 @@ function createDataListener(keypressHandler: KeypressHandler) { function* emitKeys( keypressHandler: KeypressHandler, ): Generator { + const lang = process.env['LANG'] || ''; + const lcAll = process.env['LC_ALL'] || ''; + const isGreek = lang.startsWith('el') || lcAll.startsWith('el'); + while (true) { let ch = yield; let sequence = ch; @@ -574,8 +580,15 @@ function* emitKeys( } else if (MAC_ALT_KEY_CHARACTER_MAP[ch]) { // Note: we do this even if we are not on Mac, because mac users may // remotely connect to non-Mac systems. - name = MAC_ALT_KEY_CHARACTER_MAP[ch]; - alt = true; + // We skip this mapping for Greek users to avoid blocking the Omega character. + 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}`) { // Double escape name = 'escape'; diff --git a/packages/cli/src/ui/utils/__snapshots__/terminalSetup.test.ts.snap b/packages/cli/src/ui/utils/__snapshots__/terminalSetup.test.ts.snap index 743043a0f2..c1c5f514f1 100644 --- a/packages/cli/src/ui/utils/__snapshots__/terminalSetup.test.ts.snap +++ b/packages/cli/src/ui/utils/__snapshots__/terminalSetup.test.ts.snap @@ -2,6 +2,38 @@ 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": { "text": "\\ diff --git a/packages/cli/src/ui/utils/terminalSetup.test.ts b/packages/cli/src/ui/utils/terminalSetup.test.ts index 1c565f1d7d..dc570edaff 100644 --- a/packages/cli/src/ui/utils/terminalSetup.test.ts +++ b/packages/cli/src/ui/utils/terminalSetup.test.ts @@ -129,7 +129,7 @@ describe('terminalSetup', () => { expect(result.success).toBe(true); 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 () => { @@ -145,6 +145,26 @@ describe('terminalSetup', () => { command: 'workbench.action.terminal.sendSequence', 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)); diff --git a/packages/cli/src/ui/utils/terminalSetup.ts b/packages/cli/src/ui/utils/terminalSetup.ts index ede409dd49..5114c006fa 100644 --- a/packages/cli/src/ui/utils/terminalSetup.ts +++ b/packages/cli/src/ui/utils/terminalSetup.ts @@ -204,94 +204,105 @@ async function configureVSCodeStyle( // File doesn't exist, will create new one } - const shiftEnterBinding = { - key: 'shift+enter', - command: 'workbench.action.terminal.sendSequence', - when: 'terminalFocus', - args: { text: VSCODE_SHIFT_ENTER_SEQUENCE }, - }; + const targetBindings = [ + { + key: 'shift+enter', + command: 'workbench.action.terminal.sendSequence', + 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 = { - key: 'ctrl+enter', - command: 'workbench.action.terminal.sendSequence', - when: 'terminalFocus', - args: { text: VSCODE_SHIFT_ENTER_SEQUENCE }, - }; + const results = targetBindings.map((target) => { + const hasOurBinding = keybindings.some((kb) => { + const binding = kb as { + command?: string; + 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 hasOurShiftEnter = keybindings.some((kb) => { - const binding = kb as { - command?: string; - args?: { text?: string }; - key?: string; + const existingBinding = keybindings.find((kb) => { + const binding = kb as { key?: string }; + return binding.key === target.key; + }); + + 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) => { - 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) { + if (results.every((r) => r.hasOurBinding)) { return { success: true, message: `${terminalName} keybindings already configured.`, }; } - // Check if ANY shift+enter or ctrl+enter bindings already exist (that are NOT ours) - const existingShiftEnter = keybindings.find((kb) => { - const binding = kb as { key?: string }; - return binding.key === 'shift+enter'; - }); - - const existingCtrlEnter = keybindings.find((kb) => { - const binding = kb as { key?: string }; - return binding.key === 'ctrl+enter'; - }); - - 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}`, - }; - } + const conflicts = results.filter((r) => r.conflict); + if (conflicts.length > 0) { + return { + success: false, + message: + `Existing keybindings detected. Will not modify to avoid conflicts.\n` + + conflicts.map((c) => c.conflictMessage).join('\n') + + '\n' + + `Please check and modify manually if needed: ${keybindingsFile}`, + }; } - if (!hasOurShiftEnter) keybindings.unshift(shiftEnterBinding); - if (!hasOurCtrlEnter) keybindings.unshift(ctrlEnterBinding); + for (const { hasOurBinding, target } of results) { + if (!hasOurBinding) { + keybindings.unshift(target); + } + } await fs.writeFile(keybindingsFile, JSON.stringify(keybindings, null, 4)); return { 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, }; } catch (error) { diff --git a/packages/cli/src/ui/utils/terminalUtils.test.ts b/packages/cli/src/ui/utils/terminalUtils.test.ts index 70b2a08f17..814308ddbc 100644 --- a/packages/cli/src/ui/utils/terminalUtils.test.ts +++ b/packages/cli/src/ui/utils/terminalUtils.test.ts @@ -10,7 +10,6 @@ import { isITerm2, resetITerm2Cache } from './terminalUtils.js'; describe('terminalUtils', () => { beforeEach(() => { vi.stubEnv('TERM_PROGRAM', ''); - vi.stubEnv('ITERM_SESSION_ID', ''); resetITerm2Cache(); }); @@ -24,11 +23,6 @@ describe('terminalUtils', () => { 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', () => { vi.stubEnv('TERM_PROGRAM', 'vscode'); expect(isITerm2()).toBe(false); diff --git a/packages/cli/src/ui/utils/terminalUtils.ts b/packages/cli/src/ui/utils/terminalUtils.ts index 5c03198f71..18cd08f952 100644 --- a/packages/cli/src/ui/utils/terminalUtils.ts +++ b/packages/cli/src/ui/utils/terminalUtils.ts @@ -31,9 +31,7 @@ export function isITerm2(): boolean { return cachedIsITerm2; } - cachedIsITerm2 = - process.env['TERM_PROGRAM'] === 'iTerm.app' || - !!process.env['ITERM_SESSION_ID']; + cachedIsITerm2 = process.env['TERM_PROGRAM'] === 'iTerm.app'; return cachedIsITerm2; }