@oh-my-pi/pi-tui 18.8.6 → 18.8.7

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 (69) hide show
  1. package/CHANGELOG.md +18 -16
  2. package/README.md +7 -5
  3. package/dist/types/chat/assistant-message.d.ts +1 -0
  4. package/dist/types/chat/compaction-summary-message.d.ts +1 -0
  5. package/dist/types/chat/user-message.d.ts +2 -0
  6. package/dist/types/components/box.d.ts +1 -0
  7. package/dist/types/components/disclosure.d.ts +1 -0
  8. package/dist/types/components/image.d.ts +1 -0
  9. package/dist/types/components/key-value-list.d.ts +1 -0
  10. package/dist/types/components/layout/row.d.ts +1 -0
  11. package/dist/types/components/layout/stack.d.ts +1 -0
  12. package/dist/types/components/markdown.d.ts +1 -0
  13. package/dist/types/components/scroll-view.d.ts +1 -0
  14. package/dist/types/components/section.d.ts +1 -0
  15. package/dist/types/components/table.d.ts +1 -0
  16. package/dist/types/components/text.d.ts +1 -0
  17. package/dist/types/components/truncated-text.d.ts +1 -0
  18. package/dist/types/native/blobs.d.ts +3 -1
  19. package/dist/types/overlays/session-selector.d.ts +1 -1
  20. package/dist/types/render/output-pane.d.ts +1 -0
  21. package/dist/types/render/render-utils.d.ts +1 -0
  22. package/dist/types/render/tool-card.d.ts +6 -0
  23. package/dist/types/render/width-aware-text.d.ts +2 -0
  24. package/dist/types/terminal.d.ts +10 -2
  25. package/dist/types/tools/write.d.ts +1 -1
  26. package/dist/types/tools/yield.d.ts +22 -0
  27. package/dist/types/tui.d.ts +11 -0
  28. package/package.json +9 -9
  29. package/src/chat/advisor-message.ts +11 -0
  30. package/src/chat/assistant-message.ts +6 -0
  31. package/src/chat/compaction-summary-message.ts +8 -0
  32. package/src/chat/read-tool-group.ts +6 -4
  33. package/src/chat/skill-message.ts +6 -0
  34. package/src/chat/table-chart.ts +17 -20
  35. package/src/chat/tool-execution.ts +18 -0
  36. package/src/chat/user-message.ts +14 -0
  37. package/src/chrome/transcript-container.ts +32 -1
  38. package/src/components/box.ts +5 -0
  39. package/src/components/disclosure.ts +8 -0
  40. package/src/components/image.ts +7 -5
  41. package/src/components/key-value-list.ts +4 -0
  42. package/src/components/layout/row.ts +7 -0
  43. package/src/components/layout/stack.ts +7 -0
  44. package/src/components/markdown.ts +8 -0
  45. package/src/components/scroll-view.ts +5 -0
  46. package/src/components/section.ts +5 -0
  47. package/src/components/table.ts +4 -0
  48. package/src/components/text.ts +7 -0
  49. package/src/components/truncated-text.ts +5 -0
  50. package/src/native/backend.ts +16 -15
  51. package/src/native/blobs.ts +43 -10
  52. package/src/native/reconcile.ts +4 -0
  53. package/src/overlays/session-selector.ts +29 -19
  54. package/src/overlays/snapcompact-shape-preview.ts +2 -3
  55. package/src/prompt/attachment-chips.ts +13 -10
  56. package/src/render/output-pane.ts +6 -0
  57. package/src/render/render-utils.ts +11 -0
  58. package/src/render/tool-card.ts +15 -0
  59. package/src/render/width-aware-text.ts +5 -0
  60. package/src/terminal.ts +24 -3
  61. package/src/tools/bash.ts +10 -11
  62. package/src/tools/edit.ts +142 -120
  63. package/src/tools/fetch.ts +5 -6
  64. package/src/tools/index.ts +2 -0
  65. package/src/tools/mcp.ts +15 -2
  66. package/src/tools/read.ts +7 -5
  67. package/src/tools/write.ts +70 -56
  68. package/src/tools/yield.ts +225 -0
  69. package/src/tui.ts +25 -0
