A full-height scrollable pager (the FloatBox analog) — porting it unlocks the
long-output slash commands (/status /logs /history /tools) at once (spec §2b).
- view/overlays/pager.tsx: bordered full-height overlay (title + scrollbox +
footer), scrolling driven explicitly via useKeyboard → scrollBy/scrollTo (no
reliance on scrollbox auto-focus), Esc/q/Ctrl+C close. §8 #2 scrollbox gotchas.
- store: pager state + openPager/closePager.
- view/App.tsx: content zone swaps to the Pager (replacing transcript+composer)
when store.state.pager is set; the close is deferred a tick so the closing key
can't leak into the remounting composer.
- logic/slash.ts: present() routes output to the pager when long (>180 chars or
>2 non-empty lines, Ink parity) else a system line; titled by command; /logs
always pages. New openPager on SlashContext.
Verified: bun run check green (41 tests / 7 files) — present() routing
(short→system, long→pager) + a pager frame test (renders title/content, replaces
the transcript/composer). LIVE tmux: /logs → pager (title "Logs", scroll via
PageDown, Esc closed → composer refocused, no key-leak); /version (5-line output)
→ pager titled "Version". Smoke P5a + parity matrix updated. Completions dropdown
+ pickers + chrome are the next slices.
HERMES_TUI_RESUME=<id|recent> resumes a session instead of creating one:
session.most_recent (for "recent") → session.resume {cols, session_id} →
commitSnapshot(mapResumeHistory(messages)), buffering live events across the RPC.
- logic/resume.ts: maps the session.resume history into Message[]. Resumed tool
rows arrive as {role:'tool', name, context} (NO text — gotcha §8 #5); they're
FOLDED into the preceding assistant turn's ordered parts (state:'complete',
summary=context) so a resumed transcript renders the tools INLINE like a live
one. Assistant text gets a text part (renders via native markdown). User/system
stay flat. Unknown roles / non-arrays are ignored.
- logic/store.ts: hydrate split into beginBuffer() + commitSnapshot() so the live
event buffer spans the async resume RPC (events that arrive during resume are
replayed after the snapshot, in order).
- entry/main.tsx: bootstrap branches create vs resume; the resume path is timed
(rpc_ms / hydrate_ms) for profiling.
Verified: bun run check green (40 tests / 7 files) — resume mapper (fold tool
rows, standalone holder, ignore junk) + beginBuffer/commitSnapshot replay. LIVE
tmux: Launch A created a session with a ⚡terminal tool call; Launch B
(HERMES_TUI_RESUME=recent) hydrated user + assistant + the tool row inline.
STRESS+PROFILE on a real 103-message session (~/.hermes/sessions): client hydrate
= 76ms, bun RSS = 214MB STABLE (no leak), tool rows hydrated, PageUp scroll works;
the 1.6s cost is the server-side session.resume RPC, not the TUI. Smoke P4 +
matrix updated. Note: rows instantiate for the full history (scrollbox culls
render only) → RSS ~linear in turns; list virtualization is the lever if
multi-thousand-turn sessions become a target.
The composer now routes `/command` through the Ink-parity dispatch ladder
instead of submitting it as a prompt (spec §1):
- logic/slash.ts: parseSlash + dispatchSlash — client-local command →
slash.exec {command, session_id} (output → system line) → on reject
command.dispatch {arg, name, session_id} with typed handling
(exec/plugin→system · alias→re-dispatch · skill/send→submit a turn ·
prefill→notice). 6 client commands: help/quit/exit/clear/new/logs.
- /help renders the live `commands.catalog` (reads the `pairs` shape).
- view/prompts/confirmPrompt.tsx + store.setConfirm: a LOCAL (non-gateway) Y/N
dialog for /clear and /new; store gains pushSystem + clearTranscript.
- entry: a Promise-returning `request` adapter + the SlashContext wiring (quit →
renderer.destroy, confirm, clearTranscript, logTail, submit).
Also fixes a keystroke-leak: the key that ANSWERED a prompt was bleeding into the
freshly-refocused composer (`/clear`→y left "y" in the input, breaking the next
`/quit`). PromptOverlay now defers the prompt-clear (composer remount) past the
current keystroke — this hardens every Phase 3 prompt too.
Verified: bun run check green (36 tests / 6 files) — slash.test covers parse + the
full ladder against a fake context. LIVE tmux: /help → full gateway catalog;
/version → slash.exec output; /clear → confirm → cleared, no key-leak (typed "hi"
not "yhi"); /quit → clean quit, child reaped. Remaining TUI-only commands,
completions, pager routing, and session resume are 4b/4c. Smoke P4 + matrix updated.
The 4 gateway *.request events now drive a blocking-prompt overlay instead of
deadlocking the agent (spec §8 #6). Native OpenTUI paradigm (per glitch's steer):
- view/prompts/approvalPrompt.tsx: native <select> (once/session/always/deny)
→ approval.respond {choice, session_id}.
- view/prompts/clarifyPrompt.tsx: native <select> over choices + an "✎ Other…"
option that swaps to a native <input> for free-text → clarify.respond
{answer, request_id}.
- view/prompts/maskedPrompt.tsx: sudo (🔐) / secret (🔑) — native <input> has no
mask, so we own a buffer via useKeyboard and render '*' per char →
sudo/secret.respond {password|value, request_id}.
- view/prompts/promptOverlay.tsx: dispatches by prompt kind, binds each
answer/cancel to the matching *.respond; Esc/Ctrl+C → deny/empty so the agent
always unblocks.
Wiring: store gains ActivePrompt state + the 4 reducer cases + clearPrompt;
App swaps Composer↔PromptOverlay on store.state.prompt (so the composer textarea
stops capturing keys while blocked); renderer.ts gates the global Ctrl+C-quit on
isBlocked() so a prompt owns Ctrl+C (→ cancel); entry adds a generic `respond`
runFork callback + passes sessionId.
Verified: bun run check green (28 tests / 5 files) — reducer set/clear for all 4,
+ a frame test (approval overlay renders the command + all options as a bordered
modal, composer hidden while blocked). LIVE tmux: a real `rm -rf` approval fired;
Approve-once → command ran → unblocked; Esc → deny → "BLOCKED by user" →
unblocked; Ctrl+C-while-blocked cancelled WITHOUT quitting; Ctrl+C-unblocked quit
clean, no orphan. Smoke P3 + parity matrix updated. confirm (local) → Phase 4.
An assistant turn is now ONE ordered parts[] (text/reasoning/tool) instead of a
flat string, so tool calls render INLINE between text blocks rather than dumped
as separate rows below (spec §7 — the "dump-below" bug opencode's sync-v2 avoids).
- logic/store.ts: Part discriminated union + reducer rework. message.delta
appends to the open text part (or opens one); tool.start pushes a running tool
part; tool.complete matches by tool_id and updates that part IN PLACE (state,
envelope-stripped resultText, summary, error, lineCount); reasoning.delta
accumulates a reasoning part. User/system rows stay flat text; settled/resumed
assistant rows fall back to text.
- logic/toolOutput.ts: ported pure helpers — stripToolEnvelope (unwrap
{output,exit_code}, append [exit N]/[error] suffix) + collapseToolOutput +
truncate.
- view/messageLine.tsx: <For>+<Switch> dispatch by part.type with stable id keys.
- view/toolPart.tsx: two-tier render — inline one-liner (≤1 output line) or a
capped left-bar block (TOOL_MAX_LINES, "… +N more", click-to-expand) keyed off
the theme; reactive width via useTerminalDimensions.
Verified: bun run check green (23 tests / 5 files / 64 expects) — store
interleave/in-place/reasoning, a frame test asserting the tool renders inline +
envelope stripped, and toolOutput unit tests. Live tmux: a terminal-tool prompt
rendered "⚡ terminal" with its alpha/beta output inline between the assistant's
text parts; Ctrl+C clean, no orphan. Smoke P2b + parity matrix updated. Native
<markdown> for text parts is the next slice (2b-ii).
GatewayService/liveGateway over the real Python tui_gateway: JSON-RPC stdio
framing (Bun.spawn), 16ms event coalescing flushed inside Solid batch(), typed
GatewayError, and a decode-once GatewayEvent Schema (~35-member tagged union;
unknown/malformed events skip via Option.none, never crash the stream).
The Solid sync-v2-style store grows to: streaming text concat (prefer
payload.text), gateway.ready{skin}/skin.changed -> fromSkin reactive re-theme,
LRU id-dedup, and hydrate-while-buffering (resume scaffold). Theming is a 1:1
port of Ink's theme.ts (DARK/LIGHT, detectLightMode, ANSI-256 normalization,
fromSkin) behind a Solid ThemeProvider so existing skins work unchanged and the
view carries NO hardcoded styles. A console-safe diagnostics log (in-memory
ring + NDJSON file) is the single logging path.
Entry gains a live launch path (default; HERMES_TUI_FAKE=1 -> scripted hello)
with an initial-prompt bootstrap (session.create -> prompt.submit) as the
Phase-2-composer stand-in, plus a minimal Ctrl+C graceful quit
(renderer.destroy -> shutdown Deferred -> scope finalizers -> client.stop) so
the engine reaps its own gateway child instead of orphaning it.
Verified: bun run check green (tsc + eslint + 12 tests / 4 files); live tmux
drive connect -> gateway.ready -> prompt -> streamed reply ("pong") -> clean
teardown with no orphan bun/python. Parity matrix + smoke P1 run log updated.