claude-simple 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/README.md +225 -0
  2. package/client.js +139 -0
  3. package/dist/addon-fit.min.js +2 -0
  4. package/dist/app.0.1.0.min.js +825 -0
  5. package/dist/highlight.min.css +10 -0
  6. package/dist/highlight.min.js +1244 -0
  7. package/dist/index.html +16 -0
  8. package/dist/marked.min.js +6 -0
  9. package/dist/style.0.1.0.min.css +1050 -0
  10. package/dist/xterm.min.css +209 -0
  11. package/dist/xterm.min.js +2 -0
  12. package/gateway.js +349 -0
  13. package/package.json +41 -0
  14. package/public/addon-fit.min.js +2 -0
  15. package/public/agent-panel.js +1079 -0
  16. package/public/api.js +78 -0
  17. package/public/app.js +95 -0
  18. package/public/base.css +4 -0
  19. package/public/cache.js +27 -0
  20. package/public/chat.js +1087 -0
  21. package/public/files.js +476 -0
  22. package/public/git.js +130 -0
  23. package/public/index.html +61 -0
  24. package/public/lib.js +2 -0
  25. package/public/router.js +71 -0
  26. package/public/settings.js +90 -0
  27. package/public/shell/boot.js +159 -0
  28. package/public/shell/dashboard.js +269 -0
  29. package/public/shell/hub.js +192 -0
  30. package/public/shell/layout.js +195 -0
  31. package/public/shell/notes.js +323 -0
  32. package/public/shell/session-context.js +19 -0
  33. package/public/shell/session-list.js +442 -0
  34. package/public/shell/session-nav.js +299 -0
  35. package/public/shell/templates.js +342 -0
  36. package/public/shell/util.js +63 -0
  37. package/public/shell.js +589 -0
  38. package/public/state.js +362 -0
  39. package/public/style.css +1048 -0
  40. package/public/terminal.js +177 -0
  41. package/public/xterm.min.css +209 -0
  42. package/public/xterm.min.js +2 -0
  43. package/server.js +12 -0
  44. package/src/agents/claude.js +58 -0
  45. package/src/agents/codex.js +284 -0
  46. package/src/agents/grok.js +318 -0
  47. package/src/agents/meta-agent.js +1952 -0
  48. package/src/agents/models.js +339 -0
  49. package/src/agents/sessions.js +1508 -0
  50. package/src/app.js +1282 -0
