@timurproko/a1 0.1.8-dev.287 → 0.1.8-dev.290

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 (31) hide show
  1. package/dist/contracts/owned-ui/image-attachments.d.ts +9 -0
  2. package/dist/contracts/owned-ui/image-attachments.js +19 -1
  3. package/dist/integrations/pi/components/owned-editor-ux.d.ts +6 -0
  4. package/dist/integrations/pi/components/owned-editor-ux.js +71 -0
  5. package/dist/integrations/pi/components/shell-editor-autocomplete.js +2 -0
  6. package/dist/integrations/pi/components/shell-shared-facade.d.ts +6 -1
  7. package/dist/integrations/pi/session-ui/clipboard-image.d.ts +2 -2
  8. package/dist/integrations/pi/session-ui/clipboard-image.js +10 -5
  9. package/dist/integrations/pi/session-ui/image-preparation-client.d.ts +20 -0
  10. package/dist/integrations/pi/session-ui/image-preparation-client.js +145 -0
  11. package/dist/integrations/pi/session-ui/image-preparation.d.ts +18 -0
  12. package/dist/integrations/pi/session-ui/image-preparation.js +126 -0
  13. package/dist/integrations/pi/session-ui/image-source.d.ts +13 -0
  14. package/dist/integrations/pi/session-ui/image-source.js +171 -0
  15. package/dist/integrations/pi/session-ui/image-worker.d.ts +17 -0
  16. package/dist/integrations/pi/session-ui/image-worker.js +23 -0
  17. package/dist/integrations/pi/session-ui/prompt-chips.d.ts +10 -1
  18. package/dist/integrations/pi/session-ui/prompt-chips.js +111 -4
  19. package/dist/integrations/pi/session-ui/session-shell-root.d.ts +7 -3
  20. package/dist/integrations/pi/session-ui/session-shell-root.js +18 -1
  21. package/dist/integrations/pi/session-ui/session-shell.js +67 -22
  22. package/dist/integrations/pi/session-ui/system-clipboard.d.ts +2 -2
  23. package/dist/integrations/pi/session-ui/system-clipboard.js +16 -14
  24. package/dist/native/darwin-arm64/manifest.json +2 -2
  25. package/dist/native/darwin-arm64/process-guardian +0 -0
  26. package/dist/native/linux-x64/manifest.json +3 -3
  27. package/dist/native/linux-x64/process-guardian +0 -0
  28. package/dist/native/win32-x64/manifest.json +2 -2
  29. package/dist/native/win32-x64/process-guardian.exe +0 -0
  30. package/dist/runtime-payload-inventory.json +22 -18
  31. package/package.json +2 -1
