// 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 = { read: "≡", edit: "±", delete: "✕", move: "→", search: "⌕", execute: "$", think: "…", fetch: "⇣", switch_mode: "⇄", }; const KIND_LABEL: Record = { 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, "$1") .replace(/\*\*([^*]+)\*\*/g, "$1") .replace(/(?$1"); } 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 => 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; } /** 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 = ` 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("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 }, 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 = { completed: "✓", in_progress: "▸", pending: "·" }; const PLAN_CLASS: Record = { 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 `/worktrees/` (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); }