fix(cli): give bare ↑/↓ back to input history in Virtualized History (#14016)

#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 <[email protected]>
This commit is contained in:
ChiGaoandchiga0 authored and GitHub committed 2026-10-13 06:27:55 +00:00
1 parent b55f1927c2
commit bc682d1754
9 files changed
+149 -20

No files matched your search

+1
View File
@@ -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-<branch> (<slug>)` 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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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.
@@ -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;
+10
View File
@@ -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',
@@ -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',
@@ -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(
<ScrollContext.Provider value={{ scrollBy, hasScrollableTranscript }}>
<InputPrompt {...props} />
</ScrollContext.Provider>,
{ 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<typeof useBackgroundTaskViewState>);
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', () => {
>
<InputPrompt {...props} />
</ScrollContext.Provider>,
{
settings: {
merged: {
ui: {
useTerminalBuffer: false,
arrowKeysScrollTranscript: true,
},
},
} as LoadedSettings,
},
);
await wait();
+22 -15
View File
@@ -269,6 +269,15 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
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<InputPromptProps> = ({
(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<InputPromptProps> = ({
(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<InputPromptProps> = ({
categoryTabsVisible,
voiceInput,
targetDir,
isVpMode,
arrowKeysScrollTranscript,
scrollActions,
],
);
@@ -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",