@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.
- package/CHANGELOG.md +39 -0
- package/LICENSE +21 -0
- package/README.ja.md +144 -0
- package/README.md +145 -0
- package/README.zh-TW.md +144 -0
- package/package.json +46 -0
- package/public/app.js +99 -0
- package/public/cost-budget-ui.js +305 -0
- package/public/entry-rendering.js +535 -0
- package/public/index.html +119 -0
- package/public/intercept-ui.js +335 -0
- package/public/keyboard-nav.js +208 -0
- package/public/messages.js +750 -0
- package/public/miller-columns.js +1686 -0
- package/public/quota-ticker.js +67 -0
- package/public/style.css +431 -0
- package/public/system-prompt-ui.js +327 -0
- package/server/auth.js +34 -0
- package/server/bedrock-credentials.js +141 -0
- package/server/config.js +190 -0
- package/server/cost-budget.js +220 -0
- package/server/cost-worker.js +110 -0
- package/server/eventstream.js +148 -0
- package/server/forward.js +683 -0
- package/server/helpers.js +393 -0
- package/server/hub.js +418 -0
- package/server/index.js +551 -0
- package/server/pricing.js +133 -0
- package/server/restore.js +141 -0
- package/server/routes/api.js +123 -0
- package/server/routes/costs.js +124 -0
- package/server/routes/intercept.js +89 -0
- package/server/routes/sse.js +44 -0
- package/server/sigv4.js +104 -0
- package/server/sse-broadcast.js +71 -0
- package/server/storage/index.js +36 -0
- package/server/storage/interface.js +26 -0
- package/server/storage/local.js +79 -0
- package/server/storage/s3.js +91 -0
- package/server/store.js +108 -0
- package/server/system-prompt.js +150 -0
|
@@ -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("' + t.id + '")">' + 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(/"/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
|
+
}
|