// The cockpit STRUCTURED transcript view — derived from the one ORDERED DISPLAY projection (#566, #757). // // Beside the byte-level replay (`transcript-render.ts` feeds stored chunks through the live terminal // renderer for pixel-faithful playback), the cockpit shows a STRUCTURED view of a captured session. That // view is a DERIVATION of the one typed event log — it re-parses nothing. It folds the stored chunks // through the single {@link parseTranscriptEvent} parser and the canonical ORDERED DISPLAY projection // ({@link createDisplayProjection}, agentic #566): the projection coalesces transport-fragmented message // deltas ("I", "not", "ice I am act", …) back into ONE growing block per logical message, and interleaves // text blocks, tool cards and permission prompts in strict chronological (offset) order. So a split word // reconstructs into exactly that word in one coherent block — never one bordered card per delta — and a // tool call issued mid-message renders between the text before and after it (issue #757). The raw log, // its offsets and byte-faithful replay are untouched; the drift-guard test enforces this module never // parses chunks itself. // // INCREMENTAL, not rebuild-on-every-chunk. {@link createIncrementalTranscript} keeps the display // projection and a `blockId → DOM node` map as mutable state, so a live delta updates the ONE active // block's node in place (append a new node, or patch an existing one) instead of rebuilding the whole // transcript tree. That is what lets the browser adapter (`pages/cockpit/mount.js`) preserve the // operator's selection, expansion and scroll position and auto-follow only at the tail. {@link // renderDerivedTranscript} is the pure batch convenience over the same fold — historical replay renders // IDENTICALLY to the final live rendering because both drive the one incremental renderer. // // Framework-free and DOM-agnostic, like the sibling cockpit views: it draws into the injected {@link // DocumentLike} subset so a real DOM satisfies it at runtime and an in-memory fake satisfies it for // DOM-free Node tests. import type { DocumentLike, ElementLike } from "@nanobpm/agentic/cockpit"; import { createDisplayProjection } from "../transcript-display.ts"; import { type DerivedPermission, type DerivedTool, type DerivedView, type DisplayBlock, type DisplayGapBlock, type DisplayProjection, type DisplayTextBlock, deriveViewFromChunks, optionKindAllows, parseTranscriptEvent, type StoredChunk, type TranscriptEvent, utf8ByteLength, } from "../transcript-events.ts"; import type { TranscriptDataReport } from "./transcript-render.ts"; /** * Derive the structured (event-fold) view of a fetched transcript page — the flat message/tool/permission * history, per-turn structure and raw-byte accounting. This is the {@link DerivedView} fold, kept for the * raw-fidelity footer and summary counts; the ordered, human-facing block SEQUENCE is the separate * display projection {@link renderDerivedTranscript} draws. Pure: the cockpit reads THESE instead of * re-parsing raw frame bytes. */ export function deriveTranscript(data: TranscriptDataReport): DerivedView { return deriveViewFromChunks(data.entries); } function el(doc: DocumentLike, tag: string, className?: string, text?: string): ElementLike { const node = doc.createElement(tag); if (className !== undefined) node.className = className; if (text !== undefined) node.textContent = text; return node; } /** Handles into the rendered structured tree the caller may need. */ export interface DerivedTranscriptDom { readonly root: ElementLike; } /** * Options for {@link renderDerivedTranscript}. This is the SHARED SEAM the wave-2 escalation bridge * attaches its handler to: an escalate-policy permission prompt's Allow/Deny buttons invoke * {@link onPermissionResolve} on click (mirroring how `transcript-render.ts` wires `onReplay`). The * render itself only *invokes* the callback — the relay round-trip that actually releases the blocked * agent lives in the bridge, not here. Optional/defaulted so the 3-arg call sites keep working. */ export interface RenderDerivedTranscriptOptions { /** * Called when the operator picks an Allow/Deny option on a pending `escalate` permission prompt. The * resolution shape is the minimal `{ callId, optionId, allowed }` the bridge folds into a * `permission` RESOLUTION frame — `allowed` is derived from the chosen option's kind (allow-* ⇒ true, * reject-* ⇒ false). Yolo requests never prompt, so this never fires for a yolo policy. */ readonly onPermissionResolve?: (resolution: { callId: string; optionId: string; allowed: boolean }) => void; } /** A single classified line of a rendered diff block. */ type DiffLineKind = "add" | "del" | "ctx"; interface DiffLine { readonly kind: DiffLineKind; readonly text: string; } interface DetectedDiff { readonly lines: readonly DiffLine[]; /** Where the diff came from — so the raw `args`/`result` content isn't ALSO rendered redundantly. */ readonly source: "args" | "result"; } /** Render an arbitrary derived value (tool args/result) as displayable text without re-parsing the log. */ function toText(value: unknown): string { if (typeof value === "string") return value; if (value === undefined) return ""; return JSON.stringify(value, null, 2); } /** Read the first string-valued field among `keys` off an object, without an `as` cast. */ function pickString(obj: object, keys: readonly string[]): string | undefined { for (const key of keys) { const value = Reflect.get(obj, key); if (typeof value === "string") return value; } return undefined; } /** Classify one line of a unified diff (file/hunk headers are context, not add/del). */ function classifyUnifiedLine(line: string): DiffLineKind { if (line.startsWith("+++") || line.startsWith("---") || line.startsWith("@@") || line.startsWith("diff ")) return "ctx"; if (line.startsWith("+")) return "add"; if (line.startsWith("-")) return "del"; return "ctx"; } /** Heuristic: does this string look like a unified diff (a hunk header, or paired +/- content lines)? */ function looksLikeUnifiedDiff(text: string): boolean { if (text.length === 0) return false; let add = false; let del = false; let hunk = false; for (const line of text.split("\n")) { if (line.startsWith("@@") || line.startsWith("diff --git")) hunk = true; else if (line.startsWith("+++") || line.startsWith("---")) continue; else if (line.startsWith("+")) add = true; else if (line.startsWith("-")) del = true; } return hunk || (add && del); } /** Split a unified-diff string into classified lines (dropping a single trailing empty line). */ function parseUnifiedDiff(text: string): DiffLine[] { const lines = text.split("\n"); if (lines.length > 0 && lines[lines.length - 1] === "") lines.pop(); return lines.map((line) => ({ kind: classifyUnifiedLine(line), text: line })); } /** Split a block of text into lines, dropping a single trailing empty segment (text ending in "\n"). */ function splitTextLines(text: string): string[] { const lines = text.split("\n"); if (lines.length > 0 && lines[lines.length - 1] === "") lines.pop(); return lines; } /** Synthesize a diff from structured edit args (`{ path?, oldText/old_string, newText/new_string }`). */ function structuredDiff(args: unknown): DiffLine[] | undefined { if (typeof args !== "object" || args === null) return undefined; const oldText = pickString(args, ["oldText", "old_string", "oldStr", "old", "before"]); const newText = pickString(args, ["newText", "new_string", "newStr", "new", "after"]); if (oldText === undefined && newText === undefined) return undefined; const lines: DiffLine[] = []; const path = pickString(args, ["path", "file", "filePath", "fileName"]); if (path !== undefined) lines.push({ kind: "ctx", text: `diff --git a/${path} b/${path}` }); if (oldText !== undefined && oldText.length > 0) { for (const line of splitTextLines(oldText)) lines.push({ kind: "del", text: `-${line}` }); } if (newText !== undefined && newText.length > 0) { for (const line of splitTextLines(newText)) lines.push({ kind: "add", text: `+${line}` }); } return lines.length > 0 ? lines : undefined; } /** Detect diff-shaped content on a tool call/result — a unified-diff string or structured edit args. */ function detectDiff(tool: DerivedTool): DetectedDiff | undefined { const content = tool.result?.content; if (typeof content === "string" && looksLikeUnifiedDiff(content)) { return { lines: parseUnifiedDiff(content), source: "result" }; } if (typeof tool.args === "string" && looksLikeUnifiedDiff(tool.args)) { return { lines: parseUnifiedDiff(tool.args), source: "args" }; } const structured = structuredDiff(tool.args); if (structured !== undefined) return { lines: structured, source: "args" }; return undefined; } /** Fill an EXISTING tool card node with one tool's content (name, status, args/result, diff block). Clears * the node first so it is safe to re-invoke in place when the tool's result later arrives (bounded to * this one card — no sibling block is touched). */ function applyTool(card: ElementLike, doc: DocumentLike, tool: DerivedTool): void { card.replaceChildren(); card.setAttribute("data-tool", tool.name); card.setAttribute("data-offset", String(tool.offset)); card.setAttribute("data-status", tool.result === undefined ? "pending" : tool.result.ok ? "ok" : "error"); card.appendChild(el(doc, "div", "cockpit-transcript-tool-name", tool.name)); const diff = detectDiff(tool); if (diff !== undefined) card.setAttribute("data-tool-kind", "diff"); // Show the raw args unless the diff was synthesized FROM the args (then the diff block replaces it). if (tool.args !== undefined && !(diff !== undefined && diff.source === "args")) { const argsEl = el(doc, "pre", "cockpit-transcript-tool-args", toText(tool.args)); argsEl.setAttribute("data-tool-args", "true"); card.appendChild(argsEl); } if (diff !== undefined) { const pre = el(doc, "pre", "cockpit-transcript-diff"); pre.setAttribute("data-diff", "true"); for (const line of diff.lines) { // A
 may only contain phrasing content, so each diff line is a phrasing 
      // (not a block 
