@linxiraos/pi-tui 1.4.0 → 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 (105) hide show
  1. package/CHANGELOG.md +178 -182
  2. package/dist/types/autocomplete.d.ts +6 -0
  3. package/dist/types/components/sparkline.d.ts +34 -0
  4. package/dist/types/index.d.ts +1 -0
  5. package/dist/types/native/describe.d.ts +7 -1
  6. package/dist/types/native/overlay.d.ts +2 -0
  7. package/dist/types/native/tone.d.ts +2 -0
  8. package/dist/types/overlays/agent-hub.d.ts +3 -3
  9. package/dist/types/overlays/approval-dialog.d.ts +15 -1
  10. package/dist/types/overlays/jobs-panel.d.ts +3 -0
  11. package/dist/types/overlays/model-browser.d.ts +1 -1
  12. package/dist/types/render/output-block.d.ts +2 -0
  13. package/dist/types/render/tool-card.d.ts +2 -0
  14. package/dist/types/sixel.d.ts +28 -0
  15. package/dist/types/status-line/host.d.ts +3 -0
  16. package/dist/types/terminal-capabilities.d.ts +5 -0
  17. package/dist/types/tools/bash.d.ts +2 -0
  18. package/dist/types/tools/default-renderer.d.ts +3 -0
  19. package/dist/types/tools/eval.d.ts +9 -8
  20. package/dist/types/tools/irc.d.ts +6 -0
  21. package/dist/types/tools/job-widget.d.ts +226 -0
  22. package/dist/types/tools/json-tree.d.ts +2 -0
  23. package/dist/types/tools/line-ranges.d.ts +8 -1
  24. package/dist/types/tools/mcp.d.ts +23 -0
  25. package/dist/types/tools/output-meta.d.ts +1 -1
  26. package/dist/types/tools/read.d.ts +8 -2
  27. package/dist/types/tools/renderer.d.ts +8 -2
  28. package/dist/types/tools/result-card.d.ts +1 -1
  29. package/dist/types/tools/streaming-output.d.ts +1 -0
  30. package/dist/types/tools/task.d.ts +12 -0
  31. package/dist/types/tools/wait.d.ts +5 -0
  32. package/package.json +9 -9
  33. package/src/autocomplete.d.ts +6 -0
  34. package/src/autocomplete.ts +21 -6
  35. package/src/chat/skill-message.ts +16 -17
  36. package/src/chat/tool-execution.ts +35 -0
  37. package/src/chrome/transcript-container.ts +85 -13
  38. package/src/components/editor.ts +17 -7
  39. package/src/components/image.ts +5 -4
  40. package/src/components/markdown.ts +11 -3
  41. package/src/components/sparkline.d.ts +34 -0
  42. package/src/components/sparkline.ts +89 -0
  43. package/src/index.d.ts +1 -0
  44. package/src/index.ts +1 -0
  45. package/src/native/backend.ts +32 -6
  46. package/src/native/describe.d.ts +7 -1
  47. package/src/native/describe.ts +18 -1
  48. package/src/native/overlay.d.ts +2 -0
  49. package/src/native/overlay.ts +3 -0
  50. package/src/native/tone.d.ts +2 -0
  51. package/src/native/tone.ts +22 -0
  52. package/src/overlays/agent-hub.ts +14 -10
  53. package/src/overlays/agent-transcript-viewer.ts +2 -2
  54. package/src/overlays/agents-hub.ts +2 -1
  55. package/src/overlays/approval-dialog.ts +59 -13
  56. package/src/overlays/hub-frame.ts +2 -1
  57. package/src/overlays/jobs-panel.ts +24 -2
  58. package/src/overlays/model-browser.ts +3 -0
  59. package/src/overlays/model-hub.ts +2 -1
  60. package/src/prompt/attachment-chips.ts +1 -1
  61. package/src/render/output-block.d.ts +2 -0
  62. package/src/render/output-block.ts +25 -12
  63. package/src/render/tool-card.d.ts +2 -0
  64. package/src/render/tool-card.ts +6 -0
  65. package/src/sixel.d.ts +28 -0
  66. package/src/sixel.ts +185 -0
  67. package/src/status-line/component.ts +19 -2
  68. package/src/status-line/host.ts +3 -0
  69. package/src/terminal-capabilities.d.ts +5 -0
  70. package/src/terminal-capabilities.ts +43 -3
  71. package/src/theme/symbols.ts +5 -3
  72. package/src/tools/bash.d.ts +2 -0
  73. package/src/tools/bash.ts +25 -9
  74. package/src/tools/default-renderer.d.ts +3 -0
  75. package/src/tools/default-renderer.ts +54 -17
  76. package/src/tools/eval.d.ts +9 -8
  77. package/src/tools/eval.ts +32 -14
  78. package/src/tools/find.ts +4 -5
  79. package/src/tools/glob.ts +4 -6
  80. package/src/tools/grep.ts +2 -4
  81. package/src/tools/irc.d.ts +6 -0
  82. package/src/tools/irc.ts +6 -0
  83. package/src/tools/job-widget.d.ts +226 -0
  84. package/src/tools/job-widget.ts +1036 -0
  85. package/src/tools/json-tree.d.ts +2 -0
  86. package/src/tools/json-tree.ts +8 -0
  87. package/src/tools/line-ranges.d.ts +8 -1
  88. package/src/tools/line-ranges.ts +23 -13
  89. package/src/tools/mcp.d.ts +23 -0
  90. package/src/tools/mcp.ts +44 -1
  91. package/src/tools/output-meta.d.ts +1 -1
  92. package/src/tools/output-meta.ts +1 -1
  93. package/src/tools/read.d.ts +8 -2
  94. package/src/tools/read.ts +64 -21
  95. package/src/tools/renderer.d.ts +8 -2
  96. package/src/tools/renderer.ts +8 -2
  97. package/src/tools/result-card.d.ts +1 -1
  98. package/src/tools/result-card.ts +1 -2
  99. package/src/tools/streaming-output.d.ts +1 -0
  100. package/src/tools/streaming-output.ts +11 -9
  101. package/src/tools/task.d.ts +12 -0
  102. package/src/tools/task.ts +25 -1
  103. package/src/tools/wait.d.ts +5 -0
  104. package/src/tools/wait.ts +290 -147
  105. package/src/tui.ts +45 -5
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. */
@@ -200,6 +205,40 @@ function flattenStructuredPreview(text: string): string {
200
205
  return text.slice(0, PREVIEW_LINES_EXPANDED * PREVIEW_LINE_WIDTH * 2).replace(/\s+/g, " ");
201
206
  }
