@chrok/pi-braid 0.3.3 → 0.3.5
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 +79 -25
- package/dist/assistant.js +345 -0
- package/dist/command.js +259 -34
- package/dist/display.js +19 -4
- package/dist/human-input.js +60 -0
- package/dist/index.js +41 -82
- package/dist/jobs.js +47 -15
- package/dist/runner.js +33 -4
- package/dist/session-view.js +27 -1
- package/dist/shell-tools.js +12 -5
- package/dist/status-text.js +179 -0
- package/dist/transcript.js +14 -0
- package/package.json +5 -5
package/dist/command.js
CHANGED
|
@@ -1,18 +1,24 @@
|
|
|
1
|
-
import { matchesKey, stripTerminalSequences, truncateToWidth, visibleWidth, } from "@earendil-works/pi-tui";
|
|
1
|
+
import { Input, matchesKey, stripTerminalSequences, truncateToWidth, visibleWidth, wrapTextWithAnsi, } from "@earendil-works/pi-tui";
|
|
2
2
|
import { renderGraphResult, renderNodeDetails, renderSessionHeader, SELECTED_NODE_MARK } from "./display.js";
|
|
3
3
|
import { NodeSessionView } from "./session-view.js";
|
|
4
4
|
// Match the overlay height exactly so Pi never clips the footer or bottom border.
|
|
5
5
|
const panelHeight = (rows) => Math.max(1, Math.floor(rows * 0.9));
|
|
6
6
|
const plain = (value) => stripTerminalSequences(value).replace(/\p{Cc}/gu, " ");
|
|
7
|
+
/** Below this content width the graph and the assistant share the body, switched with Tab. */
|
|
8
|
+
const SPLIT_WIDTH = 100;
|
|
9
|
+
/** Marks a separator row so it can span the whole panel or only the left column. */
|
|
10
|
+
const SEPARATOR = "\u0000separator";
|
|
7
11
|
/**
|
|
8
12
|
* A live, scrollable panel. Esc steps back one level: session → node selection →
|
|
9
|
-
* graph → close. Closing the panel leaves jobs running.
|
|
13
|
+
* graph → close. Closing the panel leaves jobs running. Tab toggles an assistant
|
|
14
|
+
* that answers questions about the job and can message running executions.
|
|
10
15
|
*/
|
|
11
16
|
export class BraidPanel {
|
|
12
17
|
jobs;
|
|
13
18
|
tui;
|
|
14
19
|
theme;
|
|
15
20
|
done;
|
|
21
|
+
assistantModel;
|
|
16
22
|
selected;
|
|
17
23
|
focus;
|
|
18
24
|
/** Node highlighted in selection mode (`v`); undefined in the plain graph view. */
|
|
@@ -24,18 +30,46 @@ export class BraidPanel {
|
|
|
24
30
|
offset = 0;
|
|
25
31
|
maxOffset = 0;
|
|
26
32
|
disposed = false;
|
|
33
|
+
/** While open, the assistant receives keys; it shares a wide panel and fills a narrow one. */
|
|
34
|
+
assistantOpen = false;
|
|
35
|
+
/** Where the body sits in the last frame, for routing mouse wheel events. */
|
|
36
|
+
layout;
|
|
37
|
+
/** Assistant lines scrolled up from the newest. */
|
|
38
|
+
chatOffset = 0;
|
|
39
|
+
chatMaxOffset = 0;
|
|
40
|
+
chatScope;
|
|
41
|
+
input;
|
|
27
42
|
session;
|
|
43
|
+
chat;
|
|
28
44
|
unsubscribe;
|
|
29
45
|
ticker;
|
|
30
|
-
constructor(jobs, tui, theme, done, jobId, focus) {
|
|
46
|
+
constructor(jobs, tui, theme, done, jobId, focus, assistantModel = () => ({ registry: { complete: () => Promise.reject(new Error("No Pi model registry")) } })) {
|
|
31
47
|
this.jobs = jobs;
|
|
32
48
|
this.tui = tui;
|
|
33
49
|
this.theme = theme;
|
|
34
50
|
this.done = done;
|
|
51
|
+
this.assistantModel = assistantModel;
|
|
35
52
|
this.selected = jobId ? jobs.get(jobId)?.jobId ?? jobId : undefined;
|
|
36
53
|
this.focus = focus;
|
|
37
54
|
// Pi's tool components only request renders through the TUI.
|
|
38
55
|
this.session = new NodeSessionView(tui, theme);
|
|
56
|
+
this.chat = new NodeSessionView(tui, theme);
|
|
57
|
+
this.input = new Input({
|
|
58
|
+
prompt: theme.fg("accent", "› "),
|
|
59
|
+
placeholder: "Ask, or say how to steer…",
|
|
60
|
+
placeholderStyle: text => theme.fg("dim", text),
|
|
61
|
+
});
|
|
62
|
+
this.input.onSubmit = value => {
|
|
63
|
+
if (!value.trim() || !this.selected)
|
|
64
|
+
return;
|
|
65
|
+
const job = this.jobs.get(this.selected);
|
|
66
|
+
if (!job)
|
|
67
|
+
return;
|
|
68
|
+
this.jobs.assistant(job.jobId, this.assistantNode(job)).send(value, this.describeView(job), this.assistantModel());
|
|
69
|
+
this.input.setValue("");
|
|
70
|
+
this.chatOffset = 0;
|
|
71
|
+
this.refresh();
|
|
72
|
+
};
|
|
39
73
|
this.unsubscribe = jobs.subscribe(() => this.refresh());
|
|
40
74
|
this.ticker = setInterval(() => this.refresh(), 1000);
|
|
41
75
|
this.ticker.unref();
|
|
@@ -108,16 +142,56 @@ export class BraidPanel {
|
|
|
108
142
|
if (current && this.choosing !== undefined && !this.nodeIds(current).includes(this.choosing)) {
|
|
109
143
|
this.choosing = this.nodeIds(current)[0];
|
|
110
144
|
}
|
|
145
|
+
if (!current)
|
|
146
|
+
this.assistantOpen = false;
|
|
147
|
+
const available = rows - header.length - 4;
|
|
148
|
+
const open = current !== undefined && this.assistantOpen;
|
|
149
|
+
const split = open && contentWidth >= SPLIT_WIDTH;
|
|
150
|
+
const leftWidth = split ? Math.floor((contentWidth - 3) * 0.6) : contentWidth;
|
|
151
|
+
const chatWidth = split ? contentWidth - leftWidth - 3 : contentWidth;
|
|
152
|
+
const graph = !open || split ? this.renderGraphPane(current, leftWidth, available) : undefined;
|
|
153
|
+
const chat = open ? this.renderAssistant(current, chatWidth, available) : [];
|
|
154
|
+
// Columns: border and gutter, then the left pane, " │ ", then the assistant.
|
|
155
|
+
this.layout = { top: header.length, height: available,
|
|
156
|
+
...(open ? { chatLeft: split ? 2 + leftWidth + 3 : 2 } : {}) };
|
|
157
|
+
const body = split
|
|
158
|
+
? graph.lines.map((line, at) => (line === SEPARATOR ? border("─".repeat(leftWidth)) : truncateToWidth(line, leftWidth, "…", true)) +
|
|
159
|
+
" " + border("│") + " " + (chat[at] ?? ""))
|
|
160
|
+
: graph?.lines ?? chat;
|
|
161
|
+
const tab = current ? "Tab assistant " : "";
|
|
162
|
+
const [wide, medium, narrow] = open
|
|
163
|
+
? ["Enter send Esc stop or hide Tab hide PgUp/PgDn or wheel scroll ctrl+c close",
|
|
164
|
+
"Enter send · Esc hide · Tab hide", "Enter send · Tab hide"]
|
|
165
|
+
: this.focus
|
|
166
|
+
? [`${tab}Esc back ←/→ executions ↑/↓ scroll End follow ctrl+o tools c cancel q close`,
|
|
167
|
+
"Esc back · ←/→ executions · ↑/↓ scroll · q close", "Esc back · q close"]
|
|
168
|
+
: this.choosing !== undefined
|
|
169
|
+
? [`${tab}↑/↓/←/→ choose node Enter open session Esc back PgUp/PgDn page q close`,
|
|
170
|
+
"↑/↓ choose · Enter open · Esc back", "Enter open · Esc back"]
|
|
171
|
+
: [`${tab}v select node ←/→ jobs ↑/↓ scroll PgUp/PgDn page c cancel Esc close`,
|
|
172
|
+
"v select node · ←/→ jobs · ↑/↓ scroll · Esc close", "v select · Esc close"];
|
|
173
|
+
const help = contentWidth >= 80 ? wide : contentWidth >= 42 ? medium : narrow;
|
|
174
|
+
const status = graph ? graph.range : "Assistant";
|
|
175
|
+
return [
|
|
176
|
+
...header,
|
|
177
|
+
...body.map(line => line === SEPARATOR ? rule("├", "┤") : row(line)),
|
|
178
|
+
rule("├", "┤"),
|
|
179
|
+
row(this.theme.fg("dim", `${status}${contentWidth >= 60 ? " · Jobs keep running when this panel closes" : ""}`)),
|
|
180
|
+
row(this.theme.fg("muted", help)),
|
|
181
|
+
rule("╰", "╯"),
|
|
182
|
+
];
|
|
183
|
+
}
|
|
184
|
+
/** The graph, node selection, or a node session: pinned summary, separator, scrolled body. */
|
|
185
|
+
renderGraphPane(current, width, available) {
|
|
111
186
|
// The node summary (or the selected node) stays pinned while the body scrolls.
|
|
112
|
-
const focused = current && this.focus ? this.renderFocus(current, this.focus,
|
|
187
|
+
const focused = current && this.focus ? this.renderFocus(current, this.focus, width) : undefined;
|
|
113
188
|
let pinned = focused?.pinned ?? (current && this.choosing !== undefined ? [this.choosingLine(current)] : []);
|
|
114
|
-
const available = rows - header.length - 4;
|
|
115
189
|
const maxPinned = Math.max(2, Math.floor(available / 2));
|
|
116
190
|
if (pinned.length > maxPinned) {
|
|
117
191
|
pinned = [...pinned.slice(0, maxPinned - 1), this.theme.fg("dim", `… ${pinned.length - maxPinned + 1} more summary lines`)];
|
|
118
192
|
}
|
|
119
193
|
const content = focused
|
|
120
|
-
? focused.body
|
|
194
|
+
? [...focused.body, ...(current && focused.executionId ? this.pendingLines(current, focused.executionId) : [])]
|
|
121
195
|
: current
|
|
122
196
|
? [
|
|
123
197
|
...(current.error
|
|
@@ -127,10 +201,7 @@ export class BraidPanel {
|
|
|
127
201
|
...(current.result ?? current.live),
|
|
128
202
|
progress: current.live.progress,
|
|
129
203
|
...(current.workspaces ? { workspaces: current.workspaces } : {}),
|
|
130
|
-
|
|
131
|
-
? { fullOutputPath: current.fullOutputPath }
|
|
132
|
-
: {}),
|
|
133
|
-
}, true, false, this.theme, "", false, this.choosing).render(contentWidth),
|
|
204
|
+
}, true, false, this.theme, "", false, this.choosing).render(width),
|
|
134
205
|
]
|
|
135
206
|
: ["No Braid jobs in this session."];
|
|
136
207
|
const height = available - (pinned.length ? pinned.length + 1 : 0);
|
|
@@ -149,26 +220,96 @@ export class BraidPanel {
|
|
|
149
220
|
const body = content.slice(this.offset, this.offset + height);
|
|
150
221
|
while (body.length < height)
|
|
151
222
|
body.push("");
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
223
|
+
return {
|
|
224
|
+
lines: [...(pinned.length ? [...pinned, SEPARATOR] : []), ...body],
|
|
225
|
+
range: `Lines ${this.offset + 1}–${Math.min(content.length, this.offset + height)}/${content.length}`,
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
/** The job's assistant conversation, newest lines at the bottom, above its input line. */
|
|
229
|
+
renderAssistant(job, width, height) {
|
|
230
|
+
const nodeId = this.assistantNode(job);
|
|
231
|
+
const assistant = this.jobs.assistant(job.jobId, nodeId);
|
|
232
|
+
// Each scope has its own conversation; a new one starts at its newest lines.
|
|
233
|
+
const scope = JSON.stringify([job.jobId, nodeId ?? null]);
|
|
234
|
+
if (scope !== this.chatScope) {
|
|
235
|
+
this.chatScope = scope;
|
|
236
|
+
this.chatOffset = 0;
|
|
237
|
+
}
|
|
238
|
+
const title = this.theme.fg("accent", this.theme.bold(nodeId === undefined ? "Assistant · job" : `Assistant · node ${plain(nodeId)}`)) +
|
|
239
|
+
this.theme.fg("muted", assistant.busy ? " · working… Esc to stop" : " · Tab to hide");
|
|
240
|
+
let lines;
|
|
241
|
+
try {
|
|
242
|
+
lines = assistant.conversation.messages.length
|
|
243
|
+
? this.chat.render(assistant.conversation, width)
|
|
244
|
+
: [
|
|
245
|
+
// Wrapped, not truncated, and kept off the input line like a finished reply.
|
|
246
|
+
...[
|
|
247
|
+
nodeId === undefined
|
|
248
|
+
? "Ask what a node is doing, why it failed, or what it produced."
|
|
249
|
+
: `Ask what ${plain(nodeId)} is doing, why it failed, or what it produced.`,
|
|
250
|
+
"To change a running execution's direction, say what it should do; the assistant messages that execution for you.",
|
|
251
|
+
].flatMap(hint => wrapTextWithAnsi(this.theme.fg("dim", hint), width)),
|
|
252
|
+
"",
|
|
253
|
+
];
|
|
254
|
+
}
|
|
255
|
+
catch (error) {
|
|
256
|
+
lines = [this.theme.fg("error", `Assistant view unavailable: ${plain(error instanceof Error ? error.message : String(error))}`)];
|
|
257
|
+
}
|
|
258
|
+
const visible = Math.max(1, height - 3);
|
|
259
|
+
this.chatMaxOffset = Math.max(0, lines.length - visible);
|
|
260
|
+
this.chatOffset = Math.min(this.chatOffset, this.chatMaxOffset);
|
|
261
|
+
const end = lines.length - this.chatOffset;
|
|
262
|
+
const body = lines.slice(Math.max(0, end - visible), end);
|
|
263
|
+
while (body.length < visible)
|
|
264
|
+
body.unshift("");
|
|
265
|
+
this.input.focused = true;
|
|
266
|
+
const input = this.input.render(width)[0] ?? "";
|
|
267
|
+
return [title, this.theme.fg("borderMuted", "─".repeat(width)), ...body, input].slice(0, height);
|
|
268
|
+
}
|
|
269
|
+
/** Messages the assistant sent that this execution has not received yet. */
|
|
270
|
+
pendingLines(job, executionId) {
|
|
271
|
+
let pending = [];
|
|
272
|
+
try {
|
|
273
|
+
pending = this.jobs.pendingInputs(job.jobId, executionId);
|
|
274
|
+
}
|
|
275
|
+
catch { /* The job was removed; show nothing. */ }
|
|
276
|
+
if (!pending.length)
|
|
277
|
+
return [];
|
|
162
278
|
return [
|
|
163
|
-
...
|
|
164
|
-
|
|
165
|
-
...body.map(row),
|
|
166
|
-
rule("├", "┤"),
|
|
167
|
-
row(this.theme.fg("dim", `${range}${contentWidth >= 60 ? " · Jobs keep running when this panel closes" : ""}`)),
|
|
168
|
-
row(this.theme.fg("muted", help)),
|
|
169
|
-
rule("╰", "╯"),
|
|
279
|
+
...pending.map(input => this.theme.fg("warning", `⧗ pending human input · ${plain(input.text)}`)),
|
|
280
|
+
this.theme.fg("dim", " Delivered before the worker's next model request; running tools are not interrupted."),
|
|
170
281
|
];
|
|
171
282
|
}
|
|
283
|
+
/** The node whose conversation the assistant shows: the open session's, or the selected one. */
|
|
284
|
+
assistantNode(job) {
|
|
285
|
+
if (!this.focus)
|
|
286
|
+
return this.choosing;
|
|
287
|
+
if ("nodeId" in this.focus)
|
|
288
|
+
return this.focus.nodeId;
|
|
289
|
+
try {
|
|
290
|
+
return this.jobs.getNode(job.jobId, undefined, this.focus.executionId).id;
|
|
291
|
+
}
|
|
292
|
+
catch {
|
|
293
|
+
return undefined;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
/** What the user is looking at, so "this node" means something to the assistant. */
|
|
297
|
+
describeView(job) {
|
|
298
|
+
if (this.focus) {
|
|
299
|
+
try {
|
|
300
|
+
const node = "nodeId" in this.focus ? this.jobs.getNode(job.jobId, this.focus.nodeId) : this.jobs.getNode(job.jobId, undefined, this.focus.executionId);
|
|
301
|
+
return `session of node ${JSON.stringify(node.id)}${node.executionId ? `, execution ${node.executionId}` : ""} (${node.status})`;
|
|
302
|
+
}
|
|
303
|
+
catch {
|
|
304
|
+
return "a node that is no longer in the graph";
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
if (this.choosing !== undefined) {
|
|
308
|
+
const node = (job.result?.nodes ?? job.live.nodes)[this.choosing];
|
|
309
|
+
return `job graph with node ${JSON.stringify(this.choosing)} selected (${node?.status ?? "pending"})`;
|
|
310
|
+
}
|
|
311
|
+
return `job graph overview (${job.status})`;
|
|
312
|
+
}
|
|
172
313
|
/** Graph definition order, which is also the flowchart's declaration order. */
|
|
173
314
|
nodeIds(job) {
|
|
174
315
|
return Object.keys(job.result?.nodes ?? job.live.nodes);
|
|
@@ -210,7 +351,7 @@ export class BraidPanel {
|
|
|
210
351
|
let failure;
|
|
211
352
|
if (transcript) {
|
|
212
353
|
try {
|
|
213
|
-
return { pinned: header, body: this.session.render(transcript, width) };
|
|
354
|
+
return { pinned: header, body: this.session.render(transcript, width), executionId: node.executionId };
|
|
214
355
|
}
|
|
215
356
|
catch (error) {
|
|
216
357
|
// A rendering failure must not take down the overlay; fall back to the summary.
|
|
@@ -234,7 +375,21 @@ export class BraidPanel {
|
|
|
234
375
|
this.offset = 0;
|
|
235
376
|
}
|
|
236
377
|
handleInput(data) {
|
|
237
|
-
if (
|
|
378
|
+
if (matchesKey(data, "ctrl+c")) {
|
|
379
|
+
this.close();
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
if (matchesKey(data, "tab") && this.selected) {
|
|
383
|
+
this.assistantOpen = !this.assistantOpen;
|
|
384
|
+
this.refresh();
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
if (this.assistantOpen) {
|
|
388
|
+
this.assistantInput(data);
|
|
389
|
+
this.refresh();
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
if (data === "q") {
|
|
238
393
|
this.close();
|
|
239
394
|
return;
|
|
240
395
|
}
|
|
@@ -314,6 +469,44 @@ export class BraidPanel {
|
|
|
314
469
|
else
|
|
315
470
|
this.scroll(data);
|
|
316
471
|
}
|
|
472
|
+
/** The wheel scrolls whichever side it is over; other mouse events are left to Pi. */
|
|
473
|
+
handleMouse(event) {
|
|
474
|
+
const layout = this.layout;
|
|
475
|
+
if (event.type !== "wheel" || !event.wheelDelta || !layout)
|
|
476
|
+
return undefined;
|
|
477
|
+
if (event.y < layout.top || event.y >= layout.top + layout.height)
|
|
478
|
+
return undefined;
|
|
479
|
+
if (layout.chatLeft !== undefined && event.x >= layout.chatLeft - 1) {
|
|
480
|
+
this.chatOffset = Math.max(0, Math.min(this.chatMaxOffset, this.chatOffset - event.wheelDelta));
|
|
481
|
+
}
|
|
482
|
+
else {
|
|
483
|
+
const before = this.offset;
|
|
484
|
+
this.offset = Math.max(0, Math.min(this.maxOffset, this.offset + event.wheelDelta));
|
|
485
|
+
if (this.offset < before)
|
|
486
|
+
this.follow = false;
|
|
487
|
+
else if (this.offset >= this.maxOffset)
|
|
488
|
+
this.follow = true;
|
|
489
|
+
}
|
|
490
|
+
this.refresh();
|
|
491
|
+
return { handled: true };
|
|
492
|
+
}
|
|
493
|
+
/** Every key belongs to the question, including q and c. */
|
|
494
|
+
assistantInput(data) {
|
|
495
|
+
const job = this.selected ? this.jobs.get(this.selected) : undefined;
|
|
496
|
+
const assistant = job ? this.jobs.assistant(job.jobId, this.assistantNode(job)) : undefined;
|
|
497
|
+
if (matchesKey(data, "escape")) {
|
|
498
|
+
if (assistant?.busy)
|
|
499
|
+
assistant.abort();
|
|
500
|
+
else
|
|
501
|
+
this.assistantOpen = false;
|
|
502
|
+
}
|
|
503
|
+
else if (matchesKey(data, "pageUp"))
|
|
504
|
+
this.chatOffset = Math.min(this.chatMaxOffset, this.chatOffset + 10);
|
|
505
|
+
else if (matchesKey(data, "pageDown"))
|
|
506
|
+
this.chatOffset = Math.max(0, this.chatOffset - 10);
|
|
507
|
+
else
|
|
508
|
+
this.input.handleInput(data);
|
|
509
|
+
}
|
|
317
510
|
scroll(data) {
|
|
318
511
|
const before = this.offset;
|
|
319
512
|
if (matchesKey(data, "up"))
|
|
@@ -348,23 +541,54 @@ export class BraidPanel {
|
|
|
348
541
|
}
|
|
349
542
|
}
|
|
350
543
|
export function registerBraidWidget(pi, jobs) {
|
|
544
|
+
// Clearing hides the jobs present at that moment; jobs submitted later still show.
|
|
545
|
+
const cleared = new Set();
|
|
546
|
+
let lastCleared = [];
|
|
547
|
+
const shown = () => jobs.list().filter(job => !cleared.has(job.jobId));
|
|
548
|
+
let refresh = () => { };
|
|
351
549
|
let dispose = () => { };
|
|
352
550
|
const clear = () => {
|
|
353
551
|
dispose();
|
|
354
552
|
dispose = () => { };
|
|
553
|
+
refresh = () => { };
|
|
355
554
|
};
|
|
555
|
+
pi.registerCommand("braid:clear", {
|
|
556
|
+
description: "Clear the Braid progress widget; run again while it is empty to restore it",
|
|
557
|
+
handler: async (_args, ctx) => {
|
|
558
|
+
if (ctx.mode !== "tui")
|
|
559
|
+
throw new Error("The Braid progress widget requires interactive Pi.");
|
|
560
|
+
const visible = shown();
|
|
561
|
+
if (visible.length) {
|
|
562
|
+
lastCleared = visible.map(job => job.jobId);
|
|
563
|
+
for (const jobId of lastCleared)
|
|
564
|
+
cleared.add(jobId);
|
|
565
|
+
ctx.ui.notify("Cleared Braid progress. Run /braid:clear again to restore it.", "info");
|
|
566
|
+
}
|
|
567
|
+
else if (lastCleared.length) {
|
|
568
|
+
for (const jobId of lastCleared)
|
|
569
|
+
cleared.delete(jobId);
|
|
570
|
+
lastCleared = [];
|
|
571
|
+
ctx.ui.notify("Restored Braid progress.", "info");
|
|
572
|
+
}
|
|
573
|
+
else {
|
|
574
|
+
ctx.ui.notify("No Braid progress to clear.", "info");
|
|
575
|
+
return;
|
|
576
|
+
}
|
|
577
|
+
refresh();
|
|
578
|
+
},
|
|
579
|
+
});
|
|
356
580
|
pi.on("session_start", (_event, ctx) => {
|
|
357
581
|
clear();
|
|
358
582
|
if (ctx.mode !== "tui")
|
|
359
583
|
return;
|
|
360
|
-
|
|
361
|
-
if (!
|
|
584
|
+
refresh = () => {
|
|
585
|
+
if (!shown().length) {
|
|
362
586
|
ctx.ui.setWidget("braid-status", undefined);
|
|
363
587
|
return;
|
|
364
588
|
}
|
|
365
589
|
ctx.ui.setWidget("braid-status", (_tui, theme) => ({
|
|
366
590
|
render(width) {
|
|
367
|
-
const all =
|
|
591
|
+
const all = shown();
|
|
368
592
|
const active = all.filter(job => job.status === "running");
|
|
369
593
|
const selected = active.length ? active.slice(0, 3) : all.slice(0, 1);
|
|
370
594
|
const lines = selected.map(job => {
|
|
@@ -393,7 +617,7 @@ export function registerBraidWidget(pi, jobs) {
|
|
|
393
617
|
invalidate() { },
|
|
394
618
|
}), { placement: "aboveEditor" });
|
|
395
619
|
};
|
|
396
|
-
const unsubscribe = jobs.subscribe(refresh);
|
|
620
|
+
const unsubscribe = jobs.subscribe(() => refresh());
|
|
397
621
|
dispose = () => {
|
|
398
622
|
unsubscribe();
|
|
399
623
|
ctx.ui.setWidget("braid-status", undefined);
|
|
@@ -416,7 +640,8 @@ export function registerBraidCommand(pi, jobs) {
|
|
|
416
640
|
let panel;
|
|
417
641
|
try {
|
|
418
642
|
await ctx.ui.custom((tui, theme, _keys, done) => {
|
|
419
|
-
|
|
643
|
+
// The assistant uses whichever model the main session has selected when asked.
|
|
644
|
+
panel = new BraidPanel(jobs, tui, theme, done, jobId, focus, () => ({ registry: ctx.modelRegistry, ...(ctx.model ? { model: ctx.model } : {}) }));
|
|
420
645
|
return panel;
|
|
421
646
|
}, {
|
|
422
647
|
overlay: true,
|
package/dist/display.js
CHANGED
|
@@ -301,6 +301,8 @@ function eventText(event) {
|
|
|
301
301
|
return `handoff · ${compact(event.from, 40)} → ${compact(event.to, 40)}: ${compact(event.output, 64)}`;
|
|
302
302
|
case "node_started":
|
|
303
303
|
return `started · ${compact(event.nodeId, 40)}${event.model ? ` · ${compact(event.model, 50)}` : ""}`;
|
|
304
|
+
case "human_input":
|
|
305
|
+
return `human input · ${compact(event.nodeId, 40)}: ${compact(event.text, 64)}`;
|
|
304
306
|
case "node_completed":
|
|
305
307
|
return `completed · ${compact(event.nodeId, 40)}${event.decision ? ` → ${compact(event.decision, 30)}` : ""}: ${compact(event.output, 64)}`;
|
|
306
308
|
case "node_skipped":
|
|
@@ -329,7 +331,9 @@ function renderEventLog(events, expanded, theme) {
|
|
|
329
331
|
? "success"
|
|
330
332
|
: event.type === "node_started"
|
|
331
333
|
? "accent"
|
|
332
|
-
: "
|
|
334
|
+
: event.type === "human_input"
|
|
335
|
+
? "warning"
|
|
336
|
+
: "dim";
|
|
333
337
|
lines.push(theme.fg(color, ` ${String(event.sequence).padStart(3, "0")} ${eventText(event)}`));
|
|
334
338
|
}
|
|
335
339
|
return lines;
|
|
@@ -351,6 +355,8 @@ export function applyEvent(state, event) {
|
|
|
351
355
|
const preview = { ...event };
|
|
352
356
|
if ("output" in preview && preview.output !== undefined)
|
|
353
357
|
preview.output = compact(preview.output, 180);
|
|
358
|
+
if (preview.type === "human_input")
|
|
359
|
+
preview.text = compact(preview.text, 180);
|
|
354
360
|
if ("error" in preview)
|
|
355
361
|
preview.error = {
|
|
356
362
|
...preview.error,
|
|
@@ -463,6 +469,9 @@ export function applyEvent(state, event) {
|
|
|
463
469
|
node.skipReason = event.reason;
|
|
464
470
|
node.finishedAt = event.timestamp;
|
|
465
471
|
break;
|
|
472
|
+
case "human_input":
|
|
473
|
+
node.humanInputs = [...(node.humanInputs ?? []), { text: compact(event.text, 180), timestamp: event.timestamp }];
|
|
474
|
+
break;
|
|
466
475
|
}
|
|
467
476
|
}
|
|
468
477
|
export function applyProgress(state, progress) {
|
|
@@ -504,7 +513,7 @@ export function renderActivity(activity, expanded, theme, now, part = "all") {
|
|
|
504
513
|
const hidden = activity.droppedEntries + activity.entries.length - visible.length;
|
|
505
514
|
lines.push(theme.fg("dim", `history · ${activity.sequence} events${hidden ? ` · ${hidden} earlier ${expanded ? "trimmed" : "hidden (expand for more)"}` : ""}:`));
|
|
506
515
|
for (const entry of visible) {
|
|
507
|
-
const color = entry.isError ? "error" : entry.kind === "tool_call" ? "accent" : entry.kind === "assistant" ? "text" : "muted";
|
|
516
|
+
const color = entry.isError ? "error" : entry.kind === "tool_call" ? "accent" : entry.kind === "assistant" ? "text" : entry.kind === "human_input" ? "warning" : "muted";
|
|
508
517
|
lines.push(theme.fg(color, ` ${`+${elapsed(Math.max(0, entry.timestamp - activity.startedAt))}`.padStart(7)} ${compact(entry.summary, 200)}`));
|
|
509
518
|
// Show a detail preview only when the summary line does not already contain it.
|
|
510
519
|
if (expanded && entry.detail && entry.kind !== "workspace" && !compact(entry.summary, 400).includes(compact(entry.detail, 400))) {
|
|
@@ -542,7 +551,7 @@ export function renderNodeResult(node, expanded, theme, fullOutputPath, progress
|
|
|
542
551
|
...(activity && activity.executionId === node.executionId ? renderActivity(activity, expanded, theme, now) : []),
|
|
543
552
|
...(node.output ? [expanded ? clean(node.output) : compact(node.output, 400)] : []),
|
|
544
553
|
...(!expanded && node.output && (node.output.length > 400 || node.output.includes("\n")) ? [theme.fg("dim", "Expand for full node output")] : []),
|
|
545
|
-
...(fullOutputPath ? [theme.fg("dim", `full
|
|
554
|
+
...(fullOutputPath ? [theme.fg("dim", `full text: ${compact(fullOutputPath, 240)}`)] : []),
|
|
546
555
|
];
|
|
547
556
|
return new Text(lines.join("\n"), 0, 0);
|
|
548
557
|
}
|
|
@@ -561,10 +570,12 @@ export function renderSessionHeader(node, theme, now, progress, activity) {
|
|
|
561
570
|
theme.fg(failed ? "error" : "accent", `${failed ? "✗" : node.status === "completed" ? "✓" : node.status === "running" ? "▶" : "○"} Braid node ${compact(node.id, 80)} · ${node.status}`),
|
|
562
571
|
...(facts.length ? [theme.fg("muted", facts.join(" · "))] : []),
|
|
563
572
|
...(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`)] : []),
|
|
573
|
+
...(node.humanInputs?.length ? [theme.fg("warning", humanInputNotice(node.humanInputs.length))] : []),
|
|
564
574
|
...(activity && activity.executionId === node.executionId ? renderActivity(activity, false, theme, now, "summary") : []),
|
|
565
575
|
...(node.error ? [theme.fg("error", `${node.error.code}: ${compact(node.error.message, 300)}`)] : []),
|
|
566
576
|
];
|
|
567
577
|
}
|
|
578
|
+
const humanInputNotice = (count) => `✎ received ${count} human message${count === 1 ? "" : "s"} while running; its result is not from the graph alone`;
|
|
568
579
|
/** What a node view shows below its pinned header when no live session is retained. */
|
|
569
580
|
export function renderNodeDetails(node, theme, now, activity) {
|
|
570
581
|
const clean = (value) => stripTerminalSequences(value).replace(/[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/gu, "");
|
|
@@ -575,6 +586,10 @@ export function renderNodeDetails(node, theme, now, activity) {
|
|
|
575
586
|
...(workspace ? [theme.fg("dim", `workspace: ${workspace.mode} · ${workspace.state}${workspaceRef ? ` · ${compact(workspaceRef, 160)}` : ""}`)] : []),
|
|
576
587
|
...(node.decision ? [theme.fg("accent", `decision: ${compact(node.decision, 80)}`)] : []),
|
|
577
588
|
...(node.skipReason ? [theme.fg("muted", `skipped: ${node.skipReason}`)] : []),
|
|
589
|
+
...(node.humanInputs?.length ? [
|
|
590
|
+
theme.fg("warning", humanInputNotice(node.humanInputs.length)),
|
|
591
|
+
...node.humanInputs.map(input => theme.fg("warning", ` ✎ ${compact(clean(input.text), 300)}`)),
|
|
592
|
+
] : []),
|
|
578
593
|
...(activity && activity.executionId === node.executionId ? renderActivity(activity, true, theme, now, "history") : []),
|
|
579
594
|
...(node.output ? ["", ...clean(node.output).split("\n")] : []),
|
|
580
595
|
];
|
|
@@ -640,7 +655,7 @@ export function renderGraphResult(result, expanded, isPartial, theme, fallback =
|
|
|
640
655
|
if (expanded)
|
|
641
656
|
lines.push(...renderEventLog(result.events ?? [], true, theme));
|
|
642
657
|
if ("fullOutputPath" in result && result.fullOutputPath)
|
|
643
|
-
lines.push(theme.fg("dim", `full
|
|
658
|
+
lines.push(theme.fg("dim", `full text: ${compact(result.fullOutputPath, 240)}`));
|
|
644
659
|
return new FixedLines(lines, chartStart, chart.length, nodeList, [
|
|
645
660
|
...(selectedNodeId === undefined ? [] : [{ mark: SELECTED_NODE_MARK, label: "selected node" }]),
|
|
646
661
|
{ mark: "▶ ACTIVE", label: "running node" },
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Messages a human sent to running executions, keyed by execution ID. The
|
|
3
|
+
* runner takes them before each model request; once an execution is closed,
|
|
4
|
+
* further messages are rejected and anything still queued is returned as
|
|
5
|
+
* undelivered.
|
|
6
|
+
*/
|
|
7
|
+
export class PiHumanInputs {
|
|
8
|
+
onChange;
|
|
9
|
+
queues = new Map();
|
|
10
|
+
closed = new Set();
|
|
11
|
+
constructor(onChange = () => { }) {
|
|
12
|
+
this.onChange = onChange;
|
|
13
|
+
}
|
|
14
|
+
send(executionId, text) {
|
|
15
|
+
if (typeof text !== "string" || !text.trim())
|
|
16
|
+
throw new TypeError("Message must not be empty");
|
|
17
|
+
if (this.closed.has(executionId))
|
|
18
|
+
throw new Error("This execution has finished; the message was not sent");
|
|
19
|
+
const input = { text, submittedAt: Date.now() };
|
|
20
|
+
const queue = this.queues.get(executionId) ?? [];
|
|
21
|
+
queue.push(input);
|
|
22
|
+
this.queues.set(executionId, queue);
|
|
23
|
+
this.changed();
|
|
24
|
+
return { ...input };
|
|
25
|
+
}
|
|
26
|
+
pending(executionId) {
|
|
27
|
+
return this.queues.get(executionId) ?? [];
|
|
28
|
+
}
|
|
29
|
+
/** Remove and return queued messages, oldest first. */
|
|
30
|
+
take(executionId) {
|
|
31
|
+
const queue = this.queues.get(executionId);
|
|
32
|
+
if (!queue?.length)
|
|
33
|
+
return [];
|
|
34
|
+
this.queues.delete(executionId);
|
|
35
|
+
this.changed();
|
|
36
|
+
return queue;
|
|
37
|
+
}
|
|
38
|
+
/** Stop accepting messages and return those never delivered. */
|
|
39
|
+
close(executionId) {
|
|
40
|
+
this.closed.add(executionId);
|
|
41
|
+
const undelivered = this.queues.get(executionId) ?? [];
|
|
42
|
+
this.queues.delete(executionId);
|
|
43
|
+
if (undelivered.length)
|
|
44
|
+
this.changed();
|
|
45
|
+
return undelivered;
|
|
46
|
+
}
|
|
47
|
+
changed() {
|
|
48
|
+
try {
|
|
49
|
+
this.onChange();
|
|
50
|
+
}
|
|
51
|
+
catch { /* Observers cannot fail a worker. */ }
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
/** How a delivered message reads to the worker, so it is never mistaken for graph input. */
|
|
55
|
+
export function formatHumanInput(text) {
|
|
56
|
+
return "Message from the human supervising this Braid execution, sent while you were running " +
|
|
57
|
+
"(it may be relayed by their Braid assistant). " +
|
|
58
|
+
"It is not part of the node prompt or predecessor outputs. Take it into account for your remaining work; " +
|
|
59
|
+
"it does not lift tool, budget, workspace, or decision constraints.\n\n" + text;
|
|
60
|
+
}
|