atom-agent 1.4.0 → 1.5.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.
Files changed (67) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/README.md +220 -224
  3. package/dist/App.js +922 -341
  4. package/dist/adapters.js +127 -14
  5. package/dist/agent/goal-evaluator.js +3 -0
  6. package/dist/agent/loop.js +211 -430
  7. package/dist/agent/tool-pipeline.js +398 -0
  8. package/dist/agent/turn-events.js +12 -0
  9. package/dist/cli.js +57 -8
  10. package/dist/compact.js +72 -8
  11. package/dist/config.js +19 -0
  12. package/dist/context-manager.js +6 -2
  13. package/dist/extensions.js +6 -0
  14. package/dist/file-diffs.js +108 -0
  15. package/dist/kilo.js +1 -1
  16. package/dist/local-discovery.js +2 -2
  17. package/dist/media.js +276 -0
  18. package/dist/overflow.js +140 -0
  19. package/dist/policy.js +8 -0
  20. package/dist/scheduler.js +38 -9
  21. package/dist/session-revert.js +125 -0
  22. package/dist/sessions.js +101 -0
  23. package/dist/snapshots.js +69 -0
  24. package/dist/system.js +2 -89
  25. package/dist/telemetry.js +26 -1
  26. package/dist/todos.js +241 -0
  27. package/dist/tools/filesystem.js +102 -22
  28. package/dist/tools/registry.js +184 -45
  29. package/dist/tools/ripgrep.js +7 -6
  30. package/dist/tools/search.js +172 -17
  31. package/dist/tools/shared.js +6 -0
  32. package/dist/tools.js +7 -39
  33. package/dist/ui/diff-panel.js +1 -1
  34. package/dist/ui/diff-view.js +13 -5
  35. package/dist/ui/diff.js +67 -0
  36. package/dist/ui/errors.js +20 -6
  37. package/dist/ui/input.js +24 -20
  38. package/dist/ui/live-tail.js +36 -1
  39. package/dist/ui/markdown.js +9 -4
  40. package/dist/ui/modals.js +7 -5
  41. package/dist/ui/paint-scheduler.js +120 -0
  42. package/dist/ui/palette.js +4 -2
  43. package/dist/ui/pickers.js +4 -1
  44. package/dist/ui/side-by-side.js +81 -22
  45. package/dist/ui/status-bar.js +63 -8
  46. package/dist/ui/stream-store.js +7 -0
  47. package/dist/ui/theme.js +23 -1
  48. package/dist/ui/todo-panel.js +5 -2
  49. package/dist/ui/tool-inspector.js +33 -4
  50. package/dist/ui/transcript.js +8 -5
  51. package/dist/web/events.js +93 -0
  52. package/dist/web/runtime.js +790 -0
  53. package/dist/web/server.js +570 -0
  54. package/dist/web/ui/app.js +1925 -0
  55. package/dist/web/ui/index.html +135 -0
  56. package/dist/web/ui/styles.css +515 -0
  57. package/dist/zen.js +115 -4
  58. package/documentation/cli.md +5 -5
  59. package/documentation/configuration.md +11 -6
  60. package/documentation/development.md +4 -3
  61. package/documentation/goals.md +1 -1
  62. package/documentation/index.md +4 -4
  63. package/documentation/providers.md +2 -3
  64. package/documentation/skills.md +3 -3
  65. package/documentation/tools.md +8 -3
  66. package/documentation/troubleshooting.md +1 -1
  67. package/package.json +3 -2
