@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,335 @@
1
+ // ══════════════════════════════════════════════════════════════════════
2
+ // ── Intercept Feature ──
3
+ // ══════════════════════════════════════════════════════════════════════
4
+
5
+ function toggleIntercept(sid) {
6
+ fetch('/_api/intercept/toggle', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sessionId: sid }) });
7
+ }
8
+
9
+ function approveIntercept() {
10
+ if (!currentPending) return;
11
+ const id = currentPending.requestId;
12
+ fetch('/_api/intercept/' + encodeURIComponent(id) + '/approve', {
13
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
14
+ body: JSON.stringify({ body: currentPending.body }),
15
+ });
16
+ }
17
+
18
+ function rejectIntercept() {
19
+ if (!currentPending) return;
20
+ const id = currentPending.requestId;
21
+ fetch('/_api/intercept/' + encodeURIComponent(id) + '/reject', { method: 'POST' });
22
+ }
23
+
24
+ // ── Intercept overlay rendering ──
25
+ let interceptTab = 'messages';
26
+
27
+ function showInterceptOverlay() {
28
+ if (!currentPending) return;
29
+ const body = currentPending.body;
30
+ const msgs = body.messages || [];
31
+ const tools = body.tools || [];
32
+ const model = body.model || '?';
33
+ const sid = currentPending.sessionId;
34
+ const shortSid = sid ? sid.slice(0, 8) : '?';
35
+
36
+ let msgCount = msgs.length;
37
+ let toolCount = tools.length;
38
+ // rough token estimate
39
+ let roughTokens = JSON.stringify(body).length / 4;
40
+
41
+ let html = '<div class="intercept-overlay" id="intercept-overlay">';
42
+
43
+ // Header
44
+ html += '<div class="intercept-header">';
45
+ html += '<span class="ih-title">⏸ Request Intercepted</span>';
46
+ html += '<span class="ih-session">session:' + escapeHtml(shortSid) + ' · ' + escapeHtml(model.replace('claude-', '')) + '</span>';
47
+ html += '</div>';
48
+
49
+ // Tabs
50
+ const tabs = [
51
+ { id: 'messages', label: 'Messages (' + msgCount + ')' },
52
+ { id: 'system', label: 'System' },
53
+ { id: 'tools', label: 'Tools (' + toolCount + ')' },
54
+ { id: 'model', label: 'Model' },
55
+ { id: 'raw', label: 'Raw JSON' },
56
+ ];
57
+ html += '<div class="intercept-tabs">';
58
+ for (const t of tabs) {
59
+ html += '<div class="intercept-tab' + (interceptTab === t.id ? ' active' : '') + '" onclick="switchInterceptTab(&quot;' + t.id + '&quot;)">' + t.label + '</div>';
60
+ }
61
+ html += '</div>';
62
+
63
+ // Editor area
64
+ html += '<div class="intercept-editor" id="intercept-editor">';
65
+ html += renderInterceptTabContent(interceptTab);
66
+ html += '</div>';
67
+
68
+ // Summary
69
+ html += '<div class="intercept-summary">' + msgCount + ' messages · ' + toolCount + ' tools · ~' + Math.round(roughTokens).toLocaleString() + ' tokens</div>';
70
+
71
+ // Countdown bar
72
+ html += '<div class="intercept-countdown"><div class="intercept-countdown-bar" id="intercept-countdown-bar"></div></div>';
73
+
74
+ // Actions
75
+ html += '<div class="intercept-actions">';
76
+ html += '<button class="btn-reject" onclick="rejectIntercept()">✕ Reject</button>';
77
+ html += '<button class="btn-approve" onclick="approveIntercept()">✓ Approve & Send</button>';
78
+ html += '</div>';
79
+
80
+ html += '</div>';
81
+ colDetail.innerHTML = html;
82
+ startCountdown();
83
+ }
84
+
85
+ function renderInterceptTabContent(tab) {
86
+ if (!currentPending) return '';
87
+ const body = currentPending.body;
88
+
89
+ switch (tab) {
90
+ case 'messages': {
91
+ const msgs = body.messages || [];
92
+ if (!msgs.length) return '<div style="color:var(--dim)">No messages</div>';
93
+ // Show in reverse order (newest first)
94
+ let html = '';
95
+ for (let i = msgs.length - 1; i >= 0; i--) {
96
+ const m = msgs[i];
97
+ const preview = getMessagePreview(m).slice(0, 60);
98
+ html += '<div class="ie-msg" data-msg-idx="' + i + '" onclick="toggleInterceptMsg(this,' + i + ',event)">';
99
+ html += '<div class="ie-msg-role ' + m.role + '">[' + i + '] ' + m.role + '</div>';
100
+ html += '<div class="ie-msg-preview">' + escapeHtml(preview) + '</div>';
101
+ html += '</div>';
102
+ }
103
+ return html;
104
+ }
105
+ case 'system': {
106
+ const sys = body.system;
107
+ const text = !sys ? '' : (typeof sys === 'string' ? sys : JSON.stringify(sys, null, 2));
108
+ return '<textarea id="ie-system-ta" oninput="onInterceptSystemEdit(this)" style="min-height:300px">' + escapeHtml(text) + '</textarea>';
109
+ }
110
+ case 'tools': {
111
+ const tools = body.tools || [];
112
+ if (!tools.length) return '<div style="color:var(--dim)">No tools</div>';
113
+ let html = '';
114
+ for (let i = 0; i < tools.length; i++) {
115
+ const t = tools[i];
116
+ const checked = t._enabled !== false ? ' checked' : '';
117
+ html += '<div class="ie-tool-item">';
118
+ html += '<input type="checkbox" id="ie-tool-' + i + '"' + checked + ' onchange="onInterceptToolToggle(' + i + ',this.checked)">';
119
+ html += '<label for="ie-tool-' + i + '">' + escapeHtml(t.name) + '</label>';
120
+ html += '</div>';
121
+ }
122
+ return html;
123
+ }
124
+ case 'model': {
125
+ const models = ['claude-opus-4-6','claude-sonnet-4-6','claude-haiku-4-5','claude-opus-4-5','claude-sonnet-4','claude-haiku-4'];
126
+ let html = '<div style="margin-bottom:8px;color:var(--dim);font-size:11px">Current model:</div>';
127
+ html += '<select id="ie-model-select" onchange="onInterceptModelChange(this.value)" style="min-width:200px">';
128
+ for (const m of models) {
129
+ const sel = body.model === m ? ' selected' : '';
130
+ html += '<option value="' + m + '"' + sel + '>' + m + '</option>';
131
+ }
132
+ // If current model not in list, add it
133
+ if (!models.includes(body.model)) {
134
+ html += '<option value="' + escapeHtml(body.model) + '" selected>' + escapeHtml(body.model) + '</option>';
135
+ }
136
+ html += '</select>';
137
+ return html;
138
+ }
139
+ case 'raw': {
140
+ const json = JSON.stringify(body, null, 2);
141
+ return '<textarea id="ie-raw-ta" oninput="onInterceptRawEdit(this)" style="min-height:400px;font-size:11px">' + escapeHtml(json) + '</textarea>';
142
+ }
143
+ default: return '';
144
+ }
145
+ }
146
+
147
+ function switchInterceptTab(tab) {
148
+ interceptTab = tab;
149
+ document.querySelectorAll('.intercept-tab').forEach(el => {
150
+ el.classList.toggle('active', el.textContent.toLowerCase().startsWith(tab));
151
+ });
152
+ const editor = document.getElementById('intercept-editor');
153
+ if (editor) editor.innerHTML = renderInterceptTabContent(tab);
154
+ }
155
+
156
+ function toggleInterceptMsg(el, idx, evt) {
157
+ // Don't collapse if user clicked inside the textarea (event bubbling)
158
+ if (evt && (evt.target.tagName === 'TEXTAREA' || evt.target.closest('textarea'))) return;
159
+ if (el.querySelector('textarea')) {
160
+ // Collapse
161
+ el.innerHTML = '<div class="ie-msg-role ' + currentPending.body.messages[idx].role + '">[' + idx + '] ' + currentPending.body.messages[idx].role + '</div>' +
162
+ '<div class="ie-msg-preview">' + escapeHtml(getMessagePreview(currentPending.body.messages[idx]).slice(0, 60)) + '</div>';
163
+ el.classList.remove('expanded');
164
+ return;
165
+ }
166
+ const m = currentPending.body.messages[idx];
167
+ const content = typeof m.content === 'string' ? m.content : JSON.stringify(m.content, null, 2);
168
+ el.classList.add('expanded');
169
+ el.innerHTML = '<div class="ie-msg-role ' + m.role + '">[' + idx + '] ' + m.role + '</div>' +
170
+ '<textarea oninput="onInterceptMsgEdit(' + idx + ',this)" style="min-height:150px">' + escapeHtml(content) + '</textarea>';
171
+ }
172
+
173
+ function onInterceptMsgEdit(idx, ta) {
174
+ if (!currentPending) return;
175
+ try {
176
+ currentPending.body.messages[idx].content = JSON.parse(ta.value);
177
+ } catch {
178
+ currentPending.body.messages[idx].content = ta.value;
179
+ }
180
+ }
181
+
182
+ function onInterceptSystemEdit(ta) {
183
+ if (!currentPending) return;
184
+ try {
185
+ currentPending.body.system = JSON.parse(ta.value);
186
+ } catch {
187
+ currentPending.body.system = ta.value;
188
+ }
189
+ }
190
+
191
+ function onInterceptToolToggle(idx, enabled) {
192
+ if (!currentPending) return;
193
+ if (enabled) {
194
+ delete currentPending.body.tools[idx]._enabled;
195
+ } else {
196
+ currentPending.body.tools[idx]._enabled = false;
197
+ }
198
+ // Remove disabled tools before sending
199
+ }
200
+
201
+ function onInterceptModelChange(model) {
202
+ if (!currentPending) return;
203
+ currentPending.body.model = model;
204
+ }
205
+
206
+ function onInterceptRawEdit(ta) {
207
+ if (!currentPending) return;
208
+ try {
209
+ currentPending.body = JSON.parse(ta.value);
210
+ } catch {
211
+ // invalid JSON, ignore
212
+ }
213
+ }
214
+
215
+ // Override approveIntercept to filter disabled tools
216
+ const _origApprove = approveIntercept;
217
+ approveIntercept = function() {
218
+ if (currentPending && currentPending.body.tools) {
219
+ currentPending.body.tools = currentPending.body.tools.filter(t => t._enabled !== false);
220
+ // Clean up _enabled markers
221
+ currentPending.body.tools.forEach(t => delete t._enabled);
222
+ }
223
+ _origApprove();
224
+ };
225
+
226
+ // ── Countdown ──
227
+ function startCountdown() {
228
+ if (countdownInterval) clearInterval(countdownInterval);
229
+ countdownInterval = setInterval(updateCountdown, 1000);
230
+ updateCountdown();
231
+ }
232
+
233
+ function updateCountdown() {
234
+ if (!currentPending) {
235
+ if (countdownInterval) { clearInterval(countdownInterval); countdownInterval = null; }
236
+ return;
237
+ }
238
+ const elapsed = (Date.now() - currentPending.receivedAt) / 1000;
239
+ const remaining = Math.max(0, interceptTimeoutSec - elapsed);
240
+ const pct = remaining / interceptTimeoutSec * 100;
241
+ const bar = document.getElementById('intercept-countdown-bar');
242
+ if (bar) {
243
+ bar.style.width = pct + '%';
244
+ bar.style.backgroundColor = pct > 50 ? 'var(--green)' : pct > 20 ? 'var(--yellow)' : 'var(--red)';
245
+ }
246
+ // Update topbar held indicator
247
+ updateTopbarHeld(Math.ceil(remaining));
248
+ }
249
+
250
+ function updateTopbarHeld(remaining) {
251
+ const el = document.getElementById('topbar-held');
252
+ if (!el) return;
253
+ if (!currentPending) {
254
+ el.style.display = 'none';
255
+ return;
256
+ }
257
+ el.style.display = 'inline';
258
+ el.textContent = 'HELD (' + (remaining != null ? remaining + 's' : '?') + ')';
259
+ el.onclick = () => showInterceptOverlay();
260
+ }
261
+
262
+ function hideInterceptOverlay() {
263
+ if (countdownInterval) { clearInterval(countdownInterval); countdownInterval = null; }
264
+ currentPending = null;
265
+ const el = document.getElementById('intercept-overlay');
266
+ if (el) el.remove();
267
+ const held = document.getElementById('topbar-held');
268
+ if (held) held.style.display = 'none';
269
+ // Re-render detail
270
+ renderDetailCol();
271
+ }
272
+
273
+ // ── SSE handlers for intercept events ──
274
+ const _origOnMessage = evtSource.onmessage;
275
+ evtSource.onmessage = (ev) => {
276
+ try {
277
+ const data = JSON.parse(ev.data);
278
+ if (data._type === 'intercept_toggled') {
279
+ if (data.enabled) interceptSessionIds.add(data.sessionId);
280
+ else interceptSessionIds.delete(data.sessionId);
281
+ // Re-render affected session item
282
+ const sid = data.sessionId;
283
+ const sessEl = document.getElementById('sess-' + sid.slice(0, 8));
284
+ const sess = sessionsMap.get(sid);
285
+ if (sessEl && sess) {
286
+ const hadFocus = sessEl.contains(document.activeElement) && document.activeElement.classList.contains('sdot');
287
+ sessEl.innerHTML = renderSessionItem(sess, sid);
288
+ if (hadFocus) { const btn = sessEl.querySelector('.sdot'); if (btn) btn.focus(); }
289
+ }
290
+ return;
291
+ }
292
+ if (data._type === 'pending_request') {
293
+ currentPending = {
294
+ requestId: data.requestId,
295
+ sessionId: data.sessionId,
296
+ body: data.body,
297
+ receivedAt: Date.now(),
298
+ };
299
+ interceptTab = 'messages';
300
+ showInterceptOverlay();
301
+ const hSessEl = document.getElementById('sess-' + data.sessionId.slice(0, 8));
302
+ const hSess = sessionsMap.get(data.sessionId);
303
+ if (hSessEl && hSess) hSessEl.innerHTML = renderSessionItem(hSess, data.sessionId);
304
+ return;
305
+ }
306
+ if (data._type === 'intercept_removed') {
307
+ const prevSid = currentPending && currentPending.sessionId;
308
+ if (currentPending && currentPending.requestId === data.requestId) {
309
+ hideInterceptOverlay();
310
+ }
311
+ if (prevSid) {
312
+ const rSessEl = document.getElementById('sess-' + prevSid.slice(0, 8));
313
+ const rSess = sessionsMap.get(prevSid);
314
+ if (rSessEl && rSess) rSessEl.innerHTML = renderSessionItem(rSess, prevSid);
315
+ }
316
+ return;
317
+ }
318
+ if (data._type === 'intercept_timeout') {
319
+ interceptTimeoutSec = data.timeout;
320
+ return;
321
+ }
322
+ if (data._type === 'version_detected') {
323
+ updateSysPromptBadge(data.agentKey || 'claude-code');
324
+ const banner = document.getElementById('version-banner');
325
+ if (banner) {
326
+ banner.style.display = 'flex';
327
+ banner.className = 'version-banner';
328
+ banner.innerHTML = `<span class="vb-dot">●</span> New cc_version: <span class="vb-ver">${escapeHtml(data.version)}</span> <span class="vb-delta">(+${(data.b2Len/4).toFixed(0)} tok)</span> <span class="vb-view" onclick="openSystemPromptPanel(true)">view</span> <span class="vb-close" onclick="this.parentElement.style.display='none'">×</span>`;
329
+ }
330
+ return;
331
+ }
332
+ } catch {}
333
+ // Fall through to original handler
334
+ _origOnMessage(ev);
335
+ };
@@ -0,0 +1,208 @@
1
+ // ── Keyboard navigation ──
2
+ document.addEventListener('keydown', (e) => {
3
+ if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
4
+ const key = e.key;
5
+
6
+ // Tab switching: 1=Dashboard, 2=Usage, 3=System Prompt
7
+ const tabMap = { '1': 'dashboard', '2': 'usage', '3': 'sysprompt' };
8
+ if (tabMap[key]) { switchTab(tabMap[key]); e.preventDefault(); return; }
9
+
10
+ // Focused mode intercept — takes priority over column navigation
11
+ if (isFocusedMode) {
12
+ if (key === 'Escape' || key === 'ArrowLeft') {
13
+ if (tlFilterActive) { closeTlFilter(); e.preventDefault(); return; }
14
+ exitFocusedMode(); e.preventDefault(); return;
15
+ }
16
+ // T11: open filter with /
17
+ if (key === '/' && selectedSection === 'timeline') { openTlFilter(); e.preventDefault(); return; }
18
+ // T12: step-type jump shortcuts
19
+ if (selectedSection === 'timeline' && !tlFilterActive) {
20
+ const dir = { 'e': ['error','next'], 'E': ['error','prev'], 't': ['thinking','next'], 'h': ['human','next'], ']': ['text','next'] }[key];
21
+ if (dir) { e.preventDefault(); jumpToStepType(dir[0], dir[1]); return; }
22
+ }
23
+ if ((key === 'ArrowUp' || key === 'ArrowDown') && selectedSection === 'timeline') {
24
+ e.preventDefault();
25
+ // Navigate between top-level steps (not sub-items within a step)
26
+ const all = [...colDetail.querySelectorAll('.tl-step-summary')];
27
+ if (!all.length) return;
28
+ // Build ordered list of unique step indices, preserving DOM order
29
+ const seen = new Set();
30
+ const steps = [];
31
+ for (const el of all) {
32
+ const s = el.dataset.step;
33
+ if (!seen.has(s)) { seen.add(s); steps.push(s); }
34
+ }
35
+ // Find current step
36
+ const curEl = colDetail.querySelector('.tl-step-summary.active');
37
+ const curStep = curEl ? curEl.dataset.step : null;
38
+ const curPos = curStep != null ? steps.indexOf(curStep) : -1;
39
+ const nextPos = Math.max(0, Math.min(steps.length - 1, curPos + (key === 'ArrowDown' ? 1 : -1)));
40
+ const nextStep = steps[nextPos];
41
+ // Click the first element of the target step
42
+ const target = colDetail.querySelector('.tl-step-summary[data-step="' + nextStep + '"]');
43
+ target?.click();
44
+ target?.scrollIntoView({ block: 'nearest' });
45
+ return;
46
+ }
47
+ // Navigate between sections while staying in focused mode
48
+ if (key === 'ArrowUp' || key === 'ArrowDown') {
49
+ e.preventDefault();
50
+ const sectionNames = [...colSections.querySelectorAll('.section-item')].map(el => el.dataset.section);
51
+ if (!sectionNames.length) return;
52
+ const cur = sectionNames.indexOf(selectedSection);
53
+ const next = Math.max(0, Math.min(sectionNames.length - 1, cur + (key === 'ArrowDown' ? 1 : -1)));
54
+ if (next === cur) return;
55
+ // Update section without exiting focused mode
56
+ selectedSection = sectionNames[next];
57
+ selectedMessageIdx = -1;
58
+ colSections.querySelectorAll('.section-item').forEach(el => {
59
+ el.classList.toggle('selected', el.dataset.section === selectedSection);
60
+ });
61
+ renderDetailCol();
62
+ renderBreadcrumb();
63
+ return;
64
+ }
65
+ return; // swallow other keys in focused mode
66
+ }
67
+
68
+ // Enter on sections → enter focused mode
69
+ if (key === 'Enter' && focusedCol === 'sections' && selectedSection) { enterFocusedMode(); e.preventDefault(); return; }
70
+ // T11: open filter in non-focused timeline view
71
+ if (key === '/' && selectedSection === 'timeline') { openTlFilter(); e.preventDefault(); return; }
72
+ if (!['ArrowUp','ArrowDown','ArrowLeft','ArrowRight'].includes(key)) return;
73
+ e.preventDefault();
74
+
75
+ if (focusedCol === 'projects') {
76
+ if (key === 'ArrowRight') { setFocus('sessions'); return; }
77
+ if (key === 'ArrowUp' || key === 'ArrowDown') {
78
+ // Build list from visible DOM items to respect active filter and sort order
79
+ const projItems = [null, ...[...colProjects.querySelectorAll('.project-item')].map(el => {
80
+ const m = el.getAttribute('onclick')?.match(/selectProject\((.+)\)/);
81
+ if (m) try { return JSON.parse(m[1].replace(/&quot;/g, '"')); } catch(e) {}
82
+ return null;
83
+ }).filter(n => n !== null)];
84
+ const cur = projItems.indexOf(selectedProjectName);
85
+ const next = Math.max(0, Math.min(projItems.length - 1, cur + (key === 'ArrowDown' ? 1 : -1)));
86
+ if (next === cur) return;
87
+ selectProject(projItems[next]);
88
+ }
89
+ } else if (focusedCol === 'sessions') {
90
+ if (key === 'ArrowLeft') { setFocus('projects'); return; }
91
+ if (key === 'ArrowRight') { setFocus('turns'); return; }
92
+ const visibleSessEls = [...colSessions.querySelectorAll('.session-item')].filter(el => el.style.display !== 'none');
93
+ const sessIds = visibleSessEls.map(el => el.dataset.sessionId);
94
+ if (!sessIds.length) return;
95
+ const cur = selectedSessionId ? sessIds.indexOf(selectedSessionId) : sessIds.length - 1;
96
+ const next = Math.max(0, Math.min(sessIds.length - 1, cur + (key === 'ArrowDown' ? 1 : -1)));
97
+ selectSession(sessIds[next]);
98
+ } else if (focusedCol === 'turns') {
99
+ if (key === 'ArrowLeft') { setFocus('sessions'); return; }
100
+ if (key === 'ArrowRight' && selectedTurnIdx >= 0) { setFocus('sections'); return; }
101
+ if (key === 'ArrowUp' || key === 'ArrowDown') {
102
+ const visible = getVisibleTurnIndices();
103
+ if (!visible.length) return;
104
+ const cur = visible.indexOf(selectedTurnIdx);
105
+ const next = Math.max(0, Math.min(visible.length - 1, cur + (key === 'ArrowDown' ? 1 : -1)));
106
+ selectTurn(visible[next]);
107
+ }
108
+ } else if (focusedCol === 'sections') {
109
+ if (key === 'ArrowLeft') { setFocus('turns'); return; }
110
+ if (key === 'ArrowRight' && selectedSection) { enterFocusedMode(); return; }
111
+ if (key === 'ArrowUp' || key === 'ArrowDown') {
112
+ const sectionNames = [...colSections.querySelectorAll('.section-item')].map(el => el.dataset.section);
113
+ if (!sectionNames.length) return;
114
+ const cur = sectionNames.indexOf(selectedSection);
115
+ const next = Math.max(0, Math.min(sectionNames.length - 1, cur + (key === 'ArrowDown' ? 1 : -1)));
116
+ selectSection(sectionNames[next]);
117
+ }
118
+ }
119
+ });
120
+
121
+ // ── T11: Timeline Filter Bar ──────────────────────────────────────────────────
122
+ let tlFilterActive = false;
123
+ let tlFilterQuery = '';
124
+
125
+ function openTlFilter() {
126
+ tlFilterActive = true;
127
+ const host = isFocusedMode ? colDetail : colDetail;
128
+ let bar = document.getElementById('tl-filter-bar');
129
+ if (!bar) {
130
+ bar = document.createElement('div');
131
+ bar.id = 'tl-filter-bar';
132
+ bar.style.cssText = 'position:sticky;top:0;z-index:10;background:var(--surface);border-bottom:1px solid var(--accent);padding:4px 8px;display:flex;align-items:center;gap:6px';
133
+ bar.innerHTML = '<span style="font-size:11px;color:var(--dim)">Filter:</span>'
134
+ + '<input id="tl-filter-input" type="text" placeholder="tool:Bash status:fail"'
135
+ + ' style="flex:1;background:transparent;border:none;outline:none;color:var(--text);font-size:12px;font-family:monospace">'
136
+ + '<span id="tl-filter-count" style="font-size:10px;color:var(--dim)"></span>'
137
+ + '<button onclick="closeTlFilter()" style="background:none;border:none;color:var(--dim);cursor:pointer;font-size:12px;padding:0 2px">✕</button>';
138
+ host.prepend(bar);
139
+ }
140
+ bar.style.display = 'flex';
141
+ const inp = document.getElementById('tl-filter-input');
142
+ inp.value = tlFilterQuery;
143
+ inp.focus();
144
+ inp.oninput = function() { tlFilterQuery = inp.value; applyTlFilter(); };
145
+ inp.onkeydown = function(ev) {
146
+ if (ev.key === 'Escape') { closeTlFilter(); ev.preventDefault(); }
147
+ ev.stopPropagation();
148
+ };
149
+ applyTlFilter();
150
+ }
151
+
152
+ function closeTlFilter() {
153
+ tlFilterActive = false;
154
+ tlFilterQuery = '';
155
+ const bar = document.getElementById('tl-filter-bar');
156
+ if (bar) bar.style.display = 'none';
157
+ applyTlFilter();
158
+ }
159
+
160
+ function applyTlFilter() {
161
+ const q = tlFilterQuery.trim().toLowerCase();
162
+ const allStepEls = [...colDetail.querySelectorAll('.tl-step-summary')];
163
+ if (!q) {
164
+ allStepEls.forEach(function(el) { el.style.display = ''; });
165
+ const cnt = document.getElementById('tl-filter-count');
166
+ if (cnt) cnt.textContent = '';
167
+ return;
168
+ }
169
+ const toolMatch = (q.match(/\btool:(\S+)/) || [])[1];
170
+ const statusFail = /\bstatus:fail\b/.test(q);
171
+ let shown = 0;
172
+ allStepEls.forEach(function(el) {
173
+ let visible = true;
174
+ if (toolMatch) {
175
+ const nameEl = el.querySelector('.msg-list-row span:nth-child(2)');
176
+ visible = (nameEl ? nameEl.textContent.toLowerCase() : '').includes(toolMatch);
177
+ }
178
+ if (statusFail && visible) visible = el.dataset.hasError === '1';
179
+ el.style.display = visible ? '' : 'none';
180
+ if (visible) shown++;
181
+ });
182
+ const cnt = document.getElementById('tl-filter-count');
183
+ if (cnt) cnt.textContent = 'Showing ' + shown + ' of ' + allStepEls.length;
184
+ }
185
+
186
+ // ── T12: Step-type jump shortcuts ─────────────────────────────────────────────
187
+ function jumpToStepType(type, dir) {
188
+ const allStepEls = [...colDetail.querySelectorAll('.tl-step-summary')];
189
+ if (!allStepEls.length) return;
190
+ const curEl = colDetail.querySelector('.tl-step-summary.active');
191
+ const curIdx = curEl ? allStepEls.indexOf(curEl) : -1;
192
+
193
+ const candidates = allStepEls.filter(function(el) {
194
+ if (type === 'error') return el.dataset.hasError === '1';
195
+ if (type === 'thinking') return el.textContent.trim().startsWith('🧠');
196
+ if (type === 'human') return el.querySelector('[style*="var(--accent)"]') !== null && el.querySelector('.msg-list-row') === null;
197
+ if (type === 'text') return el.querySelector('[style*="var(--green)"]') !== null && el.querySelector('.msg-list-row') === null;
198
+ return false;
199
+ });
200
+
201
+ if (!candidates.length) return;
202
+ const ci = candidates.indexOf(curEl);
203
+ const next = dir === 'next'
204
+ ? (ci < candidates.length - 1 ? ci + 1 : 0)
205
+ : (ci > 0 ? ci - 1 : candidates.length - 1);
206
+ const target = candidates[next];
207
+ if (target) { target.click(); target.scrollIntoView({ block: 'nearest' }); }
208
+ }