Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <jobId> <nodeId|executionId>` 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

Expand Down
11 changes: 11 additions & 0 deletions integrations/pi/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <jobId> <nodeId>` to open the panel on one node's latest invocation,
or `/braid <jobId> <executionId>` 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
Expand Down
124 changes: 115 additions & 9 deletions integrations/pi/command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 =>
Expand All @@ -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;
Expand All @@ -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();
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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))]
Expand All @@ -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"
Expand All @@ -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") ||
Expand All @@ -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);
Expand Down Expand Up @@ -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<void>(
(tui, theme, _keys, done) => {
panel = new BraidPanel(jobs, tui, theme, done, jobId);
panel = new BraidPanel(jobs, tui, theme, done, jobId, focus);
return panel;
},
{
Expand All @@ -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;
}
24 changes: 23 additions & 1 deletion integrations/pi/display.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`)] : []),
Expand Down
4 changes: 3 additions & 1 deletion integrations/pi/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Loading
Loading