@tiagojct/subsub 0.19.0 → 0.21.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 +24 -0
- package/README.md +4 -1
- package/dist/bench.js +31 -0
- package/dist/chrome.js +171 -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/screens.js +314 -0
- package/dist/skin.js +37 -0
- package/dist/subsub.js +189 -29
- package/dist/web.js +7 -58
- package/dist/words.js +26 -0
- package/package.json +1 -1
- package/prompts/compare.md +1 -1
- package/prompts/lit.md +1 -1
- package/prompts/verify.md +2 -2
- package/roles/researcher.md +4 -2
- package/scripts/make-themes.mjs +112 -0
- package/scripts/plate.json +35 -0
- package/src/bench.ts +40 -0
- package/src/chrome.ts +208 -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/screens.ts +374 -0
- package/src/skin.ts +65 -0
- package/src/subsub.ts +206 -34
- package/src/web.ts +6 -51
- 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
|
@@ -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
|
+
}
|
package/src/bench.ts
CHANGED
|
@@ -262,6 +262,46 @@ export function prf(pred: Set<string>, gold: Set<string>) {
|
|
|
262
262
|
return { tp, fp, fn };
|
|
263
263
|
}
|
|
264
264
|
|
|
265
|
+
/** A library item as the retrieval score needs it: the citekey and the title. */
|
|
266
|
+
export interface LibraryItem {
|
|
267
|
+
citekey: string;
|
|
268
|
+
title: string;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function plainTitle(t: string): string {
|
|
272
|
+
return t.toLowerCase().replace(/[^\p{L}\p{N}]+/gu, " ").trim();
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* The library items that an answer names. An item is named by its citekey, or by the first 40 characters of its title
|
|
277
|
+
* (at least 15). A duplicate copy (citekey ending in -dup) counts as its original.
|
|
278
|
+
*/
|
|
279
|
+
export function itemsNamed(text: string, library: LibraryItem[]): Set<string> {
|
|
280
|
+
const plain = plainTitle(text);
|
|
281
|
+
const found = new Set<string>();
|
|
282
|
+
for (const item of library) {
|
|
283
|
+
const key = item.citekey.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
284
|
+
const title = plainTitle(item.title).slice(0, 40).trim();
|
|
285
|
+
if (new RegExp(`(?<![A-Za-z0-9_])${key}(?![A-Za-z0-9_])`).test(text) || (title.length >= 15 && plain.includes(title))) {
|
|
286
|
+
found.add(item.citekey.replace(/-dup$/, ""));
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
return found;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Precision and recall of the items in a retrieval answer against the gold items. Items in `acceptable` are on the topic but
|
|
294
|
+
* outside the gold list: they count as neither hits nor errors. "(copy)" and "-dup" mark a duplicate and count as the original.
|
|
295
|
+
*/
|
|
296
|
+
export function retrievalScore(text: string, gold: string[], acceptable: string[], library: LibraryItem[]) {
|
|
297
|
+
const original = (k: string) => k.replace(/\s*\(copy\)\s*$/, "").replace(/-dup$/, "");
|
|
298
|
+
const goldSet = new Set(gold.map(original));
|
|
299
|
+
const skip = new Set(acceptable.map(original));
|
|
300
|
+
const said = new Set([...itemsNamed(text, library)].filter((k) => goldSet.has(k) || !skip.has(k)));
|
|
301
|
+
const { tp, fp, fn } = prf(said, goldSet);
|
|
302
|
+
return { ...f1(tp, fp, fn), tp, fp, fn };
|
|
303
|
+
}
|
|
304
|
+
|
|
265
305
|
function f1(tp: number, fp: number, fn: number) {
|
|
266
306
|
const p = tp + fp ? tp / (tp + fp) : 0;
|
|
267
307
|
const r = tp + fn ? tp / (tp + fn) : 0;
|
package/src/chrome.ts
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The parts of the terminal screens, as pure functions: (width, paint, glyphs) => lines. They know nothing about pi; the
|
|
3
|
+
* extension hands them the state and the paint. Every line fits the width, so a snapshot at 40, 80 and 120 columns is a test.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { visibleWidth } from "@earendil-works/pi-tui";
|
|
7
|
+
import { libraryLine, LIBRARY_WORDS, type LibraryState, type Paint, plain } from "./look.ts";
|
|
8
|
+
import { type Glyphs, UNICODE } from "./skin.ts";
|
|
9
|
+
|
|
10
|
+
type Lang = "en" | "pt";
|
|
11
|
+
|
|
12
|
+
export const CHROME_WORDS = {
|
|
13
|
+
en: { researcher: "Researcher", librarian: "Librarian", of: "of", tokens: "tokens", unknownUsage: "?", failed: "could not", evidence: "evidence", down: "not running", more: "more lines" },
|
|
14
|
+
pt: { researcher: "Researcher", librarian: "Librarian", of: "de", tokens: "tokens", unknownUsage: "?", failed: "não foi possível", evidence: "evidência", down: "parados", more: "linhas a mais" },
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const len = (s: string) => [...s].length;
|
|
18
|
+
|
|
19
|
+
/** Cut to the width, with "..." when something is left out. Run it before the text is painted. */
|
|
20
|
+
export function cut(s: string, width: number): string {
|
|
21
|
+
return len(s) > width ? `${[...s].slice(0, Math.max(0, width - 3)).join("")}...` : s;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Pad lines with spaces to the width, and with empty lines to the height. A screen drawn over the conversation (an overlay) must cover every cell
|
|
26
|
+
* it has, or the text under it shows through at the end of short lines and below the last one.
|
|
27
|
+
*/
|
|
28
|
+
export function fill(lines: string[], width: number, height = 0): string[] {
|
|
29
|
+
const out = lines.map((l) => l + " ".repeat(Math.max(0, width - visibleWidth(l))));
|
|
30
|
+
while (out.length < height) out.push(" ".repeat(width));
|
|
31
|
+
return height ? out.slice(0, height) : out;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** A line of one glyph. */
|
|
35
|
+
export function rule(width: number, kind: "solid" | "dashed" | "dotted", paint: (s: string) => string = (s) => s, g: Glyphs = UNICODE): string {
|
|
36
|
+
return paint(g[kind].repeat(Math.max(0, width)));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** A context meter: `▰▰▱▱▱ 38%`. null (unknown) is drawn as an empty bar and `?`. */
|
|
40
|
+
export function meter(percent: number | null | undefined, cells: number, g: Glyphs = UNICODE): { bar: string; label: string } {
|
|
41
|
+
if (percent === null || percent === undefined || Number.isNaN(percent)) return { bar: g.cellEmpty.repeat(cells), label: "?" };
|
|
42
|
+
const p = Math.min(100, Math.max(0, percent));
|
|
43
|
+
const full = Math.round((p / 100) * cells);
|
|
44
|
+
return { bar: g.cell.repeat(full) + g.cellEmpty.repeat(cells - full), label: `${Math.round(p)}%` };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function compactNumber(n: number): string {
|
|
48
|
+
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1).replace(/\.0$/, "")}M`;
|
|
49
|
+
if (n >= 1000) return `${(n / 1000).toFixed(1).replace(/\.0$/, "")}k`;
|
|
50
|
+
return String(n);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface FooterState {
|
|
54
|
+
mode: "researcher" | "librarian";
|
|
55
|
+
lang: Lang;
|
|
56
|
+
library: LibraryState;
|
|
57
|
+
model: string;
|
|
58
|
+
/** Share of the context window in use, 0 to 100; null when pi does not know it yet. */
|
|
59
|
+
percent?: number | null;
|
|
60
|
+
/** The size of the context window, in tokens. */
|
|
61
|
+
window?: number;
|
|
62
|
+
tokens?: number | null;
|
|
63
|
+
cost?: number | null;
|
|
64
|
+
/** Servers that did not start ("zotero", "scholar"). */
|
|
65
|
+
down?: string[];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The footer, in four tiers: 100 columns and more show everything on two lines; 60 to 99 leave out tokens and cost; 40 to 59 are one line with the
|
|
70
|
+
* mode, the item count and the meter; below 40 only the mode and the percentage.
|
|
71
|
+
*/
|
|
72
|
+
export function footerLines(st: FooterState, width: number, p: Paint = plain, g: Glyphs = UNICODE): string[] {
|
|
73
|
+
const w = Math.max(10, width);
|
|
74
|
+
const words = CHROME_WORDS[st.lang];
|
|
75
|
+
const lib = LIBRARY_WORDS[st.lang];
|
|
76
|
+
const mode = st.mode === "librarian" ? words.librarian : words.researcher;
|
|
77
|
+
const m = meter(st.percent, w >= 100 ? 10 : 5, g);
|
|
78
|
+
if (w < 40) return [p.mark(mode) + p.muted(cut(` ${m.label}`, Math.max(0, w - len(mode))))];
|
|
79
|
+
const count = st.library.zotero === "reachable" ? `${st.library.items ?? "?"} ${lib.items}` : st.library.zotero === "down" ? `${g.hollow} ${lib.prefix}` : `${lib.prefix}...`;
|
|
80
|
+
if (w < 60) {
|
|
81
|
+
const tail = ` | ${count} | ${m.bar} ${m.label}`;
|
|
82
|
+
return [p.mark(mode) + p.muted(cut(tail, Math.max(0, w - len(mode))))];
|
|
83
|
+
}
|
|
84
|
+
const first = libraryLine(st.library, st.lang, g).text + (st.down?.length ? ` | ${words.down}: ${st.down.join(", ")}` : "");
|
|
85
|
+
const lines = [p.mark(mode) + p.muted(cut(` | ${first}`, Math.max(0, w - len(mode))))];
|
|
86
|
+
const window = st.window ? ` ${words.of} ${compactNumber(st.window)}` : "";
|
|
87
|
+
let second = `${cut(st.model, Math.max(8, Math.floor(w / 3)))} ${m.bar} ${m.label}${m.label === "?" ? "" : window}`;
|
|
88
|
+
if (w >= 100) {
|
|
89
|
+
const tokens = st.tokens === null || st.tokens === undefined ? words.unknownUsage : compactNumber(st.tokens);
|
|
90
|
+
const cost = st.cost === null || st.cost === undefined ? "" : ` $${st.cost.toFixed(st.cost < 1 ? 3 : 2)}`;
|
|
91
|
+
second += ` ${tokens} ${words.tokens}${cost}`;
|
|
92
|
+
}
|
|
93
|
+
lines.push(p.dim(cut(second, w)));
|
|
94
|
+
return lines;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export type ToolState = "pending" | "done" | "failed";
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* One line for a tool call, in plain words: `┄ search the library: asthma` while it runs (dashed), `─ search the library: asthma` when it is done
|
|
101
|
+
* (solid), `○ could not search the library: first line of the error` when it failed (a hollow mark and a word, in ink).
|
|
102
|
+
*/
|
|
103
|
+
export function toolLine(opts: { state: ToolState; word: string; detail?: string; error?: string; lang: Lang; width: number }, p: Paint = plain, g: Glyphs = UNICODE): string {
|
|
104
|
+
const w = Math.max(10, opts.width);
|
|
105
|
+
const words = CHROME_WORDS[opts.lang];
|
|
106
|
+
if (opts.state === "failed") {
|
|
107
|
+
const why = opts.error ? `: ${opts.error.split("\n")[0]}` : "";
|
|
108
|
+
return p.text(cut(`${g.hollow} ${words.failed} ${opts.word}${why}`, w));
|
|
109
|
+
}
|
|
110
|
+
const mark = opts.state === "pending" ? p.pend(g.dashed) : p.ok(g.solid);
|
|
111
|
+
const rest = ` ${opts.word}${opts.detail ? `: ${opts.detail}` : ""}`;
|
|
112
|
+
return mark + p.muted(cut(rest, w - 1));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** A short detail for a tool call: the first useful argument (a query, a title, a key, a path), cut to the length. */
|
|
116
|
+
export function toolDetail(args: unknown, max = 40): string {
|
|
117
|
+
if (!args || typeof args !== "object") return "";
|
|
118
|
+
const a = args as Record<string, unknown>;
|
|
119
|
+
for (const k of ["query", "q", "title", "citekey", "key", "keys", "doi", "identifier", "path", "name", "text"]) {
|
|
120
|
+
const v = a[k];
|
|
121
|
+
if (v === undefined || v === null || v === "") continue;
|
|
122
|
+
const s = Array.isArray(v) ? v.filter((x) => typeof x !== "object").join(", ") : String(v);
|
|
123
|
+
if (s) return cut(s.replace(/\s+/g, " "), max);
|
|
124
|
+
}
|
|
125
|
+
return "";
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export type EvidenceKind = "full" | "abstract" | "metadata" | "other";
|
|
129
|
+
|
|
130
|
+
/** What a note was written from, from its `evidence:` field: full text is solid sap, an abstract dashed gamboge, metadata dotted muted. */
|
|
131
|
+
export function evidenceKind(evidence: string): EvidenceKind {
|
|
132
|
+
return /full/i.test(evidence) ? "full" : /abstract/i.test(evidence) ? "abstract" : /metadata|metadados/i.test(evidence) ? "metadata" : "other";
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function evidenceLine(evidence: string, lang: Lang, width: number, p: Paint = plain, g: Glyphs = UNICODE): string {
|
|
136
|
+
const kind = evidenceKind(evidence);
|
|
137
|
+
const label = `${CHROME_WORDS[lang].evidence}: ${evidence}`;
|
|
138
|
+
const lead = kind === "full" ? p.ok(g.solid.repeat(2)) : kind === "abstract" ? p.pend(g.dashed.repeat(2)) : p.muted(g.dotted.repeat(2));
|
|
139
|
+
return `${lead}${p.text(cut(` ${label}`, Math.max(0, width - 2)))}`;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Tokens and cost of the conversation, from the assistant messages of the session. null when a message has no usage (the provider did not report it). */
|
|
143
|
+
export function usageTotals(entries: unknown[]): { tokens: number | null; cost: number | null } {
|
|
144
|
+
let tokens = 0;
|
|
145
|
+
let cost = 0;
|
|
146
|
+
for (const e of entries as Array<{ type?: string; message?: { role?: string; usage?: { totalTokens?: number; input?: number; output?: number; cacheRead?: number; cacheWrite?: number; cost?: { total?: number } } } }>) {
|
|
147
|
+
if (e.type !== "message" || e.message?.role !== "assistant") continue;
|
|
148
|
+
const u = e.message.usage;
|
|
149
|
+
if (!u) return { tokens: null, cost: null };
|
|
150
|
+
tokens += u.totalTokens ?? (u.input ?? 0) + (u.output ?? 0) + (u.cacheRead ?? 0) + (u.cacheWrite ?? 0);
|
|
151
|
+
cost += u.cost?.total ?? 0;
|
|
152
|
+
}
|
|
153
|
+
return { tokens, cost };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** The shape of the pi theme and tool render context that the tool renderers use. */
|
|
157
|
+
interface RenderContext {
|
|
158
|
+
args?: unknown;
|
|
159
|
+
isPartial?: boolean;
|
|
160
|
+
isError?: boolean;
|
|
161
|
+
expanded?: boolean;
|
|
162
|
+
state?: Record<string, unknown>;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const EXPANDED_LINES = 60;
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Tool renderers for the tools of Sub-Sub's servers: one line in plain words (see toolLine) and no box. The call line follows the state of the
|
|
169
|
+
* call; the result shows nothing unless it failed (the first line of the error goes on the call line) or the row is expanded (the output, indented).
|
|
170
|
+
*/
|
|
171
|
+
export function toolRenderers(opts: { word: (toolName: string) => string; lang: Lang; glyphs: () => Glyphs; paint: (theme: any) => Paint }) {
|
|
172
|
+
const text = (result: { content?: Array<{ type?: string; text?: string }> }): string => (result.content ?? []).filter((c) => c.type === "text").map((c) => c.text ?? "").join("\n");
|
|
173
|
+
return (toolName: string) => ({
|
|
174
|
+
renderShell: "self" as const,
|
|
175
|
+
renderCall: (args: unknown, theme: unknown, context: RenderContext) => ({
|
|
176
|
+
render: (width: number): string[] => [
|
|
177
|
+
toolLine(
|
|
178
|
+
{
|
|
179
|
+
state: context.isError ? "failed" : context.isPartial === false ? "done" : "pending",
|
|
180
|
+
word: opts.word(toolName),
|
|
181
|
+
detail: toolDetail(context.args ?? args),
|
|
182
|
+
error: typeof context.state?.error === "string" ? context.state.error : undefined,
|
|
183
|
+
lang: opts.lang,
|
|
184
|
+
width,
|
|
185
|
+
},
|
|
186
|
+
opts.paint(theme),
|
|
187
|
+
opts.glyphs(),
|
|
188
|
+
),
|
|
189
|
+
],
|
|
190
|
+
invalidate() {},
|
|
191
|
+
}),
|
|
192
|
+
renderResult: (result: { content?: Array<{ type?: string; text?: string }> }, options: { expanded?: boolean }, theme: unknown, context: RenderContext) => {
|
|
193
|
+
const out = text(result);
|
|
194
|
+
if (context.state) context.state.error = context.isError ? out.trim().split("\n")[0] : undefined;
|
|
195
|
+
return {
|
|
196
|
+
render: (width: number): string[] => {
|
|
197
|
+
if (!options.expanded || !out.trim()) return [];
|
|
198
|
+
const p = opts.paint(theme);
|
|
199
|
+
const lines = out.split("\n");
|
|
200
|
+
const shown = lines.slice(0, EXPANDED_LINES).map((l) => p.muted(cut(` ${l}`, width)));
|
|
201
|
+
if (lines.length > EXPANDED_LINES) shown.push(p.dim(` ... ${lines.length - EXPANDED_LINES} ${CHROME_WORDS[opts.lang].more}`));
|
|
202
|
+
return shown;
|
|
203
|
+
},
|
|
204
|
+
invalidate() {},
|
|
205
|
+
};
|
|
206
|
+
},
|
|
207
|
+
});
|
|
208
|
+
}
|
package/src/config.ts
CHANGED
|
@@ -99,6 +99,8 @@ export interface SubsubConfig {
|
|
|
99
99
|
quotes?: boolean;
|
|
100
100
|
/** Theme, without -dark/-light; false keeps your pi theme. */
|
|
101
101
|
themes?: string | false;
|
|
102
|
+
/** Theme (without -dark/-light) for the Librarian; the default is subsub-try-works. */
|
|
103
|
+
themesLibrarian?: string;
|
|
102
104
|
/** Optional servers to start, e.g. ["starbuck"]. */
|
|
103
105
|
addons?: string[];
|
|
104
106
|
/** Local Starbuck folder (used when it has a pyproject.toml). */
|
|
@@ -183,6 +185,7 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): SubsubConfig {
|
|
|
183
185
|
const models: Partial<Record<Mode, string>> = given ?? (one !== undefined ? { librarian: one || undefined, researcher: one || undefined } : fallback);
|
|
184
186
|
for (const k of Object.keys(models) as Mode[]) if (!models[k]) delete models[k];
|
|
185
187
|
const themes = user.themes === false ? false : typeof user.themes === "string" ? user.themes : user.themes?.researcher;
|
|
188
|
+
const themesLibrarian = user.themes && typeof user.themes === "object" ? user.themes.librarian : undefined;
|
|
186
189
|
return {
|
|
187
190
|
configFile: file,
|
|
188
191
|
userName: typeof user.userName === "string" && user.userName.trim() ? user.userName.trim() : undefined,
|
|
@@ -209,6 +212,7 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): SubsubConfig {
|
|
|
209
212
|
look: user.look,
|
|
210
213
|
quotes: user.quotes,
|
|
211
214
|
themes,
|
|
215
|
+
themesLibrarian,
|
|
212
216
|
addons: Array.isArray(user.addons) ? user.addons.filter((a): a is string => typeof a === "string") : [],
|
|
213
217
|
starbuckDir: expand(user.starbuckDir ?? "~/Projects/starbuck", home),
|
|
214
218
|
usageLog: user.usageLog === true,
|
package/src/dialogs.ts
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The approval dialog of the terminal look: a preview between two dashed rules (dashed means "not done yet"), and keys that answer it.
|
|
3
|
+
* It replaces pi's confirm dialog when the look is on. Nothing is applied unless the answer is the yes key: Enter does nothing, Esc, n and
|
|
4
|
+
* Ctrl+C say no, and keys pressed in the first half second are ignored, so a letter typed just before the dialog opened cannot answer it.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { matchesKey, wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
|
8
|
+
import { cut, fill, rule } from "./chrome.ts";
|
|
9
|
+
import type { Paint } from "./look.ts";
|
|
10
|
+
import { type Glyphs, UNICODE } from "./skin.ts";
|
|
11
|
+
|
|
12
|
+
type Lang = "en" | "pt";
|
|
13
|
+
|
|
14
|
+
export const APPROVAL_WORDS = {
|
|
15
|
+
en: { yes: "y apply", no: "n leave it as it is", esc: "Esc leave it", scroll: "up and down: scroll", above: "more lines above", below: "more lines below" },
|
|
16
|
+
pt: { yes: "s aplicar", no: "n deixar como está", esc: "Esc deixar", scroll: "cima e baixo: deslocar", above: "linhas acima", below: "linhas abaixo" },
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/** The same keys as the web view: y, and s in Portuguese. */
|
|
20
|
+
export const YES_KEYS: Record<Lang, string[]> = { en: ["y"], pt: ["s", "y"] };
|
|
21
|
+
|
|
22
|
+
export interface ApprovalLines {
|
|
23
|
+
lines: string[];
|
|
24
|
+
/** Lines of the preview in all. */
|
|
25
|
+
total: number;
|
|
26
|
+
/** The largest scroll offset. */
|
|
27
|
+
maxScroll: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The dialog as lines: rule, title, preview (scrolled), rule, keys. Every line fits the width. */
|
|
31
|
+
export function approvalLines(o: { title: string; body: string; lang: Lang; width: number; paint: Paint; glyphs?: Glyphs; scroll?: number; bodyRows?: number }): ApprovalLines {
|
|
32
|
+
const g = o.glyphs ?? UNICODE;
|
|
33
|
+
const w = Math.max(20, o.width);
|
|
34
|
+
const words = APPROVAL_WORDS[o.lang];
|
|
35
|
+
const all = wrapTextWithAnsi(o.body, w - 2);
|
|
36
|
+
const rows = Math.max(3, o.bodyRows ?? 14);
|
|
37
|
+
const maxScroll = Math.max(0, all.length - rows);
|
|
38
|
+
const scroll = Math.min(Math.max(0, o.scroll ?? 0), maxScroll);
|
|
39
|
+
const shown = all.slice(scroll, scroll + rows).map((l) => ` ${l}`);
|
|
40
|
+
const below = all.length - scroll - shown.length;
|
|
41
|
+
const lines = [rule(w, "dashed", o.paint.pend, g), o.paint.text(cut(` ${o.title}`, w)), ""];
|
|
42
|
+
if (scroll > 0) lines.push(o.paint.dim(cut(` ... ${scroll} ${words.above}`, w)));
|
|
43
|
+
lines.push(...shown);
|
|
44
|
+
if (below > 0) lines.push(o.paint.dim(cut(` ... ${below} ${words.below} (${words.scroll})`, w)));
|
|
45
|
+
lines.push("", rule(w, "dashed", o.paint.pend, g));
|
|
46
|
+
lines.push(...wrapTextWithAnsi(` ${words.yes} ${words.no} ${words.esc}`, w).map((l) => o.paint.muted(l)));
|
|
47
|
+
return { lines, total: all.length, maxScroll };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface ApprovalOptions {
|
|
51
|
+
title: string;
|
|
52
|
+
body: string;
|
|
53
|
+
lang: Lang;
|
|
54
|
+
paint: Paint;
|
|
55
|
+
glyphs?: Glyphs;
|
|
56
|
+
done: (yes: boolean) => void;
|
|
57
|
+
/** Rows of the terminal, for the height of the preview. */
|
|
58
|
+
rows?: () => number;
|
|
59
|
+
/** Called after a key changed what the dialog shows. */
|
|
60
|
+
redraw?: () => void;
|
|
61
|
+
/** Milliseconds during which keys are ignored. */
|
|
62
|
+
ignoreMs?: number;
|
|
63
|
+
now?: () => number;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** The pi-tui component of the dialog (see ExtensionUIContext.custom). */
|
|
67
|
+
export class ApprovalView {
|
|
68
|
+
private scroll = 0;
|
|
69
|
+
private answered = false;
|
|
70
|
+
private readonly o: ApprovalOptions;
|
|
71
|
+
private readonly opened: number;
|
|
72
|
+
constructor(o: ApprovalOptions) {
|
|
73
|
+
this.o = o;
|
|
74
|
+
this.opened = (o.now ?? Date.now)();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
private bodyRows(): number {
|
|
78
|
+
return Math.max(3, Math.floor(((this.o.rows?.() ?? 24) * 9) / 10) - 8);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
render(width: number): string[] {
|
|
82
|
+
const view = approvalLines({ ...this.o, width, scroll: this.scroll, bodyRows: this.bodyRows() });
|
|
83
|
+
this.scroll = Math.min(this.scroll, view.maxScroll);
|
|
84
|
+
return fill(view.lines, width);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
invalidate(): void {}
|
|
88
|
+
|
|
89
|
+
private answer(yes: boolean): void {
|
|
90
|
+
if (this.answered) return;
|
|
91
|
+
this.answered = true;
|
|
92
|
+
this.o.done(yes);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
handleInput(data: string): void {
|
|
96
|
+
if ((this.o.now ?? Date.now)() - this.opened < (this.o.ignoreMs ?? 500)) return;
|
|
97
|
+
if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c") || data === "n" || data === "N") return this.answer(false);
|
|
98
|
+
if (data.length === 1 && YES_KEYS[this.o.lang].includes(data.toLowerCase())) return this.answer(true);
|
|
99
|
+
const page = this.bodyRows() - 1;
|
|
100
|
+
if (matchesKey(data, "down")) this.scroll += 1;
|
|
101
|
+
else if (matchesKey(data, "up")) this.scroll = Math.max(0, this.scroll - 1);
|
|
102
|
+
else if (matchesKey(data, "pageDown")) this.scroll += page;
|
|
103
|
+
else if (matchesKey(data, "pageUp")) this.scroll = Math.max(0, this.scroll - page);
|
|
104
|
+
else return;
|
|
105
|
+
this.o.redraw?.();
|
|
106
|
+
}
|
|
107
|
+
}
|
package/src/files.ts
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Files that the web view and the terminal screens open: which file a link may point to, which notes exist, and the system's
|
|
3
|
+
* default program. Moved out of web.ts so that the terminal does not import the web server.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { spawn, type ChildProcess } from "node:child_process";
|
|
7
|
+
import { existsSync, readdirSync, statSync } from "node:fs";
|
|
8
|
+
import { join, resolve } from "node:path";
|
|
9
|
+
import { expand } from "./config.ts";
|
|
10
|
+
import { NOTE_DIRS, obsidianRoot, SETTINGS_DIR } from "./layout.ts";
|
|
11
|
+
import { realish, within } from "./paths.ts";
|
|
12
|
+
|
|
13
|
+
/** Files that the Open button may hand to the system (never programs or scripts). */
|
|
14
|
+
export const OPENABLE = new Set([".md", ".markdown", ".txt", ".pdf", ".bib", ".csv", ".qmd"]);
|
|
15
|
+
|
|
16
|
+
/** A file inside the working folder or the Sub-Sub folder, judged by its real path, so a link cannot point outside. */
|
|
17
|
+
export function allowedFile(raw: string, cwd: string, vault?: string): string | undefined {
|
|
18
|
+
// expand() resolves against the process folder: use it for "~" only.
|
|
19
|
+
const target = resolve(cwd, raw.startsWith("~") ? expand(raw) : raw);
|
|
20
|
+
const real = realish(target);
|
|
21
|
+
const inside = within(real, realish(cwd)) || Boolean(vault && within(real, realish(vault)));
|
|
22
|
+
return inside && existsSync(target) && statSync(target).isFile() ? target : undefined;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** The note a [[wikilink]] names: the first <folder>/<name>.md in the Sub-Sub folder, as Obsidian would find it. */
|
|
26
|
+
export function wikilinkTarget(name: string, root: string): string | undefined {
|
|
27
|
+
const file = name.endsWith(".md") ? name : `${name}.md`;
|
|
28
|
+
return ["", ...NOTE_DIRS, SETTINGS_DIR].map((d) => join(root, d, file)).find((f) => existsSync(f));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** The notes in the Sub-Sub folders, newest first (dot folders such as Research/.plans left out). */
|
|
32
|
+
export function listNotes(root: string, max = 300): Array<{ path: string; rel: string; modified: number }> {
|
|
33
|
+
const out: Array<{ path: string; rel: string; modified: number }> = [];
|
|
34
|
+
for (const d of [...NOTE_DIRS, SETTINGS_DIR]) {
|
|
35
|
+
let names: string[] = [];
|
|
36
|
+
try {
|
|
37
|
+
names = readdirSync(join(root, d), { recursive: true }) as string[];
|
|
38
|
+
} catch {
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
for (const n of names) {
|
|
42
|
+
if (!n.endsWith(".md") || n.split(/[\\/]/).some((x) => x.startsWith("."))) continue;
|
|
43
|
+
const path = join(root, d, n);
|
|
44
|
+
out.push({ path, rel: join(d, n), modified: statSync(path).mtimeMs });
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return out.sort((a, b) => b.modified - a.modified).slice(0, max);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Open a URL or a file with the system's default program. */
|
|
51
|
+
export function openExternal(target: string): void {
|
|
52
|
+
const opts = { detached: true, stdio: "ignore" as const, windowsHide: true };
|
|
53
|
+
let child: ChildProcess;
|
|
54
|
+
if (process.platform === "darwin") child = spawn("open", [target], opts);
|
|
55
|
+
// rundll32 hands the target to the default program without cmd.exe parsing it.
|
|
56
|
+
else if (process.platform === "win32") child = spawn("rundll32", ["url.dll,FileProtocolHandler", target], opts);
|
|
57
|
+
else child = spawn("xdg-open", [target], opts);
|
|
58
|
+
child.on("error", () => {});
|
|
59
|
+
child.unref();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Open a note or document: in Obsidian when it is in an Obsidian vault, otherwise with the system's default program. */
|
|
63
|
+
export function openFile(target: string, vault?: string): void {
|
|
64
|
+
if (vault && within(target, vault) && obsidianRoot(vault)) openExternal(`obsidian://open?path=${encodeURIComponent(target)}`);
|
|
65
|
+
else openExternal(target);
|
|
66
|
+
}
|