hyperhive/docs/terminal-rendering.md
iris 1011428cc7 docs(terminal-rendering): document plugin_install, commands_changed, compact_boundary rows
The renderer dispatch section was missing entries for three system
subtypes that now have specific renderers (added in the preceding
commits on this branch). Update the row taxonomy table and the
numbered dispatch description to cover all four system-subtype
handlers (thinking_tokens was already documented; 1b–1e are new).
2026-07-04 11:59:37 +02:00

11 KiB
Raw Blame History

Per-agent terminal: row taxonomy (as built)

Snapshot of how the per-agent web UI's live pane renders each event kind today. Source of truth lives in frontend/packages/agent/src/app.js (renderStream, fmtToolUse, renderRichToolUse, renderToolResult, renderTaskEvent, mdNode, detailsOpenMd, fmtArgsGeneric) + frontend/packages/shared/src/terminal.css (the shared .live .<class> styling) + the marked npm package (markdown).

Layout contract

Every row — flat <div class="row …"> and expandable <details class="row …"> alike — shares one prefix column. The mechanism is padding-left + negative text-indent on .live .row: the row's first inline box gets pulled back into the column at ~0.5em, and wrapped continuation lines hang under the body, not under the glyph.

Rows that carry an icon (the per-tool emoji, 🧠/💭 thinking, etc.) pass it as the icon argument to row() / details() / detailsDiff(), which puts it in a fixed-width .row-glyph cell (display: inline-block; width: 1.4em) rather than as a bare first character. The constant cell width means every icon's left edge lines up in the column regardless of the glyph's rendered width (emoji differ; some carry a variation selector) — a flat row's 🧠 and a details summary's 🖥️ align. Rows with a plain single-char glyph (◆ · ! ←) still pass it inline; it lands at the same ~0.5em left edge.

<details> summaries inherit those metrics. The icon (when present) sits in the .row-glyph cell; the summary text lives in a .summary-text span and the disclosure caret ( / ) is supplied by CSS .summary-text::before so it leads the text, not the icon — a leading caret on the icon would push it out of the shared column. Icon-less summaries have no .row-glyph, so the caret falls into the prefix column like the old directional glyph. The summary text carries no / ; the row colour (cyan = outbound, muted = inbound) carries the direction.

Child blocks inside a row (the .md markdown wrapper, an inner <details>) get text-indent: 0 so their content lays out from the body column instead of inheriting the parent's negative pull.

Row taxonomy

CSS class Prefix glyph Color Triggered by Source
.turn-start ◆ TURN ← <from> amber, left rule LiveEvent::TurnStart harness wake
.turn-body (child div under turn-start) fg same the wake-prompt body
.turn-end-ok ✅ turn ok green, left rule LiveEvent::TurnEnd { ok: true } harness
.turn-end-fail ❌ turn fail — note red, left rule LiveEvent::TurnEnd { ok: false } harness
.turn-time · HH:MM:SS on turn-start; · HH:MM:SS · <dur> on turn-end (child span) muted, smaller per-event ts (unix seconds) on the live frame + history row harness
.text (no prefix; markdown body) fg claude assistant.content[].text stream-json
.thinking 💭 thinking … muted, italic claude assistant.content[].thinking stream-json
.tool-use (flat) <icon> Name args… cyan tool_use w/o rich renderer; <icon> from toolIcon(name) (📤 send · 📥 recv · ask · remind · 🏷️ set_status · 🪢 loose-ends · 🖥️ bash · 💬 matrix · 📦 request_* · ⏱️ schedule · 🔧 default) stream-json
.tool-use <details> 💾/✏️ Write/Edit <path> · +N (no ) cyan, body is +/- diff renderRichToolUse Write/Edit stream-json
.tool-use <details open> 📤 send → to · NL, ❓ ask → to, ✍️ answer #id cyan, body is markdown rich renderer for send / ask / answer stream-json
.tool-use .ask-answer-inline-slot (sub-block under ask → operator) inherits row inline answer form bound by reconcileAskBinds to the loose-end rich renderer
.tool-result (flat) ← <txt> muted short tool_result (≤120c, non-recv) stream-json
.tool-result-block <details> Nl · headline muted, body is text long generic tool_result stream-json
.tool-result-block <details open> recv ← <txt> muted, body is markdown tool_result correlated to a prior recv tool_use via id stream-json
.tool-result.error (flat) ✗ <msg> red tool_result with is_error: true (≤120c); <tool_use_error> wrapper stripped stream-json
.tool-result-block.error <details> Nl · headline red, body is text long error tool_result (is_error: true); wrapper stripped stream-json
.tool-use ⌁ task <id> started · <desc> [type] cyan claude Task-tool subagent start (dead path — Task omitted from agent allow-list) renderTaskEvent
.turn-end-ok / .turn-end-fail / .tool-result ⌁ task <id> ✓/✗/◌ <status> · <desc> · → <output_file> green / red / muted claude Task-tool result (dead path for agents) renderTaskEvent
.note · ⚙ plugin install · loading… or ✓ done muted system/plugin_install (status = started/completed) stream-json
.note · ⚙ commands changed · N available (expandable list of /name entries) muted system/commands_changed (slash-command set updated, usually post-plugin_install) stream-json
.note · ⚙ compact · <trigger> · <pre>→<post> tokens · <dur> muted system/compact_boundary (compaction completed; metadata includes pre/post token counts, duration, trigger) stream-json
.note · ⚙ <subtype> muted other system subtypes (context_window_exceeded, etc.) stream-json catch-all
.note · <text> muted harness chatter LiveEvent::Note
.note.stderr ! stderr: <line> amber/orange stderr lines off claude LiveEvent::Note (text starts stderr:)
.note.op · operator: <text> mauve italic operator-initiated notes (/cancel, /compact, /model, new-session) LiveEvent::Note (text starts operator:)
.sys ! {json…} amber/orange catch-all for stream shapes renderStream didn't classify catch-all
Banner shimmer mauve turn in flight (ref-counted) setBannerActive

