@vigent/cli 0.3.1 → 0.3.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
@@ -4,7 +4,7 @@ A vim-style, lightweight terminal app for running several coding agents at once,
4
4
 
5
5
  **Website:** [vigent.farming-labs.dev](https://vigent.farming-labs.dev) · **Docs:** [vigent.farming-labs.dev/docs](https://vigent.farming-labs.dev/docs)
6
6
 
7
- ![vigent running a Claude Code agent in its own worktree, with the sessions and engines sidebars](docs/screenshots/showcase.png)
7
+ ![vigent running a Claude Code agent in its own worktree, with the sessions and engines sidebars](https://vigent.farming-labs.dev/showcase.png)
8
8
 
9
9
  ## Install
10
10
 
@@ -85,6 +85,7 @@ Bump `version` in package.json, merge, then push a matching tag (`git tag v0.2.0
85
85
  | NORMAL | `i` / `a` / `o` | Enter INSERT and type a prompt for the focused agent (it waits in a queue if the agent is busy). With no agent yet, the prompt starts one |
86
86
  | NORMAL | `j` `k` `gg` `G` `Ctrl-d` `Ctrl-u` `{` `}` | Scroll the transcript, or jump between messages |
87
87
  | NORMAL | `]s` `[s` `1`–`9` `Ctrl-^` | Switch agents |
88
+ | NORMAL | `<Space>e` | Move the cursor into the SESSIONS sidebar, like neo-tree: `j`/`k` (or `gg`/`G`, `1`–`9`) pick an agent and show it, Enter / Esc / `l` / `<Space>e` go back to the transcript |
88
89
  | NORMAL | `za` `zR` `zM` | Toggle, open or close tool-output folds |
89
90
  | NORMAL | `/pattern` `?pattern` `n` `N` | Search the transcript, forward or backward |
90
91
  | NORMAL | `V` / `v` | VISUAL: select lines with `j` `k`, `ib` for a code block, `y` to copy |
@@ -97,7 +98,7 @@ Copies go to the system clipboard through the terminal (OSC 52), so they work ov
97
98
 
98
99
  ## Engines: pi, Claude Code, Codex, OpenCode
99
100
 
100
- pi runs inside vigent. Claude Code, Codex and OpenCode are the CLIs you may already use: vigent runs them as installed, in the agent's worktree, so each one uses its own login and settings. vigent never reads their credentials; it only runs their version and sign-in status commands to show what's connected.
101
+ pi runs inside vigent. Claude Code, Codex and OpenCode are the CLIs you may already use: vigent runs them as installed, in the agent's worktree, so each one uses its own login and settings. vigent never reads their credentials; it only runs their version and sign-in status commands to show what's connected. If `ANTHROPIC_API_KEY` is set and Claude Code is also signed in with a Claude account, vigent runs Claude Code without that variable, so it uses your account (a key that isn't scoped to a workspace, or is out of credit, would otherwise fail every request). `:checkhealth` says so: *signed in (Claude account; ANTHROPIC_API_KEY left out)*.
101
102
 
102
103
  ```vim
103
104
  :new fix the race --engine codex " this agent runs Codex
@@ -113,10 +114,12 @@ The sidebar's ENGINES block shows each one: `●` signed in, `◐` installed but
113
114
  | `codex` | `codex exec --json` (`exec resume` to continue) | `-c sandbox_mode="workspace-write"` |
114
115
  | `opencode` | `opencode run --format json --dir <worktree>` | none |
115
116
 
116
- **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.
117
+ **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>R`) 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.
117
118
 
118
119
  Change the arguments or the model per engine in `[engines.<id>]` ([docs/config.md](https://github.com/farming-labs/vigent/blob/main/docs/config.md#engines)).
119
120
 
121
+ **Permissions:** with `-p`, Claude Code can't stop to ask before running a command it isn't allowed, so it refuses it (for example `gh` or `cargo` under `acceptEdits`). vigent shows what was refused. You can allow commands up front with `[engines.claude] allow = ["Bash(gh:*)"]`. Or opt in with `ask = true`: vigent then asks **allow once / always allow / no** and runs the agent again with the permission ([details](https://github.com/farming-labs/vigent/blob/main/docs/config.md#when-claude-code-is-refused-a-tool)).
122
+
120
123
  ### pi extensions, skills and prompt templates
121
124
 
122
125
  A pi agent loads what pi itself would load. That covers extensions from `~/.pi/agent/extensions/` and `.pi/extensions/`, and the packages you installed with pi. It also covers skills, prompt templates, and the project's `AGENTS.md`.
package/dist/app.js CHANGED
@@ -19,6 +19,7 @@ import { dropPatch, parseDiff, restorePatch } from "./hunks.js";
19
19
  import { HunkReview } from "./hunk-review.js";
20
20
  import { listFiles, MENTION, mentionable } from "./attach.js";
21
21
  import { allTemplates, fillTemplate, needsTask } from "./templates.js";
22
+ import { describeDenial } from "./cli-engines.js";
22
23
  import { startServer } from "./serve.js";
23
24
  import { didYouMean } from "./suggest.js";
24
25
  import { alertSequence, NOTIFY_MODES } from "./notify.js";
@@ -64,6 +65,7 @@ const ICONS = {
64
65
  here: "●",
65
66
  unread: "◆",
66
67
  serve: "⇄ ",
68
+ light: "✦",
67
69
  ok: "●",
68
70
  warn: "◐",
69
71
  off: "○",
@@ -87,6 +89,7 @@ const ICONS = {
87
89
  here: NERD.circle,
88
90
  unread: NERD.bell,
89
91
  serve: "\uf10b ",
92
+ light: "\uf0e7",
90
93
  ok: NERD.check,
91
94
  warn: NERD.warning,
92
95
  off: NERD.circleO,
@@ -110,6 +113,7 @@ const ICONS = {
110
113
  here: "*",
111
114
  unread: "!",
112
115
  serve: "~ ",
116
+ light: "*",
113
117
  ok: "+",
114
118
  warn: "~",
115
119
  off: "-",
@@ -125,6 +129,10 @@ export class App {
125
129
  mode = "NORMAL";
126
130
  sessions = [];
127
131
  focusIdx = 0;
132
+ /** <leader>e: the cursor is in the SESSIONS sidebar (j/k pick an agent, Enter / Esc / l go back) */
133
+ sidebarFocused = false;
134
+ /** a key typed in the sidebar that needs a second one (gg, <leader>e) */
135
+ sidebarPending = "";
128
136
  altIdx = 0;
129
137
  cmdBuf = "";
130
138
  inputBuf = "";
@@ -214,6 +222,8 @@ export class App {
214
222
  addSession(s, focus = true) {
215
223
  if ("extensionStatus" in s)
216
224
  s.uiHost = this.piUiHost(s);
225
+ if (s.engine === "claude" && "asksToAllow" in s)
226
+ s.approver = (blocked, rules) => this.askToAllow(s, blocked, rules);
217
227
  queueMicrotask(() => this.server?.sync());
218
228
  this.sessions.push(s);
219
229
  this.lastState.set(s, s.state);
@@ -363,6 +373,10 @@ export class App {
363
373
  const key = keyFromInput(data);
364
374
  if (!key)
365
375
  return;
376
+ if (this.sidebarFocused && this.sidebarKey(key)) {
377
+ this.tui.requestRender();
378
+ return;
379
+ }
366
380
  if (this.mode === "NORMAL" && this.seq.pendingKeys.length === 0)
367
381
  this.message = "";
368
382
  for (const k of this.splitEscape(key))
@@ -550,6 +564,8 @@ export class App {
550
564
  return;
551
565
  case "session.alternate":
552
566
  return this.focusSession(this.altIdx);
567
+ case "sidebar.focus":
568
+ return this.toggleSidebarFocus();
553
569
  case "session.goto": {
554
570
  const n = Number(arg);
555
571
  if (!Number.isInteger(n) || n < 1)
@@ -1478,7 +1494,7 @@ export class App {
1478
1494
  this.refreshStorage();
1479
1495
  const by = s.engine !== "pi" ? ` · ${ENGINE_NAMES[s.engine]}` : "";
1480
1496
  if (wt?.path) {
1481
- const how = wt.riftri ? ` · riftri ${wt.riftri}` : wt.riftriSkipped ? " · full copy (riftri declined: :checkhealth)" : "";
1497
+ const how = wt.riftri ? " · lightweight copy" : wt.riftriSkipped ? " · full copy (:checkhealth says why)" : "";
1482
1498
  this.setMessage(`'${name}'${by} on ${wt.branch} in ${this.shortPath(wt.path)}${wt.base ? ` (from ${wt.base})` : ""}${how}`, wt.riftriSkipped ? "WarningMsg" : "ModeMsg");
1483
1499
  }
1484
1500
  else if (wt) {
@@ -1712,6 +1728,24 @@ export class App {
1712
1728
  setStatus: (key, text) => s.setExtensionStatus?.(key, text),
1713
1729
  };
1714
1730
  }
1731
+ /**
1732
+ * Claude Code refused an agent some tools ([engines.claude] ask = true):
1733
+ * allow them for one retry, always (saved to config.toml), or leave them blocked.
1734
+ */
1735
+ askToAllow(s, blocked, rules) {
1736
+ return new Promise((resolve) => {
1737
+ if (s !== this.focusedSession || !this.terminalFocused)
1738
+ this.alert(alertSequence(`vigent: '${s.name}' needs a permission`, this.ui.notify));
1739
+ const what = [...new Set(blocked.map(describeDenial))].join(" · ");
1740
+ const list = rules.join(", ");
1741
+ this.openChoices(`'${s.name}' was blocked · ${what}`, [
1742
+ { value: "allow once", note: `${list}, for this retry` },
1743
+ { value: "always allow", note: `${list}, saved to [engines.claude] allow` },
1744
+ { value: "no", note: "leave it blocked" },
1745
+ ], "", false, (v) => resolve(v === "allow once" ? "once" : v === "always allow" ? "always" : undefined), { onCancel: () => resolve(undefined), rows: 3 });
1746
+ this.tui.requestRender();
1747
+ });
1748
+ }
1715
1749
  /** `:pi`: what pi loaded for the focused agent. Enter on a command, skill or prompt starts a prompt with it. */
1716
1750
  openPiResources() {
1717
1751
  const s = this.focusedSession;
@@ -2549,7 +2583,7 @@ export class App {
2549
2583
  const s = this.focusedSession;
2550
2584
  const left = s ? ` ${this.icons.open_agent} ${s.name}` : ` ${this.icons.open_agent} [no agent]`;
2551
2585
  const model = s ? ` ${s.engine !== "pi" && s.model !== ENGINE_NAMES[s.engine] ? `${ENGINE_NAMES[s.engine]} · ` : ""}${s.model}${s.effort ? ` · ${s.effort}` : ""}` : "";
2552
- const where = s?.worktree?.path ? `${this.shortPath(s.worktree.path)}${s.worktree.riftri ? " · riftri" : ""}` : s ? "this checkout" : "";
2586
+ const where = s?.worktree?.path ? `${this.shortPath(s.worktree.path)}${s.worktree.riftri ? ` ${this.icons.light}` : ""}` : s ? "this checkout" : "";
2553
2587
  const ext = s && "extensionStatus" in s ? [...s.extensionStatus.values()].join(" · ") : "";
2554
2588
  const status = (s?.state === "paused" ? " paused" : s?.queue.length ? ` ${this.icons.queued}${s.queue.length} queued` : "") + (ext ? ` ${ext}` : "");
2555
2589
  // the path gives way first (from its start: the end names the worktree), always two spaces before it
@@ -2629,6 +2663,88 @@ export class App {
2629
2663
  * winbar header, number column (session index, used by 1-9),
2630
2664
  * tree guides, cursorline on the focused session, ~ filler rows.
2631
2665
  */
2666
+ /** <leader>e, like neo-tree in Neovim: move the cursor into the SESSIONS sidebar, or back to the transcript. */
2667
+ toggleSidebarFocus() {
2668
+ if (this.sidebarFocused)
2669
+ return this.leaveSidebar();
2670
+ if (this.sessions.length === 0) {
2671
+ this.setMessage("no agents yet (i or :new starts one)", "Comment");
2672
+ return;
2673
+ }
2674
+ if (this.sidebarWidth(this.terminal.columns) === 0) {
2675
+ // no room for the sidebar (or it's turned off): the same choice as a list
2676
+ this.openDashboard();
2677
+ return;
2678
+ }
2679
+ this.mode = "NORMAL";
2680
+ this.sidebarFocused = true;
2681
+ this.sidebarPending = "";
2682
+ this.sidebarHint();
2683
+ }
2684
+ leaveSidebar() {
2685
+ this.sidebarFocused = false;
2686
+ this.sidebarPending = "";
2687
+ this.message = "";
2688
+ }
2689
+ sidebarHint() {
2690
+ const s = this.focusedSession;
2691
+ this.setMessage(`-- SESSIONS -- ${s ? `${this.focusIdx + 1} ${s.name} · ` : ""}j/k move · Enter open · Esc back`, "ModeMsg");
2692
+ }
2693
+ /** A key while the sidebar has the cursor. Keys it doesn't use go back to the transcript and run there. */
2694
+ sidebarKey(key) {
2695
+ const n = this.sessions.length;
2696
+ const pending = this.sidebarPending;
2697
+ this.sidebarPending = "";
2698
+ const go = (i) => {
2699
+ if (n === 0)
2700
+ return;
2701
+ this.focusSession(Math.max(0, Math.min(n - 1, i)));
2702
+ this.sidebarHint();
2703
+ };
2704
+ if (pending === this.keymap.leader && key === "e") {
2705
+ this.leaveSidebar();
2706
+ return true;
2707
+ }
2708
+ switch (key) {
2709
+ case "j":
2710
+ case "<Down>":
2711
+ go(this.focusIdx + 1);
2712
+ return true;
2713
+ case "k":
2714
+ case "<Up>":
2715
+ go(this.focusIdx - 1);
2716
+ return true;
2717
+ case "G":
2718
+ go(n - 1);
2719
+ return true;
2720
+ case "g":
2721
+ if (pending === "g")
2722
+ go(0);
2723
+ else
2724
+ this.sidebarPending = "g";
2725
+ return true;
2726
+ case "<CR>":
2727
+ case "l":
2728
+ case "o":
2729
+ case "<Esc>":
2730
+ case "q":
2731
+ this.leaveSidebar();
2732
+ return true;
2733
+ }
2734
+ if (/^[1-9]$/.test(key)) {
2735
+ go(Number(key) - 1);
2736
+ return true;
2737
+ }
2738
+ if (key === this.keymap.leader) {
2739
+ this.sidebarPending = key;
2740
+ return true;
2741
+ }
2742
+ // anything else (:, i, /, ...) works as usual, back in the transcript
2743
+ this.leaveSidebar();
2744
+ if (pending === this.keymap.leader)
2745
+ this.seq.feed(pending);
2746
+ return false;
2747
+ }
2632
2748
  renderSidebar(w, h) {
2633
2749
  const P = this.painter;
2634
2750
  const base = P.attr("VigentSidebar");
@@ -2650,16 +2766,17 @@ export class App {
2650
2766
  // winbar
2651
2767
  const running = this.sessions.filter((x) => x.state === "running").length;
2652
2768
  const right = running > 0 ? `${running} running ` : `${this.sessions.length} `;
2653
- const title = " SESSIONS";
2769
+ const title = this.sidebarFocused ? `${I.folded} SESSIONS` : " SESSIONS";
2654
2770
  lines.push(row([
2655
- ["WinBar", title],
2771
+ [this.sidebarFocused ? "Title" : "WinBar", title],
2656
2772
  ["VigentSidebar", " ".repeat(Math.max(1, w - visibleWidth(title) - visibleWidth(right)))],
2657
2773
  ["Comment", right],
2658
2774
  ]));
2659
2775
  this.sessions.forEach((s, i) => {
2660
2776
  const last = i === this.sessions.length - 1;
2661
2777
  const focused = i === this.focusIdx;
2662
- const rowBg = focused ? clBg : bg;
2778
+ // with the cursor in the sidebar, its row reads as a selection
2779
+ const rowBg = focused ? (this.sidebarFocused ? (P.attr("PmenuSel").bg ?? P.attr("Visual").bg ?? clBg) : clBg) : bg;
2663
2780
  const glyphGroup = s.state === "running"
2664
2781
  ? "String"
2665
2782
  : s.state === "waiting"
@@ -2780,7 +2897,7 @@ export class App {
2780
2897
  case "agent": {
2781
2898
  push("", "");
2782
2899
  const text = item.text + (item.streaming ? ` ${I.cursor}` : "");
2783
- const md = renderMarkdown(text, paneW, P, I.bullet, nextBlock);
2900
+ const md = renderMarkdown(text, paneW, P, I.bullet, nextBlock, { ascii: this.ui.icons === "ascii" });
2784
2901
  md.lines.forEach((l, i) => push(P.s("Normal") + l.text + RESET, l.plain, { item: n, start: i === 0, block: l.block, label: l.label }));
2785
2902
  nextBlock += md.blocks;
2786
2903
  break;
@@ -29,6 +29,7 @@ export const claudeAdapter = {
29
29
  "--verbose",
30
30
  "--include-partial-messages",
31
31
  ...(o.extraArgs ?? this.defaultArgs),
32
+ ...(o.allowedTools?.length ? ["--allowedTools", ...o.allowedTools] : []),
32
33
  ...(o.model ? ["--model", o.model] : []),
33
34
  ...(o.effort ? ["--effort", o.effort] : []),
34
35
  ...(o.resumeId ? ["--resume", o.resumeId] : []),
@@ -82,6 +83,14 @@ export const claudeAdapter = {
82
83
  out.push({ type: "usage", tokens, ...(cost !== undefined ? { cost } : {}) });
83
84
  if (e.session_id)
84
85
  out.push({ type: "session", id: e.session_id });
86
+ if (Array.isArray(e.permission_denials) && e.permission_denials.length) {
87
+ out.push({
88
+ type: "denied",
89
+ tools: e.permission_denials
90
+ .filter((d) => d && typeof d.tool_name === "string")
91
+ .map((d) => ({ name: d.tool_name, input: d.tool_input })),
92
+ });
93
+ }
85
94
  const ok = !e.is_error && e.subtype === "success";
86
95
  out.push({ type: "done", ok, ...(ok ? {} : { error: String(e.result ?? e.errors?.join?.("; ") ?? e.subtype ?? "failed") }) });
87
96
  break;
@@ -270,6 +279,8 @@ export class CliSession extends Session {
270
279
  aborted = false;
271
280
  /** the model the user asked for; undefined = the CLI's own default */
272
281
  requestedModel;
282
+ /** asks you whether to allow tools the CLI refused (set by the app when [engines.claude] ask is on) */
283
+ approver;
273
284
  constructor(name, engine, model, opts) {
274
285
  super(name, model ?? ENGINE_NAMES[engine], opts.worktree);
275
286
  this.opts = opts;
@@ -304,6 +315,14 @@ export class CliSession extends Session {
304
315
  }
305
316
  send(text) {
306
317
  this.pushUser(text);
318
+ this.run(text);
319
+ }
320
+ /** whether a refused tool can be asked about (opt-in, and only Claude Code reports them) */
321
+ get asksToAllow() {
322
+ return !!this.opts.ask && this.engine === "claude";
323
+ }
324
+ /** One run of the CLI; `once` are permission rules for this run only. */
325
+ run(text, once = []) {
307
326
  this.setState("running");
308
327
  this.aborted = false;
309
328
  const attached = expandPrompt(text, this.adapter.id, this.cwd);
@@ -316,12 +335,13 @@ export class CliSession extends Session {
316
335
  effort: this.effort,
317
336
  resumeId: this.resumeId,
318
337
  extraArgs: this.opts.extraArgs,
338
+ allowedTools: [...new Set([...(this.opts.allow?.() ?? []), ...once])],
319
339
  });
320
340
  let child;
321
341
  try {
322
342
  child = spawn(this.opts.bin ?? this.adapter.bin, args, {
323
343
  cwd: this.cwd,
324
- env: this.opts.env ?? process.env,
344
+ env: typeof this.opts.env === "function" ? this.opts.env() : (this.opts.env ?? process.env),
325
345
  stdio: ["ignore", "pipe", "pipe"],
326
346
  });
327
347
  }
@@ -335,6 +355,7 @@ export class CliSession extends Session {
335
355
  let stderr = "";
336
356
  let result;
337
357
  let lastError;
358
+ let denied = [];
338
359
  const tools = new Map();
339
360
  const handle = (ev) => {
340
361
  switch (ev.type) {
@@ -396,6 +417,9 @@ export class CliSession extends Session {
396
417
  case "error":
397
418
  lastError = ev.message;
398
419
  break;
420
+ case "denied":
421
+ denied = ev.tools;
422
+ break;
399
423
  case "done":
400
424
  result = ev;
401
425
  break;
@@ -442,6 +466,8 @@ export class CliSession extends Session {
442
466
  return;
443
467
  }
444
468
  const ok = result ? result.ok : code === 0 && !lastError;
469
+ if (denied.length && this.blocked(denied))
470
+ return;
445
471
  if (ok) {
446
472
  this.setState("idle");
447
473
  return;
@@ -453,6 +479,35 @@ export class CliSession extends Session {
453
479
  this.fail(`${this.engineName}: ${why}`);
454
480
  });
455
481
  }
482
+ /**
483
+ * The CLI refused some tools. Say which; with [engines.claude] ask on, ask
484
+ * whether to allow them and run again. True when it's now asking you.
485
+ */
486
+ blocked(denied) {
487
+ const what = [...new Set(denied.map((d) => this.relative(describeDenial(d))))];
488
+ const rules = [...new Set(denied.map(allowRule))];
489
+ this.pushInfo(`blocked by ${this.engineName}'s permissions: ${what.join(" · ")}`);
490
+ if (!this.asksToAllow || !this.approver) {
491
+ if (this.engine === "claude")
492
+ this.pushInfo(`to allow it, in config.toml: [engines.claude] allow = ${JSON.stringify(rules)} (or ask = true, to be asked each time)`);
493
+ return false;
494
+ }
495
+ this.setState("waiting");
496
+ this.approver(denied, rules).then((choice) => {
497
+ if (this.state !== "waiting")
498
+ return; // aborted, or a new prompt was sent meanwhile
499
+ if (!choice) {
500
+ this.pushInfo("left blocked");
501
+ this.setState("idle");
502
+ return;
503
+ }
504
+ if (choice === "always")
505
+ this.opts.onAlwaysAllow?.(rules);
506
+ this.pushInfo(`${choice === "always" ? "always allowed" : "allowed once"}: ${rules.join(", ")} · retrying`);
507
+ this.run(`You now have permission to use ${rules.join(", ")}. Retry what was blocked, then carry on with the task.`, rules);
508
+ }, () => this.setState("idle"));
509
+ return true;
510
+ }
456
511
  /** paths inside the agent's folder, shown relative to it */
457
512
  relative(text) {
458
513
  let out = text;
@@ -507,6 +562,29 @@ function safeRealpath(p) {
507
562
  return undefined;
508
563
  }
509
564
  }
565
+ /** A refused tool call, short: the command for Bash, the tool name and its target otherwise. */
566
+ export function describeDenial(d) {
567
+ if (d.name === "Bash" && typeof d.input?.command === "string")
568
+ return d.input.command.replace(/\s+/g, " ").trim().slice(0, 80);
569
+ const s = summarize(d.input);
570
+ return s ? `${d.name} ${s}`.slice(0, 80) : d.name;
571
+ }
572
+ /**
573
+ * The Claude Code permission rule that allows a refused call: `Bash(gh:*)`
574
+ * for a command (its program, any arguments), the tool's name otherwise.
575
+ */
576
+ export function allowRule(d) {
577
+ if (d.name === "Bash" && typeof d.input?.command === "string") {
578
+ // skip leading VAR=value assignments; the program is the first word
579
+ const words = d.input.command.trim().split(/\s+/).filter((w) => w);
580
+ const prog = words.find((w) => !/^[A-Za-z_][A-Za-z0-9_]*=/.test(w));
581
+ const m = prog && /^[^\s;&|()<>`$'"]+/.exec(prog);
582
+ if (m)
583
+ return `Bash(${m[0]}:*)`;
584
+ return "Bash";
585
+ }
586
+ return d.name;
587
+ }
510
588
  function contentOf(message) {
511
589
  return Array.isArray(message?.content) ? message.content : [];
512
590
  }
package/dist/cli.js CHANGED
@@ -9,8 +9,9 @@ import { App } from "./app.js";
9
9
  import { FOCUS_REPORTING_OFF, FOCUS_REPORTING_ON } from "./notify.js";
10
10
  import { staleBuild, VERSION_TEXT } from "./version.js";
11
11
  import { CliSession } from "./cli-engines.js";
12
- import { defaultEngine, detectEngines, ENGINE_BINS, isEngineId, onPath, piConnected, quickEngines } from "./engines.js";
12
+ import { defaultEngine, detectEngines, ENGINE_BINS, isEngineId, onPath, piConnected, quickEngines, engineEnv } from "./engines.js";
13
13
  import { CONFIG_TEMPLATE, configPath, loadConfig } from "./config.js";
14
+ import { setTomlValue } from "./config-edit.js";
14
15
  import { createDemoSessions } from "./demo.js";
15
16
  import { buildKeymap, KEY_MODES } from "./keys.js";
16
17
  import { formatBytes, prepareViews, riftriBackends, riftriGc, riftriHealth, riftriStateDir } from "./riftri.js";
@@ -299,6 +300,22 @@ async function main() {
299
300
  detectEngines()
300
301
  .then((engines) => app.setEngines(engines))
301
302
  .catch(() => { });
303
+ /** "always allow": add the rules to [engines.<id>] allow, here and in config.toml. Returns why it couldn't save. */
304
+ const alwaysAllow = (engine, rules) => {
305
+ const e = (config.engines[engine] ??= {});
306
+ e.allow = [...new Set([...(e.allow ?? []), ...rules])];
307
+ const file = configPath();
308
+ try {
309
+ const text = fs.existsSync(file) ? fs.readFileSync(file, "utf8") : CONFIG_TEMPLATE;
310
+ const list = `[${e.allow.map((r) => JSON.stringify(r)).join(", ")}]`;
311
+ fs.mkdirSync(path.dirname(file), { recursive: true });
312
+ fs.writeFileSync(file, setTomlValue(text, `engines.${engine}`, "allow", list));
313
+ return undefined;
314
+ }
315
+ catch (err) {
316
+ return `allowed for now, but couldn't save to ${file}: ${err.message}`;
317
+ }
318
+ };
302
319
  /** A Claude Code / Codex / OpenCode agent; its conversation id is saved so it survives a restart. */
303
320
  const cliSession = (name, engine, requested, cwd, wt, resumeId, effort) => new CliSession(name, engine, requested, {
304
321
  cwd,
@@ -306,6 +323,16 @@ async function main() {
306
323
  resumeId,
307
324
  effort,
308
325
  extraArgs: config.engines[engine]?.args,
326
+ // e.g. Claude Code without ANTHROPIC_API_KEY when it has a Claude account (see detectClaude)
327
+ env: () => engineEnv(app.engines?.find((e) => e.id === engine)),
328
+ // Claude Code's permission rules: [engines.claude] allow, and ask (opt-in) to allow a refused tool and retry
329
+ allow: () => config.engines[engine]?.allow,
330
+ ask: config.engines[engine]?.ask,
331
+ onAlwaysAllow: (rules) => {
332
+ const why = alwaysAllow(engine, rules);
333
+ if (why)
334
+ app.notify(why);
335
+ },
309
336
  onResumeId: (id) => {
310
337
  if (!wt?.path)
311
338
  return;
package/dist/config.js CHANGED
@@ -99,6 +99,16 @@ export function loadConfig(file = configPath()) {
99
99
  e.model = v;
100
100
  else if (k === "effort" && typeof v === "string" && EFFORTS[id].includes(v))
101
101
  e.effort = v;
102
+ else if ((k === "allow" || k === "ask") && id !== "claude")
103
+ warnings.push(`config [engines.${id}]: '${k}' is for [engines.claude] (Claude Code's permission rules)`);
104
+ else if (k === "allow" && Array.isArray(v) && v.every((a) => typeof a === "string"))
105
+ e.allow = v;
106
+ else if (k === "ask" && typeof v === "boolean")
107
+ e.ask = v;
108
+ else if (k === "allow")
109
+ warnings.push(`config [engines.${id}]: 'allow' should be a list of permission rules, e.g. ["Bash(gh:*)"]`);
110
+ else if (k === "ask")
111
+ warnings.push(`config [engines.${id}]: 'ask' should be true or false`);
102
112
  else if (k === "effort")
103
113
  warnings.push(`config [engines.${id}]: 'effort' should be one of ${EFFORTS[id].join(", ")}`);
104
114
  else
@@ -347,6 +357,8 @@ engine = "auto"
347
357
  # [engines.claude]
348
358
  # args = ["--permission-mode", "acceptEdits"] # the default
349
359
  # model = "sonnet"
360
+ # allow = ["Bash(gh:*)", "Bash(cargo:*)"] # commands Claude Code may run without asking
361
+ # ask = true # when it's refused a command: allow once / always / no, then retry
350
362
  # [engines.codex]
351
363
  # args = ["-c", 'sandbox_mode="workspace-write"'] # the default
352
364
  # [engines.opencode]
package/dist/engines.js CHANGED
@@ -12,6 +12,15 @@ import * as os from "node:os";
12
12
  import * as path from "node:path";
13
13
  import { PI_INSTALL, piInstalled } from "./pi-sdk.js";
14
14
  export const ENGINE_IDS = ["pi", "claude", "codex", "opencode"];
15
+ /** The environment to run an engine's CLI with: everything but what detection said to leave out. */
16
+ export function engineEnv(engine, env = process.env) {
17
+ if (!engine?.dropEnv?.length)
18
+ return env;
19
+ const out = { ...env };
20
+ for (const k of engine.dropEnv)
21
+ delete out[k];
22
+ return out;
23
+ }
15
24
  export const ENGINE_NAMES = {
16
25
  pi: "pi",
17
26
  claude: "Claude Code",
@@ -127,8 +136,33 @@ async function detectClaude(env, timeoutMs) {
127
136
  try {
128
137
  const st = JSON.parse(s.stdout);
129
138
  if (st.loggedIn) {
130
- const how = st.authMethod === "api_key" || st.apiKeySource ? "API key" : "Claude account";
131
- return { ...base, installed: true, connected: true, version, detail: `signed in (${how})`, connect: undefined };
139
+ const usesKey = st.authMethod === "api_key" || !!st.apiKeySource;
140
+ // ANTHROPIC_API_KEY wins over a signed-in Claude account. When there is an account too,
141
+ // leave the key out, so Claude Code runs on the login you use it with (an unscoped or
142
+ // spent key otherwise fails every request, e.g. "not scoped to a workspace")
143
+ if (usesKey && env.ANTHROPIC_API_KEY) {
144
+ const without = { ...env };
145
+ delete without.ANTHROPIC_API_KEY;
146
+ const account = await run("claude", ["auth", "status", "--json"], timeoutMs, without);
147
+ try {
148
+ const a = JSON.parse(account.stdout);
149
+ if (a.loggedIn && a.authMethod !== "api_key" && !a.apiKeySource) {
150
+ return {
151
+ ...base,
152
+ installed: true,
153
+ connected: true,
154
+ version,
155
+ detail: "signed in (Claude account; ANTHROPIC_API_KEY left out)",
156
+ connect: undefined,
157
+ dropEnv: ["ANTHROPIC_API_KEY"],
158
+ };
159
+ }
160
+ }
161
+ catch {
162
+ // no account to fall back to: the key it is
163
+ }
164
+ }
165
+ return { ...base, installed: true, connected: true, version, detail: `signed in (${usesKey ? "API key" : "Claude account"})`, connect: undefined };
132
166
  }
133
167
  return { ...base, installed: true, connected: false, version, detail: "not signed in" };
134
168
  }
package/dist/keys.js CHANGED
@@ -59,6 +59,7 @@ export const ACTIONS = {
59
59
  "session.pause": "pause the agent and hold its queued prompts",
60
60
  "session.resume": "resume a paused agent",
61
61
  "session.tree": "the conversation tree: go back to an earlier point",
62
+ "sidebar.focus": "sessions sidebar: j/k pick an agent, Enter or Esc back",
62
63
  "fold.toggle": "fold / unfold tool output",
63
64
  "fold.open_all": "unfold all tool output",
64
65
  "fold.close_all": "fold all tool output",
@@ -130,7 +131,8 @@ export const DEFAULT_KEYS = {
130
131
  "<leader>r": ":review<CR>",
131
132
  "<leader>T": ":templates<CR>",
132
133
  "<leader>M": ":model<CR>",
133
- "<leader>e": ":effort<CR>",
134
+ "<leader>e": "sidebar.focus",
135
+ "<leader>R": ":effort<CR>",
134
136
  "<leader>E": ":engine<CR>",
135
137
  ZZ: "app.quit",
136
138
  ZQ: "app.quit_force",
package/dist/markdown.js CHANGED
@@ -1,80 +1,189 @@
1
1
  /**
2
- * Just enough markdown for agent replies, in vim's highlight groups:
3
- * headings (Title), **bold**, `code` and fenced code blocks (VigentCode),
4
- * bullet lists, and > quotes (Comment). Code blocks are numbered so
5
- * VISUAL-mode `ib` and `yib` can select them.
2
+ * Markdown for agent replies, in vim's highlight groups: headings (Title),
3
+ * **bold**, *italic*, ~~strike~~, `code` and fenced code blocks (VigentCode),
4
+ * links, bullet / numbered / task lists (nested), > quotes (Comment),
5
+ * --- rules and tables. Code blocks are numbered so VISUAL-mode `ib` and
6
+ * `yib` can select them.
6
7
  */
7
- import { wrapTextWithAnsi } from "./tui.js";
8
+ import { visibleWidth, wrapTextWithAnsi } from "./tui.js";
8
9
  import { RESET } from "./theme.js";
9
- function inline(P, text) {
10
- let out = "";
11
- const re = /(`[^`]+`)|(\*\*[^*]+\*\*)|(__[^_]+__)/g;
10
+ const BOLD = "\x1b[1m";
11
+ const ITALIC = "\x1b[3m";
12
+ const UNDERLINE = "\x1b[4m";
13
+ const STRIKE = "\x1b[9m";
14
+ /**
15
+ * Inline markdown in `base`'s color: `code`, **bold**, *italic*, ~~strike~~,
16
+ * [links](url) and <autolinks>. After each styled piece the base color comes
17
+ * back, so the rest of the line keeps it.
18
+ */
19
+ export function inline(P, text, base = "Normal", urls = true) {
20
+ const on = P.s(base);
21
+ let out = on;
22
+ const re = /(\\[\\`*_~[\]()#>+\-.!|])|(`+)([^`]|[^`].*?[^`])\2(?!`)|\*\*([^*\s](?:.*?[^*\s])?)\*\*|__([^_\s](?:.*?[^_\s])?)__|~~([^~\s](?:.*?[^~\s])?)~~|\*([^*\s](?:.*?[^*\s])?)\*|(?<![\w])_([^_\s](?:.*?[^_\s])?)_(?![\w])|\[([^\]]+)\]\(([^)\s]+)(?:\s+"[^"]*")?\)|<(https?:\/\/[^>\s]+)>/g;
12
23
  let last = 0;
13
24
  let m;
14
25
  while ((m = re.exec(text))) {
15
26
  out += text.slice(last, m.index);
16
- const tok = m[0];
17
- if (tok.startsWith("`"))
18
- out += P.p("VigentCode", tok.slice(1, -1));
19
- else
20
- out += P.s("Normal") + "\x1b[1m" + tok.slice(2, -2) + RESET;
21
- last = m.index + tok.length;
27
+ last = m.index + m[0].length;
28
+ if (m[1])
29
+ out += m[1].slice(1); // \* and friends: the character itself
30
+ else if (m[2])
31
+ out += P.p("VigentCode", m[3].replace(/^ (.*) $/, "$1")) + on;
32
+ else if (m[4] ?? m[5])
33
+ out += BOLD + inline(P, (m[4] ?? m[5]), base, urls) + RESET + on;
34
+ else if (m[6])
35
+ out += STRIKE + inline(P, m[6], base, urls) + RESET + on;
36
+ else if (m[7] ?? m[8])
37
+ out += ITALIC + inline(P, (m[7] ?? m[8]), base, urls) + RESET + on;
38
+ else if (m[9]) {
39
+ const label = m[9];
40
+ const url = m[10];
41
+ out += P.s("Special") + UNDERLINE + label + RESET + (urls && url !== label && !url.startsWith("#") ? P.p("Comment", ` (${url})`) : "") + on;
42
+ }
43
+ else if (m[11])
44
+ out += P.s("Special") + UNDERLINE + m[11] + RESET + on;
22
45
  }
23
- return out + text.slice(last);
46
+ return out + text.slice(last) + RESET;
24
47
  }
25
48
  function strip(s) {
26
49
  return s.replace(/\x1b\[[0-9;]*m/g, "");
27
50
  }
51
+ const SEPARATOR = /^\s*\|?\s*:?-+:?\s*(\|\s*:?-+:?\s*)+\|?\s*$|^\s*\|\s*:?-+:?\s*\|\s*$/;
52
+ /** The cells of a table row: `| a | b |` → ["a", "b"] (a `\|` stays in its cell). */
53
+ function cells(row) {
54
+ let s = row.trim();
55
+ if (s.startsWith("|"))
56
+ s = s.slice(1);
57
+ if (s.endsWith("|") && !s.endsWith("\\|"))
58
+ s = s.slice(0, -1);
59
+ return s.split(/(?<!\\)\|/).map((c) => c.trim().replace(/\\\|/g, "|"));
60
+ }
61
+ function isRow(line) {
62
+ return /\|/.test(line) && line.trim() !== "";
63
+ }
64
+ /**
65
+ * A table as box-drawn lines that fit `width`: columns as wide as their
66
+ * widest cell, shrunk (widest first) when the table is too wide, with cell
67
+ * text wrapping inside its column.
68
+ */
69
+ function renderTable(P, rows, aligns, width, ascii) {
70
+ const g = ascii
71
+ ? { h: "-", v: "|", tl: "+", tm: "+", tr: "+", ml: "+", mm: "+", mr: "+", bl: "+", bm: "+", br: "+" }
72
+ : { h: "─", v: "│", tl: "┌", tm: "┬", tr: "┐", ml: "├", mm: "┼", mr: "┤", bl: "└", bm: "┴", br: "┘" };
73
+ const n = Math.max(...rows.map((r) => r.length));
74
+ const styled = rows.map((r, ri) => Array.from({ length: n }, (_, c) => inline(P, r[c] ?? "", ri === 0 ? "Title" : "Normal", false)));
75
+ const want = Array.from({ length: n }, (_, c) => Math.max(1, ...styled.map((r) => visibleWidth(strip(r[c])))));
76
+ // borders and padding take 3 columns per cell, plus one
77
+ const room = Math.max(n * 3, width - (3 * n + 1));
78
+ const cols = [...want];
79
+ while (cols.reduce((a, b) => a + b, 0) > room) {
80
+ const widest = cols.indexOf(Math.max(...cols));
81
+ if (cols[widest] <= 3)
82
+ break;
83
+ cols[widest]--;
84
+ }
85
+ const border = (l, m, r) => {
86
+ const s = l + cols.map((w) => g.h.repeat(w + 2)).join(m) + r;
87
+ return { text: P.p("NonText", s), plain: s };
88
+ };
89
+ const out = [border(g.tl, g.tm, g.tr)];
90
+ styled.forEach((row, ri) => {
91
+ const wrapped = row.map((cell, c) => wrapTextWithAnsi(cell, cols[c]));
92
+ const height = Math.max(...wrapped.map((w) => w.length));
93
+ for (let li = 0; li < height; li++) {
94
+ let text = P.p("NonText", g.v);
95
+ for (let c = 0; c < n; c++) {
96
+ const piece = wrapped[c][li] ?? "";
97
+ const pad = Math.max(0, cols[c] - visibleWidth(strip(piece)));
98
+ const a = aligns[c] ?? "left";
99
+ const left = a === "right" ? pad : a === "center" ? Math.floor(pad / 2) : 0;
100
+ text += ` ${" ".repeat(left)}${piece}${RESET}${" ".repeat(pad - left)} ${P.p("NonText", g.v)}`;
101
+ }
102
+ out.push({ text, plain: strip(text) });
103
+ }
104
+ if (ri === 0)
105
+ out.push(border(g.ml, g.mm, g.mr));
106
+ });
107
+ out.push(border(g.bl, g.bm, g.br));
108
+ return out;
109
+ }
110
+ // --- blocks ---------------------------------------------------------------
28
111
  /** Render markdown into wrapped, styled lines. `bullet` is the list marker glyph. */
29
- export function renderMarkdown(text, width, P, bullet = "•", startBlock = 0) {
112
+ export function renderMarkdown(text, width, P, bullet = "•", startBlock = 0, opts = {}) {
30
113
  const lines = [];
114
+ const w = Math.max(10, width);
31
115
  let block = startBlock;
32
- let inFence = false;
33
- const push = (styled) => {
34
- for (const l of wrapTextWithAnsi(styled, Math.max(10, width)))
35
- lines.push({ text: l, plain: strip(l) });
116
+ let fence;
117
+ const push = (styled, prefix = "", rest = prefix) => {
118
+ const wrapped = wrapTextWithAnsi(styled, Math.max(4, w - visibleWidth(strip(prefix))));
119
+ wrapped.forEach((l, i) => {
120
+ const t = (i === 0 ? prefix : rest) + l;
121
+ lines.push({ text: t, plain: strip(t) });
122
+ });
36
123
  };
37
- for (const raw of text.split("\n")) {
38
- const fence = /^\s*(```|~~~)\s*([\w+-]*)\s*$/.exec(raw);
39
- if (fence) {
40
- if (!inFence) {
41
- inFence = true;
42
- lines.push({ text: P.p("NonText", ` ${fence[2] || "code"}`), plain: ` ${fence[2] || "code"}`, block, label: true });
43
- }
44
- else {
45
- inFence = false;
124
+ const src = text.split("\n");
125
+ for (let i = 0; i < src.length; i++) {
126
+ const raw = src[i];
127
+ const open = /^\s*(```+|~~~+)\s*([\w+#.-]*)/.exec(raw);
128
+ if (fence !== undefined) {
129
+ if (open && open[1][0] === fence[0] && open[1].length >= fence.length && !open[2]) {
130
+ fence = undefined;
46
131
  block++;
132
+ continue;
47
133
  }
48
- continue;
49
- }
50
- if (inFence) {
51
134
  // code is not wrapped on words; long lines are cut by the pane
52
135
  lines.push({ text: ` ${P.p("VigentCode", raw)}`, plain: ` ${raw}`, block });
53
136
  continue;
54
137
  }
55
- const heading = /^(#{1,6})\s+(.*)$/.exec(raw);
138
+ if (open) {
139
+ fence = open[1];
140
+ lines.push({ text: P.p("NonText", ` ${open[2] || "code"}`), plain: ` ${open[2] || "code"}`, block, label: true });
141
+ continue;
142
+ }
143
+ // a table: a row, then a | --- | separator
144
+ if (isRow(raw) && i + 1 < src.length && SEPARATOR.test(src[i + 1]) && cells(raw).length > 0) {
145
+ const aligns = cells(src[i + 1]).map((c) => (c.startsWith(":") && c.endsWith(":") ? "center" : c.endsWith(":") ? "right" : "left"));
146
+ const rows = [cells(raw)];
147
+ let j = i + 2;
148
+ while (j < src.length && isRow(src[j]) && !SEPARATOR.test(src[j]))
149
+ rows.push(cells(src[j++]));
150
+ lines.push(...renderTable(P, rows, aligns, w, !!opts.ascii));
151
+ i = j - 1;
152
+ continue;
153
+ }
154
+ const heading = /^\s{0,3}(#{1,6})\s+(.*?)\s*#*\s*$/.exec(raw);
56
155
  if (heading) {
57
- push(P.p("Title", heading[2]));
156
+ const body = inline(P, heading[2], "Title");
157
+ push(heading[1].length <= 2 ? BOLD + body : body);
58
158
  continue;
59
159
  }
60
- const item = /^(\s*)[-*+]\s+(.*)$/.exec(raw);
61
- if (item) {
62
- const indent = item[1].length > 0 ? " " : "";
63
- const body = wrapTextWithAnsi(inline(P, item[2]), Math.max(10, width - 2 - indent.length));
64
- body.forEach((l, i) => {
65
- const prefix = i === 0 ? `${indent}${P.p("Special", bullet)} ` : `${indent} `;
66
- lines.push({ text: prefix + l, plain: strip(prefix + l) });
67
- });
160
+ if (/^\s{0,3}([-*_])(\s*\1){2,}\s*$/.test(raw)) {
161
+ const rule = (opts.ascii ? "-" : "─").repeat(Math.min(w, 60));
162
+ lines.push({ text: P.p("NonText", rule), plain: rule });
68
163
  continue;
69
164
  }
70
- const quote = /^>\s?(.*)$/.exec(raw);
165
+ const quote = /^\s{0,3}((?:>\s?)+)(.*)$/.exec(raw);
71
166
  if (quote) {
72
- push(P.p("Comment", `▏${quote[1]}`));
167
+ const depth = (quote[1].match(/>/g) ?? []).length;
168
+ const bar = P.p("Comment", "▏ ".repeat(depth));
169
+ push(inline(P, quote[2], "Comment"), bar);
170
+ continue;
171
+ }
172
+ const item = /^(\s*)([-*+]|\d{1,9}[.)])\s+(\[[ xX]\]\s+)?(.*)$/.exec(raw);
173
+ if (item) {
174
+ const level = Math.min(6, Math.floor(item[1].replace(/\t/g, " ").length / 2));
175
+ const indent = " ".repeat(level);
176
+ const ordered = /\d/.test(item[2]);
177
+ let marker = ordered ? item[2] : bullet;
178
+ if (item[3])
179
+ marker = /x/i.test(item[3]) ? (opts.ascii ? "[x]" : "☑") : opts.ascii ? "[ ]" : "☐";
180
+ const lead = `${indent}${P.p(item[3] ? (/x/i.test(item[3]) ? "String" : "Comment") : "Special", marker)} `;
181
+ push(inline(P, item[4]), lead, " ".repeat(visibleWidth(strip(lead))));
73
182
  continue;
74
183
  }
75
- push(inline(P, raw));
184
+ push(raw.trim() === "" ? "" : inline(P, raw));
76
185
  }
77
- if (inFence)
186
+ if (fence !== undefined)
78
187
  block++;
79
188
  return { lines, blocks: block - startBlock };
80
189
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vigent/cli",
3
- "version": "0.3.1",
3
+ "version": "0.3.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",