clideck 1.27.0 → 1.29.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.
package/public/js/app.js CHANGED
@@ -14,6 +14,8 @@ import { registerHotkey, unregisterHotkey, unregisterAllForPlugin } from './hotk
14
14
  import { renderPrompts } from './prompts.js';
15
15
  import { renderRoles } from './roles.js';
16
16
 
17
+ const shownAgentHealthToasts = new Set();
18
+
17
19
  function connect() {
18
20
  state.ws = new WebSocket(`ws://${location.host}`);
19
21
 
@@ -50,6 +52,12 @@ function connect() {
50
52
  state.presets = msg.presets;
51
53
  renderSettings();
52
54
  refreshCreator();
55
+ for (const p of state.presets) {
56
+ if (p.available && p.health && !p.health.ok && p.health.reason !== 'Not installed' && !shownAgentHealthToasts.has(p.presetId)) {
57
+ shownAgentHealthToasts.add(p.presetId);
58
+ showToast(`${p.name}: ${p.health.reason}`, { id: `agent-health-${p.presetId}`, type: p.versionOk === false ? 'error' : 'warn', duration: 0, title: 'Agent Attention' });
59
+ }
60
+ }
53
61
  break;
54
62
  case 'sessions.resumable':
55
63
  state.resumable = msg.list;
@@ -83,17 +91,23 @@ function connect() {
83
91
  case 'session.status':
84
92
  setStatus(msg.id, msg.working);
85
93
  break;
86
- // Server requests screen capture (e.g. after PermissionRequest hook)
87
- case 'screen.capture': {
94
+ // Server requests terminal capture (e.g. after PermissionRequest hook)
95
+ case 'terminal.capture': {
88
96
  const ce = state.terms.get(msg.id);
89
97
  if (ce?.term) {
90
98
  const buf = ce.term.buffer.active;
91
99
  const lines = [];
92
100
  for (let i = 0; i < buf.length; i++) { const line = buf.getLine(i); if (line) lines.push(line.translateToString(true)); }
93
- send({ type: 'terminal.buffer', id: msg.id, lines });
101
+ send({ type: 'terminal.buffer', id: msg.id, lines, menuVersion: msg.menuVersion });
94
102
  }
95
103
  break;
96
104
  }
105
+ case 'session.history': {
106
+ const entry = state.terms.get(msg.id);
107
+ if (entry && !entry.queue(msg.text + '\n')) entry.term.write(msg.text + '\n');
108
+ updatePreview(msg.id);
109
+ break;
110
+ }
97
111
  // Bridge preview text (OpenCode plugin)
98
112
  case 'session.preview': {
99
113
  const pe = state.terms.get(msg.id);
@@ -193,7 +207,9 @@ function connect() {
193
207
  if (!toast) break;
194
208
  const actionsEl = toast.querySelector('.setup-actions');
195
209
  if (msg.success) {
196
- const sid = toast.dataset.sessionId;
210
+ const sid = (toast.dataset.sessionId && toast.dataset.sessionId !== 'null' && toast.dataset.sessionId !== 'undefined')
211
+ ? toast.dataset.sessionId
212
+ : '';
197
213
  actionsEl.innerHTML = `
198
214
  <div class="flex-1 flex items-center gap-1.5 text-xs text-emerald-400">
199
215
  <svg class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5"><path d="M5 13l4 4L19 7"/></svg>
@@ -221,6 +237,19 @@ function connect() {
221
237
  case 'plugins':
222
238
  loadPlugins(msg.list);
223
239
  break;
240
+ case 'plugin.install.result': {
241
+ const btn = document.querySelector(`.plugin-install-btn[data-plugin-id="${msg.pluginId}"]`);
242
+ if (!btn) break;
243
+ if (msg.success) {
244
+ btn.textContent = 'Installed';
245
+ btn.className = btn.className.replace('bg-blue-600 hover:bg-blue-500 text-white', 'bg-emerald-600/20 text-emerald-400 cursor-default');
246
+ } else {
247
+ btn.textContent = 'Failed';
248
+ btn.className = btn.className.replace('bg-blue-600 hover:bg-blue-500', 'bg-red-600/20 text-red-400 cursor-default');
249
+ btn.disabled = false;
250
+ }
251
+ break;
252
+ }
224
253
  case 'pills':
225
254
  state.pills.clear();
226
255
  for (const p of msg.list) addPill(p);
@@ -420,7 +449,7 @@ function showTelemetrySetup(commandId, sessionId) {
420
449
 
421
450
  const toast = document.createElement('div');
422
451
  toast.dataset.setupPreset = preset.presetId;
423
- toast.dataset.sessionId = sessionId;
452
+ if (sessionId) toast.dataset.sessionId = sessionId;
424
453
  toast.dataset.commandId = commandId;
425
454
  toast.className = 'fixed bottom-5 right-5 z-[500] w-[360px] bg-slate-800/95 backdrop-blur-sm border border-slate-700/60 rounded-xl shadow-2xl shadow-black/60';
426
455
  toast.style.opacity = '0';
@@ -746,15 +775,34 @@ function renderPluginsPanel(list) {
746
775
  }
747
776
  const expanded = getPluginExpanded();
748
777
  const trashSvg = `<svg class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"/></svg>`;
778
+ const defaultIcon = `<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v2m6.36 1.64l-1.42 1.42M21 12h-2M17.94 17.94l-1.42-1.42M12 19v2M6.06 17.94l1.42-1.42M3 12h2M6.06 6.06l1.42 1.42"/><circle cx="12" cy="12" r="4"/></svg>`;
749
779
 
750
780
  container.innerHTML = list.map((p, i) => {
751
781
  const open = !!expanded[p.id];
782
+ const icon = p.icon || defaultIcon;
752
783
  const deleteBtn = p.bundled ? '' : `<div class="plugin-delete flex items-center justify-center w-6 h-6 rounded text-slate-600 hover:text-red-400 hover:bg-slate-700/50 cursor-pointer transition-colors flex-shrink-0" data-plugin-id="${esc(p.id)}" data-plugin-name="${esc(p.name)}" title="Remove plugin">${trashSvg}</div>`;
753
784
  const hasFooter = p.author || !p.bundled;
785
+
786
+ if (!p.installed) {
787
+ return `
788
+ <div class="plugin-card ${i > 0 ? 'border-t border-slate-700/50' : ''}">
789
+ <div class="px-4 py-3">
790
+ <div class="flex items-center gap-2">
791
+ <span class="text-slate-500 flex-shrink-0">${icon}</span>
792
+ <span class="flex-1 text-sm font-medium text-slate-400 truncate">${esc(p.name)}</span>
793
+ <span class="text-[10px] text-slate-600 flex-shrink-0">v${esc(p.version)}</span>
794
+ <button class="plugin-install-btn px-2.5 py-1 text-[11px] font-medium bg-blue-600 hover:bg-blue-500 text-white rounded-md transition-colors flex-shrink-0" data-plugin-id="${esc(p.id)}">Install</button>
795
+ </div>
796
+ ${p.description ? `<p class="text-[11px] text-slate-600 mt-0.5 leading-snug">${esc(p.description)}</p>` : ''}
797
+ </div>
798
+ </div>`;
799
+ }
800
+
754
801
  return `
755
802
  <div class="plugin-card ${i > 0 ? 'border-t border-slate-700/50' : ''}">
756
803
  <div class="plugin-toggle px-4 py-3 hover:bg-slate-800/50 transition-colors cursor-pointer" data-plugin-id="${esc(p.id)}">
757
804
  <div class="flex items-center gap-2">
805
+ <span class="text-slate-400 flex-shrink-0">${icon}</span>
758
806
  <span class="flex-1 text-sm font-medium text-slate-200 truncate">${esc(p.name)}</span>
759
807
  <span class="text-[10px] text-slate-500 flex-shrink-0">v${esc(p.version)}</span>
760
808
  <svg class="plugin-chevron w-4 h-4 text-slate-500 transition-transform duration-200 flex-shrink-0 ${open ? '' : 'collapsed'}" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path d="M19 9l-7 7-7-7"/></svg>
@@ -793,6 +841,15 @@ function renderPluginsPanel(list) {
793
841
  });
794
842
  });
795
843
 
844
+ container.querySelectorAll('.plugin-install-btn').forEach(el => {
845
+ el.addEventListener('click', () => {
846
+ el.disabled = true;
847
+ el.textContent = 'Installing...';
848
+ el.className = el.className.replace('bg-blue-600 hover:bg-blue-500', 'bg-slate-700 cursor-wait');
849
+ send({ type: 'plugin.install', pluginId: el.dataset.pluginId });
850
+ });
851
+ });
852
+
796
853
  container.querySelectorAll('[data-setting]').forEach(el => {
797
854
  const pluginId = el.dataset.plugin;
798
855
  const key = el.dataset.setting;
@@ -35,11 +35,15 @@ function isPresetMissing(p) {
35
35
  return cmd.command === p.command;
36
36
  }
37
37
 
38
+ function isPresetOutdated(p) {
39
+ return p.available !== false && p.versionOk === false;
40
+ }
41
+
38
42
  // True if preset binary exists but telemetry/hooks are not configured yet
39
43
  function isPresetUnpatched(p) {
40
- if (p.available === false || !p.telemetryAutoSetup) return false;
44
+ if (p.available === false || p.versionOk === false || !p.telemetryAutoSetup) return false;
41
45
  const cmd = findCommandForPreset(p);
42
- return !cmd || !cmd.telemetryEnabled;
46
+ return !cmd || !cmd.telemetryStatus?.ok;
43
47
  }
44
48
 
45
49
  function renderPresetButtons() {
@@ -52,6 +56,14 @@ function renderPresetButtons() {
52
56
  <button class="install-btn px-2.5 py-1 text-[11px] font-medium text-blue-400 hover:text-blue-300 bg-blue-500/10 hover:bg-blue-500/20 rounded-md transition-colors" data-preset="${p.presetId}">Add</button>
53
57
  </div>`;
54
58
  }
59
+ if (isPresetOutdated(p)) {
60
+ return `
61
+ <div class="w-full flex items-center gap-2.5 px-3 py-2 rounded-md text-sm text-left text-slate-500">
62
+ <span class="opacity-40">${agentIcon(p.icon, 24)}</span>
63
+ <span class="flex-1 min-w-0">${esc(p.name)}</span>
64
+ <button class="install-btn px-2.5 py-1 text-[11px] font-medium text-rose-400 hover:text-rose-300 bg-rose-500/10 hover:bg-rose-500/20 rounded-md transition-colors" data-preset="${p.presetId}">Update</button>
65
+ </div>`;
66
+ }
55
67
  if (isPresetUnpatched(p)) {
56
68
  return `
57
69
  <div class="w-full flex items-center gap-2.5 px-3 py-2 rounded-md text-sm text-left text-slate-500">
@@ -111,10 +111,12 @@ function integrationSection(c) {
111
111
  const preset = telemetryPreset(c);
112
112
  if (!preset) return '';
113
113
  if (!preset.telemetryAutoSetup && !preset.bridge) return '';
114
- const configured = !!c.telemetryEnabled;
115
- const detail = configured
116
- ? `<span class="text-emerald-400/80">Configured</span> &mdash; ${esc(preset.telemetryConfigPath || '')}`
117
- : `<span class="text-slate-500">Not configured</span> &mdash; enable agent to set up`;
114
+ const configured = !!c.telemetryStatus?.ok;
115
+ const detail = preset.versionOk === false
116
+ ? `<span class="text-rose-400/80">Update required</span> &mdash; need ${esc(preset.minVersion)}+ (found ${esc(preset.version || 'unknown')})`
117
+ : configured
118
+ ? `<span class="text-emerald-400/80">Configured</span> &mdash; ${esc(preset.telemetryConfigPath || '')}`
119
+ : `<span class="text-amber-400/80">${esc(c.telemetryStatus?.error || 'Needs setup')}</span> &mdash; ${esc(preset.telemetryConfigPath || '')}`;
118
120
  return `
119
121
  <div class="mt-3 pt-3 border-t border-slate-700/50">
120
122
  <div class="text-[11px] text-slate-500">${detail}</div>
@@ -383,7 +385,7 @@ function renderThemeSection() {
383
385
  function renderNotifications() {
384
386
  const enabled = !!state.cfg.notifyIdle;
385
387
  document.getElementById('cfg-notify-idle').checked = enabled;
386
- document.getElementById('cfg-notify-min-work').value = state.cfg.notifyMinWork || 10;
388
+ document.getElementById('cfg-notify-min-work').value = state.cfg.notifyMinWork ?? 0;
387
389
 
388
390
  const permStatus = document.getElementById('notify-permission-status');
389
391
  if (enabled && 'Notification' in window) {
@@ -461,7 +463,7 @@ function saveConfig() {
461
463
  state.cfg.defaultPath = document.getElementById('cfg-default-path').value.trim();
462
464
  state.cfg.confirmClose = document.getElementById('cfg-confirm-close').checked;
463
465
  state.cfg.notifyIdle = document.getElementById('cfg-notify-idle').checked;
464
- state.cfg.notifyMinWork = parseInt(document.getElementById('cfg-notify-min-work').value, 10) || 10;
466
+ state.cfg.notifyMinWork = parseInt(document.getElementById('cfg-notify-min-work').value, 10) || 0;
465
467
  state.cfg.notifySoundEnabled = document.getElementById('cfg-notify-sound').checked;
466
468
  state.cfg.notifySound = document.getElementById('cfg-notify-sound-pick').value;
467
469
  // Preserve fields not managed by this form
@@ -1,5 +1,5 @@
1
1
  import { state, send } from './state.js';
2
- import { esc, resolveIconPath } from './utils.js';
2
+ import { esc, miniMarkdown, resolveIconPath } from './utils.js';
3
3
  import { resolveTheme, resolveAccent, applyTheme } from './profiles.js';
4
4
  import { attachToTerminal, registerHotkey } from './hotkeys.js';
5
5
  import { closeDropdown } from './prompts.js';
@@ -338,25 +338,56 @@ export function addTerminal(id, name, themeId, commandId, projectId, muted, last
338
338
  term.loadAddon(fit);
339
339
  term.onData(data => send({ type: 'input', id, data }));
340
340
 
341
- // [SCREEN-CAPTURE] extract terminal buffer when BOTH idle AND render-silent (2s)
342
- // Decoupled from status: telemetry knows when agent is done, onRender knows when terminal is done
343
- const _hasServerStatus = cmd?.presetId === 'claude-code' || cmd?.presetId === 'codex' || cmd?.presetId === 'gemini-cli' || cmd?.presetId === 'opencode';
344
- let _screenTimer = null, _renderSilent = false;
345
- function _tryScreenCapture() {
341
+ // [TRANSCRIPT-CAPTURE] initial settled capture plus one delayed idle save
342
+ let _captureTimer = null, _renderSilent = false, _lastTyping = 0, _initialCaptureDone = false, _idleSaveTimer = null;
343
+ function _sendCapture() {
346
344
  const entry = state.terms.get(id);
347
- if (!entry?.pendingScreenCapture || (!_renderSilent && !_hasServerStatus) || !entry.term) return;
348
- entry.pendingScreenCapture = false;
345
+ if (!entry?.term) return;
349
346
  const buf = entry.term.buffer.active;
350
347
  const lines = [];
351
348
  for (let i = 0; i < buf.length; i++) { const line = buf.getLine(i); if (line) lines.push(line.translateToString(true)); }
352
349
  send({ type: 'terminal.buffer', id, lines });
353
350
  }
354
- let _lastTyping = 0;
355
- term.onData(() => { _lastTyping = Date.now(); });
351
+ function _isChrome(t) {
352
+ return !t
353
+ || /^[─━═\u2500-\u257f]+$/.test(t)
354
+ || /^[▀▄█▌▐░▒▓╭╮╰╯│╔╗╚╝║]+$/.test(t)
355
+ || (/[█▀▄▌▐░▒▓]/.test(t) && /^[█▀▄▌▐░▒▓\s]+$/.test(t))
356
+ || /^[❯>$%#]\s*$/.test(t)
357
+ || /^(esc to interrupt|\? for shortcuts)$/i.test(t);
358
+ }
359
+ function _hasContent() {
360
+ const entry = state.terms.get(id);
361
+ if (!entry?.term) return false;
362
+ const buf = entry.term.buffer.active;
363
+ for (let i = 0; i < buf.length; i++) {
364
+ const text = buf.getLine(i)?.translateToString(true).trim();
365
+ if (!_isChrome(text)) return true;
366
+ }
367
+ return false;
368
+ }
369
+ function _tryCapture() {
370
+ const entry = state.terms.get(id);
371
+ if (!_renderSilent || Date.now() - _lastTyping < 2000) return;
372
+ // Initial capture: first time render settles with real content, capture regardless of working/idle
373
+ if (!_initialCaptureDone) {
374
+ if (!_hasContent()) return; // retry on next silence
375
+ _initialCaptureDone = true;
376
+ _sendCapture();
377
+ return;
378
+ }
379
+ }
380
+ term.onData(() => {
381
+ _lastTyping = Date.now();
382
+ // User typing invalidates pending capture — will re-try after silence
383
+ _renderSilent = false;
384
+ clearTimeout(_captureTimer);
385
+ _captureTimer = setTimeout(() => { _renderSilent = true; _tryCapture(); }, 2000);
386
+ });
356
387
  term.onRender(() => {
357
388
  _renderSilent = false;
358
- clearTimeout(_screenTimer);
359
- _screenTimer = setTimeout(() => { _renderSilent = true; _tryScreenCapture(); }, 2000);
389
+ clearTimeout(_captureTimer);
390
+ _captureTimer = setTimeout(() => { _renderSilent = true; _tryCapture(); }, 2000);
360
391
  });
361
392
  term.onWriteParsed(() => {
362
393
  if (Date.now() - _lastTyping < 500) return;
@@ -364,8 +395,23 @@ export function addTerminal(id, name, themeId, commandId, projectId, muted, last
364
395
  if (entry) entry.lastRenderAt = Date.now();
365
396
  });
366
397
 
367
- // Expose capture function so setStatus can trigger it when idle arrives after render silence
368
- setTimeout(() => { const e = state.terms.get(id); if (e) e.tryScreenCapture = _tryScreenCapture; }, 0);
398
+ // Expose capture function so setStatus can schedule a retry
399
+ setTimeout(() => {
400
+ const e = state.terms.get(id);
401
+ if (e) {
402
+ e.tryCapture = _tryCapture;
403
+ e.sendCaptureNow = _sendCapture;
404
+ e.scheduleIdleCapture = () => {
405
+ clearTimeout(_idleSaveTimer);
406
+ _idleSaveTimer = setTimeout(() => {
407
+ const entry = state.terms.get(id);
408
+ if (!entry || entry.working) return;
409
+ _sendCapture();
410
+ }, 300);
411
+ };
412
+ e.cancelIdleCapture = () => clearTimeout(_idleSaveTimer);
413
+ }
414
+ }, 0);
369
415
 
370
416
  term.open(el);
371
417
  attachToTerminal(term);
@@ -394,8 +440,21 @@ export function addTerminal(id, name, themeId, commandId, projectId, muted, last
394
440
  fitRaf = requestAnimationFrame(() => { fitRaf = 0; doFit(); });
395
441
  });
396
442
  ro.observe(el);
397
- // Safety: if RO hasn't fired within 500ms, flush anyway to avoid unbounded queue
398
- setTimeout(() => { if (!fitted) { fitted = true; for (const chunk of pending) term.write(chunk); pending = null; updatePreview(id); } }, 500);
443
+ // Safety: if RO hasn't fired within 500ms, flush anyway to avoid unbounded queue.
444
+ // If the element is hidden (background tab), force a reasonable default size so the PTY
445
+ // doesn't stay at a tiny default and produce garbled output.
446
+ setTimeout(() => {
447
+ if (!fitted) {
448
+ fitted = true;
449
+ if (!el.offsetWidth) {
450
+ term.resize(120, 30);
451
+ send({ type: 'resize', id, cols: 120, rows: 30 });
452
+ }
453
+ for (const chunk of pending) term.write(chunk);
454
+ pending = null;
455
+ updatePreview(id);
456
+ }
457
+ }, 500);
399
458
  const cancelFitRaf = () => { if (fitRaf) { cancelAnimationFrame(fitRaf); fitRaf = 0; } };
400
459
  state.terms.set(id, { term, fit, el, ro, cancelFitRaf, themeId, commandId, projectId: projectId || null, muted: !!muted, working: false, workStartedAt: null, stopBounce, queue: (data) => { if (!fitted) { pending.push(data); return true; } return false; }, lastActivityAt: Date.now(), unread: false, lastPreviewText: lastPreview || '', searchText: '' });
401
460
  document.getElementById('empty').style.display = 'none';
@@ -526,11 +585,12 @@ function setStatus(id, working) {
526
585
 
527
586
  // Notify on working → idle transition
528
587
  if (wasWorking && !working && !entry.muted) {
588
+ const minWork = state.cfg.notifyMinWork ?? 0;
529
589
  const workDuration = (Date.now() - (entry.workStartedAt || 0)) / 1000;
530
- const minWork = state.cfg.notifyMinWork || 10;
531
590
  if (workDuration >= minWork) {
532
- // Sound: plays unless this session is the one you're looking at
533
- if (state.cfg.notifySoundEnabled !== false && state.active !== id) {
591
+ entry.workStartedAt = null;
592
+ // Sound: all sessions when tab unfocused, all except active when focused
593
+ if (state.cfg.notifySoundEnabled !== false && (!document.hasFocus() || state.active !== id)) {
534
594
  new Audio(`/fx/${(state.cfg.notifySound || 'default-beep')}.mp3`).play().catch(() => {});
535
595
  }
536
596
  // Browser notification: plays when the CliDeck tab is not focused
@@ -545,11 +605,15 @@ function setStatus(id, working) {
545
605
  }
546
606
  }
547
607
 
548
- // Mark idle so the onRender silence watcher can capture .screen
549
- // Also try immediately — renders may already be silent
550
- if (wasWorking && !working) { entry.pendingScreenCapture = true; entry.tryScreenCapture?.(); }
608
+ // Save once shortly after idle unless the agent resumes first.
609
+ if (wasWorking && !working) {
610
+ entry.scheduleIdleCapture?.();
611
+ }
551
612
 
552
- if (working) entry.workStartedAt = Date.now();
613
+ if (working) {
614
+ entry.cancelIdleCapture?.();
615
+ if (!entry.workStartedAt) entry.workStartedAt = Date.now();
616
+ }
553
617
 
554
618
  const el = document.querySelector(`.group[data-id="${id}"] .session-status`);
555
619
  if (!el) return;
@@ -1111,7 +1175,7 @@ function openPillLog(id) {
1111
1175
  <span class="flex-1"></span>
1112
1176
  <button class="pill-log-clear text-[11px] text-slate-600 hover:text-slate-400 transition-colors">Clear</button>
1113
1177
  </div>
1114
- <div class="pill-log-body flex-1 overflow-y-auto p-4 font-mono text-xs leading-relaxed tmx-scroll"></div>
1178
+ <div class="pill-log-body flex-1 overflow-y-auto p-4 text-xs leading-relaxed tmx-scroll"></div>
1115
1179
  </div>`;
1116
1180
  document.getElementById('terminals').appendChild(panel);
1117
1181
  panel.querySelector('.pill-log-clear').addEventListener('click', () => {
@@ -1146,9 +1210,45 @@ function appendLogLine(entry) {
1146
1210
  const body = document.querySelector('#pill-log-panel .pill-log-body');
1147
1211
  if (!body) return;
1148
1212
  const line = document.createElement('div');
1149
- line.className = 'flex gap-3 py-0.5';
1150
1213
  const time = new Date(entry.ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
1151
- line.innerHTML = `<span class="text-slate-600 flex-shrink-0">${time}</span><span class="text-slate-400">${esc(entry.text)}</span>`;
1214
+ const t = entry.text;
1215
+
1216
+ // Categorize log entries for visual treatment
1217
+ let color = 'text-slate-400';
1218
+ let icon = '';
1219
+ let content = esc(t);
1220
+ if (/^Started with/.test(t)) {
1221
+ color = 'text-emerald-400';
1222
+ icon = '<span class="text-emerald-500">&#9654;</span>';
1223
+ } else if (/^Routed /.test(t)) {
1224
+ color = 'text-indigo-400';
1225
+ icon = '<span class="text-indigo-500">&#8594;</span>';
1226
+ } else if (/^Notify:/.test(t)) {
1227
+ color = 'text-amber-300';
1228
+ icon = '<span class="text-amber-500">&#9679;</span>';
1229
+ content = '<strong class="text-amber-300">Notify:</strong> ' + miniMarkdown(t.replace(/^Notify:\s*/, ''));
1230
+ } else if (/^Consulting /.test(t)) {
1231
+ color = 'text-slate-500';
1232
+ icon = '<span class="text-slate-600">&#8230;</span>';
1233
+ } else if (/→ working$/.test(t)) {
1234
+ color = 'text-blue-400';
1235
+ icon = '<span class="text-blue-500">&#9679;</span>';
1236
+ } else if (/→ idle$/.test(t)) {
1237
+ color = 'text-slate-500';
1238
+ icon = '<span class="text-slate-600">&#9675;</span>';
1239
+ } else if (/^Completed$/.test(t)) {
1240
+ color = 'text-emerald-400';
1241
+ icon = '<span class="text-emerald-500">&#10003;</span>';
1242
+ } else if (/^Stopped$/.test(t)) {
1243
+ color = 'text-slate-500';
1244
+ icon = '<span class="text-slate-600">&#9632;</span>';
1245
+ } else if (/^Paused/.test(t)) {
1246
+ color = 'text-amber-400';
1247
+ icon = '<span class="text-amber-500">&#9646;&#9646;</span>';
1248
+ }
1249
+
1250
+ line.className = 'flex gap-3 py-1 items-start';
1251
+ line.innerHTML = `<span class="text-slate-600 flex-shrink-0 tabular-nums">${time}</span><span class="w-4 flex-shrink-0 text-center">${icon}</span><span class="${color} leading-relaxed">${content}</span>`;
1152
1252
  body.appendChild(line);
1153
1253
  body.scrollTop = body.scrollHeight;
1154
1254
  }
@@ -1,3 +1,5 @@
1
+ import { esc, miniMarkdown } from './utils.js';
2
+
1
3
  const ICONS = {
2
4
  info: '<path stroke-linecap="round" stroke-linejoin="round" d="M11.25 11.25l.041-.02a.75.75 0 011.063.852l-.708 2.836a.75.75 0 001.063.853l.041-.021M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9-3.75h.008v.008H12V8.25z"/>',
3
5
  success: '<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>',
@@ -19,18 +21,6 @@ function getContainer() {
19
21
  return c;
20
22
  }
21
23
 
22
- /**
23
- * Minimal markdown: **bold**, `code`, - bullet lists, line breaks.
24
- */
25
- function miniMarkdown(text) {
26
- return esc(text)
27
- .replace(/\*\*(.+?)\*\*/g, '<strong class="text-slate-200 font-semibold">$1</strong>')
28
- .replace(/`(.+?)`/g, '<code class="px-1 py-0.5 rounded bg-slate-700/60 text-slate-300 text-[11px]">$1</code>')
29
- .replace(/^[-•]\s+(.+)$/gm, '<li class="ml-3">$1</li>')
30
- .replace(/(<li.*<\/li>\n?)+/g, '<ul class="list-disc pl-2 space-y-0.5">$&</ul>')
31
- .replace(/\n/g, '<br>');
32
- }
33
-
34
24
  /**
35
25
  * @param {string} message — plain text, markdown (if markdown option), or raw HTML (if html option)
36
26
  * @param {{ type?: string, duration?: number, id?: string, html?: boolean, markdown?: boolean, title?: string }} opts
@@ -74,8 +64,3 @@ export function showToast(message, opts = {}) {
74
64
  return { dismiss };
75
65
  }
76
66
 
77
- function esc(s) {
78
- const d = document.createElement('div');
79
- d.textContent = s;
80
- return d.innerHTML;
81
- }
@@ -8,6 +8,15 @@ export function esc(s) {
8
8
  return s.replace(/[&<>"']/g, c => ({ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;' }[c]));
9
9
  }
10
10
 
11
+ export function miniMarkdown(text) {
12
+ return esc(text)
13
+ .replace(/\*\*(.+?)\*\*/g, '<strong class="text-slate-200 font-semibold">$1</strong>')
14
+ .replace(/`(.+?)`/g, '<code class="px-1 py-0.5 rounded bg-slate-700/60 text-slate-300 text-[11px]">$1</code>')
15
+ .replace(/^[-•]\s+(.+)$/gm, '<li class="ml-3">$1</li>')
16
+ .replace(/(<li.*<\/li>\n?)+/g, '<ul class="list-disc pl-2 space-y-0.5">$&</ul>')
17
+ .replace(/\n/g, '<br>');
18
+ }
19
+
11
20
  export function debounce(fn, ms) {
12
21
  let t;
13
22
  return (...a) => { clearTimeout(t); t = setTimeout(() => fn(...a), ms); };