@mjasnikovs/pi-task 0.17.22 → 0.17.24

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,26 @@ 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
+ // Title on its own line (wraps to 2, never truncated to a stub).
80
+ var title = '<div class="widget-title">' + escHtml(d.title || '') + '</div>';
81
+ // Meta row: phase badge · step count · elapsed clock.
82
+ var meta = '<div class="widget-meta">'
83
+ + (d.phase ? '<span class="widget-phase">' + escHtml(d.phase) + '</span>' : '')
84
+ + (d.total > 0 && d.done != null ? '<span class="widget-step">' + d.done + '/' + d.total + '</span>' : '')
85
+ + (d.elapsed ? '<span class="widget-elapsed">' + escHtml(d.elapsed) + '</span>' : '')
86
+ + '</div>';
87
+ var bar = '';
88
+ if (d.total > 0 && d.done != null) {
89
+ var pct = Math.max(0, Math.min(100, Math.round((d.done / d.total) * 100)));
90
+ bar = '<div class="widget-bar"><div class="widget-bar-fill" style="width:' + pct + '%"></div></div>';
91
+ }
92
+ // Current action — the terminal's last worker/child line, one ellipsized line.
93
+ var action = d.action ? '<div class="widget-action">↳ ' + escHtml(d.action) + '</div>' : '';
94
+ statusPanel.innerHTML = title + meta + bar + action;
95
+ }
37
96
  const promptCard = document.getElementById('prompt-card');
38
97
  const promptQ = document.getElementById('prompt-q');
39
98
  const promptRec = document.getElementById('prompt-rec');