@@ -0,0 +1,1925 @@
1
+ /* ATOM WebUI workspace client — vanilla JS, no dependencies.
2
+ *
3
+ * Server contract (see src/web/server.ts + src/web/events.ts):
4
+ * - REST: /api/providers, /api/sessions(+/:id), /:id/messages, /cancel,
5
+ * /approve, /answer. Sessions carry cwd/provider/model/effort/mode.
6
+ * - SSE: /:id/events. Every agent fact renders from these events — the
7
+ * client fabricates nothing (no fake thinking, tool, or status rows).
8
+ * Correlation note: tool_call (approve args) / tool_started / tool_result
9
+ * share no single id, so the timeline pairs them FIFO by tool name — the
10
+ * same order the loop commits them in (see runLoopWithChat commit funnel).
11
+ */
12
+ "use strict";
13
+
14
+ const $ = (id) => document.getElementById(id);
15
+ const els = {
16
+ sessions: $("session-list"),
17
+ workspace: $("workspace"),
18
+ providers: $("provider"),
19
+ models: $("model"),
20
+ effort: $("effort"),
21
+ mode: $("mode"),
22
+ conn: $("conn"),
23
+ status: $("status"),
24
+ error: $("error"),
25
+ transcript: $("transcript"),
26
+ form: $("composer"),
27
+ input: $("input"),
28
+ send: $("send"),
29
+ stop: $("stop"),
30
+ attach: $("attach"),
31
+ fileInput: $("file-input"),
32
+ modal: $("modal"),
33
+ modalTitle: $("modal-title"),
34
+ modalBody: $("modal-body"),
35
+ modalActions: $("modal-actions"),
36
+ newSession: $("new-session"),
37
+ sidebar: $("sidebar"),
38
+ right: $("right"),
39
+ scrim: $("scrim"),
40
+ opNow: $("op-now"),
41
+ counts: $("tool-counts"),
42
+ timeline: $("timeline"),
43
+ files: $("files"),
44
+ commands: $("commands"),
45
+ errors: $("errors"),
46
+ viewer: $("viewer"),
47
+ viewerPath: $("viewer-path"),
48
+ viewerMeta: $("viewer-meta"),
49
+ viewerBody: $("viewer-body"),
50
+ tabUnified: $("tab-unified"),
51
+ tabSide: $("tab-side"),
52
+ };
53
+
54
+ const state = {
55
+ sessionId: null,
56
+ providers: [],
57
+ busy: false,
58
+ es: null,
59
+ draftEl: null,
60
+ thinkingEl: null,
61
+ thinkingSummary: null,
62
+ reasoningLabel: "",
63
+ // Right-panel model, rebuilt per session (all entries derive from events).
64
+ entries: [],
65
+ pendingMeta: [],
66
+ // Execution timeline: one turn group per user message. Nodes derive only
67
+ // from streamed events — the client invents no steps.
68
+ turnGroups: [],
69
+ currentTurn: null,
70
+ // File diffs by path (latest per path, capped) + error list (capped).
71
+ diffs: new Map(),
72
+ errors: [],
73
+ viewerPath: null,
74
+ viewerTab: "unified",
75
+ };
76
+
77
+ /* ---- paint scheduler: high-frequency token/thinking/timeline events
78
+ * coalesce to one render per animation frame, so long-running turns stay
79
+ * smooth. The FINAL text always renders exactly (finalizeDraft bypasses the
80
+ * queue), so throttling can never lose content. ---- */
81
+ const paint = { queued: false, draft: null, thinking: null, timeline: false };
82
+
83
+ function requestPaint() {
84
+ if (paint.queued) return;
85
+ paint.queued = true;
86
+ const flush = () => {
87
+ paint.queued = false;
88
+ if (paint.draft !== null) {
89
+ const t = paint.draft;
90
+ paint.draft = null;
91
+ renderDraft(t);
92
+ }
93
+ if (paint.thinking !== null) {
94
+ const t = paint.thinking;
95
+ paint.thinking = null;
96
+ renderThinking(t);
97
+ }
98
+ if (paint.timeline) {
99
+ paint.timeline = false;
100
+ renderAgent();
101
+ }
102
+ };
103
+ if (typeof requestAnimationFrame === "function") requestAnimationFrame(flush);
104
+ else setTimeout(flush, 64);
105
+ }
106
+
107
+ function esc(s) {
108
+ return String(s).replace(/[&<>"']/g, (c) =>
109
+ ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
110
+ }
111
+
112
+ /* ---------------- markdown (presentation only; input is server text) ---------------- */
113
+
114
+ const CODE_FENCE = /```(\w*)\n([\s\S]*?)(?:```|$)/g;
115
+
116
+ function highlight(code, lang) {
117
+ const text = String(code);
118
+ const l = (lang || "").toLowerCase();
119
+ const cLike = new Set(["js", "jsx", "ts", "tsx", "mjs", "cjs", "mts", "cts", "go", "rs", "java", "c", "h", "cc", "cpp", "hpp", "cs", "swift", "kt", "kts", "php"]);
120
+ const pyLike = new Set(["py", "pyi", "rb"]);
121
+ const shLike = new Set(["sh", "bash", "zsh", "console", "shell"]);
122
+ const dataLike = new Set(["json", "jsonc", "yaml", "yml", "toml"]);
123
+ // Family ids from the server (previewLangFromPath) select directly.
124
+ const isC = l === "c" || cLike.has(l);
125
+ const isPy = l === "py" || pyLike.has(l);
126
+ const isSh = l === "sh" || shLike.has(l);
127
+ const isData = l === "data" || dataLike.has(l);
128
+ let keywords = [];
129
+ let commentRe = null;
130
+ let allowBacktick = false;
131
+ if (isC) {
132
+ keywords = ["const", "let", "var", "function", "return", "if", "else", "for", "while", "do", "switch", "case", "break", "continue", "new", "class", "extends", "import", "export", "from", "default", "try", "catch", "finally", "throw", "typeof", "instanceof", "async", "await", "this", "null", "undefined", "true", "false", "void", "delete", "in", "of", "yield", "static", "struct", "enum", "impl", "fn", "mut", "pub", "match", "use", "trait", "interface", "public", "private", "protected", "namespace", "using", "virtual", "override", "template", "func", "chan", "select", "defer", "range", "package", "self", "Self"];
133
+ commentRe = "(\\/\\/[^\\n]*|\\/\\*[\\s\\S]*?\\*\\/)";
134
+ allowBacktick = true;
135
+ } else if (isPy) {
136
+ keywords = ["def", "return", "if", "elif", "else", "for", "while", "in", "not", "and", "or", "is", "None", "True", "False", "import", "from", "as", "class", "with", "lambda", "pass", "raise", "try", "except", "finally", "self", "async", "await", "yield", "assert", "print"];
137
+ commentRe = "(#[^\\n]*)";
138
+ } else if (isSh) {
139
+ keywords = ["if", "then", "else", "elif", "fi", "for", "while", "do", "done", "case", "esac", "function", "return", "exit", "export", "local", "echo", "cd", "set", "source", "in"];
140
+ commentRe = "(#[^\\n]*)";
141
+ } else if (isData) {
142
+ keywords = ["true", "false", "null"];
143
+ commentRe = null;
144
+ } else {
145
+ return esc(text);
146
+ }
147
+ const strRe = allowBacktick
148
+ ? "(\"(?:[^\"\\\\\\n]|\\\\.)*\"|'(?:[^'\\\\\\n]|\\\\.)*'|`(?:[^`\\\\]|\\\\.)*`)"
149
+ : "(\"(?:[^\"\\\\\\n]|\\\\.)*\"|'(?:[^'\\\\\\n]|\\\\.)*')";
150
+ const parts = [];
151
+ if (commentRe) parts.push(commentRe);
152
+ parts.push(strRe);
153
+ parts.push("(\\b\\d+(?:\\.\\d+)?\\b)");
154
+ if (keywords.length) parts.push("\\b(" + keywords.join("|") + ")\\b");
155
+ const re = new RegExp(parts.join("|"), "g");
156
+ let out = "";
157
+ let last = 0;
158
+ let m;
159
+ const groupClass = (g) => {
160
+ if (commentRe && g[1] !== undefined) return "tok-c";
161
+ const si = commentRe ? 2 : 1;
162
+ if (g[si] !== undefined) return "tok-s";
163
+ if (g[si + 1] !== undefined) return "tok-n";
164
+ return "tok-k";
165
+ };
166
+ while ((m = re.exec(text)) !== null) {
167
+ out += esc(text.slice(last, m.index));
168
+ out += '<span class="' + groupClass(m) + '">' + esc(m[0]) + "</span>";
169
+ last = m.index + m[0].length;
170
+ }
171
+ return out + esc(text.slice(last));
172
+ }
173
+
174
+ function renderInline(text) {
175
+ // Inline code spans first (placeholder-shielded from further formatting).
176
+ const codes = [];
177
+ let out = String(text).replace(/`([^`\n]+)`/g, (_, c) => {
178
+ codes.push(c);
179
+ return "\x01" + (codes.length - 1) + "\x01";
180
+ });
181
+ out = esc(out);
182
+ out = out.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
183
+ out = out.replace(/(^|[^*\w])\*([^*\n]+)\*/g, "$1<em>$2</em>");
184
+ out = out.replace(/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g, '<a href="$2" target="_blank" rel="noreferrer">$1</a>');
185
+ out = out.replace(/\x01(\d+)\x01/g, (_, i) => '<code class="inline">' + esc(codes[Number(i)]) + "</code>");
186
+ return out;
187
+ }
188
+
189
+ function renderMarkdown(text) {
190
+ const blocks = [];
191
+ const fenced = String(text).replace(CODE_FENCE, (_, lang, code) => {
192
+ blocks.push({ lang: (lang || "").trim(), code: code.replace(/\n$/, "") });
193
+ return "\x00" + (blocks.length - 1) + "\x00";
194
+ });
195
+ const lines = fenced.split("\n");
196
+ let html = "";
197
+ let i = 0;
198
+ const flushList = (items, ordered) => {
199
+ html += ordered ? "<ol>" : "<ul>";
200
+ for (const it of items) html += "<li>" + renderInline(it) + "</li>";
201
+ html += ordered ? "</ol>" : "</ul>";
202
+ };
203
+ let para = [];
204
+ const flushPara = () => {
205
+ if (para.length) {
206
+ const joined = para.join("\n");
207
+ if (/^\x00\d+\x00$/.test(joined.trim())) {
208
+ html += joined + "\n";
209
+ } else {
210
+ html += "<p>" + para.map(renderInline).join("<br>") + "</p>";
211
+ }
212
+ para = [];
213
+ }
214
+ };
215
+ const isTableRow = (ln) => /^\|.*\|\s*$/.test(ln);
216
+ while (i < lines.length) {
217
+ const line = lines[i];
218
+ if (/^\x00\d+\x00$/.test(line.trim())) {
219
+ flushPara();
220
+ html += line + "\n";
221
+ i += 1;
222
+ continue;
223
+ }
224
+ const h = line.match(/^(#{1,4})\s+(.*)$/);
225
+ if (h) {
226
+ flushPara();
227
+ html += "<h" + h[1].length + ">" + renderInline(h[2]) + "</h" + h[1].length + ">";
228
+ i += 1;
229
+ continue;
230
+ }
231
+ if (/^---+\s*$/.test(line)) {
232
+ flushPara();
233
+ html += "<hr>";
234
+ i += 1;
235
+ continue;
236
+ }
237
+ if (/^&gt;|^>/.test(line)) {
238
+ flushPara();
239
+ const quotes = [];
240
+ while (i < lines.length && /^>/.test(lines[i])) {
241
+ quotes.push(lines[i].replace(/^>\s?/, ""));
242
+ i += 1;
243
+ }
244
+ html += "<blockquote>" + quotes.map(renderInline).join("<br>") + "</blockquote>";
245
+ continue;
246
+ }
247
+ if (isTableRow(line) && i + 1 < lines.length && /^\|[\s:|-]+\|\s*$/.test(lines[i + 1])) {
248
+ flushPara();
249
+ const cells = (r) => r.trim().replace(/^\||\|$/g, "").split("|").map((c) => renderInline(c.trim()));
250
+ html += "<table><thead><tr>" + cells(line).map((c) => "<th>" + c + "</th>").join("") + "</tr></thead><tbody>";
251
+ i += 2;
252
+ while (i < lines.length && isTableRow(lines[i])) {
253
+ html += "<tr>" + cells(lines[i]).map((c) => "<td>" + c + "</td>").join("") + "</tr>";
254
+ i += 1;
255
+ }
256
+ html += "</tbody></table>";
257
+ continue;
258
+ }
259
+ const ul = line.match(/^[-*]\s+(.*)$/);
260
+ const ol = line.match(/^\d+[.)]\s+(.*)$/);
261
+ if (ul || ol) {
262
+ flushPara();
263
+ const ordered = !!ol;
264
+ const items = [];
265
+ while (i < lines.length) {
266
+ const m2 = ordered ? lines[i].match(/^\d+[.)]\s+(.*)$/) : lines[i].match(/^[-*]\s+(.*)$/);
267
+ if (!m2) break;
268
+ items.push(m2[1]);
269
+ i += 1;
270
+ }
271
+ flushList(items, ordered);
272
+ continue;
273
+ }
274
+ if (line.trim() === "") {
275
+ flushPara();
276
+ i += 1;
277
+ continue;
278
+ }
279
+ para.push(line);
280
+ i += 1;
281
+ }
282
+ flushPara();
283
+ html = html.replace(/^\x00(\d+)\x00$/gm, (_, n) => {
284
+ const b = blocks[Number(n)];
285
+ const label = b.lang ? esc(b.lang) : "code";
286
+ return '<div class="codeblock"><div class="code-head"><span>' + label +
287
+ '</span><button class="copy" type="button">copy</button></div><pre><code>' +
288
+ highlight(b.code, b.lang) + "</code></pre></div>";
289
+ });
290
+ return html;
291
+ }
292
+
293
+ /* ---------------- transcript ---------------- */
294
+
295
+ /* ---------------- transcript ----------------
296
+ * Long conversations cap the rendered rows (oldest dropped first) so the
297
+ * DOM stays bounded; the full record stays on the server (GET session).
298
+ * Live draft/thinking lanes cap their rendered text for the same reason —
299
+ * finals always render in full. */
300
+
301
+ // Max committed rows kept in the DOM; max chars rendered per live frame.
302
+ const TRANSCRIPT_ROW_CAP = 400;
303
+ const LIVE_RENDER_CAP = 20000;
304
+ let prunedRows = 0;
305
+
306
+ function pruneTranscript() {
307
+ const kids = els.transcript.children;
308
+ while (kids.length > TRANSCRIPT_ROW_CAP) {
309
+ els.transcript.removeChild(kids[0]);
310
+ prunedRows += 1;
311
+ }
312
+ let note = document.getElementById("prune-note");
313
+ if (prunedRows > 0) {
314
+ if (!note) {
315
+ note = document.createElement("div");
316
+ note.id = "prune-note";
317
+ note.className = "list-empty";
318
+ els.transcript.prepend(note);
319
+ }
320
+ note.textContent = prunedRows + " earlier row(s) not rendered (full history on the server)";
321
+ } else if (note) {
322
+ note.remove();
323
+ }
324
+ }
325
+
326
+ function capLive(text) {
327
+ if (text.length <= LIVE_RENDER_CAP) return { text, capped: false };
328
+ return {
329
+ text: text.slice(-LIVE_RENDER_CAP),
330
+ capped: true,
331
+ };
332
+ }
333
+
334
+ function relTime(iso) { const t = Date.parse(iso);
335
+ if (!Number.isFinite(t)) return "";
336
+ const s = Math.max(0, Math.floor((Date.now() - t) / 1000));
337
+ if (s < 60) return "just now";
338
+ const m = Math.floor(s / 60);
339
+ if (m < 60) return m + "m ago";
340
+ const h = Math.floor(m / 60);
341
+ if (h < 24) return h + "h ago";
342
+ return Math.floor(h / 24) + "d ago";
343
+ }
344
+
345
+ function addUserRow(content, echo) {
346
+ const div = document.createElement("div");
347
+ div.className = "msg user";
348
+ if (echo) div.dataset.echo = "1";
349
+ div.innerHTML = '<div class="role">You</div><div class="body"></div>';
350
+ div.querySelector(".body").innerHTML = renderMarkdown(content);
351
+ els.transcript.appendChild(div);
352
+ els.transcript.scrollTop = els.transcript.scrollHeight;
353
+ pruneTranscript();
354
+ return div;
355
+ }
356
+
357
+ function addAssistantRow(content) {
358
+ const div = document.createElement("div");
359
+ div.className = "msg assistant";
360
+ div.innerHTML = '<div class="role">ATOM</div><div class="body">' + renderMarkdown(content) + "</div>";
361
+ els.transcript.appendChild(div);
362
+ els.transcript.scrollTop = els.transcript.scrollHeight;
363
+ pruneTranscript();
364
+ return div;
365
+ }
366
+
367
+ function addToolRow(label, isError, args, result) {
368
+ const div = document.createElement("div");
369
+ div.className = "msg tool-row" + (isError ? " err" : "");
370
+ const dot = isError ? "fail" : "ok";
371
+ div.innerHTML = '<div class="body"><details class="tool"><summary><span class="dot ' + dot +
372
+ '"></span>' + esc(label) + "</summary>" + '<div class="tool-detail"></div></details></div>';
373
+ const detail = div.querySelector(".tool-detail");
374
+ if (args) {
375
+ const pre = document.createElement("pre");
376
+ pre.textContent = JSON.stringify(args, null, 2);
377
+ detail.appendChild(pre);
378
+ }
379
+ if (typeof result === "string" && result.length) {
380
+ const cap = 2000;
381
+ const pre = document.createElement("pre");
382
+ pre.textContent = result.length > cap
383
+ ? result.slice(0, cap) + "\n… (showing first " + cap + " of " + result.length + " chars)"
384
+ : result;
385
+ detail.appendChild(pre);
386
+ }
387
+ els.transcript.appendChild(div);
388
+ els.transcript.scrollTop = els.transcript.scrollHeight;
389
+ pruneTranscript();
390
+ return div;
391
+ }
392
+
393
+ function setDraft(text) {
394
+ // Hot path (per token chunk): store + schedule, never render synchronously.
395
+ paint.draft = text;
396
+ requestPaint();
397
+ }
398
+
399
+ function renderDraft(text) {
400
+ if (!state.draftEl) {
401
+ state.draftEl = document.createElement("div");
402
+ state.draftEl.className = "msg assistant";
403
+ state.draftEl.innerHTML = '<div class="role">ATOM · streaming</div><div class="body"></div>';
404
+ els.transcript.appendChild(state.draftEl);
405
+ }
406
+ state.draftEl.querySelector(".body").innerHTML = renderMarkdown(liveSlice(text));
407
+ els.transcript.scrollTop = els.transcript.scrollHeight;
408
+ }
409
+
410
+ // Live lanes render a bounded tail (per-frame markdown cost stays flat no
411
+ // matter how long the stream runs). Fences are rebalanced so a cut can never
412
+ // swallow the tail into an unclosed code block; the final message below
413
+ // always renders complete.
414
+ function liveSlice(text) {
415
+ const c = capLive(text);
416
+ if (!c.capped) return c.text;
417
+ let body = "… (live view capped — the completed message renders in full)\n" + c.text;
418
+ if ((body.match(/```/g) || []).length % 2 === 1) body += "\n```";
419
+ return body;
420
+ }
421
+
422
+ function finalizeDraft(content) {
423
+ // A queued paint must never resurrect the draft after the final lands.
424
+ paint.draft = null;
425
+ paint.thinking = null;
426
+ if (state.draftEl) {
427
+ state.draftEl.remove();
428
+ state.draftEl = null;
429
+ }
430
+ collapseThinking();
431
+ addAssistantRow(content);
432
+ }
433
+
434
+ function setThinking(text) {
435
+ // Hot path (per reasoning delta): store + schedule, like the draft.
436
+ paint.thinking = text;
437
+ const turn = ensureTurn();
438
+ const node = ensureThinkingNode(turn);
439
+ node.preview = text.slice(0, 160);
440
+ paint.timeline = true;
441
+ requestPaint();
442
+ }
443
+
444
+ function renderThinking(text) {
445
+ if (!state.thinkingEl) {
446
+ state.thinkingEl = document.createElement("div");
447
+ state.thinkingEl.className = "msg";
448
+ state.thinkingEl.innerHTML = '<details class="thinking" open><summary>thinking</summary><div class="body"></div></details>';
449
+ els.transcript.appendChild(state.thinkingEl);
450
+ state.thinkingSummary = state.thinkingEl.querySelector("summary");
451
+ }
452
+ state.thinkingEl.querySelector(".body").innerHTML = renderMarkdown(liveSlice(text));
453
+ if (state.reasoningLabel) state.thinkingSummary.textContent = "thinking · " + state.reasoningLabel;
454
+ els.transcript.scrollTop = els.transcript.scrollHeight;
455
+ }
456
+
457
+ function collapseThinking() {
458
+ if (state.thinkingEl) {
459
+ const d = state.thinkingEl.querySelector("details");
460
+ if (d) d.removeAttribute("open");
461
+ state.thinkingEl = null;
462
+ state.thinkingSummary = null;
463
+ }
464
+ }
465
+
466
+ function clearLive() {
467
+ paint.draft = null;
468
+ paint.thinking = null;
469
+ if (state.draftEl) { state.draftEl.remove(); state.draftEl = null; }
470
+ if (state.thinkingEl) {
471
+ const d = state.thinkingEl.querySelector("details");
472
+ if (d) d.removeAttribute("open");
473
+ state.thinkingEl = null;
474
+ state.thinkingSummary = null;
475
+ }
476
+ state.reasoningLabel = "";
477
+ }
478
+
479
+ function setStatus(text, busy) {
480
+ els.status.textContent = text;
481
+ els.status.classList.toggle("busy", !!busy);
482
+ }
483
+
484
+ function showError(message) {
485
+ els.error.hidden = !message;
486
+ els.error.textContent = message || "";
487
+ }
488
+
489
+ function setConn(live) {
490
+ els.conn.textContent = live ? "● connected" : "○ disconnected — retrying…";
491
+ els.conn.classList.toggle("live", live);
492
+ els.conn.classList.toggle("down", !live);
493
+ }
494
+
495
+ function setBusy(busy) {
496
+ state.busy = busy;
497
+ els.send.disabled = busy;
498
+ els.stop.disabled = !busy;
499
+ if (busy) { showError(""); setStatus("thinking", true); }
500
+ }
501
+
502
+ /* ---------------- right panel: agent model from real events ---------------- */
503
+
504
+ function toolDetail(name, args) {
505
+ const a = args || {};
506
+ const s = (v) => (typeof v === "string" ? v : "");
507
+ switch (name) {
508
+ case "read":
509
+ case "write":
510
+ case "edit": return s(a.path);
511
+ case "bash": return s(a.command).slice(0, 160);
512
+ case "bash_output": return s(a.taskId);
513
+ case "grep": return (s(a.pattern) + (a.include ? " " + a.include : "")).slice(0, 160);
514
+ case "glob": return s(a.pattern).slice(0, 160);
515
+ case "webfetch": return s(a.url).slice(0, 160);
516
+ case "websearch": return s(a.query).slice(0, 160);
517
+ case "todowrite": return (Array.isArray(a.todos) ? a.todos.length : 0) + " task(s)";
518
+ case "todo_update": return "#" + String(a.index !== undefined ? a.index : "?") + (a.status ? " → " + a.status : "");
519
+ case "ask_question": return s(a.question).slice(0, 160);
520
+ case "update_goal": return String(a.status !== undefined ? a.status : "");
521
+ default: return "";
522
+ }
523
+ }
524
+
525
+ function lastOpen(name) {
526
+ for (let idx = state.entries.length - 1; idx >= 0; idx--) {
527
+ const e = state.entries[idx];
528
+ if (e.name === name && e.state === "running") return e;
529
+ }
530
+ return null;
531
+ }
532
+
533
+ function bashExit(result) {
534
+ try {
535
+ const o = JSON.parse(String(result));
536
+ if (o && typeof o.exitCode === "number") return o.exitCode;
537
+ } catch { /* not a JSON envelope — unknown */ }
538
+ return null;
539
+ }
540
+
541
+ // One shared cap-note builder for committed result text (center rows and
542
+ // timeline nodes must agree instead of each inventing a truncation line).
543
+ function capNote(result, resultChars, truncated) {
544
+ return truncated ? result + "\n… (truncated: " + resultChars + " chars total)" : result;
545
+ }
546
+
547
+ function renderAgent() {
548
+ // Now: latest phase/operation or idle.
549
+ // Counts.
550
+ let running = 0, ok = 0, fail = 0, denied = 0;
551
+ for (const e of state.entries) {
552
+ if (e.state === "running") running += 1;
553
+ else if (e.state === "ok") ok += 1;
554
+ else if (e.state === "fail") fail += 1;
555
+ else if (e.state === "denied") denied += 1;
556
+ }
557
+ els.counts.textContent = state.entries.length === 0 && !state.busy
558
+ ? "no tool calls yet"
559
+ : running + " running · " + ok + " ok · " + fail + " failed" + (denied ? " · " + denied + " denied" : "");
560
+ // Execution timeline (turn groups with live nodes; string-built in one
561
+ // pass so high-frequency streams never thrash the DOM node by node).
562
+ els.timeline.innerHTML = renderTimeline();
563
+ // Files. Reads come from tool_result entries; creates/modifies come from
564
+ // file_diff events (op + pre-computed hunks). Rows with a diff open the
565
+ // diff viewer on click. ATOM has no delete tool and bash side effects are
566
+ // opaque by design — deletions never appear here (see file_diff docs).
567
+ const seenPaths = new Set();
568
+ const fileRows = [];
569
+ for (let idx = state.entries.length - 1; idx >= 0 && fileRows.length < 50; idx--) {
570
+ const e = state.entries[idx];
571
+ if ((e.name !== "read" && e.name !== "write" && e.name !== "edit") || !e.detail) continue;
572
+ if (seenPaths.has(e.detail)) continue;
573
+ seenPaths.add(e.detail);
574
+ const diff = state.diffs.get(e.detail);
575
+ fileRows.push({
576
+ op: diff ? diff.op : "read",
577
+ path: e.detail,
578
+ state: e.state,
579
+ hasDiff: !!diff,
580
+ });
581
+ }
582
+ fileRows.reverse();
583
+ els.files.innerHTML = fileRows.length === 0 ? '<div class="list-empty">no file access yet</div>' : "";
584
+ for (const r of fileRows) {
585
+ const div = document.createElement("div");
586
+ div.className = "file-row" + (r.hasDiff ? " clickable" : "");
587
+ const dot = r.state === "running" ? "run" : r.state;
588
+ const badge = r.op === "created" ? "created" : r.op === "modified" ? "modified" : "read";
589
+ div.innerHTML = '<span class="dot ' + dot + '"></span><span class="badge ' + badge + '">' + badge + "</span> " + esc(r.path);
590
+ if (r.hasDiff) {
591
+ div.dataset.diffpath = r.path;
592
+ div.title = "Show diff";
593
+ }
594
+ els.files.appendChild(div);
595
+ }
596
+ // Commands: COMMAND / OUTPUT / STATUS, output line-capped + scrollable.
597
+ const cmds = state.entries.filter((e) => e.name === "bash" && e.detail);
598
+ els.commands.innerHTML = cmds.length === 0 ? '<div class="list-empty">no commands yet</div>' : "";
599
+ for (const e of cmds.slice(-20)) {
600
+ const div = document.createElement("div");
601
+ div.className = "cmd-row";
602
+ const dot = e.state === "running" ? "run" : e.state;
603
+ const status = e.state === "running"
604
+ ? '<div class="cmd-status">STATUS · running…</div>'
605
+ : e.exitCode === null || e.exitCode === undefined
606
+ ? '<div class="cmd-status">STATUS ' + (e.state === "fail" ? "✕ failed" : "· done") + "</div>"
607
+ : e.exitCode === 0
608
+ ? '<div class="cmd-status ok">STATUS ✓ completed (exit 0)</div>'
609
+ : '<div class="cmd-status bad">STATUS ✕ failed (exit ' + e.exitCode + ")</div>";
610
+ div.innerHTML = '<div class="cmd-line"><span class="dot ' + dot + '"></span><span>$ ' + esc(e.detail) + "</span></div>" + status;
611
+ if (e.result) {
612
+ // Long output renders capped (first lines) inside a scrollable,
613
+ // collapsed block — never the full dump in the DOM.
614
+ const outLines = String(e.result).split("\n");
615
+ const lineCap = 300;
616
+ const shown = outLines.slice(0, lineCap).join("\n");
617
+ const rest = outLines.length - Math.min(outLines.length, lineCap);
618
+ const det = document.createElement("details");
619
+ const sum = document.createElement("summary");
620
+ sum.textContent = "OUTPUT (" + outLines.length + " lines" +
621
+ (e.resultChars ? ", " + e.resultChars + " chars total" : "") + ")";
622
+ const pre = document.createElement("pre");
623
+ pre.textContent = shown + (rest > 0 ? "\n… (" + rest + " more lines not rendered)" : "");
624
+ det.appendChild(sum);
625
+ det.appendChild(pre);
626
+ div.appendChild(det);
627
+ }
628
+ els.commands.appendChild(div);
629
+ }
630
+ // Errors: failed tool results + turn errors, newest last, capped.
631
+ els.errors.innerHTML = state.errors.length === 0 ? '<div class="list-empty">no errors</div>' : "";
632
+ for (const er of state.errors.slice(-20)) {
633
+ const div = document.createElement("div");
634
+ div.className = "err-row";
635
+ div.innerHTML = '<span class="dot fail"></span><span>' + esc(er.title) +
636
+ (er.detail ? '<div class="tl-detail">' + esc(er.detail) + "</div>" : "") + "</span>";
637
+ els.errors.appendChild(div);
638
+ }
639
+ }
640
+
641
+ function setOp(text) {
642
+ els.opNow.textContent = text;
643
+ }
644
+
645
+ function trackToolCall(d) {
646
+ state.entries.push({
647
+ name: d.name,
648
+ detail: toolDetail(d.name, d.args),
649
+ args: d.args,
650
+ state: d.decision === "no" ? "denied" : "running",
651
+ via: d.via || "",
652
+ result: null,
653
+ resultChars: 0,
654
+ exitCode: null,
655
+ });
656
+ if (state.entries.length > 1000) state.entries.splice(0, state.entries.length - 1000);
657
+ state.pendingMeta.push({ name: d.name, args: d.args });
658
+ if (state.pendingMeta.length > 200) state.pendingMeta.splice(0, state.pendingMeta.length - 200);
659
+ // Timeline node (pre-execution args — the only place they exist early).
660
+ // Structure only (see trackToolResult) — no result bodies.
661
+ const turn = ensureTurn();
662
+ turn.nodes.push({
663
+ type: "tool",
664
+ name: d.name,
665
+ label: d.description || d.name,
666
+ detail: toolDetail(d.name, d.args),
667
+ state: d.decision === "no" ? "denied" : "running",
668
+ });
669
+ paint.timeline = true;
670
+ requestPaint();
671
+ }
672
+
673
+ function trackToolResult(d) {
674
+ const e = lastOpen(d.name);
675
+ const capped = capNote(d.result, d.resultChars, d.truncated);
676
+ if (e) {
677
+ e.state = d.isError ? "fail" : "ok";
678
+ e.detail = e.detail || toolDetail(d.name, d.args);
679
+ e.args = e.args || d.args;
680
+ e.result = capped;
681
+ e.resultChars = d.resultChars || 0;
682
+ if (d.name === "bash") e.exitCode = bashExit(d.result);
683
+ } else {
684
+ state.entries.push({
685
+ name: d.name,
686
+ detail: toolDetail(d.name, d.args),
687
+ args: d.args,
688
+ state: d.isError ? "fail" : "ok",
689
+ via: "",
690
+ result: capped,
691
+ resultChars: d.resultChars || 0,
692
+ exitCode: d.name === "bash" ? bashExit(d.result) : null,
693
+ });
694
+ if (state.entries.length > 1000) state.entries.splice(0, state.entries.length - 1000);
695
+ }
696
+ state.pendingMeta.push({ name: d.name, args: d.args });
697
+ if (state.pendingMeta.length > 200) state.pendingMeta.splice(0, state.pendingMeta.length - 200);
698
+ // Errors panel: failed commits, newest last (capped at render).
699
+ if (d.isError) {
700
+ state.errors.push({
701
+ title: d.name + " failed",
702
+ detail: String(d.result || "").slice(0, 300),
703
+ });
704
+ if (state.errors.length > 50) state.errors.splice(0, state.errors.length - 50);
705
+ }
706
+ // Timeline node update (commit-order result). Nodes carry structure only
707
+ // (name/detail/status) — result bodies live in the center rows and the
708
+ // files/commands panels, so thousand-call turns don't bloat the timeline.
709
+ const turn = ensureTurn();
710
+ const node = lastOpenToolNode(turn, d.name);
711
+ if (node) {
712
+ node.state = d.isError ? "fail" : "ok";
713
+ node.detail = node.detail || toolDetail(d.name, d.args);
714
+ } else {
715
+ turn.nodes.push({
716
+ type: "tool",
717
+ name: d.name,
718
+ label: d.name,
719
+ detail: toolDetail(d.name, d.args),
720
+ state: d.isError ? "fail" : "ok",
721
+ });
722
+ }
723
+ paint.timeline = true;
724
+ requestPaint();
725
+ }
726
+
727
+ function takeMeta(name) {
728
+ for (let idx = 0; idx < state.pendingMeta.length; idx++) {
729
+ if (state.pendingMeta[idx].name === name) {
730
+ return state.pendingMeta.splice(idx, 1)[0].args;
731
+ }
732
+ }
733
+ return null;
734
+ }
735
+
736
+ /* ---- execution timeline: turn groups with live nodes ----
737
+ * One group opens per user message and closes on done/error/cancelled.
738
+ * Every node derives from a streamed event: thinking nodes from thinking
739
+ * TEXT (phase "thinking" alone never creates one — a POST without exposed
740
+ * reasoning shows no thinking row), tool nodes from tool_call/started/
741
+ * result, retry nodes from phase "retry", round markers from subsequent
742
+ * model rounds. Nothing here is synthesized. */
743
+
744
+ function openTurn(userText) {
745
+ const turn = {
746
+ user: userText || "",
747
+ state: "working",
748
+ nodes: [],
749
+ startedAt: Date.now(),
750
+ endedAt: null,
751
+ open: true,
752
+ };
753
+ state.turnGroups.push(turn);
754
+ if (state.turnGroups.length > 50) state.turnGroups.splice(0, state.turnGroups.length - 50);
755
+ state.currentTurn = turn;
756
+ paint.timeline = true;
757
+ requestPaint();
758
+ return turn;
759
+ }
760
+
761
+ function ensureTurn() {
762
+ const cur = state.currentTurn;
763
+ if (cur && cur.state === "working") return cur;
764
+ return openTurn("");
765
+ }
766
+
767
+ function closeTurn(finalState, note) {
768
+ const turn = state.currentTurn;
769
+ if (turn && turn.state === "working") {
770
+ turn.state = finalState;
771
+ turn.endedAt = Date.now();
772
+ if (note) turn.nodes.push({ type: "final", text: note });
773
+ }
774
+ state.currentTurn = null;
775
+ paint.timeline = true;
776
+ requestPaint();
777
+ }
778
+
779
+ function ensureThinkingNode(turn) {
780
+ for (let idx = turn.nodes.length - 1; idx >= 0; idx--) {
781
+ if (turn.nodes[idx].type === "thinking") return turn.nodes[idx];
782
+ }
783
+ const node = { type: "thinking", preview: "" };
784
+ turn.nodes.push(node);
785
+ return node;
786
+ }
787
+
788
+ function lastOpenToolNode(turn, name) {
789
+ for (let idx = turn.nodes.length - 1; idx >= 0; idx--) {
790
+ const n = turn.nodes[idx];
791
+ if (n.type === "tool" && n.name === name && n.state === "running") return n;
792
+ }
793
+ return null;
794
+ }
795
+
796
+ function turnElapsed(turn) {
797
+ const end = turn.endedAt || Date.now();
798
+ return Math.max(0, Math.round((end - turn.startedAt) / 1000));
799
+ }
800
+
801
+ // Live elapsed ticker: updates only the open turn's head, once a second.
802
+ setInterval(() => {
803
+ const turn = state.currentTurn;
804
+ if (!turn || turn.state !== "working") return;
805
+ const el = document.querySelector('[data-turn-elapsed="' + turn.startedAt + '"]');
806
+ if (el) el.textContent = turnElapsed(turn) + "s";
807
+ }, 1000);
808
+
809
+ function renderTimeline() {
810
+ if (state.turnGroups.length === 0) {
811
+ return '<div class="tl-empty">timeline fills as ATOM works</div>';
812
+ }
813
+ let html = "";
814
+ for (const turn of state.turnGroups) {
815
+ const head = turn.user ? esc(turn.user.slice(0, 80)) : "Working";
816
+ const stateLabel = turn.state === "working" ? "Working" : turn.state === "done" ? "Completed" : turn.state;
817
+ const dot = turn.state === "working" ? "run" : turn.state === "done" ? "ok" : turn.state === "cancelled" ? "denied" : "fail";
818
+ html += '<div class="turn"><button type="button" class="turn-head" data-turn="' + turn.startedAt + '">' +
819
+ '<span class="dot ' + dot + '"></span><span class="turn-title">' + esc(stateLabel) +
820
+ (turn.user ? ": " + head : "") + '</span><span class="turn-time" data-turn-elapsed="' +
821
+ turn.startedAt + '">' + turnElapsed(turn) + "s</span>" +
822
+ '<span class="turn-caret">' + (turn.open === false ? "▸" : "▾") + "</span></button>";
823
+ if (turn.open !== false) {
824
+ html += '<div class="turn-body">';
825
+ for (const n of turn.nodes) html += renderNode(n);
826
+ html += "</div>";
827
+ }
828
+ html += "</div>";
829
+ }
830
+ return html;
831
+ }
832
+
833
+ function renderNode(n) {
834
+ if (n.type === "thinking") {
835
+ return '<div class="tl-row run"><span class="dot run"></span><span><div>Thinking…</div>' +
836
+ (n.preview ? '<div class="tl-detail">' + esc(n.preview) + (n.preview.length >= 160 ? "…" : "") + "</div>" : "") + "</span></div>";
837
+ }
838
+ if (n.type === "retry") {
839
+ return '<div class="tl-row run"><span class="dot run"></span><span><div>↻ retrying…</div>' +
840
+ (n.detail ? '<div class="tl-detail">' + esc(n.detail) + "</div>" : "") + "</span></div>";
841
+ }
842
+ if (n.type === "round") {
843
+ return '<div class="tl-round">── next step ──</div>';
844
+ }
845
+ if (n.type === "final") {
846
+ return '<div class="tl-row ' + (n.ok === false ? "fail" : "ok") + '"><span class="dot ' +
847
+ (n.ok === false ? "fail" : "ok") + '"></span><span>' + esc(n.text) + "</span></div>";
848
+ }
849
+ if (n.type === "tool") {
850
+ const dot = n.state === "running" ? "run" : n.state;
851
+ let inner = '<div class="tl-detail">' + esc(n.name) + "</div>";
852
+ if (n.detail) inner += '<div class="tl-detail">' + esc(n.detail) + "</div>";
853
+ return '<details class="tl-tool ' + dot + '"' + (n.state === "running" ? " open" : "") +
854
+ "><summary><span" + ' class="dot ' + dot + '"></span>' + esc(n.label || n.name) +
855
+ "</summary><div>" + inner + "</div></details>";
856
+ }
857
+ return "";
858
+ }
859
+
860
+ /* ---------------- file diffs + viewer ----------------
861
+ * file_diff events carry server-computed hunks/rows (src/ui/diff.ts engine)
862
+ * over capped texts — the browser only renders. Diffs never trigger tool
863
+ * execution; opening a diff is a pure render of streamed evidence. */
864
+
865
+ function onFileDiff(d) {
866
+ if (!d.path) return;
867
+ state.diffs.set(d.path, d);
868
+ if (state.diffs.size > 20) {
869
+ const oldest = state.diffs.keys().next().value;
870
+ state.diffs.delete(oldest);
871
+ }
872
+ paint.timeline = true;
873
+ requestPaint();
874
+ }
875
+
876
+ function openViewer(filePath) {
877
+ const d = state.diffs.get(filePath);
878
+ if (!d) return;
879
+ state.viewerPath = filePath;
880
+ els.viewerPath.textContent = filePath;
881
+ const bits = [];
882
+ bits.push(d.op === "created" ? "created" : d.op === "modified" ? "modified" : String(d.op || ""));
883
+ bits.push("+" + (d.adds || 0) + " −" + (d.dels || 0));
884
+ if (d.isNewFile) bits.push("new file");
885
+ if (d.truncated) bits.push("texts truncated for transfer");
886
+ if (d.rowsTruncated) bits.push("rows truncated for display");
887
+ els.viewerMeta.textContent = bits.join(" · ");
888
+ els.tabUnified.classList.toggle("active", state.viewerTab !== "side");
889
+ els.tabSide.classList.toggle("active", state.viewerTab === "side");
890
+ renderViewer();
891
+ els.viewer.hidden = false;
892
+ }
893
+
894
+ function closeViewer() {
895
+ els.viewer.hidden = true;
896
+ state.viewerPath = null;
897
+ }
898
+
899
+ function renderViewer() {
900
+ const d = state.diffs.get(state.viewerPath);
901
+ if (!d) {
902
+ els.viewerBody.innerHTML = '<div class="list-empty">diff unavailable</div>';
903
+ return;
904
+ }
905
+ els.viewerBody.innerHTML = state.viewerTab === "side" ? renderSideBySide(d) : renderUnified(d);
906
+ }
907
+
908
+ // Unified view from server hunks: @@ headers, per-side line numbers,
909
+ // changed-line backgrounds, syntax-highlighted line text.
910
+ function renderUnified(d) {
911
+ const lang = d.lang || "";
912
+ const hunks = (d.hunks || []).slice(0, 60);
913
+ let html = "";
914
+ for (const h of hunks) {
915
+ html += '<div class="hunk-head">@@ -' + h.oldStart + "," + h.oldLines + " +" + h.newStart + "," + h.newLines + " @@</div>";
916
+ let oldNo = h.oldStart;
917
+ let newNo = h.newStart;
918
+ for (const ln of h.lines || []) {
919
+ if (ln.kind === "context") {
920
+ html += diffLine("ctx", oldNo, newNo, ln.text, lang);
921
+ oldNo += 1;
922
+ newNo += 1;
923
+ } else if (ln.kind === "del") {
924
+ html += diffLine("del", oldNo, null, ln.text, lang);
925
+ oldNo += 1;
926
+ } else {
927
+ html += diffLine("add", null, newNo, ln.text, lang);
928
+ newNo += 1;
929
+ }
930
+ }
931
+ }
932
+ if ((d.hunks || []).length > hunks.length) {
933
+ html += '<div class="list-empty">… ' + ((d.hunks || []).length - hunks.length) + " more hunks not rendered</div>";
934
+ }
935
+ return html || '<div class="list-empty">no changes</div>';
936
+ }
937
+
938
+ function diffLine(cls, oldNo, newNo, text, lang) {
939
+ return '<div class="dline ' + cls + '"><span class="dno">' + (oldNo === null ? "" : oldNo) +
940
+ '</span><span class="dno">' + (newNo === null ? "" : newNo) + "</span>" +
941
+ '<span class="dcode">' + highlight(text, lang) + "</span></div>";
942
+ }
943
+
944
+ // Before/after view from server rows: paired lines share a row, unpaired
945
+ // lines take a row with an empty opposite cell (same contract as the TUI).
946
+ function renderSideBySide(d) {
947
+ const lang = d.lang || "";
948
+ const rows = d.rows || [];
949
+ let html = '<div class="sbs"><div class="sbs-head"><span>before</span><span>after</span></div>';
950
+ for (const r of rows) {
951
+ if (r.kind === "context") {
952
+ html += '<div class="sbs-row"><div class="sbs-cell"><span class="dno">' + r.oldNo +
953
+ '</span><span class="dcode">' + highlight(r.text, lang) + "</span></div>" +
954
+ '<div class="sbs-cell"><span class="dno">' + r.newNo +
955
+ '</span><span class="dcode">' + highlight(r.text, lang) + "</span></div></div>";
956
+ } else {
957
+ const left = r.oldText === null || r.oldText === undefined
958
+ ? '<div class="sbs-cell empty"></div>'
959
+ : '<div class="sbs-cell del"><span class="dno">' + r.oldNo + '</span><span class="dcode">' +
960
+ highlight(r.oldText, lang) + "</span></div>";
961
+ const right = r.newText === null || r.newText === undefined
962
+ ? '<div class="sbs-cell empty"></div>'
963
+ : '<div class="sbs-cell add"><span class="dno">' + r.newNo + '</span><span class="dcode">' +
964
+ highlight(r.newText, lang) + "</span></div>";
965
+ html += '<div class="sbs-row">' + left + right + "</div>";
966
+ }
967
+ }
968
+ return html + "</div>";
969
+ }
970
+
971
+ /* ---------------- modals / requests ---------------- */
972
+
973
+ function showModal(title, body, actions) {
974
+ els.modalTitle.textContent = title;
975
+ els.modalBody.textContent = body;
976
+ els.modalActions.innerHTML = "";
977
+ for (const a of actions) {
978
+ const b = document.createElement("button");
979
+ b.type = "button";
980
+ b.textContent = a.label;
981
+ b.onclick = () => { hideModal(); a.onClick(); };
982
+ els.modalActions.appendChild(b);
983
+ }
984
+ els.modal.hidden = false;
985
+ }
986
+
987
+ function hideModal() {
988
+ els.modal.hidden = true;
989
+ els.modalActions.innerHTML = "";
990
+ }
991
+
992
+ async function postJSON(url, body) {
993
+ const res = await fetch(url, {
994
+ method: "POST",
995
+ headers: { "Content-Type": "application/json" },
996
+ body: JSON.stringify(body || {}),
997
+ });
998
+ let data = {};
999
+ try { data = await res.json(); } catch { /* non-JSON: keep {} */ }
1000
+ if (!res.ok) throw new Error(data.error || ("HTTP " + res.status));
1001
+ return data;
1002
+ }
1003
+
1004
+ async function getJSON(url) {
1005
+ const res = await fetch(url);
1006
+ if (!res.ok) throw new Error("HTTP " + res.status);
1007
+ return res.json();
1008
+ }
1009
+
1010
+ function onApprovalRequest(d) {
1011
+ const diff = d.diff
1012
+ ? "\n--- diff ---\n" + JSON.stringify(d.diff, null, 2).slice(0, 4000)
1013
+ : "";
1014
+ const args = d.args ? "\n--- args ---\n" + JSON.stringify(d.args, null, 2).slice(0, 2000) : "";
1015
+ showModal("Approval: " + d.name, (d.description || "") + args + diff, [
1016
+ { label: "Allow once", onClick: () => approve(d.id, "once") },
1017
+ { label: "Always allow " + d.name, onClick: () => approve(d.id, "always") },
1018
+ { label: "Deny", onClick: () => approve(d.id, "no") },
1019
+ ]);
1020
+ }
1021
+
1022
+ async function approve(approvalId, decision) {
1023
+ try {
1024
+ await postJSON("/api/sessions/" + state.sessionId + "/approve", { id: approvalId, decision });
1025
+ } catch (e) {
1026
+ showError("approve failed: " + e.message);
1027
+ }
1028
+ }
1029
+
1030
+ function onQuestionRequest(d) {
1031
+ const actions = (d.options || []).map((opt) => ({
1032
+ label: opt,
1033
+ onClick: () => answer(d.id, opt),
1034
+ }));
1035
+ if (d.allowCustom) {
1036
+ actions.push({
1037
+ label: "Custom…",
1038
+ onClick: () => {
1039
+ const v = window.prompt(d.question || "Answer");
1040
+ if (v) answer(d.id, v);
1041
+ },
1042
+ });
1043
+ }
1044
+ showModal("ATOM asks", d.question || "", actions);
1045
+ }
1046
+
1047
+ async function answer(questionId, answerText) {
1048
+ try {
1049
+ await postJSON("/api/sessions/" + state.sessionId + "/answer", { id: questionId, answer: answerText });
1050
+ } catch (e) {
1051
+ showError("answer failed: " + e.message);
1052
+ }
1053
+ }
1054
+
1055
+ /* ---------------- SSE dispatch ---------------- */
1056
+
1057
+ function onEvent(evt) {
1058
+ let msg;
1059
+ try {
1060
+ msg = JSON.parse(evt.data);
1061
+ } catch {
1062
+ return;
1063
+ }
1064
+ const d = msg.data || {};
1065
+ switch (msg.kind) {
1066
+ case "token": setDraft(d.text || ""); setOp("writing response"); break;
1067
+ case "thinking": setThinking(d.text || ""); setOp("thinking"); break;
1068
+ case "reasoning":
1069
+ state.reasoningLabel = d.reasoning || "";
1070
+ break;
1071
+ case "phase":
1072
+ setStatus(d.detail ? (d.phase + " · " + d.detail) : d.phase, true);
1073
+ if (d.phase === "tool" && d.detail) setOp("tool · " + d.detail);
1074
+ else if (d.phase === "retry") {
1075
+ setOp("retrying · " + (d.detail || ""));
1076
+ // Retry rows are real transport events (see MAX_RETRIES in zen.ts).
1077
+ ensureTurn().nodes.push({ type: "retry", detail: d.detail || "" });
1078
+ paint.timeline = true;
1079
+ requestPaint();
1080
+ } else if (d.phase === "thinking") {
1081
+ setOp("thinking");
1082
+ // A new model round after tool work has already streamed: the
1083
+ // "Running next step..." marker. Non-creating on purpose: the first
1084
+ // POST's thinking phase must never open a group by itself (the user
1085
+ // message event owns that), and a marker without a group is dropped.
1086
+ const t = state.currentTurn;
1087
+ if (t && t.state === "working" && t.nodes.length > 0 && t.nodes[t.nodes.length - 1].type !== "round") {
1088
+ t.nodes.push({ type: "round" });
1089
+ paint.timeline = true;
1090
+ requestPaint();
1091
+ }
1092
+ } else if (d.phase === "streaming") setOp("writing response");
1093
+ break;
1094
+ case "tool_delta": setOp("tool · " + d.name); break;
1095
+ case "tool_started": {
1096
+ setOp("tool · " + d.name);
1097
+ const e = lastOpen(d.name);
1098
+ if (!e) {
1099
+ state.entries.push({ name: d.name, detail: "", args: null, state: "running", via: "", result: null, resultChars: 0, exitCode: null });
1100
+ paint.timeline = true;
1101
+ requestPaint();
1102
+ }
1103
+ // Timeline: a started call with no approve-time node (read-only tools
1104
+ // never consult approve) opens its node here; name-only until the
1105
+ // result event fills in args. Structure only (no result bodies).
1106
+ const turn = ensureTurn();
1107
+ if (!lastOpenToolNode(turn, d.name)) {
1108
+ turn.nodes.push({ type: "tool", name: d.name, label: d.name, detail: "", state: "running" });
1109
+ paint.timeline = true;
1110
+ requestPaint();
1111
+ }
1112
+ break;
1113
+ }
1114
+ case "tool_finished": {
1115
+ const e = lastOpen(d.name);
1116
+ if (e && !e.result) {
1117
+ // Finalizer for calls whose result event never arrives (e.g. the
1118
+ // stream ends between commit and result); the result path above wins
1119
+ // whenever both arrive.
1120
+ e.state = d.isError ? "fail" : e.state;
1121
+ paint.timeline = true;
1122
+ requestPaint();
1123
+ }
1124
+ break;
1125
+ }
1126
+ case "tool_call": trackToolCall(d); setOp(d.decision === "no" ? "tool denied · " + d.name : "tool · " + d.name); break;
1127
+ case "tool_result": trackToolResult(d); break;
1128
+ case "file_diff": onFileDiff(d); break;
1129
+ case "tool_activity": addToolRow(d.label || "", !!d.isError, takeMeta(extractName(d.label)), d.result); break;
1130
+ case "usage": break;
1131
+ case "warning": addToolRow("⚠ " + (d.message || ""), false, null, null); break;
1132
+ case "approval_request": onApprovalRequest(d); break;
1133
+ case "approval_resolved": hideModal(); break;
1134
+ case "question_request": onQuestionRequest(d); break;
1135
+ case "question_resolved": hideModal(); break;
1136
+ case "message":
1137
+ // Live user echoes and tool rows already rendered (composer echo /
1138
+ // tool_activity); assistant finals render here and close the turn.
1139
+ // Every user message opens a timeline group.
1140
+ if (d.role === "assistant") {
1141
+ finalizeDraft(d.content || "");
1142
+ // Failed turns commit their streamed text as a marked partial row
1143
+ // before the error event — close those as failed, not completed.
1144
+ if ((d.content || "").indexOf("request failed before completing") !== -1) {
1145
+ closeTurn("fail", "Failed — partial output preserved");
1146
+ } else {
1147
+ closeTurn("done", "Completed");
1148
+ }
1149
+ } else if (d.role === "user") {
1150
+ adoptEcho(d.content || "");
1151
+ openTurn(d.content || "");
1152
+ }
1153
+ break;
1154
+ case "error":
1155
+ clearLive();
1156
+ hideModal();
1157
+ closeTurn("fail", "Failed: " + (d.message || "turn failed").slice(0, 160));
1158
+ state.errors.push({ title: "turn failed", detail: String(d.message || "").slice(0, 300) });
1159
+ if (state.errors.length > 50) state.errors.splice(0, state.errors.length - 50);
1160
+ showError(d.message || "turn failed");
1161
+ setBusy(false);
1162
+ setOp("error");
1163
+ paint.timeline = true;
1164
+ requestPaint();
1165
+ break;
1166
+ case "done":
1167
+ clearLive();
1168
+ hideModal();
1169
+ // The assistant final already closed the turn via message; this is the
1170
+ // backstop for turns that end without one.
1171
+ closeTurn("done", "Completed");
1172
+ setBusy(false);
1173
+ setStatus("idle", false);
1174
+ setOp("idle");
1175
+ paint.timeline = true;
1176
+ requestPaint();
1177
+ refreshSessions();
1178
+ break;
1179
+ case "cancelled":
1180
+ clearLive();
1181
+ hideModal();
1182
+ closeTurn("cancelled", "Cancelled");
1183
+ addToolRow(d.notice || "(cancelled)", false, null, null);
1184
+ setBusy(false);
1185
+ setStatus("idle", false);
1186
+ setOp("idle");
1187
+ paint.timeline = true;
1188
+ requestPaint();
1189
+ break;
1190
+ default: break;
1191
+ }
1192
+ }
1193
+
1194
+ // Adopt the optimistic composer echo when the server echoes the same user
1195
+ // text (avoids a duplicate row); render a stored row otherwise (reconnect
1196
+ // replay, where no echo exists).
1197
+ function adoptEcho(content) {
1198
+ const last = els.transcript.lastChild;
1199
+ if (last && last.dataset && last.dataset.echo === "1") {
1200
+ const body = last.querySelector(".body");
1201
+ if (body && body.textContent === content) {
1202
+ delete last.dataset.echo;
1203
+ return;
1204
+ }
1205
+ }
1206
+ addUserRow(content, false);
1207
+ }
1208
+
1209
+ // The center tool row shows the committed label; the tool name rides the
1210
+ // label prefix ("⚙ <name> ..."). The meta queue (real args) is matched by
1211
+ // that name — never parsed for values, only routed.
1212
+ function extractName(label) {
1213
+ const m = /^⚙\s+([a-z0-9_-]+)/.exec(String(label || ""));
1214
+ return m ? m[1] : "";
1215
+ }
1216
+
1217
+ function connectEvents() {
1218
+ if (state.es) { try { state.es.close(); } catch { /* ignore */ } }
1219
+ if (!state.sessionId) return;
1220
+ const es = new EventSource("/api/sessions/" + state.sessionId + "/events");
1221
+ state.es = es;
1222
+ es.onopen = () => setConn(true);
1223
+ es.onerror = () => setConn(false);
1224
+ const kinds = ["token", "thinking", "phase", "tool_delta", "tool_started",
1225
+ "tool_finished", "tool_call", "tool_activity", "tool_result", "file_diff", "usage",
1226
+ "reasoning", "warning", "approval_request", "approval_resolved",
1227
+ "question_request", "question_resolved", "message", "error", "done", "cancelled"];
1228
+ for (const k of kinds) es.addEventListener(k, onEvent);
1229
+ }
1230
+
1231
+ /* ---------------- slash commands ----------------
1232
+ * TUI parity for the commands that map to real WebUI capabilities (the
1233
+ * registry, matcher, and exact-wins semantics mirror src/App.tsx
1234
+ * SLASH_COMMANDS/filterSlashCommands/fuzzyScore — ported, not imported:
1235
+ * the browser cannot import the TUI module, and the server must not pull
1236
+ * React/Ink. Commands without a WebUI backend (/goal, /compact, /allow,
1237
+ * /skill, …) are omitted rather than faked; /help states the list. */
1238
+
1239
+ const WEB_COMMANDS = [
1240
+ { name: "/model", description: "List models, or switch (/model <name>).", takesArg: true },
1241
+ { name: "/provider", description: "Switch provider (/provider <id>).", takesArg: true },
1242
+ { name: "/effort", description: "Set reasoning effort (/effort auto|low|medium|high|max).", takesArg: true },
1243
+ { name: "/mode", description: "Set permission mode (/mode normal|yolo|plan).", takesArg: true },
1244
+ { name: "/tools", description: "List the tools with one-line descriptions.", takesArg: false },
1245
+ { name: "/thinking", description: "Show or hide model thinking in this view.", takesArg: false },
1246
+ { name: "/rename", description: "Rename the current session (/rename <name>).", takesArg: true },
1247
+ { name: "/new", description: "Start a brand-new session.", takesArg: false },
1248
+ { name: "/help", description: "List WebUI commands.", takesArg: false },
1249
+ ];
1250
+
1251
+ // Ported from src/App.tsx fuzzyScore: subsequence match with
1252
+ // gap/start/word-boundary scoring (lower is better; null = no match).
1253
+ function fuzzyScore(query, target) {
1254
+ const q = String(query).toLowerCase();
1255
+ const t = String(target).toLowerCase();
1256
+ if (!q) return 0;
1257
+ let ti = 0;
1258
+ let score = 0;
1259
+ let last = -1;
1260
+ for (let qi = 0; qi < q.length; qi++) {
1261
+ const found = t.indexOf(q[qi], ti);
1262
+ if (found === -1) return null;
1263
+ score += last === -1 ? found : found - last - 1;
1264
+ if (found === 0 || /[-_/:]/.test(t[found - 1])) score -= 2;
1265
+ if (found === last + 1) score -= 1;
1266
+ last = found;
1267
+ ti = found + 1;
1268
+ }
1269
+ return score;
1270
+ }
1271
+
1272
+ // Ported from src/App.tsx filterSlashCommands: exact match wins outright,
1273
+ // then prefix tier (registry order), then fuzzy by score.
1274
+ function filterSlashCommands(prefix) {
1275
+ const q = prefix.startsWith("/") ? prefix.slice(1) : prefix;
1276
+ const full = "/" + q;
1277
+ const exact = WEB_COMMANDS.find((c) => c.name === full);
1278
+ if (exact) return [exact];
1279
+ const pre = [];
1280
+ const fuzzy = [];
1281
+ for (const c of WEB_COMMANDS) {
1282
+ const name = c.name.slice(1);
1283
+ if (name.startsWith(q)) {
1284
+ pre.push(c);
1285
+ continue;
1286
+ }
1287
+ const s = fuzzyScore(q, name);
1288
+ if (s !== null) fuzzy.push({ c, s });
1289
+ }
1290
+ fuzzy.sort((a, b) => a.s - b.s || (a.c.name < b.c.name ? -1 : 1));
1291
+ return [...pre, ...fuzzy.map((f) => f.c)];
1292
+ }
1293
+
1294
+ const slash = { open: false, items: [], index: 0 };
1295
+
1296
+ function slashMenuEl() {
1297
+ let el = document.getElementById("slash-menu");
1298
+ if (!el) {
1299
+ el = document.createElement("div");
1300
+ el.id = "slash-menu";
1301
+ el.setAttribute("role", "listbox");
1302
+ document.querySelector(".composer-box").prepend(el);
1303
+ }
1304
+ return el;
1305
+ }
1306
+
1307
+ function closeSlash() {
1308
+ slash.open = false;
1309
+ slash.items = [];
1310
+ slash.index = 0;
1311
+ const el = document.getElementById("slash-menu");
1312
+ if (el) el.remove();
1313
+ }
1314
+
1315
+ function renderSlash() {
1316
+ const el = slashMenuEl();
1317
+ el.innerHTML = "";
1318
+ slash.items.slice(0, 8).forEach((c, i) => {
1319
+ const row = document.createElement("button");
1320
+ row.type = "button";
1321
+ row.className = "slash-row" + (i === slash.index ? " active" : "");
1322
+ row.setAttribute("role", "option");
1323
+ row.innerHTML = "<span class='slash-name'>" + esc(c.name) + "</span><span class='slash-desc'>" + esc(c.description) + "</span>";
1324
+ row.onmousedown = (e) => {
1325
+ // mousedown (not click): the textarea blur would close the menu first.
1326
+ e.preventDefault();
1327
+ acceptSlash(i);
1328
+ };
1329
+ el.appendChild(row);
1330
+ });
1331
+ }
1332
+
1333
+ function updateSlashMenu() {
1334
+ const v = els.input.value;
1335
+ if (!v.startsWith("/") || state.busy) {
1336
+ if (slash.open) closeSlash();
1337
+ return;
1338
+ }
1339
+ const first = v.split(/\s/)[0];
1340
+ slash.items = filterSlashCommands(first);
1341
+ if (slash.items.length === 0) {
1342
+ if (slash.open) closeSlash();
1343
+ return;
1344
+ }
1345
+ slash.open = true;
1346
+ slash.index = Math.min(slash.index, slash.items.length - 1);
1347
+ renderSlash();
1348
+ }
1349
+
1350
+ function acceptSlash(i) {
1351
+ const c = slash.items[i === undefined ? slash.index : i];
1352
+ if (!c) return;
1353
+ if (c.takesArg) {
1354
+ els.input.value = c.name + " ";
1355
+ closeSlash();
1356
+ els.input.focus();
1357
+ } else {
1358
+ els.input.value = "";
1359
+ closeSlash();
1360
+ runSlashCommand(c.name, "");
1361
+ }
1362
+ }
1363
+
1364
+ function addInfoRow(text) {
1365
+ const div = document.createElement("div");
1366
+ div.className = "msg tool-row";
1367
+ div.innerHTML = '<div class="body"></div>';
1368
+ div.querySelector(".body").textContent = text;
1369
+ els.transcript.appendChild(div);
1370
+ els.transcript.scrollTop = els.transcript.scrollHeight;
1371
+ pruneTranscript();
1372
+ }
1373
+
1374
+ async function runSlashCommand(name, arg) {
1375
+ if (!state.sessionId) return;
1376
+ switch (name) {
1377
+ case "/help": {
1378
+ addInfoRow("WebUI commands:\n" + WEB_COMMANDS.map((c) => c.name + " — " + c.description).join("\n") +
1379
+ "\n(TUI-only commands like /goal, /compact, /allow, /skill are not available in the WebUI.)");
1380
+ break;
1381
+ }
1382
+ case "/tools": {
1383
+ try {
1384
+ const tools = await getJSON("/api/tools");
1385
+ addInfoRow("Tools (" + tools.length + "):\n" + tools.map((t) => t.name + " — " + t.description).join("\n"));
1386
+ } catch (e) {
1387
+ showError("tools failed: " + e.message);
1388
+ }
1389
+ break;
1390
+ }
1391
+ case "/thinking": {
1392
+ els.transcript.classList.toggle("hide-thinking");
1393
+ addInfoRow("thinking is now " + (els.transcript.classList.contains("hide-thinking") ? "hidden" : "shown") + " (view only — turns are untouched).");
1394
+ break;
1395
+ }
1396
+ case "/new": {
1397
+ try {
1398
+ const res = await fetch("/api/sessions", {
1399
+ method: "POST",
1400
+ headers: { "Content-Type": "application/json" },
1401
+ body: JSON.stringify({
1402
+ provider: els.providers.value,
1403
+ model: els.models.value,
1404
+ effort: els.effort.value,
1405
+ mode: els.mode.value,
1406
+ }),
1407
+ });
1408
+ const rec = await res.json();
1409
+ await refreshSessions();
1410
+ await selectSession(rec.id);
1411
+ } catch (e) {
1412
+ showError("new session failed: " + e.message);
1413
+ }
1414
+ break;
1415
+ }
1416
+ case "/rename": {
1417
+ if (!arg) {
1418
+ addInfoRow("usage: /rename <name>");
1419
+ break;
1420
+ }
1421
+ try {
1422
+ const res = await fetch("/api/sessions/" + state.sessionId, {
1423
+ method: "PATCH",
1424
+ headers: { "Content-Type": "application/json" },
1425
+ body: JSON.stringify({ title: arg }),
1426
+ });
1427
+ if (!res.ok) throw new Error("HTTP " + res.status);
1428
+ addInfoRow("renamed to “" + arg + "”.");
1429
+ refreshSessions();
1430
+ } catch (e) {
1431
+ showError("rename failed: " + e.message);
1432
+ }
1433
+ break;
1434
+ }
1435
+ case "/model": {
1436
+ if (!arg) {
1437
+ const p = state.providers.find((x) => x.id === els.providers.value);
1438
+ const models = p ? [p.defaultModel].concat(p.fallbackModels || []) : [];
1439
+ addInfoRow("Models for " + els.providers.value + ":\n" + [...new Set(models.filter(Boolean))].join("\n") + "\n(use /model <name>)");
1440
+ break;
1441
+ }
1442
+ await applySlashSetting({ model: arg }, "model");
1443
+ break;
1444
+ }
1445
+ case "/provider": {
1446
+ if (!arg) {
1447
+ addInfoRow("Providers:\n" + state.providers.map((p) => p.id + (p.needsKey && !p.hasKey ? " (no key)" : "")).join("\n") + "\n(use /provider <id>)");
1448
+ break;
1449
+ }
1450
+ const hit = state.providers.find((p) => p.id === arg) ||
1451
+ state.providers.find((p) => p.id.indexOf(arg) === 0);
1452
+ if (!hit) {
1453
+ addInfoRow("unknown provider “" + arg + "”. Use /provider to list.");
1454
+ break;
1455
+ }
1456
+ els.providers.value = hit.id;
1457
+ refreshModels();
1458
+ await applySlashSetting({ provider: hit.id, model: hit.defaultModel || els.models.value }, "provider");
1459
+ break;
1460
+ }
1461
+ case "/effort": {
1462
+ const levels = ["auto", "low", "medium", "high", "max"];
1463
+ if (levels.indexOf(arg) === -1) {
1464
+ addInfoRow("usage: /effort " + levels.join("|"));
1465
+ break;
1466
+ }
1467
+ els.effort.value = arg;
1468
+ await applySlashSetting({ effort: arg }, "effort");
1469
+ break;
1470
+ }
1471
+ case "/mode": {
1472
+ const modes = ["normal", "yolo", "plan"];
1473
+ if (modes.indexOf(arg) === -1) {
1474
+ addInfoRow("usage: /mode " + modes.join("|"));
1475
+ break;
1476
+ }
1477
+ els.mode.value = arg;
1478
+ await applySlashSetting({ mode: arg }, "mode");
1479
+ break;
1480
+ }
1481
+ default:
1482
+ break;
1483
+ }
1484
+ }
1485
+
1486
+ async function applySlashSetting(patch, label) {
1487
+ try {
1488
+ const res = await fetch("/api/sessions/" + state.sessionId, {
1489
+ method: "PATCH",
1490
+ headers: { "Content-Type": "application/json" },
1491
+ body: JSON.stringify(patch),
1492
+ });
1493
+ if (!res.ok) {
1494
+ const data = await res.json().catch(() => ({}));
1495
+ throw new Error(data.error || ("HTTP " + res.status));
1496
+ }
1497
+ const rec = await res.json();
1498
+ if (patch.provider) {
1499
+ els.providers.value = rec.provider;
1500
+ refreshModels();
1501
+ }
1502
+ if (patch.model) els.models.value = rec.model;
1503
+ if (patch.effort) els.effort.value = rec.effort;
1504
+ if (patch.mode) els.mode.value = rec.mode;
1505
+ addInfoRow(label + " → " + (patch.provider || patch.model || patch.effort || patch.mode));
1506
+ refreshSessions();
1507
+ } catch (e) {
1508
+ showError(label + " failed: " + e.message);
1509
+ }
1510
+ }
1511
+
1512
+ // Route a submitted line: exact slash command → local execution, unknown
1513
+ // slash → inline error (mirrors the TUI: unknown commands never reach the
1514
+ // model), anything else → normal turn.
1515
+ function trySlashSubmit(text) {
1516
+ if (!text.startsWith("/")) return false;
1517
+ const space = text.indexOf(" ");
1518
+ const cmd = space === -1 ? text : text.slice(0, space);
1519
+ const arg = space === -1 ? "" : text.slice(space + 1).trim();
1520
+ const hit = WEB_COMMANDS.find((c) => c.name === cmd);
1521
+ if (!hit) {
1522
+ const sug = filterSlashCommands(cmd).slice(0, 3).map((c) => c.name).join(", ");
1523
+ addInfoRow("unknown command “" + cmd + "”." + (sug ? " Did you mean: " + sug + "?" : "") + " Use /help.");
1524
+ return true;
1525
+ }
1526
+ if (hit.takesArg && !arg) {
1527
+ runSlashCommand(hit.name, "");
1528
+ } else {
1529
+ runSlashCommand(hit.name, arg);
1530
+ }
1531
+ return true;
1532
+ }
1533
+
1534
+ /* ---------------- sessions / settings / composer ---------------- */
1535
+
1536
+ async function refreshProviders() {
1537
+ state.providers = await getJSON("/api/providers");
1538
+ els.providers.innerHTML = "";
1539
+ for (const p of state.providers) {
1540
+ const o = document.createElement("option");
1541
+ o.value = p.id;
1542
+ o.textContent = p.name + (p.needsKey && !p.hasKey ? " (no key)" : "");
1543
+ els.providers.appendChild(o);
1544
+ }
1545
+ }
1546
+
1547
+ function refreshModels() {
1548
+ const p = state.providers.find((x) => x.id === els.providers.value);
1549
+ const models = p ? [p.defaultModel].concat(p.fallbackModels || []) : [];
1550
+ const seen = [...new Set(models.filter(Boolean))];
1551
+ els.models.innerHTML = "";
1552
+ for (const m of seen) {
1553
+ const o = document.createElement("option");
1554
+ o.value = m;
1555
+ o.textContent = m;
1556
+ els.models.appendChild(o);
1557
+ }
1558
+ }
1559
+
1560
+ async function refreshSessions() {
1561
+ const list = await getJSON("/api/sessions");
1562
+ els.sessions.innerHTML = "";
1563
+ for (const s of list) {
1564
+ const b = document.createElement("button");
1565
+ b.type = "button";
1566
+ b.className = "session" + (s.id === state.sessionId ? " active" : "");
1567
+ b.innerHTML = "<div>" + esc(s.title || s.id) + "</div>" +
1568
+ '<div class="sub">' + esc(s.provider + " · " + (s.model || "no model") + " · " + relTime(s.updatedAt)) + "</div>";
1569
+ b.onclick = () => selectSession(s.id);
1570
+ els.sessions.appendChild(b);
1571
+ }
1572
+ }
1573
+
1574
+ function resetAgent() {
1575
+ state.entries = [];
1576
+ state.pendingMeta = [];
1577
+ state.turnGroups = [];
1578
+ state.currentTurn = null;
1579
+ state.diffs = new Map();
1580
+ state.errors = [];
1581
+ state.viewerPath = null;
1582
+ els.viewer.hidden = true;
1583
+ setOp("idle");
1584
+ paint.timeline = true;
1585
+ requestPaint();
1586
+ }
1587
+
1588
+ async function selectSession(id) {
1589
+ state.sessionId = id;
1590
+ clearLive();
1591
+ hideModal();
1592
+ showError("");
1593
+ els.transcript.innerHTML = "";
1594
+ prunedRows = 0;
1595
+ resetAgent();
1596
+ const rec = await getJSON("/api/sessions/" + id);
1597
+ els.workspace.textContent = rec.cwd || "—";
1598
+ els.workspace.title = rec.cwd || "";
1599
+ els.providers.value = rec.provider;
1600
+ refreshModels();
1601
+ if (rec.model) els.models.value = rec.model;
1602
+ els.effort.value = rec.effort || "auto";
1603
+ els.mode.value = rec.mode || "normal";
1604
+ for (const t of rec.turns || []) {
1605
+ if (t.role === "user") {
1606
+ addUserRow(t.content, false);
1607
+ openTurn(t.content);
1608
+ } else if (t.role === "assistant") {
1609
+ addAssistantRow(t.content);
1610
+ closeTurn(
1611
+ t.content.indexOf("request failed before completing") !== -1 ? "fail" : "done",
1612
+ t.content.indexOf("request failed before completing") !== -1 ? "Failed — partial output preserved" : "Completed"
1613
+ );
1614
+ } else {
1615
+ // Stored tool rows carry only the committed label (args live in the
1616
+ // event stream, not the store) — the label renders verbatim, and a
1617
+ // cancelled-turn line closes the group it belongs to.
1618
+ addToolRow(t.content, !!t.error, null, null);
1619
+ if (t.content.indexOf("(cancelled)") !== -1) {
1620
+ closeTurn("cancelled", "Cancelled");
1621
+ } else if (state.currentTurn && state.currentTurn.state === "working") {
1622
+ state.currentTurn.nodes.push({
1623
+ type: "tool",
1624
+ name: extractName(t.content),
1625
+ label: t.content,
1626
+ detail: "",
1627
+ state: t.error ? "fail" : "ok",
1628
+ });
1629
+ }
1630
+ }
1631
+ }
1632
+ paint.timeline = true;
1633
+ requestPaint();
1634
+ if (rec.pendingApproval) onApprovalRequest(rec.pendingApproval);
1635
+ else if (rec.pendingQuestion) onQuestionRequest(rec.pendingQuestion);
1636
+ setBusy(!!rec.busy);
1637
+ if (rec.busy) setOp("working…");
1638
+ else setStatus("idle", false);
1639
+ connectEvents();
1640
+ refreshSessions();
1641
+ closeOverlays();
1642
+ }
1643
+
1644
+ async function applySettings() {
1645
+ if (!state.sessionId || state.busy) return;
1646
+ try {
1647
+ await fetch("/api/sessions/" + state.sessionId, {
1648
+ method: "PATCH",
1649
+ headers: { "Content-Type": "application/json" },
1650
+ body: JSON.stringify({
1651
+ provider: els.providers.value,
1652
+ model: els.models.value,
1653
+ effort: els.effort.value,
1654
+ mode: els.mode.value,
1655
+ }),
1656
+ });
1657
+ } catch (e) {
1658
+ showError("settings failed: " + e.message);
1659
+ }
1660
+ }
1661
+
1662
+ els.form.addEventListener("submit", async (e) => {
1663
+ e.preventDefault();
1664
+ const text = els.input.value.trim();
1665
+ if (!text || !state.sessionId || state.busy) return;
1666
+ els.input.value = "";
1667
+ els.input.style.height = "";
1668
+ closeSlash();
1669
+ // Slash commands execute locally (settings, lists, view toggles) and
1670
+ // never start a model turn; unknown slash input is an inline error.
1671
+ if (trySlashSubmit(text)) return;
1672
+ const echo = addUserRow(text, true);
1673
+ setBusy(true);
1674
+ try {
1675
+ await postJSON("/api/sessions/" + state.sessionId + "/messages", { content: text });
1676
+ } catch (err) {
1677
+ try { echo.remove(); } catch { /* already gone */ }
1678
+ showError(err.message);
1679
+ setBusy(false);
1680
+ setStatus("idle", false);
1681
+ }
1682
+ });
1683
+
1684
+ els.input.addEventListener("keydown", (e) => {
1685
+ // Slash-menu keyboard flow (mirrors the TUI): arrows move, Tab accepts
1686
+ // the highlight into the input, Enter sends, Esc closes.
1687
+ if (slash.open) {
1688
+ if (e.key === "ArrowDown") {
1689
+ e.preventDefault();
1690
+ slash.index = (slash.index + 1) % slash.items.length;
1691
+ renderSlash();
1692
+ return;
1693
+ }
1694
+ if (e.key === "ArrowUp") {
1695
+ e.preventDefault();
1696
+ slash.index = (slash.index - 1 + slash.items.length) % slash.items.length;
1697
+ renderSlash();
1698
+ return;
1699
+ }
1700
+ if (e.key === "Tab") {
1701
+ e.preventDefault();
1702
+ acceptSlash();
1703
+ return;
1704
+ }
1705
+ if (e.key === "Escape") {
1706
+ e.preventDefault();
1707
+ closeSlash();
1708
+ return;
1709
+ }
1710
+ }
1711
+ if (e.key === "Enter" && !e.shiftKey) {
1712
+ e.preventDefault();
1713
+ els.form.requestSubmit();
1714
+ }
1715
+ if (e.key === "Escape" && !els.viewer.hidden) closeViewer();
1716
+ });
1717
+
1718
+ els.input.addEventListener("input", () => {
1719
+ els.input.style.height = "";
1720
+ els.input.style.height = Math.min(220, els.input.scrollHeight) + "px";
1721
+ updateSlashMenu();
1722
+ });
1723
+
1724
+ els.stop.addEventListener("click", async () => {
1725
+ if (!state.sessionId) return;
1726
+ try {
1727
+ await postJSON("/api/sessions/" + state.sessionId + "/cancel", {});
1728
+ } catch (e) {
1729
+ showError("cancel failed: " + e.message);
1730
+ }
1731
+ });
1732
+
1733
+ // File attachment (frontend-only assistance, like paste): the chosen file's
1734
+ // text is inserted into the composer as a fenced block. ATOM itself learns
1735
+ // it through the message — no backend feature is assumed.
1736
+ els.attach.addEventListener("click", () => els.fileInput.click());
1737
+ els.fileInput.addEventListener("change", () => {
1738
+ const f = els.fileInput.files && els.fileInput.files[0];
1739
+ els.fileInput.value = "";
1740
+ if (!f) return;
1741
+ const cap = 32768;
1742
+ const reader = new FileReader();
1743
+ reader.onload = () => {
1744
+ const text = String(reader.result || "");
1745
+ if (text.indexOf("\x00") !== -1) {
1746
+ insertAtCursor("\n\nAttached " + f.name + " (" + f.size + " bytes, binary — describe it or paste relevant text).\n");
1747
+ return;
1748
+ }
1749
+ const ext = (f.name.split(".").pop() || "").toLowerCase().slice(0, 12);
1750
+ const body = text.length > cap ? text.slice(0, cap) + "\n… (truncated: " + text.length + " chars total)" : text;
1751
+ insertAtCursor("\n\nAttached " + f.name + ":\n```" + ext + "\n" + body + "\n```\n");
1752
+ };
1753
+ reader.onerror = () => showError("could not read " + f.name);
1754
+ reader.readAsText(f);
1755
+ });
1756
+
1757
+ function insertAtCursor(snippet) {
1758
+ const start = els.input.selectionStart || els.input.value.length;
1759
+ const end = els.input.selectionEnd || start;
1760
+ els.input.value = els.input.value.slice(0, start) + snippet + els.input.value.slice(end);
1761
+ els.input.focus();
1762
+ }
1763
+
1764
+ els.newSession.addEventListener("click", async () => {
1765
+ const res = await fetch("/api/sessions", {
1766
+ method: "POST",
1767
+ headers: { "Content-Type": "application/json" },
1768
+ body: JSON.stringify({
1769
+ provider: els.providers.value,
1770
+ model: els.models.value,
1771
+ effort: els.effort.value,
1772
+ mode: els.mode.value,
1773
+ }),
1774
+ });
1775
+ const rec = await res.json();
1776
+ await refreshSessions();
1777
+ await selectSession(rec.id);
1778
+ });
1779
+
1780
+ for (const el of [els.providers, els.models, els.effort, els.mode]) {
1781
+ el.addEventListener("change", () => {
1782
+ if (el === els.providers) refreshModels();
1783
+ applySettings();
1784
+ });
1785
+ }
1786
+
1787
+ // Copy buttons inside rendered code blocks (event delegation — rows stream in).
1788
+ els.transcript.addEventListener("click", (e) => {
1789
+ const btn = e.target && e.target.closest ? e.target.closest(".copy") : null;
1790
+ if (!btn) return;
1791
+ const code = btn.closest(".codeblock");
1792
+ const text = code ? code.querySelector("code").textContent : "";
1793
+ const done = () => { btn.textContent = "copied"; setTimeout(() => { btn.textContent = "copy"; }, 1200); };
1794
+ if (navigator.clipboard && navigator.clipboard.writeText) {
1795
+ navigator.clipboard.writeText(text).then(done, done);
1796
+ } else {
1797
+ const ta = document.createElement("textarea");
1798
+ ta.value = text;
1799
+ document.body.appendChild(ta);
1800
+ ta.select();
1801
+ try { document.execCommand("copy"); } catch { /* clipboard unavailable */ }
1802
+ ta.remove();
1803
+ done();
1804
+ }
1805
+ });
1806
+
1807
+ // Turn-group expand/collapse in the execution timeline.
1808
+ els.timeline.addEventListener("click", (e) => {
1809
+ const head = e.target && e.target.closest ? e.target.closest(".turn-head") : null;
1810
+ if (!head) return;
1811
+ const key = Number(head.dataset.turn);
1812
+ const turn = state.turnGroups.find((t) => t.startedAt === key);
1813
+ if (turn) {
1814
+ turn.open = turn.open === false ? true : false;
1815
+ paint.timeline = true;
1816
+ requestPaint();
1817
+ }
1818
+ });
1819
+
1820
+ // File rows with diffs open the viewer; viewer tabs + close.
1821
+ els.files.addEventListener("click", (e) => {
1822
+ const row = e.target && e.target.closest ? e.target.closest("[data-diffpath]") : null;
1823
+ if (!row) return;
1824
+ openViewer(row.dataset.diffpath);
1825
+ });
1826
+ $("viewer-close").addEventListener("click", closeViewer);
1827
+ els.viewer.addEventListener("click", (e) => {
1828
+ if (e.target === els.viewer) closeViewer();
1829
+ });
1830
+ document.addEventListener("keydown", (e) => {
1831
+ if (e.key === "Escape" && !els.viewer.hidden) closeViewer();
1832
+ });
1833
+ els.tabUnified.addEventListener("click", () => {
1834
+ state.viewerTab = "unified";
1835
+ els.tabUnified.classList.add("active");
1836
+ els.tabSide.classList.remove("active");
1837
+ renderViewer();
1838
+ });
1839
+ els.tabSide.addEventListener("click", () => {
1840
+ state.viewerTab = "side";
1841
+ els.tabSide.classList.add("active");
1842
+ els.tabUnified.classList.remove("active");
1843
+ renderViewer();
1844
+ });
1845
+
1846
+ /* ---------------- theme (light / dark, OS-respected, persisted) ---------------- */
1847
+
1848
+ function currentTheme() {
1849
+ return document.documentElement.dataset.theme === "light" ? "light" : "dark";
1850
+ }
1851
+
1852
+ function paintThemeButton() {
1853
+ const btn = $("theme-toggle");
1854
+ if (btn) btn.textContent = currentTheme() === "light" ? "Theme: light" : "Theme: dark";
1855
+ }
1856
+
1857
+ function setTheme(next) {
1858
+ document.documentElement.dataset.theme = next === "light" ? "light" : "dark";
1859
+ try {
1860
+ localStorage.setItem("atom-theme", currentTheme());
1861
+ } catch { /* private mode — session default stands */ }
1862
+ paintThemeButton();
1863
+ }
1864
+
1865
+ $("theme-toggle").addEventListener("click", () => {
1866
+ setTheme(currentTheme() === "light" ? "dark" : "light");
1867
+ });
1868
+
1869
+ /* ---------------- responsive overlays ---------------- */
1870
+ function closeOverlays() {
1871
+ if (window.innerWidth <= 820) els.sidebar.classList.add("hidden-narrow");
1872
+ if (window.innerWidth <= 1240) els.right.classList.add("hidden-narrow");
1873
+ els.scrim.hidden = true;
1874
+ }
1875
+
1876
+ function syncScrim() {
1877
+ const sideOpen = !els.sidebar.classList.contains("hidden-narrow") && window.innerWidth <= 820;
1878
+ const rightOpen = !els.right.classList.contains("hidden-narrow") && window.innerWidth <= 1240;
1879
+ els.scrim.hidden = !(sideOpen || rightOpen);
1880
+ }
1881
+
1882
+ $("menu-open").addEventListener("click", () => {
1883
+ els.sidebar.classList.remove("hidden-narrow");
1884
+ syncScrim();
1885
+ });
1886
+ $("menu-close").addEventListener("click", closeOverlays);
1887
+ $("panel-toggle").addEventListener("click", () => {
1888
+ els.right.classList.toggle("hidden-narrow");
1889
+ syncScrim();
1890
+ });
1891
+ $("panel-close").addEventListener("click", closeOverlays);
1892
+ els.scrim.addEventListener("click", closeOverlays);
1893
+ window.addEventListener("resize", () => {
1894
+ if (window.innerWidth > 820) els.sidebar.classList.remove("hidden-narrow");
1895
+ if (window.innerWidth > 1240) els.right.classList.remove("hidden-narrow");
1896
+ syncScrim();
1897
+ });
1898
+
1899
+ /* ---------------- boot ---------------- */
1900
+
1901
+ (async function boot() {
1902
+ try {
1903
+ paintThemeButton();
1904
+ if (window.innerWidth <= 820) els.sidebar.classList.add("hidden-narrow");
1905
+ if (window.innerWidth <= 1240) els.right.classList.add("hidden-narrow");
1906
+ syncScrim();
1907
+ await refreshProviders();
1908
+ refreshModels();
1909
+ await refreshSessions();
1910
+ const first = els.sessions.querySelector(".session");
1911
+ if (first) first.click();
1912
+ else {
1913
+ const res = await fetch("/api/sessions", {
1914
+ method: "POST",
1915
+ headers: { "Content-Type": "application/json" },
1916
+ body: JSON.stringify({}),
1917
+ });
1918
+ const rec = await res.json();
1919
+ await refreshSessions();
1920
+ await selectSession(rec.id);
1921
+ }
1922
+ } catch (e) {
1923
+ showError("startup failed: " + e.message);
1924
+ }
1925
+ })();