@oh-my-pi/pi-tui 18.12.2 → 18.12.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/CHANGELOG.md +44 -48
  2. package/dist/types/autocomplete.d.ts +6 -0
  3. package/dist/types/chat/skill-message.d.ts +3 -4
  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/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.ts +12 -1
  34. package/src/chat/skill-message.ts +19 -21
  35. package/src/chat/tool-execution.ts +35 -0
  36. package/src/chrome/transcript-container.ts +85 -12
  37. package/src/components/editor.ts +17 -7
  38. package/src/components/image.ts +5 -4
  39. package/src/components/markdown.ts +11 -3
  40. package/src/components/sparkline.ts +89 -0
  41. package/src/index.ts +1 -0
  42. package/src/native/backend.ts +25 -5
  43. package/src/native/describe.ts +18 -1
  44. package/src/native/tone.ts +22 -0
  45. package/src/overlays/agent-hub.ts +14 -10
  46. package/src/overlays/agent-transcript-viewer.ts +2 -2
  47. package/src/overlays/approval-dialog.ts +59 -13
  48. package/src/overlays/jobs-panel.ts +24 -2
  49. package/src/overlays/model-browser.ts +3 -0
  50. package/src/prompt/attachment-chips.ts +1 -1
  51. package/src/render/output-block.ts +25 -12
  52. package/src/render/tool-card.ts +6 -0
  53. package/src/sixel.ts +185 -0
  54. package/src/status-line/component.ts +19 -2
  55. package/src/status-line/host.ts +3 -0
  56. package/src/terminal-capabilities.ts +43 -3
  57. package/src/theme/symbols.ts +5 -3
  58. package/src/tools/bash.ts +25 -9
  59. package/src/tools/default-renderer.ts +54 -17
  60. package/src/tools/eval.ts +32 -14
  61. package/src/tools/find.ts +4 -5
  62. package/src/tools/glob.ts +4 -6
  63. package/src/tools/grep.ts +4 -6
  64. package/src/tools/irc.ts +6 -0
  65. package/src/tools/job-widget.ts +1036 -0
  66. package/src/tools/json-tree.ts +8 -0
  67. package/src/tools/line-ranges.ts +23 -13
  68. package/src/tools/mcp.ts +44 -1
  69. package/src/tools/output-meta.ts +1 -1
  70. package/src/tools/read.ts +64 -21
  71. package/src/tools/renderer.ts +8 -2
  72. package/src/tools/result-card.ts +1 -2
  73. package/src/tools/streaming-output.ts +11 -9
  74. package/src/tools/task.ts +25 -1
  75. package/src/tools/wait.ts +288 -147
  76. 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 "@oh-my-pi/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. */
@@ -194,6 +199,40 @@ function flattenStructuredPreview(text: string): string {
194
199
  return text.slice(0, PREVIEW_LINES_EXPANDED * PREVIEW_LINE_WIDTH * 2).replace(/\s+/g, " ");
195
200
  }
196
201
 
202
+ /** The row label: a running task's own stage line when it reports one, else the job's label. */
203
+ function jobRowLabel(job: JobSnapshot): string {
204
+ return job.type === "task" && job.status === "running" && job.stage?.trim() ? job.stage : job.label;
205
+ }
206
+
207
+ /** A job as its tile; `live` while the card's clocks run. */
208
+ function jobTile(job: JobSnapshot, live: boolean): JobTile {
209
+ return {
210
+ id: job.id,
211
+ type: job.type,
212
+ status: job.status,
213
+ label: job.label,
214
+ durationMs: job.durationMs,
215
+ live: live && job.status === "running",
216
+ ...(job.widget ? { widget: job.widget } : {}),
217
+ ...(job.status === "running" && job.completionPercent !== undefined
218
+ ? { completionPercent: job.completionPercent }
219
+ : {}),
220
+ ...(job.stage ? { stage: job.stage } : {}),
221
+ ...(job.exitCode === undefined ? {} : { exitCode: job.exitCode }),
222
+ };
223
+ }
224
+
225
+ /** Identity of the widgets a job list draws: changes whenever any widget's data or state does. */
226
+ function widgetStamp(jobs: readonly JobSnapshot[] | undefined): string {
227
+ if (!jobs) return "";
228
+ let stamp = "";
229
+ for (const job of jobs) if (job.widget) stamp += `${job.id}:${job.widget.updatedAt}:${job.widget.state};`;
230
+ return stamp;
231
+ }
232
+
233
+ /** Tiles a native wait card shows before it counts the rest. */
234
+ const TILES_SHOWN = 9;
235
+
197
236
  /** Pending wait frame. */
