@vigent/cli 0.0.0-stage → 0.2.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/dist/cli.js ADDED
@@ -0,0 +1,429 @@
1
+ #!/usr/bin/env node
2
+ /** vigent — vim-native agent orchestrator on the pi engine. */
3
+ import { execFileSync } from "node:child_process";
4
+ import * as fs from "node:fs";
5
+ import * as path from "node:path";
6
+ import { ProcessTerminal, TUI } from "@mariozechner/pi-tui";
7
+ import { loadAgentMeta, restorableAgents, saveAgentMeta, sessionDir } from "./agents.js";
8
+ import { App } from "./app.js";
9
+ import { staleBuild, VERSION_TEXT } from "./version.js";
10
+ import { CliSession } from "./cli-engines.js";
11
+ import { defaultEngine, detectEngines, ENGINE_BINS, isEngineId, onPath, piConnected, quickEngines } from "./engines.js";
12
+ import { CONFIG_TEMPLATE, configPath, loadConfig } from "./config.js";
13
+ import { createDemoSessions } from "./demo.js";
14
+ import { buildKeymap, KEY_MODES } from "./keys.js";
15
+ import { formatBytes, prepareViews, riftriBackends, riftriGc, riftriHealth, riftriStateDir } from "./riftri.js";
16
+ import { DemoSession, PiSession } from "./session.js";
17
+ import { BUILTIN_THEMES, listThemes, loadTheme, Painter, themesDir } from "./theme.js";
18
+ import { themeNewTemplate } from "./theme-new.js";
19
+ import { branchName, createWorktree, findWorktree, listWorktrees, removeWorktree, repoRoot, worktreesDir, } from "./worktree.js";
20
+ const HELP = `vigent — vim-native agent orchestrator (pi engine)
21
+
22
+ usage:
23
+ vigent --version which vigent this is (and the commit, from a clone)
24
+ vigent [--demo] [--theme <name>] [--model <id>] [--engine pi|claude|codex|opencode]
25
+
26
+ engines (what runs new agents; :checkhealth shows which are signed in):
27
+ pi built in, uses ANTHROPIC_API_KEY / OPENAI_API_KEY / ... or pi's login
28
+ claude your installed Claude Code CLI, with its own login
29
+ codex your installed Codex CLI, with its own login
30
+ opencode your installed OpenCode CLI, with its own login
31
+
32
+ themes:
33
+ vigent theme list built-in and installed themes
34
+ vigent theme import import the colorscheme your nvim/vim uses now
35
+ vigent theme import <name> [--as <name>] import any colorscheme nvim/vim can load
36
+ vigent theme import ./theme.vim import a colorscheme file (no editor needed)
37
+ --vim | --nvim use this editor --light | --dark set 'background' first
38
+ --clean skip your editor config
39
+ vigent theme new <name> [--from <theme>] create a theme file to customize, pre-filled with that theme's colors
40
+ --palette build it from a palette only --transparent use the terminal's background
41
+ vigent theme path show where themes and config live
42
+ vigent worktree list agent worktrees in this repo
43
+ vigent worktree remove <branch> [--force] delete one (safe unless --force)
44
+ vigent worktree prune forget worktrees whose folder is gone
45
+ vigent worktree gc free riftri storage no worktree uses
46
+ vigent config init write a commented config.toml
47
+ vigent config check validate config.toml (theme, options, keys)
48
+
49
+ keys (NORMAL mode):
50
+ i prompt the focused agent :new <task> agent in its own worktree
51
+ j/k gg G scroll transcript :kill[!] stop it (! removes worktree)
52
+ ctrl+d/u half-page scroll :colorscheme pick a theme (live preview)
53
+ ]s [s 1-9 switch sessions :hi Group ... change a highlight group
54
+ za zM zR fold/unfold tool output :set nonumber toggle ui options
55
+ /pattern ? search transcript :diff what the agent changed
56
+ V y yy yib select and copy, last reply :merge :pr land the agent's work
57
+ { } previous / next message :dash all agents at a glance
58
+ dd kill agent (asks first) :queue <text> follow-up after this run
59
+ :worktrees list agent worktrees :attach <branch> reopen a kept agent
60
+ :diff split side-by-side diff :tree go back to any point and branch
61
+ <Space>u the conversation tree :nmap <lhs> <rhs> map keys (see :help)
62
+ :help every key, including yours :q quit
63
+ :checkhealth engines: installed? signed in? :new <task> --engine codex
64
+
65
+ Runs in demo mode (scripted sessions, no network) when --demo is passed,
66
+ or when pi has no API key and none of claude / codex / opencode is installed.
67
+ Config: ~/.config/vigent/config.toml Themes: ~/.config/vigent/themes/
68
+ `;
69
+ function flag(argv, name) {
70
+ const i = argv.indexOf(name);
71
+ return i >= 0 ? argv[i + 1] : undefined;
72
+ }
73
+ async function themeCommand(argv) {
74
+ const sub = argv[0];
75
+ if (sub === "list") {
76
+ const builtins = new Set(Object.keys(BUILTIN_THEMES));
77
+ for (const t of listThemes())
78
+ console.log(builtins.has(t) ? `${t} (built-in)` : t);
79
+ return 0;
80
+ }
81
+ if (sub === "path") {
82
+ console.log(`themes: ${themesDir()}`);
83
+ console.log(`config: ${configPath()}`);
84
+ return 0;
85
+ }
86
+ if (sub === "new") {
87
+ const name = argv[1];
88
+ if (!name || name.startsWith("-")) {
89
+ console.error("usage: vigent theme new <name> [--from <theme>] [--palette] [--transparent | --no-transparent]");
90
+ return 1;
91
+ }
92
+ const from = flag(argv, "--from") ?? "default";
93
+ loadTheme(from); // fail early on a typo
94
+ const file = path.join(themesDir(), `${name}.toml`);
95
+ if (fs.existsSync(file)) {
96
+ console.error(`${file} already exists`);
97
+ return 1;
98
+ }
99
+ fs.mkdirSync(themesDir(), { recursive: true });
100
+ fs.writeFileSync(file, themeNewTemplate(name, from, {
101
+ palette: argv.includes("--palette"),
102
+ transparent: argv.includes("--transparent") ? true : argv.includes("--no-transparent") ? false : undefined,
103
+ }));
104
+ console.log(`created ${file}`);
105
+ console.log(`use it with: vigent --theme ${name} (or :colorscheme ${name})`);
106
+ return 0;
107
+ }
108
+ if (sub === "import") {
109
+ const { importTheme } = await import("./import.js");
110
+ const rest = argv.slice(1);
111
+ const positional = rest.filter((a, i) => !a.startsWith("--") && rest[i - 1] !== "--as" && a !== "as");
112
+ const legacyAs = rest.indexOf("as");
113
+ const result = importTheme({
114
+ source: positional[0],
115
+ as: flag(rest, "--as") ?? (legacyAs >= 0 ? rest[legacyAs + 1] : undefined),
116
+ editor: rest.includes("--vim") ? "vim" : rest.includes("--nvim") ? "nvim" : undefined,
117
+ background: rest.includes("--light") ? "light" : rest.includes("--dark") ? "dark" : undefined,
118
+ clean: rest.includes("--clean"),
119
+ });
120
+ console.log(`imported '${result.name}' (${result.groups} highlight groups, via ${result.via})`);
121
+ console.log(` -> ${result.file}`);
122
+ console.log(`use it with: vigent --theme ${result.name} (or :colorscheme ${result.name})`);
123
+ return 0;
124
+ }
125
+ console.error("usage: vigent theme list | import [name|file] | new <name> | path");
126
+ return 1;
127
+ }
128
+ function worktreeCommand(argv) {
129
+ const root = repoRoot(process.cwd());
130
+ if (!root) {
131
+ console.error("vigent: not inside a git repository");
132
+ return 1;
133
+ }
134
+ const { config } = loadConfig();
135
+ const sub = argv[0];
136
+ if (sub === "list" || sub === undefined) {
137
+ const all = listWorktrees(root, config.worktree);
138
+ if (all.length === 0)
139
+ console.log(`no agent worktrees (they go in ${worktreesDir(root, config.worktree)})`);
140
+ const backends = riftriBackends(root, all.map((w) => w.path));
141
+ for (const w of all) {
142
+ const b = backends.get(w.path);
143
+ console.log(`${(w.branch ?? `(detached ${w.head})`).padEnd(36)} ${(b ? `riftri ${b}` : "git").padEnd(16)} ${w.path}`);
144
+ }
145
+ return 0;
146
+ }
147
+ if (sub === "remove" || sub === "rm") {
148
+ const query = argv[1];
149
+ if (!query) {
150
+ console.error("usage: vigent worktree remove <branch> [--force]");
151
+ return 1;
152
+ }
153
+ const w = findWorktree(root, query, config.worktree);
154
+ if (!w) {
155
+ console.error(`vigent: no agent worktree '${query}' (see: vigent worktree list)`);
156
+ return 1;
157
+ }
158
+ const r = removeWorktree(root, w, argv.includes("--force"));
159
+ console.log(r.message);
160
+ return r.removed ? 0 : 1;
161
+ }
162
+ if (sub === "prune") {
163
+ execFileSync("git", ["worktree", "prune", "-v"], { cwd: root, stdio: "inherit" });
164
+ return 0;
165
+ }
166
+ if (sub === "gc") {
167
+ if (!riftriStateDir(root)) {
168
+ console.log("no riftri storage in this repository");
169
+ return 0;
170
+ }
171
+ const r = riftriGc(root);
172
+ if (!r.ok) {
173
+ console.error(`vigent: riftri gc failed: ${r.message}`);
174
+ return 1;
175
+ }
176
+ console.log(`freed ${formatBytes(r.report?.removed_allocated_bytes ?? 0)}`);
177
+ return 0;
178
+ }
179
+ console.error("usage: vigent worktree list | remove <branch> [--force] | prune | gc");
180
+ return 1;
181
+ }
182
+ async function main() {
183
+ const argv = process.argv.slice(2);
184
+ if (argv.includes("--version") || argv.includes("-v") || argv[0] === "version") {
185
+ console.log(`vigent ${VERSION_TEXT}`);
186
+ const stale = staleBuild();
187
+ if (stale)
188
+ console.log(`warning: ${stale}`);
189
+ return 0;
190
+ }
191
+ if (argv.includes("--help") || argv.includes("-h")) {
192
+ console.log(HELP);
193
+ return 0;
194
+ }
195
+ if (argv[0] === "theme" || argv[0] === "themes")
196
+ return themeCommand(argv.slice(1));
197
+ if (argv[0] === "worktree" || argv[0] === "worktrees" || argv[0] === "wt")
198
+ return worktreeCommand(argv.slice(1));
199
+ if (argv[0] === "config" && argv[1] === "check") {
200
+ const file = configPath();
201
+ if (!fs.existsSync(file)) {
202
+ console.log(`no config at ${file} (defaults in use). Create one with: vigent config init`);
203
+ return 0;
204
+ }
205
+ const { config, warnings } = loadConfig(file);
206
+ const keys = buildKeymap(config.leader, config.keys);
207
+ warnings.push(...keys.warnings);
208
+ try {
209
+ if (config.theme)
210
+ loadTheme(config.theme);
211
+ }
212
+ catch (err) {
213
+ warnings.push(err.message);
214
+ }
215
+ const mappings = KEY_MODES.reduce((n, m) => n + keys.keymap.bindings(m).filter((b) => b.user).length, 0);
216
+ for (const w of warnings)
217
+ console.log(`warning: ${w}`);
218
+ console.log(`${file}: ${warnings.length === 0 ? "ok" : `${warnings.length} problem(s)`} · theme ${config.theme ?? "default"} · leader ${config.leader ?? "\\"} · ${mappings} mapping(s) · ${Object.keys(config.highlights).length} highlight override(s)`);
219
+ return warnings.length === 0 ? 0 : 1;
220
+ }
221
+ if (argv[0] === "config" && argv[1] === "init") {
222
+ const file = configPath();
223
+ if (fs.existsSync(file)) {
224
+ console.error(`${file} already exists`);
225
+ return 1;
226
+ }
227
+ fs.mkdirSync(path.dirname(file), { recursive: true });
228
+ fs.writeFileSync(file, CONFIG_TEMPLATE);
229
+ console.log(`created ${file}`);
230
+ return 0;
231
+ }
232
+ const { config, warnings } = loadConfig();
233
+ const stale = staleBuild();
234
+ if (stale)
235
+ warnings.push(stale);
236
+ const { keymap, warnings: keyWarnings } = buildKeymap(config.leader, config.keys);
237
+ warnings.push(...keyWarnings);
238
+ const engineArg = flag(argv, "--engine");
239
+ if (engineArg && !isEngineId(engineArg)) {
240
+ console.error(`vigent: unknown engine '${engineArg}' (pi, claude, codex or opencode)`);
241
+ return 1;
242
+ }
243
+ const chosenEngine = (engineArg ?? (config.engine === "auto" ? undefined : config.engine));
244
+ const anyCli = ["claude", "codex", "opencode"].some((id) => onPath(ENGINE_BINS[id]));
245
+ const demo = argv.includes("--demo") || (!chosenEngine && !piConnected() && !anyCli);
246
+ const modelArg = flag(argv, "--model");
247
+ const model = modelArg ?? config.model ?? "claude-sonnet-4-5";
248
+ const themeName = flag(argv, "--theme") ?? config.theme ?? "default";
249
+ let theme = BUILTIN_THEMES.default;
250
+ try {
251
+ theme = loadTheme(themeName);
252
+ }
253
+ catch (err) {
254
+ if (flag(argv, "--theme")) {
255
+ console.error(`vigent: ${err.message}`);
256
+ return 1;
257
+ }
258
+ warnings.push(`${err.message} (from config); using default`);
259
+ }
260
+ const terminal = new ProcessTerminal();
261
+ const tui = new TUI(terminal, false);
262
+ const root = repoRoot(process.cwd());
263
+ const app = new App(tui, terminal, new Painter(theme, config.highlights), config.ui, keymap, {
264
+ root,
265
+ config: config.worktree,
266
+ });
267
+ // can agent worktrees be copy-on-write here? (riftri; runs in the background)
268
+ app.checkStorage = () => riftriHealth(root, root ? worktreesDir(root, config.worktree) : undefined, config.worktree.backend);
269
+ if (!demo && root && config.worktree.enabled) {
270
+ app
271
+ .checkStorage()
272
+ .then((h) => {
273
+ app.storage = h;
274
+ })
275
+ .catch(() => { });
276
+ }
277
+ app.preferredEngine = chosenEngine ?? "auto";
278
+ app.defaultEngine = () => (app.preferredEngine === "auto" ? defaultEngine(app.engines) : app.preferredEngine);
279
+ app.detectEngines = () => detectEngines();
280
+ // installed? signed in? What's on PATH shows at once; the CLIs' sign-in checks take a moment
281
+ app.setEngines(quickEngines());
282
+ detectEngines()
283
+ .then((engines) => app.setEngines(engines))
284
+ .catch(() => { });
285
+ /** A Claude Code / Codex / OpenCode agent; its conversation id is saved so it survives a restart. */
286
+ const cliSession = (name, engine, requested, cwd, wt, resumeId, effort) => new CliSession(name, engine, requested, {
287
+ cwd,
288
+ worktree: wt,
289
+ resumeId,
290
+ effort,
291
+ extraArgs: config.engines[engine]?.args,
292
+ onResumeId: (id) => {
293
+ if (!wt?.path)
294
+ return;
295
+ const meta = loadAgentMeta(wt.path);
296
+ if (meta)
297
+ saveAgentMeta(wt.path, { ...meta, resumeId: id });
298
+ },
299
+ });
300
+ // model and effort for new agents, per engine, from [engines.<id>]
301
+ for (const [id, opts] of Object.entries(config.engines)) {
302
+ if (opts?.model)
303
+ app.newAgentModel[id] = opts.model;
304
+ if (opts?.effort)
305
+ app.newAgentEffort[id] = opts.effort;
306
+ }
307
+ // an agent's model and effort, changed with :model / :effort, survive a restart
308
+ app.persist = (s) => {
309
+ const wt = s.worktree?.path;
310
+ if (!wt)
311
+ return;
312
+ const meta = loadAgentMeta(wt);
313
+ if (!meta)
314
+ return;
315
+ const requested = s instanceof CliSession ? s.chosenModel : undefined;
316
+ saveAgentMeta(wt, {
317
+ ...meta,
318
+ model: s instanceof CliSession ? (requested ?? "") : s.model,
319
+ ...(s instanceof CliSession ? { requestedModel: requested } : {}),
320
+ effort: s.effort,
321
+ });
322
+ };
323
+ app.newSession = (name, { worktree, model: perAgent, engine = app.defaultEngine(), effort }) => {
324
+ if (demo) {
325
+ // demo mode never touches your repo: the worktree is only shown, not created
326
+ const shown = worktree ? { branch: branchName(name, config.worktree.branch) } : undefined;
327
+ return new DemoSession(name, perAgent ?? model, shown);
328
+ }
329
+ // a CLI uses its own default model unless one is asked for
330
+ const requested = engine === "pi" ? undefined : (perAgent ?? config.engines[engine]?.model ?? (modelArg && engineArg === engine ? modelArg : undefined));
331
+ const m = engine === "pi" ? (perAgent ?? model) : (requested ?? "");
332
+ const wt = worktree && root ? createWorktree(root, name, config.worktree) : undefined;
333
+ if (wt?.path) {
334
+ saveAgentMeta(wt.path, {
335
+ name,
336
+ model: m,
337
+ base: wt.base,
338
+ createdAt: new Date().toISOString(),
339
+ ...(engine === "pi" ? {} : { engine, ...(requested ? { requestedModel: requested } : {}) }),
340
+ ...(effort ? { effort } : {}),
341
+ });
342
+ }
343
+ const cwd = wt?.path ?? process.cwd();
344
+ if (engine !== "pi")
345
+ return cliSession(name, engine, requested, cwd, wt?.path ? wt : undefined, undefined, effort);
346
+ if (!wt?.path)
347
+ return new PiSession(name, m, { cwd, effort });
348
+ return new PiSession(name, m, { cwd, worktree: wt, sessionDir: sessionDir(wt.path), effort });
349
+ };
350
+ /** Reopen a saved agent with the engine it was started with. */
351
+ const reopen = (meta, worktree) => {
352
+ const engine = meta.engine && isEngineId(meta.engine) ? meta.engine : "pi";
353
+ if (engine !== "pi")
354
+ return cliSession(meta.name, engine, meta.requestedModel, worktree.path, worktree, meta.resumeId, meta.effort);
355
+ return new PiSession(meta.name, meta.model, { cwd: worktree.path, worktree, sessionDir: sessionDir(worktree.path), effort: meta.effort });
356
+ };
357
+ app.attachSession = (wt) => {
358
+ if (demo || !wt.path)
359
+ throw new Error("attaching needs a real agent (not --demo)");
360
+ const meta = loadAgentMeta(wt.path) ?? {
361
+ name: wt.branch.split("/").pop()?.replace(/-/g, " ") ?? wt.branch,
362
+ model,
363
+ createdAt: new Date().toISOString(),
364
+ };
365
+ const view = root ? prepareViews(root, [wt.path]).get(wt.path) : undefined;
366
+ if (view && !view.ready)
367
+ throw new Error(`can't reopen ${wt.branch}: ${view.reason}`);
368
+ saveAgentMeta(wt.path, { ...meta, detached: undefined }); // reopened at the next start again
369
+ return reopen(meta, { ...wt, base: wt.base ?? meta.base, riftri: view?.backend });
370
+ };
371
+ app.onQuit = () => {
372
+ app.stopTimers();
373
+ for (const s of app.sessions) {
374
+ s.abort();
375
+ s.dispose();
376
+ }
377
+ tui.stop();
378
+ process.exit(0);
379
+ };
380
+ if (demo) {
381
+ app.demo = true;
382
+ for (const s of createDemoSessions())
383
+ app.addSession(s, false);
384
+ app.focusIdx = 0;
385
+ if (!argv.includes("--demo")) {
386
+ warnings.unshift("demo mode: these agents are scripted. For real ones, install and sign in to Claude Code, Codex or OpenCode, or set ANTHROPIC_API_KEY, then restart (:checkhealth)");
387
+ }
388
+ }
389
+ else if (root && config.worktree.enabled) {
390
+ // reopen the agents that were running when vigent last quit
391
+ const agents = restorableAgents(root, config.worktree);
392
+ // riftri views on OverlayFS lose their mount on reboot: repair them before an agent runs there
393
+ const views = prepareViews(root, agents.map((a) => a.worktree.path));
394
+ for (const { meta, worktree } of agents) {
395
+ const view = views.get(worktree.path);
396
+ if (view && !view.ready) {
397
+ warnings.push(`couldn't reopen '${meta.name}': ${view.reason}`);
398
+ continue;
399
+ }
400
+ try {
401
+ app.addSession(reopen(meta, { ...worktree, riftri: view?.backend }), false);
402
+ }
403
+ catch (err) {
404
+ warnings.push(`couldn't reopen '${meta.name}': ${err.message}`);
405
+ }
406
+ }
407
+ if (app.sessions.length > 0) {
408
+ app.focusIdx = 0;
409
+ app.notify(`reopened ${app.sessions.length} agent${app.sessions.length === 1 ? "" : "s"} from last time`, "Comment");
410
+ }
411
+ }
412
+ tui.addChild(app);
413
+ tui.setFocus(app);
414
+ terminal.setTitle("vigent");
415
+ tui.start();
416
+ if (warnings.length > 0)
417
+ app.notify(warnings.join(" · "));
418
+ tui.requestRender(true);
419
+ return undefined;
420
+ }
421
+ main()
422
+ .then((code) => {
423
+ if (code !== undefined)
424
+ process.exitCode = code;
425
+ })
426
+ .catch((err) => {
427
+ console.error(`vigent: ${err instanceof Error ? err.message : err}`);
428
+ process.exit(1);
429
+ });
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Copy text to the system clipboard, like vim's "+ register.
3
+ *
4
+ * Always sends OSC 52 (the terminal sets the clipboard; works over SSH and in
5
+ * tmux with set-clipboard on), and also pipes to a local clipboard tool when
6
+ * one is installed.
7
+ */
8
+ import { spawn, spawnSync } from "node:child_process";
9
+ const TOOLS = [
10
+ ["pbcopy", []],
11
+ ["wl-copy", []],
12
+ ["xclip", ["-selection", "clipboard"]],
13
+ ["xsel", ["--clipboard", "--input"]],
14
+ ["clip.exe", []],
15
+ ];
16
+ let cachedTool;
17
+ function localTool() {
18
+ if (cachedTool !== undefined)
19
+ return cachedTool;
20
+ cachedTool = null;
21
+ for (const [cmd, args] of TOOLS) {
22
+ if (cmd === "wl-copy" && !process.env.WAYLAND_DISPLAY)
23
+ continue;
24
+ if ((cmd === "xclip" || cmd === "xsel") && !process.env.DISPLAY)
25
+ continue;
26
+ const which = spawnSync(process.platform === "win32" ? "where" : "which", [cmd], { stdio: "ignore" });
27
+ if (which.status === 0) {
28
+ cachedTool = [cmd, args];
29
+ break;
30
+ }
31
+ }
32
+ return cachedTool;
33
+ }
34
+ /** The OSC 52 escape sequence that asks the terminal to set its clipboard. */
35
+ export function osc52(text) {
36
+ const seq = `\x1b]52;c;${Buffer.from(text, "utf8").toString("base64")}\x07`;
37
+ // inside tmux, wrap it so tmux passes it on to the outer terminal
38
+ return process.env.TMUX ? `\x1bPtmux;${seq.replace(/\x1b/g, "\x1b\x1b")}\x1b\\` : seq;
39
+ }
40
+ /** Copy `text`. `write` sends raw bytes to the terminal. Returns how it was copied. */
41
+ export function copyToClipboard(text, write) {
42
+ write(osc52(text));
43
+ const tool = localTool();
44
+ if (tool) {
45
+ try {
46
+ const child = spawn(tool[0], tool[1], { stdio: ["pipe", "ignore", "ignore"] });
47
+ child.on("error", () => { });
48
+ child.stdin.end(text);
49
+ return tool[0];
50
+ }
51
+ catch {
52
+ // fall back to OSC 52 only
53
+ }
54
+ }
55
+ return "terminal";
56
+ }
package/dist/color.js ADDED
@@ -0,0 +1,126 @@
1
+ /** Color helpers: hex math, xterm-256 palette, and vim color names. */
2
+ export function parseHex(hex) {
3
+ if (!hex)
4
+ return null;
5
+ const m = /^#?([0-9a-fA-F]{6})$/.exec(hex.trim());
6
+ if (!m)
7
+ return null;
8
+ const n = parseInt(m[1], 16);
9
+ return [(n >> 16) & 0xff, (n >> 8) & 0xff, n & 0xff];
10
+ }
11
+ export function toHex([r, g, b]) {
12
+ const h = (v) => Math.round(Math.max(0, Math.min(255, v))).toString(16).padStart(2, "0");
13
+ return `#${h(r)}${h(g)}${h(b)}`;
14
+ }
15
+ /** Blend a toward b by t (0..1). Returns a unchanged if either color is invalid. */
16
+ export function mix(a, b, t) {
17
+ const x = parseHex(a);
18
+ const y = parseHex(b);
19
+ if (!x || !y)
20
+ return a;
21
+ return toHex([x[0] + (y[0] - x[0]) * t, x[1] + (y[1] - x[1]) * t, x[2] + (y[2] - x[2]) * t]);
22
+ }
23
+ /** Relative luminance, 0 (black) .. 1 (white). */
24
+ export function luminance(hex) {
25
+ const c = parseHex(hex);
26
+ if (!c)
27
+ return 0;
28
+ const [r, g, b] = c.map((v) => {
29
+ const s = v / 255;
30
+ return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
31
+ });
32
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b;
33
+ }
34
+ export function isLight(hex) {
35
+ return hex ? luminance(hex) > 0.35 : false;
36
+ }
37
+ const XTERM_16 = [
38
+ "#000000", "#800000", "#008000", "#808000", "#000080", "#800080", "#008080", "#c0c0c0",
39
+ "#808080", "#ff0000", "#00ff00", "#ffff00", "#0000ff", "#ff00ff", "#00ffff", "#ffffff",
40
+ ];
41
+ /** Convert an xterm-256 color index to hex. */
42
+ export function xterm256(n) {
43
+ if (!Number.isInteger(n) || n < 0 || n > 255)
44
+ return undefined;
45
+ if (n < 16)
46
+ return XTERM_16[n];
47
+ if (n < 232) {
48
+ const levels = [0, 95, 135, 175, 215, 255];
49
+ const i = n - 16;
50
+ return toHex([levels[Math.floor(i / 36)], levels[Math.floor(i / 6) % 6], levels[i % 6]]);
51
+ }
52
+ const v = 8 + (n - 232) * 10;
53
+ return toHex([v, v, v]);
54
+ }
55
+ /** Vim's cterm color names (ctermfg=DarkBlue) -> xterm index. */
56
+ const CTERM_NAMES = {
57
+ black: 0, darkred: 1, darkgreen: 2, brown: 3, darkyellow: 3, darkblue: 4, darkmagenta: 5,
58
+ darkcyan: 6, lightgray: 7, lightgrey: 7, gray: 7, grey: 7, darkgray: 8, darkgrey: 8,
59
+ red: 9, lightred: 9, green: 10, lightgreen: 10, yellow: 11, lightyellow: 11, blue: 12,
60
+ lightblue: 12, magenta: 13, lightmagenta: 13, cyan: 14, lightcyan: 14, white: 15,
61
+ };
62
+ /** Common GUI color names used by vim colorschemes (values from vim's rgb list). */
63
+ const GUI_NAMES = {
64
+ black: "#000000", white: "#ffffff", red: "#ff0000", green: "#00ff00", blue: "#0000ff",
65
+ yellow: "#ffff00", cyan: "#00ffff", magenta: "#ff00ff", gray: "#bebebe", grey: "#bebebe",
66
+ darkgray: "#a9a9a9", darkgrey: "#a9a9a9", lightgray: "#d3d3d3", lightgrey: "#d3d3d3",
67
+ darkred: "#8b0000", darkgreen: "#006400", darkblue: "#00008b", darkcyan: "#008b8b",
68
+ darkmagenta: "#8b008b", darkyellow: "#bbbb00", brown: "#a52a2a", orange: "#ffa500",
69
+ purple: "#a020f0", lightblue: "#add8e6", lightgreen: "#90ee90", lightcyan: "#e0ffff",
70
+ lightred: "#ffbbbb", lightmagenta: "#ffbbff", lightyellow: "#ffffe0", seagreen: "#2e8b57",
71
+ slateblue: "#6a5acd", gold: "#ffd700", khaki: "#f0e68c", navy: "#000080", wheat: "#f5deb3",
72
+ peru: "#cd853f", salmon: "#fa8072", orangered: "#ff4500", skyblue: "#87ceeb",
73
+ violet: "#ee82ee", tan: "#d2b48c", olive: "#808000", teal: "#008080", maroon: "#800000",
74
+ silver: "#c0c0c0", pink: "#ffc0cb", indianred: "#cd5c5c", steelblue: "#4682b4",
75
+ darkslategray: "#2f4f4f", darkslategrey: "#2f4f4f", darkolivegreen: "#556b2f",
76
+ darkorange: "#ff8c00", lightslategray: "#778899", midnightblue: "#191970",
77
+ };
78
+ /**
79
+ * Resolve a vim color value to hex: "#rrggbb", "NONE", a GUI color name,
80
+ * grayN/greyN, or (for cterm) an xterm index or cterm name.
81
+ * Returns "NONE", a hex string, or undefined if unknown.
82
+ */
83
+ export function resolveVimColor(value, cterm = false) {
84
+ const v = value.trim().replace(/^["']|["']$/g, "");
85
+ const lower = v.toLowerCase();
86
+ if (lower === "none")
87
+ return "NONE";
88
+ if (lower === "fg" || lower === "bg" || lower === "foreground" || lower === "background")
89
+ return undefined;
90
+ if (parseHex(v))
91
+ return `#${v.replace(/^#/, "").toLowerCase()}`;
92
+ if (cterm) {
93
+ if (/^\d+$/.test(v))
94
+ return xterm256(Number(v));
95
+ const idx = CTERM_NAMES[lower.replace(/\s+/g, "")];
96
+ return idx === undefined ? undefined : xterm256(idx);
97
+ }
98
+ const gray = /^gr[ae]y(\d{1,3})$/.exec(lower);
99
+ if (gray) {
100
+ const n = Math.round((Math.min(100, Number(gray[1])) * 255) / 100);
101
+ return toHex([n, n, n]);
102
+ }
103
+ return GUI_NAMES[lower.replace(/\s+/g, "")];
104
+ }
105
+ /** The xterm-256 index closest to a hex color (6x6x6 cube or grayscale ramp). */
106
+ export function nearest256(hex) {
107
+ const c = parseHex(hex);
108
+ if (!c)
109
+ return 7;
110
+ const levels = [0, 95, 135, 175, 215, 255];
111
+ const idx = (v) => {
112
+ let best = 0;
113
+ for (let i = 1; i < 6; i++)
114
+ if (Math.abs(levels[i] - v) < Math.abs(levels[best] - v))
115
+ best = i;
116
+ return best;
117
+ };
118
+ const [r, g, b] = c.map(idx);
119
+ const cube = 16 + 36 * r + 6 * g + b;
120
+ const cubeRgb = [levels[r], levels[g], levels[b]];
121
+ const avg = (c[0] + c[1] + c[2]) / 3;
122
+ const grayIdx = Math.max(0, Math.min(23, Math.round((avg - 8) / 10)));
123
+ const grayV = 8 + grayIdx * 10;
124
+ const dist = (x) => (x[0] - c[0]) ** 2 + (x[1] - c[1]) ** 2 + (x[2] - c[2]) ** 2;
125
+ return dist([grayV, grayV, grayV]) < dist(cubeRgb) ? 232 + grayIdx : cube;
126
+ }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Edit config.toml in place for `:settings`: change one value and keep
3
+ * everything else (comments, order, other keys) as the user wrote it.
4
+ */
5
+ /** A TOML value: strings quoted, booleans and numbers as they are. */
6
+ export function tomlValue(v) {
7
+ return typeof v === "string" ? JSON.stringify(v) : String(v);
8
+ }
9
+ /** A TOML key: bare when it can be, quoted otherwise (`"<leader>n"`). */
10
+ export function tomlKey(k) {
11
+ return /^[A-Za-z0-9_-]+$/.test(k) ? k : JSON.stringify(k);
12
+ }
13
+ const header = /^\s*\[([^\][]+)\]\s*(#.*)?$/;
14
+ /**
15
+ * Set `key = value` in `[section]` (or at the top level when section is
16
+ * undefined). A commented-out line for the same key (`# key = …`) is replaced
17
+ * in place, so the setting stays where the template documents it.
18
+ */
19
+ export function setTomlValue(text, section, key, value) {
20
+ const lines = text.length ? text.replace(/\n$/, "").split("\n") : [];
21
+ const line = `${tomlKey(key)} = ${value}`;
22
+ const keyRe = new RegExp(`^(\\s*)(#\\s*)?${escape(tomlKey(key))}\\s*=`);
23
+ // the range of lines that belong to the section
24
+ let start = 0;
25
+ let end = lines.findIndex((l) => header.test(l));
26
+ if (end < 0)
27
+ end = lines.length;
28
+ if (section) {
29
+ const at = lines.findIndex((l) => header.exec(l)?.[1].trim() === section);
30
+ if (at < 0) {
31
+ // a new section at the end
32
+ const out = [...lines];
33
+ if (out.length && out[out.length - 1].trim() !== "")
34
+ out.push("");
35
+ out.push(`[${section}]`, line);
36
+ return `${out.join("\n")}\n`;
37
+ }
38
+ start = at + 1;
39
+ end = lines.findIndex((l, i) => i >= start && header.test(l));
40
+ if (end < 0)
41
+ end = lines.length;
42
+ }
43
+ const live = lines.findIndex((l, i) => i >= start && i < end && keyRe.test(l) && !/^\s*#/.test(l));
44
+ const commented = lines.findIndex((l, i) => i >= start && i < end && keyRe.test(l) && /^\s*#/.test(l));
45
+ const out = [...lines];
46
+ const at = live >= 0 ? live : commented;
47
+ if (at >= 0) {
48
+ out[at] = line;
49
+ }
50
+ else {
51
+ // after the last non-blank line of the section
52
+ let insert = end;
53
+ while (insert > start && out[insert - 1].trim() === "")
54
+ insert--;
55
+ out.splice(insert, 0, line);
56
+ }
57
+ return `${out.join("\n")}\n`;
58
+ }
59
+ function escape(s) {
60
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
61
+ }