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 {