, which would be invalid markup) carrying a trailing "\n". The // enclosing
 preserves that newline, so lines break onto their own line without
      // depending on host CSS forcing display:block.
      const row = el(doc, "span", "cockpit-transcript-diff-line", `${line.text}\n`);
      row.setAttribute("data-diff-line", line.kind);
      pre.appendChild(row);
    }
    card.appendChild(pre);
  }

  // Render the result content unless it was itself consumed as the diff source (source === "result").
  if (typeof tool.result?.content === "string" && !(diff !== undefined && diff.source === "result")) {
    const resEl = el(doc, "pre", "cockpit-transcript-tool-result", tool.result.content);
    resEl.setAttribute("data-tool-result", "true");
    card.appendChild(resEl);
  }
}

/** Render one tool card: name, status, args + result content, and a distinguishable diff block. */
function renderTool(doc: DocumentLike, tool: DerivedTool): ElementLike {
  const card = el(doc, "div", "cockpit-transcript-tool");
  applyTool(card, doc, tool);
  return card;
}

/**
 * Render one permission prompt card from a {@link DerivedPermission}:
 * - a pending `escalate` request → interactive Allow/Deny buttons wired to `onPermissionResolve`;
 * - a `yolo` request → informational only (yolo auto-allows, it never prompts a human);
 * - a resolved permission → settled (`allowed`/`denied`), showing the chosen option, no live buttons.
 */
