@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/dist/app.js CHANGED
@@ -6,7 +6,7 @@
6
6
  * │ statusline │
7
7
  * └ cmdline ┘
8
8
  */
9
- import { truncateToWidth, visibleWidth, wrapTextWithAnsi, } from "@mariozechner/pi-tui";
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 = { question, onYes };
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 name = arg
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
- .trim() || `agent-${this.sessions.length + 1}`;
699
- this.startAgent(name, { worktree: cmd === "new", model: modelFlag?.[1], engine: engineFlag?.[1] });
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
- openChoices(title, items, current, custom, done) {
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
- handle = this.tui.showOverlay(picker, { width: Math.min(72, this.terminal.columns - 4), anchor: "center" });
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: pi with an API key, else the first signed-in CLI · :engine picks it from anywhere",
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
- lines.push([["Comment", `${this.sessions.length} agents · ${running} running · j/k move · Enter open`]]);
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 status = s?.state === "paused" ? " paused" : s?.queue.length ? ` ${this.icons.queued}${s.queue.length} queued` : "";
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
- push(P.p("VigentUser", I.prompt) + P.s("Normal") + P.p("Keyword", l), I.prompt + stripAnsi(l), {
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} ${formatTokens(s.tokens)} ${running}/${this.sessions.length}${running > 0 ? ` ${this.spinner()}` : ""} `
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],