@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/CHANGELOG.md +13 -0
- package/README.md +5 -1
- package/dist/cli.js +10 -0
- package/dist/rpc.js +185 -0
- package/dist/screens.js +111 -0
- package/dist/subsub.js +13 -6
- package/dist/tui-view.js +228 -0
- package/dist/tui.js +651 -0
- package/dist/web.js +9 -158
- package/package.json +1 -1
- package/src/cli.ts +9 -0
- package/src/rpc.ts +198 -0
- package/src/screens.ts +156 -0
- package/src/subsub.ts +12 -6
- package/src/tui-view.ts +270 -0
- package/src/tui.ts +710 -0
- package/src/web.ts +10 -160
- package/web/app.css +106 -53
- package/web/app.js +46 -9
- package/web/i18n.js +2 -0
- package/web/index.html +7 -7
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,
|
|
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
|
-
|
|
174
|
-
|
|
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
|
-
|
|
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 (
|
|
214
|
+
if (isDialog(ev)) {
|
|
348
215
|
dialogs.set(ev.id, ev);
|
|
349
216
|
broadcast(ev);
|
|
350
|
-
} else if (
|
|
351
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
171
|
+
/* Top bar text buttons, as the website's navigation: muted, inked and underlined on hover or when open. */
|
|
168
172
|
.chip-btn {
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
-
|
|
177
|
-
|
|
178
|
-
|
|
177
|
+
white-space: nowrap;
|
|
178
|
+
text-underline-offset: 0.35em;
|
|
179
|
+
text-decoration-thickness: 1px;
|
|
179
180
|
}
|
|
180
|
-
.chip-btn:hover { color: var(--text);
|
|
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
|
|
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.
|
|
464
|
-
line-height: 1.
|
|
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.
|
|
476
|
-
margin: 1.
|
|
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.
|
|
480
|
-
.md h2 { font-size: 1.
|
|
481
|
-
.md h3 { font-size:
|
|
482
|
-
.md h4 { font-size: 0.
|
|
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
|
-
|
|
666
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
680
|
-
|
|
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:
|
|
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(--
|
|
722
|
+
color: var(--muted);
|
|
687
723
|
}
|
|
688
|
-
.empty .
|
|
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(
|
|
693
|
-
line-height: 1.
|
|
694
|
-
letter-spacing: -0.
|
|
695
|
-
margin: 0 0 0.
|
|
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:
|
|
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:
|
|
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.
|
|
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 {
|
|
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(--
|
|
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
|
-
|
|
944
|
-
.note-list
|
|
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.
|
|
947
|
-
.note-list .when { font-size: 0.
|
|
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
|
-
|
|
309
|
-
|
|
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)
|
|
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
|
-
"
|
|
434
|
+
"div",
|
|
414
435
|
{ class: "note-list" },
|
|
415
|
-
|
|
436
|
+
[...groups].map(([folder, notes]) =>
|
|
416
437
|
el(
|
|
417
|
-
"
|
|
438
|
+
"section",
|
|
418
439
|
{},
|
|
419
|
-
el("
|
|
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: "
|
|
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.",
|