function applyPermission(card: ElementLike, doc: DocumentLike, perm: DerivedPermission, options: RenderDerivedTranscriptOptions): void {
  card.replaceChildren();
  card.setAttribute("data-permission", "request");
  card.setAttribute("data-policy", perm.policy);
  card.setAttribute("data-call-id", perm.callId);
  card.setAttribute("data-offset", String(perm.offset));
  if (perm.toolName !== undefined) card.setAttribute("data-tool", perm.toolName);
  if (perm.title !== undefined) card.appendChild(el(doc, "div", "cockpit-transcript-permission-title", perm.title));
  if (perm.reason !== undefined) card.appendChild(el(doc, "div", "cockpit-transcript-permission-reason", perm.reason));

  if (perm.resolved !== undefined) {
    // Settled: show which option was chosen and no live buttons.
    card.setAttribute("data-status", perm.resolved.allowed ? "allowed" : "denied");
    const chosen = perm.options.find((option) => option.optionId === perm.resolved?.optionId);
    const settled = el(doc, "div", "cockpit-transcript-permission-settled", chosen?.name ?? perm.resolved.optionId);
    settled.setAttribute("data-chosen-option", perm.resolved.optionId);
    if (perm.resolved.by !== undefined) settled.setAttribute("data-by", perm.resolved.by);
    card.appendChild(settled);
    return;
  }

  if (perm.policy === "yolo") {
    // Informational: yolo auto-allows and never prompts a human, so no Allow/Deny buttons.
    card.setAttribute("data-status", "auto");
    card.appendChild(el(doc, "div", "cockpit-transcript-permission-note", "Auto-allowed (yolo) — no operator prompt."));
    return;
  }

  // Pending escalate: one interactive button per offered option, wired to the resolve seam.
  card.setAttribute("data-status", "pending");
  const actions = el(doc, "div", "cockpit-transcript-permission-actions");
  for (const option of perm.options) {
    const allowed = optionKindAllows(option.kind);
    const button = el(doc, "button", "cockpit-transcript-permission-option", option.name);
    button.setAttribute("type", "button");
    button.setAttribute("data-option-id", option.optionId);
    button.setAttribute("data-option-kind", option.kind);
    button.setAttribute("data-allowed", String(allowed));
    const onPermissionResolve = options.onPermissionResolve;
    if (onPermissionResolve !== undefined) {
      button.addEventListener("click", () => onPermissionResolve({ callId: perm.callId, optionId: option.optionId, allowed }));
    }
    actions.appendChild(button);
  }
  card.appendChild(actions);
}

