@mjasnikovs/pi-task 0.17.21 → 0.17.23

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.
@@ -15,18 +15,57 @@ export function clientScript(wsUrl) {
15
15
  const contextFill = document.getElementById('context-bar-fill');
16
16
  function setContextBar(usage) {
17
17
  if (usage && usage.percent != null) contextFill.style.width = usage.percent + '%';
18
+ setStatusChip(usage);
19
+ }
20
+ // Compact token count for the header chip (mirrors formatContextTokens).
21
+ function fmtTokens(n) {
22
+ if (n == null) return '';
23
+ if (n < 1000) return String(n);
24
+ if (n < 10000) return (n / 1000).toFixed(1) + 'k';
25
+ if (n < 1000000) return Math.round(n / 1000) + 'k';
26
+ return (n / 1000000).toFixed(1) + 'M';
27
+ }
28
+ function setStatusChip(usage) {
29
+ if (!usage) return;
30
+ var parts = [];
31
+ if (usage.percent != null) parts.push(Math.round(usage.percent) + '%');
32
+ if (usage.tokens != null && usage.contextWindow) {
33
+ parts.push(fmtTokens(usage.tokens) + '/' + fmtTokens(usage.contextWindow));
34
+ }
35
+ statusCtx.textContent = parts.join(' \\u00B7 ');
36
+ }
37
+ function setModelName(name) {
38
+ if (name && name !== modelName) { modelName = name; statusModel.textContent = name; }
39
+ }
40
+ // Connection dot: red disconnected, mauve pulsing while running, green idle.
41
+ function updateStatusDot() {
42
+ statusDot.className = !connected ? 'disconnected' : (agentRunning ? 'running' : 'idle');
18
43
  }
19
44
  const reconnectOverlay = document.getElementById('reconnect-overlay');
20
45
  const reconnectMsg = document.getElementById('reconnect-msg');
21
46
  const cmdSuggestions = document.getElementById('cmd-suggestions');
22
47
  const statusPanel = document.getElementById('status-panel');
48
+ const statusDot = document.getElementById('status-dot');
49
+ const statusModel = document.getElementById('status-model');
50
+ const statusCtx = document.getElementById('status-ctx');
51
+ const notifPanel = document.getElementById('notif-panel');
52
+ const notifList = document.getElementById('notif-list');
53
+ const notifToggle = document.getElementById('notif-toggle');
54
+ // Last ~20 toasts, newest first, for the bell dropdown history.
55
+ let notifHistory = [];
56
+ let modelName = '';
23
57
  // Widgets are keyed (e.g. 'pi-tasks', 'pi-task-auto'); track them per key so a
24
58
  // clear for one key can't be masked by a stale message from another.
25
59
  // Single authoritative task-widget slot. The snapshot and the live 'widget'
26
60
  // delta both set this; null hides the panel. (No more per-key map that could
27
61
  // strand an orphaned widget on screen.)
28
62
  let taskWidgetLines = null;
63
+ let taskWidgetData = null;
29
64
  function renderWidgets() {
65
+ // Structured payload wins (progress bar + phase badge + elapsed); the plain
66
+ // text lines are the fallback for older/unstructured producers.
67
+ if (taskWidgetData) { renderStructuredWidget(taskWidgetData); return; }
68
+ statusPanel.classList.remove('structured');
30
69
  if (taskWidgetLines && taskWidgetLines.length) {
31
70
  statusPanel.textContent = taskWidgetLines.join('\\n');
32
71
  statusPanel.style.display = 'block';
@@ -34,6 +73,21 @@ export function clientScript(wsUrl) {
34
73
  statusPanel.style.display = 'none';
35
74
  }
36
75
  }
76
+ function renderStructuredWidget(d) {
77
+ statusPanel.classList.add('structured');
78
+ statusPanel.style.display = 'block';
79
+ var top = '<div class="widget-top">'
80
+ + '<span class="widget-title">' + escHtml(d.title || '') + '</span>'
81
+ + (d.phase ? '<span class="widget-phase">' + escHtml(d.phase) + '</span>' : '')
82
+ + (d.elapsed ? '<span class="widget-elapsed">' + escHtml(d.elapsed) + '</span>' : '')
83
+ + '</div>';
84
+ var bar = '';
85
+ if (d.total > 0 && d.done != null) {
86
+ var pct = Math.max(0, Math.min(100, Math.round((d.done / d.total) * 100)));
87
+ bar = '<div class="widget-bar"><div class="widget-bar-fill" style="width:' + pct + '%"></div></div>';
88
+ }
89
+ statusPanel.innerHTML = top + bar;
90
+ }
37
91
  const promptCard = document.getElementById('prompt-card');
38
92
  const promptQ = document.getElementById('prompt-q');
39
93
  const promptRec = document.getElementById('prompt-rec');
@@ -50,125 +104,33 @@ export function clientScript(wsUrl) {
50
104
  const toolCallMap = {};
51
105
  let currentBubble = null;
52
106
  let streamText = '';
107
+ // Composer state: input is enabled whenever connected AND no prompt card is
108
+ // open (messages typed mid-run steer the live turn); the Send button morphs
109
+ // into a red Stop while the agent is running.
110
+ let agentRunning = false;
111
+ let connected = false;
112
+ // Whether the current live turn produced any content — gates the trailing
113
+ // turn-timestamp so an empty/aborted turn doesn't leave a stray clock.
114
+ let turnHadContent = false;
115
+ let stopArmed = false;
116
+ let stopArmTimer = null;
117
+ // The live thinking (<details>) block being streamed, and its accumulated text.
118
+ let currentThinking = null;
119
+ let thinkingText = '';
53
120
  let autoScroll = true;
54
121
  let reconnectDelay = 1000;
55
122
  let reconnectAnim = null;
56
123
  let reconnectTimer = null;
57
124
  let ws = null;
58
125
 
59
- const BT = String.fromCharCode(96);
60
- const JS_LANGS = new Set(['js','jsx','mjs','cjs','javascript','ts','tsx','typescript']);
61
- const JS_KW = new Set(['break','case','catch','class','const','continue','debugger',
62
- 'default','delete','do','else','export','extends','finally','for','from','function',
63
- 'if','import','in','instanceof','let','new','of','return','static','super','switch',
64
- 'this','throw','try','typeof','var','void','while','with','yield','async','await',
65
- 'type','interface','enum','implements','abstract','as','declare','namespace',
66
- 'readonly','undefined','null','true','false','override','satisfies']);
67
-
68
- function escHtml(s) {
69
- return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
70
- }
71
-
72
- function syntaxHighlight(code, lang) {
73
- if (!JS_LANGS.has((lang || '').toLowerCase())) return escHtml(code);
74
- let r = '', i = 0;
75
- while (i < code.length) {
76
- const ch = code[i];
77
- // Template literal
78
- if (ch === BT) {
79
- let j = i + 1;
80
- while (j < code.length) {
81
- if (code[j] === '\\\\') { j += 2; continue; }
82
- if (code[j] === BT) { j++; break; }
83
- j++;
84
- }
85
- r += '<span class="hl-str">' + escHtml(code.slice(i, j)) + '</span>';
86
- i = j; continue;
87
- }
88
- // Single / double quoted string
89
- if (ch === '"' || ch === "'") {
90
- let j = i + 1;
91
- while (j < code.length) {
92
- if (code[j] === '\\\\') { j += 2; continue; }
93
- if (code[j] === ch || code[j] === '\\n') break;
94
- j++;
95
- }
96
- if (code[j] === ch) j++;
97
- r += '<span class="hl-str">' + escHtml(code.slice(i, j)) + '</span>';
98
- i = j; continue;
99
- }
100
- // Line comment
101
- if (ch === '/' && code[i + 1] === '/') {
102
- let j = i + 2;
103
- while (j < code.length && code[j] !== '\\n') j++;
104
- r += '<span class="hl-cmt">' + escHtml(code.slice(i, j)) + '</span>';
105
- i = j; continue;
106
- }
107
- // Block comment
108
- if (ch === '/' && code[i + 1] === '*') {
109
- let j = i + 2;
110
- while (j < code.length && !(code[j] === '*' && code[j + 1] === '/')) j++;
111
- j += 2;
112
- r += '<span class="hl-cmt">' + escHtml(code.slice(i, j)) + '</span>';
113
- i = j; continue;
114
- }
115
- // Number
116
- if (ch >= '0' && ch <= '9') {
117
- let j = i;
118
- if (code[i] === '0' && /[xXoObB]/.test(code[i + 1] || '')) {
119
- j += 2; while (j < code.length && /[0-9a-fA-F_]/.test(code[j])) j++;
120
- } else {
121
- while (j < code.length && (code[j] >= '0' && code[j] <= '9' || code[j] === '_')) j++;
122
- if (code[j] === '.') { j++; while (j < code.length && code[j] >= '0' && code[j] <= '9') j++; }
123
- if (code[j] === 'e' || code[j] === 'E') {
124
- j++; if (code[j] === '+' || code[j] === '-') j++;
125
- while (j < code.length && code[j] >= '0' && code[j] <= '9') j++;
126
- }
127
- if (code[j] === 'n') j++;
128
- }
129
- r += '<span class="hl-num">' + escHtml(code.slice(i, j)) + '</span>';
130
- i = j; continue;
131
- }
132
- // Identifier / keyword / function call
133
- if (/[a-zA-Z_$]/.test(ch)) {
134
- let j = i;
135
- while (j < code.length && /[a-zA-Z0-9_$]/.test(code[j])) j++;
136
- const word = code.slice(i, j);
137
- if (JS_KW.has(word)) {
138
- r += '<span class="hl-kw">' + word + '</span>';
139
- } else if (code[j] === '(') {
140
- r += '<span class="hl-fn">' + escHtml(word) + '</span>';
141
- } else {
142
- r += escHtml(word);
143
- }
144
- i = j; continue;
145
- }
146
- r += escHtml(ch); i++;
147
- }
148
- return r;
149
- }
150
-
126
+ // Render assistant text as markdown (headers, lists, tables, emphasis, links)
127
+ // with fenced code blocks syntax-highlighted. renderMarkdown/syntaxHighlight are
128
+ // the pure functions from ui-render.ts / ui-highlight.ts, concatenated into this
129
+ // same <script> ahead of clientScript. innerHTML is safe because renderMarkdown
130
+ // escapes every span of literal text and only emits a fixed set of known tags.
151
131
  function setContent(el, text) {
152
- el.innerHTML = '';
153
- const BT3 = BT + BT + BT;
154
- const re = new RegExp(BT3 + '([^\\n' + BT + ']*)\\n([\\s\\S]*?)' + BT3, 'g');
155
- let last = 0, m;
156
- while ((m = re.exec(text)) !== null) {
157
- if (m.index > last) el.appendChild(document.createTextNode(text.slice(last, m.index)));
158
- const lang = m[1].trim();
159
- const code = m[2];
160
- const wrap = document.createElement('div');
161
- wrap.className = 'code-block';
162
- if (lang) { const lb = document.createElement('div'); lb.className = 'code-lang'; lb.textContent = lang; wrap.appendChild(lb); }
163
- const pre = document.createElement('pre');
164
- const codeEl = document.createElement('code');
165
- codeEl.innerHTML = syntaxHighlight(code, lang);
166
- pre.appendChild(codeEl);
167
- wrap.appendChild(pre);
168
- el.appendChild(wrap);
169
- last = m.index + m[0].length;
170
- }
171
- if (last < text.length) el.appendChild(document.createTextNode(text.slice(last)));
132
+ el.classList.add('md');
133
+ el.innerHTML = renderMarkdown(text);
172
134
  }
173
135
 
174
136
  const COMMANDS = [
@@ -247,7 +209,10 @@ export function clientScript(wsUrl) {
247
209
  function addBubble(role, text) {
248
210
  const el = document.createElement('div');
249
211
  el.className = 'bubble ' + role;
250
- setContent(el, text);
212
+ // Only assistant text is markdown-rendered; user/error bubbles stay plain so a
213
+ // user's literal *asterisks* or backticks show verbatim.
214
+ if (role === 'assistant') { setContent(el, text); attachBubbleCopy(el, text); }
215
+ else el.textContent = text;
251
216
  chatLog.appendChild(el);
252
217
  scrollBottom();
253
218
  return el;
@@ -293,24 +258,204 @@ export function clientScript(wsUrl) {
293
258
  stopSpinIfIdle();
294
259
  }
295
260
 
296
- function addToolCall(toolName, argsStr, isError) {
297
- // argsStr can be undefined (no args / JSON.stringify(undefined)); don't let
298
- // that render as the literal "name: undefined" in the collapsed summary.
299
- const label = (toolName + (argsStr ? ': ' + argsStr : '')).slice(0, 64);
261
+ // A finished/running tool card. args is the RAW args (object or JSON string)
262
+ // toolSummary/toolBadge/toolDiffHtml (ui-tools.ts) turn it into a readable
263
+ // one-line summary, a +N −M badge, and (for edit/write) an expandable line diff.
264
+ function addToolCall(toolName, args, isError) {
300
265
  const d = document.createElement('details');
301
266
  d.className = 'tool-call' + (isError ? ' error' : '');
302
267
  const s = document.createElement('summary');
303
- s.textContent = label;
268
+ const label = document.createElement('span');
269
+ label.className = 'tool-label';
270
+ label.textContent = toolSummary(toolName, args);
271
+ s.title = label.textContent;
272
+ s.appendChild(label);
273
+ const badge = toolBadge(toolName, args);
274
+ if (badge && (badge.added || badge.removed)) {
275
+ const b = document.createElement('span');
276
+ b.className = 'tool-badge';
277
+ b.textContent = '+' + badge.added + ' \\u2212' + badge.removed;
278
+ s.appendChild(b);
279
+ }
304
280
  d.appendChild(s);
281
+ const diffHtml = toolDiffHtml(toolName, args);
282
+ if (diffHtml) {
283
+ const dv = document.createElement('div');
284
+ dv.className = 'tool-diff';
285
+ dv.innerHTML = diffHtml;
286
+ d.appendChild(dv);
287
+ }
305
288
  chatLog.appendChild(d);
306
289
  scrollBottom();
307
290
  return d;
308
291
  }
309
292
 
310
- function setEnabled(on) {
311
- const allow = on && activePromptId === null;
312
- inputEl.disabled = !allow;
313
- sendBtn.disabled = !allow;
293
+ // Dim "· 1.2s" appended to a finished tool summary.
294
+ function appendElapsed(d, elapsedMs) {
295
+ const txt = fmtElapsed(elapsedMs);
296
+ if (!txt) return;
297
+ const s = d.querySelector('summary');
298
+ if (!s || s.querySelector('.tool-elapsed')) return;
299
+ const e = document.createElement('span');
300
+ e.className = 'tool-elapsed';
301
+ e.textContent = '\\u00B7 ' + txt;
302
+ s.appendChild(e);
303
+ }
304
+
305
+ // Reconcile the composer (input + Send/Stop button) with the current state.
306
+ // The input is disabled ONLY while disconnected or a prompt card is open — a
307
+ // running agent no longer locks it, so messages can steer the live turn.
308
+ function refreshComposer() {
309
+ updateStatusDot();
310
+ const promptOpen = activePromptId !== null;
311
+ inputEl.disabled = !connected || promptOpen;
312
+ inputEl.placeholder = agentRunning
313
+ ? 'message the agent — delivered mid-run'
314
+ : 'type a message\\u2026 (/ for commands)';
315
+ if (agentRunning && !promptOpen) {
316
+ // Send morphs into a red Stop that interrupts the running turn.
317
+ sendBtn.classList.add('stop');
318
+ sendBtn.disabled = !connected;
319
+ if (!stopArmed) sendBtn.textContent = 'Stop';
320
+ } else {
321
+ disarmStop();
322
+ sendBtn.classList.remove('stop');
323
+ sendBtn.textContent = 'Send';
324
+ sendBtn.disabled = !connected || promptOpen;
325
+ }
326
+ }
327
+
328
+ function disarmStop() {
329
+ stopArmed = false;
330
+ if (stopArmTimer) { clearTimeout(stopArmTimer); stopArmTimer = null; }
331
+ sendBtn.classList.remove('armed');
332
+ if (agentRunning) sendBtn.textContent = 'Stop';
333
+ }
334
+
335
+ function sendInterrupt() {
336
+ if (ws && ws.readyState === WebSocket.OPEN) {
337
+ ws.send(JSON.stringify({ type: 'interrupt' }));
338
+ }
339
+ }
340
+
341
+ // Stop needs a two-tap confirm (same pattern as the prompt card's cancel):
342
+ // first tap arms it, a second tap within 3s actually interrupts.
343
+ function onSendClick() {
344
+ if (agentRunning && activePromptId === null) {
345
+ if (stopArmed) { disarmStop(); sendInterrupt(); return; }
346
+ stopArmed = true;
347
+ sendBtn.classList.add('armed');
348
+ sendBtn.textContent = 'Tap to stop';
349
+ if (stopArmTimer) clearTimeout(stopArmTimer);
350
+ stopArmTimer = setTimeout(function () {
351
+ stopArmed = false;
352
+ sendBtn.classList.remove('armed');
353
+ if (agentRunning) sendBtn.textContent = 'Stop';
354
+ stopArmTimer = null;
355
+ }, 3000);
356
+ return;
357
+ }
358
+ sendMessage();
359
+ }
360
+
361
+ // Copy source text to the clipboard, flashing the button. Falls back to a
362
+ // hidden textarea + execCommand for non-secure (plain-http LAN) contexts where
363
+ // navigator.clipboard is unavailable.
364
+ function flashCopied(btn) {
365
+ if (!btn) return;
366
+ const prev = btn.textContent;
367
+ btn.textContent = 'Copied';
368
+ btn.classList.add('copied');
369
+ setTimeout(function () { btn.textContent = prev; btn.classList.remove('copied'); }, 1200);
370
+ }
371
+ function fallbackCopy(text, btn) {
372
+ try {
373
+ const ta = document.createElement('textarea');
374
+ ta.value = text;
375
+ ta.style.position = 'fixed';
376
+ ta.style.top = '0';
377
+ ta.style.opacity = '0';
378
+ document.body.appendChild(ta);
379
+ ta.focus(); ta.select();
380
+ document.execCommand('copy');
381
+ document.body.removeChild(ta);
382
+ flashCopied(btn);
383
+ } catch (e) {}
384
+ }
385
+ function copyText(text, btn) {
386
+ if (navigator.clipboard && navigator.clipboard.writeText) {
387
+ navigator.clipboard.writeText(text).then(function () { flashCopied(btn); },
388
+ function () { fallbackCopy(text, btn); });
389
+ } else {
390
+ fallbackCopy(text, btn);
391
+ }
392
+ }
393
+ // One delegated handler for every copy button (code-block headers live inside
394
+ // markdown HTML; bubble buttons are appended in JS). Resolve the source text
395
+ // from whichever container the button sits in.
396
+ chatLog.addEventListener('click', function (e) {
397
+ const btn = e.target && e.target.closest ? e.target.closest('.copy-btn') : null;
398
+ if (!btn) return;
399
+ const block = btn.closest('.code-block');
400
+ if (block) {
401
+ const code = block.querySelector('code');
402
+ copyText(code ? code.textContent : '', btn);
403
+ return;
404
+ }
405
+ const bub = btn.closest('.bubble');
406
+ if (bub) copyText(bub.__copyText != null ? bub.__copyText : bub.textContent, btn);
407
+ });
408
+
409
+ // Attach a copy button to a finished assistant bubble and stash its raw text
410
+ // (the pre-markdown source) so the delegated handler copies the original.
411
+ function attachBubbleCopy(el, text) {
412
+ el.__copyText = text;
413
+ const b = document.createElement('button');
414
+ b.className = 'copy-btn bubble-copy';
415
+ b.type = 'button';
416
+ b.setAttribute('aria-label', 'Copy message');
417
+ b.textContent = 'Copy';
418
+ el.appendChild(b);
419
+ }
420
+
421
+ // "✻ Thinking… (n lines)" collapsed-block summary.
422
+ function thinkingSummary(n) {
423
+ return '\\u273B Thinking\\u2026 (' + n + (n === 1 ? ' line' : ' lines') + ')';
424
+ }
425
+ function thinkingLineCount(text) {
426
+ return text ? text.split('\\n').length : 0;
427
+ }
428
+ // Build a collapsed thinking <details>. A live block keeps a spinner in the
429
+ // summary and stays open to further deltas; otherwise it's a finished, static one.
430
+ function makeThinkingEl(text, live) {
431
+ const d = document.createElement('details');
432
+ d.className = 'thinking-block';
433
+ const s = document.createElement('summary');
434
+ const lbl = document.createElement('span');
435
+ lbl.className = 'thinking-label';
436
+ lbl.textContent = thinkingSummary(thinkingLineCount(text));
437
+ s.appendChild(lbl);
438
+ if (live) {
439
+ const sp = document.createElement('span');
440
+ sp.className = 'thinking-spin spin';
441
+ s.appendChild(sp);
442
+ }
443
+ d.appendChild(s);
444
+ const body = document.createElement('div');
445
+ body.className = 'thinking-body';
446
+ body.textContent = text || '';
447
+ d.appendChild(body);
448
+ return d;
449
+ }
450
+ // Close out the live thinking block (drop its spinner) — called when the model
451
+ // moves on to text/tools, or the turn ends.
452
+ function finalizeThinking() {
453
+ if (currentThinking) {
454
+ const sp = currentThinking.querySelector('.thinking-spin');
455
+ if (sp) { sp.remove(); stopSpinIfIdle(); }
456
+ currentThinking = null;
457
+ }
458
+ thinkingText = '';
314
459
  }
315
460
 
316
461
  // Pull the human-readable text out of a tool result. Many tools (Read, Bash,
@@ -345,9 +490,9 @@ export function clientScript(wsUrl) {
345
490
 
346
491
  // Render one tool part from the ordered parts list (running or finished).
347
492
  function renderToolPart(p) {
348
- const argsStr = typeof p.args === 'string' ? p.args : JSON.stringify(p.args);
349
- const d = addToolCall(p.toolName, argsStr, p.isError);
493
+ const d = addToolCall(p.toolName, p.args, p.isError);
350
494
  if (p.done) {
495
+ appendElapsed(d, p.elapsedMs);
351
496
  const pre = document.createElement('pre');
352
497
  pre.textContent = toolResultText(p.result);
353
498
  d.appendChild(pre);
@@ -361,6 +506,23 @@ export function clientScript(wsUrl) {
361
506
  return d;
362
507
  }
363
508
 
509
+ // Dim HH:MM shown under a turn's last bubble (local time).
510
+ function fmtClock(ts) {
511
+ if (!ts) return '';
512
+ var d = new Date(ts);
513
+ var pad = function (n) { return (n < 10 ? '0' : '') + n; };
514
+ return pad(d.getHours()) + ':' + pad(d.getMinutes());
515
+ }
516
+ function addTurnTime(ts, roleClass) {
517
+ var clock = fmtClock(ts || Date.now());
518
+ if (!clock) return;
519
+ var el = document.createElement('div');
520
+ el.className = 'turn-time ' + roleClass;
521
+ el.textContent = clock;
522
+ chatLog.appendChild(el);
523
+ scrollBottom();
524
+ }
525
+
364
526
  // A muted, centered system note (e.g. "Context compacted").
365
527
  function addSystemLine(text) {
366
528
  const el = document.createElement('div');
@@ -375,13 +537,17 @@ export function clientScript(wsUrl) {
375
537
  // parts (text segments + tool calls), so the layout matches the terminal's
376
538
  // interleaving instead of one merged blob with tools dumped at the end.
377
539
  function renderTurn(t) {
378
- if (t.error) { addBubble('error', t.text); return; }
379
- if (t.role === 'system') { addSystemLine(t.text); return; }
380
- if (t.role === 'user') { addBubble('user', t.text); return; }
540
+ if (t.error) { addBubble('error', t.text); addTurnTime(t.ts, 'assistant'); return; }
541
+ if (t.role === 'system') { addSystemLine(t.text); addTurnTime(t.ts, 'system'); return; }
542
+ if (t.role === 'user') { addBubble('user', t.text); addTurnTime(t.ts, 'user'); return; }
381
543
  for (const p of (t.parts || [])) {
382
544
  if (p.kind === 'text') { if (p.text) addBubble('assistant', p.text); }
545
+ else if (p.kind === 'thinking') {
546
+ if (p.text) { chatLog.appendChild(makeThinkingEl(p.text, false)); scrollBottom(); }
547
+ }
383
548
  else renderToolPart(p);
384
549
  }
550
+ addTurnTime(t.ts, 'assistant');
385
551
  }
386
552
 
387
553
  // Render the in-progress assistant turn from a snapshot, preserving order. The
@@ -407,6 +573,18 @@ export function clientScript(wsUrl) {
407
573
  } else if (p.text) {
408
574
  addBubble('assistant', p.text);
409
575
  }
576
+ } else if (p.kind === 'thinking') {
577
+ if (last && !p.done) {
578
+ // Reconnected mid-thinking: keep the block live so further deltas flow in.
579
+ currentThinking = makeThinkingEl(p.text || '', true);
580
+ thinkingText = p.text || '';
581
+ chatLog.appendChild(currentThinking);
582
+ startSpin();
583
+ scrollBottom();
584
+ } else if (p.text) {
585
+ chatLog.appendChild(makeThinkingEl(p.text, false));
586
+ scrollBottom();
587
+ }
410
588
  } else {
411
589
  renderToolPart(p);
412
590
  }
@@ -419,6 +597,37 @@ export function clientScript(wsUrl) {
419
597
  t.textContent = message;
420
598
  document.body.appendChild(t);
421
599
  setTimeout(function () { t.remove(); }, 4000);
600
+ recordNotif(message, level);
601
+ }
602
+
603
+ // Keep the last ~20 toasts (newest first) for the bell's history dropdown.
604
+ function recordNotif(message, level) {
605
+ notifHistory.unshift({message: message, level: level || 'info', ts: Date.now()});
606
+ if (notifHistory.length > 20) notifHistory.pop();
607
+ renderNotifList();
608
+ }
609
+ function renderNotifList() {
610
+ if (!notifHistory.length) {
611
+ notifList.innerHTML = '<div id="notif-empty">No notifications yet.</div>';
612
+ return;
613
+ }
614
+ var html = '';
615
+ for (var i = 0; i < notifHistory.length; i++) {
616
+ var n = notifHistory[i];
617
+ html += '<div class="notif-item ' + escHtml(n.level) + '">'
618
+ + '<span class="notif-dot"></span>'
619
+ + '<span class="notif-msg">' + escHtml(n.message) + '</span>'
620
+ + '<span class="notif-time">' + fmtClock(n.ts) + '</span></div>';
621
+ }
622
+ notifList.innerHTML = html;
623
+ }
624
+
625
+ let notifOpen = false;
626
+ function setNotifOpen(open) {
627
+ notifOpen = open;
628
+ notifPanel.classList.toggle('open', open);
629
+ notifPanel.setAttribute('aria-hidden', open ? 'false' : 'true');
630
+ if (open) { renderNotifList(); updateNotifToggle(); }
422
631
  }
423
632
 
424
633
  const bell = document.getElementById('bell');
@@ -435,6 +644,12 @@ export function clientScript(wsUrl) {
435
644
  var on = notifyEnabled();
436
645
  bell.textContent = on ? '\\u25C9' : '\\u25EF';
437
646
  bell.classList.toggle('on', on);
647
+ updateNotifToggle();
648
+ }
649
+ function updateNotifToggle() {
650
+ var on = notifyEnabled();
651
+ notifToggle.textContent = on ? 'On' : 'Enable';
652
+ notifToggle.classList.toggle('on', on);
438
653
  }
439
654
 
440
655
  // Why notifications can't be enabled here, or null if they can.
@@ -448,7 +663,17 @@ export function clientScript(wsUrl) {
448
663
  return null;
449
664
  }
450
665
 
451
- bell.addEventListener('click', function () {
666
+ // The bell opens the notification dropdown (history + push toggle); the push
667
+ // enable/disable lives on the toggle inside it.
668
+ bell.addEventListener('click', function (e) {
669
+ e.stopPropagation();
670
+ setNotifOpen(!notifOpen);
671
+ });
672
+ document.addEventListener('click', function (e) {
673
+ if (notifOpen && !notifPanel.contains(e.target) && e.target !== bell) setNotifOpen(false);
674
+ });
675
+
676
+ function togglePush() {
452
677
  // Turning OFF always works regardless of environment.
453
678
  if (localStorage.getItem(NOTIFY_KEY) === '1') {
454
679
  localStorage.setItem(NOTIFY_KEY, '0'); updateBell(); return;
@@ -472,7 +697,8 @@ export function clientScript(wsUrl) {
472
697
  updateBell();
473
698
  });
474
699
  });
475
- });
700
+ }
701
+ notifToggle.addEventListener('click', togglePush);
476
702
 
477
703
  // VAPID public key (base64url) -> Uint8Array for applicationServerKey.
478
704
  function urlB64ToUint8Array(base64) {
@@ -525,7 +751,7 @@ export function clientScript(wsUrl) {
525
751
  promptRec.style.display = 'none';
526
752
  activeRecommended2 = '';
527
753
  if (cancelArmTimer) { clearTimeout(cancelArmTimer); cancelArmTimer = null; }
528
- setEnabled(true);
754
+ refreshComposer();
529
755
  }
530
756
 
531
757
  function makeBtn(label, cls, onClick) {
@@ -604,8 +830,9 @@ export function clientScript(wsUrl) {
604
830
  activeRecommended = msg.recommended || '';
605
831
  activeRecommended2 = msg.recommended2 || '';
606
832
  if (msg.recommended) {
607
- // Mode A: recommendation(s) present.
608
- promptRecText.textContent = msg.recommended;
833
+ // Mode A: recommendation(s) present. Render markdown in the panel so a
834
+ // recommendation with code/emphasis reads the same as an assistant bubble.
835
+ setContent(promptRecText, msg.recommended);
609
836
  showRecommendation();
610
837
  } else {
611
838
  // Mode B: no recommendation — the user must type an answer (or skip).
@@ -620,7 +847,7 @@ export function clientScript(wsUrl) {
620
847
  promptInput.focus();
621
848
  }
622
849
  promptCard.style.display = 'block';
623
- setEnabled(false);
850
+ refreshComposer();
624
851
  }
625
852
 
626
853
  function handleMsg(msg) {
@@ -632,6 +859,7 @@ export function clientScript(wsUrl) {
632
859
  chatLog.innerHTML = '';
633
860
  closePrompt();
634
861
  hideThinking();
862
+ currentThinking = null; thinkingText = '';
635
863
  currentBubble = null; streamText = '';
636
864
  for (const k in toolCallMap) delete toolCallMap[k];
637
865
  // Per-turn try/catch: one malformed turn must never abort the rebuild
@@ -639,10 +867,14 @@ export function clientScript(wsUrl) {
639
867
  for (const t of (msg.turns || [])) { try { renderTurn(t); } catch (e) {} }
640
868
  if (msg.live) { try { renderLiveTurn(msg.live); } catch (e) {} }
641
869
  taskWidgetLines = (msg.taskWidget && msg.taskWidget.length) ? msg.taskWidget : null;
870
+ taskWidgetData = msg.taskWidgetData || null;
642
871
  renderWidgets();
872
+ setModelName(msg.model);
643
873
  if (msg.context) setContextBar(msg.context); else contextFill.style.width = '0%';
874
+ agentRunning = !!msg.agentRunning;
875
+ turnHadContent = !!(msg.live && msg.live.parts && msg.live.parts.length);
644
876
  if (msg.prompt) showPrompt(msg.prompt);
645
- setEnabled(!msg.agentRunning && !msg.prompt);
877
+ refreshComposer();
646
878
  if (msg.agentRunning && !msg.live) showThinking();
647
879
  break;
648
880
  }
@@ -650,12 +882,37 @@ export function clientScript(wsUrl) {
650
882
  autoScroll = true;
651
883
  streamText = '';
652
884
  currentBubble = null;
653
- setEnabled(false);
885
+ turnHadContent = false;
886
+ agentRunning = true;
887
+ setModelName(msg.model);
888
+ refreshComposer();
889
+ showThinking();
890
+ break;
891
+ case 'thinking_delta':
892
+ turnHadContent = true;
893
+ hideThinking(); // drop the spinner-only bubble
894
+ if (!currentThinking) {
895
+ currentThinking = makeThinkingEl('', true);
896
+ chatLog.appendChild(currentThinking);
897
+ thinkingText = '';
898
+ startSpin();
899
+ }
900
+ thinkingText += msg.delta;
901
+ currentThinking.querySelector('.thinking-body').textContent = thinkingText;
902
+ currentThinking.querySelector('.thinking-label').textContent =
903
+ thinkingSummary(thinkingLineCount(thinkingText));
904
+ scrollBottom();
905
+ break;
906
+ case 'thinking_end':
907
+ finalizeThinking();
908
+ // Model moves to text/tool next — show the spinner meanwhile.
654
909
  showThinking();
655
910
  break;
656
911
  case 'text_delta':
912
+ turnHadContent = true;
657
913
  if (!currentBubble) {
658
914
  hideThinking();
915
+ finalizeThinking();
659
916
  currentBubble = document.createElement('div');
660
917
  currentBubble.className = 'bubble assistant';
661
918
  const cursor = document.createElement('span');
@@ -675,7 +932,7 @@ export function clientScript(wsUrl) {
675
932
  if (currentBubble) {
676
933
  const c = currentBubble.querySelector('.cursor');
677
934
  if (c) c.remove();
678
- if (streamText) setContent(currentBubble, streamText);
935
+ if (streamText) { setContent(currentBubble, streamText); attachBubbleCopy(currentBubble, streamText); }
679
936
  // Close this message's bubble so the next text segment (after a tool or
680
937
  // the next message) starts a fresh bubble — matching the terminal.
681
938
  currentBubble = null;
@@ -684,9 +941,10 @@ export function clientScript(wsUrl) {
684
941
  }
685
942
  break;
686
943
  case 'tool_start': {
944
+ turnHadContent = true;
687
945
  hideThinking();
688
- const argsStr = typeof msg.args === 'string' ? msg.args : JSON.stringify(msg.args);
689
- const d = addToolCall(msg.toolName, argsStr, false);
946
+ finalizeThinking();
947
+ const d = addToolCall(msg.toolName, msg.args, false);
690
948
  const sp = document.createElement('span');
691
949
  sp.className = 'tool-spin spin';
692
950
  d.querySelector('summary').appendChild(sp);
@@ -700,6 +958,7 @@ export function clientScript(wsUrl) {
700
958
  const sp = d.querySelector('.tool-spin');
701
959
  if (sp) { sp.remove(); stopSpinIfIdle(); }
702
960
  if (msg.isError) d.classList.add('error');
961
+ appendElapsed(d, msg.elapsedMs);
703
962
  const pre = document.createElement('pre');
704
963
  pre.textContent = toolResultText(msg.result);
705
964
  d.appendChild(pre);
@@ -713,28 +972,39 @@ export function clientScript(wsUrl) {
713
972
  }
714
973
  case 'user_message':
715
974
  addBubble('user', msg.text);
975
+ addTurnTime(Date.now(), 'user');
716
976
  break;
717
977
  case 'system_note':
718
978
  addSystemLine(msg.text);
979
+ addTurnTime(Date.now(), 'system');
719
980
  break;
720
981
  case 'agent_error':
721
982
  hideThinking();
983
+ finalizeThinking();
722
984
  if (currentBubble) {
723
985
  const c = currentBubble.querySelector('.cursor');
724
986
  if (c) c.remove();
725
- if (streamText) setContent(currentBubble, streamText);
987
+ if (streamText) { setContent(currentBubble, streamText); attachBubbleCopy(currentBubble, streamText); }
726
988
  currentBubble = null;
727
989
  streamText = '';
728
990
  stopSpinIfIdle();
729
991
  }
730
992
  addBubble('error', msg.message || 'Error');
731
- setEnabled(true);
993
+ if (turnHadContent) addTurnTime(Date.now(), 'assistant');
994
+ turnHadContent = false;
995
+ agentRunning = false;
996
+ refreshComposer();
732
997
  break;
733
998
  case 'agent_end':
734
999
  hideThinking();
1000
+ finalizeThinking();
735
1001
  currentBubble = null;
736
1002
  streamText = '';
737
- setEnabled(true);
1003
+ if (turnHadContent) addTurnTime(Date.now(), 'assistant');
1004
+ turnHadContent = false;
1005
+ agentRunning = false;
1006
+ setModelName(msg.model);
1007
+ refreshComposer();
738
1008
  setContextBar(msg.contextUsage);
739
1009
  break;
740
1010
  case 'context':
@@ -749,6 +1019,7 @@ export function clientScript(wsUrl) {
749
1019
  break;
750
1020
  case 'widget':
751
1021
  taskWidgetLines = (msg.lines && msg.lines.length) ? msg.lines : null;
1022
+ taskWidgetData = msg.data || null;
752
1023
  renderWidgets();
753
1024
  break;
754
1025
  case 'notify':
@@ -762,9 +1033,14 @@ export function clientScript(wsUrl) {
762
1033
  // A new session started — wipe the previous session's transcript.
763
1034
  chatLog.innerHTML = '';
764
1035
  hideThinking();
1036
+ finalizeThinking();
765
1037
  currentBubble = null; streamText = '';
1038
+ turnHadContent = false;
766
1039
  closePrompt();
1040
+ agentRunning = false;
1041
+ refreshComposer();
767
1042
  taskWidgetLines = null;
1043
+ taskWidgetData = null;
768
1044
  renderWidgets();
769
1045
  contextFill.style.width = '0%';
770
1046
  break;
@@ -783,11 +1059,12 @@ export function clientScript(wsUrl) {
783
1059
  // The server records the message via addUserTurn and broadcasts a
784
1060
  // user_message back to every client (us included), which renders the
785
1061
  // bubble. Don't render it here too, or the sender sees it twice.
786
- setEnabled(false);
787
- showThinking();
1062
+ // Mid-run the message steers the live turn (no state change here); when
1063
+ // idle, optimistically show the spinner until agent_start lands.
1064
+ if (!agentRunning) showThinking();
788
1065
  }
789
1066
 
790
- sendBtn.addEventListener('click', sendMessage);
1067
+ sendBtn.addEventListener('click', onSendClick);
791
1068
  inputEl.addEventListener('keydown', (e) => {
792
1069
  if (cmdActive.length > 0) {
793
1070
  if (e.key === 'ArrowDown') { e.preventDefault(); cmdIndex = Math.min(cmdIndex + 1, cmdActive.length - 1); renderSuggestions(); return; }
@@ -817,7 +1094,8 @@ export function clientScript(wsUrl) {
817
1094
  if (reconnectAnim) { clearInterval(reconnectAnim); reconnectAnim = null; }
818
1095
  reconnectOverlay.classList.remove('visible');
819
1096
  reconnectDelay = 1000;
820
- setEnabled(true);
1097
+ connected = true;
1098
+ refreshComposer();
821
1099
  // Self-heal on every (re)connect, not just page load: if the server
822
1100
  // restarted it may have lost (or be rehydrating) our subscription, and
823
1101
  // browsers can rotate it. Re-registering here covers reconnects the
@@ -831,7 +1109,8 @@ export function clientScript(wsUrl) {
831
1109
  });
832
1110
  sock.addEventListener('close', () => {
833
1111
  if (ws !== sock) return;
834
- setEnabled(false);
1112
+ connected = false;
1113
+ refreshComposer();
835
1114
  reconnectOverlay.classList.add('visible');
836
1115
  // Animate the same braille spinner used elsewhere, with a live countdown.
837
1116
  const until = Date.now() + reconnectDelay;