@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/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/cli.ts
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
|
|
|
19
20
|
import { spawnSync } from "node:child_process";
|
|
@@ -99,6 +100,8 @@ function isEmptyAuth(file: string): boolean {
|
|
|
99
100
|
export function shareAuth(agentDir: string, piAgentDir: string): boolean {
|
|
100
101
|
const mine = join(agentDir, "auth.json");
|
|
101
102
|
const theirs = join(piAgentDir, "auth.json");
|
|
103
|
+
// 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.
|
|
104
|
+
if (real(mine) === real(theirs)) return false;
|
|
102
105
|
if (!existsSync(theirs)) return false;
|
|
103
106
|
try {
|
|
104
107
|
const st = lstatSync(mine);
|
|
@@ -140,6 +143,7 @@ Usage:
|
|
|
140
143
|
subsub Start Sub-Sub in the terminal
|
|
141
144
|
subsub --librarian Start in the Librarian, which only manages the library
|
|
142
145
|
subsub web Open Sub-Sub in the browser
|
|
146
|
+
subsub tui Sub-Sub on the whole terminal: transcript, side panel, footer (--light or --dark for the colours)
|
|
143
147
|
subsub init Set up or change the settings (folder, tag list, models)
|
|
144
148
|
subsub doctor Check the set-up; each line marked FIX says what to do
|
|
145
149
|
subsub usage The pilot's usage log (if turned on): a summary and where the file is
|
|
@@ -182,6 +186,11 @@ export async function run(argv: string[] = process.argv.slice(2)): Promise<void>
|
|
|
182
186
|
process.exitCode = await webMain([...args.slice(1), ...(here ? ["--here"] : [])], env);
|
|
183
187
|
return;
|
|
184
188
|
}
|
|
189
|
+
if (args[0] === "tui") {
|
|
190
|
+
const { runTui } = await import("./tui.ts");
|
|
191
|
+
// The terminal is given back before this returns; exit so that nothing else keeps the process alive.
|
|
192
|
+
process.exit(await runTui([...args.slice(1), ...(here ? ["--here"] : [])], env));
|
|
193
|
+
}
|
|
185
194
|
if (args[0] === "shortcut") {
|
|
186
195
|
const { shortcutMain } = await import("./shortcut.ts");
|
|
187
196
|
process.exitCode = shortcutMain(args.slice(1), env);
|
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
|
+
}
|
package/src/look.ts
CHANGED
|
@@ -5,12 +5,17 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
|
|
8
|
+
import { type Glyphs, UNICODE } from "./skin.ts";
|
|
9
|
+
|
|
8
10
|
export const BANNER = [
|
|
9
11
|
"╔═╗╦ ╦╔╗ ╔═╗╦ ╦╔╗",
|
|
10
12
|
"╚═╗║ ║╠╩╗───╚═╗║ ║╠╩╗",
|
|
11
13
|
"╚═╝╚═╝╚═╝ ╚═╝╚═╝╚═╝",
|
|
12
14
|
];
|
|
13
15
|
|
|
16
|
+
/** The banner for terminals without box-drawing glyphs (SUBSUB_ASCII=1 or a non-UTF-8 locale). */
|
|
17
|
+
export const BANNER_ASCII = ["+-----------+", "| Sub-Sub |", "+-----------+"];
|
|
18
|
+
|
|
14
19
|
/** Short public-domain lines from Moby-Dick and its Extracts. */
|
|
15
20
|
export const QUOTES: Array<[string, string]> = [
|
|
16
21
|
["And God created great whales.", "Genesis, in the Extracts"],
|
|
@@ -26,13 +31,16 @@ export const QUOTES: Array<[string, string]> = [
|
|
|
26
31
|
|
|
27
32
|
/** The terminal theme, without -dark/-light. subsub-try-works is the other one shipped. */
|
|
28
33
|
export const DEFAULT_THEME = "subsub-glauca";
|
|
34
|
+
/** The theme of the Librarian: the same plate with madder as its accent. */
|
|
35
|
+
export const LIBRARIAN_THEME = "subsub-try-works";
|
|
29
36
|
|
|
30
37
|
export type Scheme = "dark" | "light";
|
|
31
38
|
|
|
32
39
|
/** Light or dark terminal, judged from the text colour of the current theme (dark text means a light background). */
|
|
33
40
|
export function schemeFromAnsi(fgAnsi: string, themeName?: string): Scheme {
|
|
34
|
-
|
|
35
|
-
if (
|
|
41
|
+
const base = themeName?.replace(/-256$/, "");
|
|
42
|
+
if (base?.endsWith("-light") || base === "light") return "light";
|
|
43
|
+
if (base?.endsWith("-dark") || base === "dark") return "dark";
|
|
36
44
|
const rgb = fgAnsi.match(/38;2;(\d+);(\d+);(\d+)/);
|
|
37
45
|
if (rgb) {
|
|
38
46
|
const [r, g, b] = rgb.slice(1).map(Number);
|
|
@@ -47,8 +55,9 @@ export function schemeFromAnsi(fgAnsi: string, themeName?: string): Scheme {
|
|
|
47
55
|
return "dark";
|
|
48
56
|
}
|
|
49
57
|
|
|
50
|
-
|
|
51
|
-
|
|
58
|
+
/** The 256-colour variant ("-256") when the terminal has no truecolor: its colours are picked from the xterm palette, not rounded to it. */
|
|
59
|
+
export function themeName(scheme: Scheme, base: string | undefined = DEFAULT_THEME, colorMode?: "truecolor" | "256color"): string | undefined {
|
|
60
|
+
return base ? `${base}-${scheme}${colorMode === "256color" ? "-256" : ""}` : undefined;
|
|
52
61
|
}
|
|
53
62
|
|
|
54
63
|
export interface LibraryState {
|
|
@@ -58,26 +67,39 @@ export interface LibraryState {
|
|
|
58
67
|
error?: string;
|
|
59
68
|
}
|
|
60
69
|
|
|
70
|
+
/** Paint roles. mark is the accent of the mode; ok (sap green) is applied or added, pend (gamboge) is pending or a preview; strike draws a removal. */
|
|
61
71
|
export interface Paint {
|
|
62
72
|
mark(s: string): string;
|
|
63
73
|
text(s: string): string;
|
|
64
74
|
muted(s: string): string;
|
|
65
75
|
dim(s: string): string;
|
|
66
|
-
|
|
76
|
+
ok(s: string): string;
|
|
77
|
+
pend(s: string): string;
|
|
78
|
+
strike(s: string): string;
|
|
67
79
|
}
|
|
68
80
|
|
|
69
|
-
const plain: Paint = { mark: (s) => s, text: (s) => s, muted: (s) => s, dim: (s) => s,
|
|
81
|
+
export const plain: Paint = { mark: (s) => s, text: (s) => s, muted: (s) => s, dim: (s) => s, ok: (s) => s, pend: (s) => s, strike: (s) => s };
|
|
82
|
+
|
|
83
|
+
type Lang = "en" | "pt";
|
|
84
|
+
|
|
85
|
+
/** The words of the library line, as in the web view (web/i18n.js: zoteroChecking, zoteroDown, items, toReview). */
|
|
86
|
+
export const LIBRARY_WORDS = {
|
|
87
|
+
en: { checking: "Zotero: checking...", down: "Zotero is not running. Start it, then type /subsub.", items: "items", toCheck: "to check", prefix: "Zotero" },
|
|
88
|
+
pt: { checking: "Zotero: a verificar...", down: "O Zotero não está aberto. Abra-o e escreva /subsub.", items: "itens", toCheck: "por verificar", prefix: "Zotero" },
|
|
89
|
+
};
|
|
70
90
|
|
|
71
91
|
function cut(s: string, width: number): string {
|
|
72
92
|
return [...s].length > width ? `${[...s].slice(0, Math.max(0, width - 3)).join("")}...` : s;
|
|
73
93
|
}
|
|
74
94
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
if (st.
|
|
80
|
-
|
|
95
|
+
/** The library line. A closed Zotero is not drawn in a warning colour: a hollow mark and the words say it. */
|
|
96
|
+
export function libraryLine(st: LibraryState, lang: Lang = "en", g: Glyphs = UNICODE): { text: string } {
|
|
97
|
+
const w = LIBRARY_WORDS[lang];
|
|
98
|
+
if (st.zotero === "checking") return { text: w.checking };
|
|
99
|
+
if (st.zotero === "down") return { text: `${g.hollow} ${w.down}` };
|
|
100
|
+
const parts = [`${st.items ?? "?"} ${w.items}`];
|
|
101
|
+
if (st.toReview) parts.push(`${st.toReview} ${w.toCheck}`);
|
|
102
|
+
return { text: `${w.prefix}: ${parts.join(", ")}` };
|
|
81
103
|
}
|
|
82
104
|
|
|
83
105
|
/** Header lines. Each line is cut to the width before it is coloured. */
|
|
@@ -89,23 +111,28 @@ export function headerLines(opts: {
|
|
|
89
111
|
quote?: [string, string];
|
|
90
112
|
width: number;
|
|
91
113
|
paint?: Paint;
|
|
114
|
+
lang?: Lang;
|
|
115
|
+
glyphs?: Glyphs;
|
|
92
116
|
}): string[] {
|
|
93
117
|
const p = opts.paint ?? plain;
|
|
118
|
+
const banner = opts.glyphs && opts.glyphs !== UNICODE ? BANNER_ASCII : BANNER;
|
|
94
119
|
const w = Math.max(10, opts.width);
|
|
95
120
|
const modeLabel = opts.mode === "librarian" ? "Librarian" : "Researcher";
|
|
96
|
-
const lib = libraryLine(opts.library);
|
|
97
|
-
if (w <
|
|
98
|
-
return [p.mark(cut(`Sub-Sub ${opts.version}`, w)), p.text(cut(`${modeLabel} | ${opts.model}`, w)),
|
|
121
|
+
const lib = libraryLine(opts.library, opts.lang, opts.glyphs);
|
|
122
|
+
if (w < banner[0].length + 2) {
|
|
123
|
+
return [p.mark(cut(`Sub-Sub ${opts.version}`, w)), p.text(cut(`${modeLabel} | ${opts.model}`, w)), p.muted(cut(lib.text, w))];
|
|
99
124
|
}
|
|
100
|
-
const out = ["", ...
|
|
125
|
+
const out = ["", ...banner.map((l, i) => p.mark(l) + (i === 2 ? p.dim(cut(` ${opts.version}`, w - l.length)) : "")), ""];
|
|
101
126
|
const status = `${modeLabel} ${opts.model}`;
|
|
102
|
-
|
|
127
|
+
// The library line follows the mode and model on the same line when it fits, otherwise on a line of its own.
|
|
128
|
+
if ([...status].length + 5 + [...lib.text].length <= w) out.push(p.text(status) + p.muted(` | ${lib.text}`));
|
|
129
|
+
else out.push(p.text(cut(status, w)), p.muted(cut(lib.text, w)));
|
|
103
130
|
if (opts.quote) {
|
|
104
131
|
const one = `"${opts.quote[0]}" (${opts.quote[1]})`;
|
|
105
132
|
if ([...one].length <= w) out.push(p.dim(one));
|
|
106
133
|
else out.push(p.dim(cut(`"${opts.quote[0]}"`, w)), p.dim(cut(` (${opts.quote[1]})`, w)));
|
|
107
134
|
}
|
|
108
|
-
out.push(p.dim(cut("/researcher /librarian /read /
|
|
135
|
+
out.push(p.dim(cut("/researcher /librarian /read /notes /library /actions | / for all commands", w)), "");
|
|
109
136
|
return out;
|
|
110
137
|
}
|
|
111
138
|
|