Compare commits

...
Author SHA1 Message Date
iris
5930efc29b Trim negative-space comments per mara's review
Don't state what a function/module doesn't do and where that
happens instead — just describe what it does. Cut the "not
something this function decides" / "not affected by this" /
"not a placeholder for a later commit" asides from the doc
comments touched in the last two commits.
2026-08-30 21:31:33 +02:00
iris
907567ef76 Fix stale default-open doc comments per argus's review
ClassifyCtx's tool_use-id correlation gates markdown-vs-plain body
format for a recv result, not open/collapsed state — that's always
the operator's uniform preference now. 5 backend comments still
described it as controlling "default-open" rendering, contradicting
the actual render path and this PR's own rewritten docs.

Also fixed useAgentState.ts's stale comment promising an
SSE-triggered refresh model "in a later commit" — that's permanently
off the table now that the terminal stream's kind tag is gone by
design (argus flagged this as a drive-by, not blocking, but it's a
one-line cause-and-effect of this same PR so fixing it here).
2026-08-30 21:28:34 +02:00
iris
cebf3c6ced Drop classifyEvent.ts, render TermMsg directly
Per review: StreamRow was meant to match what the server sends in
TermMsg, not be a separate model needing a translation step.

- classifyEvent.ts and streamRow.ts deleted; termMsg.ts holds the wire
  types (TermMsg/TermEnvelope) plus TermRow, a TermMsg with just the
  key/fromHistory bookkeeping Preact needs for list rendering.
- Row.tsx renders a TermRow directly: level -> CSS class, empty
  summary + markdown body -> flat row, everything else with a body ->
  expandable details gated by the operator's preference. No separate
  classification step.
