diff --git a/CHANGELOG.md b/CHANGELOG.md index 334eca4..4312a4d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,16 @@ - Add Pi `/braid:review [target or focus]`: three parallel reviewers, narrow fixes in isolated worktrees, and at most three review rounds before summarizing and integrating reviewed fixes. +- Add `/braid ` to open the Pi panel on one node's + latest or exact invocation, with live status, elapsed time, token and context + usage, tool calls, workspace, and full output or error. Backspace returns to + the graph; Tab completes job handles and node IDs. Focused `braid_status` + results show the same details at the time of the read. + +### Fixes + +- Escape terminal controls in Pi node completions and preserve exact node IDs + containing leading/trailing whitespace or quotes with JSON string syntax. ## 0.3.1 — 2026-10-04 diff --git a/integrations/pi/README.md b/integrations/pi/README.md index a1163d7..2381f98 100644 --- a/integrations/pi/README.md +++ b/integrations/pi/README.md @@ -225,6 +225,17 @@ Use Left/Right to select jobs, Up/Down or Page Up/Page Down to scroll, `c` to cancel the selected job, and Escape or `q` to close the panel. Closing the panel leaves jobs running. In RPC or noninteractive modes, use `braid_status`. +Run `/braid ` to open the panel on one node's latest invocation, +or `/braid ` for an exact invocation such as an earlier loop +iteration. The node view refreshes live with its status, model, elapsed time, +token usage, context size, tool-call count, workspace, and full output or error. +Press Backspace to return to the whole graph; switching jobs also returns to it. +Tab completes job handles and then that job's node IDs. +IDs with leading/trailing whitespace, terminal controls, or a leading double +quote use JSON string syntax, such as `/braid job-1 " review "` or +`/braid job-1 "review\nstep"`. Tab inserts this form automatically and displays +control characters as escapes. + The panel renders a Mermaid flowchart, node states, elapsed times, context-token estimates or provider-reported usage, context-window sizes, filesystem tool-call counts, and the execution log. Active nodes are marked `▶ ACTIVE`. The status diff --git a/integrations/pi/command.ts b/integrations/pi/command.ts index 099afad..90cf14d 100644 --- a/integrations/pi/command.ts +++ b/integrations/pi/command.ts @@ -4,11 +4,12 @@ import { stripTerminalSequences, truncateToWidth, visibleWidth, + type AutocompleteItem, type Component, type TUI, } from "@earendil-works/pi-tui"; -import { BraidJobs } from "./jobs.js"; -import { renderGraphResult } from "./display.js"; +import { BraidJobs, type JobSnapshot } from "./jobs.js"; +import { renderGraphResult, renderNodeResult } from "./display.js"; // Match the overlay height exactly so Pi never clips the footer or bottom border. const panelHeight = (rows: number): number => @@ -17,9 +18,13 @@ const panelHeight = (rows: number): number => const plain = (value: string): string => stripTerminalSequences(value).replace(/\p{Cc}/gu, " "); +/** A node definition (latest invocation) or one exact execution. */ +export type NodeFocus = { nodeId: string } | { executionId: string }; + /** A live, scrollable panel. Closing the panel leaves jobs running. */ export class BraidPanel implements Component { private selected: string | undefined; + private focus: NodeFocus | undefined; private offset = 0; private maxOffset = 0; private disposed = false; @@ -32,8 +37,10 @@ export class BraidPanel implements Component { private readonly theme: Theme, private readonly done: () => void, jobId?: string, + focus?: NodeFocus, ) { this.selected = jobId ? jobs.get(jobId)?.jobId ?? jobId : undefined; + this.focus = focus; this.unsubscribe = jobs.subscribe(() => this.refresh()); this.ticker = setInterval(() => this.refresh(), 1000); this.ticker.unref(); @@ -78,8 +85,11 @@ export class BraidPanel implements Component { : current?.status === "failed" ? "error" : "muted"; + const focusLabel = this.focus + ? "nodeId" in this.focus ? `node ${plain(this.focus.nodeId)}` : `execution ${plain(this.focus.executionId)}` + : ""; const heading = current - ? `Job ${index + 1} of ${jobs.length} · ${this.theme.fg(statusColor, current.status)}` + ? `Job ${index + 1} of ${jobs.length} · ${this.theme.fg(statusColor, current.status)}${focusLabel ? ` · ${focusLabel}` : ""}` : this.theme.fg("muted", "No background jobs"); // Reserve a body row and the close hint even in a very short terminal. if (rows < 10) { @@ -112,7 +122,9 @@ export class BraidPanel implements Component { ), rule("├", "┤"), ]; - const content = current + const content = current && this.focus + ? this.renderFocus(current, this.focus, contentWidth) + : current ? [ ...(current.error ? [this.theme.fg("error", plain(current.error))] @@ -138,8 +150,13 @@ export class BraidPanel implements Component { const body = content.slice(this.offset, this.offset + height); while (body.length < height) body.push(""); const range = `Lines ${this.offset + 1}–${Math.min(content.length, this.offset + height)}/${content.length}`; - const help = - contentWidth >= 72 + const help = this.focus + ? contentWidth >= 72 + ? "⌫ graph ←/→ jobs ↑/↓ scroll PgUp/PgDn page c cancel Esc close" + : contentWidth >= 42 + ? "⌫ graph · ↑/↓ scroll · c cancel · Esc close" + : "⌫ graph · Esc close" + : contentWidth >= 72 ? "←/→ jobs ↑/↓ scroll PgUp/PgDn page c cancel Esc close" : contentWidth >= 42 ? "←/→ jobs · ↑/↓ scroll · c cancel · Esc close" @@ -159,6 +176,27 @@ export class BraidPanel implements Component { ]; } + /** A focused node can disappear after a live graph update; say so instead of failing. */ + private renderFocus(job: JobSnapshot, focus: NodeFocus, width: number): string[] { + let node; + try { + node = "nodeId" in focus + ? this.jobs.getNode(job.jobId, focus.nodeId) + : this.jobs.getNode(job.jobId, undefined, focus.executionId); + } catch { + return [ + this.theme.fg("warning", "This node or execution is no longer in the job's graph."), + this.theme.fg("muted", "Press Backspace to return to the graph."), + ]; + } + // Latest-node results omit the loop iteration; their execution record has it. + const executions = job.result?.executions ?? job.execution?.executions; + const iteration = (node as { iteration?: number }).iteration ?? + (node.executionId ? executions?.[node.executionId]?.iteration : undefined); + return renderNodeResult(iteration === undefined ? node : { ...node, iteration }, true, this.theme, undefined, job.live.progress[node.id], job.live.observedAt) + .render(width); + } + handleInput(data: string): void { if ( matchesKey(data, "escape") || @@ -177,6 +215,10 @@ export class BraidPanel implements Component { if (matchesKey(data, "left") || matchesKey(data, "right")) { const step = matchesKey(data, "right") ? 1 : -1; this.selected = jobs[(index + step + jobs.length) % jobs.length]?.jobId; + this.focus = undefined; + this.offset = 0; + } else if (matchesKey(data, "backspace") && this.focus) { + this.focus = undefined; this.offset = 0; } else if (matchesKey(data, "up")) this.offset = Math.max(0, this.offset - 1); @@ -258,20 +300,23 @@ export function registerBraidWidget(pi: ExtensionAPI, jobs: BraidJobs): () => vo export function registerBraidCommand(pi: ExtensionAPI, jobs: BraidJobs): void { pi.registerCommand("braid", { - description: "Open the live background-job flow panel: /braid [jobId]", + description: + "Open the live background-job flow panel: /braid [jobId [nodeId|executionId]]", + getArgumentCompletions: (prefix) => braidCompletions(jobs, prefix), handler: async (args, ctx) => { if (ctx.mode !== "tui") throw new Error( "The Braid panel requires interactive Pi. Use braid_status for job status.", ); - const jobId = args.trim() || undefined; + const [jobId, target] = splitArguments(args.trim()); if (jobId && !jobs.get(jobId)) throw jobs.unknownJob(jobId); + const focus = jobId && target ? resolveFocus(jobs, jobId, target) : undefined; let panel: BraidPanel | undefined; try { await ctx.ui.custom( (tui, theme, _keys, done) => { - panel = new BraidPanel(jobs, tui, theme, done, jobId); + panel = new BraidPanel(jobs, tui, theme, done, jobId, focus); return panel; }, { @@ -289,3 +334,64 @@ export function registerBraidCommand(pi: ExtensionAPI, jobs: BraidJobs): void { }, }); } + +/** Job IDs never contain whitespace; node IDs may, so the rest is one target. */ +function splitArguments(text: string): [string | undefined, string | undefined] { + const split = text.search(/\s/); + if (split < 0) return [text || undefined, undefined]; + const jobId = text.slice(0, split); + const target = text.slice(split).trim(); + if (!target.startsWith('"')) return [jobId, target || undefined]; + try { + return [jobId, JSON.parse(target) as string]; + } catch { + throw new Error('A quoted Braid target must be a valid JSON string, for example " review ".'); + } +} + +/** Quote IDs that would otherwise be trimmed, parsed as quotes, or rendered as controls. */ +function formatTarget(id: string): string { + if (id === id.trim() && !id.startsWith('"') && !/[\p{Cc}\u2028\u2029]/u.test(id)) return id; + return JSON.stringify(id).replace(/\u2028/gu, "\\u2028").replace(/\u2029/gu, "\\u2029"); +} + +/** Node IDs take precedence; anything else must be an exact execution ID. */ +function resolveFocus(jobs: BraidJobs, jobId: string, target: string): NodeFocus { + try { + jobs.getNode(jobId, target); + return { nodeId: target }; + } catch {} + try { + jobs.getNode(jobId, undefined, target); + return { executionId: target }; + } catch {} + const handle = jobs.get(jobId)?.handle ?? jobId; + throw new Error( + `Unknown Braid node or execution "${plain(target)}" in ${handle}. Open /braid ${handle} to see its nodes.`, + ); +} + +/** Pi replaces the whole argument text, so node suggestions repeat the job handle. */ +function braidCompletions(jobs: BraidJobs, prefix: string): AutocompleteItem[] | null { + const text = prefix.trimStart(); + const split = text.search(/\s/); + const jobPrefix = split < 0 ? text : text.slice(0, split); + if (split < 0) { + const items = jobs.list() + .filter((job) => job.handle.startsWith(jobPrefix)) + .map((job) => ({ value: job.handle, label: job.handle, description: `${job.status} · ${plain(job.goal)}` })); + return items.length ? items : null; + } + const nodePrefix = text.slice(split).trimStart(); + const job = jobs.get(jobPrefix); + if (!job) return null; + const nodes = job.result?.nodes ?? job.execution?.nodes ?? job.live.nodes; + const items = Object.values(nodes) + .flatMap((node) => { + const target = formatTarget(node.id); + return node.id.startsWith(nodePrefix) || target.startsWith(nodePrefix) + ? [{ value: `${job.handle} ${target}`, label: target, description: node.status }] + : []; + }); + return items.length ? items : null; +} diff --git a/integrations/pi/display.ts b/integrations/pi/display.ts index c49340a..85bd47e 100644 --- a/integrations/pi/display.ts +++ b/integrations/pi/display.ts @@ -528,12 +528,34 @@ export function applyProgress( } /** Focused status reads must show the requested execution, including historical ones. */ -export function renderNodeResult(node: NodeResult, expanded: boolean, theme: Palette, fullOutputPath?: string): Component { +export function renderNodeResult( + node: NodeResult & { iteration?: number }, + expanded: boolean, + theme: Palette, + fullOutputPath?: string, + progress?: PiNodeProgress, + now = Date.now(), +): Component { const failed = node.status === "failed"; const clean = (value: string) => stripTerminalSequences(value).replace(/[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/gu, ""); + const timing = [ + ...(node.model ? [`model ${compact(node.model, 60)}`] : []), + ...(node.iteration ? [`iteration #${node.iteration}`] : []), + ...(node.startedAt !== undefined || node.latencyMs !== undefined ? [`elapsed ${nodeElapsed(node, now)}`] : []), + ...(node.usage ? [`${compactCount(node.usage.inputTokens)} in / ${compactCount(node.usage.outputTokens)} out tokens`] : []), + ]; + // Progress is tracked per node ID; only show it for the execution it describes. + const live = progress && (!progress.executionId || progress.executionId === node.executionId) ? progress : undefined; + const workspace = node.workspace; + // Cleaned-up worktrees are gone; their checkpoint ref is what can be recovered. + const workspaceActive = workspace && ["preparing", "ready", "failed"].includes(workspace.state); + const workspaceRef = workspaceActive ? workspace.worktreeRoot : workspace?.checkpointRef ?? workspace?.reason; const lines = [ theme.fg(failed ? "error" : "accent", `${failed ? "✗" : node.status === "completed" ? "✓" : "○"} Braid node ${compact(node.id, 80)} · ${node.status}`), ...(node.executionId ? [theme.fg("dim", `execution: ${compact(node.executionId, 80)}`)] : []), + ...(timing.length ? [theme.fg("muted", timing.join(" · "))] : []), + ...(live ? [theme.fg("muted", `context ${live.contextSource === "estimate" ? "~" : ""}${compactCount(live.contextTokens)}/${live.contextWindow === undefined ? "—" : compactCount(live.contextWindow)} · ${live.toolCalls} tool calls in ${live.toolRounds} rounds · ${live.phase} phase`)] : []), + ...(workspace ? [theme.fg("dim", `workspace: ${workspace.mode} · ${workspace.state}${workspaceRef ? ` · ${compact(workspaceRef, 160)}` : ""}`)] : []), ...(node.decision ? [theme.fg("accent", `decision: ${compact(node.decision, 80)}`)] : []), ...(node.error ? [theme.fg("error", `${node.error.code}: ${clean(node.error.message)}`)] : []), ...(node.skipReason ? [theme.fg("muted", `skipped: ${node.skipReason}`)] : []), diff --git a/integrations/pi/index.ts b/integrations/pi/index.ts index 685ea3d..5f084fa 100644 --- a/integrations/pi/index.ts +++ b/integrations/pi/index.ts @@ -271,7 +271,9 @@ export function createBraidTools(jobs: BraidJobs) { const selectedNode = job.selectedNode ?? (executionId ? state && Object.hasOwn(state.executions, executionId) ? state.executions[executionId] : undefined : nodeId && state && Object.hasOwn(state.nodes, nodeId) ? state.nodes[nodeId] : undefined); - if (selectedNode) return renderNodeResult(selectedNode, options.expanded, theme, job.nodeOutputPath); + // Render at the read's observation time so a saved result never keeps counting. + if (selectedNode) return renderNodeResult(selectedNode, options.expanded, theme, job.nodeOutputPath, + job.live.progress?.[selectedNode.id], job.live.observedAt); if (executionId || nodeId) return new Text(fallback || "Braid returned no node details", 0, 0); if (job.error) return new Text( diff --git a/integrations/pi/test/panel.test.ts b/integrations/pi/test/panel.test.ts index 45058e5..286ed58 100644 --- a/integrations/pi/test/panel.test.ts +++ b/integrations/pi/test/panel.test.ts @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { visibleWidth } from "@earendil-works/pi-tui"; +import { SelectList, stripTerminalSequences, visibleWidth } from "@earendil-works/pi-tui"; import { BraidPanel, registerBraidCommand } from "../command.js"; import { BraidJobs } from "../jobs.js"; import { context, deferred, input, response } from "./helpers.js"; @@ -179,3 +179,194 @@ test("panel scrolls long execution logs within the terminal viewport", async (t) panel.handleInput("\x1b[5~"); assert.match(panel.render(100).at(-3)!, /Lines 1–/); }); + +const focusTui = { requestRender: () => {}, terminal: { rows: 30 } } as never; +const chain = { + goal: "Inspect one node", + nodes: [ + { type: "execute" as const, id: "a", prompt: "first" }, + { type: "execute" as const, id: "b", prompt: "second" }, + ], + edges: [{ from: "a", to: "b" }], +}; + +function captureCommand(jobs: BraidJobs) { + let command!: { + handler: (args: string, ctx: unknown) => Promise; + getArgumentCompletions: (prefix: string) => unknown; + }; + registerBraidCommand({ registerCommand: (_name: string, value: typeof command) => { command = value; } } as never, jobs); + return command; +} + +test("panel focuses one node with live details and returns to the graph", async (t) => { + const jobs = new BraidJobs(); + t.after(() => jobs.dispose()); + const job = jobs.start(chain, {}, context(async () => response("Full output line one.\nLine two."))); + await jobs.wait(job.jobId); + const panel = new BraidPanel(jobs, focusTui, theme, () => {}, job.handle, { nodeId: "b" }); + t.after(() => panel.dispose()); + const focused = panel.render(100).join("\n"); + assert.match(focused, /completed {2}· {2}node b/); + assert.match(focused, /Braid node b · completed/); + assert.match(focused, /Line two\./); + assert.match(focused, /elapsed .* · 1 in \/ 1 out tokens/); + assert.match(focused, /⌫ graph/); + assert.doesNotMatch(focused, /Braid completed/); + assert.ok(panel.render(100).every((line) => visibleWidth(line) === 100)); + panel.handleInput("\x7f"); + const graph = panel.render(100).join("\n"); + assert.match(graph, /Braid completed/); + assert.doesNotMatch(graph, /Braid node b|⌫ graph/); + + const executionId = jobs.getNode(job.handle, "a").executionId!; + const byExecution = new BraidPanel(jobs, focusTui, theme, () => {}, job.handle, { executionId }); + t.after(() => byExecution.dispose()); + assert.match(byExecution.render(100).join("\n"), /Braid node a · completed/); + byExecution.handleInput("\x1b[C"); + assert.doesNotMatch(byExecution.render(100).join("\n"), /Braid node a/, "switching jobs clears the focus"); + + const missing = new BraidPanel(jobs, focusTui, theme, () => {}, job.handle, { nodeId: "removed" }); + t.after(() => missing.dispose()); + assert.match(missing.render(100).join("\n"), /no longer in the job's graph/); +}); + +test("panel shows a running node's elapsed time and tool progress", { timeout: 2_000 }, async (t) => { + const jobs = new BraidJobs(); + t.after(() => jobs.dispose()); + const started = deferred(); + const job = jobs.start(input, {}, context(async () => { + started.resolve(); + return new Promise(() => {}); + })); + await started.promise; + const panel = new BraidPanel(jobs, focusTui, theme, () => {}, job.handle, { nodeId: "a" }); + t.after(() => panel.dispose()); + const rendered = panel.render(100).join("\n"); + assert.match(rendered, /Braid node a · running/); + assert.match(rendered, /elapsed \d/); + assert.match(rendered, /context ~?\d+\S*\/100K · 0 tool calls in 0 rounds · model phase/); + jobs.cancel(job.jobId); + await jobs.wait(job.jobId); +}); + +test("/braid opens a focused panel and rejects unknown targets", async () => { + const jobs = new BraidJobs(); + const job = jobs.start(chain, {}, context(async () => response("node output"))); + await jobs.wait(job.jobId); + const command = captureCommand(jobs); + const open = (args: string) => { + let rendered = ""; + return command.handler(args, { + mode: "tui", + ui: { + custom: async (factory: (...args: unknown[]) => BraidPanel) => { + const panel = factory({ requestRender: () => {}, terminal: { rows: 30 } }, theme, {}, () => {}); + rendered = panel.render(100).join("\n"); + panel.handleInput("q"); + }, + }, + }).then(() => rendered); + }; + assert.match(await open(`${job.handle} b`), /Braid node b · completed/); + assert.match(await open(` ${job.handle} a `), /Braid node a · completed/); + const executionId = jobs.getNode(job.handle, "b").executionId!; + assert.match(await open(`${job.handle} ${executionId}`), new RegExp(`execution ${executionId}[\\s\\S]*Braid node b`)); + await assert.rejects(open(`${job.handle} missing`), /Unknown Braid node or execution "missing" in job-1\. Open \/braid job-1/); + await assert.rejects(open(`${job.handle} a b`), /Unknown Braid node or execution "a b"/); + await assert.rejects(open("job-9 a"), /job-9/); + jobs.dispose(); +}); + +test("/braid completes job handles, then node IDs for that job", async () => { + const jobs = new BraidJobs(); + const command = captureCommand(jobs); + assert.equal(command.getArgumentCompletions(""), null); + const job = jobs.start(chain, {}, context(async () => response())); + await jobs.wait(job.jobId); + assert.deepEqual( + (command.getArgumentCompletions("") as Array<{ value: string }>).map((item) => item.value), + [job.handle], + ); + assert.deepEqual( + (command.getArgumentCompletions(`${job.handle} `) as Array<{ value: string; description: string }>) + .map((item) => [item.value, item.description]), + [[`${job.handle} a`, "completed"], [`${job.handle} b`, "completed"]], + ); + assert.deepEqual( + (command.getArgumentCompletions(`${job.handle} b`) as Array<{ label: string }>).map((item) => item.label), + ["b"], + ); + assert.equal(command.getArgumentCompletions("job-9 "), null); + assert.equal(command.getArgumentCompletions(`${job.handle} a extra`), null); + jobs.dispose(); +}); + +test("/braid opens and completes node IDs that contain spaces", async () => { + const jobs = new BraidJobs(); + const job = jobs.start({ ...input, nodes: [{ type: "execute", id: "code review", prompt: "work" }] }, {}, + context(async () => response("spaced output"))); + await jobs.wait(job.jobId); + const command = captureCommand(jobs); + assert.deepEqual( + (command.getArgumentCompletions(`${job.handle} code r`) as Array<{ value: string }>).map((item) => item.value), + [`${job.handle} code review`], + ); + let rendered = ""; + await command.handler(`${job.handle} code review`, { + mode: "tui", + ui: { + custom: async (factory: (...args: unknown[]) => BraidPanel) => { + const panel = factory({ requestRender: () => {}, terminal: { rows: 30 } }, theme, {}, () => {}); + rendered = panel.render(100).join("\n"); + panel.handleInput("q"); + }, + }, + }); + assert.match(rendered, /Braid node code review · completed/); + assert.match(rendered, /spaced output/); + jobs.dispose(); +}); + +test("/braid safely completes and opens exact node IDs with whitespace, quotes, and terminal controls", async (t) => { + const jobs = new BraidJobs(); + t.after(() => jobs.dispose()); + const command = captureCommand(jobs); + const ids = [" review ", '"quoted"', "review\x1b[2J\nsecond line", "review\tstep", "review\u2028step\u2029end"]; + const identity = (value: string) => value; + for (const [index, id] of ids.entries()) { + const job = jobs.start({ ...input, nodes: [{ type: "execute", id, prompt: "work" }] }, {}, + context(async () => response(`exact output ${index}`))); + await jobs.wait(job.handle); + const [item] = command.getArgumentCompletions(`${job.handle} `) as Array<{ value: string; label: string; description: string }>; + assert.ok(item); + assert.equal(JSON.parse(item.value.slice(job.handle.length + 1)), id); + assert.equal(item.label, item.value.slice(job.handle.length + 1)); + const menu = new SelectList([item], 5, { selectedPrefix: identity, selectedText: identity, description: identity, scrollInfo: identity, noMatch: identity }); + assert.ok(menu.render(100).every(line => stripTerminalSequences(line) === line && !/[\p{Cc}\u2028\u2029]/u.test(line))); + assert.ok(!/[\p{Cc}\u2028\u2029]/u.test(item.value)); + const prefix = item.value.slice(0, job.handle.length + 4); + assert.deepEqual(command.getArgumentCompletions(prefix), [item]); + let rendered = ""; + await command.handler(item.value, { + mode: "tui", + ui: { + custom: async (factory: (...args: unknown[]) => BraidPanel) => { + const panel = factory({ requestRender: () => {}, terminal: { rows: 30 } }, theme, {}, () => {}); + rendered = panel.render(100).join("\n"); + panel.handleInput("q"); + }, + }, + }); + assert.ok(rendered.includes(`exact output ${index}`), rendered); + } +}); + +test("/braid reports malformed quoted node targets before opening a panel", async (t) => { + const jobs = new BraidJobs(); + t.after(() => jobs.dispose()); + const job = jobs.start(input, {}, context(async () => response())); + await jobs.wait(job.handle); + const command = captureCommand(jobs); + await assert.rejects(command.handler(`${job.handle} "unterminated`, { mode: "tui" }), /quoted.*JSON string/i); +}); diff --git a/integrations/pi/test/render.test.ts b/integrations/pi/test/render.test.ts index 3e358d3..02bcdbd 100644 --- a/integrations/pi/test/render.test.ts +++ b/integrations/pi/test/render.test.ts @@ -3,6 +3,7 @@ import test from "node:test"; import { renderGraphCall, renderGraphResult, + renderNodeResult, applyEvent, createLiveState, type BraidToolDetails, @@ -429,3 +430,20 @@ test("terminal graph events update live state and clear pause indicators", () => assert.match(rendered, /Graph cancelled by caller/); assert.doesNotMatch(rendered, /Braid executing|paused executions/); }); + +test("node details render at the observation time and show recoverable workspace refs", () => { + const plainTheme = { fg: (_c: string, v: string) => v, bg: (_c: string, v: string) => v, bold: (v: string) => v }; + const render = (node: Parameters[0], now: number) => + renderNodeResult(node, true, plainTheme, undefined, undefined, now).render(200).join("\n"); + const running = { id: "a", status: "running" as const, startedAt: 1_000, iteration: 2 }; + assert.match(render(running, 3_500), /iteration #2 · elapsed 2s/); + assert.match(render(running, 3_500), /elapsed 2s/, "the same observation renders the same elapsed time"); + const workspace = { + nodeId: "a", mode: "worktree" as const, workingDirectory: "/tmp/wt", worktreeRoot: "/tmp/wt", + checkpointRef: "refs/braid/checkpoint", state: "ready" as const, + }; + assert.match(render({ id: "a", status: "running", workspace }, 0), /workspace: worktree · ready · \/tmp\/wt/); + const archived = render({ id: "a", status: "completed", workspace: { ...workspace, state: "archived" } }, 0); + assert.match(archived, /workspace: worktree · archived · refs\/braid\/checkpoint/); + assert.doesNotMatch(archived, /\/tmp\/wt/); +});