@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
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,30 @@
|
|
|
2
2
|
|
|
3
3
|
Sub-Sub follows the version numbers of npm: a change in the second number (0.13) adds features or changes how you use it; a change in the third (0.13.1) only fixes.
|
|
4
4
|
|
|
5
|
+
## 0.21.0 (2026-10-23)
|
|
6
|
+
|
|
7
|
+
A new look for the terminal. It is on by default in `subsub`; set `"look": false` in the settings to go back to the old screen.
|
|
8
|
+
|
|
9
|
+
- Colours: the terminal themes use the colours of the website (Prussian blue for the Researcher, madder for the Librarian, sap green for what is applied or added, gamboge for a preview). Nothing is red. A failure is ink with a hollow mark and a word, a removal is struck through, and a closed Zotero is a hollow mark and the words "not running". The theme follows the mode, and `themes.librarian` in the settings picks another theme for the Librarian. There are variants for terminals with 256 colours, and a plain look for terminals that cannot draw box glyphs (set `SUBSUB_ASCII=1`, or use a locale that is not UTF-8). `SUBSUB_REDUCE_MOTION=1` stops the working indicator from moving.
|
|
10
|
+
- Footer: the mode, the Zotero items and the items to check, the model, how much of the context is used, the tokens and the cost. It gets shorter on narrow terminals. It replaces pi's own footer, so the working folder and the git branch are not shown.
|
|
11
|
+
- Tool lines: Sub-Sub's tools show in plain words, for example "search the library: asthma", dashed while they run and solid when done. A failure says "could not search the library" and gives the first line of the error. Press Ctrl+O to expand the rows and see the output. The tools of pi itself (read, edit, write) look as before.
|
|
12
|
+
- Approvals: a library change opens a screen with the preview between dashed rules. Press y (s in Portuguese) to apply it, n or Esc to leave the library as it is. Enter does nothing, and keys pressed in the first half second are ignored, so a letter that you typed just before the screen opened cannot answer it. Removals in the preview are struck through, not red. With `look` off, pi's own Yes/No dialog is used.
|
|
13
|
+
- Screens: `/notes` lists the notes in your Sub-Sub folder, newest first, with the chosen note beside the list from 100 columns, and its evidence line (full text, abstract or metadata). `/library` searches your Zotero library as you type; Enter puts `/read <item>` in the box (in the Librarian, the item's key) and Ctrl+N puts `/lit-note <item>`. `/actions` lists the actions of the browser view. The shortcuts are Alt+N, Alt+L and Alt+A, and Alt+M switches the mode. In macOS Terminal, Option acts as Alt only if you turn on "Use Option as Meta key"; the commands work everywhere.
|
|
14
|
+
- `/read` offers the reasons (background, fact, extract, critique) when you press Tab.
|
|
15
|
+
- The library line says "to check" ("por verificar"), as the browser view does; it said "to review".
|
|
16
|
+
- Tests: snapshots of the screens at 40, 80 and 120 columns in English and Portuguese, contrast tests for the colours, and tests that run Sub-Sub in a terminal (macOS and Linux).
|
|
17
|
+
|
|
18
|
+
## 0.20.0 (2026-10-23)
|
|
19
|
+
|
|
20
|
+
Changes from a technical evaluation of 0.19.0 and Starbuck 0.2.0 (LLMSSH 2026).
|
|
21
|
+
|
|
22
|
+
- Searching the library: when you ask what you have on a topic, Sub-Sub first searches with your own words, then with close synonyms and other word forms, and says which words it searched. In the evaluation it missed items whose titles use a synonym or repeat your words. In a test on the 20 evaluation tasks, recall rose from 0.87 to 0.96 and precision fell from 0.98 to 0.91: the wider search also lists some items that are only near the topic. Check the list.
|
|
23
|
+
- Drafts: Sub-Sub now works from quotes. It reads the full text of each item, chooses a quote for each sentence, and writes the sentence so that it says no more than the quote, with one source for each sentence. A table "Quotes for the check" follows the draft with the quote, word for word, and the page or section for each sentence. A sentence without a quote is cut. In the evaluation, 37% to 53% of the factual sentences in drafts were only partly supported or unsupported by the works they cited. In a test on 18 drafts by two language-model raters, the share fell to 12% to 14% with the new rule. A second test on nine drafts of three other tasks, with the same raters, gave 14% and 16% against 35% and 37% for the old rule; one of the three tasks showed no change. The drafts have more and shorter sentences. Read the table as a way to check, not as a guarantee.
|
|
24
|
+
- Reference checks: a Check with "not found by search" is reported as "not confirmed", with the note that books, theses, reports, archives and works in Portuguese are often missing from Crossref and PubMed. Claim verdicts are reported as sentences to check; Sub-Sub does not change the text or the citation because of a verdict.
|
|
25
|
+
- Guide pages (English and Portuguese): the same two notes on reference checks.
|
|
26
|
+
- Website: a Changes page (English; the Portuguese menu links to it) shows this file.
|
|
27
|
+
- Bench: `retrievalScore` scores a retrieval answer by precision and recall against gold items, naming items by citekey or by title.
|
|
28
|
+
|
|
5
29
|
## 0.19.0 (2026-10-16)
|
|
6
30
|
|
|
7
31
|
A new look for the browser view, the website, the tutorial and the workshop, after the engraved plates of 19th-century natural history books.
|
package/README.md
CHANGED
|
@@ -86,6 +86,8 @@ subsub --help # all commands
|
|
|
86
86
|
|
|
87
87
|
In the browser view, connect a model provider with the model button. In the terminal, type `/login`.
|
|
88
88
|
|
|
89
|
+
The terminal has its own look, drawn like the website: a footer with the mode, the library, the model, the context in use and the cost; Sub-Sub's tools in plain words (`search the library: asthma`); and an approval screen for library changes (`y` applies, `n` or Esc leaves the library as it is, Enter does nothing). `/notes`, `/library` and `/actions` open screens for your notes, a library search and the actions of the browser view; Alt+N, Alt+L, Alt+A and Alt+M (mode) are shortcuts. Nothing is red: a failure is ink with a hollow mark and a word, and a removal is struck through. For terminals that cannot draw box glyphs, set `SUBSUB_ASCII=1`; `SUBSUB_REDUCE_MOTION=1` stops the working indicator from moving; `"look": false` in the settings brings back the plain screen.
|
|
90
|
+
|
|
89
91
|
The browser view also has a library search (each result can start `/read` or `/lit-note`), a notes pane that shows the notes of your Sub-Sub folder next to the conversation (note paths and `[[links]]` in replies open there), argument hints for the commands, and rename, delete (to `~/.subsub/trash`) and export for conversations.
|
|
90
92
|
|
|
91
93
|
Commands in Sub-Sub:
|
|
@@ -95,6 +97,7 @@ Commands in Sub-Sub:
|
|
|
95
97
|
| `/researcher`, `/librarian` | Switch the mode |
|
|
96
98
|
| `/subsub` | Zotero connection, mode, model and library counts |
|
|
97
99
|
| `/history`, `/undo [id]` | Recent library changes; undo the last one or one by its id |
|
|
100
|
+
| `/notes`, `/library`, `/actions` | Terminal screens: the notes in your Sub-Sub folder (the chosen note beside the list from 100 columns); a library search as you type (Enter starts `/read`, Ctrl+N `/lit-note`); the actions of the browser view as a list |
|
|
98
101
|
| `/feedback [what happened]` | A draft bug report in `Inbox/`, with versions, `subsub doctor` and the last error, and without your folders, emails or keys. You post it on GitHub; Sub-Sub sends nothing |
|
|
99
102
|
| `/ai-statement [uses]` | A draft statement on the use of AI for a manuscript, with the Sub-Sub version, its DOI and your models |
|
|
100
103
|
| `/tag-batch [size]` | Tag items that have no topic, with your tag list |
|
|
@@ -137,7 +140,7 @@ subsub review apply 13-31
|
|
|
137
140
|
| `usageLog` | `true` keeps the pilot's usage log on this computer (`subsub usage`) | off |
|
|
138
141
|
| `serverDir` | A local copy of zotero-local-mcp, for development | the PyPI release |
|
|
139
142
|
| `starbuckDir` | A local copy of Starbuck, for development | the pinned release |
|
|
140
|
-
| `look`, `quotes`, `themes` | The terminal header, the Moby-Dick quotations, and the terminal colours (`subsub-glauca`, `subsub-try-works`, or `false` for your pi theme) | on, on, `subsub-glauca` |
|
|
143
|
+
| `look`, `quotes`, `themes` | The terminal look (header, footer, approval screen, tool lines), the Moby-Dick quotations, and the terminal colours (`subsub-glauca`, `subsub-try-works`, or `false` for your pi theme). `themes` can be `{"researcher": "...", "librarian": "..."}`: the Librarian uses `subsub-try-works` unless you say otherwise | on, on, `subsub-glauca` |
|
|
141
144
|
|
|
142
145
|
The settings of the Zotero server (Sub-Sub folder, tag list, alerts file, contact email) are in `~/.config/zotero-local-mcp/env`.
|
|
143
146
|
|
package/dist/bench.js
CHANGED
|
@@ -212,6 +212,37 @@ export function prf(pred, gold) {
|
|
|
212
212
|
const fn = gold.size - tp;
|
|
213
213
|
return { tp, fp, fn };
|
|
214
214
|
}
|
|
215
|
+
function plainTitle(t) {
|
|
216
|
+
return t.toLowerCase().replace(/[^\p{L}\p{N}]+/gu, " ").trim();
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* The library items that an answer names. An item is named by its citekey, or by the first 40 characters of its title
|
|
220
|
+
* (at least 15). A duplicate copy (citekey ending in -dup) counts as its original.
|
|
221
|
+
*/
|
|
222
|
+
export function itemsNamed(text, library) {
|
|
223
|
+
const plain = plainTitle(text);
|
|
224
|
+
const found = new Set();
|
|
225
|
+
for (const item of library) {
|
|
226
|
+
const key = item.citekey.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
227
|
+
const title = plainTitle(item.title).slice(0, 40).trim();
|
|
228
|
+
if (new RegExp(`(?<![A-Za-z0-9_])${key}(?![A-Za-z0-9_])`).test(text) || (title.length >= 15 && plain.includes(title))) {
|
|
229
|
+
found.add(item.citekey.replace(/-dup$/, ""));
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
return found;
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* Precision and recall of the items in a retrieval answer against the gold items. Items in `acceptable` are on the topic but
|
|
236
|
+
* outside the gold list: they count as neither hits nor errors. "(copy)" and "-dup" mark a duplicate and count as the original.
|
|
237
|
+
*/
|
|
238
|
+
export function retrievalScore(text, gold, acceptable, library) {
|
|
239
|
+
const original = (k) => k.replace(/\s*\(copy\)\s*$/, "").replace(/-dup$/, "");
|
|
240
|
+
const goldSet = new Set(gold.map(original));
|
|
241
|
+
const skip = new Set(acceptable.map(original));
|
|
242
|
+
const said = new Set([...itemsNamed(text, library)].filter((k) => goldSet.has(k) || !skip.has(k)));
|
|
243
|
+
const { tp, fp, fn } = prf(said, goldSet);
|
|
244
|
+
return { ...f1(tp, fp, fn), tp, fp, fn };
|
|
245
|
+
}
|
|
215
246
|
function f1(tp, fp, fn) {
|
|
216
247
|
const p = tp + fp ? tp / (tp + fp) : 0;
|
|
217
248
|
const r = tp + fn ? tp / (tp + fn) : 0;
|
package/dist/chrome.js
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
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
|
+
import { visibleWidth } from "@earendil-works/pi-tui";
|
|
6
|
+
import { libraryLine, LIBRARY_WORDS, plain } from "./look.js";
|
|
7
|
+
import { UNICODE } from "./skin.js";
|
|
8
|
+
export const CHROME_WORDS = {
|
|
9
|
+
en: { researcher: "Researcher", librarian: "Librarian", of: "of", tokens: "tokens", unknownUsage: "?", failed: "could not", evidence: "evidence", down: "not running", more: "more lines" },
|
|
10
|
+
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" },
|
|
11
|
+
};
|
|
12
|
+
const len = (s) => [...s].length;
|
|
13
|
+
/** Cut to the width, with "..." when something is left out. Run it before the text is painted. */
|
|
14
|
+
export function cut(s, width) {
|
|
15
|
+
return len(s) > width ? `${[...s].slice(0, Math.max(0, width - 3)).join("")}...` : s;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* 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
|
|
19
|
+
* it has, or the text under it shows through at the end of short lines and below the last one.
|
|
20
|
+
*/
|
|
21
|
+
export function fill(lines, width, height = 0) {
|
|
22
|
+
const out = lines.map((l) => l + " ".repeat(Math.max(0, width - visibleWidth(l))));
|
|
23
|
+
while (out.length < height)
|
|
24
|
+
out.push(" ".repeat(width));
|
|
25
|
+
return height ? out.slice(0, height) : out;
|
|
26
|
+
}
|
|
27
|
+
/** A line of one glyph. */
|
|
28
|
+
export function rule(width, kind, paint = (s) => s, g = UNICODE) {
|
|
29
|
+
return paint(g[kind].repeat(Math.max(0, width)));
|
|
30
|
+
}
|
|
31
|
+
/** A context meter: `▰▰▱▱▱ 38%`. null (unknown) is drawn as an empty bar and `?`. */
|
|
32
|
+
export function meter(percent, cells, g = UNICODE) {
|
|
33
|
+
if (percent === null || percent === undefined || Number.isNaN(percent))
|
|
34
|
+
return { bar: g.cellEmpty.repeat(cells), label: "?" };
|
|
35
|
+
const p = Math.min(100, Math.max(0, percent));
|
|
36
|
+
const full = Math.round((p / 100) * cells);
|
|
37
|
+
return { bar: g.cell.repeat(full) + g.cellEmpty.repeat(cells - full), label: `${Math.round(p)}%` };
|
|
38
|
+
}
|
|
39
|
+
export function compactNumber(n) {
|
|
40
|
+
if (n >= 1_000_000)
|
|
41
|
+
return `${(n / 1_000_000).toFixed(1).replace(/\.0$/, "")}M`;
|
|
42
|
+
if (n >= 1000)
|
|
43
|
+
return `${(n / 1000).toFixed(1).replace(/\.0$/, "")}k`;
|
|
44
|
+
return String(n);
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* 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
|
|
48
|
+
* mode, the item count and the meter; below 40 only the mode and the percentage.
|
|
49
|
+
*/
|
|
50
|
+
export function footerLines(st, width, p = plain, g = UNICODE) {
|
|
51
|
+
const w = Math.max(10, width);
|
|
52
|
+
const words = CHROME_WORDS[st.lang];
|
|
53
|
+
const lib = LIBRARY_WORDS[st.lang];
|
|
54
|
+
const mode = st.mode === "librarian" ? words.librarian : words.researcher;
|
|
55
|
+
const m = meter(st.percent, w >= 100 ? 10 : 5, g);
|
|
56
|
+
if (w < 40)
|
|
57
|
+
return [p.mark(mode) + p.muted(cut(` ${m.label}`, Math.max(0, w - len(mode))))];
|
|
58
|
+
const count = st.library.zotero === "reachable" ? `${st.library.items ?? "?"} ${lib.items}` : st.library.zotero === "down" ? `${g.hollow} ${lib.prefix}` : `${lib.prefix}...`;
|
|
59
|
+
if (w < 60) {
|
|
60
|
+
const tail = ` | ${count} | ${m.bar} ${m.label}`;
|
|
61
|
+
return [p.mark(mode) + p.muted(cut(tail, Math.max(0, w - len(mode))))];
|
|
62
|
+
}
|
|
63
|
+
const first = libraryLine(st.library, st.lang, g).text + (st.down?.length ? ` | ${words.down}: ${st.down.join(", ")}` : "");
|
|
64
|
+
const lines = [p.mark(mode) + p.muted(cut(` | ${first}`, Math.max(0, w - len(mode))))];
|
|
65
|
+
const window = st.window ? ` ${words.of} ${compactNumber(st.window)}` : "";
|
|
66
|
+
let second = `${cut(st.model, Math.max(8, Math.floor(w / 3)))} ${m.bar} ${m.label}${m.label === "?" ? "" : window}`;
|
|
67
|
+
if (w >= 100) {
|
|
68
|
+
const tokens = st.tokens === null || st.tokens === undefined ? words.unknownUsage : compactNumber(st.tokens);
|
|
69
|
+
const cost = st.cost === null || st.cost === undefined ? "" : ` $${st.cost.toFixed(st.cost < 1 ? 3 : 2)}`;
|
|
70
|
+
second += ` ${tokens} ${words.tokens}${cost}`;
|
|
71
|
+
}
|
|
72
|
+
lines.push(p.dim(cut(second, w)));
|
|
73
|
+
return lines;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* 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
|
|
77
|
+
* (solid), `○ could not search the library: first line of the error` when it failed (a hollow mark and a word, in ink).
|
|
78
|
+
*/
|
|
79
|
+
export function toolLine(opts, p = plain, g = UNICODE) {
|
|
80
|
+
const w = Math.max(10, opts.width);
|
|
81
|
+
const words = CHROME_WORDS[opts.lang];
|
|
82
|
+
if (opts.state === "failed") {
|
|
83
|
+
const why = opts.error ? `: ${opts.error.split("\n")[0]}` : "";
|
|
84
|
+
return p.text(cut(`${g.hollow} ${words.failed} ${opts.word}${why}`, w));
|
|
85
|
+
}
|
|
86
|
+
const mark = opts.state === "pending" ? p.pend(g.dashed) : p.ok(g.solid);
|
|
87
|
+
const rest = ` ${opts.word}${opts.detail ? `: ${opts.detail}` : ""}`;
|
|
88
|
+
return mark + p.muted(cut(rest, w - 1));
|
|
89
|
+
}
|
|
90
|
+
/** A short detail for a tool call: the first useful argument (a query, a title, a key, a path), cut to the length. */
|
|
91
|
+
export function toolDetail(args, max = 40) {
|
|
92
|
+
if (!args || typeof args !== "object")
|
|
93
|
+
return "";
|
|
94
|
+
const a = args;
|
|
95
|
+
for (const k of ["query", "q", "title", "citekey", "key", "keys", "doi", "identifier", "path", "name", "text"]) {
|
|
96
|
+
const v = a[k];
|
|
97
|
+
if (v === undefined || v === null || v === "")
|
|
98
|
+
continue;
|
|
99
|
+
const s = Array.isArray(v) ? v.filter((x) => typeof x !== "object").join(", ") : String(v);
|
|
100
|
+
if (s)
|
|
101
|
+
return cut(s.replace(/\s+/g, " "), max);
|
|
102
|
+
}
|
|
103
|
+
return "";
|
|
104
|
+
}
|
|
105
|
+
/** What a note was written from, from its `evidence:` field: full text is solid sap, an abstract dashed gamboge, metadata dotted muted. */
|
|
106
|
+
export function evidenceKind(evidence) {
|
|
107
|
+
return /full/i.test(evidence) ? "full" : /abstract/i.test(evidence) ? "abstract" : /metadata|metadados/i.test(evidence) ? "metadata" : "other";
|
|
108
|
+
}
|
|
109
|
+
export function evidenceLine(evidence, lang, width, p = plain, g = UNICODE) {
|
|
110
|
+
const kind = evidenceKind(evidence);
|
|
111
|
+
const label = `${CHROME_WORDS[lang].evidence}: ${evidence}`;
|
|
112
|
+
const lead = kind === "full" ? p.ok(g.solid.repeat(2)) : kind === "abstract" ? p.pend(g.dashed.repeat(2)) : p.muted(g.dotted.repeat(2));
|
|
113
|
+
return `${lead}${p.text(cut(` ${label}`, Math.max(0, width - 2)))}`;
|
|
114
|
+
}
|
|
115
|
+
/** 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). */
|
|
116
|
+
export function usageTotals(entries) {
|
|
117
|
+
let tokens = 0;
|
|
118
|
+
let cost = 0;
|
|
119
|
+
for (const e of entries) {
|
|
120
|
+
if (e.type !== "message" || e.message?.role !== "assistant")
|
|
121
|
+
continue;
|
|
122
|
+
const u = e.message.usage;
|
|
123
|
+
if (!u)
|
|
124
|
+
return { tokens: null, cost: null };
|
|
125
|
+
tokens += u.totalTokens ?? (u.input ?? 0) + (u.output ?? 0) + (u.cacheRead ?? 0) + (u.cacheWrite ?? 0);
|
|
126
|
+
cost += u.cost?.total ?? 0;
|
|
127
|
+
}
|
|
128
|
+
return { tokens, cost };
|
|
129
|
+
}
|
|
130
|
+
const EXPANDED_LINES = 60;
|
|
131
|
+
/**
|
|
132
|
+
* 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
|
|
133
|
+
* 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).
|
|
134
|
+
*/
|
|
135
|
+
export function toolRenderers(opts) {
|
|
136
|
+
const text = (result) => (result.content ?? []).filter((c) => c.type === "text").map((c) => c.text ?? "").join("\n");
|
|
137
|
+
return (toolName) => ({
|
|
138
|
+
renderShell: "self",
|
|
139
|
+
renderCall: (args, theme, context) => ({
|
|
140
|
+
render: (width) => [
|
|
141
|
+
toolLine({
|
|
142
|
+
state: context.isError ? "failed" : context.isPartial === false ? "done" : "pending",
|
|
143
|
+
word: opts.word(toolName),
|
|
144
|
+
detail: toolDetail(context.args ?? args),
|
|
145
|
+
error: typeof context.state?.error === "string" ? context.state.error : undefined,
|
|
146
|
+
lang: opts.lang,
|
|
147
|
+
width,
|
|
148
|
+
}, opts.paint(theme), opts.glyphs()),
|
|
149
|
+
],
|
|
150
|
+
invalidate() { },
|
|
151
|
+
}),
|
|
152
|
+
renderResult: (result, options, theme, context) => {
|
|
153
|
+
const out = text(result);
|
|
154
|
+
if (context.state)
|
|
155
|
+
context.state.error = context.isError ? out.trim().split("\n")[0] : undefined;
|
|
156
|
+
return {
|
|
157
|
+
render: (width) => {
|
|
158
|
+
if (!options.expanded || !out.trim())
|
|
159
|
+
return [];
|
|
160
|
+
const p = opts.paint(theme);
|
|
161
|
+
const lines = out.split("\n");
|
|
162
|
+
const shown = lines.slice(0, EXPANDED_LINES).map((l) => p.muted(cut(` ${l}`, width)));
|
|
163
|
+
if (lines.length > EXPANDED_LINES)
|
|
164
|
+
shown.push(p.dim(` ... ${lines.length - EXPANDED_LINES} ${CHROME_WORDS[opts.lang].more}`));
|
|
165
|
+
return shown;
|
|
166
|
+
},
|
|
167
|
+
invalidate() { },
|
|
168
|
+
};
|
|
169
|
+
},
|
|
170
|
+
});
|
|
171
|
+
}
|
package/dist/config.js
CHANGED
|
@@ -141,6 +141,7 @@ export function loadConfig(env = process.env) {
|
|
|
141
141
|
if (!models[k])
|
|
142
142
|
delete models[k];
|
|
143
143
|
const themes = user.themes === false ? false : typeof user.themes === "string" ? user.themes : user.themes?.researcher;
|
|
144
|
+
const themesLibrarian = user.themes && typeof user.themes === "object" ? user.themes.librarian : undefined;
|
|
144
145
|
return {
|
|
145
146
|
configFile: file,
|
|
146
147
|
userName: typeof user.userName === "string" && user.userName.trim() ? user.userName.trim() : undefined,
|
|
@@ -165,6 +166,7 @@ export function loadConfig(env = process.env) {
|
|
|
165
166
|
look: user.look,
|
|
166
167
|
quotes: user.quotes,
|
|
167
168
|
themes,
|
|
169
|
+
themesLibrarian,
|
|
168
170
|
addons: Array.isArray(user.addons) ? user.addons.filter((a) => typeof a === "string") : [],
|
|
169
171
|
starbuckDir: expand(user.starbuckDir ?? "~/Projects/starbuck", home),
|
|
170
172
|
usageLog: user.usageLog === true,
|
package/dist/dialogs.js
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
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
|
+
import { matchesKey, wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
|
7
|
+
import { cut, fill, rule } from "./chrome.js";
|
|
8
|
+
import { UNICODE } from "./skin.js";
|
|
9
|
+
export const APPROVAL_WORDS = {
|
|
10
|
+
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" },
|
|
11
|
+
pt: { yes: "s aplicar", no: "n deixar como está", esc: "Esc deixar", scroll: "cima e baixo: deslocar", above: "linhas acima", below: "linhas abaixo" },
|
|
12
|
+
};
|
|
13
|
+
/** The same keys as the web view: y, and s in Portuguese. */
|
|
14
|
+
export const YES_KEYS = { en: ["y"], pt: ["s", "y"] };
|
|
15
|
+
/** The dialog as lines: rule, title, preview (scrolled), rule, keys. Every line fits the width. */
|
|
16
|
+
export function approvalLines(o) {
|
|
17
|
+
const g = o.glyphs ?? UNICODE;
|
|
18
|
+
const w = Math.max(20, o.width);
|
|
19
|
+
const words = APPROVAL_WORDS[o.lang];
|
|
20
|
+
const all = wrapTextWithAnsi(o.body, w - 2);
|
|
21
|
+
const rows = Math.max(3, o.bodyRows ?? 14);
|
|
22
|
+
const maxScroll = Math.max(0, all.length - rows);
|
|
23
|
+
const scroll = Math.min(Math.max(0, o.scroll ?? 0), maxScroll);
|
|
24
|
+
const shown = all.slice(scroll, scroll + rows).map((l) => ` ${l}`);
|
|
25
|
+
const below = all.length - scroll - shown.length;
|
|
26
|
+
const lines = [rule(w, "dashed", o.paint.pend, g), o.paint.text(cut(` ${o.title}`, w)), ""];
|
|
27
|
+
if (scroll > 0)
|
|
28
|
+
lines.push(o.paint.dim(cut(` ... ${scroll} ${words.above}`, w)));
|
|
29
|
+
lines.push(...shown);
|
|
30
|
+
if (below > 0)
|
|
31
|
+
lines.push(o.paint.dim(cut(` ... ${below} ${words.below} (${words.scroll})`, w)));
|
|
32
|
+
lines.push("", rule(w, "dashed", o.paint.pend, g));
|
|
33
|
+
lines.push(...wrapTextWithAnsi(` ${words.yes} ${words.no} ${words.esc}`, w).map((l) => o.paint.muted(l)));
|
|
34
|
+
return { lines, total: all.length, maxScroll };
|
|
35
|
+
}
|
|
36
|
+
/** The pi-tui component of the dialog (see ExtensionUIContext.custom). */
|
|
37
|
+
export class ApprovalView {
|
|
38
|
+
scroll = 0;
|
|
39
|
+
answered = false;
|
|
40
|
+
o;
|
|
41
|
+
opened;
|
|
42
|
+
constructor(o) {
|
|
43
|
+
this.o = o;
|
|
44
|
+
this.opened = (o.now ?? Date.now)();
|
|
45
|
+
}
|
|
46
|
+
bodyRows() {
|
|
47
|
+
return Math.max(3, Math.floor(((this.o.rows?.() ?? 24) * 9) / 10) - 8);
|
|
48
|
+
}
|
|
49
|
+
render(width) {
|
|
50
|
+
const view = approvalLines({ ...this.o, width, scroll: this.scroll, bodyRows: this.bodyRows() });
|
|
51
|
+
this.scroll = Math.min(this.scroll, view.maxScroll);
|
|
52
|
+
return fill(view.lines, width);
|
|
53
|
+
}
|
|
54
|
+
invalidate() { }
|
|
55
|
+
answer(yes) {
|
|
56
|
+
if (this.answered)
|
|
57
|
+
return;
|
|
58
|
+
this.answered = true;
|
|
59
|
+
this.o.done(yes);
|
|
60
|
+
}
|
|
61
|
+
handleInput(data) {
|
|
62
|
+
if ((this.o.now ?? Date.now)() - this.opened < (this.o.ignoreMs ?? 500))
|
|
63
|
+
return;
|
|
64
|
+
if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c") || data === "n" || data === "N")
|
|
65
|
+
return this.answer(false);
|
|
66
|
+
if (data.length === 1 && YES_KEYS[this.o.lang].includes(data.toLowerCase()))
|
|
67
|
+
return this.answer(true);
|
|
68
|
+
const page = this.bodyRows() - 1;
|
|
69
|
+
if (matchesKey(data, "down"))
|
|
70
|
+
this.scroll += 1;
|
|
71
|
+
else if (matchesKey(data, "up"))
|
|
72
|
+
this.scroll = Math.max(0, this.scroll - 1);
|
|
73
|
+
else if (matchesKey(data, "pageDown"))
|
|
74
|
+
this.scroll += page;
|
|
75
|
+
else if (matchesKey(data, "pageUp"))
|
|
76
|
+
this.scroll = Math.max(0, this.scroll - page);
|
|
77
|
+
else
|
|
78
|
+
return;
|
|
79
|
+
this.o.redraw?.();
|
|
80
|
+
}
|
|
81
|
+
}
|
package/dist/files.js
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
|
+
import { spawn } from "node:child_process";
|
|
6
|
+
import { existsSync, readdirSync, statSync } from "node:fs";
|
|
7
|
+
import { join, resolve } from "node:path";
|
|
8
|
+
import { expand } from "./config.js";
|
|
9
|
+
import { NOTE_DIRS, obsidianRoot, SETTINGS_DIR } from "./layout.js";
|
|
10
|
+
import { realish, within } from "./paths.js";
|
|
11
|
+
/** Files that the Open button may hand to the system (never programs or scripts). */
|
|
12
|
+
export const OPENABLE = new Set([".md", ".markdown", ".txt", ".pdf", ".bib", ".csv", ".qmd"]);
|
|
13
|
+
/** A file inside the working folder or the Sub-Sub folder, judged by its real path, so a link cannot point outside. */
|
|
14
|
+
export function allowedFile(raw, cwd, vault) {
|
|
15
|
+
// expand() resolves against the process folder: use it for "~" only.
|
|
16
|
+
const target = resolve(cwd, raw.startsWith("~") ? expand(raw) : raw);
|
|
17
|
+
const real = realish(target);
|
|
18
|
+
const inside = within(real, realish(cwd)) || Boolean(vault && within(real, realish(vault)));
|
|
19
|
+
return inside && existsSync(target) && statSync(target).isFile() ? target : undefined;
|
|
20
|
+
}
|
|
21
|
+
/** The note a [[wikilink]] names: the first <folder>/<name>.md in the Sub-Sub folder, as Obsidian would find it. */
|
|
22
|
+
export function wikilinkTarget(name, root) {
|
|
23
|
+
const file = name.endsWith(".md") ? name : `${name}.md`;
|
|
24
|
+
return ["", ...NOTE_DIRS, SETTINGS_DIR].map((d) => join(root, d, file)).find((f) => existsSync(f));
|
|
25
|
+
}
|
|
26
|
+
/** The notes in the Sub-Sub folders, newest first (dot folders such as Research/.plans left out). */
|
|
27
|
+
export function listNotes(root, max = 300) {
|
|
28
|
+
const out = [];
|
|
29
|
+
for (const d of [...NOTE_DIRS, SETTINGS_DIR]) {
|
|
30
|
+
let names = [];
|
|
31
|
+
try {
|
|
32
|
+
names = readdirSync(join(root, d), { recursive: true });
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
for (const n of names) {
|
|
38
|
+
if (!n.endsWith(".md") || n.split(/[\\/]/).some((x) => x.startsWith(".")))
|
|
39
|
+
continue;
|
|
40
|
+
const path = join(root, d, n);
|
|
41
|
+
out.push({ path, rel: join(d, n), modified: statSync(path).mtimeMs });
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return out.sort((a, b) => b.modified - a.modified).slice(0, max);
|
|
45
|
+
}
|
|
46
|
+
/** Open a URL or a file with the system's default program. */
|
|
47
|
+
export function openExternal(target) {
|
|
48
|
+
const opts = { detached: true, stdio: "ignore", windowsHide: true };
|
|
49
|
+
let child;
|
|
50
|
+
if (process.platform === "darwin")
|
|
51
|
+
child = spawn("open", [target], opts);
|
|
52
|
+
// rundll32 hands the target to the default program without cmd.exe parsing it.
|
|
53
|
+
else if (process.platform === "win32")
|
|
54
|
+
child = spawn("rundll32", ["url.dll,FileProtocolHandler", target], opts);
|
|
55
|
+
else
|
|
56
|
+
child = spawn("xdg-open", [target], opts);
|
|
57
|
+
child.on("error", () => { });
|
|
58
|
+
child.unref();
|
|
59
|
+
}
|
|
60
|
+
/** Open a note or document: in Obsidian when it is in an Obsidian vault, otherwise with the system's default program. */
|
|
61
|
+
export function openFile(target, vault) {
|
|
62
|
+
if (vault && within(target, vault) && obsidianRoot(vault))
|
|
63
|
+
openExternal(`obsidian://open?path=${encodeURIComponent(target)}`);
|
|
64
|
+
else
|
|
65
|
+
openExternal(target);
|
|
66
|
+
}
|
package/dist/look.js
CHANGED
|
@@ -3,11 +3,14 @@
|
|
|
3
3
|
* per mode (try-works for the librarian, Glauca for the researcher).
|
|
4
4
|
* Only used when Sub-Sub runs as its own command in the terminal UI.
|
|
5
5
|
*/
|
|
6
|
+
import { UNICODE } from "./skin.js";
|
|
6
7
|
export const BANNER = [
|
|
7
8
|
"╔═╗╦ ╦╔╗ ╔═╗╦ ╦╔╗",
|
|
8
9
|
"╚═╗║ ║╠╩╗───╚═╗║ ║╠╩╗",
|
|
9
10
|
"╚═╝╚═╝╚═╝ ╚═╝╚═╝╚═╝",
|
|
10
11
|
];
|
|
12
|
+
/** The banner for terminals without box-drawing glyphs (SUBSUB_ASCII=1 or a non-UTF-8 locale). */
|
|
13
|
+
export const BANNER_ASCII = ["+-----------+", "| Sub-Sub |", "+-----------+"];
|
|
11
14
|
/** Short public-domain lines from Moby-Dick and its Extracts. */
|
|
12
15
|
export const QUOTES = [
|
|
13
16
|
["And God created great whales.", "Genesis, in the Extracts"],
|
|
@@ -22,11 +25,14 @@ export const QUOTES = [
|
|
|
22
25
|
];
|
|
23
26
|
/** The terminal theme, without -dark/-light. subsub-try-works is the other one shipped. */
|
|
24
27
|
export const DEFAULT_THEME = "subsub-glauca";
|
|
28
|
+
/** The theme of the Librarian: the same plate with madder as its accent. */
|
|
29
|
+
export const LIBRARIAN_THEME = "subsub-try-works";
|
|
25
30
|
/** Light or dark terminal, judged from the text colour of the current theme (dark text means a light background). */
|
|
26
31
|
export function schemeFromAnsi(fgAnsi, themeName) {
|
|
27
|
-
|
|
32
|
+
const base = themeName?.replace(/-256$/, "");
|
|
33
|
+
if (base?.endsWith("-light") || base === "light")
|
|
28
34
|
return "light";
|
|
29
|
-
if (
|
|
35
|
+
if (base?.endsWith("-dark") || base === "dark")
|
|
30
36
|
return "dark";
|
|
31
37
|
const rgb = fgAnsi.match(/38;2;(\d+);(\d+);(\d+)/);
|
|
32
38
|
if (rgb) {
|
|
@@ -42,35 +48,48 @@ export function schemeFromAnsi(fgAnsi, themeName) {
|
|
|
42
48
|
}
|
|
43
49
|
return "dark";
|
|
44
50
|
}
|
|
45
|
-
|
|
46
|
-
|
|
51
|
+
/** The 256-colour variant ("-256") when the terminal has no truecolor: its colours are picked from the xterm palette, not rounded to it. */
|
|
52
|
+
export function themeName(scheme, base = DEFAULT_THEME, colorMode) {
|
|
53
|
+
return base ? `${base}-${scheme}${colorMode === "256color" ? "-256" : ""}` : undefined;
|
|
47
54
|
}
|
|
48
|
-
const plain = { mark: (s) => s, text: (s) => s, muted: (s) => s, dim: (s) => s,
|
|
55
|
+
export const plain = { mark: (s) => s, text: (s) => s, muted: (s) => s, dim: (s) => s, ok: (s) => s, pend: (s) => s, strike: (s) => s };
|
|
56
|
+
/** The words of the library line, as in the web view (web/i18n.js: zoteroChecking, zoteroDown, items, toReview). */
|
|
57
|
+
export const LIBRARY_WORDS = {
|
|
58
|
+
en: { checking: "Zotero: checking...", down: "Zotero is not running. Start it, then type /subsub.", items: "items", toCheck: "to check", prefix: "Zotero" },
|
|
59
|
+
pt: { checking: "Zotero: a verificar...", down: "O Zotero não está aberto. Abra-o e escreva /subsub.", items: "itens", toCheck: "por verificar", prefix: "Zotero" },
|
|
60
|
+
};
|
|
49
61
|
function cut(s, width) {
|
|
50
62
|
return [...s].length > width ? `${[...s].slice(0, Math.max(0, width - 3)).join("")}...` : s;
|
|
51
63
|
}
|
|
52
|
-
|
|
64
|
+
/** The library line. A closed Zotero is not drawn in a warning colour: a hollow mark and the words say it. */
|
|
65
|
+
export function libraryLine(st, lang = "en", g = UNICODE) {
|
|
66
|
+
const w = LIBRARY_WORDS[lang];
|
|
53
67
|
if (st.zotero === "checking")
|
|
54
|
-
return { text:
|
|
68
|
+
return { text: w.checking };
|
|
55
69
|
if (st.zotero === "down")
|
|
56
|
-
return { text:
|
|
57
|
-
const parts = [`${st.items ?? "?"} items`];
|
|
70
|
+
return { text: `${g.hollow} ${w.down}` };
|
|
71
|
+
const parts = [`${st.items ?? "?"} ${w.items}`];
|
|
58
72
|
if (st.toReview)
|
|
59
|
-
parts.push(`${st.toReview}
|
|
60
|
-
return { text:
|
|
73
|
+
parts.push(`${st.toReview} ${w.toCheck}`);
|
|
74
|
+
return { text: `${w.prefix}: ${parts.join(", ")}` };
|
|
61
75
|
}
|
|
62
76
|
/** Header lines. Each line is cut to the width before it is coloured. */
|
|
63
77
|
export function headerLines(opts) {
|
|
64
78
|
const p = opts.paint ?? plain;
|
|
79
|
+
const banner = opts.glyphs && opts.glyphs !== UNICODE ? BANNER_ASCII : BANNER;
|
|
65
80
|
const w = Math.max(10, opts.width);
|
|
66
81
|
const modeLabel = opts.mode === "librarian" ? "Librarian" : "Researcher";
|
|
67
|
-
const lib = libraryLine(opts.library);
|
|
68
|
-
if (w <
|
|
69
|
-
return [p.mark(cut(`Sub-Sub ${opts.version}`, w)), p.text(cut(`${modeLabel} | ${opts.model}`, w)),
|
|
82
|
+
const lib = libraryLine(opts.library, opts.lang, opts.glyphs);
|
|
83
|
+
if (w < banner[0].length + 2) {
|
|
84
|
+
return [p.mark(cut(`Sub-Sub ${opts.version}`, w)), p.text(cut(`${modeLabel} | ${opts.model}`, w)), p.muted(cut(lib.text, w))];
|
|
70
85
|
}
|
|
71
|
-
const out = ["", ...
|
|
86
|
+
const out = ["", ...banner.map((l, i) => p.mark(l) + (i === 2 ? p.dim(cut(` ${opts.version}`, w - l.length)) : "")), ""];
|
|
72
87
|
const status = `${modeLabel} ${opts.model}`;
|
|
73
|
-
|
|
88
|
+
// The library line follows the mode and model on the same line when it fits, otherwise on a line of its own.
|
|
89
|
+
if ([...status].length + 5 + [...lib.text].length <= w)
|
|
90
|
+
out.push(p.text(status) + p.muted(` | ${lib.text}`));
|
|
91
|
+
else
|
|
92
|
+
out.push(p.text(cut(status, w)), p.muted(cut(lib.text, w)));
|
|
74
93
|
if (opts.quote) {
|
|
75
94
|
const one = `"${opts.quote[0]}" (${opts.quote[1]})`;
|
|
76
95
|
if ([...one].length <= w)
|
|
@@ -78,7 +97,7 @@ export function headerLines(opts) {
|
|
|
78
97
|
else
|
|
79
98
|
out.push(p.dim(cut(`"${opts.quote[0]}"`, w)), p.dim(cut(` (${opts.quote[1]})`, w)));
|
|
80
99
|
}
|
|
81
|
-
out.push(p.dim(cut("/researcher /librarian /read /
|
|
100
|
+
out.push(p.dim(cut("/researcher /librarian /read /notes /library /actions | / for all commands", w)), "");
|
|
82
101
|
return out;
|
|
83
102
|
}
|
|
84
103
|
/**
|