@tiagojct/subsub 0.23.0 → 0.24.1

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/screens.ts CHANGED
@@ -237,6 +237,15 @@ export interface LibraryItem {
237
237
  title?: string;
238
238
  authors?: string;
239
239
  year?: string | number;
240
+ /** A group library's item: "group:<id>" and the group's name. Groups are read-only. */
241
+ library?: string;
242
+ group?: string;
243
+ }
244
+
245
+ /** How a command names an item: the citekey (or key), and the group library when it is in one. */
246
+ export function itemRef(it: LibraryItem): string {
247
+ const ref = it.citekey || it.key;
248
+ return it.library ? `${ref} in ${it.library}` : ref;
240
249
  }
241
250
 
242
251
  export interface LibraryLayout {
@@ -270,7 +279,7 @@ export function libraryLines(o: LibraryLayout): string[] {
270
279
  const start = Math.max(0, Math.min(o.selected - Math.floor(fit / 2), o.items.length - fit));
271
280
  for (const [k, it] of o.items.slice(start, start + fit).entries()) {
272
281
  const chosen = start + k === o.selected;
273
- const meta = [it.authors, it.year, it.citekey].filter(Boolean).join(" · ");
282
+ const meta = [it.authors, it.year, it.citekey, it.group].filter(Boolean).join(" · ");
274
283
  out.push(chosen ? `${p.mark(g.pointer)} ${p.text(cut(it.title || it.key, w - 2))}` : ` ${p.muted(cut(it.title || it.key, w - 2))}`, ` ${p.dim(cut(meta, w - 2))}`);
275
284
  }
276
285
  }
@@ -480,6 +489,8 @@ export interface InputOptions {
480
489
  title: string;
481
490
  placeholder?: string;
482
491
  prefill?: string;
492
+ /** An API key: each character is shown as a star. */
493
+ secret?: boolean;
483
494
  submit: (text: string) => void;
484
495
  cancel: () => void;
485
496
  lang: Lang;
@@ -506,7 +517,8 @@ export class InputView {
506
517
  }
507
518
 
508
519
  render(width: number): string[] {
509
- return fill(inputLines({ title: this.o.title, placeholder: this.o.placeholder, text: this.text, width, lang: this.o.lang, paint: this.o.paint, glyphs: this.o.glyphs }), width);
520
+ const text = this.o.secret ? "*".repeat([...this.text].length) : this.text;
521
+ return fill(inputLines({ title: this.o.title, placeholder: this.o.placeholder, text, width, lang: this.o.lang, paint: this.o.paint, glyphs: this.o.glyphs }), width);
510
522
  }
511
523
 
512
524
  invalidate(): void {}
package/src/subsub.ts CHANGED
@@ -45,14 +45,15 @@ import { describeArgs, formatPreview, isEmptyPreview } from "./preview.ts";
45
45
  import { perMinuteWait, providerTrouble, TROUBLE_TEXT, TROUBLE_TEXT_PT } from "./keys.ts";
46
46
  import { headerLines, LIBRARIAN_THEME, type LibraryState, paintPreview, QUOTES, type Scheme, schemeFromAnsi, themeName } from "./look.ts";
47
47
  import { footerLines, toolRenderers, usageTotals } from "./chrome.ts";
48
+ import { type CostLevel, costStatus, money, usesModel } from "./cost.ts";
48
49
  import { ApprovalView } from "./dialogs.ts";
49
50
  import { allowedFile, listNotes, OPENABLE, openFile } from "./files.ts";
50
- import { LibraryView, NotesView, type LibraryItem } from "./screens.ts";
51
+ import { itemRef, LibraryView, NotesView, type LibraryItem } from "./screens.ts";
51
52
  import { glyphs, paintFromTheme, workingFrames } from "./skin.ts";
52
53
  import { loadWords, toolWord, type Words } from "./words.ts";
53
54
  import { systemAddition } from "./prompt.ts";
54
55
  import { logUsage } from "./usage.ts";
55
- import { gateKind, SERVER_PREFIXES, toolsFor, unavailableReason, outboundProblem } from "./roles.ts";
56
+ import { activeTools, gateKind, SERVER_PREFIXES, toolsFor, unavailableReason, outboundProblem } from "./roles.ts";
56
57
 
57
58
  const MODE_ENTRY = "subsub-mode";
58
59
 
@@ -181,6 +182,8 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
181
182
  let resumeTimer: ReturnType<typeof setTimeout> | undefined;
182
183
  let turnStart = 0;
183
184
  let turnTools = 0;
185
+ /** The cost level already noted in this session, so each note comes once. */
186
+ let costNoted: CostLevel | undefined;
184
187
  let webCtx: ExtensionContext | undefined;
185
188
  function emitWeb(): void {
186
189
  const ctx = webCtx;
@@ -195,6 +198,8 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
195
198
  userName: cfg.userName ?? null,
196
199
  vault: cfg.vault ?? null,
197
200
  starbuck: starbuckEnabled(cfg),
201
+ // Shown next to the model only when there is a limit.
202
+ cost: cfg.costLimit ? { spent: usageTotals(ctx.sessionManager.getEntries()).cost, limit: cfg.costLimit } : null,
198
203
  };
199
204
  try {
200
205
  ctx.ui.setStatus("subsub.web", JSON.stringify(state));
@@ -277,15 +282,11 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
277
282
  /** /library: search as you type; Enter puts /read <item> in the box (the Librarian gets the key), ctrl+n puts /lit-note <item>. */
278
283
  async function openLibrary(ctx: ExtensionContext): Promise<void> {
279
284
  if (!screenOk(ctx)) return;
280
- const ref = (it: LibraryItem) => it.citekey || it.key;
285
+ const ref = itemRef;
281
286
  await ctx.ui.custom<void>(
282
287
  (tui, theme, _keys, done) =>
283
288
  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
+ search: searchLibraries,
289
290
  choose: (it) => {
290
291
  ctx.ui.setEditorText(mode === "researcher" ? `/read ${ref(it)}` : ref(it));
291
292
  done();
@@ -326,6 +327,33 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
326
327
  : text;
327
328
  }
328
329
 
330
+ /** The group libraries in Zotero, from the last library check (read-only; searched with the user's library). */
331
+ let groups: Array<{ library: string; name: string }> = [];
332
+
333
+ /** Search the user's library, then each group library; a group's items carry the group. */
334
+ async function searchLibraries(query: string): Promise<LibraryItem[]> {
335
+ const first = await current().call("zotero_find_items", { query, limit: 25 });
336
+ if (first.isError) throw new Error(first.text);
337
+ const items = ((first.data as { items?: LibraryItem[] })?.items ?? []).filter((it) => it && it.key);
338
+ for (const g of groups) {
339
+ const r = await current().call("zotero_find_items", { query, limit: 25, library: g.library });
340
+ if (r.isError) continue;
341
+ for (const it of (r.data as { items?: LibraryItem[] })?.items ?? []) if (it?.key) items.push({ ...it, library: g.library, group: g.name });
342
+ }
343
+ return items;
344
+ }
345
+
346
+ async function refreshGroups(): Promise<void> {
347
+ if (!current().has("zotero_list_libraries")) return;
348
+ try {
349
+ const r = await current().call("zotero_list_libraries", {});
350
+ const all = ((r.data as { libraries?: Array<{ library: string; name?: string }> })?.libraries ?? []);
351
+ groups = r.isError ? [] : all.filter((l) => l.library?.startsWith("group:")).map((l) => ({ library: l.library, name: l.name || l.library }));
352
+ } catch {
353
+ groups = [];
354
+ }
355
+ }
356
+
329
357
  async function refreshLibrary(): Promise<void> {
330
358
  try {
331
359
  const st = await current().call("zotero_status", {});
@@ -336,6 +364,7 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
336
364
  const ov = await current().call("zotero_library_overview", {});
337
365
  const o = (ov.data ?? {}) as Record<string, unknown>;
338
366
  Object.assign(library, { zotero: "reachable", items: o.items as number, toReview: o.awaiting_review as number });
367
+ await refreshGroups();
339
368
  }
340
369
  } catch {
341
370
  library.zotero = "down";
@@ -410,7 +439,7 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
410
439
  /** Set the mode's tools and, unless `switchModel` is false, the mode's model. */
411
440
  async function applyMode(ctx: ExtensionContext, next: Mode, remember: boolean, switchModel = true): Promise<void> {
412
441
  mode = next;
413
- pi.setActiveTools(toolsFor(mode, registered(), toolOpts()));
442
+ pi.setActiveTools(activeTools(pi.getActiveTools(), toolsFor(mode, registered(), toolOpts()), standalone));
414
443
  const spec = cfg.models[mode];
415
444
  if (spec && switchModel) {
416
445
  const i = spec.indexOf("/");
@@ -442,6 +471,7 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
442
471
  }
443
472
 
444
473
  pi.on("session_start", async (_event, ctx) => {
474
+ costNoted = undefined; // a new or another conversation: its own cost
445
475
  if (!bridge) {
446
476
  const b = new Bridge();
447
477
  await b.start(deps.specs ?? serverSpecs(cfg), cfg.startTimeout * 1000);
@@ -531,6 +561,17 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
531
561
  // A per-minute limit (free tiers): wait as long as the provider says, then continue the task,
532
562
  // up to three times per request of the user. Anything the user types cancels the wait.
533
563
  pi.on("agent_end", async (_event, ctx) => {
564
+ const spent = costStatus(usageTotals(ctx.sessionManager.getEntries()).cost, cfg.costLimit);
565
+ if (spent.level !== "ok" && costNoted !== spent.level) {
566
+ costNoted = spent.level;
567
+ ctx.ui.notify(
568
+ spent.level === "near"
569
+ ? L(`Sub-Sub: this conversation has used 80% of your cost limit (${money(spent.spent)} of ${money(spent.limit!)}).`, `Sub-Sub: esta conversa já usou 80% do seu limite de custo (${money(spent.spent)} de ${money(spent.limit!)}).`)
570
+ : L(`Sub-Sub: this conversation has reached your cost limit (${money(spent.spent)} of ${money(spent.limit!)}). Sub-Sub will ask before each new request.`, `Sub-Sub: esta conversa atingiu o seu limite de custo (${money(spent.spent)} de ${money(spent.limit!)}). O Sub-Sub vai perguntar antes de cada pedido novo.`),
571
+ "warning",
572
+ );
573
+ }
574
+ if (cfg.costLimit) emitWeb(); // the cost next to the model in the browser view
534
575
  if (turnStart) logUsage(cfg.usageLog, { ev: "turn", seconds: Math.round((Date.now() - turnStart) / 1000), tools: turnTools, error: providerTrouble(lastError) ?? (lastError ? "other" : undefined) });
535
576
  turnStart = 0;
536
577
  const wait = perMinuteWait(lastError);
@@ -558,6 +599,19 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
558
599
  resumeTimer = undefined;
559
600
  resumes = 0;
560
601
  const cmd = /^\/([\w-]+)(?:\s|$)/.exec(event.text.trim())?.[1];
602
+ // Over the cost limit: ask before each new request to the model (a running answer is not cut).
603
+ const spent = costStatus(usageTotals(ctx.sessionManager.getEntries()).cost, cfg.costLimit);
604
+ if (spent.level === "over" && usesModel(event.text)) {
605
+ const text = L(
606
+ `This conversation has cost ${money(spent.spent)}, over your limit of ${money(spent.limit!)} (costLimit in the settings). Send this request to the model?`,
607
+ `Esta conversa já custou ${money(spent.spent)}, acima do seu limite de ${money(spent.limit!)} (costLimit nas definições). Enviar este pedido ao modelo?`,
608
+ );
609
+ const go = ctx.hasUI ? await ctx.ui.confirm(L("Sub-Sub: cost limit", "Sub-Sub: limite de custo"), text) : false;
610
+ if (!go) {
611
+ if (!ctx.hasUI) ctx.ui.notify(text, "warning");
612
+ return { action: "handled" };
613
+ }
614
+ }
561
615
  // The usage log keeps the kind of request and its length, never its text.
562
616
  if (cmd !== "subsub-refresh") logUsage(cfg.usageLog, { ev: "ask", kind: cmd ? `/${cmd}` : "message", words: event.text.trim().split(/\s+/).filter(Boolean).length });
563
617
  if (mode === "librarian" && cmd && RESEARCH_PROMPTS.includes(cmd)) {
@@ -578,7 +632,7 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
578
632
  });
579
633
 
580
634
  pi.on("before_agent_start", async (event, ctx) => ({
581
- systemPrompt: `${event.systemPrompt}\n\n${systemAddition(mode, cfg, ctx.cwd)}`,
635
+ systemPrompt: `${event.systemPrompt}\n\n${systemAddition(mode, cfg, ctx.cwd, { zoteroDown: library.zotero === "down" })}`,
582
636
  }));
583
637
 
584
638
  /** Model test (subsub-bench): record every change instead of asking, and apply none. */
@@ -846,6 +900,8 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
846
900
  ? ov.text
847
901
  : `Items: ${o.items}. Without tags: ${o.without_manual_tags}. Missing facets: ${JSON.stringify(o.missing_facet)}. Awaiting review (_agent): ${o.awaiting_review}.`,
848
902
  );
903
+ await refreshGroups();
904
+ if (groups.length) lines.push(`Group libraries (read-only, searched with yours): ${groups.map((g) => g.name).join(", ")}.`);
849
905
  }
850
906
  }
851
907
  redraw();
@@ -870,8 +926,7 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
870
926
  const q = String(args ?? "").trim();
871
927
  let out: Record<string, unknown>;
872
928
  try {
873
- const r = await current().call("zotero_find_items", { query: q, limit: 25 });
874
- out = r.isError ? { error: r.text } : { items: (r.data as { items?: unknown[] })?.items ?? [] };
929
+ out = { items: await searchLibraries(q) };
875
930
  } catch (err) {
876
931
  out = { error: (err as Error).message };
877
932
  }
package/src/tui-view.ts CHANGED
@@ -252,7 +252,7 @@ export function sideLines(s: SideState, width: number, rows: number, p: Paint, g
252
252
  const w = Math.max(16, width);
253
253
  const words = SIDE_WORDS[s.lang];
254
254
  const mode = s.mode === "librarian" ? "Librarian" : "Researcher";
255
- const lib = s.library.zotero === "reachable" ? `${s.library.items ?? "?"} ${s.lang === "pt" ? "itens" : "items"}` : s.library.zotero === "down" ? `${g.hollow} Zotero` : "Zotero...";
255
+ const lib = s.library.zotero === "reachable" ? `${s.library.items ?? "?"} ${s.lang === "pt" ? "itens" : "items"}` : s.library.zotero === "down" ? `${g.hollow} Zotero: ${s.lang === "pt" ? "só pesquisa" : "search only"}` : "Zotero...";
256
256
  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)];
257
257
  for (const [key, word] of [["F2", words.notes], ["F3", words.library], ["F4", words.actions], ["F5", words.mode]] as const) lines.push(p.muted(cut(` ${key.padEnd(4)}${word}`, w)));
258
258
  lines.push(rule(w, "solid", p.muted, g), p.text(cut(` ${words.conversations}`, w)));
package/src/tui.ts CHANGED
@@ -12,12 +12,15 @@ import { CombinedAutocompleteProvider, Editor, HStack, Markdown, matchesKey, Pro
12
12
  import { agentDirFor, chooseCwd, PACKAGE_DIR, packageVersion } from "./cli.ts";
13
13
  import { fill, footerLines } from "./chrome.ts";
14
14
  import { loadConfig, uiLanguage } from "./config.ts";
15
+ import { modelsAfterLogin } from "./init.ts";
15
16
  import { type LibraryState, LIBRARIAN_THEME, DEFAULT_THEME, paintPreview, QUOTES } from "./look.ts";
16
17
  import { PURPOSES } from "./read.ts";
17
18
  import { allowedFile, listNotes, OPENABLE, openFile } from "./files.ts";
18
19
  import { Agent, findLibrary, isDialog, Side, withHints } from "./rpc.ts";
20
+ import { KEY_PROVIDERS, saveApiKey } from "./keys.ts";
21
+ import { CUSTOM_DEFAULT_NAME, saveCustomProvider } from "./providers.ts";
19
22
  import { ApprovalView } from "./dialogs.ts";
20
- import { type Choice, ChoiceView, InputView, LibraryView, type LibraryItem, NotesView } from "./screens.ts";
23
+ import { itemRef, type Choice, ChoiceView, InputView, LibraryView, type LibraryItem, NotesView } from "./screens.ts";
21
24
  import { glyphs as chooseGlyphs, type Glyphs, workingFrames } from "./skin.ts";
22
25
  import { bannerLines, type Entry, entriesFromMessages, loadPalette, messageText, type Palette, schemeFromEnv, type Scheme, SIDE_WIDTH, sideLines, transcriptLines } from "./tui-view.ts";
23
26
  import { loadWords, type Words } from "./words.ts";
@@ -58,6 +61,10 @@ export interface TuiDeps {
58
61
  searchDelay?: number;
59
62
  /** Called when the user quits or pi stops. */
60
63
  onQuit?: (code: number) => void;
64
+ /** pi's agent folder, where /login saves keys (auth.json) and other providers (models.json). */
65
+ agentDir?: string;
66
+ /** After /login saved a provider: set the models when none are set (spec: the other provider's model). */
67
+ afterLogin?: (provider: string, spec?: string) => void;
61
68
  }
62
69
 
63
70
  const SHORT = (s: string, n: number) => (s.length > n ? `${s.slice(0, n - 3)}...` : s);
@@ -213,6 +220,8 @@ export class TuiApp {
213
220
  { name: "library", description: pt ? "Pesquisar na biblioteca à medida que escreve" : "Search your library as you type" },
214
221
  { name: "actions", description: pt ? "As ações da vista no navegador" : "The actions of the browser view" },
215
222
  { name: "sessions", description: pt ? "Conversas anteriores" : "Earlier conversations" },
223
+ { name: "resume", description: pt ? "Retomar uma conversa anterior" : "Resume an earlier conversation" },
224
+ { name: "login", description: pt ? "Ligar um fornecedor do modelo" : "Connect a model provider" },
216
225
  { name: "new", description: pt ? "Uma conversa nova" : "A new conversation" },
217
226
  { name: "model", description: pt ? "Escolher o modelo" : "Choose the model" },
218
227
  { name: "restart", description: pt ? "Recomeçar o pi na mesma conversa" : "Start pi again in the same conversation" },
@@ -492,7 +501,7 @@ export class TuiApp {
492
501
  /** Alt+L, /library: search as you type; Enter puts /read <item> in the editor (the Librarian gets the key), ctrl+n puts /lit-note <item>. */
493
502
  async openLibrary(): Promise<void> {
494
503
  if (this.overlay) return;
495
- const ref = (it: LibraryItem) => it.citekey || it.key;
504
+ const ref = itemRef;
496
505
  this.open(
497
506
  new LibraryView({
498
507
  ...this.common(),
@@ -558,6 +567,70 @@ export class TuiApp {
558
567
  );
559
568
  }
560
569
 
570
+ /** Ask one line on its own screen; undefined when the user presses Esc. */
571
+ private ask(title: string, opts: { placeholder?: string; secret?: boolean } = {}): Promise<string | undefined> {
572
+ return new Promise((resolve) => {
573
+ this.open(
574
+ new InputView({
575
+ ...this.common(),
576
+ title,
577
+ ...opts,
578
+ submit: (text) => (this.close(), resolve(text.trim())),
579
+ cancel: () => (this.close(), resolve(undefined)),
580
+ }),
581
+ true,
582
+ );
583
+ });
584
+ }
585
+
586
+ /** /login: a provider that pi has (its API key) or another provider that speaks the OpenAI API; then pi starts again. */
587
+ async openLogin(): Promise<void> {
588
+ if (this.overlay) return;
589
+ const pt = this.lang === "pt";
590
+ const agentDir = this.d.agentDir;
591
+ if (!agentDir) return void this.notice("error", pt ? "Sem pasta do agente: use subsub web." : "No agent folder: use subsub web.");
592
+ const choices: Choice[] = [
593
+ ...KEY_PROVIDERS.map((p) => ({ label: p.label, hint: p.url, value: p.id })),
594
+ { label: pt ? "Outro fornecedor (API da OpenAI)" : "Another provider (OpenAI API)", hint: pt ? "servidor da instituição, Ollama, LM Studio" : "your institution's server, Ollama, LM Studio", value: CUSTOM_DEFAULT_NAME },
595
+ ];
596
+ const provider = await new Promise<string | undefined>((resolve) => {
597
+ this.open(
598
+ new ChoiceView({
599
+ ...this.common(),
600
+ title: pt ? "Ligar um fornecedor" : "Connect a provider",
601
+ choices,
602
+ choose: (v) => (this.close(), resolve(v)),
603
+ cancel: () => (this.close(), resolve(undefined)),
604
+ }),
605
+ true,
606
+ );
607
+ });
608
+ if (!provider) return;
609
+ let spec: string | undefined;
610
+ try {
611
+ if (provider === CUSTOM_DEFAULT_NAME) {
612
+ const baseUrl = await this.ask(pt ? "Endereço do servidor" : "Server address", { placeholder: "https://llm.example.org/v1" });
613
+ if (baseUrl === undefined) return;
614
+ const model = await this.ask(pt ? "Identificador do modelo" : "Model id");
615
+ if (model === undefined) return;
616
+ const key = await this.ask(pt ? "Chave de API (Enter sem chave, num servidor local)" : "API key (Enter for none, for a local server)", { secret: true });
617
+ if (key === undefined) return;
618
+ [spec] = saveCustomProvider(agentDir, { name: CUSTOM_DEFAULT_NAME, baseUrl, apiKey: key, models: [model] });
619
+ } else {
620
+ const label = KEY_PROVIDERS.find((p) => p.id === provider)?.label ?? provider;
621
+ const key = await this.ask(pt ? `Chave de API de ${label}` : `API key for ${label}`, { secret: true });
622
+ if (!key) return;
623
+ saveApiKey(agentDir, provider, key);
624
+ }
625
+ this.d.afterLogin?.(provider, spec);
626
+ } catch (err) {
627
+ this.notice("error", (err as Error).message);
628
+ return void this.tui.requestRender();
629
+ }
630
+ this.notice("info", pt ? "Guardado. O pi recomeça com o fornecedor." : "Saved. pi starts again with the provider.");
631
+ await this.restart();
632
+ }
633
+
561
634
  /** /new: an empty conversation. */
562
635
  async newConversation(): Promise<void> {
563
636
  try {
@@ -633,12 +706,12 @@ export class TuiApp {
633
706
  this.editor.addToHistory?.(line);
634
707
  this.editor.setText("");
635
708
  if (/^\/(quit|exit)\s*$/.test(line)) return void (await this.quit());
636
- const unavailable = /^\/(login|logout|settings|tree|resume|fork|share|export|import|hotkeys|reload)\b/.exec(line);
709
+ const unavailable = /^\/(logout|settings|tree|fork|share|export|import|hotkeys|reload)\b/.exec(line);
637
710
  if (unavailable) {
638
711
  this.notice("info", this.lang === "pt" ? `/${unavailable[1]} não existe em subsub tui. Use subsub no terminal ou subsub web.` : `/${unavailable[1]} is not in subsub tui. Use subsub in the terminal, or subsub web.`);
639
712
  return void this.tui.requestRender();
640
713
  }
641
- const own: Record<string, () => Promise<void> | void> = { "/restart": () => this.restart(), "/model": () => this.openModels(), "/notes": () => this.openNotes(), "/library": () => this.openLibrary(), "/actions": () => this.openActions(), "/sessions": () => this.openSessions(), "/new": () => this.newConversation() };
714
+ const own: Record<string, () => Promise<void> | void> = { "/restart": () => this.restart(), "/model": () => this.openModels(), "/notes": () => this.openNotes(), "/library": () => this.openLibrary(), "/actions": () => this.openActions(), "/sessions": () => this.openSessions(), "/resume": () => this.openSessions(), "/login": () => this.openLogin(), "/new": () => this.newConversation() };
642
715
  if (own[line]) return void (await own[line]());
643
716
  try {
644
717
  await this.d.agent.request({ type: "prompt", message: line, ...(this.busy ? { streamingBehavior: "followUp" } : {}) });
@@ -703,6 +776,8 @@ export async function runTui(args: string[], env: NodeJS.ProcessEnv = process.en
703
776
  themes: { researcher: typeof cfg.themes === "string" ? cfg.themes : undefined, librarian: cfg.themesLibrarian ?? (typeof cfg.themes === "string" ? cfg.themes : undefined) },
704
777
  sessions: async () => (await SessionManager.list(cwd)).filter((s) => s.messageCount > 0).sort((a, b) => +new Date(b.modified) - +new Date(a.modified)).map((s) => ({ title: s.name ?? s.firstMessage.slice(0, 60), path: s.path })),
705
778
  onQuit: resolve,
779
+ agentDir: env.PI_CODING_AGENT_DIR,
780
+ afterLogin: (provider, spec) => modelsAfterLogin(env, provider, spec),
706
781
  });
707
782
  void app.start();
708
783
  });
package/src/web.ts CHANGED
@@ -21,8 +21,9 @@ import { homedir } from "node:os";
21
21
  import { basename, dirname, extname, join, normalize, relative, resolve, sep } from "node:path";
22
22
  import { agentDirFor, chooseCwd, PACKAGE_DIR, packageVersion } from "./cli.ts";
23
23
  import { configPath, loadConfig, RECOMMENDED_MODELS, readConfigFile, saveConfig, type SubsubConfig, uiLanguage, withStarbuck } from "./config.ts";
24
- import { TESTED_MODELS } from "./init.ts";
24
+ import { modelsAfterLogin } from "./init.ts";
25
25
  import { KEY_PROVIDERS, saveApiKey } from "./keys.ts";
26
+ import { CUSTOM_DEFAULT_NAME, saveCustomProvider } from "./providers.ts";
26
27
 
27
28
  // ---------------------------------------------------------------- options
28
29
 
@@ -479,9 +480,13 @@ export async function startWeb(opts: WebOptions, env: NodeJS.ProcessEnv = proces
479
480
  if (busy || switching) return send(res, 409, { error: "busy" });
480
481
  const body = await readJson(req);
481
482
  return withRestart(res, () => {
483
+ if (body.provider === CUSTOM_DEFAULT_NAME) {
484
+ // Another provider (OpenAI API): models.json; its model becomes the default when none is set.
485
+ const [spec] = saveCustomProvider(agentDir, { name: CUSTOM_DEFAULT_NAME, baseUrl: String(body.baseUrl ?? ""), apiKey: String(body.key ?? ""), models: [String(body.model ?? "")] });
486
+ return modelsAfterLogin(env, CUSTOM_DEFAULT_NAME, spec);
487
+ }
482
488
  saveApiKey(agentDir, String(body.provider ?? ""), String(body.key ?? ""));
483
- const current = loadConfig(env);
484
- if (body.provider === "opencode-go" && Object.keys(current.models).length === 0) saveConfig({ models: TESTED_MODELS, model: undefined }, env);
489
+ modelsAfterLogin(env, String(body.provider ?? ""));
485
490
  });
486
491
  }
487
492
  case "GET /api/notes":
package/web/app.js CHANGED
@@ -12,6 +12,8 @@ let session = null; // pi's session state
12
12
  let busy = false;
13
13
  let commands = [];
14
14
  let providers = [];
15
+ /** The login form's choice for a provider that pi does not include (src/providers.ts CUSTOM_DEFAULT_NAME). */
16
+ const OTHER_PROVIDER = "custom";
15
17
  const dialogs = []; // waiting dialog requests, oldest first
16
18
  let shownDialog = null;
17
19
  let currentMsg = null; // assistant message being streamed
@@ -255,7 +257,10 @@ function renderHeader() {
255
257
  $("starbuck-label").title = t("starbuckHint");
256
258
  const m = state?.model ?? session?.model;
257
259
  const none = !m || !m.id || m.id === "unknown";
258
- $("model-btn").textContent = $("model-btn-side").textContent = none ? t("noModel") : m.id;
260
+ // With a cost limit in the settings, the conversation's cost and the limit follow the model.
261
+ const c = state?.cost;
262
+ const cost = c && typeof c.spent === "number" ? ` · $${c.spent.toFixed(2)}/$${c.limit.toFixed(2)}` : "";
263
+ $("model-btn").textContent = $("model-btn-side").textContent = none ? t("noModel") : m.id + cost;
259
264
  for (const b of [$("model-btn"), $("model-btn-side")]) b.classList.toggle("needs", none);
260
265
  const lib = $("library");
261
266
  const txt = lib.querySelector(".library-text") ?? lib;
@@ -363,7 +368,9 @@ function renderItems(items, message) {
363
368
  if (message) return ul.append(el("li", { class: "muted", text: message }));
364
369
  const researcher = mode() === "researcher";
365
370
  for (const it of items) {
366
- const ref = it.citekey || it.key;
371
+ // A group library's item says its library (keep in step with itemRef in src/screens.ts).
372
+ const ref = it.library ? `${it.citekey || it.key} in ${it.library}` : it.citekey || it.key;
373
+ const scope = it.library ? `groups/${it.library.split(":")[1]}` : "library";
367
374
  const run = (cmd) => () => {
368
375
  closeSide();
369
376
  sendMessage(`/${cmd} ${ref}`);
@@ -373,13 +380,13 @@ function renderItems(items, message) {
373
380
  "li",
374
381
  {},
375
382
  el("div", { class: "item-title", text: it.title || it.key }),
376
- el("div", { class: "item-meta", text: [it.authors, it.year, it.citekey].filter(Boolean).join(" · ") }),
383
+ el("div", { class: "item-meta", text: [it.authors, it.year, it.citekey, it.group].filter(Boolean).join(" · ") }),
377
384
  el(
378
385
  "div",
379
386
  { class: "item-actions" },
380
387
  researcher ? el("button", { type: "button", text: t("readItem"), onclick: run("read") }) : null,
381
388
  researcher ? el("button", { type: "button", text: t("noteItem"), onclick: run("lit-note") }) : null,
382
- el("a", { href: `zotero://select/library/items/${encodeURIComponent(it.key)}`, text: "Zotero" }),
389
+ el("a", { href: `zotero://select/${scope}/items/${encodeURIComponent(it.key)}`, text: "Zotero" }),
383
390
  ),
384
391
  ),
385
392
  );
@@ -1260,15 +1267,22 @@ async function openModels() {
1260
1267
  const ul = $("models");
1261
1268
  ul.replaceChildren(el("li", { class: "empty-models", text: "…" }));
1262
1269
  const sel = $("login-provider");
1263
- sel.replaceChildren(...providers.map((p) => el("option", { value: p.id, text: p.label })));
1270
+ sel.replaceChildren(...providers.map((p) => el("option", { value: p.id, text: p.label })), el("option", { value: OTHER_PROVIDER, text: t("otherProvider") }));
1264
1271
  const link = $("login-link");
1265
1272
  const setLink = () => {
1266
1273
  const p = providers.find((x) => x.id === sel.value);
1274
+ const other = sel.value === OTHER_PROVIDER;
1275
+ link.hidden = other;
1267
1276
  link.href = p?.url ?? "#";
1277
+ $("login-url-row").hidden = !other;
1278
+ $("login-model-row").hidden = !other;
1279
+ $("login-url").required = other;
1280
+ $("login-model").required = other;
1281
+ $("login-key").required = !other;
1268
1282
  const free = { google: "freeGoogle", openrouter: "freeOpenRouter" }[sel.value];
1269
1283
  $("login-free").hidden = !free;
1270
1284
  $("login-free").textContent = free ? t(free) : "";
1271
- $("login-where").textContent = p?.where ? t(`where_${p.where}`) : "";
1285
+ $("login-where").textContent = other ? t("otherNote") : p?.where ? t(`where_${p.where}`) : "";
1272
1286
  };
1273
1287
  sel.onchange = setLink;
1274
1288
  setLink();
@@ -1361,7 +1375,7 @@ $("login-form")?.addEventListener("submit", async (e) => {
1361
1375
  btn.disabled = true;
1362
1376
  btn.textContent = t("saving");
1363
1377
  try {
1364
- await api("/api/login", { provider: $("login-provider").value, key: $("login-key").value });
1378
+ await api("/api/login", { provider: $("login-provider").value, key: $("login-key").value, baseUrl: $("login-url").value, model: $("login-model").value });
1365
1379
  $("login-key").value = "";
1366
1380
  note(t("saved"));
1367
1381
  await openModels();
package/web/i18n.js CHANGED
@@ -24,7 +24,7 @@ export const STRINGS = {
24
24
  starbuckReport: "Write the Starbuck report to {x}?",
25
25
  approveHint: "Nothing changes until you select Yes.",
26
26
  zoteroChecking: "Zotero: checking",
27
- zoteroDown: "Zotero is not running",
27
+ zoteroDown: "Zotero is closed: search only",
28
28
  items: "{n} items",
29
29
  toReview: "{n} to check",
30
30
  toReviewHint: "Items that Sub-Sub tagged and that wait for your check (they carry the _agent tag). Select Library overview, or ask Sub-Sub to list them.",
@@ -59,6 +59,10 @@ export const STRINGS = {
59
59
  save: "Save",
60
60
  getKey: "Get a key",
61
61
  keyNote: "The key is saved on this computer, in Sub-Sub's settings folder, and is sent only to that provider.",
62
+ otherProvider: "Another provider (OpenAI API)",
63
+ serverUrl: "Server address",
64
+ modelId: "Model id",
65
+ otherNote: "A server that speaks the OpenAI API: your institution's, or a local one (Ollama, LM Studio). The key can stay empty for a local server. Your messages go to that server.",
62
66
  freeGoogle: "Free to try: a Google AI Studio key needs no card. The free tier limits the text sent each minute and the requests each day, enough to try Sub-Sub, and gemini-3.1-flash-lite is the tested free model. You must be 18 or older. In the EU, the UK and Switzerland, Google does not use your prompts to improve its products; elsewhere, on the free tier, it may.",
63
67
  freeOpenRouter: "Free to try: models whose names end in :free cost nothing, with 50 requests a day (1000 after you buy 10 dollars of credit). The providers of some free models may keep your prompts.",
64
68
  trouble_quota: "The model provider refused the request: a limit of your account was reached. Free tiers limit the text sent each minute and the requests each day. Wait a minute and ask Sub-Sub to continue; if it happens again, the daily limit is used up (it resets the next day). Or choose another model with the model button.",
@@ -167,7 +171,7 @@ export const STRINGS = {
167
171
  starbuckReport: "Escrever o relatório do Starbuck em {x}?",
168
172
  approveHint: "Nada muda até selecionar Sim.",
169
173
  zoteroChecking: "Zotero: a verificar",
170
- zoteroDown: "O Zotero não está aberto",
174
+ zoteroDown: "O Zotero está fechado: só pesquisa",
171
175
  items: "{n} itens",
172
176
  toReview: "{n} por verificar",
173
177
  toReviewHint: "Itens que o Sub-Sub etiquetou e que esperam a sua verificação (têm a etiqueta _agent). Selecione Resumo da biblioteca, ou peça ao Sub-Sub para os listar.",
@@ -202,6 +206,10 @@ export const STRINGS = {
202
206
  save: "Guardar",
203
207
  getKey: "Obter uma chave",
204
208
  keyNote: "A chave fica guardada neste computador, na pasta de definições do Sub-Sub, e só é enviada a esse fornecedor.",
209
+ otherProvider: "Outro fornecedor (API da OpenAI)",
210
+ serverUrl: "Endereço do servidor",
211
+ modelId: "Identificador do modelo",
212
+ otherNote: "Um servidor que fala a API da OpenAI: o da sua instituição, ou um servidor local (Ollama, LM Studio). Num servidor local, a chave pode ficar vazia. As suas mensagens vão para esse servidor.",
205
213
  freeGoogle: "Para experimentar sem pagar: uma chave do Google AI Studio não pede cartão. O nível gratuito limita o texto enviado por minuto e os pedidos por dia, o que chega para experimentar o Sub-Sub; o modelo gratuito testado é o gemini-3.1-flash-lite. Tem de ter 18 anos ou mais. Na UE, no Reino Unido e na Suíça, a Google não usa os seus pedidos para melhorar os produtos; noutros países, no nível gratuito, pode usar.",
206
214
  freeOpenRouter: "Para experimentar sem pagar: os modelos cujo nome termina em :free não custam nada, com 50 pedidos por dia (1000 depois de comprar 10 dólares de crédito). Os fornecedores de alguns modelos gratuitos podem guardar os seus pedidos.",
207
215
  trouble_quota: "O fornecedor do modelo recusou o pedido: atingiu um limite da sua conta. Os níveis gratuitos limitam o texto enviado por minuto e os pedidos por dia. Espere um minuto e peça ao Sub-Sub para continuar; se voltar a acontecer, esgotou o limite diário (renova-se no dia seguinte). Ou escolha outro modelo no botão do modelo.",
@@ -316,6 +324,7 @@ export const ACTIONS = {
316
324
  { cmd: "subsub", send: true, en: "Library overview", pt: "Resumo da biblioteca" },
317
325
  { cmd: "tag-batch 10", send: true, en: "Tag 10 items", pt: "Etiquetar 10 itens" },
318
326
  { cmd: "clean-tags", send: true, en: "Clean up tags", pt: "Limpar etiquetas" },
327
+ { cmd: "check-library", send: true, en: "Check the library", pt: "Verificar a biblioteca" },
319
328
  { cmd: "import-queue", send: true, en: "Import the queue", pt: "Importar a fila" },
320
329
  { cmd: "history", send: true, en: "Recent changes", pt: "Alterações recentes" },
321
330
  { cmd: "undo", send: true, en: "Undo the last change", pt: "Desfazer a última alteração" },
@@ -341,6 +350,7 @@ export const SUGGESTIONS = {
341
350
  export const TOOLS = {
342
351
  en: {
343
352
  find_items: "search the library",
353
+ list_libraries: "list the libraries",
344
354
  get_figure: "cut a figure out of the PDF",
345
355
  standalone_items: "list files without a parent item",
346
356
  set_parent_items: "give files a parent item",
@@ -402,6 +412,7 @@ export const TOOLS = {
402
412
  },
403
413
  pt: {
404
414
  find_items: "pesquisar na biblioteca",
415
+ list_libraries: "listar as bibliotecas",
405
416
  get_figure: "recortar uma figura do PDF",
406
417
  standalone_items: "listar ficheiros sem item principal",
407
418
  set_parent_items: "dar um item principal aos ficheiros",
package/web/index.html CHANGED
@@ -120,6 +120,12 @@
120
120
  <label><span data-i18n="provider">Provider</span>
121
121
  <select id="login-provider"></select>
122
122
  </label>
123
+ <label id="login-url-row" hidden><span data-i18n="serverUrl">Server address</span>
124
+ <input type="url" id="login-url" autocomplete="off" spellcheck="false" placeholder="https://llm.example.org/v1">
125
+ </label>
126
+ <label id="login-model-row" hidden><span data-i18n="modelId">Model id</span>
127
+ <input type="text" id="login-model" autocomplete="off" spellcheck="false">
128
+ </label>
123
129
  <label><span data-i18n="apiKey">API key</span>
124
130
  <input type="password" id="login-key" autocomplete="off" spellcheck="false" required>
125
131
  </label>