@vigent/cli 0.2.0 → 0.2.2

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/README.md CHANGED
@@ -12,7 +12,7 @@ A vim-style, lightweight terminal app for running several coding agents at once,
12
12
  npm install -g @vigent/cli
13
13
  ```
14
14
 
15
- The package is `@vigent/cli` (npm doesn't accept the plain name `vigent`, which it finds too close to `vitest`); the command is `vigent`.
15
+ The package is `@vigent/cli` (npm doesn't accept the plain name `vigent`, which it finds too close to `vitest`); the command is `vigent`. Run `vigent` in a project, or `vigent <folder>` (`vigent .`, `vigent ~/code/app`) from anywhere.
16
16
 
17
17
  Requires Node.js 20 or newer and git. Then, inside any git repository:
18
18
 
@@ -94,7 +94,7 @@ The sidebar's ENGINES block shows each one: `●` signed in, `◐` installed but
94
94
  | `codex` | `codex exec --json` (`exec resume` to continue) | `-c sandbox_mode="workspace-write"` |
95
95
  | `opencode` | `opencode run --format json --dir <worktree>` | none |
96
96
 
97
- **Model and effort:** `:model` (`<Space>M`) opens a picker with the models the agent's engine offers, and you can type any name. `:effort` (`<Space>e`) sets the reasoning effort (pi: thinking level). Both apply from the next prompt, show in the winbar and survive a restart. With no agent open, they set the defaults for new agents.
97
+ **Model and effort:** `:model` (`<Space>M`) opens a picker with the models the agent's engine offers (Claude Code's model names, Codex's own catalog from `codex debug models`, OpenCode's `opencode models`, or pi's for the providers you have a key for). In the picker, `j`/`k` move, `g`/`G` jump to the top or bottom, Enter picks and `q` or Esc closes. Press `/` to search, or to type any model name. `:effort` (`<Space>e`) sets the reasoning effort (pi: thinking level). Both apply from the next prompt, show in the winbar and survive a restart. With no agent open, they set the defaults for new agents.
98
98
 
99
99
  ![:model on a Claude Code agent](https://raw.githubusercontent.com/farming-labs/vigent/main/docs/screenshots/model-picker.png)
100
100
 
@@ -201,8 +201,11 @@ vigent theme import kanagawa # any colorscheme your editor can load (plug
201
201
  vigent theme import ./molokai.vim # a .vim file, no editor needed
202
202
  vigent theme new mine --from nord # start your own theme, pre-filled with nord's colors
203
203
  vigent --theme lazyvim # LazyVim's look, on your terminal's own (transparent) background
204
+ vigent --theme nvim # follow your Neovim: the colorscheme your config sets
204
205
  ```
205
206
 
207
+ **Follow your editor:** with `theme = "nvim"` (or `"vim"`) in config.toml, vigent uses whatever colorscheme your Neovim config and plugins set, whether that's AstroNvim's `astrodark`, LazyVim's `tokyonight` or anything else. Change it in Neovim, and vigent picks it up the next time it starts. vigent keeps a copy (`themes/nvim.json`) so it opens instantly, and checks Neovim in the background.
208
+
206
209
  Inside vigent, `:colorscheme` opens a picker with live preview. `:hi Group guifg=… guibg=…` changes any color on the spot, `:set transparent` lets your terminal's background (its blur, opacity or wallpaper) show through any theme, and `:theme save mine` keeps what's on screen as your own theme file. To keep settings, put them in `~/.config/vigent/config.toml` (`vigent config init` writes a starter file):
207
210
 
208
211
  ```toml
@@ -210,7 +213,7 @@ theme = "tokyonight"
210
213
 
211
214
  [ui]
212
215
  sidebar_width = 34
213
- icons = "unicode" # or "ascii"
216
+ icons = "unicode" # or "nerd" (Nerd Font icons), "ascii"
214
217
  transparent = true # the terminal's background, with any theme
215
218
 
216
219
  [highlights] # applied on top of any theme
@@ -218,6 +221,8 @@ Normal = { bg = "NONE" }
218
221
  CursorLine = "guibg=#2a2f3a"
219
222
  ```
220
223
 
