@tiagojct/subsub 0.20.0 → 0.22.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +21 -0
- package/README.md +8 -1
- package/dist/chrome.js +171 -0
- package/dist/cli.js +10 -0
- package/dist/config.js +2 -0
- package/dist/dialogs.js +81 -0
- package/dist/files.js +66 -0
- package/dist/look.js +36 -17
- package/dist/rpc.js +185 -0
- package/dist/screens.js +425 -0
- package/dist/skin.js +37 -0
- package/dist/subsub.js +198 -31
- package/dist/tui-view.js +228 -0
- package/dist/tui.js +651 -0
- package/dist/web.js +16 -216
- package/dist/words.js +26 -0
- package/package.json +1 -1
- package/scripts/make-themes.mjs +112 -0
- package/scripts/plate.json +35 -0
- package/src/chrome.ts +208 -0
- package/src/cli.ts +9 -0
- package/src/config.ts +4 -0
- package/src/dialogs.ts +107 -0
- package/src/files.ts +66 -0
- package/src/look.ts +45 -18
- package/src/rpc.ts +198 -0
- package/src/screens.ts +530 -0
- package/src/skin.ts +65 -0
- package/src/subsub.ts +214 -36
- package/src/tui-view.ts +270 -0
- package/src/tui.ts +710 -0
- package/src/web.ts +14 -209
- package/src/words.ts +40 -0
- package/themes/subsub-glauca-dark-256.json +80 -0
- package/themes/subsub-glauca-dark.json +73 -77
- package/themes/subsub-glauca-light-256.json +80 -0
- package/themes/subsub-glauca-light.json +73 -77
- package/themes/subsub-try-works-dark-256.json +80 -0
- package/themes/subsub-try-works-dark.json +73 -77
- package/themes/subsub-try-works-light-256.json +80 -0
- package/themes/subsub-try-works-light.json +73 -77
- package/scripts/make-themes.py +0 -116
package/dist/subsub.js
CHANGED
|
@@ -13,9 +13,9 @@
|
|
|
13
13
|
* The Python servers start in session_start (not in the factory) and stop in
|
|
14
14
|
* session_shutdown. Tools are registered once and use the current bridge.
|
|
15
15
|
*/
|
|
16
|
-
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
16
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
17
17
|
import { homedir } from "node:os";
|
|
18
|
-
import { dirname, join } from "node:path";
|
|
18
|
+
import { dirname, extname, join } from "node:path";
|
|
19
19
|
import { fileURLToPath } from "node:url";
|
|
20
20
|
import { Bridge } from "./bridge.js";
|
|
21
21
|
import { expand, findUv, FREE_MODELS, loadConfig, parseEnvFile, serverCommand, starbuckCommand, starbuckEnabled, uiLanguage, who, } from "./config.js";
|
|
@@ -28,7 +28,13 @@ import { applyEdits, checkLiteratureNote, field, frontMatter, itemKeyOf, missing
|
|
|
28
28
|
import { buildPolicy, isSecret, judgePath, normalizeToolPath, realish, within } from "./paths.js";
|
|
29
29
|
import { describeArgs, formatPreview, isEmptyPreview } from "./preview.js";
|
|
30
30
|
import { perMinuteWait, providerTrouble, TROUBLE_TEXT, TROUBLE_TEXT_PT } from "./keys.js";
|
|
31
|
-
import { headerLines, paintPreview, QUOTES, schemeFromAnsi, themeName } from "./look.js";
|
|
31
|
+
import { headerLines, LIBRARIAN_THEME, paintPreview, QUOTES, schemeFromAnsi, themeName } from "./look.js";
|
|
32
|
+
import { footerLines, toolRenderers, usageTotals } from "./chrome.js";
|
|
33
|
+
import { ApprovalView } from "./dialogs.js";
|
|
34
|
+
import { allowedFile, listNotes, OPENABLE, openFile } from "./files.js";
|
|
35
|
+
import { LibraryView, NotesView } from "./screens.js";
|
|
36
|
+
import { glyphs, paintFromTheme, workingFrames } from "./skin.js";
|
|
37
|
+
import { loadWords, toolWord } from "./words.js";
|
|
32
38
|
import { systemAddition } from "./prompt.js";
|
|
33
39
|
import { logUsage } from "./usage.js";
|
|
34
40
|
import { gateKind, SERVER_PREFIXES, toolsFor, unavailableReason, outboundProblem } from "./roles.js";
|
|
@@ -124,11 +130,21 @@ export async function createSubsub(pi, deps = {}) {
|
|
|
124
130
|
let scheme;
|
|
125
131
|
let modelId = "no model";
|
|
126
132
|
let headerTui;
|
|
133
|
+
let footerTui;
|
|
134
|
+
/** True once the session started with the look on; the tool renderers are asked before a context exists. */
|
|
135
|
+
let lookActive = false;
|
|
136
|
+
let words;
|
|
137
|
+
const redraw = () => {
|
|
138
|
+
headerTui?.requestRender();
|
|
139
|
+
footerTui?.requestRender();
|
|
140
|
+
};
|
|
127
141
|
const library = { zotero: "checking" };
|
|
128
142
|
const quote = cfg.quotes === false ? undefined : QUOTES[Math.floor(Math.random() * QUOTES.length)];
|
|
129
143
|
const lookOn = (ctx) => standalone && cfg.look !== false && ctx.mode === "tui";
|
|
130
144
|
/** True under `subsub web`: pi runs in RPC mode and the web view shows the state that the terminal header shows. */
|
|
131
145
|
const webView = process.env.SUBSUB_WEB === "1";
|
|
146
|
+
/** The state of the library goes to the client as a status text: for the web view and for the full-screen terminal client (subsub tui). */
|
|
147
|
+
const sideChannel = webView || process.env.SUBSUB_TUI === "1";
|
|
132
148
|
let lastError;
|
|
133
149
|
/** The last provider or tool error of the session, for /feedback. */
|
|
134
150
|
let recentError;
|
|
@@ -139,7 +155,7 @@ export async function createSubsub(pi, deps = {}) {
|
|
|
139
155
|
let webCtx;
|
|
140
156
|
function emitWeb() {
|
|
141
157
|
const ctx = webCtx;
|
|
142
|
-
if (!
|
|
158
|
+
if (!sideChannel || !ctx || ctx.mode !== "rpc")
|
|
143
159
|
return;
|
|
144
160
|
const down = bridge ? Object.keys(bridge.errors) : ["zotero", "scholar"];
|
|
145
161
|
const state = {
|
|
@@ -163,15 +179,110 @@ export async function createSubsub(pi, deps = {}) {
|
|
|
163
179
|
if (!lookOn(ctx) || cfg.themes === false)
|
|
164
180
|
return;
|
|
165
181
|
scheme ??= schemeFromAnsi(ctx.ui.theme.getFgAnsi("text"), ctx.ui.theme.name);
|
|
166
|
-
|
|
182
|
+
// The Librarian has its own plate (madder); a theme named in the settings applies to both modes unless themes.librarian says otherwise.
|
|
183
|
+
const base = mode === "librarian" ? (cfg.themesLibrarian ?? cfg.themes ?? LIBRARIAN_THEME) : cfg.themes || undefined;
|
|
184
|
+
const name = themeName(scheme, base, ctx.ui.theme.getColorMode?.());
|
|
167
185
|
const theme = name ? ctx.ui.getTheme(name) : undefined;
|
|
168
186
|
// A Theme object (not a name) changes the theme for this run only; the saved setting stays.
|
|
169
187
|
if (theme)
|
|
170
188
|
ctx.ui.setTheme(theme);
|
|
189
|
+
// The working indicator takes the accent of the mode.
|
|
190
|
+
const mark = paintFromTheme(ctx.ui.theme).mark;
|
|
191
|
+
ctx.ui.setWorkingIndicator?.({ frames: workingFrames(glyphs(), Boolean(process.env.SUBSUB_REDUCE_MOTION && process.env.SUBSUB_REDUCE_MOTION !== "0")).map(mark), intervalMs: 160 });
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* Ask the user to approve a change. With the look on, the preview opens as an overlay with its own keys (see dialogs.ts); otherwise, and over RPC,
|
|
195
|
+
* it is pi's confirm dialog. Only a yes counts: anything else, including an overlay that cannot open, is a no.
|
|
196
|
+
*/
|
|
197
|
+
async function approve(ctx, title, body) {
|
|
198
|
+
if (lookOn(ctx) && typeof ctx.ui.custom === "function") {
|
|
199
|
+
const answer = await ctx.ui.custom((tui, theme, _keys, done) => new ApprovalView({ title, body, lang, paint: paintFromTheme(theme), glyphs: glyphs(), done, rows: () => tui.terminal?.rows ?? 24, redraw: () => tui.requestRender() }), { overlay: true, overlayOptions: { width: "92%", minWidth: 40, maxHeight: "92%", anchor: "center", margin: 1 } });
|
|
200
|
+
return answer === true;
|
|
201
|
+
}
|
|
202
|
+
return (await ctx.ui.confirm(title, body)) === true;
|
|
203
|
+
}
|
|
204
|
+
/** The notes and library screens cover the terminal: the conversation is behind them and comes back when they close. */
|
|
205
|
+
const OVERLAY = { overlay: true, overlayOptions: { width: "100%", maxHeight: "100%", anchor: "top-left", margin: 0 } };
|
|
206
|
+
/** True when the terminal look can show a screen of its own; otherwise says where the same thing is. */
|
|
207
|
+
function screenOk(ctx) {
|
|
208
|
+
if (lookOn(ctx) && typeof ctx.ui.custom === "function")
|
|
209
|
+
return true;
|
|
210
|
+
ctx.ui.notify(L("Sub-Sub: this screen is part of the terminal look. In subsub web, use the Notes button and the library search.", "Sub-Sub: este ecrã faz parte do aspeto do terminal. Em subsub web, use o botão Notas e a pesquisa na biblioteca."), "info");
|
|
211
|
+
return false;
|
|
212
|
+
}
|
|
213
|
+
/** /notes: the notes in the Sub-Sub folder, newest first; the note under the cursor shows beside the list. */
|
|
214
|
+
async function openNotes(ctx) {
|
|
215
|
+
if (!screenOk(ctx))
|
|
216
|
+
return;
|
|
217
|
+
const root = cfg.vault ?? ctx.cwd;
|
|
218
|
+
const notes = listNotes(root);
|
|
219
|
+
const { Markdown } = await import("@earendil-works/pi-tui");
|
|
220
|
+
const { getMarkdownTheme } = await import("@earendil-works/pi-coding-agent");
|
|
221
|
+
await ctx.ui.custom((tui, theme, _keys, done) => new NotesView({
|
|
222
|
+
notes,
|
|
223
|
+
read: (path) => {
|
|
224
|
+
const file = allowedFile(path, root, cfg.vault);
|
|
225
|
+
return file && statSync(file).size < 2_000_000 ? readFileSync(file, "utf8") : "";
|
|
226
|
+
},
|
|
227
|
+
markdown: (text, width) => new Markdown(text, 1, 0, getMarkdownTheme()).render(width),
|
|
228
|
+
open: (path) => {
|
|
229
|
+
const file = allowedFile(path, root, cfg.vault);
|
|
230
|
+
if (file && OPENABLE.has(extname(file).toLowerCase()))
|
|
231
|
+
openFile(file, cfg.vault);
|
|
232
|
+
},
|
|
233
|
+
done: () => done(),
|
|
234
|
+
lang,
|
|
235
|
+
paint: paintFromTheme(theme),
|
|
236
|
+
glyphs: glyphs(),
|
|
237
|
+
rows: () => tui.terminal?.rows ?? 24,
|
|
238
|
+
redraw: () => tui.requestRender(),
|
|
239
|
+
}), OVERLAY);
|
|
240
|
+
}
|
|
241
|
+
/** /library: search as you type; Enter puts /read <item> in the box (the Librarian gets the key), ctrl+n puts /lit-note <item>. */
|
|
242
|
+
async function openLibrary(ctx) {
|
|
243
|
+
if (!screenOk(ctx))
|
|
244
|
+
return;
|
|
245
|
+
const ref = (it) => it.citekey || it.key;
|
|
246
|
+
await ctx.ui.custom((tui, theme, _keys, done) => new LibraryView({
|
|
247
|
+
search: async (query) => {
|
|
248
|
+
const r = await current().call("zotero_find_items", { query, limit: 25 });
|
|
249
|
+
if (r.isError)
|
|
250
|
+
throw new Error(r.text);
|
|
251
|
+
return (r.data?.items ?? []).filter((it) => it && it.key);
|
|
252
|
+
},
|
|
253
|
+
choose: (it) => {
|
|
254
|
+
ctx.ui.setEditorText(mode === "researcher" ? `/read ${ref(it)}` : ref(it));
|
|
255
|
+
done();
|
|
256
|
+
},
|
|
257
|
+
note: (it) => {
|
|
258
|
+
ctx.ui.setEditorText(`/lit-note ${ref(it)}`);
|
|
259
|
+
done();
|
|
260
|
+
},
|
|
261
|
+
done: () => done(),
|
|
262
|
+
lang,
|
|
263
|
+
paint: paintFromTheme(theme),
|
|
264
|
+
glyphs: glyphs(),
|
|
265
|
+
researcher: mode === "researcher",
|
|
266
|
+
rows: () => tui.terminal?.rows ?? 24,
|
|
267
|
+
redraw: () => tui.requestRender(),
|
|
268
|
+
}), OVERLAY);
|
|
269
|
+
}
|
|
270
|
+
/** /actions: the buttons of the web view as a list; the choice goes into the box, so the user adds what is missing and presses Enter. */
|
|
271
|
+
async function openActions(ctx) {
|
|
272
|
+
const w = words ?? (await loadWords());
|
|
273
|
+
const list = [...(mode === "researcher" ? w.ACTIONS.research : []), ...w.ACTIONS.library].filter((a) => a.needs !== "starbuck" || starbuckEnabled(cfg));
|
|
274
|
+
const labels = list.map((a) => `${a[lang]} /${a.cmd}`);
|
|
275
|
+
const pick = await ctx.ui.select(L("Actions", "Ações"), labels);
|
|
276
|
+
const a = list[labels.indexOf(pick ?? "")];
|
|
277
|
+
if (a)
|
|
278
|
+
ctx.ui.setEditorText(a.cmd.endsWith(" ") ? `/${a.cmd}` : `/${a.cmd} `);
|
|
171
279
|
}
|
|
172
280
|
function paintFor(ctx) {
|
|
173
281
|
return (text) => ctx.mode === "tui"
|
|
174
|
-
?
|
|
282
|
+
? (() => {
|
|
283
|
+
const p = paintFromTheme(ctx.ui.theme);
|
|
284
|
+
return paintPreview(text, p.ok, p.strike, p.text);
|
|
285
|
+
})()
|
|
175
286
|
: text;
|
|
176
287
|
}
|
|
177
288
|
async function refreshLibrary() {
|
|
@@ -190,7 +301,7 @@ export async function createSubsub(pi, deps = {}) {
|
|
|
190
301
|
catch {
|
|
191
302
|
library.zotero = "down";
|
|
192
303
|
}
|
|
193
|
-
|
|
304
|
+
redraw();
|
|
194
305
|
emitWeb();
|
|
195
306
|
}
|
|
196
307
|
function setHeader(ctx) {
|
|
@@ -209,26 +320,45 @@ export async function createSubsub(pi, deps = {}) {
|
|
|
209
320
|
library,
|
|
210
321
|
quote,
|
|
211
322
|
width,
|
|
212
|
-
paint:
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
muted: (x) => th.fg("muted", x),
|
|
216
|
-
dim: (x) => th.fg("dim", x),
|
|
217
|
-
warn: (x) => th.fg("warning", x),
|
|
218
|
-
},
|
|
323
|
+
paint: paintFromTheme(th),
|
|
324
|
+
lang,
|
|
325
|
+
glyphs: glyphs(),
|
|
219
326
|
});
|
|
220
327
|
},
|
|
221
328
|
invalidate() { },
|
|
222
329
|
};
|
|
223
330
|
});
|
|
224
331
|
}
|
|
332
|
+
function setFooter(ctx) {
|
|
333
|
+
if (!lookOn(ctx))
|
|
334
|
+
return;
|
|
335
|
+
ctx.ui.setFooter((tui) => {
|
|
336
|
+
footerTui = tui;
|
|
337
|
+
return {
|
|
338
|
+
render: (width) => {
|
|
339
|
+
try {
|
|
340
|
+
const usage = ctx.getContextUsage();
|
|
341
|
+
const totals = usageTotals(ctx.sessionManager.getEntries());
|
|
342
|
+
return footerLines({ mode, lang, library, model: modelId, percent: usage?.percent ?? null, window: usage?.contextWindow, tokens: totals.tokens, cost: totals.cost, down: bridge ? Object.keys(bridge.errors) : [] }, width, paintFromTheme(ctx.ui.theme), glyphs());
|
|
343
|
+
}
|
|
344
|
+
catch {
|
|
345
|
+
return []; // the context is gone after shutdown
|
|
346
|
+
}
|
|
347
|
+
},
|
|
348
|
+
invalidate() { },
|
|
349
|
+
dispose() {
|
|
350
|
+
footerTui = undefined;
|
|
351
|
+
},
|
|
352
|
+
};
|
|
353
|
+
});
|
|
354
|
+
}
|
|
225
355
|
function status(ctx) {
|
|
226
356
|
modelId = ctx.model ? `${ctx.model.id}` : "no model";
|
|
227
357
|
const down = bridge ? Object.keys(bridge.errors) : ["zotero", "scholar"];
|
|
228
358
|
ctx.ui.setStatus("subsub", `Sub-Sub: ${mode} | ${modelId}${down.length ? ` | not running: ${down.join(", ")}` : ""}`);
|
|
229
359
|
if (standalone)
|
|
230
360
|
ctx.ui.setTitle(`Sub-Sub: ${mode}`);
|
|
231
|
-
|
|
361
|
+
redraw();
|
|
232
362
|
webCtx = ctx;
|
|
233
363
|
emitWeb();
|
|
234
364
|
}
|
|
@@ -252,6 +382,7 @@ export async function createSubsub(pi, deps = {}) {
|
|
|
252
382
|
}
|
|
253
383
|
if (remember)
|
|
254
384
|
pi.appendEntry(MODE_ENTRY, { mode });
|
|
385
|
+
applyTheme(ctx);
|
|
255
386
|
status(ctx);
|
|
256
387
|
}
|
|
257
388
|
pi.on("session_start", async (_event, ctx) => {
|
|
@@ -279,14 +410,17 @@ export async function createSubsub(pi, deps = {}) {
|
|
|
279
410
|
// Judge light or dark before Sub-Sub changes the theme.
|
|
280
411
|
if (lookOn(ctx))
|
|
281
412
|
scheme ??= schemeFromAnsi(ctx.ui.theme.getFgAnsi("text"), ctx.ui.theme.name);
|
|
282
|
-
|
|
413
|
+
lookActive = lookOn(ctx);
|
|
414
|
+
if (lookActive)
|
|
415
|
+
words = await loadWords().catch(() => undefined);
|
|
283
416
|
await applyMode(ctx, restored ?? (pi.getFlag("librarian") ? "librarian" : pi.getFlag("researcher") ? "researcher" : cfg.defaultMode), false, !modelChosen);
|
|
284
417
|
logUsage(cfg.usageLog, { ev: "session", mode, model: ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : undefined });
|
|
285
418
|
if (lookOn(ctx)) {
|
|
286
419
|
setHeader(ctx);
|
|
420
|
+
setFooter(ctx);
|
|
287
421
|
void refreshLibrary();
|
|
288
422
|
}
|
|
289
|
-
else if (
|
|
423
|
+
else if (sideChannel) {
|
|
290
424
|
void refreshLibrary();
|
|
291
425
|
}
|
|
292
426
|
if (!ctx.model && !webView)
|
|
@@ -319,6 +453,18 @@ export async function createSubsub(pi, deps = {}) {
|
|
|
319
453
|
await applyMode(ctx, m, false, false);
|
|
320
454
|
});
|
|
321
455
|
pi.on("model_select", async (_event, ctx) => status(ctx));
|
|
456
|
+
// Plain-word tool lines for Sub-Sub's own tools (the look only); pi's tools keep pi's rows.
|
|
457
|
+
const rows = toolRenderers({ word: (name) => (words ? toolWord(words, lang, name) : name), lang, glyphs, paint: paintFromTheme });
|
|
458
|
+
pi.registerToolRenderer?.((toolName, next) => (lookActive && SERVER_PREFIXES.some((p) => toolName.startsWith(p)) ? rows(toolName) : next()));
|
|
459
|
+
pi.on("tool_execution_start", async (event, ctx) => {
|
|
460
|
+
if (lookActive && words && !event.parentToolCallId)
|
|
461
|
+
ctx.ui.setWorkingMessage?.(`${toolWord(words, lang, event.toolName)}...`);
|
|
462
|
+
});
|
|
463
|
+
pi.on("tool_execution_end", async (_event, ctx) => {
|
|
464
|
+
if (lookActive)
|
|
465
|
+
ctx.ui.setWorkingMessage?.();
|
|
466
|
+
});
|
|
467
|
+
pi.on("turn_end", async () => redraw());
|
|
322
468
|
// A provider error in plain words (quota, busy, blocked, key); the web view words its own.
|
|
323
469
|
pi.on("message_end", async (event, ctx) => {
|
|
324
470
|
const m = event.message;
|
|
@@ -433,7 +579,7 @@ export async function createSubsub(pi, deps = {}) {
|
|
|
433
579
|
return undefined;
|
|
434
580
|
if (!ctx.hasUI)
|
|
435
581
|
return { block: true, reason: `Writing ${target} needs ${user}'s approval: ${verdict.why}.` };
|
|
436
|
-
const ok = await ctx
|
|
582
|
+
const ok = await approve(ctx, `Sub-Sub: ${name} ${target}?`, L(`Asking because ${verdict.why}.`, `Pergunto porque: ${verdict.why}.`));
|
|
437
583
|
return ok ? undefined : { block: true, reason: `${user} did not allow writing to that file.` };
|
|
438
584
|
}
|
|
439
585
|
if (!ctx.hasUI) {
|
|
@@ -446,7 +592,7 @@ export async function createSubsub(pi, deps = {}) {
|
|
|
446
592
|
if (!verdict.ok && /protected|hidden/.test(verdict.why))
|
|
447
593
|
return { block: true, reason: `Sub-Sub does not write there: ${verdict.why}.` };
|
|
448
594
|
}
|
|
449
|
-
const ok = await ctx
|
|
595
|
+
const ok = await approve(ctx, L(`Sub-Sub: run ${name}?`, `Sub-Sub: executar ${name}?`), describeArgs(input));
|
|
450
596
|
return ok ? undefined : { block: true, reason: `${user} did not approve. Ask what to change.` };
|
|
451
597
|
}
|
|
452
598
|
// kind === "preview": the server computes the change first, then the user decides.
|
|
@@ -472,7 +618,7 @@ export async function createSubsub(pi, deps = {}) {
|
|
|
472
618
|
// The dialog may stay open for minutes. Before applying, compute the preview again: if the
|
|
473
619
|
// library changed meanwhile (an edit in Zotero, new items), show the new preview instead.
|
|
474
620
|
for (let round = 0; round < 3; round++) {
|
|
475
|
-
const ok = await ctx
|
|
621
|
+
const ok = await approve(ctx, round ? `${title} ${L("(the library changed; this is the new preview)", "(a biblioteca mudou; esta é a nova pré-visualização)")}` : title, paintFor(ctx)(shown));
|
|
476
622
|
logUsage(cfg.usageLog, { ev: "change", tool: name, approved: ok });
|
|
477
623
|
if (!ok)
|
|
478
624
|
return { block: true, reason: `${user} did not approve this change. Ask what to change; do not retry the same call.` };
|
|
@@ -586,7 +732,7 @@ export async function createSubsub(pi, deps = {}) {
|
|
|
586
732
|
}
|
|
587
733
|
if (!ctx.hasUI)
|
|
588
734
|
return { block: true, reason: `Writing a Starbuck report to ${dir} needs ${user}'s approval: ${verdict.why}.` };
|
|
589
|
-
const ok = await ctx
|
|
735
|
+
const ok = await approve(ctx, L(`Sub-Sub: write the Starbuck report to ${dir}?`, `Sub-Sub: escrever o relatório do Starbuck em ${dir}?`), L(`Asking because ${verdict.why}.`, `Pergunto porque: ${verdict.why}.`));
|
|
590
736
|
return ok ? undefined : { block: true, reason: `${user} did not allow writing the report there. Ask where to put it (report_dir).` };
|
|
591
737
|
}
|
|
592
738
|
async function benchGate(name, input, kind, cwd, signal) {
|
|
@@ -621,20 +767,35 @@ export async function createSubsub(pi, deps = {}) {
|
|
|
621
767
|
`for ${user}.${shown}`,
|
|
622
768
|
};
|
|
623
769
|
}
|
|
770
|
+
const switchTo = async (ctx, next) => {
|
|
771
|
+
await applyMode(ctx, next, true);
|
|
772
|
+
ctx.ui.notify(next === "librarian"
|
|
773
|
+
? L("Sub-Sub: Librarian mode. For searches and notes, switch to /researcher.", "Sub-Sub: modo Bibliotecário. Para pesquisas e notas, mude para /researcher.")
|
|
774
|
+
: L("Sub-Sub: Researcher mode. It searches, writes notes and also changes the library, with a preview.", "Sub-Sub: modo Investigador. Pesquisa, escreve notas e também altera a biblioteca, com pré-visualização."), "info");
|
|
775
|
+
};
|
|
624
776
|
pi.registerCommand("librarian", {
|
|
625
777
|
description: "Sub-Sub: switch to the Librarian (manages the library: tags, imports, metadata, PDFs)",
|
|
626
|
-
handler: async (_args, ctx) =>
|
|
627
|
-
await applyMode(ctx, "librarian", true);
|
|
628
|
-
ctx.ui.notify(L("Sub-Sub: Librarian mode. For searches and notes, switch to /researcher.", "Sub-Sub: modo Bibliotecário. Para pesquisas e notas, mude para /researcher."), "info");
|
|
629
|
-
},
|
|
778
|
+
handler: async (_args, ctx) => switchTo(ctx, "librarian"),
|
|
630
779
|
});
|
|
631
780
|
pi.registerCommand("researcher", {
|
|
632
781
|
description: "Sub-Sub: switch to the Researcher (searches, notes, reviews, and everything the Librarian does)",
|
|
633
|
-
handler: async (_args, ctx) =>
|
|
634
|
-
await applyMode(ctx, "researcher", true);
|
|
635
|
-
ctx.ui.notify(L("Sub-Sub: Researcher mode. It searches, writes notes and also changes the library, with a preview.", "Sub-Sub: modo Investigador. Pesquisa, escreve notas e também altera a biblioteca, com pré-visualização."), "info");
|
|
636
|
-
},
|
|
782
|
+
handler: async (_args, ctx) => switchTo(ctx, "researcher"),
|
|
637
783
|
});
|
|
784
|
+
// Screens of the terminal look (the web view has its own panels, so these are only for the `subsub` command).
|
|
785
|
+
if (standalone) {
|
|
786
|
+
pi.registerCommand("notes", { description: "Sub-Sub: the notes in your Sub-Sub folder", handler: async (_args, ctx) => openNotes(ctx) });
|
|
787
|
+
pi.registerCommand("library", { description: "Sub-Sub: search your Zotero library as you type", handler: async (_args, ctx) => openLibrary(ctx) });
|
|
788
|
+
pi.registerCommand("actions", { description: "Sub-Sub: the actions of the web view, as a list", handler: async (_args, ctx) => openActions(ctx) });
|
|
789
|
+
// Alt works when the terminal sends it as Escape plus the letter; macOS Terminal and iTerm2 do not by default (Option types a symbol), so each also has a function key.
|
|
790
|
+
const keys = (names, description, handler) => {
|
|
791
|
+
for (const k of names)
|
|
792
|
+
pi.registerShortcut?.(k, { description, handler });
|
|
793
|
+
};
|
|
794
|
+
keys(["alt+n", "f2"], "Sub-Sub: notes", (ctx) => openNotes(ctx));
|
|
795
|
+
keys(["alt+l", "f3"], "Sub-Sub: library search", (ctx) => openLibrary(ctx));
|
|
796
|
+
keys(["alt+a", "f4"], "Sub-Sub: actions", (ctx) => openActions(ctx));
|
|
797
|
+
keys(["alt+m", "f5"], "Sub-Sub: switch between the Researcher and the Librarian", (ctx) => switchTo(ctx, mode === "researcher" ? "librarian" : "researcher"));
|
|
798
|
+
}
|
|
638
799
|
pi.registerCommand("subsub", {
|
|
639
800
|
description: "Sub-Sub: Zotero connection and library overview",
|
|
640
801
|
handler: async (_args, ctx) => {
|
|
@@ -660,7 +821,7 @@ export async function createSubsub(pi, deps = {}) {
|
|
|
660
821
|
: `Items: ${o.items}. Without tags: ${o.without_manual_tags}. Missing facets: ${JSON.stringify(o.missing_facet)}. Awaiting review (_agent): ${o.awaiting_review}.`);
|
|
661
822
|
}
|
|
662
823
|
}
|
|
663
|
-
|
|
824
|
+
redraw();
|
|
664
825
|
emitWeb();
|
|
665
826
|
ctx.ui.notify(lines.join("\n"), "info");
|
|
666
827
|
},
|
|
@@ -713,6 +874,12 @@ export async function createSubsub(pi, deps = {}) {
|
|
|
713
874
|
// A registered command runs before the input hook, so /read checks the mode itself.
|
|
714
875
|
pi.registerCommand("read", {
|
|
715
876
|
description: "Sub-Sub: read a paper with guidance; Sub-Sub finds the passages and asks, you write the answers. /read [background|fact|extract|critique] <citekey, DOI, PMID or title words>",
|
|
877
|
+
getArgumentCompletions: (prefix) => {
|
|
878
|
+
if (/\s/.test(prefix.trim()))
|
|
879
|
+
return null;
|
|
880
|
+
const found = Object.keys(PURPOSES).filter((k) => k.startsWith(prefix.trim().toLowerCase()));
|
|
881
|
+
return found.length ? found.map((k) => ({ value: k, label: L(PURPOSES[k][0], PURPOSES[k][1]) })) : null;
|
|
882
|
+
},
|
|
716
883
|
handler: async (args, ctx) => {
|
|
717
884
|
if (mode === "librarian")
|
|
718
885
|
return ctx.ui.notify(researcherOnly("read"), "warning");
|
|
@@ -840,7 +1007,7 @@ export async function createSubsub(pi, deps = {}) {
|
|
|
840
1007
|
ctx.ui.notify(preview.text, "warning");
|
|
841
1008
|
return;
|
|
842
1009
|
}
|
|
843
|
-
const ok = await ctx
|
|
1010
|
+
const ok = await approve(ctx, L("Sub-Sub: undo?", "Sub-Sub: anular?"), paintFor(ctx)(formatPreview("zotero_undo", preview.data, lang)));
|
|
844
1011
|
if (!ok)
|
|
845
1012
|
return;
|
|
846
1013
|
const res = await current().call("zotero_undo", { ...input, dry_run: false });
|
package/dist/tui-view.js
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The screen of `subsub tui`, in parts that know nothing about processes: the colours (read from the themes in themes/, since a client of pi's RPC
|
|
3
|
+
* mode has no pi theme), the transcript, the side panel and the footer. The controller in src/tui.ts feeds them.
|
|
4
|
+
*/
|
|
5
|
+
import { readFileSync } from "node:fs";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { getTerminalColorMode, wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
|
8
|
+
import { cut, evidenceLine, rule, toolDetail, toolLine } from "./chrome.js";
|
|
9
|
+
import { headerLines } from "./look.js";
|
|
10
|
+
import { UNICODE } from "./skin.js";
|
|
11
|
+
// ---------------------------------------------------------------- colours
|
|
12
|
+
/** A Sub-Sub theme file, read as a table of foreground colours. */
|
|
13
|
+
export class Palette {
|
|
14
|
+
vars;
|
|
15
|
+
colors;
|
|
16
|
+
mode;
|
|
17
|
+
name;
|
|
18
|
+
constructor(file, mode) {
|
|
19
|
+
this.name = file.name ?? "";
|
|
20
|
+
this.vars = file.vars ?? {};
|
|
21
|
+
this.colors = file.colors;
|
|
22
|
+
this.mode = mode;
|
|
23
|
+
}
|
|
24
|
+
/** The colour of a theme role (accent, text, muted, dim, success, warning, mdLink, ...) as an SGR foreground sequence, or "" for the terminal's own. */
|
|
25
|
+
ansi(role) {
|
|
26
|
+
let v = this.colors[role];
|
|
27
|
+
if (typeof v === "string" && v in this.vars)
|
|
28
|
+
v = this.vars[v];
|
|
29
|
+
if (v === undefined || v === "")
|
|
30
|
+
return "";
|
|
31
|
+
if (typeof v === "number")
|
|
32
|
+
return `\x1b[38;5;${v}m`;
|
|
33
|
+
const [r, g, b] = [1, 3, 5].map((i) => parseInt(v.slice(i, i + 2), 16));
|
|
34
|
+
return this.mode === "truecolor" ? `\x1b[38;2;${r};${g};${b}m` : `\x1b[38;5;${rgbTo256(r, g, b)}m`;
|
|
35
|
+
}
|
|
36
|
+
fg(role, text) {
|
|
37
|
+
const on = this.ansi(role);
|
|
38
|
+
return on ? `${on}${text}\x1b[39m` : text;
|
|
39
|
+
}
|
|
40
|
+
bold = (t) => `\x1b[1m${t}\x1b[22m`;
|
|
41
|
+
italic = (t) => `\x1b[3m${t}\x1b[23m`;
|
|
42
|
+
underline = (t) => `\x1b[4m${t}\x1b[24m`;
|
|
43
|
+
strikethrough = (t) => `\x1b[9m${t}\x1b[29m`;
|
|
44
|
+
/** The paint roles of the screens (see Paint in look.ts). */
|
|
45
|
+
paint() {
|
|
46
|
+
return {
|
|
47
|
+
mark: (x) => this.fg("accent", x),
|
|
48
|
+
text: (x) => this.fg("text", x),
|
|
49
|
+
muted: (x) => this.fg("muted", x),
|
|
50
|
+
dim: (x) => this.fg("dim", x),
|
|
51
|
+
ok: (x) => this.fg("success", x),
|
|
52
|
+
pend: (x) => this.fg("warning", x),
|
|
53
|
+
strike: (x) => this.strikethrough(this.fg("muted", x)),
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
/** For pi-tui's Markdown component. */
|
|
57
|
+
markdownTheme() {
|
|
58
|
+
return {
|
|
59
|
+
heading: (t) => this.bold(this.fg("mdHeading", t)),
|
|
60
|
+
link: (t) => this.underline(this.fg("mdLink", t)),
|
|
61
|
+
linkUrl: (t) => this.fg("mdLinkUrl", t),
|
|
62
|
+
code: (t) => this.fg("mdCode", t),
|
|
63
|
+
codeBlock: (t) => this.fg("mdCodeBlock", t),
|
|
64
|
+
codeBlockBorder: (t) => this.fg("mdCodeBlockBorder", t),
|
|
65
|
+
quote: (t) => this.italic(this.fg("mdQuote", t)),
|
|
66
|
+
quoteBorder: (t) => this.fg("mdQuoteBorder", t),
|
|
67
|
+
hr: (t) => this.fg("mdHr", t),
|
|
68
|
+
listBullet: (t) => this.fg("mdListBullet", t),
|
|
69
|
+
bold: this.bold,
|
|
70
|
+
italic: this.italic,
|
|
71
|
+
strikethrough: this.strikethrough,
|
|
72
|
+
underline: this.underline,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
/** For pi-tui's Editor. */
|
|
76
|
+
editorTheme() {
|
|
77
|
+
return {
|
|
78
|
+
borderColor: (t) => this.fg("borderMuted", t),
|
|
79
|
+
selectList: {
|
|
80
|
+
selectedPrefix: (t) => this.fg("accent", t),
|
|
81
|
+
selectedText: (t) => this.fg("text", t),
|
|
82
|
+
description: (t) => this.fg("muted", t),
|
|
83
|
+
scrollInfo: (t) => this.fg("dim", t),
|
|
84
|
+
noMatch: (t) => this.fg("muted", t),
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
/** The nearest xterm-256 index of an RGB colour (cube or grey ramp). */
|
|
90
|
+
function rgbTo256(r, g, b) {
|
|
91
|
+
const cube = [0, 95, 135, 175, 215, 255];
|
|
92
|
+
const near = (v) => cube.reduce((best, c, i) => (Math.abs(c - v) < Math.abs(cube[best] - v) ? i : best), 0);
|
|
93
|
+
const [ri, gi, bi] = [near(r), near(g), near(b)];
|
|
94
|
+
const grey = Math.round((r + g + b) / 3);
|
|
95
|
+
const greyIndex = Math.min(255, Math.max(232, 232 + Math.round((grey - 8) / 10)));
|
|
96
|
+
const cubeDist = (cube[ri] - r) ** 2 + (cube[gi] - g) ** 2 + (cube[bi] - b) ** 2;
|
|
97
|
+
const greyValue = 8 + (greyIndex - 232) * 10;
|
|
98
|
+
const greyDist = (greyValue - r) ** 2 + (greyValue - g) ** 2 + (greyValue - b) ** 2;
|
|
99
|
+
return greyDist < cubeDist ? greyIndex : 16 + 36 * ri + 6 * gi + bi;
|
|
100
|
+
}
|
|
101
|
+
/** Dark or light terminal: from COLORFGBG ("15;0" is light text on black), else dark. */
|
|
102
|
+
export function schemeFromEnv(env = process.env) {
|
|
103
|
+
const bg = Number((env.COLORFGBG ?? "").split(";").at(-1));
|
|
104
|
+
if (!Number.isFinite(bg) || (env.COLORFGBG ?? "") === "")
|
|
105
|
+
return "dark";
|
|
106
|
+
return bg === 7 || bg >= 9 ? "light" : "dark";
|
|
107
|
+
}
|
|
108
|
+
/** The palette of a theme (glauca for the Researcher, try-works for the Librarian) in the colour mode of the terminal. */
|
|
109
|
+
export function loadPalette(themesDir, base, scheme, mode = getTerminalColorMode() === "256color" ? "256color" : "truecolor") {
|
|
110
|
+
const name = `${base}-${scheme}${mode === "256color" ? "-256" : ""}`;
|
|
111
|
+
return new Palette(JSON.parse(readFileSync(join(themesDir, `${name}.json`), "utf8")), mode);
|
|
112
|
+
}
|
|
113
|
+
/** The text of a message's content: a string, or the text blocks of a list. */
|
|
114
|
+
export function messageText(content) {
|
|
115
|
+
if (typeof content === "string")
|
|
116
|
+
return content;
|
|
117
|
+
if (!Array.isArray(content))
|
|
118
|
+
return "";
|
|
119
|
+
return content.filter((b) => b?.type === "text").map((b) => b.text ?? "").join("\n\n");
|
|
120
|
+
}
|
|
121
|
+
const toolWordOf = (w, lang, name) => {
|
|
122
|
+
const short = name.replace(/^(zotero|scholar)_/, "");
|
|
123
|
+
return w ? (w.TOOLS[lang][short] ?? w.TOOLS.en[short] ?? name) : name;
|
|
124
|
+
};
|
|
125
|
+
/** One entry as lines, without the empty line that separates entries. */
|
|
126
|
+
export function entryLines(e, width, c) {
|
|
127
|
+
const g = c.glyphs ?? UNICODE;
|
|
128
|
+
const p = c.paint;
|
|
129
|
+
const w = Math.max(10, width);
|
|
130
|
+
switch (e.kind) {
|
|
131
|
+
case "user": {
|
|
132
|
+
const lines = wrapTextWithAnsi(e.text, w - 2);
|
|
133
|
+
return lines.map((l, i) => `${i === 0 ? p.mark(g.pointer) : " "} ${p.text(l)}`);
|
|
134
|
+
}
|
|
135
|
+
case "assistant":
|
|
136
|
+
return c.markdown(e.text, w);
|
|
137
|
+
case "tool": {
|
|
138
|
+
const head = toolLine({ state: e.state, word: toolWordOf(c.words, c.lang, e.name), detail: toolDetail(e.args), error: e.error, lang: c.lang, width: w }, p, g);
|
|
139
|
+
if (!c.expanded || !e.output?.trim())
|
|
140
|
+
return [head];
|
|
141
|
+
return [head, ...e.output.split("\n").slice(0, 40).map((l) => p.muted(cut(` ${l}`, w)))];
|
|
142
|
+
}
|
|
143
|
+
case "notice": {
|
|
144
|
+
const text = e.level === "info" ? p.muted(cut(e.text, w)) : p.text(cut(`${g.hollow} ${e.text}`, w));
|
|
145
|
+
return [text];
|
|
146
|
+
}
|
|
147
|
+
case "evidence":
|
|
148
|
+
return [evidenceLine(e.text, c.lang, w, p, g)];
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
/** The whole transcript: the entries one after another, an empty line between them. */
|
|
152
|
+
export function transcriptLines(entries, width, c) {
|
|
153
|
+
const out = [];
|
|
154
|
+
let previous;
|
|
155
|
+
for (const e of entries) {
|
|
156
|
+
const lines = entryLines(e, width, c);
|
|
157
|
+
if (!lines.length)
|
|
158
|
+
continue;
|
|
159
|
+
// Tool rows and notices belong together; every other change of speaker gets an empty line.
|
|
160
|
+
if (out.length && !(previous?.kind === "tool" && e.kind === "tool"))
|
|
161
|
+
out.push("");
|
|
162
|
+
out.push(...lines);
|
|
163
|
+
previous = e;
|
|
164
|
+
}
|
|
165
|
+
return out;
|
|
166
|
+
}
|
|
167
|
+
/** The messages of an earlier conversation (get_messages) as entries. */
|
|
168
|
+
export function entriesFromMessages(messages) {
|
|
169
|
+
const out = [];
|
|
170
|
+
const tools = new Map();
|
|
171
|
+
for (const m of messages) {
|
|
172
|
+
if (m.role === "user") {
|
|
173
|
+
const text = messageText(m.content).trim();
|
|
174
|
+
if (text && !text.startsWith("/subsub-"))
|
|
175
|
+
out.push({ kind: "user", text });
|
|
176
|
+
}
|
|
177
|
+
else if (m.role === "assistant") {
|
|
178
|
+
for (const b of Array.isArray(m.content) ? m.content : []) {
|
|
179
|
+
if (b.type === "text" && b.text?.trim())
|
|
180
|
+
out.push({ kind: "assistant", text: b.text });
|
|
181
|
+
else if (b.type === "toolCall") {
|
|
182
|
+
const t = { kind: "tool", id: b.id, name: b.name, args: b.arguments, state: "done" };
|
|
183
|
+
tools.set(b.id, t);
|
|
184
|
+
out.push(t);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
else if (m.role === "toolResult") {
|
|
189
|
+
const t = tools.get(m.toolCallId);
|
|
190
|
+
if (t) {
|
|
191
|
+
t.output = messageText(m.content);
|
|
192
|
+
if (m.isError) {
|
|
193
|
+
t.state = "failed";
|
|
194
|
+
t.error = t.output.trim().split("\n")[0];
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
return out;
|
|
200
|
+
}
|
|
201
|
+
export const SIDE_WIDTH = 30;
|
|
202
|
+
const SIDE_WORDS = {
|
|
203
|
+
en: { notes: "notes", library: "library", actions: "actions", mode: "mode", conversations: "Conversations", none: "none yet" },
|
|
204
|
+
pt: { notes: "notas", library: "biblioteca", actions: "ações", mode: "modo", conversations: "Conversas", none: "ainda não há" },
|
|
205
|
+
};
|
|
206
|
+
/** The side panel as lines of exactly `width` visible columns are not needed: the layout pads them. */
|
|
207
|
+
export function sideLines(s, width, rows, p, g = UNICODE) {
|
|
208
|
+
const w = Math.max(16, width);
|
|
209
|
+
const words = SIDE_WORDS[s.lang];
|
|
210
|
+
const mode = s.mode === "librarian" ? "Librarian" : "Researcher";
|
|
211
|
+
const lib = s.library.zotero === "reachable" ? `${s.library.items ?? "?"} ${s.lang === "pt" ? "itens" : "items"}` : s.library.zotero === "down" ? `${g.hollow} Zotero` : "Zotero...";
|
|
212
|
+
const lines = [p.mark(cut(` Sub-Sub ${s.version}`, w)), p.text(cut(` ${mode}`, w)), p.muted(cut(` ${lib}`, w)), p.dim(cut(` ${s.model}`, w)), rule(w, "solid", p.muted, g)];
|
|
213
|
+
for (const [key, word] of [["F2", words.notes], ["F3", words.library], ["F4", words.actions], ["F5", words.mode]])
|
|
214
|
+
lines.push(p.muted(cut(` ${key.padEnd(4)}${word}`, w)));
|
|
215
|
+
lines.push(rule(w, "solid", p.muted, g), p.text(cut(` ${words.conversations}`, w)));
|
|
216
|
+
if (!s.conversations.length)
|
|
217
|
+
lines.push(p.dim(cut(` ${words.none}`, w)));
|
|
218
|
+
for (const c of s.conversations) {
|
|
219
|
+
if (lines.length >= rows)
|
|
220
|
+
break;
|
|
221
|
+
lines.push(c.open ? `${p.mark(g.pointer)}${p.text(cut(` ${c.title}`, w - 1))}` : ` ${p.muted(cut(` ${c.title}`, w - 1))}`);
|
|
222
|
+
}
|
|
223
|
+
return lines.slice(0, Math.max(rows, 1));
|
|
224
|
+
}
|
|
225
|
+
/** The top of the transcript at the start: the banner and the quote (the same as the header of `subsub`). */
|
|
226
|
+
export function bannerLines(o) {
|
|
227
|
+
return headerLines({ ...o, quote: o.quote });
|
|
228
|
+
}
|