docs/design/virtual-viewport/README.md
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)
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.
Surveyed two open-source ink-based CLIs that already solved (or worked around) the same problem:
/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 / diffEach cell-level diffingrender-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. virtualizationscreens/REPL.tsx:
visibleStreamingText = streamingText.substring(0, streamingText.lastIndexOf('\n') + 1) || null — only complete lines exposed to rendererScrollBox with scrollRef, cursorNavRefMarkdown.tsx StreamingMarkdown splits content at last top-level block boundary, memoizes stable prefix, only re-parses unstable suffixMarkdown.tsx token cache (LRU-500) — survives unmount→remount, so virtual-scroll re-mounts hit cache without re-lexingWhy 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.
/Users/gawain/Documents/codebase/opensource/gemini-cli)Uses @jrichman/[email protected] (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.
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 measureuseWindowSize — terminal resizeuseAnimation — for scrollbar fadeStatic, Box, Text, etc.ResizeObserver (component/class) — needs adaptationStaticRender — needs custom implementationConclusion: ink 7 has every primitive needed. No fork swap required.
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 |
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:
/ search — claude-code's TranscriptSearchBar pattern (V.5).contexts/ScrollProvider.tsx-style focus / lock, with full alt-screen takeover (V.6).// 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.
ResizeObserver → useBoxMetricsgemini-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.
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.
StaticRender — custom implementationgemini-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.
HistoryItemDisplaygemini-cli does:
const MemoizedHistoryItemDisplay = memo(HistoryItemDisplay);
Same pattern in qwen-code. Required for virtualization to actually skip re-renders.
| 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.
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 greenEnd-to-end (after V.3):
useTerminalBuffer: false (legacy) vs true (virtualized)ui.useTerminalBuffer (gemini-cli compat) vs ui.virtualizedHistory (more descriptive)?false (opt-in) or stage rollout via env var first?header as static. Should we also mark completed Gemini messages, tool results that are no longer in pendingHistoryItems, etc.?ScrollProvider includes mouse drag for scrollbar. Worth porting now or skip until V.4?MainContent.tsx. Coordinate merge order — likely V.2 rebases on top of #3905.main and is preserved in the legacy <Static> branch of MainContent.tsx; the VP branch supersedes it for opt-in users because the freeze trigger (full Static remount) no longer applies.chore/re-upgrade-ink-7-0-3: PR #4146 stacks on it. After #4119 (the ink 7.0.3 re-upgrade PR) merges to main, PR #4146's base will re-target to main.| 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. |
ui.useTerminalBuffer, default false (opt-in)isStaticItem={(item) => item.id > 0} (completed history items)main; #4146 preserves the legacy progressive-replay path and supersedes it only for VP users