@kylindc/ccxray 1.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.
@@ -0,0 +1,535 @@
1
+ // ── Entry rendering ──
2
+ let newTurnCount = 0;
3
+
4
+ function showNewTurnPill(count) {
5
+ const existing = document.getElementById('new-turn-pill');
6
+ if (existing) {
7
+ existing.textContent = '↓ ' + count + ' new';
8
+ return;
9
+ }
10
+
11
+ const pill = document.createElement('div');
12
+ pill.id = 'new-turn-pill';
13
+ pill.className = 'new-turn-pill';
14
+ pill.textContent = '↓ ' + count + ' new';
15
+ pill.onclick = function() {
16
+ selectTurn(allEntries.length - 1);
17
+ scrollTurnsToBottom();
18
+ };
19
+ colTurns.appendChild(pill);
20
+ }
21
+
22
+ function hideNewTurnPill() {
23
+ const pill = document.getElementById('new-turn-pill');
24
+ if (pill) pill.remove();
25
+ newTurnCount = 0;
26
+ }
27
+
28
+ function renderMessages(messages, perMessage) {
29
+ if (!messages || !messages.length) return '<pre>No messages</pre>';
30
+ return messages.map((m, i) => {
31
+ let body = '';
32
+ if (typeof m.content === 'string') {
33
+ body = escapeHtml(m.content);
34
+ } else if (Array.isArray(m.content)) {
35
+ body = m.content.map(block => {
36
+ if (block.type === 'text') return escapeHtml(block.text);
37
+ if (block.type === 'tool_use') return '<div class="content-block"><div class="type">tool_use: ' + escapeHtml(block.name) + '</div><pre>' + escapeHtml(JSON.stringify(block.input, null, 2)) + '</pre></div>';
38
+ if (block.type === 'tool_result') return '<div class="content-block"><div class="type">tool_result (id: ' + escapeHtml(block.tool_use_id) + ')</div><pre>' + escapeHtml(typeof block.content === 'string' ? block.content : JSON.stringify(block.content, null, 2)) + '</pre></div>';
39
+ return '<pre>' + escapeHtml(JSON.stringify(block, null, 2)) + '</pre>';
40
+ }).join('');
41
+ } else {
42
+ body = escapeHtml(JSON.stringify(m.content, null, 2));
43
+ }
44
+ const tokLabel = perMessage && perMessage[i] ? ' <span class="badge">' + perMessage[i].tokens + ' tok</span>' : '';
45
+ return '<div class="msg"><div class="msg-role ' + m.role + '">[' + i + '] ' + m.role + tokLabel + '</div><pre>' + body + '</pre></div>';
46
+ }).join('');
47
+ }
48
+
49
+ function makeSection(id, title, badge, contentHtml, defaultOpen) {
50
+ const openClass = defaultOpen ? ' open' : '';
51
+ return '<div class="section">' +
52
+ '<div class="section-header' + openClass + '" onclick="toggleSection(this)"><span class="arrow">▶</span> ' + title + (badge ? ' <span class="badge">' + badge + '</span>' : '') + '</div>' +
53
+ '<div class="section-content' + openClass + '"><div>' + contentHtml + '</div></div></div>';
54
+ }
55
+ function toggleSection(el) { el.classList.toggle('open'); el.nextElementSibling.classList.toggle('open'); }
56
+
57
+ function buildContextCategories(tok) {
58
+ if (!tok?.contextBreakdown) return null;
59
+ const cb = tok.contextBreakdown;
60
+ const sb = cb.systemBreakdown || {};
61
+ const cm = cb.claudeMd || {};
62
+ const ttok = cb.toolsBreakdown?.toolTokens || {};
63
+ // Order matches section items: System → Messages → Tools
64
+ const cats = [
65
+ // System (blues)
66
+ { label: 'Core instructions', color: 'var(--color-system-deep)', tokens: sb.coreInstructions || 0 },
67
+ { label: 'Plugin instructions', color: 'var(--color-system)', tokens: (sb.mcpServersList || 0) + (sb.coreIdentity || 0) + (sb.billingHeader || 0) },
68
+ { label: 'Custom skills', color: 'var(--color-system-mid)', tokens: sb.customSkills || 0 },
69
+ { label: 'Plugin skills', color: 'var(--color-system-light)', tokens: sb.pluginSkills || 0 },
70
+ { label: 'Custom agents', color: 'var(--color-system-pale)', tokens: sb.customAgents || 0 },
71
+ { label: 'Settings/Env/Git', color: 'var(--color-system-muted)', tokens: (sb.settingsJson || 0) + (sb.envAndGit || 0) },
72
+ { label: 'Global CLAUDE.md', color: 'var(--color-system-soft)', tokens: cm.globalClaudeMd || 0 },
73
+ { label: 'Project CLAUDE.md', color: 'var(--color-system-faint)', tokens: cm.projectClaudeMd || 0 },
74
+ // Messages (amber)
75
+ { label: 'Messages', color: 'var(--color-messages)', tokens: cb.messageTokens || 0 },
76
+ // Tools (greens)
77
+ { label: 'Core tools', color: 'var(--color-tools)', tokens: (ttok.core || 0) + (ttok.agent || 0) + (ttok.task || 0) + (ttok.team || 0) + (ttok.cron || 0) + (ttok.other || 0) },
78
+ { label: 'MCP tools', color: 'var(--color-mcp-tools)', tokens: ttok.mcp || 0 },
79
+ ];
80
+ const total = cats.reduce((s, c) => s + c.tokens, 0);
81
+ return total ? { cats, total, mcpPlugins: cb.toolsBreakdown?.mcpPlugins || [] } : null;
82
+ }
83
+
84
+ function renderContextBreakdownBar(tok, maxContext, usage) {
85
+ const data = buildContextCategories(tok);
86
+ if (!data) return '';
87
+ const { cats, total: estimatedTotal } = data;
88
+ // Use API usage as authoritative total when available (tokenizeRequest underestimates by 20-40%)
89
+ const apiTotal = usage ? (usage.input_tokens || 0) + (usage.cache_read_input_tokens || 0) + (usage.cache_creation_input_tokens || 0) : 0;
90
+ const total = apiTotal > estimatedTotal ? apiTotal : estimatedTotal;
91
+ // Scale category segments proportionally if API total is larger
92
+ const scale = estimatedTotal > 0 && total > estimatedTotal ? total / estimatedTotal : 1;
93
+ const windowSize = maxContext || DEFAULT_MAX_CTX;
94
+ const pct = (total / windowSize * 100).toFixed(0);
95
+ const usedPct = Math.min(100, total / windowSize * 100);
96
+ const barColor = usedPct > 90 ? 'var(--red)' : usedPct > 70 ? 'var(--yellow)' : null;
97
+
98
+ let bar = '<div style="display:flex;height:8px;border-radius:2px;overflow:hidden;margin:4px 0 2px;background:var(--border)">';
99
+ for (const c of cats) {
100
+ if (!c.tokens) continue;
101
+ const scaled = c.tokens * scale;
102
+ const w = (scaled / windowSize * 100).toFixed(3);
103
+ bar += '<div style="width:' + w + '%;background:' + (barColor || c.color) + ';min-width:1px" title="' + escapeHtml(c.label) + ': ' + Math.round(scaled).toLocaleString() + '"></div>';
104
+ }
105
+ bar += '</div>';
106
+
107
+ const pctColor = usedPct > 90 ? 'var(--red)' : usedPct > 70 ? 'var(--yellow)' : 'var(--dim)';
108
+ const label = '<div style="font-size:10px;color:var(--dim)">' +
109
+ fmt(total) + ' / ' + fmt(windowSize) + ' <span style="color:' + pctColor + '">(' + pct + '%)</span></div>';
110
+
111
+ return '<div style="padding:4px 12px 6px;border-bottom:1px solid var(--border)">' + bar + label + '</div>';
112
+ }
113
+
114
+ // Sticky bar shown at top of detail col — always visible
115
+ function renderContextBreakdownSticky(tok, maxContext, usage) {
116
+ const data = buildContextCategories(tok);
117
+ if (!data) return '';
118
+ const { cats, total: estimatedTotal, mcpPlugins } = data;
119
+ // Use API usage as authoritative total when available
120
+ const apiTotal = usage ? (usage.input_tokens || 0) + (usage.cache_read_input_tokens || 0) + (usage.cache_creation_input_tokens || 0) : 0;
121
+ const total = apiTotal > estimatedTotal ? apiTotal : estimatedTotal;
122
+ const scale = estimatedTotal > 0 && total > estimatedTotal ? total / estimatedTotal : 1;
123
+ const windowSize = maxContext || DEFAULT_MAX_CTX;
124
+ const usedPct = Math.min(100, total / windowSize * 100);
125
+ const barColor = usedPct > 90 ? 'var(--red)' : usedPct > 70 ? 'var(--yellow)' : null;
126
+
127
+ // Each segment is a fraction of windowSize; bar total = usedPct% of full width
128
+ let bar = '<div style="display:flex;height:12px;border-radius:3px;overflow:hidden;margin-bottom:6px;background:var(--border)">';
129
+ for (const c of cats) {
130
+ if (!c.tokens) continue;
131
+ const scaled = c.tokens * scale;
132
+ const pct = (scaled / windowSize * 100).toFixed(3);
133
+ const bg = barColor || c.color;
134
+ bar += '<div style="width:' + pct + '%;background:' + bg + ';min-width:1px" title="' + escapeHtml(c.label) + ': ' + Math.round(scaled).toLocaleString() + ' (' + (c.tokens / estimatedTotal * 100).toFixed(1) + '% of used)"></div>';
135
+ }
136
+ bar += '</div>';
137
+
138
+ let table = '<table style="width:100%;border-collapse:collapse;font-size:10px">';
139
+ for (const c of cats) {
140
+ if (!c.tokens) continue;
141
+ const scaled = Math.round(c.tokens * scale);
142
+ const pct = (c.tokens / estimatedTotal * 100).toFixed(1);
143
+ table += '<tr><td style="padding:1px 3px"><span style="display:inline-block;width:7px;height:7px;background:' + c.color + ';border-radius:2px;margin-right:3px"></span>' + escapeHtml(c.label) + '</td>' +
144
+ '<td style="padding:1px 3px;text-align:right">' + scaled.toLocaleString() + '</td>' +
145
+ '<td style="padding:1px 3px;text-align:right;color:var(--dim)">' + pct + '%</td></tr>';
146
+ }
147
+ const pctOfWindow = (total / windowSize * 100).toFixed(0);
148
+ table += '<tr style="border-top:1px solid var(--border)"><td style="padding:3px;color:var(--dim)">Used</td><td style="padding:3px;text-align:right">' + fmt(total) + '</td><td style="padding:3px;text-align:right;color:var(--dim)">' + pctOfWindow + '%</td></tr>';
149
+ table += '<tr><td style="padding:1px 3px;color:var(--dim)">Window</td><td style="padding:1px 3px;text-align:right;color:var(--dim)">' + fmt(windowSize) + '</td><td></td></tr>';
150
+ table += '</table>';
151
+
152
+ let mcp = '';
153
+ if (mcpPlugins.length) {
154
+ mcp = '<details style="margin-top:6px"><summary style="cursor:pointer;font-size:10px;color:var(--dim)">MCP plugins (' + mcpPlugins.length + ')</summary>' +
155
+ '<table style="width:100%;border-collapse:collapse;font-size:10px;margin-top:3px">';
156
+ for (const p of mcpPlugins.slice().sort((a, b) => b.tokens - a.tokens)) {
157
+ mcp += '<tr><td style="padding:1px 3px;color:var(--dim)">' + escapeHtml(p.plugin) + '</td><td style="padding:1px 3px;text-align:right">' + p.count + ' tools</td><td style="padding:1px 3px;text-align:right">' + p.tokens.toLocaleString() + ' tok</td></tr>';
158
+ }
159
+ mcp += '</table></details>';
160
+ }
161
+
162
+ const title = 'Context Breakdown · ' + fmt(total) + ' / ' + fmt(windowSize) + ' tokens (' + pctOfWindow + '%)';
163
+ return '<div class="ctx-sticky"><div class="ctx-sticky-title">' + title + '</div>' + bar + table + mcp + '</div>';
164
+ }
165
+
166
+ function addEntry(e) {
167
+ if (entryCount === 0) colTurns.innerHTML = '<div class="col-sticky-header"><div class="col-title" style="display:flex;align-items:center">Turns<span id="scroll-toggle" onclick="toggleFollowLive()" style="cursor:pointer;font-size:10px;margin-left:auto"><span class="scroll-on active">ON</span> <span class="scroll-off">OFF</span></span></div><div id="session-tool-bar" style="display:none"></div><div id="ctx-legend"><span><span class="ctx-legend-dot" style="background:var(--color-cache-read)"></span>cache read</span><span><span class="ctx-legend-dot" style="background:var(--color-cache-write)"></span>cache write</span><span><span class="ctx-legend-dot" style="background:var(--color-input)"></span>input</span></div><div id="session-sparkline"></div></div>';
168
+ const idx = entryCount++;
169
+
170
+ const sid = e.sessionId || 'unknown';
171
+ const model = e.model || e.req?.model || '?';
172
+ const msgCount = e.msgCount != null ? e.msgCount : (e.req?.messages?.length || 0);
173
+ const toolCount = e.toolCount != null ? e.toolCount : (e.req?.tools?.length || 0);
174
+ const stopReason = e.stopReason != null ? e.stopReason : '';
175
+ const tok = e.tokens || {};
176
+ const usage = e.usage || null;
177
+ const turnCost = e.cost?.cost != null ? e.cost.cost : (typeof e.cost === 'number' ? e.cost : null);
178
+
179
+ // Session tracking — properly deduplicated by ID
180
+ const entryId = e.id || '';
181
+ const entryCwd = e.cwd || null;
182
+ if (!sessionsMap.has(sid)) {
183
+ const shortSid = sid.slice(0, 8);
184
+ sessionsMap.set(sid, { id: sid, firstTs: e.ts, firstId: entryId, lastId: entryId, count: 0, mainCount: 0, subCount: 0, model, totalCost: 0, cwd: entryCwd });
185
+ const sessEl = document.createElement('div');
186
+ sessEl.className = 'session-item';
187
+ sessEl.dataset.sessionId = sid;
188
+ sessEl.id = 'sess-' + shortSid;
189
+ sessEl.onclick = () => selectSession(sid);
190
+ sessEl.innerHTML = renderSessionItem(sessionsMap.get(sid), sid);
191
+ // Insert at top (after col-title) — newest sessions first
192
+ const firstSession = colSessions.querySelector('.session-item');
193
+ if (firstSession) colSessions.insertBefore(sessEl, firstSession);
194
+ else colSessions.appendChild(sessEl);
195
+ }
196
+ const sess = sessionsMap.get(sid);
197
+ // Update cwd if not yet known or was only a quota-check
198
+ if (entryCwd && (!sess.cwd || sess.cwd === '(quota-check)')) sess.cwd = entryCwd;
199
+ sess.lastId = entryId;
200
+ const isSubagent = e.isSubagent || false;
201
+ sess.count++; // total (shown in session item as "Nt")
202
+ if (isSubagent) sess.subCount++;
203
+ else sess.mainCount++;
204
+ const displayNum = isSubagent ? ('s' + sess.subCount) : String(sess.mainCount);
205
+ if (turnCost != null) sess.totalCost += turnCost;
206
+ if (!sess.toolCalls) sess.toolCalls = {};
207
+ Object.entries(e.toolCalls || {}).forEach(([name, cnt]) => {
208
+ sess.toolCalls[name] = (sess.toolCalls[name] || 0) + cnt;
209
+ });
210
+ const sessEl = document.getElementById('sess-' + sid.slice(0, 8));
211
+ if (sessEl) {
212
+ sessEl.innerHTML = renderSessionItem(sess, sid);
213
+ // Move to top if not already first — this session just got the newest activity
214
+ const firstSession = colSessions.querySelector('.session-item');
215
+ if (firstSession && firstSession !== sessEl) {
216
+ colSessions.insertBefore(sessEl, firstSession);
217
+ }
218
+ }
219
+
220
+ // Project tracking
221
+ const projName = getProjectName(sess.cwd);
222
+ if (!projectsMap.has(projName)) {
223
+ projectsMap.set(projName, { name: projName, totalCost: 0, sessionIds: new Set(), firstId: entryId, lastId: entryId });
224
+ }
225
+ const proj = projectsMap.get(projName);
226
+ proj.sessionIds.add(sid);
227
+ proj.lastId = entryId;
228
+ if (turnCost != null) proj.totalCost += turnCost;
229
+ renderProjectsCol();
230
+
231
+ const statusClass = e.status >= 200 && e.status < 300 ? 'status-ok' : 'status-err';
232
+ const shortModel = model.replace('claude-', '').replace(/-[0-9]{8}$/, '');
233
+
234
+ const ctxCacheCreate = usage ? (usage.cache_creation_input_tokens || 0) : 0;
235
+ const ctxCacheRead = usage ? (usage.cache_read_input_tokens || 0) : 0;
236
+ const ctxInput = usage ? (usage.input_tokens || 0) : 0;
237
+ const ctxUsed = ctxCacheCreate + ctxCacheRead + ctxInput;
238
+
239
+ // Update session context alert badge for main turns
240
+ if (!isSubagent && ctxUsed > 0) {
241
+ sess.latestMainCtxPct = Math.min(100, ctxUsed / (e.maxContext || DEFAULT_MAX_CTX) * 100);
242
+ const sessElCtx = document.getElementById('sess-' + sid.slice(0, 8));
243
+ if (sessElCtx) sessElCtx.innerHTML = renderSessionItem(sess, sid);
244
+ }
245
+
246
+ // Compression detection: compare message count AND context tokens vs previous main turn.
247
+ // True compaction = msgCount drops significantly (messages got summarized/removed).
248
+ // Token-only drops can happen from cache eviction or normal conversation flow.
249
+ let isCompacted = false;
250
+ if (!isSubagent && ctxUsed > 0 && msgCount > 0) {
251
+ for (let i = allEntries.length - 1; i >= 0; i--) {
252
+ const prev = allEntries[i];
253
+ if (prev.sessionId === sid && !prev.isSubagent && prev.ctxUsed > 0) {
254
+ const msgDrop = (prev.msgCount || 0) - msgCount;
255
+ const tokenDrop = prev.ctxUsed - ctxUsed;
256
+ // Require both: msgCount dropped by 5+ AND tokens dropped by >15% of window
257
+ if (msgDrop >= 5 && tokenDrop / (prev.maxContext || DEFAULT_MAX_CTX) > 0.15) isCompacted = true;
258
+ break;
259
+ }
260
+ }
261
+ }
262
+
263
+ allEntries.push({
264
+ tokens: tok, usage, ts: e.ts, model, maxContext: e.maxContext, cost: turnCost, sessionId: sid,
265
+ req: e.req || null, res: e.res || null, reqLoaded: !!(e.req || e.res),
266
+ msgCount, toolCount, toolCalls: e.toolCalls || {}, stopReason,
267
+ status: e.status, elapsed: e.elapsed, method: e.method, id: e.id,
268
+ isSubagent, displayNum, ctxUsed, isCompacted, receivedAt: e.receivedAt || null,
269
+ thinkingDuration: e.thinkingDuration || null,
270
+ duplicateToolCalls: e.duplicateToolCalls || null,
271
+ });
272
+
273
+ const el = document.createElement('div');
274
+ el.className = 'turn-item' + (isSubagent ? ' turn-sub' : '');
275
+ el.dataset.entryIdx = idx;
276
+ el.dataset.sessionId = sid;
277
+ el.dataset.sessNum = displayNum;
278
+ el.onclick = () => { setFocus('turns'); selectTurn(idx); };
279
+ el.onmouseenter = () => { clearTimeout(_hoverTimer); _hoverTimer = setTimeout(() => prefetchEntry(idx), 150); };
280
+ el.onmouseleave = () => clearTimeout(_hoverTimer);
281
+ const tcNames = Object.keys(e.toolCalls || {});
282
+ const toolLine = tcNames.length
283
+ ? '<div class="turn-line3">' + tcNames.slice(0, 5).map(n => {
284
+ const cls = n === 'Agent' ? 'tool-chip chip-agent' : 'tool-chip';
285
+ return '<span class="' + cls + '">' + escapeHtml(n.replace(/^mcp__[^_]+__/, '')) + '</span>';
286
+ }).join('') + (tcNames.length > 5 ? '<span class="tool-chip">+' + (tcNames.length - 5) + '</span>' : '') + '</div>'
287
+ : '';
288
+ const dupes = e.duplicateToolCalls;
289
+ const dupeBadge = dupes ? '<span class="dupe-badge" title="Duplicate tool calls: ' + escapeHtml(Object.entries(dupes).map(([n, c]) => n + '×' + c).join(', ')) + '">⚠ dupes</span>' : '';
290
+ const indent = isSubagent ? '<span class="sub-indent">╎</span>' : '';
291
+ const titleHtml = e.title ? '<div class="turn-title">' + escapeHtml(e.title) + '</div>' : '';
292
+ const compactBadge = isCompacted ? '<span class="compact-badge">compact</span>' : '';
293
+ const ctxMax = e.maxContext || DEFAULT_MAX_CTX;
294
+ const ctxPct = Math.min(100, ctxUsed / ctxMax * 100);
295
+ const seg = (tokens, color) => tokens > 0
296
+ ? '<div style="width:' + (tokens / ctxMax * 100).toFixed(2) + '%;background:' + color + ';min-width:1px"></div>'
297
+ : '';
298
+ const pctColor = ctxPct > 90 ? 'var(--red)' : ctxPct > 70 ? 'var(--yellow)' : 'var(--dim)';
299
+ const pctLabel = ctxUsed > 0 ? '<div class="turn-ctx-pct" style="color:' + pctColor + '">' + ctxPct.toFixed(0) + '%</div>' : '';
300
+ const ctxBar = ctxUsed > 0
301
+ ? '<div class="turn-ctx-bar"><div class="turn-ctx-bar-bg">' +
302
+ seg(ctxCacheRead, 'var(--color-cache-read)') +
303
+ seg(ctxCacheCreate, 'var(--color-cache-write)') +
304
+ seg(ctxInput, 'var(--color-input)') +
305
+ '</div>' + pctLabel + '</div>'
306
+ : '';
307
+ el.innerHTML =
308
+ '<div class="turn-line1">' + indent +
309
+ '<span class="turn-num">' + (isSubagent ? '' : '#') + displayNum + '</span>' +
310
+ '<span class="turn-model">' + escapeHtml(shortModel) + '</span>' +
311
+ compactBadge +
312
+ '</div>' +
313
+ titleHtml +
314
+ '<div class="turn-line2">' +
315
+ '<span class="' + statusClass + '">' + e.status + '</span>' +
316
+ '<span>' + (e.elapsed || '?') + 's</span>' +
317
+ (stopReason ? '<span>' + escapeHtml(stopReason) + '</span>' : '') +
318
+ (e.thinkingDuration ? '<span style="color:var(--purple)">🧠 ' + e.thinkingDuration.toFixed(1) + 's</span>' : '') +
319
+ (turnCost != null ? '<span class="turn-cost">$' + turnCost.toFixed(4) + '</span>' : '') +
320
+ (tok.total > 0 ? '<span class="turn-overhead" title="Structural overhead (system + tools)">' + (((tok.system || 0) + (tok.tools || 0)) / tok.total * 100).toFixed(0) + '%♻</span>' : '') +
321
+ dupeBadge +
322
+ '</div>' +
323
+ toolLine +
324
+ ctxBar;
325
+
326
+ // Hide turn if no session selected, or if it belongs to a different session
327
+ if (!selectedSessionId || selectedSessionId !== sid) el.style.display = 'none';
328
+ // Append: chronological order — oldest at top, newest at bottom
329
+ colTurns.appendChild(el);
330
+
331
+ if (selectedSessionId === sid) renderSessionSparkline(sid);
332
+ if (!_loading && selectedSessionId === sid) {
333
+ // Only auto-follow if toggle is on AND user is currently on the live edge
334
+ // Never interrupt focused mode — user is drilling into a turn's detail
335
+ const wasOnLiveEdge = followLiveTurn && !isFocusedMode &&
336
+ (selectedTurnIdx === -1 || selectedTurnIdx === idx - 1);
337
+ if (wasOnLiveEdge) {
338
+ selectTurn(idx);
339
+ scrollTurnsToBottom();
340
+ } else if (followLiveTurn) {
341
+ newTurnCount++;
342
+ showNewTurnPill(newTurnCount);
343
+ }
344
+ }
345
+ }
346
+
347
+ // Initialize badge on load
348
+ setTimeout(() => updateSysPromptBadge('claude-code'), 500);
349
+ startQuotaTicker();
350
+ // Tab restoration happens after deep-link resolution (see _loading=false path)
351
+
352
+ // SSE live connection
353
+ const evtSource = new EventSource('/_events');
354
+ evtSource.onmessage = (ev) => {
355
+ try {
356
+ const data = JSON.parse(ev.data);
357
+ if (data._type === 'session_status') {
358
+ sessionStatusMap.set(data.sessionId, { active: data.active, lastSeenAt: data.lastSeenAt });
359
+ const sid = data.sessionId;
360
+ const sessEl = document.getElementById('sess-' + sid.slice(0, 8));
361
+ const sess = sessionsMap.get(sid);
362
+ if (sessEl && sess) sessEl.innerHTML = renderSessionItem(sess, sid);
363
+ renderProjectsCol();
364
+ applySessionFilter();
365
+ updateTopbarStatus();
366
+ } else {
367
+ addEntry(data);
368
+ }
369
+ } catch(err) { console.error(err); }
370
+ };
371
+
372
+ // Refresh status dots every 60s (to transition idle → offline)
373
+ setInterval(() => {
374
+ colSessions.querySelectorAll('.session-item').forEach(el => {
375
+ const sid = el.dataset.sessionId;
376
+ const sess = sessionsMap.get(sid);
377
+ if (sess) el.innerHTML = renderSessionItem(sess, sid);
378
+ });
379
+ renderProjectsCol();
380
+ updateTopbarStatus();
381
+ }, 60000);
382
+
383
+ // Initialize session filter dropdown from stored value
384
+ const _sessFilterSel = document.getElementById('sess-filter-select');
385
+ if (_sessFilterSel) _sessFilterSel.value = sessionFilterMode;
386
+
387
+ // ── Deep link parsing ──
388
+ const _deepLinkParams = new URLSearchParams(location.search);
389
+ const _pendingDeepLink = {
390
+ p: _deepLinkParams.get('p'),
391
+ s: _deepLinkParams.get('s'),
392
+ t: _deepLinkParams.get('t'),
393
+ sec: _deepLinkParams.get('sec'),
394
+ msg: _deepLinkParams.get('msg') != null ? parseInt(_deepLinkParams.get('msg')) : null,
395
+ };
396
+ const _hasDeepLink = _pendingDeepLink.p || _pendingDeepLink.s;
397
+
398
+ // Deferred deep link state for sec/msg (applied after lazy-load)
399
+ var _deferredDeepLink = null;
400
+
401
+ function applyDeepLink() {
402
+ const dl = _pendingDeepLink;
403
+ const failures = [];
404
+
405
+ // Check if any entries were restored
406
+ if (allEntries.length === 0 && (dl.s || dl.p)) {
407
+ failures.push('No log data available');
408
+ _showDeepLinkFailures(failures);
409
+ return;
410
+ }
411
+
412
+ // Layer 1: Resolve session
413
+ let fullSid = null;
414
+ if (dl.s) {
415
+ for (const [sid] of sessionsMap) {
416
+ if (sid.startsWith(dl.s)) { fullSid = sid; break; }
417
+ }
418
+ if (!fullSid) {
419
+ failures.push('Session "' + dl.s + '" not found');
420
+ }
421
+ }
422
+
423
+ // Force filter to 'all' if needed
424
+ if (fullSid) {
425
+ const status = getStatusClass(fullSid);
426
+ if (status === 'sdot-off' && sessionFilterMode !== 'all') {
427
+ setSessionFilter('all');
428
+ }
429
+ }
430
+
431
+ // Layer 2: Resolve project (from param or from session)
432
+ let projName = dl.p;
433
+ if (!projName && fullSid) {
434
+ const sess = sessionsMap.get(fullSid);
435
+ if (sess) projName = getProjectName(sess.cwd);
436
+ }
437
+ if (projName && !projectsMap.has(projName)) {
438
+ failures.push('Project "' + projName + '" not found');
439
+ projName = null;
440
+ }
441
+
442
+ if (projName) selectProject(projName);
443
+
444
+ // Layer 3: Resolve turn (only if session resolved)
445
+ let turnResolved = false;
446
+ if (fullSid) {
447
+ selectSessionAndLatestTurn(fullSid);
448
+
449
+ if (dl.t) {
450
+ for (let i = 0; i < allEntries.length; i++) {
451
+ if (allEntries[i].sessionId === fullSid && allEntries[i].displayNum === dl.t) {
452
+ selectTurn(i);
453
+ turnResolved = true;
454
+ break;
455
+ }
456
+ }
457
+ if (!turnResolved) {
458
+ failures.push('Turn #' + dl.t + ' not found in this session');
459
+ }
460
+ } else {
461
+ turnResolved = true; // no specific turn requested
462
+ }
463
+ }
464
+
465
+ // Layer 4+5: Defer section/message until lazy-load completes
466
+ if (fullSid && (dl.sec || dl.msg != null)) {
467
+ _deferredDeepLink = { sec: dl.sec, msg: dl.msg };
468
+ // If turn data is already loaded, apply immediately
469
+ if (selectedTurnIdx >= 0 && allEntries[selectedTurnIdx]?.reqLoaded) {
470
+ _applyDeferredDeepLink();
471
+ } else {
472
+ // Set timeout for deferred resolution
473
+ setTimeout(function() {
474
+ if (_deferredDeepLink) {
475
+ showToast('Deep link: section/message data did not load in time');
476
+ _deferredDeepLink = null;
477
+ }
478
+ }, 5000);
479
+ }
480
+ }
481
+
482
+ // Set focus to deepest resolved layer
483
+ if (fullSid && turnResolved) setFocus('turns');
484
+ else if (fullSid) setFocus('sessions');
485
+ else if (projName) setFocus('projects');
486
+
487
+ // URL cleanup: sync URL to reflect only resolved state
488
+ syncUrlFromState();
489
+
490
+ // Show coalesced failures after 500ms delay
491
+ if (failures.length) _showDeepLinkFailures(failures);
492
+ }
493
+
494
+ function _applyDeferredDeepLink() {
495
+ if (!_deferredDeepLink) return;
496
+ const deferred = _deferredDeepLink;
497
+ _deferredDeepLink = null;
498
+ if (deferred.sec) selectSection(deferred.sec);
499
+ if (deferred.msg != null && typeof selectMessage === 'function') selectMessage(deferred.msg);
500
+ }
501
+
502
+ function _showDeepLinkFailures(failures) {
503
+ setTimeout(function() {
504
+ showToast('Deep link: ' + failures.join('; '));
505
+ }, 500);
506
+ }
507
+
508
+ // Load existing entries (suppress auto-scroll during batch load)
509
+ var _loading = true;
510
+ fetch('/_api/entries').then(r => r.json()).then(data => {
511
+ data.forEach(addEntry);
512
+ _loading = false;
513
+ expireSessionPins();
514
+
515
+ if (_hasDeepLink) {
516
+ applyDeepLink();
517
+ } else if (sessionsMap.size) {
518
+ selectProject(null); // no deep link: default behavior
519
+ }
520
+ applySessionFilter();
521
+ setFocus(_hasDeepLink ? focusedCol : 'projects');
522
+ // Restore tab from URL param after deep-link resolution
523
+ if (typeof restoreTabFromUrl === 'function') restoreTabFromUrl();
524
+ });
525
+
526
+ // Safety timeout: apply deep link after 5 seconds even if entries are still loading
527
+ if (_hasDeepLink) {
528
+ setTimeout(() => {
529
+ if (_loading) {
530
+ _loading = false;
531
+ applyDeepLink();
532
+ applySessionFilter();
533
+ }
534
+ }, 5000);
535
+ }
@@ -0,0 +1,119 @@
1
+ <!DOCTYPE html>
2
+ <html lang="zh-TW">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>ccxray</title>
7
+ <link rel="stylesheet" href="/style.css">
8
+ <script>
9
+ // Apply theme before first paint to prevent flash
10
+ (function() {
11
+ var t = localStorage.getItem('theme');
12
+ if (!t) t = window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark';
13
+ if (t === 'light') document.documentElement.setAttribute('data-theme', 'light');
14
+ })();
15
+ </script>
16
+ <!--__PROXY_CONFIG__-->
17
+ </head>
18
+ <body>
19
+
20
+
21
+ <div id="app">
22
+ <div id="topbar">
23
+ <div id="topbar-row1">
24
+ <h1><span class="dot"></span>ccxray</h1>
25
+ <nav id="topbar-tabs">
26
+ <button class="topbar-tab active" data-tab="dashboard" onclick="switchTab('dashboard')">Dashboard</button>
27
+ <button class="topbar-tab" data-tab="usage" onclick="switchTab('usage')">Usage</button>
28
+ <button class="topbar-tab" data-tab="sysprompt" onclick="switchTab('sysprompt')">System Prompt<span id="sysprompt-badge" style="display:none;width:8px;height:8px;background:var(--red);border-radius:50%;margin-left:4px"></span></button>
29
+ </nav>
30
+ <span id="topbar-held"></span>
31
+ <div id="status-cluster">
32
+ <span id="topbar-status"></span>
33
+ </div>
34
+ <button id="theme-toggle" onclick="toggleTheme()" title="Toggle light/dark mode">☀️</button>
35
+ </div>
36
+ <div id="topbar-row2">
37
+ <div id="row2-dashboard">
38
+ <span id="breadcrumb">root</span>
39
+ <button id="copy-link-btn" onclick="copyCurrentUrl(this)" title="Copy link to clipboard">🔗</button>
40
+ <div id="quota-ticker">
41
+ <div id="qt-bar-wrap" style="display:none">
42
+ <div class="qt-bar-track"><div id="qt-bar-fill"></div></div>
43
+ <span id="qt-bar-pct" title="Percentage of 5-hour token quota used">0%</span>
44
+ <span id="qt-bar-time" title="Time remaining in current 5-hour window"></span>
45
+ </div>
46
+ <span id="qt-roi" style="display:none" onclick="switchTab('usage')" title="Return on investment: monthly cost / $200 plan price">ROI -</span>
47
+ <span id="qt-chip" style="display:none"></span>
48
+ </div>
49
+ </div>
50
+ <div id="row2-usage" style="display:none">
51
+ <span id="row2-usage-summary"></span>
52
+ </div>
53
+ <div id="row2-sysprompt" style="display:none">
54
+ <span id="row2-sp-version"></span>
55
+ </div>
56
+ </div>
57
+ </div>
58
+ <div id="version-banner" style="display:none"></div>
59
+ <div id="cost-page" class="fullscreen-page">
60
+ <div class="fp-header">
61
+ <button class="fp-back" onclick="hideCostPage()">←</button>
62
+ <span class="fp-title">Usage Analytics</span>
63
+ <button class="fp-close" onclick="hideCostPage()">&times;</button>
64
+ </div>
65
+ <div class="fp-content">
66
+ <div class="cost-layout">
67
+ <div class="cost-left">
68
+ <div id="cp-zone1" class="cost-card">
69
+ <div class="cost-card-label">Current 5-Hour Window</div>
70
+ <div id="cp-z1-content">Loading…</div>
71
+ </div>
72
+ <div id="cp-zone2" class="cost-card">
73
+ <div id="cp-z2-label" class="cost-card-label" style="display:flex;align-items:center;justify-content:space-between">ROI &amp; Plan Fit</div>
74
+ <div id="cp-z2-content">Loading…</div>
75
+ </div>
76
+ </div>
77
+ <div class="cost-right">
78
+ <div id="cp-zone3" class="cost-card">
79
+ <div id="cp-z3-content"></div>
80
+ </div>
81
+ </div>
82
+ </div>
83
+ </div>
84
+ </div>
85
+ <div id="columns">
86
+ <div id="col-projects" class="col"><div class="col-title">Projects</div></div>
87
+ <div id="col-sessions" class="col"><div class="col-title" style="display:flex;align-items:center;gap:6px">Sessions <select id="sess-filter-select" onchange="setSessionFilter(this.value)" style="background:var(--surface);color:var(--dim);border:1px solid var(--border);border-radius:3px;font-size:10px;padding:1px 4px;cursor:pointer"><option value="active">Active</option><option value="active+idle" selected>Active+Idle</option><option value="all">All</option></select></div></div>
88
+ <div id="col-turns" class="col"><div class="col-sticky-header"><div class="col-title" style="display:flex;align-items:center">Turns<span id="scroll-toggle" onclick="toggleFollowLive()" style="cursor:pointer;font-size:10px;margin-left:auto"><span class="scroll-on active">ON</span> <span class="scroll-off">OFF</span></span></div><div id="session-tool-bar" style="display:none"></div><div id="ctx-legend"><span><span class="ctx-legend-dot" style="background:var(--color-cache-read)"></span>cache read</span><span><span class="ctx-legend-dot" style="background:var(--color-cache-write)"></span>cache write</span><span><span class="ctx-legend-dot" style="background:var(--color-input)"></span>input</span></div><div id="session-sparkline"></div></div></div>
89
+ <div id="col-sections" class="col"><div class="col-empty">←</div></div>
90
+ <div id="col-detail" class="col"><div class="col-empty">←</div></div>
91
+ </div>
92
+ <div id="diff-overlay" class="fullscreen-page">
93
+ <div class="fp-header">
94
+ <button class="fp-back" onclick="spHandleBack()">←</button>
95
+ <span class="fp-title">System Prompt</span>
96
+ <span id="sp-mode-badge" style="font-size:10px;padding:2px 6px;border-radius:3px;font-weight:600;letter-spacing:0.5px"></span>
97
+ <span id="diff-summary" style="font-size:11px;color:var(--dim)"></span>
98
+ <button class="fp-close" onclick="closeDiffPanel()">&times;</button>
99
+ </div>
100
+ <div class="sp-changelog-body">
101
+ <div id="sp-version-list" class="sp-version-list"></div>
102
+ <div id="diff-text-panel" class="sp-content-area"></div>
103
+ </div>
104
+ <div id="sp-status-bar" class="sp-status-bar">↑↓ navigate &nbsp; Space: toggle content/diff &nbsp; j/k: next/prev hunk</div>
105
+ </div>
106
+ </div>
107
+
108
+ <div id="toast-container"></div>
109
+
110
+ <script src="/app.js"></script>
111
+ <script src="/cost-budget-ui.js"></script>
112
+ <script src="/quota-ticker.js"></script>
113
+ <script src="/miller-columns.js"></script>
114
+ <script src="/keyboard-nav.js"></script>
115
+ <script src="/messages.js"></script>
116
+ <script src="/entry-rendering.js"></script>
117
+ <script src="/intercept-ui.js"></script>
118
+ <script src="/system-prompt-ui.js"></script>
119
+ </html>