554 lines
19 KiB
TypeScript
554 lines
19 KiB
TypeScript
// 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);
|
||
}
|