Moves the TermMsg rendering pipeline (Row.tsx, termMsg.ts, linkify.tsx,
markdown.ts) from @hive/agent into @hive/shared, so swarm-ui becomes a
second consumer of it instead of forking a copy -- CSS was already
shared (@hive/shared/terminal.css). marked+dompurify move from
@hive/agent's deps to @hive/shared's; swarm-ui picks them up
transitively, no new direct dep there.
New swarm-ui route /agents/:name/term (AgentTermPage), linked from
AgentsPage's detail panel via a "terminal" badge next to "link matrix
account". Consumes GET /api/agents/{name}/term/stream: unlike
@hive/agent's own useLiveStream (TermEnvelope-wrapped, history/backfill
dance), the swarm relay forwards one bare TermMsg per SSE event with no
envelope and no history endpoint -- useSwarmTermStream is a much
smaller hook for that shape (EventSource -> parse -> coalesce, nothing
to buffer/dedupe/backfill against).
Verified against a live SSE mock (screenshots in /agents/iris/state/screenshots/
3801-agents-detail-panel-terminal-badge.png and
3801-agent-term-page-live-rows.png -- real rows rendering through the
shared Row component, not just a build/typecheck pass).
109 lines
3.9 KiB
TypeScript
109 lines
3.9 KiB
TypeScript
// Renders one `TermRow` — flat `<div class="row …">` or expandable
|
|
// `<details class="row …">`, driven straight off the wire shape
|
|
// (`./termMsg.js`'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."
|
|
//
|
|
// Shared between `@hive/agent`'s local stream and `@hive/swarm-ui`'s
|
|
// relay consumer (see `./termMsg.js`'s doc) — both hand this the same
|
|
// `TermRow` shape regardless of which wire framing they read it off of.
|
|
import { useEffect, useRef } from "preact/hooks";
|
|
import type { TermRow } from "./termMsg.js";
|
|
import { linkifyToNodes } from "./linkify.js";
|
|
import { renderMarkdown } from "./markdown.js";
|
|
import { getExpandDetailsPref, getHideDebugPref } from "../prefs.js";
|
|
|
|
function MarkdownBody({ text }: { text: string }) {
|
|
const ref = useRef<HTMLDivElement>(null);
|
|
const html = renderMarkdown(text);
|
|
useEffect(() => {
|
|
// marked autolinks URLs but leaves them same-tab — open externally
|
|
// so a click never navigates the terminal away.
|
|
ref.current?.querySelectorAll("a[href]").forEach((a) => {
|
|
a.setAttribute("target", "_blank");
|
|
a.setAttribute("rel", "noopener noreferrer");
|
|
});
|
|
}, [html]);
|
|
// eslint-disable-next-line react/no-danger -- sanitized by DOMPurify in renderMarkdown
|
|
return (
|
|
<div className="md" ref={ref} dangerouslySetInnerHTML={{ __html: html }} />
|
|
);
|
|
}
|
|
|
|
function DiffBody({ text }: { text: string }) {
|
|
const lines = String(text).split("\n");
|
|
return (
|
|
<pre className="tool-body diff-body">
|
|
{lines.map((line, i) => {
|
|
const cls = line.startsWith("+ ")
|
|
? "diff-add"
|
|
: line.startsWith("- ")
|
|
? "diff-del"
|
|
: "diff-ctx";
|
|
return (
|
|
<span key={i} className={cls}>
|
|
{line}
|
|
{"\n"}
|
|
</span>
|
|
);
|
|
})}
|
|
</pre>
|
|
);
|
|
}
|
|
|
|
export function Row({ row }: { row: TermRow }) {
|
|
// "hide debug output" is a skip, not a dimmer — a hidden debug row
|
|
// still occupies no DOM node at all, same as it never streamed, rather
|
|
// than a CSS `display: none` that would keep it in the layout/DOM for
|
|
// no benefit.
|
|
if (row.level === "debug" && getHideDebugPref()) return null;
|
|
|
|
const cssClass = "level-" + row.level;
|
|
const icon = row.icon != null && row.icon !== "" && (
|
|
<span className="row-glyph">{row.icon}</span>
|
|
);
|
|
|
|
if (row.body == null) {
|
|
return (
|
|
<div className={`row ${cssClass}`}>
|
|
{icon}
|
|
{/* Explicit wrapper, not bare children — the row's grid places
|
|
`.row-content` in its content column by class, not by
|
|
auto-placement inference (an icon-less row's lone child would
|
|
otherwise auto-place into the icon column instead). */}
|
|
<span className="row-content">{linkifyToNodes(row.summary)}</span>
|
|
</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>
|
|
);
|
|
}
|