@tiagojct/subsub 0.20.0 → 0.21.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +13 -0
- package/README.md +4 -1
- package/dist/chrome.js +171 -0
- package/dist/config.js +2 -0
- package/dist/dialogs.js +81 -0
- package/dist/files.js +66 -0
- package/dist/look.js +36 -17
- package/dist/screens.js +314 -0
- package/dist/skin.js +37 -0
- package/dist/subsub.js +189 -29
- package/dist/web.js +7 -58
- package/dist/words.js +26 -0
- package/package.json +1 -1
- package/scripts/make-themes.mjs +112 -0
- package/scripts/plate.json +35 -0
- package/src/chrome.ts +208 -0
- package/src/config.ts +4 -0
- package/src/dialogs.ts +107 -0
- package/src/files.ts +66 -0
- package/src/look.ts +45 -18
- package/src/screens.ts +374 -0
- package/src/skin.ts +65 -0
- package/src/subsub.ts +206 -34
- package/src/web.ts +6 -51
- package/src/words.ts +40 -0
- package/themes/subsub-glauca-dark-256.json +80 -0
- package/themes/subsub-glauca-dark.json +73 -77
- package/themes/subsub-glauca-light-256.json +80 -0
- package/themes/subsub-glauca-light.json +73 -77
- package/themes/subsub-try-works-dark-256.json +80 -0
- package/themes/subsub-try-works-dark.json +73 -77
- package/themes/subsub-try-works-light-256.json +80 -0
- package/themes/subsub-try-works-light.json +73 -77
- package/scripts/make-themes.py +0 -116
package/src/subsub.ts
CHANGED
|
@@ -15,9 +15,9 @@
|
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
17
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
18
|
-
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
18
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
19
19
|
import { homedir } from "node:os";
|
|
20
|
-
import { dirname, join } from "node:path";
|
|
20
|
+
import { dirname, extname, join } from "node:path";
|
|
21
21
|
import { fileURLToPath } from "node:url";
|
|
22
22
|
import { Bridge, type ServerSpec } from "./bridge.ts";
|
|
23
23
|
import {
|
|
@@ -43,7 +43,13 @@ import { applyEdits, checkLiteratureNote, field, frontMatter, itemKeyOf, missing
|
|
|
43
43
|
import { buildPolicy, isSecret, judgePath, normalizeToolPath, realish, within } from "./paths.ts";
|
|
44
44
|
import { describeArgs, formatPreview, isEmptyPreview } from "./preview.ts";
|
|
45
45
|
import { perMinuteWait, providerTrouble, TROUBLE_TEXT, TROUBLE_TEXT_PT } from "./keys.ts";
|
|
46
|
-
import { headerLines, type LibraryState, paintPreview, QUOTES, type Scheme, schemeFromAnsi, themeName } from "./look.ts";
|
|
46
|
+
import { headerLines, LIBRARIAN_THEME, type LibraryState, paintPreview, QUOTES, type Scheme, schemeFromAnsi, themeName } from "./look.ts";
|
|
47
|
+
import { footerLines, toolRenderers, usageTotals } from "./chrome.ts";
|
|
48
|
+
import { ApprovalView } from "./dialogs.ts";
|
|
49
|
+
import { allowedFile, listNotes, OPENABLE, openFile } from "./files.ts";
|
|
50
|
+
import { LibraryView, NotesView, type LibraryItem } from "./screens.ts";
|
|
51
|
+
import { glyphs, paintFromTheme, workingFrames } from "./skin.ts";
|
|
52
|
+
import { loadWords, toolWord, type Words } from "./words.ts";
|
|
47
53
|
import { systemAddition } from "./prompt.ts";
|
|
48
54
|
import { logUsage } from "./usage.ts";
|
|
49
55
|
import { gateKind, SERVER_PREFIXES, toolsFor, unavailableReason, outboundProblem } from "./roles.ts";
|
|
@@ -153,6 +159,14 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
|
|
|
153
159
|
let scheme: Scheme | undefined;
|
|
154
160
|
let modelId = "no model";
|
|
155
161
|
let headerTui: { requestRender(force?: boolean): void } | undefined;
|
|
162
|
+
let footerTui: { requestRender(force?: boolean): void } | undefined;
|
|
163
|
+
/** True once the session started with the look on; the tool renderers are asked before a context exists. */
|
|
164
|
+
let lookActive = false;
|
|
165
|
+
let words: Words | undefined;
|
|
166
|
+
const redraw = () => {
|
|
167
|
+
headerTui?.requestRender();
|
|
168
|
+
footerTui?.requestRender();
|
|
169
|
+
};
|
|
156
170
|
const library: LibraryState = { zotero: "checking" };
|
|
157
171
|
const quote = cfg.quotes === false ? undefined : QUOTES[Math.floor(Math.random() * QUOTES.length)];
|
|
158
172
|
const lookOn = (ctx: ExtensionContext) => standalone && cfg.look !== false && ctx.mode === "tui";
|
|
@@ -190,21 +204,123 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
|
|
|
190
204
|
function applyTheme(ctx: ExtensionContext): void {
|
|
191
205
|
if (!lookOn(ctx) || cfg.themes === false) return;
|
|
192
206
|
scheme ??= schemeFromAnsi(ctx.ui.theme.getFgAnsi("text"), ctx.ui.theme.name);
|
|
193
|
-
|
|
207
|
+
// The Librarian has its own plate (madder); a theme named in the settings applies to both modes unless themes.librarian says otherwise.
|
|
208
|
+
const base = mode === "librarian" ? (cfg.themesLibrarian ?? cfg.themes ?? LIBRARIAN_THEME) : cfg.themes || undefined;
|
|
209
|
+
const name = themeName(scheme, base, ctx.ui.theme.getColorMode?.());
|
|
194
210
|
const theme = name ? ctx.ui.getTheme(name) : undefined;
|
|
195
211
|
// A Theme object (not a name) changes the theme for this run only; the saved setting stays.
|
|
196
212
|
if (theme) ctx.ui.setTheme(theme);
|
|
213
|
+
// The working indicator takes the accent of the mode.
|
|
214
|
+
const mark = paintFromTheme(ctx.ui.theme).mark;
|
|
215
|
+
ctx.ui.setWorkingIndicator?.({ frames: workingFrames(glyphs(), Boolean(process.env.SUBSUB_REDUCE_MOTION && process.env.SUBSUB_REDUCE_MOTION !== "0")).map(mark), intervalMs: 160 });
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* 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,
|
|
220
|
+
* it is pi's confirm dialog. Only a yes counts: anything else, including an overlay that cannot open, is a no.
|
|
221
|
+
*/
|
|
222
|
+
async function approve(ctx: ExtensionContext, title: string, body: string): Promise<boolean> {
|
|
223
|
+
if (lookOn(ctx) && typeof ctx.ui.custom === "function") {
|
|
224
|
+
const answer = await ctx.ui.custom<boolean>(
|
|
225
|
+
(tui, theme, _keys, done) =>
|
|
226
|
+
new ApprovalView({ title, body, lang, paint: paintFromTheme(theme), glyphs: glyphs(), done, rows: () => tui.terminal?.rows ?? 24, redraw: () => tui.requestRender() }),
|
|
227
|
+
{ overlay: true, overlayOptions: { width: "92%", minWidth: 40, maxHeight: "92%", anchor: "center", margin: 1 } },
|
|
228
|
+
);
|
|
229
|
+
return answer === true;
|
|
230
|
+
}
|
|
231
|
+
return (await ctx.ui.confirm(title, body)) === true;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** The notes and library screens cover the terminal: the conversation is behind them and comes back when they close. */
|
|
235
|
+
const OVERLAY = { overlay: true, overlayOptions: { width: "100%", maxHeight: "100%", anchor: "top-left", margin: 0 } } as const;
|
|
236
|
+
|
|
237
|
+
/** True when the terminal look can show a screen of its own; otherwise says where the same thing is. */
|
|
238
|
+
function screenOk(ctx: ExtensionContext): boolean {
|
|
239
|
+
if (lookOn(ctx) && typeof ctx.ui.custom === "function") return true;
|
|
240
|
+
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");
|
|
241
|
+
return false;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** /notes: the notes in the Sub-Sub folder, newest first; the note under the cursor shows beside the list. */
|
|
245
|
+
async function openNotes(ctx: ExtensionContext): Promise<void> {
|
|
246
|
+
if (!screenOk(ctx)) return;
|
|
247
|
+
const root = cfg.vault ?? ctx.cwd;
|
|
248
|
+
const notes = listNotes(root);
|
|
249
|
+
const { Markdown } = await import("@earendil-works/pi-tui");
|
|
250
|
+
const { getMarkdownTheme } = await import("@earendil-works/pi-coding-agent");
|
|
251
|
+
await ctx.ui.custom<void>(
|
|
252
|
+
(tui, theme, _keys, done) =>
|
|
253
|
+
new NotesView({
|
|
254
|
+
notes,
|
|
255
|
+
read: (path) => {
|
|
256
|
+
const file = allowedFile(path, root, cfg.vault);
|
|
257
|
+
return file && statSync(file).size < 2_000_000 ? readFileSync(file, "utf8") : "";
|
|
258
|
+
},
|
|
259
|
+
markdown: (text, width) => new Markdown(text, 1, 0, getMarkdownTheme()).render(width),
|
|
260
|
+
open: (path) => {
|
|
261
|
+
const file = allowedFile(path, root, cfg.vault);
|
|
262
|
+
if (file && OPENABLE.has(extname(file).toLowerCase())) openFile(file, cfg.vault);
|
|
263
|
+
},
|
|
264
|
+
done: () => done(),
|
|
265
|
+
lang,
|
|
266
|
+
paint: paintFromTheme(theme),
|
|
267
|
+
glyphs: glyphs(),
|
|
268
|
+
rows: () => tui.terminal?.rows ?? 24,
|
|
269
|
+
redraw: () => tui.requestRender(),
|
|
270
|
+
}),
|
|
271
|
+
OVERLAY,
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** /library: search as you type; Enter puts /read <item> in the box (the Librarian gets the key), ctrl+n puts /lit-note <item>. */
|
|
276
|
+
async function openLibrary(ctx: ExtensionContext): Promise<void> {
|
|
277
|
+
if (!screenOk(ctx)) return;
|
|
278
|
+
const ref = (it: LibraryItem) => it.citekey || it.key;
|
|
279
|
+
await ctx.ui.custom<void>(
|
|
280
|
+
(tui, theme, _keys, done) =>
|
|
281
|
+
new LibraryView({
|
|
282
|
+
search: async (query) => {
|
|
283
|
+
const r = await current().call("zotero_find_items", { query, limit: 25 });
|
|
284
|
+
if (r.isError) throw new Error(r.text);
|
|
285
|
+
return ((r.data as { items?: LibraryItem[] })?.items ?? []).filter((it) => it && it.key);
|
|
286
|
+
},
|
|
287
|
+
choose: (it) => {
|
|
288
|
+
ctx.ui.setEditorText(mode === "researcher" ? `/read ${ref(it)}` : ref(it));
|
|
289
|
+
done();
|
|
290
|
+
},
|
|
291
|
+
note: (it) => {
|
|
292
|
+
ctx.ui.setEditorText(`/lit-note ${ref(it)}`);
|
|
293
|
+
done();
|
|
294
|
+
},
|
|
295
|
+
done: () => done(),
|
|
296
|
+
lang,
|
|
297
|
+
paint: paintFromTheme(theme),
|
|
298
|
+
glyphs: glyphs(),
|
|
299
|
+
researcher: mode === "researcher",
|
|
300
|
+
rows: () => tui.terminal?.rows ?? 24,
|
|
301
|
+
redraw: () => tui.requestRender(),
|
|
302
|
+
}),
|
|
303
|
+
OVERLAY,
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** /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. */
|
|
308
|
+
async function openActions(ctx: ExtensionContext): Promise<void> {
|
|
309
|
+
const w = words ?? (await loadWords());
|
|
310
|
+
const list = [...(mode === "researcher" ? w.ACTIONS.research : []), ...w.ACTIONS.library].filter((a) => a.needs !== "starbuck" || starbuckEnabled(cfg));
|
|
311
|
+
const labels = list.map((a) => `${a[lang]} /${a.cmd}`);
|
|
312
|
+
const pick = await ctx.ui.select(L("Actions", "Ações"), labels);
|
|
313
|
+
const a = list[labels.indexOf(pick ?? "")];
|
|
314
|
+
if (a) ctx.ui.setEditorText(a.cmd.endsWith(" ") ? `/${a.cmd}` : `/${a.cmd} `);
|
|
197
315
|
}
|
|
198
316
|
|
|
199
317
|
function paintFor(ctx: ExtensionContext) {
|
|
200
318
|
return (text: string) =>
|
|
201
319
|
ctx.mode === "tui"
|
|
202
|
-
?
|
|
203
|
-
|
|
204
|
-
(
|
|
205
|
-
|
|
206
|
-
(x) => ctx.ui.theme.fg("text", x),
|
|
207
|
-
)
|
|
320
|
+
? (() => {
|
|
321
|
+
const p = paintFromTheme(ctx.ui.theme);
|
|
322
|
+
return paintPreview(text, p.ok, p.strike, p.text);
|
|
323
|
+
})()
|
|
208
324
|
: text;
|
|
209
325
|
}
|
|
210
326
|
|
|
@@ -222,7 +338,7 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
|
|
|
222
338
|
} catch {
|
|
223
339
|
library.zotero = "down";
|
|
224
340
|
}
|
|
225
|
-
|
|
341
|
+
redraw();
|
|
226
342
|
emitWeb();
|
|
227
343
|
}
|
|
228
344
|
|
|
@@ -241,13 +357,9 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
|
|
|
241
357
|
library,
|
|
242
358
|
quote,
|
|
243
359
|
width,
|
|
244
|
-
paint:
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
muted: (x) => th.fg("muted", x),
|
|
248
|
-
dim: (x) => th.fg("dim", x),
|
|
249
|
-
warn: (x) => th.fg("warning", x),
|
|
250
|
-
},
|
|
360
|
+
paint: paintFromTheme(th),
|
|
361
|
+
lang,
|
|
362
|
+
glyphs: glyphs(),
|
|
251
363
|
});
|
|
252
364
|
},
|
|
253
365
|
invalidate() {},
|
|
@@ -255,12 +367,39 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
|
|
|
255
367
|
});
|
|
256
368
|
}
|
|
257
369
|
|
|
370
|
+
function setFooter(ctx: ExtensionContext): void {
|
|
371
|
+
if (!lookOn(ctx)) return;
|
|
372
|
+
ctx.ui.setFooter((tui) => {
|
|
373
|
+
footerTui = tui;
|
|
374
|
+
return {
|
|
375
|
+
render: (width: number) => {
|
|
376
|
+
try {
|
|
377
|
+
const usage = ctx.getContextUsage();
|
|
378
|
+
const totals = usageTotals(ctx.sessionManager.getEntries());
|
|
379
|
+
return footerLines(
|
|
380
|
+
{ mode, lang, library, model: modelId, percent: usage?.percent ?? null, window: usage?.contextWindow, tokens: totals.tokens, cost: totals.cost, down: bridge ? Object.keys(bridge.errors) : [] },
|
|
381
|
+
width,
|
|
382
|
+
paintFromTheme(ctx.ui.theme),
|
|
383
|
+
glyphs(),
|
|
384
|
+
);
|
|
385
|
+
} catch {
|
|
386
|
+
return []; // the context is gone after shutdown
|
|
387
|
+
}
|
|
388
|
+
},
|
|
389
|
+
invalidate() {},
|
|
390
|
+
dispose() {
|
|
391
|
+
footerTui = undefined;
|
|
392
|
+
},
|
|
393
|
+
};
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
|
|
258
397
|
function status(ctx: ExtensionContext): void {
|
|
259
398
|
modelId = ctx.model ? `${ctx.model.id}` : "no model";
|
|
260
399
|
const down = bridge ? Object.keys(bridge.errors) : ["zotero", "scholar"];
|
|
261
400
|
ctx.ui.setStatus("subsub", `Sub-Sub: ${mode} | ${modelId}${down.length ? ` | not running: ${down.join(", ")}` : ""}`);
|
|
262
401
|
if (standalone) ctx.ui.setTitle(`Sub-Sub: ${mode}`);
|
|
263
|
-
|
|
402
|
+
redraw();
|
|
264
403
|
webCtx = ctx;
|
|
265
404
|
emitWeb();
|
|
266
405
|
}
|
|
@@ -296,6 +435,7 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
|
|
|
296
435
|
}
|
|
297
436
|
}
|
|
298
437
|
if (remember) pi.appendEntry(MODE_ENTRY, { mode });
|
|
438
|
+
applyTheme(ctx);
|
|
299
439
|
status(ctx);
|
|
300
440
|
}
|
|
301
441
|
|
|
@@ -320,11 +460,13 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
|
|
|
320
460
|
const modelChosen = hasModel && hasMessages;
|
|
321
461
|
// Judge light or dark before Sub-Sub changes the theme.
|
|
322
462
|
if (lookOn(ctx)) scheme ??= schemeFromAnsi(ctx.ui.theme.getFgAnsi("text"), ctx.ui.theme.name);
|
|
323
|
-
|
|
463
|
+
lookActive = lookOn(ctx);
|
|
464
|
+
if (lookActive) words = await loadWords().catch(() => undefined);
|
|
324
465
|
await applyMode(ctx, restored ?? (pi.getFlag("librarian") ? "librarian" : pi.getFlag("researcher") ? "researcher" : cfg.defaultMode), false, !modelChosen);
|
|
325
466
|
logUsage(cfg.usageLog, { ev: "session", mode, model: ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : undefined });
|
|
326
467
|
if (lookOn(ctx)) {
|
|
327
468
|
setHeader(ctx);
|
|
469
|
+
setFooter(ctx);
|
|
328
470
|
void refreshLibrary();
|
|
329
471
|
} else if (webView) {
|
|
330
472
|
void refreshLibrary();
|
|
@@ -358,6 +500,16 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
|
|
|
358
500
|
});
|
|
359
501
|
|
|
360
502
|
pi.on("model_select", async (_event, ctx) => status(ctx));
|
|
503
|
+
// Plain-word tool lines for Sub-Sub's own tools (the look only); pi's tools keep pi's rows.
|
|
504
|
+
const rows = toolRenderers({ word: (name) => (words ? toolWord(words, lang, name) : name), lang, glyphs, paint: paintFromTheme });
|
|
505
|
+
pi.registerToolRenderer?.((toolName, next) => (lookActive && SERVER_PREFIXES.some((p) => toolName.startsWith(p)) ? (rows(toolName) as never) : next()));
|
|
506
|
+
pi.on("tool_execution_start", async (event, ctx) => {
|
|
507
|
+
if (lookActive && words && !event.parentToolCallId) ctx.ui.setWorkingMessage?.(`${toolWord(words, lang, event.toolName)}...`);
|
|
508
|
+
});
|
|
509
|
+
pi.on("tool_execution_end", async (_event, ctx) => {
|
|
510
|
+
if (lookActive) ctx.ui.setWorkingMessage?.();
|
|
511
|
+
});
|
|
512
|
+
pi.on("turn_end", async () => redraw());
|
|
361
513
|
// A provider error in plain words (quota, busy, blocked, key); the web view words its own.
|
|
362
514
|
pi.on("message_end", async (event, ctx) => {
|
|
363
515
|
const m = event.message as { role?: string; stopReason?: string; errorMessage?: string };
|
|
@@ -466,7 +618,7 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
|
|
|
466
618
|
const verdict = judgePath(target, buildPolicy({ vault: cfg.vault, cwd: ctx.cwd, serverDir: cfg.serverDir, packageDir: PACKAGE_DIR }));
|
|
467
619
|
if (verdict.ok) return undefined;
|
|
468
620
|
if (!ctx.hasUI) return { block: true, reason: `Writing ${target} needs ${user}'s approval: ${verdict.why}.` };
|
|
469
|
-
const ok = await ctx
|
|
621
|
+
const ok = await approve(ctx, `Sub-Sub: ${name} ${target}?`, L(`Asking because ${verdict.why}.`, `Pergunto porque: ${verdict.why}.`));
|
|
470
622
|
return ok ? undefined : { block: true, reason: `${user} did not allow writing to that file.` };
|
|
471
623
|
}
|
|
472
624
|
if (!ctx.hasUI) {
|
|
@@ -478,7 +630,7 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
|
|
|
478
630
|
const verdict = judgePath(normalizeToolPath(input.output_path, ctx.cwd), buildPolicy({ vault: cfg.vault, cwd: ctx.cwd, serverDir: cfg.serverDir, packageDir: PACKAGE_DIR }));
|
|
479
631
|
if (!verdict.ok && /protected|hidden/.test(verdict.why)) return { block: true, reason: `Sub-Sub does not write there: ${verdict.why}.` };
|
|
480
632
|
}
|
|
481
|
-
const ok = await ctx
|
|
633
|
+
const ok = await approve(ctx, L(`Sub-Sub: run ${name}?`, `Sub-Sub: executar ${name}?`), describeArgs(input));
|
|
482
634
|
return ok ? undefined : { block: true, reason: `${user} did not approve. Ask what to change.` };
|
|
483
635
|
}
|
|
484
636
|
// kind === "preview": the server computes the change first, then the user decides.
|
|
@@ -502,7 +654,7 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
|
|
|
502
654
|
// The dialog may stay open for minutes. Before applying, compute the preview again: if the
|
|
503
655
|
// library changed meanwhile (an edit in Zotero, new items), show the new preview instead.
|
|
504
656
|
for (let round = 0; round < 3; round++) {
|
|
505
|
-
const ok = await ctx
|
|
657
|
+
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));
|
|
506
658
|
logUsage(cfg.usageLog, { ev: "change", tool: name, approved: ok });
|
|
507
659
|
if (!ok) return { block: true, reason: `${user} did not approve this change. Ask what to change; do not retry the same call.` };
|
|
508
660
|
let again;
|
|
@@ -598,7 +750,7 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
|
|
|
598
750
|
return { block: true, reason: `Model test: Starbuck reports only inside the vault or ${benchOut}.` };
|
|
599
751
|
}
|
|
600
752
|
if (!ctx.hasUI) return { block: true, reason: `Writing a Starbuck report to ${dir} needs ${user}'s approval: ${verdict.why}.` };
|
|
601
|
-
const ok = await ctx
|
|
753
|
+
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}.`));
|
|
602
754
|
return ok ? undefined : { block: true, reason: `${user} did not allow writing the report there. Ask where to put it (report_dir).` };
|
|
603
755
|
}
|
|
604
756
|
|
|
@@ -634,22 +786,37 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
|
|
|
634
786
|
};
|
|
635
787
|
}
|
|
636
788
|
|
|
789
|
+
const switchTo = async (ctx: ExtensionContext, next: Mode): Promise<void> => {
|
|
790
|
+
await applyMode(ctx, next, true);
|
|
791
|
+
ctx.ui.notify(
|
|
792
|
+
next === "librarian"
|
|
793
|
+
? L("Sub-Sub: Librarian mode. For searches and notes, switch to /researcher.", "Sub-Sub: modo Bibliotecário. Para pesquisas e notas, mude para /researcher.")
|
|
794
|
+
: 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."),
|
|
795
|
+
"info",
|
|
796
|
+
);
|
|
797
|
+
};
|
|
798
|
+
|
|
637
799
|
pi.registerCommand("librarian", {
|
|
638
800
|
description: "Sub-Sub: switch to the Librarian (manages the library: tags, imports, metadata, PDFs)",
|
|
639
|
-
handler: async (_args, ctx) =>
|
|
640
|
-
await applyMode(ctx, "librarian", true);
|
|
641
|
-
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");
|
|
642
|
-
},
|
|
801
|
+
handler: async (_args, ctx) => switchTo(ctx, "librarian"),
|
|
643
802
|
});
|
|
644
803
|
|
|
645
804
|
pi.registerCommand("researcher", {
|
|
646
805
|
description: "Sub-Sub: switch to the Researcher (searches, notes, reviews, and everything the Librarian does)",
|
|
647
|
-
handler: async (_args, ctx) =>
|
|
648
|
-
await applyMode(ctx, "researcher", true);
|
|
649
|
-
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");
|
|
650
|
-
},
|
|
806
|
+
handler: async (_args, ctx) => switchTo(ctx, "researcher"),
|
|
651
807
|
});
|
|
652
808
|
|
|
809
|
+
// Screens of the terminal look (the web view has its own panels, so these are only for the `subsub` command).
|
|
810
|
+
if (standalone) {
|
|
811
|
+
pi.registerCommand("notes", { description: "Sub-Sub: the notes in your Sub-Sub folder", handler: async (_args, ctx) => openNotes(ctx) });
|
|
812
|
+
pi.registerCommand("library", { description: "Sub-Sub: search your Zotero library as you type", handler: async (_args, ctx) => openLibrary(ctx) });
|
|
813
|
+
pi.registerCommand("actions", { description: "Sub-Sub: the actions of the web view, as a list", handler: async (_args, ctx) => openActions(ctx) });
|
|
814
|
+
pi.registerShortcut?.("alt+n", { description: "Sub-Sub: notes", handler: (ctx) => openNotes(ctx) });
|
|
815
|
+
pi.registerShortcut?.("alt+l", { description: "Sub-Sub: library search", handler: (ctx) => openLibrary(ctx) });
|
|
816
|
+
pi.registerShortcut?.("alt+a", { description: "Sub-Sub: actions", handler: (ctx) => openActions(ctx) });
|
|
817
|
+
pi.registerShortcut?.("alt+m", { description: "Sub-Sub: switch between the Researcher and the Librarian", handler: (ctx) => switchTo(ctx, mode === "researcher" ? "librarian" : "researcher") });
|
|
818
|
+
}
|
|
819
|
+
|
|
653
820
|
pi.registerCommand("subsub", {
|
|
654
821
|
description: "Sub-Sub: Zotero connection and library overview",
|
|
655
822
|
handler: async (_args, ctx) => {
|
|
@@ -675,7 +842,7 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
|
|
|
675
842
|
);
|
|
676
843
|
}
|
|
677
844
|
}
|
|
678
|
-
|
|
845
|
+
redraw();
|
|
679
846
|
emitWeb();
|
|
680
847
|
ctx.ui.notify(lines.join("\n"), "info");
|
|
681
848
|
},
|
|
@@ -734,6 +901,11 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
|
|
|
734
901
|
// A registered command runs before the input hook, so /read checks the mode itself.
|
|
735
902
|
pi.registerCommand("read", {
|
|
736
903
|
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>",
|
|
904
|
+
getArgumentCompletions: (prefix: string) => {
|
|
905
|
+
if (/\s/.test(prefix.trim())) return null;
|
|
906
|
+
const found = Object.keys(PURPOSES).filter((k) => k.startsWith(prefix.trim().toLowerCase()));
|
|
907
|
+
return found.length ? found.map((k) => ({ value: k, label: L(PURPOSES[k as Purpose][0], PURPOSES[k as Purpose][1]) })) : null;
|
|
908
|
+
},
|
|
737
909
|
handler: async (args, ctx) => {
|
|
738
910
|
if (mode === "librarian") return ctx.ui.notify(researcherOnly("read"), "warning");
|
|
739
911
|
let { purpose, ref } = readArgs(String(args ?? ""));
|
|
@@ -855,7 +1027,7 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
|
|
|
855
1027
|
ctx.ui.notify(preview.text, "warning");
|
|
856
1028
|
return;
|
|
857
1029
|
}
|
|
858
|
-
const ok = await ctx
|
|
1030
|
+
const ok = await approve(ctx, L("Sub-Sub: undo?", "Sub-Sub: anular?"), paintFor(ctx)(formatPreview("zotero_undo", preview.data, lang)));
|
|
859
1031
|
if (!ok) return;
|
|
860
1032
|
const res = await current().call("zotero_undo", { ...input, dry_run: false });
|
|
861
1033
|
const d = (res.data ?? {}) as Record<string, unknown>;
|
package/src/web.ts
CHANGED
|
@@ -63,6 +63,11 @@ export { uiLanguage } from "./config.ts";
|
|
|
63
63
|
|
|
64
64
|
export { KEY_PROVIDERS, saveApiKey } from "./keys.ts";
|
|
65
65
|
|
|
66
|
+
// ---------------------------------------------------------------- files (shared with the terminal screens)
|
|
67
|
+
|
|
68
|
+
import { allowedFile, listNotes, openExternal, openFile, OPENABLE, wikilinkTarget } from "./files.ts";
|
|
69
|
+
export { allowedFile, listNotes, openExternal, OPENABLE, wikilinkTarget };
|
|
70
|
+
|
|
66
71
|
// ---------------------------------------------------------------- pi in RPC mode
|
|
67
72
|
|
|
68
73
|
type Listener = (ev: any) => void;
|
|
@@ -206,43 +211,6 @@ const NOTE_TYPES = new Set([".md", ".markdown"]);
|
|
|
206
211
|
/** Images that a note shows (a figure cut out of a PDF, for example). */
|
|
207
212
|
const IMAGE_TYPES: Record<string, string> = { ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif", ".webp": "image/webp" };
|
|
208
213
|
|
|
209
|
-
/** Files that the Open button may hand to the system (never programs or scripts). */
|
|
210
|
-
export const OPENABLE = new Set([".md", ".markdown", ".txt", ".pdf", ".bib", ".csv", ".qmd"]);
|
|
211
|
-
|
|
212
|
-
/** A file inside the working folder or the Sub-Sub folder, judged by its real path, so a link cannot point outside. */
|
|
213
|
-
export function allowedFile(raw: string, cwd: string, vault?: string): string | undefined {
|
|
214
|
-
// expand() resolves against the process folder: use it for "~" only.
|
|
215
|
-
const target = resolve(cwd, raw.startsWith("~") ? expand(raw) : raw);
|
|
216
|
-
const real = realish(target);
|
|
217
|
-
const inside = within(real, realish(cwd)) || Boolean(vault && within(real, realish(vault)));
|
|
218
|
-
return inside && existsSync(target) && statSync(target).isFile() ? target : undefined;
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
/** The note a [[wikilink]] names: the first <folder>/<name>.md in the Sub-Sub folder, as Obsidian would find it. */
|
|
222
|
-
export function wikilinkTarget(name: string, root: string): string | undefined {
|
|
223
|
-
const file = name.endsWith(".md") ? name : `${name}.md`;
|
|
224
|
-
return ["", ...NOTE_DIRS, SETTINGS_DIR].map((d) => join(root, d, file)).find((f) => existsSync(f));
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
/** The notes in the Sub-Sub folders, newest first (dot folders such as Research/.plans left out). */
|
|
228
|
-
export function listNotes(root: string, max = 300): Array<{ path: string; rel: string; modified: number }> {
|
|
229
|
-
const out: Array<{ path: string; rel: string; modified: number }> = [];
|
|
230
|
-
for (const d of [...NOTE_DIRS, SETTINGS_DIR]) {
|
|
231
|
-
let names: string[] = [];
|
|
232
|
-
try {
|
|
233
|
-
names = readdirSync(join(root, d), { recursive: true }) as string[];
|
|
234
|
-
} catch {
|
|
235
|
-
continue;
|
|
236
|
-
}
|
|
237
|
-
for (const n of names) {
|
|
238
|
-
if (!n.endsWith(".md") || n.split(/[\\/]/).some((x) => x.startsWith("."))) continue;
|
|
239
|
-
const path = join(root, d, n);
|
|
240
|
-
out.push({ path, rel: join(d, n), modified: statSync(path).mtimeMs });
|
|
241
|
-
}
|
|
242
|
-
}
|
|
243
|
-
return out.sort((a, b) => b.modified - a.modified).slice(0, max);
|
|
244
|
-
}
|
|
245
|
-
|
|
246
214
|
const CSP = [
|
|
247
215
|
"default-src 'none'",
|
|
248
216
|
"script-src 'self'",
|
|
@@ -310,18 +278,6 @@ function alive(pid: number): boolean {
|
|
|
310
278
|
}
|
|
311
279
|
}
|
|
312
280
|
|
|
313
|
-
/** Open a URL or a file with the system's default program. */
|
|
314
|
-
export function openExternal(target: string): void {
|
|
315
|
-
const opts = { detached: true, stdio: "ignore" as const, windowsHide: true };
|
|
316
|
-
let child: ChildProcess;
|
|
317
|
-
if (process.platform === "darwin") child = spawn("open", [target], opts);
|
|
318
|
-
// rundll32 hands the target to the default program without cmd.exe parsing it.
|
|
319
|
-
else if (process.platform === "win32") child = spawn("rundll32", ["url.dll,FileProtocolHandler", target], opts);
|
|
320
|
-
else child = spawn("xdg-open", [target], opts);
|
|
321
|
-
child.on("error", () => {});
|
|
322
|
-
child.unref();
|
|
323
|
-
}
|
|
324
|
-
|
|
325
281
|
// ---------------------------------------------------------------- server
|
|
326
282
|
|
|
327
283
|
interface RunningFile {
|
|
@@ -739,8 +695,7 @@ export async function startWeb(opts: WebOptions, env: NodeJS.ProcessEnv = proces
|
|
|
739
695
|
if (!target) return send(res, 404, { error: "not found in the Sub-Sub folder" });
|
|
740
696
|
// Documents only: a file that the model wrote must never be run.
|
|
741
697
|
if (!OPENABLE.has(extname(target).toLowerCase())) return send(res, 403, { error: "Sub-Sub opens only notes and documents (.md, .txt, .pdf, .bib, .csv, .qmd)." });
|
|
742
|
-
|
|
743
|
-
else openExternal(target);
|
|
698
|
+
openFile(target, vault);
|
|
744
699
|
return send(res, 200, { ok: true });
|
|
745
700
|
}
|
|
746
701
|
case "POST /api/quit":
|
package/src/words.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The plain words of the web view (actions, suggestions, names of tools, in English and Portuguese), for the terminal screens.
|
|
3
|
+
* web/i18n.js is a browser module with no imports; it is loaded here as it is, so the two views never differ.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { pathToFileURL } from "node:url";
|
|
8
|
+
import { PACKAGE_DIR } from "./cli.ts";
|
|
9
|
+
|
|
10
|
+
export interface Action {
|
|
11
|
+
cmd: string;
|
|
12
|
+
en: string;
|
|
13
|
+
pt: string;
|
|
14
|
+
/** Send the command at once instead of filling the box. */
|
|
15
|
+
send?: boolean;
|
|
16
|
+
/** The command takes a library item. */
|
|
17
|
+
item?: boolean;
|
|
18
|
+
/** Needs an add-on, for example "starbuck". */
|
|
19
|
+
needs?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface Words {
|
|
23
|
+
ACTIONS: { research: Action[]; library: Action[] };
|
|
24
|
+
SUGGESTIONS: Record<"researcher" | "librarian", Array<{ en: string; pt: string }>>;
|
|
25
|
+
TOOLS: { en: Record<string, string>; pt: Record<string, string> };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
let words: Promise<Words> | undefined;
|
|
29
|
+
|
|
30
|
+
/** A dynamic import: a static one would pull a file outside src/ into the TypeScript build. */
|
|
31
|
+
export function loadWords(): Promise<Words> {
|
|
32
|
+
words ??= import(pathToFileURL(join(PACKAGE_DIR, "web", "i18n.js")).href) as Promise<Words>;
|
|
33
|
+
return words;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** What a tool is called while it runs ("zotero_find_items" is "search the library"); unknown tools keep their name. */
|
|
37
|
+
export function toolWord(w: Words, lang: "en" | "pt", name: string): string {
|
|
38
|
+
const short = name.replace(/^(zotero|scholar)_/, "");
|
|
39
|
+
return w.TOOLS[lang][short] ?? w.TOOLS.en[short] ?? name;
|
|
40
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://raw.githubusercontent.com/earendil-works/pi/main/packages/coding-agent/src/modes/interactive/theme/theme-schema.json",
|
|
3
|
+
"name": "subsub-glauca-dark-256",
|
|
4
|
+
"vars": {
|
|
5
|
+
"ink": 254,
|
|
6
|
+
"muted": 145,
|
|
7
|
+
"faint": 246,
|
|
8
|
+
"prussian": 110,
|
|
9
|
+
"madder": 173,
|
|
10
|
+
"sap": 144,
|
|
11
|
+
"gamboge": 179,
|
|
12
|
+
"mark": 242,
|
|
13
|
+
"rule": 236,
|
|
14
|
+
"accent": 110,
|
|
15
|
+
"wash": 236
|
|
16
|
+
},
|
|
17
|
+
"colors": {
|
|
18
|
+
"text": "ink",
|
|
19
|
+
"userMessageText": "ink",
|
|
20
|
+
"customMessageText": "ink",
|
|
21
|
+
"toolTitle": "ink",
|
|
22
|
+
"mdHeading": "ink",
|
|
23
|
+
"mdCode": "ink",
|
|
24
|
+
"mdCodeBlock": "ink",
|
|
25
|
+
"error": "ink",
|
|
26
|
+
"muted": "muted",
|
|
27
|
+
"toolOutput": "muted",
|
|
28
|
+
"mdQuote": "muted",
|
|
29
|
+
"toolDiffContext": "muted",
|
|
30
|
+
"thinkingText": "muted",
|
|
31
|
+
"toolDiffRemoved": "muted",
|
|
32
|
+
"dim": "faint",
|
|
33
|
+
"mdLinkUrl": "faint",
|
|
34
|
+
"syntaxComment": "faint",
|
|
35
|
+
"border": "mark",
|
|
36
|
+
"mdQuoteBorder": "mark",
|
|
37
|
+
"mdListBullet": "mark",
|
|
38
|
+
"borderMuted": "rule",
|
|
39
|
+
"mdHr": "rule",
|
|
40
|
+
"mdCodeBlockBorder": "rule",
|
|
41
|
+
"scrollbarTrack": "rule",
|
|
42
|
+
"thinkingOff": "rule",
|
|
43
|
+
"scrollbarThumb": "faint",
|
|
44
|
+
"accent": "accent",
|
|
45
|
+
"borderAccent": "accent",
|
|
46
|
+
"bashMode": "accent",
|
|
47
|
+
"customMessageLabel": "accent",
|
|
48
|
+
"thinkingXhigh": "accent",
|
|
49
|
+
"thinkingMax": "accent",
|
|
50
|
+
"mdLink": "prussian",
|
|
51
|
+
"thinkingHigh": "prussian",
|
|
52
|
+
"success": "sap",
|
|
53
|
+
"toolDiffAdded": "sap",
|
|
54
|
+
"warning": "gamboge",
|
|
55
|
+
"selectedBg": "wash",
|
|
56
|
+
"searchMatchBg": "",
|
|
57
|
+
"searchMatchText": "ink",
|
|
58
|
+
"userMessageBg": "",
|
|
59
|
+
"toolSuccessBg": "",
|
|
60
|
+
"customMessageBg": "",
|
|
61
|
+
"toolErrorBg": "",
|
|
62
|
+
"toolPendingBg": "",
|
|
63
|
+
"syntaxKeyword": "prussian",
|
|
64
|
+
"syntaxNumber": "prussian",
|
|
65
|
+
"syntaxFunction": "ink",
|
|
66
|
+
"syntaxVariable": "ink",
|
|
67
|
+
"syntaxType": "ink",
|
|
68
|
+
"syntaxString": "muted",
|
|
69
|
+
"syntaxOperator": "muted",
|
|
70
|
+
"syntaxPunctuation": "faint",
|
|
71
|
+
"thinkingMinimal": "faint",
|
|
72
|
+
"thinkingLow": "mark",
|
|
73
|
+
"thinkingMedium": "muted"
|
|
74
|
+
},
|
|
75
|
+
"export": {
|
|
76
|
+
"pageBg": "#12171c",
|
|
77
|
+
"cardBg": "#192128",
|
|
78
|
+
"infoBg": "#0d1115"
|
|
79
|
+
}
|
|
80
|
+
}
|