@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/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,11 +159,21 @@ 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";
159
173
  /** True under `subsub web`: pi runs in RPC mode and the web view shows the state that the terminal header shows. */
160
174
  const webView = process.env.SUBSUB_WEB === "1";
175
+ /** 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). */
176
+ const sideChannel = webView || process.env.SUBSUB_TUI === "1";
161
177
  let lastError: string | undefined;
162
178
  /** The last provider or tool error of the session, for /feedback. */
163
179
  let recentError: string | undefined;
@@ -168,7 +184,7 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
168
184
  let webCtx: ExtensionContext | undefined;
169
185
  function emitWeb(): void {
170
186
  const ctx = webCtx;
171
- if (!webView || !ctx || ctx.mode !== "rpc") return;
187
+ if (!sideChannel || !ctx || ctx.mode !== "rpc") return;
172
188
  const down = bridge ? Object.keys(bridge.errors) : ["zotero", "scholar"];
173
189
  const state = {
174
190
  mode,
@@ -190,21 +206,123 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
190
206
  function applyTheme(ctx: ExtensionContext): void {
191
207
  if (!lookOn(ctx) || cfg.themes === false) return;
192
208
  scheme ??= schemeFromAnsi(ctx.ui.theme.getFgAnsi("text"), ctx.ui.theme.name);
193
- const name = themeName(scheme, cfg.themes || undefined);
209
+ // The Librarian has its own plate (madder); a theme named in the settings applies to both modes unless themes.librarian says otherwise.
210
+ const base = mode === "librarian" ? (cfg.themesLibrarian ?? cfg.themes ?? LIBRARIAN_THEME) : cfg.themes || undefined;
211
+ const name = themeName(scheme, base, ctx.ui.theme.getColorMode?.());
194
212
  const theme = name ? ctx.ui.getTheme(name) : undefined;
195
213
  // A Theme object (not a name) changes the theme for this run only; the saved setting stays.
196
214
  if (theme) ctx.ui.setTheme(theme);
215
+ // The working indicator takes the accent of the mode.
216
+ const mark = paintFromTheme(ctx.ui.theme).mark;
217
+ ctx.ui.setWorkingIndicator?.({ frames: workingFrames(glyphs(), Boolean(process.env.SUBSUB_REDUCE_MOTION && process.env.SUBSUB_REDUCE_MOTION !== "0")).map(mark), intervalMs: 160 });
218
+ }
219
+
220
+ /**
221
+ * 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,
222
+ * it is pi's confirm dialog. Only a yes counts: anything else, including an overlay that cannot open, is a no.
223
+ */
224
+ async function approve(ctx: ExtensionContext, title: string, body: string): Promise<boolean> {
225
+ if (lookOn(ctx) && typeof ctx.ui.custom === "function") {
226
+ const answer = await ctx.ui.custom<boolean>(
227
+ (tui, theme, _keys, done) =>
228
+ new ApprovalView({ title, body, lang, paint: paintFromTheme(theme), glyphs: glyphs(), done, rows: () => tui.terminal?.rows ?? 24, redraw: () => tui.requestRender() }),
229
+ { overlay: true, overlayOptions: { width: "92%", minWidth: 40, maxHeight: "92%", anchor: "center", margin: 1 } },
230
+ );
231
+ return answer === true;
232
+ }
233
+ return (await ctx.ui.confirm(title, body)) === true;
234
+ }
235
+
236
+ /** The notes and library screens cover the terminal: the conversation is behind them and comes back when they close. */
237
+ const OVERLAY = { overlay: true, overlayOptions: { width: "100%", maxHeight: "100%", anchor: "top-left", margin: 0 } } as const;
238
+
239
+ /** True when the terminal look can show a screen of its own; otherwise says where the same thing is. */
240
+ function screenOk(ctx: ExtensionContext): boolean {
241
+ if (lookOn(ctx) && typeof ctx.ui.custom === "function") return true;
242
+ 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");
243
+ return false;
244
+ }
245
+
246
+ /** /notes: the notes in the Sub-Sub folder, newest first; the note under the cursor shows beside the list. */
247
+ async function openNotes(ctx: ExtensionContext): Promise<void> {
248
+ if (!screenOk(ctx)) return;
249
+ const root = cfg.vault ?? ctx.cwd;
250
+ const notes = listNotes(root);
251
+ const { Markdown } = await import("@earendil-works/pi-tui");
252
+ const { getMarkdownTheme } = await import("@earendil-works/pi-coding-agent");
253
+ await ctx.ui.custom<void>(
254
+ (tui, theme, _keys, done) =>
255
+ new NotesView({
256
+ notes,
257
+ read: (path) => {
258
+ const file = allowedFile(path, root, cfg.vault);
259
+ return file && statSync(file).size < 2_000_000 ? readFileSync(file, "utf8") : "";
260
+ },
261
+ markdown: (text, width) => new Markdown(text, 1, 0, getMarkdownTheme()).render(width),
262
+ open: (path) => {
263
+ const file = allowedFile(path, root, cfg.vault);
264
+ if (file && OPENABLE.has(extname(file).toLowerCase())) openFile(file, cfg.vault);
265
+ },
266
+ done: () => done(),
267
+ lang,
268
+ paint: paintFromTheme(theme),
269
+ glyphs: glyphs(),
270
+ rows: () => tui.terminal?.rows ?? 24,
271
+ redraw: () => tui.requestRender(),
272
+ }),
273
+ OVERLAY,
274
+ );
275
+ }
276
+
277
+ /** /library: search as you type; Enter puts /read <item> in the box (the Librarian gets the key), ctrl+n puts /lit-note <item>. */
278
+ async function openLibrary(ctx: ExtensionContext): Promise<void> {
279
+ if (!screenOk(ctx)) return;
280
+ const ref = (it: LibraryItem) => it.citekey || it.key;
281
+ await ctx.ui.custom<void>(
282
+ (tui, theme, _keys, done) =>
283
+ new LibraryView({
284
+ search: async (query) => {
285
+ const r = await current().call("zotero_find_items", { query, limit: 25 });
286
+ if (r.isError) throw new Error(r.text);
287
+ return ((r.data as { items?: LibraryItem[] })?.items ?? []).filter((it) => it && it.key);
288
+ },
289
+ choose: (it) => {
290
+ ctx.ui.setEditorText(mode === "researcher" ? `/read ${ref(it)}` : ref(it));
291
+ done();
292
+ },
293
+ note: (it) => {
294
+ ctx.ui.setEditorText(`/lit-note ${ref(it)}`);
295
+ done();
296
+ },
297
+ done: () => done(),
298
+ lang,
299
+ paint: paintFromTheme(theme),
300
+ glyphs: glyphs(),
301
+ researcher: mode === "researcher",
302
+ rows: () => tui.terminal?.rows ?? 24,
303
+ redraw: () => tui.requestRender(),
304
+ }),
305
+ OVERLAY,
306
+ );
307
+ }
308
+
309
+ /** /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. */
310
+ async function openActions(ctx: ExtensionContext): Promise<void> {
311
+ const w = words ?? (await loadWords());
312
+ const list = [...(mode === "researcher" ? w.ACTIONS.research : []), ...w.ACTIONS.library].filter((a) => a.needs !== "starbuck" || starbuckEnabled(cfg));
313
+ const labels = list.map((a) => `${a[lang]} /${a.cmd}`);
314
+ const pick = await ctx.ui.select(L("Actions", "Ações"), labels);
315
+ const a = list[labels.indexOf(pick ?? "")];
316
+ if (a) ctx.ui.setEditorText(a.cmd.endsWith(" ") ? `/${a.cmd}` : `/${a.cmd} `);
197
317
  }
198
318
 
199
319
  function paintFor(ctx: ExtensionContext) {
200
320
  return (text: string) =>
201
321
  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
- )
322
+ ? (() => {
323
+ const p = paintFromTheme(ctx.ui.theme);
324
+ return paintPreview(text, p.ok, p.strike, p.text);
325
+ })()
208
326
  : text;
209
327
  }
210
328
 
@@ -222,7 +340,7 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
222
340
  } catch {
223
341
  library.zotero = "down";
224
342
  }
