@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 CHANGED
@@ -2,6 +2,19 @@
2
2
 
3
3
  Sub-Sub follows the version numbers of npm: a change in the second number (0.13) adds features or changes how you use it; a change in the third (0.13.1) only fixes.
4
4
 
5
+ ## 0.23.0 (2026-10-24)
6
+
7
+ - The browser view is set like the website. Replies and notes have the website's headings (a fine rule above each section), its numbered steps and a slightly larger text. A note opens on the lighter field of a page, and the list of notes is grouped by folder under small-capital heads, with the date on the right. The empty conversation has the website's running head and a larger heading. Notes and the model in the top bar are plain text buttons, as in the website's menu. A command you send (`/read ...`) is shown in the mono. The working line is drawn dotted, dashed and solid, as in the terminal, instead of a moving gradient. Scrollbars are thin. On a phone, Notes is no longer cut to "No..." (the theme button is in the side panel there).
8
+ - In the browser view, the sections of the side panel (Library, Actions, Conversations) and the two groups of actions (Research, Library) fold at their heading. With the others folded, the list of conversations takes the whole panel. The browser remembers which ones you folded.
9
+
10
+ ## 0.22.0 (2026-10-24)
11
+
12
+ - `subsub tui`: Sub-Sub on the whole terminal. It is a full-screen client of pi's RPC mode, like the browser view, with the look of `subsub`: a conversation that scrolls (mouse wheel, PgUp and PgDn), a side panel from 110 columns (mode, library, keys, earlier conversations), an editor that completes commands, and the footer. Alt+N, Alt+L, Alt+A and Alt+M open the notes, the library search and the actions, and switch the mode, as in `subsub`. `/sessions` and `/new` change the conversation, `/model` chooses the model, `/restart` starts pi again in the same conversation, `/quit` leaves. Ctrl+O shows the output of tool rows, Esc stops a running answer, and Ctrl+C twice quits. `--light` or `--dark` set the colours when the terminal does not say (the default is dark), and `--librarian` starts in the Librarian.
13
+ - In `subsub tui`, every question that Sub-Sub asks is answered on screen: the approval screen for a library change (`y` applies it, `n` or Esc leaves it, Enter does nothing, keys in the first half second are ignored), lists and typed answers. Nothing is applied without a yes. pi's own screens (`/login`, `/settings`, `/tree`, `/resume`, `/fork`, `/share`, `/export`) are not in it: use `subsub` or `subsub web` for them.
14
+ - Shortcuts: F2, F3, F4 and F5 open the notes, the library search and the actions, and switch the mode, in `subsub` and in `subsub tui`. Alt+N, Alt+L, Alt+A and Alt+M (0.21.0) need a terminal that sends Alt as Escape plus the letter; macOS Terminal and iTerm2 do not by default (Option types a symbol), so on a Mac they did nothing. On a Mac the F keys need Fn unless you set them as standard function keys in System Settings.
15
+ - Fixed: when pi is started with Sub-Sub's own folder as its folder (the browser view and `subsub tui` do this), an empty `auth.json` could be replaced by a link to itself, and the model then could not read its credentials ("Credential store read failed ... ELOOP"). It is no longer linked.
16
+ - Internal: the code that talks to pi in RPC mode (`Agent`, the library state and search, the command hints) is in `src/rpc.ts`, shared by the browser view and the terminal client. Tests: screen snapshots of the client at 80 and 120 columns, and a test that runs `subsub tui` in a terminal with a fake model and a fake Zotero (macOS and Linux).
17
+
5
18
  ## 0.21.0 (2026-10-23)
6
19
 
7
20
  A new look for the terminal. It is on by default in `subsub`; set `"look": false` in the settings to go back to the old screen.
