@vigent/cli 0.2.1 → 0.2.3

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
@@ -82,7 +82,7 @@ pi runs inside vigent. Claude Code, Codex and OpenCode are the CLIs you may alre
82
82
  :checkhealth " installed? signed in? how to connect the rest
83
83
  ```
84
84
 
85
- The sidebar's ENGINES block shows each one: `●` signed in, `◐` installed but not signed in (with the command to sign in, like `codex login`), `○` not installed. Without `--engine`, new agents use `engine` from the config. Its default, `"auto"`, picks pi if it has an API key, otherwise the first signed-in CLI. A CLI agent survives a restart too: vigent saves the CLI's conversation id and continues that conversation. Its earlier messages stay in the CLI's own history.
85
+ The sidebar's ENGINES block shows each one: `●` signed in, `◐` installed but not signed in (with the command to sign in, like `codex login`), `○` not installed. Without `--engine`, new agents use `engine` from the config. Its default, `"auto"`, picks pi if it has an API key, otherwise the first signed-in CLI. **`:engine`** (`<Space>E`, or `E` on the dashboard) switches it while vigent runs: a menu lists auto, pi, Claude Code, Codex and OpenCode with which are ready, and `:engine claude` sets it directly. Agents already open keep their engine, since their conversation lives there; `:new <task>` starts one on the new engine. A CLI agent survives a restart too: vigent saves the CLI's conversation id and continues that conversation. Its earlier messages stay in the CLI's own history.
86
86
 
87
87
  ![:checkhealth with Claude Code signed in and Codex waiting for codex login](https://raw.githubusercontent.com/farming-labs/vigent/main/docs/screenshots/checkhealth.png)
88
88
 
@@ -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,16 +201,19 @@ 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
 
206
- 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
+ **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
+
209
+ Inside vigent, `:colorscheme` (`<Space>t`) opens a picker with live preview: `j`/`k` move and preview, Enter keeps it, `q` or Esc puts the old one back, and `/` searches. `: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
209
212
  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
@@ -17,14 +17,15 @@ import * as fs from "node:fs";
17
17
  import { setDetached } from "./agents.js";
18
18
  import { VERSION_TEXT } from "./version.js";
19
19
  import { backendName, formatBytes, riftriBackends, RIFTRI_INSTALL } from "./riftri.js";
20
- import { ENGINE_NAMES, isEngineId } from "./engines.js";
20
+ import { defaultEngine as defaultEngineFor, ENGINE_NAMES, isEngineId } from "./engines.js";
21
21
  import { copyToClipboard } from "./clipboard.js";
22
22
  import { splitView, unifiedView } from "./diffview.js";
23
23
  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" },
