// Renders one `TermRow` — flat `
` or expandable // `
`, 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, useState } from "preact/hooks"; import type { TermRow } from "./termMsg.js"; import { linkifyToNodes } from "./linkify.js"; import { renderMarkdown } from "./markdown.js"; import { getExpandDetailsPref, getHideDebugPref, subscribeHideDebugPref, } from "../prefs.js"; function MarkdownBody({ text }: { text: string }) { const ref = useRef(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 (
); } function DiffBody({ text }: { text: string }) { const lines = String(text).split("\n"); return (
      {lines.map((line, i) => {
        const cls = line.startsWith("+ ")
          ? "diff-add"
          : line.startsWith("- ")
            ? "diff-del"
            : "diff-ctx";
        return (
          
            {line}
            {"\n"}
          
        );
      })}
    
); } // A `debug` row, which follows the "hide debug output" preference as it // changes. Only debug rows subscribe, so a long terminal does not hold a // listener per row. function DebugRow({ row }: { row: TermRow }) { const [hidden, setHidden] = useState(() => getHideDebugPref()); useEffect( () => subscribeHideDebugPref(() => setHidden(getHideDebugPref())), [], ); // "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. return hidden ? null : ; } export function Row({ row }: { row: TermRow }) { return row.level === "debug" ? : ; } function RowBody({ row }: { row: TermRow }) { const cssClass = "level-" + row.level; const icon = row.icon != null && row.icon !== "" && ( {row.icon} ); if (row.body == null) { return (
{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). */} {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 (
{icon} {row.summary} {row.body_format === "diff" && } {row.body_format === "markdown" && } {row.body_format == null && (
{linkifyToNodes(row.body)}
)}
); }