198
237
  function waitRenderCall(_args: object, _options: RenderResultOptions, uiTheme: Theme): Component {
199
238
  return new Text(renderStatusLine({ icon: "pending", title: "Wait" }, uiTheme), 0, 0);
@@ -201,7 +240,11 @@ function waitRenderCall(_args: object, _options: RenderResultOptions, uiTheme: T
201
240
 
202
241
  /** Result frame for wait snapshots and the agents roster. */
203
242
  function jobsRenderResult(
204
- result: { content: Array<{ type: string; text?: string }>; details?: CoordinationDetails; isError?: boolean },
243
+ result: {
244
+ content: Array<{ type: string; text?: string }>;
245
+ details?: CoordinationDetails;
246
+ isError?: boolean;
247
+ },
205
248
  options: RenderResultOptions,
206
249
  uiTheme: Theme,
207
250
  ): Component {
@@ -308,6 +351,87 @@ function jobsRenderResult(
308
351
  previewRows.set(job, { expanded, width: continuationWidth, lines });
309
352
  return lines;
310
353
  };
354
+ // Collapsed card: one line per settled job, or the bare "waiting on N jobs"
355
+ // header while nothing has settled. Repeated polls then read as a short
356
+ // completion log instead of a re-printed (and shrinking) job tree; Ctrl+O
357
+ // restores the full tree with output previews. A running job's live widget
358
+ // still draws its tile: that chart is the job's progress, not a copy of it.
359
+ const settledJobs = sortedJobs.filter(job => job.status !== "running");
360
+ const widgetJobs = sortedJobs.filter(job => job.status === "running" && job.widget?.state === "live");
361
+ const compactJobLines = (width: number, live: boolean): string[] => {
362
+ const lines: string[] = [];
363
+ const indent = " ".repeat(visibleWidth(formatStatusIcon("done", uiTheme)) + 1);
364
+ // Failures sort first, so the cap never hides one behind successes;
365
+ // Ctrl+O lists the rest.
366
+ for (const job of settledJobs.slice(0, PREVIEW_LIMITS.COLLAPSED_ITEMS)) {
367
+ const icon = formatStatusIcon(statusToIcon(job.status), uiTheme);
368
+ const word = job.status === "completed" ? "done" : job.status;
369
+ const exit =
370
+ job.exitCode !== undefined && job.exitCode !== 0
371
+ ? `${uiTheme.sep.dot}${uiTheme.fg("error", `exit ${job.exitCode}`)}`
372
+ : "";
373
+ // The outcome (status, exit, duration) is reserved first; a long id
374
+ // gives way so a failure is never cut off the row.
375
+ const outcome = ` ${uiTheme.fg(statusToColor(job.status), word)}${exit}${uiTheme.sep.dot}${uiTheme.fg("dim", formatDuration(job.durationMs))}`;
376
+ const id = truncateToWidth(
377
+ replaceTabs(job.id).replace(/\s+/g, " "),
378
+ Math.max(1, width - visibleWidth(`${icon} `) - visibleWidth(outcome)),
379
+ Ellipsis.Unicode,
380
+ );
381
+ let line = `${icon} ${uiTheme.fg("accent", id)}${outcome}`;
382
+ const label = replaceTabs(jobRowLabel(job).split(/\r?\n/)[0] ?? "").trim();
383
+ const labelBudget = width - visibleWidth(line) - 2;
384
+ if (label && label !== job.id && labelBudget >= 8) {
385
+ line += ` ${uiTheme.fg("muted", truncateToWidth(label, labelBudget, Ellipsis.Unicode))}`;
386
+ }
387
+ lines.push(line);
388
+ // A failure says why on the collapsed row; the rest of its output
389
+ // stays behind Ctrl+O.
390
+ const reason =
391
+ job.status === "failed"
392
+ ? stripOutputNotice(job.errorText ?? "", job.meta)
393
+ .split(/\r?\n/)
394
+ .find(part => part.trim())
395
+ ?.trim()
396
+ : undefined;
397
+ if (reason) {
398
+ lines.push(
399
+ `${indent}${uiTheme.fg("error", truncateToWidth(replaceTabs(reason), Math.max(1, width - indent.length), Ellipsis.Unicode))}`,
400
+ );
401
+ }
402
+ // The root warning is suppressed when a row owns the failure, so the
403
+ // compact row carries it too — otherwise it only shows after Ctrl+O.
404
+ const artifactError = job.meta?.artifactError ?? job.artifactError;
405
+ if (artifactError) {
406
+ lines.push(uiTheme.fg("warning", truncateToWidth(formatArtifactErrorNotice(artifactError), width)));
407
+ }
408
+ }
409
+ const hiddenSettled = settledJobs.length - PREVIEW_LIMITS.COLLAPSED_ITEMS;
410
+ if (hiddenSettled > 0) lines.push(`${indent}${uiTheme.fg("dim", `… ${hiddenSettled} more settled`)}`);
411
+ const shownWidgetJobs = widgetJobs.slice(0, PREVIEW_LIMITS.COLLAPSED_ITEMS);
412
+ for (const job of shownWidgetJobs) {
413
+ const typeBadge = formatBadge(job.type, statusToColor(job.status), uiTheme);
414
+ const icon = formatStatusIcon("running", uiTheme, live ? options.spinnerFrame : undefined);
415
+ const meta = compact([
416
+ job.completionPercent !== undefined ? uiTheme.fg("accent", `${job.completionPercent}%`) : undefined,
417
+ uiTheme.fg("dim", formatDuration(job.durationMs)),
418
+ ]).join(" ");
419
+ lines.push(
420
+ ...renderJobTileLines(jobTile(job, live), width, uiTheme, {
421
+ lead: `${icon} ${typeBadge} `,
422
+ meta,
423
+ full: false,
424
+ }),
425
+ );
426
+ }
427
+ // Running jobs without a drawn tile (no widget, or past the cap) collapse
428
+ // into one count; the header already carries it while nothing settled.
429
+ const quietRunning = counts.running - shownWidgetJobs.length;
430
+ if (quietRunning > 0 && (settledJobs.length > 0 || shownWidgetJobs.length < widgetJobs.length)) {
431
+ lines.push(`${indent}${uiTheme.fg("dim", `${quietRunning} still running`)}`);
432
+ }
433
+ return lines;
434
+ };
311
435
  return {
312
436
  render(width: number): readonly string[] {
313
437
  const expanded = options.expanded;
@@ -326,96 +450,125 @@ function jobsRenderResult(
326
450
  .u32(spinnerFrame)
327
451
  .bool(shimmerActive)
328
452
  .bool(showModelBadge)
453
+ .str(widgetStamp(jobs))
329
454
  .digest();
330
455
  if (!shimmerActive && cached?.key === key) return cached.lines;
331
456
 
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),
457
+ const compactView = !expanded && jobs.length > 0;
458
+ const itemLines = compactView
459
+ ? compactJobLines(width, counts.running > 0 && options.spinnerFrame !== undefined)
460
+ : renderTreeList<JobSnapshot>(
461
+ {
462
+ items: sortedJobs,
463
+ expanded,
464
+ maxCollapsed: PREVIEW_LIMITS.COLLAPSED_ITEMS,
465
+ itemType: "job",
466
+ renderItem: (job, context) => {
467
+ const rowWidth = Math.max(0, width - (context.prefixWidth ?? 0));
468
+ const lines: string[] = [];
469
+ const icon = `${formatStatusIcon(
470
+ statusToIcon(job.status),
471
+ uiTheme,
472
+ job.status === "running" ? options.spinnerFrame : undefined,
473
+ )}${job.exitCode === undefined ? "" : `${uiTheme.sep.dot}${uiTheme.fg(job.exitCode === 0 ? "muted" : "error", `exit ${job.exitCode}`)}`}`;
474
+ const typeBadge = formatBadge(job.type, statusToColor(job.status), uiTheme);
475
+ const completion =
476
+ job.status === "running" && job.completionPercent !== undefined
477
+ ? `${uiTheme.sep.dot}${uiTheme.fg("accent", `${job.completionPercent}%`)}`
478
+ : "";
479
+ const durationSuffix = `${completion}${uiTheme.sep.dot}${uiTheme.fg("dim", formatDuration(job.durationMs))}`;
480
+ const displayId = truncateToWidth(
481
+ replaceTabs(job.id).replace(/\s+/g, " "),
482
+ Math.max(0, rowWidth - visibleWidth(`${icon} ${typeBadge} ${durationSuffix}`)),
483
+ Ellipsis.Unicode,
484
+ );
485
+ const rowLabel = jobRowLabel(job);
486
+ const rawLabelLines = (rowLabel || "(no label)").split(/\r?\n/);
487
+ const maxLabelLines = expanded ? LABEL_LINES_EXPANDED : LABEL_LINES_COLLAPSED;
488
+ const visibleLabelLines = rawLabelLines
489
+ .slice(0, maxLabelLines)
490
+ .map(l => truncateToWidth(replaceTabs(l), LABEL_MAX_WIDTH, Ellipsis.Unicode));
491
+ if (rawLabelLines.length > maxLabelLines && visibleLabelLines.length > 0) {
492
+ const last = visibleLabelLines[visibleLabelLines.length - 1]!;
493
+ visibleLabelLines[visibleLabelLines.length - 1] = `${last} …`;
494
+ }
495
+ const rowPrefix = `${icon} ${typeBadge} `;
496
+ const live = job.status === "running" && options.spinnerFrame !== undefined;
497
+ const continuationWidth = Math.max(0, rowWidth - visibleWidth(" "));
498
+ if (job.widget?.state === "live") {
499
+ const meta = compact([
500
+ job.status === "running" && job.completionPercent !== undefined
501
+ ? uiTheme.fg("accent", `${job.completionPercent}%`)
502
+ : undefined,
503
+ uiTheme.fg("dim", formatDuration(job.durationMs)),
504
+ ]).join(" ");
505
+ lines.push(
506
+ ...renderJobTileLines(jobTile(job, live), rowWidth, uiTheme, {
507
+ lead: rowPrefix,
508
+ meta,
509
+ full: expanded,
510
+ }),
511
+ );
512
+ } else {
513
+ const modelIdentity = job.resolvedModelIdentity ?? job.resolvedModel;
514
+ const modelBadge =
515
+ job.type === "task" && showModelBadge && typeof modelIdentity === "string"
516
+ ? formatFeedModelBadge(
517
+ modelIdentity,
518
+ job.resolvedThinkingLevel,
519
+ job.advisor === true,
520
+ uiTheme,
521
+ Math.min(
522
+ FEED_MODEL_BADGE_WIDTH,
523
+ Math.max(
524
+ 0,
525
+ rowWidth - visibleWidth(`${rowPrefix}${displayId}${durationSuffix}`) - 1,
526
+ ),
527
+ ),
528
+ )
529
+ : "";
530
+ const modelLead = modelBadge ? `${modelBadge} ` : "";
531
+ const headRaw = displayId;
532
+ // Running rows in a live block shimmer their label; once the block
533
+ // stops animating (sealed, or a settled snapshot — spinnerFrame
534
+ // cleared) they render static so scrollback never keeps a mid-sweep
535
+ // shimmer band.
536
+ const headLabel = live
537
+ ? shimmerEnabled()
538
+ ? shimmerText(headRaw, uiTheme)
539
+ : uiTheme.fg("accent", headRaw)
540
+ : uiTheme.fg("toolOutput", headRaw);
541
+ let row = `${rowPrefix}${modelLead}${headLabel}`;
542
+ const distinctLabel = rowLabel.trim() !== job.id;
543
+ const label = visibleLabelLines[0] ?? "";
544
+ const inlineLabel =
545
+ distinctLabel && visibleWidth(`${row} ${label}${durationSuffix}`) <= rowWidth;
546
+ if (inlineLabel) row += ` ${uiTheme.fg("toolOutput", label)}`;
547
+ row += durationSuffix;
548
+ lines.push(truncateToWidth(row, rowWidth, ""));
549
+ for (let i = distinctLabel && !inlineLabel ? 0 : 1; i < visibleLabelLines.length; i++) {
550
+ lines.push(
551
+ ` ${uiTheme.fg("toolOutput", truncateToWidth(visibleLabelLines[i]!, continuationWidth))}`,
552
+ );
553
+ }
554
+ }
555
+ const artifactError = job.meta?.artifactError ?? job.artifactError;
556
+ if (artifactError) {
557
+ lines.push(
558
+ uiTheme.fg(
559
+ "warning",
560
+ truncateToWidth(formatArtifactErrorNotice(artifactError), rowWidth),
378
561
  ),
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
- );
562
+ );
563
+ }
564
+
565
+ for (const previewLine of jobPreviewRows(job, expanded, continuationWidth))
566
+ lines.push(previewLine);
567
+ return lines;
568
+ },
569
+ },
570
+ uiTheme,
571
+ );
419
572
 
420
573
  // Agents run outside job control; render them as their own tree so
421
574
  // they never skew the job counts or the "waiting on N jobs" title.
@@ -458,7 +611,7 @@ function jobsRenderResult(
458
611
  uiTheme,
459
612
  );
460
613
 
461
- const all = [header];
614
+ const all = compactView && settledJobs.length > 0 ? [] : [header];
462
615
  if (aggregateArtifactError) {
463
616
  all.push(uiTheme.fg("warning", formatArtifactErrorNotice(aggregateArtifactError)));
464
617
  }
@@ -498,7 +651,11 @@ function bodyLines(
498
651
  body: string,
499
652
  expanded: boolean,
500
653
  theme: Theme,
501
- options: { indent?: string; tone?: "dim" | "toolOutput"; collapsedLines?: number } = {},
654
+ options: {
655
+ indent?: string;
656
+ tone?: "dim" | "toolOutput";
657
+ collapsedLines?: number;
658
+ } = {},
502
659
  ): string[] {
503
660
  const indent = options.indent ?? "";
504
661
  const tone = options.tone ?? "toolOutput";
@@ -556,17 +713,18 @@ export function createIrcMessageCard(
556
713
  if (card.replyTo) meta.push("reply");
557
714
  const age = messageAge(card.timestamp);
558
715
  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
- );
716
+ const component = createCachedComponent(getExpanded, (width, expanded) => {
717
+ const lines = [renderStatusLine({ iconOverride: ircGlyph(uiTheme), title, meta }, uiTheme)];
718
+ if (body.trim()) {
719
+ lines.push(
720
+ ...bodyLines(body, expanded, uiTheme, {
721
+ indent: " ",
722
+ collapsedLines: 3,
723
+ }),
724
+ );
725
+ }
726
+ return lines.map(line => truncateToWidth(line, width, Ellipsis.Unicode));
727
+ });
570
728
  // Terminal-local collapse replaces `getExpanded`; the node never changes after creation.
571
729
  const described = cardNode(
572
730
  {
@@ -589,47 +747,6 @@ export function createIrcMessageCard(
589
747
  return Object.assign(component, { describe: () => described });
590
748
  }
591
749
 
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: "omp.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
750
  function describeJobsResult(
634
751
  result: ToolRenderResult<CoordinationDetails>,
635
752
  isPartial: boolean,
@@ -658,9 +775,23 @@ function describeJobsResult(
658
775
  if (counts.completed > 0) head.push(span(` ${counts.completed} done`, "success"));
659
776
  if (counts.failed > 0) head.push(span(` ${counts.failed} failed`, "error"));
660
777
  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 };
778
+ const order: Record<JobSnapshot["status"], number> = {
779
+ running: 0,
780
+ failed: 1,
781
+ cancelled: 2,
782
+ completed: 3,
783
+ };
662
784
  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));
785
+ // One frame of tiles: each job's status, title, figure and widget together. The job's output
786
+ // and errors fill the card of the call that started it (`parksAsBackground`), so no previews.
787
+ const body: NativeNode[] = [];
788
+ if (sorted.length > 0) {
789
+ body.push(keyed(describeJobTiles(sorted.slice(0, TILES_SHOWN).map(job => jobTile(job, isPartial))), "tiles"));
790
+ }
791
+ if (sorted.length > TILES_SHOWN) {
792
+ const rest = sorted.length - TILES_SHOWN;
793
+ body.push(text([span(`+${rest} more ${rest === 1 ? "job" : "jobs"} · /jobs`, "dim")], { role: "omp.wait.more" }));
794
+ }
664
795
  for (const agent of agents) {
665
796
  const spans: TspSpan[] = [span(plainText(agent.id), "muted")];
666
797
  if (agent.activity) spans.push(span(` ${plainText(agent.activity)}`, "toolOutput"));
@@ -684,6 +815,8 @@ function describeJobsResult(
684
815
  return {
685
816
  head,
686
817
  tone: counts.failed > 0 ? "warning" : counts.running > 0 || agents.length > 0 ? "info" : "success",
818
+ // The tiles are what a wait shows: clamping the body by lines would cut them.
819
+ preview: "children",
687
820
  body,
688
821
  };
689
822
  }
@@ -729,11 +862,15 @@ export const waitToolRenderer = {
729
862
  result: ToolRenderResult<CoordinationDetails>,
730
863
  options: RenderResultOptions,
731
864
  ): NativeToolView | undefined {
732
- return waitResultMemo.get(result, [options.isPartial], () => {
865
+ return waitResultMemo.get(result, [options.isPartial, widgetStamp(result.details?.jobs)], () => {
733
866
  if (result.isError) return errorView("Wait", resultText(result));
734
867
  const details = result.details;
735
868
  if (details?.interrupted) {
736
- return { head: toolHead("Wait", "interrupted by message"), tone: "info", inline: true };
869
+ return {
870
+ head: toolHead("Wait", "interrupted by message"),
871
+ tone: "info",
872
+ inline: true,
873
+ };
737
874
  }
738
875
  const waited = details?.waited;
739
876
  if (!waited) return describeJobsResult(result, options.isPartial);
@@ -741,7 +878,11 @@ export const waitToolRenderer = {
741
878
  });
742
879
  },
743
880
  renderResult(
744
- result: { content: Array<{ type: string; text?: string }>; details?: CoordinationDetails; isError?: boolean },
881
+ result: {
882
+ content: Array<{ type: string; text?: string }>;
883
+ details?: CoordinationDetails;
884
+ isError?: boolean;
885
+ },
745
886
  options: RenderResultOptions,
746
887
  uiTheme: Theme,
747
888
  ): 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,
@@ -1004,6 +1006,8 @@ export class TUI extends Container {
1004
1006
  #screenImagePlacements = new Map<number, LiveImagePlacement>();
1005
1007
  #altImagePlacements = new Map<number, LiveImagePlacement>();
1006
1008
  #nextImagePlacementId = 1;
1009
+ /** Last top-clipped SIXEL crop; only the block straddling row 0 is ever cropped. */
1010
+ #sixelCrop: { source: string; pixels: number; maxHeightPixels: number; output: string } | undefined;
1007
1011
  #ghosttyInitialImageDelayDone = false;
1008
1012
  #ghosttyInitialImageDelayTimer: RenderTimer | undefined;
1009
1013
  #ghosttyImageReadyAtMs = 0;
@@ -3287,10 +3291,11 @@ export class TUI extends Container {
3287
3291
  }
3288
3292
 
3289
3293
  /**
3290
- * Clip a Kitty direct placement and record its live owner; history
3291
- * placements have no mutable owner. Other direct placements (SIXEL,
3292
- * iTerm2) have no delete command and composite over whatever pixels they
3293
- * land on, so the rows they paint above `screenRow` are erased first.
3294
+ * Clip direct placements to the visible source slice: Kitty tracks a live
3295
+ * placement owner, while SIXEL crops the encoded raster (iTerm2 images are
3296
+ * not cropped). SIXEL and iTerm2 have no delete command and composite over
3297
+ * existing pixels, so erase the visible rows they paint above `screenRow`
3298
+ * before repainting.
3294
3299
  */
3295
3300
  #imageLineSequence(
3296
3301
  line: string,
@@ -3301,7 +3306,16 @@ export class TUI extends Container {
3301
3306
  if (screenRow < 0) return line;
3302
3307
  const parsed = parseKittyDirectPlacementLine(line);
3303
3308
  if (!parsed) {
3304
- const lift = Math.min(directPlacementLift(line), screenRow);
3309
+ const requestedLift = directPlacementLift(line);
3310
+ const lift = Math.min(requestedLift, screenRow);
3311
+ if (requestedLift > lift) {
3312
+ const cellHeight = getCellDimensions().heightPx;
3313
+ const cropPixels = Math.round((requestedLift - lift) * cellHeight);
3314
+ const maxHeightPixels = Math.floor((screenRow + 1) * cellHeight);
3315
+ line = line.replace(/\x1bP[0-9;]*q[\s\S]*?\x1b\\/g, sequence =>
3316
+ this.#cropSixel(sequence, cropPixels, maxHeightPixels),
3317
+ );
3318
+ }
3305
3319
  return lift > 0 ? `\x1b[${lift}A${`${ERASE_LINE}\x1b[B`.repeat(lift)}${line}` : line;
3306
3320
  }
3307
3321
  const previous = placements?.get(placementRow);
@@ -3321,6 +3335,32 @@ export class TUI extends Container {
3321
3335
  );
3322
3336
  }
3323
3337
 
3338
+ /**
3339
+ * Crop a SIXEL raster whose top rows lie above the viewport. A raster the
3340
+ * cropper cannot parse is dropped: painting it whole would overwrite the
3341
+ * rows below the visible slice.
3342
+ */
3343
+ #cropSixel(source: string, pixels: number, maxHeightPixels: number): string {
3344
+ const cached = this.#sixelCrop;
3345
+ if (
3346
+ cached !== undefined &&
3347
+ cached.pixels === pixels &&
3348
+ cached.maxHeightPixels === maxHeightPixels &&
3349
+ cached.source === source
3350
+ ) {
3351
+ return cached.output;
3352
+ }
3353
+ let output: string;
3354
+ try {
3355
+ output = cropSixelTop(source, pixels, maxHeightPixels);
3356
+ } catch (err) {
3357
+ logger.warn("Dropping top-clipped SIXEL image that cannot be cropped", { err });
3358
+ output = "";
3359
+ }
3360
+ this.#sixelCrop = { source, pixels, maxHeightPixels, output };
3361
+ return output;
3362
+ }
3363
+
3324
3364
  #terminalLine(line: PreparedLine): string {
3325
3365
  if (line.hasOsc8) return line.terminalContent + LINE_TERMINATOR;
3326
3366
  return line.terminalContent.endsWith(SEGMENT_RESET) ? line.terminalContent : line.terminalContent + SEGMENT_RESET;