@@ -50,6 +109,19 @@ export function clientScript(wsUrl) {
50
109
  const toolCallMap = {};
51
110
  let currentBubble = null;
52
111
  let streamText = '';
112
+ // Composer state: input is enabled whenever connected AND no prompt card is
113
+ // open (messages typed mid-run steer the live turn); the Send button morphs
114
+ // into a red Stop while the agent is running.
115
+ let agentRunning = false;
116
+ let connected = false;
117
+ // Whether the current live turn produced any content — gates the trailing
118
+ // turn-timestamp so an empty/aborted turn doesn't leave a stray clock.
119
+ let turnHadContent = false;
120
+ let stopArmed = false;
121
+ let stopArmTimer = null;
122
+ // The live thinking (<details>) block being streamed, and its accumulated text.
123
+ let currentThinking = null;
124
+ let thinkingText = '';
53
125
  let autoScroll = true;
54
126
  let reconnectDelay = 1000;
55
127
  let reconnectAnim = null;
@@ -144,7 +216,7 @@ export function clientScript(wsUrl) {
144
216
  el.className = 'bubble ' + role;
145
217
  // Only assistant text is markdown-rendered; user/error bubbles stay plain so a
146
218
  // user's literal *asterisks* or backticks show verbatim.
147
- if (role === 'assistant') setContent(el, text);
219
+ if (role === 'assistant') { setContent(el, text); attachBubbleCopy(el, text); }
148
220
  else el.textContent = text;
149
221
  chatLog.appendChild(el);
150
222
  scrollBottom();
@@ -235,10 +307,160 @@ export function clientScript(wsUrl) {
235
307
  s.appendChild(e);
236
308
  }
237
309
 
238
- function setEnabled(on) {
239
- const allow = on && activePromptId === null;
240
- inputEl.disabled = !allow;
241
- sendBtn.disabled = !allow;
310
+ // Reconcile the composer (input + Send/Stop button) with the current state.
311
+ // The input is disabled ONLY while disconnected or a prompt card is open — a
312
+ // running agent no longer locks it, so messages can steer the live turn.
313
+ function refreshComposer() {
314
+ updateStatusDot();
315
+ const promptOpen = activePromptId !== null;
316
+ inputEl.disabled = !connected || promptOpen;
317
+ inputEl.placeholder = agentRunning
318
+ ? 'message the agent — delivered mid-run'
319
+ : 'type a message\\u2026 (/ for commands)';
320
+ if (agentRunning && !promptOpen) {
321
+ // Send morphs into a red Stop that interrupts the running turn.
322
+ sendBtn.classList.add('stop');
323
+ sendBtn.disabled = !connected;
324
+ if (!stopArmed) sendBtn.textContent = 'Stop';
325
+ } else {
326
+ disarmStop();
327
+ sendBtn.classList.remove('stop');
328
+ sendBtn.textContent = 'Send';
329
+ sendBtn.disabled = !connected || promptOpen;
330
+ }
331
+ }
332
+
333
+ function disarmStop() {
334
+ stopArmed = false;
335
+ if (stopArmTimer) { clearTimeout(stopArmTimer); stopArmTimer = null; }
336
+ sendBtn.classList.remove('armed');
337
+ if (agentRunning) sendBtn.textContent = 'Stop';
338
+ }
339
+
340
+ function sendInterrupt() {
341
+ if (ws && ws.readyState === WebSocket.OPEN) {
342
+ ws.send(JSON.stringify({ type: 'interrupt' }));
343
+ }
344
+ }
345
+
346
+ // Stop needs a two-tap confirm (same pattern as the prompt card's cancel):
347
+ // first tap arms it, a second tap within 3s actually interrupts.
348
+ function onSendClick() {
349
+ if (agentRunning && activePromptId === null) {
350
+ if (stopArmed) { disarmStop(); sendInterrupt(); return; }
351
+ stopArmed = true;
352
+ sendBtn.classList.add('armed');
353
+ sendBtn.textContent = 'Tap to stop';
354
+ if (stopArmTimer) clearTimeout(stopArmTimer);
355
+ stopArmTimer = setTimeout(function () {
356
+ stopArmed = false;
357
+ sendBtn.classList.remove('armed');
358
+ if (agentRunning) sendBtn.textContent = 'Stop';
359
+ stopArmTimer = null;
360
+ }, 3000);
361
+ return;
362
+ }
363
+ sendMessage();
364
+ }
365
+
366
+ // Copy source text to the clipboard, flashing the button. Falls back to a
367
+ // hidden textarea + execCommand for non-secure (plain-http LAN) contexts where
368
+ // navigator.clipboard is unavailable.
369
+ function flashCopied(btn) {
370
+ if (!btn) return;
371
+ const prev = btn.textContent;
372
+ btn.textContent = 'Copied';
373
+ btn.classList.add('copied');
374
+ setTimeout(function () { btn.textContent = prev; btn.classList.remove('copied'); }, 1200);
375
+ }
376
+ function fallbackCopy(text, btn) {
377
+ try {
378
+ const ta = document.createElement('textarea');
379
+ ta.value = text;
380
+ ta.style.position = 'fixed';
381
+ ta.style.top = '0';
382
+ ta.style.opacity = '0';
383
+ document.body.appendChild(ta);
384
+ ta.focus(); ta.select();
385
+ document.execCommand('copy');
386
+ document.body.removeChild(ta);
387
+ flashCopied(btn);
388
+ } catch (e) {}
389
+ }
390
+ function copyText(text, btn) {
391
+ if (navigator.clipboard && navigator.clipboard.writeText) {
392
+ navigator.clipboard.writeText(text).then(function () { flashCopied(btn); },
393
+ function () { fallbackCopy(text, btn); });
394
+ } else {
395
+ fallbackCopy(text, btn);
396
+ }
397
+ }
398
+ // One delegated handler for every copy button (code-block headers live inside
399
+ // markdown HTML; bubble buttons are appended in JS). Resolve the source text
400
+ // from whichever container the button sits in.
401
+ chatLog.addEventListener('click', function (e) {
402
+ const btn = e.target && e.target.closest ? e.target.closest('.copy-btn') : null;
403
+ if (!btn) return;
404
+ const block = btn.closest('.code-block');
405
+ if (block) {
406
+ const code = block.querySelector('code');
407
+ copyText(code ? code.textContent : '', btn);
408
+ return;
409
+ }
410
+ const bub = btn.closest('.bubble');
411
+ if (bub) copyText(bub.__copyText != null ? bub.__copyText : bub.textContent, btn);
412
+ });
413
+
414
+ // Attach a copy button to a finished assistant bubble and stash its raw text
415
+ // (the pre-markdown source) so the delegated handler copies the original.
416
+ function attachBubbleCopy(el, text) {
417
+ el.__copyText = text;
418
+ const b = document.createElement('button');
419
+ b.className = 'copy-btn bubble-copy';
420
+ b.type = 'button';
421
+ b.setAttribute('aria-label', 'Copy message');
422
+ b.textContent = 'Copy';
423
+ el.appendChild(b);
424
+ }
425
+
426
+ // "✻ Thinking… (n lines)" collapsed-block summary.
427
+ function thinkingSummary(n) {
428
+ return '\\u273B Thinking\\u2026 (' + n + (n === 1 ? ' line' : ' lines') + ')';
429
+ }
430
+ function thinkingLineCount(text) {
431
+ return text ? text.split('\\n').length : 0;
432
+ }
433
+ // Build a collapsed thinking <details>. A live block keeps a spinner in the
434
+ // summary and stays open to further deltas; otherwise it's a finished, static one.
435
+ function makeThinkingEl(text, live) {
436
+ const d = document.createElement('details');
437
+ d.className = 'thinking-block';
438
+ const s = document.createElement('summary');
439
+ const lbl = document.createElement('span');
440
+ lbl.className = 'thinking-label';
441
+ lbl.textContent = thinkingSummary(thinkingLineCount(text));
442
+ s.appendChild(lbl);
443
+ if (live) {
444
+ const sp = document.createElement('span');
445
+ sp.className = 'thinking-spin spin';
446
+ s.appendChild(sp);
447
+ }
448
+ d.appendChild(s);
449
+ const body = document.createElement('div');
450
+ body.className = 'thinking-body';
451
+ body.textContent = text || '';
452
+ d.appendChild(body);
453
+ return d;
454
+ }
455
+ // Close out the live thinking block (drop its spinner) — called when the model
456
+ // moves on to text/tools, or the turn ends.
457
+ function finalizeThinking() {
458
+ if (currentThinking) {
459
+ const sp = currentThinking.querySelector('.thinking-spin');
460
+ if (sp) { sp.remove(); stopSpinIfIdle(); }
461
+ currentThinking = null;
462
+ }
463
+ thinkingText = '';
242
464
  }
243
465
 
244
466
  // Pull the human-readable text out of a tool result. Many tools (Read, Bash,
@@ -289,6 +511,23 @@ export function clientScript(wsUrl) {
289
511
  return d;
290
512
  }
291
513
 
514
+ // Dim HH:MM shown under a turn's last bubble (local time).
515
+ function fmtClock(ts) {
516
+ if (!ts) return '';
517
+ var d = new Date(ts);
518
+ var pad = function (n) { return (n < 10 ? '0' : '') + n; };
519
+ return pad(d.getHours()) + ':' + pad(d.getMinutes());
520
+ }
521
+ function addTurnTime(ts, roleClass) {
522
+ var clock = fmtClock(ts || Date.now());
523
+ if (!clock) return;
524
+ var el = document.createElement('div');
525
+ el.className = 'turn-time ' + roleClass;
526
+ el.textContent = clock;
527
+ chatLog.appendChild(el);
528
+ scrollBottom();
529
+ }
530
+
292
531
  // A muted, centered system note (e.g. "Context compacted").
293
532
  function addSystemLine(text) {
294
533
  const el = document.createElement('div');
@@ -303,13 +542,17 @@ export function clientScript(wsUrl) {
303
542
  // parts (text segments + tool calls), so the layout matches the terminal's
304
543
  // interleaving instead of one merged blob with tools dumped at the end.
305
544
  function renderTurn(t) {
306
- if (t.error) { addBubble('error', t.text); return; }
307
- if (t.role === 'system') { addSystemLine(t.text); return; }
308
- if (t.role === 'user') { addBubble('user', t.text); return; }
545
+ if (t.error) { addBubble('error', t.text); addTurnTime(t.ts, 'assistant'); return; }
546
+ if (t.role === 'system') { addSystemLine(t.text); addTurnTime(t.ts, 'system'); return; }
547
+ if (t.role === 'user') { addBubble('user', t.text); addTurnTime(t.ts, 'user'); return; }
309
548
  for (const p of (t.parts || [])) {
310
549
  if (p.kind === 'text') { if (p.text) addBubble('assistant', p.text); }
550
+ else if (p.kind === 'thinking') {
551
+ if (p.text) { chatLog.appendChild(makeThinkingEl(p.text, false)); scrollBottom(); }
552
+ }
311
553
  else renderToolPart(p);
312
554
  }
555
+ addTurnTime(t.ts, 'assistant');
313
556
  }
314
557
 
315
558
  // Render the in-progress assistant turn from a snapshot, preserving order. The
@@ -335,6 +578,18 @@ export function clientScript(wsUrl) {
335
578
  } else if (p.text) {
336
579
  addBubble('assistant', p.text);
337
580
  }
581
+ } else if (p.kind === 'thinking') {
582
+ if (last && !p.done) {
583
+ // Reconnected mid-thinking: keep the block live so further deltas flow in.
584
+ currentThinking = makeThinkingEl(p.text || '', true);
585
+ thinkingText = p.text || '';
586
+ chatLog.appendChild(currentThinking);
587
+ startSpin();
588
+ scrollBottom();
589
+ } else if (p.text) {
590
+ chatLog.appendChild(makeThinkingEl(p.text, false));
591
+ scrollBottom();
592
+ }
338
593
  } else {
339
594
  renderToolPart(p);
340
595
  }
@@ -347,6 +602,37 @@ export function clientScript(wsUrl) {
347
602
  t.textContent = message;
348
603
  document.body.appendChild(t);
349
604
  setTimeout(function () { t.remove(); }, 4000);
605
+ recordNotif(message, level);
606
+ }
607
+
608
+ // Keep the last ~20 toasts (newest first) for the bell's history dropdown.
609
+ function recordNotif(message, level) {
610
+ notifHistory.unshift({message: message, level: level || 'info', ts: Date.now()});
611
+ if (notifHistory.length > 20) notifHistory.pop();
612
+ renderNotifList();
613
+ }
614
+ function renderNotifList() {
615
+ if (!notifHistory.length) {
616
+ notifList.innerHTML = '<div id="notif-empty">No notifications yet.</div>';
617
+ return;
618
+ }
619
+ var html = '';
620
+ for (var i = 0; i < notifHistory.length; i++) {
621
+ var n = notifHistory[i];
622
+ html += '<div class="notif-item ' + escHtml(n.level) + '">'
623
+ + '<span class="notif-dot"></span>'
624
+ + '<span class="notif-msg">' + escHtml(n.message) + '</span>'
625
+ + '<span class="notif-time">' + fmtClock(n.ts) + '</span></div>';
626
+ }
627
+ notifList.innerHTML = html;
628
+ }
629
+
630
+ let notifOpen = false;
631
+ function setNotifOpen(open) {
632
+ notifOpen = open;
633
+ notifPanel.classList.toggle('open', open);
634
+ notifPanel.setAttribute('aria-hidden', open ? 'false' : 'true');
635
+ if (open) { renderNotifList(); updateNotifToggle(); }
350
636
  }
351
637
 
352
638
  const bell = document.getElementById('bell');
@@ -363,6 +649,12 @@ export function clientScript(wsUrl) {
363
649
  var on = notifyEnabled();
364
650
  bell.textContent = on ? '\\u25C9' : '\\u25EF';
365
651
  bell.classList.toggle('on', on);
652
+ updateNotifToggle();
653
+ }
654
+ function updateNotifToggle() {
655
+ var on = notifyEnabled();
656
+ notifToggle.textContent = on ? 'On' : 'Enable';
657
+ notifToggle.classList.toggle('on', on);
366
658
  }
367
659
 
368
660
  // Why notifications can't be enabled here, or null if they can.
@@ -376,7 +668,17 @@ export function clientScript(wsUrl) {
376
668
  return null;
377
669
  }
378
670
 
379
- bell.addEventListener('click', function () {
671
+ // The bell opens the notification dropdown (history + push toggle); the push
672
+ // enable/disable lives on the toggle inside it.
673
+ bell.addEventListener('click', function (e) {
674
+ e.stopPropagation();
675
+ setNotifOpen(!notifOpen);
676
+ });
677
+ document.addEventListener('click', function (e) {
678
+ if (notifOpen && !notifPanel.contains(e.target) && e.target !== bell) setNotifOpen(false);
679
+ });
680
+
681
+ function togglePush() {
380
682
  // Turning OFF always works regardless of environment.
381
683
  if (localStorage.getItem(NOTIFY_KEY) === '1') {
382
684
  localStorage.setItem(NOTIFY_KEY, '0'); updateBell(); return;
@@ -400,7 +702,8 @@ export function clientScript(wsUrl) {
400
702
  updateBell();
401
703
  });
402
704
  });
403
- });
705
+ }
706
+ notifToggle.addEventListener('click', togglePush);
404
707
 
405
708
  // VAPID public key (base64url) -> Uint8Array for applicationServerKey.
406
709
  function urlB64ToUint8Array(base64) {
@@ -453,7 +756,7 @@ export function clientScript(wsUrl) {
453
756
  promptRec.style.display = 'none';
454
757
  activeRecommended2 = '';
455
758
  if (cancelArmTimer) { clearTimeout(cancelArmTimer); cancelArmTimer = null; }
456
- setEnabled(true);
759
+ refreshComposer();
457
760
  }
458
761
 
459
762
  function makeBtn(label, cls, onClick) {
@@ -549,7 +852,7 @@ export function clientScript(wsUrl) {
549
852
  promptInput.focus();
550
853
  }
551
854
  promptCard.style.display = 'block';
552
- setEnabled(false);
855
+ refreshComposer();
553
856
  }
554
857
 
555
858
  function handleMsg(msg) {
@@ -561,6 +864,7 @@ export function clientScript(wsUrl) {
561
864
  chatLog.innerHTML = '';
562
865
  closePrompt();
563
866
  hideThinking();
867
+ currentThinking = null; thinkingText = '';
564
868
  currentBubble = null; streamText = '';
565
869
  for (const k in toolCallMap) delete toolCallMap[k];
566
870
  // Per-turn try/catch: one malformed turn must never abort the rebuild
@@ -568,10 +872,14 @@ export function clientScript(wsUrl) {
568
872
  for (const t of (msg.turns || [])) { try { renderTurn(t); } catch (e) {} }
569
873
  if (msg.live) { try { renderLiveTurn(msg.live); } catch (e) {} }
570
874
  taskWidgetLines = (msg.taskWidget && msg.taskWidget.length) ? msg.taskWidget : null;
875
+ taskWidgetData = msg.taskWidgetData || null;
571
876
  renderWidgets();
877
+ setModelName(msg.model);
572
878
  if (msg.context) setContextBar(msg.context); else contextFill.style.width = '0%';
879
+ agentRunning = !!msg.agentRunning;
880
+ turnHadContent = !!(msg.live && msg.live.parts && msg.live.parts.length);
573
881
  if (msg.prompt) showPrompt(msg.prompt);
574
- setEnabled(!msg.agentRunning && !msg.prompt);
882
+ refreshComposer();
575
883
  if (msg.agentRunning && !msg.live) showThinking();
576
884
  break;
577
885
  }
@@ -579,12 +887,37 @@ export function clientScript(wsUrl) {
579
887
  autoScroll = true;
580
888
  streamText = '';
581
889
  currentBubble = null;
582
- setEnabled(false);
890
+ turnHadContent = false;
891
+ agentRunning = true;
892
+ setModelName(msg.model);
893
+ refreshComposer();
894
+ showThinking();
895
+ break;
896
+ case 'thinking_delta':
897
+ turnHadContent = true;
898
+ hideThinking(); // drop the spinner-only bubble
899
+ if (!currentThinking) {
900
+ currentThinking = makeThinkingEl('', true);
901
+ chatLog.appendChild(currentThinking);
902
+ thinkingText = '';
903
+ startSpin();
904
+ }
905
+ thinkingText += msg.delta;
906
+ currentThinking.querySelector('.thinking-body').textContent = thinkingText;
907
+ currentThinking.querySelector('.thinking-label').textContent =
908
+ thinkingSummary(thinkingLineCount(thinkingText));
909
+ scrollBottom();
910
+ break;
911
+ case 'thinking_end':
912
+ finalizeThinking();
913
+ // Model moves to text/tool next — show the spinner meanwhile.
583
914
  showThinking();
584
915
  break;
585
916
  case 'text_delta':
917
+ turnHadContent = true;
586
918
  if (!currentBubble) {
587
919
  hideThinking();
920
+ finalizeThinking();
588
921
  currentBubble = document.createElement('div');
589
922
  currentBubble.className = 'bubble assistant';
590
923
  const cursor = document.createElement('span');
@@ -604,7 +937,7 @@ export function clientScript(wsUrl) {
604
937
  if (currentBubble) {
605
938
  const c = currentBubble.querySelector('.cursor');
606
939
  if (c) c.remove();
607
- if (streamText) setContent(currentBubble, streamText);
940
+ if (streamText) { setContent(currentBubble, streamText); attachBubbleCopy(currentBubble, streamText); }
608
941
  // Close this message's bubble so the next text segment (after a tool or
609
942
  // the next message) starts a fresh bubble — matching the terminal.
610
943
  currentBubble = null;
@@ -613,7 +946,9 @@ export function clientScript(wsUrl) {
613
946
  }
614
947
  break;
615
948
  case 'tool_start': {
949
+ turnHadContent = true;
616
950
  hideThinking();
951
+ finalizeThinking();
617
952
  const d = addToolCall(msg.toolName, msg.args, false);
618
953
  const sp = document.createElement('span');
619
954
  sp.className = 'tool-spin spin';
@@ -642,28 +977,39 @@ export function clientScript(wsUrl) {
642
977
  }
643
978
  case 'user_message':
644
979
  addBubble('user', msg.text);
980
+ addTurnTime(Date.now(), 'user');
645
981
  break;
646
982
  case 'system_note':
647
983
  addSystemLine(msg.text);
984
+ addTurnTime(Date.now(), 'system');
648
985
  break;
649
986
  case 'agent_error':
650
987
  hideThinking();
988
+ finalizeThinking();
651
989
  if (currentBubble) {
652
990
  const c = currentBubble.querySelector('.cursor');
653
991
  if (c) c.remove();
654
- if (streamText) setContent(currentBubble, streamText);
992
+ if (streamText) { setContent(currentBubble, streamText); attachBubbleCopy(currentBubble, streamText); }
655
993
  currentBubble = null;
656
994
  streamText = '';
657
995
  stopSpinIfIdle();
658
996
  }
659
997
  addBubble('error', msg.message || 'Error');
660
- setEnabled(true);
998
+ if (turnHadContent) addTurnTime(Date.now(), 'assistant');
999
+ turnHadContent = false;
1000
+ agentRunning = false;
1001
+ refreshComposer();
661
1002
  break;
662
1003
  case 'agent_end':
663
1004
  hideThinking();
1005
+ finalizeThinking();
664
1006
  currentBubble = null;
665
1007
  streamText = '';
666
- setEnabled(true);
1008
+ if (turnHadContent) addTurnTime(Date.now(), 'assistant');
1009
+ turnHadContent = false;
1010
+ agentRunning = false;
1011
+ setModelName(msg.model);
1012
+ refreshComposer();
667
1013
  setContextBar(msg.contextUsage);
668
1014
  break;
669
1015
  case 'context':
@@ -678,6 +1024,7 @@ export function clientScript(wsUrl) {
678
1024
  break;
679
1025
  case 'widget':
680
1026
  taskWidgetLines = (msg.lines && msg.lines.length) ? msg.lines : null;
1027
+ taskWidgetData = msg.data || null;
681
1028
  renderWidgets();
682
1029
  break;
683
1030
  case 'notify':
@@ -691,9 +1038,14 @@ export function clientScript(wsUrl) {
691
1038
  // A new session started — wipe the previous session's transcript.
692
1039
  chatLog.innerHTML = '';
693
1040
  hideThinking();
1041
+ finalizeThinking();
694
1042
  currentBubble = null; streamText = '';
1043
+ turnHadContent = false;
695
1044
  closePrompt();
1045
+ agentRunning = false;
1046
+ refreshComposer();
696
1047
  taskWidgetLines = null;
1048
+ taskWidgetData = null;
697
1049
  renderWidgets();
698
1050
  contextFill.style.width = '0%';
699
1051
  break;
@@ -712,11 +1064,12 @@ export function clientScript(wsUrl) {
712
1064
  // The server records the message via addUserTurn and broadcasts a
713
1065
  // user_message back to every client (us included), which renders the
714
1066
  // bubble. Don't render it here too, or the sender sees it twice.
715
- setEnabled(false);
716
- showThinking();
1067
+ // Mid-run the message steers the live turn (no state change here); when
1068
+ // idle, optimistically show the spinner until agent_start lands.
1069
+ if (!agentRunning) showThinking();
717
1070
  }
718
1071
 
719
- sendBtn.addEventListener('click', sendMessage);
1072
+ sendBtn.addEventListener('click', onSendClick);
720
1073
  inputEl.addEventListener('keydown', (e) => {
721
1074
  if (cmdActive.length > 0) {
722
1075
  if (e.key === 'ArrowDown') { e.preventDefault(); cmdIndex = Math.min(cmdIndex + 1, cmdActive.length - 1); renderSuggestions(); return; }
@@ -746,7 +1099,8 @@ export function clientScript(wsUrl) {
746
1099
  if (reconnectAnim) { clearInterval(reconnectAnim); reconnectAnim = null; }
747
1100
  reconnectOverlay.classList.remove('visible');
748
1101
  reconnectDelay = 1000;
749
- setEnabled(true);
1102
+ connected = true;
1103
+ refreshComposer();
750
1104
  // Self-heal on every (re)connect, not just page load: if the server
751
1105
  // restarted it may have lost (or be rehydrating) our subscription, and
752
1106
  // browsers can rotate it. Re-registering here covers reconnects the
@@ -760,7 +1114,8 @@ export function clientScript(wsUrl) {
760
1114
  });
761
1115
  sock.addEventListener('close', () => {
762
1116
  if (ws !== sock) return;
763
- setEnabled(false);
1117
+ connected = false;
1118
+ refreshComposer();
764
1119
  reconnectOverlay.classList.add('visible');
765
1120
  // Animate the same braille spinner used elsewhere, with a live countdown.
766
1121
  const until = Date.now() + reconnectDelay;