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;