@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/tui.ts
ADDED
|
@@ -0,0 +1,710 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `subsub tui`: Sub-Sub in the whole terminal. A client of pi's RPC mode, like the web view: pi runs as a child process with the Sub-Sub extension
|
|
3
|
+
* (src/rpc.ts), and this file draws what it says on a full-screen pi-tui screen (a transcript that scrolls, a side panel from 110 columns, the
|
|
4
|
+
* editor and the footer), and answers its dialogs. The look is that of `subsub` (src/chrome.ts, src/screens.ts, src/dialogs.ts); the colours are
|
|
5
|
+
* read from themes/, because RPC mode has no pi theme to share.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { readFileSync, statSync } from "node:fs";
|
|
9
|
+
import { homedir } from "node:os";
|
|
10
|
+
import { extname, join } from "node:path";
|
|
11
|
+
import { CombinedAutocompleteProvider, Editor, HStack, Markdown, matchesKey, ProcessTerminal, ScrollView, type Component, type SlashCommand, type Terminal, TuiAltScreen, VStack } from "@earendil-works/pi-tui";
|
|
12
|
+
import { agentDirFor, chooseCwd, PACKAGE_DIR, packageVersion } from "./cli.ts";
|
|
13
|
+
import { fill, footerLines } from "./chrome.ts";
|
|
14
|
+
import { loadConfig, uiLanguage } from "./config.ts";
|
|
15
|
+
import { type LibraryState, LIBRARIAN_THEME, DEFAULT_THEME, paintPreview, QUOTES } from "./look.ts";
|
|
16
|
+
import { PURPOSES } from "./read.ts";
|
|
17
|
+
import { allowedFile, listNotes, OPENABLE, openFile } from "./files.ts";
|
|
18
|
+
import { Agent, findLibrary, isDialog, Side, withHints } from "./rpc.ts";
|
|
19
|
+
import { ApprovalView } from "./dialogs.ts";
|
|
20
|
+
import { type Choice, ChoiceView, InputView, LibraryView, type LibraryItem, NotesView } from "./screens.ts";
|
|
21
|
+
import { glyphs as chooseGlyphs, type Glyphs, workingFrames } from "./skin.ts";
|
|
22
|
+
import { bannerLines, type Entry, entriesFromMessages, loadPalette, messageText, type Palette, schemeFromEnv, type Scheme, SIDE_WIDTH, sideLines, transcriptLines } from "./tui-view.ts";
|
|
23
|
+
import { loadWords, type Words } from "./words.ts";
|
|
24
|
+
|
|
25
|
+
type Lang = "en" | "pt";
|
|
26
|
+
type Mode = "researcher" | "librarian";
|
|
27
|
+
|
|
28
|
+
/** The part of the Agent class (src/rpc.ts) that the screen uses; tests give it a fake. */
|
|
29
|
+
export interface AgentLike {
|
|
30
|
+
start(extra?: string[]): void;
|
|
31
|
+
stop(): Promise<void>;
|
|
32
|
+
request<T = any>(cmd: Record<string, unknown>, timeoutMs?: number): Promise<T>;
|
|
33
|
+
write(rec: unknown): void;
|
|
34
|
+
on(l: (ev: any) => void): () => void;
|
|
35
|
+
onExit?: (code: number | null) => void;
|
|
36
|
+
stderr: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface TuiDeps {
|
|
40
|
+
terminal: Terminal;
|
|
41
|
+
agent: AgentLike;
|
|
42
|
+
version: string;
|
|
43
|
+
lang: Lang;
|
|
44
|
+
cwd: string;
|
|
45
|
+
/** The palette of a theme base ("subsub-glauca") in the scheme and colour mode of the terminal. */
|
|
46
|
+
palette: (base: string) => Palette;
|
|
47
|
+
glyphs: Glyphs;
|
|
48
|
+
words?: Words;
|
|
49
|
+
quote?: [string, string];
|
|
50
|
+
reduceMotion?: boolean;
|
|
51
|
+
/** The theme of each mode; the Researcher's is subsub-glauca and the Librarian's subsub-try-works unless the settings say otherwise. */
|
|
52
|
+
themes?: { researcher?: string; librarian?: string };
|
|
53
|
+
/** The conversations of this folder, newest first (SessionManager.list in the real client). */
|
|
54
|
+
sessions?: () => Promise<Array<{ title: string; path?: string }>>;
|
|
55
|
+
/** The Sub-Sub folder, for /notes. */
|
|
56
|
+
vault?: string;
|
|
57
|
+
/** Milliseconds between the last key and the search in /library. */
|
|
58
|
+
searchDelay?: number;
|
|
59
|
+
/** Called when the user quits or pi stops. */
|
|
60
|
+
onQuit?: (code: number) => void;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const SHORT = (s: string, n: number) => (s.length > n ? `${s.slice(0, n - 3)}...` : s);
|
|
64
|
+
|
|
65
|
+
export class TuiApp {
|
|
66
|
+
readonly tui: TuiAltScreen;
|
|
67
|
+
readonly side = new Side();
|
|
68
|
+
entries: Entry[] = [];
|
|
69
|
+
mode: Mode = "researcher";
|
|
70
|
+
busy = false;
|
|
71
|
+
private working = "";
|
|
72
|
+
private frame = 0;
|
|
73
|
+
private frames: string[];
|
|
74
|
+
private timer: ReturnType<typeof setInterval> | undefined;
|
|
75
|
+
private model = "";
|
|
76
|
+
private sessionFile: string | undefined;
|
|
77
|
+
private percent: number | null = null;
|
|
78
|
+
private window: number | undefined;
|
|
79
|
+
private tokens: number | null = 0;
|
|
80
|
+
private cost: number | null = 0;
|
|
81
|
+
private expanded = false;
|
|
82
|
+
private conversations: Array<{ title: string; open?: boolean }> = [];
|
|
83
|
+
private lastCtrlC = 0;
|
|
84
|
+
private palette: Palette;
|
|
85
|
+
private readonly d: TuiDeps;
|
|
86
|
+
private readonly editor: Editor;
|
|
87
|
+
private current: Extract<Entry, { kind: "assistant" }> | undefined;
|
|
88
|
+
private readonly tools = new Map<string, Extract<Entry, { kind: "tool" }>>();
|
|
89
|
+
private unlisten: (() => void) | undefined;
|
|
90
|
+
private stopped = false;
|
|
91
|
+
/** The screen over the conversation (notes, library, a list), if one is open. */
|
|
92
|
+
private overlay: { hide(): void } | undefined;
|
|
93
|
+
/** A dialog of the extension that waits for an answer, and the ones behind it. */
|
|
94
|
+
private dialogHandle: { hide(): void } | undefined;
|
|
95
|
+
private dialogs: any[] = [];
|
|
96
|
+
|
|
97
|
+
constructor(d: TuiDeps) {
|
|
98
|
+
this.d = d;
|
|
99
|
+
this.palette = d.palette(this.themeFor("researcher"));
|
|
100
|
+
this.frames = workingFrames(d.glyphs, d.reduceMotion);
|
|
101
|
+
this.tui = new TuiAltScreen(d.terminal, false, undefined, { mouse: true });
|
|
102
|
+
this.editor = new Editor(this.tui, this.palette.editorTheme(), { paddingX: 1 });
|
|
103
|
+
this.editor.onSubmit = (text) => void this.submit(text);
|
|
104
|
+
this.tui.setLayoutRoot(this.layout());
|
|
105
|
+
this.tui.setFocus(this.editor);
|
|
106
|
+
this.tui.addInputListener?.((data: string) => (this.globalKey(data) ? { consume: true } : undefined));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
private themeFor(mode: Mode): string {
|
|
110
|
+
return (mode === "librarian" ? this.d.themes?.librarian ?? LIBRARIAN_THEME : this.d.themes?.researcher ?? DEFAULT_THEME) as string;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ---------------------------------------------------------------- layout
|
|
114
|
+
|
|
115
|
+
private get lang(): Lang {
|
|
116
|
+
return this.d.lang;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
private library(): LibraryState {
|
|
120
|
+
const s = this.side.state as { library?: LibraryState } | null;
|
|
121
|
+
return s?.library ?? { zotero: "checking" };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
private markdown = (text: string, width: number): string[] => new Markdown(text, 0, 0, this.palette.markdownTheme() as never).render(width);
|
|
125
|
+
|
|
126
|
+
private layout(): Component {
|
|
127
|
+
const app = this;
|
|
128
|
+
const transcript: Component = {
|
|
129
|
+
render: (width) => {
|
|
130
|
+
const w = Math.max(10, width - 2);
|
|
131
|
+
const body = transcriptLines(app.entries, w, { paint: app.palette.paint(), glyphs: app.d.glyphs, lang: app.lang, markdown: app.markdown, words: app.d.words, expanded: app.expanded });
|
|
132
|
+
return [...app.banner(w), "", ...body].map((l) => ` ${l}`);
|
|
133
|
+
},
|
|
134
|
+
invalidate() {},
|
|
135
|
+
};
|
|
136
|
+
const working: Component = {
|
|
137
|
+
render: () => [app.busy ? ` ${app.palette.paint().mark(app.frames[app.frame % app.frames.length])} ${app.palette.paint().muted(app.working)}` : ""],
|
|
138
|
+
invalidate() {},
|
|
139
|
+
};
|
|
140
|
+
const footer: Component = {
|
|
141
|
+
render: (width) => footerLines({ mode: app.mode, lang: app.lang, library: app.library(), model: app.model, percent: app.percent, window: app.window, tokens: app.tokens, cost: app.cost, down: app.down() }, width - 1, app.palette.paint(), app.d.glyphs).map((l) => ` ${l}`),
|
|
142
|
+
invalidate() {},
|
|
143
|
+
};
|
|
144
|
+
const side: Component = {
|
|
145
|
+
render: (width) => {
|
|
146
|
+
const rows = app.d.terminal.rows;
|
|
147
|
+
const p = app.palette.paint();
|
|
148
|
+
const lines = sideLines({ version: app.d.version, mode: app.mode, lang: app.lang, library: app.library(), model: app.model, conversations: app.conversations }, width - 2, rows, p, app.d.glyphs);
|
|
149
|
+
const bar = p.muted(app.d.glyphs.vertical);
|
|
150
|
+
return fill(lines, width - 2, rows).map((l) => `${l}${bar} `);
|
|
151
|
+
},
|
|
152
|
+
invalidate() {},
|
|
153
|
+
};
|
|
154
|
+
const main = new VStack([
|
|
155
|
+
{ component: new ScrollView(transcript, { follow: "end", primary: true, overscroll: "chain" }), basis: 0, grow: 1, minSize: 1 },
|
|
156
|
+
{ component: working, basis: "auto" },
|
|
157
|
+
{ component: this.editor, basis: "auto", shrink: 1, minSize: 3 },
|
|
158
|
+
{ component: footer, basis: "auto" },
|
|
159
|
+
]);
|
|
160
|
+
return new HStack([
|
|
161
|
+
{ component: side, basis: SIDE_WIDTH, shrink: 0, visible: (v: { width: number }) => v.width >= 110 },
|
|
162
|
+
{ component: main, grow: 1, minSize: 20 },
|
|
163
|
+
]);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
private down(): string[] {
|
|
167
|
+
return ((this.side.state as { down?: string[] } | null)?.down ?? []).slice();
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
private banner(width: number): string[] {
|
|
171
|
+
return bannerLines({ version: this.d.version, mode: this.mode, model: this.model, library: this.library(), width, paint: this.palette.paint(), lang: this.lang, glyphs: this.d.glyphs, quote: this.d.quote });
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ---------------------------------------------------------------- life
|
|
175
|
+
|
|
176
|
+
async start(): Promise<void> {
|
|
177
|
+
this.unlisten = this.d.agent.on((ev) => this.handle(ev));
|
|
178
|
+
this.d.agent.onExit = (code) => this.exited(code);
|
|
179
|
+
this.tui.start();
|
|
180
|
+
this.d.agent.start();
|
|
181
|
+
this.timer = setInterval(() => {
|
|
182
|
+
if (!this.busy || this.frames.length < 2) return;
|
|
183
|
+
this.frame++;
|
|
184
|
+
this.tui.requestRender();
|
|
185
|
+
}, 160);
|
|
186
|
+
this.timer.unref();
|
|
187
|
+
await this.load();
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** The conversation, the commands and the usage of the session that pi has open. */
|
|
191
|
+
async load(): Promise<void> {
|
|
192
|
+
try {
|
|
193
|
+
const state = await this.d.agent.request({ type: "get_state" });
|
|
194
|
+
this.model = state?.model?.id ?? this.model;
|
|
195
|
+
this.sessionFile = state?.sessionFile ?? this.sessionFile;
|
|
196
|
+
const messages = (await this.d.agent.request({ type: "get_messages" }))?.messages ?? [];
|
|
197
|
+
this.entries = entriesFromMessages(messages);
|
|
198
|
+
const commands = withHints((await this.d.agent.request({ type: "get_commands" }))?.commands ?? []);
|
|
199
|
+
this.editor.setAutocompleteProvider(new CombinedAutocompleteProvider(this.slashCommands(commands), this.d.cwd));
|
|
200
|
+
} catch (err) {
|
|
201
|
+
this.notice("error", (err as Error).message);
|
|
202
|
+
}
|
|
203
|
+
await this.refreshStats();
|
|
204
|
+
await this.refreshConversations();
|
|
205
|
+
this.tui.requestRender();
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
private slashCommands(commands: Array<Record<string, any>>): SlashCommand[] {
|
|
209
|
+
const hidden = /^subsub-/;
|
|
210
|
+
const pt = this.lang === "pt";
|
|
211
|
+
const own: SlashCommand[] = [
|
|
212
|
+
{ name: "notes", description: pt ? "As notas da pasta do Sub-Sub" : "The notes in your Sub-Sub folder" },
|
|
213
|
+
{ name: "library", description: pt ? "Pesquisar na biblioteca à medida que escreve" : "Search your library as you type" },
|
|
214
|
+
{ name: "actions", description: pt ? "As ações da vista no navegador" : "The actions of the browser view" },
|
|
215
|
+
{ name: "sessions", description: pt ? "Conversas anteriores" : "Earlier conversations" },
|
|
216
|
+
{ name: "new", description: pt ? "Uma conversa nova" : "A new conversation" },
|
|
217
|
+
{ name: "model", description: pt ? "Escolher o modelo" : "Choose the model" },
|
|
218
|
+
{ name: "restart", description: pt ? "Recomeçar o pi na mesma conversa" : "Start pi again in the same conversation" },
|
|
219
|
+
{ name: "quit", description: pt ? "Sair" : "Quit" },
|
|
220
|
+
];
|
|
221
|
+
const fromPi: SlashCommand[] = commands
|
|
222
|
+
.filter((c) => !hidden.test(String(c.name)))
|
|
223
|
+
.map((c) => {
|
|
224
|
+
const cmd: SlashCommand = { name: String(c.name), description: c.description, argumentHint: c.hint };
|
|
225
|
+
if (c.name === "read") {
|
|
226
|
+
cmd.getArgumentCompletions = (prefix: string) => {
|
|
227
|
+
if (/\s/.test(prefix.trim())) return null;
|
|
228
|
+
const found = Object.keys(PURPOSES).filter((k) => k.startsWith(prefix.trim().toLowerCase()));
|
|
229
|
+
return found.length ? found.map((k) => ({ value: k, label: PURPOSES[k as keyof typeof PURPOSES][this.lang === "pt" ? 1 : 0] })) : null;
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
return cmd;
|
|
233
|
+
});
|
|
234
|
+
return [...own, ...fromPi];
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
async refreshStats(): Promise<void> {
|
|
238
|
+
try {
|
|
239
|
+
const s = await this.d.agent.request({ type: "get_session_stats" });
|
|
240
|
+
this.tokens = s?.tokens?.total ?? null;
|
|
241
|
+
this.cost = typeof s?.cost === "number" ? s.cost : null;
|
|
242
|
+
this.percent = s?.contextUsage?.percent ?? null;
|
|
243
|
+
this.window = s?.contextUsage?.contextWindow;
|
|
244
|
+
} catch {
|
|
245
|
+
/* keep what is shown */
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
async refreshConversations(): Promise<void> {
|
|
250
|
+
try {
|
|
251
|
+
const list = (await this.d.sessions?.()) ?? [];
|
|
252
|
+
this.conversations = list.slice(0, 12).map((s) => ({ title: SHORT(s.title || "...", SIDE_WIDTH - 4), open: Boolean(s.path && s.path === this.sessionFile) }));
|
|
253
|
+
} catch {
|
|
254
|
+
this.conversations = [];
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
async stop(): Promise<void> {
|
|
259
|
+
if (this.stopped) return;
|
|
260
|
+
this.stopped = true;
|
|
261
|
+
if (this.timer) clearInterval(this.timer);
|
|
262
|
+
this.unlisten?.();
|
|
263
|
+
this.tui.stop();
|
|
264
|
+
await this.d.agent.stop();
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
private exited(code: number | null): void {
|
|
268
|
+
if (this.stopped) return;
|
|
269
|
+
this.busy = false;
|
|
270
|
+
const pt = this.lang === "pt";
|
|
271
|
+
const parts = [`${pt ? "O pi parou" : "pi stopped"} (${code}).`, this.d.agent.stderr.slice(-300).trim(), pt ? "Escreva /restart para recomeçar." : "Type /restart to start it again."];
|
|
272
|
+
this.notice("error", parts.filter(Boolean).join(" "));
|
|
273
|
+
this.tui.requestRender();
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// ---------------------------------------------------------------- events
|
|
277
|
+
|
|
278
|
+
private notice(level: "info" | "warning" | "error", text: string): void {
|
|
279
|
+
this.entries.push({ kind: "notice", level, text });
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
handle(ev: any): void {
|
|
283
|
+
if (ev?.type === "extension_ui_request") {
|
|
284
|
+
if (isDialog(ev)) return this.dialog(ev);
|
|
285
|
+
const kind = this.side.take(ev);
|
|
286
|
+
if (kind === "state") return this.sideState();
|
|
287
|
+
if (ev.method === "notify") this.notice(ev.notifyType === "warning" ? "warning" : ev.notifyType === "error" ? "error" : "info", String(ev.message ?? ""));
|
|
288
|
+
return this.tui.requestRender();
|
|
289
|
+
}
|
|
290
|
+
switch (ev?.type) {
|
|
291
|
+
case "agent_start":
|
|
292
|
+
this.busy = true;
|
|
293
|
+
this.working = this.lang === "pt" ? "A trabalhar..." : "Working...";
|
|
294
|
+
break;
|
|
295
|
+
case "agent_settled":
|
|
296
|
+
this.busy = false;
|
|
297
|
+
this.current = undefined;
|
|
298
|
+
void this.refreshStats().then(() => this.tui.requestRender());
|
|
299
|
+
void this.refreshConversations().then(() => this.tui.requestRender());
|
|
300
|
+
break;
|
|
301
|
+
case "message_start":
|
|
302
|
+
if (ev.message?.role === "assistant") {
|
|
303
|
+
this.current = { kind: "assistant", text: "" };
|
|
304
|
+
this.entries.push(this.current);
|
|
305
|
+
}
|
|
306
|
+
break;
|
|
307
|
+
case "message_update":
|
|
308
|
+
if (ev.assistantMessageEvent?.type === "text_delta" && this.current) this.current.text += ev.assistantMessageEvent.delta ?? "";
|
|
309
|
+
break;
|
|
310
|
+
case "message_end": {
|
|
311
|
+
const m = ev.message ?? {};
|
|
312
|
+
if (m.role === "assistant") {
|
|
313
|
+
const text = messageText(m.content);
|
|
314
|
+
if (this.current) {
|
|
315
|
+
if (text.trim()) this.current.text = text;
|
|
316
|
+
else this.entries.splice(this.entries.indexOf(this.current), 1);
|
|
317
|
+
} else if (text.trim()) this.entries.push({ kind: "assistant", text });
|
|
318
|
+
this.current = undefined;
|
|
319
|
+
} else if (m.role === "user") {
|
|
320
|
+
const first = messageText(m.content).trim().split("\n")[0];
|
|
321
|
+
if (first && !first.startsWith("/subsub-")) this.entries.push({ kind: "user", text: first });
|
|
322
|
+
}
|
|
323
|
+
break;
|
|
324
|
+
}
|
|
325
|
+
case "tool_execution_start": {
|
|
326
|
+
const t: Extract<Entry, { kind: "tool" }> = { kind: "tool", id: ev.toolCallId, name: String(ev.toolName), args: ev.args, state: "pending" };
|
|
327
|
+
this.tools.set(ev.toolCallId, t);
|
|
328
|
+
this.entries.push(t);
|
|
329
|
+
break;
|
|
330
|
+
}
|
|
331
|
+
case "tool_execution_end": {
|
|
332
|
+
const t = this.tools.get(ev.toolCallId);
|
|
333
|
+
if (t) {
|
|
334
|
+
const out = messageText(ev.result?.content);
|
|
335
|
+
t.output = out;
|
|
336
|
+
t.state = ev.isError ? "failed" : "done";
|
|
337
|
+
if (ev.isError) t.error = out.trim().split("\n")[0];
|
|
338
|
+
}
|
|
339
|
+
break;
|
|
340
|
+
}
|
|
341
|
+
case "auto_retry_start":
|
|
342
|
+
this.notice("warning", this.lang === "pt" ? "A tentar outra vez..." : "Trying again...");
|
|
343
|
+
break;
|
|
344
|
+
case "extension_error":
|
|
345
|
+
this.notice("error", String(ev.error ?? "extension error"));
|
|
346
|
+
break;
|
|
347
|
+
}
|
|
348
|
+
this.tui.requestRender();
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/** The state that the extension sends: the mode (and so the colours), the library, the model, the servers that are down. */
|
|
352
|
+
private sideState(): void {
|
|
353
|
+
const s = this.side.state as { mode?: Mode; model?: { id?: string } | null } | null;
|
|
354
|
+
if (s?.mode && s.mode !== this.mode) {
|
|
355
|
+
this.mode = s.mode;
|
|
356
|
+
this.palette = this.d.palette(this.themeFor(this.mode));
|
|
357
|
+
}
|
|
358
|
+
if (s?.model?.id) this.model = s.model.id;
|
|
359
|
+
this.tui.requestRender();
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* A dialog of the extension. Nothing is applied without an answer, and anything but a yes is a no: the approval screen (src/dialogs.ts) answers
|
|
364
|
+
* `confirm`, a list answers `select`, a line answers `input` and `editor`. One dialog at a time, in the order they came.
|
|
365
|
+
*/
|
|
366
|
+
protected dialog(ev: any): void {
|
|
367
|
+
this.dialogs.push(ev);
|
|
368
|
+
if (!this.dialogHandle) this.nextDialog();
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
private reply(id: string, payload: Record<string, unknown>): void {
|
|
372
|
+
this.d.agent.write({ type: "extension_ui_response", id, ...payload });
|
|
373
|
+
this.dialogHandle?.hide();
|
|
374
|
+
this.dialogHandle = undefined;
|
|
375
|
+
this.tui.requestRender();
|
|
376
|
+
this.nextDialog();
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
private nextDialog(): void {
|
|
380
|
+
const ev = this.dialogs.shift();
|
|
381
|
+
if (!ev) return;
|
|
382
|
+
const c = this.common();
|
|
383
|
+
const show = (component: Component, options: Record<string, unknown>) => {
|
|
384
|
+
this.dialogHandle = this.tui.showOverlay(component, options as never);
|
|
385
|
+
this.tui.requestRender();
|
|
386
|
+
};
|
|
387
|
+
const centered = { width: "72%", minWidth: 44, maxHeight: "80%", anchor: "center" };
|
|
388
|
+
const title = String(ev.title ?? "Sub-Sub");
|
|
389
|
+
if (ev.method === "confirm") {
|
|
390
|
+
const p = this.palette.paint();
|
|
391
|
+
const body = paintPreview(String(ev.message ?? ""), p.ok, p.strike, p.text);
|
|
392
|
+
show(new ApprovalView({ title, body, lang: this.lang, paint: p, glyphs: this.d.glyphs, rows: c.rows, redraw: c.redraw, done: (yes) => this.reply(ev.id, { confirmed: yes }) }), { width: "92%", minWidth: 40, maxHeight: "92%", anchor: "center", margin: 1 });
|
|
393
|
+
} else if (ev.method === "select") {
|
|
394
|
+
const choices: Choice[] = (ev.options ?? []).map((o: unknown) => ({ label: String(o), value: String(o) }));
|
|
395
|
+
show(new ChoiceView({ ...c, title, choices, choose: (value) => this.reply(ev.id, { value }), cancel: () => this.reply(ev.id, { cancelled: true }) }), centered);
|
|
396
|
+
} else if (ev.method === "input" || ev.method === "editor") {
|
|
397
|
+
show(new InputView({ ...c, title, placeholder: ev.placeholder, prefill: ev.prefill, submit: (text) => this.reply(ev.id, { value: text }), cancel: () => this.reply(ev.id, { cancelled: true }) }), centered);
|
|
398
|
+
} else {
|
|
399
|
+
this.reply(ev.id, ev.method === "confirm" ? { confirmed: false } : { cancelled: true });
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/** /restart: pi again, with the same conversation and mode (after it stopped, or to take up a new add-on or key). */
|
|
404
|
+
async restart(): Promise<void> {
|
|
405
|
+
let session: string | undefined;
|
|
406
|
+
try {
|
|
407
|
+
session = (await this.d.agent.request({ type: "get_state" }))?.sessionFile;
|
|
408
|
+
} catch {
|
|
409
|
+
/* pi is not running: start a new conversation */
|
|
410
|
+
}
|
|
411
|
+
await this.d.agent.stop();
|
|
412
|
+
this.busy = false;
|
|
413
|
+
this.current = undefined;
|
|
414
|
+
this.tools.clear();
|
|
415
|
+
this.dialogs = [];
|
|
416
|
+
this.dialogHandle?.hide();
|
|
417
|
+
this.dialogHandle = undefined;
|
|
418
|
+
this.d.agent.start([`--${this.mode}`, ...(session ? ["--session", session] : [])]);
|
|
419
|
+
await this.load();
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/** /model: the models that are connected; the choice is the model of this conversation. */
|
|
423
|
+
async openModels(): Promise<void> {
|
|
424
|
+
if (this.overlay) return;
|
|
425
|
+
let models: Array<{ provider: string; id: string; name?: string }> = [];
|
|
426
|
+
try {
|
|
427
|
+
models = (await this.d.agent.request({ type: "get_available_models" }))?.models ?? [];
|
|
428
|
+
} catch (err) {
|
|
429
|
+
this.notice("error", (err as Error).message);
|
|
430
|
+
return void this.tui.requestRender();
|
|
431
|
+
}
|
|
432
|
+
const choices: Choice[] = models.map((m) => ({ label: m.name ?? m.id, hint: m.provider, value: `${m.provider}/${m.id}` }));
|
|
433
|
+
this.open(
|
|
434
|
+
new ChoiceView({
|
|
435
|
+
...this.common(),
|
|
436
|
+
title: this.lang === "pt" ? "Modelo" : "Model",
|
|
437
|
+
choices,
|
|
438
|
+
choose: (value) => {
|
|
439
|
+
this.close();
|
|
440
|
+
const i = value.indexOf("/");
|
|
441
|
+
void this.d.agent.request({ type: "set_model", provider: value.slice(0, i), modelId: value.slice(i + 1) }).then((m) => {
|
|
442
|
+
this.model = m?.id ?? value.slice(i + 1);
|
|
443
|
+
this.tui.requestRender();
|
|
444
|
+
}).catch((err) => this.notice("error", (err as Error).message));
|
|
445
|
+
},
|
|
446
|
+
cancel: () => this.close(),
|
|
447
|
+
}),
|
|
448
|
+
true,
|
|
449
|
+
);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// ---------------------------------------------------------------- screens
|
|
453
|
+
|
|
454
|
+
private open(component: Component, centered = false): void {
|
|
455
|
+
this.overlay = this.tui.showOverlay(component, centered ? { width: "72%", minWidth: 44, maxHeight: "80%", anchor: "center" } : { width: "100%", maxHeight: "100%", anchor: "top-left", margin: 0 });
|
|
456
|
+
this.tui.requestRender();
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
private close(): void {
|
|
460
|
+
this.overlay?.hide();
|
|
461
|
+
this.overlay = undefined;
|
|
462
|
+
this.tui.setFocus(this.editor);
|
|
463
|
+
this.tui.requestRender();
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
private common() {
|
|
467
|
+
return { lang: this.lang, paint: this.palette.paint(), glyphs: this.d.glyphs, rows: () => this.d.terminal.rows, redraw: () => this.tui.requestRender() };
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/** Alt+N, /notes: the notes of the Sub-Sub folder, newest first, with the chosen note beside the list. */
|
|
471
|
+
async openNotes(): Promise<void> {
|
|
472
|
+
if (this.overlay) return;
|
|
473
|
+
const root = this.d.vault ?? this.d.cwd;
|
|
474
|
+
this.open(
|
|
475
|
+
new NotesView({
|
|
476
|
+
...this.common(),
|
|
477
|
+
notes: listNotes(root),
|
|
478
|
+
read: (path) => {
|
|
479
|
+
const file = allowedFile(path, root, this.d.vault);
|
|
480
|
+
return file && statSync(file).size < 2_000_000 ? readFileSync(file, "utf8") : "";
|
|
481
|
+
},
|
|
482
|
+
markdown: (text, width) => new Markdown(text, 1, 0, this.palette.markdownTheme() as never).render(width),
|
|
483
|
+
open: (path) => {
|
|
484
|
+
const file = allowedFile(path, root, this.d.vault);
|
|
485
|
+
if (file && OPENABLE.has(extname(file).toLowerCase())) openFile(file, this.d.vault);
|
|
486
|
+
},
|
|
487
|
+
done: () => this.close(),
|
|
488
|
+
}),
|
|
489
|
+
);
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/** Alt+L, /library: search as you type; Enter puts /read <item> in the editor (the Librarian gets the key), ctrl+n puts /lit-note <item>. */
|
|
493
|
+
async openLibrary(): Promise<void> {
|
|
494
|
+
if (this.overlay) return;
|
|
495
|
+
const ref = (it: LibraryItem) => it.citekey || it.key;
|
|
496
|
+
this.open(
|
|
497
|
+
new LibraryView({
|
|
498
|
+
...this.common(),
|
|
499
|
+
researcher: this.mode === "researcher",
|
|
500
|
+
delay: this.d.searchDelay,
|
|
501
|
+
search: async (q) => {
|
|
502
|
+
const r = await findLibrary(this.d.agent, this.side, q);
|
|
503
|
+
if (r.error) throw new Error(r.error);
|
|
504
|
+
return ((r.items ?? []) as LibraryItem[]).filter((it) => it && it.key);
|
|
505
|
+
},
|
|
506
|
+
choose: (it) => {
|
|
507
|
+
this.close();
|
|
508
|
+
this.editor.setText(this.mode === "researcher" ? `/read ${ref(it)}` : ref(it));
|
|
509
|
+
},
|
|
510
|
+
note: (it) => {
|
|
511
|
+
this.close();
|
|
512
|
+
this.editor.setText(`/lit-note ${ref(it)}`);
|
|
513
|
+
},
|
|
514
|
+
done: () => this.close(),
|
|
515
|
+
}),
|
|
516
|
+
);
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/** Alt+A, /actions: the actions of the browser view; the choice goes into the editor. */
|
|
520
|
+
async openActions(): Promise<void> {
|
|
521
|
+
if (this.overlay) return;
|
|
522
|
+
const w = this.d.words ?? (await loadWords());
|
|
523
|
+
const starbuck = Boolean((this.side.state as { starbuck?: boolean } | null)?.starbuck);
|
|
524
|
+
const list = [...(this.mode === "researcher" ? w.ACTIONS.research : []), ...w.ACTIONS.library].filter((a) => a.needs !== "starbuck" || starbuck);
|
|
525
|
+
const choices: Choice[] = list.map((a) => ({ label: a[this.lang], hint: `/${a.cmd}`, value: a.cmd }));
|
|
526
|
+
this.open(
|
|
527
|
+
new ChoiceView({
|
|
528
|
+
...this.common(),
|
|
529
|
+
title: this.lang === "pt" ? "Ações" : "Actions",
|
|
530
|
+
choices,
|
|
531
|
+
choose: (cmd) => {
|
|
532
|
+
this.close();
|
|
533
|
+
this.editor.setText(cmd.endsWith(" ") ? `/${cmd}` : `/${cmd} `);
|
|
534
|
+
},
|
|
535
|
+
cancel: () => this.close(),
|
|
536
|
+
}),
|
|
537
|
+
true,
|
|
538
|
+
);
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/** /sessions: earlier conversations of this folder; the choice opens it. */
|
|
542
|
+
async openSessions(): Promise<void> {
|
|
543
|
+
if (this.overlay) return;
|
|
544
|
+
const list = (await this.d.sessions?.()) ?? [];
|
|
545
|
+
const choices: Choice[] = list.filter((s) => s.path).map((s) => ({ label: SHORT(s.title || "...", 70), value: s.path as string }));
|
|
546
|
+
this.open(
|
|
547
|
+
new ChoiceView({
|
|
548
|
+
...this.common(),
|
|
549
|
+
title: this.lang === "pt" ? "Conversas" : "Conversations",
|
|
550
|
+
choices,
|
|
551
|
+
choose: (path) => {
|
|
552
|
+
this.close();
|
|
553
|
+
void this.d.agent.request({ type: "switch_session", sessionPath: path }).then(() => this.load()).catch((err) => this.notice("error", (err as Error).message));
|
|
554
|
+
},
|
|
555
|
+
cancel: () => this.close(),
|
|
556
|
+
}),
|
|
557
|
+
true,
|
|
558
|
+
);
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/** /new: an empty conversation. */
|
|
562
|
+
async newConversation(): Promise<void> {
|
|
563
|
+
try {
|
|
564
|
+
await this.d.agent.request({ type: "new_session" });
|
|
565
|
+
await this.load();
|
|
566
|
+
} catch (err) {
|
|
567
|
+
this.notice("error", (err as Error).message);
|
|
568
|
+
this.tui.requestRender();
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
// ---------------------------------------------------------------- input
|
|
573
|
+
|
|
574
|
+
/** Keys that count whatever has the focus. Returns true when the key is used. */
|
|
575
|
+
private globalKey(data: string): boolean {
|
|
576
|
+
if (this.overlay || this.dialogHandle) return false; // the open screen has its own keys
|
|
577
|
+
const shortcuts: Record<string, () => void> = {
|
|
578
|
+
"alt+n": () => void this.openNotes(),
|
|
579
|
+
"alt+l": () => void this.openLibrary(),
|
|
580
|
+
"alt+a": () => void this.openActions(),
|
|
581
|
+
"alt+m": () => this.switchMode(),
|
|
582
|
+
// The same on the function keys, for terminals where Alt (Option on a Mac) does not send Escape plus the letter.
|
|
583
|
+
f2: () => void this.openNotes(),
|
|
584
|
+
f3: () => void this.openLibrary(),
|
|
585
|
+
f4: () => void this.openActions(),
|
|
586
|
+
f5: () => this.switchMode(),
|
|
587
|
+
};
|
|
588
|
+
for (const [key, run] of Object.entries(shortcuts)) {
|
|
589
|
+
if (matchesKey(data, key as never)) {
|
|
590
|
+
run();
|
|
591
|
+
return true;
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
if (matchesKey(data, "ctrl+c")) {
|
|
595
|
+
const now = Date.now();
|
|
596
|
+
if (this.editor.getText()) this.editor.setText("");
|
|
597
|
+
else if (now - this.lastCtrlC < 800) void this.quit();
|
|
598
|
+
else if (this.busy) void this.d.agent.request({ type: "abort" }).catch(() => {});
|
|
599
|
+
this.lastCtrlC = now;
|
|
600
|
+
this.tui.requestRender();
|
|
601
|
+
return true;
|
|
602
|
+
}
|
|
603
|
+
if (matchesKey(data, "escape") && this.busy) {
|
|
604
|
+
void this.d.agent.request({ type: "abort" }).catch(() => {});
|
|
605
|
+
return true;
|
|
606
|
+
}
|
|
607
|
+
if (matchesKey(data, "ctrl+o")) {
|
|
608
|
+
this.expanded = !this.expanded;
|
|
609
|
+
this.tui.requestRender();
|
|
610
|
+
return true;
|
|
611
|
+
}
|
|
612
|
+
return false;
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
private switchMode(): void {
|
|
616
|
+
void this.d.agent.request({ type: "prompt", message: this.mode === "researcher" ? "/librarian" : "/researcher" }).catch(() => {});
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
/** What is in the editor (for tests and for the screens that fill it). */
|
|
620
|
+
editorText(): string {
|
|
621
|
+
return this.editor.getText();
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
async quit(): Promise<void> {
|
|
625
|
+
await this.stop();
|
|
626
|
+
this.d.onQuit?.(0);
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
/** A line from the editor: /quit ends the client, everything else goes to pi (as a follow-up while pi is busy). */
|
|
630
|
+
async submit(text: string): Promise<void> {
|
|
631
|
+
const line = text.trim();
|
|
632
|
+
if (!line) return;
|
|
633
|
+
this.editor.addToHistory?.(line);
|
|
634
|
+
this.editor.setText("");
|
|
635
|
+
if (/^\/(quit|exit)\s*$/.test(line)) return void (await this.quit());
|
|
636
|
+
const unavailable = /^\/(login|logout|settings|tree|resume|fork|share|export|import|hotkeys|reload)\b/.exec(line);
|
|
637
|
+
if (unavailable) {
|
|
638
|
+
this.notice("info", this.lang === "pt" ? `/${unavailable[1]} não existe em subsub tui. Use subsub no terminal ou subsub web.` : `/${unavailable[1]} is not in subsub tui. Use subsub in the terminal, or subsub web.`);
|
|
639
|
+
return void this.tui.requestRender();
|
|
640
|
+
}
|
|
641
|
+
const own: Record<string, () => Promise<void> | void> = { "/restart": () => this.restart(), "/model": () => this.openModels(), "/notes": () => this.openNotes(), "/library": () => this.openLibrary(), "/actions": () => this.openActions(), "/sessions": () => this.openSessions(), "/new": () => this.newConversation() };
|
|
642
|
+
if (own[line]) return void (await own[line]());
|
|
643
|
+
try {
|
|
644
|
+
await this.d.agent.request({ type: "prompt", message: line, ...(this.busy ? { streamingBehavior: "followUp" } : {}) });
|
|
645
|
+
} catch (err) {
|
|
646
|
+
this.notice("error", (err as Error).message);
|
|
647
|
+
this.tui.requestRender();
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
// ---------------------------------------------------------------- the command
|
|
653
|
+
|
|
654
|
+
export interface TuiFlags {
|
|
655
|
+
scheme?: Scheme;
|
|
656
|
+
librarian: boolean;
|
|
657
|
+
here: boolean;
|
|
658
|
+
piArgs: string[];
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
export function parseTuiArgs(args: string[]): TuiFlags {
|
|
662
|
+
const f: TuiFlags = { librarian: false, here: false, piArgs: [] };
|
|
663
|
+
for (const a of args) {
|
|
664
|
+
if (a === "--light") f.scheme = "light";
|
|
665
|
+
else if (a === "--dark") f.scheme = "dark";
|
|
666
|
+
else if (a === "--librarian") f.librarian = true;
|
|
667
|
+
else if (a === "--here") f.here = true;
|
|
668
|
+
else f.piArgs.push(a);
|
|
669
|
+
}
|
|
670
|
+
return f;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
/** `subsub tui`: start pi in RPC mode and draw it on the whole terminal; resolves with the exit code when the user quits. */
|
|
674
|
+
export async function runTui(args: string[], env: NodeJS.ProcessEnv = process.env): Promise<number> {
|
|
675
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
676
|
+
process.stderr.write("subsub tui needs a terminal. In a script, use subsub web or the pi RPC mode.\n");
|
|
677
|
+
return 1;
|
|
678
|
+
}
|
|
679
|
+
const flags = parseTuiArgs(args);
|
|
680
|
+
const home = homedir();
|
|
681
|
+
const cfg = loadConfig(env);
|
|
682
|
+
env.PI_CODING_AGENT_DIR = agentDirFor(env, home);
|
|
683
|
+
const cwd = chooseCwd(process.cwd(), home, cfg.vault, flags.here);
|
|
684
|
+
const scheme = flags.scheme ?? schemeFromEnv(env);
|
|
685
|
+
const words = await loadWords().catch(() => undefined);
|
|
686
|
+
const bin = join(PACKAGE_DIR, "bin", "subsub.js");
|
|
687
|
+
const agent = new Agent(process.execPath, [bin, "--mode", "rpc", "--here", ...(flags.librarian ? ["--librarian"] : []), ...flags.piArgs], cwd, { ...env, SUBSUB_TUI: "1" });
|
|
688
|
+
const themesDir = join(PACKAGE_DIR, "themes");
|
|
689
|
+
const { SessionManager } = await import("@earendil-works/pi-coding-agent");
|
|
690
|
+
return await new Promise<number>((resolve) => {
|
|
691
|
+
const app = new TuiApp({
|
|
692
|
+
terminal: new ProcessTerminal(),
|
|
693
|
+
agent,
|
|
694
|
+
version: packageVersion(),
|
|
695
|
+
lang: uiLanguage(cfg.language),
|
|
696
|
+
cwd,
|
|
697
|
+
palette: (base) => loadPalette(themesDir, base, scheme),
|
|
698
|
+
glyphs: chooseGlyphs(env),
|
|
699
|
+
words,
|
|
700
|
+
vault: cfg.vault,
|
|
701
|
+
quote: cfg.quotes === false ? undefined : QUOTES[Math.floor(Math.random() * QUOTES.length)],
|
|
702
|
+
reduceMotion: Boolean(env.SUBSUB_REDUCE_MOTION && env.SUBSUB_REDUCE_MOTION !== "0"),
|
|
703
|
+
themes: { researcher: typeof cfg.themes === "string" ? cfg.themes : undefined, librarian: cfg.themesLibrarian ?? (typeof cfg.themes === "string" ? cfg.themes : undefined) },
|
|
704
|
+
sessions: async () => (await SessionManager.list(cwd)).filter((s) => s.messageCount > 0).sort((a, b) => +new Date(b.modified) - +new Date(a.modified)).map((s) => ({ title: s.name ?? s.firstMessage.slice(0, 60), path: s.path })),
|
|
705
|
+
onQuit: resolve,
|
|
706
|
+
});
|
|
707
|
+
void app.start();
|
|
708
|
+
});
|
|
709
|
+
}
|
|
710
|
+
|