@linxiraos/pi-tui 1.3.2 → 1.4.1

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.
Files changed (116) hide show
  1. package/CHANGELOG.md +181 -181
  2. package/dist/types/apps/session-picker.d.ts +13 -1
  3. package/dist/types/autocomplete.d.ts +6 -0
  4. package/dist/types/components/sparkline.d.ts +34 -0
  5. package/dist/types/index.d.ts +1 -0
  6. package/dist/types/native/describe.d.ts +7 -1
  7. package/dist/types/native/overlay.d.ts +2 -0
  8. package/dist/types/native/tone.d.ts +2 -0
  9. package/dist/types/overlays/agent-hub.d.ts +3 -3
  10. package/dist/types/overlays/approval-dialog.d.ts +15 -1
  11. package/dist/types/overlays/jobs-panel.d.ts +3 -0
  12. package/dist/types/overlays/model-browser.d.ts +1 -1
  13. package/dist/types/overlays/resume-picker.d.ts +293 -0
  14. package/dist/types/overlays/resume-tree.d.ts +353 -0
  15. package/dist/types/overlays/session-selector.d.ts +22 -4
  16. package/dist/types/render/output-block.d.ts +2 -0
  17. package/dist/types/render/tool-card.d.ts +2 -0
  18. package/dist/types/sixel.d.ts +28 -0
  19. package/dist/types/status-line/host.d.ts +3 -0
  20. package/dist/types/terminal-capabilities.d.ts +6 -1
  21. package/dist/types/tools/bash.d.ts +2 -0
  22. package/dist/types/tools/default-renderer.d.ts +3 -0
  23. package/dist/types/tools/eval.d.ts +9 -8
  24. package/dist/types/tools/hub.d.ts +3 -1
  25. package/dist/types/tools/irc.d.ts +11 -1
  26. package/dist/types/tools/job-widget.d.ts +226 -0
  27. package/dist/types/tools/json-tree.d.ts +2 -0
  28. package/dist/types/tools/line-ranges.d.ts +8 -1
  29. package/dist/types/tools/mcp.d.ts +23 -0
  30. package/dist/types/tools/output-meta.d.ts +1 -1
  31. package/dist/types/tools/read.d.ts +8 -2
  32. package/dist/types/tools/renderer.d.ts +8 -2
  33. package/dist/types/tools/result-card.d.ts +1 -1
  34. package/dist/types/tools/streaming-output.d.ts +1 -0
  35. package/dist/types/tools/task.d.ts +12 -0
  36. package/dist/types/tools/wait.d.ts +11 -0
  37. package/package.json +9 -9
  38. package/src/apps/session-picker.ts +20 -2
  39. package/src/autocomplete.d.ts +6 -0
  40. package/src/autocomplete.ts +21 -6
  41. package/src/chat/skill-message.ts +16 -17
  42. package/src/chat/tool-execution.ts +35 -0
  43. package/src/chrome/transcript-container.ts +85 -13
  44. package/src/components/editor.ts +17 -7
  45. package/src/components/image.ts +5 -4
  46. package/src/components/markdown.ts +11 -3
  47. package/src/components/sparkline.d.ts +34 -0
  48. package/src/components/sparkline.ts +89 -0
  49. package/src/index.d.ts +1 -0
  50. package/src/index.ts +1 -0
  51. package/src/native/backend.ts +32 -6
  52. package/src/native/describe.d.ts +7 -1
  53. package/src/native/describe.ts +18 -1
  54. package/src/native/overlay.d.ts +2 -0
  55. package/src/native/overlay.ts +3 -0
  56. package/src/native/tone.d.ts +2 -0
  57. package/src/native/tone.ts +22 -0
  58. package/src/overlays/agent-hub.ts +14 -10
  59. package/src/overlays/agent-transcript-viewer.ts +2 -2
  60. package/src/overlays/agents-hub.ts +2 -1
  61. package/src/overlays/approval-dialog.ts +59 -13
  62. package/src/overlays/hub-frame.ts +2 -1
  63. package/src/overlays/jobs-panel.ts +24 -2
  64. package/src/overlays/model-browser.ts +3 -0
  65. package/src/overlays/model-hub.ts +2 -1
  66. package/src/overlays/resume-picker.ts +706 -0
  67. package/src/overlays/resume-tree.ts +1901 -0
  68. package/src/overlays/session-selector.ts +579 -39
  69. package/src/prompt/attachment-chips.ts +1 -1
  70. package/src/render/output-block.d.ts +2 -0
  71. package/src/render/output-block.ts +25 -12
  72. package/src/render/tool-card.d.ts +2 -0
  73. package/src/render/tool-card.ts +6 -0
  74. package/src/sixel.d.ts +28 -0
  75. package/src/sixel.ts +185 -0
  76. package/src/status-line/component.ts +19 -2
  77. package/src/status-line/host.ts +3 -0
  78. package/src/terminal-capabilities.d.ts +6 -1
  79. package/src/terminal-capabilities.ts +54 -3
  80. package/src/theme/symbols.ts +5 -3
  81. package/src/tools/bash.d.ts +2 -0
  82. package/src/tools/bash.ts +25 -9
  83. package/src/tools/default-renderer.d.ts +3 -0
  84. package/src/tools/default-renderer.ts +54 -17
  85. package/src/tools/eval.d.ts +9 -8
  86. package/src/tools/eval.ts +32 -14
  87. package/src/tools/find.ts +4 -5
  88. package/src/tools/glob.ts +4 -6
  89. package/src/tools/grep.ts +2 -4
  90. package/src/tools/hub.ts +4 -1
  91. package/src/tools/irc.d.ts +11 -1
  92. package/src/tools/irc.ts +11 -1
  93. package/src/tools/job-widget.d.ts +226 -0
  94. package/src/tools/job-widget.ts +1036 -0
  95. package/src/tools/json-tree.d.ts +2 -0
  96. package/src/tools/json-tree.ts +8 -0
  97. package/src/tools/line-ranges.d.ts +8 -1
  98. package/src/tools/line-ranges.ts +23 -13
  99. package/src/tools/mcp.d.ts +23 -0
  100. package/src/tools/mcp.ts +44 -1
  101. package/src/tools/output-meta.d.ts +1 -1
  102. package/src/tools/output-meta.ts +1 -1
  103. package/src/tools/proc-render.ts +1 -0
  104. package/src/tools/read.d.ts +8 -2
  105. package/src/tools/read.ts +64 -21
  106. package/src/tools/renderer.d.ts +8 -2
  107. package/src/tools/renderer.ts +8 -2
  108. package/src/tools/result-card.d.ts +1 -1
  109. package/src/tools/result-card.ts +1 -2
  110. package/src/tools/streaming-output.d.ts +1 -0
  111. package/src/tools/streaming-output.ts +11 -9
  112. package/src/tools/task.d.ts +12 -0
  113. package/src/tools/task.ts +25 -1
  114. package/src/tools/wait.d.ts +11 -0
  115. package/src/tools/wait.ts +296 -147
  116. package/src/tui.ts +45 -5
