@tiagojct/subsub 0.6.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/README.md CHANGED
@@ -55,7 +55,8 @@ The Zotero servers (zotero-local-mcp) run from PyPI through uv. To run them from
55
55
  - pi runs in RPC mode with the Sub-Sub extension. Sub-Sub's approval dialog (the server preview) appears in the page, and the change runs only after you select Yes.
56
56
  - The server listens only on 127.0.0.1. The address that opens the page carries a random key, which becomes an HttpOnly, SameSite=Strict cookie; other requests need that cookie, the right Host header and a same-origin JSON request. The page has a strict content security policy and shows model output without raw HTML.
57
57
  - One server per user: a second `subsub web` opens the running one. Without an open page for 10 minutes (and with nothing waiting for approval), the server stops. `--stay` keeps it running; `--idle N` sets the minutes; `--no-open` does not open a browser; `--librarian` starts in the librarian mode.
58
- - Labels are in European Portuguese when the language setting is Portuguese, otherwise in English.
58
+ - Labels are in European Portuguese when the language setting is Portuguese, otherwise in English. A theme button in the header toggles light, dark, and system themes.
59
+ - Fast approval shortcuts (`y` to apply, `n` to decline), one-click copy buttons on code blocks and notes, conversation search, and export to Markdown.
59
60
  - The model button lists the models you can use and saves an API key for a provider in the same file as `/login`. Sign-ins through a provider's website stay in the terminal (`/login`).
60
61
  - A log is kept in `~/.subsub/web.log`.
61
62
 
package/dist/config.js CHANGED
@@ -19,8 +19,9 @@ import { dirname, join, resolve } from "node:path";
19
19
  export const PROFILES = ["reader", "scholar", "author", "editor"];
20
20
  /** The Zotero server release that this Sub-Sub version runs when there is no local server folder. */
21
21
  export const SERVER_VERSION = "0.4.1";
