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 ( -
+
{text} @@ -383,7 +383,10 @@ function SourceView({ filePath, language, text }: { filePath: string; language: ) })}
-
+
{selection && (
document.fonts.load(`${v} 11px 'JetBrains Mono'`))) + : Promise.resolve() - if (fonts?.ready) { - void fonts.ready.then(mount, mount) - } else { - mount() - } + void warm.then(mount, mount) return () => { disposed = true diff --git a/apps/desktop/src/fonts/JetBrainsMono-Bold.woff2 b/apps/desktop/src/fonts/JetBrainsMono-Bold.woff2 new file mode 100644 index 00000000000..81c5a219dca Binary files /dev/null and b/apps/desktop/src/fonts/JetBrainsMono-Bold.woff2 differ diff --git a/apps/desktop/src/fonts/JetBrainsMono-Italic.woff2 b/apps/desktop/src/fonts/JetBrainsMono-Italic.woff2 new file mode 100644 index 00000000000..4103d391055 Binary files /dev/null and b/apps/desktop/src/fonts/JetBrainsMono-Italic.woff2 differ diff --git a/apps/desktop/src/fonts/JetBrainsMono-Regular.woff2 b/apps/desktop/src/fonts/JetBrainsMono-Regular.woff2 new file mode 100644 index 00000000000..66c54672c78 Binary files /dev/null and b/apps/desktop/src/fonts/JetBrainsMono-Regular.woff2 differ diff --git a/apps/desktop/src/styles.css b/apps/desktop/src/styles.css index 76b31331680..a73631d3609 100644 --- a/apps/desktop/src/styles.css +++ b/apps/desktop/src/styles.css @@ -17,6 +17,30 @@ src: url('../../../node_modules/@nous-research/ui/dist/fonts/Collapse-Bold.woff2') format('woff2'); } +/* JetBrains Mono — bundled terminal font (Apache-2.0) so bold/italic share the + regular face's metrics instead of squeezing against a system fallback. */ +@font-face { + font-family: 'JetBrains Mono'; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url('./fonts/JetBrainsMono-Regular.woff2') format('woff2'); +} +@font-face { + font-family: 'JetBrains Mono'; + font-style: normal; + font-weight: 700; + font-display: swap; + src: url('./fonts/JetBrainsMono-Bold.woff2') format('woff2'); +} +@font-face { + font-family: 'JetBrains Mono'; + font-style: italic; + font-weight: 400; + font-display: swap; + src: url('./fonts/JetBrainsMono-Italic.woff2') format('woff2'); +} + @theme inline { --color-background: var(--dt-background); --color-foreground: var(--dt-foreground); diff --git a/docs/opentui-env-flags.md b/docs/opentui-env-flags.md new file mode 100644 index 00000000000..23b7ba86ec5 --- /dev/null +++ b/docs/opentui-env-flags.md @@ -0,0 +1,58 @@ +# OpenTUI env flags — the consolidated ledger + +Every environment variable the OpenTUI TUI reads (grep-verified 2026-06-12), +classified by who should ever touch it. The design rule shipped with this doc: +**regular users see zero diagnostic surface by default; one master switch +(`HERMES_TUI_DIAGNOSTICS=1`) turns all of it on when needed.** + +## 1. The master switch + +| var | default | effect | +|---|---|---| +| `HERMES_TUI_DIAGNOSTICS` | **off** | Enables the diagnostic slash commands (`/mem`, `/heapdump`). While off they're hidden from `/help` (client-side filter) and invoking them prints the enable hint rather than executing. They never appear in slash *completion* in either state — completion is gateway-driven and these are client-only commands the gateway doesn't know (an adversarial review confirmed there's no bypass path; if a SERVER command named `mem`/`heapdump` is ever added it must be gated gateway-side too — the client gate would shadow but not hide it). Also flips the *default* of `HERMES_TUI_WINDOW_STATS` to on. Not a secret — support flows are "relaunch with `HERMES_TUI_DIAGNOSTICS=1`". | + +## 2. User-facing configuration (fine to document publicly) + +| var | default | effect | +|---|---|---| +| `HERMES_TUI_ENGINE` | auto (`opentui` if Node≥26.3 + built, else `ink`) | Engine pick; also `display.tui_engine` in config.yaml. | +| `HERMES_TUI_MOUSE` | on (launcher sets it) | Mouse support (wheel scroll, selection, click-to-expand). **Glitch verdict 2026-06-12: leave as-is — always on, no realistic reason to disable; treat as plumbing, don't document it user-facing.** | +| `HERMES_TUI_MAX_MESSAGES` | ceiling | Scrollback rows kept in the TUI. Can LOWER the ceiling, never raise: 3000 with windowing, 1000 with windowing off (handle-table safety). | +| `HERMES_TUI_TOOL_OUTPUT_LINES` | unlimited | Cap expanded tool-output lines (set a number to restore a cap). | +| `HERMES_TUI_COMPOSER_ROWS` | default rows | Composer height. | + +## 3. Escape hatches & tuning (dev-facing, individually settable) + +| var | default | effect | +|---|---|---| +| `HERMES_TUI_WINDOWING` | **on** | `0` = bit-exact pre-windowing renderer (every row mounts; cap clamps back to 1000). The A/B + regression escape hatch. | +| `HERMES_TUI_WINDOW_IDLE_MS` | ~1000 | Idle-measure pulse cadence (the spacer-exactness march). Test knob. | +| `HERMES_TUI_WINDOW_STATS` | = `HERMES_TUI_DIAGNOSTICS` | Exposes live/peak mounted-row counters (`globalThis.__hermesTuiWindowStats`) for bench/live-attach reads. | +| `HERMES_TUI_MEMLOG` | = `HERMES_TUI_DIAGNOSTICS` | In-process 1Hz memory self-sampling (`boundary/memlog.ts`) → `~/.hermes/logs/memwatch/-.jsonl` (rss/heap/external + mounted rows; 14-day retention). Fleet view: `node bench/memwatch-report.mjs`. The "monitor all my sessions" answer: one `export HERMES_TUI_DIAGNOSTICS=1` in your shell rc covers every session. | +| `HERMES_TUI_LOG_LEVEL` / `HERMES_TUI_LOG_FILE` | engine defaults | Logging verbosity/destination (`/logs` reads the ring buffer regardless). Deliberately independent of the master switch — support often wants logs without the full diag surface. | + +## 4. Internal plumbing (set by the launcher/bench/tests — humans never set these) + +| var | set by | effect | +|---|---|---| +| `HERMES_PYTHON`, `HERMES_PYTHON_SRC_ROOT`, `HERMES_CWD` | launcher / bench | Which gateway python + repo root + cwd the TUI spawns against (the bench's fake-gateway seam). | +| `HERMES_TUI_ACTIVE_SESSION_FILE` | launcher/bench | Session handoff file. | +| `HERMES_TUI_RESUME`, `HERMES_TUI_PROMPT`, `HERMES_TUI_FAKE` | launcher/tests | Resume-at-boot, seeded prompt, fake-mode. | +| `HERMES_TUI_RPC_TIMEOUT_MS`, `HERMES_TUI_STARTUP_TIMEOUT_MS` | tests/CI | Protocol timeouts. | +| (`ui-tui` only) `HERMES_TUI_MEMSAMPLE_FD/MS` | bench | Ink fd-3 node sampler. | + +## How the pieces compose (the support script) + +- Regular user, normal day: zero flags, zero diagnostic commands visible. +- "My TUI feels heavy" support flow: `HERMES_TUI_DIAGNOSTICS=1 hermes` → `/mem` + for the live numbers, `/heapdump` for a snapshot to attach, window stats + exposed for `bench/live-attach.sh ` to read. +- Developer profiling: same master switch + the individual knobs + (`HERMES_TUI_WINDOWING=0` A/B, `WINDOW_IDLE_MS` tuning) as needed. +- Anything in section 4 appearing in a user-facing doc is a bug. + +Gating implementation: `logic/env.ts` (`diagnosticsEnabled()`), +`logic/slash.ts` (`DIAGNOSTIC_COMMANDS` — dispatch hint, help + completion +filtering), `view/transcript.tsx` (stats default). Tests: +`slash.test.ts` (gating both states), `utilityCommands.test.ts` (commands +themselves, gate enabled suite-wide). diff --git a/docs/opentui-memory-story.md b/docs/opentui-memory-story.md new file mode 100644 index 00000000000..63ec531c66e --- /dev/null +++ b/docs/opentui-memory-story.md @@ -0,0 +1,207 @@ +# How the OpenTUI transcript got from 686MB to ~300MB — the full story + +*For: glitch. Branch: `feat/opentui-memory-window`. Everything here is measured, +not vibes; every number has a result JSON in `bench/results/`.* + +--- + +## 1. The cast of characters (the primitives, bottom-up) + +To understand where the memory went, you need to know who's holding it. Six +layers, from the screen up: + +**The terminal grid.** Your terminal is a spreadsheet of character cells. +Nobody pays per-message here — tmux holds ~5MB flat no matter how long the +session is (we measured). The terminal is never the problem. + +**The OpenTUI native renderer (Zig).** A compiled library that owns the +"frame buffer" — the grid of cells about to be painted. Every piece of text the +TUI shows lives in a native **TextBuffer** (the characters + their colors), +viewed through a **TextBufferView**, styled by a **SyntaxStyle**. Each of those +is a **native handle** — a ticket into one global table that has only **65,535 +slots, total, ever** (16-bit indices — like a coat check with 65k hooks). +Destroying a renderable returns its tickets, so the constraint is not "how much +have you ever created" but **"how much is alive right now."** + +**Renderables.** OpenTUI's UI objects — ``, ``, ``, +``, ``. 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() + // key → cached settled-row height estimate (estimateFor scans the row text — + // caching keeps the per-append adjudication O(rows), not O(text)). Tagged + // with the /compact flag it was computed under. + const estimates = new Map() + let estimatesCompact = false + const [winState, setWinState] = createSignal(undefined, { equals: sameWinState }) + // Non-reactive mirror of the latest winState for event callbacks (onSizeChange + // must not subscribe; createSelector reads are for tracked scopes). + let liveWin: WinState | undefined + /** Non-reactive mounted-ness (for onSizeChange et al.): the window's verdict + * when the key has been adjudicated, its creation default otherwise. */ + const mountedNow = (key: number): boolean => { + if (!liveWin || !liveWin.known.has(key)) return defaults.get(key) ?? true + return liveWin.mounted.has(key) + } + // Per-row mounted-ness: only rows whose answer FLIPPED re-run their . + const isMounted = createSelector(winState, (key: number, s: WinState | undefined) => { + if (!s || !s.known.has(key)) return defaults.get(key) ?? true + return s.mounted.has(key) + }) + const estimateFor = (message: Message, key: number): number => { + const compact = props.store.state.compact + if (compact !== estimatesCompact) { + estimates.clear() + estimatesCompact = compact + } + const streaming = message.streaming ?? false + if (!streaming) { + const cached = estimates.get(key) + if (cached !== undefined) return cached + } + const estimate = estimateMessageHeight( + message, + turnSpacing(message.role, compact), + compact ? 0 : 1, + !compact && !streaming + ) + if (!streaming) estimates.set(key, estimate) + return estimate + } + // DEV stats exposure for the bench (HERMES_TUI_WINDOW_STATS — defaults to + // the HERMES_TUI_DIAGNOSTICS master switch; settable individually): the live + // current/peak mounted-row counters from logic/window.ts. + if (envFlag(process.env.HERMES_TUI_WINDOW_STATS, diagnosticsEnabled())) { + ;(globalThis as unknown as Record)['__hermesTuiWindowStats'] = windowRowStats() + } + + // ── window drivers: per-frame scrollTop/scrollHeight check (no scroll signal + // exists; the frame callback fires on every rendered frame, and scrolling + // always renders) + a synchronous re-adjudication on every append (S2). + let anchor: number | null = null + let lastCount = -1 + let lastScrollHeight = -1 + let lastScrollTop = -1 + let lastActivityAt = Date.now() + let lastPulseAt = 0 + let measureBatch: ReadonlySet = new Set() + const idleMs = measureIdleDelayMs(process.env.HERMES_TUI_WINDOW_IDLE_MS) + const tick = (force = false): void => { + const sb = scroll() + if (!sb) return + // Selection handling (S2 refinement of the S1 full freeze): + // - while DRAGGING, the native selection walks the LIVE tree on every + // update — swapping a row out (destroying its renderables) mid-walk + // would corrupt the highlight/copy. Full freeze, as in S1. + // - a FINISHED highlight persists by design (boundary/renderer.ts keeps + // it so Ctrl+C can re-copy). An indefinite full freeze would let a + // burst-streaming turn balloon the mounted set (the S1 hole this slice + // closes), so instead only the rows that CONTAIN selected renderables + // are pinned (never windowed) — the highlight and a later Ctrl+C copy + // stay exact, and everything else keeps windowing. + const selection = renderer.getSelection() + if (selection?.isActive && selection.isDragging) { + lastActivityAt = Date.now() + return + } + const pinned = new Set() + if (selection?.isActive) { + lastActivityAt = Date.now() // a live highlight is activity: no idle pulses + for (const renderable of selection.selectedRenderables) { + let node: unknown = renderable + while (node && typeof node === 'object') { + const key = wrapperKeys.get(node) + if (key !== undefined) { + pinned.add(key) + break + } + node = (node as { parent?: unknown }).parent + } + } + } + const viewportHeight = sb.viewport.height + if (viewportHeight <= 0) return + const messages = props.store.state.messages + // Idle measure pull: a batch row whose mount landed at EXACTLY the spacer + // height fires no onSizeChange — read its post-layout height directly so + // the march advances past it. (Batch publishes happen only in frame- + // callback ticks, so by ANY later tick the batch's layout has run.) + for (const key of measureBatch) { + if (heights.has(key) || !mountedNow(key)) continue + const wrapper = wrappers.get(key) + const h = wrapper?.height ?? 0 + if (h > 0) { + heights.set(key, h) + assumed.set(key, h) + } + } + const scrollTop = sb.scrollTop + const scrollHeight = sb.scrollHeight + const running = props.store.state.info.running ?? false + const countChanged = messages.length !== lastCount + const hysteresis = hysteresisFor(viewportHeight) + // Content growth without a count change (streaming deltas) moves + // scrollHeight — treat it like scroll movement against the same hysteresis. + const heightMoved = lastScrollHeight !== -1 && Math.abs(scrollHeight - lastScrollHeight) >= hysteresis + const scrolled = shouldRecompute(scrollTop, anchor, hysteresis) + const now = Date.now() + if (countChanged || scrollTop !== lastScrollTop || running) lastActivityAt = now + lastScrollTop = scrollTop + // Idle measure pulse (design §4): only in frame-callback ticks (append + // ticks are activity by definition), only when truly idle, only while + // some row is still unmeasured (heights covers every live measured key). + const pulseDue = + !force && + !running && + heights.size < messages.length && + now - lastActivityAt >= idleMs && + now - lastPulseAt >= idleMs + if (!force && !countChanged && !heightMoved && !scrolled && !pulseDue) return + const rows = messages.map((message, i) => { + const key = keyOf(message) + const height = heights.get(key) ?? null + return { + key, + height, + estimate: height === null ? estimateFor(message, key) : undefined, + // Never window: a streaming row (remount would restart native markdown + // streaming) and the last row while a turn runs (deltas land there). + // A row with an expanded tool/reasoning body is NOT detectable from + // here (the override lives in component-local signals — toolPart.tsx/ + // reasoningPart.tsx); expanded rows far above the viewport may + // re-collapse on remount. Accepted for S1. + neverWindow: (message.streaming ?? false) || (running && i === messages.length - 1) || pinned.has(key) + } + }) + // Pinned to the bottom (sticky region): anchor the window to the content + // BOTTOM so rows appended since the last layout are adjudicated against + // where the pin will land, not a stale scrollTop (S2 append-time rule). + const atBottom = scrollTop >= scrollHeight - viewportHeight + const result = computeWindow({ + rows, + scrollTop, + viewportHeight, + margin: viewportHeight, // 1 viewport each side (design §Mechanism 1) + bottomK: BOTTOM_ALWAYS_MOUNTED, + pinnedBottom: atBottom + }) + anchor = result.anchor + lastCount = messages.length + lastScrollHeight = scrollHeight + const known = new Set(rows.map(r => r.key)) + // The store cap splices old rows out — drop their per-key records too. + if (countChanged) { + for (const map of [heights, assumed, defaults, estimates]) { + for (const key of map.keys()) if (!known.has(key)) map.delete(key) + } + } + // Idle measure batch: mount the next never-measured rows nearest the + // window edge. The previous batch stays mounted until the NEXT pulse + // replaces it (not merely until a height lands): async row content — the + // native markdown tokenizes over a few frames — needs more than one layout + // pass before its recorded height is trustworthy. Once everything is + // measured the leftover batch drops back to (exact) spacers. + let batch = new Set() + if (pulseDue) { + batch = new Set(edgeMeasureBatch(rows, result.mounted, MEASURE_BATCH_ROWS)) + lastPulseAt = now + } else if (heights.size < messages.length) { + for (const key of measureBatch) if (known.has(key)) batch.add(key) + } + measureBatch = batch + const mounted = batch.size > 0 ? new Set([...result.mounted, ...batch]) : result.mounted + liveWin = { mounted, known } + setWinState(liveWin) + } + onMount(() => { + if (!windowing) return + const frame = (_deltaTime: number): Promise => { + tick() + return Promise.resolve() + } + renderer.setFrameCallback(frame) + onCleanup(() => renderer.removeFrameCallback(frame)) + }) + // Append-time adjudication (S2): re-window synchronously when the transcript + // grows/shrinks, so a burst of appends can't balloon the mounted set between + // frames. `on(..., { defer })` keeps the tick untracked — only the length + // re-runs it (content deltas are the frame driver's scrollHeight check). + if (windowing) { + createComputed( + on( + () => props.store.state.messages.length, + () => tick(true), + { defer: true } + ) + ) + } + + /** One windowed row: a measuring wrapper around the real MessageLine or an + * exact-height spacer. The wrapper stays mounted either way (1 box), so its + * `onSizeChange` keeps the height record fresh while the row is real. */ + const WindowedRow = (rowProps: { message: Message; index: () => number }) => { + const key = keyOf(rowProps.message) + // Creation default for the not-yet-adjudicated row (see `defaults`): + // appended live rows land in the bottom region → mounted instantly; a row + // created deep inside a bulk snapshot starts as an estimate spacer. + // (Component bodies are untracked — these reads are a one-time snapshot.) + defaults.set( + key, + (rowProps.message.streaming ?? false) || + rowProps.index() >= props.store.state.messages.length - BOTTOM_ALWAYS_MOUNTED + ) + let wrapper: BoxRenderable | undefined + onCleanup(() => wrappers.delete(key)) + const record = (): void => { + if (!wrapper) return + const h = wrapper.height + if (h <= 0) return + // Record the exact height only while the REAL row is mounted — a + // spacer's (estimate's) height must never overwrite the measurement. + if (mountedNow(key)) heights.set(key, h) + const prev = assumed.get(key) + assumed.set(key, h) + if (prev === undefined || prev === h) return + // ── S2 spacer correction (the zero-jank rule) ───────────────────── + // The row's laid-out height changed (estimate spacer → measured row, or + // a re-measure). onSizeChange fires inside the layout traversal, before + // paint. The scrollbox content's own onSizeChange (parent — runs FIRST) + // already re-pinned the bottom when sticky applies, so at-bottom needs + // no compensation here. Otherwise compensate scrollTop for changes + // fully ABOVE the viewport — same frame, zero visible movement. The + // legality check uses the row's PREVIOUS extent (what the user sees). + const sb = scroll() + if (!sb) return + const viewportHeight = sb.viewport.height + if (viewportHeight <= 0) return + const scrollTop = sb.scrollTop + const atBottom = scrollTop >= sb.scrollHeight - viewportHeight + if (atBottom) return // the sticky pin compensated already + const rowTop = wrapper.y - sb.content.y // content-space coordinates + const rowBottom = rowTop + prev + if (correctionIsLegal(rowTop, rowBottom, scrollTop, viewportHeight, atBottom) && rowBottom <= scrollTop) { + sb.scrollTop = scrollTop + (h - prev) + } + } + /** The real row, instrumented: the DEV mounted-row counters the headless + * tests assert against (and the bench can read — see windowRowStats). */ + const RealRow = () => { + noteRowMounted() + onCleanup(noteRowUnmounted) + return + } + return ( + { + wrapper = el + wrappers.set(key, el) + wrapperKeys.set(el, key) + }} + style={{ flexDirection: 'column', flexShrink: 0 }} + onSizeChange={record} + > + } + > + + + + ) + } + return ( @@ -58,7 +496,13 @@ export function Transcript(props: { store: SessionStore }) { - {(message, i) => } + {(message, i) => + windowing ? ( + + ) : ( + + ) + }