@@ -0,0 +1,171 @@
1
+ import { ImageAttachmentError } from "../../../contracts/owned-ui/index.js";
2
+ export const MAX_SOURCE_IMAGE_BYTES = 20 * 1024 * 1024;
3
+ export const MAX_SOURCE_PIXELS = 40_000_000;
4
+ export const MAX_SOURCE_DIMENSION = 32_768;
5
+ export const IMAGE_PREPARATION_MS = 15_000;
6
+ export function assertSourceBytes(length) {
7
+ if (length > MAX_SOURCE_IMAGE_BYTES)
8
+ throw new ImageAttachmentError("image-source-size");
9
+ if (!Number.isSafeInteger(length) || length <= 0)
10
+ throw new ImageAttachmentError("image-data");
11
+ }
12
+ export function sourceImageInfo(bytes, mimeType) {
13
+ assertSourceBytes(bytes.length);
14
+ try {
15
+ const info = readHeader(Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength));
16
+ if (info.mimeType !== mimeType.toLowerCase())
17
+ throw new ImageAttachmentError("image-mime");
18
+ if (info.width <= 0 || info.height <= 0)
19
+ throw new ImageAttachmentError("image-data");
20
+ if (info.width > MAX_SOURCE_DIMENSION || info.height > MAX_SOURCE_DIMENSION || info.width * info.height > MAX_SOURCE_PIXELS) {
21
+ throw new ImageAttachmentError("image-pixels");
22
+ }
23
+ return info;
24
+ }
25
+ catch (error) {
26
+ if (error instanceof ImageAttachmentError)
27
+ throw error;
28
+ throw new ImageAttachmentError("image-data");
29
+ }
30
+ }
31
+ function readHeader(b) {
32
+ let orientation = 1;
33
+ let conversionUnsafe = false;
34
+ const info = (width, height, mimeType) => ({ width, height, mimeType, orientation, conversionUnsafe });
35
+ if (b.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]))) {
36
+ if (b.toString("ascii", 12, 16) !== "IHDR" || b.readUInt32BE(8) !== 13)
37
+ throw new ImageAttachmentError("image-data");
38
+ for (let p = 8; p + 12 <= b.length;) {
39
+ const n = b.readUInt32BE(p);
40
+ if (p + n + 12 > b.length)
41
+ throw new ImageAttachmentError("image-data");
42
+ const type = b.toString("ascii", p + 4, p + 8);
43
+ if (type === "eXIf")
44
+ orientation = exifOrientation(b.subarray(p + 8, p + 8 + n));
45
+ if (type === "acTL" || type === "iCCP" || (type === "IHDR" && b[p + 16] === 16))
46
+ conversionUnsafe = true;
47
+ p += n + 12;
48
+ }
49
+ return info(b.readUInt32BE(16), b.readUInt32BE(20), "image/png");
50
+ }
51
+ if (b[0] === 255 && b[1] === 216) {
52
+ let width = 0, height = 0;
53
+ for (let p = 2; p < b.length;) {
54
+ if (b[p++] !== 255)
55
+ throw new ImageAttachmentError("image-data");
56
+ while (b[p] === 255)
57
+ p++;
58
+ const marker = b[p++];
59
+ if (marker === 218 || marker === 217)
60
+ break;
61
+ if (marker === 1 || (marker !== undefined && marker >= 208 && marker <= 215))
62
+ continue;
63
+ const n = b.readUInt16BE(p);
64
+ if (n < 2 || p + n > b.length)
65
+ throw new ImageAttachmentError("image-data");
66
+ if (marker === 225 && b.toString("ascii", p + 2, p + 8) === "Exif\0\0")
67
+ orientation = exifOrientation(b.subarray(p + 8, p + n));
68
+ if (marker === 226 && b.toString("ascii", p + 2, p + 13) === "ICC_PROFILE")
69
+ conversionUnsafe = true;
70
+ if (marker !== undefined && [192, 193, 194].includes(marker)) {
71
+ height = b.readUInt16BE(p + 3);
72
+ width = b.readUInt16BE(p + 5);
73
+ if (b[p + 7] === 4)
74
+ conversionUnsafe = true;
75
+ }
76
+ p += n;
77
+ }
78
+ return info(width, height, "image/jpeg");
79
+ }
80
+ if (/^GIF8[79]a$/.test(b.toString("ascii", 0, 6))) {
81
+ conversionUnsafe = gifFrameCount(b) > 1;
82
+ return info(b.readUInt16LE(6), b.readUInt16LE(8), "image/gif");
83
+ }
84
+ if (b.toString("ascii", 0, 2) === "BM") {
85
+ const dib = b.readUInt32LE(14);
86
+ if (dib === 12)
87
+ return info(b.readUInt16LE(18), b.readUInt16LE(20), "image/bmp");
88
+ if (dib < 40)
89
+ throw new ImageAttachmentError("image-data");
90
+ return info(b.readInt32LE(18), Math.abs(b.readInt32LE(22)), "image/bmp");
91
+ }
92
+ if (b.toString("ascii", 0, 4) === "RIFF" && b.toString("ascii", 8, 12) === "WEBP") {
93
+ const kind = b.toString("ascii", 12, 16);
94
+ if (kind === "VP8X") {
95
+ conversionUnsafe = ((b[20] ?? 0) & 0x22) !== 0;
96
+ for (let p = 12; p + 8 <= b.length;) {
97
+ const n = b.readUInt32LE(p + 4);
98
+ if (p + n + 8 > b.length)
99
+ throw new ImageAttachmentError("image-data");
100
+ if (b.toString("ascii", p, p + 4) === "EXIF")
101
+ orientation = exifOrientation(b.subarray(p + 8, p + 8 + n));
102
+ p += 8 + n + (n % 2);
103
+ }
104
+ return info(1 + b.readUIntLE(24, 3), 1 + b.readUIntLE(27, 3), "image/webp");
105
+ }
106
+ if (kind === "VP8 " && b.toString("hex", 23, 26) === "9d012a")
107
+ return info(b.readUInt16LE(26) & 0x3fff, b.readUInt16LE(28) & 0x3fff, "image/webp");
108
+ if (kind === "VP8L" && b[20] === 47) {
109
+ const bits = b.readUInt32LE(21);
110
+ return info((bits & 0x3fff) + 1, ((bits >>> 14) & 0x3fff) + 1, "image/webp");
111
+ }
112
+ }
113
+ throw new ImageAttachmentError("image-conversion");
114
+ }
115
+ function gifFrameCount(b) {
116
+ let p = 13 + (((b[10] ?? 0) & 128) ? 3 * 2 ** (((b[10] ?? 0) & 7) + 1) : 0);
117
+ let frames = 0;
118
+ const skipBlocks = () => {
119
+ while (p < b.length) {
120
+ const length = b[p++] ?? 0;
121
+ if (length === 0)
122
+ return;
123
+ p += length;
124
+ if (p > b.length)
125
+ throw new ImageAttachmentError("image-data");
126
+ }
127
+ throw new ImageAttachmentError("image-data");
128
+ };
129
+ while (p < b.length) {
130
+ const marker = b[p++];
131
+ if (marker === 59)
132
+ return frames;
133
+ if (marker === 33) {
134
+ p++;
135
+ skipBlocks();
136
+ continue;
137
+ }
138
+ if (marker !== 44 || p + 9 > b.length)
139
+ throw new ImageAttachmentError("image-data");
140
+ frames++;
141
+ const packed = b[p + 8] ?? 0;
142
+ p += 9 + ((packed & 128) ? 3 * 2 ** ((packed & 7) + 1) : 0);
143
+ p++;
144
+ skipBlocks();
145
+ }
146
+ throw new ImageAttachmentError("image-data");
147
+ }
148
+ function exifOrientation(input) {
149
+ const b = input.toString("ascii", 0, 6) === "Exif\0\0" ? input.subarray(6) : input;
150
+ const little = b.toString("ascii", 0, 2) === "II";
151
+ if (!little && b.toString("ascii", 0, 2) !== "MM")
152
+ throw new ImageAttachmentError("image-conversion");
153
+ const u16 = (p) => little ? b.readUInt16LE(p) : b.readUInt16BE(p);
154
+ const u32 = (p) => little ? b.readUInt32LE(p) : b.readUInt32BE(p);
155
+ if (u16(2) !== 42)
156
+ throw new ImageAttachmentError("image-conversion");
157
+ const directory = u32(4);
158
+ const count = u16(directory);
159
+ if (directory + 2 + count * 12 > b.length)
160
+ throw new ImageAttachmentError("image-conversion");
161
+ for (let i = 0; i < count; i++) {
162
+ const p = directory + 2 + i * 12;
163
+ if (u16(p) !== 274)
164
+ continue;
165
+ const value = u16(p + 8);
166
+ if (u16(p + 2) !== 3 || u32(p + 4) !== 1 || value < 1 || value > 8)
167
+ throw new ImageAttachmentError("image-conversion");
168
+ return value;
169
+ }
170
+ return 1;
171
+ }
@@ -0,0 +1,17 @@
1
+ import { type ImagePreparationLimits } from "./image-preparation.js";
2
+ export type ImageWorkerRequest = {
3
+ readonly kind: "clipboard";
4
+ } | {
5
+ readonly kind: "canonicalize";
6
+ readonly source: {
7
+ readonly data: string;
8
+ readonly mimeType: string;
9
+ };
10
+ } | {
11
+ readonly kind: "prepare";
12
+ readonly source: {
13
+ readonly data: string;
14
+ readonly mimeType: string;
15
+ };
16
+ readonly limits?: ImagePreparationLimits;
17
+ };
@@ -0,0 +1,23 @@
1
+ import { parentPort, workerData } from "node:worker_threads";
2
+ import { ImageAttachmentError } from "../../../contracts/owned-ui/index.js";
3
+ import { prepareImage } from "./image-preparation.js";
4
+ import { canonicalizeClipboardImage } from "./clipboard-image.js";
5
+ import { readSystemClipboardContent } from "./system-clipboard.js";
6
+ const controller = new AbortController();
7
+ parentPort?.on("message", message => { if (message === "cancel")
8
+ controller.abort(); });
9
+ const request = workerData;
10
+ try {
11
+ const value = request.kind === "clipboard"
12
+ ? await readSystemClipboardContent(controller.signal)
13
+ : request.kind === "canonicalize" ? canonicalizeClipboardImage(request.source, true)
14
+ : await prepareImage(request.source, request.limits);
15
+ parentPort?.postMessage({ ok: true, value });
16
+ }
17
+ catch (error) {
18
+ // Security: never forward native/codec exceptions which may contain input data.
19
+ parentPort?.postMessage({ ok: false, code: error instanceof ImageAttachmentError ? error.code : "image-codec" });
20
+ }
21
+ finally {
22
+ parentPort?.close();
23
+ }
@@ -8,9 +8,18 @@ export interface PreparedPrompt {
8
8
  readonly text: string;
9
9
  readonly images: readonly PromptImageAttachment[];
10
10
  }
