diff --git a/.gemini/skills/docs-writer/references/style-guide.md b/.gemini/skills/docs-writer/references/style-guide.md index d3d219378a..1a846ac6e1 100644 --- a/.gemini/skills/docs-writer/references/style-guide.md +++ b/.gemini/skills/docs-writer/references/style-guide.md @@ -33,8 +33,8 @@ a natural tone. - **Simple vocabulary:** Use common words. Define technical terms when necessary. -- **Conciseness:** Keep sentences short and focused, but don't omit - helpful information. +- **Conciseness:** Keep sentences short and focused, but don't omit helpful + information. - **"Please":** Avoid using the word "please." ## IV. Procedures and steps diff --git a/docs/cli/commands.md b/docs/cli/commands.md index ec9b2396fe..a5b2183206 100644 --- a/docs/cli/commands.md +++ b/docs/cli/commands.md @@ -288,12 +288,12 @@ please see the dedicated [Custom Commands documentation](./custom-commands.md). These shortcuts apply directly to the input prompt for text manipulation. - **Undo:** - - **Keyboard shortcut:** Press **Ctrl+z** to undo the last action in the input - prompt. + - **Keyboard shortcut:** Press **Cmd+z** or **Alt+z** to undo the last action + in the input prompt. - **Redo:** - - **Keyboard shortcut:** Press **Ctrl+Shift+Z** to redo the last undone action - in the input prompt. + - **Keyboard shortcut:** Press **Shift+Cmd+Z** or **Shift+Alt+Z** to redo the + last undone action in the input prompt. ## At commands (`@`) diff --git a/docs/cli/keyboard-shortcuts.md b/docs/cli/keyboard-shortcuts.md index aa2d8200fe..5cfa26cf92 100644 --- a/docs/cli/keyboard-shortcuts.md +++ b/docs/cli/keyboard-shortcuts.md @@ -30,17 +30,17 @@ available combinations. #### Editing -| Action | Keys | -| ------------------------------------------------ | --------------------------------------------------------- | -| Delete from the cursor to the end of the line. | `Ctrl + K` | -| Delete from the cursor to the start of the line. | `Ctrl + U` | -| Clear all text in the input field. | `Ctrl + C` | -| Delete the previous word. | `Ctrl + Backspace`
`Alt + Backspace`
`Ctrl + W` | -| Delete the next word. | `Ctrl + Delete`
`Alt + Delete` | -| Delete the character to the left. | `Backspace`
`Ctrl + H` | -| Delete the character to the right. | `Delete`
`Ctrl + D` | -| Undo the most recent text edit. | `Ctrl + Z (no Shift)` | -| Redo the most recent undone text edit. | `Shift + Ctrl + Z` | +| Action | Keys | +| ------------------------------------------------ | ---------------------------------------------------------------- | +| Delete from the cursor to the end of the line. | `Ctrl + K` | +| Delete from the cursor to the start of the line. | `Ctrl + U` | +| Clear all text in the input field. | `Ctrl + C` | +| Delete the previous word. | `Ctrl + Backspace`
`Alt + Backspace`
`Ctrl + W` | +| Delete the next word. | `Ctrl + Delete`
`Alt + Delete` | +| Delete the character to the left. | `Backspace`
`Ctrl + H` | +| Delete the character to the right. | `Delete`
`Ctrl + D` | +| Undo the most recent text edit. | `Cmd + Z (no Shift)`
`Alt + Z (no Shift)` | +| Redo the most recent undone text edit. | `Shift + Ctrl + Z`
`Shift + Cmd + Z`
`Shift + Alt + Z` | #### Scrolling @@ -110,6 +110,7 @@ available combinations. | Focus the Gemini input from the shell input. | `Tab` | | Clear the terminal screen and redraw the UI. | `Ctrl + L` | | Restart the application. | `R` | +| Suspend the application (not yet implemented). | `Ctrl + Z` | diff --git a/packages/cli/src/config/keyBindings.ts b/packages/cli/src/config/keyBindings.ts index 86b3580536..0d32ae2922 100644 --- a/packages/cli/src/config/keyBindings.ts +++ b/packages/cli/src/config/keyBindings.ts @@ -85,6 +85,7 @@ export enum Command { UNFOCUS_SHELL_INPUT = 'app.unfocusShellInput', CLEAR_SCREEN = 'app.clearScreen', RESTART_APP = 'app.restart', + SUSPEND_APP = 'app.suspend', } /** @@ -170,8 +171,15 @@ export const defaultKeyBindings: KeyBindingConfig = { ], [Command.DELETE_CHAR_LEFT]: [{ key: 'backspace' }, { key: 'h', ctrl: true }], [Command.DELETE_CHAR_RIGHT]: [{ key: 'delete' }, { key: 'd', ctrl: true }], - [Command.UNDO]: [{ key: 'z', shift: false, ctrl: true }], - [Command.REDO]: [{ key: 'z', shift: true, ctrl: true }], + [Command.UNDO]: [ + { key: 'z', cmd: true, shift: false }, + { key: 'z', alt: true, shift: false }, + ], + [Command.REDO]: [ + { key: 'z', ctrl: true, shift: true }, + { key: 'z', cmd: true, shift: true }, + { key: 'z', alt: true, shift: true }, + ], // Scrolling [Command.SCROLL_UP]: [{ key: 'up', shift: true }], @@ -265,6 +273,7 @@ export const defaultKeyBindings: KeyBindingConfig = { [Command.UNFOCUS_SHELL_INPUT]: [{ key: 'tab' }], [Command.CLEAR_SCREEN]: [{ key: 'l', ctrl: true }], [Command.RESTART_APP]: [{ key: 'r' }], + [Command.SUSPEND_APP]: [{ key: 'z', ctrl: true }], }; interface CommandCategory { @@ -374,6 +383,7 @@ export const commandCategories: readonly CommandCategory[] = [ Command.UNFOCUS_SHELL_INPUT, Command.CLEAR_SCREEN, Command.RESTART_APP, + Command.SUSPEND_APP, ], }, ]; @@ -464,4 +474,5 @@ export const commandDescriptions: Readonly> = { [Command.UNFOCUS_SHELL_INPUT]: 'Focus the Gemini input from the shell input.', [Command.CLEAR_SCREEN]: 'Clear the terminal screen and redraw the UI.', [Command.RESTART_APP]: 'Restart the application.', + [Command.SUSPEND_APP]: 'Suspend the application (not yet implemented).', }; diff --git a/packages/cli/src/ui/AppContainer.test.tsx b/packages/cli/src/ui/AppContainer.test.tsx index b580f2fed6..baeace8f65 100644 --- a/packages/cli/src/ui/AppContainer.test.tsx +++ b/packages/cli/src/ui/AppContainer.test.tsx @@ -193,6 +193,7 @@ import { disableMouseEvents, } from '@google/gemini-cli-core'; import { type ExtensionManager } from '../config/extension-manager.js'; +import { WARNING_PROMPT_DURATION_MS } from './constants.js'; describe('AppContainer State Management', () => { let mockConfig: Config; @@ -1900,7 +1901,7 @@ describe('AppContainer State Management', () => { // Advance timer past the reset threshold act(() => { - vi.advanceTimersByTime(1001); + vi.advanceTimersByTime(WARNING_PROMPT_DURATION_MS + 1); }); pressKey({ name: 'c', ctrl: true }); @@ -1945,7 +1946,7 @@ describe('AppContainer State Management', () => { // Advance timer past the reset threshold act(() => { - vi.advanceTimersByTime(1001); + vi.advanceTimersByTime(WARNING_PROMPT_DURATION_MS + 1); }); pressKey({ name: 'd', ctrl: true }); diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 507837be87..8c4141a66a 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -1443,6 +1443,9 @@ Logging in with Google... Restarting Gemini CLI to continue. if (keyMatchers[Command.SHOW_ERROR_DETAILS](key)) { setShowErrorDetails((prev) => !prev); return true; + } else if (keyMatchers[Command.SUSPEND_APP](key)) { + handleWarning('Undo has been moved to Cmd + Z or Alt/Opt + Z'); + return true; } else if (keyMatchers[Command.SHOW_FULL_TODOS](key)) { setShowFullTodos((prev) => !prev); return true; diff --git a/packages/cli/src/ui/constants.ts b/packages/cli/src/ui/constants.ts index 72db069e58..496217fe9e 100644 --- a/packages/cli/src/ui/constants.ts +++ b/packages/cli/src/ui/constants.ts @@ -29,7 +29,7 @@ export const TOOL_STATUS = { // Maximum number of MCP resources to display per server before truncating export const MAX_MCP_RESOURCES_TO_SHOW = 10; -export const WARNING_PROMPT_DURATION_MS = 1000; +export const WARNING_PROMPT_DURATION_MS = 3000; export const QUEUE_ERROR_DISPLAY_DURATION_MS = 3000; export const SHELL_ACTION_REQUIRED_TITLE_DELAY_MS = 30000; export const SHELL_SILENT_WORKING_TITLE_DELAY_MS = 120000; diff --git a/packages/cli/src/ui/constants/tips.ts b/packages/cli/src/ui/constants/tips.ts index 1ff19af199..1bd6f3233d 100644 --- a/packages/cli/src/ui/constants/tips.ts +++ b/packages/cli/src/ui/constants/tips.ts @@ -110,8 +110,8 @@ export const INFORMATIVE_TIPS = [ 'Delete from the cursor to the end of the line with Ctrl+K…', 'Clear the entire input prompt with a double-press of Esc…', 'Paste from your clipboard with Ctrl+V…', - 'Undo text edits in the input with Ctrl+Z…', - 'Redo undone text edits with Ctrl+Shift+Z…', + 'Undo text edits in the input with Cmd+Z or Alt+Z…', + 'Redo undone text edits with Shift+Cmd+Z or Shift+Alt+Z…', 'Open the current prompt in an external editor with Ctrl+X…', 'In menus, move up/down with k/j or the arrow keys…', 'In menus, select an item by typing its number…', diff --git a/packages/cli/src/ui/contexts/KeypressContext.tsx b/packages/cli/src/ui/contexts/KeypressContext.tsx index 661d562f83..91c4eb3493 100644 --- a/packages/cli/src/ui/contexts/KeypressContext.tsx +++ b/packages/cli/src/ui/contexts/KeypressContext.tsx @@ -124,6 +124,8 @@ function charLengthAt(str: string, i: number): number { return code !== undefined && code >= kUTF16SurrogateThreshold ? 2 : 1; } +// Note: we do not convert alt+z, alt+shift+z, or alt+v here +// because mac users have alternative hotkeys. const MAC_ALT_KEY_CHARACTER_MAP: Record = { '\u222B': 'b', // "∫" back one word '\u0192': 'f', // "ƒ" forward one word diff --git a/packages/cli/src/ui/hooks/useHookDisplayState.test.ts b/packages/cli/src/ui/hooks/useHookDisplayState.test.ts index a6c86401bd..3f087771c8 100644 --- a/packages/cli/src/ui/hooks/useHookDisplayState.test.ts +++ b/packages/cli/src/ui/hooks/useHookDisplayState.test.ts @@ -14,6 +14,7 @@ import { type HookEndPayload, } from '@google/gemini-cli-core'; import { act } from 'react'; +import { WARNING_PROMPT_DURATION_MS } from '../constants.js'; describe('useHookDisplayState', () => { beforeEach(() => { @@ -53,7 +54,7 @@ describe('useHookDisplayState', () => { }); }); - it('should remove a hook immediately if duration > 1s', () => { + it('should remove a hook immediately if duration > minimum duration', () => { const { result } = renderHook(() => useHookDisplayState()); const startPayload: HookStartPayload = { @@ -65,9 +66,9 @@ describe('useHookDisplayState', () => { coreEvents.emitHookStart(startPayload); }); - // Advance time by 1.1 seconds + // Advance time by slightly more than the minimum duration act(() => { - vi.advanceTimersByTime(1100); + vi.advanceTimersByTime(WARNING_PROMPT_DURATION_MS + 100); }); const endPayload: HookEndPayload = { @@ -83,7 +84,7 @@ describe('useHookDisplayState', () => { expect(result.current).toHaveLength(0); }); - it('should delay removal if duration < 1s', () => { + it('should delay removal if duration < minimum duration', () => { const { result } = renderHook(() => useHookDisplayState()); const startPayload: HookStartPayload = { @@ -113,9 +114,9 @@ describe('useHookDisplayState', () => { // Should still be present expect(result.current).toHaveLength(1); - // Advance remaining time (900ms needed, let's go 950ms) + // Advance remaining time + buffer act(() => { - vi.advanceTimersByTime(950); + vi.advanceTimersByTime(WARNING_PROMPT_DURATION_MS - 100 + 50); }); expect(result.current).toHaveLength(0); @@ -138,7 +139,7 @@ describe('useHookDisplayState', () => { expect(result.current).toHaveLength(2); - // End h1 (total time 500ms -> needs 500ms delay) + // End h1 (total time 500ms -> needs remaining delay) act(() => { coreEvents.emitHookEnd({ hookName: 'h1', @@ -150,15 +151,24 @@ describe('useHookDisplayState', () => { // h1 still there expect(result.current).toHaveLength(2); - // Advance 600ms. h1 should disappear. h2 has been running for 600ms. + // Advance enough for h1 to expire. + // h1 ran for 500ms. Needs WARNING_PROMPT_DURATION_MS total. + // So advance WARNING_PROMPT_DURATION_MS - 500 + 100. + const advanceForH1 = WARNING_PROMPT_DURATION_MS - 500 + 100; act(() => { - vi.advanceTimersByTime(600); + vi.advanceTimersByTime(advanceForH1); }); + // h1 should disappear. h2 has been running for 500 (initial) + advanceForH1. expect(result.current).toHaveLength(1); expect(result.current[0].name).toBe('h2'); - // End h2 (total time 600ms -> needs 400ms delay) + // End h2. + // h2 duration so far: 0 (start) -> 500 (start h2) -> (end h1) -> advanceForH1. + // Actually h2 started at t=500. Current time is t=500 + advanceForH1. + // Duration = advanceForH1. + // advanceForH1 = 3000 - 500 + 100 = 2600. + // So h2 has run for 2600ms. Needs 400ms more. act(() => { coreEvents.emitHookEnd({ hookName: 'h2', @@ -169,6 +179,8 @@ describe('useHookDisplayState', () => { expect(result.current).toHaveLength(1); + // Advance remaining needed for h2 + buffer + // 3000 - 2600 = 400. act(() => { vi.advanceTimersByTime(500); }); @@ -199,34 +211,42 @@ describe('useHookDisplayState', () => { expect(result.current[0].name).toBe('same-hook'); expect(result.current[1].name).toBe('same-hook'); - // End Hook 1 at t=600 (Duration 600ms -> delay 400ms) + // End Hook 1 at t=600 (Duration 600ms -> delay needed) act(() => { vi.advanceTimersByTime(100); coreEvents.emitHookEnd({ ...hook, success: true }); }); - // Both still visible (Hook 1 pending removal in 400ms) + // Both still visible expect(result.current).toHaveLength(2); - // Advance 400ms (t=1000). Hook 1 should be removed. + // Advance to make Hook 1 expire. + // Hook 1 duration 600ms. Needs WARNING_PROMPT_DURATION_MS total. + // Needs WARNING_PROMPT_DURATION_MS - 600 more. + const advanceForHook1 = WARNING_PROMPT_DURATION_MS - 600; act(() => { - vi.advanceTimersByTime(400); + vi.advanceTimersByTime(advanceForHook1); }); expect(result.current).toHaveLength(1); - // End Hook 2 at t=1100 (Duration: 1100 - 500 = 600ms -> delay 400ms) + // End Hook 2. + // Hook 2 started at t=500. + // Current time: t = 600 (hook 1 end) + advanceForHook1 = 600 + 3000 - 600 = 3000. + // Hook 2 duration = 3000 - 500 = 2500ms. + // Needs 3000 - 2500 = 500ms more. act(() => { - vi.advanceTimersByTime(100); + vi.advanceTimersByTime(100); // just a small step before ending coreEvents.emitHookEnd({ ...hook, success: true }); }); - // Hook 2 still visible (pending removal in 400ms) + // Hook 2 still visible (pending removal) + // Total run time: 2500 + 100 = 2600ms. Needs 400ms. expect(result.current).toHaveLength(1); - // Advance 400ms (t=1500). Hook 2 should be removed. + // Advance remaining act(() => { - vi.advanceTimersByTime(400); + vi.advanceTimersByTime(500); }); expect(result.current).toHaveLength(0); diff --git a/packages/cli/src/ui/keyMatchers.test.ts b/packages/cli/src/ui/keyMatchers.test.ts index c330bf5d14..cf520b84e8 100644 --- a/packages/cli/src/ui/keyMatchers.test.ts +++ b/packages/cli/src/ui/keyMatchers.test.ts @@ -131,13 +131,24 @@ describe('keyMatchers', () => { }, { command: Command.UNDO, - positive: [createKey('z', { shift: false, ctrl: true })], - negative: [createKey('z'), createKey('z', { shift: true, ctrl: true })], + positive: [ + createKey('z', { shift: false, cmd: true }), + createKey('z', { shift: false, alt: true }), + ], + negative: [ + createKey('z'), + createKey('z', { shift: true, cmd: true }), + createKey('z', { shift: false, ctrl: true }), + ], }, { command: Command.REDO, - positive: [createKey('z', { shift: true, ctrl: true })], - negative: [createKey('z'), createKey('z', { shift: false, ctrl: true })], + positive: [ + 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 })], }, // Screen control