@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.
@@ -0,0 +1,228 @@
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
+ import { readFileSync } from "node:fs";
6
+ import { join } from "node:path";
7
+ import { getTerminalColorMode, wrapTextWithAnsi } from "@earendil-works/pi-tui";
8
+ import { cut, evidenceLine, rule, toolDetail, toolLine } from "./chrome.js";
9
+ import { headerLines } from "./look.js";
10
+ import { UNICODE } from "./skin.js";
11
+ // ---------------------------------------------------------------- colours
12
+ /** A Sub-Sub theme file, read as a table of foreground colours. */
13
+ export class Palette {
14
+ vars;
15
+ colors;
16
+ mode;
17
+ name;
18
+ constructor(file, mode) {
19
+ this.name = file.name ?? "";
20
+ this.vars = file.vars ?? {};
21
+ this.colors = file.colors;
22
+ this.mode = mode;
23
+ }
24
+ /** The colour of a theme role (accent, text, muted, dim, success, warning, mdLink, ...) as an SGR foreground sequence, or "" for the terminal's own. */
25
+ ansi(role) {
26
+ let v = this.colors[role];
27
+ if (typeof v === "string" && v in this.vars)
28
+ v = this.vars[v];
29
+ if (v === undefined || v === "")
30
+ return "";
31
+ if (typeof v === "number")
32
+ return `\x1b[38;5;${v}m`;
33
+ const [r, g, b] = [1, 3, 5].map((i) => parseInt(v.slice(i, i + 2), 16));
34
+ return this.mode === "truecolor" ? `\x1b[38;2;${r};${g};${b}m` : `\x1b[38;5;${rgbTo256(r, g, b)}m`;
35
+ }
36
+ fg(role, text) {
37
+ const on = this.ansi(role);
38
+ return on ? `${on}${text}\x1b[39m` : text;
39
+ }
40
+ bold = (t) => `\x1b[1m${t}\x1b[22m`;
41
+ italic = (t) => `\x1b[3m${t}\x1b[23m`;
42
+ underline = (t) => `\x1b[4m${t}\x1b[24m`;
43
+ strikethrough = (t) => `\x1b[9m${t}\x1b[29m`;
44
+ /** The paint roles of the screens (see Paint in look.ts). */
45
+ paint() {
46
+ return {
47
+ mark: (x) => this.fg("accent", x),
48
+ text: (x) => this.fg("text", x),
49
+ muted: (x) => this.fg("muted", x),
50
+ dim: (x) => this.fg("dim", x),
51
+ ok: (x) => this.fg("success", x),
52
+ pend: (x) => this.fg("warning", x),
53
+ strike: (x) => this.strikethrough(this.fg("muted", x)),
54
+ };
55
+ }
56
+ /** For pi-tui's Markdown component. */
57
+ markdownTheme() {
58
+ return {
59
+ heading: (t) => this.bold(this.fg("mdHeading", t)),
60
+ link: (t) => this.underline(this.fg("mdLink", t)),
61
+ linkUrl: (t) => this.fg("mdLinkUrl", t),
62
+ code: (t) => this.fg("mdCode", t),
63
+ codeBlock: (t) => this.fg("mdCodeBlock", t),
64
+ codeBlockBorder: (t) => this.fg("mdCodeBlockBorder", t),
65
+ quote: (t) => this.italic(this.fg("mdQuote", t)),
66
+ quoteBorder: (t) => this.fg("mdQuoteBorder", t),
67
+ hr: (t) => this.fg("mdHr", t),
68
+ listBullet: (t) => this.fg("mdListBullet", t),
69
+ bold: this.bold,
70
+ italic: this.italic,
71
+ strikethrough: this.strikethrough,
72
+ underline: this.underline,
73
+ };
74
+ }
75
+ /** For pi-tui's Editor. */
76
+ editorTheme() {
77
+ return {
78
+ borderColor: (t) => this.fg("borderMuted", t),
79
+ selectList: {
80
+ selectedPrefix: (t) => this.fg("accent", t),
81
+ selectedText: (t) => this.fg("text", t),
82
+ description: (t) => this.fg("muted", t),
83
+ scrollInfo: (t) => this.fg("dim", t),
84
+ noMatch: (t) => this.fg("muted", t),
85
+ },
86
+ };
87
+ }
88
+ }
89
+ /** The nearest xterm-256 index of an RGB colour (cube or grey ramp). */
90
+ function rgbTo256(r, g, b) {
91
+ const cube = [0, 95, 135, 175, 215, 255];
92
+ const near = (v) => cube.reduce((best, c, i) => (Math.abs(c - v) < Math.abs(cube[best] - v) ? i : best), 0);
93
+ const [ri, gi, bi] = [near(r), near(g), near(b)];
94
+ const grey = Math.round((r + g + b) / 3);
95
+ const greyIndex = Math.min(255, Math.max(232, 232 + Math.round((grey - 8) / 10)));
96
+ const cubeDist = (cube[ri] - r) ** 2 + (cube[gi] - g) ** 2 + (cube[bi] - b) ** 2;
97
+ const greyValue = 8 + (greyIndex - 232) * 10;
98
+ const greyDist = (greyValue - r) ** 2 + (greyValue - g) ** 2 + (greyValue - b) ** 2;
99
+ return greyDist < cubeDist ? greyIndex : 16 + 36 * ri + 6 * gi + bi;
100
+ }
101
+ /** Dark or light terminal: from COLORFGBG ("15;0" is light text on black), else dark. */
102
+ export function schemeFromEnv(env = process.env) {
103
+ const bg = Number((env.COLORFGBG ?? "").split(";").at(-1));
104
+ if (!Number.isFinite(bg) || (env.COLORFGBG ?? "") === "")
105
+ return "dark";
106
+ return bg === 7 || bg >= 9 ? "light" : "dark";
107
+ }
108
+ /** The palette of a theme (glauca for the Researcher, try-works for the Librarian) in the colour mode of the terminal. */
109
+ export function loadPalette(themesDir, base, scheme, mode = getTerminalColorMode() === "256color" ? "256color" : "truecolor") {
110
+ const name = `${base}-${scheme}${mode === "256color" ? "-256" : ""}`;
111
+ return new Palette(JSON.parse(readFileSync(join(themesDir, `${name}.json`), "utf8")), mode);
112
+ }
113
+ /** The text of a message's content: a string, or the text blocks of a list. */
114
+ export function messageText(content) {
115
+ if (typeof content === "string")
116
+ return content;
117
+ if (!Array.isArray(content))
118
+ return "";
119
+ return content.filter((b) => b?.type === "text").map((b) => b.text ?? "").join("\n\n");
120
+ }
121
+ const toolWordOf = (w, lang, name) => {
122
+ const short = name.replace(/^(zotero|scholar)_/, "");
123
+ return w ? (w.TOOLS[lang][short] ?? w.TOOLS.en[short] ?? name) : name;
124
+ };
125
+ /** One entry as lines, without the empty line that separates entries. */
126
+ export function entryLines(e, width, c) {
127
+ const g = c.glyphs ?? UNICODE;
128
+ const p = c.paint;
129
+ const w = Math.max(10, width);
130
+ switch (e.kind) {
131
+ case "user": {
132
+ const lines = wrapTextWithAnsi(e.text, w - 2);
133
+ return lines.map((l, i) => `${i === 0 ? p.mark(g.pointer) : " "} ${p.text(l)}`);
134
+ }
135
+ case "assistant":
136
+ return c.markdown(e.text, w);
137
+ case "tool": {
138
+ 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);
139
+ if (!c.expanded || !e.output?.trim())
140
+ return [head];
141
+ return [head, ...e.output.split("\n").slice(0, 40).map((l) => p.muted(cut(` ${l}`, w)))];
142
+ }
143
+ case "notice": {
144
+ const text = e.level === "info" ? p.muted(cut(e.text, w)) : p.text(cut(`${g.hollow} ${e.text}`, w));
145
+ return [text];
146
+ }
147
+ case "evidence":
148
+ return [evidenceLine(e.text, c.lang, w, p, g)];
149
+ }
150
+ }
151
+ /** The whole transcript: the entries one after another, an empty line between them. */
152
+ export function transcriptLines(entries, width, c) {
153
+ const out = [];
154
+ let previous;
155
+ for (const e of entries) {
156
+ const lines = entryLines(e, width, c);
157
+ if (!lines.length)
158
+ continue;
159
+ // Tool rows and notices belong together; every other change of speaker gets an empty line.
160
+ if (out.length && !(previous?.kind === "tool" && e.kind === "tool"))
161
+ out.push("");
162
+ out.push(...lines);
163
+ previous = e;
164
+ }
165
+ return out;
166
+ }
167
+ /** The messages of an earlier conversation (get_messages) as entries. */
168
+ export function entriesFromMessages(messages) {
169
+ const out = [];
170
+ const tools = new Map();
171
+ for (const m of messages) {
172
+ if (m.role === "user") {
173
+ const text = messageText(m.content).trim();
174
+ if (text && !text.startsWith("/subsub-"))
175
+ out.push({ kind: "user", text });
176
+ }
177
+ else if (m.role === "assistant") {
178
+ for (const b of Array.isArray(m.content) ? m.content : []) {
179
+ if (b.type === "text" && b.text?.trim())
180
+ out.push({ kind: "assistant", text: b.text });
181
+ else if (b.type === "toolCall") {
182
+ const t = { kind: "tool", id: b.id, name: b.name, args: b.arguments, state: "done" };
183
+ tools.set(b.id, t);
184
+ out.push(t);
185
+ }
186
+ }
187
+ }
188
+ else if (m.role === "toolResult") {
189
+ const t = tools.get(m.toolCallId);
190
+ if (t) {
191
+ t.output = messageText(m.content);
192
+ if (m.isError) {
193
+ t.state = "failed";
194
+ t.error = t.output.trim().split("\n")[0];
195
+ }
196
+ }
197
+ }
198
+ }
199
+ return out;
200
+ }
201
+ export const SIDE_WIDTH = 30;
202
+ const SIDE_WORDS = {
203
+ en: { notes: "notes", library: "library", actions: "actions", mode: "mode", conversations: "Conversations", none: "none yet" },
204
+ pt: { notes: "notas", library: "biblioteca", actions: "ações", mode: "modo", conversations: "Conversas", none: "ainda não há" },
205
+ };
206
+ /** The side panel as lines of exactly `width` visible columns are not needed: the layout pads them. */
207
+ export function sideLines(s, width, rows, p, g = UNICODE) {
208
+ const w = Math.max(16, width);
209
+ const words = SIDE_WORDS[s.lang];
210
+ const mode = s.mode === "librarian" ? "Librarian" : "Researcher";
211
+ const lib = s.library.zotero === "reachable" ? `${s.library.items ?? "?"} ${s.lang === "pt" ? "itens" : "items"}` : s.library.zotero === "down" ? `${g.hollow} Zotero` : "Zotero...";
212
+ 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)];
213
+ for (const [key, word] of [["F2", words.notes], ["F3", words.library], ["F4", words.actions], ["F5", words.mode]])
214
+ lines.push(p.muted(cut(` ${key.padEnd(4)}${word}`, w)));
215
+ lines.push(rule(w, "solid", p.muted, g), p.text(cut(` ${words.conversations}`, w)));
216
+ if (!s.conversations.length)
217
+ lines.push(p.dim(cut(` ${words.none}`, w)));
218
+ for (const c of s.conversations) {
219
+ if (lines.length >= rows)
220
+ break;
221
+ lines.push(c.open ? `${p.mark(g.pointer)}${p.text(cut(` ${c.title}`, w - 1))}` : ` ${p.muted(cut(` ${c.title}`, w - 1))}`);
222
+ }
223
+ return lines.slice(0, Math.max(rows, 1));
224
+ }
225
+ /** The top of the transcript at the start: the banner and the quote (the same as the header of `subsub`). */
226
+ export function bannerLines(o) {
227
+ return headerLines({ ...o, quote: o.quote });
228
+ }