@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,750 @@
1
+ // ── Messages column helpers ──
2
+ const INJECTED_TAG_RE = /^<(system-reminder|user-prompt-submit-hook|context|antml:function_calls)[^>]*>/;
3
+ function classifyUserMessage(msg) {
4
+ if (msg.role !== 'user') return null;
5
+ const blocks = Array.isArray(msg.content)
6
+ ? msg.content
7
+ : [{ type: 'text', text: String(msg.content || '') }];
8
+ const hasToolResult = blocks.some(b => b.type === 'tool_result');
9
+ const hasHuman = blocks.some(b => b.type === 'text' && b.text && !INJECTED_TAG_RE.test(b.text.trimStart()));
10
+ const hasSystem = blocks.some(b => b.type === 'text' && b.text && INJECTED_TAG_RE.test(b.text.trimStart()));
11
+ if (!hasToolResult && !hasHuman && !hasSystem) return null;
12
+ return { hasHuman, hasSystem, hasToolResult };
13
+ }
14
+
15
+ function getUserBadgeHtml(cls) {
16
+ let html = '';
17
+ if (cls.hasHuman) html += '<span class="msg-badge msg-badge-human">YOU</span> ';
18
+ if (cls.hasSystem) html += '<span class="msg-badge msg-badge-system">SYS</span> ';
19
+ if (cls.hasToolResult) html += '<span class="msg-badge msg-badge-tool_results">TOOL</span> ';
20
+ return html;
21
+ }
22
+
23
+ function getUserMessagePreview(msg, cls) {
24
+ if (cls.hasToolResult && !cls.hasHuman) {
25
+ const blocks = Array.isArray(msg.content) ? msg.content : [];
26
+ const count = blocks.filter(b => b.type === 'tool_result').length;
27
+ return count + ' result' + (count !== 1 ? 's' : '');
28
+ }
29
+ const blocks = Array.isArray(msg.content)
30
+ ? msg.content : [{ type: 'text', text: String(msg.content || '') }];
31
+ const text = blocks
32
+ .filter(b => b.type === 'text' && b.text && !INJECTED_TAG_RE.test(b.text.trimStart()))
33
+ .map(b => b.text).join(' ').trim();
34
+ return text.slice(0, 40) || getMessagePreview(msg);
35
+ }
36
+
37
+ function classifyAssistantMessage(msg) {
38
+ if (msg.role !== 'assistant') return null;
39
+ const blocks = Array.isArray(msg.content) ? msg.content : [];
40
+ const tools = blocks.filter(b => b.type === 'tool_use');
41
+ const thinkingBlock = blocks.find(b => b.type === 'thinking');
42
+ const hasThinking = !!thinkingBlock;
43
+ const hasCall = tools.length > 0;
44
+ if (!hasThinking && !hasCall) return null;
45
+ const thinkingText = thinkingBlock ? (thinkingBlock.thinking || '') : '';
46
+ return { hasThinking, hasCall, tools, thinkingText };
47
+ }
48
+
49
+ function getAssistantBadgeHtml(asmCls) {
50
+ let html = '';
51
+ if (asmCls.hasThinking) html += '<span class="msg-badge msg-badge-think">THINK</span> ';
52
+ if (asmCls.hasCall) html += '<span class="msg-badge msg-badge-call">CALL</span> ';
53
+ return html;
54
+ }
55
+
56
+ function getAssistantPreview(asmCls) {
57
+ if (!asmCls.hasCall) return '';
58
+ const first = asmCls.tools[0].name || '?';
59
+ return asmCls.tools.length > 1 ? first + ' +' + (asmCls.tools.length - 1) : first;
60
+ }
61
+
62
+ function getMessagePreview(m) {
63
+ if (typeof m.content === 'string') return m.content.slice(0, 40);
64
+ if (!Array.isArray(m.content) || !m.content.length) return '';
65
+ const first = m.content[0];
66
+ if (first.type === 'text') return first.text.slice(0, 40);
67
+ if (first.type === 'tool_use') return '[tool] ' + first.name;
68
+ if (first.type === 'tool_result') return '[result]';
69
+ if (first.type === 'image') return '[image]';
70
+ return first.type || '';
71
+ }
72
+
73
+ // ── Merged Steps: transform flat messages into logical steps ──
74
+ function getToolPreview(toolUse) {
75
+ const inp = toolUse.input || {};
76
+ switch (toolUse.name) {
77
+ case 'Bash': return (inp.command || '').split('\n')[0].slice(0, 60);
78
+ case 'Read': case 'Write': case 'Edit': case 'NotebookEdit':
79
+ return (inp.file_path || inp.notebook_path || '').split('/').pop() || '';
80
+ case 'Grep': return (inp.pattern || '').slice(0, 40);
81
+ case 'Glob': return (inp.pattern || '').slice(0, 40);
82
+ case 'Agent': return (inp.description || inp.prompt || '').slice(0, 50);
83
+ case 'Skill': return inp.skill || '';
84
+ case 'TaskCreate': return (inp.subject || '').slice(0, 40);
85
+ case 'TaskUpdate': return (inp.taskId || '') + (inp.status ? ' → ' + inp.status : '');
86
+ case 'TaskStop': return inp.task_id || '';
87
+ case 'TaskOutput': return inp.task_id || '';
88
+ case 'WebSearch': return (inp.query || '').slice(0, 50);
89
+ case 'WebFetch': return (inp.url || '').replace(/^https?:\/\//, '').slice(0, 50);
90
+ default: {
91
+ const firstKey = Object.keys(inp)[0];
92
+ return firstKey ? String(inp[firstKey]).slice(0, 40) : '';
93
+ }
94
+ }
95
+ }
96
+
97
+ function buildMergedSteps(messages, resEvents) {
98
+ if ((!messages || !messages.length) && (!resEvents || !resEvents.length)) return [];
99
+
100
+ // Phase 1a: Build tool_use_id → tool_result map
101
+ const resultMap = new Map();
102
+ if (messages) {
103
+ for (const msg of messages) {
104
+ if (msg.role !== 'user') continue;
105
+ const blocks = Array.isArray(msg.content) ? msg.content : [];
106
+ for (const b of blocks) {
107
+ if (b.type === 'tool_result' && b.tool_use_id) {
108
+ resultMap.set(b.tool_use_id, b);
109
+ }
110
+ }
111
+ }
112
+ }
113
+
114
+ // Phase 1b: Collect all tool_use_ids from assistant messages
115
+ // After context compression, some tool_results may have no matching tool_use
116
+ const knownToolUseIds = new Set();
117
+ if (messages) {
118
+ for (const msg of messages) {
119
+ if (msg.role !== 'assistant') continue;
120
+ const blocks = Array.isArray(msg.content) ? msg.content : [];
121
+ for (const b of blocks) {
122
+ if (b.type === 'tool_use' && b.id) knownToolUseIds.add(b.id);
123
+ }
124
+ }
125
+ }
126
+
127
+ // Phase 2: Build history steps from messages
128
+ const steps = [];
129
+ if (messages) {
130
+ for (let i = 0; i < messages.length; i++) {
131
+ const msg = messages[i];
132
+ const blocks = Array.isArray(msg.content) ? msg.content : [{ type: 'text', text: String(msg.content || '') }];
133
+
134
+ if (msg.role === 'user') {
135
+ const humanTexts = blocks.filter(b => b.type === 'text' && b.text && !INJECTED_TAG_RE.test(b.text.trimStart()));
136
+ const hasSys = blocks.some(b => b.type === 'text' && b.text && INJECTED_TAG_RE.test(b.text.trimStart()));
137
+ const hasToolResult = blocks.some(b => b.type === 'tool_result');
138
+ const hasOrphanedResult = blocks.some(b => b.type === 'tool_result' && b.tool_use_id && !knownToolUseIds.has(b.tool_use_id));
139
+ if (humanTexts.length || (hasSys && !hasToolResult) || hasOrphanedResult) {
140
+ steps.push({
141
+ type: 'human',
142
+ source: 'history',
143
+ humanText: humanTexts.map(b => b.text).join('\n').slice(0, 200),
144
+ hasSys,
145
+ hasToolResult,
146
+ msgIndices: [i],
147
+ });
148
+ }
149
+
150
+ } else if (msg.role === 'assistant') {
151
+ const thinkingBlock = blocks.find(b => b.type === 'thinking');
152
+ const toolUses = blocks.filter(b => b.type === 'tool_use');
153
+ const textBlocks = blocks.filter(b => b.type === 'text' && b.text && b.text.trim());
154
+
155
+ if (textBlocks.length && !toolUses.length) {
156
+ steps.push({
157
+ type: 'assistant-text',
158
+ source: 'history',
159
+ text: textBlocks.map(b => b.text).join('\n'),
160
+ msgIndices: [i],
161
+ });
162
+ continue;
163
+ }
164
+
165
+ if (toolUses.length) {
166
+ const calls = toolUses.map(tu => {
167
+ const result = resultMap.get(tu.id);
168
+ const resultContent = result ? (typeof result.content === 'string' ? result.content : JSON.stringify(result.content)) : '';
169
+ return {
170
+ name: tu.name,
171
+ preview: getToolPreview(tu),
172
+ input: tu.input,
173
+ result: result?.content,
174
+ isError: !!(result?.is_error),
175
+ errorSummary: result?.is_error ? resultContent.slice(0, 80) : '',
176
+ toolUseId: tu.id,
177
+ pending: !result,
178
+ };
179
+ });
180
+ let resultMsgIdx = -1;
181
+ for (let j = i + 1; j < messages.length; j++) {
182
+ if (messages[j].role === 'user') { resultMsgIdx = j; break; }
183
+ }
184
+ steps.push({
185
+ type: 'tool-group',
186
+ source: 'history',
187
+ thinking: thinkingBlock ? (thinkingBlock.thinking || '') : null,
188
+ calls,
189
+ msgIndices: resultMsgIdx >= 0 ? [i, resultMsgIdx] : [i],
190
+ });
191
+ if (textBlocks.length) {
192
+ steps.push({
193
+ type: 'assistant-text',
194
+ source: 'history',
195
+ text: textBlocks.map(b => b.text).join('\n'),
196
+ msgIndices: [i],
197
+ });
198
+ }
199
+ } else if (thinkingBlock && !toolUses.length && !textBlocks.length) {
200
+ steps.push({
201
+ type: 'tool-group',
202
+ source: 'history',
203
+ thinking: thinkingBlock.thinking || '',
204
+ calls: [],
205
+ msgIndices: [i],
206
+ });
207
+ }
208
+ }
209
+ }
210
+ }
211
+
212
+ // Phase 3: Build current turn steps from resEvents
213
+ if (resEvents && resEvents.length) {
214
+ let curThinking = null;
215
+ let curThinkingStart = null;
216
+ let curThinkingEnd = null;
217
+ const curToolUses = []; // { index, name, id, inputChunks[] }
218
+ let curText = '';
219
+
220
+ for (const ev of resEvents) {
221
+ if (ev.type === 'content_block_start') {
222
+ if (ev.content_block?.type === 'thinking') {
223
+ curThinking = '';
224
+ curThinkingStart = ev._ts || null;
225
+ } else if (ev.content_block?.type === 'tool_use') {
226
+ curToolUses.push({
227
+ index: ev.index,
228
+ name: ev.content_block.name,
229
+ id: ev.content_block.id,
230
+ inputChunks: [],
231
+ });
232
+ }
233
+ } else if (ev.type === 'content_block_delta') {
234
+ if (ev.delta?.type === 'thinking_delta') {
235
+ if (curThinking !== null) curThinking += ev.delta.thinking || '';
236
+ } else if (ev.delta?.type === 'input_json_delta') {
237
+ const tu = curToolUses.find(t => t.index === ev.index);
238
+ if (tu) tu.inputChunks.push(ev.delta.partial_json || '');
239
+ } else if (ev.delta?.type === 'text_delta') {
240
+ curText += ev.delta.text || '';
241
+ }
242
+ } else if (ev.type === 'content_block_stop') {
243
+ if (curThinkingStart && !curThinkingEnd) curThinkingEnd = ev._ts || null;
244
+ }
245
+ }
246
+
247
+ // Build current turn tool calls
248
+ const currentCalls = curToolUses.map(tu => {
249
+ let input = {};
250
+ try { input = JSON.parse(tu.inputChunks.join('')); } catch {}
251
+ return {
252
+ name: tu.name,
253
+ preview: getToolPreview({ name: tu.name, input }),
254
+ input,
255
+ result: null,
256
+ isError: false,
257
+ errorSummary: '',
258
+ toolUseId: tu.id,
259
+ pending: true,
260
+ };
261
+ });
262
+
263
+ // Emit current turn thinking + tool group
264
+ if (currentCalls.length || curThinking !== null) {
265
+ const thinkingDuration = (curThinkingStart && curThinkingEnd)
266
+ ? ((curThinkingEnd - curThinkingStart) / 1000) : null;
267
+ steps.push({
268
+ type: 'tool-group',
269
+ source: 'current',
270
+ thinking: curThinking,
271
+ thinkingDuration,
272
+ calls: currentCalls,
273
+ msgIndices: [],
274
+ resEventSource: true,
275
+ });
276
+ }
277
+
278
+ // Emit current turn text
279
+ if (curText.trim()) {
280
+ steps.push({
281
+ type: 'assistant-text',
282
+ source: 'current',
283
+ text: curText,
284
+ msgIndices: [],
285
+ resEventSource: true,
286
+ });
287
+ }
288
+ }
289
+
290
+ return steps;
291
+ }
292
+
293
+ let currentSteps = []; // cached merged steps for current turn
294
+ let _stepsCache = { msgs: null, res: null, steps: [] };
295
+
296
+ // Memoized buildMergedSteps — returns cached result if inputs unchanged (by reference)
297
+ function getCachedSteps(messages, resEvents) {
298
+ if (messages === _stepsCache.msgs && resEvents === _stepsCache.res) {
299
+ return _stepsCache.steps;
300
+ }
301
+ const steps = buildMergedSteps(messages, resEvents);
302
+ _stepsCache = { msgs: messages, res: resEvents, steps };
303
+ return steps;
304
+ }
305
+
306
+ // Build timeline steps and cache them
307
+ function prepareTimelineSteps(messages, resEvents) {
308
+ if ((!messages || !messages.length) && (!resEvents || !resEvents.length)) {
309
+ currentSteps = [];
310
+ return;
311
+ }
312
+ currentSteps = getCachedSteps(messages, resEvents);
313
+ }
314
+
315
+ // Generate the step list HTML (used in both accordion and split-pane modes)
316
+ function renderStepListHtml(steps, activeStepKey) {
317
+ let html = '';
318
+ let lastSource = null;
319
+
320
+ for (let si = 0; si < steps.length; si++) {
321
+ const step = steps[si];
322
+
323
+ // Insert history/current separator
324
+ if (lastSource === 'history' && step.source === 'current') {
325
+ html += '<div style="display:flex;align-items:center;margin:8px 8px 4px;gap:6px"><div style="flex:1;border-top:1px dashed var(--accent)"></div><span style="font-size:10px;color:var(--accent);white-space:nowrap">current turn</span><div style="flex:1;border-top:1px dashed var(--accent)"></div></div>';
326
+ }
327
+ lastSource = step.source;
328
+
329
+ if (step.type === 'human') {
330
+ html += '<div class="step-separator" style="height:2px;background:var(--accent);margin:8px 0 2px"></div>';
331
+ const sel = (activeStepKey === si + ':') ? ' active' : '';
332
+ html += '<div class="tl-step-summary' + sel + '" data-step="' + si + '" onclick="selectStep(' + si + ')">';
333
+ html += '<div style="color:var(--accent);padding:6px 8px;font-size:12px;white-space:normal;line-height:1.5;background:rgba(88,166,255,0.08);border-radius:4px;border-left:2px solid var(--accent);margin:4px 0">';
334
+ html += '<span style="font-size:13px">👤</span> ' + escapeHtml((step.humanText || '').slice(0, 300));
335
+ html += '</div>';
336
+ if (step.hasSys) html += '<div style="padding:0 8px 0 24px;font-size:10px;color:var(--dim)">📋 system-reminder</div>';
337
+ html += '</div>';
338
+ html += '<div style="height:2px;background:var(--accent);margin:2px 0 4px"></div>';
339
+
340
+ } else if (step.type === 'tool-group') {
341
+ // Thinking line — T8: history=indicator only, current=Ns+preview
342
+ if (step.thinking != null) {
343
+ const tSel = (activeStepKey === si + ':thinking') ? ' active' : '';
344
+ html += '<div class="tl-step-summary' + tSel + '" data-step="' + si + '" data-sub="thinking" onclick="selectStep(' + si + ',&quot;thinking&quot;)" style="color:var(--dim);padding:2px 8px;font-size:11px">';
345
+ if (step.source === 'history') {
346
+ html += '🧠'; // indicator only — prior turn content not shown
347
+ } else {
348
+ const durLabel = step.thinkingDuration ? ' ' + step.thinkingDuration.toFixed(1) + 's' : '';
349
+ const thinkPreview = (step.thinking || '').slice(0, 80).replace(/\n/g, ' ').trim();
350
+ html += '🧠' + durLabel + (thinkPreview ? ' <span style="opacity:0.7">' + escapeHtml(thinkPreview) + '…</span>' : '');
351
+ }
352
+ html += '</div>';
353
+ }
354
+ // Tool calls
355
+ const calls = step.calls;
356
+ const isParallel = calls.length > 1;
357
+ for (let ci = 0; ci < calls.length; ci++) {
358
+ const c = calls[ci];
359
+ const bracket = isParallel ? (ci === 0 ? '┌' : ci === calls.length - 1 ? '└' : '│') : ' ';
360
+ const cSel = (activeStepKey === si + ':' + ci) ? ' active' : '';
361
+ // T6: error highlighting — CSS class + data-has-error for filter/jump
362
+ const errCls = c.isError ? ' tool-call-error' : '';
363
+ const errAttr = c.isError ? ' data-has-error="1"' : '';
364
+ html += '<div class="tl-step-summary' + cSel + errCls + '" data-step="' + si + '" data-call="' + ci + '"' + errAttr + ' onclick="selectStep(' + si + ',' + ci + ')">';
365
+ html += '<div class="msg-list-row" style="gap:4px">';
366
+ html += '<span style="color:var(--dim);width:8px;text-align:center;flex-shrink:0">' + bracket + '</span>';
367
+ html += '<span style="color:var(--green);min-width:40px;flex-shrink:0;font-weight:600">' + escapeHtml(c.name) + '</span>';
368
+ html += '<span style="color:var(--text);opacity:0.8;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">' + escapeHtml(c.preview) + '</span>';
369
+ if (c.pending) {
370
+ html += '<span style="color:var(--dim)">⏳</span>';
371
+ } else {
372
+ html += '<span style="color:' + (c.isError ? 'var(--red)' : 'var(--dim)') + ';flex-shrink:0">' + (c.isError ? '✗' : '✓') + '</span>';
373
+ }
374
+ html += '</div>';
375
+ if (c.isError && c.errorSummary) {
376
+ html += '<div style="padding:1px 8px 2px 52px;font-size:10px;color:var(--red)">' + escapeHtml(c.errorSummary.slice(0, 60)) + '</div>';
377
+ }
378
+ html += '</div>';
379
+ }
380
+
381
+ } else if (step.type === 'assistant-text') {
382
+ const aSel = (activeStepKey === si + ':') ? ' active' : '';
383
+ html += '<div class="tl-step-summary' + aSel + '" data-step="' + si + '" onclick="selectStep(' + si + ')">';
384
+ html += '<div style="color:var(--text);padding:6px 8px;font-size:12px;white-space:normal;line-height:1.5;background:rgba(63,185,80,0.08);border-radius:4px;border-left:2px solid var(--green);margin:4px 0">';
385
+ html += '<span style="font-size:13px">🤖</span> ' + escapeHtml((step.text || '').slice(0, 200));
386
+ html += '</div>';
387
+ html += '</div>';
388
+ }
389
+ }
390
+ return html;
391
+ }
392
+
393
+ // Get the active step key string for highlighting
394
+ function getActiveStepKey() {
395
+ if (selectedMessageIdx < 0) return null;
396
+ const stepIdx = Math.floor(selectedMessageIdx / 1000);
397
+ const subIdx = selectedMessageIdx % 1000;
398
+ if (subIdx === 999) return stepIdx + ':thinking';
399
+ return stepIdx + ':' + (subIdx || '');
400
+ }
401
+
402
+ // Render step detail content as HTML
403
+ function renderStepDetailHtml(req, tok) {
404
+ if (selectedMessageIdx < 0) return '';
405
+ const stepIdx = Math.floor(selectedMessageIdx / 1000);
406
+ const subIdx = selectedMessageIdx % 1000;
407
+ const step = currentSteps[stepIdx];
408
+ if (!step) return '<div class="col-empty">No step data</div>';
409
+
410
+ if (step.type === 'human') {
411
+ const msgIdx = step.msgIndices[0];
412
+ const msg = req?.messages?.[msgIdx];
413
+ return msg ? '<div class="detail-content">' + renderSingleMessage(msg, tok?.perMessage?.[msgIdx], msgIdx) + '</div>' : '<div class="col-empty">No message</div>';
414
+ } else if (step.type === 'assistant-text') {
415
+ return '<div class="detail-content"><pre>' + escapeHtml(step.text || '') + '</pre></div>';
416
+ } else if (step.type === 'tool-group') {
417
+ if (subIdx === 999) {
418
+ const durLabel = step.thinkingDuration ? ' · ' + step.thinkingDuration.toFixed(1) + 's' : '';
419
+ return '<div class="detail-content">' + renderThinkingDetail(step.thinking, durLabel) + '</div>';
420
+ } else if (subIdx < step.calls.length) {
421
+ return '<div class="detail-content">' + renderToolDetail(step.calls[subIdx]) + '</div>';
422
+ } else {
423
+ const c = step.calls[0];
424
+ return c ? '<div class="detail-content">' + renderToolDetail(c) + '</div>' : '<div class="col-empty">Empty tool group</div>';
425
+ }
426
+ }
427
+ return '<div class="col-empty">Unknown step type</div>';
428
+ }
429
+
430
+ function selectStep(stepIdx, sub) {
431
+ if (!currentSteps[stepIdx]) return; // guard: invalid step index
432
+ // If not in focused mode, enter it first (click on step = drill into timeline)
433
+ if (!isFocusedMode && typeof enterFocusedMode === 'function') {
434
+ selectedMessageIdx = stepIdx * 1000 + (sub === 'thinking' ? 999 : (typeof sub === 'number' ? sub : 0));
435
+ enterFocusedMode();
436
+ return;
437
+ }
438
+ if (sub === 'thinking') {
439
+ selectedMessageIdx = stepIdx * 1000 + 999;
440
+ } else if (typeof sub === 'number') {
441
+ selectedMessageIdx = stepIdx * 1000 + sub;
442
+ } else {
443
+ selectedMessageIdx = stepIdx * 1000;
444
+ }
445
+
446
+ // Split pane: update list highlights + detail pane
447
+ const listEl = colDetail.querySelector('.tl-scroll-area');
448
+ const detailEl = colDetail.querySelector('.tl-split-detail');
449
+ if (listEl) {
450
+ listEl.querySelectorAll('.tl-step-summary').forEach(el => {
451
+ el.classList.remove('active');
452
+ const elStep = parseInt(el.dataset.step);
453
+ const elCall = el.dataset.call != null ? parseInt(el.dataset.call) : -1;
454
+ const elSub = el.dataset.sub;
455
+ if (elStep === stepIdx) {
456
+ if (sub === 'thinking' && elSub === 'thinking') el.classList.add('active');
457
+ else if (typeof sub === 'number' && elCall === sub) el.classList.add('active');
458
+ else if (sub == null && (elCall < 0 || elCall === 0) && !elSub) el.classList.add('active');
459
+ }
460
+ });
461
+ }
462
+ if (detailEl) {
463
+ const e = selectedTurnIdx >= 0 ? allEntries[selectedTurnIdx] : null;
464
+ detailEl.innerHTML = renderStepDetailHtml(e?.req, e?.tokens);
465
+ }
466
+
467
+ // Minimap active state — shared by both modes (step-level, not sub-item)
468
+ const mm = colDetail.querySelector('.minimap');
469
+ if (mm) {
470
+ mm.querySelectorAll('.minimap-block').forEach(b =>
471
+ b.classList.toggle('mm-active', b.dataset.step === String(stepIdx)));
472
+ }
473
+ renderBreadcrumb();
474
+ }
475
+
476
+ function selectMessage(idx) {
477
+ selectedMessageIdx = idx;
478
+ renderDetailCol();
479
+ renderBreadcrumb();
480
+ }
481
+
482
+ function renderSingleMessage(msg, perMsg, msgIdx) {
483
+ const tokLabel = perMsg ? ' <span class="badge">' + perMsg.tokens + ' tok</span>' : '';
484
+ let body = '';
485
+ if (typeof msg.content === 'string') {
486
+ body = '<pre>' + escapeHtml(msg.content) + '</pre>';
487
+ } else if (Array.isArray(msg.content)) {
488
+ for (const block of msg.content) {
489
+ if (block.type === 'text') {
490
+ body += '<div class="content-block"><div class="type">text</div><pre>' + escapeHtml(block.text) + '</pre></div>';
491
+ } else if (block.type === 'tool_use') {
492
+ body += '<div class="content-block"><div class="type">tool_use: ' + escapeHtml(block.name) +
493
+ ' <span style="color:var(--dim);font-size:10px">' + escapeHtml(block.id || '') + '</span></div>' +
494
+ '<pre>' + escapeHtml(JSON.stringify(block.input, null, 2)) + '</pre></div>';
495
+ } else if (block.type === 'tool_result') {
496
+ const content = typeof block.content === 'string'
497
+ ? block.content : JSON.stringify(block.content, null, 2);
498
+ body += '<div class="content-block"><div class="type">tool_result &larr; ' +
499
+ escapeHtml(block.tool_use_id || '') + '</div>' +
500
+ '<pre>' + escapeHtml(content) + '</pre></div>';
501
+ } else if (block.type === 'image') {
502
+ body += '<div class="content-block"><div class="type">image (' +
503
+ escapeHtml(block.source?.media_type || '') + ')</div>' +
504
+ '<div style="color:var(--dim);font-size:11px">[image data]</div></div>';
505
+ } else {
506
+ body += '<div class="content-block"><pre>' + escapeHtml(JSON.stringify(block, null, 2)) + '</pre></div>';
507
+ }
508
+ }
509
+ }
510
+ const cls = msg.role === 'user' ? classifyUserMessage(msg) : null;
511
+ const asmCls = msg.role === 'assistant' ? classifyAssistantMessage(msg) : null;
512
+ const clsHtml = cls
513
+ ? ' ' + getUserBadgeHtml(cls).trimEnd()
514
+ : asmCls
515
+ ? ' ' + getAssistantBadgeHtml(asmCls).trimEnd()
516
+ : '';
517
+ return '<div class="msg"><div class="msg-role ' + msg.role + '">[' + msgIdx + '] ' + msg.role + clsHtml + tokLabel + '</div>' + body + '</div>';
518
+ }
519
+
520
+ // ── Token Minimap ──
521
+ // Color mapping for minimap blocks by content type
522
+ function mmBlockColor(stepType, blockType) {
523
+ if (blockType === 'thinking') return 'var(--color-thinking)';
524
+ if (blockType === 'tool_use') return 'var(--color-tool-use)';
525
+ if (blockType === 'tool_result') return 'var(--color-tool-result)';
526
+ if (stepType === 'human') return 'var(--accent)';
527
+ if (stepType === 'text') return 'var(--green)';
528
+ if (stepType === 'thinking') return 'var(--color-thinking)';
529
+ if (stepType === 'tool-group') return 'var(--color-tool-use)';
530
+ return 'var(--dim)';
531
+ }
532
+
533
+ // Build minimap block data from currentSteps + tok.perMessage
534
+ // Returns array of { stepIdx, color, tokens, label, isError }
535
+ function buildMinimapBlocks(steps, perMessage) {
536
+ const blocks = [];
537
+ if (!steps || !steps.length) return blocks;
538
+
539
+ for (let si = 0; si < steps.length; si++) {
540
+ const step = steps[si];
541
+ const indices = step.msgIndices || [];
542
+ const hasError = step.type === 'tool-group' && step.calls && step.calls.some(c => c.isError);
543
+
544
+ if (step.type === 'human') {
545
+ let totalTokens = 0;
546
+ if (perMessage && indices.length) {
547
+ for (const mi of indices) { totalTokens += (perMessage[mi]?.tokens || 0); }
548
+ }
549
+ blocks.push({ stepIdx: si, color: mmBlockColor('human'), tokens: totalTokens || 1, label: 'human', isError: false });
550
+ continue;
551
+ }
552
+
553
+ if (perMessage && indices.length) {
554
+ for (const mi of indices) {
555
+ const pm = perMessage[mi];
556
+ if (!pm) continue;
557
+ if (pm.blocks && pm.blocks.length > 1) {
558
+ for (const b of pm.blocks) {
559
+ blocks.push({ stepIdx: si, color: mmBlockColor(step.type, b.type), tokens: b.tokens || 0, label: b.type + (b.name ? ':' + b.name : ''), isError: hasError });
560
+ }
561
+ } else {
562
+ blocks.push({ stepIdx: si, color: mmBlockColor(step.type), tokens: pm.tokens || 1, label: step.type, isError: hasError });
563
+ }
564
+ }
565
+ } else {
566
+ blocks.push({ stepIdx: si, color: mmBlockColor(step.type), tokens: 1, label: step.type, isError: hasError });
567
+ }
568
+ }
569
+ return blocks;
570
+ }
571
+
572
+ // Render minimap HTML — cache bar + blocks + viewport + usage label
573
+ function renderMinimapHtml(steps, perMessage, activeStepIdx, maxContext, usage) {
574
+ const blocks = buildMinimapBlocks(steps, perMessage);
575
+ if (!blocks.length) return '';
576
+
577
+ const estimatedTotal = blocks.reduce((s, b) => s + b.tokens, 0) || 1;
578
+ // Use API usage as authoritative total when available (same logic as progress bar)
579
+ const apiTotal = usage
580
+ ? (usage.input_tokens || 0) + (usage.cache_read_input_tokens || 0) + (usage.cache_creation_input_tokens || 0)
581
+ : 0;
582
+ const totalTokens = apiTotal > estimatedTotal ? apiTotal : estimatedTotal;
583
+ const ctxWindow = maxContext || 200000;
584
+ const usedPct = Math.min(100, totalTokens / ctxWindow * 100).toFixed(0);
585
+ const remaining = Math.max(0, ctxWindow - totalTokens);
586
+
587
+ let html = '';
588
+
589
+ // Cache breakdown bar (D7) — 4px tall at top
590
+ if (usage) {
591
+ const cr = usage.cache_read_input_tokens || 0;
592
+ const cw = usage.cache_creation_input_tokens || 0;
593
+ const inp = usage.input_tokens || 0;
594
+ const cacheTotal = cr + cw + inp;
595
+ if (cacheTotal > 0) {
596
+ html += '<div class="minimap-cache-bar">';
597
+ if (cr) html += '<div style="width:' + (cr / cacheTotal * 100).toFixed(1) + '%;background:var(--color-cache-read)" title="cache_read: ' + cr.toLocaleString() + ' tok (' + (cr / cacheTotal * 100).toFixed(0) + '%)"></div>';
598
+ if (cw) html += '<div style="width:' + (cw / cacheTotal * 100).toFixed(1) + '%;background:var(--color-cache-write)" title="cache_write: ' + cw.toLocaleString() + ' tok (' + (cw / cacheTotal * 100).toFixed(0) + '%)"></div>';
599
+ if (inp) html += '<div style="width:' + (inp / cacheTotal * 100).toFixed(1) + '%;background:var(--color-input)" title="input: ' + inp.toLocaleString() + ' tok (' + (inp / cacheTotal * 100).toFixed(0) + '%)"></div>';
600
+ html += '</div>';
601
+ }
602
+ }
603
+
604
+ // Blocks container
605
+ html += '<div class="minimap-blocks" data-total-tokens="' + totalTokens + '" data-max-context="' + ctxWindow + '">';
606
+ for (let i = 0; i < blocks.length; i++) {
607
+ const b = blocks[i];
608
+ const isActive = activeStepIdx >= 0 && b.stepIdx === activeStepIdx;
609
+ const errStyle = b.isError ? ';border-left:2px solid var(--red)' : '';
610
+ const cls = 'minimap-block' + (b.isError ? ' mm-error' : '') + (isActive ? ' mm-active' : '');
611
+ html += '<div class="' + cls + '" data-step="' + b.stepIdx + '" data-block="' + i + '" data-tokens="' + b.tokens + '" '
612
+ + 'style="background:' + b.color + errStyle + '" '
613
+ + 'title="' + b.label + ' · ' + b.tokens.toLocaleString() + ' tok"></div>';
614
+ }
615
+ html += '</div>';
616
+
617
+ // Empty context area with tooltip
618
+ html += '<div class="minimap-empty" title="' + remaining.toLocaleString() + ' remaining"></div>';
619
+
620
+ // Usage label (hover only)
621
+ html += '<div class="minimap-usage">' + usedPct + '%</div>';
622
+
623
+ return html;
624
+ }
625
+
626
+ // Compute block heights — blocks region = (totalTokens / maxContext) * containerH
627
+ function layoutMinimapBlocks(minimapEl) {
628
+ if (!minimapEl) return;
629
+ const blocksContainer = minimapEl.querySelector('.minimap-blocks');
630
+ const emptyEl = minimapEl.querySelector('.minimap-empty');
631
+ if (!blocksContainer) return;
632
+
633
+ const containerH = minimapEl.clientHeight;
634
+ if (containerH <= 0) return;
635
+
636
+ const blockEls = blocksContainer.querySelectorAll('.minimap-block');
637
+ if (!blockEls.length) return;
638
+
639
+ const maxContext = parseInt(blocksContainer.dataset.maxContext) || 200000;
640
+ const totalTokens = parseInt(blocksContainer.dataset.totalTokens) || 1;
641
+
642
+ // Blocks region height = proportion of context used
643
+ const usedRatio = Math.min(1, totalTokens / maxContext);
644
+ // Subtract cache bar (4px) from available height
645
+ const cacheBar = minimapEl.querySelector('.minimap-cache-bar');
646
+ const cacheBarH = cacheBar ? cacheBar.offsetHeight : 0;
647
+ const availH = containerH - cacheBarH;
648
+ const blocksH = Math.max(blockEls.length, usedRatio * availH);
649
+
650
+ // Scale factor — fit all blocks within the proportional region
651
+ // Use actual sum of block tokens (not apiTotal) so blocks fill the region completely
652
+ let blockTokenSum = 0;
653
+ for (const el of blockEls) blockTokenSum += parseInt(el.dataset.tokens) || 1;
654
+ const scale = blocksH / (blockTokenSum || 1);
655
+
656
+ for (const el of blockEls) {
657
+ const tokens = parseInt(el.dataset.tokens) || 1;
658
+ el.style.height = Math.max(0.5, tokens * scale) + 'px';
659
+ }
660
+
661
+ blocksContainer.style.height = blocksH + 'px';
662
+
663
+ // Empty area fills the rest
664
+ if (emptyEl) {
665
+ const emptyH = Math.max(0, availH - blocksH);
666
+ emptyEl.style.height = emptyH + 'px';
667
+ }
668
+ }
669
+
670
+ // Wire up minimap interactions: click, hover
671
+ let _minimapCleanup = null;
672
+ function initMinimapInteractions(minimapEl, scrollAreaEl) {
673
+ if (_minimapCleanup) { _minimapCleanup(); _minimapCleanup = null; }
674
+ if (!minimapEl || !scrollAreaEl) return;
675
+
676
+ const blocksContainer = minimapEl.querySelector('.minimap-blocks');
677
+ if (!blocksContainer) return;
678
+
679
+ // Find which block is at a given Y position (relative to minimap top)
680
+ function blockAtY(y) {
681
+ const blockEls = blocksContainer.querySelectorAll('.minimap-block');
682
+ const mmRect = minimapEl.getBoundingClientRect();
683
+ for (const b of blockEls) {
684
+ const bRect = b.getBoundingClientRect();
685
+ const bTop = bRect.top - mmRect.top;
686
+ if (y >= bTop && y < bTop + bRect.height) return b;
687
+ }
688
+ // Between sub-pixel blocks — find closest
689
+ let closest = null, closestDist = Infinity;
690
+ for (const b of blockEls) {
691
+ const bRect = b.getBoundingClientRect();
692
+ const bMid = (bRect.top - mmRect.top) + bRect.height / 2;
693
+ const dist = Math.abs(y - bMid);
694
+ if (dist < closestDist) { closestDist = dist; closest = b; }
695
+ }
696
+ return closest;
697
+ }
698
+
699
+ // Click to navigate
700
+ minimapEl.addEventListener('click', (e) => {
701
+ if (e.target.classList.contains('minimap-empty')) return;
702
+ const mmRect = minimapEl.getBoundingClientRect();
703
+ const block = blockAtY(e.clientY - mmRect.top);
704
+ if (block) {
705
+ const targetStep = parseInt(block.dataset.step);
706
+ if (targetStep >= 0 && typeof selectStep === 'function') {
707
+ selectStep(targetStep);
708
+ const stepEl = scrollAreaEl.querySelector('[data-step="' + targetStep + '"]');
709
+ if (stepEl) stepEl.scrollIntoView({ block: 'center', behavior: 'smooth' });
710
+ }
711
+ }
712
+ });
713
+
714
+ // Minimap → Timeline hover
715
+ minimapEl.addEventListener('mousemove', (e) => {
716
+ if (e.target.classList.contains('minimap-empty')) {
717
+ minimapEl.querySelectorAll('.mm-highlight').forEach(b => b.classList.remove('mm-highlight'));
718
+ scrollAreaEl.querySelectorAll('.mm-hover').forEach(el => el.classList.remove('mm-hover'));
719
+ return;
720
+ }
721
+ const mmRect = minimapEl.getBoundingClientRect();
722
+ const block = blockAtY(e.clientY - mmRect.top);
723
+ if (block) {
724
+ const stepIdx = block.dataset.step;
725
+ minimapEl.querySelectorAll('.minimap-block').forEach(b => b.classList.toggle('mm-highlight', b.dataset.step === stepIdx));
726
+ scrollAreaEl.querySelectorAll('.tl-step-summary').forEach(el => el.classList.toggle('mm-hover', el.dataset.step === stepIdx));
727
+ }
728
+ });
729
+ minimapEl.addEventListener('mouseleave', () => {
730
+ minimapEl.querySelectorAll('.mm-highlight').forEach(b => b.classList.remove('mm-highlight'));
731
+ scrollAreaEl.querySelectorAll('.mm-hover').forEach(el => el.classList.remove('mm-hover'));
732
+ });
733
+
734
+ // Timeline → minimap hover
735
+ scrollAreaEl.addEventListener('mouseover', (e) => {
736
+ const stepEl = e.target.closest('.tl-step-summary');
737
+ if (!stepEl) return;
738
+ const stepIdx = stepEl.dataset.step;
739
+ minimapEl.querySelectorAll('.minimap-block').forEach(b => b.classList.toggle('mm-highlight', b.dataset.step === stepIdx));
740
+ });
741
+ scrollAreaEl.addEventListener('mouseleave', () => {
742
+ minimapEl.querySelectorAll('.mm-highlight').forEach(b => b.classList.remove('mm-highlight'));
743
+ });
744
+
745
+ // Recompute on resize
746
+ const ro = new ResizeObserver(() => { layoutMinimapBlocks(minimapEl); });
747
+ ro.observe(minimapEl);
748
+
749
+ _minimapCleanup = () => { ro.disconnect(); };
750
+ }