@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/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,27 @@
|
|
|
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.22.0 (2026-10-24)
|
|
6
|
+
|
|
7
|
+
- `subsub tui`: Sub-Sub on the whole terminal. It is a full-screen client of pi's RPC mode, like the browser view, with the look of `subsub`: a conversation that scrolls (mouse wheel, PgUp and PgDn), a side panel from 110 columns (mode, library, keys, earlier conversations), an editor that completes commands, and the footer. Alt+N, Alt+L, Alt+A and Alt+M open the notes, the library search and the actions, and switch the mode, as in `subsub`. `/sessions` and `/new` change the conversation, `/model` chooses the model, `/restart` starts pi again in the same conversation, `/quit` leaves. Ctrl+O shows the output of tool rows, Esc stops a running answer, and Ctrl+C twice quits. `--light` or `--dark` set the colours when the terminal does not say (the default is dark), and `--librarian` starts in the Librarian.
|
|
8
|
+
- In `subsub tui`, every question that Sub-Sub asks is answered on screen: the approval screen for a library change (`y` applies it, `n` or Esc leaves it, Enter does nothing, keys in the first half second are ignored), lists and typed answers. Nothing is applied without a yes. pi's own screens (`/login`, `/settings`, `/tree`, `/resume`, `/fork`, `/share`, `/export`) are not in it: use `subsub` or `subsub web` for them.
|
|
9
|
+
- Shortcuts: F2, F3, F4 and F5 open the notes, the library search and the actions, and switch the mode, in `subsub` and in `subsub tui`. Alt+N, Alt+L, Alt+A and Alt+M (0.21.0) need a terminal that sends Alt as Escape plus the letter; macOS Terminal and iTerm2 do not by default (Option types a symbol), so on a Mac they did nothing. On a Mac the F keys need Fn unless you set them as standard function keys in System Settings.
|
|
10
|
+
- Fixed: when pi is started with Sub-Sub's own folder as its folder (the browser view and `subsub tui` do this), an empty `auth.json` could be replaced by a link to itself, and the model then could not read its credentials ("Credential store read failed ... ELOOP"). It is no longer linked.
|
|
11
|
+
- Internal: the code that talks to pi in RPC mode (`Agent`, the library state and search, the command hints) is in `src/rpc.ts`, shared by the browser view and the terminal client. Tests: screen snapshots of the client at 80 and 120 columns, and a test that runs `subsub tui` in a terminal with a fake model and a fake Zotero (macOS and Linux).
|
|
12
|
+
|
|
13
|
+
## 0.21.0 (2026-10-23)
|
|
14
|
+
|
|
15
|
+
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.
|
|
16
|
+
|
|
17
|
+
- 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.
|
|
18
|
+
- 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.
|
|
19
|
+
- 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.
|
|
20
|
+
- 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.
|
|
21
|
+
- 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.
|
|
22
|
+
- `/read` offers the reasons (background, fact, extract, critique) when you press Tab.
|
|
23
|
+
- The library line says "to check" ("por verificar"), as the browser view does; it said "to review".
|
|
24
|
+
- 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).
|
|
25
|
+
|
|
5
26
|
## 0.20.0 (2026-10-23)
|
|
6
27
|
|
|
7
28
|
Changes from a technical evaluation of 0.19.0 and Starbuck 0.2.0 (LLMSSH 2026).
|
package/README.md
CHANGED
|
@@ -80,12 +80,17 @@ Sub-Sub runs its Zotero server, [zotero-local-mcp](https://github.com/tiagojct/z
|
|
|
80
80
|
subsub web # in the browser
|
|
81
81
|
subsub # in the terminal
|
|
82
82
|
subsub --librarian # in the terminal, in the Librarian
|
|
83
|
+
subsub tui # on the whole terminal (full screen)
|
|
83
84
|
subsub -c # continue the last conversation
|
|
84
85
|
subsub --help # all commands
|
|
85
86
|
```
|
|
86
87
|
|
|
87
88
|
In the browser view, connect a model provider with the model button. In the terminal, type `/login`.
|
|
88
89
|
|
|
90
|
+
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; F2, F3, F4 and F5 (mode) are shortcuts; so are Alt+N, Alt+L, Alt+A and Alt+M where your terminal sends Alt (in macOS Terminal and iTerm2, Option types a symbol unless you turn on "Use Option as Meta key", and a Mac needs Fn with the F key unless the F keys are set as standard). 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.
|
|
91
|
+
|
|
92
|
+
`subsub tui` puts Sub-Sub on the whole terminal, as the browser view puts it in the browser: a conversation that scrolls, a side panel from 110 columns, the editor and the footer, with the same colours and screens. Besides the commands above, `/sessions` and `/new` change the conversation, `/model` chooses the model, `/restart` starts pi again in the same conversation, and `/quit` leaves (Ctrl+C twice also quits). `--light` or `--dark` set the colours when your terminal does not say. It does not have pi's own screens (`/login`, `/settings`, `/tree`, `/resume`): use `subsub` or `subsub web` for them.
|
|
93
|
+
|
|
89
94
|
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
95
|
|
|
91
96
|
Commands in Sub-Sub:
|
|
@@ -95,6 +100,8 @@ Commands in Sub-Sub:
|
|
|
95
100
|
| `/researcher`, `/librarian` | Switch the mode |
|
|
96
101
|
| `/subsub` | Zotero connection, mode, model and library counts |
|
|
97
102
|
| `/history`, `/undo [id]` | Recent library changes; undo the last one or one by its id |
|
|
103
|
+
| `/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 |
|
|
104
|
+
| `/sessions`, `/new`, `/model`, `/restart`, `/quit` | In `subsub tui`: earlier conversations; a new one; the model; pi again in the same conversation; leave |
|
|
98
105
|
| `/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
106
|
| `/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
107
|
| `/tag-batch [size]` | Tag items that have no topic, with your tag list |
|
|
@@ -137,7 +144,7 @@ subsub review apply 13-31
|
|
|
137
144
|
| `usageLog` | `true` keeps the pilot's usage log on this computer (`subsub usage`) | off |
|
|
138
145
|
| `serverDir` | A local copy of zotero-local-mcp, for development | the PyPI release |
|
|
139
146
|
| `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` |
|
|
147
|
+
| `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
148
|
|
|
142
149
|
The settings of the Zotero server (Sub-Sub folder, tag list, alerts file, contact email) are in `~/.config/zotero-local-mcp/env`.
|
|
143
150
|
|
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/cli.js
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
* - `subsub init` sets Sub-Sub up; `subsub doctor` checks the setup.
|
|
15
15
|
* - `subsub web` (or `subsub serve`) opens Sub-Sub in the browser;
|
|
16
16
|
* `subsub shortcut` adds a Sub-Sub shortcut that does the same.
|
|
17
|
+
* - `subsub tui` is Sub-Sub on the whole terminal: a full-screen client of pi's RPC mode, like the web view.
|
|
17
18
|
*/
|
|
18
19
|
var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
|
|
19
20
|
if (typeof path === "string" && /^\.\.?\//.test(path)) {
|
|
@@ -106,6 +107,9 @@ function isEmptyAuth(file) {
|
|
|
106
107
|
export function shareAuth(agentDir, piAgentDir) {
|
|
107
108
|
const mine = join(agentDir, "auth.json");
|
|
108
109
|
const theirs = join(piAgentDir, "auth.json");
|
|
110
|
+
// The web view and `subsub tui` start pi with PI_CODING_AGENT_DIR set to Sub-Sub's folder: a link to itself would make auth.json unreadable.
|
|
111
|
+
if (real(mine) === real(theirs))
|
|
112
|
+
return false;
|
|
109
113
|
if (!existsSync(theirs))
|
|
110
114
|
return false;
|
|
111
115
|
try {
|
|
@@ -149,6 +153,7 @@ Usage:
|
|
|
149
153
|
subsub Start Sub-Sub in the terminal
|
|
150
154
|
subsub --librarian Start in the Librarian, which only manages the library
|
|
151
155
|
subsub web Open Sub-Sub in the browser
|
|
156
|
+
subsub tui Sub-Sub on the whole terminal: transcript, side panel, footer (--light or --dark for the colours)
|
|
152
157
|
subsub init Set up or change the settings (folder, tag list, models)
|
|
153
158
|
subsub doctor Check the set-up; each line marked FIX says what to do
|
|
154
159
|
subsub usage The pilot's usage log (if turned on): a summary and where the file is
|
|
@@ -189,6 +194,11 @@ export async function run(argv = process.argv.slice(2)) {
|
|
|
189
194
|
process.exitCode = await webMain([...args.slice(1), ...(here ? ["--here"] : [])], env);
|
|
190
195
|
return;
|
|
191
196
|
}
|
|
197
|
+
if (args[0] === "tui") {
|
|
198
|
+
const { runTui } = await import("./tui.js");
|
|
199
|
+
// The terminal is given back before this returns; exit so that nothing else keeps the process alive.
|
|
200
|
+
process.exit(await runTui([...args.slice(1), ...(here ? ["--here"] : [])], env));
|
|
201
|
+
}
|
|
192
202
|
if (args[0] === "shortcut") {
|
|
193
203
|
const { shortcutMain } = await import("./shortcut.js");
|
|
194
204
|
process.exitCode = shortcutMain(args.slice(1), env);
|
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
|
/**
|