diff --git a/docs/design/ctrl-o-detail-expand/design.md b/docs/design/ctrl-o-detail-expand/design.md index de857baa962..352da0d92db 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 层——**模型响应预算或历史 display 压缩已移除的内容无法从 UI history 恢复**(见 §4.4),不是字面“全文”。 +- 任意时刻按 **Ctrl+O**:切换到接管整屏的 transcript,渲染一个**冻结快照**:定格进入那一刻的历史,**解除 UI 层高度/行数截断**(思考全文、工具输出尽量完整),支持上下/翻页/Home/End 滚动。默认 VP 路径复用 ink root 已占用的 alternate screen;legacy `` 路径才由 `AlternateScreen` 组件写 DEC `1049`(`\x1b[?1049h`)临时进入。⚠️ “完整”只指 UI 层——**模型响应预算或历史 display 压缩已移除的内容无法从 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}`;工具输出**同时**满足两点才算解除 UI 高度裁剪——(a) `availableTerminalHeight={undefined}`(验证 `ToolGroupMessage.tsx:357-365` 据此使 `availableTerminalHeightPerToolMessage` 为 undefined);(b) 关闭 `MaxSizedBox` 的高度约束、`sliceTextForMaxHeight`、shell 的 `shellStringCapHeight/shellOutputMaxLines`(`ToolMessage.tsx:67-74,750-756`)。⚠️ **保留模型响应预算与交互历史 display 压缩**——它们是请求体和会话存储边界,不属于 transcript 的 UI 展开职责,避免单条超大输出拖垮请求、会话文件或虚拟滚动。 - **三层裁剪边界(重要,避免过度承诺)**:(1) **模型响应层**:Shell/MCP 生产者预览和最终批次预算会缩短发送给模型的 `responseParts`,完整文本可能仅保存在临时 output artifact;rich `resultDisplay` 与此独立,MCP 在生产者处仍可保留完整 transformed display。(2) **交互历史层**:写入 UI 历史/会话前,`compactResultDisplayForInteractiveHistory` / recording compaction 会对过大的 rich display 再做字符级压缩。(3) **UI 层**:`MaxSizedBox`/`sliceTextForMaxHeight` 等按终端高度裁剪。**transcript 只能解除第 3 层**;它不能恢复已经从模型响应或历史 display 中移除的内容。规则:保留已有 truncation/compaction marker;“读取 persisted output artifact 并展示”列为**后续可选增强**,不在本期范围。i18n/文案不得宣称“查看完整工具输出”,改为“查看完整上下文(不含已被模型响应或会话存储边界裁剪的部分)”。 - **键盘分工**: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/docs/design/virtual-viewport/README.md b/docs/design/virtual-viewport/README.md index 9ba6bcb71d8..004b49a92ff 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,27 +132,44 @@ 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 } ``` -`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 ?? false; +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 as-is — no regression risk for users who don't opt in. +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 @@ -316,10 +333,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 +359,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 +378,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 35f41cbb3f6..83f5f888531 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), 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 | | --------------- | ---------------------------------------------------- | @@ -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 46db6b10bde..956a13c841a 100644 --- a/docs/users/support/troubleshooting.md +++ b/docs/users/support/troubleshooting.md @@ -99,7 +99,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 3418c51e0db..13eff94c812 100644 --- a/packages/cli/src/config/settingsSchema.test.ts +++ b/packages/cli/src/config/settingsSchema.test.ts @@ -432,9 +432,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 3c8996f1b9e..a98110b8ac8 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -1043,10 +1043,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. Also enables mouse interactions: click an option in a menu/dialog to select it, hover to highlight it, and click in the prompt to position the cursor. Does NOT use the host terminal scrollback while enabled. Drag to select text in the viewport (double/triple click selects a word/line), copied on release. To use the terminal’s own selection instead, hold Shift (or Option on macOS) while dragging.', + 'Render conversation history in an in-app scrollable viewport instead of the terminal scrollback buffer. Enabled by default in compatible interactive terminals to avoid flicker, scroll-storm, and interface freeze on long sessions, after Ctrl+O, after Ctrl+E / Ctrl+F (expand), after window resize, or when alt-tabbing back. Screen reader mode and non-interactive output such as piped stdout or CI use append-only terminal output instead. Scroll with Shift+↑/↓ (line), PgUp/PgDn (page), Ctrl+Home/End (top/bottom), or the mouse wheel. Also enables mouse interactions: click an option in a menu/dialog to select it, hover to highlight it, and click in the prompt to position the cursor. Does NOT use the host terminal scrollback while enabled. Drag to select text in the viewport (double/triple click selects a word/line), copied on release. To use the terminal’s own selection instead, hold Shift (or Option on macOS) while dragging.', showInDialog: true, }, showScrollbar: { diff --git a/packages/cli/src/gemini.test.tsx b/packages/cli/src/gemini.test.tsx index 2d29ce29610..d94eeb53c6a 100644 --- a/packages/cli/src/gemini.test.tsx +++ b/packages/cli/src/gemini.test.tsx @@ -22,6 +22,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'; @@ -2221,8 +2222,41 @@ describe('startInteractiveUI', () => { render: vi.fn().mockReturnValue({ unmount: vi.fn() }), })); + let initialExitListeners: NodeJS.ExitListener[] = []; + let originalStdoutIsTTY: boolean | undefined; + let restoreCiEnv = () => {}; + beforeEach(() => { vi.clearAllMocks(); + restoreCiEnv = clearCiEnv(); + vi.stubEnv('TERM', 'xterm-256color'); + 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, + }); + } + vi.unstubAllEnvs(); + restoreCiEnv(); + 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 () => { @@ -2252,13 +2286,191 @@ 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 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 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 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); + 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('should perform all startup tasks in correct order', async () => { const { getCliVersion } = await import('./utils/version.js'); const { registerCleanup } = await import('./utils/cleanup.js'); 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..80d9cc016fd --- /dev/null +++ b/packages/cli/src/test-utils/ci-env.ts @@ -0,0 +1,34 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { isCiEnvKey } from '../ui/utils/terminal-buffer.js'; + +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 9faf8700719..fe6154110cf 100644 --- a/packages/cli/src/ui/AppContainer.test.tsx +++ b/packages/cli/src/ui/AppContainer.test.tsx @@ -186,6 +186,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'; import { restorePromptStash } from '../services/prompt-stash.js'; describe('AppContainer State Management', () => { @@ -218,10 +219,19 @@ describe('AppContainer State Management', () => { const mockedUseLoadingIndicator = useLoadingIndicator as Mock; const mockedUseTerminalSize = useTerminalSize as Mock; const mockedUseKeypress = useKeypress as Mock; + let originalStdoutIsTTY: boolean | undefined; + let restoreCiEnv = () => {}; const mockedRestorePromptStash = vi.mocked(restorePromptStash); beforeEach(() => { vi.clearAllMocks(); + restoreCiEnv = clearCiEnv(); + vi.stubEnv('TERM', 'xterm-256color'); + originalStdoutIsTTY = process.stdout.isTTY; + Object.defineProperty(process.stdout, 'isTTY', { + value: true, + configurable: true, + }); // Initialize mock stdout for terminal title tests mockStdout = { write: vi.fn() }; @@ -409,6 +419,7 @@ describe('AppContainer State Management', () => { ui: { showStatusInTitle: false, hideWindowTitle: false, + useTerminalBuffer: false, }, }, setValue: vi.fn(), @@ -444,6 +455,16 @@ 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, + }); + } + vi.unstubAllEnvs(); + restoreCiEnv(); cleanup(); vi.useRealTimers(); }); @@ -887,6 +908,190 @@ 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 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: { + 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('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 = { + 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 @@ -3260,6 +3465,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. @@ -3830,7 +4039,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 9a5965291e8..f911d5c2ebb 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, @@ -126,6 +127,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 { + 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'; @@ -458,6 +463,7 @@ interface AppContainerProps { startupWarnings?: string[]; version: string; initializationResult: InitializationResult; + initialUseVirtualViewport?: boolean; extensionRefreshState?: ExtensionRefreshState; } @@ -474,7 +480,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, initialUseVirtualViewport } = + props; const extensionRefreshState = useMemo( () => props.extensionRefreshState ?? new ExtensionRefreshState(), [props.extensionRefreshState], @@ -1145,12 +1152,20 @@ export const AppContainer = (props: AppContainerProps) => { // cursorTo+eraseDown would be a wasted flash and would also corrupt the // in-app scroll position. The remount-key bump is also a near-no-op for // VP: nothing in the VP render path is keyed by historyRemountKey, so - // the only reason to bump it is to keep the legacy `` branch in - // sync if the user toggles `useTerminalBuffer` off mid-session. The - // visible refresh in VP mode comes for free from the React tree + // keeping the bump is harmless because the startup-scoped VP decision + // is intentionally restart-only to match Ink's alternateScreen lifetime. + // 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 = settings.merged.ui?.useTerminalBuffer ?? false; + const [useTerminalBuffer] = useState( + () => + initialUseVirtualViewport ?? + shouldUseVirtualViewport( + settings.merged.ui?.useTerminalBuffer, + config.getScreenReader(), + isInteractiveTerminal(), + ), + ); const showScrollbar = settings.merged.ui?.showScrollbar ?? true; const refreshStatic = useCallback(() => { // While the transcript (alt-screen) owns the whole screen, suppress static @@ -4662,43 +4677,45 @@ export const AppContainer = (props: AppContainerProps) => { ); return ( - - - - - - - - - {transcriptFreeze ? ( - // TranscriptView renders as a sibling of , which - // owns the StreamingContext.Provider — so the frozen - // transcript subtree has no provider of its own. A - // pending tool group captured in the snapshot can hold a - // tool in the Executing state, whose spinner calls - // useStreamingContext and would otherwise throw. Provide - // the context here so the transcript renders. - - - - ) : ( - - )} - - - - - - - - + + + + + + + + + + {transcriptFreeze ? ( + // TranscriptView renders as a sibling of , which + // owns the StreamingContext.Provider — so the frozen + // transcript subtree has no provider of its own. A + // pending tool group captured in the snapshot can hold a + // tool in the Executing state, whose spinner calls + // useStreamingContext and would otherwise throw. Provide + // the context here so the transcript renders. + + + + ) : ( + + )} + + + + + + + + + ); }; diff --git a/packages/cli/src/ui/components/HistoryItemDisplay.test.tsx b/packages/cli/src/ui/components/HistoryItemDisplay.test.tsx index 92929497212..1bdee560097 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 { VirtualViewportContext } from '../contexts/VirtualViewportContext.js'; import type { MouseEvent } from '../utils/mouse.js'; import { layoutRowForEvent, @@ -586,6 +587,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(), + ); + const mouseEvent = (name: MouseEvent['name'], col: number): MouseEvent => ({ name, col, @@ -639,6 +654,35 @@ describe('', () => { expect(opts?.bypassVpGate ?? false).toBe(false); }); + 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`); + }); + it('toggles on a complete click', () => { const toggle = vi.fn(); const handler = renderThoughtWithToggle(toggle); diff --git a/packages/cli/src/ui/components/HistoryItemDisplay.tsx b/packages/cli/src/ui/components/HistoryItemDisplay.tsx index 067ac34358e..621667273d9 100644 --- a/packages/cli/src/ui/components/HistoryItemDisplay.tsx +++ b/packages/cli/src/ui/components/HistoryItemDisplay.tsx @@ -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 { 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,7 +126,7 @@ const ClickableThinkMessage: React.FC<{ const pressRef = useRef<{ col: number; row: number } | null>(null); const { rows: terminalHeight } = useTerminalSize(); const settings = useSettings(); - const clickable = !!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 5a5addd1e3a..5f25a25ebd1 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 { 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. @@ -217,6 +218,17 @@ describe('InputPrompt suggestion mouse routing', () => { unmount(); }); + 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(); + }); + 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 9f13786c4cf..5de6d1b5b9b 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, @@ -253,7 +254,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 = !!settings.merged.ui?.useTerminalBuffer; + const mouseInteractionsEnabled = useVirtualViewport( + settings.merged.ui?.useTerminalBuffer, + ); const { pasteWorkaround } = useKeypressContext(); const { agents, agentTabBarFocused } = useAgentViewState(); const { setAgentTabBarFocused } = useAgentViewActions(); @@ -281,7 +284,7 @@ export const InputPrompt: React.FC = ({ // Window by the same cap the panel actually renders (VP mode uses a // height-aware cap via getLiveAgentPanelVpMaxRows in DefaultAppLayout) // so the keyboard selection can't address a row that is scrolled off. - const liveAgentPanelMaxRows = settings.merged.ui?.useTerminalBuffer + const liveAgentPanelMaxRows = uiState.useTerminalBuffer ? getLiveAgentPanelVpMaxRows(uiState.terminalHeight) : LIVE_AGENT_PANEL_MAX_ROWS; const getVisibleBgAgents = useCallback( 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 1ba28c302bd..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,6 +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 { VirtualViewportContext } from '../../contexts/VirtualViewportContext.js'; import { RadioButtonSelect } from './RadioButtonSelect.js'; // `useMouseEvents` gates SGR mouse escapes on `stdout.isTTY` (so they never leak @@ -76,6 +77,30 @@ describe('BaseSelectionList with mouse enabled (integration)', () => { expect(enabledAnyWritten()).toBe(true); }); + it('uses the startup VP decision when the raw setting is unset', () => { + const { frames } = renderWithProviders( + + {}} /> + , + ); + const output = frames.join('\n'); + expect(output).toContain('Alpha'); + expect(output).toContain('Beta'); + 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 3b3a5bc17cf..035183cf302 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 { useVirtualViewport } from '../../contexts/VirtualViewportContext.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 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 091f108998e..a6ced0daf4e 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 { VirtualViewportContext } from '../contexts/VirtualViewportContext.js'; import type { LoadedSettings } from '../../config/settings.js'; import { useMouseEvents } from './useMouseEvents.js'; @@ -50,6 +51,28 @@ const vpWrapper = (useTerminalBuffer: boolean) => { return VpWrapper; }; +const virtualViewportWrapper = ( + virtualViewport: boolean, + rawUseTerminalBuffer?: 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 +221,20 @@ describe('useMouseEvents', () => { expect(stdout.write).toHaveBeenCalledWith(ENABLE_MOUSE); }); + it('uses the startup VP decision when the raw setting is unset', () => { + renderHook(() => useMouseEvents(() => {}, { isActive: 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 8a8d5f84b4c..2b5eddbbdc4 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 { useVirtualViewport } from '../contexts/VirtualViewportContext.js'; export type MouseHandler = (event: MouseEvent) => void; @@ -151,7 +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 isVpMode = 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 1c06c586a15..d125a2d82c5 100644 --- a/packages/cli/src/ui/startInteractiveUI.tsx +++ b/packages/cli/src/ui/startInteractiveUI.tsx @@ -33,6 +33,10 @@ import { } from './utils/kittyProtocolDetector.js'; import { installTerminalRedrawOptimizer } from './utils/terminalRedrawOptimizer.js'; import { installSynchronizedOutput } from './utils/synchronizedOutput.js'; +import { + isInteractiveTerminal, + shouldUseVirtualViewport, +} from './utils/terminal-buffer.js'; import { ErrorBoundary } from './components/shared/ErrorBoundary.js'; import { registerCleanup, runExitCleanup } from '../utils/cleanup.js'; import { stopAndGetCapturedInput } from '../utils/earlyInputCapture.js'; @@ -146,6 +150,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(); @@ -175,6 +185,7 @@ export async function startInteractiveUI( startupWarnings={startupWarnings} version={version} initializationResult={initializationResult} + initialUseVirtualViewport={useVP} extensionRefreshState={options.extensionRefreshState} /> @@ -188,7 +199,6 @@ export async function startInteractiveUI( ); }; - const useVP = settings.merged.ui?.useTerminalBuffer ?? false; const stdoutMaxListeners = process.stdout.getMaxListeners(); if (useVP) { // Visible VP rows each subscribe to resize through Ink's useBoxMetrics. 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..c4b11e9af12 --- /dev/null +++ b/packages/cli/src/ui/utils/terminal-buffer.test.ts @@ -0,0 +1,77 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it } from 'vitest'; +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, true)).toBe( + getSettingsSchema().ui.properties.useTerminalBuffer.default, + ); + }); + + it('respects explicit terminal buffer settings', () => { + 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, 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('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( + 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, { 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, { 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 new file mode 100644 index 00000000000..50c39537c96 --- /dev/null +++ b/packages/cli/src/ui/utils/terminal-buffer.ts @@ -0,0 +1,52 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +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 !== '' && + normalizedValue !== '0' && + normalizedValue !== 'false' + ); +} + +function isCiEnvironment(env: TerminalEnvironment): boolean { + return Object.keys(env).some( + (key) => isCiEnvKey(key) && isActiveCiValue(env[key]), + ); +} + +export function isInteractiveTerminal( + stdoutIsTTY: boolean | undefined = process.stdout.isTTY, + env: TerminalEnvironment = process.env, +): boolean { + return ( + Boolean(stdoutIsTTY) && + !isCiEnvironment(env) && + env['TERM']?.toLowerCase() !== 'dumb' + ); +} + +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 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 598839709a5..66da5f1b408 100644 --- a/packages/vscode-ide-companion/schemas/settings.schema.json +++ b/packages/vscode-ide-companion/schemas/settings.schema.json @@ -380,9 +380,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. Also enables mouse interactions: click an option in a menu/dialog to select it, hover to highlight it, and click in the prompt to position the cursor. Does NOT use the host terminal scrollback while enabled. Drag to select text in the viewport (double/triple click selects a word/line), copied on release. To use the terminal’s own selection instead, hold Shift (or Option on macOS) while dragging.", + "description": "Render conversation history in an in-app scrollable viewport instead of the terminal scrollback buffer. Enabled by default in compatible interactive terminals to avoid flicker, scroll-storm, and interface freeze on long sessions, after Ctrl+O, after Ctrl+E / Ctrl+F (expand), after window resize, or when alt-tabbing back. Screen reader mode and non-interactive output such as piped stdout or CI use append-only terminal output instead. Scroll with Shift+↑/↓ (line), PgUp/PgDn (page), Ctrl+Home/End (top/bottom), or the mouse wheel. Also enables mouse interactions: click an option in a menu/dialog to select it, hover to highlight it, and click in the prompt to position the cursor. Does NOT use the host terminal scrollback while enabled. Drag to select text in the viewport (double/triple click selects a word/line), copied on release. To use the terminal’s own selection instead, hold Shift (or Option on macOS) while dragging.", "type": "boolean", - "default": false + "default": true }, "showScrollbar": { "description": "Show the auto-hiding scrollbar in the in-app scrollable viewport (Virtualized History). The bar appears while scrolling and fades out when idle. Disable to hide it entirely.",