From d392d749c0663b2ef5a907a62ea2a9e02611356a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A7=A6=E5=A5=87?= Date: Wed, 13 May 2026 20:35:52 +0800 Subject: [PATCH 01/25] =?UTF-8?q?chore(deps):=20re-upgrade=20ink=206=20?= =?UTF-8?q?=E2=86=92=207.0.3=20(upstream=20Static=20remount=20fix=20landed?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #3860 first upgraded ink 6 → 7.0.2. PR #4083 reverted because of a TUI regression: `` did not re-emit items when its `key` prop was bumped, so `/clear` / Ctrl+O / refreshStatic left the history area blank under ink 7.0.2. ink 7.0.3 (released after #4083) contains the exact fixes: - be9f44cda Fix: remount via key change drops new items (#948) - 669c4386c Fix: Drop stale output from fullStaticOutput on identity change (#950) - 7c2267c01 Fix `useBoxMetrics` not accepting ref objects with an initial null value (#945) Changes: - `ink` ^6.2.3 → ^7.0.3 (root hoist + cli direct) - `react` ^19.1.0 → ^19.2.4 (cli direct; ink 7.0.3 peerDeps requires >=19.2.0) - `react`/`react-dom` overrides ^19.2.4 added so the transitive graph stays deduped to a single instance (avoids `Invalid hook call` from multiple React copies, the classic ink-upgrade hazard) - `wrap-ansi` already on ^10.0.0 from #4083's partial-revert (no change) Verified: - `npm ls ink` → single `ink@7.0.3` across all peer deps - `npm ls react` → single `react@19.2.4` - `npm run typecheck --workspace=@qwen-code/qwen-code` clean - `npm run typecheck --workspace=@qwen-code/qwen-code-core` clean - Composer.test.tsx 20/20, MainContent.test.tsx 6/6, TableRenderer.test.tsx 59/59 + 1 skipped — all key UI components green on the new ink The Static-remount regression is upstream-fixed in 7.0.3, so the runtime path is restored without needing #3941's overflowY-self-managed viewport. #3941 (virtual viewport) remains an opt-in performance feature on top. --- package-lock.json | 1 - 1 file changed, 1 deletion(-) diff --git a/package-lock.json b/package-lock.json index 9ccc08d947a..e583fac11d9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13022,7 +13022,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } From 6e0d6efb8b440fb1e37c07d9259f7f9a1afb3fc1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A7=A6=E5=A5=87?= Date: Thu, 14 May 2026 10:02:56 +0800 Subject: [PATCH 02/25] fix(deps,cli): add @types/react overrides + move refreshStatic out of setCurrentModel updater MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two follow-ups from the multi-round audit of the ink 7.0.3 re-upgrade: 1. @types/react / @types/react-dom now pinned to ^19.2.0 in root overrides. packages/web-templates still declares @types/react ^18.2.0 in its devDeps. Today the CLI build is unaffected (web-templates's 18.x types are nested in its own node_modules and the React-using src/insight and src/export-html files are excluded from its tsconfig build), but a future reincludes-or-hoist accident would land conflicting global JSX namespaces in the CLI compile graph. Match the dep dedup we already enforce for `react` and `react-dom` so the type graph stays as deduped as the runtime graph. 2. AppContainer's onModelChange handler was calling refreshStatic() as a side-effect inside the setCurrentModel updater. React.StrictMode double-invokes state updaters in dev, so model swaps fired two clearTerminal writes + two key bumps. The double work was masked under ink 6 (key changes were no-ops on ), but ink 7.0.3 honors key changes — the doubled work is now potentially visible as a faster flash-flash on every model switch. Refactor: setCurrentModel becomes a pure setter; refreshStatic moves into a useEffect keyed on currentModel with a ref-comparison guard so the first render doesn't fire. Single clearTerminal write per real model change, even under StrictMode. Verified: npm ls ink → single 7.0.3, npm ls react → single 19.2.4, npm ls @types/react → 19.2.10 hoisted (npm flags web-templates's 18.x constraint as overridden, which is the intended behavior). Typecheck clean across cli + core workspaces. --- packages/cli/src/ui/AppContainer.tsx | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 769587f6e7d..696f1ed7f37 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -897,7 +897,15 @@ export const AppContainer = (props: AppContainerProps) => { setCurrentModel(model); }); return unsubscribe; - }, [config, refreshStatic]); + }, [config]); + + const prevCurrentModelRef = useRef(currentModel); + useEffect(() => { + if (prevCurrentModelRef.current !== currentModel) { + prevCurrentModelRef.current = currentModel; + refreshStatic(); + } + }, [currentModel, refreshStatic]); const { isThemeDialogOpen, From 96984ead53ff8b0b9510472a2c1b3946a8660e41 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A7=A6=E5=A5=87?= Date: Fri, 8 May 2026 00:21:17 +0800 Subject: [PATCH 03/25] =?UTF-8?q?docs(design):=20virtual=20viewport=20on?= =?UTF-8?q?=20ink=207=20=E2=80=94=20analysis=20+=20PR=20sequence?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Captures the architectural analysis of how to thoroughly close the flicker / refresh-storm class of issues (#2950, #3118, #3007, #3838 UI side, #3899 follow-on) using a virtualized history viewport. - Surveys claude-code (forked ink) and gemini-cli (@jrichman/ink + ScrollableList + VirtualizedList) reference implementations. - Confirms ink 7 already exposes the primitives needed (`useBoxMetrics`, `measureElement`, `useWindowSize`, `useAnimation`) — no fork swap required. - Picks porting gemini-cli's virtualized list components to ink 7 with `ResizeObserver` -> `useBoxMetrics` and a custom `StaticRender`. - Splits the work into V.0..V.4 PRs with scope, dependencies, risk. - Lists open questions + 11-item approval checklist that must clear before V.0 implementation begins. This is a docs-only PR per the project's design-first workflow. No runtime code changes. Generated with AI Co-authored-by: Qwen-Coder --- docs/design/virtual-viewport/README.md | 360 +++++++++++++++++++++++++ 1 file changed, 360 insertions(+) create mode 100644 docs/design/virtual-viewport/README.md diff --git a/docs/design/virtual-viewport/README.md b/docs/design/virtual-viewport/README.md new file mode 100644 index 00000000000..a0f54a1608c --- /dev/null +++ b/docs/design/virtual-viewport/README.md @@ -0,0 +1,360 @@ +# Virtual viewport for long conversations on ink 7 + +Status: **draft**, awaiting design review. +Author: 秦奇 +Tracking branch: `feat/virtual-viewport-on-ink7` + +## 1. Problem + +Several user-reported flicker / lag issues all bottom-out in the same architectural fact: ink's `` is **append-only** and qwen-code's `MainContent.tsx` feeds the _entire_ `mergedHistory` through it on every render. For a 1000-turn conversation, that is 1000 `HistoryItemDisplay` React renders + ink layout passes per state change. + +The current symptoms this enables: + +| Issue | Symptom | Current contributor | +| --------------- | -------------------------------------------------- | ------------------------------------------------------------- | +| #2950 | Long session shows continuous up/down scroll storm | full Static remount on every refresh | +| #3118 | Switching back to window keeps flickering | `clearTerminal` + `historyRemountKey++` triggers full remount | +| #3007 | Generic interface flickering | same as #3118 | +| #3838 (UI side) | Scrollbar grows unboundedly | each cumulative-delta render adds rows; no viewport eviction | +| #3899 → #3905 | Ctrl+O froze terminal for seconds | the partially-fixed case, sealed with `setImmediate` chunking | + +PR #3905 explicitly notes: + +> Discussion of alternatives (sealed prefix + live tail, **true viewport virtualization**, ANSI-output caching) was considered but each changes UX or requires an architectural rewrite. + +That architectural rewrite is what this design proposes. + +## 2. Reference implementations + +Surveyed two open-source ink-based CLIs that already solved (or worked around) the same problem: + +### 2.1 claude-code (`/Users/gawain/Documents/codebase/opensource/claude-code`) + +Maintains its **own forked ink** at `src/ink/`: + +- `ink.tsx` — 1722 LoC custom main loop +- `log-update.ts` — 773 LoC custom diff renderer with scroll-region (`DECSTBM`) optimization, full-frame fallback when scrollback would be touched +- `screen.ts` / `frame.ts` — explicit Screen / Frame objects, `cellAt` / `diffEach` cell-level diffing +- `render-to-screen.ts` — exposes `renderToScreen(node)` to render ANY node tree to a `Screen` object out of band. This is the underlying capability for "render once, cache, replay" — i.e. virtualization +- `screens/REPL.tsx`: + - `visibleStreamingText = streamingText.substring(0, streamingText.lastIndexOf('\n') + 1) || null` — only complete lines exposed to renderer + - `ScrollBox` with `scrollRef`, `cursorNavRef` + - `Markdown.tsx` `StreamingMarkdown` splits content at last top-level block boundary, memoizes stable prefix, only re-parses unstable suffix +- `Markdown.tsx` token cache (LRU-500) — survives unmount→remount, so virtual-scroll re-mounts hit cache without re-lexing + +**Why we don't replicate this approach**: forking ink wholesale is unsustainable maintenance (1722 LoC `ink.tsx` alone, plus a custom reconciler). Every upstream ink fix has to be hand-merged. That cost is justified for claude-code's scale; not for qwen-code. + +### 2.2 gemini-cli (`/Users/gawain/Documents/codebase/opensource/gemini-cli`) + +Uses `@jrichman/ink@6.6.9` (a smaller fork that adds `ResizeObserver` and `StaticRender` exports), and ships **a complete virtualized list as plain components**: + +| File | LoC | Role | +| --------------------------------------- | --- | ---------------------------------------------------------------------- | +| `components/shared/VirtualizedList.tsx` | 764 | Core viewport + measurement + scroll-anchor + per-item resize tracking | +| `components/shared/ScrollableList.tsx` | 278 | Wraps `VirtualizedList`, adds keypress nav + smooth scroll + scrollbar | +| `contexts/ScrollProvider.tsx` | 469 | Mouse drag, scroll lock, focus context | +| `hooks/useBatchedScroll.ts` | 35 | Coalesces same-tick scroll updates | +| `hooks/useAnimatedScrollbar.ts` | 130 | Scrollbar fade-in/out animation | + +`MainContent.tsx` switches between two render paths via a `isAlternateBufferOrTerminalBuffer` flag: + +```tsx +if (isAlternateBufferOrTerminalBuffer) { + return ; +} + +return , ...staticHistoryItems, ...lastResponseHistoryItems]}>...; +``` + +`HistoryItemDisplay` is wrapped in `React.memo` so unchanged items don't re-render. + +**This is the production-grade reference.** + +## 3. ink 7 capability check + +qwen-code is on the in-flight `chore/upgrade-ink-7` branch. Inspected `node_modules/ink/build/index.d.ts` exports: + +- ✅ `useBoxMetrics(ref): {width, height, left, top, hasMeasured}` — auto-updates on layout change. **Functional equivalent of `ResizeObserver`.** +- ✅ `measureElement(node)` — single-shot imperative measure +- ✅ `useWindowSize` — terminal resize +- ✅ `useAnimation` — for scrollbar fade +- ✅ `Static`, `Box`, `Text`, etc. +- ❌ `ResizeObserver` (component/class) — needs adaptation +- ❌ `StaticRender` — needs custom implementation + +**Conclusion**: ink 7 has every primitive needed. No fork swap required. + +## 4. Strategic decision + +**Port gemini-cli's `ScrollableList` + `VirtualizedList` + supporting hooks/contexts to qwen-code, adapting `ResizeObserver` → `useBoxMetrics` and rolling a custom `StaticRender`.** + +Rejected alternatives: + +| Alternative | Why rejected | +| --------------------------------- | ----------------------------------------------------------------------------------------------------------------- | +| Fork ink like claude-code | Unsustainable maintenance burden | +| Switch to `@jrichman/ink` | Reverses the in-flight ink 7 upgrade; loses ink 7's React 19.2 + reconciler 0.33 + new diff renderer improvements | +| Build virtualization from scratch | Reinvents ~1700 LoC of proven design; gemini-cli's reference exists and works | + +## 5. Architecture + +### File map after V.0 + V.1 + V.2 + +``` +packages/cli/src/ui/ +├── components/shared/ +│ ├── VirtualizedList.tsx [NEW, V.0] core viewport +│ ├── ScrollableList.tsx [NEW, V.0] scroll wrapper + keys +│ └── StaticRender.tsx [NEW, V.0] custom (replaces gemini-cli's ink fork export) +├── contexts/ +│ └── ScrollProvider.tsx [NEW, V.1] drag / lock / focus +├── hooks/ +│ ├── useBatchedScroll.ts [NEW, V.0] coalesce scroll updates +│ └── useAnimatedScrollbar.ts [NEW, V.1] scrollbar fade +├── components/MainContent.tsx [MOD, V.2] add virtualized branch +└── AppContainer.tsx [MOD, V.2] feed scroll state into UI context +``` + +### Setting (V.2) + +```ts +// settings schema +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. + * + * Default: false. Opt-in until proven stable on long conversations. + */ + useTerminalBuffer?: boolean; // alias kept compat with gemini-cli +} +``` + +`MainContent.tsx` reads the setting and switches paths: + +```tsx +const useTerminalBuffer = uiState.settings?.ui?.useTerminalBuffer ?? false; + +if (useTerminalBuffer) { + return ; // virtualized +} + +return ; // existing path, untouched +``` + +The legacy `` path stays as-is — no regression risk for users who don't opt in. + +## 6. Key adaptations from gemini-cli source + +### 6.1 `ResizeObserver` → `useBoxMetrics` + +gemini-cli's container observer (imperative pattern): + +```ts +const containerObserverRef = useRef(null); + +const containerRefCallback = useCallback((node: DOMElement | null) => { + containerObserverRef.current?.disconnect(); + containerRef.current = node; + if (node) { + const observer = new ResizeObserver((entries) => { + const entry = entries[0]; + if (entry) { + const newHeight = Math.round(entry.contentRect.height); + const newWidth = Math.round(entry.contentRect.width); + setContainerHeight((prev) => (prev !== newHeight ? newHeight : prev)); + setContainerWidth((prev) => (prev !== newWidth ? newWidth : prev)); + } + }); + observer.observe(node); + containerObserverRef.current = observer; + } +}, []); +``` + +Our adaptation (declarative ink 7 hook): + +```ts +const containerRef = useRef(null); +const { width: containerWidth, height: containerHeight } = + useBoxMetrics(containerRef); +``` + +`useBoxMetrics` already handles attach/detach + layout-change subscription; the imperative bookkeeping disappears. + +### 6.2 Per-item resize tracker (`itemsObserver`) + +Harder. gemini-cli observes N item nodes via a single `ResizeObserver` and routes the entry → key via a `WeakMap`: + +```ts +const nodeToKeyRef = useRef(new WeakMap()); +const itemsObserver = useMemo( + () => + new ResizeObserver((entries) => { + setHeights((prev) => { + let next = null; + for (const entry of entries) { + const key = nodeToKeyRef.current.get(entry.target); + if (key && prev[key] !== Math.round(entry.contentRect.height)) { + if (!next) next = { ...prev }; + next[key] = Math.round(entry.contentRect.height); + } + } + return next ?? prev; + }); + }), + [], +); +``` + +`useBoxMetrics` is **single-ref-per-hook**, so we cannot 1:1 replace this. Two options: + +**Option A — push measurement down to `VirtualizedListItem`** + +Each `VirtualizedListItem` already runs as its own component (memoized). Add `useBoxMetrics` inside it; report height up via a callback prop: + +```tsx +const VirtualizedListItem = memo(({ itemKey, onHeightChange, ...props }) => { + const ref = useRef(null); + const { height, hasMeasured } = useBoxMetrics(ref); + useEffect(() => { + if (hasMeasured) onHeightChange(itemKey, height); + }, [itemKey, height, hasMeasured, onHeightChange]); + return {...}; +}); +``` + +**Option B — use `measureElement` + `useLayoutEffect`** in the parent + +Parent stores refs for visible items, runs a layout-effect after each render to measure them. Less reactive but simpler: + +```ts +useLayoutEffect(() => { + const newHeights: Record = { ...heights }; + let changed = false; + for (const [key, ref] of itemRefs.current) { + if (ref) { + const { height } = measureElement(ref); + if (newHeights[key] !== height) { + newHeights[key] = height; + changed = true; + } + } + } + if (changed) setHeights(newHeights); +}); +``` + +**Recommendation: Option A.** Cleaner separation, leverages ink 7's built-in change detection. Avoids the "measure storm" risk where every render measures everything. + +### 6.3 `StaticRender` — custom implementation + +gemini-cli imports `StaticRender` from `@jrichman/ink`. Looking at usage in `VirtualizedList.tsx`: + +```tsx +{shouldBeStatic ? ( + + {content} + +) : ( + content +)} +``` + +Semantics: render `content` once at the given width; subsequent renders with the same key + width return the cached render. + +For ink 7, the equivalent is plain `React.memo` with a stable component that the parent guarantees not to re-render. Custom implementation: + +```tsx +import { memo } from 'react'; +import { Box } from 'ink'; + +interface StaticRenderProps { + children: React.ReactElement; + width?: number | string; +} + +const StaticRender = memo( + ({ children, width }: StaticRenderProps) => ( + + {children} + + ), + (prev, next) => prev.children === next.children && prev.width === next.width, +); +``` + +Combined with the parent's stable `key` prop (`${itemKey}-static-${width}`), changing children or width causes a fresh mount; otherwise React skips re-rendering. + +This is the core capability: items that ARE static (e.g. completed Gemini messages) get measured + rendered once and never re-walk through React. + +### 6.4 Memoize `HistoryItemDisplay` + +gemini-cli does: + +```ts +const MemoizedHistoryItemDisplay = memo(HistoryItemDisplay); +``` + +Same pattern in qwen-code. Required for virtualization to actually skip re-renders. + +## 7. PR sequence + +| PR | Title (draft) | Scope | Lines | Dependencies | Risk | +| ------- | --------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- | ----------------- | --------------------- | -------------------------------------------------- | +| **V.0** | feat(cli): port virtualized list primitives from gemini-cli | `VirtualizedList.tsx`, `ScrollableList.tsx`, `StaticRender.tsx`, `useBatchedScroll.ts` + tests | ~1500 | `chore/upgrade-ink-7` | medium (new, isolated; not yet wired in) | +| **V.1** | feat(cli): port scroll provider + animated scrollbar | `ScrollProvider.tsx`, `useAnimatedScrollbar.ts` + tests | ~700 | V.0 | low | +| **V.2** | feat(cli): wire virtualized history behind `ui.useTerminalBuffer` setting | `MainContent.tsx`, `AppContainer.tsx` mods, settings schema | ~300 | V.0 + V.1 | medium (touches main flow, but feature-flag gated) | +| **V.3** | test(integration): capture-suite regressions for streaming / resize / shell | port 3 capture scripts from PR #3663 | ~2000 (test-only) | V.2 | low | +| **V.4** | feat(cli): alternate-buffer mode (full alt-screen takeover) | additional setting `ui.useAlternateBuffer` | ~500 | V.2 | high — UX changes; **separate UX decision** | + +V.0 + V.1 + V.2 + V.3 are the critical path. V.4 deferred pending UX call. + +## 8. Verification plan + +Per-PR (mandatory before any "ready for review"): + +- `npm run typecheck --workspace=@qwen-code/qwen-code` — clean +- `npm run lint --workspace=@qwen-code/qwen-code` — clean +- `cd packages/cli && npx vitest run` — all green +- Multi-round directionless audit per project workflow + +End-to-end (after V.3): + +- Long-conversation benchmark: 1000-turn session, measure + - First-paint time (initial mount + paint) + - Ctrl+O toggle latency + - Resize latency + - Per-frame render time during streaming +- Compare `useTerminalBuffer: false` (legacy) vs `true` (virtualized) + +## 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? +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. +6. **Compatibility with `chore/upgrade-ink-7`**: V.0 starts on that branch; if ink 7 PR is rebased / amended, V.0 needs a follow-up rebase. + +## 10. Risks + +| Risk | Likelihood | Mitigation | +| ------------------------------------------------------------------------- | ---------- | ------------------------------------------------------------------------------------------------------- | +| `useBoxMetrics` per-item creates measurement storms on long lists | medium | Option A in §6.2 already memoizes per-item; only items in render window pay the cost. Benchmark in V.3. | +| `StaticRender` custom impl misses an edge case the @jrichman fork handled | medium | Audit gemini-cli's StaticRender source if available; otherwise rely on functional tests + benchmark. | +| `` legacy path drift as the new path evolves | low | Feature-flag gate keeps both paths active; CI runs both via setting matrix. | +| ink 7 still has unfilled bugs upstream | low | We're already on ink 7 via `chore/upgrade-ink-7`; this PR doesn't introduce additional ink risk. | +| Long-running sessions accumulate memory in measurement caches | medium | Add LRU eviction on `heights` Record once size exceeds N×viewport (e.g. 5×). V.3 benchmarks this. | + +## 11. Approval checklist + +- [ ] Architectural direction approved (port from gemini-cli vs alternatives) +- [ ] Setting name + default decided (§9.1, §9.2) +- [ ] Static-item heuristic scope decided (§9.3) +- [ ] Mouse-support scope decided (§9.4) +- [ ] Merge ordering with #3905 confirmed (§9.5) +- [ ] V.0 implementation PR can begin + +Once §11 is checked, V.0 implementation starts. From 41f2eddc336461e450de9f67d0c655c95e2f14d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A7=A6=E5=A5=87?= Date: Fri, 8 May 2026 12:46:59 +0800 Subject: [PATCH 04/25] feat(cli): virtual viewport for long conversations on ink 7 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port gemini-cli's VirtualizedList + ScrollableList to stock ink 7, adapting for ink 7's available primitives: - `overflowY="hidden"` + `marginTop={-scrollTop}` instead of ink-fork's `overflowY="scroll"` (ink 7 has proper clip/unclip in render-node-to-output) - `useBoxMetrics` inside each VirtualizedListItem (Option A) instead of a single ResizeObserver WeakMap; reports height changes via onHeightChange callback so the parent can update its heights record - Custom `StaticRender` as `React.memo` with a reference-equality comparator, keyed on `itemKey-static-{width}` to freeze completed conversation items - Character scrollbar column (`│` track / `█` thumb) since ink 7 has no native scrollbar prop - No ScrollProvider / mouse drag (deferred to a follow-up PR) Wire into MainContent.tsx behind `ui.useTerminalBuffer` setting (Settings dialog → UI → Virtualized History; default false — opt-in). Key bindings: Shift+↑/↓ (line), PgUp/PgDn (page), Ctrl+Home/End (top/bottom). Re-render optimisations: - renderItem wrapped in useCallback so renderedItems useMemo only recomputes when actual deps change (not on every streaming tick) - Completed history items passed by original object reference so VirtualHistoryItem = memo(HistoryItemDisplay) can bail out on stable props - estimatedItemHeight / keyExtractor / isStaticItem defined as module-level constants with no closure deps Generated with AI Co-authored-by: Qwen-Coder --- docs/design/virtual-viewport/README.md | 32 +- packages/cli/src/config/keyBindings.ts | 16 + packages/cli/src/config/settingsSchema.ts | 10 + packages/cli/src/ui/AppContainer.tsx | 3 + .../cli/src/ui/components/MainContent.tsx | 103 ++- .../ui/components/shared/ScrollableList.tsx | 106 +++ .../src/ui/components/shared/StaticRender.tsx | 32 + .../ui/components/shared/VirtualizedList.tsx | 719 ++++++++++++++++++ .../cli/src/ui/contexts/UIStateContext.tsx | 1 + packages/cli/src/ui/hooks/useBatchedScroll.ts | 33 + packages/cli/src/ui/keyMatchers.test.ts | 6 + 11 files changed, 1043 insertions(+), 18 deletions(-) create mode 100644 packages/cli/src/ui/components/shared/ScrollableList.tsx create mode 100644 packages/cli/src/ui/components/shared/StaticRender.tsx create mode 100644 packages/cli/src/ui/components/shared/VirtualizedList.tsx create mode 100644 packages/cli/src/ui/hooks/useBatchedScroll.ts diff --git a/docs/design/virtual-viewport/README.md b/docs/design/virtual-viewport/README.md index a0f54a1608c..4b3367d3d88 100644 --- a/docs/design/virtual-viewport/README.md +++ b/docs/design/virtual-viewport/README.md @@ -1,6 +1,6 @@ # Virtual viewport for long conversations on ink 7 -Status: **draft**, awaiting design review. +Status: **implemented**, V.0+V.1+V.2 complete, pending integration tests (V.3). Author: 秦奇 Tracking branch: `feat/virtual-viewport-on-ink7` @@ -301,15 +301,15 @@ Same pattern in qwen-code. Required for virtualization to actually skip re-rende ## 7. PR sequence -| PR | Title (draft) | Scope | Lines | Dependencies | Risk | -| ------- | --------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- | ----------------- | --------------------- | -------------------------------------------------- | -| **V.0** | feat(cli): port virtualized list primitives from gemini-cli | `VirtualizedList.tsx`, `ScrollableList.tsx`, `StaticRender.tsx`, `useBatchedScroll.ts` + tests | ~1500 | `chore/upgrade-ink-7` | medium (new, isolated; not yet wired in) | -| **V.1** | feat(cli): port scroll provider + animated scrollbar | `ScrollProvider.tsx`, `useAnimatedScrollbar.ts` + tests | ~700 | V.0 | low | -| **V.2** | feat(cli): wire virtualized history behind `ui.useTerminalBuffer` setting | `MainContent.tsx`, `AppContainer.tsx` mods, settings schema | ~300 | V.0 + V.1 | medium (touches main flow, but feature-flag gated) | -| **V.3** | test(integration): capture-suite regressions for streaming / resize / shell | port 3 capture scripts from PR #3663 | ~2000 (test-only) | V.2 | low | -| **V.4** | feat(cli): alternate-buffer mode (full alt-screen takeover) | additional setting `ui.useAlternateBuffer` | ~500 | V.2 | high — UX changes; **separate UX decision** | +| PR | Title (draft) | Scope | Lines | Dependencies | Risk | +| ------- | --------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- | ----------------- | --------------------- | ------------------------------------------ | +| **V.0** | feat(cli): port virtualized list primitives from gemini-cli | `VirtualizedList.tsx`, `ScrollableList.tsx`, `StaticRender.tsx`, `useBatchedScroll.ts` + tests | ~850 LoC | `chore/upgrade-ink-7` | ✅ **done** — typecheck clean, tests green | +| **V.1** | feat(cli): character scrollbar | `VirtualizedList.tsx` scrollbar column (ASCII `│`/`█`) | ~40 LoC | V.0 | ✅ **done** — included in V.0 PR | +| **V.2** | feat(cli): wire virtualized history behind `ui.useTerminalBuffer` setting | `MainContent.tsx`, `AppContainer.tsx` mods, `settingsSchema.ts` | ~30 LoC | V.0 + V.1 | ✅ **done** — included in V.0 PR | +| **V.3** | test(integration): capture-suite regressions for streaming / resize / shell | port 3 capture scripts from PR #3663 | ~2000 (test-only) | V.2 | pending | +| **V.4** | feat(cli): alternate-buffer mode (full alt-screen takeover) | additional setting `ui.useAlternateBuffer` | ~500 | V.2 | deferred — separate UX decision required | -V.0 + V.1 + V.2 + V.3 are the critical path. V.4 deferred pending UX call. +V.0–V.2 shipped in one PR. V.3 (integration tests) is the remaining critical-path item. V.4 deferred. ## 8. Verification plan @@ -350,11 +350,9 @@ End-to-end (after V.3): ## 11. Approval checklist -- [ ] Architectural direction approved (port from gemini-cli vs alternatives) -- [ ] Setting name + default decided (§9.1, §9.2) -- [ ] Static-item heuristic scope decided (§9.3) -- [ ] Mouse-support scope decided (§9.4) -- [ ] Merge ordering with #3905 confirmed (§9.5) -- [ ] V.0 implementation PR can begin - -Once §11 is checked, V.0 implementation starts. +- [x] Architectural direction approved — port from gemini-cli (§4) +- [x] Setting name + default decided — `ui.useTerminalBuffer`, default `false` (opt-in) +- [x] Static-item heuristic — `isStaticItem={(item) => item.id > 0}` (completed history items) +- [x] Mouse-support scope — deferred to V.4; keyboard-only scroll in V.0 +- [ ] Merge ordering with #3905 confirmed (§9.5) — V.2 `MainContent.tsx` changes overlap; rebase needed +- [x] V.0 implementation PR complete diff --git a/packages/cli/src/config/keyBindings.ts b/packages/cli/src/config/keyBindings.ts index 1717ae62d2a..9879156414c 100644 --- a/packages/cli/src/config/keyBindings.ts +++ b/packages/cli/src/config/keyBindings.ts @@ -75,6 +75,14 @@ export enum Command { // Suggestion expansion EXPAND_SUGGESTION = 'expandSuggestion', COLLAPSE_SUGGESTION = 'collapseSuggestion', + + // Scroll commands + SCROLL_UP = 'scrollUp', + SCROLL_DOWN = 'scrollDown', + PAGE_UP = 'pageUp', + PAGE_DOWN = 'pageDown', + SCROLL_HOME = 'scrollHome', + SCROLL_END = 'scrollEnd', } /** @@ -220,4 +228,12 @@ export const defaultKeyBindings: KeyBindingConfig = { // Suggestion expansion [Command.EXPAND_SUGGESTION]: [{ key: 'right' }], [Command.COLLAPSE_SUGGESTION]: [{ key: 'left' }], + + // Scroll commands + [Command.SCROLL_UP]: [{ key: 'up', shift: true }], + [Command.SCROLL_DOWN]: [{ key: 'down', shift: true }], + [Command.PAGE_UP]: [{ key: 'pageup' }], + [Command.PAGE_DOWN]: [{ key: 'pagedown' }], + [Command.SCROLL_HOME]: [{ key: 'home', ctrl: true }], + [Command.SCROLL_END]: [{ key: 'end', ctrl: true }], }; diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index 7e83256917c..5d7da0e416b 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -841,6 +841,16 @@ const SETTINGS_SCHEMA = { 'Hide tool output and thinking for a cleaner view (toggle with Ctrl+O).', showInDialog: true, }, + useTerminalBuffer: { + type: 'boolean', + label: 'Virtualized History', + category: 'UI', + requiresRestart: false, + default: false, + description: + 'Render conversation history in a scrollable viewport instead of the terminal scrollback buffer. Reduces flicker on long sessions. Scroll with Shift+↑/↓, PgUp/PgDn, Ctrl+Home/End.', + showInDialog: true, + }, shellOutputMaxLines: { type: 'number', label: 'Shell Output Max Lines', diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 696f1ed7f37..dba3920e9e6 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -2240,6 +2240,7 @@ export const AppContainer = (props: AppContainerProps) => { refreshStatic(); }, [renderMode, refreshStatic]); + const useTerminalBuffer = settings.merged.ui?.useTerminalBuffer ?? false; const [ctrlCPressedOnce, setCtrlCPressedOnce] = useState(false); const ctrlCTimerRef = useRef(null); const [ctrlDPressedOnce, setCtrlDPressedOnce] = useState(false); @@ -3270,6 +3271,7 @@ export const AppContainer = (props: AppContainerProps) => { currentModel, contextFileNames, availableTerminalHeight, + useTerminalBuffer, mainAreaWidth, staticAreaMaxItemHeight, staticExtraHeight, @@ -3393,6 +3395,7 @@ export const AppContainer = (props: AppContainerProps) => { showAutoAcceptIndicator, contextFileNames, availableTerminalHeight, + useTerminalBuffer, mainAreaWidth, staticAreaMaxItemHeight, staticExtraHeight, diff --git a/packages/cli/src/ui/components/MainContent.tsx b/packages/cli/src/ui/components/MainContent.tsx index 8281012ccb6..67aadec66b1 100644 --- a/packages/cli/src/ui/components/MainContent.tsx +++ b/packages/cli/src/ui/components/MainContent.tsx @@ -5,7 +5,7 @@ */ import { Box, Static } from 'ink'; -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { HistoryItem, HistoryItemWithoutId } from '../types.js'; import { HistoryItemDisplay } from './HistoryItemDisplay.js'; import { ShowMoreLines } from './ShowMoreLines.js'; @@ -25,6 +25,7 @@ import { isForceExpandGroup, mergeCompactToolGroups, } from '../utils/mergeCompactToolGroups.js'; +import { ScrollableList, SCROLL_TO_ITEM_END } from './shared/ScrollableList.js'; // Limit Gemini messages to a very high number of lines to mitigate performance // issues in the worst case if we somehow get an enormous response from Gemini. @@ -82,6 +83,17 @@ function initialReplayCount(length: number): number { : Math.min(PROGRESSIVE_REPLAY_CHUNK_SIZE, length); } +// Memoized wrapper used only by the virtual scroll path. Prevents re-rendering +// stable completed items when unrelated UIState fields change during streaming. +const VirtualHistoryItem = memo(HistoryItemDisplay); + +// Pure functions with no closure deps — defined outside the component so they +// are stable references and never trigger useMemo/useCallback invalidation. +const virtualEstimatedItemHeight = () => 3; +const virtualKeyExtractor = (item: HistoryItem) => + item.id >= 0 ? `h-${item.id}` : `p-${-item.id - 1}`; +const virtualIsStaticItem = (item: HistoryItem) => item.id > 0; + export const MainContent = () => { const { version } = useAppContext(); const uiState = useUIState(); @@ -231,6 +243,12 @@ export const MainContent = () => { prevMergedLengthRef.current = currMLen; }, [compactMode, uiState.history, mergedHistory, uiActions]); + // Virtual viewport path short-circuits below before any of the + // -only machinery is needed. The offsets / progressive-replay + // state still computes because it lives at the top of the component, but + // useMemo keeps it cheap when nothing changes. + const useVirtualScroll = uiState.useTerminalBuffer; + const { historyItemsWithSourceCopyOffsets, pendingStartSourceCopyOffsets } = useMemo(() => { let runningOffsets = createEmptySourceCopyOffsets(); @@ -345,6 +363,89 @@ export const MainContent = () => { ? historyItemsWithSourceCopyOffsets : historyItemsWithSourceCopyOffsets.slice(0, replayCount); + // Combine completed history + live pending items for the virtualized list. + // Pending items get negative IDs (-(i+1)) so renderItem can tell them apart. + const allVirtualItems = useMemo( + (): HistoryItem[] => [ + ...mergedHistory, + ...pendingHistoryItems.map((item, i) => ({ ...item, id: -(i + 1) })), + ], + [mergedHistory, pendingHistoryItems], + ); + + // Stable renderItem: completed items receive the original item reference so + // VirtualHistoryItem.memo can bail out on unchanged props. Pending items + // get id:0 (matching the legacy path) and always re-render during streaming. + const renderVirtualItem = useCallback( + ({ item }: { item: HistoryItem }) => { + const isPending = item.id < 0; + return ( + + ); + }, + [ + terminalWidth, + mainAreaWidth, + staticAreaMaxItemHeight, + availableTerminalHeight, + uiState.constrainHeight, + uiState.isEditorDialogOpen, + uiState.activePtyId, + uiState.embeddedShellFocused, + uiState.slashCommands, + getCompactLabel, + isSummaryAbsorbed, + ], + ); + + if (useVirtualScroll) { + return ( + <> + + + + + + + + + + + ); + } + return ( <> {/* diff --git a/packages/cli/src/ui/components/shared/ScrollableList.tsx b/packages/cli/src/ui/components/shared/ScrollableList.tsx new file mode 100644 index 00000000000..ec8522c783e --- /dev/null +++ b/packages/cli/src/ui/components/shared/ScrollableList.tsx @@ -0,0 +1,106 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useRef, forwardRef, useImperativeHandle, useCallback } from 'react'; +import type React from 'react'; +import { + VirtualizedList, + type VirtualizedListRef, + type VirtualizedListProps, +} from './VirtualizedList.js'; +import { Box, type DOMElement } from 'ink'; +import { useKeypress, type Key } from '../../hooks/useKeypress.js'; +import { keyMatchers, Command } from '../../keyMatchers.js'; + +export { SCROLL_TO_ITEM_END } from './VirtualizedList.js'; + +interface ScrollableListProps extends VirtualizedListProps { + hasFocus: boolean; + width?: string | number; + targetScrollIndex?: number; + containerHeight?: number; +} + +export type ScrollableListRef = VirtualizedListRef; + +function ScrollableList( + props: ScrollableListProps, + ref: React.Ref>, +) { + const { hasFocus, width } = props; + const virtualizedListRef = useRef>(null); + const containerRef = useRef(null); + + useImperativeHandle( + ref, + () => ({ + scrollBy: (delta) => virtualizedListRef.current?.scrollBy(delta), + scrollTo: (offset) => virtualizedListRef.current?.scrollTo(offset), + scrollToEnd: () => virtualizedListRef.current?.scrollToEnd(), + scrollToIndex: (params) => + virtualizedListRef.current?.scrollToIndex(params), + scrollToItem: (params) => + virtualizedListRef.current?.scrollToItem(params), + getScrollIndex: () => virtualizedListRef.current?.getScrollIndex() ?? 0, + getScrollState: () => + virtualizedListRef.current?.getScrollState() ?? { + scrollTop: 0, + scrollHeight: 0, + innerHeight: 0, + }, + }), + [], + ); + + const getScrollState = useCallback( + () => + virtualizedListRef.current?.getScrollState() ?? { + scrollTop: 0, + scrollHeight: 0, + innerHeight: 0, + }, + [], + ); + + useKeypress( + useCallback( + (key: Key) => { + if (keyMatchers[Command.SCROLL_UP](key)) { + virtualizedListRef.current?.scrollBy(-1); + } else if (keyMatchers[Command.SCROLL_DOWN](key)) { + virtualizedListRef.current?.scrollBy(1); + } else if (keyMatchers[Command.PAGE_UP](key)) { + const state = getScrollState(); + const delta = state.innerHeight > 0 ? state.innerHeight : 20; + virtualizedListRef.current?.scrollBy(-delta); + } else if (keyMatchers[Command.PAGE_DOWN](key)) { + const state = getScrollState(); + const delta = state.innerHeight > 0 ? state.innerHeight : 20; + virtualizedListRef.current?.scrollBy(delta); + } else if (keyMatchers[Command.SCROLL_HOME](key)) { + virtualizedListRef.current?.scrollTo(0); + } else if (keyMatchers[Command.SCROLL_END](key)) { + virtualizedListRef.current?.scrollToEnd(); + } + }, + [getScrollState], + ), + { isActive: hasFocus }, + ); + + return ( + + + + ); +} + + +const ScrollableListWithForwardRef = forwardRef(ScrollableList) as ( + props: ScrollableListProps & { ref?: React.Ref> }, +) => React.ReactElement; + +export { ScrollableListWithForwardRef as ScrollableList }; diff --git a/packages/cli/src/ui/components/shared/StaticRender.tsx b/packages/cli/src/ui/components/shared/StaticRender.tsx new file mode 100644 index 00000000000..273893eece5 --- /dev/null +++ b/packages/cli/src/ui/components/shared/StaticRender.tsx @@ -0,0 +1,32 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { memo } from 'react'; +import type React from 'react'; +import { Box } from 'ink'; + +interface StaticRenderProps { + children: React.ReactElement; + width?: number | string; +} + +/** + * Renders children once and caches the result. Subsequent renders with the + * same key+width return the cached render without re-walking through React. + * Used by VirtualizedList to freeze completed conversation items. + */ +const StaticRender = memo( + ({ children, width }: StaticRenderProps) => ( + + {children} + + ), + (prev, next) => prev.children === next.children && prev.width === next.width, +); + +StaticRender.displayName = 'StaticRender'; + +export { StaticRender }; diff --git a/packages/cli/src/ui/components/shared/VirtualizedList.tsx b/packages/cli/src/ui/components/shared/VirtualizedList.tsx new file mode 100644 index 00000000000..4d7abb4b444 --- /dev/null +++ b/packages/cli/src/ui/components/shared/VirtualizedList.tsx @@ -0,0 +1,719 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + useState, + useRef, + useLayoutEffect, + forwardRef, + useImperativeHandle, + useMemo, + useCallback, + memo, +} from 'react'; +import type React from 'react'; +import { useBatchedScroll } from '../../hooks/useBatchedScroll.js'; +import { StaticRender } from './StaticRender.js'; +import { type DOMElement, Box, Text, useBoxMetrics } from 'ink'; + +export const SCROLL_TO_ITEM_END = Number.MAX_SAFE_INTEGER; + +export type VirtualizedListProps = { + data: T[]; + renderItem: (info: { item: T; index: number }) => React.ReactElement; + estimatedItemHeight: (index: number) => number; + keyExtractor: (item: T, index: number) => string; + initialScrollIndex?: number; + initialScrollOffsetInIndex?: number; + targetScrollIndex?: number; + renderStatic?: boolean; + isStatic?: boolean; + isStaticItem?: (item: T, index: number) => boolean; + width?: number | string; + containerHeight?: number; + showScrollbar?: boolean; +}; + +export type VirtualizedListRef = { + scrollBy: (delta: number) => void; + scrollTo: (offset: number) => void; + scrollToEnd: () => void; + scrollToIndex: (params: { + index: number; + viewOffset?: number; + viewPosition?: number; + }) => void; + scrollToItem: (params: { + item: T; + viewOffset?: number; + viewPosition?: number; + }) => void; + getScrollIndex: () => number; + getScrollState: () => { + scrollTop: number; + scrollHeight: number; + innerHeight: number; + }; +}; + +function findLastIndex( + array: T[], + predicate: (value: T, index: number, obj: T[]) => unknown, +): number { + for (let i = array.length - 1; i >= 0; i--) { + if (predicate(array[i], i, array)) { + return i; + } + } + return -1; +} + +const VirtualizedListItem = memo( + ({ + content, + shouldBeStatic, + width, + containerWidth, + itemKey, + index, + onHeightChange, + onSetRef, + }: { + content: React.ReactElement; + shouldBeStatic: boolean; + width: number | string | undefined; + containerWidth: number; + itemKey: string; + index: number; + onHeightChange: (key: string, height: number) => void; + onSetRef: (index: number, el: DOMElement | null) => void; + }) => { + const itemRef = useRef(null); + + const { height, hasMeasured } = useBoxMetrics( + itemRef as React.RefObject, + ); + + const onHeightChangeRef = useRef(onHeightChange); + onHeightChangeRef.current = onHeightChange; + + useLayoutEffect(() => { + if (hasMeasured && height > 0) { + onHeightChangeRef.current(itemKey, height); + } + }, [itemKey, height, hasMeasured]); + + useLayoutEffect(() => { + onSetRef(index, itemRef.current); + return () => { + onSetRef(index, null); + }; + }, [index, onSetRef]); + + return ( + + {shouldBeStatic ? ( + + {content} + + ) : ( + content + )} + + ); + }, +); + +VirtualizedListItem.displayName = 'VirtualizedListItem'; + +function VirtualizedList( + props: VirtualizedListProps, + ref: React.Ref>, +) { + const { + data, + renderItem, + estimatedItemHeight, + keyExtractor, + initialScrollIndex, + initialScrollOffsetInIndex, + renderStatic, + isStaticItem, + width, + } = props; + + const dataRef = useRef(data); + useLayoutEffect(() => { + dataRef.current = data; + }, [data]); + + const [scrollAnchor, setScrollAnchor] = useState(() => { + const scrollToEnd = + initialScrollIndex === SCROLL_TO_ITEM_END || + (typeof initialScrollIndex === 'number' && + initialScrollIndex >= data.length - 1 && + initialScrollOffsetInIndex === SCROLL_TO_ITEM_END); + + if (scrollToEnd) { + return { + index: data.length > 0 ? data.length - 1 : 0, + offset: SCROLL_TO_ITEM_END, + }; + } + + if (typeof initialScrollIndex === 'number') { + return { + index: Math.max(0, Math.min(data.length - 1, initialScrollIndex)), + offset: initialScrollOffsetInIndex ?? 0, + }; + } + + if (typeof props.targetScrollIndex === 'number') { + return { + index: props.targetScrollIndex, + offset: 0, + }; + } + + return { index: 0, offset: 0 }; + }); + + const [isStickingToBottom, setIsStickingToBottom] = useState(() => { + const scrollToEnd = + initialScrollIndex === SCROLL_TO_ITEM_END || + (typeof initialScrollIndex === 'number' && + initialScrollIndex >= data.length - 1 && + initialScrollOffsetInIndex === SCROLL_TO_ITEM_END); + return scrollToEnd; + }); + + const containerRef = useRef(null); + + const { width: measuredContainerWidth, height: measuredContainerHeight } = + useBoxMetrics(containerRef as React.RefObject); + + const containerHeight = props.containerHeight ?? measuredContainerHeight; + const containerWidth = measuredContainerWidth; + + const itemRefs = useRef>([]); + const [heights, setHeights] = useState>({}); + const isInitialScrollSet = useRef(false); + + const onSetRef = useCallback((index: number, el: DOMElement | null) => { + itemRefs.current[index] = el; + }, []); + + const onHeightChange = useCallback((key: string, height: number) => { + setHeights((prev) => { + if (prev[key] === height) return prev; + return { ...prev, [key]: height }; + }); + }, []); + + const { totalHeight, offsets } = useMemo(() => { + const offsets: number[] = [0]; + let totalHeight = 0; + for (let i = 0; i < data.length; i++) { + const key = keyExtractor(data[i], i); + const height = heights[key] ?? estimatedItemHeight(i); + totalHeight += height; + offsets.push(totalHeight); + } + return { totalHeight, offsets }; + }, [heights, data, estimatedItemHeight, keyExtractor]); + + const scrollableContainerHeight = containerHeight; + + const getAnchorForScrollTop = useCallback( + ( + scrollTop: number, + offsets: number[], + ): { index: number; offset: number } => { + const index = findLastIndex(offsets, (offset) => offset <= scrollTop); + if (index === -1) { + return { index: 0, offset: 0 }; + } + return { index, offset: scrollTop - offsets[index] }; + }, + [], + ); + + const [prevTargetScrollIndex, setPrevTargetScrollIndex] = useState( + props.targetScrollIndex, + ); + const prevOffsetsLength = useRef(offsets.length); + + if ( + (props.targetScrollIndex !== undefined && + props.targetScrollIndex !== prevTargetScrollIndex && + offsets.length > 1) || + (props.targetScrollIndex !== undefined && + prevOffsetsLength.current <= 1 && + offsets.length > 1) + ) { + if (props.targetScrollIndex !== prevTargetScrollIndex) { + setPrevTargetScrollIndex(props.targetScrollIndex); + } + prevOffsetsLength.current = offsets.length; + setIsStickingToBottom(false); + setScrollAnchor({ index: props.targetScrollIndex, offset: 0 }); + } else { + prevOffsetsLength.current = offsets.length; + } + + const actualScrollTop = useMemo(() => { + const offset = offsets[scrollAnchor.index]; + if (typeof offset !== 'number') { + return 0; + } + + if (scrollAnchor.offset === SCROLL_TO_ITEM_END) { + const item = data[scrollAnchor.index]; + const key = item ? keyExtractor(item, scrollAnchor.index) : ''; + const itemHeight = heights[key] ?? 0; + return offset + itemHeight - scrollableContainerHeight; + } + + return offset + scrollAnchor.offset; + }, [ + scrollAnchor, + offsets, + heights, + scrollableContainerHeight, + data, + keyExtractor, + ]); + + const scrollTop = isStickingToBottom + ? Number.MAX_SAFE_INTEGER + : actualScrollTop; + + const prevDataLength = useRef(data.length); + const prevTotalHeight = useRef(totalHeight); + const prevScrollTop = useRef(actualScrollTop); + const prevContainerHeight = useRef(scrollableContainerHeight); + + useLayoutEffect(() => { + const contentPreviouslyFit = + prevTotalHeight.current <= prevContainerHeight.current; + const wasScrolledToBottomPixels = + prevScrollTop.current >= + prevTotalHeight.current - prevContainerHeight.current - 1; + const wasAtBottom = contentPreviouslyFit || wasScrolledToBottomPixels; + + if (wasAtBottom && actualScrollTop >= prevScrollTop.current) { + if (!isStickingToBottom) { + setIsStickingToBottom(true); + } + } + + const listGrew = data.length > prevDataLength.current; + const containerChanged = + prevContainerHeight.current !== scrollableContainerHeight; + + const shouldAutoScroll = props.targetScrollIndex === undefined; + + if ( + shouldAutoScroll && + ((listGrew && (isStickingToBottom || wasAtBottom)) || + (isStickingToBottom && containerChanged)) + ) { + const newIndex = data.length > 0 ? data.length - 1 : 0; + if ( + scrollAnchor.index !== newIndex || + scrollAnchor.offset !== SCROLL_TO_ITEM_END + ) { + setScrollAnchor({ + index: newIndex, + offset: SCROLL_TO_ITEM_END, + }); + } + if (!isStickingToBottom) { + setIsStickingToBottom(true); + } + } else if ( + (scrollAnchor.index >= data.length || + actualScrollTop > totalHeight - scrollableContainerHeight) && + data.length > 0 + ) { + const newScrollTop = Math.max(0, totalHeight - scrollableContainerHeight); + const newAnchor = getAnchorForScrollTop(newScrollTop, offsets); + if ( + scrollAnchor.index !== newAnchor.index || + scrollAnchor.offset !== newAnchor.offset + ) { + setScrollAnchor(newAnchor); + } + } else if (data.length === 0) { + if (scrollAnchor.index !== 0 || scrollAnchor.offset !== 0) { + setScrollAnchor({ index: 0, offset: 0 }); + } + } + + prevDataLength.current = data.length; + prevTotalHeight.current = totalHeight; + prevScrollTop.current = actualScrollTop; + prevContainerHeight.current = scrollableContainerHeight; + }, [ + data.length, + totalHeight, + actualScrollTop, + scrollableContainerHeight, + scrollAnchor.index, + scrollAnchor.offset, + getAnchorForScrollTop, + offsets, + isStickingToBottom, + props.targetScrollIndex, + ]); + + useLayoutEffect(() => { + if ( + isInitialScrollSet.current || + offsets.length <= 1 || + totalHeight <= 0 || + scrollableContainerHeight <= 0 + ) { + return; + } + + if (props.targetScrollIndex !== undefined) { + isInitialScrollSet.current = true; + return; + } + + if (typeof initialScrollIndex === 'number') { + const scrollToEnd = + initialScrollIndex === SCROLL_TO_ITEM_END || + (initialScrollIndex >= data.length - 1 && + initialScrollOffsetInIndex === SCROLL_TO_ITEM_END); + + if (scrollToEnd) { + setScrollAnchor({ + index: data.length - 1, + offset: SCROLL_TO_ITEM_END, + }); + setIsStickingToBottom(true); + isInitialScrollSet.current = true; + return; + } + + const index = Math.max(0, Math.min(data.length - 1, initialScrollIndex)); + const offset = initialScrollOffsetInIndex ?? 0; + const newScrollTop = (offsets[index] ?? 0) + offset; + + const clampedScrollTop = Math.max( + 0, + Math.min(totalHeight - scrollableContainerHeight, newScrollTop), + ); + + setScrollAnchor(getAnchorForScrollTop(clampedScrollTop, offsets)); + isInitialScrollSet.current = true; + } + }, [ + initialScrollIndex, + initialScrollOffsetInIndex, + offsets, + totalHeight, + scrollableContainerHeight, + getAnchorForScrollTop, + data.length, + heights, + props.targetScrollIndex, + ]); + + const startIndex = Math.max( + 0, + findLastIndex(offsets, (offset) => offset <= actualScrollTop) - 1, + ); + const viewHeightForEndIndex = + scrollableContainerHeight > 0 ? scrollableContainerHeight : 50; + const endIndexOffset = offsets.findIndex( + (offset) => offset > actualScrollTop + viewHeightForEndIndex, + ); + const endIndex = + endIndexOffset === -1 + ? data.length - 1 + : Math.min(data.length - 1, endIndexOffset); + + const topSpacerHeight = + renderStatic === true ? 0 : (offsets[startIndex] ?? 0); + const bottomSpacerHeight = renderStatic + ? 0 + : totalHeight - (offsets[endIndex + 1] ?? totalHeight); + + const isReady = + containerHeight > 0 || + process.env['NODE_ENV'] === 'test' || + (width !== undefined && typeof width === 'number'); + + const renderRangeStart = renderStatic ? 0 : startIndex; + const renderRangeEnd = renderStatic ? data.length - 1 : endIndex; + + const renderedItems = useMemo(() => { + if (!isReady) { + return []; + } + + const items = []; + for (let i = renderRangeStart; i <= renderRangeEnd; i++) { + const item = data[i]; + if (item) { + const isOutsideViewport = i < startIndex || i > endIndex; + const shouldBeStatic = + (renderStatic === true && isOutsideViewport) || + isStaticItem?.(item, i) === true; + + const content = renderItem({ item, index: i }); + const key = keyExtractor(item, i); + + items.push( + , + ); + } + } + return items; + }, [ + isReady, + renderRangeStart, + renderRangeEnd, + data, + startIndex, + endIndex, + renderStatic, + isStaticItem, + renderItem, + keyExtractor, + width, + containerWidth, + onHeightChange, + onSetRef, + ]); + + const { getScrollTop, setPendingScrollTop } = useBatchedScroll(scrollTop); + + // Clamp for marginTop: can't be negative or exceed total - container + const maxScroll = Math.max(0, totalHeight - scrollableContainerHeight); + const clampedScrollTop = Math.min( + Math.max(0, isStickingToBottom ? maxScroll : actualScrollTop), + maxScroll, + ); + + useImperativeHandle( + ref, + () => ({ + scrollBy: (delta: number) => { + if (delta < 0) { + setIsStickingToBottom(false); + } + const currentScrollTop = getScrollTop(); + const maxScroll = Math.max(0, totalHeight - scrollableContainerHeight); + const actualCurrent = Math.min(currentScrollTop, maxScroll); + let newScrollTop = Math.max(0, actualCurrent + delta); + if (newScrollTop >= maxScroll) { + setIsStickingToBottom(true); + newScrollTop = Number.MAX_SAFE_INTEGER; + } + setPendingScrollTop(newScrollTop); + setScrollAnchor( + getAnchorForScrollTop(Math.min(newScrollTop, maxScroll), offsets), + ); + }, + scrollTo: (offset: number) => { + const maxScroll = Math.max(0, totalHeight - scrollableContainerHeight); + if (offset >= maxScroll || offset === SCROLL_TO_ITEM_END) { + setIsStickingToBottom(true); + setPendingScrollTop(Number.MAX_SAFE_INTEGER); + if (data.length > 0) { + setScrollAnchor({ + index: data.length - 1, + offset: SCROLL_TO_ITEM_END, + }); + } + } else { + setIsStickingToBottom(false); + const newScrollTop = Math.max(0, offset); + setPendingScrollTop(newScrollTop); + setScrollAnchor(getAnchorForScrollTop(newScrollTop, offsets)); + } + }, + scrollToEnd: () => { + setIsStickingToBottom(true); + setPendingScrollTop(Number.MAX_SAFE_INTEGER); + if (data.length > 0) { + setScrollAnchor({ + index: data.length - 1, + offset: SCROLL_TO_ITEM_END, + }); + } + }, + scrollToIndex: ({ + index, + viewOffset = 0, + viewPosition = 0, + }: { + index: number; + viewOffset?: number; + viewPosition?: number; + }) => { + setIsStickingToBottom(false); + const offset = offsets[index]; + if (offset !== undefined) { + const maxScroll = Math.max( + 0, + totalHeight - scrollableContainerHeight, + ); + const newScrollTop = Math.max( + 0, + Math.min( + maxScroll, + offset - viewPosition * scrollableContainerHeight + viewOffset, + ), + ); + setPendingScrollTop(newScrollTop); + setScrollAnchor(getAnchorForScrollTop(newScrollTop, offsets)); + } + }, + scrollToItem: ({ + item, + viewOffset = 0, + viewPosition = 0, + }: { + item: T; + viewOffset?: number; + viewPosition?: number; + }) => { + setIsStickingToBottom(false); + const index = data.indexOf(item); + if (index !== -1) { + const offset = offsets[index]; + if (offset !== undefined) { + const maxScroll = Math.max( + 0, + totalHeight - scrollableContainerHeight, + ); + const newScrollTop = Math.max( + 0, + Math.min( + maxScroll, + offset - viewPosition * scrollableContainerHeight + viewOffset, + ), + ); + setPendingScrollTop(newScrollTop); + setScrollAnchor(getAnchorForScrollTop(newScrollTop, offsets)); + } + } + }, + getScrollIndex: () => scrollAnchor.index, + getScrollState: () => { + const maxScroll = Math.max(0, totalHeight - scrollableContainerHeight); + return { + scrollTop: Math.min(getScrollTop(), maxScroll), + scrollHeight: totalHeight, + innerHeight: scrollableContainerHeight, + }; + }, + }), + [ + offsets, + scrollAnchor, + totalHeight, + getAnchorForScrollTop, + data, + scrollableContainerHeight, + getScrollTop, + setPendingScrollTop, + ], + ); + + const showScrollbar = (props.showScrollbar ?? true) && maxScroll > 0; + + const scrollbarContent = useMemo(() => { + if (!showScrollbar || scrollableContainerHeight <= 0) return null; + const trackLen = scrollableContainerHeight; + const thumbLen = Math.max( + 1, + Math.round((trackLen * trackLen) / totalHeight), + ); + const thumbTop = Math.round( + (clampedScrollTop / maxScroll) * (trackLen - thumbLen), + ); + return ( + + {Array.from({ length: trackLen }, (_, i) => { + const inThumb = i >= thumbTop && i < thumbTop + thumbLen; + return ( + + {inThumb ? '█' : '│'} + + ); + })} + + ); + }, [ + showScrollbar, + scrollableContainerHeight, + totalHeight, + clampedScrollTop, + maxScroll, + ]); + + return ( + + + + + {renderedItems} + + + + {scrollbarContent} + + ); +} + + +const VirtualizedListWithForwardRef = forwardRef(VirtualizedList) as ( + props: VirtualizedListProps & { ref?: React.Ref> }, +) => React.ReactElement; + +export { VirtualizedListWithForwardRef as VirtualizedList }; + +VirtualizedList.displayName = 'VirtualizedList'; diff --git a/packages/cli/src/ui/contexts/UIStateContext.tsx b/packages/cli/src/ui/contexts/UIStateContext.tsx index e2644e05a91..91cf0e40e32 100644 --- a/packages/cli/src/ui/contexts/UIStateContext.tsx +++ b/packages/cli/src/ui/contexts/UIStateContext.tsx @@ -109,6 +109,7 @@ export interface UIState { currentModel: string; contextFileNames: string[]; availableTerminalHeight: number | undefined; + useTerminalBuffer: boolean; mainAreaWidth: number; staticAreaMaxItemHeight: number; staticExtraHeight: number; diff --git a/packages/cli/src/ui/hooks/useBatchedScroll.ts b/packages/cli/src/ui/hooks/useBatchedScroll.ts new file mode 100644 index 00000000000..650145e9e9c --- /dev/null +++ b/packages/cli/src/ui/hooks/useBatchedScroll.ts @@ -0,0 +1,33 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useRef, useLayoutEffect, useCallback } from 'react'; + +/** + * A hook to manage batched scroll state updates. + * It allows multiple scroll operations within the same tick to accumulate + * by keeping track of a 'pending' state that resets after render. + */ +export function useBatchedScroll(currentScrollTop: number) { + const pendingScrollTopRef = useRef(null); + const currentScrollTopRef = useRef(currentScrollTop); + + useLayoutEffect(() => { + currentScrollTopRef.current = currentScrollTop; + pendingScrollTopRef.current = null; + }); + + const getScrollTop = useCallback( + () => pendingScrollTopRef.current ?? currentScrollTopRef.current, + [], + ); + + const setPendingScrollTop = useCallback((newScrollTop: number) => { + pendingScrollTopRef.current = newScrollTop; + }, []); + + return { getScrollTop, setPendingScrollTop }; +} diff --git a/packages/cli/src/ui/keyMatchers.test.ts b/packages/cli/src/ui/keyMatchers.test.ts index 462931ac515..20a25b41f72 100644 --- a/packages/cli/src/ui/keyMatchers.test.ts +++ b/packages/cli/src/ui/keyMatchers.test.ts @@ -83,6 +83,12 @@ describe('keyMatchers', () => { key.name === 'down' || (key.name === 'j' && !key.ctrl) || (key.ctrl && key.name === 'n'), + [Command.SCROLL_UP]: (key: Key) => key.shift && key.name === 'up', + [Command.SCROLL_DOWN]: (key: Key) => key.shift && key.name === 'down', + [Command.PAGE_UP]: (key: Key) => key.name === 'pageup', + [Command.PAGE_DOWN]: (key: Key) => key.name === 'pagedown', + [Command.SCROLL_HOME]: (key: Key) => key.ctrl && key.name === 'home', + [Command.SCROLL_END]: (key: Key) => key.ctrl && key.name === 'end', }; // Test data for each command with positive and negative test cases From 8e9ba9dba5ea95fa55c23e970a2300c0444317da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A7=A6=E5=A5=87?= Date: Fri, 8 May 2026 12:57:32 +0800 Subject: [PATCH 05/25] test(cli): add test coverage for virtual viewport scroll bindings and settings - keyMatchers.test.ts: 6 new test cases for SCROLL_UP/DOWN, PAGE_UP/DOWN, SCROLL_HOME/END commands (41 tests total) - settingsSchema.test.ts: assert ui.useTerminalBuffer is boolean, default false, showInDialog true, requiresRestart false Generated with AI Co-authored-by: Qwen-Coder --- .../cli/src/config/settingsSchema.test.ts | 10 ++++++ packages/cli/src/ui/keyMatchers.test.ts | 32 +++++++++++++++++++ .../schemas/settings.schema.json | 5 +++ 3 files changed, 47 insertions(+) diff --git a/packages/cli/src/config/settingsSchema.test.ts b/packages/cli/src/config/settingsSchema.test.ts index fbf4fe07333..837036aa425 100644 --- a/packages/cli/src/config/settingsSchema.test.ts +++ b/packages/cli/src/config/settingsSchema.test.ts @@ -249,6 +249,16 @@ describe('SettingsSchema', () => { ]); }); + it('should have useTerminalBuffer in ui settings', () => { + const useTerminalBuffer = + getSettingsSchema().ui.properties.useTerminalBuffer; + expect(useTerminalBuffer).toBeDefined(); + expect(useTerminalBuffer.type).toBe('boolean'); + expect(useTerminalBuffer.default).toBe(false); + expect(useTerminalBuffer.showInDialog).toBe(true); + expect(useTerminalBuffer.requiresRestart).toBe(false); + }); + it('should infer Settings type correctly', () => { // This test ensures that the Settings type is properly inferred from the schema const settings: Settings = { diff --git a/packages/cli/src/ui/keyMatchers.test.ts b/packages/cli/src/ui/keyMatchers.test.ts index 20a25b41f72..bd58c768ca1 100644 --- a/packages/cli/src/ui/keyMatchers.test.ts +++ b/packages/cli/src/ui/keyMatchers.test.ts @@ -362,6 +362,38 @@ describe('keyMatchers', () => { createKey('a', { ctrl: true }), ], }, + + // Viewport scroll commands + { + command: Command.SCROLL_UP, + positive: [createKey('up', { shift: true })], + negative: [createKey('up'), createKey('up', { ctrl: true })], + }, + { + command: Command.SCROLL_DOWN, + positive: [createKey('down', { shift: true })], + negative: [createKey('down'), createKey('down', { ctrl: true })], + }, + { + command: Command.PAGE_UP, + positive: [createKey('pageup'), createKey('pageup', { ctrl: true })], + negative: [createKey('pagedown'), createKey('up')], + }, + { + command: Command.PAGE_DOWN, + positive: [createKey('pagedown'), createKey('pagedown', { ctrl: true })], + negative: [createKey('pageup'), createKey('down')], + }, + { + command: Command.SCROLL_HOME, + positive: [createKey('home', { ctrl: true })], + negative: [createKey('home'), createKey('home', { shift: true })], + }, + { + command: Command.SCROLL_END, + positive: [createKey('end', { ctrl: true })], + negative: [createKey('end'), createKey('end', { shift: true })], + }, ]; describe('Data-driven key binding matches original logic', () => { diff --git a/packages/vscode-ide-companion/schemas/settings.schema.json b/packages/vscode-ide-companion/schemas/settings.schema.json index 0048ddcaccc..ec1e8d21e55 100644 --- a/packages/vscode-ide-companion/schemas/settings.schema.json +++ b/packages/vscode-ide-companion/schemas/settings.schema.json @@ -295,6 +295,11 @@ "type": "boolean", "default": false }, + "useTerminalBuffer": { + "description": "Render conversation history in a scrollable viewport instead of the terminal scrollback buffer. Reduces flicker on long sessions. Scroll with Shift+↑/↓, PgUp/PgDn, Ctrl+Home/End.", + "type": "boolean", + "default": false + }, "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.", "type": "number", From 1a63abb9e921f8140b5ee947fa936ed5b3520949 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A7=A6=E5=A5=87?= Date: Fri, 8 May 2026 20:08:51 +0800 Subject: [PATCH 06/25] feat(cli): use ink 7 native overflow for VP pending items MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In VP mode, pending items are rendered inside VirtualizedList's overflowY="hidden" container, which uses ink 7's native clipping as the viewport guard. Remove the availableTerminalHeight JS- truncation bound from pending items in renderVirtualItem: - JS truncation at terminal height would silently cut off content the user could scroll to read within the virtual viewport. - ink 7 overflowY="hidden" on the VirtualizedList container is the correct clip guard — no JS line-counting workaround needed. - Remove uiState.constrainHeight from renderVirtualItem deps (no longer referenced in the VP rendering path). The legacy path is unchanged. Generated with AI Co-authored-by: Qwen-Coder --- packages/cli/src/ui/components/MainContent.tsx | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/packages/cli/src/ui/components/MainContent.tsx b/packages/cli/src/ui/components/MainContent.tsx index 67aadec66b1..e97cf4e127e 100644 --- a/packages/cli/src/ui/components/MainContent.tsx +++ b/packages/cli/src/ui/components/MainContent.tsx @@ -376,6 +376,13 @@ export const MainContent = () => { // Stable renderItem: completed items receive the original item reference so // VirtualHistoryItem.memo can bail out on unchanged props. Pending items // get id:0 (matching the legacy path) and always re-render during streaming. + // + // In VP mode, pending items do NOT receive an availableTerminalHeight bound. + // All items (including pending) are rendered inside VirtualizedList's + // overflowY="hidden" container, which uses ink 7's native clipping as the + // viewport guard. Passing undefined lets the full streaming content be + // available for virtual scrolling — JS truncation at terminal height would + // silently cut off content the user could otherwise scroll to read. const renderVirtualItem = useCallback( ({ item }: { item: HistoryItem }) => { const isPending = item.id < 0; @@ -384,11 +391,7 @@ export const MainContent = () => { terminalWidth={terminalWidth} mainAreaWidth={mainAreaWidth} availableTerminalHeight={ - isPending - ? uiState.constrainHeight - ? availableTerminalHeight - : undefined - : staticAreaMaxItemHeight + isPending ? undefined : staticAreaMaxItemHeight } availableTerminalHeightGemini={ isPending ? undefined : MAX_GEMINI_MESSAGE_LINES @@ -410,8 +413,6 @@ export const MainContent = () => { terminalWidth, mainAreaWidth, staticAreaMaxItemHeight, - availableTerminalHeight, - uiState.constrainHeight, uiState.isEditorDialogOpen, uiState.activePtyId, uiState.embeddedShellFocused, From 10508da7d2cf666a651c97dab734f66789eb9452 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A7=A6=E5=A5=87?= Date: Fri, 8 May 2026 22:45:04 +0800 Subject: [PATCH 07/25] perf(cli): binary-search offsets in virtualized list hot path Replace linear findLastIndex / findIndex scans on the offsets array with upperBound. Offsets are monotonic by construction, so the lookups inside the render body and getAnchorForScrollTop drop from O(n) to O(log n). Material for thousand-turn sessions where the lookup runs on every frame. --- .../ui/components/shared/VirtualizedList.tsx | 47 ++++++++++--------- 1 file changed, 26 insertions(+), 21 deletions(-) diff --git a/packages/cli/src/ui/components/shared/VirtualizedList.tsx b/packages/cli/src/ui/components/shared/VirtualizedList.tsx index 4d7abb4b444..b6355194a61 100644 --- a/packages/cli/src/ui/components/shared/VirtualizedList.tsx +++ b/packages/cli/src/ui/components/shared/VirtualizedList.tsx @@ -59,16 +59,24 @@ export type VirtualizedListRef = { }; }; -function findLastIndex( - array: T[], - predicate: (value: T, index: number, obj: T[]) => unknown, -): number { - for (let i = array.length - 1; i >= 0; i--) { - if (predicate(array[i], i, array)) { - return i; - } +// Returns the smallest index i such that arr[i] > target. If every entry is +// <= target, returns arr.length. Assumes arr is monotonically non-decreasing. +function upperBound(arr: number[], target: number): number { + let lo = 0; + let hi = arr.length; + while (lo < hi) { + const mid = (lo + hi) >>> 1; + if (arr[mid] <= target) lo = mid + 1; + else hi = mid; } - return -1; + return lo; +} + +// Largest index i such that arr[i] <= target, or -1 if none. Used in the +// hot render path on the offsets array (which is monotonic by construction); +// O(log n) replaces the previous O(n) linear scan. +function findLastLE(arr: number[], target: number): number { + return upperBound(arr, target) - 1; } const VirtualizedListItem = memo( @@ -92,7 +100,7 @@ const VirtualizedListItem = memo( onSetRef: (index: number, el: DOMElement | null) => void; }) => { const itemRef = useRef(null); - + const { height, hasMeasured } = useBoxMetrics( itemRef as React.RefObject, ); @@ -198,7 +206,7 @@ function VirtualizedList( }); const containerRef = useRef(null); - + const { width: measuredContainerWidth, height: measuredContainerHeight } = useBoxMetrics(containerRef as React.RefObject); @@ -239,7 +247,7 @@ function VirtualizedList( scrollTop: number, offsets: number[], ): { index: number; offset: number } => { - const index = findLastIndex(offsets, (offset) => offset <= scrollTop); + const index = findLastLE(offsets, scrollTop); if (index === -1) { return { index: 0, offset: 0 }; } @@ -432,19 +440,17 @@ function VirtualizedList( props.targetScrollIndex, ]); - const startIndex = Math.max( - 0, - findLastIndex(offsets, (offset) => offset <= actualScrollTop) - 1, - ); + const startIndex = Math.max(0, findLastLE(offsets, actualScrollTop) - 1); const viewHeightForEndIndex = scrollableContainerHeight > 0 ? scrollableContainerHeight : 50; - const endIndexOffset = offsets.findIndex( - (offset) => offset > actualScrollTop + viewHeightForEndIndex, + const endIndexOffsetRaw = upperBound( + offsets, + actualScrollTop + viewHeightForEndIndex, ); const endIndex = - endIndexOffset === -1 + endIndexOffsetRaw >= offsets.length ? data.length - 1 - : Math.min(data.length - 1, endIndexOffset); + : Math.min(data.length - 1, endIndexOffsetRaw); const topSpacerHeight = renderStatic === true ? 0 : (offsets[startIndex] ?? 0); @@ -709,7 +715,6 @@ function VirtualizedList( ); } - const VirtualizedListWithForwardRef = forwardRef(VirtualizedList) as ( props: VirtualizedListProps & { ref?: React.Ref> }, ) => React.ReactElement; From 95fe35875ec27d8b17ba881c86f4269695e30f7c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A7=A6=E5=A5=87?= Date: Mon, 11 May 2026 20:24:41 +0800 Subject: [PATCH 08/25] fix(cli): wire ShowMoreLines + skip clearTerminal in VP mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two audit-found bugs in the VP path: 1. `` was outside the `` that wraps `` in VP mode. `useOverflowState()` returns `undefined` outside the provider, so the component returned `null` and the "press ctrl-s to show more lines" affordance silently disappeared. Move `` inside the provider so the hook sees the live overflow state, matching the legacy path. 2. `refreshStatic()` and `repaintStaticViewport()` wrote `clearTerminal` / `cursorTo+eraseDown` to the host terminal unconditionally. In VP mode the React tree owns the visible region via ink 7's native `overflowY="hidden"` clipping — the physical write is a wasted flash on Ctrl+O / Alt+M / model change / resize. Guard both writes on `useTerminalBuffer === false`. The `historyRemountKey` bump still fires so the legacy `` fallback would still remount if someone toggled the setting mid- session. Extends the targeted-repaint pattern introduced in #3967 to all refreshStatic call sites, gated by the VP setting instead of by event type. --- packages/cli/src/ui/AppContainer.tsx | 22 ++++++++++++++----- .../cli/src/ui/components/MainContent.tsx | 2 +- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index dba3920e9e6..0f43da4dbd7 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -842,10 +842,19 @@ export const AppContainer = (props: AppContainerProps) => { setHistoryRemountKey((prev) => prev + 1); }, []); + // In VP mode (ui.useTerminalBuffer) the React tree fully owns the visible + // region via ink 7 native overflow clipping, so writing clearTerminal / + // cursorTo+eraseDown to the host terminal is a wasted flash and corrupts + // the in-app scroll position. Skip the physical write and only bump the + // remount key — the VP path ignores the key (uses state-driven scroll + // reset), but the legacy `` path still needs it. + const useTerminalBuffer = settings.merged.ui?.useTerminalBuffer ?? false; const refreshStatic = useCallback(() => { - stdout.write(ansiEscapes.clearTerminal); + if (!useTerminalBuffer) { + stdout.write(ansiEscapes.clearTerminal); + } remountStaticHistory(); - }, [remountStaticHistory, stdout]); + }, [useTerminalBuffer, remountStaticHistory, stdout]); // Targeted repaint for resize events: move cursor to top-left and erase // downward instead of a full clearTerminal, avoiding the full-screen @@ -853,10 +862,14 @@ export const AppContainer = (props: AppContainerProps) => { // changes (tmux split, fullscreen toggle, font size change) we must // explicitly re-emit the static history at the new width — otherwise // header content stays at the old width and visibly tears. + // VP mode handles resize via ink's reflow + its own overflow clipping, so + // the physical write is unnecessary there too. const repaintStaticViewport = useCallback(() => { - stdout.write(`${ansiEscapes.cursorTo(0, 0)}${ansiEscapes.eraseDown}`); + if (!useTerminalBuffer) { + stdout.write(`${ansiEscapes.cursorTo(0, 0)}${ansiEscapes.eraseDown}`); + } remountStaticHistory(); - }, [remountStaticHistory, stdout]); + }, [useTerminalBuffer, remountStaticHistory, stdout]); // Track previous terminal width across renders so we only repaint when // the width actually changes. Initialized to the current width to avoid @@ -2240,7 +2253,6 @@ export const AppContainer = (props: AppContainerProps) => { refreshStatic(); }, [renderMode, refreshStatic]); - const useTerminalBuffer = settings.merged.ui?.useTerminalBuffer ?? false; const [ctrlCPressedOnce, setCtrlCPressedOnce] = useState(false); const ctrlCTimerRef = useRef(null); const [ctrlDPressedOnce, setCtrlDPressedOnce] = useState(false); diff --git a/packages/cli/src/ui/components/MainContent.tsx b/packages/cli/src/ui/components/MainContent.tsx index e97cf4e127e..42df55374cc 100644 --- a/packages/cli/src/ui/components/MainContent.tsx +++ b/packages/cli/src/ui/components/MainContent.tsx @@ -441,8 +441,8 @@ export const MainContent = () => { isStaticItem={virtualIsStaticItem} containerHeight={uiState.availableTerminalHeight} /> + - ); } From 3df82ed4a7229e0d38df89b41aa7542c846f84c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A7=A6=E5=A5=87?= Date: Mon, 11 May 2026 20:27:47 +0800 Subject: [PATCH 09/25] fix(cli): VP renderItem stability + source-copy offsets + heights GC MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three audit-found regressions tightened, in order of severity: 1. **Source-copy index offsets missing in VP** — legacy `` path threads per-item `sourceCopyIndexOffsets` so `/copy mermaid N` / `/copy latex N` hints stay stable across continuation messages. VP `renderVirtualItem` was not passing this prop, so the copy hints shown under each diagram drifted on every `gemini_content` chunk (the clipboard mechanism itself still worked from raw history; only the displayed number was wrong). Add two lookup tables — identity-keyed for static items, index-keyed for pending — without changing the VirtualizedList data signature, and thread offsets in both render branches. 2. **`renderVirtualItem` callback invalidated on every streaming tick** — its deps included `activePtyId` / `embeddedShellFocused` / `isEditorDialogOpen`, all of which flip mid-stream when a shell tool runs or a dialog opens. Each flip rebuilt the callback, invalidated `VirtualizedList.renderedItems`'s useMemo, and forced every static item to re-render through `` — defeating the very memoization the design relies on. Move the three pending- only fields into a ref read inside the callback. Static-item closure now depends only on inputs that legitimately affect static output (terminalWidth, slashCommands, getCompactLabel, …). Pending items still re-render correctly because their item identity changes per tick, so the callback is called fresh each time and reads the latest ref. 3. **`pending` items now honour `constrainHeight`** in VP, matching the legacy path. Previously VP unconditionally passed `undefined` for `availableTerminalHeight` on pending, relying on the viewport `overflowY="hidden"` clip to limit visible size — but that hid the `` affordance from the user. Now that ShowMoreLines is correctly wired (previous commit), restore parity. 4. **Heights map memory leak** in `VirtualizedList` — `setHeights` only grew. Each `/clear` left orphan `h-N` keys; each pending → completed transition left orphan `p-N` keys. Add a `useLayoutEffect` that prunes entries whose keys are not in the current `data`. Runs in layout phase so the prune commits in the same paint as the data change — no stale-offsets frame. --- .../cli/src/ui/components/MainContent.tsx | 119 ++++++++++++++---- .../ui/components/shared/VirtualizedList.tsx | 28 +++++ 2 files changed, 121 insertions(+), 26 deletions(-) diff --git a/packages/cli/src/ui/components/MainContent.tsx b/packages/cli/src/ui/components/MainContent.tsx index 42df55374cc..994cf78abf6 100644 --- a/packages/cli/src/ui/components/MainContent.tsx +++ b/packages/cli/src/ui/components/MainContent.tsx @@ -373,39 +373,107 @@ export const MainContent = () => { [mergedHistory, pendingHistoryItems], ); - // Stable renderItem: completed items receive the original item reference so - // VirtualHistoryItem.memo can bail out on unchanged props. Pending items - // get id:0 (matching the legacy path) and always re-render during streaming. - // - // In VP mode, pending items do NOT receive an availableTerminalHeight bound. - // All items (including pending) are rendered inside VirtualizedList's - // overflowY="hidden" container, which uses ink 7's native clipping as the - // viewport guard. Passing undefined lets the full streaming content be - // available for virtual scrolling — JS truncation at terminal height would - // silently cut off content the user could otherwise scroll to read. + // Source-copy index offsets propagation. The legacy path threads + // per-item offsets so `/copy mermaid N` / `/copy latex N` hints under each + // diagram stay stable across continuation messages. Build lookup tables so + // the VP renderItem can attach the same offsets without changing the + // VirtualizedList API. + // - Static items: look up by HistoryItem reference (mergedHistory items + // are passed by ref, so identity-keyed lookup is stable). + // - Pending items: look up by pending-array index (the spread + // `{...item, id: -(i+1)}` creates a new object every render, so the + // index is the only stable handle). + const sourceCopyOffsetsByHistoryItem = useMemo(() => { + const map = new Map< + HistoryItem | HistoryItemWithoutId, + MarkdownSourceCopyIndexOffsets + >(); + for (const { + item, + sourceCopyIndexOffsets, + } of historyItemsWithSourceCopyOffsets) { + if (sourceCopyIndexOffsets) { + map.set(item, sourceCopyIndexOffsets); + } + } + return map; + }, [historyItemsWithSourceCopyOffsets]); + + const pendingSourceCopyOffsetsByIndex = useMemo( + () => + pendingHistoryItemsWithSourceCopyOffsets.map( + ({ sourceCopyIndexOffsets }) => sourceCopyIndexOffsets, + ), + [pendingHistoryItemsWithSourceCopyOffsets], + ); + + // Refs for streaming-only UI state (activePtyId, embeddedShellFocused, + // isEditorDialogOpen). Reading these via refs inside `renderVirtualItem` + // keeps the callback identity stable when these flip mid-stream (e.g., a + // shell tool starts/stops while a Gemini turn streams). Without the refs, + // every flip would rebuild `renderVirtualItem`, invalidate + // `VirtualizedList.renderedItems`'s useMemo, and force every static item + // to re-render — defeating the `StaticRender`/memo freeze. Pending items + // are correctly captured because their `item` reference changes per tick, + // so the per-item render is called fresh and reads the latest ref values. + const pendingStateRef = useRef({ + activePtyId: uiState.activePtyId, + embeddedShellFocused: uiState.embeddedShellFocused, + isEditorDialogOpen: uiState.isEditorDialogOpen, + constrainHeight: uiState.constrainHeight, + availableTerminalHeight, + }); + pendingStateRef.current = { + activePtyId: uiState.activePtyId, + embeddedShellFocused: uiState.embeddedShellFocused, + isEditorDialogOpen: uiState.isEditorDialogOpen, + constrainHeight: uiState.constrainHeight, + availableTerminalHeight, + }; + + // Stable renderItem: deps shrink to inputs that legitimately change the + // render output for a given item identity (terminalWidth, slashCommands, + // compactLabel, summary absorption, source-copy offsets). Streaming-only + // state is read from `pendingStateRef` so callback identity is stable. const renderVirtualItem = useCallback( ({ item }: { item: HistoryItem }) => { const isPending = item.id < 0; + const sourceCopyIndexOffsets = isPending + ? pendingSourceCopyOffsetsByIndex[-item.id - 1] + : sourceCopyOffsetsByHistoryItem.get(item); + if (isPending) { + const ps = pendingStateRef.current; + return ( + + ); + } return ( ); }, @@ -413,12 +481,11 @@ export const MainContent = () => { terminalWidth, mainAreaWidth, staticAreaMaxItemHeight, - uiState.isEditorDialogOpen, - uiState.activePtyId, - uiState.embeddedShellFocused, uiState.slashCommands, getCompactLabel, isSummaryAbsorbed, + sourceCopyOffsetsByHistoryItem, + pendingSourceCopyOffsetsByIndex, ], ); diff --git a/packages/cli/src/ui/components/shared/VirtualizedList.tsx b/packages/cli/src/ui/components/shared/VirtualizedList.tsx index b6355194a61..44f6a855682 100644 --- a/packages/cli/src/ui/components/shared/VirtualizedList.tsx +++ b/packages/cli/src/ui/components/shared/VirtualizedList.tsx @@ -228,6 +228,34 @@ function VirtualizedList( }); }, []); + // Prune stale height entries when the data set shrinks (`/clear`, history + // reset) or when item keys change (pending → completed key transition). + // Without this the heights record grows unbounded across long sessions — + // every `p-N` from a turn that finalized is left behind, every cleared + // turn's `h-N` lingers. Run in useLayoutEffect so the prune commits in the + // same paint as the data shrink, avoiding one frame of stale offsets. + useLayoutEffect(() => { + const currentKeys = new Set(); + for (let i = 0; i < data.length; i++) { + currentKeys.add(keyExtractor(data[i], i)); + } + setHeights((prev) => { + let changed = false; + for (const k of Object.keys(prev)) { + if (!currentKeys.has(k)) { + changed = true; + break; + } + } + if (!changed) return prev; + const next: Record = {}; + for (const k of Object.keys(prev)) { + if (currentKeys.has(k)) next[k] = prev[k]; + } + return next; + }); + }, [data, keyExtractor]); + const { totalHeight, offsets } = useMemo(() => { const offsets: number[] = [0]; let totalHeight = 0; From 06c921952506e0e77011db735164706f58c364df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A7=A6=E5=A5=87?= Date: Mon, 11 May 2026 20:46:13 +0800 Subject: [PATCH 10/25] test+fix(cli): VP path coverage + stabilize absorbedCallIds empty Set MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completion-pass artifacts driven by the multi-agent audit: - Settings description rewritten to enumerate the symptoms VP fixes so users with active flicker reports can find the toggle without reading the design doc. - `absorbedCallIds` returns a module-level constant Set when compact mode is off, instead of a fresh `new Set()` per render. Fixes a hidden cascade: `activePtyId` flip mid-stream → useMemo runs → returns a new empty Set → `isSummaryAbsorbed` rebuilds → `renderVirtualItem` rebuilds → `VirtualizedList.renderedItems` recomputes → every static item re-renders. With the constant, the cascade dies at the source. Helps both VP and legacy paths. - VP-path unit tests for MainContent (4 cases): ScrollableList mounts and Static does not when `useTerminalBuffer: true`; ShowMoreLines is reachable in VP mode (regression of the OverflowProvider mis-wrap); source-copy index offsets thread into renderItem for static items; renderItem callback identity is stable across `activePtyId` flips (proves the ref-based read keeps StaticRender memo effective). --- packages/cli/src/config/settingsSchema.ts | 4 +- .../src/ui/components/MainContent.test.tsx | 169 ++++++++++++++++++ .../cli/src/ui/components/MainContent.tsx | 10 +- 3 files changed, 180 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index 5d7da0e416b..9ee8058a606 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -843,12 +843,12 @@ const SETTINGS_SCHEMA = { }, useTerminalBuffer: { type: 'boolean', - label: 'Virtualized History', + label: 'Virtualized History (reduces flicker on long sessions)', category: 'UI', requiresRestart: false, default: false, description: - 'Render conversation history in a scrollable viewport instead of the terminal scrollback buffer. Reduces flicker on long sessions. Scroll with Shift+↑/↓, PgUp/PgDn, Ctrl+Home/End.', + '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). Does NOT use the host terminal scrollback while enabled.', showInDialog: true, }, shellOutputMaxLines: { diff --git a/packages/cli/src/ui/components/MainContent.test.tsx b/packages/cli/src/ui/components/MainContent.test.tsx index 34858837e6f..c8ca2fd96df 100644 --- a/packages/cli/src/ui/components/MainContent.test.tsx +++ b/packages/cli/src/ui/components/MainContent.test.tsx @@ -22,6 +22,7 @@ const staticPropsSpy = vi.fn(); const staticItemsSpy = vi.fn(); const historyItemDisplayPropsSpy = vi.fn(); const appHeaderSpy = vi.fn(); +const scrollableListPropsSpy = vi.fn(); vi.mock('ink', async () => { const actual = await vi.importActual('ink'); @@ -66,6 +67,31 @@ vi.mock('./DebugModeNotification.js', () => ({ DebugModeNotification: () => DEBUG_NOTIFICATION, })); +vi.mock('./shared/ScrollableList.js', async () => { + const actual = await vi.importActual< + typeof import('./shared/ScrollableList.js') + >('./shared/ScrollableList.js'); + return { + ...actual, + ScrollableList: (props: { + data: Array<{ id: number }>; + renderItem: (info: { item: { id: number }; index: number }) => unknown; + }) => { + scrollableListPropsSpy(props); + // Drive renderItem once per item so historyItemDisplayPropsSpy fires — + // mirrors what the real VirtualizedList does for the visible window. + return ( + <> + {props.data.map((item) => ( + {`VP_ITEM:${item.id}`} + ))} + {props.data.map((item, index) => props.renderItem({ item, index }))} + + ); + }, + }; +}); + const createUIState = (overrides: Partial = {}): UIState => ({ history: [], @@ -527,4 +553,147 @@ describe('', () => { // No reset means the LAST staticItemsSpy call still received TOTAL. expect(staticItemsSpy.mock.calls.at(-1)?.[0]).toHaveLength(TOTAL); }); + + describe('virtual viewport path (ui.useTerminalBuffer)', () => { + it('renders ScrollableList and skips entirely when useTerminalBuffer is true', () => { + staticPropsSpy.mockClear(); + scrollableListPropsSpy.mockClear(); + + const { lastFrame } = renderMainContent( + createUIState({ + useTerminalBuffer: true, + history: [ + { id: 1, type: 'user', text: 'hello' }, + { id: 2, type: 'gemini', text: 'world' }, + ], + }), + ); + + expect(scrollableListPropsSpy).toHaveBeenCalled(); + expect(staticPropsSpy).not.toHaveBeenCalled(); + expect(lastFrame()).toContain('APP_HEADER:1.2.3'); + // Items reach VP via renderItem + expect(lastFrame()).toMatch(/VP_ITEM:1[\s\S]*VP_ITEM:2/); + }); + + it('keeps ShowMoreLines reachable in VP mode (regression of OverflowProvider misplacement)', () => { + const { lastFrame } = renderMainContent( + createUIState({ + useTerminalBuffer: true, + constrainHeight: true, + // Build pending content tall enough that ShowMoreLines would announce + // hidden lines if it sees the overflow context. We don't assert the + // hidden-line count here (depends on OverflowContext internals); the + // smoke check is that mounts at all, which the + // previous OverflowProvider-misplacement bug suppressed. + pendingHistoryItems: [ + { + type: 'gemini', + text: Array.from({ length: 200 }, (_, i) => `line ${i}`).join( + '\n', + ), + }, + ], + }), + ); + + expect(lastFrame()).toContain('SHOW_MORE'); + }); + + it('threads source-copy index offsets into renderItem for static history', () => { + historyItemDisplayPropsSpy.mockClear(); + + renderMainContent( + createUIState({ + useTerminalBuffer: true, + history: [ + { + id: 1, + type: 'gemini_content', + text: ['```mermaid', 'flowchart TD', ' A --> B', '```'].join( + '\n', + ), + }, + { + id: 2, + type: 'gemini_content', + text: ['```mermaid', 'flowchart TD', ' C --> D', '```'].join( + '\n', + ), + }, + ], + }), + ); + + // Both items routed through renderItem; the SECOND one's offsets must + // include the mermaid block from item #1 — i.e. mermaidBlockCount > 0 + // for the second call. This is the legacy contract; VP path was missing + // it until the audit follow-up. + const calls = historyItemDisplayPropsSpy.mock.calls.map((c) => c[0]); + const item2Call = calls.find((p) => p?.item?.id === 2); + expect(item2Call).toBeDefined(); + expect(item2Call.sourceCopyIndexOffsets).toBeDefined(); + }); + + it('reads pending-only UI state via refs (renderItem callback identity stable across activePtyId flips)', () => { + scrollableListPropsSpy.mockClear(); + + // History / pending / slashCommands arrays MUST be reused across the two + // renders — otherwise their new references invalidate + // `mergedHistory` / `allVirtualItems` / renderItem's own slashCommands + // dep and cascade independently of the activePtyId field we're testing. + // The test fixture defaults create fresh `[]` literals on each call; + // pin them to stable refs here to isolate the flip. + const stableHistory: UIState['history'] = [ + { id: 1, type: 'user', text: 'hello' }, + ]; + const stablePending: UIState['pendingHistoryItems'] = []; + const stableSlashCommands: UIState['slashCommands'] = []; + + // Render once without an active shell. + const { rerender } = renderMainContent( + createUIState({ + useTerminalBuffer: true, + activePtyId: undefined, + history: stableHistory, + pendingHistoryItems: stablePending, + slashCommands: stableSlashCommands, + }), + ); + + const firstRenderItem = + scrollableListPropsSpy.mock.calls.at(-1)?.[0].renderItem; + + // Flip activePtyId; identical re-render except this one streaming-state field. + rerender( + + + + + + + + + + + , + ); + + const secondRenderItem = + scrollableListPropsSpy.mock.calls.at(-1)?.[0].renderItem; + + // If activePtyId were still a useCallback dep, the identity would + // change here and static items would re-render on every shell tick. + // The ref-based read keeps identity stable. + expect(secondRenderItem).toBe(firstRenderItem); + }); + }); }); diff --git a/packages/cli/src/ui/components/MainContent.tsx b/packages/cli/src/ui/components/MainContent.tsx index 994cf78abf6..281d11eb608 100644 --- a/packages/cli/src/ui/components/MainContent.tsx +++ b/packages/cli/src/ui/components/MainContent.tsx @@ -87,6 +87,14 @@ function initialReplayCount(length: number): number { // stable completed items when unrelated UIState fields change during streaming. const VirtualHistoryItem = memo(HistoryItemDisplay); +// Stable empty Set used by `absorbedCallIds` when compact mode is off so the +// memo returns a referentially-stable value across renders. Without this, every +// re-render where compactMode is false produced a brand-new empty Set, which +// invalidated `isSummaryAbsorbed`, then `renderVirtualItem`, then forced +// `VirtualizedList.renderedItems` to recompute and call renderItem for every +// item — defeating the static-item memo. +const EMPTY_ABSORBED_CALL_IDS = new Set(); + // Pure functions with no closure deps — defined outside the component so they // are stable references and never trigger useMemo/useCallback invalidation. const virtualEstimatedItemHeight = () => 3; @@ -122,8 +130,8 @@ export const MainContent = () => { // standalone `●