@chrok/pi-braid 0.3.2 → 0.3.4
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 +85 -7
- package/dist/assistant.js +345 -0
- package/dist/command.js +428 -51
- package/dist/display.js +166 -19
- package/dist/human-input.js +60 -0
- package/dist/index.js +17 -3
- package/dist/jobs.js +78 -3
- package/dist/runner.js +109 -23
- package/dist/session-view.js +138 -0
- package/dist/shell-tools.js +12 -5
- package/dist/transcript.js +114 -0
- package/package.json +2 -2
package/dist/command.js
CHANGED
|
@@ -1,28 +1,75 @@
|
|
|
1
|
-
import { matchesKey, stripTerminalSequences, truncateToWidth, visibleWidth, } from "@earendil-works/pi-tui";
|
|
2
|
-
import { renderGraphResult,
|
|
1
|
+
import { Input, matchesKey, stripTerminalSequences, truncateToWidth, visibleWidth, wrapTextWithAnsi, } from "@earendil-works/pi-tui";
|
|
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
|
+
/** 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";
|
|
11
|
+
/**
|
|
12
|
+
* A live, scrollable panel. Esc steps back one level: session → node selection →
|
|
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.
|
|
15
|
+
*/
|
|
7
16
|
export class BraidPanel {
|
|
8
17
|
jobs;
|
|
9
18
|
tui;
|
|
10
19
|
theme;
|
|
11
20
|
done;
|
|
21
|
+
assistantModel;
|
|
12
22
|
selected;
|
|
13
23
|
focus;
|
|
24
|
+
/** Node highlighted in selection mode (`v`); undefined in the plain graph view. */
|
|
25
|
+
choosing;
|
|
26
|
+
/** Keep the newest transcript lines in view until the user scrolls up. */
|
|
27
|
+
follow = true;
|
|
28
|
+
/** The selection last scrolled into view, so manual scrolling is not overridden. */
|
|
29
|
+
revealed;
|
|
14
30
|
offset = 0;
|
|
15
31
|
maxOffset = 0;
|
|
16
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;
|
|
42
|
+
session;
|
|
43
|
+
chat;
|
|
17
44
|
unsubscribe;
|
|
18
45
|
ticker;
|
|
19
|
-
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")) } })) {
|
|
20
47
|
this.jobs = jobs;
|
|
21
48
|
this.tui = tui;
|
|
22
49
|
this.theme = theme;
|
|
23
50
|
this.done = done;
|
|
51
|
+
this.assistantModel = assistantModel;
|
|
24
52
|
this.selected = jobId ? jobs.get(jobId)?.jobId ?? jobId : undefined;
|
|
25
53
|
this.focus = focus;
|
|
54
|
+
// Pi's tool components only request renders through the TUI.
|
|
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
|
+
};
|
|
26
73
|
this.unsubscribe = jobs.subscribe(() => this.refresh());
|
|
27
74
|
this.ticker = setInterval(() => this.refresh(), 1000);
|
|
28
75
|
this.ticker.unref();
|
|
@@ -64,7 +111,7 @@ export class BraidPanel {
|
|
|
64
111
|
: "muted";
|
|
65
112
|
const focusLabel = this.focus
|
|
66
113
|
? "nodeId" in this.focus ? `node ${plain(this.focus.nodeId)}` : `execution ${plain(this.focus.executionId)}`
|
|
67
|
-
: "";
|
|
114
|
+
: this.choosing !== undefined ? "select a node" : "";
|
|
68
115
|
const heading = current
|
|
69
116
|
? `Job ${index + 1} of ${jobs.length} · ${this.theme.fg(statusColor, current.status)}${focusLabel ? ` · ${focusLabel}` : ""}`
|
|
70
117
|
: this.theme.fg("muted", "No background jobs");
|
|
@@ -92,8 +139,59 @@ export class BraidPanel {
|
|
|
92
139
|
: "Jobs will appear here as soon as they are submitted.")),
|
|
93
140
|
rule("├", "┤"),
|
|
94
141
|
];
|
|
95
|
-
|
|
96
|
-
|
|
142
|
+
if (current && this.choosing !== undefined && !this.nodeIds(current).includes(this.choosing)) {
|
|
143
|
+
this.choosing = this.nodeIds(current)[0];
|
|
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) {
|
|
186
|
+
// The node summary (or the selected node) stays pinned while the body scrolls.
|
|
187
|
+
const focused = current && this.focus ? this.renderFocus(current, this.focus, width) : undefined;
|
|
188
|
+
let pinned = focused?.pinned ?? (current && this.choosing !== undefined ? [this.choosingLine(current)] : []);
|
|
189
|
+
const maxPinned = Math.max(2, Math.floor(available / 2));
|
|
190
|
+
if (pinned.length > maxPinned) {
|
|
191
|
+
pinned = [...pinned.slice(0, maxPinned - 1), this.theme.fg("dim", `… ${pinned.length - maxPinned + 1} more summary lines`)];
|
|
192
|
+
}
|
|
193
|
+
const content = focused
|
|
194
|
+
? [...focused.body, ...(current && focused.executionId ? this.pendingLines(current, focused.executionId) : [])]
|
|
97
195
|
: current
|
|
98
196
|
? [
|
|
99
197
|
...(current.error
|
|
@@ -106,36 +204,126 @@ export class BraidPanel {
|
|
|
106
204
|
...(current.fullOutputPath
|
|
107
205
|
? { fullOutputPath: current.fullOutputPath }
|
|
108
206
|
: {}),
|
|
109
|
-
}, true, false, this.theme).render(
|
|
207
|
+
}, true, false, this.theme, "", false, this.choosing).render(width),
|
|
110
208
|
]
|
|
111
209
|
: ["No Braid jobs in this session."];
|
|
112
|
-
const height =
|
|
210
|
+
const height = available - (pinned.length ? pinned.length + 1 : 0);
|
|
113
211
|
this.maxOffset = Math.max(0, content.length - height);
|
|
114
|
-
this.offset = Math.min(this.offset, this.maxOffset);
|
|
212
|
+
this.offset = this.focus && this.follow ? this.maxOffset : Math.min(this.offset, this.maxOffset);
|
|
213
|
+
if (!this.focus && this.choosing !== undefined && this.choosing !== this.revealed) {
|
|
214
|
+
// Bring a newly selected node's box into view in a tall flowchart.
|
|
215
|
+
this.revealed = this.choosing;
|
|
216
|
+
const line = content.findIndex(text => stripTerminalSequences(text).includes(SELECTED_NODE_MARK));
|
|
217
|
+
if (line >= 0 && (line < this.offset || line > this.offset + height - 3)) {
|
|
218
|
+
this.offset = Math.max(0, Math.min(this.maxOffset, line - Math.floor(height / 3)));
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
if (this.choosing === undefined)
|
|
222
|
+
this.revealed = undefined;
|
|
115
223
|
const body = content.slice(this.offset, this.offset + height);
|
|
116
224
|
while (body.length < height)
|
|
117
225
|
body.push("");
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
226
|
+
return {
|
|
227
|
+
lines: [...(pinned.length ? [...pinned, SEPARATOR] : []), ...body],
|
|
228
|
+
range: `Lines ${this.offset + 1}–${Math.min(content.length, this.offset + height)}/${content.length}`,
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
/** The job's assistant conversation, newest lines at the bottom, above its input line. */
|
|
232
|
+
renderAssistant(job, width, height) {
|
|
233
|
+
const nodeId = this.assistantNode(job);
|
|
234
|
+
const assistant = this.jobs.assistant(job.jobId, nodeId);
|
|
235
|
+
// Each scope has its own conversation; a new one starts at its newest lines.
|
|
236
|
+
const scope = JSON.stringify([job.jobId, nodeId ?? null]);
|
|
237
|
+
if (scope !== this.chatScope) {
|
|
238
|
+
this.chatScope = scope;
|
|
239
|
+
this.chatOffset = 0;
|
|
240
|
+
}
|
|
241
|
+
const title = this.theme.fg("accent", this.theme.bold(nodeId === undefined ? "Assistant · job" : `Assistant · node ${plain(nodeId)}`)) +
|
|
242
|
+
this.theme.fg("muted", assistant.busy ? " · working… Esc to stop" : " · Tab to hide");
|
|
243
|
+
let lines;
|
|
244
|
+
try {
|
|
245
|
+
lines = assistant.conversation.messages.length
|
|
246
|
+
? this.chat.render(assistant.conversation, width)
|
|
247
|
+
: [
|
|
248
|
+
// Wrapped, not truncated, and kept off the input line like a finished reply.
|
|
249
|
+
...[
|
|
250
|
+
nodeId === undefined
|
|
251
|
+
? "Ask what a node is doing, why it failed, or what it produced."
|
|
252
|
+
: `Ask what ${plain(nodeId)} is doing, why it failed, or what it produced.`,
|
|
253
|
+
"To change a running execution's direction, say what it should do; the assistant messages that execution for you.",
|
|
254
|
+
].flatMap(hint => wrapTextWithAnsi(this.theme.fg("dim", hint), width)),
|
|
255
|
+
"",
|
|
256
|
+
];
|
|
257
|
+
}
|
|
258
|
+
catch (error) {
|
|
259
|
+
lines = [this.theme.fg("error", `Assistant view unavailable: ${plain(error instanceof Error ? error.message : String(error))}`)];
|
|
260
|
+
}
|
|
261
|
+
const visible = Math.max(1, height - 3);
|
|
262
|
+
this.chatMaxOffset = Math.max(0, lines.length - visible);
|
|
263
|
+
this.chatOffset = Math.min(this.chatOffset, this.chatMaxOffset);
|
|
264
|
+
const end = lines.length - this.chatOffset;
|
|
265
|
+
const body = lines.slice(Math.max(0, end - visible), end);
|
|
266
|
+
while (body.length < visible)
|
|
267
|
+
body.unshift("");
|
|
268
|
+
this.input.focused = true;
|
|
269
|
+
const input = this.input.render(width)[0] ?? "";
|
|
270
|
+
return [title, this.theme.fg("borderMuted", "─".repeat(width)), ...body, input].slice(0, height);
|
|
271
|
+
}
|
|
272
|
+
/** Messages the assistant sent that this execution has not received yet. */
|
|
273
|
+
pendingLines(job, executionId) {
|
|
274
|
+
let pending = [];
|
|
275
|
+
try {
|
|
276
|
+
pending = this.jobs.pendingInputs(job.jobId, executionId);
|
|
277
|
+
}
|
|
278
|
+
catch { /* The job was removed; show nothing. */ }
|
|
279
|
+
if (!pending.length)
|
|
280
|
+
return [];
|
|
130
281
|
return [
|
|
131
|
-
...
|
|
132
|
-
|
|
133
|
-
rule("├", "┤"),
|
|
134
|
-
row(this.theme.fg("dim", `${range}${contentWidth >= 60 ? " · Jobs keep running when this panel closes" : ""}`)),
|
|
135
|
-
row(this.theme.fg("muted", help)),
|
|
136
|
-
rule("╰", "╯"),
|
|
282
|
+
...pending.map(input => this.theme.fg("warning", `⧗ pending human input · ${plain(input.text)}`)),
|
|
283
|
+
this.theme.fg("dim", " Delivered before the worker's next model request; running tools are not interrupted."),
|
|
137
284
|
];
|
|
138
285
|
}
|
|
286
|
+
/** The node whose conversation the assistant shows: the open session's, or the selected one. */
|
|
287
|
+
assistantNode(job) {
|
|
288
|
+
if (!this.focus)
|
|
289
|
+
return this.choosing;
|
|
290
|
+
if ("nodeId" in this.focus)
|
|
291
|
+
return this.focus.nodeId;
|
|
292
|
+
try {
|
|
293
|
+
return this.jobs.getNode(job.jobId, undefined, this.focus.executionId).id;
|
|
294
|
+
}
|
|
295
|
+
catch {
|
|
296
|
+
return undefined;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
/** What the user is looking at, so "this node" means something to the assistant. */
|
|
300
|
+
describeView(job) {
|
|
301
|
+
if (this.focus) {
|
|
302
|
+
try {
|
|
303
|
+
const node = "nodeId" in this.focus ? this.jobs.getNode(job.jobId, this.focus.nodeId) : this.jobs.getNode(job.jobId, undefined, this.focus.executionId);
|
|
304
|
+
return `session of node ${JSON.stringify(node.id)}${node.executionId ? `, execution ${node.executionId}` : ""} (${node.status})`;
|
|
305
|
+
}
|
|
306
|
+
catch {
|
|
307
|
+
return "a node that is no longer in the graph";
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
if (this.choosing !== undefined) {
|
|
311
|
+
const node = (job.result?.nodes ?? job.live.nodes)[this.choosing];
|
|
312
|
+
return `job graph with node ${JSON.stringify(this.choosing)} selected (${node?.status ?? "pending"})`;
|
|
313
|
+
}
|
|
314
|
+
return `job graph overview (${job.status})`;
|
|
315
|
+
}
|
|
316
|
+
/** Graph definition order, which is also the flowchart's declaration order. */
|
|
317
|
+
nodeIds(job) {
|
|
318
|
+
return Object.keys(job.result?.nodes ?? job.live.nodes);
|
|
319
|
+
}
|
|
320
|
+
choosingLine(job) {
|
|
321
|
+
const nodes = job.result?.nodes ?? job.live.nodes;
|
|
322
|
+
const node = this.choosing !== undefined ? nodes[this.choosing] : undefined;
|
|
323
|
+
const ids = this.nodeIds(job);
|
|
324
|
+
return this.theme.fg("warning", `${SELECTED_NODE_MARK} Selected ${ids.indexOf(this.choosing ?? "") + 1}/${ids.length}: `) +
|
|
325
|
+
this.theme.bold(plain(this.choosing ?? "")) + this.theme.fg("muted", ` · ${node?.status ?? "pending"} · Enter to open its session`);
|
|
326
|
+
}
|
|
139
327
|
/** A focused node can disappear after a live graph update; say so instead of failing. */
|
|
140
328
|
renderFocus(job, focus, width) {
|
|
141
329
|
let node;
|
|
@@ -145,39 +333,186 @@ export class BraidPanel {
|
|
|
145
333
|
: this.jobs.getNode(job.jobId, undefined, focus.executionId);
|
|
146
334
|
}
|
|
147
335
|
catch {
|
|
148
|
-
return [
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
336
|
+
return { pinned: [], body: [
|
|
337
|
+
this.theme.fg("warning", "This node or execution is no longer in the job's graph."),
|
|
338
|
+
this.theme.fg("muted", "Press Esc to return to the graph."),
|
|
339
|
+
] };
|
|
152
340
|
}
|
|
153
341
|
// Latest-node results omit the loop iteration; their execution record has it.
|
|
154
342
|
const executions = job.result?.executions ?? job.execution?.executions;
|
|
155
343
|
const iteration = node.iteration ??
|
|
156
344
|
(node.executionId ? executions?.[node.executionId]?.iteration : undefined);
|
|
157
|
-
|
|
158
|
-
|
|
345
|
+
const withIteration = iteration === undefined ? node : { ...node, iteration };
|
|
346
|
+
const activity = node.executionId ? this.jobs.getActivity(job.jobId, node.executionId) : undefined;
|
|
347
|
+
const transcript = node.executionId ? this.jobs.getTranscript(job.jobId, node.executionId) : undefined;
|
|
348
|
+
const siblings = this.executionsOf(job, node.id);
|
|
349
|
+
const position = node.executionId ? siblings.indexOf(node.executionId) : -1;
|
|
350
|
+
const header = [
|
|
351
|
+
...renderSessionHeader(withIteration, this.theme, job.live.observedAt, job.live.progress[node.id], activity),
|
|
352
|
+
...(siblings.length > 1 ? [this.theme.fg("dim", `execution ${position + 1} of ${siblings.length} for this node · ←/→ to switch`)] : []),
|
|
353
|
+
];
|
|
354
|
+
let failure;
|
|
355
|
+
if (transcript) {
|
|
356
|
+
try {
|
|
357
|
+
return { pinned: header, body: this.session.render(transcript, width), executionId: node.executionId };
|
|
358
|
+
}
|
|
359
|
+
catch (error) {
|
|
360
|
+
// A rendering failure must not take down the overlay; fall back to the summary.
|
|
361
|
+
failure = this.theme.fg("error", `Session view unavailable: ${plain(error instanceof Error ? error.message : String(error))}`);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
// Pending, recorded before this session, or evicted: show details and history instead.
|
|
365
|
+
const note = failure ?? (node.executionId
|
|
366
|
+
? this.theme.fg("muted", "The live session for this execution is no longer retained; showing its details and activity history.")
|
|
367
|
+
: this.theme.fg("muted", "This node has not run yet."));
|
|
368
|
+
return { pinned: header, body: [note, ...renderNodeDetails(node, this.theme, job.live.observedAt, activity)] };
|
|
369
|
+
}
|
|
370
|
+
executionsOf(job, nodeId) {
|
|
371
|
+
const executions = job.result?.executions ?? job.execution?.executions ?? {};
|
|
372
|
+
return Object.values(executions).filter(execution => execution.id === nodeId).map(execution => execution.executionId);
|
|
373
|
+
}
|
|
374
|
+
open(job, nodeId) {
|
|
375
|
+
const executionId = this.executionsOf(job, nodeId).at(-1);
|
|
376
|
+
this.focus = executionId ? { executionId } : { nodeId };
|
|
377
|
+
this.follow = true;
|
|
378
|
+
this.offset = 0;
|
|
159
379
|
}
|
|
160
380
|
handleInput(data) {
|
|
161
|
-
if (matchesKey(data, "
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
381
|
+
if (matchesKey(data, "ctrl+c")) {
|
|
382
|
+
this.close();
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
if (matchesKey(data, "tab") && this.selected) {
|
|
386
|
+
this.assistantOpen = !this.assistantOpen;
|
|
387
|
+
this.refresh();
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
if (this.assistantOpen) {
|
|
391
|
+
this.assistantInput(data);
|
|
392
|
+
this.refresh();
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
if (data === "q") {
|
|
396
|
+
this.close();
|
|
166
397
|
return;
|
|
167
398
|
}
|
|
168
399
|
const jobs = this.jobs.list();
|
|
169
400
|
const index = Math.max(0, jobs.findIndex((job) => job.jobId === this.selected));
|
|
170
|
-
|
|
401
|
+
const job = this.selected ? this.jobs.get(this.selected) : undefined;
|
|
402
|
+
if (this.focus)
|
|
403
|
+
this.sessionInput(data, job);
|
|
404
|
+
else if (this.choosing !== undefined && job)
|
|
405
|
+
this.chooseInput(data, job);
|
|
406
|
+
else if (matchesKey(data, "escape")) {
|
|
407
|
+
this.close();
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
else if (data === "v" && job) {
|
|
411
|
+
const ids = this.nodeIds(job);
|
|
412
|
+
this.choosing = ids.find(id => (job.result?.nodes ?? job.live.nodes)[id]?.status === "running") ?? ids[0];
|
|
413
|
+
}
|
|
414
|
+
else if (matchesKey(data, "left") || matchesKey(data, "right")) {
|
|
171
415
|
const step = matchesKey(data, "right") ? 1 : -1;
|
|
172
416
|
this.selected = jobs[(index + step + jobs.length) % jobs.length]?.jobId;
|
|
173
|
-
this.focus = undefined;
|
|
174
417
|
this.offset = 0;
|
|
175
418
|
}
|
|
176
|
-
else
|
|
419
|
+
else
|
|
420
|
+
this.scroll(data);
|
|
421
|
+
if (data === "c" && this.selected)
|
|
422
|
+
this.jobs.cancel(this.selected);
|
|
423
|
+
this.refresh();
|
|
424
|
+
}
|
|
425
|
+
chooseInput(data, job) {
|
|
426
|
+
const ids = this.nodeIds(job);
|
|
427
|
+
const at = Math.max(0, ids.indexOf(this.choosing));
|
|
428
|
+
if (matchesKey(data, "escape") || matchesKey(data, "backspace"))
|
|
429
|
+
this.choosing = undefined;
|
|
430
|
+
else if (matchesKey(data, "up") || matchesKey(data, "left"))
|
|
431
|
+
this.choosing = ids[(at - 1 + ids.length) % ids.length];
|
|
432
|
+
else if (matchesKey(data, "down") || matchesKey(data, "right"))
|
|
433
|
+
this.choosing = ids[(at + 1) % ids.length];
|
|
434
|
+
else if (matchesKey(data, "enter") && this.choosing !== undefined)
|
|
435
|
+
this.open(job, this.choosing);
|
|
436
|
+
else
|
|
437
|
+
this.scroll(data);
|
|
438
|
+
}
|
|
439
|
+
sessionInput(data, job) {
|
|
440
|
+
if (matchesKey(data, "escape") || matchesKey(data, "backspace")) {
|
|
441
|
+
// Return to selection on the same node, so another node is one keypress away.
|
|
442
|
+
const focus = this.focus;
|
|
443
|
+
let nodeId;
|
|
444
|
+
try {
|
|
445
|
+
nodeId = job && ("nodeId" in focus ? focus.nodeId : this.jobs.getNode(job.jobId, undefined, focus.executionId).id);
|
|
446
|
+
}
|
|
447
|
+
catch { /* The node was removed; fall back to the plain graph. */ }
|
|
177
448
|
this.focus = undefined;
|
|
449
|
+
this.choosing = nodeId !== undefined && job && this.nodeIds(job).includes(nodeId) ? nodeId : undefined;
|
|
178
450
|
this.offset = 0;
|
|
179
451
|
}
|
|
180
|
-
else if (matchesKey(data, "
|
|
452
|
+
else if ((matchesKey(data, "left") || matchesKey(data, "right")) && job) {
|
|
453
|
+
let node;
|
|
454
|
+
try {
|
|
455
|
+
node = "nodeId" in this.focus ? this.jobs.getNode(job.jobId, this.focus.nodeId) : this.jobs.getNode(job.jobId, undefined, this.focus.executionId);
|
|
456
|
+
}
|
|
457
|
+
catch {
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
const siblings = this.executionsOf(job, node.id);
|
|
461
|
+
const at = node.executionId ? siblings.indexOf(node.executionId) : -1;
|
|
462
|
+
const next = siblings[at + (matchesKey(data, "right") ? 1 : -1)];
|
|
463
|
+
if (next) {
|
|
464
|
+
this.focus = { executionId: next };
|
|
465
|
+
this.follow = true;
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
else if (matchesKey(data, "end"))
|
|
469
|
+
this.follow = true;
|
|
470
|
+
else if (matchesKey(data, "ctrl+o"))
|
|
471
|
+
this.session.toggleExpanded();
|
|
472
|
+
else
|
|
473
|
+
this.scroll(data);
|
|
474
|
+
}
|
|
475
|
+
/** The wheel scrolls whichever side it is over; other mouse events are left to Pi. */
|
|
476
|
+
handleMouse(event) {
|
|
477
|
+
const layout = this.layout;
|
|
478
|
+
if (event.type !== "wheel" || !event.wheelDelta || !layout)
|
|
479
|
+
return undefined;
|
|
480
|
+
if (event.y < layout.top || event.y >= layout.top + layout.height)
|
|
481
|
+
return undefined;
|
|
482
|
+
if (layout.chatLeft !== undefined && event.x >= layout.chatLeft - 1) {
|
|
483
|
+
this.chatOffset = Math.max(0, Math.min(this.chatMaxOffset, this.chatOffset - event.wheelDelta));
|
|
484
|
+
}
|
|
485
|
+
else {
|
|
486
|
+
const before = this.offset;
|
|
487
|
+
this.offset = Math.max(0, Math.min(this.maxOffset, this.offset + event.wheelDelta));
|
|
488
|
+
if (this.offset < before)
|
|
489
|
+
this.follow = false;
|
|
490
|
+
else if (this.offset >= this.maxOffset)
|
|
491
|
+
this.follow = true;
|
|
492
|
+
}
|
|
493
|
+
this.refresh();
|
|
494
|
+
return { handled: true };
|
|
495
|
+
}
|
|
496
|
+
/** Every key belongs to the question, including q and c. */
|
|
497
|
+
assistantInput(data) {
|
|
498
|
+
const job = this.selected ? this.jobs.get(this.selected) : undefined;
|
|
499
|
+
const assistant = job ? this.jobs.assistant(job.jobId, this.assistantNode(job)) : undefined;
|
|
500
|
+
if (matchesKey(data, "escape")) {
|
|
501
|
+
if (assistant?.busy)
|
|
502
|
+
assistant.abort();
|
|
503
|
+
else
|
|
504
|
+
this.assistantOpen = false;
|
|
505
|
+
}
|
|
506
|
+
else if (matchesKey(data, "pageUp"))
|
|
507
|
+
this.chatOffset = Math.min(this.chatMaxOffset, this.chatOffset + 10);
|
|
508
|
+
else if (matchesKey(data, "pageDown"))
|
|
509
|
+
this.chatOffset = Math.max(0, this.chatOffset - 10);
|
|
510
|
+
else
|
|
511
|
+
this.input.handleInput(data);
|
|
512
|
+
}
|
|
513
|
+
scroll(data) {
|
|
514
|
+
const before = this.offset;
|
|
515
|
+
if (matchesKey(data, "up"))
|
|
181
516
|
this.offset = Math.max(0, this.offset - 1);
|
|
182
517
|
else if (matchesKey(data, "down"))
|
|
183
518
|
this.offset = Math.min(this.maxOffset, this.offset + 1);
|
|
@@ -185,9 +520,19 @@ export class BraidPanel {
|
|
|
185
520
|
this.offset = Math.max(0, this.offset - 10);
|
|
186
521
|
else if (matchesKey(data, "pageDown"))
|
|
187
522
|
this.offset = Math.min(this.maxOffset, this.offset + 10);
|
|
188
|
-
else if (data
|
|
189
|
-
this.
|
|
190
|
-
|
|
523
|
+
else if (matchesKey(data, "home"))
|
|
524
|
+
this.offset = 0;
|
|
525
|
+
else
|
|
526
|
+
return;
|
|
527
|
+
// Scrolling up pauses following; reaching the bottom resumes it.
|
|
528
|
+
if (this.offset < before)
|
|
529
|
+
this.follow = false;
|
|
530
|
+
else if (this.offset >= this.maxOffset)
|
|
531
|
+
this.follow = true;
|
|
532
|
+
}
|
|
533
|
+
close() {
|
|
534
|
+
this.dispose();
|
|
535
|
+
this.done();
|
|
191
536
|
}
|
|
192
537
|
invalidate() { }
|
|
193
538
|
dispose() {
|
|
@@ -199,23 +544,54 @@ export class BraidPanel {
|
|
|
199
544
|
}
|
|
200
545
|
}
|
|
201
546
|
export function registerBraidWidget(pi, jobs) {
|
|
547
|
+
// Clearing hides the jobs present at that moment; jobs submitted later still show.
|
|
548
|
+
const cleared = new Set();
|
|
549
|
+
let lastCleared = [];
|
|
550
|
+
const shown = () => jobs.list().filter(job => !cleared.has(job.jobId));
|
|
551
|
+
let refresh = () => { };
|
|
202
552
|
let dispose = () => { };
|
|
203
553
|
const clear = () => {
|
|
204
554
|
dispose();
|
|
205
555
|
dispose = () => { };
|
|
556
|
+
refresh = () => { };
|
|
206
557
|
};
|
|
558
|
+
pi.registerCommand("braid:clear", {
|
|
559
|
+
description: "Clear the Braid progress widget; run again while it is empty to restore it",
|
|
560
|
+
handler: async (_args, ctx) => {
|
|
561
|
+
if (ctx.mode !== "tui")
|
|
562
|
+
throw new Error("The Braid progress widget requires interactive Pi.");
|
|
563
|
+
const visible = shown();
|
|
564
|
+
if (visible.length) {
|
|
565
|
+
lastCleared = visible.map(job => job.jobId);
|
|
566
|
+
for (const jobId of lastCleared)
|
|
567
|
+
cleared.add(jobId);
|
|
568
|
+
ctx.ui.notify("Cleared Braid progress. Run /braid:clear again to restore it.", "info");
|
|
569
|
+
}
|
|
570
|
+
else if (lastCleared.length) {
|
|
571
|
+
for (const jobId of lastCleared)
|
|
572
|
+
cleared.delete(jobId);
|
|
573
|
+
lastCleared = [];
|
|
574
|
+
ctx.ui.notify("Restored Braid progress.", "info");
|
|
575
|
+
}
|
|
576
|
+
else {
|
|
577
|
+
ctx.ui.notify("No Braid progress to clear.", "info");
|
|
578
|
+
return;
|
|
579
|
+
}
|
|
580
|
+
refresh();
|
|
581
|
+
},
|
|
582
|
+
});
|
|
207
583
|
pi.on("session_start", (_event, ctx) => {
|
|
208
584
|
clear();
|
|
209
585
|
if (ctx.mode !== "tui")
|
|
210
586
|
return;
|
|
211
|
-
|
|
212
|
-
if (!
|
|
587
|
+
refresh = () => {
|
|
588
|
+
if (!shown().length) {
|
|
213
589
|
ctx.ui.setWidget("braid-status", undefined);
|
|
214
590
|
return;
|
|
215
591
|
}
|
|
216
592
|
ctx.ui.setWidget("braid-status", (_tui, theme) => ({
|
|
217
593
|
render(width) {
|
|
218
|
-
const all =
|
|
594
|
+
const all = shown();
|
|
219
595
|
const active = all.filter(job => job.status === "running");
|
|
220
596
|
const selected = active.length ? active.slice(0, 3) : all.slice(0, 1);
|
|
221
597
|
const lines = selected.map(job => {
|
|
@@ -244,7 +620,7 @@ export function registerBraidWidget(pi, jobs) {
|
|
|
244
620
|
invalidate() { },
|
|
245
621
|
}), { placement: "aboveEditor" });
|
|
246
622
|
};
|
|
247
|
-
const unsubscribe = jobs.subscribe(refresh);
|
|
623
|
+
const unsubscribe = jobs.subscribe(() => refresh());
|
|
248
624
|
dispose = () => {
|
|
249
625
|
unsubscribe();
|
|
250
626
|
ctx.ui.setWidget("braid-status", undefined);
|
|
@@ -267,7 +643,8 @@ export function registerBraidCommand(pi, jobs) {
|
|
|
267
643
|
let panel;
|
|
268
644
|
try {
|
|
269
645
|
await ctx.ui.custom((tui, theme, _keys, done) => {
|
|
270
|
-
|
|
646
|
+
// The assistant uses whichever model the main session has selected when asked.
|
|
647
|
+
panel = new BraidPanel(jobs, tui, theme, done, jobId, focus, () => ({ registry: ctx.modelRegistry, ...(ctx.model ? { model: ctx.model } : {}) }));
|
|
271
648
|
return panel;
|
|
272
649
|
}, {
|
|
273
650
|
overlay: true,
|