@vigent/cli 0.2.2 → 0.3.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/README.md +86 -28
- package/dist/agents.js +29 -0
- package/dist/app.js +565 -21
- package/dist/attach.js +102 -0
- package/dist/choices.js +4 -1
- package/dist/cli-engines.js +15 -5
- package/dist/cli.js +25 -15
- package/dist/config.js +26 -2
- package/dist/diffview.js +1 -1
- package/dist/engines.js +18 -11
- package/dist/float.js +1 -1
- package/dist/hunk-review.js +218 -0
- package/dist/hunks.js +60 -0
- package/dist/icons.js +1 -0
- package/dist/keys.js +4 -1
- package/dist/main.js +16 -0
- package/dist/markdown.js +1 -1
- package/dist/notify.js +33 -0
- package/dist/pi-sdk.js +45 -0
- package/dist/pi-ui.js +78 -0
- package/dist/picker.js +92 -47
- package/dist/serve-page.js +247 -0
- package/dist/serve.js +267 -0
- package/dist/session.js +145 -10
- package/dist/settings.js +1 -1
- package/dist/templates.js +125 -0
- package/dist/theme.js +2 -0
- package/dist/tui.js +13 -0
- package/package.json +4 -4
package/dist/app.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* │ statusline │
|
|
7
7
|
* └ cmdline ┘
|
|
8
8
|
*/
|
|
9
|
-
import { truncateToWidth, visibleWidth, wrapTextWithAnsi, } from "
|
|
9
|
+
import { truncateToWidth, visibleWidth, wrapTextWithAnsi, } from "./tui.js";
|
|
10
10
|
import { applySetCommand, CONFIG_TEMPLATE, configPath, DEFAULT_UI } from "./config.js";
|
|
11
11
|
import { setTomlValue, tomlValue } from "./config-edit.js";
|
|
12
12
|
import { SettingsFloat } from "./settings.js";
|
|
@@ -15,9 +15,16 @@ import { ACTIONS, formatKeys, KEY_MODES, KeySequencer, Keymap, keyFromInput, par
|
|
|
15
15
|
import * as path from "node:path";
|
|
16
16
|
import * as fs from "node:fs";
|
|
17
17
|
import { setDetached } from "./agents.js";
|
|
18
|
+
import { dropPatch, parseDiff, restorePatch } from "./hunks.js";
|
|
19
|
+
import { HunkReview } from "./hunk-review.js";
|
|
20
|
+
import { listFiles, MENTION, mentionable } from "./attach.js";
|
|
21
|
+
import { allTemplates, fillTemplate, needsTask } from "./templates.js";
|
|
22
|
+
import { startServer } from "./serve.js";
|
|
23
|
+
import { didYouMean } from "./suggest.js";
|
|
24
|
+
import { alertSequence, NOTIFY_MODES } from "./notify.js";
|
|
18
25
|
import { VERSION_TEXT } from "./version.js";
|
|
19
26
|
import { backendName, formatBytes, riftriBackends, RIFTRI_INSTALL } from "./riftri.js";
|
|
20
|
-
import { ENGINE_NAMES, isEngineId } from "./engines.js";
|
|
27
|
+
import { defaultEngine as defaultEngineFor, ENGINE_NAMES, isEngineId } from "./engines.js";
|
|
21
28
|
import { copyToClipboard } from "./clipboard.js";
|
|
22
29
|
import { splitView, unifiedView } from "./diffview.js";
|
|
23
30
|
import { TREE_GLYPHS } from "./tree.js";
|
|
@@ -55,6 +62,8 @@ const ICONS = {
|
|
|
55
62
|
queued: "⧗",
|
|
56
63
|
open_agent: "▸",
|
|
57
64
|
here: "●",
|
|
65
|
+
unread: "◆",
|
|
66
|
+
serve: "⇄ ",
|
|
58
67
|
ok: "●",
|
|
59
68
|
warn: "◐",
|
|
60
69
|
off: "○",
|
|
@@ -76,6 +85,8 @@ const ICONS = {
|
|
|
76
85
|
queued: NERD.clock,
|
|
77
86
|
open_agent: "▸",
|
|
78
87
|
here: NERD.circle,
|
|
88
|
+
unread: NERD.bell,
|
|
89
|
+
serve: "\uf10b ",
|
|
79
90
|
ok: NERD.check,
|
|
80
91
|
warn: NERD.warning,
|
|
81
92
|
off: NERD.circleO,
|
|
@@ -97,6 +108,8 @@ const ICONS = {
|
|
|
97
108
|
queued: "+",
|
|
98
109
|
open_agent: ">",
|
|
99
110
|
here: "*",
|
|
111
|
+
unread: "!",
|
|
112
|
+
serve: "~ ",
|
|
100
113
|
ok: "+",
|
|
101
114
|
warn: "~",
|
|
102
115
|
off: "-",
|
|
@@ -147,6 +160,8 @@ export class App {
|
|
|
147
160
|
/** model and effort for new agents, per engine ([engines.<id>] in config.toml, :model / :effort with no agent) */
|
|
148
161
|
newAgentModel = {};
|
|
149
162
|
newAgentEffort = {};
|
|
163
|
+
/** yours, from [templates] in config.toml (the built-in ones are added to these) */
|
|
164
|
+
templates = {};
|
|
150
165
|
/** save an agent's settings (model, effort) so they survive a restart */
|
|
151
166
|
persist = () => { };
|
|
152
167
|
/** `engine` from config.toml or :settings; "auto" lets defaultEngine() pick */
|
|
@@ -197,8 +212,13 @@ export class App {
|
|
|
197
212
|
return this.sessions[this.focusIdx];
|
|
198
213
|
}
|
|
199
214
|
addSession(s, focus = true) {
|
|
215
|
+
if ("extensionStatus" in s)
|
|
216
|
+
s.uiHost = this.piUiHost(s);
|
|
217
|
+
queueMicrotask(() => this.server?.sync());
|
|
200
218
|
this.sessions.push(s);
|
|
219
|
+
this.lastState.set(s, s.state);
|
|
201
220
|
s.onChange(() => {
|
|
221
|
+
this.watchState(s);
|
|
202
222
|
this.syncSpinner();
|
|
203
223
|
this.tui.requestRender();
|
|
204
224
|
});
|
|
@@ -218,9 +238,36 @@ export class App {
|
|
|
218
238
|
this.syncSpinner();
|
|
219
239
|
this.tui.requestRender();
|
|
220
240
|
}
|
|
241
|
+
/** agents that finished, failed or wait for you while you looked elsewhere */
|
|
242
|
+
unread = new Set();
|
|
243
|
+
/** the terminal window has focus (from focus reporting; assumed until told otherwise) */
|
|
244
|
+
terminalFocused = true;
|
|
245
|
+
lastState = new WeakMap();
|
|
246
|
+
/** where notifications go (replaced in tests) */
|
|
247
|
+
alert = (bytes) => this.terminal.write(bytes);
|
|
248
|
+
/** An agent stopped running: if you're not looking at it, say so (bell, desktop notification, sidebar mark). */
|
|
249
|
+
watchState(s) {
|
|
250
|
+
const before = this.lastState.get(s);
|
|
251
|
+
this.lastState.set(s, s.state);
|
|
252
|
+
if (before === "running" && s.state !== "running")
|
|
253
|
+
this.persist(s); // its usage totals, for after a restart
|
|
254
|
+
if (before !== "running" || s.state === "running" || s.state === "paused")
|
|
255
|
+
return;
|
|
256
|
+
const visible = s === this.focusedSession && this.terminalFocused;
|
|
257
|
+
if (visible)
|
|
258
|
+
return;
|
|
259
|
+
this.unread.add(s);
|
|
260
|
+
const n = this.sessions.indexOf(s) + 1;
|
|
261
|
+
const what = s.state === "error" ? "failed" : s.state === "waiting" ? "needs you" : "finished";
|
|
262
|
+
if (s !== this.focusedSession) {
|
|
263
|
+
this.setMessage(`'${s.name}' ${what} · ${n <= 9 ? `${n} or ` : ""}]s to open it`, s.state === "error" ? "ErrorMsg" : "ModeMsg");
|
|
264
|
+
}
|
|
265
|
+
this.alert(alertSequence(`vigent: '${s.name}' ${what}`, this.ui.notify));
|
|
266
|
+
}
|
|
221
267
|
focusSession(idx) {
|
|
222
268
|
if (idx < 0 || idx >= this.sessions.length || idx === this.focusIdx)
|
|
223
269
|
return;
|
|
270
|
+
this.unread.delete(this.sessions[idx]);
|
|
224
271
|
this.altIdx = this.focusIdx;
|
|
225
272
|
this.focusIdx = idx;
|
|
226
273
|
this.scrollOffset = 0;
|
|
@@ -278,17 +325,28 @@ export class App {
|
|
|
278
325
|
this.painter.setTransparency(this.ui.transparent ?? "auto");
|
|
279
326
|
}
|
|
280
327
|
/** Ask a y/n question in the command line, like vim's confirm prompts. */
|
|
281
|
-
ask(question, onYes) {
|
|
328
|
+
ask(question, onYes, onNo) {
|
|
282
329
|
this.seq.reset();
|
|
283
|
-
this.confirm
|
|
330
|
+
this.confirm?.onNo?.(); // a newer question replaces an unanswered one: that one is a no
|
|
331
|
+
this.confirm = { question, onYes, onNo };
|
|
284
332
|
}
|
|
285
333
|
handleInput(data) {
|
|
334
|
+
// focus reporting: the terminal window gained or lost focus (never an answer to a question)
|
|
335
|
+
if (data === "\x1b[I" || data === "\x1b[O") {
|
|
336
|
+
this.terminalFocused = data === "\x1b[I";
|
|
337
|
+
if (this.terminalFocused && this.focusedSession)
|
|
338
|
+
this.unread.delete(this.focusedSession);
|
|
339
|
+
this.tui.requestRender();
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
286
342
|
if (this.confirm) {
|
|
287
|
-
const { onYes } = this.confirm;
|
|
343
|
+
const { onYes, onNo } = this.confirm;
|
|
288
344
|
this.confirm = undefined;
|
|
289
345
|
this.message = "";
|
|
290
346
|
if (data === "y" || data === "Y")
|
|
291
347
|
onYes();
|
|
348
|
+
else
|
|
349
|
+
onNo?.();
|
|
292
350
|
this.tui.requestRender();
|
|
293
351
|
return;
|
|
294
352
|
}
|
|
@@ -327,6 +385,11 @@ export class App {
|
|
|
327
385
|
return ["<Esc>", ...parseKeys([...rest].length === 1 ? rest : `<${rest}>`)];
|
|
328
386
|
}
|
|
329
387
|
typeText(text) {
|
|
388
|
+
if (this.mode === "INSERT" && text === "@" && (this.inputBuf === "" || /\s$/.test(this.inputBuf))) {
|
|
389
|
+
this.inputBuf += text;
|
|
390
|
+
this.openAttach();
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
330
393
|
if (this.mode === "INSERT")
|
|
331
394
|
this.inputBuf += text;
|
|
332
395
|
else if (this.mode === "COMMAND" || this.mode === "SEARCH")
|
|
@@ -669,6 +732,17 @@ export class App {
|
|
|
669
732
|
case "dashboard":
|
|
670
733
|
this.openDashboard();
|
|
671
734
|
return;
|
|
735
|
+
case "cost":
|
|
736
|
+
case "usage":
|
|
737
|
+
this.openUsage();
|
|
738
|
+
return;
|
|
739
|
+
case "pi":
|
|
740
|
+
case "extensions":
|
|
741
|
+
this.openPiResources();
|
|
742
|
+
return;
|
|
743
|
+
case "serve":
|
|
744
|
+
void this.execServe(arg);
|
|
745
|
+
return;
|
|
672
746
|
case "tree":
|
|
673
747
|
this.openTree();
|
|
674
748
|
return;
|
|
@@ -692,17 +766,36 @@ export class App {
|
|
|
692
766
|
case "new!": {
|
|
693
767
|
const modelFlag = /(?:^|\s)--model[= ](\S+)/.exec(arg);
|
|
694
768
|
const engineFlag = /(?:^|\s)(?:--engine|-e)[= ](\S+)/.exec(arg);
|
|
695
|
-
const
|
|
769
|
+
const templateFlag = /(?:^|\s)(?:--template|-t)[= ](\S+)/.exec(arg);
|
|
770
|
+
const rest = arg
|
|
696
771
|
.replace(/(?:^|\s)--model[= ]\S+/, "")
|
|
697
772
|
.replace(/(?:^|\s)(?:--engine|-e)[= ]\S+/, "")
|
|
698
|
-
.
|
|
699
|
-
|
|
773
|
+
.replace(/(?:^|\s)(?:--template|-t)[= ]\S+/, "")
|
|
774
|
+
.trim();
|
|
775
|
+
if (templateFlag) {
|
|
776
|
+
this.startFromTemplate(templateFlag[1], rest, { worktree: cmd === "new" ? undefined : false, model: modelFlag?.[1], engine: engineFlag?.[1] });
|
|
777
|
+
return;
|
|
778
|
+
}
|
|
779
|
+
this.startAgent(rest || `agent-${this.sessions.length + 1}`, { worktree: cmd === "new", model: modelFlag?.[1], engine: engineFlag?.[1] });
|
|
780
|
+
return;
|
|
781
|
+
}
|
|
782
|
+
case "templates":
|
|
783
|
+
case "template":
|
|
784
|
+
case "tpl": {
|
|
785
|
+
if (!arg)
|
|
786
|
+
this.openTemplatePicker();
|
|
787
|
+
else
|
|
788
|
+
this.exec(`new -t ${arg}`);
|
|
700
789
|
return;
|
|
701
790
|
}
|
|
702
791
|
case "kill":
|
|
703
792
|
case "kill!":
|
|
704
793
|
this.killFocused(cmd === "kill!");
|
|
705
794
|
return;
|
|
795
|
+
case "review":
|
|
796
|
+
case "hunks":
|
|
797
|
+
this.openReview();
|
|
798
|
+
return;
|
|
706
799
|
case "diff":
|
|
707
800
|
this.openDiff(/^(split|vert|vertical|side)$/.test(arg));
|
|
708
801
|
return;
|
|
@@ -756,6 +849,13 @@ export class App {
|
|
|
756
849
|
else
|
|
757
850
|
this.openModelPicker();
|
|
758
851
|
return;
|
|
852
|
+
case "engine":
|
|
853
|
+
case "eng":
|
|
854
|
+
if (arg)
|
|
855
|
+
this.chooseEngine(arg);
|
|
856
|
+
else
|
|
857
|
+
this.openEnginePicker();
|
|
858
|
+
return;
|
|
759
859
|
case "effort":
|
|
760
860
|
case "think":
|
|
761
861
|
if (arg)
|
|
@@ -996,6 +1096,37 @@ export class App {
|
|
|
996
1096
|
this.tui.requestRender();
|
|
997
1097
|
});
|
|
998
1098
|
}
|
|
1099
|
+
/** `:engine`: which engine new agents run on, with what's ready on this machine. */
|
|
1100
|
+
openEnginePicker() {
|
|
1101
|
+
const ids = ["auto", "pi", "claude", "codex", "opencode"];
|
|
1102
|
+
const items = ids.map((id) => ({ value: id, note: this.engineNote(id) }));
|
|
1103
|
+
this.openChoices("Engine for new agents", items, this.preferredEngine, false, (v) => this.chooseEngine(v));
|
|
1104
|
+
}
|
|
1105
|
+
/** "Claude Code · ready", "Codex · not signed in: codex login", "auto: … (now Claude Code)" */
|
|
1106
|
+
engineNote(id) {
|
|
1107
|
+
if (id === "auto")
|
|
1108
|
+
return `pick for me (now ${ENGINE_NAMES[defaultEngineFor(this.engines)]})`;
|
|
1109
|
+
const e = this.engines?.find((x) => x.id === id);
|
|
1110
|
+
if (!e)
|
|
1111
|
+
return ENGINE_NAMES[id];
|
|
1112
|
+
const status = e.connected ? "ready" : !e.installed ? "not installed" : e.connect ? `not signed in: ${e.connect}` : "not signed in";
|
|
1113
|
+
return `${ENGINE_NAMES[id]} · ${status}`;
|
|
1114
|
+
}
|
|
1115
|
+
/** `:engine <name>`: new agents run on it. Agents already open keep theirs (their conversation lives there). */
|
|
1116
|
+
chooseEngine(value) {
|
|
1117
|
+
if (value !== "auto" && !isEngineId(value)) {
|
|
1118
|
+
this.setMessage("engines: auto, pi, claude, codex, opencode", "ErrorMsg");
|
|
1119
|
+
return;
|
|
1120
|
+
}
|
|
1121
|
+
this.preferredEngine = value;
|
|
1122
|
+
const now = this.defaultEngine();
|
|
1123
|
+
const e = this.engines?.find((x) => x.id === now);
|
|
1124
|
+
const warn = e && !e.connected ? ` (${e.installed ? `not signed in${e.connect ? `: run ${e.connect}` : ""}` : "not installed"})` : "";
|
|
1125
|
+
const s = this.focusedSession;
|
|
1126
|
+
const open = s && s.engine !== now ? ` · '${s.name}' stays on ${ENGINE_NAMES[s.engine]}; :new <task> starts one on ${ENGINE_NAMES[now]}` : "";
|
|
1127
|
+
this.setMessage(`new agents run on ${ENGINE_NAMES[now]}${value === "auto" ? " (auto)" : ""}${warn}${open} · :settings, w keeps it`, warn ? "WarningMsg" : "ModeMsg");
|
|
1128
|
+
this.repaint();
|
|
1129
|
+
}
|
|
999
1130
|
/** `:model`: pick from what the engine offers, or type any name. */
|
|
1000
1131
|
openModelPicker(t = this.modelTarget()) {
|
|
1001
1132
|
const current = t.session
|
|
@@ -1016,20 +1147,110 @@ export class App {
|
|
|
1016
1147
|
return this.newAgentModel[t.engine];
|
|
1017
1148
|
return "chosenModel" in t.session ? t.session.chosenModel : t.session.model;
|
|
1018
1149
|
}
|
|
1150
|
+
/** Every template: the built-in ones, then yours. */
|
|
1151
|
+
allTemplates() {
|
|
1152
|
+
return allTemplates(this.templates);
|
|
1153
|
+
}
|
|
1154
|
+
/**
|
|
1155
|
+
* `:new -t <template> [task]`: start an agent with the template's engine,
|
|
1156
|
+
* model and effort, and send it the template's prompt. Flags you type
|
|
1157
|
+
* (--engine, --model) win over the template's.
|
|
1158
|
+
*/
|
|
1159
|
+
startFromTemplate(name, task, opts = {}) {
|
|
1160
|
+
const all = this.allTemplates();
|
|
1161
|
+
const t = all.find((x) => x.name === name);
|
|
1162
|
+
if (!t) {
|
|
1163
|
+
this.setMessage(`no template '${name}'${didYouMean(name, all.map((x) => x.name))} · :templates lists them`, "ErrorMsg");
|
|
1164
|
+
return undefined;
|
|
1165
|
+
}
|
|
1166
|
+
if (needsTask(t) && !task && !(t.builtin && t.name === "review")) {
|
|
1167
|
+
this.setMessage(`template '${t.name}' needs a task: :new -t ${t.name} <what>`, "ErrorMsg");
|
|
1168
|
+
this.mode = "COMMAND";
|
|
1169
|
+
this.cmdBuf = `new -t ${t.name} `;
|
|
1170
|
+
return undefined;
|
|
1171
|
+
}
|
|
1172
|
+
const engine = opts.engine ?? t.engine;
|
|
1173
|
+
const s = this.startAgent(task ? agentNameFrom(task) : t.name, {
|
|
1174
|
+
worktree: opts.worktree ?? t.worktree,
|
|
1175
|
+
model: opts.model ?? (engine === t.engine || !t.engine ? t.model : undefined),
|
|
1176
|
+
engine,
|
|
1177
|
+
effort: !opts.engine || opts.engine === t.engine ? t.effort : undefined,
|
|
1178
|
+
});
|
|
1179
|
+
if (!s)
|
|
1180
|
+
return undefined;
|
|
1181
|
+
s.submit(fillTemplate(t, task));
|
|
1182
|
+
this.setMessage(`${this.message} · template ${t.name}`, this.messageGroup);
|
|
1183
|
+
return s;
|
|
1184
|
+
}
|
|
1185
|
+
/** `:templates`: pick one; the command line then takes the task. */
|
|
1186
|
+
openTemplatePicker() {
|
|
1187
|
+
const items = this.allTemplates().map((t) => {
|
|
1188
|
+
const how = [t.engine ? ENGINE_NAMES[t.engine] : "", t.model ?? "", t.effort ?? ""].filter(Boolean).join(" · ");
|
|
1189
|
+
const what = t.description ?? t.prompt.split("\n")[0];
|
|
1190
|
+
return { value: t.name, note: `${what}${how ? ` (${how})` : ""}${t.builtin ? "" : " · yours"}` };
|
|
1191
|
+
});
|
|
1192
|
+
const place = { width: Math.min(96, this.terminal.columns - 4), anchor: "center" };
|
|
1193
|
+
this.openChoices("Start an agent from a template", items, "", false, (name) => {
|
|
1194
|
+
const t = this.allTemplates().find((x) => x.name === name);
|
|
1195
|
+
if (t && !needsTask(t)) {
|
|
1196
|
+
this.startFromTemplate(name, "");
|
|
1197
|
+
return;
|
|
1198
|
+
}
|
|
1199
|
+
this.mode = "COMMAND";
|
|
1200
|
+
this.cmdBuf = `new -t ${name} `;
|
|
1201
|
+
this.setMessage(t?.builtin && t.name === "review" ? "what to focus on (optional), then Enter" : "what it's for, then Enter", "Comment");
|
|
1202
|
+
}, {}, place);
|
|
1203
|
+
}
|
|
1019
1204
|
/** where effort levels come from (replaced in tests) */
|
|
1020
1205
|
effortChoices = (engine, model) => effortChoicesFor(engine, model);
|
|
1021
1206
|
/** where model lists come from (replaced in tests) */
|
|
1022
1207
|
modelChoices = (engine) => modelChoices(engine);
|
|
1023
|
-
|
|
1208
|
+
/** where `@` finds files to attach (replaced in tests) */
|
|
1209
|
+
listFiles = (cwd) => listFiles(cwd);
|
|
1210
|
+
/** the folder the focused agent works in: its worktree, or this checkout */
|
|
1211
|
+
attachDir() {
|
|
1212
|
+
const s = this.focusedSession;
|
|
1213
|
+
return s?.cwd ?? s?.worktree?.path ?? this.workspace.root ?? process.cwd();
|
|
1214
|
+
}
|
|
1215
|
+
/**
|
|
1216
|
+
* `@` in the prompt: pick a file from the agent's folder. Enter writes
|
|
1217
|
+
* `@path `, Esc keeps a plain `@`, Backspace takes the `@` back.
|
|
1218
|
+
*/
|
|
1219
|
+
openAttach() {
|
|
1220
|
+
const dir = this.attachDir();
|
|
1221
|
+
const items = this.listFiles(dir).then((files) => files.filter(mentionable).map((value) => ({ value })));
|
|
1222
|
+
const takeBack = () => {
|
|
1223
|
+
if (this.inputBuf.endsWith("@"))
|
|
1224
|
+
this.inputBuf = this.inputBuf.slice(0, -1);
|
|
1225
|
+
};
|
|
1226
|
+
this.openChoices(`Attach a file · ${path.basename(dir)}`, items, "", false, (v) => {
|
|
1227
|
+
takeBack();
|
|
1228
|
+
this.inputBuf += `@${v} `;
|
|
1229
|
+
}, { search: true, onBackOut: takeBack, rows: 8 },
|
|
1230
|
+
// a completion menu: just above the prompt, where you're typing
|
|
1231
|
+
{ anchor: "bottom-left", col: this.sidebarWidth(this.terminal.columns) + 2, offsetY: -2, width: Math.min(64, this.terminal.columns - 2) });
|
|
1232
|
+
}
|
|
1233
|
+
openChoices(title, items, current, custom, done, opts = {}, place = { width: Math.min(72, this.terminal.columns - 4), anchor: "center" }) {
|
|
1024
1234
|
this.seq.reset();
|
|
1025
1235
|
let handle;
|
|
1026
1236
|
const picker = new ChoicePicker(this.painter, PICKER_ICONS[this.ui.icons], title, items, current, custom, (v) => {
|
|
1027
1237
|
handle?.hide();
|
|
1028
1238
|
if (v !== null)
|
|
1029
1239
|
done(v);
|
|
1240
|
+
else
|
|
1241
|
+
opts.onCancel?.();
|
|
1030
1242
|
this.repaint();
|
|
1031
|
-
}, () => this.tui.requestRender()
|
|
1032
|
-
|
|
1243
|
+
}, () => this.tui.requestRender(), {
|
|
1244
|
+
...opts,
|
|
1245
|
+
onBackOut: opts.onBackOut
|
|
1246
|
+
? () => {
|
|
1247
|
+
handle?.hide();
|
|
1248
|
+
opts.onBackOut?.();
|
|
1249
|
+
this.repaint();
|
|
1250
|
+
}
|
|
1251
|
+
: undefined,
|
|
1252
|
+
});
|
|
1253
|
+
handle = this.tui.showOverlay(picker, place);
|
|
1033
1254
|
this.tui.requestRender();
|
|
1034
1255
|
}
|
|
1035
1256
|
/** `:settings`: theme, look, agents and keys in one float; w saves them to config.toml. */
|
|
@@ -1089,7 +1310,7 @@ export class App {
|
|
|
1089
1310
|
change: (d) => {
|
|
1090
1311
|
this.preferredEngine = cycle(["auto", "pi", "claude", "codex", "opencode"], this.preferredEngine, d);
|
|
1091
1312
|
},
|
|
1092
|
-
hint: "auto:
|
|
1313
|
+
hint: "auto: the first signed-in CLI, else pi with an API key · :engine picks it from anywhere",
|
|
1093
1314
|
},
|
|
1094
1315
|
{
|
|
1095
1316
|
section: "Agents",
|
|
@@ -1124,6 +1345,13 @@ export class App {
|
|
|
1124
1345
|
change: (d) => (wt.backend = cycle(["auto", "riftri", "git"], wt.backend ?? "auto", d)),
|
|
1125
1346
|
hint: "riftri: copy-on-write worktrees that share unchanged files (riftri.dev)",
|
|
1126
1347
|
},
|
|
1348
|
+
{
|
|
1349
|
+
section: "Agents",
|
|
1350
|
+
label: "Notify when an agent finishes",
|
|
1351
|
+
value: () => ({ auto: "auto (bell, and a desktop notification where the terminal shows one)", bell: "bell", desktop: "desktop notification", off: "off" })[ui.notify],
|
|
1352
|
+
change: (d) => (ui.notify = cycle(NOTIFY_MODES, ui.notify, d)),
|
|
1353
|
+
hint: "when an agent finishes, fails or needs you while you're looking at another one (or another window)",
|
|
1354
|
+
},
|
|
1127
1355
|
{
|
|
1128
1356
|
section: "Keys",
|
|
1129
1357
|
label: "which-key popup",
|
|
@@ -1205,7 +1433,7 @@ export class App {
|
|
|
1205
1433
|
set(undefined, "theme", this.painter.theme.name);
|
|
1206
1434
|
set(undefined, "engine", this.preferredEngine);
|
|
1207
1435
|
set(undefined, "leader", this.keymap.leader);
|
|
1208
|
-
for (const k of ["sidebar", "sidebar_width", "number", "tilde", "icons", "termguicolors", "transparent", "whichkey"])
|
|
1436
|
+
for (const k of ["sidebar", "sidebar_width", "number", "tilde", "icons", "termguicolors", "transparent", "whichkey", "notify"])
|
|
1209
1437
|
set("ui", k, ui[k]);
|
|
1210
1438
|
set("worktree", "enabled", this.workspace.config.enabled);
|
|
1211
1439
|
set("worktree", "backend", this.workspace.config.backend ?? "auto");
|
|
@@ -1234,10 +1462,15 @@ export class App {
|
|
|
1234
1462
|
this.setMessage(`${info.name} isn't installed: ${info.install} · :checkhealth`, "ErrorMsg");
|
|
1235
1463
|
return undefined;
|
|
1236
1464
|
}
|
|
1465
|
+
// auto lands on an engine that isn't ready only when none is; an agent started there could never answer
|
|
1466
|
+
if (!opts.engine && this.preferredEngine === "auto" && info && !info.connected && !info.checking) {
|
|
1467
|
+
this.setMessage(this.noEngineReady(), "ErrorMsg");
|
|
1468
|
+
return undefined;
|
|
1469
|
+
}
|
|
1237
1470
|
const withWorktree = opts.worktree !== false && this.workspace.config.enabled;
|
|
1238
1471
|
try {
|
|
1239
1472
|
const model = opts.model ?? this.newAgentModel[engine];
|
|
1240
|
-
const effort = this.newAgentEffort[engine];
|
|
1473
|
+
const effort = opts.effort ?? this.newAgentEffort[engine];
|
|
1241
1474
|
const s = this.newSession(name, { worktree: withWorktree, model, engine, ...(effort ? { effort } : {}) });
|
|
1242
1475
|
this.addSession(s);
|
|
1243
1476
|
const wt = s.worktree;
|
|
@@ -1262,6 +1495,14 @@ export class App {
|
|
|
1262
1495
|
return undefined;
|
|
1263
1496
|
}
|
|
1264
1497
|
}
|
|
1498
|
+
/** "no engine is signed in: run claude auth login or codex login, or for pi export ANTHROPIC_API_KEY=… · :checkhealth" */
|
|
1499
|
+
noEngineReady() {
|
|
1500
|
+
const engines = this.engines ?? [];
|
|
1501
|
+
const logins = engines.filter((e) => e.id !== "pi" && e.installed && e.connect).map((e) => e.connect);
|
|
1502
|
+
const pi = engines.find((e) => e.id === "pi")?.connect;
|
|
1503
|
+
const ways = [logins.length > 0 ? `run ${logins.join(" or ")}` : "", pi ? `for pi ${pi}` : ""].filter(Boolean).join(", or ");
|
|
1504
|
+
return `no engine is signed in${ways ? `: ${ways}` : ""} · :checkhealth`;
|
|
1505
|
+
}
|
|
1265
1506
|
/**
|
|
1266
1507
|
* `:theme save <name>`: what's on screen (the theme, your :hi changes,
|
|
1267
1508
|
* transparency) as ~/.config/vigent/themes/<name>.toml, then switch to it.
|
|
@@ -1409,7 +1650,9 @@ export class App {
|
|
|
1409
1650
|
[stateGroup(s.state), s.state.padEnd(8)],
|
|
1410
1651
|
["Comment", ` ${truncateToWidth(s.worktree ? `${I.worktree}${s.worktree.branch}` : s.model, 34, "…")}`],
|
|
1411
1652
|
...stat,
|
|
1653
|
+
["Comment", s.tokens ? ` ${formatUsage(s)}` : ""],
|
|
1412
1654
|
["Comment", s.queue.length ? ` ${I.queued}${s.queue.length}` : ""],
|
|
1655
|
+
[s.state === "error" ? "ErrorMsg" : "WarningMsg", this.unread.has(s) ? ` ${I.unread} new` : ""],
|
|
1413
1656
|
]);
|
|
1414
1657
|
lines.push([
|
|
1415
1658
|
["NormalFloat", " "],
|
|
@@ -1418,13 +1661,217 @@ export class App {
|
|
|
1418
1661
|
});
|
|
1419
1662
|
const running = this.sessions.filter((s) => s.state === "running").length;
|
|
1420
1663
|
lines.push([]);
|
|
1421
|
-
|
|
1664
|
+
const total = totalUsage(this.sessions);
|
|
1665
|
+
lines.push([
|
|
1666
|
+
["Comment", `${this.sessions.length} agents · ${running} running · `],
|
|
1667
|
+
["NormalFloat", `${formatUsage(total)} in all`],
|
|
1668
|
+
["Comment", " · j/k move · Enter open · :cost for more"],
|
|
1669
|
+
]);
|
|
1422
1670
|
this.openFloat("Dashboard", lines, Math.min(110, this.terminal.columns - 4), {
|
|
1423
1671
|
selectable: rows,
|
|
1424
1672
|
onSelect: (lineIdx) => this.focusSession(rows.indexOf(lineIdx)),
|
|
1425
1673
|
initial: rows[this.focusIdx],
|
|
1426
1674
|
});
|
|
1427
1675
|
}
|
|
1676
|
+
/**
|
|
1677
|
+
* Where a pi agent's extensions talk to you: confirm() is a y/n question in
|
|
1678
|
+
* the command line, select() and input() are pickers, notify() goes to the
|
|
1679
|
+
* transcript and the message line. Each names the agent it's from.
|
|
1680
|
+
*/
|
|
1681
|
+
piUiHost(s) {
|
|
1682
|
+
const who = () => `'${s.name}'`;
|
|
1683
|
+
// one line, and emoji in text style (U+FE0F makes terminals disagree on their width)
|
|
1684
|
+
const oneLine = (t) => t.replace(/\uFE0F/g, "").replace(/\s+/g, " ").trim();
|
|
1685
|
+
const lookHere = () => {
|
|
1686
|
+
if (s !== this.focusedSession || !this.terminalFocused)
|
|
1687
|
+
this.alert(alertSequence(`vigent: ${who()} asks you something`, this.ui.notify));
|
|
1688
|
+
};
|
|
1689
|
+
return {
|
|
1690
|
+
select: (title, options) => new Promise((resolve) => {
|
|
1691
|
+
lookHere();
|
|
1692
|
+
this.openChoices(`${who()} · ${oneLine(title)}`, options.map((value) => ({ value })), "", false, resolve, {
|
|
1693
|
+
onCancel: () => resolve(undefined),
|
|
1694
|
+
rows: Math.max(3, Math.min(10, options.length)),
|
|
1695
|
+
});
|
|
1696
|
+
}),
|
|
1697
|
+
confirm: (title, message) => new Promise((resolve) => {
|
|
1698
|
+
lookHere();
|
|
1699
|
+
const text = [title, message].filter(Boolean).map(oneLine).join(": ");
|
|
1700
|
+
this.ask(`${who()} ${text} (y/n)`, () => resolve(true), () => resolve(false));
|
|
1701
|
+
this.tui.requestRender();
|
|
1702
|
+
}),
|
|
1703
|
+
input: (title, placeholder) => new Promise((resolve) => {
|
|
1704
|
+
lookHere();
|
|
1705
|
+
this.openChoices(`${who()} · ${oneLine(title)}${placeholder ? ` (${oneLine(placeholder)})` : ""}`, [], "", true, resolve, { search: true, rows: 3, onCancel: () => resolve(undefined) });
|
|
1706
|
+
}),
|
|
1707
|
+
notify: (message, type) => {
|
|
1708
|
+
s.pushInfo(message);
|
|
1709
|
+
this.setMessage(`${who()}: ${message}`, type === "error" ? "ErrorMsg" : type === "warning" ? "WarningMsg" : "ModeMsg");
|
|
1710
|
+
this.tui.requestRender();
|
|
1711
|
+
},
|
|
1712
|
+
setStatus: (key, text) => s.setExtensionStatus?.(key, text),
|
|
1713
|
+
};
|
|
1714
|
+
}
|
|
1715
|
+
/** `:pi`: what pi loaded for the focused agent. Enter on a command, skill or prompt starts a prompt with it. */
|
|
1716
|
+
openPiResources() {
|
|
1717
|
+
const s = this.focusedSession;
|
|
1718
|
+
if (!s || !("resources" in s)) {
|
|
1719
|
+
this.setMessage(s ? `:pi lists a pi agent's extensions; '${s.name}' runs on ${ENGINE_NAMES[s.engine]}` : "no agent yet", "Comment");
|
|
1720
|
+
return;
|
|
1721
|
+
}
|
|
1722
|
+
const r = s.resources();
|
|
1723
|
+
if (!r) {
|
|
1724
|
+
this.setMessage(`'${s.name}' is still starting`, "Comment");
|
|
1725
|
+
return;
|
|
1726
|
+
}
|
|
1727
|
+
const lines = [];
|
|
1728
|
+
const rows = [];
|
|
1729
|
+
const typed = [];
|
|
1730
|
+
const home = process.env.HOME ?? "";
|
|
1731
|
+
const short = (p) => (home && p.startsWith(home) ? `~${p.slice(home.length)}` : this.shortPath(p));
|
|
1732
|
+
const section = (title, n) => {
|
|
1733
|
+
if (lines.length)
|
|
1734
|
+
lines.push([]);
|
|
1735
|
+
lines.push([["Title", title], ["Comment", ` ${n}`]]);
|
|
1736
|
+
};
|
|
1737
|
+
const item = (name, note, insert) => {
|
|
1738
|
+
if (insert) {
|
|
1739
|
+
rows.push(lines.length);
|
|
1740
|
+
typed.push(insert);
|
|
1741
|
+
}
|
|
1742
|
+
lines.push([
|
|
1743
|
+
["Identifier", ` ${name.padEnd(24)}`],
|
|
1744
|
+
["Comment", note ? ` ${truncateToWidth(note.replace(/\s+/g, " "), 60, "…")}` : ""],
|
|
1745
|
+
]);
|
|
1746
|
+
};
|
|
1747
|
+
section("Extensions", r.extensions.length);
|
|
1748
|
+
if (!r.extensions.length)
|
|
1749
|
+
lines.push([["Comment", " none (~/.pi/agent/extensions/ or .pi/extensions/)"]]);
|
|
1750
|
+
for (const e of r.extensions)
|
|
1751
|
+
lines.push(e.error ? [["ErrorMsg", ` ${short(e.path)}`], ["Comment", ` ${e.error}`]] : [["NormalFloat", ` ${short(e.path)}`]]);
|
|
1752
|
+
section("Commands", r.commands.length);
|
|
1753
|
+
for (const c of r.commands)
|
|
1754
|
+
item(`/${c.name}`, c.description, `/${c.name} `);
|
|
1755
|
+
section("Skills", r.skills.length);
|
|
1756
|
+
for (const k of r.skills)
|
|
1757
|
+
item(`/skill:${k.name}`, k.description, `/skill:${k.name} `);
|
|
1758
|
+
section("Prompt templates", r.prompts.length);
|
|
1759
|
+
for (const p of r.prompts)
|
|
1760
|
+
item(`/${p.name}`, p.description, `/${p.name} `);
|
|
1761
|
+
section("Context files", r.contextFiles.length);
|
|
1762
|
+
for (const f of r.contextFiles)
|
|
1763
|
+
lines.push([["NormalFloat", ` ${short(f)}`]]);
|
|
1764
|
+
lines.push([]);
|
|
1765
|
+
lines.push([["Comment", "Enter starts a prompt with it · pi loads these itself, as in pi"]]);
|
|
1766
|
+
this.openFloat(`pi · ${s.name}`, lines, Math.min(96, this.terminal.columns - 4), {
|
|
1767
|
+
selectable: rows.length ? rows : undefined,
|
|
1768
|
+
onSelect: (lineIdx) => {
|
|
1769
|
+
const text = typed[rows.indexOf(lineIdx)];
|
|
1770
|
+
if (!text)
|
|
1771
|
+
return;
|
|
1772
|
+
this.mode = "INSERT";
|
|
1773
|
+
this.inputBuf = text;
|
|
1774
|
+
},
|
|
1775
|
+
});
|
|
1776
|
+
}
|
|
1777
|
+
/** the `:serve` server, while it runs */
|
|
1778
|
+
server;
|
|
1779
|
+
/** starts it (replaced in tests) */
|
|
1780
|
+
startServer = startServer;
|
|
1781
|
+
/** `:serve [local] [port]` starts it (or shows where it is), `:serve stop` stops it. */
|
|
1782
|
+
async execServe(arg) {
|
|
1783
|
+
const words = arg.split(/\s+/).filter(Boolean);
|
|
1784
|
+
if (words[0] === "stop") {
|
|
1785
|
+
if (!this.server)
|
|
1786
|
+
return this.setMessage(":serve isn't running", "Comment");
|
|
1787
|
+
await this.server.close();
|
|
1788
|
+
this.server = undefined;
|
|
1789
|
+
this.notify(":serve stopped", "Comment");
|
|
1790
|
+
return;
|
|
1791
|
+
}
|
|
1792
|
+
if (this.server)
|
|
1793
|
+
return this.openServeInfo();
|
|
1794
|
+
const port = Number(words.find((w) => /^\d+$/.test(w)) ?? 8377);
|
|
1795
|
+
const local = words.includes("local");
|
|
1796
|
+
try {
|
|
1797
|
+
this.server = await this.startServer(this.serveHost(), { port, host: local ? "127.0.0.1" : "0.0.0.0" });
|
|
1798
|
+
}
|
|
1799
|
+
catch (err) {
|
|
1800
|
+
const why = err?.code === "EADDRINUSE" ? `port ${port} is taken (:serve ${port + 1})` : String(err?.message ?? err);
|
|
1801
|
+
return this.notify(`:serve couldn't start: ${why}`, "ErrorMsg");
|
|
1802
|
+
}
|
|
1803
|
+
this.openServeInfo();
|
|
1804
|
+
}
|
|
1805
|
+
serveHost() {
|
|
1806
|
+
return {
|
|
1807
|
+
sessions: () => this.sessions,
|
|
1808
|
+
project: () => path.basename(this.workspace.root ?? process.cwd()),
|
|
1809
|
+
palette: () => extractPalette(this.painter.theme),
|
|
1810
|
+
isUnread: (s) => this.unread.has(s),
|
|
1811
|
+
startAgent: (task) => this.startAgent(agentNameFrom(task)),
|
|
1812
|
+
notify: (text) => this.notify(text, "ModeMsg"),
|
|
1813
|
+
};
|
|
1814
|
+
}
|
|
1815
|
+
/** Where to open it and the access code. */
|
|
1816
|
+
openServeInfo() {
|
|
1817
|
+
const sv = this.server;
|
|
1818
|
+
if (!sv)
|
|
1819
|
+
return;
|
|
1820
|
+
const lines = [];
|
|
1821
|
+
lines.push([["Comment", "On your phone, open"]]);
|
|
1822
|
+
for (const u of sv.urls.slice(0, sv.urls.length > 1 ? -1 : undefined))
|
|
1823
|
+
lines.push([["Title", ` ${u}`]]);
|
|
1824
|
+
lines.push([["Comment", "and type the access code"]]);
|
|
1825
|
+
lines.push([["String", ` ${sv.code}`]]);
|
|
1826
|
+
lines.push([]);
|
|
1827
|
+
lines.push([["Comment", "Or open it with the code in the link:"]]);
|
|
1828
|
+
lines.push([["NormalFloat", ` ${sv.urls[0]}#${sv.code}`]]);
|
|
1829
|
+
lines.push([]);
|
|
1830
|
+
if (sv.host === "127.0.0.1")
|
|
1831
|
+
lines.push([["WarningMsg", "This machine only (:serve local). :serve stop, then :serve, for your phone."]]);
|
|
1832
|
+
else
|
|
1833
|
+
lines.push([["Comment", "Same Wi-Fi as this machine, or a VPN like Tailscale. Plain HTTP:"], ["WarningMsg", " not on public Wi-Fi."]]);
|
|
1834
|
+
lines.push([["Comment", "The page lists your agents, follows a reply as it streams, and sends prompts."]]);
|
|
1835
|
+
lines.push([["Comment", ":serve shows this again · :serve stop ends it (and the code)"]]);
|
|
1836
|
+
this.openFloat("Follow from your phone", lines, Math.min(84, this.terminal.columns - 4));
|
|
1837
|
+
}
|
|
1838
|
+
/** `:cost`: what each agent has used (tokens, and money where the engine reports it) and the total. */
|
|
1839
|
+
openUsage() {
|
|
1840
|
+
if (this.sessions.length === 0) {
|
|
1841
|
+
this.setMessage("no agents yet (:new <task> starts one)", "Comment");
|
|
1842
|
+
return;
|
|
1843
|
+
}
|
|
1844
|
+
const lines = [];
|
|
1845
|
+
const rows = [];
|
|
1846
|
+
const cell = (s, w) => truncateToWidth(s, w - 1, "…").padEnd(w);
|
|
1847
|
+
lines.push([["Comment", `${cell("agent", 30)}${cell("engine", 13)}${"tokens".padStart(11)}${"cost".padStart(10)}`]]);
|
|
1848
|
+
this.sessions.forEach((s) => {
|
|
1849
|
+
rows.push(lines.length);
|
|
1850
|
+
lines.push([
|
|
1851
|
+
["Title", cell(s.name, 30)],
|
|
1852
|
+
["Comment", cell(ENGINE_NAMES[s.engine], 13)],
|
|
1853
|
+
["NormalFloat", formatTokens(s.tokens).replace(/ tok$/, "").padStart(11)],
|
|
1854
|
+
[s.cost === undefined ? "Comment" : "String", (s.cost === undefined ? "—" : formatCost(s.cost)).padStart(10)],
|
|
1855
|
+
]);
|
|
1856
|
+
});
|
|
1857
|
+
const total = totalUsage(this.sessions);
|
|
1858
|
+
lines.push([["FloatBorder", "─".repeat(64)]]);
|
|
1859
|
+
lines.push([
|
|
1860
|
+
["Title", cell("all agents", 43)],
|
|
1861
|
+
["NormalFloat", formatTokens(total.tokens).replace(/ tok$/, "").padStart(11)],
|
|
1862
|
+
["String", (total.cost === undefined ? "—" : formatCost(total.cost)).padStart(10)],
|
|
1863
|
+
]);
|
|
1864
|
+
lines.push([]);
|
|
1865
|
+
lines.push([["Comment", "Costs are as each engine reports them. Claude Code prices its"]]);
|
|
1866
|
+
lines.push([["Comment", "tokens at API rates, even on a subscription."]]);
|
|
1867
|
+
if (total.unpriced)
|
|
1868
|
+
lines.push([["Comment", "Codex reports no price (—); its tokens are counted."]]);
|
|
1869
|
+
this.openFloat("Usage", lines, Math.min(72, this.terminal.columns - 4), {
|
|
1870
|
+
selectable: rows,
|
|
1871
|
+
onSelect: (lineIdx) => this.focusSession(rows.indexOf(lineIdx)),
|
|
1872
|
+
initial: rows[this.focusIdx],
|
|
1873
|
+
});
|
|
1874
|
+
}
|
|
1428
1875
|
// ---------------------------------------------------------------- visual & yank
|
|
1429
1876
|
/** First and last+1 pane lines on screen. */
|
|
1430
1877
|
viewRange(total) {
|
|
@@ -1479,6 +1926,7 @@ export class App {
|
|
|
1479
1926
|
s.abort();
|
|
1480
1927
|
s.dispose();
|
|
1481
1928
|
this.sessions.splice(this.focusIdx, 1);
|
|
1929
|
+
this.server?.sync();
|
|
1482
1930
|
this.focusIdx = Math.max(0, Math.min(this.focusIdx, this.sessions.length - 1));
|
|
1483
1931
|
const wt = s.worktree;
|
|
1484
1932
|
if (!wt?.path || !this.workspace.root) {
|
|
@@ -1596,6 +2044,49 @@ export class App {
|
|
|
1596
2044
|
this.openFloat("Worktrees", lines, 112);
|
|
1597
2045
|
}
|
|
1598
2046
|
/** `:diff`: what the focused agent changed since its worktree was created. */
|
|
2047
|
+
/** `:review`: keep or drop the focused agent's changes hunk by hunk. */
|
|
2048
|
+
openReview() {
|
|
2049
|
+
const s = this.focusedSession;
|
|
2050
|
+
const wt = s?.worktree;
|
|
2051
|
+
if (!s || !wt?.path) {
|
|
2052
|
+
this.setMessage(s ? `'${s.name}' has no worktree to review` : "no agent", "ErrorMsg");
|
|
2053
|
+
return;
|
|
2054
|
+
}
|
|
2055
|
+
if (s.state === "running") {
|
|
2056
|
+
this.setMessage(`'${s.name}' is still working; review it when it's done (or :pause it)`, "WarningMsg");
|
|
2057
|
+
return;
|
|
2058
|
+
}
|
|
2059
|
+
const load = async () => parseDiff(await diffText(wt, Number.POSITIVE_INFINITY));
|
|
2060
|
+
load()
|
|
2061
|
+
.then((files) => {
|
|
2062
|
+
if (files.length === 0) {
|
|
2063
|
+
this.setMessage(`no changes on ${wt.branch} yet`, "Comment");
|
|
2064
|
+
this.tui.requestRender();
|
|
2065
|
+
return;
|
|
2066
|
+
}
|
|
2067
|
+
this.seq.reset();
|
|
2068
|
+
let handle;
|
|
2069
|
+
const after = async () => {
|
|
2070
|
+
s.refreshStat(0);
|
|
2071
|
+
return load();
|
|
2072
|
+
};
|
|
2073
|
+
const review = new HunkReview(this.painter, PICKER_ICONS[this.ui.icons], `Review ${wt.branch} vs ${wt.base ?? "base"}`, files, () => this.terminal.rows - 2, {
|
|
2074
|
+
drop: (patch) => dropPatch(wt.path, patch).then(after),
|
|
2075
|
+
restore: (patch) => restorePatch(wt.path, patch).then(after),
|
|
2076
|
+
close: () => {
|
|
2077
|
+
handle?.hide();
|
|
2078
|
+
this.repaint();
|
|
2079
|
+
},
|
|
2080
|
+
requestRender: () => this.tui.requestRender(),
|
|
2081
|
+
});
|
|
2082
|
+
handle = this.tui.showOverlay(review, { width: this.terminal.columns - 4, anchor: "center" });
|
|
2083
|
+
this.tui.requestRender();
|
|
2084
|
+
})
|
|
2085
|
+
.catch((err) => {
|
|
2086
|
+
this.setMessage(`git diff failed: ${err.message.split("\n")[0]}`, "ErrorMsg");
|
|
2087
|
+
this.tui.requestRender();
|
|
2088
|
+
});
|
|
2089
|
+
}
|
|
1599
2090
|
openDiff(split = false) {
|
|
1600
2091
|
const s = this.focusedSession;
|
|
1601
2092
|
const wt = s?.worktree;
|
|
@@ -1974,9 +2465,15 @@ export class App {
|
|
|
1974
2465
|
case "e":
|
|
1975
2466
|
this.openEffortPicker();
|
|
1976
2467
|
return true;
|
|
2468
|
+
case "E":
|
|
2469
|
+
this.openEnginePicker();
|
|
2470
|
+
return true;
|
|
1977
2471
|
case "c":
|
|
1978
2472
|
this.openHealth();
|
|
1979
2473
|
return true;
|
|
2474
|
+
case "T":
|
|
2475
|
+
this.openTemplatePicker();
|
|
2476
|
+
return true;
|
|
1980
2477
|
case "?":
|
|
1981
2478
|
this.openHelp();
|
|
1982
2479
|
return true;
|
|
@@ -2053,7 +2550,8 @@ export class App {
|
|
|
2053
2550
|
const left = s ? ` ${this.icons.open_agent} ${s.name}` : ` ${this.icons.open_agent} [no agent]`;
|
|
2054
2551
|
const model = s ? ` ${s.engine !== "pi" && s.model !== ENGINE_NAMES[s.engine] ? `${ENGINE_NAMES[s.engine]} · ` : ""}${s.model}${s.effort ? ` · ${s.effort}` : ""}` : "";
|
|
2055
2552
|
const where = s?.worktree?.path ? `${this.shortPath(s.worktree.path)}${s.worktree.riftri ? " · riftri" : ""}` : s ? "this checkout" : "";
|
|
2056
|
-
const
|
|
2553
|
+
const ext = s && "extensionStatus" in s ? [...s.extensionStatus.values()].join(" · ") : "";
|
|
2554
|
+
const status = (s?.state === "paused" ? " paused" : s?.queue.length ? ` ${this.icons.queued}${s.queue.length} queued` : "") + (ext ? ` ${ext}` : "");
|
|
2057
2555
|
// the path gives way first (from its start: the end names the worktree), always two spaces before it
|
|
2058
2556
|
const pathRoom = w - visibleWidth(left) - visibleWidth(model) - visibleWidth(status) - 3;
|
|
2059
2557
|
const shownWhere = !where ? "" : visibleWidth(where) <= pathRoom ? where : pathRoom > 4 ? `…${where.slice(where.length - pathRoom + 1)}` : "";
|
|
@@ -2177,6 +2675,7 @@ export class App {
|
|
|
2177
2675
|
[glyphGroup, `${glyph} `],
|
|
2178
2676
|
[focused ? "Title" : "VigentSidebar", s.name],
|
|
2179
2677
|
["Comment", s.queue.length > 0 ? ` ${I.queued}${s.queue.length}` : ""],
|
|
2678
|
+
[s.state === "error" ? "ErrorMsg" : "WarningMsg", this.unread.has(s) ? ` ${I.unread}` : ""],
|
|
2180
2679
|
], rowBg));
|
|
2181
2680
|
const model = s.model.replace(/^claude-/, "");
|
|
2182
2681
|
const indent = [
|
|
@@ -2217,7 +2716,7 @@ export class App {
|
|
|
2217
2716
|
? e.id === def
|
|
2218
2717
|
? "default"
|
|
2219
2718
|
: ""
|
|
2220
|
-
: e.id === "pi"
|
|
2719
|
+
: e.id === "pi" && e.installed
|
|
2221
2720
|
? "no API key"
|
|
2222
2721
|
: e.installed
|
|
2223
2722
|
? (e.connect ?? "not signed in")
|
|
@@ -2264,7 +2763,14 @@ export class App {
|
|
|
2264
2763
|
case "user": {
|
|
2265
2764
|
push("", "");
|
|
2266
2765
|
wrapTextWithAnsi(item.text, paneW - 2).forEach((l, i) => {
|
|
2267
|
-
|
|
2766
|
+
const painted = l
|
|
2767
|
+
.replace(MENTION, (m) => `\0${m}\0`)
|
|
2768
|
+
.split("\0")
|
|
2769
|
+
.map((part, i) => (!part ? "" : P.p(i % 2 ? "VigentMention" : "Keyword", part)))
|
|
2770
|
+
.join("");
|
|
2771
|
+
// the ❯ marks where a prompt starts; its other lines hang under it
|
|
2772
|
+
const lead = i === 0 ? I.prompt : " ".repeat(visibleWidth(I.prompt));
|
|
2773
|
+
push(P.p("VigentUser", lead) + P.s("Normal") + painted, lead + stripAnsi(l), {
|
|
2268
2774
|
item: n,
|
|
2269
2775
|
start: i === 0,
|
|
2270
2776
|
});
|
|
@@ -2318,8 +2824,10 @@ export class App {
|
|
|
2318
2824
|
const changes = st && (st.added || st.removed) ? ` +${st.added} ${this.icons.minus}${st.removed}` : "";
|
|
2319
2825
|
const worktree = s?.worktree ? `${this.icons.worktree}${s.worktree.branch}${changes} ` : "";
|
|
2320
2826
|
const right = s
|
|
2321
|
-
? ` ${s.model} ${
|
|
2322
|
-
:
|
|
2827
|
+
? ` ${this.server ? this.icons.serve : ""}${s.model} ${formatUsage(s)} ${running}/${this.sessions.length}${running > 0 ? ` ${this.spinner()}` : ""} `
|
|
2828
|
+
: this.server
|
|
2829
|
+
? ` ${this.icons.serve}serving `
|
|
2830
|
+
: "";
|
|
2323
2831
|
const plainLen = visibleWidth(` ${this.mode} `) + (this.demo ? 6 : 0) + visibleWidth(leftText) + visibleWidth(worktree) + visibleWidth(right);
|
|
2324
2832
|
const fill = Math.max(0, width - plainLen);
|
|
2325
2833
|
return (modeBadge +
|
|
@@ -2429,19 +2937,25 @@ function describeRhs(rhs) {
|
|
|
2429
2937
|
}
|
|
2430
2938
|
const EX_HELP = [
|
|
2431
2939
|
[":new [task]", "start an agent in its own worktree"],
|
|
2940
|
+
[":new -t <template> <task>", "start one from a template and send it (:templates picks)"],
|
|
2432
2941
|
[":kill / :kill!", "stop the agent (keep / remove its worktree)"],
|
|
2433
2942
|
[":new! [name]", "agent in this checkout, no worktree"],
|
|
2434
2943
|
[":diff / :diff split", "the agent's changes, unified / side by side (s switches)"],
|
|
2944
|
+
[":review", "keep or drop the agent's changes hunk by hunk"],
|
|
2435
2945
|
[":merge [--no-squash]", "squash-merge the agent's branch into yours"],
|
|
2436
2946
|
[":pr", "push the branch and open a pull request"],
|
|
2437
2947
|
[":queue <prompt>", "send after the current run (:queue lists)"],
|
|
2438
2948
|
[":pause / :resume", "hold the agent and its queue / continue"],
|
|
2439
2949
|
[":dash", "all agents at a glance"],
|
|
2950
|
+
[":cost", "tokens and money each agent has used, and the total"],
|
|
2951
|
+
[":pi", "a pi agent's extensions, commands, skills and prompt templates"],
|
|
2952
|
+
[":serve / :serve stop", "follow and prompt your agents from your phone (local: this machine only)"],
|
|
2440
2953
|
[":tree", "the conversation's branches; go back and branch"],
|
|
2441
2954
|
[":new [task] --engine claude", "run the agent with Claude Code, codex, opencode or pi"],
|
|
2442
2955
|
[":checkhealth", "which engines are installed and signed in"],
|
|
2443
2956
|
[":model [name]", "pick the agent's model (no agent: for new agents)"],
|
|
2444
2957
|
[":effort [level]", "pick its reasoning effort: low … max (pi: thinking level)"],
|
|
2958
|
+
[":engine [name]", "pick the engine new agents run on: auto, pi, claude, codex, opencode"],
|
|
2445
2959
|
[":settings", "theme, look, engine, worktrees and keys in one place (w saves)"],
|
|
2446
2960
|
[":w [file]", "save the transcript as Markdown"],
|
|
2447
2961
|
[":worktrees", "list agent worktrees"],
|
|
@@ -2485,11 +2999,39 @@ function describeAttr(a) {
|
|
|
2485
2999
|
parts.push(`gui=${flags.join(",")}`);
|
|
2486
3000
|
return parts.join(" ") || "cleared";
|
|
2487
3001
|
}
|
|
2488
|
-
function formatTokens(n) {
|
|
3002
|
+
export function formatTokens(n) {
|
|
3003
|
+
if (n >= 1_000_000)
|
|
3004
|
+
return `${(n / 1_000_000).toFixed(1)}M tok`;
|
|
2489
3005
|
if (n >= 1000)
|
|
2490
3006
|
return `${(n / 1000).toFixed(1)}k tok`;
|
|
2491
3007
|
return `${n} tok`;
|
|
2492
3008
|
}
|
|
3009
|
+
/** US dollars: cents, or a tenth of a cent below one cent */
|
|
3010
|
+
export function formatCost(usd) {
|
|
3011
|
+
if (usd > 0 && usd < 0.01)
|
|
3012
|
+
return `$${usd.toFixed(3)}`;
|
|
3013
|
+
return `$${usd.toFixed(2)}`;
|
|
3014
|
+
}
|
|
3015
|
+
/** "12.4k tok · $0.31", or just the tokens when the engine reports no price */
|
|
3016
|
+
export function formatUsage(u) {
|
|
3017
|
+
return u.cost === undefined ? formatTokens(u.tokens) : `${formatTokens(u.tokens)} · ${formatCost(u.cost)}`;
|
|
3018
|
+
}
|
|
3019
|
+
/** every agent's usage added up; cost counts the agents that report one */
|
|
3020
|
+
export function totalUsage(sessions) {
|
|
3021
|
+
let tokens = 0;
|
|
3022
|
+
let cost;
|
|
3023
|
+
let unpriced = 0;
|
|
3024
|
+
for (const s of sessions) {
|
|
3025
|
+
tokens += s.tokens;
|
|
3026
|
+
if (s.cost === undefined) {
|
|
3027
|
+
if (s.tokens)
|
|
3028
|
+
unpriced++;
|
|
3029
|
+
}
|
|
3030
|
+
else
|
|
3031
|
+
cost = (cost ?? 0) + s.cost;
|
|
3032
|
+
}
|
|
3033
|
+
return { tokens, cost, unpriced };
|
|
3034
|
+
}
|
|
2493
3035
|
/** A short agent name from a prompt: its first words, without punctuation ("Fix the flaky checkout test, please" -> "fix the flaky checkout test"). */
|
|
2494
3036
|
export function agentNameFrom(prompt) {
|
|
2495
3037
|
const words = prompt
|
|
@@ -2516,10 +3058,12 @@ const LOGO = [
|
|
|
2516
3058
|
const DASHBOARD = [
|
|
2517
3059
|
["i", "✎", "New agent: type a task, Enter", NERD.pencil],
|
|
2518
3060
|
["n", "+", "New agent by name", NERD.plus],
|
|
3061
|
+
["T", "▤", "New agent from a template", NERD.fileText],
|
|
2519
3062
|
["t", "◐", "Theme", NERD.palette],
|
|
2520
3063
|
["s", "⚙", "Settings", NERD.gear],
|
|
2521
3064
|
["m", "◆", "Model", NERD.chip],
|
|
2522
3065
|
["e", "↯", "Effort", NERD.bolt],
|
|
3066
|
+
["E", "⚑", "Engine: pi, Claude Code, Codex, OpenCode", NERD.wrench],
|
|
2523
3067
|
["c", "✓", "Check engines", NERD.check],
|
|
2524
3068
|
["?", "⌘", "Keys", NERD.keyboard],
|
|
2525
3069
|
["q", "×", "Quit", NERD.power],
|