@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/src/subsub.ts
CHANGED
|
@@ -172,6 +172,8 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
|
|
|
172
172
|
const lookOn = (ctx: ExtensionContext) => standalone && cfg.look !== false && ctx.mode === "tui";
|
|
173
173
|
/** True under `subsub web`: pi runs in RPC mode and the web view shows the state that the terminal header shows. */
|
|
174
174
|
const webView = process.env.SUBSUB_WEB === "1";
|
|
175
|
+
/** The state of the library goes to the client as a status text: for the web view and for the full-screen terminal client (subsub tui). */
|
|
176
|
+
const sideChannel = webView || process.env.SUBSUB_TUI === "1";
|
|
175
177
|
let lastError: string | undefined;
|
|
176
178
|
/** The last provider or tool error of the session, for /feedback. */
|
|
177
179
|
let recentError: string | undefined;
|
|
@@ -182,7 +184,7 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
|
|
|
182
184
|
let webCtx: ExtensionContext | undefined;
|
|
183
185
|
function emitWeb(): void {
|
|
184
186
|
const ctx = webCtx;
|
|
185
|
-
if (!
|
|
187
|
+
if (!sideChannel || !ctx || ctx.mode !== "rpc") return;
|
|
186
188
|
const down = bridge ? Object.keys(bridge.errors) : ["zotero", "scholar"];
|
|
187
189
|
const state = {
|
|
188
190
|
mode,
|
|
@@ -468,7 +470,7 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
|
|
|
468
470
|
setHeader(ctx);
|
|
469
471
|
setFooter(ctx);
|
|
470
472
|
void refreshLibrary();
|
|
471
|
-
} else if (
|
|
473
|
+
} else if (sideChannel) {
|
|
472
474
|
void refreshLibrary();
|
|
473
475
|
}
|
|
474
476
|
if (!ctx.model && !webView) ctx.ui.notify(L("Sub-Sub: no model is connected yet. Type /login and select a provider (or use the model button in subsub web).", "Sub-Sub: ainda não há um modelo ligado. Escreva /login e selecione um fornecedor (ou use o botão do modelo em subsub web)."), "warning");
|
|
@@ -811,10 +813,14 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
|
|
|
811
813
|
pi.registerCommand("notes", { description: "Sub-Sub: the notes in your Sub-Sub folder", handler: async (_args, ctx) => openNotes(ctx) });
|
|
812
814
|
pi.registerCommand("library", { description: "Sub-Sub: search your Zotero library as you type", handler: async (_args, ctx) => openLibrary(ctx) });
|
|
813
815
|
pi.registerCommand("actions", { description: "Sub-Sub: the actions of the web view, as a list", handler: async (_args, ctx) => openActions(ctx) });
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
816
|
+
// Alt works when the terminal sends it as Escape plus the letter; macOS Terminal and iTerm2 do not by default (Option types a symbol), so each also has a function key.
|
|
817
|
+
const keys = (names: string[], description: string, handler: (ctx: ExtensionContext) => Promise<void> | void) => {
|
|
818
|
+
for (const k of names) pi.registerShortcut?.(k as never, { description, handler });
|
|
819
|
+
};
|
|
820
|
+
keys(["alt+n", "f2"], "Sub-Sub: notes", (ctx) => openNotes(ctx));
|
|
821
|
+
keys(["alt+l", "f3"], "Sub-Sub: library search", (ctx) => openLibrary(ctx));
|
|
822
|
+
keys(["alt+a", "f4"], "Sub-Sub: actions", (ctx) => openActions(ctx));
|
|
823
|
+
keys(["alt+m", "f5"], "Sub-Sub: switch between the Researcher and the Librarian", (ctx) => switchTo(ctx, mode === "researcher" ? "librarian" : "researcher"));
|
|
818
824
|
}
|
|
819
825
|
|
|
820
826
|
pi.registerCommand("subsub", {
|
package/src/tui-view.ts
ADDED
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The screen of `subsub tui`, in parts that know nothing about processes: the colours (read from the themes in themes/, since a client of pi's RPC
|
|
3
|
+
* mode has no pi theme), the transcript, the side panel and the footer. The controller in src/tui.ts feeds them.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { readFileSync } from "node:fs";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
import { getTerminalColorMode, wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
|
9
|
+
import { cut, evidenceLine, rule, toolDetail, toolLine, type ToolState } from "./chrome.ts";
|
|
10
|
+
import { headerLines, type LibraryState, type Paint } from "./look.ts";
|
|
11
|
+
import { type Glyphs, UNICODE } from "./skin.ts";
|
|
12
|
+
import type { Words } from "./words.ts";
|
|
13
|
+
|
|
14
|
+
type Lang = "en" | "pt";
|
|
15
|
+
export type Scheme = "dark" | "light";
|
|
16
|
+
|
|
17
|
+
// ---------------------------------------------------------------- colours
|
|
18
|
+
|
|
19
|
+
/** A Sub-Sub theme file, read as a table of foreground colours. */
|
|
20
|
+
export class Palette {
|
|
21
|
+
private readonly vars: Record<string, string | number>;
|
|
22
|
+
private readonly colors: Record<string, string | number>;
|
|
23
|
+
private readonly mode: "truecolor" | "256color";
|
|
24
|
+
readonly name: string;
|
|
25
|
+
|
|
26
|
+
constructor(file: { name?: string; vars?: Record<string, string | number>; colors: Record<string, string | number> }, mode: "truecolor" | "256color") {
|
|
27
|
+
this.name = file.name ?? "";
|
|
28
|
+
this.vars = file.vars ?? {};
|
|
29
|
+
this.colors = file.colors;
|
|
30
|
+
this.mode = mode;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The colour of a theme role (accent, text, muted, dim, success, warning, mdLink, ...) as an SGR foreground sequence, or "" for the terminal's own. */
|
|
34
|
+
private ansi(role: string): string {
|
|
35
|
+
let v: string | number | undefined = this.colors[role];
|
|
36
|
+
if (typeof v === "string" && v in this.vars) v = this.vars[v];
|
|
37
|
+
if (v === undefined || v === "") return "";
|
|
38
|
+
if (typeof v === "number") return `\x1b[38;5;${v}m`;
|
|
39
|
+
const [r, g, b] = [1, 3, 5].map((i) => parseInt((v as string).slice(i, i + 2), 16));
|
|
40
|
+
return this.mode === "truecolor" ? `\x1b[38;2;${r};${g};${b}m` : `\x1b[38;5;${rgbTo256(r, g, b)}m`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
fg(role: string, text: string): string {
|
|
44
|
+
const on = this.ansi(role);
|
|
45
|
+
return on ? `${on}${text}\x1b[39m` : text;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
bold = (t: string) => `\x1b[1m${t}\x1b[22m`;
|
|
49
|
+
italic = (t: string) => `\x1b[3m${t}\x1b[23m`;
|
|
50
|
+
underline = (t: string) => `\x1b[4m${t}\x1b[24m`;
|
|
51
|
+
strikethrough = (t: string) => `\x1b[9m${t}\x1b[29m`;
|
|
52
|
+
|
|
53
|
+
/** The paint roles of the screens (see Paint in look.ts). */
|
|
54
|
+
paint(): Paint {
|
|
55
|
+
return {
|
|
56
|
+
mark: (x) => this.fg("accent", x),
|
|
57
|
+
text: (x) => this.fg("text", x),
|
|
58
|
+
muted: (x) => this.fg("muted", x),
|
|
59
|
+
dim: (x) => this.fg("dim", x),
|
|
60
|
+
ok: (x) => this.fg("success", x),
|
|
61
|
+
pend: (x) => this.fg("warning", x),
|
|
62
|
+
strike: (x) => this.strikethrough(this.fg("muted", x)),
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** For pi-tui's Markdown component. */
|
|
67
|
+
markdownTheme() {
|
|
68
|
+
return {
|
|
69
|
+
heading: (t: string) => this.bold(this.fg("mdHeading", t)),
|
|
70
|
+
link: (t: string) => this.underline(this.fg("mdLink", t)),
|
|
71
|
+
linkUrl: (t: string) => this.fg("mdLinkUrl", t),
|
|
72
|
+
code: (t: string) => this.fg("mdCode", t),
|
|
73
|
+
codeBlock: (t: string) => this.fg("mdCodeBlock", t),
|
|
74
|
+
codeBlockBorder: (t: string) => this.fg("mdCodeBlockBorder", t),
|
|
75
|
+
quote: (t: string) => this.italic(this.fg("mdQuote", t)),
|
|
76
|
+
quoteBorder: (t: string) => this.fg("mdQuoteBorder", t),
|
|
77
|
+
hr: (t: string) => this.fg("mdHr", t),
|
|
78
|
+
listBullet: (t: string) => this.fg("mdListBullet", t),
|
|
79
|
+
bold: this.bold,
|
|
80
|
+
italic: this.italic,
|
|
81
|
+
strikethrough: this.strikethrough,
|
|
82
|
+
underline: this.underline,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** For pi-tui's Editor. */
|
|
87
|
+
editorTheme() {
|
|
88
|
+
return {
|
|
89
|
+
borderColor: (t: string) => this.fg("borderMuted", t),
|
|
90
|
+
selectList: {
|
|
91
|
+
selectedPrefix: (t: string) => this.fg("accent", t),
|
|
92
|
+
selectedText: (t: string) => this.fg("text", t),
|
|
93
|
+
description: (t: string) => this.fg("muted", t),
|
|
94
|
+
scrollInfo: (t: string) => this.fg("dim", t),
|
|
95
|
+
noMatch: (t: string) => this.fg("muted", t),
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** The nearest xterm-256 index of an RGB colour (cube or grey ramp). */
|
|
102
|
+
function rgbTo256(r: number, g: number, b: number): number {
|
|
103
|
+
const cube = [0, 95, 135, 175, 215, 255];
|
|
104
|
+
const near = (v: number) => cube.reduce((best, c, i) => (Math.abs(c - v) < Math.abs(cube[best] - v) ? i : best), 0);
|
|
105
|
+
const [ri, gi, bi] = [near(r), near(g), near(b)];
|
|
106
|
+
const grey = Math.round((r + g + b) / 3);
|
|
107
|
+
const greyIndex = Math.min(255, Math.max(232, 232 + Math.round((grey - 8) / 10)));
|
|
108
|
+
const cubeDist = (cube[ri] - r) ** 2 + (cube[gi] - g) ** 2 + (cube[bi] - b) ** 2;
|
|
109
|
+
const greyValue = 8 + (greyIndex - 232) * 10;
|
|
110
|
+
const greyDist = (greyValue - r) ** 2 + (greyValue - g) ** 2 + (greyValue - b) ** 2;
|
|
111
|
+
return greyDist < cubeDist ? greyIndex : 16 + 36 * ri + 6 * gi + bi;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Dark or light terminal: from COLORFGBG ("15;0" is light text on black), else dark. */
|
|
115
|
+
export function schemeFromEnv(env: NodeJS.ProcessEnv = process.env): Scheme {
|
|
116
|
+
const bg = Number((env.COLORFGBG ?? "").split(";").at(-1));
|
|
117
|
+
if (!Number.isFinite(bg) || (env.COLORFGBG ?? "") === "") return "dark";
|
|
118
|
+
return bg === 7 || bg >= 9 ? "light" : "dark";
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** The palette of a theme (glauca for the Researcher, try-works for the Librarian) in the colour mode of the terminal. */
|
|
122
|
+
export function loadPalette(themesDir: string, base: string, scheme: Scheme, mode: "truecolor" | "256color" = getTerminalColorMode() === "256color" ? "256color" : "truecolor"): Palette {
|
|
123
|
+
const name = `${base}-${scheme}${mode === "256color" ? "-256" : ""}`;
|
|
124
|
+
return new Palette(JSON.parse(readFileSync(join(themesDir, `${name}.json`), "utf8")), mode);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ---------------------------------------------------------------- transcript
|
|
128
|
+
|
|
129
|
+
export type Entry =
|
|
130
|
+
| { kind: "user"; text: string }
|
|
131
|
+
| { kind: "assistant"; text: string }
|
|
132
|
+
| { kind: "tool"; id: string; name: string; args: unknown; state: ToolState; error?: string; output?: string }
|
|
133
|
+
| { kind: "notice"; level: "info" | "warning" | "error"; text: string }
|
|
134
|
+
| { kind: "evidence"; text: string };
|
|
135
|
+
|
|
136
|
+
export interface TranscriptContext {
|
|
137
|
+
paint: Paint;
|
|
138
|
+
glyphs?: Glyphs;
|
|
139
|
+
lang: Lang;
|
|
140
|
+
/** Markdown as lines for a width. */
|
|
141
|
+
markdown: (text: string, width: number) => string[];
|
|
142
|
+
words?: Words;
|
|
143
|
+
/** Show the output of the tool rows. */
|
|
144
|
+
expanded?: boolean;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** The text of a message's content: a string, or the text blocks of a list. */
|
|
148
|
+
export function messageText(content: unknown): string {
|
|
149
|
+
if (typeof content === "string") return content;
|
|
150
|
+
if (!Array.isArray(content)) return "";
|
|
151
|
+
return content.filter((b: any) => b?.type === "text").map((b: any) => b.text ?? "").join("\n\n");
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const toolWordOf = (w: Words | undefined, lang: Lang, name: string) => {
|
|
155
|
+
const short = name.replace(/^(zotero|scholar)_/, "");
|
|
156
|
+
return w ? (w.TOOLS[lang][short] ?? w.TOOLS.en[short] ?? name) : name;
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
/** One entry as lines, without the empty line that separates entries. */
|
|
160
|
+
export function entryLines(e: Entry, width: number, c: TranscriptContext): string[] {
|
|
161
|
+
const g = c.glyphs ?? UNICODE;
|
|
162
|
+
const p = c.paint;
|
|
163
|
+
const w = Math.max(10, width);
|
|
164
|
+
switch (e.kind) {
|
|
165
|
+
case "user": {
|
|
166
|
+
const lines = wrapTextWithAnsi(e.text, w - 2);
|
|
167
|
+
return lines.map((l, i) => `${i === 0 ? p.mark(g.pointer) : " "} ${p.text(l)}`);
|
|
168
|
+
}
|
|
169
|
+
case "assistant":
|
|
170
|
+
return c.markdown(e.text, w);
|
|
171
|
+
case "tool": {
|
|
172
|
+
const head = toolLine({ state: e.state, word: toolWordOf(c.words, c.lang, e.name), detail: toolDetail(e.args), error: e.error, lang: c.lang, width: w }, p, g);
|
|
173
|
+
if (!c.expanded || !e.output?.trim()) return [head];
|
|
174
|
+
return [head, ...e.output.split("\n").slice(0, 40).map((l) => p.muted(cut(` ${l}`, w)))];
|
|
175
|
+
}
|
|
176
|
+
case "notice": {
|
|
177
|
+
const text = e.level === "info" ? p.muted(cut(e.text, w)) : p.text(cut(`${g.hollow} ${e.text}`, w));
|
|
178
|
+
return [text];
|
|
179
|
+
}
|
|
180
|
+
case "evidence":
|
|
181
|
+
return [evidenceLine(e.text, c.lang, w, p, g)];
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** The whole transcript: the entries one after another, an empty line between them. */
|
|
186
|
+
export function transcriptLines(entries: Entry[], width: number, c: TranscriptContext): string[] {
|
|
187
|
+
const out: string[] = [];
|
|
188
|
+
let previous: Entry | undefined;
|
|
189
|
+
for (const e of entries) {
|
|
190
|
+
const lines = entryLines(e, width, c);
|
|
191
|
+
if (!lines.length) continue;
|
|
192
|
+
// Tool rows and notices belong together; every other change of speaker gets an empty line.
|
|
193
|
+
if (out.length && !(previous?.kind === "tool" && e.kind === "tool")) out.push("");
|
|
194
|
+
out.push(...lines);
|
|
195
|
+
previous = e;
|
|
196
|
+
}
|
|
197
|
+
return out;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** The messages of an earlier conversation (get_messages) as entries. */
|
|
201
|
+
export function entriesFromMessages(messages: any[]): Entry[] {
|
|
202
|
+
const out: Entry[] = [];
|
|
203
|
+
const tools = new Map<string, Extract<Entry, { kind: "tool" }>>();
|
|
204
|
+
for (const m of messages) {
|
|
205
|
+
if (m.role === "user") {
|
|
206
|
+
const text = messageText(m.content).trim();
|
|
207
|
+
if (text && !text.startsWith("/subsub-")) out.push({ kind: "user", text });
|
|
208
|
+
} else if (m.role === "assistant") {
|
|
209
|
+
for (const b of Array.isArray(m.content) ? m.content : []) {
|
|
210
|
+
if (b.type === "text" && b.text?.trim()) out.push({ kind: "assistant", text: b.text });
|
|
211
|
+
else if (b.type === "toolCall") {
|
|
212
|
+
const t: Extract<Entry, { kind: "tool" }> = { kind: "tool", id: b.id, name: b.name, args: b.arguments, state: "done" };
|
|
213
|
+
tools.set(b.id, t);
|
|
214
|
+
out.push(t);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
} else if (m.role === "toolResult") {
|
|
218
|
+
const t = tools.get(m.toolCallId);
|
|
219
|
+
if (t) {
|
|
220
|
+
t.output = messageText(m.content);
|
|
221
|
+
if (m.isError) {
|
|
222
|
+
t.state = "failed";
|
|
223
|
+
t.error = t.output.trim().split("\n")[0];
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
return out;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// ---------------------------------------------------------------- side panel and header
|
|
232
|
+
|
|
233
|
+
export interface SideState {
|
|
234
|
+
version: string;
|
|
235
|
+
mode: "researcher" | "librarian";
|
|
236
|
+
lang: Lang;
|
|
237
|
+
library: LibraryState;
|
|
238
|
+
model: string;
|
|
239
|
+
/** Earlier conversations, newest first; the open one is marked. */
|
|
240
|
+
conversations: Array<{ title: string; open?: boolean }>;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export const SIDE_WIDTH = 30;
|
|
244
|
+
|
|
245
|
+
const SIDE_WORDS = {
|
|
246
|
+
en: { notes: "notes", library: "library", actions: "actions", mode: "mode", conversations: "Conversations", none: "none yet" },
|
|
247
|
+
pt: { notes: "notas", library: "biblioteca", actions: "ações", mode: "modo", conversations: "Conversas", none: "ainda não há" },
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
/** The side panel as lines of exactly `width` visible columns are not needed: the layout pads them. */
|
|
251
|
+
export function sideLines(s: SideState, width: number, rows: number, p: Paint, g: Glyphs = UNICODE): string[] {
|
|
252
|
+
const w = Math.max(16, width);
|
|
253
|
+
const words = SIDE_WORDS[s.lang];
|
|
254
|
+
const mode = s.mode === "librarian" ? "Librarian" : "Researcher";
|
|
255
|
+
const lib = s.library.zotero === "reachable" ? `${s.library.items ?? "?"} ${s.lang === "pt" ? "itens" : "items"}` : s.library.zotero === "down" ? `${g.hollow} Zotero` : "Zotero...";
|
|
256
|
+
const lines = [p.mark(cut(` Sub-Sub ${s.version}`, w)), p.text(cut(` ${mode}`, w)), p.muted(cut(` ${lib}`, w)), p.dim(cut(` ${s.model}`, w)), rule(w, "solid", p.muted, g)];
|
|
257
|
+
for (const [key, word] of [["F2", words.notes], ["F3", words.library], ["F4", words.actions], ["F5", words.mode]] as const) lines.push(p.muted(cut(` ${key.padEnd(4)}${word}`, w)));
|
|
258
|
+
lines.push(rule(w, "solid", p.muted, g), p.text(cut(` ${words.conversations}`, w)));
|
|
259
|
+
if (!s.conversations.length) lines.push(p.dim(cut(` ${words.none}`, w)));
|
|
260
|
+
for (const c of s.conversations) {
|
|
261
|
+
if (lines.length >= rows) break;
|
|
262
|
+
lines.push(c.open ? `${p.mark(g.pointer)}${p.text(cut(` ${c.title}`, w - 1))}` : ` ${p.muted(cut(` ${c.title}`, w - 1))}`);
|
|
263
|
+
}
|
|
264
|
+
return lines.slice(0, Math.max(rows, 1));
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** The top of the transcript at the start: the banner and the quote (the same as the header of `subsub`). */
|
|
268
|
+
export function bannerLines(o: { version: string; mode: "researcher" | "librarian"; model: string; library: LibraryState; width: number; paint: Paint; lang: Lang; glyphs?: Glyphs; quote?: [string, string] }): string[] {
|
|
269
|
+
return headerLines({ ...o, quote: o.quote });
|
|
270
|
+
}
|