diff --git a/docs/terminal-rendering.md b/docs/terminal-rendering.md index 0a3f8a4e..35798fa0 100644 --- a/docs/terminal-rendering.md +++ b/docs/terminal-rendering.md @@ -1,264 +1,51 @@ # Per-agent terminal: row taxonomy (as built) -Snapshot of how the per-agent web UI's live pane renders each -event kind today. Every row on the wire is one **`TermMsg`** +The per-agent web UI's live pane renders one row per `TermMsg` (`hive-agent/src/term_msg.rs`): `{icon?, level: debug|info|warn|error, -summary, body?, body_format?: markdown|diff, coalesce_key?}` — six -fields, the same shape for every row, no `kind` tag. A raw claude -`stream-json` line (`LiveEvent::Stream`) can classify into zero, one, or -several `TermMsg`s (an assistant message with both a text block and a -tool_use block produces two); other `LiveEvent` variants -(`TurnStart`/`TurnEnd`/`Note`) map straight to one each, and the five -agent-state variants (`StatusChanged`/`ModelChanged`/`EffortChanged`/ -`TokenUsageChanged`/`TurnStateChanged`) always produce zero — they never -render as terminal rows, the header/badges read `/api/state` instead. +summary, body?, body_format?: markdown|diff, coalesce_key?}`. Classification +(icon, summary text, whether a tool call gets an expandable body) happens +server-side, once — `term_msg.rs` + `stream_enrich.rs` — and is served +identically by both `GET /api/events/history` and `GET /api/events/stream` +as `TermEnvelope { ts, seq?, msgs: TermMsg[] }` frames +(`hive-agent/src/web_ui/stream.rs`). -Classification happens **server-side**, once, in -`hive-agent/src/term_msg.rs` (top-level dispatch) + -`hive-agent/src/stream_enrich.rs` (the `Stream` payload's per-tool/ -per-event breakdown — icon, summary text, expandable body). Both the -live SSE tail (`GET /api/events/stream`) and the paginated history -replay (`GET /api/events/history`) call the same `classify()` and -serve identically-shaped `TermEnvelope { ts, seq?, msgs: TermMsg[] }` -frames (`hive-agent/src/web_ui/stream.rs`) — the sqlite event log still -stores the raw, unclassified event, so classification logic can change -without a DB migration. +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 `
`, 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. -The frontend (`frontend/packages/agent/src/lib/classifyEvent.ts`) is a -thin adapter: `TermMsg` → `StreamRow` (`lib/streamRow.ts`), rendered by -`components/Row.tsx`. It does **not** re-derive icons, summaries, or -per-tool formatting — that all happened server-side already. Its only -real decision is body layout: an empty-`summary` + markdown-`body` row -renders as a flat row with just the body (assistant text); every other -bodied row is an expandable `
`, opened by default according to -the operator's expand-tool-output preference (`getExpandDetailsPref()`, -`@hive/shared/prefs.ts`) — uniformly, no per-tool server override. -`frontend/packages/shared/src/terminal/terminal.css` supplies the -`.live .level-*` colour rules + shared row layout. +## Layout -## Layout contract +Every row shares one prefix column via `padding-left` + negative +`text-indent` on `.live .row`; an icon (when set) sits in a fixed-width +`.row-glyph` cell so icons of different rendered widths still line up. +`
` summaries reuse the same metrics, with the disclosure caret +leading the summary text rather than the icon. -Every row — flat `
` and expandable -`
` 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. +## Levels -Rows that carry an icon (the per-tool emoji, `🧠`/`💭` -thinking, etc.) set the `StreamRow.icon` prop, which -`components/Row.tsx` renders into 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. - -`
` 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 `
`) get `text-indent: 0` so their content -lays out from the body column instead of inheriting the -parent's negative pull. - -## Row taxonomy - -Every row carries a **level** (`debug`/`info`/`warn`/`error`), which is -the *only* thing that drives its colour — `frontend/packages/shared/src/ -terminal/terminal.css`'s `.live .level-debug/-info/-warn/-error` rules. -There's no separate per-row-kind class any more (no `.turn-start`, -`.tool-use`, `.tool-result.error`, `.sys`, …) — structural identity -(this is a turn boundary, this is a tool call, this is an error) is -carried by the row's **icon** and **summary text** instead, computed -server-side once and read verbatim by the client. - -| Level | Colour | Used for | +| Level | Colour | Roughly | |---|---|---| -| `debug` | muted | thinking, thinking-token ticks, plugin-install/status ticks, ambient harness chatter, unrecognised-system-subtype notes | -| `info` | default fg | turn start/ok, assistant text, tool calls + their (non-error) results, operator-initiated notes | -| `warn` | amber, left rule | stderr lines, an unrecognised stream-json shape, API retry backoff | -| `error` | red, left rule | turn failed, a tool result with `is_error: true`, a hard API error | +| `debug` | muted | thinking, coalesced ticks, ambient harness chatter | +| `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` | -Representative summaries (icon + text, `hive-agent/src/term_msg.rs` + -`stream_enrich.rs`): - -| Row | icon | level | summary | body | -|---|---|---|---|---| -| turn start | `◆` | info | `TURN ← ` | the wake-prompt text (plain) | -| turn ok | `✅` | info | `turn ok` | — | -| turn failed | `❌` | error | `turn fail — ` | — | -| assistant text | — | info | *(empty)* | the text itself (markdown) — renders as a flat row, no prefix line | -| thinking | `💭` | debug | the thinking text (or `thinking …`) | — | -| thinking-token tick | `🧠` | debug | `thinking… ~N tokens` (`coalesce_key: "thinking-tok"`, in-place updates) | — | -| tool call | per-tool, see [salient-arg formatting](#salient-arg-formatting) | info | ` ` | some tools: diff (Edit) / markdown (`send`) / plain (long `mcp__bash__run` cmd) | -| tool result, short | `←` | info | the trimmed result text | — | -| tool result, long | — | info | `Nl · headline…` | the full text (plain) | -| tool result, `recv`-correlated | — | info | `recv ← ` | the full text (markdown) | -| tool result, error (short) | `✗` | error | the trimmed error text | — | -| tool result, error (long) | — | error | `Nl · headline…` | the full error text (plain) | -| subagent task start/notify (dead path — `Task` not in the agent allow-list) | `⌁` | info | `task started · [type]` / `task ✓/✗/◌ · · → ` | — | -| plugin install / status tick | — | debug | `⚙ plugin install · loading…\|✓ done` / `⚙ status` (both `coalesce_key`-collapsed) | — | -| commands changed | — | debug | `⚙ commands changed · N available` | one `/name` per line (plain) | -| compact boundary / API retry / API error / unrecognised system subtype | — | debug / warn / error / debug | `⚙ compact · …` / `⚠ api retry · …` / `✗ api error · …` / `⚙ ` | — | -| harness note | — | debug | the note text | — | -| stderr line | — | warn | `stderr: ` | — | -| operator-initiated note | — | info | `operator: ` | — | -| unrecognised stream-json shape | `!` | warn | trimmed raw JSON | — | - -Whether a bodied row renders open or collapsed is a **client-only** -decision — the operator's expand-tool-output preference -(`getExpandDetailsPref()`), applied uniformly to every row with a body. -There's no server-side per-tool override any more (the old `recv`/`send` -"always default-open" special case is gone). - -## Classification pipeline - -1. `hive-agent/src/term_msg.rs::classify()` is the top-level dispatch, - called once per `LiveEvent` at SSE-emit time (both the live tail and - history replay — see `web_ui/stream.rs`): - - `TurnStart`/`TurnEnd`/`Note` map straight to one `TermMsg` each. - - The five agent-state variants (`StatusChanged`/…) always produce - zero — never rendered as terminal rows. - - `Stream(value)` delegates to `stream_enrich::classify_stream_value`. -2. `classify_stream_value` walks one raw claude `stream-json` line: - - top-level `result` / `rate_limit_event` → dropped (no rows). - - `type: "system"` → `classify_system`, which computes - `(category, summary, body)` per `subtype` (`system_fields()`) and - maps `category` to a level + optional `coalesce_key`: - `"thinking_tok"` → debug, coalesced; `"details"` (currently just - `commands_changed`) → debug, with an expandable body; `"note"` → - level varies by subtype (`api_error` → error, `api_retry` → warn, - `plugin_install`/`status` → debug + coalesced, everything else → - debug). - - `subtype: "task_started" | "task_notification"` (regardless of - top-level `type`) → `classify_task_event`. - - `type: "assistant"` → walk `message.content[]`: `text` → info row, - empty summary, markdown body; `thinking` → debug row with `💭`; - `tool_use` → `classify_tool_use` (records `id → name` in the - connection/page-scoped `ClassifyCtx` for the next step, computes - icon + summary via `fmt_tool_use()`/`tool_icon()`, and a body via - `rich_tool_body()` for the fixed set of tools that have one). - - `type: "user"` → walk `message.content[]` for `tool_result`: - `classify_tool_result` correlates `tool_use_id` against the - `ClassifyCtx` to spot a `recv` result (rendered `recv ← …` with a - markdown body), strips the `` wrapper on errors, - and otherwise picks short-flat vs. long-with-body by length. - - Unrecognised shape → warn row, trimmed raw JSON, `!` icon. -3. `frontend/packages/agent/src/lib/classifyEvent.ts::classifyEvent` - maps each `TermMsg` in the envelope to a `StreamRow` - (`lib/streamRow.ts`) — `level` → `level-*` CSS class, `body_format` - → which of `markdown`/`diff`/`plain` body prop to set, empty-summary - markdown body → the flat "assistant text" shape, everything else - with a body → an expandable `
` gated by the operator's - preference. `components/Row.tsx` renders the result. - -`ClassifyCtx` (tool_use `id → name` correlation, needed for step 2's -`recv` detection) is scoped per SSE connection on the live path and per -page on the history path — it does not cross the live/history boundary, -so a `recv` result whose `tool_use` fell on the other side of a -reconnect or page load renders as a plain block instead of default-open -markdown. Accepted, documented degradation -(`hive-agent/src/term_msg.rs`'s `ClassifyCtx` doc), not a bug. - -### Salient-arg formatting - -Server-side (`fmt_tool_use()` and its per-tool-family helpers in -`hive-agent/src/stream_enrich.rs`), computed into the `TermMsg`'s -`summary` field 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 ` | -| `Write` | flat, same shape as Read: `Write ` — no diff/count (`content` can be megabytes and is one-sided; open the file to inspect it) | -| `Edit` | rich diff row `Edit · -N +N` (just `+N` for a pure insert, i.e. empty `old_string`) | -| `Glob` | `Glob ` | -| `Grep` | `Grep ` | -| `Bash` | `Bash [bg] $ ` (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` (markdown body, expand state now follows the operator's preference like every other bodied row — see [Row taxonomy](#row-taxonomy)) | -| `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) | +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 — +read that when you need the specifics, this doc doesn't duplicate it. ## Markdown -`frontend/packages/agent/src/lib/markdown.ts`'s `renderMarkdown(text)` -runs `marked.parse(text)` through DOMPurify and is rendered into a -`
` by `components/Row.tsx`'s `MarkdownBody`. 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. Applied to any `TermMsg` with -`body_format: "markdown"` — assistant text, `send`'s body, a -`recv`-correlated tool result. - -## 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 the `TermMsg`'s `summary` field 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. +`frontend/packages/agent/src/lib/markdown.ts`'s `renderMarkdown()` runs +`marked.parse()` through DOMPurify into a `
`. Applied to +any `TermMsg` with `body_format: "markdown"`. ## 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. +The main dashboard's message-flow pane is a different shape: broker +messages render as `.msgrow` grid lines, not agent-terminal rows. diff --git a/frontend/packages/agent/src/components/Row.tsx b/frontend/packages/agent/src/components/Row.tsx index da818b48..8c435829 100644 --- a/frontend/packages/agent/src/components/Row.tsx +++ b/frontend/packages/agent/src/components/Row.tsx @@ -1,13 +1,16 @@ -// Renders one `StreamRow` — flat `
` or expandable -// `
`, matching @hive/shared/terminal.css's -// existing row-kind classes exactly (see docs/terminal-rendering.md). -// Reuses that stylesheet as-is (imported once by LiveStream.tsx) — the -// taxonomy's visual language isn't what mara asked to change, the -// component *model* underneath it is. +// Renders one `TermRow` — flat `
` or expandable +// `
`, driven straight off the wire shape +// (`lib/termMsg.ts`'s `TermMsg`, mirroring hive-agent's `term_msg.rs`): +// `level` picks the colour class, an empty `summary` + markdown `body` +// is a flat row with just the body (assistant text), anything else with +// 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 type { StreamRow } from '../lib/streamRow.js'; +import type { TermRow } from '../lib/termMsg.js'; import { linkifyToNodes } from '../lib/linkify.js'; import { renderMarkdown } from '../lib/markdown.js'; +import { getExpandDetailsPref } from '@hive/shared/prefs.js'; function MarkdownBody({ text }: { text: string }) { const ref = useRef(null); @@ -41,25 +44,39 @@ function DiffBody({ text }: { text: string }) { ); } -export function Row({ row }: { row: StreamRow }) { - if (row.details) { +export function Row({ row }: { row: TermRow }) { + const cssClass = 'level-' + row.level; + const icon = row.icon != null && row.icon !== '' && {row.icon}; + + if (row.body == null) { return ( -
- - {row.icon != null && row.icon !== '' && {row.icon}} - {row.text} - - {row.diffBody != null && } - {row.markdownBody != null && } - {row.plainBody != null &&
{linkifyToNodes(row.plainBody)}
} -
+
+ {icon} + {linkifyToNodes(row.summary)} +
); } + + // 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 ( +
+ {icon} + +
+ ); + } + return ( -
- {row.icon != null && row.icon !== '' && {row.icon}} - {row.text != null && linkifyToNodes(row.text)} - {row.markdownBody != null && } -
+
+ + {icon} + {row.summary} + + {row.body_format === 'diff' && } + {row.body_format === 'markdown' && } + {row.body_format == null &&
{linkifyToNodes(row.body)}
} +
); } diff --git a/frontend/packages/agent/src/hooks/useLiveStream.ts b/frontend/packages/agent/src/hooks/useLiveStream.ts index 51bf1f82..79ce767d 100644 --- a/frontend/packages/agent/src/hooks/useLiveStream.ts +++ b/frontend/packages/agent/src/hooks/useLiveStream.ts @@ -1,21 +1,21 @@ // Backfill + live SSE for the agent's event stream, reduced to a plain -// `StreamRow[]` — the Preact-data sibling of -// @hive/shared/terminal.js's `create()`. Scroll behaviour is -// deliberately NOT this hook's job (see components/LiveStream.tsx): -// mara flagged the old page's scroll-while-streaming bug explicitly as -// something not to copy 1:1, and keeping "what rows exist" separate -// from "where the viewport is" is what makes that fixable — this hook -// only ever appends/prepends to `rows`, the DOM-owning component -// decides whether that should move the scroll position. +// `TermRow[]` — the Preact-data sibling of @hive/shared/terminal.js's +// `create()`. Scroll behaviour is deliberately NOT this hook's job (see +// components/LiveStream.tsx): mara flagged the old page's scroll-while- +// streaming bug explicitly as something not to copy 1:1, and keeping +// "what rows exist" separate from "where the viewport is" is what makes +// that fixable — this hook only ever appends/prepends to `rows`, the +// DOM-owning component decides whether that should move the scroll +// position. // // Same subscribe → buffer → fetch-history → seq-dedupe → flush dance as // terminal.js's `start()`: a live envelope landing between EventSource- // open and the history response resolving is buffered, not dropped or // double-counted (`envelope.seq <= history.seq` → already covered by the -// initial history page, drop it from the buffer). Post mara's terminal- -// message redesign there's no per-row `kind` any more to sanity-check -// that against — `seq` alone is the whole dedup signal, see -// `TermEnvelope`'s doc in hive-agent's `web_ui/stream.rs`. +// initial history page, drop it from the buffer). There's no per-row +// `kind` any more to sanity-check that against — `seq` alone is the +// whole dedup signal, see `TermEnvelope`'s doc in hive-agent's +// `web_ui/stream.rs`. // // The old `onLiveTurnBoundary` callback (a snappier one-off `/api/state` // refresh right after a live turn_start/turn_end, instead of waiting for @@ -25,8 +25,7 @@ // longer has the structure to single one out. `useAgentState`'s 4s poll // is the only refresh path now. import { useEffect, useRef, useState } from 'preact/hooks'; -import { classifyEvent, createClassifyCtx, type ClassifyCtx, type TermEnvelope } from '../lib/classifyEvent.js'; -import type { StreamRow } from '../lib/streamRow.js'; +import type { TermEnvelope, TermRow } from '../lib/termMsg.js'; export interface UseLiveStreamOptions { historyUrl?: string; @@ -34,7 +33,7 @@ export interface UseLiveStreamOptions { } export interface UseLiveStreamResult { - rows: StreamRow[]; + rows: TermRow[]; hasMore: boolean; loadingMore: boolean; loadMore: () => void; @@ -47,12 +46,12 @@ export interface UseLiveStreamResult { clearLocal: () => void; } -function appendRow(rows: StreamRow[], row: StreamRow): StreamRow[] { +function appendRow(rows: TermRow[], row: TermRow): TermRow[] { const last = rows[rows.length - 1]; // 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, 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); next.push({ ...row, key: last.key }); return next; @@ -60,22 +59,28 @@ function appendRow(rows: StreamRow[], row: StreamRow): StreamRow[] { 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 { const historyUrl = opts.historyUrl ?? 'events/history'; const streamUrl = opts.streamUrl ?? 'events/stream'; - const [rows, setRows] = useState([]); + const [rows, setRows] = useState([]); const [hasMore, setHasMore] = useState(false); const [loadingMore, setLoadingMore] = useState(false); - const ctxRef = useRef(null); - if (!ctxRef.current) ctxRef.current = createClassifyCtx(); + const keySeqRef = useRef(0); + 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(null); const liveRef = useRef(false); const bufferedRef = useRef([]); @@ -84,8 +89,7 @@ export function useLiveStream(opts: UseLiveStreamOptions = {}): UseLiveStreamRes let cancelled = false; function pushLive(env: TermEnvelope) { - const newRows = classifyEvent(env, false, ctxRef.current!); - if (newRows.length) setRows((prev) => appendMany(prev, newRows)); + if (env.msgs.length) setRows((prev) => appendEnvelope(prev, env, false)); } const es = new EventSource(streamUrl); @@ -95,8 +99,7 @@ export function useLiveStream(opts: UseLiveStreamOptions = {}): UseLiveStreamRes env = JSON.parse(e.data); } catch { setRows((prev) => appendRow(prev, { - key: 'parse-err-' + Date.now(), cssClass: 'level-warn', fromHistory: false, - text: '[parse err] ' + e.data, + key: 'parse-err-' + Date.now(), level: 'warn', summary: '[parse err] ' + e.data, fromHistory: false, })); return; } @@ -108,8 +111,8 @@ export function useLiveStream(opts: UseLiveStreamOptions = {}): UseLiveStreamRes }; es.onerror = () => { setRows((prev) => appendRow(prev, { - key: 'conn-note', cssClass: 'level-warn', fromHistory: false, coalesceKey: 'conn-status', - text: es.readyState === 0 /* CONNECTING */ ? '[reconnecting…]' : '[disconnected]', + key: 'conn-note', level: 'warn', fromHistory: false, coalesce_key: 'conn-status', + summary: es.readyState === 0 /* CONNECTING */ ? '[reconnecting…]' : '[disconnected]', })); }; @@ -126,11 +129,11 @@ export function useLiveStream(opts: UseLiveStreamOptions = {}): UseLiveStreamRes } if (cancelled) return; - let initial: StreamRow[] = []; - for (const env of events) initial = appendMany(initial, classifyEvent(env, true, ctxRef.current!)); + let initial: TermRow[] = []; + for (const env of events) initial = appendEnvelope(initial, env, true); initial = events.length - ? appendRow(initial, { key: 'live-sep', cssClass: 'level-debug', fromHistory: true, text: '─── live (older above) ───' }) - : [{ key: 'placeholder', cssClass: 'level-debug', fromHistory: true, text: '(connected — waiting for events)' }]; + ? appendRow(initial, { key: 'live-sep', level: 'debug', fromHistory: true, summary: '─── live (older above) ───' }) + : [{ key: 'placeholder', level: 'debug', fromHistory: true, summary: '(connected — waiting for events)' }]; setRows(initial); const drained = bufferedRef.current; @@ -171,10 +174,10 @@ export function useLiveStream(opts: UseLiveStreamOptions = {}): UseLiveStreamRes setHasMore(!!body.has_more); if (typeof body.min_id === 'number') minIdRef.current = body.min_id; if (events.length) { - let older: StreamRow[] = []; - for (const env of events) older = appendMany(older, classifyEvent(env, true, ctxRef.current!)); + let older: TermRow[] = []; + for (const env of events) older = appendEnvelope(older, env, true); older = appendRow(older, { - key: 'older-sep-' + minIdRef.current, cssClass: 'level-debug', fromHistory: true, text: '─── older above ───', + key: 'older-sep-' + minIdRef.current, level: 'debug', fromHistory: true, summary: '─── older above ───', }); setRows((prev) => [...older, ...prev]); } @@ -185,10 +188,8 @@ export function useLiveStream(opts: UseLiveStreamOptions = {}): UseLiveStreamRes } } - const localKeyRef = useRef(0); function pushLocalNote(text: string) { - localKeyRef.current += 1; - setRows((prev) => appendRow(prev, { key: `local-${localKeyRef.current}`, cssClass: 'level-info', fromHistory: false, text })); + setRows((prev) => appendRow(prev, { key: 'local-' + nextKey(), level: 'info', fromHistory: false, summary: text })); } function clearLocal() { setRows([]); diff --git a/frontend/packages/agent/src/lib/classifyEvent.ts b/frontend/packages/agent/src/lib/classifyEvent.ts deleted file mode 100644 index 2fea8870..00000000 --- a/frontend/packages/agent/src/lib/classifyEvent.ts +++ /dev/null @@ -1,70 +0,0 @@ -// Thin adapter: turns one server-classified `TermEnvelope` (hive-agent's -// `web_ui/stream.rs`) into zero or more `StreamRow`s. Almost all -// classification now happens server-side (`hive-agent/src/term_msg.rs` + -// `stream_enrich.rs`) — this file used to be a large per-tool/per-event -// dispatch tree (see git history pre mara's terminal-message redesign); -// now it's just a shape translation. -import type { StreamRow } from './streamRow.js'; -import { getExpandDetailsPref } from '@hive/shared/prefs.js'; - -/** One terminal row as served by `GET /api/events/{history,stream}` — - * mirrors `hive-agent/src/term_msg.rs::TermMsg` field-for-field. */ -export interface TermMsg { - icon?: string; - level: 'debug' | 'info' | 'warn' | 'error'; - 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[]; -} - -export interface ClassifyCtx { - keySeq: { current: number }; -} - -export function createClassifyCtx(): ClassifyCtx { - return { keySeq: { current: 0 } }; -} - -function nextKey(ctx: ClassifyCtx): string { - ctx.keySeq.current += 1; - return 'r' + ctx.keySeq.current; -} - -/** `TermEnvelope` → zero or more `StreamRow`s (one per `TermMsg`). */ -export function classifyEvent(env: TermEnvelope, fromHistory: boolean, ctx: ClassifyCtx): StreamRow[] { - return env.msgs.map((m) => termMsgToRow(m, fromHistory, ctx)); -} - -function termMsgToRow(msg: TermMsg, fromHistory: boolean, ctx: ClassifyCtx): StreamRow { - const cssClass = 'level-' + msg.level; - const base = { key: nextKey(ctx), cssClass, fromHistory, icon: msg.icon, coalesceKey: msg.coalesce_key }; - - if (msg.body == null) { - return { ...base, text: msg.summary }; - } - - // Empty summary + markdown body → the old `.text` row: no prefix line, - // the body itself is the whole row (assistant text). Every other - // bodied row is an expandable details row, gated uniformly by the - // operator's expand-tool-output preference — no server-side per-tool - // override any more (mara: "client pref covers every message type - // uniformly, no server override even for send/ask/answer/recv"). - if (msg.body_format === 'markdown' && msg.summary === '') { - return { ...base, markdownBody: msg.body }; - } - - const opened = { ...base, text: msg.summary, details: true, defaultOpen: getExpandDetailsPref() }; - if (msg.body_format === 'diff') return { ...opened, diffBody: msg.body }; - if (msg.body_format === 'markdown') return { ...opened, markdownBody: msg.body }; - return { ...opened, plainBody: msg.body }; -} diff --git a/frontend/packages/agent/src/lib/streamRow.ts b/frontend/packages/agent/src/lib/streamRow.ts deleted file mode 100644 index e319d42a..00000000 --- a/frontend/packages/agent/src/lib/streamRow.ts +++ /dev/null @@ -1,48 +0,0 @@ -// Row model for the live event stream. One `StreamRow` = one rendered -// line/panel in the terminal pane; `classifyEvent` (classifyEvent.ts) -// turns a server-classified `TermMsg` (hive-agent's `term_msg.rs`) into -// one of these, and `` (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 -// taxonomy this mirrors. -// -// Post mara's terminal-message redesign, `cssClass` is always exactly -// `level-debug|info|warn|error` (derived 1:1 from the wire `level`, see -// classifyEvent.ts) rather than a free-text per-row-kind class — the -// server no longer tells the client "this is a turn-start" or "this is a -// tool call", only "this is icon+level+summary+body". `meta`/`childText` -// (turn-time, duration, unread-count spans) are gone with them: the -// signal that let the client single out a turn-boundary row to attach -// them to (the old `kind` tag) no longer exists on the wire, by design — -// see term_msg.rs's module doc. - -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; - /** Always `level-debug` / `level-info` / `level-warn` / `level-error` — - * see @hive/shared/terminal.css's level-colour rules. */ - 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 started→completed). */ - coalesceKey?: string; - /** `false`/absent → flat `
`; `true` → `
` with `.summary-text` + optional body. */ - details?: boolean; - defaultOpen?: boolean; - /** Flat-row prefix text, or the details `` text. Linkified, - * never markdown. */ - text?: string; - /** Sanitized-markdown body: appended under a flat row (assistant - * text, no `text` set) or inside an open details row (tool bodies). */ - markdownBody?: string; - /** Plain `
` body inside a details row (generic long tool output). */
-  plainBody?: string;
-  /** `+`/`-`/context diff body inside a details row (Edit tool). */
-  diffBody?: string;
-}
diff --git a/frontend/packages/agent/src/lib/termMsg.ts b/frontend/packages/agent/src/lib/termMsg.ts
new file mode 100644
index 00000000..ecfdb717
--- /dev/null
+++ b/frontend/packages/agent/src/lib/termMsg.ts
@@ -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;
+}