The .turn-time span is appended to the turn-start / turn-end rows from the event's ts (unix seconds), which the backend serializes as a flattened sibling of kind on both the live SSE frame and each history row — so the same renderer path stamps live tail and replayed scrollback identically. Turn-end also shows the elapsed duration (end start), paired against the most recent open turn-start. The read is guarded on a numeric ts: if a frame omits it the rows render without the time suffix, so the terminal degrades cleanly against older event shapes.

Renderer dispatch

renderStream(v, api) walks each stream-json line:

  1. Drops system/init, rate_limit_event, result (noise / handled elsewhere — result powers the cost badge). 1a. system/thinking_tokens (claude streams a running estimated_tokens counter while thinking — many per turn) → collapses into a single 🧠 thinking … ~N tokens .note row that updates in place. Consecutive ticks reuse the row only while it's still the last one rendered (nextElementSibling == null); any other event after it makes the next tick start a fresh row. Avoids a note-per-tick scrollback flood. 1b. system/plugin_install → muted note ⚙ plugin install · loading… (on started) or ⚙ plugin install · ✓ done (on completed). Emitted in pairs: started fires before the plugin loads, completed fires when it's ready. The uuid links the pair. 1c. system/commands_changed → collapsible .note details row showing the new slash-command count (⚙ commands changed · N available). Expanding reveals each /name and its aliases. Fires after plugin_install when a plugin registers new commands. 1d. system/compact_boundary → muted note showing compaction summary: ⚙ compact · <trigger> · <pre>→<post> tokens · <dur>. Fields are guarded individually — a missing field is silently omitted. Trigger is "manual" (operator /compact) or "auto". 1e. Other system/ subtypes → muted note ⚙ <subtype>.
  2. subtype == "task_started" | "task_notification"renderTaskEvent (subagent activity gets the glyph).
  3. type == "assistant" → walk message.content[]:
    • text.text row with a markdown body via mdNode.
    • thinking.thinking row.
    • tool_use → record id → name in toolNameById, try renderRichToolUse (Write/Edit/send/ask/answer get custom renderings); on miss fall through to a flat .tool-use row with fmtToolUse → fmtArgsGeneric. fmtToolUse surfaces the salient arg per built-in tool — e.g. recv shows wait <N>s / max <N> when set (bare recv() otherwise), Bash flags [bg] for run_in_background commands.
  4. type == "user" → walk message.content[] for tool_result; renderToolResult correlates via tool_use_id → toolNameById to default-open recv results with a markdown body, else short = flat / long = collapsed details.
  5. Unrecognised shape → .sys row (amber, ! glyph).

Markdown

mdNode(text) wraps marked.parse(text) (the marked v4.x npm dep, bundled by esbuild into the page's app.js) in a <div class="md">. CSS in terminal.css scopes paragraph / code / list / blockquote / link styling under .live .row .md so the markdown body doesn't bleed into the row's own text-indent. Falls back to plain text if marked didn't load. Applied to text rows and to send / ask / answer / recv message bodies.

Extra-MCP tools

fmtArgsGeneric(name, input) is the fallback when a tool isn't in the built-in fmtToolUse switch:

  • single string field → name k: "v"
  • single number/bool field → name k: v
  • multi-field → first 4 pairs trimmed to k: "v" / k: [N] / k: {…} with a …+N overflow

This keeps mcp__matrix__send_message and similar from dumping raw JSON.

Inline ask-operator answer

When an agent calls mcp__hyperhive__ask with to == "operator" (default), the rich tool-use renderer mounts an empty <div class="ask-answer-inline-slot"> inside the row's expanded body, then enqueues a loose-ends refresh. reconcileAskBinds() runs on every loose-ends refresh, matches each waiting slot against pending operator-bound questions by question text, and injects an inline .answer-form (textarea + send button bound to /answer-question/<id> on the host dashboard) into the matching slot. When a question subsequently leaves the pending list (answered, cancelled by asker, or TTL-expired), the same reconciler replaces the form with a struck-through [resolved] tag so the scrollback reflects the closed state. The label is neutral because /api/loose-ends only carries pending state — full resolution detail is visible via the question's history in the side panel.

Lets the operator answer mid-flow without context-switching to the loose-ends side panel or the dashboard tab. Side panel + dashboard forms remain — they're the same buildAnswerForm factory, three mount points for the same POST.

Dashboard side (not covered here)

The main dashboard's message-flow pane is a different shape: broker messages render as .msgrow grid lines (ts / arrow / from / → / to / body) with their own styling. .live .msgrow explicitly resets text-indent: 0 so the per-agent terminal's hanging-indent metrics don't leak into the flex-grid broker rows.