@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/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 (!webView || !ctx || ctx.mode !== "rpc") return;
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 (webView) {
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
- pi.registerShortcut?.("alt+n", { description: "Sub-Sub: notes", handler: (ctx) => openNotes(ctx) });
815
- pi.registerShortcut?.("alt+l", { description: "Sub-Sub: library search", handler: (ctx) => openLibrary(ctx) });
816
- pi.registerShortcut?.("alt+a", { description: "Sub-Sub: actions", handler: (ctx) => openActions(ctx) });
817
- pi.registerShortcut?.("alt+m", { description: "Sub-Sub: switch between the Researcher and the Librarian", handler: (ctx) => switchTo(ctx, mode === "researcher" ? "librarian" : "researcher") });
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", {
@@ -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
+ }