@vigent/cli 0.2.3 → 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 +85 -27
- package/dist/agents.js +29 -0
- package/dist/app.js +521 -20
- 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 +3 -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 +21 -11
- package/dist/serve-page.js +247 -0
- package/dist/serve.js +267 -0
- package/dist/session.js +122 -11
- 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,6 +15,13 @@ 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
27
|
import { defaultEngine as defaultEngineFor, ENGINE_NAMES, isEngineId } from "./engines.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;
|
|
@@ -1054,20 +1147,110 @@ export class App {
|
|
|
1054
1147
|
return this.newAgentModel[t.engine];
|
|
1055
1148
|
return "chosenModel" in t.session ? t.session.chosenModel : t.session.model;
|
|
1056
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
|
+
}
|
|
1057
1204
|
/** where effort levels come from (replaced in tests) */
|
|
1058
1205
|
effortChoices = (engine, model) => effortChoicesFor(engine, model);
|
|
1059
1206
|
/** where model lists come from (replaced in tests) */
|
|
1060
1207
|
modelChoices = (engine) => modelChoices(engine);
|
|
1061
|
-
|
|
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" }) {
|
|
1062
1234
|
this.seq.reset();
|
|
1063
1235
|
let handle;
|
|
1064
1236
|
const picker = new ChoicePicker(this.painter, PICKER_ICONS[this.ui.icons], title, items, current, custom, (v) => {
|
|
1065
1237
|
handle?.hide();
|
|
1066
1238
|
if (v !== null)
|
|
1067
1239
|
done(v);
|
|
1240
|
+
else
|
|
1241
|
+
opts.onCancel?.();
|
|
1068
1242
|
this.repaint();
|
|
1069
|
-
}, () => this.tui.requestRender()
|
|
1070
|
-
|
|
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);
|
|
1071
1254
|
this.tui.requestRender();
|
|
1072
1255
|
}
|
|
1073
1256
|
/** `:settings`: theme, look, agents and keys in one float; w saves them to config.toml. */
|
|
@@ -1127,7 +1310,7 @@ export class App {
|
|
|
1127
1310
|
change: (d) => {
|
|
1128
1311
|
this.preferredEngine = cycle(["auto", "pi", "claude", "codex", "opencode"], this.preferredEngine, d);
|
|
1129
1312
|
},
|
|
1130
|
-
hint: "auto:
|
|
1313
|
+
hint: "auto: the first signed-in CLI, else pi with an API key · :engine picks it from anywhere",
|
|
1131
1314
|
},
|
|
1132
1315
|
{
|
|
1133
1316
|
section: "Agents",
|
|
@@ -1162,6 +1345,13 @@ export class App {
|
|
|
1162
1345
|
change: (d) => (wt.backend = cycle(["auto", "riftri", "git"], wt.backend ?? "auto", d)),
|
|
1163
1346
|
hint: "riftri: copy-on-write worktrees that share unchanged files (riftri.dev)",
|
|
1164
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
|
+
},
|
|
1165
1355
|
{
|
|
1166
1356
|
section: "Keys",
|
|
1167
1357
|
label: "which-key popup",
|
|
@@ -1243,7 +1433,7 @@ export class App {
|
|
|
1243
1433
|
set(undefined, "theme", this.painter.theme.name);
|
|
1244
1434
|
set(undefined, "engine", this.preferredEngine);
|
|
1245
1435
|
set(undefined, "leader", this.keymap.leader);
|
|
1246
|
-
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"])
|
|
1247
1437
|
set("ui", k, ui[k]);
|
|
1248
1438
|
set("worktree", "enabled", this.workspace.config.enabled);
|
|
1249
1439
|
set("worktree", "backend", this.workspace.config.backend ?? "auto");
|
|
@@ -1272,10 +1462,15 @@ export class App {
|
|
|
1272
1462
|
this.setMessage(`${info.name} isn't installed: ${info.install} · :checkhealth`, "ErrorMsg");
|
|
1273
1463
|
return undefined;
|
|
1274
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
|
+
}
|
|
1275
1470
|
const withWorktree = opts.worktree !== false && this.workspace.config.enabled;
|
|
1276
1471
|
try {
|
|
1277
1472
|
const model = opts.model ?? this.newAgentModel[engine];
|
|
1278
|
-
const effort = this.newAgentEffort[engine];
|
|
1473
|
+
const effort = opts.effort ?? this.newAgentEffort[engine];
|
|
1279
1474
|
const s = this.newSession(name, { worktree: withWorktree, model, engine, ...(effort ? { effort } : {}) });
|
|
1280
1475
|
this.addSession(s);
|
|
1281
1476
|
const wt = s.worktree;
|
|
@@ -1300,6 +1495,14 @@ export class App {
|
|
|
1300
1495
|
return undefined;
|
|
1301
1496
|
}
|
|
1302
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
|
+
}
|
|
1303
1506
|
/**
|
|
1304
1507
|
* `:theme save <name>`: what's on screen (the theme, your :hi changes,
|
|
1305
1508
|
* transparency) as ~/.config/vigent/themes/<name>.toml, then switch to it.
|
|
@@ -1447,7 +1650,9 @@ export class App {
|
|
|
1447
1650
|
[stateGroup(s.state), s.state.padEnd(8)],
|
|
1448
1651
|
["Comment", ` ${truncateToWidth(s.worktree ? `${I.worktree}${s.worktree.branch}` : s.model, 34, "…")}`],
|
|
1449
1652
|
...stat,
|
|
1653
|
+
["Comment", s.tokens ? ` ${formatUsage(s)}` : ""],
|
|
1450
1654
|
["Comment", s.queue.length ? ` ${I.queued}${s.queue.length}` : ""],
|
|
1655
|
+
[s.state === "error" ? "ErrorMsg" : "WarningMsg", this.unread.has(s) ? ` ${I.unread} new` : ""],
|
|
1451
1656
|
]);
|
|
1452
1657
|
lines.push([
|
|
1453
1658
|
["NormalFloat", " "],
|
|
@@ -1456,13 +1661,217 @@ export class App {
|
|
|
1456
1661
|
});
|
|
1457
1662
|
const running = this.sessions.filter((s) => s.state === "running").length;
|
|
1458
1663
|
lines.push([]);
|
|
1459
|
-
|
|
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
|
+
]);
|
|
1460
1670
|
this.openFloat("Dashboard", lines, Math.min(110, this.terminal.columns - 4), {
|
|
1461
1671
|
selectable: rows,
|
|
1462
1672
|
onSelect: (lineIdx) => this.focusSession(rows.indexOf(lineIdx)),
|
|
1463
1673
|
initial: rows[this.focusIdx],
|
|
1464
1674
|
});
|
|
1465
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
|
+
}
|
|
1466
1875
|
// ---------------------------------------------------------------- visual & yank
|
|
1467
1876
|
/** First and last+1 pane lines on screen. */
|
|
1468
1877
|
viewRange(total) {
|
|
@@ -1517,6 +1926,7 @@ export class App {
|
|
|
1517
1926
|
s.abort();
|
|
1518
1927
|
s.dispose();
|
|
1519
1928
|
this.sessions.splice(this.focusIdx, 1);
|
|
1929
|
+
this.server?.sync();
|
|
1520
1930
|
this.focusIdx = Math.max(0, Math.min(this.focusIdx, this.sessions.length - 1));
|
|
1521
1931
|
const wt = s.worktree;
|
|
1522
1932
|
if (!wt?.path || !this.workspace.root) {
|
|
@@ -1634,6 +2044,49 @@ export class App {
|
|
|
1634
2044
|
this.openFloat("Worktrees", lines, 112);
|
|
1635
2045
|
}
|
|
1636
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
|
+
}
|
|
1637
2090
|
openDiff(split = false) {
|
|
1638
2091
|
const s = this.focusedSession;
|
|
1639
2092
|
const wt = s?.worktree;
|
|
@@ -2018,6 +2471,9 @@ export class App {
|
|
|
2018
2471
|
case "c":
|
|
2019
2472
|
this.openHealth();
|
|
2020
2473
|
return true;
|
|
2474
|
+
case "T":
|
|
2475
|
+
this.openTemplatePicker();
|
|
2476
|
+
return true;
|
|
2021
2477
|
case "?":
|
|
2022
2478
|
this.openHelp();
|
|
2023
2479
|
return true;
|
|
@@ -2094,7 +2550,8 @@ export class App {
|
|
|
2094
2550
|
const left = s ? ` ${this.icons.open_agent} ${s.name}` : ` ${this.icons.open_agent} [no agent]`;
|
|
2095
2551
|
const model = s ? ` ${s.engine !== "pi" && s.model !== ENGINE_NAMES[s.engine] ? `${ENGINE_NAMES[s.engine]} · ` : ""}${s.model}${s.effort ? ` · ${s.effort}` : ""}` : "";
|
|
2096
2552
|
const where = s?.worktree?.path ? `${this.shortPath(s.worktree.path)}${s.worktree.riftri ? " · riftri" : ""}` : s ? "this checkout" : "";
|
|
2097
|
-
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}` : "");
|
|
2098
2555
|
// the path gives way first (from its start: the end names the worktree), always two spaces before it
|
|
2099
2556
|
const pathRoom = w - visibleWidth(left) - visibleWidth(model) - visibleWidth(status) - 3;
|
|
2100
2557
|
const shownWhere = !where ? "" : visibleWidth(where) <= pathRoom ? where : pathRoom > 4 ? `…${where.slice(where.length - pathRoom + 1)}` : "";
|
|
@@ -2218,6 +2675,7 @@ export class App {
|
|
|
2218
2675
|
[glyphGroup, `${glyph} `],
|
|
2219
2676
|
[focused ? "Title" : "VigentSidebar", s.name],
|
|
2220
2677
|
["Comment", s.queue.length > 0 ? ` ${I.queued}${s.queue.length}` : ""],
|
|
2678
|
+
[s.state === "error" ? "ErrorMsg" : "WarningMsg", this.unread.has(s) ? ` ${I.unread}` : ""],
|
|
2221
2679
|
], rowBg));
|
|
2222
2680
|
const model = s.model.replace(/^claude-/, "");
|
|
2223
2681
|
const indent = [
|
|
@@ -2258,7 +2716,7 @@ export class App {
|
|
|
2258
2716
|
? e.id === def
|
|
2259
2717
|
? "default"
|
|
2260
2718
|
: ""
|
|
2261
|
-
: e.id === "pi"
|
|
2719
|
+
: e.id === "pi" && e.installed
|
|
2262
2720
|
? "no API key"
|
|
2263
2721
|
: e.installed
|
|
2264
2722
|
? (e.connect ?? "not signed in")
|
|
@@ -2305,7 +2763,14 @@ export class App {
|
|
|
2305
2763
|
case "user": {
|
|
2306
2764
|
push("", "");
|
|
2307
2765
|
wrapTextWithAnsi(item.text, paneW - 2).forEach((l, i) => {
|
|
2308
|
-
|
|
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), {
|
|
2309
2774
|
item: n,
|
|
2310
2775
|
start: i === 0,
|
|
2311
2776
|
});
|
|
@@ -2359,8 +2824,10 @@ export class App {
|
|
|
2359
2824
|
const changes = st && (st.added || st.removed) ? ` +${st.added} ${this.icons.minus}${st.removed}` : "";
|
|
2360
2825
|
const worktree = s?.worktree ? `${this.icons.worktree}${s.worktree.branch}${changes} ` : "";
|
|
2361
2826
|
const right = s
|
|
2362
|
-
? ` ${s.model} ${
|
|
2363
|
-
:
|
|
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
|
+
: "";
|
|
2364
2831
|
const plainLen = visibleWidth(` ${this.mode} `) + (this.demo ? 6 : 0) + visibleWidth(leftText) + visibleWidth(worktree) + visibleWidth(right);
|
|
2365
2832
|
const fill = Math.max(0, width - plainLen);
|
|
2366
2833
|
return (modeBadge +
|
|
@@ -2470,14 +2937,19 @@ function describeRhs(rhs) {
|
|
|
2470
2937
|
}
|
|
2471
2938
|
const EX_HELP = [
|
|
2472
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)"],
|
|
2473
2941
|
[":kill / :kill!", "stop the agent (keep / remove its worktree)"],
|
|
2474
2942
|
[":new! [name]", "agent in this checkout, no worktree"],
|
|
2475
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"],
|
|
2476
2945
|
[":merge [--no-squash]", "squash-merge the agent's branch into yours"],
|
|
2477
2946
|
[":pr", "push the branch and open a pull request"],
|
|
2478
2947
|
[":queue <prompt>", "send after the current run (:queue lists)"],
|
|
2479
2948
|
[":pause / :resume", "hold the agent and its queue / continue"],
|
|
2480
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)"],
|
|
2481
2953
|
[":tree", "the conversation's branches; go back and branch"],
|
|
2482
2954
|
[":new [task] --engine claude", "run the agent with Claude Code, codex, opencode or pi"],
|
|
2483
2955
|
[":checkhealth", "which engines are installed and signed in"],
|
|
@@ -2527,11 +2999,39 @@ function describeAttr(a) {
|
|
|
2527
2999
|
parts.push(`gui=${flags.join(",")}`);
|
|
2528
3000
|
return parts.join(" ") || "cleared";
|
|
2529
3001
|
}
|
|
2530
|
-
function formatTokens(n) {
|
|
3002
|
+
export function formatTokens(n) {
|
|
3003
|
+
if (n >= 1_000_000)
|
|
3004
|
+
return `${(n / 1_000_000).toFixed(1)}M tok`;
|
|
2531
3005
|
if (n >= 1000)
|
|
2532
3006
|
return `${(n / 1000).toFixed(1)}k tok`;
|
|
2533
3007
|
return `${n} tok`;
|
|
2534
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
|
+
}
|
|
2535
3035
|
/** A short agent name from a prompt: its first words, without punctuation ("Fix the flaky checkout test, please" -> "fix the flaky checkout test"). */
|
|
2536
3036
|
export function agentNameFrom(prompt) {
|
|
2537
3037
|
const words = prompt
|
|
@@ -2558,6 +3058,7 @@ const LOGO = [
|
|
|
2558
3058
|
const DASHBOARD = [
|
|
2559
3059
|
["i", "✎", "New agent: type a task, Enter", NERD.pencil],
|
|
2560
3060
|
["n", "+", "New agent by name", NERD.plus],
|
|
3061
|
+
["T", "▤", "New agent from a template", NERD.fileText],
|
|
2561
3062
|
["t", "◐", "Theme", NERD.palette],
|
|
2562
3063
|
["s", "⚙", "Settings", NERD.gear],
|
|
2563
3064
|
["m", "◆", "Model", NERD.chip],
|