@timurproko/a1 0.1.8-dev.282 → 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/bin/ui.js +14 -2
- package/dist/contracts/owned-ui/image-attachments.d.ts +29 -0
- package/dist/contracts/owned-ui/image-attachments.js +60 -0
- package/dist/contracts/owned-ui/index.d.ts +1 -0
- package/dist/contracts/owned-ui/index.js +1 -0
- package/dist/contracts/owned-ui/validation.js +2 -9
- package/dist/features/owned-ui/run.js +16 -1
- package/dist/foundation/release/bootstrap.js +2 -1
- package/dist/foundation/terminal-cleanup/fatal-exit.d.ts +12 -0
- package/dist/foundation/terminal-cleanup/fatal-exit.js +85 -0
- package/dist/foundation/terminal-cleanup/index.d.ts +2 -0
- package/dist/foundation/terminal-cleanup/index.js +2 -0
- package/dist/foundation/terminal-cleanup/terminal-reset.d.ts +4 -0
- package/dist/foundation/terminal-cleanup/terminal-reset.js +40 -0
- 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 +13 -4
- 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 +11 -2
- package/dist/integrations/pi/session-ui/prompt-chips.js +114 -4
- package/dist/integrations/pi/session-ui/session-shell-root.d.ts +8 -3
- package/dist/integrations/pi/session-ui/session-shell-root.js +27 -2
- package/dist/integrations/pi/session-ui/session-shell.js +179 -52
- package/dist/integrations/pi/session-ui/session-viewport-controller.js +17 -7
- package/dist/integrations/pi/session-ui/system-clipboard.d.ts +2 -2
- package/dist/integrations/pi/session-ui/system-clipboard.js +25 -14
- package/dist/integrations/pi/tui-runtime/adapter.js +13 -2
- 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/docs/architecture/boundaries.md +2 -1
- package/package.json +2 -1
|
@@ -3,18 +3,116 @@ 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, ImageAttachmentError } from "../../../contracts/owned-ui/index.js";
|
|
7
|
+
import { ImagePreparationClient } from "./image-preparation-client.js";
|
|
6
8
|
const CHIP_PATTERN = /\[(?:📷 [^\]]+|📁 [^\]]+|📄 [^\]]+|🖼 {1,2}[^\]]+|🔗 [^\]]+)\]/gu;
|
|
7
9
|
const IMAGE_EXTENSION = /\.(?:jpe?g|png|webp|gif|bmp|tiff?)$/iu;
|
|
8
10
|
const URL_PATTERN = /^https?:\/\/[^\s\u0000-\u001f\u007f]+$/iu;
|
|
9
11
|
const URL_DISPLAY_LENGTH = 40;
|
|
10
|
-
/** Owns semantic
|
|
12
|
+
/** Owns semantic chips and bounded pending paste references; cancels background work on reset or disposal. */
|
|
11
13
|
export class PromptChipStore {
|
|
12
14
|
#chips = new Map();
|
|
13
|
-
|
|
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
|
+
}
|
|
109
|
+
transformPastedContent(content, currentText = "") {
|
|
14
110
|
if (content.kind === "image") {
|
|
111
|
+
assertImageEncodedSize(content.data);
|
|
15
112
|
const image = canonicalizeClipboardImage(content);
|
|
16
113
|
if (image === null)
|
|
17
114
|
return "";
|
|
115
|
+
assertPromptImages([...this.prepareSubmission(currentText).images, { type: "image", ...image }]);
|
|
18
116
|
const id = randomBytes(5).toString("hex");
|
|
19
117
|
const tag = `[📷 screenshot-${id}.png]`;
|
|
20
118
|
this.#chips.set(tag, {
|
|
@@ -79,10 +177,22 @@ export class PromptChipStore {
|
|
|
79
177
|
}
|
|
80
178
|
#replaceResolvable(text, includeImages) {
|
|
81
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
|
+
}
|
|
82
190
|
const images = [];
|
|
83
191
|
const seenImages = new Set();
|
|
84
|
-
for (const
|
|
85
|
-
|
|
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)
|
|
86
196
|
continue;
|
|
87
197
|
if (chip.kind === "image") {
|
|
88
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>;
|
|
@@ -68,6 +68,7 @@ export declare class OwnedUiSessionShellRoot implements PiTuiComponentPort {
|
|
|
68
68
|
readonly requestHyperlinkCleanup?: () => void;
|
|
69
69
|
readonly enableDockInputReuse?: boolean;
|
|
70
70
|
readonly onSubmit: (text: string) => void;
|
|
71
|
+
readonly onPasteRejected?: (error: unknown) => void;
|
|
71
72
|
readonly onInterrupt: () => void;
|
|
72
73
|
readonly onClear?: () => void;
|
|
73
74
|
readonly onExit: () => void;
|
|
@@ -82,7 +83,7 @@ export declare class OwnedUiSessionShellRoot implements PiTuiComponentPort {
|
|
|
82
83
|
readonly onPromptSuggestionAccepted?: (text: string) => void;
|
|
83
84
|
readonly onInputSurfaceChanged?: () => void;
|
|
84
85
|
readonly onCopyText?: (text: string) => void;
|
|
85
|
-
readonly readClipboardContent?: () => Promise<PiShellClipboardContent | null>;
|
|
86
|
+
readonly readClipboardContent?: (signal?: AbortSignal) => Promise<PiShellClipboardContent | null>;
|
|
86
87
|
}, startup?: PiShellHeaderOptions, agentDir?: string, extensionRenderers?: PiShellExtensionRendererResolver, sessionLayout?: "pinned" | "custom-viewport", imageAssets?: PiShellImageAssetResolver);
|
|
87
88
|
setEditorPaddingX(padding: number): void;
|
|
88
89
|
setAutocompleteMaxVisible(maxVisible: number): void;
|
|
@@ -91,6 +92,10 @@ export declare class OwnedUiSessionShellRoot implements PiTuiComponentPort {
|
|
|
91
92
|
setMermaidRenderingMode(mode: "off" | "final" | "streaming"): void;
|
|
92
93
|
setImagePresentation(showImages: boolean, imageWidthCells: number): void;
|
|
93
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>;
|
|
94
99
|
canPreparePromptSuggestion(): boolean;
|
|
95
100
|
canPresentPromptSuggestion(): boolean;
|
|
96
101
|
setPromptSuggestion(text: string | null): void;
|
|
@@ -85,7 +85,16 @@ export class OwnedUiSessionShellRoot {
|
|
|
85
85
|
...handlers,
|
|
86
86
|
keybindingProfile: this.#customViewport ? "a1" : "pi",
|
|
87
87
|
paintEditorSelection: (line, from, to, atomic) => backgroundSgrSpan(line, from, to, atomic ? "\u001b[7m" : "\u001b[27m\u001b[48;2;38;79;120m", atomic ? "\u001b[27m" : "\u001b[49m", piShellVisibleWidth),
|
|
88
|
-
transformPastedContent: content =>
|
|
88
|
+
transformPastedContent: content => {
|
|
89
|
+
try {
|
|
90
|
+
return this.#promptChips.transformPastedContent(content, this.editor.getText());
|
|
91
|
+
}
|
|
92
|
+
catch (error) {
|
|
93
|
+
handlers.onPasteRejected?.(error);
|
|
94
|
+
return "";
|
|
95
|
+
}
|
|
96
|
+
},
|
|
97
|
+
...(this.#customViewport ? { beginClipboardPaste: () => this.#promptChips.beginPaste(this.editor.getText(), signal => handlers.readClipboardContent?.(signal) ?? Promise.resolve(null), error => handlers.onPasteRejected?.(error)) } : {}),
|
|
89
98
|
editorAtomicRanges: line => this.#promptChips.atomicRanges(line),
|
|
90
99
|
decorateEditorRow: (row, width) => {
|
|
91
100
|
const plain = stripAnsi(row);
|
|
@@ -116,7 +125,11 @@ export class OwnedUiSessionShellRoot {
|
|
|
116
125
|
styleSuggestionCaret: caretCell,
|
|
117
126
|
},
|
|
118
127
|
} : {}),
|
|
119
|
-
|
|
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
|
+
},
|
|
120
133
|
...(handlers.onPromptSuggestionAccepted === undefined ? {} : { onPromptSuggestionAccepted: handlers.onPromptSuggestionAccepted }),
|
|
121
134
|
});
|
|
122
135
|
this.#viewportController = new SessionViewportController({
|
|
@@ -194,6 +207,18 @@ export class OwnedUiSessionShellRoot {
|
|
|
194
207
|
preparePromptSubmission(text) {
|
|
195
208
|
return this.#promptChips.prepareSubmission(text);
|
|
196
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
|
+
}
|
|
197
222
|
canPreparePromptSuggestion() {
|
|
198
223
|
return this.usesDefaultInputSurface()
|
|
199
224
|
&& this.#view.dialog === null
|
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import { PRODUCT_TEXT } from "../../../product-identity.js";
|
|
2
|
+
import { boundedCleanup } from "../../../foundation/terminal-cleanup/index.js";
|
|
3
|
+
import { assertOwnedUiCommand, assertPromptImages, ImageAttachmentError } from "../../../contracts/owned-ui/index.js";
|
|
2
4
|
import { ContextualPromptSuggestionController } from "./prompt-suggestion-controller.js";
|
|
3
5
|
import { MOUSE_TRACKING_OFF, MOUSE_TRACKING_ON, parseMouseInput, readVisibleHyperlinks } from "../../../ui/components/index.js";
|
|
4
6
|
import { PINNED_PI_HIDDEN_COMMAND_NAMES, PINNED_PI_WORKFLOW_COMMAND_NAMES, } from "../engine/index.js";
|
|
5
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";
|
|
6
8
|
import { DamageAwareTerminalAdapter, PiTuiRuntimeAdapter, classifyPiTuiInput, } from "../tui-runtime/index.js";
|
|
7
|
-
import {
|
|
9
|
+
import { runImageWorker } from "./image-preparation-client.js";
|
|
8
10
|
import { STREAM_PRESENTATION_INTERVAL_MS, StreamPresentationCoalescer, } from "./stream-presentation-coalescer.js";
|
|
9
|
-
import {
|
|
11
|
+
import { writeSystemClipboardText, } from "./system-clipboard.js";
|
|
10
12
|
import { OwnedUiSessionShellRoot, shellResourceEntries, } from "./session-shell-root.js";
|
|
11
13
|
export { OwnedUiSessionShellRoot } from "./session-shell-root.js";
|
|
12
14
|
/** Coordinates backend, owned presentation, and Pi TUI lifecycles for one interactive session. */
|
|
@@ -27,6 +29,7 @@ export class OwnedUiSessionShell {
|
|
|
27
29
|
#routeHost;
|
|
28
30
|
#dialogHandle;
|
|
29
31
|
#sequence = 0;
|
|
32
|
+
#editorRevision = 0;
|
|
30
33
|
#started = false;
|
|
31
34
|
#disposed = false;
|
|
32
35
|
#pointerReporting = false;
|
|
@@ -43,6 +46,7 @@ export class OwnedUiSessionShell {
|
|
|
43
46
|
#imageWidthCells = 80;
|
|
44
47
|
#fullscreenExitOutput = "transcript";
|
|
45
48
|
#compactionQueue = [];
|
|
49
|
+
#waitingImages = new Map();
|
|
46
50
|
#lastClearTime = 0;
|
|
47
51
|
#lastEscapeTime = 0;
|
|
48
52
|
#activeLoginDialog;
|
|
@@ -55,8 +59,6 @@ export class OwnedUiSessionShell {
|
|
|
55
59
|
this.#cwd = options.cwd;
|
|
56
60
|
this.#routeHost = options.routeHost ?? null;
|
|
57
61
|
this.#customViewport = options.sessionLayout === "custom-viewport";
|
|
58
|
-
if (this.#customViewport && options.clipboard === undefined)
|
|
59
|
-
preloadSystemClipboard();
|
|
60
62
|
this.#stopped = new Promise(resolve => {
|
|
61
63
|
this.#resolveStopped = resolve;
|
|
62
64
|
});
|
|
@@ -76,7 +78,8 @@ export class OwnedUiSessionShell {
|
|
|
76
78
|
replacementSurfaceActive: !this.root.usesDefaultInputSurface(),
|
|
77
79
|
}),
|
|
78
80
|
enableDockInputReuse: options.inputPresentation?.viewportReuse !== false,
|
|
79
|
-
onSubmit: text => { void this.submit(text); },
|
|
81
|
+
onSubmit: text => { void this.submit(text).catch(() => this.#reportSubmissionError()); },
|
|
82
|
+
onPasteRejected: error => this.#reportSubmissionError(error),
|
|
80
83
|
onInterrupt: () => { void this.interrupt(); },
|
|
81
84
|
onClear: () => { void this.clearOrExit(); },
|
|
82
85
|
onExit: () => { void this.shutdown(); },
|
|
@@ -88,11 +91,14 @@ export class OwnedUiSessionShell {
|
|
|
88
91
|
this.runtime.requestRender();
|
|
89
92
|
},
|
|
90
93
|
onMessageCopy: () => { void this.runWorkflow({ command: "copy", argument: "" }); },
|
|
91
|
-
onFollowUp: () => { void this.queueFollowUp(); },
|
|
94
|
+
onFollowUp: () => { void this.queueFollowUp().catch(() => this.#reportSubmissionError()); },
|
|
92
95
|
onDequeue: () => this.restoreQueuedInput(),
|
|
93
|
-
onEditorChange: () => promptSuggestionController?.invalidate(),
|
|
96
|
+
onEditorChange: () => { this.#editorRevision++; promptSuggestionController?.invalidate(); },
|
|
94
97
|
onPromptSuggestionAccepted: () => promptSuggestionController?.accept(),
|
|
95
|
-
onInputSurfaceChanged: () =>
|
|
98
|
+
onInputSurfaceChanged: () => {
|
|
99
|
+
this.root.clearViewportPointerState();
|
|
100
|
+
promptSuggestionController?.invalidate();
|
|
101
|
+
},
|
|
96
102
|
onCopyText: text => {
|
|
97
103
|
runtime?.writeControl(`\u001b]52;c;${Buffer.from(text, "utf8").toString("base64")}\u0007`);
|
|
98
104
|
const write = options.clipboard === undefined
|
|
@@ -100,22 +106,26 @@ export class OwnedUiSessionShell {
|
|
|
100
106
|
: options.clipboard.writeText?.(text) ?? Promise.resolve();
|
|
101
107
|
pendingClipboardWrite = write.catch(() => { });
|
|
102
108
|
},
|
|
103
|
-
readClipboardContent: async () => {
|
|
109
|
+
readClipboardContent: async (signal = new AbortController().signal) => {
|
|
104
110
|
await pendingClipboardWrite;
|
|
111
|
+
if (signal.aborted)
|
|
112
|
+
throw new ImageAttachmentError("image-canceled");
|
|
105
113
|
if (options.clipboard === undefined)
|
|
106
|
-
return
|
|
114
|
+
return runImageWorker({ kind: "clipboard" }, signal);
|
|
107
115
|
try {
|
|
108
|
-
const image = await options.clipboard.readImage?.();
|
|
116
|
+
const image = await options.clipboard.readImage?.(signal);
|
|
109
117
|
if (image !== null && image !== undefined) {
|
|
110
|
-
const canonical =
|
|
118
|
+
const canonical = await runImageWorker({ kind: "canonicalize", source: image }, signal);
|
|
111
119
|
if (canonical !== null)
|
|
112
120
|
return { kind: "image", ...canonical };
|
|
113
121
|
}
|
|
114
122
|
}
|
|
115
|
-
catch {
|
|
116
|
-
|
|
123
|
+
catch (error) {
|
|
124
|
+
if (error instanceof ImageAttachmentError)
|
|
125
|
+
throw error;
|
|
126
|
+
// Compatibility: an unavailable native image reader can still provide clipboard text.
|
|
117
127
|
}
|
|
118
|
-
const text = await options.clipboard.readText();
|
|
128
|
+
const text = await options.clipboard.readText(signal);
|
|
119
129
|
return text === null ? null : { kind: "text", text };
|
|
120
130
|
},
|
|
121
131
|
}, {
|
|
@@ -226,8 +236,11 @@ export class OwnedUiSessionShell {
|
|
|
226
236
|
// surface. Letting the transcript pre-router inspect those reports
|
|
227
237
|
// steals settings value menus and numeric +/- controls before the
|
|
228
238
|
// settings app can receive them.
|
|
229
|
-
if (this.runtime.hasOverlay() || !this.root.usesDefaultInputSurface())
|
|
239
|
+
if (this.runtime.hasOverlay() || !this.root.usesDefaultInputSurface()) {
|
|
240
|
+
if (data.includes("\u001b[<"))
|
|
241
|
+
this.root.clearViewportPointerState();
|
|
230
242
|
return undefined;
|
|
243
|
+
}
|
|
231
244
|
const routed = this.root.handleViewportPreInput(data, true);
|
|
232
245
|
if (routed.copyText !== undefined) {
|
|
233
246
|
this.runtime.writeControl(`\u001b]52;c;${Buffer.from(routed.copyText, "utf8").toString("base64")}\u0007`);
|
|
@@ -421,6 +434,45 @@ export class OwnedUiSessionShell {
|
|
|
421
434
|
return this.#stopped;
|
|
422
435
|
}
|
|
423
436
|
async 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();
|
|
474
|
+
}
|
|
475
|
+
async #submit(text) {
|
|
424
476
|
this.#promptSuggestions?.invalidate();
|
|
425
477
|
const displayInput = text.trim();
|
|
426
478
|
if (!displayInput)
|
|
@@ -428,6 +480,7 @@ export class OwnedUiSessionShell {
|
|
|
428
480
|
if (displayInput.startsWith("/"))
|
|
429
481
|
return this.#slashCommand(displayInput);
|
|
430
482
|
const prepared = this.root.preparePromptSubmission(displayInput);
|
|
483
|
+
assertPromptImages(prepared.images);
|
|
431
484
|
const input = prepared.text.trim();
|
|
432
485
|
if (input.startsWith("!")) {
|
|
433
486
|
const excludeFromContext = input.startsWith("!!");
|
|
@@ -458,6 +511,7 @@ export class OwnedUiSessionShell {
|
|
|
458
511
|
this.root.editor.addToHistory(displayInput);
|
|
459
512
|
this.#compactionQueue.push({
|
|
460
513
|
text: input,
|
|
514
|
+
draft: displayInput,
|
|
461
515
|
type: "steer",
|
|
462
516
|
...(prepared.images.length === 0 ? {} : { images: prepared.images }),
|
|
463
517
|
});
|
|
@@ -474,7 +528,7 @@ export class OwnedUiSessionShell {
|
|
|
474
528
|
sessionId: this.backend.sessionId,
|
|
475
529
|
text: input,
|
|
476
530
|
...(prepared.images.length === 0 ? {} : { images: prepared.images }),
|
|
477
|
-
});
|
|
531
|
+
}, displayInput);
|
|
478
532
|
}
|
|
479
533
|
async clearOrExit(now = Date.now()) {
|
|
480
534
|
this.#promptSuggestions?.invalidate();
|
|
@@ -487,6 +541,10 @@ export class OwnedUiSessionShell {
|
|
|
487
541
|
}
|
|
488
542
|
async interrupt(now = Date.now()) {
|
|
489
543
|
this.#promptSuggestions?.invalidate();
|
|
544
|
+
if (this.#waitingImages.size > 0) {
|
|
545
|
+
this.#cancelWaitingImages();
|
|
546
|
+
return { outcome: "completed", diagnostic: null };
|
|
547
|
+
}
|
|
490
548
|
if (this.view().lifecycle === "busy")
|
|
491
549
|
return this.abort();
|
|
492
550
|
if (this.root.editor.getText().trim().length > 0) {
|
|
@@ -565,17 +623,24 @@ export class OwnedUiSessionShell {
|
|
|
565
623
|
return workflowAdapterResult(result);
|
|
566
624
|
}
|
|
567
625
|
async queueFollowUp() {
|
|
568
|
-
const
|
|
626
|
+
const draft = this.root.editor.getText();
|
|
627
|
+
return this.#submitWhenReady(draft, () => this.#queueFollowUp(draft));
|
|
628
|
+
}
|
|
629
|
+
async #queueFollowUp(draft) {
|
|
630
|
+
const displayInput = draft.trim();
|
|
569
631
|
if (!displayInput)
|
|
570
632
|
return rejected("nothing to queue");
|
|
571
633
|
const prepared = this.root.preparePromptSubmission(displayInput);
|
|
634
|
+
assertPromptImages(prepared.images);
|
|
572
635
|
const text = prepared.text.trim();
|
|
573
636
|
this.root.editor.addToHistory(displayInput);
|
|
574
|
-
this.root.editor.
|
|
637
|
+
if (this.root.editor.getText() === draft)
|
|
638
|
+
this.root.editor.setText("");
|
|
575
639
|
this.root.resumeViewportFollowing();
|
|
576
640
|
if (this.view().status.workingMessage?.startsWith("Compacting") === true) {
|
|
577
641
|
this.#compactionQueue.push({
|
|
578
642
|
text,
|
|
643
|
+
draft: displayInput,
|
|
579
644
|
type: "follow-up",
|
|
580
645
|
...(prepared.images.length === 0 ? {} : { images: prepared.images }),
|
|
581
646
|
});
|
|
@@ -587,10 +652,11 @@ export class OwnedUiSessionShell {
|
|
|
587
652
|
sessionId: this.backend.sessionId,
|
|
588
653
|
text,
|
|
589
654
|
...(prepared.images.length === 0 ? {} : { images: prepared.images }),
|
|
590
|
-
});
|
|
655
|
+
}, displayInput);
|
|
591
656
|
}
|
|
592
657
|
restoreQueuedInput() {
|
|
593
|
-
const queued = [...this.backend.clearQueuedWorkflows(), ...this.#compactionQueue.map(item => item.
|
|
658
|
+
const queued = [...this.#waitingImages.keys(), ...this.backend.clearQueuedWorkflows(), ...this.#compactionQueue.map(item => item.draft)];
|
|
659
|
+
this.#cancelWaitingImages();
|
|
594
660
|
this.#compactionQueue = [];
|
|
595
661
|
if (queued.length === 0)
|
|
596
662
|
return;
|
|
@@ -1082,34 +1148,44 @@ export class OwnedUiSessionShell {
|
|
|
1082
1148
|
if (this.#disposed)
|
|
1083
1149
|
return;
|
|
1084
1150
|
this.#disposed = true;
|
|
1085
|
-
this
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
this.#
|
|
1112
|
-
|
|
1151
|
+
this.#cancelWaitingImages();
|
|
1152
|
+
const failures = [];
|
|
1153
|
+
const attempt = (action) => { try {
|
|
1154
|
+
action();
|
|
1155
|
+
}
|
|
1156
|
+
catch (error) {
|
|
1157
|
+
failures.push(error);
|
|
1158
|
+
} };
|
|
1159
|
+
let pasteCleanup = Promise.resolve();
|
|
1160
|
+
attempt(() => { pasteCleanup = this.root.disposePendingPastes(); });
|
|
1161
|
+
attempt(() => this.root.clearViewportPointerState());
|
|
1162
|
+
attempt(() => this.#setPointerReporting(false, true));
|
|
1163
|
+
attempt(() => this.#removeViewportPreInput());
|
|
1164
|
+
attempt(() => this.#streamPresentation.dispose());
|
|
1165
|
+
attempt(() => this.#promptSuggestions?.dispose());
|
|
1166
|
+
attempt(() => this.#unsubscribePromptSuggestions());
|
|
1167
|
+
attempt(() => this.#unsubscribeSettings());
|
|
1168
|
+
let fullscreenExitText = "";
|
|
1169
|
+
attempt(() => {
|
|
1170
|
+
const exitMode = this.backend.disposed ? this.#fullscreenExitOutput : this.backend.pinnedSettingsSnapshot().fullscreenExitOutput;
|
|
1171
|
+
const exitTranscript = this.root.exitTranscript(this.runtime.viewport().columns);
|
|
1172
|
+
const resume = this.backend.currentSessionResumeMetadata();
|
|
1173
|
+
const resumeHint = resume === null ? "" : `${dim("To resume this session:")} ${formatSessionResumeCommand(resume)}`;
|
|
1174
|
+
fullscreenExitText = this.runtime.mode !== "fullscreen" ? ""
|
|
1175
|
+
: exitMode === "resume-hint" ? resumeHint : [exitTranscript, resumeHint].filter(Boolean).join("\n\n");
|
|
1176
|
+
});
|
|
1177
|
+
attempt(() => this.#unbindPiSettings());
|
|
1178
|
+
attempt(() => this.#unbindTerminalSettings());
|
|
1179
|
+
attempt(() => this.#unbindShutdownSettings());
|
|
1180
|
+
attempt(() => this.#unsubscribe());
|
|
1181
|
+
attempt(() => this.#dialogHandle?.hide());
|
|
1182
|
+
attempt(() => this.#extensionBridge.dispose());
|
|
1183
|
+
// Invariant: terminal restoration precedes any potentially stalled backend teardown.
|
|
1184
|
+
await this.runtime.dispose().catch(error => failures.push(error));
|
|
1185
|
+
await boundedCleanup(() => pasteCleanup).catch(error => failures.push(error));
|
|
1186
|
+
await boundedCleanup(() => this.backend.unbindExtensionUi()).catch(error => failures.push(error));
|
|
1187
|
+
if (failures.length > 0)
|
|
1188
|
+
throw new AggregateError(failures, "Owned UI disposal failed");
|
|
1113
1189
|
if (fullscreenExitText.length > 0)
|
|
1114
1190
|
this.runtime.writeAfterStop(`${fullscreenExitText}\n`);
|
|
1115
1191
|
}
|
|
@@ -1139,6 +1215,8 @@ export class OwnedUiSessionShell {
|
|
|
1139
1215
|
this.#streamPresentation.noteImmediatePresentation();
|
|
1140
1216
|
const view = this.view();
|
|
1141
1217
|
if (this.backend.sessionGeneration !== this.#sessionGeneration) {
|
|
1218
|
+
this.#cancelWaitingImages();
|
|
1219
|
+
this.root.resetPendingPastes();
|
|
1142
1220
|
this.#promptSuggestions?.invalidate();
|
|
1143
1221
|
this.#sessionGeneration = this.backend.sessionGeneration;
|
|
1144
1222
|
this.#activeLoginDialog = undefined;
|
|
@@ -1423,11 +1501,60 @@ export class OwnedUiSessionShell {
|
|
|
1423
1501
|
sessionId: this.backend.sessionId,
|
|
1424
1502
|
text: item.text,
|
|
1425
1503
|
...(item.images === undefined ? {} : { images: item.images }),
|
|
1426
|
-
});
|
|
1504
|
+
}, item.draft);
|
|
1505
|
+
}
|
|
1506
|
+
}
|
|
1507
|
+
async #execute(command, draft) {
|
|
1508
|
+
if (draft === undefined)
|
|
1509
|
+
return this.backend.execute(command);
|
|
1510
|
+
const revision = this.#editorRevision;
|
|
1511
|
+
try {
|
|
1512
|
+
assertOwnedUiCommand(command);
|
|
1513
|
+
}
|
|
1514
|
+
catch (error) {
|
|
1515
|
+
return this.#recoverSubmission(draft, revision, error);
|
|
1516
|
+
}
|
|
1517
|
+
try {
|
|
1518
|
+
const result = await this.backend.execute(command);
|
|
1519
|
+
if (result.outcome === "rejected")
|
|
1520
|
+
return this.#recoverSubmission(draft, revision);
|
|
1521
|
+
return result;
|
|
1427
1522
|
}
|
|
1523
|
+
catch {
|
|
1524
|
+
// Security: dispatch might already have reached the provider. Never retry automatically.
|
|
1525
|
+
this.root.editor.addToHistory(draft);
|
|
1526
|
+
this.#reportSubmissionError(undefined, "Submission failed; delivery is uncertain. Check the conversation before retrying. Press Up to recover the draft.");
|
|
1527
|
+
return { outcome: "failed", diagnostic: "submission delivery is uncertain" };
|
|
1528
|
+
}
|
|
1529
|
+
}
|
|
1530
|
+
async #guardSubmission(draft, action) {
|
|
1531
|
+
const revision = this.#editorRevision;
|
|
1532
|
+
try {
|
|
1533
|
+
return await action();
|
|
1534
|
+
}
|
|
1535
|
+
catch (error) {
|
|
1536
|
+
if (error instanceof ImageAttachmentError && error.code === "image-canceled")
|
|
1537
|
+
return rejected("image submission canceled");
|
|
1538
|
+
return this.#recoverSubmission(draft, revision, error);
|
|
1539
|
+
}
|
|
1540
|
+
}
|
|
1541
|
+
#recoverSubmission(draft, revision, error) {
|
|
1542
|
+
this.root.editor.addToHistory(draft);
|
|
1543
|
+
// Concurrency: never overwrite input typed (even typed and cleared) after this submission.
|
|
1544
|
+
if (revision === this.#editorRevision && this.root.editor.getText().length === 0)
|
|
1545
|
+
this.root.editor.setText(draft);
|
|
1546
|
+
const message = error instanceof ImageAttachmentError ? error.message : "Submission rejected. Check the prompt and attachments.";
|
|
1547
|
+
this.#reportSubmissionError(error, `${message} Press Up to recover the draft.`);
|
|
1548
|
+
return rejected(message);
|
|
1428
1549
|
}
|
|
1429
|
-
|
|
1430
|
-
|
|
1550
|
+
#reportSubmissionError(error, message) {
|
|
1551
|
+
// Security: arbitrary provider/extension error messages can contain the entire request.
|
|
1552
|
+
try {
|
|
1553
|
+
this.root.appendWorkflowResult({ command: "debug", outcome: "failed", message: message
|
|
1554
|
+
?? (error instanceof ImageAttachmentError ? error.message : "Submission failed. Check the prompt and try again.") });
|
|
1555
|
+
this.runtime.requestRender();
|
|
1556
|
+
}
|
|
1557
|
+
catch { /* Security: error presentation cannot create another rejected submission callback. */ }
|
|
1431
1558
|
}
|
|
1432
1559
|
#simple(type) {
|
|
1433
1560
|
return { type, correlationId: this.#correlation(type), sessionId: this.backend.sessionId };
|