7 Commits

Author SHA1 Message Date
alt-glitch
abce50e34d feat(opentui-v2): Phase 5a — pager overlay for long slash output
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.
2026-06-08 15:52:36 +00:00
alt-glitch
c704d384f4 feat(opentui-v2): Phase 4b — session resume with tool/transcript hydration
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.
2026-06-08 15:31:46 +00:00
alt-glitch
4f2bb7e52f feat(opentui-v2): Phase 4a — slash command system + local confirm dialog
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.
2026-06-08 15:20:06 +00:00
alt-glitch
1bc376921f feat(opentui-v2): Phase 3 — blocking prompts (clarify/approval/sudo/secret), no deadlock
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.
2026-06-08 15:06:58 +00:00
alt-glitch
59fbc05031 feat(opentui-v2): Phase 2b-i — ordered parts + inline tool render
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).
2026-06-08 14:42:24 +00:00
alt-glitch
927c902785 feat(opentui-v2): Phase 1 — live tui_gateway transport + Solid store + theming
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.
2026-06-08 14:19:21 +00:00
alt-glitch
d3943fe37d feat(opentui-v2): Phase 0 scaffold — Solid + Effect-at-boundary native TUI
New from-scratch package ui-tui-opentui-v2/ (NOT a port of the superseded React
ui-tui-opentui/; Ink ui-tui/ untouched). Mirrors opencode's method: @opentui/solid
view, Effect 4.0-beta only at the boundary (renderer lifecycle, GatewayService
transport, runtime), plain Solid for the logic/view.

Phase 0 (per docs/plans/opentui-rewrite-v4-spec.md §11):
- deps pinned: effect@4.0.0-beta.78, @opentui/{core,solid,keymap}@0.3.2, solid-js@1.9.10
- strict rails: tsconfig (verbatimModuleSyntax, exactOptionalPropertyTypes,
  noUncheckedIndexedAccess, jsxImportSource @opentui/solid), eslint, prettier
- boundary: acquireRelease(createCliRenderer) + finalizers + Deferred-on-destroy;
  GatewayService (Context.Service) shape; typed errors (Data.TaggedError); AppLayer
- logic (Solid): createSessionStore + apply(event) reducer (sync-v2 model, minimal)
- view (Solid): App shell (header + transcript); inline color via <span style={{fg}}>
- entry: the one-line render(() => <App/>, renderer) bridge + Effect.provide(layer)
- FakeGateway layer (test/dev seam) streaming a scripted hello
- test rails: test/lib/effect.ts (testEffect/testLayer over ManagedRuntime + TestClock,
  no @effect/vitest), test/lib/render.ts (testRender + renderOnce + captureCharFrame)
- 4-layer tests (boundary/store/render) 5/5 green; scripts/check.sh gate green
- docs: v4 spec + living smoke doc (Phase 0 PASS logged); v3 spec + parts/markdown
  plan marked superseded

Verified: bun run check green (tsc 0, eslint 0, bun test 5/5); live tmux drive paints
'hermes · opentui · ready' + '✦ Hi there, glitch!' in a real TTY.
2026-06-08 13:36:54 +00:00