@monoes/monomindcli 1.10.37 → 1.10.39

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/.claude/helpers/context-persistence-hook.mjs +1 -1
  2. package/.claude/helpers/utils/telemetry.cjs +1 -0
  3. package/dist/src/browser/actions.d.ts +1 -0
  4. package/dist/src/browser/actions.d.ts.map +1 -1
  5. package/dist/src/browser/actions.js +12 -5
  6. package/dist/src/browser/actions.js.map +1 -1
  7. package/dist/src/browser/batch.d.ts +0 -6
  8. package/dist/src/browser/batch.d.ts.map +1 -1
  9. package/dist/src/browser/batch.js.map +1 -1
  10. package/dist/src/browser/browser.d.ts.map +1 -1
  11. package/dist/src/browser/browser.js +37 -20
  12. package/dist/src/browser/browser.js.map +1 -1
  13. package/dist/src/browser/cdp.d.ts +1 -0
  14. package/dist/src/browser/cdp.d.ts.map +1 -1
  15. package/dist/src/browser/cdp.js +14 -4
  16. package/dist/src/browser/cdp.js.map +1 -1
  17. package/dist/src/browser/console-log.d.ts +4 -4
  18. package/dist/src/browser/console-log.d.ts.map +1 -1
  19. package/dist/src/browser/console-log.js +49 -16
  20. package/dist/src/browser/console-log.js.map +1 -1
  21. package/dist/src/browser/dialog.d.ts +2 -1
  22. package/dist/src/browser/dialog.d.ts.map +1 -1
  23. package/dist/src/browser/dialog.js +24 -10
  24. package/dist/src/browser/dialog.js.map +1 -1
  25. package/dist/src/browser/find.d.ts +1 -1
  26. package/dist/src/browser/find.d.ts.map +1 -1
  27. package/dist/src/browser/find.js +29 -10
  28. package/dist/src/browser/find.js.map +1 -1
  29. package/dist/src/browser/har.d.ts.map +1 -1
  30. package/dist/src/browser/har.js +3 -3
  31. package/dist/src/browser/har.js.map +1 -1
  32. package/dist/src/browser/network.d.ts +2 -0
  33. package/dist/src/browser/network.d.ts.map +1 -1
  34. package/dist/src/browser/network.js +64 -23
  35. package/dist/src/browser/network.js.map +1 -1
  36. package/dist/src/browser/profiler.d.ts.map +1 -1
  37. package/dist/src/browser/profiler.js +25 -15
  38. package/dist/src/browser/profiler.js.map +1 -1
  39. package/dist/src/browser/record.d.ts.map +1 -1
  40. package/dist/src/browser/record.js +7 -3
  41. package/dist/src/browser/record.js.map +1 -1
  42. package/dist/src/browser/session.d.ts.map +1 -1
  43. package/dist/src/browser/session.js +11 -7
  44. package/dist/src/browser/session.js.map +1 -1
  45. package/dist/src/browser/snapshot.js.map +1 -1
  46. package/dist/src/browser/trace.d.ts.map +1 -1
  47. package/dist/src/browser/trace.js +32 -17
  48. package/dist/src/browser/trace.js.map +1 -1
  49. package/dist/src/browser/wait.js +28 -14
  50. package/dist/src/browser/wait.js.map +1 -1
  51. package/dist/src/commands/browse.d.ts.map +1 -1
  52. package/dist/src/commands/browse.js +8 -14
  53. package/dist/src/commands/browse.js.map +1 -1
  54. package/dist/src/ui/dashboard-v2.html +563 -5
  55. package/dist/src/ui/server.mjs +140 -2
  56. package/dist/tsconfig.tsbuildinfo +1 -1
  57. package/package.json +1 -1
