From 85b46a640a31f80683e248058153db0a21bc4e5f Mon Sep 17 00:00:00 2001 From: Coco Sheng Date: Wed, 29 Apr 2026 11:58:45 -0400 Subject: [PATCH] fix(cli): implement platform-specific undo/redo and smart bubbling --- docs/cli/tutorials/session-management.md | 2 +- docs/reference/commands.md | 9 +- docs/reference/keyboard-shortcuts.md | 4 +- .../ui/components/shared/text-buffer.test.ts | 175 ++++++++++++++++++ .../src/ui/components/shared/text-buffer.ts | 10 + packages/cli/src/ui/key/keyBindings.test.ts | 30 +++ packages/cli/src/ui/key/keyBindings.ts | 19 +- 7 files changed, 236 insertions(+), 13 deletions(-) diff --git a/docs/cli/tutorials/session-management.md b/docs/cli/tutorials/session-management.md index 3a0a6fae86..b85783c86a 100644 --- a/docs/cli/tutorials/session-management.md +++ b/docs/cli/tutorials/session-management.md @@ -63,7 +63,7 @@ gemini --delete-session 1 ## How to rewind time (Undo mistakes) -Gemini CLI's **Rewind** feature is like `Ctrl+Z` for your workflow. +Gemini CLI's **Rewind** feature is like Undo for your workflow. ### Scenario: Triggering rewind diff --git a/docs/reference/commands.md b/docs/reference/commands.md index 7651539cb2..33086647df 100644 --- a/docs/reference/commands.md +++ b/docs/reference/commands.md @@ -499,12 +499,13 @@ 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) 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 **Ctrl+y** (Windows), **Shift+Cmd+Z** (macOS), + or **Shift+Alt+Z** (Linux) 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..797e85870d 100644 --- a/docs/reference/keyboard-shortcuts.md +++ b/docs/reference/keyboard-shortcuts.md @@ -39,8 +39,8 @@ 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.redo` | Redo the most recent undone text edit. | `Ctrl+Shift+Z`
`Shift+Cmd/Win+Z`
`Alt+Shift+Z` | +| `edit.undo` | Undo the most recent text edit. | `Ctrl+Z` (Windows)
`Cmd+Z` (macOS)
`Alt+Z` (Linux) | +| `edit.redo` | Redo the most recent undone text edit. | `Ctrl+Y` (Windows)
`Ctrl+Shift+Z`
`Shift+Cmd+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..b28503caec 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,180 @@ 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); + }); + + 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: 'y', + ctrl: true, + shift: false, + alt: false, + cmd: false, + insertable: false, + sequence: '\x19', + } + : 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..ecac6117d0 100644 --- a/packages/cli/src/ui/key/keyBindings.test.ts +++ b/packages/cli/src/ui/key/keyBindings.test.ts @@ -108,6 +108,36 @@ 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); + } + }); + + it('should have platform-specific REDO bindings', () => { + const redoBindings = defaultKeyBindingConfig.get(Command.REDO); + if (process.platform === 'win32') { + expect(redoBindings?.[0].name).toBe('y'); + expect(redoBindings?.[0].ctrl).toBe(true); + } else if (process.platform === 'darwin') { + expect(redoBindings?.[0].name).toBe('z'); + expect(redoBindings?.[0].shift).toBe(true); + expect(redoBindings?.[0].cmd).toBe(true); + } else { + expect(redoBindings?.[0].name).toBe('z'); + expect(redoBindings?.[0].shift).toBe(true); + expect(redoBindings?.[0].alt).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..beeffb8001 100644 --- a/packages/cli/src/ui/key/keyBindings.ts +++ b/packages/cli/src/ui/key/keyBindings.ts @@ -312,14 +312,21 @@ 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.UNDO, + process.platform === 'win32' + ? [new KeyBinding('ctrl+z'), new KeyBinding('alt+z')] + : process.platform === 'darwin' + ? [new KeyBinding('cmd+z'), new KeyBinding('alt+z')] + : [new KeyBinding('alt+z'), new KeyBinding('cmd+z')], + ], [ Command.REDO, - [ - new KeyBinding('ctrl+shift+z'), - new KeyBinding('cmd+shift+z'), - new KeyBinding('alt+shift+z'), - ], + process.platform === 'win32' + ? [new KeyBinding('ctrl+y'), new KeyBinding('ctrl+shift+z')] + : process.platform === 'darwin' + ? [new KeyBinding('cmd+shift+z'), new KeyBinding('alt+shift+z')] + : [new KeyBinding('alt+shift+z'), new KeyBinding('cmd+shift+z')], ], // Scrolling