@visual-z/xeno 0.5.0 → 0.7.0
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/CHANGELOG.md +19 -0
- package/README.md +19 -4
- package/docs/app-server.md +32 -3
- package/docs/foundations.md +88 -0
- package/docs/install.md +23 -2
- package/docs/research/foundation-clients-2026-10-09.md +27 -0
- package/docs/research/foundation-editing-2026-10-09.md +56 -0
- package/docs/research/foundation-network-2026-10-09.md +37 -0
- package/docs/research/foundation-workflow-2026-10-09.md +37 -0
- package/package.json +10 -2
- package/src/apps/acp-server.ts +41 -0
- package/src/apps/agent-profiles.ts +19 -0
- package/src/apps/app-server.ts +4 -2
- package/src/apps/cli.ts +18 -2
- package/src/apps/commands.ts +20 -0
- package/src/apps/control-transport.ts +3 -2
- package/src/apps/model-catalog.ts +57 -31
- package/src/apps/question-ui.ts +27 -0
- package/src/apps/resources.ts +63 -25
- package/src/apps/review.ts +109 -0
- package/src/apps/session-hub.ts +95 -0
- package/src/apps/shell.ts +55 -1
- package/src/apps/startup.ts +157 -22
- package/src/apps/ui/app.ts +89 -38
- package/src/apps/ui/image-cache.ts +71 -28
- package/src/apps/ui/surface.ts +1 -0
- package/src/apps/update-check.ts +71 -0
- package/src/apps/workflow.ts +58 -0
- package/src/core/activity-status.ts +28 -0
- package/src/core/agent-profile.ts +130 -0
- package/src/core/control-protocol.ts +8 -1
- package/src/core/file-precondition.ts +6 -0
- package/src/core/git-status.ts +29 -0
- package/src/core/image-limits.ts +72 -0
- package/src/core/lsp-framing.ts +34 -0
- package/src/core/mcp-config.ts +68 -0
- package/src/core/patch.ts +130 -0
- package/src/core/permissions.ts +143 -4
- package/src/core/progress.ts +9 -0
- package/src/core/question.ts +94 -0
- package/src/core/request-budget.ts +13 -0
- package/src/core/review.ts +68 -0
- package/src/core/session-exit.ts +26 -0
- package/src/core/settings.ts +1 -1
- package/src/core/stall.ts +18 -6
- package/src/core/stop-report.ts +25 -0
- package/src/core/update-check.ts +24 -5
- package/src/core/web-content.ts +80 -0
- package/src/core/workflow-mode.ts +34 -0
- package/src/runtime/acp-agent.ts +289 -0
- package/src/runtime/agent-profiles.ts +21 -0
- package/src/runtime/catalog.ts +2 -2
- package/src/runtime/control-client.ts +149 -0
- package/src/runtime/control-connection.ts +39 -4
- package/src/runtime/exec-sessions.ts +40 -4
- package/src/runtime/extensions.ts +20 -2
- package/src/runtime/formatter-tool.ts +38 -0
- package/src/runtime/formatter.ts +50 -0
- package/src/runtime/foundation.ts +78 -0
- package/src/runtime/images.ts +6 -2
- package/src/runtime/integrations.ts +8 -2
- package/src/runtime/loop.ts +124 -21
- package/src/runtime/lsp-client.ts +201 -0
- package/src/runtime/mcp-client.ts +264 -0
- package/src/runtime/mcp-oauth.ts +168 -0
- package/src/runtime/mcp-schema.ts +51 -0
- package/src/runtime/oauth.ts +1 -1
- package/src/runtime/openai-responses.ts +6 -4
- package/src/runtime/openai.ts +6 -1
- package/src/runtime/output-schema.ts +26 -0
- package/src/runtime/patch-tool.ts +38 -0
- package/src/runtime/permission-resources.ts +11 -0
- package/src/runtime/pi-provider.ts +5 -4
- package/src/runtime/provider-tool-policy.ts +33 -0
- package/src/runtime/provider.ts +8 -1
- package/src/runtime/question-tool.ts +62 -0
- package/src/runtime/review-tool.ts +48 -0
- package/src/runtime/session-control.ts +61 -2
- package/src/runtime/session-hub.ts +85 -0
- package/src/runtime/tool-discovery.ts +4 -3
- package/src/runtime/tools.ts +44 -10
- package/src/runtime/update-check.ts +3 -2
- package/src/runtime/web-http.ts +56 -0
- package/src/runtime/web-tools.ts +88 -0
- package/src/runtime/workspace-lifecycle.ts +14 -1
- package/src/store/agent-profile-files.ts +13 -0
- package/src/store/blobs.ts +2 -1
- package/src/store/context-files.ts +77 -1
- package/src/store/file-checkpoints.ts +5 -4
- package/src/store/foundation-config.ts +24 -0
- package/src/store/git-status.ts +15 -0
- package/src/store/lsp-document.ts +30 -0
- package/src/store/mcp-credentials.ts +85 -0
- package/src/store/patch.ts +92 -0
- package/src/store/permission-paths.ts +34 -0
- package/src/store/review.ts +109 -0
- package/src/store/search.ts +48 -9
- package/src/tui/ui/layout.ts +7 -7
- package/src/tui/ui/messages.ts +1 -1
- package/src/tui/ui/pictures.ts +26 -9
- package/src/tui/ui/screen.ts +12 -8
- package/src/tui/ui/status.ts +32 -6
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/** Credential storage is separate from append-only observations: secrets must never become log evidence. */
|
|
2
|
+
import { mkdir, open, rename, rm, lstat } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
5
|
+
import { constants } from "node:fs";
|
|
6
|
+
import { isJsonValue, type JsonValue } from "../core/tool-contract.ts";
|
|
7
|
+
|
|
8
|
+
export interface McpCredentials {
|
|
9
|
+
version: 1; serverUrl: string; redirectUrl: string;
|
|
10
|
+
clientIdentity?: string;
|
|
11
|
+
clients: Record<string, JsonValue>; tokens: Record<string, JsonValue>; expiresAt: Record<string, number>; activeIssuer: string | null;
|
|
12
|
+
}
|
|
13
|
+
export interface McpCredentialsStore {
|
|
14
|
+
read(serverUrl: string, clientIdentity?: string): Promise<McpCredentials | undefined>;
|
|
15
|
+
write(credentials: McpCredentials): Promise<void>;
|
|
16
|
+
remove(serverUrl: string, clientIdentity?: string): Promise<void>;
|
|
17
|
+
}
|
|
18
|
+
// Assumed bound prevents a replaced credential file from becoming an unbounded JSON allocation.
|
|
19
|
+
const MAX_CREDENTIAL_BYTES = 1024 * 1024;
|
|
20
|
+
function filename(dir: string, url: string, clientIdentity = "") { return join(dir, createHash("sha256").update(JSON.stringify([url, clientIdentity])).digest("hex") + ".json"); }
|
|
21
|
+
function record(value: unknown): value is Record<string, JsonValue> {
|
|
22
|
+
return value !== null && typeof value === "object" && !Array.isArray(value) && isJsonValue(value);
|
|
23
|
+
}
|
|
24
|
+
function validate(value: unknown, serverUrl: string, clientIdentity = ""): McpCredentials {
|
|
25
|
+
if (!record(value) || value["version"] !== 1 || value["serverUrl"] !== serverUrl || typeof value["redirectUrl"] !== "string"
|
|
26
|
+
|| (value["clientIdentity"] ?? "") !== clientIdentity
|
|
27
|
+
|| !record(value["clients"]) || !record(value["tokens"]) || !record(value["expiresAt"])
|
|
28
|
+
|| Object.values(value["expiresAt"]).some(time => typeof time !== "number" || !Number.isFinite(time))
|
|
29
|
+
|| value["activeIssuer"] !== null && typeof value["activeIssuer"] !== "string") throw Error("MCP credential record is invalid");
|
|
30
|
+
return { version: 1, serverUrl, redirectUrl: value["redirectUrl"], clients: value["clients"], tokens: value["tokens"],
|
|
31
|
+
expiresAt: Object.fromEntries(Object.entries(value["expiresAt"]).map(([issuer, time]) => [issuer, Number(time)])), activeIssuer: value["activeIssuer"],
|
|
32
|
+
...(typeof value["clientIdentity"] === "string" ? { clientIdentity: value["clientIdentity"] } : {}) };
|
|
33
|
+
}
|
|
34
|
+
/** Explicit restricted-file backend, compatible with Codex's fallback; applications may provide an OS vault adapter. */
|
|
35
|
+
export class FileMcpCredentialsStore implements McpCredentialsStore {
|
|
36
|
+
private readonly dir: string;
|
|
37
|
+
private writes = Promise.resolve();
|
|
38
|
+
constructor(dir: string) { this.dir = dir; }
|
|
39
|
+
private async directory(): Promise<void> {
|
|
40
|
+
const stat = await lstat(this.dir);
|
|
41
|
+
if (!stat.isDirectory() || stat.isSymbolicLink() || (stat.mode & 0o077) !== 0) throw Error("MCP credential directory permissions are invalid");
|
|
42
|
+
}
|
|
43
|
+
async read(serverUrl: string, clientIdentity?: string): Promise<McpCredentials | undefined> {
|
|
44
|
+
const path = filename(this.dir, serverUrl, clientIdentity);
|
|
45
|
+
try {
|
|
46
|
+
await this.directory();
|
|
47
|
+
const stat = await lstat(path);
|
|
48
|
+
if (!stat.isFile() || stat.isSymbolicLink() || (stat.mode & 0o077) !== 0 || stat.size > MAX_CREDENTIAL_BYTES) throw Error("MCP credential file permissions or size are invalid");
|
|
49
|
+
const file = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
50
|
+
try {
|
|
51
|
+
const opened = await file.stat();
|
|
52
|
+
if (!opened.isFile() || opened.size > MAX_CREDENTIAL_BYTES || (opened.mode & 0o077) !== 0) throw Error("Unsafe credential handle");
|
|
53
|
+
return validate(JSON.parse(await file.readFile("utf8")), serverUrl, clientIdentity);
|
|
54
|
+
} finally { await file.close(); }
|
|
55
|
+
} catch (error) {
|
|
56
|
+
if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") return undefined;
|
|
57
|
+
throw Error("MCP credential store could not be read safely");
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
write(credentials: McpCredentials): Promise<void> {
|
|
61
|
+
const serialized = JSON.stringify(validate(credentials, credentials.serverUrl, credentials.clientIdentity));
|
|
62
|
+
const target = filename(this.dir, credentials.serverUrl, credentials.clientIdentity);
|
|
63
|
+
if (Buffer.byteLength(serialized) > MAX_CREDENTIAL_BYTES) return Promise.reject(Error("MCP credential record exceeds size limit"));
|
|
64
|
+
const operation = this.writes.then(async () => {
|
|
65
|
+
await mkdir(this.dir, { recursive: true, mode: 0o700 });
|
|
66
|
+
await this.directory();
|
|
67
|
+
const temporary = join(this.dir, `.${randomUUID()}.tmp`);
|
|
68
|
+
try {
|
|
69
|
+
const file = await open(temporary, "wx", 0o600);
|
|
70
|
+
try { await file.writeFile(serialized); await file.sync(); } finally { await file.close(); }
|
|
71
|
+
await rename(temporary, target);
|
|
72
|
+
} finally { await rm(temporary, { force: true }); }
|
|
73
|
+
});
|
|
74
|
+
this.writes = operation.catch(() => {});
|
|
75
|
+
return operation;
|
|
76
|
+
}
|
|
77
|
+
async remove(serverUrl: string, clientIdentity?: string): Promise<void> {
|
|
78
|
+
await this.writes;
|
|
79
|
+
try { await this.directory(); await rm(filename(this.dir, serverUrl, clientIdentity), { force: true }); }
|
|
80
|
+
catch (error) {
|
|
81
|
+
if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") return;
|
|
82
|
+
throw Error("MCP credential store could not be removed safely");
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/** R4 / P.3: one fully prepared plan shares the existing confined checkpoint CAS. */
|
|
2
|
+
import { relative, resolve } from "node:path";
|
|
3
|
+
import { parsePatch, applyPatchChunks, patchPaths, PatchError, type PatchOperation } from "../core/patch.ts";
|
|
4
|
+
import { sameVersion, changedPaths, type FileChanges } from "../core/checkpoints.ts";
|
|
5
|
+
import { unifiedFileDiff } from "../core/review.ts";
|
|
6
|
+
import { isFileDigest } from "../core/file-precondition.ts";
|
|
7
|
+
import { ToolFault, NOT_STARTED, READ_ATTEMPTED } from "../core/tool-failure.ts";
|
|
8
|
+
import { editablePath, readEditableFile, applyFileChanges } from "./file-checkpoints.ts";
|
|
9
|
+
import { putBlob } from "./blobs.ts";
|
|
10
|
+
|
|
11
|
+
export interface PreparedPatch {
|
|
12
|
+
root: string;
|
|
13
|
+
changes: FileChanges;
|
|
14
|
+
operations: readonly PatchOperation[];
|
|
15
|
+
diffs: readonly { path: string; destination?: string; kind: "add" | "update" | "move" | "delete"; diff: string }[];
|
|
16
|
+
}
|
|
17
|
+
export interface PreparePatchOptions {
|
|
18
|
+
observedFileVersion?: (absolute: string) => Promise<string | undefined>;
|
|
19
|
+
expectedSha256?: Readonly<Record<string, string>>;
|
|
20
|
+
signal?: AbortSignal;
|
|
21
|
+
}
|
|
22
|
+
const decode = (bytes: Uint8Array) => {
|
|
23
|
+
try { return new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes); }
|
|
24
|
+
catch { throw new ToolFault("invalid_input", "patch requires valid UTF-8 files; use a trusted command for binary data", READ_ATTEMPTED); }
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/** No workspace write occurs until every source, destination and hunk passes. */
|
|
28
|
+
export async function preparePatch(root: string, blobs: string, text: string, options: PreparePatchOptions = {}): Promise<PreparedPatch> {
|
|
29
|
+
let operations: PatchOperation[];
|
|
30
|
+
try { operations = parsePatch(text); }
|
|
31
|
+
catch (error) { if (error instanceof PatchError) throw new ToolFault(error.code, error.message, NOT_STARTED); throw error; }
|
|
32
|
+
const names = new Map<string, string>();
|
|
33
|
+
const targets = new Set<string>();
|
|
34
|
+
for (const path of patchPaths(operations)) {
|
|
35
|
+
const absolute = await editablePath(root, path), canonical = relative(resolve(root), absolute);
|
|
36
|
+
if (targets.has(canonical) || [...targets].some(existing => existing.startsWith(canonical + "/") || canonical.startsWith(existing + "/")))
|
|
37
|
+
throw new ToolFault("invalid_input", `patch has duplicate or overlapping targets: ${path}`, NOT_STARTED);
|
|
38
|
+
names.set(path, canonical); targets.add(canonical);
|
|
39
|
+
}
|
|
40
|
+
const canonical = (path: string) => names.get(path)!;
|
|
41
|
+
const observed = new Map<string, Awaited<ReturnType<typeof readEditableFile>>>();
|
|
42
|
+
for (const path of targets) {
|
|
43
|
+
if (options.signal?.aborted) throw new ToolFault("cancelled", "patch preparation cancelled", NOT_STARTED);
|
|
44
|
+
observed.set(path, await readEditableFile(root, path));
|
|
45
|
+
}
|
|
46
|
+
const changes: FileChanges = Object.create(null) as FileChanges;
|
|
47
|
+
const diffs: PreparedPatch["diffs"][number][] = [];
|
|
48
|
+
for (const operation of operations) {
|
|
49
|
+
const path = canonical(operation.path), source = observed.get(path)!;
|
|
50
|
+
if (operation.kind !== "add") {
|
|
51
|
+
if (source.bytes === null || source.version === null) throw new ToolFault("not_found", `patch source is missing: ${path}`, READ_ATTEMPTED);
|
|
52
|
+
const explicit = options.expectedSha256?.[operation.path] ?? options.expectedSha256?.[path];
|
|
53
|
+
const expected = explicit ?? await options.observedFileVersion?.(resolve(root, path));
|
|
54
|
+
if (!isFileDigest(expected) || expected !== source.version.sha256)
|
|
55
|
+
throw new ToolFault("version_mismatch", `patch source version was not observed or changed: ${path}; read it before editing`, READ_ATTEMPTED, { recover: { tool: "read", input: { path } } });
|
|
56
|
+
} else if (source.version !== null) {
|
|
57
|
+
throw new ToolFault("version_mismatch", `patch add requires an absent destination: ${path}; existing bytes were preserved`, READ_ATTEMPTED);
|
|
58
|
+
}
|
|
59
|
+
let destination = path, content: string | null;
|
|
60
|
+
const before = source.bytes === null ? null : decode(source.bytes);
|
|
61
|
+
if (operation.kind === "delete") content = null;
|
|
62
|
+
else if (operation.kind === "add") content = operation.content;
|
|
63
|
+
else {
|
|
64
|
+
try { content = applyPatchChunks(before!, operation.chunks); }
|
|
65
|
+
catch (error) { if (error instanceof PatchError) throw new ToolFault(error.code, `${path}: ${error.message}`, READ_ATTEMPTED); throw error; }
|
|
66
|
+
if (operation.movePath !== undefined) {
|
|
67
|
+
destination = canonical(operation.movePath);
|
|
68
|
+
if (observed.get(destination)!.version !== null) throw new ToolFault("version_mismatch", `patch move requires an absent destination: ${destination}`, READ_ATTEMPTED);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
if (source.bytes !== null) await putBlob(blobs, source.bytes);
|
|
72
|
+
const after = content === null ? null : { sha256: await putBlob(blobs, content), mode: source.version?.mode ?? (0o666 & ~process.umask()) };
|
|
73
|
+
if (destination !== path) {
|
|
74
|
+
changes[path] = { before: source.version, after: null };
|
|
75
|
+
changes[destination] = { before: null, after };
|
|
76
|
+
} else changes[path] = { before: source.version, after };
|
|
77
|
+
diffs.push({ path, ...(destination === path ? {} : { destination }), kind: destination !== path ? "move" : operation.kind,
|
|
78
|
+
diff: unifiedFileDiff(path, before, content, destination) });
|
|
79
|
+
}
|
|
80
|
+
return { root: resolve(root), operations, changes, diffs };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** The caller persists prepared/applied checkpoint phases around this operation. */
|
|
84
|
+
export async function applyPreparedPatch(root: string, blobs: string, prepared: PreparedPatch, signal?: AbortSignal): Promise<void> {
|
|
85
|
+
if (resolve(root) !== prepared.root) throw new ToolFault("invalid_input", "prepared patch names another workspace", NOT_STARTED);
|
|
86
|
+
if (signal?.aborted) throw new ToolFault("cancelled", "patch application cancelled", NOT_STARTED);
|
|
87
|
+
await applyFileChanges(root, blobs, prepared.changes, signal);
|
|
88
|
+
for (const path of changedPaths(prepared.changes)) {
|
|
89
|
+
if (!sameVersion((await readEditableFile(root, path)).version, prepared.changes[path]!.after))
|
|
90
|
+
throw new Error(`file changed concurrently after patch: ${path}; inspect the prepared checkpoint`);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/** R13 / D13: path policy sees aliases and actual resources; confinement remains separate. */
|
|
2
|
+
import { realpath } from "node:fs/promises";
|
|
3
|
+
import { dirname, basename, resolve, relative, isAbsolute } from "node:path";
|
|
4
|
+
import { permissionRequest, normalizePermissionPath, type PermissionRequest } from "../core/permissions.ts";
|
|
5
|
+
|
|
6
|
+
async function canonicalPath(path: string): Promise<string> {
|
|
7
|
+
try { return await realpath(path); }
|
|
8
|
+
catch (error) {
|
|
9
|
+
if (!error || typeof error !== "object" || !("code" in error) || !["ENOENT", "ENOTDIR"].includes(String(error.code))) throw error;
|
|
10
|
+
const parent = dirname(path);
|
|
11
|
+
if (parent === path) throw error;
|
|
12
|
+
// New files have no realpath yet; existing ancestors still reveal aliases.
|
|
13
|
+
return resolve(await canonicalPath(parent), basename(path));
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
function resource(root: string, absolute: string): string {
|
|
17
|
+
const path = relative(root, absolute);
|
|
18
|
+
return normalizePermissionPath(path === ".." || path.startsWith("../") || isAbsolute(path) ? absolute : path || ".");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function permissionPaths(root: string, paths: readonly string[]): Promise<string[]> {
|
|
22
|
+
const absoluteRoot = resolve(root), canonicalRoot = await canonicalPath(absoluteRoot), resources: string[] = [];
|
|
23
|
+
for (const path of paths) {
|
|
24
|
+
const absolute = resolve(absoluteRoot, path);
|
|
25
|
+
resources.push(resource(absoluteRoot, absolute), resource(canonicalRoot, await canonicalPath(absolute)));
|
|
26
|
+
}
|
|
27
|
+
return [...new Set(resources)];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Models never supply the root or canonical target; the current runtime does. */
|
|
31
|
+
export async function canonicalPermissionRequest(root: string, name: string, input: unknown): Promise<PermissionRequest> {
|
|
32
|
+
const request = permissionRequest(name, input);
|
|
33
|
+
return request.paths ? { ...request, paths: await permissionPaths(root, request.paths) } : request;
|
|
34
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/** Read-only Git review with fixed argv; project diff drivers cannot execute code. */
|
|
2
|
+
import { lstat } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import type { FileChanges } from "../core/checkpoints.ts";
|
|
5
|
+
import { changedPaths } from "../core/checkpoints.ts";
|
|
6
|
+
import { ToolFault, READ_ATTEMPTED } from "../core/tool-failure.ts";
|
|
7
|
+
import { unifiedFileDiff, type ReviewScope, type ReviewSnapshot } from "../core/review.ts";
|
|
8
|
+
import { gitBytes, MAX_UNTRACKED_BYTES } from "./workspace-observation.ts";
|
|
9
|
+
import { readEditableFile } from "./file-checkpoints.ts";
|
|
10
|
+
import { getBlobBytes, blobAddress } from "./blobs.ts";
|
|
11
|
+
|
|
12
|
+
// Assumed bounded review memory. Exceeding it is unavailable, never silently partial.
|
|
13
|
+
export const MAX_REVIEW_BYTES = 16 * 1024 * 1024;
|
|
14
|
+
const decode = (bytes: Uint8Array) => new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes);
|
|
15
|
+
const flags = ["--no-pager", "--literal-pathspecs", "-c", "core.hooksPath=/dev/null"];
|
|
16
|
+
const diffFlags = ["--no-ext-diff", "--no-textconv", "--no-renames", "--relative"];
|
|
17
|
+
const checkedText = (bytes: Uint8Array) => {
|
|
18
|
+
if (bytes.byteLength > MAX_REVIEW_BYTES) throw new Error("review exceeds the byte budget; choose a narrower scope");
|
|
19
|
+
return decode(bytes);
|
|
20
|
+
};
|
|
21
|
+
const ordinaryText = (bytes: Uint8Array): string | null => {
|
|
22
|
+
if (bytes.includes(0)) return null;
|
|
23
|
+
try { return decode(bytes); } catch { return null; }
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export async function readReview(root: string, scope: Exclude<ReviewScope, { kind: "last-turn" }>, signal?: AbortSignal, canRead?: (path: string) => Promise<boolean>): Promise<ReviewSnapshot> {
|
|
27
|
+
const git = (args: readonly string[], input?: Uint8Array) => gitBytes(root, [...flags, ...args], input, signal);
|
|
28
|
+
const revision = async (value: string) => {
|
|
29
|
+
if (!value || value.includes("\0") || value.startsWith("-")) throw new Error("review revision must name a commit or branch");
|
|
30
|
+
const sha = decode(await git(["rev-parse", "--verify", "--end-of-options", value + "^{commit}"])).trim();
|
|
31
|
+
if (!/^[a-f0-9]{40,64}$/.test(sha)) throw new Error("review revision did not resolve to a commit");
|
|
32
|
+
return sha;
|
|
33
|
+
};
|
|
34
|
+
const emptyTree = async () => decode(await git(["hash-object", "-t", "tree", "--stdin"], new Uint8Array())).trim();
|
|
35
|
+
let base: string, end: string | undefined, label: string;
|
|
36
|
+
if (scope.kind === "commit") {
|
|
37
|
+
end = await revision(scope.commit);
|
|
38
|
+
const parents = decode(await git(["rev-list", "--parents", "-n", "1", end])).trim().split(" ");
|
|
39
|
+
base = parents[1] ?? await emptyTree();
|
|
40
|
+
label = `commit ${end.slice(0, 12)}${parents.length > 2 ? " against its first parent" : ""}`;
|
|
41
|
+
} else if (scope.kind === "branch") {
|
|
42
|
+
const branch = await revision(scope.base), head = await revision("HEAD");
|
|
43
|
+
base = decode(await git(["merge-base", head, branch])).trim();
|
|
44
|
+
label = `changes against ${scope.base} (merge base ${base.slice(0, 12)})`;
|
|
45
|
+
} else {
|
|
46
|
+
try { base = await revision("HEAD"); }
|
|
47
|
+
catch {
|
|
48
|
+
// An unborn branch has no HEAD; a corrupt/missing existing HEAD stays unavailable.
|
|
49
|
+
const heads = decode(await git(["rev-list", "--all", "--count"])).trim();
|
|
50
|
+
if (heads !== "0") throw new Error("working review cannot resolve HEAD");
|
|
51
|
+
base = await emptyTree();
|
|
52
|
+
}
|
|
53
|
+
label = "current staged, unstaged and untracked changes";
|
|
54
|
+
}
|
|
55
|
+
const range = [base, ...(end === undefined ? [] : [end])];
|
|
56
|
+
const paths = decode(await git(["diff", ...diffFlags, "--name-only", "-z", ...range, "--", "."])).split("\0").filter(Boolean);
|
|
57
|
+
const untracked = scope.kind === "commit" ? [] : decode(await git(["ls-files", "--others", "--exclude-standard", "-z", "--", "."])).split("\0").filter(Boolean);
|
|
58
|
+
for (const path of [...new Set([...paths, ...untracked])]) if (canRead && !await canRead(path))
|
|
59
|
+
throw new ToolFault("permission_denied", `diff resource access denied: ${path}`, READ_ATTEMPTED);
|
|
60
|
+
// Freeze the authorized path set in the actual collection argv. A concurrent
|
|
61
|
+
// change to a different path must not widen this observation after admission.
|
|
62
|
+
const diff = paths.length ? checkedText(await git(["diff", ...diffFlags, ...range, "--", ...paths])) : "";
|
|
63
|
+
const parts = [diff], limitations: string[] = [];
|
|
64
|
+
let bytes = Buffer.byteLength(diff);
|
|
65
|
+
if (scope.kind !== "commit") {
|
|
66
|
+
for (const path of untracked.sort()) {
|
|
67
|
+
if (signal?.aborted) throw new Error("review cancelled");
|
|
68
|
+
const info = await lstat(join(root, path));
|
|
69
|
+
if (info.size > MAX_UNTRACKED_BYTES) { limitations.push(`Untracked file ${JSON.stringify(path)} exceeds ${MAX_UNTRACKED_BYTES} bytes and is unavailable.`); continue; }
|
|
70
|
+
let source: Awaited<ReturnType<typeof readEditableFile>>;
|
|
71
|
+
try { source = await readEditableFile(root, path); }
|
|
72
|
+
catch { limitations.push(`Untracked file ${JSON.stringify(path)} is not an ordinary confined file.`); continue; }
|
|
73
|
+
if (source.bytes === null) throw new Error(`untracked file disappeared during review: ${path}`);
|
|
74
|
+
const text = ordinaryText(source.bytes);
|
|
75
|
+
const addition = text === null ? `Binary untracked file ${JSON.stringify(path)}: ${source.bytes.byteLength} bytes, sha256 ${source.version!.sha256}\n`
|
|
76
|
+
: unifiedFileDiff(path, null, text);
|
|
77
|
+
bytes += Buffer.byteLength(addition);
|
|
78
|
+
if (bytes > MAX_REVIEW_BYTES) throw new Error("review exceeds the byte budget; choose a narrower scope");
|
|
79
|
+
parts.push(addition); paths.push(path);
|
|
80
|
+
if (text === null) limitations.push(`Binary untracked content is identified but cannot be reviewed as text: ${JSON.stringify(path)}.`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return { scope, label, diff: parts.join("\n"), paths: [...new Set(paths)].sort(), limitations, base };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Build last-turn review from verified checkpoint blobs, independent of present disk bytes. */
|
|
87
|
+
export async function readCheckpointReview(blobs: string, changes: FileChanges, limitations: readonly string[] = [], canRead?: (path: string) => Promise<boolean>): Promise<ReviewSnapshot> {
|
|
88
|
+
const parts: string[] = [], notices = [...limitations], paths = changedPaths(changes);
|
|
89
|
+
for (const path of paths) if (canRead && !await canRead(path))
|
|
90
|
+
throw new ToolFault("permission_denied", `diff resource access denied: ${path}`, READ_ATTEMPTED);
|
|
91
|
+
let bytes = 0;
|
|
92
|
+
for (const path of paths) {
|
|
93
|
+
const firstPart = parts.length;
|
|
94
|
+
const { before, after } = changes[path]!;
|
|
95
|
+
const source = before === null ? null : await getBlobBytes(blobs, before.sha256);
|
|
96
|
+
const target = after === null ? null : await getBlobBytes(blobs, after.sha256);
|
|
97
|
+
if (before && (source === null || blobAddress(source) !== before.sha256) || after && (target === null || blobAddress(target) !== after.sha256))
|
|
98
|
+
throw new Error(`missing or corrupt last-turn checkpoint object: ${path}`);
|
|
99
|
+
const oldText = source === null ? null : ordinaryText(source), newText = target === null ? null : ordinaryText(target);
|
|
100
|
+
if (source !== null && oldText === null || target !== null && newText === null) {
|
|
101
|
+
parts.push(`Binary checkpoint change ${JSON.stringify(path)}: ${before?.sha256 ?? "absent"} -> ${after?.sha256 ?? "absent"}\n`);
|
|
102
|
+
notices.push(`Binary checkpoint content cannot be reviewed as text: ${JSON.stringify(path)}.`);
|
|
103
|
+
} else parts.push(unifiedFileDiff(path, oldText, newText));
|
|
104
|
+
if (before && after && before.mode !== after.mode) parts.push(`mode ${JSON.stringify(path)}: ${before.mode.toString(8)} -> ${after.mode.toString(8)}\n`);
|
|
105
|
+
bytes += parts.slice(firstPart).reduce((total, part) => total + Buffer.byteLength(part), 0);
|
|
106
|
+
if (bytes > MAX_REVIEW_BYTES) throw new Error("last-turn review exceeds the byte budget");
|
|
107
|
+
}
|
|
108
|
+
return { scope: { kind: "last-turn" }, label: "captured file changes in the last user turn", diff: parts.join("\n"), paths, limitations: notices };
|
|
109
|
+
}
|
package/src/store/search.ts
CHANGED
|
@@ -94,6 +94,8 @@ export interface GrepOpts {
|
|
|
94
94
|
/** Trusted harness budgets, not model arguments. */
|
|
95
95
|
readonly maxCaptureBytes?: number;
|
|
96
96
|
readonly maxIndexEntries?: number;
|
|
97
|
+
/** Trusted admission for each POSIX workspace-relative resource, before content is read. */
|
|
98
|
+
readonly canRead?: (path: string) => Promise<boolean>;
|
|
97
99
|
}
|
|
98
100
|
|
|
99
101
|
export interface FindOpts {
|
|
@@ -106,6 +108,7 @@ export interface FindOpts {
|
|
|
106
108
|
readonly signal?: AbortSignal;
|
|
107
109
|
readonly maxCaptureBytes?: number;
|
|
108
110
|
readonly maxIndexEntries?: number;
|
|
111
|
+
readonly canRead?: (path: string) => Promise<boolean>;
|
|
109
112
|
}
|
|
110
113
|
|
|
111
114
|
export interface LsOpts {
|
|
@@ -113,6 +116,7 @@ export interface LsOpts {
|
|
|
113
116
|
readonly path: string;
|
|
114
117
|
readonly limit?: number;
|
|
115
118
|
readonly signal?: AbortSignal;
|
|
119
|
+
readonly canRead?: (path: string) => Promise<boolean>;
|
|
116
120
|
}
|
|
117
121
|
|
|
118
122
|
function observed(observation: SearchObservation, diagnostic?: ToolDiagnostic): SearchResult {
|
|
@@ -256,6 +260,29 @@ async function listedFiles(abs: string, rel: string, kind: "file" | "dir", optio
|
|
|
256
260
|
return { paths: index.paths.map((p) => (rel.length === 0 ? p : `${rel}/${p}`)), issues: [...(index.issues ?? [])] };
|
|
257
261
|
}
|
|
258
262
|
|
|
263
|
+
/** Denied filenames cannot reappear in scan diagnostics or archived observations. */
|
|
264
|
+
function policyIssues(issues: SearchObservation["scan"]["reasons"], canRead: GrepOpts["canRead"]): SearchObservation["scan"]["reasons"] {
|
|
265
|
+
return canRead ? issues.map(issue => ({ code: issue.code })) : [...issues];
|
|
266
|
+
}
|
|
267
|
+
async function resourceAdmitted(path: string, canRead: GrepOpts["canRead"], reasons: SearchObservation["scan"]["reasons"],
|
|
268
|
+
signal?: AbortSignal, deadline?: number): Promise<boolean> {
|
|
269
|
+
if (!canRead) return true;
|
|
270
|
+
let allowed: boolean | undefined = false, abort = () => {};
|
|
271
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
272
|
+
const stopped = new Promise<undefined>(resolve => {
|
|
273
|
+
abort = () => resolve(undefined); signal?.addEventListener("abort", abort, { once: true });
|
|
274
|
+
if (deadline !== undefined) timer = setTimeout(abort, Math.max(0, deadline - performance.now()));
|
|
275
|
+
if (signal?.aborted) abort();
|
|
276
|
+
});
|
|
277
|
+
try { allowed = await Promise.race([Promise.resolve().then(() => canRead(path)).catch(() => false), stopped]); }
|
|
278
|
+
finally { clearTimeout(timer); signal?.removeEventListener("abort", abort); }
|
|
279
|
+
if (allowed === undefined || signal?.aborted || deadline !== undefined && performance.now() >= deadline) {
|
|
280
|
+
reasons.push({ code: signal?.aborted ? "cancelled" : "timeout" }); return false;
|
|
281
|
+
}
|
|
282
|
+
if (!allowed && !reasons.some(reason => reason.code === "permission_omitted")) reasons.push({ code: "permission_omitted" });
|
|
283
|
+
return allowed;
|
|
284
|
+
}
|
|
285
|
+
|
|
259
286
|
interface WorkerMatch {
|
|
260
287
|
readonly ok: boolean;
|
|
261
288
|
readonly hits?: ReadonlyArray<{ readonly line: number; readonly text: string }>;
|
|
@@ -475,7 +502,7 @@ async function grepWalk(opts: GrepOpts, abs: string, kind: "file" | "dir", rel:
|
|
|
475
502
|
const filtered = glob === undefined ? files.paths : files.paths.filter((p) => globMatches(glob, p));
|
|
476
503
|
const matcher = new LineMatcher();
|
|
477
504
|
const hits: MatchLine[] = [];
|
|
478
|
-
const reasons: SearchObservation["scan"]["reasons"] =
|
|
505
|
+
const reasons: SearchObservation["scan"]["reasons"] = policyIssues(files.issues, opts.canRead);
|
|
479
506
|
const sources: SearchObservation["sources"] = [];
|
|
480
507
|
let examined = 0, capturedBytes = 0;
|
|
481
508
|
const budget = Math.min(opts.maxCaptureBytes ?? MAX_SEARCH_CAPTURE_BYTES, MAX_SEARCH_CAPTURE_BYTES);
|
|
@@ -483,6 +510,7 @@ async function grepWalk(opts: GrepOpts, abs: string, kind: "file" | "dir", rel:
|
|
|
483
510
|
for (const fileRel of filtered) {
|
|
484
511
|
if (opts.signal?.aborted || performance.now() >= deadline) { reasons.push({ code: opts.signal?.aborted ? "cancelled" : "timeout" }); break; }
|
|
485
512
|
if (ignoredRel(fileRel)) continue;
|
|
513
|
+
if (!await resourceAdmitted(fileRel, opts.canRead, reasons, opts.signal, deadline)) continue;
|
|
486
514
|
const fileAbs = join(opts.root, fileRel);
|
|
487
515
|
let st;
|
|
488
516
|
try {
|
|
@@ -541,7 +569,9 @@ export async function grep(opts: GrepOpts): Promise<SearchResult> {
|
|
|
541
569
|
if ("error" in target) return { content: target.error, ok: false, diagnostic: target.diagnostic };
|
|
542
570
|
|
|
543
571
|
const limit = opts.limit !== undefined && opts.limit > 0 ? Math.floor(opts.limit) : DEFAULT_MATCH_LIMIT;
|
|
544
|
-
|
|
572
|
+
// External search captures bytes before the runtime can inspect each
|
|
573
|
+
// resource. A policy callback therefore always uses the admitted walk.
|
|
574
|
+
const engine = opts.canRead ? "walk" : opts.engine ?? "auto";
|
|
545
575
|
|
|
546
576
|
switch (engine) {
|
|
547
577
|
case "walk":
|
|
@@ -607,13 +637,18 @@ async function findFd(opts: FindOpts, abs: string, limit: number): Promise<Searc
|
|
|
607
637
|
}
|
|
608
638
|
|
|
609
639
|
async function findWalk(opts: FindOpts, abs: string, kind: "file" | "dir", rel: string, limit: number): Promise<SearchResult> {
|
|
610
|
-
const
|
|
640
|
+
const deadline = performance.now() + (opts.timeoutMs ?? PROCESS_TIMEOUT_MS);
|
|
641
|
+
const files = await listedFiles(abs, rel, kind, { deadline,
|
|
611
642
|
...(opts.signal ? { signal: opts.signal } : {}), ...(opts.maxIndexEntries === undefined ? {} : { maxEntries: opts.maxIndexEntries }) });
|
|
612
|
-
const
|
|
643
|
+
const reasons = policyIssues(files.issues, opts.canRead), paths: string[] = [];
|
|
644
|
+
for (const path of files.paths) {
|
|
645
|
+
if (opts.signal?.aborted || performance.now() >= deadline) { reasons.push({ code: opts.signal?.aborted ? "cancelled" : "timeout" }); break; }
|
|
646
|
+
if (!ignoredRel(path) && globMatches(opts.pattern, path) && await resourceAdmitted(path, opts.canRead, reasons, opts.signal, deadline)) paths.push(path);
|
|
647
|
+
}
|
|
613
648
|
paths.sort((a, b) => (a < b ? -1 : 1));
|
|
614
|
-
const stopped =
|
|
649
|
+
const stopped = reasons.find(reason => ["timeout", "cancelled"].includes(reason.code));
|
|
615
650
|
return observed({ engine: "walk", rows: paths.map(text => ({ prefix: "", text })), sources: [], limit, empty: "No files found matching pattern",
|
|
616
|
-
scan: { status:
|
|
651
|
+
scan: { status: reasons.length ? "partial" : "complete", reasons, examined_files: files.paths.length, source_versions: "unknown" } },
|
|
617
652
|
stopped ? failureDiagnostic(stopped.code === "cancelled" ? "cancelled" : "timeout", `search ${stopped.code}`, READ_ATTEMPTED) : undefined);
|
|
618
653
|
}
|
|
619
654
|
|
|
@@ -622,7 +657,7 @@ export async function find(opts: FindOpts): Promise<SearchResult> {
|
|
|
622
657
|
const target = await inspectTarget(opts.root, opts.path);
|
|
623
658
|
if ("error" in target) return { content: target.error, ok: false, diagnostic: target.diagnostic };
|
|
624
659
|
const limit = opts.limit !== undefined && opts.limit > 0 ? Math.floor(opts.limit) : DEFAULT_FIND_LIMIT;
|
|
625
|
-
const engine = opts.engine ?? "auto";
|
|
660
|
+
const engine = opts.canRead ? "walk" : opts.engine ?? "auto";
|
|
626
661
|
switch (engine) {
|
|
627
662
|
case "walk":
|
|
628
663
|
return findWalk(opts, opts.path, target.kind, target.rel, limit);
|
|
@@ -656,11 +691,14 @@ export async function ls(opts: LsOpts): Promise<SearchResult> {
|
|
|
656
691
|
return { content: `cannot list ${target.rel || "."}: ${(err as Error).message}`, ok: false, diagnostic: failureFromError(err, READ_ATTEMPTED) };
|
|
657
692
|
}
|
|
658
693
|
|
|
659
|
-
const names: string[] = [];
|
|
694
|
+
const names: string[] = [], reasons: SearchObservation["scan"]["reasons"] = [];
|
|
695
|
+
const deadline = performance.now() + PROCESS_TIMEOUT_MS;
|
|
660
696
|
for (const entry of entries) {
|
|
661
697
|
if (DEFAULT_IGNORE.includes(entry.name)) continue;
|
|
662
698
|
const childRel = target.rel.length === 0 ? entry.name : `${target.rel}/${entry.name}`;
|
|
663
699
|
if (ignoredRel(childRel)) continue;
|
|
700
|
+
if (opts.signal?.aborted || performance.now() >= deadline) { reasons.push({ code: opts.signal?.aborted ? "cancelled" : "timeout" }); break; }
|
|
701
|
+
if (!await resourceAdmitted(childRel, opts.canRead, reasons, opts.signal, deadline)) continue;
|
|
664
702
|
// Do not follow: a symlink is listed as a name, never as the directory
|
|
665
703
|
// it points at, so a link to / cannot walk the disk.
|
|
666
704
|
if (entry.isSymbolicLink()) {
|
|
@@ -679,5 +717,6 @@ export async function ls(opts: LsOpts): Promise<SearchResult> {
|
|
|
679
717
|
|
|
680
718
|
const limit = opts.limit !== undefined && opts.limit > 0 ? Math.floor(opts.limit) : DEFAULT_LS_LIMIT;
|
|
681
719
|
return observed({ engine: "directory", rows: names.map(text => ({ prefix: "", text })), sources: [], limit, empty: "(empty directory)",
|
|
682
|
-
scan: { status: "complete", reasons
|
|
720
|
+
scan: { status: reasons.length ? "partial" : "complete", reasons, examined_files: names.length, source_versions: "unknown" } },
|
|
721
|
+
reasons.some(reason => reason.code === "cancelled" || reason.code === "timeout") ? failureDiagnostic(opts.signal?.aborted ? "cancelled" : "timeout", "directory listing stopped", READ_ATTEMPTED) : undefined);
|
|
683
722
|
}
|
package/src/tui/ui/layout.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Where status lives at a given terminal size (docs/tasks/refactor-tui-dock-layout.md). Pure.
|
|
3
3
|
*
|
|
4
|
-
* sidebar ≥ 110 cols and ≥ 20 rows right column
|
|
4
|
+
* sidebar ≥ 110 cols and ≥ 20 rows right column plus compact provider header
|
|
5
5
|
* topbar 80–109 cols, or a wide but short terminal (≥ 18 rows) one row on top
|
|
6
6
|
* bare everything else the input box's bottom edge only;
|
|
7
7
|
* session and branch go to the terminal title
|
|
@@ -42,16 +42,16 @@ export function screenLayout(cols: number, rows: number, sidebarWanted = true):
|
|
|
42
42
|
const c = Math.max(0, Math.floor(cols)), r = Math.max(0, Math.floor(rows));
|
|
43
43
|
if (sidebarWanted && c >= SIDEBAR_MIN_COLUMNS && r >= SIDEBAR_MIN_ROWS) {
|
|
44
44
|
const sidebarWidth = sidebarContentWidth(c) + 1;
|
|
45
|
-
return { tier: "sidebar", sidebarWidth, mainWidth: c - sidebarWidth, topbar:
|
|
45
|
+
return { tier: "sidebar", sidebarWidth, mainWidth: c - sidebarWidth, topbar: true };
|
|
46
46
|
}
|
|
47
47
|
if (c >= TOPBAR_MIN_COLUMNS && r >= TOPBAR_MIN_ROWS) return { tier: "topbar", sidebarWidth: 0, mainWidth: c, topbar: true };
|
|
48
48
|
return { tier: "bare", sidebarWidth: 0, mainWidth: c, topbar: false };
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
-
export const STATUS_FIELDS = ["model", "thinking", "context", "title", "workspace", "branch", "usage", "cost", "handoff", "tasks", "files", "background", "resources", "extension"] as const;
|
|
51
|
+
export const STATUS_FIELDS = ["mode", "provider", "activity", "git", "model", "thinking", "context", "title", "workspace", "branch", "usage", "cost", "handoff", "tasks", "files", "background", "resources", "extension"] as const;
|
|
52
52
|
export type StatusField = (typeof STATUS_FIELDS)[number];
|
|
53
53
|
/** Screen surfaces only; the terminal title is outside the frame and listed separately. */
|
|
54
|
-
export type Place = "sidebar" | "topbar" | "bottomEdge" | "hidden";
|
|
54
|
+
export type Place = "sidebar" | "topbar" | "topEdge" | "bottomEdge" | "hidden";
|
|
55
55
|
|
|
56
56
|
/**
|
|
57
57
|
* The one place each field takes in a tier. Hidden fields stay one command
|
|
@@ -61,13 +61,13 @@ export type Place = "sidebar" | "topbar" | "bottomEdge" | "hidden";
|
|
|
61
61
|
export function placement(tier: Tier): Readonly<Record<StatusField, Place>> {
|
|
62
62
|
switch (tier) {
|
|
63
63
|
case "sidebar":
|
|
64
|
-
return { model: "bottomEdge", thinking: "bottomEdge", context: "sidebar", title: "sidebar", workspace: "sidebar", branch: "sidebar",
|
|
64
|
+
return { mode: "bottomEdge", provider: "topbar", activity: "topEdge", git: "sidebar", model: "bottomEdge", thinking: "bottomEdge", context: "sidebar", title: "sidebar", workspace: "sidebar", branch: "sidebar",
|
|
65
65
|
usage: "sidebar", cost: "sidebar", handoff: "sidebar", tasks: "sidebar", files: "sidebar", background: "sidebar", resources: "sidebar", extension: "sidebar" };
|
|
66
66
|
case "topbar":
|
|
67
|
-
return { model: "bottomEdge", thinking: "bottomEdge", context: "bottomEdge", title: "topbar", workspace: "topbar", branch: "topbar",
|
|
67
|
+
return { mode: "bottomEdge", provider: "topbar", activity: "topEdge", git: "hidden", model: "bottomEdge", thinking: "bottomEdge", context: "bottomEdge", title: "topbar", workspace: "topbar", branch: "topbar",
|
|
68
68
|
usage: "hidden", cost: "topbar", handoff: "topbar", tasks: "hidden", files: "hidden", background: "topbar", resources: "hidden", extension: "topbar" };
|
|
69
69
|
case "bare":
|
|
70
|
-
return { model: "bottomEdge", thinking: "bottomEdge", context: "bottomEdge", title: "hidden", workspace: "hidden", branch: "hidden",
|
|
70
|
+
return { mode: "bottomEdge", provider: "hidden", activity: "topEdge", git: "hidden", model: "bottomEdge", thinking: "bottomEdge", context: "bottomEdge", title: "hidden", workspace: "hidden", branch: "hidden",
|
|
71
71
|
usage: "hidden", cost: "hidden", handoff: "hidden", tasks: "hidden", files: "hidden", background: "hidden", resources: "hidden", extension: "hidden" };
|
|
72
72
|
default: return assertNever(tier, "placement");
|
|
73
73
|
}
|
package/src/tui/ui/messages.ts
CHANGED
|
@@ -34,7 +34,7 @@ export interface MessageFeatures {
|
|
|
34
34
|
}
|
|
35
35
|
|
|
36
36
|
/** Without a source (tests, print mode) pictures are named, not drawn. */
|
|
37
|
-
const NO_PICTURES: PictureSource = { mode: "chip", protocol: "blocks", cell: DEFAULT_CELL, widthCells: 60, load: async () =>
|
|
37
|
+
const NO_PICTURES: PictureSource = { mode: "chip", protocol: "blocks", cell: DEFAULT_CELL, widthCells: 60, load: async () => ({ kind: "error", reason: "closed" }) };
|
|
38
38
|
|
|
39
39
|
interface Entry { rev: string; node: Renderable; item: ViewItem }
|
|
40
40
|
|
package/src/tui/ui/pictures.ts
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
import { BoxRenderable, ImageRenderable, type CliRenderer, type ImageRenderProtocol, type NativeImage } from "@opentui/core";
|
|
14
14
|
import { Canvas } from "./canvas.ts";
|
|
15
15
|
import type { UiColors } from "./colors.ts";
|
|
16
|
-
import type
|
|
16
|
+
import { splitLine, type Seg } from "./edge.ts";
|
|
17
17
|
import { imageCells, IMAGES_PER_ITEM } from "./image-size.ts";
|
|
18
18
|
import type { ImageView } from "./items.ts";
|
|
19
19
|
|
|
@@ -25,10 +25,14 @@ export interface PictureSource {
|
|
|
25
25
|
readonly cell: { readonly width: number; readonly height: number };
|
|
26
26
|
/** The widest a picture may be, in cells (`terminal.imageWidthCells`). */
|
|
27
27
|
readonly widthCells: number;
|
|
28
|
-
|
|
29
|
-
|
|
28
|
+
readonly reason?: string;
|
|
29
|
+
/** The decoded picture for a blob, shared by every node that shows it, or its preparation failure. */
|
|
30
|
+
load(sha256: string): Promise<PictureLoad>;
|
|
30
31
|
}
|
|
31
32
|
|
|
33
|
+
export type PictureLoad = { readonly kind: "ready"; readonly image: NativeImage } |
|
|
34
|
+
{ readonly kind: "error"; readonly reason: "missing" | "corrupt" | "decode" | "limit" | "busy" | "closed" };
|
|
35
|
+
|
|
32
36
|
let serial = 0;
|
|
33
37
|
|
|
34
38
|
export function chipLine(images: readonly ImageView[], limit: number): Seg[] {
|
|
@@ -52,17 +56,31 @@ export class Pictures extends BoxRenderable {
|
|
|
52
56
|
const limit = options.limit ?? IMAGES_PER_ITEM;
|
|
53
57
|
const shown = images.slice(0, limit);
|
|
54
58
|
const pictures: ImageRenderable[] = [];
|
|
59
|
+
const states = shown.map(() => source.mode === "picture" ? "preparing" : source.reason ?? "preview unavailable");
|
|
60
|
+
const caption = new Canvas(ctx, { id: `${id}-caption`, height: Math.max(1, shown.length), flexShrink: 0, colors });
|
|
61
|
+
// A long filename must not hide the preparation state or the reason a preview failed.
|
|
62
|
+
const paintCaption = () => caption.setRows(shown.length ? shown.map((image, i) => splitLine(options.maxCols, [
|
|
63
|
+
{ segs: [{ text: "▣ ", role: "muted" }, { text: image.name, role: "text" }], priority: 0, min: 4, shrinkPriority: 5 },
|
|
64
|
+
{ segs: [{ text: `${image.width}×${image.height}`, role: "muted" }], priority: 3 },
|
|
65
|
+
...(i === shown.length - 1 && images.length > shown.length ? [{ segs: [{ text: `+${images.length - shown.length} more`, role: "muted" as const }], priority: 4 }] : []),
|
|
66
|
+
], [{ segs: [{ text: states[i]!, role: states[i]?.startsWith("preview failed") ? "warning" : "muted" }], priority: 0 }], 1, [{ text: " · ", role: "muted" }])) : [chipLine(images, limit)]);
|
|
55
67
|
if (source.mode === "picture" && shown.length) {
|
|
56
68
|
const row = new BoxRenderable(ctx, { id: `${id}-row`, flexDirection: "row", flexShrink: 0, columnGap: 1 });
|
|
57
69
|
// Pictures share the width; each keeps its own proportions.
|
|
58
70
|
const each = Math.max(1, Math.floor((Math.min(options.maxCols, source.widthCells * shown.length) - (shown.length - 1)) / shown.length));
|
|
59
|
-
for (const image of shown) {
|
|
71
|
+
for (const [index, image] of shown.entries()) {
|
|
60
72
|
const size = imageCells(image, Math.min(each, source.widthCells), options.maxRows, source.cell);
|
|
61
73
|
const picture = new ImageRenderable(ctx, { id: `${id}-${image.sha256.slice(0, 8)}-${serial++}`, width: size.cols, height: size.rows,
|
|
62
|
-
fit: "fit", protocol: source.protocol, flexShrink: 0
|
|
63
|
-
|
|
74
|
+
fit: "fit", protocol: source.protocol, flexShrink: 0,
|
|
75
|
+
onLoad: () => { states[index] = "decoded"; if (!caption.isDestroyed) paintCaption(); },
|
|
76
|
+
onError: () => { states[index] = "preview failed: decode"; picture.visible = false; if (!caption.isDestroyed) paintCaption(); } });
|
|
77
|
+
void source.load(image.sha256).then(result => {
|
|
78
|
+
if (picture.isDestroyed) return;
|
|
79
|
+
if (result.kind === "ready") picture.source = result.image;
|
|
80
|
+
else { picture.visible = false; states[index] = `preview failed: ${result.reason}`; paintCaption(); }
|
|
81
|
+
}).catch(() => {
|
|
64
82
|
if (picture.isDestroyed) return;
|
|
65
|
-
|
|
83
|
+
picture.visible = false; states[index] = "preview failed: decode"; paintCaption();
|
|
66
84
|
});
|
|
67
85
|
pictures.push(picture);
|
|
68
86
|
row.add(picture);
|
|
@@ -71,8 +89,7 @@ export class Pictures extends BoxRenderable {
|
|
|
71
89
|
}
|
|
72
90
|
this.pictures = pictures;
|
|
73
91
|
// The names stay visible beside the pictures (and stand in for them in chip mode).
|
|
74
|
-
|
|
75
|
-
caption.setRows([chipLine(images, source.mode === "picture" ? shown.length : limit)]);
|
|
92
|
+
paintCaption();
|
|
76
93
|
this.add(caption);
|
|
77
94
|
}
|
|
78
95
|
}
|