@tiagojct/subsub 0.20.0 → 0.22.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 +21 -0
- package/README.md +8 -1
- package/dist/chrome.js +171 -0
- package/dist/cli.js +10 -0
- package/dist/config.js +2 -0
- package/dist/dialogs.js +81 -0
- package/dist/files.js +66 -0
- package/dist/look.js +36 -17
- package/dist/rpc.js +185 -0
- package/dist/screens.js +425 -0
- package/dist/skin.js +37 -0
- package/dist/subsub.js +198 -31
- package/dist/tui-view.js +228 -0
- package/dist/tui.js +651 -0
- package/dist/web.js +16 -216
- package/dist/words.js +26 -0
- package/package.json +1 -1
- package/scripts/make-themes.mjs +112 -0
- package/scripts/plate.json +35 -0
- package/src/chrome.ts +208 -0
- package/src/cli.ts +9 -0
- package/src/config.ts +4 -0
- package/src/dialogs.ts +107 -0
- package/src/files.ts +66 -0
- package/src/look.ts +45 -18
- package/src/rpc.ts +198 -0
- package/src/screens.ts +530 -0
- package/src/skin.ts +65 -0
- package/src/subsub.ts +214 -36
- package/src/tui-view.ts +270 -0
- package/src/tui.ts +710 -0
- package/src/web.ts +14 -209
- package/src/words.ts +40 -0
- package/themes/subsub-glauca-dark-256.json +80 -0
- package/themes/subsub-glauca-dark.json +73 -77
- package/themes/subsub-glauca-light-256.json +80 -0
- package/themes/subsub-glauca-light.json +73 -77
- package/themes/subsub-try-works-dark-256.json +80 -0
- package/themes/subsub-try-works-dark.json +73 -77
- package/themes/subsub-try-works-light-256.json +80 -0
- package/themes/subsub-try-works-light.json +73 -77
- package/scripts/make-themes.py +0 -116
package/dist/web.js
CHANGED
|
@@ -13,20 +13,15 @@
|
|
|
13
13
|
* - Without an open page for a while (and with nothing waiting), the server
|
|
14
14
|
* stops by itself, so a shortcut can start it without a terminal.
|
|
15
15
|
*/
|
|
16
|
-
import { spawn } from "node:child_process";
|
|
17
16
|
import { randomBytes, timingSafeEqual } from "node:crypto";
|
|
18
|
-
import { createReadStream, existsSync, mkdirSync,
|
|
17
|
+
import { createReadStream, existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync, appendFileSync } from "node:fs";
|
|
19
18
|
import { createServer } from "node:http";
|
|
20
19
|
import { homedir } from "node:os";
|
|
21
|
-
import { basename, dirname, extname, join, normalize, relative,
|
|
22
|
-
import { StringDecoder } from "node:string_decoder";
|
|
20
|
+
import { basename, dirname, extname, join, normalize, relative, sep } from "node:path";
|
|
23
21
|
import { agentDirFor, chooseCwd, PACKAGE_DIR, packageVersion } from "./cli.js";
|
|
24
|
-
import { configPath,
|
|
22
|
+
import { configPath, loadConfig, RECOMMENDED_MODELS, readConfigFile, saveConfig, uiLanguage, withStarbuck } from "./config.js";
|
|
25
23
|
import { TESTED_MODELS } from "./init.js";
|
|
26
24
|
import { KEY_PROVIDERS, saveApiKey } from "./keys.js";
|
|
27
|
-
import { NOTE_DIRS, obsidianRoot, SETTINGS_DIR } from "./layout.js";
|
|
28
|
-
import { field, frontMatter } from "./notes.js";
|
|
29
|
-
import { realish, within } from "./paths.js";
|
|
30
25
|
export function parseWebArgs(args) {
|
|
31
26
|
const o = { port: 0, open: true, librarian: false, here: false, idleMinutes: 10, piArgs: [] };
|
|
32
27
|
for (let i = 0; i < args.length; i++) {
|
|
@@ -51,127 +46,12 @@ export function parseWebArgs(args) {
|
|
|
51
46
|
export { uiLanguage } from "./config.js";
|
|
52
47
|
// ---------------------------------------------------------------- providers (API keys)
|
|
53
48
|
export { KEY_PROVIDERS, saveApiKey } from "./keys.js";
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
pending = new Map();
|
|
61
|
-
listeners = new Set();
|
|
62
|
-
stderr = "";
|
|
63
|
-
onExit;
|
|
64
|
-
command;
|
|
65
|
-
args;
|
|
66
|
-
cwd;
|
|
67
|
-
env;
|
|
68
|
-
constructor(command, args, cwd, env) {
|
|
69
|
-
this.command = command;
|
|
70
|
-
this.args = args;
|
|
71
|
-
this.cwd = cwd;
|
|
72
|
-
this.env = env;
|
|
73
|
-
}
|
|
74
|
-
start(extra = []) {
|
|
75
|
-
this.buf = "";
|
|
76
|
-
this.decoder = new StringDecoder("utf8");
|
|
77
|
-
const proc = spawn(this.command, [...this.args, ...extra], { cwd: this.cwd, env: this.env, stdio: ["pipe", "pipe", "pipe"], windowsHide: true });
|
|
78
|
-
this.proc = proc;
|
|
79
|
-
proc.stdout.on("data", (chunk) => this.feed(this.decoder.write(chunk)));
|
|
80
|
-
proc.stderr.on("data", (d) => {
|
|
81
|
-
this.stderr = (this.stderr + d.toString()).slice(-20_000);
|
|
82
|
-
});
|
|
83
|
-
proc.once("exit", (code) => {
|
|
84
|
-
if (this.proc !== proc)
|
|
85
|
-
return;
|
|
86
|
-
this.proc = undefined;
|
|
87
|
-
for (const p of this.pending.values()) {
|
|
88
|
-
clearTimeout(p.timer);
|
|
89
|
-
p.reject(new Error(`pi stopped (code ${code}). ${this.stderr.slice(-500)}`));
|
|
90
|
-
}
|
|
91
|
-
this.pending.clear();
|
|
92
|
-
this.onExit?.(code);
|
|
93
|
-
});
|
|
94
|
-
}
|
|
95
|
-
feed(text) {
|
|
96
|
-
this.buf += text;
|
|
97
|
-
let i;
|
|
98
|
-
while ((i = this.buf.indexOf("\n")) >= 0) {
|
|
99
|
-
const line = this.buf.slice(0, i).replace(/\r$/, "");
|
|
100
|
-
this.buf = this.buf.slice(i + 1);
|
|
101
|
-
if (!line.trim())
|
|
102
|
-
continue;
|
|
103
|
-
let rec;
|
|
104
|
-
try {
|
|
105
|
-
rec = JSON.parse(line);
|
|
106
|
-
}
|
|
107
|
-
catch {
|
|
108
|
-
continue;
|
|
109
|
-
}
|
|
110
|
-
if (rec.type === "response" && typeof rec.id === "string" && this.pending.has(rec.id)) {
|
|
111
|
-
const p = this.pending.get(rec.id);
|
|
112
|
-
this.pending.delete(rec.id);
|
|
113
|
-
clearTimeout(p.timer);
|
|
114
|
-
if (rec.success === false)
|
|
115
|
-
p.reject(new Error(rec.error ?? `${rec.command} failed`));
|
|
116
|
-
else
|
|
117
|
-
p.resolve(rec.data);
|
|
118
|
-
continue;
|
|
119
|
-
}
|
|
120
|
-
for (const l of this.listeners)
|
|
121
|
-
l(rec);
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
on(l) {
|
|
125
|
-
this.listeners.add(l);
|
|
126
|
-
return () => this.listeners.delete(l);
|
|
127
|
-
}
|
|
128
|
-
write(rec) {
|
|
129
|
-
const stdin = this.proc?.stdin;
|
|
130
|
-
if (!stdin || !stdin.writable)
|
|
131
|
-
throw new Error("pi is not running");
|
|
132
|
-
stdin.write(`${JSON.stringify(rec)}\n`);
|
|
133
|
-
}
|
|
134
|
-
request(cmd, timeoutMs = 30_000) {
|
|
135
|
-
const id = `w${++this.n}`;
|
|
136
|
-
return new Promise((resolvePromise, reject) => {
|
|
137
|
-
const timer = setTimeout(() => {
|
|
138
|
-
this.pending.delete(id);
|
|
139
|
-
reject(new Error(`pi did not answer ${String(cmd.type)} in time`));
|
|
140
|
-
}, timeoutMs);
|
|
141
|
-
this.pending.set(id, { resolve: resolvePromise, reject, timer });
|
|
142
|
-
try {
|
|
143
|
-
this.write({ ...cmd, id });
|
|
144
|
-
}
|
|
145
|
-
catch (err) {
|
|
146
|
-
clearTimeout(timer);
|
|
147
|
-
this.pending.delete(id);
|
|
148
|
-
reject(err);
|
|
149
|
-
}
|
|
150
|
-
});
|
|
151
|
-
}
|
|
152
|
-
async stop() {
|
|
153
|
-
const proc = this.proc;
|
|
154
|
-
if (!proc)
|
|
155
|
-
return;
|
|
156
|
-
this.proc = undefined;
|
|
157
|
-
for (const p of this.pending.values()) {
|
|
158
|
-
clearTimeout(p.timer);
|
|
159
|
-
p.reject(new Error("pi was stopped"));
|
|
160
|
-
}
|
|
161
|
-
this.pending.clear();
|
|
162
|
-
await new Promise((done) => {
|
|
163
|
-
const t = setTimeout(() => {
|
|
164
|
-
proc.kill("SIGKILL");
|
|
165
|
-
done();
|
|
166
|
-
}, 3000);
|
|
167
|
-
proc.once("exit", () => {
|
|
168
|
-
clearTimeout(t);
|
|
169
|
-
done();
|
|
170
|
-
});
|
|
171
|
-
proc.kill();
|
|
172
|
-
});
|
|
173
|
-
}
|
|
174
|
-
}
|
|
49
|
+
// ---------------------------------------------------------------- files (shared with the terminal screens)
|
|
50
|
+
import { allowedFile, listNotes, openExternal, openFile, OPENABLE, wikilinkTarget } from "./files.js";
|
|
51
|
+
export { allowedFile, listNotes, openExternal, OPENABLE, wikilinkTarget };
|
|
52
|
+
// ---------------------------------------------------------------- pi in RPC mode (src/rpc.ts)
|
|
53
|
+
import { Agent, findLibrary, isDialog, Side, withHints } from "./rpc.js";
|
|
54
|
+
export { Agent };
|
|
175
55
|
// ---------------------------------------------------------------- helpers
|
|
176
56
|
const TYPES = {
|
|
177
57
|
".html": "text/html; charset=utf-8",
|
|
@@ -186,41 +66,6 @@ const TYPES = {
|
|
|
186
66
|
const NOTE_TYPES = new Set([".md", ".markdown"]);
|
|
187
67
|
/** Images that a note shows (a figure cut out of a PDF, for example). */
|
|
188
68
|
const IMAGE_TYPES = { ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif", ".webp": "image/webp" };
|
|
189
|
-
/** Files that the Open button may hand to the system (never programs or scripts). */
|
|
190
|
-
export const OPENABLE = new Set([".md", ".markdown", ".txt", ".pdf", ".bib", ".csv", ".qmd"]);
|
|
191
|
-
/** A file inside the working folder or the Sub-Sub folder, judged by its real path, so a link cannot point outside. */
|
|
192
|
-
export function allowedFile(raw, cwd, vault) {
|
|
193
|
-
// expand() resolves against the process folder: use it for "~" only.
|
|
194
|
-
const target = resolve(cwd, raw.startsWith("~") ? expand(raw) : raw);
|
|
195
|
-
const real = realish(target);
|
|
196
|
-
const inside = within(real, realish(cwd)) || Boolean(vault && within(real, realish(vault)));
|
|
197
|
-
return inside && existsSync(target) && statSync(target).isFile() ? target : undefined;
|
|
198
|
-
}
|
|
199
|
-
/** The note a [[wikilink]] names: the first <folder>/<name>.md in the Sub-Sub folder, as Obsidian would find it. */
|
|
200
|
-
export function wikilinkTarget(name, root) {
|
|
201
|
-
const file = name.endsWith(".md") ? name : `${name}.md`;
|
|
202
|
-
return ["", ...NOTE_DIRS, SETTINGS_DIR].map((d) => join(root, d, file)).find((f) => existsSync(f));
|
|
203
|
-
}
|
|
204
|
-
/** The notes in the Sub-Sub folders, newest first (dot folders such as Research/.plans left out). */
|
|
205
|
-
export function listNotes(root, max = 300) {
|
|
206
|
-
const out = [];
|
|
207
|
-
for (const d of [...NOTE_DIRS, SETTINGS_DIR]) {
|
|
208
|
-
let names = [];
|
|
209
|
-
try {
|
|
210
|
-
names = readdirSync(join(root, d), { recursive: true });
|
|
211
|
-
}
|
|
212
|
-
catch {
|
|
213
|
-
continue;
|
|
214
|
-
}
|
|
215
|
-
for (const n of names) {
|
|
216
|
-
if (!n.endsWith(".md") || n.split(/[\\/]/).some((x) => x.startsWith(".")))
|
|
217
|
-
continue;
|
|
218
|
-
const path = join(root, d, n);
|
|
219
|
-
out.push({ path, rel: join(d, n), modified: statSync(path).mtimeMs });
|
|
220
|
-
}
|
|
221
|
-
}
|
|
222
|
-
return out.sort((a, b) => b.modified - a.modified).slice(0, max);
|
|
223
|
-
}
|
|
224
69
|
const CSP = [
|
|
225
70
|
"default-src 'none'",
|
|
226
71
|
"script-src 'self'",
|
|
@@ -232,14 +77,6 @@ const CSP = [
|
|
|
232
77
|
"form-action 'none'",
|
|
233
78
|
"frame-ancestors 'none'",
|
|
234
79
|
].join("; ");
|
|
235
|
-
function readText(path) {
|
|
236
|
-
try {
|
|
237
|
-
return readFileSync(path, "utf8");
|
|
238
|
-
}
|
|
239
|
-
catch {
|
|
240
|
-
return "";
|
|
241
|
-
}
|
|
242
|
-
}
|
|
243
80
|
function same(a, b) {
|
|
244
81
|
const x = Buffer.from(a);
|
|
245
82
|
const y = Buffer.from(b);
|
|
@@ -285,20 +122,6 @@ function alive(pid) {
|
|
|
285
122
|
return err.code === "EPERM";
|
|
286
123
|
}
|
|
287
124
|
}
|
|
288
|
-
/** Open a URL or a file with the system's default program. */
|
|
289
|
-
export function openExternal(target) {
|
|
290
|
-
const opts = { detached: true, stdio: "ignore", windowsHide: true };
|
|
291
|
-
let child;
|
|
292
|
-
if (process.platform === "darwin")
|
|
293
|
-
child = spawn("open", [target], opts);
|
|
294
|
-
// rundll32 hands the target to the default program without cmd.exe parsing it.
|
|
295
|
-
else if (process.platform === "win32")
|
|
296
|
-
child = spawn("rundll32", ["url.dll,FileProtocolHandler", target], opts);
|
|
297
|
-
else
|
|
298
|
-
child = spawn("xdg-open", [target], opts);
|
|
299
|
-
child.on("error", () => { });
|
|
300
|
-
child.unref();
|
|
301
|
-
}
|
|
302
125
|
/**
|
|
303
126
|
* Start the web server and pi. Resolves when both are listening; the caller
|
|
304
127
|
* decides whether to open a browser. Used by `subsub web` and by the tests.
|
|
@@ -321,7 +144,7 @@ export async function startWeb(opts, env = process.env, log = () => { }) {
|
|
|
321
144
|
const clients = new Set();
|
|
322
145
|
const dialogs = new Map();
|
|
323
146
|
let subsubState = null;
|
|
324
|
-
|
|
147
|
+
const side = new Side();
|
|
325
148
|
let busy = false;
|
|
326
149
|
let lastSeen = Date.now();
|
|
327
150
|
let restarting = false;
|
|
@@ -345,27 +168,14 @@ export async function startWeb(opts, env = process.env, log = () => { }) {
|
|
|
345
168
|
}
|
|
346
169
|
agent.on((ev) => {
|
|
347
170
|
if (ev.type === "extension_ui_request") {
|
|
348
|
-
if (
|
|
171
|
+
if (isDialog(ev)) {
|
|
349
172
|
dialogs.set(ev.id, ev);
|
|
350
173
|
broadcast(ev);
|
|
351
174
|
}
|
|
352
|
-
else if (
|
|
353
|
-
|
|
354
|
-
subsubState = ev.statusText ? JSON.parse(ev.statusText) : null;
|
|
355
|
-
}
|
|
356
|
-
catch {
|
|
357
|
-
subsubState = null;
|
|
358
|
-
}
|
|
175
|
+
else if (side.take(ev) === "state") {
|
|
176
|
+
subsubState = side.state;
|
|
359
177
|
broadcast({ type: "subsub_state", state: subsubState });
|
|
360
178
|
}
|
|
361
|
-
else if (ev.method === "setStatus" && ev.statusKey === "subsub.find") {
|
|
362
|
-
try {
|
|
363
|
-
lastFind = ev.statusText ? JSON.parse(ev.statusText) : null;
|
|
364
|
-
}
|
|
365
|
-
catch {
|
|
366
|
-
lastFind = null;
|
|
367
|
-
}
|
|
368
|
-
}
|
|
369
179
|
else if (ev.method === "notify") {
|
|
370
180
|
broadcast(ev);
|
|
371
181
|
}
|
|
@@ -409,13 +219,7 @@ export async function startWeb(opts, env = process.env, log = () => { }) {
|
|
|
409
219
|
out.session = await agent.request({ type: "get_state" });
|
|
410
220
|
lastSession = out.session.sessionFile ?? lastSession;
|
|
411
221
|
out.messages = (await agent.request({ type: "get_messages" })).messages;
|
|
412
|
-
|
|
413
|
-
// RPC has no argument hints: read them from the prompt files.
|
|
414
|
-
out.commands = commands.map((c) => {
|
|
415
|
-
const front = c.source === "prompt" && c.sourceInfo?.path ? frontMatter(readText(c.sourceInfo.path)) : undefined;
|
|
416
|
-
const hint = front && field(front, "argument-hint");
|
|
417
|
-
return hint ? { ...c, hint } : c;
|
|
418
|
-
});
|
|
222
|
+
out.commands = withHints((await agent.request({ type: "get_commands" })).commands);
|
|
419
223
|
}
|
|
420
224
|
catch (err) {
|
|
421
225
|
out.error = err.message;
|
|
@@ -697,8 +501,7 @@ export async function startWeb(opts, env = process.env, log = () => { }) {
|
|
|
697
501
|
const q = (url.searchParams.get("q") ?? "").replace(/\s+/g, " ").trim().slice(0, 200);
|
|
698
502
|
if (!q)
|
|
699
503
|
return send(res, 200, { q, items: [] });
|
|
700
|
-
|
|
701
|
-
return send(res, 200, lastFind?.q === q ? lastFind : { q, items: [] });
|
|
504
|
+
return send(res, 200, await findLibrary(agent, side, q));
|
|
702
505
|
}
|
|
703
506
|
case "POST /api/session/name": {
|
|
704
507
|
const body = await readJson(req);
|
|
@@ -741,10 +544,7 @@ export async function startWeb(opts, env = process.env, log = () => { }) {
|
|
|
741
544
|
// Documents only: a file that the model wrote must never be run.
|
|
742
545
|
if (!OPENABLE.has(extname(target).toLowerCase()))
|
|
743
546
|
return send(res, 403, { error: "Sub-Sub opens only notes and documents (.md, .txt, .pdf, .bib, .csv, .qmd)." });
|
|
744
|
-
|
|
745
|
-
openExternal(`obsidian://open?path=${encodeURIComponent(target)}`);
|
|
746
|
-
else
|
|
747
|
-
openExternal(target);
|
|
547
|
+
openFile(target, vault);
|
|
748
548
|
return send(res, 200, { ok: true });
|
|
749
549
|
}
|
|
750
550
|
case "POST /api/quit":
|
package/dist/words.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The plain words of the web view (actions, suggestions, names of tools, in English and Portuguese), for the terminal screens.
|
|
3
|
+
* web/i18n.js is a browser module with no imports; it is loaded here as it is, so the two views never differ.
|
|
4
|
+
*/
|
|
5
|
+
var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
|
|
6
|
+
if (typeof path === "string" && /^\.\.?\//.test(path)) {
|
|
7
|
+
return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
|
|
8
|
+
return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
|
|
9
|
+
});
|
|
10
|
+
}
|
|
11
|
+
return path;
|
|
12
|
+
};
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
import { pathToFileURL } from "node:url";
|
|
15
|
+
import { PACKAGE_DIR } from "./cli.js";
|
|
16
|
+
let words;
|
|
17
|
+
/** A dynamic import: a static one would pull a file outside src/ into the TypeScript build. */
|
|
18
|
+
export function loadWords() {
|
|
19
|
+
words ??= import(__rewriteRelativeImportExtension(pathToFileURL(join(PACKAGE_DIR, "web", "i18n.js")).href));
|
|
20
|
+
return words;
|
|
21
|
+
}
|
|
22
|
+
/** What a tool is called while it runs ("zotero_find_items" is "search the library"); unknown tools keep their name. */
|
|
23
|
+
export function toolWord(w, lang, name) {
|
|
24
|
+
const short = name.replace(/^(zotero|scholar)_/, "");
|
|
25
|
+
return w.TOOLS[lang][short] ?? w.TOOLS.en[short] ?? name;
|
|
26
|
+
}
|
package/package.json
CHANGED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Writes the pi themes of Sub-Sub (themes/subsub-<name>-<scheme>.json) from the colours in the front matter of
|
|
3
|
+
// DESIGN.md and the role map in scripts/plate.json. The Researcher uses Prussian blue as its accent (glauca), the
|
|
4
|
+
// Librarian madder (try-works). Errors have no colour of their own, removals are muted ink (the strike-through is
|
|
5
|
+
// drawn by the code), and every meaning also has a glyph or a word, so the themes work for readers who do not see the washes.
|
|
6
|
+
//
|
|
7
|
+
// node scripts/make-themes.mjs
|
|
8
|
+
import fs from "node:fs";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
11
|
+
|
|
12
|
+
const ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
13
|
+
const SCHEMA = "https://raw.githubusercontent.com/earendil-works/pi/main/packages/coding-agent/src/modes/interactive/theme/theme-schema.json";
|
|
14
|
+
|
|
15
|
+
/** The `colors:` block of the DESIGN.md front matter as {token: "#rrggbb"}. */
|
|
16
|
+
export function designColors(markdown) {
|
|
17
|
+
const colors = {};
|
|
18
|
+
let inBlock = false;
|
|
19
|
+
for (const line of markdown.split("\n")) {
|
|
20
|
+
if (/^colors:\s*$/.test(line)) inBlock = true;
|
|
21
|
+
else if (inBlock && /^\S/.test(line)) break;
|
|
22
|
+
else if (inBlock) {
|
|
23
|
+
const m = line.match(/^\s+([a-z0-9-]+):\s*"(#[0-9a-fA-F]{6})"\s*$/);
|
|
24
|
+
if (m) colors[m[1]] = m[2].toLowerCase();
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return colors;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
const CUBE = [0, 95, 135, 175, 215, 255];
|
|
32
|
+
/** The RGB of an xterm-256 colour index from 16 to 255 (the 16 system colours depend on the terminal). */
|
|
33
|
+
export function xterm256(i) {
|
|
34
|
+
if (i >= 232) return [8 + (i - 232) * 10, 8 + (i - 232) * 10, 8 + (i - 232) * 10];
|
|
35
|
+
const n = i - 16;
|
|
36
|
+
return [CUBE[Math.floor(n / 36)], CUBE[Math.floor(n / 6) % 6], CUBE[n % 6]];
|
|
37
|
+
}
|
|
38
|
+
const toRgb = (h) => [1, 3, 5].map((k) => parseInt(h.slice(k, k + 2), 16));
|
|
39
|
+
const luminance = (rgb) => {
|
|
40
|
+
const [r, g, b] = rgb.map((v) => v / 255).map((v) => (v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4));
|
|
41
|
+
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
|
42
|
+
};
|
|
43
|
+
export function contrastRatio(a, b) {
|
|
44
|
+
const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x);
|
|
45
|
+
return (hi + 0.05) / (lo + 0.05);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The xterm-256 index nearest to a colour that reaches `min` contrast against `against` (the closest index with enough contrast, or the one with the most). */
|
|
49
|
+
export function nearest256(hex, against, min) {
|
|
50
|
+
const want = toRgb(hex);
|
|
51
|
+
const ranked = [];
|
|
52
|
+
for (let i = 16; i < 256; i++) {
|
|
53
|
+
const c = xterm256(i);
|
|
54
|
+
ranked.push({ i, d: (c[0] - want[0]) ** 2 * 0.3 + (c[1] - want[1]) ** 2 * 0.59 + (c[2] - want[2]) ** 2 * 0.11, k: against ? contrastRatio(c, against) : Infinity });
|
|
55
|
+
}
|
|
56
|
+
ranked.sort((a, b) => a.d - b.d);
|
|
57
|
+
return (ranked.find((r) => r.k >= min) ?? ranked.sort((a, b) => b.k - a.k)[0]).i;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Contrast that each colour variable needs against the terminal's own background: text 4.5, lines and bullets 3, rules none.
|
|
61
|
+
const NEEDS = { ink: 4.5, muted: 4.5, faint: 4.5, prussian: 4.5, madder: 4.5, sap: 4.5, gamboge: 4.5, mark: 3, rule: 0 };
|
|
62
|
+
// Roles whose background is left to the terminal in the 256-colour themes: a wash that is quantised to the cube loses its contrast.
|
|
63
|
+
const DEFAULT_BG = ["userMessageBg", "customMessageBg", "toolPendingBg", "toolSuccessBg", "toolErrorBg", "searchMatchBg"];
|
|
64
|
+
|
|
65
|
+
/** The same theme with xterm-256 indexes, for terminals without truecolor. */
|
|
66
|
+
function to256(theme) {
|
|
67
|
+
const v = theme.vars;
|
|
68
|
+
const sheet = toRgb(v.sheet);
|
|
69
|
+
const vars = {};
|
|
70
|
+
for (const name of Object.keys(NEEDS)) vars[name] = nearest256(v[name], sheet, NEEDS[name]);
|
|
71
|
+
vars.accent = vars[Object.keys(NEEDS).find((n) => v[n] === v.accent)];
|
|
72
|
+
// The selection needs the text colour to stay readable on it.
|
|
73
|
+
vars.wash = nearest256(v.wash, xterm256(vars.ink), 4.5);
|
|
74
|
+
const colors = { ...theme.colors };
|
|
75
|
+
for (const r of DEFAULT_BG) colors[r] = "";
|
|
76
|
+
return { ...theme, name: `${theme.name}-256`, vars, colors, export: theme.export };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** {"subsub-glauca-dark": theme, "subsub-glauca-dark-256": theme, ...} for every theme and scheme of the plate file. */
|
|
80
|
+
export function buildThemes(designMd, plate) {
|
|
81
|
+
const design = designColors(designMd);
|
|
82
|
+
const out = {};
|
|
83
|
+
for (const [name, mode] of Object.entries(plate.themes)) {
|
|
84
|
+
for (const scheme of ["dark", "light"]) {
|
|
85
|
+
const vars = {};
|
|
86
|
+
for (const [v, key] of Object.entries(plate.tokens[scheme])) {
|
|
87
|
+
if (!design[key]) throw new Error(`DESIGN.md has no colour "${key}" (${scheme} ${v})`);
|
|
88
|
+
vars[v] = design[key];
|
|
89
|
+
}
|
|
90
|
+
vars.accent = vars[mode.accent];
|
|
91
|
+
vars.wash = vars[mode.wash];
|
|
92
|
+
const colors = {};
|
|
93
|
+
for (const [role, ref] of Object.entries(plate.roles)) colors[role] = ref === "$accent" ? "accent" : ref === "$wash" ? "wash" : ref;
|
|
94
|
+
const id = `subsub-${name}-${scheme}`;
|
|
95
|
+
out[id] = { $schema: SCHEMA, name: id, vars, colors, export: { pageBg: vars.sheet, cardBg: vars.field, infoBg: vars.sunk } };
|
|
96
|
+
out[`${id}-256`] = to256(out[id]);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return out;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function main() {
|
|
103
|
+
const design = fs.readFileSync(path.join(ROOT, "DESIGN.md"), "utf8");
|
|
104
|
+
const plate = JSON.parse(fs.readFileSync(path.join(ROOT, "scripts", "plate.json"), "utf8"));
|
|
105
|
+
fs.mkdirSync(path.join(ROOT, "themes"), { recursive: true });
|
|
106
|
+
for (const [id, theme] of Object.entries(buildThemes(design, plate))) {
|
|
107
|
+
fs.writeFileSync(path.join(ROOT, "themes", `${id}.json`), JSON.stringify(theme, null, "\t") + "\n");
|
|
108
|
+
console.log(`themes/${id}.json`);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) main();
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"tokens": {
|
|
3
|
+
"light": {
|
|
4
|
+
"sheet": "sheet", "sunk": "sunk-field", "field": "plate-field", "ink": "copperplate-ink", "muted": "ink-muted", "faint": "ink-faint", "rule": "fine-rule", "mark": "plate-mark",
|
|
5
|
+
"prussian": "prussian-blue", "prussianWash": "prussian-wash", "madder": "madder", "madderWash": "madder-wash", "sap": "sap-green", "sapWash": "sap-wash", "gamboge": "gamboge", "gambogeWash": "gamboge-wash"
|
|
6
|
+
},
|
|
7
|
+
"dark": {
|
|
8
|
+
"sheet": "steel-ground", "sunk": "steel-sunk", "field": "steel-field", "ink": "steel-line", "muted": "steel-muted", "faint": "steel-faint", "rule": "steel-fine-rule", "mark": "steel-plate-mark",
|
|
9
|
+
"prussian": "steel-prussian", "prussianWash": "steel-prussian-wash", "madder": "steel-madder", "madderWash": "steel-madder-wash", "sap": "steel-sap", "sapWash": "steel-sap-wash", "gamboge": "steel-gamboge", "gambogeWash": "steel-gamboge-wash"
|
|
10
|
+
}
|
|
11
|
+
},
|
|
12
|
+
"themes": {
|
|
13
|
+
"glauca": { "accent": "prussian", "wash": "prussianWash" },
|
|
14
|
+
"try-works": { "accent": "madder", "wash": "madderWash" }
|
|
15
|
+
},
|
|
16
|
+
"roles": {
|
|
17
|
+
"text": "ink", "userMessageText": "ink", "customMessageText": "ink", "toolTitle": "ink", "mdHeading": "ink", "mdCode": "ink", "mdCodeBlock": "ink", "error": "ink",
|
|
18
|
+
"muted": "muted", "toolOutput": "muted", "mdQuote": "muted", "toolDiffContext": "muted", "thinkingText": "muted", "toolDiffRemoved": "muted",
|
|
19
|
+
"dim": "faint", "mdLinkUrl": "faint", "syntaxComment": "faint",
|
|
20
|
+
"border": "mark", "mdQuoteBorder": "mark", "mdListBullet": "mark",
|
|
21
|
+
"borderMuted": "rule", "mdHr": "rule", "mdCodeBlockBorder": "rule", "scrollbarTrack": "rule", "thinkingOff": "rule",
|
|
22
|
+
"scrollbarThumb": "faint",
|
|
23
|
+
"accent": "$accent", "borderAccent": "$accent", "bashMode": "$accent", "customMessageLabel": "$accent", "thinkingXhigh": "$accent", "thinkingMax": "$accent",
|
|
24
|
+
"mdLink": "prussian", "thinkingHigh": "prussian",
|
|
25
|
+
"success": "sap", "toolDiffAdded": "sap",
|
|
26
|
+
"warning": "gamboge",
|
|
27
|
+
"selectedBg": "$wash",
|
|
28
|
+
"searchMatchBg": "gambogeWash", "searchMatchText": "ink",
|
|
29
|
+
"userMessageBg": "field", "toolSuccessBg": "field",
|
|
30
|
+
"customMessageBg": "sunk", "toolErrorBg": "sunk",
|
|
31
|
+
"toolPendingBg": "gambogeWash",
|
|
32
|
+
"syntaxKeyword": "prussian", "syntaxNumber": "prussian", "syntaxFunction": "ink", "syntaxVariable": "ink", "syntaxType": "ink", "syntaxString": "muted", "syntaxOperator": "muted", "syntaxPunctuation": "faint",
|
|
33
|
+
"thinkingMinimal": "faint", "thinkingLow": "mark", "thinkingMedium": "muted"
|
|
34
|
+
}
|
|
35
|
+
}
|