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

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 (46) hide show
  1. package/CHANGELOG.md +17 -0
  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/sixel.d.ts +28 -0
  13. package/dist/types/status-line/host.d.ts +3 -0
  14. package/dist/types/tools/irc.d.ts +6 -0
  15. package/dist/types/tools/job-widget.d.ts +226 -0
  16. package/dist/types/tools/output-meta.d.ts +1 -1
  17. package/dist/types/tools/streaming-output.d.ts +1 -0
  18. package/dist/types/tools/task.d.ts +12 -0
  19. package/dist/types/tools/wait.d.ts +5 -0
  20. package/package.json +9 -9
  21. package/src/autocomplete.ts +12 -1
  22. package/src/chat/skill-message.ts +19 -21
  23. package/src/components/editor.ts +17 -7
  24. package/src/components/image.ts +5 -4
  25. package/src/components/sparkline.ts +89 -0
  26. package/src/index.ts +1 -0
  27. package/src/native/describe.ts +18 -1
  28. package/src/native/tone.ts +22 -0
  29. package/src/overlays/agent-hub.ts +14 -10
  30. package/src/overlays/agent-transcript-viewer.ts +2 -2
  31. package/src/overlays/approval-dialog.ts +59 -13
  32. package/src/overlays/jobs-panel.ts +10 -0
  33. package/src/overlays/model-browser.ts +3 -0
  34. package/src/prompt/attachment-chips.ts +1 -1
  35. package/src/sixel.ts +185 -0
  36. package/src/status-line/component.ts +19 -2
  37. package/src/status-line/host.ts +3 -0
  38. package/src/terminal-capabilities.ts +4 -1
  39. package/src/theme/symbols.ts +5 -3
  40. package/src/tools/irc.ts +6 -0
  41. package/src/tools/job-widget.ts +1036 -0
  42. package/src/tools/output-meta.ts +1 -1
  43. package/src/tools/streaming-output.ts +11 -9
  44. package/src/tools/task.ts +25 -1
  45. package/src/tools/wait.ts +141 -85
  46. package/src/tui.ts +45 -5
