Skip to content

Commit 6d6ee7e

Browse files
scidominosidwan02
authored andcommitted
feat(cli): update undo/redo keybindings to Cmd+Z/Alt+Z and Shift+Cmd+Z/Shift+Alt+Z (google-gemini#17800)
1 parent 6f9c56d commit 6d6ee7e

File tree

11 files changed

+96
-47
lines changed

11 files changed

+96
-47
lines changed

.gemini/skills/docs-writer/references/style-guide.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,8 @@
3333
a natural tone.
3434
- **Simple vocabulary:** Use common words. Define technical terms when
3535
necessary.
36-
- **Conciseness:** Keep sentences short and focused, but don't omit
37-
helpful information.
36+
- **Conciseness:** Keep sentences short and focused, but don't omit helpful
37+
information.
3838
- **"Please":** Avoid using the word "please."
3939

4040
## IV. Procedures and steps

docs/cli/commands.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -288,12 +288,12 @@ please see the dedicated [Custom Commands documentation](./custom-commands.md).
288288
These shortcuts apply directly to the input prompt for text manipulation.
289289

290290
- **Undo:**
291-
- **Keyboard shortcut:** Press **Ctrl+z** to undo the last action in the input
292-
prompt.
291+
- **Keyboard shortcut:** Press **Cmd+z** or **Alt+z** to undo the last action
292+
in the input prompt.
293293

294294
- **Redo:**
295-
- **Keyboard shortcut:** Press **Ctrl+Shift+Z** to redo the last undone action
296-
in the input prompt.
295+
- **Keyboard shortcut:** Press **Shift+Cmd+Z** or **Shift+Alt+Z** to redo the
296+
last undone action in the input prompt.
297297

298298
## At commands (`@`)
299299

docs/cli/keyboard-shortcuts.md

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -30,17 +30,17 @@ available combinations.
3030

3131
#### Editing
3232

33-
| Action | Keys |
34-
| ------------------------------------------------ | --------------------------------------------------------- |
35-
| Delete from the cursor to the end of the line. | `Ctrl + K` |
36-
| Delete from the cursor to the start of the line. | `Ctrl + U` |
37-
| Clear all text in the input field. | `Ctrl + C` |
38-
| Delete the previous word. | `Ctrl + Backspace`<br />`Alt + Backspace`<br />`Ctrl + W` |
39-
| Delete the next word. | `Ctrl + Delete`<br />`Alt + Delete` |
40-
| Delete the character to the left. | `Backspace`<br />`Ctrl + H` |
41-
| Delete the character to the right. | `Delete`<br />`Ctrl + D` |
42-
| Undo the most recent text edit. | `Ctrl + Z (no Shift)` |
43-
| Redo the most recent undone text edit. | `Shift + Ctrl + Z` |
33+
| Action | Keys |
34+
| ------------------------------------------------ | ---------------------------------------------------------------- |
35+
| Delete from the cursor to the end of the line. | `Ctrl + K` |
36+
| Delete from the cursor to the start of the line. | `Ctrl + U` |
37+
| Clear all text in the input field. | `Ctrl + C` |
38+
| Delete the previous word. | `Ctrl + Backspace`<br />`Alt + Backspace`<br />`Ctrl + W` |
39+
| Delete the next word. | `Ctrl + Delete`<br />`Alt + Delete` |
40+
| Delete the character to the left. | `Backspace`<br />`Ctrl + H` |
41+
| Delete the character to the right. | `Delete`<br />`Ctrl + D` |
42+
| Undo the most recent text edit. | `Cmd + Z (no Shift)`<br />`Alt + Z (no Shift)` |
43+
| Redo the most recent undone text edit. | `Shift + Ctrl + Z`<br />`Shift + Cmd + Z`<br />`Shift + Alt + Z` |
4444

4545
#### Scrolling
4646

@@ -110,6 +110,7 @@ available combinations.
110110
| Focus the Gemini input from the shell input. | `Tab` |
111111
| Clear the terminal screen and redraw the UI. | `Ctrl + L` |
112112
| Restart the application. | `R` |
113+
| Suspend the application (not yet implemented). | `Ctrl + Z` |
113114

114115
<!-- KEYBINDINGS-AUTOGEN:END -->
115116

packages/cli/src/config/keyBindings.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ export enum Command {
8585
UNFOCUS_SHELL_INPUT = 'app.unfocusShellInput',
8686
CLEAR_SCREEN = 'app.clearScreen',
8787
RESTART_APP = 'app.restart',
88+
SUSPEND_APP = 'app.suspend',
8889
}
8990

9091
/**
@@ -170,8 +171,15 @@ export const defaultKeyBindings: KeyBindingConfig = {
170171
],
171172
[Command.DELETE_CHAR_LEFT]: [{ key: 'backspace' }, { key: 'h', ctrl: true }],
172173
[Command.DELETE_CHAR_RIGHT]: [{ key: 'delete' }, { key: 'd', ctrl: true }],
173-
[Command.UNDO]: [{ key: 'z', shift: false, ctrl: true }],
174-
[Command.REDO]: [{ key: 'z', shift: true, ctrl: true }],
174+
[Command.UNDO]: [
175+
{ key: 'z', cmd: true, shift: false },
176+
{ key: 'z', alt: true, shift: false },
177+
],
178+
[Command.REDO]: [
179+
{ key: 'z', ctrl: true, shift: true },
180+
{ key: 'z', cmd: true, shift: true },
181+
{ key: 'z', alt: true, shift: true },
182+
],
175183

176184
// Scrolling
177185
[Command.SCROLL_UP]: [{ key: 'up', shift: true }],
@@ -265,6 +273,7 @@ export const defaultKeyBindings: KeyBindingConfig = {
265273
[Command.UNFOCUS_SHELL_INPUT]: [{ key: 'tab' }],
266274
[Command.CLEAR_SCREEN]: [{ key: 'l', ctrl: true }],
267275
[Command.RESTART_APP]: [{ key: 'r' }],
276+
[Command.SUSPEND_APP]: [{ key: 'z', ctrl: true }],
268277
};
269278

270279
interface CommandCategory {
@@ -374,6 +383,7 @@ export const commandCategories: readonly CommandCategory[] = [
374383
Command.UNFOCUS_SHELL_INPUT,
375384
Command.CLEAR_SCREEN,
376385
Command.RESTART_APP,
386+
Command.SUSPEND_APP,
377387
],
378388
},
379389
];
@@ -464,4 +474,5 @@ export const commandDescriptions: Readonly<Record<Command, string>> = {
464474
[Command.UNFOCUS_SHELL_INPUT]: 'Focus the Gemini input from the shell input.',
465475
[Command.CLEAR_SCREEN]: 'Clear the terminal screen and redraw the UI.',
466476
[Command.RESTART_APP]: 'Restart the application.',
477+
[Command.SUSPEND_APP]: 'Suspend the application (not yet implemented).',
467478
};

packages/cli/src/ui/AppContainer.test.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,7 @@ import {
193193
disableMouseEvents,
194194
} from '@google/gemini-cli-core';
195195
import { type ExtensionManager } from '../config/extension-manager.js';
196+
import { WARNING_PROMPT_DURATION_MS } from './constants.js';
196197

197198
describe('AppContainer State Management', () => {
198199
let mockConfig: Config;
@@ -1900,7 +1901,7 @@ describe('AppContainer State Management', () => {
19001901

19011902
// Advance timer past the reset threshold
19021903
act(() => {
1903-
vi.advanceTimersByTime(1001);
1904+
vi.advanceTimersByTime(WARNING_PROMPT_DURATION_MS + 1);
19041905
});
19051906

19061907
pressKey({ name: 'c', ctrl: true });
@@ -1945,7 +1946,7 @@ describe('AppContainer State Management', () => {
19451946

19461947
// Advance timer past the reset threshold
19471948
act(() => {
1948-
vi.advanceTimersByTime(1001);
1949+
vi.advanceTimersByTime(WARNING_PROMPT_DURATION_MS + 1);
19491950
});
19501951

19511952
pressKey({ name: 'd', ctrl: true });

packages/cli/src/ui/AppContainer.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1443,6 +1443,9 @@ Logging in with Google... Restarting Gemini CLI to continue.
14431443
if (keyMatchers[Command.SHOW_ERROR_DETAILS](key)) {
14441444
setShowErrorDetails((prev) => !prev);
14451445
return true;
1446+
} else if (keyMatchers[Command.SUSPEND_APP](key)) {
1447+
handleWarning('Undo has been moved to Cmd + Z or Alt/Opt + Z');
1448+
return true;
14461449
} else if (keyMatchers[Command.SHOW_FULL_TODOS](key)) {
14471450
setShowFullTodos((prev) => !prev);
14481451
return true;

packages/cli/src/ui/constants.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ export const TOOL_STATUS = {
2929
// Maximum number of MCP resources to display per server before truncating
3030
export const MAX_MCP_RESOURCES_TO_SHOW = 10;
3131

32-
export const WARNING_PROMPT_DURATION_MS = 1000;
32+
export const WARNING_PROMPT_DURATION_MS = 3000;
3333
export const QUEUE_ERROR_DISPLAY_DURATION_MS = 3000;
3434
export const SHELL_ACTION_REQUIRED_TITLE_DELAY_MS = 30000;
3535
export const SHELL_SILENT_WORKING_TITLE_DELAY_MS = 120000;

packages/cli/src/ui/constants/tips.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -110,8 +110,8 @@ export const INFORMATIVE_TIPS = [
110110
'Delete from the cursor to the end of the line with Ctrl+K…',
111111
'Clear the entire input prompt with a double-press of Esc…',
112112
'Paste from your clipboard with Ctrl+V…',
113-
'Undo text edits in the input with Ctrl+Z…',
114-
'Redo undone text edits with Ctrl+Shift+Z…',
113+
'Undo text edits in the input with Cmd+Z or Alt+Z…',
114+
'Redo undone text edits with Shift+Cmd+Z or Shift+Alt+Z…',
115115
'Open the current prompt in an external editor with Ctrl+X…',
116116
'In menus, move up/down with k/j or the arrow keys…',
117117
'In menus, select an item by typing its number…',

packages/cli/src/ui/contexts/KeypressContext.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,8 @@ function charLengthAt(str: string, i: number): number {
124124
return code !== undefined && code >= kUTF16SurrogateThreshold ? 2 : 1;
125125
}
126126

127+
// Note: we do not convert alt+z, alt+shift+z, or alt+v here
128+
// because mac users have alternative hotkeys.
127129
const MAC_ALT_KEY_CHARACTER_MAP: Record<string, string> = {
128130
'\u222B': 'b', // "∫" back one word
129131
'\u0192': 'f', // "ƒ" forward one word

packages/cli/src/ui/hooks/useHookDisplayState.test.ts

Lines changed: 39 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
type HookEndPayload,
1515
} from '@google/gemini-cli-core';
1616
import { act } from 'react';
17+
import { WARNING_PROMPT_DURATION_MS } from '../constants.js';
1718

1819
describe('useHookDisplayState', () => {
1920
beforeEach(() => {
@@ -53,7 +54,7 @@ describe('useHookDisplayState', () => {
5354
});
5455
});
5556

56-
it('should remove a hook immediately if duration > 1s', () => {
57+
it('should remove a hook immediately if duration > minimum duration', () => {
5758
const { result } = renderHook(() => useHookDisplayState());
5859

5960
const startPayload: HookStartPayload = {
@@ -65,9 +66,9 @@ describe('useHookDisplayState', () => {
6566
coreEvents.emitHookStart(startPayload);
6667
});
6768

68-
// Advance time by 1.1 seconds
69+
// Advance time by slightly more than the minimum duration
6970
act(() => {
70-
vi.advanceTimersByTime(1100);
71+
vi.advanceTimersByTime(WARNING_PROMPT_DURATION_MS + 100);
7172
});
7273

7374
const endPayload: HookEndPayload = {
@@ -83,7 +84,7 @@ describe('useHookDisplayState', () => {
8384
expect(result.current).toHaveLength(0);
8485
});
8586

86-
it('should delay removal if duration < 1s', () => {
87+
it('should delay removal if duration < minimum duration', () => {
8788
const { result } = renderHook(() => useHookDisplayState());
8889

8990
const startPayload: HookStartPayload = {
@@ -113,9 +114,9 @@ describe('useHookDisplayState', () => {
113114
// Should still be present
114115
expect(result.current).toHaveLength(1);
115116

116-
// Advance remaining time (900ms needed, let's go 950ms)
117+
// Advance remaining time + buffer
117118
act(() => {
118-
vi.advanceTimersByTime(950);
119+
vi.advanceTimersByTime(WARNING_PROMPT_DURATION_MS - 100 + 50);
119120
});
120121

121122
expect(result.current).toHaveLength(0);
@@ -138,7 +139,7 @@ describe('useHookDisplayState', () => {
138139

139140
expect(result.current).toHaveLength(2);
140141

141-
// End h1 (total time 500ms -> needs 500ms delay)
142+
// End h1 (total time 500ms -> needs remaining delay)
142143
act(() => {
143144
coreEvents.emitHookEnd({
144145
hookName: 'h1',
@@ -150,15 +151,24 @@ describe('useHookDisplayState', () => {
150151
// h1 still there
151152
expect(result.current).toHaveLength(2);
152153

153-
// Advance 600ms. h1 should disappear. h2 has been running for 600ms.
154+
// Advance enough for h1 to expire.
155+
// h1 ran for 500ms. Needs WARNING_PROMPT_DURATION_MS total.
156+
// So advance WARNING_PROMPT_DURATION_MS - 500 + 100.
157+
const advanceForH1 = WARNING_PROMPT_DURATION_MS - 500 + 100;
154158
act(() => {
155-
vi.advanceTimersByTime(600);
159+
vi.advanceTimersByTime(advanceForH1);
156160
});
157161

162+
// h1 should disappear. h2 has been running for 500 (initial) + advanceForH1.
158163
expect(result.current).toHaveLength(1);
159164
expect(result.current[0].name).toBe('h2');
160165

161-
// End h2 (total time 600ms -> needs 400ms delay)
166+
// End h2.
167+
// h2 duration so far: 0 (start) -> 500 (start h2) -> (end h1) -> advanceForH1.
168+
// Actually h2 started at t=500. Current time is t=500 + advanceForH1.
169+
// Duration = advanceForH1.
170+
// advanceForH1 = 3000 - 500 + 100 = 2600.
171+
// So h2 has run for 2600ms. Needs 400ms more.
162172
act(() => {
163173
coreEvents.emitHookEnd({
164174
hookName: 'h2',
@@ -169,6 +179,8 @@ describe('useHookDisplayState', () => {
169179

170180
expect(result.current).toHaveLength(1);
171181

182+
// Advance remaining needed for h2 + buffer
183+
// 3000 - 2600 = 400.
172184
act(() => {
173185
vi.advanceTimersByTime(500);
174186
});
@@ -199,34 +211,42 @@ describe('useHookDisplayState', () => {
199211
expect(result.current[0].name).toBe('same-hook');
200212
expect(result.current[1].name).toBe('same-hook');
201213

202-
// End Hook 1 at t=600 (Duration 600ms -> delay 400ms)
214+
// End Hook 1 at t=600 (Duration 600ms -> delay needed)
203215
act(() => {
204216
vi.advanceTimersByTime(100);
205217
coreEvents.emitHookEnd({ ...hook, success: true });
206218
});
207219

208-
// Both still visible (Hook 1 pending removal in 400ms)
220+
// Both still visible
209221
expect(result.current).toHaveLength(2);
210222

211-
// Advance 400ms (t=1000). Hook 1 should be removed.
223+
// Advance to make Hook 1 expire.
224+
// Hook 1 duration 600ms. Needs WARNING_PROMPT_DURATION_MS total.
225+
// Needs WARNING_PROMPT_DURATION_MS - 600 more.
226+
const advanceForHook1 = WARNING_PROMPT_DURATION_MS - 600;
212227
act(() => {
213-
vi.advanceTimersByTime(400);
228+
vi.advanceTimersByTime(advanceForHook1);
214229
});
215230

216231
expect(result.current).toHaveLength(1);
217232

218-
// End Hook 2 at t=1100 (Duration: 1100 - 500 = 600ms -> delay 400ms)
233+
// End Hook 2.
234+
// Hook 2 started at t=500.
235+
// Current time: t = 600 (hook 1 end) + advanceForHook1 = 600 + 3000 - 600 = 3000.
236+
// Hook 2 duration = 3000 - 500 = 2500ms.
237+
// Needs 3000 - 2500 = 500ms more.
219238
act(() => {
220-
vi.advanceTimersByTime(100);
239+
vi.advanceTimersByTime(100); // just a small step before ending
221240
coreEvents.emitHookEnd({ ...hook, success: true });
222241
});
223242

224-
// Hook 2 still visible (pending removal in 400ms)
243+
// Hook 2 still visible (pending removal)
244+
// Total run time: 2500 + 100 = 2600ms. Needs 400ms.
225245
expect(result.current).toHaveLength(1);
226246

227-
// Advance 400ms (t=1500). Hook 2 should be removed.
247+
// Advance remaining
228248
act(() => {
229-
vi.advanceTimersByTime(400);
249+
vi.advanceTimersByTime(500);
230250
});
231251

232252
expect(result.current).toHaveLength(0);

0 commit comments

Comments
 (0)