22
- export function expand(p) {
23
- return resolve(p.replace(/^~(?=$|\/)/, homedir()));
22
+ export function expand(p, home) {
23
+ const base = home ?? process.env.HOME ?? homedir();
24
+ return resolve(p.replace(/^~(?=$|\/)/, base));
24
25
  }
25
26
  export function parseEnvFile(path) {
26
27
  const out = {};
@@ -41,7 +42,7 @@ export function parseEnvFile(path) {
41
42
  return out;
42
43
  }
43
44
  export function configPath(env = process.env) {
44
- return expand(env.SUBSUB_CONFIG ?? "~/.config/subsub/config.json");
45
+ return expand(env.SUBSUB_CONFIG ?? "~/.config/subsub/config.json", env.HOME);
45
46
  }
46
47
  export function readConfigFile(file) {
47
48
  if (!existsSync(file))
@@ -68,6 +69,7 @@ export function isProfile(x) {
68
69
  return typeof x === "string" && PROFILES.includes(x);
69
70
  }
70
71
  export function loadConfig(env = process.env) {
72
+ const home = env.HOME;
71
73
  const file = configPath(env);
72
74
  let user = {};
73
75
  if (existsSync(file)) {
@@ -78,16 +80,18 @@ export function loadConfig(env = process.env) {
78
80
  throw new Error(`Sub-Sub: cannot read ${file}: ${err.message}`);
79
81
  }
80
82
  }
81
- const envCandidates = [
82
- user.envFile,
83
- env.ZOTERO_MCP_ENV,
84
- "~/.config/zotero-local-mcp/env",
85
- "~/.config/opencode/zotero.env",
86
- ].filter((x) => Boolean(x));
87
- const envFile = envCandidates.map(expand).find((p) => existsSync(p));
88
- const envValues = envFile ? parseEnvFile(envFile) : {};
83
+ const explicitEnv = user.envFile ?? env.ZOTERO_MCP_ENV;
84
+ const envFile = explicitEnv
85
+ ? expand(explicitEnv, home)
86
+ : [
87
+ "~/.config/zotero-local-mcp/env",
88
+ "~/.config/opencode/zotero.env",
89
+ ]
90
+ .map((p) => expand(p, home))
91
+ .find((p) => existsSync(p));
92
+ const envValues = envFile && existsSync(envFile) ? parseEnvFile(envFile) : {};
89
93
  const vaultRaw = user.vault ?? env.ZOTERO_VAULT ?? envValues.ZOTERO_VAULT;
90
- const vault = vaultRaw ? expand(vaultRaw) : undefined;
94
+ const vault = vaultRaw ? expand(vaultRaw, home) : undefined;
91
95
  const mode = user.defaultMode === "librarian" ? "librarian" : "researcher";
92
96
  return {
93
97
  configFile: file,
@@ -96,7 +100,7 @@ export function loadConfig(env = process.env) {
96
100
  about: typeof user.about === "string" && user.about.trim() ? user.about.trim() : undefined,
97
101
  language: typeof user.language === "string" && user.language.trim() ? user.language.trim() : "English",
98
102
  setup: user.setup,
99
- serverDir: expand(user.serverDir ?? "~/Projects/zotero-local-mcp"),
103
+ serverDir: expand(user.serverDir ?? "~/Projects/zotero-local-mcp", home),
100
104
  envFile,
101
105
  vault,
102
106
  models: user.models ?? {
@@ -104,14 +108,14 @@ export function loadConfig(env = process.env) {
104
108
  researcher: "opencode-go/mimo-v2.6-pro",
105
109
  },
106
110
  defaultMode: mode,
107
- vaultContext: (user.vaultContext ?? (vault ? [join(vault, ".claude", "CLAUDE.md")] : [])).map(expand),
111
+ vaultContext: (user.vaultContext ?? (vault ? [join(vault, ".claude", "CLAUDE.md")] : [])).map((p) => expand(p, home)),
108
112
  sharedRules: user.sharedRules
109
- ? expand(user.sharedRules)
113
+ ? expand(user.sharedRules, home)
110
114
  : vault
111
115
  ? join(vault, "Systems", "Zotero agent.md")
112
116
  : undefined,
113
117
  startTimeout: user.startTimeout ?? 45,
114
- uvPath: user.uvPath ? expand(user.uvPath) : undefined,
118
+ uvPath: user.uvPath ? expand(user.uvPath, home) : undefined,
115
119
  look: user.look,
116
120
  quotes: user.quotes,
117
121
  themes: user.themes,
@@ -119,11 +123,12 @@ export function loadConfig(env = process.env) {
119
123
  }
120
124
  /** uv is often missing from PATH when pi is started outside a login shell. */
121
125
  export function findUv(cfg, env = process.env, platform = process.platform) {
126
+ const home = env.HOME;
122
127
  const unix = ["~/.local/bin/uv", "/opt/homebrew/bin/uv", "/usr/local/bin/uv", "~/.cargo/bin/uv"];
123
128
  const windows = ["~/.local/bin/uv.exe", "~/.cargo/bin/uv.exe", env.LOCALAPPDATA ? join(env.LOCALAPPDATA, "uv", "uv.exe") : ""];
124
129
  const candidates = [cfg.uvPath, env.SUBSUB_UV, ...(platform === "win32" ? windows : unix)]
125
130
  .filter((x) => Boolean(x))
126
- .map(expand);
131
+ .map((p) => expand(p, home));
127
132
  return candidates.find((p) => existsSync(p)) ?? "uv";
128
133
  }
129
134
  /**
package/dist/init.js CHANGED
@@ -63,11 +63,12 @@ export async function zoteroReachable(fetchFn = fetch) {
63
63
  }
64
64
  }
65
65
  export async function runInit(io, flags, env = process.env, deps = {}) {
66
- const home = deps.home ?? homedir();
67
- const configFile = configPath(env);
66
+ const home = deps.home ?? env.HOME ?? homedir();
67
+ const initEnv = { ...env, HOME: home };
68
+ const configFile = configPath(initEnv);
68
69
  const existing = readConfigFile(configFile);
69
- const current = loadConfig(env);
70
- const envFile = current.envFile ?? expand(env.ZOTERO_MCP_ENV ?? join(home, ".config", "zotero-local-mcp", "env"));
70
+ const current = loadConfig(initEnv);
71
+ const envFile = current.envFile ?? expand(initEnv.ZOTERO_MCP_ENV ?? join(home, ".config", "zotero-local-mcp", "env"), home);
71
72
  const envValues = existsSync(envFile) ? parseEnvFile(envFile) : {};
72
73
  const created = [];
73
74
  io.say(existsSync(configFile) ? `Sub-Sub settings (${configFile}). Press Enter to keep the value in brackets.` : "Sub-Sub setup. Press Enter to take the value in brackets.");
@@ -79,10 +80,10 @@ export async function runInit(io, flags, env = process.env, deps = {}) {
79
80
  const userName = (await io.ask("Your name, for Sub-Sub's replies (Enter for none)", flags.name ?? existing.userName ?? "")).trim();
80
81
  const about = (await io.ask('One line about you, e.g. "a master\'s student in health informatics" (optional)', flags.about ?? existing.about ?? "")).trim();
81
82
  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";
82
- const notes = expand((await io.ask("Notes folder (Markdown files; an Obsidian vault works)", flags.notes ?? current.vault ?? envValues.ZOTERO_VAULT ?? join(home, "Documents", "Sub-Sub"))).trim());
83
+ const notes = expand((await io.ask("Notes folder (Markdown files; an Obsidian vault works)", flags.notes ?? current.vault ?? envValues.ZOTERO_VAULT ?? join(home, "Documents", "Sub-Sub"))).trim(), home);
83
84
  const profileDefault = flags.profile ?? existing.profile ?? (fmup ? "reader" : "scholar");
84
85
  const profile = await io.choose("Profile (change it later with /profile)", PROFILES.map((p) => ({ value: p, label: `${PROFILE_SPECS[p].label}: ${PROFILE_SPECS[p].summary}` })), isProfile(profileDefault) ? profileDefault : "scholar");
85
- const vocab = envValues.ZOTERO_VOCAB ? expand(envValues.ZOTERO_VOCAB) : join(notes, "Systems", "Zotero tags.md");
86
+ const vocab = envValues.ZOTERO_VOCAB ? expand(envValues.ZOTERO_VOCAB, home) : join(notes, "Systems", "Zotero tags.md");
86
87
  let tags = "keep";
87
88
  if (existsSync(vocab)) {
88
89
  io.say(`Tag list: keeping ${vocab}.`);
@@ -96,7 +97,7 @@ export async function runInit(io, flags, env = process.env, deps = {}) {
96
97
  { value: "later", label: "Choose later in Sub-Sub (/login, then /model)" },
97
98
  { value: "opencode-go", label: `OpenCode Go, tested: ${TESTED_MODELS.librarian} for the librarian, ${TESTED_MODELS.researcher} for the researcher` },
98
99
  ];
99
- const agentDir = env.SUBSUB_AGENT_DIR ? expand(env.SUBSUB_AGENT_DIR) : join(home, ".subsub", "agent");
100
+ const agentDir = initEnv.SUBSUB_AGENT_DIR ? expand(initEnv.SUBSUB_AGENT_DIR, home) : join(home, ".subsub", "agent");
100
101
  const modelDefault = existing.models ? "keep" : hasProvider(agentDir, "opencode") ? "opencode-go" : "later";
101
102
  const models = await io.choose("Models", modelOptions, flags.models ?? modelDefault);
102
103
  // ---- write
@@ -132,7 +133,7 @@ export async function runInit(io, flags, env = process.env, deps = {}) {
132
133
  patch.models = {};
133
134
  if (models === "opencode-go")
134
135
  patch.models = TESTED_MODELS;
135
- saveConfig(patch, env);
136
+ saveConfig(patch, initEnv);
136
137
  const zotero = await zoteroReachable(deps.fetch);
137
138
  io.say("");
138
139
  io.say(`Saved: ${configFile}`);
package/docs/Subsub.md CHANGED
@@ -58,8 +58,10 @@ Templates (type the name, then the argument):
58
58
  2. To add a shortcut (Applications on macOS), type `subsub shortcut`. To remove it, type `subsub shortcut --remove`.
59
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
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.
62
- 6. The model button lists the models and saves an API key for a provider (the same auth.json as `/login`).
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`).
63
65
 
64
66
  The log is in ~/.subsub/web.log.
65
67
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiagojct/subsub",
3
- "version": "0.6.0",
3
+ "version": "0.6.1",
4
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",
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("");
package/web/app.css CHANGED
@@ -14,7 +14,7 @@
14
14
  --side-w: 17rem;
15
15
  }
16
16
  @media (prefers-color-scheme: dark) {
17
- :root {
17
+ :root:not([data-theme="light"]) {
18
18
  --gl-bg: #10161c;
19
19
  --gl-surface: #171f26;
20
20
  --gl-surface-raised: #1f2932;
@@ -31,9 +31,28 @@
31
31
  --warn: #e3b35c;
32
32
  }
33
33
  }
34
+ [data-theme="dark"], html[data-theme="dark"] {
35
+ --gl-bg: #10161c;
36
+ --gl-surface: #171f26;
37
+ --gl-surface-raised: #1f2932;
38
+ --gl-text: #e8eef2;
39
+ --gl-text-muted: #9aa4ab;
40
+ --gl-border: #2a3540;
41
+ --gl-accent: #3d97ff;
42
+ --gl-accent-bright: #6cb2ff;
43
+ --gl-accent-deep: #007aff;
44
+ --gl-on-accent: #0b1218;
45
+ --gl-tint-pale: #93b7c9;
46
+ --add: #8fcf9a;
47
+ --rem: #f0a39c;
48
+ --warn: #e3b35c;
49
+ }
34
50
  body[data-mode="librarian"] { --mode: #a8520f; --mode-deep: #7f3d09; --on-mode: #fff8f0; }
35
51
  @media (prefers-color-scheme: dark) {
36
- body[data-mode="librarian"] { --mode: #e0832a; --mode-deep: #f09a48; --on-mode: #140c04; }
52
+ :root:not([data-theme="light"]) body[data-mode="librarian"] { --mode: #e0832a; --mode-deep: #f09a48; --on-mode: #140c04; }
53
+ }
54
+ [data-theme="dark"] body[data-mode="librarian"], html[data-theme="dark"] body[data-mode="librarian"] {
55
+ --mode: #e0832a; --mode-deep: #f09a48; --on-mode: #140c04;
37
56
  }
38
57
 
39
58
  *, *::before, *::after { box-sizing: border-box; }
@@ -41,6 +60,7 @@ html, body { height: 100%; }
41
60
  body {
42
61
  margin: 0; background: var(--gl-bg); color: var(--gl-text);
43
62
  font-family: var(--gl-font-sans); font-size: 1rem; line-height: 1.55;
63
+ transition: background-color 0.2s ease, color 0.2s ease;
44
64
  }
45
65
  button, input, select, textarea { font: inherit; color: inherit; }
46
66
  a { color: var(--gl-accent); }
@@ -74,23 +94,34 @@ button:disabled { opacity: 0.5; cursor: default; }
74
94
  .modes button { border-radius: var(--gl-radius-pill); padding: 0.3rem 0.9rem; color: var(--gl-text-muted); }
75
95
  .modes button[aria-pressed="true"] { background: var(--mode); color: var(--on-mode); font-weight: 600; }
76
96
  .status { margin-left: auto; display: flex; align-items: center; gap: 0.5rem 0.9rem; flex-wrap: wrap; font-size: 0.88rem; }
77
- .library { color: var(--gl-text-muted); }
97
+ .library { display: inline-flex; align-items: center; gap: 0.45rem; color: var(--gl-text-muted); }
98
+ .library-dot { width: 0.5rem; height: 0.5rem; border-radius: 50%; background: var(--gl-border); flex: none; transition: background 0.2s ease, box-shadow 0.2s ease; }
99
+ .library.reachable .library-dot { background: var(--add); box-shadow: 0 0 6px var(--add); }
100
+ .library.down .library-dot { background: var(--rem); box-shadow: 0 0 6px var(--rem); }
78
101
  .library.down { color: var(--rem); font-weight: 600; }
102
+ .library.checking .library-dot { background: var(--warn); animation: pulse 1.2s ease-in-out infinite; }
79
103
  .status select { border: 1px solid var(--gl-border); border-radius: var(--gl-radius-md); background: var(--gl-surface-raised); padding: 0.3rem 0.5rem; }
80
- .model-btn { font-family: var(--gl-font-mono); font-size: 0.8rem; border: 1px solid var(--gl-border); }
104
+ .model-btn, .theme-btn { font-family: var(--gl-font-mono); font-size: 0.8rem; border: 1px solid var(--gl-border); }
105
+ .theme-btn { display: inline-flex; align-items: center; gap: 0.3rem; padding: 0.3rem 0.6rem; }
81
106
 
82
107
  .side { border-right: 1px solid var(--gl-border); background: var(--gl-surface); padding: 1rem; display: flex; flex-direction: column; gap: 1.25rem; overflow-y: auto; }
83
108
  .side h2 { font-size: 0.72rem; letter-spacing: 0.12em; text-transform: uppercase; color: var(--gl-text-muted); margin: 0 0 0.5rem; font-weight: 600; }
84
- .side .grow { flex: 1; }
109
+ .side .grow { flex: 1; display: flex; flex-direction: column; min-height: 0; }
110
+ .side-subhead { display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.4rem; }
111
+ .side-subhead h2 { margin: 0; }
112
+ .search-wrap { margin-bottom: 0.6rem; }
113
+ .search-wrap input { width: 100%; font-size: 0.8rem; padding: 0.35rem 0.55rem; border: 1px solid var(--gl-border); border-radius: var(--gl-radius-md); background: var(--gl-surface-raised); color: var(--gl-text); }
114
+ .search-wrap input:focus { outline: none; border-color: var(--mode); }
85
115
  .actions { display: flex; flex-direction: column; gap: 0.25rem; }
86
116
  .actions button { text-align: left; padding: 0.35rem 0.6rem; border-radius: var(--gl-radius-md); }
87
117
  .actions button:hover { background: var(--gl-bg); }
88
118
  .actions hr { border: 0; border-top: 1px solid var(--gl-border); margin: 0.35rem 0; width: 100%; }
89
- .sessions { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 0.15rem; }
90
- .sessions button { width: 100%; text-align: left; padding: 0.4rem 0.6rem; display: block; }
119
+ .sessions { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 0.15rem; overflow-y: auto; flex: 1; }
120
+ .sessions button { width: 100%; text-align: left; padding: 0.4rem 0.6rem; display: block; border-radius: var(--gl-radius-md); }
91
121
  .sessions button:hover { background: var(--gl-bg); }
92
122
  .sessions .current button { background: var(--gl-bg); border-color: var(--gl-border); }
93
- .sessions .when { display: block; font-size: 0.75rem; color: var(--gl-text-muted); }
123
+ .sessions .when { display: flex; justify-content: space-between; font-size: 0.75rem; color: var(--gl-text-muted); margin-top: 0.15rem; }
124
+ .sessions .badge { font-family: var(--gl-font-mono); font-size: 0.7rem; background: var(--gl-surface-raised); border: 1px solid var(--gl-border); border-radius: var(--gl-radius-sm); padding: 0 0.3rem; }
94
125
  .sessions .what { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; font-size: 0.88rem; }
95
126
  .side-foot { display: flex; justify-content: space-between; align-items: center; gap: 0.5rem; }
96
127
  .version { font-size: 0.75rem; color: var(--gl-text-muted); font-family: var(--gl-font-mono); }
@@ -112,6 +143,27 @@ button:disabled { opacity: 0.5; cursor: default; }
112
143
  .md code { font-family: var(--gl-font-mono); font-size: 0.86em; background: var(--gl-surface); border: 1px solid var(--gl-border); border-radius: var(--gl-radius-sm); padding: 0.05em 0.3em; }
113
144
  .md pre { background: var(--gl-surface); border: 1px solid var(--gl-border); border-radius: var(--gl-radius-md); padding: 0.75rem; overflow-x: auto; font-size: 0.85rem; }
114
145
  .md pre code { background: none; border: 0; padding: 0; }
146
+ .pre-wrap { position: relative; margin: 0.6rem 0; }
147
+ .pre-wrap pre { margin: 0; }
148
+ .copy-btn {
149
+ position: absolute; top: 0.4rem; right: 0.4rem;
150
+ font-family: var(--gl-font-mono); font-size: 0.72rem;
151
+ background: var(--gl-surface-raised); border: 1px solid var(--gl-border);
152
+ border-radius: var(--gl-radius-sm); padding: 0.15rem 0.5rem;
153
+ color: var(--gl-text-muted); cursor: pointer; opacity: 0;
154
+ transition: opacity 0.15s ease, color 0.15s ease, border-color 0.15s ease;
155
+ z-index: 2;
156
+ }
157
+ .pre-wrap:hover .copy-btn, .copy-btn:focus-visible { opacity: 1; }
158
+ .copy-btn:hover { color: var(--gl-text); border-color: var(--mode); }
159
+ .copy-btn.copied { opacity: 1; color: var(--add); border-color: var(--add); }
160
+
161
+ .msg-actions { display: flex; gap: 0.4rem; margin-top: 0.45rem; opacity: 0; transition: opacity 0.15s ease; }
162
+ .msg.assistant:hover .msg-actions, .msg-actions:focus-within { opacity: 1; }
163
+ .msg-action-btn { font-size: 0.75rem; padding: 0.15rem 0.5rem; border-radius: var(--gl-radius-sm); color: var(--gl-text-muted); border: 1px solid transparent; background: none; }
164
+ .msg-action-btn:hover { color: var(--gl-text); background: var(--gl-surface-raised); border-color: var(--gl-border); }
165
+ .msg-action-btn.copied { color: var(--add); border-color: var(--add); }
166
+
115
167
  .md blockquote { margin: 0.6rem 0; padding-left: 0.9rem; border-left: 3px solid var(--gl-tint-pale); color: var(--gl-text-muted); }
116
168
  .md table { border-collapse: collapse; display: block; overflow-x: auto; font-size: 0.9rem; }
117
169
  .md th, .md td { border-bottom: 1px solid var(--gl-border); padding: 0.3rem 0.6rem; text-align: left; vertical-align: top; }
@@ -158,14 +210,15 @@ button:disabled { opacity: 0.5; cursor: default; }
158
210
  /* ---- dialogs */
159
211
  .modal { border: 1px solid var(--gl-border); border-radius: var(--gl-radius-lg); background: var(--gl-surface-raised); color: var(--gl-text); padding: 1.25rem 1.4rem; width: min(40rem, calc(100vw - 2rem)); max-height: calc(100dvh - 2rem); overflow-y: auto; box-shadow: 0 24px 60px -20px rgba(0,0,0,0.5); }
160
212
  .modal.wide { width: min(46rem, calc(100vw - 2rem)); }
161
- .modal::backdrop { background: rgba(16, 22, 28, 0.55); }
213
+ .modal::backdrop { background: rgba(16, 22, 28, 0.55); backdrop-filter: blur(2px); }
162
214
  .modal h2 { font-family: var(--gl-font-serif); font-size: 1.25rem; margin: 0 0 0.25rem; }
163
215
  .modal h3 { font-size: 0.95rem; margin: 1.25rem 0 0.5rem; }
164
216
  .modal .hint { color: var(--gl-text-muted); margin: 0 0 0.75rem; font-size: 0.9rem; }
165
217
  .preview { font-family: var(--gl-font-mono); font-size: 0.82rem; line-height: 1.5; white-space: pre-wrap; overflow-wrap: anywhere; background: var(--gl-surface); border: 1px solid var(--gl-border); border-radius: var(--gl-radius-md); padding: 0.75rem; max-height: 55vh; overflow-y: auto; margin: 0 0 1rem; }
166
- .preview .add { color: var(--add); font-weight: 600; }
167
- .preview .rem { color: var(--rem); font-weight: 600; }
168
- .modal-buttons { display: flex; justify-content: flex-end; gap: 0.5rem; flex-wrap: wrap; margin-top: 0.5rem; }
218
+ .preview .add { color: var(--add); background: rgba(29, 122, 58, 0.12); padding: 0.1em 0.35em; border-radius: var(--gl-radius-sm); font-weight: 600; }
219
+ .preview .rem { color: var(--rem); background: rgba(179, 38, 30, 0.12); padding: 0.1em 0.35em; border-radius: var(--gl-radius-sm); font-weight: 600; }
220
+ .modal-buttons { display: flex; justify-content: flex-end; align-items: center; gap: 0.5rem; flex-wrap: wrap; margin-top: 0.5rem; }
221
+ .modal-shortcut-hint { font-size: 0.76rem; color: var(--gl-text-muted); font-family: var(--gl-font-mono); margin-right: auto; }
169
222
  .modal-head { display: flex; justify-content: space-between; align-items: center; }
170
223
  .modal input[type="text"], .modal input[type="password"], .modal textarea, .modal select { width: 100%; padding: 0.5rem 0.6rem; border: 1px solid var(--gl-border); border-radius: var(--gl-radius-md); background: var(--gl-bg); }
171
224
  .modal textarea { min-height: 12rem; font-family: var(--gl-font-mono); font-size: 0.85rem; }
package/web/app.js CHANGED
@@ -77,11 +77,49 @@ function md(text) {
77
77
  }
78
78
  }
79
79
 
80
- // ---------------------------------------------------------------- header, side panel
80
+ // ---------------------------------------------------------------- theme & language
81
+
82
+ let currentTheme = localStorage.getItem("subsub-theme") || "auto";
83
+
84
+ function themeLabel(theme) {
85
+ if (theme === "light") return t("themeLight");
86
+ if (theme === "dark") return t("themeDark");
87
+ return t("themeAuto");
88
+ }
89
+
90
+ function updateThemeUI() {
91
+ const icon = currentTheme === "light" ? "☀️" : currentTheme === "dark" ? "🌙" : "💻";
92
+ const label = `${icon} ${themeLabel(currentTheme)}`;
93
+ const btn = $("theme-btn");
94
+ if (btn) {
95
+ btn.textContent = label;
96
+ btn.title = t("themeToggle");
97
+ }
98
+ const sideBtn = $("theme-btn-side");
99
+ if (sideBtn) sideBtn.textContent = label;
100
+ }
101
+
102
+ function setTheme(theme) {
103
+ currentTheme = theme;
104
+ localStorage.setItem("subsub-theme", theme);
105
+ if (theme === "auto") {
106
+ delete document.documentElement.dataset.theme;
107
+ } else {
108
+ document.documentElement.dataset.theme = theme;
109
+ }
110
+ updateThemeUI();
111
+ }
112
+
113
+ function toggleTheme() {
114
+ if (currentTheme === "auto") setTheme("light");
115
+ else if (currentTheme === "light") setTheme("dark");
116
+ else setTheme("auto");
117
+ }
81
118
 
82
119
  function applyLang() {
83
120
  document.documentElement.lang = lang === "pt" ? "pt-PT" : "en";
84
121
  for (const e of document.querySelectorAll("[data-i18n]")) e.textContent = t(e.dataset.i18n);
122
+ updateThemeUI();
85
123
  }
86
124
 
87
125
  function mode() {
@@ -95,24 +133,29 @@ function renderHeader() {
95
133
  const m = state?.model ?? session?.model;
96
134
  $("model-btn").textContent = $("model-btn-side").textContent = m ? m.id : t("noModel");
97
135
  const lib = $("library");
136
+ const txt = lib.querySelector(".library-text") ?? lib;
98
137
  const L = state?.library;
99
- lib.classList.remove("down");
100
- if (!L || L.zotero === "checking") lib.textContent = t("zoteroChecking");
101
- else if (L.zotero === "down") {
102
- lib.textContent = t("zoteroDown");
138
+ lib.classList.remove("down", "reachable", "checking");
139
+ if (!L || L.zotero === "checking") {
140
+ txt.textContent = t("zoteroChecking");
141
+ lib.classList.add("checking");
142
+ } else if (L.zotero === "down") {
143
+ txt.textContent = t("zoteroDown");
103
144
  lib.classList.add("down");
104
145
  } else {
105
146
  const parts = [t("items", { n: L.items ?? "?" })];
106
147
  if (L.toReview) parts.push(t("toReview", { n: L.toReview }));
107
- lib.textContent = `Zotero: ${parts.join(", ")}`;
148
+ txt.textContent = `Zotero: ${parts.join(", ")}`;
149
+ lib.classList.add("reachable");
108
150
  }
109
151
  if (state?.down?.length) {
110
- lib.textContent += ` | ${t("serversDown", { x: state.down.join(", ") })}`;
152
+ txt.textContent += ` | ${t("serversDown", { x: state.down.join(", ") })}`;
111
153
  lib.classList.add("down");
112
154
  }
113
155
  $("input").placeholder = mode() === "librarian" ? t("placeholderLibrarian") : t("placeholderResearcher");
114
156
  renderActions();
115
157
  renderBanner();
158
+ updateThemeUI();
116
159
  }
117
160
 
118
161
  function hasCommand(name) {
@@ -156,21 +199,21 @@ function renderBanner() {
156
199
  } else b.hidden = true;
157
200
  }
158
201
 
159
- async function loadSessions() {
160
- let list = [];
161
- try {
162
- list = (await api("/api/sessions")).sessions ?? [];
163
- } catch {
164
- return;
165
- }
202
+ let loadedSessions = [];
203
+
204
+ function renderSessionsList(filterText = "") {
166
205
  const ul = $("sessions");
167
206
  ul.replaceChildren();
168
- if (!list.length) {
207
+ const q = filterText.trim().toLowerCase();
208
+ const filtered = q
209
+ ? loadedSessions.filter((s) => (s.name || s.first || "").toLowerCase().includes(q))
210
+ : loadedSessions;
211
+ if (!filtered.length) {
169
212
  ul.append(el("li", { class: "muted small", text: t("noConversations") }));
170
213
  return;
171
214
  }
172
215
  const fmt = new Intl.DateTimeFormat(lang === "pt" ? "pt-PT" : "en-GB", { day: "numeric", month: "short", hour: "2-digit", minute: "2-digit" });
173
- for (const s of list) {
216
+ for (const s of filtered) {
174
217
  const current = session?.sessionFile === s.path;
175
218
  ul.append(
176
219
  el(
@@ -192,13 +235,57 @@ async function loadSessions() {
192
235
  },
193
236
  },
194
237
  el("span", { class: "what", text: s.name || s.first || "…" }),
195
- el("span", { class: "when", text: fmt.format(new Date(s.modified)) }),
238
+ el(
239
+ "span",
240
+ { class: "when" },
241
+ el("span", { text: fmt.format(new Date(s.modified)) }),
242
+ s.messages ? el("span", { class: "badge", text: `${s.messages}` }) : null,
243
+ ),
196
244
  ),
197
245
  ),
198
246
  );
199
247
  }
200
248
  }
201
249
 
250
+ async function loadSessions() {
251
+ try {
252
+ loadedSessions = (await api("/api/sessions")).sessions ?? [];
253
+ } catch {
254
+ return;
255
+ }
256
+ renderSessionsList($("session-search")?.value ?? "");
257
+ }
258
+
259
+ function exportConversation() {
260
+ const msgs = [];
261
+ const items = log.querySelectorAll(".msg");
262
+ if (!items.length) {
263
+ note(t("noConversations"), "warning");
264
+ return;
265
+ }
266
+ for (const m of items) {
267
+ if (m.classList.contains("user")) {
268
+ const text = m.querySelector(".bubble")?.innerText ?? "";
269
+ if (text && text !== "/subsub-refresh") msgs.push(`### User\n\n${text}\n`);
270
+ } else if (m.classList.contains("assistant")) {
271
+ const text = Array.from(m.querySelectorAll(".md")).map((d) => d.innerText).join("\n\n");
272
+ if (text) msgs.push(`### Sub-Sub (${mode()})\n\n${text}\n`);
273
+ }
274
+ }
275
+ if (!msgs.length) return;
276
+ const date = new Date().toISOString().slice(0, 10);
277
+ const content = `# Sub-Sub Research Session (${date})\n\n**Mode**: ${mode()} \n**Profile**: ${state?.profile ?? "scholar"} \n\n---\n\n${msgs.join("\n---\n\n")}`;
278
+ const blob = new Blob([content], { type: "text/markdown;charset=utf-8" });
279
+ const url = URL.createObjectURL(blob);
280
+ const a = document.createElement("a");
281
+ a.href = url;
282
+ a.download = `Sub-Sub-${mode()}-${date}.md`;
283
+ document.body.append(a);
284
+ a.click();
285
+ a.remove();
286
+ URL.revokeObjectURL(url);
287
+ }
288
+
202
289
  function closeSide() {
203
290
  $("side").classList.remove("open");
204
291
  $("menu-btn").setAttribute("aria-expanded", "false");
@@ -261,6 +348,7 @@ function textOf(content) {
261
348
  const sentTemplates = [];
262
349
 
263
350
  function userMessage(text) {
351
+ if (String(text).trim() === "/subsub-refresh") return;
264
352
  clearEmpty();
265
353
  let bubble;
266
354
  if (text.length > 700) {
@@ -332,6 +420,35 @@ function finishTool(id, result, isError) {
332
420
  if (isError) row.querySelector(".label").textContent = `${toolLabel(row.dataset.name)} (${t("failed")})`;
333
421
  }
334
422
 
423
+ function attachCopyButtons(root) {
424
+ for (const pre of root.querySelectorAll("pre")) {
425
+ if (pre.parentElement?.classList.contains("pre-wrap")) continue;
426
+ const wrap = el("div", { class: "pre-wrap" });
427
+ pre.parentNode.insertBefore(wrap, pre);
428
+ wrap.append(pre);
429
+ const btn = el("button", {
430
+ type: "button",
431
+ class: "copy-btn",
432
+ text: t("copy"),
433
+ onclick: async () => {
434
+ const code = pre.querySelector("code")?.innerText ?? pre.innerText;
435
+ try {
436
+ await navigator.clipboard.writeText(code);
437
+ btn.textContent = t("copied");
438
+ btn.classList.add("copied");
439
+ setTimeout(() => {
440
+ btn.textContent = t("copy");
441
+ btn.classList.remove("copied");
442
+ }, 2000);
443
+ } catch {
444
+ btn.textContent = t("error");
445
+ }
446
+ },
447
+ });
448
+ wrap.append(btn);
449
+ }
450
+ }
451
+
335
452
  /** A container for one assistant message; blocks are keyed by content index. */
336
453
  function startAssistant() {
337
454
  clearEmpty();
@@ -357,6 +474,7 @@ function paint(msg) {
357
474
  msg.raf = 0;
358
475
  for (const b of msg.blocks.values()) if (b.kind === "text" && b.dirty) {
359
476
  b.node.innerHTML = md(b.text);
477
+ attachCopyButtons(b.node);
360
478
  b.dirty = false;
361
479
  }
362
480
  scrollDown();
@@ -366,10 +484,13 @@ function paint(msg) {
366
484
  function renderAssistantFinal(message, msg = startAssistant()) {
367
485
  msg.box.replaceChildren();
368
486
  msg.blocks.clear();
487
+ let fullText = "";
369
488
  for (const c of message.content ?? []) {
370
489
  if (c.type === "text" && c.text?.trim()) {
490
+ fullText += (fullText ? "\n\n" : "") + c.text;
371
491
  const node = el("div", { class: "md" });
372
492
  node.innerHTML = md(c.text);
493
+ attachCopyButtons(node);
373
494
  msg.box.append(node);
374
495
  } else if (c.type === "toolCall") {
375
496
  const row = toolRow(c.id, c.name, c.arguments);
@@ -377,6 +498,32 @@ function renderAssistantFinal(message, msg = startAssistant()) {
377
498
  msg.box.append(row);
378
499
  }
379
500
  }
501
+ if (fullText.trim()) {
502
+ const actions = el(
503
+ "div",
504
+ { class: "msg-actions" },
505
+ el("button", {
506
+ type: "button",
507
+ class: "msg-action-btn",
508
+ text: t("copyNote"),
509
+ onclick: async (e) => {
510
+ const btn = e.currentTarget;
511
+ try {
512
+ await navigator.clipboard.writeText(fullText);
513
+ btn.textContent = t("copied");
514
+ btn.classList.add("copied");
515
+ setTimeout(() => {
516
+ btn.textContent = t("copyNote");
517
+ btn.classList.remove("copied");
518
+ }, 2000);
519
+ } catch {
520
+ btn.textContent = t("error");
521
+ }
522
+ },
523
+ }),
524
+ );
525
+ msg.box.append(actions);
526
+ }
380
527
  if (message.stopReason === "error" && message.errorMessage) msg.box.append(el("div", { class: "note error", text: message.errorMessage }));
381
528
  if (message.stopReason === "aborted") msg.box.append(el("div", { class: "note", text: t("stopped") }));
382
529
  if (!msg.box.children.length) msg.box.remove();
@@ -388,7 +535,11 @@ function renderHistory(messages) {
388
535
  toolRows.clear();
389
536
  currentMsg = null;
390
537
  for (const m of messages ?? []) {
391
- if (m.role === "user") userMessage(textOf(m.content));
538
+ if (m.role === "user") {
539
+ const txt = textOf(m.content).trim();
540
+ if (txt === "/subsub-refresh") continue;
541
+ userMessage(txt);
542
+ }
392
543
  else if (m.role === "assistant") renderAssistantFinal(m);
393
544
  else if (m.role === "toolResult") finishTool(m.toolCallId, m, m.isError);
394
545
  else if (m.role === "custom" && m.display) note(textOf(m.content));
@@ -447,7 +598,10 @@ function onEvent(ev) {
447
598
  refreshSession();
448
599
  break;
449
600
  case "message_start":
450
- if (ev.message?.role === "user") userMessage(sentTemplates.shift() ?? textOf(ev.message.content));
601
+ if (ev.message?.role === "user") {
602
+ const txt = (sentTemplates.shift() ?? textOf(ev.message.content)).trim();
603
+ if (txt !== "/subsub-refresh") userMessage(txt);
604
+ }
451
605
  else if (ev.message?.role === "assistant") startAssistant();
452
606
  else if (ev.message?.role === "custom" && ev.message.display) note(textOf(ev.message.content));
453
607
  if (working) log.append(working);
@@ -599,6 +753,7 @@ function nextDialog() {
599
753
  };
600
754
  if (d.method === "confirm") {
601
755
  buttons.append(
756
+ el("span", { class: "modal-shortcut-hint", text: t("confirmShortcutHint") }),
602
757
  el("button", { type: "button", class: "secondary", text: t("no"), onclick: () => answer({ confirmed: false }) }),
603
758
  el("button", { type: "button", class: "primary", text: t("yes"), onclick: () => answer({ confirmed: true }) }),
604
759
  );
@@ -614,6 +769,17 @@ function nextDialog() {
614
769
  el("button", { type: "button", class: "primary", text: t("ok"), onclick: () => answer({ value: field.value }) }),
615
770
  );
616
771
  }
772
+ dlg.onkeydown = (e) => {
773
+ if (d.method === "confirm") {
774
+ if (e.key === "y" || e.key === "Y" || (e.key === "Enter" && (e.metaKey || e.ctrlKey))) {
775
+ e.preventDefault();
776
+ answer({ confirmed: true });
777
+ } else if (e.key === "n" || e.key === "N") {
778
+ e.preventDefault();
779
+ answer({ confirmed: false });
780
+ }
781
+ }
782
+ };
617
783
  dlg.oncancel = (e) => {
618
784
  e.preventDefault();
619
785
  answer(d.method === "confirm" ? { confirmed: false } : { cancelled: true });
@@ -823,6 +989,13 @@ $("menu-btn").addEventListener("click", (e) => {
823
989
  side.classList.toggle("open");
824
990
  $("menu-btn").setAttribute("aria-expanded", String(side.classList.contains("open")));
825
991
  });
992
+ $("theme-btn")?.addEventListener("click", toggleTheme);
993
+ $("theme-btn-side")?.addEventListener("click", () => {
994
+ closeSide();
995
+ toggleTheme();
996
+ });
997
+ $("session-search")?.addEventListener("input", (e) => renderSessionsList(e.target.value));
998
+ $("export-btn")?.addEventListener("click", exportConversation);
826
999
  $("quit-btn").addEventListener("click", async () => {
827
1000
  try {
828
1001
  await api("/api/quit", {});
@@ -855,5 +1028,6 @@ function connect() {
855
1028
  };
856
1029
  }
857
1030
 
1031
+ setTheme(currentTheme);
858
1032
  applyLang();
859
1033
  connect();
package/web/glauca.css CHANGED
@@ -45,7 +45,9 @@
45
45
 
46
46
  /* Pruina is the light default; Profundum is dark. */
47
47
  :root,
48
- [data-mode="light"] {
48
+ [data-mode="light"],
49
+ [data-theme="light"],
50
+ html[data-theme="light"] {
49
51
  --gl-bg: #f0f4f6;
50
52
  --gl-surface: #f7fafb;
51
53
  --gl-surface-raised: #ffffff;
@@ -63,7 +65,9 @@
63
65
  --gl-on-tint: #f2f7fb;
64
66
  }
65
67
 
66
- [data-mode="dark"] {
68
+ [data-mode="dark"],
69
+ [data-theme="dark"],
70
+ html[data-theme="dark"] {
67
71
  --gl-bg: #10161c;
68
72
  --gl-surface: #171f26;
69
73
  --gl-surface-raised: #1f2932;
@@ -80,3 +84,23 @@
80
84
  --gl-tint-pale: #93b7c9;
81
85
  --gl-on-tint: #0d1720;
82
86
  }
87
+
88
+ @media (prefers-color-scheme: dark) {
89
+ :root:not([data-theme="light"]) {
90
+ --gl-bg: #10161c;
91
+ --gl-surface: #171f26;
92
+ --gl-surface-raised: #1f2932;
93
+ --gl-text: #e8eef2;
94
+ --gl-text-muted: #8c8c8c;
95
+ --gl-border: #2a3540;
96
+ --gl-accent: #3d97ff;
97
+ --gl-accent-bright: #6cb2ff;
98
+ --gl-accent-deep: #007aff;
99
+ --gl-on-accent: #0b1218;
100
+ --gl-tint-deep: #142430;
101
+ --gl-tint: #2b4356;
102
+ --gl-tint-bright: #4d7391;
103
+ --gl-tint-pale: #93b7c9;
104
+ --gl-on-tint: #0d1720;
105
+ }
106
+ }
package/web/i18n.js CHANGED
@@ -63,6 +63,18 @@ export const STRINGS = {
63
63
  menu: "Menu",
64
64
  commands: "Commands",
65
65
  error: "Error",
66
+ theme: "Theme",
67
+ themeAuto: "System",
68
+ themeLight: "Light",
69
+ themeDark: "Dark",
70
+ themeToggle: "Toggle theme",
71
+ copy: "Copy",
72
+ copied: "Copied!",
73
+ copyNote: "Copy text",
74
+ searchConversations: "Search conversations...",
75
+ export: "Export",
76
+ exportTitle: "Export conversation",
77
+ confirmShortcutHint: "Y to approve, N to decline",
66
78
  },
67
79
  pt: {
68
80
  researcher: "Investigador",
@@ -125,6 +137,18 @@ export const STRINGS = {
125
137
  menu: "Menu",
126
138
  commands: "Comandos",
127
139
  error: "Erro",
140
+ theme: "Tema",
141
+ themeAuto: "Sistema",
142
+ themeLight: "Claro",
143
+ themeDark: "Escuro",
144
+ themeToggle: "Mudar tema",
145
+ copy: "Copiar",
146
+ copied: "Copiado!",
147
+ copyNote: "Copiar texto",
148
+ searchConversations: "Procurar conversas...",
149
+ export: "Exportar",
150
+ exportTitle: "Exportar conversa",
151
+ confirmShortcutHint: "Y para aprovar, N para recusar",
128
152
  },
129
153
  };
130
154
 
package/web/index.html CHANGED
@@ -20,7 +20,7 @@
20
20
  <button type="button" data-mode="librarian" aria-pressed="false" data-i18n="librarian">Librarian</button>
21
21
  </div>
22
22
  <div class="status">
23
- <span class="library" id="library"></span>
23
+ <span class="library" id="library"><span class="library-dot" aria-hidden="true"></span><span class="library-text"></span></span>
24
24
  <label class="profile"><span class="visually-hidden" data-i18n="profile">Profile</span>
25
25
  <select id="profile">
26
26
  <option value="reader">Reader</option>
@@ -30,6 +30,7 @@
30
30
  </select>
31
31
  </label>
32
32
  <button type="button" class="ghost model-btn" id="model-btn"></button>
33
+ <button type="button" class="ghost theme-btn" id="theme-btn" aria-label="Toggle theme" title="Toggle theme"></button>
33
34
  </div>
34
35
  </header>
35
36
 
@@ -47,13 +48,22 @@
47
48
  <label><span data-i18n="model">Model</span>
48
49
  <button type="button" class="secondary model-btn" id="model-btn-side"></button>
49
50
  </label>
51
+ <label><span data-i18n="theme">Theme</span>
52
+ <button type="button" class="secondary theme-btn" id="theme-btn-side"></button>
53
+ </label>
50
54
  </section>
51
55
  <section>
52
56
  <h2 data-i18n="actions">Actions</h2>
53
57
  <div class="actions" id="actions"></div>
54
58
  </section>
55
59
  <section class="grow">
56
- <h2 data-i18n="conversations">Conversations</h2>
60
+ <div class="side-subhead">
61
+ <h2 data-i18n="conversations">Conversations</h2>
62
+ <button type="button" class="ghost small" id="export-btn" title="Export conversation as Markdown" data-i18n="export">Export</button>
63
+ </div>
64
+ <div class="search-wrap">
65
+ <input type="search" id="session-search" placeholder="Search conversations..." autocomplete="off">
66
+ </div>
57
67
  <ul class="sessions" id="sessions"></ul>
58
68
  </section>
59
69
  <footer class="side-foot">