@tiagojct/subsub 0.21.0 → 0.23.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/src/web.ts CHANGED
@@ -14,20 +14,15 @@
14
14
  * stops by itself, so a shortcut can start it without a terminal.
15
15
  */
16
16
 
17
- import { spawn, type ChildProcess } from "node:child_process";
18
17
  import { randomBytes, timingSafeEqual } from "node:crypto";
19
18
  import { createReadStream, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, statSync, writeFileSync, appendFileSync } from "node:fs";
20
19
  import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
21
20
  import { homedir } from "node:os";
22
21
  import { basename, dirname, extname, join, normalize, relative, resolve, sep } from "node:path";
23
- import { StringDecoder } from "node:string_decoder";
24
22
  import { agentDirFor, chooseCwd, PACKAGE_DIR, packageVersion } from "./cli.ts";
25
- import { configPath, expand, loadConfig, RECOMMENDED_MODELS, readConfigFile, saveConfig, type SubsubConfig, uiLanguage, withStarbuck } from "./config.ts";
23
+ import { configPath, loadConfig, RECOMMENDED_MODELS, readConfigFile, saveConfig, type SubsubConfig, uiLanguage, withStarbuck } from "./config.ts";
26
24
  import { TESTED_MODELS } from "./init.ts";
27
25
  import { KEY_PROVIDERS, saveApiKey } from "./keys.ts";
28
- import { NOTE_DIRS, obsidianRoot, SETTINGS_DIR } from "./layout.ts";
29
- import { field, frontMatter } from "./notes.ts";
30
- import { realish, within } from "./paths.ts";
31
26
 
32
27
  // ---------------------------------------------------------------- options
33
28
 
@@ -68,130 +63,10 @@ export { KEY_PROVIDERS, saveApiKey } from "./keys.ts";
68
63
  import { allowedFile, listNotes, openExternal, openFile, OPENABLE, wikilinkTarget } from "./files.ts";
69
64
  export { allowedFile, listNotes, openExternal, OPENABLE, wikilinkTarget };
70
65
 
71
- // ---------------------------------------------------------------- pi in RPC mode
72
-
73
- type Listener = (ev: any) => void;
74
-
75
- /** pi as a child process in RPC mode. Strict JSONL: records end at LF only. */
76
- export class Agent {
77
- proc?: ChildProcess;
78
- private decoder = new StringDecoder("utf8");
79
- private buf = "";
80
- private n = 0;
81
- private pending = new Map<string, { resolve: (v: any) => void; reject: (e: Error) => void; timer: NodeJS.Timeout }>();
82
- private listeners = new Set<Listener>();
83
- stderr = "";
84
- onExit?: (code: number | null) => void;
85
-
86
- private readonly command: string;
87
- private readonly args: string[];
88
- private readonly cwd: string;
89
- private readonly env: NodeJS.ProcessEnv;
90
-
91
- constructor(command: string, args: string[], cwd: string, env: NodeJS.ProcessEnv) {
92
- this.command = command;
93
- this.args = args;
94
- this.cwd = cwd;
95
- this.env = env;
96
- }
97
-
98
- start(extra: string[] = []): void {
99
- this.buf = "";
100
- this.decoder = new StringDecoder("utf8");
101
- const proc = spawn(this.command, [...this.args, ...extra], { cwd: this.cwd, env: this.env, stdio: ["pipe", "pipe", "pipe"], windowsHide: true });
102
- this.proc = proc;
103
- proc.stdout!.on("data", (chunk: Buffer) => this.feed(this.decoder.write(chunk)));
104
- proc.stderr!.on("data", (d: Buffer) => {
105
- this.stderr = (this.stderr + d.toString()).slice(-20_000);
106
- });
107
- proc.once("exit", (code) => {
108
- if (this.proc !== proc) return;
109
- this.proc = undefined;
110
- for (const p of this.pending.values()) {
111
- clearTimeout(p.timer);
112
- p.reject(new Error(`pi stopped (code ${code}). ${this.stderr.slice(-500)}`));
113
- }
114
- this.pending.clear();
115
- this.onExit?.(code);
116
- });
117
- }
118
-
119
- private feed(text: string): void {
120
- this.buf += text;
121
- let i: number;
122
- while ((i = this.buf.indexOf("\n")) >= 0) {
123
- const line = this.buf.slice(0, i).replace(/\r$/, "");
124
- this.buf = this.buf.slice(i + 1);
125
- if (!line.trim()) continue;
126
- let rec: any;
127
- try {
128
- rec = JSON.parse(line);
129
- } catch {
130
- continue;
131
- }
132
- if (rec.type === "response" && typeof rec.id === "string" && this.pending.has(rec.id)) {
133
- const p = this.pending.get(rec.id)!;
134
- this.pending.delete(rec.id);
135
- clearTimeout(p.timer);
136
- if (rec.success === false) p.reject(new Error(rec.error ?? `${rec.command} failed`));
137
- else p.resolve(rec.data);
138
- continue;
139
- }
140
- for (const l of this.listeners) l(rec);
141
- }
142
- }
143
-
144
- on(l: Listener): () => void {
145
- this.listeners.add(l);
146
- return () => this.listeners.delete(l);
147
- }
148
-
149
- write(rec: unknown): void {
150
- const stdin = this.proc?.stdin;
151
- if (!stdin || !stdin.writable) throw new Error("pi is not running");
152
- stdin.write(`${JSON.stringify(rec)}\n`);
153
- }
154
-
155
- request<T = any>(cmd: Record<string, unknown>, timeoutMs = 30_000): Promise<T> {
156
- const id = `w${++this.n}`;
157
- return new Promise<T>((resolvePromise, reject) => {
158
- const timer = setTimeout(() => {
159
- this.pending.delete(id);
160
- reject(new Error(`pi did not answer ${String(cmd.type)} in time`));
161
- }, timeoutMs);
162
- this.pending.set(id, { resolve: resolvePromise, reject, timer });
163
- try {
164
- this.write({ ...cmd, id });
165
- } catch (err) {
166
- clearTimeout(timer);
167
- this.pending.delete(id);
168
- reject(err as Error);
169
- }
170
- });
171
- }
66
+ // ---------------------------------------------------------------- pi in RPC mode (src/rpc.ts)
172
67
 
173
- async stop(): Promise<void> {
174
- const proc = this.proc;
175
- if (!proc) return;
176
- this.proc = undefined;
177
- for (const p of this.pending.values()) {
178
- clearTimeout(p.timer);
179
- p.reject(new Error("pi was stopped"));
180
- }
181
- this.pending.clear();
182
- await new Promise<void>((done) => {
183
- const t = setTimeout(() => {
184
- proc.kill("SIGKILL");
185
- done();
186
- }, 3000);
187
- proc.once("exit", () => {
188
- clearTimeout(t);
189
- done();
190
- });
191
- proc.kill();
192
- });
193
- }
194
- }
68
+ import { Agent, findLibrary, isDialog, Side, withHints } from "./rpc.ts";
69
+ export { Agent };
195
70
 
