diff --git a/packages/cli/src/ui/AppContainer.test.tsx b/packages/cli/src/ui/AppContainer.test.tsx
index 101725d0cc..88779bb760 100644
--- a/packages/cli/src/ui/AppContainer.test.tsx
+++ b/packages/cli/src/ui/AppContainer.test.tsx
@@ -956,6 +956,109 @@ describe('AppContainer State Management', () => {
});
});
+ describe('Queue Error Message', () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ it('should set and clear the queue error message after a timeout', async () => {
+ const { rerender } = render(
+ ,
+ );
+
+ expect(capturedUIState.queueErrorMessage).toBeNull();
+
+ capturedUIActions.setQueueErrorMessage('Test error');
+ rerender(
+ ,
+ );
+ expect(capturedUIState.queueErrorMessage).toBe('Test error');
+
+ vi.advanceTimersByTime(3000);
+ rerender(
+ ,
+ );
+ expect(capturedUIState.queueErrorMessage).toBeNull();
+ });
+
+ it('should reset the timer if a new error message is set', async () => {
+ const { rerender } = render(
+ ,
+ );
+
+ capturedUIActions.setQueueErrorMessage('First error');
+ rerender(
+ ,
+ );
+ expect(capturedUIState.queueErrorMessage).toBe('First error');
+
+ vi.advanceTimersByTime(1500);
+
+ capturedUIActions.setQueueErrorMessage('Second error');
+ rerender(
+ ,
+ );
+ expect(capturedUIState.queueErrorMessage).toBe('Second error');
+
+ vi.advanceTimersByTime(2000);
+ rerender(
+ ,
+ );
+ expect(capturedUIState.queueErrorMessage).toBe('Second error');
+
+ // 5. Advance time past the 3 second timeout from the second message
+ vi.advanceTimersByTime(1000);
+ rerender(
+ ,
+ );
+ expect(capturedUIState.queueErrorMessage).toBeNull();
+ });
+ });
+
describe('Terminal Height Calculation', () => {
const mockedMeasureElement = measureElement as Mock;
const mockedUseTerminalSize = useTerminalSize as Mock;
diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx
index 4eab2524f9..14101c352f 100644
--- a/packages/cli/src/ui/AppContainer.tsx
+++ b/packages/cli/src/ui/AppContainer.tsx
@@ -93,6 +93,7 @@ import { useExtensionUpdates } from './hooks/useExtensionUpdates.js';
import { ShellFocusContext } from './contexts/ShellFocusContext.js';
const CTRL_EXIT_PROMPT_DURATION_MS = 1000;
+const QUEUE_ERROR_DISPLAY_DURATION_MS = 3000;
function isToolExecuting(pendingHistoryItems: HistoryItemWithoutId[]) {
return pendingHistoryItems.some((item) => {
@@ -154,6 +155,10 @@ export const AppContainer = (props: AppContainerProps) => {
config.isTrustedFolder(),
);
+ const [queueErrorMessage, setQueueErrorMessage] = useState(
+ null,
+ );
+
const extensions = config.getExtensions();
const {
extensionsUpdateState,
@@ -815,6 +820,17 @@ Logging in with Google... Please restart Gemini CLI to continue.
}
}, [ideNeedsRestart]);
+ useEffect(() => {
+ if (queueErrorMessage) {
+ const timer = setTimeout(() => {
+ setQueueErrorMessage(null);
+ }, QUEUE_ERROR_DISPLAY_DURATION_MS);
+
+ return () => clearTimeout(timer);
+ }
+ return undefined;
+ }, [queueErrorMessage, setQueueErrorMessage]);
+
useEffect(() => {
if (isInitialMount.current) {
isInitialMount.current = false;
@@ -1131,6 +1147,7 @@ Logging in with Google... Please restart Gemini CLI to continue.
currentLoadingPhrase,
historyRemountKey,
messageQueue,
+ queueErrorMessage,
showAutoAcceptIndicator,
showWorkspaceMigrationDialog,
workspaceExtensions,
@@ -1212,6 +1229,7 @@ Logging in with Google... Please restart Gemini CLI to continue.
currentLoadingPhrase,
historyRemountKey,
messageQueue,
+ queueErrorMessage,
showAutoAcceptIndicator,
showWorkspaceMigrationDialog,
workspaceExtensions,
@@ -1276,6 +1294,7 @@ Logging in with Google... Please restart Gemini CLI to continue.
onWorkspaceMigrationDialogOpen,
onWorkspaceMigrationDialogClose,
handleProQuotaChoice,
+ setQueueErrorMessage,
}),
[
handleThemeSelect,
@@ -1301,6 +1320,7 @@ Logging in with Google... Please restart Gemini CLI to continue.
onWorkspaceMigrationDialogOpen,
onWorkspaceMigrationDialogClose,
handleProQuotaChoice,
+ setQueueErrorMessage,
],
);
diff --git a/packages/cli/src/ui/components/Composer.tsx b/packages/cli/src/ui/components/Composer.tsx
index d87737619a..0cd7f0ad98 100644
--- a/packages/cli/src/ui/components/Composer.tsx
+++ b/packages/cli/src/ui/components/Composer.tsx
@@ -89,6 +89,8 @@ export const Composer = () => {
) : uiState.showEscapePrompt ? (
Press Esc again to clear.
+ ) : uiState.queueErrorMessage ? (
+ {uiState.queueErrorMessage}
) : (
!settings.merged.ui?.hideContextSummary && (
{
? " Press 'i' for INSERT mode and 'Esc' for NORMAL mode."
: ' Type your message or @path/to/file'
}
+ setQueueErrorMessage={uiActions.setQueueErrorMessage}
+ streamingState={uiState.streamingState}
/>
)}
diff --git a/packages/cli/src/ui/components/InputPrompt.test.tsx b/packages/cli/src/ui/components/InputPrompt.test.tsx
index 4c4580b18a..c5021f68da 100644
--- a/packages/cli/src/ui/components/InputPrompt.test.tsx
+++ b/packages/cli/src/ui/components/InputPrompt.test.tsx
@@ -28,6 +28,7 @@ import { useKittyKeyboardProtocol } from '../hooks/useKittyKeyboardProtocol.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
import stripAnsi from 'strip-ansi';
import chalk from 'chalk';
+import { StreamingState } from '../types.js';
vi.mock('../hooks/useShellHistory.js');
vi.mock('../hooks/useCommandCompletion.js');
@@ -2167,7 +2168,58 @@ describe('InputPrompt', () => {
expect(mockBuffer.handleInput).toHaveBeenCalled();
unmount();
});
+ it('should prevent slash commands from being queued while streaming', async () => {
+ props.onSubmit = vi.fn();
+ props.buffer.text = '/help';
+ props.setQueueErrorMessage = vi.fn();
+ props.streamingState = StreamingState.Responding;
+ const { stdin, unmount } = renderWithProviders();
+ await wait();
+ stdin.write('/help');
+ stdin.write('\r');
+ await wait();
+
+ expect(props.onSubmit).not.toHaveBeenCalled();
+ expect(props.setQueueErrorMessage).toHaveBeenCalledWith(
+ 'Slash commands cannot be queued',
+ );
+ unmount();
+ });
+ it('should prevent shell commands from being queued while streaming', async () => {
+ props.onSubmit = vi.fn();
+ props.buffer.text = 'ls';
+ props.setQueueErrorMessage = vi.fn();
+ props.streamingState = StreamingState.Responding;
+ props.shellModeActive = true;
+ const { stdin, unmount } = renderWithProviders();
+ await wait();
+ stdin.write('ls');
+ stdin.write('\r');
+ await wait();
+
+ expect(props.onSubmit).not.toHaveBeenCalled();
+ expect(props.setQueueErrorMessage).toHaveBeenCalledWith(
+ 'Shell commands cannot be queued',
+ );
+ unmount();
+ });
+ it('should allow regular messages to be queued while streaming', async () => {
+ props.onSubmit = vi.fn();
+ props.buffer.text = 'regular message';
+ props.setQueueErrorMessage = vi.fn();
+ props.streamingState = StreamingState.Responding;
+ const { stdin, unmount } = renderWithProviders();
+ await wait();
+ stdin.write('regular message');
+ stdin.write('\r');
+ await wait();
+
+ expect(props.onSubmit).toHaveBeenCalledWith('regular message');
+ expect(props.setQueueErrorMessage).not.toHaveBeenCalled();
+ unmount();
+ });
});
+
function clean(str: string | undefined): string {
if (!str) return '';
// Remove ANSI escape codes and trim whitespace
diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx
index a4a5c35fe4..18d623fb58 100644
--- a/packages/cli/src/ui/components/InputPrompt.tsx
+++ b/packages/cli/src/ui/components/InputPrompt.tsx
@@ -38,6 +38,8 @@ import * as path from 'node:path';
import { SCREEN_READER_USER_PREFIX } from '../textConstants.js';
import { useShellFocusState } from '../contexts/ShellFocusContext.js';
import { useUIState } from '../contexts/UIStateContext.js';
+import { StreamingState } from '../types.js';
+import { isSlashCommand } from '../utils/commandUtils.js';
/**
* Returns if the terminal can be trusted to handle paste events atomically
@@ -71,6 +73,8 @@ export interface InputPromptProps {
onEscapePromptChange?: (showPrompt: boolean) => void;
vimHandleInput?: (key: Key) => boolean;
isEmbeddedShellFocused?: boolean;
+ setQueueErrorMessage: (message: string | null) => void;
+ streamingState: StreamingState;
}
// The input content, input container, and input suggestions list may have different widths
@@ -107,6 +111,8 @@ export const InputPrompt: React.FC = ({
onEscapePromptChange,
vimHandleInput,
isEmbeddedShellFocused,
+ setQueueErrorMessage,
+ streamingState,
}) => {
const kittyProtocol = useKittyKeyboardProtocol();
const isShellFocused = useShellFocusState();
@@ -221,6 +227,31 @@ export const InputPrompt: React.FC = ({
],
);
+ const handleSubmit = useCallback(
+ (submittedValue: string) => {
+ const trimmedMessage = submittedValue.trim();
+ const isSlash = isSlashCommand(trimmedMessage);
+
+ const isShell = shellModeActive;
+ if (
+ (isSlash || isShell) &&
+ streamingState === StreamingState.Responding
+ ) {
+ setQueueErrorMessage(
+ `${isShell ? 'Shell' : 'Slash'} commands cannot be queued`,
+ );
+ return;
+ }
+ handleSubmitAndClear(trimmedMessage);
+ },
+ [
+ handleSubmitAndClear,
+ shellModeActive,
+ streamingState,
+ setQueueErrorMessage,
+ ],
+ );
+
const customSetTextAndResetCompletionSignal = useCallback(
(newText: string) => {
buffer.setText(newText);
@@ -514,7 +545,7 @@ export const InputPrompt: React.FC = ({
// If the command is a perfect match, pressing enter should execute it.
if (completion.isPerfectMatch && keyMatchers[Command.RETURN](key)) {
- handleSubmitAndClear(buffer.text);
+ handleSubmit(buffer.text);
return;
}
@@ -625,7 +656,7 @@ export const InputPrompt: React.FC = ({
buffer.backspace();
buffer.newline();
} else {
- handleSubmitAndClear(buffer.text);
+ handleSubmit(buffer.text);
}
}
return;
@@ -706,6 +737,7 @@ export const InputPrompt: React.FC = ({
onClearScreen,
inputHistory,
handleSubmitAndClear,
+ handleSubmit,
shellHistory,
reverseSearchCompletion,
handleClipboardImage,
diff --git a/packages/cli/src/ui/contexts/UIActionsContext.tsx b/packages/cli/src/ui/contexts/UIActionsContext.tsx
index c9e7432fba..096128143f 100644
--- a/packages/cli/src/ui/contexts/UIActionsContext.tsx
+++ b/packages/cli/src/ui/contexts/UIActionsContext.tsx
@@ -45,6 +45,7 @@ export interface UIActions {
onWorkspaceMigrationDialogOpen: () => void;
onWorkspaceMigrationDialogClose: () => void;
handleProQuotaChoice: (choice: 'auth' | 'continue') => void;
+ setQueueErrorMessage: (message: string | null) => void;
}
export const UIActionsContext = createContext(null);
diff --git a/packages/cli/src/ui/contexts/UIStateContext.tsx b/packages/cli/src/ui/contexts/UIStateContext.tsx
index fcc86f02e2..76c0de298f 100644
--- a/packages/cli/src/ui/contexts/UIStateContext.tsx
+++ b/packages/cli/src/ui/contexts/UIStateContext.tsx
@@ -89,6 +89,7 @@ export interface UIState {
currentLoadingPhrase: string;
historyRemountKey: number;
messageQueue: string[];
+ queueErrorMessage: string | null;
showAutoAcceptIndicator: ApprovalMode;
showWorkspaceMigrationDialog: boolean;
// eslint-disable-next-line @typescript-eslint/no-explicit-any