225
- headerTui?.requestRender();
343
+ redraw();
226
344
  emitWeb();
227
345
  }
228
346
 
@@ -241,13 +359,9 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
241
359
  library,
242
360
  quote,
243
361
  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
- },
362
+ paint: paintFromTheme(th),
363
+ lang,
364
+ glyphs: glyphs(),
251
365
  });
252
366
  },
253
367
  invalidate() {},
@@ -255,12 +369,39 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
255
369
  });
256
370
  }
257
371
 
372
+ function setFooter(ctx: ExtensionContext): void {
373
+ if (!lookOn(ctx)) return;
374
+ ctx.ui.setFooter((tui) => {
375
+ footerTui = tui;
376
+ return {
377
+ render: (width: number) => {
378
+ try {
379
+ const usage = ctx.getContextUsage();
380
+ const totals = usageTotals(ctx.sessionManager.getEntries());
381
+ return footerLines(
382
+ { mode, lang, library, model: modelId, percent: usage?.percent ?? null, window: usage?.contextWindow, tokens: totals.tokens, cost: totals.cost, down: bridge ? Object.keys(bridge.errors) : [] },
383
+ width,
384
+ paintFromTheme(ctx.ui.theme),
385
+ glyphs(),
386
+ );
387
+ } catch {
388
+ return []; // the context is gone after shutdown
389
+ }
390
+ },
391
+ invalidate() {},
392
+ dispose() {
393
+ footerTui = undefined;
394
+ },
395
+ };
396
+ });
397
+ }
398
+
258
399
  function status(ctx: ExtensionContext): void {
259
400
  modelId = ctx.model ? `${ctx.model.id}` : "no model";
260
401
  const down = bridge ? Object.keys(bridge.errors) : ["zotero", "scholar"];
261
402
  ctx.ui.setStatus("subsub", `Sub-Sub: ${mode} | ${modelId}${down.length ? ` | not running: ${down.join(", ")}` : ""}`);
262
403
  if (standalone) ctx.ui.setTitle(`Sub-Sub: ${mode}`);
263
- headerTui?.requestRender();
404
+ redraw();
264
405
  webCtx = ctx;
265
406
  emitWeb();
266
407
  }
