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.',