@xingjisoft/neutrino 0.1.4 → 0.1.5

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/web/app.js ADDED
@@ -0,0 +1,999 @@
1
+ import { createSettingsUI } from "./settings.js";
2
+
3
+ // Neutrino web UI — a dependency-free single page app.
4
+ //
5
+ // It talks to the server API:
6
+ // GET /api/overview every saved conversation grouped by cwd
7
+ // GET /api/session/:id one saved transcript
8
+ // GET /api/live/:key/stream SSE stream of a running conversation
9
+ // POST /api/live/:key/{pause|resume|message|model}
10
+ // GET/POST /api/settings model defaults and conversation settings
11
+ //
12
+ // The sidebar is refreshed on a timer; the open live conversation is pushed
13
+ // over SSE so tokens appear as they are generated.
14
+
15
+ const MAX_VISIBLE = 5;
16
+ const REFRESH_MS = 4000;
17
+
18
+ // -------------------------------------------------------------------- access
19
+ //
20
+ // Login is remembered in an HttpOnly cookie shared by fetch and EventSource.
21
+ // Remove credentials left behind by older versions of the UI.
22
+ try { localStorage.removeItem("neutrino.token"); } catch { /* Storage may be blocked. */ }
23
+ async function loginWithPassword(password) {
24
+ const response = await fetch("/api/auth/login", {
25
+ method: "POST", credentials: "same-origin", headers: { "Content-Type": "application/json" },
26
+ body: JSON.stringify({ password }),
27
+ });
28
+ const body = await response.json();
29
+ if (!response.ok) throw new Error(body.error || "Could not sign in.");
30
+ }
31
+
32
+ // Complete URL login before API requests start, so a slow password check cannot
33
+ // flash the login dialog or race the initial conversation/settings requests.
34
+ const initialLogin = (() => {
35
+ const params = new URLSearchParams(location.search);
36
+ const password = params.get("pwd");
37
+ if (params.has("pwd") || params.has("t") || params.has("token")) {
38
+ params.delete("pwd");
39
+ params.delete("t");
40
+ params.delete("token");
41
+ const query = params.toString();
42
+ try {
43
+ history.replaceState(null, "", location.pathname + (query ? `?${query}` : "") + location.hash);
44
+ } catch { /* Login still works when history updates are unavailable. */ }
45
+ }
46
+ if (password === null) return Promise.resolve(true);
47
+ return loginWithPassword(password).then(() => true).catch(error => {
48
+ showGate(error.message);
49
+ return false;
50
+ });
51
+ })();
52
+
53
+ async function apiFetch(path, init = {}) {
54
+ if (!await initialLogin) throw new Error("Password login required.");
55
+ const response = await fetch(path, { ...init, credentials: "same-origin" });
56
+ if (response.status === 401) {
57
+ showGate();
58
+ throw new Error("Password login required.");
59
+ }
60
+ return response;
61
+ }
62
+
63
+ let gateShown = false;
64
+
65
+ function showGate(message = "") {
66
+ if (gateShown) return;
67
+ gateShown = true;
68
+ closeStream();
69
+ setStatus("locked", "offline");
70
+ document.getElementById("settings-dialog").close();
71
+
72
+ // A modal dialog stays above streaming repaints and traps keyboard focus.
73
+ const gate = document.createElement("dialog");
74
+ gate.className = "gate";
75
+ gate.setAttribute("aria-labelledby", "login-title");
76
+ gate.innerHTML =
77
+ '<form class="gate-card">' +
78
+ '<h2 id="login-title">Sign in to Neutrino</h2>' +
79
+ '<p>Enter your Web password. The default password is 114514.</p>' +
80
+ '<label for="login-password">Password</label>' +
81
+ '<input id="login-password" name="password" type="password" autocomplete="current-password" maxlength="256" required autofocus>' +
82
+ '<p class="form-feedback error" role="alert"></p>' +
83
+ '<button class="primary" type="submit">Sign in</button>' +
84
+ "</form>";
85
+ gate.querySelector(".form-feedback").textContent = message;
86
+ gate.addEventListener("cancel", event => event.preventDefault());
87
+ gate.querySelector("form").addEventListener("submit", async event => {
88
+ event.preventDefault();
89
+ const input = gate.querySelector("input");
90
+ const button = gate.querySelector("button");
91
+ const feedback = gate.querySelector(".form-feedback");
92
+ button.disabled = true;
93
+ feedback.textContent = "";
94
+ try {
95
+ await loginWithPassword(input.value);
96
+ location.reload();
97
+ } catch (error) {
98
+ feedback.textContent = error.message;
99
+ input.focus();
100
+ input.select();
101
+ } finally { button.disabled = false; }
102
+ });
103
+ document.body.appendChild(gate);
104
+ gate.showModal();
105
+ }
106
+
107
+ const el = {
108
+ sessions: document.getElementById("sessions"),
109
+ status: document.getElementById("status"),
110
+ refresh: document.getElementById("refresh"),
111
+ title: document.getElementById("session-title"),
112
+ sub: document.getElementById("session-sub"),
113
+ controls: document.getElementById("controls"),
114
+ messages: document.getElementById("messages"),
115
+ composer: document.getElementById("composer"),
116
+ input: document.getElementById("input"),
117
+ send: document.getElementById("send"),
118
+ approval: document.getElementById("approval"),
119
+ };
120
+
121
+ const state = {
122
+ overview: null,
123
+ // { kind: "live", key } | { kind: "file", id } | null
124
+ selection: null,
125
+ snapshot: null,
126
+ eventSource: null,
127
+ lastSessionId: null,
128
+ collapsed: new Set(),
129
+ expanded: new Set(),
130
+ openTools: new Set(),
131
+ openContext: null,
132
+ };
133
+
134
+ const preferences = createSettingsUI({
135
+ apiFetch,
136
+ async applyModel(selection) {
137
+ const current = state.selection;
138
+ if (!current) throw new Error("Select a conversation first.");
139
+ if (current.kind === "file") {
140
+ await continueSession(current.id, selection);
141
+ return;
142
+ }
143
+ const response = await apiFetch(`/api/live/${encodeURIComponent(current.key)}/model`, {
144
+ method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(selection),
145
+ });
146
+ const body = await response.json();
147
+ if (!response.ok) throw new Error(body.error || `HTTP ${response.status}`);
148
+ if (state.selection?.kind === "live" && state.selection.key === current.key) {
149
+ state.snapshot = body.snapshot;
150
+ renderLive();
151
+ }
152
+ },
153
+ });
154
+
155
+ // ------------------------------------------------------------------- sounds
156
+ //
157
+ // A turn that ends while the tab is in the background is invisible, so finished
158
+ // turns and waiting approvals each get a short motif. Browsers refuse to start
159
+ // audio without a user gesture, so the context is created on the first
160
+ // interaction and reused; before that a cue is skipped rather than queued.
161
+
162
+ let audioContext = null;
163
+
164
+ function unlockAudio() {
165
+ try {
166
+ const Ctor = window.AudioContext || window.webkitAudioContext;
167
+ if (!Ctor) return;
168
+ if (!audioContext) audioContext = new Ctor();
169
+ if (audioContext.state === "suspended") audioContext.resume().catch(() => {});
170
+ } catch {
171
+ // No audio available (blocked, unsupported, mocked): stay silent.
172
+ audioContext = null;
173
+ }
174
+ }
175
+
176
+ // Feature-detected: some embedders (and the test harness) expose no events.
177
+ if (typeof document !== "undefined" && typeof document.addEventListener === "function") {
178
+ document.addEventListener("pointerdown", unlockAudio, true);
179
+ document.addEventListener("keydown", unlockAudio, true);
180
+ }
181
+
182
+ const SOUND_MOTIFS = {
183
+ // Turn finished: two short rising notes.
184
+ turn: [[660, 0], [988, 0.13]],
185
+ // Approval waiting: three insistent notes, because this blocks the agent.
186
+ approval: [[784, 0], [587, 0.16], [784, 0.32]],
187
+ };
188
+
189
+ function chime(kind) {
190
+ if (!preferences.soundAlerts()) return;
191
+ unlockAudio();
192
+ if (!audioContext || audioContext.state === "closed") return;
193
+ const start = audioContext.currentTime + 0.01;
194
+ for (const [frequency, offset] of SOUND_MOTIFS[kind] || SOUND_MOTIFS.turn) {
195
+ const oscillator = audioContext.createOscillator();
196
+ const gain = audioContext.createGain();
197
+ oscillator.type = "sine";
198
+ oscillator.frequency.value = frequency;
199
+ const at = start + offset;
200
+ gain.gain.setValueAtTime(0.0001, at);
201
+ gain.gain.exponentialRampToValueAtTime(0.16, at + 0.02);
202
+ gain.gain.exponentialRampToValueAtTime(0.0001, at + 0.13);
203
+ oscillator.connect(gain);
204
+ gain.connect(audioContext.destination);
205
+ oscillator.start(at);
206
+ oscillator.stop(at + 0.15);
207
+ }
208
+ }
209
+
210
+ /**
211
+ * Cues fire on transitions, not on every frame: the stream re-sends a snapshot
212
+ * on every update, so comparing against the previous one keeps this from
213
+ * turning into a metronome. The first frame is skipped because it describes a
214
+ * state the page has just loaded.
215
+ */
216
+ function announce(previous, next) {
217
+ if (!previous || previous.key !== next.key) return;
218
+ if (!previous.approval && next.approval) chime("approval");
219
+ else if (previous.busy && !next.busy && !next.approval) chime("turn");
220
+ }
221
+
222
+ // ------------------------------------------------------------------ helpers
223
+
224
+ function escapeHtml(value) {
225
+ return String(value ?? "")
226
+ .replace(/&/g, "&amp;")
227
+ .replace(/</g, "&lt;")
228
+ .replace(/>/g, "&gt;")
229
+ .replace(/"/g, "&quot;")
230
+ .replace(/'/g, "&#39;");
231
+ }
232
+
233
+ function truncate(value, max) {
234
+ const text = String(value ?? "");
235
+ return text.length > max ? `${text.slice(0, max)}…` : text;
236
+ }
237
+
238
+ function safeJson(value) {
239
+ try {
240
+ return JSON.stringify(value ?? {});
241
+ } catch {
242
+ return "{}";
243
+ }
244
+ }
245
+
246
+ function formatTime(value) {
247
+ if (!value) return "";
248
+ const date = new Date(value);
249
+ if (Number.isNaN(date.getTime())) return "";
250
+ const now = Date.now();
251
+ const diff = now - date.getTime();
252
+ if (diff < 60_000) return "just now";
253
+ if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`;
254
+ if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}h ago`;
255
+ return date.toLocaleDateString(undefined, { month: "short", day: "numeric" });
256
+ }
257
+
258
+ function formatDate(value) {
259
+ if (!value) return "";
260
+ const date = new Date(value);
261
+ return Number.isNaN(date.getTime()) ? "" : date.toLocaleString();
262
+ }
263
+
264
+ function setStatus(text, kind) {
265
+ el.status.textContent = text;
266
+ el.status.className = `status${kind ? ` ${kind}` : ""}`;
267
+ }
268
+
269
+ // A deliberately small markdown renderer: code fences, inline code, bold,
270
+ // italics and links. Everything else is escaped, so model output is inert.
271
+ function renderMarkdown(value) {
272
+ const placeholders = [];
273
+ let source = String(value ?? "");
274
+ source = source.replace(/```[\w-]*\n?([\s\S]*?)```/g, (_, code) => {
275
+ const id = `\u0000CODE${placeholders.length}\u0000`;
276
+ placeholders.push(`<pre class="code"><code>${escapeHtml(code.replace(/\n$/, ""))}</code></pre>`);
277
+ return id;
278
+ });
279
+ source = escapeHtml(source);
280
+ source = source.replace(/`([^`\n]+)`/g, "<code>$1</code>");
281
+ source = source.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
282
+ source = source.replace(/(^|[^*])\*([^*\n]+)\*/g, "$1<em>$2</em>");
283
+ source = source.replace(
284
+ /\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g,
285
+ '<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>',
286
+ );
287
+ source = source.replace(/\n/g, "<br>");
288
+ source = source.replace(/\u0000CODE(\d+)\u0000/g, (_, index) => placeholders[Number(index)] ?? "");
289
+ return source;
290
+ }
291
+
292
+ // ------------------------------------------------------------------- status
293
+
294
+ async function loadOverview(options = {}) {
295
+ // Once the gate is up every poll would only produce another 401.
296
+ if (gateShown) return;
297
+ try {
298
+ const response = await apiFetch("/api/overview", { cache: "no-store" });
299
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
300
+ const overview = await response.json();
301
+ if (gateShown) return;
302
+ state.overview = overview;
303
+ setStatus(state.selection?.kind === "live" ? "live" : "online", state.selection?.kind === "live" ? "live" : "online");
304
+ renderSidebar();
305
+ syncSelection();
306
+ } catch (error) {
307
+ if (!gateShown) setStatus("offline", "offline");
308
+ if (!options.silent) console.error(error);
309
+ }
310
+ }
311
+
312
+ function syncSelection() {
313
+ const selection = state.selection;
314
+ if (!selection || selection.kind !== "live") return;
315
+ const live = state.overview?.live ?? [];
316
+ if (live.some((entry) => entry.key === selection.key)) return;
317
+
318
+ // The terminal session went away. Prefer the saved transcript when we have it.
319
+ const sessionId = state.lastSessionId;
320
+ closeStream();
321
+ if (sessionId) {
322
+ selectFile(sessionId);
323
+ return;
324
+ }
325
+ state.selection = null;
326
+ state.snapshot = null;
327
+ renderSidebar();
328
+ hideComposer();
329
+ el.controls.innerHTML = "";
330
+ el.title.textContent = "Session ended";
331
+ el.sub.textContent = "The terminal session was closed.";
332
+ el.messages.innerHTML = '<div class="empty">This live session has ended.</div>';
333
+ }
334
+
335
+ // ------------------------------------------------------------------ sidebar
336
+
337
+ function renderSidebar() {
338
+ const overview = state.overview;
339
+ el.sessions.innerHTML = "";
340
+
341
+ if (!overview || overview.groups.length === 0) {
342
+ const empty = document.createElement("div");
343
+ empty.className = "empty-side";
344
+ empty.textContent = "No conversations yet.";
345
+ el.sessions.appendChild(empty);
346
+ return;
347
+ }
348
+
349
+ for (const group of overview.groups) {
350
+ el.sessions.appendChild(renderGroup(group));
351
+ }
352
+ }
353
+
354
+ function renderGroup(group) {
355
+ const wrap = document.createElement("section");
356
+ wrap.className = "group";
357
+
358
+ const collapsed = state.collapsed.has(group.key);
359
+ const header = document.createElement("button");
360
+ header.className = "group-header";
361
+ header.innerHTML =
362
+ `<span class="chevron">${collapsed ? "▸" : "▾"}</span>` +
363
+ `<span class="path" title="${escapeHtml(group.cwd)}">${escapeHtml(group.cwd)}</span>` +
364
+ `<span class="count">${group.sessions.length}</span>`;
365
+ header.addEventListener("click", () => {
366
+ if (state.collapsed.has(group.key)) state.collapsed.delete(group.key);
367
+ else state.collapsed.add(group.key);
368
+ renderSidebar();
369
+ });
370
+ wrap.appendChild(header);
371
+
372
+ if (collapsed) return wrap;
373
+
374
+ const list = document.createElement("div");
375
+ list.className = "group-items";
376
+ const expanded = state.expanded.has(group.key);
377
+ const visible = expanded ? group.sessions : group.sessions.slice(0, MAX_VISIBLE);
378
+ for (const session of visible) list.appendChild(renderSession(session));
379
+
380
+ const hiddenCount = group.sessions.length - MAX_VISIBLE;
381
+ if (hiddenCount > 0) {
382
+ const more = document.createElement("button");
383
+ more.className = "more";
384
+ more.textContent = expanded ? "Show less" : `Show ${hiddenCount} more`;
385
+ more.addEventListener("click", () => {
386
+ if (state.expanded.has(group.key)) state.expanded.delete(group.key);
387
+ else state.expanded.add(group.key);
388
+ renderSidebar();
389
+ });
390
+ list.appendChild(more);
391
+ }
392
+
393
+ wrap.appendChild(list);
394
+ return wrap;
395
+ }
396
+
397
+ function isSelected(session) {
398
+ const selection = state.selection;
399
+ if (!selection) return false;
400
+ if (selection.kind === "live") return Boolean(session.liveKey) && selection.key === session.liveKey;
401
+ return !session.liveKey && session.id === selection.id;
402
+ }
403
+
404
+ function renderSession(session) {
405
+ const item = document.createElement("button");
406
+ item.className = "session";
407
+ if (isSelected(session)) item.classList.add("selected");
408
+ if (session.live) item.classList.add("live");
409
+
410
+ const title = session.title || "New conversation";
411
+ item.innerHTML =
412
+ `<div class="session-top"><span class="session-title">${escapeHtml(title)}</span>` +
413
+ `${session.live ? '<span class="dot" title="Live"></span>' : ""}</div>` +
414
+ `<div class="session-meta"><span class="model">${escapeHtml(session.model || "?")}</span>` +
415
+ `<span class="time">${escapeHtml(formatTime(session.updated_at))}</span>` +
416
+ `<span class="count">${Number(session.message_count ?? 0)} msg</span></div>`;
417
+
418
+ item.addEventListener("click", () => {
419
+ if (session.liveKey) selectLive(session.liveKey);
420
+ else if (session.id) selectFile(session.id);
421
+ });
422
+ return item;
423
+ }
424
+
425
+ // ------------------------------------------------------------------- select
426
+
427
+ function resetMessagePane(text) {
428
+ el.messages.innerHTML = "";
429
+ const empty = document.createElement("div");
430
+ empty.className = "empty";
431
+ empty.textContent = text;
432
+ el.messages.appendChild(empty);
433
+ }
434
+
435
+ async function selectLive(key) {
436
+ preferences.clear();
437
+ closeStream();
438
+ state.selection = { kind: "live", key };
439
+ state.snapshot = null;
440
+ state.lastSessionId = null;
441
+ state.openTools.clear();
442
+ state.openContext = null;
443
+ renderSidebar();
444
+ el.controls.innerHTML = "";
445
+ el.title.textContent = "Connecting…";
446
+ el.sub.textContent = "";
447
+ setComposerMode("live");
448
+ resetMessagePane("Connecting…");
449
+ openStream(key);
450
+ }
451
+
452
+ function openStream(key) {
453
+ closeStream();
454
+ const source = new EventSource(`/api/live/${encodeURIComponent(key)}/stream`);
455
+ state.eventSource = source;
456
+
457
+ source.addEventListener("snapshot", (event) => {
458
+ let snapshot;
459
+ try {
460
+ snapshot = JSON.parse(event.data);
461
+ } catch {
462
+ return;
463
+ }
464
+ if (gateShown) return;
465
+ const previous = state.snapshot;
466
+ state.snapshot = snapshot;
467
+ if (snapshot.sessionId) state.lastSessionId = snapshot.sessionId;
468
+ announce(previous, snapshot);
469
+ setStatus("live", "live");
470
+ renderLive();
471
+ });
472
+
473
+ source.addEventListener("gone", () => {
474
+ closeStream();
475
+ syncSelection();
476
+ });
477
+
478
+ source.onopen = () => setStatus("live", "live");
479
+ source.onerror = () => {
480
+ // EventSource retries automatically; just surface the state.
481
+ setStatus("reconnecting…", "offline");
482
+ };
483
+ }
484
+
485
+ function closeStream() {
486
+ if (state.eventSource) {
487
+ state.eventSource.close();
488
+ state.eventSource = null;
489
+ }
490
+ }
491
+
492
+ async function selectFile(id) {
493
+ preferences.clear();
494
+ closeStream();
495
+ state.selection = { kind: "file", id };
496
+ state.snapshot = null;
497
+ state.openTools.clear();
498
+ state.openContext = null;
499
+ renderSidebar();
500
+ el.controls.innerHTML = "";
501
+ setComposerMode("resume");
502
+ el.title.textContent = "Loading…";
503
+ el.sub.textContent = "";
504
+ resetMessagePane("Loading…");
505
+
506
+ try {
507
+ const response = await apiFetch(`/api/session/${encodeURIComponent(id)}`, { cache: "no-store" });
508
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
509
+ const data = await response.json();
510
+ renderFile(data);
511
+ } catch (error) {
512
+ el.title.textContent = "Conversation";
513
+ resetMessagePane(`Could not load this conversation: ${error.message}`);
514
+ }
515
+ }
516
+
517
+ // -------------------------------------------------------------------- render
518
+
519
+ function renderFile(data) {
520
+ const messages = Array.isArray(data.messages) ? data.messages : [];
521
+ el.title.textContent = firstUserMessage(messages) || "Conversation";
522
+ el.sub.textContent = [data.cwd, data.model, data.provider].filter(Boolean).join(" · ");
523
+ preferences.showConversation(data, `file:${data.id}`);
524
+ renderMessageList(messages, null, { readOnly: true, context: data.context });
525
+ renderFileControls(data);
526
+ }
527
+
528
+ /**
529
+ * A saved transcript is read-only by nature: nothing is running on the machine.
530
+ * Offer to pick it up in a browser-owned session, which is created on demand.
531
+ */
532
+ function renderFileControls(data) {
533
+ el.controls.innerHTML = "";
534
+
535
+ const badge = document.createElement("span");
536
+ badge.className = "badge readonly";
537
+ badge.textContent = "read-only";
538
+ el.controls.appendChild(badge);
539
+
540
+ const button = document.createElement("button");
541
+ button.className = "control primary";
542
+ button.textContent = "✎ Continue here";
543
+ button.title = "Resume this conversation in the browser and keep chatting";
544
+ button.addEventListener("click", async () => {
545
+ button.disabled = true;
546
+ button.textContent = "Starting…";
547
+ try {
548
+ await continueSession(data.id);
549
+ el.input.focus();
550
+ } catch (error) {
551
+ button.disabled = false;
552
+ button.textContent = "✎ Continue here";
553
+ el.sub.textContent = `Could not continue this conversation: ${error.message}`;
554
+ }
555
+ });
556
+ el.controls.appendChild(button);
557
+ renderApprovalModeControl(data.approval_mode, async (mode) => {
558
+ const key = await continueSession(data.id);
559
+ await changeApprovalMode(key, mode);
560
+ });
561
+ }
562
+
563
+ /** Attach to a resumed conversation, creating it server-side when needed. */
564
+ async function continueSession(id, selection = preferences.selectionForFile(id)) {
565
+ const response = await apiFetch(`/api/session/${encodeURIComponent(id)}/continue`, {
566
+ method: "POST",
567
+ headers: selection ? { "Content-Type": "application/json" } : undefined,
568
+ body: selection ? JSON.stringify({ selection }) : undefined,
569
+ });
570
+ const body = await response.json().catch(() => ({}));
571
+ if (!response.ok) throw new Error(body.error || `HTTP ${response.status}`);
572
+ await selectLive(body.key);
573
+ return body.key;
574
+ }
575
+
576
+ function setComposerMode(mode) {
577
+ el.composer.classList.remove("hidden");
578
+ el.input.placeholder =
579
+ mode === "resume"
580
+ ? "Continue this conversation… (Enter to send)"
581
+ : "Message the agent… (Enter to send, Shift+Enter for a new line)";
582
+ }
583
+
584
+ function hideComposer() {
585
+ preferences.clear();
586
+ el.composer.classList.add("hidden");
587
+ renderApproval(null);
588
+ }
589
+
590
+ function renderApprovalDetails(request, fallbackCwd = "") {
591
+ const details = document.createElement("div");
592
+ details.className = "approval-details";
593
+ const tool = document.createElement("div");
594
+ tool.className = "approval-tool";
595
+ tool.textContent = request.tool || "Tool call";
596
+ details.appendChild(tool);
597
+
598
+ const cwd = request.cwd || fallbackCwd;
599
+ if (cwd) {
600
+ const location = document.createElement("div");
601
+ location.className = "approval-location";
602
+ location.textContent = `Working directory: ${cwd}`;
603
+ details.appendChild(location);
604
+ }
605
+
606
+ const args = request.arguments;
607
+ // `reason` is the agent's own justification for asking. It gets its own line so
608
+ // the command block below stays exactly what would run.
609
+ const stated = args && typeof args.reason === "string" ? args.reason.trim() : "";
610
+ const callArgs = args
611
+ ? Object.fromEntries(Object.entries(args).filter(([name]) => name !== "reason"))
612
+ : null;
613
+ const command = callArgs && typeof callArgs.command === "string" ? callArgs.command : null;
614
+ const code = document.createElement("pre");
615
+ code.className = "approval-code";
616
+ // Literal text preserves the complete command and prevents tool arguments
617
+ // from becoming markup. Risk summaries may be shortened; arguments are not.
618
+ code.textContent = command !== null ? command
619
+ : callArgs ? JSON.stringify(callArgs, null, 2) : request.summary || "Command details unavailable.";
620
+ details.appendChild(code);
621
+
622
+ if (stated) {
623
+ const why = document.createElement("div");
624
+ why.className = "approval-why";
625
+ const label = document.createElement("strong");
626
+ label.textContent = "Why: ";
627
+ const claim = document.createElement("span");
628
+ claim.textContent = stated;
629
+ why.append(label, claim);
630
+ details.appendChild(why);
631
+ }
632
+
633
+ if (command !== null && Object.keys(callArgs).some(name => name !== "command")) {
634
+ const argumentsBox = document.createElement("details");
635
+ argumentsBox.className = "approval-arguments";
636
+ const label = document.createElement("summary");
637
+ label.textContent = "All arguments";
638
+ const parameters = document.createElement("pre");
639
+ parameters.className = "approval-code";
640
+ parameters.textContent = JSON.stringify(callArgs, null, 2);
641
+ argumentsBox.append(label, parameters);
642
+ details.appendChild(argumentsBox);
643
+ }
644
+
645
+ if (Array.isArray(request.reasons) && request.reasons.length) {
646
+ const reasons = document.createElement("ul");
647
+ reasons.className = "approval-reasons";
648
+ for (const reason of request.reasons) {
649
+ const item = document.createElement("li");
650
+ item.textContent = reason;
651
+ reasons.appendChild(item);
652
+ }
653
+ details.appendChild(reasons);
654
+ }
655
+ return details;
656
+ }
657
+
658
+ function renderApproval(snapshot) {
659
+ const pending = snapshot && snapshot.approval;
660
+ if (!pending) {
661
+ // An automated review runs before a card exists, so without this the tab
662
+ // just looks stuck for however long the reviewers take.
663
+ const review = snapshot && snapshot.reviewing;
664
+ if (review) {
665
+ el.approval.className = "approval high";
666
+ const head = document.createElement("div");
667
+ head.className = "approval-head";
668
+ const title = document.createElement("strong");
669
+ title.textContent = "Independent review";
670
+ const note = document.createElement("span");
671
+ note.className = "approval-risk";
672
+ // Delete-class calls report which of the three separate rounds is running.
673
+ const rounds = review.rounds || 1;
674
+ const which = rounds > 1 ? `round ${review.round || 1} of ${rounds}, ` : "";
675
+ note.textContent = `${which}${review.total} reviewer${review.total > 1 ? "s" : ""}`;
676
+ head.append(title, note);
677
+ if (review.request) {
678
+ el.approval.replaceChildren(head, renderApprovalDetails(review.request, snapshot.cwd));
679
+ } else {
680
+ el.approval.replaceChildren(head);
681
+ }
682
+ return;
683
+ }
684
+ el.approval.classList.add("hidden");
685
+ el.approval.replaceChildren();
686
+ return;
687
+ }
688
+ const extreme = pending.level === "extreme";
689
+ el.approval.className = "approval " + (extreme ? "extreme" : "high");
690
+
691
+ const head = document.createElement("div");
692
+ head.className = "approval-head";
693
+ const title = document.createElement("strong");
694
+ title.textContent = "Request Approval";
695
+ const risk = document.createElement("span");
696
+ risk.className = "approval-risk";
697
+ risk.textContent = extreme ? "EXTREME RISK" : "HIGH RISK";
698
+ head.append(title, risk);
699
+
700
+ const details = renderApprovalDetails(pending, snapshot.cwd);
701
+
702
+ const actions = document.createElement("div");
703
+ actions.className = "approval-actions";
704
+ const approve = document.createElement("button");
705
+ approve.className = "approve-btn";
706
+ approve.textContent = "Approve";
707
+ approve.addEventListener("click", () => decideApproval(true));
708
+ const deny = document.createElement("button");
709
+ deny.className = "deny-btn";
710
+ deny.textContent = "Deny";
711
+ deny.addEventListener("click", () => decideApproval(false));
712
+ actions.append(approve, deny);
713
+
714
+ el.approval.replaceChildren(head, details, actions);
715
+ }
716
+
717
+ function decideApproval(approved) {
718
+ const snapshot = state.snapshot;
719
+ if (!snapshot || !snapshot.approval) return;
720
+ postAction(snapshot.key, "approve", { approved });
721
+ }
722
+
723
+ function renderLive() {
724
+ const snapshot = state.snapshot;
725
+ if (!snapshot) return;
726
+ el.title.textContent = snapshot.title || "New conversation";
727
+ el.sub.textContent = [snapshot.cwd, snapshot.model, snapshot.provider].filter(Boolean).join(" · ");
728
+ preferences.showConversation(snapshot, `live:${snapshot.key}`);
729
+ renderControls(snapshot);
730
+ renderApproval(snapshot);
731
+ renderMessageList(snapshot.messages ?? [], snapshot.streamingContent, { readOnly: false, context: snapshot.context });
732
+ }
733
+
734
+ async function changeApprovalMode(key, mode) {
735
+ const response = await apiFetch(`/api/live/${encodeURIComponent(key)}/approval-mode`, {
736
+ method: "POST",
737
+ headers: { "Content-Type": "application/json" },
738
+ body: JSON.stringify({ mode }),
739
+ });
740
+ const body = await response.json();
741
+ if (!response.ok) throw new Error(body.error || `HTTP ${response.status}`);
742
+ if (state.selection?.kind === "live" && state.selection.key === key) {
743
+ state.snapshot = body.snapshot;
744
+ renderLive();
745
+ }
746
+ }
747
+
748
+ function renderApprovalModeControl(mode, change) {
749
+ const automatic = mode === "auto";
750
+ const button = document.createElement("button");
751
+ button.className = "control";
752
+ button.type = "button";
753
+ button.setAttribute("role", "switch");
754
+ button.setAttribute("aria-label", "Auto Approval");
755
+ button.setAttribute("aria-checked", String(automatic));
756
+ button.textContent = automatic ? "Auto Approval" : "Request Approval";
757
+ button.title = "Switch between requesting approval and automatic review";
758
+ button.addEventListener("click", async () => {
759
+ button.disabled = true;
760
+ try {
761
+ await change(automatic ? "request" : "auto");
762
+ } catch (error) {
763
+ el.sub.textContent = `Could not change approval mode: ${error.message}`;
764
+ } finally {
765
+ button.disabled = false;
766
+ }
767
+ });
768
+ el.controls.appendChild(button);
769
+ }
770
+
771
+ function renderControls(snapshot) {
772
+ el.controls.innerHTML = "";
773
+ renderApprovalModeControl(snapshot.approvalMode, mode =>
774
+ changeApprovalMode(snapshot.key, mode));
775
+
776
+ const badge = document.createElement("span");
777
+ const mode = snapshot.paused ? "paused" : snapshot.busy ? "busy" : "idle";
778
+ badge.className = `badge ${mode}`;
779
+ badge.textContent = snapshot.paused
780
+ ? "paused"
781
+ : snapshot.busy
782
+ ? snapshot.compressing
783
+ ? "compressing"
784
+ : "working"
785
+ : "idle";
786
+ el.controls.appendChild(badge);
787
+
788
+ if (snapshot.saveError) {
789
+ const warning = document.createElement("span");
790
+ warning.className = "badge paused";
791
+ warning.textContent = `History save failed: ${snapshot.saveError}`;
792
+ el.controls.appendChild(warning);
793
+ }
794
+
795
+ if (snapshot.busy || snapshot.paused) {
796
+ const button = document.createElement("button");
797
+ button.className = "control";
798
+ button.textContent = snapshot.paused ? "▶ Resume" : "❚❚ Pause";
799
+ button.addEventListener("click", () => {
800
+ postAction(snapshot.key, snapshot.paused ? "resume" : "pause");
801
+ });
802
+ el.controls.appendChild(button);
803
+ }
804
+
805
+ if (snapshot.queued) {
806
+ const queued = document.createElement("span");
807
+ queued.className = "queued";
808
+ queued.title = snapshot.queued;
809
+ queued.textContent = "queued";
810
+ el.controls.appendChild(queued);
811
+ }
812
+
813
+ if (snapshot.tokens && (snapshot.tokens.prompt || snapshot.tokens.completion)) {
814
+ const tokens = document.createElement("span");
815
+ tokens.className = "tokens";
816
+ tokens.textContent = `${snapshot.tokens.prompt}→${snapshot.tokens.completion} tok`;
817
+ el.controls.appendChild(tokens);
818
+ }
819
+ }
820
+
821
+ function renderMessageList(messages, streaming, options) {
822
+ // Re-rendering swaps the whole list, so remember where the reader was: if
823
+ // they scrolled up to read history, keep them there while tokens stream in.
824
+ const nearBottom = isNearBottom();
825
+ const previousTop = el.messages.scrollTop;
826
+ el.messages.innerHTML = "";
827
+
828
+ if (messages.length === 0 && !streaming) {
829
+ const empty = document.createElement("div");
830
+ empty.className = "empty";
831
+ empty.textContent = options?.readOnly ? "This conversation is empty." : "Say something to get started.";
832
+ el.messages.appendChild(empty);
833
+ return;
834
+ }
835
+
836
+ const original = messages.filter(message => message.role !== "system");
837
+ const context = options?.context;
838
+ const end = context && Number.isInteger(context.end) && context.end > 0 &&
839
+ context.end <= original.length && typeof context.summary === "string" && context.summary.trim()
840
+ ? context.end : 0;
841
+ if (end) {
842
+ const details = document.createElement("details");
843
+ details.className = "conversation-history";
844
+ const summary = document.createElement("summary");
845
+ summary.textContent = `Earlier conversation · ${end} messages`;
846
+ details.appendChild(summary);
847
+ const body = document.createElement("div");
848
+ body.className = "history-messages";
849
+ details.appendChild(body);
850
+ const populate = () => {
851
+ if (body.childNodes.length) return;
852
+ original.slice(0, end).forEach((message, index) => body.appendChild(renderMessage(message, index)));
853
+ };
854
+ details.open = state.openContext === end;
855
+ if (details.open) populate();
856
+ details.addEventListener("toggle", () => {
857
+ // Detached disclosures can emit a delayed toggle during a stream repaint.
858
+ if (!details.isConnected) return;
859
+ state.openContext = details.open ? end : null;
860
+ if (details.open) populate();
861
+ else body.replaceChildren();
862
+ });
863
+ el.messages.appendChild(details);
864
+ }
865
+ original.forEach((message, index) => {
866
+ if (index >= end) el.messages.appendChild(renderMessage(message, index));
867
+ });
868
+
869
+ if (streaming !== null && streaming !== undefined && streaming !== "") {
870
+ const block = renderMessage({ role: "assistant", content: streaming }, -1);
871
+ block.classList.add("streaming");
872
+ el.messages.appendChild(block);
873
+ }
874
+
875
+ if (nearBottom) scrollToBottom();
876
+ else el.messages.scrollTop = previousTop;
877
+ }
878
+
879
+ function renderMessage(message, index) {
880
+ const article = document.createElement("article");
881
+ article.className = `message ${message.role}`;
882
+
883
+ if (message.role === "tool") {
884
+ const details = document.createElement("details");
885
+ details.className = "tool";
886
+ details.dataset.index = String(index);
887
+ if (state.openTools.has(index)) details.open = true;
888
+ details.addEventListener("toggle", () => {
889
+ if (details.open) state.openTools.add(index);
890
+ else state.openTools.delete(index);
891
+ });
892
+
893
+ const summary = document.createElement("summary");
894
+ summary.innerHTML =
895
+ `<span class="tool-name">⚙ ${escapeHtml(message.name || "tool")}</span>` +
896
+ `<span class="tool-args">${escapeHtml(truncate(safeJson(message.arguments), 120))}</span>`;
897
+ details.appendChild(summary);
898
+
899
+ const result = document.createElement("pre");
900
+ result.className = "tool-result";
901
+ result.textContent = message.result || "(no output)";
902
+ details.appendChild(result);
903
+
904
+ article.appendChild(details);
905
+ return article;
906
+ }
907
+
908
+ if (message.role === "summary") {
909
+ article.classList.add("summary");
910
+ article.innerHTML =
911
+ `<div class="summary-label">Context summary · ${Number(message.count ?? 0)} messages</div>` +
912
+ `<div class="body">${renderMarkdown(message.content)}</div>`;
913
+ return article;
914
+ }
915
+
916
+ const who = message.role === "user" ? "You" : "Neutrino";
917
+ article.innerHTML = `<div class="who">${who}</div><div class="body">${renderMarkdown(message.content)}</div>`;
918
+ return article;
919
+ }
920
+
921
+ function firstUserMessage(messages) {
922
+ for (const message of messages) {
923
+ if (message.role === "user" && message.content) {
924
+ return truncate(message.content.replace(/\s+/g, " ").trim(), 120);
925
+ }
926
+ }
927
+ return "";
928
+ }
929
+
930
+ function isNearBottom() {
931
+ return el.messages.scrollHeight - el.messages.scrollTop - el.messages.clientHeight < 90;
932
+ }
933
+
934
+ function scrollToBottom() {
935
+ el.messages.scrollTop = el.messages.scrollHeight;
936
+ }
937
+
938
+ // ------------------------------------------------------------------- actions
939
+
940
+ async function postAction(key, action, body) {
941
+ try {
942
+ await apiFetch(`/api/live/${encodeURIComponent(key)}/${action}`, {
943
+ method: "POST",
944
+ headers: body ? { "Content-Type": "application/json" } : undefined,
945
+ body: body ? JSON.stringify(body) : undefined,
946
+ });
947
+ } catch (error) {
948
+ console.error(error);
949
+ }
950
+ }
951
+
952
+ async function sendMessage() {
953
+ const selection = state.selection;
954
+ if (!selection) return;
955
+ const text = el.input.value;
956
+ if (!text.trim()) return;
957
+
958
+ if (selection.kind === "live") {
959
+ el.input.value = "";
960
+ autosize();
961
+ await postAction(selection.key, "message", { text });
962
+ return;
963
+ }
964
+
965
+ // Reading a saved transcript: resume it in the browser, then deliver the text.
966
+ el.input.value = "";
967
+ autosize();
968
+ try {
969
+ const key = await continueSession(selection.id);
970
+ await postAction(key, "message", { text });
971
+ } catch (error) {
972
+ el.input.value = text;
973
+ autosize();
974
+ el.sub.textContent = `Could not continue this conversation: ${error.message}`;
975
+ }
976
+ }
977
+
978
+ function autosize() {
979
+ el.input.style.height = "auto";
980
+ el.input.style.height = `${Math.min(el.input.scrollHeight, 220)}px`;
981
+ }
982
+
983
+ // --------------------------------------------------------------------- init
984
+
985
+ el.refresh.addEventListener("click", () => loadOverview());
986
+ el.send.addEventListener("click", sendMessage);
987
+ el.input.addEventListener("input", autosize);
988
+ el.input.addEventListener("keydown", (event) => {
989
+ if (event.key === "Enter" && !event.shiftKey) {
990
+ event.preventDefault();
991
+ sendMessage();
992
+ }
993
+ });
994
+
995
+ window.addEventListener("beforeunload", closeStream);
996
+
997
+ preferences.load().catch(error => console.error(error));
998
+ loadOverview();
999
+ setInterval(() => loadOverview({ silent: true }), REFRESH_MS);