@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/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, contentWidth) : undefined;
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
- ...(current.fullOutputPath
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
- const range = `Lines ${this.offset + 1}–${Math.min(content.length, this.offset + height)}/${content.length}`;
153
- const [wide, medium, narrow] = this.focus
154
- ? ["Esc back ←/→ executions ↑/↓ scroll End follow ctrl+o tools c cancel q close",
155
- "Esc back · ←/→ executions · ↑/↓ scroll · q close", "Esc back · q close"]
156
- : this.choosing !== undefined
157
- ? ["↑/↓/←/→ choose node Enter open session Esc back PgUp/PgDn page q close",
158
- "↑/↓ choose · Enter open · Esc back", "Enter open · Esc back"]
159
- : ["v select node ←/→ jobs ↑/↓ scroll PgUp/PgDn page c cancel Esc close",
160
- "v select node · ←/→ jobs · ↑/↓ scroll · Esc close", "v select · Esc close"];
161
- const help = contentWidth >= 80 ? wide : contentWidth >= 42 ? medium : narrow;
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
- ...header,
164
- ...(pinned.length ? [...pinned.map(row), rule("├", "┤")] : []),
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 (data === "q" || matchesKey(data, "ctrl+c")) {
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
- const refresh = () => {
361
- if (!jobs.list().length) {
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 = jobs.list();
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
- panel = new BraidPanel(jobs, tui, theme, done, jobId, focus);
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
- : "dim";
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 node result: ${compact(fullOutputPath, 240)}`)] : []),
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 result/log: ${compact(result.fullOutputPath, 240)}`));
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
+ }