@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.
Files changed (45) hide show
  1. package/bin/ui.js +14 -2
  2. package/dist/contracts/owned-ui/image-attachments.d.ts +29 -0
  3. package/dist/contracts/owned-ui/image-attachments.js +60 -0
  4. package/dist/contracts/owned-ui/index.d.ts +1 -0
  5. package/dist/contracts/owned-ui/index.js +1 -0
  6. package/dist/contracts/owned-ui/validation.js +2 -9
  7. package/dist/features/owned-ui/run.js +16 -1
  8. package/dist/foundation/release/bootstrap.js +2 -1
  9. package/dist/foundation/terminal-cleanup/fatal-exit.d.ts +12 -0
  10. package/dist/foundation/terminal-cleanup/fatal-exit.js +85 -0
  11. package/dist/foundation/terminal-cleanup/index.d.ts +2 -0
  12. package/dist/foundation/terminal-cleanup/index.js +2 -0
  13. package/dist/foundation/terminal-cleanup/terminal-reset.d.ts +4 -0
  14. package/dist/foundation/terminal-cleanup/terminal-reset.js +40 -0
  15. package/dist/integrations/pi/components/owned-editor-ux.d.ts +6 -0
  16. package/dist/integrations/pi/components/owned-editor-ux.js +71 -0
  17. package/dist/integrations/pi/components/shell-editor-autocomplete.js +2 -0
  18. package/dist/integrations/pi/components/shell-shared-facade.d.ts +6 -1
  19. package/dist/integrations/pi/session-ui/clipboard-image.d.ts +2 -2
  20. package/dist/integrations/pi/session-ui/clipboard-image.js +13 -4
  21. package/dist/integrations/pi/session-ui/image-preparation-client.d.ts +20 -0
  22. package/dist/integrations/pi/session-ui/image-preparation-client.js +145 -0
  23. package/dist/integrations/pi/session-ui/image-preparation.d.ts +18 -0
  24. package/dist/integrations/pi/session-ui/image-preparation.js +126 -0
  25. package/dist/integrations/pi/session-ui/image-source.d.ts +13 -0
  26. package/dist/integrations/pi/session-ui/image-source.js +171 -0
  27. package/dist/integrations/pi/session-ui/image-worker.d.ts +17 -0
  28. package/dist/integrations/pi/session-ui/image-worker.js +23 -0
  29. package/dist/integrations/pi/session-ui/prompt-chips.d.ts +11 -2
  30. package/dist/integrations/pi/session-ui/prompt-chips.js +114 -4
  31. package/dist/integrations/pi/session-ui/session-shell-root.d.ts +8 -3
  32. package/dist/integrations/pi/session-ui/session-shell-root.js +27 -2
  33. package/dist/integrations/pi/session-ui/session-shell.js +179 -52
  34. package/dist/integrations/pi/session-ui/session-viewport-controller.js +17 -7
  35. package/dist/integrations/pi/session-ui/system-clipboard.d.ts +2 -2
  36. package/dist/integrations/pi/session-ui/system-clipboard.js +25 -14
  37. package/dist/integrations/pi/tui-runtime/adapter.js +13 -2
  38. package/dist/native/darwin-arm64/manifest.json +1 -1
  39. package/dist/native/linux-x64/manifest.json +3 -3
  40. package/dist/native/linux-x64/process-guardian +0 -0
  41. package/dist/native/win32-x64/manifest.json +2 -2
  42. package/dist/native/win32-x64/process-guardian.exe +0 -0
  43. package/dist/runtime-payload-inventory.json +22 -18
  44. package/docs/architecture/boundaries.md +2 -1
  45. 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 clipboard records while the prompt displays compact chips. */
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
- transformPastedContent(content) {
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 [tag, chip] of this.#chips) {
85
- 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)
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 => this.#promptChips.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
- ...(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
+ },
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 { canonicalizeClipboardImage } from "./clipboard-image.js";
9
+ import { runImageWorker } from "./image-preparation-client.js";
8
10
  import { STREAM_PRESENTATION_INTERVAL_MS, StreamPresentationCoalescer, } from "./stream-presentation-coalescer.js";
9
- import { preloadSystemClipboard, readSystemClipboardContent, writeSystemClipboardText, } from "./system-clipboard.js";
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: () => promptSuggestionController?.invalidate(),
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 readSystemClipboardContent();
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 = canonicalizeClipboardImage(image);
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
- // Compatibility: treat an unavailable or malformed image as text-capable clipboard input.
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 displayInput = this.root.editor.getText().trim();
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.setText("");
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.text)];
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.root.clearViewportPointerState();
1086
- this.#setPointerReporting(false, true);
1087
- this.#removeViewportPreInput();
1088
- this.#streamPresentation.dispose();
1089
- this.#promptSuggestions?.dispose();
1090
- this.#unsubscribePromptSuggestions();
1091
- this.#unsubscribeSettings();
1092
- const exitMode = this.backend.disposed
1093
- ? this.#fullscreenExitOutput
1094
- : this.backend.pinnedSettingsSnapshot().fullscreenExitOutput;
1095
- const exitTranscript = this.root.exitTranscript(this.runtime.viewport().columns);
1096
- const resume = this.backend.currentSessionResumeMetadata();
1097
- const resumeHint = resume === null
1098
- ? ""
1099
- : `${dim("To resume this session:")} ${formatSessionResumeCommand(resume)}`;
1100
- const fullscreenExitText = this.runtime.mode !== "fullscreen"
1101
- ? ""
1102
- : exitMode === "resume-hint"
1103
- ? resumeHint
1104
- : [exitTranscript, resumeHint].filter(Boolean).join("\n\n");
1105
- this.#unbindPiSettings();
1106
- this.#unbindTerminalSettings();
1107
- this.#unbindShutdownSettings();
1108
- this.#unsubscribe();
1109
- this.#dialogHandle?.hide();
1110
- await this.backend.unbindExtensionUi();
1111
- this.#extensionBridge.dispose();
1112
- await this.runtime.dispose();
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
- async #execute(command) {
1430
- return this.backend.execute(command);
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 };