From 764e370f3e88ecc2b75d7cd88c1d2c4219c2909f Mon Sep 17 00:00:00 2001 From: Wangshuyi Date: Fri, 9 Oct 2026 10:14:51 +0800 Subject: [PATCH 1/2] feat(pi): open the Braid panel on a single node Add `/braid ` to show one node's latest or exact invocation in the live panel: status, model, loop iteration, elapsed time, token and context usage, tool calls, workspace, and the full output or error. Backspace returns to the graph, and Tab completes job handles and then node IDs, including IDs that contain spaces. Focused braid_status results share the renderer and now show the same details, rendered at the read's observation time so a saved result does not keep counting. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 8 ++ integrations/pi/README.md | 7 ++ integrations/pi/command.ts | 107 ++++++++++++++++++-- integrations/pi/display.ts | 24 ++++- integrations/pi/index.ts | 4 +- integrations/pi/test/panel.test.ts | 148 ++++++++++++++++++++++++++++ integrations/pi/test/render.test.ts | 18 ++++ 7 files changed, 305 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e5de8ac..2bfc647 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ ## Unreleased +### Features + +- 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. + ## 0.3.1 — 2026-10-04 ### Features diff --git a/integrations/pi/README.md b/integrations/pi/README.md index 6f91e20..2f16b02 100644 --- a/integrations/pi/README.md +++ b/integrations/pi/README.md @@ -171,6 +171,13 @@ 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. + 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 fa697bc..de96768 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); @@ -202,20 +244,23 @@ export class BraidPanel implements Component { 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; }, { @@ -233,3 +278,47 @@ 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]; + return [text.slice(0, split), text.slice(split).trim() || undefined]; +} + +/** 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) + .filter((node) => node.id.startsWith(nodePrefix)) + .map((node) => ({ value: `${job.handle} ${node.id}`, label: node.id, 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 e617352..469ecc6 100644 --- a/integrations/pi/index.ts +++ b/integrations/pi/index.ts @@ -270,7 +270,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..abdd196 100644 --- a/integrations/pi/test/panel.test.ts +++ b/integrations/pi/test/panel.test.ts @@ -179,3 +179,151 @@ 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(); +}); 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/); +}); From b39f742378c25eeec29b7e48d07e2a6ce5ad3c02 Mon Sep 17 00:00:00 2001 From: Chrok Date: Fri, 9 Oct 2026 16:53:39 +0800 Subject: [PATCH 2/2] fix(pi): safely quote exact node targets in panel commands --- CHANGELOG.md | 5 ++++ integrations/pi/README.md | 4 +++ integrations/pi/command.ts | 23 +++++++++++++-- integrations/pi/test/panel.test.ts | 45 +++++++++++++++++++++++++++++- 4 files changed, 73 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2bfc647..f9f1cd1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,11 @@ 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 ### Features diff --git a/integrations/pi/README.md b/integrations/pi/README.md index 2f16b02..75fee63 100644 --- a/integrations/pi/README.md +++ b/integrations/pi/README.md @@ -177,6 +177,10 @@ 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 diff --git a/integrations/pi/command.ts b/integrations/pi/command.ts index de96768..146d148 100644 --- a/integrations/pi/command.ts +++ b/integrations/pi/command.ts @@ -283,7 +283,20 @@ export function registerBraidCommand(pi: ExtensionAPI, jobs: BraidJobs): void { function splitArguments(text: string): [string | undefined, string | undefined] { const split = text.search(/\s/); if (split < 0) return [text || undefined, undefined]; - return [text.slice(0, split), text.slice(split).trim() || 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. */ @@ -318,7 +331,11 @@ function braidCompletions(jobs: BraidJobs, prefix: string): AutocompleteItem[] | if (!job) return null; const nodes = job.result?.nodes ?? job.execution?.nodes ?? job.live.nodes; const items = Object.values(nodes) - .filter((node) => node.id.startsWith(nodePrefix)) - .map((node) => ({ value: `${job.handle} ${node.id}`, label: node.id, description: node.status })); + .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/test/panel.test.ts b/integrations/pi/test/panel.test.ts index abdd196..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"; @@ -327,3 +327,46 @@ test("/braid opens and completes node IDs that contain spaces", async () => { 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); +});