hyperhive/docs/terminal-rendering.md
iris 85ef5e5fbe hive-agent: show mark_todos_done ids in the terminal, not just a count
fmt_args_generic's generic array handling collapsed `ids: [4]` — the
count — since mark_todos_done had no dedicated match arm. Added one,
matching the file's existing per-tool pattern (extracted into its own
helper to stay under the 100-line clippy limit on fmt_hyperhive_tool).
2026-08-16 15:45:05 +02:00

16 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. The per-tool icon/summary/category (and, for a few rich tools, the expandable body) are pre-computed server-side by hive-agent/src/stream_enrich.rs::enrich and stamped onto the stream-json value as _icon/_summary/_category/_body/ _body_type before SSE delivery, so the frontend just dispatches on those fields instead of re-deriving them. Frontend source of truth lives in frontend/packages/agent/src/app.js (renderStream, renderRichToolUse, renderToolResult, renderTaskEvent, mdNode, detailsOpenMd) + frontend/packages/shared/src/terminal/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 the backend's tool_icon(name) (stream_enrich.rs): 📤 send · 📥 recv · ask · remind · 🏷️ set_status · 🪢 loose-ends · ✂️ cancel_loose_end · get_agent_meta · ack_until · 📜 get_logs/get_host_journal · ↻ restart · ⏹️ kill · ▶️ start · 🔄 update · 📋 list_containers/list_rooms/list_room_members/list_invites · 📖 read_room/Read · 👁️ mark_read · 🛑 bash kill · 🖥️ bash other · 💬 matrix send/reply/dm · 📦 request_* · ⏱️ schedule · 🔧 default stream-json
.tool-use <details> ✏️ Edit <path> · -N +N (no ) cyan, body is +/- diff renderRichToolUse 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-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. Most of the per-event classification it used to do itself is now pre-computed server-side by hive-agent/src/stream_enrich.rs::enrich (stamped onto the value as _category/_summary/_icon/_body/ _body_type at SSE-emit time, for both the live tail and history replay) — the client mostly just dispatches on those fields rather than re-deriving them from raw claude field names:

  1. v._category === 'drop' → dropped without rendering. Covers the top-level result / rate_limit_event types (result powers the cost badge elsewhere) and the system subtypes init / result / rate_limit_event. 1a. system events with _category === 'thinking_tok' (subtype == "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, text taken verbatim from the backend-computed _summary. 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 (matched on subtype, not _category, so start/complete can coalesce into one row) → muted note ⚙ plugin install · loading… (on started) or ⚙ plugin install · ✓ done (on completed), text from _summary. Emitted in pairs: started fires before the plugin loads, completed fires when it's ready. 1c. system/status (matched on subtype) → muted note from _summary, except while the harness's local turn_state is compacting: the client overrides the text with an elapsed-time counter (⚙ compact · <N>s…) computed client-side from stateSince, since the backend can't know client wall-clock time at emit time. 1d. _category === 'details' (currently just system/commands_changed) → collapsible .note details row: _summary as the header (⚙ commands changed · N available), _body (one /name per line) as the expandable content. 1e. Other system/ subtypes (e.g. compact_boundary, api_retry, api_error, or an unrecognised subtype) → _category === 'note', rendered as a single muted note from _summary — computed by system_fields() in stream_enrich.rs (e.g. compact_boundary⚙ compact · <trigger> · <pre>→<post> tokens · <dur> with each field guarded individually; an unrecognised subtype falls back to ⚙ <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. The backend stamps every tool_use entry with _icon + _summary (via fmt_tool_use() in stream_enrich.rs — see salient-arg formatting below) and, for a fixed set of tools, _category: "rich" + _body/_body_type. When _category === "rich", renderRichToolUse dispatches on _body_type ("diff"api.detailsDiff, "markdown"detailsOpenMd, else api.details) to build the expandable row; otherwise it falls through to a flat .tool-use row using _icon + _summary as-is — no per-tool JS.
  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).

