@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/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 (!webView || !ctx || ctx.mode !== "rpc")
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
- const name = themeName(scheme, cfg.themes || undefined);
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
- ? paintPreview(text, (x) => ctx.ui.theme.fg("success", x), (x) => ctx.ui.theme.fg("error", x), (x) => ctx.ui.theme.fg("text", x))
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
- headerTui?.requestRender();
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
- mark: (x) => th.fg("accent", x),
214
- text: (x) => th.fg("text", x),
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
- headerTui?.requestRender();
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
- applyTheme(ctx);
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 (webView) {
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.ui.confirm(`Sub-Sub: ${name} ${target}?`, L(`Asking because ${verdict.why}.`, `Pergunto porque: ${verdict.why}.`));
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.ui.confirm(L(`Sub-Sub: run ${name}?`, `Sub-Sub: executar ${name}?`), describeArgs(input));
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.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));
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.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}.`));
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
- headerTui?.requestRender();
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.ui.confirm(L("Sub-Sub: undo?", "Sub-Sub: anular?"), paintFor(ctx)(formatPreview("zotero_undo", preview.data, lang)));
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 });
@@ -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
+ }