@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/serve.js ADDED
@@ -0,0 +1,267 @@
1
+ /**
2
+ * `:serve`: follow your agents from your phone.
3
+ *
4
+ * A small HTTP server on your machine: a page that lists the agents and shows
5
+ * a transcript as it streams, where you can send a prompt or stop an agent.
6
+ * It starts only when you ask (`:serve`, or `vigent --serve`) and stops with
7
+ * `:serve stop` or when vigent quits.
8
+ *
9
+ * Every API call needs the access code shown in vigent (8 characters, new
10
+ * each time). Prompts and stops need it in a header, so another web page
11
+ * can't send them from your browser. After 10 wrong codes the server stops
12
+ * answering until you start it again. It's plain HTTP: use it on a network
13
+ * you trust (home Wi-Fi, a VPN such as Tailscale), not on public Wi-Fi.
14
+ */
15
+ import { randomInt, timingSafeEqual } from "node:crypto";
16
+ import * as http from "node:http";
17
+ import * as os from "node:os";
18
+ import { ENGINE_NAMES } from "./engines.js";
19
+ import { servePage } from "./serve-page.js";
20
+ const CODE_ALPHABET = "abcdefghjkmnpqrstuvwxyz23456789"; // no 0/o, 1/l/i: easy to type on a phone
21
+ const MAX_FAILURES = 10;
22
+ const MAX_PROMPT = 20_000;
23
+ /** A fresh access code, like "k3x9-q2m7". */
24
+ export function newAccessCode() {
25
+ const pick = () => CODE_ALPHABET[randomInt(CODE_ALPHABET.length)];
26
+ return `${Array.from({ length: 4 }, pick).join("")}-${Array.from({ length: 4 }, pick).join("")}`;
27
+ }
28
+ /** This machine's addresses a phone on the same network can reach (IPv4, not loopback). */
29
+ export function lanAddresses() {
30
+ const out = [];
31
+ for (const list of Object.values(os.networkInterfaces())) {
32
+ for (const a of list ?? [])
33
+ if (a.family === "IPv4" && !a.internal)
34
+ out.push(a.address);
35
+ }
36
+ return out;
37
+ }
38
+ function same(a, b) {
39
+ const x = Buffer.from(a);
40
+ const y = Buffer.from(b);
41
+ return x.length === y.length && timingSafeEqual(x, y);
42
+ }
43
+ function agentSummary(s, unread) {
44
+ const st = s.worktree?.stat;
45
+ const last = [...s.items].reverse().find((i) => i.kind === "agent" || i.kind === "user");
46
+ return {
47
+ id: s.id,
48
+ name: s.name,
49
+ engine: s.engine,
50
+ engineName: ENGINE_NAMES[s.engine],
51
+ model: s.model,
52
+ effort: s.effort,
53
+ state: s.state,
54
+ branch: s.worktree?.branch,
55
+ added: st?.added ?? 0,
56
+ removed: st?.removed ?? 0,
57
+ tokens: s.tokens,
58
+ cost: s.cost,
59
+ queued: s.queue.length,
60
+ unread,
61
+ version: s.version,
62
+ last: last ? { kind: last.kind, text: preview(last.text) } : undefined,
63
+ };
64
+ }
65
+ /** a reply as one plain line for the agent list: no markdown marks, no code fences */
66
+ function preview(text) {
67
+ return text
68
+ .replace(/\x60\x60\x60[^\n]*\n?/g, " ")
69
+ .replace(/\*\*|__|\x60/g, "")
70
+ .replace(/^#+ /gm, "")
71
+ .replace(/\s+/g, " ")
72
+ .trim()
73
+ .slice(0, 160);
74
+ }
75
+ function itemJson(i) {
76
+ return {
77
+ kind: i.kind,
78
+ text: i.text.length > 50_000 ? `${i.text.slice(0, 50_000)}\n…` : i.text,
79
+ streaming: i.streaming || undefined,
80
+ tool: i.tool
81
+ ? { name: i.tool.name, summary: i.tool.summary, output: i.tool.output.length > 4000 ? `${i.tool.output.slice(0, 4000)}\n…` : i.tool.output }
82
+ : undefined,
83
+ };
84
+ }
85
+ export function startServer(host, opts = {}) {
86
+ const code = opts.code ?? newAccessCode();
87
+ const bind = opts.host ?? "0.0.0.0";
88
+ let failures = 0;
89
+ let locked = false;
90
+ const streams = new Set();
91
+ const watching = new Map();
92
+ let pending;
93
+ let everConnected = false;
94
+ const broadcast = () => {
95
+ if (pending)
96
+ return;
97
+ // coalesce: a streaming reply changes many times a second
98
+ pending = setTimeout(() => {
99
+ pending = undefined;
100
+ const data = JSON.stringify({ agents: host.sessions().map((s) => agentSummary(s, host.isUnread(s))) });
101
+ for (const res of streams)
102
+ res.write(`event: agents\ndata: ${data}\n\n`);
103
+ }, 120);
104
+ };
105
+ const sync = () => {
106
+ const now = new Set(host.sessions());
107
+ for (const [s, off] of watching) {
108
+ if (!now.has(s)) {
109
+ off();
110
+ watching.delete(s);
111
+ }
112
+ }
113
+ for (const s of now)
114
+ if (!watching.has(s))
115
+ watching.set(s, s.onChange(broadcast));
116
+ broadcast();
117
+ };
118
+ const authorized = (req, url, headerOnly) => {
119
+ if (locked)
120
+ return false;
121
+ const header = /^Bearer (.+)$/.exec(req.headers.authorization ?? "")?.[1];
122
+ const given = header ?? (headerOnly ? undefined : (url.searchParams.get("code") ?? undefined));
123
+ if (given && same(given.trim().toLowerCase(), code))
124
+ return true;
125
+ if (given !== undefined && ++failures >= MAX_FAILURES) {
126
+ locked = true;
127
+ host.notify(`:serve stopped answering after ${MAX_FAILURES} wrong access codes (:serve stop, then :serve, for a new one)`);
128
+ }
129
+ return false;
130
+ };
131
+ const send = (res, status, body) => {
132
+ res.writeHead(status, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" });
133
+ res.end(JSON.stringify(body));
134
+ };
135
+ const readBody = (req) => new Promise((resolve, reject) => {
136
+ let size = 0;
137
+ const chunks = [];
138
+ req.on("data", (c) => {
139
+ size += c.length;
140
+ if (size > MAX_PROMPT * 4) {
141
+ reject(new Error("too large"));
142
+ req.destroy();
143
+ }
144
+ else
145
+ chunks.push(c);
146
+ });
147
+ req.on("end", () => {
148
+ try {
149
+ resolve(JSON.parse(Buffer.concat(chunks).toString("utf8") || "{}"));
150
+ }
151
+ catch {
152
+ reject(new Error("not JSON"));
153
+ }
154
+ });
155
+ req.on("error", reject);
156
+ });
157
+ const server = http.createServer(async (req, res) => {
158
+ const url = new URL(req.url ?? "/", "http://vigent");
159
+ const route = `${req.method} ${url.pathname}`;
160
+ try {
161
+ if (route === "GET /" || route === "GET /index.html") {
162
+ res.writeHead(200, {
163
+ "content-type": "text/html; charset=utf-8",
164
+ "cache-control": "no-store",
165
+ "content-security-policy": "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; connect-src 'self'; img-src data:",
166
+ "referrer-policy": "no-referrer",
167
+ });
168
+ res.end(servePage(host.project(), host.palette()));
169
+ return;
170
+ }
171
+ if (!url.pathname.startsWith("/api/"))
172
+ return send(res, 404, { error: "not found" });
173
+ // reads may carry the code in the URL (EventSource can't set headers); changes need the header
174
+ const writes = req.method !== "GET";
175
+ if (!authorized(req, url, writes))
176
+ return send(res, locked ? 423 : 401, { error: locked ? "locked: start :serve again in vigent" : "wrong access code" });
177
+ if (route === "GET /api/state") {
178
+ return send(res, 200, { project: host.project(), agents: host.sessions().map((s) => agentSummary(s, host.isUnread(s))) });
179
+ }
180
+ if (route === "GET /api/events") {
181
+ res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-store", connection: "keep-alive" });
182
+ res.write(": vigent\n\n");
183
+ streams.add(res);
184
+ if (!everConnected) {
185
+ everConnected = true;
186
+ host.notify("a phone is following (:serve)");
187
+ }
188
+ const beat = setInterval(() => res.write(": beat\n\n"), 15_000);
189
+ req.on("close", () => {
190
+ clearInterval(beat);
191
+ streams.delete(res);
192
+ });
193
+ broadcast();
194
+ return;
195
+ }
196
+ if (route === "POST /api/agents") {
197
+ const body = await readBody(req);
198
+ const text = typeof body.text === "string" ? body.text.trim() : "";
199
+ if (!text || text.length > MAX_PROMPT)
200
+ return send(res, 400, { error: "a task, up to 20000 characters" });
201
+ const s = host.startAgent(text);
202
+ if (!s)
203
+ return send(res, 409, { error: "couldn't start an agent (see vigent)" });
204
+ s.submit(text);
205
+ host.notify(`'${s.name}' started from your phone`);
206
+ sync();
207
+ return send(res, 200, { id: s.id });
208
+ }
209
+ const m = /^\/api\/agents\/(\d+)(\/prompt|\/stop)?$/.exec(url.pathname);
210
+ const s = m ? host.sessions().find((x) => x.id === Number(m[1])) : undefined;
211
+ if (!m || !s)
212
+ return send(res, 404, { error: "no such agent" });
213
+ if (req.method === "GET" && !m[2]) {
214
+ return send(res, 200, { agent: agentSummary(s, host.isUnread(s)), items: s.items.map(itemJson) });
215
+ }
216
+ if (req.method === "POST" && m[2] === "/prompt") {
217
+ const body = await readBody(req);
218
+ const text = typeof body.text === "string" ? body.text.trim() : "";
219
+ if (!text || text.length > MAX_PROMPT)
220
+ return send(res, 400, { error: "a prompt, up to 20000 characters" });
221
+ const waiting = s.submit(text);
222
+ host.notify(`'${s.name}' got a prompt from your phone${waiting > 0 ? ` (queued, ${waiting} waiting)` : ""}`);
223
+ return send(res, 200, { queued: waiting });
224
+ }
225
+ if (req.method === "POST" && m[2] === "/stop") {
226
+ if (s.state === "running")
227
+ s.abort();
228
+ return send(res, 200, { state: s.state });
229
+ }
230
+ return send(res, 405, { error: "not allowed" });
231
+ }
232
+ catch (err) {
233
+ if (!res.headersSent)
234
+ send(res, 400, { error: String(err.message ?? err) });
235
+ }
236
+ });
237
+ return new Promise((resolve, reject) => {
238
+ server.once("error", reject);
239
+ server.listen(opts.port ?? 8377, bind, () => {
240
+ server.off("error", reject);
241
+ const port = server.address().port;
242
+ const local = bind === "127.0.0.1" || bind === "localhost";
243
+ const urls = [...(local ? [] : lanAddresses().map((a) => `http://${a}:${port}/`)), `http://127.0.0.1:${port}/`];
244
+ sync();
245
+ resolve({
246
+ port,
247
+ host: bind,
248
+ code,
249
+ urls,
250
+ clients: () => streams.size,
251
+ sync,
252
+ close: () => new Promise((done) => {
253
+ if (pending)
254
+ clearTimeout(pending);
255
+ for (const off of watching.values())
256
+ off();
257
+ watching.clear();
258
+ for (const r of streams)
259
+ r.end();
260
+ streams.clear();
261
+ server.close(() => done());
262
+ server.closeAllConnections?.();
263
+ }),
264
+ });
265
+ });
266
+ });
267
+ }
package/dist/session.js CHANGED
@@ -1,3 +1,14 @@
1
+ /**
2
+ * Session layer: one Session per agent.
3
+ *
4
+ * - PiSession runs a real pi coding agent in-process, with its read / write /
5
+ * edit / bash tools rooted in the agent's own git worktree.
6
+ * - DemoSession replays a scripted transcript so the UI can be explored
7
+ * without API keys.
8
+ */
9
+ import { expandPrompt } from "./attach.js";
10
+ import { loadPiAi, loadPiSdk } from "./pi-sdk.js";
11
+ import { extensionUi, piResources } from "./pi-ui.js";
1
12
  import { flattenTree } from "./tree.js";
2
13
  import { diffStat } from "./worktree.js";
3
14
  export const STATE_GLYPH = {
@@ -20,6 +31,8 @@ export class Session {
20
31
  state = "idle";
21
32
  items = [];
22
33
  tokens = 0;
34
+ /** money spent, in US dollars, as the engine reports it; undefined = the engine doesn't say (Codex) */
35
+ cost;
23
36
  version = 0; // bumped on every change, used for render caching
24
37
  listeners = new Set();
25
38
  statTimer;
@@ -244,7 +257,7 @@ export class PiSession extends Session {
244
257
  this.ready = this.init();
245
258
  }
246
259
  async init() {
247
- const sdk = await import("@mariozechner/pi-coding-agent");
260
+ const sdk = await loadPiSdk();
248
261
  const sessionManager = this.opts.sessionDir
249
262
  ? sdk.SessionManager.continueRecent(this.cwd, this.opts.sessionDir)
250
263
  : sdk.SessionManager.inMemory();
@@ -263,22 +276,28 @@ export class PiSession extends Session {
263
276
  ...(this.opts.agentDir ? { agentDir: this.opts.agentDir } : {}),
264
277
  });
265
278
  this.session = session;
279
+ await this.bindExtensions(sdk, session);
266
280
  if (resuming) {
267
281
  const restored = itemsFromMessages(session.messages ?? []);
268
282
  this.items.push(...restored.items);
269
283
  this.tokens += restored.tokens;
284
+ if (restored.cost !== undefined)
285
+ this.cost = (this.cost ?? 0) + restored.cost;
270
286
  this.pushInfo(`resumed: ${restored.items.filter((i) => i.kind === "user").length} earlier prompts`);
271
287
  this.refreshStat(0);
272
288
  }
273
289
  session.subscribe((event) => this.onEvent(event));
274
290
  }
291
+ /** the run ended on a failed request (shown as an error, not as silence) */
292
+ failed = false;
275
293
  onEvent(event) {
276
294
  switch (event.type) {
277
295
  case "agent_start":
296
+ this.failed = false;
278
297
  this.setState("running");
279
298
  break;
280
299
  case "agent_end":
281
- this.setState("idle");
300
+ this.setState(this.failed ? "error" : "idle");
282
301
  this.refreshStat(0);
283
302
  break;
284
303
  case "message_start": {
@@ -303,12 +322,21 @@ export class PiSession extends Session {
303
322
  last.text = extractText(event.message.content);
304
323
  last.streaming = false;
305
324
  const usage = event.message?.usage;
306
- if (usage)
307
- this.tokens += (usage.input ?? 0) + (usage.output ?? 0);
325
+ if (usage) {
326
+ this.tokens += usageTokens(usage);
327
+ if (typeof usage.cost?.total === "number")
328
+ this.cost = (this.cost ?? 0) + usage.cost.total;
329
+ }
308
330
  if (!last.text)
309
331
  this.items.pop(); // a turn that only called tools
310
332
  this.emit();
311
333
  }
334
+ // a failed request ends the reply with an error instead of throwing: say so
335
+ const err = piError(event.message);
336
+ if (err) {
337
+ this.failed = true;
338
+ this.pushInfo(err);
339
+ }
312
340
  break;
313
341
  }
314
342
  case "tool_execution_start": {
@@ -368,15 +396,93 @@ export class PiSession extends Session {
368
396
  this.emit();
369
397
  return { editorText: r.editorText };
370
398
  }
399
+ /** where this agent's extensions show dialogs and messages (set by the app) */
400
+ uiHost;
401
+ /** statuses extensions keep up to date (ctx.ui.setStatus), shown in the winbar */
402
+ extensionStatus = new Map();
403
+ /**
404
+ * Hand pi's extensions their UI and start them (session_start), as pi's
405
+ * own modes do. Without this, an extension's confirm() always answers no
406
+ * and its notify() goes nowhere.
407
+ */
408
+ async bindExtensions(sdk, session) {
409
+ const THEME = Symbol.for("@mariozechner/pi-coding-agent:theme");
410
+ try {
411
+ if (!globalThis[THEME])
412
+ sdk.initTheme?.(undefined, false);
413
+ }
414
+ catch {
415
+ // extensions that style text get plain text
416
+ }
417
+ const ui = extensionUi(() => this.uiHost ?? this.fallbackUi, () => globalThis[THEME]);
418
+ try {
419
+ await session.bindExtensions({
420
+ uiContext: ui,
421
+ commandContextActions: {
422
+ waitForIdle: () => session.agent.waitForIdle(),
423
+ // one conversation per agent in vigent: :new starts another agent
424
+ newSession: async () => ({ cancelled: true }),
425
+ fork: async () => ({ cancelled: true }),
426
+ switchSession: async () => ({ cancelled: true }),
427
+ navigateTree: async (targetId) => {
428
+ const r = await session.navigateTree(targetId, {});
429
+ return { cancelled: r.cancelled };
430
+ },
431
+ reload: async () => {
432
+ await session.reload();
433
+ },
434
+ },
435
+ onError: (err) => this.pushInfo(`extension error (${shortExtPath(err?.extensionPath)}${err?.event ? `, ${err.event}` : ""}): ${err?.error ?? err}`),
436
+ });
437
+ }
438
+ catch (err) {
439
+ this.pushInfo(`pi extensions didn't start: ${String(err?.message ?? err)}`);
440
+ }
441
+ const failed = piResources(session).extensions.filter((e) => e.error);
442
+ for (const e of failed)
443
+ this.pushInfo(`extension ${shortExtPath(e.path)} didn't load: ${e.error}`);
444
+ }
445
+ /** with no screen to ask on: messages still reach the transcript, dialogs answer no */
446
+ fallbackUi = {
447
+ select: async () => undefined,
448
+ confirm: async () => false,
449
+ input: async () => undefined,
450
+ notify: (message) => this.pushInfo(message),
451
+ setStatus: (key, text) => this.setExtensionStatus(key, text),
452
+ };
453
+ setExtensionStatus(key, text) {
454
+ if (text)
455
+ this.extensionStatus.set(key, text);
456
+ else
457
+ this.extensionStatus.delete(key);
458
+ this.emit();
459
+ }
460
+ /** What pi loaded for this agent: extensions, their commands, skills, prompt templates, AGENTS.md files. */
461
+ resources() {
462
+ return this.session ? piResources(this.session) : undefined;
463
+ }
371
464
  send(text) {
372
465
  this.pushUser(text);
373
466
  this.setState("running");
467
+ const attached = expandPrompt(text, "pi", this.cwd);
468
+ if (attached.skipped.length)
469
+ this.pushInfo(`not attached: ${attached.skipped.join(", ")}`);
374
470
  this.ready
375
471
  // pi reports agent_end a moment before it accepts the next prompt (queued prompts hit this)
376
472
  .then(() => this.session.agent?.waitForIdle?.())
377
- .then(() => this.session.prompt(text))
473
+ .then(() => this.session.prompt(attached.prompt))
474
+ // an extension command (/name) or a handled input runs no turn: nothing else will end it
475
+ .then(() => {
476
+ if (this.state === "running")
477
+ this.setState("idle");
478
+ })
378
479
  .catch((err) => {
379
- this.pushInfo(`error: ${err?.message ?? err}`);
480
+ const text = String(err?.message ?? err);
481
+ // pi's own advice (/login, its docs paths) is for pi's TUI, not vigent: keep its first line
482
+ const key = /api key|login|auth/i.test(text);
483
+ this.pushInfo(`error: ${key ? text.split("\n")[0] : text}`);
484
+ if (key)
485
+ this.pushInfo(PI_KEY_HINT);
380
486
  this.setState("error");
381
487
  });
382
488
  }
@@ -394,6 +500,7 @@ export function itemsFromMessages(messages) {
394
500
  const items = [];
395
501
  const byCallId = new Map();
396
502
  let tokens = 0;
503
+ let cost;
397
504
  for (const m of messages) {
398
505
  if (m?.role === "user") {
399
506
  const text = extractText(m.content);
@@ -417,8 +524,14 @@ export function itemsFromMessages(messages) {
417
524
  if (b.id)
418
525
  byCallId.set(b.id, item);
419
526
  }
420
- if (m.usage)
421
- tokens += (m.usage.input ?? 0) + (m.usage.output ?? 0);
527
+ if (m.usage) {
528
+ tokens += usageTokens(m.usage);
529
+ if (typeof m.usage.cost?.total === "number")
530
+ cost = (cost ?? 0) + m.usage.cost.total;
531
+ }
532
+ const err = piError(m);
533
+ if (err)
534
+ items.push({ kind: "info", text: err });
422
535
  }
423
536
  else if (m?.role === "toolResult") {
424
537
  const item = byCallId.get(m.toolCallId);
@@ -426,7 +539,24 @@ export function itemsFromMessages(messages) {
426
539
  item.tool.output = extractText(m.content);
427
540
  }
428
541
  }
429
- return { items, tokens };
542
+ return { items, tokens, cost };
543
+ }
544
+ /** pi's usage: every token the request read or wrote, cached ones included (like Claude Code's count) */
545
+ function usageTokens(u) {
546
+ return u.totalTokens ?? (u.input ?? 0) + (u.output ?? 0) + (u.cacheRead ?? 0) + (u.cacheWrite ?? 0);
547
+ }
548
+ /** What to do when pi can't get through: this agent stays on pi, so start one elsewhere. */
549
+ export const PI_KEY_HINT = "this agent runs on pi, and pi has no working API key. Agents keep the engine they started on: :new <task> starts one on the default engine (:engine picks it, e.g. Claude Code or Codex with your existing login), and :kill removes this one.";
550
+ /**
551
+ * The error of a reply whose request failed (pi ends it with stopReason
552
+ * "error" and an errorMessage), with a hint when the key or login was refused.
553
+ */
554
+ export function piError(message) {
555
+ if (message?.role !== "assistant" || message.stopReason !== "error")
556
+ return undefined;
557
+ const text = String(message.errorMessage ?? "").trim() || "the request failed";
558
+ const auth = /\b(401|403)\b|auth|api[ _-]?key|unauthori[sz]ed|forbidden|credential|expired|login/i.test(text);
559
+ return auth ? `error: ${text}\n${PI_KEY_HINT}` : `error: ${text}`;
430
560
  }
431
561
  function extractText(content) {
432
562
  if (typeof content === "string")
@@ -448,7 +578,7 @@ function summarizeArgs(args) {
448
578
  }
449
579
  /** A pi-ai model from "provider/model", or a bare id (Anthropic's). Undefined when pi doesn't know it. */
450
580
  export async function piModel(id) {
451
- const ai = await import("@mariozechner/pi-ai");
581
+ const ai = await loadPiAi();
452
582
  const slash = id.indexOf("/");
453
583
  const [provider, modelId] = slash > 0 ? [id.slice(0, slash), id.slice(slash + 1)] : ["anthropic", id];
454
584
  try {
@@ -458,3 +588,8 @@ export async function piModel(id) {
458
588
  return undefined;
459
589
  }
460
590
  }
591
+ /** ~/.pi/agent/extensions/guard.ts → extensions/guard.ts */
592
+ function shortExtPath(p) {
593
+ const parts = String(p ?? "extension").split(/[\\/]/);
594
+ return parts.slice(-2).join("/");
595
+ }
package/dist/settings.js CHANGED
@@ -7,7 +7,7 @@
7
7
  * Changes apply at once. A key row waits for the new keys: type them, then
8
8
  * Enter (Esc cancels).
9
9
  */
10
- import { truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
10
+ import { truncateToWidth, visibleWidth } from "./tui.js";
11
11
  import { keyFromInput } from "./keys.js";
12
12
  import { RESET } from "./theme.js";
13
13
  export class SettingsFloat {
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Session templates: a prompt you start agents with often, with the engine,
3
+ * model and effort it should run on.
4
+ *
5
+ * [templates.fix]
6
+ * prompt = "Find and fix this bug: {task}. Reproduce it with a failing test first."
7
+ * engine = "claude"
8
+ * effort = "high"
9
+ *
10
+ * `:new -t fix the cart total is off` starts an agent and sends the prompt,
11
+ * with `{task}` replaced by what you typed. A few are built in; a template
12
+ * in config.toml with the same name replaces the built-in one.
13
+ */
14
+ import { EFFORTS } from "./choices.js";
15
+ import { isEngineId } from "./engines.js";
16
+ export const BUILTIN_TEMPLATES = [
17
+ {
18
+ name: "fix",
19
+ description: "reproduce a bug with a failing test, then fix it",
20
+ prompt: "Find and fix this bug: {task}\n\nFirst reproduce it with a failing test. Then make the smallest change that makes the test pass, and run the whole test suite before you finish.",
21
+ },
22
+ {
23
+ name: "test",
24
+ description: "write tests in the project's own style",
25
+ prompt: "Write tests for {task}. Cover the usual cases and the edge cases, follow the style of the tests already in the project, and run them. Don't change the code under test; if a test finds a bug, say so.",
26
+ },
27
+ {
28
+ name: "review",
29
+ description: "review this branch's work; changes nothing",
30
+ prompt: "Review the work on this branch: compare it with the branch it started from using git. Look for bugs, missing tests and code that's hard to follow.{task}\n\nList what you find by file and line, most serious first. Don't change any files.",
31
+ },
32
+ {
33
+ name: "explain",
34
+ description: "how something works and where it lives; changes nothing",
35
+ prompt: "Explain {task}: how it works, where it lives in the code, and what would break if it changed. Don't change any files.",
36
+ },
37
+ ];
38
+ /** Whether the prompt has a place for the task. */
39
+ export function needsTask(t) {
40
+ return t.prompt.includes("{task}");
41
+ }
42
+ /**
43
+ * The prompt for a task. `{task}` takes the task; a template without one gets
44
+ * the task added after it. The review template's `{task}` is optional focus.
45
+ */
46
+ export function fillTemplate(t, task) {
47
+ const text = task.trim();
48
+ if (!needsTask(t))
49
+ return text ? `${t.prompt}\n\n${text}` : t.prompt;
50
+ if (t.name === "review" && t.builtin)
51
+ return t.prompt.replace("{task}", text ? ` Focus on: ${text}.` : "");
52
+ return t.prompt.split("{task}").join(text);
53
+ }
54
+ /** Built-ins first, then yours (yours replace a built-in of the same name), in config order. */
55
+ export function allTemplates(user) {
56
+ const out = BUILTIN_TEMPLATES.map((t) => user[t.name] ?? { ...t, builtin: true });
57
+ for (const t of Object.values(user))
58
+ if (!BUILTIN_TEMPLATES.some((b) => b.name === t.name))
59
+ out.push(t);
60
+ return out;
61
+ }
62
+ /** `[templates]` from config.toml; problems become warnings, never errors. */
63
+ export function parseTemplates(value, warnings) {
64
+ const out = {};
65
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
66
+ warnings.push("config: [templates] should be a table");
67
+ return out;
68
+ }
69
+ for (const [name, raw] of Object.entries(value)) {
70
+ const where = `config [templates.${name}]`;
71
+ if (!/^[\w-]+$/.test(name)) {
72
+ warnings.push(`${where}: a template name is letters, digits, - and _`);
73
+ continue;
74
+ }
75
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
76
+ warnings.push(`${where} should be a table`);
77
+ continue;
78
+ }
79
+ const r = raw;
80
+ if (typeof r.prompt !== "string" || !r.prompt.trim()) {
81
+ warnings.push(`${where}: 'prompt' is required`);
82
+ continue;
83
+ }
84
+ const t = { name, prompt: r.prompt.trim() };
85
+ for (const [k, v] of Object.entries(r)) {
86
+ switch (k) {
87
+ case "prompt":
88
+ break;
89
+ case "description":
90
+ case "model":
91
+ if (typeof v === "string")
92
+ t[k] = v;
93
+ else
94
+ warnings.push(`${where}: '${k}' should be a string`);
95
+ break;
96
+ case "engine":
97
+ if (typeof v === "string" && isEngineId(v))
98
+ t.engine = v;
99
+ else
100
+ warnings.push(`${where}: 'engine' should be "pi", "claude", "codex" or "opencode"`);
101
+ break;
102
+ case "worktree":
103
+ if (typeof v === "boolean")
104
+ t.worktree = v;
105
+ else
106
+ warnings.push(`${where}: 'worktree' should be true or false`);
107
+ break;
108
+ case "effort":
109
+ if (typeof v === "string")
110
+ t.effort = v;
111
+ else
112
+ warnings.push(`${where}: 'effort' should be a string`);
113
+ break;
114
+ default:
115
+ warnings.push(`${where}: unknown option '${k}'`);
116
+ }
117
+ }
118
+ if (t.effort && t.engine && !EFFORTS[t.engine].includes(t.effort)) {
119
+ warnings.push(`${where}: 'effort' should be one of ${EFFORTS[t.engine].join(", ")}`);
120
+ delete t.effort;
121
+ }
122
+ out[name] = t;
123
+ }
124
+ return out;
125
+ }
package/dist/theme.js CHANGED
@@ -105,6 +105,8 @@ const LINKS = {
105
105
  VigentSidebar: "NeoTreeNormal",
106
106
  VigentIndent: "NeoTreeIndentMarker",
107
107
  VigentUser: "Title",
108
+ /** `@path` in a prompt */
109
+ VigentMention: "Directory",
108
110
  VigentTool: "Function",
109
111
  VigentToolOutput: "Comment",
110
112
  VigentSpinner: "WarningMsg",
package/dist/tui.js ADDED
@@ -0,0 +1,13 @@
1
+ /**
2
+ * The parts of pi-tui vigent uses, imported from their own modules.
3
+ *
4
+ * pi-tui's index also loads its editor, markdown (marked), image and
5
+ * autocomplete components, none of which vigent draws; skipping them makes
6
+ * every start faster. Everything from pi-tui goes through this file, so a
7
+ * change in its layout is fixed here once.
8
+ */
9
+ export { fuzzyFilter } from "@mariozechner/pi-tui/dist/fuzzy.js";
10
+ export { parseKey } from "@mariozechner/pi-tui/dist/keys.js";
11
+ export { ProcessTerminal } from "@mariozechner/pi-tui/dist/terminal.js";
12
+ export { TUI } from "@mariozechner/pi-tui/dist/tui.js";
13
+ export { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@mariozechner/pi-tui/dist/utils.js";