@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.
- package/CHANGELOG.md +18 -16
- package/README.md +7 -5
- package/dist/types/chat/assistant-message.d.ts +1 -0
- package/dist/types/chat/compaction-summary-message.d.ts +1 -0
- package/dist/types/chat/user-message.d.ts +2 -0
- package/dist/types/components/box.d.ts +1 -0
- package/dist/types/components/disclosure.d.ts +1 -0
- package/dist/types/components/image.d.ts +1 -0
- package/dist/types/components/key-value-list.d.ts +1 -0
- package/dist/types/components/layout/row.d.ts +1 -0
- package/dist/types/components/layout/stack.d.ts +1 -0
- package/dist/types/components/markdown.d.ts +1 -0
- package/dist/types/components/scroll-view.d.ts +1 -0
- package/dist/types/components/section.d.ts +1 -0
- package/dist/types/components/table.d.ts +1 -0
- package/dist/types/components/text.d.ts +1 -0
- package/dist/types/components/truncated-text.d.ts +1 -0
- package/dist/types/native/blobs.d.ts +3 -1
- package/dist/types/overlays/session-selector.d.ts +1 -1
- package/dist/types/render/output-pane.d.ts +1 -0
- package/dist/types/render/render-utils.d.ts +1 -0
- package/dist/types/render/tool-card.d.ts +6 -0
- package/dist/types/render/width-aware-text.d.ts +2 -0
- package/dist/types/terminal.d.ts +10 -2
- package/dist/types/tools/write.d.ts +1 -1
- package/dist/types/tools/yield.d.ts +22 -0
- package/dist/types/tui.d.ts +11 -0
- package/package.json +9 -9
- package/src/chat/advisor-message.ts +11 -0
- package/src/chat/assistant-message.ts +6 -0
- package/src/chat/compaction-summary-message.ts +8 -0
- package/src/chat/read-tool-group.ts +6 -4
- package/src/chat/skill-message.ts +6 -0
- package/src/chat/table-chart.ts +17 -20
- package/src/chat/tool-execution.ts +18 -0
- package/src/chat/user-message.ts +14 -0
- package/src/chrome/transcript-container.ts +32 -1
- package/src/components/box.ts +5 -0
- package/src/components/disclosure.ts +8 -0
- package/src/components/image.ts +7 -5
- package/src/components/key-value-list.ts +4 -0
- package/src/components/layout/row.ts +7 -0
- package/src/components/layout/stack.ts +7 -0
- package/src/components/markdown.ts +8 -0
- package/src/components/scroll-view.ts +5 -0
- package/src/components/section.ts +5 -0
- package/src/components/table.ts +4 -0
- package/src/components/text.ts +7 -0
- package/src/components/truncated-text.ts +5 -0
- package/src/native/backend.ts +16 -15
- package/src/native/blobs.ts +43 -10
- package/src/native/reconcile.ts +4 -0
- package/src/overlays/session-selector.ts +29 -19
- package/src/overlays/snapcompact-shape-preview.ts +2 -3
- package/src/prompt/attachment-chips.ts +13 -10
- package/src/render/output-pane.ts +6 -0
- package/src/render/render-utils.ts +11 -0
- package/src/render/tool-card.ts +15 -0
- package/src/render/width-aware-text.ts +5 -0
- package/src/terminal.ts +24 -3
- package/src/tools/bash.ts +10 -11
- package/src/tools/edit.ts +142 -120
- package/src/tools/fetch.ts +5 -6
- package/src/tools/index.ts +2 -0
- package/src/tools/mcp.ts +15 -2
- package/src/tools/read.ts +7 -5
- package/src/tools/write.ts +70 -56
- package/src/tools/yield.ts +225 -0
- 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)
|
|
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 {
|
package/src/components/box.ts
CHANGED
|
@@ -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;
|
package/src/components/image.ts
CHANGED
|
@@ -10,8 +10,7 @@ import {
|
|
|
10
10
|
renderImage,
|
|
11
11
|
TERMINAL,
|
|
12
12
|
} from "../terminal-capabilities";
|
|
13
|
-
import {
|
|
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 =
|
|
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;
|
package/src/components/table.ts
CHANGED
|
@@ -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
|
package/src/components/text.ts
CHANGED
|
@@ -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;
|
package/src/native/backend.ts
CHANGED
|
@@ -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
|
-
/**
|
|
309
|
-
#blobs = new Map<string,
|
|
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,
|
|
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
|
|
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
|
|
684
|
-
if (
|
|
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 `
|
|
688
|
-
#askBlobs(
|
|
689
|
-
for (const
|
|
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
|
-
|
|
711
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
|
package/src/native/blobs.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
37
|
-
|
|
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
|
|
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. */
|
package/src/native/reconcile.ts
CHANGED
|
@@ -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
|
|
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-
|
|
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. (
|
|
547
|
-
* only
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
720
|
-
this.#
|
|
721
|
-
if (
|
|
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
|
-
//
|
|
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 {
|
|
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 =
|
|
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 {
|
|
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:
|
|
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
|
|
90
|
-
const
|
|
91
|
-
|
|
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
|
-
|
|
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("");
|