@timurproko/a1 0.1.8-dev.287 → 0.1.8-dev.289
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/dist/contracts/owned-ui/image-attachments.d.ts +9 -0
- package/dist/contracts/owned-ui/image-attachments.js +19 -1
- package/dist/integrations/pi/components/owned-editor-ux.d.ts +6 -0
- package/dist/integrations/pi/components/owned-editor-ux.js +71 -0
- package/dist/integrations/pi/components/shell-editor-autocomplete.js +2 -0
- package/dist/integrations/pi/components/shell-shared-facade.d.ts +6 -1
- package/dist/integrations/pi/session-ui/clipboard-image.d.ts +2 -2
- package/dist/integrations/pi/session-ui/clipboard-image.js +10 -5
- package/dist/integrations/pi/session-ui/image-preparation-client.d.ts +20 -0
- package/dist/integrations/pi/session-ui/image-preparation-client.js +145 -0
- package/dist/integrations/pi/session-ui/image-preparation.d.ts +18 -0
- package/dist/integrations/pi/session-ui/image-preparation.js +126 -0
- package/dist/integrations/pi/session-ui/image-source.d.ts +13 -0
- package/dist/integrations/pi/session-ui/image-source.js +171 -0
- package/dist/integrations/pi/session-ui/image-worker.d.ts +17 -0
- package/dist/integrations/pi/session-ui/image-worker.js +23 -0
- package/dist/integrations/pi/session-ui/prompt-chips.d.ts +10 -1
- package/dist/integrations/pi/session-ui/prompt-chips.js +111 -4
- package/dist/integrations/pi/session-ui/session-shell-root.d.ts +7 -3
- package/dist/integrations/pi/session-ui/session-shell-root.js +18 -1
- package/dist/integrations/pi/session-ui/session-shell.js +67 -22
- package/dist/integrations/pi/session-ui/system-clipboard.d.ts +2 -2
- package/dist/integrations/pi/session-ui/system-clipboard.js +16 -14
- package/dist/native/darwin-arm64/manifest.json +1 -1
- package/dist/native/linux-x64/manifest.json +3 -3
- package/dist/native/linux-x64/process-guardian +0 -0
- package/dist/native/win32-x64/manifest.json +2 -2
- package/dist/native/win32-x64/process-guardian.exe +0 -0
- package/dist/runtime-payload-inventory.json +22 -18
- 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
|
|
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
|
|
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
|
|
88
|
-
|
|
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
|
-
|
|
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 {
|
|
9
|
+
import { runImageWorker } from "./image-preparation-client.js";
|
|
10
10
|
import { STREAM_PRESENTATION_INTERVAL_MS, StreamPresentationCoalescer, } from "./stream-presentation-coalescer.js";
|
|
11
|
-
import {
|
|
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
|
|
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 =
|
|
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
|
-
|
|
129
|
-
|
|
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.#
|
|
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.#
|
|
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.
|
|
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>;
|