@chrok/pi-braid 0.3.1 → 0.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -25,6 +25,7 @@ It registers:
25
25
  - `braid_update` / `braid_resume` — edit live definitions or release paused executions.
26
26
  - `braid_cancel` — cancel a job with `{ "jobId": "..." }`.
27
27
  - `/braid [jobId]` — open a live flow panel in interactive Pi.
28
+ - `/braid:review [target or focus]` — run a bounded parallel review/fix loop.
28
29
 
29
30
  Submission and completion reminders use short session handles such as `job-1`.
30
31
  Status, cancellation and the panel accept either that exact handle or the original
@@ -50,6 +51,44 @@ or switching/forking sessions aborts outstanding work and suppresses its
50
51
  reminders. Job IDs cannot be retrieved after that lifecycle ends. They are not
51
52
  persistent processes outside Pi.
52
53
 
54
+ ## Review command
55
+
56
+ Run `/braid:review` to review current uncommitted changes (including staged, unstaged,
57
+ and non-ignored new files). With no uncommitted changes, it reviews the latest
58
+ commit. Supply a file, commit range, or focus with `/braid:review src/runtime.ts` or
59
+ `/braid:review main..HEAD, focus on cancellation`. Workers inspect the supplied target
60
+ with local tools; they report unavailable remote targets instead of inventing
61
+ their contents. Use `/braid:review review-only, src/runtime.ts` to request findings
62
+ without fixes.
63
+
64
+ Each round assigns three independent, read-only reviewers distinct angles based
65
+ on the change. Findings need concrete evidence and file/line references, with
66
+ P0/P1/P2 priority and a merge verdict. One writer synthesizes the feedback and
67
+ fixes only evidenced, in-scope P0/P1 problems in an isolated worktree, then runs
68
+ focused validation. Optional P2 polish is reported. A product, scope, or
69
+ architecture decision stops the loop for user input.
70
+
71
+ The graph enforces a fixed maximum of **three review rounds**, including the
72
+ first, and **two fix passes**. The final round reports remaining issues without
73
+ making further edits; reaching the cap does not mean the review is clean.
74
+ Only fixes that passed re-review and validation are integrated into the invoking
75
+ checkout, preserving existing user changes. Conflicting concurrent edits or
76
+ failed validation leave the fix checkpoint archived for recovery. Outside Git,
77
+ workers can only inspect files and report findings.
78
+
79
+ The command returns immediately with a job handle. Use `/braid [jobId]` to see
80
+ progress, `braid_status` for results, and `braid_cancel` to stop. The existing
81
+ completion reminder resumes the parent to report rounds, applied fixes,
82
+ validation, remaining findings, and the stop reason. Session shutdown cancels
83
+ the job. Cancellation or failure during integration can leave partial checkout
84
+ changes; Braid retains backup and checkpoint refs for recovery.
85
+
86
+ The review policy is adapted from pi-subagents'
87
+ [review-loop](https://github.com/nicobailon/pi-subagents/blob/main/prompts/review-loop.md)
88
+ and [parallel-review](https://github.com/nicobailon/pi-subagents/blob/main/prompts/parallel-review.md)
89
+ prompts. Invoking `/braid:review` authorizes the narrow fix passes described above;
90
+ an explicit review-only request disables them.
91
+
53
92
  ## Graph definitions
54
93
 
55
94
  Each node has a unique `id` and one of these four shapes:
@@ -163,6 +202,21 @@ scheduler persistence across Pi reloads. Reminders alone do not pause execution.
163
202
 
164
203
  ## Live flow panel
165
204
 
205
+ In interactive Pi, background progress appears automatically **above the input
206
+ editor** as soon as a job is submitted; no command or polling is needed:
207
+
208
+ ```text
209
+ Braid job-1 · running · 2/5 done · review
210
+ ```
211
+
212
+ The widget shows node progress, active node IDs, paused gates, and failures.
213
+ It shows up to three active jobs, with an overflow count for the rest. When no
214
+ jobs are active, the newest job's final completed/failed/cancelled status stays
215
+ visible until another job is submitted or the session ends. Counts use the
216
+ latest state of each current node definition; failed and skipped nodes count as
217
+ done, and reruns or live edits can change progress and totals. RPC, JSON, and
218
+ print modes do not create a terminal widget.
219
+
166
220
  Run `/braid` to open the newest job, or `/braid <jobId>` to open a specific job.
167
221
  The bordered panel keeps the job header and keyboard controls visible while
168
222
  you scroll the flow and event log. It refreshes as nodes start, finish, fail,
@@ -171,12 +225,54 @@ Use Left/Right to select jobs, Up/Down or Page Up/Page Down to scroll, `c` to
171
225
  cancel the selected job, and Escape or `q` to close the panel. Closing the panel
172
226
  leaves jobs running. In RPC or noninteractive modes, use `braid_status`.
173
227
 
228
+ A flowchart wider than the terminal is cropped to the terminal width around the
229
+ first running node, or from its left edge; a `[chart 1–76 of 245 cols]` line
230
+ shows the visible columns.
231
+
232
+ Press `v` to select a node: the arrow keys move a `◆` marker through the
233
+ flowchart (starting at a running node), and the view pans horizontally and
234
+ scrolls vertically to keep the selected node in sight. If the flowchart cannot be
235
+ drawn, a node list replaces it while selecting. Enter opens that node's latest
236
+ invocation as a live session. The session is drawn with Pi's own chat
237
+ components, as in the main conversation: the worker's prompt, streamed
238
+ assistant text and thinking, and tool cards whose arguments and output update
239
+ while they run. The node summary—status, elapsed time, usage, context, and live
240
+ activity (see below)—stays pinned at the top while the session scrolls. The
241
+ session follows the newest lines; scroll up to pause, and press End to follow again. Left/Right switch between
242
+ invocations of the same node (for example loop iterations), and ctrl+o expands
243
+ tool output. Escape steps back one level—session, then node selection, then the
244
+ graph—and closes the panel from the graph; `q` closes it from anywhere. Sessions
245
+ are read-only. Running invocations and the 20 most recently finished ones per
246
+ job keep their session; older ones show the summary and activity history.
247
+
248
+ Run `/braid <jobId> <nodeId>` to open that node's latest invocation directly in
249
+ the session view, or `/braid <jobId> <executionId>` for an exact invocation such
250
+ as an earlier loop iteration. The header shows live activity for that exact invocation: the current phase
251
+ (workspace preparation, worker, model request, tool, or checkpoint/cleanup) and
252
+ how long it has been in it, time since the last observed signal, total time, the
253
+ in-flight model request (sent, awaiting the first stream event, or streaming
254
+ text/reasoning/tool-call arguments with a character count and latest tail), and
255
+ running tools with their arguments, elapsed time, and latest output. Focused
256
+ `braid_status` reads, and invocations whose session is no longer retained, add a
257
+ chronological history of model requests and responses, assistant text, tool calls
258
+ and results, workspace changes, and completion, with bounded detail previews.
259
+ A quiet period is shown as elapsed time, not declared a stall. If the host cannot
260
+ report a signal—for example a model registry without streaming—the view says so.
261
+ Tab completes job handles and then that job's node IDs.
262
+ IDs with leading/trailing whitespace, terminal controls, or a leading double
263
+ quote use JSON string syntax, such as `/braid job-1 " review "` or
264
+ `/braid job-1 "review\nstep"`. Tab inserts this form automatically and displays
265
+ control characters as escapes.
266
+
174
267
  The panel renders a Mermaid flowchart, node states, elapsed times, context-token
175
268
  estimates or provider-reported usage, context-window sizes, filesystem tool-call
176
269
  counts, and the execution log. Active nodes are marked `▶ ACTIVE`. The status
177
270
  tool also renders a flowchart; expand its result to see more log events.
178
271
  When selecting `nodeId` or `executionId`, it instead shows that invocation's
179
- output/error, with the full text available on expansion.
272
+ output/error and activity, with the full text and history available on expansion.
273
+ The JSON includes `activity` with the latest 40 history entries; pass
274
+ `activityBefore` with an entry sequence to page earlier ones. While the invocation
275
+ runs, `activity` comes before `node` so it survives preview truncation.
180
276
 
181
277
  `/braid` now opens this panel; it no longer arms the next prompt. To request
182
278
  Braid explicitly, ask the agent to analyze the task using Braid.
package/dist/command.js CHANGED
@@ -1,26 +1,41 @@
1
1
  import { matchesKey, stripTerminalSequences, truncateToWidth, visibleWidth, } from "@earendil-works/pi-tui";
2
- import { renderGraphResult } from "./display.js";
2
+ import { renderGraphResult, renderNodeDetails, renderSessionHeader, SELECTED_NODE_MARK } from "./display.js";
3
+ import { NodeSessionView } from "./session-view.js";
3
4
  // Match the overlay height exactly so Pi never clips the footer or bottom border.
4
5
  const panelHeight = (rows) => Math.max(1, Math.floor(rows * 0.9));
5
6
  const plain = (value) => stripTerminalSequences(value).replace(/\p{Cc}/gu, " ");
6
- /** A live, scrollable panel. Closing the panel leaves jobs running. */
7
+ /**
8
+ * A live, scrollable panel. Esc steps back one level: session → node selection →
9
+ * graph → close. Closing the panel leaves jobs running.
10
+ */
7
11
  export class BraidPanel {
8
12
  jobs;
9
13
  tui;
10
14
  theme;
11
15
  done;
12
16
  selected;
17
+ focus;
18
+ /** Node highlighted in selection mode (`v`); undefined in the plain graph view. */
19
+ choosing;
20
+ /** Keep the newest transcript lines in view until the user scrolls up. */
21
+ follow = true;
22
+ /** The selection last scrolled into view, so manual scrolling is not overridden. */
23
+ revealed;
13
24
  offset = 0;
14
25
  maxOffset = 0;
15
26
  disposed = false;
27
+ session;
16
28
  unsubscribe;
17
29
  ticker;
18
- constructor(jobs, tui, theme, done, jobId) {
30
+ constructor(jobs, tui, theme, done, jobId, focus) {
19
31
  this.jobs = jobs;
20
32
  this.tui = tui;
21
33
  this.theme = theme;
22
34
  this.done = done;
23
35
  this.selected = jobId ? jobs.get(jobId)?.jobId ?? jobId : undefined;
36
+ this.focus = focus;
37
+ // Pi's tool components only request renders through the TUI.
38
+ this.session = new NodeSessionView(tui, theme);
24
39
  this.unsubscribe = jobs.subscribe(() => this.refresh());
25
40
  this.ticker = setInterval(() => this.refresh(), 1000);
26
41
  this.ticker.unref();
@@ -60,8 +75,11 @@ export class BraidPanel {
60
75
  : current?.status === "failed"
61
76
  ? "error"
62
77
  : "muted";
78
+ const focusLabel = this.focus
79
+ ? "nodeId" in this.focus ? `node ${plain(this.focus.nodeId)}` : `execution ${plain(this.focus.executionId)}`
80
+ : this.choosing !== undefined ? "select a node" : "";
63
81
  const heading = current
64
- ? `Job ${index + 1} of ${jobs.length} · ${this.theme.fg(statusColor, current.status)}`
82
+ ? `Job ${index + 1} of ${jobs.length} · ${this.theme.fg(statusColor, current.status)}${focusLabel ? ` · ${focusLabel}` : ""}`
65
83
  : this.theme.fg("muted", "No background jobs");
66
84
  // Reserve a body row and the close hint even in a very short terminal.
67
85
  if (rows < 10) {
@@ -87,35 +105,63 @@ export class BraidPanel {
87
105
  : "Jobs will appear here as soon as they are submitted.")),
88
106
  rule("├", "┤"),
89
107
  ];
90
- const content = current
91
- ? [
92
- ...(current.error
93
- ? [this.theme.fg("error", plain(current.error))]
94
- : []),
95
- ...renderGraphResult({
96
- ...(current.result ?? current.live),
97
- progress: current.live.progress,
98
- ...(current.workspaces ? { workspaces: current.workspaces } : {}),
99
- ...(current.fullOutputPath
100
- ? { fullOutputPath: current.fullOutputPath }
101
- : {}),
102
- }, true, false, this.theme).render(contentWidth),
103
- ]
104
- : ["No Braid jobs in this session."];
105
- const height = rows - header.length - 4;
108
+ if (current && this.choosing !== undefined && !this.nodeIds(current).includes(this.choosing)) {
109
+ this.choosing = this.nodeIds(current)[0];
110
+ }
111
+ // The node summary (or the selected node) stays pinned while the body scrolls.
112
+ const focused = current && this.focus ? this.renderFocus(current, this.focus, contentWidth) : undefined;
113
+ let pinned = focused?.pinned ?? (current && this.choosing !== undefined ? [this.choosingLine(current)] : []);
114
+ const available = rows - header.length - 4;
115
+ const maxPinned = Math.max(2, Math.floor(available / 2));
116
+ if (pinned.length > maxPinned) {
117
+ pinned = [...pinned.slice(0, maxPinned - 1), this.theme.fg("dim", `… ${pinned.length - maxPinned + 1} more summary lines`)];
118
+ }
119
+ const content = focused
120
+ ? focused.body
121
+ : current
122
+ ? [
123
+ ...(current.error
124
+ ? [this.theme.fg("error", plain(current.error))]
125
+ : []),
126
+ ...renderGraphResult({
127
+ ...(current.result ?? current.live),
128
+ progress: current.live.progress,
129
+ ...(current.workspaces ? { workspaces: current.workspaces } : {}),
130
+ ...(current.fullOutputPath
131
+ ? { fullOutputPath: current.fullOutputPath }
132
+ : {}),
133
+ }, true, false, this.theme, "", false, this.choosing).render(contentWidth),
134
+ ]
135
+ : ["No Braid jobs in this session."];
136
+ const height = available - (pinned.length ? pinned.length + 1 : 0);
106
137
  this.maxOffset = Math.max(0, content.length - height);
107
- this.offset = Math.min(this.offset, this.maxOffset);
138
+ this.offset = this.focus && this.follow ? this.maxOffset : Math.min(this.offset, this.maxOffset);
139
+ if (!this.focus && this.choosing !== undefined && this.choosing !== this.revealed) {
140
+ // Bring a newly selected node's box into view in a tall flowchart.
141
+ this.revealed = this.choosing;
142
+ const line = content.findIndex(text => stripTerminalSequences(text).includes(SELECTED_NODE_MARK));
143
+ if (line >= 0 && (line < this.offset || line > this.offset + height - 3)) {
144
+ this.offset = Math.max(0, Math.min(this.maxOffset, line - Math.floor(height / 3)));
145
+ }
146
+ }
147
+ if (this.choosing === undefined)
148
+ this.revealed = undefined;
108
149
  const body = content.slice(this.offset, this.offset + height);
109
150
  while (body.length < height)
110
151
  body.push("");
111
152
  const range = `Lines ${this.offset + 1}–${Math.min(content.length, this.offset + height)}/${content.length}`;
112
- const help = contentWidth >= 72
113
- ? "←/→ jobs ↑/↓ scroll PgUp/PgDn page c cancel Esc close"
114
- : contentWidth >= 42
115
- ? "←/→ jobs · ↑/↓ scroll · c cancel · Esc close"
116
- : "↑/↓ scroll · Esc close";
153
+ const [wide, medium, narrow] = this.focus
154
+ ? ["Esc back ←/→ executions ↑/↓ scroll End follow ctrl+o tools c cancel q close",
155
+ "Esc back · ←/→ executions · ↑/↓ scroll · q close", "Esc back · q close"]
156
+ : this.choosing !== undefined
157
+ ? ["↑/↓/←/→ choose node Enter open session Esc back PgUp/PgDn page q close",
158
+ "↑/↓ choose · Enter open · Esc back", "Enter open · Esc back"]
159
+ : ["v select node ←/→ jobs ↑/↓ scroll PgUp/PgDn page c cancel Esc close",
160
+ "v select node · ←/→ jobs · ↑/↓ scroll · Esc close", "v select · Esc close"];
161
+ const help = contentWidth >= 80 ? wide : contentWidth >= 42 ? medium : narrow;
117
162
  return [
118
163
  ...header,
164
+ ...(pinned.length ? [...pinned.map(row), rule("├", "┤")] : []),
119
165
  ...body.map(row),
120
166
  rule("├", "┤"),
121
167
  row(this.theme.fg("dim", `${range}${contentWidth >= 60 ? " · Jobs keep running when this panel closes" : ""}`)),
@@ -123,22 +169,154 @@ export class BraidPanel {
123
169
  rule("╰", "╯"),
124
170
  ];
125
171
  }
172
+ /** Graph definition order, which is also the flowchart's declaration order. */
173
+ nodeIds(job) {
174
+ return Object.keys(job.result?.nodes ?? job.live.nodes);
175
+ }
176
+ choosingLine(job) {
177
+ const nodes = job.result?.nodes ?? job.live.nodes;
178
+ const node = this.choosing !== undefined ? nodes[this.choosing] : undefined;
179
+ const ids = this.nodeIds(job);
180
+ return this.theme.fg("warning", `${SELECTED_NODE_MARK} Selected ${ids.indexOf(this.choosing ?? "") + 1}/${ids.length}: `) +
181
+ this.theme.bold(plain(this.choosing ?? "")) + this.theme.fg("muted", ` · ${node?.status ?? "pending"} · Enter to open its session`);
182
+ }
183
+ /** A focused node can disappear after a live graph update; say so instead of failing. */
184
+ renderFocus(job, focus, width) {
185
+ let node;
186
+ try {
187
+ node = "nodeId" in focus
188
+ ? this.jobs.getNode(job.jobId, focus.nodeId)
189
+ : this.jobs.getNode(job.jobId, undefined, focus.executionId);
190
+ }
191
+ catch {
192
+ return { pinned: [], body: [
193
+ this.theme.fg("warning", "This node or execution is no longer in the job's graph."),
194
+ this.theme.fg("muted", "Press Esc to return to the graph."),
195
+ ] };
196
+ }
197
+ // Latest-node results omit the loop iteration; their execution record has it.
198
+ const executions = job.result?.executions ?? job.execution?.executions;
199
+ const iteration = node.iteration ??
200
+ (node.executionId ? executions?.[node.executionId]?.iteration : undefined);
201
+ const withIteration = iteration === undefined ? node : { ...node, iteration };
202
+ const activity = node.executionId ? this.jobs.getActivity(job.jobId, node.executionId) : undefined;
203
+ const transcript = node.executionId ? this.jobs.getTranscript(job.jobId, node.executionId) : undefined;
204
+ const siblings = this.executionsOf(job, node.id);
205
+ const position = node.executionId ? siblings.indexOf(node.executionId) : -1;
206
+ const header = [
207
+ ...renderSessionHeader(withIteration, this.theme, job.live.observedAt, job.live.progress[node.id], activity),
208
+ ...(siblings.length > 1 ? [this.theme.fg("dim", `execution ${position + 1} of ${siblings.length} for this node · ←/→ to switch`)] : []),
209
+ ];
210
+ let failure;
211
+ if (transcript) {
212
+ try {
213
+ return { pinned: header, body: this.session.render(transcript, width) };
214
+ }
215
+ catch (error) {
216
+ // A rendering failure must not take down the overlay; fall back to the summary.
217
+ failure = this.theme.fg("error", `Session view unavailable: ${plain(error instanceof Error ? error.message : String(error))}`);
218
+ }
219
+ }
220
+ // Pending, recorded before this session, or evicted: show details and history instead.
221
+ const note = failure ?? (node.executionId
222
+ ? this.theme.fg("muted", "The live session for this execution is no longer retained; showing its details and activity history.")
223
+ : this.theme.fg("muted", "This node has not run yet."));
224
+ return { pinned: header, body: [note, ...renderNodeDetails(node, this.theme, job.live.observedAt, activity)] };
225
+ }
226
+ executionsOf(job, nodeId) {
227
+ const executions = job.result?.executions ?? job.execution?.executions ?? {};
228
+ return Object.values(executions).filter(execution => execution.id === nodeId).map(execution => execution.executionId);
229
+ }
230
+ open(job, nodeId) {
231
+ const executionId = this.executionsOf(job, nodeId).at(-1);
232
+ this.focus = executionId ? { executionId } : { nodeId };
233
+ this.follow = true;
234
+ this.offset = 0;
235
+ }
126
236
  handleInput(data) {
127
- if (matchesKey(data, "escape") ||
128
- data === "q" ||
129
- matchesKey(data, "ctrl+c")) {
130
- this.dispose();
131
- this.done();
237
+ if (data === "q" || matchesKey(data, "ctrl+c")) {
238
+ this.close();
132
239
  return;
133
240
  }
134
241
  const jobs = this.jobs.list();
135
242
  const index = Math.max(0, jobs.findIndex((job) => job.jobId === this.selected));
136
- if (matchesKey(data, "left") || matchesKey(data, "right")) {
243
+ const job = this.selected ? this.jobs.get(this.selected) : undefined;
244
+ if (this.focus)
245
+ this.sessionInput(data, job);
246
+ else if (this.choosing !== undefined && job)
247
+ this.chooseInput(data, job);
248
+ else if (matchesKey(data, "escape")) {
249
+ this.close();
250
+ return;
251
+ }
252
+ else if (data === "v" && job) {
253
+ const ids = this.nodeIds(job);
254
+ this.choosing = ids.find(id => (job.result?.nodes ?? job.live.nodes)[id]?.status === "running") ?? ids[0];
255
+ }
256
+ else if (matchesKey(data, "left") || matchesKey(data, "right")) {
137
257
  const step = matchesKey(data, "right") ? 1 : -1;
138
258
  this.selected = jobs[(index + step + jobs.length) % jobs.length]?.jobId;
139
259
  this.offset = 0;
140
260
  }
141
- else if (matchesKey(data, "up"))
261
+ else
262
+ this.scroll(data);
263
+ if (data === "c" && this.selected)
264
+ this.jobs.cancel(this.selected);
265
+ this.refresh();
266
+ }
267
+ chooseInput(data, job) {
268
+ const ids = this.nodeIds(job);
269
+ const at = Math.max(0, ids.indexOf(this.choosing));
270
+ if (matchesKey(data, "escape") || matchesKey(data, "backspace"))
271
+ this.choosing = undefined;
272
+ else if (matchesKey(data, "up") || matchesKey(data, "left"))
273
+ this.choosing = ids[(at - 1 + ids.length) % ids.length];
274
+ else if (matchesKey(data, "down") || matchesKey(data, "right"))
275
+ this.choosing = ids[(at + 1) % ids.length];
276
+ else if (matchesKey(data, "enter") && this.choosing !== undefined)
277
+ this.open(job, this.choosing);
278
+ else
279
+ this.scroll(data);
280
+ }
281
+ sessionInput(data, job) {
282
+ if (matchesKey(data, "escape") || matchesKey(data, "backspace")) {
283
+ // Return to selection on the same node, so another node is one keypress away.
284
+ const focus = this.focus;
285
+ let nodeId;
286
+ try {
287
+ nodeId = job && ("nodeId" in focus ? focus.nodeId : this.jobs.getNode(job.jobId, undefined, focus.executionId).id);
288
+ }
289
+ catch { /* The node was removed; fall back to the plain graph. */ }
290
+ this.focus = undefined;
291
+ this.choosing = nodeId !== undefined && job && this.nodeIds(job).includes(nodeId) ? nodeId : undefined;
292
+ this.offset = 0;
293
+ }
294
+ else if ((matchesKey(data, "left") || matchesKey(data, "right")) && job) {
295
+ let node;
296
+ try {
297
+ node = "nodeId" in this.focus ? this.jobs.getNode(job.jobId, this.focus.nodeId) : this.jobs.getNode(job.jobId, undefined, this.focus.executionId);
298
+ }
299
+ catch {
300
+ return;
301
+ }
302
+ const siblings = this.executionsOf(job, node.id);
303
+ const at = node.executionId ? siblings.indexOf(node.executionId) : -1;
304
+ const next = siblings[at + (matchesKey(data, "right") ? 1 : -1)];
305
+ if (next) {
306
+ this.focus = { executionId: next };
307
+ this.follow = true;
308
+ }
309
+ }
310
+ else if (matchesKey(data, "end"))
311
+ this.follow = true;
312
+ else if (matchesKey(data, "ctrl+o"))
313
+ this.session.toggleExpanded();
314
+ else
315
+ this.scroll(data);
316
+ }
317
+ scroll(data) {
318
+ const before = this.offset;
319
+ if (matchesKey(data, "up"))
142
320
  this.offset = Math.max(0, this.offset - 1);
143
321
  else if (matchesKey(data, "down"))
144
322
  this.offset = Math.min(this.maxOffset, this.offset + 1);
@@ -146,9 +324,19 @@ export class BraidPanel {
146
324
  this.offset = Math.max(0, this.offset - 10);
147
325
  else if (matchesKey(data, "pageDown"))
148
326
  this.offset = Math.min(this.maxOffset, this.offset + 10);
149
- else if (data === "c" && this.selected)
150
- this.jobs.cancel(this.selected);
151
- this.refresh();
327
+ else if (matchesKey(data, "home"))
328
+ this.offset = 0;
329
+ else
330
+ return;
331
+ // Scrolling up pauses following; reaching the bottom resumes it.
332
+ if (this.offset < before)
333
+ this.follow = false;
334
+ else if (this.offset >= this.maxOffset)
335
+ this.follow = true;
336
+ }
337
+ close() {
338
+ this.dispose();
339
+ this.done();
152
340
  }
153
341
  invalidate() { }
154
342
  dispose() {
@@ -159,19 +347,76 @@ export class BraidPanel {
159
347
  this.unsubscribe();
160
348
  }
161
349
  }
350
+ export function registerBraidWidget(pi, jobs) {
351
+ let dispose = () => { };
352
+ const clear = () => {
353
+ dispose();
354
+ dispose = () => { };
355
+ };
356
+ pi.on("session_start", (_event, ctx) => {
357
+ clear();
358
+ if (ctx.mode !== "tui")
359
+ return;
360
+ const refresh = () => {
361
+ if (!jobs.list().length) {
362
+ ctx.ui.setWidget("braid-status", undefined);
363
+ return;
364
+ }
365
+ ctx.ui.setWidget("braid-status", (_tui, theme) => ({
366
+ render(width) {
367
+ const all = jobs.list();
368
+ const active = all.filter(job => job.status === "running");
369
+ const selected = active.length ? active.slice(0, 3) : all.slice(0, 1);
370
+ const lines = selected.map(job => {
371
+ const progress = jobs.progress(job.jobId);
372
+ const status = job.status === "running" && progress.paused && !progress.running.length
373
+ ? "paused" : job.status;
374
+ const color = status === "completed" ? "success"
375
+ : status === "failed" ? "error"
376
+ : status === "cancelled" ? "muted" : "warning";
377
+ const parts = [
378
+ theme.fg("accent", `Braid ${job.handle}`),
379
+ theme.fg(color, status),
380
+ `${progress.done}/${progress.total} done`,
381
+ ];
382
+ if (progress.failed)
383
+ parts.push(theme.fg("error", `${progress.failed} failed`));
384
+ if (progress.paused && status !== "paused")
385
+ parts.push(theme.fg("warning", `${progress.paused} paused`));
386
+ parts.push(plain(progress.running.length ? progress.running.join(", ") : job.goal));
387
+ return truncateToWidth(parts.join(" · "), width, "…");
388
+ });
389
+ if (active.length > selected.length)
390
+ lines.push(truncateToWidth(theme.fg("muted", `Braid · +${active.length - selected.length} active jobs · /braid details`), width, "…"));
391
+ return lines;
392
+ },
393
+ invalidate() { },
394
+ }), { placement: "aboveEditor" });
395
+ };
396
+ const unsubscribe = jobs.subscribe(refresh);
397
+ dispose = () => {
398
+ unsubscribe();
399
+ ctx.ui.setWidget("braid-status", undefined);
400
+ };
401
+ refresh();
402
+ });
403
+ return clear;
404
+ }
162
405
  export function registerBraidCommand(pi, jobs) {
163
406
  pi.registerCommand("braid", {
164
- description: "Open the live background-job flow panel: /braid [jobId]",
407
+ description: "Open the live background-job flow panel: /braid [jobId [nodeId|executionId]]",
408
+ getArgumentCompletions: (prefix) => braidCompletions(jobs, prefix),
165
409
  handler: async (args, ctx) => {
166
410
  if (ctx.mode !== "tui")
167
411
  throw new Error("The Braid panel requires interactive Pi. Use braid_status for job status.");
168
- const jobId = args.trim() || undefined;
412
+ const [jobId, target] = splitArguments(args.trim());
169
413
  if (jobId && !jobs.get(jobId))
170
414
  throw jobs.unknownJob(jobId);
415
+ const focus = jobId && target ? resolveFocus(jobs, jobId, target) : undefined;
171
416
  let panel;
172
417
  try {
173
418
  await ctx.ui.custom((tui, theme, _keys, done) => {
174
- panel = new BraidPanel(jobs, tui, theme, done, jobId);
419
+ panel = new BraidPanel(jobs, tui, theme, done, jobId, focus);
175
420
  return panel;
176
421
  }, {
177
422
  overlay: true,
@@ -188,3 +433,65 @@ export function registerBraidCommand(pi, jobs) {
188
433
  },
189
434
  });
190
435
  }
436
+ /** Job IDs never contain whitespace; node IDs may, so the rest is one target. */
437
+ function splitArguments(text) {
438
+ const split = text.search(/\s/);
439
+ if (split < 0)
440
+ return [text || undefined, undefined];
441
+ const jobId = text.slice(0, split);
442
+ const target = text.slice(split).trim();
443
+ if (!target.startsWith('"'))
444
+ return [jobId, target || undefined];
445
+ try {
446
+ return [jobId, JSON.parse(target)];
447
+ }
448
+ catch {
449
+ throw new Error('A quoted Braid target must be a valid JSON string, for example " review ".');
450
+ }
451
+ }
452
+ /** Quote IDs that would otherwise be trimmed, parsed as quotes, or rendered as controls. */
453
+ function formatTarget(id) {
454
+ if (id === id.trim() && !id.startsWith('"') && !/[\p{Cc}\u2028\u2029]/u.test(id))
455
+ return id;
456
+ return JSON.stringify(id).replace(/\u2028/gu, "\\u2028").replace(/\u2029/gu, "\\u2029");
457
+ }
458
+ /** Node IDs take precedence; anything else must be an exact execution ID. */
459
+ function resolveFocus(jobs, jobId, target) {
460
+ try {
461
+ jobs.getNode(jobId, target);
462
+ return { nodeId: target };
463
+ }
464
+ catch { }
465
+ try {
466
+ jobs.getNode(jobId, undefined, target);
467
+ return { executionId: target };
468
+ }
469
+ catch { }
470
+ const handle = jobs.get(jobId)?.handle ?? jobId;
471
+ throw new Error(`Unknown Braid node or execution "${plain(target)}" in ${handle}. Open /braid ${handle} to see its nodes.`);
472
+ }
473
+ /** Pi replaces the whole argument text, so node suggestions repeat the job handle. */
474
+ function braidCompletions(jobs, prefix) {
475
+ const text = prefix.trimStart();
476
+ const split = text.search(/\s/);
477
+ const jobPrefix = split < 0 ? text : text.slice(0, split);
478
+ if (split < 0) {
479
+ const items = jobs.list()
480
+ .filter((job) => job.handle.startsWith(jobPrefix))
481
+ .map((job) => ({ value: job.handle, label: job.handle, description: `${job.status} · ${plain(job.goal)}` }));
482
+ return items.length ? items : null;
483
+ }
484
+ const nodePrefix = text.slice(split).trimStart();
485
+ const job = jobs.get(jobPrefix);
486
+ if (!job)
487
+ return null;
488
+ const nodes = job.result?.nodes ?? job.execution?.nodes ?? job.live.nodes;
489
+ const items = Object.values(nodes)
490
+ .flatMap((node) => {
491
+ const target = formatTarget(node.id);
492
+ return node.id.startsWith(nodePrefix) || target.startsWith(nodePrefix)
493
+ ? [{ value: `${job.handle} ${target}`, label: target, description: node.status }]
494
+ : [];
495
+ });
496
+ return items.length ? items : null;
497
+ }