diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 9c546004c..53e1ea9e3 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -692,7 +692,6 @@ export const AppContainer = (props: AppContainerProps) => { terminalWidth, terminalHeight, handleVisionSwitchRequired, // onVisionSwitchRequired - embeddedShellFocused, ); // Track whether suggestions are visible for Tab key handling @@ -900,6 +899,8 @@ export const AppContainer = (props: AppContainerProps) => { const ctrlCTimerRef = useRef(null); const [ctrlDPressedOnce, setCtrlDPressedOnce] = useState(false); const ctrlDTimerRef = useRef(null); + const [escapePressedOnce, setEscapePressedOnce] = useState(false); + const escapeTimerRef = useRef(null); const [constrainHeight, setConstrainHeight] = useState(true); const [ideContextState, setIdeContextState] = useState< IdeContext | undefined @@ -1176,6 +1177,47 @@ export const AppContainer = (props: AppContainerProps) => { } handleExit(ctrlDPressedOnce, setCtrlDPressedOnce, ctrlDTimerRef); return; + } else if (keyMatchers[Command.ESCAPE](key)) { + // Escape key handling + // Skip if shell is focused (to allow shell's own escape handling) + if (embeddedShellFocused) { + return; + } + + // If input has content, use double-press to clear + if (buffer.text.length > 0) { + if (escapePressedOnce) { + // Second press: clear input, keep the flag to allow immediate cancel + buffer.setText(''); + return; + } + // First press: set flag and show prompt + setEscapePressedOnce(true); + escapeTimerRef.current = setTimeout(() => { + setEscapePressedOnce(false); + escapeTimerRef.current = null; + }, CTRL_EXIT_PROMPT_DURATION_MS); + return; + } + + // Input is empty, cancel request immediately (no double-press needed) + if (streamingState === StreamingState.Responding) { + if (escapeTimerRef.current) { + clearTimeout(escapeTimerRef.current); + escapeTimerRef.current = null; + } + cancelOngoingRequest?.(); + setEscapePressedOnce(false); + return; + } + + // No action available, reset the flag + if (escapeTimerRef.current) { + clearTimeout(escapeTimerRef.current); + escapeTimerRef.current = null; + } + setEscapePressedOnce(false); + return; } let enteringConstrainHeightMode = false; @@ -1220,10 +1262,15 @@ export const AppContainer = (props: AppContainerProps) => { ctrlCPressedOnce, setCtrlCPressedOnce, ctrlCTimerRef, - buffer.text.length, ctrlDPressedOnce, setCtrlDPressedOnce, ctrlDTimerRef, + escapePressedOnce, + setEscapePressedOnce, + escapeTimerRef, + streamingState, + cancelOngoingRequest, + buffer, handleSlashCommand, activePtyId, embeddedShellFocused, diff --git a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx index 45ead0f14..edf0e0576 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx +++ b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx @@ -9,7 +9,6 @@ import type { Mock, MockInstance } from 'vitest'; import { describe, it, expect, vi, beforeEach } from 'vitest'; import { renderHook, act, waitFor } from '@testing-library/react'; import { useGeminiStream } from './useGeminiStream.js'; -import { useKeypress } from './useKeypress.js'; import * as atCommandProcessor from './atCommandProcessor.js'; import type { TrackedToolCall, @@ -112,10 +111,6 @@ vi.mock('./useVisionAutoSwitch.js', () => ({ })), })); -vi.mock('./useKeypress.js', () => ({ - useKeypress: vi.fn(), -})); - vi.mock('./shellCommandProcessor.js', () => ({ useShellCommandProcessor: vi.fn().mockReturnValue({ handleShellCommand: vi.fn(), @@ -839,28 +834,8 @@ describe('useGeminiStream', () => { expect(result.current.streamingState).toBe(StreamingState.Responding); }); - describe('User Cancellation', () => { - let keypressCallback: (key: any) => void; - const mockUseKeypress = useKeypress as Mock; - - beforeEach(() => { - // Capture the callback passed to useKeypress - mockUseKeypress.mockImplementation((callback, options) => { - if (options.isActive) { - keypressCallback = callback; - } else { - keypressCallback = () => {}; - } - }); - }); - - const simulateEscapeKeyPress = () => { - act(() => { - keypressCallback({ name: 'escape' }); - }); - }; - - it('should cancel an in-progress stream when escape is pressed', async () => { + describe('Cancellation', () => { + it('should cancel an in-progress stream when cancelOngoingRequest is called', async () => { const mockStream = (async function* () { yield { type: 'content', value: 'Part 1' }; // Keep the stream open @@ -880,8 +855,10 @@ describe('useGeminiStream', () => { expect(result.current.streamingState).toBe(StreamingState.Responding); }); - // Simulate escape key press - simulateEscapeKeyPress(); + // Call cancelOngoingRequest directly + act(() => { + result.current.cancelOngoingRequest(); + }); // Verify cancellation message is added await waitFor(() => { @@ -898,7 +875,7 @@ describe('useGeminiStream', () => { expect(result.current.streamingState).toBe(StreamingState.Idle); }); - it('should call onCancelSubmit handler when escape is pressed', async () => { + it('should call onCancelSubmit handler when cancelOngoingRequest is called', async () => { const cancelSubmitSpy = vi.fn(); const mockStream = (async function* () { yield { type: 'content', value: 'Part 1' }; @@ -936,12 +913,14 @@ describe('useGeminiStream', () => { result.current.submitQuery('test query'); }); - simulateEscapeKeyPress(); + act(() => { + result.current.cancelOngoingRequest(); + }); expect(cancelSubmitSpy).toHaveBeenCalled(); }); - it('should call setShellInputFocused(false) when escape is pressed', async () => { + it('should call setShellInputFocused(false) when cancelOngoingRequest is called', async () => { const setShellInputFocusedSpy = vi.fn(); const mockStream = (async function* () { yield { type: 'content', value: 'Part 1' }; @@ -978,18 +957,22 @@ describe('useGeminiStream', () => { result.current.submitQuery('test query'); }); - simulateEscapeKeyPress(); + act(() => { + result.current.cancelOngoingRequest(); + }); expect(setShellInputFocusedSpy).toHaveBeenCalledWith(false); }); - it('should not do anything if escape is pressed when not responding', () => { + it('should not do anything if cancelOngoingRequest is called when not responding', () => { const { result } = renderTestHook(); expect(result.current.streamingState).toBe(StreamingState.Idle); - // Simulate escape key press - simulateEscapeKeyPress(); + // Call cancelOngoingRequest + act(() => { + result.current.cancelOngoingRequest(); + }); // No change should happen, no cancellation message expect(mockAddItem).not.toHaveBeenCalledWith( @@ -1024,7 +1007,9 @@ describe('useGeminiStream', () => { }); // Cancel the request - simulateEscapeKeyPress(); + act(() => { + result.current.cancelOngoingRequest(); + }); // Allow the stream to continue act(() => { @@ -1072,7 +1057,9 @@ describe('useGeminiStream', () => { expect(result.current.streamingState).toBe(StreamingState.Responding); // Try to cancel - simulateEscapeKeyPress(); + act(() => { + result.current.cancelOngoingRequest(); + }); // Nothing should happen because the state is not `Responding` expect(abortSpy).not.toHaveBeenCalled(); diff --git a/packages/cli/src/ui/hooks/useGeminiStream.ts b/packages/cli/src/ui/hooks/useGeminiStream.ts index 2c0144246..5bebbac7e 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.ts +++ b/packages/cli/src/ui/hooks/useGeminiStream.ts @@ -63,7 +63,6 @@ import { import { promises as fs } from 'node:fs'; import path from 'node:path'; import { useSessionStats } from '../contexts/SessionContext.js'; -import { useKeypress } from './useKeypress.js'; import type { LoadedSettings } from '../../config/settings.js'; import { t } from '../../i18n/index.js'; @@ -116,7 +115,6 @@ export const useGeminiStream = ( persistSessionModel?: string; showGuidance?: boolean; }>, - isShellFocused?: boolean, ) => { const [initError, setInitError] = useState(null); const abortControllerRef = useRef(null); @@ -385,15 +383,6 @@ export const useGeminiStream = ( getPromptCount, ]); - useKeypress( - (key) => { - if (key.name === 'escape' && !isShellFocused) { - cancelOngoingRequest(); - } - }, - { isActive: streamingState === StreamingState.Responding }, - ); - const prepareQueryForGemini = useCallback( async ( query: PartListUnion,