@@ -296,6 +437,7 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
296
437
  }
297
438
  }
298
439
  if (remember) pi.appendEntry(MODE_ENTRY, { mode });
440
+ applyTheme(ctx);
299
441
  status(ctx);
300
442
  }
301
443
 
@@ -320,13 +462,15 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
320
462
  const modelChosen = hasModel && hasMessages;
321
463
  // Judge light or dark before Sub-Sub changes the theme.
322
464
  if (lookOn(ctx)) scheme ??= schemeFromAnsi(ctx.ui.theme.getFgAnsi("text"), ctx.ui.theme.name);
323
- applyTheme(ctx);
465
+ lookActive = lookOn(ctx);
466
+ if (lookActive) words = await loadWords().catch(() => undefined);
324
467
  await applyMode(ctx, restored ?? (pi.getFlag("librarian") ? "librarian" : pi.getFlag("researcher") ? "researcher" : cfg.defaultMode), false, !modelChosen);
325
468
  logUsage(cfg.usageLog, { ev: "session", mode, model: ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : undefined });
326
469
  if (lookOn(ctx)) {
327
470
  setHeader(ctx);
471
+ setFooter(ctx);
328
472
  void refreshLibrary();
329
- } else if (webView) {
473
+ } else if (sideChannel) {
330
474
  void refreshLibrary();
331
475
  }
332
476
  if (!ctx.model && !webView) ctx.ui.notify(L("Sub-Sub: no model is connected yet. Type /login and select a provider (or use the model button in subsub web).", "Sub-Sub: ainda não há um modelo ligado. Escreva /login e selecione um fornecedor (ou use o botão do modelo em subsub web)."), "warning");
@@ -358,6 +502,16 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
358
502
  });
359
503
 
