@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/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,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 = { 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;
@@ -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
- 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" }) {
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
- 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);
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: pi with an API key, else the first signed-in CLI · :checkhealth shows which are ready",
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
- 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
+ ]);
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 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}` : "");
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
- 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), {
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} ${formatTokens(s.tokens)} ${running}/${this.sessions.length}${running > 0 ? ` ${this.spinner()}` : ""} `
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],