@@ -389,6 +389,69 @@ html, body { height: 100%; background: var(--bg); color: var(--text-hi); font-fa
389
389
  /* ── forecast row ────────────────────────────────────────── */
390
390
  .m-val.forecast { color:var(--text-lo); font-size:11px; font-weight:500; }
391
391
 
392
+ /* ── session date groups ─────────────────────────────────── */
393
+ .sg-section { margin-bottom:4px; }
394
+ .sg-header { display:flex; align-items:center; gap:6px; padding:5px 2px 3px; cursor:pointer; user-select:none; }
395
+ .sg-title { font-size:10px; letter-spacing:0.07em; text-transform:uppercase; color:var(--text-xs); font-weight:600; }
396
+ .sg-count { font-size:10px; color:var(--text-lo); background:var(--surface-hi); border-radius:8px; padding:1px 6px; }
397
+ .sg-toggle { font-size:10px; color:var(--text-xs); margin-left:auto; }
398
+ .sg-header:hover .sg-title { color:var(--text-lo); }
399
+ .sg-body.collapsed { display:none; }
400
+
401
+ /* ── session heatmap calendar ─────────────────────────────── */
402
+ #sess-heatmap { margin-bottom:14px; }
403
+ .shm-grid { display:grid; grid-template-rows:repeat(7,10px); grid-auto-flow:column; grid-auto-columns:10px; gap:2px; margin-top:6px; }
404
+ .shm-cell { border-radius:2px; background:var(--surface-hi); cursor:pointer; transition:outline 0.1s; }
405
+ .shm-cell:hover { outline:1px solid var(--accent); outline-offset:-1px; }
406
+ .shm-cell.shm-1 { background:oklch(72% 0.18 75 / 0.22); }
407
+ .shm-cell.shm-2 { background:oklch(72% 0.18 75 / 0.42); }
408
+ .shm-cell.shm-3 { background:oklch(72% 0.18 75 / 0.65); }
409
+ .shm-cell.shm-4 { background:oklch(72% 0.18 75 / 0.85); }
410
+ .shm-cell.shm-active { outline:2px solid var(--accent); outline-offset:-1px; }
411
+ .shm-label { font-size:10px; color:var(--text-xs); display:flex; align-items:center; justify-content:space-between; }
412
+ #shm-clear { font-size:10px; color:var(--accent); background:none; border:none; cursor:pointer; padding:0; display:none; }
413
+ #shm-clear.show { display:inline; }
414
+
415
+ /* ── cost period toggle ───────────────────────────────────── */
416
+ .period-toggles { display:flex; gap:4px; margin-bottom:10px; flex-wrap:wrap; }
417
+ .period-btn { font-size:11px; padding:3px 10px; border-radius:8px; border:1px solid var(--border); background:transparent; color:var(--text-lo); cursor:pointer; font-family:var(--sans); transition:color 0.1s, background 0.1s; }
418
+ .period-btn:hover { color:var(--text-hi); }
419
+ .period-btn.active { background:var(--accent-dim); color:var(--accent); border-color:oklch(72% 0.18 75 / 0.3); }
420
+
421
+ /* ── bulk session actions ─────────────────────────────────── */
422
+ .sess-row.bulk-sel { background:oklch(72% 0.18 75 / 0.08); outline:1px solid oklch(72% 0.18 75 / 0.3); outline-offset:-1px; }
423
+ #bulk-toolbar { display:none; position:sticky; top:0; z-index:10; background:oklch(18% 0.009 55); border:1px solid oklch(72% 0.18 75 / 0.3); border-radius:var(--r); padding:8px 14px; margin-bottom:10px; gap:10px; align-items:center; }
424
+ #bulk-toolbar.show { display:flex; }
425
+ .bulk-count { font-size:12px; color:var(--text-mid); flex:1; }
426
+ .bulk-btn { font-size:11px; padding:4px 12px; border-radius:6px; border:1px solid var(--border); background:var(--surface); color:var(--text-mid); cursor:pointer; font-family:var(--sans); transition:color 0.1s; }
427
+ .bulk-btn:hover { color:var(--text-hi); }
428
+ .bulk-btn.danger { color:oklch(65% 0.15 25); border-color:oklch(65% 0.15 25 / 0.3); }
429
+
430
+ /* ── files touched ────────────────────────────────────────── */
431
+ .sr-files { font-size:10px; color:var(--text-xs); margin-top:2px; display:flex; flex-wrap:wrap; gap:3px; }
432
+ .sr-file-chip { font-family:var(--mono); background:var(--surface-hi); border-radius:3px; padding:1px 5px; color:var(--text-lo); }
433
+
434
+ /* ── memory age bars ──────────────────────────────────────── */
435
+ .dr-age-bar { height:2px; border-radius:1px; margin-top:4px; background:oklch(72% 0.18 75 / 0.25); }
436
+ .dr-age-bar-fill { height:100%; border-radius:1px; background:var(--accent); }
437
+
438
+ /* ── loop creation form ───────────────────────────────────── */
439
+ #loop-create-form { background:var(--surface); border:1px solid var(--border); border-radius:var(--r); padding:14px 16px; margin-bottom:16px; }
440
+ .lcf-title { font-size:12px; font-weight:600; color:var(--text-hi); margin-bottom:10px; }
441
+ .lcf-row { display:flex; flex-direction:column; gap:3px; margin-bottom:10px; }
442
+ .lcf-label { font-size:10px; color:var(--text-xs); text-transform:uppercase; letter-spacing:0.06em; }
443
+ .lcf-input, .lcf-textarea { background:var(--bg); border:1px solid var(--border); border-radius:4px; color:var(--text-hi); font-family:var(--sans); font-size:12px; padding:6px 10px; transition:border-color 0.15s; }
444
+ .lcf-textarea { resize:vertical; min-height:56px; }
445
+ .lcf-input:focus, .lcf-textarea:focus { outline:none; border-color:oklch(72% 0.18 75 / 0.5); }
446
+ .lcf-row-inline { display:flex; gap:8px; }
447
+ .lcf-row-inline .lcf-row { flex:1; margin-bottom:0; }
448
+ .lcf-actions { display:flex; justify-content:flex-end; gap:8px; margin-top:4px; }
449
+ .lcf-submit { background:var(--accent); color:oklch(11% 0.009 55); border:none; border-radius:6px; font-size:12px; font-weight:600; padding:5px 16px; cursor:pointer; font-family:var(--sans); }
450
+ .lcf-submit:hover { background:oklch(78% 0.18 75); }
451
+ .lcf-cancel { background:transparent; border:1px solid var(--border); border-radius:6px; font-size:12px; color:var(--text-lo); padding:5px 12px; cursor:pointer; font-family:var(--sans); }
452
+ #btn-new-loop { font-size:11px; color:var(--text-lo); background:transparent; border:1px solid var(--border); border-radius:8px; padding:3px 10px; cursor:pointer; transition:color 0.1s; font-family:var(--sans); margin-bottom:12px; }
453
+ #btn-new-loop:hover { color:var(--accent); border-color:oklch(72% 0.18 75 / 0.4); }
454
+
392
455
  /* ── auto-tags ───────────────────────────────────────────── */
393
456
  .sr-autotag { font-size:10px; padding:1px 6px; border-radius:8px; background:oklch(72% 0.18 75 / 0.1); color:oklch(78% 0.18 75); border:1px solid oklch(72% 0.18 75 / 0.2); }
394
457
  .tag-filter-bar { display:flex; flex-wrap:wrap; gap:5px; margin-bottom:12px; }
@@ -436,6 +499,7 @@ html, body { height: 100%; background: var(--bg); color: var(--text-hi); font-fa
436
499
  #app.ambient #feed-recap,
437
500
  #app.ambient #feed-timeline,
438
501
  #app.ambient #digest-card { display:none !important; }
502
+ #app.ambient #weekly-card { display:none !important; }
439
503
  #app.ambient #main { background:var(--bg); }
440
504
  #app.ambient #view-now { height:100vh; }
441
505
  #app.ambient #feed-pane { border:none; }
@@ -597,6 +661,59 @@ textarea.sess-note-input:focus { border-color:var(--accent); }
597
661
  .bm-save:hover { background:oklch(68% 0.18 75); }
598
662
  .bm-cancel { background:transparent; border:1px solid var(--border); border-radius:var(--r); padding:7px 12px; font-size:13px; color:var(--text-lo); cursor:pointer; }
599
663
  .bm-cancel:hover { color:var(--text-hi); }
664
+
665
+ /* ── toast notifications ──────────────────────────────────── */
666
+ #toast-rack { position:fixed; bottom:20px; right:20px; z-index:9999; display:flex; flex-direction:column; gap:8px; pointer-events:none; }
667
+ .toast { display:flex; align-items:flex-start; gap:10px; padding:10px 14px; border-radius:8px; font-size:12px; max-width:300px; pointer-events:all; background:oklch(18% 0.012 55); border:1px solid var(--border); color:var(--text-mid); box-shadow:0 4px 24px rgba(0,0,0,0.4); animation:toast-in 0.25s var(--ease-out); }
668
+ .toast.t-warn { border-color:oklch(72% 0.18 75 / 0.4); background:oklch(18% 0.015 75); }
669
+ .toast.t-err { border-color:oklch(60% 0.18 25 / 0.4); background:oklch(17% 0.015 25); }
670
+ .toast.t-ok { border-color:oklch(65% 0.15 150 / 0.4); background:oklch(17% 0.012 150); }
671
+ .toast-ico { flex-shrink:0; font-size:14px; }
672
+ .toast-body { flex:1; min-width:0; }
673
+ .toast-title { font-weight:600; color:var(--text-hi); margin-bottom:2px; }
674
+ .toast-msg { color:var(--text-lo); line-height:1.4; }
675
+ .toast-close { background:none; border:none; color:var(--text-xs); cursor:pointer; font-size:12px; line-height:1; padding:0; flex-shrink:0; }
676
+ .toast-close:hover { color:var(--text-lo); }
677
+ @keyframes toast-in { from { opacity:0; transform:translateY(8px); } to { opacity:1; transform:translateY(0); } }
678
+
679
+ /* ── token velocity sparkline ─────────────────────────────── */
680
+ #vel-chart { display:flex; align-items:flex-end; gap:2px; height:28px; margin-top:6px; }
681
+ .vel-bar { flex:1; border-radius:2px 2px 0 0; min-height:2px; background:oklch(65% 0.18 200 / 0.55); }
682
+ .vel-bar.vel-hi { background:oklch(65% 0.18 150 / 0.8); }
683
+ .vel-bar.vel-lo { background:oklch(65% 0.08 200 / 0.3); }
684
+
685
+ /* ── shortcut help modal ──────────────────────────────────── */
686
+ #shortcut-modal { display:none; position:fixed; inset:0; z-index:500; background:oklch(0% 0 0 / 0.6); align-items:center; justify-content:center; }
687
+ #shortcut-modal.open { display:flex; }
688
+ #shortcut-box { background:var(--surface); border:1px solid var(--border); border-radius:12px; padding:24px 28px; max-width:460px; width:90%; max-height:80vh; overflow-y:auto; }
689
+ .sk-title { font-size:14px; font-weight:600; color:var(--text-hi); margin-bottom:16px; display:flex; justify-content:space-between; align-items:center; }
690
+ .sk-close { background:none; border:none; color:var(--text-xs); cursor:pointer; font-size:16px; padding:0; }
691
+ .sk-close:hover { color:var(--text-lo); }
692
+ .sk-section { font-size:10px; letter-spacing:0.08em; text-transform:uppercase; color:var(--text-xs); margin:14px 0 6px; }
693
+ .sk-row { display:flex; justify-content:space-between; align-items:center; padding:5px 0; border-bottom:1px solid oklch(25% 0.008 55 / 0.4); }
694
+ .sk-row:last-child { border-bottom:none; }
695
+ .sk-keys { display:flex; gap:3px; }
696
+ .sk-keys kbd { font-size:10px; background:var(--surface-hi); border:1px solid var(--border); border-radius:3px; padding:1px 6px; font-family:var(--mono); color:var(--text-lo); }
697
+ .sk-desc { font-size:11px; color:var(--text-mid); }
698
+
699
+ /* ── session filter ───────────────────────────────────────── */
700
+ #sess-filter-wrap { display:flex; align-items:center; gap:8px; margin-bottom:10px; }
701
+ #sess-filter-input { flex:1; background:var(--surface-hi); border:1px solid var(--border); border-radius:var(--r); padding:5px 10px; font-size:12px; color:var(--text-hi); outline:none; font-family:var(--sans); }
702
+ #sess-filter-input:focus { border-color:oklch(72% 0.18 75 / 0.5); }
703
+ #sess-filter-input::placeholder { color:var(--text-xs); }
704
+ #sess-filter-count { font-size:11px; color:var(--text-xs); white-space:nowrap; min-width:60px; text-align:right; }
705
+
706
+ /* ── topbar activity chip ─────────────────────────────────── */
707
+ #topbar-activity { font-size:10px; color:var(--text-xs); font-family:var(--mono); max-width:180px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; opacity:0; transition:opacity 0.3s; }
708
+ #topbar-activity.loaded { opacity:1; }
709
+
710
+ /* ── anomaly badge on session rows ────────────────────────── */
711
+ .sess-anomaly { display:inline-flex; align-items:center; font-size:10px; padding:1px 5px; border-radius:5px; font-weight:600; margin-left:4px; vertical-align:middle; }
712
+ .sess-anomaly.anom-cost { background:oklch(60% 0.18 25 / 0.12); color:oklch(70% 0.18 25); }
713
+ .sess-anomaly.anom-err { background:oklch(65% 0.15 300 / 0.12); color:oklch(72% 0.15 300); }
714
+
715
+ /* ── streak badge ─────────────────────────────────────────── */
716
+ .streak-chip { display:inline-flex; align-items:center; gap:3px; font-size:11px; padding:2px 7px; border-radius:8px; background:oklch(65% 0.18 75 / 0.12); color:oklch(78% 0.18 75); white-space:nowrap; }
600
717
  </style>
601
718
  </head>
602
719
  <body>
@@ -654,9 +771,11 @@ textarea.sess-note-input:focus { border-color:var(--accent); }
654
771
  <span id="view-title">Now</span>
655
772
  <span class="pill"><span class="live-dot"></span> live</span>
656
773
  <span id="topbar-cost"></span>
774
+ <span id="topbar-activity"></span>
657
775
  <div id="tb-right">
658
776
  <button class="btn" id="btn-budget" onclick="openBudgetModal()" title="Set daily/monthly cost budget">⚑ Budget</button>
659
777
  <button class="btn" onclick="openCmdPalette()">⌕ Search <kbd style="font-size:10px;opacity:0.6;margin-left:3px">⌘K</kbd></button>
778
+ <button class="btn" onclick="openShortcutHelp()" title="Keyboard shortcuts (?)">? Help</button>
660
779
  <button class="btn" onclick="refreshCurrent()">↺ Refresh</button>
661
780
  </div>
662
781
  </div>
@@ -792,6 +911,10 @@ textarea.sess-note-input:focus { border-color:var(--accent); }
792
911
  <div class="m-group-title">Activity Heatmap</div>
793
912
  <div class="loading-txt">—</div>
794
913
  </div>
914
+ <div id="m-velocity">
915
+ <div class="m-group-title">Token Velocity</div>
916
+ <div class="loading-txt">—</div>
917
+ </div>
795
918
  </div>
796
919
  </div>
797
920
 
@@ -816,6 +939,10 @@ textarea.sess-note-input:focus { border-color:var(--accent); }
816
939
  <button class="lb-toggle" id="btn-leaderboard" onclick="toggleLeaderboard()" title="Cost leaderboard">⬆ Leaderboard</button>
817
940
  <button class="lb-toggle" id="btn-model-mix" onclick="toggleModelMix()" title="Model usage breakdown">⬡ Models</button>
818
941
  <button class="lb-toggle" id="btn-tool-errors" onclick="toggleToolErrors()" title="Tool error rate">⚠ Errors</button>
942
+ <button class="lb-toggle" id="btn-tool-rank" onclick="toggleToolRank()" title="Most-used tools across sessions">⟳ Tools</button>
943
+ <button class="lb-toggle" id="btn-proj-costs" onclick="toggleProjCosts()" title="Cost breakdown by project">$ Projects</button>
944
+ <button class="lb-toggle" id="btn-export-csv" onclick="exportSessionsCSV()" title="Export sessions as CSV">⬇ CSV</button>
945
+ <button class="lb-toggle" id="btn-patterns" onclick="togglePatterns()" title="Prompt word frequency across sessions">⊞ Patterns</button>
819
946
  <button class="diff-toggle" id="btn-diff" onclick="toggleDiffMode()" title="Compare two sessions">⇄ Compare</button>
820
947
  </div>
821
948
  <div class="pg-sub" id="sess-pg-sub">Recent Claude Code sessions for this project</div>
@@ -835,6 +962,36 @@ textarea.sess-note-input:focus { border-color:var(--accent); }
835
962
  <div id="tool-errors-panel" style="display:none;margin-bottom:16px">
836
963
  <div id="tool-errors-body"></div>
837
964
  </div>
965
+ <div id="tool-rank-panel" style="display:none;margin-bottom:16px">
966
+ <div id="tool-rank-body"></div>
967
+ </div>
968
+ <div id="proj-costs-panel" style="display:none;margin-bottom:16px">
969
+ <div id="proj-costs-body"></div>
970
+ </div>
971
+ <div id="patterns-panel" style="display:none;margin-bottom:16px">
972
+ <div id="patterns-body"></div>
973
+ </div>
974
+ <div id="sess-heatmap" style="margin-bottom:14px;display:none">
975
+ <div class="shm-label"><span>12-week activity</span><button id="shm-clear" onclick="clearHeatmapFilter()">✕ Clear filter</button></div>
976
+ <div class="shm-grid" id="shm-grid"></div>
977
+ </div>
978
+ <div class="period-toggles" id="period-toggles">
979
+ <span style="font-size:10px;color:var(--text-xs);align-self:center;text-transform:uppercase;letter-spacing:0.06em">Period:</span>
980
+ <button class="period-btn active" data-period="day" onclick="setPeriod('day')">Day</button>
981
+ <button class="period-btn" data-period="week" onclick="setPeriod('week')">Week</button>
982
+ <button class="period-btn" data-period="month" onclick="setPeriod('month')">Month</button>
983
+ <button class="period-btn" data-period="all" onclick="setPeriod('all')">All</button>
984
+ </div>
985
+ <div id="bulk-toolbar">
986
+ <span class="bulk-count" id="bulk-count">0 selected</span>
987
+ <button class="bulk-btn" onclick="bulkExport()">⬇ Export</button>
988
+ <button class="bulk-btn" onclick="bulkBookmark()">☆ Bookmark all</button>
989
+ <button class="bulk-btn danger" onclick="clearBulkSelection()">✕ Clear</button>
990
+ </div>
991
+ <div id="sess-filter-wrap">
992
+ <input id="sess-filter-input" type="text" placeholder="Filter sessions by prompt…" oninput="filterSessions(this.value)" autocomplete="off">
993
+ <span id="sess-filter-count"></span>
994
+ </div>
838
995
  <div id="sess-content" class="sess-list"><div class="loading-txt">Loading…</div></div>
839
996
  </div>
840
997
  </div>
@@ -844,6 +1001,32 @@ textarea.sess-note-input:focus { border-color:var(--accent); }
844
1001
  <div class="vscroll">
845
1002
  <div class="pg-title">Loops</div>
846
1003
  <div class="pg-sub">Scheduled automation loops</div>
1004
+ <button id="btn-new-loop" onclick="showLoopForm()">+ New Loop</button>
1005
+ <div id="loop-create-form" style="display:none">
1006
+ <div class="lcf-title">Create Loop</div>
1007
+ <div class="lcf-row">
1008
+ <label class="lcf-label">Prompt</label>
1009
+ <textarea class="lcf-textarea" id="lcf-prompt" placeholder="What should the agent do each iteration?"></textarea>
1010
+ </div>
1011
+ <div class="lcf-row">
1012
+ <label class="lcf-label">Name (optional)</label>
1013
+ <input class="lcf-input" id="lcf-name" type="text" placeholder="My loop">
1014
+ </div>
1015
+ <div class="lcf-row-inline">
1016
+ <div class="lcf-row">
1017
+ <label class="lcf-label">Interval</label>
1018
+ <input class="lcf-input" id="lcf-interval" type="text" placeholder="1h" value="1h">
1019
+ </div>
1020
+ <div class="lcf-row">
1021
+ <label class="lcf-label">Max reps (blank = ∞)</label>
1022
+ <input class="lcf-input" id="lcf-maxreps" type="number" placeholder="∞" min="1">
1023
+ </div>
1024
+ </div>
1025
+ <div class="lcf-actions">
1026
+ <button class="lcf-cancel" onclick="hideLoopForm()">Cancel</button>
1027
+ <button class="lcf-submit" onclick="createLoop()">Create Loop</button>
1028
+ </div>
1029
+ </div>
847
1030
  <div id="loops-content" class="loop-list"><div class="loading-txt">Loading…</div></div>
848
1031
  </div>
849
1032
  </div>
@@ -885,6 +1068,25 @@ textarea.sess-note-input:focus { border-color:var(--accent); }
885
1068
  </div><!-- /main -->
886
1069
  <div id="app-ambient-hint">Press A to exit ambient mode</div>
887
1070
  </div><!-- /app -->
1071
+ <div id="toast-rack"></div>
1072
+
1073
+ <!-- shortcut help modal -->
1074
+ <div id="shortcut-modal" onclick="if(event.target===this)closeShortcutHelp()">
1075
+ <div id="shortcut-box">
1076
+ <div class="sk-title">Keyboard Shortcuts <button class="sk-close" onclick="closeShortcutHelp()">✕</button></div>
1077
+ <div class="sk-section">Feed (Now view)</div>
1078
+ <div class="sk-row"><span class="sk-desc">Navigate entries</span><span class="sk-keys"><kbd>J</kbd><kbd>K</kbd></span></div>
1079
+ <div class="sk-row"><span class="sk-desc">Open detail drawer</span><span class="sk-keys"><kbd>↵</kbd></span></div>
1080
+ <div class="sk-row"><span class="sk-desc">Search in feed</span><span class="sk-keys"><kbd>/</kbd></span></div>
1081
+ <div class="sk-row"><span class="sk-desc">Jump to live session</span><span class="sk-keys"><kbd>G</kbd></span></div>
1082
+ <div class="sk-row"><span class="sk-desc">Refresh current view</span><span class="sk-keys"><kbd>R</kbd></span></div>
1083
+ <div class="sk-row"><span class="sk-desc">Toggle ambient mode</span><span class="sk-keys"><kbd>A</kbd></span></div>
1084
+ <div class="sk-section">Global</div>
1085
+ <div class="sk-row"><span class="sk-desc">Command palette</span><span class="sk-keys"><kbd>⌘</kbd><kbd>K</kbd></span></div>
1086
+ <div class="sk-row"><span class="sk-desc">Close / dismiss</span><span class="sk-keys"><kbd>Esc</kbd></span></div>
1087
+ <div class="sk-row"><span class="sk-desc">This help</span><span class="sk-keys"><kbd>?</kbd></span></div>
1088
+ </div>
1089
+ </div>
888
1090
 
889
1091
  <!-- budget modal (fixed overlay, outside app) -->
890
1092
  <div id="budget-modal" onclick="if(event.target===this)closeBudgetModal()">
@@ -975,9 +1177,19 @@ async function init() {
975
1177
  document.getElementById('sb-path').textContent = DIR;
976
1178
  document.getElementById('sb-proj').textContent = DIR.split('/').filter(Boolean).pop() || '—';
977
1179
  } catch (_) {}
1180
+ // deep-link: ?sess=<id>&proj=<path>
1181
+ const params = new URLSearchParams(window.location.search);
1182
+ const projParam = params.get('proj');
1183
+ const sessParam = params.get('sess');
1184
+ if (projParam) {
1185
+ DIR = projParam;
1186
+ document.getElementById('sb-proj').textContent = DIR.split('/').filter(Boolean).pop() || '—';
1187
+ document.getElementById('sb-path').textContent = DIR;
1188
+ }
978
1189
  viewRendered['now'] = true; // prevents switchView from re-rendering NOW on jumpToSession
979
1190
  updateBudgetBtnStyle();
980
1191
  await refreshNow();
1192
+ if (sessParam) setTimeout(() => jumpToSession(sessParam), 300);
981
1193
  startPolling();
982
1194
  }
983
1195
 
@@ -1197,6 +1409,8 @@ function renderFeedEvents(events, silent) {
1197
1409
  updateBurnRate(filtered);
1198
1410
  // session recap card
1199
1411
  buildRecap(filtered, allSessions[sessionIdx]);
1412
+ // infer current activity from most recent tool
1413
+ updateCurrentActivity(visible);
1200
1414
  }
1201
1415
 
1202
1416
  function renderGroupRow(g) {
@@ -1568,6 +1782,9 @@ async function renderSessions() {
1568
1782
  el.innerHTML = '<div class="empty"><div class="empty-ico">☆</div><div>No bookmarked sessions</div></div>';
1569
1783
  return;
1570
1784
  }
1785
+ // compute median cost for anomaly detection
1786
+ const costsForMedian = sessions.map(s => s.totalCost || 0).filter(c => c > 0).sort((a, b) => a - b);
1787
+ const medianCost = costsForMedian.length ? costsForMedian[Math.floor(costsForMedian.length / 2)] : 0;
1571
1788
  const sessData = JSON.stringify(toShow).replace(/'/g, '&#39;');
1572
1789
  el.innerHTML = toShow.map((s, idx) => {
1573
1790
  const dur = s.totalDurationMs ? fmtDur(s.totalDurationMs) : '';
@@ -1575,6 +1792,14 @@ async function renderSessions() {
1575
1792
  const cost = typeof s.totalCost === 'number' ? '$' + s.totalCost.toFixed(2)
1576
1793
  : typeof s.cost === 'number' ? '$' + s.cost.toFixed(2) : '';
1577
1794
  const meta = [dur, msgs, cost].filter(Boolean).join(' · ') || s.id.slice(0, 16);
1795
+ // anomaly badge
1796
+ const sCost = s.totalCost || 0;
1797
+ let anomBadge = '';
1798
+ if (medianCost > 0.05 && sCost > medianCost * 3 && sCost > 0.5) {
1799
+ anomBadge = `<span class="sess-anomaly anom-cost" title="Cost ${((sCost/medianCost).toFixed(1))}× above median">! costly</span>`;
1800
+ } else if (s.toolCalls > 0 && (s.errorCount || 0) / s.toolCalls > 0.3) {
1801
+ anomBadge = `<span class="sess-anomaly anom-err" title="${s.errorCount} tool errors">! errors</span>`;
1802
+ }
1578
1803
  const satPct = Math.min(100, Math.round((s.totalMessages || 0) / 200 * 100));
1579
1804
  const satColor = satPct > 80 ? 'oklch(65% 0.2 25)' : satPct > 50 ? 'oklch(70% 0.18 80)' : 'var(--accent)';
1580
1805
  const satBar = satPct > 0 ? `<div class="ctx-sat-wrap" title="Context saturation ~${satPct}% (${s.totalMessages||0} turns / 200 est. max)">
@@ -1593,7 +1818,7 @@ async function renderSessions() {
1593
1818
  <button class="sess-star${isStarred ? ' on' : ''}" data-sid="${esc(s.id)}" onclick="toggleBookmark('${esc(s.id)}',event)" title="${isStarred ? 'Remove bookmark' : 'Bookmark session'}">${isStarred ? '★' : '☆'}</button>
1594
1819
  <span class="sr-view">→ view</span>
1595
1820
  </div>
1596
- <div class="sr-meta">${esc(meta)}</div>
1821
+ <div class="sr-meta">${esc(meta)}${anomBadge}</div>
1597
1822
  ${(summaries || autoTags) ? `<div class="sr-tags">${summaries}${autoTags}</div>` : ''}
1598
1823
  ${satBar}
1599
1824
  <div class="sess-notes-wrap" onclick="event.stopPropagation()">
@@ -1613,7 +1838,24 @@ async function renderSessions() {
1613
1838
  buildWeeklyRecap();
1614
1839
  buildEfficiencyPanel();
1615
1840
  buildActivityHeatmap();
1841
+ buildTokenVelocity();
1616
1842
  if (leaderboardOpen) renderLeaderboard();
1843
+ // check budget thresholds and fire toasts
1844
+ const todayCost = allSessions.filter(s => {
1845
+ const t = s.firstTs || s.mtime;
1846
+ if (!t) return false;
1847
+ const d = new Date(typeof t === 'number' ? t : t);
1848
+ const now = new Date();
1849
+ return d.getFullYear() === now.getFullYear() && d.getMonth() === now.getMonth() && d.getDate() === now.getDate();
1850
+ }).reduce((a, s) => a + (s.totalCost || 0), 0);
1851
+ const monthCost = allSessions.filter(s => {
1852
+ const t = s.firstTs || s.mtime;
1853
+ if (!t) return false;
1854
+ const d = new Date(typeof t === 'number' ? t : t);
1855
+ const now = new Date();
1856
+ return d.getFullYear() === now.getFullYear() && d.getMonth() === now.getMonth();
1857
+ }).reduce((a, s) => a + (s.totalCost || 0), 0);
1858
+ checkBudgetToast(todayCost, monthCost);
1617
1859
  } catch (err) {
1618
1860
  el.innerHTML = '<div class="empty">Could not load sessions: ' + esc(err.message) + '</div>';
1619
1861
  }
@@ -1621,6 +1863,7 @@ async function renderSessions() {
1621
1863
 
1622
1864
  function jumpToSession(id) {
1623
1865
  switchView('now');
1866
+ history.replaceState(null, '', '?sess=' + encodeURIComponent(id) + '&proj=' + encodeURIComponent(DIR));
1624
1867
  setTimeout(() => {
1625
1868
  const i = allSessions.findIndex(x => x.id === id);
1626
1869
  if (i >= 0) { sessionIdx = i; userScrolled = false; loadFeedForSession(allSessions[i]); }
@@ -2004,8 +2247,21 @@ function buildDigest() {
2004
2247
  ...themes.map(t => `#${t}`),
2005
2248
  ].filter(Boolean);
2006
2249
 
2250
+ // cost forecast: project monthly spend from daily average
2251
+ const today2 = new Date();
2252
+ const daysInMonth = new Date(today2.getFullYear(), today2.getMonth() + 1, 0).getDate();
2253
+ const dayOfMonth = today2.getDate();
2254
+ const monthCostSoFar = allSessions.filter(s => {
2255
+ const t = s.firstTs || s.mtime;
2256
+ if (!t) return false;
2257
+ const d = new Date(typeof t === 'number' ? t : t);
2258
+ return d.getFullYear() === today2.getFullYear() && d.getMonth() === today2.getMonth();
2259
+ }).reduce((a, s) => a + (s.totalCost || 0), 0);
2260
+ const dailyAvg = dayOfMonth > 0 ? monthCostSoFar / dayOfMonth : 0;
2261
+ const projected = dailyAvg * daysInMonth;
2262
+ const forecastHtml = projected > 0.05 ? `<span class="digest-stat" title="Projected monthly spend at current pace" style="color:oklch(72% 0.14 200)">~$${projected.toFixed(2)}/mo</span>` : '';
2007
2263
  document.getElementById('digest-stats').innerHTML =
2008
- stats.map(s => `<span class="digest-stat">${esc(s)}</span>`).join('');
2264
+ stats.map(s => `<span class="digest-stat">${esc(s)}</span>`).join('') + forecastHtml;
2009
2265
  document.getElementById('digest-card').classList.add('show');
2010
2266
  }
2011
2267
 
@@ -2331,7 +2587,33 @@ function buildSwimlane() {
2331
2587
  </div>
2332
2588
  </div>`;
2333
2589
  }).join('');
2334
- 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>`;
2590
+ // dead time: find largest gap between consecutive sessions
2591
+ const sorted = recent.slice().sort((a, b) => {
2592
+ const aTs = typeof (a.firstTs || a.mtime) === 'number' ? (a.firstTs || a.mtime) : new Date(a.firstTs || a.mtime).getTime();
2593
+ const bTs = typeof (b.firstTs || b.mtime) === 'number' ? (b.firstTs || b.mtime) : new Date(b.firstTs || b.mtime).getTime();
2594
+ return aTs - bTs;
2595
+ });
2596
+ let maxGapMs = 0; let gapStart = 0;
2597
+ for (let i = 1; i < sorted.length; i++) {
2598
+ const prev = sorted[i - 1];
2599
+ const curr = sorted[i];
2600
+ const prevTs = typeof (prev.firstTs || prev.mtime) === 'number' ? (prev.firstTs || prev.mtime) : new Date(prev.firstTs || prev.mtime).getTime();
2601
+ const prevEnd = prevTs + (prev.totalDurationMs || 60000);
2602
+ const currTs = typeof (curr.firstTs || curr.mtime) === 'number' ? (curr.firstTs || curr.mtime) : new Date(curr.firstTs || curr.mtime).getTime();
2603
+ const gap = currTs - prevEnd;
2604
+ if (gap > maxGapMs) { maxGapMs = gap; gapStart = prevEnd; }
2605
+ }
2606
+ const MIN15 = 15 * 60000;
2607
+ let gapNote = '';
2608
+ if (maxGapMs > MIN15) {
2609
+ const gapMins = Math.round(maxGapMs / 60000);
2610
+ const when = new Date(gapStart);
2611
+ const hr = when.getHours().toString().padStart(2, '0');
2612
+ const mn = when.getMinutes().toString().padStart(2, '0');
2613
+ const label = maxGapMs > 3600000 ? `${Math.floor(maxGapMs/3600000)}h idle` : `${gapMins}m idle`;
2614
+ gapNote = `<div style="font-size:10px;color:var(--text-xs);margin-top:5px;padding:3px 6px;border-radius:4px;background:oklch(40% 0.04 55 / 0.15)">Longest gap: ${label} starting ${hr}:${mn}</div>`;
2615
+ }
2616
+ 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>${gapNote}`;
2335
2617
  }
2336
2618
 
2337
2619
  // ── feature 18: loop run history ──────────────────────────
@@ -2489,6 +2771,7 @@ function buildWeeklyRecap() {
2489
2771
  return new Date(typeof t === 'number' ? t : t).toDateString();
2490
2772
  })).size;
2491
2773
  const longestMs = Math.max(...weekSess.map(s => s.totalDurationMs||0));
2774
+ const streak = calcStreak();
2492
2775
  const stats = [
2493
2776
  `${weekSess.length} session${weekSess.length!==1?'s':''}`,
2494
2777
  `${days} day${days!==1?'s':''}`,
@@ -2496,7 +2779,8 @@ function buildWeeklyRecap() {
2496
2779
  totalTools > 0 ? `${totalTools.toLocaleString()} tool calls` : null,
2497
2780
  longestMs > 0 ? `${fmtDur(longestMs)} longest` : null,
2498
2781
  ].filter(Boolean);
2499
- document.getElementById('weekly-stats').innerHTML = stats.map(s => `<span class="digest-stat">${esc(s)}</span>`).join('');
2782
+ const streakHtml = streak >= 2 ? `<span class="streak-chip" title="${streak} consecutive days with sessions">🔥 ${streak}d streak</span>` : '';
2783
+ document.getElementById('weekly-stats').innerHTML = stats.map(s => `<span class="digest-stat">${esc(s)}</span>`).join('') + streakHtml;
2500
2784
  document.getElementById('weekly-card').classList.add('show');
2501
2785
  }
2502
2786
  function dismissWeekly() {
@@ -2997,7 +3281,8 @@ document.addEventListener('keydown', e => {
2997
3281
  if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
2998
3282
  if (document.getElementById('cmd-palette').classList.contains('open')) return;
2999
3283
 
3000
- if (e.key === 'Escape') { closeDetail(); closeCmdPalette(); if (document.getElementById('app').classList.contains('ambient')) toggleAmbient(); }
3284
+ if (e.key === 'Escape') { closeDetail(); closeCmdPalette(); closeShortcutHelp(); if (document.getElementById('app').classList.contains('ambient')) toggleAmbient(); }
3285
+ if (e.key === '?') { e.preventDefault(); openShortcutHelp(); return; }
3001
3286
  if (e.key === 'a' || e.key === 'A') { if (currentView === 'now') { e.preventDefault(); toggleAmbient(); } }
3002
3287
 
3003
3288
  if (currentView === 'now') {
@@ -3026,6 +3311,279 @@ document.addEventListener('keydown', e => {
3026
3311
  }
3027
3312
  });
3028
3313
 
3314
+ // ── feature 31: inline session filter ─────────────────────
3315
+ function filterSessions(q) {
3316
+ const rows = document.querySelectorAll('#sess-content .sess-row');
3317
+ const lq = q.toLowerCase().trim();
3318
+ let visible = 0;
3319
+ rows.forEach(row => {
3320
+ const prompt = (row.querySelector('.sr-prompt')?.textContent || '').toLowerCase();
3321
+ const meta = (row.querySelector('.sr-meta')?.textContent || '').toLowerCase();
3322
+ const tags = (row.querySelector('.sr-tags')?.textContent || '').toLowerCase();
3323
+ const match = !lq || prompt.includes(lq) || meta.includes(lq) || tags.includes(lq);
3324
+ row.style.display = match ? '' : 'none';
3325
+ if (match) visible++;
3326
+ });
3327
+ const countEl = document.getElementById('sess-filter-count');
3328
+ if (countEl) countEl.textContent = lq && rows.length ? `${visible} / ${rows.length}` : '';
3329
+ }
3330
+
3331
+ // ── feature 32: keyboard shortcut help modal ──────────────
3332
+ function openShortcutHelp() { document.getElementById('shortcut-modal').classList.add('open'); }
3333
+ function closeShortcutHelp() { document.getElementById('shortcut-modal').classList.remove('open'); }
3334
+
3335
+ // ── feature 33: "currently working on" inference ──────────
3336
+ function updateCurrentActivity(events) {
3337
+ const el = document.getElementById('topbar-activity');
3338
+ if (!el) return;
3339
+ if (!events?.length) { el.textContent = ''; el.classList.remove('loaded'); return; }
3340
+ const recent = [...events].reverse().find(ev => ev.kind === 'tool');
3341
+ if (!recent) { el.textContent = ''; el.classList.remove('loaded'); return; }
3342
+ const name = recent.name || '';
3343
+ let activity = '';
3344
+ if (['Write', 'Edit', 'Read', 'MultiEdit'].includes(name)) {
3345
+ const lbl = recent.label || '';
3346
+ const match = lbl.match(/([^/\\]+\.[a-zA-Z0-9]+)/) ;
3347
+ activity = match ? match[1] : (lbl.split('/').pop() || name);
3348
+ } else if (name === 'Bash') {
3349
+ activity = 'bash: ' + (recent.label || '').slice(0, 24);
3350
+ } else if (name === 'WebSearch' || name === 'WebFetch') {
3351
+ activity = 'web: ' + (recent.label || '').slice(0, 20);
3352
+ } else if (name) {
3353
+ activity = name;
3354
+ }
3355
+ if (activity) { el.textContent = '⤷ ' + activity; el.classList.add('loaded'); }
3356
+ else { el.textContent = ''; el.classList.remove('loaded'); }
3357
+ }
3358
+
3359
+ // ── feature 34: prompt pattern analysis ───────────────────
3360
+ const STOP_WORDS = new Set(['the','a','an','and','or','but','in','on','at','to','for','of','with','by','from','is','it','this','that','be','as','i','my','me','we','you','your','can','do','did','have','had','has','was','were','are','will','would','should','could','not','no','if','so','then','what','how','when','where','which','who','all','get','make','add','new','old','use','its','into','out','up','any','just','let','set','also','more','using','used','fix','run','file','please']);
3361
+ let patternsOpen = false;
3362
+ function togglePatterns() {
3363
+ patternsOpen = !patternsOpen;
3364
+ document.getElementById('btn-patterns').classList.toggle('on', patternsOpen);
3365
+ const p = document.getElementById('patterns-panel');
3366
+ p.style.display = patternsOpen ? '' : 'none';
3367
+ if (patternsOpen) buildPatterns();
3368
+ }
3369
+ function buildPatterns() {
3370
+ const el = document.getElementById('patterns-body');
3371
+ if (!allSessions.length) { el.innerHTML = '<div class="loading-txt">No sessions loaded</div>'; return; }
3372
+ const freq = {};
3373
+ for (const s of allSessions) {
3374
+ const words = (s.lastPrompt || '').toLowerCase().match(/\b[a-z]{4,}\b/g) || [];
3375
+ const seen = new Set();
3376
+ for (const w of words) {
3377
+ if (!STOP_WORDS.has(w) && !seen.has(w)) { freq[w] = (freq[w] || 0) + 1; seen.add(w); }
3378
+ }
3379
+ }
3380
+ const sorted = Object.entries(freq).filter(([,c]) => c >= 2).sort((a, b) => b[1] - a[1]).slice(0, 20);
3381
+ if (!sorted.length) { el.innerHTML = '<div class="loading-txt">Not enough prompt data</div>'; return; }
3382
+ const maxCount = sorted[0][1];
3383
+ const rows = sorted.map(([word, count], i) => {
3384
+ const barW = Math.round((count / maxCount) * 100);
3385
+ return `<tr><td class="lb-rank">${i + 1}</td>
3386
+ <td style="font-size:12px;color:var(--text-mid)">${esc(word)}</td>
3387
+ <td style="width:100px;padding:4px 6px"><div style="height:4px;background:var(--surface-hi);border-radius:2px;overflow:hidden">
3388
+ <div style="height:100%;width:${barW}%;background:oklch(70% 0.15 300);border-radius:2px"></div></div></td>
3389
+ <td class="lb-cost" style="color:var(--text-mid)">${count}</td></tr>`;
3390
+ }).join('');
3391
+ el.innerHTML = `<table class="lb-table"><thead><tr>
3392
+ <th class="lb-rank">#</th><th>Term</th><th></th><th class="lb-cost">Sessions</th>
3393
+ </tr></thead><tbody>${rows}</tbody></table>`;
3394
+ }
3395
+
3396
+ // ── feature 35: session streak tracker ────────────────────
3397
+ function calcStreak() {
3398
+ const dates = new Set(allSessions.map(s => {
3399
+ const t = s.firstTs || s.mtime;
3400
+ if (!t) return null;
3401
+ return new Date(typeof t === 'number' ? t : t).toDateString();
3402
+ }).filter(Boolean));
3403
+ let streak = 0;
3404
+ const today = new Date();
3405
+ for (let i = 0; i <= 365; i++) {
3406
+ const d = new Date(today);
3407
+ d.setDate(d.getDate() - i);
3408
+ if (dates.has(d.toDateString())) {
3409
+ streak++;
3410
+ } else if (i > 0) {
3411
+ break;
3412
+ }
3413
+ }
3414
+ return streak;
3415
+ }
3416
+
3417
+ // ── feature 25: notification toasts ──────────────────────
3418
+ let _toastLastBudgetKey = '';
3419
+ function showToast(title, msg, type = 'info', duration = 5000) {
3420
+ const rack = document.getElementById('toast-rack');
3421
+ if (!rack) return;
3422
+ const icoMap = { warn: '⚑', err: '⚠', ok: '✓', info: '◉' };
3423
+ const div = document.createElement('div');
3424
+ div.className = 'toast t-' + type;
3425
+ div.innerHTML = `<span class="toast-ico">${icoMap[type] || '◉'}</span>
3426
+ <div class="toast-body">
3427
+ <div class="toast-title">${esc(title)}</div>
3428
+ <div class="toast-msg">${esc(msg)}</div>
3429
+ </div>
3430
+ <button class="toast-close" onclick="this.closest('.toast').remove()">✕</button>`;
3431
+ rack.appendChild(div);
3432
+ if (duration > 0) setTimeout(() => { try { div.remove(); } catch {} }, duration);
3433
+ }
3434
+
3435
+ function checkBudgetToast(todayCost, monthCost) {
3436
+ const budget = JSON.parse(localStorage.getItem('mm-budget') || '{}');
3437
+ const daily = parseFloat(budget.daily) || 0;
3438
+ const monthly = parseFloat(budget.monthly) || 0;
3439
+ if (daily > 0 && todayCost >= daily * 0.9) {
3440
+ const pct = Math.round((todayCost / daily) * 100);
3441
+ const key = 'daily-' + pct;
3442
+ if (key !== _toastLastBudgetKey) {
3443
+ _toastLastBudgetKey = key;
3444
+ showToast('Budget alert', `Daily spend at ${pct}% ($${todayCost.toFixed(2)} of $${daily})`, todayCost >= daily ? 'err' : 'warn');
3445
+ }
3446
+ }
3447
+ if (monthly > 0 && monthCost >= monthly * 0.9) {
3448
+ const pct = Math.round((monthCost / monthly) * 100);
3449
+ const key = 'monthly-' + pct;
3450
+ if (key !== _toastLastBudgetKey) {
3451
+ _toastLastBudgetKey = key;
3452
+ showToast('Monthly budget', `Monthly spend at ${pct}% ($${monthCost.toFixed(2)} of $${monthly})`, monthCost >= monthly ? 'err' : 'warn');
3453
+ }
3454
+ }
3455
+ }
3456
+
3457
+ // ── feature 26: token velocity sparkline ──────────────────
3458
+ function buildTokenVelocity() {
3459
+ const el = document.getElementById('m-velocity');
3460
+ if (!el || !allSessions.length) return;
3461
+ const now = Date.now();
3462
+ const HOUR = 3600000;
3463
+ const buckets = new Array(24).fill(0);
3464
+ for (const s of allSessions) {
3465
+ const t = s.firstTs || s.mtime;
3466
+ if (!t) continue;
3467
+ const ts = typeof t === 'number' ? t : new Date(t).getTime();
3468
+ const hoursAgo = (now - ts) / HOUR;
3469
+ if (hoursAgo < 0 || hoursAgo >= 24) continue;
3470
+ const bucket = Math.min(23, Math.floor(23 - hoursAgo));
3471
+ buckets[bucket] += s.totalInputTokens || 0;
3472
+ }
3473
+ const maxTok = Math.max(1, ...buckets);
3474
+ const totalTok = buckets.reduce((a, b) => a + b, 0);
3475
+ const fmt = n => n > 1e6 ? (n/1e6).toFixed(1)+'M' : n > 1e3 ? (n/1e3).toFixed(0)+'k' : String(n);
3476
+ const bars = buckets.map((v, i) => {
3477
+ const h = Math.max(2, Math.round((v / maxTok) * 28));
3478
+ const cls = v > maxTok * 0.66 ? 'vel-hi' : v < maxTok * 0.15 ? 'vel-lo' : '';
3479
+ const hrsAgo = 23 - i;
3480
+ return `<div class="vel-bar ${cls}" style="height:${h}px" title="${fmt(v)} tokens — ${hrsAgo}h ago"></div>`;
3481
+ }).join('');
3482
+ const totalCost = allSessions.reduce((a, s) => a + (s.totalCost || 0), 0);
3483
+ el.innerHTML = `<div class="m-group-title">Token Velocity <span style="font-size:10px;color:var(--text-xs);font-weight:400">24h · ${fmt(totalTok)}</span></div>
3484
+ <div id="vel-chart">${bars}</div>
3485
+ <div style="font-size:10px;color:var(--text-xs);margin-top:4px">Total cost <span style="color:oklch(78% 0.18 75)">$${totalCost.toFixed(2)}</span></div>`;
3486
+ }
3487
+
3488
+ // ── feature 27: export sessions CSV ───────────────────────
3489
+ function exportSessionsCSV() {
3490
+ if (!allSessions.length) { showToast('No data', 'No sessions loaded yet', 'warn'); return; }
3491
+ const headers = ['Date', 'Session ID', 'Prompt', 'Cost ($)', 'Duration (s)', 'Tool Calls', 'User Messages', 'Cache Hit %', 'Input Tokens'];
3492
+ const rows = allSessions.map(s => {
3493
+ const dt = new Date(s.firstTs || s.mtime || 0).toISOString().slice(0, 19).replace('T', ' ');
3494
+ const cost = typeof s.totalCost === 'number' ? s.totalCost.toFixed(4) : '';
3495
+ const dur = s.totalDurationMs ? Math.round(s.totalDurationMs / 1000) : '';
3496
+ const cachePct = s.totalInputTokens > 0 ? Math.round((s.cacheReadTokens || 0) / s.totalInputTokens * 100) : '';
3497
+ const prompt = (s.lastPrompt || '').replace(/"/g, '""');
3498
+ return [dt, s.id, prompt, cost, dur, s.toolCalls || '', s.userMessages || '', cachePct, s.totalInputTokens || ''];
3499
+ });
3500
+ const csv = [headers, ...rows].map(r => r.map(c => `"${c}"`).join(',')).join('\n');
3501
+ const blob = new Blob([csv], { type: 'text/csv' });
3502
+ const a = document.createElement('a');
3503
+ a.href = URL.createObjectURL(blob);
3504
+ a.download = `sessions-${new Date().toISOString().slice(0, 10)}.csv`;
3505
+ a.click();
3506
+ URL.revokeObjectURL(a.href);
3507
+ showToast('Exported', `${allSessions.length} sessions saved as CSV`, 'ok');
3508
+ }
3509
+
3510
+ // ── feature 28: tool usage ranking ────────────────────────
3511
+ let toolRankOpen = false;
3512
+ function toggleToolRank() {
3513
+ toolRankOpen = !toolRankOpen;
3514
+ document.getElementById('btn-tool-rank').classList.toggle('on', toolRankOpen);
3515
+ const p = document.getElementById('tool-rank-panel');
3516
+ p.style.display = toolRankOpen ? '' : 'none';
3517
+ if (toolRankOpen) loadToolRank();
3518
+ }
3519
+ async function loadToolRank() {
3520
+ const el = document.getElementById('tool-rank-body');
3521
+ el.innerHTML = '<div class="loading-txt">Loading…</div>';
3522
+ try {
3523
+ const data = await apiFetch('/api/tool-ranking?dir=' + enc(DIR));
3524
+ if (!data.tools?.length) { el.innerHTML = '<div class="loading-txt">No tool usage data</div>'; return; }
3525
+ const maxCount = data.tools[0].count;
3526
+ const rows = data.tools.slice(0, 15).map((t, i) => {
3527
+ const barW = Math.round((t.count / maxCount) * 100);
3528
+ const errRate = t.errors > 0 ? ((t.errors / t.count) * 100).toFixed(0) + '%' : '—';
3529
+ return `<tr><td class="lb-rank">${i + 1}</td>
3530
+ <td style="font-size:12px;color:var(--text-mid);max-width:180px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(t.tool)}</td>
3531
+ <td style="width:80px;padding:4px 6px">
3532
+ <div style="height:4px;background:var(--surface-hi);border-radius:2px;overflow:hidden">
3533
+ <div style="height:100%;width:${barW}%;background:oklch(65% 0.15 200);border-radius:2px"></div>
3534
+ </div>
3535
+ </td>
3536
+ <td class="lb-cost" style="color:var(--text-mid)">${t.count.toLocaleString()}</td>
3537
+ <td class="lb-dur" style="color:${t.errors > 0 ? 'oklch(70% 0.18 25)' : 'var(--text-xs)'}">${errRate}</td>
3538
+ </tr>`;
3539
+ }).join('');
3540
+ el.innerHTML = `<table class="lb-table"><thead><tr>
3541
+ <th class="lb-rank">#</th><th>Tool</th><th></th><th class="lb-cost">Calls</th><th class="lb-dur">Error%</th>
3542
+ </tr></thead><tbody>${rows}</tbody></table>`;
3543
+ } catch (err) {
3544
+ el.innerHTML = '<div class="loading-txt">Could not load: ' + esc(err.message) + '</div>';
3545
+ }
3546
+ }
3547
+
3548
+ // ── feature 29: cost breakdown by project ─────────────────
3549
+ let projCostsOpen = false;
3550
+ function toggleProjCosts() {
3551
+ projCostsOpen = !projCostsOpen;
3552
+ document.getElementById('btn-proj-costs').classList.toggle('on', projCostsOpen);
3553
+ const p = document.getElementById('proj-costs-panel');
3554
+ p.style.display = projCostsOpen ? '' : 'none';
3555
+ if (projCostsOpen) loadProjCosts();
3556
+ }
3557
+ async function loadProjCosts() {
3558
+ const el = document.getElementById('proj-costs-body');
3559
+ el.innerHTML = '<div class="loading-txt">Loading…</div>';
3560
+ try {
3561
+ const data = await apiFetch('/api/project-costs');
3562
+ if (!data.projects?.length) { el.innerHTML = '<div class="loading-txt">No cost data across projects</div>'; return; }
3563
+ const maxCost = data.projects[0].cost;
3564
+ const rows = data.projects.slice(0, 10).map((p, i) => {
3565
+ const barW = maxCost > 0 ? Math.round((p.cost / maxCost) * 100) : 0;
3566
+ const name = p.path.split('/').filter(Boolean).pop() || p.path;
3567
+ return `<tr onclick="switchProject('${esc(p.path)}')" style="cursor:pointer" title="${esc(p.path)}">
3568
+ <td class="lb-rank">${i + 1}</td>
3569
+ <td style="font-size:12px;color:var(--text-mid);max-width:140px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(name)}</td>
3570
+ <td class="lb-cost">$${p.cost.toFixed(2)}</td>
3571
+ <td style="width:80px;padding:4px 6px">
3572
+ <div style="height:4px;background:var(--surface-hi);border-radius:2px;overflow:hidden">
3573
+ <div style="height:100%;width:${barW}%;background:oklch(72% 0.18 75 / 0.7);border-radius:2px"></div>
3574
+ </div>
3575
+ </td>
3576
+ <td class="lb-dur">${p.sessions}</td>
3577
+ </tr>`;
3578
+ }).join('');
3579
+ el.innerHTML = `<table class="lb-table"><thead><tr>
3580
+ <th class="lb-rank">#</th><th>Project</th><th class="lb-cost">Cost</th><th></th><th class="lb-dur">Sessions</th>
3581
+ </tr></thead><tbody>${rows}</tbody></table>`;
3582
+ } catch (err) {
3583
+ el.innerHTML = '<div class="loading-txt">Could not load: ' + esc(err.message) + '</div>';
3584
+ }
3585
+ }
3586
+
3029
3587
  // ── helpers ────────────────────────────────────────────────
3030
3588
  function enc(s) { return encodeURIComponent(s); }
3031
3589
  function esc(s) {