@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/CHANGELOG.md +21 -0
- package/README.md +8 -1
- package/dist/chrome.js +171 -0
- package/dist/cli.js +10 -0
- package/dist/config.js +2 -0
- package/dist/dialogs.js +81 -0
- package/dist/files.js +66 -0
- package/dist/look.js +36 -17
- package/dist/rpc.js +185 -0
- package/dist/screens.js +425 -0
- package/dist/skin.js +37 -0
- package/dist/subsub.js +198 -31
- package/dist/tui-view.js +228 -0
- package/dist/tui.js +651 -0
- package/dist/web.js +16 -216
- package/dist/words.js +26 -0
- package/package.json +1 -1
- package/scripts/make-themes.mjs +112 -0
- package/scripts/plate.json +35 -0
- package/src/chrome.ts +208 -0
- package/src/cli.ts +9 -0
- package/src/config.ts +4 -0
- package/src/dialogs.ts +107 -0
- package/src/files.ts +66 -0
- package/src/look.ts +45 -18
- package/src/rpc.ts +198 -0
- package/src/screens.ts +530 -0
- package/src/skin.ts +65 -0
- package/src/subsub.ts +214 -36
- package/src/tui-view.ts +270 -0
- package/src/tui.ts +710 -0
- package/src/web.ts +14 -209
- package/src/words.ts +40 -0
- package/themes/subsub-glauca-dark-256.json +80 -0
- package/themes/subsub-glauca-dark.json +73 -77
- package/themes/subsub-glauca-light-256.json +80 -0
- package/themes/subsub-glauca-light.json +73 -77
- package/themes/subsub-try-works-dark-256.json +80 -0
- package/themes/subsub-try-works-dark.json +73 -77
- package/themes/subsub-try-works-light-256.json +80 -0
- package/themes/subsub-try-works-light.json +73 -77
- package/scripts/make-themes.py +0 -116
package/src/subsub.ts
CHANGED
|
@@ -15,9 +15,9 @@
|
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
17
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
18
|
-
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
18
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
19
19
|
import { homedir } from "node:os";
|
|
20
|
-
import { dirname, join } from "node:path";
|
|
20
|
+
import { dirname, extname, join } from "node:path";
|
|
21
21
|
import { fileURLToPath } from "node:url";
|
|
22
22
|
import { Bridge, type ServerSpec } from "./bridge.ts";
|
|
23
23
|
import {
|
|
@@ -43,7 +43,13 @@ import { applyEdits, checkLiteratureNote, field, frontMatter, itemKeyOf, missing
|
|
|
43
43
|
import { buildPolicy, isSecret, judgePath, normalizeToolPath, realish, within } from "./paths.ts";
|
|
44
44
|
import { describeArgs, formatPreview, isEmptyPreview } from "./preview.ts";
|
|
45
45
|
import { perMinuteWait, providerTrouble, TROUBLE_TEXT, TROUBLE_TEXT_PT } from "./keys.ts";
|
|
46
|
-
import { headerLines, type LibraryState, paintPreview, QUOTES, type Scheme, schemeFromAnsi, themeName } from "./look.ts";
|
|
46
|
+
import { headerLines, LIBRARIAN_THEME, type LibraryState, paintPreview, QUOTES, type Scheme, schemeFromAnsi, themeName } from "./look.ts";
|
|
47
|
+
import { footerLines, toolRenderers, usageTotals } from "./chrome.ts";
|
|
48
|
+
import { ApprovalView } from "./dialogs.ts";
|
|
49
|
+
import { allowedFile, listNotes, OPENABLE, openFile } from "./files.ts";
|
|
50
|
+
import { LibraryView, NotesView, type LibraryItem } from "./screens.ts";
|
|
51
|
+
import { glyphs, paintFromTheme, workingFrames } from "./skin.ts";
|
|
52
|
+
import { loadWords, toolWord, type Words } from "./words.ts";
|
|
47
53
|
import { systemAddition } from "./prompt.ts";
|
|
48
54
|
import { logUsage } from "./usage.ts";
|
|
49
55
|
import { gateKind, SERVER_PREFIXES, toolsFor, unavailableReason, outboundProblem } from "./roles.ts";
|
|
@@ -153,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 (!
|
|
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
|
-
|
|
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
|
-
?
|
|
203
|
-
|
|
204
|
-
(
|
|
205
|
-
|
|
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
|
-
|
|
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
|
-
|
|
246
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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>;
|