@hncode/hncode 0.3.6 → 0.4.1

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 (148) hide show
  1. package/package.json +5 -3
  2. package/src/agent-task.js +154 -84
  3. package/src/agent.js +533 -145
  4. package/src/clipboard.js +5 -2
  5. package/src/colors.js +242 -238
  6. package/src/config.js +217 -17
  7. package/src/file-history.js +177 -0
  8. package/src/hooks.js +11 -1
  9. package/src/i18n.js +389 -0
  10. package/src/index.js +46 -3
  11. package/src/llm.js +139 -16
  12. package/src/permissions.js +120 -0
  13. package/src/session-hub.js +407 -0
  14. package/src/session.js +31 -0
  15. package/src/term.js +19 -3
  16. package/src/tool-result.js +236 -0
  17. package/src/tools/agent.js +11 -0
  18. package/src/tools/bash.js +84 -17
  19. package/src/tools/edit.js +192 -32
  20. package/src/tools/file-lines.js +5 -0
  21. package/src/tools/index.js +4 -0
  22. package/src/tools/memory.js +88 -0
  23. package/src/tools/read-media-file.js +85 -68
  24. package/src/tools/read.js +15 -1
  25. package/src/tools/utils.js +14 -0
  26. package/src/tools/write.js +35 -5
  27. package/src/tui.js +11665 -9179
  28. package/src/web-auth.js +98 -0
  29. package/src/web-daemon-cli.js +63 -0
  30. package/src/web-daemon-client.js +203 -0
  31. package/src/web-daemon.js +871 -0
  32. package/src/web-public/app.css +1509 -0
  33. package/src/web-public/app.js +2913 -0
  34. package/src/web-public/complete.js +154 -0
  35. package/src/web-public/fonts/JetBrainsMono-Bold.woff2 +0 -0
  36. package/src/web-public/fonts/JetBrainsMono-Regular.woff2 +0 -0
  37. package/src/web-public/fonts/noto-sans-sc-100-400-normal.woff2 +0 -0
  38. package/src/web-public/fonts/noto-sans-sc-101-400-normal.woff2 +0 -0
  39. package/src/web-public/fonts/noto-sans-sc-102-400-normal.woff2 +0 -0
  40. package/src/web-public/fonts/noto-sans-sc-103-400-normal.woff2 +0 -0
  41. package/src/web-public/fonts/noto-sans-sc-104-400-normal.woff2 +0 -0
  42. package/src/web-public/fonts/noto-sans-sc-105-400-normal.woff2 +0 -0
  43. package/src/web-public/fonts/noto-sans-sc-106-400-normal.woff2 +0 -0
  44. package/src/web-public/fonts/noto-sans-sc-107-400-normal.woff2 +0 -0
  45. package/src/web-public/fonts/noto-sans-sc-108-400-normal.woff2 +0 -0
  46. package/src/web-public/fonts/noto-sans-sc-109-400-normal.woff2 +0 -0
  47. package/src/web-public/fonts/noto-sans-sc-110-400-normal.woff2 +0 -0
  48. package/src/web-public/fonts/noto-sans-sc-111-400-normal.woff2 +0 -0
  49. package/src/web-public/fonts/noto-sans-sc-112-400-normal.woff2 +0 -0
  50. package/src/web-public/fonts/noto-sans-sc-113-400-normal.woff2 +0 -0
  51. package/src/web-public/fonts/noto-sans-sc-114-400-normal.woff2 +0 -0
  52. package/src/web-public/fonts/noto-sans-sc-115-400-normal.woff2 +0 -0
  53. package/src/web-public/fonts/noto-sans-sc-116-400-normal.woff2 +0 -0
  54. package/src/web-public/fonts/noto-sans-sc-117-400-normal.woff2 +0 -0
  55. package/src/web-public/fonts/noto-sans-sc-118-400-normal.woff2 +0 -0
  56. package/src/web-public/fonts/noto-sans-sc-119-400-normal.woff2 +0 -0
  57. package/src/web-public/fonts/noto-sans-sc-21-400-normal.woff2 +0 -0
  58. package/src/web-public/fonts/noto-sans-sc-22-400-normal.woff2 +0 -0
  59. package/src/web-public/fonts/noto-sans-sc-23-400-normal.woff2 +0 -0
  60. package/src/web-public/fonts/noto-sans-sc-24-400-normal.woff2 +0 -0
  61. package/src/web-public/fonts/noto-sans-sc-25-400-normal.woff2 +0 -0
  62. package/src/web-public/fonts/noto-sans-sc-26-400-normal.woff2 +0 -0
  63. package/src/web-public/fonts/noto-sans-sc-27-400-normal.woff2 +0 -0
  64. package/src/web-public/fonts/noto-sans-sc-28-400-normal.woff2 +0 -0
  65. package/src/web-public/fonts/noto-sans-sc-29-400-normal.woff2 +0 -0
  66. package/src/web-public/fonts/noto-sans-sc-30-400-normal.woff2 +0 -0
  67. package/src/web-public/fonts/noto-sans-sc-31-400-normal.woff2 +0 -0
  68. package/src/web-public/fonts/noto-sans-sc-32-400-normal.woff2 +0 -0
  69. package/src/web-public/fonts/noto-sans-sc-33-400-normal.woff2 +0 -0
  70. package/src/web-public/fonts/noto-sans-sc-34-400-normal.woff2 +0 -0
  71. package/src/web-public/fonts/noto-sans-sc-35-400-normal.woff2 +0 -0
  72. package/src/web-public/fonts/noto-sans-sc-36-400-normal.woff2 +0 -0
  73. package/src/web-public/fonts/noto-sans-sc-37-400-normal.woff2 +0 -0
  74. package/src/web-public/fonts/noto-sans-sc-38-400-normal.woff2 +0 -0
  75. package/src/web-public/fonts/noto-sans-sc-39-400-normal.woff2 +0 -0
  76. package/src/web-public/fonts/noto-sans-sc-4-400-normal.woff2 +0 -0
  77. package/src/web-public/fonts/noto-sans-sc-40-400-normal.woff2 +0 -0
  78. package/src/web-public/fonts/noto-sans-sc-41-400-normal.woff2 +0 -0
  79. package/src/web-public/fonts/noto-sans-sc-42-400-normal.woff2 +0 -0
  80. package/src/web-public/fonts/noto-sans-sc-43-400-normal.woff2 +0 -0
  81. package/src/web-public/fonts/noto-sans-sc-44-400-normal.woff2 +0 -0
  82. package/src/web-public/fonts/noto-sans-sc-45-400-normal.woff2 +0 -0
  83. package/src/web-public/fonts/noto-sans-sc-46-400-normal.woff2 +0 -0
  84. package/src/web-public/fonts/noto-sans-sc-47-400-normal.woff2 +0 -0
  85. package/src/web-public/fonts/noto-sans-sc-48-400-normal.woff2 +0 -0
  86. package/src/web-public/fonts/noto-sans-sc-49-400-normal.woff2 +0 -0
  87. package/src/web-public/fonts/noto-sans-sc-5-400-normal.woff2 +0 -0
  88. package/src/web-public/fonts/noto-sans-sc-50-400-normal.woff2 +0 -0
  89. package/src/web-public/fonts/noto-sans-sc-51-400-normal.woff2 +0 -0
  90. package/src/web-public/fonts/noto-sans-sc-52-400-normal.woff2 +0 -0
  91. package/src/web-public/fonts/noto-sans-sc-53-400-normal.woff2 +0 -0
  92. package/src/web-public/fonts/noto-sans-sc-54-400-normal.woff2 +0 -0
  93. package/src/web-public/fonts/noto-sans-sc-55-400-normal.woff2 +0 -0
  94. package/src/web-public/fonts/noto-sans-sc-56-400-normal.woff2 +0 -0
  95. package/src/web-public/fonts/noto-sans-sc-57-400-normal.woff2 +0 -0
  96. package/src/web-public/fonts/noto-sans-sc-58-400-normal.woff2 +0 -0
  97. package/src/web-public/fonts/noto-sans-sc-59-400-normal.woff2 +0 -0
  98. package/src/web-public/fonts/noto-sans-sc-6-400-normal.woff2 +0 -0
  99. package/src/web-public/fonts/noto-sans-sc-60-400-normal.woff2 +0 -0
  100. package/src/web-public/fonts/noto-sans-sc-61-400-normal.woff2 +0 -0
  101. package/src/web-public/fonts/noto-sans-sc-62-400-normal.woff2 +0 -0
  102. package/src/web-public/fonts/noto-sans-sc-63-400-normal.woff2 +0 -0
  103. package/src/web-public/fonts/noto-sans-sc-64-400-normal.woff2 +0 -0
  104. package/src/web-public/fonts/noto-sans-sc-65-400-normal.woff2 +0 -0
  105. package/src/web-public/fonts/noto-sans-sc-66-400-normal.woff2 +0 -0
  106. package/src/web-public/fonts/noto-sans-sc-67-400-normal.woff2 +0 -0
  107. package/src/web-public/fonts/noto-sans-sc-68-400-normal.woff2 +0 -0
  108. package/src/web-public/fonts/noto-sans-sc-69-400-normal.woff2 +0 -0
  109. package/src/web-public/fonts/noto-sans-sc-70-400-normal.woff2 +0 -0
  110. package/src/web-public/fonts/noto-sans-sc-71-400-normal.woff2 +0 -0
  111. package/src/web-public/fonts/noto-sans-sc-72-400-normal.woff2 +0 -0
  112. package/src/web-public/fonts/noto-sans-sc-73-400-normal.woff2 +0 -0
  113. package/src/web-public/fonts/noto-sans-sc-74-400-normal.woff2 +0 -0
  114. package/src/web-public/fonts/noto-sans-sc-75-400-normal.woff2 +0 -0
  115. package/src/web-public/fonts/noto-sans-sc-76-400-normal.woff2 +0 -0
  116. package/src/web-public/fonts/noto-sans-sc-77-400-normal.woff2 +0 -0
  117. package/src/web-public/fonts/noto-sans-sc-78-400-normal.woff2 +0 -0
  118. package/src/web-public/fonts/noto-sans-sc-79-400-normal.woff2 +0 -0
  119. package/src/web-public/fonts/noto-sans-sc-80-400-normal.woff2 +0 -0
  120. package/src/web-public/fonts/noto-sans-sc-81-400-normal.woff2 +0 -0
  121. package/src/web-public/fonts/noto-sans-sc-82-400-normal.woff2 +0 -0
  122. package/src/web-public/fonts/noto-sans-sc-83-400-normal.woff2 +0 -0
  123. package/src/web-public/fonts/noto-sans-sc-84-400-normal.woff2 +0 -0
  124. package/src/web-public/fonts/noto-sans-sc-85-400-normal.woff2 +0 -0
  125. package/src/web-public/fonts/noto-sans-sc-86-400-normal.woff2 +0 -0
  126. package/src/web-public/fonts/noto-sans-sc-87-400-normal.woff2 +0 -0
  127. package/src/web-public/fonts/noto-sans-sc-88-400-normal.woff2 +0 -0
  128. package/src/web-public/fonts/noto-sans-sc-89-400-normal.woff2 +0 -0
  129. package/src/web-public/fonts/noto-sans-sc-90-400-normal.woff2 +0 -0
  130. package/src/web-public/fonts/noto-sans-sc-91-400-normal.woff2 +0 -0
  131. package/src/web-public/fonts/noto-sans-sc-97-400-normal.woff2 +0 -0
  132. package/src/web-public/fonts/noto-sans-sc-98-400-normal.woff2 +0 -0
  133. package/src/web-public/fonts/noto-sans-sc-99-400-normal.woff2 +0 -0
  134. package/src/web-public/fonts/noto-sans-sc.css +775 -0
  135. package/src/web-public/fonts.css +31 -0
  136. package/src/web-public/home.css +222 -0
  137. package/src/web-public/home.html +33 -0
  138. package/src/web-public/home.js +160 -0
  139. package/src/web-public/i18n.js +983 -0
  140. package/src/web-public/index.html +196 -0
  141. package/src/web-public/login.css +123 -0
  142. package/src/web-public/login.html +25 -0
  143. package/src/web-public/login.js +46 -0
  144. package/src/web-public/settings.js +290 -0
  145. package/src/web-ws.js +226 -0
  146. package/src/web.js +380 -0
  147. /package/bin/{install.js → install.cjs} +0 -0
  148. /package/bin/{preinstall.js → preinstall.cjs} +0 -0