224
+ **Icons and fonts:** `icons = "nerd"` (or `:settings`, Icons) uses Nerd Font icons like AstroNvim and LazyVim: a git branch icon, status and engine icons, an icon per tool call (terminal, pencil, file, search…) and file-type icons in the diff view, colored from your theme. vigent draws in your terminal's font, the same as Neovim in a terminal, so for these icons set your terminal's font to a [Nerd Font](https://www.nerdfonts.com) (for example JetBrainsMono Nerd Font).
225
+
221
226
  25 built in, including `lazyvim` (LazyVim's look, transparent), the tokyonight, catppuccin and rose-pine families, `dracula`, `onedark`, `everforest`, `nightfox`, `gruvbox`, `kanagawa`, `nord`, `monokai-pro`, `ayu-mirage`, and GitHub and solarized in dark and light. The picker shows each one's colors:
222
227
 
223
228
  ![the colorscheme picker: every theme with its colors, dark / light / transparent](https://raw.githubusercontent.com/farming-labs/vigent/main/docs/screenshots/colorscheme-swatches.png)
package/dist/app.js CHANGED
@@ -24,7 +24,8 @@ import { TREE_GLYPHS } from "./tree.js";
24
24
  import { transcriptMarkdown } from "./export.js";
25
25
  import { renderMarkdown } from "./markdown.js";
26
26
  import { ChoicePicker, PICKER_ICONS, ThemePicker } from "./picker.js";
27
- import { EFFORTS, effortChoices, modelChoices } from "./choices.js";
27
+ import { EFFORTS, effortChoicesFor, modelChoices } from "./choices.js";
28
+ import { ICON_SETS, NERD, toolIcon } from "./icons.js";
28
29
  import { mergeWorktree, openPullRequest } from "./review.js";
29
30
  import { BUILTIN_THEMES, extractPalette, listThemes, loadTheme, RESET, sgr, themeFileFor, themesDir } from "./theme.js";
30
31
  import { parseHighlightCommand } from "./vimcolors.js";
@@ -58,6 +59,27 @@ const ICONS = {
58
59
  warn: "◐",
59
60
  off: "○",
60
61
  },
62
+ nerd: {
63
+ spinner: ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"],
64
+ state: { waiting: NERD.hourglass, idle: NERD.circleO, paused: NERD.pause, error: NERD.times },
65
+ tee: "├╴",
66
+ last: "└╴",
67
+ pipe: "│ ",
68
+ sep: "│",
69
+ worktree: `${NERD.branch} `,
70
+ prompt: "❯ ",
71
+ folded: NERD.caretRight,
72
+ open: NERD.caretDown,
73
+ cursor: "▍",
74
+ minus: "−",
75
+ bullet: "•",
76
+ queued: NERD.clock,
77
+ open_agent: "▸",
78
+ here: NERD.circle,
79
+ ok: NERD.check,
80
+ warn: NERD.warning,
81
+ off: NERD.circleO,
82
+ },
61
83
  ascii: {
62
84
  spinner: ["|", "/", "-", "\\"],
63
85
  state: { waiting: "~", idle: "o", paused: "=", error: "x" },
@@ -948,7 +970,8 @@ export class App {
948
970
  /** `:effort <level>` */
949
971
  chooseEffort(level, t = this.modelTarget()) {
950
972
  const allowed = ["default", ...EFFORTS[t.engine]];
951
- if (!allowed.includes(level)) {
973
+ // Codex's levels differ per model and version; Codex itself says if one doesn't fit
974
+ if (!allowed.includes(level) && !(t.engine === "codex" && /^[a-z]+$/.test(level))) {
952
975
  this.setMessage(`${ENGINE_NAMES[t.engine]} takes: ${allowed.join(", ")}`, "ErrorMsg");
953
976
  return;
954
977
  }
@@ -985,8 +1008,16 @@ export class App {
985
1008
  /** `:effort`: the reasoning effort / thinking levels the engine accepts. */
986
1009
  openEffortPicker(t = this.modelTarget()) {
987
1010
  const current = (t.session ? t.session.effort : this.newAgentEffort[t.engine]) ?? "default";
988
- this.openChoices(`Effort · ${t.label}`, effortChoices(t.engine), current, false, (v) => this.chooseEffort(v, t));
1011
+ this.openChoices(`Effort · ${t.label}`, this.effortChoices(t.engine, this.modelOf(t)), current, false, (v) => this.chooseEffort(v, t));
1012
+ }
1013
+ /** the model an agent (or new agents) asked for, if any */
1014
+ modelOf(t) {
1015
+ if (!t.session)
1016
+ return this.newAgentModel[t.engine];
1017
+ return "chosenModel" in t.session ? t.session.chosenModel : t.session.model;
989
1018
  }
1019
+ /** where effort levels come from (replaced in tests) */
1020
+ effortChoices = (engine, model) => effortChoicesFor(engine, model);
990
1021
  /** where model lists come from (replaced in tests) */
991
1022
  modelChoices = (engine) => modelChoices(engine);
992
1023
  openChoices(title, items, current, custom, done) {
@@ -1033,7 +1064,13 @@ export class App {
1033
1064
  },
1034
1065
  hint: "the terminal's own background shows through (its blur, opacity or wallpaper)",
1035
1066
  },
1036
- { section: "Look", label: "Icons", value: () => ui.icons, change: () => ((ui.icons = ui.icons === "unicode" ? "ascii" : "unicode"), refresh()), hint: "ascii for fonts without box-drawing characters" },
1067
+ {
1068
+ section: "Look",
1069
+ label: "Icons",
1070
+ value: () => ui.icons,
1071
+ change: () => ((ui.icons = ICON_SETS[(ICON_SETS.indexOf(ui.icons) + 1) % ICON_SETS.length]), refresh()),
1072
+ hint: "nerd: Nerd Font icons (needs a Nerd Font) · ascii: fonts without box-drawing characters",
1073
+ },
1037
1074
  { section: "Look", label: "24-bit color", value: () => onOff(ui.termguicolors), change: () => ((ui.termguicolors = !ui.termguicolors), refresh()), hint: "off: the nearest of the 256 standard colors (vim's 'termguicolors')" },
1038
1075
  { section: "Layout", label: "Sidebar", value: () => onOff(ui.sidebar), change: () => ((ui.sidebar = !ui.sidebar), refresh()) },
1039
1076
  {
@@ -1575,7 +1612,7 @@ export class App {
1575
1612
  }
1576
1613
  const width = this.terminal.columns - 4;
1577
1614
  const unified = unifiedView(text);
1578
- const side = splitView(text, width - 3);
1615
+ const side = splitView(text, width - 3, this.ui.icons === "nerd");
1579
1616
  const n = unified.jumps.length;
1580
1617
  const title = (layout) => `${wt.branch} vs ${wt.base ?? "base"} · ${n} hunk${n === 1 ? "" : "s"} · ]c [c jump · s ${layout}`;
1581
1618
  const views = [
@@ -2042,7 +2079,7 @@ export class App {
2042
2079
  const rows = [];
2043
2080
  const add = (text, plain, indent = center(plain)) => rows.push({ text: indent + text, plain: indent + plain });
2044
2081
  // LazyVim's dashboard: a big logo, then one key per action
2045
- const logo = this.ui.icons === "unicode" && paneW >= LOGO[0].length + 2 && paneH >= 22;
2082
+ const logo = this.ui.icons !== "ascii" && paneW >= LOGO[0].length + 2 && paneH >= 22;
2046
2083
  if (logo) {
2047
2084
  const groups = ["Function", "Function", "Special", "Special", "Keyword", "Keyword"];
2048
2085
  LOGO.forEach((l, i) => add(P.p(groups[i] ?? "Title", l), l));
@@ -2054,7 +2091,7 @@ export class App {
2054
2091
  const tagline = "coding agents, the vim way · each one in its own git worktree";
2055
2092
  add(P.p("Comment", tagline), tagline);
2056
2093
  add("", "");
2057
- const actions = DASHBOARD.map(([key, icon, label]) => [key, this.ui.icons === "unicode" ? icon : "", label]);
2094
+ const actions = DASHBOARD.map(([key, icon, label, nerd]) => [key, this.ui.icons === "nerd" ? nerd : this.ui.icons === "unicode" ? icon : "", label]);
2058
2095
  const labelW = Math.max(...actions.map(([, , l]) => l.length));
2059
2096
  const blockW = 2 + labelW + 6;
2060
2097
  const indent = center("x".repeat(blockW));
@@ -2075,7 +2112,7 @@ export class App {
2075
2112
  });
2076
2113
  add(text, plain, indentE);
2077
2114
  const ready = this.engines.filter((e) => e.connected).length;
2078
- const foot = `${this.ui.icons === "unicode" ? "⚡ " : ""}vigent ${VERSION_TEXT} · ${listThemes().length} themes · ${ready} of ${this.engines.length} engines ready`;
2115
+ const foot = `${this.ui.icons === "nerd" ? `${NERD.bolt} ` : this.ui.icons === "unicode" ? "⚡ " : ""}vigent ${VERSION_TEXT} · ${listThemes().length} themes · ${ready} of ${this.engines.length} engines ready`;
2079
2116
  add(P.p("Comment", foot), foot);
2080
2117
  }
2081
2118
  if (!this.workspace.root) {
@@ -2245,7 +2282,8 @@ export class App {
2245
2282
  case "tool": {
2246
2283
  const t = item.tool;
2247
2284
  const fold = t.folded ? I.folded : I.open;
2248
- push(P.p("NonText", ` ${fold} `) + P.p("VigentTool", t.name) + P.p("Comment", ` ${t.summary}`), ` ${fold} ${t.name} ${t.summary}`, { item: n });
2285
+ const icon = this.ui.icons === "nerd" ? `${toolIcon(t.name)} ` : "";
2286
+ push(P.p("NonText", ` ${fold} `) + P.p("Special", icon) + P.p("VigentTool", t.name) + P.p("Comment", ` ${t.summary}`), ` ${fold} ${icon}${t.name} ${t.summary}`, { item: n });
2249
2287
  if (!t.folded && t.output) {
2250
2288
  const block = nextBlock++;
2251
2289
  const outLines = t.output.split("\n");
@@ -2474,14 +2512,15 @@ const LOGO = [
2474
2512
  " ╚═══╝ ╚═╝ ╚═════╝ ╚══════╝╚═╝ ╚═══╝ ╚═╝ ",
2475
2513
  ];
2476
2514
  /** The dashboard's keys (active while no agent is open): key, icon, label. */
2515
+ /** key, unicode icon, label, Nerd Font icon */
2477
2516
  const DASHBOARD = [
2478
- ["i", "✎", "New agent: type a task, Enter"],
2479
- ["n", "+", "New agent by name"],
2480
- ["t", "◐", "Theme"],
2481
- ["s", "⚙", "Settings"],
2482
- ["m", "◆", "Model"],
2483
- ["e", "↯", "Effort"],
2484
- ["c", "✓", "Check engines"],
2485
- ["?", "⌘", "Keys"],
2486
- ["q", "×", "Quit"],
2517
+ ["i", "✎", "New agent: type a task, Enter", NERD.pencil],
2518
+ ["n", "+", "New agent by name", NERD.plus],
2519
+ ["t", "◐", "Theme", NERD.palette],
2520
+ ["s", "⚙", "Settings", NERD.gear],
2521
+ ["m", "◆", "Model", NERD.chip],
2522
+ ["e", "↯", "Effort", NERD.bolt],
2523
+ ["c", "✓", "Check engines", NERD.check],
2524
+ ["?", "⌘", "Keys", NERD.keyboard],
2525
+ ["q", "×", "Quit", NERD.power],
2487
2526
  ];
package/dist/choices.js CHANGED
@@ -1,14 +1,17 @@
1
1
  /**
2
2
  * What `:model` and `:effort` offer, per engine. Model lists come from the
3
- * engine itself where it can say (pi-ai's catalog, `opencode models`); any
3
+ * engine itself where it can say (pi-ai's catalog, `codex debug models`,
4
+ * `opencode models`); any
4
5
  * name can still be typed in the picker.
5
6
  */
6
7
  import { execFile } from "node:child_process";
8
+ import { ENGINE_BINS } from "./engines.js";
7
9
  /** Reasoning effort / thinking levels each engine accepts. */
8
10
  export const EFFORTS = {
9
11
  pi: ["off", "minimal", "low", "medium", "high", "xhigh"],
10
12
  claude: ["low", "medium", "high", "xhigh", "max"],
11
- codex: ["minimal", "low", "medium", "high", "xhigh"],
13
+ // what Codex's models take between them; each model's own levels come from `codex debug models`
14
+ codex: ["minimal", "low", "medium", "high", "xhigh", "max", "ultra"],
12
15
  opencode: ["minimal", "low", "medium", "high", "max"],
13
16
  };
14
17
  export function effortChoices(engine) {
@@ -36,7 +39,10 @@ export async function modelChoices(engine, env = process.env) {
36
39
  { value: "haiku", note: "latest Haiku" },
37
40
  ];
38
41
  case "codex":
39
- return [{ value: "default", note: "Codex's own choice (type any model name)" }];
42
+ return [
43
+ { value: "default", note: "Codex's own choice" },
44
+ ...(await codexModels(env)).map((m) => ({ value: m.slug, note: m.description ? `${m.name} · ${m.description}` : m.name })),
45
+ ];
40
46
  case "opencode":
41
47
  return [{ value: "default", note: "OpenCode's own choice" }, ...(await opencodeModels(env)).map((value) => ({ value }))];
42
48
  case "pi": {
@@ -71,3 +77,61 @@ function opencodeModels(env) {
71
77
  });
72
78
  });
73
79
  }
80
+ let codexCatalog;
81
+ /**
82
+ * The models your Codex offers, from `codex debug models` (its own catalog,
83
+ * so it follows your Codex version and account). Asked once per run; empty
84
+ * when Codex isn't installed or can't say.
85
+ */
86
+ export function codexModels(env = process.env) {
87
+ if (env !== process.env)
88
+ return readCodexModels(env);
89
+ codexCatalog ??= readCodexModels(env).then((list) => {
90
+ if (!list.length)
91
+ codexCatalog = undefined; // try again next time
92
+ return list;
93
+ });
94
+ return codexCatalog;
95
+ }
96
+ function readCodexModels(env) {
97
+ return new Promise((resolve) => {
98
+ execFile(ENGINE_BINS.codex, ["debug", "models"], { env, timeout: 20000, maxBuffer: 16 * 1024 * 1024 }, (err, stdout) => {
99
+ if (err)
100
+ return resolve([]);
101
+ resolve(parseCodexModels(String(stdout)));
102
+ });
103
+ });
104
+ }
105
+ /** The models `codex debug models` lists for people to pick (not the hidden ones). */
106
+ export function parseCodexModels(json) {
107
+ try {
108
+ const models = JSON.parse(json)?.models ?? [];
109
+ return models
110
+ .filter((m) => m && typeof m.slug === "string" && (m.visibility === undefined || m.visibility === "list"))
111
+ .map((m) => ({
112
+ slug: m.slug,
113
+ name: typeof m.display_name === "string" ? m.display_name : m.slug,
114
+ description: typeof m.description === "string" ? m.description : undefined,
115
+ efforts: Array.isArray(m.supported_reasoning_levels)
116
+ ? m.supported_reasoning_levels.map((l) => (typeof l === "string" ? l : l?.effort)).filter((e) => typeof e === "string")
117
+ : [],
118
+ }));
119
+ }
120
+ catch {
121
+ return [];
122
+ }
123
+ }
124
+ /**
125
+ * `:effort` for an agent: the levels its engine takes. For Codex, the levels
126
+ * of the model it uses (all of Codex's levels when that's its default).
127
+ */
128
+ export async function effortChoicesFor(engine, model, env = process.env) {
129
+ if (engine !== "codex")
130
+ return effortChoices(engine);
131
+ const catalog = await codexModels(env);
132
+ const own = catalog.find((m) => m.slug === model)?.efforts;
133
+ const levels = own?.length ? own : [...new Set(catalog.flatMap((m) => m.efforts))];
134
+ if (!levels.length)
135
+ return effortChoices(engine);
136
+ return [{ value: "default", note: "what the model uses" }, ...EFFORTS.codex.filter((e) => levels.includes(e)).map((value) => ({ value })), ...levels.filter((e) => !EFFORTS.codex.includes(e)).map((value) => ({ value }))];
137
+ }
package/dist/cli.js CHANGED
@@ -14,6 +14,7 @@ import { createDemoSessions } from "./demo.js";
14
14
  import { buildKeymap, KEY_MODES } from "./keys.js";
15
15
  import { formatBytes, prepareViews, riftriBackends, riftriGc, riftriHealth, riftriStateDir } from "./riftri.js";
16
16
  import { DemoSession, PiSession } from "./session.js";
17
+ import { followedColorscheme, followTheme, isFollow, refreshFollow } from "./follow.js";
17
18
  import { BUILTIN_THEMES, listThemes, loadTheme, Painter, themesDir } from "./theme.js";
18
19
  import { themeNewTemplate } from "./theme-new.js";
19
20
  import { branchName, createWorktree, findWorktree, listWorktrees, removeWorktree, repoRoot, worktreesDir, } from "./worktree.js";
@@ -21,7 +22,7 @@ const HELP = `vigent — vim-native agent orchestrator (pi engine)
21
22
 
22
23
  usage:
23
24
  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
+ vigent [folder] [--demo] [--theme <name>] [--model <id>] [--engine pi|claude|codex|opencode]
25
26
 
26
27
  engines (what runs new agents; :checkhealth shows which are signed in):
27
28
  pi built in, uses ANTHROPIC_API_KEY / OPENAI_API_KEY / ... or pi's login
@@ -206,7 +207,9 @@ async function main() {
206
207
  const keys = buildKeymap(config.leader, config.keys);
207
208
  warnings.push(...keys.warnings);
208
209
  try {
209
- if (config.theme)
210
+ if (isFollow(config.theme))
211
+ followTheme(config.theme);
212
+ else if (config.theme)
210
213
  loadTheme(config.theme);
211
214
  }
212
215
  catch (err) {
@@ -229,6 +232,17 @@ async function main() {
229
232
  console.log(`created ${file}`);
230
233
  return 0;
231
234
  }
235
+ // `vigent <folder>` (like `code .` or `nvim .`) opens that folder; with none, the current one.
236
+ const VALUE_FLAGS = ["--theme", "--model", "--engine"];
237
+ const folder = argv.find((a, i) => !a.startsWith("-") && !VALUE_FLAGS.includes(argv[i - 1]));
238
+ if (folder !== undefined) {
239
+ const dir = path.resolve(folder);
240
+ if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) {
241
+ console.error(`vigent: '${folder}' is not a folder (usage: vigent [folder])`);
242
+ return 1;
243
+ }
244
+ process.chdir(dir);
245
+ }
232
246
  const { config, warnings } = loadConfig();
233
247
  const stale = staleBuild();
234
248
  if (stale)
@@ -248,7 +262,8 @@ async function main() {
248
262
  const themeName = flag(argv, "--theme") ?? config.theme ?? "default";
249
263
  let theme = BUILTIN_THEMES.default;
250
264
  try {
251
- theme = loadTheme(themeName);
265
+ // theme = "nvim" / "vim": your editor's colorscheme (kept up to date below)
266
+ theme = isFollow(themeName) ? followTheme(themeName) : loadTheme(themeName);
252
267
  }
253
268
  catch (err) {
254
269
  if (flag(argv, "--theme")) {
@@ -415,6 +430,13 @@ async function main() {
415
430
  tui.start();
416
431
  if (warnings.length > 0)
417
432
  app.notify(warnings.join(" · "));
433
+ if (isFollow(themeName)) {
434
+ const editor = themeName;
435
+ refreshFollow(editor, (t) => {
436
+ app.setTheme(t);
437
+ app.notify(`colorscheme from ${editor}: ${followedColorscheme(editor) ?? editor}`, "Comment");
438
+ });
439
+ }
418
440
  tui.requestRender(true);
419
441
  return undefined;
420
442
  }
package/dist/config.js CHANGED
@@ -8,7 +8,7 @@
8
8
  * sidebar_width = 30
9
9
  * number = true
10
10
  * tilde = true
11
- * icons = "unicode" # or "ascii"
11
+ * icons = "unicode" # or "nerd" (a Nerd Font), "ascii"
12
12
  *
13
13
  * [highlights] # applied on top of whichever theme is active
14
14
  * Normal = { bg = "NONE" } # transparent background
@@ -19,6 +19,7 @@ import * as fs from "node:fs";
19
19
  import * as path from "node:path";
20
20
  import { parse as parseToml } from "smol-toml";
21
21
  import { EFFORTS } from "./choices.js";
22
+ import { ICON_SETS } from "./icons.js";
22
23
  import { isEngineId } from "./engines.js";
23
24
  import { WORKTREE_BACKENDS } from "./riftri.js";
24
25
  import { KEY_MODES } from "./keys.js";
@@ -223,8 +224,8 @@ export function setOption(ui, name, value) {
223
224
  return;
224
225
  }
225
226
  case "icons":
226
- if (value !== "unicode" && value !== "ascii")
227
- return `'${name}' should be "unicode" or "ascii"`;
227
+ if (!ICON_SETS.includes(value))
228
+ return `'${name}' should be "unicode", "nerd" or "ascii"`;
228
229
  ui.icons = value;
229
230
  return;
230
231
  case "transparent":
@@ -316,6 +317,8 @@ export const CONFIG_TEMPLATE = `# vigent config: ~/.config/vigent/config.toml
316
317
  # Built-in: default, lazyvim (transparent), tokyonight, tokyonight-moon, gruvbox,
317
318
  # catppuccin, catppuccin-latte, nord, rose-pine, kanagawa. Or any theme in ~/.config/vigent/themes, e.g. one made
318
319
  # with vigent theme import (your nvim/vim colorscheme) or vigent theme new.
320
+ # "nvim" (or "vim") follows your editor: the colorscheme your config and plugins
321
+ # set (AstroNvim, LazyVim, NvChad...), updated each time vigent starts.
319
322
  theme = "default"
320
323
 
321
324
  # Model used for new pi agents.
@@ -347,7 +350,7 @@ sidebar = true
347
350
  sidebar_width = 30
348
351
  number = true # session numbers in the sidebar
349
352
  tilde = true # ~ on empty sidebar rows
350
- icons = "unicode" # "ascii" for fonts without box-drawing characters
353
+ icons = "unicode" # "nerd" for Nerd Font icons (like AstroNvim/LazyVim), "ascii" for fonts without box-drawing characters
351
354
  timeoutlen = 1000 # ms to wait for the rest of a multi-key mapping
352
355
  # termguicolors = false # for terminals without 24-bit color: use the nearest of 256
353
356
  # transparent = true # use the terminal's background with any theme ("auto": the theme decides)
package/dist/diffview.js CHANGED
@@ -8,6 +8,7 @@
8
8
  * Both return the line indices of hunk headers so ]c / [c can jump.
9
9
  */
10
10
  import { truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
11
+ import { fileIcon } from "./icons.js";
11
12
  function lineGroup(line) {
12
13
  if (line.startsWith("+++") || line.startsWith("---"))
13
14
  return "Title";
@@ -38,8 +39,8 @@ function cell(no, text, width, group) {
38
39
  [group, body + " ".repeat(Math.max(0, width - 5 - visibleWidth(body)))],
39
40
  ];
40
41
  }
41
- /** Side-by-side layout for a window `width` columns wide (inside its border). */
42
- export function splitView(text, width) {
42
+ /** Side-by-side layout for a window `width` columns wide (inside its border). With `icons`, file names get their Nerd Font icon. */
43
+ export function splitView(text, width, icons = false) {
43
44
  const col = Math.max(12, Math.floor((width - 3) / 2));
44
45
  const lines = [];
45
46
  const jumps = [];
@@ -63,7 +64,9 @@ export function splitView(text, width) {
63
64
  }
64
65
  const file = /^\+\+\+ (?:b\/)?(.*)$/.exec(line);
65
66
  if (file) {
66
- lines.push([["Title", file[1] === "/dev/null" ? "(deleted file)" : file[1]]]);
67
+ const name = file[1] === "/dev/null" ? "(deleted file)" : file[1];
68
+ const [glyph, group] = fileIcon(name);
69
+ lines.push(icons ? [[group, `${glyph} `], ["Title", name]] : [["Title", name]]);
67
70
  continue;
68
71
  }
69
72
  if (/^(---|index |new file|deleted file|similarity|rename |\\ No newline)/.test(line))
package/dist/follow.js ADDED
@@ -0,0 +1,75 @@
1
+ /**
2
+ * `theme = "nvim"` (or `"vim"`): vigent uses the colorscheme your editor
3
+ * uses, with your config and plugins (AstroNvim, LazyVim, NvChad...).
4
+ *
5
+ * The editor's colors are kept as the theme file `nvim.json` (`vim.json`).
6
+ * At start vigent shows that copy at once, and imports again in the
7
+ * background: when you've changed your editor's colorscheme since, the new
8
+ * one replaces it while vigent runs. The first time there's no copy yet, so
9
+ * vigent imports before it opens.
10
+ */
11
+ import { spawn } from "node:child_process";
12
+ import * as fs from "node:fs";
13
+ import * as path from "node:path";
14
+ import { importTheme } from "./import.js";
15
+ import { loadTheme, themesDir } from "./theme.js";
16
+ export const FOLLOW_EDITORS = ["nvim", "vim"];
17
+ export function isFollow(theme) {
18
+ return FOLLOW_EDITORS.includes(theme ?? "");
19
+ }
20
+ /** The theme file that holds the editor's colors. */
21
+ export function followFile(editor, dir = themesDir()) {
22
+ return path.join(dir, `${editor}.json`);
23
+ }
24
+ /** The colorscheme the copy came from (e.g. "astrodark"), if it says. */
25
+ export function followedColorscheme(editor, dir = themesDir()) {
26
+ try {
27
+ const c = JSON.parse(fs.readFileSync(followFile(editor, dir), "utf8")).colorscheme;
28
+ return typeof c === "string" && c ? c : undefined;
29
+ }
30
+ catch {
31
+ return undefined;
32
+ }
33
+ }
34
+ /** The editor's theme: the copy from last time, or (the first time) an import now. Throws if the editor can't say. */
35
+ export function followTheme(editor, dir = themesDir()) {
36
+ const file = followFile(editor, dir);
37
+ if (!fs.existsSync(file))
38
+ importTheme({ editor, as: editor, outDir: dir });
39
+ return loadTheme(file);
40
+ }
41
+ /** The copy's colors in a fixed order (the editor lists its groups in any order). */
42
+ function read(file) {
43
+ try {
44
+ const sorted = (v) => v && typeof v === "object" && !Array.isArray(v)
45
+ ? Object.fromEntries(Object.keys(v).sort().map((k) => [k, sorted(v[k])]))
46
+ : v;
47
+ return JSON.stringify(sorted(JSON.parse(fs.readFileSync(file, "utf8"))));
48
+ }
49
+ catch {
50
+ return undefined;
51
+ }
52
+ }
53
+ /**
54
+ * Import the editor's colorscheme again without blocking (in a child
55
+ * `vigent theme import`); `onChange` gets the theme when it differs from the copy.
56
+ */
57
+ export function refreshFollow(editor, onChange, cli = process.argv[1]) {
58
+ const file = followFile(editor);
59
+ const before = read(file);
60
+ const child = spawn(process.execPath, [cli, "theme", "import", `--${editor}`, "--as", editor], { stdio: "ignore" });
61
+ child.on("error", () => { });
62
+ child.on("exit", (code) => {
63
+ if (code !== 0)
64
+ return;
65
+ const after = read(file);
66
+ if (after === undefined || after === before)
67
+ return;
68
+ try {
69
+ onChange(loadTheme(file));
70
+ }
71
+ catch {
72
+ // a copy that doesn't load: keep what's on screen
73
+ }
74
+ });
75
+ }
package/dist/icons.js ADDED
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Nerd Font icons (`icons = "nerd"`), the glyphs AstroNvim, LazyVim and
3
+ * nvim-web-devicons draw. They need a Nerd Font in the terminal
4
+ * (https://www.nerdfonts.com); codepoints are Nerd Fonts v3.
5
+ */
6
+ export const ICON_SETS = ["unicode", "nerd", "ascii"];
7
+ export const NERD = {
8
+ branch: "", // nf-dev-git_branch
9
+ check: "", // nf-fa-check
10
+ times: "", // nf-fa-times
11
+ warning: "", // nf-fa-warning
12
+ circle: "", // nf-fa-circle
13
+ circleO: "", // nf-fa-circle_o
14
+ pause: "", // nf-fa-pause
15
+ clock: "", // nf-fa-clock_o
16
+ hourglass: "", // nf-fa-hourglass_half
17
+ search: "", // nf-fa-search
18
+ pencil: "", // nf-fa-pencil_square_o
19
+ plus: "", // nf-fa-plus
20
+ palette: "", // nf-fa-palette
21
+ gear: "", // nf-fa-gear
22
+ chip: "", // nf-fa-microchip
23
+ bolt: "", // nf-fa-bolt
24
+ keyboard: "", // nf-fa-keyboard_o
25
+ power: "", // nf-fa-power_off
26
+ terminal: "", // nf-oct-terminal
27
+ file: "", // nf-fa-file
28
+ fileText: "", // nf-fa-file_text
29
+ globe: "", // nf-fa-globe
30
+ wrench: "", // nf-fa-wrench
31
+ list: "", // nf-fa-list
32
+ caretRight: "", // nf-fa-caret_right
33
+ caretDown: "", // nf-fa-caret_down
34
+ };
35
+ /** An icon for a tool call, by the tool's name (Claude Code, Codex, OpenCode and pi names). */
36
+ export function toolIcon(name) {
37
+ const n = name.toLowerCase();
38
+ if (/bash|shell|exec|command|run/.test(n))
39
+ return NERD.terminal;
40
+ if (/edit|write|patch|apply/.test(n))
41
+ return NERD.pencil;
42
+ if (/read|view|cat|open/.test(n))
43
+ return NERD.fileText;
44
+ if (/grep|glob|search|find|^ls$|list/.test(n))
45
+ return NERD.search;
46
+ if (/web|fetch|http|url/.test(n))
47
+ return NERD.globe;
48
+ if (/todo|plan|task/.test(n))
49
+ return NERD.list;
50
+ return NERD.wrench;
51
+ }
52
+ /**
53
+ * A file's icon and the highlight group to color it with, by name or
54
+ * extension (like nvim-web-devicons, colored from the theme).
55
+ */
56
+ export function fileIcon(file) {
57
+ const base = file.split(/[/\\]/).pop()?.toLowerCase() ?? "";
58
+ const byName = {
59
+ "package.json": ["", "DiagnosticError"], // nf-dev-npm
60
+ "package-lock.json": ["", "DiagnosticError"],
61
+ dockerfile: ["", "DiagnosticInfo"], // nf-linux-docker
62
+ ".gitignore": ["", "DiagnosticWarn"], // nf-dev-git
63
+ makefile: ["", "Comment"], // nf-dev-gnu
64
+ license: ["", "DiagnosticWarn"], // nf-seti-license
65
+ };
66
+ if (byName[base])
67
+ return byName[base];
68
+ const ext = base.includes(".") ? base.slice(base.lastIndexOf(".") + 1) : "";
69
+ const byExt = {
70
+ ts: ["", "Function"], // nf-seti-typescript
71
+ tsx: ["", "Function"], // nf-dev-react
72
+ mts: ["", "Function"],
73
+ js: ["", "DiagnosticWarn"], // nf-dev-javascript
74
+ jsx: ["", "Function"],
75
+ mjs: ["", "DiagnosticWarn"],
76
+ cjs: ["", "DiagnosticWarn"],
77
+ json: ["", "DiagnosticWarn"], // nf-seti-json
78
+ md: ["", "Normal"], // nf-dev-markdown
79
+ py: ["", "DiagnosticWarn"], // nf-dev-python
80
+ rs: ["", "Special"], // nf-dev-rust
81
+ go: ["", "Function"], // nf-seti-go
82
+ lua: ["", "Function"], // nf-seti-lua
83
+ vim: ["", "String"], // nf-seti-vim
84
+ html: ["", "Special"], // nf-dev-html5
85
+ css: ["", "Function"], // nf-dev-css3
86
+ scss: ["", "Statement"],
87
+ sh: ["", "String"], // nf-oct-terminal
88
+ bash: ["", "String"],
89
+ zsh: ["", "String"],
90
+ toml: ["", "Comment"], // nf-seti-config
91
+ yml: ["", "Comment"],
92
+ yaml: ["", "Comment"],
93
+ lock: ["", "Comment"], // nf-fa-lock
94
+ svg: ["", "DiagnosticWarn"], // nf-fa-file_image_o
95
+ png: ["", "Constant"],
96
+ jpg: ["", "Constant"],
97
+ java: ["", "DiagnosticError"], // nf-dev-java
98
+ rb: ["", "DiagnosticError"], // nf-dev-ruby
99
+ c: ["", "Function"], // nf-custom-c
100
+ cpp: ["", "Function"], // nf-custom-cpp
101
+ h: ["", "Statement"],
102
+ };
103
+ return byExt[ext] ?? [NERD.file, "Comment"];
104
+ }
package/dist/import.js CHANGED
@@ -255,6 +255,7 @@ export function importTheme(opts = {}) {
255
255
  const dir = opts.outDir ?? themesDir();
256
256
  fs.mkdirSync(dir, { recursive: true });
257
257
  const file = path.join(dir, `${name}.json`);
258
- fs.writeFileSync(file, `${JSON.stringify({ name, background, source: via, groups }, null, "\t")}\n`);
258
+ const colorscheme = dump?.name || undefined;
259
+ fs.writeFileSync(file, `${JSON.stringify({ name, background, source: via, colorscheme, groups }, null, "\t")}\n`);
259
260
  return { name, file, groups: Object.keys(groups).length, via };
260
261
  }
package/dist/picker.js CHANGED
@@ -8,6 +8,7 @@ import { fuzzyFilter, parseKey, truncateToWidth, visibleWidth } from "@mariozech
8
8
  import { RESET, sgr } from "./theme.js";
9
9
  export const PICKER_ICONS = {
10
10
  unicode: { tl: "╭", tr: "╮", bl: "╰", br: "╯", h: "─", v: "│", lt: "├", rt: "┤", current: "●", prompt: "❯" },
11
+ nerd: { tl: "╭", tr: "╮", bl: "╰", br: "╯", h: "─", v: "│", lt: "├", rt: "┤", current: "\uf00c", prompt: "\uf002" },
11
12
  ascii: { tl: "+", tr: "+", bl: "+", br: "+", h: "-", v: "|", lt: "+", rt: "+", current: "*", prompt: ">" },
12
13
  };
13
14
  const VISIBLE = 14;
@@ -171,9 +172,10 @@ export class ThemePicker {
171
172
  }
172
173
  }
173
174
  /**
174
- * A filterable list (`:model`, `:effort`). Type to filter, ↑/↓ (Ctrl-n/p,
175
- * Tab) to move, Enter picks. With `custom`, Enter on text that matches
176
- * nothing picks the text itself (a model the list doesn't know).
175
+ * A list to pick from (`:model`, `:effort`), vim-style. `j`/`k` (↓/↑, Ctrl-n/p,
176
+ * Tab) move, `g`/`G` jump to the top/bottom, Enter picks, `q`/Esc closes.
177
+ * `/` starts a search: type to filter, Esc ends it. With `custom`, Enter on a
178
+ * search that matches nothing picks the text itself (a model the list doesn't know).
177
179
  */
178
180
  export class ChoicePicker {
179
181
  painter;
@@ -184,6 +186,8 @@ export class ChoicePicker {
184
186
  onDone;
185
187
  requestRender;
186
188
  query = "";
189
+ /** typing goes to the search (after `/`) */
190
+ searching = false;
187
191
  index = 0;
188
192
  scroll = 0;
189
193
  items = [];
@@ -215,45 +219,70 @@ export class ChoicePicker {
215
219
  handleInput(data) {
216
220
  const key = parseKey(data) ?? "";
217
221
  const items = this.filtered();
218
- switch (key) {
219
- case "escape":
220
- case "ctrl+c":
221
- this.onDone(null);
222
- return;
223
- case "enter":
224
- case "return": {
225
- const picked = items[this.index]?.value ?? (this.custom && this.query.trim() ? this.query.trim() : undefined);
226
- if (picked !== undefined)
227
- this.onDone(picked);
228
- return;
229
- }
230
- case "down":
231
- case "tab":
232
- case "ctrl+n":
233
- case "ctrl+j":
234
- if (items.length)
235
- this.index = (this.index + 1) % items.length;
236
- break;
237
- case "up":
238
- case "shift+tab":
239
- case "ctrl+p":
240
- case "ctrl+k":
241
- if (items.length)
242
- this.index = (this.index - 1 + items.length) % items.length;
243
- break;
244
- case "backspace":
245
- this.query = this.query.slice(0, -1);
222
+ const move = (by) => {
223
+ if (items.length)
224
+ this.index = (this.index + by + items.length) % items.length;
225
+ };
226
+ if (key === "ctrl+c")
227
+ return this.onDone(null);
228
+ if (key === "enter" || key === "return") {
229
+ const picked = items[this.index]?.value ?? (this.custom && this.query.trim() ? this.query.trim() : undefined);
230
+ if (picked !== undefined)
231
+ this.onDone(picked);
232
+ return;
233
+ }
234
+ if (["down", "tab", "ctrl+n", "ctrl+j"].includes(key))
235
+ move(1);
236
+ else if (["up", "shift+tab", "ctrl+p", "ctrl+k"].includes(key))
237
+ move(-1);
238
+ else if (this.searching) {
239
+ if (key === "escape")
240
+ this.searching = false; // the filter stays; j/k move through it
241
+ else if (key === "backspace") {
242
+ if (this.query)
243
+ this.query = this.query.slice(0, -1);
244
+ else
245
+ this.searching = false;
246
246
  this.index = 0;
247
- break;
248
- case "ctrl+u":
247
+ }
248
+ else if (key === "ctrl+u") {
249
249
  this.query = "";
250
250
  this.index = 0;
251
- break;
252
- default:
253
- if (data.length >= 1 && !data.startsWith("\x1b") && data >= " ") {
254
- this.query += data;
251
+ }
252
+ else if (data.length >= 1 && !data.startsWith("\x1b") && data >= " ") {
253
+ this.query += data;
254
+ this.index = 0;
255
+ }
256
+ }
257
+ else {
258
+ switch (data) {
259
+ case "j":
260
+ move(1);
261
+ break;
262
+ case "k":
263
+ move(-1);
264
+ break;
265
+ case "g":
255
266
  this.index = 0;
256
- }
267
+ break;
268
+ case "G":
269
+ this.index = Math.max(0, items.length - 1);
270
+ break;
271
+ case "/":
272
+ this.searching = true;
273
+ break;
274
+ case "q":
275
+ return this.onDone(null);
276
+ default:
277
+ if (key === "escape") {
278
+ if (this.query) {
279
+ this.query = ""; // first Esc drops the filter, the next one closes
280
+ this.index = 0;
281
+ }
282
+ else
283
+ return this.onDone(null);
284
+ }
285
+ }
257
286
  }
258
287
  if (this.index < this.scroll)
259
288
  this.scroll = this.index;
@@ -272,7 +301,14 @@ export class ChoicePicker {
272
301
  const items = this.filtered();
273
302
  const title = ` ${this.title} `;
274
303
  const lines = [border(I.tl + I.h) + P.on("FloatTitle", bg) + title + RESET + border(I.h.repeat(Math.max(0, inner - 1 - visibleWidth(title))) + I.tr)];
275
- lines.push(row(P.on("Title", bg) + ` ${I.prompt} ` + RESET + P.on("NormalFloat", bg) + this.query + P.on("PmenuSel", undefined) + " " + RESET, visibleWidth(this.query) + 4));
304
+ if (this.searching) {
305
+ lines.push(row(P.on("Title", bg) + ` ${I.prompt} ` + RESET + P.on("NormalFloat", bg) + this.query + P.on("PmenuSel", undefined) + " " + RESET, visibleWidth(this.query) + 4));
306
+ }
307
+ else {
308
+ const hint = this.query ? `/${this.query}` : `/ to search${this.custom ? " or type any name" : ""}`;
309
+ const text = truncateToWidth(hint, Math.max(0, inner - 4), "…");
310
+ lines.push(row(P.on("Comment", bg) + ` ${I.prompt} ${text}` + RESET, visibleWidth(text) + 3));
311
+ }
276
312
  lines.push(border(I.lt + I.h.repeat(inner) + I.rt));
277
313
  const shown = items.slice(this.scroll, this.scroll + VISIBLE);
278
314
  for (let i = 0; i < VISIBLE; i++) {
@@ -294,7 +330,8 @@ export class ChoicePicker {
294
330
  const note = c.note ? truncateToWidth(` ${c.note}`, Math.max(0, inner - 2 - visibleWidth(name)), "…") : "";
295
331
  lines.push(row(P.on(group, rowBg) + ` ${name}` + P.on("Comment", rowBg) + note + RESET, 1 + visibleWidth(name) + visibleWidth(note), rowBg));
296
332
  }
297
- const count = ` ${items.length ? this.index + 1 : 0}/${items.length}${this.custom ? " · type any name" : ""} `;
333
+ const keys = this.searching ? "type to filter · Esc done" : "j/k move · Enter pick · q close";
334
+ const count = ` ${items.length ? this.index + 1 : 0}/${items.length} · ${keys} `;
298
335
  lines.push(border(I.bl + I.h.repeat(Math.max(0, inner - visibleWidth(count) - 1))) + P.on("Comment", bg) + count + RESET + border(I.h + I.br));
299
336
  return lines;
300
337
  }
package/dist/tree.js CHANGED
@@ -9,6 +9,7 @@
9
9
  */
10
10
  export const TREE_GLYPHS = {
11
11
  unicode: { tee: "├─ ", last: "└─ ", pipe: "│ ", blank: " " },
12
+ nerd: { tee: "├─ ", last: "└─ ", pipe: "│ ", blank: " " },
12
13
  ascii: { tee: "|- ", last: "`- ", pipe: "| ", blank: " " },
13
14
  };
14
15
  function textOf(content) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vigent/cli",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "A vim-style terminal app for running coding agents side by side, each in its own git worktree. Built on pi.",
5
5
  "keywords": [
6
6
  "vim",