mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-09 13:21:42 +00:00
opentui(v6): terminal window title (OSC 0/2) + waiting-on-you notifications (OSC 9/99/777)
Window title: a render-nothing <TerminalChrome> tracks session.info — the
native renderer.setTerminalTitle (frame-safe, zig-side OSC emit) shows
'{session title} — Hermes' once the session is titled, 'Hermes Agent' until
then. The user's previous title is bracketed with the XTWINOPS title stack
(save on boot, best-effort restore on quit). Gateway: _session_info now
carries the live title (DB row, pending_title fallback) and a session.info
refresh follows every title change — pending-title application, the
auto-title worker landing (via maybe_auto_title's title_callback), and
session.title renames — so the window retitles without waiting for the
next turn.
Notifications: when the TUI starts waiting on the user — any blocking
prompt (clarify/approval/sudo/secret/confirm) or turn completion — three
dialect sequences go out through renderer.writeOut: OSC 9 (iTerm2/wezterm),
OSC 99 (kitty), OSC 777 (urxvt/foot); terminals ignore what they don't
speak. Suppressed while the terminal reports focused (core's mode-1004
focus/blur events; until a first blur proves reporting works, notify
unconditionally). HERMES_TUI_NOTIFY=0/false/off kills notifications; the
title is not gated. All text is OSC-sanitized (control chars stripped,
777's semicolon fields spliced-proof, length-capped).
13 new TUI tests (pure shaping/sequences/env gate + store-edge wiring via
an injected seam) and 2 gateway tests (title resolution order, thread-safe
refresh emitter). Live-smoked: tmux pane_title shows 'Hermes Agent' from
the native title path.
This commit is contained in:
parent
3616b813ec
commit
ef94562125
27 changed files with 545 additions and 2095 deletions
|
|
@ -1,58 +0,0 @@
|
|||
# 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/<boot>-<pid>.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 <pid>` 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).
|
||||
|
|
@ -1,207 +0,0 @@
|
|||
# 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 — `<text>`, `<box>`, `<markdown>`,
|
||||
`<code>`, `<scrollbox>`. 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
|
||||
`<For>`. 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 `<box height={exactly-that}/>` — 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).*
|
||||
|
|
@ -1,73 +0,0 @@
|
|||
# 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 `<scrollbox>` through documented surface only — `onSizeChange`,
|
||||
`setFrameCallback`, `scrollTop`/`viewport`/`scrollHeight`, Solid `<Show>`
|
||||
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.
|
||||
|
|
@ -3804,6 +3804,51 @@ def test_session_info_includes_mcp_servers(monkeypatch):
|
|||
assert info["mcp_servers"] == fake_status
|
||||
|
||||
|
||||
def test_session_info_includes_session_title(monkeypatch):
|
||||
"""session.info carries the live session title (window-title chrome).
|
||||
|
||||
Resolution order mirrors _session_live_title: DB row wins, a queued
|
||||
pending_title fills in before the row exists, "" until either lands.
|
||||
"""
|
||||
agent = types.SimpleNamespace(tools=[], model="m", provider="p")
|
||||
|
||||
# No session at all -> "" (and never a crash).
|
||||
assert server._session_info(agent, None)["title"] == ""
|
||||
|
||||
# pending_title before the DB row exists.
|
||||
session = {"session_key": "k1", "pending_title": "rename the moon"}
|
||||
monkeypatch.setattr(server, "_get_db", lambda: None)
|
||||
assert server._session_info(agent, session)["title"] == "rename the moon"
|
||||
|
||||
# DB row wins over pending.
|
||||
fake_db = types.SimpleNamespace(get_session_title=lambda key: "db title")
|
||||
monkeypatch.setattr(server, "_get_db", lambda: fake_db)
|
||||
assert server._session_info(agent, session)["title"] == "db title"
|
||||
|
||||
|
||||
def test_emit_title_refresh_pushes_session_info(monkeypatch):
|
||||
"""_emit_title_refresh emits a session.info for a live session and is a
|
||||
silent no-op for unknown/agent-less sessions (it runs on the auto-title
|
||||
worker thread -- it must never raise)."""
|
||||
events = []
|
||||
monkeypatch.setattr(
|
||||
server, "_emit", lambda event_type, sid, payload: events.append((event_type, sid, payload))
|
||||
)
|
||||
monkeypatch.setattr(server, "_session_info", lambda agent, session: {"title": "t"})
|
||||
|
||||
agent = types.SimpleNamespace(tools=[], model="m", provider="p")
|
||||
monkeypatch.setitem(server._sessions, "title-test", {"agent": agent, "session_key": "k"})
|
||||
|
||||
server._emit_title_refresh("title-test")
|
||||
assert events == [("session.info", "title-test", {"title": "t"})]
|
||||
|
||||
# Unknown sid / no agent -> no emission, no exception.
|
||||
server._emit_title_refresh("missing-sid")
|
||||
monkeypatch.setitem(server._sessions, "agentless", {"session_key": "k2"})
|
||||
server._emit_title_refresh("agentless")
|
||||
assert len(events) == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# History-mutating commands must reject while session.running is True.
|
||||
# Without these guards, prompt.submit's post-run history write either
|
||||
|
|
|
|||
|
|
@ -2373,6 +2373,17 @@ def _session_info(agent, session: dict | None = None) -> dict:
|
|||
yolo = bool(_YOLO_MODE_FROZEN) or session_yolo or _get_approval_mode() == "off"
|
||||
except Exception:
|
||||
yolo = False
|
||||
# Session title (DB row, falling back to a not-yet-applied pending_title).
|
||||
# Drives client window-title chrome (OSC 0/2 in the native TUI); "" until
|
||||
# the first exchange titles the session.
|
||||
title = ""
|
||||
if session is not None:
|
||||
try:
|
||||
title = _session_live_title(
|
||||
session, str(session.get("session_key") or "")
|
||||
)
|
||||
except Exception:
|
||||
title = ""
|
||||
info: dict = {
|
||||
"model": getattr(agent, "model", ""),
|
||||
"provider": getattr(agent, "provider", ""),
|
||||
|
|
@ -2385,6 +2396,7 @@ def _session_info(agent, session: dict | None = None) -> dict:
|
|||
"cwd": cwd,
|
||||
"branch": _git_branch_for_cwd(cwd),
|
||||
"personality": str(personality or ""),
|
||||
"title": title,
|
||||
"running": bool((session or {}).get("running")),
|
||||
"desktop_contract": DESKTOP_BACKEND_CONTRACT,
|
||||
"version": "",
|
||||
|
|
@ -4399,6 +4411,22 @@ def _session_live_title(session: dict, key: str) -> str:
|
|||
return title
|
||||
|
||||
|
||||
def _emit_title_refresh(sid: str) -> None:
|
||||
"""Push a session.info refresh after a title change (pending-title
|
||||
application, auto-title landing, or a session.title rename) so clients'
|
||||
window-title chrome updates immediately. Thread-safe (auto-title calls
|
||||
this from its worker thread; _emit serializes on the stdout lock).
|
||||
Never raises."""
|
||||
try:
|
||||
session = _sessions.get(sid)
|
||||
agent = (session or {}).get("agent")
|
||||
if session is None or agent is None:
|
||||
return
|
||||
_emit("session.info", sid, _session_info(agent, session))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _session_live_item(sid: str, session: dict, current_sid: str = "") -> dict:
|
||||
key = str(session.get("session_key") or sid)
|
||||
agent = session.get("agent")
|
||||
|
|
@ -4624,15 +4652,18 @@ def _(rid, params: dict) -> dict:
|
|||
title = (params.get("title", "") or "").strip()
|
||||
if not title:
|
||||
return _err(rid, 4021, "title required")
|
||||
sid = str(params.get("session_id") or "")
|
||||
try:
|
||||
if db.set_session_title(key, title):
|
||||
session["pending_title"] = None
|
||||
_emit_title_refresh(sid)
|
||||
return _ok(rid, {"pending": False, "title": title})
|
||||
# rowcount == 0 can mean "same value" as well as "missing row".
|
||||
# Queue only when the session row truly does not exist yet.
|
||||
existing_row = db.get_session(key)
|
||||
if existing_row:
|
||||
session["pending_title"] = None
|
||||
_emit_title_refresh(sid)
|
||||
return _ok(
|
||||
rid,
|
||||
{
|
||||
|
|
@ -4641,6 +4672,7 @@ def _(rid, params: dict) -> dict:
|
|||
},
|
||||
)
|
||||
session["pending_title"] = title
|
||||
_emit_title_refresh(sid)
|
||||
return _ok(rid, {"pending": True, "title": title})
|
||||
except ValueError as e:
|
||||
return _err(rid, 4022, str(e))
|
||||
|
|
@ -5997,10 +6029,20 @@ def _run_prompt_submit(rid, sid: str, session: dict, text: Any) -> None:
|
|||
text,
|
||||
raw,
|
||||
session.get("history", []),
|
||||
# Auto-title lands on a background thread — refresh
|
||||
# session.info when it does so clients' window-title
|
||||
# chrome (OSC 0/2) updates without waiting for the
|
||||
# next turn. _emit is stdout-lock-guarded (thread-safe).
|
||||
title_callback=lambda _title: _emit_title_refresh(sid),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# The pending title (applied synchronously above) is visible NOW —
|
||||
# refresh session.info so window-title chrome picks it up.
|
||||
if status == "complete" and _pending:
|
||||
_emit_title_refresh(sid)
|
||||
|
||||
# CLI parity: when voice-mode TTS is on, speak the agent reply
|
||||
# (cli.py:_voice_speak_response). Only the final text — tool
|
||||
# calls / reasoning already stream separately and would be
|
||||
|
|
|
|||
|
|
@ -1 +0,0 @@
|
|||
26.3
|
||||
|
|
@ -1,53 +0,0 @@
|
|||
# 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`.
|
||||
|
|
@ -4,9 +4,9 @@ import unusedImports from "eslint-plugin-unused-imports"
|
|||
|
||||
export default tseslint.config(
|
||||
{
|
||||
// .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"],
|
||||
// .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"],
|
||||
},
|
||||
js.configs.recommended,
|
||||
...tseslint.configs.recommendedTypeChecked,
|
||||
|
|
|
|||
|
|
@ -3,9 +3,6 @@
|
|||
"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",
|
||||
|
|
|
|||
|
|
@ -1,91 +0,0 @@
|
|||
/**
|
||||
* 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 () => {}
|
||||
}
|
||||
}
|
||||
|
|
@ -52,6 +52,9 @@ export const SessionInfoPatchSchema = Schema.Struct({
|
|||
fast: opt(Bool),
|
||||
cwd: opt(Str),
|
||||
branch: opt(Str),
|
||||
// session title ("" until the first exchange titles it) — drives the
|
||||
// terminal window-title chrome (OSC 0/2 via renderer.setTerminalTitle).
|
||||
title: opt(Str),
|
||||
running: opt(Bool),
|
||||
// status-bar chrome extras (Epic 1.3): update banner, profile badge, MCP count.
|
||||
// `update_behind` is null on the wire until the async update check resolves.
|
||||
|
|
|
|||
106
ui-opentui/src/boundary/termChrome.ts
Normal file
106
ui-opentui/src/boundary/termChrome.ts
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
/**
|
||||
* Terminal chrome seam — window title (OSC 0/2) + desktop notifications
|
||||
* (OSC 9/99/777) through the renderer's native output path.
|
||||
*
|
||||
* Why the renderer and not process.stdout: the zig side owns the terminal —
|
||||
* `setTerminalTitle` is a native FFI call and `writeOut` serializes raw
|
||||
* control bytes with frame presentation (core itself uses it for OSC 111),
|
||||
* so chrome writes can never tear a frame.
|
||||
*
|
||||
* Focus suppression: core parses mode-1004 focus reports (`ESC[I`/`ESC[O`)
|
||||
* and re-emits them as renderer `focus`/`blur` events — notifications are
|
||||
* skipped while the terminal reports focused (you're already looking at it).
|
||||
* Terminals that never report focus leave the state at the assumed-focused
|
||||
* initial value… which would swallow every notification, so the FIRST blur
|
||||
* is what arms suppression: until a blur arrives we treat focus as unknown
|
||||
* and notify unconditionally (worst case: a redundant ping while focused).
|
||||
*
|
||||
* Everything here is total — chrome must never throw into the render loop
|
||||
* or a teardown path.
|
||||
*/
|
||||
import type { CliRenderer } from '@opentui/core'
|
||||
|
||||
import type { TermNotification } from '../logic/termChrome.ts'
|
||||
import {
|
||||
notifyEnabled,
|
||||
notifySequences,
|
||||
TITLE_STACK_RESTORE,
|
||||
TITLE_STACK_SAVE,
|
||||
windowTitleFor
|
||||
} from '../logic/termChrome.ts'
|
||||
import { getLog } from './log.ts'
|
||||
|
||||
/** What the view layer needs from the chrome seam (DI-friendly for tests). */
|
||||
export interface TerminalChromeSeam {
|
||||
/** Set the window title from the session title (undefined → generic). */
|
||||
readonly setTitle: (sessionTitle: string | undefined) => void
|
||||
/** Announce "waiting on you" to the hosting terminal (no-op while focused). */
|
||||
readonly notify: (notification: TermNotification) => void
|
||||
}
|
||||
|
||||
/** The renderer surface the seam writes through (runtime-verified shapes). */
|
||||
interface RendererSeam {
|
||||
setTerminalTitle(title: string): void
|
||||
writeOut(chunk: string): void
|
||||
on(event: 'focus' | 'blur', listener: () => void): unknown
|
||||
once(event: 'destroy', listener: () => void): unknown
|
||||
readonly isDestroyed: boolean
|
||||
}
|
||||
|
||||
/** Install the chrome seam on a live renderer. Idempotent per renderer use —
|
||||
* the entry calls it once, right next to the render bridge. */
|
||||
export function installTerminalChrome(renderer: CliRenderer): TerminalChromeSeam {
|
||||
const seam = renderer as unknown as RendererSeam
|
||||
const notificationsOn = notifyEnabled()
|
||||
|
||||
// unknown (null) until the terminal proves it reports focus; then boolean.
|
||||
let focused: boolean | null = null
|
||||
try {
|
||||
seam.on('focus', () => {
|
||||
focused = true
|
||||
})
|
||||
seam.on('blur', () => {
|
||||
focused = false
|
||||
})
|
||||
} catch (cause) {
|
||||
getLog().warn('chrome', 'focus tracking unavailable', { cause: String(cause) })
|
||||
}
|
||||
|
||||
// Bracket our title ownership: save the user's title now, restore on quit.
|
||||
// Best-effort — terminals without the XTWINOPS title stack ignore both.
|
||||
writeRaw(seam, TITLE_STACK_SAVE)
|
||||
seam.once('destroy', () => writeRaw(seam, TITLE_STACK_RESTORE, { evenIfDestroyed: true }))
|
||||
|
||||
let lastTitle = ''
|
||||
return {
|
||||
setTitle: sessionTitle => {
|
||||
const title = windowTitleFor(sessionTitle)
|
||||
if (title === lastTitle) return
|
||||
lastTitle = title
|
||||
try {
|
||||
if (!seam.isDestroyed) seam.setTerminalTitle(title)
|
||||
} catch (cause) {
|
||||
getLog().warn('chrome', 'setTerminalTitle failed', { cause: String(cause) })
|
||||
}
|
||||
},
|
||||
notify: notification => {
|
||||
if (!notificationsOn || focused === true) return
|
||||
for (const sequence of notifySequences(notification)) writeRaw(seam, sequence)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Raw control write through the renderer; falls back to process.stdout when
|
||||
* the renderer is already gone (the title-stack restore on destroy — at that
|
||||
* point there is no frame left to tear). */
|
||||
function writeRaw(seam: RendererSeam, chunk: string, options?: { evenIfDestroyed?: boolean }): void {
|
||||
try {
|
||||
if (!seam.isDestroyed) {
|
||||
seam.writeOut(chunk)
|
||||
return
|
||||
}
|
||||
if (options?.evenIfDestroyed) process.stdout.write(chunk)
|
||||
} catch (cause) {
|
||||
getLog().warn('chrome', 'control write failed', { cause: String(cause) })
|
||||
}
|
||||
}
|
||||
|
|
@ -29,7 +29,6 @@ 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'
|
||||
|
|
@ -48,6 +47,7 @@ import {
|
|||
} from '../logic/slash.ts'
|
||||
import { createSessionStore, type SessionStore } from '../logic/store.ts'
|
||||
import { App } from '../view/App.tsx'
|
||||
import { TerminalChrome } from '../view/terminalChrome.tsx'
|
||||
import type { SessionPickerOps } from '../view/overlays/sessionPicker.tsx'
|
||||
import { ThemeProvider } from '../view/theme.tsx'
|
||||
import { makeFakeGatewayLayer, type FakeGatewayController } from './fakeGateway.ts'
|
||||
|
|
@ -382,10 +382,6 @@ 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()
|
||||
}
|
||||
|
|
@ -542,6 +538,7 @@ export const run = Effect.fn('Tui.run')(function* (input: TuiInput) {
|
|||
() => (
|
||||
<KeymapProvider keymap={keymap}>
|
||||
<ThemeProvider theme={() => store.state.theme}>
|
||||
<TerminalChrome store={store} />
|
||||
<App
|
||||
store={store}
|
||||
onSubmit={submit}
|
||||
|
|
|
|||
|
|
@ -15,21 +15,6 @@ 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.
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@
|
|||
* (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'
|
||||
|
|
@ -150,16 +149,7 @@ function present(ctx: SlashContext, title: string, text: string): void {
|
|||
else ctx.pushSystem(text)
|
||||
}
|
||||
|
||||
/** 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 = [
|
||||
const CLIENT_HELP = [
|
||||
'/help — list commands',
|
||||
'/model [name] — switch model (picker if bare)',
|
||||
'/copy [n] — copy the last (or n-th) response',
|
||||
|
|
@ -170,17 +160,12 @@ const CLIENT_HELP_LINES = [
|
|||
'/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 (diag)',
|
||||
'/heapdump — write a V8 heap snapshot (diag)',
|
||||
'/mem — live memory stats',
|
||||
'/heapdump — write a V8 heap snapshot',
|
||||
'/logs — recent engine log lines',
|
||||
'/quit, /exit — quit',
|
||||
'(other /commands run on the gateway)'
|
||||
]
|
||||
|
||||
function clientHelp(): string {
|
||||
const lines = diagnosticsEnabled() ? CLIENT_HELP_LINES : CLIENT_HELP_LINES.filter(l => !l.includes('(diag)'))
|
||||
return lines.join('\n')
|
||||
}
|
||||
].join('\n')
|
||||
|
||||
type ClientHandler = (arg: string, ctx: SlashContext) => void | Promise<void>
|
||||
|
||||
|
|
@ -646,9 +631,9 @@ const CLIENT: Record<string, ClientHandler> = {
|
|||
// 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) || clientHelp())
|
||||
ctx.pushSystem(renderCatalog(cat) || CLIENT_HELP)
|
||||
} catch {
|
||||
ctx.pushSystem(clientHelp())
|
||||
ctx.pushSystem(CLIENT_HELP)
|
||||
}
|
||||
},
|
||||
logs: (_arg, ctx) => ctx.openPager('Logs', ctx.logTail().join('\n') || '(log empty)'),
|
||||
|
|
@ -658,8 +643,7 @@ const CLIENT: Record<string, ClientHandler> = {
|
|||
|
||||
/** The registered client-command names (catalog introspection — tests/menus). */
|
||||
export function clientCommandNames(): string[] {
|
||||
const names = Object.keys(CLIENT)
|
||||
return (diagnosticsEnabled() ? names : names.filter(n => !DIAGNOSTIC_COMMANDS.has(n))).sort()
|
||||
return Object.keys(CLIENT).sort()
|
||||
}
|
||||
|
||||
/** Render the gateway `commands.catalog` into a help block (loose-typed read).
|
||||
|
|
@ -717,12 +701,6 @@ export async function dispatchSlash(input: string, ctx: SlashContext): Promise<v
|
|||
const parsed = parseSlash(input)
|
||||
if (!parsed) return
|
||||
|
||||
if (DIAGNOSTIC_COMMANDS.has(parsed.name) && !diagnosticsEnabled()) {
|
||||
// Not a secret — an enable switch. Tell the user exactly how to get it.
|
||||
ctx.pushSystem(`/${parsed.name} is a diagnostic command — relaunch with HERMES_TUI_DIAGNOSTICS=1 to enable it.`)
|
||||
return
|
||||
}
|
||||
|
||||
const client = CLIENT[parsed.name]
|
||||
if (client) {
|
||||
await client(parsed.arg, ctx)
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ import {
|
|||
import type { DetailsMode } from './details.ts'
|
||||
import { diffStats, type DiffStats } from './diff.ts'
|
||||
import type { SessionTabId } from './sessionPicker.ts'
|
||||
import { envFlag, envOutputUnlimited } from './env.ts'
|
||||
import { envOutputUnlimited } from './env.ts'
|
||||
import { registerNotifier } from './notify.ts'
|
||||
import { stripAnsi, stripOmittedNote, stripToolEnvelope } from './toolOutput.ts'
|
||||
import { DEFAULT_THEME, type Theme, themeFromSkin } from './theme.ts'
|
||||
|
|
@ -181,6 +181,9 @@ export interface SessionInfo {
|
|||
fast?: boolean
|
||||
cwd?: string
|
||||
branch?: string
|
||||
/** Session title (auto-titled after the first exchange / renamed via the
|
||||
* picker) — drives the terminal window-title chrome; unset until titled. */
|
||||
title?: string
|
||||
running?: boolean
|
||||
contextUsed?: number
|
||||
contextMax?: number
|
||||
|
|
@ -324,6 +327,7 @@ function infoPatchFrom(d: SessionInfoPatchDecoded): Partial<SessionInfo> {
|
|||
if (d.fast !== undefined) patch.fast = d.fast
|
||||
if (d.cwd) patch.cwd = d.cwd
|
||||
if (d.branch) patch.branch = d.branch
|
||||
if (d.title) patch.title = d.title
|
||||
if (d.running !== undefined) patch.running = d.running
|
||||
// prefer the nested usage.context_* numbers, else the top-level fallback.
|
||||
const used = d.usage?.context_used ?? d.context_used
|
||||
|
|
@ -411,27 +415,17 @@ export function createSessionStore(options?: SessionStoreOptions) {
|
|||
// HANDLE_SAFE_MAX_ROWS = 1000 ≈ 47k handles ≈ 72% of the table on the
|
||||
// realistic-fixture mix, leaving ~18k slots of headroom for chrome
|
||||
// (composer/pickers/dashboard) and heavier-than-fixture rows. Pathological
|
||||
// rows can still exceed it; nativeHandles.ts degrades (unstyled text)
|
||||
// instead of crashing. `HERMES_TUI_MAX_MESSAGES` can LOWER the cap but
|
||||
// rows can still exceed it — renderable-weight-aware capping belongs to the
|
||||
// virtualization work (#27); until then nativeHandles.ts degrades (unstyled
|
||||
// text) instead of crashing. `HERMES_TUI_MAX_MESSAGES` can LOWER the cap but
|
||||
// never raise it past the ceiling. Read once per store. Trimmed turns aren't
|
||||
// lost — they live on the gateway and are recoverable via `/resume`.
|
||||
//
|
||||
// With transcript WINDOWING on (#27, S1+S2 in view/transcript.tsx — the
|
||||
// default), handles no longer scale with the store: out-of-window rows are
|
||||
// exact-height spacers and the mounted set is ~3 viewports (measured peak 31
|
||||
// rows over a 1500-row burst), so the scrollback ceiling returns to 3000
|
||||
// (the originally shipped default, regression documented in
|
||||
// docs/plans/opentui-fixes-audit.md §2). The HERMES_TUI_WINDOWING=0 escape
|
||||
// hatch mounts every row again, so it keeps the handle-safe 1000 clamp.
|
||||
const HANDLE_SAFE_MAX_ROWS = 1000
|
||||
const WINDOWED_MAX_ROWS = 3000
|
||||
const MESSAGE_CAP = (() => {
|
||||
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 : ceiling
|
||||
return Math.min(requested, ceiling)
|
||||
const requested = Number.isFinite(raw) && raw > 0 ? raw : HANDLE_SAFE_MAX_ROWS
|
||||
return Math.min(requested, HANDLE_SAFE_MAX_ROWS)
|
||||
})()
|
||||
|
||||
const [state, setState] = createStore<StoreState>({
|
||||
|
|
@ -1000,11 +994,6 @@ export function createSessionStore(options?: SessionStoreOptions) {
|
|||
// briefly hands the full fetched history to <For>. 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
|
||||
|
|
|
|||
105
ui-opentui/src/logic/termChrome.ts
Normal file
105
ui-opentui/src/logic/termChrome.ts
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
/**
|
||||
* Terminal chrome logic — window-title text and desktop-notification OSC
|
||||
* sequences. Pure string work (no OpenTUI imports); the boundary shim
|
||||
* (`boundary/termChrome.ts`) owns the renderer writes and focus tracking.
|
||||
*
|
||||
* Title: OSC 0/2 content is set natively via `renderer.setTerminalTitle`
|
||||
* (the zig side emits the escape) — this module only SHAPES the text:
|
||||
* `"{session title} — Hermes"` once the gateway titles the session,
|
||||
* `"Hermes Agent"` until then.
|
||||
*
|
||||
* Notifications: emitted when the TUI starts waiting on the user (blocking
|
||||
* prompt, turn complete). Three dialects, terminals ignore what they don't
|
||||
* speak:
|
||||
* OSC 9 `ESC ] 9 ; message BEL` (iTerm2 / ConEmu / wezterm)
|
||||
* OSC 99 `ESC ] 99 ; i=hermes ; title ST` (kitty desktop-notification
|
||||
* protocol — `p` defaults to title, `d` defaults to done)
|
||||
* OSC 777 `ESC ] 777 ; notify ; title ; body BEL` (urxvt / foot)
|
||||
*/
|
||||
|
||||
const ESC = '\u001b'
|
||||
const BEL = '\u0007'
|
||||
const ST = `${ESC}\\`
|
||||
|
||||
/** Strip control chars (C0/C1, incl. ESC/BEL) so user text can never
|
||||
* terminate or splice an escape sequence; collapse runs of whitespace;
|
||||
* cap the length. */
|
||||
export function sanitizeOscText(text: string, max = 120): string {
|
||||
const clean = (text ?? '')
|
||||
// eslint-disable-next-line no-control-regex
|
||||
.replace(/[\u0000-\u001f\u007f-\u009f]/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
return clean.length > max ? clean.slice(0, Math.max(1, max - 1)) + '…' : clean
|
||||
}
|
||||
|
||||
/** The window-title string: session title when known, Ink-era generic otherwise. */
|
||||
export function windowTitleFor(sessionTitle: string | undefined): string {
|
||||
const title = sanitizeOscText(sessionTitle ?? '', 80)
|
||||
return title ? `${title} — Hermes` : 'Hermes Agent'
|
||||
}
|
||||
|
||||
/** A notification's two text parts (body optional). */
|
||||
export interface TermNotification {
|
||||
readonly title: string
|
||||
readonly body?: string
|
||||
}
|
||||
|
||||
/** The raw escape sequences announcing `n` to the hosting terminal. OSC 9 and
|
||||
* 777 conventionally terminate with BEL; kitty's OSC 99 spec uses ST.
|
||||
* Semicolons are swapped out of the OSC 777 fields (its field separator). */
|
||||
export function notifySequences(n: TermNotification): string[] {
|
||||
const title = sanitizeOscText(n.title)
|
||||
const body = sanitizeOscText(n.body ?? '')
|
||||
if (!title) return []
|
||||
const combined = body ? `${title}: ${body}` : title
|
||||
const f777 = (s: string) => s.replace(/;/g, ',')
|
||||
return [
|
||||
`${ESC}]9;${combined}${BEL}`,
|
||||
`${ESC}]99;i=hermes;${combined}${ST}`,
|
||||
`${ESC}]777;notify;${f777(title)};${f777(body || title)}${BEL}`
|
||||
]
|
||||
}
|
||||
|
||||
/** The XTWINOPS title-stack pushes/pops bracketing our title ownership: save
|
||||
* the user's title on install, restore it on teardown (terminals without the
|
||||
* stack ignore these — they just keep our last title, same as today). */
|
||||
export const TITLE_STACK_SAVE = `${ESC}[22;0t`
|
||||
export const TITLE_STACK_RESTORE = `${ESC}[23;0t`
|
||||
|
||||
/** What to announce for a blocking prompt, by kind. Kinds arrive from the
|
||||
* store's ActivePrompt union; unknown kinds get the generic line so a new
|
||||
* prompt type can never silently drop notifications. */
|
||||
export function promptNotification(kind: string): TermNotification {
|
||||
switch (kind) {
|
||||
case 'clarify':
|
||||
return { title: 'Hermes', body: 'needs an answer to continue' }
|
||||
case 'approval':
|
||||
return { title: 'Hermes', body: 'wants approval to run a command' }
|
||||
case 'sudo':
|
||||
return { title: 'Hermes', body: 'needs your sudo password' }
|
||||
case 'secret':
|
||||
return { title: 'Hermes', body: 'needs a secret/API key' }
|
||||
case 'confirm':
|
||||
return { title: 'Hermes', body: 'is asking you to confirm' }
|
||||
default:
|
||||
return { title: 'Hermes', body: 'is waiting for your input' }
|
||||
}
|
||||
}
|
||||
|
||||
/** Turn-complete announcement. */
|
||||
export const TURN_COMPLETE_NOTIFICATION: TermNotification = {
|
||||
title: 'Hermes',
|
||||
body: 'finished — awaiting your input'
|
||||
}
|
||||
|
||||
/**
|
||||
* `HERMES_TUI_NOTIFY` kill-switch (TUI-only env var, same family as
|
||||
* HERMES_TUI_TOOL_OUTPUT_LINES): unset/anything-else = on, `0`/`false`/`off`
|
||||
* = no notification sequences are ever written. The window title is NOT
|
||||
* gated by this — it's chrome, not interruption.
|
||||
*/
|
||||
export function notifyEnabled(env: { readonly [k: string]: string | undefined } = process.env): boolean {
|
||||
const raw = (env.HERMES_TUI_NOTIFY ?? '').trim().toLowerCase()
|
||||
return raw !== '0' && raw !== 'false' && raw !== 'off'
|
||||
}
|
||||
|
|
@ -1,263 +0,0 @@
|
|||
/**
|
||||
* 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<K> {
|
||||
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<K> {
|
||||
readonly rows: readonly WindowRow<K>[]
|
||||
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<K> {
|
||||
/** Row keys that must be mounted; everything else renders as a spacer. */
|
||||
readonly mounted: ReadonlySet<K>
|
||||
/** 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<K>(params: WindowParams<K>): WindowResult<K> {
|
||||
const fallback = params.fallbackHeight ?? DEFAULT_FALLBACK_HEIGHT
|
||||
const bottomK = params.bottomK ?? 0
|
||||
const heightOf = (r: WindowRow<K>): 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<K>()
|
||||
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<K>(rows: readonly WindowRow<K>[], mounted: ReadonlySet<K>, 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<WindowRowStats> {
|
||||
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<Message, 'text' | 'parts'> & { 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
|
||||
}
|
||||
|
|
@ -65,8 +65,6 @@ export interface RenderProbe {
|
|||
readonly scroll: (x: number, y: number, direction: 'up' | 'down') => Promise<void>
|
||||
/** 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<void>
|
||||
readonly destroy: () => void
|
||||
|
|
@ -123,7 +121,6 @@ export async function renderProbe(
|
|||
await setup.flush()
|
||||
},
|
||||
keys: setup.mockInput,
|
||||
renderer: setup.renderer,
|
||||
settle: async () => {
|
||||
await setup.renderOnce()
|
||||
await setup.flush()
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ import { afterEach, describe, expect, test } from 'vitest'
|
|||
import type { DetailsMode } from '../logic/details.ts'
|
||||
import {
|
||||
buildModelTabs,
|
||||
clientCommandNames,
|
||||
dispatchSlash,
|
||||
mapCompletions,
|
||||
parseSlash,
|
||||
|
|
@ -567,40 +566,3 @@ 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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -506,14 +506,10 @@ 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)', () => {
|
||||
|
|
@ -582,37 +578,23 @@ describe('session store — rolling message cap (bounds the Yoga node high-water
|
|||
expect(store.state.messages.at(-1)!.text).toBe('h7')
|
||||
})
|
||||
|
||||
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).
|
||||
test('defaults to 1000 (the handle-safe ceiling) when the env var is unset/invalid', () => {
|
||||
delete process.env[ENV_KEY]
|
||||
delete process.env[WINDOWING_KEY]
|
||||
const store = createSessionStore()
|
||||
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('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')
|
||||
expect(store.state.messages[0]!.text).toBe('m50') // oldest 50 dropped
|
||||
})
|
||||
|
||||
test('env values ABOVE the ceiling are clamped to it (the native handle table binds, not memory)', () => {
|
||||
test('env values ABOVE the handle-safe 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 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.
|
||||
// ≳1,400 live rows crashes mid-mount ("Failed to create SyntaxStyle").
|
||||
// A 100000 "cap" is therefore a crash sentence, not a cap — clamp it.
|
||||
process.env[ENV_KEY] = '100000'
|
||||
delete process.env[WINDOWING_KEY]
|
||||
const store = createSessionStore()
|
||||
for (let i = 0; i < 3100; i++) store.pushUser(`m${i}`)
|
||||
expect(store.state.messages).toHaveLength(3000)
|
||||
for (let i = 0; i < 1100; i++) store.pushUser(`m${i}`)
|
||||
expect(store.state.messages).toHaveLength(1000)
|
||||
expect(store.state.dropped).toBe(100)
|
||||
expect(store.state.messages[0]!.text).toBe('m100')
|
||||
})
|
||||
|
|
|
|||
149
ui-opentui/src/test/termChrome.test.tsx
Normal file
149
ui-opentui/src/test/termChrome.test.tsx
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
/**
|
||||
* Terminal chrome: OSC 0/2 window title + OSC 9/99/777 waiting-on-you
|
||||
* notifications. Layers:
|
||||
* 1. pure: title shaping, OSC sanitization, the three notification
|
||||
* dialect sequences, the prompt-kind copy, the env kill-switch
|
||||
* (logic/termChrome.ts).
|
||||
* 2. wiring: <TerminalChrome> with an injected fake seam over a real
|
||||
* store — the title tracks session.info, prompts and turn-completion
|
||||
* edges notify exactly once, initial state stays silent.
|
||||
*/
|
||||
import { createRoot } from 'solid-js'
|
||||
import { describe, expect, test } from 'vitest'
|
||||
|
||||
import type { TerminalChromeSeam } from '../boundary/termChrome.ts'
|
||||
import { createSessionStore } from '../logic/store.ts'
|
||||
import {
|
||||
notifyEnabled,
|
||||
notifySequences,
|
||||
promptNotification,
|
||||
sanitizeOscText,
|
||||
TURN_COMPLETE_NOTIFICATION,
|
||||
windowTitleFor
|
||||
} from '../logic/termChrome.ts'
|
||||
import { TerminalChrome } from '../view/terminalChrome.tsx'
|
||||
|
||||
const ESC = '\u001b'
|
||||
const BEL = '\u0007'
|
||||
|
||||
describe('windowTitleFor — title shaping', () => {
|
||||
test('generic until the session is titled', () => {
|
||||
expect(windowTitleFor(undefined)).toBe('Hermes Agent')
|
||||
expect(windowTitleFor('')).toBe('Hermes Agent')
|
||||
expect(windowTitleFor(' ')).toBe('Hermes Agent')
|
||||
})
|
||||
|
||||
test('session title gets the — Hermes suffix', () => {
|
||||
expect(windowTitleFor('fix the flaky tests')).toBe('fix the flaky tests — Hermes')
|
||||
})
|
||||
|
||||
test('long titles are capped', () => {
|
||||
const long = 'x'.repeat(200)
|
||||
expect(windowTitleFor(long).length).toBeLessThanOrEqual(80 + ' — Hermes'.length)
|
||||
expect(windowTitleFor(long)).toContain('…')
|
||||
})
|
||||
})
|
||||
|
||||
describe('sanitizeOscText — escape-splice safety', () => {
|
||||
test('control chars (incl. ESC/BEL) can never splice a sequence', () => {
|
||||
expect(sanitizeOscText(`evil${ESC}]0;pwned${BEL}title`)).toBe('evil ]0;pwned title')
|
||||
})
|
||||
|
||||
test('whitespace collapses; ends trimmed', () => {
|
||||
expect(sanitizeOscText(' a\n\tb ')).toBe('a b')
|
||||
})
|
||||
})
|
||||
|
||||
describe('notifySequences — the three dialects', () => {
|
||||
test('emits OSC 9, kitty OSC 99, and OSC 777', () => {
|
||||
const [osc9, osc99, osc777] = notifySequences({ title: 'Hermes', body: 'finished' })
|
||||
expect(osc9).toBe(`${ESC}]9;Hermes: finished${BEL}`)
|
||||
expect(osc99).toBe(`${ESC}]99;i=hermes;Hermes: finished${ESC}\\`)
|
||||
expect(osc777).toBe(`${ESC}]777;notify;Hermes;finished${BEL}`)
|
||||
})
|
||||
|
||||
test('semicolons cannot splice OSC 777 fields', () => {
|
||||
const [, , osc777] = notifySequences({ title: 'a;b', body: 'c;d' })
|
||||
expect(osc777).toBe(`${ESC}]777;notify;a,b;c,d${BEL}`)
|
||||
})
|
||||
|
||||
test('an empty title produces nothing', () => {
|
||||
expect(notifySequences({ title: ' ' })).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('promptNotification + env gate', () => {
|
||||
test('every known prompt kind has copy; unknown kinds fall back', () => {
|
||||
for (const kind of ['clarify', 'approval', 'sudo', 'secret', 'confirm', 'someday-new']) {
|
||||
const n = promptNotification(kind)
|
||||
expect(n.title).toBe('Hermes')
|
||||
expect(n.body).toBeTruthy()
|
||||
}
|
||||
expect(promptNotification('someday-new').body).toBe('is waiting for your input')
|
||||
})
|
||||
|
||||
test('HERMES_TUI_NOTIFY=0/false/off disables; default on', () => {
|
||||
expect(notifyEnabled({})).toBe(true)
|
||||
expect(notifyEnabled({ HERMES_TUI_NOTIFY: '1' })).toBe(true)
|
||||
expect(notifyEnabled({ HERMES_TUI_NOTIFY: '0' })).toBe(false)
|
||||
expect(notifyEnabled({ HERMES_TUI_NOTIFY: 'false' })).toBe(false)
|
||||
expect(notifyEnabled({ HERMES_TUI_NOTIFY: 'off' })).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('<TerminalChrome> wiring — store edges drive the seam', () => {
|
||||
function mount() {
|
||||
const store = createSessionStore()
|
||||
const titles: Array<string | undefined> = []
|
||||
const notifications: string[] = []
|
||||
const seam: TerminalChromeSeam = {
|
||||
setTitle: t => titles.push(t),
|
||||
notify: n => notifications.push(n.body ?? n.title)
|
||||
}
|
||||
const dispose = createRoot(d => {
|
||||
TerminalChrome({ chrome: seam, store })
|
||||
return d
|
||||
})
|
||||
return { dispose, notifications, store, titles }
|
||||
}
|
||||
|
||||
test('sets the generic title immediately, then tracks session.info title', () => {
|
||||
const { dispose, store, titles } = mount()
|
||||
try {
|
||||
expect(titles).toEqual([undefined]) // boot → windowTitleFor(undefined) inside the seam
|
||||
store.apply({ type: 'session.info', payload: { title: 'rename the moon' } })
|
||||
expect(titles).toEqual([undefined, 'rename the moon'])
|
||||
} finally {
|
||||
dispose()
|
||||
}
|
||||
})
|
||||
|
||||
test('a blocking prompt notifies once; initial state is silent', () => {
|
||||
const { dispose, notifications, store } = mount()
|
||||
try {
|
||||
expect(notifications).toEqual([])
|
||||
store.apply({
|
||||
type: 'clarify.request',
|
||||
payload: { choices: null, question: 'which one?', request_id: 'r1' }
|
||||
})
|
||||
expect(notifications).toEqual(['needs an answer to continue'])
|
||||
// clearing the prompt does not notify again
|
||||
store.clearPrompt()
|
||||
expect(notifications).toEqual(['needs an answer to continue'])
|
||||
} finally {
|
||||
dispose()
|
||||
}
|
||||
})
|
||||
|
||||
test('turn completion (running true→false) notifies; boot idle does not', () => {
|
||||
const { dispose, notifications, store } = mount()
|
||||
try {
|
||||
store.apply({ type: 'message.start' })
|
||||
expect(notifications).toEqual([])
|
||||
store.apply({ type: 'message.complete', payload: { text: 'done' } })
|
||||
expect(notifications).toEqual([TURN_COMPLETE_NOTIFICATION.body])
|
||||
} finally {
|
||||
dispose()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
@ -1,370 +0,0 @@
|
|||
/**
|
||||
* 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<typeof createSessionStore>
|
||||
|
||||
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<Mounted> {
|
||||
process.env.HERMES_TUI_WINDOWING = windowing
|
||||
let root: Renderable | undefined
|
||||
function Grab() {
|
||||
root = useRenderer().root
|
||||
return null
|
||||
}
|
||||
const probe = await renderProbe(
|
||||
() => (
|
||||
<ThemeProvider theme={() => store.state.theme}>
|
||||
<Grab />
|
||||
<Transcript store={store} />
|
||||
</ThemeProvider>
|
||||
),
|
||||
{ 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)
|
||||
})
|
||||
|
|
@ -22,19 +22,6 @@ 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') }))
|
||||
|
|
|
|||
|
|
@ -1,375 +0,0 @@
|
|||
/**
|
||||
* 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<number> {
|
||||
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<number>[] {
|
||||
return Array.from({ length: n }, (_, i) => row(i, h))
|
||||
}
|
||||
|
||||
function mountedKeys(result: { mounted: ReadonlySet<number> }): 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<string>[] = [
|
||||
{ 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<Message, 'text' | 'parts'> = {
|
||||
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<Message, 'text' | 'parts'> = {
|
||||
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<Message, 'text' | 'parts'> = {
|
||||
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<Message, 'text' | 'parts'> = {
|
||||
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<number>([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<number>(), 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<number>([2]), 10)).toEqual([0])
|
||||
})
|
||||
|
||||
test('fully measured transcript → nothing to do', () => {
|
||||
expect(edgeMeasureBatch(uniform(5, 10), new Set<number>(), 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()
|
||||
})
|
||||
})
|
||||
61
ui-opentui/src/view/terminalChrome.tsx
Normal file
61
ui-opentui/src/view/terminalChrome.tsx
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
/**
|
||||
* <TerminalChrome> — render-nothing wiring between store state and the
|
||||
* terminal-chrome seam (window title + waiting-on-you notifications).
|
||||
*
|
||||
* title: tracks `info.title` — "Hermes Agent" until the gateway titles
|
||||
* the session (first-exchange auto-title / picker rename), then
|
||||
* "{title} — Hermes". Runs immediately on mount so the generic
|
||||
* title is set at boot.
|
||||
* notify: fires on the EDGES the user must act on —
|
||||
* · a blocking prompt appearing (clarify/approval/sudo/secret/
|
||||
* confirm — `state.prompt` undefined → defined), and
|
||||
* · a turn finishing (`info.running` true → false).
|
||||
* Both deferred (no notification for the initial state) and
|
||||
* de-duplicated by the seam's focus gate.
|
||||
*
|
||||
* The seam is injectable for headless tests; production resolves it from
|
||||
* the live renderer via useRenderer().
|
||||
*/
|
||||
import { useRenderer } from '@opentui/solid'
|
||||
import { createEffect, on } from 'solid-js'
|
||||
|
||||
import { installTerminalChrome, type TerminalChromeSeam } from '../boundary/termChrome.ts'
|
||||
import type { createSessionStore } from '../logic/store.ts'
|
||||
import { promptNotification, TURN_COMPLETE_NOTIFICATION } from '../logic/termChrome.ts'
|
||||
|
||||
type Store = ReturnType<typeof createSessionStore>
|
||||
|
||||
export function TerminalChrome(props: { store: Store; chrome?: TerminalChromeSeam }) {
|
||||
// Injected seam (tests) skips useRenderer() — headless mounts have no
|
||||
// opentui context to read.
|
||||
const chrome = props.chrome ?? installTerminalChrome(useRenderer())
|
||||
|
||||
// Window title — immediate (sets the generic title at boot) and reactive.
|
||||
createEffect(() => {
|
||||
chrome.setTitle(props.store.state.info.title)
|
||||
})
|
||||
|
||||
// Blocking prompt appeared → the agent is waiting on the user.
|
||||
createEffect(
|
||||
on(
|
||||
() => props.store.state.prompt,
|
||||
(prompt, previous) => {
|
||||
if (prompt && !previous) chrome.notify(promptNotification(prompt.kind))
|
||||
},
|
||||
{ defer: true }
|
||||
)
|
||||
)
|
||||
|
||||
// Turn finished → control is back with the user.
|
||||
createEffect(
|
||||
on(
|
||||
() => props.store.state.info.running,
|
||||
(running, previous) => {
|
||||
if (previous === true && running === false) chrome.notify(TURN_COMPLETE_NOTIFICATION)
|
||||
},
|
||||
{ defer: true }
|
||||
)
|
||||
)
|
||||
|
||||
return null
|
||||
}
|
||||
|
|
@ -13,138 +13,20 @@
|
|||
*
|
||||
* 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 `<box height={recorded}/>`
|
||||
* — 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 `<Show>` 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 { 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 { ScrollBoxRenderable } from '@opentui/core'
|
||||
import { createMemo, createSignal, For, Show } from 'solid-js'
|
||||
|
||||
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 type { SessionStore } from '../logic/store.ts'
|
||||
import { DisplayProvider } from './display.tsx'
|
||||
import { HomeHint } from './homeHint.tsx'
|
||||
import { MessageLine, turnSpacing } from './messageLine.tsx'
|
||||
import { MessageLine } 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<number>
|
||||
readonly known: ReadonlySet<number>
|
||||
}
|
||||
|
||||
function sameSet(a: ReadonlySet<number>, b: ReadonlySet<number>): 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<ScrollBoxRenderable | undefined>()
|
||||
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
|
||||
|
|
@ -156,326 +38,6 @@ 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 (<For> 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<Message, number>()
|
||||
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<number, number>()
|
||||
// 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<number, number>()
|
||||
// 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<number, boolean>()
|
||||
// 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<number, BoxRenderable>()
|
||||
const wrapperKeys = new WeakMap<object, number>()
|
||||
// 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<number, number>()
|
||||
let estimatesCompact = false
|
||||
const [winState, setWinState] = createSignal<WinState | undefined>(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 <Show>.
|
||||
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<string, unknown>)['__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<number> = new Set<number>()
|
||||
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<number>()
|
||||
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<number>()
|
||||
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<void> => {
|
||||
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 <MessageLine message={rowProps.message} latest={rowProps.index() === latestAssistant()} />
|
||||
}
|
||||
return (
|
||||
<box
|
||||
ref={el => {
|
||||
wrapper = el
|
||||
wrappers.set(key, el)
|
||||
wrapperKeys.set(el, key)
|
||||
}}
|
||||
style={{ flexDirection: 'column', flexShrink: 0 }}
|
||||
onSizeChange={record}
|
||||
>
|
||||
<Show
|
||||
when={isMounted(key)}
|
||||
fallback={<box style={{ height: heights.get(key) ?? estimateFor(rowProps.message, key), flexShrink: 0 }} />}
|
||||
>
|
||||
<RealRow />
|
||||
</Show>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<box style={{ flexGrow: 1, minHeight: 0 }}>
|
||||
<scrollbox ref={setScroll} style={{ flexGrow: 1, minHeight: 0 }} stickyScroll stickyStart="bottom">
|
||||
|
|
@ -496,13 +58,7 @@ export function Transcript(props: { store: SessionStore }) {
|
|||
</text>
|
||||
</Show>
|
||||
<For each={props.store.state.messages}>
|
||||
{(message, i) =>
|
||||
windowing ? (
|
||||
<WindowedRow message={message} index={i} />
|
||||
) : (
|
||||
<MessageLine message={message} latest={i() === latestAssistant()} />
|
||||
)
|
||||
}
|
||||
{(message, i) => <MessageLine message={message} latest={i() === latestAssistant()} />}
|
||||
</For>
|
||||
</DisplayProvider>
|
||||
</ScrollAnchorProvider>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue