diff --git a/docs/reference/keyboard-shortcuts.md b/docs/reference/keyboard-shortcuts.md
index 2ca7a6bb39..58edd797c6 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.queueMessage` | Queue the current prompt to be processed after the current task finishes. | `Tab` |
+| `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` |
#### App Controls
diff --git a/packages/cli/src/test-utils/render.tsx b/packages/cli/src/test-utils/render.tsx
index c4aec2e9cd..f4822c7158 100644
--- a/packages/cli/src/test-utils/render.tsx
+++ b/packages/cli/src/test-utils/render.tsx
@@ -568,6 +568,7 @@ const mockUIActions: UIActions = {
handleOverageMenuChoice: vi.fn(),
handleEmptyWalletChoice: vi.fn(),
setQueueErrorMessage: vi.fn(),
+ addMessage: vi.fn(),
popAllMessages: vi.fn(),
handleApiKeySubmit: vi.fn(),
handleApiKeyCancel: vi.fn(),
diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx
index d5b34915bc..3cde63a6e8 100644
--- a/packages/cli/src/ui/AppContainer.tsx
+++ b/packages/cli/src/ui/AppContainer.tsx
@@ -2502,6 +2502,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
handleResumeSession,
handleDeleteSession,
setQueueErrorMessage,
+ addMessage,
popAllMessages,
handleApiKeySubmit,
handleApiKeyCancel,
@@ -2593,6 +2594,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
handleResumeSession,
handleDeleteSession,
setQueueErrorMessage,
+ addMessage,
popAllMessages,
handleApiKeySubmit,
handleApiKeyCancel,
diff --git a/packages/cli/src/ui/components/Composer.tsx b/packages/cli/src/ui/components/Composer.tsx
index 5c9850bf92..590d1e9c6b 100644
--- a/packages/cli/src/ui/components/Composer.tsx
+++ b/packages/cli/src/ui/components/Composer.tsx
@@ -152,6 +152,7 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
vimHandleInput={uiActions.vimHandleInput}
isEmbeddedShellFocused={uiState.embeddedShellFocused}
popAllMessages={uiActions.popAllMessages}
+ onQueueMessage={uiActions.addMessage}
placeholder={
vimEnabled
? vimMode === 'INSERT'
diff --git a/packages/cli/src/ui/components/InputPrompt.test.tsx b/packages/cli/src/ui/components/InputPrompt.test.tsx
index e9f4efcd8f..626f4fa61e 100644
--- a/packages/cli/src/ui/components/InputPrompt.test.tsx
+++ b/packages/cli/src/ui/components/InputPrompt.test.tsx
@@ -191,6 +191,7 @@ describe('InputPrompt', () => {
setCleanUiDetailsVisible: mockSetCleanUiDetailsVisible,
toggleCleanUiDetailsVisible: mockToggleCleanUiDetailsVisible,
revealCleanUiDetailsTemporarily: mockRevealCleanUiDetailsTemporarily,
+ addMessage: vi.fn(),
};
beforeEach(() => {
@@ -352,6 +353,8 @@ describe('InputPrompt', () => {
vi.mocked(clipboardy.read).mockResolvedValue('');
props = {
+ onQueueMessage: vi.fn(),
+
buffer: mockBuffer,
onSubmit: vi.fn(),
userMessages: [],
@@ -1099,6 +1102,76 @@ describe('InputPrompt', () => {
unmount();
});
+ it('queues a message when Tab is pressed during generation', async () => {
+ props.buffer.setText('A new prompt');
+ props.streamingState = StreamingState.Responding;
+
+ const { stdin, unmount } = await renderWithProviders(
+ ,
+ {
+ uiActions,
+ },
+ );
+
+ await act(async () => {
+ stdin.write('\t');
+ });
+
+ await waitFor(() => {
+ expect(props.onQueueMessage).toHaveBeenCalledWith('A new prompt');
+ expect(props.buffer.text).toBe('');
+ });
+ unmount();
+ });
+
+ it('shows an error when attempting to queue a slash command', async () => {
+ props.buffer.setText('/clear');
+ props.streamingState = StreamingState.Responding;
+
+ const { stdin, unmount } = await renderWithProviders(
+ ,
+ {
+ uiActions,
+ },
+ );
+
+ await act(async () => {
+ stdin.write('\t');
+ });
+
+ await waitFor(() => {
+ expect(props.setQueueErrorMessage).toHaveBeenCalledWith(
+ 'Slash commands cannot be queued',
+ );
+ expect(props.onQueueMessage).not.toHaveBeenCalled();
+ });
+ unmount();
+ });
+
+ it('shows an error when attempting to queue a shell command', async () => {
+ props.shellModeActive = true;
+ props.buffer.setText('ls');
+ props.streamingState = StreamingState.Responding;
+
+ const { stdin, unmount } = await renderWithProviders(
+ ,
+ {
+ uiActions,
+ },
+ );
+
+ await act(async () => {
+ stdin.write('\t');
+ });
+
+ await waitFor(() => {
+ expect(props.setQueueErrorMessage).toHaveBeenCalledWith(
+ 'Shell commands cannot be queued',
+ );
+ expect(props.onQueueMessage).not.toHaveBeenCalled();
+ });
+ unmount();
+ });
it('should not submit on Enter when the buffer is empty or only contains whitespace', async () => {
props.buffer.setText(' '); // Set buffer to whitespace
diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx
index e7c221579a..b8dfaf3c0e 100644
--- a/packages/cli/src/ui/components/InputPrompt.tsx
+++ b/packages/cli/src/ui/components/InputPrompt.tsx
@@ -117,6 +117,7 @@ export interface InputPromptProps {
setQueueErrorMessage: (message: string | null) => void;
streamingState: StreamingState;
popAllMessages?: () => string | undefined;
+ onQueueMessage?: (message: string) => void;
suggestionsPosition?: 'above' | 'below';
setBannerVisible: (visible: boolean) => void;
copyModeEnabled?: boolean;
@@ -211,6 +212,7 @@ export const InputPrompt: React.FC = ({
setQueueErrorMessage,
streamingState,
popAllMessages,
+ onQueueMessage,
suggestionsPosition = 'below',
setBannerVisible,
copyModeEnabled = false,
@@ -690,6 +692,7 @@ export const InputPrompt: React.FC = ({
streamingState === StreamingState.Responding ||
streamingState === StreamingState.WaitingForConfirmation;
+ const isQueueMessageKey = keyMatchers[Command.QUEUE_MESSAGE](key);
const isPlainTab =
key.name === 'tab' && !key.shift && !key.alt && !key.ctrl && !key.cmd;
const hasTabCompletionInteraction =
@@ -698,6 +701,29 @@ export const InputPrompt: React.FC = ({
reverseSearchActive ||
commandSearchActive;
+ if (
+ isGenerating &&
+ isQueueMessageKey &&
+ !hasTabCompletionInteraction &&
+ buffer.text.trim().length > 0
+ ) {
+ const trimmedMessage = buffer.text.trim();
+ const isSlash = isSlashCommand(trimmedMessage);
+
+ if (isSlash || shellModeActive) {
+ setQueueErrorMessage(
+ `${shellModeActive ? 'Shell' : 'Slash'} commands cannot be queued`,
+ );
+ } else if (onQueueMessage) {
+ onQueueMessage(buffer.text);
+ buffer.setText('');
+ resetCompletionState();
+ resetReverseSearchCompletionState();
+ }
+ resetPlainTabPress();
+ return true;
+ }
+
if (isPlainTab && shellModeActive) {
resetPlainTabPress();
if (!shouldShowSuggestions) {
@@ -1293,6 +1319,9 @@ export const InputPrompt: React.FC = ({
shortcutsHelpVisible,
setShortcutsHelpVisible,
tryLoadQueuedMessages,
+ onQueueMessage,
+ setQueueErrorMessage,
+ resetReverseSearchCompletionState,
setBannerVisible,
activePtyId,
setEmbeddedShellFocused,
diff --git a/packages/cli/src/ui/contexts/UIActionsContext.tsx b/packages/cli/src/ui/contexts/UIActionsContext.tsx
index db9a51a269..9d83070e94 100644
--- a/packages/cli/src/ui/contexts/UIActionsContext.tsx
+++ b/packages/cli/src/ui/contexts/UIActionsContext.tsx
@@ -70,6 +70,7 @@ export interface UIActions {
handleResumeSession: (session: SessionInfo) => Promise;
handleDeleteSession: (session: SessionInfo) => Promise;
setQueueErrorMessage: (message: string | null) => void;
+ addMessage: (message: string) => void;
popAllMessages: () => string | undefined;
handleApiKeySubmit: (apiKey: string) => Promise;
handleApiKeyCancel: () => void;
diff --git a/packages/cli/src/ui/key/keyBindings.ts b/packages/cli/src/ui/key/keyBindings.ts
index c84f189664..ae5350e394 100644
--- a/packages/cli/src/ui/key/keyBindings.ts
+++ b/packages/cli/src/ui/key/keyBindings.ts
@@ -74,6 +74,7 @@ export enum Command {
// Text Input
SUBMIT = 'input.submit',
+ QUEUE_MESSAGE = 'input.queueMessage',
NEWLINE = 'input.newline',
OPEN_EXTERNAL_EDITOR = 'input.openExternalEditor',
PASTE_CLIPBOARD = 'input.paste',
@@ -354,6 +355,7 @@ export const defaultKeyBindingConfig: KeyBindingConfig = new Map([
// Text Input
// Must also exclude shift to allow shift+enter for newline
[Command.SUBMIT, [new KeyBinding('enter')]],
+ [Command.QUEUE_MESSAGE, [new KeyBinding('tab')]],
[
Command.NEWLINE,
[
@@ -488,6 +490,7 @@ export const commandCategories: readonly CommandCategory[] = [
title: 'Text Input',
commands: [
Command.SUBMIT,
+ Command.QUEUE_MESSAGE,
Command.NEWLINE,
Command.OPEN_EXTERNAL_EDITOR,
Command.PASTE_CLIPBOARD,
@@ -593,6 +596,8 @@ export const commandDescriptions: Readonly> = {
// Text Input
[Command.SUBMIT]: 'Submit the current prompt.',
+ [Command.QUEUE_MESSAGE]:
+ 'Queue the current prompt to be processed after the current task finishes.',
[Command.NEWLINE]: 'Insert a newline without submitting.',
[Command.OPEN_EXTERNAL_EDITOR]:
'Open the current prompt or the plan in an external editor.',