@@ -3,6 +3,7 @@ import type { Theme } from "../theme/theme";
3
3
  import { type OutputMeta } from "./output-meta";
4
4
  import { type ConfiguredThinkingLevel } from "../render/render-utils";
5
5
  import type { StructuredSubagentOutput } from "./task";
6
+ import { type JobWidgetView } from "./job-widget";
6
7
  import type { RenderResultOptions, ToolActivitySummary } from "./renderer";
7
8
  import type { IrcDeliveryReceipt, IrcMessage } from "./irc";
8
9
  import type { NativeToolView, ToolRenderResult } from "./renderer";
@@ -29,6 +30,10 @@ export interface JobSnapshot {
29
30
  advisor?: boolean;
30
31
  /** The task agent's latest self-estimated completion (0–100); see `AgentProgress.completionPercent`. */
31
32
  completionPercent?: number;
33
+ /** The task agent's latest stage in its own words; see `AgentProgress.stage`. */
34
+ stage?: string;
35
+ /** The job's live widget, while one is being drawn; frozen at its last state once the job settles. */
36
+ widget?: JobWidgetView;
32
37
  resultText?: string;
33
38
  errorText?: string;
34
39
  /** Source-output metadata retained for per-job warnings and persisted row rendering. */
@@ -96,6 +101,12 @@ export interface CoordinationDetails {
96
101
  agents?: AgentActivitySnapshot[];
97
102
  /** `wait` was cut short by steering, a peer message, or a completion notice that injects next. */
98
103
  interrupted?: boolean;
104
+ /**
105
+ * `wait` returned early without a message because continuing would deadlock:
106
+ * the peer it was waiting on is itself waiting on this agent. Both sides can
107
+ * keep working — answer the peer, then wait again.
108
+ */
109
+ status?: "peer-waiting-on-you";
99
110
  }
100
111
  /** Pending wait frame. */
101
112
  declare function waitRenderCall(_args: object, _options: RenderResultOptions, uiTheme: Theme): Component;
package/src/tools/wait.ts CHANGED
@@ -24,10 +24,11 @@ import {
24
24
  type ConfiguredThinkingLevel,
25
25
  } from "../render/render-utils";
26
26
  import type { StructuredSubagentOutput } from "./task";
27
+ import { describeJobTiles, type JobTile, type JobWidgetView, renderJobTileLines } from "./job-widget";
27
28
  import type { RenderResultOptions, ToolRenderer, ToolActivitySummary } from "./renderer";
28
29
  import type { IrcDeliveryReceipt, IrcMessage } from "./irc";
29
30
  import type { TspSpan } from "@linxiraos/pi-wire";
30
- import { card as cardNode, compact, elapsed, md, node, row, span, text } from "../native/describe";
31
+ import { card as cardNode, compact, elapsed, keyed, md, node, row, span, text } from "../native/describe";
31
32
  import type { NativeNode } from "../native/node";
32
33
  import { OwnerMemo } from "../native/memo";
33
34
  import { plainText } from "../native/spans";
@@ -63,6 +64,10 @@ export interface JobSnapshot {
63
64
  advisor?: boolean;
64
65
  /** The task agent's latest self-estimated completion (0–100); see `AgentProgress.completionPercent`. */
65
66
  completionPercent?: number;
67
+ /** The task agent's latest stage in its own words; see `AgentProgress.stage`. */
68
+ stage?: string;
69
+ /** The job's live widget, while one is being drawn; frozen at its last state once the job settles. */
70
+ widget?: JobWidgetView;
66
71
  resultText?: string;
67
72
  errorText?: string;
68
73
  /** Source-output metadata retained for per-job warnings and persisted row rendering. */
@@ -131,6 +136,12 @@ export interface CoordinationDetails {
131
136
  agents?: AgentActivitySnapshot[];
132
137
  /** `wait` was cut short by steering, a peer message, or a completion notice that injects next. */
133
138
  interrupted?: boolean;
139
+ /**
140
+ * `wait` returned early without a message because continuing would deadlock:
141
+ * the peer it was waiting on is itself waiting on this agent. Both sides can
142
+ * keep working — answer the peer, then wait again.
143
+ */
144
+ status?: "peer-waiting-on-you";
134
145
  }
135
146
 
136
147
  // =============================================================================
@@ -194,6 +205,40 @@ function flattenStructuredPreview(text: string): string {
194
205
  return text.slice(0, PREVIEW_LINES_EXPANDED * PREVIEW_LINE_WIDTH * 2).replace(/\s+/g, " ");
195
206
  }
196
207
 
208
+ /** The row label: a running task's own stage line when it reports one, else the job's label. */
209
+ function jobRowLabel(job: JobSnapshot): string {
210
+ return job.type === "task" && job.status === "running" && job.stage?.trim() ? job.stage : job.label;
211
+ }
212
+
213
+ /** A job as its tile; `live` while the card's clocks run. */
214
+ function jobTile(job: JobSnapshot, live: boolean): JobTile {
215
+ return {
216
+ id: job.id,
217
+ type: job.type,
218
+ status: job.status,
219
+ label: job.label,
220
+ durationMs: job.durationMs,
221
+ live: live && job.status === "running",
222
+ ...(job.widget ? { widget: job.widget } : {}),
223
+ ...(job.status === "running" && job.completionPercent !== undefined
224
+ ? { completionPercent: job.completionPercent }
225
+ : {}),
226
+ ...(job.stage ? { stage: job.stage } : {}),
227
+ ...(job.exitCode === undefined ? {} : { exitCode: job.exitCode }),
228
+ };
229
+ }
230
+
231
+ /** Identity of the widgets a job list draws: changes whenever any widget's data or state does. */
232
+ function widgetStamp(jobs: readonly JobSnapshot[] | undefined): string {
233
+ if (!jobs) return "";
234
+ let stamp = "";
235
+ for (const job of jobs) if (job.widget) stamp += `${job.id}:${job.widget.updatedAt}:${job.widget.state};`;
236
+ return stamp;
237
+ }
238
+
239
+ /** Tiles a native wait card shows before it counts the rest. */
240
+ const TILES_SHOWN = 9;
241
+
197
242
  /** Pending wait frame. */
198
243
  function waitRenderCall(_args: object, _options: RenderResultOptions, uiTheme: Theme): Component {
199
244
  return new Text(renderStatusLine({ icon: "pending", title: "Wait" }, uiTheme), 0, 0);
@@ -201,7 +246,11 @@ function waitRenderCall(_args: object, _options: RenderResultOptions, uiTheme: T
201
246
 
202
247
  /** Result frame for wait snapshots and the agents roster. */
203
248
  function jobsRenderResult(
204
- result: { content: Array<{ type: string; text?: string }>; details?: CoordinationDetails; isError?: boolean },
249
+ result: {
250
+ content: Array<{ type: string; text?: string }>;
251
+ details?: CoordinationDetails;
252
+ isError?: boolean;
253
+ },
205
254
  options: RenderResultOptions,
206
255
  uiTheme: Theme,
207
256
  ): Component {
@@ -308,6 +357,87 @@ function jobsRenderResult(
308
357
  previewRows.set(job, { expanded, width: continuationWidth, lines });
309
358
  return lines;
310
359
  };
360
+ // Collapsed card: one line per settled job, or the bare "waiting on N jobs"
361
+ // header while nothing has settled. Repeated polls then read as a short
362
+ // completion log instead of a re-printed (and shrinking) job tree; Ctrl+O
363
+ // restores the full tree with output previews. A running job's live widget
364
+ // still draws its tile: that chart is the job's progress, not a copy of it.
365
+ const settledJobs = sortedJobs.filter(job => job.status !== "running");
366
+ const widgetJobs = sortedJobs.filter(job => job.status === "running" && job.widget?.state === "live");
367
+ const compactJobLines = (width: number, live: boolean): string[] => {
368
+ const lines: string[] = [];
369
+ const indent = " ".repeat(visibleWidth(formatStatusIcon("done", uiTheme)) + 1);
370
+ // Failures sort first, so the cap never hides one behind successes;
371
+ // Ctrl+O lists the rest.
372
+ for (const job of settledJobs.slice(0, PREVIEW_LIMITS.COLLAPSED_ITEMS)) {
373
+ const icon = formatStatusIcon(statusToIcon(job.status), uiTheme);
374
+ const word = job.status === "completed" ? "done" : job.status;
375
+ const exit =
376
+ job.exitCode !== undefined && job.exitCode !== 0
377
+ ? `${uiTheme.sep.dot}${uiTheme.fg("error", `exit ${job.exitCode}`)}`
378
+ : "";
379
+ // The outcome (status, exit, duration) is reserved first; a long id
380
+ // gives way so a failure is never cut off the row.
381
+ const outcome = ` ${uiTheme.fg(statusToColor(job.status), word)}${exit}${uiTheme.sep.dot}${uiTheme.fg("dim", formatDuration(job.durationMs))}`;
382
+ const id = truncateToWidth(
383
+ replaceTabs(job.id).replace(/\s+/g, " "),
384
+ Math.max(1, width - visibleWidth(`${icon} `) - visibleWidth(outcome)),
385
+ Ellipsis.Unicode,
386
+ );
387
+ let line = `${icon} ${uiTheme.fg("accent", id)}${outcome}`;
388
+ const label = replaceTabs(jobRowLabel(job).split(/\r?\n/)[0] ?? "").trim();
389
+ const labelBudget = width - visibleWidth(line) - 2;
390
+ if (label && label !== job.id && labelBudget >= 8) {
391
+ line += ` ${uiTheme.fg("muted", truncateToWidth(label, labelBudget, Ellipsis.Unicode))}`;
392
+ }
393
+ lines.push(line);
394
+ // A failure says why on the collapsed row; the rest of its output
395
+ // stays behind Ctrl+O.
396
+ const reason =
397
+ job.status === "failed"
398
+ ? stripOutputNotice(job.errorText ?? "", job.meta)
399
+ .split(/\r?\n/)
400
+ .find(part => part.trim())
401
+ ?.trim()
402
+ : undefined;
403
+ if (reason) {
404
+ lines.push(
405
+ `${indent}${uiTheme.fg("error", truncateToWidth(replaceTabs(reason), Math.max(1, width - indent.length), Ellipsis.Unicode))}`,
406
+ );
407
+ }
408
+ // The root warning is suppressed when a row owns the failure, so the
409
+ // compact row carries it too — otherwise it only shows after Ctrl+O.
410
+ const artifactError = job.meta?.artifactError ?? job.artifactError;
411
+ if (artifactError) {
412
+ lines.push(uiTheme.fg("warning", truncateToWidth(formatArtifactErrorNotice(artifactError), width)));
413
+ }
414
+ }
415
+ const hiddenSettled = settledJobs.length - PREVIEW_LIMITS.COLLAPSED_ITEMS;
416
+ if (hiddenSettled > 0) lines.push(`${indent}${uiTheme.fg("dim", `… ${hiddenSettled} more settled`)}`);
417
+ const shownWidgetJobs = widgetJobs.slice(0, PREVIEW_LIMITS.COLLAPSED_ITEMS);
418
+ for (const job of shownWidgetJobs) {
419
+ const typeBadge = formatBadge(job.type, statusToColor(job.status), uiTheme);
420
+ const icon = formatStatusIcon("running", uiTheme, live ? options.spinnerFrame : undefined);
421
+ const meta = compact([
422
+ job.completionPercent !== undefined ? uiTheme.fg("accent", `${job.completionPercent}%`) : undefined,
423
+ uiTheme.fg("dim", formatDuration(job.durationMs)),
424
+ ]).join(" ");
425
+ lines.push(
426
+ ...renderJobTileLines(jobTile(job, live), width, uiTheme, {
427
+ lead: `${icon} ${typeBadge} `,
428
+ meta,
429
+ full: false,
430
+ }),
431
+ );
432
+ }
433
+ // Running jobs without a drawn tile (no widget, or past the cap) collapse
434
+ // into one count; the header already carries it while nothing settled.
435
+ const quietRunning = counts.running - shownWidgetJobs.length;
436
+ if (quietRunning > 0 && (settledJobs.length > 0 || shownWidgetJobs.length < widgetJobs.length)) {
437
+ lines.push(`${indent}${uiTheme.fg("dim", `${quietRunning} still running`)}`);
438
+ }
439
+ return lines;
440
+ };
311
441
  return {
312
442
  render(width: number): readonly string[] {
313
443
  const expanded = options.expanded;
@@ -326,96 +456,125 @@ function jobsRenderResult(
326
456
  .u32(spinnerFrame)
327
457
  .bool(shimmerActive)
328
458
  .bool(showModelBadge)
459
+ .str(widgetStamp(jobs))
329
460
  .digest();
330
461
  if (!shimmerActive && cached?.key === key) return cached.lines;
331
462
 
332
- const itemLines = renderTreeList<JobSnapshot>(
333
- {
334
- items: sortedJobs,
335
- expanded,
336
- maxCollapsed: PREVIEW_LIMITS.COLLAPSED_ITEMS,
337
- itemType: "job",
338
- renderItem: (job, context) => {
339
- const rowWidth = Math.max(0, width - (context.prefixWidth ?? 0));
340
- const lines: string[] = [];
341
- const icon = `${formatStatusIcon(
342
- statusToIcon(job.status),
343
- uiTheme,
344
- job.status === "running" ? options.spinnerFrame : undefined,
345
- )}${job.exitCode === undefined ? "" : `${uiTheme.sep.dot}${uiTheme.fg(job.exitCode === 0 ? "muted" : "error", `exit ${job.exitCode}`)}`}`;
346
- const typeBadge = formatBadge(job.type, statusToColor(job.status), uiTheme);
347
- const completion =
348
- job.status === "running" && job.completionPercent !== undefined
349
- ? `${uiTheme.sep.dot}${uiTheme.fg("accent", `${job.completionPercent}%`)}`
350
- : "";
351
- const durationSuffix = `${completion}${uiTheme.sep.dot}${uiTheme.fg("dim", formatDuration(job.durationMs))}`;
352
- const displayId = truncateToWidth(
353
- replaceTabs(job.id).replace(/\s+/g, " "),
354
- Math.max(0, rowWidth - visibleWidth(`${icon} ${typeBadge} ${durationSuffix}`)),
355
- Ellipsis.Unicode,
356
- );
357
- const rawLabelLines = (job.label || "(no label)").split(/\r?\n/);
358
- const maxLabelLines = expanded ? LABEL_LINES_EXPANDED : LABEL_LINES_COLLAPSED;
359
- const visibleLabelLines = rawLabelLines
360
- .slice(0, maxLabelLines)
361
- .map(l => truncateToWidth(replaceTabs(l), LABEL_MAX_WIDTH, Ellipsis.Unicode));
362
- if (rawLabelLines.length > maxLabelLines && visibleLabelLines.length > 0) {
363
- const last = visibleLabelLines[visibleLabelLines.length - 1]!;
364
- visibleLabelLines[visibleLabelLines.length - 1] = `${last} …`;
365
- }
366
- const rowPrefix = `${icon} ${typeBadge} `;
367
- const modelIdentity = job.resolvedModelIdentity ?? job.resolvedModel;
368
- const modelBadge =
369
- job.type === "task" && showModelBadge && typeof modelIdentity === "string"
370
- ? formatFeedModelBadge(
371
- modelIdentity,
372
- job.resolvedThinkingLevel,
373
- job.advisor === true,
374
- uiTheme,
375
- Math.min(
376
- FEED_MODEL_BADGE_WIDTH,
377
- Math.max(0, rowWidth - visibleWidth(`${rowPrefix}${displayId}${durationSuffix}`) - 1),
463
+ const compactView = !expanded && jobs.length > 0;
464
+ const itemLines = compactView
465
+ ? compactJobLines(width, counts.running > 0 && options.spinnerFrame !== undefined)
466
+ : renderTreeList<JobSnapshot>(
467
+ {
468
+ items: sortedJobs,
469
+ expanded,
470
+ maxCollapsed: PREVIEW_LIMITS.COLLAPSED_ITEMS,
471
+ itemType: "job",
472
+ renderItem: (job, context) => {
473
+ const rowWidth = Math.max(0, width - (context.prefixWidth ?? 0));
474
+ const lines: string[] = [];
475
+ const icon = `${formatStatusIcon(
476
+ statusToIcon(job.status),
477
+ uiTheme,
478
+ job.status === "running" ? options.spinnerFrame : undefined,
479
+ )}${job.exitCode === undefined ? "" : `${uiTheme.sep.dot}${uiTheme.fg(job.exitCode === 0 ? "muted" : "error", `exit ${job.exitCode}`)}`}`;
480
+ const typeBadge = formatBadge(job.type, statusToColor(job.status), uiTheme);
481
+ const completion =
482
+ job.status === "running" && job.completionPercent !== undefined
483
+ ? `${uiTheme.sep.dot}${uiTheme.fg("accent", `${job.completionPercent}%`)}`
484
+ : "";
485
+ const durationSuffix = `${completion}${uiTheme.sep.dot}${uiTheme.fg("dim", formatDuration(job.durationMs))}`;
486
+ const displayId = truncateToWidth(
487
+ replaceTabs(job.id).replace(/\s+/g, " "),
488
+ Math.max(0, rowWidth - visibleWidth(`${icon} ${typeBadge} ${durationSuffix}`)),
489
+ Ellipsis.Unicode,
490
+ );
491
+ const rowLabel = jobRowLabel(job);
492
+ const rawLabelLines = (rowLabel || "(no label)").split(/\r?\n/);
493
+ const maxLabelLines = expanded ? LABEL_LINES_EXPANDED : LABEL_LINES_COLLAPSED;
494
+ const visibleLabelLines = rawLabelLines
495
+ .slice(0, maxLabelLines)
496
+ .map(l => truncateToWidth(replaceTabs(l), LABEL_MAX_WIDTH, Ellipsis.Unicode));
497
+ if (rawLabelLines.length > maxLabelLines && visibleLabelLines.length > 0) {
498
+ const last = visibleLabelLines[visibleLabelLines.length - 1]!;
499
+ visibleLabelLines[visibleLabelLines.length - 1] = `${last} …`;
500
+ }
501
+ const rowPrefix = `${icon} ${typeBadge} `;
502
+ const live = job.status === "running" && options.spinnerFrame !== undefined;
503
+ const continuationWidth = Math.max(0, rowWidth - visibleWidth(" "));
504
+ if (job.widget?.state === "live") {
505
+ const meta = compact([
506
+ job.status === "running" && job.completionPercent !== undefined
507
+ ? uiTheme.fg("accent", `${job.completionPercent}%`)
508
+ : undefined,
509
+ uiTheme.fg("dim", formatDuration(job.durationMs)),
510
+ ]).join(" ");
511
+ lines.push(
512
+ ...renderJobTileLines(jobTile(job, live), rowWidth, uiTheme, {
513
+ lead: rowPrefix,
514
+ meta,
515
+ full: expanded,
516
+ }),
517
+ );
518
+ } else {
519
+ const modelIdentity = job.resolvedModelIdentity ?? job.resolvedModel;
520
+ const modelBadge =
521
+ job.type === "task" && showModelBadge && typeof modelIdentity === "string"
522
+ ? formatFeedModelBadge(
523
+ modelIdentity,
524
+ job.resolvedThinkingLevel,
525
+ job.advisor === true,
526
+ uiTheme,
527
+ Math.min(
528
+ FEED_MODEL_BADGE_WIDTH,
529
+ Math.max(
530
+ 0,
531
+ rowWidth - visibleWidth(`${rowPrefix}${displayId}${durationSuffix}`) - 1,
532
+ ),
533
+ ),
534
+ )
535
+ : "";
536
+ const modelLead = modelBadge ? `${modelBadge} ` : "";
537
+ const headRaw = displayId;
538
+ // Running rows in a live block shimmer their label; once the block
539
+ // stops animating (sealed, or a settled snapshot — spinnerFrame
540
+ // cleared) they render static so scrollback never keeps a mid-sweep
541
+ // shimmer band.
542
+ const headLabel = live
543
+ ? shimmerEnabled()
544
+ ? shimmerText(headRaw, uiTheme)
545
+ : uiTheme.fg("accent", headRaw)
546
+ : uiTheme.fg("toolOutput", headRaw);
547
+ let row = `${rowPrefix}${modelLead}${headLabel}`;
548
+ const distinctLabel = rowLabel.trim() !== job.id;
549
+ const label = visibleLabelLines[0] ?? "";
550
+ const inlineLabel =
551
+ distinctLabel && visibleWidth(`${row} ${label}${durationSuffix}`) <= rowWidth;
552
+ if (inlineLabel) row += ` ${uiTheme.fg("toolOutput", label)}`;
553
+ row += durationSuffix;
554
+ lines.push(truncateToWidth(row, rowWidth, ""));
555
+ for (let i = distinctLabel && !inlineLabel ? 0 : 1; i < visibleLabelLines.length; i++) {
556
+ lines.push(
557
+ ` ${uiTheme.fg("toolOutput", truncateToWidth(visibleLabelLines[i]!, continuationWidth))}`,
558
+ );
559
+ }
560
+ }
561
+ const artifactError = job.meta?.artifactError ?? job.artifactError;
562
+ if (artifactError) {
563
+ lines.push(
564
+ uiTheme.fg(
565
+ "warning",
566
+ truncateToWidth(formatArtifactErrorNotice(artifactError), rowWidth),
378
567
  ),
379
- )
380
- : "";
381
- const modelLead = modelBadge ? `${modelBadge} ` : "";
382
- const headRaw = displayId;
383
- // Running rows in a live block shimmer their label; once the block
384
- // stops animating (sealed, or a settled snapshot — spinnerFrame
385
- // cleared) they render static so scrollback never keeps a mid-sweep
386
- // shimmer band.
387
- const live = job.status === "running" && options.spinnerFrame !== undefined;
388
- const headLabel = live
389
- ? shimmerEnabled()
390
- ? shimmerText(headRaw, uiTheme)
391
- : uiTheme.fg("accent", headRaw)
392
- : uiTheme.fg("toolOutput", headRaw);
393
- let row = `${rowPrefix}${modelLead}${headLabel}`;
394
- const distinctLabel = job.label.trim() !== job.id;
395
- const label = visibleLabelLines[0] ?? "";
396
- const inlineLabel = distinctLabel && visibleWidth(`${row} ${label}${durationSuffix}`) <= rowWidth;
397
- if (inlineLabel) row += ` ${uiTheme.fg("toolOutput", label)}`;
398
- row += durationSuffix;
399
- lines.push(truncateToWidth(row, rowWidth, ""));
400
- const continuationWidth = Math.max(0, rowWidth - visibleWidth(" "));
401
- for (let i = distinctLabel && !inlineLabel ? 0 : 1; i < visibleLabelLines.length; i++) {
402
- lines.push(
403
- ` ${uiTheme.fg("toolOutput", truncateToWidth(visibleLabelLines[i]!, continuationWidth))}`,
404
- );
405
- }
406
- const artifactError = job.meta?.artifactError ?? job.artifactError;
407
- if (artifactError) {
408
- lines.push(
409
- uiTheme.fg("warning", truncateToWidth(formatArtifactErrorNotice(artifactError), rowWidth)),
410
- );
411
- }
412
-
413
- for (const previewLine of jobPreviewRows(job, expanded, continuationWidth)) lines.push(previewLine);
414
- return lines;
415
- },
416
- },
417
- uiTheme,
418
- );
568
+ );
569
+ }
570
+
571
+ for (const previewLine of jobPreviewRows(job, expanded, continuationWidth))
572
+ lines.push(previewLine);
573
+ return lines;
574
+ },
575
+ },
576
+ uiTheme,
577
+ );
419
578
 
420
579
  // Agents run outside job control; render them as their own tree so
421
580
  // they never skew the job counts or the "waiting on N jobs" title.
@@ -458,7 +617,7 @@ function jobsRenderResult(
458
617
  uiTheme,
459
618
  );
460
619
 
461
- const all = [header];
620
+ const all = compactView && settledJobs.length > 0 ? [] : [header];
462
621
  if (aggregateArtifactError) {
463
622
  all.push(uiTheme.fg("warning", formatArtifactErrorNotice(aggregateArtifactError)));
464
623
  }
@@ -498,7 +657,11 @@ function bodyLines(
498
657
  body: string,
499
658
  expanded: boolean,
500
659
  theme: Theme,
501
- options: { indent?: string; tone?: "dim" | "toolOutput"; collapsedLines?: number } = {},
660
+ options: {
661
+ indent?: string;
662
+ tone?: "dim" | "toolOutput";
663
+ collapsedLines?: number;
664
+ } = {},
502
665
  ): string[] {
503
666
  const indent = options.indent ?? "";
504
667
  const tone = options.tone ?? "toolOutput";
@@ -556,17 +719,18 @@ export function createIrcMessageCard(
556
719
  if (card.replyTo) meta.push("reply");
557
720
  const age = messageAge(card.timestamp);
558
721
  if (age) meta.push(age);
559
- const component = createCachedComponent(
560
- getExpanded,
561
- (width, expanded) => {
562
- const lines = [renderStatusLine({ iconOverride: ircGlyph(uiTheme), title, meta }, uiTheme)];
563
- if (body.trim()) {
564
- lines.push(...bodyLines(body, expanded, uiTheme, { indent: " ", collapsedLines: 3 }));
565
- }
566
- return lines.map(line => truncateToWidth(line, width, Ellipsis.Unicode));
567
- },
568
- { paddingX: 1 },
569
- );
722
+ const component = createCachedComponent(getExpanded, (width, expanded) => {
723
+ const lines = [renderStatusLine({ iconOverride: ircGlyph(uiTheme), title, meta }, uiTheme)];
724
+ if (body.trim()) {
725
+ lines.push(
726
+ ...bodyLines(body, expanded, uiTheme, {
727
+ indent: " ",
728
+ collapsedLines: 3,
729
+ }),
730
+ );
731
+ }
732
+ return lines.map(line => truncateToWidth(line, width, Ellipsis.Unicode));
733
+ });
570
734
  // Terminal-local collapse replaces `getExpanded`; the node never changes after creation.
571
735
  const described = cardNode(
572
736
  {
@@ -589,47 +753,6 @@ export function createIrcMessageCard(
589
753
  return Object.assign(component, { describe: () => described });
590
754
  }
591
755
 
592
- /**
593
- * One job row on one line: type badge, id + label, terminal-clocked duration
594
- * (live while running). The job's output and errors fill the card of the call
595
- * that started it (`parksAsBackground`), so the row carries no preview.
596
- */
597
- function describeJob(job: JobSnapshot, isPartial: boolean): NativeNode {
598
- const running = job.status === "running" && isPartial;
599
- const label = job.label.trim() !== job.id ? plainText(job.label.split(/\r?\n/)[0] ?? "") : "";
600
- const spans: TspSpan[] = [
601
- span(plainText(job.id), running ? "accent" : "toolOutput", running ? { fx: "shimmer" } : undefined),
602
- ];
603
- if (label) spans.push(span(` ${label}`, "toolOutput"));
604
- if (job.exitCode !== undefined) spans.push(span(` exit ${job.exitCode}`, job.exitCode === 0 ? "muted" : "error"));
605
- const tone =
606
- job.status === "completed"
607
- ? "success"
608
- : job.status === "failed"
609
- ? "error"
610
- : job.status === "cancelled"
611
- ? "warning"
612
- : "accent";
613
- return node(
614
- "row",
615
- { role: "zeta.wait.job", tone, gap: "sm" },
616
- compact([
617
- node("badge", { text: job.type, tone }),
618
- text(spans, { truncate: "end", grow: 1 }),
619
- job.status === "running" && job.completionPercent !== undefined
620
- ? node("progress", {
621
- value: job.completionPercent / 100,
622
- label: `${job.completionPercent}%`,
623
- tone: "accent",
624
- title: "Agent's own completion estimate",
625
- })
626
- : undefined,
627
- elapsed(job.durationMs, !running),
628
- ]),
629
- job.id,
630
- );
631
- }
632
-
633
756
  function describeJobsResult(
634
757
  result: ToolRenderResult<CoordinationDetails>,
635
758
  isPartial: boolean,
@@ -658,9 +781,25 @@ function describeJobsResult(
658
781
  if (counts.completed > 0) head.push(span(` ${counts.completed} done`, "success"));
659
782
  if (counts.failed > 0) head.push(span(` ${counts.failed} failed`, "error"));
660
783
  if (counts.cancelled > 0) head.push(span(` ${counts.cancelled} cancelled`, "warning"));
661
- const order: Record<JobSnapshot["status"], number> = { running: 0, failed: 1, cancelled: 2, completed: 3 };
784
+ const order: Record<JobSnapshot["status"], number> = {
785
+ running: 0,
786
+ failed: 1,
787
+ cancelled: 2,
788
+ completed: 3,
789
+ };
662
790
  const sorted = [...jobs].sort((a, b) => order[a.status] - order[b.status] || b.durationMs - a.durationMs);
663
- const body: NativeNode[] = sorted.map(job => describeJob(job, isPartial));
791
+ // One frame of tiles: each job's status, title, figure and widget together. The job's output
792
+ // and errors fill the card of the call that started it (`parksAsBackground`), so no previews.
793
+ const body: NativeNode[] = [];
794
+ if (sorted.length > 0) {
795
+ body.push(keyed(describeJobTiles(sorted.slice(0, TILES_SHOWN).map(job => jobTile(job, isPartial))), "tiles"));
796
+ }
797
+ if (sorted.length > TILES_SHOWN) {
798
+ const rest = sorted.length - TILES_SHOWN;
799
+ body.push(
800
+ text([span(`+${rest} more ${rest === 1 ? "job" : "jobs"} · /jobs`, "dim")], { role: "zeta.wait.more" }),
801
+ );
802
+ }
664
803
  for (const agent of agents) {
665
804
  const spans: TspSpan[] = [span(plainText(agent.id), "muted")];
666
805
  if (agent.activity) spans.push(span(` ${plainText(agent.activity)}`, "toolOutput"));
@@ -684,6 +823,8 @@ function describeJobsResult(
684
823
  return {
685
824
  head,
686
825
  tone: counts.failed > 0 ? "warning" : counts.running > 0 || agents.length > 0 ? "info" : "success",
826
+ // The tiles are what a wait shows: clamping the body by lines would cut them.
827
+ preview: "children",
687
828
  body,
688
829
  };
689
830
  }
@@ -729,11 +870,15 @@ export const waitToolRenderer = {
729
870
  result: ToolRenderResult<CoordinationDetails>,
730
871
  options: RenderResultOptions,
731
872
  ): NativeToolView | undefined {
732
- return waitResultMemo.get(result, [options.isPartial], () => {
873
+ return waitResultMemo.get(result, [options.isPartial, widgetStamp(result.details?.jobs)], () => {
733
874
  if (result.isError) return errorView("Wait", resultText(result));
734
875
  const details = result.details;
735
876
  if (details?.interrupted) {
736
- return { head: toolHead("Wait", "interrupted by message"), tone: "info", inline: true };
877
+ return {
878
+ head: toolHead("Wait", "interrupted by message"),
879
+ tone: "info",
880
+ inline: true,
881
+ };
737
882
  }
738
883
  const waited = details?.waited;
739
884
  if (!waited) return describeJobsResult(result, options.isPartial);
@@ -741,7 +886,11 @@ export const waitToolRenderer = {
741
886
  });
742
887
  },
743
888
  renderResult(
744
- result: { content: Array<{ type: string; text?: string }>; details?: CoordinationDetails; isError?: boolean },
889
+ result: {
890
+ content: Array<{ type: string; text?: string }>;
891
+ details?: CoordinationDetails;
892
+ isError?: boolean;
893
+ },
745
894
  options: RenderResultOptions,
746
895
  uiTheme: Theme,
747
896
  ): Component {