@tiagojct/subsub 0.20.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 +21 -0
- package/README.md +8 -1
- package/dist/chrome.js +171 -0
- package/dist/cli.js +10 -0
- package/dist/config.js +2 -0
- package/dist/dialogs.js +81 -0
- package/dist/files.js +66 -0
- package/dist/look.js +36 -17
- package/dist/rpc.js +185 -0
- package/dist/screens.js +425 -0
- package/dist/skin.js +37 -0
- package/dist/subsub.js +198 -31
- package/dist/tui-view.js +228 -0
- package/dist/tui.js +651 -0
- package/dist/web.js +16 -216
- package/dist/words.js +26 -0
- package/package.json +1 -1
- package/scripts/make-themes.mjs +112 -0
- package/scripts/plate.json +35 -0
- package/src/chrome.ts +208 -0
- package/src/cli.ts +9 -0
- package/src/config.ts +4 -0
- package/src/dialogs.ts +107 -0
- package/src/files.ts +66 -0
- package/src/look.ts +45 -18
- package/src/rpc.ts +198 -0
- package/src/screens.ts +530 -0
- package/src/skin.ts +65 -0
- package/src/subsub.ts +214 -36
- package/src/tui-view.ts +270 -0
- package/src/tui.ts +710 -0
- package/src/web.ts +14 -209
- package/src/words.ts +40 -0
- package/themes/subsub-glauca-dark-256.json +80 -0
- package/themes/subsub-glauca-dark.json +73 -77
- package/themes/subsub-glauca-light-256.json +80 -0
- package/themes/subsub-glauca-light.json +73 -77
- package/themes/subsub-try-works-dark-256.json +80 -0
- package/themes/subsub-try-works-dark.json +73 -77
- package/themes/subsub-try-works-light-256.json +80 -0
- package/themes/subsub-try-works-light.json +73 -77
- package/scripts/make-themes.py +0 -116
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
|
+
}
|