From bc682d175444fd5af99a2fb4378957a93bce859a Mon Sep 17 00:00:00 2001 From: ChiGao Date: Tue, 13 Oct 2026 06:27:55 +0000 Subject: [PATCH] =?UTF-8?q?fix(cli):=20give=20bare=20=E2=86=91/=E2=86=93?= =?UTF-8?q?=20back=20to=20input=20history=20in=20Virtualized=20History=20(?= =?UTF-8?q?#14016)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #12518 routed a bare ↑/↓ on an empty composer to transcript scrolling in VP mode, on the theory that terminals without SGR mouse reporting deliver the wheel as arrow-key sequences. The composer is empty at rest, so the fallback fired on every deliberate ↑/↓: input history became unreachable from the arrow keys and only Ctrl+P / Ctrl+N still recalled it. That also contradicted the contract the project had already settled on — #4942 ("scroll with wheel or Shift+↑/↓ anytime, type messages anytime"), #5159 (closed with a docs workaround rather than by reassigning the keys), ui.useTerminalBuffer's own description, and both keyboard-shortcuts.md and troubleshooting.md, none of which #12518 updated. Gate the fallback behind a new opt-in ui.arrowKeysScrollTranscript (default false). Bare ↑/↓ walk input history again everywhere by default; a terminal that genuinely cannot report the wheel turns the setting on and gets #12518's behaviour verbatim, with Ctrl+P / Ctrl+N for history. The opt-in keeps every guard #12518 added — empty composer, transcript actually overflowing, ↓ still yielding to descendFromComposer — and non-VP mode is untouched. Docs now name the setting as the alternative for the tmux trackpad case. Fixes #14015 Co-authored-by: chiga0 --- docs/users/configuration/settings.md | 1 + docs/users/reference/keyboard-shortcuts.md | 2 +- docs/users/support/troubleshooting.md | 2 +- .../cli/src/config/settingsSchema.test.ts | 11 ++ packages/cli/src/config/settingsSchema.ts | 10 ++ .../src/serve/routes/workspace-settings.ts | 1 + .../src/ui/components/InputPrompt.test.tsx | 100 +++++++++++++++++- .../cli/src/ui/components/InputPrompt.tsx | 37 ++++--- .../schemas/settings.schema.json | 5 + 9 files changed, 149 insertions(+), 20 deletions(-) diff --git a/docs/users/configuration/settings.md b/docs/users/configuration/settings.md index bde7a912b31..61639b686c7 100644 --- a/docs/users/configuration/settings.md +++ b/docs/users/configuration/settings.md @@ -163,6 +163,7 @@ These settings are read from operator scopes only (User, System, and SystemDefau | `ui.useTerminalBuffer` | boolean | Render conversation history in an in-app scrollable viewport instead of the terminal scrollback buffer. Enabled by default in compatible interactive terminals to avoid flicker, scroll-storm, and interface freeze on long sessions, after Ctrl+O, after Ctrl+E / Ctrl+F (expand), after window resize, or when alt-tabbing back. Screen reader mode and non-interactive output such as piped stdout or CI use append-only terminal output instead. Scroll with `Shift+↑/↓` (line), `PgUp`/`PgDn` (page), `Ctrl+Home/End` (top/bottom), or the mouse wheel. Also enables mouse interactions: click an option in a menu/dialog to select it, hover to highlight it, and click in the prompt to position the cursor. Does NOT use the host terminal scrollback while enabled. Drag to select text in the viewport (double/triple click selects a word/line), copied on release. To use the terminal’s own selection instead, hold Shift (or Option on macOS) while dragging. A single click opens an http(s) hyperlink under the pointer (other link schemes are copied to the clipboard), and right-click over a link or a text selection opens an in-app context menu. These mouse interactions are controlled by `ui.mouseTracking`; disable that setting to hand the mouse fully back to the terminal. | `true` | | `ui.showScrollbar` | boolean | Show the auto-hiding scrollbar in the in-app scrollable viewport (Virtualized History). The bar appears while scrolling and fades out when idle. Disable to hide it entirely. Only applies in the interactive terminal UI. | `true` | | `ui.mouseTracking` | boolean | Enable in-app SGR mouse tracking. While enabled, Qwen Code captures mouse events for text selection, click-to-position in text inputs, row hover, history-item toggling, and viewport scrolling. Because the terminal forwards all mouse events to the app, Qwen Code supplies its own equivalents for what the terminal can no longer do natively: a single click opens an http(s) hyperlink under the pointer (other link schemes are copied to the clipboard), and right-click over a link or a text selection opens an in-app context menu with Open Link / Copy Link Address / Copy Selection. Disable to hand the mouse fully back to the terminal (native right-click menu and link clicks); this turns off all in-app mouse interaction, and in Virtualized History the wheel no longer scrolls the transcript — use Shift+↑/↓, PgUp/PgDn, or Ctrl+Home/End instead (pair with `ui.useTerminalBuffer: false` to restore native terminal scrollback). Only applies in the interactive terminal UI. | `true` | +| `ui.arrowKeysScrollTranscript` | boolean | Let a bare ↑/↓ scroll the transcript while the composer is empty in Virtualized History, instead of recalling input history. Off by default: ↑/↓ walk your previous prompts, and the transcript scrolls with Shift+↑/↓ (line), PgUp/PgDn (page), Ctrl+Home/End (top/bottom), or the mouse wheel. Turn it on only for terminals that cannot report the mouse wheel to the app — macOS Terminal.app is the common case — where a wheel or trackpad scroll arrives as a burst of ↑/↓ key presses and would otherwise silently replace the composer with an older prompt. While it is on, use Ctrl+P / Ctrl+N for input history from an empty composer. Only applies in the interactive terminal UI. | `false` | | `ui.hideBuiltinWorktreeIndicator` | boolean | Hide the built-in `⎇ worktree- ()` line in the Footer. The worktree state is still passed to custom statusline scripts via the stdin payload. Keep at the default unless your custom statusline renders the worktree itself. | `false` | #### ide diff --git a/docs/users/reference/keyboard-shortcuts.md b/docs/users/reference/keyboard-shortcuts.md index d9127b84e16..8b5650906db 100644 --- a/docs/users/reference/keyboard-shortcuts.md +++ b/docs/users/reference/keyboard-shortcuts.md @@ -138,7 +138,7 @@ A **plain single click** on an http(s) OSC 8 hyperlink in the viewport opens it Inside tmux, some terminals translate trackpad or wheel gestures into plain `Up Arrow` and `Down Arrow` sequences before qwen-code sees them. Those bytes are identical to real arrow-key presses, so qwen-code cannot tell whether you meant to scroll the viewport or navigate prompt history. -If trackpad scrolling changes the prompt history in tmux, make sure `ui.useTerminalBuffer` is enabled; then use `Shift+Up` / `Shift+Down`, or the mouse wheel when tmux forwards wheel events to the app (requires `ui.mouseTracking`). If you prefer host scrollback, adjust your tmux mouse bindings for wheel events. +If trackpad scrolling changes the prompt history in tmux, make sure `ui.useTerminalBuffer` is enabled; then use `Shift+Up` / `Shift+Down`, or the mouse wheel when tmux forwards wheel events to the app (requires `ui.mouseTracking`). If you would rather have plain `Up Arrow` / `Down Arrow` scroll the viewport while the composer is empty, set `ui.arrowKeysScrollTranscript` to `true` — input history then moves to `Ctrl+P` / `Ctrl+N` from an empty composer. If you prefer host scrollback, adjust your tmux mouse bindings for wheel events. ## IDE Integration diff --git a/docs/users/support/troubleshooting.md b/docs/users/support/troubleshooting.md index 4002f92a075..946a6c8c3d2 100644 --- a/docs/users/support/troubleshooting.md +++ b/docs/users/support/troubleshooting.md @@ -109,7 +109,7 @@ The attachment limit remains 8 MiB. If an upload expires or the daemon restarts, - **Trackpad scrolling in tmux changes prompt history instead of scrolling the conversation** - **Issue:** In a tmux session, trackpad or wheel scrolling may cycle through previous prompts, similar to pressing `Up Arrow` or `Down Arrow`. - **Cause:** tmux can translate wheel gestures into plain arrow-key sequences. Those sequences are indistinguishable from real arrow-key presses by the time qwen-code receives them. - - **Solution:** If screen reader mode is disabled, make sure `ui.useTerminalBuffer` is enabled; then use `Shift+Up` / `Shift+Down`, or the mouse wheel when tmux forwards wheel events to the app (requires `ui.mouseTracking`). If you prefer host scrollback, adjust your tmux mouse bindings for wheel events. + - **Solution:** If screen reader mode is disabled, make sure `ui.useTerminalBuffer` is enabled; then use `Shift+Up` / `Shift+Down`, or the mouse wheel when tmux forwards wheel events to the app (requires `ui.mouseTracking`). To hand plain `Up Arrow` / `Down Arrow` to the viewport while the composer is empty, set `ui.arrowKeysScrollTranscript` to `true`; input history is then reachable with `Ctrl+P` / `Ctrl+N`. If you prefer host scrollback, adjust your tmux mouse bindings for wheel events. - **Right-click does nothing, links do not open, or text cannot be selected in the terminal** - **Issue:** Native right-click context menus, OSC 8 hyperlink clicks, and terminal-native text selection stop working while Qwen Code is running. diff --git a/packages/cli/src/config/settingsSchema.test.ts b/packages/cli/src/config/settingsSchema.test.ts index 538eba51f9e..696145d266f 100644 --- a/packages/cli/src/config/settingsSchema.test.ts +++ b/packages/cli/src/config/settingsSchema.test.ts @@ -771,6 +771,17 @@ describe('SettingsSchema', () => { expect(mouseTracking.requiresRestart).toBe(true); }); + it('should have arrowKeysScrollTranscript in ui settings', () => { + const arrowKeysScrollTranscript = + getSettingsSchema().ui.properties.arrowKeysScrollTranscript; + expect(arrowKeysScrollTranscript).toBeDefined(); + expect(arrowKeysScrollTranscript.type).toBe('boolean'); + // Default must stay false — bare ↑/↓ are input-history keys. + expect(arrowKeysScrollTranscript.default).toBe(false); + expect(arrowKeysScrollTranscript.showInDialog).toBe(true); + expect(arrowKeysScrollTranscript.requiresRestart).toBe(false); + }); + it('should expose tool call details as a live UI setting', () => { const showToolCallDetails = getSettingsSchema().ui.properties.showToolCallDetails; diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index c790e92ce7e..14a91e3cba5 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -1296,6 +1296,16 @@ const SETTINGS_SCHEMA = { 'Enable in-app SGR mouse tracking. While enabled, Qwen Code captures mouse events for text selection, click-to-position in text inputs, row hover, history-item toggling, and viewport scrolling. Because the terminal forwards all mouse events to the app, Qwen Code supplies its own equivalents for what the terminal can no longer do natively: a single click opens an http(s) hyperlink under the pointer (other link schemes are copied to the clipboard), and right-click over a link or a text selection opens an in-app context menu with Open Link / Copy Link Address / Copy Selection. Disable to hand the mouse fully back to the terminal (native right-click menu and link clicks); this turns off all in-app mouse interaction, and in Virtualized History the wheel no longer scrolls the transcript — use Shift+↑/↓, PgUp/PgDn, or Ctrl+Home/End instead (pair with ui.useTerminalBuffer: false to restore native terminal scrollback).', showInDialog: true, }, + arrowKeysScrollTranscript: { + type: 'boolean', + label: 'Arrow Keys Scroll Transcript (Virtualized History)', + category: 'UI', + requiresRestart: false, + default: false, + description: + 'Let a bare ↑/↓ scroll the transcript while the composer is empty in Virtualized History, instead of recalling input history. Off by default: ↑/↓ walk your previous prompts, and the transcript scrolls with Shift+↑/↓ (line), PgUp/PgDn (page), Ctrl+Home/End (top/bottom), or the mouse wheel. Turn it on only for terminals that cannot report the mouse wheel to the app — macOS Terminal.app is the common case — where a wheel or trackpad scroll arrives as a burst of ↑/↓ key presses and would otherwise silently replace the composer with an older prompt. While it is on, use Ctrl+P / Ctrl+N for input history from an empty composer.', + showInDialog: true, + }, showToolCallDetails: { type: 'boolean', label: 'Show Tool Call Details', diff --git a/packages/cli/src/serve/routes/workspace-settings.ts b/packages/cli/src/serve/routes/workspace-settings.ts index 38f8087e5d5..86e53b6d317 100644 --- a/packages/cli/src/serve/routes/workspace-settings.ts +++ b/packages/cli/src/serve/routes/workspace-settings.ts @@ -56,6 +56,7 @@ const TUI_ONLY_SETTINGS = new Set([ 'ui.renderMode', 'ui.useTerminalBuffer', 'ui.mouseTracking', + 'ui.arrowKeysScrollTranscript', 'ui.showScrollbar', 'ui.hideBanner', 'ui.accessibility.enableLoadingPhrases', diff --git a/packages/cli/src/ui/components/InputPrompt.test.tsx b/packages/cli/src/ui/components/InputPrompt.test.tsx index 6f89f144ef6..5cc9ced856c 100644 --- a/packages/cli/src/ui/components/InputPrompt.test.tsx +++ b/packages/cli/src/ui/components/InputPrompt.test.tsx @@ -6287,13 +6287,95 @@ describe('InputPrompt', () => { }); }); - describe('VP-mode bare Up/Down scrolls the conversation (#10749)', () => { + describe('VP-mode bare Up/Down recalls input history by default', () => { + // `ui.useTerminalBuffer` (VP mode, on by default) documents the transcript + // scroll keys as Shift+↑/↓, PgUp/PgDn, Ctrl+Home/End and the mouse wheel, + // so a bare ↑/↓ reaching an empty composer has to walk input history — + // even when the transcript overflows and the scrollbar is on screen. + const vpSettings = { + merged: { ui: { useTerminalBuffer: true } }, + } as LoadedSettings; + + const renderVp = ( + scrollBy: (delta: number) => void, + hasScrollableTranscript: () => boolean = () => true, + ) => + renderWithProviders( + + + , + { settings: vpSettings }, + ); + + it('Up from an empty composer recalls history instead of scrolling the transcript', async () => { + const scrollBy = vi.fn(); + (mockInputHistory.navigateUp as Mock).mockReturnValue(true); + mockBuffer.setText(''); + mockBuffer.visualCursor = [0, 0]; + const { stdin, unmount } = renderVp(scrollBy); + await wait(); + + stdin.write('\u001B[A'); // Up arrow + await wait(); + + expect(mockInputHistory.navigateUp).toHaveBeenCalled(); + expect(scrollBy).not.toHaveBeenCalled(); + unmount(); + }); + + it('Down from an empty composer recalls history instead of scrolling the transcript', async () => { + const scrollBy = vi.fn(); + (mockInputHistory.navigateDown as Mock).mockReturnValue(true); + mockBuffer.setText(''); + mockBuffer.visualCursor = [0, 0]; + const { stdin, unmount } = renderVp(scrollBy); + await wait(); + + stdin.write('\u001B[B'); // Down arrow + await wait(); + + expect(mockInputHistory.navigateDown).toHaveBeenCalled(); + expect(scrollBy).not.toHaveBeenCalled(); + unmount(); + }); + + it('Down from an empty composer still descends when history is exhausted', async () => { + // The pre-existing ↓ fall-through (history → descendFromComposer) must + // survive unchanged now that the scroll fallback no longer preempts it. + const scrollBy = vi.fn(); + (mockInputHistory.navigateDown as Mock).mockReturnValue(false); + mockedUseBackgroundTaskViewState.mockReturnValue({ + entries: [{ kind: 'workflow', runId: 'wf-1', status: 'running' }], + selectedIndex: 0, + dialogMode: 'closed', + dialogOpen: false, + pillFocused: false, + livePanelFocused: false, + livePanelSelectedIndex: 0, + } as unknown as ReturnType); + mockBuffer.setText(''); + mockBuffer.visualCursor = [0, 0]; + const { stdin, unmount } = renderVp(scrollBy); + await wait(); + + stdin.write('\u001B[B'); // Down arrow + await wait(); + + expect(mockViewActions.setBgPillFocused).toHaveBeenCalledWith(true); + expect(scrollBy).not.toHaveBeenCalled(); + unmount(); + }); + }); + + describe('VP-mode bare Up/Down scrolls the conversation when opted in (#10749)', () => { // Terminals without SGR mouse reporting turn wheel events into bare ↑/↓. - // In VP mode (ui.useTerminalBuffer, on by default) those keys have to + // ui.arrowKeysScrollTranscript is the explicit opt-in that lets those keys // scroll the transcript while the composer is empty instead of rewriting // the input with an old prompt. const vpSettings = { - merged: { ui: { useTerminalBuffer: true } }, + merged: { + ui: { useTerminalBuffer: true, arrowKeysScrollTranscript: true }, + }, } as LoadedSettings; const renderVp = ( @@ -6357,6 +6439,8 @@ describe('InputPrompt', () => { }); it('keeps history navigation when VP mode is off', async () => { + // The opt-in is meaningless outside VP mode — there the host terminal + // still owns native scrollback and never translates the wheel into ↑/↓. const scrollBy = vi.fn(); (mockInputHistory.navigateUp as Mock).mockReturnValue(true); mockBuffer.setText(''); @@ -6367,6 +6451,16 @@ describe('InputPrompt', () => { > , + { + settings: { + merged: { + ui: { + useTerminalBuffer: false, + arrowKeysScrollTranscript: true, + }, + }, + } as LoadedSettings, + }, ); await wait(); diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx index d97e79eaa2f..fab97e53e49 100644 --- a/packages/cli/src/ui/components/InputPrompt.tsx +++ b/packages/cli/src/ui/components/InputPrompt.tsx @@ -269,6 +269,15 @@ export const InputPrompt: React.FC = ({ const mouseTrackingEnabled = useMouseTrackingEnabled(); const isVpMode = useVirtualViewport(settings.merged.ui?.useTerminalBuffer); const mouseInteractionsEnabled = isVpMode && mouseTrackingEnabled; + // Bare ↑/↓ belong to input history; the transcript is scrolled with + // Shift+↑/↓, PgUp/PgDn, Ctrl+Home/End or the mouse wheel (the contract + // ui.useTerminalBuffer documents). Opt-in only: on a terminal that cannot + // report the wheel — macOS Terminal.app — the wheel arrives as a burst of + // ↑/↓ and would rewrite the composer with an older prompt (#10749), so + // ui.arrowKeysScrollTranscript hands those keys to the viewport instead. + // Derived once so the ↑ and ↓ branches cannot drift apart. + const arrowKeysScrollTranscript = + isVpMode && settings.merged.ui?.arrowKeysScrollTranscript === true; const { pasteWorkaround } = useKeypressContext(); const { agents, agentTabBarFocused } = useAgentViewState(); const { setAgentTabBarFocused } = useAgentViewActions(); @@ -1741,14 +1750,14 @@ export const InputPrompt: React.FC = ({ (buffer.allVisualLines.length === 1 || (buffer.visualCursor[0] === 0 && buffer.visualScrollRow === 0)) ) { - // In VP mode with empty input, scroll the conversation instead of - // navigating input history. This handles terminals that translate - // mouse wheel events to Up/Down arrow keys. Only take the key when - // the transcript actually overflows: in a short conversation there is - // nothing to scroll and ↑ must keep recalling history rather than - // going dead. + // Opt-in wheel fallback (ui.arrowKeysScrollTranscript): a terminal + // that cannot report the mouse wheel delivers it as bare ↑/↓, so + // hand those to the viewport while the composer is empty. Only take + // the key when the transcript actually overflows: in a short + // conversation there is nothing to scroll and ↑ must keep recalling + // history rather than going dead. if ( - isVpMode && + arrowKeysScrollTranscript && buffer.text.length === 0 && scrollActions?.hasScrollableTranscript() ) { @@ -1770,14 +1779,12 @@ export const InputPrompt: React.FC = ({ (buffer.allVisualLines.length === 1 || buffer.visualCursor[0] === buffer.allVisualLines.length - 1) ) { - // In VP mode with empty input, scroll the conversation instead of - // navigating input history. This handles terminals that translate - // mouse wheel events to Up/Down arrow keys. Yields to history when - // the transcript does not overflow, and to descendFromComposer when - // an agent surface is on screen — descending is the only keyboard - // route into those. + // Opt-in wheel fallback (ui.arrowKeysScrollTranscript), mirroring ↑. + // Yields to history when the transcript does not overflow, and to + // descendFromComposer when an agent surface is on screen — + // descending is the only keyboard route into those. if ( - isVpMode && + arrowKeysScrollTranscript && buffer.text.length === 0 && scrollActions?.hasScrollableTranscript() && !hasComposerDescendTarget() @@ -2024,7 +2031,7 @@ export const InputPrompt: React.FC = ({ categoryTabsVisible, voiceInput, targetDir, - isVpMode, + arrowKeysScrollTranscript, scrollActions, ], ); diff --git a/packages/vscode-ide-companion/schemas/settings.schema.json b/packages/vscode-ide-companion/schemas/settings.schema.json index 4b8828b881d..22f55105599 100644 --- a/packages/vscode-ide-companion/schemas/settings.schema.json +++ b/packages/vscode-ide-companion/schemas/settings.schema.json @@ -496,6 +496,11 @@ "type": "boolean", "default": true }, + "arrowKeysScrollTranscript": { + "description": "Let a bare ↑/↓ scroll the transcript while the composer is empty in Virtualized History, instead of recalling input history. Off by default: ↑/↓ walk your previous prompts, and the transcript scrolls with Shift+↑/↓ (line), PgUp/PgDn (page), Ctrl+Home/End (top/bottom), or the mouse wheel. Turn it on only for terminals that cannot report the mouse wheel to the app — macOS Terminal.app is the common case — where a wheel or trackpad scroll arrives as a burst of ↑/↓ key presses and would otherwise silently replace the composer with an older prompt. While it is on, use Ctrl+P / Ctrl+N for input history from an empty composer.", + "type": "boolean", + "default": false + }, "showToolCallDetails": { "description": "Show tool arguments and results inline. Disable to render ordinary tool calls as a one-line summary; click a summary in Virtualized History or press Ctrl+O to expand its details. Approval prompts, user-initiated shell commands, and focused interactive shells remain expanded.", "type": "boolean",