@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/dist/command.js CHANGED
@@ -1,28 +1,75 @@
1
- import { matchesKey, stripTerminalSequences, truncateToWidth, visibleWidth, } from "@earendil-works/pi-tui";
2
- import { renderGraphResult, renderNodeResult } from "./display.js";
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
- /** A live, scrollable panel. Closing the panel leaves jobs running. */
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
- const content = current && this.focus
96
- ? this.renderFocus(current, this.focus, contentWidth)
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(contentWidth),
207
+ }, true, false, this.theme, "", false, this.choosing).render(width),
110
208
  ]
111
209
  : ["No Braid jobs in this session."];
112
- const height = rows - header.length - 4;
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
- const range = `Lines ${this.offset + 1}–${Math.min(content.length, this.offset + height)}/${content.length}`;
119
- const help = this.focus
120
- ? contentWidth >= 72
121
- ? "⌫ graph ←/→ jobs ↑/↓ scroll PgUp/PgDn page c cancel Esc close"
122
- : contentWidth >= 42
123
- ? "⌫ graph · ↑/↓ scroll · c cancel · Esc close"
124
- : "⌫ graph · Esc close"
125
- : contentWidth >= 72
126
- ? "←/→ jobs ↑/↓ scroll PgUp/PgDn page c cancel Esc close"
127
- : contentWidth >= 42
128
- ? "←/→ jobs · ↑/↓ scroll · c cancel · Esc close"
129
- : "↑/↓ scroll · Esc close";
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
- ...header,
132
- ...body.map(row),
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
- this.theme.fg("warning", "This node or execution is no longer in the job's graph."),
150
- this.theme.fg("muted", "Press Backspace to return to the graph."),
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
- return renderNodeResult(iteration === undefined ? node : { ...node, iteration }, true, this.theme, undefined, job.live.progress[node.id], job.live.observedAt)
158
- .render(width);
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, "escape") ||
162
- data === "q" ||
163
- matchesKey(data, "ctrl+c")) {
164
- this.dispose();
165
- this.done();
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
- if (matchesKey(data, "left") || matchesKey(data, "right")) {
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 if (matchesKey(data, "backspace") && this.focus) {
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, "up"))
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 === "c" && this.selected)
189
- this.jobs.cancel(this.selected);
190
- this.refresh();
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
- const refresh = () => {
212
- if (!jobs.list().length) {
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 = jobs.list();
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
- panel = new BraidPanel(jobs, tui, theme, done, jobId, focus);
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,