196
71
  // ---------------------------------------------------------------- helpers
197
72
 
@@ -223,14 +98,6 @@ const CSP = [
223
98
  "frame-ancestors 'none'",
224
99
  ].join("; ");
225
100
 
226
- function readText(path: string): string {
227
- try {
228
- return readFileSync(path, "utf8");
229
- } catch {
230
- return "";
231
- }
232
- }
233
-
234
101
  function same(a: string, b: string): boolean {
235
102
  const x = Buffer.from(a);
236
103
  const y = Buffer.from(b);
@@ -319,7 +186,7 @@ export async function startWeb(opts: WebOptions, env: NodeJS.ProcessEnv = proces
319
186
  const clients = new Set<ServerResponse>();
320
187
  const dialogs = new Map<string, any>();
321
188
  let subsubState: unknown = null;
322
- let lastFind: { q?: string } | null = null;
189
+ const side = new Side();
323
190
  let busy = false;
324
191
  let lastSeen = Date.now();
325
192
  let restarting = false;
@@ -344,22 +211,12 @@ export async function startWeb(opts: WebOptions, env: NodeJS.ProcessEnv = proces
344
211
 
345
212
  agent.on((ev) => {
346
213
  if (ev.type === "extension_ui_request") {
347
- if (["select", "confirm", "input", "editor"].includes(ev.method)) {
214
+ if (isDialog(ev)) {
348
215
  dialogs.set(ev.id, ev);
349
216
  broadcast(ev);
350
- } else if (ev.method === "setStatus" && ev.statusKey === "subsub.web") {
351
- try {
352
- subsubState = ev.statusText ? JSON.parse(ev.statusText) : null;
353
- } catch {
354
- subsubState = null;
355
- }
217
+ } else if (side.take(ev) === "state") {
218
+ subsubState = side.state;
356
219
  broadcast({ type: "subsub_state", state: subsubState });
357
- } else if (ev.method === "setStatus" && ev.statusKey === "subsub.find") {
358
- try {
359
- lastFind = ev.statusText ? JSON.parse(ev.statusText) : null;
360
- } catch {
361
- lastFind = null;
362
- }
363
220
  } else if (ev.method === "notify") {
364
221
  broadcast(ev);
365
222
  }
@@ -401,13 +258,7 @@ export async function startWeb(opts: WebOptions, env: NodeJS.ProcessEnv = proces
401
258
  out.session = await agent.request({ type: "get_state" });
402
259
  lastSession = (out.session as { sessionFile?: string }).sessionFile ?? lastSession;
403
260
  out.messages = ((await agent.request({ type: "get_messages" })) as { messages: unknown[] }).messages;
404
- const commands = ((await agent.request({ type: "get_commands" })) as { commands: Array<Record<string, any>> }).commands;
405
- // RPC has no argument hints: read them from the prompt files.
406
- out.commands = commands.map((c) => {
407
- const front = c.source === "prompt" && c.sourceInfo?.path ? frontMatter(readText(c.sourceInfo.path)) : undefined;
408
- const hint = front && field(front, "argument-hint");
409
- return hint ? { ...c, hint } : c;
410
- });
261
+ out.commands = withHints(((await agent.request({ type: "get_commands" })) as { commands: Array<Record<string, any>> }).commands);
411
262
  } catch (err) {
412
263
  out.error = (err as Error).message;
413
264
  }
@@ -658,8 +509,7 @@ export async function startWeb(opts: WebOptions, env: NodeJS.ProcessEnv = proces
658
509
  case "GET /api/library": {
659
510
  const q = (url.searchParams.get("q") ?? "").replace(/\s+/g, " ").trim().slice(0, 200);
660
511
  if (!q) return send(res, 200, { q, items: [] });
661
- await agent.request({ type: "prompt", message: `/subsub-find ${q}` });
662
- return send(res, 200, lastFind?.q === q ? lastFind : { q, items: [] });
512
+ return send(res, 200, await findLibrary(agent, side, q));
663
513
  }
664
514
  case "POST /api/session/name": {
665
515
  const body = await readJson(req);
package/web/app.css CHANGED
@@ -115,9 +115,13 @@ body {
115
115
  }
116
116
 
117
117
  button, input, select, textarea { font: inherit; color: inherit; }
118
- a { color: var(--accent); text-underline-offset: 0.18em; text-decoration-thickness: 1px; }
118
+ a { color: var(--accent); text-underline-offset: 0.2em; text-decoration-thickness: 1px; }
119
119
  a:hover { text-decoration-thickness: 2px; }
120
120
  svg { flex: none; }
121
+ /* Scrollbars as fine rules, not as grey bars. */
122
+ * { scrollbar-width: thin; }
123
+ /* The name breaks nowhere, least of all at its hyphen. */
124
+ .nobr { white-space: nowrap; }
121
125
 
122
126
  .visually-hidden { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
123
127
  [hidden] { display: none !important; }
@@ -164,20 +168,20 @@ button:disabled { opacity: 0.45; cursor: default; }
164
168
  .icon-btn:hover { color: var(--text); background: var(--surface); }
165
169
  .icon-btn[aria-pressed="true"] { color: var(--accent); background: var(--accent-soft); }
166
170
 
167
- .model-btn.needs { color: var(--warn); border-color: var(--warn); font-family: var(--sans); }
171
+ /* Top bar text buttons, as the website's navigation: muted, inked and underlined on hover or when open. */
168
172
  .chip-btn {
169
- max-width: 16rem;
170
- overflow: hidden;
171
- text-overflow: ellipsis;
172
- white-space: nowrap;
173
- font-family: var(--mono);
174
- font-size: 0.78rem;
173
+ flex: none;
174
+ padding: 0.35rem 0.5rem;
175
+ font-size: 0.9rem;
175
176
  color: var(--muted);
176
- border-color: var(--rule);
177
- background: var(--raised);
178
- padding: 0.35rem 0.65rem;
177
+ white-space: nowrap;
178
+ text-underline-offset: 0.35em;
179
+ text-decoration-thickness: 1px;
179
180
  }
180
- .chip-btn:hover { color: var(--text); border-color: var(--rule-strong); }
181
+ .chip-btn:hover { color: var(--text); text-decoration-line: underline; }
182
+ .chip-btn.model-btn { flex: 0 1 auto; max-width: 16rem; overflow: hidden; text-overflow: ellipsis; font-family: var(--mono); font-size: 0.8rem; }
183
+ /* No model yet: pending, so gamboge and dashed. */
184
+ .model-btn.needs { color: var(--warn); border-color: var(--warn); border-style: dashed; font-family: var(--sans); }
181
185
 
182
186
  select {
183
187
  appearance: none;
@@ -319,7 +323,7 @@ select:hover { border-color: var(--rule-strong); }
319
323
  .new-btn:hover { border-color: var(--text); }
320
324
  .new-btn svg { width: 16px; height: 16px; color: var(--accent); }
321
325
 
322
- .actions { display: flex; flex-direction: column; }
326
+ .actions, .actions > div { display: flex; flex-direction: column; }
323
327
  .actions button {
324
328
  text-align: left;
325
329
  padding: 0.32rem 0.5rem;
@@ -329,9 +333,35 @@ select:hover { border-color: var(--rule-strong); }
329
333
  }
330
334
  .actions button:hover { background: var(--bg); }
331
335
  .actions .group { margin: 0.6rem 0 0.15rem; padding: 0 0.5rem; font-family: var(--serif); font-style: italic; font-size: 0.86rem; color: var(--muted); }
332
- .actions .group:first-child { margin-top: 0; }
336
+ .actions > :first-child .group { margin-top: 0; }
337
+
338
+ /* Sections and action groups fold at their heading; a fine chevron points down when open, right when folded. */
339
+ .side .fold {
340
+ display: inline-flex;
341
+ align-items: center;
342
+ gap: 0.55em;
343
+ padding: 0;
344
+ border: 0;
345
+ border-radius: 0;
346
+ font-size: inherit;
347
+ letter-spacing: inherit;
348
+ color: inherit;
349
+ text-align: left;
350
+ }
351
+ .side .fold:hover { color: var(--text); background: none; }
352
+ .side .fold::after {
353
+ content: "";
354
+ width: 0.34em;
355
+ height: 0.34em;
356
+ border-right: 1px solid currentColor;
357
+ border-bottom: 1px solid currentColor;
358
+ transform: translateY(-0.15em) rotate(45deg);
359
+ }
360
+ .side .fold[aria-expanded="false"]::after { transform: rotate(-45deg); }
361
+ [data-fold].folded > :not(h2, .side-subhead, .group) { display: none; }
333
362
 
334
363
  .side .grow { flex: 1; display: flex; flex-direction: column; min-height: 8rem; }
364
+ .side .grow.folded { flex: none; min-height: 0; }
335
365
  .side-subhead { display: flex; justify-content: space-between; align-items: baseline; padding-right: 0.3rem; }
336
366
 
337
367
  .search {
@@ -423,7 +453,6 @@ body.layout-full { --measure: 72rem; }
423
453
  padding: 0.6rem 0.6rem 0.6rem 0.9rem;
424
454
  border: 1px solid var(--rule);
425
455
  border-top: 2px solid var(--warn);
426
- border-radius: var(--radius);
427
456
  background: var(--raised);
428
457
  display: flex;
429
458
  gap: 0.75rem;
@@ -454,14 +483,16 @@ body.layout-full { --measure: 72rem; }
454
483
  overflow-wrap: anywhere;
455
484
  font-size: 0.95rem;
456
485
  }
486
+ /* A command is code: it is set in the mono, as the website's plate draws it. */
487
+ .msg.user .bubble.cmd { font-family: var(--mono); font-size: 0.86rem; }
457
488
  .msg.user details.bubble summary { cursor: pointer; color: var(--muted); }
458
489
  .msg.user details.bubble[open] summary { margin-bottom: 0.4rem; }
459
490
 
460
491
  /* Replies read as text, in the serif. */
461
492
  .md {
462
493
  font-family: var(--serif);
463
- font-size: 1.02rem;
464
- line-height: 1.68;
494
+ font-size: 1.0625rem;
495
+ line-height: 1.65;
465
496
  font-variant-numeric: oldstyle-nums proportional-nums;
466
497
  overflow-wrap: anywhere;
467
498
  }
@@ -470,16 +501,20 @@ body.layout-full { --measure: 72rem; }
470
501
  .md p, .md ul, .md ol { margin: 0.7rem 0; }
471
502
  .md li { margin: 0.2rem 0; }
472
503
  .md li::marker { color: var(--faint); }
504
+ /* Numbered steps as on the website: the number in the accent, lining figures. */
505
+ .md ol > li::marker { color: var(--accent); font-weight: 600; font-variant-numeric: lining-nums tabular-nums; }
506
+ /* Headings as on the website: roman, weight 500 for the large ones, a fine rule above a section. */
473
507
  .md h1, .md h2, .md h3, .md h4 {
474
508
  font-family: var(--serif);
475
- line-height: 1.3;
476
- margin: 1.6rem 0 0.5rem;
509
+ line-height: 1.25;
510
+ margin: 1.8rem 0 0.6rem;
477
511
  font-weight: 600;
512
+ text-wrap: balance;
478
513
  }
479
- .md h1 { font-size: 1.3rem; }
480
- .md h2 { font-size: 1.12rem; }
481
- .md h3 { font-size: 1rem; }
482
- .md h4 { font-size: 0.92rem; color: var(--muted); }
514
+ .md h1 { font-size: 1.5rem; font-weight: 500; line-height: 1.15; letter-spacing: -0.012em; }
515
+ .md h2 { font-size: 1.25rem; font-weight: 500; letter-spacing: -0.008em; padding-top: 0.5rem; border-top: 1px solid var(--rule-strong); }
516
+ .md h3 { font-size: 1.04rem; }
517
+ .md h4 { font-size: 0.95rem; color: var(--muted); }
483
518
  .md strong { font-weight: 600; }
484
519
  .md hr { border: 0; border-top: 1px solid var(--rule); margin: 1.5rem 0; }
485
520
  .msg.assistant + .msg.assistant { margin-top: 0.9rem; }
@@ -568,7 +603,7 @@ body.layout-full { --measure: 72rem; }
568
603
 
569
604
  .msg-actions { display: flex; gap: 0.25rem; margin-top: 0.4rem; opacity: 0; transition: opacity 0.12s ease; }
570
605
  .msg.assistant:hover .msg-actions, .msg-actions:focus-within { opacity: 1; }
571
- .msg-action-btn { font-size: 0.75rem; padding: 0.15rem 0.45rem; color: var(--faint); }
606
+ .msg-action-btn { font-family: var(--sans); font-size: 0.75rem; padding: 0.15rem 0.45rem; color: var(--faint); }
572
607
  .msg-action-btn:hover { color: var(--text); background: var(--surface); }
573
608
  .msg-action-btn.copied { color: var(--add); }
574
609
  .table-actions { display: flex; justify-content: flex-end; margin: -0.35rem 0 0.9rem; }
@@ -633,7 +668,6 @@ body.layout-full { --measure: 72rem; }
633
668
  .note {
634
669
  margin-top: 1rem;
635
670
  padding: 0.6rem 0.9rem;
636
- border-radius: var(--radius);
637
671
  background: var(--surface);
638
672
  border: 1px solid var(--rule);
639
673
  font-size: 0.88rem;
@@ -659,44 +693,48 @@ body.layout-full { --measure: 72rem; }
659
693
  gap: 0.55rem;
660
694
  align-items: center;
661
695
  }
696
+ /* Working is drawn by line, as in the terminal: dotted, dashed, solid. Without motion it stays dashed: pending. */
662
697
  .working::before {
663
698
  content: "";
664
699
  width: 28px;
665
- height: 3px;
666
- border-radius: 2px;
667
- background: linear-gradient(90deg, var(--accent) 0 40%, var(--rule) 40% 100%);
668
- background-size: 250% 100%;
669
- animation: sweep 1.3s ease-in-out infinite alternate;
700
+ border-top: 2px dashed var(--accent);
701
+ animation: lines 1.5s steps(1, end) infinite;
670
702
  }
671
- @keyframes sweep { from { background-position: 100% 0; } to { background-position: 0 0; } }
703
+ @keyframes lines { 0% { border-top-style: dotted; } 33% { border-top-style: dashed; } 67% { border-top-style: solid; } }
672
704
  @keyframes pulse { 50% { opacity: 0.3; } }
673
705
 
674
706
  /* Empty conversation. */
675
707
  .empty { padding-top: 11vh; }
676
- .empty .kicker {
708
+ /* The running head of the website's plates: the name, an italic middle, the mode in its colour. */
709
+ .empty .runhead {
677
710
  display: flex;
678
711
  justify-content: space-between;
679
- margin: 0 0 1.5rem;
680
- padding-bottom: 0.3rem;
712
+ align-items: baseline;
713
+ gap: 0.5rem 1.5rem;
714
+ margin: 0 0 1.75rem;
715
+ padding-bottom: 0.35rem;
681
716
  border-bottom: 1px solid var(--text);
682
717
  font-family: var(--serif);
683
- font-size: 1rem;
718
+ font-size: 1.02rem;
719
+ line-height: 1.3;
684
720
  font-variant-caps: all-small-caps;
685
721
  letter-spacing: 0.08em;
686
- color: var(--accent);
722
+ color: var(--muted);
687
723
  }
688
- .empty .kicker::before { content: "Sub-Sub"; color: var(--muted); order: -1; }
724
+ .empty .runhead .mid { font-style: italic; font-variant-caps: normal; letter-spacing: 0; text-align: center; }
725
+ .empty .runhead .mode { color: var(--accent); }
689
726
  .empty h1 {
690
727
  font-family: var(--serif);
691
728
  font-weight: 500;
692
- font-size: clamp(1.6rem, 3.2vw, 2.15rem);
693
- line-height: 1.2;
694
- letter-spacing: -0.015em;
695
- margin: 0 0 0.6rem;
729
+ font-size: clamp(2rem, 3.6vw, 2.75rem);
730
+ line-height: 1.06;
731
+ letter-spacing: -0.018em;
732
+ margin: 0 0 0.9rem;
733
+ text-wrap: balance;
696
734
  }
697
- .empty .lede { color: var(--muted); max-width: 34rem; margin: 0 0 1.75rem; }
735
+ .empty .lede { font-family: var(--serif); font-size: 1.08rem; line-height: 1.55; color: var(--muted); max-width: 36rem; margin: 0 0 2rem; text-wrap: pretty; }
698
736
  /* Suggestions: a short list of lines to start from, not a row of cards. */
699
- .chips { display: grid; max-width: 34rem; border-bottom: 1px solid var(--rule-strong); }
737
+ .chips { display: grid; max-width: 36rem; border-bottom: 1px solid var(--rule-strong); }
700
738
  .chips button {
701
739
  text-align: left;
702
740
  border: 0;
@@ -792,7 +830,7 @@ body > p.empty { text-align: center; color: var(--muted); padding: 30vh 1rem 0;
792
830
  }
793
831
  .modal-kicker::after { content: "Sub-Sub"; color: var(--muted); }
794
832
  .modal-kicker:empty { display: none; }
795
- .modal h2 { font-family: var(--serif); font-size: 1.25rem; font-weight: 600; line-height: 1.3; margin: 0 0 0.3rem; overflow-wrap: anywhere; }
833
+ .modal h2 { font-family: var(--serif); font-size: 1.4rem; font-weight: 500; line-height: 1.2; letter-spacing: -0.01em; margin: 0 0 0.35rem; overflow-wrap: anywhere; }
796
834
  .modal h3 { font-family: var(--serif); font-size: 0.98rem; font-weight: 400; font-variant-caps: all-small-caps; letter-spacing: 0.08em; color: var(--muted); margin: 1.4rem 0 0.5rem; }
797
835
  .modal .hint { color: var(--muted); margin: 0 0 1rem; font-size: 0.88rem; }
798
836
 
@@ -888,8 +926,7 @@ body > p.empty { text-align: center; color: var(--muted); padding: 30vh 1rem 0;
888
926
 
889
927
  /* ---------------------------------------------------------------- library search and notes */
890
928
 
891
- #notes-btn { font-family: var(--sans); }
892
- #notes-btn[aria-expanded="true"] { color: var(--accent); border-color: var(--accent); }
929
+ #notes-btn[aria-expanded="true"] { color: var(--text); text-decoration-line: underline; }
893
930
 
894
931
  .items { list-style: none; margin: 0; padding: 0; max-height: 18rem; overflow-y: auto; }
895
932
  .items:empty { display: none; }
@@ -910,11 +947,12 @@ body > p.empty { text-align: center; color: var(--muted); padding: 30vh 1rem 0;
910
947
  .session-tools button { width: auto; padding: 0.1rem 0.35rem; font-size: 0.7rem; color: var(--muted); }
911
948
  .session-tools button:hover { color: var(--text); background: var(--bg); }
912
949
 
950
+ /* A note is a page: the lighter plate field, as on the website. */
913
951
  .note-pane {
914
952
  grid-row: 2;
915
953
  grid-column: 3;
916
- border-left: 1px solid var(--rule);
917
- background: var(--surface);
954
+ border-left: 1px solid var(--rule-strong);
955
+ background: var(--raised);
918
956
  display: flex;
919
957
  flex-direction: column;
920
958
  min-height: 0;
@@ -923,7 +961,7 @@ body > p.empty { text-align: center; color: var(--muted); padding: 30vh 1rem 0;
923
961
  .note-pane[hidden] { display: none; }
924
962
  .note-head { display: flex; align-items: center; gap: 0.6rem; padding: 0.6rem 0.9rem; border-bottom: 1px solid var(--rule); }
925
963
  .note-name { flex: 1; min-width: 0; font-family: var(--mono); font-size: 0.8rem; font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
926
- .note-pane > .search { margin: 0.6rem 0.9rem 0; width: auto; }
964
+ .note-pane > .search { margin: 0.6rem 0.9rem 0; width: auto; background: var(--bg); }
927
965
  .note-body { flex: 1; overflow-y: auto; padding: 0.9rem 1.1rem 1.5rem; }
928
966
  .note-body .front { font-size: 0.74rem; color: var(--muted); margin: 0 0 1rem; }
929
967
  /* What a note was written from, drawn by line: solid for the full text, dashed for the abstract,
@@ -940,11 +978,25 @@ body > p.empty { text-align: center; color: var(--muted); padding: 30vh 1rem 0;
940
978
  #note-read[aria-pressed="true"] { color: var(--accent); text-decoration: underline; }
941
979
  .note-body .front summary { cursor: pointer; }
942
980
  .note-body .front pre { background: var(--bg); border: 1px solid var(--rule); border-radius: var(--radius); padding: 0.5rem 0.7rem; margin: 0.4rem 0 0; white-space: pre-wrap; overflow-wrap: anywhere; }
943
- .note-list { list-style: none; margin: 0; padding: 0; }
944
- .note-list button { width: 100%; text-align: left; padding: 0.4rem 0.5rem; border-radius: 0; display: grid; gap: 0.1rem; }
981
+ /* The notes, one folder per section under a small-capitals head, rows ruled as in a table. */
982
+ .note-list section + section { margin-top: 1.4rem; }
983
+ .note-list h3 {
984
+ margin: 0;
985
+ padding: 0 0.5rem 0.25rem;
986
+ border-bottom: 1px solid var(--text);
987
+ font-family: var(--serif);
988
+ font-size: 1rem;
989
+ font-weight: 400;
990
+ font-variant-caps: all-small-caps;
991
+ letter-spacing: 0.08em;
992
+ color: var(--muted);
993
+ }
994
+ .note-list ul { list-style: none; margin: 0; padding: 0; }
995
+ .note-list li + li { border-top: 1px solid var(--rule); }
996
+ .note-list button { width: 100%; text-align: left; padding: 0.5rem; border-radius: 0; display: flex; justify-content: space-between; align-items: baseline; gap: 1rem; }
945
997
  .note-list button:hover { background: var(--bg); }
946
- .note-list .rel { font-size: 0.84rem; overflow-wrap: anywhere; }
947
- .note-list .when { font-size: 0.72rem; color: var(--faint); }
998
+ .note-list .rel { min-width: 0; font-size: 0.9rem; overflow-wrap: anywhere; }
999
+ .note-list .when { flex: none; font-size: 0.74rem; color: var(--faint); font-variant-numeric: tabular-nums; }
948
1000
  .note-body img.note-img { display: block; max-width: 100%; height: auto; margin: 0.8rem 0; border: 1px solid var(--rule); border-radius: var(--radius); background: #fff; }
949
1001
  a[data-note] { color: var(--accent); text-decoration: underline; text-decoration-color: var(--accent-soft); text-underline-offset: 0.18em; cursor: pointer; }
950
1002
 
@@ -980,6 +1032,7 @@ a[data-note] { color: var(--accent); text-decoration: underline; text-decoration
980
1032
  @media (max-width: 30rem) {
981
1033
  .brand > span { display: none; }
982
1034
  .modes button { padding: 0.28rem 0.6rem; }
1035
+ .empty .runhead .mid { display: none; }
983
1036
  }
984
1037
 
985
1038
  @media (prefers-reduced-motion: reduce) {
package/web/app.js CHANGED
@@ -296,6 +296,16 @@ function hasCommand(name) {
296
296
  return commands.length === 0 || commands.some((c) => c.name === base);
297
297
  }
298
298
 
299
+ // Side panel sections and action groups fold at their heading; the folded ones are remembered on this computer.
300
+ const folded = new Set((localStorage.getItem("subsub-folded") || "").split(" ").filter(Boolean));
301
+ function setFold(box, on) {
302
+ box.classList.toggle("folded", on);
303
+ box.querySelector(".fold").setAttribute("aria-expanded", String(!on));
304
+ if (on) folded.add(box.dataset.fold);
305
+ else folded.delete(box.dataset.fold);
306
+ localStorage.setItem("subsub-folded", [...folded].join(" "));
307
+ }
308
+
299
309
  function renderActions() {
300
310
  const box = $("actions");
301
311
  box.replaceChildren();
@@ -305,8 +315,9 @@ function renderActions() {
305
315
  for (const [group, label] of groups) {
306
316
  const list = ACTIONS[group].filter(shown);
307
317
  if (!list.length) continue;
308
- box.append(el("p", { class: "group", text: t(label) }));
309
- for (const a of list) box.append(actionButton(a));
318
+ const g = el("div", { "data-fold": `actions-${group}` }, el("p", { class: "group" }, el("button", { type: "button", class: "fold", text: t(label) })), list.map(actionButton));
319
+ setFold(g, folded.has(g.dataset.fold));
320
+ box.append(g);
310
321
  }
311
322
  }
312
323
 
@@ -315,7 +326,10 @@ function actionButton(a) {
315
326
  type: "button",
316
327
  text: a[lang] ?? a.en,
317
328
  onclick: () => {
318
- if (a.item) return $("library-search").focus();
329
+ if (a.item) {
330
+ setFold($("library-search").closest("[data-fold]"), false);
331
+ return $("library-search").focus();
332
+ }
319
333
  closeSide();
320
334
  if (a.send) sendMessage(`/${a.cmd}`);
321
335
  else {
@@ -407,16 +421,34 @@ function renderNoteList() {
407
421
  const q = $("note-filter").value.trim().toLowerCase();
408
422
  const list = notesList.filter((n) => !q || n.rel.toLowerCase().includes(q));
409
423
  const fmt = new Intl.DateTimeFormat(lang === "pt" ? "pt-PT" : "en-GB", { day: "numeric", month: "short", year: "numeric" });
424
+ // One group per folder (Inbox, Literature, ...), newest first inside each, as the server sorts them.
425
+ const groups = new Map();
426
+ for (const n of list) {
427
+ const [folder, ...rest] = n.rel.split(/[\\/]/);
428
+ if (!groups.has(folder)) groups.set(folder, []);
429
+ groups.get(folder).push({ ...n, name: rest.join("/").replace(/\.md$/i, "") || folder });
430
+ }
410
431
  body.replaceChildren(
411
432
  list.length
412
433
  ? el(
413
- "ul",
434
+ "div",
414
435
  { class: "note-list" },
415
- list.map((n) =>
436
+ [...groups].map(([folder, notes]) =>
416
437
  el(
417
- "li",
438
+ "section",
418
439
  {},
419
- el("button", { type: "button", onclick: () => openNote(n.path) }, el("span", { class: "rel", text: n.rel }), el("span", { class: "when", text: fmt.format(new Date(n.modified)) })),
440
+ el("h3", { text: folder }),
441
+ el(
442
+ "ul",
443
+ {},
444
+ notes.map((n) =>
445
+ el(
446
+ "li",
447
+ {},
448
+ el("button", { type: "button", title: n.rel, onclick: () => openNote(n.path) }, el("span", { class: "rel", text: n.name }), el("span", { class: "when", text: fmt.format(new Date(n.modified)) })),
449
+ ),
450
+ ),
451
+ ),
420
452
  ),
421
453
  ),
422
454
  )
@@ -692,7 +724,7 @@ function renderEmpty() {
692
724
  el(
693
725
  "div",
694
726
  { class: "empty" },
695
- el("p", { class: "kicker", text: t(mode()) }),
727
+ el("p", { class: "runhead" }, el("span", { class: "nobr", text: "Sub-Sub" }), el("span", { class: "mid", text: t("tagline") }), el("span", { class: "mode", text: t(mode()) })),
696
728
  el("h1", { text: t("emptyTitle") }),
697
729
  el("p", { class: "lede", text: t(mode() === "librarian" ? "emptyLibrarian" : "emptyResearcher") }),
698
730
  setupList(),
@@ -732,7 +764,7 @@ function userMessage(text) {
732
764
  if (text.length > 700) {
733
765
  const first = text.split("\n").find((l) => l.trim()) ?? "";
734
766
  bubble = el("details", { class: "bubble" }, el("summary", { text: first.length > 120 ? `${first.slice(0, 117)}...` : first }), el("div", { text }));
735
- } else bubble = el("div", { class: "bubble", text });
767
+ } else bubble = el("div", { class: /^\/\S/.test(text) ? "bubble cmd" : "bubble", text });
736
768
  log.append(el("div", { class: "msg user" }, bubble));
737
769
  scrollDown(true);
738
770
  }
@@ -1517,6 +1549,11 @@ $("theme-btn-side")?.addEventListener("click", () => {
1517
1549
  closeSide();
1518
1550
  toggleTheme();
1519
1551
  });
1552
+ $("side").addEventListener("click", (e) => {
1553
+ const box = e.target.closest(".fold")?.closest("[data-fold]");
1554
+ if (box) setFold(box, !box.classList.contains("folded"));
1555
+ });
1556
+ for (const box of document.querySelectorAll(".side section[data-fold]")) setFold(box, folded.has(box.dataset.fold));
1520
1557
  $("session-search")?.addEventListener("input", (e) => renderSessionsList(e.target.value));
1521
1558
  $("library-search").addEventListener("input", searchLibrary);
1522
1559
  $("notes-btn").addEventListener("click", () => ($("note-pane").hidden ? showNoteList() : showPane(false)));
package/web/i18n.js CHANGED
@@ -84,6 +84,7 @@ export const STRINGS = {
84
84
  saved: "Saved.",
85
85
  busy: "Wait until Sub-Sub finishes.",
86
86
  emptyTitle: "What would you like to do?",
87
+ tagline: "A research assistant for Zotero",
87
88
  quit: "Close Sub-Sub",
88
89
  quitDone: "Sub-Sub is closed. You can close this tab.",
89
90
  earlier: "Earlier messages were summarised.",
@@ -226,6 +227,7 @@ export const STRINGS = {
226
227
  saved: "Guardado.",
227
228
  busy: "Espere que o Sub-Sub termine.",
228
229
  emptyTitle: "O que quer fazer?",
230
+ tagline: "Um assistente de investigação para o Zotero",
229
231
  quit: "Fechar o Sub-Sub",
230
232
  quitDone: "O Sub-Sub está fechado. Pode fechar este separador.",
231
233
  earlier: "As mensagens anteriores foram resumidas.",