@oh-my-pi/pi-tui 18.7.0 → 18.8.0

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 (79) hide show
  1. package/CHANGELOG.md +21 -52
  2. package/dist/types/apps/debug/log-viewer.d.ts +4 -0
  3. package/dist/types/apps/debug/raw-sse-buffer.d.ts +2 -0
  4. package/dist/types/chat/fence-figure.d.ts +42 -0
  5. package/dist/types/chat/svg-figure.d.ts +1 -1
  6. package/dist/types/chat/tool-execution.d.ts +2 -0
  7. package/dist/types/components/editor.d.ts +8 -3
  8. package/dist/types/components/markdown.d.ts +11 -6
  9. package/dist/types/fuzzy.d.ts +17 -3
  10. package/dist/types/native/backend.d.ts +2 -1
  11. package/dist/types/native/blobs.d.ts +4 -2
  12. package/dist/types/native/encode.d.ts +3 -4
  13. package/dist/types/overlays/agent-transcript-viewer.d.ts +8 -1
  14. package/dist/types/overlays/history-search.d.ts +10 -0
  15. package/dist/types/overlays/model-browser.d.ts +6 -0
  16. package/dist/types/prompt/custom-editor.d.ts +7 -4
  17. package/dist/types/prompt/interactive-context-helpers.d.ts +2 -1
  18. package/dist/types/tools/renderer.d.ts +22 -0
  19. package/dist/types/tools/streaming-output.d.ts +11 -1
  20. package/dist/types/tools/write.d.ts +8 -1
  21. package/package.json +9 -9
  22. package/src/apps/debug/log-viewer.ts +188 -72
  23. package/src/apps/debug/raw-sse-buffer.ts +8 -0
  24. package/src/apps/debug/raw-sse.ts +39 -5
  25. package/src/apps/git/diff-pane.ts +26 -5
  26. package/src/autocomplete.ts +59 -33
  27. package/src/chat/assistant-message.ts +30 -18
  28. package/src/chat/fence-figure.ts +80 -0
  29. package/src/chat/svg-figure.ts +2 -4
  30. package/src/chat/tool-execution.ts +53 -4
  31. package/src/components/editor.ts +16 -3
  32. package/src/components/markdown.ts +80 -307
  33. package/src/fuzzy.ts +59 -14
  34. package/src/native/backend.ts +170 -15
  35. package/src/native/blobs.ts +4 -2
  36. package/src/native/encode.ts +58 -31
  37. package/src/overlays/agent-hub-projection.ts +70 -2
  38. package/src/overlays/agent-hub.ts +3 -2
  39. package/src/overlays/agent-transcript-viewer.ts +64 -13
  40. package/src/overlays/agents-hub.ts +14 -7
  41. package/src/overlays/copy-selector.ts +30 -8
  42. package/src/overlays/extensions/state-manager.ts +52 -12
  43. package/src/overlays/history-search.ts +100 -7
  44. package/src/overlays/jobs-panel.ts +30 -2
  45. package/src/overlays/model-browser.ts +107 -21
  46. package/src/overlays/model-hub.ts +34 -7
  47. package/src/overlays/oauth-selector.ts +41 -26
  48. package/src/overlays/plan-review-overlay.ts +142 -64
  49. package/src/overlays/rewind-selector.ts +86 -17
  50. package/src/overlays/session-selector.ts +24 -4
  51. package/src/overlays/settings-selector.ts +21 -6
  52. package/src/overlays/tree-selector.ts +20 -4
  53. package/src/overlays/usage-dashboard.ts +12 -15
  54. package/src/prompt/attachment-chips.ts +2 -1
  55. package/src/prompt/composer-attachments.ts +6 -4
  56. package/src/prompt/custom-editor.ts +48 -19
  57. package/src/prompt/interactive-context-helpers.ts +27 -18
  58. package/src/prompt/macos-spelling.ts +7 -0
  59. package/src/prompt/magic-keywords.ts +11 -2
  60. package/src/prompt/word-completion.ts +38 -5
  61. package/src/setup/scenes/splash.ts +109 -45
  62. package/src/status-line/component.ts +220 -64
  63. package/src/status-line/segments.ts +5 -2
  64. package/src/terminal.ts +3 -2
  65. package/src/tools/ast-edit.ts +59 -35
  66. package/src/tools/ast-grep.ts +35 -16
  67. package/src/tools/default-renderer.ts +35 -19
  68. package/src/tools/eval-format/index.ts +28 -2
  69. package/src/tools/eval-format/javascript.ts +7 -5
  70. package/src/tools/eval.ts +164 -78
  71. package/src/tools/grep.ts +81 -48
  72. package/src/tools/lsp.ts +81 -59
  73. package/src/tools/mcp.ts +98 -68
  74. package/src/tools/renderer.ts +27 -0
  75. package/src/tools/streaming-output.ts +120 -48
  76. package/src/tools/task.ts +111 -54
  77. package/src/tools/todo.ts +115 -29
  78. package/src/tools/wait.ts +34 -24
  79. package/src/tools/write.ts +77 -39
