@visual-z/xeno 0.2.0 → 0.3.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 +15 -0
- package/README.md +106 -26
- package/docs/install.md +52 -9
- package/docs/providers.md +11 -0
- package/package.json +5 -4
- package/src/apps/approve.ts +3 -3
- package/src/apps/cli.ts +10 -11
- package/src/apps/commands.ts +38 -5
- package/src/apps/completions.ts +73 -19
- package/src/apps/image-draft.ts +46 -0
- package/src/apps/project-trust.ts +2 -42
- package/src/apps/provider-connect.ts +346 -0
- package/src/apps/render.ts +22 -47
- package/src/apps/resources.ts +5 -2
- package/src/apps/shell.ts +298 -1526
- package/src/apps/startup-input.ts +130 -0
- package/src/apps/startup.ts +143 -115
- package/src/apps/ui/app.ts +1383 -0
- package/src/apps/ui/extension-host.ts +194 -0
- package/src/apps/ui/image-cache.ts +78 -0
- package/src/apps/ui/palette.ts +88 -0
- package/src/apps/ui/project.ts +127 -0
- package/src/apps/ui/standalone.ts +146 -0
- package/src/apps/ui/status-model.ts +68 -0
- package/src/apps/ui/surface.ts +84 -0
- package/src/apps/ui/theme.ts +86 -0
- package/src/core/acceptance.ts +107 -0
- package/src/core/action-defaults.ts +47 -295
- package/src/core/benchmark.ts +2 -2
- package/src/core/branch-receipt.ts +24 -0
- package/src/core/checkpoints.ts +64 -0
- package/src/core/churn.ts +2 -0
- package/src/core/command-artifacts.ts +94 -0
- package/src/core/command-contract.ts +35 -0
- package/src/core/completion-review.ts +21 -5
- package/src/core/context-detail.ts +37 -0
- package/src/core/context-policy.ts +74 -1
- package/src/core/continuation.ts +17 -2
- package/src/core/derive.ts +29 -3
- package/src/core/draft-images.ts +56 -0
- package/src/core/environment.ts +145 -0
- package/src/core/evaluation-coverage.ts +74 -0
- package/src/core/event-validation.ts +80 -5
- package/src/core/events.ts +97 -3
- package/src/core/evidence-links.ts +7 -0
- package/src/core/evidence.ts +13 -3
- package/src/core/execution.ts +8 -2
- package/src/core/experiment.ts +133 -0
- package/src/core/export.ts +9 -0
- package/src/core/failure-recovery.ts +173 -0
- package/src/core/file-precondition.ts +72 -0
- package/src/core/handoff.ts +65 -4
- package/src/core/history-query.ts +38 -2
- package/src/core/history.ts +38 -1
- package/src/core/integration-health.ts +34 -0
- package/src/core/keymap.ts +8 -20
- package/src/core/model-picker.ts +64 -0
- package/src/core/native-eval.ts +25 -0
- package/src/core/note-guidance.ts +34 -0
- package/src/core/observation-view.ts +49 -8
- package/src/core/permissions.ts +24 -2
- package/src/core/process-receipt.ts +105 -0
- package/src/core/process-state.ts +37 -0
- package/src/core/project.ts +45 -18
- package/src/core/prompt-policy.ts +37 -0
- package/src/core/query-eval-contract.ts +14 -0
- package/src/core/read-observation.ts +82 -0
- package/src/core/recovery-corpus.ts +17 -0
- package/src/core/recovery-evaluation.ts +60 -0
- package/src/core/recovery-sources.ts +74 -0
- package/src/core/runtime-target.ts +94 -0
- package/src/core/search-observation.ts +97 -0
- package/src/core/session-tree.ts +147 -0
- package/src/core/settings.ts +26 -10
- package/src/core/skills.ts +44 -0
- package/src/core/sql-query.ts +211 -0
- package/src/core/theme.ts +36 -58
- package/src/core/tool-contract.ts +51 -2
- package/src/core/tool-discovery.ts +87 -0
- package/src/core/tool-failure.ts +60 -0
- package/src/core/tool-mode-protocol.ts +25 -0
- package/src/core/transcript.ts +19 -4
- package/src/core/trial-lifecycle.ts +14 -0
- package/src/core/tui-mode.ts +29 -0
- package/src/core/update-check.ts +97 -0
- package/src/core/version.ts +55 -0
- package/src/core/work-state.ts +49 -3
- package/src/core/working-set.ts +91 -0
- package/src/runtime/auth.ts +2 -2
- package/src/runtime/branch-jobs.ts +36 -0
- package/src/runtime/clipboard.ts +15 -4
- package/src/runtime/command-artifacts.ts +64 -0
- package/src/runtime/environment-tools.ts +60 -0
- package/src/runtime/evidence-notes.ts +4 -2
- package/src/runtime/exec-sessions.ts +206 -61
- package/src/runtime/execution-control.ts +1 -0
- package/src/runtime/extension-ui.ts +22 -30
- package/src/runtime/extensions.ts +95 -16
- package/src/runtime/harness-comparison.ts +27 -7
- package/src/runtime/harness-eval.ts +460 -0
- package/src/runtime/history-cache.ts +57 -0
- package/src/runtime/history-tools.ts +62 -12
- package/src/runtime/integrations.ts +210 -0
- package/src/runtime/loop.ts +1021 -268
- package/src/runtime/model-discovery.ts +208 -0
- package/src/runtime/native-eval-fixtures.ts +33 -0
- package/src/runtime/native-eval.ts +143 -0
- package/src/runtime/oauth.ts +1 -1
- package/src/runtime/provider-connect.ts +109 -0
- package/src/runtime/provider-presets.ts +87 -0
- package/src/runtime/query-eval-verifier.ts +28 -0
- package/src/runtime/recovery-coverage.ts +71 -0
- package/src/runtime/recovery-evidence.ts +76 -0
- package/src/runtime/recovery-fixtures.ts +116 -0
- package/src/runtime/runtime-target.ts +149 -0
- package/src/runtime/tool-broker.ts +137 -0
- package/src/runtime/tool-discovery.ts +57 -0
- package/src/runtime/tool-mode-eval.ts +146 -0
- package/src/runtime/tool-queue.ts +77 -0
- package/src/runtime/tools.ts +628 -183
- package/src/runtime/trial-control.ts +45 -0
- package/src/runtime/update-check.ts +47 -0
- package/src/runtime/window-trial.ts +2 -1
- package/src/runtime/working-notes.ts +33 -3
- package/src/runtime/workspace-lifecycle.ts +97 -0
- package/src/store/acceptance.ts +15 -0
- package/src/store/checkpoint-history.ts +43 -0
- package/src/store/command-artifacts.ts +96 -0
- package/src/store/command.ts +65 -37
- package/src/store/file-checkpoints.ts +104 -0
- package/src/store/file-index.ts +13 -1
- package/src/store/git-checkpoints.ts +67 -0
- package/src/store/observations.ts +61 -20
- package/src/store/process-receipts.ts +34 -0
- package/src/store/query-source.ts +13 -8
- package/src/store/recovery.ts +2 -3
- package/src/store/replay.ts +16 -4
- package/src/store/runtime-target.ts +64 -0
- package/src/store/search.ts +171 -132
- package/src/store/session-archive.ts +4 -0
- package/src/store/source-cancellation.ts +6 -0
- package/src/store/update-cache.ts +36 -0
- package/src/store/workspace-observation.ts +113 -0
- package/src/tui/ansi-spans.ts +126 -0
- package/src/tui/style.ts +19 -16
- package/src/tui/ui/atomic-draft.ts +121 -0
- package/src/tui/ui/canvas.ts +96 -0
- package/src/tui/ui/colors.ts +70 -0
- package/src/tui/ui/composer.ts +199 -0
- package/src/tui/ui/dialog.ts +379 -0
- package/src/tui/ui/draft.ts +23 -0
- package/src/tui/ui/edge.ts +186 -0
- package/src/tui/ui/image-size.ts +40 -0
- package/src/tui/ui/ink.ts +210 -0
- package/src/tui/ui/items.ts +40 -0
- package/src/tui/ui/keys.ts +40 -0
- package/src/tui/ui/layout.ts +74 -0
- package/src/tui/ui/list-model.ts +86 -0
- package/src/tui/ui/list-view.ts +172 -0
- package/src/tui/ui/mascot.ts +67 -0
- package/src/tui/ui/messages.ts +518 -0
- package/src/tui/ui/motion.ts +28 -0
- package/src/tui/ui/paths.ts +10 -0
- package/src/tui/ui/pictures.ts +78 -0
- package/src/tui/ui/screen.ts +421 -0
- package/src/tui/ui/status.ts +271 -0
- package/src/tui/ui/styled.ts +85 -0
- package/src/tui/ui/testing.ts +5 -0
- package/src/tui/ui/tool-card.ts +150 -0
- package/src/apps/extension-ui.ts +0 -330
- package/src/apps/resource-browser.ts +0 -75
- package/src/apps/session-browser.ts +0 -113
- package/src/apps/session-picker.ts +0 -55
- package/src/tui/ansi.ts +0 -67
- package/src/tui/autocomplete.ts +0 -29
- package/src/tui/chat-viewport.ts +0 -47
- package/src/tui/complete.ts +0 -87
- package/src/tui/editor.ts +0 -940
- package/src/tui/host.ts +0 -264
- package/src/tui/input.ts +0 -75
- package/src/tui/keys.ts +0 -378
- package/src/tui/markdown.ts +0 -17
- package/src/tui/render.ts +0 -153
- package/src/tui/select.ts +0 -258
- package/src/tui/settings.ts +0 -44
- package/src/tui/statusline.ts +0 -162
- package/src/tui/system-theme.ts +0 -700
- package/src/tui/terminal.ts +0 -30
- package/src/tui/text.ts +0 -52
- package/src/tui/toolview.ts +0 -386
- package/src/tui/transcript.ts +0 -123
- package/src/tui/tree.ts +0 -471
- package/src/vendor/pi-tui/LICENSE.upstream +0 -21
- package/src/vendor/pi-tui/UPSTREAM.json +0 -50
- package/src/vendor/pi-tui/VENDOR.md +0 -25
- package/src/vendor/pi-tui/alt-screen-search.ts +0 -327
- package/src/vendor/pi-tui/autocomplete.ts +0 -860
- package/src/vendor/pi-tui/colors.ts +0 -367
- package/src/vendor/pi-tui/components/alt-screen-flash.ts +0 -51
- package/src/vendor/pi-tui/components/box.ts +0 -166
- package/src/vendor/pi-tui/components/cancellable-loader.ts +0 -40
- package/src/vendor/pi-tui/components/editor.ts +0 -2472
- package/src/vendor/pi-tui/components/h-stack.ts +0 -44
- package/src/vendor/pi-tui/components/image.ts +0 -127
- package/src/vendor/pi-tui/components/input.ts +0 -494
- package/src/vendor/pi-tui/components/loader.ts +0 -101
- package/src/vendor/pi-tui/components/markdown.ts +0 -1021
- package/src/vendor/pi-tui/components/mouse-region.ts +0 -33
- package/src/vendor/pi-tui/components/scroll-view.ts +0 -224
- package/src/vendor/pi-tui/components/select-list.ts +0 -273
- package/src/vendor/pi-tui/components/settings-list.ts +0 -328
- package/src/vendor/pi-tui/components/spacer.ts +0 -28
- package/src/vendor/pi-tui/components/stack.ts +0 -154
- package/src/vendor/pi-tui/components/text.ts +0 -107
- package/src/vendor/pi-tui/components/truncated-text.ts +0 -65
- package/src/vendor/pi-tui/components/v-stack.ts +0 -33
- package/src/vendor/pi-tui/editor-component.ts +0 -74
- package/src/vendor/pi-tui/fuzzy.ts +0 -138
- package/src/vendor/pi-tui/index.ts +0 -186
- package/src/vendor/pi-tui/keybindings.ts +0 -320
- package/src/vendor/pi-tui/keys.ts +0 -1401
- package/src/vendor/pi-tui/kill-ring.ts +0 -46
- package/src/vendor/pi-tui/latex.ts +0 -1506
- package/src/vendor/pi-tui/layout-node.ts +0 -51
- package/src/vendor/pi-tui/layout.ts +0 -449
- package/src/vendor/pi-tui/native/UPSTREAM.json +0 -23
- package/src/vendor/pi-tui/native/clipboard.h +0 -208
- package/src/vendor/pi-tui/native/darwin/README.md +0 -22
- package/src/vendor/pi-tui/native/darwin/build.sh +0 -69
- package/src/vendor/pi-tui/native/darwin/prebuilds/darwin-arm64/darwin-platform.node +0 -0
- package/src/vendor/pi-tui/native/darwin/prebuilds/darwin-x64/darwin-platform.node +0 -0
- package/src/vendor/pi-tui/native/darwin/src/darwin-platform.m +0 -132
- package/src/vendor/pi-tui/native/linux/README.md +0 -23
- package/src/vendor/pi-tui/native/linux/build.sh +0 -65
- package/src/vendor/pi-tui/native/linux/prebuilds/linux-arm64/linux-platform-x11.node +0 -0
- package/src/vendor/pi-tui/native/linux/prebuilds/linux-x64/linux-platform-x11.node +0 -0
- package/src/vendor/pi-tui/native/linux/src/clipboard-worker.h +0 -70
- package/src/vendor/pi-tui/native/linux/src/linux-platform-x11.c +0 -274
- package/src/vendor/pi-tui/native/napi.h +0 -91
- package/src/vendor/pi-tui/native/win32/README.md +0 -31
- package/src/vendor/pi-tui/native/win32/build.mjs +0 -232
- package/src/vendor/pi-tui/native/win32/prebuilds/win32-arm64/win32-platform.node +0 -0
- package/src/vendor/pi-tui/native/win32/prebuilds/win32-x64/win32-platform.node +0 -0
- package/src/vendor/pi-tui/native/win32/src/win32-platform.c +0 -241
- package/src/vendor/pi-tui/native-modifiers.ts +0 -13
- package/src/vendor/pi-tui/native-module-path.ts +0 -31
- package/src/vendor/pi-tui/native-platform.ts +0 -65
- package/src/vendor/pi-tui/oklab.ts +0 -233
- package/src/vendor/pi-tui/stdin-buffer.ts +0 -444
- package/src/vendor/pi-tui/terminal-colors.ts +0 -91
- package/src/vendor/pi-tui/terminal-image.ts +0 -731
- package/src/vendor/pi-tui/terminal.ts +0 -554
- package/src/vendor/pi-tui/tui-alt-screen.ts +0 -1750
- package/src/vendor/pi-tui/tui-main-screen.ts +0 -655
- package/src/vendor/pi-tui/tui.ts +0 -1493
- package/src/vendor/pi-tui/undo-stack.ts +0 -28
- package/src/vendor/pi-tui/utils.ts +0 -1385
- package/src/vendor/pi-tui/wheel-scroll.ts +0 -82
- package/src/vendor/pi-tui/word-navigation.ts +0 -117
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/** P.3: explicit source inventory for verification and optional Git checkpoints. */
|
|
2
|
+
import { readdir, lstat } from "node:fs/promises";
|
|
3
|
+
import { join, relative, resolve } from "node:path";
|
|
4
|
+
import { blobAddress, digestFile } from "./blobs.ts";
|
|
5
|
+
|
|
6
|
+
// Conservative resource budgets, not measured performance targets. Exceeding them is unknown, never an empty workspace.
|
|
7
|
+
const MAX_FILES = 20_000;
|
|
8
|
+
const MAX_BYTES = 256 * 1024 * 1024;
|
|
9
|
+
export const MAX_UNTRACKED_BYTES = 2 * 1024 * 1024;
|
|
10
|
+
export const SOURCE_IGNORE = [".git", ".xeno", "node_modules", "target", ".venv", "venv", "__pycache__", ".pytest_cache", ".mypy_cache", ".ruff_cache", ".tox", ".gradle", ".next", ".turbo", ".cache"];
|
|
11
|
+
const ignored = (path: string) => path.split("/").some(part => SOURCE_IGNORE.includes(part));
|
|
12
|
+
|
|
13
|
+
/** Bounded subprocess; private-repository callers override only Git's storage, never the user's index. */
|
|
14
|
+
export async function gitBytes(root: string, args: readonly string[], input?: Uint8Array, signal?: AbortSignal,
|
|
15
|
+
environment: Record<string, string> = {}): Promise<Uint8Array> {
|
|
16
|
+
if (signal?.aborted) throw new Error("Git observation cancelled");
|
|
17
|
+
const env: Record<string, string | undefined> = { ...process.env, GIT_OPTIONAL_LOCKS: "0", ...environment };
|
|
18
|
+
for (const key of ["GIT_INDEX_FILE", "GIT_DIR", "GIT_WORK_TREE", "GIT_COMMON_DIR", "GIT_OBJECT_DIRECTORY", "GIT_ALTERNATE_OBJECT_DIRECTORIES"]) {
|
|
19
|
+
if (!(key in environment)) delete env[key];
|
|
20
|
+
}
|
|
21
|
+
const child = Bun.spawn(["git", "--no-optional-locks", ...args], { cwd: root, env, stdin: input ?? "ignore", stdout: "pipe", stderr: "pipe" });
|
|
22
|
+
const abort = () => child.kill();
|
|
23
|
+
const timer = setTimeout(abort, 10_000);
|
|
24
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
25
|
+
const read = async (stream: ReadableStream<Uint8Array>) => {
|
|
26
|
+
const reader = stream.getReader(), chunks: Uint8Array[] = [];
|
|
27
|
+
let bytes = 0;
|
|
28
|
+
for (;;) {
|
|
29
|
+
const item = await reader.read();
|
|
30
|
+
if (item.done) break;
|
|
31
|
+
bytes += item.value.length;
|
|
32
|
+
if (bytes > MAX_BYTES) { child.kill(); throw new Error("Git observation exceeds resource budget"); }
|
|
33
|
+
chunks.push(item.value);
|
|
34
|
+
}
|
|
35
|
+
return Buffer.concat(chunks);
|
|
36
|
+
};
|
|
37
|
+
try {
|
|
38
|
+
const [stdout, , code] = await Promise.all([read(child.stdout), read(child.stderr), child.exited]);
|
|
39
|
+
if (code !== 0 || signal?.aborted) throw new Error("Git observation unavailable or cancelled");
|
|
40
|
+
return stdout;
|
|
41
|
+
} finally { clearTimeout(timer); signal?.removeEventListener("abort", abort); }
|
|
42
|
+
}
|
|
43
|
+
const text = (bytes: Uint8Array) => new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
44
|
+
|
|
45
|
+
export async function sourceInventory(root: string, signal?: AbortSignal, requireGit = false, ownedPaths: readonly string[] = []): Promise<{ paths: string[]; git: boolean }> {
|
|
46
|
+
const owned = ownedPaths.map(path => relative(resolve(root), resolve(path))).filter(path => path && !path.startsWith("../"));
|
|
47
|
+
const excluded = (path: string) => ignored(path) || owned.some(directory => path === directory || path.startsWith(directory + "/"));
|
|
48
|
+
let git = false;
|
|
49
|
+
try { await gitBytes(root, ["rev-parse", "--show-toplevel"], undefined, signal); git = true; }
|
|
50
|
+
catch { if (requireGit || signal?.aborted) throw new Error("Git checkpoints require a Git working directory"); }
|
|
51
|
+
let candidates: { path: string; tracked: boolean }[] = [];
|
|
52
|
+
if (git) {
|
|
53
|
+
const tracked = text(await gitBytes(root, ["ls-files", "-z", "--cached"], undefined, signal)).split("\0").filter(Boolean);
|
|
54
|
+
const untracked = text(await gitBytes(root, ["ls-files", "-z", "--others", "--exclude-standard"], undefined, signal)).split("\0").filter(Boolean);
|
|
55
|
+
candidates = [...new Set(tracked)].map(path => ({ path, tracked: true }));
|
|
56
|
+
const known = new Set(tracked);
|
|
57
|
+
candidates.push(...untracked.filter(path => !known.has(path)).map(path => ({ path, tracked: false })));
|
|
58
|
+
} else {
|
|
59
|
+
let entries = 0;
|
|
60
|
+
const walk = async (directory: string, prefix: string): Promise<void> => {
|
|
61
|
+
if (signal?.aborted) throw new Error("workspace observation cancelled");
|
|
62
|
+
for (const entry of await readdir(directory, { withFileTypes: true })) {
|
|
63
|
+
if (++entries > MAX_FILES) throw new Error("source inventory exceeds file budget");
|
|
64
|
+
const path = prefix + entry.name;
|
|
65
|
+
if (excluded(path)) continue;
|
|
66
|
+
if (entry.isDirectory()) await walk(join(directory, entry.name), path + "/");
|
|
67
|
+
else if (entry.isFile()) candidates.push({ path, tracked: true });
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
await walk(root, "");
|
|
71
|
+
}
|
|
72
|
+
candidates = candidates.filter(candidate => !excluded(candidate.path));
|
|
73
|
+
if (candidates.length > MAX_FILES) throw new Error("source inventory exceeds file budget");
|
|
74
|
+
const paths: string[] = [];
|
|
75
|
+
let bytes = 0;
|
|
76
|
+
for (const candidate of candidates.sort((a, b) => a.path < b.path ? -1 : 1)) {
|
|
77
|
+
if (signal?.aborted) throw new Error("workspace observation cancelled");
|
|
78
|
+
const path = candidate.path;
|
|
79
|
+
if (excluded(path)) continue;
|
|
80
|
+
const parts = path.split("/");
|
|
81
|
+
if (parts.includes("..") || path.startsWith("/")) throw new Error("unsafe source inventory");
|
|
82
|
+
let safe = true;
|
|
83
|
+
for (let i = 1; i <= parts.length; i++) {
|
|
84
|
+
try {
|
|
85
|
+
const info = await lstat(join(root, ...parts.slice(0, i)));
|
|
86
|
+
if (info.isSymbolicLink() || i === parts.length && !info.isFile()) { safe = false; break; }
|
|
87
|
+
if (i === parts.length) {
|
|
88
|
+
if (!candidate.tracked && info.size > MAX_UNTRACKED_BYTES) { safe = false; break; }
|
|
89
|
+
bytes += info.size;
|
|
90
|
+
}
|
|
91
|
+
} catch (error) {
|
|
92
|
+
if ((error as { code?: string }).code !== "ENOENT") throw error;
|
|
93
|
+
safe = false; break;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
if (bytes > MAX_BYTES) throw new Error("source inventory exceeds byte budget");
|
|
97
|
+
if (safe) paths.push(path);
|
|
98
|
+
}
|
|
99
|
+
return { paths, git };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export type WorkspaceObservation = { ok: true; fingerprint: string } | { ok: false; reason: string };
|
|
103
|
+
export async function observeWorkspace(root: string, signal?: AbortSignal, ownedPaths: readonly string[] = []): Promise<WorkspaceObservation> {
|
|
104
|
+
try {
|
|
105
|
+
const inventory = await sourceInventory(root, signal, false, ownedPaths);
|
|
106
|
+
const entries: [string, string, number][] = [];
|
|
107
|
+
for (const path of inventory.paths) {
|
|
108
|
+
if (signal?.aborted) throw new Error("workspace observation cancelled");
|
|
109
|
+
entries.push([path, await digestFile(join(root, path)), (await lstat(join(root, path))).mode & 0o777]);
|
|
110
|
+
}
|
|
111
|
+
return { ok: true, fingerprint: blobAddress(JSON.stringify(["source-v1", inventory.git ? "git" : "files", entries])) };
|
|
112
|
+
} catch (error) { return { ok: false, reason: error instanceof Error ? error.message : "workspace observation unavailable" }; }
|
|
113
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ANSI → styled spans. Pure; no renderer import.
|
|
3
|
+
*
|
|
4
|
+
* Message content (Markdown with highlighting, Mermaid, diffs, tool views)
|
|
5
|
+
* and every extension renderer produce `string[]` lines with SGR escapes —
|
|
6
|
+
* that is the Pi-compatible component contract extensions already rely on.
|
|
7
|
+
* The OpenTUI view draws cells from styled chunks, not escapes, and ships no
|
|
8
|
+
* ANSI parser, so this module is the one bridge: it turns those lines into
|
|
9
|
+
* spans the view converts to TextChunks. Keeping the bridge here (instead of
|
|
10
|
+
* re-implementing every renderer natively) keeps the extension API and the
|
|
11
|
+
* tested renderers intact across the UI rewrite (D20).
|
|
12
|
+
*
|
|
13
|
+
* Supported: SGR 0/1/2/3/4/7/9/22/23/24/27/29, 30–37/90–97/39, 40–47/100–107/49,
|
|
14
|
+
* 38;5;n / 48;5;n, 38;2;r;g;b / 48;2;r;g;b, OSC 8 hyperlinks. Every other
|
|
15
|
+
* CSI/OSC/APC (cursor moves, Pi's cursor marker, kitty graphics) is dropped:
|
|
16
|
+
* a renderable lays out its own cells, so movement escapes have no meaning.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
export type SpanColor = { kind: "index"; index: number } | { kind: "rgb"; r: number; g: number; b: number };
|
|
20
|
+
|
|
21
|
+
export interface SpanStyle {
|
|
22
|
+
readonly fg?: SpanColor;
|
|
23
|
+
readonly bg?: SpanColor;
|
|
24
|
+
readonly bold?: boolean;
|
|
25
|
+
readonly dim?: boolean;
|
|
26
|
+
readonly italic?: boolean;
|
|
27
|
+
readonly underline?: boolean;
|
|
28
|
+
readonly reverse?: boolean;
|
|
29
|
+
readonly strikethrough?: boolean;
|
|
30
|
+
readonly link?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface Span { readonly text: string; readonly style: SpanStyle }
|
|
34
|
+
|
|
35
|
+
type Mutable = { -readonly [K in keyof SpanStyle]: SpanStyle[K] };
|
|
36
|
+
|
|
37
|
+
function applySgr(style: Mutable, params: number[]): void {
|
|
38
|
+
if (params.length === 0) params = [0];
|
|
39
|
+
for (let i = 0; i < params.length; i++) {
|
|
40
|
+
const p = params[i]!;
|
|
41
|
+
if (p === 0) { for (const key of Object.keys(style) as (keyof Mutable)[]) if (key !== "link") delete style[key]; }
|
|
42
|
+
else if (p === 1) style.bold = true;
|
|
43
|
+
else if (p === 2) style.dim = true;
|
|
44
|
+
else if (p === 3) style.italic = true;
|
|
45
|
+
else if (p === 4) style.underline = true;
|
|
46
|
+
else if (p === 7) style.reverse = true;
|
|
47
|
+
else if (p === 9) style.strikethrough = true;
|
|
48
|
+
else if (p === 22) { delete style.bold; delete style.dim; }
|
|
49
|
+
else if (p === 23) delete style.italic;
|
|
50
|
+
else if (p === 24) delete style.underline;
|
|
51
|
+
else if (p === 27) delete style.reverse;
|
|
52
|
+
else if (p === 29) delete style.strikethrough;
|
|
53
|
+
else if (p >= 30 && p <= 37) style.fg = { kind: "index", index: p - 30 };
|
|
54
|
+
else if (p >= 90 && p <= 97) style.fg = { kind: "index", index: p - 90 + 8 };
|
|
55
|
+
else if (p === 39) delete style.fg;
|
|
56
|
+
else if (p >= 40 && p <= 47) style.bg = { kind: "index", index: p - 40 };
|
|
57
|
+
else if (p >= 100 && p <= 107) style.bg = { kind: "index", index: p - 100 + 8 };
|
|
58
|
+
else if (p === 49) delete style.bg;
|
|
59
|
+
else if (p === 38 || p === 48) {
|
|
60
|
+
const mode = params[i + 1];
|
|
61
|
+
let color: SpanColor | undefined;
|
|
62
|
+
if (mode === 5 && params[i + 2] !== undefined) { color = { kind: "index", index: clamp(params[i + 2]!) }; i += 2; }
|
|
63
|
+
else if (mode === 2 && params[i + 4] !== undefined) { color = { kind: "rgb", r: clamp(params[i + 2]!), g: clamp(params[i + 3]!), b: clamp(params[i + 4]!) }; i += 4; }
|
|
64
|
+
else break; // malformed extended colour: ignore the rest of this sequence
|
|
65
|
+
if (p === 38) style.fg = color; else style.bg = color;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function clamp(n: number): number { return Math.max(0, Math.min(255, Number.isFinite(n) ? n : 0)); }
|
|
71
|
+
|
|
72
|
+
function freeze(style: Mutable): SpanStyle { return { ...style }; }
|
|
73
|
+
|
|
74
|
+
function sameStyle(a: SpanStyle, b: SpanStyle): boolean { return JSON.stringify(a) === JSON.stringify(b); }
|
|
75
|
+
|
|
76
|
+
/** Parse one line. Adjacent text with equal style merges into one span. */
|
|
77
|
+
export function ansiSpans(line: string): Span[] {
|
|
78
|
+
const spans: Span[] = [];
|
|
79
|
+
const style: Mutable = {};
|
|
80
|
+
let text = "";
|
|
81
|
+
const flush = () => {
|
|
82
|
+
if (!text) return;
|
|
83
|
+
const frozen = freeze(style);
|
|
84
|
+
const last = spans[spans.length - 1];
|
|
85
|
+
if (last && sameStyle(last.style, frozen)) spans[spans.length - 1] = { text: last.text + text, style: frozen };
|
|
86
|
+
else spans.push({ text, style: frozen });
|
|
87
|
+
text = "";
|
|
88
|
+
};
|
|
89
|
+
let i = 0;
|
|
90
|
+
while (i < line.length) {
|
|
91
|
+
const ch = line[i]!;
|
|
92
|
+
if (ch !== "\x1b") { text += ch; i++; continue; }
|
|
93
|
+
const kind = line[i + 1];
|
|
94
|
+
if (kind === "[") {
|
|
95
|
+
let j = i + 2;
|
|
96
|
+
while (j < line.length && !/[@-~]/.test(line[j]!)) j++;
|
|
97
|
+
const body = line.slice(i + 2, j);
|
|
98
|
+
if (line[j] === "m" && /^[0-9;]*$/.test(body)) {
|
|
99
|
+
flush();
|
|
100
|
+
applySgr(style, body === "" ? [] : body.split(";").map(part => (part === "" ? 0 : Number(part))));
|
|
101
|
+
}
|
|
102
|
+
i = j + 1;
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
if (kind === "]" || kind === "_" || kind === "P") {
|
|
106
|
+
let j = i + 2;
|
|
107
|
+
while (j < line.length && line[j] !== "\x07" && !(line[j] === "\x1b" && line[j + 1] === "\\")) j++;
|
|
108
|
+
const body = line.slice(i + 2, j);
|
|
109
|
+
if (kind === "]" && body.startsWith("8;")) {
|
|
110
|
+
flush();
|
|
111
|
+
const url = body.slice(body.indexOf(";", 2) + 1);
|
|
112
|
+
if (url) style.link = url; else delete style.link;
|
|
113
|
+
}
|
|
114
|
+
i = line[j] === "\x07" ? j + 1 : j + 2;
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
i += 2;
|
|
118
|
+
}
|
|
119
|
+
flush();
|
|
120
|
+
return spans;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Visible text of a line: what a terminal would print, escapes removed. */
|
|
124
|
+
export function plainText(line: string): string {
|
|
125
|
+
return ansiSpans(line).map(span => span.text).join("");
|
|
126
|
+
}
|
package/src/tui/style.ts
CHANGED
|
@@ -72,23 +72,26 @@ export type ColorTokens = {
|
|
|
72
72
|
readonly mdLink: string;
|
|
73
73
|
};
|
|
74
74
|
|
|
75
|
-
/**
|
|
75
|
+
/**
|
|
76
|
+
* Must stay in lockstep with core/theme.ts dark builtin: terminal slots only,
|
|
77
|
+
* no truecolor, no background, no SGR faint for muted text.
|
|
78
|
+
*/
|
|
76
79
|
export const DEFAULT_COLORS: ColorTokens = {
|
|
77
|
-
"text": "
|
|
78
|
-
"thinkingText": "
|
|
79
|
-
"toolTitle": "
|
|
80
|
-
"toolOutput": "
|
|
81
|
-
"error": "
|
|
82
|
-
"toolDiffAdded": "
|
|
83
|
-
"toolDiffRemoved": "
|
|
84
|
-
"status": "
|
|
85
|
-
"prompt": "
|
|
86
|
-
"selectedBg": "
|
|
87
|
-
"muted": "
|
|
88
|
-
"warning": "
|
|
89
|
-
"mdHeading": "
|
|
90
|
-
"mdCode": "
|
|
91
|
-
"mdLink": "
|
|
80
|
+
"text": "",
|
|
81
|
+
"thinkingText": "95",
|
|
82
|
+
"toolTitle": "",
|
|
83
|
+
"toolOutput": "37",
|
|
84
|
+
"error": "91",
|
|
85
|
+
"toolDiffAdded": "92",
|
|
86
|
+
"toolDiffRemoved": "91",
|
|
87
|
+
"status": "37",
|
|
88
|
+
"prompt": "",
|
|
89
|
+
"selectedBg": "",
|
|
90
|
+
"muted": "37",
|
|
91
|
+
"warning": "93",
|
|
92
|
+
"mdHeading": "94",
|
|
93
|
+
"mdCode": "96",
|
|
94
|
+
"mdLink": "94"
|
|
92
95
|
};
|
|
93
96
|
|
|
94
97
|
function anyAttr(a: Attrs, color: string): boolean {
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { TextareaRenderable, type OptimizedBuffer, type KeyEvent } from "@opentui/core";
|
|
2
|
+
|
|
3
|
+
/** D20: the native editor owns text and undo; this adapter only protects registered ASCII tokens.
|
|
4
|
+
* Offsets in OpenTUI are terminal cells, while app projections use UTF-16. Resolve token starts
|
|
5
|
+
* through the native buffer, so wide glyphs, combining marks, tabs and newlines stay its concern.
|
|
6
|
+
* Native virtual extmarks in 0.5.14 subtract one on left navigation and do not expand word or
|
|
7
|
+
* selection deletion; use one range policy for every edit rather than patching their internals.
|
|
8
|
+
*/
|
|
9
|
+
export interface DraftAtom { readonly start: number; readonly end: number }
|
|
10
|
+
|
|
11
|
+
export class AtomicDraftArea extends TextareaRenderable {
|
|
12
|
+
atoms: ((text: string) => readonly DraftAtom[]) | undefined;
|
|
13
|
+
onRendered: (() => void) | undefined;
|
|
14
|
+
private killed = "";
|
|
15
|
+
|
|
16
|
+
get textCursor(): number { return this.editBuffer.getTextRange(0, this.cursorOffset).length; }
|
|
17
|
+
|
|
18
|
+
private ranges(): DraftAtom[] {
|
|
19
|
+
const text = this.plainText;
|
|
20
|
+
return (this.atoms?.(text) ?? []).map(atom => {
|
|
21
|
+
// Every token starts with one ASCII character. The first native prefix containing it
|
|
22
|
+
// ends exactly one cell after its start, irrespective of the preceding Unicode text.
|
|
23
|
+
let low = 0, high = text.length * Math.max(2, this.editBuffer.getTabWidth()) + 1;
|
|
24
|
+
while (low < high) {
|
|
25
|
+
const middle = Math.floor((low + high) / 2);
|
|
26
|
+
if (this.editBuffer.getTextRange(0, middle).length > atom.start) high = middle;
|
|
27
|
+
else low = middle + 1;
|
|
28
|
+
}
|
|
29
|
+
const start = Math.max(0, low - 1);
|
|
30
|
+
return { start, end: start + atom.end - atom.start };
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
private snap(direction = 0): void {
|
|
35
|
+
if (this.hasSelection()) return;
|
|
36
|
+
const cursor = this.cursorOffset;
|
|
37
|
+
const atom = this.ranges().find(range => cursor > range.start && cursor < range.end);
|
|
38
|
+
if (!atom) return;
|
|
39
|
+
this.editorView.setCursorByOffset(direction < 0 ? atom.start : direction > 0 ? atom.end :
|
|
40
|
+
cursor - atom.start < atom.end - cursor ? atom.start : atom.end);
|
|
41
|
+
this.requestRender();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
private move(action: () => boolean, direction: number): boolean {
|
|
45
|
+
const result = action(); this.snap(direction); return result;
|
|
46
|
+
}
|
|
47
|
+
override moveCursorLeft(options?: { select?: boolean }): boolean { return this.move(() => super.moveCursorLeft(options), -1); }
|
|
48
|
+
override moveCursorRight(options?: { select?: boolean }): boolean { return this.move(() => super.moveCursorRight(options), 1); }
|
|
49
|
+
override moveCursorUp(options?: { select?: boolean }): boolean { return this.move(() => super.moveCursorUp(options), 0); }
|
|
50
|
+
override moveCursorDown(options?: { select?: boolean }): boolean { return this.move(() => super.moveCursorDown(options), 0); }
|
|
51
|
+
override moveWordBackward(options?: { select?: boolean }): boolean { return this.move(() => super.moveWordBackward(options), -1); }
|
|
52
|
+
override moveWordForward(options?: { select?: boolean }): boolean { return this.move(() => super.moveWordForward(options), 1); }
|
|
53
|
+
override gotoVisualLineHome(options?: { select?: boolean }): boolean { return this.move(() => super.gotoVisualLineHome(options), -1); }
|
|
54
|
+
override gotoVisualLineEnd(options?: { select?: boolean }): boolean { return this.move(() => super.gotoVisualLineEnd(options), 1); }
|
|
55
|
+
override setCursor(row: number, col: number): void { super.setCursor(row, col); this.snap(); }
|
|
56
|
+
|
|
57
|
+
private erase(start: number, end: number, kill = false): boolean {
|
|
58
|
+
if (start > end) [start, end] = [end, start];
|
|
59
|
+
if (start === end) return true;
|
|
60
|
+
for (const atom of this.ranges()) {
|
|
61
|
+
if (atom.start < end && atom.end > start) { start = Math.min(start, atom.start); end = Math.max(end, atom.end); }
|
|
62
|
+
}
|
|
63
|
+
if (kill) this.killed = this.editBuffer.getTextRange(start, end);
|
|
64
|
+
const a = this.editBuffer.offsetToPosition(start), b = this.editBuffer.offsetToPosition(end);
|
|
65
|
+
if (!a || !b) return false;
|
|
66
|
+
this.clearSelection();
|
|
67
|
+
this.editBuffer.deleteRange(a.row, a.col, b.row, b.col);
|
|
68
|
+
this.requestRender();
|
|
69
|
+
return true;
|
|
70
|
+
}
|
|
71
|
+
override deleteSelection(): boolean {
|
|
72
|
+
const selection = this.getSelection();
|
|
73
|
+
return selection ? this.erase(selection.start, selection.end) : false;
|
|
74
|
+
}
|
|
75
|
+
override deleteCharBackward(): boolean {
|
|
76
|
+
if (this.hasSelection()) return this.deleteSelection();
|
|
77
|
+
const atom = this.ranges().find(range => this.cursorOffset > range.start && this.cursorOffset <= range.end);
|
|
78
|
+
return atom ? this.erase(atom.start, atom.end) : super.deleteCharBackward();
|
|
79
|
+
}
|
|
80
|
+
override deleteChar(): boolean {
|
|
81
|
+
if (this.hasSelection()) return this.deleteSelection();
|
|
82
|
+
const atom = this.ranges().find(range => this.cursorOffset >= range.start && this.cursorOffset < range.end);
|
|
83
|
+
return atom ? this.erase(atom.start, atom.end) : super.deleteChar();
|
|
84
|
+
}
|
|
85
|
+
override deleteWordBackward(): boolean {
|
|
86
|
+
if (this.hasSelection()) return this.deleteSelection();
|
|
87
|
+
return this.erase(this.editBuffer.getPrevWordBoundary().offset, this.cursorOffset, true);
|
|
88
|
+
}
|
|
89
|
+
override deleteWordForward(): boolean {
|
|
90
|
+
if (this.hasSelection()) return this.deleteSelection();
|
|
91
|
+
return this.erase(this.cursorOffset, this.editBuffer.getNextWordBoundary().offset, true);
|
|
92
|
+
}
|
|
93
|
+
override deleteToLineStart(): boolean {
|
|
94
|
+
const cursor = this.editBuffer.getCursorPosition();
|
|
95
|
+
return cursor.col ? this.erase(this.editBuffer.getLineStartOffset(cursor.row), cursor.offset, true) : super.deleteToLineStart();
|
|
96
|
+
}
|
|
97
|
+
override deleteToLineEnd(): boolean {
|
|
98
|
+
const end = this.editBuffer.getEOL().offset;
|
|
99
|
+
return end > this.cursorOffset ? this.erase(this.cursorOffset, end, true) : super.deleteToLineEnd();
|
|
100
|
+
}
|
|
101
|
+
override insertText(text: string): void {
|
|
102
|
+
if (this.hasSelection()) this.deleteSelection();
|
|
103
|
+
this.snap(); super.insertText(text);
|
|
104
|
+
}
|
|
105
|
+
override insertChar(char: string): void {
|
|
106
|
+
if (this.hasSelection()) this.deleteSelection();
|
|
107
|
+
this.snap(); super.insertChar(char);
|
|
108
|
+
}
|
|
109
|
+
override newLine(): boolean {
|
|
110
|
+
if (this.hasSelection()) this.deleteSelection();
|
|
111
|
+
this.snap(); return super.newLine();
|
|
112
|
+
}
|
|
113
|
+
override handleKeyPress(event: KeyEvent): boolean {
|
|
114
|
+
if (event.ctrl && event.name === "y" && this.killed) { this.insertText(this.killed); return true; }
|
|
115
|
+
return super.handleKeyPress(event);
|
|
116
|
+
}
|
|
117
|
+
clearKilledText(): void { this.killed = ""; }
|
|
118
|
+
override render(buffer: OptimizedBuffer, deltaTime: number): void {
|
|
119
|
+
this.snap(); super.render(buffer, deltaTime); this.onRendered?.();
|
|
120
|
+
}
|
|
121
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rows of styled segments drawn straight into the frame buffer.
|
|
3
|
+
*
|
|
4
|
+
* Chrome (input box edges, top bar, sidebar, greeting, completion list)
|
|
5
|
+
* changes often and never needs wrapping or selection, so it skips the text
|
|
6
|
+
* layout machinery: setting a TextRenderable's content re-measures the node
|
|
7
|
+
* and dirties Yoga, even for an identical string (OpenTUI 0.5.14,
|
|
8
|
+
* TextBufferRenderable). A Canvas only stores rows and requests a frame;
|
|
9
|
+
* the activity glyph is read at paint time, so a spinner tick touches no
|
|
10
|
+
* node at all (docs/tasks/refactor-tui-dock-layout.md DoD 9).
|
|
11
|
+
*/
|
|
12
|
+
import { Renderable, TextAttributes, type OptimizedBuffer, type RenderContext, type RenderableOptions, type RGBA } from "@opentui/core";
|
|
13
|
+
import type { UiColors } from "./colors.ts";
|
|
14
|
+
import type { Role } from "./edge.ts";
|
|
15
|
+
import type { Line } from "./status.ts";
|
|
16
|
+
import { displayWidth } from "../width.ts";
|
|
17
|
+
|
|
18
|
+
export interface CanvasOptions extends RenderableOptions<Canvas> {
|
|
19
|
+
readonly colors: UiColors;
|
|
20
|
+
/** The current activity glyph for `spin` segments. */
|
|
21
|
+
readonly glyph?: () => string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function roleColor(colors: UiColors, role: Role): RGBA {
|
|
25
|
+
switch (role) {
|
|
26
|
+
case "text": return colors.text;
|
|
27
|
+
case "muted": return colors.muted;
|
|
28
|
+
case "edge": return colors.edge;
|
|
29
|
+
case "line": return colors.line;
|
|
30
|
+
case "accent": return colors.accent;
|
|
31
|
+
case "success": return colors.success;
|
|
32
|
+
case "error": return colors.error;
|
|
33
|
+
case "warning": return colors.warning;
|
|
34
|
+
case "reasoning": return colors.reasoning;
|
|
35
|
+
case "code": return colors.code;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export class Canvas extends Renderable {
|
|
40
|
+
private rows: readonly Line[] = [];
|
|
41
|
+
private colors: UiColors;
|
|
42
|
+
private readonly glyph: () => string;
|
|
43
|
+
|
|
44
|
+
constructor(ctx: RenderContext, options: CanvasOptions) {
|
|
45
|
+
super(ctx, options);
|
|
46
|
+
this.colors = options.colors;
|
|
47
|
+
this.glyph = options.glyph ?? (() => "●");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
get lines(): readonly Line[] { return this.rows; }
|
|
51
|
+
/** Plain text of what is drawn (tests, /debug). */
|
|
52
|
+
get text(): string { return this.rows.map(row => row.map(seg => seg.spin ? this.glyph() : seg.text).join("")).join("\n"); }
|
|
53
|
+
|
|
54
|
+
/** Store new rows; an identical set requests nothing. */
|
|
55
|
+
setRows(rows: readonly Line[]): void {
|
|
56
|
+
if (sameRows(rows, this.rows)) return;
|
|
57
|
+
this.rows = rows;
|
|
58
|
+
this.requestRender();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
setColors(colors: UiColors): void { this.colors = colors; this.requestRender(); }
|
|
62
|
+
|
|
63
|
+
protected override renderSelf(buffer: OptimizedBuffer): void {
|
|
64
|
+
const x0 = this.screenX, y0 = this.screenY, width = this.width, height = this.height;
|
|
65
|
+
for (let row = 0; row < Math.min(height, this.rows.length); row++) {
|
|
66
|
+
// Rows scrolled above the buffer must be skipped here: the native drawText
|
|
67
|
+
// clamps a negative row to 0, where the scissor (the scroll viewport) lets it through.
|
|
68
|
+
const y = y0 + row;
|
|
69
|
+
if (y < 0 || y >= buffer.height) continue;
|
|
70
|
+
let x = x0;
|
|
71
|
+
for (const seg of this.rows[row]!) {
|
|
72
|
+
const text = seg.spin ? this.glyph() : seg.text;
|
|
73
|
+
if (!text) continue;
|
|
74
|
+
const w = seg.spin ? 1 : displayWidth(text);
|
|
75
|
+
if (x - x0 + w > width) break;
|
|
76
|
+
const attributes = (seg.bold ? TextAttributes.BOLD : 0) | (seg.italic ? TextAttributes.ITALIC : 0);
|
|
77
|
+
buffer.drawText(text, x, y, roleColor(this.colors, seg.role), undefined, attributes);
|
|
78
|
+
x += w;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function sameRows(a: readonly Line[], b: readonly Line[]): boolean {
|
|
85
|
+
if (a === b) return true;
|
|
86
|
+
if (a.length !== b.length) return false;
|
|
87
|
+
for (let i = 0; i < a.length; i++) {
|
|
88
|
+
const x = a[i]!, y = b[i]!;
|
|
89
|
+
if (x.length !== y.length) return false;
|
|
90
|
+
for (let j = 0; j < x.length; j++) {
|
|
91
|
+
const p = x[j]!, q = y[j]!;
|
|
92
|
+
if (p.text !== q.text || p.role !== q.role || p.bold !== q.bold || p.italic !== q.italic || p.spin !== q.spin) return false;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return true;
|
|
96
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The view's colour vocabulary, as OpenTUI colours ready to draw with.
|
|
3
|
+
*
|
|
4
|
+
* Built-in appearances come from the terminal's own palette (ink.ts): the
|
|
5
|
+
* background is never painted and body text is the terminal's foreground.
|
|
6
|
+
* A custom theme file supplies explicit colours instead. `src/tui` may not
|
|
7
|
+
* import core, so the app maps its Theme into this record (apps/ui/theme.ts).
|
|
8
|
+
*
|
|
9
|
+
* Colour carries a stable role: one accent for navigation, success/error
|
|
10
|
+
* only for logged outcomes (D11), warning for things waiting on the user.
|
|
11
|
+
*/
|
|
12
|
+
import { RGBA } from "@opentui/core";
|
|
13
|
+
import { terminalInks, type InkSet, type InkSpec } from "./ink.ts";
|
|
14
|
+
|
|
15
|
+
export interface UiColors {
|
|
16
|
+
/** The terminal default unless a custom theme sets one explicitly. */
|
|
17
|
+
readonly background: RGBA;
|
|
18
|
+
readonly text: RGBA;
|
|
19
|
+
readonly muted: RGBA;
|
|
20
|
+
/** The input box border. */
|
|
21
|
+
readonly edge: RGBA;
|
|
22
|
+
/** Rules, separators and scrollbar tracks. */
|
|
23
|
+
readonly line: RGBA;
|
|
24
|
+
readonly accent: RGBA;
|
|
25
|
+
readonly success: RGBA;
|
|
26
|
+
readonly error: RGBA;
|
|
27
|
+
readonly warning: RGBA;
|
|
28
|
+
readonly reasoning: RGBA;
|
|
29
|
+
readonly code: RGBA;
|
|
30
|
+
/** Mouse-selected text (feat-tui-copy-selection). */
|
|
31
|
+
readonly selectionBg: RGBA;
|
|
32
|
+
readonly selectionFg: RGBA;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const cache = new Map<string, RGBA>();
|
|
36
|
+
function cached(key: string, make: () => RGBA): RGBA {
|
|
37
|
+
let value = cache.get(key);
|
|
38
|
+
if (!value) { value = make(); cache.set(key, value); }
|
|
39
|
+
return value;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function inkColor(spec: InkSpec): RGBA {
|
|
43
|
+
switch (spec.kind) {
|
|
44
|
+
case "default": return cached("default:fg", () => RGBA.defaultForeground());
|
|
45
|
+
case "index": return cached(`index:${spec.index}`, () => RGBA.fromIndex(spec.index));
|
|
46
|
+
case "rgb": return rgba(spec.hex);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The terminal's own background: SGR 49, so transparency and blur survive. */
|
|
51
|
+
export const TERMINAL_BACKGROUND: RGBA = RGBA.defaultBackground();
|
|
52
|
+
/** Nothing at all (OpenTUI skips the cell's background). */
|
|
53
|
+
export const CLEAR: RGBA = RGBA.fromValues(0, 0, 0, 0);
|
|
54
|
+
|
|
55
|
+
export function colorsFromInks(inks: InkSet): UiColors {
|
|
56
|
+
return {
|
|
57
|
+
background: inks.background.kind === "default" ? TERMINAL_BACKGROUND : inkColor(inks.background),
|
|
58
|
+
text: inkColor(inks.text), muted: inkColor(inks.muted), edge: inkColor(inks.edge), line: inkColor(inks.line),
|
|
59
|
+
accent: inkColor(inks.accent), success: inkColor(inks.success), error: inkColor(inks.error),
|
|
60
|
+
warning: inkColor(inks.warning), reasoning: inkColor(inks.reasoning), code: inkColor(inks.code),
|
|
61
|
+
selectionBg: inkColor(inks.selectionBg), selectionFg: inkColor(inks.selectionFg),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Before the terminal answers (or when it never does). */
|
|
66
|
+
export const FALLBACK_COLORS: UiColors = colorsFromInks(terminalInks(null, "dark"));
|
|
67
|
+
|
|
68
|
+
export function rgba(hex: string): RGBA {
|
|
69
|
+
return cached(`hex:${hex}`, () => RGBA.fromHex(hex));
|
|
70
|
+
}
|