/**
 * Render one permission prompt card from a {@link DerivedPermission}:
 * - a pending `escalate` request → interactive Allow/Deny buttons wired to `onPermissionResolve`;
 * - a `yolo` request → informational only (yolo auto-allows, it never prompts a human);
 * - a resolved permission → settled (`allowed`/`denied`), showing the chosen option, no live buttons.
 */
function renderPermission(doc: DocumentLike, perm: DerivedPermission, options: RenderDerivedTranscriptOptions): ElementLike {
  const card = el(doc, "div", "cockpit-transcript-permission");
  applyPermission(card, doc, perm, options);
  return card;
}

/** Fill an EXISTING text-block node with a coalesced message's text + offsets. The block is ONE growing
 *  node per logical message — a delta patches this node's `textContent` in place (never a new card per
 *  fragment), so a split word reconstructs into exactly that word. */
function applyText(node: ElementLike, block: DisplayTextBlock): void {
  node.setAttribute("data-role", block.role);
  node.setAttribute("data-offset", String(block.startOffset));
  node.setAttribute("data-end-offset", String(block.endOffset));
  node.setAttribute("data-block-id", block.id);
  if (block.messageId !== undefined) node.setAttribute("data-message-id", block.messageId);
  node.setAttribute("data-complete", String(block.complete));
  node.textContent = block.text;
}

/** Render one coalesced-message text block (a single growing node). */
function renderText(doc: DocumentLike, block: DisplayTextBlock): ElementLike {
  const node = el(doc, "div", "cockpit-transcript-message");
  applyText(node, block);
  return node;
}

/** Fill an EXISTING retention-gap node. A gap is a first-class visible break so a reattach that dropped
 *  chunks never implies the surrounding text is continuous; its `beforeOffset` is anchored once the first
 *  post-gap block opens. */
function applyGap(node: ElementLike, block: DisplayGapBlock): void {
  node.setAttribute("data-gap", "true");
  node.setAttribute("data-block-id", block.id);
  if (block.beforeOffset !== undefined) node.setAttribute("data-before-offset", String(block.beforeOffset));
  node.textContent = "⋯ retained-data gap — earlier output was evicted ⋯";
}

/** Render one retention-gap block. */
function renderGap(doc: DocumentLike, block: DisplayGapBlock): ElementLike {
  const node = el(doc, "div", "cockpit-transcript-gap");
  applyGap(node, block);
  return node;
}

/** Build a fresh DOM node for any display block kind. */
function renderBlock(doc: DocumentLike, block: DisplayBlock, options: RenderDerivedTranscriptOptions): ElementLike {
  switch (block.kind) {
    case "text":
      return renderText(doc, block);
    case "tool":
      return renderTool(doc, block.tool);
    case "permission":
      return renderPermission(doc, block.permission, options);
    case "gap":
      return renderGap(doc, block);
  }
}

/** Patch an EXISTING block node in place (bounded to that one block — no sibling node is touched). */
function patchBlock(node: ElementLike, doc: DocumentLike, block: DisplayBlock, options: RenderDerivedTranscriptOptions): void {
  switch (block.kind) {
    case "text":
      applyText(node, block);
      return;
    case "tool":
      applyTool(node, doc, block.tool);
      return;
    case "permission":
      applyPermission(node, doc, block.permission, options);
      return;
    case "gap":
      applyGap(node, block);
      return;
  }
}

