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

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

@ -0,0 +1,285 @@
//! 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 as a default-open markdown message body. 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 (same shape as the turn-timestamp fallback documented in
/// `docs/terminal-rendering.md`) — the only user-visible effect is a `recv`
/// result whose `tool_use` fell on the other side of a page/reconnect
/// boundary rendering as a plain block instead of default-open markdown,
/// not a functional loss.
#[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()
);
}
}