11
- /** Owns semantic clipboard records while the prompt displays compact chips. */
11
+ /** Owns semantic chips and bounded pending paste references; cancels background work on reset or disposal. */
12
12
  export declare class PromptChipStore {
13
13
  #private;
14
+ beginPaste(currentText: string, read: (signal: AbortSignal) => Promise<PiShellClipboardContent | null>, onError: (error: unknown) => void): {
15
+ marker: string;
16
+ result: Promise<string>;
17
+ };
18
+ hasPending(text: string): boolean;
19
+ waitForPastes(text: string, signal: AbortSignal, readDraft?: () => string): Promise<PreparedPrompt>;
20
+ reconcileDraft(text: string): void;
21
+ resetPastes(text: string): string;
22
+ dispose(): Promise<void>;
14
23
  transformPastedContent(content: PiShellClipboardContent, currentText?: string): string;
15
24
  atomicRanges(line: string): readonly PiShellEditorTextRange[];
16
25
  hyperlinkRanges(text: string): readonly {
@@ -3,14 +3,109 @@ import { existsSync, statSync } from "node:fs";
3
3
  import path from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { canonicalizeClipboardImage } from "./clipboard-image.js";
6
- import { assertImageEncodedSize, assertPromptImages } from "../../../contracts/owned-ui/index.js";
6
+ import { assertImageEncodedSize, assertPromptImages, ImageAttachmentError } from "../../../contracts/owned-ui/index.js";
7
+ import { ImagePreparationClient } from "./image-preparation-client.js";
7
8
  const CHIP_PATTERN = /\[(?:📷 [^\]]+|📁 [^\]]+|📄 [^\]]+|🖼 {1,2}[^\]]+|🔗 [^\]]+)\]/gu;
