From 4f17eae5ccb43e3d87d663226b9afa26722660b7 Mon Sep 17 00:00:00 2001 From: Jainam M Date: Wed, 15 Oct 2025 22:32:50 +0530 Subject: [PATCH] feat(cli): Prevent queuing of slash and shell commands (#11094) Co-authored-by: Jacob Richman --- packages/cli/src/ui/AppContainer.test.tsx | 103 ++++++++++++++++++ packages/cli/src/ui/AppContainer.tsx | 20 ++++ packages/cli/src/ui/components/Composer.tsx | 4 + .../src/ui/components/InputPrompt.test.tsx | 52 +++++++++ .../cli/src/ui/components/InputPrompt.tsx | 36 +++++- .../cli/src/ui/contexts/UIActionsContext.tsx | 1 + .../cli/src/ui/contexts/UIStateContext.tsx | 1 + 7 files changed, 215 insertions(+), 2 deletions(-) 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