feat(cli): support 'tab to queue' for messages while generating (#24052)

This commit is contained in:
Christian Gunderman
2026-03-28 01:31:11 +00:00
committed by GitHub
parent afc1d50c20
commit 07ab16dbbe
8 changed files with 119 additions and 6 deletions
+7 -6
View File
@@ -86,12 +86,13 @@ available combinations.
#### Text Input #### Text Input
| Command | Action | Keys | | Command | Action | Keys |
| -------------------------- | ---------------------------------------------------------- | ----------------------------------------------------------------------------------- | | -------------------------- | ------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- |
| `input.submit` | Submit the current prompt. | `Enter` | | `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.queueMessage` | Queue the current prompt to be processed after the current task finishes. | `Tab` |
| `input.openExternalEditor` | Open the current prompt or the plan in an external editor. | `Ctrl+X` | | `input.newline` | Insert a newline without submitting. | `Ctrl+Enter`<br />`Cmd/Win+Enter`<br />`Alt+Enter`<br />`Shift+Enter`<br />`Ctrl+J` |
| `input.paste` | Paste from the clipboard. | `Ctrl+V`<br />`Cmd/Win+V`<br />`Alt+V` | | `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` |
#### App Controls #### App Controls
+1
View File
@@ -568,6 +568,7 @@ const mockUIActions: UIActions = {
handleOverageMenuChoice: vi.fn(), handleOverageMenuChoice: vi.fn(),
handleEmptyWalletChoice: vi.fn(), handleEmptyWalletChoice: vi.fn(),
setQueueErrorMessage: vi.fn(), setQueueErrorMessage: vi.fn(),
addMessage: vi.fn(),
popAllMessages: vi.fn(), popAllMessages: vi.fn(),
handleApiKeySubmit: vi.fn(), handleApiKeySubmit: vi.fn(),
handleApiKeyCancel: vi.fn(), handleApiKeyCancel: vi.fn(),
+2
View File
@@ -2502,6 +2502,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
handleResumeSession, handleResumeSession,
handleDeleteSession, handleDeleteSession,
setQueueErrorMessage, setQueueErrorMessage,
addMessage,
popAllMessages, popAllMessages,
handleApiKeySubmit, handleApiKeySubmit,
handleApiKeyCancel, handleApiKeyCancel,
@@ -2593,6 +2594,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
handleResumeSession, handleResumeSession,
handleDeleteSession, handleDeleteSession,
setQueueErrorMessage, setQueueErrorMessage,
addMessage,
popAllMessages, popAllMessages,
handleApiKeySubmit, handleApiKeySubmit,
handleApiKeyCancel, handleApiKeyCancel,
@@ -152,6 +152,7 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
vimHandleInput={uiActions.vimHandleInput} vimHandleInput={uiActions.vimHandleInput}
isEmbeddedShellFocused={uiState.embeddedShellFocused} isEmbeddedShellFocused={uiState.embeddedShellFocused}
popAllMessages={uiActions.popAllMessages} popAllMessages={uiActions.popAllMessages}
onQueueMessage={uiActions.addMessage}
placeholder={ placeholder={
vimEnabled vimEnabled
? vimMode === 'INSERT' ? vimMode === 'INSERT'
@@ -191,6 +191,7 @@ describe('InputPrompt', () => {
setCleanUiDetailsVisible: mockSetCleanUiDetailsVisible, setCleanUiDetailsVisible: mockSetCleanUiDetailsVisible,
toggleCleanUiDetailsVisible: mockToggleCleanUiDetailsVisible, toggleCleanUiDetailsVisible: mockToggleCleanUiDetailsVisible,
revealCleanUiDetailsTemporarily: mockRevealCleanUiDetailsTemporarily, revealCleanUiDetailsTemporarily: mockRevealCleanUiDetailsTemporarily,
addMessage: vi.fn(),
}; };
beforeEach(() => { beforeEach(() => {
@@ -352,6 +353,8 @@ describe('InputPrompt', () => {
vi.mocked(clipboardy.read).mockResolvedValue(''); vi.mocked(clipboardy.read).mockResolvedValue('');
props = { props = {
onQueueMessage: vi.fn(),
buffer: mockBuffer, buffer: mockBuffer,
onSubmit: vi.fn(), onSubmit: vi.fn(),
userMessages: [], userMessages: [],
@@ -1099,6 +1102,76 @@ describe('InputPrompt', () => {
unmount(); 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(
<InputPrompt {...props} />,
{
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(
<InputPrompt {...props} />,
{
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(
<InputPrompt {...props} />,
{
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 () => { it('should not submit on Enter when the buffer is empty or only contains whitespace', async () => {
props.buffer.setText(' '); // Set buffer to whitespace props.buffer.setText(' '); // Set buffer to whitespace
@@ -117,6 +117,7 @@ export interface InputPromptProps {
setQueueErrorMessage: (message: string | null) => void; setQueueErrorMessage: (message: string | null) => void;
streamingState: StreamingState; streamingState: StreamingState;
popAllMessages?: () => string | undefined; popAllMessages?: () => string | undefined;
onQueueMessage?: (message: string) => void;
suggestionsPosition?: 'above' | 'below'; suggestionsPosition?: 'above' | 'below';
setBannerVisible: (visible: boolean) => void; setBannerVisible: (visible: boolean) => void;
copyModeEnabled?: boolean; copyModeEnabled?: boolean;
@@ -211,6 +212,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
setQueueErrorMessage, setQueueErrorMessage,
streamingState, streamingState,
popAllMessages, popAllMessages,
onQueueMessage,
suggestionsPosition = 'below', suggestionsPosition = 'below',
setBannerVisible, setBannerVisible,
copyModeEnabled = false, copyModeEnabled = false,
@@ -690,6 +692,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
streamingState === StreamingState.Responding || streamingState === StreamingState.Responding ||
streamingState === StreamingState.WaitingForConfirmation; streamingState === StreamingState.WaitingForConfirmation;
const isQueueMessageKey = keyMatchers[Command.QUEUE_MESSAGE](key);
const isPlainTab = const isPlainTab =
key.name === 'tab' && !key.shift && !key.alt && !key.ctrl && !key.cmd; key.name === 'tab' && !key.shift && !key.alt && !key.ctrl && !key.cmd;
const hasTabCompletionInteraction = const hasTabCompletionInteraction =
@@ -698,6 +701,29 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
reverseSearchActive || reverseSearchActive ||
commandSearchActive; 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) { if (isPlainTab && shellModeActive) {
resetPlainTabPress(); resetPlainTabPress();
if (!shouldShowSuggestions) { if (!shouldShowSuggestions) {
@@ -1293,6 +1319,9 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
shortcutsHelpVisible, shortcutsHelpVisible,
setShortcutsHelpVisible, setShortcutsHelpVisible,
tryLoadQueuedMessages, tryLoadQueuedMessages,
onQueueMessage,
setQueueErrorMessage,
resetReverseSearchCompletionState,
setBannerVisible, setBannerVisible,
activePtyId, activePtyId,
setEmbeddedShellFocused, setEmbeddedShellFocused,
@@ -70,6 +70,7 @@ export interface UIActions {
handleResumeSession: (session: SessionInfo) => Promise<void>; handleResumeSession: (session: SessionInfo) => Promise<void>;
handleDeleteSession: (session: SessionInfo) => Promise<void>; handleDeleteSession: (session: SessionInfo) => Promise<void>;
setQueueErrorMessage: (message: string | null) => void; setQueueErrorMessage: (message: string | null) => void;
addMessage: (message: string) => void;
popAllMessages: () => string | undefined; popAllMessages: () => string | undefined;
handleApiKeySubmit: (apiKey: string) => Promise<void>; handleApiKeySubmit: (apiKey: string) => Promise<void>;
handleApiKeyCancel: () => void; handleApiKeyCancel: () => void;
+5
View File
@@ -74,6 +74,7 @@ export enum Command {
// Text Input // Text Input
SUBMIT = 'input.submit', SUBMIT = 'input.submit',
QUEUE_MESSAGE = 'input.queueMessage',
NEWLINE = 'input.newline', NEWLINE = 'input.newline',
OPEN_EXTERNAL_EDITOR = 'input.openExternalEditor', OPEN_EXTERNAL_EDITOR = 'input.openExternalEditor',
PASTE_CLIPBOARD = 'input.paste', PASTE_CLIPBOARD = 'input.paste',
@@ -354,6 +355,7 @@ export const defaultKeyBindingConfig: KeyBindingConfig = new Map([
// Text Input // Text Input
// Must also exclude shift to allow shift+enter for newline // Must also exclude shift to allow shift+enter for newline
[Command.SUBMIT, [new KeyBinding('enter')]], [Command.SUBMIT, [new KeyBinding('enter')]],
[Command.QUEUE_MESSAGE, [new KeyBinding('tab')]],
[ [
Command.NEWLINE, Command.NEWLINE,
[ [
@@ -488,6 +490,7 @@ export const commandCategories: readonly CommandCategory[] = [
title: 'Text Input', title: 'Text Input',
commands: [ commands: [
Command.SUBMIT, Command.SUBMIT,
Command.QUEUE_MESSAGE,
Command.NEWLINE, Command.NEWLINE,
Command.OPEN_EXTERNAL_EDITOR, Command.OPEN_EXTERNAL_EDITOR,
Command.PASTE_CLIPBOARD, Command.PASTE_CLIPBOARD,
@@ -593,6 +596,8 @@ export const commandDescriptions: Readonly<Record<Command, string>> = {
// Text Input // Text Input
[Command.SUBMIT]: 'Submit the current prompt.', [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.NEWLINE]: 'Insert a newline without submitting.',
[Command.OPEN_EXTERNAL_EDITOR]: [Command.OPEN_EXTERNAL_EDITOR]:
'Open the current prompt or the plan in an external editor.', 'Open the current prompt or the plan in an external editor.',