@vigent/cli 0.0.0-stage → 0.2.1

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,49 @@
1
+ /** `vigent theme new`: a commented theme file, pre-filled with the colors of the theme it starts from. */
2
+ import { extractPalette, loadTheme } from "./theme.js";
3
+ /** A commented theme file: start from a theme, and recolor it from a palette pre-filled with that theme's colors. */
4
+ export function themeNewTemplate(name, from, opts = {}) {
5
+ const base = loadTheme(from);
6
+ const p = extractPalette(base);
7
+ const transparent = opts.transparent ?? Boolean(base.transparent);
8
+ const row = (key, note = "") => `${opts.palette ? "" : "# "}${`${key} = "${p[key]}"`.padEnd(24)}${note ? `# ${note}` : ""}`.trimEnd();
9
+ return `# ${name}: a vigent theme. Edit, then :colorscheme ${name} to see it (the picker previews live).
10
+ # Docs: https://github.com/farming-labs/vigent/blob/main/docs/themes.md
11
+ name = "${name}"
12
+ ${opts.palette ? `# made from ${from}'s colors` : `\n# Start from any built-in or installed theme, then change what you like.\nextends = "${from}"`}
13
+
14
+ # Draw on the terminal's own background (its blur, opacity or wallpaper shows
15
+ # through). Selections, the cursorline and mode badges keep their colors.
16
+ transparent = ${transparent}
17
+
18
+ # ${opts.palette ? "The whole theme comes from these colors" : "Uncomment [palette] to recolor the whole theme from these colors"}
19
+ # (${from}'s, read from its highlight groups). Only bg, fg and the six accents
20
+ # are required; leave out the rest and vigent derives them.
21
+ ${opts.palette ? "" : "# "}[palette]
22
+ ${row("bg", "background")}
23
+ ${row("fg", "text")}
24
+ ${row("red")}
25
+ ${row("yellow")}
26
+ ${row("green")}
27
+ ${row("cyan")}
28
+ ${row("blue")}
29
+ ${row("magenta")}
30
+ ${row("bg_dark", "sidebar, floats")}
31
+ ${row("bg_alt", "cursorline, statusline")}
32
+ ${row("selection", "visual selection")}
33
+ ${row("muted", "comments")}
34
+ ${row("subtle", "line numbers, separators")}
35
+ ${row("orange")}
36
+ ${row("accent", "current line number")}
37
+
38
+ # Any highlight group, by its Neovim name: a table, a :hi string, or a group to link to.
39
+ # These win over extends and [palette]. Tweak live with :hi, then :theme save ${name}!
40
+ [groups]
41
+ # Normal = { fg = "${p.fg}", bg = "${p.bg}" }
42
+ # Comment = "guifg=${p.muted} gui=italic"
43
+ # VigentSidebar = { bg = "${p.bg_dark}" }
44
+ # VigentModeNormal = { fg = "${p.bg}", bg = "${p.blue}", bold = true }
45
+ # VigentModeInsert = { fg = "${p.bg}", bg = "${p.green}", bold = true }
46
+ # FloatBorder = { fg = "${p.blue}" }
47
+ # CursorLineNr = "Title"
48
+ `;
49
+ }
package/dist/theme.js ADDED
@@ -0,0 +1,487 @@
1
+ /**
2
+ * Vigent theme engine.
3
+ *
4
+ * Themes are keyed by Neovim highlight group names (Normal, StatusLine,
5
+ * Visual, Comment, DiffAdd, ...), so any vim/nvim colorscheme maps on
6
+ * directly. A group is resolved in this order:
7
+ *
8
+ * 1. user overrides (config [highlights] and live `:hi`) and the theme's
9
+ * own definition, following `link`s
10
+ * 2. colors derived from the theme for vigent's own groups the theme does
11
+ * not define (mode badges, sidebar background)
12
+ * 3. vim-style fallback links, ending at Normal
13
+ *
14
+ * A group with no foreground uses Normal's foreground, like nvim.
15
+ */
16
+ import * as fs from "node:fs";
17
+ import * as os from "node:os";
18
+ import * as path from "node:path";
19
+ import { parse as parseToml } from "smol-toml";
20
+ import { BUILTIN_THEMES } from "./builtin-themes.js";
21
+ import { isLight, mix, nearest256, parseHex } from "./color.js";
22
+ import { fromPalette } from "./palette.js";
23
+ import { didYouMean } from "./suggest.js";
24
+ import { parseHighlightCommand, parseVimColorscheme } from "./vimcolors.js";
25
+ /**
26
+ * Groups that paint a background behind text. A transparent theme drops their
27
+ * background so the terminal's (and its blur or wallpaper) shows through;
28
+ * selections, the cursorline, popup menus and mode badges keep theirs.
29
+ */
30
+ export const TRANSPARENT_GROUPS = new Set([
31
+ "Normal",
32
+ "NormalNC",
33
+ "NormalFloat",
34
+ "FloatBorder",
35
+ "FloatTitle",
36
+ "MsgArea",
37
+ "StatusLine",
38
+ "StatusLineNC",
39
+ "WinBar",
40
+ "WinBarNC",
41
+ "WinSeparator",
42
+ "LineNr",
43
+ "CursorLineNr",
44
+ "SignColumn",
45
+ "EndOfBuffer",
46
+ "VigentSidebar",
47
+ "NeoTreeNormal",
48
+ "NvimTreeNormal",
49
+ ]);
50
+ export const RESET = "\x1b[0m";
51
+ /** Build an SGR escape sequence for an attribute set (24-bit color, or the nearest of 256). */
52
+ export function sgr(attr, mode = "truecolor") {
53
+ const parts = [];
54
+ if (attr.bold)
55
+ parts.push("1");
56
+ if (attr.italic)
57
+ parts.push("3");
58
+ if (attr.underline)
59
+ parts.push("4");
60
+ if (attr.reverse)
61
+ parts.push("7");
62
+ const fg = parseHex(attr.fg);
63
+ if (fg)
64
+ parts.push(mode === "256" ? `38;5;${nearest256(attr.fg)}` : `38;2;${fg[0]};${fg[1]};${fg[2]}`);
65
+ else if (attr.fg === "NONE")
66
+ parts.push("39"); // the terminal's own color, replacing whatever came before
67
+ const bg = parseHex(attr.bg);
68
+ if (bg)
69
+ parts.push(mode === "256" ? `48;5;${nearest256(attr.bg)}` : `48;2;${bg[0]};${bg[1]};${bg[2]}`);
70
+ else if (attr.bg === "NONE")
71
+ parts.push("49"); // transparent: the terminal's own background
72
+ return parts.length === 0 ? "" : `\x1b[${parts.join(";")}m`;
73
+ }
74
+ /** Fallback links, mirroring how nvim links its default groups. */
75
+ const LINKS = {
76
+ NormalNC: "Normal",
77
+ NormalFloat: "Pmenu",
78
+ FloatBorder: "NormalFloat",
79
+ FloatTitle: "Title",
80
+ WinSeparator: "VertSplit",
81
+ VertSplit: "NonText",
82
+ EndOfBuffer: "NonText",
83
+ CursorLineNr: "LineNr",
84
+ LineNr: "NonText",
85
+ StatusLineNC: "StatusLine",
86
+ WinBar: "Title",
87
+ TabLine: "StatusLineNC",
88
+ TabLineSel: "StatusLine",
89
+ Directory: "Identifier",
90
+ Function: "Identifier",
91
+ Keyword: "Statement",
92
+ Title: "Normal",
93
+ MsgArea: "Normal",
94
+ PmenuSel: "Visual",
95
+ CurSearch: "Search",
96
+ IncSearch: "Search",
97
+ Added: "DiffAdd",
98
+ Removed: "DiffDelete",
99
+ Changed: "DiffChange",
100
+ // tree plugins: imported themes keep their sidebar colors
101
+ NeoTreeNormal: "NvimTreeNormal",
102
+ NeoTreeIndentMarker: "NvimTreeIndentMarker",
103
+ NvimTreeIndentMarker: "NonText",
104
+ // vigent groups
105
+ VigentSidebar: "NeoTreeNormal",
106
+ VigentIndent: "NeoTreeIndentMarker",
107
+ VigentUser: "Title",
108
+ VigentTool: "Function",
109
+ VigentToolOutput: "Comment",
110
+ VigentSpinner: "WarningMsg",
111
+ VigentCode: "String",
112
+ Question: "MoreMsg",
113
+ MoreMsg: "String",
114
+ };
115
+ function hasStyle(a) {
116
+ if (!a)
117
+ return false;
118
+ return Object.entries(a).some(([k, v]) => k !== "link" && v !== undefined);
119
+ }
120
+ /** Colors computed from the theme when it does not define a group itself. */
121
+ const DERIVED = {
122
+ VigentModeNormal: (p) => p.badge(["Function", "Directory", "Identifier"]),
123
+ VigentModeInsert: (p) => p.badge(["String", "MoreMsg", "Added"]),
124
+ VigentModeVisual: (p) => p.badge(["Statement", "Constant", "Special"]),
125
+ VigentModeCommand: (p) => p.badge(["WarningMsg", "Type", "Todo"]),
126
+ // last link in the sidebar chain: shade Normal's background
127
+ NvimTreeNormal: (p) => {
128
+ const n = p.normal();
129
+ if (!parseHex(n.bg))
130
+ return undefined;
131
+ return { fg: n.fg, bg: mix(n.bg, "#000000", isLight(n.bg) ? 0.05 : 0.22) };
132
+ },
133
+ };
134
+ export class Painter {
135
+ theme;
136
+ sgrCache = new Map();
137
+ attrCache = new Map();
138
+ overrides;
139
+ /** bumped whenever colors change, so renderers can drop cached output */
140
+ generation = 0;
141
+ /** 24-bit color, or the nearest of 256 for terminals without it (vim's 'termguicolors') */
142
+ colorMode = "truecolor";
143
+ /** `:set transparent` / `notransparent`; "auto" follows the theme */
144
+ transparency = "auto";
145
+ setTransparency(t) {
146
+ if (t === this.transparency)
147
+ return;
148
+ this.transparency = t;
149
+ this.flush();
150
+ }
151
+ /** Is the terminal's background showing through right now? */
152
+ get transparent() {
153
+ return this.transparency === "auto" ? Boolean(this.theme.transparent) : this.transparency;
154
+ }
155
+ setColorMode(mode) {
156
+ if (mode === this.colorMode)
157
+ return;
158
+ this.colorMode = mode;
159
+ this.flush();
160
+ }
161
+ constructor(theme, overrides = {}) {
162
+ this.theme = theme;
163
+ this.overrides = { ...overrides };
164
+ }
165
+ /** Theme definition with user overrides merged on top. */
166
+ explicit(group) {
167
+ const o = this.overrides[group];
168
+ const t = this.theme.groups[group];
169
+ if (!o)
170
+ return t;
171
+ if (o.link)
172
+ return { link: o.link };
173
+ const base = t && !t.link ? t : {};
174
+ return { ...base, ...o };
175
+ }
176
+ normal() {
177
+ const n = this.explicit("Normal");
178
+ return n && !n.link ? n : {};
179
+ }
180
+ /** Resolve a group without filling in Normal's foreground. */
181
+ resolve(group) {
182
+ const seen = new Set();
183
+ let g = group;
184
+ while (g && !seen.has(g)) {
185
+ seen.add(g);
186
+ const e = this.explicit(g);
187
+ if (e?.link) {
188
+ g = e.link;
189
+ continue;
190
+ }
191
+ if (hasStyle(e))
192
+ return e;
193
+ const derived = DERIVED[g]?.(this);
194
+ if (derived)
195
+ return derived;
196
+ g = LINKS[g];
197
+ }
198
+ return this.normal();
199
+ }
200
+ attr(group) {
201
+ let a = this.attrCache.get(group);
202
+ if (!a) {
203
+ const { link: _link, ...r } = this.resolve(group);
204
+ a = r;
205
+ if (group !== "Normal" && a.fg === undefined && !a.reverse) {
206
+ const fg = this.normal().fg;
207
+ if (fg)
208
+ a = { ...a, fg };
209
+ }
210
+ // transparent: background groups, and anything painted on Normal's background, use the terminal's
211
+ if (this.transparent && a.bg !== undefined && a.bg !== "NONE") {
212
+ const normalBg = this.normal().bg;
213
+ if (TRANSPARENT_GROUPS.has(group) || (normalBg && a.bg.toLowerCase() === normalBg.toLowerCase())) {
214
+ a = { ...a, bg: "NONE" };
215
+ }
216
+ }
217
+ this.attrCache.set(group, a);
218
+ }
219
+ return a;
220
+ }
221
+ /** A mode badge colored from the first candidate group with a usable foreground. */
222
+ badge(candidates) {
223
+ const n = this.normal();
224
+ for (const c of candidates) {
225
+ const color = this.resolve(c).fg;
226
+ if (!parseHex(color) || color === n.fg)
227
+ continue;
228
+ const text = parseHex(n.bg) ? n.bg : isLight(color) ? "#111111" : "#eeeeee";
229
+ return { fg: text, bg: color, bold: true };
230
+ }
231
+ const s = this.resolve("StatusLine");
232
+ return hasStyle(s) ? { ...s, bold: true } : undefined;
233
+ }
234
+ /** SGR prefix for a group (cached). */
235
+ s(group) {
236
+ let v = this.sgrCache.get(group);
237
+ if (v === undefined) {
238
+ v = sgr(this.attr(group), this.colorMode);
239
+ this.sgrCache.set(group, v);
240
+ }
241
+ return v;
242
+ }
243
+ /** SGR prefix for a group drawn on an explicit background (cached). */
244
+ on(group, bg, extra) {
245
+ const key = `${group}|${bg ?? ""}|${extra ? JSON.stringify(extra) : ""}`;
246
+ let v = this.sgrCache.get(key);
247
+ if (v === undefined) {
248
+ v = sgr({ ...this.attr(group), ...extra, bg }, this.colorMode);
249
+ this.sgrCache.set(key, v);
250
+ }
251
+ return v;
252
+ }
253
+ /** Paint text with a group, resetting afterwards. */
254
+ p(group, text) {
255
+ return this.s(group) + text + RESET;
256
+ }
257
+ setTheme(theme) {
258
+ this.theme = theme;
259
+ this.flush();
260
+ }
261
+ setOverrides(overrides) {
262
+ this.overrides = { ...overrides };
263
+ this.flush();
264
+ }
265
+ /** `:hi Group ...`: merge into the group, like vim. */
266
+ highlight(group, attr) {
267
+ const current = this.overrides[group];
268
+ this.overrides[group] = current && !current.link ? { ...current, ...attr } : { ...attr };
269
+ this.flush();
270
+ }
271
+ /** `:hi link From To` */
272
+ link(from, to) {
273
+ this.overrides[from] = { link: to };
274
+ this.flush();
275
+ }
276
+ /** `:hi clear [Group]`: drop overrides, back to the theme's colors. */
277
+ clear(group) {
278
+ if (group)
279
+ delete this.overrides[group];
280
+ else
281
+ this.overrides = {};
282
+ this.flush();
283
+ }
284
+ flush() {
285
+ this.sgrCache.clear();
286
+ this.attrCache.clear();
287
+ this.generation++;
288
+ }
289
+ }
290
+ // ---------------------------------------------------------------------------
291
+ // Loading themes from files
292
+ // ---------------------------------------------------------------------------
293
+ export function configDir() {
294
+ const xdg = process.env.XDG_CONFIG_HOME;
295
+ return path.join(xdg && xdg.length > 0 ? xdg : path.join(os.homedir(), ".config"), "vigent");
296
+ }
297
+ export function themesDir() {
298
+ return path.join(configDir(), "themes");
299
+ }
300
+ const THEME_EXTS = [".toml", ".json", ".vim"];
301
+ const SPEC_KEYS = ["name", "background", "extends", "transparent", "palette", "groups", "highlights"];
302
+ /**
303
+ * Normalize user-written group values. Each value may be:
304
+ * { fg = "#..", bg = "NONE", bold = true } table / object
305
+ * { link = "Visual" } link
306
+ * "guifg=#.. guibg=#.. gui=bold" vim :hi syntax
307
+ * "Visual" shorthand for a link
308
+ */
309
+ export function normalizeGroups(input) {
310
+ const out = {};
311
+ if (!input)
312
+ return out;
313
+ for (const [group, value] of Object.entries(input)) {
314
+ if (typeof value === "string") {
315
+ if (/=/.test(value) || /^(bold|italic|underline|reverse|NONE)\b/i.test(value)) {
316
+ const cmd = parseHighlightCommand(`${group} ${value}`);
317
+ if (cmd?.kind === "set")
318
+ out[group] = cmd.attr;
319
+ }
320
+ else {
321
+ out[group] = { link: value };
322
+ }
323
+ continue;
324
+ }
325
+ if (value && typeof value === "object") {
326
+ const v = value;
327
+ const attr = {};
328
+ for (const key of ["fg", "bg", "link"]) {
329
+ if (typeof v[key] === "string")
330
+ attr[key] = v[key];
331
+ }
332
+ for (const key of ["bold", "italic", "underline", "reverse"]) {
333
+ if (typeof v[key] === "boolean")
334
+ attr[key] = v[key];
335
+ }
336
+ out[group] = attr;
337
+ }
338
+ }
339
+ return out;
340
+ }
341
+ function readSpec(file) {
342
+ const text = fs.readFileSync(file, "utf8");
343
+ if (file.endsWith(".vim")) {
344
+ const parsed = parseVimColorscheme(text);
345
+ return { name: parsed.name, background: parsed.background, groups: parsed.groups };
346
+ }
347
+ const raw = (file.endsWith(".toml") ? parseToml(text) : JSON.parse(text));
348
+ if (!Object.keys(raw).some((k) => SPEC_KEYS.includes(k)))
349
+ return { groups: raw };
350
+ return raw;
351
+ }
352
+ function findThemeFile(name) {
353
+ if (/[/\\]/.test(name) || THEME_EXTS.some((e) => name.endsWith(e))) {
354
+ const p = path.resolve(name.replace(/^~(?=\/)/, os.homedir()));
355
+ return fs.existsSync(p) ? p : undefined;
356
+ }
357
+ for (const ext of THEME_EXTS) {
358
+ const p = path.join(themesDir(), name + ext);
359
+ if (fs.existsSync(p))
360
+ return p;
361
+ }
362
+ return undefined;
363
+ }
364
+ export function buildTheme(spec, fallbackName, seen = new Set()) {
365
+ const name = spec.name ?? fallbackName;
366
+ let groups = {};
367
+ let background = spec.background;
368
+ let transparent = typeof spec.transparent === "boolean" ? spec.transparent : undefined;
369
+ let palette;
370
+ if (spec.extends) {
371
+ if (seen.has(spec.extends))
372
+ throw new Error(`theme '${name}' extends itself through '${spec.extends}'`);
373
+ const base = loadTheme(spec.extends, new Set([...seen, name]));
374
+ groups = { ...base.groups };
375
+ background ??= base.background;
376
+ transparent ??= base.transparent;
377
+ palette = base.palette;
378
+ }
379
+ if (spec.palette) {
380
+ const missing = ["bg", "fg", "red", "yellow", "green", "cyan", "blue", "magenta"].filter((k) => !spec.palette?.[k]);
381
+ if (missing.length > 0)
382
+ throw new Error(`theme '${name}': palette is missing ${missing.join(", ")}`);
383
+ const t = fromPalette(name, spec.palette);
384
+ groups = { ...groups, ...t.groups };
385
+ background ??= t.background;
386
+ palette = t.palette;
387
+ }
388
+ groups = { ...groups, ...normalizeGroups(spec.groups), ...normalizeGroups(spec.highlights) };
389
+ // groups set after the palette may have changed its colors; only keep it when they didn't
390
+ const exactPalette = palette && !spec.groups && !spec.highlights ? palette : undefined;
391
+ return { name, background, groups, ...(transparent ? { transparent } : {}), ...(exactPalette ? { palette: exactPalette } : {}) };
392
+ }
393
+ /** Load a built-in theme, a theme file from ~/.config/vigent/themes, or a path. */
394
+ export function loadTheme(nameOrPath, seen = new Set()) {
395
+ const builtin = BUILTIN_THEMES[nameOrPath];
396
+ if (builtin)
397
+ return builtin;
398
+ const file = findThemeFile(nameOrPath);
399
+ if (!file)
400
+ throw new Error(`E185: Cannot find color scheme '${nameOrPath}'${didYouMean(nameOrPath, listThemes())}`);
401
+ const fallbackName = path.basename(file).replace(/\.(toml|json|vim)$/, "");
402
+ return buildTheme(readSpec(file), fallbackName, seen);
403
+ }
404
+ export function listThemes() {
405
+ const names = new Set(Object.keys(BUILTIN_THEMES));
406
+ try {
407
+ for (const f of fs.readdirSync(themesDir())) {
408
+ const ext = THEME_EXTS.find((e) => f.endsWith(e));
409
+ if (ext)
410
+ names.add(f.slice(0, -ext.length));
411
+ }
412
+ }
413
+ catch {
414
+ // no user themes dir yet
415
+ }
416
+ return [...names].sort((a, b) => (a === "default" ? -1 : b === "default" ? 1 : a.localeCompare(b)));
417
+ }
418
+ /** The palette a theme's colors come from, read back from its groups (for `vigent theme new`). */
419
+ export function extractPalette(theme) {
420
+ if (theme.palette)
421
+ return { ...theme.palette };
422
+ const p = new Painter({ ...theme, transparent: false });
423
+ const fg = (...groups) => {
424
+ for (const g of groups) {
425
+ const c = p.attr(g).fg;
426
+ if (parseHex(c))
427
+ return c;
428
+ }
429
+ return p.attr("Normal").fg ?? "#c0c0c0";
430
+ };
431
+ const bg = (fallback, ...groups) => {
432
+ for (const g of groups) {
433
+ const c = p.attr(g).bg;
434
+ if (parseHex(c))
435
+ return c;
436
+ }
437
+ return fallback;
438
+ };
439
+ const base = bg("#1a1b26", "Normal");
440
+ return {
441
+ bg: base,
442
+ fg: fg("Normal"),
443
+ red: fg("ErrorMsg", "DiagnosticError", "Removed"),
444
+ yellow: fg("WarningMsg", "Type"),
445
+ green: fg("String", "Added"),
446
+ cyan: fg("Special", "Function"),
447
+ blue: fg("Function", "Directory", "Title"),
448
+ magenta: fg("Statement", "Keyword", "Identifier"),
449
+ bg_dark: bg(base, "VigentSidebar", "NormalFloat"),
450
+ bg_alt: bg(base, "CursorLine", "StatusLine"),
451
+ selection: bg(base, "Visual"),
452
+ muted: fg("Comment"),
453
+ subtle: fg("LineNr", "NonText"),
454
+ orange: fg("Constant", "WarningMsg"),
455
+ accent: fg("CursorLineNr", "Title"),
456
+ };
457
+ }
458
+ /** A theme file (TOML) for `:theme save`: what's on screen, as small as it can be. */
459
+ export function themeFileFor(name, painter, builtinBase) {
460
+ const lines = [`# ${name}: saved from vigent with :theme save. Edit freely; reload with :colorscheme ${name}`, `name = "${name}"`];
461
+ const groups = builtinBase ? { ...painter.overrides } : { ...painter.theme.groups, ...painter.overrides };
462
+ if (builtinBase)
463
+ lines.push(`extends = "${painter.theme.name}"`);
464
+ else if (painter.theme.background)
465
+ lines.push(`background = "${painter.theme.background}"`);
466
+ if (painter.transparent !== Boolean(builtinBase && painter.theme.transparent) || (!builtinBase && painter.transparent)) {
467
+ lines.push(`transparent = ${painter.transparent}`);
468
+ }
469
+ lines.push("", "[groups]");
470
+ for (const [group, attr] of Object.entries(groups).sort(([a], [b]) => a.localeCompare(b))) {
471
+ lines.push(`${/^[A-Za-z0-9_-]+$/.test(group) ? group : JSON.stringify(group)} = ${tomlAttr(attr)}`);
472
+ }
473
+ return `${lines.join("\n")}\n`;
474
+ }
475
+ function tomlAttr(a) {
476
+ if (a.link)
477
+ return JSON.stringify(a.link);
478
+ const parts = [];
479
+ for (const k of ["fg", "bg"])
480
+ if (a[k])
481
+ parts.push(`${k} = ${JSON.stringify(a[k])}`);
482
+ for (const k of ["bold", "italic", "underline", "reverse"])
483
+ if (a[k] !== undefined)
484
+ parts.push(`${k} = ${a[k]}`);
485
+ return `{ ${parts.join(", ")} }`;
486
+ }
487
+ export { BUILTIN_THEMES };
package/dist/tree.js ADDED
@@ -0,0 +1,115 @@
1
+ /**
2
+ * `:tree` — the agent's conversation as a tree (pi keeps every branch).
3
+ *
4
+ * Flattens pi's session tree into rows for a floating list: a straight run of
5
+ * messages stays at one level, and only where the conversation branched do
6
+ * the alternatives get ├─ / └─ guides. Tool results are folded into the
7
+ * reply that called them; bookkeeping entries (model changes, labels...) are
8
+ * skipped.
9
+ */
10
+ export const TREE_GLYPHS = {
11
+ unicode: { tee: "├─ ", last: "└─ ", pipe: "│ ", blank: " " },
12
+ ascii: { tee: "|- ", last: "`- ", pipe: "| ", blank: " " },
13
+ };
14
+ function textOf(content) {
15
+ if (typeof content === "string")
16
+ return content;
17
+ if (!Array.isArray(content))
18
+ return "";
19
+ return content
20
+ .filter((b) => b?.type === "text" && typeof b.text === "string")
21
+ .map((b) => b.text)
22
+ .join(" ");
23
+ }
24
+ function oneLine(s) {
25
+ return s.replace(/```[\s\S]*?```/g, "[code]").replace(/\s+/g, " ").trim();
26
+ }
27
+ /** What a tree entry shows as, or undefined to skip it. */
28
+ function describe(entry) {
29
+ if (entry.type === "message") {
30
+ const m = entry.message ?? {};
31
+ if (m.role === "user")
32
+ return { kind: "user", text: oneLine(textOf(m.content)) || "(empty prompt)" };
33
+ if (m.role === "assistant") {
34
+ const text = oneLine(textOf(m.content));
35
+ if (text)
36
+ return { kind: "agent", text };
37
+ const calls = (Array.isArray(m.content) ? m.content : []).filter((b) => b?.type === "toolCall");
38
+ if (calls.length === 0)
39
+ return undefined;
40
+ const names = calls.map((c) => {
41
+ const a = c.arguments ?? {};
42
+ const arg = a.command ?? a.path ?? a.file_path ?? "";
43
+ return `${c.name}${arg ? ` ${String(arg).slice(0, 40)}` : ""}`;
44
+ });
45
+ return { kind: "tool", text: names.join(", ") };
46
+ }
47
+ return undefined; // tool results belong to the call above them
48
+ }
49
+ if (entry.type === "branch_summary")
50
+ return { kind: "summary", text: `summary of another branch: ${oneLine(entry.summary ?? "")}` };
51
+ if (entry.type === "compaction")
52
+ return { kind: "note", text: "earlier messages compacted" };
53
+ return undefined;
54
+ }
55
+ /** Flatten pi's session tree into list rows. */
56
+ export function flattenTree(roots, leafId, glyphs = TREE_GLYPHS.unicode) {
57
+ // the current branch: walk up from the leaf
58
+ const parents = new Map();
59
+ const index = (nodes) => {
60
+ for (const n of nodes) {
61
+ parents.set(n.entry.id, n.entry.parentId);
62
+ index(n.children);
63
+ }
64
+ };
65
+ index(roots);
66
+ const onPath = new Set();
67
+ for (let id = leafId; id; id = parents.get(id) ?? null)
68
+ onPath.add(id);
69
+ // skipped entries hand their children to the nearest shown ancestor
70
+ const visibleChildren = (nodes) => nodes.flatMap((n) => (describe(n.entry) ? [n] : visibleChildren(n.children)));
71
+ // the current position, mapped to the nearest shown entry at or above the leaf
72
+ let current = null;
73
+ for (let id = leafId; id && current === null; id = parents.get(id) ?? null) {
74
+ const n = find(roots, id);
75
+ if (n && describe(n.entry))
76
+ current = id;
77
+ }
78
+ const rows = [];
79
+ const emit = (n, guide) => {
80
+ const d = describe(n.entry);
81
+ rows.push({
82
+ id: n.entry.id,
83
+ guide,
84
+ kind: d.kind,
85
+ text: d.text,
86
+ onPath: onPath.has(n.entry.id),
87
+ current: n.entry.id === current,
88
+ label: n.label,
89
+ });
90
+ };
91
+ const walk = (nodes, prefix) => {
92
+ if (nodes.length === 1) {
93
+ emit(nodes[0], prefix);
94
+ walk(visibleChildren(nodes[0].children), prefix);
95
+ return;
96
+ }
97
+ nodes.forEach((n, i) => {
98
+ const last = i === nodes.length - 1;
99
+ emit(n, prefix + (last ? glyphs.last : glyphs.tee));
100
+ walk(visibleChildren(n.children), prefix + (last ? glyphs.blank : glyphs.pipe));
101
+ });
102
+ };
103
+ walk(visibleChildren(roots), "");
104
+ return rows;
105
+ }
106
+ function find(nodes, id) {
107
+ for (const n of nodes) {
108
+ if (n.entry.id === id)
109
+ return n;
110
+ const hit = find(n.children, id);
111
+ if (hit)
112
+ return hit;
113
+ }
114
+ return undefined;
115
+ }