@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 +4 -1
- package/dist/app.js +118 -4
- package/dist/cli-engines.js +78 -0
- package/dist/cli.js +25 -0
- package/dist/config.js +12 -0
- package/dist/keys.js +3 -1
- package/dist/markdown.js +155 -46
- package/package.json +1 -1
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>
|
|
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
|
-
|
|
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;
|
package/dist/cli-engines.js
CHANGED
|
@@ -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": "
|
|
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
|
-
*
|
|
3
|
-
*
|
|
4
|
-
* bullet lists,
|
|
5
|
-
* VISUAL-mode `ib` and
|
|
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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
-
|
|
17
|
-
if (
|
|
18
|
-
out +=
|
|
19
|
-
else
|
|
20
|
-
out += P.
|
|
21
|
-
|
|
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
|
|
33
|
-
const push = (styled) => {
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
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
|
-
|
|
156
|
+
const body = inline(P, heading[2], "Title");
|
|
157
|
+
push(heading[1].length <= 2 ? BOLD + body : body);
|
|
58
158
|
continue;
|
|
59
159
|
}
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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 =
|
|
165
|
+
const quote = /^\s{0,3}((?:>\s?)+)(.*)$/.exec(raw);
|
|
71
166
|
if (quote) {
|
|
72
|
-
|
|
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 (
|
|
186
|
+
if (fence !== undefined)
|
|
78
187
|
block++;
|
|
79
188
|
return { lines, blocks: block - startBlock };
|
|
80
189
|
}
|