- useLiveStream.ts drops ClassifyCtx (a single incrementing key
  counter didn't need a whole context object) and maps envelopes to
  rows inline.
- docs/terminal-rendering.md trimmed substantially — was documenting
  more implementation detail than useful; points at stream_enrich.rs
  for the per-tool specifics instead of duplicating them in prose.
2026-08-30 21:23:28 +02:00
iris
5eefaa951d Simplify terminal message shape to a uniform TermMsg
Move terminal-row classification server-side into a new
hive-agent/src/term_msg.rs, replacing the old JSON-mutation
enrich()/stamped-field approach in stream_enrich.rs with one
uniform wire shape: {icon?, level: debug|info|warn|error, summary,
body?, body_format?: markdown|diff, coalesce_key?}. No more per-row
`kind` tag or raw claude-JSON passthrough — every row is the same
shape, with structural identity carried by icon + summary text
instead of a CSS class per row kind.

hive-agent/src/web_ui/stream.rs's history + SSE endpoints now both
call term_msg::classify() and serve TermEnvelope{ts, seq?, msgs}
frames; events that classify to zero rows (agent-state changes,
drop-noise) never reach the wire.

Frontend: classifyEvent.ts collapses from a large per-tool dispatch
tree to a thin TermMsg -> StreamRow adapter. streamRow.ts/Row.tsx
drop the now-dead meta/childText fields. terminal.css switches from
a dozen-odd per-row-kind classes to four level-based color rules.
Expand/collapse of a bodied row is now a uniform client-side
decision (the operator's preference), no server-side per-tool
override.

docs/terminal-rendering.md rewritten to match.
2026-08-30 21:11:25 +02:00
15 changed files with 822 additions and 864 deletions

View file

@ -1,249 +1,51 @@
# Per-agent terminal: row taxonomy (as built) # Per-agent terminal: row taxonomy (as built)
Snapshot of how the per-agent web UI's live pane renders each The per-agent web UI's live pane renders one row per `TermMsg`
event kind today. The per-tool icon/summary/category (and, for a (`hive-agent/src/term_msg.rs`): `{icon?, level: debug|info|warn|error,
few rich tools, the expandable body) are pre-computed server-side by summary, body?, body_format?: markdown|diff, coalesce_key?}`. Classification
`hive-agent/src/stream_enrich.rs::enrich` and stamped onto the (icon, summary text, whether a tool call gets an expandable body) happens
stream-json value as `_icon`/`_summary`/`_category`/`_body`/ server-side, once — `term_msg.rs` + `stream_enrich.rs` — and is served
`_body_type` before SSE delivery, so the frontend just dispatches on identically by both `GET /api/events/history` and `GET /api/events/stream`
those fields instead of re-deriving them. Frontend source of truth as `TermEnvelope { ts, seq?, msgs: TermMsg[] }` frames
lives in `frontend/packages/agent/src/app.js` (`renderStream`, (`hive-agent/src/web_ui/stream.rs`).
`renderRichToolUse`, `renderToolResult`, `renderTaskEvent`,
`mdNode`, `detailsOpenMd`) +
`frontend/packages/shared/src/terminal/terminal.css` (the shared
`.live .<class>` styling) + the `marked` npm package (markdown).
## Layout contract The frontend renders a `TermMsg` close to as-is
(`frontend/packages/agent/src/components/Row.tsx`): `level` picks the
CSS colour (`terminal.css`'s `.live .level-*`), an empty `summary` +
markdown `body` renders as a flat row with just the body (assistant
text), and any other bodied row is an expandable `<details>`, opened by
default according to the operator's expand-tool-output preference
(`getExpandDetailsPref()`) — uniformly, no per-tool override. There's no
separate client-side row model or classification step.
Every row — flat `<div class="row …">` and expandable ## Layout
`<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, `🧠`/`💭` Every row shares one prefix column via `padding-left` + negative
thinking, etc.) pass it as the `icon` argument to `text-indent` on `.live .row`; an icon (when set) sits in a fixed-width
`row()` / `details()` / `detailsDiff()`, which puts it in a `.row-glyph` cell so icons of different rendered widths still line up.
fixed-width `.row-glyph` cell (`display: inline-block; `<details>` summaries reuse the same metrics, with the disclosure caret
width: 1.4em`) rather than as a bare first character. The leading the summary text rather than the icon.
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 ## Levels
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 | Level | Colour | Roughly |
inner `<details>`) get `text-indent: 0` so their content |---|---|---|
lays out from the body column instead of inheriting the | `debug` | muted | thinking, coalesced ticks, ambient harness chatter |
parent's negative pull. | `info` | default fg | turn start/ok, assistant text, tool calls + results |
| `warn` | amber, left rule | stderr, an unrecognised event shape, API retries |
| `error` | red, left rule | turn failed, a tool result with `is_error: true` |
## Row taxonomy Per-tool icon + summary formatting (what a `Read`/`Edit`/`send` call's row
actually says) lives in `stream_enrich.rs`'s `fmt_tool_use()` family —
| CSS class | Prefix glyph | Color | Triggered by | Source | read that when you need the specifics, this doc doesn't duplicate it.
|---|---|---|---|---|
| `.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 · ⏰ 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` | cyan, body is markdown | rich renderer for send | 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](#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` |
| `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](#extra-mcp-tools) |
## Markdown ## Markdown
`mdNode(text)` wraps `marked.parse(text)` (the `marked` npm dep, `frontend/packages/agent/src/lib/markdown.ts`'s `renderMarkdown()` runs
bundled by esbuild into the page's `app.js`) in a `<div `marked.parse()` through DOMPurify into a `<div class="md">`. Applied to
class="md">`. CSS in `terminal.css` scopes paragraph / code / any `TermMsg` with `body_format: "markdown"`.
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 / 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.
## Dashboard side (not covered here) ## Dashboard side (not covered here)
The main dashboard's message-flow pane is a different The main dashboard's message-flow pane is a different shape: broker
shape: broker messages render as `.msgrow` grid lines (ts / messages render as `.msgrow` grid lines, not agent-terminal rows.
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.

View file

@ -240,7 +240,7 @@ export function Root() {
{state.status === 'needs_login_idle' || state.status === 'needs_login_in_progress' ? ( {state.status === 'needs_login_idle' || state.status === 'needs_login_in_progress' ? (
<LoginFlow status={state.status} session={state.session} onRefresh={refresh} /> <LoginFlow status={state.status} session={state.session} onRefresh={refresh} />
) : null} ) : null}
<LiveStream ref={liveStreamRef} onLiveTurnBoundary={refresh} /> <LiveStream ref={liveStreamRef} />
</main> </main>
{termInput} {termInput}
{panel} {panel}

View file

@ -28,12 +28,6 @@ import { Row } from './Row.js';
const NEAR_BOTTOM_PX = 48; const NEAR_BOTTOM_PX = 48;
const LOAD_MORE_SCROLL_PX = 80; const LOAD_MORE_SCROLL_PX = 80;
export interface LiveStreamProps {
/** Forwarded to useLiveStream fires on live turn_start/turn_end so
* the caller can refresh `/api/state` sooner than its poll interval. */
onLiveTurnBoundary?: () => void;
}
/** Imperative escape hatch for TermInput's local-only slash commands /** Imperative escape hatch for TermInput's local-only slash commands
* (`/help`, `/clear`) same shape as app.js's old `termAPI` object, * (`/help`, `/clear`) same shape as app.js's old `termAPI` object,
* kept as a ref handle rather than lifting the whole row array up to * kept as a ref handle rather than lifting the whole row array up to
@ -43,11 +37,8 @@ export interface LiveStreamHandle {
clear: () => void; clear: () => void;
} }
export const LiveStream = forwardRef<LiveStreamHandle, LiveStreamProps>(function LiveStream( export const LiveStream = forwardRef<LiveStreamHandle, object>(function LiveStream(_props, ref) {
{ onLiveTurnBoundary }, const { rows, hasMore, loadingMore, loadMore, pushLocalNote, clearLocal } = useLiveStream();
ref,
) {
const { rows, hasMore, loadingMore, loadMore, pushLocalNote, clearLocal } = useLiveStream({ onLiveTurnBoundary });
useImperativeHandle(ref, () => ({ pushNote: pushLocalNote, clear: clearLocal }), [pushLocalNote, clearLocal]); useImperativeHandle(ref, () => ({ pushNote: pushLocalNote, clear: clearLocal }), [pushLocalNote, clearLocal]);
const logRef = useRef<HTMLDivElement>(null); const logRef = useRef<HTMLDivElement>(null);
const [stickToBottom, setStickToBottom] = useState(true); const [stickToBottom, setStickToBottom] = useState(true);

View file

@ -1,13 +1,16 @@
// Renders one `StreamRow` — flat `<div class="row …">` or expandable // Renders one `TermRow` — flat `<div class="row …">` or expandable
// `<details class="row …">`, matching @hive/shared/terminal.css's // `<details class="row …">`, driven straight off the wire shape
// existing row-kind classes exactly (see docs/terminal-rendering.md). // (`lib/termMsg.ts`'s `TermMsg`, mirroring hive-agent's `term_msg.rs`):
// Reuses that stylesheet as-is (imported once by LiveStream.tsx) — the // `level` picks the colour class, an empty `summary` + markdown `body`
// taxonomy's visual language isn't what mara asked to change, the // is a flat row with just the body (assistant text), anything else with
// component *model* underneath it is. // a body is an expandable details row gated by the operator's
// expand-tool-output preference. No separate classification step —
// mara: "StreamRow should now match what the server sends in TermMsg."
import { useEffect, useRef } from 'preact/hooks'; import { useEffect, useRef } from 'preact/hooks';
import type { StreamRow } from '../lib/streamRow.js'; import type { TermRow } from '../lib/termMsg.js';
import { linkifyToNodes } from '../lib/linkify.js'; import { linkifyToNodes } from '../lib/linkify.js';
import { renderMarkdown } from '../lib/markdown.js'; import { renderMarkdown } from '../lib/markdown.js';
import { getExpandDetailsPref } from '@hive/shared/prefs.js';
function MarkdownBody({ text }: { text: string }) { function MarkdownBody({ text }: { text: string }) {
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
@ -41,31 +44,39 @@ function DiffBody({ text }: { text: string }) {
); );
} }
export function Row({ row }: { row: StreamRow }) { export function Row({ row }: { row: TermRow }) {
if (row.details) { const cssClass = 'level-' + row.level;
const icon = row.icon != null && row.icon !== '' && <span className="row-glyph">{row.icon}</span>;
if (row.body == null) {
return ( return (
<details className={`row ${row.cssClass}`} open={row.defaultOpen || undefined}> <div className={`row ${cssClass}`}>
<summary> {icon}
{row.icon != null && row.icon !== '' && <span className="row-glyph">{row.icon}</span>} {linkifyToNodes(row.summary)}
<span className="summary-text">{row.text}</span>
</summary>
{row.diffBody != null && <DiffBody text={row.diffBody} />}
{row.markdownBody != null && <MarkdownBody text={row.markdownBody} />}
{row.plainBody != null && <pre className="tool-body">{linkifyToNodes(row.plainBody)}</pre>}
</details>
);
}
return (
<div className={`row ${row.cssClass}`}>
{row.icon != null && row.icon !== '' && <span className="row-glyph">{row.icon}</span>}
{row.text != null && linkifyToNodes(row.text)}
{row.meta?.map((m) => (
<span key={m.cls} className={m.cls}>
{m.text}
</span>
))}
{row.childText != null && <div className={row.childText.cls}>{row.childText.text}</div>}
{row.markdownBody != null && <MarkdownBody text={row.markdownBody} />}
</div> </div>
); );
}
// Empty summary + markdown body → the body itself is the whole row
// (assistant text), no summary prefix line, never collapsible.
if (row.body_format === 'markdown' && row.summary === '') {
return (
<div className={`row ${cssClass}`}>
{icon}
<MarkdownBody text={row.body} />
</div>
);
}
return (
<details className={`row ${cssClass}`} open={getExpandDetailsPref() || undefined}>
<summary>
{icon}
<span className="summary-text">{row.summary}</span>
</summary>
{row.body_format === 'diff' && <DiffBody text={row.body} />}
{row.body_format === 'markdown' && <MarkdownBody text={row.body} />}
{row.body_format == null && <pre className="tool-body">{linkifyToNodes(row.body)}</pre>}
</details>
);
} }

View file

@ -1,16 +1,5 @@
// useAgentState — polls `GET /api/state` and exposes the latest // useAgentState — polls `GET /api/state` every 4s and exposes the latest
// snapshot + loading/error status. Mirrors app.js's old `refreshState` // snapshot + loading/error status.
// data fetch (not yet its exact re-poll cadence — see the interval
// comment below).
//
// Cadence note: the old page only re-polls on a timer while a login is
// in flight, and otherwise waits for an SSE `turn_end` event to trigger
// one-shot refreshes — this avoids clobbering the operator's half-typed
// message in the term-input field. That field doesn't exist in this
// rewrite yet (a later commit on this same PR), so there's nothing to
// clobber yet; this hook uses a flat interval for now and switches to
// the SSE-triggered model in the commit that adds TermInput + the live
// stream, matching the original behavior once it's actually needed.
import { useEffect, useRef, useState } from 'preact/hooks'; import { useEffect, useRef, useState } from 'preact/hooks';
import type { AgentState } from '../types.js'; import type { AgentState } from '../types.js';

View file

@ -1,37 +1,30 @@
// Backfill + live SSE for the agent's event stream, reduced to a plain // Backfill + live SSE for the agent's event stream, reduced to a plain
// `StreamRow[]` — the Preact-data sibling of // `TermRow[]` — the Preact-data sibling of @hive/shared/terminal.js's
// @hive/shared/terminal.js's `create()`. Scroll behaviour is // `create()`. Scroll behaviour is deliberately NOT this hook's job (see
// deliberately NOT this hook's job (see components/LiveStream.tsx): // components/LiveStream.tsx): mara flagged the old page's scroll-while-
// mara flagged the old page's scroll-while-streaming bug explicitly as // streaming bug explicitly as something not to copy 1:1, and keeping
// something not to copy 1:1, and keeping "what rows exist" separate // "what rows exist" separate from "where the viewport is" is what makes
// from "where the viewport is" is what makes that fixable — this hook // that fixable — this hook only ever appends/prepends to `rows`, the
// only ever appends/prepends to `rows`, the DOM-owning component // DOM-owning component decides whether that should move the scroll
// decides whether that should move the scroll position. // position.
// //
// Same subscribe → buffer → fetch-history → seq-dedupe → flush dance as // Same subscribe → buffer → fetch-history → seq-dedupe → flush dance as
// terminal.js's `start()`: a live event landing between EventSource-open // terminal.js's `start()`: a live envelope landing between EventSource-
// and the history response resolving is buffered, not dropped or // open and the history response resolving is buffered, not dropped or
// double-counted (seq <= history.seq AND the event's kind appeared in // double-counted (`envelope.seq <= history.seq` → already covered by the
// the history replay → already covered, drop it from the buffer). // initial history page, drop it from the buffer). `seq` alone is the
// whole dedup signal, see `TermEnvelope`'s doc in hive-agent's
// `web_ui/stream.rs`.
import { useEffect, useRef, useState } from 'preact/hooks'; import { useEffect, useRef, useState } from 'preact/hooks';
import { classifyEvent, createClassifyCtx, type ClassifyCtx } from '../lib/classifyEvent.js'; import type { TermEnvelope, TermRow } from '../lib/termMsg.js';
import type { StreamRow } from '../lib/streamRow.js';
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- raw SSE payload, dynamically shaped
type AnyEvent = any;
export interface UseLiveStreamOptions { export interface UseLiveStreamOptions {
historyUrl?: string; historyUrl?: string;
streamUrl?: string; streamUrl?: string;
/** Fired on every LIVE (not history-replay) turn_start/turn_end, so the
* caller can trigger a snappier `/api/state` refresh than the plain
* poll interval mirrors app.js's turn_end refreshState()/
* refreshTodos(). */
onLiveTurnBoundary?: () => void;
} }
export interface UseLiveStreamResult { export interface UseLiveStreamResult {
rows: StreamRow[]; rows: TermRow[];
hasMore: boolean; hasMore: boolean;
loadingMore: boolean; loadingMore: boolean;
loadMore: () => void; loadMore: () => void;
@ -44,12 +37,12 @@ export interface UseLiveStreamResult {
clearLocal: () => void; clearLocal: () => void;
} }
function appendRow(rows: StreamRow[], row: StreamRow): StreamRow[] { function appendRow(rows: TermRow[], row: TermRow): TermRow[] {
const last = rows[rows.length - 1]; const last = rows[rows.length - 1];
// Coalesce in place only while the coalescible row is still the last // Coalesce in place only while the coalescible row is still the last
// one in the list — any other row landing in between starts a fresh // one in the list — any other row landing in between starts a fresh
// one, same rule as terminal.js's makeCoalescer. // one, same rule as terminal.js's makeCoalescer.
if (row.coalesceKey && last && last.coalesceKey === row.coalesceKey) { if (row.coalesce_key && last && last.coalesce_key === row.coalesce_key) {
const next = rows.slice(0, -1); const next = rows.slice(0, -1);
next.push({ ...row, key: last.key }); next.push({ ...row, key: last.key });
return next; return next;
@ -57,59 +50,60 @@ function appendRow(rows: StreamRow[], row: StreamRow): StreamRow[] {
return [...rows, row]; return [...rows, row];
} }
function appendMany(rows: StreamRow[], newRows: StreamRow[]): StreamRow[] {
let next = rows;
for (const r of newRows) next = appendRow(next, r);
return next;
}
export function useLiveStream(opts: UseLiveStreamOptions = {}): UseLiveStreamResult { export function useLiveStream(opts: UseLiveStreamOptions = {}): UseLiveStreamResult {
const historyUrl = opts.historyUrl ?? 'events/history'; const historyUrl = opts.historyUrl ?? 'events/history';
const streamUrl = opts.streamUrl ?? 'events/stream'; const streamUrl = opts.streamUrl ?? 'events/stream';
const [rows, setRows] = useState<StreamRow[]>([]); const [rows, setRows] = useState<TermRow[]>([]);
const [hasMore, setHasMore] = useState(false); const [hasMore, setHasMore] = useState(false);
const [loadingMore, setLoadingMore] = useState(false); const [loadingMore, setLoadingMore] = useState(false);
const ctxRef = useRef<ClassifyCtx | null>(null); const keySeqRef = useRef(0);
if (!ctxRef.current) ctxRef.current = createClassifyCtx(); function nextKey(): string {
keySeqRef.current += 1;
return 'r' + keySeqRef.current;
}
function toRows(env: TermEnvelope, fromHistory: boolean): TermRow[] {
return env.msgs.map((m) => ({ ...m, key: nextKey(), fromHistory }));
}
function appendEnvelope(rows: TermRow[], env: TermEnvelope, fromHistory: boolean): TermRow[] {
let next = rows;
for (const row of toRows(env, fromHistory)) next = appendRow(next, row);
return next;
}
const minIdRef = useRef<number | null>(null); const minIdRef = useRef<number | null>(null);
const liveRef = useRef(false); const liveRef = useRef(false);
const bufferedRef = useRef<AnyEvent[]>([]); const bufferedRef = useRef<TermEnvelope[]>([]);
const onBoundaryRef = useRef(opts.onLiveTurnBoundary);
onBoundaryRef.current = opts.onLiveTurnBoundary;
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
function pushLive(ev: AnyEvent) { function pushLive(env: TermEnvelope) {
const newRows = classifyEvent(ev, false, ctxRef.current!); if (env.msgs.length) setRows((prev) => appendEnvelope(prev, env, false));
if (newRows.length) setRows((prev) => appendMany(prev, newRows));
if (ev.kind === 'turn_start' || ev.kind === 'turn_end') onBoundaryRef.current?.();
} }
const es = new EventSource(streamUrl); const es = new EventSource(streamUrl);
es.onmessage = (e) => { es.onmessage = (e) => {
let ev: AnyEvent; let env: TermEnvelope;
try { try {
ev = JSON.parse(e.data); env = JSON.parse(e.data);
} catch { } catch {
setRows((prev) => appendRow(prev, { setRows((prev) => appendRow(prev, {
key: 'parse-err-' + Date.now(), cssClass: 'note', fromHistory: false, key: 'parse-err-' + Date.now(), level: 'warn', summary: '[parse err] ' + e.data, fromHistory: false,
text: '[parse err] ' + e.data,
})); }));
return; return;
} }
if (!liveRef.current) { if (!liveRef.current) {
bufferedRef.current.push(ev); bufferedRef.current.push(env);
return; return;
} }
pushLive(ev); pushLive(env);
}; };
es.onerror = () => { es.onerror = () => {
setRows((prev) => appendRow(prev, { setRows((prev) => appendRow(prev, {
key: 'conn-note', cssClass: 'note', fromHistory: false, coalesceKey: 'conn-status', key: 'conn-note', level: 'warn', fromHistory: false, coalesce_key: 'conn-status',
text: es.readyState === 0 /* CONNECTING */ ? '[reconnecting…]' : '[disconnected]', summary: es.readyState === 0 /* CONNECTING */ ? '[reconnecting…]' : '[disconnected]',
})); }));
}; };
@ -118,30 +112,29 @@ export function useLiveStream(opts: UseLiveStreamOptions = {}): UseLiveStreamRes
const resp = await fetch(historyUrl); const resp = await fetch(historyUrl);
if (!resp.ok) throw new Error('http ' + resp.status); if (!resp.ok) throw new Error('http ' + resp.status);
const body = await resp.json(); const body = await resp.json();
const events: AnyEvent[] = Array.isArray(body) ? body : body.events || []; const events: TermEnvelope[] = Array.isArray(body) ? body : body.events || [];
const boundarySeq: number | null = Array.isArray(body) ? null : (body.seq ?? null); const boundarySeq: number | null = Array.isArray(body) ? null : (body.seq ?? null);
if (!Array.isArray(body)) { if (!Array.isArray(body)) {
setHasMore(!!body.has_more); setHasMore(!!body.has_more);
if (typeof body.min_id === 'number') minIdRef.current = body.min_id; if (typeof body.min_id === 'number') minIdRef.current = body.min_id;
} }
const historyKinds = new Set(events.map((e) => e.kind));
if (cancelled) return; if (cancelled) return;
let initial: StreamRow[] = []; let initial: TermRow[] = [];
for (const ev of events) initial = appendMany(initial, classifyEvent(ev, true, ctxRef.current!)); for (const env of events) initial = appendEnvelope(initial, env, true);
initial = events.length initial = events.length
? appendRow(initial, { key: 'live-sep', cssClass: 'note', fromHistory: true, text: '─── live (older above) ───' }) ? appendRow(initial, { key: 'live-sep', level: 'debug', fromHistory: true, summary: '─── live (older above) ───' })
: [{ key: 'placeholder', cssClass: 'note', fromHistory: true, text: '(connected — waiting for events)' }]; : [{ key: 'placeholder', level: 'debug', fromHistory: true, summary: '(connected — waiting for events)' }];
setRows(initial); setRows(initial);
const drained = bufferedRef.current; const drained = bufferedRef.current;
bufferedRef.current = []; bufferedRef.current = [];
liveRef.current = true; liveRef.current = true;
for (const ev of drained) { for (const env of drained) {
if (boundarySeq != null && typeof ev.seq === 'number' && ev.seq <= boundarySeq && historyKinds.has(ev.kind)) { // Already covered by the initial history page — drop it from
continue; // the buffer rather than rendering it twice.
} if (boundarySeq != null && typeof env.seq === 'number' && env.seq <= boundarySeq) continue;
pushLive(ev); pushLive(env);
} }
} catch (err) { } catch (err) {
console.warn('history backfill failed', err); console.warn('history backfill failed', err);
@ -149,7 +142,7 @@ export function useLiveStream(opts: UseLiveStreamOptions = {}): UseLiveStreamRes
const drained = bufferedRef.current; const drained = bufferedRef.current;
bufferedRef.current = []; bufferedRef.current = [];
liveRef.current = true; liveRef.current = true;
for (const ev of drained) pushLive(ev); for (const env of drained) pushLive(env);
} }
} }
backfill(); backfill();
@ -168,14 +161,14 @@ export function useLiveStream(opts: UseLiveStreamOptions = {}): UseLiveStreamRes
const resp = await fetch(historyUrl + sep + 'before=' + minIdRef.current); const resp = await fetch(historyUrl + sep + 'before=' + minIdRef.current);
if (!resp.ok) return; if (!resp.ok) return;
const body = await resp.json(); const body = await resp.json();
const events: AnyEvent[] = Array.isArray(body) ? body : body.events || []; const events: TermEnvelope[] = Array.isArray(body) ? body : body.events || [];
setHasMore(!!body.has_more); setHasMore(!!body.has_more);
if (typeof body.min_id === 'number') minIdRef.current = body.min_id; if (typeof body.min_id === 'number') minIdRef.current = body.min_id;
if (events.length) { if (events.length) {
let older: StreamRow[] = []; let older: TermRow[] = [];
for (const ev of events) older = appendMany(older, classifyEvent(ev, true, ctxRef.current!)); for (const env of events) older = appendEnvelope(older, env, true);
older = appendRow(older, { older = appendRow(older, {
key: 'older-sep-' + minIdRef.current, cssClass: 'note', fromHistory: true, text: '─── older above ───', key: 'older-sep-' + minIdRef.current, level: 'debug', fromHistory: true, summary: '─── older above ───',
}); });
setRows((prev) => [...older, ...prev]); setRows((prev) => [...older, ...prev]);
} }
@ -186,10 +179,8 @@ export function useLiveStream(opts: UseLiveStreamOptions = {}): UseLiveStreamRes
} }
} }
const localKeyRef = useRef(0);
function pushLocalNote(text: string) { function pushLocalNote(text: string) {
localKeyRef.current += 1; setRows((prev) => appendRow(prev, { key: 'local-' + nextKey(), level: 'info', fromHistory: false, summary: text }));
setRows((prev) => appendRow(prev, { key: `local-${localKeyRef.current}`, cssClass: 'note', fromHistory: false, text }));
} }
function clearLocal() { function clearLocal() {
setRows([]); setRows([]);

View file

@ -1,268 +0,0 @@
// Turns one raw harness/stream-json event into zero or more `StreamRow`s.
// Preact-data port of app.js's `renderStream` / `renderRichToolUse` /
// `renderToolResult` / `renderTaskEvent` — see docs/terminal-rendering.md
// for the row taxonomy this mirrors. Almost all per-tool classification
// is already done server-side (`hive-agent/src/stream_enrich.rs::enrich`
// stamps `_icon`/`_summary`/`_category`/`_body`/`_body_type`), so this
// mostly just dispatches on those fields rather than re-deriving them.
//
// One deliberate simplification vs. app.js: the client-side "compacting…
// <N>s" live override on `system/status` ticks (computed from the
// harness's local `stateSince`) isn't ported — this just shows the
// backend's `_summary` as-is. `turn_state`/`state_since` (and therefore
// the "compacting" badge itself) still update correctly via
// useAgentState's poll; only that one status row's live elapsed-seconds
// text loses its client-side tick. Revisit if that's missed in practice.
import type { StreamRow, StreamRowMeta } from './streamRow.js';
import { fmtAge, fmtClock } from './format.js';
import { getExpandDetailsPref } from '@hive/shared/prefs.js';
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- stream-json
// content is dynamically-shaped JSON, same as app.js's untyped handling.
type AnyEvent = any;
export interface ClassifyCtx {
toolNameById: Map<string, string>;
pendingTurnStartTs: { current: number | null };
keySeq: { current: number };
}
export function createClassifyCtx(): ClassifyCtx {
return { toolNameById: new Map(), pendingTurnStartTs: { current: null }, keySeq: { current: 0 } };
}
function nextKey(ctx: ClassifyCtx): string {
ctx.keySeq.current += 1;
return 'r' + ctx.keySeq.current;
}
function trim(s: string, n: number): string {
return s.length > n ? s.slice(0, n) + '…' : s;
}
export function classifyEvent(ev: AnyEvent, fromHistory: boolean, ctx: ClassifyCtx): StreamRow[] {
switch (ev.kind) {
case 'turn_start':
return [classifyTurnStart(ev, fromHistory, ctx)];
case 'turn_end':
return [classifyTurnEnd(ev, fromHistory, ctx)];
case 'note':
return [classifyNote(ev, fromHistory, ctx)];
case 'stream': {
const v = { ...ev };
delete v.kind;
return classifyStream(v, fromHistory, ctx);
}
default:
// status_changed / model_changed / effort_changed /
// token_usage_changed / turn_state_changed drive badges elsewhere
// (useAgentState's poll — see Root.tsx) rather than rows here.
return [];
}
}
function classifyTurnStart(ev: AnyEvent, fromHistory: boolean, ctx: ClassifyCtx): StreamRow {
const meta: StreamRowMeta[] = [];
if (typeof ev.ts === 'number') {
ctx.pendingTurnStartTs.current = ev.ts;
meta.push({ cls: 'turn-time', text: '· ' + fmtClock(ev.ts) });
}
if (ev.unread > 0) {
meta.push({ cls: 'unread-badge', text: '· ' + ev.unread + ' unread' });
}
return {
key: nextKey(ctx),
cssClass: 'turn-start',
fromHistory,
text: '◆ TURN ← ' + ev.from,
meta,
childText: { cls: 'turn-body', text: String(ev.body ?? '') },
};
}
function classifyTurnEnd(ev: AnyEvent, fromHistory: boolean, ctx: ClassifyCtx): StreamRow {
const meta: StreamRowMeta[] = [];
if (typeof ev.ts === 'number') {
let label = '· ' + fmtClock(ev.ts);
if (ctx.pendingTurnStartTs.current != null && ev.ts >= ctx.pendingTurnStartTs.current) {
label += ' · ' + fmtAge((ev.ts - ctx.pendingTurnStartTs.current) * 1000);
}
meta.push({ cls: 'turn-time', text: label });
}
ctx.pendingTurnStartTs.current = null;
return {
key: nextKey(ctx),
cssClass: ev.ok ? 'turn-end-ok' : 'turn-end-fail',
fromHistory,
text: (ev.ok ? '✅' : '❌') + ' turn ' + (ev.ok ? 'ok' : 'fail') + (ev.note ? ' — ' + ev.note : ''),
meta,
};
}
function classifyNote(ev: AnyEvent, fromHistory: boolean, ctx: ClassifyCtx): StreamRow {
const t = String(ev.text ?? '');
if (t.startsWith('stderr:')) return { key: nextKey(ctx), cssClass: 'note stderr', fromHistory, text: '! ' + t };
if (t.startsWith('operator:')) return { key: nextKey(ctx), cssClass: 'note op', fromHistory, text: '· ' + t };
return { key: nextKey(ctx), cssClass: 'note', fromHistory, text: '· ' + t };
}
function classifyStream(v: AnyEvent, fromHistory: boolean, ctx: ClassifyCtx): StreamRow[] {
if (v._category === 'drop') return [];
if (v.type === 'system') {
const cat = v._category;
const summary = v._summary;
if (cat === 'thinking_tok') {
return [{
key: nextKey(ctx), cssClass: 'note', icon: '🧠', fromHistory,
text: summary || 'thinking…', coalesceKey: 'thinking-tok',
}];
}
if (v.subtype === 'plugin_install') {
return [{
key: nextKey(ctx), cssClass: 'note', fromHistory,
text: summary || '⚙ plugin install', coalesceKey: 'plugin-install',
}];
}
if (v.subtype === 'status') {
return [{
key: nextKey(ctx), cssClass: 'note', fromHistory,
text: summary || '⚙ status', coalesceKey: 'status-tick',
}];
}
if (cat === 'details') {
return [{
key: nextKey(ctx), cssClass: 'note', fromHistory, details: true,
text: summary || '⚙ ' + (v.subtype || ''), plainBody: v._body || '',
}];
}
return [{ key: nextKey(ctx), cssClass: 'note', fromHistory, text: summary || '⚙ ' + (v.subtype || 'system') }];
}
if (v.subtype === 'task_started' || v.subtype === 'task_notification') {
const row = classifyTaskEvent(v, fromHistory, ctx);
if (row) return [row];
}
if (v.type === 'assistant' && v.message && v.message.content) {
const rows: StreamRow[] = [];
for (const c of v.message.content) {
if (c.type === 'text' && c.text && String(c.text).trim()) {
rows.push({ key: nextKey(ctx), cssClass: 'text', fromHistory, markdownBody: c.text });
} else if (c.type === 'thinking') {
const txt = String(c.thinking || c.text || '').trim();
rows.push({ key: nextKey(ctx), cssClass: 'thinking', icon: '💭', fromHistory, text: txt || 'thinking …' });
} else if (c.type === 'tool_use') {
if (c.id && c.name) ctx.toolNameById.set(c.id, c.name);
rows.push(classifyToolUse(c, fromHistory, ctx));
}
}
return rows;
}
if (v.type === 'user' && v.message && v.message.content) {
const rows: StreamRow[] = [];
for (const c of v.message.content) {
if (c.type === 'tool_result') rows.push(classifyToolResult(c, fromHistory, ctx));
}
return rows;
}
return [{ key: nextKey(ctx), cssClass: 'sys', fromHistory, text: '! ' + trim(JSON.stringify(v), 200) }];
}
// `_category === 'rich'` tools get an expandable row: diff body (Edit),
// default-open markdown body (send/recv-shaped, always open regardless
// of the preference below — matches app.js), or a plain
// body (collapsed unless the operator's "expand tool output" preference
// says otherwise — @hive/shared/prefs.js's getExpandDetailsPref(), read
// fresh per row so a mid-session preference change applies going
// forward without a reload) — all pre-computed server-side, no per-tool
// JS needed.
function classifyToolUse(c: AnyEvent, fromHistory: boolean, ctx: ClassifyCtx): StreamRow {
const icon = c._icon || '🔧';
const name = c.name || '';
if (c._category === 'rich' && c._body != null) {
const summary = c._summary || name || '?';
if (c._body_type === 'diff') {
return {
key: nextKey(ctx), cssClass: 'tool-use', fromHistory, details: true, defaultOpen: getExpandDetailsPref(),
icon, text: summary, diffBody: c._body,
};
}
if (c._body_type === 'markdown') {
return {
key: nextKey(ctx), cssClass: 'tool-use', fromHistory, details: true, defaultOpen: true,
icon, text: summary, markdownBody: c._body,
};
}
return {
key: nextKey(ctx), cssClass: 'tool-use', fromHistory, details: true, defaultOpen: getExpandDetailsPref(),
icon, text: summary, plainBody: c._body,
};
}
return { key: nextKey(ctx), cssClass: 'tool-use', fromHistory, icon, text: c._summary || name || '?' };
}
function classifyToolResult(c: AnyEvent, fromHistory: boolean, ctx: ClassifyCtx): StreamRow {
const rawTxt = Array.isArray(c.content) ? c.content.map((p: AnyEvent) => p.text || '').join('') : c.content || '';
const isError = !!c.is_error;
// Strip the <tool_use_error>…</tool_use_error> wrapper — implementation
// detail claude emits on failed tool calls, adds nothing for the operator.
const txt = isError
? String(rawTxt).replace(/^<tool_use_error>([\s\S]*)<\/tool_use_error>$/, '$1').trim()
: String(rawTxt);
const sourceName = c.tool_use_id ? ctx.toolNameById.get(c.tool_use_id) : null;
const isMessageBearing = sourceName === 'mcp__hyperhive__recv';
const trimmed = txt.replace(/\s+/g, ' ').trim();
const summaryBody = (() => {
if (!trimmed) return '(empty)';
if (trimmed.length <= 120) return trimmed;
const lines = txt.split('\n').filter((l: string) => l.length).length;
const headline = trimmed.slice(0, 90) + '…';
return `${lines}L · ${headline}`;
})();
if (isError) {
if (!txt.trim() || txt.length <= 120) {
return { key: nextKey(ctx), cssClass: 'tool-result error', fromHistory, text: '✗ ' + summaryBody };
}
return {
key: nextKey(ctx), cssClass: 'tool-result-block error', fromHistory, details: true,
defaultOpen: getExpandDetailsPref(), text: summaryBody, plainBody: txt,
};
}
if (isMessageBearing && txt.trim()) {
return {
key: nextKey(ctx), cssClass: 'tool-result-block', fromHistory, details: true, defaultOpen: true,
text: 'recv ← ' + summaryBody, markdownBody: txt,
};
}
if (!txt.trim() || txt.length <= 120) {
return { key: nextKey(ctx), cssClass: 'tool-result', fromHistory, text: '← ' + summaryBody };
}
return {
key: nextKey(ctx), cssClass: 'tool-result-block', fromHistory, details: true,
defaultOpen: getExpandDetailsPref(), text: summaryBody, plainBody: txt,
};
}
// Subagent (claude `Task`-tool) activity — dead path for agents today
// (`Task` is omitted from the allow-list) but kept for parity, same as
// app.js's `renderTaskEvent`. Glyph stays embedded in the row text
// (not routed through the icon column) — matches the original exactly.
function classifyTaskEvent(v: AnyEvent, fromHistory: boolean, ctx: ClassifyCtx): StreamRow | null {
const id = String(v.task_id || '').slice(0, 8);
const kind = v.task_type ? ` [${v.task_type}]` : '';
const desc = v.description || v.summary || '(no description)';
if (v.subtype === 'task_started') {
return { key: nextKey(ctx), cssClass: 'tool-use', fromHistory, text: `⌁ task ${id} started · ${desc}${kind}` };
}
if (v.subtype === 'task_notification') {
const status = v.status || 'unknown';
const glyph = status === 'completed' ? '✓' : status === 'failed' ? '✗' : '◌';
const cssClass = status === 'completed' ? 'turn-end-ok' : status === 'failed' ? 'turn-end-fail' : 'tool-result';
const out = v.output_file ? ` · → ${v.output_file}` : '';
return { key: nextKey(ctx), cssClass, fromHistory, text: `⌁ task ${id} ${glyph} ${status} · ${desc}${out}` };
}
return null;
}

View file

@ -15,10 +15,3 @@ export function fmtAge(ms: number): string {
const h = Math.floor(m / 60); const h = Math.floor(m / 60);
return h + 'h ' + (m % 60) + 'm'; return h + 'h ' + (m % 60) + 'm';
} }
/** Wall-clock HH:MM:SS (UTC) from a unix-seconds value labels
* turn-start / turn-end rows in the live stream (ported from app.js's
* `fmtClock`). */
export function fmtClock(sec: number): string {
return new Date(sec * 1000).toISOString().slice(11, 19);
}

View file

@ -1,49 +0,0 @@
// Row model for the live event stream. One `StreamRow` = one rendered
// line/panel in the terminal pane; `classifyEvent` (classifyEvent.ts)
// turns a raw harness/stream-json event into zero or more of these, and
// `<Row>` (components/Row.tsx) renders one. Kept as plain data (not JSX)
// so the append/coalesce bookkeeping in useLiveStream.ts stays pure —
// see docs/terminal-rendering.md for the exact taxonomy this mirrors.
export interface StreamRowMeta {
cls: string;
text: string;
}
export interface StreamRow {
/** Stable across re-renders; reused in place when a row is coalesced
* (e.g. the thinking-token counter) so Preact updates rather than
* remounts it. */
key: string;
/** Space-joined class names appended after "row" / "row details", e.g.
* "turn-start", "tool-result error" taken verbatim from
* @hive/shared/terminal.css's row-kind taxonomy. */
cssClass: string;
icon?: string;
fromHistory: boolean;
/** When set, an event that maps to the same coalesceKey and lands
* while this row is still the last one in the list replaces it in
* place instead of appending a new row (thinking-token ticks, status
* ticks, plugin_install startedcompleted). */
coalesceKey?: string;
/** `false`/absent flat `<div class="row">`; `true` `<details
* class="row">` with `.summary-text` + optional body. */
details?: boolean;
defaultOpen?: boolean;
/** Flat-row prefix text, or the details `<summary>` text. Linkified,
* never markdown. */
text?: string;
/** Small trailing spans after the prefix text (turn-time, unread
* count) flat rows only. */
meta?: StreamRowMeta[];
/** Plain-text child block under a flat row (just the turn-start wake
* body today) its own class, no markdown parsing. */
childText?: StreamRowMeta;
/** Sanitized-markdown body: appended under a flat row (assistant
* text) or inside an open details row (send/recv bodies). */
markdownBody?: string;
/** Plain `<pre>` body inside a details row (generic long tool output). */
plainBody?: string;
/** `+`/`-`/context diff body inside a details row (Edit tool). */
diffBody?: string;
}

View file

@ -0,0 +1,33 @@
// Wire types for the agent's terminal stream — mirrors hive-agent's
// `term_msg.rs`/`web_ui/stream.rs` field-for-field. The frontend renders
// a `TermMsg` close to as-is (see components/Row.tsx); there's no
// separate client-side row model or classification step any more —
// mara: "StreamRow should now match what the server sends in TermMsg."
export type Level = 'debug' | 'info' | 'warn' | 'error';
export interface TermMsg {
icon?: string;
level: Level;
summary: string;
body?: string;
body_format?: 'markdown' | 'diff';
coalesce_key?: string;
}
/** One SSE frame / history array entry `hive-agent`'s `TermEnvelope`.
* `seq` is the live per-event dedup counter (`BusEvent::seq`); absent on
* history-replayed envelopes, see useLiveStream.ts's backfill dance. */
export interface TermEnvelope {
ts: number;
seq?: number;
msgs: TermMsg[];
}
/** A `TermMsg` plus the bookkeeping Preact needs to render a list
* stable identity for coalescing/keys, and whether it came from history
* replay vs. the live tail. Not a separate model: everything content-wise
* is still exactly the wire shape. */
export interface TermRow extends TermMsg {
key: string;
fromHistory: boolean;
}

View file

@ -97,55 +97,34 @@
display: inline-block; display: inline-block;
width: 1.4em; width: 1.4em;
} }
/* Row-kind colours. Pages register renderers that emit these classes; /* Row colours, keyed by severity `level` (hive-agent's `term_msg.rs`),
any class no page emits is just dead CSS, which is fine. Turn-framing not by row kind any more mara's terminal-message redesign dropped
classes carry their signal entirely on the coloured border-left rule the server-side `kind` tag (turn-start/tool-use/tool-result/etc) in
no bold, no top/bottom margins, no background tint. The chrome was favour of one uniform shape, `{icon, level, summary, body,
overweight for what's just a "this is a boundary" marker. */ body_format, coalesce_key}`. What used to be a dozen-odd per-kind
.live .turn-start { color: var(--amber); border-left-color: var(--amber); } classes (`.turn-start`, `.tool-use`, `.tool-result.error`, `.sys`, )
/* turn-body is a child block under turn-start carrying the wake-prompt is now four: the four severities every row already carries. Structural
body; reset text-indent so wrapped content stays under its own column identity (this is a turn boundary, this is a tool call) is carried by
instead of pulling back into the parent's prefix. */ the row's icon (``, `🔧`, ``, ) and summary text instead of colour
.live .turn-body { color: var(--fg); text-indent: 0; margin-top: 0.15em; } see docs/terminal-rendering.md. */
/* Any child block (markdown body, nested details) resets the parent .live .level-debug { color: var(--muted); }
row's hanging indent so the content lays out from column 0 of the .live .level-info { color: var(--fg); }
body area. */ .live .level-warn { color: var(--amber); border-left-color: var(--amber); }
.live .row .md, .live .row > details { text-indent: 0; } .live .level-error { color: var(--red); border-left-color: var(--red); }
.live .turn-end-ok { color: var(--green); border-left-color: var(--green); } /* `badge-pulse` itself is no longer used by any terminal row (the
.live .turn-end-fail { color: var(--red); border-left-color: var(--red); } turn-start `unread` count it animated is gone see term_msg.rs's
/* Wall-clock time (+ duration on turn-end) appended to the turn-start / module doc), but agent.css's `.state-badge.state-thinking`/
turn-end rows. Dim + smaller so the boundary glyph stays the focus and `.state-compacting` badges still reuse this keyframe via
the timestamp reads as metadata. */ `@import "@hive/shared/terminal.css"` keep the definition here, drop
.live .turn-time { color: var(--muted); font-size: 0.85em; margin-left: 0.5em; } only the terminal-specific `.unread-badge` selector that used it. */
.live .text { color: var(--fg); }
.live .thinking { color: var(--muted); font-style: italic; }
.live .tool-use { color: var(--cyan); }
.live .tool-result { color: var(--muted); }
.live .tool-result.error { color: var(--red); }
.live .tool-result-block.error { color: var(--red); }
.live .result { color: var(--green); }
.live .note { color: var(--muted); }
/* Distinguish stderr lines (orange) and operator-initiated notes
(mauve, lightly emphasised) from ambient harness chatter so the
eye picks out anomalies + operator actions in the scrollback. */
.live .note.stderr { color: var(--amber); }
.live .note.op { color: var(--purple); font-style: italic; }
/* The .sys catch-all fires when renderStream landed an event shape it
couldn't classify. Make it visually loud so silently-dropped event
types surface for follow-up. */
.live .sys { color: var(--amber); }
.live .unread-badge {
color: var(--amber);
font-weight: normal;
margin-left: 0.6em;
font-size: 0.85em;
text-shadow: 0 0 6px color-mix(in srgb, var(--amber) 55%, transparent);
animation: badge-pulse 1.4s ease-in-out infinite;
}
@keyframes badge-pulse { @keyframes badge-pulse {
0%, 100% { opacity: 1; text-shadow: 0 0 6px color-mix(in srgb, var(--amber) 55%, transparent); } 0%, 100% { opacity: 1; text-shadow: 0 0 6px color-mix(in srgb, var(--amber) 55%, transparent); }
50% { opacity: 0.7; text-shadow: 0 0 14px color-mix(in srgb, var(--amber) 95%, transparent); } 50% { opacity: 0.7; text-shadow: 0 0 14px color-mix(in srgb, var(--amber) 95%, transparent); }
} }
/* Any child block (markdown body, nested details) resets the parent
row's hanging indent so the content lays out from column 0 of the
body area. */
.live .row .md, .live .row > details { text-indent: 0; }
/* "↓ N new" pill: shown when new rows arrive while the operator is /* "↓ N new" pill: shown when new rows arrive while the operator is
scrolled up; click to jump to bottom. Positioned by the wrapper's scrolled up; click to jump to bottom. Positioned by the wrapper's
`position: relative` (terminal-wrap supplies it; pages that skip the `position: relative` (terminal-wrap supplies it; pages that skip the

View file

@ -28,6 +28,7 @@ mod serve_common;
mod state_entry_watch; mod state_entry_watch;
mod stats; mod stats;
mod stream_enrich; mod stream_enrich;
mod term_msg;
mod todo_server; mod todo_server;
mod todos; mod todos;
mod turn; mod turn;

View file

@ -1,75 +1,97 @@
//! Enrich raw claude stream-json values before SSE delivery. //! Classify raw claude stream-json values into [`crate::term_msg::TermMsg`]
//! rows before SSE delivery.
//! //!
//! A single [`enrich`] function stamps `_icon`, `_summary`, `_category`, //! [`classify_stream_value`] is the entry point — it walks one raw claude
//! and optionally `_body` onto [`crate::events::LiveEvent::Stream`] payloads //! `stream-json` line (the payload of a [`crate::events::LiveEvent::Stream`])
//! so the frontend can read pre-computed fields instead of duplicating the //! and returns zero or more terminal rows. Applied at SSE-emit time in
//! dispatch logic in JavaScript. //! `crate::web_ui::stream` so both the live tail (`events/stream`) and the
//! history replay (`events/history`) endpoints deliver the same classified
//! shape — the sqlite event log stores the raw, unclassified event, so the
//! DB never needs migration when classification logic changes.
//! //!
//! The sqlite event log stores raw (un-enriched) events — the DB never needs //! The per-tool icon/summary formatting below (`tool_icon`, `fmt_tool_use`
//! migration when the enrichment logic changes. Enrichment is applied at //! and its per-family helpers, `rich_tool_body`) is reused as-is from
//! SSE-emit time in `crate::web_ui::stream` so both the live tail //! before mara's terminal-message redesign — that logic (what does
//! (`events/stream`) and the history replay (`events/history`) endpoints //! `mcp__hyperhive__send`'s row say, which tools get an expandable body)
//! deliver the same enriched shape. //! didn't change; only the shape it gets packed into did.
//!
//! # Migration (two-phase)
//!
//! **Phase 1** (this change): backend stamps `_icon`/`_summary`/`_category`
//! fields; the client reads them when present and falls back to its own JS
//! tables when absent. Zero user-visible change — a no-op for clients that
//! haven't yet been updated.
//!
//! **Phase 2** (follow-up): the client drops the JS tables once phase 1 is
//! deployed everywhere.
use crate::term_msg::{BodyFormat, ClassifyCtx, Level, TermMsg};
use serde_json::{Value, json}; use serde_json::{Value, json};
/// Stamp enrichment fields onto a raw claude stream-json [`Value`]. /// Classify one raw claude stream-json line into zero or more terminal rows.
/// ///
/// - `type="system"` events get `_category` + `_summary` (and `_body` for /// Dispatch order mirrors the old client-side `classifyEvent.ts` (before
/// expandable detail, e.g. `commands_changed`). /// this classification moved server-side):
/// - `type="assistant"` events get `_icon`, `_summary`, and optionally /// top-level drop-noise types first, then `type="system"`, then task events
/// `_category: "rich"` stamped onto each `message.content[]` entry that /// (matched on `subtype` regardless of `type`), then `assistant`/`user`
/// has `type="tool_use"`. /// content, with an unrecognised shape falling through to a loud
/// /// warn-level catch-all so a silently-dropped event type stays visible.
/// No-ops for unknown/unhandled top-level types. Existing `_`-prefixed fields pub fn classify_stream_value(v: &Value, ctx: &mut ClassifyCtx) -> Vec<TermMsg> {
/// are left unchanged so the call is idempotent (history replay may hit let vtype = v.get("type").and_then(Value::as_str).unwrap_or("");
/// already-enriched values if the DB is ever pre-populated by a future phase). if matches!(vtype, "result" | "rate_limit_event") {
pub fn enrich(v: &mut Value) { return vec![];
match v.get("type").and_then(Value::as_str).unwrap_or("") {
// Top-level result/rate_limit_event are drop-category noise — stamp
// the same category the frontend uses to silently discard them.
"result" | "rate_limit_event" => {
if let Some(obj) = v.as_object_mut() {
obj.entry("_category").or_insert_with(|| json!("drop"));
} }
if vtype == "system" {
return classify_system(v);
} }
"system" => enrich_system(v), let subtype = v.get("subtype").and_then(Value::as_str);
"assistant" => enrich_assistant(v), if matches!(subtype, Some("task_started" | "task_notification")) {
_ => {} return classify_task_event(v).into_iter().collect();
} }
if vtype == "assistant" {
return v
.get("message")
.and_then(|m| m.get("content"))
.and_then(Value::as_array)
.map_or_else(Vec::new, |content| classify_assistant_content(content, ctx));
}
if vtype == "user" {
return v
.get("message")
.and_then(|m| m.get("content"))
.and_then(Value::as_array)
.map_or_else(Vec::new, |content| classify_user_content(content, ctx));
}
vec![TermMsg::new(Level::Warn, trim_str(&v.to_string(), 200)).icon("!")]
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// system events // system events
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
fn enrich_system(v: &mut Value) { fn classify_system(v: &Value) -> Vec<TermMsg> {
if v.get("_category").is_some() { let subtype = v.get("subtype").and_then(Value::as_str).unwrap_or("");
return; // idempotent let (category, summary, body) = system_fields(v, subtype);
} match category {
let subtype = v "drop" => vec![],
.get("subtype") "thinking_tok" => vec![
.and_then(Value::as_str) TermMsg::new(
.unwrap_or("") Level::Debug,
.to_owned(); summary.unwrap_or_else(|| "thinking…".to_owned()),
let (category, summary, body) = system_fields(v, &subtype); )
let Some(obj) = v.as_object_mut() else { return }; .icon("🧠")
obj.insert("_category".to_owned(), json!(category)); .coalesce("thinking-tok"),
if let Some(s) = summary { ],
obj.insert("_summary".to_owned(), json!(s)); "details" => {
} let mut m = TermMsg::new(Level::Debug, summary.unwrap_or_default());
if let Some(b) = body { if let Some(b) = body {
obj.insert("_body".to_owned(), json!(b)); m = m.body(b, None);
}
vec![m]
}
// "note" category — ambient harness/system chatter. Level + coalesce
// key vary by subtype; everything else defaults to a plain debug note.
_ => {
let s = summary.unwrap_or_default();
let m = match subtype {
"api_error" => TermMsg::new(Level::Error, s),
"api_retry" => TermMsg::new(Level::Warn, s),
"plugin_install" => TermMsg::new(Level::Debug, s).coalesce("plugin-install"),
"status" => TermMsg::new(Level::Debug, s).coalesce("status-tick"),
_ => TermMsg::new(Level::Debug, s),
};
vec![m]
}
} }
} }
@ -197,51 +219,197 @@ fn system_fields(v: &Value, subtype: &str) -> (&'static str, Option<String>, Opt
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// assistant events // assistant events (claude's own output: text / thinking / tool calls)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
fn enrich_assistant(v: &mut Value) { fn classify_assistant_content(content: &[Value], ctx: &mut ClassifyCtx) -> Vec<TermMsg> {
// Navigate message.content[] — absent on text-only turns. let mut rows = Vec::new();
let Some(content) = v for c in content {
.get_mut("message") match c.get("type").and_then(Value::as_str) {
.and_then(|m| m.get_mut("content")) Some("text") => {
.and_then(Value::as_array_mut) let text = c.get("text").and_then(Value::as_str).unwrap_or("");
else { if !text.trim().is_empty() {
return; // No separate summary line — the markdown body is the
// whole row, matching the old `.text` row shape.
rows.push(
TermMsg::new(Level::Info, String::new())
.body(text.to_owned(), Some(BodyFormat::Markdown)),
);
}
}
Some("thinking") => {
let txt = c
.get("thinking")
.or_else(|| c.get("text"))
.and_then(Value::as_str)
.unwrap_or("")
.trim()
.to_owned();
let summary = if txt.is_empty() {
"thinking …".to_owned()
} else {
txt
}; };
for entry in content.iter_mut() { rows.push(TermMsg::new(Level::Debug, summary).icon("💭"));
enrich_tool_use_entry(entry); }
Some("tool_use") => {
if let (Some(id), Some(name)) = (
c.get("id").and_then(Value::as_str),
c.get("name").and_then(Value::as_str),
) {
ctx.record_tool_use(id, name);
}
rows.push(classify_tool_use(c));
}
_ => {}
}
}
rows
}
/// `_category === 'rich'` tools used to get an expandable row (diff body
/// for Edit, markdown body for send, plain for everything else with a
/// body). That's now just "does this row have a body" — `body.is_some()`
/// on the returned [`TermMsg`] *is* the expandable signal, no separate
/// flag.
fn classify_tool_use(c: &Value) -> TermMsg {
let name = c.get("name").and_then(Value::as_str).unwrap_or("");
let input = c.get("input").cloned().unwrap_or_else(|| json!({}));
let mut m = TermMsg::new(Level::Info, fmt_tool_use(name, &input)).icon(tool_icon(name));
if let Some((body, body_type)) = rich_tool_body(name, &input) {
let format = match body_type {
"diff" => Some(BodyFormat::Diff),
"markdown" => Some(BodyFormat::Markdown),
_ => None, // "plain"
};
m = m.body(body, format);
}
m
}
// ---------------------------------------------------------------------------
// user events (tool_result — claude's own tool calls answered)
// ---------------------------------------------------------------------------
fn classify_user_content(content: &[Value], ctx: &ClassifyCtx) -> Vec<TermMsg> {
content
.iter()
.filter(|c| c.get("type").and_then(Value::as_str) == Some("tool_result"))
.map(|c| classify_tool_result(c, ctx))
.collect()
}
/// `<tool_use_error>…</tool_use_error>` is claude's own wrapper on failed
/// tool calls — implementation detail, adds nothing for the operator.
fn strip_tool_use_error_wrapper(s: &str) -> String {
let trimmed = s.trim();
trimmed
.strip_prefix("<tool_use_error>")
.and_then(|rest| rest.strip_suffix("</tool_use_error>"))
.map_or_else(|| trimmed.to_owned(), |inner| inner.trim().to_owned())
}
fn classify_tool_result(c: &Value, ctx: &ClassifyCtx) -> TermMsg {
let raw_txt = match c.get("content") {
Some(Value::Array(parts)) => parts
.iter()
.filter_map(|p| p.get("text").and_then(Value::as_str))
.collect::<String>(),
Some(Value::String(s)) => s.clone(),
_ => String::new(),
};
let is_error = c.get("is_error").and_then(Value::as_bool).unwrap_or(false);
let txt = if is_error {
strip_tool_use_error_wrapper(&raw_txt)
} else {
raw_txt
};
let tool_use_id = c.get("tool_use_id").and_then(Value::as_str);
let source_name = tool_use_id.and_then(|id| ctx.tool_name(id));
let is_message_bearing = source_name == Some("mcp__hyperhive__recv");
let trimmed: String = txt.split_whitespace().collect::<Vec<_>>().join(" ");
let summary = summarize_tool_result(&txt, &trimmed);
let short = txt.trim().is_empty() || txt.chars().count() <= 120;
if is_error {
let m = TermMsg::new(Level::Error, summary);
return if short {
m.icon("")
} else {
m.body(txt, None)
};
}
if is_message_bearing && !txt.trim().is_empty() {
return TermMsg::new(Level::Info, format!("recv ← {summary}"))
.body(txt, Some(BodyFormat::Markdown));
}
let m = TermMsg::new(Level::Info, summary);
if short {
m.icon("")
} else {
m.body(txt, None)
} }
} }
fn enrich_tool_use_entry(entry: &mut Value) { /// `(empty)` / a short trimmed line / `"NL · headline…"` for a long one —
if entry.get("type").and_then(Value::as_str) != Some("tool_use") { /// matches the old client-side `summaryBody` computation exactly.
return; fn summarize_tool_result(txt: &str, trimmed: &str) -> String {
if trimmed.is_empty() {
return "(empty)".to_owned();
} }
if entry.get("_icon").is_some() { if trimmed.chars().count() <= 120 {
return; // idempotent return trimmed.to_owned();
} }
let name = entry let lines = txt.lines().filter(|l| !l.is_empty()).count();
.get("name") let headline: String = trimmed.chars().take(90).collect();
format!("{lines}L · {headline}")
}
// ---------------------------------------------------------------------------
// subagent (claude Task-tool) activity — dead path for agents today (`Task`
// is omitted from the allow-list) but kept for parity with the old
// client-side `classifyTaskEvent`.
// ---------------------------------------------------------------------------
fn classify_task_event(v: &Value) -> Option<TermMsg> {
let id: String = v
.get("task_id")
.and_then(Value::as_str) .and_then(Value::as_str)
.unwrap_or("") .unwrap_or("")
.to_owned(); .chars()
let input = entry.get("input").cloned().unwrap_or_else(|| json!({})); .take(8)
let icon = tool_icon(&name); .collect();
let summary = fmt_tool_use(&name, &input); let kind = v
let rich = is_rich_tool(&name); .get("task_type")
let body = rich_tool_body(&name, &input); .and_then(Value::as_str)
let Some(obj) = entry.as_object_mut() else { .map(|t| format!(" [{t}]"))
return; .unwrap_or_default();
}; let desc = v
obj.insert("_icon".to_owned(), json!(icon)); .get("description")
obj.insert("_summary".to_owned(), json!(summary)); .or_else(|| v.get("summary"))
if rich { .and_then(Value::as_str)
obj.insert("_category".to_owned(), json!("rich")); .unwrap_or("(no description)");
match v.get("subtype").and_then(Value::as_str) {
Some("task_started") => {
Some(TermMsg::new(Level::Info, format!("task {id} started · {desc}{kind}")).icon(""))
} }
if let Some((b, bt)) = body { Some("task_notification") => {
obj.insert("_body".to_owned(), json!(b)); let status = v.get("status").and_then(Value::as_str).unwrap_or("unknown");
obj.insert("_body_type".to_owned(), json!(bt)); let (glyph, level) = match status {
"completed" => ("", Level::Info),
"failed" => ("", Level::Error),
_ => ("", Level::Info),
};
let out = v
.get("output_file")
.and_then(Value::as_str)
.map(|f| format!(" · → {f}"))
.unwrap_or_default();
Some(TermMsg::new(level, format!("task {id} {glyph} {status} · {desc}{out}")).icon(""))
}
_ => None,
} }
} }
@ -249,22 +417,14 @@ fn enrich_tool_use_entry(entry: &mut Value) {
// tool helpers // tool helpers
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/// Whether this tool is rendered by the frontend's *rich* renderer (diff
/// view, body expansion) rather than the flat `_summary` row. Flagged with
/// `_category: "rich"` so the client can distinguish without re-implementing
/// the tool name list.
fn is_rich_tool(name: &str) -> bool {
matches!(name, "Edit" | "mcp__bash__run" | "mcp__hyperhive__send")
}
/// Pre-compute the expandable body for rich tool entries. /// Pre-compute the expandable body for rich tool entries.
/// ///
/// Returns `Some((body, body_type))` where `body_type` tells the frontend /// Returns `Some((body, body_type))` where `body_type` becomes the
/// which renderer to use: /// `TermMsg`'s `body_format` and tells the frontend which renderer to use:
/// - `"diff"` → `api.detailsDiff` (colour-coded `+`/`-` lines) /// - `"diff"` → colour-coded `+`/`-` lines
/// - `"plain"` → `api.details` (plain `<pre>` block) /// - `"plain"` (`None` on the wire) → a plain `<pre>` block
/// - `"markdown"` → `api.detailsOpenMd` (markdown rendered via marked + `DOMPurify`, /// - `"markdown"` → rendered via `marked` + `DOMPurify`; used for
/// default-open; used for message-bearing tools: `send`) /// message-bearing tools: `send`
/// ///
/// Returns `None` for tools that have no body at all. /// Returns `None` for tools that have no body at all.
/// ///

283
hive-agent/src/term_msg.rs Normal file
View file

@ -0,0 +1,283 @@
//! Terminal-message wire shape: what the per-agent web UI's live/history
//! endpoints actually serve for the "terminal" event stream, as opposed to
//! agent-state changes (`StatusChanged`/`ModelChanged`/`EffortChanged`/
//! `TokenUsageChanged`/`TurnStateChanged`), which have never rendered as
//! terminal rows (the header/badges poll `/api/state`, not this stream) and
//! produce zero [`TermMsg`]s here.
//!
//! One [`crate::events::LiveEvent`] maps to zero or more `TermMsg`s — most
//! map to exactly one, but `LiveEvent::Stream` (one raw claude
//! `stream-json` line) can expand to several: an `assistant` message with
//! both a text block and a `tool_use` block produces two rows.
//!
//! Design history: mara's terminal-message redesign (six rounds of
//! negotiation on the forge) collapsed what was an 11-field frontend-side
//! row shape (`StreamRow`, `frontend/packages/agent/src/lib/streamRow.ts`)
//! plus raw claude-JSON passthrough into this 6-field shape, with
//! classification moved server-side so the client-side `classifyEvent.ts` —
//! a large per-tool dispatch table — mostly goes away. `kind`, `unread`,
//! `from`, and `expanded_default` were all considered and dropped along the
//! way; `level` replaces free-text CSS-class styling.
use serde::Serialize;
use std::collections::HashMap;
use crate::events::LiveEvent;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum Level {
/// Low-signal / ambient chatter — thinking, progress ticks, harness
/// housekeeping. The client's default rendering can dim/de-emphasize
/// these without hiding them outright.
Debug,
/// Routine substantive content — turn boundaries, assistant text, tool
/// calls/results, message bodies.
Info,
/// Heads-up, not necessarily broken — stderr lines, an unclassified
/// event shape landing (the old `.sys` catch-all), API retries.
Warn,
/// Something actually failed — a turn ending non-ok, a tool result with
/// `is_error: true`.
Error,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum BodyFormat {
Markdown,
Diff,
}
/// One terminal row. `body_format: None` with `body: Some(_)` means plain
/// text (the common case — no explicit tag on the wire for it, same logic
/// as `body` itself being absent meaning "nothing to expand").
#[derive(Debug, Clone, Serialize)]
pub struct TermMsg {
#[serde(skip_serializing_if = "Option::is_none")]
pub icon: Option<String>,
pub level: Level,
pub summary: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub body: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub body_format: Option<BodyFormat>,
#[serde(skip_serializing_if = "Option::is_none")]
pub coalesce_key: Option<String>,
}
impl TermMsg {
pub fn new(level: Level, summary: impl Into<String>) -> Self {
Self {
icon: None,
level,
summary: summary.into(),
body: None,
body_format: None,
coalesce_key: None,
}
}
#[must_use]
pub fn icon(mut self, icon: impl Into<String>) -> Self {
self.icon = Some(icon.into());
self
}
#[must_use]
pub fn body(mut self, body: impl Into<String>, format: Option<BodyFormat>) -> Self {
self.body = Some(body.into());
self.body_format = format;
self
}
#[must_use]
pub fn coalesce(mut self, key: impl Into<String>) -> Self {
self.coalesce_key = Some(key.into());
self
}
}
/// Per-connection/per-request classification state. A live SSE stream keeps
/// one of these alive for the connection's lifetime — `tool_use` id → name
/// correlation, so a `tool_result` can tell it's answering a `recv` call and
/// render its body as markdown instead of plain text. The history endpoint
/// uses a fresh one per page: correlation only works within the page
/// actually returned, not across the live/history boundary. Accepted
/// degradation — the only user-visible effect is a `recv` result whose
/// `tool_use` fell on the other side of a page/reconnect boundary rendering
/// its body as plain text instead of markdown.
#[derive(Default)]
pub struct ClassifyCtx {
tool_name_by_id: HashMap<String, String>,
}
impl ClassifyCtx {
pub fn record_tool_use(&mut self, id: &str, name: &str) {
self.tool_name_by_id.insert(id.to_owned(), name.to_owned());
}
#[must_use]
pub fn tool_name(&self, id: &str) -> Option<&str> {
self.tool_name_by_id.get(id).map(String::as_str)
}
}
/// Classify one [`LiveEvent`] into zero or more terminal rows.
pub fn classify(ev: &LiveEvent, ctx: &mut ClassifyCtx) -> Vec<TermMsg> {
match ev {
LiveEvent::TurnStart { from, body, .. } => {
// `unread` (the third field) was dropped — mara: "i can see
// the todo/inbox count in toolbar and dont need the N unread".
let mut m = TermMsg::new(Level::Info, format!("TURN ← {from}")).icon("");
if !body.trim().is_empty() {
m = m.body(body.clone(), None);
}
vec![m]
}
LiveEvent::TurnEnd { ok, note } => {
let msg = if *ok {
TermMsg::new(Level::Info, "turn ok").icon("")
} else {
let summary = note
.as_deref()
.filter(|n| !n.is_empty())
.map_or_else(|| "turn fail".to_owned(), |n| format!("turn fail — {n}"));
TermMsg::new(Level::Error, summary).icon("")
};
vec![msg]
}
LiveEvent::Note { text } => vec![classify_note(text)],
LiveEvent::Stream(v) => crate::stream_enrich::classify_stream_value(v, ctx),
// Agent-state transitions never render as terminal rows — the
// header/badges read `/api/state`, not this stream (see module doc).
LiveEvent::StatusChanged { .. }
| LiveEvent::ModelChanged { .. }
| LiveEvent::EffortChanged { .. }
| LiveEvent::TokenUsageChanged { .. }
| LiveEvent::TurnStateChanged { .. } => vec![],
}
}
fn classify_note(text: &str) -> TermMsg {
if let Some(rest) = text.strip_prefix("stderr:") {
TermMsg::new(Level::Warn, format!("stderr:{rest}"))
} else if let Some(rest) = text.strip_prefix("operator:") {
TermMsg::new(Level::Info, format!("operator:{rest}"))
} else {
// Ambient harness chatter (session archived, plugin loaded, etc.) —
// routine, not worth the same visual weight as a tool call or
// assistant text.
TermMsg::new(Level::Debug, text.to_owned())
}
}
#[cfg(test)]
mod tests {
use super::{ClassifyCtx, Level, classify};
use crate::events::LiveEvent;
#[test]
fn turn_start_carries_from_and_body_no_unread() {
let ev = LiveEvent::TurnStart {
from: "operator".into(),
body: "sweep the backlog".into(),
unread: 3,
};
let mut ctx = ClassifyCtx::default();
let msgs = classify(&ev, &mut ctx);
assert_eq!(msgs.len(), 1);
assert_eq!(msgs[0].summary, "TURN ← operator");
assert_eq!(msgs[0].body.as_deref(), Some("sweep the backlog"));
assert_eq!(msgs[0].level, Level::Info);
}
#[test]
fn turn_start_empty_body_has_no_body() {
let ev = LiveEvent::TurnStart {
from: "reminder".into(),
body: String::new(),
unread: 0,
};
let mut ctx = ClassifyCtx::default();
let msgs = classify(&ev, &mut ctx);
assert!(msgs[0].body.is_none());
}
#[test]
fn turn_end_ok_is_info() {
let ev = LiveEvent::TurnEnd {
ok: true,
note: None,
};
let mut ctx = ClassifyCtx::default();
let msgs = classify(&ev, &mut ctx);
assert_eq!(msgs[0].level, Level::Info);
assert_eq!(msgs[0].summary, "turn ok");
}
#[test]
fn turn_end_fail_is_error_with_note() {
let ev = LiveEvent::TurnEnd {
ok: false,
note: Some("rate limited".into()),
};
let mut ctx = ClassifyCtx::default();
let msgs = classify(&ev, &mut ctx);
assert_eq!(msgs[0].level, Level::Error);
assert_eq!(msgs[0].summary, "turn fail — rate limited");
}
#[test]
fn note_stderr_is_warn() {
let ev = LiveEvent::Note {
text: "stderr: warning: deprecated flag".into(),
};
let mut ctx = ClassifyCtx::default();
let msgs = classify(&ev, &mut ctx);
assert_eq!(msgs[0].level, Level::Warn);
}
#[test]
fn note_operator_is_info() {
let ev = LiveEvent::Note {
text: "operator: /compact requested".into(),
};
let mut ctx = ClassifyCtx::default();
let msgs = classify(&ev, &mut ctx);
assert_eq!(msgs[0].level, Level::Info);
}
#[test]
fn note_plain_is_debug() {
let ev = LiveEvent::Note {
text: "created fresh session".into(),
};
let mut ctx = ClassifyCtx::default();
let msgs = classify(&ev, &mut ctx);
assert_eq!(msgs[0].level, Level::Debug);
}
#[test]
fn agent_state_events_produce_no_rows() {
let mut ctx = ClassifyCtx::default();
assert!(
classify(
&LiveEvent::StatusChanged {
status: "online".into()
},
&mut ctx
)
.is_empty()
);
assert!(
classify(
&LiveEvent::ModelChanged {
model: "opus".into()
},
&mut ctx
)
.is_empty()
);
}
}

View file

@ -9,13 +9,36 @@ use serde::{Deserialize, Serialize};
use tokio_stream::{Stream, StreamExt, wrappers::BroadcastStream}; use tokio_stream::{Stream, StreamExt, wrappers::BroadcastStream};
use super::AppState; use super::AppState;
use crate::term_msg::{ClassifyCtx, Level, TermMsg, classify};
/// One classified envelope on the wire: transport-level metadata (a sibling
/// of the terminal-row payload, not part of it) plus the zero-or-more rows
/// the raw event classified into. An event that classifies to zero rows (an
/// agent-state change — `StatusChanged`/`ModelChanged`/etc. — or
/// drop-category noise) never reaches the wire at all; see
/// `crate::term_msg` for why.
///
/// `seq` is the live per-event dedup counter (`BusEvent::seq`) — `Some` on
/// the SSE path, `None` on history replay (a stored row has no live seq).
/// Same category of plumbing as `ts`: the client already used it to drop
/// buffered live traffic it's about to see again in the initial history
/// page, and that need didn't go away just because rows lost their `kind`
/// tag — dropping it here would silently reintroduce duplicate rows across
/// the live/history boundary.
#[derive(Serialize)]
pub(super) struct TermEnvelope {
ts: i64,
#[serde(skip_serializing_if = "Option::is_none")]
seq: Option<u64>,
msgs: Vec<TermMsg>,
}
/// Response body for `GET /api/events/history`. `seq` is omitted from the /// Response body for `GET /api/events/history`. `seq` is omitted from the
/// wire entirely on a paginated (non-initial) load — matches the old /// wire entirely on a paginated (non-initial) load — matches the old
/// `json!` shape, which only ever set the `"seq"` key when `Some`. /// `json!` shape, which only ever set the `"seq"` key when `Some`.
#[derive(Serialize)] #[derive(Serialize)]
pub(super) struct EventsHistoryBody { pub(super) struct EventsHistoryBody {
events: Vec<crate::events::StoredEvent>, events: Vec<TermEnvelope>,
min_id: Option<i64>, min_id: Option<i64>,
has_more: bool, has_more: bool,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
@ -52,15 +75,28 @@ pub(super) async fn events_history(
}; };
let (events, min_id, has_more) = state.bus.history_page(before, limit); let (events, min_id, has_more) = state.bus.history_page(before, limit);
// Apply the same enrichment as the live SSE path so history replay // Classify with the same function the live SSE path uses so history
// and live tail deliver identical shapes. The DB stores raw events. // replay and live tail deliver identical shapes. The DB stores raw
let events: Vec<_> = events // events; classification is applied at read time here (see
// `crate::term_msg`). One `ClassifyCtx` for the whole page — tool_use→
// name correlation (for markdown-vs-plain `recv` result bodies) only
// works within a single page/connection, not across the live/history
// boundary; see that module's doc for why that's an accepted
// degradation.
let mut ctx = ClassifyCtx::default();
let events: Vec<TermEnvelope> = events
.into_iter() .into_iter()
.map(|mut se| { .filter_map(|se| {
if let crate::events::LiveEvent::Stream(ref mut v) = se.event { let msgs = classify(&se.event, &mut ctx);
crate::stream_enrich::enrich(v); if msgs.is_empty() {
None
} else {
Some(TermEnvelope {
ts: se.ts,
seq: None,
msgs,
})
} }
se
}) })
.collect(); .collect();
Json(EventsHistoryBody { Json(EventsHistoryBody {
@ -81,23 +117,29 @@ pub(super) async fn events_stream(
// stream rather than emitted to the bus — a bus emit would spam every // stream rather than emitted to the bus — a bus emit would spam every
// already-connected client with a spurious note each time anyone opens // already-connected client with a spurious note each time anyone opens
// the stream. // the stream.
let hello = Event::default().data( let hello_envelope = TermEnvelope {
serde_json::to_string(&crate::events::LiveEvent::Note { ts: chrono::Utc::now().timestamp(),
text: "live stream attached".into(), seq: None,
}) msgs: vec![TermMsg::new(Level::Debug, "live stream attached")],
.unwrap_or_default(), };
); let hello = Event::default().data(serde_json::to_string(&hello_envelope).unwrap_or_default());
let live = BroadcastStream::new(rx).filter_map(|res| { // One `ClassifyCtx` per connection, moved into the closure — tool_use→
let mut ev = res.ok()?; // name correlation persists for the connection's lifetime (see
// Enrich stream-json values with pre-computed display fields // `crate::term_msg::ClassifyCtx`'s doc for the history-page boundary
// (`_icon`, `_summary`, `_category`) so the frontend doesn't need to // this doesn't cross).
// duplicate the dispatch logic. The DB stores raw events; enrichment let mut ctx = ClassifyCtx::default();
// is applied here so both the live tail and the history endpoint let live = BroadcastStream::new(rx).filter_map(move |res| {
// deliver the same shape (see `events_history` above). let ev = res.ok()?;
if let crate::events::LiveEvent::Stream(ref mut v) = ev.event { let msgs = classify(&ev.event, &mut ctx);
crate::stream_enrich::enrich(v); if msgs.is_empty() {
return None;
} }
let json = serde_json::to_string(&ev).ok()?; let envelope = TermEnvelope {
ts: ev.ts,
seq: Some(ev.seq),
msgs,
};
let json = serde_json::to_string(&envelope).ok()?;
Some(Ok(Event::default().data(json))) Some(Ok(Event::default().data(json)))
}); });
let stream = tokio_stream::once(Ok(hello)).chain(live); let stream = tokio_stream::once(Ok(hello)).chain(live);