hyperhive/docs/terminal-rendering.md
iris 1c47bd4333 agent-ui: real fixed-width icon column for terminal rows
Replaces the first-character-glyph + negative-text-indent trick (which let a
wide emoji or a leading disclosure caret knock the icon out of column) with a
genuine icon cell.

terminal.js: row() / details() / detailsDiff() take an optional `icon` that
goes in a fixed-width `.row-glyph` element (inline-block, 1.4em). Details
summaries wrap their text in a `.summary-text` span; the disclosure caret
moves to `.summary-text::before` so it leads the text, not the icon — keeping
the icon in the shared column. terminal.css carries the cell + caret rules.

app.js passes the per-tool emoji as `icon` for the flat tool-use row and every
expandable tool summary (Write/Edit/send/ask/answer/bash) plus the 💭 thinking
row, instead of string-prefixing it. A details `🖥️` now lines up under a flat
row's `🧠` regardless of emoji width. Doc: terminal-rendering.md layout
contract updated. Closes #1844.
2026-06-22 00:41:24 +02:00

9.6 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-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 · <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.
  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.