@monoes/monomindcli 1.10.32 → 1.10.34

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.
@@ -74,7 +74,7 @@ html, body { height: 100%; background: var(--bg); color: var(--text-hi); font-fa
74
74
  #feed-sess-nav { margin-left: auto; display: flex; gap: 6px; align-items: center; }
75
75
  .sess-btn { font-size: 11px; color: var(--text-lo); background: var(--surface); border: 1px solid var(--border); border-radius: 4px; padding: 2px 8px; cursor: pointer; transition: color 0.1s; line-height: 1.4; }
76
76
  .sess-btn:hover { color: var(--text-hi); }
77
- #feed-scroll { flex: 1; overflow-y: auto; }
77
+ #feed-scroll { flex: 1; overflow-y: auto; min-width: 0; }
78
78
  #feed-scroll::-webkit-scrollbar { width: 3px; }
79
79
  #feed-scroll::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
80
80
 
@@ -425,6 +425,133 @@ html, body { height: 100%; background: var(--bg); color: var(--text-hi); font-fa
425
425
  .ph-mid { background:oklch(72% 0.18 75 / 0.15); color:oklch(78% 0.18 75); }
426
426
  .ph-lo { background:oklch(60% 0.18 25 / 0.12); color:oklch(70% 0.18 25); }
427
427
 
428
+ /* ── ambient mode ────────────────────────────────────────── */
429
+ #app.ambient #sidebar,
430
+ #app.ambient #topbar,
431
+ #app.ambient #alerts-rail,
432
+ #app.ambient #feed-head,
433
+ #app.ambient #feed-time-filter,
434
+ #app.ambient #metrics-pane,
435
+ #app.ambient #replay-bar,
436
+ #app.ambient #feed-recap,
437
+ #app.ambient #feed-timeline,
438
+ #app.ambient #digest-card { display:none !important; }
439
+ #app.ambient #main { background:var(--bg); }
440
+ #app.ambient #view-now { height:100vh; }
441
+ #app.ambient #feed-pane { border:none; }
442
+ #app.ambient .feed-entry { padding: 6px 22px; }
443
+ #app.ambient .feed-lbl { font-size:14px; }
444
+ #app-ambient-hint { display:none; position:fixed; bottom:16px; right:16px; font-size:11px; color:var(--text-xs); z-index:300; pointer-events:none; }
445
+ #app.ambient #app-ambient-hint { display:block; }
446
+
447
+ /* ── minimap scrubber ─────────────────────────────────────── */
448
+ #feed-minimap { position:absolute; top:0; right:0; width:8px; height:100%; z-index:10; cursor:pointer; }
449
+ #feed-minimap-track { position:absolute; inset:0; }
450
+ .mm-pip { position:absolute; right:1px; width:6px; border-radius:3px; min-height:3px; opacity:0.55; transition:opacity 0.1s; }
451
+ .mm-pip:hover { opacity:1; }
452
+ .mm-pip.mp-file { background:oklch(60% 0.12 220); }
453
+ .mm-pip.mp-bash { background:oklch(72% 0.18 75); }
454
+ .mm-pip.mp-agent { background:oklch(70% 0.15 300); }
455
+ .mm-pip.mp-mcp { background:oklch(65% 0.15 200); }
456
+ .mm-pip.mp-err { background:var(--red); opacity:0.8; }
457
+ .mm-pip.mp-user { background:var(--text-xs); }
458
+ .mm-pip.mp-other { background:var(--surface-hi); }
459
+ #mm-thumb { position:absolute; right:0; width:8px; background:oklch(100% 0 0 / 0.08); border-radius:4px; pointer-events:none; transition:top 0.05s; }
460
+ #feed-scroll-wrap { position:relative; flex:1; overflow:hidden; display:flex; }
461
+ #feed-scroll { flex:1; }
462
+
463
+ /* ── daily digest ─────────────────────────────────────────── */
464
+ #digest-card { display:none; flex-shrink:0; border-bottom:1px solid var(--border); background:oklch(13.5% 0.009 55); padding:10px 18px; }
465
+ #digest-card.show { display:block; }
466
+ .digest-row { display:flex; align-items:center; gap:6px; flex-wrap:wrap; }
467
+ .digest-title { font-size:11px; font-weight:600; letter-spacing:0.07em; text-transform:uppercase; color:var(--text-lo); margin-bottom:6px; display:flex; align-items:center; gap:8px; }
468
+ .digest-close { margin-left:auto; background:none; border:none; color:var(--text-xs); cursor:pointer; font-size:13px; line-height:1; padding:0; }
469
+ .digest-close:hover { color:var(--text-lo); }
470
+ .digest-stat { display:inline-flex; align-items:center; gap:4px; font-size:11px; padding:2px 8px; border-radius:8px; background:var(--surface-hi); color:var(--text-mid); white-space:nowrap; }
471
+
472
+ /* ── cost leaderboard ────────────────────────────────────── */
473
+ .lb-toggle { font-size:11px; color:var(--text-lo); background:transparent; border:1px solid transparent; border-radius:8px; padding:2px 9px; cursor:pointer; transition:color 0.1s, background 0.1s; font-family:var(--sans); }
474
+ .lb-toggle:hover { color:var(--text-hi); }
475
+ .lb-toggle.on { background:oklch(72% 0.18 75 / 0.1); color:var(--accent); border-color:oklch(72% 0.18 75 / 0.3); }
476
+ .lb-table { width:100%; border-collapse:collapse; margin-top:4px; }
477
+ .lb-table th { font-size:10px; letter-spacing:0.07em; text-transform:uppercase; color:var(--text-xs); padding:4px 6px; text-align:left; border-bottom:1px solid var(--border); }
478
+ .lb-table td { font-size:12px; padding:7px 6px; border-bottom:1px solid oklch(25% 0.008 55 / 0.5); color:var(--text-mid); vertical-align:top; cursor:pointer; }
479
+ .lb-table tr:hover td { background:var(--surface-hi); color:var(--text-hi); }
480
+ .lb-rank { font-family:var(--mono); color:var(--text-xs); width:22px; }
481
+ .lb-cost { font-family:var(--mono); color:oklch(78% 0.18 75); white-space:nowrap; }
482
+ .lb-dur { font-family:var(--mono); color:var(--text-lo); white-space:nowrap; }
483
+ .lb-prompt { max-width:260px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
484
+
485
+ /* ── session diff ─────────────────────────────────────────── */
486
+ .diff-toggle { font-size:11px; color:var(--text-lo); background:transparent; border:1px solid transparent; border-radius:8px; padding:2px 9px; cursor:pointer; transition:color 0.1s, background 0.1s; font-family:var(--sans); }
487
+ .diff-toggle:hover { color:var(--text-hi); }
488
+ .diff-toggle.on { background:oklch(60% 0.12 220 / 0.1); color:oklch(65% 0.12 220); border-color:oklch(60% 0.12 220 / 0.3); }
489
+ #diff-panel { display:none; border:1px solid var(--border); border-radius:8px; margin-bottom:16px; overflow:hidden; }
490
+ #diff-panel.show { display:block; }
491
+ .diff-header { display:flex; align-items:center; gap:10px; padding:8px 12px; border-bottom:1px solid var(--border); background:oklch(14% 0.009 55); }
492
+ .diff-title { font-size:11px; font-weight:600; letter-spacing:0.07em; text-transform:uppercase; color:var(--text-lo); flex:1; }
493
+ .diff-clear { background:none; border:none; color:var(--text-xs); cursor:pointer; font-size:12px; }
494
+ .diff-cols { display:grid; grid-template-columns:1fr 1fr; }
495
+ .diff-col { padding:10px 14px; }
496
+ .diff-col + .diff-col { border-left:1px solid var(--border); }
497
+ .diff-col-title { font-size:11px; font-weight:600; color:var(--text-mid); margin-bottom:8px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
498
+ .diff-row { display:flex; justify-content:space-between; gap:8px; padding:3px 0; border-bottom:1px solid oklch(25% 0.008 55 / 0.4); }
499
+ .diff-row:last-child { border-bottom:none; }
500
+ .diff-k { font-size:11px; color:var(--text-xs); }
501
+ .diff-v { font-size:11px; font-family:var(--mono); color:var(--text-mid); }
502
+ .diff-v.diff-hi { color:oklch(78% 0.18 75); }
503
+ .diff-v.diff-lo { color:var(--red); }
504
+ .diff-hint { font-size:11px; color:var(--text-xs); padding:8px 12px; text-align:center; }
505
+ .sess-row.diff-sel-a { background:oklch(60% 0.12 220 / 0.08); outline:1px solid oklch(60% 0.12 220 / 0.3); }
506
+ .sess-row.diff-sel-b { background:oklch(72% 0.18 75 / 0.06); outline:1px solid oklch(72% 0.18 75 / 0.3); }
507
+
508
+ /* ── burn rate gauge ─────────────────────────────────────── */
509
+ .burn-gauge-wrap { margin-top:6px; }
510
+ .burn-gauge-row { display:flex; align-items:center; gap:8px; margin-bottom:4px; }
511
+ .burn-gauge-label { font-size:10px; color:var(--text-lo); width:60px; flex-shrink:0; }
512
+ .burn-gauge-track { flex:1; height:6px; background:var(--surface-hi); border-radius:3px; overflow:hidden; }
513
+ .burn-gauge-fill { height:100%; border-radius:3px; transition:width 0.4s, background 0.4s; }
514
+ .burn-val { font-size:10px; font-family:var(--mono); color:var(--text-xs); white-space:nowrap; }
515
+ .burn-rate-ok { background:oklch(65% 0.15 150); }
516
+ .burn-rate-warn { background:oklch(72% 0.18 75); }
517
+ .burn-rate-hot { background:oklch(60% 0.18 25); }
518
+
519
+ /* ── swimlane timeline ────────────────────────────────────── */
520
+ #swimlane-wrap { padding:4px 0; }
521
+ .sw-row { display:flex; align-items:center; gap:6px; margin-bottom:3px; height:14px; }
522
+ .sw-lbl { font-size:10px; color:var(--text-xs); width:60px; flex-shrink:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; text-align:right; cursor:pointer; }
523
+ .sw-lbl:hover { color:var(--text-lo); }
524
+ .sw-track { flex:1; height:10px; background:var(--surface-hi); border-radius:3px; position:relative; overflow:hidden; }
525
+ .sw-bar { position:absolute; top:0; height:100%; border-radius:2px; opacity:0.8; }
526
+ .sw-gap { position:absolute; top:0; height:100%; background:var(--bg); width:2px; }
527
+
528
+ /* ── focus mode ──────────────────────────────────────────── */
529
+ #feed-pane.focus-mode .feed-entry.k-tool:not(.errored) { display:none; }
530
+ #feed-pane.focus-mode .feed-group { display:none; }
531
+ .focus-btn { font-size:10px; color:var(--text-lo); background:var(--surface); border:1px solid var(--border); border-radius:4px; padding:2px 7px; cursor:pointer; transition:color 0.15s, border-color 0.15s; line-height:1.4; white-space:nowrap; }
532
+ .focus-btn:hover { color:var(--text-hi); }
533
+ .focus-btn.on { color:oklch(60% 0.12 220); border-color:oklch(60% 0.12 220 / 0.5); }
534
+
535
+ /* ── session notes ───────────────────────────────────────── */
536
+ .sess-notes-wrap { margin-top:8px; }
537
+ .sess-notes-toggle { font-size:11px; color:var(--text-xs); background:none; border:none; cursor:pointer; padding:0; display:flex; align-items:center; gap:5px; }
538
+ .sess-notes-toggle:hover { color:var(--text-lo); }
539
+ .sess-notes-toggle.has-note { color:oklch(72% 0.14 220); }
540
+ .sess-notes-area { display:none; margin-top:6px; }
541
+ .sess-notes-area.open { display:block; }
542
+ textarea.sess-note-input { width:100%; background:var(--surface); border:1px solid var(--border); border-radius:var(--r); color:var(--text-mid); font-size:12px; font-family:var(--sans); padding:6px 8px; resize:vertical; min-height:56px; outline:none; transition:border-color 0.1s; }
543
+ textarea.sess-note-input:focus { border-color:var(--accent); }
544
+ .sess-note-saved { font-size:10px; color:var(--text-xs); margin-top:3px; height:14px; }
545
+
546
+ /* ── export button ───────────────────────────────────────── */
547
+ .sess-copy-btn.dl { }
548
+
549
+ /* ── loop run history sparkline ──────────────────────────── */
550
+ .loop-sparkline { display:flex; gap:2px; align-items:flex-end; height:20px; margin-top:4px; }
551
+ .lsp-bar { width:5px; border-radius:2px 2px 0 0; background:var(--accent); opacity:0.6; min-height:3px; }
552
+ .lsp-bar.err { background:var(--red); opacity:0.8; }
553
+ .le-spark { display:flex; align-items:center; gap:8px; }
554
+
428
555
  /* ── budget cap ──────────────────────────────────────────── */
429
556
  #budget-modal { display:none; position:fixed; inset:0; z-index:200; background:oklch(5% 0 0 / 0.6); align-items:center; justify-content:center; }
430
557
  #budget-modal.open { display:flex; }
@@ -533,6 +660,8 @@ html, body { height: 100%; background: var(--bg); color: var(--text-hi); font-fa
533
660
  <button class="live-tail-btn" id="btn-live-tail" onclick="toggleLiveTail()" title="Toggle live tail (auto-scroll + 5s refresh)">⬤ Tail</button>
534
661
  <button class="sess-copy-btn" id="btn-replay" onclick="replayToggle()" title="Replay session event-by-event">⏵ Replay</button>
535
662
  <button class="sess-copy-btn" id="btn-copy-sess" onclick="copySession()" title="Copy session as markdown">⎘ Copy</button>
663
+ <button class="sess-copy-btn" id="btn-export-sess" onclick="exportSession()" title="Download session as .md file">⬇ Export</button>
664
+ <button class="focus-btn" id="btn-focus" onclick="toggleFocusMode()" title="Focus mode: show only user messages + errors">⊡ Focus</button>
536
665
  <button class="density-btn" id="btn-density" onclick="toggleDensity()" title="Toggle compact view">⊟</button>
537
666
  <button class="sess-btn" onclick="toggleFeedSearch()" title="Search in feed (/)">⌕</button>
538
667
  <button class="sess-btn" id="btn-prev-sess" onclick="prevSession()" title="Older session">‹</button>
@@ -566,10 +695,20 @@ html, body { height: 100%; background: var(--bg); color: var(--text-hi); font-fa
566
695
  <button class="tf-btn" data-tf="1h" onclick="setFeedTimeFilter('1h')">1h</button>
567
696
  <button class="tf-btn" data-tf="6h" onclick="setFeedTimeFilter('6h')">6h</button>
568
697
  <button class="tf-btn" data-tf="24h" onclick="setFeedTimeFilter('24h')">24h</button>
569
- <span class="kb-hint"><kbd>J</kbd><kbd>K</kbd> navigate &nbsp;<kbd>↵</kbd> detail &nbsp;<kbd>/</kbd> find &nbsp;<kbd>G</kbd> live &nbsp;<kbd>⌘K</kbd> search</span>
698
+ <span class="kb-hint"><kbd>J</kbd><kbd>K</kbd> navigate &nbsp;<kbd>↵</kbd> detail &nbsp;<kbd>/</kbd> find &nbsp;<kbd>G</kbd> live &nbsp;<kbd>A</kbd> ambient &nbsp;<kbd>⌘K</kbd> search</span>
699
+ </div>
700
+ <div id="digest-card">
701
+ <div class="digest-title">Today's Digest <button class="digest-close" onclick="dismissDigest()" title="Dismiss">✕</button></div>
702
+ <div class="digest-row" id="digest-stats"></div>
570
703
  </div>
571
- <div id="feed-scroll">
572
- <div id="feed-content"><div class="loading-txt">Loading activity…</div></div>
704
+ <div id="feed-scroll-wrap">
705
+ <div id="feed-scroll">
706
+ <div id="feed-content"><div class="loading-txt">Loading activity…</div></div>
707
+ </div>
708
+ <div id="feed-minimap" title="Click to jump · events map">
709
+ <div id="feed-minimap-track"></div>
710
+ <div id="mm-thumb"></div>
711
+ </div>
573
712
  </div>
574
713
  </div>
575
714
 
@@ -599,6 +738,14 @@ html, body { height: 100%; background: var(--bg); color: var(--text-hi); font-fa
599
738
  <div class="m-group-title">Tool Usage</div>
600
739
  <div class="loading-txt">—</div>
601
740
  </div>
741
+ <div id="m-burn">
742
+ <div class="m-group-title">Burn Rate</div>
743
+ <div class="loading-txt">—</div>
744
+ </div>
745
+ <div id="m-swimlane">
746
+ <div class="m-group-title">Session Lanes</div>
747
+ <div class="loading-txt">—</div>
748
+ </div>
602
749
  </div>
603
750
  </div>
604
751
 
@@ -617,11 +764,23 @@ html, body { height: 100%; background: var(--bg); color: var(--text-hi); font-fa
617
764
  <!-- SESSIONS -->
618
765
  <div class="view" id="view-sessions">
619
766
  <div class="vscroll">
620
- <div style="display:flex;align-items:baseline;gap:10px;margin-bottom:4px">
767
+ <div style="display:flex;align-items:center;gap:8px;margin-bottom:4px;flex-wrap:wrap">
621
768
  <div class="pg-title" style="margin-bottom:0">Sessions</div>
622
769
  <button id="sess-star-filter" onclick="toggleSessStarFilter()" title="Show only bookmarked sessions">☆ Starred</button>
770
+ <button class="lb-toggle" id="btn-leaderboard" onclick="toggleLeaderboard()" title="Cost leaderboard">⬆ Leaderboard</button>
771
+ <button class="diff-toggle" id="btn-diff" onclick="toggleDiffMode()" title="Compare two sessions">⇄ Compare</button>
623
772
  </div>
624
773
  <div class="pg-sub" id="sess-pg-sub">Recent Claude Code sessions for this project</div>
774
+ <div id="diff-panel">
775
+ <div class="diff-header"><span class="diff-title">Session Comparison</span><button class="diff-clear" onclick="clearDiff()" title="Clear">✕</button></div>
776
+ <div class="diff-hint" id="diff-hint">Click two sessions below to compare them</div>
777
+ <div class="diff-cols" id="diff-cols" style="display:none"></div>
778
+ </div>
779
+ <div id="lb-panel" style="display:none;margin-bottom:16px">
780
+ <table class="lb-table"><thead><tr>
781
+ <th class="lb-rank">#</th><th>Session</th><th class="lb-cost">Cost</th><th class="lb-dur">Duration</th>
782
+ </tr></thead><tbody id="lb-body"></tbody></table>
783
+ </div>
625
784
  <div id="sess-content" class="sess-list"><div class="loading-txt">Loading…</div></div>
626
785
  </div>
627
786
  </div>
@@ -670,6 +829,7 @@ html, body { height: 100%; background: var(--bg); color: var(--text-hi); font-fa
670
829
 
671
830
  </div><!-- /view-wrap -->
672
831
  </div><!-- /main -->
832
+ <div id="app-ambient-hint">Press A to exit ambient mode</div>
673
833
  </div><!-- /app -->
674
834
 
675
835
  <!-- budget modal (fixed overlay, outside app) -->
@@ -978,7 +1138,9 @@ function renderFeedEvents(events, silent) {
978
1138
 
979
1139
  // update timeline + breakdown with all original events (before time-filter)
980
1140
  buildTimeline(filtered);
981
- buildBreakdown(filtered);
1141
+ buildBreakdownByName(filtered);
1142
+ buildMinimap(filtered);
1143
+ updateBurnRate(filtered);
982
1144
  // session recap card
983
1145
  buildRecap(filtered, allSessions[sessionIdx]);
984
1146
  }
@@ -1280,6 +1442,7 @@ function renderMiniSessions(sessions) {
1280
1442
  <div class="ms-meta">${relTime(s.lastTs || s.mtime)}</div>
1281
1443
  </div>`).join('');
1282
1444
  document.getElementById('m-sessions').innerHTML = `<div class="m-group-title">Recent Sessions</div>${items}`;
1445
+ buildSwimlane();
1283
1446
  }
1284
1447
 
1285
1448
  // ── projects ───────────────────────────────────────────────
@@ -1287,8 +1450,8 @@ async function renderProjects() {
1287
1450
  const el = document.getElementById('proj-content');
1288
1451
  el.innerHTML = '<div class="loading-txt">Loading…</div>';
1289
1452
  try {
1290
- const data = await apiFetch('/api/data?dir=' + enc(ORIGINAL_DIR));
1291
- allProjects = data?.allProjects || [];
1453
+ const data = await apiFetch('/api/projects');
1454
+ allProjects = data?.projects || [];
1292
1455
  document.getElementById('bdg-projects').textContent = allProjects.length || '—';
1293
1456
  document.getElementById('proj-pg-sub').textContent =
1294
1457
  allProjects.length + ' project' + (allProjects.length !== 1 ? 's' : '') + ' found';
@@ -1351,7 +1514,8 @@ async function renderSessions() {
1351
1514
  el.innerHTML = '<div class="empty"><div class="empty-ico">☆</div><div>No bookmarked sessions</div></div>';
1352
1515
  return;
1353
1516
  }
1354
- el.innerHTML = toShow.map(s => {
1517
+ const sessData = JSON.stringify(toShow).replace(/'/g, '&#39;');
1518
+ el.innerHTML = toShow.map((s, idx) => {
1355
1519
  const dur = s.totalDurationMs ? fmtDur(s.totalDurationMs) : '';
1356
1520
  const msgs = s.totalMessages ? s.totalMessages + ' msg' : '';
1357
1521
  const cost = typeof s.totalCost === 'number' ? '$' + s.totalCost.toFixed(2)
@@ -1360,7 +1524,10 @@ async function renderSessions() {
1360
1524
  const summaries = (s.summaries || []).slice(0, 2).map(sm => { const t = typeof sm === 'string' ? sm : (sm.summary || sm.text || String(sm)); return `<span class="sr-tag">${esc(t.slice(0, 40))}</span>`; }).join('');
1361
1525
  const autoTags = (allTags.sessionTags.get(s.id) || []).map(t => `<span class="sr-autotag">${esc(t)}</span>`).join('');
1362
1526
  const isStarred = bookmarks.has(s.id);
1363
- return `<div class="sess-row" onclick="jumpToSession('${esc(s.id)}')">
1527
+ const sData = JSON.stringify(s).replace(/'/g, '&#39;');
1528
+ const note = getSessNote(s.id);
1529
+ const hasNote = !!note;
1530
+ return `<div class="sess-row" data-sess-idx="${idx}" onclick="diffMode ? diffSelectSession(JSON.parse(this.dataset.sessData || '{}'), this) : jumpToSession('${esc(s.id)}')" data-sess-data='${sData}'>
1364
1531
  <div class="sr-top">
1365
1532
  <div class="sr-prompt">${esc(s.lastPrompt || s.id)}</div>
1366
1533
  <div class="sr-time">${relTime(s.lastTs || s.mtime)}</div>
@@ -1369,12 +1536,21 @@ async function renderSessions() {
1369
1536
  </div>
1370
1537
  <div class="sr-meta">${esc(meta)}</div>
1371
1538
  ${(summaries || autoTags) ? `<div class="sr-tags">${summaries}${autoTags}</div>` : ''}
1539
+ <div class="sess-notes-wrap" onclick="event.stopPropagation()">
1540
+ <button class="sess-notes-toggle${hasNote ? ' has-note' : ''}" onclick="toggleSessNote('${esc(s.id)}',this)">✎ ${hasNote ? 'Note' : 'Add note'}</button>
1541
+ <div class="sess-notes-area" id="snote-${esc(s.id)}">
1542
+ <textarea class="sess-note-input" rows="2" placeholder="Session note…" oninput="saveSessNote('${esc(s.id)}',this.value,this.closest('.sess-notes-wrap').querySelector('.sess-notes-toggle'),this.closest('.sess-row').querySelector('.sess-note-saved'))">${esc(note)}</textarea>
1543
+ <div class="sess-note-saved"></div>
1544
+ </div>
1545
+ </div>
1372
1546
  </div>`;
1373
1547
  }).join('');
1374
1548
  // prepend tag filter bar if there are common tags
1375
1549
  if (allTags.common.size > 1) {
1376
1550
  el.innerHTML = buildTagFilterBar(toShow) + el.innerHTML;
1377
1551
  }
1552
+ buildDigest();
1553
+ if (leaderboardOpen) renderLeaderboard();
1378
1554
  } catch (err) {
1379
1555
  el.innerHTML = '<div class="empty">Could not load sessions: ' + esc(err.message) + '</div>';
1380
1556
  }
@@ -1492,9 +1668,9 @@ async function renderGlobalFeed() {
1492
1668
  const el = document.getElementById('gf-content');
1493
1669
  el.innerHTML = '<div class="loading-txt">Loading all projects…</div>';
1494
1670
  try {
1495
- // fetch project list using ORIGINAL_DIR
1496
- const data = await apiFetch('/api/data?dir=' + enc(ORIGINAL_DIR));
1497
- const projects = (data?.allProjects || []).slice(0, 8);
1671
+ // fetch project list
1672
+ const data = await apiFetch('/api/projects');
1673
+ const projects = (data?.projects || []).slice(0, 8);
1498
1674
  if (!projects.length) {
1499
1675
  el.innerHTML = '<div class="empty"><div class="empty-ico">⊕</div><div>No projects found</div></div>';
1500
1676
  return;
@@ -1695,6 +1871,423 @@ function healthClass(score) {
1695
1871
  return 'ph-lo';
1696
1872
  }
1697
1873
 
1874
+ // ── feature 7: tool call frequency chart (by name) ─────────
1875
+ function buildBreakdownByName(events) {
1876
+ const counts = {};
1877
+ for (const ev of events) {
1878
+ if (ev.kind !== 'tool') continue;
1879
+ const name = (ev.name || ev.cat || 'other').replace(/^mcp__.*$/, 'MCP').replace(/^m__.*$/, 'MCP');
1880
+ counts[name] = (counts[name] || 0) + 1;
1881
+ }
1882
+ const total = Object.values(counts).reduce((a, b) => a + b, 0);
1883
+ if (!total) {
1884
+ document.getElementById('m-breakdown').innerHTML =
1885
+ '<div class="m-group-title">Tool Usage</div><div class="loading-txt" style="padding:6px 0">—</div>';
1886
+ return;
1887
+ }
1888
+ const CAT_COLOR = { Bash:'oklch(72% 0.18 75)', Read:'oklch(60% 0.12 220)', Edit:'oklch(65% 0.15 160)', Write:'oklch(65% 0.15 160)', Agent:'oklch(70% 0.15 300)', Task:'oklch(65% 0.15 280)', MCP:'oklch(65% 0.15 200)', WebFetch:'oklch(60% 0.12 195)', WebSearch:'oklch(60% 0.12 195)' };
1889
+ const getColor = n => CAT_COLOR[n] || 'var(--text-xs)';
1890
+ const sorted = Object.entries(counts).sort((a, b) => b[1] - a[1]).slice(0, 8);
1891
+ const rows = sorted.map(([name, cnt]) => {
1892
+ const pct = Math.round(cnt / total * 100);
1893
+ return `<div class="tb-row">
1894
+ <div class="tb-lbl" style="width:54px" title="${esc(name)}">${esc(name.length > 8 ? name.slice(0,7)+'…' : name)}</div>
1895
+ <div class="tb-bar-wrap"><div class="tb-bar" style="width:${pct}%;background:${getColor(name)}"></div></div>
1896
+ <div class="tb-count">${cnt}</div>
1897
+ </div>`;
1898
+ }).join('');
1899
+ document.getElementById('m-breakdown').innerHTML =
1900
+ `<div class="m-group-title">Tool Usage <span style="font-size:10px;color:var(--text-xs);font-weight:400">${total} calls</span></div><div class="m-breakdown">${rows}</div>`;
1901
+ }
1902
+
1903
+ // ── feature 8: ambient mode ────────────────────────────────
1904
+ function toggleAmbient() {
1905
+ document.getElementById('app').classList.toggle('ambient');
1906
+ }
1907
+
1908
+ // ── feature 9: daily digest ────────────────────────────────
1909
+ const DIGEST_DISMISSED_KEY = 'mm-digest-dismissed';
1910
+
1911
+ function buildDigest() {
1912
+ const todayKey = new Date().toISOString().slice(0, 10);
1913
+ if (localStorage.getItem(DIGEST_DISMISSED_KEY) === todayKey) return;
1914
+ if (!allSessions.length) return;
1915
+
1916
+ const todayStart = new Date(); todayStart.setHours(0, 0, 0, 0);
1917
+ const todaySessions = allSessions.filter(s => {
1918
+ const t = s.lastTs || s.mtime;
1919
+ return t && new Date(typeof t === 'number' ? t : t).getTime() >= todayStart.getTime();
1920
+ });
1921
+ if (!todaySessions.length) return;
1922
+
1923
+ const totalCost = todaySessions.reduce((a, s) => a + (s.totalCost || 0), 0);
1924
+ const totalTools = todaySessions.reduce((a, s) => a + (s.toolCalls || 0), 0);
1925
+ const totalMsgs = todaySessions.reduce((a, s) => a + (s.userMessages || 0), 0);
1926
+ const longestMs = Math.max(...todaySessions.map(s => s.totalDurationMs || 0));
1927
+
1928
+ // top tags from today's sessions using the auto-tag system
1929
+ const tagFreq = {};
1930
+ for (const s of todaySessions) {
1931
+ for (const t of (allTags.sessionTags.get(s.id) || [])) tagFreq[t] = (tagFreq[t] || 0) + 1;
1932
+ }
1933
+ const themes = Object.entries(tagFreq).sort((a,b) => b[1]-a[1]).slice(0, 3).map(e => e[0]);
1934
+
1935
+ const stats = [
1936
+ `${todaySessions.length} session${todaySessions.length > 1 ? 's' : ''}`,
1937
+ totalCost > 0 ? `$${totalCost.toFixed(2)} spent` : null,
1938
+ totalTools > 0 ? `${totalTools} tool calls` : null,
1939
+ totalMsgs > 0 ? `${totalMsgs} messages` : null,
1940
+ longestMs > 0 ? `${fmtDur(longestMs)} longest` : null,
1941
+ ...themes.map(t => `#${t}`),
1942
+ ].filter(Boolean);
1943
+
1944
+ document.getElementById('digest-stats').innerHTML =
1945
+ stats.map(s => `<span class="digest-stat">${esc(s)}</span>`).join('');
1946
+ document.getElementById('digest-card').classList.add('show');
1947
+ }
1948
+
1949
+ function dismissDigest() {
1950
+ const todayKey = new Date().toISOString().slice(0, 10);
1951
+ localStorage.setItem(DIGEST_DISMISSED_KEY, todayKey);
1952
+ document.getElementById('digest-card').classList.remove('show');
1953
+ }
1954
+
1955
+ // ── feature 10: minimap scrubber ──────────────────────────
1956
+ function buildMinimap(events) {
1957
+ const track = document.getElementById('feed-minimap-track');
1958
+ const thumb = document.getElementById('mm-thumb');
1959
+ const scroll = document.getElementById('feed-scroll');
1960
+ if (!track || !thumb || !scroll) return;
1961
+
1962
+ // feed renders newest-first (reversed), so reverse events to match visual order
1963
+ const tools = [...events].reverse().filter(ev => ev.kind === 'tool' || ev.kind === 'user');
1964
+ if (!tools.length) { track.innerHTML = ''; return; }
1965
+
1966
+ const CAT_CLS = { file:'mp-file', bash:'mp-bash', agent:'mp-agent', mcp:'mp-mcp', user:'mp-user' };
1967
+ const H = scroll.clientHeight || 400;
1968
+ const N = tools.length;
1969
+ track.innerHTML = tools.map((ev, i) => {
1970
+ const top = Math.round((i / N) * H);
1971
+ const h = Math.max(3, Math.round((1 / N) * H * 0.7));
1972
+ const cls = ev._errored ? 'mp-err' : (ev.kind === 'user' ? 'mp-user' : (CAT_CLS[ev.cat] || 'mp-other'));
1973
+ return `<div class="mm-pip ${cls}" style="top:${top}px;height:${h}px" onclick="minimapJump(${i},${N})"></div>`;
1974
+ }).join('');
1975
+
1976
+ // sync thumb
1977
+ const updateThumb = () => {
1978
+ const ratio = scroll.scrollHeight > H ? scroll.scrollTop / (scroll.scrollHeight - H) : 0;
1979
+ const thH = Math.max(20, Math.round(H * (H / Math.max(scroll.scrollHeight, H + 1))));
1980
+ thumb.style.height = thH + 'px';
1981
+ thumb.style.top = Math.round(ratio * (H - thH)) + 'px';
1982
+ };
1983
+ scroll.removeEventListener('scroll', scroll._mmListener || (() => {}));
1984
+ scroll._mmListener = updateThumb;
1985
+ scroll.addEventListener('scroll', scroll._mmListener);
1986
+ updateThumb();
1987
+ }
1988
+
1989
+ function minimapJump(idx, total) {
1990
+ const scroll = document.getElementById('feed-scroll');
1991
+ if (!scroll) return;
1992
+ scroll.scrollTop = Math.round((idx / total) * scroll.scrollHeight);
1993
+ }
1994
+
1995
+ // ── feature 11: cost leaderboard ──────────────────────────
1996
+ let leaderboardOpen = false;
1997
+
1998
+ function toggleLeaderboard() {
1999
+ leaderboardOpen = !leaderboardOpen;
2000
+ document.getElementById('btn-leaderboard').classList.toggle('on', leaderboardOpen);
2001
+ const panel = document.getElementById('lb-panel');
2002
+ panel.style.display = leaderboardOpen ? 'block' : 'none';
2003
+ if (leaderboardOpen) renderLeaderboard();
2004
+ }
2005
+
2006
+ function renderLeaderboard() {
2007
+ const sorted = [...allSessions]
2008
+ .filter(s => typeof s.totalCost === 'number' && s.totalCost > 0)
2009
+ .sort((a, b) => b.totalCost - a.totalCost)
2010
+ .slice(0, 15);
2011
+ const body = document.getElementById('lb-body');
2012
+ if (!sorted.length) { body.innerHTML = '<tr><td colspan="4" style="text-align:center;color:var(--text-xs);padding:12px">No cost data yet</td></tr>'; return; }
2013
+ body.innerHTML = sorted.map((s, i) => {
2014
+ const cost = '$' + s.totalCost.toFixed(2);
2015
+ const dur = s.totalDurationMs ? fmtDur(s.totalDurationMs) : '—';
2016
+ const prompt = s.lastPrompt || s.id;
2017
+ return `<tr onclick="jumpToSession('${esc(s.id)}')" title="${esc(prompt)}">
2018
+ <td class="lb-rank">${i + 1}</td>
2019
+ <td class="lb-prompt">${esc(prompt.slice(0, 60))}</td>
2020
+ <td class="lb-cost">${cost}</td>
2021
+ <td class="lb-dur">${dur}</td>
2022
+ </tr>`;
2023
+ }).join('');
2024
+ }
2025
+
2026
+ // ── feature 12: session diff ──────────────────────────────
2027
+ let diffMode = false;
2028
+ let diffSelA = null; let diffSelB = null;
2029
+
2030
+ function toggleDiffMode() {
2031
+ diffMode = !diffMode;
2032
+ document.getElementById('btn-diff').classList.toggle('on', diffMode);
2033
+ const panel = document.getElementById('diff-panel');
2034
+ panel.classList.toggle('show', diffMode);
2035
+ if (!diffMode) clearDiff();
2036
+ }
2037
+
2038
+ function clearDiff() {
2039
+ diffSelA = null; diffSelB = null;
2040
+ document.getElementById('diff-hint').style.display = '';
2041
+ document.getElementById('diff-cols').style.display = 'none';
2042
+ document.getElementById('diff-cols').innerHTML = '';
2043
+ document.querySelectorAll('.sess-row.diff-sel-a, .sess-row.diff-sel-b').forEach(el => {
2044
+ el.classList.remove('diff-sel-a', 'diff-sel-b');
2045
+ });
2046
+ }
2047
+
2048
+ function diffSelectSession(sess, el) {
2049
+ if (!diffMode) return;
2050
+ if (diffSelA && diffSelA.id === sess.id) {
2051
+ diffSelA = null;
2052
+ el.classList.remove('diff-sel-a');
2053
+ document.getElementById('diff-hint').style.display = '';
2054
+ document.getElementById('diff-cols').style.display = 'none';
2055
+ return;
2056
+ }
2057
+ if (diffSelB && diffSelB.id === sess.id) {
2058
+ diffSelB = null;
2059
+ el.classList.remove('diff-sel-b');
2060
+ renderDiff();
2061
+ return;
2062
+ }
2063
+ if (!diffSelA) {
2064
+ diffSelA = sess;
2065
+ el.classList.add('diff-sel-a');
2066
+ } else if (!diffSelB) {
2067
+ diffSelB = sess;
2068
+ el.classList.add('diff-sel-b');
2069
+ } else {
2070
+ // replace B with new selection
2071
+ document.querySelectorAll('.sess-row.diff-sel-b').forEach(e => e.classList.remove('diff-sel-b'));
2072
+ diffSelB = sess;
2073
+ el.classList.add('diff-sel-b');
2074
+ }
2075
+ if (diffSelA && diffSelB) {
2076
+ renderDiff();
2077
+ } else {
2078
+ document.getElementById('diff-hint').textContent = diffSelA ? 'Now click a second session to compare' : 'Click two sessions below to compare them';
2079
+ document.getElementById('diff-hint').style.display = '';
2080
+ document.getElementById('diff-cols').style.display = 'none';
2081
+ }
2082
+ }
2083
+
2084
+ function renderDiff() {
2085
+ if (!diffSelA || !diffSelB) return;
2086
+ document.getElementById('diff-hint').style.display = 'none';
2087
+ const cols = document.getElementById('diff-cols');
2088
+ cols.style.display = '';
2089
+ const fmt = (a, b, key, prefix = '') => {
2090
+ const va = a[key], vb = b[key];
2091
+ if (va == null && vb == null) return '';
2092
+ const fa = prefix + (va != null ? va : '—');
2093
+ const fb = prefix + (vb != null ? vb : '—');
2094
+ const hiA = va != null && vb != null && va > vb ? ' diff-hi' : (va != null && vb != null && va < vb ? ' diff-lo' : '');
2095
+ const hiB = va != null && vb != null && vb > va ? ' diff-hi' : (va != null && vb != null && vb < va ? ' diff-lo' : '');
2096
+ return [fa, hiA, fb, hiB];
2097
+ };
2098
+
2099
+ const diffCol = (s) => {
2100
+ const cost = typeof s.totalCost === 'number' ? '$' + s.totalCost.toFixed(2) : '—';
2101
+ const dur = s.totalDurationMs ? fmtDur(s.totalDurationMs) : '—';
2102
+ const tools = s.toolCalls != null ? s.toolCalls : '—';
2103
+ const msgs = s.userMessages != null ? s.userMessages : '—';
2104
+ const errs = s.errors != null ? s.errors : '—';
2105
+ const prompt = (s.lastPrompt || s.id || '').slice(0, 50);
2106
+ return { cost, dur, tools, msgs, errs, prompt, s };
2107
+ };
2108
+
2109
+ const a = diffCol(diffSelA), b = diffCol(diffSelB);
2110
+ const rows = [
2111
+ ['Cost', a.cost, b.cost, typeof diffSelA.totalCost === 'number' && typeof diffSelB.totalCost === 'number' ? (diffSelA.totalCost > diffSelB.totalCost ? ['diff-hi','diff-lo'] : diffSelA.totalCost < diffSelB.totalCost ? ['diff-lo','diff-hi'] : ['','']) : ['','']],
2112
+ ['Duration', a.dur, b.dur, ['','']],
2113
+ ['Tool calls', a.tools, b.tools, typeof a.tools === 'number' && typeof b.tools === 'number' ? (a.tools > b.tools ? ['diff-hi','diff-lo'] : a.tools < b.tools ? ['diff-lo','diff-hi'] : ['','']) : ['','']],
2114
+ ['Messages', a.msgs, b.msgs, ['','']],
2115
+ ['Errors', a.errs, b.errs, ['','']],
2116
+ ];
2117
+
2118
+ const colHtml = (idx) => `<div class="diff-col">
2119
+ <div class="diff-col-title">${esc(idx === 0 ? a.prompt : b.prompt)}</div>
2120
+ ${rows.map(([k, va, vb, cls]) => `<div class="diff-row">
2121
+ <span class="diff-k">${k}</span>
2122
+ <span class="diff-v ${cls[idx]}">${esc(idx === 0 ? va : vb)}</span>
2123
+ </div>`).join('')}
2124
+ </div>`;
2125
+
2126
+ cols.innerHTML = colHtml(0) + colHtml(1);
2127
+ }
2128
+
2129
+ // ── feature 13: export session as .md file ────────────────
2130
+ async function exportSession() {
2131
+ const btn = document.getElementById('btn-export-sess');
2132
+ const sess = allSessions[sessionIdx];
2133
+ if (!sess?.file) return;
2134
+ try {
2135
+ btn.textContent = '…';
2136
+ const data = await apiFetch('/api/session?dir=' + enc(DIR) + '&file=' + enc(sess.file) + '&limit=300');
2137
+ const events = data.events || [];
2138
+ const lines = [
2139
+ `# Session: ${sess.lastPrompt || sess.id}`,
2140
+ `> ${new Date(sess.lastTs || sess.mtime).toLocaleString()}`,
2141
+ sess.totalCost != null ? `> Cost: $${sess.totalCost.toFixed(2)}` : '',
2142
+ sess.totalDurationMs ? `> Duration: ${fmtDur(sess.totalDurationMs)}` : '',
2143
+ '',
2144
+ ].filter(s => s !== null);
2145
+ for (const ev of events) {
2146
+ if (ev.kind === 'user' && ev.text?.trim()) {
2147
+ lines.push(`\n## ${ev.text.trim().slice(0, 80)}`);
2148
+ if (ev.ts) lines.push(`_${new Date(ev.ts).toLocaleTimeString()}_`);
2149
+ } else if (ev.kind === 'tool') {
2150
+ const label = ev.label || ev.name || ev.cat;
2151
+ lines.push(`- \`${ev.name || ev.cat}\`${label ? ': ' + label : ''}${ev._errored ? ' ⚠ error' : ''}`);
2152
+ }
2153
+ }
2154
+ lines.push('', `---`, `_Exported from monomind dashboard_`);
2155
+ const blob = new Blob([lines.join('\n')], { type: 'text/markdown' });
2156
+ const a = document.createElement('a');
2157
+ a.href = URL.createObjectURL(blob);
2158
+ a.download = `session-${sess.id.slice(0, 8)}.md`;
2159
+ a.click();
2160
+ URL.revokeObjectURL(a.href);
2161
+ btn.textContent = '✓ Saved';
2162
+ setTimeout(() => { btn.textContent = '⬇ Export'; }, 1800);
2163
+ } catch (err) {
2164
+ btn.textContent = '✕ Error';
2165
+ setTimeout(() => { btn.textContent = '⬇ Export'; }, 1500);
2166
+ }
2167
+ }
2168
+
2169
+ // ── feature 14: focus mode ────────────────────────────────
2170
+ let focusMode = false;
2171
+ function toggleFocusMode() {
2172
+ focusMode = !focusMode;
2173
+ document.getElementById('feed-pane').classList.toggle('focus-mode', focusMode);
2174
+ document.getElementById('btn-focus').classList.toggle('on', focusMode);
2175
+ document.getElementById('btn-focus').title = focusMode ? 'Exit focus mode' : 'Focus mode: user messages + errors only';
2176
+ }
2177
+
2178
+ // ── feature 15: session notes ─────────────────────────────
2179
+ const NOTES_KEY = 'mm-sess-notes';
2180
+ function getAllNotes() {
2181
+ try { return JSON.parse(localStorage.getItem(NOTES_KEY) || '{}'); } catch { return {}; }
2182
+ }
2183
+ function getSessNote(id) { return getAllNotes()[id] || ''; }
2184
+ function saveSessNote(id, text, toggleBtn, savedEl) {
2185
+ const all = getAllNotes();
2186
+ if (text.trim()) all[id] = text; else delete all[id];
2187
+ localStorage.setItem(NOTES_KEY, JSON.stringify(all));
2188
+ if (toggleBtn) {
2189
+ toggleBtn.classList.toggle('has-note', !!text.trim());
2190
+ toggleBtn.textContent = '✎ ' + (text.trim() ? 'Note' : 'Add note');
2191
+ }
2192
+ if (savedEl) {
2193
+ savedEl.textContent = 'Saved';
2194
+ clearTimeout(savedEl._t);
2195
+ savedEl._t = setTimeout(() => { savedEl.textContent = ''; }, 1200);
2196
+ }
2197
+ }
2198
+ function toggleSessNote(id, btn) {
2199
+ const area = document.getElementById('snote-' + id);
2200
+ if (!area) return;
2201
+ const open = area.classList.toggle('open');
2202
+ if (open) { const ta = area.querySelector('textarea'); if (ta) { ta.focus(); ta.setSelectionRange(ta.value.length, ta.value.length); } }
2203
+ }
2204
+
2205
+ // ── feature 16: burn rate gauge ───────────────────────────
2206
+ let burnRateEvents = [];
2207
+ let burnRateTimer = null;
2208
+
2209
+ function updateBurnRate(events) {
2210
+ burnRateEvents = events;
2211
+ renderBurnGauge();
2212
+ }
2213
+
2214
+ function renderBurnGauge() {
2215
+ const el = document.getElementById('m-burn');
2216
+ if (!el) return;
2217
+ const tools = burnRateEvents.filter(ev => ev.kind === 'tool' && ev.ts);
2218
+ if (tools.length < 2) {
2219
+ el.innerHTML = '<div class="m-group-title">Burn Rate</div><div class="loading-txt" style="padding:4px 0">—</div>';
2220
+ return;
2221
+ }
2222
+ const now = Date.now();
2223
+ // calls in last 5 min, 15 min, 60 min
2224
+ const t5 = tools.filter(e => now - new Date(e.ts).getTime() < 300000).length;
2225
+ const t15 = tools.filter(e => now - new Date(e.ts).getTime() < 900000).length;
2226
+ const t60 = tools.filter(e => now - new Date(e.ts).getTime() < 3600000).length;
2227
+ const rate5 = (t5 / 5).toFixed(1); // calls/min
2228
+ const rate15 = (t15 / 15).toFixed(1);
2229
+ const rate60 = (t60 / 60).toFixed(1);
2230
+ const maxRate = 20; // calls/min considered "hot"
2231
+ const pct = v => Math.min(100, Math.round((parseFloat(v) / maxRate) * 100));
2232
+ const cls = v => parseFloat(v) < 4 ? 'burn-rate-ok' : parseFloat(v) < 10 ? 'burn-rate-warn' : 'burn-rate-hot';
2233
+ el.innerHTML = `<div class="m-group-title">Burn Rate <span style="font-size:10px;color:var(--text-xs);font-weight:400">calls/min</span></div>
2234
+ <div class="burn-gauge-wrap">
2235
+ <div class="burn-gauge-row"><div class="burn-gauge-label">5 min</div><div class="burn-gauge-track"><div class="burn-gauge-fill ${cls(rate5)}" style="width:${pct(rate5)}%"></div></div><div class="burn-val">${rate5}</div></div>
2236
+ <div class="burn-gauge-row"><div class="burn-gauge-label">15 min</div><div class="burn-gauge-track"><div class="burn-gauge-fill ${cls(rate15)}" style="width:${pct(rate15)}%"></div></div><div class="burn-val">${rate15}</div></div>
2237
+ <div class="burn-gauge-row"><div class="burn-gauge-label">60 min</div><div class="burn-gauge-track"><div class="burn-gauge-fill ${cls(rate60)}" style="width:${pct(rate60)}%"></div></div><div class="burn-val">${rate60}</div></div>
2238
+ </div>`;
2239
+ }
2240
+
2241
+ // ── feature 17: session swimlane ──────────────────────────
2242
+ function buildSwimlane() {
2243
+ const el = document.getElementById('m-swimlane');
2244
+ if (!el) return;
2245
+ const recent = allSessions.slice(0, 8);
2246
+ if (!recent.length) { el.innerHTML = '<div class="m-group-title">Session Lanes</div><div class="loading-txt" style="padding:4px 0">—</div>'; return; }
2247
+ const now = Date.now();
2248
+ const DAY = 86400000;
2249
+ const windowMs = DAY; // 24-hour window
2250
+ const windowStart = now - windowMs;
2251
+ // hues spread across sessions for visual distinction
2252
+ const LANE_HUES = [75, 200, 300, 150, 25, 220, 340, 120];
2253
+ const rows = recent.map((s, si) => {
2254
+ const start = s.startTs || s.mtime || now;
2255
+ const startMs = typeof start === 'number' ? start : new Date(start).getTime();
2256
+ const dur = s.totalDurationMs || 60000;
2257
+ const endMs = startMs + dur;
2258
+ const leftPct = Math.max(0, Math.min(100, ((startMs - windowStart) / windowMs) * 100));
2259
+ const rightPct = Math.max(0, Math.min(100, ((now - endMs) / windowMs) * 100));
2260
+ const widthPct = Math.max(2, 100 - leftPct - rightPct);
2261
+ const hue = LANE_HUES[si % LANE_HUES.length];
2262
+ const color = `oklch(65% 0.14 ${hue})`;
2263
+ const name = (s.lastPrompt || s.id).slice(0, 16);
2264
+ return `<div class="sw-row">
2265
+ <div class="sw-lbl" onclick="jumpToSession('${esc(s.id)}')" title="${esc(s.lastPrompt || s.id)}">${esc(name)}</div>
2266
+ <div class="sw-track">
2267
+ <div class="sw-bar" style="left:${leftPct}%;width:${widthPct}%;background:${color}" title="${esc(name)} · ${fmtDur(dur)}"></div>
2268
+ </div>
2269
+ </div>`;
2270
+ }).join('');
2271
+ el.innerHTML = `<div class="m-group-title">Session Lanes <span style="font-size:10px;color:var(--text-xs);font-weight:400">24h</span></div><div id="swimlane-wrap">${rows}</div>`;
2272
+ }
2273
+
2274
+ // ── feature 18: loop run history ──────────────────────────
2275
+ function buildLoopSparkline(l) {
2276
+ // Synthesize a run history from runCount + startedAt if actual history unavailable
2277
+ const runHistory = l.runHistory || [];
2278
+ if (!runHistory.length && l.runCount) {
2279
+ for (let i = 0; i < Math.min(l.runCount, 10); i++) {
2280
+ runHistory.push({ ok: true });
2281
+ }
2282
+ }
2283
+ if (!runHistory.length) return '';
2284
+ const bars = runHistory.slice(-10).map(r => {
2285
+ const h = r.durationMs ? Math.max(4, Math.min(20, Math.round(r.durationMs / 2000))) : 10;
2286
+ return `<div class="lsp-bar${r.error || r.ok === false ? ' err' : ''}" style="height:${h}px" title="${r.error ? 'error' : 'ok'}${r.durationMs ? ' · ' + fmtDur(r.durationMs) : ''}"></div>`;
2287
+ }).join('');
2288
+ return `<div class="le-spark"><span style="font-size:10px;color:var(--text-xs)">last ${runHistory.slice(-10).length} runs</span><div class="loop-sparkline">${bars}</div></div>`;
2289
+ }
2290
+
1698
2291
  // ── loops ──────────────────────────────────────────────────
1699
2292
  async function renderLoops() {
1700
2293
  const el = document.getElementById('loops-content');
@@ -1730,6 +2323,7 @@ async function renderLoops() {
1730
2323
  <div class="le-row"><div class="le-lbl">Started</div><div class="le-val mono">${esc(startedAt)}</div></div>
1731
2324
  <div class="le-row"><div class="le-lbl">Last run</div><div class="le-val">${esc(lastRun)}</div></div>
1732
2325
  <div class="le-row"><div class="le-lbl">Run count</div><div class="le-val">${esc(String(runs))}</div></div>
2326
+ ${buildLoopSparkline(l)}
1733
2327
  </div>`;
1734
2328
  }).join('');
1735
2329
  } catch (err) {
@@ -1869,38 +2463,6 @@ function buildTimeline(events) {
1869
2463
  tl.innerHTML = segs.join('');
1870
2464
  }
1871
2465
 
1872
- // ── tool breakdown ──────────────────────────────────────────
1873
- const TB_COLORS = TL_COLORS;
1874
- const TB_ORDER = ['file','bash','mcp','agent','search','skill','task','mem','other'];
1875
-
1876
- function buildBreakdown(events) {
1877
- const counts = {};
1878
- for (const ev of events) {
1879
- if (ev.kind !== 'tool') continue;
1880
- const c = ev.cat || 'other';
1881
- counts[c] = (counts[c] || 0) + 1;
1882
- }
1883
- const total = Object.values(counts).reduce((a, b) => a + b, 0);
1884
- if (!total) {
1885
- document.getElementById('m-breakdown').innerHTML =
1886
- '<div class="m-group-title">Tool Usage</div><div class="loading-txt" style="padding:6px 0">—</div>';
1887
- return;
1888
- }
1889
- const rows = TB_ORDER.filter(c => counts[c])
1890
- .sort((a, b) => counts[b] - counts[a])
1891
- .map(c => {
1892
- const pct = Math.round(counts[c] / total * 100);
1893
- const color = TB_COLORS[c] || TB_COLORS.other;
1894
- return `<div class="tb-row">
1895
- <div class="tb-lbl">${c}</div>
1896
- <div class="tb-bar-wrap"><div class="tb-bar" style="width:${pct}%;background:${color}"></div></div>
1897
- <div class="tb-count">${counts[c]}</div>
1898
- </div>`;
1899
- }).join('');
1900
- document.getElementById('m-breakdown').innerHTML =
1901
- `<div class="m-group-title">Tool Usage</div><div class="m-breakdown">${rows}</div>`;
1902
- }
1903
-
1904
2466
  // ── error jump ──────────────────────────────────────────────
1905
2467
  function jumpToErrors() {
1906
2468
  // find first errored feed entry and scroll to it
@@ -2193,7 +2755,8 @@ document.addEventListener('keydown', e => {
2193
2755
  if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
2194
2756
  if (document.getElementById('cmd-palette').classList.contains('open')) return;
2195
2757
 
2196
- if (e.key === 'Escape') { closeDetail(); closeCmdPalette(); }
2758
+ if (e.key === 'Escape') { closeDetail(); closeCmdPalette(); if (document.getElementById('app').classList.contains('ambient')) toggleAmbient(); }
2759
+ if (e.key === 'a' || e.key === 'A') { if (currentView === 'now') { e.preventDefault(); toggleAmbient(); } }
2197
2760
 
2198
2761
  if (currentView === 'now') {
2199
2762
  if (e.key === '/') { e.preventDefault(); toggleFeedSearch(); }