CoolFace
Datasetpublic

basant307/AI_Governance_Project

sourceHugging Faceapache-2.0updated 2mo agoView on Hugging Face
0likes48downloads
README.md369 linesDownload Raw Back to virtual-viewport
1# Virtual viewport for long conversations on ink 72 3Status: **implemented**, PR #4146 ships:4core viewport, ASCII scrollbar with auto-hide animation, SGR mouse-wheel, `ui.useTerminalBuffer` gate, keyboard scroll keys.5Scrollbar drag / in-app search / alt-buffer mode / dual-write to host scrollback are scoped out to V.3+ (see §7).6Author: 秦奇7Tracking branch: `feat/virtual-viewport-on-ink7` (base: `main`)8 9## 1. Problem10 11Several 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.12 13The current symptoms this enables:14 15| Issue           | Symptom                                            | Current contributor                                           |16| --------------- | -------------------------------------------------- | ------------------------------------------------------------- |17| #2950           | Long session shows continuous up/down scroll storm | full Static remount on every refresh                          |18| #3118           | Switching back to window keeps flickering          | `clearTerminal` + `historyRemountKey++` triggers full remount |19| #3007           | Generic interface flickering                       | same as #3118                                                 |20| #3838 (UI side) | Scrollbar grows unboundedly                        | each cumulative-delta render adds rows; no viewport eviction  |21| #3899 → #3905   | Ctrl+O froze terminal for seconds                  | the partially-fixed case, sealed with `setImmediate` chunking |22 23PR #3905 explicitly notes:24 25> Discussion of alternatives (sealed prefix + live tail, **true viewport virtualization**, ANSI-output caching) was considered but each changes UX or requires an architectural rewrite.26 27That architectural rewrite is what this design proposes.28 29## 2. Reference implementations30 31Surveyed two open-source ink-based CLIs that already solved (or worked around) the same problem:32 33### 2.1 claude-code (`/Users/gawain/Documents/codebase/opensource/claude-code`)34 35Maintains its **own forked ink** at `src/ink/`:36 37- `ink.tsx` — 1722 LoC custom main loop38- `log-update.ts` — 773 LoC custom diff renderer with scroll-region (`DECSTBM`) optimization, full-frame fallback when scrollback would be touched39- `screen.ts` / `frame.ts` — explicit Screen / Frame objects, `cellAt` / `diffEach` cell-level diffing40- `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. virtualization41- `screens/REPL.tsx`:42  - `visibleStreamingText = streamingText.substring(0, streamingText.lastIndexOf('\n') + 1) || null` — only complete lines exposed to renderer43  - `ScrollBox` with `scrollRef`, `cursorNavRef`44  - `Markdown.tsx` `StreamingMarkdown` splits content at last top-level block boundary, memoizes stable prefix, only re-parses unstable suffix45- `Markdown.tsx` token cache (LRU-500) — survives unmount→remount, so virtual-scroll re-mounts hit cache without re-lexing46 47**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.48 49### 2.2 gemini-cli (`/Users/gawain/Documents/codebase/opensource/gemini-cli`)50 51Uses `@jrichman/ink@6.6.9` (a smaller fork that adds `ResizeObserver` and `StaticRender` exports), and ships **a complete virtualized list as plain components**:52 53| File                                    | LoC | Role                                                                   |54| --------------------------------------- | --- | ---------------------------------------------------------------------- |55| `components/shared/VirtualizedList.tsx` | 764 | Core viewport + measurement + scroll-anchor + per-item resize tracking |56| `components/shared/ScrollableList.tsx`  | 278 | Wraps `VirtualizedList`, adds keypress nav + smooth scroll + scrollbar |57| `contexts/ScrollProvider.tsx`           | 469 | Mouse drag, scroll lock, focus context                                 |58| `hooks/useBatchedScroll.ts`             | 35  | Coalesces same-tick scroll updates                                     |59| `hooks/useAnimatedScrollbar.ts`         | 130 | Scrollbar fade-in/out animation                                        |60 61`MainContent.tsx` switches between two render paths via a `isAlternateBufferOrTerminalBuffer` flag:62 63```tsx64if (isAlternateBufferOrTerminalBuffer) {65  return <ScrollableList data={virtualizedData} renderItem={renderItem} ... />;66}67 68return <Static items={[<AppHeader />, ...staticHistoryItems, ...lastResponseHistoryItems]}>...</Static>;69```70 71`HistoryItemDisplay` is wrapped in `React.memo` so unchanged items don't re-render.72 73**This is the production-grade reference.**74 75## 3. ink 7 capability check76 77qwen-code is on the in-flight `chore/upgrade-ink-7` branch. Inspected `node_modules/ink/build/index.d.ts` exports:78 79- ✅ `useBoxMetrics(ref): {width, height, left, top, hasMeasured}` — auto-updates on layout change. **Functional equivalent of `ResizeObserver`.**80- ✅ `measureElement(node)` — single-shot imperative measure81- ✅ `useWindowSize` — terminal resize82- ✅ `useAnimation` — for scrollbar fade83- ✅ `Static`, `Box`, `Text`, etc.84- ❌ `ResizeObserver` (component/class) — needs adaptation85- ❌ `StaticRender` — needs custom implementation86 87**Conclusion**: ink 7 has every primitive needed. No fork swap required.88 89## 4. Strategic decision90 91**Port gemini-cli's `ScrollableList` + `VirtualizedList` + supporting hooks/contexts to qwen-code, adapting `ResizeObserver` → `useBoxMetrics` and rolling a custom `StaticRender`.**92 93Rejected alternatives:94 95| Alternative                       | Why rejected                                                                                                      |96| --------------------------------- | ----------------------------------------------------------------------------------------------------------------- |97| Fork ink like claude-code         | Unsustainable maintenance burden                                                                                  |98| 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 |99| Build virtualization from scratch | Reinvents ~1700 LoC of proven design; gemini-cli's reference exists and works                                     |100 101## 5. Architecture102 103### File map after PR #4146104 105```106packages/cli/src/ui/107├── components/shared/108│   ├── VirtualizedList.tsx          [NEW] core viewport + ASCII scrollbar109│   ├── ScrollableList.tsx           [NEW] keyboard + mouse-wheel wrapper110│   └── StaticRender.tsx             [NEW] React.memo wrapper (replaces gemini-cli's ink fork export)111├── hooks/112│   ├── useBatchedScroll.ts          [NEW] coalesce same-tick scroll updates113│   ├── useMouseEvents.ts            [NEW] enable SGR mouse mode + parse stdin events114│   └── useAnimatedScrollbar.ts      [NEW] thumb flash on scroll + idle auto-hide115├── utils/116│   └── mouse.ts                     [NEW] SGR + X11 mouse-event parser (port from gemini-cli)117├── components/MainContent.tsx       [MOD] add virtualized branch + stability refs118└── AppContainer.tsx                 [MOD] feed scroll-related UI state into context + gate refreshStatic119```120 121Deferred to follow-up PRs:122 123- **Scrollbar drag + click-to-position** — needs screen-absolute element coords, blocked on a stock-ink-7 limitation (see V.4 / V.7).124- **In-app `/` search** — claude-code's `TranscriptSearchBar` pattern (V.5).125- **Alternate-buffer mode** — `contexts/ScrollProvider.tsx`-style focus / lock, with full alt-screen takeover (V.6).126 127### Setting (V.2)128 129```ts130// settings schema131ui: {132  /**133   * Enables virtualized history rendering for long conversations.134   * When true, only items in the visible viewport are rendered through React;135   * scrolled-out items remain in the terminal scrollback buffer.136   *137   * Default: false. Opt-in until proven stable on long conversations.138   */139  useTerminalBuffer?: boolean;  // alias kept compat with gemini-cli140}141```142 143`MainContent.tsx` reads the setting and switches paths:144 145```tsx146const useTerminalBuffer = uiState.settings?.ui?.useTerminalBuffer ?? false;147 148if (useTerminalBuffer) {149  return <ScrollableList .../>; // virtualized150}151 152return <Static .../>; // existing path, untouched153```154 155The legacy `<Static>` path stays as-is — no regression risk for users who don't opt in.156 157## 6. Key adaptations from gemini-cli source158 159### 6.1 `ResizeObserver` → `useBoxMetrics`160 161gemini-cli's container observer (imperative pattern):162 163```ts164const containerObserverRef = useRef<ResizeObserver | null>(null);165 166const containerRefCallback = useCallback((node: DOMElement | null) => {167  containerObserverRef.current?.disconnect();168  containerRef.current = node;169  if (node) {170    const observer = new ResizeObserver((entries) => {171      const entry = entries[0];172      if (entry) {173        const newHeight = Math.round(entry.contentRect.height);174        const newWidth = Math.round(entry.contentRect.width);175        setContainerHeight((prev) => (prev !== newHeight ? newHeight : prev));176        setContainerWidth((prev) => (prev !== newWidth ? newWidth : prev));177      }178    });179    observer.observe(node);180    containerObserverRef.current = observer;181  }182}, []);183```184 185Our adaptation (declarative ink 7 hook):186 187```ts188const containerRef = useRef<DOMElement>(null);189const { width: containerWidth, height: containerHeight } =190  useBoxMetrics(containerRef);191```192 193`useBoxMetrics` already handles attach/detach + layout-change subscription; the imperative bookkeeping disappears.194 195### 6.2 Per-item resize tracker (`itemsObserver`)196 197Harder. gemini-cli observes N item nodes via a single `ResizeObserver` and routes the entry → key via a `WeakMap`:198 199```ts200const nodeToKeyRef = useRef(new WeakMap<DOMElement, string>());201const itemsObserver = useMemo(202  () =>203    new ResizeObserver((entries) => {204      setHeights((prev) => {205        let next = null;206        for (const entry of entries) {207          const key = nodeToKeyRef.current.get(entry.target);208          if (key && prev[key] !== Math.round(entry.contentRect.height)) {209            if (!next) next = { ...prev };210            next[key] = Math.round(entry.contentRect.height);211          }212        }213        return next ?? prev;214      });215    }),216  [],217);218```219 220`useBoxMetrics` is **single-ref-per-hook**, so we cannot 1:1 replace this. Two options:221 222**Option A — push measurement down to `VirtualizedListItem`**223 224Each `VirtualizedListItem` already runs as its own component (memoized). Add `useBoxMetrics` inside it; report height up via a callback prop:225 226```tsx227const VirtualizedListItem = memo(({ itemKey, onHeightChange, ...props }) => {228  const ref = useRef<DOMElement>(null);229  const { height, hasMeasured } = useBoxMetrics(ref);230  useEffect(() => {231    if (hasMeasured) onHeightChange(itemKey, height);232  }, [itemKey, height, hasMeasured, onHeightChange]);233  return <Box ref={ref}>{...}</Box>;234});235```236 237**Option B — use `measureElement` + `useLayoutEffect`** in the parent238 239Parent stores refs for visible items, runs a layout-effect after each render to measure them. Less reactive but simpler:240 241```ts242useLayoutEffect(() => {243  const newHeights: Record<string, number> = { ...heights };244  let changed = false;245  for (const [key, ref] of itemRefs.current) {246    if (ref) {247      const { height } = measureElement(ref);248      if (newHeights[key] !== height) {249        newHeights[key] = height;250        changed = true;251      }252    }253  }254  if (changed) setHeights(newHeights);255});256```257 258**Recommendation: Option A.** Cleaner separation, leverages ink 7's built-in change detection. Avoids the "measure storm" risk where every render measures everything.259 260### 6.3 `StaticRender` — custom implementation261 262gemini-cli imports `StaticRender` from `@jrichman/ink`. Looking at usage in `VirtualizedList.tsx`:263 264```tsx265{shouldBeStatic ? (266  <StaticRender width={...} key={`${itemKey}-static-${width}`}>267    {content}268  </StaticRender>269) : (270  content271)}272```273 274Semantics: render `content` once at the given width; subsequent renders with the same key + width return the cached render.275 276For ink 7, the equivalent is plain `React.memo` with a stable component that the parent guarantees not to re-render. Custom implementation:277 278```tsx279import { memo } from 'react';280import { Box } from 'ink';281 282interface StaticRenderProps {283  children: React.ReactElement;284  width?: number | string;285}286 287const StaticRender = memo(288  ({ children, width }: StaticRenderProps) => (289    <Box width={width} flexDirection="column" flexShrink={0}>290      {children}291    </Box>292  ),293  (prev, next) => prev.children === next.children && prev.width === next.width,294);295```296 297Combined with the parent's stable `key` prop (`${itemKey}-static-${width}`), changing children or width causes a fresh mount; otherwise React skips re-rendering.298 299This is the core capability: items that ARE static (e.g. completed Gemini messages) get measured + rendered once and never re-walk through React.300 301### 6.4 Memoize `HistoryItemDisplay`302 303gemini-cli does:304 305```ts306const MemoizedHistoryItemDisplay = memo(HistoryItemDisplay);307```308 309Same pattern in qwen-code. Required for virtualization to actually skip re-renders.310 311## 7. PR sequence312 313| PR        | Title (draft)                                                               | Scope                                                                                                                                                                              | Lines             | Dependencies | Risk                                           |314| --------- | --------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------- | ------------ | ---------------------------------------------- |315| **#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 |316| **V.3**   | test(integration): capture-suite regressions for streaming / resize / shell | port 3 capture scripts from PR #3663                                                                                                                                               | ~2000 (test-only) | #4146        | pending                                        |317| **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                       |318| **V.5**   | feat(cli): in-app `/` search                                                | viewport-bound highlight + n/N navigation (claude-code's `TranscriptSearchBar` pattern)                                                                                            | ~300              | #4146        | deferred                                       |319| **V.6**   | feat(cli): alternate-buffer mode (full alt-screen takeover)                 | additional setting `ui.useAlternateBuffer`                                                                                                                                         | ~500              | #4146        | deferred — separate UX decision required       |320| **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            |321 322V.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.323 324## 8. Verification plan325 326Per-PR (mandatory before any "ready for review"):327 328- `npm run typecheck --workspace=@qwen-code/qwen-code` — clean329- `npm run lint --workspace=@qwen-code/qwen-code` — clean330- `cd packages/cli && npx vitest run` — all green331- Multi-round directionless audit per project workflow332 333End-to-end (after V.3):334 335- Long-conversation benchmark: 1000-turn session, measure336  - First-paint time (initial mount + paint)337  - Ctrl+O toggle latency338  - Resize latency339  - Per-frame render time during streaming340- Compare `useTerminalBuffer: false` (legacy) vs `true` (virtualized)341 342## 9. Open questions / decisions needed343 3441. **Setting name**: `ui.useTerminalBuffer` (gemini-cli compat) vs `ui.virtualizedHistory` (more descriptive)?3452. **Default value**: ship as `false` (opt-in) or stage rollout via env var first?3463. **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.?3474. **Mouse support**: gemini-cli's `ScrollProvider` includes mouse drag for scrollbar. Worth porting now or skip until V.4?3485. **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.3496. **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`.350 351## 10. Risks352 353| Risk                                                                      | Likelihood | Mitigation                                                                                              |354| ------------------------------------------------------------------------- | ---------- | ------------------------------------------------------------------------------------------------------- |355| `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. |356| `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.    |357| `<Static>` legacy path drift as the new path evolves                      | low        | Feature-flag gate keeps both paths active; CI runs both via setting matrix.                             |358| 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.        |359| 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.       |360 361## 11. Approval checklist362 363- [x] Architectural direction approved — port from gemini-cli (§4)364- [x] Setting name + default decided — `ui.useTerminalBuffer`, default `false` (opt-in)365- [x] Static-item heuristic — `isStaticItem={(item) => item.id > 0}` (completed history items)366- [x] Mouse-support scope — deferred to V.4; keyboard-only scroll in #4146367- [x] Merge ordering with #3905 (§9.5) — #3905 already in `main`; #4146 preserves the legacy progressive-replay path and supersedes it only for VP users368- [x] PR #4146 implementation complete369 
basant307/AI_Governance_Project · CoolFace