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(() =>