360
504
  pi.on("model_select", async (_event, ctx) => status(ctx));
505
+ // Plain-word tool lines for Sub-Sub's own tools (the look only); pi's tools keep pi's rows.
506
+ const rows = toolRenderers({ word: (name) => (words ? toolWord(words, lang, name) : name), lang, glyphs, paint: paintFromTheme });
507
+ pi.registerToolRenderer?.((toolName, next) => (lookActive && SERVER_PREFIXES.some((p) => toolName.startsWith(p)) ? (rows(toolName) as never) : next()));
508
+ pi.on("tool_execution_start", async (event, ctx) => {
509
+ if (lookActive && words && !event.parentToolCallId) ctx.ui.setWorkingMessage?.(`${toolWord(words, lang, event.toolName)}...`);
510
+ });
511
+ pi.on("tool_execution_end", async (_event, ctx) => {
512
+ if (lookActive) ctx.ui.setWorkingMessage?.();
513
+ });
514
+ pi.on("turn_end", async () => redraw());
361
515
  // A provider error in plain words (quota, busy, blocked, key); the web view words its own.
362
516
  pi.on("message_end", async (event, ctx) => {
363
517
  const m = event.message as { role?: string; stopReason?: string; errorMessage?: string };
@@ -466,7 +620,7 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
466
620
  const verdict = judgePath(target, buildPolicy({ vault: cfg.vault, cwd: ctx.cwd, serverDir: cfg.serverDir, packageDir: PACKAGE_DIR }));
467
621
  if (verdict.ok) return undefined;
468
622
  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}.`));
623
+ const ok = await approve(ctx, `Sub-Sub: ${name} ${target}?`, L(`Asking because ${verdict.why}.`, `Pergunto porque: ${verdict.why}.`));
470
624
  return ok ? undefined : { block: true, reason: `${user} did not allow writing to that file.` };
471
625
  }
472
626
  if (!ctx.hasUI) {
@@ -478,7 +632,7 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
478
632
  const verdict = judgePath(normalizeToolPath(input.output_path, ctx.cwd), buildPolicy({ vault: cfg.vault, cwd: ctx.cwd, serverDir: cfg.serverDir, packageDir: PACKAGE_DIR }));
479
633
  if (!verdict.ok && /protected|hidden/.test(verdict.why)) return { block: true, reason: `Sub-Sub does not write there: ${verdict.why}.` };
480
634
  }
481
- const ok = await ctx.ui.confirm(L(`Sub-Sub: run ${name}?`, `Sub-Sub: executar ${name}?`), describeArgs(input));
635
+ const ok = await approve(ctx, L(`Sub-Sub: run ${name}?`, `Sub-Sub: executar ${name}?`), describeArgs(input));
482
636
  return ok ? undefined : { block: true, reason: `${user} did not approve. Ask what to change.` };
483
637
  }
484
638
  // kind === "preview": the server computes the change first, then the user decides.
@@ -502,7 +656,7 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
502
656
  // The dialog may stay open for minutes. Before applying, compute the preview again: if the
503
657
  // library changed meanwhile (an edit in Zotero, new items), show the new preview instead.
504
658
  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));
659
+ 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
660
  logUsage(cfg.usageLog, { ev: "change", tool: name, approved: ok });
507
661
  if (!ok) return { block: true, reason: `${user} did not approve this change. Ask what to change; do not retry the same call.` };
508
662
  let again;
@@ -598,7 +752,7 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
598
752
  return { block: true, reason: `Model test: Starbuck reports only inside the vault or ${benchOut}.` };
599
753
  }
600
754
  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}.`));
755
+ 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
756
  return ok ? undefined : { block: true, reason: `${user} did not allow writing the report there. Ask where to put it (report_dir).` };
603
757
  }
604
758
 
@@ -634,22 +788,41 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
634
788
  };
635
789
  }
636
790
 
