claude-code-watch 0.0.23 → 0.0.25

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -18,6 +18,7 @@ Claude Code writes detailed JSONL logs under `~/.claude/projects/` as it works
18
18
  - **Token & cost visibility** — tracks input/output/cache tokens per agent, with context window utilization
19
19
  - **Filter controls** — toggle thinking, tool input, tool output, hook output, and text visibility independently
20
20
  - **Auto-discovery** — automatically picks up new sessions as they start (toggleable)
21
+ - **HTML export** — export the current stream as a self-contained HTML file with embedded session list, token stats, filter state, and per-session filtering
21
22
 
22
23
  ## Quick Start
23
24
 
package/README.zh-CN.md CHANGED
@@ -28,6 +28,7 @@ Claude Code 在运行时会将详细的 JSONL 日志写入 `~/.claude/projects/`
28
28
  - **Token/成本追踪** — 每个代理的输入/输出/缓存 token 及上下文窗口利用率
29
29
  - **过滤控制** — 独立切换 thinking、工具输入/输出、hook 输出、文本的可见性
30
30
  - **自动发现** — 新会话启动时自动纳入监控
31
+ - **HTML 导出** — 将当前会话流导出为自包含 HTML 文件,内嵌 session 列表、token 统计、filter 状态,并支持按 session 筛选浏览
31
32
 
32
33
  ## 致谢
33
34
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-watch",
3
- "version": "0.0.23",
3
+ "version": "0.0.25",
4
4
  "description": "Web-based real-time monitor for Claude Code.",
5
5
  "main": "./src/server/server.js",
