@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 +6 -3
- package/dist/app.js +123 -6
- package/dist/cli-engines.js +79 -1
- package/dist/cli.js +28 -1
- package/dist/config.js +12 -0
- package/dist/engines.js +36 -2
- package/dist/keys.js +3 -1
- package/dist/markdown.js +155 -46
- package/package.json +1 -1
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
|
-

|
|
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>
|
|
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 ?
|
|
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 ?
|
|
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
|
-
|
|
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;
|
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,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
|
|
131
|
-
|
|
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": "
|
|
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
|
}
|