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.
This commit is contained in:
iris 2026-08-30 21:11:22 +02:00
commit 5eefaa951d
13 changed files with 886 additions and 628 deletions

View file

@ -9,13 +9,36 @@ use serde::{Deserialize, Serialize};
use tokio_stream::{Stream, StreamExt, wrappers::BroadcastStream};
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
/// wire entirely on a paginated (non-initial) load — matches the old
/// `json!` shape, which only ever set the `"seq"` key when `Some`.
#[derive(Serialize)]
pub(super) struct EventsHistoryBody {
events: Vec<crate::events::StoredEvent>,
events: Vec<TermEnvelope>,
min_id: Option<i64>,
has_more: bool,
#[serde(skip_serializing_if = "Option::is_none")]
@ -52,15 +75,27 @@ pub(super) async fn events_history(
};
let (events, min_id, has_more) = state.bus.history_page(before, limit);
// Apply the same enrichment as the live SSE path so history replay
// and live tail deliver identical shapes. The DB stores raw events.
let events: Vec<_> = events
// Classify with the same function the live SSE path uses so history
// replay and live tail deliver identical shapes. The DB stores raw
// events; classification is applied at read time here (see
// `crate::term_msg`). One `ClassifyCtx` for the whole page — tool_use→
// name correlation (for default-open `recv` results) 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()
.map(|mut se| {
if let crate::events::LiveEvent::Stream(ref mut v) = se.event {
crate::stream_enrich::enrich(v);
.filter_map(|se| {
let msgs = classify(&se.event, &mut ctx);
if msgs.is_empty() {
None
} else {
Some(TermEnvelope {
ts: se.ts,
seq: None,
msgs,
})
}
se
})
.collect();
Json(EventsHistoryBody {
@ -81,23 +116,29 @@ pub(super) async fn events_stream(
// stream rather than emitted to the bus — a bus emit would spam every
// already-connected client with a spurious note each time anyone opens
// the stream.
let hello = Event::default().data(
serde_json::to_string(&crate::events::LiveEvent::Note {
text: "live stream attached".into(),
})
.unwrap_or_default(),
);
let live = BroadcastStream::new(rx).filter_map(|res| {
let mut ev = res.ok()?;
// Enrich stream-json values with pre-computed display fields
// (`_icon`, `_summary`, `_category`) so the frontend doesn't need to
// duplicate the dispatch logic. The DB stores raw events; enrichment
// is applied here so both the live tail and the history endpoint
// deliver the same shape (see `events_history` above).
if let crate::events::LiveEvent::Stream(ref mut v) = ev.event {
crate::stream_enrich::enrich(v);
let hello_envelope = TermEnvelope {
ts: chrono::Utc::now().timestamp(),
seq: None,
msgs: vec![TermMsg::new(Level::Debug, "live stream attached")],
};
let hello = Event::default().data(serde_json::to_string(&hello_envelope).unwrap_or_default());
// One `ClassifyCtx` per connection, moved into the closure — tool_use→
// name correlation persists for the connection's lifetime (see
// `crate::term_msg::ClassifyCtx`'s doc for the history-page boundary
// this doesn't cross).
let mut ctx = ClassifyCtx::default();
let live = BroadcastStream::new(rx).filter_map(move |res| {
let ev = res.ok()?;
let msgs = classify(&ev.event, &mut ctx);
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)))
});
let stream = tokio_stream::once(Ok(hello)).chain(live);