@tiagojct/subsub 0.5.0 → 0.6.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/docs/Subsub.md CHANGED
@@ -52,6 +52,19 @@ Templates (type the name, then the argument):
52
52
  - Librarian: `/tag-batch [size]`, `/clean-tags`, `/import-queue [note path]`
53
53
  - Researcher: `/lit-note <citekey>`, `/synthesis <topic>`, `/gaps <topic>`, `/manuscript <path>`, `/alert`
54
54
 
55
+ ## Web view
56
+
57
+ 1. Type `subsub web`. Sub-Sub opens in the browser, with the same modes, profiles, tools and approvals. The approval dialog shows the server preview; the change runs only after Yes.
58
+ 2. To add a shortcut (Applications on macOS), type `subsub shortcut`. To remove it, type `subsub shortcut --remove`.
59
+ 3. The server listens only on 127.0.0.1 and needs the random key in the address that `subsub web` opens. A second `subsub web` opens the running one.
60
+ 4. The server stops 10 minutes after the last page closes. `subsub web --stay` keeps it running.
61
+ 5. The labels are in European Portuguese when the language setting is Portuguese. A theme toggle button in the header switches between light, dark and system themes.
62
+ 6. Faster review: in approval dialogs, press `y` to apply or `n` to cancel without using the mouse.
63
+ 7. One-click copy buttons appear on every code block and assistant note. The sidebar includes real-time conversation search and an Export button to download the discussion as Markdown.
64
+ 8. The model button lists the models and saves an API key for a provider (the same auth.json as `/login`).
65
+
66
+ The log is in ~/.subsub/web.log.
67
+
55
68
  ## Profiles
56
69
 
57
70
  The profile sets how much Sub-Sub does. To see the profile, type `/profile`. To change it, type `/profile reader` (or scholar, author, editor). The change is saved.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@tiagojct/subsub",
3
- "version": "0.5.0",
4
- "description": "Sub-Sub: a Zotero librarian and research assistant in the terminal, built on pi",
3
+ "version": "0.6.1",
4
+ "description": "Sub-Sub: a Zotero librarian and research assistant in the browser or the terminal, built on pi",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "author": "Tiago Jacinto",
@@ -44,6 +44,7 @@
44
44
  },
45
45
  "files": [
46
46
  "README.md",
47
+ "assets",
47
48
  "bin",
48
49
  "dist",
49
50
  "docs",
@@ -53,7 +54,8 @@
53
54
  "scripts",
54
55
  "src",
55
56
  "templates",
56
- "themes"
57
+ "themes",
58
+ "web"
57
59
  ],
58
60
  "devDependencies": {
59
61
  "@types/node": "^24.19.0",
package/src/cli.ts CHANGED
@@ -12,6 +12,8 @@
12
12
  * - `subsub review preview|apply NOTE...` runs zotero-review (tag review notes,
13
13
  * several at a time) with Sub-Sub's server folder and settings.
14
14
  * - `subsub init` sets Sub-Sub up; `subsub doctor` checks the setup.
15
+ * - `subsub web` (or `subsub serve`) opens Sub-Sub in the browser;
16
+ * `subsub shortcut` adds a Sub-Sub shortcut that does the same.
15
17
  */
16
18
 
17
19
  import { spawnSync } from "node:child_process";
@@ -148,6 +150,16 @@ export async function run(argv: string[] = process.argv.slice(2)): Promise<void>
148
150
  process.exitCode = await initMain(args.slice(1), env);
149
151
  return;
150
152
  }
