Commit graph

11030 commits

Author SHA1 Message Date
alt-glitch
00cb21de3e opentui(copy): /copy [n] copies the agent response 2026-06-09 07:09:10 +00:00
alt-glitch
0437dd060c opentui(copy): assistant-text extraction helpers 2026-06-09 07:09:07 +00:00
alt-glitch
bc9447d23b opentui(harden): fix 3 triaged findings (timer leak, tool-match scope, complete-only)
Subagent hardening pass over boundary/logic/view, findings triaged (most were
false positives or app-lifetime-moot). The 3 genuine fixes:
- liveGateway.stop() now clears the pending 16ms coalesce timer before
  client.stop() — a queued flush() could otherwise fire batch()/handlers into a
  torn-down store after the layer scope releases.
- store.findToolPart now scans only the LIVE (last) assistant turn, not every
  message — a tool.complete pairs with a tool.start in the current turn, so this
  avoids matching a same-id tool in an older/resumed turn (and is O(parts)).
- store message.complete with text but NO prior start/delta now creates the turn
  (complete-only gateways) instead of dropping the final text; still no empty
  bubble when there's no text. +2 regression tests.

Triaged as NOT-a-bug / accepted-risk (documented so they're not relitigated):
@opentui/solid useKeyboard DOES auto-cleanup (onCleanup keyHandler.off, index.js:59);
dimensions/scrollAnchor timers are app-lifetime / try-catch-safe; unbounded-growth,
duplicate-dedup, and split-frame are theoretical for a trusted local newline-framed
subprocess; clipboard spawn timeout + atomic active-session write are minor follow-ups.
93 pass.
2026-06-09 06:35:29 +00:00
alt-glitch
79dc862680 opentui(test): track the test harness (test/lib/) swallowed by global lib/ ignore
The Solid render-test harness (src/test/lib/render.ts + effect.ts) was never
committed — a global ~/.gitignore_global `lib/` rule silently excluded it, so the
opentui-v2 test suite wasn't reproducible from a clean checkout (render.test.tsx
imports ./lib/render). Force-add both + add a repo .gitignore negation
(!src/test/lib/). render.ts also carries the withKeymap() wrapper the keymap
migration needs (view tests mount under a KeymapProvider). 91 pass.
2026-06-09 06:25:55 +00:00
alt-glitch
01fa8dcc00 opentui(keymap): adopt native @opentui/keymap for overlay close + confirm
@opentui/keymap@0.3.2 was installed but unused (the spec said we'd use it). Wire
it natively: createDefaultOpenTuiKeymap(renderer) + <KeymapProvider> at the render
root, and a useCloseLayer(target,onClose) helper that registers a focus-within
Esc/Ctrl+C → close layer. Migrated the close handling of sessionSwitcher, picker,
approvalPrompt (close-only), confirmPrompt (y/n via confirm/cancel commands), and
pager + agentsDashboard (close via keymap; scroll/select stay raw — not cleanly
focus-gated). Overlays gain a root ref + focus-on-mount so the focus-within layer
activates. q-close re-added to pager/dashboard (footer advertises it).

Composer history/refocus + masked prompt + the Ctrl+C quit machine stay raw by
design (need the in-flight keystroke / careful state). Test harness gains a
withKeymap() wrapper so view tests mount under a provider. 91 pass; live-verified
/sessions + /tools Esc-close and composer focus recovery after.
2026-06-09 06:24:14 +00:00
alt-glitch
3882cc6e61 opentui(input): "Pasted text" placeholder for large pastes
Large bracketed pastes no longer flood the composer. On paste, if the text is
≥4 lines or >400 chars, insert a compact `[Pasted text #N +M lines]` chip and
hold the real content in a PasteStore; on submit, expand the chip back to the
full text before sending (free-code model). Single-pass String.replace keeps a
pasted block that itself contains a `[Pasted text #k]` literal safe.

The store is created ONCE in main.tsx and passed App→Composer (NOT per-composer)
so it survives the composer remounting on overlay open/close — a per-composer
store would lose a pending paste mid-compose. +6 unit tests (91 pass). Verified
live: paste 10 lines → chip; submit → transcript shows the full expanded code;
composer cleared.
2026-06-09 06:09:49 +00:00
alt-glitch
6b87243ecd opentui(input): auto-expanding composer textbox
The composer was a fixed height:3. Match free-code/opencode: native textarea
auto-grow via direct minHeight={1} maxHeight={max(6,⌊rows/3⌋)} props (opencode's
prompt sizing) — 1 row when empty, grows with wrapped/multiline content up to ~a
third of the screen, then scrolls internally. maxHeight is a DIRECT reactive prop
(not in style) so the cap tracks terminal resize via useDimensions. 85 pass;
verified live (a long wrapping line grew the box to 3 rows).
2026-06-09 06:05:05 +00:00
alt-glitch
49d90e68c6 opentui(v5b): fix first-letter duplication on always-active refocus
Typing while the textarea was unfocused doubled the FIRST letter: the always-active
handler did ta.focus() AND ta.insertText(key.sequence), but the renderer runs the
global useKeyboard handler BEFORE routing the key to the focused renderable — so
after focus() the same keystroke was also delivered to the now-focused textarea,
inserting it twice. Subsequent keys were fine (textarea already focused → block
skipped). Fix: focus() only; let the textarea insert the char it now receives.
Verified live: typing 'x' then 'y' while blurred yields '❯ xy' (no dup). 85 pass.
2026-06-09 05:36:37 +00:00
alt-glitch
2cd122c9c1 opentui(v5b): frame the startup panel in a themed border box
Design-judge top nit: Ink's bordered-box-around-the-session-info is the single
biggest 'designed home screen vs log output' signal, and the flat left-aligned
version lacked it. Wrap the model/dir/session block + Tools/Skills/MCP sections +
summary in a full border box (theme border token); banner+tagline stay above,
tips below. 85 pass.
2026-06-09 05:22:26 +00:00
alt-glitch
53438228ee opentui(v5b/item1): Ink-parity startup banner panel
Rebuilt the home screen to match hermes --tui: the HERMES-AGENT banner + tagline,
then a session info block (model · Nous Research / dir (branch) / Session: <id>),
then SEPARATE collapsible sections — Available Tools (enabled toolsets each as
'name: tool1, tool2', capped + '(and N more toolsets…)'), Available Skills (N) in
M categories, MCP Servers (N) connected — and a '… /help for commands' summary.
Previously it was one combined '▶ N tools · M skills · K MCP' dropdown that only
listed tools and showed no model/dir/session.

- gateway startup.catalog now returns per-toolset {enabled, tools} (resolved_tools,
  session-aware enabled set — mirrors tools.list); py_compile OK.
- store Catalog gains toolset.enabled/tools; new sessionId field + setSessionId,
  set on session create/resume (alongside the active-session-file write).
- homeHint takes the store, reads info (model/cwd/branch) + sessionId + catalog.
85 pass; verified live (model·Nous·dir·session + enabled toolsets w/ tools).
2026-06-09 05:19:21 +00:00
alt-glitch
503c1201ff opentui(v5b): visual hierarchy — color-code roles + clean turn spacing
The transcript read as one undifferentiated gold blob (user/assistant/tool all
the same color). Adopt the Ink model where color IS the hierarchy, in 3 brightness
tiers:
- USER input  → label (gold)        — the human's turn stands out.
- ASSISTANT answer → text (bright)   — the primary content, brightest.
- TOOL / REASONING → muted (dim) with an ACCENT glyph (/▶/▼ amber) that marks
  the block — clearly the secondary 'working area' below the answer.
Spacing: one blank line above every turn (was cramped: user had a blank, the
reply didn't) + the existing gap:1 between parts. Dropped the transcript's extra
marginTop (turns own their spacing now). 85 pass; verified live — gold ask, dim
tool, white answer read as three distinct things.
2026-06-09 05:13:37 +00:00
alt-glitch
e44b43ad16 opentui(v5b/item5): track active session for the post-quit resume epilogue
The launcher (hermes_cli/main.py _print_tui_exit_summary) reads
HERMES_TUI_ACTIVE_SESSION_FILE to print 'Resume this session with…' on exit. The
Ink TUI writes the current session id there on every session change
(useSessionLifecycle.writeActiveSessionFile); the native engine never did, so
after a /session switch the launcher fell back to the INITIAL launch session and
showed resume info for the wrong session (the reported leak).

Now writeActiveSession() writes {session_id} on session.create AND inside
resumeInto (every /session switch), mirroring Ink. Verified live: file shows the
created session, then updates to the switched-to session. 85 pass.
2026-06-09 05:08:57 +00:00
alt-glitch
cd09aa61ef opentui(v5b/item4): hold viewport on tool/thinking expand (no scroll jump)
The transcript scrollbox (stickyScroll+stickyStart=bottom) re-pins to the bottom
on any content-height change when the user is at the bottom (@opentui/core
ScrollBox: `if (stickyStart && !_hasManualScroll) applyStickyStart`). So expanding
a tool/thinking block scrolled the clicked header up off-screen. A
ScrollAnchorProvider (transcript owns the scrollbox ref) lets toolPart/reasoningPart
wrap their toggle so scrollTop is held constant across the height change (re-asserted
over a few frames as layout settles) — the clicked header stays put and the
expansion reveals beneath it. 85 pass.
2026-06-09 05:05:01 +00:00
alt-glitch
c507ca6b3b opentui(v5b/item2+3): fix streaming flicker + native markdown tables
#2 (flicker regression): my item-7 AssistantText wrapped text in
<For each={segmentMarkdown(text)}> — segmentMarkdown returns NEW objects per
delta, so <For> (keyed by reference) DISPOSED and re-created the markdown
renderable on EVERY streamed delta. Each remount re-measured from zero → content
height oscillated → the scrollbar grew/shrank (exactly the reported symptom).

Fix (deep opencode parity): render assistant text as ONE stable native
<markdown> (MarkdownRenderable) fed the growing content in place, with
internalBlockMode="top-level" — opencode's anti-flicker mode where settled
top-level blocks aren't re-rendered per delta (_stableBlockCount, managed
internally). This is opencode's TextPart verbatim (routes/session/index.tsx:1687).

#3 (table inline formatting): the native <markdown tableOptions={{style:grid}}>
renders GFM tables as a grid WITH inline bold/italic/code in cells — so the
hand-rolled segmentMarkdown + MdTable grid are deleted (obsolete). Switched from
<code filetype=markdown> to <markdown> (the former re-measured the whole buffer
each delta and never aligned tables). 85 pass; verified live (smooth stream,
boxed table, concealed **/* markers styled).
2026-06-09 04:57:57 +00:00
alt-glitch
d90e195670 opentui(v5/item4): coalesce resize via a shared debounced dimensions signal
Raw useTerminalDimensions fires on every SIGWINCH tick; during a drag that's a
recompute/reflow storm across every width-sensitive component (tool bodies,
tables, status bar, banner). Add a DimensionsProvider that runs the raw hook
ONCE and feeds a single leading+trailing-debounced (40ms) signal — mirroring the
gateway's 16ms event coalescing / opencode's createLeadingTrailingSignal — that
every consumer shares via useDimensions(). They now reflow together (no tearing)
and at most once per window. Falls back to the raw hook outside a provider
(headless tests). Verified: single resizes converge clean (wide banner ⇄ compact
brand at the 102-col threshold); rapid bursts coalesce. 90 pass.
2026-06-09 04:08:56 +00:00
alt-glitch
793462a395 opentui(v5/item9): startup HERMES banner + collapsible tools/skills/MCP panel
Home screen now shows the canonical HERMES-AGENT block logo (hermes_cli/banner.py,
gold->amber->bronze via primary/accent/border tokens; width-guarded to a compact
brand line under 102 cols) plus a collapsible '▶ N tools · M skills · K MCP' panel
that expands to per-toolset / per-category / per-server detail.

Data comes from a new opt-in gateway RPC 'startup.catalog' (aggregates
get_all_toolsets + banner.get_available_skills + config mcp_servers); the native
engine fetches it best-effort on session start (Effect.catchCause swallows it on
old gateways). Opt-in => Ink path untouched. py_compile OK. Store gains a typed
Catalog + defensive setCatalog mapper. +2 tests (90 pass); verified live
(1185 tools / 196 skills / 2 MCP, expand shows the full lists).
2026-06-09 04:04:43 +00:00
alt-glitch
636bb6e928 opentui(v5/item8): design polish — header chrome, status segments, ANSI strip
Visual-hierarchy pass (design-reviewed against free-code/opencode):
- header: brand glyph in accent + name in primary/bold + a bottom rule, so it
  reads as chrome and bookends the transcript with the status bar's top rule
  (fixes 'nothing differentiates the header from the text stream').
- status bar: a dim │ divider segments model·effort from the context meter.
- user/assistant turn glyphs bold + the user ❯ in accent so turns are scannable.
- reasoning 'Thought' label uses label (not warn) so it matches tool headers —
  warn is reserved for warnings; reasoning/tool now read as one aside family.
- home screen: brand in primary/bold, command names in accent vs muted descs,
  wider column.
- FIX (load-bearing): strip ANSI/SGR escape sequences from slash/notice text
  (pushSystem + openPager) — the gateway colors them for Ink, which interprets
  them; the native <text> rendered them as literal  glyphs. +stripAnsi
  + 3 tests. All tokens themed (no hardcoded colors). 88 pass.
2026-06-09 03:56:44 +00:00
alt-glitch
0da48b0c7f opentui(v5/item7): render GFM markdown tables as aligned grids
The native <code filetype=markdown> colorizes pipes but never aligns tables.
Add a pure segmenter (segmentMarkdown) that splits assistant text into prose
runs (native renderable) and GFM table blocks, plus an MdTable grid renderer:
per-column widths (free-code's stringWidth+padAligned), :--- / :--: / ---:
alignment, bold header, dim │ separators, a ┼ header rule, width-aware column
shrink on resize. Incomplete tables (no separator yet, e.g. mid-stream) stay
prose until they close. +5 unit tests (85 pass); verified live with a 3-col table.
2026-06-09 03:48:02 +00:00
alt-glitch
50023fd151 opentui(v5/item5): stabilize inter-part spacing (kill streaming jitter)
Blank lines between reasoning/tool/text grew and shrank mid-stream because
spacing was ad-hoc: tools carried marginTop:1, text/reasoning none, and the
markdown text part rendered the model's leading/trailing newlines as transient
blank lines that filled in as deltas arrived.

Now the parts column owns ALL spacing via gap:1 (uniform 1 line between any two
parts regardless of type/order), per-part marginTop is dropped, and text parts
are stripped of leading/trailing blank lines so the gap is the sole source —
no double gaps, no popping. Verified live: Thought/tool/tool/answer all spaced
by exactly one line. (80 pass.)
2026-06-09 03:43:43 +00:00
alt-glitch
5352aec064 opentui(v5/item6): collapsible thinking traces
Reasoning rendered as an always-expanded plain muted blob. Now it's a proper
collapsible part (opencode ReasoningPart): auto-EXPANDED while the turn streams
(watch it think), then collapses to a one-line `▶ Thought: <title>` when settled;
click toggles. Title is the model's leading `**bold**` line (reasoningSummary).
Body renders as DIM markdown in a left-`│`-border block (Markdown gained an
optional `fg` so reasoning is muted vs the answer). +1 render test (80 pass).
2026-06-09 03:37:42 +00:00
alt-glitch
7b14c51e7b opentui(v5/item1): resumed tools render like live (collapsible + output)
Resumed tool calls were flat `name arg` rows — no output, not collapsible —
because the resume snapshot (_history_to_messages) dropped each tool's result.
Now the native engine passes `with_tool_output: true` on session.resume and the
gateway folds the tool's redacted+capped result + args into its row, so resumed
turns show `▶ name arg (N lines)` collapsible blocks identical to a live turn.

The flag is OPT-IN: Ink doesn't pass it, so _history_to_messages stays byte-for-
byte unchanged for the Ink path (its expanded verbose-trail render OOM'd on big
output, #34095; the native engine renders tools collapsed, so the capped tail is
safe there). resume.ts maps context→argsPreview, result_text→resultText (label
peeled + envelope stripped), args→argsText — same shape as the live tool part.

py_compile OK. resume tests updated + 1 added (79 pass).
2026-06-09 03:32:52 +00:00
alt-glitch
8c1b62e72f opentui(v5/item2): surface tool-call args + de-pad output
The gateway already ships per-tool arg metadata the client was discarding:
`context` (build_tool_preview's primary-arg line, always sent), `args` (full
dict on complete), `args_text` (redacted JSON, verbose), `duration_s`. Capture
them on the tool part and render free-code style:

- collapsed header: `▶ name <arg-preview> · <duration> (N lines)` — args are
  finally visible without expanding (the core item-2 complaint).
- expanded: a single left-bordered (`│`) column with a key:value args block
  (suppressed when the lone arg is already the header preview — judge nit) then
  the output block.
- strip the gateway's `[showing verbose tail; omitted N chars]` banner into a
  tidy `… omitted N chars` note; unwrap tail-capped `{"output":…}` envelope
  fragments so the last line isn't a dangling JSON tail.

Left bar is a border glyph (opencode BlockTool style), not a bg fill — cleaner
and renders faithfully. +4 unit tests, +1 render test (78 pass).
2026-06-09 03:24:15 +00:00
alt-glitch
e136314039 opentui(v5/item3): composer flush to bottom — drop root paddingBottom
The root box used padding:1 (all edges), reserving a blank row BELOW the
status-bar+composer block. Switch to paddingTop/Left/Right only so the input
hugs the last terminal row. Transcript stays flexGrow:1 minHeight:0; the
bottom block is the flexShrink:0 last child. StatusLine already renders
zero-height when idle, so no other change is needed.
2026-06-09 03:00:16 +00:00
alt-glitch
73d5c2871d opentui(v2): home hint (item 12) + verify /goal + expand feature matrix (item 8)
Item 12 — the missing helper/home screen: view/homeHint.tsx renders on an empty
transcript (Ink helpHint.tsx parity) — brand line, common commands (/help /model
/sessions /skills /agents /clear), and input tips (type · ↑↓ history · @file ·
Ctrl+C). Decorative → selectable={false}. Replaced by the transcript on the first
turn.

Item 8 — /goal verified live: slash.exec rejects it (pending-input) → dispatch
falls to command.dispatch {name:'goal'} → {type:'send', notice:'⊙ Goal set…',
message} → notice shown + the goal turn submitted (handleDispatchResult). Wired.

Docs: opentui-feature-map.md gains the full 15-item live-feedback parity matrix
(Ink/opencode primitive · v2 file · status); opentui-smoke.md gains the 15-item
run log. All 15 items  (image-paste wired but unverified in the clipboard-less
CI env).

Tests: home-hint render. 72 pass. Live-smoked: empty launch shows the home hint.
2026-06-08 18:26:13 +00:00
alt-glitch
d46a8f4492 opentui(v2): clipboard copy/paste, image paste, glyph-free selection (items 1, 4)
Item 1 — copy/paste:
- boundary/clipboard.ts (ported/trimmed from opencode): writeClipboard = OSC 52
  (SSH/tmux-safe) + a native command (pbcopy/wl-copy/xclip/xsel/clip);
  readClipboardImage = clipboard PNG via wl-paste/xclip/pngpaste/powershell.
- Ctrl+C copies a live MOUSE SELECTION (renderer.getSelection) before the
  interrupt/quit machine runs (opencode's selection-key precedence), with a
  "Copied to clipboard" hint; falls through to interrupt/quit when there's no
  selection.
- text paste inserts natively (textarea handlePaste); the composer's onPaste only
  intercepts an EMPTY bracketed paste (image-only clipboard) → readClipboardImage
  → image.attach_bytes (the next prompt.submit picks it up).

Item 4 — mouse selection now ignores decorative glyphs: selectable={false} on the
message/tool gutter glyphs and all chrome (header, status bar, status line,
composer prompt glyph), so a drag copies the message text, not ❯/⚕/▶/.

Live-smoked (this env has no clipboard tools/DISPLAY, so native copy + image read
can't be confirmed here, but): drag-select + Ctrl+C → "Copied to clipboard" (not
quit); no-selection Ctrl+C still arms quit; bracketed text paste lands in the
composer. 71 pass.
2026-06-08 18:20:59 +00:00
alt-glitch
eaee382b47 opentui(v2): fix streaming caret alignment during model response (item 10)
A just-started assistant turn (message.start, no deltas yet) rendered an EMPTY
fallback <text> on the glyph's line plus the `▍` caret on a SEPARATE line below —
so `⚕` sat alone with the caret dangling beneath it, indented. Folded the caret
into the no-parts fallback so it renders inline with the glyph (` ⚕ ▍`); a settled
row still shows its flat text, a turn with parts renders the parts. 71 pass.

Live-smoked: streaming start now shows `⚕ ▍` on one line; the reply text then
aligns with the glyph.
2026-06-08 18:13:04 +00:00
alt-glitch
59e9e6a26e opentui(v2): live agent trace + /tools navigable overlay (items 9, 15)
Item 15 — "/agents doesn't let me look into an agent trace live":
- store accumulates a concise per-subagent trace from the subagent.* stream
  (▶ start /  tool — preview / progress text / ✓ summary), capped at 200 lines;
  thinking deltas update a transient `thought` (not appended — they'd flood).
- AgentsDashboard is now master-detail: ↑/↓ select a subagent (▸ + accent), and
  the bottom pane shows the selected agent's goal · status · model, its latest
  thought, and a sticky-bottom (live) trace scrollbox. PgUp/PgDn scroll the trace.

Item 9 — /tools wired to a deliberate navigable overlay (fetch the roster via
slash.exec → pager) instead of incidental fallthrough; /skills already opens the
native picker.

Tests: store trace accumulation + dashboard render (trace line + footer). 71 pass.

Live-smoked: /tools → tool roster pager; /skills → picker; a real delegation
(spawn a subagent → reply PURPLE) → /agents showed the subagent with its goal ·
completed · model, 🧠 PURPLE thought, and ▶/✓ trace lines.
2026-06-08 18:09:32 +00:00
alt-glitch
a046cee754 opentui(v2): collapsible tools + composer glyph, drop the blue tint (items 3, 7)
Item 7 — tools were non-collapsible and "ugly-interlaced":
- ToolPart now renders COLLAPSED by default as one line: `▶ name  summary  (N
  lines)` (summary = explicit summary / first output line / error). A ▶/▼ glyph
  marks expandable tools; clicking the header toggles a left-bar block of the
  full (capped) output. Running tools show `name …`; single-line/erroring tools
  render inline. Compact by default → far less interlacing clutter.
- toolOutput.normalizeOutput: un-double-escapes literal \n/\t when they dominate
  over real newlines (some gateway tool tails are repr'd, so newlines arrived as
  backslash-n and rendered as one ugly line). Conservative — genuine multi-line
  output and legit `\n`-in-code are left alone. Applied in stripToolEnvelope.

Item 3 — the input "blue tint": dropped the textarea's blue focusedBackgroundColor
and added a `❯` prompt glyph. The composer is now distinguished by structure (the
glyph + the status-bar rule above it), not a background tint.

Tests: normalizeOutput (dominant-literal vs genuine-multiline). 70 pass.

Live-smoked: `ls -la` tool → collapsed `▶ terminal  total 3460  (N lines)`;
SGR-click → `▼` + clean per-line output; composer shows `❯` with no blue tint.
2026-06-08 18:04:08 +00:00
alt-glitch
15ccaf9ab9 opentui(v2): slash-arg autocomplete + file/@-mention completion (items 5, 13)
onType used to fire complete.slash only for an argless `/command`, and Tab
replaced the whole line. Now:

- planCompletion(text) (pure, in slash.ts) routes: a `/command [args]` line →
  complete.slash (the gateway completes names AND args, e.g. /details section
  names); a trailing path-like word (@…, ~/…, ./…, /…, or anything with /) →
  complete.path for file/dir tagging; else nothing.
- the accepted item splices ONLY its token: store tracks completionFrom (gateway
  replace_from via readReplaceFrom, or the path-token start), and the composer's
  Tab handler keeps the text before `from` and appends the candidate.

Tests: planCompletion (slash/path/prose/multiline) + readReplaceFrom. 69 pass.

Live-smoked: `/details ` → section dropdown (hidden/collapsed/.../activity), Tab
→ `/details hidden` (arg-only splice); `tui_gateway/` → its .py files;
`@hermes_cli/m` → m-prefixed files.
2026-06-08 17:57:07 +00:00
alt-glitch
c391add579 opentui(v2): prompt history — Up/Down cycling, per-directory scope (item 6)
New logic/history.ts: createPromptHistory (pure cursor cycling — Up walks older,
Down walks newer back to the stashed draft, push dedupes a consecutive duplicate
+ resets) plus best-effort per-dir JSONL persistence under
$HERMES_HOME/tui-history/<sha1(cwd)>.jsonl (one JSON-encoded prompt per line,
multiline-safe).

Scoping matches the ask: prior prompts from the SAME launch dir are loaded on
start (recallable across relaunches), but a different dir keeps its own list — no
cross-dir/cross-session bleed.

Composer: Up at the first line → older prompt; Down at the last line → newer/draft
(at the boundary the textarea's own up/down is a no-op, so no conflict; mid-buffer
it still moves the cursor). setText + cursor-to-end on recall; any edit resets the
recall cursor. submit() pushes the prompt. Threaded entry → App → Composer; cwd =
process.cwd() (the launch dir under the real launcher).

Tests: 5 pure cursor-cycling cases. Live-smoked: seeded a dir file → Up/Up/Down
cycled two→one→two; a freshly submitted prompt was recalled via Up. 65 pass.
2026-06-08 17:52:38 +00:00
alt-glitch
1e55b3b294 opentui(v2): always-active input — typing reclaims the composer (item 2)
The textarea focuses on mount and when an overlay closes (remount), but focus
could drift to the transcript scrollbox on a mouse-scroll, dropping keystrokes.
Now (opencode's keep-the-prompt-focused idea, adapted):
- onMouseDown → focus the textarea (click-to-focus).
- a global keystroke net: a PRINTABLE, unmodified key while the textarea is
  unfocused reclaims focus AND recovers the char (the in-flight event went to
  the global handler, not the unfocused textarea, so insert it). Nav/scroll keys
  (arrows/page/home/end/…) are deliberately left alone so keyboard transcript
  scroll still works; kitty `release` events are skipped to avoid double-insert.
Completion accept/dismiss handler folded into the same useKeyboard with early
returns.

Live-smoked: type → text lands; `/` → completions; Esc → dismiss; type again →
lands; clean quit. 60 pass.
2026-06-08 17:46:37 +00:00
alt-glitch
76cf809066 opentui(v2): Ctrl-C stops the agent; second press (debounced) quits
Item 11 — "stopping the agent doesn't work". Ctrl+C used to immediately destroy
the renderer. Now a turn-aware state machine (opencode's double-press model, the
user's preferred behaviour):

- While a turn runs (store.info.running): first Ctrl+C → session.interrupt
  {session_id} (STOP the agent), and arms a 3s quit window with a warn hint
  "⏹ stopped — Ctrl+C again to quit".
- Idle: first Ctrl+C arms the window ("Ctrl+C again to quit"); a stray single
  press never nukes the session.
- A second Ctrl+C within the window KILLS the TUI (renderer.destroy → clean
  scope teardown → gateway child EOF).
- A blocking prompt still owns Ctrl+C (deny/cancel) — unchanged.

Wiring: renderer.ts gains an `onCtrlC` hook (owns Ctrl+C when not blocked);
entry builds the machine (gateway yielded before the renderer so it can read
`running` + send interrupt). store gains a transient `hint` slice; StatusLine
shows hint (warn, priority) or the busy face (dim).

Live-smoked: long turn → Ctrl+C shows "stopped" + idle dot; second press exits
cleanly with no orphaned gateway child (the user's installed-venv sessions
untouched). 60 pass.
2026-06-08 17:42:21 +00:00
alt-glitch
915b9b5f6f opentui(v2): status bar (status·model·effort·context·dir) above composer
Item 14: a persistent bottom-chrome status bar, ported from Ink's appChrome
StatusRule. Sourced from the session.info event (model / reasoning_effort /
fast / cwd / branch / running / usage.context_*) which was decoded but dropped
until now; also folded session.create/resume result.info and message.complete
usage into a new store `info` slice.

- store: SessionInfo slice + applyInfo(); session.info handler; message.start/
  complete flip `running` (the flag the Ctrl-C interrupt will read); refresh
  usage on complete.
- schema: MessageComplete.payload gains loose `usage` so it survives decode.
- view/statusBar.tsx: width-aware (Ink progressive disclosure) — context bar
  drops on narrow terminals, cwd compacts to last two segments + left-truncates
  so the row never wraps. Turn/connection dot ◐/●/○.
- App: status bar sits ABOVE the composer; a top-edge rule (border:['top'])
  visually separates the status bar + textbox input region from the transcript.
- tests: store info slice (3) + headless status-bar render (1); bumped the
  approval-prompt capture height for the taller input region. 59 pass.

Live-smoked: bar shows model·effort·context%·dir; context updates 0→4% across a
turn; running dot flips; separator divides input region from transcript.
2026-06-08 17:38:10 +00:00
alt-glitch
1bf9dff1fb fix(opentui-v2): route thinking-faces to a transient status line (not the transcript)
Live-usage issue 3/5: the kaomoji faces ("(¬_¬) processing…") lingered in the
transcript. Traced (instrumented capture): they arrive via `thinking.delta` —
Hermes's transient kaomoji busy *indicator* (_INDICATOR_DEFAULT=kaomoji), which I
was rendering as a persistent reasoning part.

- store: new transient `status` field. thinking.delta / status.update → `status`
  (not a part); message.start + message.complete clear it. Only the real
  `reasoning.delta` still becomes a (dim) transcript part.
- view/statusLine.tsx: a dim busy line above the composer shown while `status` is
  set (Ink's FaceTicker analog), rendering nothing when idle; wired into App
  between the transcript and the input zone.

Verified: bun run check green (55 tests / 7 files) — store tests assert
thinking.delta → status (no transcript part) + cleared on complete; status.update
→ status. Live tmux: a turn showed "٩(๑❛ᴗ❛๑)۶ cogitating…" on the transient status
line (cleared on completion) with NO face left in the transcript.
2026-06-08 16:54:07 +00:00
alt-glitch
e14dfa86c6 fix(opentui-v2): live UX — enable mouse + smooth streaming markdown (opencode parity)
From live-usage feedback (driving the real TUI):

- Mouse ON by default (opencode parity; HERMES_TUI_MOUSE=0 opts out). Was hardcoded
  off, which is why transcript wheel-scroll, scrollbar drag, and click-to-expand
  tools didn't work and the terminal's native region-select polluted copy. With
  useMouse the scrollbox handles the wheel + scrollbar and tools are click-expandable;
  selection becomes OpenTUI's text-aware select. (Mouse can't be driven via tmux
  send-keys — verify wheel/drag/click interactively.)
- Streaming markdown: match opencode's v2 text path —
  <code filetype="markdown" streaming drawUnstyledText={false}>. The previous
  drawUnstyledText:true drew raw text then overlaid styling each delta (a flash);
  false avoids that and re-tokenizes incrementally for smoother streaming. (The
  native renderable's tree-sitter doesn't settle in the headless test renderer with
  drawUnstyledText:false, so the two markdown frame tests now assert the assistant
  text via the store — paint is verified in the live smoke; render.ts also settles
  to waitForVisualIdle.)

Verified: bun run check green (53 tests / 7 files). Live mouse + streaming
smoothness for glitch to confirm. Part of the live-feedback polish goal.
2026-06-08 16:48:23 +00:00
alt-glitch
055bc3e3a2 feat(opentui-v2): Phase 8 — launcher cutover to the v4 Solid engine
Repoint hermes_cli/main.py `_make_opentui_argv` from the superseded React entry
to the v4 Solid + Effect-at-boundary entry: it now prefers
`ui-tui-opentui-v2/src/entry/main.tsx` (cwd ui-tui-opentui-v2) and falls back to
`ui-tui-opentui/src/entry.real.tsx` only if the v2 package is absent (graceful
during coexistence). The engine gate (_resolve_tui_engine: HERMES_TUI_ENGINE /
display.tui_engine → opentui; Windows/Termux → Ink fallback) and the dual-engine
dispatch in _make_tui_argv are unchanged; Ink (ui-tui/) is untouched. The spawned
tui_gateway's source-root default lands on PROJECT_ROOT (package at
<root>/ui-tui-opentui-v2), so it loads Python from the same checkout, no extra env.

So `HERMES_TUI_ENGINE=opentui hermes --tui` now launches the v4 engine — the exact
`bun …/v2/src/entry/main.tsx` invocation live-smoked across P1–P5e, making every
first-class surface reachable from the real CLI.

Also: a consolidated 3-way acceptance summary (Ink ↔ opencode ↔ build) at the top
of opentui-feature-map.md covering all 7 first-class surfaces + the foundation +
the launcher, each  + tested + smoked.

Verified: py_compile main.py OK (dev-skill rule for the 4k-line file); imported
the worktree CLI with HERMES_TUI_ENGINE=opentui → _resolve_tui_engine()='opentui',
_make_opentui_argv() → [bun, …/ui-tui-opentui-v2/src/entry/main.tsx] (cwd
ui-tui-opentui-v2, --watch in dev). v2 `bun run check` green (53 tests / 7 files).
Smoke P8 + matrix updated. Remaining: header chrome detail (5b), agent-feature
trail (5d), distribution (§10) — polish, not first-class blockers.
2026-06-08 16:27:21 +00:00
alt-glitch
c019a9d2d5 feat(opentui-v2): Phase 5e — agents dashboard (7th first-class surface; ALL done)
The agents dashboard (spec §2b; Ink agentsOverlay) — the last first-class
interactive surface. Subagent delegations are tracked from the `subagent.*`
event stream and shown in a full-height overlay.

- store: subagents[] built from subagent.{spawn_requested,start,thinking,tool,
  progress,complete} by subagent_id (status·goal·model·depth·lastTool·summary);
  clearTranscript clears them. dashboard flag + openDashboard/closeDashboard.
- view/overlays/agentsDashboard.tsx: full-height overlay (replaces transcript+
  composer), depth-indented subagent rows colored by status, scroll via
  scrollBy/scrollTo, Esc/q close. Empty state prompts to delegate.
- view/App.tsx: content zone is now a <Switch> — pager / agents dashboard /
  (transcript + input zone).
- logic/slash.ts: /agents, /tasks → openDashboard (SlashContext.openDashboard).

Verified: bun run check green (53 tests / 7 files) — subagent reducer + a
dashboard frame test (seeded tree renders, transcript replaced) + /agents
dispatch. LIVE tmux: /agents opened empty; then a REAL delegation spawned a
subagent → /agents showed "⛓ Agents · 1 subagent · ● completed <goal>
(model) terminal". ALL 7 first-class surfaces are now +tested+smoked
(blocking prompts, pager, session switcher, model picker, skills hub,
completions, agents dashboard). Smoke P5e + matrix updated. Remaining: chrome
(5b), agent-feature polish (5d), launcher (8).
2026-06-08 16:23:17 +00:00
alt-glitch
99b24f6747 feat(opentui-v2): Phase 5a — slash completions dropdown (last first-class overlay)
A live slash-completion dropdown renders above the composer as you type `/…`
(spec §1 autocomplete) — the 6th and final first-class overlay surface.

- view/composer.tsx: onContentChange → onType (reads ta.plainText); a dropdown
  of candidates (display + meta) renders above the textarea when completions are
  set. The textarea owns key input (live refine-by-typing), so Tab accepts the
  top match (ta.clear()+insertText) and Esc dismisses; arrow-nav would fight the
  cursor (noted polish).
- store: completions state + setCompletions/clearCompletions; CompletionItem.
- logic/slash.ts: mapCompletions(complete.slash result) → candidates.
- entry: onType queries complete.slash for `/word` (no space) and sets/clears the
  store completions; cleared on submit / non-slash / space.

Verified: bun run check green (49 tests / 7 files) — mapCompletions + a
composer-dropdown frame test. LIVE tmux: typing `/comp` showed /compress,
/composio, /compact (with descriptions); Tab accepted the top + cleared the
dropdown. ALL 6 first-class overlays are now +tested+smoked (blocking prompts,
pager, session switcher, model picker, skills hub, completions). Smoke P5a +
matrix updated. Remaining: chrome (5b), agent features (5d), agents dashboard (5e).
2026-06-08 16:15:38 +00:00
alt-glitch
d4d7c9b0ae feat(opentui-v2): Phase 5c — model picker + skills hub (generic Picker overlay)
A reusable generic picker (titled <select> + onPick) powers two more first-class
overlays (spec §2b):

- view/overlays/picker.tsx + store picker/openPicker/closePicker + PickerItem.
- /model: bare → model.options → a picker of authenticated providers' models
  (current marked ✓), pick switches via `slash.exec model <name>`; `/model <name>`
  switches directly without the picker.
- /skills: skills.manage {action:list} → a picker flattened from
  {category: names[]}; picking inspects (skills.manage inspect) → the pager.
- view/App.tsx: the input zone is now a <Switch> — prompt → switcher → picker →
  composer (overlays replace, never stack, so the composer remounts/refocuses).

Verified: bun run check green (47 tests / 7 files) — /model bare→picker (auth
filtered, current marked, pick→slash.exec), /model <name> direct, /skills flatten.
LIVE tmux: /model → picker listing 8 models (anthropic/claude-opus-4.8 ▶, nous,
…), Esc closed clean; /skills → hub listing skills w/ category descriptions.
5 of 6 first-class overlays done (prompts, pager, session switcher, model picker,
skills hub) — completions dropdown remains. Smoke P5c + matrix updated.
(Note: model.options is ~5s server-side; a loading indicator is a polish TODO.)
2026-06-08 16:08:25 +00:00
alt-glitch
ba10594322 feat(opentui-v2): Phase 5c — session switcher overlay (list → pick → resume)
A first-class picker (spec §2b, Ink activeSessionSwitcher): /sessions (aliases
/resume, /switch, /session) → session.list → a native <select> overlay; Enter
resumes the chosen session via the SAME resumeInto hydrate path as launch, so
tool rows + transcript hydrate correctly. Esc closes. Reuses Phase 4b resume.

- view/overlays/sessionSwitcher.tsx: <select> of sessions (title / preview /
  message count), onSelect → onPick(id); Esc cancels.
- store: switcher state + openSwitcher/closeSwitcher; SessionItem type.
- logic/resume.ts: mapSessionList(session.list result) → SessionItem[].
- logic/slash.ts: /sessions|/resume|/switch|/session client commands +
  listSessions/openSwitcher on SlashContext.
- entry: resumeInto extracted (shared by bootstrap + switcher); slashCtx wires
  listSessions (session.list → mapSessionList) + openSwitcher; onResume runs
  resumeInto via runFork. App input zone is now prompt → switcher → composer
  (overlays replace, not stack, so the composer remounts/refocuses on close).

Verified: bun run check green (43 tests / 7 files) — slash /sessions → switcher,
+ a switcher frame test (rows render, composer replaced). LIVE tmux: /sessions
listed real titled sessions w/ counts/previews; ↓+Enter resumed the picked one
(hydrate_ms=8) → transcript hydrated incl. the terminal tool row; switcher
closed, composer returned; /quit clean. 3 of 6 first-class overlays done
(prompts, pager, switcher). Smoke P5c + matrix updated.
2026-06-08 16:00:39 +00:00
alt-glitch
0d0e9203cf 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
abdc21f39a 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
87634e19fd 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
d01b573796 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
a572a1eae4 feat(opentui-v2): Phase 2b-ii — native markdown for assistant text (Phase 2 done)
Assistant text parts now render through the NATIVE markdown renderable instead of
plain spans — bold/headings/lists/fences render, raw `**`/backtick markup is
concealed (spec §7; never hand-roll a parser).

- view/markdown.tsx: `<code filetype="markdown" streaming conceal drawUnstyledText>`
  (CodeRenderable — opencode's v2 AssistantText path; `<markdown>` +
  internalBlockMode="top-level" deferred paint headlessly). SyntaxStyle.fromStyles
  is derived from the theme (markup.* → theme.color.*, non-hex colors guarded) and
  cached by theme-object identity so all text parts share one instance, rebuilt
  only on skin change. drawUnstyledText paints raw text immediately while
  Tree-sitter highlighting settles (and makes it headless-capturable).
- view/messageLine.tsx: text-part Match renders <Markdown> instead of <text>.
- test/lib/render.ts: settle async markdown via flush(); captureFrame gains an
  `until` option (waitForFrame) for content that paints after the first pass.

Verified: bun run check green (23 tests / 5 files). Live tmux: a markdown reply
(heading + bold word + 2-item list) rendered with `**` concealed (grep -c '**' = 0);
Ctrl+C clean, no orphan. Phase 2 complete (2a shell + 2b-i parts/tools + 2b-ii
markdown) — smoke steps 1–4 run live. Next: Phase 3 blocking prompts.
2026-06-08 14:49:52 +00:00
alt-glitch
b72ac77783 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
53b37463c4 feat(opentui-v2): Phase 2a — scrollbox transcript + textarea composer + header
Turns the read-only Phase-1 view into an interactive shell, split into focused
view components (spec v4 §2 layout):

- view/transcript.tsx: ONE full-height <scrollbox> with a reactive <For>
  (opencode's no-scrollback model). Applies the §8 #2 gotchas exactly:
  minHeight:0 on the wrapper AND the scrollbox, NO flexDirection on the
  scrollbox root, stickyScroll + stickyStart="bottom".
- view/composer.tsx: a native <textarea> captured by ref — flexShrink:0,
  focus-on-mount, Enter->submit via keyBindings, imperative .clear() on submit,
  and a `submitting` re-entrancy guard. Wired by the entry to fire prompt.submit
  (Effect.runFork on the in-hand service value); it's now the PRIMARY input, with
  the HERMES_TUI_PROMPT stand-in kept only for launch-with-prompt.
- view/header.tsx + view/messageLine.tsx: extracted, themed (no hardcoded
  styles). MessageLine stays flat-text this slice; ordered parts (§7) land in 2b.

test/lib/render.ts now flushes 3 renderOnce passes before capture — a <scrollbox>
needs more than one pass to measure content + apply sticky, else the transcript
row paints blank.

Verified: bun run check green (12 tests / 4 files / 31 expects). Live tmux drive:
typed into the composer -> cleared -> user row -> streamed reply ("Here are three
words"); Ctrl+C quits cleanly even with the textarea focused, no orphan child.
Composer placeholder rendered the live skin's welcome string (skin->theme live).
Smoke P2a + parity matrix updated. Phase 2b (ordered parts/tool render/markdown)
is the next slice.
2026-06-08 14:28:59 +00:00
alt-glitch
cc2c881fd1 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
12342a4bce 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
alt-glitch
ea0de82422 opentui(phase3): launcher integration — HERMES_TUI_ENGINE dual-engine
hermes --tui launches the native OpenTUI engine (Bun) when
HERMES_TUI_ENGINE=opentui (env) or display.tui_engine=opentui (config);
Ink stays the default and the shipping path is untouched.

- _resolve_tui_engine() (env > config > ink); refuses opentui on
  Windows/Termux (no Bun) -> falls back to ink with a notice.
- _make_opentui_argv() -> [bun, src/entry.real.tsx] (no build step).
- _bun_bin() with HERMES_BUN override.
- Branch at top of _make_tui_argv BEFORE _ensure_tui_node (Bun-only host
  must not bootstrap Node).
- Gate _launch_tui NODE_OPTIONS/--max-old-space-size on engine==ink (Bun
  is JSC; the V8 flag errors/ignores).

Verified end-to-end via tmux: real hermes --tui -> Bun -> OpenTUI ->
real Python gateway streamed a real reply. No-flag default still ink.
2026-06-08 11:11:54 +00:00