This repository has been archived on 2026-10-08. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
maestro/web/src/structuredRender.ts
T

554 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Pure per-row and rail DOM builders for the structured transcript (#140).
// No socket, no daemon calls — structured.ts owns the connection and calls
// into here with data already in hand, so every rendering decision here is
// unit-testable without a WebSocket.
import { escapeHtml } from "./html.js";
import { computeDiffLines, diffStats } from "./structuredDiff.js";
import type {
AcpContentBlock,
AcpPermissionOption,
AcpPlan,
AcpToolCallContent,
AcpToolCallLike,
AcpUsage,
InTheWay,
Session,
StructuredApprovalDecision,
TranscriptRow,
} from "./types.js";
// ---------------------------------------------------------------------------
// row taxonomy
// ---------------------------------------------------------------------------
/** Glyph shown in a tool row's head, by `ToolKind`. Unrecognised/absent kinds
* fall back to the generic bullet — never a blank glyph column. */
const KIND_GLYPH: Record<string, string> = {
read: "≡",
edit: "±",
delete: "✕",
move: "→",
search: "⌕",
execute: "$",
think: "…",
fetch: "⇣",
switch_mode: "⇄",
};
const KIND_LABEL: Record<string, string> = {
read: "Read",
edit: "Edit",
delete: "Delete",
move: "Move",
search: "Search",
execute: "Execute",
think: "Think",
fetch: "Fetch",
switch_mode: "Switch mode",
};
function toolGlyph(kind: string | undefined): string {
return (kind && KIND_GLYPH[kind]) || "•";
}
function toolKindLabel(kind: string | undefined): string {
return (kind && KIND_LABEL[kind]) || "Tool";
}
/** Split a title into `{label, arg}` when it follows the adapter's own
* `Name(argument)` convention; otherwise the whole title is the arg and the
* tool's `kind` supplies a generic label. Never guesses at a shape the data
* doesn't actually show. */
function splitToolTitle(
title: string | undefined,
name: string | null | undefined,
kind: string | undefined,
): { label: string; arg: string } {
const label = name ?? toolKindLabel(kind);
if (!title) return { label, arg: "" };
const m = /^(\S+)\(([\s\S]*)\)$/.exec(title);
if (m) return { label: name ?? m[1], arg: m[2] };
return { label, arg: title };
}
function el(tag: string, className?: string): HTMLElement {
const node = document.createElement(tag);
if (className) node.className = className;
return node;
}
/** Content-block text, or a labelled placeholder for anything that isn't
* text — never dropped silently, never fabricated. */
function contentBlockText(block: AcpContentBlock | undefined): string {
if (!block) return "";
switch (block.type) {
case "text":
return block.text;
case "image":
return "[image]";
case "audio":
return "[audio]";
case "resource_link":
return `[resource: ${block.name ?? block.uri ?? "linked"}]`;
case "resource":
return "[resource]";
default:
return "";
}
}
/** A tiny, dependency-free inline renderer: `**bold**`, `*italic*`, and
* `` `code` `` become real markup; everything else stays escaped plain
* text. No block-level markdown (lists, headings) — agent prose is
* paragraphs, not documents. */
export function renderInlineMarkdown(text: string): string {
const escaped = escapeHtml(text);
return escaped
.replace(/`([^`]+)`/g, "<code>$1</code>")
.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
.replace(/(?<!\*)\*([^*]+)\*(?!\*)/g, "<em>$1</em>");
}
function renderProse(container: HTMLElement, text: string): void {
const paragraphs = text.split(/\n{2,}/);
for (const para of paragraphs) {
if (!para) continue;
const p = document.createElement("p");
p.innerHTML = renderInlineMarkdown(para);
container.appendChild(p);
}
if (container.children.length === 0) container.textContent = "";
}
// ---------------------------------------------------------------------------
// row builders
// ---------------------------------------------------------------------------
function buildContentRow(row: TranscriptRow, variant: "user" | "agent" | "thought"): HTMLElement {
const data = row.data as { content?: AcpContentBlock; messageId?: string } | undefined;
const text = contentBlockText(data?.content);
const wrap = el("div", `st-row st-row-${variant}`);
const body = el("div", "st-body");
if (variant === "user") {
const who = el("div", "st-who");
who.textContent = "You";
body.appendChild(who);
const line = el("div", "st-user-text");
line.textContent = text;
body.appendChild(line);
} else if (variant === "agent") {
renderProse(body, text);
} else {
body.textContent = text;
}
wrap.appendChild(body);
return wrap;
}
function buildDiffBlock(diff: { path: string; oldText?: string | null; newText: string }): HTMLElement {
const wrap = el("div", "st-diff-block");
const label = el("div", "st-diff-path");
label.textContent = diff.path;
label.title = diff.path;
wrap.appendChild(label);
const scroller = el("div", "st-diff");
const pre = document.createElement("pre");
for (const line of computeDiffLines(diff.oldText, diff.newText)) {
const span = document.createElement("span");
span.className = `st-ln st-ln-${line.kind}`;
span.textContent = line.text;
pre.appendChild(span);
pre.appendChild(document.createTextNode("\n"));
}
scroller.appendChild(pre);
wrap.appendChild(scroller);
return wrap;
}
/** `+added -removed` across every diff block in a tool call's content —
* real derived data, never a fabricated duration. */
function toolDiffMeta(content: AcpToolCallContent[]): string {
let added = 0;
let removed = 0;
for (const block of content) {
if (block.type !== "diff") continue;
const stats = diffStats(computeDiffLines(block.oldText, block.newText));
added += stats.added;
removed += stats.removed;
}
return `+${added} −${removed}`;
}
function statusMeta(status: string | undefined, hasDiff: boolean): { text: string; kind: string } | null {
if (status === "in_progress") return { text: "running", kind: "running" };
if (status === "failed") return { text: "failed", kind: "failed" };
if (status === "pending" && !hasDiff) return { text: "pending", kind: "pending" };
return null;
}
function buildToolRow(
row: TranscriptRow,
expanded: boolean,
onExpandedChange?: (expanded: boolean) => void,
): HTMLElement {
const tool = (row.data ?? {}) as AcpToolCallLike;
const content = tool.content ?? [];
const hasDiff = content.some((c) => c.type === "diff");
const { label, arg } = splitToolTitle(tool.title, tool.name, tool.kind);
const wrap = el("div", "st-row st-row-tool");
const card = el("div", "st-tool");
wrap.appendChild(card);
const head = document.createElement("button");
head.type = "button";
head.className = "st-tool-head";
head.setAttribute("aria-expanded", "false");
const glyph = el("span", "st-tool-glyph");
glyph.textContent = toolGlyph(tool.kind);
glyph.setAttribute("aria-hidden", "true");
const name = el("span", "st-tool-name");
name.textContent = label;
const argEl = el("span", "st-tool-arg");
argEl.textContent = arg;
argEl.title = arg;
head.append(glyph, name, argEl);
const metaParts: string[] = [];
if (hasDiff) metaParts.push(toolDiffMeta(content));
const meta = statusMeta(tool.status, hasDiff);
if (meta) metaParts.push(meta.text);
if (metaParts.length > 0) {
const metaEl = el("span", "st-tool-meta");
if (meta) metaEl.classList.add(`st-tool-meta-${meta.kind}`);
metaEl.textContent = metaParts.join(" · ");
head.appendChild(metaEl);
}
card.appendChild(head);
const out = el("div", "st-tool-out");
out.hidden = true;
let outBuilt = false;
const buildOut = () => {
if (outBuilt) return;
outBuilt = true;
const texts = content
.filter((c): c is Extract<AcpToolCallContent, { type: "content" }> => c.type === "content")
.map((c) => contentBlockText(c.content))
.filter(Boolean);
if (texts.length > 0) {
const textPanel = el("div", "st-tool-text");
textPanel.textContent = texts.join("\n");
out.appendChild(textPanel);
}
for (const block of content) {
if (block.type === "diff") out.appendChild(buildDiffBlock(block));
if (block.type === "terminal") {
const note = el("div", "st-tool-text");
note.textContent = "[embedded terminal]";
out.appendChild(note);
}
}
if (out.children.length === 0) out.hidden = true;
};
card.appendChild(out);
const setExpanded = (nextExpanded: boolean) => {
if (nextExpanded) buildOut();
if (out.children.length === 0) nextExpanded = false;
head.setAttribute("aria-expanded", String(nextExpanded));
out.hidden = !nextExpanded;
};
head.addEventListener("click", () => {
setExpanded(head.getAttribute("aria-expanded") !== "true");
onExpandedChange?.(head.getAttribute("aria-expanded") === "true");
});
setExpanded(expanded);
return wrap;
}
function buildPlainRow(row: TranscriptRow): HTMLElement {
const wrap = el("div", "st-row st-row-notice");
const body = el("div", "st-body");
body.textContent = `[${row.kind}]`;
wrap.appendChild(body);
return wrap;
}
export interface ApprovalRowState {
pending: boolean;
/** Resolves once the decision has been submitted; rejects when the POST
* failed and this approval is still the one awaiting a decision, so the
* caller knows to restore its controls. Never rejects for a stale click
* (the approval already moved on) — there is nothing to restore then. */
onDecide: (decision: StructuredApprovalDecision) => Promise<void>;
}
/** Which decisions a permission request actually backs, by the
* `PermissionOptionKind` values it offers (docs/STRUCTURED-VIEW.md's
* "Decision mapping"). A decision with no matching option resolves
* `Cancelled` server-side regardless of what the client sent — offering its
* button would let the operator "allow" something that silently no-ops. */
function offeredDecisions(options: AcpPermissionOption[]): StructuredApprovalDecision[] {
const kinds = new Set(options.map((o) => o.kind));
const decisions: StructuredApprovalDecision[] = [];
if (kinds.has("allow_once")) decisions.push("allow_once");
if (kinds.has("allow_always")) decisions.push("allow_for_session");
if (kinds.has("reject_once") || kinds.has("reject_always")) decisions.push("deny");
return decisions;
}
function buildApprovalRow(row: TranscriptRow, approval: ApprovalRowState): HTMLElement {
const request = (row.data ?? {}) as { toolCall?: AcpToolCallLike; options?: AcpPermissionOption[] };
const toolCall = request.toolCall ?? {};
const wrap = el("div", "st-row st-row-approval");
const card = el("div", "st-approval");
wrap.appendChild(card);
const title = el("div", "st-approval-title");
title.innerHTML = `<span aria-hidden="true">⚠</span> permission requested`;
card.appendChild(title);
const what = el("div", "st-approval-what");
what.textContent = toolCall.title ?? toolKindLabel(toolCall.kind);
card.appendChild(what);
const path = toolCall.locations?.[0]?.path;
if (path) {
const pathEl = el("div", "st-approval-path");
pathEl.textContent = path;
card.appendChild(pathEl);
}
if (approval.pending) {
const acts = el("div", "st-approval-acts");
const mk = (label: string, decision: StructuredApprovalDecision, cls: string) => {
const btn = document.createElement("button");
btn.type = "button";
btn.className = cls ? `act ${cls}` : "act";
btn.textContent = label;
btn.addEventListener("click", () => {
const buttons = [...acts.querySelectorAll<HTMLButtonElement>("button")];
buttons.forEach((b) => (b.disabled = true));
approval.onDecide(decision).catch(() => {
// Rejects only when this approval is still pending (see
// ApprovalRowState) — restore the controls so the operator can
// retry; the error itself is surfaced by the connection.
buttons.forEach((b) => (b.disabled = false));
});
});
return btn;
};
const offered = offeredDecisions(request.options ?? []);
const specs: [StructuredApprovalDecision, string, string][] = [
["allow_once", "Allow once", "primary"],
["allow_for_session", "Allow for session", ""],
["deny", "Deny", "deny"],
];
for (const [decision, label, cls] of specs) {
if (offered.includes(decision)) acts.appendChild(mk(label, decision, cls));
}
if (acts.children.length > 0) {
card.appendChild(acts);
} else {
const none = el("div", "st-approval-resolved");
none.textContent = "no decision offered";
card.appendChild(none);
}
} else {
const resolved = el("div", "st-approval-resolved");
resolved.textContent = "answered";
card.appendChild(resolved);
}
return wrap;
}
/** Build the DOM for one transcript row, or `null` for a plan shown only in
* the rail. `approval` is consulted only for an `approval` row; tool
* expansion is supplied and reported explicitly. */
export function buildTranscriptRow(
row: TranscriptRow & { kind: "plan" },
approval?: ApprovalRowState,
toolExpanded?: boolean,
onToolExpandedChange?: (expanded: boolean) => void,
): null;
export function buildTranscriptRow(
row: TranscriptRow & { kind: Exclude<TranscriptRow["kind"], "plan"> },
approval?: ApprovalRowState,
toolExpanded?: boolean,
onToolExpandedChange?: (expanded: boolean) => void,
): HTMLElement;
export function buildTranscriptRow(
row: TranscriptRow,
approval?: ApprovalRowState,
toolExpanded?: boolean,
onToolExpandedChange?: (expanded: boolean) => void,
): HTMLElement | null;
export function buildTranscriptRow(
row: TranscriptRow,
approval?: ApprovalRowState,
toolExpanded = false,
onToolExpandedChange?: (expanded: boolean) => void,
): HTMLElement | null {
let built: HTMLElement;
switch (row.kind) {
case "user":
built = buildContentRow(row, "user");
break;
case "agent_text":
built = buildContentRow(row, "agent");
break;
case "agent_thought":
built = buildContentRow(row, "thought");
break;
case "tool_call":
case "tool_diff":
built = buildToolRow(row, toolExpanded, onToolExpandedChange);
break;
case "approval":
built = buildApprovalRow(row, approval ?? { pending: false, onDecide: () => Promise.resolve() });
break;
case "plan":
return null;
default:
built = buildPlainRow(row);
break;
}
built.dataset.rowId = row.id;
return built;
}
// ---------------------------------------------------------------------------
// rail
// ---------------------------------------------------------------------------
const PLAN_MARK: Record<string, string> = { completed: "✓", in_progress: "▸", pending: "·" };
const PLAN_CLASS: Record<string, string> = { completed: "done", in_progress: "now", pending: "todo" };
function renderPlanSection(plan: AcpPlan | null | undefined): HTMLElement | null {
if (!plan || plan.entries.length === 0) return null;
const section = el("div", "st-rail-section");
const h = document.createElement("h3");
h.textContent = "Plan";
section.appendChild(h);
const list = document.createElement("ul");
list.className = "st-plan";
for (const entry of plan.entries) {
const li = document.createElement("li");
li.className = `st-plan-${PLAN_CLASS[entry.status] ?? "todo"}`;
li.setAttribute("aria-label", `${entry.status.replace("_", " ")}: ${entry.content}`);
const mark = el("span", "st-plan-mark");
mark.textContent = PLAN_MARK[entry.status] ?? "·";
mark.setAttribute("aria-hidden", "true");
const text = document.createElement("span");
text.textContent = entry.content;
li.append(mark, text);
list.appendChild(li);
}
section.appendChild(list);
return section;
}
function kv(label: string, value: string): HTMLElement {
const row = el("div", "st-kv-row");
const k = el("span", "st-kv-k");
k.textContent = label;
const v = el("span", "st-kv-v");
v.textContent = value;
row.append(k, v);
return row;
}
function renderTurnSection(
turn: { responding: boolean; thinking: boolean; active_tool_calls: string[] },
toolRowCount: number,
usage: AcpUsage | null | undefined,
): HTMLElement {
const section = el("div", "st-rail-section");
const h = document.createElement("h3");
h.textContent = "Turn";
section.appendChild(h);
const box = el("div", "st-kv");
const activity = turn.thinking ? "thinking" : turn.responding ? "responding" : "idle";
box.appendChild(kv("state", activity));
box.appendChild(kv("active tools", String(turn.active_tool_calls.length)));
box.appendChild(kv("tool calls", String(toolRowCount)));
if (usage?.cost) box.appendChild(kv("cost", `$${usage.cost.amount.toFixed(2)}`));
section.appendChild(box);
return section;
}
function renderContextSection(usage: AcpUsage | null | undefined): HTMLElement | null {
if (!usage) return null;
const section = el("div", "st-rail-section");
const h = document.createElement("h3");
h.textContent = "Context";
section.appendChild(h);
const box = el("div", "st-kv");
box.appendChild(kv("used", formatTokenCount(usage.used)));
box.appendChild(kv("window", formatTokenCount(usage.size)));
section.appendChild(box);
const meter = el("div", "st-meter");
const fill = el("i");
const pct = usage.size > 0 ? Math.min(100, (usage.used / usage.size) * 100) : 0;
fill.style.width = `${pct}%`;
meter.appendChild(fill);
section.appendChild(meter);
return section;
}
function formatTokenCount(n: number): string {
return n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n);
}
/** `SessionWorktree.path` is `<repo>/worktrees/<branch>` (docs/WORKTREES.md);
* the branch is not carried anywhere else on the wire, so derive it here. */
export function branchFromWorktreePath(path: string): string {
const marker = "/worktrees/";
const i = path.indexOf(marker);
if (i >= 0) return path.slice(i + marker.length);
return path.split("/").filter(Boolean).pop() ?? path;
}
function renderWorktreeSection(session: Session | undefined): HTMLElement | null {
const worktree = session?.worktree;
if (!worktree) return null;
const section = el("div", "st-rail-section");
const h = document.createElement("h3");
h.textContent = "Worktree";
section.appendChild(h);
const box = el("div", "st-kv");
box.appendChild(kv("branch", branchFromWorktreePath(worktree.path)));
const blocked: InTheWay | null | undefined = session?.blocked_work;
box.appendChild(kv("ahead", String(blocked?.unpushed_commits ?? 0)));
const dirty = (blocked?.uncommitted_files ?? 0) + (blocked?.untracked_files ?? 0);
box.appendChild(kv("dirty", String(dirty)));
section.appendChild(box);
return section;
}
export interface RailContext {
plan: AcpPlan | null | undefined;
turn: { responding: boolean; thinking: boolean; active_tool_calls: string[] };
toolRowCount: number;
usage: AcpUsage | null | undefined;
session: Session | undefined;
}
/** Rebuild the rail wholesale from the latest state — rails are small, so a
* full rebuild per update is simpler and cheap enough that no incremental
* patching is worth the complexity. */
export function renderRail(container: HTMLElement, ctx: RailContext): void {
const sections = [
renderPlanSection(ctx.plan),
renderTurnSection(ctx.turn, ctx.toolRowCount, ctx.usage),
renderContextSection(ctx.usage),
renderWorktreeSection(ctx.session),
].filter((s): s is HTMLElement => s !== null);
container.replaceChildren(...sections);
}