package/public/chat.js ADDED
@@ -0,0 +1,1087 @@
1
+ // chat.js — WebSocket, message rendering, send actions
2
+ import { watch, delegate, esc, $ } from './lib.js';
3
+ import { ctx, isProcessing, currentProject, currentTab, currentModel, currentEffort, currentPermission,
4
+ currentAgent, setAgent, AGENT_LABELS, getDefaultModel, getModelsForAgent, getEffortsForModel,
5
+ addCustomModel, chatDensity } from './state.js';
6
+ import { sendWs, api } from './api.js';
7
+
8
+ function assistantLabel() {
9
+ return AGENT_LABELS[currentAgent.peek()] || 'Assistant';
10
+ }
11
+
12
+ // ── Scroll helpers ────────────────────────────────────────────────────────────
13
+ // True when the user is at (or near) the bottom of the message list.
14
+ function atBottom(el) {
15
+ return el.scrollHeight - el.scrollTop - el.clientHeight < 80;
16
+ }
17
+ // Scroll to bottom only if the user was already following along; otherwise leave
18
+ // their scroll position untouched so reading history isn't interrupted.
19
+ function stickBottom(el, force) {
20
+ if (!el) return;
21
+ if (force || atBottom(el)) el.scrollTop = el.scrollHeight;
22
+ }
23
+
24
+ // ── WebSocket ─────────────────────────────────────────────────────────────────
25
+ export function connectWS() {
26
+ ctx.ws?.close();
27
+ const proto = location.protocol === 'https:' ? 'wss' : 'ws';
28
+ const q = new URLSearchParams();
29
+ if (ctx.token) q.set('token', ctx.token);
30
+ // Hub mode: route chat WS to the selected edge
31
+ const mid = ctx.machineId || (typeof localStorage !== 'undefined' ? localStorage.getItem('machineId') : null);
32
+ if (mid) q.set('machine', mid);
33
+ ctx.ws = new WebSocket(`${proto}://${location.host}/ws/chat?${q}`);
34
+ ctx.ws.addEventListener('open', () => appendSystemMsg(mid ? `Connected · ${mid}` : 'Connected'));
35
+ ctx.ws.addEventListener('message', e => handleWsMessage(JSON.parse(e.data)));
36
+ ctx.ws.addEventListener('close', () => { if (isProcessing.peek()) { appendSystemMsg('Connection closed'); isProcessing.value = false; } });
37
+ ctx.ws.addEventListener('error', () => { appendSystemMsg('WebSocket error'); isProcessing.value = false; });
38
+ }
39
+
40
+ function handleWsMessage(msg) {
41
+ if (msg.type === 'session-created') {
42
+ ctx.sessionId = msg.sessionId;
43
+ if (msg.agent) setAgent(msg.agent);
44
+ // Notify shell to refresh session list — use custom event to avoid circular import
45
+ document.dispatchEvent(new CustomEvent('sessions-changed'));
46
+ return;
47
+ }
48
+ if (msg.type === 'result') {
49
+ finalizeThoughtStream();
50
+ finalizeAssistantStream();
51
+ flushToolBatch();
52
+ isProcessing.value = false;
53
+ const u = msg.usage;
54
+ if (u) {
55
+ const norm = {
56
+ input_tokens: u.inputTokens ?? u.input_tokens,
57
+ output_tokens: u.outputTokens ?? u.output_tokens,
58
+ cache_read_input_tokens: u.cacheReadInputTokens ?? u.cache_read_input_tokens,
59
+ cache_creation_input_tokens: u.cacheCreationInputTokens ?? u.cache_creation_input_tokens,
60
+ total_tokens: u.totalTokens ?? u.total_tokens,
61
+ reasoning_tokens: u.reasoningTokens ?? u.reasoning_tokens,
62
+ costUSD: u.costUSD ?? u.cost_usd,
63
+ };
64
+ ctx.lastUsage = norm;
65
+ if (ctx.sessionId) ctx.lastUsageSessionId = ctx.sessionId;
66
+ appendHistoryTokenBar(norm);
67
+ }
68
+ if (!msg.is_error) appendSystemMsg('✓ Done');
69
+ return;
70
+ }
71
+ if (msg.type === 'complete') {
72
+ finalizeThoughtStream();
73
+ finalizeAssistantStream();
74
+ flushToolBatch();
75
+ isProcessing.value = false;
76
+ // result already printed "Done" for codex/grok; avoid double for those
77
+ if (!msg.aborted && msg.agent === 'claude') appendSystemMsg('✓ Done');
78
+ else if (!msg.aborted && msg.agent && msg.agent !== 'claude' && !msg.error) { /* result handled it */ }
79
+ else if (!msg.aborted && !msg.agent) appendSystemMsg('✓ Done');
80
+ return;
81
+ }
82
+ if (msg.type === 'error') {
83
+ finalizeThoughtStream();
84
+ finalizeAssistantStream();
85
+ appendMsg('error', 'Error', msg.message);
86
+ isProcessing.value = false;
87
+ return;
88
+ }
89
+ if (msg.type === 'shell-start') { ctx.shellBubble = null; return; }
90
+ if (msg.type === 'shell-output') {
91
+ if (!ctx.shellBubble) ctx.shellBubble = appendStreamBubble(msg.stream === 'stderr' ? 'shell-err' : 'shell');
92
+ appendToStreamBubble(ctx.shellBubble, msg.data);
93
+ return;
94
+ }
95
+ if (msg.type === 'shell-exit') {
96
+ ctx.shellBubble = null;
97
+ appendSystemMsg(msg.code === 0 ? '✓ Exit 0' : `✗ Exit ${msg.code}${msg.error ? ': ' + msg.error : ''}`);
98
+ isProcessing.value = false;
99
+ return;
100
+ }
101
+ if (msg.type === 'assistant') {
102
+ // Finalize any live stream before a discrete assistant block
103
+ finalizeAssistantStream();
104
+ for (const block of (msg.message?.content || [])) {
105
+ if (block.type === 'text' && block.text) {
106
+ flushToolBatch();
107
+ appendMsg('assistant', assistantLabel(), block.text, { ts: msg.ts || Date.now() });
108
+ } else if (block.type === 'tool_use') {
109
+ renderToolUse(block, { ts: msg.ts || Date.now() });
110
+ }
111
+ }
112
+ return;
113
+ }
114
+
115
+ // ── Live streaming (Grok / future agents) ─────────────────────────────────
116
+ if (msg.type === 'assistant_stream_start') {
117
+ ensureAssistantStream(msg.ts);
118
+ return;
119
+ }
120
+ if (msg.type === 'assistant_delta') {
121
+ appendAssistantDelta(msg.data || '');
122
+ return;
123
+ }
124
+ if (msg.type === 'assistant_stream_end') {
125
+ finalizeAssistantStream();
126
+ return;
127
+ }
128
+ if (msg.type === 'thought_stream_start') {
129
+ ensureThoughtStream();
130
+ return;
131
+ }
132
+ if (msg.type === 'thought_delta') {
133
+ appendThoughtDelta(msg.data || '');
134
+ return;
135
+ }
136
+ if (msg.type === 'thought_stream_end') {
137
+ finalizeThoughtStream();
138
+ return;
139
+ }
140
+ if (msg.type === 'status') {
141
+ // Lightweight status line under typing indicator
142
+ const el = $('stream-status');
143
+ if (el) el.textContent = msg.message || '';
144
+ else {
145
+ const msgs = $('messages');
146
+ if (msgs && msg.message) {
147
+ msgs.insertAdjacentHTML('beforeend',
148
+ `<div id="stream-status" class="text-center text-[10px] text-base-content/35 py-0.5">${esc(msg.message)}</div>`);
149
+ stickBottom(msgs);
150
+ }
151
+ }
152
+ return;
153
+ }
154
+
155
+ if (msg.type === 'permission_request' || (msg.request_id && msg.tool_name))
156
+ renderPermissionRequest(msg);
157
+ }
158
+
159
+ // ── Live stream bubbles ───────────────────────────────────────────────────────
160
+ /** @type {HTMLElement | null} */
161
+ let liveAsstEl = null;
162
+ /** @type {HTMLElement | null} */
163
+ let liveAsstBody = null;
164
+ /** @type {HTMLElement | null} */
165
+ let liveThoughtEl = null;
166
+ /** @type {HTMLElement | null} */
167
+ let liveThoughtBody = null;
168
+
169
+ function ensureAssistantStream(ts) {
170
+ const msgs = $('messages');
171
+ if (!msgs) return;
172
+ flushToolBatch();
173
+ finalizeThoughtStream();
174
+ if (liveAsstEl && msgs.contains(liveAsstEl)) return;
175
+
176
+ const t = formatMsgTime(ts || Date.now());
177
+ const title = tsAttr(ts || Date.now());
178
+ msgs.insertAdjacentHTML('beforeend', `
179
+ <div class="msg-row assistant px-2 live-stream" data-ts="${esc(title)}">
180
+ <div class="max-w-[88%] rounded-2xl rounded-tl-sm px-3 py-2 text-sm bg-base-200 break-words md">
181
+ <div class="msg-stream-body whitespace-pre-wrap"></div>
182
+ </div>
183
+ ${t ? `<div class="msg-ts" title="${esc(title)}">${esc(t)}</div>` : ''}
184
+ </div>`);
185
+ liveAsstEl = msgs.lastElementChild;
186
+ liveAsstBody = liveAsstEl.querySelector('.msg-stream-body');
187
+ $('stream-status')?.remove();
188
+ stickBottom(msgs, true);
189
+ }
190
+
191
+ function appendAssistantDelta(chunk) {
192
+ if (!chunk) return;
193
+ if (!liveAsstBody) ensureAssistantStream(Date.now());
194
+ if (!liveAsstBody) return;
195
+ // Keep raw text while streaming; re-render markdown on finalize
196
+ liveAsstBody.textContent += chunk;
197
+ stickBottom($('messages'), true);
198
+ }
199
+
200
+ function finalizeAssistantStream() {
201
+ if (!liveAsstEl || !liveAsstBody) {
202
+ liveAsstEl = null;
203
+ liveAsstBody = null;
204
+ return;
205
+ }
206
+ const text = liveAsstBody.textContent || '';
207
+ if (text.trim()) {
208
+ // Upgrade plain text to markdown rendering
209
+ liveAsstBody.classList.remove('whitespace-pre-wrap');
210
+ liveAsstBody.classList.add('md');
211
+ liveAsstBody.innerHTML = renderMarkdown(text);
212
+ highlightCode(liveAsstEl);
213
+ } else {
214
+ liveAsstEl.remove();
215
+ }
216
+ liveAsstEl = null;
217
+ liveAsstBody = null;
218
+ $('stream-status')?.remove();
219
+ }
220
+
221
+ function ensureThoughtStream() {
222
+ const msgs = $('messages');
223
+ if (!msgs) return;
224
+ if (liveThoughtEl && msgs.contains(liveThoughtEl)) return;
225
+ msgs.insertAdjacentHTML('beforeend', `
226
+ <div class="msg-row assistant px-2 thought-stream">
227
+ <div class="max-w-[88%] rounded-lg border border-base-300/60 bg-base-200/50 px-2.5 py-1.5 text-[11px] text-base-content/45">
228
+ <div class="font-medium text-base-content/35 mb-0.5">thinking…</div>
229
+ <div class="thought-body font-mono whitespace-pre-wrap max-h-24 overflow-y-auto opacity-80"></div>
230
+ </div>
231
+ </div>`);
232
+ liveThoughtEl = msgs.lastElementChild;
233
+ liveThoughtBody = liveThoughtEl.querySelector('.thought-body');
234
+ stickBottom(msgs, true);
235
+ }
236
+
237
+ function appendThoughtDelta(chunk) {
238
+ if (!chunk) return;
239
+ if (!liveThoughtBody) ensureThoughtStream();
240
+ if (!liveThoughtBody) return;
241
+ liveThoughtBody.textContent += chunk;
242
+ // Keep thought panel scrolled to end
243
+ liveThoughtBody.scrollTop = liveThoughtBody.scrollHeight;
244
+ stickBottom($('messages'), true);
245
+ }
246
+
247
+ function finalizeThoughtStream() {
248
+ // Collapse finished thoughts to a one-liner so the chat stays clean
249
+ if (liveThoughtEl && liveThoughtBody) {
250
+ const n = (liveThoughtBody.textContent || '').length;
251
+ liveThoughtEl.innerHTML = `
252
+ <div class="text-[10px] text-base-content/30 px-1 select-none">💭 thought ${n ? `(${n} chars)` : ''}</div>`;
253
+ }
254
+ liveThoughtEl = null;
255
+ liveThoughtBody = null;
256
+ }
257
+
258
+ // ── Slash commands (client-side) ──────────────────────────────────────────────
259
+ const MODEL_ALIASES = {
260
+ 'haiku': 'claude-haiku-4-5',
261
+ 'sonnet': 'claude-sonnet-4-6',
262
+ 'opus': 'claude-opus-4-5',
263
+ 'sonnet4': 'claude-sonnet-4-6',
264
+ 'haiku4': 'claude-haiku-4-5',
265
+ };
266
+
267
+ // Returns true if handled, false if should be sent to model
268
+ function handleSlashCommand(text) {
269
+ const [cmd, ...args] = text.slice(1).trim().split(/\s+/);
270
+ const arg = args.join(' ');
271
+
272
+ switch (cmd.toLowerCase()) {
273
+ case 'agent': {
274
+ if (!arg) {
275
+ appendSystemMsg(`Current agent: ${currentAgent.peek()} · agents: claude | codex | grok`);
276
+ return true;
277
+ }
278
+ const a = arg.toLowerCase();
279
+ if (!['claude', 'codex', 'grok'].includes(a)) {
280
+ appendSystemMsg(`Unknown agent "${arg}" · valid: claude | codex | grok`);
281
+ return true;
282
+ }
283
+ // Switching agent starts a fresh conversation
284
+ if (a !== currentAgent.peek()) {
285
+ ctx.sessionId = null;
286
+ setAgent(a);
287
+ currentModel.value = getDefaultModel(a);
288
+ localStorage.setItem('model', currentModel.peek());
289
+ document.dispatchEvent(new CustomEvent('agent-changed'));
290
+ appendSystemMsg(`Agent → ${AGENT_LABELS[a]} · model ${currentModel.peek()} · new session`);
291
+ } else {
292
+ appendSystemMsg(`Agent already ${AGENT_LABELS[a]}`);
293
+ }
294
+ return true;
295
+ }
296
+ case 'model': {
297
+ if (!arg) {
298
+ const agent = currentAgent.peek();
299
+ const list = getModelsForAgent(agent).map(m => m.value).join(' | ');
300
+ appendSystemMsg(`Current model: ${currentModel.peek()} (${agent})${list ? `\nAvailable: ${list}` : ''}`);
301
+ return true;
302
+ }
303
+ const resolved = MODEL_ALIASES[arg.toLowerCase()] || arg;
304
+ const agent = currentAgent.peek() || 'claude';
305
+ // Persist free-form ids so they stay in the model dropdown
306
+ const known = getModelsForAgent(agent).some(m => m.value === resolved);
307
+ if (!known) addCustomModel(agent, resolved);
308
+ currentModel.value = resolved;
309
+ localStorage.setItem('model', resolved);
310
+ appendSystemMsg(`Model set to ${resolved}${known ? '' : ' (saved as custom ★)'}`);
311
+ document.dispatchEvent(new CustomEvent('agent-changed'));
312
+ return true;
313
+ }
314
+ case 'effort': {
315
+ const levels = getEffortsForModel(currentAgent.peek(), currentModel.peek());
316
+ if (!arg) {
317
+ appendSystemMsg(`Current effort: ${currentEffort.peek() || '(default)'} · levels: ${levels.join(' | ')}`);
318
+ return true;
319
+ }
320
+ if (!levels.includes(arg.toLowerCase())) {
321
+ appendSystemMsg(`Unknown effort level "${arg}" · valid: ${levels.join(' | ')}`);
322
+ return true;
323
+ }
324
+ currentEffort.value = arg.toLowerCase();
325
+ appendSystemMsg(`Effort set to ${arg.toLowerCase()}`);
326
+ return true;
327
+ }
328
+ case 'clear':
329
+ clearMessages();
330
+ ctx.sessionId = null;
331
+ ctx.lastUsage = null;
332
+ appendSystemMsg('Cleared · new session');
333
+ return true;
334
+ case 'usage': {
335
+ // Async fetch — return true immediately so message isn't sent to the model
336
+ showUsageCommand();
337
+ return true;
338
+ }
339
+ case 'help': {
340
+ appendSystemMsg(`Available slash commands:\n /agent [claude|codex|grok]\n /model [haiku|sonnet|opus|<model-id>]\n /effort [low|medium|high|xhigh|max]\n /usage\n /clear\n /help`);
341
+ return true;
342
+ }
343
+ default: {
344
+ return false;
345
+ }
346
+ }
347
+ }
348
+
349
+ function fmtUsageNum(n) {
350
+ if (n == null || Number.isNaN(Number(n))) return '—';
351
+ const v = Number(n);
352
+ if (v >= 1_000_000) return `${(v / 1_000_000).toFixed(2)}M`;
353
+ if (v >= 1000) return `${(v / 1000).toFixed(1)}k`;
354
+ return String(Math.round(v));
355
+ }
356
+
357
+ /** /usage — show last-turn + session context (Grok signals, etc.) */
358
+ async function showUsageCommand() {
359
+ const agent = currentAgent.peek() || 'claude';
360
+ const model = currentModel.peek() || '—';
361
+ const sid = ctx.sessionId;
362
+ const lines = [
363
+ `Agent: ${AGENT_LABELS[agent] || agent}`,
364
+ `Model: ${model}`,
365
+ `Session: ${sid ? sid.slice(0, 8) + '…' : '(new — send a message first)'}`,
366
+ ];
367
+
368
+ // Live last-turn from this browser session
369
+ if (ctx.lastUsage && (!sid || ctx.lastUsageSessionId === sid)) {
370
+ const u = ctx.lastUsage;
371
+ lines.push(
372
+ `Last turn: ↑${fmtUsageNum(u.input_tokens)} ↓${fmtUsageNum(u.output_tokens)}`
373
+ + (u.cache_read_input_tokens ? ` · ${fmtUsageNum(u.cache_read_input_tokens)} cached` : '')
374
+ + (u.reasoning_tokens ? ` · ${fmtUsageNum(u.reasoning_tokens)} reason` : '')
375
+ + (u.total_tokens ? ` · Σ${fmtUsageNum(u.total_tokens)}` : '')
376
+ + (u.costUSD != null ? ` · $${Number(u.costUSD).toFixed(4)}` : ''),
377
+ );
378
+ }
379
+
380
+ if (!sid) {
381
+ appendSystemMsg(lines.join('\n'));
382
+ appendTokenBar('◈ /usage · no session yet');
383
+ return;
384
+ }
385
+
386
+ appendSystemMsg('Loading usage…');
387
+ try {
388
+ const q = new URLSearchParams({ sessionId: sid, agent });
389
+ const data = await api('GET', `/api/usage?${q}`);
390
+ const c = data.context || {};
391
+ if (c.model) lines[1] = `Model: ${c.model}`;
392
+ if (c.title) lines.push(`Title: ${c.title}`);
393
+
394
+ if (c.tokensUsed != null || c.windowTokens != null || c.usagePercent != null) {
395
+ const used = c.tokensUsed != null ? fmtUsageNum(c.tokensUsed) : '—';
396
+ const win = c.windowTokens != null ? fmtUsageNum(c.windowTokens) : '—';
397
+ const pct = c.usagePercent != null ? `${c.usagePercent}%` : '—';
398
+ lines.push(`Context window: ${used} / ${win} (${pct})`);
399
+ }
400
+ if (c.turnCount != null) lines.push(`Turns: ${c.turnCount}`);
401
+ if (c.toolCallCount != null) lines.push(`Tools: ${c.toolCallCount}`);
402
+ if (c.sessionDurationSeconds != null) {
403
+ const m = Math.floor(c.sessionDurationSeconds / 60);
404
+ const s = Math.round(c.sessionDurationSeconds % 60);
405
+ lines.push(`Duration: ${m}m ${s}s`);
406
+ }
407
+ if (c.sessionTotals) {
408
+ const t = c.sessionTotals;
409
+ lines.push(
410
+ `Session totals: ↑${fmtUsageNum(t.input_tokens)} ↓${fmtUsageNum(t.output_tokens)}`
411
+ + (t.cache_read_input_tokens ? ` · ${fmtUsageNum(t.cache_read_input_tokens)} cached` : '')
412
+ + (t.total_tokens ? ` · Σ${fmtUsageNum(t.total_tokens)}` : '')
413
+ + (t.costUSD ? ` · $${Number(t.costUSD).toFixed(4)}` : ''),
414
+ );
415
+ }
416
+ if (c.lastTurn && !ctx.lastUsage) {
417
+ const u = c.lastTurn;
418
+ lines.push(
419
+ `Last completed turn: ↑${fmtUsageNum(u.input_tokens)} ↓${fmtUsageNum(u.output_tokens)}`
420
+ + (u.cache_read_input_tokens ? ` · ${fmtUsageNum(u.cache_read_input_tokens)} cached` : '')
421
+ + (u.costUSD != null ? ` · $${Number(u.costUSD).toFixed(4)}` : ''),
422
+ );
423
+ }
424
+ if (c.note) lines.push(c.note);
425
+
426
+ // Replace "Loading usage…" with full report
427
+ const msgs = $('messages');
428
+ if (msgs) {
429
+ const kids = [...msgs.children];
430
+ for (let i = kids.length - 1; i >= 0; i--) {
431
+ if (kids[i].textContent === 'Loading usage…') { kids[i].remove(); break; }
432
+ }
433
+ }
434
+ appendSystemMsg(lines.join('\n'));
435
+
436
+ // Visual token / context bars
437
+ if (c.tokensUsed != null || c.windowTokens != null) {
438
+ appendContextBar({
439
+ tokensUsed: c.tokensUsed,
440
+ windowTokens: c.windowTokens,
441
+ usagePercent: c.usagePercent,
442
+ model: c.model || model,
443
+ turnCount: c.turnCount,
444
+ toolCallCount: c.toolCallCount,
445
+ });
446
+ }
447
+ if (c.sessionTotals) {
448
+ appendHistoryTokenBar(c.sessionTotals);
449
+ } else if (c.lastTurn) {
450
+ appendHistoryTokenBar(c.lastTurn);
451
+ } else if (ctx.lastUsage) {
452
+ appendHistoryTokenBar(ctx.lastUsage);
453
+ }
454
+ } catch (e) {
455
+ const msgs = $('messages');
456
+ if (msgs) {
457
+ const kids = [...msgs.children];
458
+ for (let i = kids.length - 1; i >= 0; i--) {
459
+ if (kids[i].textContent === 'Loading usage…') { kids[i].remove(); break; }
460
+ }
461
+ }
462
+ appendSystemMsg(`${lines.join('\n')}\n(usage fetch failed: ${e.message})`);
463
+ }
464
+ }
465
+
466
+ // ── Send / control ────────────────────────────────────────────────────────────
467
+ export function sendMessage() {
468
+ const input = $('chat-input');
469
+ const text = input?.value.trim();
470
+ if (!text || isProcessing.peek() || !currentProject.peek()) return;
471
+ input.value = '';
472
+ autoResize(input);
473
+ updateInputMode('');
474
+
475
+ if (text.startsWith('/')) {
476
+ if (handleSlashCommand(text)) {
477
+ return;
478
+ }
479
+ }
480
+
481
+ isProcessing.value = true;
482
+ const now = Date.now();
483
+ if (text.startsWith('!')) {
484
+ appendMsg('shell', '$ Shell', text.slice(1).trim(), { ts: now });
485
+ sendWs({ type: 'shell-command', command: text.slice(1).trim(), cwd: currentProject.peek().path });
486
+ } else {
487
+ flushToolBatch();
488
+ appendMsg('user', 'You', text, { ts: now });
489
+ const effort = currentEffort.peek();
490
+ const permission = currentPermission.peek();
491
+ const agent = currentAgent.peek() || 'claude';
492
+ sendWs({ type: 'agent-command', agent, command: text, options: {
493
+ agent,
494
+ cwd: currentProject.peek().path,
495
+ sessionId: ctx.sessionId,
496
+ configDir: ctx.configDir,
497
+ model: currentModel.peek(),
498
+ ...(effort && { effort }),
499
+ ...(permission && permission !== 'default' && { permissionMode: permission }),
500
+ ...(permission === 'bypassPermissions' && { allowDangerouslySkipPermissions: true }),
501
+ }});
502
+ }
503
+ }
504
+
505
+ export function stopProcessing() {
506
+ if (ctx.sessionId) sendWs({ type: 'abort-session', sessionId: ctx.sessionId });
507
+ isProcessing.value = false;
508
+ }
509
+
510
+ export function newSession() {
511
+ ctx.sessionId = null;
512
+ clearMessages();
513
+ appendSystemMsg('New session — type to start');
514
+ }
515
+
516
+ // ── Input helpers ─────────────────────────────────────────────────────────────
517
+ function updateInputMode(value) {
518
+ const isShell = value.startsWith('!');
519
+ const input = $('chat-input'), btn = $('send-btn');
520
+ if (!input || !btn) return;
521
+ input.classList.toggle('textarea-warning', isShell);
522
+ input.style.fontFamily = isShell ? 'monospace' : '';
523
+ btn.classList.toggle('btn-warning', isShell);
524
+ btn.classList.toggle('btn-primary', !isShell);
525
+ btn.textContent = isShell ? 'Run' : 'Send';
526
+ const label = AGENT_LABELS[currentAgent.peek()] || 'agent';
527
+ input.placeholder = isShell ? 'Shell command (without !)…' : `Ask ${label}… or !cmd for shell`;
528
+ }
529
+
530
+ function autoResize(el) {
531
+ el.style.height = 'auto';
532
+ el.style.height = Math.min(el.scrollHeight, 140) + 'px';
533
+ }
534
+
535
+ // ── Message rendering ─────────────────────────────────────────────────────────
536
+ const USER_COLLAPSE = 200;
537
+ const ASST_COLLAPSE = 500;
538
+
539
+ // Re-export shared timestamp helper (also used by shell/dashboard)
540
+ export { coerceTs } from './shell/util.js';
541
+ import { coerceTs } from './shell/util.js';
542
+
543
+ /**
544
+ * Format a timestamp for chat (local time).
545
+ * Today → HH:mm:ss ; other day → M/D HH:mm:ss
546
+ */
547
+ export function formatMsgTime(ts) {
548
+ const ms = coerceTs(ts);
549
+ if (ms == null) return '';
550
+ const d = new Date(ms);
551
+ if (Number.isNaN(d.getTime())) return '';
552
+ const now = new Date();
553
+ const sameDay = d.toDateString() === now.toDateString();
554
+ const hms = d.toLocaleTimeString(undefined, {
555
+ hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false,
556
+ });
557
+ if (sameDay) return hms;
558
+ const md = d.toLocaleDateString(undefined, { month: 'numeric', day: 'numeric' });
559
+ return `${md} ${hms}`;
560
+ }
561
+
562
+ function tsAttr(ts) {
563
+ const ms = coerceTs(ts);
564
+ if (ms == null) return '';
565
+ return new Date(ms).toISOString();
566
+ }
567
+
568
+ function wrapMsgRow(role, bubbleInner, ts) {
569
+ if (role === 'system') return bubbleInner;
570
+ const t = formatMsgTime(ts);
571
+ const title = tsAttr(ts);
572
+ const tsHtml = t
573
+ ? `<div class="msg-ts" title="${esc(title)}">${esc(t)}</div>`
574
+ : '';
575
+ // user / assistant alignment
576
+ const align = role === 'user' ? 'user' : (role === 'shell' || role === 'shell-err' ? 'assistant' : role);
577
+ return `<div class="msg-row ${align} px-2" data-ts="${esc(title)}">${bubbleInner}${tsHtml}</div>`;
578
+ }
579
+
580
+ function bubbleHtml(role, text, ts) {
581
+ const expandBtn = `<button class="msg-expand-btn text-[10px] text-primary/70 mt-1.5 block cursor-pointer hover:text-primary">▼ Show more</button>`;
582
+ let bubble;
583
+ switch (role) {
584
+ case 'user': {
585
+ const hasImage = /!\[.*?\]\(.*?\)/.test(text);
586
+ const rendered = hasImage ? renderMarkdown(text) : null;
587
+ const long = text.length > USER_COLLAPSE;
588
+ const inner = long
589
+ ? `<div class="msg-body">${rendered ?? esc(text.trimEnd())}</div>${expandBtn}`
590
+ : (rendered ?? esc(text.trimEnd()));
591
+ const style = rendered ? '' : ' style="white-space:pre-wrap"';
592
+ bubble = `<div class="max-w-[80%] rounded-2xl rounded-tr-sm px-3 py-2 text-sm bg-primary/15 border border-primary/20 text-base-content break-words${long ? ' msg-card' : ''}${rendered ? ' md' : ''}"${style}>${inner}</div>`;
593
+ break;
594
+ }
595
+ case 'assistant': {
596
+ const long = text.length > ASST_COLLAPSE;
597
+ const inner = long ? `<div class="msg-body">${renderMarkdown(text)}</div>${expandBtn}` : renderMarkdown(text);
598
+ bubble = `<div class="max-w-[min(92%,52rem)] rounded-2xl rounded-tl-sm px-3.5 py-2.5 text-sm bg-base-200 border border-base-300/40 break-words md leading-relaxed${long ? ' msg-card' : ''}">${inner}</div>`;
599
+ break;
600
+ }
601
+ case 'error':
602
+ bubble = `<div class="max-w-[88%] rounded-2xl rounded-tl-sm px-3 py-2 text-sm bg-error/10 border border-error/20 text-error break-words" style="white-space:pre-wrap">${esc(text)}</div>`;
603
+ break;
604
+ case 'shell':
605
+ case 'shell-err':
606
+ bubble = `<div class="w-full max-w-[96%] rounded-lg code-surface px-3 py-2 text-sm font-mono ${role === 'shell-err' ? 'text-error' : ''} break-words" style="white-space:pre-wrap">${esc(text)}</div>`;
607
+ break;
608
+ case 'system':
609
+ return `<div class="text-center text-[11px] text-base-content/30 py-0.5 select-none">${esc(text)}</div>`;
610
+ default:
611
+ bubble = `<div class="rounded-lg px-3 py-2 text-sm bg-base-300 break-words" style="white-space:pre-wrap">${esc(text)}</div>`;
612
+ }
613
+ return wrapMsgRow(role, bubble, ts);
614
+ }
615
+
616
+ /**
617
+ * @param {string} role
618
+ * @param {string} _label
619
+ * @param {string} text
620
+ * @param {{ ts?: number|string }} [opts]
621
+ */
622
+ export function appendMsg(role, _label, text, opts = {}) {
623
+ const msgs = $('messages');
624
+ if (!msgs) return;
625
+ const ts = coerceTs(opts.ts) ?? (role === 'system' ? null : Date.now());
626
+ msgs.insertAdjacentHTML('beforeend', bubbleHtml(role, text, ts));
627
+ highlightCode(msgs.lastElementChild);
628
+ stickBottom(msgs, role === 'user');
629
+ }
630
+
631
+ export function appendSystemMsg(text) { appendMsg('system', '', text); }
632
+
633
+ async function uploadImageBlob(blob, filename) {
634
+ const res = await fetch('/api/upload-image', {
635
+ method: 'POST',
636
+ headers: { Authorization: `Bearer ${ctx.token}`, 'x-filename': filename },
637
+ body: blob,
638
+ });
639
+ if (!res.ok) throw new Error(`Upload failed: ${res.status}`);
640
+ return (await res.json()).path;
641
+ }
642
+
643
+ function insertAtCursor(ta, text) {
644
+ const s = ta.selectionStart, e = ta.selectionEnd;
645
+ ta.value = ta.value.slice(0, s) + text + ta.value.slice(e);
646
+ ta.selectionStart = ta.selectionEnd = s + text.length;
647
+ ta.dispatchEvent(new Event('input'));
648
+ }
649
+
650
+ export function initImagePaste() {
651
+ document.addEventListener('paste', async e => {
652
+ const ta = $('chat-input');
653
+ if (!ta || document.activeElement !== ta) return;
654
+ const items = [...(e.clipboardData?.items || [])];
655
+ const imageItem = items.find(i => i.type.startsWith('image/'));
656
+ if (!imageItem) return;
657
+ e.preventDefault();
658
+ const blob = imageItem.getAsFile();
659
+ const ext = imageItem.type.split('/')[1] || 'png';
660
+ const name = `paste-${Date.now()}.${ext}`;
661
+ ta.disabled = true;
662
+ try {
663
+ const path = await uploadImageBlob(blob, name);
664
+ insertAtCursor(ta, `![${name}](${path})`);
665
+ } catch (err) {
666
+ appendSystemMsg(`Image paste failed: ${err.message}`);
667
+ } finally {
668
+ ta.disabled = false;
669
+ ta.focus();
670
+ }
671
+ });
672
+ }
673
+
674
+ export function attachImage() {
675
+ const input = document.createElement('input');
676
+ input.type = 'file';
677
+ input.accept = 'image/*';
678
+ input.multiple = true;
679
+ input.addEventListener('change', async () => {
680
+ for (const file of input.files) {
681
+ appendSystemMsg(`Uploading ${file.name}…`);
682
+ try {
683
+ const res = await fetch('/api/upload-image', {
684
+ method: 'POST',
685
+ headers: { Authorization: `Bearer ${ctx.token}`, 'x-filename': file.name },
686
+ body: file,
687
+ });
688
+ const { path } = await res.json();
689
+ // Insert path reference into chat input
690
+ const ta = $('chat-input');
691
+ if (ta) {
692
+ ta.value += (ta.value ? '\n' : '') + path;
693
+ ta.dispatchEvent(new Event('input'));
694
+ ta.focus();
695
+ }
696
+ appendSystemMsg(`📎 ${path}`);
697
+ } catch (e) { appendSystemMsg(`Upload failed: ${e.message}`); }
698
+ }
699
+ });
700
+ input.click();
701
+ }
702
+
703
+ function fmtNum(n) { return n >= 1000 ? `${(n/1000).toFixed(1)}k` : String(n); }
704
+
705
+ function appendTokenBar(text) {
706
+ const msgs = $('messages');
707
+ if (!msgs) return;
708
+ msgs.insertAdjacentHTML('beforeend',
709
+ `<div class="flex items-center gap-1.5 px-1 py-0.5 text-[10px] font-mono text-base-content/30 select-none">
710
+ <span class="text-base-content/20">◈</span>${esc(text)}
711
+ </div>`);
712
+ stickBottom(msgs);
713
+ }
714
+
715
+ // Called when loading history from JSONL (usage keys use snake_case from file)
716
+ export function appendHistoryTokenBar(u) {
717
+ if (!u) return;
718
+ const input = u.input_tokens ?? u.inputTokens ?? 0;
719
+ const output = u.output_tokens ?? u.outputTokens ?? 0;
720
+ const total = u.total_tokens ?? u.totalTokens ?? (input + output);
721
+ const cache = (u.cache_read_input_tokens ?? u.cachedReadTokens)
722
+ ? ` · ${fmtNum(u.cache_read_input_tokens ?? u.cachedReadTokens)} cached` : '';
723
+ const create = (u.cache_creation_input_tokens ?? u.cacheCreationInputTokens)
724
+ ? ` · ${fmtNum(u.cache_creation_input_tokens ?? u.cacheCreationInputTokens)} cache-write` : '';
725
+ const reason = (u.reasoning_tokens ?? u.reasoningTokens)
726
+ ? ` · ${fmtNum(u.reasoning_tokens ?? u.reasoningTokens)} reason` : '';
727
+ const costVal = u.costUSD ?? u.cost_usd;
728
+ const cost = costVal != null ? ` · $${Number(costVal).toFixed(4)}` : '';
729
+ const tot = total ? ` · Σ${fmtNum(total)}` : '';
730
+ appendTokenBar(`↑${fmtNum(input)} ↓${fmtNum(output)}${cache}${create}${reason}${tot}${cost}`);
731
+ }
732
+
733
+ /** Session-level context window footer (Grok signals.json etc.) */
734
+ export function appendContextBar(ctxInfo) {
735
+ if (!ctxInfo) return;
736
+ const used = ctxInfo.tokensUsed ?? ctxInfo.contextTokensUsed;
737
+ const win = ctxInfo.windowTokens ?? ctxInfo.contextWindowTokens;
738
+ const pct = ctxInfo.usagePercent ?? ctxInfo.contextWindowUsage;
739
+ if (used == null && win == null && pct == null) return;
740
+ const parts = [];
741
+ if (used != null && win != null) parts.push(`context ${fmtNum(used)} / ${fmtNum(win)}`);
742
+ else if (used != null) parts.push(`context ${fmtNum(used)}`);
743
+ if (pct != null) parts.push(`${pct}%`);
744
+ if (ctxInfo.model) parts.push(ctxInfo.model);
745
+ if (ctxInfo.turnCount != null) parts.push(`${ctxInfo.turnCount} turns`);
746
+ if (ctxInfo.toolCallCount != null) parts.push(`${ctxInfo.toolCallCount} tools`);
747
+ appendTokenBar(`▣ ${parts.join(' · ')}`);
748
+ }
749
+
750
+ export function clearMessages() {
751
+ const msgs = $('messages'), perms = $('permission-requests');
752
+ if (msgs) msgs.innerHTML = '';
753
+ if (perms) perms.innerHTML = '';
754
+ ctx.shellBubble = null;
755
+ toolBatch = null;
756
+ liveAsstEl = liveAsstBody = null;
757
+ liveThoughtEl = liveThoughtBody = null;
758
+ }
759
+
760
+ function appendStreamBubble(role) {
761
+ const msgs = $('messages');
762
+ if (!msgs) return null;
763
+ const err = role === 'shell-err' ? ' text-error' : '';
764
+ msgs.insertAdjacentHTML('beforeend',
765
+ `<div class="mx-2 rounded-lg code-surface px-3 py-2 text-sm font-mono${err} whitespace-pre-wrap break-words"></div>`);
766
+ stickBottom(msgs);
767
+ return msgs.lastElementChild;
768
+ }
769
+
770
+ function appendToStreamBubble(el, text) {
771
+ if (!el) return;
772
+ el.textContent += text;
773
+ stickBottom(el.closest('#messages'));
774
+ }
775
+
776
+ // ── Tool rendering + density ──────────────────────────────────────────────────
777
+ /** @type {{ el: HTMLElement, names: string[], count: number } | null} */
778
+ let toolBatch = null;
779
+
780
+ function toolPreview(block) {
781
+ const inputStr = typeof block.input === 'object'
782
+ ? JSON.stringify(block.input, null, 2)
783
+ : String(block.input || '');
784
+ const first = typeof block.input === 'object'
785
+ ? Object.values(block.input).find(v => typeof v === 'string' && v.trim())
786
+ : inputStr;
787
+ const preview = (first || '').replace(/\n/g, ' ').slice(0, chatDensity.peek() === 'full' ? 120 : 80);
788
+ return { inputStr, preview };
789
+ }
790
+
791
+ function toolCardHtml(block, { inBatch = false } = {}) {
792
+ const { inputStr, preview } = toolPreview(block);
793
+ return `<div class="tool-card${inBatch ? ' tool-batch-item' : ''} cursor-pointer select-none px-2 py-1">
794
+ <div class="flex items-center gap-1.5 text-xs font-mono">
795
+ <span class="text-warning">⚡</span>
796
+ <span class="text-warning font-semibold">${esc(block.name || 'tool')}</span>
797
+ <span class="text-base-content/50 truncate flex-1">${esc(preview)}</span>
798
+ <span class="tool-chevron text-[10px] text-base-content/40">▶</span>
799
+ </div>
800
+ <div class="tool-detail">${esc(inputStr)}</div>
801
+ </div>`;
802
+ }
803
+
804
+ function updateBatchHeader(batch) {
805
+ const uniq = [...new Set(batch.names)];
806
+ const sample = uniq.slice(0, 4).join(', ') + (uniq.length > 4 ? '…' : '');
807
+ const summary = batch.el.querySelector('.tool-batch-summary');
808
+ if (summary) {
809
+ summary.innerHTML =
810
+ `<span class="text-warning">⚡</span>
811
+ <span class="font-semibold text-base-content/70">${batch.count} tool${batch.count === 1 ? '' : 's'}</span>
812
+ <span class="text-base-content/40 truncate flex-1">${esc(sample)}</span>
813
+ <span class="tool-chevron text-[10px] text-base-content/40">▶</span>`;
814
+ }
815
+ }
816
+
817
+ /** Flush open tool batch (e.g. before user/assistant text). */
818
+ export function flushToolBatch() {
819
+ toolBatch = null;
820
+ }
821
+
822
+ /**
823
+ * Render a tool_use block, respecting chat density.
824
+ * clean → collapse consecutive tools into one expandable batch
825
+ * normal → one-line cards
826
+ * full → one-line cards (longer preview)
827
+ */
828
+ export function renderToolUse(block, opts = {}) {
829
+ const msgs = $('messages');
830
+ if (!msgs) return;
831
+ const density = chatDensity.peek() || 'normal';
832
+ const ts = coerceTs(opts.ts);
833
+
834
+ if (density === 'clean') {
835
+ // Append into current batch or start a new one
836
+ if (!toolBatch || !msgs.contains(toolBatch.el)) {
837
+ const t = formatMsgTime(ts);
838
+ const title = tsAttr(ts);
839
+ msgs.insertAdjacentHTML('beforeend', `
840
+ <div class="msg-row assistant px-2" data-ts="${esc(title)}">
841
+ <div class="tool-batch w-full max-w-[96%] px-2 py-1.5">
842
+ <div class="tool-batch-summary flex items-center gap-1.5 text-xs font-mono"></div>
843
+ <div class="tool-batch-list"></div>
844
+ </div>
845
+ ${t ? `<div class="msg-ts" title="${esc(title)}">${esc(t)}</div>` : ''}
846
+ </div>`);
847
+ const row = msgs.lastElementChild;
848
+ toolBatch = {
849
+ el: row.querySelector('.tool-batch'),
850
+ names: [],
851
+ count: 0,
852
+ };
853
+ }
854
+ toolBatch.names.push(block.name || 'tool');
855
+ toolBatch.count++;
856
+ const list = toolBatch.el.querySelector('.tool-batch-list');
857
+ list?.insertAdjacentHTML('beforeend', toolCardHtml(block, { inBatch: true }));
858
+ updateBatchHeader(toolBatch);
859
+ stickBottom(msgs);
860
+ return;
861
+ }
862
+
863
+ // normal / full — individual cards
864
+ toolBatch = null;
865
+ const t = formatMsgTime(ts);
866
+ const title = tsAttr(ts);
867
+ msgs.insertAdjacentHTML('beforeend', `
868
+ <div class="msg-row assistant px-2" data-ts="${esc(title)}">
869
+ <div class="w-full max-w-[96%]">${toolCardHtml(block)}</div>
870
+ ${t ? `<div class="msg-ts" title="${esc(title)}">${esc(t)}</div>` : ''}
871
+ </div>`);
872
+ stickBottom(msgs);
873
+ }
874
+
875
+ /** Apply density attribute on the messages container. */
876
+ export function applyChatDensity() {
877
+ const el = $('messages');
878
+ if (el) el.setAttribute('data-density', chatDensity.peek() || 'normal');
879
+ }
880
+
881
+ function renderPermissionRequest(msg) {
882
+ const id = msg.request_id || msg.requestId;
883
+ if (!id || $(`perm-${id}`)) return;
884
+ const inputStr = msg.tool_input ? JSON.stringify(msg.tool_input).slice(0, 200) : '';
885
+ $('permission-requests')?.insertAdjacentHTML('beforeend', `
886
+ <div id="perm-${id}" class="perm-banner mx-3 mb-2 rounded-lg border border-warning/25 bg-warning/8 p-3">
887
+ <div class="text-xs text-warning font-semibold mb-1">⚠️ Permission Required</div>
888
+ <div class="font-mono text-xs text-base-content/60 break-all mb-2">
889
+ <strong class="text-base-content">${esc(msg.tool_name || '')}</strong> ${esc(inputStr)}
890
+ </div>
891
+ <div class="flex gap-2">
892
+ <button class="btn btn-success btn-xs" data-perm-id="${id}" data-perm-allow="1">Allow</button>
893
+ <button class="btn btn-error btn-xs" data-perm-id="${id}" data-perm-allow="0">Deny</button>
894
+ </div>
895
+ </div>`);
896
+ $('messages').scrollTop = 999999;
897
+ }
898
+
899
+ /** Configure marked once: GFM + code chrome + table wrap. */
900
+ let _markedReady = false;
901
+ function ensureMarked() {
902
+ if (_markedReady || typeof marked === 'undefined') return;
903
+
904
+ const escapeCode = (raw) => String(raw ?? '')
905
+ .replace(/&/g, '&amp;')
906
+ .replace(/</g, '&lt;')
907
+ .replace(/>/g, '&gt;');
908
+
909
+ const renderCode = (body, infostring) => {
910
+ const lang = String(infostring || '').trim().split(/\s+/)[0] || '';
911
+ const langLabel = lang || 'text';
912
+ const langClass = lang ? `language-${esc(lang)}` : '';
913
+ return `<div class="code-block">
914
+ <div class="code-block-header">
915
+ <span class="code-block-lang">${esc(langLabel)}</span>
916
+ <button type="button" class="code-copy" data-copy="1" title="Copy code">Copy</button>
917
+ </div>
918
+ <pre><code class="hljs ${langClass}">${escapeCode(body)}</code></pre>
919
+ </div>`;
920
+ };
921
+
922
+ // marked@9 still calls classic renderer signatures: code(code, lang, escaped), table(header, body)
923
+ const renderer = {
924
+ code(code, infostring) {
925
+ // Support both classic (string) and token-object if a future build passes it
926
+ if (code && typeof code === 'object' && code.text != null) {
927
+ return renderCode(code.text, code.lang || infostring);
928
+ }
929
+ return renderCode(code, infostring);
930
+ },
931
+ table(header, body) {
932
+ if (header && typeof header === 'object' && !Array.isArray(header) && header.header) {
933
+ // unexpected token form — let default handle by returning false if supported
934
+ return false;
935
+ }
936
+ return `<div class="table-wrap"><table><thead>${header}</thead><tbody>${body}</tbody></table></div>`;
937
+ },
938
+ };
939
+
940
+ try {
941
+ if (typeof marked.use === 'function') {
942
+ marked.use({ gfm: true, breaks: true, renderer });
943
+ } else if (typeof marked.setOptions === 'function') {
944
+ const r = new marked.Renderer();
945
+ r.code = renderer.code;
946
+ r.table = renderer.table;
947
+ marked.setOptions({ gfm: true, breaks: true, renderer: r });
948
+ }
949
+ } catch (e) {
950
+ console.warn('[md] marked setup failed', e);
951
+ }
952
+ _markedReady = true;
953
+ }
954
+
955
+ function renderMarkdown(text) {
956
+ if (typeof marked === 'undefined') return esc(text);
957
+ try {
958
+ ensureMarked();
959
+ return marked.parse(String(text ?? ''), { breaks: true, gfm: true });
960
+ } catch {
961
+ return esc(text);
962
+ }
963
+ }
964
+
965
+ // Syntax-highlight any code blocks inside a freshly-inserted element.
966
+ function highlightCode(root) {
967
+ if (!root || typeof hljs === 'undefined') return;
968
+ root.querySelectorAll('pre code:not(.hljs)').forEach(el => {
969
+ try { hljs.highlightElement(el); } catch {}
970
+ });
971
+ // hljs may strip class list — keep language-* for CSS hooks
972
+ root.querySelectorAll('pre code.hljs').forEach(el => {
973
+ el.classList.add('hljs');
974
+ });
975
+ }
976
+
977
+ // ── Init (called after HTML is in DOM) ────────────────────────────────────────
978
+ export function initChat() {
979
+ applyChatDensity();
980
+
981
+ // Processing state → send/stop buttons + typing indicator
982
+ watch(isProcessing, val => {
983
+ $('send-btn')?.classList.toggle('hidden', val);
984
+ $('stop-btn')?.classList.toggle('hidden', !val);
985
+ const input = $('chat-input');
986
+ if (input) input.disabled = val;
987
+ const msgs = $('messages');
988
+ if (!msgs) return;
989
+ const existing = $('typing-indicator');
990
+ if (val && !existing) {
991
+ msgs.insertAdjacentHTML('beforeend',
992
+ '<div id="typing-indicator" class="flex justify-start px-2"><div class="flex items-center gap-1.5 rounded-2xl rounded-tl-sm px-3 py-2 bg-base-200 text-xs text-base-content/40"><span class="dot"></span><span class="dot"></span><span class="dot"></span></div></div>');
993
+ stickBottom(msgs);
994
+ } else if (!val) {
995
+ existing?.remove();
996
+ flushToolBatch();
997
+ }
998
+ });
999
+
1000
+ watch(chatDensity, () => applyChatDensity());
1001
+
1002
+ // Chat input: Enter = send, auto-resize, shell mode styling
1003
+ delegate.on('keydown', '#chat-input', e => {
1004
+ const send = e.key === 'Enter' && (e.metaKey || e.ctrlKey);
1005
+ if (send) { e.preventDefault(); sendMessage(); }
1006
+ });
1007
+ delegate.on('input', '#chat-input', (e, el) => { autoResize(el); updateInputMode(el.value); });
1008
+
1009
+ // Permission buttons
1010
+ delegate.on('click', '[data-perm-id]', (e, el) => {
1011
+ sendWs({ type: 'claude-permission-response', requestId: el.dataset.permId, allow: el.dataset.permAllow === '1' });
1012
+ el.closest('.perm-banner')?.remove();
1013
+ });
1014
+
1015
+ // Tool card expand/collapse — keep the clicked card anchored, don't jump to bottom
1016
+ delegate.on('click', '.tool-card', (e, el) => {
1017
+ // If click is on a card inside a closed batch, let batch handler open first
1018
+ const batch = el.closest('.tool-batch');
1019
+ if (batch && !batch.classList.contains('open') && !el.classList.contains('tool-batch-item')) return;
1020
+ e.stopPropagation();
1021
+ const msgs = $('messages');
1022
+ const before = el.getBoundingClientRect().top;
1023
+ el.classList.toggle('open');
1024
+ if (msgs) {
1025
+ const after = el.getBoundingClientRect().top;
1026
+ msgs.scrollTop += after - before;
1027
+ }
1028
+ });
1029
+
1030
+ // Tool batch expand (clean density)
1031
+ delegate.on('click', '.tool-batch', (e, el) => {
1032
+ if (e.target.closest('.tool-card') && el.classList.contains('open')) return;
1033
+ const msgs = $('messages');
1034
+ const before = el.getBoundingClientRect().top;
1035
+ el.classList.toggle('open');
1036
+ if (msgs) {
1037
+ const after = el.getBoundingClientRect().top;
1038
+ msgs.scrollTop += after - before;
1039
+ }
1040
+ });
1041
+
1042
+ // Floating jump-to-top / jump-to-bottom buttons
1043
+ const msgsEl = $('messages');
1044
+ const topBtn = $('scroll-top-btn'), botBtn = $('scroll-bottom-btn');
1045
+ if (msgsEl && topBtn && botBtn) {
1046
+ const updateNav = () => {
1047
+ const canScroll = msgsEl.scrollHeight - msgsEl.clientHeight > 200;
1048
+ topBtn.classList.toggle('hidden', !canScroll || msgsEl.scrollTop < 200);
1049
+ botBtn.classList.toggle('hidden', !canScroll || atBottom(msgsEl));
1050
+ };
1051
+ msgsEl.addEventListener('scroll', updateNav, { passive: true });
1052
+ new ResizeObserver(updateNav).observe(msgsEl);
1053
+ new MutationObserver(updateNav).observe(msgsEl, { childList: true, subtree: true });
1054
+ topBtn.addEventListener('click', () => msgsEl.scrollTo({ top: 0, behavior: 'smooth' }));
1055
+ botBtn.addEventListener('click', () => msgsEl.scrollTo({ top: msgsEl.scrollHeight, behavior: 'smooth' }));
1056
+ updateNav();
1057
+ }
1058
+
1059
+ // Long message expand/collapse
1060
+ delegate.on('click', '.msg-expand-btn', (e, el) => {
1061
+ e.stopPropagation();
1062
+ const card = el.closest('.msg-card');
1063
+ const expanded = card.classList.toggle('open');
1064
+ el.textContent = expanded ? '▲ Show less' : '▼ Show more';
1065
+ });
1066
+
1067
+ // Copy code from fenced blocks
1068
+ delegate.on('click', '.code-copy', async (e, el) => {
1069
+ e.preventDefault();
1070
+ e.stopPropagation();
1071
+ const block = el.closest('.code-block');
1072
+ const code = block?.querySelector('pre code');
1073
+ const text = code?.textContent ?? '';
1074
+ try {
1075
+ await navigator.clipboard.writeText(text);
1076
+ el.textContent = 'Copied';
1077
+ el.classList.add('copied');
1078
+ setTimeout(() => {
1079
+ el.textContent = 'Copy';
1080
+ el.classList.remove('copied');
1081
+ }, 1400);
1082
+ } catch {
1083
+ el.textContent = 'Failed';
1084
+ setTimeout(() => { el.textContent = 'Copy'; }, 1400);
1085
+ }
1086
+ });
1087
+ }