/**
 * A STATEFUL, incremental transcript renderer over a `host` element. It maintains the ordered
 * {@link DisplayBlock} sequence (via the canonical {@link createDisplayProjection}) and a `blockId → DOM
 * node` map, so feeding it one live chunk at a time updates just the ONE touched block's node in place —
 * append a brand-new block node, or patch an existing block's node (a growing text delta, a tool result
 * pairing, a permission resolution, or a now-anchored gap) — instead of rebuilding the whole transcript
 * tree. That bounded update is what lets the browser adapter preserve selection, expansion and scroll.
 *
 * DOM shape (stable across live growth so unaffected nodes are never replaced):
 *   div.cockpit-transcript-derived[data-*]
 *     div.cockpit-transcript-blocks   ← ordered block nodes are appended here / patched in place
 *     div.cockpit-transcript-empty    ← shown (data-empty="true") only while there are zero blocks
 *     footer.cockpit-transcript-raw   ← retained raw bytes/chunks (byte-replay is preserved alongside)
 */
export interface IncrementalTranscript {
  /** The rendered root (a `cockpit-transcript-derived` element) appended under the host. */
  readonly root: ElementLike;
  /**
   * Fold ONE stored chunk (by offset) into the display and update the DOM minimally. Idempotent on
   * offset — re-feeding an already-applied offset (reconnect, pagination overlap, a duplicated chunk) is
   * a no-op, so replayed text never doubles. Feed chunks in offset order (the projection drops a late
   * lower offset rather than merging it out of place).
   */
  applyChunk(chunk: StoredChunk): void;
  /**
   * Record a retention gap at the current tail BEFORE feeding the post-gap chunks: the consumer resumed
   * from an offset older than the oldest retained chunk, so what follows is NOT continuous with what
   * precedes. Renders a visible break; its `beforeOffset` is anchored when the next block opens.
   */
  noteGap(): void;
  /** A snapshot of the ordered display blocks as they stand now (for tests/inspection). */
  blocks(): readonly DisplayBlock[];
}

/** Running summary tallies for the root attributes + raw footer, maintained WITHOUT re-folding. */
interface Tallies {
  text: number;
  tool: number;
  permission: number;
  gap: number;
  turns: number;
  rawBytes: number;
  rawChunks: number;
  lifecycle: "open" | "completed" | "exited";
}

/**
 * Build the incremental renderer's stable DOM scaffold under `host` and return the mutable render state.
 * Shared by {@link createIncrementalTranscript} (live) and {@link renderDerivedTranscript} (batch) so
 * historical replay renders IDENTICALLY to the final live rendering.
 */
