@vigent/cli 0.0.0-stage → 0.2.0
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/LICENSE +21 -0
- package/README.md +261 -2
- package/dist/agents.js +65 -0
- package/dist/app.js +2487 -0
- package/dist/builtin-themes.js +352 -0
- package/dist/choices.js +73 -0
- package/dist/cli-engines.js +537 -0
- package/dist/cli.js +429 -0
- package/dist/clipboard.js +56 -0
- package/dist/color.js +126 -0
- package/dist/config-edit.js +61 -0
- package/dist/config.js +406 -0
- package/dist/demo.js +81 -0
- package/dist/diffview.js +94 -0
- package/dist/engines.js +195 -0
- package/dist/export.js +29 -0
- package/dist/float.js +182 -0
- package/dist/import.js +260 -0
- package/dist/keys.js +589 -0
- package/dist/markdown.js +80 -0
- package/dist/palette.js +83 -0
- package/dist/picker.js +301 -0
- package/dist/review.js +158 -0
- package/dist/riftri.js +284 -0
- package/dist/session.js +460 -0
- package/dist/settings.js +174 -0
- package/dist/suggest.js +31 -0
- package/dist/theme-new.js +49 -0
- package/dist/theme.js +487 -0
- package/dist/tree.js +115 -0
- package/dist/version.js +45 -0
- package/dist/vimcolors.js +206 -0
- package/dist/worktree.js +357 -0
- package/package.json +64 -4
package/dist/cli.js
ADDED
|
@@ -0,0 +1,429 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/** vigent — vim-native agent orchestrator on the pi engine. */
|
|
3
|
+
import { execFileSync } from "node:child_process";
|
|
4
|
+
import * as fs from "node:fs";
|
|
5
|
+
import * as path from "node:path";
|
|
6
|
+
import { ProcessTerminal, TUI } from "@mariozechner/pi-tui";
|
|
7
|
+
import { loadAgentMeta, restorableAgents, saveAgentMeta, sessionDir } from "./agents.js";
|
|
8
|
+
import { App } from "./app.js";
|
|
9
|
+
import { staleBuild, VERSION_TEXT } from "./version.js";
|
|
10
|
+
import { CliSession } from "./cli-engines.js";
|
|
11
|
+
import { defaultEngine, detectEngines, ENGINE_BINS, isEngineId, onPath, piConnected, quickEngines } from "./engines.js";
|
|
12
|
+
import { CONFIG_TEMPLATE, configPath, loadConfig } from "./config.js";
|
|
13
|
+
import { createDemoSessions } from "./demo.js";
|
|
14
|
+
import { buildKeymap, KEY_MODES } from "./keys.js";
|
|
15
|
+
import { formatBytes, prepareViews, riftriBackends, riftriGc, riftriHealth, riftriStateDir } from "./riftri.js";
|
|
16
|
+
import { DemoSession, PiSession } from "./session.js";
|
|
17
|
+
import { BUILTIN_THEMES, listThemes, loadTheme, Painter, themesDir } from "./theme.js";
|
|
18
|
+
import { themeNewTemplate } from "./theme-new.js";
|
|
19
|
+
import { branchName, createWorktree, findWorktree, listWorktrees, removeWorktree, repoRoot, worktreesDir, } from "./worktree.js";
|
|
20
|
+
const HELP = `vigent — vim-native agent orchestrator (pi engine)
|
|
21
|
+
|
|
22
|
+
usage:
|
|
23
|
+
vigent --version which vigent this is (and the commit, from a clone)
|
|
24
|
+
vigent [--demo] [--theme <name>] [--model <id>] [--engine pi|claude|codex|opencode]
|
|
25
|
+
|
|
26
|
+
engines (what runs new agents; :checkhealth shows which are signed in):
|
|
27
|
+
pi built in, uses ANTHROPIC_API_KEY / OPENAI_API_KEY / ... or pi's login
|
|
28
|
+
claude your installed Claude Code CLI, with its own login
|
|
29
|
+
codex your installed Codex CLI, with its own login
|
|
30
|
+
opencode your installed OpenCode CLI, with its own login
|
|
31
|
+
|
|
32
|
+
themes:
|
|
33
|
+
vigent theme list built-in and installed themes
|
|
34
|
+
vigent theme import import the colorscheme your nvim/vim uses now
|
|
35
|
+
vigent theme import <name> [--as <name>] import any colorscheme nvim/vim can load
|
|
36
|
+
vigent theme import ./theme.vim import a colorscheme file (no editor needed)
|
|
37
|
+
--vim | --nvim use this editor --light | --dark set 'background' first
|
|
38
|
+
--clean skip your editor config
|
|
39
|
+
vigent theme new <name> [--from <theme>] create a theme file to customize, pre-filled with that theme's colors
|
|
40
|
+
--palette build it from a palette only --transparent use the terminal's background
|
|
41
|
+
vigent theme path show where themes and config live
|
|
42
|
+
vigent worktree list agent worktrees in this repo
|
|
43
|
+
vigent worktree remove <branch> [--force] delete one (safe unless --force)
|
|
44
|
+
vigent worktree prune forget worktrees whose folder is gone
|
|
45
|
+
vigent worktree gc free riftri storage no worktree uses
|
|
46
|
+
vigent config init write a commented config.toml
|
|
47
|
+
vigent config check validate config.toml (theme, options, keys)
|
|
48
|
+
|
|
49
|
+
keys (NORMAL mode):
|
|
50
|
+
i prompt the focused agent :new <task> agent in its own worktree
|
|
51
|
+
j/k gg G scroll transcript :kill[!] stop it (! removes worktree)
|
|
52
|
+
ctrl+d/u half-page scroll :colorscheme pick a theme (live preview)
|
|
53
|
+
]s [s 1-9 switch sessions :hi Group ... change a highlight group
|
|
54
|
+
za zM zR fold/unfold tool output :set nonumber toggle ui options
|
|
55
|
+
/pattern ? search transcript :diff what the agent changed
|
|
56
|
+
V y yy yib select and copy, last reply :merge :pr land the agent's work
|
|
57
|
+
{ } previous / next message :dash all agents at a glance
|
|
58
|
+
dd kill agent (asks first) :queue <text> follow-up after this run
|
|
59
|
+
:worktrees list agent worktrees :attach <branch> reopen a kept agent
|
|
60
|
+
:diff split side-by-side diff :tree go back to any point and branch
|
|
61
|
+
<Space>u the conversation tree :nmap <lhs> <rhs> map keys (see :help)
|
|
62
|
+
:help every key, including yours :q quit
|
|
63
|
+
:checkhealth engines: installed? signed in? :new <task> --engine codex
|
|
64
|
+
|
|
65
|
+
Runs in demo mode (scripted sessions, no network) when --demo is passed,
|
|
66
|
+
or when pi has no API key and none of claude / codex / opencode is installed.
|
|
67
|
+
Config: ~/.config/vigent/config.toml Themes: ~/.config/vigent/themes/
|
|
68
|
+
`;
|
|
69
|
+
function flag(argv, name) {
|
|
70
|
+
const i = argv.indexOf(name);
|
|
71
|
+
return i >= 0 ? argv[i + 1] : undefined;
|
|
72
|
+
}
|
|
73
|
+
async function themeCommand(argv) {
|
|
74
|
+
const sub = argv[0];
|
|
75
|
+
if (sub === "list") {
|
|
76
|
+
const builtins = new Set(Object.keys(BUILTIN_THEMES));
|
|
77
|
+
for (const t of listThemes())
|
|
78
|
+
console.log(builtins.has(t) ? `${t} (built-in)` : t);
|
|
79
|
+
return 0;
|
|
80
|
+
}
|
|
81
|
+
if (sub === "path") {
|
|
82
|
+
console.log(`themes: ${themesDir()}`);
|
|
83
|
+
console.log(`config: ${configPath()}`);
|
|
84
|
+
return 0;
|
|
85
|
+
}
|
|
86
|
+
if (sub === "new") {
|
|
87
|
+
const name = argv[1];
|
|
88
|
+
if (!name || name.startsWith("-")) {
|
|
89
|
+
console.error("usage: vigent theme new <name> [--from <theme>] [--palette] [--transparent | --no-transparent]");
|
|
90
|
+
return 1;
|
|
91
|
+
}
|
|
92
|
+
const from = flag(argv, "--from") ?? "default";
|
|
93
|
+
loadTheme(from); // fail early on a typo
|
|
94
|
+
const file = path.join(themesDir(), `${name}.toml`);
|
|
95
|
+
if (fs.existsSync(file)) {
|
|
96
|
+
console.error(`${file} already exists`);
|
|
97
|
+
return 1;
|
|
98
|
+
}
|
|
99
|
+
fs.mkdirSync(themesDir(), { recursive: true });
|
|
100
|
+
fs.writeFileSync(file, themeNewTemplate(name, from, {
|
|
101
|
+
palette: argv.includes("--palette"),
|
|
102
|
+
transparent: argv.includes("--transparent") ? true : argv.includes("--no-transparent") ? false : undefined,
|
|
103
|
+
}));
|
|
104
|
+
console.log(`created ${file}`);
|
|
105
|
+
console.log(`use it with: vigent --theme ${name} (or :colorscheme ${name})`);
|
|
106
|
+
return 0;
|
|
107
|
+
}
|
|
108
|
+
if (sub === "import") {
|
|
109
|
+
const { importTheme } = await import("./import.js");
|
|
110
|
+
const rest = argv.slice(1);
|
|
111
|
+
const positional = rest.filter((a, i) => !a.startsWith("--") && rest[i - 1] !== "--as" && a !== "as");
|
|
112
|
+
const legacyAs = rest.indexOf("as");
|
|
113
|
+
const result = importTheme({
|
|
114
|
+
source: positional[0],
|
|
115
|
+
as: flag(rest, "--as") ?? (legacyAs >= 0 ? rest[legacyAs + 1] : undefined),
|
|
116
|
+
editor: rest.includes("--vim") ? "vim" : rest.includes("--nvim") ? "nvim" : undefined,
|
|
117
|
+
background: rest.includes("--light") ? "light" : rest.includes("--dark") ? "dark" : undefined,
|
|
118
|
+
clean: rest.includes("--clean"),
|
|
119
|
+
});
|
|
120
|
+
console.log(`imported '${result.name}' (${result.groups} highlight groups, via ${result.via})`);
|
|
121
|
+
console.log(` -> ${result.file}`);
|
|
122
|
+
console.log(`use it with: vigent --theme ${result.name} (or :colorscheme ${result.name})`);
|
|
123
|
+
return 0;
|
|
124
|
+
}
|
|
125
|
+
console.error("usage: vigent theme list | import [name|file] | new <name> | path");
|
|
126
|
+
return 1;
|
|
127
|
+
}
|
|
128
|
+
function worktreeCommand(argv) {
|
|
129
|
+
const root = repoRoot(process.cwd());
|
|
130
|
+
if (!root) {
|
|
131
|
+
console.error("vigent: not inside a git repository");
|
|
132
|
+
return 1;
|
|
133
|
+
}
|
|
134
|
+
const { config } = loadConfig();
|
|
135
|
+
const sub = argv[0];
|
|
136
|
+
if (sub === "list" || sub === undefined) {
|
|
137
|
+
const all = listWorktrees(root, config.worktree);
|
|
138
|
+
if (all.length === 0)
|
|
139
|
+
console.log(`no agent worktrees (they go in ${worktreesDir(root, config.worktree)})`);
|
|
140
|
+
const backends = riftriBackends(root, all.map((w) => w.path));
|
|
141
|
+
for (const w of all) {
|
|
142
|
+
const b = backends.get(w.path);
|
|
143
|
+
console.log(`${(w.branch ?? `(detached ${w.head})`).padEnd(36)} ${(b ? `riftri ${b}` : "git").padEnd(16)} ${w.path}`);
|
|
144
|
+
}
|
|
145
|
+
return 0;
|
|
146
|
+
}
|
|
147
|
+
if (sub === "remove" || sub === "rm") {
|
|
148
|
+
const query = argv[1];
|
|
149
|
+
if (!query) {
|
|
150
|
+
console.error("usage: vigent worktree remove <branch> [--force]");
|
|
151
|
+
return 1;
|
|
152
|
+
}
|
|
153
|
+
const w = findWorktree(root, query, config.worktree);
|
|
154
|
+
if (!w) {
|
|
155
|
+
console.error(`vigent: no agent worktree '${query}' (see: vigent worktree list)`);
|
|
156
|
+
return 1;
|
|
157
|
+
}
|
|
158
|
+
const r = removeWorktree(root, w, argv.includes("--force"));
|
|
159
|
+
console.log(r.message);
|
|
160
|
+
return r.removed ? 0 : 1;
|
|
161
|
+
}
|
|
162
|
+
if (sub === "prune") {
|
|
163
|
+
execFileSync("git", ["worktree", "prune", "-v"], { cwd: root, stdio: "inherit" });
|
|
164
|
+
return 0;
|
|
165
|
+
}
|
|
166
|
+
if (sub === "gc") {
|
|
167
|
+
if (!riftriStateDir(root)) {
|
|
168
|
+
console.log("no riftri storage in this repository");
|
|
169
|
+
return 0;
|
|
170
|
+
}
|
|
171
|
+
const r = riftriGc(root);
|
|
172
|
+
if (!r.ok) {
|
|
173
|
+
console.error(`vigent: riftri gc failed: ${r.message}`);
|
|
174
|
+
return 1;
|
|
175
|
+
}
|
|
176
|
+
console.log(`freed ${formatBytes(r.report?.removed_allocated_bytes ?? 0)}`);
|
|
177
|
+
return 0;
|
|
178
|
+
}
|
|
179
|
+
console.error("usage: vigent worktree list | remove <branch> [--force] | prune | gc");
|
|
180
|
+
return 1;
|
|
181
|
+
}
|
|
182
|
+
async function main() {
|
|
183
|
+
const argv = process.argv.slice(2);
|
|
184
|
+
if (argv.includes("--version") || argv.includes("-v") || argv[0] === "version") {
|
|
185
|
+
console.log(`vigent ${VERSION_TEXT}`);
|
|
186
|
+
const stale = staleBuild();
|
|
187
|
+
if (stale)
|
|
188
|
+
console.log(`warning: ${stale}`);
|
|
189
|
+
return 0;
|
|
190
|
+
}
|
|
191
|
+
if (argv.includes("--help") || argv.includes("-h")) {
|
|
192
|
+
console.log(HELP);
|
|
193
|
+
return 0;
|
|
194
|
+
}
|
|
195
|
+
if (argv[0] === "theme" || argv[0] === "themes")
|
|
196
|
+
return themeCommand(argv.slice(1));
|
|
197
|
+
if (argv[0] === "worktree" || argv[0] === "worktrees" || argv[0] === "wt")
|
|
198
|
+
return worktreeCommand(argv.slice(1));
|
|
199
|
+
if (argv[0] === "config" && argv[1] === "check") {
|
|
200
|
+
const file = configPath();
|
|
201
|
+
if (!fs.existsSync(file)) {
|
|
202
|
+
console.log(`no config at ${file} (defaults in use). Create one with: vigent config init`);
|
|
203
|
+
return 0;
|
|
204
|
+
}
|
|
205
|
+
const { config, warnings } = loadConfig(file);
|
|
206
|
+
const keys = buildKeymap(config.leader, config.keys);
|
|
207
|
+
warnings.push(...keys.warnings);
|
|
208
|
+
try {
|
|
209
|
+
if (config.theme)
|
|
210
|
+
loadTheme(config.theme);
|
|
211
|
+
}
|
|
212
|
+
catch (err) {
|
|
213
|
+
warnings.push(err.message);
|
|
214
|
+
}
|
|
215
|
+
const mappings = KEY_MODES.reduce((n, m) => n + keys.keymap.bindings(m).filter((b) => b.user).length, 0);
|
|
216
|
+
for (const w of warnings)
|
|
217
|
+
console.log(`warning: ${w}`);
|
|
218
|
+
console.log(`${file}: ${warnings.length === 0 ? "ok" : `${warnings.length} problem(s)`} · theme ${config.theme ?? "default"} · leader ${config.leader ?? "\\"} · ${mappings} mapping(s) · ${Object.keys(config.highlights).length} highlight override(s)`);
|
|
219
|
+
return warnings.length === 0 ? 0 : 1;
|
|
220
|
+
}
|
|
221
|
+
if (argv[0] === "config" && argv[1] === "init") {
|
|
222
|
+
const file = configPath();
|
|
223
|
+
if (fs.existsSync(file)) {
|
|
224
|
+
console.error(`${file} already exists`);
|
|
225
|
+
return 1;
|
|
226
|
+
}
|
|
227
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
228
|
+
fs.writeFileSync(file, CONFIG_TEMPLATE);
|
|
229
|
+
console.log(`created ${file}`);
|
|
230
|
+
return 0;
|
|
231
|
+
}
|
|
232
|
+
const { config, warnings } = loadConfig();
|
|
233
|
+
const stale = staleBuild();
|
|
234
|
+
if (stale)
|
|
235
|
+
warnings.push(stale);
|
|
236
|
+
const { keymap, warnings: keyWarnings } = buildKeymap(config.leader, config.keys);
|
|
237
|
+
warnings.push(...keyWarnings);
|
|
238
|
+
const engineArg = flag(argv, "--engine");
|
|
239
|
+
if (engineArg && !isEngineId(engineArg)) {
|
|
240
|
+
console.error(`vigent: unknown engine '${engineArg}' (pi, claude, codex or opencode)`);
|
|
241
|
+
return 1;
|
|
242
|
+
}
|
|
243
|
+
const chosenEngine = (engineArg ?? (config.engine === "auto" ? undefined : config.engine));
|
|
244
|
+
const anyCli = ["claude", "codex", "opencode"].some((id) => onPath(ENGINE_BINS[id]));
|
|
245
|
+
const demo = argv.includes("--demo") || (!chosenEngine && !piConnected() && !anyCli);
|
|
246
|
+
const modelArg = flag(argv, "--model");
|
|
247
|
+
const model = modelArg ?? config.model ?? "claude-sonnet-4-5";
|
|
248
|
+
const themeName = flag(argv, "--theme") ?? config.theme ?? "default";
|
|
249
|
+
let theme = BUILTIN_THEMES.default;
|
|
250
|
+
try {
|
|
251
|
+
theme = loadTheme(themeName);
|
|
252
|
+
}
|
|
253
|
+
catch (err) {
|
|
254
|
+
if (flag(argv, "--theme")) {
|
|
255
|
+
console.error(`vigent: ${err.message}`);
|
|
256
|
+
return 1;
|
|
257
|
+
}
|
|
258
|
+
warnings.push(`${err.message} (from config); using default`);
|
|
259
|
+
}
|
|
260
|
+
const terminal = new ProcessTerminal();
|
|
261
|
+
const tui = new TUI(terminal, false);
|
|
262
|
+
const root = repoRoot(process.cwd());
|
|
263
|
+
const app = new App(tui, terminal, new Painter(theme, config.highlights), config.ui, keymap, {
|
|
264
|
+
root,
|
|
265
|
+
config: config.worktree,
|
|
266
|
+
});
|
|
267
|
+
// can agent worktrees be copy-on-write here? (riftri; runs in the background)
|
|
268
|
+
app.checkStorage = () => riftriHealth(root, root ? worktreesDir(root, config.worktree) : undefined, config.worktree.backend);
|
|
269
|
+
if (!demo && root && config.worktree.enabled) {
|
|
270
|
+
app
|
|
271
|
+
.checkStorage()
|
|
272
|
+
.then((h) => {
|
|
273
|
+
app.storage = h;
|
|
274
|
+
})
|
|
275
|
+
.catch(() => { });
|
|
276
|
+
}
|
|
277
|
+
app.preferredEngine = chosenEngine ?? "auto";
|
|
278
|
+
app.defaultEngine = () => (app.preferredEngine === "auto" ? defaultEngine(app.engines) : app.preferredEngine);
|
|
279
|
+
app.detectEngines = () => detectEngines();
|
|
280
|
+
// installed? signed in? What's on PATH shows at once; the CLIs' sign-in checks take a moment
|
|
281
|
+
app.setEngines(quickEngines());
|
|
282
|
+
detectEngines()
|
|
283
|
+
.then((engines) => app.setEngines(engines))
|
|
284
|
+
.catch(() => { });
|
|
285
|
+
/** A Claude Code / Codex / OpenCode agent; its conversation id is saved so it survives a restart. */
|
|
286
|
+
const cliSession = (name, engine, requested, cwd, wt, resumeId, effort) => new CliSession(name, engine, requested, {
|
|
287
|
+
cwd,
|
|
288
|
+
worktree: wt,
|
|
289
|
+
resumeId,
|
|
290
|
+
effort,
|
|
291
|
+
extraArgs: config.engines[engine]?.args,
|
|
292
|
+
onResumeId: (id) => {
|
|
293
|
+
if (!wt?.path)
|
|
294
|
+
return;
|
|
295
|
+
const meta = loadAgentMeta(wt.path);
|
|
296
|
+
if (meta)
|
|
297
|
+
saveAgentMeta(wt.path, { ...meta, resumeId: id });
|
|
298
|
+
},
|
|
299
|
+
});
|
|
300
|
+
// model and effort for new agents, per engine, from [engines.<id>]
|
|
301
|
+
for (const [id, opts] of Object.entries(config.engines)) {
|
|
302
|
+
if (opts?.model)
|
|
303
|
+
app.newAgentModel[id] = opts.model;
|
|
304
|
+
if (opts?.effort)
|
|
305
|
+
app.newAgentEffort[id] = opts.effort;
|
|
306
|
+
}
|
|
307
|
+
// an agent's model and effort, changed with :model / :effort, survive a restart
|
|
308
|
+
app.persist = (s) => {
|
|
309
|
+
const wt = s.worktree?.path;
|
|
310
|
+
if (!wt)
|
|
311
|
+
return;
|
|
312
|
+
const meta = loadAgentMeta(wt);
|
|
313
|
+
if (!meta)
|
|
314
|
+
return;
|
|
315
|
+
const requested = s instanceof CliSession ? s.chosenModel : undefined;
|
|
316
|
+
saveAgentMeta(wt, {
|
|
317
|
+
...meta,
|
|
318
|
+
model: s instanceof CliSession ? (requested ?? "") : s.model,
|
|
319
|
+
...(s instanceof CliSession ? { requestedModel: requested } : {}),
|
|
320
|
+
effort: s.effort,
|
|
321
|
+
});
|
|
322
|
+
};
|
|
323
|
+
app.newSession = (name, { worktree, model: perAgent, engine = app.defaultEngine(), effort }) => {
|
|
324
|
+
if (demo) {
|
|
325
|
+
// demo mode never touches your repo: the worktree is only shown, not created
|
|
326
|
+
const shown = worktree ? { branch: branchName(name, config.worktree.branch) } : undefined;
|
|
327
|
+
return new DemoSession(name, perAgent ?? model, shown);
|
|
328
|
+
}
|
|
329
|
+
// a CLI uses its own default model unless one is asked for
|
|
330
|
+
const requested = engine === "pi" ? undefined : (perAgent ?? config.engines[engine]?.model ?? (modelArg && engineArg === engine ? modelArg : undefined));
|
|
331
|
+
const m = engine === "pi" ? (perAgent ?? model) : (requested ?? "");
|
|
332
|
+
const wt = worktree && root ? createWorktree(root, name, config.worktree) : undefined;
|
|
333
|
+
if (wt?.path) {
|
|
334
|
+
saveAgentMeta(wt.path, {
|
|
335
|
+
name,
|
|
336
|
+
model: m,
|
|
337
|
+
base: wt.base,
|
|
338
|
+
createdAt: new Date().toISOString(),
|
|
339
|
+
...(engine === "pi" ? {} : { engine, ...(requested ? { requestedModel: requested } : {}) }),
|
|
340
|
+
...(effort ? { effort } : {}),
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
const cwd = wt?.path ?? process.cwd();
|
|
344
|
+
if (engine !== "pi")
|
|
345
|
+
return cliSession(name, engine, requested, cwd, wt?.path ? wt : undefined, undefined, effort);
|
|
346
|
+
if (!wt?.path)
|
|
347
|
+
return new PiSession(name, m, { cwd, effort });
|
|
348
|
+
return new PiSession(name, m, { cwd, worktree: wt, sessionDir: sessionDir(wt.path), effort });
|
|
349
|
+
};
|
|
350
|
+
/** Reopen a saved agent with the engine it was started with. */
|
|
351
|
+
const reopen = (meta, worktree) => {
|
|
352
|
+
const engine = meta.engine && isEngineId(meta.engine) ? meta.engine : "pi";
|
|
353
|
+
if (engine !== "pi")
|
|
354
|
+
return cliSession(meta.name, engine, meta.requestedModel, worktree.path, worktree, meta.resumeId, meta.effort);
|
|
355
|
+
return new PiSession(meta.name, meta.model, { cwd: worktree.path, worktree, sessionDir: sessionDir(worktree.path), effort: meta.effort });
|
|
356
|
+
};
|
|
357
|
+
app.attachSession = (wt) => {
|
|
358
|
+
if (demo || !wt.path)
|
|
359
|
+
throw new Error("attaching needs a real agent (not --demo)");
|
|
360
|
+
const meta = loadAgentMeta(wt.path) ?? {
|
|
361
|
+
name: wt.branch.split("/").pop()?.replace(/-/g, " ") ?? wt.branch,
|
|
362
|
+
model,
|
|
363
|
+
createdAt: new Date().toISOString(),
|
|
364
|
+
};
|
|
365
|
+
const view = root ? prepareViews(root, [wt.path]).get(wt.path) : undefined;
|
|
366
|
+
if (view && !view.ready)
|
|
367
|
+
throw new Error(`can't reopen ${wt.branch}: ${view.reason}`);
|
|
368
|
+
saveAgentMeta(wt.path, { ...meta, detached: undefined }); // reopened at the next start again
|
|
369
|
+
return reopen(meta, { ...wt, base: wt.base ?? meta.base, riftri: view?.backend });
|
|
370
|
+
};
|
|
371
|
+
app.onQuit = () => {
|
|
372
|
+
app.stopTimers();
|
|
373
|
+
for (const s of app.sessions) {
|
|
374
|
+
s.abort();
|
|
375
|
+
s.dispose();
|
|
376
|
+
}
|
|
377
|
+
tui.stop();
|
|
378
|
+
process.exit(0);
|
|
379
|
+
};
|
|
380
|
+
if (demo) {
|
|
381
|
+
app.demo = true;
|
|
382
|
+
for (const s of createDemoSessions())
|
|
383
|
+
app.addSession(s, false);
|
|
384
|
+
app.focusIdx = 0;
|
|
385
|
+
if (!argv.includes("--demo")) {
|
|
386
|
+
warnings.unshift("demo mode: these agents are scripted. For real ones, install and sign in to Claude Code, Codex or OpenCode, or set ANTHROPIC_API_KEY, then restart (:checkhealth)");
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
else if (root && config.worktree.enabled) {
|
|
390
|
+
// reopen the agents that were running when vigent last quit
|
|
391
|
+
const agents = restorableAgents(root, config.worktree);
|
|
392
|
+
// riftri views on OverlayFS lose their mount on reboot: repair them before an agent runs there
|
|
393
|
+
const views = prepareViews(root, agents.map((a) => a.worktree.path));
|
|
394
|
+
for (const { meta, worktree } of agents) {
|
|
395
|
+
const view = views.get(worktree.path);
|
|
396
|
+
if (view && !view.ready) {
|
|
397
|
+
warnings.push(`couldn't reopen '${meta.name}': ${view.reason}`);
|
|
398
|
+
continue;
|
|
399
|
+
}
|
|
400
|
+
try {
|
|
401
|
+
app.addSession(reopen(meta, { ...worktree, riftri: view?.backend }), false);
|
|
402
|
+
}
|
|
403
|
+
catch (err) {
|
|
404
|
+
warnings.push(`couldn't reopen '${meta.name}': ${err.message}`);
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
if (app.sessions.length > 0) {
|
|
408
|
+
app.focusIdx = 0;
|
|
409
|
+
app.notify(`reopened ${app.sessions.length} agent${app.sessions.length === 1 ? "" : "s"} from last time`, "Comment");
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
tui.addChild(app);
|
|
413
|
+
tui.setFocus(app);
|
|
414
|
+
terminal.setTitle("vigent");
|
|
415
|
+
tui.start();
|
|
416
|
+
if (warnings.length > 0)
|
|
417
|
+
app.notify(warnings.join(" · "));
|
|
418
|
+
tui.requestRender(true);
|
|
419
|
+
return undefined;
|
|
420
|
+
}
|
|
421
|
+
main()
|
|
422
|
+
.then((code) => {
|
|
423
|
+
if (code !== undefined)
|
|
424
|
+
process.exitCode = code;
|
|
425
|
+
})
|
|
426
|
+
.catch((err) => {
|
|
427
|
+
console.error(`vigent: ${err instanceof Error ? err.message : err}`);
|
|
428
|
+
process.exit(1);
|
|
429
|
+
});
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copy text to the system clipboard, like vim's "+ register.
|
|
3
|
+
*
|
|
4
|
+
* Always sends OSC 52 (the terminal sets the clipboard; works over SSH and in
|
|
5
|
+
* tmux with set-clipboard on), and also pipes to a local clipboard tool when
|
|
6
|
+
* one is installed.
|
|
7
|
+
*/
|
|
8
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
9
|
+
const TOOLS = [
|
|
10
|
+
["pbcopy", []],
|
|
11
|
+
["wl-copy", []],
|
|
12
|
+
["xclip", ["-selection", "clipboard"]],
|
|
13
|
+
["xsel", ["--clipboard", "--input"]],
|
|
14
|
+
["clip.exe", []],
|
|
15
|
+
];
|
|
16
|
+
let cachedTool;
|
|
17
|
+
function localTool() {
|
|
18
|
+
if (cachedTool !== undefined)
|
|
19
|
+
return cachedTool;
|
|
20
|
+
cachedTool = null;
|
|
21
|
+
for (const [cmd, args] of TOOLS) {
|
|
22
|
+
if (cmd === "wl-copy" && !process.env.WAYLAND_DISPLAY)
|
|
23
|
+
continue;
|
|
24
|
+
if ((cmd === "xclip" || cmd === "xsel") && !process.env.DISPLAY)
|
|
25
|
+
continue;
|
|
26
|
+
const which = spawnSync(process.platform === "win32" ? "where" : "which", [cmd], { stdio: "ignore" });
|
|
27
|
+
if (which.status === 0) {
|
|
28
|
+
cachedTool = [cmd, args];
|
|
29
|
+
break;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return cachedTool;
|
|
33
|
+
}
|
|
34
|
+
/** The OSC 52 escape sequence that asks the terminal to set its clipboard. */
|
|
35
|
+
export function osc52(text) {
|
|
36
|
+
const seq = `\x1b]52;c;${Buffer.from(text, "utf8").toString("base64")}\x07`;
|
|
37
|
+
// inside tmux, wrap it so tmux passes it on to the outer terminal
|
|
38
|
+
return process.env.TMUX ? `\x1bPtmux;${seq.replace(/\x1b/g, "\x1b\x1b")}\x1b\\` : seq;
|
|
39
|
+
}
|
|
40
|
+
/** Copy `text`. `write` sends raw bytes to the terminal. Returns how it was copied. */
|
|
41
|
+
export function copyToClipboard(text, write) {
|
|
42
|
+
write(osc52(text));
|
|
43
|
+
const tool = localTool();
|
|
44
|
+
if (tool) {
|
|
45
|
+
try {
|
|
46
|
+
const child = spawn(tool[0], tool[1], { stdio: ["pipe", "ignore", "ignore"] });
|
|
47
|
+
child.on("error", () => { });
|
|
48
|
+
child.stdin.end(text);
|
|
49
|
+
return tool[0];
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
// fall back to OSC 52 only
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return "terminal";
|
|
56
|
+
}
|
package/dist/color.js
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/** Color helpers: hex math, xterm-256 palette, and vim color names. */
|
|
2
|
+
export function parseHex(hex) {
|
|
3
|
+
if (!hex)
|
|
4
|
+
return null;
|
|
5
|
+
const m = /^#?([0-9a-fA-F]{6})$/.exec(hex.trim());
|
|
6
|
+
if (!m)
|
|
7
|
+
return null;
|
|
8
|
+
const n = parseInt(m[1], 16);
|
|
9
|
+
return [(n >> 16) & 0xff, (n >> 8) & 0xff, n & 0xff];
|
|
10
|
+
}
|
|
11
|
+
export function toHex([r, g, b]) {
|
|
12
|
+
const h = (v) => Math.round(Math.max(0, Math.min(255, v))).toString(16).padStart(2, "0");
|
|
13
|
+
return `#${h(r)}${h(g)}${h(b)}`;
|
|
14
|
+
}
|
|
15
|
+
/** Blend a toward b by t (0..1). Returns a unchanged if either color is invalid. */
|
|
16
|
+
export function mix(a, b, t) {
|
|
17
|
+
const x = parseHex(a);
|
|
18
|
+
const y = parseHex(b);
|
|
19
|
+
if (!x || !y)
|
|
20
|
+
return a;
|
|
21
|
+
return toHex([x[0] + (y[0] - x[0]) * t, x[1] + (y[1] - x[1]) * t, x[2] + (y[2] - x[2]) * t]);
|
|
22
|
+
}
|
|
23
|
+
/** Relative luminance, 0 (black) .. 1 (white). */
|
|
24
|
+
export function luminance(hex) {
|
|
25
|
+
const c = parseHex(hex);
|
|
26
|
+
if (!c)
|
|
27
|
+
return 0;
|
|
28
|
+
const [r, g, b] = c.map((v) => {
|
|
29
|
+
const s = v / 255;
|
|
30
|
+
return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
|
|
31
|
+
});
|
|
32
|
+
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
|
33
|
+
}
|
|
34
|
+
export function isLight(hex) {
|
|
35
|
+
return hex ? luminance(hex) > 0.35 : false;
|
|
36
|
+
}
|
|
37
|
+
const XTERM_16 = [
|
|
38
|
+
"#000000", "#800000", "#008000", "#808000", "#000080", "#800080", "#008080", "#c0c0c0",
|
|
39
|
+
"#808080", "#ff0000", "#00ff00", "#ffff00", "#0000ff", "#ff00ff", "#00ffff", "#ffffff",
|
|
40
|
+
];
|
|
41
|
+
/** Convert an xterm-256 color index to hex. */
|
|
42
|
+
export function xterm256(n) {
|
|
43
|
+
if (!Number.isInteger(n) || n < 0 || n > 255)
|
|
44
|
+
return undefined;
|
|
45
|
+
if (n < 16)
|
|
46
|
+
return XTERM_16[n];
|
|
47
|
+
if (n < 232) {
|
|
48
|
+
const levels = [0, 95, 135, 175, 215, 255];
|
|
49
|
+
const i = n - 16;
|
|
50
|
+
return toHex([levels[Math.floor(i / 36)], levels[Math.floor(i / 6) % 6], levels[i % 6]]);
|
|
51
|
+
}
|
|
52
|
+
const v = 8 + (n - 232) * 10;
|
|
53
|
+
return toHex([v, v, v]);
|
|
54
|
+
}
|
|
55
|
+
/** Vim's cterm color names (ctermfg=DarkBlue) -> xterm index. */
|
|
56
|
+
const CTERM_NAMES = {
|
|
57
|
+
black: 0, darkred: 1, darkgreen: 2, brown: 3, darkyellow: 3, darkblue: 4, darkmagenta: 5,
|
|
58
|
+
darkcyan: 6, lightgray: 7, lightgrey: 7, gray: 7, grey: 7, darkgray: 8, darkgrey: 8,
|
|
59
|
+
red: 9, lightred: 9, green: 10, lightgreen: 10, yellow: 11, lightyellow: 11, blue: 12,
|
|
60
|
+
lightblue: 12, magenta: 13, lightmagenta: 13, cyan: 14, lightcyan: 14, white: 15,
|
|
61
|
+
};
|
|
62
|
+
/** Common GUI color names used by vim colorschemes (values from vim's rgb list). */
|
|
63
|
+
const GUI_NAMES = {
|
|
64
|
+
black: "#000000", white: "#ffffff", red: "#ff0000", green: "#00ff00", blue: "#0000ff",
|
|
65
|
+
yellow: "#ffff00", cyan: "#00ffff", magenta: "#ff00ff", gray: "#bebebe", grey: "#bebebe",
|
|
66
|
+
darkgray: "#a9a9a9", darkgrey: "#a9a9a9", lightgray: "#d3d3d3", lightgrey: "#d3d3d3",
|
|
67
|
+
darkred: "#8b0000", darkgreen: "#006400", darkblue: "#00008b", darkcyan: "#008b8b",
|
|
68
|
+
darkmagenta: "#8b008b", darkyellow: "#bbbb00", brown: "#a52a2a", orange: "#ffa500",
|
|
69
|
+
purple: "#a020f0", lightblue: "#add8e6", lightgreen: "#90ee90", lightcyan: "#e0ffff",
|
|
70
|
+
lightred: "#ffbbbb", lightmagenta: "#ffbbff", lightyellow: "#ffffe0", seagreen: "#2e8b57",
|
|
71
|
+
slateblue: "#6a5acd", gold: "#ffd700", khaki: "#f0e68c", navy: "#000080", wheat: "#f5deb3",
|
|
72
|
+
peru: "#cd853f", salmon: "#fa8072", orangered: "#ff4500", skyblue: "#87ceeb",
|
|
73
|
+
violet: "#ee82ee", tan: "#d2b48c", olive: "#808000", teal: "#008080", maroon: "#800000",
|
|
74
|
+
silver: "#c0c0c0", pink: "#ffc0cb", indianred: "#cd5c5c", steelblue: "#4682b4",
|
|
75
|
+
darkslategray: "#2f4f4f", darkslategrey: "#2f4f4f", darkolivegreen: "#556b2f",
|
|
76
|
+
darkorange: "#ff8c00", lightslategray: "#778899", midnightblue: "#191970",
|
|
77
|
+
};
|
|
78
|
+
/**
|
|
79
|
+
* Resolve a vim color value to hex: "#rrggbb", "NONE", a GUI color name,
|
|
80
|
+
* grayN/greyN, or (for cterm) an xterm index or cterm name.
|
|
81
|
+
* Returns "NONE", a hex string, or undefined if unknown.
|
|
82
|
+
*/
|
|
83
|
+
export function resolveVimColor(value, cterm = false) {
|
|
84
|
+
const v = value.trim().replace(/^["']|["']$/g, "");
|
|
85
|
+
const lower = v.toLowerCase();
|
|
86
|
+
if (lower === "none")
|
|
87
|
+
return "NONE";
|
|
88
|
+
if (lower === "fg" || lower === "bg" || lower === "foreground" || lower === "background")
|
|
89
|
+
return undefined;
|
|
90
|
+
if (parseHex(v))
|
|
91
|
+
return `#${v.replace(/^#/, "").toLowerCase()}`;
|
|
92
|
+
if (cterm) {
|
|
93
|
+
if (/^\d+$/.test(v))
|
|
94
|
+
return xterm256(Number(v));
|
|
95
|
+
const idx = CTERM_NAMES[lower.replace(/\s+/g, "")];
|
|
96
|
+
return idx === undefined ? undefined : xterm256(idx);
|
|
97
|
+
}
|
|
98
|
+
const gray = /^gr[ae]y(\d{1,3})$/.exec(lower);
|
|
99
|
+
if (gray) {
|
|
100
|
+
const n = Math.round((Math.min(100, Number(gray[1])) * 255) / 100);
|
|
101
|
+
return toHex([n, n, n]);
|
|
102
|
+
}
|
|
103
|
+
return GUI_NAMES[lower.replace(/\s+/g, "")];
|
|
104
|
+
}
|
|
105
|
+
/** The xterm-256 index closest to a hex color (6x6x6 cube or grayscale ramp). */
|
|
106
|
+
export function nearest256(hex) {
|
|
107
|
+
const c = parseHex(hex);
|
|
108
|
+
if (!c)
|
|
109
|
+
return 7;
|
|
110
|
+
const levels = [0, 95, 135, 175, 215, 255];
|
|
111
|
+
const idx = (v) => {
|
|
112
|
+
let best = 0;
|
|
113
|
+
for (let i = 1; i < 6; i++)
|
|
114
|
+
if (Math.abs(levels[i] - v) < Math.abs(levels[best] - v))
|
|
115
|
+
best = i;
|
|
116
|
+
return best;
|
|
117
|
+
};
|
|
118
|
+
const [r, g, b] = c.map(idx);
|
|
119
|
+
const cube = 16 + 36 * r + 6 * g + b;
|
|
120
|
+
const cubeRgb = [levels[r], levels[g], levels[b]];
|
|
121
|
+
const avg = (c[0] + c[1] + c[2]) / 3;
|
|
122
|
+
const grayIdx = Math.max(0, Math.min(23, Math.round((avg - 8) / 10)));
|
|
123
|
+
const grayV = 8 + grayIdx * 10;
|
|
124
|
+
const dist = (x) => (x[0] - c[0]) ** 2 + (x[1] - c[1]) ** 2 + (x[2] - c[2]) ** 2;
|
|
125
|
+
return dist([grayV, grayV, grayV]) < dist(cubeRgb) ? 232 + grayIdx : cube;
|
|
126
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Edit config.toml in place for `:settings`: change one value and keep
|
|
3
|
+
* everything else (comments, order, other keys) as the user wrote it.
|
|
4
|
+
*/
|
|
5
|
+
/** A TOML value: strings quoted, booleans and numbers as they are. */
|
|
6
|
+
export function tomlValue(v) {
|
|
7
|
+
return typeof v === "string" ? JSON.stringify(v) : String(v);
|
|
8
|
+
}
|
|
9
|
+
/** A TOML key: bare when it can be, quoted otherwise (`"<leader>n"`). */
|
|
10
|
+
export function tomlKey(k) {
|
|
11
|
+
return /^[A-Za-z0-9_-]+$/.test(k) ? k : JSON.stringify(k);
|
|
12
|
+
}
|
|
13
|
+
const header = /^\s*\[([^\][]+)\]\s*(#.*)?$/;
|
|
14
|
+
/**
|
|
15
|
+
* Set `key = value` in `[section]` (or at the top level when section is
|
|
16
|
+
* undefined). A commented-out line for the same key (`# key = …`) is replaced
|
|
17
|
+
* in place, so the setting stays where the template documents it.
|
|
18
|
+
*/
|
|
19
|
+
export function setTomlValue(text, section, key, value) {
|
|
20
|
+
const lines = text.length ? text.replace(/\n$/, "").split("\n") : [];
|
|
21
|
+
const line = `${tomlKey(key)} = ${value}`;
|
|
22
|
+
const keyRe = new RegExp(`^(\\s*)(#\\s*)?${escape(tomlKey(key))}\\s*=`);
|
|
23
|
+
// the range of lines that belong to the section
|
|
24
|
+
let start = 0;
|
|
25
|
+
let end = lines.findIndex((l) => header.test(l));
|
|
26
|
+
if (end < 0)
|
|
27
|
+
end = lines.length;
|
|
28
|
+
if (section) {
|
|
29
|
+
const at = lines.findIndex((l) => header.exec(l)?.[1].trim() === section);
|
|
30
|
+
if (at < 0) {
|
|
31
|
+
// a new section at the end
|
|
32
|
+
const out = [...lines];
|
|
33
|
+
if (out.length && out[out.length - 1].trim() !== "")
|
|
34
|
+
out.push("");
|
|
35
|
+
out.push(`[${section}]`, line);
|
|
36
|
+
return `${out.join("\n")}\n`;
|
|
37
|
+
}
|
|
38
|
+
start = at + 1;
|
|
39
|
+
end = lines.findIndex((l, i) => i >= start && header.test(l));
|
|
40
|
+
if (end < 0)
|
|
41
|
+
end = lines.length;
|
|
42
|
+
}
|
|
43
|
+
const live = lines.findIndex((l, i) => i >= start && i < end && keyRe.test(l) && !/^\s*#/.test(l));
|
|
44
|
+
const commented = lines.findIndex((l, i) => i >= start && i < end && keyRe.test(l) && /^\s*#/.test(l));
|
|
45
|
+
const out = [...lines];
|
|
46
|
+
const at = live >= 0 ? live : commented;
|
|
47
|
+
if (at >= 0) {
|
|
48
|
+
out[at] = line;
|
|
49
|
+
}
|
|
50
|
+
else {
|
|
51
|
+
// after the last non-blank line of the section
|
|
52
|
+
let insert = end;
|
|
53
|
+
while (insert > start && out[insert - 1].trim() === "")
|
|
54
|
+
insert--;
|
|
55
|
+
out.splice(insert, 0, line);
|
|
56
|
+
}
|
|
57
|
+
return `${out.join("\n")}\n`;
|
|
58
|
+
}
|
|
59
|
+
function escape(s) {
|
|
60
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
61
|
+
}
|