@timurproko/a1 0.1.8-dev.282 → 0.1.8-dev.287
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 +20 -0
- package/dist/contracts/owned-ui/image-attachments.js +42 -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/session-ui/clipboard-image.js +5 -1
- package/dist/integrations/pi/session-ui/prompt-chips.d.ts +1 -1
- package/dist/integrations/pi/session-ui/prompt-chips.js +4 -1
- package/dist/integrations/pi/session-ui/session-shell-root.d.ts +1 -0
- package/dist/integrations/pi/session-ui/session-shell-root.js +9 -1
- package/dist/integrations/pi/session-ui/session-shell.js +124 -42
- package/dist/integrations/pi/session-ui/session-viewport-controller.js +17 -7
- package/dist/integrations/pi/session-ui/system-clipboard.js +12 -3
- 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 +1 -1
- package/dist/native/win32-x64/manifest.json +2 -2
- package/dist/native/win32-x64/process-guardian.exe +0 -0
- package/docs/architecture/boundaries.md +2 -1
- package/package.json +1 -1
package/bin/ui.js
CHANGED
|
@@ -2,6 +2,16 @@
|
|
|
2
2
|
|
|
3
3
|
const startup = await import("../dist/foundation/startup/index.js");
|
|
4
4
|
startup.enableEnvironmentCompileCache(process.env);
|
|
5
|
+
const profile = process.env.A1_LAUNCH_PROFILE ?? "a1";
|
|
6
|
+
const { installFatalExit } = await import("../dist/foundation/terminal-cleanup/index.js");
|
|
7
|
+
const { resolveProductPaths } = await import("../dist/foundation/lifecycle/index.js");
|
|
8
|
+
const { join } = await import("node:path");
|
|
9
|
+
let runningApplication;
|
|
10
|
+
const fatal = profile === "a1" ? installFatalExit({
|
|
11
|
+
directory: join(resolveProductPaths().runtimeDir, "crashes"),
|
|
12
|
+
releaseId: process.env.A1_RELEASE_ID,
|
|
13
|
+
dispose: () => runningApplication?.dispose(),
|
|
14
|
+
}) : undefined;
|
|
5
15
|
// Performance: begin the exact launch graph together while the trace write is pending.
|
|
6
16
|
// Direct owned-module entries avoid evaluating unrelated barrel exports before first paint.
|
|
7
17
|
const modules = Promise.all([
|
|
@@ -32,7 +42,6 @@ assertSinglePiTuiModuleAtLaunch(fileURLToPath(new URL("..", import.meta.url)), m
|
|
|
32
42
|
await startup.markStartupPhase(process.env, "ui-modules-loaded");
|
|
33
43
|
|
|
34
44
|
const sessionSelection = parseSessionSelection(process.argv.slice(2));
|
|
35
|
-
const profile = process.env.A1_LAUNCH_PROFILE ?? "a1";
|
|
36
45
|
Promise.resolve().then(() => {
|
|
37
46
|
if (sessionSelection && profile !== "a1") throw new Error("session selection requires the normal A1 profile");
|
|
38
47
|
return runSelectedInteractiveRuntime(profile, {
|
|
@@ -45,12 +54,15 @@ Promise.resolve().then(() => {
|
|
|
45
54
|
sessionForkPrompt: createConsoleSessionForkPrompt(),
|
|
46
55
|
...(sessionSelection === undefined ? {} : { sessionSelection }),
|
|
47
56
|
});
|
|
57
|
+
runningApplication = application;
|
|
48
58
|
return await runOwnedUi({ application, ...(settings === null ? {} : { settings }) });
|
|
49
59
|
},
|
|
50
60
|
});
|
|
51
61
|
}).then(
|
|
52
|
-
code => { process.exitCode = code; },
|
|
62
|
+
code => { fatal?.remove(); process.exitCode = code; },
|
|
53
63
|
error => {
|
|
64
|
+
if (fatal && error?.name !== "PiSessionSelectionError") { fatal.fail(error); return; }
|
|
65
|
+
fatal?.remove();
|
|
54
66
|
console.error(error instanceof Error ? error.message : String(error));
|
|
55
67
|
process.exitCode = error?.name === "PiSessionSelectionError" ? error.exitCode : 1;
|
|
56
68
|
},
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { OwnedUiImageAttachment } from "./model.js";
|
|
2
|
+
export declare const MAX_PROMPT_IMAGES = 8;
|
|
3
|
+
/** Limit applies to canonical ASCII base64, not decoded image bytes. */
|
|
4
|
+
export declare const MAX_IMAGE_DATA_BYTES: number;
|
|
5
|
+
declare const MESSAGES: {
|
|
6
|
+
readonly "image-size": "Image exceeds the 8 MiB encoded base64 limit. Reduce the image size and paste it again.";
|
|
7
|
+
readonly "image-count": "A prompt supports at most 8 images. Remove an attachment before adding another.";
|
|
8
|
+
readonly "image-data": "Image data is invalid. Paste a valid image again.";
|
|
9
|
+
readonly "image-mime": "Image MIME type is invalid. Paste a supported image again.";
|
|
10
|
+
};
|
|
11
|
+
/** Trusted, payload-free diagnostics for user-correctable attachment failures. */
|
|
12
|
+
export declare class ImageAttachmentError extends TypeError {
|
|
13
|
+
readonly code: keyof typeof MESSAGES;
|
|
14
|
+
constructor(code: keyof typeof MESSAGES);
|
|
15
|
+
}
|
|
16
|
+
/** Checks encoded length before decoding or copying a clipboard payload. */
|
|
17
|
+
export declare function assertImageEncodedSize(data: string): void;
|
|
18
|
+
/** Final command admission also covers restored and deferred non-clipboard inputs. */
|
|
19
|
+
export declare function assertPromptImages(images: readonly OwnedUiImageAttachment[]): void;
|
|
20
|
+
export {};
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
export const MAX_PROMPT_IMAGES = 8;
|
|
2
|
+
/** Limit applies to canonical ASCII base64, not decoded image bytes. */
|
|
3
|
+
export const MAX_IMAGE_DATA_BYTES = 8 * 1024 * 1024;
|
|
4
|
+
const MESSAGES = {
|
|
5
|
+
"image-size": "Image exceeds the 8 MiB encoded base64 limit. Reduce the image size and paste it again.",
|
|
6
|
+
"image-count": "A prompt supports at most 8 images. Remove an attachment before adding another.",
|
|
7
|
+
"image-data": "Image data is invalid. Paste a valid image again.",
|
|
8
|
+
"image-mime": "Image MIME type is invalid. Paste a supported image again.",
|
|
9
|
+
};
|
|
10
|
+
/** Trusted, payload-free diagnostics for user-correctable attachment failures. */
|
|
11
|
+
export class ImageAttachmentError extends TypeError {
|
|
12
|
+
code;
|
|
13
|
+
constructor(code) {
|
|
14
|
+
super(MESSAGES[code]);
|
|
15
|
+
this.code = code;
|
|
16
|
+
this.name = "ImageAttachmentError";
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
/** Checks encoded length before decoding or copying a clipboard payload. */
|
|
20
|
+
export function assertImageEncodedSize(data) {
|
|
21
|
+
if (typeof data !== "string" || data.length === 0)
|
|
22
|
+
throw new ImageAttachmentError("image-data");
|
|
23
|
+
if (Math.ceil(data.length / 4) * 4 > MAX_IMAGE_DATA_BYTES)
|
|
24
|
+
throw new ImageAttachmentError("image-size");
|
|
25
|
+
}
|
|
26
|
+
/** Final command admission also covers restored and deferred non-clipboard inputs. */
|
|
27
|
+
export function assertPromptImages(images) {
|
|
28
|
+
if (!Array.isArray(images) || images.length > MAX_PROMPT_IMAGES)
|
|
29
|
+
throw new ImageAttachmentError("image-count");
|
|
30
|
+
for (const image of images) {
|
|
31
|
+
if (!image || image.type !== "image")
|
|
32
|
+
throw new ImageAttachmentError("image-data");
|
|
33
|
+
assertImageEncodedSize(image.data);
|
|
34
|
+
if (!/^[A-Za-z0-9+/]+={0,2}$/.test(image.data) || image.data.length % 4 !== 0
|
|
35
|
+
|| Buffer.from(image.data, "base64").toString("base64") !== image.data) {
|
|
36
|
+
throw new ImageAttachmentError("image-data");
|
|
37
|
+
}
|
|
38
|
+
if (typeof image.mimeType !== "string" || image.mimeType.length > 256 || !/^image\/[a-z0-9.+-]+$/i.test(image.mimeType)) {
|
|
39
|
+
throw new ImageAttachmentError("image-mime");
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
import { OWNED_UI_CONTRACT_VERSION, } from "./model.js";
|
|
2
|
+
import { assertPromptImages } from "./image-attachments.js";
|
|
2
3
|
const MAX_ID_LENGTH = 128;
|
|
3
4
|
const MAX_LABEL_LENGTH = 256;
|
|
4
5
|
const MAX_MESSAGE_LENGTH = 4_096;
|
|
5
6
|
const MAX_TEXT_BYTES = 256 * 1024;
|
|
6
7
|
const MAX_PAYLOAD_BYTES = 64 * 1024;
|
|
7
8
|
const MAX_SESSION_VIEW_BYTES = 1024 * 1024;
|
|
8
|
-
const MAX_IMAGE_DATA_BYTES = 8 * 1024 * 1024;
|
|
9
|
-
const MAX_PROMPT_IMAGES = 8;
|
|
10
9
|
const MAX_BLOCKS = 10_000;
|
|
11
10
|
const MAX_CUSTOMIZATIONS = 1_000;
|
|
12
11
|
const MAX_DIAGNOSTICS = 1_000;
|
|
@@ -57,13 +56,7 @@ export function assertOwnedUiCommand(command) {
|
|
|
57
56
|
case "follow-up":
|
|
58
57
|
assertBoundedText(command.text, "owned-UI prompt text", MAX_TEXT_BYTES);
|
|
59
58
|
if (command.images !== undefined) {
|
|
60
|
-
|
|
61
|
-
for (const image of command.images) {
|
|
62
|
-
if (image.type !== "image")
|
|
63
|
-
throw new TypeError("owned-UI prompt image type is invalid");
|
|
64
|
-
assertBoundedText(image.data, "owned-UI prompt image data", MAX_IMAGE_DATA_BYTES);
|
|
65
|
-
assertBoundedText(image.mimeType, "owned-UI prompt image MIME type", MAX_LABEL_LENGTH);
|
|
66
|
-
}
|
|
59
|
+
assertPromptImages(command.images);
|
|
67
60
|
}
|
|
68
61
|
return;
|
|
69
62
|
case "abort":
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { markStartupPhase } from "../../foundation/startup/index.js";
|
|
2
|
+
import { boundedCleanup } from "../../foundation/terminal-cleanup/index.js";
|
|
2
3
|
export async function runOwnedUi(options) {
|
|
3
4
|
const { application, settings } = options;
|
|
5
|
+
let failed = false;
|
|
6
|
+
let originalFailure;
|
|
4
7
|
try {
|
|
5
8
|
if (settings)
|
|
6
9
|
await settings.load();
|
|
@@ -11,7 +14,19 @@ export async function runOwnedUi(options) {
|
|
|
11
14
|
await application.waitUntilStopped();
|
|
12
15
|
return 0;
|
|
13
16
|
}
|
|
17
|
+
catch (error) {
|
|
18
|
+
failed = true;
|
|
19
|
+
originalFailure = error;
|
|
20
|
+
throw error;
|
|
21
|
+
}
|
|
14
22
|
finally {
|
|
15
|
-
|
|
23
|
+
try {
|
|
24
|
+
await boundedCleanup(() => application.dispose(), 2500);
|
|
25
|
+
}
|
|
26
|
+
catch (error) {
|
|
27
|
+
if (failed)
|
|
28
|
+
throw new AggregateError([originalFailure, error], "Owned UI run and cleanup failed", { cause: originalFailure });
|
|
29
|
+
throw error;
|
|
30
|
+
}
|
|
16
31
|
}
|
|
17
32
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
|
+
import { restoreAfterOwnedExit } from "../terminal-cleanup/index.js";
|
|
2
3
|
import { chmod, mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
3
4
|
import { connect } from "node:net";
|
|
4
5
|
import { platform } from "node:os";
|
|
@@ -234,7 +235,7 @@ async function launchUi(release, environment, sessionArgs) {
|
|
|
234
235
|
windowsHide: false,
|
|
235
236
|
});
|
|
236
237
|
child.once("error", rejectPromise);
|
|
237
|
-
child.once("close", (code, signal) => resolvePromise(
|
|
238
|
+
child.once("close", (code, signal) => resolvePromise(restoreAfterOwnedExit(environment[PRODUCT_IDENTITY.environment.launchProfile] === "a1", code, signal)));
|
|
238
239
|
});
|
|
239
240
|
}
|
|
240
241
|
export function releaseEnvironment(environment, release) {
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export interface FatalExitOptions {
|
|
2
|
+
readonly directory: string;
|
|
3
|
+
readonly releaseId?: string;
|
|
4
|
+
readonly restore?: () => void;
|
|
5
|
+
readonly dispose?: () => Promise<unknown> | void;
|
|
6
|
+
readonly timeoutMs?: number;
|
|
7
|
+
}
|
|
8
|
+
export declare function writeFatalDiagnostic(directory: string, origin: string, error: unknown, releaseId?: string): Promise<string | null>;
|
|
9
|
+
export declare function installFatalExit(options: FatalExitOptions): {
|
|
10
|
+
fail(error: unknown): void;
|
|
11
|
+
remove(): void;
|
|
12
|
+
};
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { mkdir, readdir, unlink, writeFile } from "node:fs/promises";
|
|
3
|
+
import { writeSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { boundedCleanup, restoreProcessTerminal } from "./terminal-reset.js";
|
|
6
|
+
export async function writeFatalDiagnostic(directory, origin, error, releaseId) {
|
|
7
|
+
try {
|
|
8
|
+
// Security: only known classifications and code locations are retained. Error messages,
|
|
9
|
+
// function names, arbitrary paths, and request objects are never persisted.
|
|
10
|
+
const cleanupFailed = error instanceof AggregateError && error.cause instanceof Error;
|
|
11
|
+
const primary = cleanupFailed ? error.cause : error;
|
|
12
|
+
const classification = primary instanceof TypeError ? "TypeError" : primary instanceof RangeError ? "RangeError" : "Error";
|
|
13
|
+
const stack = primary instanceof Error && typeof primary.stack === "string" ? primary.stack.slice(0, 32768) : "";
|
|
14
|
+
const locations = stack.split("\n").slice(1).flatMap(line => {
|
|
15
|
+
const match = /\/(?:dist|src)\/([a-zA-Z0-9_./-]+\.[cm]?[jt]s):(\d+):(\d+)\)?$/.exec(line.replaceAll("\\", "/"));
|
|
16
|
+
return match ? [{ file: match[1].slice(0, 160), line: Number(match[2]), column: Number(match[3]) }] : [];
|
|
17
|
+
}).slice(0, 16);
|
|
18
|
+
const record = {
|
|
19
|
+
timestamp: new Date().toISOString(),
|
|
20
|
+
origin: ["uncaughtException", "unhandledRejection", "entry"].includes(origin) ? origin : "entry",
|
|
21
|
+
classification, cleanupFailed, locations, node: process.version, platform: process.platform,
|
|
22
|
+
releaseId: releaseId && /^[a-zA-Z0-9._-]{1,128}$/.test(releaseId) ? releaseId : "unknown",
|
|
23
|
+
};
|
|
24
|
+
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
25
|
+
const name = `fatal-${Date.now()}-${randomUUID()}.json`;
|
|
26
|
+
const path = join(directory, name);
|
|
27
|
+
const text = JSON.stringify(record, null, 2);
|
|
28
|
+
if (Buffer.byteLength(text) > 16 * 1024)
|
|
29
|
+
return null;
|
|
30
|
+
await writeFile(path, text, { flag: "wx", mode: 0o600 });
|
|
31
|
+
const names = (await readdir(directory)).filter(file => /^fatal-\d+-[a-f0-9-]+\.json$/.test(file)).sort().reverse();
|
|
32
|
+
await Promise.all(names.slice(10).map(file => unlink(join(directory, file)).catch(() => undefined)));
|
|
33
|
+
return path;
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
export function installFatalExit(options) {
|
|
40
|
+
let failing = false;
|
|
41
|
+
const restore = () => { try {
|
|
42
|
+
(options.restore ?? restoreProcessTerminal)();
|
|
43
|
+
}
|
|
44
|
+
catch { } };
|
|
45
|
+
const terminate = (origin, error) => {
|
|
46
|
+
if (failing)
|
|
47
|
+
return;
|
|
48
|
+
failing = true;
|
|
49
|
+
process.exitCode = 1;
|
|
50
|
+
// Security: stop accepting input immediately; fatal errors never resume normal work.
|
|
51
|
+
try {
|
|
52
|
+
process.stdin.pause();
|
|
53
|
+
process.stdin.removeAllListeners("data");
|
|
54
|
+
}
|
|
55
|
+
catch { }
|
|
56
|
+
restore();
|
|
57
|
+
let record = null;
|
|
58
|
+
let finished = false;
|
|
59
|
+
const finish = () => {
|
|
60
|
+
if (finished)
|
|
61
|
+
return;
|
|
62
|
+
finished = true;
|
|
63
|
+
restore();
|
|
64
|
+
try {
|
|
65
|
+
const location = record?.replace(/[\x00-\x1f\x7f]/g, "").slice(0, 512);
|
|
66
|
+
writeSync(2, `A1 stopped after an unexpected error.${location ? ` Diagnostic: ${location}` : " Diagnostic storage unavailable."}\n`);
|
|
67
|
+
}
|
|
68
|
+
catch { }
|
|
69
|
+
process.exit(1);
|
|
70
|
+
};
|
|
71
|
+
const deadline = setTimeout(finish, options.timeoutMs ?? 1000);
|
|
72
|
+
void Promise.all([
|
|
73
|
+
writeFatalDiagnostic(options.directory, origin, error, options.releaseId).then(path => { record = path; }),
|
|
74
|
+
boundedCleanup(() => options.dispose?.(), options.timeoutMs ?? 1000).catch(() => undefined),
|
|
75
|
+
]).then(() => { clearTimeout(deadline); finish(); }, finish);
|
|
76
|
+
};
|
|
77
|
+
const exception = (error) => terminate("uncaughtException", error);
|
|
78
|
+
const rejection = (error) => terminate("unhandledRejection", error);
|
|
79
|
+
process.on("uncaughtException", exception);
|
|
80
|
+
process.on("unhandledRejection", rejection);
|
|
81
|
+
return {
|
|
82
|
+
fail: error => terminate("entry", error),
|
|
83
|
+
remove() { process.off("uncaughtException", exception); process.off("unhandledRejection", rejection); },
|
|
84
|
+
};
|
|
85
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export declare const EMERGENCY_TERMINAL_RESET: string;
|
|
2
|
+
export declare function restoreProcessTerminal(): void;
|
|
3
|
+
export declare function restoreAfterOwnedExit(ownsTerminal: boolean, code: number | null, signal: string | null, restore?: () => void): number;
|
|
4
|
+
export declare function boundedCleanup(cleanup: () => Promise<unknown> | void, timeoutMs?: number): Promise<void>;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { writeSync } from "node:fs";
|
|
2
|
+
// Protocol: reset modes on both screens; setting keyboard flags to zero is idempotent,
|
|
3
|
+
// unlike repeatedly popping a keyboard-protocol stack owned by a parent application.
|
|
4
|
+
export const EMERGENCY_TERMINAL_RESET = "\x1b[?2026l\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1004l\x1b[?1006l"
|
|
5
|
+
+ "\x1b[?2004l\x1b[=0u\x1b[>4;0m\x1b]8;;\x1b\\\x1b[0m\x1b[r\x1b[?7h\x1b[?25h"
|
|
6
|
+
+ "\x1b[?1049l\x1b[r\x1b[?7h\x1b[?25h\x1b]9;4;0\x07";
|
|
7
|
+
export function restoreProcessTerminal() {
|
|
8
|
+
try {
|
|
9
|
+
if (process.stdout.isTTY)
|
|
10
|
+
writeSync(process.stdout.fd, EMERGENCY_TERMINAL_RESET);
|
|
11
|
+
}
|
|
12
|
+
catch { }
|
|
13
|
+
try {
|
|
14
|
+
if (process.stdin.isTTY)
|
|
15
|
+
process.stdin.setRawMode(false);
|
|
16
|
+
}
|
|
17
|
+
catch { }
|
|
18
|
+
}
|
|
19
|
+
export function restoreAfterOwnedExit(ownsTerminal, code, signal, restore = restoreProcessTerminal) {
|
|
20
|
+
const outcome = code ?? (signal ? 1 : 0);
|
|
21
|
+
if (ownsTerminal && outcome !== 0) {
|
|
22
|
+
try {
|
|
23
|
+
restore();
|
|
24
|
+
}
|
|
25
|
+
catch { }
|
|
26
|
+
}
|
|
27
|
+
return outcome;
|
|
28
|
+
}
|
|
29
|
+
export async function boundedCleanup(cleanup, timeoutMs = 1000) {
|
|
30
|
+
let timer;
|
|
31
|
+
try {
|
|
32
|
+
await Promise.race([
|
|
33
|
+
Promise.resolve().then(cleanup),
|
|
34
|
+
new Promise((_, reject) => { timer = setTimeout(() => reject(new Error("Cleanup deadline exceeded")), timeoutMs); }),
|
|
35
|
+
]);
|
|
36
|
+
}
|
|
37
|
+
finally {
|
|
38
|
+
clearTimeout(timer);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { assertImageEncodedSize } from "../../../contracts/owned-ui/index.js";
|
|
1
2
|
const STANDARD_BASE64 = /^[A-Za-z0-9+/]*={0,2}$/u;
|
|
2
3
|
/**
|
|
3
4
|
* Converts valid padded or unpadded standard base64 into its unique padded form.
|
|
@@ -9,7 +10,10 @@ export function canonicalizeClipboardImage(image) {
|
|
|
9
10
|
return data === null ? null : { data, mimeType: image.mimeType };
|
|
10
11
|
}
|
|
11
12
|
export function canonicalizeStandardBase64(value) {
|
|
12
|
-
if (value.length === 0
|
|
13
|
+
if (typeof value !== "string" || value.length === 0)
|
|
14
|
+
return null;
|
|
15
|
+
assertImageEncodedSize(value);
|
|
16
|
+
if (!STANDARD_BASE64.test(value))
|
|
13
17
|
return null;
|
|
14
18
|
const paddingStart = value.indexOf("=");
|
|
15
19
|
const core = paddingStart < 0 ? value : value.slice(0, paddingStart);
|
|
@@ -11,7 +11,7 @@ export interface PreparedPrompt {
|
|
|
11
11
|
/** Owns semantic clipboard records while the prompt displays compact chips. */
|
|
12
12
|
export declare class PromptChipStore {
|
|
13
13
|
#private;
|
|
14
|
-
transformPastedContent(content: PiShellClipboardContent): string;
|
|
14
|
+
transformPastedContent(content: PiShellClipboardContent, currentText?: string): string;
|
|
15
15
|
atomicRanges(line: string): readonly PiShellEditorTextRange[];
|
|
16
16
|
hyperlinkRanges(text: string): readonly {
|
|
17
17
|
start: number;
|
|
@@ -3,6 +3,7 @@ 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
7
|
const CHIP_PATTERN = /\[(?:📷 [^\]]+|📁 [^\]]+|📄 [^\]]+|🖼 {1,2}[^\]]+|🔗 [^\]]+)\]/gu;
|
|
7
8
|
const IMAGE_EXTENSION = /\.(?:jpe?g|png|webp|gif|bmp|tiff?)$/iu;
|
|
8
9
|
const URL_PATTERN = /^https?:\/\/[^\s\u0000-\u001f\u007f]+$/iu;
|
|
@@ -10,11 +11,13 @@ const URL_DISPLAY_LENGTH = 40;
|
|
|
10
11
|
/** Owns semantic clipboard records while the prompt displays compact chips. */
|
|
11
12
|
export class PromptChipStore {
|
|
12
13
|
#chips = new Map();
|
|
13
|
-
transformPastedContent(content) {
|
|
14
|
+
transformPastedContent(content, currentText = "") {
|
|
14
15
|
if (content.kind === "image") {
|
|
16
|
+
assertImageEncodedSize(content.data);
|
|
15
17
|
const image = canonicalizeClipboardImage(content);
|
|
16
18
|
if (image === null)
|
|
17
19
|
return "";
|
|
20
|
+
assertPromptImages([...this.prepareSubmission(currentText).images, { type: "image", ...image }]);
|
|
18
21
|
const id = randomBytes(5).toString("hex");
|
|
19
22
|
const tag = `[📷 screenshot-${id}.png]`;
|
|
20
23
|
this.#chips.set(tag, {
|
|
@@ -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;
|
|
@@ -85,7 +85,15 @@ 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
|
+
},
|
|
89
97
|
editorAtomicRanges: line => this.#promptChips.atomicRanges(line),
|
|
90
98
|
decorateEditorRow: (row, width) => {
|
|
91
99
|
const plain = stripAnsi(row);
|
|
@@ -1,4 +1,6 @@
|
|
|
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";
|
|
@@ -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;
|
|
@@ -76,7 +79,8 @@ export class OwnedUiSessionShell {
|
|
|
76
79
|
replacementSurfaceActive: !this.root.usesDefaultInputSurface(),
|
|
77
80
|
}),
|
|
78
81
|
enableDockInputReuse: options.inputPresentation?.viewportReuse !== false,
|
|
79
|
-
onSubmit: text => { void this.submit(text); },
|
|
82
|
+
onSubmit: text => { void this.submit(text).catch(() => this.#reportSubmissionError()); },
|
|
83
|
+
onPasteRejected: error => this.#reportSubmissionError(error),
|
|
80
84
|
onInterrupt: () => { void this.interrupt(); },
|
|
81
85
|
onClear: () => { void this.clearOrExit(); },
|
|
82
86
|
onExit: () => { void this.shutdown(); },
|
|
@@ -88,11 +92,14 @@ export class OwnedUiSessionShell {
|
|
|
88
92
|
this.runtime.requestRender();
|
|
89
93
|
},
|
|
90
94
|
onMessageCopy: () => { void this.runWorkflow({ command: "copy", argument: "" }); },
|
|
91
|
-
onFollowUp: () => { void this.queueFollowUp(); },
|
|
95
|
+
onFollowUp: () => { void this.queueFollowUp().catch(() => this.#reportSubmissionError()); },
|
|
92
96
|
onDequeue: () => this.restoreQueuedInput(),
|
|
93
|
-
onEditorChange: () => promptSuggestionController?.invalidate(),
|
|
97
|
+
onEditorChange: () => { this.#editorRevision++; promptSuggestionController?.invalidate(); },
|
|
94
98
|
onPromptSuggestionAccepted: () => promptSuggestionController?.accept(),
|
|
95
|
-
onInputSurfaceChanged: () =>
|
|
99
|
+
onInputSurfaceChanged: () => {
|
|
100
|
+
this.root.clearViewportPointerState();
|
|
101
|
+
promptSuggestionController?.invalidate();
|
|
102
|
+
},
|
|
96
103
|
onCopyText: text => {
|
|
97
104
|
runtime?.writeControl(`\u001b]52;c;${Buffer.from(text, "utf8").toString("base64")}\u0007`);
|
|
98
105
|
const write = options.clipboard === undefined
|
|
@@ -103,7 +110,11 @@ export class OwnedUiSessionShell {
|
|
|
103
110
|
readClipboardContent: async () => {
|
|
104
111
|
await pendingClipboardWrite;
|
|
105
112
|
if (options.clipboard === undefined)
|
|
106
|
-
return readSystemClipboardContent()
|
|
113
|
+
return readSystemClipboardContent().catch(error => {
|
|
114
|
+
if (error instanceof ImageAttachmentError)
|
|
115
|
+
this.#reportSubmissionError(error);
|
|
116
|
+
return null;
|
|
117
|
+
});
|
|
107
118
|
try {
|
|
108
119
|
const image = await options.clipboard.readImage?.();
|
|
109
120
|
if (image !== null && image !== undefined) {
|
|
@@ -112,7 +123,11 @@ export class OwnedUiSessionShell {
|
|
|
112
123
|
return { kind: "image", ...canonical };
|
|
113
124
|
}
|
|
114
125
|
}
|
|
115
|
-
catch {
|
|
126
|
+
catch (error) {
|
|
127
|
+
if (error instanceof ImageAttachmentError) {
|
|
128
|
+
this.#reportSubmissionError(error);
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
116
131
|
// Compatibility: treat an unavailable or malformed image as text-capable clipboard input.
|
|
117
132
|
}
|
|
118
133
|
const text = await options.clipboard.readText();
|
|
@@ -226,8 +241,11 @@ export class OwnedUiSessionShell {
|
|
|
226
241
|
// surface. Letting the transcript pre-router inspect those reports
|
|
227
242
|
// steals settings value menus and numeric +/- controls before the
|
|
228
243
|
// settings app can receive them.
|
|
229
|
-
if (this.runtime.hasOverlay() || !this.root.usesDefaultInputSurface())
|
|
244
|
+
if (this.runtime.hasOverlay() || !this.root.usesDefaultInputSurface()) {
|
|
245
|
+
if (data.includes("\u001b[<"))
|
|
246
|
+
this.root.clearViewportPointerState();
|
|
230
247
|
return undefined;
|
|
248
|
+
}
|
|
231
249
|
const routed = this.root.handleViewportPreInput(data, true);
|
|
232
250
|
if (routed.copyText !== undefined) {
|
|
233
251
|
this.runtime.writeControl(`\u001b]52;c;${Buffer.from(routed.copyText, "utf8").toString("base64")}\u0007`);
|
|
@@ -421,6 +439,9 @@ export class OwnedUiSessionShell {
|
|
|
421
439
|
return this.#stopped;
|
|
422
440
|
}
|
|
423
441
|
async submit(text) {
|
|
442
|
+
return this.#guardSubmission(text, () => this.#submit(text));
|
|
443
|
+
}
|
|
444
|
+
async #submit(text) {
|
|
424
445
|
this.#promptSuggestions?.invalidate();
|
|
425
446
|
const displayInput = text.trim();
|
|
426
447
|
if (!displayInput)
|
|
@@ -428,6 +449,7 @@ export class OwnedUiSessionShell {
|
|
|
428
449
|
if (displayInput.startsWith("/"))
|
|
429
450
|
return this.#slashCommand(displayInput);
|
|
430
451
|
const prepared = this.root.preparePromptSubmission(displayInput);
|
|
452
|
+
assertPromptImages(prepared.images);
|
|
431
453
|
const input = prepared.text.trim();
|
|
432
454
|
if (input.startsWith("!")) {
|
|
433
455
|
const excludeFromContext = input.startsWith("!!");
|
|
@@ -458,6 +480,7 @@ export class OwnedUiSessionShell {
|
|
|
458
480
|
this.root.editor.addToHistory(displayInput);
|
|
459
481
|
this.#compactionQueue.push({
|
|
460
482
|
text: input,
|
|
483
|
+
draft: displayInput,
|
|
461
484
|
type: "steer",
|
|
462
485
|
...(prepared.images.length === 0 ? {} : { images: prepared.images }),
|
|
463
486
|
});
|
|
@@ -474,7 +497,7 @@ export class OwnedUiSessionShell {
|
|
|
474
497
|
sessionId: this.backend.sessionId,
|
|
475
498
|
text: input,
|
|
476
499
|
...(prepared.images.length === 0 ? {} : { images: prepared.images }),
|
|
477
|
-
});
|
|
500
|
+
}, displayInput);
|
|
478
501
|
}
|
|
479
502
|
async clearOrExit(now = Date.now()) {
|
|
480
503
|
this.#promptSuggestions?.invalidate();
|
|
@@ -565,10 +588,15 @@ export class OwnedUiSessionShell {
|
|
|
565
588
|
return workflowAdapterResult(result);
|
|
566
589
|
}
|
|
567
590
|
async queueFollowUp() {
|
|
568
|
-
const
|
|
591
|
+
const draft = this.root.editor.getText();
|
|
592
|
+
return this.#guardSubmission(draft, () => this.#queueFollowUp(draft));
|
|
593
|
+
}
|
|
594
|
+
async #queueFollowUp(draft) {
|
|
595
|
+
const displayInput = draft.trim();
|
|
569
596
|
if (!displayInput)
|
|
570
597
|
return rejected("nothing to queue");
|
|
571
598
|
const prepared = this.root.preparePromptSubmission(displayInput);
|
|
599
|
+
assertPromptImages(prepared.images);
|
|
572
600
|
const text = prepared.text.trim();
|
|
573
601
|
this.root.editor.addToHistory(displayInput);
|
|
574
602
|
this.root.editor.setText("");
|
|
@@ -576,6 +604,7 @@ export class OwnedUiSessionShell {
|
|
|
576
604
|
if (this.view().status.workingMessage?.startsWith("Compacting") === true) {
|
|
577
605
|
this.#compactionQueue.push({
|
|
578
606
|
text,
|
|
607
|
+
draft: displayInput,
|
|
579
608
|
type: "follow-up",
|
|
580
609
|
...(prepared.images.length === 0 ? {} : { images: prepared.images }),
|
|
581
610
|
});
|
|
@@ -587,10 +616,10 @@ export class OwnedUiSessionShell {
|
|
|
587
616
|
sessionId: this.backend.sessionId,
|
|
588
617
|
text,
|
|
589
618
|
...(prepared.images.length === 0 ? {} : { images: prepared.images }),
|
|
590
|
-
});
|
|
619
|
+
}, displayInput);
|
|
591
620
|
}
|
|
592
621
|
restoreQueuedInput() {
|
|
593
|
-
const queued = [...this.backend.clearQueuedWorkflows(), ...this.#compactionQueue.map(item => item.
|
|
622
|
+
const queued = [...this.backend.clearQueuedWorkflows(), ...this.#compactionQueue.map(item => item.draft)];
|
|
594
623
|
this.#compactionQueue = [];
|
|
595
624
|
if (queued.length === 0)
|
|
596
625
|
return;
|
|
@@ -1082,34 +1111,40 @@ export class OwnedUiSessionShell {
|
|
|
1082
1111
|
if (this.#disposed)
|
|
1083
1112
|
return;
|
|
1084
1113
|
this.#disposed = true;
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
?
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
this.#
|
|
1109
|
-
this.#
|
|
1110
|
-
|
|
1111
|
-
this.#
|
|
1112
|
-
|
|
1114
|
+
const failures = [];
|
|
1115
|
+
const attempt = (action) => { try {
|
|
1116
|
+
action();
|
|
1117
|
+
}
|
|
1118
|
+
catch (error) {
|
|
1119
|
+
failures.push(error);
|
|
1120
|
+
} };
|
|
1121
|
+
attempt(() => this.root.clearViewportPointerState());
|
|
1122
|
+
attempt(() => this.#setPointerReporting(false, true));
|
|
1123
|
+
attempt(() => this.#removeViewportPreInput());
|
|
1124
|
+
attempt(() => this.#streamPresentation.dispose());
|
|
1125
|
+
attempt(() => this.#promptSuggestions?.dispose());
|
|
1126
|
+
attempt(() => this.#unsubscribePromptSuggestions());
|
|
1127
|
+
attempt(() => this.#unsubscribeSettings());
|
|
1128
|
+
let fullscreenExitText = "";
|
|
1129
|
+
attempt(() => {
|
|
1130
|
+
const exitMode = this.backend.disposed ? this.#fullscreenExitOutput : this.backend.pinnedSettingsSnapshot().fullscreenExitOutput;
|
|
1131
|
+
const exitTranscript = this.root.exitTranscript(this.runtime.viewport().columns);
|
|
1132
|
+
const resume = this.backend.currentSessionResumeMetadata();
|
|
1133
|
+
const resumeHint = resume === null ? "" : `${dim("To resume this session:")} ${formatSessionResumeCommand(resume)}`;
|
|
1134
|
+
fullscreenExitText = this.runtime.mode !== "fullscreen" ? ""
|
|
1135
|
+
: exitMode === "resume-hint" ? resumeHint : [exitTranscript, resumeHint].filter(Boolean).join("\n\n");
|
|
1136
|
+
});
|
|
1137
|
+
attempt(() => this.#unbindPiSettings());
|
|
1138
|
+
attempt(() => this.#unbindTerminalSettings());
|
|
1139
|
+
attempt(() => this.#unbindShutdownSettings());
|
|
1140
|
+
attempt(() => this.#unsubscribe());
|
|
1141
|
+
attempt(() => this.#dialogHandle?.hide());
|
|
1142
|
+
attempt(() => this.#extensionBridge.dispose());
|
|
1143
|
+
// Invariant: terminal restoration precedes any potentially stalled backend teardown.
|
|
1144
|
+
await this.runtime.dispose().catch(error => failures.push(error));
|
|
1145
|
+
await boundedCleanup(() => this.backend.unbindExtensionUi()).catch(error => failures.push(error));
|
|
1146
|
+
if (failures.length > 0)
|
|
1147
|
+
throw new AggregateError(failures, "Owned UI disposal failed");
|
|
1113
1148
|
if (fullscreenExitText.length > 0)
|
|
1114
1149
|
this.runtime.writeAfterStop(`${fullscreenExitText}\n`);
|
|
1115
1150
|
}
|
|
@@ -1423,11 +1458,58 @@ export class OwnedUiSessionShell {
|
|
|
1423
1458
|
sessionId: this.backend.sessionId,
|
|
1424
1459
|
text: item.text,
|
|
1425
1460
|
...(item.images === undefined ? {} : { images: item.images }),
|
|
1426
|
-
});
|
|
1461
|
+
}, item.draft);
|
|
1427
1462
|
}
|
|
1428
1463
|
}
|
|
1429
|
-
async #execute(command) {
|
|
1430
|
-
|
|
1464
|
+
async #execute(command, draft) {
|
|
1465
|
+
if (draft === undefined)
|
|
1466
|
+
return this.backend.execute(command);
|
|
1467
|
+
const revision = this.#editorRevision;
|
|
1468
|
+
try {
|
|
1469
|
+
assertOwnedUiCommand(command);
|
|
1470
|
+
}
|
|
1471
|
+
catch (error) {
|
|
1472
|
+
return this.#recoverSubmission(draft, revision, error);
|
|
1473
|
+
}
|
|
1474
|
+
try {
|
|
1475
|
+
const result = await this.backend.execute(command);
|
|
1476
|
+
if (result.outcome === "rejected")
|
|
1477
|
+
return this.#recoverSubmission(draft, revision);
|
|
1478
|
+
return result;
|
|
1479
|
+
}
|
|
1480
|
+
catch {
|
|
1481
|
+
// Security: dispatch might already have reached the provider. Never retry automatically.
|
|
1482
|
+
this.root.editor.addToHistory(draft);
|
|
1483
|
+
this.#reportSubmissionError(undefined, "Submission failed; delivery is uncertain. Check the conversation before retrying. Press Up to recover the draft.");
|
|
1484
|
+
return { outcome: "failed", diagnostic: "submission delivery is uncertain" };
|
|
1485
|
+
}
|
|
1486
|
+
}
|
|
1487
|
+
async #guardSubmission(draft, action) {
|
|
1488
|
+
const revision = this.#editorRevision;
|
|
1489
|
+
try {
|
|
1490
|
+
return await action();
|
|
1491
|
+
}
|
|
1492
|
+
catch (error) {
|
|
1493
|
+
return this.#recoverSubmission(draft, revision, error);
|
|
1494
|
+
}
|
|
1495
|
+
}
|
|
1496
|
+
#recoverSubmission(draft, revision, error) {
|
|
1497
|
+
this.root.editor.addToHistory(draft);
|
|
1498
|
+
// Concurrency: never overwrite input typed (even typed and cleared) after this submission.
|
|
1499
|
+
if (revision === this.#editorRevision && this.root.editor.getText().length === 0)
|
|
1500
|
+
this.root.editor.setText(draft);
|
|
1501
|
+
const message = error instanceof ImageAttachmentError ? error.message : "Submission rejected. Check the prompt and attachments.";
|
|
1502
|
+
this.#reportSubmissionError(error, `${message} Press Up to recover the draft.`);
|
|
1503
|
+
return rejected(message);
|
|
1504
|
+
}
|
|
1505
|
+
#reportSubmissionError(error, message) {
|
|
1506
|
+
// Security: arbitrary provider/extension error messages can contain the entire request.
|
|
1507
|
+
try {
|
|
1508
|
+
this.root.appendWorkflowResult({ command: "debug", outcome: "failed", message: message
|
|
1509
|
+
?? (error instanceof ImageAttachmentError ? error.message : "Submission failed. Check the prompt and try again.") });
|
|
1510
|
+
this.runtime.requestRender();
|
|
1511
|
+
}
|
|
1512
|
+
catch { /* Security: error presentation cannot create another rejected submission callback. */ }
|
|
1431
1513
|
}
|
|
1432
1514
|
#simple(type) {
|
|
1433
1515
|
return { type, correlationId: this.#correlation(type), sessionId: this.backend.sessionId };
|
|
@@ -159,8 +159,16 @@ export class SessionViewportController {
|
|
|
159
159
|
this.#requestHyperlinkCleanup();
|
|
160
160
|
this.#requestRender(true);
|
|
161
161
|
}
|
|
162
|
-
if (this.#viewport.frame === null)
|
|
163
|
-
|
|
162
|
+
if (this.#viewport.frame === null) {
|
|
163
|
+
// Invariant: a not-yet-painted A1 screen is not Pi's selection surface.
|
|
164
|
+
return routeMouseInput(data, event => {
|
|
165
|
+
if (event.kind === "press" && event.button === 0)
|
|
166
|
+
this.#tailPointerSuppressed = true;
|
|
167
|
+
if (event.kind === "release")
|
|
168
|
+
this.#tailPointerSuppressed = false;
|
|
169
|
+
return event.kind === "motion" || event.kind === "release" || (event.kind === "press" && event.button === 0);
|
|
170
|
+
});
|
|
171
|
+
}
|
|
164
172
|
if (data === "\u0003") {
|
|
165
173
|
if (this.#editor.hasSelection()) {
|
|
166
174
|
if (this.#viewport.clearSelection())
|
|
@@ -287,6 +295,8 @@ export class SessionViewportController {
|
|
|
287
295
|
}
|
|
288
296
|
if (event.button !== 0)
|
|
289
297
|
return false;
|
|
298
|
+
this.#dockPointerSuppressed = false;
|
|
299
|
+
this.#tailPointerSuppressed = false;
|
|
290
300
|
this.#stopSelectionAutoScroll();
|
|
291
301
|
if (this.#viewport.clearSelection())
|
|
292
302
|
repaint = true;
|
|
@@ -335,16 +345,16 @@ export class SessionViewportController {
|
|
|
335
345
|
}
|
|
336
346
|
if (event.row >= 1 && event.row <= hits.viewportHeight && event.column <= frame.contentWidth) {
|
|
337
347
|
if (!this.#viewport.pressSelection(event.column, event.row, now)) {
|
|
338
|
-
// Invariant:
|
|
339
|
-
|
|
340
|
-
this.#tailPointerSuppressed = true;
|
|
348
|
+
// Invariant: empty rows and transient tail rows must never start Pi selection.
|
|
349
|
+
this.#tailPointerSuppressed = true;
|
|
341
350
|
repaint = true;
|
|
342
|
-
return
|
|
351
|
+
return true;
|
|
343
352
|
}
|
|
344
353
|
repaint = true;
|
|
345
354
|
return true;
|
|
346
355
|
}
|
|
347
|
-
|
|
356
|
+
this.#tailPointerSuppressed = true;
|
|
357
|
+
return true;
|
|
348
358
|
}
|
|
349
359
|
if (event.kind === "release") {
|
|
350
360
|
if (this.#editor.ownsPointer() && this.#editorPointerFrame !== undefined) {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { execFile } from "node:child_process";
|
|
2
2
|
import { canonicalizeClipboardImage } from "./clipboard-image.js";
|
|
3
|
+
import { ImageAttachmentError, MAX_IMAGE_DATA_BYTES } from "../../../contracts/owned-ui/index.js";
|
|
3
4
|
const MAX_CLIPBOARD_BYTES = 16 * 1024 * 1024;
|
|
4
5
|
let nativeClipboardPromise;
|
|
5
6
|
let pendingClipboardWrite = Promise.resolve();
|
|
@@ -27,7 +28,9 @@ export async function readSystemClipboardContent() {
|
|
|
27
28
|
if (image !== null)
|
|
28
29
|
return { kind: "image", ...image };
|
|
29
30
|
}
|
|
30
|
-
catch {
|
|
31
|
+
catch (error) {
|
|
32
|
+
if (error instanceof ImageAttachmentError)
|
|
33
|
+
throw error;
|
|
31
34
|
// Compatibility: fall through to text and platform readers.
|
|
32
35
|
}
|
|
33
36
|
}
|
|
@@ -40,6 +43,8 @@ export async function readSystemClipboardImage(reader) {
|
|
|
40
43
|
if (reader.getImageBinary !== undefined) {
|
|
41
44
|
try {
|
|
42
45
|
const bytes = await reader.getImageBinary();
|
|
46
|
+
if (Math.ceil(bytes.length / 3) * 4 > MAX_IMAGE_DATA_BYTES)
|
|
47
|
+
throw new ImageAttachmentError("image-size");
|
|
43
48
|
if (bytes.length > 0 && bytes.every(byte => Number.isInteger(byte) && byte >= 0 && byte <= 255)) {
|
|
44
49
|
return canonicalizeClipboardImage({
|
|
45
50
|
data: Buffer.from(bytes).toString("base64"),
|
|
@@ -47,7 +52,9 @@ export async function readSystemClipboardImage(reader) {
|
|
|
47
52
|
});
|
|
48
53
|
}
|
|
49
54
|
}
|
|
50
|
-
catch {
|
|
55
|
+
catch (error) {
|
|
56
|
+
if (error instanceof ImageAttachmentError)
|
|
57
|
+
throw error;
|
|
51
58
|
// Compatibility: older or partially available native bindings may still expose base64.
|
|
52
59
|
}
|
|
53
60
|
}
|
|
@@ -58,7 +65,9 @@ export async function readSystemClipboardImage(reader) {
|
|
|
58
65
|
mimeType: "image/png",
|
|
59
66
|
});
|
|
60
67
|
}
|
|
61
|
-
catch {
|
|
68
|
+
catch (error) {
|
|
69
|
+
if (error instanceof ImageAttachmentError)
|
|
70
|
+
throw error;
|
|
62
71
|
// Compatibility: fall through to the caller's text path.
|
|
63
72
|
}
|
|
64
73
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { HStack, ProcessTerminal, ScrollView, TuiAltScreen, TuiMainScreen, VStack, visibleWidth, } from "#pi-tui";
|
|
2
|
+
import { boundedCleanup, EMERGENCY_TERMINAL_RESET } from "../../../foundation/terminal-cleanup/index.js";
|
|
2
3
|
import { InputPresentationCoordinator, } from "./input-presentation-coordinator.js";
|
|
3
4
|
/** Identifies the Pi TUI lifecycle stage that failed while preserving the original cause. */
|
|
4
5
|
export class PiTuiRuntimeError extends Error {
|
|
@@ -402,11 +403,16 @@ export class PiTuiRuntimeAdapter {
|
|
|
402
403
|
}
|
|
403
404
|
async #stop(options) {
|
|
404
405
|
this.#state = "stopping";
|
|
405
|
-
this.#inputCoordinator?.flush();
|
|
406
406
|
let failure;
|
|
407
|
+
try {
|
|
408
|
+
this.#inputCoordinator?.dispose(false);
|
|
409
|
+
}
|
|
410
|
+
catch (error) {
|
|
411
|
+
failure = new PiTuiRuntimeError("restoration", error);
|
|
412
|
+
}
|
|
407
413
|
if (options.drainInput !== false) {
|
|
408
414
|
try {
|
|
409
|
-
await this.#terminal.drainInput(options.drainMaxMs, options.drainIdleMs);
|
|
415
|
+
await boundedCleanup(() => this.#terminal.drainInput(options.drainMaxMs, options.drainIdleMs));
|
|
410
416
|
}
|
|
411
417
|
catch (error) {
|
|
412
418
|
failure = new PiTuiRuntimeError("input-drain", error);
|
|
@@ -559,6 +565,11 @@ export class PiTuiRuntimeAdapter {
|
|
|
559
565
|
}
|
|
560
566
|
#bestEffortTerminalRestore() {
|
|
561
567
|
this.#clearTerminalProgress();
|
|
568
|
+
try {
|
|
569
|
+
if (this.mode === "fullscreen")
|
|
570
|
+
this.#terminal.write(EMERGENCY_TERMINAL_RESET);
|
|
571
|
+
}
|
|
572
|
+
catch { }
|
|
562
573
|
try {
|
|
563
574
|
this.#terminal.showCursor();
|
|
564
575
|
}
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
"platform": "darwin",
|
|
6
6
|
"architecture": "arm64",
|
|
7
7
|
"capability": "supported",
|
|
8
|
-
"builtAt": "2026-09-
|
|
8
|
+
"builtAt": "2026-09-09T13:32:45.025Z",
|
|
9
9
|
"artifact": {
|
|
10
10
|
"filename": "process-guardian",
|
|
11
11
|
"sha256": "dc03605e5780e2aeebb4ecafd62868dc673e22ddd38b024a369aff704ff136dc",
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
"platform": "linux",
|
|
6
6
|
"architecture": "x64",
|
|
7
7
|
"capability": "supported",
|
|
8
|
-
"builtAt": "2026-09-
|
|
8
|
+
"builtAt": "2026-09-09T13:32:36.719Z",
|
|
9
9
|
"artifact": {
|
|
10
10
|
"filename": "process-guardian",
|
|
11
11
|
"sha256": "ee8a00eaaf79c707459bbbfb52518e9739314967049fbe5fa625f36ce33db9ee",
|
|
@@ -5,10 +5,10 @@
|
|
|
5
5
|
"platform": "win32",
|
|
6
6
|
"architecture": "x64",
|
|
7
7
|
"capability": "supported",
|
|
8
|
-
"builtAt": "2026-09-
|
|
8
|
+
"builtAt": "2026-09-09T13:34:58.223Z",
|
|
9
9
|
"artifact": {
|
|
10
10
|
"filename": "process-guardian.exe",
|
|
11
|
-
"sha256": "
|
|
11
|
+
"sha256": "ed500da6662711b12a26dd40d4b2d659a201a0d9e43fd8d521d8a16e04889f4b",
|
|
12
12
|
"size": 177664
|
|
13
13
|
},
|
|
14
14
|
"provenance": {
|
|
Binary file
|
|
@@ -31,7 +31,8 @@ The owned Pi-backed surface is not an arbitrary-CLI terminal multiplexer. A feat
|
|
|
31
31
|
- `storage`: SQLite migrations, prior-boot reconciliation, and plural launch-instance persistence. Legacy foreground rows are historical migration input and never authorize current ownership.
|
|
32
32
|
- `supervisor`: endpoint identity, plural cohort ownership, per-instance reconciliation, and aggregate release shutdown coordination. It owns no terminal surface.
|
|
33
33
|
- `pi-engine-adapter`, `pi-component-adapter`, `pi-tui-runtime-adapter`, and `pi-session-ui-integration`: isolate pinned Pi engine and presentation knowledge behind neutral contracts; product features do not import them directly. The session UI render root assembles semantic document and dock rows, while its focused viewport controller owns follow state, pointer routing, selection, and interaction timers. Owned-app route lifecycle belongs to the neutral `ui-apps` owner and is only hosted by the Pi session UI.
|
|
34
|
-
- release/update/bootstrap: package-derived immutable release identity, process-guardian integrity, cohort selection, durable update transactions, rollback, and dependency-light command entry.
|
|
34
|
+
- release/update/bootstrap: package-derived immutable release identity, process-guardian integrity, cohort selection, durable update transactions, rollback, and dependency-light command entry. After an unsuccessful bare-A1 child exit, the bootstrap restores owned terminal modes; neither guardian emits terminal controls.
|
|
35
|
+
- `terminal-cleanup`: bounded, idempotent emergency restoration and fatal-exit diagnostics for an explicitly owned terminal. It does not inspect commands, relay terminal input/output, or emulate terminal state. The owned UI installs its fatal boundary before terminal activation; packaged bootstrap and checkout launch provide the surviving-owner fallback. Fatal records retain at most ten 16 KiB records with trusted classifications and code locations, never prompts, attachments, raw input, credentials, or arbitrary exception messages.
|
|
35
36
|
|
|
36
37
|
## Dependency direction
|
|
37
38
|
|