From 8f5eb9829c8ce75dc2136de5ec2141d2251e8900 Mon Sep 17 00:00:00 2001
From: hzb <991333136@qq.com>
Date: Tue, 23 Jun 2026 11:13:25 +0800
Subject: [PATCH 01/11] fix(cli): default to virtualized terminal history
---
docs/design/virtual-viewport/README.md | 21 +--
docs/users/reference/keyboard-shortcuts.md | 4 +-
docs/users/support/troubleshooting.md | 2 +-
.../cli/src/config/settingsSchema.test.ts | 4 +-
packages/cli/src/config/settingsSchema.ts | 6 +-
packages/cli/src/gemini.test.tsx | 166 +++++++++++++++++-
packages/cli/src/gemini.tsx | 12 +-
packages/cli/src/ui/AppContainer.test.tsx | 104 ++++++++++-
packages/cli/src/ui/AppContainer.tsx | 8 +-
packages/cli/src/ui/utils/terminal-buffer.ts | 31 ++++
.../schemas/settings.schema.json | 4 +-
11 files changed, 338 insertions(+), 24 deletions(-)
create mode 100644 packages/cli/src/ui/utils/terminal-buffer.ts
diff --git a/docs/design/virtual-viewport/README.md b/docs/design/virtual-viewport/README.md
index 9ba6bcb71d8..3738cb09f6a 100644
--- a/docs/design/virtual-viewport/README.md
+++ b/docs/design/virtual-viewport/README.md
@@ -122,7 +122,7 @@ Deferred to follow-up PRs:
- **Scrollbar drag + click-to-position** — needs screen-absolute element coords, blocked on a stock-ink-7 limitation (see V.4 / V.7).
- **In-app `/` search** — claude-code's `TranscriptSearchBar` pattern (V.5).
-- **Alternate-buffer mode** — `contexts/ScrollProvider.tsx`-style focus / lock, with full alt-screen takeover (V.6).
+- **Dedicated alternate-buffer setting** — VP already enters alternate screen; revisit a separate toggle only if compatibility reports require it.
### Setting (V.2)
@@ -132,9 +132,10 @@ ui: {
/**
* Enables virtualized history rendering for long conversations.
* When true, only items in the visible viewport are rendered through React;
- * scrolled-out items remain in the terminal scrollback buffer.
+ * scrolled-out items stay in the in-app scrollback model instead of the
+ * host terminal scrollback buffer.
*
- * Default: false. Opt-in until proven stable on long conversations.
+ * Default: true. Users can opt out if they prefer host terminal scrollback.
*/
useTerminalBuffer?: boolean; // alias kept compat with gemini-cli
}
@@ -143,7 +144,7 @@ ui: {
`MainContent.tsx` reads the setting and switches paths:
```tsx
-const useTerminalBuffer = uiState.settings?.ui?.useTerminalBuffer ?? false;
+const useTerminalBuffer = uiState.settings?.ui?.useTerminalBuffer ?? true;
if (useTerminalBuffer) {
return ; // virtualized
@@ -152,7 +153,7 @@ if (useTerminalBuffer) {
return ; // existing path, untouched
```
-The legacy `` path stays as-is — no regression risk for users who don't opt in.
+The legacy `` path stays available for users who explicitly opt out.
## 6. Key adaptations from gemini-cli source
@@ -316,10 +317,10 @@ Same pattern in qwen-code. Required for virtualization to actually skip re-rende
| **V.3** | test(integration): capture-suite regressions for streaming / resize / shell | port 3 capture scripts from PR #3663 | ~2000 (test-only) | #4146 | pending |
| **V.4** | feat(cli): scrollbar drag + click-to-position | SGR mouse hit-test on scrollbar column. Needs screen-absolute coords — either upstream `getBoundingBox` to ink 7 or own yoga walker. Auto-hide animation already shipped in #4146. | ~400 | #4146 | deferred — coord blocker |
| **V.5** | feat(cli): in-app `/` search | viewport-bound highlight + n/N navigation (claude-code's `TranscriptSearchBar` pattern) | ~300 | #4146 | deferred |
-| **V.6** | feat(cli): alternate-buffer mode (full alt-screen takeover) | additional setting `ui.useAlternateBuffer` | ~500 | #4146 | deferred — separate UX decision required |
+| **V.6** | feat(cli): dedicated alternate-buffer toggle | no separate setting planned for the default flow; VP already enters alternate screen, revisit only if compatibility reports require it | — | #4146 | deferred — compatibility-driven only |
| **V.7** | research: preserve host terminal scrollback (dual-write) | `@jrichman/ink`'s `overflowToBackbuffer` is fork-only. Options: upstream PR to ink 7, own dual-write, or accept loss. Investigation. | — | #4146 | structurally blocked on stock ink 7 |
-V.3 (integration tests) is the remaining critical-path item before flipping the default. V.4–V.6 close the remaining gemini-cli-parity gaps; V.7 is open research because the underlying ink prop we'd need (`overflowToBackbuffer`) only exists in gemini-cli's `@jrichman/ink` fork.
+V.3 (integration tests) remains desirable for long-session regression coverage but is no longer a gating prerequisite for the default flip. V.4–V.6 close the remaining gemini-cli-parity gaps; V.7 is open research because the underlying ink prop we'd need (`overflowToBackbuffer`) only exists in gemini-cli's `@jrichman/ink` fork.
## 8. Verification plan
@@ -342,10 +343,10 @@ End-to-end (after V.3):
## 9. Open questions / decisions needed
1. **Setting name**: `ui.useTerminalBuffer` (gemini-cli compat) vs `ui.virtualizedHistory` (more descriptive)?
-2. **Default value**: ship as `false` (opt-in) or stage rollout via env var first?
+2. **Default value**: resolved as `true` (default-on) with `false` as an explicit opt-out.
3. **Static-item heuristic**: gemini-cli marks only `header` as static. Should we also mark completed Gemini messages, tool results that are no longer in `pendingHistoryItems`, etc.?
4. **Mouse support**: gemini-cli's `ScrollProvider` includes mouse drag for scrollbar. Worth porting now or skip until V.4?
-5. **Compatibility with #3905**: ~~PR #3905 (Ctrl+O freeze fix) is open and modifies the same `MainContent.tsx`. Coordinate merge order — likely V.2 rebases on top of #3905.~~ **Resolved**: #3905's progressive-replay landed in `main` and is preserved in the legacy `` branch of `MainContent.tsx`; the VP branch supersedes it for opt-in users because the freeze trigger (full Static remount) no longer applies.
+5. **Compatibility with #3905**: ~~PR #3905 (Ctrl+O freeze fix) is open and modifies the same `MainContent.tsx`. Coordinate merge order — likely V.2 rebases on top of #3905.~~ **Resolved**: #3905's progressive-replay landed in `main` and is preserved in the legacy `` branch of `MainContent.tsx`; the VP branch supersedes it for default users because the freeze trigger (full Static remount) no longer applies.
6. **Compatibility with `chore/re-upgrade-ink-7-0-3`**: PR #4146 stacks on it. After #4119 (the ink 7.0.3 re-upgrade PR) merges to `main`, PR #4146's base will re-target to `main`.
## 10. Risks
@@ -361,7 +362,7 @@ End-to-end (after V.3):
## 11. Approval checklist
- [x] Architectural direction approved — port from gemini-cli (§4)
-- [x] Setting name + default decided — `ui.useTerminalBuffer`, default `false` (opt-in)
+- [x] Setting name + default decided — `ui.useTerminalBuffer`, default `true` (opt-out)
- [x] Static-item heuristic — `isStaticItem={(item) => item.id > 0}` (completed history items)
- [x] Mouse-support scope — deferred to V.4; keyboard-only scroll in #4146
- [x] Merge ordering with #3905 (§9.5) — #3905 already in `main`; #4146 preserves the legacy progressive-replay path and supersedes it only for VP users
diff --git a/docs/users/reference/keyboard-shortcuts.md b/docs/users/reference/keyboard-shortcuts.md
index e5d257e7189..c43ee66cc18 100644
--- a/docs/users/reference/keyboard-shortcuts.md
+++ b/docs/users/reference/keyboard-shortcuts.md
@@ -69,7 +69,7 @@ This document lists the available keyboard shortcuts in Qwen Code.
## History scrollback
-Active only when `ui.useTerminalBuffer` is enabled (Settings → UI → Virtualized History). In that mode conversation history is rendered inside an in-app viewport instead of the host terminal scrollback, so the keys below replace the terminal's native scroll.
+Active when `ui.useTerminalBuffer` is enabled (Settings → UI → Virtualized History) and screen reader mode is off, which is the default for non-screen-reader sessions. In that mode conversation history is rendered inside an in-app viewport instead of the host terminal scrollback, so the keys below replace the terminal's native scroll.
| Shortcut | Description |
| --------------- | ---------------------------------------------------- |
@@ -87,7 +87,7 @@ When `ui.useTerminalBuffer` is on, the terminal forwards mouse events to qwen-co
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, enable `ui.useTerminalBuffer`; then use `Shift+Up` / `Shift+Down`, or the mouse wheel when tmux forwards wheel events to the app. 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. 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 bbca2b67b69..afc9ba41ee0 100644
--- a/docs/users/support/troubleshooting.md
+++ b/docs/users/support/troubleshooting.md
@@ -88,7 +88,7 @@ This guide provides solutions to common issues and debugging tips, including top
- **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:** Enable `ui.useTerminalBuffer`; then use `Shift+Up` / `Shift+Down`, or the mouse wheel when tmux forwards wheel events to the app. 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. If you prefer host scrollback, adjust your tmux mouse bindings for wheel events.
## IDE Companion not connecting
diff --git a/packages/cli/src/config/settingsSchema.test.ts b/packages/cli/src/config/settingsSchema.test.ts
index ff0bfe4d48e..2ed2c0d26b1 100644
--- a/packages/cli/src/config/settingsSchema.test.ts
+++ b/packages/cli/src/config/settingsSchema.test.ts
@@ -319,9 +319,9 @@ describe('SettingsSchema', () => {
getSettingsSchema().ui.properties.useTerminalBuffer;
expect(useTerminalBuffer).toBeDefined();
expect(useTerminalBuffer.type).toBe('boolean');
- expect(useTerminalBuffer.default).toBe(false);
+ expect(useTerminalBuffer.default).toBe(true);
expect(useTerminalBuffer.showInDialog).toBe(true);
- expect(useTerminalBuffer.requiresRestart).toBe(false);
+ expect(useTerminalBuffer.requiresRestart).toBe(true);
});
it('should expose response tokens/sec as an opt-in UI setting', () => {
diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts
index 31bb7f96f60..0a3aa2050ce 100644
--- a/packages/cli/src/config/settingsSchema.ts
+++ b/packages/cli/src/config/settingsSchema.ts
@@ -962,10 +962,10 @@ const SETTINGS_SCHEMA = {
type: 'boolean',
label: 'Virtualized History (reduces flicker on long sessions)',
category: 'UI',
- requiresRestart: false,
- default: false,
+ requiresRestart: true,
+ default: true,
description:
- 'Render conversation history in an in-app scrollable viewport instead of the terminal scrollback buffer. Recommended if you see flicker, scroll-storm, or interface freeze on long sessions, after Ctrl+O, after Ctrl+E / Ctrl+F (expand), after window resize, or when alt-tabbing back. Scroll with Shift+↑/↓ (line), PgUp/PgDn (page), Ctrl+Home/End (top/bottom), or the mouse wheel. Does NOT use the host terminal scrollback while enabled; for native text selection, hold Shift (or Option on macOS) while dragging.',
+ 'Render conversation history in an in-app scrollable viewport instead of the terminal scrollback buffer. Enabled by default 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 uses append-only terminal output instead. Scroll with Shift+↑/↓ (line), PgUp/PgDn (page), Ctrl+Home/End (top/bottom), or the mouse wheel. Does NOT use the host terminal scrollback while enabled; for native text selection, hold Shift (or Option on macOS) while dragging.',
showInDialog: true,
},
shellOutputMaxLines: {
diff --git a/packages/cli/src/gemini.test.tsx b/packages/cli/src/gemini.test.tsx
index 1c156a8e401..abd379ff27a 100644
--- a/packages/cli/src/gemini.test.tsx
+++ b/packages/cli/src/gemini.test.tsx
@@ -1216,8 +1216,22 @@ describe('startInteractiveUI', () => {
render: vi.fn().mockReturnValue({ unmount: vi.fn() }),
}));
+ let initialExitListeners: NodeJS.ExitListener[] = [];
+
beforeEach(() => {
vi.clearAllMocks();
+ initialExitListeners = process.listeners('exit') as NodeJS.ExitListener[];
+ });
+
+ afterEach(() => {
+ const currentExitListeners = process.listeners(
+ 'exit',
+ ) as NodeJS.ExitListener[];
+ for (const listener of currentExitListeners) {
+ if (!initialExitListeners.includes(listener)) {
+ process.removeListener('exit', listener);
+ }
+ }
});
it('should render the UI with proper React context and exitOnCtrlC disabled', async () => {
@@ -1247,13 +1261,163 @@ describe('startInteractiveUI', () => {
expect(options).toEqual({
exitOnCtrlC: false,
isScreenReaderEnabled: false,
- alternateScreen: false,
+ alternateScreen: true,
});
// Verify React element structure is valid (but don't deep dive into JSX internals)
expect(reactElement).toBeDefined();
});
+ it('should not use alternate screen when VP mode is explicitly disabled', async () => {
+ const { render } = await import('ink');
+ const renderSpy = vi.mocked(render);
+ const legacySettings = {
+ ...mockSettings,
+ merged: {
+ ...mockSettings.merged,
+ ui: {
+ ...mockSettings.merged.ui,
+ useTerminalBuffer: false,
+ },
+ },
+ } as LoadedSettings;
+
+ const mockInitializationResult = {
+ authError: null,
+ themeError: null,
+ shouldOpenAuthDialog: false,
+ geminiMdFileCount: 0,
+ };
+
+ await startInteractiveUI(
+ mockConfig,
+ legacySettings,
+ mockStartupWarnings,
+ mockWorkspaceRoot,
+ mockInitializationResult,
+ );
+
+ const [, options] = renderSpy.mock.calls[0];
+ expect(options).toMatchObject({ alternateScreen: false });
+ });
+
+ it('should not use alternate screen in screen reader mode when VP mode is unset', async () => {
+ const { render } = await import('ink');
+ const renderSpy = vi.mocked(render);
+ const screenReaderConfig = {
+ ...mockConfig,
+ getScreenReader: () => true,
+ } as Config;
+
+ const mockInitializationResult = {
+ authError: null,
+ themeError: null,
+ shouldOpenAuthDialog: false,
+ geminiMdFileCount: 0,
+ };
+
+ await startInteractiveUI(
+ screenReaderConfig,
+ mockSettings,
+ mockStartupWarnings,
+ mockWorkspaceRoot,
+ mockInitializationResult,
+ );
+
+ const [, options] = renderSpy.mock.calls[0];
+ expect(options).toMatchObject({
+ isScreenReaderEnabled: true,
+ alternateScreen: false,
+ });
+ });
+
+ it('should not use alternate screen in screen reader mode even when VP mode is explicitly enabled', async () => {
+ const { render } = await import('ink');
+ const renderSpy = vi.mocked(render);
+ const screenReaderConfig = {
+ ...mockConfig,
+ getScreenReader: () => true,
+ } as Config;
+ const vpSettings = {
+ ...mockSettings,
+ merged: {
+ ...mockSettings.merged,
+ ui: {
+ ...mockSettings.merged.ui,
+ useTerminalBuffer: true,
+ },
+ },
+ } as LoadedSettings;
+
+ const mockInitializationResult = {
+ authError: null,
+ themeError: null,
+ shouldOpenAuthDialog: false,
+ geminiMdFileCount: 0,
+ };
+
+ await startInteractiveUI(
+ screenReaderConfig,
+ vpSettings,
+ mockStartupWarnings,
+ mockWorkspaceRoot,
+ mockInitializationResult,
+ );
+
+ const [, options] = renderSpy.mock.calls[0];
+ expect(options).toMatchObject({
+ isScreenReaderEnabled: true,
+ alternateScreen: false,
+ });
+ });
+
+ it('installs an alternate-screen exit safety net in VP mode', async () => {
+ const originalIsTTY = process.stdout.isTTY;
+ Object.defineProperty(process.stdout, 'isTTY', {
+ configurable: true,
+ value: true,
+ });
+ const writeSpy = vi
+ .spyOn(process.stdout, 'write')
+ .mockImplementation((() => true) as typeof process.stdout.write);
+
+ const mockInitializationResult = {
+ authError: null,
+ themeError: null,
+ shouldOpenAuthDialog: false,
+ geminiMdFileCount: 0,
+ };
+
+ try {
+ await startInteractiveUI(
+ mockConfig,
+ mockSettings,
+ mockStartupWarnings,
+ mockWorkspaceRoot,
+ mockInitializationResult,
+ );
+
+ const addedExitListeners = (
+ process.listeners('exit') as NodeJS.ExitListener[]
+ ).filter((listener) => !initialExitListeners.includes(listener));
+
+ expect(addedExitListeners.length).toBeGreaterThan(0);
+ for (const listener of addedExitListeners) {
+ listener(1);
+ }
+
+ expect(
+ writeSpy.mock.calls.some(([chunk]) => chunk === '\x1b[?25h\x1b[?1049l'),
+ ).toBe(true);
+ } finally {
+ writeSpy.mockRestore();
+ Object.defineProperty(process.stdout, 'isTTY', {
+ configurable: true,
+ value: originalIsTTY,
+ });
+ }
+ });
+
it('should perform all startup tasks in correct order', async () => {
const { getCliVersion } = await import('./utils/version.js');
const { checkForUpdates } = await import('./ui/utils/updateCheck.js');
diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx
index 489390a8345..90f4cd9cc88 100644
--- a/packages/cli/src/gemini.tsx
+++ b/packages/cli/src/gemini.tsx
@@ -113,6 +113,10 @@ import { RemoteInputWatcher } from './remoteInput/RemoteInputWatcher.js';
import { RemoteInputContext } from './remoteInput/RemoteInputContext.js';
import { installTerminalRedrawOptimizer } from './ui/utils/terminalRedrawOptimizer.js';
import { installSynchronizedOutput } from './ui/utils/synchronizedOutput.js';
+import {
+ installAlternateScreenExitHandler,
+ shouldUseVirtualViewport,
+} from './ui/utils/terminal-buffer.js';
const debugLogger = createDebugLogger('STARTUP');
@@ -364,7 +368,12 @@ export async function startInteractiveUI(
);
};
- const useVP = settings.merged.ui?.useTerminalBuffer ?? false;
+ const useVP = shouldUseVirtualViewport(
+ settings.merged.ui?.useTerminalBuffer,
+ config.getScreenReader(),
+ );
+ const removeAlternateScreenExitHandler =
+ installAlternateScreenExitHandler(useVP);
const instance = render(
process.env['DEBUG'] ? (
@@ -410,6 +419,7 @@ export async function startInteractiveUI(
// operational, preventing garbled terminal output after the app exits.
disableKittyProtocol();
instance.unmount();
+ removeAlternateScreenExitHandler();
restoreSynchronizedOutput();
restoreTerminalRedrawOptimizer();
});
diff --git a/packages/cli/src/ui/AppContainer.test.tsx b/packages/cli/src/ui/AppContainer.test.tsx
index 821bc39a0a3..5c7738e14a9 100644
--- a/packages/cli/src/ui/AppContainer.test.tsx
+++ b/packages/cli/src/ui/AppContainer.test.tsx
@@ -32,7 +32,7 @@ import {
type Mock,
} from 'vitest';
import { render, cleanup } from 'ink-testing-library';
-import { useContext, act } from 'react';
+import { useContext, useState, act } from 'react';
import {
AppContainer,
dedupeNewestFirst,
@@ -396,6 +396,7 @@ describe('AppContainer State Management', () => {
ui: {
showStatusInTitle: false,
hideWindowTitle: false,
+ useTerminalBuffer: false,
},
},
setValue: vi.fn(),
@@ -713,6 +714,107 @@ describe('AppContainer State Management', () => {
);
});
+ it('defaults to VP mode when useTerminalBuffer is unset', () => {
+ const defaultSettings = {
+ merged: {
+ hideTips: false,
+ theme: 'default',
+ ui: {
+ showStatusInTitle: false,
+ hideWindowTitle: false,
+ },
+ },
+ setValue: vi.fn(),
+ } as unknown as LoadedSettings;
+
+ render(
+ ,
+ );
+
+ expect(capturedUIState.useTerminalBuffer).toBe(true);
+ });
+
+ it('keeps screen reader mode on the Static path when useTerminalBuffer is unset', () => {
+ vi.spyOn(mockConfig, 'getScreenReader').mockReturnValue(true);
+ const defaultSettings = {
+ merged: {
+ hideTips: false,
+ theme: 'default',
+ ui: {
+ showStatusInTitle: false,
+ hideWindowTitle: false,
+ },
+ },
+ setValue: vi.fn(),
+ } as unknown as LoadedSettings;
+
+ render(
+ ,
+ );
+
+ expect(capturedUIState.useTerminalBuffer).toBe(false);
+ });
+
+ it('locks terminal buffer mode for the running session', () => {
+ const vpSettings = {
+ merged: {
+ hideTips: false,
+ theme: 'default',
+ ui: {
+ showStatusInTitle: false,
+ hideWindowTitle: false,
+ useTerminalBuffer: true,
+ },
+ },
+ setValue: vi.fn(),
+ } as unknown as LoadedSettings;
+ const legacySettings = {
+ merged: {
+ hideTips: false,
+ theme: 'default',
+ ui: {
+ showStatusInTitle: false,
+ hideWindowTitle: false,
+ useTerminalBuffer: false,
+ },
+ },
+ setValue: vi.fn(),
+ } as unknown as LoadedSettings;
+
+ vi.spyOn(mockConfig, 'initialize').mockResolvedValue(undefined);
+ let updateSettings!: (settings: LoadedSettings) => void;
+ function Wrapper() {
+ const [settings, setSettings] = useState(vpSettings);
+ updateSettings = setSettings;
+ return (
+
+ );
+ }
+
+ render();
+
+ expect(capturedUIState.useTerminalBuffer).toBe(true);
+
+ act(() => updateSettings(legacySettings));
+
+ expect(capturedUIState.useTerminalBuffer).toBe(true);
+ });
+
// #4891 changed the resize contract: width changes now trigger ONE full
// clearTerminal after RESIZE_REPAINT_SETTLE_MS (trailing-edge debounce),
// instead of never (#3967) or per-event (pre-#3967). This test pins the
diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx
index 3c98f48a421..a27617aa70f 100644
--- a/packages/cli/src/ui/AppContainer.tsx
+++ b/packages/cli/src/ui/AppContainer.tsx
@@ -116,6 +116,7 @@ import { useAuthCommand } from './auth/useAuth.js';
import { useEditorSettings } from './hooks/useEditorSettings.js';
import { usePreferredEditor } from './hooks/usePreferredEditor.js';
import { useSettingsCommand } from './hooks/useSettingsCommand.js';
+import { shouldUseVirtualViewport } from './utils/terminal-buffer.js';
import { useModelCommand } from './hooks/useModelCommand.js';
import { useArenaCommand } from './hooks/useArenaCommand.js';
import { useApprovalModeCommand } from './hooks/useApprovalModeCommand.js';
@@ -954,7 +955,12 @@ export const AppContainer = (props: AppContainerProps) => {
// visible refresh in VP mode comes for free from the React tree
// re-reading `mergedHistory` / `allVirtualItems` on whatever state
// change triggered refreshStatic (Ctrl+O, model change, etc.).
- const useTerminalBuffer = settings.merged.ui?.useTerminalBuffer ?? false;
+ const [useTerminalBuffer] = useState(() =>
+ shouldUseVirtualViewport(
+ settings.merged.ui?.useTerminalBuffer,
+ config.getScreenReader(),
+ ),
+ );
const refreshStatic = useCallback(() => {
if (!useTerminalBuffer) {
stdout.write(ansiEscapes.clearTerminal);
diff --git a/packages/cli/src/ui/utils/terminal-buffer.ts b/packages/cli/src/ui/utils/terminal-buffer.ts
new file mode 100644
index 00000000000..32b345b6494
--- /dev/null
+++ b/packages/cli/src/ui/utils/terminal-buffer.ts
@@ -0,0 +1,31 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+const RESTORE_TERMINAL_FROM_ALT_SCREEN = '\x1b[?25h\x1b[?1049l';
+
+export function shouldUseVirtualViewport(
+ useTerminalBuffer: boolean | undefined,
+ screenReader: boolean,
+): boolean {
+ return (useTerminalBuffer ?? true) && !screenReader;
+}
+
+export function installAlternateScreenExitHandler(
+ enabled: boolean,
+): () => void {
+ if (!enabled || !process.stdout.isTTY) {
+ return () => {};
+ }
+
+ const restoreTerminal = () => {
+ process.stdout.write(RESTORE_TERMINAL_FROM_ALT_SCREEN);
+ };
+
+ process.once('exit', restoreTerminal);
+ return () => {
+ process.removeListener('exit', restoreTerminal);
+ };
+}
diff --git a/packages/vscode-ide-companion/schemas/settings.schema.json b/packages/vscode-ide-companion/schemas/settings.schema.json
index f9661023379..508a2bee5d6 100644
--- a/packages/vscode-ide-companion/schemas/settings.schema.json
+++ b/packages/vscode-ide-companion/schemas/settings.schema.json
@@ -350,9 +350,9 @@
"default": false
},
"useTerminalBuffer": {
- "description": "Render conversation history in an in-app scrollable viewport instead of the terminal scrollback buffer. Recommended if you see flicker, scroll-storm, or interface freeze on long sessions, after Ctrl+O, after Ctrl+E / Ctrl+F (expand), after window resize, or when alt-tabbing back. Scroll with Shift+↑/↓ (line), PgUp/PgDn (page), Ctrl+Home/End (top/bottom), or the mouse wheel. Does NOT use the host terminal scrollback while enabled; for native text selection, hold Shift (or Option on macOS) while dragging.",
+ "description": "Render conversation history in an in-app scrollable viewport instead of the terminal scrollback buffer. Enabled by default 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 uses append-only terminal output instead. Scroll with Shift+↑/↓ (line), PgUp/PgDn (page), Ctrl+Home/End (top/bottom), or the mouse wheel. Does NOT use the host terminal scrollback while enabled; for native text selection, hold Shift (or Option on macOS) while dragging.",
"type": "boolean",
- "default": false
+ "default": true
},
"shellOutputMaxLines": {
"description": "Max number of shell output lines shown inline. Set to 0 to disable the cap and show full output. The hidden line count is still surfaced via the `+N lines` indicator.",
From 604e7332f14a8aecad55a2da982b6eb2f2cf645c Mon Sep 17 00:00:00 2001
From: hzb <991333136@qq.com>
Date: Fri, 26 Jun 2026 10:36:02 +0800
Subject: [PATCH 02/11] fix(cli): remove redundant alternate screen exit
handler
---
packages/cli/src/gemini.test.tsx | 47 -------------------
packages/cli/src/ui/startInteractiveUI.tsx | 8 +---
.../cli/src/ui/utils/terminal-buffer.test.ts | 25 ++++++++++
packages/cli/src/ui/utils/terminal-buffer.ts | 21 +--------
4 files changed, 28 insertions(+), 73 deletions(-)
create mode 100644 packages/cli/src/ui/utils/terminal-buffer.test.ts
diff --git a/packages/cli/src/gemini.test.tsx b/packages/cli/src/gemini.test.tsx
index 640c0224e6b..5636e49da94 100644
--- a/packages/cli/src/gemini.test.tsx
+++ b/packages/cli/src/gemini.test.tsx
@@ -1401,53 +1401,6 @@ describe('startInteractiveUI', () => {
});
});
- it('installs an alternate-screen exit safety net in VP mode', async () => {
- const originalIsTTY = process.stdout.isTTY;
- Object.defineProperty(process.stdout, 'isTTY', {
- configurable: true,
- value: true,
- });
- const writeSpy = vi
- .spyOn(process.stdout, 'write')
- .mockImplementation((() => true) as typeof process.stdout.write);
-
- const mockInitializationResult = {
- authError: null,
- themeError: null,
- shouldOpenAuthDialog: false,
- geminiMdFileCount: 0,
- };
-
- try {
- await startInteractiveUI(
- mockConfig,
- mockSettings,
- mockStartupWarnings,
- mockWorkspaceRoot,
- mockInitializationResult,
- );
-
- const addedExitListeners = (
- process.listeners('exit') as NodeJS.ExitListener[]
- ).filter((listener) => !initialExitListeners.includes(listener));
-
- expect(addedExitListeners.length).toBeGreaterThan(0);
- for (const listener of addedExitListeners) {
- listener(1);
- }
-
- expect(
- writeSpy.mock.calls.some(([chunk]) => chunk === '\x1b[?25h\x1b[?1049l'),
- ).toBe(true);
- } finally {
- writeSpy.mockRestore();
- Object.defineProperty(process.stdout, 'isTTY', {
- configurable: true,
- value: originalIsTTY,
- });
- }
- });
-
it('should perform all startup tasks in correct order', async () => {
const { getCliVersion } = await import('./utils/version.js');
const { checkForUpdates } = await import('./ui/utils/updateCheck.js');
diff --git a/packages/cli/src/ui/startInteractiveUI.tsx b/packages/cli/src/ui/startInteractiveUI.tsx
index 9234c00c332..65e5763ca2e 100644
--- a/packages/cli/src/ui/startInteractiveUI.tsx
+++ b/packages/cli/src/ui/startInteractiveUI.tsx
@@ -30,10 +30,7 @@ import { checkForUpdates } from './utils/updateCheck.js';
import { disableKittyProtocol } from './utils/kittyProtocolDetector.js';
import { installTerminalRedrawOptimizer } from './utils/terminalRedrawOptimizer.js';
import { installSynchronizedOutput } from './utils/synchronizedOutput.js';
-import {
- installAlternateScreenExitHandler,
- shouldUseVirtualViewport,
-} from './utils/terminal-buffer.js';
+import { shouldUseVirtualViewport } from './utils/terminal-buffer.js';
import { handleAutoUpdate } from '../utils/handleAutoUpdate.js';
import { registerCleanup } from '../utils/cleanup.js';
import { stopAndGetCapturedInput } from '../utils/earlyInputCapture.js';
@@ -179,8 +176,6 @@ export async function startInteractiveUI(
settings.merged.ui?.useTerminalBuffer,
config.getScreenReader(),
);
- const removeAlternateScreenExitHandler =
- installAlternateScreenExitHandler(useVP);
const instance = render(
process.env['DEBUG'] ? (
@@ -226,7 +221,6 @@ export async function startInteractiveUI(
// operational, preventing garbled terminal output after the app exits.
disableKittyProtocol();
instance.unmount();
- removeAlternateScreenExitHandler();
restoreSynchronizedOutput();
restoreTerminalRedrawOptimizer();
});
diff --git a/packages/cli/src/ui/utils/terminal-buffer.test.ts b/packages/cli/src/ui/utils/terminal-buffer.test.ts
new file mode 100644
index 00000000000..906b57633c3
--- /dev/null
+++ b/packages/cli/src/ui/utils/terminal-buffer.test.ts
@@ -0,0 +1,25 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { describe, expect, it } from 'vitest';
+import { shouldUseVirtualViewport } from './terminal-buffer.js';
+
+describe('shouldUseVirtualViewport', () => {
+ it('defaults to virtual viewport when the setting is unset', () => {
+ expect(shouldUseVirtualViewport(undefined, false)).toBe(true);
+ });
+
+ it('respects explicit terminal buffer settings', () => {
+ expect(shouldUseVirtualViewport(true, false)).toBe(true);
+ expect(shouldUseVirtualViewport(false, false)).toBe(false);
+ });
+
+ it('keeps screen-reader mode off the virtual viewport path', () => {
+ expect(shouldUseVirtualViewport(undefined, true)).toBe(false);
+ expect(shouldUseVirtualViewport(true, true)).toBe(false);
+ expect(shouldUseVirtualViewport(false, true)).toBe(false);
+ });
+});
diff --git a/packages/cli/src/ui/utils/terminal-buffer.ts b/packages/cli/src/ui/utils/terminal-buffer.ts
index 32b345b6494..ab04e6ca156 100644
--- a/packages/cli/src/ui/utils/terminal-buffer.ts
+++ b/packages/cli/src/ui/utils/terminal-buffer.ts
@@ -4,28 +4,11 @@
* SPDX-License-Identifier: Apache-2.0
*/
-const RESTORE_TERMINAL_FROM_ALT_SCREEN = '\x1b[?25h\x1b[?1049l';
-
export function shouldUseVirtualViewport(
useTerminalBuffer: boolean | undefined,
screenReader: boolean,
): boolean {
+ // The settings loader does not apply schema defaults, so keep this fallback
+ // in sync with settingsSchema.ts's default for ui.useTerminalBuffer.
return (useTerminalBuffer ?? true) && !screenReader;
}
-
-export function installAlternateScreenExitHandler(
- enabled: boolean,
-): () => void {
- if (!enabled || !process.stdout.isTTY) {
- return () => {};
- }
-
- const restoreTerminal = () => {
- process.stdout.write(RESTORE_TERMINAL_FROM_ALT_SCREEN);
- };
-
- process.once('exit', restoreTerminal);
- return () => {
- process.removeListener('exit', restoreTerminal);
- };
-}
From 8632a62a516c55b6c280be32b8ad9aeff8c7fe05 Mon Sep 17 00:00:00 2001
From: hzb <991333136@qq.com>
Date: Sun, 5 Jul 2026 14:45:45 +0800
Subject: [PATCH 03/11] fix(cli): keep non-interactive output off VP mode
---
docs/design/virtual-viewport/README.md | 24 +++++++++--
packages/cli/src/config/settingsSchema.ts | 2 +-
packages/cli/src/gemini.test.tsx | 41 ++++++++++++++++++
packages/cli/src/ui/AppContainer.test.tsx | 14 ++++++
packages/cli/src/ui/AppContainer.tsx | 6 ++-
packages/cli/src/ui/startInteractiveUI.tsx | 6 ++-
.../cli/src/ui/utils/terminal-buffer.test.ts | 43 ++++++++++++++++---
packages/cli/src/ui/utils/terminal-buffer.ts | 26 ++++++++++-
.../schemas/settings.schema.json | 2 +-
9 files changed, 148 insertions(+), 16 deletions(-)
diff --git a/docs/design/virtual-viewport/README.md b/docs/design/virtual-viewport/README.md
index 3738cb09f6a..004b49a92ff 100644
--- a/docs/design/virtual-viewport/README.md
+++ b/docs/design/virtual-viewport/README.md
@@ -141,19 +141,35 @@ ui: {
}
```
-`MainContent.tsx` reads the setting and switches paths:
+`AppContainer.tsx` freezes the startup decision so it stays in sync with
+Ink's `alternateScreen` lifetime:
```tsx
-const useTerminalBuffer = uiState.settings?.ui?.useTerminalBuffer ?? true;
+const [useTerminalBuffer] = useState(() =>
+ shouldUseVirtualViewport(
+ settings.merged.ui?.useTerminalBuffer,
+ config.getScreenReader(),
+ isInteractiveTerminal(),
+ ),
+);
+```
+
+`MainContent.tsx` then reads the frozen UI state and switches paths:
+
+```tsx
+const useVirtualScroll = uiState.useTerminalBuffer;
-if (useTerminalBuffer) {
+if (useVirtualScroll) {
return ; // virtualized
}
return ; // existing path, untouched
```
-The legacy `` path stays available for users who explicitly opt out.
+The legacy `` path stays available for users who explicitly opt out,
+for screen-reader mode, and for non-interactive output such as piped stdout or
+CI. Because the decision controls Ink's alternate screen, changes to
+`ui.useTerminalBuffer` require a restart.
## 6. Key adaptations from gemini-cli source
diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts
index 93f34424ff0..5c90c4a3a92 100644
--- a/packages/cli/src/config/settingsSchema.ts
+++ b/packages/cli/src/config/settingsSchema.ts
@@ -1023,7 +1023,7 @@ const SETTINGS_SCHEMA = {
requiresRestart: true,
default: true,
description:
- 'Render conversation history in an in-app scrollable viewport instead of the terminal scrollback buffer. Enabled by default 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 uses 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; for native text selection, hold Shift (or Option on macOS) while dragging.',
+ 'Render conversation history in an in-app scrollable viewport instead of the terminal scrollback buffer. Enabled by default in 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; for native text selection, hold Shift (or Option on macOS) while dragging.',
showInDialog: true,
},
shellOutputMaxLines: {
diff --git a/packages/cli/src/gemini.test.tsx b/packages/cli/src/gemini.test.tsx
index c81e0c1f84d..22a60c37ba8 100644
--- a/packages/cli/src/gemini.test.tsx
+++ b/packages/cli/src/gemini.test.tsx
@@ -1263,13 +1263,27 @@ describe('startInteractiveUI', () => {
}));
let initialExitListeners: NodeJS.ExitListener[] = [];
+ let originalStdoutIsTTY: boolean | undefined;
beforeEach(() => {
vi.clearAllMocks();
+ originalStdoutIsTTY = process.stdout.isTTY;
+ Object.defineProperty(process.stdout, 'isTTY', {
+ value: true,
+ configurable: true,
+ });
initialExitListeners = process.listeners('exit') as NodeJS.ExitListener[];
});
afterEach(() => {
+ if (originalStdoutIsTTY === undefined) {
+ delete (process.stdout as { isTTY?: unknown }).isTTY;
+ } else {
+ Object.defineProperty(process.stdout, 'isTTY', {
+ value: originalStdoutIsTTY,
+ configurable: true,
+ });
+ }
const currentExitListeners = process.listeners(
'exit',
) as NodeJS.ExitListener[];
@@ -1347,6 +1361,33 @@ describe('startInteractiveUI', () => {
expect(options).toMatchObject({ alternateScreen: false });
});
+ it('should not use alternate screen when stdout is not interactive', async () => {
+ Object.defineProperty(process.stdout, 'isTTY', {
+ value: false,
+ configurable: true,
+ });
+ const { render } = await import('ink');
+ const renderSpy = vi.mocked(render);
+
+ const mockInitializationResult = {
+ authError: null,
+ themeError: null,
+ shouldOpenAuthDialog: false,
+ geminiMdFileCount: 0,
+ };
+
+ await startInteractiveUI(
+ mockConfig,
+ mockSettings,
+ mockStartupWarnings,
+ mockWorkspaceRoot,
+ mockInitializationResult,
+ );
+
+ const [, options] = renderSpy.mock.calls[0];
+ expect(options).toMatchObject({ alternateScreen: false });
+ });
+
it('should not use alternate screen in screen reader mode when VP mode is unset', async () => {
const { render } = await import('ink');
const renderSpy = vi.mocked(render);
diff --git a/packages/cli/src/ui/AppContainer.test.tsx b/packages/cli/src/ui/AppContainer.test.tsx
index 9b036a5dcb8..ceee62b7b09 100644
--- a/packages/cli/src/ui/AppContainer.test.tsx
+++ b/packages/cli/src/ui/AppContainer.test.tsx
@@ -207,9 +207,15 @@ describe('AppContainer State Management', () => {
const mockedUseLoadingIndicator = useLoadingIndicator as Mock;
const mockedUseTerminalSize = useTerminalSize as Mock;
const mockedUseKeypress = useKeypress as Mock;
+ let originalStdoutIsTTY: boolean | undefined;
beforeEach(() => {
vi.clearAllMocks();
+ originalStdoutIsTTY = process.stdout.isTTY;
+ Object.defineProperty(process.stdout, 'isTTY', {
+ value: true,
+ configurable: true,
+ });
// Initialize mock stdout for terminal title tests
mockStdout = { write: vi.fn() };
@@ -412,6 +418,14 @@ describe('AppContainer State Management', () => {
});
afterEach(() => {
+ if (originalStdoutIsTTY === undefined) {
+ delete (process.stdout as { isTTY?: unknown }).isTTY;
+ } else {
+ Object.defineProperty(process.stdout, 'isTTY', {
+ value: originalStdoutIsTTY,
+ configurable: true,
+ });
+ }
cleanup();
});
diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx
index e55e2b3783c..2827faa4702 100644
--- a/packages/cli/src/ui/AppContainer.tsx
+++ b/packages/cli/src/ui/AppContainer.tsx
@@ -116,7 +116,10 @@ import { useAuthCommand } from './auth/useAuth.js';
import { useEditorSettings } from './hooks/useEditorSettings.js';
import { usePreferredEditor } from './hooks/usePreferredEditor.js';
import { useSettingsCommand } from './hooks/useSettingsCommand.js';
-import { shouldUseVirtualViewport } from './utils/terminal-buffer.js';
+import {
+ isInteractiveTerminal,
+ shouldUseVirtualViewport,
+} from './utils/terminal-buffer.js';
import { useModelCommand } from './hooks/useModelCommand.js';
import { useArenaCommand } from './hooks/useArenaCommand.js';
import { useApprovalModeCommand } from './hooks/useApprovalModeCommand.js';
@@ -970,6 +973,7 @@ export const AppContainer = (props: AppContainerProps) => {
shouldUseVirtualViewport(
settings.merged.ui?.useTerminalBuffer,
config.getScreenReader(),
+ isInteractiveTerminal(),
),
);
const refreshStatic = useCallback(() => {
diff --git a/packages/cli/src/ui/startInteractiveUI.tsx b/packages/cli/src/ui/startInteractiveUI.tsx
index 65e5763ca2e..79a40ba8195 100644
--- a/packages/cli/src/ui/startInteractiveUI.tsx
+++ b/packages/cli/src/ui/startInteractiveUI.tsx
@@ -30,7 +30,10 @@ import { checkForUpdates } from './utils/updateCheck.js';
import { disableKittyProtocol } from './utils/kittyProtocolDetector.js';
import { installTerminalRedrawOptimizer } from './utils/terminalRedrawOptimizer.js';
import { installSynchronizedOutput } from './utils/synchronizedOutput.js';
-import { shouldUseVirtualViewport } from './utils/terminal-buffer.js';
+import {
+ isInteractiveTerminal,
+ shouldUseVirtualViewport,
+} from './utils/terminal-buffer.js';
import { handleAutoUpdate } from '../utils/handleAutoUpdate.js';
import { registerCleanup } from '../utils/cleanup.js';
import { stopAndGetCapturedInput } from '../utils/earlyInputCapture.js';
@@ -175,6 +178,7 @@ export async function startInteractiveUI(
const useVP = shouldUseVirtualViewport(
settings.merged.ui?.useTerminalBuffer,
config.getScreenReader(),
+ isInteractiveTerminal(),
);
const instance = render(
process.env['DEBUG'] ? (
diff --git a/packages/cli/src/ui/utils/terminal-buffer.test.ts b/packages/cli/src/ui/utils/terminal-buffer.test.ts
index 906b57633c3..29ffa92677f 100644
--- a/packages/cli/src/ui/utils/terminal-buffer.test.ts
+++ b/packages/cli/src/ui/utils/terminal-buffer.test.ts
@@ -5,21 +5,50 @@
*/
import { describe, expect, it } from 'vitest';
-import { shouldUseVirtualViewport } from './terminal-buffer.js';
+import { getSettingsSchema } from '../../config/settingsSchema.js';
+import {
+ isInteractiveTerminal,
+ shouldUseVirtualViewport,
+} from './terminal-buffer.js';
describe('shouldUseVirtualViewport', () => {
it('defaults to virtual viewport when the setting is unset', () => {
- expect(shouldUseVirtualViewport(undefined, false)).toBe(true);
+ expect(shouldUseVirtualViewport(undefined, false, true)).toBe(
+ getSettingsSchema().ui.properties.useTerminalBuffer.default,
+ );
});
it('respects explicit terminal buffer settings', () => {
- expect(shouldUseVirtualViewport(true, false)).toBe(true);
- expect(shouldUseVirtualViewport(false, false)).toBe(false);
+ expect(shouldUseVirtualViewport(true, false, true)).toBe(true);
+ expect(shouldUseVirtualViewport(false, false, true)).toBe(false);
});
it('keeps screen-reader mode off the virtual viewport path', () => {
- expect(shouldUseVirtualViewport(undefined, true)).toBe(false);
- expect(shouldUseVirtualViewport(true, true)).toBe(false);
- expect(shouldUseVirtualViewport(false, true)).toBe(false);
+ expect(shouldUseVirtualViewport(undefined, true, true)).toBe(false);
+ expect(shouldUseVirtualViewport(true, true, true)).toBe(false);
+ expect(shouldUseVirtualViewport(false, true, true)).toBe(false);
+ });
+
+ it('keeps non-interactive output on the legacy append-only path', () => {
+ expect(shouldUseVirtualViewport(undefined, false, false)).toBe(false);
+ expect(shouldUseVirtualViewport(true, false, false)).toBe(false);
+ });
+});
+
+describe('isInteractiveTerminal', () => {
+ it('requires a TTY stdout outside CI', () => {
+ expect(isInteractiveTerminal(true, {})).toBe(true);
+ expect(isInteractiveTerminal(false, {})).toBe(false);
+ expect(isInteractiveTerminal(undefined, {})).toBe(false);
+ });
+
+ it('treats CI sessions as non-interactive unless CI is explicitly disabled', () => {
+ expect(isInteractiveTerminal(true, { CI: 'true' })).toBe(false);
+ expect(
+ isInteractiveTerminal(true, { CONTINUOUS_INTEGRATION: 'true' }),
+ ).toBe(false);
+ expect(isInteractiveTerminal(true, { CI_NAME: 'buildkite' })).toBe(false);
+ expect(isInteractiveTerminal(true, { CI: '0' })).toBe(true);
+ expect(isInteractiveTerminal(true, { CI: 'false' })).toBe(true);
});
});
diff --git a/packages/cli/src/ui/utils/terminal-buffer.ts b/packages/cli/src/ui/utils/terminal-buffer.ts
index ab04e6ca156..86392852317 100644
--- a/packages/cli/src/ui/utils/terminal-buffer.ts
+++ b/packages/cli/src/ui/utils/terminal-buffer.ts
@@ -4,11 +4,35 @@
* SPDX-License-Identifier: Apache-2.0
*/
+import process from 'node:process';
+
+type TerminalEnvironment = Record;
+
+function isCiEnvironment(env: TerminalEnvironment): boolean {
+ if (env['CI'] === '0' || env['CI'] === 'false') {
+ return false;
+ }
+
+ return (
+ 'CI' in env ||
+ 'CONTINUOUS_INTEGRATION' in env ||
+ Object.keys(env).some((key) => key.startsWith('CI_'))
+ );
+}
+
+export function isInteractiveTerminal(
+ stdoutIsTTY: boolean | undefined = process.stdout.isTTY,
+ env: TerminalEnvironment = process.env,
+): boolean {
+ return Boolean(stdoutIsTTY) && !isCiEnvironment(env);
+}
+
export function shouldUseVirtualViewport(
useTerminalBuffer: boolean | undefined,
screenReader: boolean,
+ terminalInteractive: boolean,
): boolean {
// The settings loader does not apply schema defaults, so keep this fallback
// in sync with settingsSchema.ts's default for ui.useTerminalBuffer.
- return (useTerminalBuffer ?? true) && !screenReader;
+ return terminalInteractive && (useTerminalBuffer ?? true) && !screenReader;
}
diff --git a/packages/vscode-ide-companion/schemas/settings.schema.json b/packages/vscode-ide-companion/schemas/settings.schema.json
index 7c582a9ee0d..b8462d049d3 100644
--- a/packages/vscode-ide-companion/schemas/settings.schema.json
+++ b/packages/vscode-ide-companion/schemas/settings.schema.json
@@ -372,7 +372,7 @@
"default": false
},
"useTerminalBuffer": {
- "description": "Render conversation history in an in-app scrollable viewport instead of the terminal scrollback buffer. Enabled by default 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 uses append-only terminal output instead. Scroll with Shift+\u2191/\u2193 (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; for native text selection, hold Shift (or Option on macOS) while dragging.",
+ "description": "Render conversation history in an in-app scrollable viewport instead of the terminal scrollback buffer. Enabled by default in 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; for native text selection, hold Shift (or Option on macOS) while dragging.",
"type": "boolean",
"default": true
},
From 975d216d57a94edcf5bf390b67b74b8a08b841e0 Mon Sep 17 00:00:00 2001
From: hzb <991333136@qq.com>
Date: Mon, 6 Jul 2026 10:19:13 +0800
Subject: [PATCH 04/11] fix(cli): stabilize VP tests in CI environments
---
packages/cli/src/gemini.test.tsx | 4 ++
packages/cli/src/test-utils/ci-env.ts | 38 +++++++++++++++++++
packages/cli/src/ui/AppContainer.test.tsx | 4 ++
.../cli/src/ui/utils/terminal-buffer.test.ts | 13 +++++++
packages/cli/src/ui/utils/terminal-buffer.ts | 18 +++++----
5 files changed, 70 insertions(+), 7 deletions(-)
create mode 100644 packages/cli/src/test-utils/ci-env.ts
diff --git a/packages/cli/src/gemini.test.tsx b/packages/cli/src/gemini.test.tsx
index 22a60c37ba8..4432d5d9f19 100644
--- a/packages/cli/src/gemini.test.tsx
+++ b/packages/cli/src/gemini.test.tsx
@@ -21,6 +21,7 @@ import {
validateDnsResolutionOrder,
} from './gemini.js';
import { startInteractiveUI } from './ui/startInteractiveUI.js';
+import { clearCiEnv } from './test-utils/ci-env.js';
import type { CliArgs } from './config/config.js';
import { type LoadedSettings } from './config/settings.js';
import { appEvents, AppEvent } from './utils/events.js';
@@ -1264,9 +1265,11 @@ describe('startInteractiveUI', () => {
let initialExitListeners: NodeJS.ExitListener[] = [];
let originalStdoutIsTTY: boolean | undefined;
+ let restoreCiEnv = () => {};
beforeEach(() => {
vi.clearAllMocks();
+ restoreCiEnv = clearCiEnv();
originalStdoutIsTTY = process.stdout.isTTY;
Object.defineProperty(process.stdout, 'isTTY', {
value: true,
@@ -1284,6 +1287,7 @@ describe('startInteractiveUI', () => {
configurable: true,
});
}
+ restoreCiEnv();
const currentExitListeners = process.listeners(
'exit',
) as NodeJS.ExitListener[];
diff --git a/packages/cli/src/test-utils/ci-env.ts b/packages/cli/src/test-utils/ci-env.ts
new file mode 100644
index 00000000000..128b0d381a1
--- /dev/null
+++ b/packages/cli/src/test-utils/ci-env.ts
@@ -0,0 +1,38 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+function isCiEnvKey(key: string): boolean {
+ return (
+ key === 'CI' || key === 'CONTINUOUS_INTEGRATION' || key.startsWith('CI_')
+ );
+}
+
+export function clearCiEnv(): () => void {
+ const saved = new Map();
+
+ for (const key of Object.keys(process.env)) {
+ if (isCiEnvKey(key)) {
+ saved.set(key, process.env[key]);
+ delete process.env[key];
+ }
+ }
+
+ return () => {
+ for (const key of Object.keys(process.env)) {
+ if (isCiEnvKey(key) && !saved.has(key)) {
+ delete process.env[key];
+ }
+ }
+
+ for (const [key, value] of saved) {
+ if (value === undefined) {
+ delete process.env[key];
+ } else {
+ process.env[key] = value;
+ }
+ }
+ };
+}
diff --git a/packages/cli/src/ui/AppContainer.test.tsx b/packages/cli/src/ui/AppContainer.test.tsx
index ceee62b7b09..f310d938b45 100644
--- a/packages/cli/src/ui/AppContainer.test.tsx
+++ b/packages/cli/src/ui/AppContainer.test.tsx
@@ -177,6 +177,7 @@ import { useLoadingIndicator } from './hooks/useLoadingIndicator.js';
import { useTerminalSize } from './hooks/useTerminalSize.js';
import { useKeypress, type Key } from './hooks/useKeypress.js';
import { ShellExecutionService } from '@qwen-code/qwen-code-core';
+import { clearCiEnv } from '../test-utils/ci-env.js';
describe('AppContainer State Management', () => {
let mockConfig: Config;
@@ -208,9 +209,11 @@ describe('AppContainer State Management', () => {
const mockedUseTerminalSize = useTerminalSize as Mock;
const mockedUseKeypress = useKeypress as Mock;
let originalStdoutIsTTY: boolean | undefined;
+ let restoreCiEnv = () => {};
beforeEach(() => {
vi.clearAllMocks();
+ restoreCiEnv = clearCiEnv();
originalStdoutIsTTY = process.stdout.isTTY;
Object.defineProperty(process.stdout, 'isTTY', {
value: true,
@@ -426,6 +429,7 @@ describe('AppContainer State Management', () => {
configurable: true,
});
}
+ restoreCiEnv();
cleanup();
});
diff --git a/packages/cli/src/ui/utils/terminal-buffer.test.ts b/packages/cli/src/ui/utils/terminal-buffer.test.ts
index 29ffa92677f..6d7cd5e4055 100644
--- a/packages/cli/src/ui/utils/terminal-buffer.test.ts
+++ b/packages/cli/src/ui/utils/terminal-buffer.test.ts
@@ -48,7 +48,20 @@ describe('isInteractiveTerminal', () => {
isInteractiveTerminal(true, { CONTINUOUS_INTEGRATION: 'true' }),
).toBe(false);
expect(isInteractiveTerminal(true, { CI_NAME: 'buildkite' })).toBe(false);
+ expect(isInteractiveTerminal(true, { CI: '' })).toBe(true);
expect(isInteractiveTerminal(true, { CI: '0' })).toBe(true);
expect(isInteractiveTerminal(true, { CI: 'false' })).toBe(true);
+ expect(isInteractiveTerminal(true, { CONTINUOUS_INTEGRATION: '' })).toBe(
+ true,
+ );
+ expect(isInteractiveTerminal(true, { CONTINUOUS_INTEGRATION: '0' })).toBe(
+ true,
+ );
+ expect(
+ isInteractiveTerminal(true, { CONTINUOUS_INTEGRATION: 'false' }),
+ ).toBe(true);
+ expect(isInteractiveTerminal(true, { CI_NAME: '' })).toBe(true);
+ expect(isInteractiveTerminal(true, { CI_NAME: '0' })).toBe(true);
+ expect(isInteractiveTerminal(true, { CI_NAME: 'false' })).toBe(true);
});
});
diff --git a/packages/cli/src/ui/utils/terminal-buffer.ts b/packages/cli/src/ui/utils/terminal-buffer.ts
index 86392852317..7a72a35824f 100644
--- a/packages/cli/src/ui/utils/terminal-buffer.ts
+++ b/packages/cli/src/ui/utils/terminal-buffer.ts
@@ -8,15 +8,19 @@ import process from 'node:process';
type TerminalEnvironment = Record;
-function isCiEnvironment(env: TerminalEnvironment): boolean {
- if (env['CI'] === '0' || env['CI'] === 'false') {
- return false;
- }
+function isActiveCiValue(value: string | undefined): boolean {
+ return (
+ value !== undefined && value !== '' && value !== '0' && value !== 'false'
+ );
+}
+function isCiEnvironment(env: TerminalEnvironment): boolean {
return (
- 'CI' in env ||
- 'CONTINUOUS_INTEGRATION' in env ||
- Object.keys(env).some((key) => key.startsWith('CI_'))
+ isActiveCiValue(env['CI']) ||
+ isActiveCiValue(env['CONTINUOUS_INTEGRATION']) ||
+ Object.keys(env).some(
+ (key) => key.startsWith('CI_') && isActiveCiValue(env[key]),
+ )
);
}
From 50187daee0480a898317dd3fe29f98f59df7a9ad Mon Sep 17 00:00:00 2001
From: hzb <991333136@qq.com>
Date: Mon, 6 Jul 2026 14:24:40 +0800
Subject: [PATCH 05/11] test(cli): resolve SDK daemon source in vitest
---
packages/cli/vitest.config.ts | 4 ++++
1 file changed, 4 insertions(+)
diff --git a/packages/cli/vitest.config.ts b/packages/cli/vitest.config.ts
index fe51cd9b18c..ac07674d99a 100644
--- a/packages/cli/vitest.config.ts
+++ b/packages/cli/vitest.config.ts
@@ -77,6 +77,10 @@ export default defineConfig({
__dirname,
'../audio-capture/src/index.ts',
),
+ '@qwen-code/sdk/daemon': path.resolve(
+ __dirname,
+ '../sdk-typescript/src/daemon/index.ts',
+ ),
},
},
test: {
From 81daecf39580e451acf98582117f4178fb05c37f Mon Sep 17 00:00:00 2001
From: hzb <991333136@qq.com>
Date: Mon, 6 Jul 2026 14:28:38 +0800
Subject: [PATCH 06/11] fix(cli): normalize CI env checks for VP mode
---
packages/cli/src/test-utils/ci-env.ts | 6 +-----
.../cli/src/ui/utils/terminal-buffer.test.ts | 5 +++++
packages/cli/src/ui/utils/terminal-buffer.ts | 20 ++++++++++++-------
3 files changed, 19 insertions(+), 12 deletions(-)
diff --git a/packages/cli/src/test-utils/ci-env.ts b/packages/cli/src/test-utils/ci-env.ts
index 128b0d381a1..80d9cc016fd 100644
--- a/packages/cli/src/test-utils/ci-env.ts
+++ b/packages/cli/src/test-utils/ci-env.ts
@@ -4,11 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
-function isCiEnvKey(key: string): boolean {
- return (
- key === 'CI' || key === 'CONTINUOUS_INTEGRATION' || key.startsWith('CI_')
- );
-}
+import { isCiEnvKey } from '../ui/utils/terminal-buffer.js';
export function clearCiEnv(): () => void {
const saved = new Map();
diff --git a/packages/cli/src/ui/utils/terminal-buffer.test.ts b/packages/cli/src/ui/utils/terminal-buffer.test.ts
index 6d7cd5e4055..9a6ff91c2db 100644
--- a/packages/cli/src/ui/utils/terminal-buffer.test.ts
+++ b/packages/cli/src/ui/utils/terminal-buffer.test.ts
@@ -51,6 +51,7 @@ describe('isInteractiveTerminal', () => {
expect(isInteractiveTerminal(true, { CI: '' })).toBe(true);
expect(isInteractiveTerminal(true, { CI: '0' })).toBe(true);
expect(isInteractiveTerminal(true, { CI: 'false' })).toBe(true);
+ expect(isInteractiveTerminal(true, { CI: 'False' })).toBe(true);
expect(isInteractiveTerminal(true, { CONTINUOUS_INTEGRATION: '' })).toBe(
true,
);
@@ -60,8 +61,12 @@ describe('isInteractiveTerminal', () => {
expect(
isInteractiveTerminal(true, { CONTINUOUS_INTEGRATION: 'false' }),
).toBe(true);
+ expect(
+ isInteractiveTerminal(true, { CONTINUOUS_INTEGRATION: 'FALSE' }),
+ ).toBe(true);
expect(isInteractiveTerminal(true, { CI_NAME: '' })).toBe(true);
expect(isInteractiveTerminal(true, { CI_NAME: '0' })).toBe(true);
expect(isInteractiveTerminal(true, { CI_NAME: 'false' })).toBe(true);
+ expect(isInteractiveTerminal(true, { CI_NAME: 'False' })).toBe(true);
});
});
diff --git a/packages/cli/src/ui/utils/terminal-buffer.ts b/packages/cli/src/ui/utils/terminal-buffer.ts
index 7a72a35824f..dc9995baf10 100644
--- a/packages/cli/src/ui/utils/terminal-buffer.ts
+++ b/packages/cli/src/ui/utils/terminal-buffer.ts
@@ -8,19 +8,25 @@ import process from 'node:process';
type TerminalEnvironment = Record;
+export function isCiEnvKey(key: string): boolean {
+ return (
+ key === 'CI' || key === 'CONTINUOUS_INTEGRATION' || key.startsWith('CI_')
+ );
+}
+
function isActiveCiValue(value: string | undefined): boolean {
+ const normalizedValue = value?.toLowerCase();
return (
- value !== undefined && value !== '' && value !== '0' && value !== 'false'
+ value !== undefined &&
+ value !== '' &&
+ normalizedValue !== '0' &&
+ normalizedValue !== 'false'
);
}
function isCiEnvironment(env: TerminalEnvironment): boolean {
- return (
- isActiveCiValue(env['CI']) ||
- isActiveCiValue(env['CONTINUOUS_INTEGRATION']) ||
- Object.keys(env).some(
- (key) => key.startsWith('CI_') && isActiveCiValue(env[key]),
- )
+ return Object.keys(env).some(
+ (key) => isCiEnvKey(key) && isActiveCiValue(env[key]),
);
}
From ddc0c31d7442d93a2d69d0d62d6d95ce48478258 Mon Sep 17 00:00:00 2001
From: hzb <991333136@qq.com>
Date: Mon, 6 Jul 2026 15:49:50 +0800
Subject: [PATCH 07/11] fix(cli): keep default VP mouse interactions enabled
---
packages/cli/src/ui/AppContainer.test.tsx | 27 ++++++++++++++++
packages/cli/src/ui/AppContainer.tsx | 18 +++++++----
.../InputPrompt.suggestionMouse.test.tsx | 32 +++++++++++++++++--
.../cli/src/ui/components/InputPrompt.tsx | 2 +-
.../shared/BaseSelectionList.mouse.test.tsx | 15 +++++++++
.../components/shared/BaseSelectionList.tsx | 5 ++-
.../cli/src/ui/hooks/useMouseEvents.test.tsx | 25 +++++++++++++++
packages/cli/src/ui/hooks/useMouseEvents.ts | 7 +++-
packages/cli/src/ui/startInteractiveUI.tsx | 12 ++++---
9 files changed, 125 insertions(+), 18 deletions(-)
diff --git a/packages/cli/src/ui/AppContainer.test.tsx b/packages/cli/src/ui/AppContainer.test.tsx
index 76f1f0eea9f..d22836833b4 100644
--- a/packages/cli/src/ui/AppContainer.test.tsx
+++ b/packages/cli/src/ui/AppContainer.test.tsx
@@ -760,6 +760,33 @@ describe('AppContainer State Management', () => {
expect(capturedUIState.useTerminalBuffer).toBe(true);
});
+ it('uses the startup VP decision when provided', () => {
+ const legacySettings = {
+ merged: {
+ hideTips: false,
+ theme: 'default',
+ ui: {
+ showStatusInTitle: false,
+ hideWindowTitle: false,
+ useTerminalBuffer: false,
+ },
+ },
+ setValue: vi.fn(),
+ } as unknown as LoadedSettings;
+
+ render(
+ ,
+ );
+
+ expect(capturedUIState.useTerminalBuffer).toBe(true);
+ });
+
it('keeps screen reader mode on the Static path when useTerminalBuffer is unset', () => {
vi.spyOn(mockConfig, 'getScreenReader').mockReturnValue(true);
const defaultSettings = {
diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx
index e5500f70cfb..4c48be991eb 100644
--- a/packages/cli/src/ui/AppContainer.tsx
+++ b/packages/cli/src/ui/AppContainer.tsx
@@ -398,6 +398,7 @@ interface AppContainerProps {
startupWarnings?: string[];
version: string;
initializationResult: InitializationResult;
+ initialUseTerminalBuffer?: boolean;
}
/**
@@ -413,7 +414,8 @@ const SHELL_WIDTH_FRACTION = 0.89;
const SHELL_HEIGHT_PADDING = 10;
export const AppContainer = (props: AppContainerProps) => {
- const { settings, config, initializationResult } = props;
+ const { settings, config, initializationResult, initialUseTerminalBuffer } =
+ props;
const historyManager = useHistory();
// `useHistory()` returns a fresh memoized object whenever `history` changes,
// so depending on `historyManager` directly inside event-handler callbacks
@@ -1017,12 +1019,14 @@ export const AppContainer = (props: AppContainerProps) => {
// The visible refresh in VP mode comes for free from the React tree
// re-reading `mergedHistory` / `allVirtualItems` on whatever state
// change triggered refreshStatic (Ctrl+O, model change, etc.).
- const [useTerminalBuffer] = useState(() =>
- shouldUseVirtualViewport(
- settings.merged.ui?.useTerminalBuffer,
- config.getScreenReader(),
- isInteractiveTerminal(),
- ),
+ const [useTerminalBuffer] = useState(
+ () =>
+ initialUseTerminalBuffer ??
+ shouldUseVirtualViewport(
+ settings.merged.ui?.useTerminalBuffer,
+ config.getScreenReader(),
+ isInteractiveTerminal(),
+ ),
);
const refreshStatic = useCallback(() => {
if (!useTerminalBuffer) {
diff --git a/packages/cli/src/ui/components/InputPrompt.suggestionMouse.test.tsx b/packages/cli/src/ui/components/InputPrompt.suggestionMouse.test.tsx
index de48948d809..821d8f63b19 100644
--- a/packages/cli/src/ui/components/InputPrompt.suggestionMouse.test.tsx
+++ b/packages/cli/src/ui/components/InputPrompt.suggestionMouse.test.tsx
@@ -25,6 +25,7 @@ import { useInputHistory } from '../hooks/useInputHistory.js';
import { useReverseSearchCompletion } from '../hooks/useReverseSearchCompletion.js';
import { useVoiceInput } from '../hooks/use-voice-input.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
+import { useUIState } from '../contexts/UIStateContext.js';
// Capture the props handed to SuggestionsDisplay so we can drive the mouse
// hover/select callbacks directly, without simulating raw SGR mouse bytes.
@@ -48,9 +49,17 @@ vi.mock('../hooks/useCommandCompletion.js');
vi.mock('../hooks/useInputHistory.js');
vi.mock('../hooks/useReverseSearchCompletion.js');
vi.mock('../hooks/use-voice-input.js');
-vi.mock('../contexts/UIStateContext.js', () => ({
- useUIState: vi.fn(() => ({ isFeedbackDialogOpen: false, messageQueue: [] })),
-}));
+vi.mock('../contexts/UIStateContext.js', async () => {
+ const { createContext } = await import('react');
+ return {
+ UIStateContext: createContext(null),
+ useUIState: vi.fn(() => ({
+ isFeedbackDialogOpen: false,
+ messageQueue: [],
+ useTerminalBuffer: false,
+ })),
+ };
+});
vi.mock('../contexts/UIActionsContext.js', () => ({
useUIActions: vi.fn(() => ({
handleRetryLastPrompt: vi.fn(),
@@ -86,6 +95,13 @@ vi.mock('../contexts/BackgroundTaskViewContext.js', () => ({
const mockSlashCommands: SlashCommand[] = [];
+const mockUIState = (useTerminalBuffer = false) =>
+ ({
+ isFeedbackDialogOpen: false,
+ messageQueue: [],
+ useTerminalBuffer,
+ }) as unknown as ReturnType;
+
describe('InputPrompt suggestion mouse routing', () => {
let props: InputPromptProps;
let mockBuffer: TextBuffer;
@@ -125,6 +141,7 @@ describe('InputPrompt suggestion mouse routing', () => {
beforeEach(() => {
captured.props = null;
vi.clearAllMocks();
+ vi.mocked(useUIState).mockReturnValue(mockUIState());
mockBuffer = makeBuffer('/sk');
vi.mocked(useShellHistory).mockReturnValue({
@@ -216,6 +233,15 @@ describe('InputPrompt suggestion mouse routing', () => {
unmount();
});
+ it('uses UIState VP mode for suggestion mouse when the raw setting is unset', () => {
+ vi.mocked(useUIState).mockReturnValue(mockUIState(true));
+
+ const { unmount } = renderWithProviders();
+ expect(captured.props).not.toBeNull();
+ expect(captured.props!['mouseEnabled']).toBe(true);
+ unmount();
+ });
+
it('hovering a suggestion updates the active index on the default source', () => {
const { unmount } = renderWithProviders();
act(() => {
diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx
index 64173908c31..12a5f117d65 100644
--- a/packages/cli/src/ui/components/InputPrompt.tsx
+++ b/packages/cli/src/ui/components/InputPrompt.tsx
@@ -219,7 +219,7 @@ export const InputPrompt: React.FC = ({
const settings = useSettings();
// Mouse interactions (suggestion list + click-to-position cursor) are enabled
// in alternate-screen mode (see RowMouseController's coordinate assumptions).
- const mouseInteractionsEnabled = !!settings.merged.ui?.useTerminalBuffer;
+ const mouseInteractionsEnabled = uiState.useTerminalBuffer;
const { pasteWorkaround } = useKeypressContext();
const { agents, agentTabBarFocused } = useAgentViewState();
const { setAgentTabBarFocused } = useAgentViewActions();
diff --git a/packages/cli/src/ui/components/shared/BaseSelectionList.mouse.test.tsx b/packages/cli/src/ui/components/shared/BaseSelectionList.mouse.test.tsx
index 5852ee0fea9..b7dd61a4f3e 100644
--- a/packages/cli/src/ui/components/shared/BaseSelectionList.mouse.test.tsx
+++ b/packages/cli/src/ui/components/shared/BaseSelectionList.mouse.test.tsx
@@ -7,6 +7,7 @@
import { describe, it, expect } from 'vitest';
import { renderWithProviders } from '../../../test-utils/render.js';
import { LoadedSettings } from '../../../config/settings.js';
+import { UIStateContext, type UIState } from '../../contexts/UIStateContext.js';
import { RadioButtonSelect } from './RadioButtonSelect.js';
// Integration smoke test: with ui.useTerminalBuffer on, BaseSelectionList
@@ -50,6 +51,20 @@ describe('BaseSelectionList with mouse enabled (integration)', () => {
expect(output).toContain(ENABLE_ANY);
});
+ it('uses UIState VP mode when the raw setting is unset', () => {
+ const { frames } = renderWithProviders(
+
+ {}} />
+ ,
+ );
+ const output = frames.join('\n');
+ expect(output).toContain('Alpha');
+ expect(output).toContain('Beta');
+ expect(output).toContain(ENABLE_ANY);
+ });
+
it('does not mount the mouse layer when ui.useTerminalBuffer is off', () => {
const { lastFrame, frames } = renderWithProviders(
{}} />,
diff --git a/packages/cli/src/ui/components/shared/BaseSelectionList.tsx b/packages/cli/src/ui/components/shared/BaseSelectionList.tsx
index 3b3a5bc17cf..0459ab64e92 100644
--- a/packages/cli/src/ui/components/shared/BaseSelectionList.tsx
+++ b/packages/cli/src/ui/components/shared/BaseSelectionList.tsx
@@ -10,6 +10,7 @@ import { Text, Box, type DOMElement } from 'ink';
import { theme } from '../../semantic-colors.js';
import { useSelectionList } from '../../hooks/useSelectionList.js';
import { SettingsContext } from '../../contexts/SettingsContext.js';
+import { UIStateContext } from '../../contexts/UIStateContext.js';
import { RowMouseController } from './RowMouseController.js';
import type { SelectionListItem } from '../../hooks/useSelectionList.js';
@@ -112,7 +113,9 @@ export function BaseSelectionList<
// Read the context raw (not the throwing useSettings) so the component still
// renders outside a SettingsProvider — e.g. in unit tests.
const settings = useContext(SettingsContext);
- const mouseEnabled = !!settings?.merged.ui?.useTerminalBuffer;
+ const uiState = useContext(UIStateContext);
+ const mouseEnabled =
+ uiState?.useTerminalBuffer ?? !!settings?.merged.ui?.useTerminalBuffer;
const containerRef = useRef(null);
const itemRefs = useRef>([]);
diff --git a/packages/cli/src/ui/hooks/useMouseEvents.test.tsx b/packages/cli/src/ui/hooks/useMouseEvents.test.tsx
index 76debccd101..d00424df6e3 100644
--- a/packages/cli/src/ui/hooks/useMouseEvents.test.tsx
+++ b/packages/cli/src/ui/hooks/useMouseEvents.test.tsx
@@ -11,6 +11,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { useStdin, useStdout } from 'ink';
import { KeypressProvider } from '../contexts/KeypressContext.js';
import { SettingsContext } from '../contexts/SettingsContext.js';
+import { UIStateContext, type UIState } from '../contexts/UIStateContext.js';
import type { LoadedSettings } from '../../config/settings.js';
import { useMouseEvents } from './useMouseEvents.js';
@@ -50,6 +51,23 @@ const vpWrapper = (useTerminalBuffer: boolean) => {
return VpWrapper;
};
+const uiStateVpWrapper = (useTerminalBuffer: boolean) => {
+ const VpWrapper = ({ children }: { children: React.ReactNode }) => (
+
+
+
+ {children}
+
+
+
+ );
+ return VpWrapper;
+};
+
// Mechanism tests exercise enable/disable/ref-counting independent of the VP
// gate, so they opt out via bypassVpGate.
function useTwoMouseSubscribers(firstActive: boolean, secondActive: boolean) {
@@ -198,6 +216,13 @@ describe('useMouseEvents', () => {
expect(stdout.write).toHaveBeenCalledWith(ENABLE_MOUSE);
});
+ it('uses UIState VP mode when the raw setting is unset', () => {
+ renderHook(() => useMouseEvents(() => {}, { isActive: true }), {
+ wrapper: uiStateVpWrapper(true),
+ });
+ expect(stdout.write).toHaveBeenCalledWith(ENABLE_MOUSE);
+ });
+
it('bypassVpGate: enables mouse mode even in non-VP (modal / VP viewport)', () => {
renderHook(
() => useMouseEvents(() => {}, { isActive: true, bypassVpGate: true }),
diff --git a/packages/cli/src/ui/hooks/useMouseEvents.ts b/packages/cli/src/ui/hooks/useMouseEvents.ts
index 546d4712f1b..20fe23e7ec9 100644
--- a/packages/cli/src/ui/hooks/useMouseEvents.ts
+++ b/packages/cli/src/ui/hooks/useMouseEvents.ts
@@ -18,6 +18,7 @@ import {
} from '../utils/mouse.js';
import { useKeypressContext } from '../contexts/KeypressContext.js';
import { SettingsContext } from '../contexts/SettingsContext.js';
+import { UIStateContext } from '../contexts/UIStateContext.js';
export type MouseHandler = (event: MouseEvent) => void;
@@ -151,7 +152,11 @@ export function useMouseEvents(
// pass `bypassVpGate` to opt in. This keeps the non-VP transcript scrollable
// no matter how many click/hover subscribers are added later.
const settings = useContext(SettingsContext);
- const isVpMode = settings?.merged.ui?.useTerminalBuffer ?? false;
+ const uiState = useContext(UIStateContext);
+ const isVpMode =
+ uiState?.useTerminalBuffer ??
+ settings?.merged.ui?.useTerminalBuffer ??
+ false;
const vpGateOpen = isVpMode || bypassVpGate;
const handlerRef = useRef(handler);
diff --git a/packages/cli/src/ui/startInteractiveUI.tsx b/packages/cli/src/ui/startInteractiveUI.tsx
index 79a40ba8195..1ca5de6fc98 100644
--- a/packages/cli/src/ui/startInteractiveUI.tsx
+++ b/packages/cli/src/ui/startInteractiveUI.tsx
@@ -134,6 +134,12 @@ export async function startInteractiveUI(
// always reads from the same stable prop rather than the (now empty) module buffer.
const initialCapturedInput = stopAndGetCapturedInput();
+ const useVP = shouldUseVirtualViewport(
+ settings.merged.ui?.useTerminalBuffer,
+ config.getScreenReader(),
+ isInteractiveTerminal(),
+ );
+
// Create wrapper component to use hooks inside render
const AppWrapper = () => {
const kittyProtocolStatus = useKittyKeyboardProtocol();
@@ -163,6 +169,7 @@ export async function startInteractiveUI(
startupWarnings={startupWarnings}
version={version}
initializationResult={initializationResult}
+ initialUseTerminalBuffer={useVP}
/>
@@ -175,11 +182,6 @@ export async function startInteractiveUI(
);
};
- const useVP = shouldUseVirtualViewport(
- settings.merged.ui?.useTerminalBuffer,
- config.getScreenReader(),
- isInteractiveTerminal(),
- );
const instance = render(
process.env['DEBUG'] ? (
From f4cdeccce21b9214cb1a72bf62d3ba2db3a5fa4a Mon Sep 17 00:00:00 2001
From: ZevGit <991333136@qq.com>
Date: Fri, 10 Jul 2026 17:29:31 +0800
Subject: [PATCH 08/11] fix(cli): align VP mouse behavior with runtime state
---
.../ui/components/HistoryItemDisplay.test.tsx | 17 +++++++++++++++++
.../src/ui/components/HistoryItemDisplay.tsx | 7 +++++--
.../shared/BaseSelectionList.mouse.test.tsx | 2 +-
3 files changed, 23 insertions(+), 3 deletions(-)
diff --git a/packages/cli/src/ui/components/HistoryItemDisplay.test.tsx b/packages/cli/src/ui/components/HistoryItemDisplay.test.tsx
index 07583b2702b..e7ed08e1108 100644
--- a/packages/cli/src/ui/components/HistoryItemDisplay.test.tsx
+++ b/packages/cli/src/ui/components/HistoryItemDisplay.test.tsx
@@ -18,6 +18,7 @@ import { renderWithProviders } from '../../test-utils/render.js';
import { LoadedSettings } from '../../config/settings.js';
import { ConfigContext } from '../contexts/ConfigContext.js';
import { ThoughtExpandedProvider } from '../contexts/ThoughtExpandedContext.js';
+import { UIStateContext, type UIState } from '../contexts/UIStateContext.js';
// Mock child components
vi.mock('./messages/ToolGroupMessage.js', () => ({
@@ -592,5 +593,21 @@ describe('', () => {
expect(opts?.isActive).toBe(true);
expect(opts?.bypassVpGate ?? false).toBe(false);
});
+
+ it('shows the click hint when raw settings are unset but UIState is in VP mode', () => {
+ const { lastFrame } = renderWithProviders(
+
+
+ ,
+ );
+
+ expect(lastFrame()).toContain(`click or ${toggleKeyHint} to expand`);
+ });
});
});
diff --git a/packages/cli/src/ui/components/HistoryItemDisplay.tsx b/packages/cli/src/ui/components/HistoryItemDisplay.tsx
index c64f5372064..781fa380489 100644
--- a/packages/cli/src/ui/components/HistoryItemDisplay.tsx
+++ b/packages/cli/src/ui/components/HistoryItemDisplay.tsx
@@ -5,7 +5,7 @@
*/
import type React from 'react';
-import { memo, useMemo, useRef, useCallback } from 'react';
+import { memo, useMemo, useRef, useCallback, useContext } from 'react';
import type { DOMElement } from 'ink';
import {
escapeAnsiCtrlCodes,
@@ -60,6 +60,7 @@ import { MemorySavedMessage } from './messages/MemorySavedMessage.js';
import { DiffStatsDisplay } from './messages/DiffStatsDisplay.js';
import { GoalStatusMessage } from './messages/GoalStatusMessage.js';
import { useSettings } from '../contexts/SettingsContext.js';
+import { UIStateContext } from '../contexts/UIStateContext.js';
import { useThoughtExpanded } from '../contexts/ThoughtExpandedContext.js';
import { useMouseEvents } from '../hooks/useMouseEvents.js';
import type { MouseEvent } from '../utils/mouse.js';
@@ -124,7 +125,9 @@ const ClickableThinkMessage: React.FC<{
// via Alt+T. Advertise "click" in the collapsed hint only in VP, where the
// click actually does something.
const settings = useSettings();
- const clickable = !!settings.merged.ui?.useTerminalBuffer;
+ const uiState = useContext(UIStateContext);
+ const clickable =
+ uiState?.useTerminalBuffer ?? !!settings.merged.ui?.useTerminalBuffer;
const isActive = !isPending;
useMouseEvents(
diff --git a/packages/cli/src/ui/components/shared/BaseSelectionList.mouse.test.tsx b/packages/cli/src/ui/components/shared/BaseSelectionList.mouse.test.tsx
index ce18759cf22..c8e2389f67a 100644
--- a/packages/cli/src/ui/components/shared/BaseSelectionList.mouse.test.tsx
+++ b/packages/cli/src/ui/components/shared/BaseSelectionList.mouse.test.tsx
@@ -88,7 +88,7 @@ describe('BaseSelectionList with mouse enabled (integration)', () => {
const output = frames.join('\n');
expect(output).toContain('Alpha');
expect(output).toContain('Beta');
- expect(output).toContain(ENABLE_ANY);
+ expect(enabledAnyWritten()).toBe(true);
});
it('does not mount the mouse layer when ui.useTerminalBuffer is off', () => {
From 0c9df2bf83dfa839539e92497e06d9b36946abc4 Mon Sep 17 00:00:00 2001
From: hzb <991333136@qq.com>
Date: Mon, 13 Jul 2026 17:01:12 +0800
Subject: [PATCH 09/11] fix(cli): stabilize virtual viewport runtime state
---
docs/design/ctrl-o-detail-expand/design.md | 16 ++---
packages/cli/src/gemini.test.tsx | 26 +++++++
packages/cli/src/ui/AppContainer.test.tsx | 37 +++++++++-
packages/cli/src/ui/AppContainer.tsx | 67 ++++++++++---------
.../ui/components/HistoryItemDisplay.test.tsx | 39 +++++++++--
.../src/ui/components/HistoryItemDisplay.tsx | 8 +--
.../InputPrompt.suggestionMouse.test.tsx | 34 +++-------
.../cli/src/ui/components/InputPrompt.tsx | 5 +-
.../shared/BaseSelectionList.mouse.test.tsx | 22 ++++--
.../components/shared/BaseSelectionList.tsx | 8 +--
.../contexts/VirtualViewportContext.test.tsx | 44 ++++++++++++
.../ui/contexts/VirtualViewportContext.tsx | 15 +++++
.../cli/src/ui/hooks/useMouseEvents.test.tsx | 30 ++++++---
packages/cli/src/ui/hooks/useMouseEvents.ts | 8 +--
packages/cli/src/ui/startInteractiveUI.tsx | 2 +-
.../cli/src/ui/utils/terminal-buffer.test.ts | 5 ++
packages/cli/src/ui/utils/terminal-buffer.ts | 6 +-
17 files changed, 267 insertions(+), 105 deletions(-)
create mode 100644 packages/cli/src/ui/contexts/VirtualViewportContext.test.tsx
create mode 100644 packages/cli/src/ui/contexts/VirtualViewportContext.tsx
diff --git a/docs/design/ctrl-o-detail-expand/design.md b/docs/design/ctrl-o-detail-expand/design.md
index 4d986bce521..699b0ec2050 100644
--- a/docs/design/ctrl-o-detail-expand/design.md
+++ b/docs/design/ctrl-o-detail-expand/design.md
@@ -101,10 +101,10 @@ qwen-code 当前把 **Ctrl+O 绑定为 `TOGGLE_COMPACT_MODE`**:一个**全局
忠实还原 Claude Code 的 transcript(已从 claude-code 源码取证):
-- 任意时刻按 **Ctrl+O**:进入 **alternate screen buffer**(DEC `1049`,`\x1b[?1049h`)接管整屏,渲染一个**冻结快照**:定格进入那一刻的历史,**解除 UI 层高度/行数截断**(思考全文、工具输出尽量完整),支持上下/翻页/Home/End 滚动。⚠️ "完整"只指 UI 层——**core 层 `truncateToolOutput` 已截断的内容无法从 UI history 恢复**(见 §4.4),不是字面"全文"。
+- 任意时刻按 **Ctrl+O**:切换到接管整屏的 transcript,渲染一个**冻结快照**:定格进入那一刻的历史,**解除 UI 层高度/行数截断**(思考全文、工具输出尽量完整),支持上下/翻页/Home/End 滚动。默认 VP 路径复用 ink root 已占用的 alternate screen;legacy `` 路径才由 `AlternateScreen` 组件写 DEC `1049`(`\x1b[?1049h`)临时进入。⚠️ "完整"只指 UI 层——**core 层 `truncateToolOutput` 已截断的内容无法从 UI history 恢复**(见 §4.4),不是字面"全文"。
- **冻结快照语义(含 pending;存长度而非克隆 history)**:qwen-code 的历史是**两段**——已落定的 `history: HistoryItem[]`(`UIStateContext.tsx:45`)与流式进行中的 `pendingHistoryItems`(`:123`,渲染时以负 id 拼接,`MainContent.tsx:456-461`)。Claude Code 的 freeze 实际只存两个数字 `{ messagesLength, streamingToolUsesLength }`、render 时 slice,而非 entry-time 克隆。**qwen-code 据此同时冻结两段,但用最省的形式**:已落定 history **只存长度** `historyLength`(render 时 `history.slice(0, historyLength)`,不克隆整个 history),流式 `pendingItems: [...pendingHistoryItems]` **存浅副本**(pending 是临时区、会被后续重写或清空,必须副本才能定格那一刻形态)。transcript 渲染 `history.slice(0, historyLength)` 拼接**进入那一刻定格的** pending 快照。后台后续新增的 history / pending **均不进入** transcript,保证定格不抖动。
-- **不影响主屏**:后台对话/流式继续运行(只是不渲染输入框/spinner);退出时 `AlternateScreen` 卸载写 `EXIT_ALT_SCREEN` 还原 normal buffer,再经一次 `refreshStatic()` 把当前完整 history 重绘到主屏(见 §4.4——**不是字面"原样不动"**,而是退出时统一重绘一次,保证无重复/无缺失/scrollback 不破坏)。
-- **退出键**:`Esc` / `q`(less 风格)/ `Ctrl+C` 关闭;再按 **Ctrl+O** 亦 toggle 关闭。退出后回到主屏,可看到 transcript 打开期间后台新增的流式内容(主屏 Static 一直在追加,只是被 alt-screen 暂时遮住)。
+- **不影响主屏数据**:后台对话/流式继续运行(只是不渲染输入框/spinner)。退出时默认 VP 路径在同一个 root alt-screen 内恢复主树;legacy 路径退出临时 alt-screen 后通过 `refreshStatic()` 重挂当前 history,保证无重复、无缺失且不污染 scrollback。
+- **退出键**:`Esc` / `q`(less 风格)/ `Ctrl+C` 关闭;再按 **Ctrl+O** 亦 toggle 关闭。退出后回到主屏,可看到 transcript 打开期间后台新增的流式内容。
- 行内 `(ctrl+o to expand)` 提示语义**统一为"按 Ctrl+O 进入 transcript 查看完整上下文",而非"此处被截断"**。注意思考块摘要恒带该提示(无论原文长短),工具输出仅在被高度约束截断时带 `+N lines`——两者提示触发条件不同,属预期(见 §7 #7)。
> 取证:claude-code `ink/components/AlternateScreen.tsx`、`termio/dec.ts:16`(`ALT_SCREEN_CLEAR: 1049`)、`screens/REPL.tsx:1325/4184/4381`(frozenTranscriptState + slice)、`keybindings/defaultBindings.ts:160-169`(`escape/q/ctrl+c → transcript:exit`)。
@@ -160,8 +160,8 @@ qwen-code 当前把 **Ctrl+O 绑定为 `TOGGLE_COMPACT_MODE`**:一个**全局
**alt-screen 能力已有现成组件可复用**:qwen-code 用的是上游官方 **`ink ^7.0.3`**(注意:与 gemini-cli **不同包不同大版本**——gemini-cli 用 fork `npm:@jrichman/ink@6.6.9`(v6);**不要**再把两者当同版本看待)。更关键的是,**main 已落地可直接复用的 `packages/cli/src/ui/components/AlternateScreen.tsx`(PR #5627)**,无需新建、无需移植 hook、无需引入 ink fork:
- **复用现成组件**:`AlternateScreen.tsx` 在 `useEffect` 中 `writeRaw(ENTER_ALT_SCREEN + CLEAR + HIDE_CURSOR)`,卸载/`process.on('exit')` 时 `writeRaw(SHOW_CURSOR + EXIT_ALT_SCREEN)`;内部用 `useTerminalOutput()`/`useTerminalSize()`。transcript 只需用 `` 包裹 `TranscriptView` 即可获得"进入时进 alt-screen、卸载时回 normal buffer"的完整生命周期。
-- ❌ **不用** ink `render()` 的 `alternateScreen: true` 整应用选项——那会让**整个 app 常驻 alt-screen**,丢掉 qwen-code 默认主视图赖以为生的**终端原生 scrollback**,不符合"主屏保持干净、仅 transcript 接管整屏"的需求。
-- ⚠️ **VP 模式(`useTerminalBuffer`)已常驻 alt-screen,必须用 `disabled` prop 避免 double-enter**:当 `settings.merged.ui?.useTerminalBuffer` 开启时,ink root 自身已通过 `render()` 占有 alt-screen(`gemini.tsx:367` `const useVP = settings.merged.ui?.useTerminalBuffer ?? false;`,`:379` `alternateScreen: useVP`)。此时 transcript 若再写一次 `?1049h` 就会 double-enter,破坏 buffer 状态。`AlternateScreen.tsx` 正为此带了 `disabled?: boolean` prop(其注释:"Skip escape writes when the root Ink renderer already owns the alt screen (VP mode)")。因此 transcript 一律以 **``** 包裹:
+- **默认交互路径已使用 ink root 的 `alternateScreen: true`**:启动时 `startInteractiveUI.tsx` 通过 `shouldUseVirtualViewport(setting, screenReader, isInteractiveTerminal())` 计算一次最终 VP 决策,并同时用于 ink 的 `alternateScreen` 与传给 `AppContainer` 的冻结初始值。正常交互式终端在设置未指定时默认进入 VP/alt-screen;显式 `ui.useTerminalBuffer: false`、screen-reader、CI、非 TTY 或 `TERM=dumb` 走 legacy `` + 原生 scrollback 路径。
+- ⚠️ **VP 模式已由 ink root 常驻 alt-screen,必须用 `disabled` prop 避免 double-enter**:当启动时冻结的 VP 决策为 true,transcript 若再写一次 `?1049h` 就会破坏 buffer 状态。`AlternateScreen.tsx` 因此提供 `disabled?: boolean` prop(其注释:"Skip escape writes when the root Ink renderer already owns the alt screen (VP mode)")。transcript 一律以 **``** 包裹:
- 非 VP 模式(`useVP=false`):组件正常写 `ENTER_ALT_SCREEN`/`EXIT_ALT_SCREEN`,进出 alt-screen;
- VP 模式(`useVP=true`):传 `disabled` 跳过转义写入,因为 ink root 已在 alt-screen,transcript 直接在该 buffer 内以替换主内容树的方式渲染。
- **降级 / 可用性判定收敛**:不再需要模糊的 `isAltScreenSupported()` 启发式判定。判定收敛为两条明确依据——(1) **是否已在 alt-screen 由 `useVP` 决定**(决定是否传 `disabled`);(2) **非 TTY 防护**。⚠️ **现状澄清(取证)**:`AlternateScreen.tsx` 当前**并没有** `process.stdout.isTTY` 防护(`useEffect` 内无条件 `writeRaw(ENTER_ALT_SCREEN…)`)。但 TUI 本身只有 `interactive` 为真才渲染,无 prompt 时 `interactive = process.stdin.isTTY ?? false`(`config.ts:1532`)——**非 TTY 默认根本不进交互渲染**,TranscriptView/AlternateScreen 不挂载;唯一边角是显式 `-i`(强制 interactive 而 stdout 可能非 TTY)。**待实现**:给 `AlternateScreen` 补一个 `process.stdout.isTTY` guard(写转义前判定,非 TTY 不接管整屏、退化为普通 buffer 内渲染),对齐仓库既有约定(`startInteractiveUI.tsx:77/81`、`notificationService.ts:53` 等均在写终端转义前判 `isTTY`)。改动极小、属"对齐约定的兜底",并补对应单测。
@@ -214,12 +214,12 @@ qwen-code 当前把 **Ctrl+O 绑定为 `TOGGLE_COMPACT_MODE`**:一个**全局
新增 `components/TranscriptView.tsx`,外层包**复用现有**的 ``(§4.2:VP 模式下 ink root 已占 alt-screen,传 `disabled` 跳过转义写入;非 VP 模式正常进出 alt-screen;非 TTY 由**待补的** `process.stdout.isTTY` guard 退化为普通 buffer 内渲染,见 §4.2):
- **数据(双段冻结快照)**:`[...history.slice(0, freeze.historyLength), ...freeze.pendingItems]` —— history 前缀 + 进入那一刻定格的 pending 副本(见 §3.2)。后台后续新增项不进入,避免滚动抖动。
-- **渲染容器(注意 gating)**:`ScrollableList`/`VirtualizedList` **已存在于 main**(标准 Ink 7 组件,非 Ink fork;`ScrollableList.tsx` 具备 `scrollBy/scrollTo/scrollToEnd/scrollToIndex` 与 PageUp/Down/Home/End/滚轮),但**当前仅在 `useTerminalBuffer`(VP/virtual-viewport 模式)下被 `MainContent` 使用**——默认主视图走 `` + pending,不用它们。transcript **无条件复用**这两个组件(与 `useTerminalBuffer` 解耦,自管滚动容器),因此不受默认 Static 路径限制。⚠️ 这些组件相对较新,长会话下的滚动性能、键盘滚动、resize 重排须纳入测试(§8),不能假设"零成本复用"。
+- **渲染容器(注意 gating)**:`ScrollableList`/`VirtualizedList` **已存在于 main**(标准 Ink 7 组件,非 Ink fork;`ScrollableList.tsx` 具备 `scrollBy/scrollTo/scrollToEnd/scrollToIndex` 与 PageUp/Down/Home/End/滚轮),由 `MainContent` 在默认 VP/virtual-viewport 路径使用;只有显式 opt-out、screen-reader、CI、非交互或不兼容终端回退到 `` + pending。transcript **无条件复用**这两个组件(与主屏 gating 解耦,自管滚动容器)。⚠️ 这些组件相对较新,长会话下的滚动性能、键盘滚动、resize 重排须纳入测试(§8),不能假设"零成本复用"。
- **`estimatedItemHeight`(虚拟滚动估高,必须调大/自适应)**:`MainContent` 当前对 `VirtualizedList` 用恒定 `estimatedItemHeight=3`。transcript 以 `fullDetail` 渲染(思考全文、工具全输出),**每项远高于 3 行**,若沿用 3 会导致滚动条/定位失真、PageUp/Down 跳幅错乱。transcript 必须用**更大或自适应的 `estimatedItemHeight`**(按内容类型估算,或交由 `VirtualizedList` 的实测高度回填机制修正)。该估高纳入测试(§8)。
- **完整展开(`fullDetail` prop)**:为渲染路径引入显式 `fullDetail` 替代原先靠 `!compactMode` 推导。`fullDetail=true` 时:思考块 `expanded={true}`;工具输出**同时**满足两点才算真正不截断——(a) `availableTerminalHeight={undefined}`(验证 `ToolGroupMessage.tsx:357-365` 据此使 `availableTerminalHeightPerToolMessage` 为 undefined);(b) 关闭 `MaxSizedBox` 的高度约束、`sliceTextForMaxHeight`、shell 的 `shellStringCapHeight/shellOutputMaxLines`(`ToolMessage.tsx:67-74,750-756`)。⚠️ **保留按字符数的性能上限**(qwen-code 侧为工具输出截断阈值 `DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD` ~25000,**非** gemini-cli 的 `SlicingMaxSizedBox`)——区分"行高截断(为显示,transcript 解除)"与"字符上限(为性能,始终保留)",避免单条超大输出拖垮虚拟滚动。
- **两层截断的边界(重要,避免过度承诺)**:截断发生在**两层**——(1) **core 层** `truncateToolOutput`(`packages/core/src/utils/truncation.ts`,被 `shell.ts`/`mcp-tool.ts` 调用)在工具产出时就按 `truncateToolOutputThreshold/Lines` 截断,写进 history 的 `resultDisplay` 已是截断后的,原文可能仅以临时 output 文件存在;(2) **UI 层** `MaxSizedBox`/`sliceTextForMaxHeight` 等按终端高度截断。**transcript 只能解除 UI 层**——core 层已丢弃的内容 UI 拿不回来。规则:transcript 对 core-截断项**保留其 truncation marker**(如"… output truncated, N lines omitted"),明示不可恢复;"读取 core 保存的 output 文件并展示"列为**后续可选增强**,不在本期范围。i18n/文案不得宣称"查看完整工具输出",改为"查看完整上下文(不含已被 core 截断的部分)"。
- **键盘分工**:TranscriptView 自身 `useKeypress`(`isActive: isTranscriptOpen`)**只处理滚动键**(上下/翻页/Home/End)。**关闭键(Esc/q/Ctrl+C/Ctrl+O)一律由全局 `handleGlobalKeypress` 处理**(§4.3),TranscriptView 不碰,杜绝广播双响应。
-- **渲染模型(明确单一策略,消除歧义)**:单 ink root 只能线性渲染一个树。transcript 打开时,顶层 layout **以 `` 包裹的 `TranscriptView` 替代主内容树**(`MainContent` 从渲染中卸载,**不再绘制**);后台对话/流式只更新**数据层**(`history`/`pendingHistoryItems` 继续增长),但**不被绘制**。退出时:`AlternateScreen` 卸载写 `EXIT_ALT_SCREEN`(VP 模式由 `disabled` 跳过)回到 normal buffer(其中仍是进入前那帧 `` 旧内容)→ **必须再调用一次 `refreshStatic()`**(清屏 + 重挂 Static key)把当前完整 history **一次性重绘**,从而保证退出后主屏**无重复回放、无缺失、无错位**。这是 alt-screen + Static append-only 模型下的正确收尾,**不是**"原样不动"。
+- **渲染模型(明确单一策略,消除歧义)**:单 ink root 只能线性渲染一个树。transcript 打开时,顶层 layout **以 `` 包裹的 `TranscriptView` 替代主内容树**(`MainContent` 从渲染中卸载,**不再绘制**);后台对话/流式只更新**数据层**(`history`/`pendingHistoryItems` 继续增长),但**不被绘制**。退出时,默认 VP 路径保持在 root 已占用的 alt-screen 内并由 React 恢复主树;legacy `` 路径则由 `AlternateScreen` 退出到 normal buffer,再通过 `refreshStatic()` 重挂 history,保证主屏无重复、无缺失、无错位。
- **transcript 打开期间抑制/守卫 `refreshStatic`(避免污染主屏 scrollback)**:`useResizeSettleRepaint` 等内部路径(如 resize)可能在 transcript 打开期间触发 `refreshStatic`——若放任,它会向 **normal-buffer 的 scrollback** 写入/重排主内容,而此刻屏幕正被 alt-screen 占据,导致退出后主屏错位或 scrollback 被污染。规则:**用 `isTranscriptOpenRef` 守卫 `refreshStatic`,transcript 打开期间一律跳过**;退出 transcript 时再统一做一次 `refreshStatic()` 重绘主屏(即上一条)。如此可澄清"主屏 normal-buffer scrollback 不被 alt-screen 期间的写入污染"。**测试**:打开 transcript 期间后台完成一轮工具调用 / 触发 resize,退出后主屏该轮内容恰好出现一次、scrollback 不被破坏。
- **页眉/页脚**:标题(如 `Transcript — ↑↓ scroll · Ctrl+O/Esc/q to close`),初始 `initialScrollIndex` 滚到底部(对齐 Claude Code 打开即在最新处)。
@@ -517,7 +517,7 @@ claude code 的机制是"**存储层保留完整、显示层按 `verbose` 截断
- settings:`settingsSchema.ts:940-958`(`compactMode/compactInline`);`serve/routes/workspace-settings.ts:36`
- 可复用滚动屏底座:`components/shared/ScrollableList.tsx`、`VirtualizedList.tsx`(`MainContent` 默认对其用恒定 `estimatedItemHeight=3`,transcript 须调大/自适应);覆盖层 `DialogManager.tsx`、`layouts/DefaultAppLayout.tsx`;Esc 统一关闭 `hooks/useDialogClose.ts`
- **可复用 alt-screen 组件(qwen 自身)**:`packages/cli/src/ui/components/AlternateScreen.tsx`(PR #5627)——`useEffect` 写 `ENTER_ALT_SCREEN+CLEAR+HIDE_CURSOR`、卸载/`process.on('exit')` 写 `SHOW_CURSOR+EXIT_ALT_SCREEN`,用 `useTerminalOutput()`/`useTerminalSize()`,带 `disabled?: boolean`(注释:"Skip escape writes when the root Ink renderer already owns the alt screen (VP mode)")
-- **VP 模式 alt-screen 常驻**:`gemini.tsx:367`(`const useVP = settings.merged.ui?.useTerminalBuffer ?? false;`)、`:379`(`alternateScreen: useVP`)
+- **VP 模式 alt-screen 决策**:`startInteractiveUI.tsx` 用 `shouldUseVirtualViewport(...)` 计算一次启动决策,同时传给 ink `render({ alternateScreen: useVP })` 和 `AppContainer`;`AppContainer` 冻结该值并供主内容、transcript 与鼠标消费者使用
- **ink 版本(澄清)**:qwen-code 用上游官方 `ink ^7.0.3`;gemini-cli 用 fork `npm:@jrichman/ink@6.6.9`(v6)——**不同包不同大版本**,alt-screen 能力基于 qwen 自己的 ink v7 + 复用上述组件
- **main per-block 思考机制(与本方案共存)**:`ThoughtExpandedContext`(Alt+T `TOGGLE_THINKING_EXPANDED`)、`ThinkingViewer`/`ThinkingViewerContext`、`thoughtExpanded`/`thinkingFullText` props、`buildThinkingFullTextMap`、`ClickableThinkMessage`(详见 §4.7)
- **阻塞确认/对话框(全部需自动关闭 transcript)**:`DialogManager.tsx` 渲染 `shellConfirmationRequest`(ShellConfirmationDialog)、`loopDetectionConfirmationRequest`(LoopDetectionConfirmation)、`confirmationRequest`(ConsentPrompt)、`confirmUpdateExtensionRequests`(ConsentPrompt)、`providerUpdateRequest`(ProviderUpdatePrompt) 等(§4.6 #1)
diff --git a/packages/cli/src/gemini.test.tsx b/packages/cli/src/gemini.test.tsx
index ea5a4d04644..cc0742f82d0 100644
--- a/packages/cli/src/gemini.test.tsx
+++ b/packages/cli/src/gemini.test.tsx
@@ -1925,6 +1925,7 @@ describe('startInteractiveUI', () => {
beforeEach(() => {
vi.clearAllMocks();
restoreCiEnv = clearCiEnv();
+ vi.stubEnv('TERM', 'xterm-256color');
originalStdoutIsTTY = process.stdout.isTTY;
Object.defineProperty(process.stdout, 'isTTY', {
value: true,
@@ -1943,6 +1944,7 @@ describe('startInteractiveUI', () => {
});
}
restoreCiEnv();
+ vi.unstubAllEnvs();
const currentExitListeners = process.listeners(
'exit',
) as NodeJS.ExitListener[];
@@ -2047,6 +2049,30 @@ describe('startInteractiveUI', () => {
expect(options).toMatchObject({ alternateScreen: false });
});
+ it('should not use alternate screen when TERM is dumb', async () => {
+ vi.stubEnv('TERM', 'dumb');
+ const { render } = await import('ink');
+ const renderSpy = vi.mocked(render);
+
+ const mockInitializationResult = {
+ authError: null,
+ themeError: null,
+ shouldOpenAuthDialog: false,
+ geminiMdFileCount: 0,
+ };
+
+ await startInteractiveUI(
+ mockConfig,
+ mockSettings,
+ mockStartupWarnings,
+ mockWorkspaceRoot,
+ mockInitializationResult,
+ );
+
+ const [, options] = renderSpy.mock.calls[0];
+ expect(options).toMatchObject({ alternateScreen: false });
+ });
+
it('should not use alternate screen in screen reader mode when VP mode is unset', async () => {
const { render } = await import('ink');
const renderSpy = vi.mocked(render);
diff --git a/packages/cli/src/ui/AppContainer.test.tsx b/packages/cli/src/ui/AppContainer.test.tsx
index b93b0abd5da..fbb8d83acf3 100644
--- a/packages/cli/src/ui/AppContainer.test.tsx
+++ b/packages/cli/src/ui/AppContainer.test.tsx
@@ -219,6 +219,7 @@ describe('AppContainer State Management', () => {
beforeEach(() => {
vi.clearAllMocks();
restoreCiEnv = clearCiEnv();
+ vi.stubEnv('TERM', 'xterm-256color');
originalStdoutIsTTY = process.stdout.isTTY;
Object.defineProperty(process.stdout, 'isTTY', {
value: true,
@@ -435,6 +436,7 @@ describe('AppContainer State Management', () => {
});
}
restoreCiEnv();
+ vi.unstubAllEnvs();
cleanup();
});
@@ -826,13 +828,40 @@ describe('AppContainer State Management', () => {
settings={legacySettings}
version="1.0.0"
initializationResult={mockInitResult}
- initialUseTerminalBuffer={true}
+ initialUseVirtualViewport={true}
/>,
);
expect(capturedUIState.useTerminalBuffer).toBe(true);
});
+ it('uses a disabled startup VP decision over an enabled setting', () => {
+ const vpSettings = {
+ merged: {
+ hideTips: false,
+ theme: 'default',
+ ui: {
+ showStatusInTitle: false,
+ hideWindowTitle: false,
+ useTerminalBuffer: true,
+ },
+ },
+ setValue: vi.fn(),
+ } as unknown as LoadedSettings;
+
+ render(
+ ,
+ );
+
+ expect(capturedUIState.useTerminalBuffer).toBe(false);
+ });
+
it('keeps screen reader mode on the Static path when useTerminalBuffer is unset', () => {
vi.spyOn(mockConfig, 'getScreenReader').mockReturnValue(true);
const defaultSettings = {
@@ -2446,6 +2475,10 @@ describe('AppContainer State Management', () => {
};
beforeEach(() => {
+ vi.stubEnv('TMUX', undefined);
+ vi.stubEnv('STY', undefined);
+ vi.stubEnv('ZELLIJ', undefined);
+ vi.stubEnv('DVTM', undefined);
// Reset mock stdout for each test. The title useEffect now uses
// process.stdout.write directly (to avoid Ink proxy corruption of
// OSC escape sequences), so we spy on that.
@@ -3016,7 +3049,7 @@ describe('AppContainer State Management', () => {
vi.stubEnv('CLI_TITLE', 'Custom Title');
const staticTitleWithEnv = formatSessionWindowTitle(null, folderName);
expect(staticTitleWithEnv).toBe('Custom Title');
- vi.unstubAllEnvs();
+ vi.stubEnv('CLI_TITLE', undefined);
// Verify the escape sequence format for the static title
const writeSpy = vi.fn();
diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx
index 47306ac6047..782a92bee39 100644
--- a/packages/cli/src/ui/AppContainer.tsx
+++ b/packages/cli/src/ui/AppContainer.tsx
@@ -18,6 +18,7 @@ import { type DOMElement, measureElement } from 'ink';
import { App } from './App.js';
import { AppContext } from './contexts/AppContext.js';
import { UIStateContext, type UIState } from './contexts/UIStateContext.js';
+import { VirtualViewportContext } from './contexts/VirtualViewportContext.js';
import {
UIActionsContext,
type UIActions,
@@ -433,7 +434,7 @@ interface AppContainerProps {
startupWarnings?: string[];
version: string;
initializationResult: InitializationResult;
- initialUseTerminalBuffer?: boolean;
+ initialUseVirtualViewport?: boolean;
extensionRefreshState?: ExtensionRefreshState;
}
@@ -450,7 +451,7 @@ const SHELL_WIDTH_FRACTION = 0.89;
const SHELL_HEIGHT_PADDING = 10;
export const AppContainer = (props: AppContainerProps) => {
- const { settings, config, initializationResult, initialUseTerminalBuffer } =
+ const { settings, config, initializationResult, initialUseVirtualViewport } =
props;
const extensionRefreshState = useMemo(
() => props.extensionRefreshState ?? new ExtensionRefreshState(),
@@ -1099,7 +1100,7 @@ export const AppContainer = (props: AppContainerProps) => {
// change triggered refreshStatic (Ctrl+O, model change, etc.).
const [useTerminalBuffer] = useState(
() =>
- initialUseTerminalBuffer ??
+ initialUseVirtualViewport ??
shouldUseVirtualViewport(
settings.merged.ui?.useTerminalBuffer,
config.getScreenReader(),
@@ -4479,34 +4480,36 @@ export const AppContainer = (props: AppContainerProps) => {
);
return (
-
-
-
-
-
-
-
-
- {transcriptFreeze ? (
-
- ) : (
-
- )}
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+ {transcriptFreeze ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+
+
+
+
);
};
diff --git a/packages/cli/src/ui/components/HistoryItemDisplay.test.tsx b/packages/cli/src/ui/components/HistoryItemDisplay.test.tsx
index e7ed08e1108..983695bfe71 100644
--- a/packages/cli/src/ui/components/HistoryItemDisplay.test.tsx
+++ b/packages/cli/src/ui/components/HistoryItemDisplay.test.tsx
@@ -18,7 +18,7 @@ import { renderWithProviders } from '../../test-utils/render.js';
import { LoadedSettings } from '../../config/settings.js';
import { ConfigContext } from '../contexts/ConfigContext.js';
import { ThoughtExpandedProvider } from '../contexts/ThoughtExpandedContext.js';
-import { UIStateContext, type UIState } from '../contexts/UIStateContext.js';
+import { VirtualViewportContext } from '../contexts/VirtualViewportContext.js';
// Mock child components
vi.mock('./messages/ToolGroupMessage.js', () => ({
@@ -577,6 +577,20 @@ describe('', () => {
durationMs: 1200,
};
+ const settingsWithVp = (enabled: boolean) =>
+ new LoadedSettings(
+ { path: '', settings: {}, originalSettings: {} },
+ { path: '', settings: {}, originalSettings: {} },
+ {
+ path: '',
+ settings: { ui: { useTerminalBuffer: enabled } },
+ originalSettings: {},
+ },
+ { path: '', settings: {}, originalSettings: {} },
+ true,
+ new Set(),
+ );
+
it('subscribes the click handler without bypassVpGate (stays VP-gated)', () => {
vi.mocked(useMouseEvents).mockClear();
renderWithProviders(
@@ -594,20 +608,33 @@ describe('', () => {
expect(opts?.bypassVpGate ?? false).toBe(false);
});
- it('shows the click hint when raw settings are unset but UIState is in VP mode', () => {
+ it('shows the click hint when raw settings are unset but startup VP is enabled', () => {
const { lastFrame } = renderWithProviders(
-
+
- ,
+ ,
);
expect(lastFrame()).toContain(`click or ${toggleKeyHint} to expand`);
});
+
+ it('hides the click hint when startup VP overrides an enabled setting', () => {
+ const { lastFrame } = renderWithProviders(
+
+
+ ,
+ { settings: settingsWithVp(true) },
+ );
+
+ expect(lastFrame()).not.toContain(`click or ${toggleKeyHint} to expand`);
+ });
});
});
diff --git a/packages/cli/src/ui/components/HistoryItemDisplay.tsx b/packages/cli/src/ui/components/HistoryItemDisplay.tsx
index 781fa380489..ee3c9b39dfe 100644
--- a/packages/cli/src/ui/components/HistoryItemDisplay.tsx
+++ b/packages/cli/src/ui/components/HistoryItemDisplay.tsx
@@ -5,7 +5,7 @@
*/
import type React from 'react';
-import { memo, useMemo, useRef, useCallback, useContext } from 'react';
+import { memo, useMemo, useRef, useCallback } from 'react';
import type { DOMElement } from 'ink';
import {
escapeAnsiCtrlCodes,
@@ -60,7 +60,7 @@ import { MemorySavedMessage } from './messages/MemorySavedMessage.js';
import { DiffStatsDisplay } from './messages/DiffStatsDisplay.js';
import { GoalStatusMessage } from './messages/GoalStatusMessage.js';
import { useSettings } from '../contexts/SettingsContext.js';
-import { UIStateContext } from '../contexts/UIStateContext.js';
+import { useVirtualViewport } from '../contexts/VirtualViewportContext.js';
import { useThoughtExpanded } from '../contexts/ThoughtExpandedContext.js';
import { useMouseEvents } from '../hooks/useMouseEvents.js';
import type { MouseEvent } from '../utils/mouse.js';
@@ -125,9 +125,7 @@ const ClickableThinkMessage: React.FC<{
// via Alt+T. Advertise "click" in the collapsed hint only in VP, where the
// click actually does something.
const settings = useSettings();
- const uiState = useContext(UIStateContext);
- const clickable =
- uiState?.useTerminalBuffer ?? !!settings.merged.ui?.useTerminalBuffer;
+ const clickable = useVirtualViewport(settings.merged.ui?.useTerminalBuffer);
const isActive = !isPending;
useMouseEvents(
diff --git a/packages/cli/src/ui/components/InputPrompt.suggestionMouse.test.tsx b/packages/cli/src/ui/components/InputPrompt.suggestionMouse.test.tsx
index 821d8f63b19..84f2dc97644 100644
--- a/packages/cli/src/ui/components/InputPrompt.suggestionMouse.test.tsx
+++ b/packages/cli/src/ui/components/InputPrompt.suggestionMouse.test.tsx
@@ -25,7 +25,7 @@ import { useInputHistory } from '../hooks/useInputHistory.js';
import { useReverseSearchCompletion } from '../hooks/useReverseSearchCompletion.js';
import { useVoiceInput } from '../hooks/use-voice-input.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
-import { useUIState } from '../contexts/UIStateContext.js';
+import { VirtualViewportContext } from '../contexts/VirtualViewportContext.js';
// Capture the props handed to SuggestionsDisplay so we can drive the mouse
// hover/select callbacks directly, without simulating raw SGR mouse bytes.
@@ -49,17 +49,9 @@ vi.mock('../hooks/useCommandCompletion.js');
vi.mock('../hooks/useInputHistory.js');
vi.mock('../hooks/useReverseSearchCompletion.js');
vi.mock('../hooks/use-voice-input.js');
-vi.mock('../contexts/UIStateContext.js', async () => {
- const { createContext } = await import('react');
- return {
- UIStateContext: createContext(null),
- useUIState: vi.fn(() => ({
- isFeedbackDialogOpen: false,
- messageQueue: [],
- useTerminalBuffer: false,
- })),
- };
-});
+vi.mock('../contexts/UIStateContext.js', () => ({
+ useUIState: vi.fn(() => ({ isFeedbackDialogOpen: false, messageQueue: [] })),
+}));
vi.mock('../contexts/UIActionsContext.js', () => ({
useUIActions: vi.fn(() => ({
handleRetryLastPrompt: vi.fn(),
@@ -95,13 +87,6 @@ vi.mock('../contexts/BackgroundTaskViewContext.js', () => ({
const mockSlashCommands: SlashCommand[] = [];
-const mockUIState = (useTerminalBuffer = false) =>
- ({
- isFeedbackDialogOpen: false,
- messageQueue: [],
- useTerminalBuffer,
- }) as unknown as ReturnType;
-
describe('InputPrompt suggestion mouse routing', () => {
let props: InputPromptProps;
let mockBuffer: TextBuffer;
@@ -141,7 +126,6 @@ describe('InputPrompt suggestion mouse routing', () => {
beforeEach(() => {
captured.props = null;
vi.clearAllMocks();
- vi.mocked(useUIState).mockReturnValue(mockUIState());
mockBuffer = makeBuffer('/sk');
vi.mocked(useShellHistory).mockReturnValue({
@@ -233,10 +217,12 @@ describe('InputPrompt suggestion mouse routing', () => {
unmount();
});
- it('uses UIState VP mode for suggestion mouse when the raw setting is unset', () => {
- vi.mocked(useUIState).mockReturnValue(mockUIState(true));
-
- const { unmount } = renderWithProviders();
+ it('uses the startup VP decision for suggestion mouse when the raw setting is unset', () => {
+ const { unmount } = renderWithProviders(
+
+
+ ,
+ );
expect(captured.props).not.toBeNull();
expect(captured.props!['mouseEnabled']).toBe(true);
unmount();
diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx
index 1ce58b0e064..709e156e8d0 100644
--- a/packages/cli/src/ui/components/InputPrompt.tsx
+++ b/packages/cli/src/ui/components/InputPrompt.tsx
@@ -49,6 +49,7 @@ import { useShellFocusState } from '../contexts/ShellFocusContext.js';
import { useUIState } from '../contexts/UIStateContext.js';
import { useUIActions } from '../contexts/UIActionsContext.js';
import { useSettings } from '../contexts/SettingsContext.js';
+import { useVirtualViewport } from '../contexts/VirtualViewportContext.js';
import { useKeypressContext } from '../contexts/KeypressContext.js';
import {
useAgentViewState,
@@ -246,7 +247,9 @@ export const InputPrompt: React.FC = ({
const settings = useSettings();
// Mouse interactions (suggestion list + click-to-position cursor) are enabled
// in alternate-screen mode (see RowMouseController's coordinate assumptions).
- const mouseInteractionsEnabled = uiState.useTerminalBuffer;
+ const mouseInteractionsEnabled = useVirtualViewport(
+ settings.merged.ui?.useTerminalBuffer,
+ );
const { pasteWorkaround } = useKeypressContext();
const { agents, agentTabBarFocused } = useAgentViewState();
const { setAgentTabBarFocused } = useAgentViewActions();
diff --git a/packages/cli/src/ui/components/shared/BaseSelectionList.mouse.test.tsx b/packages/cli/src/ui/components/shared/BaseSelectionList.mouse.test.tsx
index c8e2389f67a..9be9e86ec47 100644
--- a/packages/cli/src/ui/components/shared/BaseSelectionList.mouse.test.tsx
+++ b/packages/cli/src/ui/components/shared/BaseSelectionList.mouse.test.tsx
@@ -8,7 +8,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
import { useStdout } from 'ink';
import { renderWithProviders } from '../../../test-utils/render.js';
import { LoadedSettings } from '../../../config/settings.js';
-import { UIStateContext, type UIState } from '../../contexts/UIStateContext.js';
+import { VirtualViewportContext } from '../../contexts/VirtualViewportContext.js';
import { RadioButtonSelect } from './RadioButtonSelect.js';
// `useMouseEvents` gates SGR mouse escapes on `stdout.isTTY` (so they never leak
@@ -77,13 +77,11 @@ describe('BaseSelectionList with mouse enabled (integration)', () => {
expect(enabledAnyWritten()).toBe(true);
});
- it('uses UIState VP mode when the raw setting is unset', () => {
+ it('uses the startup VP decision when the raw setting is unset', () => {
const { frames } = renderWithProviders(
-
+
{}} />
- ,
+ ,
);
const output = frames.join('\n');
expect(output).toContain('Alpha');
@@ -91,6 +89,18 @@ describe('BaseSelectionList with mouse enabled (integration)', () => {
expect(enabledAnyWritten()).toBe(true);
});
+ it('keeps the mouse layer off when the startup decision overrides an enabled setting', () => {
+ const { lastFrame } = renderWithProviders(
+
+ {}} />
+ ,
+ { settings: settingsWithMouse(true) },
+ );
+ expect(lastFrame()).toContain('Alpha');
+ expect(lastFrame()).toContain('Beta');
+ expect(enabledAnyWritten()).toBe(false);
+ });
+
it('does not mount the mouse layer when ui.useTerminalBuffer is off', () => {
const { lastFrame } = renderWithProviders(
{}} />,
diff --git a/packages/cli/src/ui/components/shared/BaseSelectionList.tsx b/packages/cli/src/ui/components/shared/BaseSelectionList.tsx
index 0459ab64e92..035183cf302 100644
--- a/packages/cli/src/ui/components/shared/BaseSelectionList.tsx
+++ b/packages/cli/src/ui/components/shared/BaseSelectionList.tsx
@@ -10,7 +10,7 @@ import { Text, Box, type DOMElement } from 'ink';
import { theme } from '../../semantic-colors.js';
import { useSelectionList } from '../../hooks/useSelectionList.js';
import { SettingsContext } from '../../contexts/SettingsContext.js';
-import { UIStateContext } from '../../contexts/UIStateContext.js';
+import { useVirtualViewport } from '../../contexts/VirtualViewportContext.js';
import { RowMouseController } from './RowMouseController.js';
import type { SelectionListItem } from '../../hooks/useSelectionList.js';
@@ -113,9 +113,9 @@ export function BaseSelectionList<
// Read the context raw (not the throwing useSettings) so the component still
// renders outside a SettingsProvider — e.g. in unit tests.
const settings = useContext(SettingsContext);
- const uiState = useContext(UIStateContext);
- const mouseEnabled =
- uiState?.useTerminalBuffer ?? !!settings?.merged.ui?.useTerminalBuffer;
+ const mouseEnabled = useVirtualViewport(
+ settings?.merged.ui?.useTerminalBuffer,
+ );
const containerRef = useRef(null);
const itemRefs = useRef>([]);
diff --git a/packages/cli/src/ui/contexts/VirtualViewportContext.test.tsx b/packages/cli/src/ui/contexts/VirtualViewportContext.test.tsx
new file mode 100644
index 00000000000..27e735bd481
--- /dev/null
+++ b/packages/cli/src/ui/contexts/VirtualViewportContext.test.tsx
@@ -0,0 +1,44 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import type React from 'react';
+import { renderHook } from '@testing-library/react';
+import { describe, expect, it } from 'vitest';
+import {
+ useVirtualViewport,
+ VirtualViewportContext,
+} from './VirtualViewportContext.js';
+
+const wrapper = (value: boolean) =>
+ function VirtualViewportWrapper({ children }: { children: React.ReactNode }) {
+ return (
+
+ {children}
+
+ );
+ };
+
+describe('useVirtualViewport', () => {
+ it('uses the fallback outside the app provider', () => {
+ expect(renderHook(() => useVirtualViewport()).result.current).toBe(false);
+ expect(renderHook(() => useVirtualViewport(true)).result.current).toBe(
+ true,
+ );
+ });
+
+ it('gives the startup decision precedence over the fallback', () => {
+ expect(
+ renderHook(() => useVirtualViewport(true), {
+ wrapper: wrapper(false),
+ }).result.current,
+ ).toBe(false);
+ expect(
+ renderHook(() => useVirtualViewport(false), {
+ wrapper: wrapper(true),
+ }).result.current,
+ ).toBe(true);
+ });
+});
diff --git a/packages/cli/src/ui/contexts/VirtualViewportContext.tsx b/packages/cli/src/ui/contexts/VirtualViewportContext.tsx
new file mode 100644
index 00000000000..34ce66419b5
--- /dev/null
+++ b/packages/cli/src/ui/contexts/VirtualViewportContext.tsx
@@ -0,0 +1,15 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { createContext, useContext } from 'react';
+
+export const VirtualViewportContext = createContext(
+ undefined,
+);
+
+export function useVirtualViewport(fallback?: boolean): boolean {
+ return useContext(VirtualViewportContext) ?? fallback ?? false;
+}
diff --git a/packages/cli/src/ui/hooks/useMouseEvents.test.tsx b/packages/cli/src/ui/hooks/useMouseEvents.test.tsx
index 6fffb43d91b..a6ced0daf4e 100644
--- a/packages/cli/src/ui/hooks/useMouseEvents.test.tsx
+++ b/packages/cli/src/ui/hooks/useMouseEvents.test.tsx
@@ -11,7 +11,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { useStdin, useStdout } from 'ink';
import { KeypressProvider } from '../contexts/KeypressContext.js';
import { SettingsContext } from '../contexts/SettingsContext.js';
-import { UIStateContext, type UIState } from '../contexts/UIStateContext.js';
+import { VirtualViewportContext } from '../contexts/VirtualViewportContext.js';
import type { LoadedSettings } from '../../config/settings.js';
import { useMouseEvents } from './useMouseEvents.js';
@@ -51,18 +51,23 @@ const vpWrapper = (useTerminalBuffer: boolean) => {
return VpWrapper;
};
-const uiStateVpWrapper = (useTerminalBuffer: boolean) => {
+const virtualViewportWrapper = (
+ virtualViewport: boolean,
+ rawUseTerminalBuffer?: boolean,
+) => {
const VpWrapper = ({ children }: { children: React.ReactNode }) => (
-
+
{children}
-
+
);
return VpWrapper;
@@ -216,13 +221,20 @@ describe('useMouseEvents', () => {
expect(stdout.write).toHaveBeenCalledWith(ENABLE_MOUSE);
});
- it('uses UIState VP mode when the raw setting is unset', () => {
+ it('uses the startup VP decision when the raw setting is unset', () => {
renderHook(() => useMouseEvents(() => {}, { isActive: true }), {
- wrapper: uiStateVpWrapper(true),
+ wrapper: virtualViewportWrapper(true),
});
expect(stdout.write).toHaveBeenCalledWith(ENABLE_MOUSE);
});
+ it('keeps mouse mode off when the startup decision overrides an enabled setting', () => {
+ renderHook(() => useMouseEvents(() => {}, { isActive: true }), {
+ wrapper: virtualViewportWrapper(false, true),
+ });
+ expect(stdout.write).not.toHaveBeenCalledWith(ENABLE_MOUSE);
+ });
+
it('bypassVpGate: enables mouse mode even in non-VP (modal / VP viewport)', () => {
renderHook(
() => useMouseEvents(() => {}, { isActive: true, bypassVpGate: true }),
diff --git a/packages/cli/src/ui/hooks/useMouseEvents.ts b/packages/cli/src/ui/hooks/useMouseEvents.ts
index 8790a40070e..2b5eddbbdc4 100644
--- a/packages/cli/src/ui/hooks/useMouseEvents.ts
+++ b/packages/cli/src/ui/hooks/useMouseEvents.ts
@@ -18,7 +18,7 @@ import {
} from '../utils/mouse.js';
import { useKeypressContext } from '../contexts/KeypressContext.js';
import { SettingsContext } from '../contexts/SettingsContext.js';
-import { UIStateContext } from '../contexts/UIStateContext.js';
+import { useVirtualViewport } from '../contexts/VirtualViewportContext.js';
export type MouseHandler = (event: MouseEvent) => void;
@@ -152,11 +152,7 @@ export function useMouseEvents(
// pass `bypassVpGate` to opt in. This keeps the non-VP transcript scrollable
// no matter how many click/hover subscribers are added later.
const settings = useContext(SettingsContext);
- const uiState = useContext(UIStateContext);
- const isVpMode =
- uiState?.useTerminalBuffer ??
- settings?.merged.ui?.useTerminalBuffer ??
- false;
+ const isVpMode = useVirtualViewport(settings?.merged.ui?.useTerminalBuffer);
const vpGateOpen = isVpMode || bypassVpGate;
const handlerRef = useRef(handler);
diff --git a/packages/cli/src/ui/startInteractiveUI.tsx b/packages/cli/src/ui/startInteractiveUI.tsx
index 6e6f47f2ea5..2c31155c9b6 100644
--- a/packages/cli/src/ui/startInteractiveUI.tsx
+++ b/packages/cli/src/ui/startInteractiveUI.tsx
@@ -181,7 +181,7 @@ export async function startInteractiveUI(
startupWarnings={startupWarnings}
version={version}
initializationResult={initializationResult}
- initialUseTerminalBuffer={useVP}
+ initialUseVirtualViewport={useVP}
extensionRefreshState={options.extensionRefreshState}
/>
diff --git a/packages/cli/src/ui/utils/terminal-buffer.test.ts b/packages/cli/src/ui/utils/terminal-buffer.test.ts
index 9a6ff91c2db..c4b11e9af12 100644
--- a/packages/cli/src/ui/utils/terminal-buffer.test.ts
+++ b/packages/cli/src/ui/utils/terminal-buffer.test.ts
@@ -42,6 +42,11 @@ describe('isInteractiveTerminal', () => {
expect(isInteractiveTerminal(undefined, {})).toBe(false);
});
+ it('keeps dumb terminals on the append-only path', () => {
+ expect(isInteractiveTerminal(true, { TERM: 'dumb' })).toBe(false);
+ expect(isInteractiveTerminal(true, { TERM: 'DUMB' })).toBe(false);
+ });
+
it('treats CI sessions as non-interactive unless CI is explicitly disabled', () => {
expect(isInteractiveTerminal(true, { CI: 'true' })).toBe(false);
expect(
diff --git a/packages/cli/src/ui/utils/terminal-buffer.ts b/packages/cli/src/ui/utils/terminal-buffer.ts
index dc9995baf10..50c39537c96 100644
--- a/packages/cli/src/ui/utils/terminal-buffer.ts
+++ b/packages/cli/src/ui/utils/terminal-buffer.ts
@@ -34,7 +34,11 @@ export function isInteractiveTerminal(
stdoutIsTTY: boolean | undefined = process.stdout.isTTY,
env: TerminalEnvironment = process.env,
): boolean {
- return Boolean(stdoutIsTTY) && !isCiEnvironment(env);
+ return (
+ Boolean(stdoutIsTTY) &&
+ !isCiEnvironment(env) &&
+ env['TERM']?.toLowerCase() !== 'dumb'
+ );
}
export function shouldUseVirtualViewport(
From a22120ae0e3be508ec4395c55da1a9a3e5fd1857 Mon Sep 17 00:00:00 2001
From: hzb <991333136@qq.com>
Date: Mon, 13 Jul 2026 17:04:25 +0800
Subject: [PATCH 10/11] test(cli): cover virtual viewport fallbacks
---
packages/cli/src/gemini.test.tsx | 26 ++++++++++++++++++-
packages/cli/src/ui/AppContainer.test.tsx | 31 ++++++++++++++++++++++-
2 files changed, 55 insertions(+), 2 deletions(-)
diff --git a/packages/cli/src/gemini.test.tsx b/packages/cli/src/gemini.test.tsx
index cc0742f82d0..ed0440cdf6c 100644
--- a/packages/cli/src/gemini.test.tsx
+++ b/packages/cli/src/gemini.test.tsx
@@ -1943,8 +1943,8 @@ describe('startInteractiveUI', () => {
configurable: true,
});
}
- restoreCiEnv();
vi.unstubAllEnvs();
+ restoreCiEnv();
const currentExitListeners = process.listeners(
'exit',
) as NodeJS.ExitListener[];
@@ -2073,6 +2073,30 @@ describe('startInteractiveUI', () => {
expect(options).toMatchObject({ alternateScreen: false });
});
+ it('should not use alternate screen in CI with a TTY stdout', async () => {
+ vi.stubEnv('CI', 'true');
+ const { render } = await import('ink');
+ const renderSpy = vi.mocked(render);
+
+ const mockInitializationResult = {
+ authError: null,
+ themeError: null,
+ shouldOpenAuthDialog: false,
+ geminiMdFileCount: 0,
+ };
+
+ await startInteractiveUI(
+ mockConfig,
+ mockSettings,
+ mockStartupWarnings,
+ mockWorkspaceRoot,
+ mockInitializationResult,
+ );
+
+ const [, options] = renderSpy.mock.calls[0];
+ expect(options).toMatchObject({ alternateScreen: false });
+ });
+
it('should not use alternate screen in screen reader mode when VP mode is unset', async () => {
const { render } = await import('ink');
const renderSpy = vi.mocked(render);
diff --git a/packages/cli/src/ui/AppContainer.test.tsx b/packages/cli/src/ui/AppContainer.test.tsx
index fbb8d83acf3..0a6311f69ed 100644
--- a/packages/cli/src/ui/AppContainer.test.tsx
+++ b/packages/cli/src/ui/AppContainer.test.tsx
@@ -435,8 +435,8 @@ describe('AppContainer State Management', () => {
configurable: true,
});
}
- restoreCiEnv();
vi.unstubAllEnvs();
+ restoreCiEnv();
cleanup();
});
@@ -808,6 +808,35 @@ describe('AppContainer State Management', () => {
expect(capturedUIState.useTerminalBuffer).toBe(true);
});
+ it('keeps non-TTY output on the Static path', () => {
+ Object.defineProperty(process.stdout, 'isTTY', {
+ value: false,
+ configurable: true,
+ });
+ const defaultSettings = {
+ merged: {
+ hideTips: false,
+ theme: 'default',
+ ui: {
+ showStatusInTitle: false,
+ hideWindowTitle: false,
+ },
+ },
+ setValue: vi.fn(),
+ } as unknown as LoadedSettings;
+
+ render(
+ ,
+ );
+
+ expect(capturedUIState.useTerminalBuffer).toBe(false);
+ });
+
it('uses the startup VP decision when provided', () => {
const legacySettings = {
merged: {
From cef51a62bba922df0a69109113fcad03df49ca3e Mon Sep 17 00:00:00 2001
From: hzb <991333136@qq.com>
Date: Tue, 14 Jul 2026 18:33:47 +0800
Subject: [PATCH 11/11] docs(cli): clarify virtual viewport requirements
---
docs/users/reference/keyboard-shortcuts.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/docs/users/reference/keyboard-shortcuts.md b/docs/users/reference/keyboard-shortcuts.md
index e9df3e32c84..fa4e3c7ad14 100644
--- a/docs/users/reference/keyboard-shortcuts.md
+++ b/docs/users/reference/keyboard-shortcuts.md
@@ -69,7 +69,7 @@ This document lists the available keyboard shortcuts in Qwen Code.
## History scrollback
-Active when `ui.useTerminalBuffer` is enabled (Settings → UI → Virtualized History) and screen reader mode is off, which is the default for non-screen-reader sessions. In that mode conversation history is rendered inside an in-app viewport instead of the host terminal scrollback, so the keys below replace the terminal's native scroll.
+Active when `ui.useTerminalBuffer` is enabled (Settings → UI → Virtualized History), screen reader mode is off, and Qwen Code is running in a compatible interactive terminal (`stdout` is a TTY, CI is inactive, and `TERM` is not `dumb`), which is the default for ordinary non-screen-reader sessions. In that mode conversation history is rendered inside an in-app viewport instead of the host terminal scrollback, so the keys below replace the terminal's native scroll.
| Shortcut | Description |
| --------------- | ---------------------------------------------------- |