@@ -1128,6 +1128,9 @@ export function createCachedComponent(
1128
1128
  invalidate() {
1129
1129
  cached = undefined;
1130
1130
  },
1131
+ releaseRenderCaches() {
1132
+ cached = undefined;
1133
+ },
1131
1134
  };
1132
1135
  }
1133
1136
 
@@ -1160,6 +1163,14 @@ export function invalidateRenderedStringCache(cache: RenderedStringCache): void
1160
1163
  cache.theme = null;
1161
1164
  }
1162
1165
 
1166
+ export function releaseRenderedStringCache(cache: RenderedStringCache): void {
1167
+ cache.theme = null;
1168
+ cache.expanded = false;
1169
+ cache.salt = "";
1170
+ cache.content = "";
1171
+ cache.value = "";
1172
+ }
1173
+
1163
1174
  /** Reuse a rendered string while its theme, expansion, content, and salt match. */
1164
1175
  export function cachedRenderedString(
1165
1176
  cache: RenderedStringCache | undefined,
@@ -69,6 +69,11 @@ export interface ToolCardOptions {
69
69
  paddingY?: number;
70
70
  ignoreTight?: boolean;
71
71
  onInvalidate?: () => void;
72
+ /**
73
+ * Drop builder-owned memos the next build recomputes. Called by
74
+ * {@link ToolCard.releaseRenderCaches}, which never calls `onInvalidate`.
75
+ */
76
+ onReleaseRenderCaches?: () => void;
72
77
  onDispose?: () => void;
73
78
  }
74
79
 
@@ -381,6 +386,16 @@ export class ToolCard implements Component {
381
386
  this.#options.onInvalidate?.();
382
387
  }
383
388
 
389
+ releaseRenderCaches(): void {
390
+ this.#block.invalidate();
391
+ this.#lastBlockOptions = undefined;
392
+ this.#plainKey = "";
393
+ this.#plainText.setText("");
394
+ this.#plainText.releaseRenderCaches();
395
+ for (const child of this.#renderedChildren) child.releaseRenderCaches?.();
396
+ this.#options.onReleaseRenderCaches?.();
397
+ }
398
+
384
399
  dispose(): void {
385
400
  if (this.#disposed) return;
386
401
  this.#disposed = true;
@@ -51,6 +51,11 @@ export class WidthAwareText implements Component {
51
51
  this.#inner.invalidate();
52
52
  }
53
53
 
54
+ /** The formatted text stays: the inner `Text` renders from it, so dropping it would free nothing. */
55
+ releaseRenderCaches(): void {
56
+ this.#inner.releaseRenderCaches();
57
+ }
58
+
54
59
  /**
55
60
  * Re-run the formatter on the next render because its inputs changed.
56
61
  * Unlike {@link invalidate}, the inner `Text` keeps its wrap cache, so a
package/src/terminal.ts CHANGED
@@ -501,7 +501,12 @@ export const CURSOR_SHAPE_CODES: Record<CursorShape, number> = {
501
501
  bar: 6,
502
502
  };
503
503
  export interface Terminal {
504
- // Start the terminal with input, resize, and host-disconnect handlers.
504
+ /**
505
+ * Start the terminal with input, resize, and host-disconnect handlers.
506
+ * Unless input is deferred, this sends capability probes; a terminal that
507
+ * cannot parse one may leave concealed bytes on the cursor row, so the
508
+ * caller repaints after starting.
509
+ */
505
510
  start(
506
511
  onInput: (data: string) => void,
507
512
  onResize: () => void,
@@ -513,8 +518,10 @@ export interface Terminal {
513
518
  * Take ownership of stdin after a `deferInput` start: enable raw mode,
514
519
  * attach input handlers, and run the capability probes start() skipped.
515
520
  * Bytes the user typed in cooked mode meanwhile are replayed through
516
- * `onInput`. No-op when input was never deferred. Optional so custom
517
- * Terminals built against older pi-tui versions keep working.
521
+ * `onInput`. No-op when input was never deferred. Like {@link start}, the
522
+ * probes may leave concealed bytes on the cursor row, so the caller
523
+ * repaints afterwards. Optional so custom Terminals built against older
524
+ * pi-tui versions keep working.
518
525
  */
519
526
  enableInput?(): void;
520
527
 
@@ -707,6 +714,17 @@ function parseOsc99KeyValues(section: string): Map<string, string> {
707
714
  }
708
715
  return values;
709
716
  }
717
+ /**
718
+ * Brackets the startup capability probes. A terminal that cannot parse a probe
719
+ * prints it as text at the cursor (Apple Terminal drops `ESC _`/`ESC \` and
720
+ * prints APC payloads, and abandons DECRQM at its `$`, printing the final `p`).
721
+ * DECSC, autowrap off, and SGR 8 (conceal) keep that text invisible and on the
722
+ * cursor row, so it can neither wrap nor scroll the screen; DECRC puts the
723
+ * cursor back. The screen owner repaints the row afterwards (see
724
+ * {@link Terminal.start} / {@link Terminal.enableInput}).
725
+ */
726
+ const PROBE_GUARD_BEGIN = "\x1b7\x1b[?7l\x1b[8m";
727
+ const PROBE_GUARD_END = "\x1b[28m\x1b[?7h\x1b8";
710
728
  const XTERM_SCROLL_TO_BOTTOM_MODES = [1010, 1011] as const;
711
729
  type Osc11QueryRoute = "direct" | "tmux";
712
730
  const TMUX_OSC11_CACHE_REFRESH_DELAY_MS = 100;
@@ -1099,6 +1117,8 @@ export class ProcessTerminal implements Terminal {
1099
1117
  // events that lose modifier information. Must run after setRawMode(true)
1100
1118
  // since that resets console mode flags.
1101
1119
  this.#enableWindowsVTInput();
1120
+ // Every response-eliciting probe goes out inside the leak guard.
1121
+ this.#safeWrite(PROBE_GUARD_BEGIN);
1102
1122
  // Query and enable Kitty keyboard protocol
1103
1123
  // The query handler intercepts input temporarily, then installs the user's handler
1104
1124
  // See: https://sw.kovidgoyal.net/kitty/keyboard-protocol/
@@ -1161,6 +1181,7 @@ export class ProcessTerminal implements Terminal {
1161
1181
  for (const mode of XTERM_SCROLL_TO_BOTTOM_MODES) {
1162
1182
  this.#queryPrivateMode(mode);
1163
1183
  }
1184
+ this.#safeWrite(PROBE_GUARD_END);
1164
1185
  }
1165
1186
 
1166
1187
  /**
package/src/tools/bash.ts CHANGED
@@ -494,6 +494,15 @@ export function createShellRenderer<TArgs>(config: ShellRendererConfig<TArgs>) {
494
494
  let cachedIsPartial: boolean | undefined;
495
495
  let cachedPreviewWindow: number | undefined;
496
496
  let cachedSnapshot: ToolCardSnapshot | undefined;
497
+ const dropSnapshot = () => {
498
+ cachedSnapshot = undefined;
499
+ cachedWidth = undefined;
500
+ cachedPreviewLines = undefined;
501
+ cachedExpanded = undefined;
502
+ cachedRawOutput = undefined;
503
+ cachedIsPartial = undefined;
504
+ cachedPreviewWindow = undefined;
505
+ };
497
506
 
498
507
  return framedToolCard(
499
508
  uiTheme,
@@ -596,17 +605,7 @@ export function createShellRenderer<TArgs>(config: ShellRendererConfig<TArgs>) {
596
605
  cachedSnapshot = snapshot;
597
606
  return snapshot;
598
607
  },
599
- {
600
- onInvalidate: () => {
601
- cachedSnapshot = undefined;
602
- cachedWidth = undefined;
603
- cachedPreviewLines = undefined;
604
- cachedExpanded = undefined;
605
- cachedRawOutput = undefined;
606
- cachedIsPartial = undefined;
607
- cachedPreviewWindow = undefined;
608
- },
609
- },
608
+ { onInvalidate: dropSnapshot, onReleaseRenderCaches: dropSnapshot },
610
609
  );
611
610
  },
612
611
  describeCall(args: TArgs, options: RenderResultOptions): NativeToolView {
package/src/tools/edit.ts CHANGED
@@ -36,6 +36,7 @@ import {
36
36
  invalidateRenderedStringCache,
37
37
  PREVIEW_LIMITS,
38
38
  previewWindowRows,
39
+ releaseRenderedStringCache,
39
40
  type RenderedStringCache,
40
41
  replaceTabs,
41
42
  shortenPath,
@@ -1110,41 +1111,49 @@ export const editToolRenderer = {
1110
1111
  return renderInlineEditRow(uiTheme, { op, rename, rawPath, pending: true });
1111
1112
  }
1112
1113
  const callPreviewCaches: RenderedStringCache[] = [];
1113
- return framedToolCard(uiTheme, ({ width }) => {
1114
- // No status icon on the head row: it's the head of the framed block,
1115
- // and native-scrollback commits are prefix-only — an animated glyph
1116
- // would pin the commit boundary at the top, and the pending hourglass
1117
- // just adds noise. The liveness cue rides the trailing "(preview)" /
1118
- // "(streaming)" line instead.
1119
- const header = renderEditHeader(width, uiTheme, {
1120
- op,
1121
- rawPath,
1122
- rename,
1123
- extraSuffix: fileCount > 1 ? uiTheme.fg("dim", ` (+${fileCount - 1} more)`) : undefined,
1124
- });
1125
- let body = getCallPreview(
1126
- editArgs,
1127
- rawPath,
1128
- width,
1129
- uiTheme,
1130
- renderContext,
1131
- options.expanded,
1132
- options?.spinnerFrame,
1133
- callPreviewCaches,
1134
- );
1135
- if (applyPatchError) {
1136
- body += `\n${uiTheme.fg("error", truncateToWidth(replaceTabs(applyPatchError), Math.max(1, width - 2)))}`;
1137
- }
1138
- const bodyLines = body ? body.split("\n") : [];
1139
- while (bodyLines.length > 0 && bodyLines[0].trim() === "") bodyLines.shift();
1140
- return {
1141
- header,
1142
- sections: bodyLines.length > 0 ? [{ content: bodyLines }] : [],
1143
- phase: applyPatchError ? "error" : "pending",
1144
- borderColor: applyPatchError ? "error" : "borderMuted",
1145
- contentPaddingLeft: 0,
1146
- };
1147
- });
1114
+ return framedToolCard(
1115
+ uiTheme,
1116
+ ({ width }) => {
1117
+ // No status icon on the head row: it's the head of the framed block,
1118
+ // and native-scrollback commits are prefix-only — an animated glyph
1119
+ // would pin the commit boundary at the top, and the pending hourglass
1120
+ // just adds noise. The liveness cue rides the trailing "(preview)" /
1121
+ // "(streaming)" line instead.
1122
+ const header = renderEditHeader(width, uiTheme, {
1123
+ op,
1124
+ rawPath,
1125
+ rename,
1126
+ extraSuffix: fileCount > 1 ? uiTheme.fg("dim", ` (+${fileCount - 1} more)`) : undefined,
1127
+ });
1128
+ let body = getCallPreview(
1129
+ editArgs,
1130
+ rawPath,
1131
+ width,
1132
+ uiTheme,
1133
+ renderContext,
1134
+ options.expanded,
1135
+ options?.spinnerFrame,
1136
+ callPreviewCaches,
1137
+ );
1138
+ if (applyPatchError) {
1139
+ body += `\n${uiTheme.fg("error", truncateToWidth(replaceTabs(applyPatchError), Math.max(1, width - 2)))}`;
1140
+ }
1141
+ const bodyLines = body ? body.split("\n") : [];
1142
+ while (bodyLines.length > 0 && bodyLines[0].trim() === "") bodyLines.shift();
1143
+ return {
1144
+ header,
1145
+ sections: bodyLines.length > 0 ? [{ content: bodyLines }] : [],
1146
+ phase: applyPatchError ? "error" : "pending",
1147
+ borderColor: applyPatchError ? "error" : "borderMuted",
1148
+ contentPaddingLeft: 0,
1149
+ };
1150
+ },
1151
+ {
1152
+ onReleaseRenderCaches: () => {
1153
+ for (const cache of callPreviewCaches) releaseRenderedStringCache(cache);
1154
+ },
1155
+ },
1156
+ );
1148
1157
  },
1149
1158
 
1150
1159
  describeCall(
@@ -1452,74 +1461,54 @@ function renderSingleFileResult(
1452
1461
  const renderedDiffCache = createRenderedStringCache();
1453
1462
  const statsSuffixCache = createRenderedStringCache();
1454
1463
 
1455
- return framedToolCard(uiTheme, ({ width }) => {
1456
- const { expanded, renderContext } = options;
1457
- // A finalized result is authoritative: its `details` describe exactly
1458
- // what happened. The shared streaming `editDiffPreview` is a call-phase
1459
- // artifact (in a batch it reflects only the first file), so consulting it
1460
- // for an empty-diff delete/move/no-op result mislabels the card. Fall
1461
- // back to the preview only when no details exist yet.
1462
- const editDiffPreview = details ? undefined : renderContext?.editDiffPreview;
1463
- const renderDiffFn = renderContext?.renderDiff ?? renderFallbackDiff;
1464
-
1465
- if (diffSectionRenderDiffFn !== renderDiffFn) {
1466
- diffSectionRenderDiffFn = renderDiffFn;
1467
- invalidateRenderedStringCache(diffSectionCache);
1468
- invalidateRenderedStringCache(renderedDiffCache);
1469
- }
1470
- const firstChangedLine =
1471
- (editDiffPreview && "firstChangedLine" in editDiffPreview ? editDiffPreview.firstChangedLine : undefined) ||
1472
- (details && !isError ? details.firstChangedLine : undefined);
1473
- const linkPath = details && "path" in details ? details.path : undefined;
1474
-
1475
- // Change stats ride inline on the header bar next to the path.
1476
- const previewDiff = editDiffPreview && !("error" in editDiffPreview) ? editDiffPreview.diff : undefined;
1477
- const headerDiff = isError ? undefined : details?.diff || previewDiff;
1478
- const statsSuffix = headerDiff
1479
- ? cachedRenderedString(statsSuffixCache, uiTheme, false, "", headerDiff, () =>
1480
- formatDiffStatsSuffix(headerDiff, uiTheme),
1481
- )
1482
- : "";
1483
- const header = renderEditHeader(width, uiTheme, {
1484
- icon: isError ? "error" : "success",
1485
- iconOverride: !isError && !options.isPartial ? uiTheme.styledSymbol("tool.edit", "accent") : undefined,
1486
- op,
1487
- rawPath,
1488
- rename,
1489
- firstChangedLine,
1490
- linkPath,
1491
- statsSuffix,
1492
- });
1493
- const innerWidth = Math.max(1, width - 2);
1494
-
1495
- let body = "";
1496
- if (isError) {
1497
- if (errorText) body = uiTheme.fg("error", replaceTabs(errorText));
1498
- } else if (details?.diff) {
1499
- body = renderDiffSection(
1500
- details.diff,
1501
- rawPath,
1502
- expanded,
1503
- innerWidth,
1504
- uiTheme,
1505
- renderDiffFn,
1506
- renderedDiffCache,
1507
- diffSectionCache,
1508
- );
1509
- } else if (details) {
1510
- // Authoritative result with no textual diff: a delete, a move-only
1511
- // rename, or a genuine no-op. The header already names the op
1512
- // (Delete / `src → dst`); only a true no-op needs an explanatory
1513
- // body so an empty card isn't mistaken for a stalled edit.
1514
- if (op !== "delete" && op !== "create" && !rename) {
1515
- const noChangePath = linkPath ? shortenPath(linkPath) : rawPath ? shortenPath(rawPath) : "";
1516
- body = uiTheme.fg("dim", `No changes were made${noChangePath ? ` to ${noChangePath}` : ""}.`);
1464
+ return framedToolCard(
1465
+ uiTheme,
1466
+ ({ width }) => {
1467
+ const { expanded, renderContext } = options;
1468
+ // A finalized result is authoritative: its `details` describe exactly
1469
+ // what happened. The shared streaming `editDiffPreview` is a call-phase
1470
+ // artifact (in a batch it reflects only the first file), so consulting it
1471
+ // for an empty-diff delete/move/no-op result mislabels the card. Fall
1472
+ // back to the preview only when no details exist yet.
1473
+ const editDiffPreview = details ? undefined : renderContext?.editDiffPreview;
1474
+ const renderDiffFn = renderContext?.renderDiff ?? renderFallbackDiff;
1475
+
1476
+ if (diffSectionRenderDiffFn !== renderDiffFn) {
1477
+ diffSectionRenderDiffFn = renderDiffFn;
1478
+ invalidateRenderedStringCache(diffSectionCache);
1479
+ invalidateRenderedStringCache(renderedDiffCache);
1517
1480
  }
1518
- } else if (editDiffPreview) {
1519
- if ("error" in editDiffPreview) body = uiTheme.fg("error", replaceTabs(editDiffPreview.error));
1520
- else if (editDiffPreview.diff)
1481
+ const firstChangedLine =
1482
+ (editDiffPreview && "firstChangedLine" in editDiffPreview ? editDiffPreview.firstChangedLine : undefined) ||
1483
+ (details && !isError ? details.firstChangedLine : undefined);
1484
+ const linkPath = details && "path" in details ? details.path : undefined;
1485
+
1486
+ // Change stats ride inline on the header bar next to the path.
1487
+ const previewDiff = editDiffPreview && !("error" in editDiffPreview) ? editDiffPreview.diff : undefined;
1488
+ const headerDiff = isError ? undefined : details?.diff || previewDiff;
1489
+ const statsSuffix = headerDiff
1490
+ ? cachedRenderedString(statsSuffixCache, uiTheme, false, "", headerDiff, () =>
1491
+ formatDiffStatsSuffix(headerDiff, uiTheme),
1492
+ )
1493
+ : "";
1494
+ const header = renderEditHeader(width, uiTheme, {
1495
+ icon: isError ? "error" : "success",
1496
+ iconOverride: !isError && !options.isPartial ? uiTheme.styledSymbol("tool.edit", "accent") : undefined,
1497
+ op,
1498
+ rawPath,
1499
+ rename,
1500
+ firstChangedLine,
1501
+ linkPath,
1502
+ statsSuffix,
1503
+ });
1504
+ const innerWidth = Math.max(1, width - 2);
1505
+
1506
+ let body = "";
1507
+ if (isError) {
1508
+ if (errorText) body = uiTheme.fg("error", replaceTabs(errorText));
1509
+ } else if (details?.diff) {
1521
1510
  body = renderDiffSection(
1522
- editDiffPreview.diff,
1511
+ details.diff,
1523
1512
  rawPath,
1524
1513
  expanded,
1525
1514
  innerWidth,
@@ -1528,27 +1517,56 @@ function renderSingleFileResult(
1528
1517
  renderedDiffCache,
1529
1518
  diffSectionCache,
1530
1519
  );
1531
- }
1532
- if (details?.diagnostics) {
1533
- body += formatDiagnostics(details.diagnostics, expanded, uiTheme, (fp: string) =>
1534
- uiTheme.getLangIcon(getLanguageFromPath(fp)),
1535
- );
1536
- }
1520
+ } else if (details) {
1521
+ // Authoritative result with no textual diff: a delete, a move-only
1522
+ // rename, or a genuine no-op. The header already names the op
1523
+ // (Delete / `src → dst`); only a true no-op needs an explanatory
1524
+ // body so an empty card isn't mistaken for a stalled edit.
1525
+ if (op !== "delete" && op !== "create" && !rename) {
1526
+ const noChangePath = linkPath ? shortenPath(linkPath) : rawPath ? shortenPath(rawPath) : "";
1527
+ body = uiTheme.fg("dim", `No changes were made${noChangePath ? ` to ${noChangePath}` : ""}.`);
1528
+ }
1529
+ } else if (editDiffPreview) {
1530
+ if ("error" in editDiffPreview) body = uiTheme.fg("error", replaceTabs(editDiffPreview.error));
1531
+ else if (editDiffPreview.diff)
1532
+ body = renderDiffSection(
1533
+ editDiffPreview.diff,
1534
+ rawPath,
1535
+ expanded,
1536
+ innerWidth,
1537
+ uiTheme,
1538
+ renderDiffFn,
1539
+ renderedDiffCache,
1540
+ diffSectionCache,
1541
+ );
1542
+ }
1543
+ if (details?.diagnostics) {
1544
+ body += formatDiagnostics(details.diagnostics, expanded, uiTheme, (fp: string) =>
1545
+ uiTheme.getLangIcon(getLanguageFromPath(fp)),
1546
+ );
1547
+ }
1537
1548
 
1538
- // Diff lines self-wrap with a continuation gutter; pre-wrap to the frame's
1539
- // inner width so renderOutputBlock's generic wrap is a no-op. Edit frames
1540
- // use a flush left border because code-frame gutters already provide padding.
1541
- const bodyLines = body.length > 0 ? body.split("\n").flatMap(line => wrapEditRendererLine(line, innerWidth)) : [];
1542
- while (bodyLines.length > 0 && bodyLines[0].trim() === "") bodyLines.shift();
1549
+ // Diff lines self-wrap with a continuation gutter; pre-wrap to the frame's
1550
+ // inner width so renderOutputBlock's generic wrap is a no-op. Edit frames
1551
+ // use a flush left border because code-frame gutters already provide padding.
1552
+ const bodyLines =
1553
+ body.length > 0 ? body.split("\n").flatMap(line => wrapEditRendererLine(line, innerWidth)) : [];
1554
+ while (bodyLines.length > 0 && bodyLines[0].trim() === "") bodyLines.shift();
1543
1555
 
1544
- return {
1545
- header,
1546
- sections: bodyLines.length > 0 ? [{ content: bodyLines }] : [],
1547
- phase: isError ? "error" : options.isPartial ? "partial" : "success",
1548
- borderColor: isError ? "error" : "borderMuted",
1549
- contentPaddingLeft: 0,
1550
- };
1551
- });
1556
+ return {
1557
+ header,
1558
+ sections: bodyLines.length > 0 ? [{ content: bodyLines }] : [],
1559
+ phase: isError ? "error" : options.isPartial ? "partial" : "success",
1560
+ borderColor: isError ? "error" : "borderMuted",
1561
+ contentPaddingLeft: 0,
1562
+ };
1563
+ },
1564
+ {
1565
+ // Only the width-keyed diff section goes; the highlighted diff and stats suffix do not depend on width, and
1566
+ // re-highlighting every committed card on each resize replay costs seconds.
1567
+ onReleaseRenderCaches: () => releaseRenderedStringCache(diffSectionCache),
1568
+ },
1569
+ );
1552
1570
  }
1553
1571
 
1554
1572
  function renderMultiFileResult(
@@ -1603,5 +1621,9 @@ function renderMultiFileResult(
1603
1621
  cached = undefined;
1604
1622
  for (const c of fileComponents) c.invalidate?.();
1605
1623
  },
1624
+ releaseRenderCaches() {
1625
+ cached = undefined;
1626
+ for (const c of fileComponents) c.releaseRenderCaches?.();
1627
+ },
1606
1628
  };
1607
1629
  }
@@ -282,6 +282,10 @@ export function renderReadUrlResult(
282
282
 
283
283
  let lastExpanded: boolean | undefined;
284
284
  let contentPreviewLines: string[] | undefined;
285
+ const dropPreview = () => {
286
+ lastExpanded = undefined;
287
+ contentPreviewLines = undefined;
288
+ };
285
289
  return framedToolCard(
286
290
  uiTheme,
287
291
  () => {
@@ -315,11 +319,6 @@ export function renderReadUrlResult(
315
319
  applyBg: false,
316
320
  };
317
321
  },
318
- {
319
- onInvalidate: () => {
320
- lastExpanded = undefined;
321
- contentPreviewLines = undefined;
322
- },
323
- },
322
+ { onInvalidate: dropPreview, onReleaseRenderCaches: dropPreview },
324
323
  );
325
324
  }
@@ -27,6 +27,7 @@ import { todoToolRenderer } from "./todo";
27
27
  import { createVibeToolRenderer } from "./vibe";
28
28
  import { webSearchToolRenderer } from "./web-search";
29
29
  import { writeToolRenderer } from "./write";
30
+ import { yieldToolRenderer } from "./yield";
30
31
  import { setXdevRendererLookup } from "./xdev";
31
32
 
32
33
  export * from "./renderer";
@@ -67,6 +68,7 @@ export const toolRenderers: Record<string, ToolRenderer> = {
67
68
  vibe_kill: createVibeToolRenderer("kill"),
68
69
  vibe_list: createVibeToolRenderer("list"),
69
70
  write: writeToolRenderer,
71
+ yield: yieldToolRenderer,
70
72
  };
71
73
 
72
74
  // Wire the xd:// render delegation without the xdev module importing this registry.
package/src/tools/mcp.ts CHANGED
@@ -121,7 +121,15 @@ function renderMarkdownMCPResult(
121
121
  applyBg: false,
122
122
  };
123
123
  },
124
- { paddingX: 0, paddingY: 0, onInvalidate: () => (bodyMemo = undefined) },
124
+ {
125
+ paddingX: 0,
126
+ paddingY: 0,
127
+ onInvalidate: () => (bodyMemo = undefined),
128
+ onReleaseRenderCaches: () => {
129
+ bodyMemo = undefined;
130
+ markdown.releaseRenderCaches();
131
+ },
132
+ },
125
133
  );
126
134
  }
127
135
 
@@ -208,7 +216,12 @@ export function renderMCPResult(
208
216
  }
209
217
  return { status, phase, body: bodyMemo.lines, applyBg: false };
210
218
  },
211
- { paddingX: 0, paddingY: 0, onInvalidate: () => (bodyMemo = undefined) },
219
+ {
220
+ paddingX: 0,
221
+ paddingY: 0,
222
+ onInvalidate: () => (bodyMemo = undefined),
223
+ onReleaseRenderCaches: () => (bodyMemo = undefined),
224
+ },
212
225
  );
213
226
 
214
227
  function buildMcpResultBody(contentWidth: number): string[] {
package/src/tools/read.ts CHANGED
@@ -600,6 +600,11 @@ export const readToolRenderer = {
600
600
  let cachedWidth: number | undefined;
601
601
  let cachedExpanded: boolean | undefined;
602
602
  let cachedLines: string[] | undefined;
603
+ const dropCache = () => {
604
+ cachedWidth = undefined;
605
+ cachedExpanded = undefined;
606
+ cachedLines = undefined;
607
+ };
603
608
  return markFramedBlockComponent({
604
609
  render: (width: number) => {
605
610
  const expanded = options.expanded;
@@ -634,11 +639,8 @@ export const readToolRenderer = {
634
639
  cachedExpanded = expanded;
635
640
  return cachedLines;
636
641
  },
637
- invalidate: () => {
638
- cachedWidth = undefined;
639
- cachedExpanded = undefined;
640
- cachedLines = undefined;
641
- },
642
+ invalidate: dropCache,
643
+ releaseRenderCaches: dropCache,
642
644
  });
643
645
  },
644
646
  describeCall(args: ReadRenderArgs): NativeToolView {