791
+ const switchTo = async (ctx: ExtensionContext, next: Mode): Promise<void> => {
792
+ await applyMode(ctx, next, true);
793
+ ctx.ui.notify(
794
+ next === "librarian"
795
+ ? L("Sub-Sub: Librarian mode. For searches and notes, switch to /researcher.", "Sub-Sub: modo Bibliotecário. Para pesquisas e notas, mude para /researcher.")
796
+ : 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."),
797
+ "info",
798
+ );
799
+ };
800
+
637
801
  pi.registerCommand("librarian", {
638
802
  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
- },
803
+ handler: async (_args, ctx) => switchTo(ctx, "librarian"),
643
804
  });
644
805
 
645
806
  pi.registerCommand("researcher", {
646
807
  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
- },
808
+ handler: async (_args, ctx) => switchTo(ctx, "researcher"),
651
809
  });
652
810
 
811
+ // Screens of the terminal look (the web view has its own panels, so these are only for the `subsub` command).
812
+ if (standalone) {
813
+ pi.registerCommand("notes", { description: "Sub-Sub: the notes in your Sub-Sub folder", handler: async (_args, ctx) => openNotes(ctx) });
814
+ pi.registerCommand("library", { description: "Sub-Sub: search your Zotero library as you type", handler: async (_args, ctx) => openLibrary(ctx) });
815
+ pi.registerCommand("actions", { description: "Sub-Sub: the actions of the web view, as a list", handler: async (_args, ctx) => openActions(ctx) });
816
+ // 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.
817
+ const keys = (names: string[], description: string, handler: (ctx: ExtensionContext) => Promise<void> | void) => {
818
+ for (const k of names) pi.registerShortcut?.(k as never, { description, handler });
819
+ };
820
+ keys(["alt+n", "f2"], "Sub-Sub: notes", (ctx) => openNotes(ctx));
821
+ keys(["alt+l", "f3"], "Sub-Sub: library search", (ctx) => openLibrary(ctx));
822
+ keys(["alt+a", "f4"], "Sub-Sub: actions", (ctx) => openActions(ctx));
823
+ keys(["alt+m", "f5"], "Sub-Sub: switch between the Researcher and the Librarian", (ctx) => switchTo(ctx, mode === "researcher" ? "librarian" : "researcher"));
824
+ }
825
+
653
826
  pi.registerCommand("subsub", {
654
827
  description: "Sub-Sub: Zotero connection and library overview",
655
828
  handler: async (_args, ctx) => {
@@ -675,7 +848,7 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
675
848
  );
676
849
  }
677
850
  }
678
- headerTui?.requestRender();
851
+ redraw();
679
852
  emitWeb();
680
853
  ctx.ui.notify(lines.join("\n"), "info");
681
854
  },
@@ -734,6 +907,11 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
734
907
  // A registered command runs before the input hook, so /read checks the mode itself.
735
908
  pi.registerCommand("read", {
736
909
  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>",
910
+ getArgumentCompletions: (prefix: string) => {
911
+ if (/\s/.test(prefix.trim())) return null;
912
+ const found = Object.keys(PURPOSES).filter((k) => k.startsWith(prefix.trim().toLowerCase()));
913
+ return found.length ? found.map((k) => ({ value: k, label: L(PURPOSES[k as Purpose][0], PURPOSES[k as Purpose][1]) })) : null;
914
+ },
737
915
  handler: async (args, ctx) => {
738
916
  if (mode === "librarian") return ctx.ui.notify(researcherOnly("read"), "warning");
739
917
  let { purpose, ref } = readArgs(String(args ?? ""));
@@ -855,7 +1033,7 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
855
1033
  ctx.ui.notify(preview.text, "warning");
856
1034
  return;
857
1035
  }
858
- const ok = await ctx.ui.confirm(L("Sub-Sub: undo?", "Sub-Sub: anular?"), paintFor(ctx)(formatPreview("zotero_undo", preview.data, lang)));
1036
+ const ok = await approve(ctx, L("Sub-Sub: undo?", "Sub-Sub: anular?"), paintFor(ctx)(formatPreview("zotero_undo", preview.data, lang)));
859
1037
  if (!ok) return;
860
1038
  const res = await current().call("zotero_undo", { ...input, dry_run: false });
861
1039
  const d = (res.data ?? {}) as Record<string, unknown>;