@chrok/pi-braid 0.1.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Epsirom
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,344 @@
1
+ # Braid for Pi (`@chrok/pi-braid`)
2
+
3
+ This optional Pi integration runs Braid graphs as background jobs. It registers:
4
+
5
+ - `braid` — submit a complete DAG and immediately receive a `jobId`.
6
+ - `braid_status` — retrieve progress and results with `{ "jobId": "..." }`, or
7
+ omit the ID to list jobs in the current session.
8
+ - `braid_cancel` — cancel a job with `{ "jobId": "..." }`.
9
+ - `/braid [jobId]` — open a live flow panel in interactive Pi.
10
+
11
+ Submission and completion reminders use short session handles such as `job-1`.
12
+ Status, cancellation and the panel accept either that exact handle or the original
13
+ UUID. Unknown IDs report available handles; IDs are never guessed or fuzzy-matched.
14
+
15
+ The parent can continue independent work or finish its response while a job runs.
16
+ On completion, failure, or cancellation, the extension sends a custom
17
+ `system-reminder` containing the job ID and a request to retrieve its results.
18
+ Pi queues it as a follow-up during streaming; when idle, it starts a new agent
19
+ turn automatically. The agent should wait for this reminder rather than poll.
20
+ Stopping the foreground response does not stop background jobs.
21
+
22
+ Jobs live in memory for the current Pi session. Quitting, reloading extensions,
23
+ or switching/forking sessions aborts outstanding work and suppresses its
24
+ reminders. Job IDs cannot be retrieved after that lifecycle ends. They are not
25
+ persistent processes outside Pi.
26
+
27
+ ## Live flow panel
28
+
29
+ Run `/braid` to open the newest job, or `/braid <jobId>` to open a specific job.
30
+ The bordered panel keeps the job header and keyboard controls visible while
31
+ you scroll the flow and event log. It refreshes as nodes start, finish, fail,
32
+ and pass outputs downstream.
33
+ Use Left/Right to select jobs, Up/Down or Page Up/Page Down to scroll, `c` to
34
+ cancel the selected job, and Escape or `q` to close the panel. Closing the panel
35
+ leaves jobs running. In RPC or noninteractive modes, use `braid_status`.
36
+
37
+ The panel renders a Mermaid flowchart, node states, elapsed times, context-token
38
+ estimates or provider-reported usage, context-window sizes, filesystem tool-call
39
+ counts, and the execution log. Active nodes are marked `▶ ACTIVE`. The status
40
+ tool also renders a flowchart; expand its result to see more log events.
41
+
42
+ `/braid` now opens this panel; it no longer arms the next prompt. To request
43
+ Braid explicitly, ask the agent to analyze the task using Braid.
44
+
45
+ Braid owns graph validation, scheduling, joins, routing, skip/failure propagation,
46
+ timeouts, Git worktree/checkpoint/merge lifecycle, and result metadata. Pi owns
47
+ model lookup, credentials/OAuth, provider transport, filesystem tool execution,
48
+ and token/cost accounting. The first
49
+ `braid_status` retrieval of a finished job reports its accumulated Pi usage;
50
+ subsequent retrievals do not count the same usage again.
51
+
52
+ ## When Pi will use Braid
53
+
54
+ Tool selection is made by the parent Pi model. It is not possible for an
55
+ extension to force a model tool call safely for every prompt. This adapter adds
56
+ an explicit per-turn planning policy to Pi's system prompt and tool metadata:
57
+
58
+ - for code reviews, bug investigations, design comparisons, test planning, or
59
+ changes spanning multiple files, call Braid first when two or more concerns
60
+ can be handled independently; nodes can inspect the project and edit isolated
61
+ worktrees in Git repositories;
62
+ - do not use Braid for simple one-step answers, trivial direct edits, or shell
63
+ work; keep tests and shell commands in the parent agent;
64
+ - the user does not need to say “Braid” or design the graph;
65
+ - when Braid fits, the model should construct and submit the complete graph
66
+ immediately, continue independent work, and retrieve the terminal outputs after
67
+ the completion reminder.
68
+
69
+ This is a recommendation to the model, not hard enforcement. If a model still
70
+ ignores the policy, use a short instruction such as “decompose this with Braid”
71
+ or strengthen the project/system prompt for that model. The adapter explicitly
72
+ asks the model to make the delegation choice before directly inspecting the
73
+ repository. Do not add a generic `always call braid` rule: that would waste
74
+ model calls and bypass direct tools.
75
+ Merge agents review and integrate node changes; the parent reviews results and runs tests.
76
+ Each node gets a new Pi AI context containing only the Braid goal, its node prompt,
77
+ labelled direct predecessor outputs, and workspace metadata. It receives Pi's
78
+ `read` and `ls`, plus `grep` when local `rg` is available and `find` when
79
+ local `fd`/`fdfind` is available. Missing search dependencies are reported in the
80
+ node prompt, with `ls`/`read` as alternatives. Dependencies are checked before
81
+ exposing search tools and again before executing them; missing tools are not
82
+ installed by Braid. Git worktrees additionally receive `write` and `edit`.
83
+ It receives no parent transcript, shell tools, test runner, skills, or arbitrary
84
+ code execution. Decision nodes additionally receive `decide`. Git nodes receive
85
+ local Git inspection; merge nodes also receive Git integration commands and
86
+ `finish_merge`. Merge agents receive bounded changed-file lists, diff statistics
87
+ and previews, plus the source checkout's dirty status. The model-facing `git`
88
+ tool has a role-specific `command` enum and separate `args`; `finish_merge` lists
89
+ only the current source IDs and diagnoses missing, duplicate or unexpected IDs.
90
+
91
+ ## Install from npm
92
+
93
+ Requires Node.js 22.19+ and Pi 0.85.1 (the tested version):
94
+
95
+ ```sh
96
+ pi install npm:@chrok/pi-braid
97
+ ```
98
+
99
+ Add `-l` for a project-local installation. Run `/reload` after installation.
100
+ The package includes compiled Braid core code from the matching release; it does
101
+ not depend on a source checkout. Pi supplies its core peer packages at runtime.
102
+ Their wildcard ranges follow Pi's packaging convention, not universal version
103
+ compatibility. Development and CI pin Pi 0.85.1.
104
+
105
+ ## Install this local checkout in Pi
106
+
107
+ From the repository root:
108
+
109
+ ```sh
110
+ npm ci
111
+ npm ci --prefix integrations/pi
112
+ npm run build:pi
113
+ pi install ./integrations/pi
114
+ ```
115
+
116
+ Run these commands from the repository root. To install for only one project,
117
+ add `-l`:
118
+
119
+ ```sh
120
+ pi install -l ./integrations/pi
121
+ ```
122
+
123
+ For local installations, rebuild with `npm run build:pi` after source edits,
124
+ then reload Pi. In Pi, run `/reload`. Restart Pi if the package was installed into an
125
+ already running process and the tool does not appear. Inspect installation with:
126
+
127
+ ```sh
128
+ pi list
129
+ pi config
130
+ ```
131
+
132
+ The extension loads its own compiled `dist/` and the Pi host dependencies.
133
+ `npm ci --prefix integrations/pi` installs the pinned development environment;
134
+ use `npm install --prefix integrations/pi` when intentionally updating its lockfile. The adapter uses the `grok-mermaid` terminal renderer for Mermaid flowcharts.
135
+ The local install is trusted code: Pi extensions execute with the process's full
136
+ permissions.
137
+
138
+ ## Test the adapter without spending money
139
+
140
+ After both `npm ci` commands above, verify the core and adapter:
141
+
142
+ ```sh
143
+ npm run check
144
+ npm test
145
+ npm run build
146
+ npm run check:pi
147
+ npm run test:pi
148
+ npm run demo
149
+ ```
150
+
151
+ The Pi adapter tests use a fake model registry and assert exact model lookup,
152
+ fresh contexts, tool isolation, decision handling, filesystem tool execution,
153
+ continuation behavior, usage aggregation, context-token progress, and tool counts.
154
+ Renderer tests cover Mermaid topology, live active-node highlighting,
155
+ handoff/failure logs, expanded per-node output, and bounded event previews.
156
+ Background-job tests cover immediate submission, foreground independence,
157
+ completion reminders, explicit cancellation, shutdown, usage accounting, and
158
+ large-result retrieval. Panel tests cover navigation, scrolling, and cleanup.
159
+ They make no provider requests.
160
+
161
+ ## Node filesystem capabilities
162
+
163
+ Core owns workspace preparation, checkpointing, serialization, and cleanup for
164
+ all integrations. Pi exposes `read` and `ls` in all directories, plus search tools whose local dependencies are available.
165
+ In Git, execute and decision nodes also get `write`/`edit` restricted to their own
166
+ detached worktree, plus local Git inspection. Outside Git, filesystem tools stay
167
+ read-only. Nodes never receive shell commands or a test runner.
168
+
169
+ The initial snapshot includes tracked staged/unstaged changes, deletions, and
170
+ non-ignored untracked files. It preserves the source index and files. Ignored
171
+ files are not copied; submodules are not initialized or recursively snapshotted,
172
+ and Pi rejects writes inside them to keep checkpoint recovery complete.
173
+ Every worker shares that baseline until a merge ends, after which new workers
174
+ snapshot the current source checkout. Uncommitted predecessor changes are not
175
+ implicitly applied to downstream workers. Their paths and checkpoint refs are
176
+ available as context for inspection.
177
+
178
+ A `merge` node accepts multiple predecessors and an optional prompt/model. It
179
+ operates in the source checkout, with guarded `write`/`edit` and local `git`
180
+ commands (`add`, `commit`, `merge`, `cherry-pick`, `apply`, `restore`, plus
181
+ inspection). The agent decides which changes to use and how to integrate them.
182
+ Core never automatically merges or cherry-picks. The agent must call
183
+ `finish_merge` with `integrated`, `discarded`, or `archived` and a reason for every
184
+ source. Tool errors and conflicts go back to the agent for recovery. Failed
185
+ predecessors pass errors and partial work along unconditional edges.
186
+
187
+ Core removes processed source worktrees after the merge agent finishes. If any
188
+ worktrees remain after declared nodes settle, core appends a final merge agent.
189
+ Its model, tool calls, budgets, events, and usage behave like any other node.
190
+ Missing finish calls, unresolved conflicts, or archived sources fail the merge.
191
+ Cancellation, timeout, and failure archive remaining changes and clean worktrees;
192
+ they do not start new merge agents after graph cancellation.
193
+
194
+ `braid_status` includes core's `workspaces` map with workspace paths, states,
195
+ reasons, `checkpointRef`, and pre-merge `backupRef`. The panel distinguishes active
196
+ worktrees from cleaned workspaces. Worktrees use
197
+ `os.tmpdir()/braid-workspaces-*/<unique-id>`; after removal their contents remain
198
+ recoverable from `refs/braid/checkpoints/*`. Use `git show <checkpointRef>:<path>`
199
+ or `git diff <snapshotCommit> <checkpointRef>` to inspect archived changes.
200
+ Remove individual recovery refs with `git update-ref -d <ref>` once reviewed.
201
+
202
+ A failed merge does not reset partial changes or conflict state in the source
203
+ checkout. Its `backupRef` preserves the pre-agent snapshot. Cleanup errors report
204
+ retained paths instead of silently claiming success. A process crash cannot run
205
+ cleanup. The merge mutex coordinates runs in the same process only; avoid parent
206
+ edits to the source checkout while a merge agent is running.
207
+
208
+ File writes reject external paths, Git metadata, symlinks, hard links, and special
209
+ files. Read access follows Pi's normal permissions. This does not replace an OS
210
+ sandbox against concurrent filesystem attacks. For programmatic use, pass
211
+ `createPiRunner(...)` to core `braid(..., { cwd, runner })`; calling the runner
212
+ directly without a core workspace gives read-only capabilities.
213
+
214
+ Tool and time budgets are unlimited by default in Pi. To set finite hard limits,
215
+ pass any of these fields in the `braid` tool's `options`:
216
+
217
+ | Option | Meaning |
218
+ | --- | --- |
219
+ | `maxToolRounds` | Maximum assistant responses containing tool calls, per node |
220
+ | `maxToolCalls` | Maximum total requested tool calls, per node |
221
+ | `nodeTimeoutMs` | Time allowed for each node after it starts, in milliseconds |
222
+ | `graphTimeoutMs` | Time allowed for the entire graph, including queueing, in milliseconds |
223
+
224
+ For example, `options: { maxToolRounds: 20, maxToolCalls: 60, nodeTimeoutMs: 120000 }`.
225
+ Omit a field for no limit; programmatic runner/job options also accept `Infinity`.
226
+ Tool limits must be positive safe integers. Counts include `decide`, `git`,
227
+ `finish_merge`, and rejected
228
+ tool requests. A batch exceeding either tool limit is rejected before execution
229
+ and fails the node; a final text response is still allowed at the exact limit.
230
+
231
+ When any budget is finite, the worker's system prompt contains a `system-reminder`
232
+ before its first model call and refreshes it before each continuation. It reports
233
+ finite tool limits and remaining rounds/calls, and remaining node/graph time.
234
+ Workers must reserve a call for `decide` or `finish_merge` when required and finish
235
+ within the remaining budgets. Graph time is shared across all nodes; a queued
236
+ node receives the remaining graph time, not a fresh graph timeout. Reminders do
237
+ not extend deadlines or interrupt an in-flight model response.
238
+
239
+ ## Test real Pi models
240
+
241
+ Authenticate Pi normally first, for example with `/login`, an environment key,
242
+ or an existing `~/.pi/agent/auth.json`. Select a model with `/model`. Braid uses
243
+ that exact current model by default. Per-node overrides use an exact
244
+ `provider/modelId`, for example `anthropic/claude-sonnet-4-5` or
245
+ `openai/gpt-5-mini`.
246
+
247
+ Ask Pi to make one very small test call (this explicitly names Braid so it
248
+ also verifies the tool wiring):
249
+
250
+ ```text
251
+ Use the braid tool with this graph:
252
+ - goal: "Return the word PASS."
253
+ - one execute node: id "check", prompt "Return exactly PASS."
254
+ - no edges
255
+ Use the currently selected Pi model. Do not use any other tool.
256
+ ```
257
+
258
+ Pi should call `braid` and show a completed result whose terminal output is
259
+ keyed by `check`. This is a billable model request. A slightly richer routing
260
+ test is:
261
+
262
+ ```text
263
+ Use braid with this complete graph. Goal: "Test routing."
264
+ Nodes:
265
+ 1. decision id route, prompt "Select go and then explain briefly", choices ["go", "stop"]
266
+ 2. execute id left, prompt "Return LEFT"
267
+ 3. execute id right, prompt "Return RIGHT"
268
+ 4. execute id join, prompt "List your predecessor IDs and outputs"
269
+ Edges:
270
+ - route -> left labelled choice go
271
+ - route -> right labelled choice stop
272
+ - left -> join
273
+ Ask the decision node to choose go. Use the current Pi model.
274
+ ```
275
+
276
+ Expected behavior:
277
+
278
+ - `route` completes with decision `go`.
279
+ - `left` runs.
280
+ - `right` is `skipped` with reason `inactive`.
281
+ - `join` receives only `left` as a labelled predecessor.
282
+ - `join` appears in `terminalOutputs`.
283
+ - The Pi TUI shows a compact graph summary rather than the full input JSON.
284
+ - Submission returns a job ID immediately. Open `/braid` to see active nodes
285
+ and the execution log update with starts and handoffs.
286
+ - Completion sends a reminder and resumes an idle agent; `braid_status` retrieves
287
+ the finished result.
288
+ - The result shows completion/failure, node counts, terminal IDs, decisions, failures, skips, and the execution log. Expand the result row to see per-node output and more events.
289
+
290
+ To test per-node model selection, add `model: "provider/modelId"` to one node.
291
+ Use an exact ID shown by `/model` or `pi --list-models`; an unrecognized model
292
+ fails that node cleanly and does not invoke another provider.
293
+
294
+ ## Cancellation and limits
295
+
296
+ The Pi extension has no node or graph time limit by default. Use `braid_cancel`
297
+ or press `c` in the flow panel to abort running nodes and mark queued nodes
298
+ `cancelled`. Escape stops the foreground response or closes the panel without
299
+ cancelling jobs. To set a node or graph timeout, supply milliseconds in the
300
+ submission tool input under `options`; each omitted timeout remains unlimited:
301
+
302
+ ```json
303
+ {
304
+ "maxConcurrency": 2,
305
+ "nodeTimeoutMs": 30000,
306
+ "graphTimeoutMs": 120000
307
+ }
308
+ ```
309
+
310
+ The adapter sets `maxRetries: 0`, `cacheRetention: "none"`, and gives each node a
311
+ fresh provider context. Tool budgets are unlimited unless `maxToolRounds` or
312
+ `maxToolCalls` is set. Missing paths, invalid arguments, disallowed writes, and
313
+ unavailable tools are returned as tool errors so the node can recover. Provider
314
+ authentication and calls may still incur normal provider costs. Braid cannot
315
+ forcibly stop synchronous JavaScript or a remote provider that ignores
316
+ cancellation. Cancellation prevents further tool calls but does not roll back
317
+ writes already made. Core waits for tracked writes, preserves checkpoints, and
318
+ removes worker worktrees before completing cancellation.
319
+
320
+ The tool output is capped at 50KB/2000 lines to protect Pi context. When exceeded,
321
+ the adapter writes a full JSON result to a temporary file (mode 600 on POSIX;
322
+ inherited ACLs on Windows) and includes
323
+ its path in the tool output.
324
+
325
+ For a test of proactive selection, start a fresh Pi turn with a task such as:
326
+
327
+ ```text
328
+ Compare these three proposed designs, identify independent risks for each,
329
+ and finish with a recommendation. You may use the available execution
330
+ primitives when they improve the result.
331
+ ```
332
+
333
+ The adapter's policy tells Pi to consider Braid because this task has multiple
334
+ independent reasoning branches and a final synthesis. Whether it actually calls
335
+ the tool remains model-dependent; inspect the transcript for the `braid` tool
336
+ submission, completion reminder, and the live graph/handoff log in `/braid`.
337
+
338
+ ## Live end-to-end tests
339
+
340
+ The reusable RPC suite is in [test/live/README.md](test/live/README.md). It uses
341
+ your local Pi installation and configured credentials with real provider calls.
342
+ Run it explicitly; it is separate from the deterministic tests and incurs model
343
+ usage. It saves prompts, actual Braid parameters, node/tool transcripts, Git/file
344
+ assertions and a Markdown report in a temporary output directory.
@@ -0,0 +1,190 @@
1
+ import { matchesKey, stripTerminalSequences, truncateToWidth, visibleWidth, } from "@earendil-works/pi-tui";
2
+ import { renderGraphResult } from "./display.js";
3
+ // Match the overlay height exactly so Pi never clips the footer or bottom border.
4
+ const panelHeight = (rows) => Math.max(1, Math.floor(rows * 0.9));
5
+ const plain = (value) => stripTerminalSequences(value).replace(/\p{Cc}/gu, " ");
6
+ /** A live, scrollable panel. Closing the panel leaves jobs running. */
7
+ export class BraidPanel {
8
+ jobs;
9
+ tui;
10
+ theme;
11
+ done;
12
+ selected;
13
+ offset = 0;
14
+ maxOffset = 0;
15
+ disposed = false;
16
+ unsubscribe;
17
+ ticker;
18
+ constructor(jobs, tui, theme, done, jobId) {
19
+ this.jobs = jobs;
20
+ this.tui = tui;
21
+ this.theme = theme;
22
+ this.done = done;
23
+ this.selected = jobId ? jobs.get(jobId)?.jobId ?? jobId : undefined;
24
+ this.unsubscribe = jobs.subscribe(() => this.refresh());
25
+ this.ticker = setInterval(() => this.refresh(), 1000);
26
+ this.ticker.unref();
27
+ }
28
+ refresh() {
29
+ if (!this.disposed)
30
+ this.tui.requestRender();
31
+ }
32
+ render(width) {
33
+ const rows = panelHeight(this.tui.terminal.rows);
34
+ if (width < 8 || rows < 4) {
35
+ return [truncateToWidth("Braid · Esc close", width, "")];
36
+ }
37
+ const innerWidth = width - 2;
38
+ const contentWidth = width - 4;
39
+ const border = (value) => this.theme.fg("borderMuted", value);
40
+ const rule = (left, right, label = "") => {
41
+ const title = truncateToWidth(label ? ` ${label} ` : "", innerWidth, "");
42
+ return (border(left) +
43
+ this.theme.fg("accent", this.theme.bold(title)) +
44
+ border("─".repeat(innerWidth - visibleWidth(title)) + right));
45
+ };
46
+ const row = (value) => border("│") +
47
+ " " +
48
+ truncateToWidth(value, contentWidth, "…", true) +
49
+ " " +
50
+ border("│");
51
+ const jobs = this.jobs.list();
52
+ const summary = jobs.find((job) => job.jobId === this.selected) ?? jobs[0];
53
+ const current = summary ? this.jobs.get(summary.jobId) : undefined;
54
+ this.selected = current?.jobId;
55
+ const index = jobs.findIndex((job) => job.jobId === current?.jobId);
56
+ const statusColor = current?.status === "running"
57
+ ? "warning"
58
+ : current?.status === "completed"
59
+ ? "success"
60
+ : current?.status === "failed"
61
+ ? "error"
62
+ : "muted";
63
+ const heading = current
64
+ ? `Job ${index + 1} of ${jobs.length} · ${this.theme.fg(statusColor, current.status)}`
65
+ : this.theme.fg("muted", "No background jobs");
66
+ // Reserve a body row and the close hint even in a very short terminal.
67
+ if (rows < 10) {
68
+ const compact = [
69
+ heading,
70
+ ...(current
71
+ ? [plain(current.goal)]
72
+ : ["Ask the agent to run a Braid graph."]),
73
+ ];
74
+ return [
75
+ rule("╭", "╮", "Braid"),
76
+ ...compact.slice(0, rows - 3).map(row),
77
+ row("Esc close"),
78
+ rule("╰", "╯"),
79
+ ];
80
+ }
81
+ const header = [
82
+ rule("╭", "╮", "Braid"),
83
+ row(heading),
84
+ row(current ? plain(current.goal) : "Ask the agent to run a Braid graph."),
85
+ row(this.theme.fg("dim", current
86
+ ? current.jobId
87
+ : "Jobs will appear here as soon as they are submitted.")),
88
+ rule("├", "┤"),
89
+ ];
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;
106
+ this.maxOffset = Math.max(0, content.length - height);
107
+ this.offset = Math.min(this.offset, this.maxOffset);
108
+ const body = content.slice(this.offset, this.offset + height);
109
+ while (body.length < height)
110
+ body.push("");
111
+ 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";
117
+ return [
118
+ ...header,
119
+ ...body.map(row),
120
+ rule("├", "┤"),
121
+ row(this.theme.fg("dim", `${range}${contentWidth >= 60 ? " · Jobs keep running when this panel closes" : ""}`)),
122
+ row(this.theme.fg("muted", help)),
123
+ rule("╰", "╯"),
124
+ ];
125
+ }
126
+ handleInput(data) {
127
+ if (matchesKey(data, "escape") ||
128
+ data === "q" ||
129
+ matchesKey(data, "ctrl+c")) {
130
+ this.dispose();
131
+ this.done();
132
+ return;
133
+ }
134
+ const jobs = this.jobs.list();
135
+ const index = Math.max(0, jobs.findIndex((job) => job.jobId === this.selected));
136
+ if (matchesKey(data, "left") || matchesKey(data, "right")) {
137
+ const step = matchesKey(data, "right") ? 1 : -1;
138
+ this.selected = jobs[(index + step + jobs.length) % jobs.length]?.jobId;
139
+ this.offset = 0;
140
+ }
141
+ else if (matchesKey(data, "up"))
142
+ this.offset = Math.max(0, this.offset - 1);
143
+ else if (matchesKey(data, "down"))
144
+ this.offset = Math.min(this.maxOffset, this.offset + 1);
145
+ else if (matchesKey(data, "pageUp"))
146
+ this.offset = Math.max(0, this.offset - 10);
147
+ else if (matchesKey(data, "pageDown"))
148
+ 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();
152
+ }
153
+ invalidate() { }
154
+ dispose() {
155
+ if (this.disposed)
156
+ return;
157
+ this.disposed = true;
158
+ clearInterval(this.ticker);
159
+ this.unsubscribe();
160
+ }
161
+ }
162
+ export function registerBraidCommand(pi, jobs) {
163
+ pi.registerCommand("braid", {
164
+ description: "Open the live background-job flow panel: /braid [jobId]",
165
+ handler: async (args, ctx) => {
166
+ if (ctx.mode !== "tui")
167
+ throw new Error("The Braid panel requires interactive Pi. Use braid_status for job status.");
168
+ const jobId = args.trim() || undefined;
169
+ if (jobId && !jobs.get(jobId))
170
+ throw jobs.unknownJob(jobId);
171
+ let panel;
172
+ try {
173
+ await ctx.ui.custom((tui, theme, _keys, done) => {
174
+ panel = new BraidPanel(jobs, tui, theme, done, jobId);
175
+ return panel;
176
+ }, {
177
+ overlay: true,
178
+ overlayOptions: {
179
+ anchor: "center",
180
+ width: "95%",
181
+ maxHeight: "90%",
182
+ },
183
+ });
184
+ }
185
+ finally {
186
+ panel?.dispose();
187
+ }
188
+ },
189
+ });
190
+ }