6
6
  "bin": {
package/public/index.html CHANGED
@@ -286,6 +286,73 @@ body {
286
286
 
287
287
  /* Theme toggle button */
288
288
  #btn-theme { font-size: 14px; }
289
+
290
+ /* ── Export modal ── */
291
+ .modal-overlay {
292
+ position: fixed; inset: 0;
293
+ background: rgba(0, 0, 0, 0.6);
294
+ z-index: 10000;
295
+ display: flex; align-items: center; justify-content: center;
296
+ }
297
+ :root[data-theme="light"] .modal-overlay { background: rgba(0, 0, 0, 0.3); }
298
+ :root[data-theme="light"] .modal-session-row.selected { background: rgba(124, 58, 237, 0.2); }
299
+
300
+ .modal-box {
301
+ background: var(--bg); border: 1px solid var(--border); border-radius: 8px;
302
+ width: 480px; max-width: 90vw; max-height: 80vh;
303
+ display: flex; flex-direction: column; overflow: hidden;
304
+ }
305
+
306
+ .modal-header {
307
+ display: flex; align-items: center; justify-content: space-between;
308
+ padding: 8px 12px; border-bottom: 1px solid var(--border); background: var(--bg2); flex-shrink: 0;
309
+ }
310
+ .modal-title { font-size: 13px; font-weight: 600; color: var(--white); }
311
+
312
+ .modal-toolbar {
313
+ display: flex; align-items: center; gap: 4px;
314
+ padding: 6px 12px; border-bottom: 1px solid var(--border); flex-shrink: 0;
315
+ }
316
+ .modal-count { margin-left: auto; font-size: 11px; color: var(--dim); }
317
+
318
+ .modal-body { flex: 1; overflow-y: auto; padding: 6px 0; }
319
+
320
+ .modal-session-row {
321
+ display: flex; align-items: center; gap: 8px;
322
+ padding: 6px 12px; cursor: pointer; transition: background 0.1s; user-select: none;
323
+ }
324
+ .modal-session-row:hover { background: var(--bg2); }
325
+ .modal-session-row.selected { background: rgba(124, 58, 237, 0.15); }
326
+
327
+ .modal-checkbox {
328
+ appearance: none; width: 16px; height: 16px;
329
+ border: 1px solid var(--border); border-radius: 3px; background: var(--bg2);
330
+ cursor: pointer; position: relative; flex-shrink: 0; transition: all 0.15s;
331
+ }
332
+ .modal-checkbox:checked { background: var(--purple); border-color: var(--purple); }
333
+ .modal-checkbox:checked::after {
334
+ content: '✓'; position: absolute; inset: 0;
335
+ display: flex; align-items: center; justify-content: center;
336
+ color: var(--white); font-size: 11px; font-weight: bold;
337
+ }
338
+
339
+ .modal-session-prefix {
340
+ font-family: monospace; font-size: 12px; font-weight: 600; letter-spacing: 0.5px; flex-shrink: 0;
341
+ }
342
+ .modal-session-info {
343
+ flex: 1; min-width: 0; display: flex; align-items: baseline; gap: 4px; overflow: hidden;
344
+ }
345
+ .modal-session-project {
346
+ font-size: 12px; font-weight: 500; color: var(--text);
347
+ white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
348
+ }
349
+ .modal-session-model { font-size: 10px; color: var(--dim); flex-shrink: 0; }
350
+ .modal-session-time { font-size: 10px; color: var(--dim); flex-shrink: 0; margin-left: auto; }
351
+
352
+ .modal-footer {
353
+ display: flex; align-items: center; justify-content: flex-end; gap: 6px;
354
+ padding: 8px 12px; border-top: 1px solid var(--border); background: var(--bg2); flex-shrink: 0;
355
+ }
289
356
  </style>
290
357
  </head>
291
358
  <body>
@@ -302,6 +369,7 @@ body {
302
369
  <span class="sep">│</span>
303
370
  <span id="session-info">Connecting...</span>
304
371
  <div class="auto">
372
+ <button class="btn btn-icon" id="btn-export" onclick="openExportModal()" data-tooltip="导出 HTML">💾</button>
305
373
  <button class="btn btn-icon" id="btn-theme" onclick="toggleTheme()" data-tooltip="Toggle theme">🌙</button>
306
374
  <button class="btn on" id="btn-autodisco" onclick="toggleAutoDiscovery()" data-tooltip="Auto-discover">🔍 Auto</button>
307
375
  <span class="sep">│</span>
@@ -340,6 +408,25 @@ body {
340
408
  <span id="footer-version" style="margin-left:auto;font-size:10px;color:var(--dim)"></span>
341
409
  </div>
342
410
 
411
+ <div id="export-modal" class="modal-overlay" style="display:none">
412
+ <div class="modal-box">
413
+ <div class="modal-header">
414
+ <span class="modal-title">选择要导出的会话</span>
415
+ <button class="btn btn-icon" onclick="closeExportModal()" data-tooltip="关闭">✕</button>
416
+ </div>
417
+ <div class="modal-toolbar">
418
+ <button class="btn" onclick="exportModalToggleAll(true)">全选</button>
419
+ <button class="btn" onclick="exportModalToggleAll(false)">取消全选</button>
420
+ <span class="modal-count" id="modal-selected-count">已选 0 / 0</span>
421
+ </div>
422
+ <div class="modal-body" id="modal-session-list"></div>
423
+ <div class="modal-footer">
424
+ <button class="btn" onclick="closeExportModal()">取消</button>
425
+ <button class="btn on" id="modal-export-btn" onclick="confirmExport()" disabled>导出</button>
426
+ </div>
427
+ </div>
428
+ </div>
429
+
343
430
  <script src="vendor/highlight.min.js"></script>
344
431
  <script src="vendor/marked.min.js"></script>
345
432
  <script src="vendor/purify.min.js"></script>
@@ -447,6 +534,11 @@ let needsFullRender = true;
447
534
  let treeDirty = true;
448
535
  let lastTreeCursor = -1;
449
536
 
537
+ // Cache highlight.js CSS for HTML export
538
+ let hljsDarkCSS = '', hljsLightCSS = '';
539
+ fetch('vendor/github-dark.min.css').then(r => r.text()).then(t => { hljsDarkCSS = t; }).catch(() => {});
540
+ fetch('vendor/github-light.min.css').then(r => r.text()).then(t => { hljsLightCSS = t; }).catch(() => {});
541
+
450
542
  // ══════════════════════════════════════════════════════════════════════════════
451
543
  // Markdown renderer (marked + highlight.js)
452
544
  // ══════════════════════════════════════════════════════════════════════════════
@@ -1106,8 +1198,9 @@ function renderStream() {
1106
1198
  let html;
1107
1199
  if (lines.length > 0) {
1108
1200
  html = lines.map(l => {
1109
- if (l.html) return `<div class="${esc(l.cls)}">${l.text}</div>`;
1110
- return `<div class="${esc(l.cls)}">${esc(l.text)}</div>`;
1201
+ const sidAttr = l.sessionID ? ` data-session-id="${esc(l.sessionID)}"` : '';
1202
+ if (l.html) return `<div class="${esc(l.cls)}"${sidAttr}>${l.text}</div>`;
1203
+ return `<div class="${esc(l.cls)}"${sidAttr}>${esc(l.text)}</div>`;
1111
1204
  }).join('\n');
1112
1205
  } else if (streamItems.length > 0) {
1113
1206
  html = `<div style="color:#fbbf24;padding:20px;text-align:center">${streamItems.length} items buffered, 0 visible — check toggles or tree selection</div>`;
@@ -1125,6 +1218,7 @@ function renderStream() {
1125
1218
  for (const l of renderItem(visible[i])) {
1126
1219
  const div = document.createElement('div');
1127
1220
  div.className = l.cls;
1221
+ if (l.sessionID) div.dataset.sessionId = l.sessionID;
1128
1222
  div.innerHTML = l.html ? l.text : esc(l.text);
1129
1223
  streamEl.appendChild(div);
1130
1224
  }
@@ -1144,16 +1238,17 @@ function renderItem(item) {
1144
1238
  const isSub = !!item.agentID;
1145
1239
  const agentTagCls = 'stream-line ' + (isSub ? 'agent-sub agent-tag' : 'agent-main agent-tag');
1146
1240
  const sep = ' » ';
1241
+ const sid = item.sessionID || '';
1147
1242
 
1148
1243
  if (item.type === 'turn_marker') {
1149
- return [{ cls: 'stream-line marker', text: `── turn ended ${fmtDur(item.durationMs)} ──` }];
1244
+ return [{ cls: 'stream-line marker', text: `── turn ended ${fmtDur(item.durationMs)} ──`, sessionID: sid }];
1150
1245
  }
1151
1246
  if (item.type === 'compact_marker') {
1152
1247
  const label = item.content ? `compacted (${item.content})` : 'compacted';
1153
- return [{ cls: 'stream-line marker', text: `── ${label} ──` }];
1248
+ return [{ cls: 'stream-line marker', text: `── ${label} ──`, sessionID: sid }];
1154
1249
  }
1155
1250
  if (item.type === 'pr_link') {
1156
- return [{ cls: 'stream-line marker', text: `── ${item.content} ──` }];
1251
+ return [{ cls: 'stream-line marker', text: `── ${item.content} ──`, sessionID: sid }];
1157
1252
  }
1158
1253
 
1159
1254
  const agentName = item.agentName || 'Main';
@@ -1165,12 +1260,12 @@ function renderItem(item) {
1165
1260
 
1166
1261
  switch (item.type) {
1167
1262
  case 'thinking':
1168
- lines.push({ cls: agentTagCls, text: `<span class="tag-label">${agentLabel}${sep}🧠 Thinking</span>${tsHtml}`, html: true });
1169
- for (const l of truncContent(item.content)) lines.push({ cls: 'stream-line thinking', text: l });
1263
+ lines.push({ cls: agentTagCls, text: `<span class="tag-label">${agentLabel}${sep}🧠 Thinking</span>${tsHtml}`, html: true, sessionID: sid });
1264
+ for (const l of truncContent(item.content)) lines.push({ cls: 'stream-line thinking', text: l, sessionID: sid });
1170
1265
  break;
1171
1266
  case 'tool_input':
1172
- lines.push({ cls: agentTagCls, text: `<span class="tag-label">${agentLabel}${sep}🔧 ${esc(item.toolName || '')}</span>${tsHtml}`, html: true });
1173
- for (const l of truncContent(item.content)) lines.push({ cls: 'stream-line tool-input', text: l });
1267
+ lines.push({ cls: agentTagCls, text: `<span class="tag-label">${agentLabel}${sep}🔧 ${esc(item.toolName || '')}</span>${tsHtml}`, html: true, sessionID: sid });
1268
+ for (const l of truncContent(item.content)) lines.push({ cls: 'stream-line tool-input', text: l, sessionID: sid });
1174
1269
  break;
1175
1270
  case 'tool_output': {
1176
1271
  let tn = '';
@@ -1179,43 +1274,43 @@ function renderItem(item) {
1179
1274
  }
1180
1275
  let label = tn ? `📤 ${tn} result` : '📤 Output';
1181
1276
  if (item.durationMs > 0) label += ' ' + fmtDur(item.durationMs);
1182
- lines.push({ cls: agentTagCls, text: `<span class="tag-label">${agentLabel}${sep}${esc(label)}</span>${tsHtml}`, html: true });
1183
- for (const l of truncContent(item.content)) lines.push({ cls: 'stream-line tool-output', text: l });
1277
+ lines.push({ cls: agentTagCls, text: `<span class="tag-label">${agentLabel}${sep}${esc(label)}</span>${tsHtml}`, html: true, sessionID: sid });
1278
+ for (const l of truncContent(item.content)) lines.push({ cls: 'stream-line tool-output', text: l, sessionID: sid });
1184
1279
  break;
1185
1280
  }
1186
1281
  case 'text':
1187
- lines.push({ cls: agentTagCls, text: `<span class="tag-label">${agentLabel}${sep}💬 Response</span>${tsHtml}`, html: true });
1188
- lines.push({ cls: 'stream-line text md-content', text: mdRender(item.content), html: true });
1282
+ lines.push({ cls: agentTagCls, text: `<span class="tag-label">${agentLabel}${sep}💬 Response</span>${tsHtml}`, html: true, sessionID: sid });
1283
+ lines.push({ cls: 'stream-line text md-content', text: mdRender(item.content), html: true, sessionID: sid });
1189
1284
  break;
1190
1285
  case 'hook_output': {
1191
1286
  let label = '🪝 Hook';
1192
1287
  if (item.toolName) label += ' ' + item.toolName;
1193
1288
  if (item.durationMs > 0) label += ' ' + fmtDur(item.durationMs);
1194
- lines.push({ cls: agentTagCls, text: `<span class="tag-label">${agentLabel}${sep}${esc(label)}</span>${tsHtml}`, html: true });
1195
- if (item.hookCommand) lines.push({ cls: 'stream-line hook', text: `<span class="hook-label">command:</span> ${esc(item.hookCommand)}`, html: true });
1289
+ lines.push({ cls: agentTagCls, text: `<span class="tag-label">${agentLabel}${sep}${esc(label)}</span>${tsHtml}`, html: true, sessionID: sid });
1290
+ if (item.hookCommand) lines.push({ cls: 'stream-line hook', text: `<span class="hook-label">command:</span> ${esc(item.hookCommand)}`, html: true, sessionID: sid });
1196
1291
  if (item.hookContent) {
1197
- for (const l of truncContent(item.hookContent)) lines.push({ cls: 'stream-line hook', text: `<span class="hook-label">content:</span> ${esc(l)}`, html: true });
1292
+ for (const l of truncContent(item.hookContent)) lines.push({ cls: 'stream-line hook', text: `<span class="hook-label">content:</span> ${esc(l)}`, html: true, sessionID: sid });
1198
1293
  }
1199
- for (const l of truncContent(item.content)) lines.push({ cls: 'stream-line hook', text: `<span class="hook-label">stdout:</span> ${esc(l)}`, html: true });
1294
+ for (const l of truncContent(item.content)) lines.push({ cls: 'stream-line hook', text: `<span class="hook-label">stdout:</span> ${esc(l)}`, html: true, sessionID: sid });
1200
1295
  break;
1201
1296
  }
1202
1297
  case 'diagnostics': {
1203
1298
  let label = '⚠ Diagnostics';
1204
1299
  if (item.toolName) label += ' ' + item.toolName;
1205
- lines.push({ cls: agentTagCls, text: `<span class="tag-label">${agentLabel}${sep}${esc(label)}</span>${tsHtml}`, html: true });
1206
- for (const l of truncContent(item.content)) lines.push({ cls: 'stream-line diag', text: l });
1300
+ lines.push({ cls: agentTagCls, text: `<span class="tag-label">${agentLabel}${sep}${esc(label)}</span>${tsHtml}`, html: true, sessionID: sid });
1301
+ for (const l of truncContent(item.content)) lines.push({ cls: 'stream-line diag', text: l, sessionID: sid });
1207
1302
  break;
1208
1303
  }
1209
1304
  case 'debug': {
1210
1305
  let label = '🔍 Debug';
1211
1306
  if (item.toolName) label += ' ' + item.toolName;
1212
- lines.push({ cls: agentTagCls, text: `<span class="tag-label">${agentLabel}${sep}${esc(label)}</span>${tsHtml}`, html: true });
1213
- for (const l of truncContent(item.content)) lines.push({ cls: 'stream-line debug', text: l });
1307
+ lines.push({ cls: agentTagCls, text: `<span class="tag-label">${agentLabel}${sep}${esc(label)}</span>${tsHtml}`, html: true, sessionID: sid });
1308
+ for (const l of truncContent(item.content)) lines.push({ cls: 'stream-line debug', text: l, sessionID: sid });
1214
1309
  break;
1215
1310
  }
1216
1311
  }
1217
1312
 
1218
- lines.push({ cls: 'stream-line separator', text: '─'.repeat(60) });
1313
+ lines.push({ cls: 'stream-line separator', text: '─'.repeat(60), sessionID: sid });
1219
1314
  return lines;
1220
1315
  }
1221
1316
 
@@ -1624,6 +1719,281 @@ function scheduleRender() {
1624
1719
  }
1625
1720
  }
1626
1721
 
1722
+ // ══════════════════════════════════════════════════════════════════════════════
1723
+ // Export modal — session selection
1724
+ // ══════════════════════════════════════════════════════════════════════════════
1725
+
1726
+ let exportModalSelected = new Set();
1727
+
1728
+ function openExportModal() {
1729
+ if (sessions.length === 0) {
1730
+ const btn = document.getElementById('btn-export');
1731
+ const orig = btn.textContent;
1732
+ btn.textContent = '✕ 无会话';
1733
+ setTimeout(() => { btn.textContent = orig; }, 2000);
1734
+ return;
1735
+ }
1736
+ exportModalSelected = new Set(sessions.map(s => s.id));
1737
+ renderModalSessionList();
1738
+ updateModalCount();
1739
+ document.getElementById('export-modal').style.display = 'flex';
1740
+ }
1741
+
1742
+ function renderModalSessionList() {
1743
+ const listEl = document.getElementById('modal-session-list');
1744
+ const sorted = [...sessions].sort((a, b) => (a.colorRank || 0) - (b.colorRank || 0));
1745
+ listEl.innerHTML = sorted.map(s => {
1746
+ const color = idColor(s.colorRank || 0);
1747
+ const project = folderName(s.projectPath) || s.projectPath || '';
1748
+ const prefix = s.id.split('-')[0].toUpperCase();
1749
+ const model = s.model || '';
1750
+ const time = formatTime(s.birthtimeMs);
1751
+ const checked = exportModalSelected.has(s.id) ? 'checked' : '';
1752
+ const selectedClass = exportModalSelected.has(s.id) ? ' selected' : '';
1753
+ return `<div class="modal-session-row${selectedClass}" data-sid="${esc(s.id)}" onclick="toggleModalSession('${esc(s.id)}', this)">
1754
+ <input type="checkbox" class="modal-checkbox" data-sid="${esc(s.id)}" ${checked} onclick="event.stopPropagation(); toggleModalSession('${esc(s.id)}', this.parentElement)">
1755
+ <span class="modal-session-prefix" style="color:${color}">${esc(prefix)}</span>
1756
+ <div class="modal-session-info">
1757
+ <span class="modal-session-project">${esc(project)}</span>
1758
+ ${model ? `<span class="modal-session-model">${esc(model)}</span>` : ''}
1759
+ </div>
1760
+ ${time ? `<span class="modal-session-time">${esc(time)}</span>` : ''}
1761
+ </div>`;
1762
+ }).join('\n');
1763
+ }
1764
+
1765
+ function toggleModalSession(sid, rowEl) {
1766
+ if (exportModalSelected.has(sid)) {
1767
+ exportModalSelected.delete(sid);
1768
+ } else {
1769
+ exportModalSelected.add(sid);
1770
+ }
1771
+ const checkbox = rowEl.querySelector('.modal-checkbox');
1772
+ checkbox.checked = exportModalSelected.has(sid);
1773
+ rowEl.classList.toggle('selected', exportModalSelected.has(sid));
1774
+ updateModalCount();
1775
+ }
1776
+
1777
+ function exportModalToggleAll(selectAll) {
1778
+ if (selectAll) {
1779
+ exportModalSelected = new Set(sessions.map(s => s.id));
1780
+ } else {
1781
+ exportModalSelected.clear();
1782
+ }
1783
+ document.querySelectorAll('#modal-session-list .modal-session-row').forEach(row => {
1784
+ const sid = row.dataset.sid;
1785
+ const checkbox = row.querySelector('.modal-checkbox');
1786
+ checkbox.checked = exportModalSelected.has(sid);
1787
+ row.classList.toggle('selected', exportModalSelected.has(sid));
1788
+ });
1789
+ updateModalCount();
1790
+ }
1791
+
1792
+ function updateModalCount() {
1793
+ const total = sessions.length;
1794
+ const selected = exportModalSelected.size;
1795
+ document.getElementById('modal-selected-count').textContent = `已选 ${selected} / ${total}`;
1796
+ document.getElementById('modal-export-btn').disabled = selected === 0;
1797
+ }
1798
+
1799
+ function closeExportModal() {
1800
+ document.getElementById('export-modal').style.display = 'none';
1801
+ exportModalSelected.clear();
1802
+ }
1803
+
1804
+ // Esc key closes modal
1805
+ document.addEventListener('keydown', (e) => {
1806
+ if (e.key === 'Escape') {
1807
+ const modal = document.getElementById('export-modal');
1808
+ if (modal.style.display !== 'none') {
1809
+ closeExportModal();
1810
+ e.stopPropagation();
1811
+ }
1812
+ }
1813
+ });
1814
+
1815
+ function confirmExport() {
1816
+ if (exportModalSelected.size === 0) return;
1817
+ const selectedIds = new Set(exportModalSelected);
1818
+ closeExportModal();
1819
+ exportHTML(selectedIds);
1820
+ }
1821
+
1822
+ // ══════════════════════════════════════════════════════════════════════════════
1823
+ // Export HTML
1824
+ // ══════════════════════════════════════════════════════════════════════════════
1825
+
1826
+ function exportHTML(selectedIds = null) {
1827
+ const theme = document.documentElement.getAttribute('data-theme') || 'dark';
1828
+
1829
+ // Collect sessions to export
1830
+ let sidsInExport;
1831
+ if (selectedIds) {
1832
+ sidsInExport = selectedIds;
1833
+ } else {
1834
+ sidsInExport = new Set();
1835
+ for (const item of visibleItems) {
1836
+ if (item.sessionID) sidsInExport.add(item.sessionID);
1837
+ }
1838
+ }
1839
+ const exportSessions = [];
1840
+ for (const sid of sidsInExport) {
1841
+ const s = sessionsMap.get(sid);
1842
+ if (s) exportSessions.push(s);
1843
+ }
1844
+ // Sort by colorRank to match the order in the tree
1845
+ exportSessions.sort((a, b) => (a.colorRank || 0) - (b.colorRank || 0));
1846
+
1847
+ // Build session list header
1848
+ let sessionListHTML = '';
1849
+ if (exportSessions.length > 0) {
1850
+ const items = exportSessions.map(s => {
1851
+ const color = idColor(s.colorRank || 0);
1852
+ const project = folderName(s.projectPath) || s.projectPath || '';
1853
+ const model = s.model || '';
1854
+ return `<div class="export-session-item" data-sid="${esc(s.id)}" onclick="filterBySession('${esc(s.id)}')"><div class="export-item-top"><span class="export-project">${esc(project)}</span>${model ? ` <span class="export-model" style="color:var(--dim)">${esc(model)}</span>` : ''}</div><div class="export-item-sid" style="color:${color}">${esc(s.id)}</div></div>`;
1855
+ }).join('\n');
1856
+ sessionListHTML = `<div class="export-session-list">
1857
+ <div class="export-session-item export-all-btn active" onclick="filterBySession(null)">全部</div>
1858
+ ${items}
1859
+ </div>`;
1860
+ }
1861
+
1862
+ // Token info
1863
+ computeTokensFromContext();
1864
+ let tokenHTML = '';
1865
+ if (totalInput > 0 || totalOutput > 0) {
1866
+ let tokStr = `Input: ${fmtTok(totalInput)} · Output: ${fmtTok(totalOutput)}`;
1867
+ if (totalCacheCreate > 0 || totalCacheRead > 0) tokStr += ` · Cache: ${fmtTok(totalCacheCreate)}+${fmtTok(totalCacheRead)}`;
1868
+ tokenHTML = `<div class="export-meta-line" style="color:var(--dim)">Tokens: ${tokStr}</div>`;
1869
+ }
1870
+
1871
+ // Filter state
1872
+ const filterState = [];
1873
+ if (!showThinking) filterState.push('thinking hidden');
1874
+ if (!showToolInput) filterState.push('tools hidden');
1875
+ if (!showToolOutput) filterState.push('output hidden');
1876
+ if (!showText) filterState.push('text hidden');
1877
+ if (!showHook) filterState.push('hook hidden');
1878
+ let filterHTML = '';
1879
+ if (filterState.length > 0) filterHTML = `<div class="export-meta-line" style="color:var(--dim)">Filters: ${filterState.join(', ')}</div>`;
1880
+
1881
+ // Export timestamp
1882
+ const now = new Date();
1883
+ const exportTime = fmtTimestamp(now);
1884
+ const timeHTML = `<div class="export-meta-line" style="color:var(--dim)">Exported: ${exportTime}</div>`;
1885
+
1886
+ // Clone stream content and strip interactive elements
1887
+ const clone = streamEl.cloneNode(true);
1888
+ clone.querySelectorAll('.copy-btn').forEach(el => el.remove());
1889
+ clone.querySelectorAll('[onclick]').forEach(el => el.removeAttribute('onclick'));
1890
+
1891
+ // Filter out stream lines from non-selected sessions
1892
+ if (selectedIds) {
1893
+ clone.querySelectorAll('[data-session-id]').forEach(el => {
1894
+ if (!selectedIds.has(el.dataset.sessionId)) el.remove();
1895
+ });
1896
+ }
1897
+
1898
+ // Get the cleaned innerHTML
1899
+ const streamHTML = clone.innerHTML;
1900
+
1901
+ // Get page CSS
1902
+ const pageStyleEl = document.querySelector('style');
1903
+ const appCSS = pageStyleEl ? pageStyleEl.textContent : '';
1904
+
1905
+ // Get highlight.js CSS from cache
1906
+ const hlCSS = theme === 'dark' ? hljsDarkCSS : hljsLightCSS;
1907
+
1908
+ // Export-specific CSS
1909
+ const exportCSS = `
1910
+ .export-session-list { display: flex; flex-wrap: wrap; gap: 6px; padding: 8px 0; }
1911
+ .export-session-item { cursor: pointer; padding: 6px 8px; border-radius: 4px; border: 1px solid var(--border); opacity: 0.7; transition: all 0.15s; font-size: 12px; display: flex; flex-direction: column; gap: 2px; }
1912
+ .export-session-item:hover { opacity: 1; border-color: var(--dim); }
1913
+ .export-session-item.active { opacity: 1; border-color: var(--purple); background: var(--purple); color: var(--white); }
1914
+ .export-all-btn { font-weight: 600; align-items: center; }
1915
+ .export-item-top { display: flex; align-items: baseline; gap: 4px; }
1916
+ .export-item-sid { font-family: monospace; font-size: 10px; opacity: 0.8; }
1917
+ .export-session-item.active .export-item-sid { opacity: 1; color: var(--white); }
1918
+ .export-project { font-weight: 500; }
1919
+ .export-model { font-size: 11px; }
1920
+ .export-meta-line { padding: 2px 0; font-size: 11px; }
1921
+ .export-header { padding: 12px; border-bottom: 1px solid var(--border); position: sticky; top: 0; background: var(--bg); z-index: 100; }
1922
+ .export-header h1 { margin: 0 0 4px 0; font-size: 16px; color: var(--white); }
1923
+ `;
1924
+
1925
+ // Export-specific JS for session filtering
1926
+ const exportJS = `
1927
+ let _activeSid = null;
1928
+ function filterBySession(sid) {
1929
+ _activeSid = sid;
1930
+ const lines = document.querySelectorAll('#export-stream [data-session-id]');
1931
+ lines.forEach(el => {
1932
+ el.style.display = (sid === null || el.dataset.sessionId === sid) ? '' : 'none';
1933
+ });
1934
+ document.querySelectorAll('.export-session-item[data-sid]').forEach(el => {
1935
+ el.classList.toggle('active', sid !== null && el.dataset.sid === sid);
1936
+ });
1937
+ document.querySelector('.export-all-btn').classList.toggle('active', sid === null);
1938
+ }
1939
+ `;
1940
+
1941
+ // Assemble complete HTML document
1942
+ const htmlAttrs = theme === 'light' ? ' lang="en" data-theme="light"' : ' lang="en"';
1943
+ const fullDoc = `<!DOCTYPE html>
1944
+ <html${htmlAttrs}>
1945
+ <head>
1946
+ <meta charset="UTF-8">
1947
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
1948
+ <title>claude-watch Export</title>
1949
+ <style>
1950
+ ${appCSS}
1951
+ ${hlCSS}
1952
+ ${exportCSS}
1953
+ </style>
1954
+ </head>
1955
+ <body style="overflow-y:auto;height:auto">
1956
+ <div class="export-header">
1957
+ <h1>claude-watch Export</h1>
1958
+ ${sessionListHTML}
1959
+ ${tokenHTML}
1960
+ ${filterHTML}
1961
+ ${timeHTML}
1962
+ </div>
1963
+ <div id="export-stream" style="padding:8px 12px;font-size:12px">
1964
+ ${streamHTML}
1965
+ </div>
1966
+ <script>${exportJS}<\/script>
1967
+ </body>
1968
+ </html>`;
1969
+
1970
+ // Blob download
1971
+ const blob = new Blob([fullDoc], { type: 'text/html;charset=utf-8' });
1972
+ const url = URL.createObjectURL(blob);
1973
+ const a = document.createElement('a');
1974
+
1975
+ let filePrefix;
1976
+ if (sidsInExport.size === 1) {
1977
+ filePrefix = [...sidsInExport][0].split('-')[0].toUpperCase();
1978
+ } else {
1979
+ filePrefix = 'multi';
1980
+ }
1981
+ const pad = (n, len) => String(n).padStart(len, '0');
1982
+ const ts = `${pad(now.getFullYear(),4)}${pad(now.getMonth()+1,2)}${pad(now.getDate(),2)}-${pad(now.getHours(),2)}${pad(now.getMinutes(),2)}${pad(now.getSeconds(),2)}`;
1983
+ a.download = `claude-watch-${filePrefix}-${ts}.html`;
1984
+ a.href = url;
1985
+ document.body.appendChild(a);
1986
+ a.click();
1987
+ document.body.removeChild(a);
1988
+ URL.revokeObjectURL(url);
1989
+
1990
+ // Visual feedback
1991
+ const btn = document.getElementById('btn-export');
1992
+ const orig = btn.textContent;
1993
+ btn.textContent = '✓';
1994
+ setTimeout(() => { btn.textContent = orig; }, 2000);
1995
+ }
1996
+
1627
1997
  // ══════════════════════════════════════════════════════════════════════════════
1628
1998
  // Theme toggle
1629
1999
  // ══════════════════════════════════════════════════════════════════════════════