claude-code-watch 0.0.23 → 0.0.24

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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.24",
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
@@ -302,6 +302,7 @@ body {
302
302
  <span class="sep">│</span>
303
303
  <span id="session-info">Connecting...</span>
304
304
  <div class="auto">
305
+ <button class="btn btn-icon" id="btn-export" onclick="exportHTML()" data-tooltip="导出 HTML">💾</button>
305
306
  <button class="btn btn-icon" id="btn-theme" onclick="toggleTheme()" data-tooltip="Toggle theme">🌙</button>
306
307
  <button class="btn on" id="btn-autodisco" onclick="toggleAutoDiscovery()" data-tooltip="Auto-discover">🔍 Auto</button>
307
308
  <span class="sep">│</span>
@@ -447,6 +448,11 @@ let needsFullRender = true;
447
448
  let treeDirty = true;
448
449
  let lastTreeCursor = -1;
449
450
 
451
+ // Cache highlight.js CSS for HTML export
452
+ let hljsDarkCSS = '', hljsLightCSS = '';
453
+ fetch('vendor/github-dark.min.css').then(r => r.text()).then(t => { hljsDarkCSS = t; }).catch(() => {});
454
+ fetch('vendor/github-light.min.css').then(r => r.text()).then(t => { hljsLightCSS = t; }).catch(() => {});
455
+
450
456
  // ══════════════════════════════════════════════════════════════════════════════
451
457
  // Markdown renderer (marked + highlight.js)
452
458
  // ══════════════════════════════════════════════════════════════════════════════
@@ -1106,8 +1112,9 @@ function renderStream() {
1106
1112
  let html;
1107
1113
  if (lines.length > 0) {
1108
1114
  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>`;
1115
+ const sidAttr = l.sessionID ? ` data-session-id="${esc(l.sessionID)}"` : '';
1116
+ if (l.html) return `<div class="${esc(l.cls)}"${sidAttr}>${l.text}</div>`;
1117
+ return `<div class="${esc(l.cls)}"${sidAttr}>${esc(l.text)}</div>`;
1111
1118
  }).join('\n');
1112
1119
  } else if (streamItems.length > 0) {
1113
1120
  html = `<div style="color:#fbbf24;padding:20px;text-align:center">${streamItems.length} items buffered, 0 visible — check toggles or tree selection</div>`;
@@ -1125,6 +1132,7 @@ function renderStream() {
1125
1132
  for (const l of renderItem(visible[i])) {
1126
1133
  const div = document.createElement('div');
1127
1134
  div.className = l.cls;
1135
+ if (l.sessionID) div.dataset.sessionId = l.sessionID;
1128
1136
  div.innerHTML = l.html ? l.text : esc(l.text);
1129
1137
  streamEl.appendChild(div);
1130
1138
  }
@@ -1144,16 +1152,17 @@ function renderItem(item) {
1144
1152
  const isSub = !!item.agentID;
1145
1153
  const agentTagCls = 'stream-line ' + (isSub ? 'agent-sub agent-tag' : 'agent-main agent-tag');
1146
1154
  const sep = ' » ';
1155
+ const sid = item.sessionID || '';
1147
1156
 
1148
1157
  if (item.type === 'turn_marker') {
1149
- return [{ cls: 'stream-line marker', text: `── turn ended ${fmtDur(item.durationMs)} ──` }];
1158
+ return [{ cls: 'stream-line marker', text: `── turn ended ${fmtDur(item.durationMs)} ──`, sessionID: sid }];
1150
1159
  }
1151
1160
  if (item.type === 'compact_marker') {
1152
1161
  const label = item.content ? `compacted (${item.content})` : 'compacted';
1153
- return [{ cls: 'stream-line marker', text: `── ${label} ──` }];
1162
+ return [{ cls: 'stream-line marker', text: `── ${label} ──`, sessionID: sid }];
1154
1163
  }
1155
1164
  if (item.type === 'pr_link') {
1156
- return [{ cls: 'stream-line marker', text: `── ${item.content} ──` }];
1165
+ return [{ cls: 'stream-line marker', text: `── ${item.content} ──`, sessionID: sid }];
1157
1166
  }
1158
1167
 
1159
1168
  const agentName = item.agentName || 'Main';
@@ -1165,12 +1174,12 @@ function renderItem(item) {
1165
1174
 
1166
1175
  switch (item.type) {
1167
1176
  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 });
1177
+ lines.push({ cls: agentTagCls, text: `<span class="tag-label">${agentLabel}${sep}🧠 Thinking</span>${tsHtml}`, html: true, sessionID: sid });
1178
+ for (const l of truncContent(item.content)) lines.push({ cls: 'stream-line thinking', text: l, sessionID: sid });
1170
1179
  break;
1171
1180
  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 });
1181
+ lines.push({ cls: agentTagCls, text: `<span class="tag-label">${agentLabel}${sep}🔧 ${esc(item.toolName || '')}</span>${tsHtml}`, html: true, sessionID: sid });
1182
+ for (const l of truncContent(item.content)) lines.push({ cls: 'stream-line tool-input', text: l, sessionID: sid });
1174
1183
  break;
1175
1184
  case 'tool_output': {
1176
1185
  let tn = '';
@@ -1179,43 +1188,43 @@ function renderItem(item) {
1179
1188
  }
1180
1189
  let label = tn ? `📤 ${tn} result` : '📤 Output';
1181
1190
  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 });
1191
+ lines.push({ cls: agentTagCls, text: `<span class="tag-label">${agentLabel}${sep}${esc(label)}</span>${tsHtml}`, html: true, sessionID: sid });
1192
+ for (const l of truncContent(item.content)) lines.push({ cls: 'stream-line tool-output', text: l, sessionID: sid });
1184
1193
  break;
1185
1194
  }
1186
1195
  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 });
1196
+ lines.push({ cls: agentTagCls, text: `<span class="tag-label">${agentLabel}${sep}💬 Response</span>${tsHtml}`, html: true, sessionID: sid });
1197
+ lines.push({ cls: 'stream-line text md-content', text: mdRender(item.content), html: true, sessionID: sid });
1189
1198
  break;
1190
1199
  case 'hook_output': {
1191
1200
  let label = '🪝 Hook';
1192
1201
  if (item.toolName) label += ' ' + item.toolName;
1193
1202
  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 });
1203
+ lines.push({ cls: agentTagCls, text: `<span class="tag-label">${agentLabel}${sep}${esc(label)}</span>${tsHtml}`, html: true, sessionID: sid });
1204
+ if (item.hookCommand) lines.push({ cls: 'stream-line hook', text: `<span class="hook-label">command:</span> ${esc(item.hookCommand)}`, html: true, sessionID: sid });
1196
1205
  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 });
1206
+ 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
1207
  }
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 });
1208
+ 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
1209
  break;
1201
1210
  }
1202
1211
  case 'diagnostics': {
1203
1212
  let label = '⚠ Diagnostics';
1204
1213
  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 });
1214
+ lines.push({ cls: agentTagCls, text: `<span class="tag-label">${agentLabel}${sep}${esc(label)}</span>${tsHtml}`, html: true, sessionID: sid });
1215
+ for (const l of truncContent(item.content)) lines.push({ cls: 'stream-line diag', text: l, sessionID: sid });
1207
1216
  break;
1208
1217
  }
1209
1218
  case 'debug': {
1210
1219
  let label = '🔍 Debug';
1211
1220
  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 });
1221
+ lines.push({ cls: agentTagCls, text: `<span class="tag-label">${agentLabel}${sep}${esc(label)}</span>${tsHtml}`, html: true, sessionID: sid });
1222
+ for (const l of truncContent(item.content)) lines.push({ cls: 'stream-line debug', text: l, sessionID: sid });
1214
1223
  break;
1215
1224
  }
1216
1225
  }
1217
1226
 
1218
- lines.push({ cls: 'stream-line separator', text: '─'.repeat(60) });
1227
+ lines.push({ cls: 'stream-line separator', text: '─'.repeat(60), sessionID: sid });
1219
1228
  return lines;
1220
1229
  }
1221
1230
 
@@ -1624,6 +1633,169 @@ function scheduleRender() {
1624
1633
  }
1625
1634
  }
1626
1635
 
1636
+ // ══════════════════════════════════════════════════════════════════════════════
1637
+ // Export HTML
1638
+ // ══════════════════════════════════════════════════════════════════════════════
1639
+
1640
+ function exportHTML() {
1641
+ const theme = document.documentElement.getAttribute('data-theme') || 'dark';
1642
+
1643
+ // Collect unique sessions from visible items
1644
+ const sidsInExport = new Set();
1645
+ for (const item of visibleItems) {
1646
+ if (item.sessionID) sidsInExport.add(item.sessionID);
1647
+ }
1648
+ const exportSessions = [];
1649
+ for (const sid of sidsInExport) {
1650
+ const s = sessionsMap.get(sid);
1651
+ if (s) exportSessions.push(s);
1652
+ }
1653
+ // Sort by colorRank to match the order in the tree
1654
+ exportSessions.sort((a, b) => (a.colorRank || 0) - (b.colorRank || 0));
1655
+
1656
+ // Build session list header
1657
+ let sessionListHTML = '';
1658
+ if (exportSessions.length > 0) {
1659
+ const items = exportSessions.map(s => {
1660
+ const color = idColor(s.colorRank || 0);
1661
+ const project = folderName(s.projectPath) || s.projectPath || '';
1662
+ const model = s.model || '';
1663
+ 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>`;
1664
+ }).join('\n');
1665
+ sessionListHTML = `<div class="export-session-list">
1666
+ <div class="export-session-item export-all-btn active" onclick="filterBySession(null)">全部</div>
1667
+ ${items}
1668
+ </div>`;
1669
+ }
1670
+
1671
+ // Token info
1672
+ computeTokensFromContext();
1673
+ let tokenHTML = '';
1674
+ if (totalInput > 0 || totalOutput > 0) {
1675
+ let tokStr = `Input: ${fmtTok(totalInput)} · Output: ${fmtTok(totalOutput)}`;
1676
+ if (totalCacheCreate > 0 || totalCacheRead > 0) tokStr += ` · Cache: ${fmtTok(totalCacheCreate)}+${fmtTok(totalCacheRead)}`;
1677
+ tokenHTML = `<div class="export-meta-line" style="color:var(--dim)">Tokens: ${tokStr}</div>`;
1678
+ }
1679
+
1680
+ // Filter state
1681
+ const filterState = [];
1682
+ if (!showThinking) filterState.push('thinking hidden');
1683
+ if (!showToolInput) filterState.push('tools hidden');
1684
+ if (!showToolOutput) filterState.push('output hidden');
1685
+ if (!showText) filterState.push('text hidden');
1686
+ if (!showHook) filterState.push('hook hidden');
1687
+ let filterHTML = '';
1688
+ if (filterState.length > 0) filterHTML = `<div class="export-meta-line" style="color:var(--dim)">Filters: ${filterState.join(', ')}</div>`;
1689
+
1690
+ // Export timestamp
1691
+ const now = new Date();
1692
+ const exportTime = fmtTimestamp(now);
1693
+ const timeHTML = `<div class="export-meta-line" style="color:var(--dim)">Exported: ${exportTime}</div>`;
1694
+
1695
+ // Clone stream content and strip interactive elements
1696
+ const clone = streamEl.cloneNode(true);
1697
+ clone.querySelectorAll('.copy-btn').forEach(el => el.remove());
1698
+ clone.querySelectorAll('[onclick]').forEach(el => el.removeAttribute('onclick'));
1699
+
1700
+ // Get the cleaned innerHTML
1701
+ const streamHTML = clone.innerHTML;
1702
+
1703
+ // Get page CSS
1704
+ const pageStyleEl = document.querySelector('style');
1705
+ const appCSS = pageStyleEl ? pageStyleEl.textContent : '';
1706
+
1707
+ // Get highlight.js CSS from cache
1708
+ const hlCSS = theme === 'dark' ? hljsDarkCSS : hljsLightCSS;
1709
+
1710
+ // Export-specific CSS
1711
+ const exportCSS = `
1712
+ .export-session-list { display: flex; flex-wrap: wrap; gap: 6px; padding: 8px 0; }
1713
+ .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; }
1714
+ .export-session-item:hover { opacity: 1; border-color: var(--dim); }
1715
+ .export-session-item.active { opacity: 1; border-color: var(--purple); background: var(--purple); color: var(--white); }
1716
+ .export-all-btn { font-weight: 600; align-items: center; }
1717
+ .export-item-top { display: flex; align-items: baseline; gap: 4px; }
1718
+ .export-item-sid { font-family: monospace; font-size: 10px; opacity: 0.8; }
1719
+ .export-session-item.active .export-item-sid { opacity: 1; color: var(--white); }
1720
+ .export-project { font-weight: 500; }
1721
+ .export-model { font-size: 11px; }
1722
+ .export-meta-line { padding: 2px 0; font-size: 11px; }
1723
+ .export-header { padding: 12px; border-bottom: 1px solid var(--border); position: sticky; top: 0; background: var(--bg); z-index: 100; }
1724
+ .export-header h1 { margin: 0 0 4px 0; font-size: 16px; color: var(--white); }
1725
+ `;
1726
+
1727
+ // Export-specific JS for session filtering
1728
+ const exportJS = `
1729
+ let _activeSid = null;
1730
+ function filterBySession(sid) {
1731
+ _activeSid = sid;
1732
+ const lines = document.querySelectorAll('#export-stream [data-session-id]');
1733
+ lines.forEach(el => {
1734
+ el.style.display = (sid === null || el.dataset.sessionId === sid) ? '' : 'none';
1735
+ });
1736
+ document.querySelectorAll('.export-session-item[data-sid]').forEach(el => {
1737
+ el.classList.toggle('active', sid !== null && el.dataset.sid === sid);
1738
+ });
1739
+ document.querySelector('.export-all-btn').classList.toggle('active', sid === null);
1740
+ }
1741
+ `;
1742
+
1743
+ // Assemble complete HTML document
1744
+ const htmlAttrs = theme === 'light' ? ' lang="en" data-theme="light"' : ' lang="en"';
1745
+ const fullDoc = `<!DOCTYPE html>
1746
+ <html${htmlAttrs}>
1747
+ <head>
1748
+ <meta charset="UTF-8">
1749
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
1750
+ <title>claude-watch Export</title>
1751
+ <style>
1752
+ ${appCSS}
1753
+ ${hlCSS}
1754
+ ${exportCSS}
1755
+ </style>
1756
+ </head>
1757
+ <body style="overflow-y:auto;height:auto">
1758
+ <div class="export-header">
1759
+ <h1>claude-watch Export</h1>
1760
+ ${sessionListHTML}
1761
+ ${tokenHTML}
1762
+ ${filterHTML}
1763
+ ${timeHTML}
1764
+ </div>
1765
+ <div id="export-stream" style="padding:8px 12px;font-size:12px">
1766
+ ${streamHTML}
1767
+ </div>
1768
+ <script>${exportJS}<\/script>
1769
+ </body>
1770
+ </html>`;
1771
+
1772
+ // Blob download
1773
+ const blob = new Blob([fullDoc], { type: 'text/html;charset=utf-8' });
1774
+ const url = URL.createObjectURL(blob);
1775
+ const a = document.createElement('a');
1776
+
1777
+ let filePrefix;
1778
+ if (sidsInExport.size === 1) {
1779
+ filePrefix = [...sidsInExport][0].split('-')[0].toUpperCase();
1780
+ } else {
1781
+ filePrefix = 'multi';
1782
+ }
1783
+ const pad = (n, len) => String(n).padStart(len, '0');
1784
+ 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)}`;
1785
+ a.download = `claude-watch-${filePrefix}-${ts}.html`;
1786
+ a.href = url;
1787
+ document.body.appendChild(a);
1788
+ a.click();
1789
+ document.body.removeChild(a);
1790
+ URL.revokeObjectURL(url);
1791
+
1792
+ // Visual feedback
1793
+ const btn = document.getElementById('btn-export');
1794
+ const orig = btn.textContent;
1795
+ btn.textContent = '✓';
1796
+ setTimeout(() => { btn.textContent = orig; }, 2000);
1797
+ }
1798
+
1627
1799
  // ══════════════════════════════════════════════════════════════════════════════
1628
1800
  // Theme toggle
1629
1801
  // ══════════════════════════════════════════════════════════════════════════════