* chore(deps): re-upgrade ink 6 → 7.0.3 (upstream Static remount fix landed) PR #3860 first upgraded ink 6 → 7.0.2. PR #4083 reverted because of a TUI regression: `<Static>` 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: <Static> remount via key change drops new items (#948) - 669c4386c Fix: Drop stale <Static> 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. * fix(deps,cli): add @types/react overrides + move refreshStatic out of setCurrentModel updater 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 <Static> key bumps. The double work was masked under ink 6 (key changes were no-ops on <Static>), 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. * docs(design): virtual viewport on ink 7 — analysis + PR sequence 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 <qwen-coder@alibabacloud.com> * feat(cli): virtual viewport for long conversations on ink 7 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 <qwen-coder@alibabacloud.com> * 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 <qwen-coder@alibabacloud.com> * feat(cli): use ink 7 native overflow for VP pending items 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 <Static> path is unchanged. Generated with AI Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * 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. * fix(cli): wire ShowMoreLines + skip clearTerminal in VP mode Two audit-found bugs in the VP path: 1. `<ShowMoreLines>` was outside the `<OverflowProvider>` that wraps `<ScrollableList>` 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 `<ShowMoreLines>` 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 `<Static>` 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. * fix(cli): VP renderItem stability + source-copy offsets + heights GC Three audit-found regressions tightened, in order of severity: 1. **Source-copy index offsets missing in VP** — legacy `<Static>` 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 `<StaticRender>` — 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 `<ShowMoreLines>` 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. * test+fix(cli): VP path coverage + stabilize absorbedCallIds empty Set 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). * fix(cli): stabilize absorbedCallIds in compact mode + gate heights prune + tighten ShowMoreLines test Round-2 audit follow-ups. Three real findings addressed; one flagged false positive documented separately. 1. **absorbedCallIds Set identity now content-stable when compact mode is on.** The earlier EMPTY constant only short-circuited the compactMode= false path; when compact mode is enabled (some users default-on it), activePtyId / embeddedShellFocused flips during streaming still produced fresh Sets per render even when membership was unchanged, restarting the same cascade the pendingStateRef fix was meant to avoid. Compare-and-reuse via a ref: if the new Set has identical membership to the previous one, return the previous reference. 2. **`heights` map prune in `VirtualizedList` is gated.** Previously every streaming tick rebuilt an N-key Set and walked all heights, even on the steady-state path where nothing changes. Now only fires when the heights record has clearly outpaced live data (`size > max(8, 2 × data.length)`) — covers `/clear` and accumulated pending → completed transitions, skips the 30-Hz hot path entirely. 3. **VP ShowMoreLines test now actually verifies overflow connectivity.** Previous mock unconditionally rendered "SHOW_MORE", so the test only proved the JSX mounted — it would still pass if a future refactor moved `<OverflowProvider>` out of the VP tree again. The mock now reads `useOverflowState()` and emits "OVERFLOW_DISCONNECTED" when the context is missing. The VP test asserts both presence of "SHOW_MORE" and absence of the disconnected marker, so the regression is now caught. Not addressed: - Audit P0-1 claim that `renderMode` (Alt+M) / model-change updates don't reach VP static items: false positive. `renderMode` is a React Context (`RenderModeContext`), and Context propagation traverses the tree past `memo` boundaries — MarkdownDisplay's `useRenderMode()` consumer re-renders on context change regardless of whether `StaticRender` bails out. Verified by reading `packages/cli/src/ui/contexts/RenderModeContext.tsx` and `MarkdownDisplay.tsx:172`. No code change. - Audit P1-2 pendingStateRef write-during-render race: speculative, relies on a multi-pass render path React 18+ does not currently use. Documented assumption in the existing inline comment. * fix(cli): isolate renderItem errors + defensive height coerce + compact-mode mergedHistory stability Round-3 audit follow-ups. Three real findings; the rest verified clean. 1. **`renderItem` errors no longer crash the CLI.** Previously a throw inside a per-item render propagated through `VirtualizedList`'s useMemo into React's commit phase, tearing down the whole Ink tree — one bad history record could nuke the session. Wrap each call in a try/catch and substitute a small red `[render error] …` text box on failure. The row stays in the viewport so the user can scroll past it. 2. **Defensive height coerce in offset accumulation.** A buggy `estimatedItemHeight` returning NaN / negative / Infinity would poison every downstream offset and break the `upperBound` / `findLastLE` binary search (which assumes monotonic offsets). Clamp to `Number.isFinite(raw) && raw > 0 ? raw : 0`. No-op for the in-tree estimators that return 3; insurance against future consumers. 3. **`mergedHistory` is content-stable when compact mode is on.** The Round-2 absorbedCallIds stability fix didn't reach this path: `mergeCompactToolGroups` always allocates a fresh array, and `mergedHistory`'s useMemo lists `activePtyId` / `embeddedShellFocused` as deps, so every streaming tick mid-shell-tool produced a new array even when items aligned. Cascade went `mergedHistory` → offsets map → `renderVirtualItem` → every static item re-rendered. Pair-wise compare new vs previous and return the previous reference when items align. Restores StaticRender memo effectiveness for compact-mode users. Not addressed (audit findings deemed not worth fixing in this PR): - `scrollToItem` silently no-ops when item is not in data — no current caller checks the return value, low impact. - `allVirtualItems` array spread is O(n) per streaming tick — real but not a crash; revisit in a perf-focused follow-up. - `itemRefs.current` is dead surface (never read) — cosmetic. - StrictMode-only-in-DEBUG double-invoke paths verified safe. * test+chore(cli): VP review round 4 — VirtualizedList/useBatchedScroll coverage + cleanups Addresses wenshao's CHANGES_REQUESTED review on PR #3941. - Add focused unit tests for `VirtualizedList` (9 cases) covering empty data, `renderStatic` full-render, `initialScrollIndex` with `SCROLL_TO_ITEM_END`, `targetScrollIndex` anchoring, imperative `scrollToEnd` / `scrollToIndex`, per-item `renderItem` error isolation, NaN/negative estimator coercion, and out-of-range `initialScrollIndex` clamping. - Add `useBatchedScroll` unit tests (4 cases) covering initial reads, pending-value reads in the same tick, post-commit pending reset, and callback identity stability across rerenders. - Remove dead `itemRefs` / `onSetRef` plumbing (declared, written, never read; `useCallback` with empty deps was also a stale-closure trap). - Remove unused `isStatic?: boolean` from `VirtualizedListProps` (only `isStaticItem` is actually consumed). - Tighten the render-phase setState block: each setter is now guarded by an equality check so React bails out of redundant updates, and a comment documents that this is the React-endorsed "adjusting state while rendering" pattern (the synchronous update avoids a one-frame flash at the previous position when `targetScrollIndex` changes). Generated with AI Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * chore(cli): remove dead `dataRef` from VirtualizedList (round-4 followup) Declared and written in a `useLayoutEffect` on every `data` change but never read anywhere in the component. Flagged in wenshao's round-4 review of PR #3941. Generated with AI Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * fix(cli): collapse model-change effect back into one batched handler wenshao's PR #4119 review correctly flagged that splitting the onModelChange flow into two effects ( |
||
|---|---|---|
| .. | ||
| README.md | ||
Virtual viewport for long conversations on ink 7
Status: implemented, PR #4146 ships:
core viewport, ASCII scrollbar with auto-hide animation, SGR mouse-wheel, ui.useTerminalBuffer gate, keyboard scroll keys.
Scrollbar drag / in-app search / alt-buffer mode / dual-write to host scrollback are scoped out to V.3+ (see §7).
Author: 秦奇
Tracking branch: feat/virtual-viewport-on-ink7 (base: main)
1. Problem
Several user-reported flicker / lag issues all bottom-out in the same architectural fact: ink's <Static> 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 looplog-update.ts— 773 LoC custom diff renderer with scroll-region (DECSTBM) optimization, full-frame fallback when scrollback would be touchedscreen.ts/frame.ts— explicit Screen / Frame objects,cellAt/diffEachcell-level diffingrender-to-screen.ts— exposesrenderToScreen(node)to render ANY node tree to aScreenobject out of band. This is the underlying capability for "render once, cache, replay" — i.e. virtualizationscreens/REPL.tsx:visibleStreamingText = streamingText.substring(0, streamingText.lastIndexOf('\n') + 1) || null— only complete lines exposed to rendererScrollBoxwithscrollRef,cursorNavRefMarkdown.tsxStreamingMarkdownsplits content at last top-level block boundary, memoizes stable prefix, only re-parses unstable suffix
Markdown.tsxtoken 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:
if (isAlternateBufferOrTerminalBuffer) {
return <ScrollableList data={virtualizedData} renderItem={renderItem} ... />;
}
return <Static items={[<AppHeader />, ...staticHistoryItems, ...lastResponseHistoryItems]}>...</Static>;
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 ofResizeObserver. - ✅
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 PR #4146
packages/cli/src/ui/
├── components/shared/
│ ├── VirtualizedList.tsx [NEW] core viewport + ASCII scrollbar
│ ├── ScrollableList.tsx [NEW] keyboard + mouse-wheel wrapper
│ └── StaticRender.tsx [NEW] React.memo wrapper (replaces gemini-cli's ink fork export)
├── hooks/
│ ├── useBatchedScroll.ts [NEW] coalesce same-tick scroll updates
│ ├── useMouseEvents.ts [NEW] enable SGR mouse mode + parse stdin events
│ └── useAnimatedScrollbar.ts [NEW] thumb flash on scroll + idle auto-hide
├── utils/
│ └── mouse.ts [NEW] SGR + X11 mouse-event parser (port from gemini-cli)
├── components/MainContent.tsx [MOD] add virtualized branch + stability refs
└── AppContainer.tsx [MOD] feed scroll-related UI state into context + gate refreshStatic
Deferred to follow-up PRs:
- Scrollbar drag + click-to-position — needs screen-absolute element coords, blocked on a stock-ink-7 limitation (see V.4 / V.7).
- In-app
/search — claude-code'sTranscriptSearchBarpattern (V.5). - Alternate-buffer mode —
contexts/ScrollProvider.tsx-style focus / lock, with full alt-screen takeover (V.6).
Setting (V.2)
// 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:
const useTerminalBuffer = uiState.settings?.ui?.useTerminalBuffer ?? false;
if (useTerminalBuffer) {
return <ScrollableList .../>; // virtualized
}
return <Static .../>; // existing path, untouched
The legacy <Static> 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):
const containerObserverRef = useRef<ResizeObserver | null>(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):
const containerRef = useRef<DOMElement>(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:
const nodeToKeyRef = useRef(new WeakMap<DOMElement, string>());
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:
const VirtualizedListItem = memo(({ itemKey, onHeightChange, ...props }) => {
const ref = useRef<DOMElement>(null);
const { height, hasMeasured } = useBoxMetrics(ref);
useEffect(() => {
if (hasMeasured) onHeightChange(itemKey, height);
}, [itemKey, height, hasMeasured, onHeightChange]);
return <Box ref={ref}>{...}</Box>;
});
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:
useLayoutEffect(() => {
const newHeights: Record<string, number> = { ...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:
{shouldBeStatic ? (
<StaticRender width={...} key={`${itemKey}-static-${width}`}>
{content}
</StaticRender>
) : (
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:
import { memo } from 'react';
import { Box } from 'ink';
interface StaticRenderProps {
children: React.ReactElement;
width?: number | string;
}
const StaticRender = memo(
({ children, width }: StaticRenderProps) => (
<Box width={width} flexDirection="column" flexShrink={0}>
{children}
</Box>
),
(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:
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 |
|---|---|---|---|---|---|
| #4146 | feat(cli): virtual viewport for long conversations on ink 7 | core primitives + ASCII scrollbar with auto-hide animation + SGR mouse-wheel + ui.useTerminalBuffer gate + MainContent/AppContainer wiring + tests |
~2800 LoC | main |
✅ shipped — typecheck clean, vitest green |
| V.3 | test(integration): capture-suite regressions for streaming / resize / shell | port 3 capture scripts from PR #3663 | ~2000 (test-only) | #4146 | pending |
| V.4 | feat(cli): scrollbar drag + click-to-position | SGR mouse hit-test on scrollbar column. Needs screen-absolute coords — either upstream getBoundingBox to ink 7 or own yoga walker. Auto-hide animation already shipped in #4146. |
~400 | #4146 | deferred — coord blocker |
| V.5 | feat(cli): in-app / search |
viewport-bound highlight + n/N navigation (claude-code's TranscriptSearchBar pattern) |
~300 | #4146 | deferred |
| V.6 | feat(cli): alternate-buffer mode (full alt-screen takeover) | additional setting ui.useAlternateBuffer |
~500 | #4146 | deferred — separate UX decision required |
| V.7 | research: preserve host terminal scrollback (dual-write) | @jrichman/ink's overflowToBackbuffer is fork-only. Options: upstream PR to ink 7, own dual-write, or accept loss. Investigation. |
— | #4146 | structurally blocked on stock ink 7 |
V.3 (integration tests) is the remaining critical-path item before flipping the default. V.4–V.6 close the remaining gemini-cli-parity gaps; V.7 is open research because the underlying ink prop we'd need (overflowToBackbuffer) only exists in gemini-cli's @jrichman/ink fork.
8. Verification plan
Per-PR (mandatory before any "ready for review"):
npm run typecheck --workspace=@qwen-code/qwen-code— cleannpm run lint --workspace=@qwen-code/qwen-code— cleancd 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) vstrue(virtualized)
9. Open questions / decisions needed
- Setting name:
ui.useTerminalBuffer(gemini-cli compat) vsui.virtualizedHistory(more descriptive)? - Default value: ship as
false(opt-in) or stage rollout via env var first? - Static-item heuristic: gemini-cli marks only
headeras static. Should we also mark completed Gemini messages, tool results that are no longer inpendingHistoryItems, etc.? - Mouse support: gemini-cli's
ScrollProviderincludes mouse drag for scrollbar. Worth porting now or skip until V.4? - Compatibility with #3905:
PR #3905 (Ctrl+O freeze fix) is open and modifies the sameResolved: #3905's progressive-replay landed inMainContent.tsx. Coordinate merge order — likely V.2 rebases on top of #3905.mainand is preserved in the legacy<Static>branch ofMainContent.tsx; the VP branch supersedes it for opt-in users because the freeze trigger (full Static remount) no longer applies. - Compatibility with
chore/re-upgrade-ink-7-0-3: PR #4146 stacks on it. After #4119 (the ink 7.0.3 re-upgrade PR) merges tomain, PR #4146's base will re-target tomain.
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. |
<Static> 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 (§4)
- Setting name + default decided —
ui.useTerminalBuffer, defaultfalse(opt-in) - Static-item heuristic —
isStaticItem={(item) => item.id > 0}(completed history items) - Mouse-support scope — deferred to V.4; keyboard-only scroll in #4146
- Merge ordering with #3905 (§9.5) — #3905 already in
main; #4146 preserves the legacy progressive-replay path and supersedes it only for VP users - PR #4146 implementation complete