@tiagojct/subsub 0.21.0 → 0.22.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 +8 -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/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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
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 (
|
|
171
|
+
if (isDialog(ev)) {
|
|
301
172
|
dialogs.set(ev.id, ev);
|
|
302
173
|
broadcast(ev);
|
|
303
174
|
}
|
|
304
|
-
else if (
|
|
305
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
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
|
+
}
|