202
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
+
203
242
  /** Pending wait frame. */
204
243
  function waitRenderCall(_args: object, _options: RenderResultOptions, uiTheme: Theme): Component {
205
244
  return new Text(renderStatusLine({ icon: "pending", title: "Wait" }, uiTheme), 0, 0);
@@ -207,7 +246,11 @@ function waitRenderCall(_args: object, _options: RenderResultOptions, uiTheme: T
207
246
 
208
247
  /** Result frame for wait snapshots and the agents roster. */
209
248
  function jobsRenderResult(
210
- 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
+ },
211
254
  options: RenderResultOptions,
212
255
  uiTheme: Theme,
213
256
  ): Component {
@@ -314,6 +357,87 @@ function jobsRenderResult(
314
357
  previewRows.set(job, { expanded, width: continuationWidth, lines });
315
358
  return lines;
316
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
+ };
317
441
  return {
318
442
  render(width: number): readonly string[] {
319
443
  const expanded = options.expanded;
@@ -332,96 +456,125 @@ function jobsRenderResult(
332
456
  .u32(spinnerFrame)
333
457
  .bool(shimmerActive)
334
458
  .bool(showModelBadge)
459
+ .str(widgetStamp(jobs))
335
460
  .digest();
336
461
  if (!shimmerActive && cached?.key === key) return cached.lines;
337
462
 
338
- const itemLines = renderTreeList<JobSnapshot>(
339
- {
340
- items: sortedJobs,
341
- expanded,
342
- maxCollapsed: PREVIEW_LIMITS.COLLAPSED_ITEMS,
343
- itemType: "job",
344
- renderItem: (job, context) => {
345
- const rowWidth = Math.max(0, width - (context.prefixWidth ?? 0));
346
- const lines: string[] = [];
347
- const icon = `${formatStatusIcon(
348
- statusToIcon(job.status),
349
- uiTheme,
350
- job.status === "running" ? options.spinnerFrame : undefined,
351
- )}${job.exitCode === undefined ? "" : `${uiTheme.sep.dot}${uiTheme.fg(job.exitCode === 0 ? "muted" : "error", `exit ${job.exitCode}`)}`}`;
352
- const typeBadge = formatBadge(job.type, statusToColor(job.status), uiTheme);
353
- const completion =
354
- job.status === "running" && job.completionPercent !== undefined
355
- ? `${uiTheme.sep.dot}${uiTheme.fg("accent", `${job.completionPercent}%`)}`
356
- : "";
357
- const durationSuffix = `${completion}${uiTheme.sep.dot}${uiTheme.fg("dim", formatDuration(job.durationMs))}`;
358
- const displayId = truncateToWidth(
359
- replaceTabs(job.id).replace(/\s+/g, " "),
360
- Math.max(0, rowWidth - visibleWidth(`${icon} ${typeBadge} ${durationSuffix}`)),
361
- Ellipsis.Unicode,
362
- );
363
- const rawLabelLines = (job.label || "(no label)").split(/\r?\n/);
364
- const maxLabelLines = expanded ? LABEL_LINES_EXPANDED : LABEL_LINES_COLLAPSED;
365
- const visibleLabelLines = rawLabelLines
366
- .slice(0, maxLabelLines)
367
- .map(l => truncateToWidth(replaceTabs(l), LABEL_MAX_WIDTH, Ellipsis.Unicode));
368
- if (rawLabelLines.length > maxLabelLines && visibleLabelLines.length > 0) {
369
- const last = visibleLabelLines[visibleLabelLines.length - 1]!;
370
- visibleLabelLines[visibleLabelLines.length - 1] = `${last} …`;
371
- }
372
- const rowPrefix = `${icon} ${typeBadge} `;
373
- const modelIdentity = job.resolvedModelIdentity ?? job.resolvedModel;
374
- const modelBadge =
375
- job.type === "task" && showModelBadge && typeof modelIdentity === "string"
376
- ? formatFeedModelBadge(
377
- modelIdentity,
378
- job.resolvedThinkingLevel,
379
- job.advisor === true,
380
- uiTheme,
381
- Math.min(
382
- FEED_MODEL_BADGE_WIDTH,
383
- 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),
384
567
  ),
385
- )
386
- : "";
387
- const modelLead = modelBadge ? `${modelBadge} ` : "";
388
- const headRaw = displayId;
389
- // Running rows in a live block shimmer their label; once the block
390
- // stops animating (sealed, or a settled snapshot — spinnerFrame
391
- // cleared) they render static so scrollback never keeps a mid-sweep
392
- // shimmer band.
393
- const live = job.status === "running" && options.spinnerFrame !== undefined;
394
- const headLabel = live
395
- ? shimmerEnabled()
396
- ? shimmerText(headRaw, uiTheme)
397
- : uiTheme.fg("accent", headRaw)
398
- : uiTheme.fg("toolOutput", headRaw);
399
- let row = `${rowPrefix}${modelLead}${headLabel}`;
400
- const distinctLabel = job.label.trim() !== job.id;
401
- const label = visibleLabelLines[0] ?? "";
402
- const inlineLabel = distinctLabel && visibleWidth(`${row} ${label}${durationSuffix}`) <= rowWidth;
403
- if (inlineLabel) row += ` ${uiTheme.fg("toolOutput", label)}`;
404
- row += durationSuffix;
405
- lines.push(truncateToWidth(row, rowWidth, ""));
406
- const continuationWidth = Math.max(0, rowWidth - visibleWidth(" "));
407
- for (let i = distinctLabel && !inlineLabel ? 0 : 1; i < visibleLabelLines.length; i++) {
408
- lines.push(
409
- ` ${uiTheme.fg("toolOutput", truncateToWidth(visibleLabelLines[i]!, continuationWidth))}`,
410
- );
411
- }
412
- const artifactError = job.meta?.artifactError ?? job.artifactError;
413
- if (artifactError) {
414
- lines.push(
415
- uiTheme.fg("warning", truncateToWidth(formatArtifactErrorNotice(artifactError), rowWidth)),
416
- );
417
- }
418
-
419
- for (const previewLine of jobPreviewRows(job, expanded, continuationWidth)) lines.push(previewLine);
420
- return lines;
421
- },
422
- },
423
- uiTheme,
424
- );
568
+ );
569
+ }
570
+
571
+ for (const previewLine of jobPreviewRows(job, expanded, continuationWidth))
572
+ lines.push(previewLine);
573
+ return lines;
574
+ },
575
+ },
576
+ uiTheme,
577
+ );
425
578
 