153
+ if (args[0] === "web" || args[0] === "serve") {
154
+ const { webMain } = await import("./web.ts");
155
+ process.exitCode = await webMain([...args.slice(1), ...(here ? ["--here"] : [])], env);
156
+ return;
157
+ }
158
+ if (args[0] === "shortcut") {
159
+ const { shortcutMain } = await import("./shortcut.ts");
160
+ process.exitCode = shortcutMain(args.slice(1), env);
161
+ return;
162
+ }
151
163
  if (args[0] === "doctor") {
152
164
  const { doctorMain } = await import("./doctor.ts");
153
165
  process.exitCode = await doctorMain(env);
package/src/config.ts CHANGED
@@ -59,8 +59,9 @@ export interface SubsubConfig {
59
59
  themes?: Partial<Record<Mode, string>> | false;
60
60
  }
61
61
 
62
- export function expand(p: string): string {
63
- return resolve(p.replace(/^~(?=$|\/)/, homedir()));
62
+ export function expand(p: string, home?: string): string {
63
+ const base = home ?? process.env.HOME ?? homedir();
64
+ return resolve(p.replace(/^~(?=$|\/)/, base));
64
65
  }
65
66
 
66
67
  export function parseEnvFile(path: string): Record<string, string> {
@@ -81,7 +82,7 @@ export function parseEnvFile(path: string): Record<string, string> {
81
82
  }
82
83
 
83
84
  export function configPath(env: NodeJS.ProcessEnv = process.env): string {
84
- return expand(env.SUBSUB_CONFIG ?? "~/.config/subsub/config.json");
85
+ return expand(env.SUBSUB_CONFIG ?? "~/.config/subsub/config.json", env.HOME);
85
86
  }
86
87
 
87
88
  export function readConfigFile(file: string): Record<string, unknown> {
@@ -108,6 +109,7 @@ export function isProfile(x: unknown): x is Profile {
108
109
  }
109
110
 
110
111
  export function loadConfig(env: NodeJS.ProcessEnv = process.env): SubsubConfig {
112
+ const home = env.HOME;
111
113
  const file = configPath(env);
112
114
  let user: Partial<SubsubConfig> = {};
113
115
  if (existsSync(file)) {
@@ -117,16 +119,18 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): SubsubConfig {
117
119
  throw new Error(`Sub-Sub: cannot read ${file}: ${(err as Error).message}`);
118
120
  }
119
121
  }
120
- const envCandidates = [
121
- user.envFile,
122
- env.ZOTERO_MCP_ENV,
123
- "~/.config/zotero-local-mcp/env",
124
- "~/.config/opencode/zotero.env",
125
- ].filter((x): x is string => Boolean(x));
126
- const envFile = envCandidates.map(expand).find((p) => existsSync(p));
127
- const envValues = envFile ? parseEnvFile(envFile) : {};
122
+ const explicitEnv = user.envFile ?? env.ZOTERO_MCP_ENV;
123
+ const envFile = explicitEnv
124
+ ? expand(explicitEnv, home)
125
+ : [
126
+ "~/.config/zotero-local-mcp/env",
127
+ "~/.config/opencode/zotero.env",
128
+ ]
129
+ .map((p) => expand(p, home))
130
+ .find((p) => existsSync(p));
131
+ const envValues = envFile && existsSync(envFile) ? parseEnvFile(envFile) : {};
128
132
  const vaultRaw = user.vault ?? env.ZOTERO_VAULT ?? envValues.ZOTERO_VAULT;
129
- const vault = vaultRaw ? expand(vaultRaw) : undefined;
133
+ const vault = vaultRaw ? expand(vaultRaw, home) : undefined;
130
134
  const mode = user.defaultMode === "librarian" ? "librarian" : "researcher";
131
135
  return {
132
136
  configFile: file,
@@ -135,7 +139,7 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): SubsubConfig {
135
139
  about: typeof user.about === "string" && user.about.trim() ? user.about.trim() : undefined,
136
140
  language: typeof user.language === "string" && user.language.trim() ? user.language.trim() : "English",
137
141
  setup: user.setup,
138
- serverDir: expand(user.serverDir ?? "~/Projects/zotero-local-mcp"),
142
+ serverDir: expand(user.serverDir ?? "~/Projects/zotero-local-mcp", home),
139
143
  envFile,
140
144
  vault,
141
145
  models: user.models ?? {
@@ -143,14 +147,14 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): SubsubConfig {
143
147
  researcher: "opencode-go/mimo-v2.6-pro",
144
148
  },
145
149
  defaultMode: mode,
146
- vaultContext: (user.vaultContext ?? (vault ? [join(vault, ".claude", "CLAUDE.md")] : [])).map(expand),
150
+ vaultContext: (user.vaultContext ?? (vault ? [join(vault, ".claude", "CLAUDE.md")] : [])).map((p) => expand(p, home)),
147
151
  sharedRules: user.sharedRules
148
- ? expand(user.sharedRules)
152
+ ? expand(user.sharedRules, home)
149
153
  : vault
150
154
  ? join(vault, "Systems", "Zotero agent.md")
151
155
  : undefined,
152
156
  startTimeout: user.startTimeout ?? 45,
153
- uvPath: user.uvPath ? expand(user.uvPath) : undefined,
157
+ uvPath: user.uvPath ? expand(user.uvPath, home) : undefined,
154
158
  look: user.look,
155
159
  quotes: user.quotes,
156
160
  themes: user.themes,
@@ -159,11 +163,12 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): SubsubConfig {
159
163
 
160
164
  /** uv is often missing from PATH when pi is started outside a login shell. */
161
165
  export function findUv(cfg: Pick<SubsubConfig, "uvPath">, env: NodeJS.ProcessEnv = process.env, platform: string = process.platform): string {
166
+ const home = env.HOME;
162
167
  const unix = ["~/.local/bin/uv", "/opt/homebrew/bin/uv", "/usr/local/bin/uv", "~/.cargo/bin/uv"];
163
168
  const windows = ["~/.local/bin/uv.exe", "~/.cargo/bin/uv.exe", env.LOCALAPPDATA ? join(env.LOCALAPPDATA, "uv", "uv.exe") : ""];
164
169
  const candidates = [cfg.uvPath, env.SUBSUB_UV, ...(platform === "win32" ? windows : unix)]
165
170
  .filter((x): x is string => Boolean(x))
166
- .map(expand);
171
+ .map((p) => expand(p, home));
167
172
  return candidates.find((p) => existsSync(p)) ?? "uv";
168
173
  }
169
174
 
package/src/init.ts CHANGED
@@ -85,11 +85,12 @@ export async function runInit(
85
85
  env: NodeJS.ProcessEnv = process.env,
86
86
  deps: { fetch?: typeof fetch; home?: string } = {},
87
87
  ): Promise<InitResult> {
88
- const home = deps.home ?? homedir();
89
- const configFile = configPath(env);
88
+ const home = deps.home ?? env.HOME ?? homedir();
89
+ const initEnv: NodeJS.ProcessEnv = { ...env, HOME: home };
90
+ const configFile = configPath(initEnv);
90
91
  const existing = readConfigFile(configFile) as Record<string, any>;
91
- const current = loadConfig(env);
92
- const envFile = current.envFile ?? expand(env.ZOTERO_MCP_ENV ?? join(home, ".config", "zotero-local-mcp", "env"));
92
+ const current = loadConfig(initEnv);
93
+ const envFile = current.envFile ?? expand(initEnv.ZOTERO_MCP_ENV ?? join(home, ".config", "zotero-local-mcp", "env"), home);
93
94
  const envValues = existsSync(envFile) ? parseEnvFile(envFile) : {};
94
95
  const created: string[] = [];
95
96
 
@@ -109,6 +110,7 @@ export async function runInit(
109
110
  const language = (await io.ask("Language for replies and notes (a language, or auto for the language you write in)", flags.language ?? existing.language ?? "English")).trim() || "English";
110
111
  const notes = expand(
111
112
  (await io.ask("Notes folder (Markdown files; an Obsidian vault works)", flags.notes ?? current.vault ?? envValues.ZOTERO_VAULT ?? join(home, "Documents", "Sub-Sub"))).trim(),
113
+ home,
112
114
  );
113
115
  const profileDefault = flags.profile ?? existing.profile ?? (fmup ? "reader" : "scholar");
114
116
  const profile = await io.choose(
@@ -117,7 +119,7 @@ export async function runInit(
117
119
  isProfile(profileDefault) ? profileDefault : "scholar",
118
120
  );
119
121
 
120
- const vocab = envValues.ZOTERO_VOCAB ? expand(envValues.ZOTERO_VOCAB) : join(notes, "Systems", "Zotero tags.md");
122
+ const vocab = envValues.ZOTERO_VOCAB ? expand(envValues.ZOTERO_VOCAB, home) : join(notes, "Systems", "Zotero tags.md");
121
123
  let tags = "keep";
122
124
  if (existsSync(vocab)) {
123
125
  io.say(`Tag list: keeping ${vocab}.`);
@@ -136,7 +138,7 @@ export async function runInit(
136
138
  { value: "later", label: "Choose later in Sub-Sub (/login, then /model)" },
137
139
  { value: "opencode-go", label: `OpenCode Go, tested: ${TESTED_MODELS.librarian} for the librarian, ${TESTED_MODELS.researcher} for the researcher` },
138
140
  ];
139
- const agentDir = env.SUBSUB_AGENT_DIR ? expand(env.SUBSUB_AGENT_DIR) : join(home, ".subsub", "agent");
141
+ const agentDir = initEnv.SUBSUB_AGENT_DIR ? expand(initEnv.SUBSUB_AGENT_DIR, home) : join(home, ".subsub", "agent");
140
142
  const modelDefault = existing.models ? "keep" : hasProvider(agentDir, "opencode") ? "opencode-go" : "later";
141
143
  const models = await io.choose("Models", modelOptions, flags.models ?? modelDefault);
142
144
 
@@ -172,7 +174,7 @@ export async function runInit(
172
174
  };
173
175
  if (models === "later") patch.models = {};
174
176
  if (models === "opencode-go") patch.models = TESTED_MODELS;
175
- saveConfig(patch, env);
177
+ saveConfig(patch, initEnv);
176
178
 
177
179
  const zotero = await zoteroReachable(deps.fetch);
178
180
  io.say("");
@@ -184,8 +186,8 @@ export async function runInit(
184
186
  ? "Zotero: reachable."
185
187
  : "Zotero: not reachable. Start Zotero 10, then in Zotero open Settings > Advanced and turn on \"Allow other applications on this computer to communicate with Zotero\".",
186
188
  );
187
- if (fmup) io.say("FMUP / U.Porto: the FMUP model service will come in a later version. Until then, choose a model provider with /login.");
188
- io.say("Next: type subsub. In Sub-Sub, type /login to connect a model provider. To check the setup, type subsub doctor.");
189
+ if (fmup) io.say("FMUP / U.Porto: the FMUP model service will come in a later version. Until then, connect a model provider yourself.");
190
+ io.say("Next: type subsub web to open Sub-Sub in your browser (or subsub for the terminal). Connect a model provider with the model button (or /login in the terminal). To check the setup, type subsub doctor.");
189
191
  return { configFile, envFile, notes, created, zotero };
190
192
  }
191
193
 
@@ -0,0 +1,184 @@
1
+ /**
2
+ * `subsub shortcut`: a Sub-Sub shortcut that opens the web view, so students
3
+ * can start Sub-Sub without a terminal.
4
+ *
5
+ * - macOS: ~/Applications/Sub-Sub.app (a small app bundle that runs `subsub web`;
6
+ * no Dock icon, it stops by itself when the page has been closed for a while).
7
+ * - Windows: Sub-Sub in the Start menu and on the desktop.
8
+ * - Linux: ~/.local/share/applications/sub-sub.desktop.
9
+ *
10
+ * The shortcut runs this copy of Sub-Sub with this Node.js (absolute paths), so
11
+ * it works without PATH. Run `subsub shortcut` again after moving either.
12
+ * `subsub shortcut --remove` removes what this command created.
13
+ */
14
+
15
+ import { spawnSync } from "node:child_process";
16
+ import { chmodSync, copyFileSync, existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
17
+ import { homedir } from "node:os";
18
+ import { join } from "node:path";
19
+ import { PACKAGE_DIR, packageVersion } from "./cli.ts";
20
+
21
+ export interface ShortcutPlan {
22
+ platform: NodeJS.Platform;
23
+ node: string;
24
+ bin: string;
25
+ home: string;
26
+ packageDir: string;
27
+ desktop: boolean;
28
+ }
29
+
30
+ export function planFor(env: NodeJS.ProcessEnv = process.env, platform: NodeJS.Platform = process.platform, desktop = true): ShortcutPlan {
31
+ const home = platform === "win32" ? (env.USERPROFILE ?? homedir()) : (env.HOME ?? homedir());
32
+ return { platform, node: process.execPath, bin: join(PACKAGE_DIR, "bin", "subsub.js"), home, packageDir: PACKAGE_DIR, desktop };
33
+ }
34
+
35
+ /** Single-quote a string for sh. */
36
+ export function shQuote(s: string): string {
37
+ return `'${s.replace(/'/g, `'\\''`)}'`;
38
+ }
39
+
40
+ /** Quote an argument for the Exec line of a .desktop file. */
41
+ export function desktopQuote(s: string): string {
42
+ return `"${s.replace(/(["`$\\])/g, "\\$1")}"`;
43
+ }
44
+
45
+ /** Single-quote a string for PowerShell. */
46
+ export function psQuote(s: string): string {
47
+ return `'${s.replace(/'/g, "''")}'`;
48
+ }
49
+
50
+ export function macScript(p: ShortcutPlan): string {
51
+ return [
52
+ "#!/bin/sh",
53
+ "# Opens Sub-Sub in the browser. Written by: subsub shortcut",
54
+ 'cd "$HOME" || exit 1',
55
+ `exec ${shQuote(p.node)} ${shQuote(p.bin)} web >/dev/null 2>&1`,
56
+ "",
57
+ ].join("\n");
58
+ }
59
+
60
+ export function macPlist(version: string): string {
61
+ return `<?xml version="1.0" encoding="UTF-8"?>
62
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
63
+ <plist version="1.0">
64
+ <dict>
65
+ <key>CFBundleName</key><string>Sub-Sub</string>
66
+ <key>CFBundleDisplayName</key><string>Sub-Sub</string>
67
+ <key>CFBundleIdentifier</key><string>eu.tiagojacinto.subsub</string>
68
+ <key>CFBundleExecutable</key><string>Sub-Sub</string>
69
+ <key>CFBundleIconFile</key><string>icon</string>
70
+ <key>CFBundlePackageType</key><string>APPL</string>
71
+ <key>CFBundleShortVersionString</key><string>${version}</string>
72
+ <key>CFBundleVersion</key><string>${version}</string>
73
+ <key>LSUIElement</key><true/>
74
+ </dict>
75
+ </plist>
76
+ `;
77
+ }
78
+
79
+ export function desktopEntry(p: ShortcutPlan): string {
80
+ return [
81
+ "[Desktop Entry]",
82
+ "Type=Application",
83
+ "Name=Sub-Sub",
84
+ "Comment=Zotero librarian and research assistant",
85
+ `Exec=${desktopQuote(p.node)} ${desktopQuote(p.bin)} web`,
86
+ `Path=${p.home}`,
87
+ `Icon=${join(p.packageDir, "assets", "icon.png")}`,
88
+ "Terminal=false",
89
+ "Categories=Education;Office;",
90
+ "",
91
+ ].join("\n");
92
+ }
93
+
94
+ export function windowsScript(p: ShortcutPlan): string {
95
+ const lines = [
96
+ "$ErrorActionPreference = 'Stop'",
97
+ "$shell = New-Object -ComObject WScript.Shell",
98
+ "$places = @([Environment]::GetFolderPath('Programs'))",
99
+ ...(p.desktop ? ["$places += [Environment]::GetFolderPath('Desktop')"] : []),
100
+ "foreach ($dir in $places) {",
101
+ " $s = $shell.CreateShortcut((Join-Path $dir 'Sub-Sub.lnk'))",
102
+ ` $s.TargetPath = ${psQuote(p.node)}`,
103
+ ` $s.Arguments = ${psQuote(`"${p.bin}" web`)}`,
104
+ ` $s.WorkingDirectory = ${psQuote(p.home)}`,
105
+ ` $s.IconLocation = ${psQuote(join(p.packageDir, "assets", "icon.ico"))}`,
106
+ " $s.Description = 'Sub-Sub: Zotero librarian and research assistant'",
107
+ " $s.WindowStyle = 7",
108
+ " $s.Save()",
109
+ " Write-Output (Join-Path $dir 'Sub-Sub.lnk')",
110
+ "}",
111
+ ];
112
+ return lines.join("\n");
113
+ }
114
+
115
+ function runPowerShell(script: string): { ok: boolean; out: string } {
116
+ const r = spawnSync("powershell", ["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", script], { encoding: "utf8", windowsHide: true });
117
+ return { ok: r.status === 0, out: `${r.stdout ?? ""}${r.stderr ?? ""}${r.error ? r.error.message : ""}`.trim() };
118
+ }
119
+
120
+ export function createShortcuts(p: ShortcutPlan): string[] {
121
+ if (p.platform === "darwin") {
122
+ const app = join(p.home, "Applications", "Sub-Sub.app");
123
+ mkdirSync(join(app, "Contents", "MacOS"), { recursive: true });
124
+ mkdirSync(join(app, "Contents", "Resources"), { recursive: true });
125
+ writeFileSync(join(app, "Contents", "Info.plist"), macPlist(packageVersion(p.packageDir)));
126
+ const exe = join(app, "Contents", "MacOS", "Sub-Sub");
127
+ writeFileSync(exe, macScript(p));
128
+ chmodSync(exe, 0o755);
129
+ const icns = join(p.packageDir, "assets", "icon.icns");
130
+ if (existsSync(icns)) copyFileSync(icns, join(app, "Contents", "Resources", "icon.icns"));
131
+ return [app];
132
+ }
133
+ if (p.platform === "win32") {
134
+ const r = runPowerShell(windowsScript(p));
135
+ if (!r.ok) throw new Error(`could not create the shortcut: ${r.out}`);
136
+ return r.out.split(/\r?\n/).filter(Boolean);
137
+ }
138
+ const dir = join(p.home, ".local", "share", "applications");
139
+ mkdirSync(dir, { recursive: true });
140
+ const file = join(dir, "sub-sub.desktop");
141
+ writeFileSync(file, desktopEntry(p));
142
+ chmodSync(file, 0o755);
143
+ return [file];
144
+ }
145
+
146
+ export function removeShortcuts(p: ShortcutPlan): string[] {
147
+ if (p.platform === "win32") {
148
+ const script = [
149
+ "foreach ($dir in @([Environment]::GetFolderPath('Programs'), [Environment]::GetFolderPath('Desktop'))) {",
150
+ " $f = Join-Path $dir 'Sub-Sub.lnk'",
151
+ " if (Test-Path $f) { Remove-Item $f; Write-Output $f }",
152
+ "}",
153
+ ].join("\n");
154
+ const r = runPowerShell(script);
155
+ return r.out.split(/\r?\n/).filter(Boolean);
156
+ }
157
+ const target = p.platform === "darwin" ? join(p.home, "Applications", "Sub-Sub.app") : join(p.home, ".local", "share", "applications", "sub-sub.desktop");
158
+ if (!existsSync(target)) return [];
159
+ rmSync(target, { recursive: true, force: true });
160
+ return [target];
161
+ }
162
+
163
+ export function shortcutMain(args: string[], env: NodeJS.ProcessEnv = process.env): number {
164
+ const plan = planFor(env, process.platform, !args.includes("--no-desktop"));
165
+ try {
166
+ if (args.includes("--remove")) {
167
+ const gone = removeShortcuts(plan);
168
+ console.log(gone.length ? `Removed:\n${gone.join("\n")}` : "No Sub-Sub shortcut found.");
169
+ return 0;
170
+ }
171
+ const made = createShortcuts(plan);
172
+ const where =
173
+ plan.platform === "darwin"
174
+ ? "Open Sub-Sub from Applications, Launchpad or Spotlight."
175
+ : plan.platform === "win32"
176
+ ? "Open Sub-Sub from the Start menu or the desktop."
177
+ : "Open Sub-Sub from your applications menu.";
178
+ console.log(`Shortcut:\n${made.join("\n")}\n${where} It opens Sub-Sub in your browser.`);
179
+ return 0;
180
+ } catch (err) {
181
+ console.error(`Sub-Sub: ${(err as Error).message}`);
182
+ return 1;
183
+ }
184
+ }
package/src/subsub.ts CHANGED
@@ -108,6 +108,30 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
108
108
  const library: LibraryState = { zotero: "checking" };
109
109
  const quote = cfg.quotes === false ? undefined : QUOTES[Math.floor(Math.random() * QUOTES.length)];
110
110
  const lookOn = (ctx: ExtensionContext) => standalone && cfg.look !== false && ctx.mode === "tui";
111
+ /** True under `subsub web`: pi runs in RPC mode and the web view shows the state that the terminal header shows. */
112
+ const webView = process.env.SUBSUB_WEB === "1";
113
+ let webCtx: ExtensionContext | undefined;
114
+ function emitWeb(): void {
115
+ const ctx = webCtx;
116
+ if (!webView || !ctx || ctx.mode !== "rpc") return;
117
+ const down = bridge ? Object.keys(bridge.errors) : ["zotero", "scholar"];
118
+ const state = {
119
+ mode,
120
+ profile,
121
+ profileLabel: profileSpec(profile).label,
122
+ model: ctx.model ? { provider: ctx.model.provider, id: ctx.model.id } : null,
123
+ down,
124
+ library,
125
+ language: cfg.language,
126
+ userName: cfg.userName ?? null,
127
+ vault: cfg.vault ?? null,
128
+ };
129
+ try {
130
+ ctx.ui.setStatus("subsub.web", JSON.stringify(state));
131
+ } catch {
132
+ /* the context is gone after shutdown */
133
+ }
134
+ }
111
135
 
112
136
  function applyTheme(ctx: ExtensionContext): void {
113
137
  if (!lookOn(ctx) || cfg.themes === false) return;
@@ -145,6 +169,7 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
145
169
  library.zotero = "down";
146
170
  }
147
171
  headerTui?.requestRender();
172
+ emitWeb();
148
173
  }
149
174
 
150
175
  function setHeader(ctx: ExtensionContext): void {
@@ -183,6 +208,8 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
183
208
  ctx.ui.setStatus("subsub", `Sub-Sub: ${mode} | ${profileSpec(profile).label} | ${modelId}${down.length ? ` | not running: ${down.join(", ")}` : ""}`);
184
209
  if (standalone) ctx.ui.setTitle(`Sub-Sub: ${mode}`);
185
210
  headerTui?.requestRender();
211
+ webCtx = ctx;
212
+ emitWeb();
186
213
  }
187
214
 
188
215
  async function applyMode(ctx: ExtensionContext, next: Mode, remember: boolean): Promise<void> {
@@ -218,6 +245,8 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
218
245
  if (lookOn(ctx)) {
219
246
  setHeader(ctx);
220
247
  void refreshLibrary();
248
+ } else if (webView) {
249
+ void refreshLibrary();
221
250
  }
222
251
  for (const [name, err] of Object.entries(bridge.errors)) {
223
252
  ctx.ui.notify(`Sub-Sub: the ${name} server did not start: ${err.split("\n")[0]}`, "error");
@@ -382,10 +411,19 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
382
411
  }
383
412
  }
384
413
  headerTui?.requestRender();
414
+ emitWeb();
385
415
  ctx.ui.notify(lines.join("\n"), "info");
386
416
  },
387
417
  });
388
418
 
419
+ pi.registerCommand("subsub-refresh", {
420
+ description: "Sub-Sub: refresh the library counts shown in the web view",
421
+ handler: async (_args, ctx) => {
422
+ webCtx = ctx;
423
+ await refreshLibrary();
424
+ },
425
+ });
426
+
389
427
  pi.registerCommand("history", {
390
428
  description: "Sub-Sub: recent library changes",
391
429
  handler: async (_args, ctx) => {