From fd0893c346929f6c2be1d06bafce30b1c1230ce2 Mon Sep 17 00:00:00 2001 From: Prasanna Pal Date: Thu, 26 Mar 2026 01:55:13 +0530 Subject: [PATCH] fix(ui): prevent escape key from cancelling requests in shell mode (#21245) --- .../src/ui/components/InputPrompt.test.tsx | 62 ++++++++++++++++++- .../cli/src/ui/components/InputPrompt.tsx | 16 ++--- .../src/ui/contexts/KeypressContext.test.tsx | 43 +++++++++++++ 3 files changed, 113 insertions(+), 8 deletions(-) diff --git a/packages/cli/src/ui/components/InputPrompt.test.tsx b/packages/cli/src/ui/components/InputPrompt.test.tsx index 330faec022..e9f4efcd8f 100644 --- a/packages/cli/src/ui/components/InputPrompt.test.tsx +++ b/packages/cli/src/ui/components/InputPrompt.test.tsx @@ -61,7 +61,7 @@ import type { UIState } from '../contexts/UIStateContext.js'; import { isLowColorDepth } from '../utils/terminalUtils.js'; import { cpLen } from '../utils/textUtils.js'; import { defaultKeyMatchers, Command } from '../key/keyMatchers.js'; -import type { Key } from '../hooks/useKeypress.js'; +import { useKeypress, type Key } from '../hooks/useKeypress.js'; import { appEvents, AppEvent, @@ -163,6 +163,18 @@ describe('InputPrompt', () => { let mockBuffer: TextBuffer; let mockCommandContext: CommandContext; + const GlobalEscapeHandler = ({ onEscape }: { onEscape: () => void }) => { + useKeypress( + (key) => { + if (key.name !== 'escape') return false; + onEscape(); + return true; + }, + { isActive: true, priority: false }, + ); + return null; + }; + const mockedUseShellHistory = vi.mocked(useShellHistory); const mockedUseCommandCompletion = vi.mocked(useCommandCompletion); const mockedUseInputHistory = vi.mocked(useInputHistory); @@ -2770,6 +2782,54 @@ describe('InputPrompt', () => { unmount(); }); + it('should not propagate ESC to global cancellation handler when shell mode is active (responding)', async () => { + props.shellModeActive = true; + props.streamingState = StreamingState.Responding; + const onGlobalEscape = vi.fn(); + + const { stdin, unmount } = await renderWithProviders( + <> + + + , + ); + + await act(async () => { + stdin.write('\x1B'); + vi.advanceTimersByTime(100); + }); + + await waitFor(() => { + expect(props.setShellModeActive).toHaveBeenCalledWith(false); + }); + expect(onGlobalEscape).not.toHaveBeenCalled(); + unmount(); + }); + + it('should allow ESC to reach global cancellation handler when responding and no overlay is active', async () => { + props.shellModeActive = false; + props.streamingState = StreamingState.Responding; + const onGlobalEscape = vi.fn(); + + const { stdin, unmount } = await renderWithProviders( + <> + + + , + ); + + await act(async () => { + stdin.write('\x1B'); + vi.advanceTimersByTime(100); + }); + + await waitFor(() => { + expect(onGlobalEscape).toHaveBeenCalledTimes(1); + }); + expect(props.setShellModeActive).not.toHaveBeenCalled(); + unmount(); + }); + it('should handle ESC when completion suggestions are showing', async () => { mockedUseCommandCompletion.mockReturnValue({ ...mockCommandCompletion, diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx index 35cf7ef656..e7c221579a 100644 --- a/packages/cli/src/ui/components/InputPrompt.tsx +++ b/packages/cli/src/ui/components/InputPrompt.tsx @@ -686,13 +686,9 @@ export const InputPrompt: React.FC = ({ return true; } - if ( - key.name === 'escape' && - (streamingState === StreamingState.Responding || - streamingState === StreamingState.WaitingForConfirmation) - ) { - return false; - } + const isGenerating = + streamingState === StreamingState.Responding || + streamingState === StreamingState.WaitingForConfirmation; const isPlainTab = key.name === 'tab' && !key.shift && !key.alt && !key.ctrl && !key.cmd; @@ -877,6 +873,12 @@ export const InputPrompt: React.FC = ({ return true; } + // If we're generating and no local overlay consumed Escape, let it + // propagate to the global cancellation handler. + if (isGenerating) { + return false; + } + handleEscPress(); return true; } diff --git a/packages/cli/src/ui/contexts/KeypressContext.test.tsx b/packages/cli/src/ui/contexts/KeypressContext.test.tsx index c2256ed5ae..e7d0406dd7 100644 --- a/packages/cli/src/ui/contexts/KeypressContext.test.tsx +++ b/packages/cli/src/ui/contexts/KeypressContext.test.tsx @@ -14,6 +14,7 @@ import { useKeypressContext, ESC_TIMEOUT, FAST_RETURN_TIMEOUT, + KeypressPriority, type Key, } from './KeypressContext.js'; import { terminalCapabilityManager } from '../utils/terminalCapabilityManager.js'; @@ -259,6 +260,48 @@ describe('KeypressContext', () => { ); }); + it('should stop propagation when a higher priority handler returns true', async () => { + const higherPriorityHandler = vi.fn(() => true); + const lowerPriorityHandler = vi.fn(); + const { result } = await renderHookWithProviders(() => + useKeypressContext(), + ); + + act(() => { + result.current.subscribe(higherPriorityHandler, KeypressPriority.High); + result.current.subscribe(lowerPriorityHandler, KeypressPriority.Normal); + }); + + act(() => stdin.write('\x1b[27u')); + + expect(higherPriorityHandler).toHaveBeenCalledWith( + expect.objectContaining({ name: 'escape' }), + ); + expect(lowerPriorityHandler).not.toHaveBeenCalled(); + }); + + it('should continue propagation when a higher priority handler does not consume the event', async () => { + const higherPriorityHandler = vi.fn(() => false); + const lowerPriorityHandler = vi.fn(); + const { result } = await renderHookWithProviders(() => + useKeypressContext(), + ); + + act(() => { + result.current.subscribe(higherPriorityHandler, KeypressPriority.High); + result.current.subscribe(lowerPriorityHandler, KeypressPriority.Normal); + }); + + act(() => stdin.write('\x1b[27u')); + + expect(higherPriorityHandler).toHaveBeenCalledWith( + expect.objectContaining({ name: 'escape' }), + ); + expect(lowerPriorityHandler).toHaveBeenCalledWith( + expect.objectContaining({ name: 'escape' }), + ); + }); + it('should handle double Escape', async () => { const keyHandler = vi.fn(); const { result } = await renderHookWithProviders(() =>