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 | | --------------- | ---------------------------------------------------- |