426
579
  // Agents run outside job control; render them as their own tree so
427
580
  // they never skew the job counts or the "waiting on N jobs" title.
@@ -464,7 +617,7 @@ function jobsRenderResult(
464
617
  uiTheme,
465
618
  );
466
619
 
467
- const all = [header];
620
+ const all = compactView && settledJobs.length > 0 ? [] : [header];
468
621
  if (aggregateArtifactError) {
469
622
  all.push(uiTheme.fg("warning", formatArtifactErrorNotice(aggregateArtifactError)));
470
623
  }
@@ -504,7 +657,11 @@ function bodyLines(
504
657
  body: string,
505
658
  expanded: boolean,
506
659
  theme: Theme,
507
- options: { indent?: string; tone?: "dim" | "toolOutput"; collapsedLines?: number } = {},
660
+ options: {
661
+ indent?: string;
662
+ tone?: "dim" | "toolOutput";
663
+ collapsedLines?: number;
664
+ } = {},
508
665
  ): string[] {
509
666
  const indent = options.indent ?? "";
510
667
  const tone = options.tone ?? "toolOutput";
@@ -562,17 +719,18 @@ export function createIrcMessageCard(
562
719
  if (card.replyTo) meta.push("reply");
563
720
  const age = messageAge(card.timestamp);
564
721
  if (age) meta.push(age);
565
- const component = createCachedComponent(
566
- getExpanded,
567
- (width, expanded) => {
568
- const lines = [renderStatusLine({ iconOverride: ircGlyph(uiTheme), title, meta }, uiTheme)];
569
- if (body.trim()) {
570
- lines.push(...bodyLines(body, expanded, uiTheme, { indent: " ", collapsedLines: 3 }));
571
- }
572
- return lines.map(line => truncateToWidth(line, width, Ellipsis.Unicode));
573
- },
574
- { paddingX: 1 },
575
- );
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
+ });
576
734
  // Terminal-local collapse replaces `getExpanded`; the node never changes after creation.
