From ef9456212538fe6ee2f2dbf354726fc2017a8e0f Mon Sep 17 00:00:00 2001 From: alt-glitch Date: Fri, 12 Jun 2026 13:09:23 +0530 Subject: [PATCH] opentui(v6): terminal window title (OSC 0/2) + waiting-on-you notifications (OSC 9/99/777) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Window title: a render-nothing 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. --- docs/opentui-env-flags.md | 58 --- docs/opentui-memory-story.md | 207 -------- docs/opentui-upstream-alignment.md | 73 --- tests/test_tui_gateway_server.py | 45 ++ tui_gateway/server.py | 42 ++ ui-opentui/.node-version | 1 - ui-opentui/README.md | 53 -- ui-opentui/eslint.config.mjs | 6 +- ui-opentui/package.json | 3 - ui-opentui/src/boundary/memlog.ts | 91 ---- ui-opentui/src/boundary/schema/SessionInfo.ts | 3 + ui-opentui/src/boundary/termChrome.ts | 106 ++++ ui-opentui/src/entry/main.tsx | 7 +- ui-opentui/src/logic/env.ts | 15 - ui-opentui/src/logic/slash.ts | 36 +- ui-opentui/src/logic/store.ts | 31 +- ui-opentui/src/logic/termChrome.ts | 105 ++++ ui-opentui/src/logic/window.ts | 263 ---------- ui-opentui/src/test/lib/render.ts | 3 - ui-opentui/src/test/slash.test.ts | 38 -- ui-opentui/src/test/store.test.ts | 32 +- ui-opentui/src/test/termChrome.test.tsx | 149 ++++++ ui-opentui/src/test/transcriptWindow.test.tsx | 370 -------------- ui-opentui/src/test/utilityCommands.test.ts | 13 - ui-opentui/src/test/window.test.ts | 375 --------------- ui-opentui/src/view/terminalChrome.tsx | 61 +++ ui-opentui/src/view/transcript.tsx | 454 +----------------- 27 files changed, 545 insertions(+), 2095 deletions(-) delete mode 100644 docs/opentui-env-flags.md delete mode 100644 docs/opentui-memory-story.md delete mode 100644 docs/opentui-upstream-alignment.md delete mode 100644 ui-opentui/.node-version delete mode 100644 ui-opentui/README.md delete mode 100644 ui-opentui/src/boundary/memlog.ts create mode 100644 ui-opentui/src/boundary/termChrome.ts create mode 100644 ui-opentui/src/logic/termChrome.ts delete mode 100644 ui-opentui/src/logic/window.ts create mode 100644 ui-opentui/src/test/termChrome.test.tsx delete mode 100644 ui-opentui/src/test/transcriptWindow.test.tsx delete mode 100644 ui-opentui/src/test/window.test.ts create mode 100644 ui-opentui/src/view/terminalChrome.tsx diff --git a/docs/opentui-env-flags.md b/docs/opentui-env-flags.md deleted file mode 100644 index 23b7ba86ec5..00000000000 --- a/docs/opentui-env-flags.md +++ /dev/null @@ -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/-.jsonl` (rss/heap/external + mounted rows; 14-day retention). Fleet view: `node bench/memwatch-report.mjs`. The "monitor all my sessions" answer: one `export HERMES_TUI_DIAGNOSTICS=1` in your shell rc covers every session. | -| `HERMES_TUI_LOG_LEVEL` / `HERMES_TUI_LOG_FILE` | engine defaults | Logging verbosity/destination (`/logs` reads the ring buffer regardless). Deliberately independent of the master switch — support often wants logs without the full diag surface. | - -## 4. Internal plumbing (set by the launcher/bench/tests — humans never set these) - -| var | set by | effect | -|---|---|---| -| `HERMES_PYTHON`, `HERMES_PYTHON_SRC_ROOT`, `HERMES_CWD` | launcher / bench | Which gateway python + repo root + cwd the TUI spawns against (the bench's fake-gateway seam). | -| `HERMES_TUI_ACTIVE_SESSION_FILE` | launcher/bench | Session handoff file. | -| `HERMES_TUI_RESUME`, `HERMES_TUI_PROMPT`, `HERMES_TUI_FAKE` | launcher/tests | Resume-at-boot, seeded prompt, fake-mode. | -| `HERMES_TUI_RPC_TIMEOUT_MS`, `HERMES_TUI_STARTUP_TIMEOUT_MS` | tests/CI | Protocol timeouts. | -| (`ui-tui` only) `HERMES_TUI_MEMSAMPLE_FD/MS` | bench | Ink fd-3 node sampler. | - -## How the pieces compose (the support script) - -- Regular user, normal day: zero flags, zero diagnostic commands visible. -- "My TUI feels heavy" support flow: `HERMES_TUI_DIAGNOSTICS=1 hermes` → `/mem` - for the live numbers, `/heapdump` for a snapshot to attach, window stats - exposed for `bench/live-attach.sh ` to read. -- Developer profiling: same master switch + the individual knobs - (`HERMES_TUI_WINDOWING=0` A/B, `WINDOW_IDLE_MS` tuning) as needed. -- Anything in section 4 appearing in a user-facing doc is a bug. - -Gating implementation: `logic/env.ts` (`diagnosticsEnabled()`), -`logic/slash.ts` (`DIAGNOSTIC_COMMANDS` — dispatch hint, help + completion -filtering), `view/transcript.tsx` (stats default). Tests: -`slash.test.ts` (gating both states), `utilityCommands.test.ts` (commands -themselves, gate enabled suite-wide). diff --git a/docs/opentui-memory-story.md b/docs/opentui-memory-story.md deleted file mode 100644 index 63ec531c66e..00000000000 --- a/docs/opentui-memory-story.md +++ /dev/null @@ -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 — ``, ``, ``, -``, ``. One transcript row (a message with its tool calls, -markdown, code blocks, copy chips) is a *tree* of these: **~16 text renderables -≈ 47 native handles ≈ ~250–340KB of RSS, per row.** This is the number that -drives everything. 1,400 mounted rows × 47 handles = table full = the crash we -root-caused last week. - -**Yoga (the layout engine, WASM).** Every renderable also has a Yoga node — -Yoga is the flexbox calculator that decides where boxes go. OpenTUI ships it -compiled to **WebAssembly**, and WASM has a brutal property: its memory can -**grow but never shrink** back to the OS. So the peak number of -*simultaneously-mounted* renderables sets a high-water mark you pay **forever**, -even after everything is destroyed. (Fun fact from this week's forensics: we -spent two days believing Ink had this disease. It doesn't — our Ink fork swapped -Yoga-WASM for a plain TypeScript port at fork creation. **We** are the ones -running layout in WASM. The accusation was true; we just had the defendant -wrong.) - -**Solid (the view framework).** Renders each store message into a row via -``. The property we exploit: Solid mounts/unmounts *surgically* — remove a -row from what the component returns and Solid destroys exactly that row's -renderables (returning its handles and freeing its Yoga nodes), touching -nothing else. No virtual-DOM diffing, no collateral re-renders. - -**V8 (the JavaScript engine) + the store.** The store keeps every message as JS -strings/objects. V8's garbage collector is *lazy by design*: with the default -8GB ceiling we launch with, it sees no reason to clean up aggressively, so RSS -includes a lot of "collectible but not yet collected" garbage. Cheap to fix, -worth real MB (measured below). - -**The scrollbox.** One detail that fooled everyone at some point: -`viewportCulling` (on by default) skips *drawing* offscreen rows — but they stay -fully **mounted**: handles held, Yoga nodes alive, memory paid. Culling saves -paint time, not memory. That misunderstanding is half the reason the "rolling -store cap" was expected to be enough, and wasn't. - -## 2. Why it was 686MB - -Simple arithmetic. The old TUI mounted **every message in the store** as a full -renderable tree. 2,000 messages × ~16 renderables × (handles + Yoga nodes + -text buffers + V8 objects) ≈ 670–690MB, growing ~300MB per 1,000 messages. And -at ~1,400 rows the handle table filled: first a hard crash (exit 7), then — -after our containment fix — survival with **unstyled text** past that point, -plus a cap clamped from 3,000 rows down to 1,000 as the price of not crashing. - -Ink, meanwhile, sat at ~234MB at the same workload, because Ink only ever -mounts the rows near your viewport (~84–400 live nodes). Its memory is the -*data* plus some caches — not the *view*. - -## 3. The decisions, in order - -### Decision 1: virtualize the view, don't starve the store - -Two ways to cut view memory: keep fewer messages (opencode's answer — they keep -100 and delete the rest from memory; transcript truth lives on their server), or -keep all messages but only *materialize* the ones near the viewport. You vetoed -the first (your p90 session is 182 messages — a 100-row store truncates normal -sessions), so: **windowing**. Notably the OpenTUI devs confirmed this week that -framework-level virtualization is the intended path — the engine doesn't ship -it out of the box, and opencode never built it. We did. - -### Decision 2: exact heights, recorded at unmount — never estimates in your face - -This is the load-bearing idea, and it's where we beat Ink at its own game. - -The hard problem of any virtualized list: an unmounted row still needs to -occupy its correct *height*, or the scrollbar lies and content jumps. Ink -solves it by **guessing** heights and correcting after measurement — those -corrections are precisely the 83–101ms scroll stutters you hate. You explicitly -vetoed "estimate-correction jank" as a model. - -Our advantage: OpenTUI lays out with real, queryable heights. So when a row -scrolls out of the window, we record its **exact laid-out height** (an -`onSizeChange` hook fires inside layout, pre-paint) and replace the row with an -empty `` — a **spacer**: one Yoga node, zero text -buffers, zero native handles. Think of a bookshelf where books you're not -reading are swapped for cardboard sleeves cut to *exactly* the book's -thickness: the shelf never shifts, and you can't tell from across the room. - -The window is your viewport ± one viewport of margin (plus hysteresis so it -doesn't thrash at the edges). Scroll near a spacer and the real row remounts — -at the recorded height, so nothing moves. - -And one **law**, written into the code as `correctionIsLegal`: a spacer's -height may only ever be corrected where you *cannot see it* — fully above the -viewport (with the scroll position compensated in the same frame, so the world -doesn't move) or fully below it. A correction that would shift visible content -is forbidden, structurally. Jank isn't tuned down; it's outlawed. - -### Decision 3 (the S2 insight): adjudicate on *append*, not just on scroll - -S1 alone got 686 → 518MB. Why not more? Because of *when* windowing decided. -S1 re-decided the window when you **scrolled**. But during a streaming burst — -an agent turn dumping hundreds of rows — you don't scroll; rows arrive, each -mounting fully, and only get demoted later. That transient pile-up is mostly -invisible in steady-state numbers… except for Yoga-WASM, where **the transient -peak is permanent** (memory never shrinks). The burst was quietly ratcheting -the floor. - -S2 makes the window recompute on **transcript growth**: while you're pinned at -the bottom, the window anchors to the content *bottom*, so a row that falls -more than a margin behind the live edge becomes a spacer the moment it's -measured — not whenever you next scroll. Measured result: across a 1,500-row -burst, the peak number of simultaneously-mounted rows is **31**. - -Same trick for **resume**: opening a 2,000-message session used to mount all of -it (transient peak again — paid forever). Now resume mounts only the bottom -window; everything above starts as spacers using a line-count estimate, and an -idle-time "measure march" quietly mounts ten rows at a time near the window -edge, records their true heights, and swaps them back — all outside the -viewport, all invisible by the law above. - -### Decision 4: rows that must never be windowed - -Windowing has to know what it's not allowed to touch: -- **Streaming rows** — the native markdown renderer streams incrementally; - unmounting mid-stream would restart it visibly. -- **The bottom 30 rows** — the region you actually live in. -- **Rows under a mouse selection** — the review caught that a lingering - highlight originally froze windowing *forever* (memory regrowing silently). - Fixed: only an active drag pauses swaps, and selected rows get pinned, so - copy is byte-exact while everything else keeps windowing. - -### Decision 5: give back the scrollback (cap 1,000 → 3,000) - -The 1,000-row clamp existed only because mounted-rows == stored-rows and the -handle table dies at ~1,400. With windowing, mounted ≈ 31 regardless of store -size — so the cap went back to the originally-shipped 3,000. It's -windowing-aware: the `HERMES_TUI_WINDOWING=0` escape hatch (which mounts -everything again) keeps the safe 1,000. - -### Decision 6 (measured, not yet shipped as default): right-size the V8 heap - -Running the windowed TUI with a 512MB heap ceiling instead of 8GB forced V8 to -actually collect: another −90MB with zero latency cost. That's queued as a -launcher default change (~1GB), for both engines. - -## 4. The scoreboard - -At 2,000 messages (your real p99 session size — yes, we checked your DB: -median session is 20 messages, p99 is 1,941): - -| | peak memory | scroll p99 (slowest 1-in-100) | -|---|---|---| -| OpenTUI before | 686MB | 16ms | -| + S1 windowing | 518MB | 16ms | -| + S2 append/resume windowing | **300–375MB** | **6ms** | -| Ink (reference) | 229–246MB | ~100ms | - -At the **3,000-message stress** with the restored triple-size scrollback: -**360MB, fully styled, scroll p99 8ms** — a workload that six days ago crashed -the process, and three days ago survived only by dropping syntax colors. - -Scroll got *faster* because there are simply fewer live renderables to walk. -The determinism gate stayed **byte-identical** — the windowed TUI's settled -frame is provably the same pixels as before. And the live smoke (2,000-message -session: full sweep to the top, resize storm, back to bottom) returned a frame -pixel-identical to boot, with deep history fully syntax-highlighted — something -the pre-windowing TUI literally could not do. - -## 5. What's honestly still open - -- The remaining ~60–120MB over Ink is mostly the **store's JS strings** and - process baseline — the view is no longer the problem. The structural fix is - the **thin renderer** (W1): bodies live in the Python gateway (which already - has them in SQLite); the TUI keeps ~300-byte stubs and fetches bodies only - for the window. That also fixes the class of problem neither engine handles - today: a single 10MB tool output. -- Two accepted, documented limits: scrollbar-*jumping* deep into a freshly - resumed session can land on estimate-height rows that snap to true height as - they enter view (normal scrolling doesn't — the margin pre-measures; the idle - march erodes the exposure over time), and a tool you expanded, scrolled far - away from, then returned to will have re-collapsed (state is component-local; - hoisting it to the store is queued). -- Everything is behind `HERMES_TUI_WINDOWING` (default on, `0` = bit-exact old - behavior) — a one-env escape hatch if anything feels off in real use. - -*Where to verify: `bench/results/` (every number above), the design+gates doc -`docs/plans/opentui-transcript-windowing.md`, tests in -`ui-opentui/src/test/window.test.ts` and `transcriptWindow.test.tsx` (the -zero-jank invariants are literal assertions: identical scrollHeight windowed -vs not, byte-stable frames across corrections).* diff --git a/docs/opentui-upstream-alignment.md b/docs/opentui-upstream-alignment.md deleted file mode 100644 index 3a5dd1c5510..00000000000 --- a/docs/opentui-upstream-alignment.md +++ /dev/null @@ -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 `` through documented surface only — `onSizeChange`, -`setFrameCallback`, `scrollTop`/`viewport`/`scrollHeight`, Solid `` -mount/unmount. Zero patches to `@opentui/core`. Every upstream release -therefore drops in by bumping three pinned versions in `ui-opentui/package.json` -(`@opentui/{core,keymap,solid}`, currently 0.4.0). Keep it that way: any new -code that needs core behavior goes through a `boundary/` wrapper, never a -patched dependency. - -## What native yoga changes for us (and what it doesn't) - -- **Kills the WASM ratchet** (grow-only linear memory → freeable native - allocations). This retro-justifies S2 less, but S2's append-time windowing - remains correct: transient mounted peaks still cost handles and RSS. -- **Does NOT obsolete windowing.** The binding constraint is the 65,535-slot - native handle table: ~47 handles/row × 3,000 stored rows ≈ 141k handles — - over the table at ANY layout speed. Windowing is what makes the 3,000-row - scrollback possible; yoga's backend is irrelevant to that math. -- **Makes windowing feel even better**: 2× layout = cheaper margin remounts = - smaller window margins viable and less exposure for the one accepted limit - (estimate-height snap under scrollbar jumps). After the bump, re-tune margin/ - hysteresis against the scroll cell. - -## The shim ledger (delete-on-upstream-fix; all in `ui-opentui/src/boundary/`) - -| shim | what it papers over | delete when | -|---|---|---| -| `ffiSafe.ts` | u32 draw coords go negative under Node FFI (Bun silently wraps) — ERR_INVALID_ARG_VALUE loop | upstream clamps, or Node FFI path is officially supported | -| `nativeHandles.ts` | SyntaxStyle exhaustion crashes mid-mount; degrade-to-unstyled | handle table widened (INDEX_BITS>16) or per-kind tables | -| `renderer.ts` exit-signal guard | core 0.4.0 treats SIGPIPE (clipboard spawn) as an exit signal; its own uncaughtException handler allocates a handle and dies (exit-7 masking) | both fixed upstream | -| `clipboard.ts` hardening | same SIGPIPE incident class | with the above | - -Each is (a) isolated, (b) inert if upstream fixes the behavior, (c) worth -reporting upstream — four concrete, reproduced, root-caused issues. Filing them -is the cheapest alignment lever we have: it converts our workarounds into -upstream regression tests. (Needs glitch's go-ahead — public repo activity.) - -## The upgrade playbook (per upstream release) - -1. Branch `chore/opentui-X.Y.Z`, bump the three pins, `npm ci`. -2. `npm run check` (648 tests; the windowing invariants — identical - scrollHeight ON/OFF, byte-stable frames across corrections — are literal - assertions and will catch behavioral drift). -3. Bench acceptance, sequential: `--cell gate` (determinism digest; EXPECT a - new digest if upstream changed rendering — eyeball the frame, re-bless), - `--cell mem3000 --msgs 2000` + `--cell scroll --msgs 3000` vs current - numbers (300–375MB / p99 6–8ms), `--cell pipeline` (frame pacing ≥22fps). -4. Shim audit: try each boundary shim OFF; delete the ones upstream fixed. -5. Live tmux smoke (scroll sweep / resize / selection-copy), screenshots. -6. Windowing re-tune if layout got faster: margins up or hysteresis down, - re-run scroll cell, keep p99 ≤ 17ms gate. - -The bench suite IS the upgrade contract — it's exactly the harness that lets -us take every upstream improvement within a day of release, with proof. - -## Questions worth relaying to the maintainer - -1. Any plan to widen the 16-bit native handle table (or split per-kind)? - That's our hard ceiling, independent of yoga. -2. Is the Node `--experimental-ffi` path on their support radar, or Bun-only? - (Native yoga adds new FFI surface; we run Node.) -3. Would they take the windowing layer's core-agnostic pieces (exact-height - spacer pattern, correction-legality rule) as a documented recipe or - framework-level utility? We have it production-shaped with tests. diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index 54cc1640e5b..886744d6880 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -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 diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 83304a9ba12..9364dd2c07a 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -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 diff --git a/ui-opentui/.node-version b/ui-opentui/.node-version deleted file mode 100644 index 59c8fb19779..00000000000 --- a/ui-opentui/.node-version +++ /dev/null @@ -1 +0,0 @@ -26.3 diff --git a/ui-opentui/README.md b/ui-opentui/README.md deleted file mode 100644 index 64d1a2f2041..00000000000 --- a/ui-opentui/README.md +++ /dev/null @@ -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`. diff --git a/ui-opentui/eslint.config.mjs b/ui-opentui/eslint.config.mjs index 9f4dcba7f09..cfe89bf821b 100644 --- a/ui-opentui/eslint.config.mjs +++ b/ui-opentui/eslint.config.mjs @@ -4,9 +4,9 @@ import unusedImports from "eslint-plugin-unused-imports" export default tseslint.config( { - // .bench/ 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, diff --git a/ui-opentui/package.json b/ui-opentui/package.json index 21604f0bbe4..aa4af6bef91 100644 --- a/ui-opentui/package.json +++ b/ui-opentui/package.json @@ -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", diff --git a/ui-opentui/src/boundary/memlog.ts b/ui-opentui/src/boundary/memlog.ts deleted file mode 100644 index 0f832f5e82f..00000000000 --- a/ui-opentui/src/boundary/memlog.ts +++ /dev/null @@ -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 () => {} - } -} diff --git a/ui-opentui/src/boundary/schema/SessionInfo.ts b/ui-opentui/src/boundary/schema/SessionInfo.ts index 20c3425ee87..5e56c55931f 100644 --- a/ui-opentui/src/boundary/schema/SessionInfo.ts +++ b/ui-opentui/src/boundary/schema/SessionInfo.ts @@ -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. diff --git a/ui-opentui/src/boundary/termChrome.ts b/ui-opentui/src/boundary/termChrome.ts new file mode 100644 index 00000000000..8b3246c25da --- /dev/null +++ b/ui-opentui/src/boundary/termChrome.ts @@ -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) }) + } +} diff --git a/ui-opentui/src/entry/main.tsx b/ui-opentui/src/entry/main.tsx index 6578da969e1..77b9064ca79 100644 --- a/ui-opentui/src/entry/main.tsx +++ b/ui-opentui/src/entry/main.tsx @@ -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) { () => ( store.state.theme}> + - {(message, i) => - windowing ? ( - - ) : ( - - ) - } + {(message, i) => }