claude-code-watch 0.1.5 → 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,436 +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: 20px 24px; gap: 16px; background: var(--bg); }
213
- .tp-top { display: flex; gap: 16px; }
214
- .tp-left { width: 260px; flex-shrink: 0; display: flex; flex-direction: column; gap: 12px; }
215
- .tp-right { flex: 1; display: flex; flex-direction: column; gap: 12px; }
216
- .tp-box { background: var(--bg2); border: 1px solid var(--border); border-radius: 8px; padding: 14px 16px; }
217
- .tp-total-label { font-size: 11px; color: var(--dim); text-transform: uppercase; margin-bottom: 2px; }
218
- .tp-total-value { font-size: 22px; font-weight: 700; color: var(--white); font-family: monospace; }
219
- .tp-total-sub { font-size: 10px; color: var(--dim); margin-top: 2px; }
220
- .tp-stat-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; }
221
- .tp-stat { padding: 8px 10px; background: var(--bg3); border-radius: 6px; }
222
- .tp-stat .tp-s-l { font-size: 10px; color: var(--dim); }
223
- .tp-stat .tp-s-v { font-size: 13px; font-weight: 600; color: var(--white); font-family: monospace; }
224
- .tp-rank-title { font-size: 12px; color: var(--dim); font-weight: 600; margin-bottom: 8px; }
225
- .tp-rank-item { display: flex; align-items: center; gap: 8px; padding: 5px 0; border-bottom: 1px solid var(--border); }
226
- .tp-rank-num { width: 18px; height: 18px; display: flex; align-items: center; justify-content: center; border-radius: 50%; background: var(--bg3); font-size: 10px; font-weight: 600; color: var(--dim); flex-shrink: 0; }
227
- .tp-rank-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
228
- .tp-rank-name { font-size: 12px; color: var(--text); flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
229
- .tp-rank-pct { font-size: 12px; font-weight: 600; color: var(--white); font-family: monospace; flex-shrink: 0; }
230
- .tp-footer-stats { display: flex; justify-content: space-between; padding-top: 8px; border-top: 1px solid var(--border); font-size: 11px; color: var(--dim); }
231
- .tp-footer-stats .tp-fv { color: var(--text); font-family: monospace; }
232
- .tp-h3 { font-size: 12px; color: var(--dim); font-weight: 600; text-transform: uppercase; margin-bottom: 8px; }
233
-
234
- /* ── Heatmap ── */
235
- .tp-heatmap { overflow-x: auto; }
236
- .tp-heatmap-inner { display: inline-flex; flex-direction: column; gap: 2px; }
237
- .tp-hm-months { display: flex; gap: 0; font-size: 10px; color: var(--dim); margin-bottom: 2px; padding-left: 28px; }
238
- .tp-hm-row { display: flex; align-items: center; gap: 2px; }
239
- .tp-hm-day-label { width: 24px; font-size: 10px; color: var(--dim); text-align: right; flex-shrink: 0; }
240
- .tp-hm-cell { width: 12px; height: 12px; border-radius: 2px; transition: transform 0.15s; cursor: pointer; position: relative; }
241
- .tp-hm-cell:hover { transform: scale(1.6); z-index: 10; }
242
- .tp-hm-cell[title]:hover::after { content: attr(title); position: absolute; bottom: calc(100% + 4px); left: 50%; transform: translateX(-50%); background: var(--bg2); color: var(--white); padding: 2px 8px; border-radius: 4px; font-size: 10px; white-space: nowrap; z-index: 100; border: 1px solid var(--border); pointer-events: none; }
243
- .tp-hm-legend { display: flex; align-items: center; gap: 4px; font-size: 10px; color: var(--dim); margin-top: 6px; justify-content: flex-end; }
244
- .tp-hm-legend-cell { width: 12px; height: 12px; border-radius: 2px; }
245
-
246
- /* ── Trend bars ── */
247
- .tp-trend-bars { display: flex; align-items: flex-end; gap: 3px; height: 140px; position: relative; padding-bottom: 20px; }
248
- .tp-trend-bar-wrap { flex: 1; display: flex; flex-direction: column; position: relative; min-width: 0; }
249
- .tp-trend-bar { position: relative; border-radius: 3px 3px 0 0; transition: all 0.15s; cursor: pointer; min-height: 4px; }
250
- .tp-trend-bar:hover { filter: brightness(1.3); }
251
- .tp-trend-bar:hover::after { content: attr(data-tip); position: absolute; bottom: calc(100% + 4px); left: 50%; transform: translateX(-50%); background: var(--bg2); color: var(--white); padding: 2px 8px; border-radius: 4px; font-size: 10px; white-space: nowrap; z-index: 100; border: 1px solid var(--border); pointer-events: none; }
252
- .tp-trend-label { font-size: 9px; color: var(--dim); text-align: center; position: absolute; bottom: 0; width: 100%; white-space: nowrap; overflow: hidden; }
253
- .tp-trend-grid-lines { position: absolute; inset: 0 0 20px 0; display: flex; flex-direction: column; justify-content: space-between; pointer-events: none; }
254
- .tp-trend-grid-line { width: 100%; border-top: 1px dashed var(--border); opacity: 0.4; }
255
-
256
- /* ── Detail table ── */
257
- .tp-tabs { display: flex; gap: 2px; }
258
- .tp-tab { padding: 5px 12px; cursor: pointer; border-radius: 6px 6px 0 0; background: var(--bg3); color: var(--dim); border: 1px solid var(--border); border-bottom: none; font-size: 12px; font-weight: 500; }
259
- .tp-tab.active { background: var(--bg2); color: var(--white); font-weight: 600; }
260
- .tp-tc { display: none; }
261
- .tp-tc.active { display: block; }
262
- .tp-st { max-height: 480px; overflow-y: auto; border: 1px solid var(--border); border-radius: 8px; }
263
- .tp-table { width: 100%; border-collapse: collapse; font-size: 11px; background: var(--bg2); }
264
- .tp-table th { background: var(--bg3); color: var(--white); padding: 7px 10px; text-align: left; font-weight: 600; white-space: nowrap; }
265
- .tp-table td { padding: 6px 10px; border-top: 1px solid var(--border); white-space: nowrap; font-family: monospace; color: var(--text); }
266
- .tp-table tr:hover td { background: var(--bg3); }
267
- .tp-table tfoot td { font-weight: 700; background: var(--bg3); }
268
- .tp-mtag { display: inline-block; padding: 1px 6px; border-radius: 3px; border: 1px solid; font-size: 10px; margin: 1px; font-weight: 500; }
269
- .tp-mbreak { white-space: normal; min-width: 200px; }
270
-
271
- /* ── Stream panel ── */
272
- #stream-panel-wrap {
273
- flex: 1; display: flex; flex-direction: column; overflow: hidden;
274
- }
275
- #stream-toolbar {
276
- display: flex; gap: 4px; padding: 4px 8px;
277
- border-bottom: 1px solid var(--border);
278
- background: var(--bg); flex-shrink: 0;
279
- }
280
- #stream-panel {
281
- flex: 1; overflow-y: auto; padding: 8px 12px;
282
- font-size: 12px;
283
- }
284
-
285
- /* ── Stream lines ── */
286
- .stream-line { white-space: pre-wrap; word-break: break-all; }
287
- .stream-line.thinking { color: var(--magenta); }
288
- .stream-line.tool-input { color: var(--yellow); }
289
- .stream-line.tool-output { color: var(--green); }
290
- .stream-line.text { color: var(--text); }
291
- .stream-line.hook { color: var(--cyan); }
292
- .stream-line .hook-label { color: var(--dim); }
293
- .stream-line.diag { color: var(--red); }
294
- .stream-line.debug { color: var(--gray); }
295
- .stream-line.marker { color: var(--dim); }
296
- .stream-line.agent-tag { font-weight: bold; display: flex; justify-content: space-between; align-items: baseline; white-space: nowrap; }
297
- .stream-line.agent-main { color: var(--blue); }
298
- .stream-line.agent-sub { color: var(--magenta); }
299
- .stream-line.agent-tag .tag-label { flex-shrink: 0; }
300
- .stream-line.agent-tag .timestamp { font-weight: normal; font-size: 0.85em; color: var(--dim); white-space: nowrap; }
301
- .stream-line.separator { color: var(--dim); }
302
- .stream-line.user-prompt-block {
303
- background: rgba(59,130,246,0.08);
304
- border-left: 8px solid var(--blue);
305
- border-radius: 4px;
306
- padding: 6px 10px;
307
- margin: 4px 0;
308
- color: var(--white);
309
- line-height: 1.5;
310
- }
311
-
312
- /* ── Footer ── */
313
- #footer {
314
- height: 28px; background: var(--bg2);
315
- border-top: 1px solid var(--border);
316
- display: flex; align-items: center;
317
- padding: 0 8px; gap: 6px;
318
- font-size: 11px; flex-shrink: 0; flex-wrap: wrap;
319
- }
320
- #footer .sep { color: var(--dim); margin: 0 2px; }
321
-
322
- /* ── Scrollbar ── */
323
- ::-webkit-scrollbar { width: 6px; height: 6px; }
324
- ::-webkit-scrollbar-track { background: transparent; }
325
- ::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
326
-
327
- /* ── Focus ring ── */
328
- #stream-panel:focus { outline: none; }
329
-
330
- /* ── Markdown & code blocks ── */
331
- .md-content { line-height: 1.6; color: var(--text); }
332
- .md-content p { margin: 4px 0; }
333
- .md-content ul, .md-content ol { padding-left: 20px; margin: 4px 0; }
334
- .md-content li { margin: 2px 0; }
335
- .md-content strong { color: var(--white); }
336
- .md-content a { color: var(--blue); text-decoration: underline; }
337
- .md-content h1, .md-content h2, .md-content h3, .md-content h4,
338
- .md-content h5, .md-content h6 { color: var(--white); margin: 8px 0 4px; font-size: inherit; font-weight: bold; }
339
- .md-content blockquote { border-left: 3px solid var(--purple); padding-left: 12px; color: var(--dim); margin: 4px 0; }
340
- .md-content hr { border: none; border-top: 1px solid var(--border); margin: 8px 0; }
341
- .md-content table { border-collapse: collapse; margin: 4px 0; width: 100%; }
342
- .md-content th, .md-content td { border: 1px solid var(--border); padding: 4px 8px; text-align: left; }
343
- .md-content th { background: var(--bg3); color: var(--white); }
344
- .md-content img { max-width: 100%; }
345
-
346
- .code-block-wrapper { margin: 8px 0; border-radius: 6px; overflow: hidden; border: 1px solid var(--border); }
347
- .code-block-header { display: flex; align-items: center; justify-content: space-between; padding: 4px 12px; background: var(--bg3); font-size: 11px; color: var(--dim); }
348
- .code-block-header .lang-tag { color: var(--blue); font-weight: bold; }
349
- .code-block-header .copy-btn { cursor: pointer; opacity: 0.5; transition: opacity 0.2s; font-size: 11px; }
350
- .code-block-header .copy-btn:hover { opacity: 1; }
351
- .code-block-wrapper pre { margin: 0; padding: 12px; overflow-x: auto; font-size: 12px; line-height: 1.5; }
352
- .code-block-wrapper pre code { font-family: 'SF Mono', 'Menlo', 'Monaco', 'Consolas', monospace; font-size: 12px; }
353
-
354
- /* Override highlight.js background to match our theme */
355
- .hljs { background: #0d1117 !important; }
356
-
357
- /* Light theme overrides */
358
- :root[data-theme="light"] .btn.on { background: var(--purple); border-color: var(--purple); color: #fff; }
359
- :root[data-theme="light"] .btn.on:hover { background: var(--purple2); color: #fff; }
360
- :root[data-theme="light"] .btn.on:hover::after { background: var(--purple2); color: #fff; }
361
- :root[data-theme="light"] .hljs { background: #f0f0f0 !important; }
362
- :root[data-theme="light"] .tree-content:hover { background: rgba(0,0,0,0.06); }
363
- :root[data-theme="light"] .tree-row.selected > .tree-content { background: rgba(124,58,237,0.2); }
364
- :root[data-theme="light"] .tree-node .active-dot.off { color: #bbb; }
365
- :root[data-theme="light"] #tree-resize-handle:hover,
366
- :root[data-theme="light"] #tree-resize-handle.active { background: var(--purple); }
367
- :root[data-theme="light"] .stream-line.text { color: var(--text); }
368
-
369
- /* Theme toggle button */
370
- #btn-theme { font-size: 14px; }
371
-
372
- /* ── Export modal ── */
373
- .modal-overlay {
374
- position: fixed; inset: 0;
375
- background: rgba(0, 0, 0, 0.6);
376
- z-index: 10000;
377
- display: flex; align-items: center; justify-content: center;
378
- }
379
- :root[data-theme="light"] .modal-overlay { background: rgba(0, 0, 0, 0.3); }
380
- :root[data-theme="light"] .modal-session-row.selected { background: rgba(124, 58, 237, 0.2); }
381
-
382
- .modal-box {
383
- background: var(--bg); border: 1px solid var(--border); border-radius: 8px;
384
- width: 480px; max-width: 90vw; max-height: 80vh;
385
- display: flex; flex-direction: column; overflow: hidden;
386
- }
387
-
388
- .modal-header {
389
- display: flex; align-items: center; justify-content: space-between;
390
- padding: 8px 12px; border-bottom: 1px solid var(--border); background: var(--bg2); flex-shrink: 0;
391
- }
392
- .modal-title { font-size: 13px; font-weight: 600; color: var(--white); }
393
-
394
- .modal-toolbar {
395
- display: flex; align-items: center; gap: 4px;
396
- padding: 6px 12px; border-bottom: 1px solid var(--border); flex-shrink: 0;
397
- }
398
- .modal-count { margin-left: auto; font-size: 11px; color: var(--dim); }
399
-
400
- .modal-body { flex: 1; overflow-y: auto; padding: 6px 0; }
401
-
402
- .modal-session-row {
403
- display: flex; align-items: center; gap: 8px;
404
- padding: 6px 12px; cursor: pointer; transition: background 0.1s; user-select: none;
405
- }
406
- .modal-session-row:hover { background: var(--bg2); }
407
- .modal-session-row.selected { background: rgba(124, 58, 237, 0.15); }
408
-
409
- .modal-checkbox {
410
- appearance: none; width: 16px; height: 16px;
411
- border: 1px solid var(--border); border-radius: 3px; background: var(--bg2);
412
- cursor: pointer; position: relative; flex-shrink: 0; transition: all 0.15s;
413
- }
414
- .modal-checkbox:checked { background: var(--purple); border-color: var(--purple); }
415
- .modal-checkbox:checked::after {
416
- content: '✓'; position: absolute; inset: 0;
417
- display: flex; align-items: center; justify-content: center;
418
- color: var(--white); font-size: 11px; font-weight: bold;
419
- }
420
-
421
- .modal-session-prefix {
422
- font-family: monospace; font-size: 12px; font-weight: 600; letter-spacing: 0.5px; flex-shrink: 0;
423
- }
424
- .modal-session-info {
425
- flex: 1; min-width: 0; display: flex; align-items: baseline; gap: 4px; overflow: hidden;
426
- }
427
- .modal-session-project {
428
- font-size: 12px; font-weight: 500; color: var(--text);
429
- white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
430
- }
431
- .modal-session-model { font-size: 10px; color: var(--dim); flex-shrink: 0; }
432
- .modal-session-time { font-size: 10px; color: var(--dim); flex-shrink: 0; margin-left: auto; }
433
-
434
- .modal-footer {
435
- display: flex; align-items: center; justify-content: flex-end; gap: 6px;
436
- padding: 8px 12px; border-top: 1px solid var(--border); background: var(--bg2); flex-shrink: 0;
437
- }
438
- </style>
9
+ <link rel="stylesheet" href="css/app.css">
439
10
  </head>
440
11
  <body>
441
12
 
@@ -488,7 +59,10 @@ body {
488
59
  </div>
489
60
 
490
61
  <div id="tokens-page" style="display:none">
491
- <!-- Top row: left sidebar + right content -->
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>
492
66
  <div class="tp-top">
493
67
  <div class="tp-left">
494
68
  <div class="tp-box" id="tp-total-card"></div>
@@ -500,7 +74,12 @@ body {
500
74
  <div class="tp-box" id="tp-heatmap-card"></div>
501
75
  </div>
502
76
  </div>
503
- <!-- Detail table -->
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>
82
+ </div>
504
83
  <div class="tp-box">
505
84
  <div class="tp-tabs" id="tp-detail-tabs"></div>
506
85
  <div id="tp-tc-daily" class="tp-tc"><div class="tp-st" id="tp-daily-table"></div></div>
@@ -515,7 +94,7 @@ body {
515
94
  <span id="item-count">0 items</span>
516
95
  <span class="sep">│</span>
517
96
  <span id="footer-version" style="margin-left:auto;font-size:10px;color:var(--dim)"></span>
518
- </div>
97
+ </div>
519
98
 
520
99
  <div id="export-modal" class="modal-overlay" style="display:none">
521
100
  <div class="modal-box">
@@ -539,2059 +118,9 @@ body {
539
118
  <script src="vendor/highlight.min.js"></script>
540
119
  <script src="vendor/marked.min.js"></script>
541
120
  <script src="vendor/purify.min.js"></script>
542
- <script>
543
- // ══════════════════════════════════════════════════════════════════════════════
544
- // DOM refs
545
- // ══════════════════════════════════════════════════════════════════════════════
546
-
547
- const streamEl = document.getElementById('stream-panel');
548
- const treeEl = document.getElementById('tree-content');
549
- const sessionInfo = document.getElementById('session-info');
550
- const tokenInfo = document.getElementById('token-info');
551
- const treeCursorInfo = document.getElementById('tree-cursor-info');
552
-
553
- // ══════════════════════════════════════════════════════════════════════════════
554
- // State
555
- // ══════════════════════════════════════════════════════════════════════════════
556
-
557
- let ws = null;
558
- let reconnectTimer = null;
559
- let reconnectDelay = 1000;
560
- const MaxReconnectDelay = 30000;
561
- const MaxReconnectAttempts = 20;
562
- let reconnectAttempts = 0;
563
- let showTree = true;
564
- let autoScroll = true;
565
- let lastMsgTime = 0;
566
- let staleCheckTimer = null;
567
-
568
- let sessions = [];
569
- let sessionsMap = new Map(); // id -> session, for O(1) lookups
570
- let treeNodes = [];
571
- let treeCursor = 0;
572
- let folderCollapsed = {}; // dateStr -> boolean, default collapsed
573
- let streamItems = [];
574
- let visibleItems = [];
575
- let visibleDirty = true;
576
- // LRU cache: recently accessed keys survive eviction, so a tool_input's ID
577
- // stays alive long enough for its matching tool_output to arrive and merge.
578
- class LRUCache {
579
- constructor(max) { this.max = max; this.map = new Map(); }
580
- 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; }
581
- 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; }
582
- 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); } }
583
- delete(key) { return this.map.delete(key); }
584
- keys() { return this.map.keys(); }
585
- }
586
- const seenToolIDs = new LRUCache(20000);
587
- const toolNameMap = new LRUCache(2000);
588
- const agentActivity = new LRUCache(500); // "sessionID:agentID" → { toolName, content }
589
- const taskDescriptions = new LRUCache(2000); // toolID → description string
590
- const MAX_DESC_STORE = 200;
591
- let filters = new Map();
592
- let visibleFilterCount = 0;
593
-
594
- let showThinking = true;
595
- let showToolInput = true;
596
- let showToolOutput = true;
597
- let showText = true;
598
- let showHook = true;
599
- let showUserPrompt = true;
600
- let showActivity = true;
601
- let showTokenCount = true;
602
- let autoDiscovery = true;
603
- let appVersion = '';
604
- let currentTab = 'stream';
605
- let tokenStatsData = { totals: { messages: 0, input: 0, output: 0, cacheCreation: 0, cacheRead: 0, days: 0 }, modelTotals: {}, daily: {} };
606
-
607
- const HIDDEN_KEY = 'claude-watch-hidden';
608
- function loadHiddenSessions() {
609
- try {
610
- const data = JSON.parse(localStorage.getItem(HIDDEN_KEY) || '{}');
611
- const now = Date.now();
612
- for (const [id, ts] of Object.entries(data)) {
613
- if (now - ts < 24 * 60 * 60 * 1000) hiddenSessionIDs.add(id);
614
- }
615
- _saveHiddenSessions();
616
- } catch {}
617
- }
618
- function _saveHiddenSessions() {
619
- const data = {};
620
- for (const id of hiddenSessionIDs) data[id] = Date.now();
621
- localStorage.setItem(HIDDEN_KEY, JSON.stringify(data));
622
- }
623
- const hiddenSessionIDs = new Set();
624
- loadHiddenSessions();
625
-
626
- let renderPending = false;
627
-
628
- let totalInput = 0, totalOutput = 0, totalCacheCreate = 0, totalCacheRead = 0;
629
- let contextData = {};
630
-
631
- function computeTokensFromContext() {
632
- totalInput = 0; totalOutput = 0; totalCacheCreate = 0; totalCacheRead = 0;
633
- for (const ctx of Object.values(contextData)) {
634
- totalInput += ctx.inputTokens || 0;
635
- totalOutput += ctx.outputTokens || 0;
636
- totalCacheCreate += ctx.cacheCreation || 0;
637
- totalCacheRead += ctx.cacheRead || 0;
638
- }
639
- }
640
-
641
- let collapseAfter = 0;
642
- let collapseTimer = null;
643
- let activeRefreshTimer = null;
644
-
645
- const MAX_ITEMS = 9999;
646
- const MAX_LINES = 50;
647
- let renderedItemCount = 0;
648
- let needsFullRender = true;
649
- let treeDirty = true;
650
- let lastTreeCursor = -1;
651
-
652
- // Cache highlight.js CSS for HTML export
653
- let hljsDarkCSS = '', hljsLightCSS = '';
654
- fetch('vendor/github-dark.min.css').then(r => r.text()).then(t => { hljsDarkCSS = t; }).catch(() => {});
655
- fetch('vendor/github-light.min.css').then(r => r.text()).then(t => { hljsLightCSS = t; }).catch(() => {});
656
-
657
- // ══════════════════════════════════════════════════════════════════════════════
658
- // Markdown renderer (marked + highlight.js)
659
- // ══════════════════════════════════════════════════════════════════════════════
660
-
661
- const mdRenderer = new marked.Renderer();
662
- mdRenderer.code = function (codeOrObj, langOrEsc) {
663
- // marked v4: code(text, lang, escaped) — marked v5+: code({ text, lang })
664
- const text = typeof codeOrObj === 'object' ? codeOrObj.text : codeOrObj;
665
- const lang = typeof codeOrObj === 'object' ? codeOrObj.lang : langOrEsc;
666
- let highlighted;
667
- if (lang && hljs.getLanguage(lang)) {
668
- try {
669
- highlighted = hljs.highlight(text, { language: lang }).value;
670
- } catch {
671
- highlighted = hljs.highlightAuto(text).value;
672
- }
673
- } else {
674
- highlighted = hljs.highlightAuto(text).value;
675
- }
676
- const langTag = lang ? `<span class="lang-tag">${esc(lang)}</span>` : '';
677
- return `<div class="code-block-wrapper">
678
- <div class="code-block-header">${langTag}<span class="copy-btn" onclick="copyCode(this)">&#x2398;</span></div>
679
- <pre><code>${highlighted}</code></pre>
680
- </div>`;
681
- };
682
- marked.setOptions({ renderer: mdRenderer, breaks: true, gfm: true });
683
-
684
- function copyCode(btn) {
685
- const wrapper = btn.closest('.code-block-wrapper');
686
- const code = wrapper ? wrapper.querySelector('code') : null;
687
- if (!code) return;
688
- navigator.clipboard.writeText(code.textContent).then(() => {
689
- btn.innerHTML = '&#x2713;';
690
- setTimeout(() => { btn.innerHTML = '&#x2398;'; }, 1500);
691
- });
692
- }
693
-
694
- function mdRender(text) {
695
- try {
696
- return DOMPurify.sanitize(marked.parse(text));
697
- } catch {
698
- return esc(text);
699
- }
700
- }
701
-
702
- // ══════════════════════════════════════════════════════════════════════════════
703
- // WebSocket
704
- // ══════════════════════════════════════════════════════════════════════════════
705
-
706
- function connect() {
707
- const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
708
- ws = new WebSocket(`${proto}//${location.host}`);
709
-
710
- ws.onopen = () => {
711
- sessionInfo.textContent = 'Connected';
712
- lastMsgTime = Date.now();
713
- reconnectDelay = 1000;
714
- reconnectAttempts = 0;
715
- startStaleCheck();
716
- startActiveRefresh();
717
- };
718
- ws.onclose = () => {
719
- reconnectAttempts++;
720
- if (reconnectAttempts >= MaxReconnectAttempts) {
721
- sessionInfo.textContent = 'Disconnected. Please refresh to reconnect.';
722
- return;
723
- }
724
- sessionInfo.textContent = 'Disconnected, reconnecting...';
725
- stopStaleCheck();
726
- if (activeRefreshTimer) { clearInterval(activeRefreshTimer); activeRefreshTimer = null; }
727
- reconnectTimer = setTimeout(connect, reconnectDelay);
728
- reconnectDelay = Math.min(reconnectDelay * 2, MaxReconnectDelay);
729
- };
730
- ws.onerror = (e) => { console.warn('[ws] connection error', e); };
731
-
732
- ws.onmessage = (e) => {
733
- lastMsgTime = Date.now();
734
- let msg;
735
- try { msg = JSON.parse(e.data); } catch { return; }
736
- handleMessage(msg);
737
- };
738
- }
739
-
740
- function startStaleCheck() {
741
- if (staleCheckTimer) clearInterval(staleCheckTimer);
742
- staleCheckTimer = setInterval(() => {
743
- if (Date.now() - lastMsgTime > 45000) {
744
- sessionInfo.textContent = 'Stale connection, reconnecting...';
745
- stopStaleCheck();
746
- try { ws.close(); } catch {}
747
- // onclose handler will handle reconnect — no separate timer needed
748
- }
749
- }, 10000);
750
- }
751
-
752
- function stopStaleCheck() {
753
- if (staleCheckTimer) { clearInterval(staleCheckTimer); staleCheckTimer = null; }
754
- }
755
-
756
- function handleMessage(msg) {
757
- switch (msg.type) {
758
- case 'snapshot': handleSnapshot(msg.payload); break;
759
- case 'itemBatch': handleItemBatch(msg.payload); break;
760
- case 'item': handleItem(msg.payload); break;
761
- case 'newSession': handleNewSession(msg.payload); break;
762
- case 'newAgent': handleNewAgent(msg.payload); break;
763
- case 'newBackgroundTask': handleNewBgTask(msg.payload); break;
764
- case 'sessionRemoved': handleSessionRemoved(msg.payload); break;
765
- case 'autoDiscoveryChanged': autoDiscovery = msg.payload.enabled; scheduleRender(); break;
766
- case 'context': contextData = msg.payload; updateTreeDots(); refreshButtons(); break;
767
- case 'tokenStats': tokenStatsData = msg.payload; if (currentTab === 'tokens') renderTokenPage(); break;
768
- case 'config':
769
- if (msg.payload.version) appVersion = msg.payload.version;
770
- if (msg.payload.collapseAfter > 0 && !collapseTimer) {
771
- applyCollapsePolicy(msg.payload.collapseAfter);
772
- }
773
- break;
774
- case 'heartbeat': break;
775
- }
776
- }
777
-
778
- function sendCmd(action, extra = {}) {
779
- if (ws && ws.readyState === 1) ws.send(JSON.stringify({ action, ...extra }));
780
- }
781
-
782
- // ══════════════════════════════════════════════════════════════════════════════
783
- // Snapshot / Session management
784
- // ══════════════════════════════════════════════════════════════════════════════
785
-
786
- function handleSnapshot(payload) {
787
- autoDiscovery = payload.autoDiscovery;
788
- const incomingIDs = new Set((payload.sessions || []).map(s => s.id));
789
- for (let i = sessions.length - 1; i >= 0; i--) {
790
- const s = sessions[i];
791
- if (!incomingIDs.has(s.id) && !s.pinned) {
792
- sessions.splice(i, 1);
793
- sessionsMap.delete(s.id);
794
- }
795
- }
796
- for (const s of (payload.sessions || [])) {
797
- if (hiddenSessionIDs.has(s.id)) continue;
798
- let session = sessionsMap.get(s.id);
799
- if (!session) {
800
- session = {
801
- id: s.id, projectPath: s.projectPath, title: '',
802
- folder: folderName(s.projectPath), model: '',
803
- agents: [], tasks: [], collapsed: false, pinned: false,
804
- lastActivity: s.birthtimeMs || 0,
805
- birthtimeMs: s.birthtimeMs || 0,
806
- };
807
- sessions.push(session);
808
- sessionsMap.set(session.id, session);
809
- session.agents.push({ id: '', name: 'Main', type: 'main' });
810
- }
811
- for (const [aid, atype] of Object.entries(s.subagents || {})) {
812
- if (!session.agents.find(a => a.id === aid)) {
813
- session.agents.push({ id: aid, name: agentDisplayName(aid, atype), type: 'agent' });
814
- }
815
- }
816
- for (const t of (s.backgroundTasks || [])) {
817
- if (!session.tasks.find(ta => ta.id === t.id)) {
818
- session.tasks.push({
819
- id: t.id, parentAgentID: t.parentAgentID,
820
- toolName: t.toolName, outputPath: t.outputPath,
821
- isComplete: t.isComplete,
822
- });
823
- }
824
- }
825
- }
826
- // Initialize agentActivity from server-computed lastActivities (survives history skip)
827
- for (const [key, val] of Object.entries(payload.lastActivities || {})) {
828
- agentActivity.set(key, val);
829
- }
830
- updateFilters();
831
- rebuildNodes();
832
- needsFullRender = true;
833
- visibleDirty = true;
834
- // Don't render tree yet — wait for itemBatch to fill title/model/taskDescriptions
835
- }
836
-
837
- function handleNewSession(payload) {
838
- if (hiddenSessionIDs.has(payload.sessionID)) return;
839
- if (sessionsMap.has(payload.sessionID)) return;
840
- const session = {
841
- id: payload.sessionID, projectPath: payload.projectPath,
842
- title: '', folder: folderName(payload.projectPath), model: '',
843
- agents: [{ id: '', name: 'Main', type: 'main' }],
844
- tasks: [], collapsed: false, pinned: false,
845
- lastActivity: payload.birthtimeMs || Date.now(),
846
- birthtimeMs: payload.birthtimeMs || 0,
847
- };
848
- sessions.push(session);
849
- sessionsMap.set(session.id, session);
850
- updateFilters();
851
- rebuildNodes();
852
- needsFullRender = true;
853
- visibleDirty = true;
854
- scheduleRender();
855
- }
856
-
857
- function handleNewAgent(payload) {
858
- const s = sessionsMap.get(payload.sessionID);
859
- if (!s || s.agents.find(a => a.id === payload.agentID)) return;
860
- s.agents.push({
861
- id: payload.agentID,
862
- name: agentDisplayName(payload.agentID, payload.agentType),
863
- type: 'agent',
864
- });
865
- updateFilters();
866
- rebuildNodes();
867
- needsFullRender = true;
868
- visibleDirty = true;
869
- scheduleRender();
870
- }
871
-
872
- function handleNewBgTask(payload) {
873
- const s = sessionsMap.get(payload.sessionID);
874
- if (!s || s.tasks.find(t => t.id === payload.toolID)) return;
875
- s.tasks.push({
876
- id: payload.toolID, parentAgentID: payload.parentAgentID,
877
- toolName: payload.toolName, outputPath: payload.outputPath,
878
- isComplete: payload.isComplete,
879
- });
880
- rebuildNodes();
881
- scheduleRender();
882
- }
883
-
884
- function handleSessionRemoved(payload) {
885
- const sid = payload.sessionID;
886
- const s = sessionsMap.get(sid);
887
- if (s) {
888
- for (const a of s.agents) agentActivity.delete(sid + ':' + a.id);
889
- for (const t of s.tasks) taskDescriptions.delete(t.id);
890
- }
891
- const idx = sessions.findIndex(s => s.id === sid);
892
- if (idx >= 0) {
893
- sessions.splice(idx, 1);
894
- sessionsMap.delete(sid);
895
- }
896
- updateFilters();
897
- rebuildNodes();
898
- needsFullRender = true;
899
- visibleDirty = true;
900
- scheduleRender();
901
- }
902
-
903
- // ══════════════════════════════════════════════════════════════════════════════
904
- // Stream items
905
- // ══════════════════════════════════════════════════════════════════════════════
906
-
907
- function handleItem(item) {
908
- if (item.type === 'session_title') {
909
- const s = sessionsMap.get(item.sessionID);
910
- if (s) { s.title = item.content.slice(0, 30); }
911
- scheduleRender();
912
- return;
913
- }
914
- // Update activity
915
- const s = sessionsMap.get(item.sessionID);
916
- if (s) s.lastActivity = itemTime(item);
917
- pushItem(item);
918
- scheduleRender();
919
- }
920
-
921
- function handleItemBatch(items) {
922
- for (const item of items) {
923
- if (item.type === 'session_title') {
924
- const s = sessionsMap.get(item.sessionID);
925
- if (s) { s.title = item.content.slice(0, 30); }
926
- continue;
927
- }
928
- const s = sessionsMap.get(item.sessionID);
929
- if (s) s.lastActivity = itemTime(item);
930
- pushItem(item);
931
- }
932
- rebuildNodes();
933
- scheduleRender();
934
- }
935
-
936
- function pushItem(item) {
937
- if (hiddenSessionIDs.has(item.sessionID)) return;
938
- // Token counts are sourced exclusively from server context messages
939
- // to avoid divergence between frontend accumulation and server tracking
940
-
941
- if (item.model) {
942
- const s = sessionsMap.get(item.sessionID);
943
- if (s) s.model = item.model;
944
- }
945
-
946
- if (item.type === 'tool_input' && item.toolID && item.toolName) {
947
- toolNameMap.set(item.toolID, item.toolName);
948
- }
949
-
950
- if (item.type === 'tool_input') {
951
- if (item.agentID) {
952
- agentActivity.set(item.sessionID + ':' + item.agentID, { toolName: item.toolName || '', content: (item.content || '').slice(0, MAX_DESC_STORE) });
953
- }
954
- if (item.toolID) {
955
- taskDescriptions.set(item.toolID, (item.content || '').slice(0, MAX_DESC_STORE));
956
- }
957
- }
958
-
959
- if (item.type === 'user_text') {
960
- agentActivity.set(item.sessionID + ':' + (item.agentID || ''), { toolName: '', content: (item.content || '').slice(0, MAX_DESC_STORE) });
961
- }
962
-
963
- if (item.toolID) {
964
- const key = `${item.toolID}:${item.type}`;
965
- if (seenToolIDs.has(key)) return;
966
- seenToolIDs.set(key, true);
967
- }
968
-
969
- streamItems.push(item);
970
- if (streamItems.length > MAX_ITEMS) {
971
- streamItems = streamItems.slice(-MAX_ITEMS);
972
- visibleDirty = true;
973
- }
974
- // Incrementally update visibleItems — no need to re-filter on every item
975
- if (!visibleDirty && isItemVisible(item)) {
976
- visibleItems.push(item);
977
- }
978
- }
979
-
980
- function isItemVisible(item) {
981
- if (!filters.has(item.sessionID + ':' + (item.agentID || ''))) return false;
982
- switch (item.type) {
983
- case 'thinking': return showThinking;
984
- case 'tool_input': return showToolInput;
985
- case 'tool_output': return showToolOutput;
986
- case 'text': return showText;
987
- case 'hook_output': return showHook;
988
- case 'user_text': return showUserPrompt;
989
- default: return true;
990
- }
991
- }
992
-
993
- // ══════════════════════════════════════════════════════════════════════════════
994
- // Tree
995
- // ══════════════════════════════════════════════════════════════════════════════
996
-
997
- function idColor(rank) {
998
- const hue = (rank * 137.508) % 360;
999
- return `hsl(${hue}, 75%, 60%)`;
1000
- }
1001
-
1002
- function rebuildNodes() {
1003
- // Sort sessions by creation time, newest first
1004
- sessions.sort((a, b) => (b.birthtimeMs || 0) - (a.birthtimeMs || 0));
1005
- for (let i = 0; i < sessions.length; i++) sessions[i].colorRank = i;
1006
-
1007
- computeAgentIdDisplayLengths();
1008
-
1009
- const today = new Date();
1010
- const todayStr = `${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
1011
-
1012
- const flatSessions = [];
1013
- const olderByDate = new Map(); // dateStr -> [sessions]
1014
-
1015
- for (const s of sessions) {
1016
- const dateStr = s.birthtimeMs ? formatTime(s.birthtimeMs).split(' ')[0] : null;
1017
- if (!dateStr || dateStr === todayStr || isSessionActive(s)) {
1018
- flatSessions.push(s);
1019
- } else {
1020
- if (!olderByDate.has(dateStr)) olderByDate.set(dateStr, []);
1021
- olderByDate.get(dateStr).push(s);
1022
- }
1023
- }
1024
-
1025
- treeNodes = [];
1026
-
1027
- function addSessionWithChildren(s, inFolder) {
1028
- treeNodes.push({ type: 'session', level: 0, isLast: false, inFolder: !!inFolder, ...s });
1029
- if (s.collapsed) return;
1030
- const agents = s.agents || [];
1031
- const lastAgentIdx = agents.length - 1;
1032
- for (let ai = 0; ai < agents.length; ai++) {
1033
- const a = agents[ai];
1034
- const isLastAgent = ai === lastAgentIdx;
1035
- const tasks = s.tasks.filter(t =>
1036
- (a.id === '' && !t.parentAgentID) || t.parentAgentID === a.id
1037
- );
1038
- const lastTaskIdx = tasks.length - 1;
1039
- const hasTasks = tasks.length > 0;
1040
- const actKey = s.id + ':' + a.id;
1041
- const act = agentActivity.get(actKey);
1042
- treeNodes.push({
1043
- type: a.type, id: a.id, name: a.name, sessionID: s.id,
1044
- level: 1, isLast: isLastAgent,
1045
- activityTool: act ? act.toolName : '',
1046
- activityDesc: act ? act.content : '',
1047
- });
1048
- for (let ti = 0; ti < tasks.length; ti++) {
1049
- const t = tasks[ti];
1050
- const tDesc = taskDescriptions.get(t.id);
1051
- treeNodes.push({
1052
- type: 'task', id: t.id, name: t.toolName,
1053
- sessionID: s.id, parentAgentID: t.parentAgentID,
1054
- outputPath: t.outputPath, isComplete: t.isComplete,
1055
- level: 2, isLast: ti === lastTaskIdx,
1056
- parentIsLast: isLastAgent,
1057
- description: tDesc || '',
1058
- });
1059
- }
1060
- }
1061
- }
1062
-
1063
- // Today's + active sessions (expanded, not in date folders)
1064
- for (const s of flatSessions) {
1065
- addSessionWithChildren(s, false);
1066
- }
1067
-
1068
- // Date folders (older dates, collapsed by default)
1069
- const sortedDates = [...olderByDate.keys()].sort((a, b) => b.localeCompare(a));
1070
- for (let di = 0; di < sortedDates.length; di++) {
1071
- const dateStr = sortedDates[di];
1072
- const folderSessions = olderByDate.get(dateStr);
1073
- const collapsed = folderCollapsed[dateStr] !== false; // default collapsed
1074
- const isLastFolder = di === sortedDates.length - 1;
1075
-
1076
- treeNodes.push({
1077
- type: 'date-folder', date: dateStr, level: 0, isLast: false,
1078
- collapsed, sessionCount: folderSessions.length,
1079
- });
1080
-
1081
- if (!collapsed) {
1082
- for (const s of folderSessions) {
1083
- addSessionWithChildren(s, true);
1084
- }
1085
- }
1086
- }
1087
-
1088
- // Mark last session among flat sessions
1089
- const flatSessionNodes = treeNodes.filter(n => n.type === 'session' && !n.inFolder);
1090
- if (flatSessionNodes.length > 0) flatSessionNodes[flatSessionNodes.length - 1].isLast = true;
1091
-
1092
- // Mark last session inside each folder
1093
- for (const dateStr of sortedDates) {
1094
- if (folderCollapsed[dateStr] !== false) continue;
1095
- // Find sessions belonging to this folder
1096
- const thisFolder = [];
1097
- let inThisFolder = false;
1098
- for (const n of treeNodes) {
1099
- if (n.type === 'date-folder' && n.date === dateStr) { inThisFolder = true; continue; }
1100
- if (n.type === 'date-folder' && n.date !== dateStr) { inThisFolder = false; continue; }
1101
- if (inThisFolder && n.type === 'session') thisFolder.push(n);
1102
- }
1103
- if (thisFolder.length > 0) thisFolder[thisFolder.length - 1].isLast = true;
1104
- }
1105
-
1106
- if (treeCursor >= treeNodes.length) treeCursor = Math.max(0, treeNodes.length - 1);
1107
- treeDirty = true;
1108
- }
1109
-
1110
- function treePrefix(node) {
1111
- if (node.level === 0) {
1112
- return node.inFolder ? ' ' : '';
1113
- }
1114
- const branch = node.isLast ? '└──' : '├──';
1115
- if (node.level === 1) return ' ' + branch;
1116
- // Level 2: use pre-computed parentIsLast from rebuildNodes
1117
- const parentIsLast = node.parentIsLast !== undefined ? node.parentIsLast : true;
1118
- const stem = parentIsLast ? ' ' : '│ ';
1119
- return ' ' + stem + branch;
1120
- }
1121
-
1122
- function getNodeHTML(node, idx) {
1123
- const isSelected = idx === treeCursor;
1124
- const selClass = isSelected ? ' selected' : '';
1125
-
1126
- if (node.type === 'date-folder') {
1127
- const icon = node.collapsed ? '▸' : '▾';
1128
- return `<div class="tree-row tree-row-folder${selClass ? ' selected' : ''}">
1129
- <div class="tree-content" onclick="treeClick(${idx})" data-idx="${idx}">
1130
- <div class="tree-node folder-node">
1131
- ${icon} 📁 ${node.date} <span style="font-size:10px;color:var(--dim);margin-left:4px">(${node.sessionCount})</span>
1132
- </div>
1133
- </div>
1134
- </div>`;
1135
- }
1136
-
1137
- if (node.type === 'session') {
1138
- const displayName = folderName(node.projectPath) || node.title || node.id.slice(0, 14);
1139
- const parts = [];
1140
- if (node.model) parts.push(`🧠 ${esc(node.model)}`);
1141
- const activeDot = isSessionActive(node) ? '<span class="active-dot on">🟢</span>' : '<span class="active-dot off">⚪</span>';
1142
- const subInfo = parts.length > 0 ? ` <span style="color:#6b7280;font-size:10px">${parts.join(' · ')}</span>` : '';
1143
- const agentCount = node.agents ? node.agents.filter(a => a.type === 'agent').length : 0;
1144
- const timeStr = formatTime(node.birthtimeMs);
1145
- const timeHtml = timeStr ? `<span style="margin-left:auto;font-size:10px;color:var(--dim);flex-shrink:0">${timeStr}</span>` : '';
1146
- return `<div class="tree-row tree-row-session${selClass ? ' selected' : ''}">
1147
- <div class="tree-content" onclick="treeClick(${idx})" data-idx="${idx}">
1148
- <div class="tree-node">
1149
- <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)}
1150
- ${node.collapsed && agentCount > 0 ? `(${esc(String(agentCount))})` : ''}
1151
- ${subInfo}
1152
- ${timeHtml}
1153
- </div>
1154
- </div>
1155
- <span class="tree-actions">
1156
- <button class="btn btn-icon accent" onclick="event.stopPropagation();selectIndex(${idx});soloSelected()" data-tooltip="Solo">⊙</button>
1157
- <button class="btn btn-icon danger" onclick="event.stopPropagation();selectIndex(${idx});removeSelectedSession()" data-tooltip="Remove">✕</button>
1158
- </span>
1159
- </div>`;
1160
- }
1161
-
1162
- if (node.type === 'main' || node.type === 'agent') {
1163
- const icon = node.type === 'main' ? '💬' : '🤖';
1164
- const enabled = filters.get(node.sessionID + ':' + node.id);
1165
- const ctxKey = node.sessionID + ':' + node.id;
1166
- const ctx = contextData[ctxKey];
1167
- let ctxPct = '';
1168
- if (ctx && ctx.contextWindow > 0 && ctx.inputTokens > 0) {
1169
- const pct = Math.round(ctx.inputTokens / ctx.contextWindow * 100);
1170
- const cls = pct > 80 ? 'danger' : pct > 50 ? 'warn' : '';
1171
- if (showTokenCount) {
1172
- ctxPct = `<span class="ctx-pct ${cls}">${fmtTok(ctx.inputTokens)}</span>`;
1173
- } else {
1174
- ctxPct = `<span class="ctx-pct ${cls}">${pct}%</span>`;
1175
- }
1176
- }
1177
- const activeDot = ctx && (Date.now() - ctx.lastActivity < 120000) ? '<span class="active-dot on">🟢</span>' : '<span class="active-dot off">⚪</span>';
1178
- const actIcon = node.type === 'main' ? '🗣' : '⚡';
1179
- const actText = showActivity && (node.activityTool || node.activityDesc)
1180
- ? (node.activityTool && node.activityDesc ? `${node.activityTool}: ${node.activityDesc}` : (node.activityTool || node.activityDesc))
1181
- : '';
1182
- const indent = treePrefix(node).replace(/[├└]──/, ' ');
1183
- const actPrefix = `<span class="tree-prefix">${indent}</span>`;
1184
- const activityHTML = actText
1185
- ? `<div class="tree-activity">${actPrefix}<span class="act-text">${actIcon} ${esc(actText)}</span></div>`
1186
- : '';
1187
- return `<div class="tree-row${selClass ? ' selected' : ''}">
1188
- <div class="tree-content${enabled ? '' : ' dim'}" onclick="treeClick(${idx})" data-idx="${idx}">
1189
- <div class="tree-node">
1190
- <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}
1191
- </div>
1192
- ${activityHTML}
1193
- </div>
1194
- <span class="tree-actions">
1195
- <button class="btn btn-icon accent" onclick="event.stopPropagation();selectIndex(${idx});soloSelected()" data-tooltip="Solo">⊙</button>
1196
- <button class="btn btn-icon" onclick="event.stopPropagation();selectIndex(${idx});toggleNodeVisibility(${idx})" data-tooltip="${enabled ? 'Hide' : 'Show'}">${enabled ? '👁' : '─'}</button>
1197
- </span>
1198
- </div>`;
1199
- }
1200
-
1201
- if (node.type === 'task') {
1202
- const icon = node.isComplete ? '✓' : '⏳';
1203
- const taskIndent = treePrefix(node).replace(/[├└]──/, ' ');
1204
- const taskPrefix = `<span class="tree-prefix">${taskIndent}</span>`;
1205
- const descHTML = showActivity && node.description
1206
- ? `<div class="tree-activity">${taskPrefix}<span class="act-text">📋 ${esc(node.description)}</span></div>`
1207
- : '';
1208
- return `<div class="tree-row${selClass ? ' selected' : ''}">
1209
- <div class="tree-content dim" onclick="treeClick(${idx})" data-idx="${idx}">
1210
- <div class="tree-node">
1211
- <span class="tree-prefix">${treePrefix(node)}</span>${icon} ${esc(node.name || 'bg-task')}
1212
- </div>
1213
- ${descHTML}
1214
- </div>
1215
- <span class="tree-actions">
1216
- <button class="btn btn-icon" onclick="event.stopPropagation();selectIndex(${idx});loadBgTask(${idx})" data-tooltip="Load output">▶</button>
1217
- </span>
1218
- </div>`;
1219
- }
1220
-
1221
- return '';
1222
- }
1223
-
1224
- function renderTree() {
1225
- if (treeNodes.length === 0) {
1226
- treeEl.innerHTML = '<div class="tree-node" style="padding:8px;color:var(--dim)">Waiting for sessions...</div>';
1227
- treeCursorInfo.textContent = '';
1228
- return;
1229
- }
1230
-
1231
- const cursorChanged = treeCursor !== lastTreeCursor;
1232
- if (treeDirty) {
1233
- let html = '';
1234
- for (let i = 0; i < treeNodes.length; i++) {
1235
- html += getNodeHTML(treeNodes[i], i);
1236
- }
1237
- treeEl.innerHTML = html;
1238
- treeDirty = false;
1239
- } else if (cursorChanged) {
1240
- const prevSel = treeEl.querySelector('.tree-row.selected');
1241
- if (prevSel) prevSel.classList.remove('selected');
1242
- const newContent = treeEl.querySelector('[data-idx="' + treeCursor + '"]');
1243
- if (newContent) {
1244
- const row = newContent.closest('.tree-row');
1245
- if (row) row.classList.add('selected');
1246
- }
1247
- }
1248
- lastTreeCursor = treeCursor;
1249
-
1250
- // Scroll selected into view
1251
- const sel = treeEl.querySelector('.tree-row.selected');
1252
- if (sel) sel.scrollIntoView({ block: 'nearest' });
1253
-
1254
- treeCursorInfo.textContent = `${treeCursor + 1}/${treeNodes.length}`;
1255
- }
1256
-
1257
- function updateTreeDots() {
1258
- const dots = treeEl.querySelectorAll('.active-dot');
1259
- const now = Date.now();
1260
- for (const dot of dots) {
1261
- const content = dot.closest('.tree-content');
1262
- if (!content) continue;
1263
- const idx = parseInt(content.getAttribute('data-idx'));
1264
- if (isNaN(idx)) continue;
1265
- const node = treeNodes[idx];
1266
- if (!node) continue;
1267
- let active = false;
1268
- if (node.type === 'session') {
1269
- active = isSessionActive(node);
1270
- } else if (node.type === 'main' || node.type === 'agent') {
1271
- const ctxKey = node.sessionID + ':' + node.id;
1272
- const ctx = contextData[ctxKey];
1273
- const threshold = node.type === 'main' ? 600000 : 180000;
1274
- active = ctx && (now - ctx.lastActivity < threshold);
1275
- }
1276
- const newCls = active ? 'active-dot on' : 'active-dot off';
1277
- const newHTML = active ? '🟢' : '⚪';
1278
- if (dot.className !== newCls) {
1279
- dot.className = newCls;
1280
- dot.innerHTML = newHTML;
1281
- }
1282
- }
1283
- }
1284
-
1285
- const ACTIVE_THRESHOLD = 600000; // 10 minutes
1286
-
1287
- function itemTime(item) {
1288
- if (item && item.timestamp) {
1289
- const ts = item.timestamp instanceof Date ? item.timestamp : new Date(item.timestamp);
1290
- if (!isNaN(ts.getTime())) return ts.getTime();
1291
- }
1292
- return Date.now();
1293
- }
1294
-
1295
- function isSessionActive(session) {
1296
- if (!session) return false;
1297
- const now = Date.now();
1298
- // Main agent: 10 minutes
1299
- const mainCtx = contextData[session.id + ':'];
1300
- if (mainCtx && (now - mainCtx.lastActivity) < 600000) return true;
1301
- // Subagents: 3 minutes
1302
- for (const a of session.agents) {
1303
- if (a.id === '') continue;
1304
- const ctx = contextData[session.id + ':' + a.id];
1305
- if (ctx && (now - ctx.lastActivity) < 180000) return true;
1306
- }
1307
- // Session fallback: 10 minutes
1308
- return (now - session.lastActivity) < 600000;
1309
- }
1310
-
1311
- // ══════════════════════════════════════════════════════════════════════════════
1312
- // Stream rendering
1313
- // ══════════════════════════════════════════════════════════════════════════════
1314
-
1315
- function renderStream() {
1316
- // Rebuild visibleItems from scratch only when filters/toggles changed
1317
- if (visibleDirty) {
1318
- visibleItems = streamItems.filter(isItemVisible);
1319
- visibleDirty = false;
1320
- }
1321
-
1322
- const visible = visibleItems;
1323
- const wasAutoScroll = autoScroll;
1324
-
1325
- if (needsFullRender || renderedItemCount > visible.length) {
1326
- // Full rebuild: filter changed, items trimmed, or initial render
1327
- const lines = [];
1328
- for (const item of visible) {
1329
- for (const l of renderItem(item)) lines.push(l);
1330
- }
1331
-
1332
- let html;
1333
- if (lines.length > 0) {
1334
- html = lines.map(l => {
1335
- const sidAttr = l.sessionID ? ` data-session-id="${esc(l.sessionID)}"` : '';
1336
- if (l.html) return `<div class="${esc(l.cls)}"${sidAttr}>${l.text}</div>`;
1337
- return `<div class="${esc(l.cls)}"${sidAttr}>${esc(l.text)}</div>`;
1338
- }).join('\n');
1339
- } else if (streamItems.length > 0) {
1340
- html = `<div style="color:#fbbf24;padding:20px;text-align:center">${streamItems.length} items buffered, 0 visible — check toggles or tree selection</div>`;
1341
- } else {
1342
- html = '<div style="color:#6b7280;padding:20px;text-align:center">Waiting for output...</div>';
1343
- }
1344
-
1345
- streamEl.innerHTML = html;
1346
- renderedItemCount = visible.length;
1347
- needsFullRender = false;
1348
- if (wasAutoScroll) requestAnimationFrame(() => { streamEl.scrollTop = streamEl.scrollHeight; });
1349
- } else {
1350
- // Incremental append: only add new items since last render
1351
- for (let i = renderedItemCount; i < visible.length; i++) {
1352
- for (const l of renderItem(visible[i])) {
1353
- const div = document.createElement('div');
1354
- div.className = l.cls;
1355
- if (l.sessionID) div.dataset.sessionId = l.sessionID;
1356
- div.innerHTML = l.html ? l.text : esc(l.text);
1357
- streamEl.appendChild(div);
1358
- }
1359
- }
1360
- renderedItemCount = visible.length;
1361
- if (autoScroll) requestAnimationFrame(() => { streamEl.scrollTop = streamEl.scrollHeight; });
1362
- }
1363
-
1364
- const maxScroll = streamEl.scrollHeight - streamEl.clientHeight;
1365
- const pct = maxScroll > 0 ? Math.round(streamEl.scrollTop / maxScroll * 100) : 0;
1366
- document.getElementById('scroll-pos').textContent = Math.min(100, pct) + '%';
1367
- document.getElementById('item-count').textContent = streamItems.length + ' items';
1368
- }
1369
-
1370
- function renderItem(item) {
1371
- const lines = [];
1372
- const isSub = !!item.agentID;
1373
- const agentTagCls = 'stream-line ' + (isSub ? 'agent-sub agent-tag' : 'agent-main agent-tag');
1374
- const sep = ' » ';
1375
- const sid = item.sessionID || '';
1376
-
1377
- if (item.type === 'turn_marker') {
1378
- return [{ cls: 'stream-line marker', text: `── turn ended ${fmtDur(item.durationMs)} ──`, sessionID: sid }];
1379
- }
1380
- if (item.type === 'compact_marker') {
1381
- const label = item.content ? `compacted (${item.content})` : 'compacted';
1382
- return [{ cls: 'stream-line marker', text: `── ${label} ──`, sessionID: sid }];
1383
- }
1384
- if (item.type === 'pr_link') {
1385
- return [{ cls: 'stream-line marker', text: `── ${item.content} ──`, sessionID: sid }];
1386
- }
1387
-
1388
- const agentName = item.agentName || 'Main';
1389
- const sForColor = sessionsMap.get(item.sessionID);
1390
- const prefixTag = `<span class="session-prefix" style="color:${idColor(sForColor ? sForColor.colorRank : 0)}">[${esc(item.sessionID.split('-')[0].toUpperCase())}]</span>`;
1391
- 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>` : '';
1392
- const agentLabel = prefixTag + agentIdTag + ' ' + esc(agentName);
1393
- const tsHtml = item.timestamp ? `<span class="timestamp">${fmtTimestamp(item.timestamp)}</span>` : '';
1394
-
1395
- switch (item.type) {
1396
- case 'thinking':
1397
- lines.push({ cls: agentTagCls, text: `<span class="tag-label">${agentLabel}${sep}🧠 Thinking</span>${tsHtml}`, html: true, sessionID: sid });
1398
- for (const l of truncContent(item.content)) lines.push({ cls: 'stream-line thinking', text: l, sessionID: sid });
1399
- break;
1400
- case 'tool_input':
1401
- lines.push({ cls: agentTagCls, text: `<span class="tag-label">${agentLabel}${sep}🔧 ${esc(item.toolName || '')}</span>${tsHtml}`, html: true, sessionID: sid });
1402
- for (const l of truncContent(item.content)) lines.push({ cls: 'stream-line tool-input', text: l, sessionID: sid });
1403
- break;
1404
- case 'tool_output': {
1405
- let tn = '';
1406
- if (item.toolID) {
1407
- tn = toolNameMap.get(item.toolID) || '';
1408
- }
1409
- let label = tn ? `📤 ${tn} result` : '📤 Output';
1410
- if (item.durationMs > 0) label += ' ' + fmtDur(item.durationMs);
1411
- lines.push({ cls: agentTagCls, text: `<span class="tag-label">${agentLabel}${sep}${esc(label)}</span>${tsHtml}`, html: true, sessionID: sid });
1412
- for (const l of truncContent(item.content)) lines.push({ cls: 'stream-line tool-output', text: l, sessionID: sid });
1413
- break;
1414
- }
1415
- case 'text':
1416
- lines.push({ cls: agentTagCls, text: `<span class="tag-label">${agentLabel}${sep}💬 Response</span>${tsHtml}`, html: true, sessionID: sid });
1417
- lines.push({ cls: 'stream-line text md-content', text: mdRender(item.content), html: true, sessionID: sid });
1418
- break;
1419
- case 'hook_output': {
1420
- let label = '🪝 Hook';
1421
- if (item.toolName) label += ' ' + item.toolName;
1422
- if (item.durationMs > 0) label += ' ' + fmtDur(item.durationMs);
1423
- lines.push({ cls: agentTagCls, text: `<span class="tag-label">${agentLabel}${sep}${esc(label)}</span>${tsHtml}`, html: true, sessionID: sid });
1424
- if (item.hookCommand) lines.push({ cls: 'stream-line hook', text: `<span class="hook-label">command:</span> ${esc(item.hookCommand)}`, html: true, sessionID: sid });
1425
- if (item.hookContent) {
1426
- 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 });
1427
- }
1428
- 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 });
1429
- break;
1430
- }
1431
- case 'diagnostics': {
1432
- let label = '⚠ Diagnostics';
1433
- if (item.toolName) label += ' ' + item.toolName;
1434
- lines.push({ cls: agentTagCls, text: `<span class="tag-label">${agentLabel}${sep}${esc(label)}</span>${tsHtml}`, html: true, sessionID: sid });
1435
- for (const l of truncContent(item.content)) lines.push({ cls: 'stream-line diag', text: l, sessionID: sid });
1436
- break;
1437
- }
1438
- case 'debug': {
1439
- let label = '🔍 Debug';
1440
- if (item.toolName) label += ' ' + item.toolName;
1441
- lines.push({ cls: agentTagCls, text: `<span class="tag-label">${agentLabel}${sep}${esc(label)}</span>${tsHtml}`, html: true, sessionID: sid });
1442
- for (const l of truncContent(item.content)) lines.push({ cls: 'stream-line debug', text: l, sessionID: sid });
1443
- break;
1444
- }
1445
- case 'user_text':
1446
- lines.push({ cls: agentTagCls, text: `<span class="tag-label">${agentLabel}${sep}👤 User Prompt</span>${tsHtml}`, html: true, sessionID: sid });
1447
- lines.push({ cls: 'stream-line user-prompt-block md-content', text: mdRender(item.content), html: true, sessionID: sid });
1448
- break;
1449
- }
1450
-
1451
- lines.push({ cls: 'stream-line separator', text: '─'.repeat(60), sessionID: sid });
1452
- return lines;
1453
- }
1454
-
1455
- function truncContent(content) {
1456
- const raw = content.split('\n');
1457
- return raw.length > MAX_LINES ? raw.slice(0, MAX_LINES).concat([`... (${raw.length - MAX_LINES} more lines)`]) : raw;
1458
- }
1459
-
1460
- // ══════════════════════════════════════════════════════════════════════════════
1461
- // Button / header refresh
1462
- // ══════════════════════════════════════════════════════════════════════════════
1463
-
1464
- function refreshButtons() {
1465
- document.getElementById('btn-thinking').classList.toggle('on', showThinking);
1466
- document.getElementById('btn-tool-input').classList.toggle('on', showToolInput);
1467
- document.getElementById('btn-tool-output').classList.toggle('on', showToolOutput);
1468
- document.getElementById('btn-text').classList.toggle('on', showText);
1469
- document.getElementById('btn-hook').classList.toggle('on', showHook);
1470
- document.getElementById('btn-user-prompt').classList.toggle('on', showUserPrompt);
1471
- document.getElementById('btn-activity').classList.toggle('on', showActivity);
1472
- const btnTokenDisplay = document.getElementById('btn-token-display');
1473
- btnTokenDisplay.classList.toggle('on', true);
1474
- btnTokenDisplay.textContent = showTokenCount ? 'T' : '%';
1475
- btnTokenDisplay.setAttribute('data-tooltip', showTokenCount ? '上下文:Token数 ↔ 百分比切换' : '上下文:百分比 ↔ Token数切换');
1476
- document.getElementById('btn-autoscroll').classList.toggle('on', autoScroll);
1477
- document.getElementById('btn-tree-toggle').classList.toggle('on', showTree);
1478
- document.getElementById('btn-autodisco').classList.toggle('on', autoDiscovery);
1479
-
1480
- // Session info
1481
- let info = '';
1482
- if (sessions.length === 0) info = 'Waiting...';
1483
- else if (sessions.length === 1) {
1484
- const s = sessions[0];
1485
- info = (folderName(s.projectPath) || s.title || s.id.slice(0, 14));
1486
- } else info = sessions.length + ' sessions';
1487
- if (!autoDiscovery) info += ' [paused]';
1488
- sessionInfo.textContent = info;
1489
-
1490
- // Token info
1491
- computeTokensFromContext();
1492
- let tokStr = '';
1493
- if (totalInput > 0 || totalOutput > 0) {
1494
- tokStr = `${fmtTok(totalInput)} in / ${fmtTok(totalOutput)} out`;
1495
- if (totalCacheCreate > 0 || totalCacheRead > 0) {
1496
- tokStr += ` · cache ${fmtTok(totalCacheCreate)}+${fmtTok(totalCacheRead)}`;
1497
- }
1498
- }
1499
- tokenInfo.textContent = tokStr;
1500
-
1501
- // Footer version
1502
- const vEl = document.getElementById('footer-version');
1503
- if (vEl) {
1504
- const v = appVersion ? `v${appVersion}` : '';
1505
- 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>`;
1506
- }
1507
- }
1508
-
1509
- // ══════════════════════════════════════════════════════════════════════════════
1510
- // Session ID tooltip
1511
- // ══════════════════════════════════════════════════════════════════════════════
1512
-
1513
- let sessionIdTipTimer = null;
1514
- let sessionIdTipEl = null;
1515
- function showSessionIdTip(el) {
1516
- hideAllSessionIdTips();
1517
- const sid = el.getAttribute('data-sid');
1518
- if (!sid) return;
1519
- sessionIdTipTimer = setTimeout(() => {
1520
- const rect = el.getBoundingClientRect();
1521
- const tip = document.createElement('div');
1522
- tip.className = 'session-id-tip';
1523
- tip.style.top = (rect.bottom + 4) + 'px';
1524
- tip.style.left = rect.left + 'px';
1525
- tip.innerHTML = `<button class="tip-copy-btn" onclick="event.stopPropagation();copySessionId(this)">Copy</button><code>${esc(sid)}</code>`;
1526
- tip.onmouseenter = () => clearTimeout(sessionIdTipTimer);
1527
- tip.onmouseleave = () => { hideAllSessionIdTips(); };
1528
- document.body.appendChild(tip);
1529
- sessionIdTipEl = tip;
1530
- el._tip = tip;
1531
- }, 300);
1532
- }
1533
- function hideSessionIdTip(el) {
1534
- sessionIdTipTimer = setTimeout(() => {
1535
- if (el._tip) { el._tip.remove(); el._tip = null; }
1536
- sessionIdTipEl = null;
1537
- }, 200);
1538
- }
1539
- function hideAllSessionIdTips() {
1540
- clearTimeout(sessionIdTipTimer);
1541
- document.querySelectorAll('.session-id-tip').forEach(t => t.remove());
1542
- sessionIdTipEl = null;
1543
- }
1544
- function copySessionId(btn) {
1545
- const code = btn.parentElement.querySelector('code');
1546
- if (!code) return;
1547
- navigator.clipboard.writeText(code.textContent).then(() => {
1548
- btn.textContent = 'Copied!';
1549
- setTimeout(() => { btn.closest('.session-id-tip')?.remove(); }, 800);
1550
- });
1551
- }
1552
-
1553
- // ══════════════════════════════════════════════════════════════════════════════
1554
- // Actions
1555
- // ══════════════════════════════════════════════════════════════════════════════
1556
-
1557
- function selectIndex(idx) {
1558
- if (idx >= 0 && idx < treeNodes.length) treeCursor = idx;
1559
- }
1560
-
1561
- function treeClick(idx) {
1562
- selectIndex(idx);
1563
- const node = treeNodes[idx];
1564
- if (!node) return;
1565
- if (node.type === 'date-folder') {
1566
- node.collapsed = !node.collapsed;
1567
- folderCollapsed[node.date] = node.collapsed;
1568
- rebuildNodes();
1569
- } else if (node.type === 'session') {
1570
- const session = sessions.find(s => s.id === node.id);
1571
- if (session) {
1572
- session.collapsed = !session.collapsed;
1573
- if (!session.collapsed) session.pinned = true;
1574
- }
1575
- rebuildNodes();
1576
- } else if (node.type === 'main' || node.type === 'agent') {
1577
- toggleNodeVisibility(idx);
1578
- return;
1579
- } else if (node.type === 'task') {
1580
- loadBgTask(idx);
1581
- return;
1582
- }
1583
- renderAll();
1584
- }
1585
-
1586
- function toggleNodeVisibility(idx) {
1587
- const node = treeNodes[idx];
1588
- if (!node) return;
1589
- const key = node.sessionID + ':' + node.id;
1590
- const wasEnabled = filters.get(key);
1591
- filters.set(key, !wasEnabled);
1592
- if (wasEnabled) visibleFilterCount--;
1593
- else visibleFilterCount++;
1594
- renderAll();
1595
- }
1596
-
1597
- function loadBgTask(idx) {
1598
- const node = treeNodes[idx];
1599
- if (!node || node.type !== 'task') return;
1600
- if (!node.outputPath) return;
1601
-
1602
- // Fetch the actual output file
1603
- fetch(`/api/task-output?path=${encodeURIComponent(node.outputPath)}`)
1604
- .then(r => r.json())
1605
- .then(data => {
1606
- const content = data.content || `[Error: ${data.error || 'unknown'}]`;
1607
- const statusIcon = node.isComplete ? '✓' : '⏳';
1608
- streamItems.push({
1609
- type: 'tool_output', sessionID: node.sessionID, agentID: node.parentAgentID || '',
1610
- agentName: '', toolName: `${statusIcon} ${node.name || 'bg-task'}`,
1611
- content: content,
1612
- timestamp: new Date(), toolID: '', durationMs: 0,
1613
- inputTokens: 0, outputTokens: 0, cacheCreationTokens: 0, cacheReadTokens: 0, model: '',
1614
- });
1615
- renderAll();
1616
- })
1617
- .catch(err => {
1618
- streamItems.push({
1619
- type: 'tool_output', sessionID: node.sessionID, agentID: node.parentAgentID || '',
1620
- agentName: '', toolName: `⏳ ${node.name || 'bg-task'}`,
1621
- content: `[Failed to load: ${err.message}]`,
1622
- timestamp: new Date(), toolID: '', durationMs: 0,
1623
- inputTokens: 0, outputTokens: 0, cacheCreationTokens: 0, cacheReadTokens: 0, model: '',
1624
- });
1625
- renderAll();
1626
- });
1627
- }
1628
-
1629
- function soloSelected() {
1630
- const node = treeNodes[treeCursor];
1631
- if (!node || node.type === 'task') return;
1632
-
1633
- if (isSoloed(node)) {
1634
- updateFilters();
1635
- } else {
1636
- filters.clear();
1637
- visibleFilterCount = 0;
1638
- if (node.type === 'session') {
1639
- const session = sessions.find(s => s.id === node.id);
1640
- if (session && session.collapsed) {
1641
- session.collapsed = false;
1642
- session.pinned = true;
1643
- rebuildNodes();
1644
- }
1645
- for (const a of node.agents) {
1646
- filters.set(node.id + ':' + a.id, true);
1647
- visibleFilterCount++;
1648
- }
1649
- } else if (node.type === 'main' || node.type === 'agent') {
1650
- filters.set(node.sessionID + ':' + node.id, true);
1651
- visibleFilterCount = 1;
1652
- }
1653
- }
1654
- renderAll();
1655
- }
1656
-
1657
- function isSoloed(node) {
1658
- if (node.type === 'session') {
1659
- if (visibleFilterCount !== node.agents.length) return false;
1660
- for (const a of node.agents) {
1661
- if (!filters.get(node.id + ':' + a.id)) return false;
1662
- }
1663
- return true;
1664
- }
1665
- if (node.type === 'main' || node.type === 'agent') {
1666
- const key = node.sessionID + ':' + node.id;
1667
- return visibleFilterCount === 1 && filters.get(key);
1668
- }
1669
- return false;
1670
- }
1671
-
1672
- function selectAll() {
1673
- updateFilters();
1674
- renderAll();
1675
- }
1676
-
1677
- function removeSelectedSession() {
1678
- const node = treeNodes[treeCursor];
1679
- if (!node) return;
1680
- let sid;
1681
- if (node.type === 'session') sid = node.id;
1682
- else sid = node.sessionID;
1683
- if (!sid) return;
1684
- if (!confirm(`Remove session ${sid.slice(0, 12)}...?`)) return;
1685
- hiddenSessionIDs.add(sid);
1686
- _saveHiddenSessions();
1687
- const idx = sessions.findIndex(s => s.id === sid);
1688
- if (idx >= 0) {
1689
- sessions.splice(idx, 1);
1690
- sessionsMap.delete(sid);
1691
- }
1692
- sendCmd('removeSession', { sessionID: sid });
1693
- updateFilters();
1694
- rebuildNodes();
1695
- renderAll();
1696
- }
1697
-
1698
- // ══════════════════════════════════════════════════════════════════════════════
1699
- // Toggles
1700
- // ══════════════════════════════════════════════════════════════════════════════
1701
-
1702
- function toggleThinking() { showThinking = !showThinking; needsFullRender = true;
1703
- visibleDirty = true; renderStream(); refreshButtons(); }
1704
- function toggleToolInput() { showToolInput = !showToolInput; needsFullRender = true;
1705
- visibleDirty = true; renderStream(); refreshButtons(); }
1706
- function toggleToolOutput() { showToolOutput = !showToolOutput; needsFullRender = true;
1707
- visibleDirty = true; renderStream(); refreshButtons(); }
1708
- function toggleText() { showText = !showText; needsFullRender = true;
1709
- visibleDirty = true; renderStream(); refreshButtons(); }
1710
- function toggleHook() { showHook = !showHook; needsFullRender = true;
1711
- visibleDirty = true; renderStream(); refreshButtons(); }
1712
- function toggleUserPrompt() { showUserPrompt = !showUserPrompt; needsFullRender = true;
1713
- visibleDirty = true; renderStream(); refreshButtons(); }
1714
- function toggleActivity() { showActivity = !showActivity; rebuildNodes(); scheduleRender(); refreshButtons(); }
1715
- function toggleTokenDisplay() {
1716
- showTokenCount = !showTokenCount;
1717
- treeDirty = true;
1718
- scheduleRender();
1719
- refreshButtons();
1720
- }
1721
-
1722
- // ══════════════════════════════════════════════════════════════════════════════
1723
- // Tab switching & Token stats page
1724
- // ══════════════════════════════════════════════════════════════════════════════
1725
- // Token Statistics (completely independent from stream/context)
1726
- // ══════════════════════════════════════════════════════════════════════════════
1727
-
1728
- const MODEL_COLORS = {
1729
- 'claude-opus-4-7': '#e74c3c', 'claude-opus-4-6': '#c0392b', 'claude-opus-4-8': '#e67e22',
1730
- 'claude-sonnet-4-6': '#3498db', 'claude-sonnet-4-5': '#2980b9',
1731
- 'claude-haiku-4-5': '#5dade2', 'claude-haiku-4': '#1abc9c',
1732
- 'glm-5.1': '#2980b9', 'glm-5': '#3498db', 'glm-4.7': '#5dade2',
1733
- 'qwen3.7-max': '#55efc4', 'qwen3.6-plus': '#2ecc71', 'qwen3.5-plus': '#27ae60',
1734
- 'qwen3-max': '#1abc9c',
1735
- 'deepseek-v4-pro': '#9b59b6',
1736
- 'kimi-k2.5': '#f39c12', 'kimi-k2.6': '#d35400', 'kimi-k2-thinking': '#d4a017',
1737
- 'MiniMax-M2.5': '#1abc9c',
1738
- };
1739
- let _modelColorIdx = 0;
1740
- function modelColor(name) {
1741
- if (MODEL_COLORS[name]) return MODEL_COLORS[name];
1742
- const fallback = ['#e74c3c','#3498db','#2ecc71','#9b59b6','#f39c12','#1abc9c','#e67e22','#c0392b','#5dade2','#d35400','#55efc4','#d4a017'];
1743
- return fallback[_modelColorIdx++ % fallback.length];
1744
- }
1745
-
1746
- let tsDetailTab = 'daily';
1747
-
1748
- function switchTab(tab) {
1749
- currentTab = tab;
1750
- document.getElementById('main').style.display = tab === 'stream' ? 'flex' : 'none';
1751
- document.getElementById('tokens-page').style.display = tab === 'tokens' ? 'flex' : 'none';
1752
- document.getElementById('tab-stream').classList.toggle('on', tab === 'stream');
1753
- document.getElementById('tab-tokens').classList.toggle('on', tab === 'tokens');
1754
- document.getElementById('footer').style.display = tab === 'stream' ? 'flex' : 'none';
1755
- if (tab === 'tokens') renderTokenPage();
1756
- }
1757
-
1758
- function tsSwitchDetail(n) {
1759
- tsDetailTab = n;
1760
- document.querySelectorAll('.tp-tab').forEach(t => t.classList.remove('active'));
1761
- document.querySelectorAll('.tp-tc').forEach(t => t.classList.remove('active'));
1762
- document.querySelector(`.tp-tab[data-tab="${n}"]`)?.classList.add('active');
1763
- document.getElementById('tp-tc-' + n)?.classList.add('active');
1764
- }
1765
-
1766
- function fmtTS(n) {
1767
- if (!n) return '0';
1768
- return n.toLocaleString();
1769
- }
1770
-
1771
- function fmtDateISO(d) {
1772
- return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0');
1773
- }
1774
-
1775
- // ── Heatmap: 52-week × 7-day GitHub-style grid ──
1776
- function buildHeatmap(daily) {
1777
- const today = new Date();
1778
- const dailyTotalsMap = {};
1779
- for (const [k, d] of Object.entries(daily)) {
1780
- dailyTotalsMap[k] = d.input + d.output + d.cacheCreation + d.cacheRead;
1781
- }
1782
-
1783
- // Start 52 weeks ago from Sunday
1784
- const startSunday = new Date(today);
1785
- startSunday.setDate(startSunday.getDate() - startSunday.getDay() - 52 * 7);
1786
- const startStr = fmtDateISO(startSunday);
1787
-
1788
- // Compute maxVal only from dates within the heatmap window
1789
- let maxVal = 0;
1790
- for (const [k, v] of Object.entries(dailyTotalsMap)) {
1791
- if (k >= startStr && v > maxVal) maxVal = v;
1792
- }
1793
-
1794
- const weeks = [];
1795
- const monthLabels = [];
1796
- let lastMonth = -1;
1797
- let currentSunday = new Date(startSunday);
1798
-
1799
- for (let w = 0; w < 53; w++) {
1800
- const weekData = [];
1801
- for (let dow = 0; dow < 7; dow++) {
1802
- const d = new Date(currentSunday);
1803
- d.setDate(d.getDate() + dow);
1804
- const ds = fmtDateISO(d);
1805
- const val = dailyTotalsMap[ds] || 0;
1806
- weekData.push({ date: ds, val, future: d > today });
1807
- if (dow === 0) {
1808
- const m = d.getMonth();
1809
- if (m !== lastMonth) { monthLabels.push({ month: m, week: w }); lastMonth = m; }
1810
- }
1811
- }
1812
- weeks.push(weekData);
1813
- currentSunday.setDate(currentSunday.getDate() + 7);
1814
- }
1815
-
1816
- function cellColor(val, future) {
1817
- if (future) return 'var(--bg3)';
1818
- if (val === 0) return '#0d423d';
1819
- const pct = maxVal > 0 ? val / maxVal : 0;
1820
- if (pct < 0.25) return '#0e6b5a';
1821
- if (pct < 0.5) return '#12b886';
1822
- if (pct < 0.75) return '#34d399';
1823
- return '#6ee7b7';
1824
- }
1825
-
1826
- const monthNames = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
1827
- let monthsHTML = '<div class="tp-hm-months">';
1828
- let prevWeek = 0;
1829
- for (const ml of monthLabels) {
1830
- const offset = ml.week - prevWeek;
1831
- if (offset > 0) monthsHTML += `<span style="width:${offset * 14}px"></span>`;
1832
- monthsHTML += `<span style="width:14px">${monthNames[ml.month]}</span>`;
1833
- prevWeek = ml.week + 1;
1834
- }
1835
- monthsHTML += '</div>';
1836
-
1837
- const dayLabels = ['','Mon','','Wed','','Fri',''];
1838
- let gridHTML = '';
1839
- for (let dow = 0; dow < 7; dow++) {
1840
- gridHTML += `<div class="tp-hm-row"><span class="tp-hm-day-label">${dayLabels[dow]}</span>`;
1841
- for (let w = 0; w < weeks.length; w++) {
1842
- const cell = weeks[w][dow];
1843
- const bg = cellColor(cell.val, cell.future);
1844
- const tip = `${cell.date} · ${fmtTS(cell.val)} tokens`;
1845
- gridHTML += `<span class="tp-hm-cell" style="background:${bg}" title="${tip}"></span>`;
1846
- }
1847
- gridHTML += '</div>';
1848
- }
1849
-
1850
- let legendHTML = '<div class="tp-hm-legend"><span>Less</span>';
1851
- legendHTML += '<span class="tp-hm-legend-cell" style="background:#0d423d"></span>';
1852
- legendHTML += '<span class="tp-hm-legend-cell" style="background:#0e6b5a"></span>';
1853
- legendHTML += '<span class="tp-hm-legend-cell" style="background:#12b886"></span>';
1854
- legendHTML += '<span class="tp-hm-legend-cell" style="background:#34d399"></span>';
1855
- legendHTML += '<span class="tp-hm-legend-cell" style="background:#6ee7b7"></span>';
1856
- legendHTML += '<span>More</span></div>';
1857
-
1858
- return `<div class="tp-heatmap"><div class="tp-heatmap-inner">${monthsHTML}${gridHTML}</div>${legendHTML}</div>`;
1859
- }
1860
-
1861
- // ── Trend: bar chart for last 30 days ──
1862
- function buildTrend(daily) {
1863
- const keys = Object.keys(daily).sort();
1864
- const recentKeys = keys.slice(-30);
1865
- if (recentKeys.length === 0) return '<div style="color:var(--dim);padding:8px">暂无趋势数据</div>';
1866
-
1867
- const values = recentKeys.map(k => {
1868
- const d = daily[k];
1869
- return d.input + d.output + d.cacheCreation + d.cacheRead;
1870
- });
1871
- const maxVal = Math.max(...values);
1872
-
1873
- let barsHTML = '';
1874
- for (let i = 0; i < recentKeys.length; i++) {
1875
- const k = recentKeys[i];
1876
- const v = values[i];
1877
- const pct = maxVal > 0 ? (v / maxVal * 100) : 0;
1878
- const label = k.slice(5);
1879
- const tip = `${k}: ${fmtTS(v)}`;
1880
- const color = pct < 30 ? '#0e6b5a' : pct < 60 ? '#12b886' : pct < 80 ? '#34d399' : '#6ee7b7';
1881
- barsHTML += `<div class="tp-trend-bar-wrap"><div class="tp-trend-bar" style="height:${Math.max(pct, 3)}%;background:${color}" data-tip="${tip}"></div><span class="tp-trend-label">${label}</span></div>`;
1882
- }
1883
-
1884
- const gridLines = `<div class="tp-trend-grid-lines">
1885
- <span style="font-size:9px;color:var(--dim);align-self:flex-start">${fmtTS(maxVal)}</span>
1886
- <div class="tp-trend-grid-line"></div>
1887
- <div class="tp-trend-grid-line"></div>
1888
- <span style="font-size:9px;color:var(--dim);align-self:center">${fmtTS(maxVal * 0.5)}</span>
1889
- <div class="tp-trend-grid-line"></div>
1890
- <span style="font-size:9px;color:var(--dim);align-self:flex-end">0</span>
1891
- </div>`;
1892
-
1893
- return `<div class="tp-trend-bars">${gridLines}${barsHTML}</div>`;
1894
- }
1895
-
1896
- // ── Model ranking sidebar ──
1897
- function buildModelRank(mt, totalAll) {
1898
- const sorted = Object.entries(mt).sort((a, b) => {
1899
- const sA = a[1].input + a[1].output + a[1].cacheCreation + a[1].cacheRead;
1900
- const sB = b[1].input + b[1].output + b[1].cacheCreation + b[1].cacheRead;
1901
- return sB - sA;
1902
- });
1903
-
1904
- let html = '<div class="tp-rank-title">🏆 Model Ranking</div>';
1905
- for (let i = 0; i < Math.min(sorted.length, 5); i++) {
1906
- const [name, m] = sorted[i];
1907
- const mTotal = m.input + m.output + m.cacheCreation + m.cacheRead;
1908
- const pct = totalAll > 0 ? (mTotal / totalAll * 100).toFixed(1) : '0';
1909
- const c = modelColor(name);
1910
- html += `<div class="tp-rank-item">
1911
- <span class="tp-rank-num">${i + 1}</span>
1912
- <span class="tp-rank-dot" style="background:${c}"></span>
1913
- <span class="tp-rank-name">${esc(name)}</span>
1914
- <span class="tp-rank-pct">${pct}%</span>
1915
- </div>`;
1916
- }
1917
- return html;
1918
- }
1919
-
1920
- // ── Render entire token page ──
1921
- function renderTokenPage() {
1922
- const t = tokenStatsData.totals;
1923
- const mt = tokenStatsData.modelTotals;
1924
- const daily = tokenStatsData.daily;
1925
- const totalAll = t.input + t.output + t.cacheCreation + t.cacheRead;
1926
-
1927
- if (totalAll === 0) {
1928
- document.getElementById('tp-total-card').innerHTML = '<div style="color:var(--dim);padding:8px">暂无历史 Token 数据</div>';
1929
- document.getElementById('tp-stats-grid').innerHTML = '';
1930
- document.getElementById('tp-model-rank').innerHTML = '';
1931
- document.getElementById('tp-trend-card').innerHTML = '';
1932
- document.getElementById('tp-heatmap-card').innerHTML = '';
1933
- document.getElementById('tp-detail-tabs').innerHTML = '';
1934
- document.getElementById('tp-daily-table').innerHTML = '';
1935
- document.getElementById('tp-weekly-table').innerHTML = '';
1936
- document.getElementById('tp-monthly-table').innerHTML = '';
1937
- return;
1938
- }
1939
-
1940
- const inputPct = totalAll > 0 ? (t.input / totalAll * 100).toFixed(1) : '0';
1941
- const outputPct = totalAll > 0 ? (t.output / totalAll * 100).toFixed(1) : '0';
1942
- const crPct = totalAll > 0 ? (t.cacheRead / totalAll * 100).toFixed(1) : '0';
1943
- const ccPct = totalAll > 0 ? (t.cacheCreation / totalAll * 100).toFixed(1) : '0';
1944
- const dailyAvg = t.days > 0 ? Math.round(totalAll / t.days).toLocaleString() : '—';
1945
-
1946
- // 1. Total tokens card
1947
- document.getElementById('tp-total-card').innerHTML = `
1948
- <div class="tp-total-label">TOTAL TOKENS</div>
1949
- <div class="tp-total-value">${fmtTS(totalAll)}</div>
1950
- <div class="tp-footer-stats">
1951
- <span>Started <span class="tp-fv">${Object.keys(daily).sort()[0] || '—'}</span></span>
1952
- <span>Active <span class="tp-fv">${t.days} DAY</span></span>
1953
- <span>Models <span class="tp-fv">${Object.keys(mt).length}</span></span>
1954
- </div>`;
1955
-
1956
- // 2. Stats grid
1957
- const stats = [
1958
- { l: 'Input', v: fmtTS(t.input), s: inputPct + '%' },
1959
- { l: 'Output', v: fmtTS(t.output), s: outputPct + '%' },
1960
- { l: 'Cache Read', v: fmtTS(t.cacheRead), s: crPct + '%' },
1961
- { l: 'Cache Create', v: fmtTS(t.cacheCreation), s: ccPct + '%' },
1962
- { l: 'Messages', v: fmtTS(t.messages), s: t.messages.toLocaleString() },
1963
- { l: 'Daily Avg', v: dailyAvg, s: 'tokens/day' },
1964
- ];
1965
- document.getElementById('tp-stats-grid').innerHTML = `<div class="tp-stat-grid">${stats.map(s => `<div class="tp-stat"><div class="tp-s-l">${s.l}</div><div class="tp-s-v">${s.v}</div><div style="font-size:9px;color:var(--dim)">${s.s}</div></div>`).join('')}</div>`;
1966
-
1967
- // 3. Model ranking
1968
- document.getElementById('tp-model-rank').innerHTML = buildModelRank(mt, totalAll);
1969
-
1970
- // 4. Usage Trend
1971
- document.getElementById('tp-trend-card').innerHTML = `<div class="tp-h3">📊 Usage Trend</div>${buildTrend(daily)}`;
1972
-
1973
- // 5. Activity Heatmap
1974
- const tzOffset = -(new Date().getTimezoneOffset() / 60);
1975
- document.getElementById('tp-heatmap-card').innerHTML = `<div class="tp-h3">🗓 Activity Heatmap</div><span style="font-size:10px;color:var(--dim);float:right">UTC+${tzOffset.toFixed(0)}</span>${buildHeatmap(daily)}`;
1976
-
1977
- // 6. Detail tabs
1978
- const dailyKeys = Object.keys(daily);
1979
- const weeklyCount = weeklyKeysFromDaily(dailyKeys).length;
1980
- const monthlyCount = monthlyKeysFromDaily(dailyKeys).length;
1981
- document.getElementById('tp-detail-tabs').innerHTML = `<div class="tp-tab ${tsDetailTab === 'daily' ? 'active' : ''}" data-tab="daily" onclick="tsSwitchDetail('daily')">Daily Breakdown (${dailyKeys.length})</div><div class="tp-tab ${tsDetailTab === 'weekly' ? 'active' : ''}" data-tab="weekly" onclick="tsSwitchDetail('weekly')">Weekly (${weeklyCount})</div><div class="tp-tab ${tsDetailTab === 'monthly' ? 'active' : ''}" data-tab="monthly" onclick="tsSwitchDetail('monthly')">Monthly (${monthlyCount})</div>`;
1982
- document.querySelectorAll('.tp-tc').forEach(tc => tc.classList.remove('active'));
1983
- document.getElementById('tp-tc-' + tsDetailTab)?.classList.add('active');
1984
-
1985
- document.getElementById('tp-daily-table').innerHTML = renderPeriodTable(dailyKeys, daily, 'daily');
1986
- const weekly = aggregateWeekly(dailyKeys, daily);
1987
- document.getElementById('tp-weekly-table').innerHTML = renderPeriodTable(Object.keys(weekly), weekly, 'weekly');
1988
- const monthly = aggregateMonthly(dailyKeys, daily);
1989
- document.getElementById('tp-monthly-table').innerHTML = renderPeriodTable(Object.keys(monthly), monthly, 'monthly');
1990
- }
1991
-
1992
- function weeklyKeysFromDaily(keys) {
1993
- const weeks = new Set();
1994
- for (const k of keys) { const d = new Date(k); const wk = getWeekKey(d); weeks.add(wk); }
1995
- return [...weeks];
1996
- }
1997
- function monthlyKeysFromDaily(keys) {
1998
- const months = new Set();
1999
- for (const k of keys) { months.add(k.slice(0, 7)); }
2000
- return [...months];
2001
- }
2002
-
2003
- function getWeekKey(d) {
2004
- const dayNum = d.getDay() || 7; // Sunday (0) becomes 7 for ISO week calculation
2005
- const thursday = new Date(d);
2006
- thursday.setDate(d.getDate() + 4 - dayNum); // ISO Thursday offset
2007
- const year = thursday.getFullYear();
2008
- const jan1 = new Date(year, 0, 1);
2009
- const wk = Math.ceil(((thursday - jan1) / 86400000 + jan1.getDay() + 1) / 7);
2010
- return year + '-W' + String(wk).padStart(2, '0');
2011
- }
2012
-
2013
- function aggregateWeekly(dailyKeys, daily) {
2014
- const result = {};
2015
- for (const k of dailyKeys) {
2016
- const d = new Date(k);
2017
- const wk = getWeekKey(d);
2018
- if (!result[wk]) result[wk] = { messages: 0, input: 0, output: 0, cacheCreation: 0, cacheRead: 0, models: {}, dateRange: k };
2019
- else result[wk].dateRange += ' ~ ' + k;
2020
- const day = daily[k];
2021
- result[wk].messages += day.messages;
2022
- result[wk].input += day.input;
2023
- result[wk].output += day.output;
2024
- result[wk].cacheCreation += day.cacheCreation;
2025
- result[wk].cacheRead += day.cacheRead;
2026
- for (const [mn, m] of Object.entries(day.models)) {
2027
- if (!result[wk].models[mn]) result[wk].models[mn] = { input: 0, output: 0, cacheCreation: 0, cacheRead: 0 };
2028
- result[wk].models[mn].input += m.input;
2029
- result[wk].models[mn].output += m.output;
2030
- result[wk].models[mn].cacheCreation += m.cacheCreation;
2031
- result[wk].models[mn].cacheRead += m.cacheRead;
2032
- }
2033
- }
2034
- return result;
2035
- }
2036
-
2037
- function aggregateMonthly(dailyKeys, daily) {
2038
- const result = {};
2039
- for (const k of dailyKeys) {
2040
- const mk = k.slice(0, 7);
2041
- if (!result[mk]) result[mk] = { messages: 0, input: 0, output: 0, cacheCreation: 0, cacheRead: 0, models: {}, dateRange: k };
2042
- else result[mk].dateRange += ' ~ ' + k.slice(5);
2043
- const day = daily[k];
2044
- result[mk].messages += day.messages;
2045
- result[mk].input += day.input;
2046
- result[mk].output += day.output;
2047
- result[mk].cacheCreation += day.cacheCreation;
2048
- result[mk].cacheRead += day.cacheRead;
2049
- for (const [mn, m] of Object.entries(day.models)) {
2050
- if (!result[mk].models[mn]) result[mk].models[mn] = { input: 0, output: 0, cacheCreation: 0, cacheRead: 0 };
2051
- result[mk].models[mn].input += m.input;
2052
- result[mk].models[mn].output += m.output;
2053
- result[mk].models[mn].cacheCreation += m.cacheCreation;
2054
- result[mk].models[mn].cacheRead += m.cacheRead;
2055
- }
2056
- }
2057
- return result;
2058
- }
2059
-
2060
- function renderPeriodTable(keys, data, type) {
2061
- const sorted = keys.sort((a, b) => b.localeCompare(a));
2062
- let html = '<table class="tp-table"><thead><tr><th>Date</th><th>Total</th><th>Input</th><th>Output</th><th>Cache Read</th><th>Cache Create</th><th>Messages</th><th>Models</th></tr></thead><tbody>';
2063
- for (const k of sorted) {
2064
- const d = data[k];
2065
- const total = d.input + d.output + d.cacheCreation + d.cacheRead;
2066
- const label = type === 'daily' ? k : k + '<br><small style="color:var(--dim)">' + esc(d.dateRange || '') + '</small>';
2067
- const modelsHtml = Object.entries(d.models).sort((a, b) => {
2068
- const sA = a[1].input + a[1].output + a[1].cacheCreation + a[1].cacheRead;
2069
- const sB = b[1].input + b[1].output + b[1].cacheCreation + b[1].cacheRead;
2070
- return sB - sA;
2071
- }).slice(0, 4).map(([mn, m]) => {
2072
- const mT = m.input + m.output + m.cacheCreation + m.cacheRead;
2073
- const c = modelColor(mn);
2074
- return `<span class="tp-mtag" style="background:${c}20;border-color:${c};color:${c}">${esc(mn)}: ${fmtTS(mT)}</span>`;
2075
- }).join(' ');
2076
- html += `<tr><td>${label}</td><td><b>${fmtTS(total)}</b></td><td>${fmtTS(d.input)}</td><td>${fmtTS(d.output)}</td><td>${fmtTS(d.cacheRead)}</td><td>${fmtTS(d.cacheCreation)}</td><td>${d.messages.toLocaleString()}</td><td class="tp-mbreak">${modelsHtml}</td></tr>`;
2077
- }
2078
- html += '</tbody></table>';
2079
- return html;
2080
- }
2081
- function toggleAutoScroll() { autoScroll = !autoScroll; if (autoScroll) streamEl.scrollTop = streamEl.scrollHeight; renderAll(); }
2082
- function toggleTree() { showTree = !showTree; document.getElementById('tree-panel').classList.toggle('hidden', !showTree); }
2083
- function toggleAutoDiscovery() { sendCmd('toggleAutoDiscovery'); }
2084
-
2085
- function scrollToTop() { streamEl.scrollTop = 0; autoScroll = false; renderAll(); }
2086
- function scrollUp() { streamEl.scrollTop -= 80; autoScroll = false; renderAll(); }
2087
- function scrollDown() { streamEl.scrollTop += 80; if (autoScroll) autoScroll = false; renderAll(); }
2088
- function scrollToBottom() { streamEl.scrollTop = streamEl.scrollHeight; autoScroll = true; renderAll(); }
2089
-
2090
- // ══════════════════════════════════════════════════════════════════════════════
2091
- // Tree panel resize
2092
- // ══════════════════════════════════════════════════════════════════════════════
2093
-
2094
- (function setupResize() {
2095
- const panel = document.getElementById('tree-panel');
2096
- const handle = document.getElementById('tree-resize-handle');
2097
- let startX, startWidth;
2098
-
2099
- handle.addEventListener('mousedown', (e) => {
2100
- e.preventDefault();
2101
- startX = e.clientX;
2102
- startWidth = panel.offsetWidth;
2103
- handle.classList.add('active');
2104
- document.body.style.cursor = 'col-resize';
2105
- document.body.style.userSelect = 'none';
2106
- });
2107
-
2108
- document.addEventListener('mousemove', (e) => {
2109
- if (!handle.classList.contains('active')) return;
2110
- const dx = e.clientX - startX;
2111
- const newWidth = startWidth + dx;
2112
- if (newWidth >= 180 && newWidth <= window.innerWidth * 0.6) {
2113
- panel.style.width = newWidth + 'px';
2114
- }
2115
- });
2116
-
2117
- document.addEventListener('mouseup', () => {
2118
- handle.classList.remove('active');
2119
- document.body.style.cursor = '';
2120
- document.body.style.userSelect = '';
2121
- });
2122
- })();
2123
-
2124
- // ══════════════════════════════════════════════════════════════════════════════
2125
- // Auto-collapse
2126
- // ══════════════════════════════════════════════════════════════════════════════
2127
-
2128
- function applyCollapsePolicy(duration) {
2129
- collapseAfter = duration;
2130
- if (collapseTimer) clearInterval(collapseTimer);
2131
- if (duration <= 0) return;
2132
-
2133
- collapseTimer = setInterval(() => {
2134
- if (!collapseAfter) return;
2135
- const now = Date.now();
2136
- let changed = false;
2137
- for (const s of sessions) {
2138
- if (s.pinned || s.collapsed) continue;
2139
- if ((now - s.lastActivity) > collapseAfter) {
2140
- s.collapsed = true;
2141
- changed = true;
2142
- }
2143
- }
2144
- if (changed) {
2145
- rebuildNodes();
2146
- renderAll();
2147
- }
2148
- }, 5000);
2149
- }
2150
-
2151
- function startActiveRefresh() {
2152
- if (activeRefreshTimer) clearInterval(activeRefreshTimer);
2153
- activeRefreshTimer = setInterval(() => {
2154
- updateTreeDots();
2155
- refreshButtons();
2156
- }, 15000);
2157
- }
2158
-
2159
- // ══════════════════════════════════════════════════════════════════════════════
2160
- // Scroll detection
2161
- // ══════════════════════════════════════════════════════════════════════════════
2162
-
2163
- streamEl.addEventListener('scroll', () => {
2164
- const atBottom = streamEl.scrollHeight - streamEl.scrollTop - streamEl.clientHeight < 50;
2165
- if (atBottom && !autoScroll) autoScroll = true;
2166
- if (!atBottom && autoScroll) autoScroll = false;
2167
- refreshButtons();
2168
- });
2169
-
2170
- // ══════════════════════════════════════════════════════════════════════════════
2171
- // Helpers
2172
- // ══════════════════════════════════════════════════════════════════════════════
2173
-
2174
- function updateFilters() {
2175
- filters.clear();
2176
- visibleFilterCount = 0;
2177
- for (const s of sessions) {
2178
- for (const a of s.agents) {
2179
- filters.set(s.id + ':' + a.id, true);
2180
- visibleFilterCount++;
2181
- }
2182
- }
2183
- }
2184
-
2185
- function agentDisplayName(id, type) {
2186
- if (type) {
2187
- const idx = type.lastIndexOf(':');
2188
- if (idx >= 0 && idx < type.length - 1) return type.slice(idx + 1);
2189
- return type;
2190
- }
2191
- if (!id) return 'Main';
2192
- return 'Agent-' + id.slice(0, 7);
2193
- }
2194
-
2195
- // Compute the minimum display length per agentID so that within each session,
2196
- // no two agent IDs share the same truncated prefix. Minimum 7 chars.
2197
- const agentIdDisplayLen = new Map();
2198
- function computeAgentIdDisplayLengths() {
2199
- agentIdDisplayLen.clear();
2200
- for (const s of sessions) {
2201
- const agentIds = s.agents.filter(a => a.id).map(a => a.id);
2202
- if (agentIds.length === 0) continue;
2203
- // Find minimum length that makes all prefixes unique
2204
- let minLen = 7;
2205
- while (minLen < 21) {
2206
- const prefixes = agentIds.map(id => id.slice(0, minLen));
2207
- const unique = new Set(prefixes);
2208
- if (unique.size === agentIds.length) break;
2209
- minLen++;
2210
- }
2211
- for (const id of agentIds) {
2212
- agentIdDisplayLen.set(s.id + ':' + id, minLen);
2213
- }
2214
- }
2215
- }
2216
-
2217
- function folderName(projectPath) {
2218
- if (!projectPath) return '';
2219
- const parts = projectPath.split('/');
2220
- return parts[parts.length - 1] || projectPath;
2221
- }
2222
-
2223
- function esc(s) {
2224
- return (s ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#x27;').replace(/\\/g, '&#x5C;');
2225
- }
2226
-
2227
- function fmtDur(ms) {
2228
- if (!ms || ms <= 0) return '';
2229
- if (ms < 1000) return `(${ms}ms)`;
2230
- if (ms < 60000) return `(${(ms / 1000).toFixed(1)}s)`;
2231
- return `(${(ms / 60000).toFixed(1)}m)`;
2232
- }
2233
-
2234
- function formatTime(ms) {
2235
- if (!ms) return '';
2236
- const d = new Date(ms);
2237
- const pad = (n) => String(n).padStart(2, '0');
2238
- return `${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
2239
- }
2240
-
2241
- function fmtTimestamp(ts) {
2242
- if (!ts) return '';
2243
- const d = ts instanceof Date ? ts : new Date(ts);
2244
- if (isNaN(d.getTime())) return '';
2245
- const pad = (n, len) => String(n).padStart(len, '0');
2246
- const ms = pad(d.getMilliseconds(), 3);
2247
- 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}`;
2248
- }
2249
-
2250
- function fmtTok(n) {
2251
- if (!n) return '0';
2252
- if (n < 1000) return String(n);
2253
- if (n < 1000000) return (n / 1000).toFixed(2) + 'k';
2254
- return (n / 1000000).toFixed(2) + 'm';
2255
- }
2256
-
2257
- function renderAll() {
2258
- needsFullRender = true;
2259
- visibleDirty = true;
2260
- renderTree();
2261
- renderStream();
2262
- refreshButtons();
2263
- }
2264
-
2265
- function scheduleRender() {
2266
- if (!renderPending) {
2267
- renderPending = true;
2268
- requestAnimationFrame(() => {
2269
- renderPending = false;
2270
- renderTree();
2271
- renderStream();
2272
- refreshButtons();
2273
- });
2274
- }
2275
- }
2276
-
2277
- // ══════════════════════════════════════════════════════════════════════════════
2278
- // Export modal — session selection
2279
- // ══════════════════════════════════════════════════════════════════════════════
2280
-
2281
- let exportModalSelected = new Set();
2282
-
2283
- function openExportModal() {
2284
- if (sessions.length === 0) {
2285
- const btn = document.getElementById('btn-export');
2286
- const orig = btn.textContent;
2287
- btn.textContent = '✕ 无会话';
2288
- setTimeout(() => { btn.textContent = orig; }, 2000);
2289
- return;
2290
- }
2291
- exportModalSelected = new Set(sessions.map(s => s.id));
2292
- renderModalSessionList();
2293
- updateModalCount();
2294
- document.getElementById('export-modal').style.display = 'flex';
2295
- }
2296
-
2297
- function renderModalSessionList() {
2298
- const listEl = document.getElementById('modal-session-list');
2299
- const sorted = [...sessions].sort((a, b) => (a.colorRank || 0) - (b.colorRank || 0));
2300
- listEl.innerHTML = sorted.map(s => {
2301
- const color = idColor(s.colorRank || 0);
2302
- const project = folderName(s.projectPath) || s.projectPath || '';
2303
- const prefix = s.id.split('-')[0].toUpperCase();
2304
- const model = s.model || '';
2305
- const time = formatTime(s.birthtimeMs);
2306
- const checked = exportModalSelected.has(s.id) ? 'checked' : '';
2307
- const selectedClass = exportModalSelected.has(s.id) ? ' selected' : '';
2308
- return `<div class="modal-session-row${selectedClass}" data-sid="${esc(s.id)}" onclick="toggleModalSession('${esc(s.id)}', this)">
2309
- <input type="checkbox" class="modal-checkbox" data-sid="${esc(s.id)}" ${checked} onclick="event.stopPropagation(); toggleModalSession('${esc(s.id)}', this.parentElement)">
2310
- <span class="modal-session-prefix" style="color:${color}">${esc(prefix)}</span>
2311
- <div class="modal-session-info">
2312
- <span class="modal-session-project">${esc(project)}</span>
2313
- ${model ? `<span class="modal-session-model">${esc(model)}</span>` : ''}
2314
- </div>
2315
- ${time ? `<span class="modal-session-time">${esc(time)}</span>` : ''}
2316
- </div>`;
2317
- }).join('\n');
2318
- }
2319
-
2320
- function toggleModalSession(sid, rowEl) {
2321
- if (exportModalSelected.has(sid)) {
2322
- exportModalSelected.delete(sid);
2323
- } else {
2324
- exportModalSelected.add(sid);
2325
- }
2326
- const checkbox = rowEl.querySelector('.modal-checkbox');
2327
- checkbox.checked = exportModalSelected.has(sid);
2328
- rowEl.classList.toggle('selected', exportModalSelected.has(sid));
2329
- updateModalCount();
2330
- }
2331
-
2332
- function exportModalToggleAll(selectAll) {
2333
- if (selectAll) {
2334
- exportModalSelected = new Set(sessions.map(s => s.id));
2335
- } else {
2336
- exportModalSelected.clear();
2337
- }
2338
- document.querySelectorAll('#modal-session-list .modal-session-row').forEach(row => {
2339
- const sid = row.dataset.sid;
2340
- const checkbox = row.querySelector('.modal-checkbox');
2341
- checkbox.checked = exportModalSelected.has(sid);
2342
- row.classList.toggle('selected', exportModalSelected.has(sid));
2343
- });
2344
- updateModalCount();
2345
- }
2346
-
2347
- function updateModalCount() {
2348
- const total = sessions.length;
2349
- const selected = exportModalSelected.size;
2350
- document.getElementById('modal-selected-count').textContent = `已选 ${selected} / ${total}`;
2351
- document.getElementById('modal-export-btn').disabled = selected === 0;
2352
- }
2353
-
2354
- function closeExportModal() {
2355
- document.getElementById('export-modal').style.display = 'none';
2356
- exportModalSelected.clear();
2357
- }
2358
-
2359
- // Esc key closes modal
2360
- document.addEventListener('keydown', (e) => {
2361
- if (e.key === 'Escape') {
2362
- const modal = document.getElementById('export-modal');
2363
- if (modal.style.display !== 'none') {
2364
- closeExportModal();
2365
- e.stopPropagation();
2366
- }
2367
- }
2368
- });
2369
-
2370
- function confirmExport() {
2371
- if (exportModalSelected.size === 0) return;
2372
- const selectedIds = new Set(exportModalSelected);
2373
- closeExportModal();
2374
- exportHTML(selectedIds);
2375
- }
2376
-
2377
- // ══════════════════════════════════════════════════════════════════════════════
2378
- // Export HTML
2379
- // ══════════════════════════════════════════════════════════════════════════════
2380
-
2381
- function exportHTML(selectedIds = null) {
2382
- const theme = document.documentElement.getAttribute('data-theme') || 'dark';
2383
-
2384
- // Collect sessions to export
2385
- let sidsInExport;
2386
- if (selectedIds) {
2387
- sidsInExport = selectedIds;
2388
- } else {
2389
- sidsInExport = new Set();
2390
- for (const item of visibleItems) {
2391
- if (item.sessionID) sidsInExport.add(item.sessionID);
2392
- }
2393
- }
2394
- const exportSessions = [];
2395
- for (const sid of sidsInExport) {
2396
- const s = sessionsMap.get(sid);
2397
- if (s) exportSessions.push(s);
2398
- }
2399
- // Sort by colorRank to match the order in the tree
2400
- exportSessions.sort((a, b) => (a.colorRank || 0) - (b.colorRank || 0));
2401
-
2402
- // Build session list header
2403
- let sessionListHTML = '';
2404
- if (exportSessions.length > 0) {
2405
- const items = exportSessions.map(s => {
2406
- const color = idColor(s.colorRank || 0);
2407
- const project = folderName(s.projectPath) || s.projectPath || '';
2408
- const model = s.model || '';
2409
- 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>`;
2410
- }).join('\n');
2411
- sessionListHTML = `<div class="export-session-list">
2412
- <div class="export-session-item export-all-btn active" onclick="filterBySession(null)">全部</div>
2413
- ${items}
2414
- </div>`;
2415
- }
2416
-
2417
- // Token info
2418
- computeTokensFromContext();
2419
- let tokenHTML = '';
2420
- if (totalInput > 0 || totalOutput > 0) {
2421
- let tokStr = `Input: ${fmtTok(totalInput)} · Output: ${fmtTok(totalOutput)}`;
2422
- if (totalCacheCreate > 0 || totalCacheRead > 0) tokStr += ` · Cache: ${fmtTok(totalCacheCreate)}+${fmtTok(totalCacheRead)}`;
2423
- tokenHTML = `<div class="export-meta-line" style="color:var(--dim)">Tokens: ${tokStr}</div>`;
2424
- }
2425
-
2426
- // Filter state
2427
- const filterState = [];
2428
- if (!showThinking) filterState.push('thinking hidden');
2429
- if (!showToolInput) filterState.push('tools hidden');
2430
- if (!showToolOutput) filterState.push('output hidden');
2431
- if (!showText) filterState.push('text hidden');
2432
- if (!showHook) filterState.push('hook hidden');
2433
- let filterHTML = '';
2434
- if (filterState.length > 0) filterHTML = `<div class="export-meta-line" style="color:var(--dim)">Filters: ${filterState.join(', ')}</div>`;
2435
-
2436
- // Export timestamp
2437
- const now = new Date();
2438
- const exportTime = fmtTimestamp(now);
2439
- const timeHTML = `<div class="export-meta-line" style="color:var(--dim)">Exported: ${exportTime}</div>`;
2440
-
2441
- // Clone stream content and strip interactive elements
2442
- const clone = streamEl.cloneNode(true);
2443
- clone.querySelectorAll('.copy-btn').forEach(el => el.remove());
2444
- clone.querySelectorAll('[onclick]').forEach(el => el.removeAttribute('onclick'));
2445
-
2446
- // Filter out stream lines from non-selected sessions
2447
- if (selectedIds) {
2448
- clone.querySelectorAll('[data-session-id]').forEach(el => {
2449
- if (!selectedIds.has(el.dataset.sessionId)) el.remove();
2450
- });
2451
- }
2452
-
2453
- // Get the cleaned innerHTML
2454
- const streamHTML = clone.innerHTML;
2455
-
2456
- // Get page CSS
2457
- const pageStyleEl = document.querySelector('style');
2458
- const appCSS = pageStyleEl ? pageStyleEl.textContent : '';
2459
-
2460
- // Get highlight.js CSS from cache
2461
- const hlCSS = theme === 'dark' ? hljsDarkCSS : hljsLightCSS;
2462
-
2463
- // Export-specific CSS
2464
- const exportCSS = `
2465
- .export-session-list { display: flex; flex-wrap: wrap; gap: 6px; padding: 8px 0; }
2466
- .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; }
2467
- .export-session-item:hover { opacity: 1; border-color: var(--dim); }
2468
- .export-session-item.active { opacity: 1; border-color: var(--purple); background: var(--purple); color: var(--white); }
2469
- .export-all-btn { font-weight: 600; align-items: center; }
2470
- .export-item-top { display: flex; align-items: baseline; gap: 4px; }
2471
- .export-item-sid { font-family: monospace; font-size: 10px; opacity: 0.8; }
2472
- .export-session-item.active .export-item-sid { opacity: 1; color: var(--white); }
2473
- .export-project { font-weight: 500; }
2474
- .export-model { font-size: 11px; }
2475
- .export-meta-line { padding: 2px 0; font-size: 11px; }
2476
- .export-header { padding: 12px; border-bottom: 1px solid var(--border); position: sticky; top: 0; background: var(--bg); z-index: 100; }
2477
- .export-header h1 { margin: 0 0 4px 0; font-size: 16px; color: var(--white); }
2478
- `;
2479
-
2480
- // Export-specific JS for session filtering
2481
- const exportJS = `
2482
- let _activeSid = null;
2483
- function filterBySession(sid) {
2484
- _activeSid = sid;
2485
- const lines = document.querySelectorAll('#export-stream [data-session-id]');
2486
- lines.forEach(el => {
2487
- el.style.display = (sid === null || el.dataset.sessionId === sid) ? '' : 'none';
2488
- });
2489
- document.querySelectorAll('.export-session-item[data-sid]').forEach(el => {
2490
- el.classList.toggle('active', sid !== null && el.dataset.sid === sid);
2491
- });
2492
- document.querySelector('.export-all-btn').classList.toggle('active', sid === null);
2493
- }
2494
- `;
2495
-
2496
- // Assemble complete HTML document
2497
- const htmlAttrs = theme === 'light' ? ' lang="en" data-theme="light"' : ' lang="en"';
2498
- const fullDoc = `<!DOCTYPE html>
2499
- <html${htmlAttrs}>
2500
- <head>
2501
- <meta charset="UTF-8">
2502
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
2503
- <title>claude-watch Export</title>
2504
- <style>
2505
- ${appCSS}
2506
- ${hlCSS}
2507
- ${exportCSS}
2508
- </style>
2509
- </head>
2510
- <body style="overflow-y:auto;height:auto">
2511
- <div class="export-header">
2512
- <h1>claude-watch Export</h1>
2513
- ${sessionListHTML}
2514
- ${tokenHTML}
2515
- ${filterHTML}
2516
- ${timeHTML}
2517
- </div>
2518
- <div id="export-stream" style="padding:8px 12px;font-size:12px">
2519
- ${streamHTML}
2520
- </div>
2521
- <script>${exportJS}<\/script>
2522
- </body>
2523
- </html>`;
2524
-
2525
- // Blob download
2526
- const blob = new Blob([fullDoc], { type: 'text/html;charset=utf-8' });
2527
- const url = URL.createObjectURL(blob);
2528
- const a = document.createElement('a');
2529
-
2530
- let filePrefix;
2531
- if (sidsInExport.size === 1) {
2532
- filePrefix = [...sidsInExport][0].split('-')[0].toUpperCase();
2533
- } else {
2534
- filePrefix = 'multi';
2535
- }
2536
- const pad = (n, len) => String(n).padStart(len, '0');
2537
- 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)}`;
2538
- a.download = `claude-watch-${filePrefix}-${ts}.html`;
2539
- a.href = url;
2540
- document.body.appendChild(a);
2541
- a.click();
2542
- document.body.removeChild(a);
2543
- URL.revokeObjectURL(url);
2544
-
2545
- // Visual feedback
2546
- const btn = document.getElementById('btn-export');
2547
- const orig = btn.textContent;
2548
- btn.textContent = '✓';
2549
- setTimeout(() => { btn.textContent = orig; }, 2000);
2550
- }
2551
-
2552
- // ══════════════════════════════════════════════════════════════════════════════
2553
- // Theme toggle
2554
- // ══════════════════════════════════════════════════════════════════════════════
2555
-
2556
- function applyTheme(theme) {
2557
- document.documentElement.setAttribute('data-theme', theme);
2558
- const btn = document.getElementById('btn-theme');
2559
- if (btn) {
2560
- btn.textContent = theme === 'dark' ? '🌙' : '☀️';
2561
- btn.setAttribute('data-tooltip', theme === 'dark' ? 'Switch to light' : 'Switch to dark');
2562
- }
2563
- // Swap highlight.js stylesheet for theme
2564
- const hlLink = document.querySelector('link[rel="stylesheet"][href*="github"]');
2565
- if (hlLink) {
2566
- hlLink.href = theme === 'dark' ? 'vendor/github-dark.min.css' : 'vendor/github-light.min.css';
2567
- }
2568
- }
2569
-
2570
- function toggleTheme() {
2571
- const current = document.documentElement.getAttribute('data-theme') || 'dark';
2572
- const next = current === 'dark' ? 'light' : 'dark';
2573
- localStorage.setItem('theme', next);
2574
- applyTheme(next);
2575
- }
2576
-
2577
- // Apply saved theme on load (default dark)
2578
- (function() {
2579
- const saved = localStorage.getItem('theme');
2580
- applyTheme(saved || 'dark');
2581
- })();
2582
-
2583
- // ══════════════════════════════════════════════════════════════════════════════
2584
- // Init
2585
- // ══════════════════════════════════════════════════════════════════════════════
2586
-
2587
- // Apply collapse-after from URL param
2588
- const urlParams = new URLSearchParams(location.search);
2589
- const ca = urlParams.get('collapseAfter');
2590
- if (ca) {
2591
- applyCollapsePolicy(parseInt(ca) || 0);
2592
- }
2593
-
2594
- connect();
2595
- </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>
2596
125
  </body>
2597
126
  </html>