@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.
@@ -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
+ }