mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-24 03:54:43 -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
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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.',
|
||||||
|
|||||||
Reference in New Issue
Block a user