export function createIncrementalTranscript(
  host: ElementLike,
  doc: DocumentLike,
  stream: string,
  options: RenderDerivedTranscriptOptions = {},
): IncrementalTranscript {
  host.replaceChildren();
  const projection: DisplayProjection = createDisplayProjection();
  const nodes = new Map();
  const tallies: Tallies = { text: 0, tool: 0, permission: 0, gap: 0, turns: 0, rawBytes: 0, rawChunks: 0, lifecycle: "open" };
  // A turn opens implicitly before the first structured block even without an explicit `turn` event
  // (mirrors deriveView's implicit turn 0), so any structured content means at least one turn.
  let structured = false;

  const root = el(doc, "div", "cockpit-transcript-derived");
  root.setAttribute("data-stream", stream);
  const blocksHost = el(doc, "div", "cockpit-transcript-blocks");
  const empty = el(doc, "div", "cockpit-transcript-empty");
  const footer = el(doc, "footer", "cockpit-transcript-raw");
  root.appendChild(blocksHost);
  root.appendChild(empty);
  root.appendChild(footer);
  host.appendChild(root);

  function refreshSummary(): void {
    const turnCount = tallies.turns > 0 ? tallies.turns : structured ? 1 : 0;
    root.setAttribute("data-lifecycle", tallies.lifecycle);
    root.setAttribute("data-turn-count", String(turnCount));
    root.setAttribute("data-message-count", String(tallies.text));
    root.setAttribute("data-tool-count", String(tallies.tool));
    root.setAttribute("data-permission-count", String(tallies.permission));
    root.setAttribute("data-gap-count", String(tallies.gap));
    root.setAttribute("data-block-count", String(nodes.size));

    const hasBlocks = tallies.text + tallies.tool + tallies.permission > 0;
    // Toggle (never remove — ElementLike has no removeChild) so a live first block clears the empty note
    // without rebuilding, and an all-raw page still shows exactly one data-empty="true" element.
    empty.setAttribute("data-empty", String(!hasBlocks));
    empty.textContent = hasBlocks ? "" : "No structured events derived — raw replay only.";

    footer.setAttribute("data-raw-bytes", String(tallies.rawBytes));
    footer.setAttribute("data-raw-chunks", String(tallies.rawChunks));
    footer.textContent = `${tallies.rawChunks} raw chunk(s) · ${tallies.rawBytes} B retained for replay`;
  }

  function countAppended(block: DisplayBlock): void {
    structured = structured || block.kind !== "gap";
    if (block.kind === "text") tallies.text++;
    else if (block.kind === "tool") tallies.tool++;
    else if (block.kind === "permission") tallies.permission++;
    else tallies.gap++;
  }

  /** Reconcile ONE projection apply-result into the DOM: append a new node, patch an existing one, and/or
   *  patch a secondary now-anchored gap. Bounded to the touched block(s) — no unaffected node is replaced. */
  function reconcile(changed: DisplayBlock | undefined, appended: boolean, anchored: DisplayBlock | undefined): void {
    if (changed !== undefined) {
      if (appended) {
        const node = renderBlock(doc, changed, options);
        nodes.set(changed.id, node);
        blocksHost.appendChild(node);
        countAppended(changed);
      } else {
        const node = nodes.get(changed.id);
        if (node !== undefined) patchBlock(node, doc, changed, options);
      }
    }
    if (anchored !== undefined) {
      const node = nodes.get(anchored.id);
      if (node !== undefined) patchBlock(node, doc, anchored, options);
    }
  }

  function applyEvent(event: TranscriptEvent): void {
    // Raw bytes feed the byte-replay footer but produce no display block (the projection ignores them).
    if (event.kind === "stream-chunk") {
      tallies.rawChunks++;
      tallies.rawBytes += utf8ByteLength(event.chunk);
    } else if (event.kind === "turn") {
      tallies.turns++;
    } else if (event.kind === "lifecycle") {
      tallies.lifecycle = event.phase;
    }
    const result = projection.apply(event);
    reconcile(result.changed, result.appended, result.anchored);
  }

  refreshSummary();

  return {
    root,
    applyChunk(chunk: StoredChunk): void {
      applyEvent(parseTranscriptEvent(chunk));
      refreshSummary();
    },
    noteGap(): void {
      const result = projection.noteGap();
      reconcile(result.changed, result.appended, result.anchored);
      refreshSummary();
    },
    blocks(): readonly DisplayBlock[] {
      return projection.blocks();
    },
  };
}

/**
 * Render the DERIVED, ORDERED display of a fetched transcript page into `host`, replacing whatever was
 * there — the pure BATCH convenience over {@link createIncrementalTranscript}. Draws one growing text
 * block per logical message, with tool/diff cards and permission prompts interleaved in chronological
 * (offset) order, plus a raw-fidelity footer (retained bytes/chunks) so the byte-replay stays visibly
 * preserved. A retention `gap` on the page (`data.gap`) renders a leading visible break. Idempotent —
 * call again on each refresh. Everything it shows is a derivation of the one event log, and it renders
 * IDENTICALLY to the final live rendering (same incremental fold). `options.onPermissionResolve`, when
 * provided, is invoked by a pending escalate-permission prompt's Allow/Deny buttons.
 */
export function renderDerivedTranscript(
  host: ElementLike,
  doc: DocumentLike,
  data: TranscriptDataReport,
  options: RenderDerivedTranscriptOptions = {},
): DerivedTranscriptDom {
  const incremental = createIncrementalTranscript(host, doc, data.stream, options);
  // A page-level retention gap precedes the page's first chunk: note it before folding so a leading
  // visible break renders (a reattach that dropped chunks never implies false continuity).
  if (data.gap) incremental.noteGap();
  // The batch path folds the SAME chunks the live path does, in offset order, through the ONE projection,
  // so historical and live rendering are byte-for-byte the same tree.
  for (const chunk of [...data.entries].sort((a, b) => a.offset - b.offset)) incremental.applyChunk(chunk);
  return { root: incremental.root };
}