1<!DOCTYPE html>
2<html lang="en">
3<head>
4 <meta charset="UTF-8">
5 <meta name="viewport" content="width=device-width, initial-scale=1.0">
6 <title>{{.Title}}</title>
7 <style>
8 :root {
9 /* Charmtone palette */
10 --pepper: #201F26;
11 --bbq: #2d2c35;
12 --charcoal: #3A3943;
13 --iron: #4D4C57;
14 --oyster: #605F6B;
15 --squid: #858392;
16 --smoke: #BFBCC8;
17 --ash: #DFDBDD;
18 --salt: #F1EFEF;
19 --butter: #FFFAF1;
20
21 /* Accents */
22 --charple: #6B50FF;
23 --dolly: #FF60FF;
24 --julep: #00FFB2;
25 --tang: #FF985A;
26 --malibu: #00A4FF;
27 --cherry: #FF388B;
28 --hazy: #8B75FF;
29 --blush: #FF84FF;
30 --bok: #68FFD6;
31
32 /* Semantic */
33 --bg: var(--pepper);
34 --bg-secondary: var(--bbq);
35 --bg-tertiary: var(--charcoal);
36 --border: var(--iron);
37 --text: var(--smoke);
38 --text-bright: var(--butter);
39 --text-muted: var(--squid);
40 --user: var(--julep);
41 --agent: var(--dolly);
42 --system: var(--tang);
43 }
44 * { box-sizing: border-box; margin: 0; padding: 0; }
45 body {
46 font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
47 background: var(--bg);
48 color: var(--text);
49 line-height: 1.5;
50 padding: 1.5rem;
51 font-size: 14px;
52 }
53 .container { max-width: 1100px; margin: 0 auto; }
54 header {
55 border-bottom: 1px solid var(--border);
56 padding-bottom: 1rem;
57 margin-bottom: 1.5rem;
58 }
59 h1 { font-size: 1.25rem; font-weight: 600; color: var(--text-bright); margin-bottom: 0.25rem; }
60 .meta { color: var(--text-muted); font-size: 0.75rem; display: flex; gap: 1rem; flex-wrap: wrap; }
61 .metrics {
62 display: flex;
63 gap: 1.5rem;
64 margin-top: 0.75rem;
65 padding: 0.75rem 1rem;
66 background: var(--bg-secondary);
67 border-radius: 6px;
68 font-size: 0.8rem;
69 }
70 .metric { text-align: center; }
71 .metric-value { font-size: 1.1rem; font-weight: 600; color: var(--charple); }
72 .metric-label { color: var(--text-muted); font-size: 0.7rem; }
73
74 /* Timeline */
75 .timeline { display: flex; flex-direction: column; gap: 2px; }
76 .step {
77 background: var(--bg-secondary);
78 border-radius: 4px;
79 overflow: hidden;
80 }
81 .step-header {
82 display: flex;
83 align-items: center;
84 gap: 0.5rem;
85 padding: 0.5rem 0.75rem;
86 cursor: pointer;
87 user-select: none;
88 transition: background 0.15s;
89 }
90 .step-header:hover { background: var(--bg-tertiary); }
91 .step-toggle {
92 color: var(--text-muted);
93 font-size: 0.7rem;
94 transition: transform 0.15s;
95 width: 1rem;
96 }
97 .step.expanded .step-toggle { transform: rotate(90deg); }
98 .step-source {
99 font-size: 0.65rem;
100 font-weight: 600;
101 text-transform: uppercase;
102 padding: 0.125rem 0.4rem;
103 border-radius: 3px;
104 letter-spacing: 0.03em;
105 }
106 .step.user .step-source { background: rgba(0, 255, 178, 0.15); color: var(--user); }
107 .step.agent .step-source { background: rgba(255, 96, 255, 0.15); color: var(--agent); }
108 .step.system .step-source { background: rgba(255, 152, 90, 0.15); color: var(--system); }
109 .step-id { font-size: 0.7rem; color: var(--text-muted); font-family: monospace; }
110 .step-preview {
111 flex: 1;
112 font-size: 0.8rem;
113 color: var(--text);
114 white-space: nowrap;
115 overflow: hidden;
116 text-overflow: ellipsis;
117 margin-left: 0.5rem;
118 }
119 .step-time { font-size: 0.65rem; color: var(--text-muted); margin-left: auto; }
120 .step-badges { display: flex; gap: 0.25rem; margin-left: 0.5rem; }
121 .badge {
122 font-size: 0.6rem;
123 padding: 0.1rem 0.35rem;
124 border-radius: 3px;
125 background: var(--bg-tertiary);
126 color: var(--text-muted);
127 }
128 .badge.tools { background: rgba(107, 80, 255, 0.2); color: var(--charple); }
129 .badge.thinking { background: rgba(255, 96, 255, 0.2); color: var(--blush); }
130
131 /* Expanded content */
132 .step-body { display: none; padding: 0.75rem; border-top: 1px solid var(--border); }
133 .step.expanded .step-body { display: block; }
134 .message {
135 background: var(--bg);
136 padding: 0.75rem;
137 border-radius: 4px;
138 white-space: pre-wrap;
139 word-wrap: break-word;
140 font-size: 0.85rem;
141 max-height: 300px;
142 overflow-y: auto;
143 }
144 .message:empty { display: none; }
145 .reasoning {
146 margin-top: 0.5rem;
147 padding: 0.5rem 0.75rem;
148 background: rgba(255, 96, 255, 0.08);
149 border-left: 2px solid var(--agent);
150 border-radius: 0 4px 4px 0;
151 font-size: 0.8rem;
152 color: var(--text-muted);
153 max-height: 200px;
154 overflow-y: auto;
155 }
156 .section-label {
157 font-size: 0.65rem;
158 text-transform: uppercase;
159 color: var(--text-muted);
160 margin-bottom: 0.25rem;
161 letter-spacing: 0.03em;
162 }
163 .reasoning .section-label { color: var(--blush); }
164
165 /* Tool calls */
166 .tool-calls { margin-top: 0.5rem; display: flex; flex-direction: column; gap: 2px; }
167 .tool-call {
168 background: var(--bg);
169 border-radius: 4px;
170 overflow: hidden;
171 }
172 .tool-call-header {
173 display: flex;
174 align-items: center;
175 gap: 0.5rem;
176 padding: 0.4rem 0.6rem;
177 cursor: pointer;
178 user-select: none;
179 font-size: 0.8rem;
180 }
181 .tool-call-header:hover { background: var(--bg-tertiary); }
182 .tool-status {
183 font-size: 0.7rem;
184 width: 1rem;
185 }
186 .tool-call.completed .tool-status { color: var(--julep); }
187 .tool-call.pending .tool-status { color: var(--squid); }
188 .tool-call-name { font-family: monospace; font-weight: 600; color: var(--malibu); }
189 .tool-call-preview {
190 flex: 1;
191 color: var(--squid);
192 font-size: 0.75rem;
193 white-space: nowrap;
194 overflow: hidden;
195 text-overflow: ellipsis;
196 }
197 .tool-call-toggle {
198 color: var(--text-muted);
199 font-size: 0.6rem;
200 transition: transform 0.15s;
201 margin-left: auto;
202 }
203 .tool-call.expanded .tool-call-toggle { transform: rotate(90deg); }
204 .tool-call-body { display: none; padding: 0.5rem 0.6rem; border-top: 1px solid var(--border); }
205 .tool-call.expanded .tool-call-body { display: block; }
206 .tool-args { margin-bottom: 0.5rem; }
207 .tool-result {
208 border-top: 1px solid var(--border);
209 padding-top: 0.5rem;
210 margin-top: 0.5rem;
211 }
212 .tool-result-content {
213 background: var(--bg-secondary);
214 padding: 0.5rem;
215 border-radius: 4px;
216 font-size: 0.75rem;
217 font-family: 'SF Mono', Monaco, 'Courier New', monospace;
218 white-space: pre-wrap;
219 word-wrap: break-word;
220 max-height: 200px;
221 overflow-y: auto;
222 color: var(--smoke);
223 }
224 .tool-pending {
225 color: var(--squid);
226 font-size: 0.75rem;
227 font-style: italic;
228 padding: 0.25rem 0;
229 }
230 pre {
231 background: var(--bg-tertiary);
232 padding: 0.5rem;
233 border-radius: 3px;
234 overflow-x: auto;
235 font-size: 0.75rem;
236 font-family: 'SF Mono', Monaco, 'Courier New', monospace;
237 max-height: 200px;
238 overflow-y: auto;
239 }
240
241 footer {
242 margin-top: 1.5rem;
243 padding-top: 0.75rem;
244 border-top: 1px solid var(--border);
245 text-align: center;
246 color: var(--text-muted);
247 font-size: 0.7rem;
248 }
249 footer a { color: var(--charple); text-decoration: none; }
250 footer a:hover { text-decoration: underline; }
251
252 /* Controls */
253 .controls {
254 display: flex;
255 gap: 0.5rem;
256 margin-bottom: 0.75rem;
257 }
258 .btn {
259 font-size: 0.7rem;
260 padding: 0.3rem 0.6rem;
261 border-radius: 4px;
262 border: 1px solid var(--border);
263 background: var(--bg-secondary);
264 color: var(--text);
265 cursor: pointer;
266 transition: all 0.15s;
267 }
268 .btn:hover { background: var(--bg-tertiary); border-color: var(--oyster); }
269 .filter-group { display: flex; gap: 0.25rem; }
270 .filter-btn { border-radius: 3px; }
271 .filter-btn.active { background: var(--charple); border-color: var(--charple); color: var(--butter); }
272 </style>
273</head>
274<body>
275 <div class="container">
276 <header>
277 <h1 id="title"></h1>
278 <div class="meta">
279 <span id="session-id"></span>
280 <span id="schema-version"></span>
281 <span id="model-name"></span>
282 </div>
283 <div class="metrics" id="metrics"></div>
284 </header>
285 <div class="controls">
286 <button class="btn" onclick="expandAll()">Expand All</button>
287 <button class="btn" onclick="collapseAll()">Collapse All</button>
288 <div class="filter-group">
289 <button class="btn filter-btn active" data-filter="all" onclick="filterSteps('all', this)">All</button>
290 <button class="btn filter-btn" data-filter="user" onclick="filterSteps('user', this)">User</button>
291 <button class="btn filter-btn" data-filter="agent" onclick="filterSteps('agent', this)">Agent</button>
292 <button class="btn filter-btn" data-filter="system" onclick="filterSteps('system', this)">System</button>
293 </div>
294 </div>
295 <div class="timeline" id="timeline"></div>
296 <footer>
297 Generated by <a href="https://github.com/charmbracelet/crush">Crush</a> · Harbor ATIF v1.4
298 </footer>
299 </div>
300
301 <script>
302 const trajectory = {{.TrajectoryJSON}};
303
304 function formatTimestamp(ts) {
305 if (!ts) return '';
306 const d = new Date(ts);
307 return d.toLocaleTimeString([], {hour: '2-digit', minute: '2-digit'});
308 }
309
310 function escapeHtml(text) {
311 if (!text) return '';
312 const div = document.createElement('div');
313 div.textContent = text;
314 return div.innerHTML;
315 }
316
317 function truncate(text, max = 100) {
318 if (!text) return '';
319 const single = text.replace(/\n/g, ' ').replace(/\s+/g, ' ').trim();
320 if (single.length <= max) return single;
321 return single.slice(0, max) + '…';
322 }
323
324 function renderHeader() {
325 document.getElementById('title').textContent = trajectory.agent.name + ' Trajectory';
326 document.getElementById('session-id').textContent = 'Session: ' + trajectory.session_id.slice(0, 20) + (trajectory.session_id.length > 20 ? '…' : '');
327 document.getElementById('schema-version').textContent = trajectory.schema_version;
328 if (trajectory.agent.model_name) {
329 document.getElementById('model-name').textContent = 'Model: ' + trajectory.agent.model_name;
330 }
331
332 const metrics = trajectory.final_metrics;
333 if (metrics) {
334 document.getElementById('metrics').innerHTML = `
335 <div class="metric">
336 <div class="metric-value">${metrics.total_steps || trajectory.steps.length}</div>
337 <div class="metric-label">Steps</div>
338 </div>
339 <div class="metric">
340 <div class="metric-value">${((metrics.total_prompt_tokens || 0) / 1000).toFixed(1)}k</div>
341 <div class="metric-label">Prompt</div>
342 </div>
343 <div class="metric">
344 <div class="metric-value">${((metrics.total_completion_tokens || 0) / 1000).toFixed(1)}k</div>
345 <div class="metric-label">Completion</div>
346 </div>
347 <div class="metric">
348 <div class="metric-value">$${(metrics.total_cost_usd || 0).toFixed(3)}</div>
349 <div class="metric-label">Cost</div>
350 </div>
351 `;
352 }
353 }
354
355 function renderToolCall(tc, idx, observationResults) {
356 const argsJson = typeof tc.arguments === 'string'
357 ? tc.arguments
358 : JSON.stringify(tc.arguments, null, 2);
359
360 // Extract key params for header preview
361 const args = typeof tc.arguments === 'object' ? tc.arguments : {};
362 const preview = getToolPreview(tc.function_name, args);
363
364 // Find the matching result for this tool call
365 const result = (observationResults || []).find(r => r.source_call_id === tc.tool_call_id);
366
367 const resultHtml = result ? `
368 <div class="tool-result">
369 <div class="tool-result-content">${escapeHtml(result.content)}</div>
370 </div>
371 ` : '<div class="tool-pending">Waiting for result...</div>';
372
373 return `
374 <div class="tool-call ${result ? 'completed' : 'pending'}" id="tc-${idx}">
375 <div class="tool-call-header" onclick="toggleToolCall('tc-${idx}')">
376 <span class="tool-status">${result ? '✓' : '○'}</span>
377 <span class="tool-call-name">${escapeHtml(tc.function_name)}</span>
378 <span class="tool-call-preview">${escapeHtml(preview)}</span>
379 <span class="tool-call-toggle">▶</span>
380 </div>
381 <div class="tool-call-body">
382 <div class="tool-args">
383 <div class="section-label">Arguments</div>
384 <pre>${escapeHtml(argsJson)}</pre>
385 </div>
386 ${resultHtml}
387 </div>
388 </div>
389 `;
390 }
391
392 function getToolPreview(name, args) {
393 // Extract key params like the TUI does
394 switch(name) {
395 case 'bash':
396 return truncate(args.command || '', 60);
397 case 'view':
398 case 'edit':
399 case 'write':
400 case 'multiedit':
401 return args.file_path || '';
402 case 'glob':
403 return args.pattern || '';
404 case 'grep':
405 return `${args.pattern || ''}${args.path ? ' in ' + args.path : ''}`;
406 case 'ls':
407 return args.path || '.';
408 case 'fetch':
409 case 'agentic_fetch':
410 case 'download':
411 return args.url || '';
412 case 'sourcegraph':
413 return args.query || '';
414 case 'todos':
415 const todos = args.todos || [];
416 const completed = todos.filter(t => t.status === 'completed').length;
417 return `${completed}/${todos.length}`;
418 case 'agent':
419 return truncate(args.prompt || '', 50);
420 default:
421 // Try to get first string value
422 for (const key of Object.keys(args)) {
423 if (typeof args[key] === 'string' && args[key].length < 80) {
424 return truncate(args[key], 60);
425 }
426 }
427 return '';
428 }
429 }
430
431 function renderStep(step, idx) {
432 const observationResults = step.observation?.results || [];
433 const toolCalls = (step.tool_calls || []).map((tc, i) => renderToolCall(tc, `${idx}-${i}`, observationResults)).join('');
434 const reasoning = step.reasoning_content ? `
435 <div class="reasoning">
436 <div class="section-label">Thinking</div>
437 ${escapeHtml(step.reasoning_content)}
438 </div>
439 ` : '';
440
441 const badges = [];
442 if (step.tool_calls && step.tool_calls.length > 0) {
443 badges.push(`<span class="badge tools">${step.tool_calls.length} tool${step.tool_calls.length > 1 ? 's' : ''}</span>`);
444 }
445 if (step.reasoning_content) {
446 badges.push(`<span class="badge thinking">thinking</span>`);
447 }
448
449 const preview = step.message || (step.observation?.results?.[0]?.content) || '';
450
451 return `
452 <div class="step ${step.source}" data-source="${step.source}" id="step-${idx}">
453 <div class="step-header" onclick="toggleStep('step-${idx}')">
454 <span class="step-toggle">▶</span>
455 <span class="step-source">${step.source}</span>
456 <span class="step-id">#${step.step_id}</span>
457 <span class="step-preview">${escapeHtml(truncate(preview, 80))}</span>
458 <div class="step-badges">${badges.join('')}</div>
459 <span class="step-time">${formatTimestamp(step.timestamp)}</span>
460 </div>
461 <div class="step-body">
462 ${step.message ? `<div class="message">${escapeHtml(step.message)}</div>` : ''}
463 ${reasoning}
464 ${toolCalls ? '<div class="tool-calls">' + toolCalls + '</div>' : ''}
465 </div>
466 </div>
467 `;
468 }
469
470 function renderTimeline() {
471 const timeline = document.getElementById('timeline');
472 timeline.innerHTML = trajectory.steps.map((s, i) => renderStep(s, i)).join('');
473 }
474
475 function toggleStep(id) {
476 document.getElementById(id).classList.toggle('expanded');
477 }
478
479 function toggleToolCall(id) {
480 document.getElementById(id).classList.toggle('expanded');
481 }
482
483 function expandAll() {
484 document.querySelectorAll('.step').forEach(el => el.classList.add('expanded'));
485 }
486
487 function collapseAll() {
488 document.querySelectorAll('.step').forEach(el => el.classList.remove('expanded'));
489 document.querySelectorAll('.tool-call').forEach(el => el.classList.remove('expanded'));
490 }
491
492 function filterSteps(filter, btn) {
493 document.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active'));
494 btn.classList.add('active');
495 document.querySelectorAll('.step').forEach(el => {
496 if (filter === 'all' || el.dataset.source === filter) {
497 el.style.display = '';
498 } else {
499 el.style.display = 'none';
500 }
501 });
502 }
503
504 renderHeader();
505 renderTimeline();
506 </script>
507</body>
508</html>