@@ -734,6 +756,13 @@ export class App {
734
756
  else
735
757
  this.openModelPicker();
736
758
  return;
759
+ case "engine":
760
+ case "eng":
761
+ if (arg)
762
+ this.chooseEngine(arg);
763
+ else
764
+ this.openEnginePicker();
765
+ return;
737
766
  case "effort":
738
767
  case "think":
739
768
  if (arg)
@@ -948,7 +977,8 @@ export class App {
948
977
  /** `:effort <level>` */
949
978
  chooseEffort(level, t = this.modelTarget()) {
950
979
  const allowed = ["default", ...EFFORTS[t.engine]];
951
- if (!allowed.includes(level)) {
980
+ // Codex's levels differ per model and version; Codex itself says if one doesn't fit
981
+ if (!allowed.includes(level) && !(t.engine === "codex" && /^[a-z]+$/.test(level))) {
952
982
  this.setMessage(`${ENGINE_NAMES[t.engine]} takes: ${allowed.join(", ")}`, "ErrorMsg");
953
983
  return;
954
984
  }
@@ -973,6 +1003,37 @@ export class App {
973
1003
  this.tui.requestRender();
974
1004
  });
975
1005
  }
1006
+ /** `:engine`: which engine new agents run on, with what's ready on this machine. */
1007
+ openEnginePicker() {
1008
+ const ids = ["auto", "pi", "claude", "codex", "opencode"];
1009
+ const items = ids.map((id) => ({ value: id, note: this.engineNote(id) }));
1010
+ this.openChoices("Engine for new agents", items, this.preferredEngine, false, (v) => this.chooseEngine(v));
1011
+ }
1012
+ /** "Claude Code · ready", "Codex · not signed in: codex login", "auto: … (now Claude Code)" */
1013
+ engineNote(id) {
1014
+ if (id === "auto")
1015
+ return `pick for me (now ${ENGINE_NAMES[defaultEngineFor(this.engines)]})`;
1016
+ const e = this.engines?.find((x) => x.id === id);
1017
+ if (!e)
1018
+ return ENGINE_NAMES[id];
1019
+ const status = e.connected ? "ready" : !e.installed ? "not installed" : e.connect ? `not signed in: ${e.connect}` : "not signed in";
1020
+ return `${ENGINE_NAMES[id]} · ${status}`;
1021
+ }
1022
+ /** `:engine <name>`: new agents run on it. Agents already open keep theirs (their conversation lives there). */
1023
+ chooseEngine(value) {
1024
+ if (value !== "auto" && !isEngineId(value)) {
1025
+ this.setMessage("engines: auto, pi, claude, codex, opencode", "ErrorMsg");
1026
+ return;
1027
+ }
1028
+ this.preferredEngine = value;
1029
+ const now = this.defaultEngine();
1030
+ const e = this.engines?.find((x) => x.id === now);
1031
+ const warn = e && !e.connected ? ` (${e.installed ? `not signed in${e.connect ? `: run ${e.connect}` : ""}` : "not installed"})` : "";
1032
+ const s = this.focusedSession;
1033
+ const open = s && s.engine !== now ? ` · '${s.name}' stays on ${ENGINE_NAMES[s.engine]}; :new <task> starts one on ${ENGINE_NAMES[now]}` : "";
1034
+ this.setMessage(`new agents run on ${ENGINE_NAMES[now]}${value === "auto" ? " (auto)" : ""}${warn}${open} · :settings, w keeps it`, warn ? "WarningMsg" : "ModeMsg");
1035
+ this.repaint();
1036
+ }
976
1037
  /** `:model`: pick from what the engine offers, or type any name. */
977
1038
  openModelPicker(t = this.modelTarget()) {
978
1039
  const current = t.session
@@ -985,8 +1046,16 @@ export class App {
985
1046
  /** `:effort`: the reasoning effort / thinking levels the engine accepts. */
986
1047
  openEffortPicker(t = this.modelTarget()) {
987
1048
  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));
1049
+ this.openChoices(`Effort · ${t.label}`, this.effortChoices(t.engine, this.modelOf(t)), current, false, (v) => this.chooseEffort(v, t));
1050
+ }
1051
+ /** the model an agent (or new agents) asked for, if any */
1052
+ modelOf(t) {
1053
+ if (!t.session)
1054
+ return this.newAgentModel[t.engine];
1055
+ return "chosenModel" in t.session ? t.session.chosenModel : t.session.model;
989
1056
  }
1057
+ /** where effort levels come from (replaced in tests) */
1058
+ effortChoices = (engine, model) => effortChoicesFor(engine, model);
990
1059
  /** where model lists come from (replaced in tests) */
991
1060
  modelChoices = (engine) => modelChoices(engine);
992
1061
  openChoices(title, items, current, custom, done) {
@@ -1033,7 +1102,13 @@ export class App {
1033
1102
  },
1034
1103
  hint: "the terminal's own background shows through (its blur, opacity or wallpaper)",
1035
1104
  },
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" },
1105
+ {
1106
+ section: "Look",
1107
+ label: "Icons",
1108
+ value: () => ui.icons,
1109
+ change: () => ((ui.icons = ICON_SETS[(ICON_SETS.indexOf(ui.icons) + 1) % ICON_SETS.length]), refresh()),
1110
+ hint: "nerd: Nerd Font icons (needs a Nerd Font) · ascii: fonts without box-drawing characters",
1111
+ },
1037
1112
  { 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
1113
  { section: "Layout", label: "Sidebar", value: () => onOff(ui.sidebar), change: () => ((ui.sidebar = !ui.sidebar), refresh()) },
1039
1114
  {
@@ -1052,7 +1127,7 @@ export class App {
1052
1127
  change: (d) => {
1053
1128
  this.preferredEngine = cycle(["auto", "pi", "claude", "codex", "opencode"], this.preferredEngine, d);
1054
1129
  },
1055
- hint: "auto: pi with an API key, else the first signed-in CLI · :checkhealth shows which are ready",
1130
+ hint: "auto: pi with an API key, else the first signed-in CLI · :engine picks it from anywhere",
1056
1131
  },
1057
1132
  {
1058
1133
  section: "Agents",
@@ -1575,7 +1650,7 @@ export class App {
1575
1650
  }
1576
1651
  const width = this.terminal.columns - 4;
1577
1652
  const unified = unifiedView(text);
1578
- const side = splitView(text, width - 3);
1653
+ const side = splitView(text, width - 3, this.ui.icons === "nerd");
1579
1654
  const n = unified.jumps.length;
1580
1655
  const title = (layout) => `${wt.branch} vs ${wt.base ?? "base"} · ${n} hunk${n === 1 ? "" : "s"} · ]c [c jump · s ${layout}`;
1581
1656
  const views = [
@@ -1937,6 +2012,9 @@ export class App {
1937
2012
  case "e":
1938
2013
  this.openEffortPicker();
1939
2014
  return true;
2015
+ case "E":
2016
+ this.openEnginePicker();
2017
+ return true;
1940
2018
  case "c":
1941
2019
  this.openHealth();
1942
2020
  return true;
@@ -2042,7 +2120,7 @@ export class App {
2042
2120
  const rows = [];
2043
2121
  const add = (text, plain, indent = center(plain)) => rows.push({ text: indent + text, plain: indent + plain });
2044
2122
  // 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;
2123
+ const logo = this.ui.icons !== "ascii" && paneW >= LOGO[0].length + 2 && paneH >= 22;
2046
2124
  if (logo) {
2047
2125
  const groups = ["Function", "Function", "Special", "Special", "Keyword", "Keyword"];
2048
2126
  LOGO.forEach((l, i) => add(P.p(groups[i] ?? "Title", l), l));
@@ -2054,7 +2132,7 @@ export class App {
2054
2132
  const tagline = "coding agents, the vim way · each one in its own git worktree";
2055
2133
  add(P.p("Comment", tagline), tagline);
2056
2134
  add("", "");
2057
- const actions = DASHBOARD.map(([key, icon, label]) => [key, this.ui.icons === "unicode" ? icon : "", label]);
2135
+ const actions = DASHBOARD.map(([key, icon, label, nerd]) => [key, this.ui.icons === "nerd" ? nerd : this.ui.icons === "unicode" ? icon : "", label]);
2058
2136
  const labelW = Math.max(...actions.map(([, , l]) => l.length));
2059
2137
  const blockW = 2 + labelW + 6;
2060
2138
  const indent = center("x".repeat(blockW));
@@ -2075,7 +2153,7 @@ export class App {
2075
2153
  });
2076
2154
  add(text, plain, indentE);
2077
2155
  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`;
2156
+ 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
2157
  add(P.p("Comment", foot), foot);
2080
2158
  }
2081
2159
  if (!this.workspace.root) {
@@ -2245,7 +2323,8 @@ export class App {
2245
2323
  case "tool": {
2246
2324
  const t = item.tool;
2247
2325
  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 });
2326
+ const icon = this.ui.icons === "nerd" ? `${toolIcon(t.name)} ` : "";
2327
+ 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
2328
  if (!t.folded && t.output) {
2250
2329
  const block = nextBlock++;
2251
2330
  const outLines = t.output.split("\n");
@@ -2404,6 +2483,7 @@ const EX_HELP = [
2404
2483
  [":checkhealth", "which engines are installed and signed in"],
2405
2484
  [":model [name]", "pick the agent's model (no agent: for new agents)"],
2406
2485
  [":effort [level]", "pick its reasoning effort: low … max (pi: thinking level)"],
2486
+ [":engine [name]", "pick the engine new agents run on: auto, pi, claude, codex, opencode"],
2407
2487
  [":settings", "theme, look, engine, worktrees and keys in one place (w saves)"],
2408
2488
  [":w [file]", "save the transcript as Markdown"],
2409
2489
  [":worktrees", "list agent worktrees"],
@@ -2474,14 +2554,16 @@ const LOGO = [
2474
2554
  " ╚═══╝ ╚═╝ ╚═════╝ ╚══════╝╚═╝ ╚═══╝ ╚═╝ ",
2475
2555
  ];
2476
2556
  /** The dashboard's keys (active while no agent is open): key, icon, label. */
2557
+ /** key, unicode icon, label, Nerd Font icon */
2477
2558
  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"],
2559
+ ["i", "✎", "New agent: type a task, Enter", NERD.pencil],
2560
+ ["n", "+", "New agent by name", NERD.plus],
2561
+ ["t", "◐", "Theme", NERD.palette],
2562
+ ["s", "⚙", "Settings", NERD.gear],
2563
+ ["m", "◆", "Model", NERD.chip],
2564
+ ["e", "↯", "Effort", NERD.bolt],
2565
+ ["E", "⚑", "Engine: pi, Claude Code, Codex, OpenCode", NERD.wrench],
2566
+ ["c", "✓", "Check engines", NERD.check],
2567
+ ["?", "⌘", "Keys", NERD.keyboard],
2568
+ ["q", "×", "Quit", NERD.power],
2487
2569
  ];
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";
@@ -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) {
@@ -259,7 +262,8 @@ async function main() {
259
262
  const themeName = flag(argv, "--theme") ?? config.theme ?? "default";
260
263
  let theme = BUILTIN_THEMES.default;
261
264
  try {
262
- theme = loadTheme(themeName);
265
+ // theme = "nvim" / "vim": your editor's colorscheme (kept up to date below)
266
+ theme = isFollow(themeName) ? followTheme(themeName) : loadTheme(themeName);
263
267
  }
264
268
  catch (err) {
265
269
  if (flag(argv, "--theme")) {
@@ -426,6 +430,13 @@ async function main() {
426
430
  tui.start();
427
431
  if (warnings.length > 0)
428
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
+ }
429
440
  tui.requestRender(true);
430
441
  return undefined;
431
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/keys.js CHANGED
@@ -129,6 +129,7 @@ export const DEFAULT_KEYS = {
129
129
  "<leader>s": ":settings<CR>",
130
130
  "<leader>M": ":model<CR>",
131
131
  "<leader>e": ":effort<CR>",
132
+ "<leader>E": ":engine<CR>",
132
133
  ZZ: "app.quit",
133
134
  ZQ: "app.quit_force",
134
135
  q: "app.quit_hint",
package/dist/picker.js CHANGED
@@ -8,9 +8,15 @@ 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;
15
+ /**
16
+ * `:colorscheme`, vim-style with live preview: `j`/`k` (↓/↑, Ctrl-n/p, Tab)
17
+ * move and preview, `g`/`G` jump, Enter keeps it, `q`/Esc puts the old one
18
+ * back. `/` starts a search: type to filter, Esc ends it.
19
+ */
14
20
  export class ThemePicker {
15
21
  painter;
16
22
  themes;
@@ -21,6 +27,8 @@ export class ThemePicker {
21
27
  requestRender;
22
28
  look;
23
29
  query = "";
30
+ /** typing goes to the search (after `/`) */
31
+ searching = false;
24
32
  index = 0;
25
33
  scroll = 0;
26
34
  constructor(painter, themes, current, icons, onPreview, onDone, requestRender, look = () => undefined) {
@@ -51,42 +59,63 @@ export class ThemePicker {
51
59
  }
52
60
  handleInput(data) {
53
61
  const key = parseKey(data) ?? "";
54
- switch (key) {
55
- case "escape":
56
- case "ctrl+c":
57
- this.onDone(null);
58
- return;
59
- case "enter":
60
- case "return": {
61
- const items = this.filtered();
62
- this.onDone(items[this.index] ?? null);
63
- return;
62
+ if (key === "ctrl+c")
63
+ return this.onDone(null);
64
+ if (key === "enter" || key === "return") {
65
+ this.onDone(this.filtered()[this.index] ?? null);
66
+ return;
67
+ }
68
+ if (["down", "tab", "ctrl+n", "ctrl+j"].includes(key))
69
+ this.move(1);
70
+ else if (["up", "shift+tab", "ctrl+p", "ctrl+k"].includes(key))
71
+ this.move(-1);
72
+ else if (this.searching) {
73
+ if (key === "escape")
74
+ this.searching = false; // the filter stays; j/k move through it
75
+ else if (key === "backspace") {
76
+ if (this.query) {
77
+ this.query = this.query.slice(0, -1);
78
+ this.resetSelection();
79
+ }
80
+ else
81
+ this.searching = false;
64
82
  }
65
- case "down":
66
- case "tab":
67
- case "ctrl+n":
68
- case "ctrl+j":
69
- this.move(1);
70
- break;
71
- case "up":
72
- case "shift+tab":
73
- case "ctrl+p":
74
- case "ctrl+k":
75
- this.move(-1);
76
- break;
77
- case "backspace":
78
- this.query = this.query.slice(0, -1);
79
- this.resetSelection();
80
- break;
81
- case "ctrl+u":
83
+ else if (key === "ctrl+u") {
82
84
  this.query = "";
83
85
  this.resetSelection();
84
- break;
85
- default:
86
- if (data.length >= 1 && !data.startsWith("\x1b") && data >= " ") {
87
- this.query += data;
88
- this.resetSelection();
89
- }
86
+ }
87
+ else if (data.length >= 1 && !data.startsWith("\x1b") && data >= " ") {
88
+ this.query += data;
89
+ this.resetSelection();
90
+ }
91
+ }
92
+ else {
93
+ switch (data) {
94
+ case "j":
95
+ this.move(1);
96
+ break;
97
+ case "k":
98
+ this.move(-1);
99
+ break;
100
+ case "g":
101
+ this.move(-this.index);
102
+ break;
103
+ case "G":
104
+ this.move(this.filtered().length - 1 - this.index);
105
+ break;
106
+ case "/":
107
+ this.searching = true;
108
+ break;
109
+ case "q":
110
+ return this.onDone(null);
111
+ default:
112
+ if (key === "escape") {
113
+ if (!this.query)
114
+ return this.onDone(null);
115
+ this.query = ""; // first Esc drops the filter, the next one closes
116
+ this.resetSelection();
117
+ }
118
+ }
90
119
  }
91
120
  this.requestRender();
92
121
  }
@@ -119,15 +148,22 @@ export class ThemePicker {
119
148
  border(I.v);
120
149
  const items = this.filtered();
121
150
  const title = " Colorschemes ";
122
- const count = ` ${items.length === 0 ? 0 : this.index + 1}/${items.length} `;
151
+ const keys = this.searching ? "type to filter · Esc done" : "j/k move · Enter pick · q close";
152
+ const count = ` ${items.length === 0 ? 0 : this.index + 1}/${items.length} · ${keys} `;
123
153
  const top = border(I.tl + I.h) +
124
154
  P.on("FloatTitle", bg) +
125
155
  title +
126
156
  RESET +
127
157
  border(I.h.repeat(Math.max(0, inner - 1 - visibleWidth(title))) + I.tr);
128
158
  const lines = [top];
129
- const prompt = `${I.prompt} ${this.query}`;
130
- lines.push(row(P.on("Title", bg) + ` ${I.prompt} ` + RESET + P.on("NormalFloat", bg) + this.query + P.on("PmenuSel", undefined) + " " + RESET, visibleWidth(prompt) + 2));
159
+ if (this.searching) {
160
+ const prompt = `${I.prompt} ${this.query}`;
161
+ lines.push(row(P.on("Title", bg) + ` ${I.prompt} ` + RESET + P.on("NormalFloat", bg) + this.query + P.on("PmenuSel", undefined) + " " + RESET, visibleWidth(prompt) + 2));
162
+ }
163
+ else {
164
+ const hint = truncateToWidth(this.query ? `/${this.query}` : "/ to search", Math.max(0, inner - 4), "…");
165
+ lines.push(row(P.on("Comment", bg) + ` ${I.prompt} ${hint}` + RESET, visibleWidth(hint) + 3));
166
+ }
131
167
  lines.push(border(I.lt + I.h.repeat(inner) + I.rt));
132
168
  const shown = items.slice(this.scroll, this.scroll + VISIBLE);
133
169
  for (let i = 0; i < VISIBLE; i++) {
@@ -171,9 +207,10 @@ export class ThemePicker {
171
207
  }
172
208
  }
173
209
  /**
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).
210
+ * A list to pick from (`:model`, `:effort`), vim-style. `j`/`k` (↓/↑, Ctrl-n/p,
211
+ * Tab) move, `g`/`G` jump to the top/bottom, Enter picks, `q`/Esc closes.
212
+ * `/` starts a search: type to filter, Esc ends it. With `custom`, Enter on a
213
+ * search that matches nothing picks the text itself (a model the list doesn't know).
177
214
  */
178
215
  export class ChoicePicker {
179
216
  painter;
@@ -184,6 +221,8 @@ export class ChoicePicker {
184
221
  onDone;
185
222
  requestRender;
186
223
  query = "";
224
+ /** typing goes to the search (after `/`) */
225
+ searching = false;
187
226
  index = 0;
188
227
  scroll = 0;
189
228
  items = [];
@@ -215,45 +254,70 @@ export class ChoicePicker {
215
254
  handleInput(data) {
216
255
  const key = parseKey(data) ?? "";
217
256
  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);
257
+ const move = (by) => {
258
+ if (items.length)
259
+ this.index = (this.index + by + items.length) % items.length;
260
+ };
261
+ if (key === "ctrl+c")
262
+ return this.onDone(null);
263
+ if (key === "enter" || key === "return") {
264
+ const picked = items[this.index]?.value ?? (this.custom && this.query.trim() ? this.query.trim() : undefined);
265
+ if (picked !== undefined)
266
+ this.onDone(picked);
267
+ return;
268
+ }
269
+ if (["down", "tab", "ctrl+n", "ctrl+j"].includes(key))
270
+ move(1);
271
+ else if (["up", "shift+tab", "ctrl+p", "ctrl+k"].includes(key))
272
+ move(-1);
273
+ else if (this.searching) {
274
+ if (key === "escape")
275
+ this.searching = false; // the filter stays; j/k move through it
276
+ else if (key === "backspace") {
277
+ if (this.query)
278
+ this.query = this.query.slice(0, -1);
279
+ else
280
+ this.searching = false;
246
281
  this.index = 0;
247
- break;
248
- case "ctrl+u":
282
+ }
283
+ else if (key === "ctrl+u") {
249
284
  this.query = "";
250
285
  this.index = 0;
251
- break;
252
- default:
253
- if (data.length >= 1 && !data.startsWith("\x1b") && data >= " ") {
254
- this.query += data;
286
+ }
287
+ else if (data.length >= 1 && !data.startsWith("\x1b") && data >= " ") {
288
+ this.query += data;
289
+ this.index = 0;
290
+ }
291
+ }
292
+ else {
293
+ switch (data) {
294
+ case "j":
295
+ move(1);
296
+ break;
297
+ case "k":
298
+ move(-1);
299
+ break;
300
+ case "g":
255
301
  this.index = 0;
256
- }
302
+ break;
303
+ case "G":
304
+ this.index = Math.max(0, items.length - 1);
305
+ break;
306
+ case "/":
307
+ this.searching = true;
308
+ break;
309
+ case "q":
310
+ return this.onDone(null);
311
+ default:
312
+ if (key === "escape") {
313
+ if (this.query) {
314
+ this.query = ""; // first Esc drops the filter, the next one closes
315
+ this.index = 0;
316
+ }
317
+ else
318
+ return this.onDone(null);
319
+ }
320
+ }
257
321
  }
258
322
  if (this.index < this.scroll)
259
323
  this.scroll = this.index;
@@ -272,7 +336,14 @@ export class ChoicePicker {
272
336
  const items = this.filtered();
273
337
  const title = ` ${this.title} `;
274
338
  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));
339
+ if (this.searching) {
340
+ 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));
341
+ }
342
+ else {
343
+ const hint = this.query ? `/${this.query}` : `/ to search${this.custom ? " or type any name" : ""}`;
344
+ const text = truncateToWidth(hint, Math.max(0, inner - 4), "…");
345
+ lines.push(row(P.on("Comment", bg) + ` ${I.prompt} ${text}` + RESET, visibleWidth(text) + 3));
346
+ }
276
347
  lines.push(border(I.lt + I.h.repeat(inner) + I.rt));
277
348
  const shown = items.slice(this.scroll, this.scroll + VISIBLE);
278
349
  for (let i = 0; i < VISIBLE; i++) {
@@ -294,7 +365,8 @@ export class ChoicePicker {
294
365
  const note = c.note ? truncateToWidth(` ${c.note}`, Math.max(0, inner - 2 - visibleWidth(name)), "…") : "";
295
366
  lines.push(row(P.on(group, rowBg) + ` ${name}` + P.on("Comment", rowBg) + note + RESET, 1 + visibleWidth(name) + visibleWidth(note), rowBg));
296
367
  }
297
- const count = ` ${items.length ? this.index + 1 : 0}/${items.length}${this.custom ? " · type any name" : ""} `;
368
+ const keys = this.searching ? "type to filter · Esc done" : "j/k move · Enter pick · q close";
369
+ const count = ` ${items.length ? this.index + 1 : 0}/${items.length} · ${keys} `;
298
370
  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
371
  return lines;
300
372
  }
package/dist/session.js CHANGED
@@ -272,13 +272,16 @@ export class PiSession extends Session {
272
272
  }
273
273
  session.subscribe((event) => this.onEvent(event));
274
274
  }
275
+ /** the run ended on a failed request (shown as an error, not as silence) */
276
+ failed = false;
275
277
  onEvent(event) {
276
278
  switch (event.type) {
277
279
  case "agent_start":
280
+ this.failed = false;
278
281
  this.setState("running");
279
282
  break;
280
283
  case "agent_end":
281
- this.setState("idle");
284
+ this.setState(this.failed ? "error" : "idle");
282
285
  this.refreshStat(0);
283
286
  break;
284
287
  case "message_start": {
@@ -309,6 +312,12 @@ export class PiSession extends Session {
309
312
  this.items.pop(); // a turn that only called tools
310
313
  this.emit();
311
314
  }
315
+ // a failed request ends the reply with an error instead of throwing: say so
316
+ const err = piError(event.message);
317
+ if (err) {
318
+ this.failed = true;
319
+ this.pushInfo(err);
320
+ }
312
321
  break;
313
322
  }
314
323
  case "tool_execution_start": {
@@ -419,6 +428,9 @@ export function itemsFromMessages(messages) {
419
428
  }
420
429
  if (m.usage)
421
430
  tokens += (m.usage.input ?? 0) + (m.usage.output ?? 0);
431
+ const err = piError(m);
432
+ if (err)
433
+ items.push({ kind: "info", text: err });
422
434
  }
423
435
  else if (m?.role === "toolResult") {
424
436
  const item = byCallId.get(m.toolCallId);
@@ -428,6 +440,18 @@ export function itemsFromMessages(messages) {
428
440
  }
429
441
  return { items, tokens };
430
442
  }
443
+ /**
444
+ * The error of a reply whose request failed (pi ends it with stopReason
445
+ * "error" and an errorMessage), with a hint when the key or login was refused.
446
+ */
447
+ export function piError(message) {
448
+ if (message?.role !== "assistant" || message.stopReason !== "error")
449
+ return undefined;
450
+ const text = String(message.errorMessage ?? "").trim() || "the request failed";
451
+ const auth = /\b(401|403)\b|auth|api[ _-]?key|unauthori[sz]ed|forbidden|credential|expired|login/i.test(text);
452
+ const hint = auth ? " · pi's API key or login was refused; :checkhealth shows the engines, and :new <task> --engine claude starts one on Claude Code" : "";
453
+ return `error: ${text}${hint}`;
454
+ }
431
455
  function extractText(content) {
432
456
  if (typeof content === "string")
433
457
  return content;
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.1",
3
+ "version": "0.2.3",
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",