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