@vigent/cli 0.3.2 → 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
@@ -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 |
@@ -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";
@@ -128,6 +129,10 @@ export class App {
128
129
  mode = "NORMAL";
129
130
  sessions = [];
130
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 = "";
131
136
  altIdx = 0;
132
137
  cmdBuf = "";
133
138
  inputBuf = "";
@@ -217,6 +222,8 @@ export class App {
217
222
  addSession(s, focus = true) {
218
223
  if ("extensionStatus" in s)
219
224
  s.uiHost = this.piUiHost(s);
225
+ if (s.engine === "claude" && "asksToAllow" in s)
226
+ s.approver = (blocked, rules) => this.askToAllow(s, blocked, rules);
220
227
  queueMicrotask(() => this.server?.sync());
221
228
  this.sessions.push(s);
222
229
  this.lastState.set(s, s.state);
@@ -366,6 +373,10 @@ export class App {
366
373
  const key = keyFromInput(data);
367
374
  if (!key)
368
375
  return;
376
+ if (this.sidebarFocused && this.sidebarKey(key)) {
377
+ this.tui.requestRender();
378
+ return;
379
+ }
369
380
  if (this.mode === "NORMAL" && this.seq.pendingKeys.length === 0)
370
381
  this.message = "";
371
382
  for (const k of this.splitEscape(key))
@@ -553,6 +564,8 @@ export class App {
553
564
  return;
554
565
  case "session.alternate":
555
566
  return this.focusSession(this.altIdx);
567
+ case "sidebar.focus":
568
+ return this.toggleSidebarFocus();
556
569
  case "session.goto": {
557
570
  const n = Number(arg);
558
571
  if (!Number.isInteger(n) || n < 1)
@@ -1715,6 +1728,24 @@ export class App {
1715
1728
  setStatus: (key, text) => s.setExtensionStatus?.(key, text),
1716
1729
  };
1717
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
+ }
1718
1749
  /** `:pi`: what pi loaded for the focused agent. Enter on a command, skill or prompt starts a prompt with it. */
1719
1750
  openPiResources() {
1720
1751
  const s = this.focusedSession;
@@ -2632,6 +2663,88 @@ export class App {
2632
2663
  * winbar header, number column (session index, used by 1-9),
2633
2664
  * tree guides, cursorline on the focused session, ~ filler rows.
2634
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
+ }
2635
2748
  renderSidebar(w, h) {
2636
2749
  const P = this.painter;
2637
2750
  const base = P.attr("VigentSidebar");
@@ -2653,16 +2766,17 @@ export class App {
2653
2766
  // winbar
2654
2767
  const running = this.sessions.filter((x) => x.state === "running").length;
2655
2768
  const right = running > 0 ? `${running} running ` : `${this.sessions.length} `;
2656
- const title = " SESSIONS";
2769
+ const title = this.sidebarFocused ? `${I.folded} SESSIONS` : " SESSIONS";
2657
2770
  lines.push(row([
2658
- ["WinBar", title],
2771
+ [this.sidebarFocused ? "Title" : "WinBar", title],
2659
2772
  ["VigentSidebar", " ".repeat(Math.max(1, w - visibleWidth(title) - visibleWidth(right)))],
2660
2773
  ["Comment", right],
2661
2774
  ]));
2662
2775
  this.sessions.forEach((s, i) => {
2663
2776
  const last = i === this.sessions.length - 1;
2664
2777
  const focused = i === this.focusIdx;
2665
- 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;
2666
2780
  const glyphGroup = s.state === "running"
2667
2781
  ? "String"
2668
2782
  : s.state === "waiting"
@@ -2783,7 +2897,7 @@ export class App {
2783
2897
  case "agent": {
2784
2898
  push("", "");
2785
2899
  const text = item.text + (item.streaming ? ` ${I.cursor}` : "");
2786
- const md = renderMarkdown(text, paneW, P, I.bullet, nextBlock);
2900
+ const md = renderMarkdown(text, paneW, P, I.bullet, nextBlock, { ascii: this.ui.icons === "ascii" });
2787
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 }));
2788
2902
  nextBlock += md.blocks;
2789
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,6 +335,7 @@ 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 {
@@ -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
@@ -11,6 +11,7 @@ import { staleBuild, VERSION_TEXT } from "./version.js";
11
11
  import { CliSession } from "./cli-engines.js";
12
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,
@@ -308,6 +325,14 @@ async function main() {
308
325
  extraArgs: config.engines[engine]?.args,
309
326
  // e.g. Claude Code without ANTHROPIC_API_KEY when it has a Claude account (see detectClaude)
310
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
+ },
311
336
  onResumeId: (id) => {
312
337
  if (!wt?.path)
313
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/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.2",
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",