@tiagojct/subsub 0.19.0 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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
- const name = themeName(scheme, cfg.themes || undefined);
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
- ? paintPreview(
203
- text,
204
- (x) => ctx.ui.theme.fg("success", x),
205
- (x) => ctx.ui.theme.fg("error", x),
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
- headerTui?.requestRender();
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
- mark: (x) => th.fg("accent", x),
246
- text: (x) => th.fg("text", x),
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
- headerTui?.requestRender();
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
- applyTheme(ctx);
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.ui.confirm(`Sub-Sub: ${name} ${target}?`, L(`Asking because ${verdict.why}.`, `Pergunto porque: ${verdict.why}.`));
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.ui.confirm(L(`Sub-Sub: run ${name}?`, `Sub-Sub: executar ${name}?`), describeArgs(input));
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.ui.confirm(round ? `${title} ${L("(the library changed; this is the new preview)", "(a biblioteca mudou; esta é a nova pré-visualização)")}` : title, paintFor(ctx)(shown));
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.ui.confirm(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}.`));
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
- headerTui?.requestRender();
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.ui.confirm(L("Sub-Sub: undo?", "Sub-Sub: anular?"), paintFor(ctx)(formatPreview("zotero_undo", preview.data, lang)));
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
- if (vault && within(target, vault) && obsidianRoot(vault)) openExternal(`obsidian://open?path=${encodeURIComponent(target)}`);
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
+ }