@@ -0,0 +1,2913 @@
1
+ import { t, catalog, LANGUAGES, DEFAULT_LANG } from './i18n.js';
2
+ import { Completer } from './complete.js';
3
+ import { SettingsPanel, attachSettings } from './settings.js';
4
+
5
+
6
+ const $ = (id) => document.getElementById(id);
7
+ const stream = $('stream');
8
+ const input = $('input');
9
+
10
+ let LANG = DEFAULT_LANG;
11
+ try {
12
+ const saved = localStorage.getItem('hncode.lang');
13
+ if (saved && LANGUAGES.some((l) => l.id === saved)) LANG = saved;
14
+ } catch {}
15
+
16
+ let T = catalog(LANG);
17
+
18
+ const tr = (key, vars) => {
19
+ let s = T[key] || key;
20
+ if (vars) {
21
+ for (const k of Object.keys(vars)) s = s.split('{' + k + '}').join(String(vars[k]));
22
+ }
23
+ return s;
24
+ };
25
+
26
+ // Strip ANSI escape codes from a string. Rows the TUI sends for WIN-PLATFORM
27
+ // (role 'rich', some system lines) carry terminal styling (\x1b[97m etc.); the
28
+ // browser has no terminal, so those must become plain text or the literal
29
+ // "[97m" leak shows up in the transcript. Applied once at paintRow so every
30
+ // role — simple text, markdown, tool rows — is clean.
31
+ const ANSI_RE = /\u001b\[[0-9;:<=>?]*[ -/]*[@-~]/g;
32
+ function stripAnsi(s) { return String(s == null ? '' : s).replace(ANSI_RE, ''); }
33
+
34
+ let status = {};
35
+ let completer = null;
36
+ let settingsPanel = null;
37
+
38
+ function applyStatic() {
39
+ document.documentElement.lang = LANG === 'zh' ? 'zh-CN' : 'en';
40
+ for (const node of document.querySelectorAll('[data-i18n]')) {
41
+ node.textContent = tr(node.getAttribute('data-i18n'));
42
+ }
43
+ for (const node of document.querySelectorAll('[data-i18n-title]')) {
44
+ node.title = tr(node.getAttribute('data-i18n-title'));
45
+ }
46
+ for (const node of document.querySelectorAll('[data-i18n-placeholder]')) {
47
+ node.placeholder = tr(node.getAttribute('data-i18n-placeholder'));
48
+ }
49
+ for (const node of document.querySelectorAll('[data-i18n-aria-label]')) {
50
+ node.setAttribute('aria-label', tr(node.getAttribute('data-i18n-aria-label')));
51
+ }
52
+ rerenderRows();
53
+ renderStatus();
54
+ setPmode();
55
+ }
56
+
57
+ function buildLangSwitch() {
58
+ const box = $('lang-switch');
59
+ box.replaceChildren();
60
+ box.title = tr('nav.language');
61
+ for (const l of LANGUAGES) {
62
+ const b = document.createElement('button');
63
+ b.type = 'button';
64
+ b.textContent = l.label;
65
+ b.className = l.id === LANG ? 'is-on' : '';
66
+ b.addEventListener('click', () => {
67
+ if (l.id === LANG) return;
68
+ LANG = l.id;
69
+ T = catalog(LANG);
70
+ try { localStorage.setItem('hncode.lang', LANG); } catch {}
71
+ buildLangSwitch();
72
+ applyStatic();
73
+ });
74
+ box.appendChild(b);
75
+ }
76
+ }
77
+
78
+ function rerenderRows() {
79
+ for (const [i, node] of win.els) {
80
+ const row = rowItems[i];
81
+ if (row && node._rec) paintRow(node._rec);
82
+ }
83
+ }
84
+
85
+ let es = null;
86
+ let retry = 0;
87
+ let retryTimer = null;
88
+ let readOnly = false;
89
+ let readOnlyChecked = false;
90
+
91
+ function setConn(kind, text) {
92
+ $('conn').className = 'dot dot-' + kind;
93
+ $('conn-text').textContent = text;
94
+ }
95
+
96
+ // Live updates arrive over WebSocket, not EventSource. The server (web.js) runs a
97
+ // zero-dependency RFC 6455 endpoint at /api/ws: on connect it sends a `snapshot`
98
+ // of the current view, then every hub event as a JSON text frame. A dropped
99
+ // connection is retried with backoff, like EventSource auto-reconnect did for
100
+ // SSE.
101
+ function connect() {
102
+ if (retryTimer) { clearTimeout(retryTimer); retryTimer = null; }
103
+ if (es) { try { es.close(); } catch { /* already gone */ } }
104
+
105
+ setConn('wait', tr('nav.connecting'));
106
+ // Relative URL, exactly like the old `new EventSource('api/events')`: the
107
+ // browser resolves it against the current document path, so behind the daemon
108
+ // (page at /s/<id>/) the handshake reaches /s/<id>/api/ws, and a standalone
109
+ // /web reaches /api/ws. Building `location.host + '/api/ws'` instead dropped
110
+ // the /s/<id> prefix and made the daemon drop every handshake.
111
+ const src = new WebSocket('api/ws');
112
+ es = src;
113
+
114
+ src.onopen = () => {
115
+ if (src !== es) return;
116
+ retry = 0;
117
+ setConn('on', tr('nav.connected'));
118
+ };
119
+
120
+ src.onmessage = (ev) => {
121
+ let msg;
122
+ try { msg = JSON.parse(ev.data); } catch { return; }
123
+ handle(msg);
124
+ };
125
+
126
+ src.onclose = (ev) => {
127
+ // TEMP diag: record every close so a "reloads every few seconds" bug can be
128
+ // tied to who dropped the socket. Remove after diagnosing.
129
+ if (!window.__wsCloseLog) window.__wsCloseLog = [];
130
+ window.__wsCloseLog.push({ t: Date.now(), code: ev && ev.code, wasClean: ev && ev.wasClean, reason: ev && ev.reason });
131
+ console.log('WS-close', ev && ev.code, ev && ev.wasClean);
132
+ if (src !== es) return;
133
+ if (es === src) es = null;
134
+ // A WebSocket that never opened usually means the session has EXITED: the
135
+ // daemon destroys the handshake for read-only sessions (there is no live
136
+ // server to proxy). Fall back to a one-shot `api/state` fetch so the
137
+ // finished transcript still renders, instead of spinning on reconnect.
138
+ if (!readOnly && !readOnlyChecked && retry === 0) {
139
+ readOnlyChecked = true;
140
+ loadReadOnlySnapshot();
141
+ return;
142
+ }
143
+ setConn('off', tr('nav.disconnected'));
144
+ retry = Math.min(retry + 1, 6);
145
+ retryTimer = setTimeout(connect, 400 * retry);
146
+ };
147
+
148
+ src.onerror = () => { /* onclose follows and does the retry */ };
149
+ }
150
+
151
+ // The session has EXITED (read-only): load its snapshot over HTTP and freeze
152
+ // the UI. api/state on the daemon answer for a saved session; standalone web.js
153
+ // does too. Disable the composer so a read-only page cannot pretend it can act.
154
+ async function loadReadOnlySnapshot() {
155
+ if (readOnly) return;
156
+ let res;
157
+ try { res = await fetch('api/state', { headers: { accept: 'application/json' } }); }
158
+ catch { /* network error — fall through to normal retry */ }
159
+ if (!res || !res.ok) {
160
+ // Not a read-only case; resume normal reconnect.
161
+ if (res) setConn('off', tr('nav.disconnected'));
162
+ retry = Math.min(retry + 1, 6);
163
+ retryTimer = setTimeout(connect, 400 * retry);
164
+ return;
165
+ }
166
+ let data;
167
+ try { data = await res.json(); } catch { /* ignore */ return; }
168
+ readOnly = true;
169
+ applySnapshot({ rows: (data && data.rows) || [], status: (data && data.status) || {} });
170
+ setConn('off', tr('nav.disconnected'));
171
+ // Freeze the composer: no WS to send on, and the session is read-only anyway.
172
+ const inputEl = $('input');
173
+ if (inputEl) inputEl.disabled = true;
174
+ setPanelVisible('send', false);
175
+ setPanelVisible('btn-interrupt', false);
176
+ setPanelVisible('working', false);
177
+ const readonlyBadge = $('readonly-badge');
178
+ if (readonlyBadge) readonlyBadge.hidden = false;
179
+ }
180
+
181
+ async function act(name, args) {
182
+ // The ONLY transport is the WebSocket. Sending is fire-and-forget (the UI
183
+ // updates optimistically; the real result rides the status/row stream back),
184
+ // and there is deliberately NO HTTP fallback: if the socket is down the server
185
+ // is unreachable, so a POST would not reach it either — it would just hang the
186
+ // caller. Tell the user the link is down and let the reconnect loop restore it.
187
+ var ws = es;
188
+ if (ws && ws.readyState === 1) {
189
+ try {
190
+ ws.send(JSON.stringify({ action: name, args: args || [] }));
191
+ return { ok: true };
192
+ } catch (e) { /* fall through to down-report */ }
193
+ }
194
+ // Connection lost. If the socket is simply reconnecting (readyState 0), wait
195
+ // for it; if it is closed, tell the user and ensure connect() is running.
196
+ if (ws && ws.readyState === 0) {
197
+ return { ok: false, error: tr('err.unreachable') };
198
+ }
199
+ toast(tr('nav.disconnected'));
200
+ connect();
201
+ return { ok: false, error: tr('err.unreachable') };
202
+ }
203
+
204
+ async function actReturning(name, args) {
205
+ const body = await act(name, args);
206
+ if (!body || body.ok !== true) throw new Error((body && body.error) || tr('err.actionFailed'));
207
+ return body;
208
+ }
209
+
210
+ let toastTimer = null;
211
+
212
+ function toast(text) {
213
+ const node = $('notice');
214
+ node.textContent = text;
215
+ node.hidden = false;
216
+ clearTimeout(toastTimer);
217
+ toastTimer = setTimeout(() => { node.hidden = true; }, 4000);
218
+ }
219
+
220
+ function handle(msg) {
221
+ switch (msg.type) {
222
+ case 'snapshot': applySnapshot(msg); break;
223
+ case 'row': addRow(msg.row); break;
224
+ case 'row_patch': patchRow(msg.id, msg.patch); break;
225
+ case 'rows_reset': applySnapshot({ rows: msg.rows, status }); break;
226
+ case 'status': applyStatus(msg.status); break;
227
+ // Granular events (claude-code style): update just one panel, not the whole
228
+ // status DOM. The server pushes these when a single field changes.
229
+ case 'queued': {
230
+ const q = Array.isArray(msg.queued) ? msg.queued : [];
231
+ if (!status) status = {};
232
+ status.queued = q;
233
+ setPanelVisible('queue-panel', q.length > 0);
234
+ renderQueuePanel(q, !!(status && status.busy));
235
+ break;
236
+ }
237
+ case 'todos': {
238
+ const t = Array.isArray(msg.todos) ? msg.todos : [];
239
+ if (!status) status = {};
240
+ status.todos = t;
241
+ setPanelVisible('todo-panel', t.length > 0);
242
+ renderTodoPanel(t);
243
+ break;
244
+ }
245
+ case 'tasks': {
246
+ const t = Array.isArray(msg.tasks) ? msg.tasks : [];
247
+ if (status) status.tasks = t;
248
+ if (!isViewHidden('tasks')) renderTasks(t);
249
+ renderTabCounts(status || {});
250
+ break;
251
+ }
252
+ default: break;
253
+ }
254
+ }
255
+
256
+ /**
257
+ * Animation-only fields, matching the server's exclusion list (session-hub.js).
258
+ * They drive the Working pulse, which the browser draws for itself; a change in
259
+ * just these must NOT trigger a full status re-render, or a long turn would
260
+ * rebuild every card pane every frame — exactly the churn that froze the tab.
261
+ */
262
+ const ANIMATION_FIELDS = new Set(['spin', 'workMsg', 'pulseStart']);
263
+
264
+ function samePlain(a, b) {
265
+ if (a === b) return true; // identical ref or primitive
266
+ if (Array.isArray(a) && Array.isArray(b)) {
267
+ if (a.length !== b.length) return false; // cheap length gate first
268
+ }
269
+ return JSON.stringify(a) === JSON.stringify(b); // deep: last resort only
270
+ }
271
+
272
+ function stableStatusChanged(next) {
273
+ for (const k of Object.keys(next)) {
274
+ if (ANIMATION_FIELDS.has(k)) continue;
275
+ if (!samePlain(next[k], status[k])) return true;
276
+ }
277
+ for (const k of Object.keys(status)) {
278
+ if (ANIMATION_FIELDS.has(k)) continue;
279
+ if (!(k in next)) return true;
280
+ }
281
+ return false;
282
+ }
283
+
284
+ function applyStatus(s) {
285
+ const next = s || {};
286
+ // The WORKING row's word / pulse phase is drawn by drawPulse() off its own
287
+ // clock, so a broadcast that only moved `spin` should not touch the panels.
288
+ const stableChanged = !status || stableStatusChanged(next);
289
+ const wasBusy = !!(status && status.busy);
290
+ status = next;
291
+ // Always animate the Working row off the latest state; only re-render the
292
+ // whole status DOM when a stable field actually changed.
293
+ if (status.busy) drawPulse(); else clearPulse();
294
+ if (stableChanged) renderStatus();
295
+ else if (status.busy !== wasBusy) renderBusyButton();
296
+ }
297
+
298
+ let rowItems = [];
299
+
300
+ const rowIndex = new Map();
301
+ let expandedRows = new Set();
302
+
303
+ /**
304
+ * Set scrollTop as a PROGRAMMATIC move and remember the target, so the async
305
+ * `scroll` event it fires is recognized as ours and not mistaken for a user
306
+ * scroll. The old `pinning` flag alone was unreliable: the scroll event is
307
+ * dispatched a frame later, by which time the microtask that cleared `pinning`
308
+ * had already run — so a programmatic "snap to bottom" was read back as the user
309
+ * scrolling to the bottom, which re-pinned the view and fought a user who had
310
+ * just scrolled up.
311
+ */
312
+ function setScrollTop(v) {
313
+ stream.scrollTop = v;
314
+ // Remember the CLAMPED position the browser actually landed on (scrollTop is
315
+ // capped to the scrollable range), so the async scroll event compares equal and
316
+ // is correctly ignored rather than misread as a user scroll.
317
+ win._progTop = stream.scrollTop;
318
+ }
319
+
320
+ // Flow virtualisation: rows live in NORMAL document flow; the browser computes
321
+ // every real height, so two rows can never overlap. Only rows inside the
322
+ // window have DOM; the ranges above/below it are reserved by two padding divs,
323
+ // sized from measured heights (PLACEHOLDER_H until a row is first mounted).
324
+ const WIN_OVERSCAN = 8;
325
+ const NEAR_END_PX = 24;
326
+ // TanStack Virtual's rule for estimateSize: "estimate the LARGEST possible
327
+ // size (within comfort)". An underestimate makes the scroll range too short,
328
+ // so unmeasured history is skipped past and rows pop/jump as they mount. A
329
+ // generous overestimate only makes the scrollbar slightly long, which the
330
+ // measurement cache shrinks as rows mount. Tool rows dominate this transcript,
331
+ // and a folded result is ~8 lines + chrome, so ~180px is a safe floor.
332
+ const PLACEHOLDER_H = 180;
333
+
334
+ const win = {
335
+ spacer: null,
336
+ topPad: null,
337
+ bottomPad: null,
338
+ heights: [], // REAL heights, measured per mounted row (0 = never seen)
339
+ start: 0,
340
+ end: 0,
341
+ els: new Map(),
342
+ raf: null,
343
+ pinned: true,
344
+ _lastStart: -1,
345
+ _lastEnd: -1,
346
+ _progTop: null, // last programmatic scrollTop, to ignore our own scroll event
347
+ };
348
+
349
+ function knownHeight(i) {
350
+ const h = win.heights[i];
351
+ return h > 0 ? h : PLACEHOLDER_H;
352
+ }
353
+
354
+ function heightAbove(i) {
355
+ let sum = 0;
356
+ for (let k = 0; k < i && k < rowItems.length; k++) sum += knownHeight(k);
357
+ return sum;
358
+ }
359
+
360
+ function heightBelow(i) {
361
+ let sum = 0;
362
+ for (let k = i; k < rowItems.length; k++) sum += knownHeight(k);
363
+ return sum;
364
+ }
365
+
366
+ function scheduleWindow() {
367
+ if (win.raf !== null) return;
368
+ win.raf = requestAnimationFrame(renderWindow);
369
+ }
370
+
371
+ function ensureSpacer() {
372
+ if (win.spacer && win.spacer.isConnected) return win.spacer;
373
+ stream.replaceChildren();
374
+ win.spacer = document.createElement("div");
375
+ win.spacer.className = "win-spacer";
376
+ win.topPad = document.createElement("div");
377
+ win.bottomPad = document.createElement("div");
378
+ win.topPad.className = "win-pad";
379
+ win.bottomPad.className = "win-pad";
380
+ win.spacer.append(win.topPad, win.bottomPad);
381
+ stream.appendChild(win.spacer);
382
+ win.els.clear();
383
+ return win.spacer;
384
+ }
385
+
386
+ function mountRow(i, el) {
387
+ // Insert at the row's DOCUMENT position, not blindly at the end. Scrolling
388
+ // UP mounts top rows while later rows are already in the spacer; appending
389
+ // them at the end put a row that belongs ABOVE below rows that belong BELOW
390
+ // — document order broke and the transcript rendered scrambled/overlapping.
391
+ let before = win.bottomPad;
392
+ for (let k = i + 1; k < win.end; k++) {
393
+ const next = win.els.get(k);
394
+ if (next) { before = next; break; }
395
+ }
396
+ win.spacer.insertBefore(el, before);
397
+ win.els.set(i, el);
398
+ const rec = el._rec;
399
+ if (rec) rec.index = i;
400
+ }
401
+
402
+ function unmountRow(i) {
403
+ const el = win.els.get(i);
404
+ if (!el) return;
405
+ const rec = el._rec;
406
+ if (rec) {
407
+ const h = el.getBoundingClientRect().height;
408
+ if (h > 0 && win.heights[rec.index] !== h) { win.heights[rec.index] = h; win._lastStart = -1; }
409
+ if (rec.ro) rec.ro.unobserve(el);
410
+ }
411
+ el.remove();
412
+ win.els.delete(i);
413
+ }
414
+
415
+ function clearAllRows() {
416
+ for (const i of [...win.els.keys()]) unmountRow(i);
417
+ win._lastStart = -1;
418
+ win._lastEnd = -1;
419
+ }
420
+
421
+ function renderWindow() {
422
+ win.raf = null;
423
+ if (!win.spacer || !win.spacer.isConnected) return;
424
+ if (!rowItems.length) {
425
+ win.topPad.style.height = "0px";
426
+ win.bottomPad.style.height = "0px";
427
+ clearAllRows();
428
+ win.start = win.end = 0;
429
+ return;
430
+ }
431
+ const viewH = stream.clientHeight || 608;
432
+ if (win.pinned) {
433
+ // Fixed-size tail window (by count, not by cumulative pixels): the tail
434
+ // rows are the newest, and a row-count window is exact regardless of how
435
+ // tall the measured rows are. PLACEHOLDER_H only prices the mounts into
436
+ // the two pads above/below the window; the pad math handles real heights.
437
+ const want = Math.ceil(viewH / PLACEHOLDER_H) + WIN_OVERSCAN;
438
+ win.end = rowItems.length;
439
+ win.start = Math.max(0, win.end - want);
440
+ } else {
441
+ let acc = 0;
442
+ let first = rowItems.length - 1;
443
+ for (let i = 0; i < rowItems.length; i++) {
444
+ if (acc >= stream.scrollTop) { first = i; break; }
445
+ acc += knownHeight(i);
446
+ }
447
+ const start = Math.max(0, first - WIN_OVERSCAN);
448
+ let end = start;
449
+ let used = 0;
450
+ while (end < rowItems.length && used < viewH + WIN_OVERSCAN * PLACEHOLDER_H) {
451
+ used += knownHeight(end);
452
+ end++;
453
+ }
454
+ win.start = start;
455
+ win.end = Math.min(rowItems.length, end + WIN_OVERSCAN);
456
+ }
457
+ const same = win.start === win._lastStart && win.end === win._lastEnd;
458
+ win._lastStart = win.start;
459
+ win._lastEnd = win.end;
460
+ if (same) return;
461
+ for (const k of [...win.els.keys()]) {
462
+ if (k < win.start || k >= win.end) unmountRow(k);
463
+ }
464
+ for (let k = win.start; k < win.end; k++) {
465
+ if (!win.els.has(k)) mountRow(k, buildRow(rowItems[k]));
466
+ }
467
+ win.topPad.style.height = heightAbove(win.start) + "px";
468
+ win.bottomPad.style.height = heightBelow(win.end) + "px";
469
+ if (win.pinned) setScrollTop(stream.scrollHeight);
470
+ }
471
+
472
+ var sharedRO = typeof ResizeObserver === "function" ? new ResizeObserver(function (entries) {
473
+ let delta = 0;
474
+ for (const en of entries) {
475
+ const rec = en.target._rec;
476
+ if (!rec || rec.index < 0) continue;
477
+ const h = en.target.getBoundingClientRect().height;
478
+ const prev = win.heights[rec.index] > 0 ? win.heights[rec.index] : PLACEHOLDER_H;
479
+ if (h <= 0 || prev === h) continue;
480
+ win.heights[rec.index] = h;
481
+ delta += h - prev;
482
+ }
483
+ if (delta !== 0) {
484
+ win._lastStart = -1; // heights changed: paddings must be recomputed
485
+ scheduleWindow();
486
+ // TanStack semantics (shouldAdjustScrollPositionOnItemSizeChange): correct
487
+ // the scroll offset only when the change is ABOVE the viewport AND the user
488
+ // is not scrolling backward — that combination is the classic "rows jump
489
+ // while scrolling up" jank. End-pinned views still re-anchor to the bottom.
490
+ if (win.pinned) {
491
+ setScrollTop(stream.scrollHeight);
492
+ } else if (delta > 0) {
493
+ // A row above the viewport got TALLER: keep the text under the reader
494
+ // stationary by shifting the offset by the same amount.
495
+ setScrollTop(stream.scrollTop + delta);
496
+ }
497
+ // delta < 0 while unpinned: content above shrank; no correction needed,
498
+ // the browser keeps the viewport anchored to the same content.
499
+ }
500
+ }) : null;
501
+
502
+ function observeRow(el) {
503
+ if (sharedRO) sharedRO.observe(el);
504
+ return sharedRO;
505
+ }
506
+
507
+ function onScroll() {
508
+ // Ignore the scroll event our OWN setScrollTop just fired (same position),
509
+ // so a programmatic snap-to-bottom is never mistaken for a user scroll.
510
+ if (win._progTop != null && Math.abs(stream.scrollTop - win._progTop) <= 1) {
511
+ return;
512
+ }
513
+ win._progTop = null;
514
+ const dist = stream.scrollHeight - stream.scrollTop - stream.clientHeight;
515
+ // Any scroll AWAY from the bottom is a deliberate move by the user, so the
516
+ // tail-follow ends IMMEDIATELY — no matter how small. The old rule kept
517
+ // `pinned` true until the user had scrolled more than a full viewport, so a
518
+ // small upward scroll left the view pinned and the next render yanked it back
519
+ // to the bottom: the "fighting the scroll" jank. Re-pin only when the user
520
+ // returns to (or near) the bottom.
521
+ win.pinned = dist <= NEAR_END_PX * 3;
522
+ scheduleWindow();
523
+ }
524
+
525
+ // Patch any height-affecting repaint into the measurement cycle.
526
+ const patchQueue = new Set();
527
+ let patchFlushQueued = false;
528
+
529
+ /**
530
+ * A streaming patch repaints the row's content, which almost always changes its
531
+ * height — and a changed height invalidates every offset below it. So the reflow
532
+ * is forced here instead of waiting for the row's resize observer: the observer
533
+ * fires a frame LATER, and until it does the rows underneath sit at their old
534
+ * positions with the new, taller row drawn over them. That gap is the overlap
535
+ * that showed up while text streamed in.
536
+ */
537
+ function queueRowPatch(rec, patch) {
538
+ for (const k of Object.keys(patch)) rec.row[k] = patch[k];
539
+ if (patch.append !== undefined) {
540
+ const cut = typeof patch.textTo === 'number' ? patch.textTo : 0;
541
+ rec.row.text = String(rec.row.text || '').slice(0, cut) + patch.append;
542
+ }
543
+ patchQueue.add(rec);
544
+ if (patchFlushQueued) return;
545
+ patchFlushQueued = true;
546
+ requestAnimationFrame(() => {
547
+ patchFlushQueued = false;
548
+ let heightsChanged = false;
549
+ for (const r of patchQueue) {
550
+ paintRow(r);
551
+ const h = r.el.getBoundingClientRect().height;
552
+ if (h > 0 && r.index >= 0 && win.heights[r.index] !== h) { win.heights[r.index] = h; heightsChanged = true; }
553
+ }
554
+ if (heightsChanged) win._lastStart = -1;
555
+ patchQueue.clear();
556
+ scheduleWindow();
557
+ });
558
+ }
559
+
560
+ function resetView(rows, nextStatus) {
561
+ rowIndex.clear();
562
+ rowItems = (rows || []).map((r) => ({ ...r }));
563
+ for (let i = 0; i < rowItems.length; i++) {
564
+ const r = rowItems[i];
565
+ if (r && r.id != null) rowIndex.set(r.id, i);
566
+ }
567
+ status = nextStatus || {};
568
+ renderStatus();
569
+
570
+ expandedRows = new Set();
571
+ win.heights = new Array(rowItems.length).fill(0); // 0 = unmeasured placeholder
572
+ win.start = win.end = 0;
573
+ win.pinned = true;
574
+ clearAllRows();
575
+ win.spacer = null;
576
+ ensureSpacer();
577
+ renderWindow();
578
+ }
579
+
580
+ function applySnapshot(s) {
581
+ resetView(s.rows, s.status);
582
+ }
583
+
584
+ /**
585
+ * The browser's transcript is capped at the same number the server keeps
586
+ * (`MAX_ROWS` in session-hub.js). Without a cap the two grow apart in the way
587
+ * that only shows up on the long sessions this UI exists for: the server trims
588
+ * its ring while the tab keeps every row it was ever sent, so the page holds a
589
+ * transcript the server no longer has — and every structure derived from it
590
+ * (the id index, the heights array, the expanded set) grows with it.
591
+ */
592
+ const MAX_ROWS = 4000;
593
+
594
+ /** Drop the oldest rows until the list is back under the cap, moving every
595
+ derived structure with them. */
596
+ function trimRows() {
597
+ const over = rowItems.length - MAX_ROWS;
598
+ if (over <= 0) return;
599
+
600
+ rowItems.splice(0, over);
601
+ win.heights.splice(0, over);
602
+
603
+ // Every remaining row moved up by `over`, so the id index is rebuilt rather
604
+ // than patched — an incremental fix-up here is exactly where an off-by-`over`
605
+ // bug would hide.
606
+ rowIndex.clear();
607
+ for (let i = 0; i < rowItems.length; i++) {
608
+ const r = rowItems[i];
609
+ if (r && r.id != null) rowIndex.set(r.id, i);
610
+ }
611
+
612
+ // Mounted elements are keyed by index, so they are now wrong too; dropping
613
+ // them costs one re-render and removes any chance of a stale element painting
614
+ // a row that has moved.
615
+ clearAllRows();
616
+ win.start = win.end = 0;
617
+ }
618
+
619
+ function addRow(row) {
620
+ if (!row) return;
621
+ if (row.id != null && rowIndex.has(row.id)) { patchRow(row.id, row); return; }
622
+ const item = { ...row };
623
+ rowItems.push(item);
624
+ // Pre-estimate the row's height so a long message is sized correctly even
625
+ // before it scrolls into view; the resize observer replaces this once mounted.
626
+ win.heights.push(0); // unmeasured until first mount
627
+ if (item.id != null) rowIndex.set(item.id, rowItems.length - 1);
628
+ // Results start FOLDED — the "more" button adds the id to expandedRows.
629
+ // (Every row used to start expanded, which defeated the fold caps entirely
630
+ // and made a giant tool result overlap everything around it.)
631
+ trimRows();
632
+ scheduleWindow();
633
+ }
634
+
635
+ function patchRow(id, patch) {
636
+ const i = rowIndex.get(id);
637
+ if (i === undefined) return;
638
+ const prev = rowItems[i];
639
+ const next = { ...prev, ...patch };
640
+ if (patch.append !== undefined) {
641
+ const cut = typeof patch.textTo === 'number' ? patch.textTo : 0;
642
+ next.text = String(prev.text || '').slice(0, cut) + patch.append;
643
+ delete next.append;
644
+ delete next.textTo;
645
+ }
646
+ rowItems[i] = next;
647
+ const el = win.els.get(i);
648
+ if (el && el._rec) {
649
+ queueRowPatch(el._rec, patch);
650
+ } else if (win.heights[i] !== undefined) {
651
+ // Not on screen: keep the pre-estimate in step with the growing text, or the
652
+ // scrollbar lags a long streaming message by a whole viewport.
653
+ win.heights[i] = 0; // re-measure on next mount
654
+ scheduleWindow();
655
+ }
656
+ }
657
+
658
+
659
+ function repaintRow(id) {
660
+ const i = rowIndex.get(id);
661
+ if (i === undefined) return;
662
+ const el = win.els.get(i);
663
+ if (!el || !el._rec) return;
664
+ paintRow(el._rec);
665
+ const h = el.getBoundingClientRect().height;
666
+ if (h > 0) win.heights[i] = h;
667
+ scheduleWindow();
668
+ }
669
+
670
+
671
+
672
+ /**
673
+ * MARKERS — the `❯` / `●` / `↳` the terminal prints at the start of a line.
674
+ *
675
+ * The terminal has NO gutter column: `messageLines()` builds each row as a text
676
+ * prefix (`'❯ '`, `'● '`, `'↳ '`, or a two-space continuation) concatenated to
677
+ * the body, and `rowToLine()` emits `ind + body` verbatim. The marker's two
678
+ * columns ARE the layout, so a wrapped paragraph hangs under the marker for
679
+ * free. The browser reproduces that with a CSS `::before` on the body box — a
680
+ * real grid column would have to centre the glyph and would not hang right.
681
+ */
682
+ const MARK_KEY = {
683
+ user: 'mark.user',
684
+ queued: 'mark.queued',
685
+ steer: 'mark.steer',
686
+ bash: 'mark.bash',
687
+ warn: 'mark.warn',
688
+ };
689
+
690
+ const BOX_ROLES = new Set(['user', 'queued', 'bash']);
691
+ const SIMPLE_ROLES = new Set(['user', 'queued', 'steer', 'bash', 'system', 'warn', 'error', 'aborted']);
692
+
693
+ function markFor(role, row) {
694
+ if (role === 'tool') return tr(row.failed ? 'mark.toolFailed' : 'mark.tool');
695
+ if (role === 'tool_result') return tr('mark.result');
696
+ if (role === 'thinking') return tr('mark.thinking');
697
+ if (role === 'compaction') return tr('mark.tool');
698
+ if (role === 'bg_task') {
699
+ return tr(row.card && row.card.phase === 'failed' ? 'mark.bgTaskFailed' : 'mark.bgTask');
700
+ }
701
+ const key = MARK_KEY[role];
702
+ return key ? tr(key) : '';
703
+ }
704
+
705
+ function rowClassFor(role) {
706
+ if (role === 'tool') return 'msg-tool';
707
+ if (role === 'tool_result') return 'msg-result';
708
+ if (role === 'bg_task') return 'msg-bgtask';
709
+ return 'msg-' + role;
710
+ }
711
+
712
+ // A single assistant row past this many characters renders as PLAIN TEXT even
713
+ // if it contains markdown: block-parsing a 200KB reply into thousands of nodes
714
+ // in one row stalled the tab. Plain text is one node and reads fine; the row
715
+ // content itself is what the model sent.
716
+ const MD_CHAR_BUDGET = 60000;
717
+
718
+ function buildRow(row) {
719
+ const el = document.createElement('div');
720
+ el.className = 'win-row';
721
+ const rec = { el, row: row || {}, role: '', body: null, ro: null, simple: false, index: -1, mark: null };
722
+ el._rec = rec;
723
+ rec.ro = observeRow(el);
724
+ paintRow(rec);
725
+ return el;
726
+ }
727
+
728
+ function paintRow(rec) {
729
+ const r = rec.row || {};
730
+ const role = String(r.role || 'system');
731
+ const text = stripAnsi(String(r.text == null ? '' : r.text));
732
+
733
+ // STREAMING: a pending assistant row ALWAYS renders as one text node. The
734
+ // markdown parser is O(text) per repaint, so running it on every streamed
735
+ // patch of a growing reply is quadratic and froze the tab. When the turn
736
+ // finishes (pending -> false) the row is re-classified once and the full
737
+ // markdown render happens exactly once, like the terminal finishing a block.
738
+ const streamPending = role === 'assistant' && r.pending === true;
739
+ const tooBigForMd = role === 'assistant' && text.length > MD_CHAR_BUDGET;
740
+ const simple = SIMPLE_ROLES.has(role) || (role === 'assistant' && (streamPending || tooBigForMd || !hasMarkup(text)));
741
+ if (rec.role !== role || rec.simple !== simple) {
742
+ rec.el.className = 'win-row ' + rowClassFor(role);
743
+ rec.body = document.createElement('div');
744
+ rec.body.className = BOX_ROLES.has(role) ? 'msg-box' : 'msg-body';
745
+ rec.el.replaceChildren(rec.body);
746
+ rec.role = role;
747
+ rec.simple = simple;
748
+ }
749
+
750
+ rec.el.classList.toggle('is-failed', r.failed === true);
751
+ rec.el.classList.toggle('is-running', role === 'tool' && !!r.pending && !r.failed);
752
+ rec.el.classList.toggle('is-done', role === 'tool' && !r.pending && !r.failed);
753
+ if (role === 'compaction') {
754
+ rec.el.classList.toggle('is-done', r.phase === 'done');
755
+ rec.el.classList.toggle('is-cancelled', r.phase === 'cancelled');
756
+ rec.el.classList.toggle('is-running', r.phase !== 'done' && r.phase !== 'cancelled');
757
+ }
758
+ if (role === 'bg_task' && r.card) {
759
+ rec.el.classList.toggle('is-started', r.card.phase === 'started');
760
+ rec.el.classList.toggle('is-completed', r.card.phase === 'completed');
761
+ rec.el.classList.toggle('is-failed', r.card.phase === 'failed');
762
+ }
763
+
764
+ const mark = markFor(role, r);
765
+ if (rec.mark !== mark) {
766
+ rec.mark = mark;
767
+ if (mark) rec.el.setAttribute('data-mark', mark);
768
+ else rec.el.removeAttribute('data-mark');
769
+ }
770
+
771
+ if (rec.simple) {
772
+ if (rec.body.textContent !== text) rec.body.textContent = text;
773
+ return;
774
+ }
775
+ paintBody(rec, r, role, text);
776
+ }
777
+
778
+
779
+ /** True when the text needs block rendering. A plain reply keeps ONE text node
780
+ for its whole life, so a streamed patch is a single assignment instead of a
781
+ rebuilt subtree on every frame. */
782
+ function hasMarkup(text) {
783
+ return text.indexOf('```') >= 0 || text.indexOf('#') >= 0
784
+ || text.indexOf('`') >= 0 || text.indexOf('**') >= 0 || text.indexOf('](') >= 0;
785
+ }
786
+
787
+ const RESULT_MAX_LINES = 12;
788
+ const DIFF_MAX_ROWS = 16;
789
+ const THINK_PREVIEW = 1; // collapsed thinking shows a single live/one-line preview
790
+
791
+ function paintBody(rec, r, role, text) {
792
+ const body = rec.body;
793
+ body.replaceChildren();
794
+
795
+ if (role === 'tool') { paintToolRow(body, r, rec); return; }
796
+ if (role === 'tool_result') { paintResultRow(body, r); return; }
797
+ if (role === 'compaction') { paintCompactionRow(body, r); return; }
798
+ if (role === 'bg_task') { paintBgTaskRow(body, r); return; }
799
+ if (role === 'thinking') { paintThinkingRow(body, r, text); return; }
800
+ appendMarkdown(body, text);
801
+ }
802
+
803
+ function el(cls, text) {
804
+ const n = document.createElement('span');
805
+ if (cls) n.className = cls;
806
+ if (text != null) n.textContent = text;
807
+ return n;
808
+ }
809
+
810
+ /**
811
+ * Which argument identifies a call, mirroring the terminal's `KEY_ARG`: a tool
812
+ * line must read `Using Read (src/tui.js)`, so the field is chosen per tool
813
+ * rather than being "the first string that happens to be non-empty".
814
+ */
815
+ const KEY_ARG = {
816
+ Bash: ['command'],
817
+ Read: ['path', 'file_path'],
818
+ Write: ['path', 'file_path'],
819
+ Edit: ['path', 'file_path'],
820
+ Grep: ['pattern'],
821
+ Glob: ['pattern'],
822
+ FileLines: ['path', 'file_path'],
823
+ FetchURL: ['url'],
824
+ WebSearch: ['query'],
825
+ Agent: ['description', 'prompt'],
826
+ TaskOutput: ['task_id'],
827
+ TaskStop: ['task_id'],
828
+ };
829
+
830
+ const MAX_ARG = 60;
831
+
832
+ /** Terminal `keyArgument`: first line only, workspace-relative for paths,
833
+ truncated to MAX_ARG with the ellipsis on the side that keeps a path's
834
+ basename readable. */
835
+ function keyArgument(name, args, workspace) {
836
+ if (!args || typeof args !== 'object') return '';
837
+ const keys = KEY_ARG[name] || Object.keys(args);
838
+ for (const k of keys) {
839
+ const v = args[k];
840
+ if (typeof v !== 'string' || !v.length) continue;
841
+ let text = v.split('\n')[0];
842
+ const isPath = k === 'path' || k === 'file_path';
843
+ if (isPath && workspace && text.startsWith(workspace)) {
844
+ const rel = text.slice(workspace.length).replace(/^[/\\]+/, '');
845
+ if (rel) text = rel;
846
+ }
847
+ if (text.length > MAX_ARG) {
848
+ text = isPath ? '\u2026' + text.slice(text.length - (MAX_ARG - 1))
849
+ : text.slice(0, MAX_ARG - 1) + '\u2026';
850
+ }
851
+ if (isPath) text = text.replace(/\\/g, '/');
852
+ return text;
853
+ }
854
+ return '';
855
+ }
856
+
857
+ /**
858
+ * The tool NAME's sweep while a call is running — the terminal's own animation,
859
+ * ported rather than approximated.
860
+ *
861
+ * A 2s loop in two 1s colour pairs: base cyan → pale cyan, back, then base cyan
862
+ * → cyan-blue, back. Each character fades across a band ~2-4 characters wide, so
863
+ * the crest moves as a gradient instead of a hard edge. The phase is measured
864
+ * from the tick the pulse STARTED (`pulseStart`), not the raw spinner tick: the
865
+ * spinner has been running since the turn began, so using it directly drops the
866
+ * pulse into a mid-cycle colour the instant the animation hands over.
867
+ */
868
+ const SWEEP = 6;
869
+ const SWEEP_HALF = SWEEP + SWEEP;
870
+ const SWEEP_CYCLE = SWEEP_HALF * 2;
871
+ const CYAN_RGB = [0, 215, 255];
872
+ const PALE_CYAN_RGB = [150, 255, 255];
873
+ const CYAN_BLUE_RGB = [0, 150, 255];
874
+
875
+ function mixRgb(from, to, k) {
876
+ const t = Math.max(0, Math.min(1, k));
877
+ return 'rgb('
878
+ + Math.round(from[0] + (to[0] - from[0]) * t) + ','
879
+ + Math.round(from[1] + (to[1] - from[1]) * t) + ','
880
+ + Math.round(from[2] + (to[2] - from[2]) * t) + ')';
881
+ }
882
+
883
+ function paintToolName(rec, name, pending, tick) {
884
+ const box = rec.nameEl;
885
+ if (!box || !box.isConnected) return;
886
+
887
+ if (!pending) {
888
+ unregisterSweep(rec);
889
+ if (rec.namePending !== false) {
890
+ box.replaceChildren(document.createTextNode(name));
891
+ rec.namePending = false;
892
+ rec.nameText = name;
893
+ } else if (box.textContent !== name) {
894
+ box.textContent = name;
895
+ rec.nameText = name;
896
+ }
897
+ return;
898
+ }
899
+
900
+ // The tick comes from a LOCAL clock (see sweepFrame), not from `status.spin`.
901
+ // The server stopped broadcasting every spin frame (ANIMATION_FIELDS in
902
+ // session-hub.js), so a sweep driven by it only moved when some OTHER field
903
+ // happened to change — it froze for long stretches and then jumped. Worse,
904
+ // pushing per-character gradient values over the wire for every frame is exactly
905
+ // the cost the browser should be paying instead.
906
+ const start = rec.sweepStart == null ? (rec.sweepStart = tick) : rec.sweepStart;
907
+ if (rec.namePending === true && rec.nameTick === tick && rec.nameText === name) return;
908
+ rec.namePending = true;
909
+ rec.nameTick = tick;
910
+ rec.nameText = name;
911
+
912
+ const inCycle = (((tick - start) % SWEEP_CYCLE) + SWEEP_CYCLE) % SWEEP_CYCLE;
913
+ const inHalf = inCycle % SWEEP_HALF;
914
+ const target = inCycle < SWEEP_HALF ? PALE_CYAN_RGB : CYAN_BLUE_RGB;
915
+ const n = name.length || 1;
916
+ const band = Math.max(2, Math.min(4, n / 3)) / n;
917
+
918
+ const frag = document.createDocumentFragment();
919
+ for (let i = 0; i < name.length; i++) {
920
+ const at = i / n;
921
+ const t = inHalf < SWEEP
922
+ ? ((inHalf + 1) / SWEEP - at) / band
923
+ : 1 - ((inHalf - SWEEP + 1) / SWEEP - at) / band;
924
+ const s = document.createElement('span');
925
+ s.style.color = mixRgb(CYAN_RGB, target, t);
926
+ s.textContent = name[i];
927
+ frag.appendChild(s);
928
+ }
929
+ box.replaceChildren(frag);
930
+ }
931
+
932
+ /* ---- pending tool-name sweeps, on their own clock -------------------------
933
+ Every pending tool row registers here; ONE rAF loop repaints them all at the
934
+ terminal's 80ms tick (not 60fps: rebuilding per-character spans faster buys
935
+ nothing visible and burns CPU). A row unregisters the moment it stops pending,
936
+ so an idle page runs no loop at all. */
937
+ const sweeps = new Set();
938
+ let sweepRaf = null;
939
+ let sweepLastTick = -1;
940
+
941
+ function registerSweep(rec) {
942
+ sweeps.add(rec);
943
+ if (sweepRaf === null) sweepRaf = requestAnimationFrame(sweepFrame);
944
+ }
945
+
946
+ function unregisterSweep(rec) {
947
+ rec.sweepStart = null;
948
+ if (!sweeps.delete(rec)) return;
949
+ if (!sweeps.size) stopSweeps();
950
+ }
951
+
952
+ function stopSweeps() {
953
+ if (sweepRaf !== null) { cancelAnimationFrame(sweepRaf); sweepRaf = null; }
954
+ sweepLastTick = -1;
955
+ sweeps.clear();
956
+ }
957
+
958
+ const SWEEP_TICK_MS = 80;
959
+
960
+ function sweepFrame() {
961
+ sweepRaf = null;
962
+ // Drop anything detached (its row was removed or re-rendered).
963
+ for (const rec of [...sweeps]) if (!rec.nameEl || !rec.nameEl.isConnected) sweeps.delete(rec);
964
+ if (!sweeps.size) { sweepLastTick = -1; return; }
965
+ const now = performance.now();
966
+ const tick = Math.floor(now / SWEEP_TICK_MS);
967
+ if (tick !== sweepLastTick) {
968
+ sweepLastTick = tick;
969
+ for (const rec of sweeps) paintToolName(rec, rec.nameText, true, tick);
970
+ }
971
+ sweepRaf = requestAnimationFrame(sweepFrame);
972
+ }
973
+
974
+ function paintToolRow(body, r, rec) {
975
+ const name = r.toolName || r.name || 'tool';
976
+ // The verb IS the state: `Using` while running, `Used` once finished. It is
977
+ // the one thing that tells a reader at a glance whether a call is in flight.
978
+ body.appendChild(el('tool-verb', tr(r.pending ? 'tool.using' : 'tool.used')));
979
+ body.append(' ');
980
+
981
+ // The name gets its own node so the sweep can repaint just its glyphs. The
982
+ // record lives on the NODE, never on the row: the row object is serialized to
983
+ // the server and copied on every patch, so putting DOM references on it would
984
+ // keep the old tree alive after every repaint.
985
+ const nameEl = document.createElement('span');
986
+ nameEl.className = 'tool-name';
987
+ body.appendChild(nameEl);
988
+ // The sweep record lives on the ROW record (`rec`), which survives a repaint; only
989
+ // its `nameEl` is refreshed here. `paintBody` calls `replaceChildren()` before
990
+ // rebuilding, so this node is a NEW element on every patch — keying the state on
991
+ // the node reset the cycle on each frame and left detached records in the set.
992
+ if (!rec._sweep) rec._sweep = { nameEl, namePending: null, nameTick: -1, nameText: '', sweepStart: null };
993
+ rec._sweep.nameEl = nameEl;
994
+ // A pending row joins the local sweep loop; a finished one paints once and leaves
995
+ // it. `sweepStart` is stamped on the first frame, so the cycle begins when the
996
+ // call started rather than at an arbitrary phase.
997
+ if (r.pending) {
998
+ registerSweep(rec._sweep);
999
+ paintToolName(rec._sweep, name, true, Math.floor(performance.now() / SWEEP_TICK_MS));
1000
+ } else {
1001
+ paintToolName(rec._sweep, name, false, 0);
1002
+ }
1003
+
1004
+
1005
+ const arg = keyArgument(name, r.toolArgs, status.cwd);
1006
+ if (arg) body.appendChild(el('tool-arg', ' (' + arg + ')'));
1007
+
1008
+ if (name === 'Write' && typeof r.streamContent === 'string' && r.streamContent.length) {
1009
+ const bytes = r.streamContent.length;
1010
+ const size = bytes >= 1048576 ? (bytes / 1048576).toFixed(1) + 'MB'
1011
+ : bytes >= 1024 ? (bytes / 1024).toFixed(1) + 'KB'
1012
+ : bytes + 'B';
1013
+ body.appendChild(el('tool-size', ' ' + size));
1014
+ }
1015
+
1016
+ // The counts ride the tool line; the diff BODY belongs to the result row. The
1017
+ // terminal shows them in exactly that split, never the diff twice.
1018
+ if (name === 'Edit' && !r.pending) {
1019
+ const d = Array.isArray(r.diff) && r.diff.length ? r.diff : r._diffCounts;
1020
+ if (Array.isArray(d) && d.length) {
1021
+ const adds = d.filter((x) => x.type === 'add').length;
1022
+ const dels = d.filter((x) => x.type === 'del').length;
1023
+ if (adds) body.appendChild(el('tool-add', ' +' + adds));
1024
+ if (dels) body.appendChild(el('tool-del', ' \u2212' + dels));
1025
+ }
1026
+ }
1027
+
1028
+ // Live output of a still-running command, where the terminal puts it: under the
1029
+ // `Using …` row, tail-capped, so a chatty command cannot flood the row.
1030
+ if (r.pending && typeof r.liveOutput === 'string' && r.liveOutput.length) {
1031
+ const raw = r.liveOutput.replace(/\r\n/g, '\n').split('\n');
1032
+ const cap = expandedRows.has(r.id) ? Infinity : 8;
1033
+ body.appendChild(el('tool-live', '\n' + raw.slice(-cap).map((l) => ' ' + l).join('\n')));
1034
+ }
1035
+ }
1036
+
1037
+
1038
+
1039
+ /**
1040
+ * Tool output. The terminal's shape is the whole point of the row:
1041
+ *
1042
+ * ● Used Edit (a.js) +1 -1 <- tool line, counts only
1043
+ * ↳ 179 - old line <- ONE `↳` on the FIRST body row
1044
+ * 179 + new line
1045
+ * Edited a.js: replaced 1 occurrence(s). <- receipt LAST
1046
+ *
1047
+ * An Edit's DIFF is the body and carries the marker, with the receipt moved to
1048
+ * the bottom; every other tool's output IS the body and carries the marker on
1049
+ * its first line.
1050
+ */
1051
+ function paintResultRow(body, r) {
1052
+ const failed = r.failed === true;
1053
+ const text = String(r.text == null ? '' : r.text);
1054
+ const diff = Array.isArray(r.diff) ? r.diff.filter((d) => d.type !== 'ctx') : [];
1055
+
1056
+ if (diff.length) {
1057
+ if (!expandedRows.has(r.id)) {
1058
+ body.appendChild(el('res-fold', tr('mark.result')));
1059
+ body.appendChild(expandButton(r.id, diff.length));
1060
+ return;
1061
+ }
1062
+ const width = Math.max(1, ...diff.map((d) => String(d.no || 0).length));
1063
+ diff.forEach((d, i) => {
1064
+ const no = String(d.no || 0).padStart(width);
1065
+ const mark = d.type === 'add' ? '+' : '-';
1066
+ const prefix = i === 0 ? tr('mark.result') + ' ' : ' ';
1067
+ body.appendChild(el(d.type === 'add' ? 'diff-add' : 'diff-del',
1068
+ prefix + no + ' ' + mark + ' ' + String(d.text) + '\n'));
1069
+ });
1070
+ const receipt = text.trim()
1071
+ .split('\n').filter((ln) => !/^\[exit code:/.test(ln.trim())).join('\n')
1072
+ .trim();
1073
+ if (receipt) body.appendChild(el(failed ? 'diff-del' : 'res-more', receipt));
1074
+ body.appendChild(collapseButton(r.id));
1075
+ return;
1076
+ }
1077
+
1078
+ // A finished result is FOLDED by default (12 lines, like the terminal's
1079
+ // ctrl+o): a whole-file Read is thousands of DOM nodes in ONE row, which
1080
+ // stutters scrolling. The "more" button expands it in place.
1081
+ // `[exit code: N]` is bookkeeping for the model, never shown to the user.
1082
+ const lines = text.replace(/\r\n/g, '\n').split('\n')
1083
+ .filter((ln) => !/^\[exit code:/.test(ln.trim()));
1084
+ while (lines.length && lines[0].trim() === '') lines.shift();
1085
+ while (lines.length && lines[lines.length - 1].trim() === '') lines.pop();
1086
+ if (!lines.length) return;
1087
+
1088
+ if (!expandedRows.has(r.id)) {
1089
+ body.appendChild(el('res-fold', tr('mark.result')));
1090
+ body.appendChild(expandButton(r.id, lines.length));
1091
+ return;
1092
+ }
1093
+ const shown = lines;
1094
+ shown.forEach((ln, i) => {
1095
+ const isErr = /^\[error:/.test(ln.trim());
1096
+ const prefix = i === 0 ? tr('mark.result') + ' ' : ' ';
1097
+ body.appendChild(el(isErr || failed ? 'diff-del' : '', prefix + ln + '\n'));
1098
+ });
1099
+ const hidden = lines.length - shown.length;
1100
+ if (hidden > 0) body.appendChild(moreButton(r.id, hidden));
1101
+ body.appendChild(collapseButton(r.id));
1102
+ }
1103
+
1104
+
1105
+ function expandButton(id, n) {
1106
+ const b = document.createElement('button');
1107
+ b.type = 'button';
1108
+ b.className = 'msg-more';
1109
+ b.textContent = tr('tool.moreLines', { n: Number(n) || 0 });
1110
+ b.addEventListener('click', () => { expandedRows.add(id); repaintRow(id); });
1111
+ return b;
1112
+ }
1113
+
1114
+ function moreButton(id, n) {
1115
+ const b = document.createElement('button');
1116
+ b.type = 'button';
1117
+ b.className = 'msg-more';
1118
+ b.textContent = tr('tool.moreLines', { n });
1119
+ b.addEventListener('click', () => { expandedRows.add(id); repaintRow(id); });
1120
+ return b;
1121
+ }
1122
+
1123
+ /** A "collapse" control shown once a foldable row is expanded, so a long
1124
+ thinking / result block can be folded back without a reload. */
1125
+ function collapseButton(id) {
1126
+ const b = document.createElement('button');
1127
+ b.type = 'button';
1128
+ b.className = 'msg-more';
1129
+ b.textContent = tr('tool.collapse');
1130
+ b.addEventListener('click', () => { expandedRows.delete(id); repaintRow(id); });
1131
+ return b;
1132
+ }
1133
+
1134
+ /** Compaction, matching the terminal: `● Compacting context…` / `● Compaction
1135
+ complete (X → Y tokens)` / `● Compaction cancelled`. The marker carries the
1136
+ phase colour, so it is part of the line rather than a separate column. */
1137
+ function paintCompactionRow(body, r) {
1138
+ const done = r.phase === 'done';
1139
+ const cancelled = r.phase === 'cancelled';
1140
+ const label = done ? tr('tool.compacted')
1141
+ : cancelled ? tr('tool.compactCancelled')
1142
+ : tr('tool.compacting');
1143
+ let line = tr('mark.tool') + ' ' + label;
1144
+ if (done && r.tokensBefore != null && r.tokensAfter != null) {
1145
+ line += ' (' + fmtNum(r.tokensBefore) + ' \u2192 ' + fmtNum(r.tokensAfter) + ' tokens)';
1146
+ }
1147
+ body.appendChild(document.createTextNode(line));
1148
+ if (r.instruction) body.appendChild(document.createTextNode('\n ' + String(r.instruction)));
1149
+ }
1150
+
1151
+ /** Background-task lifecycle card: `● agent task completed in background`, the
1152
+ conclusion indented underneath and capped like a tool result so one chatty
1153
+ subagent cannot flood the transcript. */
1154
+ function paintBgTaskRow(body, r) {
1155
+ const card = r.card || { phase: 'completed', headline: '' };
1156
+ body.appendChild(document.createTextNode(
1157
+ tr(card.phase === 'failed' ? 'mark.bgTaskFailed' : 'mark.bgTask') + ' ' + (card.headline || '')));
1158
+ if (card.detail) body.appendChild(el('res-more', ' (' + card.detail + ')'));
1159
+ const t = String(r.text || '').trim();
1160
+ if (!t) return;
1161
+ const lines = t.split('\n');
1162
+ const cap = expandedRows.has(r.id) ? 200 : 6;
1163
+ body.appendChild(document.createTextNode('\n ' + lines.slice(0, cap).join('\n ')));
1164
+ if (lines.length > cap) body.appendChild(el('res-more', '\n \u2026 ' + (lines.length - cap)));
1165
+ }
1166
+
1167
+
1168
+ /**
1169
+ * Reasoning block, matching the terminal:
1170
+ * live -> "<spinner> thinking…" then the LAST 2 content lines, indented
1171
+ * finalized -> `●` inline on the first line, up to 2 preview lines, then a
1172
+ * "… (N more lines)" hint
1173
+ * All of it dim, body italic. The marker is part of the text (prefix `● `,
1174
+ * continuations indented two), exactly as `messageLines` builds it.
1175
+ */
1176
+ function paintThinkingRow(body, r, text) {
1177
+ const lines = text ? text.replace(/\r\n/g, '\n').split('\n') : [];
1178
+ const cap = expandedRows.has(r.id) ? Infinity : THINK_PREVIEW;
1179
+
1180
+ if (r.pending) {
1181
+ // The live form shows a spinner label instead of the `●`, and previews the
1182
+ // TAIL of the reasoning rather than its head.
1183
+ const label = document.createElement('div');
1184
+ label.className = 'think-live';
1185
+ label.textContent = tr('chat.thinking');
1186
+ body.appendChild(label);
1187
+ // Live: show the newest lines (up to THINK_PREVIEW), replaced in place as the
1188
+ // model's reasoning grows — matching the terminal's `contentLines.slice(-PREVIEW)`.
1189
+ const tail = lines.slice(-THINK_PREVIEW);
1190
+ for (const ln of tail) body.appendChild(document.createTextNode(' ' + ln + '\n'));
1191
+ return;
1192
+ }
1193
+
1194
+ const shown = lines.slice(0, cap);
1195
+ shown.forEach((ln, i) => {
1196
+ body.appendChild(document.createTextNode(
1197
+ (i === 0 ? tr('mark.thinking') + ' ' : ' ') + ln + '\n'));
1198
+ });
1199
+ const hidden = lines.length - shown.length;
1200
+ if (hidden > 0) body.appendChild(moreButton(r.id, hidden));
1201
+ else if (expandedRows.has(r.id) && lines.length > 1) body.appendChild(collapseButton(r.id));
1202
+ }
1203
+
1204
+
1205
+ const FENCE_RE = /^[ \t]*```+\s*(\S*)/;
1206
+ const INLINE_RE = /(`[^`]+`|\*\*[^*]+\*\*|\*[^*\s][^*]*?\*|~~[^~]+~~|\[[^\]]+\]\([^)\s]+\)|(^|\s)https?:\/\/[^\s]+)/g;
1207
+
1208
+ /** Markdown as the terminal renders it: a fenced block becomes its own box with
1209
+ the language on a rule, `#` lines are emphasised, and inline `code` / **bold**
1210
+ / [links](url) keep their form. Everything else stays verbatim — a tool
1211
+ result or a shell line must never be reinterpreted as prose. */
1212
+ function appendMarkdown(wrap, text) {
1213
+ const lines = String(text == null ? '' : text).split('\n');
1214
+ let plain = [];
1215
+ let code = null;
1216
+ let lang = '';
1217
+
1218
+ const flushPlain = () => {
1219
+ if (!plain.length) return;
1220
+ const chunk = plain;
1221
+ plain = [];
1222
+ // Split the buffered lines into block groups: lists, tables, quotes, hr,
1223
+ // headings, and paragraphs. Each is appended as its own container.
1224
+ let i = 0;
1225
+ const isTableSep = (l) => /^\s*\|?\s*:?-{2,}.*\|/.test(l) || /^\s*\|[-: ]+\|/.test(l);
1226
+ while (i < chunk.length) {
1227
+ const raw = chunk[i];
1228
+ const t = raw.replace(/\s+$/, '');
1229
+ // table: header line, then a separator line
1230
+ if (t.includes('|') && i + 1 < chunk.length && isTableSep(chunk[i + 1])) {
1231
+ const header = t.split('|').map((c) => c.trim()).filter((c, j, a) => !(c === '' && (j === 0 || j === a.length - 1)));
1232
+ const body = [];
1233
+ i += 2;
1234
+ while (i < chunk.length && chunk[i].trim().includes('|') && !isTableSep(chunk[i])) {
1235
+ const cells = chunk[i].split('|').map((c) => c.trim()).filter((c, j, a) => !(c === '' && (j === 0 || j === a.length - 1)));
1236
+ body.push(cells);
1237
+ i++;
1238
+ }
1239
+ wrap.appendChild(buildTable(header, body));
1240
+ continue;
1241
+ }
1242
+ // hr
1243
+ if (/^\s*(-{3,}|\*{3,}|_{3,})\s*$/.test(t)) { wrap.appendChild(el('md-hr', '')); i++; continue; }
1244
+ // quote: one or more consecutive `>` lines
1245
+ if (/^\s*>\s?/.test(t)) {
1246
+ const q = [];
1247
+ while (i < chunk.length && /^\s*>\s?/.test(chunk[i])) {
1248
+ q.push(chunk[i].replace(/^\s*>\s?/, ''));
1249
+ i++;
1250
+ }
1251
+ const box = document.createElement('div');
1252
+ box.className = 'md-quote';
1253
+ q.forEach((ql, j) => { if (j) box.appendChild(document.createTextNode('\n')); box.appendChild(inlineParts(ql)); });
1254
+ wrap.appendChild(box);
1255
+ continue;
1256
+ }
1257
+ // task checkbox
1258
+ const task = /^(\s*)([-*+]\s+)?\[( |x|X)\]\s+(.*)$/.exec(t);
1259
+ if (task) {
1260
+ const row = document.createElement('div');
1261
+ row.className = 'md-task';
1262
+ const box = el('md-check', task[3] !== ' ' ? '\u2713' : '\u25cb');
1263
+ row.appendChild(box);
1264
+ row.appendChild(inlineParts(task[4]));
1265
+ wrap.appendChild(row);
1266
+ i++;
1267
+ continue;
1268
+ }
1269
+ // list item, ordered or unordered
1270
+ const li = /^(\s*)([-*+]|\d+[.)])\s+(.*)$/.exec(t);
1271
+ if (li) {
1272
+ const list = document.createElement('div');
1273
+ list.className = /^\d/.test(li[2]) ? 'md-list-ol' : 'md-list-ul';
1274
+ while (i < chunk.length) {
1275
+ const m = /^(\s*)([-*+]|\d+[.)])\s+(.*)$/.exec(chunk[i].replace(/\s+$/, ''));
1276
+ if (!m || (/^\d/.test(m[2]))) {
1277
+ if (!m) break;
1278
+ }
1279
+ const isO = /^\d/.test(m[2]);
1280
+ if (isO !== /^\d/.test(li[2])) break;
1281
+ const item = document.createElement('div');
1282
+ item.className = 'md-li';
1283
+ const bullet = isO ? m[2] : '\u2022';
1284
+ item.appendChild(el('md-bullet', bullet));
1285
+ item.appendChild(inlineParts(m[3]));
1286
+ list.appendChild(item);
1287
+ i++;
1288
+ }
1289
+ wrap.appendChild(list);
1290
+ continue;
1291
+ }
1292
+ // heading
1293
+ const head = /^\s*#{1,6}\s+(.*)$/.exec(t);
1294
+ if (head) { wrap.appendChild(el('md-h', head[1])); i++; continue; }
1295
+ // paragraph / plain
1296
+ const paraNode = document.createElement('div');
1297
+ paraNode.className = 'md-p';
1298
+ paraNode.appendChild(inlineParts(t));
1299
+ wrap.appendChild(paraNode);
1300
+ i++;
1301
+ }
1302
+ };
1303
+
1304
+ const flushCode = () => {
1305
+ const box = document.createElement('div');
1306
+ box.className = 'code-block';
1307
+ if (lang) {
1308
+ const bar = document.createElement('div');
1309
+ bar.className = 'code-bar';
1310
+ bar.textContent = lang;
1311
+ box.appendChild(bar);
1312
+ }
1313
+ const pre = document.createElement('pre');
1314
+ pre.className = 'code-body';
1315
+ pre.textContent = code.join('\n');
1316
+ box.appendChild(pre);
1317
+ wrap.appendChild(box);
1318
+ code = null;
1319
+ lang = '';
1320
+ };
1321
+
1322
+ for (const line of lines) {
1323
+ const fence = FENCE_RE.exec(line);
1324
+ if (fence) {
1325
+ if (code === null) { flushPlain(); code = []; lang = fence[1] || ''; }
1326
+ else flushCode();
1327
+ continue;
1328
+ }
1329
+ if (code === null) plain.push(line);
1330
+ else code.push(line);
1331
+ }
1332
+ flushPlain();
1333
+ if (code !== null && code.length) flushCode();
1334
+ }
1335
+
1336
+ /** Render a Markdown table as a grid. Header is the first row (bold). */
1337
+ function buildTable(header, body) {
1338
+ const table = document.createElement('div');
1339
+ table.className = 'md-table';
1340
+ const h = document.createElement('div');
1341
+ h.className = 'md-tr';
1342
+ for (const c of header) { const cell = el('md-th', c); h.appendChild(cell); }
1343
+ table.appendChild(h);
1344
+ for (const row of body) {
1345
+ const r = document.createElement('div');
1346
+ r.className = 'md-tr';
1347
+ for (const c of row) r.appendChild(el('md-td', c));
1348
+ table.appendChild(r);
1349
+ }
1350
+ return table;
1351
+ }
1352
+
1353
+ function inlineParts(line) {
1354
+ if (!line) return document.createTextNode('');
1355
+ const frag = document.createDocumentFragment();
1356
+ let at = 0;
1357
+ for (const m of line.matchAll(INLINE_RE)) {
1358
+ if (m.index > at) frag.appendChild(document.createTextNode(line.slice(at, m.index)));
1359
+ const tok = m[0];
1360
+ if (tok[0] === '`') frag.appendChild(el('md-code', tok.slice(1, -1)));
1361
+ else if (tok.startsWith('**')) frag.appendChild(el('md-b', tok.slice(2, -2)));
1362
+ else if (tok.startsWith('~~')) frag.appendChild(el('md-s', tok.slice(2, -2)));
1363
+ else if (tok.startsWith('*')) frag.appendChild(el('md-i', tok.slice(1, -1)));
1364
+ else if (tok.startsWith('[')) {
1365
+ const label = tok.slice(1, tok.indexOf(']'));
1366
+ const href = tok.slice(tok.indexOf('(') + 1, -1);
1367
+ const a = document.createElement('a');
1368
+ a.className = 'md-link';
1369
+ a.href = href;
1370
+ a.target = '_blank';
1371
+ a.rel = 'noreferrer noopener';
1372
+ a.textContent = label;
1373
+ frag.appendChild(a);
1374
+ } else {
1375
+ // Bare URL, possibly left-trimmed to the leading whitespace captured.
1376
+ const url = tok.trim();
1377
+ const a = document.createElement('a');
1378
+ a.className = 'md-link';
1379
+ a.href = url;
1380
+ a.target = '_blank';
1381
+ a.rel = 'noreferrer noopener';
1382
+ a.textContent = url;
1383
+ if (tok[0].match(/\s/)) frag.appendChild(document.createTextNode(' '));
1384
+ frag.appendChild(a);
1385
+ }
1386
+ at = m.index + tok.length;
1387
+ }
1388
+ if (at < line.length) frag.appendChild(document.createTextNode(line.slice(at)));
1389
+ return frag;
1390
+ }
1391
+
1392
+ function summarizeArgs(args) {
1393
+ if (!args || typeof args !== 'object') return '';
1394
+ const keys = ['command', 'cmd', 'path', 'file_path', 'pattern', 'query', 'url', 'description', 'task_id'];
1395
+ for (const k of keys) {
1396
+ const v = args[k];
1397
+ if (typeof v === 'string' && v) return v.split('\n')[0].slice(0, 160);
1398
+ }
1399
+ return '';
1400
+ }
1401
+
1402
+ function fmtNum(n) {
1403
+ n = Number(n) || 0;
1404
+ const trim = (v) => {
1405
+ const s = v.toFixed(1);
1406
+ return s.endsWith('.0') ? s.slice(0, -2) : s;
1407
+ };
1408
+ if (n >= 1048576) return trim(n / 1048576) + 'M';
1409
+ if (n >= 1024) return (n >= 102400 ? Math.round(n / 1024) : trim(n / 1024)) + 'k';
1410
+ return String(n);
1411
+ }
1412
+
1413
+ // ------------------------------------------------------- status and panels
1414
+
1415
+ // A hidden tab pane is skipped by renderStatus; VIEW_IDS tracks which one is
1416
+ // visible so switching a tab back on rebuilds it once from the latest status.
1417
+ const dirtyViews = new Set();
1418
+ function isViewHidden(name) {
1419
+ const sec = document.getElementById("view-" + name);
1420
+ if (!sec) return false;
1421
+ if (!sec.hidden) return false;
1422
+ dirtyViews.add(name);
1423
+ return true;
1424
+ }
1425
+
1426
+ function renderStatus() {
1427
+ const s = status || {};
1428
+
1429
+ $('sess-title').textContent = s.title || tr('chat.untitled');
1430
+ const sessId = String(s.session || '');
1431
+ const idEl = $('sess-id');
1432
+ idEl.textContent = sessId;
1433
+ const oldCopy = $('sess-id-copy');
1434
+ if (oldCopy) oldCopy.remove();
1435
+ if (sessId) {
1436
+ const cb = copyButton(sessId, tr('copy.copy'));
1437
+ cb.id = 'sess-id-copy';
1438
+ idEl.after(cb);
1439
+ }
1440
+
1441
+ // Send and Interrupt share one edge of the composer: while a turn runs, the
1442
+ // only useful action there is to stop it, so Send yields to Stop instead of
1443
+ // sitting beside a button that would just queue another message.
1444
+ setPanelVisible('btn-interrupt', !!s.busy);
1445
+ setPanelVisible('send', !s.busy);
1446
+
1447
+
1448
+ setPanelVisible('todo-panel', (s.todos || []).length > 0);
1449
+ setPanelVisible('queue-panel', (s.queued || []).length > 0);
1450
+ setPanelVisible('working', !!s.busy);
1451
+
1452
+ renderTodoPanel(s.todos || []);
1453
+ renderQueuePanel(s.queued || [], !!s.busy);
1454
+ // Hidden tabs keep stale DOM — their pane is display:none, nobody sees it,
1455
+ // and rebuilding hundreds of cards per status broadcast was pure waste.
1456
+ // setupViews() marks a pane "dirty" when its view becomes visible.
1457
+ if (!isViewHidden("tasks")) renderTasks(s.tasks || []);
1458
+ if (!isViewHidden("files")) renderFiles(s.files || []);
1459
+ if (!isViewHidden("tools")) renderTools(s.tools || []);
1460
+ if (!isViewHidden("commands")) renderCommands(s.commands || []);
1461
+ renderTabCounts(s);
1462
+ applyModal(s);
1463
+ renderStatusLine(s);
1464
+ }
1465
+
1466
+ /** Flip only the composer's Send/Stop state when `busy` changed — called from
1467
+ applyStatus on an animation-only broadcast, so a run that never changes a
1468
+ stable field still swaps the button without touching the panes. */
1469
+ function renderBusyButton() {
1470
+ const busy = !!(status && status.busy);
1471
+ setPanelVisible('btn-interrupt', busy);
1472
+ setPanelVisible('send', !busy);
1473
+ setPanelVisible('working', busy);
1474
+ }
1475
+
1476
+ function setPanelVisible(id, on) {
1477
+ const node = $(id);
1478
+ if (node) node.hidden = !on;
1479
+ }
1480
+
1481
+ /**
1482
+ * One row, grouped the way the terminal's footer groups it: which mode I am in,
1483
+ * which model, where I am, what git says, and the usage readout pinned right.
1484
+ * Every part is a fact about the running session — nothing here is decoration.
1485
+ */
1486
+ function renderStatusLine(s) {
1487
+ const line = $('statusline');
1488
+ line.replaceChildren();
1489
+ const o = s.options || {};
1490
+
1491
+ line.appendChild(popoverButton('sl-mode', modeLabel(s.mode), 'side.mode', () => modeItems(o)));
1492
+ const badge = s.plan ? 'Plan' : s.focus ? 'Focus' : s.swarm ? 'Swarm' : '';
1493
+ if (badge) line.appendChild(el('sl-badge', badge));
1494
+
1495
+ if (s.model) {
1496
+ const label = s.model + (s.reasoning
1497
+ ? (s.effort && s.effort !== 'on' ? ' thinking: ' + s.effort : ' thinking')
1498
+ : '');
1499
+ line.appendChild(popoverButton('sl-model', label, 'side.model', () => modelItems(o)));
1500
+ }
1501
+
1502
+ const running = (s.tasks || []).filter((t) => t.status === 'running');
1503
+ const bash = running.filter((t) => t.kind !== 'agent').length;
1504
+ const agents = running.filter((t) => t.kind === 'agent').length;
1505
+ if (bash) line.appendChild(el('sl-tasks-bash', tr('status.tasksBash', { n: bash })));
1506
+ if (agents) line.appendChild(el('sl-tasks-agent', tr('status.tasksAgent', { n: agents })));
1507
+
1508
+ if (s.cwd) line.appendChild(el('sl-cwd', s.cwd));
1509
+
1510
+ const gi = s.git;
1511
+ if (gi && gi.branch) {
1512
+ line.appendChild(el('sl-branch', gi.branch));
1513
+ if (gi.insertions) line.appendChild(el('sl-add', '+' + gi.insertions));
1514
+ if (gi.deletions) line.appendChild(el('sl-del', '\u2212' + gi.deletions));
1515
+ }
1516
+
1517
+ line.appendChild(el('sl-spacer', ''));
1518
+
1519
+ // The usage readout is the terminal's own string (`3 turns | 7 steps | 42
1520
+ // tok/s`), and the terminal does not translate it. Neither does this: one
1521
+ // person reading both screens must not see two different sentences.
1522
+ line.appendChild(el('sl-usage', tr('status.usage', {
1523
+ turns: Number(s.rounds || 0),
1524
+ steps: Number(s.steps || 0),
1525
+ rate: Math.round(Number(s.tokRate || 0)),
1526
+ })));
1527
+ line.appendChild(el('sl-ctx', tr('status.context', {
1528
+ pct: Number(s.ctxPercent || 0),
1529
+ used: fmtNum(s.ctxTokens || 0),
1530
+ max: fmtNum(s.ctxMax || 1),
1531
+ })));
1532
+ }
1533
+
1534
+
1535
+ function popoverButton(cls, label, titleKey, buildItems) {
1536
+ const b = document.createElement('button');
1537
+ b.type = 'button';
1538
+ b.className = cls;
1539
+ b.textContent = label;
1540
+ b.title = tr('popover.click');
1541
+ b.addEventListener('click', (ev) => {
1542
+ ev.preventDefault();
1543
+ openPopover(b, tr(titleKey), buildItems(), cls);
1544
+ });
1545
+ return b;
1546
+ }
1547
+
1548
+ function modelItems(o) {
1549
+ const items = [];
1550
+ for (const m of (o.model && o.model.items) || []) {
1551
+ items.push({
1552
+ label: m.label || m.value,
1553
+ sub: m.sub || '',
1554
+ on: m.value === (o.model && o.model.value),
1555
+ cmd: '/model',
1556
+ arg: m.value,
1557
+ // Each model's own thinking levels, sent by the server. Null when the
1558
+ // model has no Thinking control. The effort popover renders these on the
1559
+ // right of the "Thinking Effort" row when the pointer sits on it.
1560
+ efforts: (m.efforts && m.efforts.map((e) => ({
1561
+ label: e.label || e.value,
1562
+ on: e.value === (o.effort && o.effort.value),
1563
+ cmd: '/effort',
1564
+ arg: e.value,
1565
+ }))) || null,
1566
+ });
1567
+ }
1568
+ return items;
1569
+ }
1570
+
1571
+ function modeItems(o) {
1572
+ return ((o.mode && o.mode.items) || []).map((m) => ({
1573
+ label: m.label || m.value,
1574
+ sub: m.sub || '',
1575
+ on: m.value === (o.mode && o.mode.value),
1576
+ cmd: '/permission',
1577
+ arg: m.value,
1578
+ }));
1579
+ }
1580
+
1581
+ function modeLabel(mode) {
1582
+ const m = String(mode || '').toLowerCase();
1583
+ if (m === 'auto') return 'Auto';
1584
+ if (m === 'yolo') return 'Yolo';
1585
+ if (m === 'ask') return 'Ask';
1586
+ return mode ? String(mode) : '\u2014';
1587
+ }
1588
+
1589
+ let popoverOpen = null;
1590
+
1591
+ function openPopover(anchor, title, items, kind) {
1592
+ const box = $('popover');
1593
+ const list = $('popover-list');
1594
+ if (!box || !list) return;
1595
+ $('popover-title').textContent = title;
1596
+ list.replaceChildren();
1597
+
1598
+ if (!items.length) {
1599
+ list.appendChild(el('popover-item', tr('side.none')));
1600
+ box.hidden = false; positionPopover(anchor, box);
1601
+ return;
1602
+ }
1603
+
1604
+ // A model popover is two stacked sections, exactly as the user asked:
1605
+ // Model A
1606
+ // Model B <- pick a model (its own levels follow)
1607
+ // Model C
1608
+ // ──────────────
1609
+ // Thinking Effort > on <- hover the row to reveal its level list
1610
+ const isModelPopover = kind === 'sl-model';
1611
+ box.dataset.modelPopover = isModelPopover ? 'yes' : 'no';
1612
+ if (isModelPopover) {
1613
+ renderModelPopover(list, items);
1614
+ } else {
1615
+ for (const it of items) list.appendChild(buildPopoverItem(it));
1616
+ }
1617
+
1618
+ box.hidden = false;
1619
+ positionPopover(anchor, box);
1620
+ popoverOpen = kind;
1621
+
1622
+ // The listener is registered on the NEXT frame's task, not in a microtask: the
1623
+ // click that opened the popover is still propagating up to `document`, so a
1624
+ // listener added synchronously here would receive that same click, see a target
1625
+ // outside the popover, and close it again — the menu appeared and vanished in
1626
+ // one gesture, which is why the buttons looked dead.
1627
+ setTimeout(() => {
1628
+ document.addEventListener('mousedown', onDocDown);
1629
+ document.addEventListener('keydown', onDocKey);
1630
+ }, 0);
1631
+ }
1632
+
1633
+ /**
1634
+ * Position the popover ABOVE the anchor — the status line sits at the bottom of
1635
+ * the screen, so a menu that appears below the button runs into the floor.
1636
+ * `gap` is kept small (a few px) so the menu reads as attached to what it opens.
1637
+ */
1638
+ function positionPopover(anchor, box) {
1639
+ const r = anchor.getBoundingClientRect();
1640
+ box.style.left = Math.max(8, Math.min(r.left, window.innerWidth - box.offsetWidth - 8)) + 'px';
1641
+ box.style.top = Math.max(8, r.top - box.offsetHeight - 8) + 'px';
1642
+ }
1643
+
1644
+ /** Position the floating level list just to the RIGHT of the effort row, flush
1645
+ with its top. */
1646
+ function positionFlyout(flyout, anchor) {
1647
+ const r = anchor.getBoundingClientRect();
1648
+ flyout.style.left = Math.max(4, r.right + 4) + 'px';
1649
+ flyout.style.top = Math.max(4, r.top - 2) + 'px';
1650
+ }
1651
+
1652
+ function buildPopoverItem(it) {
1653
+ const b = document.createElement('button');
1654
+ b.type = 'button';
1655
+ b.className = 'popover-item' + (it.on ? ' is-on' : '');
1656
+ b.appendChild(el('', it.label));
1657
+ if (it.sub) b.appendChild(el('sub', it.sub));
1658
+ b.addEventListener('click', () => {
1659
+ closePopover();
1660
+ void act('dispatch', [it.cmd, it.arg]);
1661
+ });
1662
+ return b;
1663
+ }
1664
+
1665
+ /** Model + effort popover, three stacked blocks:
1666
+ 1. the model list (its own scroll)
1667
+ 2. a hairline divider
1668
+ 3. a "Thinking Effort" row showing the current level
1669
+ Hovering the effort row floats the level list out to its RIGHT, as a separate
1670
+ panel — it does not push the main layout. Hovering a model on top shows that
1671
+ model's levels instead. */
1672
+ function renderModelPopover(list, items) {
1673
+ const currentEff = items.find((m) => m.on) || items[0];
1674
+ const currentEfforts = (currentEff && currentEff.efforts) || null;
1675
+
1676
+ // 1) model list.
1677
+ const modelBox = document.createElement('div');
1678
+ modelBox.className = 'popover-section';
1679
+ for (const m of items) {
1680
+ const it = document.createElement('button');
1681
+ it.type = 'button';
1682
+ it.className = 'popover-item' + (m.on ? ' is-on' : '');
1683
+ it.appendChild(el('', m.label));
1684
+ if (m.sub) it.appendChild(el('sub', m.sub));
1685
+ it.addEventListener('click', () => {
1686
+ closePopover();
1687
+ void act('dispatch', [m.cmd, m.arg]);
1688
+ });
1689
+ it._efforts = m.efforts;
1690
+ it._isCurrent = !!m.on;
1691
+ modelBox.appendChild(it);
1692
+ }
1693
+ list.appendChild(modelBox);
1694
+
1695
+ // 2) divider.
1696
+ list.appendChild(el('popover-rule', ''));
1697
+
1698
+ // 3) effort row.
1699
+ const effRow = document.createElement('div');
1700
+ effRow.className = 'popover-effort';
1701
+ effRow.appendChild(el('', tr('popover.effortLabel')));
1702
+ const effValue = document.createElement('span');
1703
+ effValue.className = 'popover-effort-val' + (currentEfforts ? ' has-levels' : '');
1704
+ const curLevel = currentEfforts && currentEfforts.filter((e) => e.on).map((e) => e.label)[0];
1705
+ effValue.textContent = curLevel || (currentEfforts ? tr('popover.off') : tr('popover.unknown'));
1706
+ effRow.appendChild(effValue);
1707
+ list.appendChild(effRow);
1708
+
1709
+ if (!currentEfforts) return;
1710
+
1711
+ // The floating level list, positioned to the RIGHT of the effort row. It is a
1712
+ // sibling of the main popover so it can break out beside it rather than
1713
+ // reflowing the stack.
1714
+ const effHost = $('popover');
1715
+ const flyout = document.createElement('div');
1716
+ flyout.className = 'popover-flyout';
1717
+ flyout.hidden = true;
1718
+ document.body.appendChild(flyout);
1719
+
1720
+ const setLevels = (efforts, isCurrent) => {
1721
+ flyout.replaceChildren();
1722
+ if (!efforts || !efforts.length) {
1723
+ flyout.appendChild(el('popover-item', tr('popover.unknown')));
1724
+ return;
1725
+ }
1726
+ for (const e of efforts) flyout.appendChild(buildPopoverItem({ ...e, on: e.on && isCurrent }));
1727
+ positionFlyout(flyout, effRow);
1728
+ flyout.hidden = false;
1729
+ };
1730
+
1731
+ const hideFlyout = () => { flyout.hidden = true; };
1732
+
1733
+ const clearHover = () => {
1734
+ for (const o of list.querySelectorAll('.popover-item.is-hover')) o.classList.remove('is-hover');
1735
+ };
1736
+
1737
+ // hovering a model row hides any open effort flyout — the flyout belongs to the
1738
+ // Thinking Effort row, so moving to the model list dismisses it.
1739
+ for (const it of modelBox.children) {
1740
+ it.addEventListener('mouseenter', () => {
1741
+ clearHover();
1742
+ it.classList.add('is-hover');
1743
+ hideFlyout();
1744
+ });
1745
+ it.addEventListener('mouseleave', () => { it.classList.remove('is-hover'); });
1746
+ }
1747
+
1748
+ // hovering the effort row floats the CURRENT model's levels.
1749
+ effRow.addEventListener('mouseenter', () => {
1750
+ clearHover();
1751
+ setLevels(currentEfforts, true);
1752
+ });
1753
+ effRow.addEventListener('mouseleave', () => { /* keep flyout until mouse leaves it */ });
1754
+
1755
+ // Keep the flyout visible only while the pointer is over it or the popover.
1756
+ // (flyoutWatcher, registered once below, does that.)
1757
+ }
1758
+
1759
+ /** Last pointer position, used by flyoutWatcher. */
1760
+ let flyoutX = -1, flyoutY = -1;
1761
+
1762
+ /** Hide the effort flyout whenever the pointer leaves both the popover and the
1763
+ flyout itself. Runs for every mousemove; the check is cheap. */
1764
+ function flyoutWatcher(ev) {
1765
+ flyoutX = ev.clientX;
1766
+ flyoutY = ev.clientY;
1767
+ const f = document.querySelector('.popover-flyout');
1768
+ if (!f || f.hidden) return;
1769
+ const box = document.getElementById('popover');
1770
+ const inBox = box && !box.hidden && insideRect(box, flyoutX, flyoutY);
1771
+ const inFly = insideRect(f, flyoutX, flyoutY);
1772
+ if (!inBox && !inFly) f.hidden = true;
1773
+ }
1774
+
1775
+ /** True when (x, y) lies within the element's box. */
1776
+ function insideRect(el, x, y) {
1777
+ const r = el.getBoundingClientRect();
1778
+ return x >= r.left && x <= r.right && y >= r.top && y <= r.bottom;
1779
+ }
1780
+
1781
+ // Registered ONCE (module scope), not per popover open — the flyout is looked up
1782
+ // each move, so one listener serves every open/close cycle.
1783
+ if (typeof document !== 'undefined') document.addEventListener('mousemove', flyoutWatcher);
1784
+
1785
+ function onDocDown(ev) {
1786
+ const box = $('popover');
1787
+ if (box && box.contains(ev.target)) return;
1788
+ closePopover();
1789
+ }
1790
+
1791
+ function onDocKey(ev) {
1792
+ if (ev.key === 'Escape') closePopover();
1793
+ }
1794
+
1795
+ function closePopover() {
1796
+ const box = $('popover');
1797
+ if (box) box.hidden = true;
1798
+ popoverOpen = null;
1799
+ // The floating level list is appended to <body>, so it must be removed here or
1800
+ // it would linger after the popover closes.
1801
+ for (const f of document.querySelectorAll('.popover-flyout')) f.remove();
1802
+ document.removeEventListener('mousedown', onDocDown);
1803
+ document.removeEventListener('keydown', onDocKey);
1804
+ }
1805
+
1806
+ const SPINNER = ['\u280b', '\u2819', '\u2839', '\u2838', '\u283c', '\u2834', '\u2826', '\u2827', '\u2807', '\u280f'];
1807
+ const ORANGE_RGB = [255, 140, 0];
1808
+ const WORK_YELLOW_RGB = [255, 240, 120];
1809
+ const WORK_RED_RGB = [255, 0, 0];
1810
+ /** Animation clock and phrase for the Working pulse. Kept at module scope so the
1811
+ rAF loop can stop itself and so `applyStatus` can restart it without a fresh
1812
+ capture each time. */
1813
+ let pulseRaf = null;
1814
+ let pulseMsg = '';
1815
+
1816
+ /**
1817
+ * The Working row's pulse, driven by its OWN rAF loop, not by the server's `spin`
1818
+ * counter. The server no longer broadcasts every spin frame (see
1819
+ * ANIMATION_FIELDS in session-hub.js); even when it does, redrawing the row from
1820
+ * a remote tick lets the two drift. The sweep keeps the terminal's orange→yellow→
1821
+ * red cycle, advanced by elapsed TIME so it stays smooth.
1822
+ */
1823
+ function drawPulse() {
1824
+ pulseMsg = String((status && status.workMsg) || '');
1825
+ if (pulseRaf === null) pulseRaf = requestAnimationFrame(pulseFrame);
1826
+ }
1827
+
1828
+ /* The pulse advances at the TERMINAL's tick — 80ms per spinner frame — not at
1829
+ display refresh rate: rebuilding the row's per-character spans at 60fps buys
1830
+ nothing the eye can see and burns real CPU. */
1831
+ const PULSE_TICK_MS = 80;
1832
+ let pulseLastTick = -1;
1833
+
1834
+ function pulseFrame() {
1835
+ pulseRaf = null;
1836
+ if (!status || !status.busy) { stopPulse(); return; }
1837
+ const box = $('working-text');
1838
+ const now = performance.now();
1839
+ if (box && box.isConnected) {
1840
+ const tick = Math.floor(now / PULSE_TICK_MS);
1841
+ if (tick !== pulseLastTick) { paintPulseFrame(box, now); pulseLastTick = tick; }
1842
+ }
1843
+ pulseRaf = requestAnimationFrame(pulseFrame);
1844
+ }
1845
+
1846
+ function clearPulse() {
1847
+ stopPulse();
1848
+ const box = $('working-text');
1849
+ if (box) box.replaceChildren();
1850
+ }
1851
+
1852
+ function stopPulse() {
1853
+ if (pulseRaf !== null) { cancelAnimationFrame(pulseRaf); pulseRaf = null; }
1854
+ pulseMsg = '';
1855
+ pulseLastTick = -1;
1856
+ }
1857
+
1858
+ function paintPulseFrame(box, now) {
1859
+ const msg = pulseMsg;
1860
+ if (!msg) { box.replaceChildren(); return; }
1861
+ // The sweep advances on time: 6 ticks ≈ 0.5s, so a 24-tick cycle is ~2s.
1862
+ const tick = Math.floor(now / 80);
1863
+ const inCycle = tick % SWEEP_CYCLE;
1864
+ const inHalf = inCycle % SWEEP_HALF;
1865
+ const target = inCycle < SWEEP_HALF ? WORK_YELLOW_RGB : WORK_RED_RGB;
1866
+ const n = msg.length;
1867
+ const band = Math.max(2, Math.min(4, n / 3)) / n;
1868
+
1869
+ const frag = document.createDocumentFragment();
1870
+ const spin = document.createElement('span');
1871
+ spin.className = 'spin';
1872
+ spin.textContent = SPINNER[tick % SPINNER.length];
1873
+ frag.appendChild(spin);
1874
+
1875
+ for (let i = 0; i < n; i++) {
1876
+ const at = i / n;
1877
+ const t = inHalf < SWEEP
1878
+ ? ((inHalf + 1) / SWEEP - at) / band
1879
+ : 1 - ((inHalf - SWEEP + 1) / SWEEP - at) / band;
1880
+ const s = document.createElement('span');
1881
+ s.style.color = mixRgb(ORANGE_RGB, target, t);
1882
+ s.textContent = msg[i];
1883
+ frag.appendChild(s);
1884
+ }
1885
+ box.replaceChildren(frag);
1886
+ }
1887
+
1888
+
1889
+ /**
1890
+ * Drag-to-resize for the todo and queue panels, ported from the terminal.
1891
+ *
1892
+ * The terminal marks each panel's top rule as a resize hitbox, and dragging
1893
+ * works in ROWS with `up = more rows`: the delta is taken against the row the
1894
+ * drag started on, so the panel grows as the pointer rises and shrinks as it
1895
+ * falls. The result is clamped to `[1, total]` — at least one row, never more
1896
+ * than exist. `null` means "no manual size", i.e. the automatic heuristic.
1897
+ */
1898
+ const QUEUE_MAX_VISIBLE = 5;
1899
+ const panelRows = { todo: null, queue: null };
1900
+ const resizeDrag = { kind: null, startY: 0, startRows: 0 };
1901
+
1902
+
1903
+ /** Row count a panel shows: the manual drag value when set, else the automatic
1904
+ one. Always clamped to [1, total], and 0 when there is nothing to show. */
1905
+ function panelRowCount(kind, total) {
1906
+ if (!total) return 0;
1907
+ const rows = panelRows[kind];
1908
+ if (typeof rows === 'number' && Number.isFinite(rows)) {
1909
+ return Math.max(1, Math.min(total, Math.round(rows)));
1910
+ }
1911
+ return kind === 'todo'
1912
+ ? (todosExpanded ? total : selectVisibleTodos(status.todos || []).rows.length)
1913
+ : Math.min(total, QUEUE_MAX_VISIBLE);
1914
+ }
1915
+
1916
+ function setResizeHover(el, on) {
1917
+ el.classList.toggle('is-hover', on);
1918
+ }
1919
+
1920
+ function setupPanelResize() {
1921
+ for (const rule of document.querySelectorAll('.stack-rule')) {
1922
+ const kind = rule.getAttribute('data-resize');
1923
+
1924
+ rule.addEventListener('mouseenter', () => setResizeHover(rule, true));
1925
+ rule.addEventListener('mouseleave', () => {
1926
+ if (resizeDrag.kind !== kind) setResizeHover(rule, false);
1927
+ });
1928
+
1929
+ rule.addEventListener('pointerdown', (ev) => {
1930
+ // Capturing keeps the drag alive once the pointer leaves the 6px rule,
1931
+ // which it immediately does — a resize handle you have to stay on top of
1932
+ // is unusable.
1933
+ ev.preventDefault();
1934
+ rule.setPointerCapture(ev.pointerId);
1935
+ rule.classList.add('is-dragging');
1936
+ const total = kind === 'todo'
1937
+ ? (status.todos || []).length
1938
+ : (status.queued || []).length;
1939
+ resizeDrag.kind = kind;
1940
+ resizeDrag.startY = ev.clientY;
1941
+ resizeDrag.startRows = panelRowCount(kind, total);
1942
+ setResizeHover(rule, true);
1943
+ });
1944
+
1945
+ rule.addEventListener('pointermove', (ev) => {
1946
+ if (resizeDrag.kind !== kind) return;
1947
+ const total = kind === 'todo'
1948
+ ? (status.todos || []).length
1949
+ : (status.queued || []).length;
1950
+ if (!total) return;
1951
+ // Rows are a line-height tall; converting the pixel delta into rows is what
1952
+ // makes the drag track the pointer instead of jumping a row per pixel.
1953
+ const rowPx = kind === 'todo' ? TODO_ROW_PX : QUEUE_ROW_PX;
1954
+ const delta = Math.round((resizeDrag.startY - ev.clientY) / rowPx);
1955
+ const want = Math.max(1, Math.min(total, resizeDrag.startRows + delta));
1956
+ if (panelRows[kind] === want) return;
1957
+ panelRows[kind] = want;
1958
+ if (kind === 'todo') renderTodoPanel(status.todos || []);
1959
+ else renderQueuePanel(status.queued || [], !!status.busy);
1960
+ });
1961
+
1962
+ const end = (ev) => {
1963
+ if (resizeDrag.kind !== kind) return;
1964
+ rule.classList.remove('is-dragging');
1965
+ try { rule.releasePointerCapture(ev.pointerId); } catch { /* already gone */ }
1966
+ resizeDrag.kind = null;
1967
+ setResizeHover(rule, false);
1968
+ };
1969
+ rule.addEventListener('pointerup', end);
1970
+ rule.addEventListener('pointercancel', end);
1971
+ }
1972
+ }
1973
+
1974
+ // -------------------------------------------------------------- todo panel
1975
+
1976
+ const TODO_MAX_VISIBLE = 5;
1977
+ const TODO_MARK = { in_progress: '\u25cf', done: '\u2713', pending: '\u25cb' };
1978
+ /** One rendered todo row, in CSS pixels — the pointer-delta to row conversion. */
1979
+ const TODO_ROW_PX = 21;
1980
+ const QUEUE_ROW_PX = 21;
1981
+
1982
+ let todosExpanded = false;
1983
+
1984
+
1985
+ function selectVisibleTodos(todos) {
1986
+ if (todos.length <= TODO_MAX_VISIBLE) return { rows: todos, hidden: 0, counts: null };
1987
+ const inProgress = [], pending = [], done = [];
1988
+ todos.forEach((t, i) => {
1989
+ if (t.status === 'in_progress') inProgress.push(i);
1990
+ else if (t.status === 'pending') pending.push(i);
1991
+ else done.push(i);
1992
+ });
1993
+ const picked = new Set(inProgress.slice(0, TODO_MAX_VISIBLE));
1994
+ if (picked.size < TODO_MAX_VISIBLE) {
1995
+ const doneC = done.slice().reverse();
1996
+ const remaining = TODO_MAX_VISIBLE - picked.size;
1997
+ let doneCount, pendCount;
1998
+ if (!doneC.length) { doneCount = 0; pendCount = Math.min(remaining, pending.length); }
1999
+ else if (!pending.length) { pendCount = 0; doneCount = Math.min(remaining, doneC.length); }
2000
+ else {
2001
+ doneCount = 1;
2002
+ pendCount = Math.min(remaining - 1, pending.length);
2003
+ if (pendCount < remaining - 1) doneCount = Math.min(doneC.length, remaining - pendCount);
2004
+ }
2005
+ for (let i = 0; i < doneCount; i++) picked.add(doneC[i]);
2006
+ for (let i = 0; i < pendCount; i++) picked.add(pending[i]);
2007
+ }
2008
+ const idx = [...picked].sort((a, b) => a - b);
2009
+ const counts = { done: 0, in_progress: 0, pending: 0 };
2010
+ todos.forEach((t, i) => { if (!picked.has(i)) counts[t.status] = (counts[t.status] || 0) + 1; });
2011
+ return { rows: idx.map((i) => todos[i]), hidden: todos.length - idx.length, counts };
2012
+ }
2013
+
2014
+ function todoMoreButton(label, title, onClick) {
2015
+ const b = document.createElement('button');
2016
+ b.type = 'button';
2017
+ b.className = 'msg-more';
2018
+ b.style.margin = '0';
2019
+ b.textContent = label;
2020
+ if (title) b.title = title;
2021
+ b.addEventListener('click', onClick);
2022
+ return b;
2023
+ }
2024
+
2025
+ function renderTodoPanel(todos) {
2026
+ const ul = $('st-todos');
2027
+ if (!ul) return;
2028
+ ul.replaceChildren();
2029
+ if (!todos.length) return;
2030
+
2031
+ // The visible set is the automatic pick topped up to the panel's row count, so
2032
+ // dragging the rule taller ADDS rows rather than reordering them — the
2033
+ // in-progress item keeps its priority while more of the list appears under it.
2034
+ const want = panelRowCount('todo', todos.length);
2035
+ const auto = selectVisibleTodos(todos);
2036
+ const rows = auto.rows.slice();
2037
+ if (rows.length < want) {
2038
+ const seen = new Set(rows);
2039
+ for (const t of todos) {
2040
+ if (rows.length >= want) break;
2041
+ if (!seen.has(t)) { rows.push(t); seen.add(t); }
2042
+ }
2043
+ }
2044
+ const shown = rows.slice(0, want);
2045
+
2046
+ for (const t of shown) {
2047
+ const li = document.createElement('li');
2048
+ li.className = t.status === 'done' ? 'done' : t.status === 'in_progress' ? 'now' : '';
2049
+ li.appendChild(el('mark', TODO_MARK[t.status] || TODO_MARK.pending));
2050
+ li.appendChild(el('t', String(t.title == null ? '' : t.title)));
2051
+ ul.appendChild(li);
2052
+ }
2053
+
2054
+ const hidden = todos.length - shown.length;
2055
+ if (hidden > 0) {
2056
+ const counts = { done: 0, in_progress: 0, pending: 0 };
2057
+ for (const t of todos) if (!shown.includes(t)) counts[t.status] = (counts[t.status] || 0) + 1;
2058
+ const dist = [['done', 'done'], ['in_progress', 'in progress'], ['pending', 'pending']]
2059
+ .filter(([k]) => counts[k] > 0)
2060
+ .map(([k, label]) => counts[k] + ' ' + label)
2061
+ .join(', ');
2062
+ const li = document.createElement('li');
2063
+ li.className = 'more';
2064
+ li.appendChild(todoMoreButton(
2065
+ '\u2026 +' + hidden + (dist ? ' (' + dist + ')' : ''),
2066
+ tr('status.expandTodos'),
2067
+ () => { todosExpanded = true; panelRows.todo = todos.length; renderTodoPanel(todos); },
2068
+ ));
2069
+ ul.appendChild(li);
2070
+ } else if (todosExpanded && todos.length > TODO_MAX_VISIBLE) {
2071
+ const li = document.createElement('li');
2072
+ li.className = 'more';
2073
+ li.appendChild(todoMoreButton(tr('tool.collapse'), '', () => {
2074
+
2075
+ todosExpanded = false;
2076
+ panelRows.todo = null;
2077
+ renderTodoPanel(todos);
2078
+ }));
2079
+ ul.appendChild(li);
2080
+ }
2081
+ }
2082
+
2083
+
2084
+ // ------------------------------------------------------------- queue panel
2085
+
2086
+ function renderQueuePanel(queued, busy) {
2087
+ const ul = $('st-queue');
2088
+ if (!ul) return;
2089
+ ul.replaceChildren();
2090
+ if (!queued.length) return;
2091
+
2092
+ // Same rule as the todo panel: the drag count wins, clamped to what exists.
2093
+ const rows = queued.slice(0, panelRowCount('queue', queued.length));
2094
+
2095
+ rows.forEach((raw) => {
2096
+ const text = String(raw == null ? '' : raw);
2097
+ const single = text.replace(/\s+/g, ' ').trim();
2098
+ const li = document.createElement('li');
2099
+ li.appendChild(el('mark', tr('mark.queued')));
2100
+ const tx = el('t', single);
2101
+ tx.title = single;
2102
+ li.appendChild(tx);
2103
+
2104
+ if (busy) {
2105
+ const b = document.createElement('button');
2106
+ b.type = 'button';
2107
+ b.className = 'steer-btn';
2108
+ b.textContent = tr('queue.steer');
2109
+ b.title = tr('queue.steerHint');
2110
+ b.addEventListener('click', (ev) => {
2111
+ ev.preventDefault();
2112
+ // The payload is the item's OWN text, captured here. Indexing into the
2113
+ // `queued` array read a list that status rebuilds from scratch on every
2114
+ // frame, so a click after any change steered the wrong message — or
2115
+ // nothing. A successful steer removes the item, so the row disappears on
2116
+ // the next status frame.
2117
+ void steerQueued(b, text);
2118
+ });
2119
+ li.appendChild(b);
2120
+ }
2121
+
2122
+ // Edit: pull the message back out of the queue and load it into the composer,
2123
+ // mirroring the terminal's ↑-to-recall. Available whether or not a turn is
2124
+ // running — a queued message is editable at any time.
2125
+ const eb = document.createElement('button');
2126
+ eb.type = 'button';
2127
+ eb.className = 'steer-btn edit-btn';
2128
+ eb.textContent = tr('queue.edit');
2129
+ eb.title = tr('queue.editHint');
2130
+ eb.addEventListener('click', (ev) => {
2131
+ ev.preventDefault();
2132
+ void editQueued(eb, text);
2133
+ });
2134
+ li.appendChild(eb);
2135
+
2136
+ // Drop: remove it from the queue without editing.
2137
+ const db = document.createElement('button');
2138
+ db.type = 'button';
2139
+ db.className = 'steer-btn drop-btn';
2140
+ db.textContent = tr('queue.drop');
2141
+ db.title = tr('queue.dropHint');
2142
+ db.addEventListener('click', (ev) => {
2143
+ ev.preventDefault();
2144
+ void dropQueued(db, text);
2145
+ });
2146
+ li.appendChild(db);
2147
+ ul.appendChild(li);
2148
+ });
2149
+
2150
+ const li = document.createElement('li');
2151
+ li.className = 'more';
2152
+ const hidden = queued.length - rows.length;
2153
+ li.textContent = hidden > 0
2154
+ ? tr('queue.moreHint', { n: hidden })
2155
+ : tr(busy ? 'queue.hintRunning' : 'queue.hintIdle');
2156
+ ul.appendChild(li);
2157
+ }
2158
+
2159
+ /** Steer one queued message into the running turn. The button reports its own
2160
+ progress, because a send that silently does nothing is indistinguishable
2161
+ from a broken button. */
2162
+ async function steerQueued(btn, text) {
2163
+ btn.disabled = true;
2164
+ btn.textContent = tr('queue.steering');
2165
+ const body = await act('steer', [text]);
2166
+ if (!body || body.ok !== true) {
2167
+ btn.disabled = false;
2168
+ btn.textContent = tr('queue.steer');
2169
+ }
2170
+ }
2171
+
2172
+ /** Recall a queued message into the composer for editing, like the terminal's ↑.
2173
+ The server removes it from the queue and returns its text; we put that text in
2174
+ the input so the user can change and re-send it. */
2175
+ async function editQueued(btn, text) {
2176
+ btn.disabled = true;
2177
+ const body = await act('editQueued', [text]);
2178
+ btn.disabled = false;
2179
+ if (!body || body.ok !== true) return;
2180
+ input.value = String(body.text == null ? text : body.text);
2181
+ input.focus();
2182
+ input.setSelectionRange(input.value.length, input.value.length);
2183
+ autoGrow();
2184
+ setPmode();
2185
+ }
2186
+
2187
+ /** Remove a queued message without editing it. */
2188
+ async function dropQueued(btn, text) {
2189
+ btn.disabled = true;
2190
+ const body = await act('dropQueued', [text]);
2191
+ btn.disabled = false;
2192
+ if (!body || body.ok !== true) btn.disabled = false;
2193
+ }
2194
+
2195
+
2196
+ // ------------------------------------------------------------- side tabs
2197
+
2198
+ function card() {
2199
+ const n = document.createElement('div');
2200
+ n.className = 'card';
2201
+ return n;
2202
+ }
2203
+
2204
+ function cardHead(...nodes) {
2205
+ const h = document.createElement('div');
2206
+ h.className = 'card-head';
2207
+ h.append(...nodes);
2208
+ return h;
2209
+ }
2210
+
2211
+ function cardFields() {
2212
+ const g = document.createElement('div');
2213
+ g.className = 'card-fields';
2214
+ return g;
2215
+ }
2216
+
2217
+ function rightMeta(text) {
2218
+ const s = document.createElement('span');
2219
+ s.className = 'right';
2220
+ s.textContent = text;
2221
+ return s;
2222
+ }
2223
+
2224
+ function field(label, value) {
2225
+ const row = document.createElement('div');
2226
+ row.className = 'field';
2227
+ row.appendChild(el('field-label', label));
2228
+ const v = el('field-value', '');
2229
+ if (value == null || value === '') {
2230
+ v.className = 'field-value dim';
2231
+ v.textContent = tr('unit.none');
2232
+ } else if (value instanceof Node) {
2233
+ v.appendChild(value);
2234
+ } else {
2235
+ v.textContent = String(value);
2236
+ }
2237
+ row.appendChild(v);
2238
+ return row;
2239
+ }
2240
+
2241
+ function pill(text, tone) {
2242
+ return el('pill' + (tone ? ' pill-' + tone : ''), text);
2243
+ }
2244
+
2245
+ function emptyNote(text) {
2246
+ return el('cards-empty', text);
2247
+ }
2248
+
2249
+ function renderTasks(list) {
2250
+ const wrap = $('tasks');
2251
+ wrap.replaceChildren();
2252
+ if (!list.length) { wrap.appendChild(emptyNote(tr('tasks.empty'))); return; }
2253
+ for (const task of list) {
2254
+ const c = card();
2255
+ const tone = task.status === 'running' ? 'info'
2256
+ : task.status === 'failed' ? 'danger'
2257
+ : task.status === 'done' || task.status === 'completed' ? 'ok'
2258
+ : 'soft';
2259
+ const head = cardHead(
2260
+ pill(task.kind || 'bash', 'soft'),
2261
+ pill(task.status || '', tone),
2262
+ pill(task.id || '', 'sub'),
2263
+ );
2264
+ if (task.status === 'running') {
2265
+ const b = document.createElement('button');
2266
+ b.className = 'steer-btn';
2267
+ b.textContent = tr('tasks.stop');
2268
+ b.addEventListener('click', () => act('stopTask', [task.id]));
2269
+ head.appendChild(b);
2270
+ }
2271
+ c.appendChild(head);
2272
+ c.appendChild(rightMeta(task.startedAt ? ago(task.startedAt) : ''));
2273
+
2274
+ const fields = cardFields();
2275
+ fields.appendChild(field(tr('tasks.colSummary'), String(task.summary || task.prompt || '')));
2276
+ if (task.agentId) fields.appendChild(field('agent', task.agentId));
2277
+ c.appendChild(fields);
2278
+ wrap.appendChild(c);
2279
+ }
2280
+ }
2281
+
2282
+ function renderFiles(files) {
2283
+ const wrap = $('files');
2284
+ wrap.replaceChildren();
2285
+ if (!files.length) { wrap.appendChild(emptyNote(tr('files.empty'))); return; }
2286
+ for (const f of files) {
2287
+ const c = card();
2288
+ c.appendChild(cardHead(pill(f.ops || '', 'soft')));
2289
+ c.appendChild(rightMeta(String(f.count || 0)));
2290
+ const fields = cardFields();
2291
+ fields.appendChild(field(tr('files.colPath'), f.path));
2292
+ c.appendChild(fields);
2293
+ wrap.appendChild(c);
2294
+ }
2295
+ }
2296
+
2297
+ function renderTools(list) {
2298
+ const wrap = $('tools');
2299
+ wrap.replaceChildren();
2300
+ if (!list.length) { wrap.appendChild(emptyNote(tr('side.none'))); return; }
2301
+ for (const x of list) {
2302
+ const c = card();
2303
+ c.appendChild(cardHead(pill(x.name || '', 'soft')));
2304
+ const fields = cardFields();
2305
+ fields.appendChild(field('description', String(x.description || '').split('\n')[0]));
2306
+ c.appendChild(fields);
2307
+ wrap.appendChild(c);
2308
+ }
2309
+ }
2310
+
2311
+ function renderCommands(list) {
2312
+ const wrap = $('commands');
2313
+ wrap.replaceChildren();
2314
+ if (!list.length) { wrap.appendChild(emptyNote(tr('side.none'))); return; }
2315
+ for (const cmd of list) {
2316
+ const c = card();
2317
+ c.classList.add('cmd-card'); // enables the hover affordance
2318
+ c.appendChild(cardHead(pill('/' + (cmd.name || ''), 'solid'), pill(cmd.argumentHint || '', 'soft')));
2319
+ const fields = cardFields();
2320
+ fields.appendChild(field('description', cmd.desc || ''));
2321
+ c.appendChild(fields);
2322
+ // Clicking a command card acts on it, the way accepting a completion does in
2323
+ // the terminal:
2324
+ // * a command that takes NO argument runs immediately,
2325
+ // * a command that needs one (its argumentHint is shown on the card head) is
2326
+ // PREPARED in the composer, with the leading slash and trailing space in
2327
+ // place, so the user fills in the value and presses Enter — sending it
2328
+ // empty would just bounce the usage/error back.
2329
+ const nm = String(cmd.name || '');
2330
+ const needsArg = !!(cmd && cmd.argumentHint);
2331
+ c.addEventListener('click', () => {
2332
+ if (needsArg) {
2333
+ const box = $('composer');
2334
+ if (box) box.focus();
2335
+ input.value = '/' + nm + ' ';
2336
+ input.setSelectionRange(input.value.length, input.value.length);
2337
+ if (typeof autoGrow === 'function') autoGrow();
2338
+ if (typeof setPmode === 'function') setPmode();
2339
+ } else {
2340
+ void send('/' + nm);
2341
+ }
2342
+ });
2343
+ wrap.appendChild(c);
2344
+ }
2345
+ }
2346
+
2347
+
2348
+ function renderTabCounts(s) {
2349
+ const counts = {
2350
+ tasks: (s.tasks || []).length,
2351
+ files: (s.files || []).length,
2352
+ logs: ($('logs') || { childElementCount: 0 }).childElementCount,
2353
+ tools: (s.tools || []).length,
2354
+ commands: (s.commands || []).length,
2355
+ };
2356
+ for (const b of document.querySelectorAll('.tab')) {
2357
+ const n = counts[b.getAttribute('data-view')];
2358
+ const old = b.querySelector('.tab-count');
2359
+ if (!n) { if (old) old.remove(); continue; }
2360
+ if (old) { old.textContent = String(n); continue; }
2361
+ b.appendChild(el('tab-count', String(n)));
2362
+ }
2363
+ }
2364
+
2365
+ const MAX_LOG = 600;
2366
+
2367
+ function addLog(e) {
2368
+ const box = $('logs');
2369
+ const line = document.createElement('div');
2370
+ line.className = 'log-line';
2371
+ line.append(
2372
+ el('log-t', new Date(e.at || Date.now()).toTimeString().slice(0, 8)),
2373
+ el('log-k', String(e.kind || e.type || '')),
2374
+ el('log-d', String(e.text || '')),
2375
+ );
2376
+ box.appendChild(line);
2377
+ while (box.children.length > MAX_LOG) box.removeChild(box.firstChild);
2378
+ if (!$('view-logs').hidden) box.scrollTop = box.scrollHeight;
2379
+ }
2380
+
2381
+ // ----------------------------------------------------------------- modal
2382
+
2383
+ let modalKey = '';
2384
+
2385
+ function applyModal(s) {
2386
+ const box = $('modal');
2387
+ const pending = s.pending;
2388
+ if (!pending) { box.hidden = true; modalKey = ''; return; }
2389
+ const key = JSON.stringify(pending);
2390
+ if (key === modalKey && !box.hidden) return;
2391
+ modalKey = key;
2392
+
2393
+ $('modal-box').className = 'modal-box '
2394
+ + (pending.kind === 'approval' ? 'is-approval' : 'is-question');
2395
+ $('modal-title').textContent = pending.kind === 'approval' ? tr('modal.approve') : tr('modal.question');
2396
+ $('modal-body').textContent = pending.detail || pending.question || '';
2397
+ const actions = $('modal-actions');
2398
+ actions.replaceChildren();
2399
+
2400
+ const mkBtn = (label, fn, primary) => {
2401
+ const b = document.createElement('button');
2402
+ b.className = primary ? 'btn' : 'btn-quiet';
2403
+ b.textContent = label;
2404
+ b.addEventListener('click', fn);
2405
+ actions.appendChild(b);
2406
+ };
2407
+
2408
+ if (pending.kind === 'approval') {
2409
+ mkBtn(tr('modal.allow'), () => act('approve', [pending.id, true]), true);
2410
+ mkBtn(tr('modal.deny'), () => act('approve', [pending.id, false]), false);
2411
+ } else {
2412
+ const body = $('modal-body');
2413
+ const multi = !!pending.multiSelect;
2414
+ const picked = new Set();
2415
+ // Declared before the option handlers below reference it (single-select
2416
+ // submits immediately and must be able to read the note if it is present).
2417
+ let noteEl = null;
2418
+ // Progress line: "Question n/total" + optional header.
2419
+ const total = Number(pending.total || 1);
2420
+ const idx = Number(pending.index || 0);
2421
+ if (total > 1) {
2422
+ const prog = document.createElement('div');
2423
+ prog.className = 'modal-progress';
2424
+ prog.textContent = tr('modal.progress', { n: idx + 1, total })
2425
+ + (pending.header ? ' ' + pending.header : '');
2426
+ body.appendChild(prog);
2427
+ }
2428
+ // Option rows (the model's real options).
2429
+ const list = document.createElement('div');
2430
+ list.className = 'modal-options';
2431
+ for (const o of pending.options || []) {
2432
+ const label = (o && typeof o === 'object') ? o.label : String(o);
2433
+ const desc = (o && typeof o === 'object') ? (o.description || '') : '';
2434
+ const b = document.createElement('button');
2435
+ b.type = 'button';
2436
+ b.className = 'modal-opt';
2437
+ b.dataset.label = label;
2438
+ const t = document.createElement('span');
2439
+ t.className = 'modal-opt-label';
2440
+ t.textContent = label;
2441
+ b.appendChild(t);
2442
+ if (desc) {
2443
+ const d = document.createElement('span');
2444
+ d.className = 'modal-opt-desc';
2445
+ d.textContent = desc;
2446
+ b.appendChild(d);
2447
+ }
2448
+ if (multi) {
2449
+ const box = document.createElement('span');
2450
+ box.className = 'modal-check';
2451
+ box.textContent = '\u25cb';
2452
+ b.prepend(box);
2453
+ b.addEventListener('click', () => {
2454
+ if (picked.has(label)) picked.delete(label); else picked.add(label);
2455
+ b.classList.toggle('is-on', picked.has(label));
2456
+ box.textContent = picked.has(label) ? '\u2713' : '\u25cb';
2457
+ });
2458
+ } else {
2459
+ // Single-select: choosing an option answers immediately, like the terminal.
2460
+ b.addEventListener('click', () => {
2461
+ void act('answerQuestion', [pending.id, [label], '', noteEl ? noteEl.value : '', true]);
2462
+ });
2463
+ }
2464
+ list.appendChild(b);
2465
+ }
2466
+ body.appendChild(list);
2467
+ // Per-question "Other": a free-text answer of the user's own.
2468
+ const otherWrap = document.createElement('div');
2469
+ otherWrap.className = 'modal-other';
2470
+ const otherBtn = document.createElement('button');
2471
+ otherBtn.type = 'button';
2472
+ otherBtn.className = 'modal-opt modal-other-toggle';
2473
+ otherBtn.textContent = tr('modal.other');
2474
+ const otherInput = document.createElement('input');
2475
+ otherInput.type = 'text';
2476
+ otherInput.className = 'modal-input inline';
2477
+ otherInput.placeholder = tr('modal.otherHint');
2478
+ let otherOpen = false;
2479
+ otherBtn.addEventListener('click', () => {
2480
+ otherOpen = !otherOpen;
2481
+ otherInput.hidden = !otherOpen;
2482
+ otherBtn.classList.toggle('is-on', otherOpen);
2483
+ if (otherOpen) otherInput.focus();
2484
+ });
2485
+ otherInput.hidden = true;
2486
+ otherWrap.append(otherBtn, otherInput);
2487
+ body.appendChild(otherWrap);
2488
+ // Whole-request note, only after the last question (may be left blank).
2489
+ if (pending.hasSupplement) {
2490
+ const noteWrap = document.createElement('div');
2491
+ noteWrap.className = 'modal-note';
2492
+ const nl = document.createElement('div');
2493
+ nl.className = 'modal-note-label';
2494
+ nl.textContent = tr('modal.note');
2495
+ noteEl = document.createElement('textarea');
2496
+ noteEl.rows = 2;
2497
+ noteEl.className = 'modal-input';
2498
+ noteEl.placeholder = tr('modal.noteHint');
2499
+ noteWrap.append(nl, noteEl);
2500
+ body.appendChild(noteWrap);
2501
+ }
2502
+ // Actions: submit the current answers. In single-select the option click
2503
+ // already submitted, but Submit also works (honours the Other text box).
2504
+ const submitCurrent = () => {
2505
+ const other = otherOpen ? otherInput.value.trim() : '';
2506
+ const list0 = [...picked];
2507
+ void act('answerQuestion', [pending.id, other ? [] : list0, other, noteEl ? noteEl.value : '', true]);
2508
+ };
2509
+ mkBtn(total > 1 && idx < total - 1 ? tr('modal.next') : tr('modal.submit'), submitCurrent, true);
2510
+ }
2511
+ box.hidden = false;
2512
+ }
2513
+
2514
+ function copyButton(value, label) {
2515
+ const b = document.createElement('button');
2516
+ b.type = 'button';
2517
+ b.className = 'copy-btn';
2518
+ b.textContent = label || tr('copy.copy');
2519
+ b.title = tr('copy.copyTitle');
2520
+ let timer = null;
2521
+ b.addEventListener('click', (ev) => {
2522
+ ev.stopPropagation();
2523
+ const done = (okState) => {
2524
+ b.textContent = okState ? tr('copy.done') : tr('copy.failed');
2525
+ b.classList.toggle('is-ok', okState);
2526
+ b.classList.toggle('is-err', !okState);
2527
+ if (timer) clearTimeout(timer);
2528
+ timer = setTimeout(() => {
2529
+ b.textContent = label || tr('copy.copy');
2530
+ b.classList.remove('is-ok', 'is-err');
2531
+ }, 1200);
2532
+ };
2533
+ const text = String(value == null ? '' : value);
2534
+ if (navigator.clipboard && navigator.clipboard.writeText) {
2535
+ navigator.clipboard.writeText(text).then(() => done(true)).catch(() => done(false));
2536
+ } else {
2537
+ done(false);
2538
+ }
2539
+ });
2540
+ return b;
2541
+ }
2542
+
2543
+ // ------------------------------------------------------------------ rail
2544
+
2545
+ function ago(ms) {
2546
+ if (!ms) return '';
2547
+ const diff = Date.now() - ms;
2548
+ if (diff < 0) return new Date(ms).toLocaleDateString();
2549
+ const min = Math.floor(diff / 60000);
2550
+ if (min < 1) return tr('home.justNow');
2551
+ if (min < 60) return tr('home.minutesAgo', { n: min });
2552
+ const hr = Math.floor(min / 60);
2553
+ if (hr < 24) return tr('home.hoursAgo', { n: hr });
2554
+ const day = Math.floor(hr / 24);
2555
+ if (day < 7) return tr('home.daysAgo', { n: day });
2556
+ return new Date(ms).toLocaleDateString();
2557
+ }
2558
+
2559
+ function workspaceLabel(p) {
2560
+ const parts = String(p || '').split(/[/\\]+/).filter(Boolean);
2561
+ if (!parts.length) return tr('rail.noWorkspace');
2562
+ return parts.slice(-2).join('/');
2563
+ }
2564
+
2565
+ function shortId(id) {
2566
+ const s = String(id || '');
2567
+ return s.length > 12 ? '\u2026' + s.slice(-10) : s;
2568
+ }
2569
+
2570
+ let railShape = '';
2571
+ let railFilter = '';
2572
+ let railData = { sessions: [], current: '' };
2573
+
2574
+ function renderRail(data) {
2575
+ const list = $('rail-list');
2576
+ if (!list) return;
2577
+ railData = data || railData;
2578
+ const all = railData.sessions || [];
2579
+ const current = String(railData.current || '');
2580
+
2581
+ const q = railFilter.trim().toLowerCase();
2582
+ const sessions = q
2583
+ ? all.filter((s) => (s.id + ' ' + (s.title || '') + ' ' + (s.workspace || '')).toLowerCase().includes(q))
2584
+ : all;
2585
+
2586
+ const shape = current + '\u0000' + q + '\u0000'
2587
+ + sessions.map((s) => s.id + ':' + s.title + ':' + s.updatedAt).join('\u0000');
2588
+ if (shape === railShape) return;
2589
+ railShape = shape;
2590
+
2591
+ $('rail-count').textContent = !all.length ? ''
2592
+ : q ? sessions.length + ' / ' + all.length
2593
+ : String(all.length);
2594
+
2595
+ list.replaceChildren();
2596
+ if (!sessions.length) {
2597
+ list.appendChild(el('rail-empty', all.length ? tr('rail.noMatch') : tr('rail.empty')));
2598
+ return;
2599
+ }
2600
+
2601
+ const byWs = new Map();
2602
+ for (const s of sessions) {
2603
+ const key = workspaceLabel(s.workspace);
2604
+ if (!byWs.has(key)) byWs.set(key, []);
2605
+ byWs.get(key).push(s);
2606
+ }
2607
+ const groups = [...byWs.entries()].sort((a, b) => {
2608
+ const na = Math.max(0, ...a[1].map((s) => s.updatedAt || 0));
2609
+ const nb = Math.max(0, ...b[1].map((s) => s.updatedAt || 0));
2610
+ return nb - na;
2611
+ });
2612
+
2613
+ for (const [ws, items] of groups) {
2614
+ const head = document.createElement('div');
2615
+ head.className = 'rail-group';
2616
+ const name = document.createElement('span');
2617
+ name.className = 'ws';
2618
+ name.textContent = ws;
2619
+ name.title = items[0].workspace || '';
2620
+ head.append(name, el('mono', String(items.length)));
2621
+ list.appendChild(head);
2622
+ for (const s of items) list.appendChild(railRow(s, s.id === current));
2623
+ }
2624
+ }
2625
+
2626
+ function railRow(s, isCurrent) {
2627
+ const a = document.createElement('a');
2628
+ a.className = 'rail-row' + (isCurrent ? ' is-current' : '');
2629
+ a.href = '/s/' + encodeURIComponent(s.id) + '/';
2630
+ a.title = (s.title || tr('home.untitled')) + '\n' + (s.id || '') + '\n' + (s.workspace || '');
2631
+
2632
+ const top = document.createElement('span');
2633
+ top.className = 'rail-row-top';
2634
+ top.append(
2635
+ el('rail-dot' + (isCurrent ? ' is-current' : ''), ''),
2636
+ el('rail-id', shortId(s.id)),
2637
+ el('rail-when', ago(s.updatedAt)),
2638
+ );
2639
+ a.appendChild(top);
2640
+ a.appendChild(el('rail-title', s.title || tr('home.untitled')));
2641
+ return a;
2642
+ }
2643
+
2644
+ async function loadRail() {
2645
+ try {
2646
+ const res = await fetch('api/sessions', { headers: { accept: 'application/json' } });
2647
+ if (!res.ok) return;
2648
+ renderRail(await res.json());
2649
+ } catch { /* the rail is an accessory; a failed poll must not spam errors */ }
2650
+ }
2651
+
2652
+ function startRail() {
2653
+ void loadRail();
2654
+ setInterval(() => { void loadRail(); }, 10000);
2655
+ const box = $('rail-filter');
2656
+ if (box) {
2657
+ box.addEventListener('input', () => {
2658
+ railFilter = box.value;
2659
+ railShape = '';
2660
+ renderRail(railData);
2661
+ });
2662
+ }
2663
+ }
2664
+
2665
+ // ------------------------------------------------------------------ input
2666
+
2667
+ let inputHistory = [];
2668
+ let historyIdx = -1;
2669
+
2670
+ function rememberInput(text) {
2671
+ const t = String(text || '').trim();
2672
+ if (!t) return;
2673
+ if (inputHistory[inputHistory.length - 1] !== t) inputHistory.push(t);
2674
+ if (inputHistory.length > 200) inputHistory.shift();
2675
+ }
2676
+
2677
+ function historyNavigate(dir) {
2678
+ if (!inputHistory.length) return false;
2679
+ const caret = input.selectionStart == null ? 0 : input.selectionStart;
2680
+ const end = input.selectionEnd == null ? caret : input.selectionEnd;
2681
+ if (caret !== end) return false;
2682
+ if (dir < 0 && input.value.slice(0, caret).includes('\n')) return false;
2683
+ if (dir > 0 && input.value.slice(end).includes('\n')) return false;
2684
+
2685
+ if (historyIdx === -1) historyIdx = inputHistory.length;
2686
+ historyIdx = dir < 0
2687
+ ? Math.max(0, historyIdx - 1)
2688
+ : Math.min(inputHistory.length, historyIdx + 1);
2689
+ input.value = historyIdx >= inputHistory.length ? '' : (inputHistory[historyIdx] || '');
2690
+ const at = input.value.length;
2691
+ input.setSelectionRange(at, at);
2692
+ autoGrow();
2693
+ setPmode();
2694
+ return true;
2695
+ }
2696
+
2697
+ function resetHistoryCursor() {
2698
+ historyIdx = -1;
2699
+ }
2700
+
2701
+ let growTimer = null;
2702
+ let growRows = 0;
2703
+
2704
+ function autoGrow() {
2705
+ const est = 1 + (input.value.match(/\n/g) || []).length
2706
+ + Math.floor(input.value.length / 90);
2707
+ if (est === growRows) return;
2708
+ if (growTimer !== null) return;
2709
+ growTimer = requestAnimationFrame(() => {
2710
+ growTimer = null;
2711
+ growRows = 1 + (input.value.match(/\n/g) || []).length
2712
+ + Math.floor(input.value.length / 90);
2713
+ input.style.height = 'auto';
2714
+ input.style.height = Math.min(200, input.scrollHeight) + 'px';
2715
+ });
2716
+ }
2717
+
2718
+ function setPmode() {
2719
+ const shell = input.value.startsWith('!');
2720
+ const node = $('pmode');
2721
+ node.textContent = shell ? '!' : '\u203a';
2722
+ node.title = tr(shell ? 'chat.shellMode' : 'chat.shellHint');
2723
+ $('composer').classList.toggle('is-shell', shell);
2724
+ }
2725
+
2726
+ async function send(forced) {
2727
+ const text = forced === undefined ? input.value : String(forced);
2728
+ if (!text.trim()) return;
2729
+ if (forced === undefined) {
2730
+ input.value = '';
2731
+ autoGrow();
2732
+ setPmode();
2733
+ }
2734
+ rememberInput(text);
2735
+ resetHistoryCursor();
2736
+
2737
+ if (text.startsWith('!')) await act('shell', [text.replace(/^!+/, '').trim()]);
2738
+ else if (text.startsWith('/')) {
2739
+ // Slash commands (including /skill:<name>) go through the dispatcher, not
2740
+ // the plain submit path — otherwise a command line was sent as a literal
2741
+ // user message and the skill body / action never ran.
2742
+ const sp = text.indexOf(' ');
2743
+ const cmd = sp === -1 ? text : text.slice(0, sp);
2744
+ const arg = sp === -1 ? '' : text.slice(sp + 1).trim();
2745
+ await act('dispatch', [cmd, arg]);
2746
+ }
2747
+ else await act('submit', [text]);
2748
+ }
2749
+
2750
+ // ------------------------------------------------------------------ views
2751
+
2752
+ const VIEW_IDS = ['chat', 'tasks', 'files', 'tools', 'commands', 'logs'];
2753
+
2754
+ const VIEW_SUB = {
2755
+ chat: '',
2756
+ tasks: 'tasks.sub',
2757
+ files: 'files.sub',
2758
+ tools: 'tools.sub',
2759
+ commands: 'commands.sub',
2760
+ logs: 'logs.sub',
2761
+ };
2762
+
2763
+ function setupViews() {
2764
+ for (const b of document.querySelectorAll('.tab')) {
2765
+ b.addEventListener('click', () => {
2766
+ for (const o of document.querySelectorAll('.tab')) o.classList.toggle('is-on', o === b);
2767
+ const v = b.getAttribute('data-view');
2768
+ for (const id of VIEW_IDS) $('view-' + id).hidden = id !== v;
2769
+ $('view-sub').textContent = VIEW_SUB[v] ? tr(VIEW_SUB[v]) : '';
2770
+ // A pane rebuilt-while-hidden never rendered: paint it once now from the
2771
+ // latest status, then clear its dirty flag.
2772
+ if (dirtyViews.has(v)) {
2773
+ dirtyViews.delete(v);
2774
+ if (v === 'tasks') renderTasks(status.tasks || []);
2775
+ else if (v === 'files') renderFiles(status.files || []);
2776
+ else if (v === 'tools') renderTools(status.tools || []);
2777
+ else if (v === 'commands') renderCommands(status.commands || []);
2778
+ }
2779
+ if (v === 'chat') { win.pinned = true; scheduleWindow(); }
2780
+ });
2781
+ }
2782
+ $('btn-interrupt').addEventListener('click', () => act('interrupt'));
2783
+ $('logs-clear').addEventListener('click', () => {
2784
+ $('logs').replaceChildren();
2785
+ renderTabCounts(status);
2786
+ });
2787
+ }
2788
+
2789
+ /**
2790
+ * The rail on a narrow screen.
2791
+ *
2792
+ * Below the breakpoint the rail is no longer a column, so the ONLY way to reach
2793
+ * sessions, settings or the language switch is through the header button.
2794
+ * Drawing it as an overlay (rather than hiding it outright) is what keeps those
2795
+ * reachable on a phone at all — `display: none` left them permanently
2796
+ * inaccessible, which is a missing feature rather than a layout choice.
2797
+ */
2798
+ const railDrawerOpen = { on: false };
2799
+
2800
+ function setRailDrawer(open) {
2801
+ railDrawerOpen.on = open;
2802
+ document.body.classList.toggle('rail-open', open);
2803
+ const btn = $('btn-rail');
2804
+ if (btn) btn.setAttribute('aria-expanded', open ? 'true' : 'false');
2805
+ }
2806
+
2807
+ function setupRailDrawer() {
2808
+ const btn = $('btn-rail');
2809
+ if (!btn) return;
2810
+ btn.addEventListener('click', () => setRailDrawer(!railDrawerOpen.on));
2811
+
2812
+ // A scrim click and Escape both close it, matching the settings overlay.
2813
+ const scrim = $('rail-scrim');
2814
+ if (scrim) scrim.addEventListener('click', () => setRailDrawer(false));
2815
+ document.addEventListener('keydown', (ev) => {
2816
+ if (ev.key === 'Escape' && railDrawerOpen.on) setRailDrawer(false);
2817
+ });
2818
+
2819
+ // Choosing a session is the reason the drawer was opened; leaving it up would
2820
+ // cover the page that was just loaded.
2821
+ $('rail-list').addEventListener('click', (ev) => {
2822
+ if (ev.target.closest && ev.target.closest('a.rail-row')) setRailDrawer(false);
2823
+ });
2824
+
2825
+ // Growing past the breakpoint turns the rail back into a column, where the
2826
+ // drawer state means nothing.
2827
+ const mq = window.matchMedia('(max-width: 720px)');
2828
+ const sync = () => { if (!mq.matches) setRailDrawer(false); };
2829
+ if (mq.addEventListener) mq.addEventListener('change', sync);
2830
+ else if (mq.addListener) mq.addListener(sync);
2831
+ }
2832
+
2833
+ // ------------------------------------------------------------------- boot
2834
+
2835
+
2836
+ function main() {
2837
+ applyStatic();
2838
+ buildLangSwitch();
2839
+ setupViews();
2840
+ setupRailDrawer();
2841
+ setupPanelResize();
2842
+ setPmode();
2843
+ startRail();
2844
+
2845
+ stream.addEventListener('scroll', onScroll, { passive: true });
2846
+ if (typeof ResizeObserver === 'function') {
2847
+ new ResizeObserver(() => scheduleWindow()).observe(stream);
2848
+ } else {
2849
+ window.addEventListener('resize', () => scheduleWindow());
2850
+ }
2851
+
2852
+ completer = new Completer(input, $('complete'), {
2853
+ getCommands: () => (status && status.commands) || [],
2854
+ getFiles: () => (status && status.workspaceFiles) || [],
2855
+ onAccept: (text, caret) => {
2856
+ input.value = text;
2857
+ input.setSelectionRange(caret, caret);
2858
+ autoGrow();
2859
+ setPmode();
2860
+ },
2861
+ onSubmit: (cmd) => {
2862
+ input.value = '';
2863
+ autoGrow();
2864
+ setPmode();
2865
+ void send(cmd);
2866
+ },
2867
+ });
2868
+
2869
+ input.addEventListener('input', () => {
2870
+ if (historyIdx !== -1 && input.value !== inputHistory[historyIdx]) resetHistoryCursor();
2871
+ autoGrow();
2872
+ setPmode();
2873
+ completer.refresh();
2874
+ });
2875
+ input.addEventListener('click', () => { completer.refresh(); });
2876
+ input.addEventListener('blur', () => { completer.close(); });
2877
+ input.addEventListener('keydown', (ev) => {
2878
+ if (completer.open) {
2879
+ if (ev.key === 'ArrowDown') { ev.preventDefault(); completer.move(1); return; }
2880
+ if (ev.key === 'ArrowUp') { ev.preventDefault(); completer.move(-1); return; }
2881
+ if (ev.key === 'Tab') { ev.preventDefault(); completer.accept(); return; }
2882
+ if (ev.key === 'Escape') { ev.preventDefault(); completer.close(); return; }
2883
+ if (ev.key === 'Enter' && !ev.shiftKey) {
2884
+ ev.preventDefault();
2885
+ if (completer.run()) return;
2886
+ void send();
2887
+ return;
2888
+ }
2889
+ }
2890
+
2891
+ if (ev.key === 'ArrowUp' || ev.key === 'ArrowDown') {
2892
+ if (!historyNavigate(ev.key === 'ArrowUp' ? -1 : 1)) return;
2893
+ ev.preventDefault();
2894
+ return;
2895
+ }
2896
+
2897
+ if (ev.key === 'Enter' && !ev.shiftKey) { ev.preventDefault(); void send(); }
2898
+ });
2899
+ $('composer').addEventListener('submit', (ev) => { ev.preventDefault(); void send(); });
2900
+
2901
+ settingsPanel = new SettingsPanel({
2902
+ act: actReturning,
2903
+ getStatus: () => status,
2904
+ tr,
2905
+ toast: (m) => toast(m),
2906
+ });
2907
+ attachSettings(settingsPanel);
2908
+
2909
+ connect();
2910
+ }
2911
+
2912
+ main();
2913
+