@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,1686 @@
1
+
2
+ const allEntries = [];
3
+ let entryCount = 0;
4
+ const sessionsMap = new Map(); // sid → { id, firstTs, firstId, count, model, totalCost, cwd }
5
+ const projectsMap = new Map(); // projectName → { name, totalCost, sessionIds, firstId, lastId }
6
+ const sessionStatusMap = new Map(); // sid → { active: bool, lastSeenAt: number|null }
7
+
8
+ // ── Toast notifications ──
9
+ function showToast(message, duration) {
10
+ duration = duration || 5000;
11
+ const container = document.getElementById('toast-container');
12
+ const el = document.createElement('div');
13
+ el.className = 'toast';
14
+ el.textContent = message;
15
+ el.onclick = function() { el.classList.add('fade-out'); setTimeout(function() { el.remove(); }, 300); };
16
+ container.appendChild(el);
17
+ setTimeout(function() { if (el.parentNode) { el.classList.add('fade-out'); setTimeout(function() { el.remove(); }, 300); } }, duration);
18
+ }
19
+
20
+ // ── System prompt block viewer ──
21
+ const SP_BLOCK_OWNERS = {
22
+ billingHeader: 'anthropic', coreIdentity: 'anthropic', coreInstructions: 'anthropic',
23
+ customSkills: 'user', pluginSkills: 'user', mcpServersList: 'user', settingsJson: 'user', envAndGit: 'user',
24
+ autoMemory: 'user', customAgents: 'user',
25
+ };
26
+
27
+ function splitB2IntoBlocks(b2) {
28
+ const markerDefs = [
29
+ { key: 'customSkills', pattern: /# User'?s Current Configuration/ },
30
+ { key: 'customAgents', pattern: /\*\*Available custom agents/ },
31
+ { key: 'mcpServersList', pattern: /\*\*Configured MCP servers/ },
32
+ { key: 'pluginSkills', pattern: /\*\*Available plugin skills/ },
33
+ { key: 'settingsJson', pattern: /\*\*User's settings\.json/ },
34
+ { key: 'envAndGit', pattern: /# Environment\n|<env>/ },
35
+ { key: 'autoMemory', pattern: /# auto memory\n|You have a persistent, file-based memory/ },
36
+ ];
37
+ var positions = [];
38
+ for (var i = 0; i < markerDefs.length; i++) {
39
+ var m = markerDefs[i];
40
+ var match = m.pattern.exec(b2);
41
+ if (match) positions.push({ key: m.key, index: match.index });
42
+ }
43
+ positions.sort(function(a, b) { return a.index - b.index; });
44
+ var result = {};
45
+ var firstPos = positions.length > 0 ? positions[0].index : b2.length;
46
+ result['coreInstructions'] = b2.slice(0, firstPos);
47
+ for (var j = 0; j < positions.length; j++) {
48
+ var start = positions[j].index;
49
+ var end = j + 1 < positions.length ? positions[j + 1].index : b2.length;
50
+ result[positions[j].key] = b2.slice(start, end);
51
+ }
52
+ return result;
53
+ }
54
+
55
+ function renderSystemBlockViewer(system) {
56
+ // Non-array system prompt → raw text fallback
57
+ if (typeof system === 'string') {
58
+ return '<pre style="margin:0;font-size:11px;font-family:monospace;line-height:1.5;white-space:pre-wrap;word-break:break-word">' + escapeHtml(system) + '</pre>';
59
+ }
60
+ if (!Array.isArray(system) || system.length < 3) {
61
+ return '<pre style="margin:0;font-size:11px;font-family:monospace;line-height:1.5;white-space:pre-wrap;word-break:break-word">' + escapeHtml(JSON.stringify(system, null, 2)) + '</pre>';
62
+ }
63
+
64
+ var b2 = (system[2] && system[2].text) || '';
65
+ var blocks = splitB2IntoBlocks(b2);
66
+ var totalLen = b2.length;
67
+ var html = '<div style="font-size:11px;color:var(--dim);margin-bottom:8px">System Prompt (this turn) &nbsp; <span style="color:var(--text)">' + (totalLen / 1000).toFixed(1) + 'k</span> chars</div>';
68
+
69
+ var blockOrder = ['coreInstructions', 'customSkills', 'customAgents', 'pluginSkills', 'mcpServersList', 'settingsJson', 'envAndGit', 'autoMemory'];
70
+ for (var i = 0; i < blockOrder.length; i++) {
71
+ var key = blockOrder[i];
72
+ var text = blocks[key];
73
+ if (!text) continue;
74
+ var owner = SP_BLOCK_OWNERS[key] || 'unknown';
75
+ var size = (text.length / 1000).toFixed(1) + 'k';
76
+ var ownerColor = owner === 'anthropic' ? 'var(--accent)' : 'var(--green)';
77
+ var isOpen = key !== 'coreInstructions' && key !== 'billingHeader';
78
+ html += '<details' + (isOpen ? ' open' : '') + ' style="margin-bottom:4px;border:1px solid var(--border);border-radius:4px">';
79
+ html += '<summary style="padding:6px 10px;cursor:pointer;font-size:11px;display:flex;align-items:center;gap:8px;background:var(--surface)">';
80
+ html += '<span style="font-weight:600;color:var(--text)">' + escapeHtml(key) + '</span>';
81
+ html += '<span style="color:var(--dim)">' + size + '</span>';
82
+ html += '<span style="font-size:9px;padding:1px 5px;border:1px solid ' + ownerColor + ';color:' + ownerColor + ';border-radius:3px">' + owner + '</span>';
83
+ html += '</summary>';
84
+ html += '<pre style="margin:0;padding:8px 10px;font-size:10px;font-family:monospace;line-height:1.4;white-space:pre-wrap;word-break:break-word;max-height:400px;overflow-y:auto">' + escapeHtml(text) + '</pre>';
85
+ html += '</details>';
86
+ }
87
+ return html;
88
+ }
89
+
90
+ // ── Pin storage ──
91
+ const pinnedProjects = new Set(JSON.parse(localStorage.getItem('xray-pinned-projects') || '[]'));
92
+ const pinnedSessions = new Map(); // sid → { sid, pinnedAt }
93
+ (JSON.parse(localStorage.getItem('xray-pinned-sessions') || '[]')).forEach(p => pinnedSessions.set(p.sid, p));
94
+
95
+ function savePinnedProjects() { localStorage.setItem('xray-pinned-projects', JSON.stringify([...pinnedProjects])); }
96
+ function savePinnedSessions() { localStorage.setItem('xray-pinned-sessions', JSON.stringify([...pinnedSessions.values()])); }
97
+ function togglePinProject(name) {
98
+ if (pinnedProjects.has(name)) pinnedProjects.delete(name);
99
+ else pinnedProjects.add(name);
100
+ savePinnedProjects();
101
+ renderProjectsCol();
102
+ }
103
+ function togglePinSession(sid) {
104
+ if (pinnedSessions.has(sid)) pinnedSessions.delete(sid);
105
+ else pinnedSessions.set(sid, { sid, pinnedAt: Date.now() });
106
+ savePinnedSessions();
107
+ const sessEl = document.getElementById('sess-' + sid.slice(0, 8));
108
+ const sess = sessionsMap.get(sid);
109
+ if (sessEl && sess) sessEl.innerHTML = renderSessionItem(sess, sid);
110
+ applySessionFilter();
111
+ }
112
+
113
+ function expireSessionPins() {
114
+ const SEVEN_DAYS = 7 * 24 * 60 * 60 * 1000;
115
+ const now = Date.now();
116
+ let changed = false;
117
+ for (const [sid, pin] of pinnedSessions) {
118
+ const sess = sessionsMap.get(sid);
119
+ // If session exists, check last activity; if not, use pinnedAt as fallback
120
+ let lastActive = pin.pinnedAt;
121
+ if (sess && sess.lastId && sess.lastId.length >= 19) {
122
+ const ts = new Date(sess.lastId.slice(0, 10) + 'T' + sess.lastId.slice(11, 19).replace(/-/g, ':')).getTime();
123
+ if (ts) lastActive = ts;
124
+ }
125
+ if (now - lastActive > SEVEN_DAYS) {
126
+ pinnedSessions.delete(sid);
127
+ changed = true;
128
+ }
129
+ }
130
+ if (changed) savePinnedSessions();
131
+ }
132
+
133
+ // ── Project visibility filter ──
134
+ let projectFilterMode = sessionStorage.getItem('xray-project-filter') || 'active';
135
+
136
+ function setProjectFilter(mode) {
137
+ projectFilterMode = mode;
138
+ sessionStorage.setItem('xray-project-filter', mode);
139
+ renderProjectsCol();
140
+ }
141
+
142
+ function isSystemProject(name) {
143
+ return name === '(quota-check)' || name === '(unknown)';
144
+ }
145
+
146
+ // ── Session visibility filter ──
147
+ let sessionFilterMode = sessionStorage.getItem('xray-session-filter') || 'active+idle';
148
+
149
+ function setSessionFilter(mode) {
150
+ sessionFilterMode = mode;
151
+ sessionStorage.setItem('xray-session-filter', mode);
152
+ applySessionFilter();
153
+ // Update dropdown display
154
+ const label = document.getElementById('sess-filter-label');
155
+ if (label) label.textContent = mode === 'active' ? 'Active' : mode === 'active+idle' ? 'Active+Idle' : 'All';
156
+ }
157
+
158
+ function applySessionFilter() {
159
+ colSessions.querySelectorAll('.session-item').forEach(el => {
160
+ const sid = el.dataset.sessionId;
161
+ // Pinned sessions are always visible
162
+ if (pinnedSessions.has(sid)) { el.style.display = ''; return; }
163
+ // Project filter still applies
164
+ if (selectedProjectName) {
165
+ const sess = sessionsMap.get(sid);
166
+ const projName = getProjectName(sess ? sess.cwd : null);
167
+ if (projName !== selectedProjectName) { el.style.display = 'none'; return; }
168
+ }
169
+ if (sessionFilterMode === 'all') { el.style.display = ''; return; }
170
+ const status = getStatusClass(sid);
171
+ if (sessionFilterMode === 'active') {
172
+ el.style.display = status === 'sdot-stream' ? '' : 'none';
173
+ } else { // active+idle
174
+ el.style.display = status !== 'sdot-off' ? '' : 'none';
175
+ }
176
+ });
177
+ }
178
+
179
+ function getStatusClass(sid) {
180
+ const s = sessionStatusMap.get(sid);
181
+ if (!s) return 'sdot-off';
182
+ if (s.active) return 'sdot-stream';
183
+ if (s.lastSeenAt && Date.now() - s.lastSeenAt < 5 * 60 * 1000) return 'sdot-idle';
184
+ return 'sdot-off';
185
+ }
186
+ function getProjectStatusClass(proj) {
187
+ const classes = [...proj.sessionIds].map(getStatusClass);
188
+ if (classes.includes('sdot-stream')) return 'sdot-stream';
189
+ if (classes.includes('sdot-idle')) return 'sdot-idle';
190
+ return 'sdot-off';
191
+ }
192
+ function getStatusPriority(statusClass) {
193
+ if (statusClass === 'sdot-stream') return 0;
194
+ if (statusClass === 'sdot-idle') return 1;
195
+ return 2;
196
+ }
197
+ function updateTopbarStatus() {
198
+ const streaming = [...sessionStatusMap.values()].filter(s => s.active).length;
199
+ const idle = [...sessionStatusMap.values()].filter(s =>
200
+ !s.active && s.lastSeenAt && Date.now() - s.lastSeenAt < 5 * 60 * 1000
201
+ ).length;
202
+ let txt = '';
203
+ if (streaming) txt += '<span style="color:var(--green)">●' + streaming + ' streaming</span> ';
204
+ if (idle) txt += '<span style="color:var(--yellow)">◐' + idle + ' idle</span>';
205
+ document.getElementById('topbar-status').innerHTML = txt;
206
+ }
207
+
208
+ // ── Intercept state ──
209
+ const interceptSessionIds = new Set();
210
+ let currentPending = null; // { requestId, sessionId, body, receivedAt }
211
+ let interceptTimeoutSec = 120;
212
+ let countdownInterval = null;
213
+
214
+ // ── Follow live turn state ──
215
+ let followLiveTurn = true;
216
+ function toggleFollowLive() {
217
+ followLiveTurn = !followLiveTurn;
218
+ const btn = document.getElementById('scroll-toggle');
219
+ if (btn) {
220
+ btn.querySelector('.scroll-on').classList.toggle('active', followLiveTurn);
221
+ btn.querySelector('.scroll-off').classList.toggle('active', !followLiveTurn);
222
+ }
223
+ }
224
+ function scrollTurnsToBottom() {
225
+ if (followLiveTurn) colTurns.scrollTop = colTurns.scrollHeight;
226
+ }
227
+
228
+ let selectedProjectName = null; // null = (all)
229
+ let selectedSessionId = null;
230
+ let selectedTurnIdx = -1;
231
+ let selectedSection = null;
232
+ let selectedMessageIdx = -1;
233
+ let focusedCol = 'projects'; // 'projects' | 'sessions' | 'turns' | 'sections' | 'messages'
234
+ let isFocusedMode = false;
235
+
236
+ function enterFocusedMode() {
237
+ if (isFocusedMode) return;
238
+ isFocusedMode = true;
239
+ document.getElementById('columns').classList.add('focused');
240
+ renderDetailCol();
241
+ }
242
+
243
+ function exitFocusedMode() {
244
+ if (!isFocusedMode) return;
245
+ isFocusedMode = false;
246
+ document.getElementById('columns').classList.remove('focused');
247
+ setFocus('sections');
248
+ renderDetailCol();
249
+ }
250
+ const colProjects = document.getElementById('col-projects');
251
+ const colSessions = document.getElementById('col-sessions');
252
+ const colTurns = document.getElementById('col-turns');
253
+ const colSections = document.getElementById('col-sections');
254
+ const colDetail = document.getElementById('col-detail');
255
+
256
+ function truncateMiddle(s, max) {
257
+ if (s.length <= max) return s;
258
+ const tail = Math.ceil(max * 0.6);
259
+ const head = max - tail - 1;
260
+ return s.slice(0, head) + '…' + s.slice(-tail);
261
+ }
262
+
263
+ function getProjectName(cwd) {
264
+ if (!cwd) return '(unknown)';
265
+ if (cwd.startsWith('(')) return cwd;
266
+ const parts = cwd.split('/').filter(Boolean);
267
+ return parts[parts.length - 1] || cwd;
268
+ }
269
+
270
+ function formatEntryDate(id) {
271
+ // id format: "2026-03-08T17-47-13-000"
272
+ if (!id || id.length < 16) return '';
273
+ const months = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
274
+ const month = parseInt(id.slice(5, 7)) - 1;
275
+ const day = id.slice(8, 10);
276
+ const hour = id.slice(11, 13);
277
+ const min = id.slice(14, 16);
278
+ if (month < 0 || month > 11) return '';
279
+ return months[month] + ' ' + day + ' ' + hour + ':' + min;
280
+ }
281
+
282
+ function formatRelativeTime(id) {
283
+ if (!id || id.length < 19) return formatEntryDate(id);
284
+ const ts = new Date(id.slice(0, 10) + 'T' + id.slice(11, 19).replace(/-/g, ':')).getTime();
285
+ if (!ts) return formatEntryDate(id);
286
+ const diff = Date.now() - ts;
287
+ if (diff < 60000) return 'just now';
288
+ if (diff < 3600000) return Math.floor(diff / 60000) + 'm ago';
289
+ if (diff < 86400000) return Math.floor(diff / 3600000) + 'h ago';
290
+ if (diff < 604800000) return Math.floor(diff / 86400000) + 'd ago';
291
+ return formatEntryDate(id);
292
+ }
293
+
294
+ function formatEntryDateShort(id) {
295
+ if (!id || id.length < 10) return '';
296
+ const months = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
297
+ const month = parseInt(id.slice(5, 7)) - 1;
298
+ const day = id.slice(8, 10);
299
+ if (month < 0 || month > 11) return '';
300
+ return months[month] + ' ' + day;
301
+ }
302
+
303
+ function copyLaunchCmd(sid, btn) {
304
+ const port = window.__PROXY_CONFIG__?.PORT || location.port || 5577;
305
+ const cmd = 'ANTHROPIC_BASE_URL=http://localhost:' + port + ' claude --continue ' + sid;
306
+ navigator.clipboard.writeText(cmd).then(() => {
307
+ btn.textContent = '✓';
308
+ btn.style.color = 'var(--green)';
309
+ setTimeout(() => { btn.textContent = '⧉'; btn.style.color = ''; }, 1500);
310
+ });
311
+ }
312
+
313
+ function copyCurrentUrl(btn) {
314
+ navigator.clipboard.writeText(window.location.href).then(() => {
315
+ btn.textContent = '✓';
316
+ btn.style.color = 'var(--green)';
317
+ setTimeout(() => { btn.textContent = '🔗'; btn.style.color = ''; }, 1500);
318
+ });
319
+ }
320
+
321
+ function clearAll() { // kept for console use if needed
322
+ colProjects.innerHTML = '<div class="col-title">Projects</div>';
323
+ colSessions.innerHTML = '<div class="col-title">Sessions</div>';
324
+ 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>';
325
+ colSections.innerHTML = '<div class="col-empty">←</div>';
326
+ colDetail.innerHTML = '<div class="col-empty">←</div>';
327
+ allEntries.length = 0;
328
+ sessionsMap.clear();
329
+ projectsMap.clear();
330
+ entryCount = 0;
331
+ selectedProjectName = null;
332
+ selectedSessionId = null;
333
+ selectedTurnIdx = -1;
334
+ selectedSection = null;
335
+ selectedMessageIdx = -1;
336
+ renderBreadcrumb();
337
+ }
338
+
339
+ function renderSessionItem(sess, sid) {
340
+ const shortSid = sid === 'direct-api' ? 'direct API' : sid.slice(0, 8);
341
+ const shortModel = (sess.model || '?').replace('claude-', '').replace(/-[0-9]{8}$/, '');
342
+ const costStr = sess.totalCost > 0 ? '$' + sess.totalCost.toFixed(3) : '—';
343
+ const dateStr = sess.lastId ? formatRelativeTime(sess.lastId) : (sess.firstId ? formatEntryDate(sess.firstId) : escapeHtml(sess.firstTs || ''));
344
+ const totalCalls = Object.values(sess.toolCalls || {}).reduce((s, n) => s + n, 0);
345
+ const topTools = Object.entries(sess.toolCalls || {})
346
+ .sort((a, b) => b[1] - a[1]).slice(0, 3)
347
+ .map(([n, c]) => escapeHtml(n.replace(/^mcp__[^_]+__/, '')) + '·' + c)
348
+ .join(' ');
349
+ const toolRow = totalCalls > 0
350
+ ? '<div class="si-tools">' + (topTools || totalCalls + ' calls') + '</div>'
351
+ : '';
352
+ const ctxPct = sess.latestMainCtxPct || 0;
353
+ const ctxAlertHtml = ctxPct >= 90
354
+ ? '<span class="ctx-alert ctx-alert-red">' + Math.round(ctxPct) + '%</span>'
355
+ : ctxPct >= 80
356
+ ? '<span class="ctx-alert ctx-alert-yellow">' + Math.round(ctxPct) + '%</span>'
357
+ : '';
358
+ const isOnline = getStatusClass(sid) !== 'sdot-off';
359
+ const isArmed = interceptSessionIds.has(sid);
360
+ const isHeld = currentPending && currentPending.sessionId === sid;
361
+ const sdotClasses = 'sdot ' + getStatusClass(sid) + (isArmed ? ' sdot-armed' : '');
362
+ const sdotTitle = !isOnline ? '' : isArmed ? 'Intercept armed · click to disable' : 'Click to arm intercept';
363
+ const sdotOnclick = isOnline ? 'event.stopPropagation();toggleIntercept(&quot;' + escapeHtml(sid) + '&quot;)' : '';
364
+ const heldHtml = isHeld ? '<span class="held-badge" onclick="event.stopPropagation();showInterceptOverlay()">HELD</span>' : '';
365
+ const isPinned = pinnedSessions.has(sid);
366
+ const pinBtn = '<button class="pin-btn' + (isPinned ? ' pinned' : '') + '" onclick="event.stopPropagation();togglePinSession(&quot;' + escapeHtml(sid) + '&quot;)" title="' + (isPinned ? 'Unpin' : 'Pin') + '">★</button>';
367
+ return '<div class="si-row1">' +
368
+ '<button class="' + sdotClasses + '"' + (sdotTitle ? ' title="' + sdotTitle + '"' : '') + (sdotOnclick ? ' onclick="' + sdotOnclick + '"' : '') + ' tabindex="-1"></button>' +
369
+ '<span class="sid">' + escapeHtml(shortSid) + '</span>' +
370
+ pinBtn +
371
+ '<button class="launch-btn" onclick="event.stopPropagation();copyLaunchCmd(&quot;' + escapeHtml(sid) + '&quot;,this)" title="Copy launch cmd">&#8855;</button>' +
372
+ heldHtml +
373
+ '</div>' +
374
+ '<div class="si-row2">' + escapeHtml(shortModel) + ' · ' + sess.count + 't · <span class="si-cost">' + escapeHtml(costStr) + '</span></div>' +
375
+ toolRow +
376
+ '<div class="si-row3"><span title="' + escapeHtml(sess.lastId ? formatEntryDate(sess.lastId) : '') + '">' + dateStr + '</span>' + ctxAlertHtml + '</div>' +
377
+ renderPredictionRow(sid);
378
+ }
379
+
380
+ function renderProjectsCol() {
381
+ let html = '<div class="col-title" style="display:flex;align-items:center;gap:6px">Projects' +
382
+ '<select id="proj-filter-select" onchange="setProjectFilter(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">' +
383
+ '<option value="active"' + (projectFilterMode === 'active' ? ' selected' : '') + '>Active</option>' +
384
+ '<option value="all"' + (projectFilterMode === 'all' ? ' selected' : '') + '>All</option>' +
385
+ '</select></div>';
386
+
387
+ const sorted = [...projectsMap.values()].sort((a, b) => {
388
+ // Sort by: pinned first, then status (streaming > idle > off), then last activity
389
+ const pa = pinnedProjects.has(a.name) ? 0 : 1;
390
+ const pb = pinnedProjects.has(b.name) ? 0 : 1;
391
+ if (pa !== pb) return pa - pb;
392
+ const sa = getStatusPriority(getProjectStatusClass(a));
393
+ const sb = getStatusPriority(getProjectStatusClass(b));
394
+ if (sa !== sb) return sa - sb;
395
+ return (b.lastId || '').localeCompare(a.lastId || '');
396
+ });
397
+ for (const proj of sorted) {
398
+ const isPinned = pinnedProjects.has(proj.name);
399
+ const statusClass = getProjectStatusClass(proj);
400
+ // Filter: in 'active' mode, hide system + inactive (unless pinned or selected)
401
+ if (projectFilterMode === 'active') {
402
+ const isSel = selectedProjectName === proj.name;
403
+ if (!isPinned && !isSel && (isSystemProject(proj.name) || statusClass === 'sdot-off')) continue;
404
+ }
405
+ const isSel = selectedProjectName === proj.name;
406
+ const firstDate = proj.firstId ? formatEntryDateShort(proj.firstId) : '';
407
+ const lastDate = proj.lastId ? formatEntryDateShort(proj.lastId) : '';
408
+ const rangeStr = firstDate === lastDate ? firstDate : firstDate + '—' + lastDate;
409
+ const pinBtn = '<button class="pin-btn' + (isPinned ? ' pinned' : '') + '" onclick="event.stopPropagation();togglePinProject(' + JSON.stringify(proj.name).replace(/"/g, '&quot;') + ')" title="' + (isPinned ? 'Unpin' : 'Pin') + '">★</button>';
410
+ html += '<div class="project-item' + (isSel ? ' selected' : '') + '" onclick="selectProject(' + JSON.stringify(proj.name).replace(/"/g, '&quot;') + ')">' +
411
+ '<div class="pi-name"><span class="sdot ' + statusClass + '"></span>' + escapeHtml(truncateMiddle(proj.name, 20)) + pinBtn + '</div>' +
412
+ '<div class="pi-meta">' + proj.sessionIds.size + ' sessions</div>' +
413
+ '<div class="pi-meta pi-cost">$' + proj.totalCost.toFixed(3) + '</div>' +
414
+ (rangeStr ? '<div class="pi-range">' + escapeHtml(rangeStr) + '</div>' : '') +
415
+ '</div>';
416
+ }
417
+ colProjects.innerHTML = html;
418
+ }
419
+
420
+ function selectProject(name) {
421
+ // Toggle: clicking already-selected project returns to (all)
422
+ selectedProjectName = (name !== null && name === selectedProjectName) ? null : name;
423
+ renderProjectsCol();
424
+ applySessionFilter();
425
+
426
+ // Clear downstream — Miller column rule: N+2 onwards must clear
427
+ selectedSessionId = null;
428
+ selectedTurnIdx = -1;
429
+ selectedSection = null;
430
+ selectedMessageIdx = -1;
431
+ colTurns.querySelectorAll('.turn-item').forEach(el => { el.style.display = 'none'; });
432
+ colSections.innerHTML = '';
433
+ colDetail.innerHTML = '';
434
+ renderBreadcrumb();
435
+ setFocus('projects');
436
+ }
437
+
438
+ function escapeHtml(s) {
439
+ if (typeof s !== 'string') s = JSON.stringify(s, null, 2);
440
+ return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
441
+ }
442
+
443
+ function fmt(n) { return n != null ? n.toLocaleString() : '—'; }
444
+
445
+ // ── Miller Columns: Selection ──
446
+ let _hoverTimer = null;
447
+
448
+ function setFocus(col) {
449
+ focusedCol = col;
450
+ colProjects.classList.toggle('col-focused', col === 'projects');
451
+ colSessions.classList.toggle('col-focused', col === 'sessions');
452
+ colTurns.classList.toggle('col-focused', col === 'turns');
453
+ colSections.classList.toggle('col-focused', col === 'sections');
454
+ }
455
+
456
+ function getVisibleTurnIndices() {
457
+ return allEntries
458
+ .map((e, i) => i)
459
+ .filter(i => selectedSessionId && allEntries[i].sessionId === selectedSessionId);
460
+ }
461
+
462
+ function renderSessionToolBar(sid) {
463
+ const bar = document.getElementById('session-tool-bar');
464
+ if (!bar) return;
465
+ const sess = sid ? sessionsMap.get(sid) : null;
466
+ if (!sess || !sess.toolCalls) { bar.style.display = 'none'; return; }
467
+ const total = Object.values(sess.toolCalls).reduce((s, n) => s + n, 0);
468
+ if (!total) { bar.style.display = 'none'; return; }
469
+ const sorted = Object.entries(sess.toolCalls).sort((a, b) => b[1] - a[1]);
470
+ const chips = sorted.slice(0, 6).map(([n, c]) =>
471
+ '<span class="tool-chip">' + escapeHtml(n.replace(/^mcp__[^_]+__/, '')) + '·' + c + '</span>'
472
+ ).join('');
473
+ bar.innerHTML = total + ' calls &nbsp;' + chips;
474
+ bar.style.display = '';
475
+ }
476
+
477
+ function renderSessionSparkline(sid) {
478
+ const el = document.getElementById('session-sparkline');
479
+ if (!el) return;
480
+ if (!sid) { el.style.display = 'none'; return; }
481
+
482
+ const turns = allEntries.filter(e =>
483
+ e.sessionId === sid &&
484
+ !e.isSubagent &&
485
+ e.usage && (e.usage.input_tokens || 0) > 0
486
+ );
487
+ if (turns.length < 1) { el.style.display = 'none'; return; }
488
+
489
+ // Use stacked area chart for ≥ 3 turns, fallback to bar chart for < 3
490
+ if (turns.length >= 3) {
491
+ renderStackedAreaChart(el, turns);
492
+ } else {
493
+ renderBarChart(el, turns);
494
+ }
495
+ el.style.display = '';
496
+ }
497
+
498
+ function renderBarChart(el, turns) {
499
+ const W = 400, H = 40, PAD = 4;
500
+ const maxCtx = turns.reduce((m, e) => Math.max(m, e.maxContext || DEFAULT_MAX_CTX), 0) || DEFAULT_MAX_CTX;
501
+ const barW = Math.max(2, (W - 2 * PAD) / turns.length);
502
+ const gap = Math.min(1, barW * 0.15);
503
+
504
+ let svg = '<svg viewBox="0 0 ' + W + ' ' + H + '" preserveAspectRatio="none">';
505
+ const threshY = (H - PAD - (0.8 * (H - 2 * PAD))).toFixed(1);
506
+ svg += '<line x1="' + PAD + '" y1="' + threshY + '" x2="' + (W - PAD) + '" y2="' + threshY + '" stroke="var(--dim)" stroke-width="0.5" stroke-dasharray="4 2"/>';
507
+
508
+ turns.forEach((e, i) => {
509
+ const ctx = e.ctxUsed || 0;
510
+ const pct = Math.min(100, ctx / maxCtx * 100);
511
+ const color = pct > 90 ? 'var(--red)' : pct > 80 ? 'var(--yellow)' : 'var(--accent)';
512
+ const x = PAD + i * barW;
513
+ const barH = pct / 100 * (H - 2 * PAD);
514
+ const y = H - PAD - barH;
515
+ svg += '<rect x="' + x.toFixed(1) + '" y="' + y.toFixed(1) + '" width="' + (barW - gap).toFixed(1) + '" height="' + barH.toFixed(1) + '" fill="' + color + '"/>';
516
+ if (e.isCompacted) {
517
+ svg += '<rect x="' + x.toFixed(1) + '" y="' + PAD + '" width="' + (barW - gap).toFixed(1) + '" height="3" fill="var(--red)" opacity="0.9"/>';
518
+ }
519
+ });
520
+ svg += '</svg>';
521
+ el.innerHTML = svg;
522
+ }
523
+
524
+ function predictRemainingTurns(sid) {
525
+ const turns = allEntries.filter(e =>
526
+ e.sessionId === sid && !e.isSubagent &&
527
+ e.usage && (e.usage.input_tokens || 0) > 0 &&
528
+ e.tokens && e.tokens.messages > 0
529
+ );
530
+ if (turns.length < 3) return null;
531
+
532
+ // Find last compaction and only use turns after it
533
+ let startIdx = 0;
534
+ for (let i = turns.length - 1; i >= 0; i--) {
535
+ if (turns[i].isCompacted) { startIdx = i; break; }
536
+ }
537
+ const recent = turns.slice(startIdx);
538
+ if (recent.length < 2) return null;
539
+
540
+ // Take last 5 turns, compute message token increments
541
+ const window = recent.slice(-5);
542
+ const deltas = [];
543
+ for (let i = 1; i < window.length; i++) {
544
+ deltas.push((window[i].tokens.messages || 0) - (window[i - 1].tokens.messages || 0));
545
+ }
546
+ if (!deltas.length) return null;
547
+ const avgDelta = deltas.reduce((s, d) => s + d, 0) / deltas.length;
548
+ if (avgDelta <= 0) return null;
549
+
550
+ const last = recent[recent.length - 1];
551
+ const maxCtx = last.maxContext || DEFAULT_MAX_CTX;
552
+ const currentTotal = (last.tokens.system || 0) + (last.tokens.tools || 0) + (last.tokens.messages || 0);
553
+ const remaining = maxCtx - currentTotal;
554
+ if (remaining <= 0) return 0;
555
+
556
+ return Math.round(remaining / avgDelta);
557
+ }
558
+
559
+ function computeSessionScorecard(sid) {
560
+ const turns = allEntries.filter(e =>
561
+ e.sessionId === sid && !e.isSubagent && e.usage && (e.usage.input_tokens || 0) > 0
562
+ );
563
+ if (turns.length < 2) return null;
564
+
565
+ // Cache hit rate
566
+ let totalCacheRead = 0, totalCacheCreate = 0;
567
+ for (const e of turns) {
568
+ totalCacheRead += e.usage.cache_read_input_tokens || 0;
569
+ totalCacheCreate += e.usage.cache_creation_input_tokens || 0;
570
+ }
571
+ const totalCache = totalCacheRead + totalCacheCreate;
572
+ const cacheHitRate = totalCache > 0 ? (totalCacheRead / totalCache * 100) : 0;
573
+
574
+ // Context efficiency: messages / total context (how much is "useful" conversation)
575
+ const latest = turns[turns.length - 1];
576
+ const tok = latest.tokens || {};
577
+ const msgTokens = tok.messages || 0;
578
+ const totalTok = (tok.system || 0) + (tok.tools || 0) + msgTokens;
579
+ const contextEfficiency = totalTok > 0 ? (msgTokens / totalTok * 100) : 0;
580
+
581
+ // Compression count
582
+ const compressionCount = turns.filter(e => e.isCompacted).length;
583
+
584
+ // Tool utilization
585
+ const usedTools = new Set();
586
+ let availableTools = 0;
587
+ for (const e of turns) {
588
+ for (const name of Object.keys(e.toolCalls || {})) usedTools.add(name);
589
+ if (e.toolCount) availableTools = Math.max(availableTools, e.toolCount);
590
+ }
591
+ const toolUtilization = availableTools > 0 ? (usedTools.size / availableTools * 100) : 0;
592
+
593
+ return { cacheHitRate, contextEfficiency, compressionCount, toolUtilization, turnCount: turns.length };
594
+ }
595
+
596
+ function renderPredictionRow(sid) {
597
+ const remaining = predictRemainingTurns(sid);
598
+ if (remaining === null) return '';
599
+ const color = remaining <= 3 ? 'var(--red)' : remaining <= 8 ? 'var(--yellow)' : 'var(--dim)';
600
+ return '<div style="font-size:10px;color:' + color + ';margin-top:2px">≈' + remaining + ' turns left</div>';
601
+ }
602
+
603
+ // ── Scorecard hover card ──
604
+ let scorecardTimer = null;
605
+ let scorecardEl = null;
606
+
607
+ function initScorecardHover() {
608
+ colSessions.addEventListener('mouseenter', function(ev) {
609
+ const sessItem = ev.target.closest('.session-item');
610
+ if (!sessItem) return;
611
+ clearTimeout(scorecardTimer);
612
+ scorecardTimer = setTimeout(() => showScorecard(sessItem), 300);
613
+ }, true);
614
+ colSessions.addEventListener('mouseleave', function(ev) {
615
+ const sessItem = ev.target.closest('.session-item');
616
+ if (!sessItem) return;
617
+ clearTimeout(scorecardTimer);
618
+ hideScorecard();
619
+ }, true);
620
+ }
621
+
622
+ function showScorecard(sessItem) {
623
+ const sid = sessItem.dataset.sessionId;
624
+ if (!sid) return;
625
+ const sc = computeSessionScorecard(sid);
626
+ if (!sc) return;
627
+
628
+ hideScorecard();
629
+ const el = document.createElement('div');
630
+ el.className = 'scorecard-tooltip';
631
+
632
+ function bar(pct, color) {
633
+ return '<div class="sc-bar"><div class="sc-bar-fill" style="width:' + Math.min(100, pct).toFixed(0) + '%;background:' + color + '"></div></div>';
634
+ }
635
+ function row(label, value, pct, color) {
636
+ return '<div class="sc-row"><span class="sc-label">' + label + '</span><span class="sc-value" style="color:' + color + '">' + value + '</span></div>' + bar(pct, color);
637
+ }
638
+
639
+ const chColor = sc.cacheHitRate >= 80 ? 'var(--green)' : sc.cacheHitRate >= 50 ? 'var(--yellow)' : 'var(--red)';
640
+ const ceColor = sc.contextEfficiency >= 50 ? 'var(--accent)' : 'var(--yellow)';
641
+ const tuColor = sc.toolUtilization >= 30 ? 'var(--green)' : 'var(--yellow)';
642
+ const ccColor = sc.compressionCount === 0 ? 'var(--green)' : 'var(--yellow)';
643
+
644
+ el.innerHTML = '<div style="font-weight:bold;margin-bottom:6px;font-size:11px">Session Scorecard</div>' +
645
+ row('Context Efficiency', sc.contextEfficiency.toFixed(0) + '%', sc.contextEfficiency, ceColor) +
646
+ row('Cache Hit Rate', sc.cacheHitRate.toFixed(0) + '%', sc.cacheHitRate, chColor) +
647
+ '<div class="sc-row"><span class="sc-label">Compressions</span><span class="sc-value" style="color:' + ccColor + '">' + sc.compressionCount + '</span></div>' +
648
+ row('Tool Utilization', sc.toolUtilization.toFixed(0) + '%', sc.toolUtilization, tuColor) +
649
+ '<div style="margin-top:4px;color:var(--dim)">' + sc.turnCount + ' turns</div>';
650
+
651
+ sessItem.style.position = 'relative';
652
+ sessItem.appendChild(el);
653
+ scorecardEl = el;
654
+ }
655
+
656
+ function hideScorecard() {
657
+ if (scorecardEl) {
658
+ scorecardEl.remove();
659
+ scorecardEl = null;
660
+ }
661
+ }
662
+
663
+ // Initialize scorecard hover when DOM is ready
664
+ if (document.readyState === 'loading') {
665
+ document.addEventListener('DOMContentLoaded', initScorecardHover);
666
+ } else {
667
+ initScorecardHover();
668
+ }
669
+
670
+ function renderStackedAreaChart(el, turns) {
671
+ const W = 400, H = 56, PAD = 4;
672
+ const maxCtx = turns.reduce((m, e) => Math.max(m, e.maxContext || DEFAULT_MAX_CTX), 0) || DEFAULT_MAX_CTX;
673
+ const drawW = W - 2 * PAD;
674
+ const drawH = H - 2 * PAD;
675
+ const n = turns.length;
676
+
677
+ // Extract stacked values per turn: [system, tools, messages]
678
+ const layers = [
679
+ { key: 'system', color: 'var(--color-system-deep)', label: 'System' },
680
+ { key: 'tools', color: 'var(--color-tools)', label: 'Tools' },
681
+ { key: 'messages', color: 'var(--color-messages)', label: 'Messages' },
682
+ ];
683
+
684
+ // Build cumulative Y values for each turn
685
+ const stacks = turns.map(e => {
686
+ const tok = e.tokens || {};
687
+ return [tok.system || 0, tok.tools || 0, tok.messages || 0];
688
+ });
689
+
690
+ function yPos(val) {
691
+ return H - PAD - Math.min(1, val / maxCtx) * drawH;
692
+ }
693
+ function xPos(i) {
694
+ return PAD + (i / (n - 1)) * drawW;
695
+ }
696
+
697
+ let svg = '<svg viewBox="0 0 ' + W + ' ' + H + '" preserveAspectRatio="none" style="cursor:crosshair">';
698
+
699
+ // 80% threshold dashed line
700
+ const threshY = yPos(maxCtx * 0.8).toFixed(1);
701
+ svg += '<line x1="' + PAD + '" y1="' + threshY + '" x2="' + (W - PAD) + '" y2="' + threshY + '" stroke="var(--dim)" stroke-width="0.5" stroke-dasharray="4 2"/>';
702
+
703
+ // Draw stacked areas (bottom to top: system, tools, messages)
704
+ for (let layerIdx = layers.length - 1; layerIdx >= 0; layerIdx--) {
705
+ // Cumulative top for this layer = sum of layers 0..layerIdx
706
+ const topPoints = [];
707
+ const botPoints = [];
708
+ for (let i = 0; i < n; i++) {
709
+ let cumTop = 0;
710
+ for (let j = 0; j <= layerIdx; j++) cumTop += stacks[i][j];
711
+ let cumBot = 0;
712
+ for (let j = 0; j < layerIdx; j++) cumBot += stacks[i][j];
713
+ topPoints.push(xPos(i).toFixed(1) + ',' + yPos(cumTop).toFixed(1));
714
+ botPoints.push(xPos(i).toFixed(1) + ',' + yPos(cumBot).toFixed(1));
715
+ }
716
+ const d = 'M' + topPoints.join(' L') + ' L' + botPoints.reverse().join(' L') + ' Z';
717
+ svg += '<path d="' + d + '" fill="' + layers[layerIdx].color + '" opacity="0.8"/>';
718
+ }
719
+
720
+ // Compression markers: red vertical dashed lines
721
+ turns.forEach((e, i) => {
722
+ if (e.isCompacted) {
723
+ const x = xPos(i).toFixed(1);
724
+ svg += '<line x1="' + x + '" y1="' + PAD + '" x2="' + x + '" y2="' + (H - PAD) + '" stroke="var(--red)" stroke-width="1.5" stroke-dasharray="3 2" opacity="0.9"/>';
725
+ }
726
+ });
727
+
728
+ // Prediction extension line: dashed line from last turn projecting to maxContext
729
+ if (n >= 3) {
730
+ const lastStk = stacks[n - 1];
731
+ const lastTotal = lastStk[0] + lastStk[1] + lastStk[2];
732
+ const lastPct = lastTotal / maxCtx;
733
+ if (lastPct < 0.95) {
734
+ // Compute avg messages delta from last 5 turns
735
+ const windowSize = Math.min(5, n);
736
+ let sumDelta = 0, count = 0;
737
+ for (let i = n - windowSize; i < n - 1; i++) {
738
+ const d = stacks[i + 1][2] - stacks[i][2]; // messages delta
739
+ if (d > 0) { sumDelta += d; count++; }
740
+ }
741
+ if (count > 0) {
742
+ const avgDelta = sumDelta / count;
743
+ const turnsToFull = (maxCtx - lastTotal) / avgDelta;
744
+ const projX = xPos(n - 1 + turnsToFull);
745
+ // Clamp to chart width
746
+ const clampX = Math.min(W - PAD, projX).toFixed(1);
747
+ svg += '<line x1="' + xPos(n - 1).toFixed(1) + '" y1="' + yPos(lastTotal).toFixed(1) + '" x2="' + clampX + '" y2="' + yPos(maxCtx).toFixed(1) + '" stroke="var(--dim)" stroke-width="1" stroke-dasharray="4 2" opacity="0.6"/>';
748
+ }
749
+ }
750
+ }
751
+
752
+ // Invisible hover rects for tooltip interaction
753
+ const sliceW = drawW / n;
754
+ turns.forEach((e, i) => {
755
+ const tok = e.tokens || {};
756
+ const sys = tok.system || 0, tools = tok.tools || 0, msgs = tok.messages || 0;
757
+ const total = sys + tools + msgs;
758
+ const pctS = total ? (sys / total * 100).toFixed(0) : '0';
759
+ const pctT = total ? (tools / total * 100).toFixed(0) : '0';
760
+ const pctM = total ? (msgs / total * 100).toFixed(0) : '0';
761
+ const num = e.displayNum || (i + 1);
762
+ const compactNote = e.isCompacted ? ' ⚠ compressed' : '';
763
+ const title = '#' + num + compactNote
764
+ + '\\nSystem: ' + (sys).toLocaleString() + ' (' + pctS + '%)'
765
+ + '\\nTools: ' + (tools).toLocaleString() + ' (' + pctT + '%)'
766
+ + '\\nMsgs: ' + (msgs).toLocaleString() + ' (' + pctM + '%)'
767
+ + '\\nTotal: ' + (total).toLocaleString() + ' / ' + maxCtx.toLocaleString();
768
+ const rx = PAD + i * sliceW;
769
+ svg += '<rect x="' + rx.toFixed(1) + '" y="0" width="' + sliceW.toFixed(1) + '" height="' + H + '" fill="transparent"><title>' + title + '</title></rect>';
770
+ });
771
+
772
+ svg += '</svg>';
773
+ el.innerHTML = svg;
774
+ }
775
+
776
+ function selectSessionAndLatestTurn(sid) {
777
+ selectedSessionId = sid;
778
+ colSessions.querySelectorAll('.session-item').forEach(el => {
779
+ el.classList.toggle('selected', el.dataset.sessionId === sid);
780
+ });
781
+ colTurns.querySelectorAll('.turn-item').forEach(el => {
782
+ el.style.display = (sid && el.dataset.sessionId === sid) ? '' : 'none';
783
+ });
784
+ // Auto-select latest turn in this session
785
+ const visible = getVisibleTurnIndices();
786
+ if (visible.length) selectTurn(visible[visible.length - 1]);
787
+ renderSessionToolBar(sid);
788
+ renderSessionSparkline(sid);
789
+ renderBreadcrumb();
790
+ }
791
+
792
+ function renderBreadcrumb() {
793
+ const segments = [];
794
+ segments.push({ label: 'root', action: () => selectProject(null) });
795
+ const projName = selectedProjectName || (selectedSessionId && sessionsMap.get(selectedSessionId) && getProjectName(sessionsMap.get(selectedSessionId).cwd));
796
+ if (projName) segments.push({ label: projName, action: () => { selectProject(projName); } });
797
+ if (selectedSessionId) segments.push({ label: 'session:' + selectedSessionId.slice(0, 8), action: () => { selectSessionAndLatestTurn(selectedSessionId); } });
798
+ if (selectedTurnIdx >= 0) {
799
+ const e = allEntries[selectedTurnIdx];
800
+ const sessEl = e ? colTurns.querySelector('.turn-item[data-entry-idx="' + selectedTurnIdx + '"]') : null;
801
+ const sessNum = sessEl ? sessEl.dataset.sessNum : (selectedTurnIdx + 1);
802
+ const idx = selectedTurnIdx;
803
+ segments.push({ label: '#' + sessNum, action: () => { selectTurn(idx); } });
804
+ }
805
+ if (selectedSection) {
806
+ const sec = selectedSection;
807
+ segments.push({ label: sec, action: () => { selectSection(sec); } });
808
+ }
809
+ if (selectedSection === 'timeline' && selectedMessageIdx >= 0)
810
+ segments.push({ label: 'step[' + selectedMessageIdx + ']', action: null });
811
+
812
+ const bc = document.getElementById('breadcrumb');
813
+ bc.innerHTML = '';
814
+ segments.forEach((seg, i) => {
815
+ if (i > 0) {
816
+ const sep = document.createElement('span');
817
+ sep.className = 'bc-sep';
818
+ sep.textContent = ' › ';
819
+ bc.appendChild(sep);
820
+ }
821
+ const span = document.createElement('span');
822
+ span.className = 'bc-seg';
823
+ span.textContent = seg.label;
824
+ if (seg.action && i < segments.length - 1) {
825
+ span.onclick = (e) => { e.stopPropagation(); seg.action(); };
826
+ } else {
827
+ span.style.cursor = 'default';
828
+ }
829
+ bc.appendChild(span);
830
+ });
831
+ syncUrlFromState();
832
+ }
833
+
834
+ function syncUrlFromState() {
835
+ if (_loading) return; // Don't update URL during initial load
836
+ const params = new URLSearchParams();
837
+ // Preserve view param from tab system
838
+ if (typeof activeTab !== 'undefined' && activeTab !== 'dashboard') params.set('view', activeTab);
839
+ const projName = selectedProjectName || (selectedSessionId && sessionsMap.get(selectedSessionId) && getProjectName(sessionsMap.get(selectedSessionId).cwd));
840
+ if (projName) params.set('p', projName);
841
+ if (selectedSessionId) params.set('s', selectedSessionId.slice(0, 8));
842
+ if (selectedTurnIdx >= 0) {
843
+ const e = allEntries[selectedTurnIdx];
844
+ const turnEl = e ? colTurns.querySelector('.turn-item[data-entry-idx="' + selectedTurnIdx + '"]') : null;
845
+ const num = turnEl ? turnEl.dataset.sessNum : String(selectedTurnIdx + 1);
846
+ params.set('t', num);
847
+ }
848
+ if (selectedSection) params.set('sec', selectedSection);
849
+ if (selectedMessageIdx >= 0) params.set('msg', String(selectedMessageIdx));
850
+ const qs = params.toString();
851
+ const newUrl = qs ? '?' + qs : location.pathname;
852
+ history.replaceState(null, '', newUrl);
853
+ }
854
+
855
+ function selectSession(id) {
856
+ setFocus('sessions');
857
+ if (id === selectedSessionId) return;
858
+ selectedSessionId = id;
859
+ selectedTurnIdx = -1;
860
+ selectedSection = null;
861
+ selectedMessageIdx = -1;
862
+
863
+ // Highlight selected session
864
+ colSessions.querySelectorAll('.session-item').forEach(el => {
865
+ el.classList.toggle('selected', el.dataset.sessionId === id);
866
+ });
867
+ // Show turns for this session
868
+ colTurns.querySelectorAll('.turn-item').forEach(el => {
869
+ el.style.display = (id && el.dataset.sessionId === id) ? '' : 'none';
870
+ });
871
+ // Clear downstream — Miller column rule: N+2 onwards must clear
872
+ colSections.innerHTML = '';
873
+ colDetail.innerHTML = '';
874
+
875
+ renderSessionToolBar(id);
876
+ renderSessionSparkline(id);
877
+ renderBreadcrumb();
878
+ }
879
+
880
+ // Coalesced render scheduler — merges multiple async render requests into one rAF
881
+ let _renderDirty = false;
882
+ let _renderCallback = null;
883
+ function scheduleRender(afterRender) {
884
+ if (afterRender) _renderCallback = afterRender;
885
+ if (_renderDirty) return;
886
+ _renderDirty = true;
887
+ requestAnimationFrame(() => {
888
+ _renderDirty = false;
889
+ if (selectedTurnIdx >= 0) {
890
+ renderSectionsCol(selectedTurnIdx);
891
+ renderDetailCol();
892
+ }
893
+ if (_renderCallback) { _renderCallback(); _renderCallback = null; }
894
+ });
895
+ }
896
+
897
+ function prefetchEntry(idx) {
898
+ const e = allEntries[idx];
899
+ if (!e || e.reqLoaded || e._prefetching) return;
900
+ e._prefetching = true;
901
+ fetch('/_api/entry/' + encodeURIComponent(e.id))
902
+ .then(r => r.json())
903
+ .then(data => {
904
+ if (!data) return;
905
+ allEntries[idx].req = data.req;
906
+ allEntries[idx].res = data.res;
907
+ allEntries[idx].reqLoaded = true;
908
+ if (data.receivedAt) allEntries[idx].receivedAt = data.receivedAt;
909
+ if (selectedTurnIdx === idx) {
910
+ scheduleRender(() => {
911
+ // Apply deferred deep link sec/msg after lazy-load completes
912
+ if (typeof _deferredDeepLink !== 'undefined' && _deferredDeepLink) {
913
+ _applyDeferredDeepLink();
914
+ }
915
+ });
916
+ }
917
+ }).catch(() => { allEntries[idx]._prefetching = false; });
918
+ }
919
+
920
+ function selectTurn(idx) {
921
+ if (idx < 0 || idx >= allEntries.length) return;
922
+ if (typeof hideNewTurnPill === 'function') hideNewTurnPill();
923
+ // Exit focused mode when switching turns — user is browsing, not drilling into timeline
924
+ if (isFocusedMode) {
925
+ isFocusedMode = false;
926
+ document.getElementById('columns').classList.remove('focused');
927
+ }
928
+ selectedTurnIdx = idx;
929
+ selectedMessageIdx = -1;
930
+ colTurns.querySelectorAll('.turn-item').forEach(el => {
931
+ el.classList.toggle('selected', parseInt(el.dataset.entryIdx) === idx);
932
+ });
933
+ const selEl = colTurns.querySelector('.turn-item[data-entry-idx="' + idx + '"]');
934
+ if (selEl) selEl.scrollIntoView({ block: 'nearest' });
935
+ // Auto-highlight the session this turn belongs to (read-only indicator, not a gate)
936
+ const sid = allEntries[idx]?.sessionId;
937
+ colSessions.querySelectorAll('.session-item').forEach(el => {
938
+ el.classList.toggle('selected', el.dataset.sessionId === sid);
939
+ });
940
+ prefetchEntry(idx);
941
+ if (!selectedSection) selectedSection = 'timeline';
942
+ renderSectionsCol(idx);
943
+ renderDetailCol();
944
+ // Fetch tokens if needed
945
+ const e = allEntries[idx];
946
+ if (e && (!e.tokens || !e.tokens.total)) {
947
+ fetch('/_api/tokens/' + encodeURIComponent(e.id))
948
+ .then(r => r.json())
949
+ .then(tok => {
950
+ if (!tok) return;
951
+ allEntries[idx].tokens = tok;
952
+ if (selectedTurnIdx === idx) { scheduleRender(); }
953
+ }).catch(() => {});
954
+ }
955
+ renderBreadcrumb();
956
+ }
957
+
958
+ function selectSection(name) {
959
+ setFocus('sections');
960
+ selectedSection = name;
961
+ selectedMessageIdx = -1;
962
+ colSections.querySelectorAll('.section-item').forEach(el => {
963
+ el.classList.toggle('selected', el.dataset.section === name);
964
+ });
965
+ renderDetailCol();
966
+ renderBreadcrumb();
967
+ }
968
+
969
+ function renderSectionsCol(idx) {
970
+ const e = allEntries[idx];
971
+ if (!e) { colSections.innerHTML = '<div class="col-empty">No data</div>'; return; }
972
+ const tok = e.tokens || {};
973
+ const req = e.req || {};
974
+ const usage = e.usage || {};
975
+ const inTok = usage.input_tokens || '?';
976
+ const outTok = usage.output_tokens || '?';
977
+ const statusClass = e.status >= 200 && e.status < 300 ? 'status-ok' : 'status-err';
978
+ const resEvents = Array.isArray(e.res) ? e.res : [];
979
+ const stopReason = e.stopReason || (Array.isArray(resEvents) ? (resEvents.find(ev => ev.type === 'message_delta')?.delta?.stop_reason || '') : '');
980
+ const turnCost = e.cost;
981
+ const shortModel = (e.model || '?').replace('claude-', '').replace(/-[0-9]{8}$/, '');
982
+ const isSubagent = e.isSubagent || false;
983
+ const displayNum = e.displayNum || String(idx + 1);
984
+ const subBadge = isSubagent
985
+ ? ' <span style="font-size:10px;background:var(--orange);color:#000;border-radius:3px;padding:0 4px;margin-left:4px">sub</span>'
986
+ : '';
987
+
988
+ let html = '<div class="col-header">';
989
+ html += '<div class="ch-line1"><span style="color:var(--dim)">' + (isSubagent ? '' : '#') + escapeHtml(displayNum) + '</span> <span style="color:var(--purple)">' + escapeHtml(shortModel) + '</span>' + subBadge + '</div>';
990
+ html += '<div class="ch-line2"><span class="' + statusClass + '">' + e.status + '</span> · 🤖 ' + (e.elapsed || '?') + 's';
991
+ if (stopReason) html += ' · ' + escapeHtml(stopReason);
992
+ if (e.thinkingDuration) html += ' · <span style="color:var(--purple)">🧠 ' + e.thinkingDuration.toFixed(1) + 's</span>';
993
+ if (turnCost != null) html += ' · <span style="color:var(--yellow)">$' + turnCost.toFixed(4) + '</span>';
994
+ html += '</div>';
995
+ const cacheRead = usage.cache_read_input_tokens || 0;
996
+ const cacheCreate = usage.cache_creation_input_tokens || 0;
997
+ html += '<div class="ch-line2" style="margin-top:2px">' + fmt(inTok) + ' in / ' + fmt(outTok) + ' out';
998
+ if (cacheRead || cacheCreate) {
999
+ html += ' <span style="color:var(--dim);font-size:10px">(';
1000
+ const parts = [];
1001
+ if (cacheRead) parts.push('cache ' + fmt(cacheRead));
1002
+ if (cacheCreate) parts.push('new ' + fmt(cacheCreate));
1003
+ html += parts.join(' · ') + ')</span>';
1004
+ }
1005
+ html += '</div>';
1006
+ html += '</div>';
1007
+
1008
+ if (tok?.contextBreakdown) {
1009
+ html += renderContextBreakdownBar(tok, e.maxContext, e.usage);
1010
+ } else if (!e.reqLoaded) {
1011
+ html += '<div style="padding:4px 12px 6px;border-bottom:1px solid var(--border)"><div style="height:8px;border-radius:2px;background:var(--border);margin:4px 0 2px"></div><div style="height:12px;width:80px;border-radius:2px;background:var(--border)"></div></div>';
1012
+ }
1013
+
1014
+ const coreTools = req.tools ? req.tools.filter(t => !t.name.startsWith('mcp__')) : null;
1015
+ const mcpTools = req.tools ? req.tools.filter(t => t.name.startsWith('mcp__')) : null;
1016
+ const tc = allEntries[idx]?.toolCalls || {};
1017
+ const coreCalls = Object.entries(tc).filter(([n]) => !n.startsWith('mcp__')).reduce((s, [, c]) => s + c, 0);
1018
+ const mcpCalls = Object.entries(tc).filter(([n]) => n.startsWith('mcp__')).reduce((s, [, c]) => s + c, 0);
1019
+
1020
+ // Extract skill usage from messages (Skill tool input.skill parameter)
1021
+ const skillCalls = {};
1022
+ if (req.messages) {
1023
+ for (const msg of req.messages) {
1024
+ if (!Array.isArray(msg.content)) continue;
1025
+ for (const b of msg.content) {
1026
+ if (b.type === 'tool_use' && b.name === 'Skill' && b.input?.skill) {
1027
+ skillCalls[b.input.skill] = (skillCalls[b.input.skill] || 0) + 1;
1028
+ }
1029
+ }
1030
+ }
1031
+ }
1032
+ const skillCount = Object.keys(skillCalls).length;
1033
+ const skillTotal = Object.values(skillCalls).reduce((s, n) => s + n, 0);
1034
+ // Extract cc_version for system badge
1035
+ const ccVer = req.system && Array.isArray(req.system) && req.system[0]
1036
+ ? (req.system[0].text || '').match(/cc_version=(S+?)[; ]/)?.[1] : null;
1037
+ const sysVerBadge = ccVer
1038
+ ? `<div class="sysprompt-badge" onclick="event.stopPropagation();openSystemPromptPanel()">⚡ cc ${escapeHtml(ccVer)}</div>`
1039
+ : '';
1040
+
1041
+ // Compute step stats for Timeline badge
1042
+ const previewSteps = e.reqLoaded ? getCachedSteps(req.messages, resEvents) : [];
1043
+ const stepCount = previewSteps.length;
1044
+ const stepErrorCount = previewSteps.filter(s => s.type === 'tool-group' && s.calls.some(c => c.isError)).length;
1045
+
1046
+ function renderSectionItem(s) {
1047
+ const sel = selectedSection === s.name ? ' selected' : '';
1048
+ let h = '<div class="section-item' + sel + '" data-section="' + s.name + '" onclick="selectSection(&quot;' + s.name + '&quot;)">';
1049
+ const dot = s.color ? '<span style="display:inline-block;width:7px;height:7px;border-radius:50%;background:' + s.color + ';margin-right:5px;flex-shrink:0"></span>' : '<span style="display:inline-block;width:7px;margin-right:5px"></span>';
1050
+ h += '<span class="si-name">' + dot + s.label + '</span>';
1051
+ if (s.badge) h += '<span class="si-badge">' + escapeHtml(s.badge) + '</span>';
1052
+ if (s.extra) h += s.extra;
1053
+ h += '<span class="si-arrow">›</span></div>';
1054
+ return h;
1055
+ }
1056
+
1057
+ // Timeline — independent top-level, not in any group
1058
+ const timelineBadge = stepCount ? stepCount + ' steps' + (stepErrorCount ? ' · ' + stepErrorCount + '✗' : '') : (e.reqLoaded ? '' : '…');
1059
+ html += renderSectionItem({ name: 'timeline', label: 'Timeline', color: 'var(--color-messages)', badge: timelineBadge, extra: '' });
1060
+
1061
+ // CONTEXT group (replaces REQUEST)
1062
+ html += '<div class="section-group-title">CONTEXT</div>';
1063
+ const contextSections = [
1064
+ { name: 'system', label: 'System', color: 'var(--color-system)', badge: tok.system ? fmt(tok.system) + ' tok' : (req.system ? '' : (e.reqLoaded ? '' : '…')), extra: sysVerBadge },
1065
+ { name: 'core-tools', label: 'Core', color: 'var(--color-tools)', badge: coreTools ? coreTools.length + ' tools' + (coreCalls ? ' · ' + coreCalls + '×' : '') : (e.reqLoaded ? '' : '…'), extra: '' },
1066
+ { name: 'mcp-tools', label: 'MCP', color: 'var(--color-mcp-tools)', badge: mcpTools ? mcpTools.length + ' tools' + (mcpCalls ? ' · ' + mcpCalls + '×' : '') : (e.reqLoaded ? '' : '…'), extra: '' },
1067
+ ];
1068
+ for (const s of contextSections) { html += renderSectionItem(s); }
1069
+ // Skills section — shown when Skill tool is available or skills were invoked
1070
+ const sb = tok.contextBreakdown?.systemBreakdown;
1071
+ const loadedSkills = tok.contextBreakdown?.loadedSkills || [];
1072
+ const hasSkillTool = e.reqLoaded ? !!(req.tools?.some(t => t.name === 'Skill')) : false;
1073
+ const hasSkillsInContext = hasSkillTool
1074
+ || (sb?.pluginSkills > 0 || sb?.customSkills > 0)
1075
+ || loadedSkills.length > 0
1076
+ || tc['Skill'] > 0;
1077
+ if (hasSkillsInContext) {
1078
+ const skillsLoaded = (sb?.pluginSkills > 0 || sb?.customSkills > 0)
1079
+ ? ((sb.pluginSkills > 0 ? 1 : 0) + (sb.customSkills > 0 ? 1 : 0))
1080
+ : loadedSkills.length;
1081
+ let skillBadge;
1082
+ if (!e.reqLoaded && tc['Skill'] > 0) {
1083
+ skillBadge = '…';
1084
+ } else if (skillsLoaded > 0) {
1085
+ skillBadge = skillsLoaded + ' skills' + (skillTotal > 0 ? ' · ' + skillTotal + '×' : '');
1086
+ } else if (skillTotal > 0) {
1087
+ skillBadge = skillTotal + '×';
1088
+ } else {
1089
+ skillBadge = '';
1090
+ }
1091
+ html += renderSectionItem({ name: 'skills', label: 'Skills', color: 'var(--purple)', badge: skillBadge, extra: '' });
1092
+ }
1093
+
1094
+ // ANALYSIS group
1095
+ html += '<div class="section-group-title">ANALYSIS</div>';
1096
+ html += renderSectionItem({ name: 'cost-efficiency', label: '💰 Cost Efficiency', color: null, badge: '', extra: '' });
1097
+
1098
+ // RAW group (simplified to 2 items)
1099
+ html += '<div class="section-group-title">RAW</div>';
1100
+ html += renderSectionItem({ name: 'raw-req', label: 'Request', color: null, badge: '', extra: '' });
1101
+ html += renderSectionItem({ name: 'raw-res', label: 'Events', color: null, badge: resEvents.length ? resEvents.length + ' events' : '', extra: '' });
1102
+ if (!e.reqLoaded) html += '<div style="padding:8px 12px;font-size:11px;color:var(--dim)">⏳ Loading…</div>';
1103
+ colSections.innerHTML = html;
1104
+ }
1105
+
1106
+ let cachedPricing = null;
1107
+ function fetchPricingData() {
1108
+ if (cachedPricing) return Promise.resolve(cachedPricing);
1109
+ return fetch('/_api/pricing').then(r => r.json()).then(d => { cachedPricing = d; return d; });
1110
+ }
1111
+
1112
+ function renderCostEfficiencyPanel(currentEntry) {
1113
+ const sid = currentEntry.sessionId;
1114
+ const sessionTurns = allEntries.filter(e => e.sessionId === sid && !e.isSubagent && e.usage);
1115
+
1116
+ // --- Cache efficiency ---
1117
+ let totalCacheRead = 0, totalCacheCreate = 0;
1118
+ for (const e of sessionTurns) {
1119
+ totalCacheRead += e.usage.cache_read_input_tokens || 0;
1120
+ totalCacheCreate += e.usage.cache_creation_input_tokens || 0;
1121
+ }
1122
+ const totalCache = totalCacheRead + totalCacheCreate;
1123
+ const hitRate = totalCache > 0 ? (totalCacheRead / totalCache * 100) : 0;
1124
+ const hitColor = hitRate >= 80 ? 'var(--green)' : hitRate >= 50 ? 'var(--yellow)' : 'var(--red)';
1125
+
1126
+ let html = '<div class="detail-content" style="padding:12px">';
1127
+ html += '<div style="font-size:13px;font-weight:bold;margin-bottom:12px">Cost Efficiency</div>';
1128
+
1129
+ // Cache hit rate bar
1130
+ html += '<div style="margin-bottom:12px">';
1131
+ html += '<div style="font-size:11px;color:var(--dim);margin-bottom:4px">Cache Hit Rate</div>';
1132
+ if (totalCache > 0) {
1133
+ html += '<div style="display:flex;height:10px;border-radius:3px;overflow:hidden;background:var(--border);margin-bottom:4px">';
1134
+ html += '<div style="width:' + hitRate.toFixed(1) + '%;background:' + hitColor + '"></div>';
1135
+ html += '</div>';
1136
+ html += '<div style="font-size:11px;color:' + hitColor + '">' + hitRate.toFixed(1) + '% · ↩ ' + fmt(totalCacheRead) + ' read · ↗ ' + fmt(totalCacheCreate) + ' write</div>';
1137
+ } else {
1138
+ html += '<div style="font-size:11px;color:var(--dim)">No cache data for this session</div>';
1139
+ }
1140
+ html += '</div>';
1141
+
1142
+ // Fetch pricing and compute savings async, show placeholder
1143
+ const savingsId = 'ce-savings-' + Date.now();
1144
+ html += '<div id="' + savingsId + '" style="font-size:11px;color:var(--dim);margin-bottom:16px">Calculating savings…</div>';
1145
+
1146
+ // --- System section token ranking ---
1147
+ const tok = currentEntry.tokens || {};
1148
+ const cb = tok.contextBreakdown;
1149
+ if (cb) {
1150
+ html += '<div style="font-size:11px;color:var(--dim);margin-bottom:4px">Fixed Cost per Turn (System + Tools)</div>';
1151
+ const sb = cb.systemBreakdown || {};
1152
+ const cm = cb.claudeMd || {};
1153
+ const systemSections = [
1154
+ { label: 'Core instructions', tokens: sb.coreInstructions || 0 },
1155
+ { label: 'Plugin skills', tokens: sb.pluginSkills || 0 },
1156
+ { label: 'Custom skills', tokens: sb.customSkills || 0 },
1157
+ { label: 'Custom agents', tokens: sb.customAgents || 0 },
1158
+ { label: 'MCP instructions', tokens: (sb.mcpServersList || 0) },
1159
+ { label: 'Settings/Env/Git', tokens: (sb.settingsJson || 0) + (sb.envAndGit || 0) },
1160
+ { label: 'Global CLAUDE.md', tokens: cm.globalClaudeMd || 0 },
1161
+ { label: 'Project CLAUDE.md', tokens: cm.projectClaudeMd || 0 },
1162
+ { label: 'Auto memory', tokens: sb.autoMemory || 0 },
1163
+ { label: 'Core identity', tokens: (sb.coreIdentity || 0) + (sb.billingHeader || 0) },
1164
+ ].filter(s => s.tokens > 0).sort((a, b) => b.tokens - a.tokens);
1165
+
1166
+ // --- MCP plugin token ranking ---
1167
+ const mcpPlugins = cb.toolsBreakdown?.mcpPlugins || [];
1168
+ const ttok = cb.toolsBreakdown?.toolTokens || {};
1169
+ const coreToolTokens = (ttok.core || 0) + (ttok.agent || 0) + (ttok.task || 0) + (ttok.team || 0) + (ttok.cron || 0) + (ttok.other || 0);
1170
+
1171
+ const toolSections = [
1172
+ { label: 'Core tools', tokens: coreToolTokens, count: cb.toolsBreakdown?.counts ? Object.entries(cb.toolsBreakdown.counts).filter(([k]) => k !== 'mcp').reduce((s, [, v]) => s + v, 0) : 0, color: 'var(--color-tools)' },
1173
+ ...mcpPlugins.map(p => ({ label: 'MCP: ' + p.plugin, tokens: p.tokens, count: p.count, color: 'var(--color-mcp-tools)', isMcp: true, plugin: p.plugin }))
1174
+ ].filter(s => s.tokens > 0).sort((a, b) => b.tokens - a.tokens);
1175
+
1176
+ // Combine system + tools into one aligned chart
1177
+ const allBars = [
1178
+ ...systemSections.map(s => ({ label: s.label, tokens: s.tokens, color: 'var(--color-system)' })),
1179
+ null, // separator
1180
+ ...toolSections.map(s => ({ label: s.label + ' (' + s.count + ')', tokens: s.tokens, color: s.color })),
1181
+ ];
1182
+ const maxTok = Math.max(...allBars.filter(Boolean).map(s => s.tokens), 1);
1183
+ const maxLabelLen = Math.max(...allBars.filter(Boolean).map(s => s.label.length), 1);
1184
+ const labelW = Math.max(120, Math.min(220, maxLabelLen * 7));
1185
+
1186
+ html += '<div style="margin-bottom:12px">';
1187
+ for (const s of allBars) {
1188
+ if (!s) { html += '<div style="height:8px"></div>'; continue; }
1189
+ const barW = (s.tokens / maxTok * 100).toFixed(1);
1190
+ html += '<div style="display:flex;align-items:center;gap:6px;margin-bottom:3px;font-size:10px">';
1191
+ html += '<span style="width:' + labelW + 'px;flex-shrink:0;color:var(--text)">' + escapeHtml(s.label) + '</span>';
1192
+ html += '<div style="flex:1;height:6px;border-radius:2px;background:var(--border)"><div style="width:' + barW + '%;height:100%;border-radius:2px;background:' + s.color + '"></div></div>';
1193
+ html += '<span style="width:50px;text-align:right;flex-shrink:0;color:var(--dim)">' + s.tokens.toLocaleString() + '</span>';
1194
+ html += '</div>';
1195
+ }
1196
+ html += '</div>';
1197
+
1198
+ // --- Fixed tax summary ---
1199
+ const sysTok = tok.system || 0;
1200
+ const toolsTok = tok.tools || 0;
1201
+ const fixedTax = sysTok + toolsTok;
1202
+ const maxCtx = currentEntry.maxContext || 200000;
1203
+ const fixedPct = (fixedTax / maxCtx * 100).toFixed(1);
1204
+ html += '<div style="background:var(--border);border-radius:4px;padding:8px;font-size:11px;margin-bottom:12px">';
1205
+ html += 'Fixed tax per turn: <strong>' + fmt(fixedTax) + ' tokens</strong> = ' + fixedPct + '% of ' + fmt(maxCtx) + ' context window';
1206
+ html += '</div>';
1207
+
1208
+ // --- Unused MCP plugins warning ---
1209
+ if (mcpPlugins.length) {
1210
+ const sessionToolCalls = {};
1211
+ for (const e of sessionTurns) {
1212
+ for (const [name, count] of Object.entries(e.toolCalls || {})) {
1213
+ sessionToolCalls[name] = (sessionToolCalls[name] || 0) + count;
1214
+ }
1215
+ }
1216
+ const unusedPlugins = mcpPlugins.filter(p => {
1217
+ // Check if any tool from this plugin was used
1218
+ return !Object.keys(sessionToolCalls).some(name => name.startsWith('mcp__' + p.plugin + '__'));
1219
+ });
1220
+ for (const p of unusedPlugins) {
1221
+ html += '<div style="padding:2px 0;font-size:11px;color:var(--yellow)">';
1222
+ html += '⚠ ' + escapeHtml(p.plugin) + ' has ' + p.count + ' tools but 0 uses this session (' + p.tokens.toLocaleString() + ' tok)';
1223
+ html += '</div>';
1224
+ }
1225
+ }
1226
+ } else {
1227
+ html += '<div style="font-size:11px;color:var(--dim)">Load request data to see full analysis</div>';
1228
+ }
1229
+
1230
+ html += '</div>';
1231
+
1232
+ // Async: compute savings from pricing
1233
+ fetchPricingData().then(pricing => {
1234
+ const el = document.getElementById(savingsId);
1235
+ if (!el) return;
1236
+ const model = currentEntry.model;
1237
+ const rates = pricing[model];
1238
+ if (!rates || totalCache === 0) {
1239
+ el.textContent = totalCache === 0 ? '' : 'Unable to fetch pricing data';
1240
+ return;
1241
+ }
1242
+ const normalCost = totalCacheRead / 1_000_000 * rates.input_cost_per_mtok;
1243
+ const cacheCost = totalCacheRead / 1_000_000 * rates.cache_read_cost_per_mtok;
1244
+ const saved = normalCost - cacheCost;
1245
+ el.innerHTML = 'Cache savings this session: <strong style="color:var(--green)">$' + saved.toFixed(3) + '</strong>';
1246
+ }).catch(() => {
1247
+ const el = document.getElementById(savingsId);
1248
+ if (el) el.textContent = '';
1249
+ });
1250
+
1251
+ return html;
1252
+ }
1253
+
1254
+ function renderDetailCol() {
1255
+ const renderToken = ++renderDetailRenderToken;
1256
+ colDetail.style.opacity = '0';
1257
+ const e = selectedTurnIdx >= 0 ? allEntries[selectedTurnIdx] : null;
1258
+ const tok = e?.tokens || {};
1259
+ const commitDetailHtml = function(html, afterRender) {
1260
+ requestAnimationFrame(() => {
1261
+ setTimeout(() => {
1262
+ if (renderToken !== renderDetailRenderToken) return;
1263
+ colDetail.innerHTML = html;
1264
+ requestAnimationFrame(() => {
1265
+ if (renderToken !== renderDetailRenderToken) return;
1266
+ colDetail.style.opacity = '1';
1267
+ if (afterRender) afterRender();
1268
+ });
1269
+ }, DETAIL_RENDER_FADE_MS);
1270
+ });
1271
+ };
1272
+
1273
+ if (!selectedSection) {
1274
+ commitDetailHtml('<div class="detail-scroll"><div class="col-empty"><div class="col-empty-hint">← Select a section</div></div></div>'); return;
1275
+ }
1276
+ if (!e) { commitDetailHtml('<div class="detail-scroll"><div class="col-empty">No data</div></div>'); return; }
1277
+
1278
+ const req = e.req || {};
1279
+ const resEvents = Array.isArray(e.res) ? e.res : [];
1280
+ const loading = '<div class="col-empty">⏳ Loading…</div>';
1281
+ let inner = '';
1282
+
1283
+ // Detail header — timeline always uses focused-style header
1284
+ const sectionLabel = selectedSection.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
1285
+ let headerHtml;
1286
+ if (selectedSection === 'timeline' || isFocusedMode) {
1287
+ headerHtml = '<div class="fp-header">'
1288
+ + '<button class="fp-back" onclick="exitFocusedMode()">←</button>'
1289
+ + '<span class="fp-title">' + escapeHtml(sectionLabel) + '</span>'
1290
+ + '</div>';
1291
+ } else {
1292
+ headerHtml = '<div class="col-header" style="display:flex;align-items:center;justify-content:space-between;padding:8px 12px;border-bottom:1px solid var(--border)">'
1293
+ + '<span style="font-size:11px;font-weight:600;color:var(--dim);text-transform:uppercase;letter-spacing:0.08em">' + escapeHtml(sectionLabel) + '</span>'
1294
+ + '<span class="expand-btn" onclick="enterFocusedMode()" title="Expand (Enter)">⛶</span>'
1295
+ + '</div>';
1296
+ }
1297
+
1298
+ switch (selectedSection) {
1299
+ case 'system':
1300
+ if (req.system) {
1301
+ inner = '<div class="detail-content">' + renderSystemBlockViewer(req.system) + '</div>';
1302
+ } else { inner = e.reqLoaded ? '<div class="col-empty">No system prompt</div>' : loading; }
1303
+ break;
1304
+ case 'timeline': {
1305
+ if (!isFocusedMode) {
1306
+ // Non-focused: show step summary list with minimap (no detail pane)
1307
+ // User clicks a step or presses Enter to enter split-pane
1308
+ prepareTimelineSteps(req.messages, resEvents);
1309
+ if (!currentSteps.length) {
1310
+ inner = e.reqLoaded ? '<div class="col-empty">No messages</div>' : loading;
1311
+ break;
1312
+ }
1313
+ const toolFreqPreview = {};
1314
+ currentSteps.forEach(s => { if (s.type === 'tool-group') s.calls.forEach(c => { toolFreqPreview[c.name] = (toolFreqPreview[c.name] || 0) + 1; }); });
1315
+ const totalPreview = currentSteps.filter(s => s.type === 'tool-group').length;
1316
+ const errorPreview = currentSteps.filter(s => s.type === 'tool-group' && s.calls.some(c => c.isError)).length;
1317
+ const summaryPreview = '<div style="padding:4px 8px 6px;border-bottom:1px solid var(--border);font-size:11px;color:var(--dim)">'
1318
+ + totalPreview + ' steps · ' + (totalPreview - errorPreview) + '✓'
1319
+ + (errorPreview ? ' <span style="color:var(--red)">' + errorPreview + '✗</span>' : '')
1320
+ + '</div>';
1321
+ const previewStepsHtml = renderStepListHtml(currentSteps, getActiveStepKey());
1322
+ const previewMinimapHtml = (typeof renderMinimapHtml === 'function')
1323
+ ? renderMinimapHtml(currentSteps, tok?.perMessage || null, -1, e.maxContext, e.usage)
1324
+ : '';
1325
+ inner = summaryPreview
1326
+ + '<div class="tl-with-minimap" style="flex:1;overflow:hidden">'
1327
+ + '<div class="minimap">' + previewMinimapHtml + '</div>'
1328
+ + '<div class="tl-scroll-area">' + previewStepsHtml + '</div>'
1329
+ + '</div>';
1330
+ break;
1331
+ }
1332
+
1333
+ // Prepare steps if needed
1334
+ prepareTimelineSteps(req.messages, resEvents);
1335
+ if (!currentSteps.length) {
1336
+ inner = e.reqLoaded ? '<div class="col-empty">No messages</div>' : loading;
1337
+ break;
1338
+ }
1339
+
1340
+ // Tool frequency summary
1341
+ const toolFreq = {};
1342
+ currentSteps.forEach(s => { if (s.type === 'tool-group') s.calls.forEach(c => { toolFreq[c.name] = (toolFreq[c.name] || 0) + 1; }); });
1343
+ const totalSteps = currentSteps.filter(s => s.type === 'tool-group').length;
1344
+ const errorCount = currentSteps.filter(s => s.type === 'tool-group' && s.calls.some(c => c.isError)).length;
1345
+ const summaryHtml = '<div style="padding:4px 8px 6px;border-bottom:1px solid var(--border);font-size:11px;color:var(--dim)">'
1346
+ + totalSteps + ' steps · ' + (totalSteps - errorCount) + '✓'
1347
+ + (errorCount ? ' <span style="color:var(--red)">' + errorCount + '✗</span>' : '')
1348
+ + '</div>';
1349
+
1350
+ const activeKey = getActiveStepKey();
1351
+ const stepsHtml = renderStepListHtml(currentSteps, activeKey);
1352
+
1353
+ const minimapHtml = (typeof renderMinimapHtml === 'function')
1354
+ ? renderMinimapHtml(currentSteps, tok?.perMessage || null, -1, e.maxContext, e.usage)
1355
+ : '';
1356
+
1357
+ // Split pane: left minimap + list + right detail
1358
+ const detailHtml = selectedMessageIdx >= 0
1359
+ ? renderStepDetailHtml(req, tok)
1360
+ : '<div class="col-empty" style="padding:20px">← Select a step</div>';
1361
+ const focusedHtml = headerHtml + summaryHtml
1362
+ + '<div class="tl-split">'
1363
+ + '<div class="tl-with-minimap" style="width:280px;min-width:200px;max-width:400px;flex-shrink:0;border-right:1px solid var(--border)">'
1364
+ + '<div class="minimap">' + minimapHtml + '</div>'
1365
+ + '<div class="tl-scroll-area">' + stepsHtml + '</div>'
1366
+ + '</div>'
1367
+ + '<div class="tl-split-detail">' + detailHtml + '</div>'
1368
+ + '</div>';
1369
+ commitDetailHtml(focusedHtml, function() {
1370
+ requestAnimationFrame(() => {
1371
+ const mm = colDetail.querySelector('.minimap');
1372
+ const sa = colDetail.querySelector('.tl-scroll-area');
1373
+ if (mm && sa) { layoutMinimapBlocks(mm); initMinimapInteractions(mm, sa); }
1374
+ });
1375
+ if (currentSteps.length && selectedMessageIdx < 0) {
1376
+ selectStep(currentSteps.length - 1);
1377
+ }
1378
+ });
1379
+ return; // early return — we set innerHTML directly
1380
+ break;
1381
+ }
1382
+ case 'core-tools':
1383
+ case 'mcp-tools': {
1384
+ const isMcp = selectedSection === 'mcp-tools';
1385
+ const filtered = req.tools ? req.tools.filter(t => isMcp ? t.name.startsWith('mcp__') : !t.name.startsWith('mcp__')) : null;
1386
+ if (filtered?.length) {
1387
+ const usageCount = allEntries[selectedTurnIdx]?.toolCalls || {};
1388
+ const sorted = [...filtered].sort((a, b) => (usageCount[b.name] || 0) - (usageCount[a.name] || 0));
1389
+ const tags = sorted.map(t => {
1390
+ const cnt = usageCount[t.name] || 0;
1391
+ const badge = cnt > 0 ? ' <span style="font-size:9px;background:var(--accent);color:#fff;border-radius:3px;padding:0 3px;margin-left:3px">' + cnt + 'x</span>' : '';
1392
+ return '<span class="tool-tag">' + escapeHtml(t.name) + badge + '</span>';
1393
+ }).join('');
1394
+ inner = '<div class="detail-content"><div class="tool-grid">' + tags + '</div>' +
1395
+ '<details style="margin-top:8px"><summary style="color:var(--dim);cursor:pointer;font-size:11px">Full definitions</summary><pre>' + escapeHtml(JSON.stringify(filtered, null, 2)) + '</pre></details></div>';
1396
+ } else { inner = e.reqLoaded ? '<div class="col-empty">No ' + (isMcp ? 'MCP' : 'core') + ' tools</div>' : loading; }
1397
+ break;
1398
+ }
1399
+ case 'skills': {
1400
+ if (!e.reqLoaded) { inner = loading; break; }
1401
+ // Invoked skills (from tool_use in messages)
1402
+ const sc = {};
1403
+ for (const msg of (req.messages || [])) {
1404
+ if (!Array.isArray(msg.content)) continue;
1405
+ for (const b of msg.content) {
1406
+ if (b.type === 'tool_use' && b.name === 'Skill' && b.input?.skill) {
1407
+ sc[b.input.skill] = (sc[b.input.skill] || 0) + 1;
1408
+ }
1409
+ }
1410
+ }
1411
+ const sortedInvoked = Object.entries(sc).sort((a, b) => b[1] - a[1]);
1412
+ // Loaded skills (from system-reminder in messages)
1413
+ const detailLoadedSkills = tok.contextBreakdown?.loadedSkills || [];
1414
+ let html2 = '';
1415
+ if (detailLoadedSkills.length) {
1416
+ const loadedTags = detailLoadedSkills.map(name => {
1417
+ const cnt = sc[name] || 0;
1418
+ return '<span class="tool-tag" style="border-color:var(--purple);opacity:' + (cnt > 0 ? '1' : '0.45') + '">'
1419
+ + escapeHtml(name)
1420
+ + (cnt > 1 ? ' <span style="font-size:9px;background:var(--purple);color:#fff;border-radius:3px;padding:0 3px;margin-left:3px">' + cnt + 'x</span>' : '')
1421
+ + '</span>';
1422
+ }).join('');
1423
+ html2 += '<div class="detail-content"><div class="tool-grid">' + loadedTags + '</div></div>';
1424
+ } else if (sortedInvoked.length) {
1425
+ const tags = sortedInvoked.map(([name, cnt]) =>
1426
+ '<span class="tool-tag" style="border-color:var(--purple)">' + escapeHtml(name) +
1427
+ (cnt > 1 ? ' <span style="font-size:9px;background:var(--purple);color:#fff;border-radius:3px;padding:0 3px;margin-left:3px">' + cnt + 'x</span>' : '') +
1428
+ '</span>'
1429
+ ).join('');
1430
+ html2 += '<div class="detail-content"><div class="tool-grid">' + tags + '</div></div>';
1431
+ } else {
1432
+ html2 += '<div class="col-empty">0 invocations</div>';
1433
+ }
1434
+ inner = html2;
1435
+ break;
1436
+ }
1437
+ case 'cost-efficiency':
1438
+ inner = renderCostEfficiencyPanel(e);
1439
+ break;
1440
+ case 'raw-req':
1441
+ inner = req && Object.keys(req).length
1442
+ ? '<div class="detail-content"><pre>' + escapeHtml(JSON.stringify(req, null, 2)) + '</pre></div>'
1443
+ : (e.reqLoaded ? '<div class="col-empty">No request data</div>' : loading);
1444
+ break;
1445
+ case 'raw-res':
1446
+ inner = resEvents.length
1447
+ ? '<div class="detail-content"><pre>' + escapeHtml(JSON.stringify(resEvents, null, 2)) + '</pre></div>'
1448
+ : (e.reqLoaded ? '<div class="col-empty">No response data</div>' : loading);
1449
+ break;
1450
+ default: inner = '<div class="col-empty">Unknown section</div>';
1451
+ }
1452
+
1453
+ const scrollStyle = selectedSection === 'timeline' ? ' style="display:flex;flex-direction:column"' : '';
1454
+ commitDetailHtml(headerHtml + '<div class="detail-scroll"' + scrollStyle + '>' + inner + '</div>', function() {
1455
+ if (selectedSection === 'timeline') {
1456
+ requestAnimationFrame(() => {
1457
+ const mm = colDetail.querySelector('.minimap');
1458
+ const sa = colDetail.querySelector('.tl-scroll-area');
1459
+ if (mm && sa) { layoutMinimapBlocks(mm); initMinimapInteractions(mm, sa); }
1460
+ });
1461
+ }
1462
+ });
1463
+ }
1464
+
1465
+ const DETAIL_RENDER_FADE_MS = 150;
1466
+ let renderDetailRenderToken = 0;
1467
+
1468
+ // ── Detail Panel: Tool-Specific Rendering ──
1469
+
1470
+ const COLLAPSE_THRESHOLD = 50;
1471
+ const HEAD_LINES = 30;
1472
+ const TAIL_LINES = 10;
1473
+
1474
+ function renderToolDetail(c) {
1475
+ const statusBadge = c.pending ? '⏳' : c.isError
1476
+ ? '<span style="color:var(--red)">✗ ERROR</span>'
1477
+ : '<span style="color:var(--green)">✓</span>';
1478
+
1479
+ let html = '<div class="detail-tool-header">';
1480
+ html += '<div class="detail-tool-title">' + escapeHtml(c.name) + ' ' + statusBadge + '</div>';
1481
+ html += '<button class="detail-copy-btn" onclick="copyDetailContent(this)" title="Copy output">Copy</button>';
1482
+ html += '</div>';
1483
+ html += renderToolMeta(c);
1484
+ html += renderToolInput(c);
1485
+ html += renderToolOutput(c);
1486
+ return html;
1487
+ }
1488
+
1489
+ function renderToolMeta(c) {
1490
+ const inp = c.input || {};
1491
+ switch (c.name) {
1492
+ case 'Bash': {
1493
+ const cmd = (inp.command || '');
1494
+ const desc = inp.description ? '<div class="detail-meta-line">' + escapeHtml(inp.description) + '</div>' : '';
1495
+ return '<div class="detail-meta"><code class="detail-cmd-block">$ ' + escapeHtml(cmd.split('\n')[0]) + (cmd.includes('\n') ? ' ...' : '') + '</code>' + desc + '</div>';
1496
+ }
1497
+ case 'Read':
1498
+ return '<div class="detail-meta"><code>' + escapeHtml(inp.file_path || '') + '</code>'
1499
+ + (inp.offset ? ' <span class="detail-tag">L' + inp.offset + (inp.limit ? '-' + (inp.offset + inp.limit) : '+') + '</span>' : '')
1500
+ + '</div>';
1501
+ case 'Write':
1502
+ return '<div class="detail-meta"><code>' + escapeHtml(inp.file_path || '') + '</code></div>';
1503
+ case 'Edit':
1504
+ return '<div class="detail-meta"><code>' + escapeHtml(inp.file_path || '') + '</code>'
1505
+ + (inp.replace_all ? ' <span class="detail-tag">replace_all</span>' : '') + '</div>';
1506
+ case 'Grep':
1507
+ return '<div class="detail-meta"><code>/' + escapeHtml(inp.pattern || '') + '/</code>'
1508
+ + (inp.glob ? ' <span class="detail-tag">' + escapeHtml(inp.glob) + '</span>' : '')
1509
+ + (inp.path ? ' in <code>' + escapeHtml(inp.path.split('/').slice(-2).join('/')) + '</code>' : '') + '</div>';
1510
+ case 'Glob':
1511
+ return '<div class="detail-meta"><code>' + escapeHtml(inp.pattern || '') + '</code>'
1512
+ + (inp.path ? ' in <code>' + escapeHtml(inp.path.split('/').slice(-2).join('/')) + '</code>' : '') + '</div>';
1513
+ case 'Agent':
1514
+ return '<div class="detail-meta">' + escapeHtml(inp.description || (inp.prompt || '').slice(0, 80))
1515
+ + (inp.subagent_type ? ' <span class="detail-tag">' + escapeHtml(inp.subagent_type) + '</span>' : '') + '</div>';
1516
+ case 'WebSearch':
1517
+ return '<div class="detail-meta"><code>' + escapeHtml(inp.query || '') + '</code></div>';
1518
+ case 'WebFetch':
1519
+ return '<div class="detail-meta"><code>' + escapeHtml((inp.url || '').replace(/^https?:\/\//, '').slice(0, 60)) + '</code></div>';
1520
+ case 'TaskCreate':
1521
+ return '<div class="detail-meta">' + escapeHtml(inp.subject || '') + '</div>';
1522
+ case 'TaskUpdate':
1523
+ return '<div class="detail-meta">Task #' + escapeHtml(inp.taskId || '') + (inp.status ? ' → <span class="detail-tag">' + escapeHtml(inp.status) + '</span>' : '') + '</div>';
1524
+ default:
1525
+ return '';
1526
+ }
1527
+ }
1528
+
1529
+ function renderToolInput(c) {
1530
+ const inp = c.input || {};
1531
+
1532
+ if (c.name === 'Edit' && inp.old_string != null) {
1533
+ return renderEditDiff(inp.old_string, inp.new_string || '');
1534
+ }
1535
+
1536
+ if (c.name === 'Bash') {
1537
+ const cmd = inp.command || '';
1538
+ const lines = cmd.split('\n');
1539
+ let html = '<div class="content-block"><div class="type">COMMAND</div>';
1540
+ if (lines.length > COLLAPSE_THRESHOLD) {
1541
+ html += renderCollapsedContent(lines);
1542
+ } else {
1543
+ html += '<pre class="detail-cmd-block">' + escapeHtml(cmd) + '</pre>';
1544
+ }
1545
+ if (inp.timeout && inp.timeout !== 120000) html += '<div class="detail-meta-line">timeout: ' + inp.timeout + 'ms</div>';
1546
+ html += '</div>';
1547
+ return html;
1548
+ }
1549
+
1550
+ // For tools where meta already shows the key info, collapse JSON by default
1551
+ const metaTools = ['Read', 'Write', 'Grep', 'Glob', 'WebSearch', 'WebFetch', 'Agent', 'TaskCreate', 'TaskUpdate'];
1552
+ const json = JSON.stringify(inp, null, 2);
1553
+ if (metaTools.includes(c.name)) {
1554
+ return '<details class="detail-input-details"><summary>INPUT JSON</summary><pre>' + escapeHtml(json) + '</pre></details>';
1555
+ }
1556
+
1557
+ return '<div class="content-block"><div class="type">INPUT</div><pre>' + escapeHtml(json) + '</pre></div>';
1558
+ }
1559
+
1560
+ function renderToolOutput(c) {
1561
+ if (c.result == null) {
1562
+ if (c.pending) {
1563
+ return '<div class="content-block"><div class="type" style="color:var(--dim)">OUTPUT</div><div style="color:var(--dim);padding:8px">⏳ Waiting...</div></div>';
1564
+ }
1565
+ return '';
1566
+ }
1567
+
1568
+ // Handle array results with image blocks
1569
+ if (Array.isArray(c.result)) {
1570
+ const hasImage = c.result.some(b => b.type === 'image');
1571
+ if (hasImage) {
1572
+ let html = '';
1573
+ for (const block of c.result) {
1574
+ if (block.type === 'image' && block.source?.data) {
1575
+ const mediaType = block.source.media_type || 'image/png';
1576
+ html += '<div class="content-block"><div class="type">IMAGE</div>'
1577
+ + '<img src="data:' + escapeHtml(mediaType) + ';base64,' + block.source.data + '" '
1578
+ + 'style="max-width:100%;border-radius:4px;margin-top:4px;background:var(--bg);cursor:pointer" '
1579
+ + 'onclick="showImageOverlay(this.src)" title="Click to enlarge">'
1580
+ + '</div>';
1581
+ } else if (block.type === 'text' && block.text) {
1582
+ html += '<div class="content-block"><div class="type">TEXT</div><pre>' + escapeHtml(block.text) + '</pre></div>';
1583
+ }
1584
+ }
1585
+ return html;
1586
+ }
1587
+ }
1588
+
1589
+ const resultStr = typeof c.result === 'string' ? c.result : JSON.stringify(c.result, null, 2);
1590
+ const lines = resultStr.split('\n');
1591
+ const errStyle = c.isError ? ' style="color:var(--red)"' : '';
1592
+ const label = c.isError ? 'OUTPUT (error)' : 'OUTPUT';
1593
+
1594
+ let html = '<div class="content-block"><div class="type"' + errStyle + '>' + label
1595
+ + ' <span style="color:var(--dim);font-weight:normal">' + lines.length + ' lines</span></div>';
1596
+
1597
+ if (c.isError) {
1598
+ html += renderErrorOutput(resultStr);
1599
+ } else if (lines.length > COLLAPSE_THRESHOLD) {
1600
+ html += renderCollapsedContent(lines);
1601
+ } else {
1602
+ html += '<pre>' + escapeHtml(resultStr) + '</pre>';
1603
+ }
1604
+
1605
+ html += '</div>';
1606
+ return html;
1607
+ }
1608
+
1609
+ function renderCollapsedContent(lines) {
1610
+ const head = lines.slice(0, HEAD_LINES).join('\n');
1611
+ const tail = lines.slice(-TAIL_LINES).join('\n');
1612
+ const hiddenCount = lines.length - HEAD_LINES - TAIL_LINES;
1613
+ const id = 'collapse-' + Math.random().toString(36).slice(2, 8);
1614
+
1615
+ return '<pre>' + escapeHtml(head) + '</pre>'
1616
+ + '<div class="detail-collapse-bar" onclick="document.getElementById(\'' + id + '\').style.display=\'block\';this.style.display=\'none\'">⋯ '
1617
+ + hiddenCount + ' lines hidden — click to expand</div>'
1618
+ + '<div id="' + id + '" style="display:none"><pre>' + escapeHtml(lines.slice(HEAD_LINES, -TAIL_LINES).join('\n')) + '</pre></div>'
1619
+ + '<pre>' + escapeHtml(tail) + '</pre>';
1620
+ }
1621
+
1622
+ function renderEditDiff(oldStr, newStr) {
1623
+ let html = '<div class="detail-diff">';
1624
+ html += '<div class="detail-diff-section detail-diff-old">';
1625
+ html += '<div class="detail-diff-label">OLD</div><pre>';
1626
+ for (const line of (oldStr || '').split('\n')) {
1627
+ html += '<span class="diff-line-del">- ' + escapeHtml(line) + '</span>\n';
1628
+ }
1629
+ html += '</pre></div>';
1630
+ html += '<div class="detail-diff-section detail-diff-new">';
1631
+ html += '<div class="detail-diff-label">NEW</div><pre>';
1632
+ for (const line of (newStr || '').split('\n')) {
1633
+ html += '<span class="diff-line-add">+ ' + escapeHtml(line) + '</span>\n';
1634
+ }
1635
+ html += '</pre></div></div>';
1636
+ return html;
1637
+ }
1638
+
1639
+ function renderErrorOutput(text) {
1640
+ const lines = text.split('\n');
1641
+ const stackStart = lines.findIndex(l => /^\s+at\s/.test(l));
1642
+ if (stackStart > 0) {
1643
+ const msg = lines.slice(0, stackStart).join('\n');
1644
+ const stack = lines.slice(stackStart).join('\n');
1645
+ return '<pre style="color:var(--red)">' + escapeHtml(msg) + '</pre>'
1646
+ + '<details><summary style="color:var(--dim);font-size:11px;cursor:pointer">Stack trace ('
1647
+ + (lines.length - stackStart) + ' lines)</summary>'
1648
+ + '<pre style="color:var(--red);opacity:0.7">' + escapeHtml(stack) + '</pre></details>';
1649
+ }
1650
+ return '<pre style="color:var(--red)">' + escapeHtml(text) + '</pre>';
1651
+ }
1652
+
1653
+ function renderThinkingDetail(thinking, durLabel) {
1654
+ const charCount = (thinking || '').length;
1655
+ const paras = (thinking || '').split(/\n{2,}/);
1656
+ const formatted = paras.map(p => '<p class="think-para">' + escapeHtml(p) + '</p>').join('');
1657
+ return '<div class="detail-tool-header">'
1658
+ + '<div class="detail-tool-title">🧠 Thinking' + durLabel + '</div>'
1659
+ + '<span style="color:var(--dim);font-size:11px">' + charCount.toLocaleString() + ' chars</span>'
1660
+ + '</div>'
1661
+ + '<div class="detail-thinking">' + formatted + '</div>';
1662
+ }
1663
+
1664
+ function showImageOverlay(src) {
1665
+ let overlay = document.getElementById('img-overlay');
1666
+ if (!overlay) {
1667
+ overlay = document.createElement('div');
1668
+ overlay.id = 'img-overlay';
1669
+ overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.85);z-index:300;display:flex;align-items:center;justify-content:center;cursor:zoom-out';
1670
+ overlay.addEventListener('click', () => overlay.style.display = 'none');
1671
+ document.body.appendChild(overlay);
1672
+ }
1673
+ overlay.innerHTML = '<img src="' + src + '" style="max-width:90vw;max-height:90vh;border-radius:6px;box-shadow:0 4px 30px rgba(0,0,0,0.5)">';
1674
+ overlay.style.display = 'flex';
1675
+ }
1676
+
1677
+ function copyDetailContent(btn) {
1678
+ const content = btn.closest('.detail-content');
1679
+ if (!content) return;
1680
+ const pres = content.querySelectorAll('pre');
1681
+ const text = Array.from(pres).map(p => p.textContent).join('\n\n');
1682
+ navigator.clipboard.writeText(text).then(() => {
1683
+ btn.textContent = '✓';
1684
+ setTimeout(() => { btn.textContent = 'Copy'; }, 1500);
1685
+ });
1686
+ }