577
735
  const described = cardNode(
578
736
  {
@@ -595,47 +753,6 @@ export function createIrcMessageCard(
595
753
  return Object.assign(component, { describe: () => described });
596
754
  }
597
755
 
598
- /**
599
- * One job row on one line: type badge, id + label, terminal-clocked duration
600
- * (live while running). The job's output and errors fill the card of the call
601
- * that started it (`parksAsBackground`), so the row carries no preview.
602
- */
603
- function describeJob(job: JobSnapshot, isPartial: boolean): NativeNode {
604
- const running = job.status === "running" && isPartial;
605
- const label = job.label.trim() !== job.id ? plainText(job.label.split(/\r?\n/)[0] ?? "") : "";
606
- const spans: TspSpan[] = [
607
- span(plainText(job.id), running ? "accent" : "toolOutput", running ? { fx: "shimmer" } : undefined),
608
- ];
609
- if (label) spans.push(span(` ${label}`, "toolOutput"));
610
- if (job.exitCode !== undefined) spans.push(span(` exit ${job.exitCode}`, job.exitCode === 0 ? "muted" : "error"));
611
- const tone =
612
- job.status === "completed"
613
- ? "success"
614
- : job.status === "failed"
615
- ? "error"
616
- : job.status === "cancelled"
617
- ? "warning"
618
- : "accent";
619
- return node(
620
- "row",
621
- { role: "zeta.wait.job", tone, gap: "sm" },
622
- compact([
623
- node("badge", { text: job.type, tone }),
624
- text(spans, { truncate: "end", grow: 1 }),
625
- job.status === "running" && job.completionPercent !== undefined
626
- ? node("progress", {
627
- value: job.completionPercent / 100,
628
- label: `${job.completionPercent}%`,
629
- tone: "accent",
630
- title: "Agent's own completion estimate",
631
- })
632
- : undefined,
633
- elapsed(job.durationMs, !running),
634
- ]),
635
- job.id,
636
- );
637
- }
638
-
639
756
  function describeJobsResult(
640
757
  result: ToolRenderResult<CoordinationDetails>,
641
758
  isPartial: boolean,
@@ -664,9 +781,25 @@ function describeJobsResult(
664
781
  if (counts.completed > 0) head.push(span(` ${counts.completed} done`, "success"));
665
782
  if (counts.failed > 0) head.push(span(` ${counts.failed} failed`, "error"));
666
783
  if (counts.cancelled > 0) head.push(span(` ${counts.cancelled} cancelled`, "warning"));
667
- 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
+ };
668
790
  const sorted = [...jobs].sort((a, b) => order[a.status] - order[b.status] || b.durationMs - a.durationMs);
669
- 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
+ }
670
803
  for (const agent of agents) {
671
804
  const spans: TspSpan[] = [span(plainText(agent.id), "muted")];
672
805
  if (agent.activity) spans.push(span(` ${plainText(agent.activity)}`, "toolOutput"));
@@ -690,6 +823,8 @@ function describeJobsResult(
690
823
  return {
691
824
  head,
692
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",
693
828
  body,
694
829
  };
695
830
  }
@@ -735,11 +870,15 @@ export const waitToolRenderer = {
735
870
  result: ToolRenderResult<CoordinationDetails>,
736
871
  options: RenderResultOptions,
737
872
  ): NativeToolView | undefined {
738
- return waitResultMemo.get(result, [options.isPartial], () => {
873
+ return waitResultMemo.get(result, [options.isPartial, widgetStamp(result.details?.jobs)], () => {
739
874
  if (result.isError) return errorView("Wait", resultText(result));
740
875
  const details = result.details;
741
876
  if (details?.interrupted) {
742
- 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
+ };
743
882
  }
744
883
  const waited = details?.waited;
745
884
  if (!waited) return describeJobsResult(result, options.isPartial);
@@ -747,7 +886,11 @@ export const waitToolRenderer = {
747
886
  });
748
887
  },
749
888
  renderResult(
750
- 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
+ },
751
894
  options: RenderResultOptions,
752
895
  uiTheme: Theme,
753
896
  ): Component {
package/src/tui.ts CHANGED
@@ -28,6 +28,7 @@ import { assumedTspHello, NativeBackend, type NativeHost } from "./native/backen
28
28
  import { col } from "./native/describe";
29
29
  import { TSP_PREFIX, type TspHello } from "./native/encode";
30
30
  import type { DescribeContext, NativeNode, NativeScreen, NativeSurfaceProvider, NativeUiEvent } from "./native/node";
31
+ import { cropSixelTop } from "./sixel";
31
32
  import { STDOUT_BACKLOG_CLEAR_BYTES, setAltScreenActive, type Terminal } from "./terminal";
32
33
  import { classifyTerminalMultiplexerModule, terminalMultiplexerSessions } from "./terminal-multiplexer";
33
34
  import {
@@ -36,6 +37,7 @@ import {
36
37
  encodeKittyDeleteImage,
37
38
  encodeKittyDeletePlacement,
38
39
  encodeKittyPlacementLine,
40
+ getCellDimensions,
39
41
  getTerminalBackgroundColor,
40
42
  ImageProtocol,
41
43
  isImageProtocolForced,
@@ -1022,6 +1024,8 @@ export class TUI extends Container {
1022
1024
  #screenImagePlacements = new Map<number, LiveImagePlacement>();
1023
1025
  #altImagePlacements = new Map<number, LiveImagePlacement>();
1024
1026
  #nextImagePlacementId = 1;
1027
+ /** Last top-clipped SIXEL crop; only the block straddling row 0 is ever cropped. */
1028
+ #sixelCrop: { source: string; pixels: number; maxHeightPixels: number; output: string } | undefined;
1025
1029
  #ghosttyInitialImageDelayDone = false;
1026
1030
  #ghosttyInitialImageDelayTimer: RenderTimer | undefined;
1027
1031
  #ghosttyImageReadyAtMs = 0;
@@ -3386,10 +3390,11 @@ export class TUI extends Container {
3386
3390
  }
3387
3391
 
3388
3392
  /**
3389
- * Clip a Kitty direct placement and record its live owner; history
3390
- * placements have no mutable owner. Other direct placements (SIXEL,
3391
- * iTerm2) have no delete command and composite over whatever pixels they
3392
- * land on, so the rows they paint above `screenRow` are erased first.
3393
+ * Clip direct placements to the visible source slice: Kitty tracks a live
3394
+ * placement owner, while SIXEL crops the encoded raster (iTerm2 images are
3395
+ * not cropped). SIXEL and iTerm2 have no delete command and composite over
3396
+ * existing pixels, so erase the visible rows they paint above `screenRow`
3397
+ * before repainting.
3393
3398
  */
3394
3399
  #imageLineSequence(
3395
3400
  line: string,
@@ -3400,7 +3405,16 @@ export class TUI extends Container {
3400
3405
  if (screenRow < 0) return line;
3401
3406
  const parsed = parseKittyDirectPlacementLine(line);
3402
3407
  if (!parsed) {
3403
- const lift = Math.min(directPlacementLift(line), screenRow);
3408
+ const requestedLift = directPlacementLift(line);
3409
+ const lift = Math.min(requestedLift, screenRow);
3410
+ if (requestedLift > lift) {
3411
+ const cellHeight = getCellDimensions().heightPx;
3412
+ const cropPixels = Math.round((requestedLift - lift) * cellHeight);
3413
+ const maxHeightPixels = Math.floor((screenRow + 1) * cellHeight);
3414
+ line = line.replace(/\x1bP[0-9;]*q[\s\S]*?\x1b\\/g, sequence =>
3415
+ this.#cropSixel(sequence, cropPixels, maxHeightPixels),
3416
+ );
3417
+ }
3404
3418
  return lift > 0 ? `\x1b[${lift}A${`${ERASE_LINE}\x1b[B`.repeat(lift)}${line}` : line;
3405
3419
  }
3406
3420
  const previous = placements?.get(placementRow);
@@ -3420,6 +3434,32 @@ export class TUI extends Container {
3420
3434
  );
3421
3435
  }
3422
3436
 
3437
+ /**
3438
+ * Crop a SIXEL raster whose top rows lie above the viewport. A raster the
3439
+ * cropper cannot parse is dropped: painting it whole would overwrite the
3440
+ * rows below the visible slice.
3441
+ */
3442
+ #cropSixel(source: string, pixels: number, maxHeightPixels: number): string {
3443
+ const cached = this.#sixelCrop;
3444
+ if (
3445
+ cached !== undefined &&
3446
+ cached.pixels === pixels &&
3447
+ cached.maxHeightPixels === maxHeightPixels &&
3448
+ cached.source === source
3449
+ ) {
3450
+ return cached.output;
3451
+ }
3452
+ let output: string;
3453
+ try {
3454
+ output = cropSixelTop(source, pixels, maxHeightPixels);
3455
+ } catch (err) {
3456
+ logger.warn("Dropping top-clipped SIXEL image that cannot be cropped", { err });
3457
+ output = "";
3458
+ }
3459
+ this.#sixelCrop = { source, pixels, maxHeightPixels, output };
3460
+ return output;
3461
+ }
3462
+
3423
3463
  #terminalLine(line: PreparedLine): string {
3424
3464
  if (line.hasOsc8) return line.terminalContent + LINE_TERMINATOR;
3425
3465
  return line.terminalContent.endsWith(SEGMENT_RESET) ? line.terminalContent : line.terminalContent + SEGMENT_RESET;