@@ -87,7 +87,7 @@ export interface OutputMeta {
87
87
  /** Capture failure of this output itself; aggregate reports keep source failures on their entries. */
88
88
  artifactError?: OutputArtifactError;
89
89
  source?: SourceMeta;
90
- /** The output is a bounded page of session artifact storage its `source` re-reads with line selectors; spill-to-artifact skips it. */
90
+ /** The output is a bounded page of a file or session artifact its `source` re-reads with line selectors; spill-to-artifact skips it. */
91
91
  pagedSource?: true;
92
92
  diagnostics?: DiagnosticMeta;
93
93
  limits?: LimitsMeta;
@@ -1432,6 +1432,7 @@ export class OutputSink {
1432
1432
  * Replace the in-memory buffer with the given text. Used when an upstream
1433
1433
  * minimizer rewrites the captured output after the raw bytes have already
1434
1434
  * been streamed.
1435
+ * The replacement is held to the per-line column cap like streamed output.
1435
1436
  *
1436
1437
  * After this call the buffer is authoritative: streaming counters realign
1437
1438
  * to the replacement, the retained head window is cleared, and head
@@ -1442,23 +1443,24 @@ export class OutputSink {
1442
1443
  */
1443
1444
  replace(text: string): void {
1444
1445
  this.#clearPendingChunkTimer();
1445
- this.#buffer = text;
1446
- this.#bufferBytes = Buffer.byteLength(text, "utf-8");
1446
+ this.#currentLineBytes = 0;
1447
+ this.#columnEllipsisAdded = false;
1448
+ this.#columnDroppedBytes = 0;
1449
+ this.#columnTruncatedLines = 0;
1450
+ // The artifact keeps the replaced stream, so later line numbers no longer match it.
1451
+ this.#columnCutLinesUnmapped = true;
1452
+ const capped = this.#maxColumns > 0 ? this.#applyColumnCap(text, 0) : text;
1453
+ this.#buffer = capped;
1454
+ this.#bufferBytes = Buffer.byteLength(capped, "utf-8");
1447
1455
  this.#tailUntrimmed = false;
1448
1456
  this.#head = "";
1449
1457
  this.#headBytes = 0;
1450
1458
  this.#headLines = 0;
1451
1459
  this.#headRetentionDisabled = true;
1452
- this.#totalBytes = this.#bufferBytes;
1460
+ this.#totalBytes = Buffer.byteLength(text, "utf-8");
1453
1461
  this.#totalLines = countNewlines(text);
1454
1462
  this.#sawData = text.length > 0;
1455
1463
  this.#truncated = false;
1456
- this.#currentLineBytes = 0;
1457
- this.#columnEllipsisAdded = false;
1458
- this.#columnDroppedBytes = 0;
1459
- this.#columnTruncatedLines = 0;
1460
- // The artifact keeps the replaced stream, so later line numbers no longer match it.
1461
- this.#columnCutLinesUnmapped = true;
1462
1464
  this.#pendingChunk = "";
1463
1465
  this.#pendingCarriageReturn = false;
1464
1466
  }
package/src/tools/task.ts CHANGED
@@ -764,6 +764,15 @@ function renderAgentProgress(
764
764
 
765
765
  // Current tool (if running) or most recent completed tool
766
766
  if (progress.status === "running") {
767
+ if (progress.stage) {
768
+ lines.push(
769
+ truncateTaskRow(
770
+ `${continuePrefix}${theme.fg("muted", replaceTabs(sanitizeText(progress.stage)))}`,
771
+ maxWidth,
772
+ "",
773
+ ),
774
+ );
775
+ }
767
776
  if (progress.currentTool) {
768
777
  let toolLine = `${continuePrefix}${theme.tree.hook} ${theme.fg("muted", sanitizeText(progress.currentTool))}`;
769
778
  const toolDetail = toolCallDetail(
@@ -1873,6 +1882,9 @@ function describeProgressAgent(progress: AgentProgress, state: AgentDescribeStat
1873
1882
  age: progress.currentToolStartMs ? Math.max(0, nowMs - progress.currentToolStartMs) : undefined,
1874
1883
  }
1875
1884
  : null;
1885
+ const summary = taskSummary(progress.description || progress.assignment || progress.task);
1886
+ // The agent's own stage line (from its completion probe) rides beside the task while it runs.
1887
+ const stage = running && progress.stage ? plainText(progress.stage) : "";
1876
1888
  const retry =
1877
1889
  running && progress.retryState
1878
1890
  ? {
@@ -1887,7 +1899,7 @@ function describeProgressAgent(progress: AgentProgress, state: AgentDescribeStat
1887
1899
  {
1888
1900
  name: formatTaskId(progress.id),
1889
1901
  agent: progress.agent || undefined,
1890
- task: taskSummary(progress.description || progress.assignment || progress.task) || undefined,
1902
+ task: stage ? (summary ? [span(summary), span(` · ${stage}`, "muted")] : stage) : summary || undefined,
1891
1903
  status,
1892
1904
  model: progress.resolvedModelIdentity ?? progress.resolvedModel,
1893
1905
  thinking: thinkingToken(progress.resolvedThinkingLevel),
@@ -2383,6 +2395,18 @@ export interface AgentProgress {
2383
2395
  advisor?: boolean;
2384
2396
  /** The agent's latest self-estimate of task completion (0–100), from the periodic `task.completionProbe` side request. */
2385
2397
  completionPercent?: number;
2398
+ /**
2399
+ * The agent's current step in its own words (one line, ≤ 80 chars), from the
2400
+ * same probe reply as {@link completionPercent}. Running rows prefer it over
2401
+ * `lastIntent`; the job widget uses it as its stage line.
2402
+ */
2403
+ stage?: string;
2404
+ /**
2405
+ * What the agent says shows its progress live (one line, ≤ 200 chars), e.g.
2406
+ * "BENCH lines in bg_2 pipe:1, tok_s higher is better, target 70% of roofline";
2407
+ * from the probe reply. Feeds the job widget author's brief; never rendered.
2408
+ */
2409
+ watch?: string;
2386
2410
  /** Data extracted by registered subprocess tool handlers (keyed by tool name) */
2387
2411
  extractedToolData?: Record<string, unknown[]>;
2388
2412
  /**
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 {
@@ -326,6 +369,7 @@ function jobsRenderResult(
326
369
  .u32(spinnerFrame)
327
370
  .bool(shimmerActive)
328
371
  .bool(showModelBadge)
372
+ .str(widgetStamp(jobs))
329
373
  .digest();
330
374
  if (!shimmerActive && cached?.key === key) return cached.lines;
331
375
 
@@ -354,7 +398,8 @@ function jobsRenderResult(
354
398
  Math.max(0, rowWidth - visibleWidth(`${icon} ${typeBadge} ${durationSuffix}`)),
355
399
  Ellipsis.Unicode,
356
400
  );
357
- const rawLabelLines = (job.label || "(no label)").split(/\r?\n/);
401
+ const rowLabel = jobRowLabel(job);
402
+ const rawLabelLines = (rowLabel || "(no label)").split(/\r?\n/);
358
403
  const maxLabelLines = expanded ? LABEL_LINES_EXPANDED : LABEL_LINES_COLLAPSED;
359
404
  const visibleLabelLines = rawLabelLines
360
405
  .slice(0, maxLabelLines)
@@ -364,44 +409,63 @@ function jobsRenderResult(
364
409
  visibleLabelLines[visibleLabelLines.length - 1] = `${last} …`;
365
410
  }
366
411
  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),
378
- ),
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
412
  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
413
  const continuationWidth = Math.max(0, rowWidth - visibleWidth(" "));
401
- for (let i = distinctLabel && !inlineLabel ? 0 : 1; i < visibleLabelLines.length; i++) {
414
+ if (job.widget?.state === "live") {
415
+ const meta = compact([
416
+ job.status === "running" && job.completionPercent !== undefined
417
+ ? uiTheme.fg("accent", `${job.completionPercent}%`)
418
+ : undefined,
419
+ uiTheme.fg("dim", formatDuration(job.durationMs)),
420
+ ]).join(" ");
402
421
  lines.push(
403
- ` ${uiTheme.fg("toolOutput", truncateToWidth(visibleLabelLines[i]!, continuationWidth))}`,
422
+ ...renderJobTileLines(jobTile(job, live), rowWidth, uiTheme, {
423
+ lead: rowPrefix,
424
+ meta,
425
+ full: expanded,
426
+ }),
404
427
  );
428
+ } else {
429
+ const modelIdentity = job.resolvedModelIdentity ?? job.resolvedModel;
430
+ const modelBadge =
431
+ job.type === "task" && showModelBadge && typeof modelIdentity === "string"
432
+ ? formatFeedModelBadge(
433
+ modelIdentity,
434
+ job.resolvedThinkingLevel,
435
+ job.advisor === true,
436
+ uiTheme,
437
+ Math.min(
438
+ FEED_MODEL_BADGE_WIDTH,
439
+ Math.max(
440
+ 0,
441
+ rowWidth - visibleWidth(`${rowPrefix}${displayId}${durationSuffix}`) - 1,
442
+ ),
443
+ ),
444
+ )
445
+ : "";
446
+ const modelLead = modelBadge ? `${modelBadge} ` : "";
447
+ const headRaw = displayId;
448
+ // Running rows in a live block shimmer their label; once the block
449
+ // stops animating (sealed, or a settled snapshot — spinnerFrame
450
+ // cleared) they render static so scrollback never keeps a mid-sweep
451
+ // shimmer band.
452
+ const headLabel = live
453
+ ? shimmerEnabled()
454
+ ? shimmerText(headRaw, uiTheme)
455
+ : uiTheme.fg("accent", headRaw)
456
+ : uiTheme.fg("toolOutput", headRaw);
457
+ let row = `${rowPrefix}${modelLead}${headLabel}`;
458
+ const distinctLabel = rowLabel.trim() !== job.id;
459
+ const label = visibleLabelLines[0] ?? "";
460
+ const inlineLabel = distinctLabel && visibleWidth(`${row} ${label}${durationSuffix}`) <= rowWidth;
461
+ if (inlineLabel) row += ` ${uiTheme.fg("toolOutput", label)}`;
462
+ row += durationSuffix;
463
+ lines.push(truncateToWidth(row, rowWidth, ""));
464
+ for (let i = distinctLabel && !inlineLabel ? 0 : 1; i < visibleLabelLines.length; i++) {
465
+ lines.push(
466
+ ` ${uiTheme.fg("toolOutput", truncateToWidth(visibleLabelLines[i]!, continuationWidth))}`,
467
+ );
468
+ }
405
469
  }
406
470
  const artifactError = job.meta?.artifactError ?? job.artifactError;
407
471
  if (artifactError) {
@@ -498,7 +562,11 @@ function bodyLines(
498
562
  body: string,
499
563
  expanded: boolean,
500
564
  theme: Theme,
501
- options: { indent?: string; tone?: "dim" | "toolOutput"; collapsedLines?: number } = {},
565
+ options: {
566
+ indent?: string;
567
+ tone?: "dim" | "toolOutput";
568
+ collapsedLines?: number;
569
+ } = {},
502
570
  ): string[] {
503
571
  const indent = options.indent ?? "";
504
572
  const tone = options.tone ?? "toolOutput";
@@ -561,7 +629,12 @@ export function createIrcMessageCard(
561
629
  (width, expanded) => {
562
630
  const lines = [renderStatusLine({ iconOverride: ircGlyph(uiTheme), title, meta }, uiTheme)];
563
631
  if (body.trim()) {
564
- lines.push(...bodyLines(body, expanded, uiTheme, { indent: " ", collapsedLines: 3 }));
632
+ lines.push(
633
+ ...bodyLines(body, expanded, uiTheme, {
634
+ indent: " ",
635
+ collapsedLines: 3,
636
+ }),
637
+ );
565
638
  }
566
639
  return lines.map(line => truncateToWidth(line, width, Ellipsis.Unicode));
567
640
  },
@@ -589,47 +662,6 @@ export function createIrcMessageCard(
589
662
  return Object.assign(component, { describe: () => described });
590
663
  }
591
664
 
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
665
  function describeJobsResult(
634
666
  result: ToolRenderResult<CoordinationDetails>,
635
667
  isPartial: boolean,
@@ -658,9 +690,23 @@ function describeJobsResult(
658
690
  if (counts.completed > 0) head.push(span(` ${counts.completed} done`, "success"));
659
691
  if (counts.failed > 0) head.push(span(` ${counts.failed} failed`, "error"));
660
692
  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 };
693
+ const order: Record<JobSnapshot["status"], number> = {
694
+ running: 0,
695
+ failed: 1,
696
+ cancelled: 2,
697
+ completed: 3,
698
+ };
662
699
  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));
700
+ // One frame of tiles: each job's status, title, figure and widget together. The job's output
701
+ // and errors fill the card of the call that started it (`parksAsBackground`), so no previews.
702
+ const body: NativeNode[] = [];
703
+ if (sorted.length > 0) {
704
+ body.push(keyed(describeJobTiles(sorted.slice(0, TILES_SHOWN).map(job => jobTile(job, isPartial))), "tiles"));
705
+ }
706
+ if (sorted.length > TILES_SHOWN) {
707
+ const rest = sorted.length - TILES_SHOWN;
708
+ body.push(text([span(`+${rest} more ${rest === 1 ? "job" : "jobs"} · /jobs`, "dim")], { role: "omp.wait.more" }));
709
+ }
664
710
  for (const agent of agents) {
665
711
  const spans: TspSpan[] = [span(plainText(agent.id), "muted")];
666
712
  if (agent.activity) spans.push(span(` ${plainText(agent.activity)}`, "toolOutput"));
@@ -684,6 +730,8 @@ function describeJobsResult(
684
730
  return {
685
731
  head,
686
732
  tone: counts.failed > 0 ? "warning" : counts.running > 0 || agents.length > 0 ? "info" : "success",
733
+ // The tiles are what a wait shows: clamping the body by lines would cut them.
734
+ preview: "children",
687
735
  body,
688
736
  };
689
737
  }
@@ -729,11 +777,15 @@ export const waitToolRenderer = {
729
777
  result: ToolRenderResult<CoordinationDetails>,
730
778
  options: RenderResultOptions,
731
779
  ): NativeToolView | undefined {
732
- return waitResultMemo.get(result, [options.isPartial], () => {
780
+ return waitResultMemo.get(result, [options.isPartial, widgetStamp(result.details?.jobs)], () => {
733
781
  if (result.isError) return errorView("Wait", resultText(result));
734
782
  const details = result.details;
735
783
  if (details?.interrupted) {
736
- return { head: toolHead("Wait", "interrupted by message"), tone: "info", inline: true };
784
+ return {
785
+ head: toolHead("Wait", "interrupted by message"),
786
+ tone: "info",
787
+ inline: true,
788
+ };
737
789
  }
738
790
  const waited = details?.waited;
739
791
  if (!waited) return describeJobsResult(result, options.isPartial);
@@ -741,7 +793,11 @@ export const waitToolRenderer = {
741
793
  });
742
794
  },
743
795
  renderResult(
744
- result: { content: Array<{ type: string; text?: string }>; details?: CoordinationDetails; isError?: boolean },
796
+ result: {
797
+ content: Array<{ type: string; text?: string }>;
798
+ details?: CoordinationDetails;
799
+ isError?: boolean;
800
+ },
745
801
  options: RenderResultOptions,
746
802
  uiTheme: Theme,
747
803
  ): 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;