mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-05-12 12:54:07 -07:00
feat(cli): support 'tab to queue' for messages while generating (#24052)
This commit is contained in:
committed by
GitHub
parent
afc1d50c20
commit
07ab16dbbe
@@ -87,8 +87,9 @@ available combinations.
|
||||
#### Text Input
|
||||
|
||||
| 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`<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` |
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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(
|
||||
<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 () => {
|
||||
props.buffer.setText(' '); // Set buffer to whitespace
|
||||
|
||||
|
||||
@@ -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<InputPromptProps> = ({
|
||||
setQueueErrorMessage,
|
||||
streamingState,
|
||||
popAllMessages,
|
||||
onQueueMessage,
|
||||
suggestionsPosition = 'below',
|
||||
setBannerVisible,
|
||||
copyModeEnabled = false,
|
||||
@@ -690,6 +692,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
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<InputPromptProps> = ({
|
||||
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<InputPromptProps> = ({
|
||||
shortcutsHelpVisible,
|
||||
setShortcutsHelpVisible,
|
||||
tryLoadQueuedMessages,
|
||||
onQueueMessage,
|
||||
setQueueErrorMessage,
|
||||
resetReverseSearchCompletionState,
|
||||
setBannerVisible,
|
||||
activePtyId,
|
||||
setEmbeddedShellFocused,
|
||||
|
||||
@@ -70,6 +70,7 @@ export interface UIActions {
|
||||
handleResumeSession: (session: SessionInfo) => Promise<void>;
|
||||
handleDeleteSession: (session: SessionInfo) => Promise<void>;
|
||||
setQueueErrorMessage: (message: string | null) => void;
|
||||
addMessage: (message: string) => void;
|
||||
popAllMessages: () => string | undefined;
|
||||
handleApiKeySubmit: (apiKey: string) => Promise<void>;
|
||||
handleApiKeyCancel: () => void;
|
||||
|
||||
@@ -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<Record<Command, string>> = {
|
||||
|
||||
// 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.',
|
||||
|
||||
Reference in New Issue
Block a user