@@ -592,6 +592,22 @@ function buildMidPromptSkillCompletions(commands: CommandEntry[], lowerPrefix: s
592
592
  );
593
593
  }
594
594
 
595
+ const DIR_CACHE_MAX = 100;
596
+ const DIR_CACHE_EVICT = 50;
597
+
598
+ /**
599
+ * Drops the `count` oldest entries once `map` exceeds `max`. Callers delete a
600
+ * key before re-setting it, so Map insertion order is refresh order.
601
+ */
602
+ function evictOldest(map: Map<string, unknown>, max: number, count: number): void {
603
+ if (map.size <= max) return;
604
+ let remaining = count;
605
+ for (const key of map.keys()) {
606
+ if (remaining-- === 0) break;
607
+ map.delete(key);
608
+ }
609
+ }
610
+
595
611
  // Combined provider that handles both slash commands and file paths.
596
612
  export class CombinedAutocompleteProvider implements AutocompleteProvider {
597
613
  #commands: CommandEntry[];
@@ -601,6 +617,9 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
601
617
  // per-directory readdir fast-path for prefix completions. Global fuzzy
602
618
  // discovery continues to use native fuzzyFind + shared scan cache.
603
619
  #dirCache: Map<string, { entries: fs.Dirent[]; timestamp: number }> = new Map();
620
+ // Whether a scoped fuzzy query's base (`src/` in `@src/foo`) is a directory;
621
+ // stat'ing it on every keystroke is redundant while the base is unchanged.
622
+ #scopedBaseCache: Map<string, { isDirectory: boolean; timestamp: number }> = new Map();
604
623
  readonly #DIR_CACHE_TTL = 2000; // 2 seconds
605
624
 
606
625
  constructor(
@@ -979,11 +998,21 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
979
998
  baseDir = path.join(this.#basePath, displayBase);
980
999
  }
981
1000
 
982
- try {
983
- if (!(await fs.promises.stat(baseDir)).isDirectory()) {
984
- return null;
1001
+ const now = Date.now();
1002
+ let cachedBase = this.#scopedBaseCache.get(baseDir);
1003
+ if (!cachedBase || now - cachedBase.timestamp >= this.#DIR_CACHE_TTL) {
1004
+ let isDirectory = false;
1005
+ try {
1006
+ isDirectory = (await fs.promises.stat(baseDir)).isDirectory();
1007
+ } catch {
1008
+ // Missing or inaccessible base: not a scope.
985
1009
  }
986
- } catch {
1010
+ cachedBase = { isDirectory, timestamp: now };
1011
+ this.#scopedBaseCache.delete(baseDir);
1012
+ this.#scopedBaseCache.set(baseDir, cachedBase);
1013
+ evictOldest(this.#scopedBaseCache, DIR_CACHE_MAX, DIR_CACHE_EVICT);
1014
+ }
1015
+ if (!cachedBase.isDirectory) {
987
1016
  return null;
988
1017
  }
989
1018
 
@@ -1006,17 +1035,9 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
1006
1035
  }
1007
1036
 
1008
1037
  const entries = await fs.promises.readdir(searchDir, { withFileTypes: true });
1038
+ this.#dirCache.delete(searchDir);
1009
1039
  this.#dirCache.set(searchDir, { entries, timestamp: now });
1010
-
1011
- if (this.#dirCache.size > 100) {
1012
- const sortedKeys = [...this.#dirCache.entries()]
1013
- .sort((a, b) => a[1].timestamp - b[1].timestamp)
1014
- .slice(0, 50)
1015
- .map(([key]) => key);
1016
- for (const key of sortedKeys) {
1017
- this.#dirCache.delete(key);
1018
- }
1019
- }
1040
+ evictOldest(this.#dirCache, DIR_CACHE_MAX, DIR_CACHE_EVICT);
1020
1041
 
1021
1042
  return entries;
1022
1043
  }
@@ -1024,8 +1045,10 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
1024
1045
  invalidateDirCache(dir?: string): void {
1025
1046
  if (dir) {
1026
1047
  this.#dirCache.delete(dir);
1048
+ this.#scopedBaseCache.delete(dir);
1027
1049
  } else {
1028
1050
  this.#dirCache.clear();
1051
+ this.#scopedBaseCache.clear();
1029
1052
  }
1030
1053
  }
1031
1054
 
@@ -1090,26 +1113,29 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
1090
1113
  const entries = await this.#getCachedDirEntries(searchDir);
1091
1114
  const suggestions: AutocompleteItem[] = [];
1092
1115
 
1093
- for (const entry of entries) {
1094
- if (!entry.name.toLowerCase().startsWith(searchPrefix.toLowerCase())) {
1095
- continue;
1096
- }
1097
- // Skip .git directory
1098
- if (entry.name === ".git") {
1099
- continue;
1100
- }
1116
+ const lowerSearchPrefix = searchPrefix.toLowerCase();
1117
+ // Skip .git directory
1118
+ const matched = entries.filter(
1119
+ entry => entry.name !== ".git" && entry.name.toLowerCase().startsWith(lowerSearchPrefix),
1120
+ );
1121
+ // Directory flag per match; symlinks are stat'ed concurrently to learn whether
1122
+ // they point at a directory. `null` drops a broken symlink, a file deleted
1123
+ // between readdir and stat, or a permission error.
1124
+ const directoryFlags = await Promise.all(
1125
+ matched.map(entry =>
1126
+ !entry.isDirectory() && entry.isSymbolicLink()
1127
+ ? fs.promises.stat(path.join(searchDir, entry.name)).then(
1128
+ stats => stats.isDirectory(),
1129
+ () => null,
1130
+ )
1131
+ : entry.isDirectory(),
1132
+ ),
1133
+ );
1101
1134
 
1102
- // Check if entry is a directory (or a symlink pointing to a directory)
1103
- let isDirectory = entry.isDirectory();
1104
- if (!isDirectory && entry.isSymbolicLink()) {
1105
- try {
1106
- const fullPath = path.join(searchDir, entry.name);
1107
- isDirectory = (await fs.promises.stat(fullPath)).isDirectory();
1108
- } catch {
1109
- // Broken symlink, file deleted between readdir and stat, or permission error
1110
- continue;
1111
- }
1112
- }
1135
+ for (let index = 0; index < matched.length; index++) {
1136
+ const isDirectory = directoryFlags[index];
1137
+ if (typeof isDirectory !== "boolean") continue;
1138
+ const entry = matched[index]!;
1113
1139
 
1114
1140
  let relativePath: string;
1115
1141
  const name = entry.name;
@@ -247,16 +247,22 @@ export class AssistantMessageComponent extends Container {
247
247
  #toolImagesByCallId = new Map<string, ImageContent[]>();
248
248
  /**
249
249
  * Payload keys ({@link imagePayloadKey}) whose Kitty PNG conversion this
250
- * component already awaits, so a re-delivered image neither re-encodes nor
250
+ * component is awaiting, so a re-delivered image neither re-encodes nor
251
251
  * schedules a second {@link updateContent} cascade. The conversions
252
- * themselves are shared process-wide by {@link convertImageToPngShared}.
252
+ * themselves live in the bounded process-wide cache behind
253
+ * {@link convertImageToPngShared}; an evicted one is redone on the next
254
+ * render instead of being pinned here for the session.
253
255
  */
254
256
  #kittyConversionsAwaited = new Set<string>();
255
257
  /**
256
- * Conversions this component displays, held so a later re-render (theme
257
- * invalidation) still finds them after the bounded shared cache evicts them.
258
+ * Conversions the current image children display, by payload key. Rebuilt
259
+ * on every full render pass ({@link updateContent}), so a conversion is held
260
+ * only while it is on screen — a re-render after the shared cache evicted
261
+ * it still finds it — and released once hidden or replaced.
258
262
  */
259
- #kittyConverted = new Map<string, ImageContent>();
263
+ #kittyDisplayed = new Map<string, ImageContent>();
264
+ /** The previous pass's {@link #kittyDisplayed}, readable only during a render pass. */
265
+ #kittyPreviouslyDisplayed: Map<string, ImageContent> | undefined;
260
266
  #showImages = true;
261
267
  #showToolResultImages = true;
262
268
  /** Charts under numeric tables; off for subagent transcripts. */
@@ -1462,22 +1468,24 @@ export class AssistantMessageComponent extends Container {
1462
1468
  }
1463
1469
  }
1464
1470
 
1471
+ /** A displayable Kitty PNG for a non-PNG `image`: the shared cache, else this component's displayed copy. */
1472
+ #kittyConversion(image: ImageContent): ImageContent | undefined {
1473
+ const cached = cachedPngConversion(image);
1474
+ if (cached) return cached;
1475
+ const key = imagePayloadKey(image);
1476
+ return this.#kittyDisplayed.get(key) ?? this.#kittyPreviouslyDisplayed?.get(key);
1477
+ }
1478
+
1465
1479
  #convertImagesForKitty(entries: Array<{ image: ImageContent; key: string }>): void {
1466
1480
  if (TERMINAL.imageProtocol !== ImageProtocol.Kitty) return;
1467
1481
  for (const { image } of entries) {
1468
- if (image.mimeType === "image/png") continue;
1482
+ if (image.mimeType === "image/png" || this.#kittyConversion(image)) continue;
1469
1483
  const key = imagePayloadKey(image);
1470
- if (this.#kittyConverted.has(key)) continue;
1471
- const cached = cachedPngConversion(image);
1472
- if (cached) {
1473
- this.#kittyConverted.set(key, cached);
1474
- continue;
1475
- }
1476
1484
  if (this.#kittyConversionsAwaited.has(key)) continue;
1477
1485
  this.#kittyConversionsAwaited.add(key);
1478
1486
  convertImageToPngShared(image)
1479
- .then(converted => {
1480
- this.#kittyConverted.set(key, converted);
1487
+ .then(() => {
1488
+ this.#kittyConversionsAwaited.delete(key);
1481
1489
  if (this.#lastMessage) {
1482
1490
  this.updateContent(this.#lastMessage, { transient: this.#lastUpdateTransient });
1483
1491
  }
@@ -1495,10 +1503,11 @@ export class AssistantMessageComponent extends Container {
1495
1503
 
1496
1504
  if (withLeadingSpacer) this.#contentContainer.addChild(new Spacer(1));
1497
1505
  for (const { image, key } of entries) {
1498
- const displayImage =
1499
- TERMINAL.imageProtocol === ImageProtocol.Kitty && image.mimeType !== "image/png"
1500
- ? this.#kittyConverted.get(imagePayloadKey(image))
1501
- : image;
1506
+ let displayImage: ImageContent | undefined = image;
1507
+ if (TERMINAL.imageProtocol === ImageProtocol.Kitty && image.mimeType !== "image/png") {
1508
+ displayImage = this.#kittyConversion(image);
1509
+ if (displayImage) this.#kittyDisplayed.set(imagePayloadKey(image), displayImage);
1510
+ }
1502
1511
  if (TERMINAL.imageProtocol && displayImage) {
1503
1512
  this.#contentContainer.addChild(
1504
1513
  new Image(
@@ -1713,6 +1722,8 @@ export class AssistantMessageComponent extends Container {
1713
1722
 
1714
1723
  // Clear content container
1715
1724
  this.#contentContainer.clear();
1725
+ this.#kittyPreviouslyDisplayed = this.#kittyDisplayed;
1726
+ this.#kittyDisplayed = new Map();
1716
1727
  this.#thinkingExtensions.clear();
1717
1728
  this.#emergencyText = undefined;
1718
1729
  this.#thinkingDots = undefined;
@@ -1795,6 +1806,7 @@ export class AssistantMessageComponent extends Container {
1795
1806
  }
1796
1807
 
1797
1808
  this.#renderToolImages();
1809
+ this.#kittyPreviouslyDisplayed = undefined;
1798
1810
  const errorPresentation = resolveAssistantErrorPresentation(message);
1799
1811
  const hasToolCalls = message.content.some(c => c.type === "toolCall");
1800
1812
  if (errorPresentation.kind === "compact-recovered") {
@@ -0,0 +1,80 @@
1
+ /**
2
+ * A tool call's {@link ToolFigure} drawn under its card, the way assistant
3
+ * Markdown draws that fence: ```svg as an inline image ({@link SvgFigure}),
4
+ * redrawn as the body streams; ```mermaid as ASCII art once the body is
5
+ * final. The card already shows the source, so unlike the assistant's fence
6
+ * it never falls back to code: {@link FenceFigure.draws} turns down fences
7
+ * this terminal shows as code, and an svg that does not rasterize stays blank.
8
+ */
9
+ import type { ImageBudget } from "../components/image";
10
+ import { fencedCode, Markdown } from "../components/markdown";
11
+ import { TERMINAL } from "../terminal-capabilities";
12
+ import { getMarkdownTheme } from "../theme";
13
+ import type { ToolFigure } from "../tools/renderer";
14
+ import type { Component } from "../tui";
15
+ import { SvgFigure, svgFigureRendering } from "./svg-figure";
16
+
17
+ /** Fence languages a {@link FenceFigure} draws. */
18
+ export type FenceFigureLang = "svg" | "mermaid";
19
+
20
+ export interface FenceFigureOptions {
21
+ /** Shared inline-image budget for an svg's rasters. */
22
+ budget?: ImageBudget;
23
+ /** The figure's rows changed outside a render: a raster landed. */
24
+ onChange: () => void;
25
+ }
26
+
27
+ export class FenceFigure implements Component {
28
+ /**
29
+ * Whether this terminal draws `figure` now: an svg where inline images show
30
+ * (`showImages` and a graphics protocol), a closed mermaid fence when
31
+ * diagram rendering is on and the source renders.
32
+ */
33
+ static draws(
34
+ figure: ToolFigure,
35
+ options: { showImages: boolean },
36
+ ): figure is ToolFigure & { readonly lang: FenceFigureLang } {
37
+ if (figure.lang === "svg") return options.showImages && TERMINAL.imageProtocol !== null && svgFigureRendering();
38
+ if (figure.lang !== "mermaid" || !figure.closed) return false;
39
+ return getMarkdownTheme().resolveMermaidAscii?.(figure.source.trimEnd()) != null;
40
+ }
41
+
42
+ readonly lang: FenceFigureLang;
43
+ readonly #svg: SvgFigure | undefined;
44
+ #source = "";
45
+ /** The mermaid fence as assistant Markdown renders it; rebuilt after a theme change. */
46
+ #diagram: Markdown | undefined;
47
+
48
+ constructor(lang: FenceFigureLang, options: FenceFigureOptions) {
49
+ this.lang = lang;
50
+ if (lang === "svg") this.#svg = new SvgFigure({ budget: options.budget, onChange: options.onChange });
51
+ }
52
+
53
+ /** Feed the fence as it stands; its `lang` is this figure's. */
54
+ update(figure: ToolFigure): void {
55
+ if (figure.source !== this.#source) this.#diagram = undefined;
56
+ this.#source = figure.source;
57
+ this.#svg?.update(figure.source, figure.closed);
58
+ }
59
+
60
+ /** Whether a closed svg's raster is still on its way. */
61
+ get pending(): boolean {
62
+ return this.#svg?.pending === true;
63
+ }
64
+
65
+ /** The drawing; no rows while an svg draws, or when it does not. */
66
+ render(width: number): readonly string[] {
67
+ if (this.#svg) return this.#svg.render(width);
68
+ this.#diagram ??= new Markdown(fencedCode("mermaid", this.#source), 1, 0, getMarkdownTheme());
69
+ return this.#diagram.render(width);
70
+ }
71
+
72
+ invalidate(): void {
73
+ this.#svg?.invalidate();
74
+ this.#diagram = undefined;
75
+ }
76
+
77
+ dispose(): void {
78
+ this.#svg?.dispose();
79
+ }
80
+ }
@@ -15,7 +15,7 @@
15
15
  import { rasterizeSvg } from "@oh-my-pi/pi-natives";
16
16
  import { logger } from "@oh-my-pi/pi-utils";
17
17
  import { Image, type ImageBudget, imageMaxColumns } from "../components/image";
18
- import type { Markdown } from "../components/markdown";
18
+ import { fencedCode, type Markdown } from "../components/markdown";
19
19
  import { type CellDimensions, getCellDimensions, getImageDimensions } from "../terminal-capabilities";
20
20
  import { getThemeEpoch, type ThemeColor, theme } from "../theme";
21
21
  import type { Component } from "../tui";
@@ -355,9 +355,7 @@ export class SvgFigure implements Component {
355
355
  const source = this.#source;
356
356
  if (!this.#options.markdown) return [];
357
357
  if (this.#fallback?.source !== source) {
358
- const longest = Math.max(2, ...(source.match(/`+/g) ?? []).map(run => run.length));
359
- const fence = "`".repeat(longest + 1);
360
- this.#fallback = { source, md: this.#options.markdown(`${fence}svg\n${source.trimEnd()}\n${fence}`) };
358
+ this.#fallback = { source, md: this.#options.markdown(fencedCode("svg", source)) };
361
359
  }
362
360
  return this.#fallback.md.render(width);
363
361
  }
@@ -48,6 +48,7 @@ import {
48
48
  import type { XdevMountedState } from "../tools/xdev";
49
49
  import { isFramedBlockComponent, markFramedBlockComponent, renderStatusLine, WidthAwareText } from "../render/index";
50
50
  import { cachedPngConversion, convertImageToPngShared, imagePayloadKey } from "./image-loading";
51
+ import { FenceFigure } from "./fence-figure";
51
52
  import { sanitizeWithOptionalSixelPassthrough } from "../render/sixel";
52
53
  import { renderDiff } from "../chrome/diff";
53
54
  import { type AnimationFrame, trimBlankEdges } from "../chrome/transcript-container";
@@ -301,6 +302,8 @@ export class ToolExecutionComponent extends Container {
301
302
  #multiFileBoxes: (Box | Spacer)[] = []; // Extra boxes for multi-file edit results
302
303
  #imageComponents: Image[] = [];
303
304
  #imageSpacers: Spacer[] = [];
305
+ /** The renderer's {@link ToolRenderer.figure} drawing; kept across rebuilds while its language is unchanged, so it redraws in place and its raster survives them. */
306
+ #figure: FenceFigure | undefined;
304
307
  readonly #instanceId = ++toolExecutionInstanceSeq;
305
308
  #toolName: string;
306
309
  #toolLabel: string;
@@ -759,6 +762,11 @@ export class ToolExecutionComponent extends Container {
759
762
  }
760
763
  }
761
764
 
765
+ /** Whether the result's figure still waits for its raster; the transcript holds retirement meanwhile. */
766
+ isTranscriptBlockPending(): boolean {
767
+ return this.#toolActivityVisible && this.#figure?.pending === true;
768
+ }
769
+
762
770
  /**
763
771
  * Whether this block is ready to retire as immutable history. Partial
764
772
  * results, including detached background tasks, remain active and mutable
@@ -941,11 +949,13 @@ export class ToolExecutionComponent extends Container {
941
949
 
942
950
  /**
943
951
  * The native `collapsed` prop: the transcript's expand state, except that a
944
- * call streaming its body (edit, write) stays open until it settles, then
952
+ * view that asks to be {@link NativeToolView.open} never starts folded, and
953
+ * a call streaming its body (edit, write) stays open until it settles, then
945
954
  * folds like a finished thought. The user's own toggle still wins meanwhile:
946
955
  * the terminal keeps local collapse state until this prop changes.
947
956
  */
948
- #nativeCollapsed(status: TspCardStatus): boolean {
957
+ #nativeCollapsed(status: TspCardStatus, view: NativeToolView): boolean {
958
+ if (view.open) return false;
949
959
  const live = status === "pending" || status === "running";
950
960
  return !this.#expanded && !(live && streamsBody(this.#toolName));
951
961
  }
@@ -1008,7 +1018,7 @@ export class ToolExecutionComponent extends Container {
1008
1018
  intent: typeof intent === "string" && intent ? plainText(intent) : undefined,
1009
1019
  frame: inline ? "inline" : "card",
1010
1020
  collapsible: hasBody,
1011
- collapsed: hasBody ? this.#nativeCollapsed(status) : undefined,
1021
+ collapsed: hasBody ? this.#nativeCollapsed(status, view) : undefined,
1012
1022
  preview: hasBody ? (preview === "auto" ? { lines: DEFAULT_TERMINAL_PREVIEW_LINES } : preview) : undefined,
1013
1023
  tools: view.tools,
1014
1024
  tone: view.tone,
@@ -1076,7 +1086,7 @@ export class ToolExecutionComponent extends Container {
1076
1086
  tone: view.tone ?? NATIVE_STATUS_TONE[status],
1077
1087
  status,
1078
1088
  collapsible: hasBody,
1079
- collapsed: hasBody ? this.#nativeCollapsed(status) : undefined,
1089
+ collapsed: hasBody ? this.#nativeCollapsed(status, view) : undefined,
1080
1090
  preview: hasBody ? cardPreview(view.preview) : undefined,
1081
1091
  },
1082
1092
  children,
@@ -1132,6 +1142,7 @@ export class ToolExecutionComponent extends Container {
1132
1142
  body: [...(callView.body ?? []), ...(resultView.body ?? [])],
1133
1143
  tone: resultView.tone ?? callView.tone,
1134
1144
  preview: resultView.preview ?? callView.preview,
1145
+ open: resultView.open ?? callView.open,
1135
1146
  inline: resultView.inline ?? callView.inline,
1136
1147
  tools: resultView.tools ?? callView.tools,
1137
1148
  };
@@ -1611,6 +1622,7 @@ export class ToolExecutionComponent extends Container {
1611
1622
  this.removeChild(spacer);
1612
1623
  }
1613
1624
  this.#imageSpacers = [];
1625
+ this.#syncFigure();
1614
1626
 
1615
1627
  if (this.#result) {
1616
1628
  const imageBlocks = this.#getAllImageBlocks();
@@ -1654,6 +1666,43 @@ export class ToolExecutionComponent extends Container {
1654
1666
  this.#renderedImageCount = this.#imageComponents.length;
1655
1667
  }
1656
1668
 
1669
+ /**
1670
+ * Mount the built-in renderer's figure under the card and feed it the fence
1671
+ * as it stands: the mounted one stays while its language is unchanged, so it
1672
+ * redraws in place as the args stream.
1673
+ */
1674
+ #syncFigure(): void {
1675
+ const result = this.#result;
1676
+ const renderer = this.#tool?.renderCall || this.#tool?.renderResult ? undefined : this.#renderer;
1677
+ const fence =
1678
+ this.#isBenignSkip() || isNativeRendering()
1679
+ ? undefined
1680
+ : renderer?.figure?.(
1681
+ this.#args,
1682
+ result && { content: result.content, details: result.details, isError: result.isError },
1683
+ this.#renderState,
1684
+ );
1685
+ const drawable = fence !== undefined && FenceFigure.draws(fence, { showImages: this.#showImages });
1686
+ let figure = this.#figure;
1687
+ if (figure && (!drawable || figure.lang !== fence.lang)) {
1688
+ this.removeChild(figure);
1689
+ figure.dispose();
1690
+ figure = this.#figure = undefined;
1691
+ }
1692
+ if (!drawable) return;
1693
+ if (!figure) {
1694
+ figure = this.#figure = new FenceFigure(fence.lang, {
1695
+ budget: this.#ui.imageBudget,
1696
+ onChange: () => {
1697
+ this.#blockVersion++;
1698
+ this.#ui.requestRender();
1699
+ },
1700
+ });
1701
+ this.addChild(figure);
1702
+ }
1703
+ figure.update(fence);
1704
+ }
1705
+
1657
1706
  #getCallArgsForRender(): unknown {
1658
1707
  const renderArgs = this.#args;
1659
1708
  if (!isEditLikeToolName(this.#toolName)) {
@@ -14,7 +14,7 @@ import { BracketedPasteHandler, decodeReencodedPasteControls } from "../brackete
14
14
  import { canonicalKeyId, getKeybindings, type KeybindingsManager } from "../keybindings";
15
15
  import { extractPrintableText, matchesKey, parseKey } from "../keys";
16
16
  import { KillRing } from "../kill-ring";
17
- import type { TspEditorDecoration, TspEditorProps, TspTone } from "@oh-my-pi/pi-wire";
17
+ import type { TspEditorDecoration, TspEditorProps, TspText, TspTone } from "@oh-my-pi/pi-wire";
18
18
  import { col, node } from "../native/describe";
19
19
  import { sameItems, sameProps } from "../native/memo";
20
20
  import { plainText } from "../native/spans";
@@ -625,8 +625,11 @@ export class Editor implements Component, Focusable {
625
625
  * dialog's text field; only the prompt composer claims `omp.editor`).
626
626
  */
627
627
  describeLayout: ((input: NativeNode, cx: DescribeContext) => NativeEditorLayout) | undefined;
628
- /** TSP placeholder for the empty buffer; natively it replaces the rotating ANSI {@link placeholder} hints. */
629
- describePlaceholder: (() => string) | undefined;
628
+ /**
629
+ * TSP placeholder for the empty buffer; natively it replaces the rotating ANSI {@link placeholder} hints.
630
+ * Return a stable value (the same spans array) while it is unchanged: the editor node is reused by identity.
631
+ */
632
+ describePlaceholder: (() => TspText) | undefined;
630
633
  #promptGutter: string | undefined;
631
634
  /** Bumped by {@link invalidate}: host-side decoration inputs (spelling results, settings) changed. */
632
635
  #nativeGeneration = 0;
@@ -713,6 +716,7 @@ export class Editor implements Component, Focusable {
713
716
 
714
717
  // Host-registered atomic chip tokens: exact buffer label → expansion emitted on submit.
715
718
  #atoms: Map<string, string> = new Map();
719
+ #atomsRevision = 0;
716
720
 
717
721
  /** Optional pattern matching atomic placeholder tokens (e.g. `[Image #1, 800x600]` or
718
722
  * `[Paste #2, +30 lines]`) that the editor treats as indivisible: a backspace or forward-delete
@@ -1025,6 +1029,7 @@ export class Editor implements Component, Focusable {
1025
1029
  this.#pastes.clear();
1026
1030
  this.#pasteCounter = 0;
1027
1031
  this.#atoms.clear();
1032
+ this.#atomsRevision++;
1028
1033
  this.#historyDraftActive = false;
1029
1034
  }
1030
1035
 
@@ -1064,6 +1069,7 @@ export class Editor implements Component, Focusable {
1064
1069
  if (entry?.draft || this.#historyDraftActive) {
1065
1070
  this.#pastes = new Map(entry?.draft?.pastes);
1066
1071
  this.#atoms = new Map(entry?.draft?.atoms);
1072
+ this.#atomsRevision++;
1067
1073
  this.#pasteCounter = entry?.draft?.pasteCounter ?? 0;
1068
1074
  this.restoreHistoryState(entry?.draft?.restore);
1069
1075
  this.#historyDraftActive = entry?.draft !== undefined;
@@ -2661,6 +2667,11 @@ export class Editor implements Component, Focusable {
2661
2667
  return this.#textRevision;
2662
2668
  }
2663
2669
 
2670
+ /** Monotonic atom-table revision for render caches: advances whenever {@link atoms} is mutated or replaced. */
2671
+ get atomsRevision(): number {
2672
+ return this.#atomsRevision;
2673
+ }
2674
+
2664
2675
  #notifyChange(text?: string): void {
2665
2676
  this.#textRevision++;
2666
2677
  this.onChange?.(text ?? this.getText());
@@ -2699,6 +2710,7 @@ export class Editor implements Component, Focusable {
2699
2710
  * it — for hosts that re-collapse restored draft text via {@link setText}. */
2700
2711
  registerAtom(label: string, expansion: string): void {
2701
2712
  this.#atoms.set(label, expansion);
2713
+ this.#atomsRevision++;
2702
2714
  }
2703
2715
 
2704
2716
  /** Insert `label` (plus a trailing space) at the cursor and register it as an atom expanding
@@ -2735,6 +2747,7 @@ export class Editor implements Component, Focusable {
2735
2747
  /** Drop every registered atom expansion (draft cleared or replaced by the host). */
2736
2748
  clearAtoms(): void {
2737
2749
  this.#atoms.clear();
2750
+ this.#atomsRevision++;
2738
2751
  }
2739
2752
 
2740
2753
  /**