package/README.md CHANGED
@@ -80,13 +80,16 @@ Sub-Sub runs its Zotero server, [zotero-local-mcp](https://github.com/tiagojct/z
80
80
  subsub web # in the browser
81
81
  subsub # in the terminal
82
82
  subsub --librarian # in the terminal, in the Librarian
83
+ subsub tui # on the whole terminal (full screen)
83
84
  subsub -c # continue the last conversation
84
85
  subsub --help # all commands
85
86
  ```
86
87
 
87
88
  In the browser view, connect a model provider with the model button. In the terminal, type `/login`.
88
89
 
89
- The terminal has its own look, drawn like the website: a footer with the mode, the library, the model, the context in use and the cost; Sub-Sub's tools in plain words (`search the library: asthma`); and an approval screen for library changes (`y` applies, `n` or Esc leaves the library as it is, Enter does nothing). `/notes`, `/library` and `/actions` open screens for your notes, a library search and the actions of the browser view; Alt+N, Alt+L, Alt+A and Alt+M (mode) are shortcuts. Nothing is red: a failure is ink with a hollow mark and a word, and a removal is struck through. For terminals that cannot draw box glyphs, set `SUBSUB_ASCII=1`; `SUBSUB_REDUCE_MOTION=1` stops the working indicator from moving; `"look": false` in the settings brings back the plain screen.
90
+ The terminal has its own look, drawn like the website: a footer with the mode, the library, the model, the context in use and the cost; Sub-Sub's tools in plain words (`search the library: asthma`); and an approval screen for library changes (`y` applies, `n` or Esc leaves the library as it is, Enter does nothing). `/notes`, `/library` and `/actions` open screens for your notes, a library search and the actions of the browser view; F2, F3, F4 and F5 (mode) are shortcuts; so are Alt+N, Alt+L, Alt+A and Alt+M where your terminal sends Alt (in macOS Terminal and iTerm2, Option types a symbol unless you turn on "Use Option as Meta key", and a Mac needs Fn with the F key unless the F keys are set as standard). Nothing is red: a failure is ink with a hollow mark and a word, and a removal is struck through. For terminals that cannot draw box glyphs, set `SUBSUB_ASCII=1`; `SUBSUB_REDUCE_MOTION=1` stops the working indicator from moving; `"look": false` in the settings brings back the plain screen.
91
+
92
+ `subsub tui` puts Sub-Sub on the whole terminal, as the browser view puts it in the browser: a conversation that scrolls, a side panel from 110 columns, the editor and the footer, with the same colours and screens. Besides the commands above, `/sessions` and `/new` change the conversation, `/model` chooses the model, `/restart` starts pi again in the same conversation, and `/quit` leaves (Ctrl+C twice also quits). `--light` or `--dark` set the colours when your terminal does not say. It does not have pi's own screens (`/login`, `/settings`, `/tree`, `/resume`): use `subsub` or `subsub web` for them.
90
93
 
91
94
  The browser view also has a library search (each result can start `/read` or `/lit-note`), a notes pane that shows the notes of your Sub-Sub folder next to the conversation (note paths and `[[links]]` in replies open there), argument hints for the commands, and rename, delete (to `~/.subsub/trash`) and export for conversations.
92
95
 
@@ -98,6 +101,7 @@ Commands in Sub-Sub:
98
101
  | `/subsub` | Zotero connection, mode, model and library counts |
99
102
  | `/history`, `/undo [id]` | Recent library changes; undo the last one or one by its id |
100
103
  | `/notes`, `/library`, `/actions` | Terminal screens: the notes in your Sub-Sub folder (the chosen note beside the list from 100 columns); a library search as you type (Enter starts `/read`, Ctrl+N `/lit-note`); the actions of the browser view as a list |
104
+ | `/sessions`, `/new`, `/model`, `/restart`, `/quit` | In `subsub tui`: earlier conversations; a new one; the model; pi again in the same conversation; leave |
101
105
  | `/feedback [what happened]` | A draft bug report in `Inbox/`, with versions, `subsub doctor` and the last error, and without your folders, emails or keys. You post it on GitHub; Sub-Sub sends nothing |
102
106
  | `/ai-statement [uses]` | A draft statement on the use of AI for a manuscript, with the Sub-Sub version, its DOI and your models |
103
107
  | `/tag-batch [size]` | Tag items that have no topic, with your tag list |
package/dist/cli.js CHANGED
@@ -14,6 +14,7 @@
14
14
  * - `subsub init` sets Sub-Sub up; `subsub doctor` checks the setup.
15
15
  * - `subsub web` (or `subsub serve`) opens Sub-Sub in the browser;
16
16
  * `subsub shortcut` adds a Sub-Sub shortcut that does the same.
17
+ * - `subsub tui` is Sub-Sub on the whole terminal: a full-screen client of pi's RPC mode, like the web view.
17
18
  */
18
19
  var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
19
20
  if (typeof path === "string" && /^\.\.?\//.test(path)) {
@@ -106,6 +107,9 @@ function isEmptyAuth(file) {
106
107
  export function shareAuth(agentDir, piAgentDir) {
107
108
  const mine = join(agentDir, "auth.json");
108
109
  const theirs = join(piAgentDir, "auth.json");
110
+ // The web view and `subsub tui` start pi with PI_CODING_AGENT_DIR set to Sub-Sub's folder: a link to itself would make auth.json unreadable.
111
+ if (real(mine) === real(theirs))
112
+ return false;
109
113
  if (!existsSync(theirs))
110
114
  return false;
111
115
  try {
@@ -149,6 +153,7 @@ Usage:
149
153
  subsub Start Sub-Sub in the terminal
150
154
  subsub --librarian Start in the Librarian, which only manages the library
151
155
  subsub web Open Sub-Sub in the browser
156
+ subsub tui Sub-Sub on the whole terminal: transcript, side panel, footer (--light or --dark for the colours)
152
157
  subsub init Set up or change the settings (folder, tag list, models)
153
158
  subsub doctor Check the set-up; each line marked FIX says what to do
154
159
  subsub usage The pilot's usage log (if turned on): a summary and where the file is
@@ -189,6 +194,11 @@ export async function run(argv = process.argv.slice(2)) {
189
194
  process.exitCode = await webMain([...args.slice(1), ...(here ? ["--here"] : [])], env);
190
195
  return;
191
196
  }
197
+ if (args[0] === "tui") {
198
+ const { runTui } = await import("./tui.js");
199
+ // The terminal is given back before this returns; exit so that nothing else keeps the process alive.
200
+ process.exit(await runTui([...args.slice(1), ...(here ? ["--here"] : [])], env));
201
+ }
192
202
  if (args[0] === "shortcut") {
193
203
  const { shortcutMain } = await import("./shortcut.js");
194
204
  process.exitCode = shortcutMain(args.slice(1), env);
package/dist/rpc.js ADDED
@@ -0,0 +1,185 @@
1
+ /**
2
+ * pi in RPC mode, for the clients that show Sub-Sub: the web view (src/web.ts) and the full-screen terminal client (src/tui.ts). One child
3
+ * process, strict JSONL (records end at LF only), requests answered by id, every other record handed to the listeners. Sub-Sub's extension
4
+ * tells its clients what pi's RPC protocol has no place for through two status texts: the state of the library ("subsub.web") and the
5
+ * answer to a library search ("subsub.find").
6
+ */
7
+ import { spawn } from "node:child_process";
8
+ import { readFileSync } from "node:fs";
9
+ import { StringDecoder } from "node:string_decoder";
10
+ import { field, frontMatter } from "./notes.js";
11
+ /** pi as a child process in RPC mode. Strict JSONL: records end at LF only. */
12
+ export class Agent {
13
+ proc;
14
+ decoder = new StringDecoder("utf8");
15
+ buf = "";
16
+ n = 0;
17
+ pending = new Map();
18
+ listeners = new Set();
19
+ stderr = "";
20
+ onExit;
21
+ command;
22
+ args;
23
+ cwd;
24
+ env;
25
+ constructor(command, args, cwd, env) {
26
+ this.command = command;
27
+ this.args = args;
28
+ this.cwd = cwd;
29
+ this.env = env;
30
+ }
31
+ start(extra = []) {
32
+ this.buf = "";
33
+ this.decoder = new StringDecoder("utf8");
34
+ const proc = spawn(this.command, [...this.args, ...extra], { cwd: this.cwd, env: this.env, stdio: ["pipe", "pipe", "pipe"], windowsHide: true });
35
+ this.proc = proc;
36
+ proc.stdout.on("data", (chunk) => this.feed(this.decoder.write(chunk)));
37
+ proc.stderr.on("data", (d) => {
38
+ this.stderr = (this.stderr + d.toString()).slice(-20_000);
39
+ });
40
+ proc.once("exit", (code) => {
41
+ if (this.proc !== proc)
42
+ return;
43
+ this.proc = undefined;
44
+ for (const p of this.pending.values()) {
45
+ clearTimeout(p.timer);
46
+ p.reject(new Error(`pi stopped (code ${code}). ${this.stderr.slice(-500)}`));
47
+ }
48
+ this.pending.clear();
49
+ this.onExit?.(code);
50
+ });
51
+ }
52
+ feed(text) {
53
+ this.buf += text;
54
+ let i;
55
+ while ((i = this.buf.indexOf("\n")) >= 0) {
56
+ const line = this.buf.slice(0, i).replace(/\r$/, "");
57
+ this.buf = this.buf.slice(i + 1);
58
+ if (!line.trim())
59
+ continue;
60
+ let rec;
61
+ try {
62
+ rec = JSON.parse(line);
63
+ }
64
+ catch {
65
+ continue;
66
+ }
67
+ if (rec.type === "response" && typeof rec.id === "string" && this.pending.has(rec.id)) {
68
+ const p = this.pending.get(rec.id);
69
+ this.pending.delete(rec.id);
70
+ clearTimeout(p.timer);
71
+ if (rec.success === false)
72
+ p.reject(new Error(rec.error ?? `${rec.command} failed`));
73
+ else
74
+ p.resolve(rec.data);
75
+ continue;
76
+ }
77
+ for (const l of this.listeners)
78
+ l(rec);
79
+ }
80
+ }
81
+ on(l) {
82
+ this.listeners.add(l);
83
+ return () => this.listeners.delete(l);
84
+ }
85
+ write(rec) {
86
+ const stdin = this.proc?.stdin;
87
+ if (!stdin || !stdin.writable)
88
+ throw new Error("pi is not running");
89
+ stdin.write(`${JSON.stringify(rec)}\n`);
90
+ }
91
+ request(cmd, timeoutMs = 30_000) {
92
+ const id = `w${++this.n}`;
93
+ return new Promise((resolvePromise, reject) => {
94
+ const timer = setTimeout(() => {
95
+ this.pending.delete(id);
96
+ reject(new Error(`pi did not answer ${String(cmd.type)} in time`));
97
+ }, timeoutMs);
98
+ this.pending.set(id, { resolve: resolvePromise, reject, timer });
99
+ try {
100
+ this.write({ ...cmd, id });
101
+ }
102
+ catch (err) {
103
+ clearTimeout(timer);
104
+ this.pending.delete(id);
105
+ reject(err);
106
+ }
107
+ });
108
+ }
109
+ async stop() {
110
+ const proc = this.proc;
111
+ if (!proc)
112
+ return;
113
+ this.proc = undefined;
114
+ for (const p of this.pending.values()) {
115
+ clearTimeout(p.timer);
116
+ p.reject(new Error("pi was stopped"));
117
+ }
118
+ this.pending.clear();
119
+ await new Promise((done) => {
120
+ const t = setTimeout(() => {
121
+ proc.kill("SIGKILL");
122
+ done();
123
+ }, 3000);
124
+ proc.once("exit", () => {
125
+ clearTimeout(t);
126
+ done();
127
+ });
128
+ proc.kill();
129
+ });
130
+ }
131
+ }
132
+ /** The dialogs that wait for an answer (extension_ui_response): the rest of the extension UI requests are notices. */
133
+ export function isDialog(ev) {
134
+ return ev?.type === "extension_ui_request" && ["select", "confirm", "input", "editor"].includes(ev.method);
135
+ }
136
+ /** What the extension sends as status texts: the state of the library and the answer to the last library search. */
137
+ export class Side {
138
+ state = null;
139
+ find = null;
140
+ /** Decode an extension_ui_request; tells which of the two it was, or undefined. */
141
+ take(ev) {
142
+ if (ev?.type !== "extension_ui_request" || ev.method !== "setStatus")
143
+ return undefined;
144
+ const decode = () => {
145
+ try {
146
+ return ev.statusText ? JSON.parse(ev.statusText) : null;
147
+ }
148
+ catch {
149
+ return null;
150
+ }
151
+ };
152
+ if (ev.statusKey === "subsub.web") {
153
+ this.state = decode();
154
+ return "state";
155
+ }
156
+ if (ev.statusKey === "subsub.find") {
157
+ this.find = decode();
158
+ return "find";
159
+ }
160
+ return undefined;
161
+ }
162
+ }
163
+ /** Search the library through the extension (the hidden /subsub-find command); the answer comes back as a status text. */
164
+ export async function findLibrary(agent, side, q) {
165
+ if (!q)
166
+ return { q, items: [] };
167
+ await agent.request({ type: "prompt", message: `/subsub-find ${q}` });
168
+ return side.find?.q === q ? side.find : { q, items: [] };
169
+ }
170
+ function readText(path) {
171
+ try {
172
+ return readFileSync(path, "utf8");
173
+ }
174
+ catch {
175
+ return "";
176
+ }
177
+ }
178
+ /** RPC has no argument hints: read them from the front matter of the prompt files. */
179
+ export function withHints(commands) {
180
+ return commands.map((c) => {
181
+ const front = c.source === "prompt" && c.sourceInfo?.path ? frontMatter(readText(c.sourceInfo.path)) : undefined;
182
+ const hint = front && field(front, "argument-hint");
183
+ return hint ? { ...c, hint } : c;
184
+ });
185
+ }
package/dist/screens.js CHANGED
@@ -13,12 +13,14 @@ export const SCREEN_WORDS = {
13
13
  hintNotes: "up and down: choose type: filter PgUp PgDn: scroll ctrl+o: open Esc: close", hintRead: "up and down: scroll ctrl+o: open Esc: back",
14
14
  library: "Library", typeToSearch: "Type to search your library.", searching: "Searching...", noItems: "Nothing found.", failed: "The search failed",
15
15
  hintLibrary: "up and down: choose Enter: read ctrl+n: note Esc: close", hintLibrarian: "up and down: choose Enter: copy the key Esc: close",
16
+ hintChoice: "up and down: choose Enter: select type: filter Esc: cancel", noChoice: "Nothing matches.", hintInput: "Enter: send Esc: cancel",
16
17
  },
17
18
  pt: {
18
19
  notes: "Notas", of: "de", filter: "filtro", none: "Ainda não há notas. O Sub-Sub escreve-as em Literature/, Research/ e Syntheses/.", noMatch: "Nenhuma nota corresponde.", empty: "(nota vazia)",
19
20
  hintNotes: "cima e baixo: escolher escrever: filtrar PgUp PgDn: deslocar ctrl+o: abrir Esc: fechar", hintRead: "cima e baixo: deslocar ctrl+o: abrir Esc: voltar",
20
21
  library: "Biblioteca", typeToSearch: "Escreva para pesquisar na biblioteca.", searching: "A pesquisar...", noItems: "Nada encontrado.", failed: "A pesquisa falhou",
21
22
  hintLibrary: "cima e baixo: escolher Enter: ler ctrl+n: nota Esc: fechar", hintLibrarian: "cima e baixo: escolher Enter: copiar a chave Esc: fechar",
23
+ hintChoice: "cima e baixo: escolher Enter: selecionar escrever: filtrar Esc: cancelar", noChoice: "Nada corresponde.", hintInput: "Enter: enviar Esc: cancelar",
22
24
  },
23
25
  };
24
26
  const LEFT = 36;
@@ -312,3 +314,112 @@ export class LibraryView {
312
314
  this.o.redraw?.();
313
315
  }
314
316
  }
317
+ export function choiceLines(o) {
318
+ const g = o.glyphs ?? UNICODE;
319
+ const p = o.paint;
320
+ const w = Math.max(20, o.width);
321
+ const words = SCREEN_WORDS[o.lang];
322
+ const room = Math.max(1, o.rows - 4);
323
+ const out = [p.mark(cut(` ${o.title}`, w)) + (o.filter ? p.muted(cut(` ${words.filter}: ${o.filter}`, Math.max(0, w - o.title.length - 1))) : ""), rule(w, "solid", p.muted, g)];
324
+ if (!o.choices.length)
325
+ out.push(p.muted(cut(` ${words.noChoice}`, w)));
326
+ const start = Math.max(0, Math.min(o.selected - Math.floor(room / 2), o.choices.length - room));
327
+ for (const [k, c] of o.choices.slice(start, start + room).entries()) {
328
+ const chosen = start + k === o.selected;
329
+ const hint = c.hint ? ` ${c.hint}` : "";
330
+ const label = cut(c.label, Math.max(8, w - 2 - hint.length));
331
+ out.push(`${chosen ? p.mark(g.pointer) : " "} ${chosen ? p.text(label) : p.muted(label)}${p.dim(cut(hint, Math.max(0, w - 2 - label.length)))}`);
332
+ }
333
+ out.push(rule(w, "solid", p.muted, g), ...wrapTextWithAnsi(` ${words.hintChoice}`, w).map((l) => p.muted(l)));
334
+ return out;
335
+ }
336
+ export class ChoiceView {
337
+ o;
338
+ filter = "";
339
+ selected = 0;
340
+ answered = false;
341
+ constructor(o) {
342
+ this.o = o;
343
+ }
344
+ shown() {
345
+ const f = this.filter.toLowerCase();
346
+ return f ? this.o.choices.filter((c) => `${c.label} ${c.hint ?? ""}`.toLowerCase().includes(f)) : this.o.choices;
347
+ }
348
+ render(width) {
349
+ const rows = Math.max(8, Math.floor(((this.o.rows?.() ?? 24) * 8) / 10));
350
+ return fill(choiceLines({ title: this.o.title, choices: this.shown(), selected: this.selected, filter: this.filter, width, rows, lang: this.o.lang, paint: this.o.paint, glyphs: this.o.glyphs }), width);
351
+ }
352
+ invalidate() { }
353
+ handleInput(data) {
354
+ if (this.answered)
355
+ return;
356
+ const list = this.shown();
357
+ if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) {
358
+ this.answered = true;
359
+ return this.o.cancel();
360
+ }
361
+ if (matchesKey(data, "enter")) {
362
+ const c = list[this.selected];
363
+ if (!c)
364
+ return;
365
+ this.answered = true;
366
+ return this.o.choose(c.value);
367
+ }
368
+ if (matchesKey(data, "up"))
369
+ this.selected = Math.max(0, this.selected - 1);
370
+ else if (matchesKey(data, "down"))
371
+ this.selected = Math.min(Math.max(0, list.length - 1), this.selected + 1);
372
+ else if (matchesKey(data, "backspace")) {
373
+ this.filter = this.filter.slice(0, -1);
374
+ this.selected = 0;
375
+ }
376
+ else if (printable(data)) {
377
+ this.filter += data;
378
+ this.selected = 0;
379
+ }
380
+ else
381
+ return;
382
+ this.o.redraw?.();
383
+ }
384
+ }
385
+ export function inputLines(o) {
386
+ const g = o.glyphs ?? UNICODE;
387
+ const p = o.paint;
388
+ const w = Math.max(20, o.width);
389
+ const shown = o.text ? p.text(cut(o.text, w - 5)) + p.mark("_") : p.dim(cut(o.placeholder ?? "", w - 5)) + p.mark("_");
390
+ return [p.mark(cut(` ${o.title}`, w)), rule(w, "solid", p.muted, g), ` ${p.muted(">")} ${shown}`, rule(w, "solid", p.muted, g), p.muted(cut(` ${SCREEN_WORDS[o.lang].hintInput}`, w))];
391
+ }
392
+ export class InputView {
393
+ o;
394
+ text;
395
+ answered = false;
396
+ constructor(o) {
397
+ this.o = o;
398
+ this.text = o.prefill ?? "";
399
+ }
400
+ render(width) {
401
+ return fill(inputLines({ title: this.o.title, placeholder: this.o.placeholder, text: this.text, width, lang: this.o.lang, paint: this.o.paint, glyphs: this.o.glyphs }), width);
402
+ }
403
+ invalidate() { }
404
+ handleInput(data) {
405
+ if (this.answered)
406
+ return;
407
+ if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) {
408
+ this.answered = true;
409
+ return this.o.cancel();
410
+ }
411
+ if (matchesKey(data, "enter")) {
412
+ this.answered = true;
413
+ return this.o.submit(this.text);
414
+ }
415
+ if (matchesKey(data, "backspace"))
416
+ this.text = this.text.slice(0, -1);
417
+ else if (matchesKey(data, "ctrl+u"))
418
+ this.text = "";
419
+ else if (printable(data))
420
+ this.text += data;
421
+ else
422
+ return;
423
+ this.o.redraw?.();
424
+ }
425
+ }
package/dist/subsub.js CHANGED
@@ -143,6 +143,8 @@ export async function createSubsub(pi, deps = {}) {
143
143
  const lookOn = (ctx) => standalone && cfg.look !== false && ctx.mode === "tui";
144
144
  /** True under `subsub web`: pi runs in RPC mode and the web view shows the state that the terminal header shows. */
145
145
  const webView = process.env.SUBSUB_WEB === "1";
146
+ /** The state of the library goes to the client as a status text: for the web view and for the full-screen terminal client (subsub tui). */
147
+ const sideChannel = webView || process.env.SUBSUB_TUI === "1";
146
148
  let lastError;
147
149
  /** The last provider or tool error of the session, for /feedback. */
148
150
  let recentError;
@@ -153,7 +155,7 @@ export async function createSubsub(pi, deps = {}) {
153
155
  let webCtx;
154
156
  function emitWeb() {
155
157
  const ctx = webCtx;
156
- if (!webView || !ctx || ctx.mode !== "rpc")
158
+ if (!sideChannel || !ctx || ctx.mode !== "rpc")
157
159
  return;
158
160
  const down = bridge ? Object.keys(bridge.errors) : ["zotero", "scholar"];
159
161
  const state = {
@@ -418,7 +420,7 @@ export async function createSubsub(pi, deps = {}) {
418
420
  setFooter(ctx);
419
421
  void refreshLibrary();
420
422
  }
421
- else if (webView) {
423
+ else if (sideChannel) {
422
424
  void refreshLibrary();
423
425
  }
424
426
  if (!ctx.model && !webView)
@@ -784,10 +786,15 @@ export async function createSubsub(pi, deps = {}) {
784
786
  pi.registerCommand("notes", { description: "Sub-Sub: the notes in your Sub-Sub folder", handler: async (_args, ctx) => openNotes(ctx) });
785
787
  pi.registerCommand("library", { description: "Sub-Sub: search your Zotero library as you type", handler: async (_args, ctx) => openLibrary(ctx) });
786
788
  pi.registerCommand("actions", { description: "Sub-Sub: the actions of the web view, as a list", handler: async (_args, ctx) => openActions(ctx) });
787
- pi.registerShortcut?.("alt+n", { description: "Sub-Sub: notes", handler: (ctx) => openNotes(ctx) });
788
- pi.registerShortcut?.("alt+l", { description: "Sub-Sub: library search", handler: (ctx) => openLibrary(ctx) });
789
- pi.registerShortcut?.("alt+a", { description: "Sub-Sub: actions", handler: (ctx) => openActions(ctx) });
790
- pi.registerShortcut?.("alt+m", { description: "Sub-Sub: switch between the Researcher and the Librarian", handler: (ctx) => switchTo(ctx, mode === "researcher" ? "librarian" : "researcher") });
789
+ // Alt works when the terminal sends it as Escape plus the letter; macOS Terminal and iTerm2 do not by default (Option types a symbol), so each also has a function key.
790
+ const keys = (names, description, handler) => {
791
+ for (const k of names)
792
+ pi.registerShortcut?.(k, { description, handler });
793
+ };
794
+ keys(["alt+n", "f2"], "Sub-Sub: notes", (ctx) => openNotes(ctx));
795
+ keys(["alt+l", "f3"], "Sub-Sub: library search", (ctx) => openLibrary(ctx));
796
+ keys(["alt+a", "f4"], "Sub-Sub: actions", (ctx) => openActions(ctx));
797
+ keys(["alt+m", "f5"], "Sub-Sub: switch between the Researcher and the Librarian", (ctx) => switchTo(ctx, mode === "researcher" ? "librarian" : "researcher"));
791
798
  }
792
799
  pi.registerCommand("subsub", {
793
800
  description: "Sub-Sub: Zotero connection and library overview",