@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/picker.js CHANGED
@@ -4,7 +4,7 @@
4
4
  * Type to filter, arrows / Ctrl-n / Ctrl-p / Tab to move (each move previews
5
5
  * the theme live), Enter to keep it, Esc to go back to the previous theme.
6
6
  */
7
- import { fuzzyFilter, parseKey, truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
7
+ import { fuzzyFilter, parseKey, truncateToWidth, visibleWidth } from "./tui.js";
8
8
  import { RESET, sgr } from "./theme.js";
9
9
  export const PICKER_ICONS = {
10
10
  unicode: { tl: "╭", tr: "╮", bl: "╰", br: "╯", h: "─", v: "│", lt: "├", rt: "┤", current: "●", prompt: "❯" },
@@ -12,6 +12,11 @@ export const PICKER_ICONS = {
12
12
  ascii: { tl: "+", tr: "+", bl: "+", br: "+", h: "-", v: "|", lt: "+", rt: "+", current: "*", prompt: ">" },
13
13
  };
14
14
  const VISIBLE = 14;
15
+ /**
16
+ * `:colorscheme`, vim-style with live preview: `j`/`k` (↓/↑, Ctrl-n/p, Tab)
17
+ * move and preview, `g`/`G` jump, Enter keeps it, `q`/Esc puts the old one
18
+ * back. `/` starts a search: type to filter, Esc ends it.
19
+ */
15
20
  export class ThemePicker {
16
21
  painter;
17
22
  themes;
@@ -22,6 +27,8 @@ export class ThemePicker {
22
27
  requestRender;
23
28
  look;
24
29
  query = "";
30
+ /** typing goes to the search (after `/`) */
31
+ searching = false;
25
32
  index = 0;
26
33
  scroll = 0;
27
34
  constructor(painter, themes, current, icons, onPreview, onDone, requestRender, look = () => undefined) {
@@ -52,42 +59,63 @@ export class ThemePicker {
52
59
  }
53
60
  handleInput(data) {
54
61
  const key = parseKey(data) ?? "";
55
- switch (key) {
56
- case "escape":
57
- case "ctrl+c":
58
- this.onDone(null);
59
- return;
60
- case "enter":
61
- case "return": {
62
- const items = this.filtered();
63
- this.onDone(items[this.index] ?? null);
64
- return;
62
+ if (key === "ctrl+c")
63
+ return this.onDone(null);
64
+ if (key === "enter" || key === "return") {
65
+ this.onDone(this.filtered()[this.index] ?? null);
66
+ return;
67
+ }
68
+ if (["down", "tab", "ctrl+n", "ctrl+j"].includes(key))
69
+ this.move(1);
70
+ else if (["up", "shift+tab", "ctrl+p", "ctrl+k"].includes(key))
71
+ this.move(-1);
72
+ else if (this.searching) {
73
+ if (key === "escape")
74
+ this.searching = false; // the filter stays; j/k move through it
75
+ else if (key === "backspace") {
76
+ if (this.query) {
77
+ this.query = this.query.slice(0, -1);
78
+ this.resetSelection();
79
+ }
80
+ else
81
+ this.searching = false;
65
82
  }
66
- case "down":
67
- case "tab":
68
- case "ctrl+n":
69
- case "ctrl+j":
70
- this.move(1);
71
- break;
72
- case "up":
73
- case "shift+tab":
74
- case "ctrl+p":
75
- case "ctrl+k":
76
- this.move(-1);
77
- break;
78
- case "backspace":
79
- this.query = this.query.slice(0, -1);
80
- this.resetSelection();
81
- break;
82
- case "ctrl+u":
83
+ else if (key === "ctrl+u") {
83
84
  this.query = "";
84
85
  this.resetSelection();
85
- break;
86
- default:
87
- if (data.length >= 1 && !data.startsWith("\x1b") && data >= " ") {
88
- this.query += data;
89
- this.resetSelection();
90
- }
86
+ }
87
+ else if (data.length >= 1 && !data.startsWith("\x1b") && data >= " ") {
88
+ this.query += data;
89
+ this.resetSelection();
90
+ }
91
+ }
92
+ else {
93
+ switch (data) {
94
+ case "j":
95
+ this.move(1);
96
+ break;
97
+ case "k":
98
+ this.move(-1);
99
+ break;
100
+ case "g":
101
+ this.move(-this.index);
102
+ break;
103
+ case "G":
104
+ this.move(this.filtered().length - 1 - this.index);
105
+ break;
106
+ case "/":
107
+ this.searching = true;
108
+ break;
109
+ case "q":
110
+ return this.onDone(null);
111
+ default:
112
+ if (key === "escape") {
113
+ if (!this.query)
114
+ return this.onDone(null);
115
+ this.query = ""; // first Esc drops the filter, the next one closes
116
+ this.resetSelection();
117
+ }
118
+ }
91
119
  }
92
120
  this.requestRender();
93
121
  }
@@ -120,15 +148,22 @@ export class ThemePicker {
120
148
  border(I.v);
121
149
  const items = this.filtered();
122
150
  const title = " Colorschemes ";
123
- const count = ` ${items.length === 0 ? 0 : this.index + 1}/${items.length} `;
151
+ const keys = this.searching ? "type to filter · Esc done" : "j/k move · Enter pick · q close";
152
+ const count = ` ${items.length === 0 ? 0 : this.index + 1}/${items.length} · ${keys} `;
124
153
  const top = border(I.tl + I.h) +
125
154
  P.on("FloatTitle", bg) +
126
155
  title +
127
156
  RESET +
128
157
  border(I.h.repeat(Math.max(0, inner - 1 - visibleWidth(title))) + I.tr);
129
158
  const lines = [top];
130
- const prompt = `${I.prompt} ${this.query}`;
131
- lines.push(row(P.on("Title", bg) + ` ${I.prompt} ` + RESET + P.on("NormalFloat", bg) + this.query + P.on("PmenuSel", undefined) + " " + RESET, visibleWidth(prompt) + 2));
159
+ if (this.searching) {
160
+ const prompt = `${I.prompt} ${this.query}`;
161
+ lines.push(row(P.on("Title", bg) + ` ${I.prompt} ` + RESET + P.on("NormalFloat", bg) + this.query + P.on("PmenuSel", undefined) + " " + RESET, visibleWidth(prompt) + 2));
162
+ }
163
+ else {
164
+ const hint = truncateToWidth(this.query ? `/${this.query}` : "/ to search", Math.max(0, inner - 4), "…");
165
+ lines.push(row(P.on("Comment", bg) + ` ${I.prompt} ${hint}` + RESET, visibleWidth(hint) + 3));
166
+ }
132
167
  lines.push(border(I.lt + I.h.repeat(inner) + I.rt));
133
168
  const shown = items.slice(this.scroll, this.scroll + VISIBLE);
134
169
  for (let i = 0; i < VISIBLE; i++) {
@@ -192,7 +227,9 @@ export class ChoicePicker {
192
227
  scroll = 0;
193
228
  items = [];
194
229
  loading = true;
195
- constructor(painter, icons, title, items, current, custom, onDone, requestRender) {
230
+ onBackOut;
231
+ rows;
232
+ constructor(painter, icons, title, items, current, custom, onDone, requestRender, opts = {}) {
196
233
  this.painter = painter;
197
234
  this.icons = icons;
198
235
  this.title = title;
@@ -200,12 +237,15 @@ export class ChoicePicker {
200
237
  this.custom = custom;
201
238
  this.onDone = onDone;
202
239
  this.requestRender = requestRender;
240
+ this.searching = opts.search ?? false;
241
+ this.rows = opts.rows ?? VISIBLE;
242
+ this.onBackOut = opts.onBackOut;
203
243
  Promise.resolve(items)
204
244
  .then((list) => {
205
245
  this.items = list;
206
246
  this.loading = false;
207
247
  this.index = Math.max(0, list.findIndex((c) => c.value === current));
208
- this.scroll = Math.max(0, this.index - VISIBLE + 1);
248
+ this.scroll = Math.max(0, this.index - this.rows + 1);
209
249
  this.requestRender();
210
250
  })
211
251
  .catch(() => {
@@ -241,6 +281,8 @@ export class ChoicePicker {
241
281
  else if (key === "backspace") {
242
282
  if (this.query)
243
283
  this.query = this.query.slice(0, -1);
284
+ else if (this.onBackOut)
285
+ return this.onBackOut();
244
286
  else
245
287
  this.searching = false;
246
288
  this.index = 0;
@@ -286,8 +328,8 @@ export class ChoicePicker {
286
328
  }
287
329
  if (this.index < this.scroll)
288
330
  this.scroll = this.index;
289
- if (this.index >= this.scroll + VISIBLE)
290
- this.scroll = this.index - VISIBLE + 1;
331
+ if (this.index >= this.scroll + this.rows)
332
+ this.scroll = this.index - this.rows + 1;
291
333
  this.requestRender();
292
334
  }
293
335
  invalidate() { }
@@ -299,7 +341,7 @@ export class ChoicePicker {
299
341
  const border = (s) => P.on("FloatBorder", bg) + s + RESET;
300
342
  const row = (content, used, rowBg = bg) => border(I.v) + content + P.on("NormalFloat", rowBg) + " ".repeat(Math.max(0, inner - used)) + RESET + border(I.v);
301
343
  const items = this.filtered();
302
- const title = ` ${this.title} `;
344
+ const title = truncateToWidth(` ${this.title} `, Math.max(0, inner - 2), "…");
303
345
  const lines = [border(I.tl + I.h) + P.on("FloatTitle", bg) + title + RESET + border(I.h.repeat(Math.max(0, inner - 1 - visibleWidth(title))) + I.tr)];
304
346
  if (this.searching) {
305
347
  lines.push(row(P.on("Title", bg) + ` ${I.prompt} ` + RESET + P.on("NormalFloat", bg) + this.query + P.on("PmenuSel", undefined) + " " + RESET, visibleWidth(this.query) + 4));
@@ -310,15 +352,15 @@ export class ChoicePicker {
310
352
  lines.push(row(P.on("Comment", bg) + ` ${I.prompt} ${text}` + RESET, visibleWidth(text) + 3));
311
353
  }
312
354
  lines.push(border(I.lt + I.h.repeat(inner) + I.rt));
313
- const shown = items.slice(this.scroll, this.scroll + VISIBLE);
314
- for (let i = 0; i < VISIBLE; i++) {
355
+ const shown = items.slice(this.scroll, this.scroll + this.rows);
356
+ for (let i = 0; i < this.rows; i++) {
315
357
  const c = shown[i];
316
358
  if (!c) {
317
359
  let text = "";
318
360
  if (i === 0 && this.loading)
319
361
  text = " loading…";
320
362
  else if (i === 0 && items.length === 0)
321
- text = this.custom && this.query.trim() ? ` Enter uses '${this.query.trim()}'` : " no match";
363
+ text = this.custom && this.query.trim() ? ` Enter uses '${this.query.trim()}'` : this.custom && this.items.length === 0 ? " type it, then Enter" : " no match";
322
364
  lines.push(row(P.on("Comment", bg) + truncateToWidth(text, inner, "…") + RESET, Math.min(inner, visibleWidth(text))));
323
365
  continue;
324
366
  }
@@ -326,9 +368,12 @@ export class ChoicePicker {
326
368
  const rowBg = selected ? (P.attr("PmenuSel").bg ?? P.attr("Visual").bg) : bg;
327
369
  const group = selected ? "PmenuSel" : "NormalFloat";
328
370
  const marker = c.value === this.current ? `${I.current} ` : " ";
329
- const name = truncateToWidth(`${marker}${c.value}`, Math.max(10, inner - 4), "…");
371
+ // notes line up in a column after the longest name (up to a third of the width)
372
+ const nameW = Math.min(Math.floor(inner / 3), Math.max(...items.map((x) => visibleWidth(x.value))));
373
+ const padded = c.note ? c.value + " ".repeat(Math.max(0, nameW - visibleWidth(c.value))) : c.value;
374
+ const name = truncateToWidth(`${marker}${padded}`, Math.max(10, inner - 4), "…");
330
375
  const note = c.note ? truncateToWidth(` ${c.note}`, Math.max(0, inner - 2 - visibleWidth(name)), "…") : "";
331
- lines.push(row(P.on(group, rowBg) + ` ${name}` + P.on("Comment", rowBg) + note + RESET, 1 + visibleWidth(name) + visibleWidth(note), rowBg));
376
+ lines.push(row(P.on(group, rowBg) + ` ${name}` + P.on(selected ? "PmenuSel" : "Comment", rowBg) + note + RESET, 1 + visibleWidth(name) + visibleWidth(note), rowBg));
332
377
  }
333
378
  const keys = this.searching ? "type to filter · Esc done" : "j/k move · Enter pick · q close";
334
379
  const count = ` ${items.length ? this.index + 1 : 0}/${items.length} · ${keys} `;
@@ -0,0 +1,247 @@
1
+ const esc = (s) => s.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
2
+ export function servePage(project, palette) {
3
+ const p = {
4
+ ...palette,
5
+ bg_dark: palette.bg_dark ?? palette.bg,
6
+ bg_alt: palette.bg_alt ?? palette.bg,
7
+ selection: palette.selection ?? palette.blue,
8
+ muted: palette.muted ?? palette.fg,
9
+ subtle: palette.subtle ?? palette.fg,
10
+ accent: palette.accent ?? palette.yellow,
11
+ };
12
+ return `<!doctype html>
13
+ <html lang="en">
14
+ <head>
15
+ <meta charset="utf-8">
16
+ <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
17
+ <meta name="theme-color" content="${esc(p.bg_dark)}">
18
+ <title>vigent · ${esc(project)}</title>
19
+ <style>
20
+ :root {
21
+ --bg: ${p.bg}; --bg-dark: ${p.bg_dark}; --bg-alt: ${p.bg_alt}; --sel: ${p.selection};
22
+ --fg: ${p.fg}; --muted: ${p.muted}; --subtle: ${p.subtle}; --accent: ${p.accent};
23
+ --blue: ${p.blue}; --green: ${p.green}; --yellow: ${p.yellow}; --red: ${p.red}; --cyan: ${p.cyan}; --magenta: ${p.magenta};
24
+ --mono: ui-monospace, "SF Mono", "JetBrains Mono", Menlo, Consolas, monospace;
25
+ --sans: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
26
+ }
27
+ * { box-sizing: border-box; }
28
+ html, body { margin: 0; height: 100%; background: var(--bg); color: var(--fg); font: 15px/1.5 var(--sans); -webkit-text-size-adjust: 100%; }
29
+ button, textarea, input { font: inherit; color: inherit; }
30
+ header { position: sticky; top: 0; z-index: 2; display: flex; align-items: center; gap: 10px; padding: 12px 16px; padding-top: max(12px, env(safe-area-inset-top)); background: var(--bg-dark); border-bottom: 1px solid var(--sel); }
31
+ header h1 { margin: 0; font: 600 16px/1.2 var(--mono); flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
32
+ header .project { color: var(--muted); font-weight: 400; }
33
+ .back { border: 0; background: none; color: var(--blue); font-size: 22px; line-height: 1; padding: 4px 8px 4px 0; cursor: pointer; }
34
+ .dot { width: 9px; height: 9px; border-radius: 50%; background: var(--subtle); flex: none; }
35
+ .dot.live { background: var(--green); }
36
+ main { padding: 12px 16px 160px; max-width: 760px; margin: 0 auto; }
37
+ .empty, .note { color: var(--muted); text-align: center; padding: 40px 12px; }
38
+ .card { display: block; width: 100%; text-align: left; border: 1px solid var(--sel); background: var(--bg-dark); border-radius: 12px; padding: 12px 14px; margin: 0 0 10px; cursor: pointer; }
39
+ .card:active { background: var(--bg-alt); }
40
+ .row { display: flex; align-items: center; gap: 8px; min-width: 0; }
41
+ .name { font: 600 15px/1.3 var(--mono); flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
42
+ .meta { color: var(--muted); font: 12px/1.4 var(--mono); margin-top: 4px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
43
+ .last { color: var(--fg); opacity: .85; font-size: 13px; margin-top: 6px; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
44
+ .badge { font: 600 11px/1 var(--mono); padding: 4px 7px; border-radius: 999px; background: var(--bg-alt); color: var(--muted); flex: none; text-transform: lowercase; }
45
+ .badge.running { color: var(--bg); background: var(--green); }
46
+ .badge.waiting { color: var(--bg); background: var(--yellow); }
47
+ .badge.error { color: var(--bg); background: var(--red); }
48
+ .badge.paused { color: var(--bg); background: var(--subtle); }
49
+ .new { color: var(--yellow); font: 600 12px var(--mono); }
50
+ .add { color: var(--green); } .del { color: var(--red); }
51
+ .item { margin: 14px 0; }
52
+ .user { font: 600 14px/1.5 var(--mono); color: var(--accent); white-space: pre-wrap; word-break: break-word; }
53
+ .user::before { content: "❯ "; color: var(--blue); }
54
+ .agent { white-space: pre-wrap; word-break: break-word; }
55
+ .agent code { font: 13px var(--mono); background: var(--bg-alt); padding: 1px 5px; border-radius: 4px; color: var(--green); }
56
+ .agent pre, details pre { font: 12.5px/1.45 var(--mono); background: var(--bg-dark); border: 1px solid var(--sel); border-radius: 8px; padding: 10px 12px; overflow-x: auto; white-space: pre; margin: 8px 0; }
57
+ .agent pre code { background: none; padding: 0; color: var(--fg); }
58
+ .agent .h { font-weight: 700; color: var(--accent); }
59
+ .streaming::after { content: "▍"; color: var(--accent); animation: blink 1s steps(1) infinite; }
60
+ @keyframes blink { 50% { opacity: 0; } }
61
+ details { font: 13px/1.4 var(--mono); }
62
+ summary { color: var(--muted); cursor: pointer; list-style: none; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
63
+ summary::-webkit-details-marker { display: none; }
64
+ summary::before { content: "▸ "; } details[open] summary::before { content: "▾ "; }
65
+ summary b { color: var(--cyan); font-weight: 600; }
66
+ .info { color: var(--muted); font-size: 13px; font-style: italic; white-space: pre-wrap; }
67
+ .composer { position: fixed; left: 0; right: 0; bottom: 0; z-index: 3; background: var(--bg-dark); border-top: 1px solid var(--sel); padding: 10px 12px; padding-bottom: max(10px, env(safe-area-inset-bottom)); }
68
+ .composer .inner { max-width: 760px; margin: 0 auto; display: flex; gap: 8px; align-items: flex-end; }
69
+ textarea { flex: 1; min-height: 44px; max-height: 40vh; resize: none; border: 1px solid var(--sel); background: var(--bg); border-radius: 12px; padding: 10px 12px; font: 15px/1.4 var(--sans); outline: none; }
70
+ textarea:focus, input:focus { border-color: var(--blue); }
71
+ .btn { border: 0; border-radius: 12px; padding: 0 16px; height: 44px; font-weight: 600; cursor: pointer; background: var(--blue); color: var(--bg); flex: none; }
72
+ .btn.stop { background: var(--red); }
73
+ .btn:disabled { opacity: .5; }
74
+ .login { max-width: 360px; margin: 18vh auto 0; padding: 0 20px; text-align: center; }
75
+ .login h2 { font: 700 26px var(--mono); margin: 0 0 6px; color: var(--accent); }
76
+ .login p { color: var(--muted); margin: 0 0 22px; }
77
+ .login input { width: 100%; text-align: center; letter-spacing: 3px; font: 600 22px var(--mono); padding: 12px; border-radius: 12px; border: 1px solid var(--sel); background: var(--bg-dark); outline: none; }
78
+ .login .btn { width: 100%; margin-top: 12px; }
79
+ .err { color: var(--red); min-height: 1.5em; margin-top: 10px; font-size: 14px; }
80
+ .toast { position: fixed; left: 50%; transform: translateX(-50%); bottom: 96px; background: var(--bg-alt); border: 1px solid var(--sel); padding: 8px 14px; border-radius: 999px; font-size: 13px; z-index: 4; }
81
+ </style>
82
+ </head>
83
+ <body>
84
+ <div id="app"></div>
85
+ <script>
86
+ "use strict";
87
+ const store = { get(k) { try { return sessionStorage.getItem(k); } catch { return null; } }, set(k, v) { try { sessionStorage.setItem(k, v); } catch {} } };
88
+ let code = store.get("vigent-code") || "";
89
+ if (location.hash.length > 1) { code = decodeURIComponent(location.hash.slice(1)); store.set("vigent-code", code); history.replaceState(null, "", location.pathname); }
90
+ const state = { agents: [], open: null, items: [], version: -1, live: false, project: ${JSON.stringify(project)}, sending: false };
91
+ const $app = document.getElementById("app");
92
+ const h = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => "&#" + c.charCodeAt(0) + ";");
93
+ const tok = (n) => n >= 1e6 ? (n / 1e6).toFixed(1) + "M" : n >= 1e3 ? (n / 1e3).toFixed(1) + "k" : String(n);
94
+ const usd = (c) => c === undefined || c === null ? "" : " · $" + (c > 0 && c < 0.01 ? c.toFixed(3) : c.toFixed(2));
95
+
96
+ async function api(path, body) {
97
+ const r = await fetch(path, { method: body ? "POST" : "GET", headers: { authorization: "Bearer " + code, ...(body ? { "content-type": "application/json" } : {}) }, body: body ? JSON.stringify(body) : undefined, cache: "no-store" });
98
+ if (r.status === 401 || r.status === 423) { const e = await r.json().catch(() => ({})); throw Object.assign(new Error(e.error || "wrong access code"), { auth: true }); }
99
+ const data = await r.json().catch(() => ({}));
100
+ if (!r.ok) throw new Error(data.error || r.statusText);
101
+ return data;
102
+ }
103
+
104
+ function toast(text) {
105
+ const t = document.createElement("div"); t.className = "toast"; t.textContent = text; document.body.appendChild(t);
106
+ setTimeout(() => t.remove(), 2200);
107
+ }
108
+
109
+ function markdown(text) {
110
+ return text.split(/(\\x60\\x60\\x60[^\\n]*\\n[\\s\\S]*?(?:\\x60\\x60\\x60|$))/).map((part) => {
111
+ const fence = /^\\x60\\x60\\x60([^\\n]*)\\n([\\s\\S]*?)(?:\\x60\\x60\\x60)?$/.exec(part);
112
+ if (fence) return "<pre><code>" + h(fence[2].replace(/\\n$/, "")) + "</code></pre>";
113
+ return h(part)
114
+ .replace(/^(#{1,4}) (.*)$/gm, (_m, _hashes, t) => '<span class="h">' + t + "</span>")
115
+ .replace(/\\x60([^\\x60\\n]+)\\x60/g, "<code>$1</code>")
116
+ .replace(/\\*\\*([^*\\n]+)\\*\\*/g, "<strong>$1</strong>");
117
+ }).join("");
118
+ }
119
+
120
+ function renderLogin(error) {
121
+ $app.innerHTML = '<div class="login"><h2>vigent</h2><p>Type the access code vigent shows after <code>:serve</code>.</p>' +
122
+ '<form id="f"><input id="c" autocomplete="one-time-code" autocapitalize="none" spellcheck="false" placeholder="xxxx-xxxx" value="' + h(code) + '"><button class="btn">Follow</button></form>' +
123
+ '<div class="err">' + h(error || "") + "</div></div>";
124
+ document.getElementById("f").onsubmit = (e) => { e.preventDefault(); code = document.getElementById("c").value.trim().toLowerCase(); store.set("vigent-code", code); start(); };
125
+ }
126
+
127
+ function header(title, back) {
128
+ return "<header>" + (back ? '<button class="back" id="back" aria-label="All agents">‹</button>' : "") +
129
+ "<h1>" + title + '</h1><span class="dot' + (state.live ? " live" : "") + '" title="' + (state.live ? "live" : "reconnecting") + '"></span></header>';
130
+ }
131
+
132
+ function meta(a) {
133
+ return h(a.engineName) + " · " + h(a.model || "default") + (a.effort ? " · " + h(a.effort) : "") +
134
+ (a.branch ? " · ⎇ " + h(a.branch) : "") +
135
+ (a.added || a.removed ? ' · <span class="add">+' + a.added + '</span> <span class="del">−' + a.removed + "</span>" : "") +
136
+ (a.tokens ? " · " + tok(a.tokens) + " tok" + usd(a.cost) : "");
137
+ }
138
+
139
+ function composer(placeholder, running) {
140
+ return '<div class="composer"><form class="inner" id="send"><textarea id="t" rows="1" placeholder="' + h(placeholder) + '"></textarea>' +
141
+ (running ? '<button type="button" class="btn stop" id="stop">Stop</button>' : "") + '<button class="btn" id="go">Send</button></form></div>';
142
+ }
143
+
144
+ function wireComposer(onSend) {
145
+ const t = document.getElementById("t");
146
+ const grow = () => { t.style.height = "auto"; t.style.height = Math.min(t.scrollHeight, innerHeight * 0.4) + "px"; };
147
+ t.oninput = grow;
148
+ t.onkeydown = (e) => { if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) { e.preventDefault(); document.getElementById("send").requestSubmit(); } };
149
+ document.getElementById("send").onsubmit = async (e) => {
150
+ e.preventDefault();
151
+ const text = t.value.trim();
152
+ if (!text || state.sending) return;
153
+ state.sending = true; document.getElementById("go").disabled = true;
154
+ try { await onSend(text); t.value = ""; grow(); } catch (err) { toast(err.message); }
155
+ state.sending = false; const go = document.getElementById("go"); if (go) go.disabled = false;
156
+ };
157
+ const stop = document.getElementById("stop");
158
+ if (stop) stop.onclick = async () => { try { await api("/api/agents/" + state.open + "/stop", {}); toast("stopped"); } catch (err) { toast(err.message); } };
159
+ }
160
+
161
+ function renderList() {
162
+ const draft = document.getElementById("t")?.value || "";
163
+ const cards = state.agents.map((a) =>
164
+ '<button class="card" data-id="' + a.id + '"><div class="row"><span class="name">' + h(a.name) + "</span>" +
165
+ (a.unread ? '<span class="new">● new</span>' : "") + '<span class="badge ' + h(a.state) + '">' + h(a.state) + (a.queued ? " +" + a.queued : "") + "</span></div>" +
166
+ '<div class="meta">' + meta(a) + "</div>" + (a.last ? '<div class="last">' + (a.last.kind === "user" ? "❯ " : "") + h(a.last.text) + "</div>" : "") + "</button>").join("");
167
+ $app.innerHTML = header('vigent <span class="project">· ' + h(state.project) + "</span>") +
168
+ "<main>" + (cards || '<div class="empty">No agents yet. Describe a task below to start one.</div>') + "</main>" + composer("New agent: describe a task", false);
169
+ for (const c of document.querySelectorAll(".card")) c.onclick = () => openAgent(Number(c.dataset.id));
170
+ document.getElementById("t").value = draft;
171
+ wireComposer(async (text) => { const r = await api("/api/agents", { text }); toast("started"); openAgent(r.id); });
172
+ }
173
+
174
+ function itemHtml(i, last) {
175
+ if (i.kind === "user") return '<div class="item user">' + h(i.text) + "</div>";
176
+ if (i.kind === "agent") return '<div class="item agent' + (i.streaming && last ? " streaming" : "") + '">' + markdown(i.text) + "</div>";
177
+ if (i.kind === "tool" && i.tool) return '<details class="item"><summary><b>' + h(i.tool.name) + "</b> " + h(i.tool.summary) + "</summary>" + (i.tool.output ? "<pre>" + h(i.tool.output) + "</pre>" : "") + "</details>";
178
+ return '<div class="item info">' + h(i.text) + "</div>";
179
+ }
180
+
181
+ function renderAgent() {
182
+ const a = state.agents.find((x) => x.id === state.open);
183
+ if (!a) { state.open = null; return renderList(); }
184
+ const nearBottom = innerHeight + scrollY >= document.body.scrollHeight - 80;
185
+ const open = new Set([...document.querySelectorAll("details[open]")].map((d) => d.dataset.i));
186
+ const draft = document.getElementById("t")?.value || "";
187
+ const hadFocus = document.activeElement?.id === "t";
188
+ $app.innerHTML = header(h(a.name), true) +
189
+ '<main><div class="meta" style="white-space:normal">' + meta(a) + ' · <span class="badge ' + h(a.state) + '">' + h(a.state) + "</span></div>" +
190
+ (state.items.length ? state.items.map((i, n) => itemHtml(i, n === state.items.length - 1).replace("<details", '<details data-i="' + n + '"' + (open.has(String(n)) ? " open" : ""))).join("") : '<div class="note">Nothing yet.</div>') +
191
+ "</main>" + composer(a.state === "running" ? "Queue a follow-up…" : "Follow up…", a.state === "running");
192
+ document.getElementById("back").onclick = () => { state.open = null; history.back(); };
193
+ const t = document.getElementById("t"); t.value = draft; if (hadFocus) t.focus();
194
+ wireComposer(async (text) => { const r = await api("/api/agents/" + a.id + "/prompt", { text }); toast(r.queued ? "queued" : "sent"); });
195
+ if (nearBottom) scrollTo(0, document.body.scrollHeight);
196
+ }
197
+
198
+ let loading = null;
199
+ async function loadAgent() {
200
+ if (state.open === null) return;
201
+ if (loading) { loading.again = true; return; }
202
+ loading = { again: false };
203
+ try {
204
+ const r = await api("/api/agents/" + state.open);
205
+ state.items = r.items; state.version = r.agent.version;
206
+ if (state.open === r.agent.id) renderAgent();
207
+ } catch (err) { if (err.auth) return renderLogin(err.message); toast(err.message); }
208
+ const again = loading.again; loading = null;
209
+ if (again) loadAgent();
210
+ }
211
+
212
+ function openAgent(id) {
213
+ state.open = id; state.items = []; state.version = -1;
214
+ history.pushState({ id }, "", "#agent-" + id);
215
+ renderAgent(); scrollTo(0, 0); loadAgent();
216
+ }
217
+ addEventListener("popstate", () => { if (state.open !== null) { state.open = null; renderList(); } });
218
+
219
+ let source;
220
+ function listen() {
221
+ source?.close();
222
+ source = new EventSource("/api/events?code=" + encodeURIComponent(code));
223
+ source.onopen = () => { state.live = true; document.querySelector(".dot")?.classList.add("live"); };
224
+ source.onerror = () => { state.live = false; document.querySelector(".dot")?.classList.remove("live"); };
225
+ source.addEventListener("agents", (e) => {
226
+ state.agents = JSON.parse(e.data).agents;
227
+ if (state.open === null) return renderList();
228
+ const a = state.agents.find((x) => x.id === state.open);
229
+ if (!a) { state.open = null; return renderList(); }
230
+ if (a.version !== state.version) loadAgent(); else renderAgent();
231
+ });
232
+ }
233
+
234
+ async function start() {
235
+ if (!code) return renderLogin();
236
+ try {
237
+ const r = await api("/api/state");
238
+ state.project = r.project; state.agents = r.agents;
239
+ renderList(); listen();
240
+ } catch (err) { renderLogin(err.auth ? err.message : "can't reach vigent: " + err.message); }
241
+ }
242
+ start();
243
+ </script>
244
+ </body>
245
+ </html>
246
+ `;
247
+ }