diff --git a/packages/cli/src/ui/App.test.tsx b/packages/cli/src/ui/App.test.tsx index a2b9354e..e6de3137 100644 --- a/packages/cli/src/ui/App.test.tsx +++ b/packages/cli/src/ui/App.test.tsx @@ -1572,4 +1572,59 @@ describe('App UI', () => { expect(mockSettings.merged.debugKeystrokeLogging).toBeUndefined(); }); }); + + describe('Ctrl+C behavior', () => { + it('should call cancel but only clear the prompt when a tool is executing', async () => { + const mockCancel = vi.fn(); + + // Simulate a tool in the "Executing" state. + vi.mocked(useGeminiStream).mockReturnValue({ + streamingState: StreamingState.Responding, + submitQuery: vi.fn(), + initError: null, + pendingHistoryItems: [ + { + type: 'tool_group', + tools: [ + { + name: 'test_tool', + status: 'Executing', + result: '', + args: {}, + }, + ], + }, + ], + thought: null, + cancelOngoingRequest: mockCancel, + }); + + const { stdin, lastFrame, unmount } = renderWithProviders( + , + ); + currentUnmount = unmount; + + // Simulate user typing something into the prompt while a tool is running. + stdin.write('some text'); + await new Promise((resolve) => setTimeout(resolve, 100)); + + // Verify the text is in the prompt. + expect(lastFrame()).toContain('some text'); + + // Simulate Ctrl+C. + stdin.write('\x03'); + await new Promise((resolve) => setTimeout(resolve, 100)); + + // The main cancellation handler SHOULD be called. + expect(mockCancel).toHaveBeenCalled(); + + // The prompt should now be empty as a result of the cancellation handler's logic. + // We can't directly test the buffer's state, but we can see the rendered output. + expect(lastFrame()).not.toContain('some text'); + }); + }); }); diff --git a/packages/cli/src/ui/App.tsx b/packages/cli/src/ui/App.tsx index 4561853b..6972a88c 100644 --- a/packages/cli/src/ui/App.tsx +++ b/packages/cli/src/ui/App.tsx @@ -5,9 +5,22 @@ */ import { useCallback, useEffect, useMemo, useState, useRef } from 'react'; -import type { DOMElement } from 'ink'; -import { Box, measureElement, Static, Text, useStdin, useStdout } from 'ink'; -import { StreamingState, type HistoryItem, MessageType } from './types.js'; +import { + Box, + type DOMElement, + measureElement, + Static, + Text, + useStdin, + useStdout, +} from 'ink'; +import { + StreamingState, + type HistoryItem, + MessageType, + ToolCallStatus, + type HistoryItemWithoutId, +} from './types.js'; import { useTerminalSize } from './hooks/useTerminalSize.js'; import { useGeminiStream } from './hooks/useGeminiStream.js'; import { useLoadingIndicator } from './hooks/useLoadingIndicator.js'; @@ -102,6 +115,17 @@ interface AppProps { version: string; } +function isToolExecuting(pendingHistoryItems: HistoryItemWithoutId[]) { + return pendingHistoryItems.some((item) => { + if (item && item.type === 'tool_group') { + return item.tools.some( + (tool) => ToolCallStatus.Executing === tool.status, + ); + } + return false; + }); +} + export const AppWrapper = (props: AppProps) => { const kittyProtocolStatus = useKittyKeyboardProtocol(); return ( @@ -564,6 +588,11 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => { () => cancelHandlerRef.current(), ); + const pendingHistoryItems = useMemo( + () => [...pendingSlashCommandHistoryItems, ...pendingGeminiHistoryItems], + [pendingSlashCommandHistoryItems, pendingGeminiHistoryItems], + ); + // Message queue for handling input during streaming const { messageQueue, addMessage, clearQueue, getQueuedMessagesText } = useMessageQueue({ @@ -573,6 +602,11 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => { // Update the cancel handler with message queue support cancelHandlerRef.current = useCallback(() => { + if (isToolExecuting(pendingHistoryItems)) { + buffer.setText(''); // Just clear the prompt + return; + } + const lastUserMessage = userMessages.at(-1); let textToSet = lastUserMessage || ''; @@ -586,7 +620,13 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => { if (textToSet) { buffer.setText(textToSet); } - }, [buffer, userMessages, getQueuedMessagesText, clearQueue]); + }, [ + buffer, + userMessages, + getQueuedMessagesText, + clearQueue, + pendingHistoryItems, + ]); // Input handling - queue messages for processing const handleFinalSubmit = useCallback( @@ -622,8 +662,6 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => { ); const { handleInput: vimHandleInput } = useVim(buffer, handleFinalSubmit); - const pendingHistoryItems = [...pendingSlashCommandHistoryItems]; - pendingHistoryItems.push(...pendingGeminiHistoryItems); const { elapsedTime, currentLoadingPhrase } = useLoadingIndicator(streamingState);