8
9
  const IMAGE_EXTENSION = /\.(?:jpe?g|png|webp|gif|bmp|tiff?)$/iu;
9
10
  const URL_PATTERN = /^https?:\/\/[^\s\u0000-\u001f\u007f]+$/iu;
10
11
  const URL_DISPLAY_LENGTH = 40;
11
- /** Owns semantic clipboard records while the prompt displays compact chips. */
12
+ /** Owns semantic chips and bounded pending paste references; cancels background work on reset or disposal. */
12
13
  export class PromptChipStore {
13
14
  #chips = new Map();
15
+ #pending = new Map();
16
+ #preparation = new ImagePreparationClient();
17
+ #stopping = new Set();
18
+ beginPaste(currentText, read, onError) {
19
+ const marker = `[📷 preparing-${randomBytes(5).toString("hex")}]`;
20
+ const job = this.#preparation.start(async (signal) => {
21
+ const content = await read(signal);
22
+ entry.kind = content?.kind === "image" ? "image" : "text";
23
+ if (content?.kind === "image" && this.#imageCount(currentText) >= 8)
24
+ throw new ImageAttachmentError("image-count");
25
+ return content;
26
+ });
27
+ const entry = { marker, job, references: 0, kind: "unknown", completion: job.result.then(content => {
28
+ if (content === null)
29
+ return "";
30
+ if (content.kind === "image") {
31
+ return this.#addPreparedImage(content);
32
+ }
33
+ return this.transformPastedContent(content);
34
+ }).catch(error => {
35
+ entry.error = error instanceof ImageAttachmentError ? error : new ImageAttachmentError("image-codec");
36
+ if (entry.error.code !== "image-canceled" && entry.references === 0)
37
+ onError(entry.error);
38
+ return marker.replace("preparing-", "failed-");
39
+ }).then(replacement => {
40
+ entry.replacement = replacement;
41
+ return replacement;
42
+ }) };
43
+ this.#pending.set(marker, entry);
44
+ return { marker, result: entry.completion };
45
+ }
46
+ hasPending(text) {
47
+ return [...this.#pending.values()].some(item => text.includes(item.marker) && item.replacement === undefined);
48
+ }
49
+ async waitForPastes(text, signal, readDraft = () => "") {
50
+ const entries = [...this.#pending.values()].filter(item => text.includes(item.marker));
51
+ for (const entry of entries)
52
+ entry.references++;
53
+ try {
54
+ await new Promise((resolve, reject) => {
55
+ const abort = () => reject(new ImageAttachmentError("image-canceled"));
56
+ if (signal.aborted) {
57
+ abort();
58
+ return;
59
+ }
60
+ signal.addEventListener("abort", abort, { once: true });
61
+ void Promise.all(entries.map(item => item.completion)).then(() => {
62
+ signal.removeEventListener("abort", abort);
63
+ resolve();
64
+ }, reject);
65
+ });
66
+ return this.prepareSubmission(text);
67
+ }
68
+ finally {
69
+ for (const entry of entries) {
70
+ entry.references--;
71
+ if (signal.aborted && entry.references === 0 && !readDraft().includes(entry.marker))
72
+ entry.job.cancel();
73
+ }
74
+ }
75
+ }
76
+ reconcileDraft(text) {
77
+ for (const entry of this.#pending.values()) {
78
+ if (entry.references === 0 && entry.replacement === undefined && !text.includes(entry.marker))
79
+ entry.job.cancel();
80
+ }
81
+ }
82
+ resetPastes(text) {
83
+ let remaining = text;
84
+ for (const entry of this.#pending.values())
85
+ if (entry.replacement === undefined)
86
+ remaining = remaining.replaceAll(entry.marker, "");
87
+ const stopped = this.#preparation.dispose();
88
+ this.#stopping.add(stopped);
89
+ void stopped.finally(() => this.#stopping.delete(stopped));
90
+ this.#preparation = new ImagePreparationClient();
91
+ for (const entry of this.#pending.values())
92
+ if (entry.replacement === undefined)
93
+ entry.job.cancel();
94
+ return remaining;
95
+ }
96
+ async dispose() { await Promise.all([this.#preparation.dispose(), ...this.#stopping]); }
97
+ #imageCount(text) {
98
+ const regular = [...this.#chips.values()].filter(chip => chip.kind === "image" && text.includes(chip.tag)).length;
99
+ return regular + [...this.#pending.values()].filter(entry => entry.kind !== "text" && (text.includes(entry.marker) || text.includes(entry.marker.replace("preparing-", "failed-")))).length;
100
+ }
101
+ #addPreparedImage(image) {
102
+ const suffix = image.mimeType === "image/jpeg" ? "jpg" : image.mimeType.split("/")[1] ?? "png";
103
+ const tag = `[📷 screenshot-${randomBytes(5).toString("hex")}${image.transformed ? "-resized" : ""}.${suffix}]`;
104
+ const attachment = Object.freeze({ type: "image", data: image.data, mimeType: image.mimeType });
105
+ assertPromptImages([attachment]);
106
+ this.#chips.set(tag, { kind: "image", tag, image: attachment });
107
+ return tag;
108
+ }
14
109
  transformPastedContent(content, currentText = "") {
15
110
  if (content.kind === "image") {
16
111
  assertImageEncodedSize(content.data);
@@ -82,10 +177,22 @@ export class PromptChipStore {
82
177
  }
83
178
  #replaceResolvable(text, includeImages) {
84
179
  let expanded = text;
180
+ for (const entry of this.#pending.values()) {
181
+ if (!expanded.includes(entry.marker) && !expanded.includes(entry.marker.replace("preparing-", "failed-")))
182
+ continue;
183
+ if (includeImages && entry.error !== undefined)
184
+ throw entry.error;
185
+ if (includeImages && entry.replacement === undefined)
186
+ throw new ImageAttachmentError("image-pending");
187
+ if (entry.replacement !== undefined)
188
+ expanded = expanded.replaceAll(entry.marker, entry.replacement);
189
+ }
85
190
  const images = [];
86
191
  const seenImages = new Set();
87
- for (const [tag, chip] of this.#chips) {
88
- if (!expanded.includes(tag))
192
+ for (const match of expanded.matchAll(CHIP_PATTERN)) {
193
+ const tag = match[0];
194
+ const chip = this.#chips.get(tag);
195
+ if (chip === undefined)
89
196
  continue;
90
197
  if (chip.kind === "image") {
91
198
  if (includeImages && !seenImages.has(tag)) {
@@ -10,8 +10,8 @@ export type OwnedUiBackendPort = PiEngineAdapter;
10
10
  export type OwnedUiTerminalPort = PiTuiTerminalPort;
11
11
  type OwnedUiStartupOptions = PiShellHeaderOptions;
12
12
  export interface OwnedUiClipboardPort {
13
- readText(): Promise<string | null>;
14
- readImage?(): Promise<{
13
+ readText(signal?: AbortSignal): Promise<string | null>;
14
+ readImage?(signal?: AbortSignal): Promise<{
15
15
  readonly data: string;
16
16
  readonly mimeType: string;
17
17
  } | null>;
@@ -83,7 +83,7 @@ export declare class OwnedUiSessionShellRoot implements PiTuiComponentPort {
83
83
  readonly onPromptSuggestionAccepted?: (text: string) => void;
84
84
  readonly onInputSurfaceChanged?: () => void;
85
85
  readonly onCopyText?: (text: string) => void;
86
- readonly readClipboardContent?: () => Promise<PiShellClipboardContent | null>;
86
+ readonly readClipboardContent?: (signal?: AbortSignal) => Promise<PiShellClipboardContent | null>;
87
87
  }, startup?: PiShellHeaderOptions, agentDir?: string, extensionRenderers?: PiShellExtensionRendererResolver, sessionLayout?: "pinned" | "custom-viewport", imageAssets?: PiShellImageAssetResolver);
88
88
  setEditorPaddingX(padding: number): void;
89
89
  setAutocompleteMaxVisible(maxVisible: number): void;
@@ -92,6 +92,10 @@ export declare class OwnedUiSessionShellRoot implements PiTuiComponentPort {
92
92
  setMermaidRenderingMode(mode: "off" | "final" | "streaming"): void;
93
93
  setImagePresentation(showImages: boolean, imageWidthCells: number): void;
94
94
  preparePromptSubmission(text: string): PreparedPrompt;
95
+ hasPendingPastes(text: string): boolean;
96
+ waitForPromptPastes(text: string, signal: AbortSignal): Promise<PreparedPrompt>;
97
+ resetPendingPastes(): void;
98
+ disposePendingPastes(): Promise<void>;
95
99
  canPreparePromptSuggestion(): boolean;
96
100
  canPresentPromptSuggestion(): boolean;
97
101
  setPromptSuggestion(text: string | null): void;
@@ -94,6 +94,7 @@ export class OwnedUiSessionShellRoot {
94
94
  return "";
95
95
  }
96
96
  },
97
+ ...(this.#customViewport ? { beginClipboardPaste: () => this.#promptChips.beginPaste(this.editor.getText(), signal => handlers.readClipboardContent?.(signal) ?? Promise.resolve(null), error => handlers.onPasteRejected?.(error)) } : {}),
97
98
  editorAtomicRanges: line => this.#promptChips.atomicRanges(line),
98
99
  decorateEditorRow: (row, width) => {
99
100
  const plain = stripAnsi(row);
@@ -124,7 +125,11 @@ export class OwnedUiSessionShellRoot {
124
125
  styleSuggestionCaret: caretCell,
125
126
  },
126
127
  } : {}),
127
- ...(handlers.onEditorChange === undefined ? {} : { onChange: handlers.onEditorChange }),
128
+ onChange: text => {
129
+ handlers.onEditorChange?.(text);
130
+ // Concurrency: Pi clears before onSubmit; capture waiting references before pruning removed chips.
131
+ queueMicrotask(() => this.#promptChips.reconcileDraft(this.editor.getText()));
132
+ },
128
133
  ...(handlers.onPromptSuggestionAccepted === undefined ? {} : { onPromptSuggestionAccepted: handlers.onPromptSuggestionAccepted }),
129
134
  });
130
135
  this.#viewportController = new SessionViewportController({
@@ -202,6 +207,18 @@ export class OwnedUiSessionShellRoot {
202
207
  preparePromptSubmission(text) {
203
208
  return this.#promptChips.prepareSubmission(text);
204
209
  }
210
+ hasPendingPastes(text) { return this.#promptChips.hasPending(text); }
211
+ waitForPromptPastes(text, signal) {
212
+ return this.#promptChips.waitForPastes(text, signal, () => this.editor.getText());
213
+ }
214
+ resetPendingPastes() {
215
+ this.editor.cancelPendingPastes?.();
216
+ this.editor.setText(this.#promptChips.resetPastes(this.editor.getText()));
217
+ }
218
+ disposePendingPastes() {
219
+ this.resetPendingPastes();
220
+ return this.#promptChips.dispose();
221
+ }
205
222
  canPreparePromptSuggestion() {
206
223
  return this.usesDefaultInputSurface()
207
224
  && this.#view.dialog === null
@@ -6,9 +6,9 @@ import { MOUSE_TRACKING_OFF, MOUSE_TRACKING_ON, parseMouseInput, readVisibleHype
6
6
  import { PINNED_PI_HIDDEN_COMMAND_NAMES, PINNED_PI_WORKFLOW_COMMAND_NAMES, } from "../engine/index.js";
7
7
  import { createPiExtensionUiBridge, createPiQueuedInputStatus, createPiShellArmin, createPiShellAuthProviderSelector, createPiShellChangelog, createPiShellDaxnuts, createPiShellDialog, createPiShellEarendilAnnouncement, createPiShellEditor, createPiShellExtensionSelector, createPiShellFooter, createPiShellHeader, createPiShellHotkeys, createPiShellLoadedResources, createPiShellLoginDialog, createPiShellModelSelector, createPiShellOperationLoader, createPiShellReloadBox, createPiShellScopedModelsSelector, createPiShellSelector, createPiShellSessionInfo, createPiShellSessionSelector, createPiShellSettingsSelector, createPiShellStatus, createPiShellTranscriptComponent, createPiShellTreeSelector, createPiShellTrustSelector, createPiShellUserMessageSelector, onPiThemeChange, piTheme, renderPiShellPackageUpdateNotice, renderPiShellStartupDiagnostic, renderPiShellStatusText, renderPiShellTranscriptBlock, } from "../components/index.js";
8
8
  import { DamageAwareTerminalAdapter, PiTuiRuntimeAdapter, classifyPiTuiInput, } from "../tui-runtime/index.js";
9
- import { canonicalizeClipboardImage } from "./clipboard-image.js";
9
+ import { runImageWorker } from "./image-preparation-client.js";
10
10
  import { STREAM_PRESENTATION_INTERVAL_MS, StreamPresentationCoalescer, } from "./stream-presentation-coalescer.js";
11
- import { preloadSystemClipboard, readSystemClipboardContent, writeSystemClipboardText, } from "./system-clipboard.js";
11
+ import { writeSystemClipboardText, } from "./system-clipboard.js";
12
12
  import { OwnedUiSessionShellRoot, shellResourceEntries, } from "./session-shell-root.js";
13
13
  export { OwnedUiSessionShellRoot } from "./session-shell-root.js";
14
14
  /** Coordinates backend, owned presentation, and Pi TUI lifecycles for one interactive session. */
@@ -46,6 +46,7 @@ export class OwnedUiSessionShell {
46
46
  #imageWidthCells = 80;
47
47
  #fullscreenExitOutput = "transcript";
48
48
  #compactionQueue = [];
49
+ #waitingImages = new Map();
49
50
  #lastClearTime = 0;
50
51
  #lastEscapeTime = 0;
51
52
  #activeLoginDialog;
@@ -58,8 +59,6 @@ export class OwnedUiSessionShell {
58
59
  this.#cwd = options.cwd;
59
60
  this.#routeHost = options.routeHost ?? null;
60
61
  this.#customViewport = options.sessionLayout === "custom-viewport";
61
- if (this.#customViewport && options.clipboard === undefined)
62
- preloadSystemClipboard();
63
62
  this.#stopped = new Promise(resolve => {
64
63
  this.#resolveStopped = resolve;
65
64
  });
@@ -107,30 +106,26 @@ export class OwnedUiSessionShell {
107
106
  : options.clipboard.writeText?.(text) ?? Promise.resolve();
108
107
  pendingClipboardWrite = write.catch(() => { });
109
108
  },
110
- readClipboardContent: async () => {
109
+ readClipboardContent: async (signal = new AbortController().signal) => {
111
110
  await pendingClipboardWrite;
111
+ if (signal.aborted)
112
+ throw new ImageAttachmentError("image-canceled");
112
113
  if (options.clipboard === undefined)
113
- return readSystemClipboardContent().catch(error => {
114
- if (error instanceof ImageAttachmentError)
115
- this.#reportSubmissionError(error);
116
- return null;
117
- });
114
+ return runImageWorker({ kind: "clipboard" }, signal);
118
115
  try {
119
- const image = await options.clipboard.readImage?.();
116
+ const image = await options.clipboard.readImage?.(signal);
120
117
  if (image !== null && image !== undefined) {
121
- const canonical = canonicalizeClipboardImage(image);
118
+ const canonical = await runImageWorker({ kind: "canonicalize", source: image }, signal);
122
119
  if (canonical !== null)
123
120
  return { kind: "image", ...canonical };
124
121
  }
125
122
  }
126
123
  catch (error) {
127
- if (error instanceof ImageAttachmentError) {
128
- this.#reportSubmissionError(error);
129
- return null;
130
- }
131
- // Compatibility: treat an unavailable or malformed image as text-capable clipboard input.
124
+ if (error instanceof ImageAttachmentError)
125
+ throw error;
126
+ // Compatibility: an unavailable native image reader can still provide clipboard text.
132
127
  }
133
- const text = await options.clipboard.readText();
128
+ const text = await options.clipboard.readText(signal);
134
129
  return text === null ? null : { kind: "text", text };
135
130
  },
136
131
  }, {
@@ -439,7 +434,43 @@ export class OwnedUiSessionShell {
439
434
  return this.#stopped;
440
435
  }
441
436
  async submit(text) {
442
- return this.#guardSubmission(text, () => this.#submit(text));
437
+ return this.#submitWhenReady(text, () => this.#submit(text));
438
+ }
439
+ #submitWhenReady(draft, action) {
440
+ const previous = this.#waitingImages.get(draft);
441
+ if (previous !== undefined)
442
+ return previous.result;
443
+ if (!this.root.hasPendingPastes(draft))
444
+ return this.#guardSubmission(draft, action);
445
+ const controller = new AbortController();
446
+ const generation = this.backend.sessionGeneration;
447
+ const result = this.#guardSubmission(draft, async () => {
448
+ await this.root.waitForPromptPastes(draft, controller.signal);
449
+ if (controller.signal.aborted || this.#disposed || this.backend.sessionGeneration !== generation)
450
+ return rejected("image submission canceled");
451
+ return action();
452
+ }).finally(() => {
453
+ this.#waitingImages.delete(draft);
454
+ if (!this.#disposed)
455
+ this.#showWaitingImages();
456
+ });
457
+ this.#waitingImages.set(draft, { controller, result });
458
+ if (this.root.editor.getText() === draft)
459
+ this.root.editor.setText("");
460
+ this.#showWaitingImages();
461
+ return result;
462
+ }
463
+ #showWaitingImages() {
464
+ const count = this.#waitingImages.size;
465
+ this.root.setExtensionWidget("owned-image-preparation", count === 0 ? null : {
466
+ render: width => [...renderPiShellStatusText(`Waiting for images (${count} submission${count === 1 ? "" : "s"}) — Esc cancels; dequeue restores`, width)],
467
+ invalidate: () => { },
468
+ }, "aboveEditor");
469
+ this.runtime.requestRender();
470
+ }
471
+ #cancelWaitingImages() {
472
+ for (const item of this.#waitingImages.values())
473
+ item.controller.abort();
443
474
  }
444
475
  async #submit(text) {
445
476
  this.#promptSuggestions?.invalidate();
@@ -510,6 +541,10 @@ export class OwnedUiSessionShell {
510
541
  }
511
542
  async interrupt(now = Date.now()) {
512
543
  this.#promptSuggestions?.invalidate();
544
+ if (this.#waitingImages.size > 0) {
545
+ this.#cancelWaitingImages();
546
+ return { outcome: "completed", diagnostic: null };
547
+ }
513
548
  if (this.view().lifecycle === "busy")
514
549
  return this.abort();
515
550
  if (this.root.editor.getText().trim().length > 0) {
@@ -589,7 +624,7 @@ export class OwnedUiSessionShell {
589
624
  }
590
625
  async queueFollowUp() {
591
626
  const draft = this.root.editor.getText();
592
- return this.#guardSubmission(draft, () => this.#queueFollowUp(draft));
627
+ return this.#submitWhenReady(draft, () => this.#queueFollowUp(draft));
593
628
  }
594
629
  async #queueFollowUp(draft) {
595
630
  const displayInput = draft.trim();
@@ -599,7 +634,8 @@ export class OwnedUiSessionShell {
599
634
  assertPromptImages(prepared.images);
600
635
  const text = prepared.text.trim();
601
636
  this.root.editor.addToHistory(displayInput);
602
- this.root.editor.setText("");
637
+ if (this.root.editor.getText() === draft)
638
+ this.root.editor.setText("");
603
639
  this.root.resumeViewportFollowing();
604
640
  if (this.view().status.workingMessage?.startsWith("Compacting") === true) {
605
641
  this.#compactionQueue.push({
@@ -619,7 +655,8 @@ export class OwnedUiSessionShell {
619
655
  }, displayInput);
620
656
  }
621
657
  restoreQueuedInput() {
622
- const queued = [...this.backend.clearQueuedWorkflows(), ...this.#compactionQueue.map(item => item.draft)];
658
+ const queued = [...this.#waitingImages.keys(), ...this.backend.clearQueuedWorkflows(), ...this.#compactionQueue.map(item => item.draft)];
659
+ this.#cancelWaitingImages();
623
660
  this.#compactionQueue = [];
624
661
  if (queued.length === 0)
625
662
  return;
@@ -1111,6 +1148,7 @@ export class OwnedUiSessionShell {
1111
1148
  if (this.#disposed)
1112
1149
  return;
1113
1150
  this.#disposed = true;
1151
+ this.#cancelWaitingImages();
1114
1152
  const failures = [];
1115
1153
  const attempt = (action) => { try {
1116
1154
  action();
@@ -1118,6 +1156,8 @@ export class OwnedUiSessionShell {
1118
1156
  catch (error) {
1119
1157
  failures.push(error);
1120
1158
  } };
1159
+ let pasteCleanup = Promise.resolve();
1160
+ attempt(() => { pasteCleanup = this.root.disposePendingPastes(); });
1121
1161
  attempt(() => this.root.clearViewportPointerState());
1122
1162
  attempt(() => this.#setPointerReporting(false, true));
1123
1163
  attempt(() => this.#removeViewportPreInput());
@@ -1142,6 +1182,7 @@ export class OwnedUiSessionShell {
1142
1182
  attempt(() => this.#extensionBridge.dispose());
1143
1183
  // Invariant: terminal restoration precedes any potentially stalled backend teardown.
1144
1184
  await this.runtime.dispose().catch(error => failures.push(error));
1185
+ await boundedCleanup(() => pasteCleanup).catch(error => failures.push(error));
1145
1186
  await boundedCleanup(() => this.backend.unbindExtensionUi()).catch(error => failures.push(error));
1146
1187
  if (failures.length > 0)
1147
1188
  throw new AggregateError(failures, "Owned UI disposal failed");
@@ -1174,6 +1215,8 @@ export class OwnedUiSessionShell {
1174
1215
  this.#streamPresentation.noteImmediatePresentation();
1175
1216
  const view = this.view();
1176
1217
  if (this.backend.sessionGeneration !== this.#sessionGeneration) {
1218
+ this.#cancelWaitingImages();
1219
+ this.root.resetPendingPastes();
1177
1220
  this.#promptSuggestions?.invalidate();
1178
1221
  this.#sessionGeneration = this.backend.sessionGeneration;
1179
1222
  this.#activeLoginDialog = undefined;
@@ -1490,6 +1533,8 @@ export class OwnedUiSessionShell {
1490
1533
  return await action();
1491
1534
  }
1492
1535
  catch (error) {
1536
+ if (error instanceof ImageAttachmentError && error.code === "image-canceled")
1537
+ return rejected("image submission canceled");
1493
1538
  return this.#recoverSubmission(draft, revision, error);
1494
1539
  }
1495
1540
  }
@@ -11,10 +11,10 @@ export declare function preloadSystemClipboard(): void;
11
11
  * private clipboard module. Failures are deliberately non-fatal: a denied or
12
12
  * unavailable clipboard simply makes the paste action a no-op.
13
13
  */
14
- export declare function readSystemClipboardContent(): Promise<PiShellClipboardContent | null>;
14
+ export declare function readSystemClipboardContent(signal?: AbortSignal): Promise<PiShellClipboardContent | null>;
15
15
  export declare function readSystemClipboardImage(reader: SystemClipboardImageReader): Promise<{
16
16
  readonly data: string;
17
17
  readonly mimeType: string;
18
18
  } | null>;
19
- export declare function readSystemClipboardText(): Promise<string | null>;
19
+ export declare function readSystemClipboardText(signal?: AbortSignal): Promise<string | null>;
20
20
  export declare function writeSystemClipboardText(text: string): Promise<void>;