Skip to content

Commit 07ab16d

Browse files
authored
feat(cli): support 'tab to queue' for messages while generating (#24052)
1 parent afc1d50 commit 07ab16d

8 files changed

Lines changed: 119 additions & 6 deletions

File tree

docs/reference/keyboard-shortcuts.md

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -86,12 +86,13 @@ available combinations.
8686

8787
#### Text Input
8888

89-
| Command | Action | Keys |
90-
| -------------------------- | ---------------------------------------------------------- | ----------------------------------------------------------------------------------- |
91-
| `input.submit` | Submit the current prompt. | `Enter` |
92-
| `input.newline` | Insert a newline without submitting. | `Ctrl+Enter`<br />`Cmd/Win+Enter`<br />`Alt+Enter`<br />`Shift+Enter`<br />`Ctrl+J` |
93-
| `input.openExternalEditor` | Open the current prompt or the plan in an external editor. | `Ctrl+X` |
94-
| `input.paste` | Paste from the clipboard. | `Ctrl+V`<br />`Cmd/Win+V`<br />`Alt+V` |
89+
| Command | Action | Keys |
90+
| -------------------------- | ------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- |
91+
| `input.submit` | Submit the current prompt. | `Enter` |
92+
| `input.queueMessage` | Queue the current prompt to be processed after the current task finishes. | `Tab` |
93+
| `input.newline` | Insert a newline without submitting. | `Ctrl+Enter`<br />`Cmd/Win+Enter`<br />`Alt+Enter`<br />`Shift+Enter`<br />`Ctrl+J` |
94+
| `input.openExternalEditor` | Open the current prompt or the plan in an external editor. | `Ctrl+X` |
95+
| `input.paste` | Paste from the clipboard. | `Ctrl+V`<br />`Cmd/Win+V`<br />`Alt+V` |
9596

9697
#### App Controls
9798

packages/cli/src/test-utils/render.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -568,6 +568,7 @@ const mockUIActions: UIActions = {
568568
handleOverageMenuChoice: vi.fn(),
569569
handleEmptyWalletChoice: vi.fn(),
570570
setQueueErrorMessage: vi.fn(),
571+
addMessage: vi.fn(),
571572
popAllMessages: vi.fn(),
572573
handleApiKeySubmit: vi.fn(),
573574
handleApiKeyCancel: vi.fn(),

packages/cli/src/ui/AppContainer.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2502,6 +2502,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
25022502
handleResumeSession,
25032503
handleDeleteSession,
25042504
setQueueErrorMessage,
2505+
addMessage,
25052506
popAllMessages,
25062507
handleApiKeySubmit,
25072508
handleApiKeyCancel,
@@ -2593,6 +2594,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
25932594
handleResumeSession,
25942595
handleDeleteSession,
25952596
setQueueErrorMessage,
2597+
addMessage,
25962598
popAllMessages,
25972599
handleApiKeySubmit,
25982600
handleApiKeyCancel,

packages/cli/src/ui/components/Composer.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,7 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
152152
vimHandleInput={uiActions.vimHandleInput}
153153
isEmbeddedShellFocused={uiState.embeddedShellFocused}
154154
popAllMessages={uiActions.popAllMessages}
155+
onQueueMessage={uiActions.addMessage}
155156
placeholder={
156157
vimEnabled
157158
? vimMode === 'INSERT'

packages/cli/src/ui/components/InputPrompt.test.tsx

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,7 @@ describe('InputPrompt', () => {
191191
setCleanUiDetailsVisible: mockSetCleanUiDetailsVisible,
192192
toggleCleanUiDetailsVisible: mockToggleCleanUiDetailsVisible,
193193
revealCleanUiDetailsTemporarily: mockRevealCleanUiDetailsTemporarily,
194+
addMessage: vi.fn(),
194195
};
195196

196197
beforeEach(() => {
@@ -352,6 +353,8 @@ describe('InputPrompt', () => {
352353
vi.mocked(clipboardy.read).mockResolvedValue('');
353354

354355
props = {
356+
onQueueMessage: vi.fn(),
357+
355358
buffer: mockBuffer,
356359
onSubmit: vi.fn(),
357360
userMessages: [],
@@ -1099,6 +1102,76 @@ describe('InputPrompt', () => {
10991102
unmount();
11001103
});
11011104

1105+
it('queues a message when Tab is pressed during generation', async () => {
1106+
props.buffer.setText('A new prompt');
1107+
props.streamingState = StreamingState.Responding;
1108+
1109+
const { stdin, unmount } = await renderWithProviders(
1110+
<InputPrompt {...props} />,
1111+
{
1112+
uiActions,
1113+
},
1114+
);
1115+
1116+
await act(async () => {
1117+
stdin.write('\t');
1118+
});
1119+
1120+
await waitFor(() => {
1121+
expect(props.onQueueMessage).toHaveBeenCalledWith('A new prompt');
1122+
expect(props.buffer.text).toBe('');
1123+
});
1124+
unmount();
1125+
});
1126+
1127+
it('shows an error when attempting to queue a slash command', async () => {
1128+
props.buffer.setText('/clear');
1129+
props.streamingState = StreamingState.Responding;
1130+
1131+
const { stdin, unmount } = await renderWithProviders(
1132+
<InputPrompt {...props} />,
1133+
{
1134+
uiActions,
1135+
},
1136+
);
1137+
1138+
await act(async () => {
1139+
stdin.write('\t');
1140+
});
1141+
1142+
await waitFor(() => {
1143+
expect(props.setQueueErrorMessage).toHaveBeenCalledWith(
1144+
'Slash commands cannot be queued',
1145+
);
1146+
expect(props.onQueueMessage).not.toHaveBeenCalled();
1147+
});
1148+
unmount();
1149+
});
1150+
1151+
it('shows an error when attempting to queue a shell command', async () => {
1152+
props.shellModeActive = true;
1153+
props.buffer.setText('ls');
1154+
props.streamingState = StreamingState.Responding;
1155+
1156+
const { stdin, unmount } = await renderWithProviders(
1157+
<InputPrompt {...props} />,
1158+
{
1159+
uiActions,
1160+
},
1161+
);
1162+
1163+
await act(async () => {
1164+
stdin.write('\t');
1165+
});
1166+
1167+
await waitFor(() => {
1168+
expect(props.setQueueErrorMessage).toHaveBeenCalledWith(
1169+
'Shell commands cannot be queued',
1170+
);
1171+
expect(props.onQueueMessage).not.toHaveBeenCalled();
1172+
});
1173+
unmount();
1174+
});
11021175
it('should not submit on Enter when the buffer is empty or only contains whitespace', async () => {
11031176
props.buffer.setText(' '); // Set buffer to whitespace
11041177

packages/cli/src/ui/components/InputPrompt.tsx

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,7 @@ export interface InputPromptProps {
117117
setQueueErrorMessage: (message: string | null) => void;
118118
streamingState: StreamingState;
119119
popAllMessages?: () => string | undefined;
120+
onQueueMessage?: (message: string) => void;
120121
suggestionsPosition?: 'above' | 'below';
121122
setBannerVisible: (visible: boolean) => void;
122123
copyModeEnabled?: boolean;
@@ -211,6 +212,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
211212
setQueueErrorMessage,
212213
streamingState,
213214
popAllMessages,
215+
onQueueMessage,
214216
suggestionsPosition = 'below',
215217
setBannerVisible,
216218
copyModeEnabled = false,
@@ -690,6 +692,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
690692
streamingState === StreamingState.Responding ||
691693
streamingState === StreamingState.WaitingForConfirmation;
692694

695+
const isQueueMessageKey = keyMatchers[Command.QUEUE_MESSAGE](key);
693696
const isPlainTab =
694697
key.name === 'tab' && !key.shift && !key.alt && !key.ctrl && !key.cmd;
695698
const hasTabCompletionInteraction =
@@ -698,6 +701,29 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
698701
reverseSearchActive ||
699702
commandSearchActive;
700703

704+
if (
705+
isGenerating &&
706+
isQueueMessageKey &&
707+
!hasTabCompletionInteraction &&
708+
buffer.text.trim().length > 0
709+
) {
710+
const trimmedMessage = buffer.text.trim();
711+
const isSlash = isSlashCommand(trimmedMessage);
712+
713+
if (isSlash || shellModeActive) {
714+
setQueueErrorMessage(
715+
`${shellModeActive ? 'Shell' : 'Slash'} commands cannot be queued`,
716+
);
717+
} else if (onQueueMessage) {
718+
onQueueMessage(buffer.text);
719+
buffer.setText('');
720+
resetCompletionState();
721+
resetReverseSearchCompletionState();
722+
}
723+
resetPlainTabPress();
724+
return true;
725+
}
726+
701727
if (isPlainTab && shellModeActive) {
702728
resetPlainTabPress();
703729
if (!shouldShowSuggestions) {
@@ -1293,6 +1319,9 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
12931319
shortcutsHelpVisible,
12941320
setShortcutsHelpVisible,
12951321
tryLoadQueuedMessages,
1322+
onQueueMessage,
1323+
setQueueErrorMessage,
1324+
resetReverseSearchCompletionState,
12961325
setBannerVisible,
12971326
activePtyId,
12981327
setEmbeddedShellFocused,

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ export interface UIActions {
7070
handleResumeSession: (session: SessionInfo) => Promise<void>;
7171
handleDeleteSession: (session: SessionInfo) => Promise<void>;
7272
setQueueErrorMessage: (message: string | null) => void;
73+
addMessage: (message: string) => void;
7374
popAllMessages: () => string | undefined;
7475
handleApiKeySubmit: (apiKey: string) => Promise<void>;
7576
handleApiKeyCancel: () => void;

packages/cli/src/ui/key/keyBindings.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ export enum Command {
7474

7575
// Text Input
7676
SUBMIT = 'input.submit',
77+
QUEUE_MESSAGE = 'input.queueMessage',
7778
NEWLINE = 'input.newline',
7879
OPEN_EXTERNAL_EDITOR = 'input.openExternalEditor',
7980
PASTE_CLIPBOARD = 'input.paste',
@@ -354,6 +355,7 @@ export const defaultKeyBindingConfig: KeyBindingConfig = new Map([
354355
// Text Input
355356
// Must also exclude shift to allow shift+enter for newline
356357
[Command.SUBMIT, [new KeyBinding('enter')]],
358+
[Command.QUEUE_MESSAGE, [new KeyBinding('tab')]],
357359
[
358360
Command.NEWLINE,
359361
[
@@ -488,6 +490,7 @@ export const commandCategories: readonly CommandCategory[] = [
488490
title: 'Text Input',
489491
commands: [
490492
Command.SUBMIT,
493+
Command.QUEUE_MESSAGE,
491494
Command.NEWLINE,
492495
Command.OPEN_EXTERNAL_EDITOR,
493496
Command.PASTE_CLIPBOARD,
@@ -593,6 +596,8 @@ export const commandDescriptions: Readonly<Record<Command, string>> = {
593596

594597
// Text Input
595598
[Command.SUBMIT]: 'Submit the current prompt.',
599+
[Command.QUEUE_MESSAGE]:
600+
'Queue the current prompt to be processed after the current task finishes.',
596601
[Command.NEWLINE]: 'Insert a newline without submitting.',
597602
[Command.OPEN_EXTERNAL_EDITOR]:
598603
'Open the current prompt or the plan in an external editor.',

0 commit comments

Comments
 (0)