@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
@@ -206,6 +206,7 @@ export class TranscriptContainer extends Container {
206
206
  */
207
207
  #frameRows = new Map<TranscriptEntry, readonly string[]>();
208
208
  #frameRowsWidth = 0;
209
+ #releaseFailures = new WeakSet<Component>();
209
210
  /** The `children` array `#entries` last mirrored; see {@link #syncEntries}. */
210
211
  #syncedChildren: Component[] | undefined;
211
212
  /** Forces the next {@link #syncEntries} to compare every entry, not just the live tail. */
@@ -583,7 +584,10 @@ export class TranscriptContainer extends Container {
583
584
  popLoopPhase();
584
585
  }
585
586
  this.#replayPending = false;
586
- if (rows.length === 0) return undefined;
587
+ if (rows.length === 0) {
588
+ this.#releaseCommittedRenderCaches();
589
+ return undefined;
590
+ }
587
591
  const batch: HistoryBatch = { id: this.#nextBatchId++, rows, kind: "replay" };
588
592
  this.#offered = { batch, kind: "replay" };
589
593
  return batch;
@@ -747,6 +751,8 @@ export class TranscriptContainer extends Container {
747
751
  this.#retireEntry(this.#entries[index]!);
748
752
  }
749
753
  this.#frontier = offered.end;
754
+ } else {
755
+ this.#releaseCommittedRenderCaches();
750
756
  }
751
757
  this.#offered = undefined;
752
758
  if (this.#replayRequested) this.#startReplay();
@@ -766,6 +772,7 @@ export class TranscriptContainer extends Container {
766
772
  const entry = this.#entries[index]!;
767
773
  this.#setAllocation(entry.component, Number.MAX_SAFE_INTEGER, this.#lastFrame);
768
774
  const block = trimBlankEdges(entry.component.render(width));
775
+ if (entry.state === "committed") this.#releaseRenderCaches(entry);
769
776
  if (block.length === 0) continue;
770
777
  if (rows.length > 0) rows.unshift("");
771
778
  rows.unshift(...block);
@@ -813,6 +820,7 @@ export class TranscriptContainer extends Container {
813
820
  for (const entry of this.#entries) {
814
821
  this.#setAllocation(entry.component, Number.MAX_SAFE_INTEGER, this.#lastFrame);
815
822
  const block = this.#renderEntry(entry, width);
823
+ if (entry.state === "committed") this.#releaseRenderCaches(entry);
816
824
  if (block.length === 0) continue;
817
825
  if (rows.length > 0) rows.push("");
818
826
  this.#childStartRows.set(entry.component, rows.length);
@@ -1054,6 +1062,29 @@ export class TranscriptContainer extends Container {
1054
1062
  entry.stableRows = EMPTY_STABLE_ROWS;
1055
1063
  entry.renderedStableByWidth = new Map();
1056
1064
  entry.stableRowCountByWidth = new Map();
1065
+ this.#frameRows.delete(entry);
1066
+ this.#releaseRenderCaches(entry);
1067
+ }
1068
+
1069
+ #releaseCommittedRenderCaches(): void {
1070
+ for (let index = 0; index < this.#frontier; index++) {
1071
+ this.#releaseRenderCaches(this.#entries[index]!);
1072
+ }
1073
+ }
1074
+
1075
+ #releaseRenderCaches(entry: TranscriptEntry): void {
1076
+ const release = entry.component.releaseRenderCaches;
1077
+ if (release === undefined) return;
1078
+ try {
1079
+ release.call(entry.component);
1080
+ } catch (err) {
1081
+ if (this.#releaseFailures.has(entry.component)) return;
1082
+ this.#releaseFailures.add(entry.component);
1083
+ logger.warn("Transcript block failed to release render caches", {
1084
+ component: entry.component.constructor.name,
1085
+ error: String(err),
1086
+ });
1087
+ }
1057
1088
  }
1058
1089
 
1059
1090
  #startReplay(): void {
@@ -135,6 +135,11 @@ export class Box implements Component {
135
135
  }
136
136
  }
137
137
 
138
+ releaseRenderCaches(): void {
139
+ this.#invalidateCache();
140
+ for (const child of this.children) child.releaseRenderCaches?.();
141
+ }
142
+
138
143
  /**
139
144
  * A `card` when the box has a border or background: the background token
140
145
  * picks tone/role (user message, tool state, selection), a border colour
@@ -100,6 +100,14 @@ export class Disclosure implements Component {
100
100
  this.#body?.invalidate?.();
101
101
  }
102
102
 
103
+ releaseRenderCaches(): void {
104
+ this.#cache = undefined;
105
+ this.#boundedCache = undefined;
106
+ this.#summary?.releaseRenderCaches?.();
107
+ this.#collapsedBody?.releaseRenderCaches?.();
108
+ this.#body?.releaseRenderCaches?.();
109
+ }
110
+
103
111
  dispose(): void {
104
112
  if (this.#disposed) return;
105
113
  this.#disposed = true;
@@ -10,8 +10,7 @@ import {
10
10
  renderImage,
11
11
  TERMINAL,
12
12
  } from "../terminal-capabilities";
13
- import { registerNativeBlob } from "../native/blobs";
14
- import { node } from "../native/describe";
13
+ import { nativeImageNode } from "../native/blobs";
15
14
  import type { DescribeContext, NativeNode } from "../native/node";
16
15
  import type { Component } from "../tui";
17
16
 
@@ -826,6 +825,11 @@ export class Image implements Component {
826
825
  this.#cachedWidth = undefined;
827
826
  }
828
827
 
828
+ releaseRenderCaches(): void {
829
+ this.#cachedLines = undefined;
830
+ this.#cachedWidth = undefined;
831
+ }
832
+
829
833
  /**
830
834
  * SIXEL sequence for a target size. A new size starts the encode off the JS
831
835
  * thread and answers `undefined` until it settles; the settled encode
@@ -865,11 +869,9 @@ export class Image implements Component {
865
869
  */
866
870
  describe(_cx: DescribeContext): NativeNode {
867
871
  if (this.#native) return this.#native;
868
- const blob = registerNativeBlob(Buffer.from(this.#base64Data, "base64"), this.#mimeType);
869
872
  const maxW = this.#options.maxWidthCells;
870
873
  const maxH = this.#options.maxHeightCells;
871
- this.#native = node("image", {
872
- blob,
874
+ this.#native = nativeImageNode(Buffer.from(this.#base64Data, "base64"), this.#mimeType, {
873
875
  alt: imageFallback(this.#mimeType, this.#dimensions, this.#options.filename),
874
876
  w: this.#dimensions.widthPx,
875
877
  h: this.#dimensions.heightPx,
@@ -54,6 +54,10 @@ export class KeyValueList implements Component {
54
54
  this.#native = undefined;
55
55
  }
56
56
 
57
+ releaseRenderCaches(): void {
58
+ this.#cache = undefined;
59
+ }
60
+
57
61
  /** An aligned `kv` grid; label width and value wrapping are the terminal's. */
58
62
  describe(_cx: DescribeContext): NativeNode {
59
63
  this.#native ??= node("kv", {
@@ -168,6 +168,13 @@ export class Row implements Component, MouseRoutable {
168
168
  }
169
169
  }
170
170
 
171
+ releaseRenderCaches(): void {
172
+ this.#memo = undefined;
173
+ for (const child of uniqueLayoutComponents(this.#children.map(item => item.content))) {
174
+ child.releaseRenderCaches?.();
175
+ }
176
+ }
177
+
171
178
  dispose(): void {
172
179
  for (const child of uniqueLayoutComponents(this.#children.map(item => item.content))) {
173
180
  child.dispose?.();
@@ -140,6 +140,13 @@ export class Stack implements Component, MouseRoutable {
140
140
  }
141
141
  }
142
142
 
143
+ releaseRenderCaches(): void {
144
+ this.#memo = undefined;
145
+ for (const child of uniqueLayoutComponents(this.#children.map(item => item.content))) {
146
+ child.releaseRenderCaches?.();
147
+ }
148
+ }
149
+
143
150
  dispose(): void {
144
151
  for (const child of uniqueLayoutComponents(this.#children.map(item => item.content))) {
145
152
  child.dispose?.();
@@ -2016,6 +2016,14 @@ export class Markdown implements Component {
2016
2016
  this.#cachedLines = undefined;
2017
2017
  }
2018
2018
 
2019
+ releaseRenderCaches(): void {
2020
+ this.invalidate();
2021
+ this.#dropStreamPrefix();
2022
+ this.#streamingHighlightCache = undefined;
2023
+ this.#fastTail = undefined;
2024
+ this.#lastTailCapture = undefined;
2025
+ }
2026
+
2019
2027
  /**
2020
2028
  * The unrendered source as an `md` node, `stream` while the streaming
2021
2029
  * (transient) cache is on. Append-only growth yields a fresh node of the
@@ -408,6 +408,11 @@ export class ScrollView implements Component {
408
408
  this.#child?.invalidate?.();
409
409
  }
410
410
 
411
+ releaseRenderCaches(): void {
412
+ this.#cache = undefined;
413
+ this.#child?.releaseRenderCaches?.();
414
+ }
415
+
411
416
  /** Permanently release a wrapped child's timers, subscriptions, and other resources. */
412
417
  dispose(): void {
413
418
  if (this.#disposed) return;
@@ -51,6 +51,11 @@ export class Section implements Component {
51
51
  this.#cache = undefined;
52
52
  }
53
53
 
54
+ releaseRenderCaches(): void {
55
+ this.#cache = undefined;
56
+ if (isComponent(this.#options.body)) this.#options.body.releaseRenderCaches?.();
57
+ }
58
+
54
59
  dispose(): void {
55
60
  if (this.#disposed) return;
56
61
  this.#disposed = true;
@@ -117,6 +117,10 @@ export class Table implements Component {
117
117
  this.#native = undefined;
118
118
  }
119
119
 
120
+ releaseRenderCaches(): void {
121
+ this.#cache = undefined;
122
+ }
123
+
120
124
  /**
121
125
  * A native `table`. Column priorities carry over unchanged (lower gives
122
126
  * way first); truncating columns truncate at the end, the rest keep
@@ -109,6 +109,13 @@ export class Text implements Component {
109
109
  this.#cachedLines = undefined;
110
110
  }
111
111
 
112
+ releaseRenderCaches(): void {
113
+ this.#cachedText = undefined;
114
+ this.#cachedWidth = undefined;
115
+ this.#cachedWidthConfigEpoch = undefined;
116
+ this.#cachedLines = undefined;
117
+ }
118
+
112
119
  /**
113
120
  * Wrapped text; styling re-derived from the styled source so a theme swap
114
121
  * re-resolves tokens. A background fill becomes the node's tone.
@@ -41,6 +41,11 @@ export class TruncatedText implements Component {
41
41
  this.#cachedLines = undefined;
42
42
  }
43
43
 
44
+ releaseRenderCaches(): void {
45
+ this.#cachedWidth = -1;
46
+ this.#cachedLines = undefined;
47
+ }
48
+
44
49
  /** First line only, clamped to one visual line with an end ellipsis. */
45
50
  describe(_cx: DescribeContext): NativeNode {
46
51
  if (this.#native) return this.#native;
@@ -305,8 +305,8 @@ export class NativeBackend {
305
305
  #paletteKey: string | undefined;
306
306
  /** Serialized palette last sent, to skip resends of an unchanged theme. */
307
307
  #paletteSent: string | undefined;
308
- /** Blobs handled on this connection: asked about in a `blobs` query, or sent (or held by the terminal). */
309
- #blobs = new Map<string, "asked" | "sent">();
308
+ /** Pending delivery owns bytes until sent or confirmed held; settled descriptions may already be released. */
309
+ #blobs = new Map<string, NativeBlob | "sent">();
310
310
  /** `blobs` queries sent, oldest first: the terminal answers each once, in order. */
311
311
  #blobQueries: BlobQuery[] = [];
312
312
  /** The next pass with new blobs asks the terminal which it holds (a fresh connection). */
@@ -663,13 +663,13 @@ export class NativeBackend {
663
663
  }
664
664
  const dir = Bun.env.TERN_BLOB_DIR;
665
665
  if (dir) {
666
- for (const blob of fresh) this.#blobs.set(blob.id, "asked");
666
+ for (const blob of fresh) this.#blobs.set(blob.id, blob);
667
667
  void this.#cacheBlobs(dir, fresh);
668
668
  return;
669
669
  }
670
670
  if (this.#askHeld) {
671
671
  this.#askHeld = false;
672
- this.#askBlobs(fresh.map(blob => blob.id));
672
+ this.#askBlobs(fresh);
673
673
  return;
674
674
  }
675
675
  for (const blob of fresh) this.#sendBlob(blob);
@@ -680,13 +680,14 @@ export class NativeBackend {
680
680
  const epoch = this.#blobEpoch;
681
681
  await Promise.all(blobs.map(blob => cacheBlob(dir, blob)));
682
682
  if (epoch !== this.#blobEpoch) return;
683
- const ids = blobs.map(blob => blob.id).filter(id => this.#blobs.get(id) === "asked");
684
- if (ids.length > 0) this.#askBlobs(ids);
683
+ const pending = blobs.filter(blob => this.#blobs.get(blob.id) === blob);
684
+ if (pending.length > 0) this.#askBlobs(pending);
685
685
  }
686
686
 
687
- /** Ask the terminal which of `ids` it holds; the reply (or its absence) settles them. */
688
- #askBlobs(ids: readonly string[]): void {
689
- for (const id of ids) this.#blobs.set(id, "asked");
687
+ /** Ask the terminal which of `blobs` it holds; the reply (or its absence) settles them. */
688
+ #askBlobs(blobs: readonly NativeBlob[]): void {
689
+ for (const blob of blobs) this.#blobs.set(blob.id, blob);
690
+ const ids = blobs.map(blob => blob.id);
690
691
  const query: BlobQuery = { ids, timer: undefined };
691
692
  query.timer = this.#scheduler.scheduleRender(() => {
692
693
  query.timer = undefined;
@@ -707,16 +708,16 @@ export class NativeBackend {
707
708
  #onBlobsReply(have: readonly string[]): void {
708
709
  const query = this.#blobQueries.shift();
709
710
  for (const id of have) {
710
- if (this.#blobs.get(id) !== "asked") continue;
711
- this.#blobs.set(id, "sent");
711
+ const blob = this.#blobs.get(id);
712
+ if (blob === undefined || blob === "sent") continue;
712
713
  // The bytes went another way; a recording still carries them so a replay without them shows the image.
713
- const blob = this.#recordPath ? getNativeBlob(id) : undefined;
714
- if (blob) {
714
+ if (this.#recordPath) {
715
715
  const body = Buffer.from(blob.bytes.buffer, blob.bytes.byteOffset, blob.bytes.byteLength).toString(
716
716
  "base64",
717
717
  );
718
718
  this.#record("out", "b", blobParams(blob), body);
719
719
  }
720
+ this.#blobs.set(id, "sent");
720
721
  }
721
722
  if (!query?.timer) return;
722
723
  query.timer.cancel();
@@ -727,8 +728,8 @@ export class NativeBackend {
727
728
  /** Send inline every id of `ids` still waiting on a query. */
728
729
  #sendMissing(ids: readonly string[]): void {
729
730
  for (const id of ids) {
730
- const blob = this.#blobs.get(id) === "asked" ? getNativeBlob(id) : undefined;
731
- if (blob) this.#sendBlob(blob);
731
+ const blob = this.#blobs.get(id);
732
+ if (blob !== undefined && blob !== "sent") this.#sendBlob(blob);
732
733
  }
733
734
  }
734
735
 
@@ -18,7 +18,13 @@ export interface NativeBlob {
18
18
  readonly bytes: Uint8Array;
19
19
  }
20
20
 
21
- const blobs = new Map<string, NativeBlob>();
21
+ const blobs = new Map<string, WeakRef<NativeBlob>>();
22
+ const registeredBlobs = new Map<string, NativeBlob>();
23
+ const releasedBlobs = new FinalizationRegistry<{ id: string; reference: WeakRef<NativeBlob> }>(({ id, reference }) => {
24
+ if (blobs.get(id) === reference) blobs.delete(id);
25
+ });
26
+ // Props spreads preserve ownership; wire serialization omits symbol keys.
27
+ const kBlob = Symbol("native.blob");
22
28
  // Registration usually repeats with the same bytes object on every describe;
23
29
  // the tag skips rehashing it.
24
30
  const kBlobId = Symbol("native.blobId");
@@ -27,19 +33,42 @@ interface BlobTagged {
27
33
  [kBlobId]?: string;
28
34
  }
29
35
 
30
- /** Register `bytes` and return their content address (sha256 hex) for `image.p.blob`. */
31
- export function registerNativeBlob(bytes: Uint8Array, mime: string): string {
36
+ function getOrCreateBlob(bytes: Uint8Array, mime: string): NativeBlob {
32
37
  const known = (bytes as BlobTagged)[kBlobId];
33
- if (known !== undefined) return known;
34
- const id = new Bun.CryptoHasher("sha256").update(bytes).digest("hex");
38
+ const id = known ?? new Bun.CryptoHasher("sha256").update(bytes).digest("hex");
35
39
  (bytes as BlobTagged)[kBlobId] = id;
36
- if (!blobs.has(id)) blobs.set(id, { id, mime, bytes });
37
- return id;
40
+ let blob = blobs.get(id)?.deref();
41
+ if (!blob) {
42
+ blob = { id, mime, bytes };
43
+ const reference = new WeakRef(blob);
44
+ blobs.set(id, reference);
45
+ releasedBlobs.register(blob, { id, reference });
46
+ }
47
+ return blob;
48
+ }
49
+
50
+ /** Register bytes for process-lifetime, id-only use. Prefer {@link nativeImageNode} for owned images. */
51
+ export function registerNativeBlob(bytes: Uint8Array, mime: string): string {
52
+ const blob = getOrCreateBlob(bytes, mime);
53
+ registeredBlobs.set(blob.id, blob);
54
+ return blob.id;
38
55
  }
39
56
 
40
57
  /** A registered blob by id. */
41
58
  export function getNativeBlob(id: string): NativeBlob | undefined {
42
- return blobs.get(id);
59
+ return blobs.get(id)?.deref();
60
+ }
61
+
62
+ /** An image whose props own its bytes, including while hidden, unmounted, or awaiting upload. */
63
+ export function nativeImageNode(
64
+ bytes: Uint8Array,
65
+ mime: string,
66
+ p?: Omit<TspProps<"image">, "blob" | "builtin">,
67
+ key?: string,
68
+ ): NativeNode {
69
+ const blob = getOrCreateBlob(bytes, mime);
70
+ const props = { ...p, blob: blob.id, [kBlob]: blob };
71
+ return node("image", props, undefined, key);
43
72
  }
44
73
 
45
74
  /**
@@ -53,9 +82,13 @@ export function base64ImageNode(
53
82
  p?: Omit<TspProps<"image">, "blob" | "builtin" | "w" | "h">,
54
83
  key?: string,
55
84
  ): NativeNode {
56
- const blob = registerNativeBlob(Buffer.from(data, "base64"), mimeType);
57
85
  const size = getImageDimensions(data, mimeType);
58
- return node("image", size ? { ...p, blob, w: size.widthPx, h: size.heightPx } : { ...p, blob }, undefined, key);
86
+ return nativeImageNode(
87
+ Buffer.from(data, "base64"),
88
+ mimeType,
89
+ size ? { ...p, w: size.widthPx, h: size.heightPx } : p,
90
+ key,
91
+ );
59
92
  }
60
93
 
61
94
  /** `image` nodes for base64 payloads, one per slot key, rebuilt only when the slot's payload changes. */
@@ -30,6 +30,7 @@
30
30
  import * as logger from "@oh-my-pi/pi-utils/logger";
31
31
  import { TSP_TEXT_KINDS, type TspKind, type TspNode, type TspOp, type TspScrollBy } from "@oh-my-pi/pi-wire";
32
32
  import { type Component, Container, CURSOR_MARKER } from "../tui";
33
+ import { getNativeBlob } from "./blobs";
33
34
  import { normalizeIconProps } from "./icons";
34
35
  import type { DescribeContext, NativeChild, NativeNode, NativeRevealAt } from "./node";
35
36
  import { isNativeSettled } from "./settle";
@@ -291,6 +292,7 @@ export class Reconciler {
291
292
  this.#prevPortals = portals;
292
293
  this.#regions = next;
293
294
  for (const entry of this.#dels) this.#deleteEntry(entry);
295
+ this.#dels = [];
294
296
  // Parked to survive a parent/child swap but absent from the new description.
295
297
  for (const state of this.#parked) {
296
298
  if (!state.parked) continue;
@@ -711,6 +713,8 @@ export class Reconciler {
711
713
  owner: Owner,
712
714
  entries: readonly Entry[],
713
715
  ): Readonly<Record<string, unknown>> | undefined {
716
+ // Dereferencing pins the blob through this synchronous reconcile/upload job, even after settling drops its node.
717
+ if (node.k === "image" && node.p?.blob) getNativeBlob(node.p.blob);
714
718
  return normalizeIconProps(node.k, this.#resolveRefs(node, owner, entries));
715
719
  }
716
720
 
@@ -525,12 +525,12 @@ class SessionList<T extends SessionSelectorEntry> implements Component {
525
525
  readonly #getCurrentSessionPath: () => string | undefined;
526
526
  readonly #historyMatcher?: SessionHistoryMatcher;
527
527
  #historyMergeTimer: NodeJS.Timeout | undefined;
528
- /** Re-render hook for async list updates (fuzzy scan chunks, history merge). */
528
+ /** Re-render hook for async list updates (fuzzy scan completion, history merge). */
529
529
  onRequestRender?: () => void;
530
530
 
531
531
  // ── Incremental search state ──────────────────────────────────────────
532
532
  // The menu's visible list is always composed from these three inputs (see
533
- // #composeFiltered), so late-arriving fuzzy chunks and the debounced
533
+ // #composeFiltered), so a late-finishing fuzzy scan and the debounced
534
534
  // history merge can land in any order without clobbering each other.
535
535
  /** Recency-ranked sessions whose text contains every query token verbatim. */
536
536
  #literalRanked: RankedSessionMatch<T>[] = [];
@@ -543,8 +543,8 @@ class SessionList<T extends SessionSelectorEntry> implements Component {
543
543
  #scanTimer: NodeJS.Timeout | undefined;
544
544
  /**
545
545
  * True once the user moved the selection for the current query; blocks the
546
- * history merge from reordering the list under their cursor. (Fuzzy chunks
547
- * only append below the literal group, which never shifts existing rows.)
546
+ * history merge from reordering the list under their cursor. (The fuzzy scan
547
+ * only appends below the literal group, which never shifts existing rows.)
548
548
  */
549
549
  #selectionMoved = false;
550
550
  /** True after a nonempty query; empty refilter restores current only then. */
@@ -687,38 +687,48 @@ class SessionList<T extends SessionSelectorEntry> implements Component {
687
687
  // Fuzzy pass: building a fuzzy index per session is too expensive to run
688
688
  // across a huge listing inside one keystroke, so scan a bounded slice now
689
689
  // and spill the remainder into async chunks.
690
- this.#scanFuzzySlice(this.#scanGeneration, tokens, rest, 0, FUZZY_SCAN_INLINE_COUNT);
690
+ const inlineEnd = Math.min(rest.length, FUZZY_SCAN_INLINE_COUNT);
691
+ this.#scoreFuzzy(tokens, rest, 0, inlineEnd);
691
692
  this.#composeFiltered();
693
+ if (inlineEnd < rest.length) {
694
+ this.#scheduleFuzzyScan(this.#scanGeneration, tokens, rest, inlineEnd, this.#fuzzyRanked.length);
695
+ }
692
696
  // New query rebuilds ranking from scratch. Same-query refilter (delete)
693
- // and async compose (fuzzy chunks / history merge) only clamp so an
697
+ // and async compose (fuzzy scan / history merge) only clamp so an
694
698
  // arrow selection survives. A live-session index > 0 would otherwise
695
699
  // land on a lower-ranked match after the first keystroke.
696
700
  if (queryChanged) this.#menu.setSelectedIndex(0);
697
701
  this.#scheduleHistoryMerge(query);
698
702
  }
699
703
 
700
- /**
701
- * Score up to `budget` sessions from `rest[start..]` (indexes into the
702
- * unfiltered list), then schedule the remainder on a macrotask so pending
703
- * input events run first. Chunks that added matches recompose the visible
704
- * list and request a render; a stale generation aborts silently.
705
- */
706
- #scanFuzzySlice(generation: number, tokens: string[], rest: number[], start: number, budget: number): void {
704
+ /** Fuzzy-score `rest[start..end)` (indexes into the unfiltered list) into {@link #fuzzyRanked}. */
705
+ #scoreFuzzy(tokens: string[], rest: number[], start: number, end: number): void {
707
706
  const all = this.#allSessions;
708
- const end = Math.min(rest.length, start + budget);
709
707
  for (let i = start; i < end; i++) {
710
708
  const index = rest[i]!;
711
709
  const session = all[index]!;
712
710
  const match = scoreFuzzySession(session, index, tokens, new FuzzyText(sessionTextLower(session)));
713
711
  if (match) this.#fuzzyRanked.push(match);
714
712
  }
715
- if (end >= rest.length) return;
713
+ }
714
+
715
+ /**
716
+ * Score `rest[start..]` in {@link FUZZY_SCAN_CHUNK_COUNT}-session macrotask
717
+ * chunks so pending input runs between them, then recompose the visible
718
+ * list once if the scan matched more than the `shown` fuzzy hits already
719
+ * composed. Publishing per chunk reorders the list dozens of times per
720
+ * keystroke on a large listing, and the native picker replays its
721
+ * row-arrival animation on every reorder. A stale generation aborts silently.
722
+ */
723
+ #scheduleFuzzyScan(generation: number, tokens: string[], rest: number[], start: number, shown: number): void {
716
724
  this.#scanTimer = setTimeout(() => {
717
725
  this.#scanTimer = undefined;
718
726
  if (generation !== this.#scanGeneration) return;
719
- const before = this.#fuzzyRanked.length;
720
- this.#scanFuzzySlice(generation, tokens, rest, end, FUZZY_SCAN_CHUNK_COUNT);
721
- if (this.#fuzzyRanked.length > before) {
727
+ const end = Math.min(rest.length, start + FUZZY_SCAN_CHUNK_COUNT);
728
+ this.#scoreFuzzy(tokens, rest, start, end);
729
+ if (end < rest.length) {
730
+ this.#scheduleFuzzyScan(generation, tokens, rest, end, shown);
731
+ } else if (this.#fuzzyRanked.length > shown) {
722
732
  this.#composeFiltered();
723
733
  this.onRequestRender?.();
724
734
  }
@@ -740,7 +750,7 @@ class SessionList<T extends SessionSelectorEntry> implements Component {
740
750
  tokenizeSessionQuery(this.#searchInput.getValue()),
741
751
  this.#literalRanked,
742
752
  );
743
- // Async chunks and the history merge only clamp the cursor so an arrow
753
+ // The fuzzy scan and the history merge only clamp the cursor so an arrow
744
754
  // selection survives recomposition; the query path resets explicitly.
745
755
  const keepIndex = Math.min(this.#menu.selectedIndex, Math.max(0, composed.length - 1));
746
756
  this.#menu.setItems(composed, keepIndex >= 0 ? composed[keepIndex]?.path : undefined);
@@ -31,7 +31,7 @@ import { theme } from "../theme/theme";
31
31
  import sampleDoc from "./snapcompact-shape-preview-doc.md" with { type: "text" };
32
32
  import type { DescribeContext, NativeNode } from "../native/node";
33
33
  import { col, node, span, text } from "../native/describe";
34
- import { registerNativeBlob } from "../native/blobs";
34
+ import { nativeImageNode } from "../native/blobs";
35
35
 
36
36
  /** Mini-frame edge in px — a small page from the real rasterizer ≈ a zoomed crop. */
37
37
  const SRC_FRAME_PX = 128;
@@ -100,8 +100,7 @@ export class SnapcompactShapePreview implements Component {
100
100
  else if (entry.state === "rendering") sample = node("spinner", { label: [span("rendering sample…", "dim")] });
101
101
  else if (entry.state === "failed") sample = text([span("(sample render failed)", "dim")]);
102
102
  else {
103
- sample = node("image", {
104
- blob: registerNativeBlob(entry.bytes, "image/png"),
103
+ sample = nativeImageNode(entry.bytes, "image/png", {
105
104
  alt: `snapcompact ${label} sample`,
106
105
  w: entry.edgePx,
107
106
  h: entry.edgePx,
@@ -12,7 +12,7 @@ import {
12
12
  visibleWidth,
13
13
  } from "../index";
14
14
  import { fileHyperlink } from "../render/hyperlink";
15
- import { registerNativeBlob } from "../native/blobs";
15
+ import { nativeImageNode } from "../native/blobs";
16
16
  import { node, row, span } from "../native/describe";
17
17
  import { plainText } from "../native/spans";
18
18
  import type { DescribeContext, NativeNode } from "../native/node";
@@ -33,12 +33,9 @@ const RESET_FG = "\x1b[39m";
33
33
  * (pastes are usually re-encoded JPEG/WebP) convert before transmit — the same pipeline
34
34
  * the transcript uses. `null` = conversion in flight or failed. */
35
35
  const kImagePng = Symbol("omp.imagePng");
36
- /** Content address of the draft image's decoded bytes, registered once for TSP `image` nodes. */
37
- const kImageBlob = Symbol("omp.imageBlob");
38
36
 
39
37
  interface ImageContentWithPng extends ImageContent {
40
38
  [kImagePng]?: ImageContent | null;
41
- [kImageBlob]?: string;
42
39
  }
43
40
 
44
41
  /**
@@ -57,7 +54,9 @@ export class AttachmentChipsBand implements Component {
57
54
  private readonly requestRender: () => void,
58
55
  ) {}
59
56
 
60
- #native: { chips: readonly ComposerChipDescriptor[]; node: NativeNode } | undefined;
57
+ #native:
58
+ | { chips: readonly ComposerChipDescriptor[]; node: NativeNode; images: ReadonlyMap<ImageContent, NativeNode> }
59
+ | undefined;
61
60
 
62
61
  /**
63
62
  * A wrapping `row` of chip `card`s (`omp.composer.chip`) titled with the
@@ -69,6 +68,7 @@ export class AttachmentChipsBand implements Component {
69
68
  const chips = this.editor.composerChips();
70
69
  if (this.#native?.chips === chips) return this.#native.node;
71
70
  const cards: NativeNode[] = [];
71
+ const images = new Map<ImageContent, NativeNode>();
72
72
  for (const chip of chips) {
73
73
  const icon = theme.symbol(
74
74
  chip.kind === "paste" ? "chip.paste" : chip.kind === "video" ? "chip.video" : "chip.image",
@@ -86,11 +86,14 @@ export class AttachmentChipsBand implements Component {
86
86
  } else {
87
87
  const dims = this.#imageDims(chip.image);
88
88
  caption = dims ? `${dims.width}x${dims.height}` : "";
89
- const image = chip.image as ImageContentWithPng;
90
- const blob = image[kImageBlob] ?? registerNativeBlob(Buffer.from(image.data, "base64"), image.mimeType);
91
- image[kImageBlob] = blob;
89
+ const image = chip.image;
90
+ const native =
91
+ images.get(image) ??
92
+ this.#native?.images.get(image) ??
93
+ nativeImageNode(Buffer.from(image.data, "base64"), image.mimeType);
94
+ images.set(image, native);
92
95
  content = node("image", {
93
- blob,
96
+ ...native.p,
94
97
  alt: title,
95
98
  w: dims?.width,
96
99
  h: dims?.height,
@@ -106,7 +109,7 @@ export class AttachmentChipsBand implements Component {
106
109
  );
107
110
  }
108
111
  const described = row(cards, { gap: "sm", wrap: true, role: "omp.composer.chips", hidden: cards.length === 0 });
109
- this.#native = { chips, node: described };
112
+ this.#native = { chips, node: described, images };
110
113
  return described;
111
114
  }
112
115
 
@@ -302,6 +302,12 @@ export class OutputPane implements Component {
302
302
  this.#text.invalidate();
303
303
  }
304
304
 
305
+ releaseRenderCaches(): void {
306
+ this.#renderKey = "";
307
+ this.#text.setText("");
308
+ this.#text.releaseRenderCaches();
309
+ }
310
+
305
311
  #appendToTail(text: string): void {
306
312
  if (!text) return;
307
313
  if (this.#lines.length === 0) this.#lines.push("");