diff --git a/docs/reference/keyboard-shortcuts.md b/docs/reference/keyboard-shortcuts.md index 2ca7a6bb39..ede4470b54 100644 --- a/docs/reference/keyboard-shortcuts.md +++ b/docs/reference/keyboard-shortcuts.md @@ -86,12 +86,13 @@ available combinations. #### Text Input -| Command | Action | Keys | -| -------------------------- | ---------------------------------------------------------- | ----------------------------------------------------------------------------------- | -| `input.submit` | Submit the current prompt. | `Enter` | -| `input.newline` | Insert a newline without submitting. | `Ctrl+Enter`
`Cmd/Win+Enter`
`Alt+Enter`
`Shift+Enter`
`Ctrl+J` | -| `input.openExternalEditor` | Open the current prompt or the plan in an external editor. | `Ctrl+X` | -| `input.paste` | Paste from the clipboard. | `Ctrl+V`
`Cmd/Win+V`
`Alt+V` | +| Command | Action | Keys | +| -------------------------- | ----------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- | +| `input.submit` | Submit the current prompt. | `Enter` | +| `input.newline` | Insert a newline without submitting. | `Ctrl+Enter`
`Cmd/Win+Enter`
`Alt+Enter`
`Shift+Enter`
`Ctrl+J` | +| `input.openExternalEditor` | Open the current prompt or the plan in an external editor. | `Ctrl+X` | +| `input.paste` | Paste from the clipboard. | `Ctrl+V`
`Cmd/Win+V`
`Alt+V` | +| `input.stash` | Stash the current input to temporarily set it aside. Restores on next submit. | `Alt+S` | #### App Controls @@ -232,6 +233,8 @@ a `key` combination. - `Ctrl + X` (while a plan is presented): Open the plan in an external editor to [collaboratively edit or comment](../cli/plan-mode.md#collaborative-plan-editing) on the implementation strategy. +- `Alt+S`: Stash the current prompt to temporarily set it aside. The stashed + prompt is restored to the input box when you submit your next prompt. - `Double-click` on a paste placeholder (alternate buffer mode only): Expand to view full content inline. Double-click again to collapse. diff --git a/packages/cli/src/test-utils/render.tsx b/packages/cli/src/test-utils/render.tsx index 9dd0f96758..d93b7acc18 100644 --- a/packages/cli/src/test-utils/render.tsx +++ b/packages/cli/src/test-utils/render.tsx @@ -567,6 +567,8 @@ const mockUIActions: UIActions = { handleEmptyWalletChoice: vi.fn(), setQueueErrorMessage: vi.fn(), popAllMessages: vi.fn(), + stashPrompt: vi.fn(), + popStashedPrompt: vi.fn(), handleApiKeySubmit: vi.fn(), handleApiKeyCancel: vi.fn(), setBannerVisible: vi.fn(), diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index ce5fc7c872..267bffd8d8 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -253,6 +253,18 @@ export const AppContainer = (props: AppContainerProps) => { QUEUE_ERROR_DISPLAY_DURATION_MS, ); + const [stashedPrompt, setStashedPrompt] = useState(null); + const stashPrompt = useCallback((text: string) => { + if (text.length > 0) { + setStashedPrompt(text); + } + }, []); + const popStashedPrompt = useCallback(() => { + const prompt = stashedPrompt; + setStashedPrompt(null); + return prompt; + }, [stashedPrompt]); + const [newAgents, setNewAgents] = useState(null); const [constrainHeight, setConstrainHeight] = useState(true); const [expandHintTrigger, triggerExpandHint] = useTimedMessage( @@ -2267,6 +2279,7 @@ Logging in with Google... Restarting Gemini CLI to continue. activeHooks, messageQueue, queueErrorMessage, + stashedPrompt, showApprovalModeIndicator, allowPlanMode, currentModel, @@ -2393,6 +2406,7 @@ Logging in with Google... Restarting Gemini CLI to continue. activeHooks, messageQueue, queueErrorMessage, + stashedPrompt, showApprovalModeIndicator, allowPlanMode, userTier, @@ -2492,6 +2506,8 @@ Logging in with Google... Restarting Gemini CLI to continue. handleDeleteSession, setQueueErrorMessage, popAllMessages, + stashPrompt, + popStashedPrompt, handleApiKeySubmit, handleApiKeyCancel, setBannerVisible, @@ -2583,6 +2599,8 @@ Logging in with Google... Restarting Gemini CLI to continue. handleDeleteSession, setQueueErrorMessage, popAllMessages, + stashPrompt, + popStashedPrompt, handleApiKeySubmit, handleApiKeyCancel, setBannerVisible, diff --git a/packages/cli/src/ui/components/Composer.test.tsx b/packages/cli/src/ui/components/Composer.test.tsx index 1cbb29a06c..a9e1c9ab05 100644 --- a/packages/cli/src/ui/components/Composer.test.tsx +++ b/packages/cli/src/ui/components/Composer.test.tsx @@ -158,6 +158,7 @@ const createMockUIState = (overrides: Partial = {}): UIState => contextFileNames: [], showApprovalModeIndicator: ApprovalMode.DEFAULT, messageQueue: [], + stashedPrompt: null, showErrorDetails: false, constrainHeight: false, isInputActive: true, diff --git a/packages/cli/src/ui/components/Composer.tsx b/packages/cli/src/ui/components/Composer.tsx index af6d3b32da..a5db19061c 100644 --- a/packages/cli/src/ui/components/Composer.tsx +++ b/packages/cli/src/ui/components/Composer.tsx @@ -39,6 +39,7 @@ import { InputPrompt } from './InputPrompt.js'; import { Footer } from './Footer.js'; import { ShowMoreLines } from './ShowMoreLines.js'; import { QueuedMessageDisplay } from './QueuedMessageDisplay.js'; +import { StashedPromptDisplay } from './StashedPromptDisplay.js'; import { OverflowProvider } from '../contexts/OverflowContext.js'; import { ConfigInitDisplay } from './ConfigInitDisplay.js'; import { TodoTray } from './messages/Todo.js'; @@ -522,6 +523,10 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => { )} + {showUiDetails && ( + + )} + {showUiDetails && ( )} diff --git a/packages/cli/src/ui/components/Help.tsx b/packages/cli/src/ui/components/Help.tsx index 2569623c80..2543748e14 100644 --- a/packages/cli/src/ui/components/Help.tsx +++ b/packages/cli/src/ui/components/Help.tsx @@ -190,6 +190,12 @@ export const Help: React.FC = ({ commands }) => ( {' '} - Cycle through your prompt history + + + {formatCommand(Command.STASH_INPUT)} + {' '} + - Stash current prompt (restores on next submit) + For a full list of shortcuts, see{' '} diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx index 35cf7ef656..baa8257b96 100644 --- a/packages/cli/src/ui/components/InputPrompt.tsx +++ b/packages/cli/src/ui/components/InputPrompt.tsx @@ -225,6 +225,8 @@ export const InputPrompt: React.FC = ({ setEmbeddedShellFocused, setShortcutsHelpVisible, toggleCleanUiDetailsVisible, + stashPrompt, + popStashedPrompt, } = useUIActions(); const { terminalWidth, @@ -372,6 +374,12 @@ export const InputPrompt: React.FC = ({ onSubmit(processedValue); resetCompletionState(); resetReverseSearchCompletionState(); + + // Restore stashed prompt after submit + const stashed = popStashedPrompt(); + if (stashed) { + buffer.setText(stashed, 'end'); + } }, [ buffer, @@ -380,6 +388,7 @@ export const InputPrompt: React.FC = ({ shellModeActive, shellHistory, resetReverseSearchCompletionState, + popStashedPrompt, ], ); @@ -1213,6 +1222,16 @@ export const InputPrompt: React.FC = ({ return true; } + // Stash current input + if (keyMatchers[Command.STASH_INPUT](key)) { + if (buffer.text.length > 0) { + stashPrompt(buffer.text); + buffer.setText(''); + resetCompletionState(); + } + return true; + } + // External editor if (keyMatchers[Command.OPEN_EXTERNAL_EDITOR](key)) { // eslint-disable-next-line @typescript-eslint/no-floating-promises @@ -1304,6 +1323,7 @@ export const InputPrompt: React.FC = ({ shouldShowSuggestions, isShellSuggestionsVisible, forceShowShellSuggestions, + stashPrompt, keyMatchers, isHelpDismissKey, settings, diff --git a/packages/cli/src/ui/components/ShortcutsHelp.tsx b/packages/cli/src/ui/components/ShortcutsHelp.tsx index d94bf2b1d4..edc84cfb9d 100644 --- a/packages/cli/src/ui/components/ShortcutsHelp.tsx +++ b/packages/cli/src/ui/components/ShortcutsHelp.tsx @@ -44,6 +44,10 @@ const buildShortcutItems = (): ShortcutItem[] => [ key: formatCommand(Command.OPEN_EXTERNAL_EDITOR), description: 'open external editor', }, + { + key: formatCommand(Command.STASH_INPUT), + description: 'stash prompt', + }, ]; const Shortcut: React.FC<{ item: ShortcutItem }> = ({ item }) => ( @@ -73,8 +77,9 @@ export const ShortcutsHelp: React.FC = () => { items[7], items[2], items[8], - items[9], + items[10], items[3], + items[9], ]; return ( diff --git a/packages/cli/src/ui/components/StashedPromptDisplay.test.tsx b/packages/cli/src/ui/components/StashedPromptDisplay.test.tsx new file mode 100644 index 0000000000..b7f0bc32f8 --- /dev/null +++ b/packages/cli/src/ui/components/StashedPromptDisplay.test.tsx @@ -0,0 +1,41 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect } from 'vitest'; +import { render } from '../../test-utils/render.js'; +import { StashedPromptDisplay } from './StashedPromptDisplay.js'; + +describe('StashedPromptDisplay', () => { + it('renders nothing when no stash exists', async () => { + const { lastFrame, unmount } = await render( + , + ); + + expect(lastFrame({ allowEmpty: true })).toBe(''); + unmount(); + }); + + it('displays stash indicator when stash exists', async () => { + const { lastFrame, unmount } = await render( + , + ); + + const output = lastFrame(); + expect(output).toContain('Stashed (restores after submit)'); + unmount(); + }); + + it('does not display the stashed text content', async () => { + const { lastFrame, unmount } = await render( + , + ); + + const output = lastFrame(); + expect(output).toContain('Stashed'); + expect(output).not.toContain('secret stashed content'); + unmount(); + }); +}); diff --git a/packages/cli/src/ui/components/StashedPromptDisplay.tsx b/packages/cli/src/ui/components/StashedPromptDisplay.tsx new file mode 100644 index 0000000000..12baeca5bf --- /dev/null +++ b/packages/cli/src/ui/components/StashedPromptDisplay.tsx @@ -0,0 +1,25 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Box, Text } from 'ink'; + +export interface StashedPromptDisplayProps { + stashedPrompt: string | null; +} + +export const StashedPromptDisplay = ({ + stashedPrompt, +}: StashedPromptDisplayProps) => { + if (!stashedPrompt) { + return null; + } + + return ( + + Stashed (restores after submit) + + ); +}; diff --git a/packages/cli/src/ui/contexts/KeypressContext.tsx b/packages/cli/src/ui/contexts/KeypressContext.tsx index 3189172792..f6209a67d6 100644 --- a/packages/cli/src/ui/contexts/KeypressContext.tsx +++ b/packages/cli/src/ui/contexts/KeypressContext.tsx @@ -202,6 +202,7 @@ const MAC_ALT_KEY_CHARACTER_MAP: Record = { '\u222B': 'b', // "∫" back one word '\u0192': 'f', // "ƒ" forward one word '\u00B5': 'm', // "µ" toggle markup view + '\u00DF': 's', // "ß" stash prompt '\u03A9': 'z', // "Ω" Option+z '\u00B8': 'Z', // "¸" Option+Shift+z '\u2202': 'd', // "∂" delete word forward diff --git a/packages/cli/src/ui/contexts/UIActionsContext.tsx b/packages/cli/src/ui/contexts/UIActionsContext.tsx index db9a51a269..843b08815f 100644 --- a/packages/cli/src/ui/contexts/UIActionsContext.tsx +++ b/packages/cli/src/ui/contexts/UIActionsContext.tsx @@ -71,6 +71,8 @@ export interface UIActions { handleDeleteSession: (session: SessionInfo) => Promise; setQueueErrorMessage: (message: string | null) => void; popAllMessages: () => string | undefined; + stashPrompt: (text: string) => void; + popStashedPrompt: () => string | null; handleApiKeySubmit: (apiKey: string) => Promise; handleApiKeyCancel: () => void; setBannerVisible: (visible: boolean) => void; diff --git a/packages/cli/src/ui/contexts/UIStateContext.tsx b/packages/cli/src/ui/contexts/UIStateContext.tsx index e4d95a79af..75b8843c0d 100644 --- a/packages/cli/src/ui/contexts/UIStateContext.tsx +++ b/packages/cli/src/ui/contexts/UIStateContext.tsx @@ -172,6 +172,7 @@ export interface UIState { activeHooks: ActiveHook[]; messageQueue: string[]; queueErrorMessage: string | null; + stashedPrompt: string | null; showApprovalModeIndicator: ApprovalMode; allowPlanMode: boolean; // Quota-related state diff --git a/packages/cli/src/ui/key/keyBindings.ts b/packages/cli/src/ui/key/keyBindings.ts index c84f189664..79b19e1a94 100644 --- a/packages/cli/src/ui/key/keyBindings.ts +++ b/packages/cli/src/ui/key/keyBindings.ts @@ -77,6 +77,7 @@ export enum Command { NEWLINE = 'input.newline', OPEN_EXTERNAL_EDITOR = 'input.openExternalEditor', PASTE_CLIPBOARD = 'input.paste', + STASH_INPUT = 'input.stash', // App Controls SHOW_ERROR_DETAILS = 'app.showErrorDetails', @@ -374,6 +375,8 @@ export const defaultKeyBindingConfig: KeyBindingConfig = new Map([ ], ], + [Command.STASH_INPUT, [new KeyBinding('alt+s')]], + // App Controls [Command.SHOW_ERROR_DETAILS, [new KeyBinding('f12')]], [Command.SHOW_FULL_TODOS, [new KeyBinding('ctrl+t')]], @@ -491,6 +494,7 @@ export const commandCategories: readonly CommandCategory[] = [ Command.NEWLINE, Command.OPEN_EXTERNAL_EDITOR, Command.PASTE_CLIPBOARD, + Command.STASH_INPUT, ], }, { @@ -597,6 +601,8 @@ export const commandDescriptions: Readonly> = { [Command.OPEN_EXTERNAL_EDITOR]: 'Open the current prompt or the plan in an external editor.', [Command.PASTE_CLIPBOARD]: 'Paste from the clipboard.', + [Command.STASH_INPUT]: + 'Stash the current input to temporarily set it aside. Restores on next submit.', // App Controls [Command.SHOW_ERROR_DETAILS]: 'Toggle detailed error information.', diff --git a/prompt-stashing-spec.md b/prompt-stashing-spec.md new file mode 100644 index 0000000000..4c4f41c81b --- /dev/null +++ b/prompt-stashing-spec.md @@ -0,0 +1,206 @@ +# Prompt Stashing Feature Spec + +## Overview + +Prompt stashing allows a user to temporarily set aside the current contents of +the prompt input box, type and submit a different prompt, and then have the +stashed text automatically re-populated into the input box as soon as the new +prompt is submitted. + +This is useful when a user is composing a long or complex prompt and needs to +quickly ask the model something else (e.g., a clarifying question, a quick +lookup) without losing their in-progress work. + +## User Flow + +1. User is typing a prompt in the input box (e.g., "Refactor the auth module to + use...") +2. User presses **Alt+S** to stash the current input +3. The input box clears — ready for a new prompt +4. A **"Stashed"** indicator appears above the input (similar to the existing + "Queued" display) +5. User types and submits a new prompt (e.g., "What auth patterns does this + codebase use?") +6. **Immediately on submit**, the stashed text re-populates the input box +7. The "Stashed" indicator disappears +8. The model responds to the submitted prompt while the user can continue + editing their original prompt + +## Keybinding + +| Action | Shortcut | Command ID | +| ------------------- | --------- | ------------- | +| Stash current input | **Alt+S** | `input.stash` | + +- `Alt+S` is currently unbound — no conflicts +- Added to the `Command` enum as `STASH_INPUT = 'input.stash'` +- Added to `defaultKeyBindingConfig` with `new KeyBinding('alt+s')` +- Added to the `'Text Input'` command category and `commandDescriptions` +- User-customizable via `keybindings.json` like all other shortcuts + +## Behavior Rules + +### Stashing + +- **Alt+S with text in the input box**: Stashes the text. Input box clears. + Stash indicator appears. +- **Alt+S with empty input box**: No-op (nothing to stash), even if a stash + already exists. +- **Alt+S when a stash already exists and input has text**: Overwrites the + existing stash with the current input text. + +### Restoring + +- **On prompt submit**: When the user submits a prompt and a stash exists, the + stashed text is immediately restored into the input box via + `buffer.setText()`. Cursor is placed at the end of the restored text. This + happens as part of the submit flow, before the model begins responding. The + stash is consumed (cleared) — it only restores once. + +### Edge Cases + +- **User quits/restarts while stash exists**: Stash is lost (in-memory only, not + persisted). +- **Slash commands that open dialogs** (e.g. `/help`, `/settings`): The stash + restores on any submit, including these. The stash is consumed even if the + submission doesn't go to the model. +- **Shell mode**: Stashing works in shell mode too. +- **External editor (Ctrl+X)**: If the user opens the external editor while a + stash exists, the editor shows only the current (non-stashed) input. The stash + remains separately stored. +- **Ctrl+C to cancel a response**: The stash survives. Ctrl+C only cancels the + running response — the stash persists and restores on the next submit. +- **Queued messages + stash**: These are independent systems. If a prompt is + queued (submitted while model is responding), the stash still restores on that + submit. The queued message and the stash restore happen independently. + +## State Management + +### New Hook: `usePromptStash` + +Located at `packages/cli/src/ui/hooks/usePromptStash.ts`. + +```typescript +interface UsePromptStashReturn { + stashedPrompt: string | null; + stashPrompt: (text: string) => void; + popStashedPrompt: () => string | null; // returns & clears + hasStash: boolean; +} +``` + +- Simple `useState`-based hook — no persistence, no core dependency +- Exposed through `UIStateContext` (`stashedPrompt`) and `UIActionsContext` + (`stashPrompt`, `popStashedPrompt`) + +### Integration Points + +| File | Change | +| -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| **Code** | | +| `keyBindings.ts` | Add `STASH_INPUT` command, binding, category entry, description | +| `usePromptStash.ts` | New hook (state management) | +| `StashedPromptDisplay.tsx` | New component (UI display) | +| `InputPrompt.tsx` | Handle `Alt+S` keypress: call `stashPrompt(buffer.text)` then `buffer.setText('')`. On submit (in `handleSubmit`): if `hasStash`, call `popStashedPrompt()` and `buffer.setText(text)` after submission (cursor at end). | +| `Composer.tsx` | Render `StashedPromptDisplay` above `QueuedMessageDisplay` | +| `UIStateContext` / `UIActionsContext` | Expose stash state and actions | +| **UX / Docs** | | +| `ShortcutsHelp.tsx` | Add `Alt+S — Stash prompt` to the `?` panel | +| `Help.tsx` | Add `Alt+S — Stash current prompt` to `/help` keyboard shortcuts list | +| `docs/reference/keyboard-shortcuts.md` | Auto-regenerated via `npm run docs:keybindings`; also add manual entry in context-specific section | + +## Documentation Updates + +### Auto-Generated Keyboard Shortcuts Reference + +**File:** `docs/reference/keyboard-shortcuts.md` + +This file is auto-generated from `keyBindings.ts` via +`scripts/generate-keybindings-doc.ts`. After adding the new `STASH_INPUT` +command to `keyBindings.ts` (enum, config, category, description), run: + +``` +npm run docs:keybindings +``` + +This will regenerate the tables between `` and +`` markers, adding the new `Alt+S` shortcut +under the **Text Input** section automatically. + +### Shortcuts Help Panel (`?` key) + +**File:** `packages/cli/src/ui/components/ShortcutsHelp.tsx` + +Add `Alt+S — Stash prompt` to the curated shortcut list in +`buildShortcutItems()`. This is the quick-reference panel users see when +pressing `?` — it shows only the most essential shortcuts, and stashing is a key +workflow shortcut worth surfacing here. + +### /help Command Output + +**File:** `packages/cli/src/ui/components/Help.tsx` + +Add `Alt+S — Stash current prompt` to the keyboard shortcuts section (lines +~115-192). This component renders when users type `/help` and lists selected +important shortcuts. It also points users to the full reference at +`https://geminicli.com/docs/reference/keyboard-shortcuts/`. + +### Context-Specific Shortcuts Section + +**File:** `docs/reference/keyboard-shortcuts.md` (manual section, lines +~206-237) + +Add a brief entry under the context-specific shortcuts section explaining the +stash workflow: + +> **Alt+S** — Stash the current prompt to temporarily set it aside. The stashed +> prompt is restored to the input box when you submit your next prompt. + +## UX: Stashed Prompt Display + +A new `StashedPromptDisplay` component, rendered in `Composer.tsx` in the same +location as `QueuedMessageDisplay`, shown when a stash exists. + +### Visual Design + +``` + Stashed (restores after submit) +``` + +- Same dim styling as `QueuedMessageDisplay` (``) +- No preview of the stashed text — just the indicator line +- Rendered **above** `QueuedMessageDisplay` when both exist +- Only shown when `showUiDetails` is true (matching queued display behavior) + +### Layout in Composer.tsx + +``` + [StashedPromptDisplay] ← new, only if stash exists + [QueuedMessageDisplay] ← existing, only if queue non-empty + [ShortcutsHelp] ← existing, only if ? panel open + [InputPrompt] ← existing +``` + +## Testing + +### Unit Tests (`usePromptStash.test.ts`) + +- Stash stores text +- `popStashedPrompt` returns stashed text and clears state +- `hasStash` reflects current state accurately +- Stash with empty string is a no-op + +### Integration Tests (`InputPrompt.test.tsx`) + +- Alt+S with text clears input and stores stash +- Alt+S with empty input is a no-op +- Alt+S when stash already exists overwrites the stash +- Submitting a prompt while stash exists restores stashed text to input box +- Restored text has cursor at end + +### E2E Considerations + +- Stash → submit interrupting prompt → verify stashed text re-populates input + immediately on submit +- Stash → submit while model responding (queued) → verify stash still restores + on submit