diff --git a/docs/users/reference/keyboard-shortcuts.md b/docs/users/reference/keyboard-shortcuts.md
index 2eccf2c8051..5a5c654fdc0 100644
--- a/docs/users/reference/keyboard-shortcuts.md
+++ b/docs/users/reference/keyboard-shortcuts.md
@@ -18,42 +18,42 @@ This document lists the available keyboard shortcuts in Qwen Code.
## Input Prompt
-| Shortcut | Description |
-| ----------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
-| `!` | Toggle shell mode when the input is empty. |
-| `?` | Toggle keyboard shortcuts display when the input is empty. |
-| `/` | Open slash-command completion. |
-| `@` | Open completion for files, folders, and other context. |
-| `Space` (empty prompt) | Start voice dictation when it and a voice model are configured; hold or tap behavior follows `general.voice.mode`. |
-| `Ctrl+Enter` / `Cmd+Enter` / `Shift+Enter` / `Ctrl+J` | Insert a newline. |
-| `Down Arrow` | Row down, then snap to end, then history next. |
-| `Enter` | Submit the current prompt. While a response is running, steer the current turn. |
-| `Ctrl+Q` | Queue the current prompt or command for the next turn instead of steering; it runs after Qwen Code returns to idle. |
-| `Up Arrow` (at the top) / `Esc` | When queued messages are present, move them back into the input for editing. |
-| `Meta+D` / `Meta+Delete` / `Ctrl+Delete` | Delete the word to the right of the cursor. |
-| `Tab` | Autocomplete the current suggestion if one exists. |
-| `Up Arrow` | Row up, then snap to start, then history prev. |
-| `Ctrl+A` / `Home` | Move the cursor to the beginning of the line. |
-| `Ctrl+B` / `Left Arrow` | Move the cursor one character to the left. |
-| `Ctrl+C` | Clear the input prompt |
-| `Esc` (double press) | Clear the input prompt. |
-| `Ctrl+D` / `Delete` | Delete the character to the right of the cursor. |
-| `Ctrl+E` / `End` | Move the cursor to the end of the line. |
-| `Ctrl+F` / `Right Arrow` | Move the cursor one character to the right. |
-| `Ctrl+H` / `Backspace` | Delete the character to the left of the cursor. |
-| `Ctrl+K` | Delete from the cursor to the end of the line. |
-| `Ctrl+Left Arrow` / `Meta+Left Arrow` / `Meta+B` | Move the cursor one word to the left. |
-| `Ctrl+N` | Row down, then snap to end, then history next. |
-| `Ctrl+P` | Row up, then snap to start, then history prev. |
-| `Ctrl+R` | Reverse search through input/shell history. |
-| `Ctrl+Y` | Retry the last failed request. |
-| `Ctrl+Right Arrow` / `Meta+Right Arrow` / `Meta+F` | Move the cursor one word to the right. |
-| `Ctrl+U` | Delete from the cursor to the beginning of the line. |
-| `Ctrl+V` / `Option+V` (Windows: `Alt+V`) | Paste clipboard content. If the clipboard contains an image, it will be saved and a reference to it will be inserted in the prompt. |
-| `Ctrl+W` / `Meta+Backspace` / `Ctrl+Backspace` | Delete the word to the left of the cursor. |
-| `Ctrl+X` | Open the current input in an external editor. |
-| `Ctrl+Z` | Undo the last input edit. |
-| `Ctrl+Shift+Z` | Redo the last undone input edit. |
+| Shortcut | Description |
+| ----------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `!` | Toggle shell mode when the input is empty. |
+| `?` | Toggle keyboard shortcuts display when the input is empty. |
+| `/` | Open slash-command completion. |
+| `@` | Open completion for files, folders, and other context. |
+| `Space` (empty prompt) | Start voice dictation when it and a voice model are configured; hold or tap behavior follows `general.voice.mode`. |
+| `Ctrl+Enter` / `Cmd+Enter` / `Shift+Enter` / `Ctrl+J` | Insert a newline. |
+| `Down Arrow` | Row down, then snap to end, then history next. |
+| `Enter` | Submit the current prompt. While a response is running, steer the current turn. |
+| `Ctrl+Q` | Queue the current prompt or command for the next turn instead of steering; it runs after Qwen Code returns to idle. |
+| `Up Arrow` (at the top) / `Esc` | When queued messages are present, move them back into the input for editing (`Up Arrow` at the top whenever the input is shown; `Esc` only when the agent is idle). While the agent is responding and the input is empty, `Esc` cancels the ongoing request instead (queued messages are then moved back into the input). |
+| `Meta+D` / `Meta+Delete` / `Ctrl+Delete` | Delete the word to the right of the cursor. |
+| `Tab` | Autocomplete the current suggestion if one exists. |
+| `Up Arrow` | Row up, then snap to start, then history prev. |
+| `Ctrl+A` / `Home` | Move the cursor to the beginning of the line. |
+| `Ctrl+B` / `Left Arrow` | Move the cursor one character to the left. |
+| `Ctrl+C` | Clear the input prompt |
+| `Esc` (double press) | Clear the input prompt. |
+| `Ctrl+D` / `Delete` | Delete the character to the right of the cursor. |
+| `Ctrl+E` / `End` | Move the cursor to the end of the line. |
+| `Ctrl+F` / `Right Arrow` | Move the cursor one character to the right. |
+| `Ctrl+H` / `Backspace` | Delete the character to the left of the cursor. |
+| `Ctrl+K` | Delete from the cursor to the end of the line. |
+| `Ctrl+Left Arrow` / `Meta+Left Arrow` / `Meta+B` | Move the cursor one word to the left. |
+| `Ctrl+N` | Row down, then snap to end, then history next. |
+| `Ctrl+P` | Row up, then snap to start, then history prev. |
+| `Ctrl+R` | Reverse search through input/shell history. |
+| `Ctrl+Y` | Retry the last failed request. |
+| `Ctrl+Right Arrow` / `Meta+Right Arrow` / `Meta+F` | Move the cursor one word to the right. |
+| `Ctrl+U` | Delete from the cursor to the beginning of the line. |
+| `Ctrl+V` / `Option+V` (Windows: `Alt+V`) | Paste clipboard content. If the clipboard contains an image, it will be saved and a reference to it will be inserted in the prompt. |
+| `Ctrl+W` / `Meta+Backspace` / `Ctrl+Backspace` | Delete the word to the left of the cursor. |
+| `Ctrl+X` | Open the current input in an external editor. |
+| `Ctrl+Z` | Undo the last input edit. |
+| `Ctrl+Shift+Z` | Redo the last undone input edit. |
## Foreground Shell
diff --git a/packages/cli/src/ui/AppContainer.test.tsx b/packages/cli/src/ui/AppContainer.test.tsx
index 6a66052271a..7ea92e0d8de 100644
--- a/packages/cli/src/ui/AppContainer.test.tsx
+++ b/packages/cli/src/ui/AppContainer.test.tsx
@@ -232,6 +232,20 @@ describe('AppContainer State Management', () => {
let restoreCiEnv = () => {};
const mockedRestorePromptStash = vi.mocked(restorePromptStash);
+ // Shared helper to extract AppContainer's global keypress handler
+ // (handleGlobalKeypress) from the useKeypress mock. The handler stringifies
+ // to include TOGGLE_THINKING_EXPANDED, so that token is the stable
+ // discovery idiom. Used by the Ctrl+O and Cancel Handler describe blocks.
+ const getGlobalKeypress = (): ((key: Key) => void) | undefined =>
+ mockedUseKeypress.mock.calls
+ .map((call) => call[0])
+ .reverse()
+ .find(
+ (handler): handler is (key: Key) => void =>
+ typeof handler === 'function' &&
+ handler.toString().includes('TOGGLE_THINKING_EXPANDED'),
+ );
+
beforeEach(() => {
vi.clearAllMocks();
restoreCiEnv = clearCiEnv();
@@ -2567,6 +2581,15 @@ describe('AppContainer State Management', () => {
// signature change surfaces as a clear test failure rather than silently
// grabbing the wrong callback.
const ON_CANCEL_SUBMIT_ARG_INDEX = 15;
+ // Shared ESC key fixture for the Cancel Handler describe block.
+ const escKey: Key = {
+ name: 'escape',
+ sequence: '\u001b',
+ ctrl: false,
+ meta: false,
+ shift: false,
+ paste: false,
+ };
type CapturedCancelSubmit = (info?: {
pendingItem: HistoryItemWithoutId | null;
lastTurnUserItem: {
@@ -2666,30 +2689,77 @@ describe('AppContainer State Management', () => {
await Promise.resolve();
await Promise.resolve();
- const handleKeypress = mockedUseKeypress.mock.calls
- .map((call) => call[0])
- .reverse()
- .find(
- (handler): handler is (key: Key) => void =>
- typeof handler === 'function' &&
- handler.toString().includes('handleExit'),
- ) as ((key: Key) => void) | undefined;
+ const handleKeypress = getGlobalKeypress();
expect(handleKeypress).toBeDefined();
- const escKey: Key = {
- name: 'escape',
- sequence: '\u001b',
- ctrl: false,
- meta: false,
- shift: false,
- paste: false,
- };
handleKeypress!(escKey);
// In vim INSERT mode, Esc must NOT trigger the outer cancel handler.
expect(cancelSpy).not.toHaveBeenCalled();
});
+ it('cancels the ongoing request on a single Esc with an empty buffer and queued follow-ups', async () => {
+ // Positive counterpart to the vim-INSERT guard above: while the agent
+ // is Responding and the buffer is empty, one Esc must reach the
+ // cancel-work branch of the global handler. InputPrompt must NOT pop
+ // the queue into the buffer on that Esc (its Responding guard skips
+ // the pop; #8201). The global handler itself does not touch the
+ // queue either — end-to-end the cancel path drains it back into the
+ // buffer via the cancel handler, but that hop is severed here because
+ // cancelOngoingRequest is replaced by a spy.
+ const cancelSpy = vi.fn();
+ const mockPopAllMessages = vi.fn().mockReturnValue(null);
+ installCancelCapture({
+ streamingState: 'responding',
+ submitQuery: vi.fn(),
+ initError: null,
+ pendingHistoryItems: [],
+ thought: null,
+ cancelOngoingRequest: cancelSpy,
+ retryLastPrompt: vi.fn(),
+ });
+ mockedUseTextBuffer.mockReturnValue({
+ text: '',
+ setText: vi.fn(),
+ });
+ mockedUseMessageQueue.mockReturnValue({
+ messageQueue: ['queued follow-up'],
+ addMessage: vi.fn(),
+ clearQueue: vi.fn(),
+ getQueuedMessagesText: vi.fn().mockReturnValue('queued follow-up'),
+ popAllMessages: mockPopAllMessages,
+ drainQueue: vi.fn().mockReturnValue(['queued follow-up']),
+ popNextTurn: vi.fn().mockReturnValue({ modelText: 'queued follow-up' }),
+ removeGoalTurns: vi.fn().mockReturnValue([]),
+ });
+
+ render(
+ ,
+ );
+
+ await Promise.resolve();
+ await Promise.resolve();
+
+ const handleKeypress = getGlobalKeypress();
+ expect(handleKeypress).toBeDefined();
+
+ handleKeypress!(escKey);
+
+ // A single Esc cancels the in-flight request...
+ expect(cancelSpy).toHaveBeenCalledOnce();
+ // ...and the global keypress handler itself must not pop the queue
+ // (InputPrompt owns the ESC pop decision and skips it while Responding;
+ // #8201). End-to-end the cancel path then drains the queue back into
+ // the buffer via the cancel handler - that hop is severed here because
+ // cancelOngoingRequest is replaced by a spy.
+ expect(mockPopAllMessages).not.toHaveBeenCalled();
+ });
+
it('does not repopulate the buffer with the previous prompt on ESC cancel', async () => {
const mockSetText = vi.fn();
mockedUseTextBuffer.mockReturnValue({
@@ -5412,16 +5482,6 @@ describe('AppContainer State Management', () => {
...overrides,
}) as Key;
- const getGlobalKeypress = () =>
- mockedUseKeypress.mock.calls
- .map((call) => call[0])
- .reverse()
- .find(
- (handler): handler is (key: Key) => void =>
- typeof handler === 'function' &&
- handler.toString().includes('TOGGLE_THINKING_EXPANDED'),
- ) as ((key: Key) => void) | undefined;
-
const ctrlO = makeKey({ name: 'o', ctrl: true, sequence: '\x0f' });
it('Ctrl+O flips the full-detail state that expands thoughts and tool output', () => {
diff --git a/packages/cli/src/ui/components/InputPrompt.test.tsx b/packages/cli/src/ui/components/InputPrompt.test.tsx
index 58f4db2ca0e..4cc4a2313ce 100644
--- a/packages/cli/src/ui/components/InputPrompt.test.tsx
+++ b/packages/cli/src/ui/components/InputPrompt.test.tsx
@@ -37,6 +37,7 @@ import { createMockCommandContext } from '../../test-utils/mockCommandContext.js
import stripAnsi from 'strip-ansi';
import { renderSoftwareCursor } from '../utils/software-cursor.js';
import { useUIState } from '../contexts/UIStateContext.js';
+import { StreamingState } from '../types.js';
import { useUIActions } from '../contexts/UIActionsContext.js';
import {
useAgentViewActions,
@@ -5689,6 +5690,160 @@ describe('InputPrompt', () => {
unmountEdited();
});
});
+
+ describe('ESC during active agent response (#8201)', () => {
+ it('does not pop queued messages into input when responding', async () => {
+ mockedUseUIState.mockReturnValue({
+ isFeedbackDialogOpen: false,
+ messageQueue: ['queued message'],
+ pendingGeminiHistoryItems: [],
+ streamingState: StreamingState.Responding,
+ } as unknown as ReturnType);
+ const mockPopAllQueued = vi.fn(() => null);
+ mockedUseUIActions.mockReturnValue({
+ handleRetryLastPrompt: vi.fn(),
+ temporaryCloseFeedbackDialog: vi.fn(),
+ popAllQueuedMessages: mockPopAllQueued,
+ invalidateSubmittedPromptProvenance: vi.fn(),
+ } as unknown as ReturnType);
+
+ const { stdin, unmount } = renderWithProviders(
+ ,
+ );
+ await wait();
+
+ stdin.write('\x1B');
+ await wait();
+
+ // popAllQueuedMessages must NOT be called when agent is responding
+ expect(mockPopAllQueued).not.toHaveBeenCalled();
+ // ...and the shared buffer must stay empty so AppContainer's global ESC
+ // handler takes its cancel branch instead of "input has content".
+ expect(props.buffer.text).toBe('');
+ unmount();
+ });
+
+ it('does not silently clear typed input on single ESC when responding', async () => {
+ mockedUseUIState.mockReturnValue({
+ isFeedbackDialogOpen: false,
+ messageQueue: [],
+ pendingGeminiHistoryItems: [],
+ streamingState: StreamingState.Responding,
+ } as unknown as ReturnType);
+
+ props.buffer.setText('half typed message');
+ const { stdin, unmount } = renderWithProviders(
+ ,
+ );
+ await wait();
+
+ stdin.write('\x1B');
+ await wait();
+
+ // Buffer must NOT be cleared - double-ESC confirmation still applies
+ expect(props.buffer.text).toBe('half typed message');
+ unmount();
+ });
+
+ it('does not pop the queue or clear the buffer on ESC when responding with an empty buffer', async () => {
+ // Pins the no-side-effect contract for ESC while the agent is Responding
+ // and both the buffer and the queue are empty. This branch consumes the
+ // key (returns true) but mutates nothing; AppContainer's broadcast ESC
+ // handler runs after this one regardless of return values and cancels
+ // because the buffer stayed empty. #8201.
+ mockedUseUIState.mockReturnValue({
+ isFeedbackDialogOpen: false,
+ messageQueue: [],
+ pendingGeminiHistoryItems: [],
+ streamingState: StreamingState.Responding,
+ } as unknown as ReturnType);
+ const mockPopAllQueued = vi.fn(() => null);
+ mockedUseUIActions.mockReturnValue({
+ handleRetryLastPrompt: vi.fn(),
+ temporaryCloseFeedbackDialog: vi.fn(),
+ popAllQueuedMessages: mockPopAllQueued,
+ invalidateSubmittedPromptProvenance: vi.fn(),
+ } as unknown as ReturnType);
+ props.buffer.setText('');
+ const setTextSpy = vi.spyOn(props.buffer, 'setText');
+
+ const { stdin, unmount } = renderWithProviders(
+ ,
+ );
+ await wait();
+
+ stdin.write('\x1B');
+ await wait();
+
+ // No queue pop, no buffer mutation - the branch returns false so
+ // AppContainer's broadcast ESC handler is the one that acts.
+ expect(mockPopAllQueued).not.toHaveBeenCalled();
+ expect(setTextSpy).not.toHaveBeenCalled();
+ unmount();
+ });
+
+ it('does not pop queued messages on ESC when responding with typed text AND a queue (#8201)', async () => {
+ // R6-1: the pop-skip guard is pinned only for the empty-buffer case
+ // without this. A mutation to `!== Responding || buffer.text !== ''`
+ // survives all tests unless this combination is covered.
+ mockedUseUIState.mockReturnValue({
+ isFeedbackDialogOpen: false,
+ messageQueue: ['queued follow-up'],
+ pendingGeminiHistoryItems: [],
+ streamingState: StreamingState.Responding,
+ } as unknown as ReturnType);
+ const mockPopAllQueued = vi.fn(() => null);
+ mockedUseUIActions.mockReturnValue({
+ handleRetryLastPrompt: vi.fn(),
+ temporaryCloseFeedbackDialog: vi.fn(),
+ popAllQueuedMessages: mockPopAllQueued,
+ invalidateSubmittedPromptProvenance: vi.fn(),
+ } as unknown as ReturnType);
+ props.buffer.setText('half typed message');
+ const { stdin, unmount } = renderWithProviders(
+ ,
+ );
+ await wait();
+
+ stdin.write('\x1B');
+ await wait();
+
+ // Queue must NOT be popped (guard fires regardless of buffer content).
+ expect(mockPopAllQueued).not.toHaveBeenCalled();
+ // Typed text survives single ESC (double-press still required).
+ expect(props.buffer.text).toBe('half typed message');
+ unmount();
+ });
+
+ it('still clears typed input on double-ESC while responding (#8201)', async () => {
+ // R6-2: the double-press contract this diff preserves - double-ESC
+ // clears typed input even while Responding - had no test. Without it,
+ // adding `if (streamingState === Responding) return true` before the
+ // double-ESC block ships green.
+ mockedUseUIState.mockReturnValue({
+ isFeedbackDialogOpen: false,
+ messageQueue: [],
+ pendingGeminiHistoryItems: [],
+ streamingState: StreamingState.Responding,
+ } as unknown as ReturnType);
+ props.buffer.setText('draft to clear');
+ const { stdin, unmount } = renderWithProviders(
+ ,
+ );
+ await wait();
+
+ stdin.write('\x1B');
+ await wait(50);
+ // First ESC: show double-press prompt, buffer preserved.
+ expect(props.buffer.text).toBe('draft to clear');
+
+ stdin.write('\x1B');
+ await wait(50);
+ // Second ESC within the timeout: clear typed input.
+ expect(props.buffer.text).toBe('');
+ unmount();
+ });
+ });
});
function clean(str: string | undefined): string {
if (!str) return '';
diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx
index a2f03edf160..52d075cae06 100644
--- a/packages/cli/src/ui/components/InputPrompt.tsx
+++ b/packages/cli/src/ui/components/InputPrompt.tsx
@@ -26,6 +26,7 @@ import { useFollowupSuggestionsCLI } from '../hooks/useFollowupSuggestions.js';
import type { Key } from '../hooks/useKeypress.js';
import { keyMatchers, Command } from '../keyMatchers.js';
import type { CommandContext, SlashCommand } from '../commands/types.js';
+import { StreamingState } from '../types.js';
import {
ApprovalMode,
type Config,
@@ -1199,8 +1200,33 @@ export const InputPrompt: React.FC = ({
return true;
}
- // Pop queued messages into input on ESC (before double-ESC clear)
- if (!isAttachmentMode && uiState.messageQueue.length > 0) {
+ // Pop queued messages into input on ESC (before double-ESC clear).
+ // Skip when the agent is actively responding: popQueueIntoInput()
+ // fills the shared buffer (a live getter over stateRef.current, not
+ // React state), which makes AppContainer's broadcast ESC handler -
+ // which runs AFTER this one because child useEffects subscribe to
+ // KeypressContext first and useKeypress memoizes on [] - take its
+ // "input has content -> double-press to clear" branch instead of the
+ // cancel-work branch. This guard breaks that chain. #8201.
+ //
+ // Relies on one invariant: buffer.text reads through to
+ // stateRef.current synchronously. Subscription order does not gate the
+ // cancel: the Responding pop guard skips the pop in either order (and
+ // BaseTextInput re-subscribes after AppContainer after any remount of
+ // InputPrompt, e.g. a tool-confirmation round trip). Break the buffer
+ // invariant and the single-ESC cancel regresses with a fully green
+ // suite (no integration test covers this hop yet - the two harnesses
+ // mock each other's side).
+ // Only Responding is gated (matching AppContainer's cancel branch).
+ // WaitingForConfirmation needs no handling here: Composer unmounts
+ // InputPrompt whenever isInputActive is false (isInputActiveForState
+ // admits only Idle/Responding), so this branch never runs during a
+ // tool confirmation.
+ if (
+ !isAttachmentMode &&
+ uiState.messageQueue.length > 0 &&
+ uiState.streamingState !== StreamingState.Responding
+ ) {
if (popQueueIntoInput()) {
resetEscapeState();
return true;
@@ -1208,7 +1234,13 @@ export const InputPrompt: React.FC = ({
// returned false (queue already cleared) — fall through
}
- // Handle double ESC for clearing input
+ // Handle double ESC for clearing input. Note: while Responding this
+ // clear composes with AppContainer's broadcast ESC handler - in the
+ // initial subscription order this handler empties the buffer first and
+ // AppContainer's cancel branch fires on the SAME keypress; after any
+ // remount of InputPrompt (e.g. a tool-confirmation round trip)
+ // AppContainer runs first, so the cancel lands on the next press
+ // instead. #8201.
if (escPressCount === 0) {
if (buffer.text === '') {
return true;