Salient-arg formatting

Server-side (fmt_tool_use() and its per-tool-family helpers in hive-agent/src/stream_enrich.rs), computed into _summary and read verbatim by the client. The short name strips the mcp__hyperhive__ / mcp__bash__ / mcp__matrix__ prefix and appends * (e.g. recv*, run*, send_message*). Unprefixed tools (Read, Write, etc.) keep their name as-is.

Tool Rendered as
Claude built-ins
Read Read <path>
Write flat, same shape as Read: Write <path> — no diff/count (content can be megabytes and is one-sided; open the file to inspect it)
Edit rich diff row Edit <path> · -N +N (just +N for a pure insert, i.e. empty old_string)
Glob Glob <pattern>
Grep Grep <pattern>
Bash Bash [bg] $ <cmd> (dead path — built-in Bash isn't in the agent allow-list either; shell execution goes through mcp__bash__run / run* below instead)
TodoWrite TodoWrite (N items) (dead path — TodoWrite isn't in the agent allow-list; its state lives in claude's in-process session and evaporates on /compact, so agents plan in /state notes instead)
Core hyperhive
send* rich renderer: send* → to · NL (default-open body)
recv* recv*() · recv* wait Ns · recv* max N
ask* rich renderer: ask* → to (no inline answer form — see Inline ask-operator answer)
answer* rich renderer: answer* #id
remind* remind* +Xm "preview" or remind* at HH:MMZ "preview"
set_status* set_status* "text"
get_loose_ends* get_loose_ends*() or get_loose_ends* [agent]
get_agent_meta* get_agent_meta*() or get_agent_meta* name
cancel_loose_end* cancel_loose_end* kind #id
ack_until* ack_until* ≤N
mark_todos_done* mark_todos_done* [id1, id2, …] (first 8 ids, past that)
Lifecycle
kill*/restart*/start*/update* kill* name (etc.)
get_logs* get_logs* name or get_logs* name NL
get_host_journal* get_host_journal*() or with [container] · [/grep/] · NL
Approvals / config
request_init_config* request_init_config* name
request_update_meta_inputs* request_update_meta_inputs* [inp1, …] or all
Scheduling
list_schedules* list_schedules*()
cancel_schedule* cancel_schedule* #id all or #id [t1, t2]
fire_schedule_now* fire_schedule_now* #id
edit_schedule* edit_schedule* #id · body · interval · next · +N tgt · -N tgt (only changed fields shown)
request_schedule_prompt* request_schedule_prompt* → t1, t2 at HH:MMZ (+ +Ns if recurring)
Bash MCP
run* run* [bg] $ cmd (also rich renderer for full cmd body)
status* (bash) status* id:xyz or status* id:xyz · wait Ns
kill* (bash) kill* id or kill* id [force]
Matrix MCP
send_message*/send_dm*/send_reply* send_message* → room: "body" / send_dm* → @user: "body"
send_reaction* send_reaction* room emoji
read_room* read_room* room or read_room* room [N]
mark_read* mark_read* room
join_room*/open_dm* join_room* room / open_dm* @user
invite_user* invite_user* @user → room
download_file* download_file* room
Everything else fmt_args_generic — see Extra-MCP tools

Markdown

mdNode(text) wraps marked.parse(text) (the marked 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

fmt_args_generic(name, input) (hive-agent/src/stream_enrich.rs) is the fallback when a tool isn't in the built-in fmt_tool_use switch, computed into _summary server-side:

  • 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 less-frequent tools that don't have a specific fmt_tool_use case from dumping raw JSON. Common matrix and hyperhive tools have their own cases and skip this path.

Inline ask-operator answer

An mcp__hyperhive__ask(to: "operator", ...) row has no inline answer form in this terminal — it renders like any other tool call. The operator answers a pending question from the main dashboard's own question surfacing (dashboard/src/swarm.js + call.js, the Y3R C4LL tab), not from the per-agent page.

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.