@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 +97 -1
- package/dist/command.js +346 -39
- package/dist/display.js +165 -18
- package/dist/index.js +23 -4
- package/dist/jobs.js +58 -3
- package/dist/review.js +64 -0
- package/dist/runner.js +90 -21
- package/dist/session-view.js +112 -0
- package/dist/transcript.js +100 -0
- package/package.json +2 -2
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
|
-
/**
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
|
113
|
-
? "←/→
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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, "
|
|
128
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
150
|
-
this.
|
|
151
|
-
|
|
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()
|
|
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
|
+
}
|