@vigent/cli 0.2.2 → 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
 
@@ -206,7 +206,7 @@ vigent --theme nvim # follow your Neovim: the colorscheme your c
206
206
 
207
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
208
 
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):
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):
210
210
 
211
211
  ```toml
212
212
  theme = "tokyonight"
package/dist/app.js CHANGED
@@ -17,7 +17,7 @@ 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";
@@ -756,6 +756,13 @@ export class App {
756
756
  else
757
757
  this.openModelPicker();
758
758
  return;
759
+ case "engine":
760
+ case "eng":
761
+ if (arg)
762
+ this.chooseEngine(arg);
763
+ else
764
+ this.openEnginePicker();
765
+ return;
759
766
  case "effort":
760
767
  case "think":
761
768
  if (arg)
@@ -996,6 +1003,37 @@ export class App {
996
1003
  this.tui.requestRender();
997
1004
  });
998
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
+ }
999
1037
  /** `:model`: pick from what the engine offers, or type any name. */
1000
1038
  openModelPicker(t = this.modelTarget()) {
1001
1039
  const current = t.session
@@ -1089,7 +1127,7 @@ export class App {
1089
1127
  change: (d) => {
1090
1128
  this.preferredEngine = cycle(["auto", "pi", "claude", "codex", "opencode"], this.preferredEngine, d);
1091
1129
  },
1092
- 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",
1093
1131
  },
1094
1132
  {
1095
1133
  section: "Agents",
@@ -1974,6 +2012,9 @@ export class App {
1974
2012
  case "e":
1975
2013
  this.openEffortPicker();
1976
2014
  return true;
2015
+ case "E":
2016
+ this.openEnginePicker();
2017
+ return true;
1977
2018
  case "c":
1978
2019
  this.openHealth();
1979
2020
  return true;
@@ -2442,6 +2483,7 @@ const EX_HELP = [
2442
2483
  [":checkhealth", "which engines are installed and signed in"],
2443
2484
  [":model [name]", "pick the agent's model (no agent: for new agents)"],
2444
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"],
2445
2487
  [":settings", "theme, look, engine, worktrees and keys in one place (w saves)"],
2446
2488
  [":w [file]", "save the transcript as Markdown"],
2447
2489
  [":worktrees", "list agent worktrees"],
@@ -2520,6 +2562,7 @@ const DASHBOARD = [
2520
2562
  ["s", "⚙", "Settings", NERD.gear],
2521
2563
  ["m", "◆", "Model", NERD.chip],
2522
2564
  ["e", "↯", "Effort", NERD.bolt],
2565
+ ["E", "⚑", "Engine: pi, Claude Code, Codex, OpenCode", NERD.wrench],
2523
2566
  ["c", "✓", "Check engines", NERD.check],
2524
2567
  ["?", "⌘", "Keys", NERD.keyboard],
2525
2568
  ["q", "×", "Quit", NERD.power],
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
@@ -12,6 +12,11 @@ export const PICKER_ICONS = {
12
12
  ascii: { tl: "+", tr: "+", bl: "+", br: "+", h: "-", v: "|", lt: "+", rt: "+", current: "*", prompt: ">" },
13
13
  };
14
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
+ */
15
20
  export class ThemePicker {
16
21
  painter;
17
22
  themes;
@@ -22,6 +27,8 @@ export class ThemePicker {
22
27
  requestRender;
23
28
  look;
24
29
  query = "";
30
+ /** typing goes to the search (after `/`) */
31
+ searching = false;
25
32
  index = 0;
26
33
  scroll = 0;
27
34
  constructor(painter, themes, current, icons, onPreview, onDone, requestRender, look = () => undefined) {
@@ -52,42 +59,63 @@ export class ThemePicker {
52
59
  }
53
60
  handleInput(data) {
54
61
  const key = parseKey(data) ?? "";
55
- switch (key) {
56
- case "escape":
57
- case "ctrl+c":
58
- this.onDone(null);
59
- return;
60
- case "enter":
61
- case "return": {
62
- const items = this.filtered();
63
- this.onDone(items[this.index] ?? null);
64
- 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;
65
82
  }
66
- case "down":
67
- case "tab":
68
- case "ctrl+n":
69
- case "ctrl+j":
70
- this.move(1);
71
- break;
72
- case "up":
73
- case "shift+tab":
74
- case "ctrl+p":
75
- case "ctrl+k":
76
- this.move(-1);
77
- break;
78
- case "backspace":
79
- this.query = this.query.slice(0, -1);
80
- this.resetSelection();
81
- break;
82
- case "ctrl+u":
83
+ else if (key === "ctrl+u") {
83
84
  this.query = "";
84
85
  this.resetSelection();
85
- break;
86
- default:
87
- if (data.length >= 1 && !data.startsWith("\x1b") && data >= " ") {
88
- this.query += data;
89
- this.resetSelection();
90
- }
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
+ }
91
119
  }
92
120
  this.requestRender();
93
121
  }
@@ -120,15 +148,22 @@ export class ThemePicker {
120
148
  border(I.v);
121
149
  const items = this.filtered();
122
150
  const title = " Colorschemes ";
123
- 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} `;
124
153
  const top = border(I.tl + I.h) +
125
154
  P.on("FloatTitle", bg) +
126
155
  title +
127
156
  RESET +
128
157
  border(I.h.repeat(Math.max(0, inner - 1 - visibleWidth(title))) + I.tr);
129
158
  const lines = [top];
130
- const prompt = `${I.prompt} ${this.query}`;
131
- 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
+ }
132
167
  lines.push(border(I.lt + I.h.repeat(inner) + I.rt));
133
168
  const shown = items.slice(this.scroll, this.scroll + VISIBLE);
134
169
  for (let i = 0; i < VISIBLE; i++) {
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vigent/cli",
3
- "version": "0.2.2",
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",