@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/dist/web.js CHANGED
@@ -13,18 +13,15 @@
13
13
  * - Without an open page for a while (and with nothing waiting), the server
14
14
  * stops by itself, so a shortcut can start it without a terminal.
15
15
  */
16
- import { spawn } from "node:child_process";
17
16
  import { randomBytes, timingSafeEqual } from "node:crypto";
18
17
  import { createReadStream, existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync, appendFileSync } from "node:fs";
19
18
  import { createServer } from "node:http";
20
19
  import { homedir } from "node:os";
21
20
  import { basename, dirname, extname, join, normalize, relative, sep } from "node:path";
22
- import { StringDecoder } from "node:string_decoder";
23
21
  import { agentDirFor, chooseCwd, PACKAGE_DIR, packageVersion } from "./cli.js";
24
22
  import { configPath, loadConfig, RECOMMENDED_MODELS, readConfigFile, saveConfig, uiLanguage, withStarbuck } from "./config.js";
25
23
  import { TESTED_MODELS } from "./init.js";
26
24
  import { KEY_PROVIDERS, saveApiKey } from "./keys.js";
27
- import { field, frontMatter } from "./notes.js";
28
25
  export function parseWebArgs(args) {
29
26
  const o = { port: 0, open: true, librarian: false, here: false, idleMinutes: 10, piArgs: [] };
30
27
  for (let i = 0; i < args.length; i++) {
@@ -52,127 +49,9 @@ export { KEY_PROVIDERS, saveApiKey } from "./keys.js";
52
49
  // ---------------------------------------------------------------- files (shared with the terminal screens)
53
50
  import { allowedFile, listNotes, openExternal, openFile, OPENABLE, wikilinkTarget } from "./files.js";
54
51
  export { allowedFile, listNotes, openExternal, OPENABLE, wikilinkTarget };
55
- /** pi as a child process in RPC mode. Strict JSONL: records end at LF only. */
56
- export class Agent {
57
- proc;
58
- decoder = new StringDecoder("utf8");
59
- buf = "";
60
- n = 0;
61
- pending = new Map();
62
- listeners = new Set();
63
- stderr = "";
64
- onExit;
65
- command;
66
- args;
67
- cwd;
68
- env;
69
- constructor(command, args, cwd, env) {
70
- this.command = command;
71
- this.args = args;
72
- this.cwd = cwd;
73
- this.env = env;
74
- }
75
- start(extra = []) {
76
- this.buf = "";
77
- this.decoder = new StringDecoder("utf8");
78
- const proc = spawn(this.command, [...this.args, ...extra], { cwd: this.cwd, env: this.env, stdio: ["pipe", "pipe", "pipe"], windowsHide: true });
79
- this.proc = proc;
80
- proc.stdout.on("data", (chunk) => this.feed(this.decoder.write(chunk)));
81
- proc.stderr.on("data", (d) => {
82
- this.stderr = (this.stderr + d.toString()).slice(-20_000);
83
- });
84
- proc.once("exit", (code) => {
85
- if (this.proc !== proc)
86
- return;
87
- this.proc = undefined;
88
- for (const p of this.pending.values()) {
89
- clearTimeout(p.timer);
90
- p.reject(new Error(`pi stopped (code ${code}). ${this.stderr.slice(-500)}`));
91
- }
92
- this.pending.clear();
93
- this.onExit?.(code);
94
- });
95
- }
96
- feed(text) {
97
- this.buf += text;
98
- let i;
99
- while ((i = this.buf.indexOf("\n")) >= 0) {
100
- const line = this.buf.slice(0, i).replace(/\r$/, "");
101
- this.buf = this.buf.slice(i + 1);
102
- if (!line.trim())
103
- continue;
104
- let rec;
105
- try {
106
- rec = JSON.parse(line);
107
- }
108
- catch {
109
- continue;
110
- }
111
- if (rec.type === "response" && typeof rec.id === "string" && this.pending.has(rec.id)) {
112
- const p = this.pending.get(rec.id);
113
- this.pending.delete(rec.id);
114
- clearTimeout(p.timer);
115
- if (rec.success === false)
116
- p.reject(new Error(rec.error ?? `${rec.command} failed`));
117
- else
118
- p.resolve(rec.data);
119
- continue;
120
- }
121
- for (const l of this.listeners)
122
- l(rec);
123
- }
124
- }
125
- on(l) {
126
- this.listeners.add(l);
127
- return () => this.listeners.delete(l);
128
- }
129
- write(rec) {
130
- const stdin = this.proc?.stdin;
131
- if (!stdin || !stdin.writable)
132
- throw new Error("pi is not running");
133
- stdin.write(`${JSON.stringify(rec)}\n`);
134
- }
135
- request(cmd, timeoutMs = 30_000) {
136
- const id = `w${++this.n}`;
137
- return new Promise((resolvePromise, reject) => {
138
- const timer = setTimeout(() => {
139
- this.pending.delete(id);
140
- reject(new Error(`pi did not answer ${String(cmd.type)} in time`));
141
- }, timeoutMs);
142
- this.pending.set(id, { resolve: resolvePromise, reject, timer });
143
- try {
144
- this.write({ ...cmd, id });
145
- }
146
- catch (err) {
147
- clearTimeout(timer);
148
- this.pending.delete(id);
149
- reject(err);
150
- }
151
- });
152
- }
153
- async stop() {
154
- const proc = this.proc;
155
- if (!proc)
156
- return;
157
- this.proc = undefined;
158
- for (const p of this.pending.values()) {
159
- clearTimeout(p.timer);
160
- p.reject(new Error("pi was stopped"));
161
- }
162
- this.pending.clear();
163
- await new Promise((done) => {
164
- const t = setTimeout(() => {
165
- proc.kill("SIGKILL");
166
- done();
167
- }, 3000);
168
- proc.once("exit", () => {
169
- clearTimeout(t);
170
- done();
171
- });
172
- proc.kill();
173
- });
174
- }
175
- }
52
+ // ---------------------------------------------------------------- pi in RPC mode (src/rpc.ts)
53
+ import { Agent, findLibrary, isDialog, Side, withHints } from "./rpc.js";
54
+ export { Agent };
176
55
  // ---------------------------------------------------------------- helpers
177
56
  const TYPES = {
178
57
  ".html": "text/html; charset=utf-8",
@@ -198,14 +77,6 @@ const CSP = [
198
77
  "form-action 'none'",
199
78
  "frame-ancestors 'none'",
200
79
  ].join("; ");
201
- function readText(path) {
202
- try {
203
- return readFileSync(path, "utf8");
204
- }
205
- catch {
206
- return "";
207
- }
208
- }
209
80
  function same(a, b) {
210
81
  const x = Buffer.from(a);
211
82
  const y = Buffer.from(b);
@@ -273,7 +144,7 @@ export async function startWeb(opts, env = process.env, log = () => { }) {
273
144
  const clients = new Set();
274
145
  const dialogs = new Map();
275
146
  let subsubState = null;
276
- let lastFind = null;
147
+ const side = new Side();
277
148
  let busy = false;
278
149
  let lastSeen = Date.now();
279
150
  let restarting = false;
@@ -297,27 +168,14 @@ export async function startWeb(opts, env = process.env, log = () => { }) {
297
168
  }
298
169
  agent.on((ev) => {
299
170
  if (ev.type === "extension_ui_request") {
300
- if (["select", "confirm", "input", "editor"].includes(ev.method)) {
171
+ if (isDialog(ev)) {
301
172
  dialogs.set(ev.id, ev);
302
173
  broadcast(ev);
303
174
  }
304
- else if (ev.method === "setStatus" && ev.statusKey === "subsub.web") {
305
- try {
306
- subsubState = ev.statusText ? JSON.parse(ev.statusText) : null;
307
- }
308
- catch {
309
- subsubState = null;
310
- }
175
+ else if (side.take(ev) === "state") {
176
+ subsubState = side.state;
311
177
  broadcast({ type: "subsub_state", state: subsubState });
312
178
  }
313
- else if (ev.method === "setStatus" && ev.statusKey === "subsub.find") {
314
- try {
315
- lastFind = ev.statusText ? JSON.parse(ev.statusText) : null;
316
- }
317
- catch {
318
- lastFind = null;
319
- }
320
- }
321
179
  else if (ev.method === "notify") {
322
180
  broadcast(ev);
323
181
  }
@@ -361,13 +219,7 @@ export async function startWeb(opts, env = process.env, log = () => { }) {
361
219
  out.session = await agent.request({ type: "get_state" });
362
220
  lastSession = out.session.sessionFile ?? lastSession;
363
221
  out.messages = (await agent.request({ type: "get_messages" })).messages;
364
- const commands = (await agent.request({ type: "get_commands" })).commands;
365
- // RPC has no argument hints: read them from the prompt files.
366
- out.commands = commands.map((c) => {
367
- const front = c.source === "prompt" && c.sourceInfo?.path ? frontMatter(readText(c.sourceInfo.path)) : undefined;
368
- const hint = front && field(front, "argument-hint");
369
- return hint ? { ...c, hint } : c;
370
- });
222
+ out.commands = withHints((await agent.request({ type: "get_commands" })).commands);
371
223
  }
372
224
  catch (err) {
373
225
  out.error = err.message;
@@ -649,8 +501,7 @@ export async function startWeb(opts, env = process.env, log = () => { }) {
649
501
  const q = (url.searchParams.get("q") ?? "").replace(/\s+/g, " ").trim().slice(0, 200);
650
502
  if (!q)
651
503
  return send(res, 200, { q, items: [] });
652
- await agent.request({ type: "prompt", message: `/subsub-find ${q}` });
653
- return send(res, 200, lastFind?.q === q ? lastFind : { q, items: [] });
504
+ return send(res, 200, await findLibrary(agent, side, q));
654
505
  }
655
506
  case "POST /api/session/name": {
656
507
  const body = await readJson(req);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiagojct/subsub",
3
- "version": "0.21.0",
3
+ "version": "0.23.0",
4
4
  "description": "Sub-Sub: a research assistant that works from your own Zotero library, in the browser or the terminal, built on pi",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/cli.ts 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
 
19
20
  import { spawnSync } from "node:child_process";
@@ -99,6 +100,8 @@ function isEmptyAuth(file: string): boolean {
99
100
  export function shareAuth(agentDir: string, piAgentDir: string): boolean {
100
101
  const mine = join(agentDir, "auth.json");
101
102
  const theirs = join(piAgentDir, "auth.json");
103
+ // 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.
104
+ if (real(mine) === real(theirs)) return false;
102
105
  if (!existsSync(theirs)) return false;
103
106
  try {
104
107
  const st = lstatSync(mine);
@@ -140,6 +143,7 @@ Usage:
140
143
  subsub Start Sub-Sub in the terminal
141
144
  subsub --librarian Start in the Librarian, which only manages the library
142
145
  subsub web Open Sub-Sub in the browser
146
+ subsub tui Sub-Sub on the whole terminal: transcript, side panel, footer (--light or --dark for the colours)
143
147
  subsub init Set up or change the settings (folder, tag list, models)
144
148
  subsub doctor Check the set-up; each line marked FIX says what to do
145
149
  subsub usage The pilot's usage log (if turned on): a summary and where the file is
@@ -182,6 +186,11 @@ export async function run(argv: string[] = process.argv.slice(2)): Promise<void>
182
186
  process.exitCode = await webMain([...args.slice(1), ...(here ? ["--here"] : [])], env);
183
187
  return;
184
188
  }
189
+ if (args[0] === "tui") {
190
+ const { runTui } = await import("./tui.ts");
191
+ // The terminal is given back before this returns; exit so that nothing else keeps the process alive.
192
+ process.exit(await runTui([...args.slice(1), ...(here ? ["--here"] : [])], env));
193
+ }
185
194
  if (args[0] === "shortcut") {
186
195
  const { shortcutMain } = await import("./shortcut.ts");
187
196
  process.exitCode = shortcutMain(args.slice(1), env);
package/src/rpc.ts ADDED
@@ -0,0 +1,198 @@
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
+
8
+ import { spawn, type ChildProcess } from "node:child_process";
9
+ import { readFileSync } from "node:fs";
10
+ import { StringDecoder } from "node:string_decoder";
11
+ import { field, frontMatter } from "./notes.ts";
12
+
13
+
14
+ type Listener = (ev: any) => void;
15
+
16
+ /** pi as a child process in RPC mode. Strict JSONL: records end at LF only. */
17
+ export class Agent {
18
+ proc?: ChildProcess;
19
+ private decoder = new StringDecoder("utf8");
20
+ private buf = "";
21
+ private n = 0;
22
+ private pending = new Map<string, { resolve: (v: any) => void; reject: (e: Error) => void; timer: NodeJS.Timeout }>();
23
+ private listeners = new Set<Listener>();
24
+ stderr = "";
25
+ onExit?: (code: number | null) => void;
26
+
27
+ private readonly command: string;
28
+ private readonly args: string[];
29
+ private readonly cwd: string;
30
+ private readonly env: NodeJS.ProcessEnv;
31
+
32
+ constructor(command: string, args: string[], cwd: string, env: NodeJS.ProcessEnv) {
33
+ this.command = command;
34
+ this.args = args;
35
+ this.cwd = cwd;
36
+ this.env = env;
37
+ }
38
+
39
+ start(extra: string[] = []): void {
40
+ this.buf = "";
41
+ this.decoder = new StringDecoder("utf8");
42
+ const proc = spawn(this.command, [...this.args, ...extra], { cwd: this.cwd, env: this.env, stdio: ["pipe", "pipe", "pipe"], windowsHide: true });
43
+ this.proc = proc;
44
+ proc.stdout!.on("data", (chunk: Buffer) => this.feed(this.decoder.write(chunk)));
45
+ proc.stderr!.on("data", (d: Buffer) => {
46
+ this.stderr = (this.stderr + d.toString()).slice(-20_000);
47
+ });
48
+ proc.once("exit", (code) => {
49
+ if (this.proc !== proc) return;
50
+ this.proc = undefined;
51
+ for (const p of this.pending.values()) {
52
+ clearTimeout(p.timer);
53
+ p.reject(new Error(`pi stopped (code ${code}). ${this.stderr.slice(-500)}`));
54
+ }
55
+ this.pending.clear();
56
+ this.onExit?.(code);
57
+ });
58
+ }
59
+
60
+ private feed(text: string): void {
61
+ this.buf += text;
62
+ let i: number;
63
+ while ((i = this.buf.indexOf("\n")) >= 0) {
64
+ const line = this.buf.slice(0, i).replace(/\r$/, "");
65
+ this.buf = this.buf.slice(i + 1);
66
+ if (!line.trim()) continue;
67
+ let rec: any;
68
+ try {
69
+ rec = JSON.parse(line);
70
+ } catch {
71
+ continue;
72
+ }
73
+ if (rec.type === "response" && typeof rec.id === "string" && this.pending.has(rec.id)) {
74
+ const p = this.pending.get(rec.id)!;
75
+ this.pending.delete(rec.id);
76
+ clearTimeout(p.timer);
77
+ if (rec.success === false) p.reject(new Error(rec.error ?? `${rec.command} failed`));
78
+ else p.resolve(rec.data);
79
+ continue;
80
+ }
81
+ for (const l of this.listeners) l(rec);
82
+ }
83
+ }
84
+
85
+ on(l: Listener): () => void {
86
+ this.listeners.add(l);
87
+ return () => this.listeners.delete(l);
88
+ }
89
+
90
+ write(rec: unknown): void {
91
+ const stdin = this.proc?.stdin;
92
+ if (!stdin || !stdin.writable) throw new Error("pi is not running");
93
+ stdin.write(`${JSON.stringify(rec)}\n`);
94
+ }
95
+
96
+ request<T = any>(cmd: Record<string, unknown>, timeoutMs = 30_000): Promise<T> {
97
+ const id = `w${++this.n}`;
98
+ return new Promise<T>((resolvePromise, reject) => {
99
+ const timer = setTimeout(() => {
100
+ this.pending.delete(id);
101
+ reject(new Error(`pi did not answer ${String(cmd.type)} in time`));
102
+ }, timeoutMs);
103
+ this.pending.set(id, { resolve: resolvePromise, reject, timer });
104
+ try {
105
+ this.write({ ...cmd, id });
106
+ } catch (err) {
107
+ clearTimeout(timer);
108
+ this.pending.delete(id);
109
+ reject(err as Error);
110
+ }
111
+ });
112
+ }
113
+
114
+ async stop(): Promise<void> {
115
+ const proc = this.proc;
116
+ if (!proc) return;
117
+ this.proc = undefined;
118
+ for (const p of this.pending.values()) {
119
+ clearTimeout(p.timer);
120
+ p.reject(new Error("pi was stopped"));
121
+ }
122
+ this.pending.clear();
123
+ await new Promise<void>((done) => {
124
+ const t = setTimeout(() => {
125
+ proc.kill("SIGKILL");
126
+ done();
127
+ }, 3000);
128
+ proc.once("exit", () => {
129
+ clearTimeout(t);
130
+ done();
131
+ });
132
+ proc.kill();
133
+ });
134
+ }
135
+ }
136
+
137
+
138
+ /** The dialogs that wait for an answer (extension_ui_response): the rest of the extension UI requests are notices. */
139
+ export function isDialog(ev: any): boolean {
140
+ return ev?.type === "extension_ui_request" && ["select", "confirm", "input", "editor"].includes(ev.method);
141
+ }
142
+
143
+ export interface LibrarySearch {
144
+ q?: string;
145
+ items?: unknown[];
146
+ error?: string;
147
+ }
148
+
149
+ /** What the extension sends as status texts: the state of the library and the answer to the last library search. */
150
+ export class Side {
151
+ state: unknown = null;
152
+ find: LibrarySearch | null = null;
153
+
154
+ /** Decode an extension_ui_request; tells which of the two it was, or undefined. */
155
+ take(ev: any): "state" | "find" | undefined {
156
+ if (ev?.type !== "extension_ui_request" || ev.method !== "setStatus") return undefined;
157
+ const decode = (): any => {
158
+ try {
159
+ return ev.statusText ? JSON.parse(ev.statusText) : null;
160
+ } catch {
161
+ return null;
162
+ }
163
+ };
164
+ if (ev.statusKey === "subsub.web") {
165
+ this.state = decode();
166
+ return "state";
167
+ }
168
+ if (ev.statusKey === "subsub.find") {
169
+ this.find = decode();
170
+ return "find";
171
+ }
172
+ return undefined;
173
+ }
174
+ }
175
+
176
+ /** Search the library through the extension (the hidden /subsub-find command); the answer comes back as a status text. */
177
+ export async function findLibrary(agent: { request: Agent["request"] }, side: Side, q: string): Promise<LibrarySearch> {
178
+ if (!q) return { q, items: [] };
179
+ await agent.request({ type: "prompt", message: `/subsub-find ${q}` });
180
+ return side.find?.q === q ? side.find : { q, items: [] };
181
+ }
182
+
183
+ function readText(path: string): string {
184
+ try {
185
+ return readFileSync(path, "utf8");
186
+ } catch {
187
+ return "";
188
+ }
189
+ }
190
+
191
+ /** RPC has no argument hints: read them from the front matter of the prompt files. */
192
+ export function withHints(commands: Array<Record<string, any>>): Array<Record<string, any>> {
193
+ return commands.map((c) => {
194
+ const front = c.source === "prompt" && c.sourceInfo?.path ? frontMatter(readText(c.sourceInfo.path)) : undefined;
195
+ const hint = front && field(front, "argument-hint");
196
+ return hint ? { ...c, hint } : c;
197
+ });
198
+ }
package/src/screens.ts CHANGED
@@ -18,12 +18,14 @@ export const SCREEN_WORDS = {
18
18
  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",
19
19
  library: "Library", typeToSearch: "Type to search your library.", searching: "Searching...", noItems: "Nothing found.", failed: "The search failed",
20
20
  hintLibrary: "up and down: choose Enter: read ctrl+n: note Esc: close", hintLibrarian: "up and down: choose Enter: copy the key Esc: close",
21
+ hintChoice: "up and down: choose Enter: select type: filter Esc: cancel", noChoice: "Nothing matches.", hintInput: "Enter: send Esc: cancel",
21
22
  },
22
23
  pt: {
23
24
  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)",
24
25
  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",
25
26
  library: "Biblioteca", typeToSearch: "Escreva para pesquisar na biblioteca.", searching: "A pesquisar...", noItems: "Nada encontrado.", failed: "A pesquisa falhou",
26
27
  hintLibrary: "cima e baixo: escolher Enter: ler ctrl+n: nota Esc: fechar", hintLibrarian: "cima e baixo: escolher Enter: copiar a chave Esc: fechar",
28
+ hintChoice: "cima e baixo: escolher Enter: selecionar escrever: filtrar Esc: cancelar", noChoice: "Nada corresponde.", hintInput: "Enter: enviar Esc: cancelar",
27
29
  },
28
30
  };
29
31
 
@@ -372,3 +374,157 @@ export class LibraryView {
372
374
  this.o.redraw?.();
373
375
  }
374
376
  }
377
+
378
+ // ---------------------------------------------------------------- a list to choose from
379
+
380
+ export interface Choice {
381
+ label: string;
382
+ /** Dim text after the label (a command, a date). */
383
+ hint?: string;
384
+ value: string;
385
+ }
386
+
387
+ export interface ChoiceLayout {
388
+ title: string;
389
+ choices: Choice[];
390
+ selected: number;
391
+ filter: string;
392
+ width: number;
393
+ rows: number;
394
+ lang: Lang;
395
+ paint: Paint;
396
+ glyphs?: Glyphs;
397
+ }
398
+
399
+ export function choiceLines(o: ChoiceLayout): string[] {
400
+ const g = o.glyphs ?? UNICODE;
401
+ const p = o.paint;
402
+ const w = Math.max(20, o.width);
403
+ const words = SCREEN_WORDS[o.lang];
404
+ const room = Math.max(1, o.rows - 4);
405
+ 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)];
406
+ if (!o.choices.length) out.push(p.muted(cut(` ${words.noChoice}`, w)));
407
+ const start = Math.max(0, Math.min(o.selected - Math.floor(room / 2), o.choices.length - room));
408
+ for (const [k, c] of o.choices.slice(start, start + room).entries()) {
409
+ const chosen = start + k === o.selected;
410
+ const hint = c.hint ? ` ${c.hint}` : "";
411
+ const label = cut(c.label, Math.max(8, w - 2 - hint.length));
412
+ 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)))}`);
413
+ }
414
+ out.push(rule(w, "solid", p.muted, g), ...wrapTextWithAnsi(` ${words.hintChoice}`, w).map((l) => p.muted(l)));
415
+ return out;
416
+ }
417
+
418
+ export interface ChoiceOptions {
419
+ title: string;
420
+ choices: Choice[];
421
+ choose: (value: string) => void;
422
+ cancel: () => void;
423
+ lang: Lang;
424
+ paint: Paint;
425
+ glyphs?: Glyphs;
426
+ rows?: () => number;
427
+ redraw?: () => void;
428
+ }
429
+
430
+ export class ChoiceView {
431
+ private readonly o: ChoiceOptions;
432
+ private filter = "";
433
+ private selected = 0;
434
+ private answered = false;
435
+ constructor(o: ChoiceOptions) {
436
+ this.o = o;
437
+ }
438
+
439
+ private shown(): Choice[] {
440
+ const f = this.filter.toLowerCase();
441
+ return f ? this.o.choices.filter((c) => `${c.label} ${c.hint ?? ""}`.toLowerCase().includes(f)) : this.o.choices;
442
+ }
443
+
444
+ render(width: number): string[] {
445
+ const rows = Math.max(8, Math.floor(((this.o.rows?.() ?? 24) * 8) / 10));
446
+ 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);
447
+ }
448
+
449
+ invalidate(): void {}
450
+
451
+ handleInput(data: string): void {
452
+ if (this.answered) return;
453
+ const list = this.shown();
454
+ if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) {
455
+ this.answered = true;
456
+ return this.o.cancel();
457
+ }
458
+ if (matchesKey(data, "enter")) {
459
+ const c = list[this.selected];
460
+ if (!c) return;
461
+ this.answered = true;
462
+ return this.o.choose(c.value);
463
+ }
464
+ if (matchesKey(data, "up")) this.selected = Math.max(0, this.selected - 1);
465
+ else if (matchesKey(data, "down")) this.selected = Math.min(Math.max(0, list.length - 1), this.selected + 1);
466
+ else if (matchesKey(data, "backspace")) {
467
+ this.filter = this.filter.slice(0, -1);
468
+ this.selected = 0;
469
+ } else if (printable(data)) {
470
+ this.filter += data;
471
+ this.selected = 0;
472
+ } else return;
473
+ this.o.redraw?.();
474
+ }
475
+ }
476
+
477
+ // ---------------------------------------------------------------- a question with a typed answer
478
+
479
+ export interface InputOptions {
480
+ title: string;
481
+ placeholder?: string;
482
+ prefill?: string;
483
+ submit: (text: string) => void;
484
+ cancel: () => void;
485
+ lang: Lang;
486
+ paint: Paint;
487
+ glyphs?: Glyphs;
488
+ redraw?: () => void;
489
+ }
490
+
491
+ export function inputLines(o: { title: string; placeholder?: string; text: string; width: number; lang: Lang; paint: Paint; glyphs?: Glyphs }): string[] {
492
+ const g = o.glyphs ?? UNICODE;
493
+ const p = o.paint;
494
+ const w = Math.max(20, o.width);
495
+ const shown = o.text ? p.text(cut(o.text, w - 5)) + p.mark("_") : p.dim(cut(o.placeholder ?? "", w - 5)) + p.mark("_");
496
+ 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))];
497
+ }
498
+
499
+ export class InputView {
500
+ private readonly o: InputOptions;
501
+ private text: string;
502
+ private answered = false;
503
+ constructor(o: InputOptions) {
504
+ this.o = o;
505
+ this.text = o.prefill ?? "";
506
+ }
507
+
508
+ render(width: number): string[] {
509
+ 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);
510
+ }
511
+
512
+ invalidate(): void {}
513
+
514
+ handleInput(data: string): void {
515
+ if (this.answered) return;
516
+ if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) {
517
+ this.answered = true;
518
+ return this.o.cancel();
519
+ }
520
+ if (matchesKey(data, "enter")) {
521
+ this.answered = true;
522
+ return this.o.submit(this.text);
523
+ }
524
+ if (matchesKey(data, "backspace")) this.text = this.text.slice(0, -1);
525
+ else if (matchesKey(data, "ctrl+u")) this.text = "";
526
+ else if (printable(data)) this.text += data;
527
+ else return;
528
+ this.o.redraw?.();
529
+ }
530
+ }