diff --git a/docs/reference/commands.md b/docs/reference/commands.md index f7f8692e38..22605d9b08 100644 --- a/docs/reference/commands.md +++ b/docs/reference/commands.md @@ -504,12 +504,12 @@ the dedicated [Custom Commands documentation](../cli/custom-commands.md). These shortcuts apply directly to the input prompt for text manipulation. - **Undo:** - - **Keyboard shortcut:** Press **Alt+z** or **Cmd+z** to undo the last action - in the input prompt. + - **Keyboard shortcut:** Press **Ctrl+z** (Windows), **Cmd+z** (macOS), or + **Alt+z** (Linux/WSL) to undo the last action in the input prompt. - **Redo:** - - **Keyboard shortcut:** Press **Shift+Alt+Z** or **Shift+Cmd+Z** to redo the - last undone action in the input prompt. + - **Keyboard shortcut:** Press **Shift+Cmd+Z** (macOS), or **Shift+Alt+Z** + (Linux/WSL) to redo the last undone action in the input prompt. ## At commands (`@`) diff --git a/docs/reference/keyboard-shortcuts.md b/docs/reference/keyboard-shortcuts.md index 6f7a8cce4a..a570d4a2c9 100644 --- a/docs/reference/keyboard-shortcuts.md +++ b/docs/reference/keyboard-shortcuts.md @@ -39,7 +39,7 @@ available combinations. | `edit.deleteWordRight` | Delete the next word. | `Ctrl+Delete`
`Alt+Delete`
`Alt+D` | | `edit.deleteLeft` | Delete the character to the left. | `Backspace`
`Ctrl+H` | | `edit.deleteRight` | Delete the character to the right. | `Delete`
`Ctrl+D` | -| `edit.undo` | Undo the most recent text edit. | `Cmd/Win+Z`
`Alt+Z` | +| `edit.undo` | Undo the most recent text edit. | `Ctrl+Z`
`Alt+Z`
`Cmd/Win+Z` | | `edit.redo` | Redo the most recent undone text edit. | `Ctrl+Shift+Z`
`Shift+Cmd/Win+Z`
`Alt+Shift+Z` | #### Scrolling diff --git a/packages/cli/src/ui/components/shared/text-buffer.test.ts b/packages/cli/src/ui/components/shared/text-buffer.test.ts index 32077b736a..a3052f546b 100644 --- a/packages/cli/src/ui/components/shared/text-buffer.test.ts +++ b/packages/cli/src/ui/components/shared/text-buffer.test.ts @@ -41,6 +41,7 @@ import { getTransformedImagePath, } from './text-buffer.js'; import { cpLen } from '../../utils/textUtils.js'; +import { type Key } from '../../hooks/useKeypress.js'; import { escapePath } from '@google/gemini-cli-core'; const defaultVisualLayout: VisualLayout = { @@ -1799,6 +1800,229 @@ describe('useTextBuffer', () => { expect(getBufferState(result).text).toBe(''); }); + it('should only handle Undo if there is something to undo', async () => { + const { result } = await renderHook(() => useTextBuffer({ viewport })); + + // Platform-specific undo key + const undoKey: Key = + process.platform === 'win32' + ? { + name: 'z', + ctrl: true, + shift: false, + alt: false, + cmd: false, + insertable: false, + sequence: '\x1a', + } + : process.platform === 'darwin' + ? { + name: 'z', + ctrl: false, + shift: false, + alt: false, + cmd: true, + insertable: false, + sequence: '\u001b[122;D', + } + : { + name: 'z', + ctrl: false, + shift: false, + alt: true, + cmd: false, + insertable: false, + sequence: '\u001bz', + }; + + // 1. Initial state: nothing to undo + let handled = true; + act(() => { + handled = result.current.handleInput(undoKey); + }); + expect(handled).toBe(false); + + // 2. Insert something + act(() => { + result.current.handleInput({ + name: 'a', + shift: false, + alt: false, + ctrl: false, + cmd: false, + insertable: true, + sequence: 'a', + }); + }); + expect(getBufferState(result).text).toBe('a'); + + // 3. Now undo should work + act(() => { + handled = result.current.handleInput(undoKey); + }); + expect(handled).toBe(true); + expect(getBufferState(result).text).toBe(''); + + // 4. Undo again: nothing left to undo + act(() => { + handled = result.current.handleInput(undoKey); + }); + expect(handled).toBe(false); + }); + + if (process.platform === 'linux') { + it('should handle "Ctrl+Z" for smart bubbling on Linux/WSL', async () => { + const { result } = await renderHook(() => useTextBuffer({ viewport })); + + const ctrlZ: Key = { + name: 'z', + ctrl: true, + shift: false, + alt: false, + cmd: false, + insertable: false, + sequence: '\x1a', + }; + + // 1. Empty buffer: should NOT handle (bubble up to Suspend) + let handled = true; + act(() => { + handled = result.current.handleInput(ctrlZ); + }); + expect(handled).toBe(false); + + // 2. Add text + act(() => { + result.current.handleInput({ + name: 'x', + insertable: true, + sequence: 'x', + shift: false, + alt: false, + ctrl: false, + cmd: false, + }); + }); + + // 3. Has history: should handle (perform Undo) + act(() => { + handled = result.current.handleInput(ctrlZ); + }); + expect(handled).toBe(true); + expect(getBufferState(result).text).toBe(''); + + // 4. Empty again: should NOT handle + act(() => { + handled = result.current.handleInput(ctrlZ); + }); + expect(handled).toBe(false); + }); + } + + it('should only handle Redo if there is something to redo', async () => { + const { result } = await renderHook(() => useTextBuffer({ viewport })); + + // Platform-specific redo key (first in list) + const redoKey: Key = + process.platform === 'win32' + ? { + name: 'z', + ctrl: true, + shift: true, + alt: false, + cmd: false, + insertable: false, + sequence: '\x1a', + } + : process.platform === 'darwin' + ? { + name: 'z', + ctrl: false, + shift: true, + alt: false, + cmd: true, + insertable: false, + sequence: '\u001b[122;2D', + } + : { + name: 'z', + ctrl: false, + shift: true, + alt: true, + cmd: false, + insertable: false, + sequence: '\u001bZ', + }; + + const undoKey: Key = + process.platform === 'win32' + ? { + name: 'z', + ctrl: true, + shift: false, + alt: false, + cmd: false, + insertable: false, + sequence: '\x1a', + } + : process.platform === 'darwin' + ? { + name: 'z', + ctrl: false, + shift: false, + alt: false, + cmd: true, + insertable: false, + sequence: '\u001b[122;D', + } + : { + name: 'z', + ctrl: false, + shift: false, + alt: true, + cmd: false, + insertable: false, + sequence: '\u001bz', + }; + + // 1. Initial state: nothing to redo + let handled = true; + act(() => { + handled = result.current.handleInput(redoKey); + }); + expect(handled).toBe(false); + + // 2. Insert and Undo + act(() => { + result.current.handleInput({ + name: 'a', + shift: false, + alt: false, + ctrl: false, + cmd: false, + insertable: true, + sequence: 'a', + }); + }); + act(() => { + result.current.handleInput(undoKey); + }); + expect(getBufferState(result).text).toBe(''); + + // 3. Now redo should work + act(() => { + handled = result.current.handleInput(redoKey); + }); + expect(handled).toBe(true); + expect(getBufferState(result).text).toBe('a'); + + // 4. Redo again: nothing left to redo + act(() => { + handled = result.current.handleInput(redoKey); + }); + expect(handled).toBe(false); + }); + it('should handle multiple delete characters in one input', async () => { const { result } = await renderHook(() => useTextBuffer({ diff --git a/packages/cli/src/ui/components/shared/text-buffer.ts b/packages/cli/src/ui/components/shared/text-buffer.ts index d6b95d6016..89b6f8f158 100644 --- a/packages/cli/src/ui/components/shared/text-buffer.ts +++ b/packages/cli/src/ui/components/shared/text-buffer.ts @@ -2889,6 +2889,8 @@ export function useTextBuffer({ transformationsByLine, pastedContent, expandedPaste, + undoStack, + redoStack, } = state; const text = useMemo(() => lines.join('\n'), [lines]); @@ -3454,10 +3456,16 @@ export function useTextBuffer({ return true; } if (keyMatchers[Command.UNDO](key)) { + if (undoStack.length === 0) { + return false; + } undo(); return true; } if (keyMatchers[Command.REDO](key)) { + if (redoStack.length === 0) { + return false; + } redo(); return true; } @@ -3486,6 +3494,8 @@ export function useTextBuffer({ visualCursor, visualLines, keyMatchers, + undoStack.length, + redoStack.length, ], ); diff --git a/packages/cli/src/ui/key/keyBindings.test.ts b/packages/cli/src/ui/key/keyBindings.test.ts index 10f88dd4d9..dc590e99c4 100644 --- a/packages/cli/src/ui/key/keyBindings.test.ts +++ b/packages/cli/src/ui/key/keyBindings.test.ts @@ -108,6 +108,30 @@ describe('keyBindings config', () => { } }); + it('should have platform-specific UNDO bindings', () => { + const undoBindings = defaultKeyBindingConfig.get(Command.UNDO); + if (process.platform === 'win32') { + expect(undoBindings?.[0].name).toBe('z'); + expect(undoBindings?.[0].ctrl).toBe(true); + } else if (process.platform === 'darwin') { + expect(undoBindings?.[0].name).toBe('z'); + expect(undoBindings?.[0].cmd).toBe(true); + } else { + expect(undoBindings?.[0].name).toBe('z'); + expect(undoBindings?.[0].alt).toBe(true); + // Ensure ctrl+z is also present for smart bubbling + expect(undoBindings?.some((b) => b.name === 'z' && b.ctrl)).toBe(true); + } + }); + + it('should have platform-specific REDO bindings', () => { + const redoBindings = defaultKeyBindingConfig.get(Command.REDO); + // Ctrl+Shift+Z is now the universal primary to avoid conflict with YOLO (Ctrl+Y) + expect(redoBindings?.[0].name).toBe('z'); + expect(redoBindings?.[0].shift).toBe(true); + expect(redoBindings?.[0].ctrl).toBe(true); + }); + describe('command metadata', () => { const commandValues = Object.values(Command); diff --git a/packages/cli/src/ui/key/keyBindings.ts b/packages/cli/src/ui/key/keyBindings.ts index a038f6173c..67e2ff1941 100644 --- a/packages/cli/src/ui/key/keyBindings.ts +++ b/packages/cli/src/ui/key/keyBindings.ts @@ -312,15 +312,8 @@ export const defaultKeyBindingConfig: KeyBindingConfig = new Map([ Command.DELETE_CHAR_RIGHT, [new KeyBinding('delete'), new KeyBinding('ctrl+d')], ], - [Command.UNDO, [new KeyBinding('cmd+z'), new KeyBinding('alt+z')]], - [ - Command.REDO, - [ - new KeyBinding('ctrl+shift+z'), - new KeyBinding('cmd+shift+z'), - new KeyBinding('alt+shift+z'), - ], - ], + [Command.UNDO, getPlatformUndoBindings(process.platform)], + [Command.REDO, getPlatformRedoBindings(process.platform)], // Scrolling [Command.SCROLL_UP, [new KeyBinding('shift+up')]], @@ -782,3 +775,33 @@ export async function loadCustomKeybindings(): Promise<{ return { config, errors }; } + +export function getPlatformUndoBindings( + platform: string, +): readonly KeyBinding[] { + if (platform === 'win32') { + return [new KeyBinding('ctrl+z'), new KeyBinding('alt+z')]; + } + if (platform === 'darwin') { + return [new KeyBinding('cmd+z'), new KeyBinding('alt+z')]; + } + // Linux / WSL: Promote Alt+Z to avoid Windows interception, + // but keep Ctrl+Z for smart bubbling. + return [ + new KeyBinding('alt+z'), + new KeyBinding('cmd+z'), + new KeyBinding('ctrl+z'), + ]; +} + +export function getPlatformRedoBindings( + _platform: string, +): readonly KeyBinding[] { + // Use a stable order for all platforms to minimize churn. + // Ctrl+Shift+Z is the universal primary. + return [ + new KeyBinding('ctrl+shift+z'), + new KeyBinding('cmd+shift+z'), + new KeyBinding('alt+shift+z'), + ]; +} diff --git a/packages/cli/src/ui/key/keyMatchers.test.ts b/packages/cli/src/ui/key/keyMatchers.test.ts index 0fc2f00ac7..a5f28d8cb7 100644 --- a/packages/cli/src/ui/key/keyMatchers.test.ts +++ b/packages/cli/src/ui/key/keyMatchers.test.ts @@ -149,23 +149,44 @@ describe('keyMatchers', () => { { command: Command.UNDO, positive: [ - createKey('z', { shift: false, cmd: true }), - createKey('z', { shift: false, alt: true }), + ...(process.platform === 'win32' + ? [createKey('z', { shift: false, ctrl: true })] + : process.platform === 'darwin' + ? [createKey('z', { shift: false, cmd: true })] + : [ + createKey('z', { shift: false, alt: true }), + createKey('z', { shift: false, cmd: true }), + createKey('z', { shift: false, ctrl: true }), + ]), + ...(process.platform !== 'linux' + ? [createKey('z', { shift: false, alt: true })] + : []), ], negative: [ createKey('z'), createKey('z', { shift: true, cmd: true }), - createKey('z', { shift: false, ctrl: true }), + ...(process.platform === 'darwin' + ? [createKey('z', { shift: false, ctrl: true })] + : []), + ...(process.platform === 'win32' + ? [createKey('z', { shift: false, cmd: true })] + : []), ], }, { command: Command.REDO, positive: [ - createKey('z', { shift: true, cmd: true }), + ...(process.platform === 'win32' + ? [] + : [createKey('z', { shift: true, cmd: true })]), createKey('z', { shift: true, alt: true }), createKey('z', { shift: true, ctrl: true }), ], - negative: [createKey('z'), createKey('z', { shift: false, cmd: true })], + negative: [ + createKey('z'), + createKey('z', { shift: false, cmd: true }), + createKey('y', { shift: false, ctrl: true }), + ], }, // Screen control diff --git a/scripts/generate-keybindings-doc.ts b/scripts/generate-keybindings-doc.ts index 10c95d9649..d1d4d85f4c 100644 --- a/scripts/generate-keybindings-doc.ts +++ b/scripts/generate-keybindings-doc.ts @@ -13,6 +13,9 @@ import { commandCategories, commandDescriptions, defaultKeyBindingConfig, + Command, + getPlatformUndoBindings, + getPlatformRedoBindings, } from '../packages/cli/src/ui/key/keyBindings.js'; import { formatWithPrettier, @@ -81,14 +84,54 @@ export async function main(argv = process.argv.slice(2)) { export function buildDefaultDocSections(): readonly KeybindingDocSection[] { return commandCategories.map((category) => ({ title: category.title, - commands: category.commands.map((command) => ({ - command: command, - description: commandDescriptions[command], - bindings: defaultKeyBindingConfig.get(command) ?? [], - })), + commands: category.commands.map((command) => { + // For UNDO and REDO, we want to show all platform variants in the docs + if (command === Command.UNDO) { + return { + command: command, + description: commandDescriptions[command], + bindings: getMergedPlatformBindings(getPlatformUndoBindings), + }; + } + if (command === Command.REDO) { + return { + command: command, + description: commandDescriptions[command], + bindings: getMergedPlatformBindings(getPlatformRedoBindings), + }; + } + + return { + command: command, + description: commandDescriptions[command], + bindings: defaultKeyBindingConfig.get(command) ?? [], + }; + }), })); } +function getMergedPlatformBindings( + getBindings: (platform: string) => readonly KeyBinding[], +): readonly KeyBinding[] { + const win32 = getBindings('win32'); + const darwin = getBindings('darwin'); + const linux = getBindings('linux'); + + const all = [...win32, ...darwin, ...linux]; + const seen = new Set(); + const unique: KeyBinding[] = []; + + for (const b of all) { + const key = `${b.name}-${b.ctrl}-${b.shift}-${b.alt}-${b.cmd}`; + if (!seen.has(key)) { + seen.add(key); + unique.push(b); + } + } + + return unique; +} + export function renderDocumentation( sections: readonly KeybindingDocSection[], ): string {