diff --git a/apps/desktop/src/app/chat/right-rail/preview-file.tsx b/apps/desktop/src/app/chat/right-rail/preview-file.tsx index 18dc113d29a..8d9ce922917 100644 --- a/apps/desktop/src/app/chat/right-rail/preview-file.tsx +++ b/apps/desktop/src/app/chat/right-rail/preview-file.tsx @@ -287,7 +287,7 @@ const MARKDOWN_COMPONENTS = { function MarkdownPreview({ text }: { text: string }) { return ( -
`, ``. One transcript row (a message with its tool calls,
+markdown, code blocks, copy chips) is a *tree* of these: **~16 text renderables
+≈ 47 native handles ≈ ~250–340KB of RSS, per row.** This is the number that
+drives everything. 1,400 mounted rows × 47 handles = table full = the crash we
+root-caused last week.
+
+**Yoga (the layout engine, WASM).** Every renderable also has a Yoga node —
+Yoga is the flexbox calculator that decides where boxes go. OpenTUI ships it
+compiled to **WebAssembly**, and WASM has a brutal property: its memory can
+**grow but never shrink** back to the OS. So the peak number of
+*simultaneously-mounted* renderables sets a high-water mark you pay **forever**,
+even after everything is destroyed. (Fun fact from this week's forensics: we
+spent two days believing Ink had this disease. It doesn't — our Ink fork swapped
+Yoga-WASM for a plain TypeScript port at fork creation. **We** are the ones
+running layout in WASM. The accusation was true; we just had the defendant
+wrong.)
+
+**Solid (the view framework).** Renders each store message into a row via
+``. The property we exploit: Solid mounts/unmounts *surgically* — remove a
+row from what the component returns and Solid destroys exactly that row's
+renderables (returning its handles and freeing its Yoga nodes), touching
+nothing else. No virtual-DOM diffing, no collateral re-renders.
+
+**V8 (the JavaScript engine) + the store.** The store keeps every message as JS
+strings/objects. V8's garbage collector is *lazy by design*: with the default
+8GB ceiling we launch with, it sees no reason to clean up aggressively, so RSS
+includes a lot of "collectible but not yet collected" garbage. Cheap to fix,
+worth real MB (measured below).
+
+**The scrollbox.** One detail that fooled everyone at some point:
+`viewportCulling` (on by default) skips *drawing* offscreen rows — but they stay
+fully **mounted**: handles held, Yoga nodes alive, memory paid. Culling saves
+paint time, not memory. That misunderstanding is half the reason the "rolling
+store cap" was expected to be enough, and wasn't.
+
+## 2. Why it was 686MB
+
+Simple arithmetic. The old TUI mounted **every message in the store** as a full
+renderable tree. 2,000 messages × ~16 renderables × (handles + Yoga nodes +
+text buffers + V8 objects) ≈ 670–690MB, growing ~300MB per 1,000 messages. And
+at ~1,400 rows the handle table filled: first a hard crash (exit 7), then —
+after our containment fix — survival with **unstyled text** past that point,
+plus a cap clamped from 3,000 rows down to 1,000 as the price of not crashing.
+
+Ink, meanwhile, sat at ~234MB at the same workload, because Ink only ever
+mounts the rows near your viewport (~84–400 live nodes). Its memory is the
+*data* plus some caches — not the *view*.
+
+## 3. The decisions, in order
+
+### Decision 1: virtualize the view, don't starve the store
+
+Two ways to cut view memory: keep fewer messages (opencode's answer — they keep
+100 and delete the rest from memory; transcript truth lives on their server), or
+keep all messages but only *materialize* the ones near the viewport. You vetoed
+the first (your p90 session is 182 messages — a 100-row store truncates normal
+sessions), so: **windowing**. Notably the OpenTUI devs confirmed this week that
+framework-level virtualization is the intended path — the engine doesn't ship
+it out of the box, and opencode never built it. We did.
+
+### Decision 2: exact heights, recorded at unmount — never estimates in your face
+
+This is the load-bearing idea, and it's where we beat Ink at its own game.
+
+The hard problem of any virtualized list: an unmounted row still needs to
+occupy its correct *height*, or the scrollbar lies and content jumps. Ink
+solves it by **guessing** heights and correcting after measurement — those
+corrections are precisely the 83–101ms scroll stutters you hate. You explicitly
+vetoed "estimate-correction jank" as a model.
+
+Our advantage: OpenTUI lays out with real, queryable heights. So when a row
+scrolls out of the window, we record its **exact laid-out height** (an
+`onSizeChange` hook fires inside layout, pre-paint) and replace the row with an
+empty `` — a **spacer**: one Yoga node, zero text
+buffers, zero native handles. Think of a bookshelf where books you're not
+reading are swapped for cardboard sleeves cut to *exactly* the book's
+thickness: the shelf never shifts, and you can't tell from across the room.
+
+The window is your viewport ± one viewport of margin (plus hysteresis so it
+doesn't thrash at the edges). Scroll near a spacer and the real row remounts —
+at the recorded height, so nothing moves.
+
+And one **law**, written into the code as `correctionIsLegal`: a spacer's
+height may only ever be corrected where you *cannot see it* — fully above the
+viewport (with the scroll position compensated in the same frame, so the world
+doesn't move) or fully below it. A correction that would shift visible content
+is forbidden, structurally. Jank isn't tuned down; it's outlawed.
+
+### Decision 3 (the S2 insight): adjudicate on *append*, not just on scroll
+
+S1 alone got 686 → 518MB. Why not more? Because of *when* windowing decided.
+S1 re-decided the window when you **scrolled**. But during a streaming burst —
+an agent turn dumping hundreds of rows — you don't scroll; rows arrive, each
+mounting fully, and only get demoted later. That transient pile-up is mostly
+invisible in steady-state numbers… except for Yoga-WASM, where **the transient
+peak is permanent** (memory never shrinks). The burst was quietly ratcheting
+the floor.
+
+S2 makes the window recompute on **transcript growth**: while you're pinned at
+the bottom, the window anchors to the content *bottom*, so a row that falls
+more than a margin behind the live edge becomes a spacer the moment it's
+measured — not whenever you next scroll. Measured result: across a 1,500-row
+burst, the peak number of simultaneously-mounted rows is **31**.
+
+Same trick for **resume**: opening a 2,000-message session used to mount all of
+it (transient peak again — paid forever). Now resume mounts only the bottom
+window; everything above starts as spacers using a line-count estimate, and an
+idle-time "measure march" quietly mounts ten rows at a time near the window
+edge, records their true heights, and swaps them back — all outside the
+viewport, all invisible by the law above.
+
+### Decision 4: rows that must never be windowed
+
+Windowing has to know what it's not allowed to touch:
+- **Streaming rows** — the native markdown renderer streams incrementally;
+ unmounting mid-stream would restart it visibly.
+- **The bottom 30 rows** — the region you actually live in.
+- **Rows under a mouse selection** — the review caught that a lingering
+ highlight originally froze windowing *forever* (memory regrowing silently).
+ Fixed: only an active drag pauses swaps, and selected rows get pinned, so
+ copy is byte-exact while everything else keeps windowing.
+
+### Decision 5: give back the scrollback (cap 1,000 → 3,000)
+
+The 1,000-row clamp existed only because mounted-rows == stored-rows and the
+handle table dies at ~1,400. With windowing, mounted ≈ 31 regardless of store
+size — so the cap went back to the originally-shipped 3,000. It's
+windowing-aware: the `HERMES_TUI_WINDOWING=0` escape hatch (which mounts
+everything again) keeps the safe 1,000.
+
+### Decision 6 (measured, not yet shipped as default): right-size the V8 heap
+
+Running the windowed TUI with a 512MB heap ceiling instead of 8GB forced V8 to
+actually collect: another −90MB with zero latency cost. That's queued as a
+launcher default change (~1GB), for both engines.
+
+## 4. The scoreboard
+
+At 2,000 messages (your real p99 session size — yes, we checked your DB:
+median session is 20 messages, p99 is 1,941):
+
+| | peak memory | scroll p99 (slowest 1-in-100) |
+|---|---|---|
+| OpenTUI before | 686MB | 16ms |
+| + S1 windowing | 518MB | 16ms |
+| + S2 append/resume windowing | **300–375MB** | **6ms** |
+| Ink (reference) | 229–246MB | ~100ms |
+
+At the **3,000-message stress** with the restored triple-size scrollback:
+**360MB, fully styled, scroll p99 8ms** — a workload that six days ago crashed
+the process, and three days ago survived only by dropping syntax colors.
+
+Scroll got *faster* because there are simply fewer live renderables to walk.
+The determinism gate stayed **byte-identical** — the windowed TUI's settled
+frame is provably the same pixels as before. And the live smoke (2,000-message
+session: full sweep to the top, resize storm, back to bottom) returned a frame
+pixel-identical to boot, with deep history fully syntax-highlighted — something
+the pre-windowing TUI literally could not do.
+
+## 5. What's honestly still open
+
+- The remaining ~60–120MB over Ink is mostly the **store's JS strings** and
+ process baseline — the view is no longer the problem. The structural fix is
+ the **thin renderer** (W1): bodies live in the Python gateway (which already
+ has them in SQLite); the TUI keeps ~300-byte stubs and fetches bodies only
+ for the window. That also fixes the class of problem neither engine handles
+ today: a single 10MB tool output.
+- Two accepted, documented limits: scrollbar-*jumping* deep into a freshly
+ resumed session can land on estimate-height rows that snap to true height as
+ they enter view (normal scrolling doesn't — the margin pre-measures; the idle
+ march erodes the exposure over time), and a tool you expanded, scrolled far
+ away from, then returned to will have re-collapsed (state is component-local;
+ hoisting it to the store is queued).
+- Everything is behind `HERMES_TUI_WINDOWING` (default on, `0` = bit-exact old
+ behavior) — a one-env escape hatch if anything feels off in real use.
+
+*Where to verify: `bench/results/` (every number above), the design+gates doc
+`docs/plans/opentui-transcript-windowing.md`, tests in
+`ui-opentui/src/test/window.test.ts` and `transcriptWindow.test.tsx` (the
+zero-jank invariants are literal assertions: identical scrollHeight windowed
+vs not, byte-stable frames across corrections).*
diff --git a/docs/opentui-upstream-alignment.md b/docs/opentui-upstream-alignment.md
new file mode 100644
index 00000000000..3a5dd1c5510
--- /dev/null
+++ b/docs/opentui-upstream-alignment.md
@@ -0,0 +1,73 @@
+# Upstream alignment — how we inherit OpenTUI's performance work for free
+
+Context (maintainer, 2026-06-11): opencode's 100-message cap was a November-era
+performance workaround, since obsoleted; the **next OpenTUI version ships
+native yoga** (≥2× layout performance, more improvements building on it);
+opencode does not use virtualization.
+
+## The invariant that makes alignment free
+
+**We are forkless and public-API-only.** The windowing layer (S1+S2) drives the
+STOCK `` through documented surface only — `onSizeChange`,
+`setFrameCallback`, `scrollTop`/`viewport`/`scrollHeight`, Solid ``
+mount/unmount. Zero patches to `@opentui/core`. Every upstream release
+therefore drops in by bumping three pinned versions in `ui-opentui/package.json`
+(`@opentui/{core,keymap,solid}`, currently 0.4.0). Keep it that way: any new
+code that needs core behavior goes through a `boundary/` wrapper, never a
+patched dependency.
+
+## What native yoga changes for us (and what it doesn't)
+
+- **Kills the WASM ratchet** (grow-only linear memory → freeable native
+ allocations). This retro-justifies S2 less, but S2's append-time windowing
+ remains correct: transient mounted peaks still cost handles and RSS.
+- **Does NOT obsolete windowing.** The binding constraint is the 65,535-slot
+ native handle table: ~47 handles/row × 3,000 stored rows ≈ 141k handles —
+ over the table at ANY layout speed. Windowing is what makes the 3,000-row
+ scrollback possible; yoga's backend is irrelevant to that math.
+- **Makes windowing feel even better**: 2× layout = cheaper margin remounts =
+ smaller window margins viable and less exposure for the one accepted limit
+ (estimate-height snap under scrollbar jumps). After the bump, re-tune margin/
+ hysteresis against the scroll cell.
+
+## The shim ledger (delete-on-upstream-fix; all in `ui-opentui/src/boundary/`)
+
+| shim | what it papers over | delete when |
+|---|---|---|
+| `ffiSafe.ts` | u32 draw coords go negative under Node FFI (Bun silently wraps) — ERR_INVALID_ARG_VALUE loop | upstream clamps, or Node FFI path is officially supported |
+| `nativeHandles.ts` | SyntaxStyle exhaustion crashes mid-mount; degrade-to-unstyled | handle table widened (INDEX_BITS>16) or per-kind tables |
+| `renderer.ts` exit-signal guard | core 0.4.0 treats SIGPIPE (clipboard spawn) as an exit signal; its own uncaughtException handler allocates a handle and dies (exit-7 masking) | both fixed upstream |
+| `clipboard.ts` hardening | same SIGPIPE incident class | with the above |
+
+Each is (a) isolated, (b) inert if upstream fixes the behavior, (c) worth
+reporting upstream — four concrete, reproduced, root-caused issues. Filing them
+is the cheapest alignment lever we have: it converts our workarounds into
+upstream regression tests. (Needs glitch's go-ahead — public repo activity.)
+
+## The upgrade playbook (per upstream release)
+
+1. Branch `chore/opentui-X.Y.Z`, bump the three pins, `npm ci`.
+2. `npm run check` (648 tests; the windowing invariants — identical
+ scrollHeight ON/OFF, byte-stable frames across corrections — are literal
+ assertions and will catch behavioral drift).
+3. Bench acceptance, sequential: `--cell gate` (determinism digest; EXPECT a
+ new digest if upstream changed rendering — eyeball the frame, re-bless),
+ `--cell mem3000 --msgs 2000` + `--cell scroll --msgs 3000` vs current
+ numbers (300–375MB / p99 6–8ms), `--cell pipeline` (frame pacing ≥22fps).
+4. Shim audit: try each boundary shim OFF; delete the ones upstream fixed.
+5. Live tmux smoke (scroll sweep / resize / selection-copy), screenshots.
+6. Windowing re-tune if layout got faster: margins up or hysteresis down,
+ re-run scroll cell, keep p99 ≤ 17ms gate.
+
+The bench suite IS the upgrade contract — it's exactly the harness that lets
+us take every upstream improvement within a day of release, with proof.
+
+## Questions worth relaying to the maintainer
+
+1. Any plan to widen the 16-bit native handle table (or split per-kind)?
+ That's our hard ceiling, independent of yoga.
+2. Is the Node `--experimental-ffi` path on their support radar, or Bun-only?
+ (Native yoga adds new FFI surface; we run Node.)
+3. Would they take the windowing layer's core-agnostic pieces (exact-height
+ spacer pattern, correction-legality rule) as a documented recipe or
+ framework-level utility? We have it production-shaped with tests.
diff --git a/ui-opentui/.node-version b/ui-opentui/.node-version
new file mode 100644
index 00000000000..59c8fb19779
--- /dev/null
+++ b/ui-opentui/.node-version
@@ -0,0 +1 @@
+26.3
diff --git a/ui-opentui/README.md b/ui-opentui/README.md
new file mode 100644
index 00000000000..64d1a2f2041
--- /dev/null
+++ b/ui-opentui/README.md
@@ -0,0 +1,53 @@
+# ui-opentui — native OpenTUI engine for Hermes
+
+Solid + `@opentui/core` over Node FFI. Ink (`ui-tui/`) is the shipping default;
+this is the experimental engine (draft PR #42922).
+
+## Node 26 setup (required; will not touch your other projects)
+
+This package needs **Node ≥ 26.3** (`--experimental-ffi` floor). Everything
+else on this machine/repo can keep whatever Node it already uses — pin 26 to
+this directory only:
+
+```sh
+# 1. install fnm (skip if you have it; nvm/mise work too — see below)
+curl -fsSL https://fnm.vercel.app/install | bash
+# add to ~/.zshrc (or bashrc): eval "$(fnm env --use-on-cd --shell zsh)"
+
+# 2. install Node 26 SIDE BY SIDE (does NOT change your default)
+fnm install 26
+
+# 3. done — this directory has a .node-version (26.3), so `cd ui-opentui`
+# auto-switches to 26 and leaving switches back. Do NOT run `fnm default 26`.
+node -v # v26.x here; your old version everywhere else
+```
+
+No shell integration wanted (CI, scripts, one-off): `fnm exec --using 26 -- node ...`
+or invoke the absolute binary (`~/.local/share/fnm/node-versions/v26.*/installation/bin/node`).
+mise users: `mise use node@26` in this directory. nvm users: `nvm install 26`,
+plus an `.nvmrc` shim (`echo 26 > .nvmrc`) if you rely on auto-switching.
+
+### Gotchas
+
+- **Native modules are ABI-locked.** A `node_modules` installed under Node
+ 20/22 will not load under 26 (and vice versa) — run `npm ci` (or
+ `npm rebuild`) after switching versions. Same applies to `bench/`'s node-pty.
+- **Global npm packages don't follow** between versions (per-version prefix);
+ reinstall the few you need, or don't use globals.
+- **Editor terminals** (Zed/VS Code) need the `fnm env` line in your shell rc;
+ the `.node-version` auto-switch then covers any shell that cd's here.
+- **Never run this package with bun** — the FFI seam and the Solid/JSX build
+ are Node-path only here.
+- `package.json` declares `engines.node >= 26.3`, so a wrong-Node `npm ci`
+ warns immediately.
+
+## Build & run
+
+```sh
+node scripts/build.mjs
+HERMES_TUI_MOUSE=1 node --experimental-ffi --no-warnings dist/main.js
+```
+
+Gates: `npm run check` (typecheck + lint + tests). Memory/perf benchmarks live
+in `../bench/` (see its README). Transcript windowing (memory architecture) is
+documented in `../docs/plans/opentui-transcript-windowing.md`.
diff --git a/ui-opentui/eslint.config.mjs b/ui-opentui/eslint.config.mjs
index cfe89bf821b..9f4dcba7f09 100644
--- a/ui-opentui/eslint.config.mjs
+++ b/ui-opentui/eslint.config.mjs
@@ -4,9 +4,9 @@ import unusedImports from "eslint-plugin-unused-imports"
export default tseslint.config(
{
- // .bench/ is a build artifact of the bench suite's `nodes` cell
- // (bench/run.mjs builds scripts/mem-bench.tsx into it) — never lint it.
- ignores: ["node_modules/**", "dist/**", ".bench/**", ".repos/**", "*.frame.txt", "*.ansi"],
+ // .bench/ and .demo/ are build artifacts (bench `nodes` cell and the
+ // smoke demo: `node scripts/build.mjs scripts/demo.tsx .demo`) — never lint.
+ ignores: ["node_modules/**", "dist/**", ".bench/**", ".demo/**", ".repos/**", "*.frame.txt", "*.ansi"],
},
js.configs.recommended,
...tseslint.configs.recommendedTypeChecked,
diff --git a/ui-opentui/package.json b/ui-opentui/package.json
index aa4af6bef91..21604f0bbe4 100644
--- a/ui-opentui/package.json
+++ b/ui-opentui/package.json
@@ -3,6 +3,9 @@
"version": "0.0.0",
"private": true,
"type": "module",
+ "engines": {
+ "node": ">=26.3"
+ },
"description": "Native OpenTUI engine for Hermes (Solid + Effect-at-boundary, from scratch). Ink (ui-tui/) stays the shipping default.",
"scripts": {
"type-check": "tsc --noEmit",
diff --git a/ui-opentui/src/boundary/memlog.ts b/ui-opentui/src/boundary/memlog.ts
new file mode 100644
index 00000000000..0f832f5e82f
--- /dev/null
+++ b/ui-opentui/src/boundary/memlog.ts
@@ -0,0 +1,91 @@
+/**
+ * memlog — in-process 1Hz memory self-sampling to NDJSON.
+ *
+ * The fleet-monitoring answer to "attach live-attach.sh to all 5–10 of my
+ * sessions": instead of an external watcher chasing pids, every TUI session
+ * logs its OWN samples when enabled, keyed by pid + boot time, into
+ * `~/.hermes/logs/memwatch/`. Aggregate across sessions with
+ * `bench/memwatch-report.mjs`.
+ *
+ * Gating (docs/opentui-env-flags.md): `HERMES_TUI_MEMLOG` — defaults to the
+ * `HERMES_TUI_DIAGNOSTICS` master switch, individually overridable either way.
+ * One `export HERMES_TUI_DIAGNOSTICS=1` in a dev's shell rc therefore covers
+ * every session they ever start; regular users write nothing.
+ *
+ * Cost when on: one `process.memoryUsage()` + one short append per second
+ * (~60 bytes/s, ~5MB/day across ten busy sessions). The interval is unref'd —
+ * it never keeps the process alive. Every failure path disables the logger
+ * silently (diagnostics must never break the TUI). Retention: files older
+ * than 14 days are pruned at start, best-effort.
+ *
+ * Sample shape (one JSON object per line):
+ * { t, rss_kb, heap_used_kb, external_kb, mounted, peak_mounted }
+ * `mounted`/`peak_mounted` come from the windowing DEV counters
+ * (logic/window.ts) — they update whenever windowing is active, independent
+ * of the WINDOW_STATS exposure flag.
+ */
+import { appendFileSync, mkdirSync, readdirSync, statSync, unlinkSync } from 'node:fs'
+import { homedir } from 'node:os'
+import { join } from 'node:path'
+
+import { diagnosticsEnabled, envFlag } from '../logic/env.ts'
+import { windowRowStats } from '../logic/window.ts'
+
+const RETENTION_DAYS = 14
+const SAMPLE_MS = 1000
+
+function memwatchDir(): string {
+ const home = process.env.HERMES_HOME?.trim()
+ const base = home && home.length > 0 ? home : join(homedir(), '.hermes')
+ return join(base, 'logs', 'memwatch')
+}
+
+function pruneOld(dir: string): void {
+ const cutoff = Date.now() - RETENTION_DAYS * 24 * 3600 * 1000
+ try {
+ for (const name of readdirSync(dir)) {
+ if (!name.endsWith('.jsonl')) continue
+ const p = join(dir, name)
+ try {
+ if (statSync(p).mtimeMs < cutoff) unlinkSync(p)
+ } catch {
+ /* best-effort */
+ }
+ }
+ } catch {
+ /* best-effort */
+ }
+}
+
+/** Start the self-sampler (no-op unless enabled). Returns a stop function. */
+export function startMemlog(): () => void {
+ if (!envFlag(process.env.HERMES_TUI_MEMLOG, diagnosticsEnabled())) return () => {}
+ try {
+ const dir = memwatchDir()
+ mkdirSync(dir, { recursive: true })
+ pruneOld(dir)
+ const boot = new Date().toISOString().replace(/[:.]/g, '').slice(0, 15)
+ const file = join(dir, `${boot}-${process.pid}.jsonl`)
+ const timer = setInterval(() => {
+ try {
+ const m = process.memoryUsage()
+ const w = windowRowStats()
+ const line = JSON.stringify({
+ t: Math.floor(Date.now() / 1000),
+ rss_kb: Math.floor(m.rss / 1024),
+ heap_used_kb: Math.floor(m.heapUsed / 1024),
+ external_kb: Math.floor(m.external / 1024),
+ mounted: w.mounted,
+ peak_mounted: w.peakMounted
+ })
+ appendFileSync(file, line + '\n')
+ } catch {
+ clearInterval(timer) // a failing diagnostic must not retry forever
+ }
+ }, SAMPLE_MS)
+ timer.unref?.()
+ return () => clearInterval(timer)
+ } catch {
+ return () => {}
+ }
+}
diff --git a/ui-opentui/src/entry/main.tsx b/ui-opentui/src/entry/main.tsx
index a57071520f0..6578da969e1 100644
--- a/ui-opentui/src/entry/main.tsx
+++ b/ui-opentui/src/entry/main.tsx
@@ -29,6 +29,7 @@ import { readClipboardImage, writeClipboard } from '../boundary/clipboard.ts'
import { GatewayService, type GatewayServiceShape } from '../boundary/gateway/GatewayService.ts'
import { liveGatewayLayer } from '../boundary/gateway/liveGateway.ts'
import { getLog } from '../boundary/log.ts'
+import { startMemlog } from '../boundary/memlog.ts'
import { acquireRenderer } from '../boundary/renderer.ts'
import { makeAppLayer } from '../boundary/runtime.ts'
import { nthAssistantResponse } from '../logic/copy.ts'
@@ -381,6 +382,10 @@ export const run = Effect.fn('Tui.run')(function* (input: TuiInput) {
onCtrlC,
onCopySelection
})
+ // Fleet memory self-sampling (HERMES_TUI_MEMLOG / diagnostics master
+ // switch — boundary/memlog.ts). Scoped acquire→release like the renderer.
+ const stopMemlog = startMemlog()
+ yield* Effect.addFinalizer(() => Effect.sync(stopMemlog))
doQuit = () => {
if (!renderer.isDestroyed) renderer.destroy()
}
diff --git a/ui-opentui/src/logic/env.ts b/ui-opentui/src/logic/env.ts
index 362d2704503..e43861e5801 100644
--- a/ui-opentui/src/logic/env.ts
+++ b/ui-opentui/src/logic/env.ts
@@ -15,6 +15,21 @@ export function envFlag(value: string | undefined, fallback: boolean): boolean {
return fallback
}
+/**
+ * The diagnostics master switch — `HERMES_TUI_DIAGNOSTICS` (default OFF).
+ *
+ * Gates the developer/profiling surface a regular user should never trip
+ * over: the diagnostic slash commands (`/mem`, `/heapdump`) and the default
+ * for `HERMES_TUI_WINDOW_STATS` (which can still be set individually). It is
+ * an enable switch, not a secret: anyone CAN set it (support flows say
+ * "relaunch with HERMES_TUI_DIAGNOSTICS=1"), it just keeps the day-to-day
+ * surface clean. Read per call so tests (and long-lived processes whose
+ * wrapper mutates env before launch) see the live value.
+ */
+export function diagnosticsEnabled(): boolean {
+ return envFlag(process.env.HERMES_TUI_DIAGNOSTICS, false)
+}
+
/**
* Parse `HERMES_TUI_TOOL_OUTPUT_LINES` (a TUI-only env var — deliberately NOT
* a config.yaml knob): how many output lines an expanded tool body shows.
diff --git a/ui-opentui/src/logic/slash.ts b/ui-opentui/src/logic/slash.ts
index 1b69ed8e059..f31b6f7a1f6 100644
--- a/ui-opentui/src/logic/slash.ts
+++ b/ui-opentui/src/logic/slash.ts
@@ -11,6 +11,7 @@
* (exec/plugin → system · alias → re-dispatch · skill/send → submit a turn ·
* prefill → notice). Long output routes to the pager (Phase 5a).
*/
+import { diagnosticsEnabled } from './env.ts'
import { DETAILS_SECTIONS, DETAILS_USAGE, type DetailsMode, nextDetailsMode, parseDetailsMode } from './details.ts'
import { formatBytes, memReport, performHeapdump } from './diagnostics.ts'
import { formatSpawnTree, formatSpawnTreeList, readSpawnTreeEntries } from './replay.ts'
@@ -149,7 +150,16 @@ function present(ctx: SlashContext, title: string, text: string): void {
else ctx.pushSystem(text)
}
-const CLIENT_HELP = [
+/** Process-diagnostic commands — hidden behind `HERMES_TUI_DIAGNOSTICS`
+ * (logic/env.ts). Regular users never see them; support flows enable them
+ * with one env var. Keep this set in sync with the `(diag)` lines below.
+ * DESIGN ASSUMPTION (review 2026-06-12): these stay CLIENT-ONLY. Completion
+ * is gateway-driven and hides them only because the gateway doesn't know
+ * them — adding a server command with one of these names requires gating it
+ * gateway-side too (the early return below would shadow, not hide, it). */
+const DIAGNOSTIC_COMMANDS = new Set(['mem', 'heapdump'])
+
+const CLIENT_HELP_LINES = [
'/help — list commands',
'/model [name] — switch model (picker if bare)',
'/copy [n] — copy the last (or n-th) response',
@@ -160,12 +170,17 @@ const CLIENT_HELP = [
'/compact [on|off|toggle] — compact transcript spacing',
'/details [hidden|collapsed|expanded|cycle] — tool/reasoning detail',
'/replay [n|path] — inspect an archived spawn tree',
- '/mem — live memory stats',
- '/heapdump — write a V8 heap snapshot',
+ '/mem — live memory stats (diag)',
+ '/heapdump — write a V8 heap snapshot (diag)',
'/logs — recent engine log lines',
'/quit, /exit — quit',
'(other /commands run on the gateway)'
-].join('\n')
+]
+
+function clientHelp(): string {
+ const lines = diagnosticsEnabled() ? CLIENT_HELP_LINES : CLIENT_HELP_LINES.filter(l => !l.includes('(diag)'))
+ return lines.join('\n')
+}
type ClientHandler = (arg: string, ctx: SlashContext) => void | Promise
@@ -631,9 +646,9 @@ const CLIENT: Record = {
// Prefer the live catalog; fall back to the client list if it's unavailable.
try {
const cat = await ctx.request('commands.catalog', {})
- ctx.pushSystem(renderCatalog(cat) || CLIENT_HELP)
+ ctx.pushSystem(renderCatalog(cat) || clientHelp())
} catch {
- ctx.pushSystem(CLIENT_HELP)
+ ctx.pushSystem(clientHelp())
}
},
logs: (_arg, ctx) => ctx.openPager('Logs', ctx.logTail().join('\n') || '(log empty)'),
@@ -643,7 +658,8 @@ const CLIENT: Record = {
/** The registered client-command names (catalog introspection — tests/menus). */
export function clientCommandNames(): string[] {
- return Object.keys(CLIENT).sort()
+ const names = Object.keys(CLIENT)
+ return (diagnosticsEnabled() ? names : names.filter(n => !DIAGNOSTIC_COMMANDS.has(n))).sort()
}
/** Render the gateway `commands.catalog` into a help block (loose-typed read).
@@ -701,6 +717,12 @@ export async function dispatchSlash(input: string, ctx: SlashContext): Promise {
if (options?.uncappedFixture) return Number.MAX_SAFE_INTEGER
+ const windowing = envFlag(process.env.HERMES_TUI_WINDOWING, true)
+ const ceiling = windowing ? WINDOWED_MAX_ROWS : HANDLE_SAFE_MAX_ROWS
const raw = Number.parseInt(process.env.HERMES_TUI_MAX_MESSAGES ?? '', 10)
- const requested = Number.isFinite(raw) && raw > 0 ? raw : HANDLE_SAFE_MAX_ROWS
- return Math.min(requested, HANDLE_SAFE_MAX_ROWS)
+ const requested = Number.isFinite(raw) && raw > 0 ? raw : ceiling
+ return Math.min(requested, ceiling)
})()
const [state, setState] = createStore({
@@ -990,6 +1000,11 @@ export function createSessionStore(options?: SessionStoreOptions) {
// briefly hands the full fetched history to . Pre-slicing guarantees
// resuming ANY session mounts at most MESSAGE_CAP rows. (Events buffered
// across the resume RPC, replayed below, self-cap via capMessages per push.)
+ // With windowing ON (HERMES_TUI_WINDOWING — view/transcript.tsx S2) the
+ // view mounts only the BOTTOM window of this snapshot anyway: rows created
+ // deep in a bulk replace start as line-count-estimate spacers and are
+ // measured lazily. The pre-slice still bounds the windowing-OFF path and
+ // the store's own JS retention.
const capped = snapshot.length > MESSAGE_CAP ? snapshot.slice(-MESSAGE_CAP) : snapshot
setState('messages', capped)
// A resume is a fresh view → SET (not accumulate) the dropped count to what the
diff --git a/ui-opentui/src/logic/window.ts b/ui-opentui/src/logic/window.ts
new file mode 100644
index 00000000000..e16f6a04824
--- /dev/null
+++ b/ui-opentui/src/logic/window.ts
@@ -0,0 +1,263 @@
+/**
+ * window — pure transcript-windowing math (slices S1+S2 of docs/plans/
+ * opentui-transcript-windowing.md, issue #27). The view (view/transcript.tsx)
+ * replaces out-of-window rows with EXACT-HEIGHT empty boxes (1 yoga node, no
+ * text buffers / native handles), so the mounted set stays ~3 viewports of
+ * rows regardless of transcript length. This module is the testable core:
+ *
+ * - `computeWindow` — which row keys must be mounted for a given scrollTop:
+ * rows intersecting [scrollTop − margin, scrollTop + viewport + margin)
+ * over CUMULATIVE row heights (exact recorded heights; a line-count
+ * estimate stands in for never-measured rows), plus the never-window rows
+ * (streaming/live) and the bottom K rows (sticky-bottom region). With
+ * `pinnedBottom` (S2) the window anchors to the BOTTOM of the content
+ * instead of `scrollTop`: during burst appends / a resume snapshot the
+ * sticky pin will land at the new bottom, but layout (and therefore
+ * scrollTop) lags the store — anchoring to the cumulative content bottom
+ * adjudicates appended rows immediately instead of one frame late.
+ * - `shouldRecompute` — the hysteresis gate (≥ ¼ viewport via
+ * `hysteresisFor`): a computed window only changes once scrollTop has
+ * moved ≥ hysteresis from the anchor it was computed at, so swaps don't
+ * thrash at window edges.
+ * - `correctionIsLegal` — the jank rule for spacer-height corrections:
+ * a correction may only touch rows fully ABOVE the viewport (the caller
+ * compensates scrollTop in the same frame — automatic when bottom-anchored
+ * via the sticky pin) or fully BELOW it (invisible by definition). Anything
+ * intersecting the viewport would visibly move content: forbidden.
+ * - `estimateMessageHeight` — the cheap line-count estimate for rows that
+ * have never been measured (resume history above the viewport). A wrong
+ * estimate is fixed by remount (scrolling near) or by the S2 idle measure
+ * pass, both governed by the jank rule.
+ * - `edgeMeasureBatch` (S2 — design §4, the SIMPLE choice): @opentui/core
+ * cannot lay a renderable out without parenting it into the live tree
+ * (layout is the tree's Yoga pass), so true offscreen measurement isn't
+ * available. Instead the idle pass mounts a small batch of never-measured
+ * rows nearest the bottom window edge — they are the next to be seen when
+ * the user scrolls back — records their exact heights, and lets the next
+ * window recompute swap them back to (now exact) spacers. Estimates far
+ * from the window stay estimates until the march reaches them.
+ * - `windowRowStats` — a DEV counter (current / peak simultaneously-mounted
+ * real rows) the integration tests assert against and the bench can read
+ * (transcript.tsx exposes it on globalThis behind HERMES_TUI_WINDOW_STATS).
+ */
+import type { Message, Part } from './store.ts'
+
+/** One transcript row as the window calc sees it. */
+export interface WindowRow {
+ readonly key: K
+ /** Exact recorded height (the row wrapper's last onSizeChange measurement,
+ * margins included) — or null when the row has never been measured. */
+ readonly height: number | null
+ /** Line-count estimate used while `height` is null (see estimateMessageHeight). */
+ readonly estimate?: number | undefined
+ /** Always mounted regardless of the window (streaming/live rows — a remount
+ * would restart native markdown streaming). */
+ readonly neverWindow: boolean
+}
+
+export interface WindowParams {
+ readonly rows: readonly WindowRow[]
+ readonly scrollTop: number
+ readonly viewportHeight: number
+ /** Mounted band kept above/below the viewport (design: 1 viewport each side). */
+ readonly margin: number
+ /** Stand-in height for null-height rows without their own estimate. */
+ readonly fallbackHeight?: number
+ /** The bottom K rows are always mounted (sticky-bottom region). */
+ readonly bottomK?: number
+ /** Anchor the window to the BOTTOM of the cumulative content instead of
+ * `scrollTop` (S2 append-time adjudication): while the view is pinned to
+ * the bottom, appended rows extend the content BELOW the last laid-out
+ * scrollTop — the sticky pin only catches up at the next layout pass.
+ * Anchoring to the content bottom adjudicates those rows immediately
+ * (new in-window rows mount, rows pushed past the margin become spacers)
+ * without waiting a frame. */
+ readonly pinnedBottom?: boolean
+}
+
+export interface WindowResult {
+ /** Row keys that must be mounted; everything else renders as a spacer. */
+ readonly mounted: ReadonlySet
+ /** The scrollTop this window was computed at — the next hysteresis anchor. */
+ readonly anchor: number
+}
+
+/** Default stand-in for a null-height row with no estimate (≈ a short row). */
+export const DEFAULT_FALLBACK_HEIGHT = 2
+
+/** Ceiling on a single row's line-count estimate — a pathological wall of text
+ * must not make the never-mounted region look kilometers tall. */
+const ESTIMATE_MAX_LINES = 500
+
+/** Hysteresis for the window recompute: ≥ ¼ viewport (design rule), never 0. */
+export function hysteresisFor(viewportHeight: number): number {
+ return Math.max(1, Math.ceil(viewportHeight / 4))
+}
+
+/** Whether scrollTop has moved far enough from the last computation anchor to
+ * justify a new window (no anchor yet → always). */
+export function shouldRecompute(scrollTop: number, anchor: number | null, hysteresis: number): boolean {
+ if (anchor === null) return true
+ return Math.abs(scrollTop - anchor) >= hysteresis
+}
+
+/** Compute the set of row keys that must be mounted for this scroll position. */
+export function computeWindow(params: WindowParams): WindowResult {
+ const fallback = params.fallbackHeight ?? DEFAULT_FALLBACK_HEIGHT
+ const bottomK = params.bottomK ?? 0
+ const heightOf = (r: WindowRow): number => r.height ?? r.estimate ?? fallback
+ // pinnedBottom: the effective scrollTop is where the sticky pin will land —
+ // the cumulative content bottom minus one viewport (clamped at 0).
+ let effectiveTop = params.scrollTop
+ if (params.pinnedBottom) {
+ let contentHeight = 0
+ for (const r of params.rows) contentHeight += heightOf(r)
+ effectiveTop = Math.max(0, contentHeight - params.viewportHeight)
+ }
+ const windowStart = effectiveTop - params.margin
+ const windowEnd = effectiveTop + params.viewportHeight + params.margin
+ const total = params.rows.length
+ const mounted = new Set()
+ let top = 0
+ let index = 0
+ for (const r of params.rows) {
+ const bottom = top + heightOf(r)
+ // half-open intersection: a row merely touching a window edge stays out.
+ const intersects = bottom > windowStart && top < windowEnd
+ if (intersects || r.neverWindow || index >= total - bottomK) mounted.add(r.key)
+ top = bottom
+ index++
+ }
+ return { mounted, anchor: effectiveTop }
+}
+
+/** Rows the S2 idle measure pass should mount next: up to `batch` never-
+ * measured, not-currently-mounted, windowable rows, NEAREST THE BOTTOM first
+ * (the bottom window edge is where a scroll-back enters history, so these are
+ * the next rows to be seen; the march then proceeds upward over idle pulses).
+ * Never-window rows are excluded — they are always mounted anyway. */
+export function edgeMeasureBatch(rows: readonly WindowRow[], mounted: ReadonlySet, batch: number): K[] {
+ const out: K[] = []
+ for (let i = rows.length - 1; i >= 0 && out.length < batch; i--) {
+ const r = rows[i]
+ if (!r || r.height !== null || r.neverWindow || mounted.has(r.key)) continue
+ out.push(r.key)
+ }
+ return out
+}
+
+/** Default idle delay before a lazy measure pulse (design §4): no appends, no
+ * scroll movement, no running turn for this long → mount one small batch. */
+export const DEFAULT_MEASURE_IDLE_MS = 1000
+
+/** Parse `HERMES_TUI_WINDOW_IDLE_MS` (TUI-only DEV/test knob): the idle delay
+ * before a lazy measure pulse. A non-negative integer → that delay (0 = pulse
+ * on every idle frame — the headless tests use this to make pulses
+ * deterministic); unset/garbage → DEFAULT_MEASURE_IDLE_MS. */
+export function measureIdleDelayMs(value: string | undefined): number {
+ const v = value?.trim() ?? ''
+ if (!/^\d+$/.test(v)) return DEFAULT_MEASURE_IDLE_MS
+ return Number.parseInt(v, 10)
+}
+
+// ── DEV counter: simultaneously-mounted real rows (current + peak) ────────
+// Two ints, always maintained (the cost is negligible); the integration tests
+// assert `peakMounted` stays bounded during bursts/resume, and transcript.tsx
+// exposes the live object on globalThis when HERMES_TUI_WINDOW_STATS is set so
+// the bench can sample it. One transcript per process in practice; tests that
+// mount several reset between phases.
+export interface WindowRowStats {
+ mounted: number
+ peakMounted: number
+}
+
+const rowStats: WindowRowStats = { mounted: 0, peakMounted: 0 }
+
+/** The live stats object (mutated in place — safe to hold a reference). */
+export function windowRowStats(): Readonly {
+ return rowStats
+}
+
+export function noteRowMounted(): void {
+ rowStats.mounted++
+ if (rowStats.mounted > rowStats.peakMounted) rowStats.peakMounted = rowStats.mounted
+}
+
+export function noteRowUnmounted(): void {
+ rowStats.mounted--
+}
+
+/** Reset the peak to the CURRENT mounted count (rows still live stay counted). */
+export function resetWindowRowStats(): void {
+ rowStats.peakMounted = rowStats.mounted
+}
+
+/**
+ * The jank rule: may a spacer-height correction for the row spanning
+ * [rowTop, rowBottom) be applied at this scroll position without visibly
+ * moving content?
+ *
+ * - Fully BELOW the viewport → legal (invisible by definition).
+ * - Fully ABOVE the viewport → legal, PROVIDED the caller compensates
+ * scrollTop by the height delta in the same frame. When `atBottom`
+ * (sticky-bottom pinned) the pin performs that compensation automatically
+ * (bottom-anchored ⇒ zero visual movement); legality is the same either
+ * way — the flag documents which side owes the compensation.
+ * - Intersecting the viewport → forbidden; defer until the row scrolls out
+ * or is remounted for view.
+ */
+export function correctionIsLegal(
+ rowTop: number,
+ rowBottom: number,
+ scrollTop: number,
+ viewportHeight: number,
+ _atBottom: boolean
+): boolean {
+ if (rowTop >= scrollTop + viewportHeight) return true // fully below the viewport
+ if (rowBottom <= scrollTop) return true // fully above — compensate scrollTop in the same frame
+ return false
+}
+
+/** Rendered line count of a text block (1-based; empty text still occupies a row). */
+function lineCount(text: string): number {
+ if (!text) return 1
+ let lines = 1
+ for (let i = 0; i < text.length; i++) if (text.charCodeAt(i) === 10) lines++
+ return lines
+}
+
+/** Estimated rendered lines of one part: text → its line count (view strips
+ * leading/trailing blanks — mirror that) + the settled block's `⧉ copy` chip
+ * line when `chips`; tool/reasoning → 1 collapsed header line (the default
+ * render for settled, never-mounted history). */
+function partLines(part: Part, chips: boolean): number {
+ if (part.type === 'text') return lineCount(part.text.replace(/^\n+|\n+$/g, '')) + (chips ? 1 : 0)
+ return 1 // collapsed tool/reasoning header line
+}
+
+/**
+ * Cheap line-count height estimate for a row that has never been measured
+ * (resume history above the viewport). Deliberately ignores soft wrapping
+ * — it is a placeholder until the row is actually mounted/measured, and a
+ * wrong value may only be corrected per `correctionIsLegal` (or left until
+ * remount). `spacing` is the row's turnSpacing margins; `gap` the inter-part
+ * blank line (0 in /compact); `chips` mirrors the view's per-block `⧉ copy`
+ * line (settled non-system rows outside /compact — messageLine.tsx CopyChip).
+ */
+export function estimateMessageHeight(
+ message: Pick & { readonly role?: Message['role'] },
+ spacing: { readonly top: number; readonly bottom: number },
+ gap: number,
+ chips = false
+): number {
+ const parts = message.parts
+ let content: number
+ if (parts && parts.length > 0) {
+ content = gap * (parts.length - 1)
+ for (const part of parts) content += partLines(part, chips)
+ } else {
+ content = lineCount(message.text)
+ if (chips && message.role !== undefined && message.role !== 'system' && message.text.trim()) content += 1
+ }
+ return Math.min(ESTIMATE_MAX_LINES, Math.max(1, content)) + spacing.top + spacing.bottom
+}
diff --git a/ui-opentui/src/test/lib/render.ts b/ui-opentui/src/test/lib/render.ts
index d4bd8638527..f0dfe613612 100644
--- a/ui-opentui/src/test/lib/render.ts
+++ b/ui-opentui/src/test/lib/render.ts
@@ -65,6 +65,8 @@ export interface RenderProbe {
readonly scroll: (x: number, y: number, direction: 'up' | 'down') => Promise
/** The mock keyboard (typeText / pressArrow / pressEnter / …) — pair with `settle()`. */
readonly keys: TestRendererSetup['mockInput']
+ /** The live test renderer (e.g. `getSelection()` assertions). */
+ readonly renderer: TestRendererSetup['renderer']
/** Run a render pass + flush so simulated input lands in the next `frame()`. */
readonly settle: () => Promise
readonly destroy: () => void
@@ -121,6 +123,7 @@ export async function renderProbe(
await setup.flush()
},
keys: setup.mockInput,
+ renderer: setup.renderer,
settle: async () => {
await setup.renderOnce()
await setup.flush()
diff --git a/ui-opentui/src/test/slash.test.ts b/ui-opentui/src/test/slash.test.ts
index 06b5c9745a4..8c99e649ca4 100644
--- a/ui-opentui/src/test/slash.test.ts
+++ b/ui-opentui/src/test/slash.test.ts
@@ -7,6 +7,7 @@ import { afterEach, describe, expect, test } from 'vitest'
import type { DetailsMode } from '../logic/details.ts'
import {
buildModelTabs,
+ clientCommandNames,
dispatchSlash,
mapCompletions,
parseSlash,
@@ -566,3 +567,40 @@ describe('dispatchSlash — server ladder', () => {
expect(p.system).toContain('done')
})
})
+
+describe('diagnostic command gating (HERMES_TUI_DIAGNOSTICS)', () => {
+ const KEY = 'HERMES_TUI_DIAGNOSTICS'
+ const prev = process.env[KEY]
+ afterEach(() => {
+ if (prev === undefined) delete process.env[KEY]
+ else process.env[KEY] = prev
+ })
+
+ test('OFF (default): /mem and /heapdump respond with the enable hint, not the command', async () => {
+ delete process.env[KEY]
+ const p = makeCtx(async () => ({}))
+ await dispatchSlash('/mem', p.ctx)
+ await dispatchSlash('/heapdump', p.ctx)
+ expect(p.system[0]).toContain('HERMES_TUI_DIAGNOSTICS=1')
+ expect(p.system[1]).toContain('HERMES_TUI_DIAGNOSTICS=1')
+ expect(p.calls).toHaveLength(0) // never reached the gateway ladder either
+ })
+
+ test('OFF: the diagnostic names are absent from clientCommandNames()', () => {
+ delete process.env[KEY]
+ const names = clientCommandNames()
+ expect(names).not.toContain('mem')
+ expect(names).not.toContain('heapdump')
+ expect(names).toContain('logs') // non-diagnostic neighbors stay
+ })
+
+ test('ON: /mem executes (live memory stats), names are listed', async () => {
+ process.env[KEY] = '1'
+ expect(clientCommandNames()).toContain('mem')
+ expect(clientCommandNames()).toContain('heapdump')
+ const p = makeCtx(async () => ({}))
+ await dispatchSlash('/mem', p.ctx)
+ const out = [...p.system, ...p.paged.map(x => x.text)].join('\n')
+ expect(out).toMatch(/rss|heap/i)
+ })
+})
diff --git a/ui-opentui/src/test/store.test.ts b/ui-opentui/src/test/store.test.ts
index a7cfad7f6f1..5216a392c54 100644
--- a/ui-opentui/src/test/store.test.ts
+++ b/ui-opentui/src/test/store.test.ts
@@ -506,10 +506,14 @@ describe('session store — resume hydrate (Phase 4b)', () => {
describe('session store — rolling message cap (bounds the Yoga node high-water mark)', () => {
const ENV_KEY = 'HERMES_TUI_MAX_MESSAGES'
+ const WINDOWING_KEY = 'HERMES_TUI_WINDOWING'
const prev = process.env[ENV_KEY]
+ const prevWindowing = process.env[WINDOWING_KEY]
afterEach(() => {
if (prev === undefined) delete process.env[ENV_KEY]
else process.env[ENV_KEY] = prev
+ if (prevWindowing === undefined) delete process.env[WINDOWING_KEY]
+ else process.env[WINDOWING_KEY] = prevWindowing
})
test('caps the message array at the env-tuned MESSAGE_CAP, dropping the oldest (head)', () => {
@@ -578,23 +582,37 @@ describe('session store — rolling message cap (bounds the Yoga node high-water
expect(store.state.messages.at(-1)!.text).toBe('h7')
})
- test('defaults to 1000 (the handle-safe ceiling) when the env var is unset/invalid', () => {
+ test('defaults to 3000 (windowed ceiling) when the env var is unset/invalid and windowing is on', () => {
+ // With transcript windowing (the default) the mounted set is ~3 viewports
+ // regardless of store size, so the scrollback ceiling is 3000 (#27 payoff).
delete process.env[ENV_KEY]
+ delete process.env[WINDOWING_KEY]
const store = createSessionStore()
- for (let i = 0; i < 1050; i++) store.pushUser(`m${i}`)
- expect(store.state.messages).toHaveLength(1000)
+ for (let i = 0; i < 3050; i++) store.pushUser(`m${i}`)
+ expect(store.state.messages).toHaveLength(3000)
expect(store.state.messages[0]!.text).toBe('m50') // oldest 50 dropped
})
- test('env values ABOVE the handle-safe ceiling are clamped to it (the native handle table binds, not memory)', () => {
+ test('HERMES_TUI_WINDOWING=0 keeps the handle-safe 1000 ceiling (every row mounts again)', () => {
+ delete process.env[ENV_KEY]
+ process.env[WINDOWING_KEY] = '0'
+ const store = createSessionStore()
+ for (let i = 0; i < 1050; i++) store.pushUser(`m${i}`)
+ expect(store.state.messages).toHaveLength(1000)
+ expect(store.state.messages[0]!.text).toBe('m50')
+ })
+
+ test('env values ABOVE the ceiling are clamped to it (the native handle table binds, not memory)', () => {
// @opentui/core's global handle registry holds 65,534 live objects and a
// text renderable costs 3; ~47 handles/row on the realistic fixture means
- // ≳1,400 live rows crashes mid-mount ("Failed to create SyntaxStyle").
- // A 100000 "cap" is therefore a crash sentence, not a cap — clamp it.
+ // ≳1,400 live MOUNTED rows crashes mid-mount ("Failed to create
+ // SyntaxStyle"). Windowing bounds the mounted set (peak 31 measured), so
+ // the windowed ceiling is 3000 stored rows; a 100000 "cap" still clamps.
process.env[ENV_KEY] = '100000'
+ delete process.env[WINDOWING_KEY]
const store = createSessionStore()
- for (let i = 0; i < 1100; i++) store.pushUser(`m${i}`)
- expect(store.state.messages).toHaveLength(1000)
+ for (let i = 0; i < 3100; i++) store.pushUser(`m${i}`)
+ expect(store.state.messages).toHaveLength(3000)
expect(store.state.dropped).toBe(100)
expect(store.state.messages[0]!.text).toBe('m100')
})
diff --git a/ui-opentui/src/test/transcriptWindow.test.tsx b/ui-opentui/src/test/transcriptWindow.test.tsx
new file mode 100644
index 00000000000..423dbef485c
--- /dev/null
+++ b/ui-opentui/src/test/transcriptWindow.test.tsx
@@ -0,0 +1,370 @@
+/**
+ * Transcript windowing S1+S2 — headless integration (view/transcript.tsx +
+ * logic/window.ts behind HERMES_TUI_WINDOWING). Proves, against the REAL
+ * renderer tree:
+ * - out-of-window rows are actually UNMOUNTED (far fewer live renderables
+ * than the unwindowed tree — the spacer is 1 box, the row was ~3+ texts),
+ * - spacers are EXACT-height (total scrollHeight identical ON vs OFF — the
+ * zero-jank invariant),
+ * - scrolling far away REMOUNTS spaced-out rows (content paints again),
+ * - the flag OFF renders the legacy tree (no wrappers, everything mounted),
+ * and the S2 slices:
+ * - append-time adjudication: bursting 1500 appends keeps the PEAK
+ * simultaneously-mounted row count bounded (< 120) — rows scrolled past
+ * the margin become spacers without a manual scroll,
+ * - resume (commitSnapshot): a bulk snapshot mounts only the bottom window;
+ * everything above starts as estimate spacers and remounts on scroll-back,
+ * - the idle exact-measure march + spacer corrections are ZERO-JANK: with
+ * wrong estimates (soft-wrapped rows), idle pulses fix spacer heights
+ * while the visible frame stays byte-identical — sticky-bottom pinning
+ * compensates when pinned, explicit scrollTop compensation when reading
+ * mid-history.
+ */
+import { ScrollBoxRenderable, type Renderable } from '@opentui/core'
+import { useRenderer } from '@opentui/solid'
+import { afterEach, describe, expect, test } from 'vitest'
+
+import { createSessionStore, type Message } from '../logic/store.ts'
+import { resetWindowRowStats, windowRowStats } from '../logic/window.ts'
+import { ThemeProvider } from '../view/theme.tsx'
+import { Transcript } from '../view/transcript.tsx'
+import { renderProbe, type RenderProbe } from './lib/render.ts'
+
+type Store = ReturnType
+
+const ENV_KEYS = ['HERMES_TUI_WINDOWING', 'HERMES_TUI_WINDOW_IDLE_MS'] as const
+const envBefore = ENV_KEYS.map(k => process.env[k])
+afterEach(() => {
+ ENV_KEYS.forEach((k, i) => {
+ const v = envBefore[i]
+ if (v === undefined) delete process.env[k]
+ else process.env[k] = v
+ })
+})
+
+/** Seed `n` settled one-line system rows (flat text — no async markdown). */
+function seedRows(n: number): Store {
+ const store = createSessionStore()
+ store.apply({ type: 'gateway.ready' })
+ for (let i = 0; i < n; i++) store.pushSystem(`row-${i} marker`)
+ return store
+}
+
+function walk(node: Renderable, visit: (n: Renderable) => void): void {
+ visit(node)
+ for (const child of node.getChildren()) walk(child, visit)
+}
+
+interface Mounted {
+ probe: RenderProbe
+ count: () => number
+ scrollbox: () => ScrollBoxRenderable
+}
+
+async function mountTranscript(store: Store, windowing: '1' | '0'): Promise {
+ process.env.HERMES_TUI_WINDOWING = windowing
+ let root: Renderable | undefined
+ function Grab() {
+ root = useRenderer().root
+ return null
+ }
+ const probe = await renderProbe(
+ () => (
+ store.state.theme}>
+
+
+
+ ),
+ { width: 50, height: 12 }
+ )
+ // several passes: layout (heights recorded) → frame tick (window computed)
+ // → swap render — the driver is the renderer frame callback.
+ for (let i = 0; i < 6; i++) await probe.settle()
+ const count = () => {
+ let n = 0
+ if (root) walk(root, () => n++)
+ return n
+ }
+ const scrollbox = () => {
+ let sb: ScrollBoxRenderable | undefined
+ if (root) {
+ walk(root, node => {
+ if (node instanceof ScrollBoxRenderable) sb ??= node
+ })
+ }
+ if (!sb) throw new Error('no scrollbox in the mounted tree')
+ return sb
+ }
+ return { probe, count, scrollbox }
+}
+
+const ROWS = 120
+
+describe('transcript windowing (HERMES_TUI_WINDOWING) — S1 machinery', () => {
+ test('out-of-window rows unmount into exact-height spacers; OFF keeps the full tree', async () => {
+ const on = await mountTranscript(seedRows(ROWS), '1')
+ const off = await mountTranscript(seedRows(ROWS), '0')
+ try {
+ // sticky-bottom: both variants sit pinned at the bottom showing the tail.
+ expect(on.probe.frame()).toContain(`row-${ROWS - 1} marker`)
+ expect(off.probe.frame()).toContain(`row-${ROWS - 1} marker`)
+
+ // ZERO-JANK INVARIANT: spacers are exact-height — the windowed content
+ // is precisely as tall as the fully-mounted content.
+ expect(on.scrollbox().scrollHeight).toBe(off.scrollbox().scrollHeight)
+ expect(on.scrollbox().scrollHeight).toBeGreaterThan(100) // sanity: way past the viewport
+
+ // The window actually sheds renderables: ~viewport±margin + bottom-30
+ // stay mounted out of 120 rows; the rest are 1-box spacers. The legacy
+ // tree keeps every row's text renderables alive.
+ expect(on.count()).toBeLessThan(off.count() * 0.6)
+ } finally {
+ on.probe.destroy()
+ off.probe.destroy()
+ }
+ })
+
+ test('scrolling far from the bottom remounts spaced-out rows (and the frame paints them)', async () => {
+ const on = await mountTranscript(seedRows(ROWS), '1')
+ try {
+ const sb = on.scrollbox()
+ // row-0 is far outside the bottom window: not painted while pinned.
+ expect(on.probe.frame()).not.toContain('row-0 marker')
+ sb.scrollTo(0)
+ for (let i = 0; i < 6; i++) await on.probe.settle()
+ expect(sb.scrollTop).toBe(0)
+ expect(on.probe.frame()).toContain('row-0 marker')
+ } finally {
+ on.probe.destroy()
+ }
+ })
+})
+
+// ── S2 — append-time adjudication + windowed resume + idle exact-measure ──
+
+/** Drop the right-most columns (scrollbar territory): corrections legitimately
+ * resize the thumb as estimates become exact — that is NOT content movement. */
+function clipScrollbar(frame: string): string {
+ return frame
+ .split('\n')
+ .map(line => line.slice(0, -2).replace(/\s+$/, ''))
+ .join('\n')
+}
+
+describe('transcript windowing — S2 append-time adjudication', () => {
+ test('bursting 1500 appends keeps the peak mounted-row count bounded (< 120)', async () => {
+ // Pin the cap below the burst size: this test ALSO exercises the
+ // cap-trim × windowing interplay (trimmed rows' spacers must be pruned,
+ // scroll-to-top must land on the oldest SURVIVOR, row-500). The default
+ // ceiling is 3000 under windowing (#27) — a 1500-row burst never trims it.
+ process.env.HERMES_TUI_MAX_MESSAGES = '1000'
+ const onStore = (() => {
+ try {
+ return createSessionStore()
+ } finally {
+ delete process.env.HERMES_TUI_MAX_MESSAGES
+ }
+ })()
+ onStore.apply({ type: 'gateway.ready' })
+ const on = await mountTranscript(onStore, '1')
+ try {
+ resetWindowRowStats()
+ // Burst: 100 appends per frame — the per-append adjudication must window
+ // rows out as they pass the margin, NOT wait for the next frame tick.
+ for (let i = 0; i < 1500; i++) {
+ onStore.pushSystem(i % 5 === 4 ? `row-${i} marker\nsecond line\nthird line` : `row-${i} marker`)
+ if (i % 100 === 99) await on.probe.settle()
+ }
+ for (let i = 0; i < 6; i++) await on.probe.settle()
+ expect(windowRowStats().peakMounted).toBeLessThan(120)
+ // pinned at the bottom: the tail painted (live rows mount instantly)
+ expect(on.probe.frame()).toContain('row-1499 marker')
+
+ // ZERO-JANK INVARIANT survives the burst: spacers (measured or
+ // estimated — these rows never soft-wrap) occupy EXACTLY the height the
+ // full tree would. (The store cap trims both to the same 1000 rows.)
+ process.env.HERMES_TUI_MAX_MESSAGES = '1000'
+ const offStore = (() => {
+ try {
+ return createSessionStore()
+ } finally {
+ delete process.env.HERMES_TUI_MAX_MESSAGES
+ }
+ })()
+ offStore.apply({ type: 'gateway.ready' })
+ for (let i = 0; i < 1500; i++) {
+ offStore.pushSystem(i % 5 === 4 ? `row-${i} marker\nsecond line\nthird line` : `row-${i} marker`)
+ }
+ const off = await mountTranscript(offStore, '0')
+ try {
+ expect(on.scrollbox().scrollHeight).toBe(off.scrollbox().scrollHeight)
+ } finally {
+ off.probe.destroy()
+ }
+
+ // scroll-to-top remounts burst rows that were never painted
+ const sb = on.scrollbox()
+ sb.scrollTo(0)
+ for (let i = 0; i < 6; i++) await on.probe.settle()
+ // the cap kept the newest 1000 rows → the oldest surviving row is 500
+ expect(on.probe.frame()).toContain('row-500 marker')
+ } finally {
+ on.probe.destroy()
+ }
+ }, 120_000)
+})
+
+describe('transcript windowing — S2 selection: drag freezes, a finished highlight only pins its rows', () => {
+ test('a persisting (finished) selection does not freeze windowing; its rows stay mounted for copy', async () => {
+ const store = seedRows(60)
+ const on = await mountTranscript(store, '1')
+ try {
+ // drag-select across a visible row's TEXT line (rows interleave with
+ // margin lines — find one from the frame), then release — the highlight
+ // PERSISTS by design (boundary/renderer.ts keeps it so Ctrl+C re-copies).
+ const textY = on.probe
+ .frame()
+ .split('\n')
+ .findIndex(line => line.includes('marker'))
+ expect(textY).toBeGreaterThanOrEqual(0)
+ await on.probe.mouse.drag(3, textY, 30, textY + 2)
+ await on.probe.settle()
+ const selection = on.probe.renderer.getSelection()
+ expect(selection?.isActive).toBe(true)
+ expect(selection?.isDragging).toBe(false)
+ const copied = selection?.getSelectedText() ?? ''
+ expect(copied).toContain('marker')
+
+ // burst 300 appends while the highlight lingers: windowing must keep
+ // adjudicating (the S1 full freeze would balloon the mounted set)…
+ resetWindowRowStats()
+ for (let i = 0; i < 300; i++) {
+ store.pushSystem(`late-${i} marker`)
+ if (i % 50 === 49) await on.probe.settle()
+ }
+ for (let i = 0; i < 6; i++) await on.probe.settle()
+ expect(windowRowStats().peakMounted).toBeLessThan(120)
+
+ // …while the selected row — long scrolled out past the margin — stays
+ // PINNED: the highlight's renderables are alive and Ctrl+C still copies
+ // the exact same text.
+ expect(on.probe.renderer.getSelection()?.getSelectedText()).toBe(copied)
+ } finally {
+ on.probe.destroy()
+ }
+ }, 60_000)
+})
+
+describe('transcript windowing — S2 windowed resume (commitSnapshot)', () => {
+ function snapshot(n: number): Message[] {
+ const out: Message[] = []
+ for (let i = 0; i < n; i++) {
+ if (i % 3 === 0) out.push({ role: 'user', text: `question-${i} marker` })
+ else if (i % 3 === 1) out.push({ role: 'assistant', text: `answer-${i} marker\nwith a second line` })
+ else out.push({ role: 'system', text: `note-${i} marker` })
+ }
+ return out
+ }
+
+ test('a bulk snapshot mounts only the bottom window; history starts as estimate spacers', async () => {
+ const store = createSessionStore()
+ store.apply({ type: 'gateway.ready' })
+ const on = await mountTranscript(store, '1')
+ try {
+ resetWindowRowStats()
+ store.hydrate(() => snapshot(600))
+ for (let i = 0; i < 6; i++) await on.probe.settle()
+ // Only the bottom window (+ bottom-30 sticky region) ever mounted — the
+ // 600-row snapshot must NOT transiently mount everything.
+ expect(windowRowStats().peakMounted).toBeLessThan(120)
+ expect(on.probe.frame()).toContain('answer-598 marker')
+
+ // estimate spacers above are exact for these unwrapped rows (incl. the
+ // ⧉ copy chip line on settled user/assistant rows) — scrollHeight
+ // matches the fully-mounted legacy tree.
+ const offStore = createSessionStore()
+ offStore.apply({ type: 'gateway.ready' })
+ const off = await mountTranscript(offStore, '0')
+ try {
+ offStore.hydrate(() => snapshot(600))
+ for (let i = 0; i < 6; i++) await off.probe.settle()
+ expect(on.scrollbox().scrollHeight).toBe(off.scrollbox().scrollHeight)
+ } finally {
+ off.probe.destroy()
+ }
+
+ // scroll-back into never-mounted history remounts it
+ on.scrollbox().scrollTo(0)
+ for (let i = 0; i < 6; i++) await on.probe.settle()
+ expect(on.probe.frame()).toContain('question-0 marker')
+ } finally {
+ on.probe.destroy()
+ }
+ }, 60_000)
+})
+
+describe('transcript windowing — S2 idle exact-measure + zero-jank corrections', () => {
+ // Every 4th row soft-wraps (~120 chars at width 50): the line-count estimate
+ // is WRONG (1 line vs 3), so the idle march must correct spacer heights —
+ // without ever moving visible content.
+ function wrappySeed(n: number): Store {
+ const store = createSessionStore()
+ store.apply({ type: 'gateway.ready' })
+ const long = 'wrap '.repeat(24).trim() // ~119 chars → 3 wrapped lines
+ for (let i = 0; i < n; i++) store.pushSystem(i % 4 === 0 ? `row-${i} ${long}` : `row-${i} marker`)
+ return store
+ }
+
+ // 400 rows: the mount itself runs ~10 frames (≈100 rows measured by the
+ // march before the baseline is captured) — enough unmeasured, wrongly-
+ // estimated history must REMAIN above for the assertions to bite.
+ const WRAPPY_ROWS = 400
+
+ test('pinned at the bottom: sticky pinning absorbs above-viewport corrections (frame is byte-stable)', async () => {
+ process.env.HERMES_TUI_WINDOW_IDLE_MS = '0' // pulse every idle frame
+ const on = await mountTranscript(wrappySeed(WRAPPY_ROWS), '1')
+ try {
+ const sb = on.scrollbox()
+ const before = clipScrollbar(on.probe.frame())
+ const shBefore = sb.scrollHeight
+ // idle pulses march up the history, mounting+measuring 10 rows at a time
+ for (let i = 0; i < 50; i++) {
+ await on.probe.settle()
+ expect(clipScrollbar(on.probe.frame())).toBe(before) // ZERO jank, every pulse
+ }
+ // corrections actually happened: the wrapped rows were under-estimated
+ expect(sb.scrollHeight).toBeGreaterThan(shBefore)
+ // ...and converged to the legacy tree's exact total
+ const off = await mountTranscript(wrappySeed(WRAPPY_ROWS), '0')
+ try {
+ expect(sb.scrollHeight).toBe(off.scrollbox().scrollHeight)
+ } finally {
+ off.probe.destroy()
+ }
+ } finally {
+ on.probe.destroy()
+ }
+ }, 120_000)
+
+ test('reading mid-history: above-viewport corrections compensate scrollTop in the same frame', async () => {
+ process.env.HERMES_TUI_WINDOW_IDLE_MS = '0'
+ const on = await mountTranscript(wrappySeed(WRAPPY_ROWS), '1')
+ try {
+ const sb = on.scrollbox()
+ // leave the sticky pin and park mid-history (estimates above AND below)
+ sb.scrollTo(Math.floor(sb.scrollHeight / 2))
+ for (let i = 0; i < 6; i++) await on.probe.settle() // window remount settles
+ const baseline = clipScrollbar(on.probe.frame())
+ const scrollTopBefore = sb.scrollTop
+ for (let i = 0; i < 50; i++) {
+ await on.probe.settle()
+ expect(clipScrollbar(on.probe.frame())).toBe(baseline) // ZERO jank
+ }
+ // the under-estimated rows ABOVE the viewport grew; scrollTop was
+ // compensated by exactly that growth — that's WHY the frame held still.
+ expect(sb.scrollTop).toBeGreaterThan(scrollTopBefore)
+ } finally {
+ on.probe.destroy()
+ }
+ }, 120_000)
+})
diff --git a/ui-opentui/src/test/utilityCommands.test.ts b/ui-opentui/src/test/utilityCommands.test.ts
index cd6e03bc3bf..93c4edf38d3 100644
--- a/ui-opentui/src/test/utilityCommands.test.ts
+++ b/ui-opentui/src/test/utilityCommands.test.ts
@@ -22,6 +22,19 @@ import { formatSpawnTree, formatSpawnTreeList, readSpawnTreeEntries } from '../l
import { clientCommandNames, dispatchSlash, type SlashContext } from '../logic/slash.ts'
import type { Part } from '../logic/store.ts'
+// The utility commands under test are DIAGNOSTIC commands — gated behind
+// HERMES_TUI_DIAGNOSTICS (logic/env.ts). This suite tests the commands
+// themselves, so enable the gate for the whole file (gating behavior has its
+// own tests in slash.test.ts).
+const PREV_DIAG = process.env.HERMES_TUI_DIAGNOSTICS
+beforeEach(() => {
+ process.env.HERMES_TUI_DIAGNOSTICS = '1'
+})
+afterEach(() => {
+ if (PREV_DIAG === undefined) delete process.env.HERMES_TUI_DIAGNOSTICS
+ else process.env.HERMES_TUI_DIAGNOSTICS = PREV_DIAG
+})
+
// /heapdump must not write a REAL multi-MB snapshot per test run — stub the V8
// seam; the path/mkdir plumbing still runs for real (under a temp HERMES_HOME).
vi.mock('node:v8', () => ({ writeHeapSnapshot: vi.fn((path?: string) => path ?? 'unnamed.heapsnapshot') }))
diff --git a/ui-opentui/src/test/window.test.ts b/ui-opentui/src/test/window.test.ts
new file mode 100644
index 00000000000..7ad0e071c2f
--- /dev/null
+++ b/ui-opentui/src/test/window.test.ts
@@ -0,0 +1,375 @@
+/**
+ * window.ts — pure transcript-windowing math (design: docs/plans/
+ * opentui-transcript-windowing.md, slices S1+S2). Table-tests the window calc
+ * (viewport ± margin intersection over cumulative exact heights), the
+ * hysteresis recompute gate, the never-window / bottom-K rules, the
+ * null-height estimate fallback, the correction-legality jank rule, the S2
+ * pinned-bottom (append-time) anchoring, the idle edge-measure batch picker,
+ * the idle-delay knob, and the DEV mounted-row counters.
+ */
+import { describe, expect, test } from 'vitest'
+
+import type { Message } from '../logic/store.ts'
+import {
+ computeWindow,
+ correctionIsLegal,
+ DEFAULT_MEASURE_IDLE_MS,
+ edgeMeasureBatch,
+ estimateMessageHeight,
+ hysteresisFor,
+ measureIdleDelayMs,
+ noteRowMounted,
+ noteRowUnmounted,
+ resetWindowRowStats,
+ shouldRecompute,
+ windowRowStats,
+ type WindowRow
+} from '../logic/window.ts'
+
+function row(
+ key: number,
+ height: number | null,
+ opts?: { neverWindow?: boolean; estimate?: number }
+): WindowRow {
+ const base = { key, height, neverWindow: opts?.neverWindow ?? false }
+ return opts?.estimate === undefined ? base : { ...base, estimate: opts.estimate }
+}
+
+/** n rows of uniform height h, keyed 0..n-1 (row i spans [i*h, (i+1)*h)). */
+function uniform(n: number, h: number): WindowRow[] {
+ return Array.from({ length: n }, (_, i) => row(i, h))
+}
+
+function mountedKeys(result: { mounted: ReadonlySet }): number[] {
+ return [...result.mounted].sort((a, b) => a - b)
+}
+
+describe('hysteresisFor', () => {
+ test('≥ ¼ viewport, rounded up', () => {
+ expect(hysteresisFor(40)).toBe(10)
+ expect(hysteresisFor(5)).toBe(2)
+ expect(hysteresisFor(4)).toBe(1)
+ })
+
+ test('never below 1 row (degenerate viewports)', () => {
+ expect(hysteresisFor(0)).toBe(1)
+ expect(hysteresisFor(2)).toBe(1)
+ })
+})
+
+describe('shouldRecompute', () => {
+ test('no prior anchor → always recompute', () => {
+ expect(shouldRecompute(0, null, 10)).toBe(true)
+ expect(shouldRecompute(500, null, 10)).toBe(true)
+ })
+
+ test('movement below hysteresis → keep the current window', () => {
+ expect(shouldRecompute(108, 100, 10)).toBe(false)
+ expect(shouldRecompute(92, 100, 10)).toBe(false)
+ expect(shouldRecompute(100, 100, 10)).toBe(false)
+ })
+
+ test('movement at/above hysteresis (either direction) → recompute', () => {
+ expect(shouldRecompute(110, 100, 10)).toBe(true)
+ expect(shouldRecompute(90, 100, 10)).toBe(true)
+ expect(shouldRecompute(250, 100, 10)).toBe(true)
+ })
+})
+
+describe('computeWindow — viewport ± margin intersection', () => {
+ // 100 rows × 10 → content height 1000. Viewport 40, margin 40 (1 viewport),
+ // scrollTop 480 → window [440, 560). Row i spans [10i, 10i+10).
+ const base = { viewportHeight: 40, margin: 40, scrollTop: 480 }
+
+ test('mounts exactly the rows intersecting [scrollTop − margin, scrollTop + viewport + margin)', () => {
+ const result = computeWindow({ rows: uniform(100, 10), ...base })
+ expect(mountedKeys(result)).toEqual([44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55])
+ })
+
+ test('rows merely TOUCHING the window edge are not mounted', () => {
+ const result = computeWindow({ rows: uniform(100, 10), ...base })
+ // row 43 spans [430, 440) — its bottom touches windowStart 440: out.
+ expect(result.mounted.has(43)).toBe(false)
+ // row 56 spans [560, 570) — its top touches windowEnd 560: out.
+ expect(result.mounted.has(56)).toBe(false)
+ })
+
+ test('anchor echoes the scrollTop the window was computed at', () => {
+ expect(computeWindow({ rows: uniform(100, 10), ...base }).anchor).toBe(480)
+ expect(computeWindow({ rows: [], scrollTop: 7, viewportHeight: 40, margin: 40 }).anchor).toBe(7)
+ })
+
+ test('scrolled to the top: window clamps naturally (no negative-row weirdness)', () => {
+ const result = computeWindow({ rows: uniform(100, 10), scrollTop: 0, viewportHeight: 40, margin: 40 })
+ // window [-40, 80) → rows 0..7
+ expect(mountedKeys(result)).toEqual([0, 1, 2, 3, 4, 5, 6, 7])
+ })
+
+ test('empty transcript → empty window', () => {
+ const result = computeWindow({ rows: [], scrollTop: 0, viewportHeight: 40, margin: 40 })
+ expect(result.mounted.size).toBe(0)
+ })
+
+ test('everything fits in the window → everything mounted', () => {
+ const result = computeWindow({ rows: uniform(5, 2), scrollTop: 0, viewportHeight: 40, margin: 40 })
+ expect(mountedKeys(result)).toEqual([0, 1, 2, 3, 4])
+ })
+
+ test('works with non-numeric keys (generic)', () => {
+ const rows: WindowRow[] = [
+ { key: 'a', height: 50, neverWindow: false },
+ { key: 'b', height: 50, neverWindow: false },
+ { key: 'c', height: 50, neverWindow: false }
+ ]
+ const result = computeWindow({ rows, scrollTop: 60, viewportHeight: 30, margin: 0 })
+ // window [60, 90) → only 'b' ([50, 100)) intersects
+ expect([...result.mounted]).toEqual(['b'])
+ })
+})
+
+describe('computeWindow — pinnedBottom (S2 append-time anchoring)', () => {
+ test('anchors the window to the content BOTTOM regardless of a stale scrollTop', () => {
+ // 100 rows × 10 → content 1000; viewport 40, margin 40. scrollTop is a
+ // STALE 0 (layout lagging a burst append), but pinnedBottom anchors to
+ // 1000 − 40 = 960 → window [920, 1040) → rows 92..99.
+ const result = computeWindow({
+ rows: uniform(100, 10),
+ scrollTop: 0,
+ viewportHeight: 40,
+ margin: 40,
+ pinnedBottom: true
+ })
+ expect(mountedKeys(result)).toEqual([92, 93, 94, 95, 96, 97, 98, 99])
+ expect(result.anchor).toBe(960)
+ })
+
+ test('appended rows past the margin become spacers without any scroll movement', () => {
+ // The append-time rule: grow 100 → 200 rows at the same stale scrollTop —
+ // the window slides to the NEW bottom; the old bottom rows fall out.
+ const before = computeWindow({
+ rows: uniform(100, 10),
+ scrollTop: 0,
+ viewportHeight: 40,
+ margin: 40,
+ pinnedBottom: true
+ })
+ const after = computeWindow({
+ rows: uniform(200, 10),
+ scrollTop: 0,
+ viewportHeight: 40,
+ margin: 40,
+ pinnedBottom: true
+ })
+ expect(before.mounted.has(99)).toBe(true)
+ expect(after.mounted.has(99)).toBe(false)
+ expect(after.mounted.has(199)).toBe(true)
+ })
+
+ test('short content (fits the viewport) clamps to scrollTop 0 → everything mounted', () => {
+ const result = computeWindow({
+ rows: uniform(3, 10),
+ scrollTop: 0,
+ viewportHeight: 40,
+ margin: 40,
+ pinnedBottom: true
+ })
+ expect(result.mounted.size).toBe(3)
+ expect(result.anchor).toBe(0)
+ })
+
+ test('estimates participate in the bottom anchoring (never-measured history)', () => {
+ // 5 estimate-only rows of 100 above 5 exact rows of 10 → content 550;
+ // viewport 40, margin 0 → window [510, 550) → only the exact bottom rows.
+ const rows = [
+ ...Array.from({ length: 5 }, (_, i) => row(i, null, { estimate: 100 })),
+ ...Array.from({ length: 5 }, (_, i) => row(5 + i, 10))
+ ]
+ const result = computeWindow({ rows, scrollTop: 0, viewportHeight: 40, margin: 0, pinnedBottom: true })
+ expect(mountedKeys(result)).toEqual([6, 7, 8, 9])
+ })
+})
+
+describe('computeWindow — never-window and bottom-K rules', () => {
+ test('neverWindow rows stay mounted however far outside the window', () => {
+ const rows = uniform(100, 10)
+ rows[90] = row(90, 10, { neverWindow: true })
+ const result = computeWindow({ rows, scrollTop: 0, viewportHeight: 40, margin: 40 })
+ expect(result.mounted.has(90)).toBe(true)
+ expect(result.mounted.has(89)).toBe(false)
+ })
+
+ test('the bottom K rows are always mounted (sticky-bottom region)', () => {
+ const result = computeWindow({ rows: uniform(100, 10), scrollTop: 0, viewportHeight: 40, margin: 40, bottomK: 5 })
+ expect(mountedKeys(result)).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 95, 96, 97, 98, 99])
+ })
+
+ test('bottomK larger than the transcript mounts everything', () => {
+ const result = computeWindow({ rows: uniform(10, 10), scrollTop: 0, viewportHeight: 4, margin: 0, bottomK: 50 })
+ expect(result.mounted.size).toBe(10)
+ })
+})
+
+describe('computeWindow — null heights use the estimate', () => {
+ test('a per-row estimate stands in for a never-measured height (and shifts later offsets)', () => {
+ // row 0 estimated at 100 → row 1 starts at 100; window [100, 110) hits only row 1.
+ const rows = [row(0, null, { estimate: 100 }), row(1, 10)]
+ const result = computeWindow({ rows, scrollTop: 100, viewportHeight: 10, margin: 0 })
+ expect(mountedKeys(result)).toEqual([1])
+ })
+
+ test('a recorded height wins over the estimate', () => {
+ const rows = [row(0, 10, { estimate: 100 }), row(1, 10)]
+ const result = computeWindow({ rows, scrollTop: 100, viewportHeight: 10, margin: 0 })
+ // row 0 is REALLY 10 tall → row 1 spans [10, 20): nothing in [100, 110).
+ expect(result.mounted.size).toBe(0)
+ })
+
+ test('null height with no estimate falls back to fallbackHeight', () => {
+ const rows = [row(0, null), row(1, 10)]
+ const result = computeWindow({ rows, scrollTop: 0, viewportHeight: 10, margin: 0, fallbackHeight: 100 })
+ // row 0 assumed [0, 100) → mounted; row 1 [100, 110) → out of [0, 10).
+ expect(mountedKeys(result)).toEqual([0])
+ })
+})
+
+describe('correctionIsLegal — the jank rule', () => {
+ // viewport shows [100, 140)
+ const scrollTop = 100
+ const viewportHeight = 40
+
+ test.each([true, false])('fully ABOVE the viewport is legal (compensation applies) — atBottom=%s', atBottom => {
+ expect(correctionIsLegal(20, 60, scrollTop, viewportHeight, atBottom)).toBe(true)
+ // boundary: row bottom touching the viewport top is still fully above
+ expect(correctionIsLegal(50, 100, scrollTop, viewportHeight, atBottom)).toBe(true)
+ })
+
+ test.each([true, false])('fully BELOW the viewport is legal (invisible) — atBottom=%s', atBottom => {
+ expect(correctionIsLegal(150, 170, scrollTop, viewportHeight, atBottom)).toBe(true)
+ // boundary: row top touching the viewport bottom is still fully below
+ expect(correctionIsLegal(140, 160, scrollTop, viewportHeight, atBottom)).toBe(true)
+ })
+
+ test.each([true, false])('any intersection with the viewport is FORBIDDEN — atBottom=%s', atBottom => {
+ expect(correctionIsLegal(90, 110, scrollTop, viewportHeight, atBottom)).toBe(false) // clips the top edge
+ expect(correctionIsLegal(130, 150, scrollTop, viewportHeight, atBottom)).toBe(false) // clips the bottom edge
+ expect(correctionIsLegal(110, 120, scrollTop, viewportHeight, atBottom)).toBe(false) // inside
+ expect(correctionIsLegal(90, 150, scrollTop, viewportHeight, atBottom)).toBe(false) // spans the whole viewport
+ })
+})
+
+describe('estimateMessageHeight — line-count estimate for never-mounted rows', () => {
+ const spacing = { top: 2, bottom: 1 }
+
+ test('flat row: newline count + turn spacing', () => {
+ expect(estimateMessageHeight({ text: 'hello' }, spacing, 1)).toBe(1 + 3)
+ expect(estimateMessageHeight({ text: 'a\nb\nc' }, spacing, 1)).toBe(3 + 3)
+ })
+
+ test('empty text still occupies at least one row', () => {
+ expect(estimateMessageHeight({ text: '' }, { top: 0, bottom: 0 }, 0)).toBe(1)
+ })
+
+ test('parts row: text lines + 1 per collapsed tool/reasoning + inter-part gaps', () => {
+ const message: Pick = {
+ text: '',
+ parts: [
+ { type: 'text', id: 'p1', text: 'line1\nline2' },
+ { type: 'tool', id: 't1', name: 'terminal', state: 'complete' },
+ { type: 'reasoning', id: 'p2', text: 'thought\nover\nlines' }
+ ]
+ }
+ // 2 (text) + 1 (tool header) + 1 (collapsed reasoning) + 2 gaps + 3 spacing
+ expect(estimateMessageHeight(message, spacing, 1)).toBe(2 + 1 + 1 + 2 + 3)
+ })
+
+ test('text parts strip leading/trailing blank lines (the view does the same)', () => {
+ const message: Pick = {
+ text: '',
+ parts: [{ type: 'text', id: 'p1', text: '\n\nhello\n' }]
+ }
+ expect(estimateMessageHeight(message, { top: 0, bottom: 0 }, 1)).toBe(1)
+ })
+
+ test('compact mode (gap 0, no margins) collapses the chrome', () => {
+ const message: Pick = {
+ text: '',
+ parts: [
+ { type: 'text', id: 'p1', text: 'one' },
+ { type: 'tool', id: 't1', name: 'terminal', state: 'complete' }
+ ]
+ }
+ expect(estimateMessageHeight(message, { top: 0, bottom: 0 }, 0)).toBe(2)
+ })
+
+ test('a pathological wall of text is clamped', () => {
+ const text = Array.from({ length: 10_000 }, (_, i) => `l${i}`).join('\n')
+ expect(estimateMessageHeight({ text }, { top: 0, bottom: 0 }, 0)).toBeLessThanOrEqual(500)
+ })
+
+ test('chips: settled non-system rows count the ⧉ copy line; system rows do not', () => {
+ const spacing0 = { top: 0, bottom: 0 }
+ expect(estimateMessageHeight({ role: 'user', text: 'hi' }, spacing0, 1, true)).toBe(2)
+ expect(estimateMessageHeight({ role: 'system', text: 'note' }, spacing0, 1, true)).toBe(1)
+ expect(estimateMessageHeight({ role: 'user', text: 'hi' }, spacing0, 1, false)).toBe(1)
+ // parts: one chip line per text block, none for tool headers
+ const message: Pick = {
+ text: '',
+ parts: [
+ { type: 'text', id: 'p1', text: 'one\ntwo' },
+ { type: 'tool', id: 't1', name: 'terminal', state: 'complete' }
+ ]
+ }
+ // (2 text + 1 chip) + 1 tool + 1 gap
+ expect(estimateMessageHeight(message, spacing0, 1, true)).toBe(5)
+ })
+})
+
+describe('edgeMeasureBatch — the S2 idle measure picker', () => {
+ test('picks never-measured, unmounted rows nearest the bottom first', () => {
+ const rows = [row(0, null), row(1, null), row(2, 10), row(3, null), row(4, 10)]
+ expect(edgeMeasureBatch(rows, new Set([4]), 10)).toEqual([3, 1, 0])
+ })
+
+ test('respects the batch size (the march is incremental)', () => {
+ const rows = Array.from({ length: 20 }, (_, i) => row(i, null))
+ expect(edgeMeasureBatch(rows, new Set(), 3)).toEqual([19, 18, 17])
+ })
+
+ test('skips mounted, measured, and never-window rows', () => {
+ const rows = [row(0, null), row(1, null, { neverWindow: true }), row(2, null), row(3, 7)]
+ expect(edgeMeasureBatch(rows, new Set([2]), 10)).toEqual([0])
+ })
+
+ test('fully measured transcript → nothing to do', () => {
+ expect(edgeMeasureBatch(uniform(5, 10), new Set(), 10)).toEqual([])
+ })
+})
+
+describe('measureIdleDelayMs — the idle-pulse knob', () => {
+ test('unset/garbage → the 1s default; integers (incl. 0) parse', () => {
+ expect(measureIdleDelayMs(undefined)).toBe(DEFAULT_MEASURE_IDLE_MS)
+ expect(measureIdleDelayMs('soon')).toBe(DEFAULT_MEASURE_IDLE_MS)
+ expect(measureIdleDelayMs('-5')).toBe(DEFAULT_MEASURE_IDLE_MS)
+ expect(measureIdleDelayMs('0')).toBe(0)
+ expect(measureIdleDelayMs(' 250 ')).toBe(250)
+ })
+})
+
+describe('windowRowStats — the DEV mounted-row counters', () => {
+ test('tracks current and peak; reset re-bases the peak on the live count', () => {
+ resetWindowRowStats()
+ const base = windowRowStats().mounted
+ noteRowMounted()
+ noteRowMounted()
+ noteRowMounted()
+ expect(windowRowStats().mounted).toBe(base + 3)
+ expect(windowRowStats().peakMounted).toBe(base + 3)
+ noteRowUnmounted()
+ expect(windowRowStats().mounted).toBe(base + 2)
+ expect(windowRowStats().peakMounted).toBe(base + 3) // peak is sticky
+ resetWindowRowStats()
+ expect(windowRowStats().peakMounted).toBe(base + 2) // re-based, live rows kept
+ noteRowUnmounted()
+ noteRowUnmounted()
+ })
+})
diff --git a/ui-opentui/src/view/transcript.tsx b/ui-opentui/src/view/transcript.tsx
index 15dbc3efee4..bb519183c74 100644
--- a/ui-opentui/src/view/transcript.tsx
+++ b/ui-opentui/src/view/transcript.tsx
@@ -13,20 +13,138 @@
*
* A `ScrollAnchorProvider` gives collapse/expand toggles (tool/thinking) a handle
* to hold the viewport in place so expanding doesn't yank to the bottom (#4).
+ *
+ * ── Windowing (S1+S2 of docs/plans/opentui-transcript-windowing.md, #27) ───
+ * Behind `HERMES_TUI_WINDOWING` (unset → ON; 0/false/no/off → OFF), each row is
+ * wrapped in a measuring box (`onSizeChange` records its exact laid-out height,
+ * margins included) and rows outside [scrollTop − viewport, scrollTop +
+ * 2·viewport) swap to an EXACT-HEIGHT empty spacer ``
+ * — 1 yoga node, no text buffers, no native handles — so the mounted set stays
+ * ~3 viewports of rows regardless of transcript length (the 671MB→Ink-parity
+ * memory fix). The Solid `` unmount destroys the row's renderables
+ * (@opentui/solid `_removeNode` → `destroyRecursively()` once unparented).
+ *
+ * Drivers (S2 — append-time adjudication, not just scroll):
+ * - a renderer frame callback (`setFrameCallback` — scroll always triggers a
+ * render, so every scroll movement is observed; no extra timer) compares
+ * `scrollTop` AND `scrollHeight` to the last computation anchor with
+ * ≥ ¼-viewport hysteresis (logic/window.ts),
+ * - a `createComputed` on `messages.length` re-adjudicates SYNCHRONOUSLY on
+ * every append/splice — while pinned at the bottom the window is anchored
+ * to the cumulative content BOTTOM (`pinnedBottom`), so burst-appended rows
+ * are windowed out the moment they scroll past the margin instead of
+ * ballooning the mounted set until the next frame.
+ * Both publish the mounted-key set through one signal + `createSelector`, so
+ * only rows whose mounted-ness actually flipped re-render.
+ *
+ * Never windowed: streaming rows, the last row while a turn runs, and the
+ * bottom BOTTOM_ALWAYS_MOUNTED rows (see its doc). Rows the window has never
+ * adjudicated default to MOUNTED only when created within the bottom
+ * BOTTOM_ALWAYS_MOUNTED of the transcript or streaming (live rows paint
+ * instantly with zero added latency); a row created deep in a bulk snapshot
+ * (resume `commitSnapshot`) starts as an ESTIMATE spacer — a resumed 2k
+ * session mounts only the bottom window. While a mouse selection is being
+ * DRAGGED the window FREEZES (no swaps — a swap would destroy highlighted
+ * renderables out from under the native selection walk); once the drag
+ * finishes, the persisting highlight only PINS the rows containing selected
+ * renderables (highlight + later Ctrl+C copy stay exact) so a streaming turn
+ * can't balloon the mounted set behind a lingering selection.
+ *
+ * Spacer corrections (S2, the zero-jank rule): when a remount/measure lands a
+ * height different from what the spacer occupied, the wrapper's onSizeChange
+ * fires DURING the layout traversal, before any cell is painted. If the view
+ * is pinned to the bottom the scrollbox's own sticky re-pin (which runs in the
+ * content's onSizeChange, i.e. BEFORE the row wrappers') has already
+ * re-anchored — nothing to do. Otherwise, for a row fully ABOVE the viewport
+ * (`correctionIsLegal`), scrollTop is compensated by the delta in the same
+ * frame, so visible content never moves. Corrections intersecting the
+ * viewport are forbidden and simply not applied (the swap itself is the
+ * accepted "fixed by remount" path).
+ *
+ * Lazy exact-measure (S2, design §4 — the documented SIMPLE choice):
+ * @opentui/core cannot lay out without parenting into the live tree, so there
+ * is no true offscreen measurement. When idle (no appends, no scroll movement,
+ * no running turn, no selection for HERMES_TUI_WINDOW_IDLE_MS ≈ 1s), a pulse
+ * mounts a small batch (MEASURE_BATCH_ROWS) of never-measured rows nearest the
+ * bottom window edge (`edgeMeasureBatch` — they're the next to be seen),
+ * records exact heights, and the next recompute swaps them back to now-exact
+ * spacers; corrections obey the jank rule above. Rows far from the window keep
+ * their estimates until the march reaches them. Scrolling itself measures the
+ * margin band as it always did.
+ *
+ * DEV stats: current/peak simultaneously-mounted rows (logic/window.ts
+ * `windowRowStats`) — exposed on `globalThis.__hermesTuiWindowStats` when
+ * HERMES_TUI_WINDOW_STATS is set, asserted bounded by the headless tests.
+ *
+ * Known S2 limits (documented, deferred): /compact·/details toggles and width
+ * resizes leave out-of-window spacer heights stale until remount or the idle
+ * march (resize invalidation is S3, design §5). A discrete scroll jump larger
+ * than the margin in one frame remounts a mis-estimated row already inside
+ * the viewport — the in-viewport correction is forbidden (jank rule), so that
+ * single user-caused frame absorbs the estimate error (the design's accepted
+ * "remounted for view" path).
*/
-import type { ScrollBoxRenderable } from '@opentui/core'
-import { createMemo, createSignal, For, Show } from 'solid-js'
+import type { BoxRenderable, ScrollBoxRenderable } from '@opentui/core'
+import { useRenderer } from '@opentui/solid'
+import { createComputed, createMemo, createSelector, createSignal, For, on, onCleanup, onMount, Show } from 'solid-js'
-import type { SessionStore } from '../logic/store.ts'
+import { diagnosticsEnabled, envFlag } from '../logic/env.ts'
+import type { Message, SessionStore } from '../logic/store.ts'
+import {
+ computeWindow,
+ correctionIsLegal,
+ edgeMeasureBatch,
+ estimateMessageHeight,
+ hysteresisFor,
+ measureIdleDelayMs,
+ noteRowMounted,
+ noteRowUnmounted,
+ shouldRecompute,
+ windowRowStats
+} from '../logic/window.ts'
import { DisplayProvider } from './display.tsx'
import { HomeHint } from './homeHint.tsx'
-import { MessageLine } from './messageLine.tsx'
+import { MessageLine, turnSpacing } from './messageLine.tsx'
import { ScrollAnchorProvider } from './scrollAnchor.tsx'
import { useTheme } from './theme.tsx'
+/**
+ * The bottom K rows are ALWAYS mounted (the sticky-bottom region the user
+ * lives in; also the zone where swap turbulence would be most visible). 30 is
+ * a fixed, documented pick (the design's alternative — ceil(viewport/avg-row)
+ * — buys little: rows under the viewport+margin are mounted by the window calc
+ * anyway, so K only backstops sticky re-pins and burst appends).
+ */
+const BOTTOM_ALWAYS_MOUNTED = 30
+
+/** Rows mounted per idle measure pulse (design §4 "small batches"). Small
+ * enough that a pulse's churn is invisible work above the viewport; the march
+ * covers a full resume snapshot over a couple of minutes of idleness. */
+const MEASURE_BATCH_ROWS = 10
+
+/** The published window state: which keys are mounted, and which keys the
+ * computation has SEEN (unseen keys default to mounted — see isMounted). */
+interface WinState {
+ readonly mounted: ReadonlySet
+ readonly known: ReadonlySet
+}
+
+function sameSet(a: ReadonlySet, b: ReadonlySet): boolean {
+ if (a.size !== b.size) return false
+ for (const k of a) if (!b.has(k)) return false
+ return true
+}
+
+/** Signal equality for WinState — identical sets must not re-notify selectors. */
+function sameWinState(a: WinState | undefined, b: WinState | undefined): boolean {
+ if (!a || !b) return a === b
+ return sameSet(a.mounted, b.mounted) && sameSet(a.known, b.known)
+}
+
export function Transcript(props: { store: SessionStore }) {
const [scroll, setScroll] = createSignal()
const theme = useTheme()
+ const renderer = useRenderer()
const dropped = () => props.store.state.dropped
const sid = () => props.store.state.sessionId
// The NEWEST assistant answer's index — gold is earned (design pass): only
@@ -38,6 +156,326 @@ export function Transcript(props: { store: SessionStore }) {
}
return -1
})
+
+ // ── windowing state (S1) ───────────────────────────────────────────────
+ // Read once per transcript: the flag is an A/B + escape hatch, not live config.
+ const windowing = envFlag(process.env.HERMES_TUI_WINDOWING, true)
+ // Stable row keys: messages carry no id and the store relies on reference
+ // identity ( keys by item reference; solid-js/store proxies are cached
+ // per underlying object, so the reference is stable across reads/mutations).
+ // A WeakMap-assigned monotonic number gives the window math a primitive key
+ // without restructuring the store or mutating Message objects.
+ const rowKeys = new WeakMap()
+ let rowSeq = 0
+ const keyOf = (message: Message): number => {
+ let key = rowKeys.get(message)
+ if (key === undefined) {
+ key = ++rowSeq
+ rowKeys.set(message, key)
+ }
+ return key
+ }
+ // key → last exact height measured while the REAL row was mounted (the
+ // wrapper's onSizeChange value; includes the row's margins). Non-reactive:
+ // spacers read it once at swap time, the frame driver reads it per compute.
+ const heights = new Map()
+ // key → the height the LAYOUT currently occupies for the row (real or
+ // spacer/estimate — whatever the wrapper last laid out at). The S2 spacer-
+ // correction compares a fresh measurement against this to compute the delta
+ // that must be compensated when the change sits above the viewport.
+ const assumed = new Map()
+ // key → the mount default for rows the window has never adjudicated,
+ // decided at row CREATION: streaming rows and rows created within the bottom
+ // BOTTOM_ALWAYS_MOUNTED of the transcript mount (live rows paint instantly);
+ // anything deeper (a bulk resume snapshot) starts as an estimate spacer.
+ const defaults = new Map()
+ // key → the row's live measuring wrapper (the idle measure pull below reads
+ // post-layout heights for batch rows whose mount changed nothing); and the
+ // reverse map (wrapper → key) for pinning rows under a persisting selection.
+ const wrappers = new Map()
+ const wrapperKeys = new WeakMap
+ )
+ }
+
return (
@@ -58,7 +496,13 @@ export function Transcript(props: { store: SessionStore }) {