@hydra-acp/browser 0.1.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.
@@ -0,0 +1,2151 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
6
+ <title>hydra-acp-browser</title>
7
+ <style nonce="__CSP_NONCE__">
8
+ :root {
9
+ color-scheme: dark;
10
+ --bg: #0e1116;
11
+ --panel: #161b22;
12
+ --panel-2: #1c2230;
13
+ --border: #2a3346;
14
+ --fg: #d6deeb;
15
+ --muted: #7c8aa8;
16
+ --accent: #6ea8fe;
17
+ --good: #4ec9b0;
18
+ --bad: #f97583;
19
+ --warn: #d4a17e;
20
+ --user-bg: #1f2a44;
21
+ --agent-bg: #161b22;
22
+ --tool-bg: #19202d;
23
+ --code-bg: #0a0d12;
24
+ font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
25
+ font-size: 15px;
26
+ }
27
+ * { box-sizing: border-box; }
28
+ html, body { height: 100%; margin: 0; }
29
+ body { background: var(--bg); color: var(--fg); overflow: hidden; }
30
+ button { font: inherit; color: inherit; background: transparent; border: 1px solid var(--border); border-radius: 6px; padding: 0.35rem 0.75rem; cursor: pointer; }
31
+ button:hover { background: var(--panel-2); }
32
+ button.primary { background: var(--accent); color: #0a0d12; border-color: var(--accent); font-weight: 600; }
33
+ button.primary:hover { filter: brightness(0.9); }
34
+ button.danger { color: var(--bad); border-color: var(--bad); }
35
+ button.danger:hover { background: rgba(249, 117, 131, 0.12); }
36
+ button.ghost { border-color: transparent; }
37
+ input, select, textarea {
38
+ font: inherit; color: inherit; background: var(--panel-2);
39
+ border: 1px solid var(--border); border-radius: 6px; padding: 0.5rem 0.75rem;
40
+ }
41
+ input:focus, textarea:focus, select:focus { outline: 2px solid var(--accent); outline-offset: -1px; }
42
+ a { color: var(--accent); text-decoration: none; }
43
+ a:hover { text-decoration: underline; }
44
+ code { background: var(--panel-2); padding: 0.1rem 0.4rem; border-radius: 4px; font-family: ui-monospace, "SFMono-Regular", Menlo, monospace; font-size: 0.9em; }
45
+ pre { background: var(--code-bg); border: 1px solid var(--border); border-radius: 6px; padding: 0.75rem; overflow-x: auto; font-family: ui-monospace, "SFMono-Regular", Menlo, monospace; font-size: 0.88em; line-height: 1.45; margin: 0.5rem 0; }
46
+ pre code { background: transparent; padding: 0; }
47
+
48
+ #app { height: 100dvh; display: flex; flex-direction: column; }
49
+
50
+ /* Header */
51
+ .topbar { display: flex; align-items: center; gap: 0.75rem; padding: 0.6rem 0.9rem; border-bottom: 1px solid var(--border); background: var(--panel); }
52
+ .topbar .title { font-weight: 600; font-size: 0.95rem; }
53
+ .topbar .spacer { flex: 1; }
54
+ .pill { font-size: 0.8rem; color: var(--muted); padding: 0.15rem 0.5rem; border: 1px solid var(--border); border-radius: 999px; display: inline-flex; align-items: center; gap: 0.3rem; }
55
+ .pill.clickable { cursor: pointer; }
56
+ .pill.clickable:hover { background: var(--panel-2); color: var(--fg); }
57
+ .pill.ready { color: var(--good); border-color: var(--good); }
58
+ .pill.working { color: var(--warn); border-color: var(--warn); }
59
+ .pill.working .dot { animation: pill-pulse 1.1s ease-in-out infinite; }
60
+ @keyframes pill-pulse { 0%, 100% { opacity: 0.35; } 50% { opacity: 1; } }
61
+
62
+ /* Session list */
63
+ .list { flex: 1; overflow-y: auto; padding: 1rem; }
64
+ .list .group { margin-bottom: 1.25rem; }
65
+ .list .group h2 { font-size: 0.85rem; text-transform: uppercase; letter-spacing: 0.05em; color: var(--muted); margin: 0 0 0.5rem; }
66
+ .card { background: var(--panel); border: 1px solid var(--border); border-radius: 8px; padding: 0.75rem 1rem; margin-bottom: 0.5rem; display: flex; align-items: center; gap: 0.75rem; cursor: pointer; }
67
+ .card:hover { background: var(--panel-2); }
68
+ .card .meta { flex: 1; min-width: 0; }
69
+ .card .meta .row1 { font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
70
+ .card .meta .row2 { color: var(--muted); font-size: 0.85rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; margin-top: 0.15rem; }
71
+ .card .badges { display: flex; gap: 0.4rem; flex-wrap: wrap; align-items: center; }
72
+ .badge { font-size: 0.75rem; padding: 0.1rem 0.45rem; border-radius: 999px; border: 1px solid var(--border); color: var(--muted); }
73
+ .badge.live { color: var(--good); border-color: var(--good); }
74
+ .badge.cold { color: var(--warn); border-color: var(--warn); }
75
+ .card .actions { display: flex; gap: 0.4rem; }
76
+ .card .actions button { padding: 0.25rem 0.55rem; font-size: 0.85rem; }
77
+ .empty { color: var(--muted); padding: 2rem; text-align: center; }
78
+
79
+ /* Chat */
80
+ .chat { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
81
+ /* Reading-column constraint: on wide screens, .chat-body and .composer
82
+ collapse their inline padding to keep content in a ~56rem column
83
+ (centered) instead of stretching across the full window. */
84
+ .chat-header { display: flex; gap: 0.5rem; align-items: center; padding: 0.5rem 0.9rem; border-bottom: 1px solid var(--border); background: var(--panel); }
85
+ .chat-header .info { flex: 1; min-width: 0; }
86
+ .chat-header .info .row1 { font-weight: 600; font-size: 0.95rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
87
+ .chat-header .info .row2 { color: var(--muted); font-size: 0.8rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
88
+ .chat-body { flex: 1; overflow-y: auto; padding: 1rem max(1rem, calc(50% - 28rem)); display: flex; flex-direction: column; gap: 0.65rem; }
89
+ .msg { padding: 0.6rem 0.9rem; border-radius: 10px; max-width: 100%; word-wrap: break-word; }
90
+ .msg.user { background: var(--user-bg); border: 1px solid var(--border); align-self: flex-end; max-width: 70%; }
91
+ .msg.agent { background: var(--agent-bg); border: 1px solid var(--border); align-self: flex-start; max-width: 90%; }
92
+ .msg.system { background: var(--panel-2); border: 1px solid var(--border); color: var(--muted); font-size: 0.85rem; align-self: center; max-width: 70%; text-align: center; }
93
+ .msg.error { background: rgba(249,117,131,0.1); border: 1px solid var(--bad); color: var(--bad); }
94
+ .msg .body { margin: 0; }
95
+ .msg .body p:first-child { margin-top: 0; }
96
+ .msg .body p:last-child { margin-bottom: 0; }
97
+ .msg .body p { margin: 0.4rem 0; line-height: 1.5; }
98
+ .msg .body ul, .msg .body ol { margin: 0.4rem 0; padding-left: 1.4rem; }
99
+ .msg .body li { margin: 0.2rem 0; }
100
+ .msg .body blockquote { margin: 0.4rem 0; padding: 0.25rem 0.75rem; border-left: 3px solid var(--border); color: var(--muted); }
101
+ .msg .body h1, .msg .body h2, .msg .body h3 { margin: 0.5rem 0 0.3rem; font-size: 1.05rem; }
102
+ .msg .body table { border-collapse: collapse; margin: 0.5rem 0; font-size: 0.9em; max-width: 100%; display: block; overflow-x: auto; }
103
+ .msg .body th, .msg .body td { border: 1px solid var(--border); padding: 0.3rem 0.55rem; vertical-align: top; }
104
+ .msg .body th { background: var(--panel-2); font-weight: 600; text-align: left; }
105
+ .msg .body tr:nth-child(even) td { background: rgba(255, 255, 255, 0.02); }
106
+
107
+ /* Tool spinner / cards */
108
+ .spinner { background: var(--tool-bg); border: 1px solid var(--border); border-radius: 8px; padding: 0.5rem 0.75rem; cursor: pointer; align-self: stretch; }
109
+ .spinner .head { display: flex; gap: 0.5rem; align-items: center; color: var(--muted); font-size: 0.9rem; }
110
+ .spinner .head .queue-cancel { margin-left: auto; }
111
+ .spinner .head .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--accent); animation: pulse 1.2s infinite ease-in-out; }
112
+ @keyframes pulse { 0%,100% { transform: scale(1); opacity: 0.6; } 50% { transform: scale(1.4); opacity: 1; } }
113
+ .spinner.expanded { background: var(--panel); }
114
+ .spinner ul { margin: 0.4rem 0 0; padding-left: 1rem; font-size: 0.85rem; color: var(--fg); }
115
+ .spinner li { margin: 0.15rem 0; display: flex; gap: 0.4rem; align-items: center; }
116
+ .spinner li .icon { font-family: ui-monospace, monospace; }
117
+
118
+ .toolcard { background: var(--tool-bg); border: 1px solid var(--border); border-radius: 8px; padding: 0; align-self: stretch; }
119
+ .toolcard .head { padding: 0.5rem 0.75rem; display: flex; gap: 0.5rem; align-items: center; cursor: pointer; }
120
+ .toolcard .head .title { flex: 1; font-size: 0.9rem; }
121
+ .toolcard .head .kind { color: var(--muted); font-size: 0.8rem; }
122
+ .toolcard .body { display: none; padding: 0 0.75rem 0.75rem; border-top: 1px solid var(--border); }
123
+ .toolcard.open .body { display: block; }
124
+ .toolcard pre { margin: 0.5rem 0 0; max-height: 24rem; }
125
+
126
+ /* Permission card */
127
+ .perm { background: rgba(110,168,254,0.07); border: 1px solid var(--accent); border-radius: 8px; padding: 0.75rem 0.9rem; align-self: stretch; }
128
+ .perm .title { font-weight: 600; margin-bottom: 0.35rem; }
129
+ .perm .desc { color: var(--muted); font-size: 0.9rem; margin-bottom: 0.6rem; }
130
+ .perm .opts { display: flex; gap: 0.5rem; flex-wrap: wrap; }
131
+
132
+ /* Queue / processing chips on user bubbles */
133
+ .queue-chip { display: inline-flex; gap: 0.35rem; align-items: center; font-size: 0.72rem; padding: 0.1rem 0.45rem; border-radius: 999px; margin-bottom: 0.3rem; }
134
+ .queue-chip .dot { width: 6px; height: 6px; border-radius: 50%; background: var(--accent); animation: pulse 1.2s infinite ease-in-out; }
135
+ .queue-queued { color: var(--warn); border: 1px solid var(--warn); background: rgba(212,161,126,0.1); }
136
+ .queue-processing { color: var(--accent); border: 1px solid var(--accent); background: rgba(110,168,254,0.1); }
137
+ .queue-cancelled { color: var(--muted); border: 1px solid var(--border); background: var(--panel-2); }
138
+ .queue-cancel { padding: 0; border: none; color: inherit; font-size: 0.95rem; line-height: 1; cursor: pointer; background: transparent; opacity: 0.7; }
139
+ .queue-cancel:hover { opacity: 1; background: transparent; }
140
+
141
+ /* Composer */
142
+ .composer { display: flex; gap: 0.5rem; padding: 0.6rem max(0.6rem, calc(50% - 28rem)); border-top: 1px solid var(--border); background: var(--panel); align-items: flex-end; }
143
+ .composer textarea { flex: 1; min-height: 2.4rem; max-height: 12rem; resize: none; }
144
+ .composer .stop { color: var(--bad); border-color: var(--bad); }
145
+
146
+ /* Modal */
147
+ .modal-bg { position: fixed; inset: 0; background: rgba(0,0,0,0.5); display: flex; align-items: center; justify-content: center; padding: 1rem; z-index: 50; }
148
+ .modal { background: var(--panel); border: 1px solid var(--border); border-radius: 10px; padding: 1.25rem; width: 28rem; max-width: 100%; max-height: 100%; overflow-y: auto; }
149
+ .modal h2 { margin: 0 0 0.75rem; font-size: 1.05rem; }
150
+ .modal .field { display: flex; flex-direction: column; gap: 0.25rem; margin-bottom: 0.7rem; }
151
+ .modal .field label { font-size: 0.85rem; color: var(--muted); }
152
+ .modal .actions { display: flex; gap: 0.5rem; justify-content: flex-end; margin-top: 0.5rem; }
153
+ .modal .err { color: var(--bad); font-size: 0.85rem; margin-top: 0.4rem; }
154
+
155
+ /* File overlay */
156
+ .files { display: flex; flex-direction: column; height: 100%; }
157
+ .files .crumbs { padding: 0.5rem 0.75rem; border-bottom: 1px solid var(--border); font-family: ui-monospace, monospace; font-size: 0.85rem; overflow-x: auto; white-space: nowrap; }
158
+ .files .crumb { color: var(--accent); cursor: pointer; }
159
+ .files .crumb:hover { text-decoration: underline; }
160
+ .files .body { flex: 1; overflow-y: auto; padding: 0.5rem 0; }
161
+ .files .entry { display: flex; gap: 0.5rem; align-items: center; padding: 0.35rem 0.75rem; cursor: pointer; }
162
+ .files .entry:hover { background: var(--panel-2); }
163
+ .files .entry .icon { width: 1.2rem; text-align: center; color: var(--muted); }
164
+ .files .entry .name { flex: 1; }
165
+ .files .entry .size { color: var(--muted); font-size: 0.8rem; }
166
+ .files .preview { padding: 0.75rem; }
167
+ .files .preview pre { max-height: none; }
168
+
169
+ /* Banner */
170
+ .banner { padding: 0.4rem 0.9rem; font-size: 0.85rem; text-align: center; }
171
+ .banner.bad { background: rgba(249,117,131,0.12); color: var(--bad); border-bottom: 1px solid var(--bad); }
172
+ .banner.warn { background: rgba(212,161,126,0.12); color: var(--warn); border-bottom: 1px solid var(--warn); }
173
+ </style>
174
+ </head>
175
+ <body>
176
+ <div id="app"></div>
177
+ <script nonce="__CSP_NONCE__">"use strict";
178
+ (() => {
179
+ // src/ui/dom.ts
180
+ function el(tag, attrs, ...children) {
181
+ const node = document.createElement(tag);
182
+ if (attrs) {
183
+ for (const [k, v] of Object.entries(attrs)) {
184
+ if (v === void 0 || v === null || v === false) {
185
+ continue;
186
+ }
187
+ if (k === "class") {
188
+ node.className = v;
189
+ } else if (k.startsWith("on") && typeof v === "function") {
190
+ node.addEventListener(k.slice(2).toLowerCase(), v);
191
+ } else if (k === "html") {
192
+ node.innerHTML = v;
193
+ } else {
194
+ node.setAttribute(k, String(v));
195
+ }
196
+ }
197
+ }
198
+ for (const c of children) {
199
+ if (c == null || c === false) continue;
200
+ if (Array.isArray(c)) {
201
+ for (const cc of c) appendChild(node, cc);
202
+ } else {
203
+ appendChild(node, c);
204
+ }
205
+ }
206
+ return node;
207
+ }
208
+ function appendChild(parent, c) {
209
+ if (c == null || c === false) return;
210
+ if (c instanceof Node) {
211
+ parent.appendChild(c);
212
+ } else {
213
+ parent.appendChild(document.createTextNode(String(c)));
214
+ }
215
+ }
216
+
217
+ // src/ui/markdown.ts
218
+ function escapeHtml(s) {
219
+ return String(s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
220
+ }
221
+ function inlineMd(s) {
222
+ s = s.replace(/`([^`\n]+)`/g, (_m, c) => `<code>${c}</code>`);
223
+ s = s.replace(/\*\*([^*\n]+)\*\*/g, "<strong>$1</strong>");
224
+ s = s.replace(/(^|[^*])\*([^*\n]+)\*/g, "$1<em>$2</em>");
225
+ s = s.replace(/__([^_\n]+)__/g, "<strong>$1</strong>");
226
+ s = s.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (_m, text, url) => {
227
+ if (!/^(https?:\/\/|\/|\.)/i.test(url)) {
228
+ return `[${text}](${url})`;
229
+ }
230
+ return `<a href="${escapeHtml(url)}" target="_blank" rel="noopener noreferrer">${text}</a>`;
231
+ });
232
+ return s;
233
+ }
234
+ function parseTableRow(s) {
235
+ const trimmed = s.trim();
236
+ if (!trimmed.includes("|")) return null;
237
+ let stripped = trimmed;
238
+ if (stripped.startsWith("|")) stripped = stripped.slice(1);
239
+ if (stripped.endsWith("|")) stripped = stripped.slice(0, -1);
240
+ return stripped.split("|").map((c) => c.trim());
241
+ }
242
+ function parseTableSeparator(s) {
243
+ const cells = parseTableRow(s);
244
+ if (!cells || cells.length === 0) return null;
245
+ const aligns = [];
246
+ for (const cell of cells) {
247
+ if (!/^:?-+:?$/.test(cell)) return null;
248
+ const left = cell.startsWith(":");
249
+ const right = cell.endsWith(":");
250
+ if (left && right) aligns.push("center");
251
+ else if (right) aligns.push("right");
252
+ else if (left) aligns.push("left");
253
+ else aligns.push(null);
254
+ }
255
+ return aligns;
256
+ }
257
+ function renderTableRow(cells, aligns, tag) {
258
+ let out = "<tr>";
259
+ for (let i = 0; i < cells.length; i++) {
260
+ const align = aligns[i] ?? null;
261
+ const styleAttr = align ? ` style="text-align:${align}"` : "";
262
+ out += `<${tag}${styleAttr}>${inlineMd(escapeHtml(cells[i]))}</${tag}>`;
263
+ }
264
+ out += "</tr>";
265
+ return out;
266
+ }
267
+ function renderMarkdown(src) {
268
+ if (typeof src !== "string") {
269
+ src = String(src ?? "");
270
+ }
271
+ const lines = src.split("\n");
272
+ let out = "";
273
+ let inCode = false;
274
+ let codeLang = "";
275
+ let codeBuf = [];
276
+ let listType = null;
277
+ let para = [];
278
+ function flushPara() {
279
+ if (para.length === 0) return;
280
+ out += `<p>${inlineMd(para.join(" "))}</p>`;
281
+ para = [];
282
+ }
283
+ function closeList() {
284
+ if (listType) {
285
+ out += `</${listType}>`;
286
+ listType = null;
287
+ }
288
+ }
289
+ let i = 0;
290
+ while (i < lines.length) {
291
+ const raw = lines[i];
292
+ if (inCode) {
293
+ if (/^```/.test(raw)) {
294
+ out += `<pre><code data-lang="${escapeHtml(codeLang)}">${escapeHtml(codeBuf.join("\n"))}</code></pre>`;
295
+ inCode = false;
296
+ codeBuf = [];
297
+ codeLang = "";
298
+ i++;
299
+ continue;
300
+ }
301
+ codeBuf.push(raw);
302
+ i++;
303
+ continue;
304
+ }
305
+ if (/^```/.test(raw)) {
306
+ flushPara();
307
+ closeList();
308
+ inCode = true;
309
+ codeLang = raw.slice(3).trim();
310
+ i++;
311
+ continue;
312
+ }
313
+ if (i + 1 < lines.length && raw.includes("|")) {
314
+ const headerCells = parseTableRow(raw);
315
+ const aligns = parseTableSeparator(lines[i + 1]);
316
+ if (headerCells && aligns && headerCells.length > 0 && // Most agents emit equal-cell-count tables; pad/truncate
317
+ // gracefully if the separator's count differs.
318
+ aligns.length > 0) {
319
+ flushPara();
320
+ closeList();
321
+ const cols = Math.max(headerCells.length, aligns.length);
322
+ const paddedAligns = [];
323
+ for (let c = 0; c < cols; c++) paddedAligns.push(aligns[c] ?? null);
324
+ out += "<table><thead>";
325
+ out += renderTableRow(headerCells, paddedAligns, "th");
326
+ out += "</thead><tbody>";
327
+ let j = i + 2;
328
+ while (j < lines.length) {
329
+ const rowCells = parseTableRow(lines[j]);
330
+ if (!rowCells) break;
331
+ out += renderTableRow(rowCells, paddedAligns, "td");
332
+ j++;
333
+ }
334
+ out += "</tbody></table>";
335
+ i = j;
336
+ continue;
337
+ }
338
+ }
339
+ if (/^\s*$/.test(raw)) {
340
+ flushPara();
341
+ closeList();
342
+ i++;
343
+ continue;
344
+ }
345
+ let m;
346
+ if (m = raw.match(/^(#{1,3})\s+(.+)$/)) {
347
+ flushPara();
348
+ closeList();
349
+ const level = m[1].length;
350
+ out += `<h${level}>${inlineMd(escapeHtml(m[2]))}</h${level}>`;
351
+ i++;
352
+ continue;
353
+ }
354
+ if (m = raw.match(/^\s*[-*]\s+(.*)$/)) {
355
+ flushPara();
356
+ if (listType !== "ul") {
357
+ closeList();
358
+ out += "<ul>";
359
+ listType = "ul";
360
+ }
361
+ out += `<li>${inlineMd(escapeHtml(m[1]))}</li>`;
362
+ i++;
363
+ continue;
364
+ }
365
+ if (m = raw.match(/^\s*\d+\.\s+(.*)$/)) {
366
+ flushPara();
367
+ if (listType !== "ol") {
368
+ closeList();
369
+ out += "<ol>";
370
+ listType = "ol";
371
+ }
372
+ out += `<li>${inlineMd(escapeHtml(m[1]))}</li>`;
373
+ i++;
374
+ continue;
375
+ }
376
+ if (m = raw.match(/^\s*>\s?(.*)$/)) {
377
+ flushPara();
378
+ closeList();
379
+ out += `<blockquote>${inlineMd(escapeHtml(m[1]))}</blockquote>`;
380
+ i++;
381
+ continue;
382
+ }
383
+ closeList();
384
+ para.push(escapeHtml(raw));
385
+ i++;
386
+ }
387
+ if (inCode) {
388
+ out += `<pre><code>${escapeHtml(codeBuf.join("\n"))}</code></pre>`;
389
+ }
390
+ flushPara();
391
+ closeList();
392
+ return out;
393
+ }
394
+ function contentToText(content) {
395
+ if (typeof content === "string") return content;
396
+ if (Array.isArray(content)) {
397
+ return content.map(contentToText).join("");
398
+ }
399
+ if (content && typeof content === "object") {
400
+ const c = content;
401
+ if (typeof c.text === "string") return c.text;
402
+ if (typeof c.content === "string") return c.content;
403
+ }
404
+ return "";
405
+ }
406
+
407
+ // src/ui/acp.ts
408
+ function pushLog(item) {
409
+ if (!state.current) return;
410
+ state.current.log.push(item);
411
+ }
412
+ function markActive() {
413
+ if (!state.current) return;
414
+ state.current.inTurn = true;
415
+ }
416
+ function markIdleAndDrain() {
417
+ if (!state.current) return;
418
+ state.current.inTurn = false;
419
+ const listeners = state.current.idleListeners;
420
+ state.current.idleListeners = [];
421
+ for (const fn of listeners) {
422
+ try {
423
+ fn();
424
+ } catch {
425
+ }
426
+ }
427
+ }
428
+ function pushChunk(role, content) {
429
+ if (!state.current) return;
430
+ const text = contentToText(content);
431
+ if (!text) return;
432
+ let last;
433
+ for (let i = state.current.log.length - 1; i >= 0; i--) {
434
+ const e = state.current.log[i];
435
+ if (e.kind !== "spinner") {
436
+ last = e;
437
+ break;
438
+ }
439
+ }
440
+ if (last && last.kind === "stream" && last.role === role && !last.closed) {
441
+ last.text += text;
442
+ return;
443
+ }
444
+ state.current.log.push({ kind: "stream", role, text });
445
+ }
446
+ function closeOpenStream() {
447
+ if (!state.current) return;
448
+ for (let i = state.current.log.length - 1; i >= 0; i--) {
449
+ const e = state.current.log[i];
450
+ if (e.kind === "stream") {
451
+ e.closed = true;
452
+ return;
453
+ }
454
+ }
455
+ }
456
+ function consumeOwnPromptEcho(content) {
457
+ if (!state.current) return false;
458
+ const text = contentToText(content);
459
+ if (!text) return false;
460
+ const list = state.current.recentOwnPrompts;
461
+ for (let i = 0; i < list.length; i++) {
462
+ if (list[i].text === text) {
463
+ list.splice(i, 1);
464
+ return true;
465
+ }
466
+ }
467
+ return false;
468
+ }
469
+ function extractToolContent(content) {
470
+ if (!content) return "";
471
+ if (typeof content === "string") return content;
472
+ if (Array.isArray(content)) return content.map(extractToolContent).join("");
473
+ if (typeof content === "object") {
474
+ const c = content;
475
+ if (c.text) return String(c.text);
476
+ if (c.content) return extractToolContent(c.content);
477
+ if (c.diff) return String(c.diff);
478
+ try {
479
+ return JSON.stringify(content, null, 2);
480
+ } catch {
481
+ return String(content);
482
+ }
483
+ }
484
+ return "";
485
+ }
486
+ function ensureSpinner() {
487
+ if (!state.current) return;
488
+ const c = state.current;
489
+ if (c.spinner) {
490
+ if (!c.log.some((e) => e.kind === "spinner")) {
491
+ c.log.push({ kind: "spinner", spinner: c.spinner });
492
+ }
493
+ return;
494
+ }
495
+ c.spinner = { toolCallIds: [], expanded: false };
496
+ c.log.push({ kind: "spinner", spinner: c.spinner });
497
+ }
498
+ function onToolCall(update) {
499
+ if (!state.current) return;
500
+ closeOpenStream();
501
+ const tc = {
502
+ toolCallId: String(update.toolCallId),
503
+ title: String(update.title ?? update.kind ?? "tool"),
504
+ kind: String(update.kind ?? ""),
505
+ status: String(update.status ?? "in_progress"),
506
+ content: extractToolContent(update.content)
507
+ };
508
+ state.current.toolCalls.set(tc.toolCallId, tc);
509
+ ensureSpinner();
510
+ state.current.spinner.toolCallIds.push(tc.toolCallId);
511
+ maybeResolvePermissionByToolCall(tc.toolCallId, tc.status);
512
+ }
513
+ function onToolCallUpdate(update) {
514
+ if (!state.current) return;
515
+ const existing = state.current.toolCalls.get(String(update.toolCallId));
516
+ if (!existing) return;
517
+ if (typeof update.status === "string") {
518
+ existing.status = update.status;
519
+ maybeResolvePermissionByToolCall(existing.toolCallId, update.status);
520
+ }
521
+ if (typeof update.title === "string") existing.title = update.title;
522
+ if (update.content !== void 0) {
523
+ existing.content = (existing.content || "") + extractToolContent(update.content);
524
+ }
525
+ }
526
+ function finalizeTurn() {
527
+ if (!state.current) return;
528
+ state.current.spinner = null;
529
+ state.current.log = state.current.log.filter((e) => e.kind !== "spinner");
530
+ closeOpenStream();
531
+ state.current.currentPlanEntry = null;
532
+ markIdleAndDrain();
533
+ }
534
+ function onPromptReceived(update) {
535
+ if (!state.current) return;
536
+ const blocks = Array.isArray(update.prompt) ? update.prompt : [];
537
+ const text = blocks.map((b) => contentToText(b)).join("");
538
+ if (!text) return;
539
+ if (consumeOwnPromptEcho({ text })) {
540
+ return;
541
+ }
542
+ closeOpenStream();
543
+ state.current.log.push({
544
+ kind: "stream",
545
+ role: "user",
546
+ text,
547
+ closed: true
548
+ });
549
+ }
550
+ function onPlanUpdate(update) {
551
+ if (!state.current) return;
552
+ const entries = update.entries ?? update.plan ?? null;
553
+ state.current.plan = entries;
554
+ if (state.current.currentPlanEntry) {
555
+ state.current.currentPlanEntry.entries = entries;
556
+ return;
557
+ }
558
+ const item = { kind: "plan", entries };
559
+ state.current.log.push(item);
560
+ state.current.currentPlanEntry = item;
561
+ }
562
+ function onPermissionResolved(params) {
563
+ if (!state.current) return;
564
+ const requestId = params?.requestId;
565
+ if (requestId === void 0) return;
566
+ resolvePermissionByRequestId(String(requestId));
567
+ }
568
+ function resolvePermissionByRequestId(idKey) {
569
+ if (!state.current) return;
570
+ state.current.pendingPermissions.delete(idKey);
571
+ state.current.log = state.current.log.filter(
572
+ (e) => !(e.kind === "perm" && String(e.requestId) === idKey)
573
+ );
574
+ }
575
+ function maybeResolvePermissionByToolCall(toolCallId, status) {
576
+ if (!state.current || !toolCallId || !status || status === "pending") {
577
+ return;
578
+ }
579
+ for (const [idKey, entry] of state.current.pendingPermissions) {
580
+ const entryToolCallId = entry.toolCall?.toolCallId;
581
+ if (entryToolCallId === toolCallId) {
582
+ resolvePermissionByRequestId(idKey);
583
+ return;
584
+ }
585
+ }
586
+ }
587
+ function handleNotification(frame) {
588
+ if (frame.method === "session/permission_resolved") {
589
+ onPermissionResolved(frame.params);
590
+ return;
591
+ }
592
+ if (frame.method !== "session/update") return;
593
+ const update = frame.params?.update ?? null;
594
+ if (!update || typeof update !== "object") return;
595
+ const kind = String(update.sessionUpdate ?? "");
596
+ switch (kind) {
597
+ case "prompt_received":
598
+ case "user_message_chunk":
599
+ case "agent_message_chunk":
600
+ case "agent_thought_chunk":
601
+ case "tool_call":
602
+ case "tool_call_update":
603
+ case "plan":
604
+ markActive();
605
+ break;
606
+ default:
607
+ break;
608
+ }
609
+ switch (kind) {
610
+ case "user_message_chunk": {
611
+ const meta = update._meta ?? {};
612
+ const hydraMeta = meta["hydra-acp"] ?? {};
613
+ if (hydraMeta.compatFor === "prompt_received") {
614
+ break;
615
+ }
616
+ if (!consumeOwnPromptEcho(update.content)) {
617
+ pushChunk("user", update.content);
618
+ }
619
+ break;
620
+ }
621
+ case "agent_message_chunk":
622
+ pushChunk("agent", update.content);
623
+ break;
624
+ case "agent_thought_chunk":
625
+ pushChunk("thought", update.content);
626
+ break;
627
+ case "tool_call":
628
+ onToolCall(update);
629
+ break;
630
+ case "tool_call_update":
631
+ onToolCallUpdate(update);
632
+ break;
633
+ case "current_mode_update":
634
+ if (state.current) {
635
+ state.current.mode = update.modeId ?? update.currentModeId ?? null;
636
+ if (Array.isArray(update.availableModes)) {
637
+ state.current.modes = update.availableModes;
638
+ }
639
+ }
640
+ break;
641
+ case "current_model_update":
642
+ if (state.current) {
643
+ state.current.model = update.modelId ?? update.currentModelId ?? null;
644
+ if (Array.isArray(update.availableModels)) {
645
+ state.current.models = update.availableModels;
646
+ }
647
+ }
648
+ break;
649
+ case "usage_update":
650
+ if (state.current) {
651
+ const used = update.used ?? update.contextUsed;
652
+ const size = update.size ?? update.contextSize;
653
+ if (typeof used === "number") state.current.contextUsed = used;
654
+ if (typeof size === "number") state.current.contextSize = size;
655
+ if (update.cost) state.current.cost = update.cost;
656
+ }
657
+ break;
658
+ case "plan":
659
+ onPlanUpdate(update);
660
+ break;
661
+ case "prompt_received":
662
+ onPromptReceived(update);
663
+ break;
664
+ case "stop":
665
+ case "turn_complete":
666
+ finalizeTurn();
667
+ break;
668
+ case "session_info_update":
669
+ if (state.current && typeof update.title === "string") {
670
+ state.current.title = update.title;
671
+ }
672
+ break;
673
+ default:
674
+ break;
675
+ }
676
+ if (state.current?.inTurn) {
677
+ ensureSpinner();
678
+ }
679
+ }
680
+ function handleAgentRequest(req) {
681
+ if (!state.current) return;
682
+ if (req.method === "session/request_permission") {
683
+ const params = req.params ?? {};
684
+ const idKey = String(req.id);
685
+ state.current.pendingPermissions.set(idKey, {
686
+ requestId: req.id,
687
+ toolCall: params.toolCall ?? {},
688
+ options: params.options ?? []
689
+ });
690
+ pushLog({ kind: "perm", requestId: idKey });
691
+ return;
692
+ }
693
+ state.current.ws?.send(
694
+ JSON.stringify({
695
+ jsonrpc: "2.0",
696
+ id: req.id,
697
+ error: { code: -32601, message: `method not handled: ${String(req.method)}` }
698
+ })
699
+ );
700
+ }
701
+
702
+ // src/ui/bridge.ts
703
+ function send(method, params) {
704
+ const c = state.current;
705
+ if (!c || !c.ws || c.ws.readyState !== WebSocket.OPEN) {
706
+ return void 0;
707
+ }
708
+ const id = c.nextId = c.nextId ?? 1;
709
+ c.nextId = id + 1;
710
+ c.ws.send(JSON.stringify({ jsonrpc: "2.0", id, method, params }));
711
+ return id;
712
+ }
713
+ function notify(method, params) {
714
+ const c = state.current;
715
+ if (!c || !c.ws || c.ws.readyState !== WebSocket.OPEN) {
716
+ return;
717
+ }
718
+ c.ws.send(JSON.stringify({ jsonrpc: "2.0", method, params }));
719
+ }
720
+ function reply(id, result) {
721
+ const c = state.current;
722
+ if (!c || !c.ws || c.ws.readyState !== WebSocket.OPEN) {
723
+ return;
724
+ }
725
+ c.ws.send(JSON.stringify({ jsonrpc: "2.0", id, result }));
726
+ }
727
+ function handleFrame(frame) {
728
+ if (!state.current) return;
729
+ if (frame.method === "bridge/ready") {
730
+ state.current.ready = true;
731
+ state.banner = null;
732
+ const listeners = state.current.readyListeners;
733
+ state.current.readyListeners = [];
734
+ for (const fn of listeners) {
735
+ try {
736
+ fn();
737
+ } catch {
738
+ }
739
+ }
740
+ render();
741
+ return;
742
+ }
743
+ if (frame.method === "bridge/error") {
744
+ pushLog({
745
+ kind: "error",
746
+ text: `Bridge error: ${frame.params?.["message"] ?? "?"}`
747
+ });
748
+ render();
749
+ return;
750
+ }
751
+ if (frame.method && "id" in frame) {
752
+ handleAgentRequest(frame);
753
+ render();
754
+ return;
755
+ }
756
+ if (frame.method) {
757
+ handleNotification(frame);
758
+ render();
759
+ return;
760
+ }
761
+ if ("id" in frame && frame.id !== void 0 && state.current.ownPromptIds.has(String(frame.id))) {
762
+ state.current.ownPromptIds.delete(String(frame.id));
763
+ finalizeTurn();
764
+ render();
765
+ }
766
+ }
767
+ function respondPermission(idKey, optionId) {
768
+ if (!state.current) return;
769
+ const entry = state.current.pendingPermissions.get(idKey);
770
+ if (!entry) return;
771
+ state.current.pendingPermissions.delete(idKey);
772
+ state.current.log = state.current.log.filter(
773
+ (e) => !(e.kind === "perm" && String(e.requestId) === String(idKey))
774
+ );
775
+ reply(entry.requestId, {
776
+ outcome: optionId === "__cancel__" ? { outcome: "cancelled" } : { outcome: "selected", optionId }
777
+ });
778
+ render();
779
+ }
780
+
781
+ // src/ui/queue.ts
782
+ function sendPrompt() {
783
+ const c = state.current;
784
+ if (!c) return;
785
+ const text = c.composerValue.trim();
786
+ if (!text) return;
787
+ if (!c.ws || c.ws.readyState !== WebSocket.OPEN) {
788
+ c.log.push({
789
+ kind: "error",
790
+ text: "Not connected to session \u2014 prompt not sent."
791
+ });
792
+ c.composerValue = "";
793
+ render();
794
+ return;
795
+ }
796
+ const ahead = c.promptQueue.length;
797
+ const aheadActive = c.inTurn ? 1 : 0;
798
+ const totalAhead = ahead + aheadActive;
799
+ const entry = {
800
+ id: "p_" + Math.random().toString(36).slice(2, 10),
801
+ text,
802
+ status: totalAhead > 0 ? "queued" : "pending",
803
+ aheadAtEnqueue: totalAhead,
804
+ cancelled: false,
805
+ started: false,
806
+ waitResolver: null
807
+ };
808
+ c.promptQueue.push(entry);
809
+ c.log.push({
810
+ kind: "stream",
811
+ role: "user",
812
+ text,
813
+ closed: true,
814
+ queueEntry: entry
815
+ });
816
+ c.recentOwnPrompts.push({ text, at: Date.now() });
817
+ const cutoff = Date.now() - 6e4;
818
+ c.recentOwnPrompts = c.recentOwnPrompts.filter((p) => p.at >= cutoff).slice(-16);
819
+ scheduleSendPrompt(entry);
820
+ c.composerValue = "";
821
+ render();
822
+ }
823
+ function scheduleSendPrompt(entry) {
824
+ const c = state.current;
825
+ const previous = c.promptChain ?? Promise.resolve();
826
+ const next = previous.catch(() => void 0).then(async () => {
827
+ if (entry.cancelled) {
828
+ return;
829
+ }
830
+ try {
831
+ await waitForReadyOrCancel(entry);
832
+ if (entry.cancelled) return;
833
+ await waitForIdleOrCancel(entry);
834
+ if (entry.cancelled) return;
835
+ entry.started = true;
836
+ entry.status = "processing";
837
+ c.inTurn = true;
838
+ const idx = c.log.findIndex(
839
+ (e) => e.kind === "stream" && e.role === "user" && e.queueEntry === entry
840
+ );
841
+ if (idx >= 0 && idx < c.log.length - 1) {
842
+ const item = c.log.splice(idx, 1)[0];
843
+ if (item) c.log.push(item);
844
+ }
845
+ ensureSpinner();
846
+ render();
847
+ const promptId = send("session/prompt", {
848
+ sessionId: c.sessionId,
849
+ prompt: [{ type: "text", text: entry.text }]
850
+ });
851
+ if (promptId !== void 0) {
852
+ c.ownPromptIds.add(String(promptId));
853
+ }
854
+ await waitForIdleOrCancel(entry);
855
+ } finally {
856
+ const idx = c.promptQueue.indexOf(entry);
857
+ if (idx >= 0) {
858
+ c.promptQueue.splice(idx, 1);
859
+ }
860
+ if (entry.cancelled && !entry.started) {
861
+ entry.status = "cancelled";
862
+ } else {
863
+ entry.status = "done";
864
+ }
865
+ render();
866
+ }
867
+ });
868
+ c.promptChain = next;
869
+ }
870
+ function waitForIdleOrCancel(entry) {
871
+ return waitOnListOrCancel(entry, state.current.idleListeners, () => state.current.inTurn);
872
+ }
873
+ function waitForReadyOrCancel(entry) {
874
+ return waitOnListOrCancel(entry, state.current.readyListeners, () => !state.current.ready);
875
+ }
876
+ function waitOnListOrCancel(entry, list, shouldWait) {
877
+ if (entry.cancelled) {
878
+ return Promise.resolve();
879
+ }
880
+ if (!shouldWait()) {
881
+ return Promise.resolve();
882
+ }
883
+ return new Promise((resolve) => {
884
+ const listener = () => {
885
+ entry.waitResolver = null;
886
+ resolve();
887
+ };
888
+ list.push(listener);
889
+ entry.waitResolver = () => {
890
+ const idx = list.indexOf(listener);
891
+ if (idx >= 0) list.splice(idx, 1);
892
+ entry.waitResolver = null;
893
+ resolve();
894
+ };
895
+ });
896
+ }
897
+ function cancelQueuedPrompt(entry) {
898
+ entry.cancelled = true;
899
+ if (entry.waitResolver) {
900
+ entry.waitResolver();
901
+ }
902
+ }
903
+ function cancelProcessingPrompt() {
904
+ const c = state.current;
905
+ if (!c || !c.inTurn) return;
906
+ notify("session/cancel", { sessionId: c.sessionId });
907
+ }
908
+ function sendCancel() {
909
+ const c = state.current;
910
+ if (!c) return;
911
+ let cancelledLocal = 0;
912
+ for (const entry of c.promptQueue) {
913
+ if (!entry.started && !entry.cancelled) {
914
+ entry.cancelled = true;
915
+ if (entry.waitResolver) entry.waitResolver();
916
+ cancelledLocal += 1;
917
+ }
918
+ }
919
+ if (c.inTurn) {
920
+ notify("session/cancel", { sessionId: c.sessionId });
921
+ }
922
+ if (cancelledLocal > 0) {
923
+ render();
924
+ }
925
+ }
926
+ function sendSetMode(modeId) {
927
+ if (!state.current) return;
928
+ send("session/set_mode", { sessionId: state.current.sessionId, modeId });
929
+ }
930
+ function sendSetModel(modelId) {
931
+ if (!state.current) return;
932
+ send("session/set_model", { sessionId: state.current.sessionId, modelId });
933
+ }
934
+ function cancelAllQueued(c) {
935
+ for (const entry of c.promptQueue) {
936
+ if (!entry.cancelled) cancelQueuedPrompt(entry);
937
+ }
938
+ }
939
+
940
+ // src/ui/routing.ts
941
+ function buildSessionHash(sessionId, load) {
942
+ const id = encodeURIComponent(sessionId);
943
+ return load ? `#/session/${id}?load=true` : `#/session/${id}`;
944
+ }
945
+ var hashWriting = false;
946
+ function setLocationHash(hash) {
947
+ if (window.location.hash === hash) return;
948
+ hashWriting = true;
949
+ try {
950
+ history.pushState(
951
+ null,
952
+ "",
953
+ hash || window.location.pathname + window.location.search
954
+ );
955
+ } finally {
956
+ queueMicrotask(() => {
957
+ hashWriting = false;
958
+ });
959
+ }
960
+ }
961
+ function applyHashRoute() {
962
+ if (hashWriting) return;
963
+ const hash = window.location.hash;
964
+ const m = hash.match(/^#\/session\/([^?]+)(?:\?(.*))?$/);
965
+ if (m) {
966
+ const sessionId = decodeURIComponent(m[1]);
967
+ const params = new URLSearchParams(m[2] ?? "");
968
+ const load = params.get("load") === "true";
969
+ if (state.view === "chat" && state.current?.sessionId === sessionId) {
970
+ return;
971
+ }
972
+ openChat(sessionId, load);
973
+ return;
974
+ }
975
+ if (state.view !== "list") {
976
+ closeChat();
977
+ }
978
+ }
979
+ function openChat(sessionId, load) {
980
+ setLocationHash(buildSessionHash(sessionId, load));
981
+ closeChatSocket();
982
+ const session = state.sessions.find(
983
+ (s) => s.sessionId === sessionId
984
+ );
985
+ const initial = {
986
+ sessionId,
987
+ // Empty string is the right sentinel here — renderChat falls back
988
+ // to its own placeholder when this is empty and there's no live
989
+ // session metadata yet. Doing the placeholder here too would mean
990
+ // a brief flash of "untitled · …" before the title-cache landed.
991
+ title: session?.title ?? "",
992
+ cwd: session?.cwd || "",
993
+ agentId: session?.agentId || "",
994
+ ws: null,
995
+ ready: false,
996
+ log: [],
997
+ toolCalls: /* @__PURE__ */ new Map(),
998
+ pendingPermissions: /* @__PURE__ */ new Map(),
999
+ pendingRequestById: /* @__PURE__ */ new Map(),
1000
+ spinner: null,
1001
+ plan: null,
1002
+ mode: null,
1003
+ model: null,
1004
+ modes: [],
1005
+ models: [],
1006
+ contextUsed: null,
1007
+ contextSize: null,
1008
+ cost: null,
1009
+ fileOverlay: null,
1010
+ composerValue: "",
1011
+ busy: false,
1012
+ recentOwnPrompts: [],
1013
+ _lastMetaFp: session ? `${session.title}|${session.cwd}|${session.agentId}` : "",
1014
+ promptQueue: [],
1015
+ promptChain: null,
1016
+ ownPromptIds: /* @__PURE__ */ new Set(),
1017
+ inTurn: false,
1018
+ idleListeners: [],
1019
+ readyListeners: [],
1020
+ currentPlanEntry: null
1021
+ };
1022
+ state.current = initial;
1023
+ setState({ view: "chat" });
1024
+ const url = new URL("/ws", location.href);
1025
+ url.protocol = location.protocol === "https:" ? "wss:" : "ws:";
1026
+ url.searchParams.set("session", sessionId);
1027
+ if (load) url.searchParams.set("load", "true");
1028
+ const ws = new WebSocket(url.toString());
1029
+ initial.ws = ws;
1030
+ ws.addEventListener("open", () => {
1031
+ });
1032
+ ws.addEventListener("message", (ev) => {
1033
+ let parsed;
1034
+ try {
1035
+ parsed = JSON.parse(String(ev.data));
1036
+ } catch {
1037
+ return;
1038
+ }
1039
+ handleFrame(parsed);
1040
+ });
1041
+ ws.addEventListener("close", () => {
1042
+ if (state.current && state.current.ws === ws) {
1043
+ state.current.ready = false;
1044
+ cancelAllQueued(state.current);
1045
+ setState({
1046
+ banner: { kind: "warn", text: "Disconnected from session." }
1047
+ });
1048
+ }
1049
+ });
1050
+ ws.addEventListener("error", () => {
1051
+ setState({ banner: { kind: "bad", text: "Connection error." } });
1052
+ });
1053
+ }
1054
+ function closeChat() {
1055
+ setLocationHash("");
1056
+ closeChatSocket();
1057
+ setState({ view: "list", current: null });
1058
+ }
1059
+ function closeChatSocket() {
1060
+ if (state.current && state.current.ws) {
1061
+ try {
1062
+ state.current.ws.close();
1063
+ } catch {
1064
+ }
1065
+ state.current.ws = null;
1066
+ }
1067
+ }
1068
+
1069
+ // src/ui/views.ts
1070
+ function fallbackTitle(_sessionId) {
1071
+ return "untitled";
1072
+ }
1073
+ function shortSessionId(sessionId) {
1074
+ const tail = sessionId.replace(/^hydra_session_/, "");
1075
+ return tail.length > 10 ? tail.slice(0, 10) : tail;
1076
+ }
1077
+ function fmtTokens(n) {
1078
+ if (!Number.isFinite(n)) return "?";
1079
+ if (n < 1e3) return String(n);
1080
+ if (n < 1e6) {
1081
+ const v = (n / 1e3).toFixed(n < 1e4 ? 1 : 0);
1082
+ return v.replace(/\.0$/, "") + "k";
1083
+ }
1084
+ return (n / 1e6).toFixed(2).replace(/\.?0+$/, "") + "M";
1085
+ }
1086
+ function fmtCost(cost) {
1087
+ let amount = null;
1088
+ let currency = "USD";
1089
+ if (typeof cost === "number") {
1090
+ amount = cost;
1091
+ } else if (cost && typeof cost === "object") {
1092
+ const c = cost;
1093
+ if (typeof c.amount === "number") amount = c.amount;
1094
+ else if (typeof c.total === "number") amount = c.total;
1095
+ else if (c.total && typeof c.total === "object") {
1096
+ const t = c.total;
1097
+ if (typeof t.amount === "number") amount = t.amount;
1098
+ if (typeof t.currency === "string") currency = t.currency;
1099
+ }
1100
+ if (typeof c.currency === "string") currency = c.currency;
1101
+ }
1102
+ if (amount === null) return null;
1103
+ const decimals = amount < 0.01 ? 4 : amount < 1 ? 3 : 2;
1104
+ if (currency === "USD") return `$${amount.toFixed(decimals)}`;
1105
+ return `${amount.toFixed(decimals)} ${currency}`;
1106
+ }
1107
+ function renderApp(root, s) {
1108
+ if (s.banner) {
1109
+ root.appendChild(
1110
+ el("div", { class: "banner " + s.banner.kind }, s.banner.text)
1111
+ );
1112
+ }
1113
+ if (s.view === "list") {
1114
+ root.appendChild(renderTopbar());
1115
+ root.appendChild(renderList());
1116
+ } else if (s.view === "chat" && s.current) {
1117
+ root.appendChild(renderChat(s.current));
1118
+ if (s.current.fileOverlay) {
1119
+ root.appendChild(renderFileOverlay(s.current));
1120
+ }
1121
+ }
1122
+ if (s.modal) {
1123
+ if (s.modal.kind === "session") {
1124
+ root.appendChild(renderSessionModal(s.modal));
1125
+ } else if (s.modal.kind === "modes" && s.current) {
1126
+ root.appendChild(
1127
+ renderListModal(
1128
+ "Mode",
1129
+ s.current.modes,
1130
+ s.current.mode,
1131
+ (m) => sendSetMode(m.id)
1132
+ )
1133
+ );
1134
+ } else if (s.modal.kind === "models" && s.current) {
1135
+ root.appendChild(
1136
+ renderListModal(
1137
+ "Model",
1138
+ s.current.models,
1139
+ s.current.model,
1140
+ (m) => sendSetModel(m.id)
1141
+ )
1142
+ );
1143
+ }
1144
+ }
1145
+ }
1146
+ function renderTopbar() {
1147
+ return el(
1148
+ "div",
1149
+ { class: "topbar" },
1150
+ el("span", { class: "title" }, "hydra-acp-browser"),
1151
+ el(
1152
+ "span",
1153
+ {
1154
+ class: "pill clickable",
1155
+ onclick: () => setState({ groupBy: state.groupBy === "project" ? "recent" : "project" })
1156
+ },
1157
+ `group: ${state.groupBy}`
1158
+ ),
1159
+ el(
1160
+ "span",
1161
+ {
1162
+ class: "pill clickable",
1163
+ title: "Click to toggle showing disk-only sessions",
1164
+ onclick: () => {
1165
+ setState({ showCold: !state.showCold });
1166
+ }
1167
+ },
1168
+ state.showCold ? "all" : "live"
1169
+ ),
1170
+ el("span", { class: "spacer" }),
1171
+ el("button", { onclick: openSessionModal, class: "primary" }, "+ Session")
1172
+ );
1173
+ }
1174
+ function renderList() {
1175
+ const visible = state.showCold ? state.sessions : state.sessions.filter((s) => s.status !== "cold");
1176
+ const hiddenCold = state.sessions.length - visible.length;
1177
+ const groups = groupSessions(visible, state.groupBy);
1178
+ const list = el("div", { class: "list" });
1179
+ if (visible.length === 0) {
1180
+ const msg = state.sessions.length === 0 ? "No sessions. Use + to create one, or run `hydra-acp launch <agent>` from your editor." : `No live sessions. ${hiddenCold} cold session${hiddenCold === 1 ? "" : "s"} hidden \u2014 click "live" to switch to "all".`;
1181
+ list.appendChild(el("div", { class: "empty" }, msg));
1182
+ }
1183
+ for (const g of groups) {
1184
+ const groupNode = el("div", { class: "group" });
1185
+ if (g.label) {
1186
+ groupNode.appendChild(el("h2", null, g.label));
1187
+ }
1188
+ for (const s of g.sessions) {
1189
+ groupNode.appendChild(renderSessionCard(s));
1190
+ }
1191
+ list.appendChild(groupNode);
1192
+ }
1193
+ return list;
1194
+ }
1195
+ function compareSessions(a, b) {
1196
+ const liveDiff = (b.status === "live" ? 1 : 0) - (a.status === "live" ? 1 : 0);
1197
+ if (liveDiff !== 0) {
1198
+ return liveDiff;
1199
+ }
1200
+ return String(b.updatedAt || "").localeCompare(String(a.updatedAt || ""));
1201
+ }
1202
+ function groupSessions(sessions, mode) {
1203
+ if (mode === "recent") {
1204
+ const sorted = sessions.slice().sort(compareSessions);
1205
+ return [{ label: null, sessions: sorted }];
1206
+ }
1207
+ const map = /* @__PURE__ */ new Map();
1208
+ for (const s of sessions) {
1209
+ const key = s.cwd || "(unknown)";
1210
+ if (!map.has(key)) map.set(key, []);
1211
+ map.get(key).push(s);
1212
+ }
1213
+ const out = [];
1214
+ for (const [cwd, items] of [...map.entries()].sort((a, b) => a[0].localeCompare(b[0]))) {
1215
+ const last = cwd.split("/").filter(Boolean).pop() || cwd;
1216
+ items.sort(compareSessions);
1217
+ out.push({ label: `${last} \u2014 ${cwd}`, sessions: items });
1218
+ }
1219
+ return out;
1220
+ }
1221
+ function renderSessionCard(s) {
1222
+ const title = s.title || fallbackTitle(s.sessionId);
1223
+ const subtitle = `${shortSessionId(s.sessionId)} \xB7 ${s.agentId || "?"} \xB7 ${s.cwd || "?"}`;
1224
+ return el(
1225
+ "div",
1226
+ {
1227
+ class: "card",
1228
+ onclick: (e) => {
1229
+ const target = e.target;
1230
+ if (target.closest("button")) return;
1231
+ openChat(s.sessionId, s.status === "cold");
1232
+ }
1233
+ },
1234
+ el(
1235
+ "div",
1236
+ { class: "meta" },
1237
+ el("div", { class: "row1" }, title),
1238
+ el("div", { class: "row2" }, subtitle)
1239
+ ),
1240
+ el(
1241
+ "div",
1242
+ { class: "badges" },
1243
+ el(
1244
+ "span",
1245
+ {
1246
+ class: `badge ${s.status === "cold" ? "cold" : "live"}`,
1247
+ title: s.status === "cold" ? "Disk-only \u2014 opening will resurrect the session" : "Live in-memory session"
1248
+ },
1249
+ s.status === "cold" ? "cold" : "live"
1250
+ ),
1251
+ el("span", { class: "badge" }, `${s.attachedClients ?? 0} attached`)
1252
+ ),
1253
+ el(
1254
+ "div",
1255
+ { class: "actions" },
1256
+ el(
1257
+ "button",
1258
+ {
1259
+ class: "danger",
1260
+ onclick: (e) => {
1261
+ e.stopPropagation();
1262
+ void killSession(s);
1263
+ }
1264
+ },
1265
+ "\xD7"
1266
+ )
1267
+ )
1268
+ );
1269
+ }
1270
+ async function killSession(s) {
1271
+ if (!confirm(
1272
+ `Kill session ${s.title ? `"${s.title}" (${shortSessionId(s.sessionId)})` : shortSessionId(s.sessionId)}?`
1273
+ ))
1274
+ return;
1275
+ try {
1276
+ await api("/api/kill", {
1277
+ method: "POST",
1278
+ body: JSON.stringify({ sessionId: s.sessionId })
1279
+ });
1280
+ void pollSessions();
1281
+ } catch (err) {
1282
+ setState({
1283
+ banner: { kind: "bad", text: "kill failed: " + err.message }
1284
+ });
1285
+ }
1286
+ }
1287
+ function openSessionModal() {
1288
+ setState({
1289
+ modal: {
1290
+ kind: "session",
1291
+ cwd: "",
1292
+ agentId: state.agents[0]?.id ?? "",
1293
+ name: "",
1294
+ prompt: "",
1295
+ err: null,
1296
+ busy: false
1297
+ }
1298
+ });
1299
+ }
1300
+ function renderSessionModal(m) {
1301
+ return el(
1302
+ "div",
1303
+ {
1304
+ class: "modal-bg",
1305
+ onclick: (e) => {
1306
+ if (e.target.classList.contains("modal-bg")) closeModal();
1307
+ }
1308
+ },
1309
+ el(
1310
+ "div",
1311
+ { class: "modal" },
1312
+ el("h2", null, "New session"),
1313
+ el(
1314
+ "div",
1315
+ { class: "field" },
1316
+ el("label", { for: "f-cwd" }, "cwd"),
1317
+ el("input", {
1318
+ id: "f-cwd",
1319
+ value: m.cwd,
1320
+ placeholder: "/home/you/dev/project",
1321
+ oninput: (e) => {
1322
+ m.cwd = e.target.value;
1323
+ }
1324
+ })
1325
+ ),
1326
+ el(
1327
+ "div",
1328
+ { class: "field" },
1329
+ el("label", { for: "f-agent" }, "agent"),
1330
+ renderAgentSelect(m)
1331
+ ),
1332
+ el(
1333
+ "div",
1334
+ { class: "field" },
1335
+ el("label", { for: "f-name" }, "name (optional)"),
1336
+ el("input", {
1337
+ id: "f-name",
1338
+ value: m.name,
1339
+ placeholder: "feature-x",
1340
+ oninput: (e) => {
1341
+ m.name = e.target.value;
1342
+ }
1343
+ })
1344
+ ),
1345
+ el(
1346
+ "div",
1347
+ { class: "field" },
1348
+ el("label", { for: "f-prompt" }, "first prompt (optional)"),
1349
+ el(
1350
+ "textarea",
1351
+ {
1352
+ id: "f-prompt",
1353
+ rows: "4",
1354
+ placeholder: "What should the agent do first?",
1355
+ oninput: (e) => {
1356
+ m.prompt = e.target.value;
1357
+ }
1358
+ },
1359
+ m.prompt
1360
+ )
1361
+ ),
1362
+ m.err ? el("div", { class: "err" }, m.err) : null,
1363
+ el(
1364
+ "div",
1365
+ { class: "actions" },
1366
+ el("button", { onclick: closeModal, disabled: m.busy }, "Cancel"),
1367
+ el(
1368
+ "button",
1369
+ { class: "primary", onclick: createSession, disabled: m.busy },
1370
+ m.busy ? "Creating\u2026" : "Create"
1371
+ )
1372
+ )
1373
+ )
1374
+ );
1375
+ }
1376
+ function renderAgentSelect(m) {
1377
+ const sel = el("select", {
1378
+ id: "f-agent",
1379
+ onchange: (e) => {
1380
+ m.agentId = e.target.value;
1381
+ }
1382
+ });
1383
+ if (state.agents.length === 0) {
1384
+ sel.appendChild(el("option", { value: "" }, "(default)"));
1385
+ }
1386
+ for (const a of state.agents) {
1387
+ const opt = el("option", { value: a.id }, a.id);
1388
+ if (a.id === m.agentId) opt.setAttribute("selected", "");
1389
+ sel.appendChild(opt);
1390
+ }
1391
+ return sel;
1392
+ }
1393
+ async function createSession() {
1394
+ const m = state.modal;
1395
+ if (!m || m.kind !== "session") return;
1396
+ if (!m.cwd) {
1397
+ m.err = "cwd is required";
1398
+ render();
1399
+ return;
1400
+ }
1401
+ m.busy = true;
1402
+ m.err = null;
1403
+ render();
1404
+ try {
1405
+ const body = { cwd: m.cwd };
1406
+ if (m.agentId) body.agentId = m.agentId;
1407
+ if (m.name) body.name = m.name;
1408
+ if (m.prompt) body.prompt = m.prompt;
1409
+ const data = await api("/api/sessions", {
1410
+ method: "POST",
1411
+ body: JSON.stringify(body)
1412
+ });
1413
+ closeModal();
1414
+ void pollSessions();
1415
+ if (data && data.sessionId) {
1416
+ openChat(data.sessionId, false);
1417
+ }
1418
+ } catch (err) {
1419
+ m.err = err.message;
1420
+ m.busy = false;
1421
+ render();
1422
+ }
1423
+ }
1424
+ function closeModal() {
1425
+ setState({ modal: null });
1426
+ }
1427
+ function openModePicker() {
1428
+ if (!state.current?.modes || state.current.modes.length === 0) return;
1429
+ setState({ modal: { kind: "modes" } });
1430
+ }
1431
+ function openModelPicker() {
1432
+ if (!state.current?.models || state.current.models.length === 0) return;
1433
+ setState({ modal: { kind: "models" } });
1434
+ }
1435
+ function renderListModal(title, items, selectedId, onPick) {
1436
+ return el(
1437
+ "div",
1438
+ {
1439
+ class: "modal-bg",
1440
+ onclick: (e) => {
1441
+ if (e.target.classList.contains("modal-bg")) closeModal();
1442
+ }
1443
+ },
1444
+ el(
1445
+ "div",
1446
+ { class: "modal" },
1447
+ el("h2", null, title),
1448
+ ...items.map(
1449
+ (it) => el(
1450
+ "div",
1451
+ {
1452
+ class: "card",
1453
+ onclick: () => {
1454
+ onPick(it);
1455
+ closeModal();
1456
+ }
1457
+ },
1458
+ el(
1459
+ "div",
1460
+ { class: "meta" },
1461
+ el("div", { class: "row1" }, it.name || it.id),
1462
+ el("div", { class: "row2" }, it.id)
1463
+ ),
1464
+ it.id === selectedId ? el("span", { class: "badge live" }, "current") : null
1465
+ )
1466
+ )
1467
+ )
1468
+ );
1469
+ }
1470
+ function renderChat(c) {
1471
+ const live = state.sessions.find((s) => s.sessionId === c.sessionId);
1472
+ const title = live?.title || c.title || fallbackTitle(c.sessionId);
1473
+ const cwd = live?.cwd || c.cwd;
1474
+ const agentId = live?.agentId || c.agentId;
1475
+ const header = el(
1476
+ "div",
1477
+ { class: "chat-header" },
1478
+ el("button", { class: "ghost", onclick: closeChat }, "\u2190"),
1479
+ el(
1480
+ "div",
1481
+ { class: "info" },
1482
+ el("div", { class: "row1" }, title),
1483
+ el(
1484
+ "div",
1485
+ { class: "row2" },
1486
+ `${shortSessionId(c.sessionId)} \xB7 ${agentId || "?"} \xB7 ${cwd || "?"}`
1487
+ )
1488
+ ),
1489
+ !c.ready ? el("span", { class: "pill" }, "connecting\u2026") : c.inTurn ? el(
1490
+ "span",
1491
+ { class: "pill working", title: "Agent is working" },
1492
+ el("span", { class: "dot" }, "\u25CF"),
1493
+ "working"
1494
+ ) : el(
1495
+ "span",
1496
+ { class: "pill ready", title: "Ready for a prompt" },
1497
+ el("span", { class: "dot" }, "\u25CF"),
1498
+ "ready"
1499
+ ),
1500
+ c.mode ? el("span", { class: "pill clickable", onclick: openModePicker }, "mode: " + c.mode) : null,
1501
+ c.model ? el("span", { class: "pill clickable", onclick: openModelPicker }, "model: " + c.model) : null,
1502
+ c.contextUsed != null && c.contextSize ? el(
1503
+ "span",
1504
+ {
1505
+ class: "pill",
1506
+ title: `${c.contextUsed.toLocaleString()} / ${c.contextSize.toLocaleString()} context tokens`
1507
+ },
1508
+ `${fmtTokens(c.contextUsed)}/${fmtTokens(c.contextSize)} tokens`
1509
+ ) : null,
1510
+ fmtCost(c.cost) ? el(
1511
+ "span",
1512
+ { class: "pill", title: "Session cost so far" },
1513
+ fmtCost(c.cost)
1514
+ ) : null,
1515
+ el("button", { onclick: openFiles }, "Files")
1516
+ );
1517
+ const body = el("div", { class: "chat-body" });
1518
+ for (const item of c.log) {
1519
+ body.appendChild(renderLogItem(item));
1520
+ }
1521
+ const composerOnKey = (e) => {
1522
+ if (e.key !== "Enter" || e.isComposing) return;
1523
+ if (e.shiftKey) return;
1524
+ if (e.altKey || e.ctrlKey || e.metaKey) {
1525
+ e.preventDefault();
1526
+ insertAtCaret(e.target, "\n");
1527
+ return;
1528
+ }
1529
+ e.preventDefault();
1530
+ sendPrompt();
1531
+ };
1532
+ const autosize = (t) => {
1533
+ t.style.height = "auto";
1534
+ t.style.height = t.scrollHeight + "px";
1535
+ };
1536
+ const textarea = el(
1537
+ "textarea",
1538
+ {
1539
+ "data-focus-key": "composer",
1540
+ placeholder: c.ready ? "Message\u2026" : "Connecting\u2026",
1541
+ rows: "1",
1542
+ onkeydown: composerOnKey,
1543
+ oninput: (e) => {
1544
+ const t = e.target;
1545
+ c.composerValue = t.value;
1546
+ autosize(t);
1547
+ }
1548
+ },
1549
+ c.composerValue
1550
+ );
1551
+ if (c.composerValue && c.composerValue.length > 0) {
1552
+ queueMicrotask(() => autosize(textarea));
1553
+ }
1554
+ const composer = el(
1555
+ "div",
1556
+ { class: "composer" },
1557
+ textarea,
1558
+ el("button", { class: "stop", onclick: sendCancel, title: "Cancel current turn" }, "Stop"),
1559
+ el("button", { class: "primary", onclick: sendPrompt }, "Send")
1560
+ );
1561
+ return el("div", { class: "chat" }, header, body, composer);
1562
+ }
1563
+ function renderLogItem(item) {
1564
+ if (item.kind === "stream") {
1565
+ const cls = item.role === "user" ? "msg user" : item.role === "thought" ? "msg system" : "msg agent";
1566
+ const node = el("div", { class: cls });
1567
+ const qe = item.queueEntry;
1568
+ if (qe && (qe.status === "queued" || qe.status === "cancelled")) {
1569
+ node.appendChild(renderQueueChip(qe));
1570
+ }
1571
+ const body = el("div", { class: "body" });
1572
+ body.innerHTML = renderMarkdown(item.text);
1573
+ if (qe && qe.status === "cancelled") {
1574
+ body.style.textDecoration = "line-through";
1575
+ body.style.opacity = "0.6";
1576
+ }
1577
+ node.appendChild(body);
1578
+ return node;
1579
+ }
1580
+ if (item.kind === "system") {
1581
+ return el("div", { class: "msg system" }, item.text);
1582
+ }
1583
+ if (item.kind === "error") {
1584
+ return el("div", { class: "msg error" }, item.text);
1585
+ }
1586
+ if (item.kind === "spinner") {
1587
+ return renderSpinner(item.spinner);
1588
+ }
1589
+ if (item.kind === "perm") {
1590
+ if (!state.current) return document.createTextNode("");
1591
+ const entry = state.current.pendingPermissions.get(item.requestId);
1592
+ if (!entry) return document.createTextNode("");
1593
+ return renderPermission(entry);
1594
+ }
1595
+ if (item.kind === "plan") {
1596
+ return renderPlan(item.entries);
1597
+ }
1598
+ return document.createTextNode("");
1599
+ }
1600
+ function renderQueueChip(entry) {
1601
+ if (entry.status === "queued") {
1602
+ const ahead = Math.max(1, entry.aheadAtEnqueue);
1603
+ return el(
1604
+ "div",
1605
+ { class: "queue-chip queue-queued" },
1606
+ el(
1607
+ "span",
1608
+ null,
1609
+ ahead === 1 ? "queued \xB7 waiting on 1 turn" : `queued \xB7 waiting on ${ahead} turns`
1610
+ ),
1611
+ el(
1612
+ "button",
1613
+ {
1614
+ class: "queue-cancel",
1615
+ onclick: () => cancelQueuedPrompt(entry),
1616
+ title: "Cancel before sending"
1617
+ },
1618
+ "\xD7"
1619
+ )
1620
+ );
1621
+ }
1622
+ if (entry.status === "processing") {
1623
+ return el(
1624
+ "div",
1625
+ { class: "queue-chip queue-processing" },
1626
+ el("span", { class: "dot" }),
1627
+ el("span", null, "processing")
1628
+ );
1629
+ }
1630
+ if (entry.status === "cancelled") {
1631
+ return el(
1632
+ "div",
1633
+ { class: "queue-chip queue-cancelled" },
1634
+ el("span", null, "cancelled")
1635
+ );
1636
+ }
1637
+ return document.createTextNode("");
1638
+ }
1639
+ function renderSpinner(spinner) {
1640
+ const cancelBtn = el(
1641
+ "button",
1642
+ {
1643
+ class: "queue-cancel",
1644
+ onclick: (e) => {
1645
+ e.stopPropagation();
1646
+ cancelProcessingPrompt();
1647
+ },
1648
+ title: "Cancel this turn"
1649
+ },
1650
+ "\xD7"
1651
+ );
1652
+ if (!spinner.expanded) {
1653
+ return el(
1654
+ "div",
1655
+ {
1656
+ class: "spinner",
1657
+ onclick: () => {
1658
+ spinner.expanded = true;
1659
+ render();
1660
+ }
1661
+ },
1662
+ el(
1663
+ "div",
1664
+ { class: "head" },
1665
+ el("span", { class: "dot" }),
1666
+ el(
1667
+ "span",
1668
+ null,
1669
+ spinner.toolCallIds.length === 0 ? "thinking\u2026" : `working \u2014 ${spinner.toolCallIds.length} tool call${spinner.toolCallIds.length === 1 ? "" : "s"}`
1670
+ ),
1671
+ cancelBtn
1672
+ )
1673
+ );
1674
+ }
1675
+ const items = spinner.toolCallIds.map((id) => {
1676
+ const tc = state.current?.toolCalls.get(id);
1677
+ if (!tc) return null;
1678
+ const icon = tc.status === "completed" || tc.status === "success" ? "\u2705" : tc.status === "failed" || tc.status === "error" ? "\u274C" : "\u25B6";
1679
+ return el(
1680
+ "li",
1681
+ null,
1682
+ el("span", { class: "icon" }, icon),
1683
+ document.createTextNode(" " + tc.title)
1684
+ );
1685
+ });
1686
+ return el(
1687
+ "div",
1688
+ {
1689
+ class: "spinner expanded",
1690
+ onclick: () => {
1691
+ spinner.expanded = false;
1692
+ render();
1693
+ }
1694
+ },
1695
+ el(
1696
+ "div",
1697
+ { class: "head" },
1698
+ el("span", { class: "dot" }),
1699
+ el("span", null, "working"),
1700
+ cancelBtn
1701
+ ),
1702
+ el("ul", null, items)
1703
+ );
1704
+ }
1705
+ function renderPermission(entry) {
1706
+ const tc = entry.toolCall || {};
1707
+ return el(
1708
+ "div",
1709
+ { class: "perm" },
1710
+ el("div", { class: "title" }, "\u{1F512} Permission requested"),
1711
+ el("div", { class: "desc" }, tc.title || tc.name || "tool call"),
1712
+ el(
1713
+ "div",
1714
+ { class: "opts" },
1715
+ ...entry.options.map(
1716
+ (o) => el(
1717
+ "button",
1718
+ {
1719
+ class: o.kind?.startsWith("allow") ? "primary" : o.kind?.startsWith("reject") ? "danger" : "",
1720
+ onclick: () => respondPermission(String(entry.requestId), o.optionId)
1721
+ },
1722
+ o.name || o.optionId
1723
+ )
1724
+ ),
1725
+ el(
1726
+ "button",
1727
+ { onclick: () => respondPermission(String(entry.requestId), "__cancel__") },
1728
+ "Cancel"
1729
+ )
1730
+ )
1731
+ );
1732
+ }
1733
+ function renderPlan(plan) {
1734
+ if (!Array.isArray(plan)) return document.createTextNode("");
1735
+ return el(
1736
+ "div",
1737
+ { class: "msg agent" },
1738
+ el("div", { class: "body" }, el("strong", null, "Plan")),
1739
+ el(
1740
+ "ul",
1741
+ null,
1742
+ ...plan.map(
1743
+ (p) => el(
1744
+ "li",
1745
+ null,
1746
+ `${p.status === "completed" ? "\u2713" : p.status === "in_progress" ? "\u25B8" : "\xB7"} ${p.content || p.title || ""}`
1747
+ )
1748
+ )
1749
+ )
1750
+ );
1751
+ }
1752
+ function openFiles() {
1753
+ if (!state.current) return;
1754
+ state.current.fileOverlay = { path: "", entries: [], preview: null, err: null };
1755
+ render();
1756
+ void listFiles("");
1757
+ }
1758
+ async function listFiles(p) {
1759
+ if (!state.current) return;
1760
+ try {
1761
+ const data = await api(
1762
+ "/api/files/list",
1763
+ {
1764
+ method: "POST",
1765
+ body: JSON.stringify({ sessionId: state.current.sessionId, path: p })
1766
+ }
1767
+ );
1768
+ state.current.fileOverlay = {
1769
+ path: data.path,
1770
+ entries: data.entries ?? [],
1771
+ preview: null,
1772
+ err: null
1773
+ };
1774
+ render();
1775
+ } catch (err) {
1776
+ state.current.fileOverlay = {
1777
+ path: p,
1778
+ entries: [],
1779
+ preview: null,
1780
+ err: err.message
1781
+ };
1782
+ render();
1783
+ }
1784
+ }
1785
+ async function readFile(p) {
1786
+ if (!state.current) return;
1787
+ try {
1788
+ const data = await api("/api/files/read", {
1789
+ method: "POST",
1790
+ body: JSON.stringify({ sessionId: state.current.sessionId, path: p })
1791
+ });
1792
+ const fo = state.current.fileOverlay;
1793
+ state.current.fileOverlay = {
1794
+ path: fo.path,
1795
+ entries: fo.entries,
1796
+ preview: { path: p, content: data.content },
1797
+ err: null
1798
+ };
1799
+ render();
1800
+ } catch (err) {
1801
+ if (state.current.fileOverlay) {
1802
+ state.current.fileOverlay = {
1803
+ ...state.current.fileOverlay,
1804
+ err: err.message
1805
+ };
1806
+ }
1807
+ render();
1808
+ }
1809
+ }
1810
+ function closeFiles() {
1811
+ if (!state.current) return;
1812
+ state.current.fileOverlay = null;
1813
+ render();
1814
+ }
1815
+ function navigateFile(entry) {
1816
+ if (!state.current?.fileOverlay) return;
1817
+ const fo = state.current.fileOverlay;
1818
+ const childPath = fo.path ? `${fo.path}/${entry.name}` : entry.name;
1819
+ if (entry.kind === "dir") {
1820
+ void listFiles(childPath);
1821
+ } else if (entry.kind === "file") {
1822
+ void readFile(childPath);
1823
+ }
1824
+ }
1825
+ function fileBreadcrumb(path) {
1826
+ const parts = path ? path.split("/").filter(Boolean) : [];
1827
+ const crumbs = [
1828
+ el("span", { class: "crumb", onclick: () => listFiles("") }, ".")
1829
+ ];
1830
+ let acc = "";
1831
+ for (const p of parts) {
1832
+ acc = acc ? `${acc}/${p}` : p;
1833
+ const target = acc;
1834
+ crumbs.push(document.createTextNode(" / "));
1835
+ crumbs.push(el("span", { class: "crumb", onclick: () => listFiles(target) }, p));
1836
+ }
1837
+ return el("div", { class: "crumbs" }, crumbs);
1838
+ }
1839
+ function closeFilePreview() {
1840
+ if (!state.current?.fileOverlay) return;
1841
+ state.current.fileOverlay.preview = null;
1842
+ render();
1843
+ }
1844
+ function renderFileOverlay(c) {
1845
+ const fo = c.fileOverlay;
1846
+ if (!fo) return document.createTextNode("");
1847
+ const body = fo.preview ? el(
1848
+ "div",
1849
+ { class: "preview" },
1850
+ el(
1851
+ "div",
1852
+ { class: "crumbs" },
1853
+ el("span", { class: "crumb", onclick: () => closeFilePreview() }, "\u2190 back to listing"),
1854
+ document.createTextNode(` ${fo.preview.path}`)
1855
+ ),
1856
+ el("pre", { html: escapeHtml(fo.preview.content) })
1857
+ ) : el(
1858
+ "div",
1859
+ { class: "body" },
1860
+ fo.err ? el("div", { class: "msg error" }, fo.err) : null,
1861
+ ...fo.entries.map(
1862
+ (e) => el(
1863
+ "div",
1864
+ { class: "entry", onclick: () => navigateFile(e) },
1865
+ el("span", { class: "icon" }, e.kind === "dir" ? "\u25B8" : "\xB7"),
1866
+ el("span", { class: "name" }, e.name),
1867
+ el("span", { class: "size" }, e.kind === "file" ? `${e.size}b` : "")
1868
+ )
1869
+ )
1870
+ );
1871
+ return el(
1872
+ "div",
1873
+ {
1874
+ class: "modal-bg",
1875
+ onclick: (ev) => {
1876
+ if (ev.target.classList.contains("modal-bg")) closeFiles();
1877
+ }
1878
+ },
1879
+ el(
1880
+ "div",
1881
+ {
1882
+ class: "modal",
1883
+ style: "width:42rem;height:80vh;display:flex;flex-direction:column;padding:0"
1884
+ },
1885
+ el(
1886
+ "div",
1887
+ { class: "topbar", style: "border-bottom:1px solid var(--border)" },
1888
+ el("span", { class: "title" }, "Files"),
1889
+ el("span", { class: "pill" }, c.cwd),
1890
+ el("span", { class: "spacer" }),
1891
+ el("button", { onclick: closeFiles }, "\xD7")
1892
+ ),
1893
+ fileBreadcrumb(fo.path),
1894
+ el(
1895
+ "div",
1896
+ {
1897
+ class: "files",
1898
+ style: "flex:1;overflow:hidden;display:flex;flex-direction:column"
1899
+ },
1900
+ body
1901
+ )
1902
+ )
1903
+ );
1904
+ }
1905
+ function insertAtCaret(textarea, text) {
1906
+ textarea.focus();
1907
+ let inserted = false;
1908
+ if (typeof document.execCommand === "function") {
1909
+ try {
1910
+ inserted = document.execCommand("insertText", false, text);
1911
+ } catch {
1912
+ inserted = false;
1913
+ }
1914
+ }
1915
+ if (!inserted) {
1916
+ const start = textarea.selectionStart ?? textarea.value.length;
1917
+ const end = textarea.selectionEnd ?? textarea.value.length;
1918
+ const before = textarea.value.slice(0, start);
1919
+ const after = textarea.value.slice(end);
1920
+ textarea.value = before + text + after;
1921
+ const caret = start + text.length;
1922
+ textarea.setSelectionRange(caret, caret);
1923
+ textarea.dispatchEvent(new Event("input", { bubbles: true }));
1924
+ }
1925
+ }
1926
+
1927
+ // src/ui/renderer.ts
1928
+ var scheduled = false;
1929
+ var DEBUG_RENDER = (() => {
1930
+ try {
1931
+ return new URLSearchParams(window.location.search).get("debug") === "1";
1932
+ } catch {
1933
+ return false;
1934
+ }
1935
+ })();
1936
+ var renderCount = 0;
1937
+ var lastReasons = [];
1938
+ function render() {
1939
+ if (DEBUG_RENDER) {
1940
+ const stack = new Error().stack ?? "";
1941
+ const lines = stack.split("\n").slice(1, 5);
1942
+ const trace = lines.map((l) => l.trim()).join(" \u2190 ");
1943
+ lastReasons.push(`${(/* @__PURE__ */ new Date()).toISOString().slice(11, 23)} ${trace}`);
1944
+ if (lastReasons.length > 20) lastReasons = lastReasons.slice(-20);
1945
+ }
1946
+ if (scheduled) return;
1947
+ scheduled = true;
1948
+ requestAnimationFrame(() => {
1949
+ scheduled = false;
1950
+ actuallyRender();
1951
+ if (DEBUG_RENDER) {
1952
+ renderCount += 1;
1953
+ updateDebugOverlay();
1954
+ }
1955
+ });
1956
+ }
1957
+ function updateDebugOverlay() {
1958
+ let overlay = document.getElementById("__render_debug__");
1959
+ if (!overlay) {
1960
+ overlay = document.createElement("div");
1961
+ overlay.id = "__render_debug__";
1962
+ overlay.style.cssText = "position:fixed;bottom:0.5rem;right:0.5rem;z-index:9999;background:rgba(0,0,0,0.85);color:#9fd;padding:0.4rem 0.6rem;font:11px/1.3 ui-monospace,monospace;border:1px solid #6ea8fe;border-radius:6px;max-width:30rem;max-height:14rem;overflow:auto;pointer-events:none;white-space:pre-wrap";
1963
+ document.body.appendChild(overlay);
1964
+ }
1965
+ const recent = lastReasons.slice(-5).join("\n");
1966
+ overlay.textContent = `renders: ${renderCount}
1967
+ ${recent}`;
1968
+ }
1969
+ function actuallyRender() {
1970
+ const root = document.getElementById("app");
1971
+ if (!root) return;
1972
+ const active = document.activeElement;
1973
+ let focusKey = null;
1974
+ let selStart = null;
1975
+ let selEnd = null;
1976
+ if (active && active !== document.body && active.dataset && active.dataset.focusKey) {
1977
+ focusKey = active.dataset.focusKey;
1978
+ if ("selectionStart" in active) {
1979
+ try {
1980
+ selStart = active.selectionStart;
1981
+ selEnd = active.selectionEnd;
1982
+ } catch {
1983
+ }
1984
+ }
1985
+ }
1986
+ const oldBody = root.querySelector(".chat-body");
1987
+ let oldScrollTop = null;
1988
+ let oldWasAtBottom = true;
1989
+ if (oldBody) {
1990
+ oldScrollTop = oldBody.scrollTop;
1991
+ oldWasAtBottom = oldBody.scrollHeight - oldBody.scrollTop - oldBody.clientHeight < 50;
1992
+ }
1993
+ const oldList = root.querySelector(".list");
1994
+ const oldListScrollTop = oldList ? oldList.scrollTop : null;
1995
+ root.replaceChildren();
1996
+ renderApp(root, state);
1997
+ const newBody = root.querySelector(".chat-body");
1998
+ if (newBody) {
1999
+ if (oldScrollTop === null || oldWasAtBottom) {
2000
+ newBody.scrollTop = newBody.scrollHeight;
2001
+ } else {
2002
+ newBody.scrollTop = oldScrollTop;
2003
+ }
2004
+ }
2005
+ const newList = root.querySelector(".list");
2006
+ if (newList && oldListScrollTop !== null) {
2007
+ newList.scrollTop = oldListScrollTop;
2008
+ }
2009
+ if (focusKey) {
2010
+ const next = root.querySelector(
2011
+ `[data-focus-key="${CSS.escape(focusKey)}"]`
2012
+ );
2013
+ if (next) {
2014
+ next.focus();
2015
+ const inputLike = next;
2016
+ if (selStart !== null && typeof inputLike.setSelectionRange === "function") {
2017
+ try {
2018
+ inputLike.setSelectionRange(selStart, selEnd);
2019
+ } catch {
2020
+ }
2021
+ }
2022
+ }
2023
+ }
2024
+ }
2025
+
2026
+ // src/ui/state.ts
2027
+ var state = {
2028
+ view: "list",
2029
+ sessions: [],
2030
+ agents: [],
2031
+ groupBy: "project",
2032
+ // Hide cold (disk-only) sessions by default. The "show cold"
2033
+ // toggle in the topbar reveals them; clicking one attaches over
2034
+ // WSS, which causes hydra to resurrect it from disk automatically.
2035
+ showCold: false,
2036
+ banner: null,
2037
+ modal: null,
2038
+ current: null
2039
+ };
2040
+ function setState(patch) {
2041
+ let changed = false;
2042
+ for (const k of Object.keys(patch)) {
2043
+ const next = patch[k];
2044
+ if (!sameValue(state[k], next)) {
2045
+ state[k] = next;
2046
+ changed = true;
2047
+ }
2048
+ }
2049
+ if (changed) {
2050
+ render();
2051
+ }
2052
+ }
2053
+ function sameValue(a, b) {
2054
+ if (a === b) return true;
2055
+ if (a === null || b === null) return false;
2056
+ if (typeof a !== typeof b) return false;
2057
+ if (typeof a !== "object") return false;
2058
+ return JSON.stringify(a) === JSON.stringify(b);
2059
+ }
2060
+
2061
+ // src/ui/api.ts
2062
+ function hasActiveSelection() {
2063
+ const sel = window.getSelection();
2064
+ if (!sel || sel.isCollapsed) {
2065
+ return false;
2066
+ }
2067
+ return sel.toString().length > 0;
2068
+ }
2069
+ async function api(path, opts) {
2070
+ const r = await fetch(path, {
2071
+ credentials: "same-origin",
2072
+ headers: { "Content-Type": "application/json" },
2073
+ ...opts
2074
+ });
2075
+ if (!r.ok) {
2076
+ let msg = `${r.status} ${r.statusText}`;
2077
+ try {
2078
+ const j = await r.json();
2079
+ if (j && j.error) msg = j.error;
2080
+ } catch {
2081
+ }
2082
+ throw new Error(msg);
2083
+ }
2084
+ if (r.status === 204) return null;
2085
+ return await r.json();
2086
+ }
2087
+ var pollTimer = null;
2088
+ async function pollSessions() {
2089
+ try {
2090
+ const data = await api("/api/sessions");
2091
+ const newSessions = data.sessions ?? [];
2092
+ const hadBanner = state.banner !== null;
2093
+ const sessionsChanged = !sameValue(state.sessions, newSessions);
2094
+ state.sessions = newSessions;
2095
+ state.banner = null;
2096
+ if (!sessionsChanged && !hadBanner) {
2097
+ return;
2098
+ }
2099
+ if (!hadBanner && hasActiveSelection()) {
2100
+ return;
2101
+ }
2102
+ if (state.view === "list" || hadBanner) {
2103
+ render();
2104
+ } else if (state.view === "chat" && state.current) {
2105
+ const live = state.sessions.find(
2106
+ (s) => s.sessionId === state.current.sessionId
2107
+ );
2108
+ const fp = live ? `${live.title}|${live.cwd}|${live.agentId}` : "";
2109
+ if (fp !== state.current._lastMetaFp) {
2110
+ state.current._lastMetaFp = fp;
2111
+ render();
2112
+ }
2113
+ }
2114
+ } catch (err) {
2115
+ setState({
2116
+ banner: { kind: "bad", text: "session list failed: " + err.message }
2117
+ });
2118
+ }
2119
+ }
2120
+ function startPolling() {
2121
+ if (pollTimer) clearInterval(pollTimer);
2122
+ void pollSessions();
2123
+ pollTimer = setInterval(() => {
2124
+ void pollSessions();
2125
+ }, 2e3);
2126
+ }
2127
+ async function loadAgents() {
2128
+ try {
2129
+ const data = await api("/api/agents");
2130
+ setState({ agents: data.agents ?? [] });
2131
+ } catch (err) {
2132
+ setState({
2133
+ banner: { kind: "warn", text: "agents unavailable: " + err.message }
2134
+ });
2135
+ }
2136
+ }
2137
+
2138
+ // src/ui/main.ts
2139
+ window.addEventListener("DOMContentLoaded", () => {
2140
+ startPolling();
2141
+ void loadAgents();
2142
+ applyHashRoute();
2143
+ render();
2144
+ });
2145
+ window.addEventListener("hashchange", () => {
2146
+ applyHashRoute();
2147
+ });
2148
+ })();
2149
+ </script>
2150
+ </body>
2151
+ </html>