qwen-code/docs/design/virtual-viewport
ChiGao 1285214d10
feat(cli): virtual viewport for long conversations on ink 7 (#4146)
* 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 (b25831b0e) reintroduced the
issue #3899 freeze regression on every model switch:

  1. setCurrentModel(model) commits first, with the OLD
     historyRemountKey.
  2. <Static key={`${historyRemountKey}-${currentModel}`}> sees its
     key change (because currentModel did) and remounts immediately.
  3. MainContent's render-phase progressive-replay reset only fires
     when historyRemountKey changes, so replayCount is still the
     full mergedHistory.length from any prior catch-up.
  4. The remounted Static dumps the entire history in one synchronous
     layout pass — exactly the freeze progressive replay was added
     to avoid (#3899). The second effect's refreshStatic() bump
     arrives a render too late.

Fix: do not split. Both side effects (refreshStatic, which writes
clearTerminal + bumps historyRemountKey, and setCurrentModel) live
in the event handler again, with a ref guard for same-model
notifications. The React.StrictMode concern that motivated b25831b0e
is addressed by keeping the side effect OUT of the setState updater
(it now runs once per event-handler invocation, not once per
double-invoked updater call). Both setState calls land in the same
React batch, so historyRemountKey and currentModel update together —
MainContent's render-phase reset sees the new key, replayCount drops
to the first chunk, and Static remounts with chunked replay intact.

Tests:
- AppContainer.test.tsx: 4 new tests covering the synchronous
  refreshStatic side-effect contract, same-model no-op, ref-guarded
  StrictMode double-invoke, and unsubscribe-on-unmount.
- MainContent.test.tsx: new regression guard — when currentModel
  changes but historyRemountKey is held constant, progressive replay
  must NOT reset (pins the MainContent invariant the two-effect
  refactor accidentally relied on).

Verified: vitest packages/cli AppContainer + MainContent green (82/82).
Typecheck clean.

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix+docs(cli): VP review round 5 — typecheck, doc drift, scroll keys

PR #4146 review feedback (wenshao + Claude Opus 4.7 audit) addressed:

Code:
- MainContent.test: activePtyId typed as number (was 'pty-xyz' string,
  broke tsc with TS2322 — the test only relies on reference change so
  any number works).
- VirtualizedList: sanitize renderItem error path. Display becomes the
  generic `[render error]` marker; full err goes to debugLogger.debug
  so file paths / partial tool state don't leak to scrollback.
- MainContent: move pendingSourceCopyOffsetsByIndex into a ref so it
  no longer rebuilds renderVirtualItem identity every streaming tick.
  Without this, VirtualizedList.renderedItems useMemo invalidated
  per-tick → JSX rebuilt for every visible item → memo(HistoryItem
  Display) was still bailing but allocations were O(visible) per tick.
- AppContainer: drop the misleading "state-driven scroll reset" claim
  in the VP refreshStatic comment. VP is intentionally near-no-op:
  the React tree owns the visible region, mergedHistory mutation is
  what refreshes the screen, and the remount-key bump is preserved
  only to keep the legacy Static branch in sync if the user toggles
  the flag off mid-session.
- StaticRender: rewrite JSDoc to match reality. The custom React.memo
  is NOT output caching like @jrichman/ink's StaticRender export;
  the comparator rarely matches (parent allocates fresh JSX); the
  real skip happens at memo(HistoryItemDisplay) one level deeper.

Docs:
- docs/design/virtual-viewport: sync file map (drop non-existent
  ScrollProvider.tsx / useAnimatedScrollbar.ts), PR sequence (one PR
  #4146, V.3-V.5 deferred), open-question + checklist resolution for
  #3905 (superseded) and base branch rename.
- docs/users/reference/keyboard-shortcuts: document the 6 VP scroll
  keys (Shift+↑/↓, PgUp/PgDn, Ctrl+Home/End) under a "History
  scrollback (when ui.useTerminalBuffer is on)" section. Previously
  the only discovery path was the Settings dialog description.

Verified: tsc --noEmit -p packages/cli ✓, vitest 160/160 ✓ across
AppContainer / MainContent / VirtualizedList / useBatchedScroll /
keyMatchers / settingsSchema, eslint clean on touched files.

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* feat(cli): SGR mouse wheel scroll in VP mode

Recovers the most-felt UX regression vs legacy `<Static>` mode: when
`ui.useTerminalBuffer` is on, legacy users lose mouse wheel as a way
to scroll history (the host terminal stopped seeing the conversation
in its scrollback buffer). This PR enables button-event tracking
(`?1002h`) + SGR coordinates (`?1006h`) while the ScrollableList has
focus, parses wheel events off stdin, and routes them to scrollBy.

Scope kept tight on purpose:
- Wheel only. Hit-testing for scrollbar drag / click-to-position
  needs screen-absolute element coords; stock ink 7's useBoxMetrics
  returns yoga's parent-relative layout. Deferred to V.4 with two
  exit paths (upstream getBoundingBox to ink 7, or local yoga walker).
- Mouse mode is enabled only while ScrollableList is mounted; non-VP
  users never see their terminal flipped into button-event tracking.
- Side effect: native click-and-drag text selection is captured by
  the program. Docs + settings dialog description now spell out the
  Shift / Option (macOS) bypass.

Implementation:
- `ui/utils/mouse.ts` — SGR + X11 parser, ported and trimmed from
  gemini-cli (Google LLC, Apache-2.0). Single-consumer.
- `ui/hooks/useMouseEvents.ts` — enable/parse/disable lifecycle
  hook. Listens on stdin via `useStdin().stdin`, runs handler
  through a ref so callers don't have to memoize.
- `ui/components/shared/ScrollableList.tsx` — subscribe to mouse
  events, route wheel → `scrollBy(±3)`. Also drops a dead outer
  `<Box flexGrow={1}>` wrapper that held an unread containerRef
  and collapsed to zero height in ink-testing-library (the test
  renderer has no flex parent, so flexGrow=1 → 0 height → no items
  ever rendered, which is how this dead code was exposed).

Tests:
- `ui/utils/mouse.test.ts` — 14 cases: SGR parsing (wheel, presses,
  modifiers, move), X11 parsing, fallback chain, incomplete-sequence
  guard (including the >50-byte garbage cap).
- `ui/components/shared/ScrollableList.test.tsx` — 3 cases: wheel
  events shift the rendered window; hasFocus=false makes the mouse
  pipeline inactive (no throw); non-wheel events leave the window
  unchanged. Renders are wrapped in `<KeypressProvider>` (required
  by useKeypress in production but easy to forget in standalone
  tests).

Docs:
- `docs/users/reference/keyboard-shortcuts.md` — adds "Mouse wheel"
  row + the Shift/Option-to-select note.
- `packages/cli/src/config/settingsSchema.ts` — the in-app dialog
  description now mentions mouse wheel and the text-select bypass.
- `docs/design/virtual-viewport/README.md` — §1 status, §5 file map,
  §7 PR sequence all reflect mouse wheel landing in #4146 and the
  V.4–V.7 follow-up split (scrollbar drag / in-app search / alt-
  buffer / host-scrollback dual-write research).

Verified: tsc --noEmit -p packages/cli ✓, vitest 182/182 ✓ across
AppContainer / MainContent / VirtualizedList / ScrollableList /
useBatchedScroll / mouse / keyMatchers / settingsSchema.

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* feat(cli): auto-hide animation for VP scrollbar thumb

Pairs with the SGR mouse-wheel work from the previous commit:
when the user actually scrolls, the thumb pops bright; after a
1.5s idle it fades into the dim track so the bar stops competing
with the conversation. The track column itself stays in layout
regardless, so the viewport never reflows mid-flash (which would
trigger per-item re-measure and a visible jitter).

Implementation kept minimal for stock ink 7:
- gemini-cli's `useAnimatedScrollbar` interpolates RGB colors via
  a theme + per-frame setInterval. The terminal can't render
  smooth fades anyway, so this hook collapses the state to a
  binary `isVisible` flag with a single setTimeout. ~75 LoC.
- `VirtualizedList` calls `flashScrollbar()` from a useLayoutEffect
  keyed on `clampedScrollTop`. The very first commit is skipped
  via a ref so initial mount doesn't paint a flash.
- The render switches the thumb glyph (`█` vs `│`) and `dimColor`
  based on `isVisible && inThumb`. Width stays 1 either way.

Tests (6 new):
- initial mount stays hidden (no spurious mount flash)
- flash → visible, hides after idle timeout, successive flashes
  reset the timer (no premature hide), idleHideMs<=0 disables
  auto-hide for tests that want to assert on the visible state,
  unmount cleans up the pending timer.

Doc updates:
- `docs/design/virtual-viewport/README.md` §1 status, §5 file map,
  §7 PR sequence — V.4 row now scopes only the drag/click-jump
  work (still coord-blocked); animated scrollbar moved out of
  deferred and into shipped.
- PR #4146 body — architecture table mentions the auto-hide, new
  files list adds `useAnimatedScrollbar.ts`, test count refreshed
  to 188/188.

Verified: tsc --noEmit -p packages/cli ✓, vitest 188/188 ✓.

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(cli): VP review round 6 — ESC bug, CI lint, scope-controlled cleanup

Triage of /review feedback from 2026-05-18 + 2026-05-19. Took the
ones that are real and small; declined the ones that are
false-positive / out-of-scope so this PR stops expanding.

Must-fix:
- CI Lint failure: vscode-ide-companion/schemas/settings.schema.json
  was stale after the keyboard-shortcuts description bump. Regenerated
  via `npm run generate:settings-schema`.
- useMouseEvents.ts had `const ESC = '';` (literal empty string after
  the raw 0x1B byte got stripped somewhere in the source pipeline).
  `buffer.indexOf('', 1) === 1` would have degraded garbage skipping
  to a one-byte scan, and the `else { buffer = ''; break }` branch
  could never run. Fixed by switching to the `'\x1b'` text escape and
  doing the same in `mouse.ts` (which had the raw byte, also fragile).
  Comment explains why.

Small wins (one-liners taken from the review batch):
- ScrollableList: rest-spread separates `hasFocus` from the props
  forwarded to VirtualizedList. Latent collision risk; no behaviour
  change today.
- VirtualizedList: `debugLogger.debug` when isReady=false so blank-
  viewport edge cases (tiny terminal / mid-resize race) become
  diagnosable from the debug log instead of looking like a hang.

Real perf (VP-only):
- MainContent: gated the progressive-Static-replay machinery behind
  `!useVirtualScroll`. The render-phase reset still consumes the
  remount-key bump so flag-off toggles mid-session catch up cleanly,
  but `setReplayCount` and the setImmediate chunking effect are now
  skipped for VP users. Saves ~M/CHUNK_SIZE wasted re-renders per
  Ctrl+O / model change on a 1000-turn session.

Belt-and-braces:
- useMouseEvents: added a `process.on('exit')` handler that writes
  the SGR mouse disable seq again. The React cleanup already covers
  normal unmount, but Ctrl+C / SIGTERM / parent kill bypass it and
  the terminal would otherwise stay in button-event-tracking mode
  after qwen exits.

Explicitly declined / deferred (with reasoning logged on the PR):
- requestAnimationFrame wheel throttle: rAF doesn't exist in Node;
  React 19 already batches state updates within a tick, and the
  renderedItems memo bounds the actual work to visible items. Will
  revisit if profiling shows it.
- Stable pending-item IDs (`p-N` keys shifting on completion): the
  observable jitter is at most one frame of estimated-vs-actual
  height delta. Moderate scope (creation-time ID allocation); fits
  better in a focused follow-up than in this PR.

Verified: tsc --noEmit -p packages/cli ✓, vitest 188/188 ✓ across
the full VP suite.

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(cli): scrollBy bottom uses live end anchor in virtualized list

When keyboard scroll reaches the bottom, scrollBy set isStickingToBottom
but anchored via getAnchorForScrollTop(maxScroll), a fixed {index,offset}
pixel anchor. scrollTo/scrollToEnd instead use {index: last, offset:
SCROLL_TO_ITEM_END}, which recomputes the bottom from live item heights
each render. The fixed anchor did not track the last item growing during
streaming, so scroll-to-bottom via keyboard lagged behind new tokens.
Align scrollBy's bottom branch with the sibling methods.

Reported by wenshao in PR review.

* fix(cli): parse mouse events via ink useInput, not a stdin data listener

useMouseEvents attached its own stdin.on('data', ...) listener. Adding a
'data' listener switches stdin into flowing mode, which drains the buffer
before ink's readable + stdin.read() reader (ink App) can consume it, so
all keyboard input routed through useInput was silently starved while
mouse mode was active.

Parse mouse sequences from ink's existing input pipeline via useInput
instead, so there is only one stdin reader. ink captures a full SGR
sequence (ESC [ < .. M/m) as a single CSI event and delivers it with the
leading ESC stripped, so we re-prepend it before parsing. Non-mouse input
does not match and is ignored; ink still routes input to the app's other
useInput handlers, so keyboard navigation keeps working.

Only SGR mode (1006h, which we enable) is parsed via this path; the legacy
X11 encoding is not recoverable through ink's CSI parser, which is the
encoding modern terminals stop emitting once 1006h is set.

Reported by wenshao in PR review.

* fix(cli): parse only SGR in mouse hook to avoid X11 paste misfire

The useInput-based mouse hook called parseMouseEvent, which also tries the
X11 fallback (parseX11MouseEvent). An X11 prefix (ESC [ M + 3 bytes) can
reach the handler via pasted text — ink emits paste content as input when
no paste listener is registered — and would misfire a spurious mouse event.
Call parseSGRMouseEvent directly so only the SGR encoding we enable (1006h)
is parsed, matching the hook's documented contract.

Reported by wenshao in PR review.

* test(cli): assert SGR mouse parser rejects X11 sequences

Locks in the security property behind the parseMouseEvent ->
parseSGRMouseEvent switch in useMouseEvents: an X11 sequence arriving as
pasted text must not misfire a mouse event. Asserts a well-formed X11
sequence is a valid X11 event yet returns null from parseSGRMouseEvent, so
a future revert to parseMouseEvent fails this test.

Reported by wenshao in PR review.

* test(cli): add VP scroll coverage + eslint-disable for useBatchedScroll

Cover keyboard scroll commands (Shift+Up/Down, PageUp/Down, Ctrl+Home/End),
scrollBy/scrollTo imperative API (positive/negative/overflow/clamp), and
auto-scroll-during-streaming state machine (stick-to-bottom, disengage on
user scroll, re-engage on scrollToEnd). Add missing eslint-disable-next-line
for intentionally dep-free useLayoutEffect in useBatchedScroll.

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* chore(cli): remove trailing whitespace in useBatchedScroll

The eslint-disable-next-line comment was removed by eslint --fix as an
unused directive (exhaustive-deps does not flag a useLayoutEffect with
no dependency array). Clean up the residual blank line.

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

---------

Co-authored-by: 秦奇 <gary.gq@alibaba-inc.com>
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-06-02 13:57:17 +08:00
..
README.md feat(cli): virtual viewport for long conversations on ink 7 (#4146) 2026-06-02 13:57:17 +08:00

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

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 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 ResizeObserveruseBoxMetrics 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's TranscriptSearchBar pattern (V.5).
  • Alternate-buffer modecontexts/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 ResizeObserveruseBoxMetrics

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.4V.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 — 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. Resolved: #3905's progressive-replay landed in 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.
  6. Compatibility with chore/re-upgrade-ink-7-0-3: PR #4146 stacks on it. After #4119 (the ink 7.0.3 re-upgrade PR) merges to main, PR #4146's base will re-target to main.

10. Risks

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, default false (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