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