claude-code-watch 0.1.4 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/public/index.html CHANGED
@@ -6,403 +6,7 @@
6
6
  <title>claude-watch</title>
7
7
  <link rel="icon" type="image/svg+xml" href="favicon.svg">
8
8
  <link rel="stylesheet" href="vendor/github-dark.min.css">
9
- <style>
10
- *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
11
-
12
- :root {
13
- --bg: #111827;
14
- --bg2: #1f2937;
15
- --bg3: #374151;
16
- --border: #4b5563;
17
- --text: #d1d5db;
18
- --dim: #6b7280;
19
- --white: #f9fafb;
20
- --purple: #7c3aed;
21
- --purple2: #5b21b6;
22
- --blue: #3b82f6;
23
- --magenta: #c084fc;
24
- --yellow: #fbbf24;
25
- --yellow2: #92400e;
26
- --green: #34d399;
27
- --cyan: #22d3ee;
28
- --red: #f87171;
29
- --red2: #dc2626;
30
- --gray: #9ca3af;
31
- --orange: #fb923c;
32
- }
33
-
34
- :root[data-theme="light"] {
35
- --bg: #f8f9fa;
36
- --bg2: #e9ecef;
37
- --bg3: #ced4da;
38
- --border: #adb5bd;
39
- --text: #495057;
40
- --dim: #868e96;
41
- --white: #212529;
42
- --purple: #6741d9;
43
- --purple2: #5b21b6;
44
- --blue: #2563eb;
45
- --magenta: #9333ea;
46
- --yellow: #d97706;
47
- --yellow2: #92400e;
48
- --green: #059669;
49
- --cyan: #0891b2;
50
- --red: #dc2626;
51
- --red2: #b91c1c;
52
- --gray: #6b7280;
53
- --orange: #ea580c;
54
- }
55
-
56
- body {
57
- background: var(--bg);
58
- color: var(--text);
59
- font-family: 'SF Mono', 'Menlo', 'Monaco', 'Consolas', monospace;
60
- font-size: 13px;
61
- line-height: 1.5;
62
- height: 100vh;
63
- display: flex;
64
- flex-direction: column;
65
- overflow: hidden;
66
- }
67
-
68
- /* ── Header ── */
69
- #header {
70
- height: 32px;
71
- background: var(--bg3);
72
- display: flex;
73
- align-items: center;
74
- padding: 0 12px;
75
- gap: 8px;
76
- font-size: 12px;
77
- color: var(--white);
78
- flex-shrink: 0;
79
- user-select: none;
80
- }
81
- #header .sep { color: var(--dim); margin: 0 4px; }
82
- #header .auto { margin-left: auto; display: flex; gap: 4px; align-items: center; }
83
-
84
- .btn {
85
- position: relative;
86
- display: inline-flex; align-items: center; gap: 3px;
87
- padding: 2px 8px; border-radius: 4px; border: 1px solid var(--border);
88
- background: var(--bg2); color: var(--text); cursor: pointer;
89
- font-family: inherit; font-size: 11px; line-height: 1.5;
90
- white-space: nowrap; user-select: none; transition: all 0.15s;
91
- }
92
- .btn[data-tooltip]:hover::after {
93
- content: attr(data-tooltip);
94
- position: absolute;
95
- top: calc(100% + 5px);
96
- left: 0;
97
- background: var(--bg2); color: var(--white);
98
- padding: 2px 8px; border-radius: 4px;
99
- font-size: 10px; white-space: nowrap; z-index: 9999;
100
- pointer-events: none; border: 1px solid var(--border);
101
- font-family: inherit; line-height: 1.4;
102
- }
103
- .btn:hover { background: var(--bg3); border-color: var(--dim); }
104
- .btn.on { background: var(--purple); border-color: var(--purple); color: var(--white); }
105
- .btn.on:hover { background: var(--purple2); }
106
- .btn.on:hover::after { background: var(--purple2); }
107
- .btn.danger { border-color: var(--red2); color: var(--red); }
108
- .btn.danger:hover { background: var(--red2); color: var(--white); }
109
- .btn.accent { border-color: var(--yellow2); color: var(--yellow); }
110
- .btn:disabled { opacity: 0.3; cursor: not-allowed; }
111
- .btn-icon { padding: 2px 6px; min-width: 28px; justify-content: center; }
112
-
113
- /* ── Main area ── */
114
- #main {
115
- display: flex;
116
- flex: 1;
117
- overflow: hidden;
118
- }
119
-
120
- /* ── Tree panel ── */
121
- #tree-panel {
122
- width: 30%; min-width: 180px; max-width: 60%;
123
- border-right: 1px solid var(--border);
124
- background: var(--bg2);
125
- overflow: hidden;
126
- display: flex; flex-direction: column;
127
- flex-shrink: 0;
128
- position: relative;
129
- }
130
- #tree-panel.hidden { display: none; }
131
-
132
- #tree-resize-handle {
133
- position: absolute; right: -3px; top: 0; bottom: 0;
134
- width: 6px; cursor: col-resize; z-index: 10;
135
- }
136
- #tree-resize-handle:hover, #tree-resize-handle.active { background: var(--purple); }
137
-
138
- #tree-toolbar {
139
- display: flex; gap: 4px; padding: 4px 6px;
140
- border-bottom: 1px solid var(--border);
141
- flex-shrink: 0; flex-wrap: wrap;
142
- }
143
- #tree-content {
144
- flex: 1; overflow-y: auto; overflow-x: hidden; padding: 4px 0;
145
- }
146
-
147
- /* ── Tree node styles ── */
148
- .tree-row {
149
- display: flex; align-items: flex-start;
150
- }
151
- .tree-row-session {
152
- margin-top: 6px;
153
- }
154
- .tree-row-folder {
155
- margin-top: 6px;
156
- }
157
- .folder-node {
158
- font-size: 13px; font-weight: 500; color: var(--yellow);
159
- }
160
- .tree-content {
161
- flex: 1; min-width: 0;
162
- }
163
- .tree-content:hover { background: rgba(255,255,255,0.05); }
164
- .tree-row.selected > .tree-content { background: rgba(124,58,237,0.3); }
165
- .tree-content.dim { opacity: 0.4; }
166
- .tree-node {
167
- display: flex; align-items: center;
168
- padding: 3px 2px 3px 0;
169
- cursor: pointer; white-space: nowrap; gap: 4px;
170
- overflow: hidden;
171
- }
172
- .tree-prefix { color: var(--dim); font-size: 12px; flex-shrink: 0; letter-spacing: 0; font-family: monospace; white-space: pre; }
173
- .tree-activity {
174
- font-size: 10px; color: var(--dim); white-space: pre;
175
- overflow: hidden; line-height: 1.2;
176
- padding: 0 2px 2px; cursor: pointer;
177
- }
178
- .tree-activity .act-text {
179
- white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
180
- }
181
- .tree-node .ctx-pct { font-size: 10px; margin-left: 4px; flex-shrink: 0; }
182
- .tree-node .ctx-pct.warn { color: var(--yellow); }
183
- .tree-node .ctx-pct.danger { color: var(--red); }
184
- .tree-node .active-dot { flex-shrink: 0; }
185
- .tree-node .active-dot.on { color: var(--green); text-shadow: 0 0 6px var(--green); }
186
- .tree-node .active-dot.off { color: #555; opacity: 1; }
187
- .tree-node .tree-agent-id { font-size: 10px; color: var(--dim); margin-left: 2px; font-family: monospace; }
188
-
189
- .tree-node .session-prefix {
190
- background: rgba(255,255,255,0.08);
191
- padding: 0 3px; border-radius: 3px; flex-shrink: 0; font-family: monospace;
192
- letter-spacing: 0.5px; vertical-align: middle; font-weight: 600;
193
- position: relative; cursor: pointer;
194
- }
195
- .session-id-tip {
196
- position: fixed; z-index: 10000;
197
- background: var(--bg2); border: 1px solid var(--border); border-radius: 4px;
198
- padding: 4px 8px; font-size: 11px; white-space: nowrap; color: var(--text);
199
- box-shadow: 0 4px 12px rgba(0,0,0,0.4); display: flex; align-items: center; gap: 6px;
200
- }
201
- .session-id-tip code { font-family: monospace; user-select: all; color: var(--white); }
202
- .session-id-tip .tip-copy-btn {
203
- background: var(--bg3); border: 1px solid var(--border); border-radius: 3px;
204
- color: var(--text); font-size: 10px; padding: 1px 5px; cursor: pointer;
205
- }
206
- .session-id-tip .tip-copy-btn:hover { background: var(--accent); color: #fff; }
207
- .tree-actions { display: none; gap: 2px; padding-right: 4px; }
208
- .tree-row:hover .tree-actions { display: flex; }
209
- .tree-row.selected>.tree-actions { display: flex; }
210
-
211
- /* ── Tokens page ── */
212
- #tokens-page { flex: 1; display: flex; flex-direction: column; overflow-y: auto; padding: 16px 24px; gap: 16px; background: var(--bg); }
213
- .token-card { background: var(--bg2); border: 1px solid var(--border); border-radius: 8px; padding: 12px 16px; }
214
- .token-card-title { font-size: 13px; font-weight: 600; color: var(--white); margin-bottom: 8px; display: flex; align-items: center; gap: 6px; }
215
- .token-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 8px; }
216
- .token-item { display: flex; flex-direction: column; gap: 2px; }
217
- .token-label { font-size: 10px; color: var(--dim); text-transform: uppercase; letter-spacing: 0.5px; }
218
- .token-value { font-size: 16px; font-weight: 600; color: var(--white); font-family: monospace; }
219
- .token-bar { height: 4px; border-radius: 2px; background: var(--bg3); margin-top: 4px; }
220
- .token-bar-fill { height: 100%; border-radius: 2px; background: var(--purple); transition: width 0.3s; }
221
- .token-bar-fill.warn { background: var(--yellow); }
222
- .token-bar-fill.danger { background: var(--red); }
223
- .token-section-title { font-size: 12px; font-weight: 600; color: var(--dim); margin: 8px 0 4px; padding-bottom: 4px; border-bottom: 1px solid var(--border); }
224
- .token-table { width: 100%; border-collapse: collapse; font-size: 12px; margin-top: 4px; }
225
- .token-table th { background: var(--bg3); color: var(--white); padding: 6px 8px; text-align: left; font-weight: 600; font-size: 11px; border-bottom: 1px solid var(--border); }
226
- .token-table td { padding: 5px 8px; color: var(--text); border-bottom: 1px solid var(--border); font-family: monospace; }
227
- .token-table tr:hover td { background: var(--bg3); }
228
- .token-detail-row { display: flex; gap: 4px; margin-top: 6px; font-size: 12px; flex-wrap: wrap; }
229
- .token-detail-row .token-kv { color: var(--dim); }
230
- .token-detail-row .token-kv b { color: var(--text); font-family: monospace; }
231
- .token-usage-line { display: flex; align-items: baseline; gap: 8px; margin-top: 2px; }
232
- .token-usage-line .token-pct { font-size: 12px; font-weight: 600; }
233
- .token-usage-line .token-pct.warn { color: var(--yellow); }
234
- .token-usage-line .token-pct.danger { color: var(--red); }
235
- .token-usage-line .token-ctx-info { font-size: 11px; color: var(--dim); }
236
- .token-active-dot { font-size: 10px; }
237
-
238
- /* ── Stream panel ── */
239
- #stream-panel-wrap {
240
- flex: 1; display: flex; flex-direction: column; overflow: hidden;
241
- }
242
- #stream-toolbar {
243
- display: flex; gap: 4px; padding: 4px 8px;
244
- border-bottom: 1px solid var(--border);
245
- background: var(--bg); flex-shrink: 0;
246
- }
247
- #stream-panel {
248
- flex: 1; overflow-y: auto; padding: 8px 12px;
249
- font-size: 12px;
250
- }
251
-
252
- /* ── Stream lines ── */
253
- .stream-line { white-space: pre-wrap; word-break: break-all; }
254
- .stream-line.thinking { color: var(--magenta); }
255
- .stream-line.tool-input { color: var(--yellow); }
256
- .stream-line.tool-output { color: var(--green); }
257
- .stream-line.text { color: var(--text); }
258
- .stream-line.hook { color: var(--cyan); }
259
- .stream-line .hook-label { color: var(--dim); }
260
- .stream-line.diag { color: var(--red); }
261
- .stream-line.debug { color: var(--gray); }
262
- .stream-line.marker { color: var(--dim); }
263
- .stream-line.agent-tag { font-weight: bold; display: flex; justify-content: space-between; align-items: baseline; white-space: nowrap; }
264
- .stream-line.agent-main { color: var(--blue); }
265
- .stream-line.agent-sub { color: var(--magenta); }
266
- .stream-line.agent-tag .tag-label { flex-shrink: 0; }
267
- .stream-line.agent-tag .timestamp { font-weight: normal; font-size: 0.85em; color: var(--dim); white-space: nowrap; }
268
- .stream-line.separator { color: var(--dim); }
269
- .stream-line.user-prompt-block {
270
- background: rgba(59,130,246,0.08);
271
- border-left: 8px solid var(--blue);
272
- border-radius: 4px;
273
- padding: 6px 10px;
274
- margin: 4px 0;
275
- color: var(--white);
276
- line-height: 1.5;
277
- }
278
-
279
- /* ── Footer ── */
280
- #footer {
281
- height: 28px; background: var(--bg2);
282
- border-top: 1px solid var(--border);
283
- display: flex; align-items: center;
284
- padding: 0 8px; gap: 6px;
285
- font-size: 11px; flex-shrink: 0; flex-wrap: wrap;
286
- }
287
- #footer .sep { color: var(--dim); margin: 0 2px; }
288
-
289
- /* ── Scrollbar ── */
290
- ::-webkit-scrollbar { width: 6px; height: 6px; }
291
- ::-webkit-scrollbar-track { background: transparent; }
292
- ::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
293
-
294
- /* ── Focus ring ── */
295
- #stream-panel:focus { outline: none; }
296
-
297
- /* ── Markdown & code blocks ── */
298
- .md-content { line-height: 1.6; color: var(--text); }
299
- .md-content p { margin: 4px 0; }
300
- .md-content ul, .md-content ol { padding-left: 20px; margin: 4px 0; }
301
- .md-content li { margin: 2px 0; }
302
- .md-content strong { color: var(--white); }
303
- .md-content a { color: var(--blue); text-decoration: underline; }
304
- .md-content h1, .md-content h2, .md-content h3, .md-content h4,
305
- .md-content h5, .md-content h6 { color: var(--white); margin: 8px 0 4px; font-size: inherit; font-weight: bold; }
306
- .md-content blockquote { border-left: 3px solid var(--purple); padding-left: 12px; color: var(--dim); margin: 4px 0; }
307
- .md-content hr { border: none; border-top: 1px solid var(--border); margin: 8px 0; }
308
- .md-content table { border-collapse: collapse; margin: 4px 0; width: 100%; }
309
- .md-content th, .md-content td { border: 1px solid var(--border); padding: 4px 8px; text-align: left; }
310
- .md-content th { background: var(--bg3); color: var(--white); }
311
- .md-content img { max-width: 100%; }
312
-
313
- .code-block-wrapper { margin: 8px 0; border-radius: 6px; overflow: hidden; border: 1px solid var(--border); }
314
- .code-block-header { display: flex; align-items: center; justify-content: space-between; padding: 4px 12px; background: var(--bg3); font-size: 11px; color: var(--dim); }
315
- .code-block-header .lang-tag { color: var(--blue); font-weight: bold; }
316
- .code-block-header .copy-btn { cursor: pointer; opacity: 0.5; transition: opacity 0.2s; font-size: 11px; }
317
- .code-block-header .copy-btn:hover { opacity: 1; }
318
- .code-block-wrapper pre { margin: 0; padding: 12px; overflow-x: auto; font-size: 12px; line-height: 1.5; }
319
- .code-block-wrapper pre code { font-family: 'SF Mono', 'Menlo', 'Monaco', 'Consolas', monospace; font-size: 12px; }
320
-
321
- /* Override highlight.js background to match our theme */
322
- .hljs { background: #0d1117 !important; }
323
-
324
- /* Light theme overrides */
325
- :root[data-theme="light"] .btn.on { background: var(--purple); border-color: var(--purple); color: #fff; }
326
- :root[data-theme="light"] .btn.on:hover { background: var(--purple2); color: #fff; }
327
- :root[data-theme="light"] .btn.on:hover::after { background: var(--purple2); color: #fff; }
328
- :root[data-theme="light"] .hljs { background: #f0f0f0 !important; }
329
- :root[data-theme="light"] .tree-content:hover { background: rgba(0,0,0,0.06); }
330
- :root[data-theme="light"] .tree-row.selected > .tree-content { background: rgba(124,58,237,0.2); }
331
- :root[data-theme="light"] .tree-node .active-dot.off { color: #bbb; }
332
- :root[data-theme="light"] #tree-resize-handle:hover,
333
- :root[data-theme="light"] #tree-resize-handle.active { background: var(--purple); }
334
- :root[data-theme="light"] .stream-line.text { color: var(--text); }
335
-
336
- /* Theme toggle button */
337
- #btn-theme { font-size: 14px; }
338
-
339
- /* ── Export modal ── */
340
- .modal-overlay {
341
- position: fixed; inset: 0;
342
- background: rgba(0, 0, 0, 0.6);
343
- z-index: 10000;
344
- display: flex; align-items: center; justify-content: center;
345
- }
346
- :root[data-theme="light"] .modal-overlay { background: rgba(0, 0, 0, 0.3); }
347
- :root[data-theme="light"] .modal-session-row.selected { background: rgba(124, 58, 237, 0.2); }
348
-
349
- .modal-box {
350
- background: var(--bg); border: 1px solid var(--border); border-radius: 8px;
351
- width: 480px; max-width: 90vw; max-height: 80vh;
352
- display: flex; flex-direction: column; overflow: hidden;
353
- }
354
-
355
- .modal-header {
356
- display: flex; align-items: center; justify-content: space-between;
357
- padding: 8px 12px; border-bottom: 1px solid var(--border); background: var(--bg2); flex-shrink: 0;
358
- }
359
- .modal-title { font-size: 13px; font-weight: 600; color: var(--white); }
360
-
361
- .modal-toolbar {
362
- display: flex; align-items: center; gap: 4px;
363
- padding: 6px 12px; border-bottom: 1px solid var(--border); flex-shrink: 0;
364
- }
365
- .modal-count { margin-left: auto; font-size: 11px; color: var(--dim); }
366
-
367
- .modal-body { flex: 1; overflow-y: auto; padding: 6px 0; }
368
-
369
- .modal-session-row {
370
- display: flex; align-items: center; gap: 8px;
371
- padding: 6px 12px; cursor: pointer; transition: background 0.1s; user-select: none;
372
- }
373
- .modal-session-row:hover { background: var(--bg2); }
374
- .modal-session-row.selected { background: rgba(124, 58, 237, 0.15); }
375
-
376
- .modal-checkbox {
377
- appearance: none; width: 16px; height: 16px;
378
- border: 1px solid var(--border); border-radius: 3px; background: var(--bg2);
379
- cursor: pointer; position: relative; flex-shrink: 0; transition: all 0.15s;
380
- }
381
- .modal-checkbox:checked { background: var(--purple); border-color: var(--purple); }
382
- .modal-checkbox:checked::after {
383
- content: '✓'; position: absolute; inset: 0;
384
- display: flex; align-items: center; justify-content: center;
385
- color: var(--white); font-size: 11px; font-weight: bold;
386
- }
387
-
388
- .modal-session-prefix {
389
- font-family: monospace; font-size: 12px; font-weight: 600; letter-spacing: 0.5px; flex-shrink: 0;
390
- }
391
- .modal-session-info {
392
- flex: 1; min-width: 0; display: flex; align-items: baseline; gap: 4px; overflow: hidden;
393
- }
394
- .modal-session-project {
395
- font-size: 12px; font-weight: 500; color: var(--text);
396
- white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
397
- }
398
- .modal-session-model { font-size: 10px; color: var(--dim); flex-shrink: 0; }
399
- .modal-session-time { font-size: 10px; color: var(--dim); flex-shrink: 0; margin-left: auto; }
400
-
401
- .modal-footer {
402
- display: flex; align-items: center; justify-content: flex-end; gap: 6px;
403
- padding: 8px 12px; border-top: 1px solid var(--border); background: var(--bg2); flex-shrink: 0;
404
- }
405
- </style>
9
+ <link rel="stylesheet" href="css/app.css">
406
10
  </head>
407
11
  <body>
408
12
 
@@ -455,18 +59,32 @@ body {
455
59
  </div>
456
60
 
457
61
  <div id="tokens-page" style="display:none">
458
- <div class="token-card" id="token-overview">
459
- <div class="token-card-title">📊 Token Overview</div>
460
- <div class="token-grid" id="token-overview-grid"></div>
62
+ <div class="tp-refresh-bar">
63
+ <span class="tp-refresh-info" id="tp-refresh-info">Token 统计不会自动刷新,点击按钮手动更新</span>
64
+ <button class="btn" id="btn-refresh-tokens" onclick="refreshTokenStats()">🔄 刷新数据</button>
65
+ </div>
66
+ <div class="tp-top">
67
+ <div class="tp-left">
68
+ <div class="tp-box" id="tp-total-card"></div>
69
+ <div class="tp-box" id="tp-stats-grid"></div>
70
+ <div class="tp-box" id="tp-model-rank"></div>
71
+ </div>
72
+ <div class="tp-right">
73
+ <div class="tp-box" id="tp-trend-card"></div>
74
+ <div class="tp-box" id="tp-heatmap-card"></div>
75
+ </div>
76
+ </div>
77
+ <div class="tp-charts-row" id="tp-charts-row">
78
+ <div class="tp-chart-box" id="tp-weekly-chart" role="img" aria-label="Weekly token consumption chart"></div>
79
+ <div class="tp-chart-box" id="tp-monthly-chart" role="img" aria-label="Monthly token consumption chart"></div>
80
+ <div class="tp-chart-box" id="tp-model-pie" role="img" aria-label="Model token proportion chart"></div>
81
+ <div class="tp-chart-box" id="tp-hourly-chart" role="img" aria-label="Active time distribution chart"></div>
461
82
  </div>
462
- <div class="token-section-title">按 Agent 分项</div>
463
- <div id="token-agent-cards"></div>
464
- <div class="token-section-title" style="cursor:pointer" onclick="toggleTokenTable()" id="token-table-toggle">明细表格 ▸</div>
465
- <div id="token-table-wrap" style="display:none">
466
- <table class="token-table" id="token-detail-table">
467
- <thead><tr><th>Agent</th><th>Model</th><th>Input</th><th>Output</th><th>Cache+</th><th>Cache Read</th><th>Context</th><th>%</th><th>I/O</th></tr></thead>
468
- <tbody id="token-table-body"></tbody>
469
- </table>
83
+ <div class="tp-box">
84
+ <div class="tp-tabs" id="tp-detail-tabs"></div>
85
+ <div id="tp-tc-daily" class="tp-tc"><div class="tp-st" id="tp-daily-table"></div></div>
86
+ <div id="tp-tc-weekly" class="tp-tc"><div class="tp-st" id="tp-weekly-table"></div></div>
87
+ <div id="tp-tc-monthly" class="tp-tc"><div class="tp-st" id="tp-monthly-table"></div></div>
470
88
  </div>
471
89
  </div>
472
90
 
@@ -476,7 +94,7 @@ body {
476
94
  <span id="item-count">0 items</span>
477
95
  <span class="sep">│</span>
478
96
  <span id="footer-version" style="margin-left:auto;font-size:10px;color:var(--dim)"></span>
479
- </div>
97
+ </div>
480
98
 
481
99
  <div id="export-modal" class="modal-overlay" style="display:none">
482
100
  <div class="modal-box">
@@ -500,1798 +118,9 @@ body {
500
118
  <script src="vendor/highlight.min.js"></script>
501
119
  <script src="vendor/marked.min.js"></script>
502
120
  <script src="vendor/purify.min.js"></script>
503
- <script>
504
- // ══════════════════════════════════════════════════════════════════════════════
505
- // DOM refs
506
- // ══════════════════════════════════════════════════════════════════════════════
507
-
508
- const streamEl = document.getElementById('stream-panel');
509
- const treeEl = document.getElementById('tree-content');
510
- const sessionInfo = document.getElementById('session-info');
511
- const tokenInfo = document.getElementById('token-info');
512
- const treeCursorInfo = document.getElementById('tree-cursor-info');
513
-
514
- // ══════════════════════════════════════════════════════════════════════════════
515
- // State
516
- // ══════════════════════════════════════════════════════════════════════════════
517
-
518
- let ws = null;
519
- let reconnectTimer = null;
520
- let reconnectDelay = 1000;
521
- const MaxReconnectDelay = 30000;
522
- const MaxReconnectAttempts = 20;
523
- let reconnectAttempts = 0;
524
- let showTree = true;
525
- let autoScroll = true;
526
- let lastMsgTime = 0;
527
- let staleCheckTimer = null;
528
-
529
- let sessions = [];
530
- let sessionsMap = new Map(); // id -> session, for O(1) lookups
531
- let treeNodes = [];
532
- let treeCursor = 0;
533
- let folderCollapsed = {}; // dateStr -> boolean, default collapsed
534
- let streamItems = [];
535
- let visibleItems = [];
536
- let visibleDirty = true;
537
- // LRU cache: recently accessed keys survive eviction, so a tool_input's ID
538
- // stays alive long enough for its matching tool_output to arrive and merge.
539
- class LRUCache {
540
- constructor(max) { this.max = max; this.map = new Map(); }
541
- has(key) { if (!this.map.has(key)) return false; const v = this.map.get(key); this.map.delete(key); this.map.set(key, v); return true; }
542
- get(key) { if (!this.map.has(key)) return undefined; const v = this.map.get(key); this.map.delete(key); this.map.set(key, v); return v; }
543
- set(key, val) { if (this.map.has(key)) this.map.delete(key); this.map.set(key, val); if (this.map.size > this.max) { const oldest = this.map.keys().next().value; this.map.delete(oldest); } }
544
- delete(key) { return this.map.delete(key); }
545
- keys() { return this.map.keys(); }
546
- }
547
- const seenToolIDs = new LRUCache(20000);
548
- const toolNameMap = new LRUCache(2000);
549
- const agentActivity = new LRUCache(500); // "sessionID:agentID" → { toolName, content }
550
- const taskDescriptions = new LRUCache(2000); // toolID → description string
551
- const MAX_DESC_STORE = 200;
552
- let filters = new Map();
553
- let visibleFilterCount = 0;
554
-
555
- let showThinking = true;
556
- let showToolInput = true;
557
- let showToolOutput = true;
558
- let showText = true;
559
- let showHook = true;
560
- let showUserPrompt = true;
561
- let showActivity = true;
562
- let showTokenCount = true;
563
- let autoDiscovery = true;
564
- let appVersion = '';
565
- let currentTab = 'stream';
566
-
567
- const HIDDEN_KEY = 'claude-watch-hidden';
568
- function loadHiddenSessions() {
569
- try {
570
- const data = JSON.parse(localStorage.getItem(HIDDEN_KEY) || '{}');
571
- const now = Date.now();
572
- for (const [id, ts] of Object.entries(data)) {
573
- if (now - ts < 24 * 60 * 60 * 1000) hiddenSessionIDs.add(id);
574
- }
575
- _saveHiddenSessions();
576
- } catch {}
577
- }
578
- function _saveHiddenSessions() {
579
- const data = {};
580
- for (const id of hiddenSessionIDs) data[id] = Date.now();
581
- localStorage.setItem(HIDDEN_KEY, JSON.stringify(data));
582
- }
583
- const hiddenSessionIDs = new Set();
584
- loadHiddenSessions();
585
-
586
- let renderPending = false;
587
-
588
- let totalInput = 0, totalOutput = 0, totalCacheCreate = 0, totalCacheRead = 0;
589
- let contextData = {};
590
-
591
- function computeTokensFromContext() {
592
- totalInput = 0; totalOutput = 0; totalCacheCreate = 0; totalCacheRead = 0;
593
- for (const ctx of Object.values(contextData)) {
594
- totalInput += ctx.inputTokens || 0;
595
- totalOutput += ctx.outputTokens || 0;
596
- totalCacheCreate += ctx.cacheCreation || 0;
597
- totalCacheRead += ctx.cacheRead || 0;
598
- }
599
- }
600
-
601
- let collapseAfter = 0;
602
- let collapseTimer = null;
603
- let activeRefreshTimer = null;
604
-
605
- const MAX_ITEMS = 9999;
606
- const MAX_LINES = 50;
607
- let renderedItemCount = 0;
608
- let needsFullRender = true;
609
- let treeDirty = true;
610
- let lastTreeCursor = -1;
611
-
612
- // Cache highlight.js CSS for HTML export
613
- let hljsDarkCSS = '', hljsLightCSS = '';
614
- fetch('vendor/github-dark.min.css').then(r => r.text()).then(t => { hljsDarkCSS = t; }).catch(() => {});
615
- fetch('vendor/github-light.min.css').then(r => r.text()).then(t => { hljsLightCSS = t; }).catch(() => {});
616
-
617
- // ══════════════════════════════════════════════════════════════════════════════
618
- // Markdown renderer (marked + highlight.js)
619
- // ══════════════════════════════════════════════════════════════════════════════
620
-
621
- const mdRenderer = new marked.Renderer();
622
- mdRenderer.code = function (codeOrObj, langOrEsc) {
623
- // marked v4: code(text, lang, escaped) — marked v5+: code({ text, lang })
624
- const text = typeof codeOrObj === 'object' ? codeOrObj.text : codeOrObj;
625
- const lang = typeof codeOrObj === 'object' ? codeOrObj.lang : langOrEsc;
626
- let highlighted;
627
- if (lang && hljs.getLanguage(lang)) {
628
- try {
629
- highlighted = hljs.highlight(text, { language: lang }).value;
630
- } catch {
631
- highlighted = hljs.highlightAuto(text).value;
632
- }
633
- } else {
634
- highlighted = hljs.highlightAuto(text).value;
635
- }
636
- const langTag = lang ? `<span class="lang-tag">${esc(lang)}</span>` : '';
637
- return `<div class="code-block-wrapper">
638
- <div class="code-block-header">${langTag}<span class="copy-btn" onclick="copyCode(this)">&#x2398;</span></div>
639
- <pre><code>${highlighted}</code></pre>
640
- </div>`;
641
- };
642
- marked.setOptions({ renderer: mdRenderer, breaks: true, gfm: true });
643
-
644
- function copyCode(btn) {
645
- const wrapper = btn.closest('.code-block-wrapper');
646
- const code = wrapper ? wrapper.querySelector('code') : null;
647
- if (!code) return;
648
- navigator.clipboard.writeText(code.textContent).then(() => {
649
- btn.innerHTML = '&#x2713;';
650
- setTimeout(() => { btn.innerHTML = '&#x2398;'; }, 1500);
651
- });
652
- }
653
-
654
- function mdRender(text) {
655
- try {
656
- return DOMPurify.sanitize(marked.parse(text));
657
- } catch {
658
- return esc(text);
659
- }
660
- }
661
-
662
- // ══════════════════════════════════════════════════════════════════════════════
663
- // WebSocket
664
- // ══════════════════════════════════════════════════════════════════════════════
665
-
666
- function connect() {
667
- const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
668
- ws = new WebSocket(`${proto}//${location.host}`);
669
-
670
- ws.onopen = () => {
671
- sessionInfo.textContent = 'Connected';
672
- lastMsgTime = Date.now();
673
- reconnectDelay = 1000;
674
- reconnectAttempts = 0;
675
- startStaleCheck();
676
- startActiveRefresh();
677
- };
678
- ws.onclose = () => {
679
- reconnectAttempts++;
680
- if (reconnectAttempts >= MaxReconnectAttempts) {
681
- sessionInfo.textContent = 'Disconnected. Please refresh to reconnect.';
682
- return;
683
- }
684
- sessionInfo.textContent = 'Disconnected, reconnecting...';
685
- stopStaleCheck();
686
- if (activeRefreshTimer) { clearInterval(activeRefreshTimer); activeRefreshTimer = null; }
687
- reconnectTimer = setTimeout(connect, reconnectDelay);
688
- reconnectDelay = Math.min(reconnectDelay * 2, MaxReconnectDelay);
689
- };
690
- ws.onerror = (e) => { console.warn('[ws] connection error', e); };
691
-
692
- ws.onmessage = (e) => {
693
- lastMsgTime = Date.now();
694
- let msg;
695
- try { msg = JSON.parse(e.data); } catch { return; }
696
- handleMessage(msg);
697
- };
698
- }
699
-
700
- function startStaleCheck() {
701
- if (staleCheckTimer) clearInterval(staleCheckTimer);
702
- staleCheckTimer = setInterval(() => {
703
- if (Date.now() - lastMsgTime > 45000) {
704
- sessionInfo.textContent = 'Stale connection, reconnecting...';
705
- stopStaleCheck();
706
- try { ws.close(); } catch {}
707
- // onclose handler will handle reconnect — no separate timer needed
708
- }
709
- }, 10000);
710
- }
711
-
712
- function stopStaleCheck() {
713
- if (staleCheckTimer) { clearInterval(staleCheckTimer); staleCheckTimer = null; }
714
- }
715
-
716
- function handleMessage(msg) {
717
- switch (msg.type) {
718
- case 'snapshot': handleSnapshot(msg.payload); break;
719
- case 'itemBatch': handleItemBatch(msg.payload); break;
720
- case 'item': handleItem(msg.payload); break;
721
- case 'newSession': handleNewSession(msg.payload); break;
722
- case 'newAgent': handleNewAgent(msg.payload); break;
723
- case 'newBackgroundTask': handleNewBgTask(msg.payload); break;
724
- case 'sessionRemoved': handleSessionRemoved(msg.payload); break;
725
- case 'autoDiscoveryChanged': autoDiscovery = msg.payload.enabled; scheduleRender(); break;
726
- case 'context': contextData = msg.payload; updateTreeDots(); refreshButtons(); if (currentTab === 'tokens') renderTokenPage(); break;
727
- case 'config':
728
- if (msg.payload.version) appVersion = msg.payload.version;
729
- if (msg.payload.collapseAfter > 0 && !collapseTimer) {
730
- applyCollapsePolicy(msg.payload.collapseAfter);
731
- }
732
- break;
733
- case 'heartbeat': break;
734
- }
735
- }
736
-
737
- function sendCmd(action, extra = {}) {
738
- if (ws && ws.readyState === 1) ws.send(JSON.stringify({ action, ...extra }));
739
- }
740
-
741
- // ══════════════════════════════════════════════════════════════════════════════
742
- // Snapshot / Session management
743
- // ══════════════════════════════════════════════════════════════════════════════
744
-
745
- function handleSnapshot(payload) {
746
- autoDiscovery = payload.autoDiscovery;
747
- const incomingIDs = new Set((payload.sessions || []).map(s => s.id));
748
- for (let i = sessions.length - 1; i >= 0; i--) {
749
- const s = sessions[i];
750
- if (!incomingIDs.has(s.id) && !s.pinned) {
751
- sessions.splice(i, 1);
752
- sessionsMap.delete(s.id);
753
- }
754
- }
755
- for (const s of (payload.sessions || [])) {
756
- if (hiddenSessionIDs.has(s.id)) continue;
757
- let session = sessionsMap.get(s.id);
758
- if (!session) {
759
- session = {
760
- id: s.id, projectPath: s.projectPath, title: '',
761
- folder: folderName(s.projectPath), model: '',
762
- agents: [], tasks: [], collapsed: false, pinned: false,
763
- lastActivity: s.birthtimeMs || 0,
764
- birthtimeMs: s.birthtimeMs || 0,
765
- };
766
- sessions.push(session);
767
- sessionsMap.set(session.id, session);
768
- session.agents.push({ id: '', name: 'Main', type: 'main' });
769
- }
770
- for (const [aid, atype] of Object.entries(s.subagents || {})) {
771
- if (!session.agents.find(a => a.id === aid)) {
772
- session.agents.push({ id: aid, name: agentDisplayName(aid, atype), type: 'agent' });
773
- }
774
- }
775
- for (const t of (s.backgroundTasks || [])) {
776
- if (!session.tasks.find(ta => ta.id === t.id)) {
777
- session.tasks.push({
778
- id: t.id, parentAgentID: t.parentAgentID,
779
- toolName: t.toolName, outputPath: t.outputPath,
780
- isComplete: t.isComplete,
781
- });
782
- }
783
- }
784
- }
785
- // Initialize agentActivity from server-computed lastActivities (survives history skip)
786
- for (const [key, val] of Object.entries(payload.lastActivities || {})) {
787
- agentActivity.set(key, val);
788
- }
789
- updateFilters();
790
- rebuildNodes();
791
- needsFullRender = true;
792
- visibleDirty = true;
793
- // Don't render tree yet — wait for itemBatch to fill title/model/taskDescriptions
794
- }
795
-
796
- function handleNewSession(payload) {
797
- if (hiddenSessionIDs.has(payload.sessionID)) return;
798
- if (sessionsMap.has(payload.sessionID)) return;
799
- const session = {
800
- id: payload.sessionID, projectPath: payload.projectPath,
801
- title: '', folder: folderName(payload.projectPath), model: '',
802
- agents: [{ id: '', name: 'Main', type: 'main' }],
803
- tasks: [], collapsed: false, pinned: false,
804
- lastActivity: payload.birthtimeMs || Date.now(),
805
- birthtimeMs: payload.birthtimeMs || 0,
806
- };
807
- sessions.push(session);
808
- sessionsMap.set(session.id, session);
809
- updateFilters();
810
- rebuildNodes();
811
- needsFullRender = true;
812
- visibleDirty = true;
813
- scheduleRender();
814
- }
815
-
816
- function handleNewAgent(payload) {
817
- const s = sessionsMap.get(payload.sessionID);
818
- if (!s || s.agents.find(a => a.id === payload.agentID)) return;
819
- s.agents.push({
820
- id: payload.agentID,
821
- name: agentDisplayName(payload.agentID, payload.agentType),
822
- type: 'agent',
823
- });
824
- updateFilters();
825
- rebuildNodes();
826
- needsFullRender = true;
827
- visibleDirty = true;
828
- scheduleRender();
829
- }
830
-
831
- function handleNewBgTask(payload) {
832
- const s = sessionsMap.get(payload.sessionID);
833
- if (!s || s.tasks.find(t => t.id === payload.toolID)) return;
834
- s.tasks.push({
835
- id: payload.toolID, parentAgentID: payload.parentAgentID,
836
- toolName: payload.toolName, outputPath: payload.outputPath,
837
- isComplete: payload.isComplete,
838
- });
839
- rebuildNodes();
840
- scheduleRender();
841
- }
842
-
843
- function handleSessionRemoved(payload) {
844
- const sid = payload.sessionID;
845
- const s = sessionsMap.get(sid);
846
- if (s) {
847
- for (const a of s.agents) agentActivity.delete(sid + ':' + a.id);
848
- for (const t of s.tasks) taskDescriptions.delete(t.id);
849
- }
850
- const idx = sessions.findIndex(s => s.id === sid);
851
- if (idx >= 0) {
852
- sessions.splice(idx, 1);
853
- sessionsMap.delete(sid);
854
- }
855
- updateFilters();
856
- rebuildNodes();
857
- needsFullRender = true;
858
- visibleDirty = true;
859
- scheduleRender();
860
- }
861
-
862
- // ══════════════════════════════════════════════════════════════════════════════
863
- // Stream items
864
- // ══════════════════════════════════════════════════════════════════════════════
865
-
866
- function handleItem(item) {
867
- if (item.type === 'session_title') {
868
- const s = sessionsMap.get(item.sessionID);
869
- if (s) { s.title = item.content.slice(0, 30); }
870
- scheduleRender();
871
- return;
872
- }
873
- // Update activity
874
- const s = sessionsMap.get(item.sessionID);
875
- if (s) s.lastActivity = itemTime(item);
876
- pushItem(item);
877
- scheduleRender();
878
- }
879
-
880
- function handleItemBatch(items) {
881
- for (const item of items) {
882
- if (item.type === 'session_title') {
883
- const s = sessionsMap.get(item.sessionID);
884
- if (s) { s.title = item.content.slice(0, 30); }
885
- continue;
886
- }
887
- const s = sessionsMap.get(item.sessionID);
888
- if (s) s.lastActivity = itemTime(item);
889
- pushItem(item);
890
- }
891
- rebuildNodes();
892
- scheduleRender();
893
- }
894
-
895
- function pushItem(item) {
896
- if (hiddenSessionIDs.has(item.sessionID)) return;
897
- // Token counts are sourced exclusively from server context messages
898
- // to avoid divergence between frontend accumulation and server tracking
899
-
900
- if (item.model) {
901
- const s = sessionsMap.get(item.sessionID);
902
- if (s) s.model = item.model;
903
- }
904
-
905
- if (item.type === 'tool_input' && item.toolID && item.toolName) {
906
- toolNameMap.set(item.toolID, item.toolName);
907
- }
908
-
909
- if (item.type === 'tool_input') {
910
- if (item.agentID) {
911
- agentActivity.set(item.sessionID + ':' + item.agentID, { toolName: item.toolName || '', content: (item.content || '').slice(0, MAX_DESC_STORE) });
912
- }
913
- if (item.toolID) {
914
- taskDescriptions.set(item.toolID, (item.content || '').slice(0, MAX_DESC_STORE));
915
- }
916
- }
917
-
918
- if (item.type === 'user_text') {
919
- agentActivity.set(item.sessionID + ':' + (item.agentID || ''), { toolName: '', content: (item.content || '').slice(0, MAX_DESC_STORE) });
920
- }
921
-
922
- if (item.toolID) {
923
- const key = `${item.toolID}:${item.type}`;
924
- if (seenToolIDs.has(key)) return;
925
- seenToolIDs.set(key, true);
926
- }
927
-
928
- streamItems.push(item);
929
- if (streamItems.length > MAX_ITEMS) {
930
- streamItems = streamItems.slice(-MAX_ITEMS);
931
- visibleDirty = true;
932
- }
933
- // Incrementally update visibleItems — no need to re-filter on every item
934
- if (!visibleDirty && isItemVisible(item)) {
935
- visibleItems.push(item);
936
- }
937
- }
938
-
939
- function isItemVisible(item) {
940
- if (!filters.has(item.sessionID + ':' + (item.agentID || ''))) return false;
941
- switch (item.type) {
942
- case 'thinking': return showThinking;
943
- case 'tool_input': return showToolInput;
944
- case 'tool_output': return showToolOutput;
945
- case 'text': return showText;
946
- case 'hook_output': return showHook;
947
- case 'user_text': return showUserPrompt;
948
- default: return true;
949
- }
950
- }
951
-
952
- // ══════════════════════════════════════════════════════════════════════════════
953
- // Tree
954
- // ══════════════════════════════════════════════════════════════════════════════
955
-
956
- function idColor(rank) {
957
- const hue = (rank * 137.508) % 360;
958
- return `hsl(${hue}, 75%, 60%)`;
959
- }
960
-
961
- function rebuildNodes() {
962
- // Sort sessions by creation time, newest first
963
- sessions.sort((a, b) => (b.birthtimeMs || 0) - (a.birthtimeMs || 0));
964
- for (let i = 0; i < sessions.length; i++) sessions[i].colorRank = i;
965
-
966
- computeAgentIdDisplayLengths();
967
-
968
- const today = new Date();
969
- const todayStr = `${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
970
-
971
- const flatSessions = [];
972
- const olderByDate = new Map(); // dateStr -> [sessions]
973
-
974
- for (const s of sessions) {
975
- const dateStr = s.birthtimeMs ? formatTime(s.birthtimeMs).split(' ')[0] : null;
976
- if (!dateStr || dateStr === todayStr || isSessionActive(s)) {
977
- flatSessions.push(s);
978
- } else {
979
- if (!olderByDate.has(dateStr)) olderByDate.set(dateStr, []);
980
- olderByDate.get(dateStr).push(s);
981
- }
982
- }
983
-
984
- treeNodes = [];
985
-
986
- function addSessionWithChildren(s, inFolder) {
987
- treeNodes.push({ type: 'session', level: 0, isLast: false, inFolder: !!inFolder, ...s });
988
- if (s.collapsed) return;
989
- const agents = s.agents || [];
990
- const lastAgentIdx = agents.length - 1;
991
- for (let ai = 0; ai < agents.length; ai++) {
992
- const a = agents[ai];
993
- const isLastAgent = ai === lastAgentIdx;
994
- const tasks = s.tasks.filter(t =>
995
- (a.id === '' && !t.parentAgentID) || t.parentAgentID === a.id
996
- );
997
- const lastTaskIdx = tasks.length - 1;
998
- const hasTasks = tasks.length > 0;
999
- const actKey = s.id + ':' + a.id;
1000
- const act = agentActivity.get(actKey);
1001
- treeNodes.push({
1002
- type: a.type, id: a.id, name: a.name, sessionID: s.id,
1003
- level: 1, isLast: isLastAgent,
1004
- activityTool: act ? act.toolName : '',
1005
- activityDesc: act ? act.content : '',
1006
- });
1007
- for (let ti = 0; ti < tasks.length; ti++) {
1008
- const t = tasks[ti];
1009
- const tDesc = taskDescriptions.get(t.id);
1010
- treeNodes.push({
1011
- type: 'task', id: t.id, name: t.toolName,
1012
- sessionID: s.id, parentAgentID: t.parentAgentID,
1013
- outputPath: t.outputPath, isComplete: t.isComplete,
1014
- level: 2, isLast: ti === lastTaskIdx,
1015
- parentIsLast: isLastAgent,
1016
- description: tDesc || '',
1017
- });
1018
- }
1019
- }
1020
- }
1021
-
1022
- // Today's + active sessions (expanded, not in date folders)
1023
- for (const s of flatSessions) {
1024
- addSessionWithChildren(s, false);
1025
- }
1026
-
1027
- // Date folders (older dates, collapsed by default)
1028
- const sortedDates = [...olderByDate.keys()].sort((a, b) => b.localeCompare(a));
1029
- for (let di = 0; di < sortedDates.length; di++) {
1030
- const dateStr = sortedDates[di];
1031
- const folderSessions = olderByDate.get(dateStr);
1032
- const collapsed = folderCollapsed[dateStr] !== false; // default collapsed
1033
- const isLastFolder = di === sortedDates.length - 1;
1034
-
1035
- treeNodes.push({
1036
- type: 'date-folder', date: dateStr, level: 0, isLast: false,
1037
- collapsed, sessionCount: folderSessions.length,
1038
- });
1039
-
1040
- if (!collapsed) {
1041
- for (const s of folderSessions) {
1042
- addSessionWithChildren(s, true);
1043
- }
1044
- }
1045
- }
1046
-
1047
- // Mark last session among flat sessions
1048
- const flatSessionNodes = treeNodes.filter(n => n.type === 'session' && !n.inFolder);
1049
- if (flatSessionNodes.length > 0) flatSessionNodes[flatSessionNodes.length - 1].isLast = true;
1050
-
1051
- // Mark last session inside each folder
1052
- for (const dateStr of sortedDates) {
1053
- if (folderCollapsed[dateStr] !== false) continue;
1054
- // Find sessions belonging to this folder
1055
- const thisFolder = [];
1056
- let inThisFolder = false;
1057
- for (const n of treeNodes) {
1058
- if (n.type === 'date-folder' && n.date === dateStr) { inThisFolder = true; continue; }
1059
- if (n.type === 'date-folder' && n.date !== dateStr) { inThisFolder = false; continue; }
1060
- if (inThisFolder && n.type === 'session') thisFolder.push(n);
1061
- }
1062
- if (thisFolder.length > 0) thisFolder[thisFolder.length - 1].isLast = true;
1063
- }
1064
-
1065
- if (treeCursor >= treeNodes.length) treeCursor = Math.max(0, treeNodes.length - 1);
1066
- treeDirty = true;
1067
- }
1068
-
1069
- function treePrefix(node) {
1070
- if (node.level === 0) {
1071
- return node.inFolder ? ' ' : '';
1072
- }
1073
- const branch = node.isLast ? '└──' : '├──';
1074
- if (node.level === 1) return ' ' + branch;
1075
- // Level 2: use pre-computed parentIsLast from rebuildNodes
1076
- const parentIsLast = node.parentIsLast !== undefined ? node.parentIsLast : true;
1077
- const stem = parentIsLast ? ' ' : '│ ';
1078
- return ' ' + stem + branch;
1079
- }
1080
-
1081
- function getNodeHTML(node, idx) {
1082
- const isSelected = idx === treeCursor;
1083
- const selClass = isSelected ? ' selected' : '';
1084
-
1085
- if (node.type === 'date-folder') {
1086
- const icon = node.collapsed ? '▸' : '▾';
1087
- return `<div class="tree-row tree-row-folder${selClass ? ' selected' : ''}">
1088
- <div class="tree-content" onclick="treeClick(${idx})" data-idx="${idx}">
1089
- <div class="tree-node folder-node">
1090
- ${icon} 📁 ${node.date} <span style="font-size:10px;color:var(--dim);margin-left:4px">(${node.sessionCount})</span>
1091
- </div>
1092
- </div>
1093
- </div>`;
1094
- }
1095
-
1096
- if (node.type === 'session') {
1097
- const displayName = folderName(node.projectPath) || node.title || node.id.slice(0, 14);
1098
- const parts = [];
1099
- if (node.model) parts.push(`🧠 ${esc(node.model)}`);
1100
- const activeDot = isSessionActive(node) ? '<span class="active-dot on">🟢</span>' : '<span class="active-dot off">⚪</span>';
1101
- const subInfo = parts.length > 0 ? ` <span style="color:#6b7280;font-size:10px">${parts.join(' · ')}</span>` : '';
1102
- const agentCount = node.agents ? node.agents.filter(a => a.type === 'agent').length : 0;
1103
- const timeStr = formatTime(node.birthtimeMs);
1104
- const timeHtml = timeStr ? `<span style="margin-left:auto;font-size:10px;color:var(--dim);flex-shrink:0">${timeStr}</span>` : '';
1105
- return `<div class="tree-row tree-row-session${selClass ? ' selected' : ''}">
1106
- <div class="tree-content" onclick="treeClick(${idx})" data-idx="${idx}">
1107
- <div class="tree-node">
1108
- <span class="tree-prefix">${treePrefix(node)}</span>${activeDot} ${node.collapsed ? '▸' : '▾'} <span class="session-prefix" style="color:${idColor(node.colorRank)}" data-sid="${esc(node.id)}" onmouseenter="showSessionIdTip(this)" onmouseleave="hideSessionIdTip(this)">${esc(node.id.split('-')[0].toUpperCase())}</span> ${esc(displayName)}
1109
- ${node.collapsed && agentCount > 0 ? `(${esc(String(agentCount))})` : ''}
1110
- ${subInfo}
1111
- ${timeHtml}
1112
- </div>
1113
- </div>
1114
- <span class="tree-actions">
1115
- <button class="btn btn-icon accent" onclick="event.stopPropagation();selectIndex(${idx});soloSelected()" data-tooltip="Solo">⊙</button>
1116
- <button class="btn btn-icon danger" onclick="event.stopPropagation();selectIndex(${idx});removeSelectedSession()" data-tooltip="Remove">✕</button>
1117
- </span>
1118
- </div>`;
1119
- }
1120
-
1121
- if (node.type === 'main' || node.type === 'agent') {
1122
- const icon = node.type === 'main' ? '💬' : '🤖';
1123
- const enabled = filters.get(node.sessionID + ':' + node.id);
1124
- const ctxKey = node.sessionID + ':' + node.id;
1125
- const ctx = contextData[ctxKey];
1126
- let ctxPct = '';
1127
- if (ctx && ctx.contextWindow > 0 && ctx.inputTokens > 0) {
1128
- const pct = Math.round(ctx.inputTokens / ctx.contextWindow * 100);
1129
- const cls = pct > 80 ? 'danger' : pct > 50 ? 'warn' : '';
1130
- if (showTokenCount) {
1131
- ctxPct = `<span class="ctx-pct ${cls}">${fmtTok(ctx.inputTokens)}</span>`;
1132
- } else {
1133
- ctxPct = `<span class="ctx-pct ${cls}">${pct}%</span>`;
1134
- }
1135
- }
1136
- const activeDot = ctx && (Date.now() - ctx.lastActivity < 120000) ? '<span class="active-dot on">🟢</span>' : '<span class="active-dot off">⚪</span>';
1137
- const actIcon = node.type === 'main' ? '🗣' : '⚡';
1138
- const actText = showActivity && (node.activityTool || node.activityDesc)
1139
- ? (node.activityTool && node.activityDesc ? `${node.activityTool}: ${node.activityDesc}` : (node.activityTool || node.activityDesc))
1140
- : '';
1141
- const indent = treePrefix(node).replace(/[├└]──/, ' ');
1142
- const actPrefix = `<span class="tree-prefix">${indent}</span>`;
1143
- const activityHTML = actText
1144
- ? `<div class="tree-activity">${actPrefix}<span class="act-text">${actIcon} ${esc(actText)}</span></div>`
1145
- : '';
1146
- return `<div class="tree-row${selClass ? ' selected' : ''}">
1147
- <div class="tree-content${enabled ? '' : ' dim'}" onclick="treeClick(${idx})" data-idx="${idx}">
1148
- <div class="tree-node">
1149
- <span class="tree-prefix">${treePrefix(node)}</span>${activeDot} ${icon} ${esc(node.name || '')}${node.type === 'agent' && node.id ? '<span class="tree-agent-id">(' + esc(node.id.slice(0, agentIdDisplayLen.get(node.sessionID + ':' + node.id) || 7)) + ')</span>' : ''}${ctxPct}
1150
- </div>
1151
- ${activityHTML}
1152
- </div>
1153
- <span class="tree-actions">
1154
- <button class="btn btn-icon accent" onclick="event.stopPropagation();selectIndex(${idx});soloSelected()" data-tooltip="Solo">⊙</button>
1155
- <button class="btn btn-icon" onclick="event.stopPropagation();selectIndex(${idx});toggleNodeVisibility(${idx})" data-tooltip="${enabled ? 'Hide' : 'Show'}">${enabled ? '👁' : '─'}</button>
1156
- </span>
1157
- </div>`;
1158
- }
1159
-
1160
- if (node.type === 'task') {
1161
- const icon = node.isComplete ? '✓' : '⏳';
1162
- const taskIndent = treePrefix(node).replace(/[├└]──/, ' ');
1163
- const taskPrefix = `<span class="tree-prefix">${taskIndent}</span>`;
1164
- const descHTML = showActivity && node.description
1165
- ? `<div class="tree-activity">${taskPrefix}<span class="act-text">📋 ${esc(node.description)}</span></div>`
1166
- : '';
1167
- return `<div class="tree-row${selClass ? ' selected' : ''}">
1168
- <div class="tree-content dim" onclick="treeClick(${idx})" data-idx="${idx}">
1169
- <div class="tree-node">
1170
- <span class="tree-prefix">${treePrefix(node)}</span>${icon} ${esc(node.name || 'bg-task')}
1171
- </div>
1172
- ${descHTML}
1173
- </div>
1174
- <span class="tree-actions">
1175
- <button class="btn btn-icon" onclick="event.stopPropagation();selectIndex(${idx});loadBgTask(${idx})" data-tooltip="Load output">▶</button>
1176
- </span>
1177
- </div>`;
1178
- }
1179
-
1180
- return '';
1181
- }
1182
-
1183
- function renderTree() {
1184
- if (treeNodes.length === 0) {
1185
- treeEl.innerHTML = '<div class="tree-node" style="padding:8px;color:var(--dim)">Waiting for sessions...</div>';
1186
- treeCursorInfo.textContent = '';
1187
- return;
1188
- }
1189
-
1190
- const cursorChanged = treeCursor !== lastTreeCursor;
1191
- if (treeDirty) {
1192
- let html = '';
1193
- for (let i = 0; i < treeNodes.length; i++) {
1194
- html += getNodeHTML(treeNodes[i], i);
1195
- }
1196
- treeEl.innerHTML = html;
1197
- treeDirty = false;
1198
- } else if (cursorChanged) {
1199
- const prevSel = treeEl.querySelector('.tree-row.selected');
1200
- if (prevSel) prevSel.classList.remove('selected');
1201
- const newContent = treeEl.querySelector('[data-idx="' + treeCursor + '"]');
1202
- if (newContent) {
1203
- const row = newContent.closest('.tree-row');
1204
- if (row) row.classList.add('selected');
1205
- }
1206
- }
1207
- lastTreeCursor = treeCursor;
1208
-
1209
- // Scroll selected into view
1210
- const sel = treeEl.querySelector('.tree-row.selected');
1211
- if (sel) sel.scrollIntoView({ block: 'nearest' });
1212
-
1213
- treeCursorInfo.textContent = `${treeCursor + 1}/${treeNodes.length}`;
1214
- }
1215
-
1216
- function updateTreeDots() {
1217
- const dots = treeEl.querySelectorAll('.active-dot');
1218
- const now = Date.now();
1219
- for (const dot of dots) {
1220
- const content = dot.closest('.tree-content');
1221
- if (!content) continue;
1222
- const idx = parseInt(content.getAttribute('data-idx'));
1223
- if (isNaN(idx)) continue;
1224
- const node = treeNodes[idx];
1225
- if (!node) continue;
1226
- let active = false;
1227
- if (node.type === 'session') {
1228
- active = isSessionActive(node);
1229
- } else if (node.type === 'main' || node.type === 'agent') {
1230
- const ctxKey = node.sessionID + ':' + node.id;
1231
- const ctx = contextData[ctxKey];
1232
- const threshold = node.type === 'main' ? 600000 : 180000;
1233
- active = ctx && (now - ctx.lastActivity < threshold);
1234
- }
1235
- const newCls = active ? 'active-dot on' : 'active-dot off';
1236
- const newHTML = active ? '🟢' : '⚪';
1237
- if (dot.className !== newCls) {
1238
- dot.className = newCls;
1239
- dot.innerHTML = newHTML;
1240
- }
1241
- }
1242
- }
1243
-
1244
- const ACTIVE_THRESHOLD = 600000; // 10 minutes
1245
-
1246
- function itemTime(item) {
1247
- if (item && item.timestamp) {
1248
- const ts = item.timestamp instanceof Date ? item.timestamp : new Date(item.timestamp);
1249
- if (!isNaN(ts.getTime())) return ts.getTime();
1250
- }
1251
- return Date.now();
1252
- }
1253
-
1254
- function isSessionActive(session) {
1255
- if (!session) return false;
1256
- const now = Date.now();
1257
- // Main agent: 10 minutes
1258
- const mainCtx = contextData[session.id + ':'];
1259
- if (mainCtx && (now - mainCtx.lastActivity) < 600000) return true;
1260
- // Subagents: 3 minutes
1261
- for (const a of session.agents) {
1262
- if (a.id === '') continue;
1263
- const ctx = contextData[session.id + ':' + a.id];
1264
- if (ctx && (now - ctx.lastActivity) < 180000) return true;
1265
- }
1266
- // Session fallback: 10 minutes
1267
- return (now - session.lastActivity) < 600000;
1268
- }
1269
-
1270
- // ══════════════════════════════════════════════════════════════════════════════
1271
- // Stream rendering
1272
- // ══════════════════════════════════════════════════════════════════════════════
1273
-
1274
- function renderStream() {
1275
- // Rebuild visibleItems from scratch only when filters/toggles changed
1276
- if (visibleDirty) {
1277
- visibleItems = streamItems.filter(isItemVisible);
1278
- visibleDirty = false;
1279
- }
1280
-
1281
- const visible = visibleItems;
1282
- const wasAutoScroll = autoScroll;
1283
-
1284
- if (needsFullRender || renderedItemCount > visible.length) {
1285
- // Full rebuild: filter changed, items trimmed, or initial render
1286
- const lines = [];
1287
- for (const item of visible) {
1288
- for (const l of renderItem(item)) lines.push(l);
1289
- }
1290
-
1291
- let html;
1292
- if (lines.length > 0) {
1293
- html = lines.map(l => {
1294
- const sidAttr = l.sessionID ? ` data-session-id="${esc(l.sessionID)}"` : '';
1295
- if (l.html) return `<div class="${esc(l.cls)}"${sidAttr}>${l.text}</div>`;
1296
- return `<div class="${esc(l.cls)}"${sidAttr}>${esc(l.text)}</div>`;
1297
- }).join('\n');
1298
- } else if (streamItems.length > 0) {
1299
- html = `<div style="color:#fbbf24;padding:20px;text-align:center">${streamItems.length} items buffered, 0 visible — check toggles or tree selection</div>`;
1300
- } else {
1301
- html = '<div style="color:#6b7280;padding:20px;text-align:center">Waiting for output...</div>';
1302
- }
1303
-
1304
- streamEl.innerHTML = html;
1305
- renderedItemCount = visible.length;
1306
- needsFullRender = false;
1307
- if (wasAutoScroll) requestAnimationFrame(() => { streamEl.scrollTop = streamEl.scrollHeight; });
1308
- } else {
1309
- // Incremental append: only add new items since last render
1310
- for (let i = renderedItemCount; i < visible.length; i++) {
1311
- for (const l of renderItem(visible[i])) {
1312
- const div = document.createElement('div');
1313
- div.className = l.cls;
1314
- if (l.sessionID) div.dataset.sessionId = l.sessionID;
1315
- div.innerHTML = l.html ? l.text : esc(l.text);
1316
- streamEl.appendChild(div);
1317
- }
1318
- }
1319
- renderedItemCount = visible.length;
1320
- if (autoScroll) requestAnimationFrame(() => { streamEl.scrollTop = streamEl.scrollHeight; });
1321
- }
1322
-
1323
- const maxScroll = streamEl.scrollHeight - streamEl.clientHeight;
1324
- const pct = maxScroll > 0 ? Math.round(streamEl.scrollTop / maxScroll * 100) : 0;
1325
- document.getElementById('scroll-pos').textContent = Math.min(100, pct) + '%';
1326
- document.getElementById('item-count').textContent = streamItems.length + ' items';
1327
- }
1328
-
1329
- function renderItem(item) {
1330
- const lines = [];
1331
- const isSub = !!item.agentID;
1332
- const agentTagCls = 'stream-line ' + (isSub ? 'agent-sub agent-tag' : 'agent-main agent-tag');
1333
- const sep = ' » ';
1334
- const sid = item.sessionID || '';
1335
-
1336
- if (item.type === 'turn_marker') {
1337
- return [{ cls: 'stream-line marker', text: `── turn ended ${fmtDur(item.durationMs)} ──`, sessionID: sid }];
1338
- }
1339
- if (item.type === 'compact_marker') {
1340
- const label = item.content ? `compacted (${item.content})` : 'compacted';
1341
- return [{ cls: 'stream-line marker', text: `── ${label} ──`, sessionID: sid }];
1342
- }
1343
- if (item.type === 'pr_link') {
1344
- return [{ cls: 'stream-line marker', text: `── ${item.content} ──`, sessionID: sid }];
1345
- }
1346
-
1347
- const agentName = item.agentName || 'Main';
1348
- const sForColor = sessionsMap.get(item.sessionID);
1349
- const prefixTag = `<span class="session-prefix" style="color:${idColor(sForColor ? sForColor.colorRank : 0)}">[${esc(item.sessionID.split('-')[0].toUpperCase())}]</span>`;
1350
- const agentIdTag = item.agentID ? `<span class="session-prefix" style="color:var(--dim)">(</span><span class="session-prefix" style="color:var(--magenta)">${esc(item.agentID.slice(0, agentIdDisplayLen.get(item.sessionID + ':' + item.agentID) || 7))}</span><span class="session-prefix" style="color:var(--dim)">)</span>` : '';
1351
- const agentLabel = prefixTag + agentIdTag + ' ' + esc(agentName);
1352
- const tsHtml = item.timestamp ? `<span class="timestamp">${fmtTimestamp(item.timestamp)}</span>` : '';
1353
-
1354
- switch (item.type) {
1355
- case 'thinking':
1356
- lines.push({ cls: agentTagCls, text: `<span class="tag-label">${agentLabel}${sep}🧠 Thinking</span>${tsHtml}`, html: true, sessionID: sid });
1357
- for (const l of truncContent(item.content)) lines.push({ cls: 'stream-line thinking', text: l, sessionID: sid });
1358
- break;
1359
- case 'tool_input':
1360
- lines.push({ cls: agentTagCls, text: `<span class="tag-label">${agentLabel}${sep}🔧 ${esc(item.toolName || '')}</span>${tsHtml}`, html: true, sessionID: sid });
1361
- for (const l of truncContent(item.content)) lines.push({ cls: 'stream-line tool-input', text: l, sessionID: sid });
1362
- break;
1363
- case 'tool_output': {
1364
- let tn = '';
1365
- if (item.toolID) {
1366
- tn = toolNameMap.get(item.toolID) || '';
1367
- }
1368
- let label = tn ? `📤 ${tn} result` : '📤 Output';
1369
- if (item.durationMs > 0) label += ' ' + fmtDur(item.durationMs);
1370
- lines.push({ cls: agentTagCls, text: `<span class="tag-label">${agentLabel}${sep}${esc(label)}</span>${tsHtml}`, html: true, sessionID: sid });
1371
- for (const l of truncContent(item.content)) lines.push({ cls: 'stream-line tool-output', text: l, sessionID: sid });
1372
- break;
1373
- }
1374
- case 'text':
1375
- lines.push({ cls: agentTagCls, text: `<span class="tag-label">${agentLabel}${sep}💬 Response</span>${tsHtml}`, html: true, sessionID: sid });
1376
- lines.push({ cls: 'stream-line text md-content', text: mdRender(item.content), html: true, sessionID: sid });
1377
- break;
1378
- case 'hook_output': {
1379
- let label = '🪝 Hook';
1380
- if (item.toolName) label += ' ' + item.toolName;
1381
- if (item.durationMs > 0) label += ' ' + fmtDur(item.durationMs);
1382
- lines.push({ cls: agentTagCls, text: `<span class="tag-label">${agentLabel}${sep}${esc(label)}</span>${tsHtml}`, html: true, sessionID: sid });
1383
- if (item.hookCommand) lines.push({ cls: 'stream-line hook', text: `<span class="hook-label">command:</span> ${esc(item.hookCommand)}`, html: true, sessionID: sid });
1384
- if (item.hookContent) {
1385
- 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 });
1386
- }
1387
- 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 });
1388
- break;
1389
- }
1390
- case 'diagnostics': {
1391
- let label = '⚠ Diagnostics';
1392
- if (item.toolName) label += ' ' + item.toolName;
1393
- lines.push({ cls: agentTagCls, text: `<span class="tag-label">${agentLabel}${sep}${esc(label)}</span>${tsHtml}`, html: true, sessionID: sid });
1394
- for (const l of truncContent(item.content)) lines.push({ cls: 'stream-line diag', text: l, sessionID: sid });
1395
- break;
1396
- }
1397
- case 'debug': {
1398
- let label = '🔍 Debug';
1399
- if (item.toolName) label += ' ' + item.toolName;
1400
- lines.push({ cls: agentTagCls, text: `<span class="tag-label">${agentLabel}${sep}${esc(label)}</span>${tsHtml}`, html: true, sessionID: sid });
1401
- for (const l of truncContent(item.content)) lines.push({ cls: 'stream-line debug', text: l, sessionID: sid });
1402
- break;
1403
- }
1404
- case 'user_text':
1405
- lines.push({ cls: agentTagCls, text: `<span class="tag-label">${agentLabel}${sep}👤 User Prompt</span>${tsHtml}`, html: true, sessionID: sid });
1406
- lines.push({ cls: 'stream-line user-prompt-block md-content', text: mdRender(item.content), html: true, sessionID: sid });
1407
- break;
1408
- }
1409
-
1410
- lines.push({ cls: 'stream-line separator', text: '─'.repeat(60), sessionID: sid });
1411
- return lines;
1412
- }
1413
-
1414
- function truncContent(content) {
1415
- const raw = content.split('\n');
1416
- return raw.length > MAX_LINES ? raw.slice(0, MAX_LINES).concat([`... (${raw.length - MAX_LINES} more lines)`]) : raw;
1417
- }
1418
-
1419
- // ══════════════════════════════════════════════════════════════════════════════
1420
- // Button / header refresh
1421
- // ══════════════════════════════════════════════════════════════════════════════
1422
-
1423
- function refreshButtons() {
1424
- document.getElementById('btn-thinking').classList.toggle('on', showThinking);
1425
- document.getElementById('btn-tool-input').classList.toggle('on', showToolInput);
1426
- document.getElementById('btn-tool-output').classList.toggle('on', showToolOutput);
1427
- document.getElementById('btn-text').classList.toggle('on', showText);
1428
- document.getElementById('btn-hook').classList.toggle('on', showHook);
1429
- document.getElementById('btn-user-prompt').classList.toggle('on', showUserPrompt);
1430
- document.getElementById('btn-activity').classList.toggle('on', showActivity);
1431
- const btnTokenDisplay = document.getElementById('btn-token-display');
1432
- btnTokenDisplay.classList.toggle('on', true);
1433
- btnTokenDisplay.textContent = showTokenCount ? 'T' : '%';
1434
- btnTokenDisplay.setAttribute('data-tooltip', showTokenCount ? '上下文:Token数 ↔ 百分比切换' : '上下文:百分比 ↔ Token数切换');
1435
- document.getElementById('btn-autoscroll').classList.toggle('on', autoScroll);
1436
- document.getElementById('btn-tree-toggle').classList.toggle('on', showTree);
1437
- document.getElementById('btn-autodisco').classList.toggle('on', autoDiscovery);
1438
-
1439
- // Session info
1440
- let info = '';
1441
- if (sessions.length === 0) info = 'Waiting...';
1442
- else if (sessions.length === 1) {
1443
- const s = sessions[0];
1444
- info = (folderName(s.projectPath) || s.title || s.id.slice(0, 14));
1445
- } else info = sessions.length + ' sessions';
1446
- if (!autoDiscovery) info += ' [paused]';
1447
- sessionInfo.textContent = info;
1448
-
1449
- // Token info
1450
- computeTokensFromContext();
1451
- let tokStr = '';
1452
- if (totalInput > 0 || totalOutput > 0) {
1453
- tokStr = `${fmtTok(totalInput)} in / ${fmtTok(totalOutput)} out`;
1454
- if (totalCacheCreate > 0 || totalCacheRead > 0) {
1455
- tokStr += ` · cache ${fmtTok(totalCacheCreate)}+${fmtTok(totalCacheRead)}`;
1456
- }
1457
- }
1458
- tokenInfo.textContent = tokStr;
1459
-
1460
- // Footer version
1461
- const vEl = document.getElementById('footer-version');
1462
- if (vEl) {
1463
- const v = appVersion ? `v${appVersion}` : '';
1464
- vEl.innerHTML = `${v ? v + ' · ' : ''}<a href="https://github.com/shuxuecode/claude-watch" target="_blank" rel="noopener" style="color:var(--dim);display:inline-flex;align-items:center;gap:3px"><svg viewBox="0 0 16 16" width="12" height="12" fill="currentColor" style="vertical-align:middle"><path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"/></svg>claude-watch</a>`;
1465
- }
1466
- }
1467
-
1468
- // ══════════════════════════════════════════════════════════════════════════════
1469
- // Session ID tooltip
1470
- // ══════════════════════════════════════════════════════════════════════════════
1471
-
1472
- let sessionIdTipTimer = null;
1473
- let sessionIdTipEl = null;
1474
- function showSessionIdTip(el) {
1475
- hideAllSessionIdTips();
1476
- const sid = el.getAttribute('data-sid');
1477
- if (!sid) return;
1478
- sessionIdTipTimer = setTimeout(() => {
1479
- const rect = el.getBoundingClientRect();
1480
- const tip = document.createElement('div');
1481
- tip.className = 'session-id-tip';
1482
- tip.style.top = (rect.bottom + 4) + 'px';
1483
- tip.style.left = rect.left + 'px';
1484
- tip.innerHTML = `<button class="tip-copy-btn" onclick="event.stopPropagation();copySessionId(this)">Copy</button><code>${esc(sid)}</code>`;
1485
- tip.onmouseenter = () => clearTimeout(sessionIdTipTimer);
1486
- tip.onmouseleave = () => { hideAllSessionIdTips(); };
1487
- document.body.appendChild(tip);
1488
- sessionIdTipEl = tip;
1489
- el._tip = tip;
1490
- }, 300);
1491
- }
1492
- function hideSessionIdTip(el) {
1493
- sessionIdTipTimer = setTimeout(() => {
1494
- if (el._tip) { el._tip.remove(); el._tip = null; }
1495
- sessionIdTipEl = null;
1496
- }, 200);
1497
- }
1498
- function hideAllSessionIdTips() {
1499
- clearTimeout(sessionIdTipTimer);
1500
- document.querySelectorAll('.session-id-tip').forEach(t => t.remove());
1501
- sessionIdTipEl = null;
1502
- }
1503
- function copySessionId(btn) {
1504
- const code = btn.parentElement.querySelector('code');
1505
- if (!code) return;
1506
- navigator.clipboard.writeText(code.textContent).then(() => {
1507
- btn.textContent = 'Copied!';
1508
- setTimeout(() => { btn.closest('.session-id-tip')?.remove(); }, 800);
1509
- });
1510
- }
1511
-
1512
- // ══════════════════════════════════════════════════════════════════════════════
1513
- // Actions
1514
- // ══════════════════════════════════════════════════════════════════════════════
1515
-
1516
- function selectIndex(idx) {
1517
- if (idx >= 0 && idx < treeNodes.length) treeCursor = idx;
1518
- }
1519
-
1520
- function treeClick(idx) {
1521
- selectIndex(idx);
1522
- const node = treeNodes[idx];
1523
- if (!node) return;
1524
- if (node.type === 'date-folder') {
1525
- node.collapsed = !node.collapsed;
1526
- folderCollapsed[node.date] = node.collapsed;
1527
- rebuildNodes();
1528
- } else if (node.type === 'session') {
1529
- const session = sessions.find(s => s.id === node.id);
1530
- if (session) {
1531
- session.collapsed = !session.collapsed;
1532
- if (!session.collapsed) session.pinned = true;
1533
- }
1534
- rebuildNodes();
1535
- } else if (node.type === 'main' || node.type === 'agent') {
1536
- toggleNodeVisibility(idx);
1537
- return;
1538
- } else if (node.type === 'task') {
1539
- loadBgTask(idx);
1540
- return;
1541
- }
1542
- renderAll();
1543
- }
1544
-
1545
- function toggleNodeVisibility(idx) {
1546
- const node = treeNodes[idx];
1547
- if (!node) return;
1548
- const key = node.sessionID + ':' + node.id;
1549
- const wasEnabled = filters.get(key);
1550
- filters.set(key, !wasEnabled);
1551
- if (wasEnabled) visibleFilterCount--;
1552
- else visibleFilterCount++;
1553
- renderAll();
1554
- }
1555
-
1556
- function loadBgTask(idx) {
1557
- const node = treeNodes[idx];
1558
- if (!node || node.type !== 'task') return;
1559
- if (!node.outputPath) return;
1560
-
1561
- // Fetch the actual output file
1562
- fetch(`/api/task-output?path=${encodeURIComponent(node.outputPath)}`)
1563
- .then(r => r.json())
1564
- .then(data => {
1565
- const content = data.content || `[Error: ${data.error || 'unknown'}]`;
1566
- const statusIcon = node.isComplete ? '✓' : '⏳';
1567
- streamItems.push({
1568
- type: 'tool_output', sessionID: node.sessionID, agentID: node.parentAgentID || '',
1569
- agentName: '', toolName: `${statusIcon} ${node.name || 'bg-task'}`,
1570
- content: content,
1571
- timestamp: new Date(), toolID: '', durationMs: 0,
1572
- inputTokens: 0, outputTokens: 0, cacheCreationTokens: 0, cacheReadTokens: 0, model: '',
1573
- });
1574
- renderAll();
1575
- })
1576
- .catch(err => {
1577
- streamItems.push({
1578
- type: 'tool_output', sessionID: node.sessionID, agentID: node.parentAgentID || '',
1579
- agentName: '', toolName: `⏳ ${node.name || 'bg-task'}`,
1580
- content: `[Failed to load: ${err.message}]`,
1581
- timestamp: new Date(), toolID: '', durationMs: 0,
1582
- inputTokens: 0, outputTokens: 0, cacheCreationTokens: 0, cacheReadTokens: 0, model: '',
1583
- });
1584
- renderAll();
1585
- });
1586
- }
1587
-
1588
- function soloSelected() {
1589
- const node = treeNodes[treeCursor];
1590
- if (!node || node.type === 'task') return;
1591
-
1592
- if (isSoloed(node)) {
1593
- updateFilters();
1594
- } else {
1595
- filters.clear();
1596
- visibleFilterCount = 0;
1597
- if (node.type === 'session') {
1598
- const session = sessions.find(s => s.id === node.id);
1599
- if (session && session.collapsed) {
1600
- session.collapsed = false;
1601
- session.pinned = true;
1602
- rebuildNodes();
1603
- }
1604
- for (const a of node.agents) {
1605
- filters.set(node.id + ':' + a.id, true);
1606
- visibleFilterCount++;
1607
- }
1608
- } else if (node.type === 'main' || node.type === 'agent') {
1609
- filters.set(node.sessionID + ':' + node.id, true);
1610
- visibleFilterCount = 1;
1611
- }
1612
- }
1613
- renderAll();
1614
- }
1615
-
1616
- function isSoloed(node) {
1617
- if (node.type === 'session') {
1618
- if (visibleFilterCount !== node.agents.length) return false;
1619
- for (const a of node.agents) {
1620
- if (!filters.get(node.id + ':' + a.id)) return false;
1621
- }
1622
- return true;
1623
- }
1624
- if (node.type === 'main' || node.type === 'agent') {
1625
- const key = node.sessionID + ':' + node.id;
1626
- return visibleFilterCount === 1 && filters.get(key);
1627
- }
1628
- return false;
1629
- }
1630
-
1631
- function selectAll() {
1632
- updateFilters();
1633
- renderAll();
1634
- }
1635
-
1636
- function removeSelectedSession() {
1637
- const node = treeNodes[treeCursor];
1638
- if (!node) return;
1639
- let sid;
1640
- if (node.type === 'session') sid = node.id;
1641
- else sid = node.sessionID;
1642
- if (!sid) return;
1643
- if (!confirm(`Remove session ${sid.slice(0, 12)}...?`)) return;
1644
- hiddenSessionIDs.add(sid);
1645
- _saveHiddenSessions();
1646
- const idx = sessions.findIndex(s => s.id === sid);
1647
- if (idx >= 0) {
1648
- sessions.splice(idx, 1);
1649
- sessionsMap.delete(sid);
1650
- }
1651
- sendCmd('removeSession', { sessionID: sid });
1652
- updateFilters();
1653
- rebuildNodes();
1654
- renderAll();
1655
- }
1656
-
1657
- // ══════════════════════════════════════════════════════════════════════════════
1658
- // Toggles
1659
- // ══════════════════════════════════════════════════════════════════════════════
1660
-
1661
- function toggleThinking() { showThinking = !showThinking; needsFullRender = true;
1662
- visibleDirty = true; renderStream(); refreshButtons(); }
1663
- function toggleToolInput() { showToolInput = !showToolInput; needsFullRender = true;
1664
- visibleDirty = true; renderStream(); refreshButtons(); }
1665
- function toggleToolOutput() { showToolOutput = !showToolOutput; needsFullRender = true;
1666
- visibleDirty = true; renderStream(); refreshButtons(); }
1667
- function toggleText() { showText = !showText; needsFullRender = true;
1668
- visibleDirty = true; renderStream(); refreshButtons(); }
1669
- function toggleHook() { showHook = !showHook; needsFullRender = true;
1670
- visibleDirty = true; renderStream(); refreshButtons(); }
1671
- function toggleUserPrompt() { showUserPrompt = !showUserPrompt; needsFullRender = true;
1672
- visibleDirty = true; renderStream(); refreshButtons(); }
1673
- function toggleActivity() { showActivity = !showActivity; rebuildNodes(); scheduleRender(); refreshButtons(); }
1674
- function toggleTokenDisplay() {
1675
- showTokenCount = !showTokenCount;
1676
- treeDirty = true;
1677
- scheduleRender();
1678
- refreshButtons();
1679
- }
1680
-
1681
- // ══════════════════════════════════════════════════════════════════════════════
1682
- // Tab switching & Token page
1683
- // ══════════════════════════════════════════════════════════════════════════════
1684
-
1685
- function switchTab(tab) {
1686
- currentTab = tab;
1687
- document.getElementById('main').style.display = tab === 'stream' ? 'flex' : 'none';
1688
- document.getElementById('tokens-page').style.display = tab === 'tokens' ? 'flex' : 'none';
1689
- document.getElementById('tab-stream').classList.toggle('on', tab === 'stream');
1690
- document.getElementById('tab-tokens').classList.toggle('on', tab === 'tokens');
1691
- // footer 只在 stream 模式下有意义
1692
- document.getElementById('footer').style.display = tab === 'stream' ? 'flex' : 'none';
1693
- if (tab === 'tokens') renderTokenPage();
1694
- }
1695
-
1696
- let tokenTableVisible = false;
1697
- function toggleTokenTable() {
1698
- tokenTableVisible = !tokenTableVisible;
1699
- document.getElementById('token-table-wrap').style.display = tokenTableVisible ? 'block' : 'none';
1700
- document.getElementById('token-table-toggle').textContent = '明细表格 ' + (tokenTableVisible ? '▾' : '▸');
1701
- }
1702
-
1703
- function renderTokenPage() {
1704
- computeTokensFromContext();
1705
- const entries = Object.entries(contextData);
1706
- if (entries.length === 0) {
1707
- document.getElementById('token-overview-grid').innerHTML = '<div style="color:var(--dim);padding:8px">暂无 Token 数据</div>';
1708
- document.getElementById('token-agent-cards').innerHTML = '';
1709
- document.getElementById('token-table-body').innerHTML = '';
1710
- return;
1711
- }
1712
-
1713
- // ── Overview card ──
1714
- const overviewGrid = document.getElementById('token-overview-grid');
1715
- const overviewItems = [
1716
- { label: 'Input Tokens', value: fmtTok(totalInput), pct: null },
1717
- { label: 'Output Tokens', value: fmtTok(totalOutput), pct: null },
1718
- { label: 'Cache Creation', value: fmtTok(totalCacheCreate), pct: null },
1719
- { label: 'Cache Read', value: fmtTok(totalCacheRead), pct: null },
1720
- { label: 'I/O Ratio', value: totalOutput > 0 ? (totalInput / totalOutput).toFixed(1) + ' : 1' : '—', pct: null },
1721
- ];
1722
- overviewGrid.innerHTML = overviewItems.map(it =>
1723
- `<div class="token-item"><span class="token-label">${it.label}</span><span class="token-value">${it.value}</span></div>`
1724
- ).join('');
1725
-
1726
- // ── Agent cards ──
1727
- const agentCardsEl = document.getElementById('token-agent-cards');
1728
- // Sort by lastActivity descending (active first)
1729
- const sorted = entries.sort((a, b) => (b[1].lastActivity || 0) - (a[1].lastActivity || 0));
1730
- agentCardsEl.innerHTML = sorted.map(([key, ctx]) => {
1731
- const [sid, agentId] = key.split(':');
1732
- const isMain = agentId === 'main' || !agentId.includes('-');
1733
- const icon = isMain ? '🗣' : '🤖';
1734
- const agentName = isMain ? 'Main' : agentId;
1735
- const active = ctx.lastActivity && (Date.now() - ctx.lastActivity < 180000);
1736
- const activeDot = active ? '<span class="token-active-dot">🟢</span>' : '<span class="token-active-dot">⚪</span>';
1737
-
1738
- const pct = ctx.contextWindow > 0 ? Math.round(ctx.inputTokens / ctx.contextWindow * 100) : 0;
1739
- const pctCls = pct > 80 ? 'danger' : pct > 50 ? 'warn' : '';
1740
- const barCls = pct > 80 ? 'danger' : pct > 50 ? 'warn' : '';
1741
- const barWidth = Math.min(pct, 100);
1742
-
1743
- const ioRatio = ctx.outputTokens > 0 ? (ctx.inputTokens / ctx.outputTokens).toFixed(1) + ' : 1' : '—';
1744
-
1745
- return `<div class="token-card">
1746
- <div class="token-card-title">${icon} ${esc(agentName)} ${ctx.model ? '· ' + esc(ctx.model) : ''} ${activeDot}</div>
1747
- <div class="token-usage-line">
1748
- <span class="token-pct ${pctCls}">${pct}%</span>
1749
- <span class="token-ctx-info">${fmtTok(ctx.inputTokens)} / ${fmtTok(ctx.contextWindow)}</span>
1750
- </div>
1751
- <div class="token-bar"><div class="token-bar-fill ${barCls}" style="width:${barWidth}%"></div></div>
1752
- <div class="token-detail-row">
1753
- <span class="token-kv">Output: <b>${fmtTok(ctx.outputTokens)}</b></span>
1754
- <span class="token-kv">Cache+: <b>${fmtTok(ctx.cacheCreation)}</b></span>
1755
- <span class="token-kv">Cache Read: <b>${fmtTok(ctx.cacheRead)}</b></span>
1756
- <span class="token-kv">I/O: <b>${ioRatio}</b></span>
1757
- </div>
1758
- </div>`;
1759
- }).join('');
1760
-
1761
- // ── Detail table ──
1762
- const tbody = document.getElementById('token-table-body');
1763
- tbody.innerHTML = sorted.map(([key, ctx]) => {
1764
- const [sid, agentId] = key.split(':');
1765
- const agentName = (agentId === 'main' || !agentId.includes('-')) ? 'Main' : agentId;
1766
- const pct = ctx.contextWindow > 0 ? (ctx.inputTokens / ctx.contextWindow * 100).toFixed(1) + '%' : '—';
1767
- const ioRatio = ctx.outputTokens > 0 ? (ctx.inputTokens / ctx.outputTokens).toFixed(1) : '—';
1768
- return `<tr>
1769
- <td>${esc(agentName)}</td>
1770
- <td>${esc(ctx.model || '—')}</td>
1771
- <td>${ctx.inputTokens}</td>
1772
- <td>${ctx.outputTokens}</td>
1773
- <td>${ctx.cacheCreation}</td>
1774
- <td>${ctx.cacheRead}</td>
1775
- <td>${ctx.contextWindow}</td>
1776
- <td>${pct}</td>
1777
- <td>${ioRatio}</td>
1778
- </tr>`;
1779
- }).join('');
1780
- }
1781
- function toggleAutoScroll() { autoScroll = !autoScroll; if (autoScroll) streamEl.scrollTop = streamEl.scrollHeight; renderAll(); }
1782
- function toggleTree() { showTree = !showTree; document.getElementById('tree-panel').classList.toggle('hidden', !showTree); }
1783
- function toggleAutoDiscovery() { sendCmd('toggleAutoDiscovery'); }
1784
-
1785
- function scrollToTop() { streamEl.scrollTop = 0; autoScroll = false; renderAll(); }
1786
- function scrollUp() { streamEl.scrollTop -= 80; autoScroll = false; renderAll(); }
1787
- function scrollDown() { streamEl.scrollTop += 80; if (autoScroll) autoScroll = false; renderAll(); }
1788
- function scrollToBottom() { streamEl.scrollTop = streamEl.scrollHeight; autoScroll = true; renderAll(); }
1789
-
1790
- // ══════════════════════════════════════════════════════════════════════════════
1791
- // Tree panel resize
1792
- // ══════════════════════════════════════════════════════════════════════════════
1793
-
1794
- (function setupResize() {
1795
- const panel = document.getElementById('tree-panel');
1796
- const handle = document.getElementById('tree-resize-handle');
1797
- let startX, startWidth;
1798
-
1799
- handle.addEventListener('mousedown', (e) => {
1800
- e.preventDefault();
1801
- startX = e.clientX;
1802
- startWidth = panel.offsetWidth;
1803
- handle.classList.add('active');
1804
- document.body.style.cursor = 'col-resize';
1805
- document.body.style.userSelect = 'none';
1806
- });
1807
-
1808
- document.addEventListener('mousemove', (e) => {
1809
- if (!handle.classList.contains('active')) return;
1810
- const dx = e.clientX - startX;
1811
- const newWidth = startWidth + dx;
1812
- if (newWidth >= 180 && newWidth <= window.innerWidth * 0.6) {
1813
- panel.style.width = newWidth + 'px';
1814
- }
1815
- });
1816
-
1817
- document.addEventListener('mouseup', () => {
1818
- handle.classList.remove('active');
1819
- document.body.style.cursor = '';
1820
- document.body.style.userSelect = '';
1821
- });
1822
- })();
1823
-
1824
- // ══════════════════════════════════════════════════════════════════════════════
1825
- // Auto-collapse
1826
- // ══════════════════════════════════════════════════════════════════════════════
1827
-
1828
- function applyCollapsePolicy(duration) {
1829
- collapseAfter = duration;
1830
- if (collapseTimer) clearInterval(collapseTimer);
1831
- if (duration <= 0) return;
1832
-
1833
- collapseTimer = setInterval(() => {
1834
- if (!collapseAfter) return;
1835
- const now = Date.now();
1836
- let changed = false;
1837
- for (const s of sessions) {
1838
- if (s.pinned || s.collapsed) continue;
1839
- if ((now - s.lastActivity) > collapseAfter) {
1840
- s.collapsed = true;
1841
- changed = true;
1842
- }
1843
- }
1844
- if (changed) {
1845
- rebuildNodes();
1846
- renderAll();
1847
- }
1848
- }, 5000);
1849
- }
1850
-
1851
- function startActiveRefresh() {
1852
- if (activeRefreshTimer) clearInterval(activeRefreshTimer);
1853
- activeRefreshTimer = setInterval(() => {
1854
- updateTreeDots();
1855
- refreshButtons();
1856
- }, 15000);
1857
- }
1858
-
1859
- // ══════════════════════════════════════════════════════════════════════════════
1860
- // Scroll detection
1861
- // ══════════════════════════════════════════════════════════════════════════════
1862
-
1863
- streamEl.addEventListener('scroll', () => {
1864
- const atBottom = streamEl.scrollHeight - streamEl.scrollTop - streamEl.clientHeight < 50;
1865
- if (atBottom && !autoScroll) autoScroll = true;
1866
- if (!atBottom && autoScroll) autoScroll = false;
1867
- refreshButtons();
1868
- });
1869
-
1870
- // ══════════════════════════════════════════════════════════════════════════════
1871
- // Helpers
1872
- // ══════════════════════════════════════════════════════════════════════════════
1873
-
1874
- function updateFilters() {
1875
- filters.clear();
1876
- visibleFilterCount = 0;
1877
- for (const s of sessions) {
1878
- for (const a of s.agents) {
1879
- filters.set(s.id + ':' + a.id, true);
1880
- visibleFilterCount++;
1881
- }
1882
- }
1883
- }
1884
-
1885
- function agentDisplayName(id, type) {
1886
- if (type) {
1887
- const idx = type.lastIndexOf(':');
1888
- if (idx >= 0 && idx < type.length - 1) return type.slice(idx + 1);
1889
- return type;
1890
- }
1891
- if (!id) return 'Main';
1892
- return 'Agent-' + id.slice(0, 7);
1893
- }
1894
-
1895
- // Compute the minimum display length per agentID so that within each session,
1896
- // no two agent IDs share the same truncated prefix. Minimum 7 chars.
1897
- const agentIdDisplayLen = new Map();
1898
- function computeAgentIdDisplayLengths() {
1899
- agentIdDisplayLen.clear();
1900
- for (const s of sessions) {
1901
- const agentIds = s.agents.filter(a => a.id).map(a => a.id);
1902
- if (agentIds.length === 0) continue;
1903
- // Find minimum length that makes all prefixes unique
1904
- let minLen = 7;
1905
- while (minLen < 21) {
1906
- const prefixes = agentIds.map(id => id.slice(0, minLen));
1907
- const unique = new Set(prefixes);
1908
- if (unique.size === agentIds.length) break;
1909
- minLen++;
1910
- }
1911
- for (const id of agentIds) {
1912
- agentIdDisplayLen.set(s.id + ':' + id, minLen);
1913
- }
1914
- }
1915
- }
1916
-
1917
- function folderName(projectPath) {
1918
- if (!projectPath) return '';
1919
- const parts = projectPath.split('/');
1920
- return parts[parts.length - 1] || projectPath;
1921
- }
1922
-
1923
- function esc(s) {
1924
- return (s ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#x27;').replace(/\\/g, '&#x5C;');
1925
- }
1926
-
1927
- function fmtDur(ms) {
1928
- if (!ms || ms <= 0) return '';
1929
- if (ms < 1000) return `(${ms}ms)`;
1930
- if (ms < 60000) return `(${(ms / 1000).toFixed(1)}s)`;
1931
- return `(${(ms / 60000).toFixed(1)}m)`;
1932
- }
1933
-
1934
- function formatTime(ms) {
1935
- if (!ms) return '';
1936
- const d = new Date(ms);
1937
- const pad = (n) => String(n).padStart(2, '0');
1938
- return `${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
1939
- }
1940
-
1941
- function fmtTimestamp(ts) {
1942
- if (!ts) return '';
1943
- const d = ts instanceof Date ? ts : new Date(ts);
1944
- if (isNaN(d.getTime())) return '';
1945
- const pad = (n, len) => String(n).padStart(len, '0');
1946
- const ms = pad(d.getMilliseconds(), 3);
1947
- return `${pad(d.getFullYear(),4)}-${pad(d.getMonth()+1,2)}-${pad(d.getDate(),2)} ${pad(d.getHours(),2)}:${pad(d.getMinutes(),2)}:${pad(d.getSeconds(),2)}.${ms}`;
1948
- }
1949
-
1950
- function fmtTok(n) {
1951
- if (!n) return '0';
1952
- if (n < 1000) return String(n);
1953
- if (n < 1000000) return (n / 1000).toFixed(2) + 'k';
1954
- return (n / 1000000).toFixed(2) + 'm';
1955
- }
1956
-
1957
- function renderAll() {
1958
- needsFullRender = true;
1959
- visibleDirty = true;
1960
- renderTree();
1961
- renderStream();
1962
- refreshButtons();
1963
- }
1964
-
1965
- function scheduleRender() {
1966
- if (!renderPending) {
1967
- renderPending = true;
1968
- requestAnimationFrame(() => {
1969
- renderPending = false;
1970
- renderTree();
1971
- renderStream();
1972
- refreshButtons();
1973
- });
1974
- }
1975
- }
1976
-
1977
- // ══════════════════════════════════════════════════════════════════════════════
1978
- // Export modal — session selection
1979
- // ══════════════════════════════════════════════════════════════════════════════
1980
-
1981
- let exportModalSelected = new Set();
1982
-
1983
- function openExportModal() {
1984
- if (sessions.length === 0) {
1985
- const btn = document.getElementById('btn-export');
1986
- const orig = btn.textContent;
1987
- btn.textContent = '✕ 无会话';
1988
- setTimeout(() => { btn.textContent = orig; }, 2000);
1989
- return;
1990
- }
1991
- exportModalSelected = new Set(sessions.map(s => s.id));
1992
- renderModalSessionList();
1993
- updateModalCount();
1994
- document.getElementById('export-modal').style.display = 'flex';
1995
- }
1996
-
1997
- function renderModalSessionList() {
1998
- const listEl = document.getElementById('modal-session-list');
1999
- const sorted = [...sessions].sort((a, b) => (a.colorRank || 0) - (b.colorRank || 0));
2000
- listEl.innerHTML = sorted.map(s => {
2001
- const color = idColor(s.colorRank || 0);
2002
- const project = folderName(s.projectPath) || s.projectPath || '';
2003
- const prefix = s.id.split('-')[0].toUpperCase();
2004
- const model = s.model || '';
2005
- const time = formatTime(s.birthtimeMs);
2006
- const checked = exportModalSelected.has(s.id) ? 'checked' : '';
2007
- const selectedClass = exportModalSelected.has(s.id) ? ' selected' : '';
2008
- return `<div class="modal-session-row${selectedClass}" data-sid="${esc(s.id)}" onclick="toggleModalSession('${esc(s.id)}', this)">
2009
- <input type="checkbox" class="modal-checkbox" data-sid="${esc(s.id)}" ${checked} onclick="event.stopPropagation(); toggleModalSession('${esc(s.id)}', this.parentElement)">
2010
- <span class="modal-session-prefix" style="color:${color}">${esc(prefix)}</span>
2011
- <div class="modal-session-info">
2012
- <span class="modal-session-project">${esc(project)}</span>
2013
- ${model ? `<span class="modal-session-model">${esc(model)}</span>` : ''}
2014
- </div>
2015
- ${time ? `<span class="modal-session-time">${esc(time)}</span>` : ''}
2016
- </div>`;
2017
- }).join('\n');
2018
- }
2019
-
2020
- function toggleModalSession(sid, rowEl) {
2021
- if (exportModalSelected.has(sid)) {
2022
- exportModalSelected.delete(sid);
2023
- } else {
2024
- exportModalSelected.add(sid);
2025
- }
2026
- const checkbox = rowEl.querySelector('.modal-checkbox');
2027
- checkbox.checked = exportModalSelected.has(sid);
2028
- rowEl.classList.toggle('selected', exportModalSelected.has(sid));
2029
- updateModalCount();
2030
- }
2031
-
2032
- function exportModalToggleAll(selectAll) {
2033
- if (selectAll) {
2034
- exportModalSelected = new Set(sessions.map(s => s.id));
2035
- } else {
2036
- exportModalSelected.clear();
2037
- }
2038
- document.querySelectorAll('#modal-session-list .modal-session-row').forEach(row => {
2039
- const sid = row.dataset.sid;
2040
- const checkbox = row.querySelector('.modal-checkbox');
2041
- checkbox.checked = exportModalSelected.has(sid);
2042
- row.classList.toggle('selected', exportModalSelected.has(sid));
2043
- });
2044
- updateModalCount();
2045
- }
2046
-
2047
- function updateModalCount() {
2048
- const total = sessions.length;
2049
- const selected = exportModalSelected.size;
2050
- document.getElementById('modal-selected-count').textContent = `已选 ${selected} / ${total}`;
2051
- document.getElementById('modal-export-btn').disabled = selected === 0;
2052
- }
2053
-
2054
- function closeExportModal() {
2055
- document.getElementById('export-modal').style.display = 'none';
2056
- exportModalSelected.clear();
2057
- }
2058
-
2059
- // Esc key closes modal
2060
- document.addEventListener('keydown', (e) => {
2061
- if (e.key === 'Escape') {
2062
- const modal = document.getElementById('export-modal');
2063
- if (modal.style.display !== 'none') {
2064
- closeExportModal();
2065
- e.stopPropagation();
2066
- }
2067
- }
2068
- });
2069
-
2070
- function confirmExport() {
2071
- if (exportModalSelected.size === 0) return;
2072
- const selectedIds = new Set(exportModalSelected);
2073
- closeExportModal();
2074
- exportHTML(selectedIds);
2075
- }
2076
-
2077
- // ══════════════════════════════════════════════════════════════════════════════
2078
- // Export HTML
2079
- // ══════════════════════════════════════════════════════════════════════════════
2080
-
2081
- function exportHTML(selectedIds = null) {
2082
- const theme = document.documentElement.getAttribute('data-theme') || 'dark';
2083
-
2084
- // Collect sessions to export
2085
- let sidsInExport;
2086
- if (selectedIds) {
2087
- sidsInExport = selectedIds;
2088
- } else {
2089
- sidsInExport = new Set();
2090
- for (const item of visibleItems) {
2091
- if (item.sessionID) sidsInExport.add(item.sessionID);
2092
- }
2093
- }
2094
- const exportSessions = [];
2095
- for (const sid of sidsInExport) {
2096
- const s = sessionsMap.get(sid);
2097
- if (s) exportSessions.push(s);
2098
- }
2099
- // Sort by colorRank to match the order in the tree
2100
- exportSessions.sort((a, b) => (a.colorRank || 0) - (b.colorRank || 0));
2101
-
2102
- // Build session list header
2103
- let sessionListHTML = '';
2104
- if (exportSessions.length > 0) {
2105
- const items = exportSessions.map(s => {
2106
- const color = idColor(s.colorRank || 0);
2107
- const project = folderName(s.projectPath) || s.projectPath || '';
2108
- const model = s.model || '';
2109
- 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>`;
2110
- }).join('\n');
2111
- sessionListHTML = `<div class="export-session-list">
2112
- <div class="export-session-item export-all-btn active" onclick="filterBySession(null)">全部</div>
2113
- ${items}
2114
- </div>`;
2115
- }
2116
-
2117
- // Token info
2118
- computeTokensFromContext();
2119
- let tokenHTML = '';
2120
- if (totalInput > 0 || totalOutput > 0) {
2121
- let tokStr = `Input: ${fmtTok(totalInput)} · Output: ${fmtTok(totalOutput)}`;
2122
- if (totalCacheCreate > 0 || totalCacheRead > 0) tokStr += ` · Cache: ${fmtTok(totalCacheCreate)}+${fmtTok(totalCacheRead)}`;
2123
- tokenHTML = `<div class="export-meta-line" style="color:var(--dim)">Tokens: ${tokStr}</div>`;
2124
- }
2125
-
2126
- // Filter state
2127
- const filterState = [];
2128
- if (!showThinking) filterState.push('thinking hidden');
2129
- if (!showToolInput) filterState.push('tools hidden');
2130
- if (!showToolOutput) filterState.push('output hidden');
2131
- if (!showText) filterState.push('text hidden');
2132
- if (!showHook) filterState.push('hook hidden');
2133
- let filterHTML = '';
2134
- if (filterState.length > 0) filterHTML = `<div class="export-meta-line" style="color:var(--dim)">Filters: ${filterState.join(', ')}</div>`;
2135
-
2136
- // Export timestamp
2137
- const now = new Date();
2138
- const exportTime = fmtTimestamp(now);
2139
- const timeHTML = `<div class="export-meta-line" style="color:var(--dim)">Exported: ${exportTime}</div>`;
2140
-
2141
- // Clone stream content and strip interactive elements
2142
- const clone = streamEl.cloneNode(true);
2143
- clone.querySelectorAll('.copy-btn').forEach(el => el.remove());
2144
- clone.querySelectorAll('[onclick]').forEach(el => el.removeAttribute('onclick'));
2145
-
2146
- // Filter out stream lines from non-selected sessions
2147
- if (selectedIds) {
2148
- clone.querySelectorAll('[data-session-id]').forEach(el => {
2149
- if (!selectedIds.has(el.dataset.sessionId)) el.remove();
2150
- });
2151
- }
2152
-
2153
- // Get the cleaned innerHTML
2154
- const streamHTML = clone.innerHTML;
2155
-
2156
- // Get page CSS
2157
- const pageStyleEl = document.querySelector('style');
2158
- const appCSS = pageStyleEl ? pageStyleEl.textContent : '';
2159
-
2160
- // Get highlight.js CSS from cache
2161
- const hlCSS = theme === 'dark' ? hljsDarkCSS : hljsLightCSS;
2162
-
2163
- // Export-specific CSS
2164
- const exportCSS = `
2165
- .export-session-list { display: flex; flex-wrap: wrap; gap: 6px; padding: 8px 0; }
2166
- .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; }
2167
- .export-session-item:hover { opacity: 1; border-color: var(--dim); }
2168
- .export-session-item.active { opacity: 1; border-color: var(--purple); background: var(--purple); color: var(--white); }
2169
- .export-all-btn { font-weight: 600; align-items: center; }
2170
- .export-item-top { display: flex; align-items: baseline; gap: 4px; }
2171
- .export-item-sid { font-family: monospace; font-size: 10px; opacity: 0.8; }
2172
- .export-session-item.active .export-item-sid { opacity: 1; color: var(--white); }
2173
- .export-project { font-weight: 500; }
2174
- .export-model { font-size: 11px; }
2175
- .export-meta-line { padding: 2px 0; font-size: 11px; }
2176
- .export-header { padding: 12px; border-bottom: 1px solid var(--border); position: sticky; top: 0; background: var(--bg); z-index: 100; }
2177
- .export-header h1 { margin: 0 0 4px 0; font-size: 16px; color: var(--white); }
2178
- `;
2179
-
2180
- // Export-specific JS for session filtering
2181
- const exportJS = `
2182
- let _activeSid = null;
2183
- function filterBySession(sid) {
2184
- _activeSid = sid;
2185
- const lines = document.querySelectorAll('#export-stream [data-session-id]');
2186
- lines.forEach(el => {
2187
- el.style.display = (sid === null || el.dataset.sessionId === sid) ? '' : 'none';
2188
- });
2189
- document.querySelectorAll('.export-session-item[data-sid]').forEach(el => {
2190
- el.classList.toggle('active', sid !== null && el.dataset.sid === sid);
2191
- });
2192
- document.querySelector('.export-all-btn').classList.toggle('active', sid === null);
2193
- }
2194
- `;
2195
-
2196
- // Assemble complete HTML document
2197
- const htmlAttrs = theme === 'light' ? ' lang="en" data-theme="light"' : ' lang="en"';
2198
- const fullDoc = `<!DOCTYPE html>
2199
- <html${htmlAttrs}>
2200
- <head>
2201
- <meta charset="UTF-8">
2202
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
2203
- <title>claude-watch Export</title>
2204
- <style>
2205
- ${appCSS}
2206
- ${hlCSS}
2207
- ${exportCSS}
2208
- </style>
2209
- </head>
2210
- <body style="overflow-y:auto;height:auto">
2211
- <div class="export-header">
2212
- <h1>claude-watch Export</h1>
2213
- ${sessionListHTML}
2214
- ${tokenHTML}
2215
- ${filterHTML}
2216
- ${timeHTML}
2217
- </div>
2218
- <div id="export-stream" style="padding:8px 12px;font-size:12px">
2219
- ${streamHTML}
2220
- </div>
2221
- <script>${exportJS}<\/script>
2222
- </body>
2223
- </html>`;
2224
-
2225
- // Blob download
2226
- const blob = new Blob([fullDoc], { type: 'text/html;charset=utf-8' });
2227
- const url = URL.createObjectURL(blob);
2228
- const a = document.createElement('a');
2229
-
2230
- let filePrefix;
2231
- if (sidsInExport.size === 1) {
2232
- filePrefix = [...sidsInExport][0].split('-')[0].toUpperCase();
2233
- } else {
2234
- filePrefix = 'multi';
2235
- }
2236
- const pad = (n, len) => String(n).padStart(len, '0');
2237
- 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)}`;
2238
- a.download = `claude-watch-${filePrefix}-${ts}.html`;
2239
- a.href = url;
2240
- document.body.appendChild(a);
2241
- a.click();
2242
- document.body.removeChild(a);
2243
- URL.revokeObjectURL(url);
2244
-
2245
- // Visual feedback
2246
- const btn = document.getElementById('btn-export');
2247
- const orig = btn.textContent;
2248
- btn.textContent = '✓';
2249
- setTimeout(() => { btn.textContent = orig; }, 2000);
2250
- }
2251
-
2252
- // ══════════════════════════════════════════════════════════════════════════════
2253
- // Theme toggle
2254
- // ══════════════════════════════════════════════════════════════════════════════
2255
-
2256
- function applyTheme(theme) {
2257
- document.documentElement.setAttribute('data-theme', theme);
2258
- const btn = document.getElementById('btn-theme');
2259
- if (btn) {
2260
- btn.textContent = theme === 'dark' ? '🌙' : '☀️';
2261
- btn.setAttribute('data-tooltip', theme === 'dark' ? 'Switch to light' : 'Switch to dark');
2262
- }
2263
- // Swap highlight.js stylesheet for theme
2264
- const hlLink = document.querySelector('link[rel="stylesheet"][href*="github"]');
2265
- if (hlLink) {
2266
- hlLink.href = theme === 'dark' ? 'vendor/github-dark.min.css' : 'vendor/github-light.min.css';
2267
- }
2268
- }
2269
-
2270
- function toggleTheme() {
2271
- const current = document.documentElement.getAttribute('data-theme') || 'dark';
2272
- const next = current === 'dark' ? 'light' : 'dark';
2273
- localStorage.setItem('theme', next);
2274
- applyTheme(next);
2275
- }
2276
-
2277
- // Apply saved theme on load (default dark)
2278
- (function() {
2279
- const saved = localStorage.getItem('theme');
2280
- applyTheme(saved || 'dark');
2281
- })();
2282
-
2283
- // ══════════════════════════════════════════════════════════════════════════════
2284
- // Init
2285
- // ══════════════════════════════════════════════════════════════════════════════
2286
-
2287
- // Apply collapse-after from URL param
2288
- const urlParams = new URLSearchParams(location.search);
2289
- const ca = urlParams.get('collapseAfter');
2290
- if (ca) {
2291
- applyCollapsePolicy(parseInt(ca) || 0);
2292
- }
2293
-
2294
- connect();
2295
- </script>
121
+ <script src="js/shared.js"></script>
122
+ <script src="js/stream.js"></script>
123
+ <script src="js/token.js"></script>
124
+ <script src="js/app.js"></script>
2296
125
  </body>
2297
126
  </html>