feat(cli): implement prompt stashing with Alt+S

This commit is contained in:
Jack Wotherspoon
2026-03-24 23:09:00 -07:00
parent 0c919857fa
commit b101a83414
15 changed files with 349 additions and 7 deletions
+9 -6
View File
@@ -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`<br />`Cmd/Win+Enter`<br />`Alt+Enter`<br />`Shift+Enter`<br />`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`<br />`Cmd/Win+V`<br />`Alt+V` |
| Command | Action | Keys |
| -------------------------- | ----------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- |
| `input.submit` | Submit the current prompt. | `Enter` |
| `input.newline` | Insert a newline without submitting. | `Ctrl+Enter`<br />`Cmd/Win+Enter`<br />`Alt+Enter`<br />`Shift+Enter`<br />`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`<br />`Cmd/Win+V`<br />`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.
+2
View File
@@ -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(),
+18
View File
@@ -253,6 +253,18 @@ export const AppContainer = (props: AppContainerProps) => {
QUEUE_ERROR_DISPLAY_DURATION_MS,
);
const [stashedPrompt, setStashedPrompt] = useState<string | null>(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<AgentDefinition[] | null>(null);
const [constrainHeight, setConstrainHeight] = useState<boolean>(true);
const [expandHintTrigger, triggerExpandHint] = useTimedMessage<boolean>(
@@ -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,
@@ -158,6 +158,7 @@ const createMockUIState = (overrides: Partial<UIState> = {}): UIState =>
contextFileNames: [],
showApprovalModeIndicator: ApprovalMode.DEFAULT,
messageQueue: [],
stashedPrompt: null,
showErrorDetails: false,
constrainHeight: false,
isInputActive: true,
@@ -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 }) => {
<ConfigInitDisplay message="Resuming session..." />
)}
{showUiDetails && (
<StashedPromptDisplay stashedPrompt={uiState.stashedPrompt} />
)}
{showUiDetails && (
<QueuedMessageDisplay messageQueue={uiState.messageQueue} />
)}
+6
View File
@@ -190,6 +190,12 @@ export const Help: React.FC<Help> = ({ commands }) => (
</Text>{' '}
- Cycle through your prompt history
</Text>
<Text color={theme.text.primary}>
<Text bold color={theme.text.accent}>
{formatCommand(Command.STASH_INPUT)}
</Text>{' '}
- Stash current prompt (restores on next submit)
</Text>
<Box height={1} />
<Text color={theme.text.primary}>
For a full list of shortcuts, see{' '}
@@ -225,6 +225,8 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
setEmbeddedShellFocused,
setShortcutsHelpVisible,
toggleCleanUiDetailsVisible,
stashPrompt,
popStashedPrompt,
} = useUIActions();
const {
terminalWidth,
@@ -372,6 +374,12 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
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<InputPromptProps> = ({
shellModeActive,
shellHistory,
resetReverseSearchCompletionState,
popStashedPrompt,
],
);
@@ -1213,6 +1222,16 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
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<InputPromptProps> = ({
shouldShowSuggestions,
isShellSuggestionsVisible,
forceShowShellSuggestions,
stashPrompt,
keyMatchers,
isHelpDismissKey,
settings,
@@ -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 (
@@ -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(
<StashedPromptDisplay stashedPrompt={null} />,
);
expect(lastFrame({ allowEmpty: true })).toBe('');
unmount();
});
it('displays stash indicator when stash exists', async () => {
const { lastFrame, unmount } = await render(
<StashedPromptDisplay stashedPrompt="some stashed text" />,
);
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(
<StashedPromptDisplay stashedPrompt="secret stashed content" />,
);
const output = lastFrame();
expect(output).toContain('Stashed');
expect(output).not.toContain('secret stashed content');
unmount();
});
});
@@ -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 (
<Box paddingLeft={2} marginTop={1}>
<Text dimColor>Stashed (restores after submit)</Text>
</Box>
);
};
@@ -202,6 +202,7 @@ const MAC_ALT_KEY_CHARACTER_MAP: Record<string, string> = {
'\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
@@ -71,6 +71,8 @@ export interface UIActions {
handleDeleteSession: (session: SessionInfo) => Promise<void>;
setQueueErrorMessage: (message: string | null) => void;
popAllMessages: () => string | undefined;
stashPrompt: (text: string) => void;
popStashedPrompt: () => string | null;
handleApiKeySubmit: (apiKey: string) => Promise<void>;
handleApiKeyCancel: () => void;
setBannerVisible: (visible: boolean) => void;
@@ -172,6 +172,7 @@ export interface UIState {
activeHooks: ActiveHook[];
messageQueue: string[];
queueErrorMessage: string | null;
stashedPrompt: string | null;
showApprovalModeIndicator: ApprovalMode;
allowPlanMode: boolean;
// Quota-related state
+6
View File
@@ -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<Record<Command, string>> = {
[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.',
+206
View File
@@ -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 `<!-- KEYBINDINGS-AUTOGEN:START -->` and
`<!-- KEYBINDINGS-AUTOGEN:END -->` 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` (`<Text dimColor>`)
- 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