@arcreflex/agent-transcripts 0.1.8 → 0.1.9

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,1096 @@
1
+ /**
2
+ * Render: intermediate transcript format → HTML
3
+ *
4
+ * Parallel to render.ts but outputs standalone HTML with:
5
+ * - Collapsible tool calls with input/result details
6
+ * - Collapsible thinking sections
7
+ * - Raw JSON toggle for each block
8
+ * - Inline styles (no external dependencies)
9
+ * - Terminal-inspired dark theme with amber accents
10
+ */
11
+
12
+ import type { Transcript, Message, ToolCall } from "./types.ts";
13
+ import { createHighlighter, type Highlighter } from "shiki";
14
+ import {
15
+ buildTree,
16
+ findLatestLeaf,
17
+ tracePath,
18
+ getFirstLine,
19
+ } from "./utils/tree.ts";
20
+ import { escapeHtml } from "./utils/html.ts";
21
+
22
+ // Lazy-loaded shiki highlighter
23
+ let highlighter: Highlighter | null = null;
24
+
25
+ async function getHighlighter(): Promise<Highlighter> {
26
+ if (!highlighter) {
27
+ highlighter = await createHighlighter({
28
+ themes: ["ayu-dark", "github-light"],
29
+ langs: ["markdown", "javascript", "typescript", "python", "bash", "json"],
30
+ });
31
+ }
32
+ return highlighter;
33
+ }
34
+
35
+ // ============================================================================
36
+ // Styles - Terminal Chronicle Theme
37
+ // ============================================================================
38
+
39
+ const STYLES = `
40
+ /* ============================================================================
41
+ Agent Transcripts - Terminal Chronicle Theme
42
+ Inspired by the Claude Code TUI: dark, focused, monospace-forward
43
+ ============================================================================ */
44
+
45
+ @import url('https://fonts.googleapis.com/css2?family=Berkeley+Mono:wght@400;500&family=IBM+Plex+Mono:wght@400;500;600&family=Inter:wght@400;500&display=swap');
46
+
47
+ :root {
48
+ /* Typography - Monospace primary, clean sans for body */
49
+ --font-mono: 'Berkeley Mono', 'IBM Plex Mono', 'JetBrains Mono', 'SF Mono', Consolas, monospace;
50
+ --font-body: 'Inter', -apple-system, BlinkMacSystemFont, system-ui, sans-serif;
51
+
52
+ /* Dark theme - Terminal aesthetic */
53
+ --bg: #0d0d0d;
54
+ --bg-elevated: #141414;
55
+ --bg-surface: #1a1a1a;
56
+ --fg: #e4e4e4;
57
+ --fg-secondary: #a3a3a3;
58
+ --muted: #666666;
59
+ --border: #2a2a2a;
60
+ --border-subtle: #222222;
61
+
62
+ /* Accent - Amber/Orange (Claude Code cursor vibe) */
63
+ --accent: #f59e0b;
64
+ --accent-dim: #b45309;
65
+ --accent-glow: rgba(245, 158, 11, 0.15);
66
+
67
+ /* Semantic colors */
68
+ --user-accent: #3b82f6;
69
+ --user-bg: rgba(59, 130, 246, 0.08);
70
+ --user-border: rgba(59, 130, 246, 0.3);
71
+ --assistant-bg: var(--bg-elevated);
72
+ --assistant-border: var(--border);
73
+ --system-accent: #8b5cf6;
74
+ --system-bg: rgba(139, 92, 246, 0.06);
75
+ --system-border: rgba(139, 92, 246, 0.25);
76
+ --error-accent: #ef4444;
77
+ --error-bg: rgba(239, 68, 68, 0.08);
78
+ --error-border: rgba(239, 68, 68, 0.3);
79
+ --tool-accent: #10b981;
80
+ --tool-bg: rgba(16, 185, 129, 0.06);
81
+ --tool-border: rgba(16, 185, 129, 0.2);
82
+
83
+ /* Code blocks */
84
+ --code-bg: #0f0f0f;
85
+ --code-border: #252525;
86
+ --thinking-bg: #111111;
87
+ --thinking-border: #1f1f1f;
88
+ --raw-bg: #0a0a0a;
89
+
90
+ /* Links */
91
+ --link: #60a5fa;
92
+ --link-hover: #93c5fd;
93
+
94
+ /* Shadows & effects */
95
+ --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
96
+ --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.4);
97
+ --glow: 0 0 20px var(--accent-glow);
98
+ }
99
+
100
+ /* Light theme - Minimal, paper-like */
101
+ @media (prefers-color-scheme: light) {
102
+ :root {
103
+ --bg: #fafafa;
104
+ --bg-elevated: #ffffff;
105
+ --bg-surface: #f5f5f5;
106
+ --fg: #171717;
107
+ --fg-secondary: #525252;
108
+ --muted: #a3a3a3;
109
+ --border: #e5e5e5;
110
+ --border-subtle: #f0f0f0;
111
+
112
+ --accent: #d97706;
113
+ --accent-dim: #92400e;
114
+ --accent-glow: rgba(217, 119, 6, 0.1);
115
+
116
+ --user-accent: #2563eb;
117
+ --user-bg: rgba(37, 99, 235, 0.04);
118
+ --user-border: rgba(37, 99, 235, 0.2);
119
+ --assistant-bg: var(--bg-elevated);
120
+ --assistant-border: var(--border);
121
+ --system-accent: #7c3aed;
122
+ --system-bg: rgba(124, 58, 237, 0.04);
123
+ --system-border: rgba(124, 58, 237, 0.15);
124
+ --error-accent: #dc2626;
125
+ --error-bg: rgba(220, 38, 38, 0.04);
126
+ --error-border: rgba(220, 38, 38, 0.2);
127
+ --tool-accent: #059669;
128
+ --tool-bg: rgba(5, 150, 105, 0.04);
129
+ --tool-border: rgba(5, 150, 105, 0.15);
130
+
131
+ --code-bg: #f5f5f5;
132
+ --code-border: #e5e5e5;
133
+ --thinking-bg: #fafafa;
134
+ --thinking-border: #e5e5e5;
135
+ --raw-bg: #f0f0f0;
136
+
137
+ --link: #2563eb;
138
+ --link-hover: #1d4ed8;
139
+
140
+ --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
141
+ --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.08);
142
+ --glow: none;
143
+ }
144
+ }
145
+
146
+ *, *::before, *::after { box-sizing: border-box; }
147
+
148
+ html {
149
+ font-size: 15px;
150
+ -webkit-font-smoothing: antialiased;
151
+ -moz-osx-font-smoothing: grayscale;
152
+ }
153
+
154
+ body {
155
+ font-family: var(--font-body);
156
+ background: var(--bg);
157
+ color: var(--fg);
158
+ line-height: 1.65;
159
+ margin: 0;
160
+ padding: 0;
161
+ min-height: 100vh;
162
+ }
163
+
164
+ /* Main container */
165
+ .transcript-container {
166
+ max-width: 54rem;
167
+ margin: 0 auto;
168
+ padding: 2.5rem 2rem 4rem;
169
+ position: relative;
170
+ }
171
+
172
+ /* Subtle left border accent */
173
+ .transcript-container::before {
174
+ content: '';
175
+ position: fixed;
176
+ left: 0;
177
+ top: 0;
178
+ bottom: 0;
179
+ width: 2px;
180
+ background: linear-gradient(
181
+ 180deg,
182
+ transparent 0%,
183
+ var(--accent-dim) 15%,
184
+ var(--accent) 50%,
185
+ var(--accent-dim) 85%,
186
+ transparent 100%
187
+ );
188
+ opacity: 0.6;
189
+ }
190
+
191
+ a {
192
+ color: var(--link);
193
+ text-decoration: none;
194
+ transition: color 0.15s ease;
195
+ }
196
+
197
+ a:hover {
198
+ color: var(--link-hover);
199
+ }
200
+
201
+ /* ============================================================================
202
+ Header - Terminal prompt style
203
+ ============================================================================ */
204
+
205
+ header {
206
+ margin-bottom: 2.5rem;
207
+ padding-bottom: 1.5rem;
208
+ border-bottom: 1px solid var(--border);
209
+ }
210
+
211
+ header h1 {
212
+ font-family: var(--font-mono);
213
+ font-weight: 500;
214
+ font-size: 1.125rem;
215
+ line-height: 1.4;
216
+ margin: 0 0 0.75rem 0;
217
+ color: var(--fg);
218
+ display: flex;
219
+ align-items: baseline;
220
+ gap: 0.5rem;
221
+ flex-wrap: wrap;
222
+ }
223
+
224
+ header h1::before {
225
+ content: '>';
226
+ color: var(--accent);
227
+ font-weight: 600;
228
+ }
229
+
230
+ .meta {
231
+ font-family: var(--font-mono);
232
+ color: var(--muted);
233
+ font-size: 0.75rem;
234
+ line-height: 1.7;
235
+ display: flex;
236
+ flex-direction: column;
237
+ gap: 0.25rem;
238
+ }
239
+
240
+ .meta code {
241
+ color: var(--fg-secondary);
242
+ background: none;
243
+ padding: 0;
244
+ font-size: inherit;
245
+ }
246
+
247
+ .warnings {
248
+ background: var(--error-bg);
249
+ border: 1px solid var(--error-border);
250
+ border-left: 3px solid var(--error-accent);
251
+ padding: 0.875rem 1rem;
252
+ border-radius: 0 4px 4px 0;
253
+ margin-top: 1.25rem;
254
+ font-family: var(--font-mono);
255
+ font-size: 0.8rem;
256
+ }
257
+
258
+ .warnings strong {
259
+ color: var(--error-accent);
260
+ }
261
+
262
+ .warnings ul {
263
+ margin: 0.5rem 0 0 0;
264
+ padding-left: 1.25rem;
265
+ color: var(--fg-secondary);
266
+ }
267
+
268
+ .warnings li {
269
+ margin: 0.25rem 0;
270
+ }
271
+
272
+ /* ============================================================================
273
+ Messages
274
+ ============================================================================ */
275
+
276
+ main {
277
+ position: relative;
278
+ }
279
+
280
+ .message {
281
+ margin: 1.25rem 0;
282
+ position: relative;
283
+ }
284
+
285
+ /* User messages - boxed with background, extended to align content with assistant */
286
+ .message.user {
287
+ padding: 1rem 1.25rem;
288
+ margin-left: -1.25rem;
289
+ margin-right: -1.25rem;
290
+ border-radius: 6px;
291
+ background: var(--user-bg);
292
+ border: 1px solid var(--user-border);
293
+ }
294
+
295
+ .message-header {
296
+ display: flex;
297
+ justify-content: space-between;
298
+ align-items: center;
299
+ margin-bottom: 0.625rem;
300
+ }
301
+
302
+ .message-label {
303
+ font-family: var(--font-mono);
304
+ font-weight: 500;
305
+ font-size: 0.6875rem;
306
+ text-transform: uppercase;
307
+ letter-spacing: 0.1em;
308
+ display: flex;
309
+ align-items: center;
310
+ gap: 0.5rem;
311
+ }
312
+
313
+ /* Role indicator dot */
314
+ .message-label::before {
315
+ content: '';
316
+ display: inline-block;
317
+ width: 6px;
318
+ height: 6px;
319
+ border-radius: 50%;
320
+ background: currentColor;
321
+ }
322
+
323
+ .message.user .message-label {
324
+ color: var(--user-accent);
325
+ }
326
+
327
+ .message.user .message-label::before {
328
+ background: var(--user-accent);
329
+ box-shadow: 0 0 6px var(--user-accent);
330
+ }
331
+
332
+ /* Assistant messages - no box, flows on page */
333
+ .message.assistant {
334
+ padding: 0;
335
+ background: transparent;
336
+ border: none;
337
+ }
338
+
339
+ .message.assistant .message-header {
340
+ padding-bottom: 0.5rem;
341
+ border-bottom: 1px solid var(--border-subtle);
342
+ margin-bottom: 1rem;
343
+ }
344
+
345
+ .message.assistant .message-label {
346
+ color: var(--accent);
347
+ }
348
+
349
+ .message.assistant .message-label::before {
350
+ background: var(--accent);
351
+ box-shadow: 0 0 6px var(--accent);
352
+ }
353
+
354
+ /* System messages */
355
+ .message.system {
356
+ padding: 1rem 1.25rem;
357
+ border-radius: 6px;
358
+ background: var(--system-bg);
359
+ border: 1px solid var(--system-border);
360
+ }
361
+
362
+ .message.system .message-label {
363
+ color: var(--system-accent);
364
+ }
365
+
366
+ .message.system .message-label::before {
367
+ background: var(--system-accent);
368
+ }
369
+
370
+ /* Error messages */
371
+ .message.error {
372
+ padding: 1rem 1.25rem;
373
+ border-radius: 6px;
374
+ background: var(--error-bg);
375
+ border: 1px solid var(--error-border);
376
+ }
377
+
378
+ .message.error .message-label {
379
+ color: var(--error-accent);
380
+ }
381
+
382
+ .message.error .message-label::before {
383
+ background: var(--error-accent);
384
+ box-shadow: 0 0 6px var(--error-accent);
385
+ }
386
+
387
+ /* ============================================================================
388
+ Content
389
+ ============================================================================ */
390
+
391
+ .content {
392
+ font-size: 0.9375rem;
393
+ line-height: 1.7;
394
+ color: var(--fg);
395
+ }
396
+
397
+ /* Shiki syntax highlighting - dual theme support */
398
+ .content .shiki,
399
+ .thinking .shiki {
400
+ background: transparent !important;
401
+ border: none;
402
+ padding: 0;
403
+ margin: 0;
404
+ }
405
+
406
+ .content .shiki span,
407
+ .thinking .shiki span {
408
+ color: var(--shiki-light);
409
+ }
410
+
411
+ @media (prefers-color-scheme: dark) {
412
+ .content .shiki span,
413
+ .thinking .shiki span {
414
+ color: var(--shiki-dark);
415
+ }
416
+ }
417
+
418
+ .content p {
419
+ margin: 0 0 0.875rem 0;
420
+ }
421
+
422
+ .content p:last-child {
423
+ margin-bottom: 0;
424
+ }
425
+
426
+ .content ul, .content ol {
427
+ margin: 0 0 0.875rem 0;
428
+ padding-left: 1.5rem;
429
+ }
430
+
431
+ .content li {
432
+ margin: 0.25rem 0;
433
+ }
434
+
435
+ /* ============================================================================
436
+ Code - Primary visual element
437
+ ============================================================================ */
438
+
439
+ pre, code {
440
+ font-family: var(--font-mono);
441
+ font-size: 0.8125rem;
442
+ }
443
+
444
+ code {
445
+ background: var(--code-bg);
446
+ border: 1px solid var(--code-border);
447
+ padding: 0.125rem 0.375rem;
448
+ border-radius: 3px;
449
+ color: var(--fg);
450
+ }
451
+
452
+ pre {
453
+ background: var(--code-bg);
454
+ border: 1px solid var(--code-border);
455
+ padding: 1rem 1.25rem;
456
+ border-radius: 4px;
457
+ overflow-x: auto;
458
+ margin: 0.875rem 0;
459
+ line-height: 1.5;
460
+ }
461
+
462
+ pre code {
463
+ background: none;
464
+ border: none;
465
+ padding: 0;
466
+ border-radius: 0;
467
+ }
468
+
469
+ /* ============================================================================
470
+ Details & Thinking
471
+ ============================================================================ */
472
+
473
+ details {
474
+ margin: 0.625rem 0;
475
+ }
476
+
477
+ summary {
478
+ cursor: pointer;
479
+ user-select: none;
480
+ font-family: var(--font-mono);
481
+ font-size: 0.75rem;
482
+ color: var(--muted);
483
+ padding: 0.25rem 0;
484
+ display: inline-flex;
485
+ align-items: center;
486
+ gap: 0.375rem;
487
+ transition: color 0.15s ease;
488
+ }
489
+
490
+ summary:hover {
491
+ color: var(--fg-secondary);
492
+ }
493
+
494
+ summary::marker,
495
+ summary::-webkit-details-marker {
496
+ color: var(--accent-dim);
497
+ }
498
+
499
+ .thinking {
500
+ border-left: 2px solid var(--accent-dim);
501
+ padding: 0.5rem 0 0.5rem 1rem;
502
+ margin: 0.75rem 0;
503
+ font-size: 0.8125rem;
504
+ color: var(--fg-secondary);
505
+ font-style: italic;
506
+ line-height: 1.65;
507
+ }
508
+
509
+ /* ============================================================================
510
+ Tool Calls - Inline command style
511
+ ============================================================================ */
512
+
513
+ .tool-calls {
514
+ margin: 0.75rem 0;
515
+ position: relative;
516
+ }
517
+
518
+ .tool-call {
519
+ margin: 0.25rem 0;
520
+ }
521
+
522
+ details.tool-call {
523
+ margin: 0.25rem 0;
524
+ }
525
+
526
+ details.tool-call > summary {
527
+ cursor: pointer;
528
+ list-style: none;
529
+ }
530
+
531
+ details.tool-call > summary::-webkit-details-marker {
532
+ display: none;
533
+ }
534
+
535
+ details.tool-call > summary::marker {
536
+ display: none;
537
+ }
538
+
539
+ .tool-call-header {
540
+ font-family: var(--font-mono);
541
+ font-size: 0.8125rem;
542
+ line-height: 1.5;
543
+ color: var(--tool-accent);
544
+ display: inline-flex;
545
+ align-items: baseline;
546
+ gap: 0;
547
+ }
548
+
549
+ details.tool-call > .tool-call-header::before {
550
+ content: '▸';
551
+ margin-right: 0.5rem;
552
+ font-size: 0.625rem;
553
+ transition: transform 0.15s ease;
554
+ }
555
+
556
+ details.tool-call[open] > .tool-call-header::before {
557
+ transform: rotate(90deg);
558
+ }
559
+
560
+ .tool-call-name {
561
+ font-weight: 500;
562
+ }
563
+
564
+ .tool-call-summary {
565
+ color: var(--muted);
566
+ margin-left: 0.5rem;
567
+ }
568
+
569
+ .tool-call-error {
570
+ color: var(--error-accent);
571
+ margin-left: 0.5rem;
572
+ font-weight: 500;
573
+ }
574
+
575
+ .tool-detail-content {
576
+ margin-top: 0.375rem;
577
+ padding: 0.75rem 1rem;
578
+ background: var(--code-bg);
579
+ border: 1px solid var(--code-border);
580
+ border-radius: 3px;
581
+ font-family: var(--font-mono);
582
+ font-size: 0.6875rem;
583
+ white-space: pre-wrap;
584
+ word-wrap: break-word;
585
+ max-height: 16rem;
586
+ overflow-y: auto;
587
+ line-height: 1.5;
588
+ }
589
+
590
+ /* ============================================================================
591
+ Branch Notes
592
+ ============================================================================ */
593
+
594
+ .branch-note {
595
+ background: var(--bg-surface);
596
+ border: 1px solid var(--border);
597
+ border-left: 2px solid var(--accent);
598
+ padding: 0.875rem 1rem;
599
+ border-radius: 0 4px 4px 0;
600
+ margin: 1.25rem 0;
601
+ font-family: var(--font-mono);
602
+ font-size: 0.75rem;
603
+ color: var(--fg-secondary);
604
+ }
605
+
606
+ .branch-note strong {
607
+ color: var(--fg);
608
+ }
609
+
610
+ .branch-note ul {
611
+ margin: 0.375rem 0 0 0;
612
+ padding-left: 1.25rem;
613
+ }
614
+
615
+ .branch-note code {
616
+ font-size: 0.6875rem;
617
+ }
618
+
619
+ /* ============================================================================
620
+ Raw JSON Toggle - appears on hover/focus
621
+ ============================================================================ */
622
+
623
+ .raw-toggle {
624
+ position: absolute;
625
+ top: 0.2rem;
626
+ left: -2rem;
627
+ background: none;
628
+ border: none;
629
+ padding: 0.25rem;
630
+ font-size: 0.625rem;
631
+ color: var(--muted);
632
+ cursor: pointer;
633
+ font-family: var(--font-mono);
634
+ font-weight: 500;
635
+ opacity: 0;
636
+ transition: opacity 0.15s ease, color 0.15s ease;
637
+ }
638
+
639
+ /* Show on hover or when parent has focus-within */
640
+ .message:hover .raw-toggle,
641
+ .message:focus-within .raw-toggle,
642
+ .tool-calls:hover .raw-toggle,
643
+ .tool-calls:focus-within .raw-toggle,
644
+ .raw-toggle:focus {
645
+ opacity: 1;
646
+ }
647
+
648
+ .raw-toggle:hover {
649
+ color: var(--fg);
650
+ }
651
+
652
+ .raw-toggle.active {
653
+ opacity: 1;
654
+ color: var(--accent);
655
+ }
656
+
657
+ .raw-view {
658
+ display: none;
659
+ margin-top: 0.75rem;
660
+ padding: 0.875rem 1rem;
661
+ background: var(--raw-bg);
662
+ border: 1px solid var(--code-border);
663
+ border-radius: 3px;
664
+ font-family: var(--font-mono);
665
+ font-size: 0.625rem;
666
+ white-space: pre-wrap;
667
+ word-wrap: break-word;
668
+ max-height: 20rem;
669
+ overflow-y: auto;
670
+ line-height: 1.5;
671
+ }
672
+
673
+ .raw-view.visible {
674
+ display: block;
675
+ }
676
+
677
+ .rendered-view.hidden {
678
+ display: none;
679
+ }
680
+
681
+ /* ============================================================================
682
+ Scrollbar
683
+ ============================================================================ */
684
+
685
+ ::-webkit-scrollbar {
686
+ width: 6px;
687
+ height: 6px;
688
+ }
689
+
690
+ ::-webkit-scrollbar-track {
691
+ background: var(--border-subtle);
692
+ }
693
+
694
+ ::-webkit-scrollbar-thumb {
695
+ background: var(--muted);
696
+ border-radius: 3px;
697
+ }
698
+
699
+ ::-webkit-scrollbar-thumb:hover {
700
+ background: var(--fg-secondary);
701
+ }
702
+
703
+ /* ============================================================================
704
+ Responsive
705
+ ============================================================================ */
706
+
707
+ @media (max-width: 640px) {
708
+ html {
709
+ font-size: 14px;
710
+ }
711
+
712
+ .transcript-container {
713
+ padding: 1.5rem 1rem 3rem;
714
+ }
715
+
716
+ .transcript-container::before {
717
+ display: none;
718
+ }
719
+
720
+ header h1 {
721
+ font-size: 1rem;
722
+ }
723
+
724
+ .message {
725
+ margin: 1rem 0;
726
+ }
727
+
728
+ .message.user {
729
+ padding: 0.875rem 1rem;
730
+ margin-left: -1rem;
731
+ margin-right: -1rem;
732
+ }
733
+
734
+ pre {
735
+ padding: 0.75rem 1rem;
736
+ }
737
+ }
738
+
739
+ /* ============================================================================
740
+ Print
741
+ ============================================================================ */
742
+
743
+ @media print {
744
+ body {
745
+ background: #fff;
746
+ color: #000;
747
+ }
748
+
749
+ .transcript-container::before {
750
+ display: none;
751
+ }
752
+
753
+ .raw-toggle {
754
+ display: none;
755
+ }
756
+
757
+ .message {
758
+ break-inside: avoid;
759
+ box-shadow: none;
760
+ border: 1px solid #ccc;
761
+ background: #fff;
762
+ }
763
+
764
+ a {
765
+ color: inherit;
766
+ }
767
+ }`;
768
+
769
+ // ============================================================================
770
+ // JavaScript for raw toggle
771
+ // ============================================================================
772
+
773
+ const SCRIPT = `
774
+ (function() {
775
+ document.querySelectorAll('.raw-toggle').forEach(function(btn) {
776
+ btn.addEventListener('click', function() {
777
+ const block = btn.closest('.message, .tool-calls');
778
+ const rawView = block.querySelector('.raw-view');
779
+ const renderedView = block.querySelector('.rendered-view');
780
+
781
+ if (rawView.classList.contains('visible')) {
782
+ rawView.classList.remove('visible');
783
+ renderedView.classList.remove('hidden');
784
+ btn.classList.remove('active');
785
+ } else {
786
+ rawView.classList.add('visible');
787
+ renderedView.classList.add('hidden');
788
+ btn.classList.add('active');
789
+ }
790
+ });
791
+ });
792
+ })();
793
+ `;
794
+
795
+ // ============================================================================
796
+ // HTML Utilities
797
+ // ============================================================================
798
+
799
+ /**
800
+ * Format JSON for display with indentation.
801
+ */
802
+ function formatJson(obj: unknown): string {
803
+ try {
804
+ return JSON.stringify(obj, null, 2);
805
+ } catch {
806
+ return String(obj);
807
+ }
808
+ }
809
+
810
+ /**
811
+ * Convert markdown content to syntax-highlighted HTML.
812
+ * Uses shiki to highlight markdown, preserving the raw markdown structure
813
+ * while making it visually distinct (code blocks, inline code, etc.).
814
+ */
815
+ async function contentToHtml(content: string): Promise<string> {
816
+ const hl = await getHighlighter();
817
+ const html = hl.codeToHtml(content, {
818
+ lang: "markdown",
819
+ themes: {
820
+ light: "github-light",
821
+ dark: "ayu-dark",
822
+ },
823
+ defaultColor: false, // Use CSS variables for theme switching
824
+ });
825
+
826
+ return html;
827
+ }
828
+
829
+ // ============================================================================
830
+ // Message Rendering
831
+ // ============================================================================
832
+
833
+ function renderToolCall(call: ToolCall): string {
834
+ let headerContent = `<span class="tool-call-name">${escapeHtml(call.name)}</span>`;
835
+ if (call.summary) {
836
+ headerContent += `<span class="tool-call-summary">${escapeHtml(call.summary)}</span>`;
837
+ }
838
+ if (call.error) {
839
+ headerContent += `<span class="tool-call-error">${escapeHtml(call.error)}</span>`;
840
+ }
841
+
842
+ if (call.result && call.result.length > 0) {
843
+ // Wrap in details/summary for expandable result
844
+ return `<details class="tool-call">
845
+ <summary class="tool-call-header">${headerContent}</summary>
846
+ <div class="tool-detail-content">${escapeHtml(call.result)}</div>
847
+ </details>`;
848
+ } else {
849
+ // No result, just show the header
850
+ return `<div class="tool-call">
851
+ <div class="tool-call-header">${headerContent}</div>
852
+ </div>`;
853
+ }
854
+ }
855
+
856
+ function renderRawToggle(): string {
857
+ return `<button class="raw-toggle" title="Toggle raw JSON">&lt;/&gt;</button>`;
858
+ }
859
+
860
+ interface RenderContext {
861
+ showAssistantHeader: boolean;
862
+ }
863
+
864
+ async function renderMessage(
865
+ msg: Message,
866
+ ctx: RenderContext,
867
+ ): Promise<string> {
868
+ const rawJson = msg.rawJson
869
+ ? escapeHtml(formatJson(JSON.parse(msg.rawJson)))
870
+ : "";
871
+
872
+ switch (msg.type) {
873
+ case "user":
874
+ return `
875
+ <div class="message user">
876
+ <div class="message-header">
877
+ <span class="message-label">User</span>
878
+ </div>
879
+ ${msg.rawJson ? renderRawToggle() : ""}
880
+ <div class="rendered-view">
881
+ <div class="content">${await contentToHtml(msg.content)}</div>
882
+ </div>
883
+ ${msg.rawJson ? `<div class="raw-view">${rawJson}</div>` : ""}
884
+ </div>`;
885
+
886
+ case "assistant": {
887
+ let rendered = "";
888
+
889
+ if (msg.thinking) {
890
+ rendered += `
891
+ <details>
892
+ <summary>thinking...</summary>
893
+ <div class="thinking">${await contentToHtml(msg.thinking)}</div>
894
+ </details>`;
895
+ }
896
+
897
+ if (msg.content.trim()) {
898
+ rendered += `
899
+ <div class="content">${await contentToHtml(msg.content)}</div>`;
900
+ }
901
+
902
+ const header = ctx.showAssistantHeader
903
+ ? `
904
+ <div class="message-header">
905
+ <span class="message-label">Assistant</span>
906
+ </div>`
907
+ : "";
908
+
909
+ return `
910
+ <div class="message assistant">${header}
911
+ ${msg.rawJson ? renderRawToggle() : ""}
912
+ <div class="rendered-view">${rendered}
913
+ </div>
914
+ ${msg.rawJson ? `<div class="raw-view">${rawJson}</div>` : ""}
915
+ </div>`;
916
+ }
917
+
918
+ case "system":
919
+ return `
920
+ <div class="message system">
921
+ <div class="message-header">
922
+ <span class="message-label">System</span>
923
+ </div>
924
+ ${msg.rawJson ? renderRawToggle() : ""}
925
+ <div class="rendered-view">
926
+ <div class="content"><pre>${escapeHtml(msg.content)}</pre></div>
927
+ </div>
928
+ ${msg.rawJson ? `<div class="raw-view">${rawJson}</div>` : ""}
929
+ </div>`;
930
+
931
+ case "tool_calls": {
932
+ return `
933
+ <div class="tool-calls">
934
+ ${msg.rawJson ? renderRawToggle() : ""}
935
+ <div class="rendered-view">
936
+ ${msg.calls.map(renderToolCall).join("\n ")}
937
+ </div>
938
+ ${msg.rawJson ? `<div class="raw-view">${rawJson}</div>` : ""}
939
+ </div>`;
940
+ }
941
+
942
+ case "error":
943
+ return `
944
+ <div class="message error">
945
+ <div class="message-header">
946
+ <span class="message-label">Error</span>
947
+ </div>
948
+ ${msg.rawJson ? renderRawToggle() : ""}
949
+ <div class="rendered-view">
950
+ <div class="content"><pre>${escapeHtml(msg.content)}</pre></div>
951
+ </div>
952
+ ${msg.rawJson ? `<div class="raw-view">${rawJson}</div>` : ""}
953
+ </div>`;
954
+
955
+ default:
956
+ return "";
957
+ }
958
+ }
959
+
960
+ // ============================================================================
961
+ // Main Renderer
962
+ // ============================================================================
963
+
964
+ export interface RenderHtmlOptions {
965
+ head?: string; // render branch ending at this message ID
966
+ title?: string; // page title (used in index linking)
967
+ }
968
+
969
+ /**
970
+ * Render transcript to standalone HTML.
971
+ */
972
+ export async function renderTranscriptHtml(
973
+ transcript: Transcript,
974
+ options: RenderHtmlOptions = {},
975
+ ): Promise<string> {
976
+ const { head, title } = options;
977
+
978
+ const pageTitle = title || `Transcript - ${transcript.source.file}`;
979
+
980
+ // Build header section
981
+ let headerHtml = `
982
+ <header>
983
+ <h1>${escapeHtml(pageTitle)}</h1>
984
+ <div class="meta">
985
+ <span>source: <code>${escapeHtml(transcript.source.file)}</code></span>
986
+ <span>adapter: ${escapeHtml(transcript.source.adapter)}</span>
987
+ </div>`;
988
+
989
+ if (transcript.metadata.warnings.length > 0) {
990
+ headerHtml += `
991
+ <div class="warnings">
992
+ <strong>! warnings</strong>
993
+ <ul>
994
+ ${transcript.metadata.warnings.map((w) => `<li>${escapeHtml(w.type)}: ${escapeHtml(w.detail)}</li>`).join("\n ")}
995
+ </ul>
996
+ </div>`;
997
+ }
998
+
999
+ headerHtml += "\n</header>";
1000
+
1001
+ // Build messages section
1002
+ let messagesHtml = "";
1003
+
1004
+ if (transcript.messages.length === 0) {
1005
+ messagesHtml = "<p><em>No messages in this transcript.</em></p>";
1006
+ } else {
1007
+ const { bySourceRef, children, parents } = buildTree(transcript.messages);
1008
+
1009
+ let target: string | undefined;
1010
+ if (head) {
1011
+ if (!bySourceRef.has(head)) {
1012
+ messagesHtml = `<p class="error">Message ID <code>${escapeHtml(head)}</code> not found</p>`;
1013
+ } else {
1014
+ target = head;
1015
+ }
1016
+ } else {
1017
+ target = findLatestLeaf(bySourceRef, children);
1018
+ }
1019
+
1020
+ if (target) {
1021
+ const path = tracePath(target, parents);
1022
+ const pathSet = new Set(path);
1023
+ let inAssistantTurn = false;
1024
+
1025
+ for (const sourceRef of path) {
1026
+ const msgs = bySourceRef.get(sourceRef);
1027
+ if (!msgs) continue;
1028
+
1029
+ for (const msg of msgs) {
1030
+ // Track when we enter/exit assistant turns
1031
+ const isAssistantContent =
1032
+ msg.type === "assistant" || msg.type === "tool_calls";
1033
+
1034
+ // Show header only at the START of an assistant turn (after user)
1035
+ const showAssistantHeader = isAssistantContent && !inAssistantTurn;
1036
+
1037
+ messagesHtml += await renderMessage(msg, { showAssistantHeader });
1038
+
1039
+ // Update turn state
1040
+ if (msg.type === "user") {
1041
+ inAssistantTurn = false;
1042
+ } else if (isAssistantContent) {
1043
+ inAssistantTurn = true;
1044
+ }
1045
+ }
1046
+
1047
+ // Branch notes
1048
+ if (!head) {
1049
+ const childSet = children.get(sourceRef);
1050
+ if (childSet && childSet.size > 1) {
1051
+ const otherBranches = [...childSet].filter((c) => !pathSet.has(c));
1052
+ if (otherBranches.length > 0) {
1053
+ messagesHtml += `
1054
+ <div class="branch-note">
1055
+ <strong>Other branches:</strong>
1056
+ <ul>
1057
+ ${otherBranches
1058
+ .map((branchRef) => {
1059
+ const branchMsgs = bySourceRef.get(branchRef);
1060
+ if (branchMsgs && branchMsgs.length > 0) {
1061
+ const firstLine = getFirstLine(branchMsgs[0]);
1062
+ return `<li><code>${escapeHtml(branchRef)}</code> "${escapeHtml(firstLine)}"</li>`;
1063
+ }
1064
+ return "";
1065
+ })
1066
+ .filter(Boolean)
1067
+ .join("\n ")}
1068
+ </ul>
1069
+ </div>`;
1070
+ }
1071
+ }
1072
+ }
1073
+ }
1074
+ }
1075
+ }
1076
+
1077
+ // Assemble full HTML
1078
+ return `<!DOCTYPE html>
1079
+ <html lang="en">
1080
+ <head>
1081
+ <meta charset="UTF-8">
1082
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
1083
+ <title>${escapeHtml(pageTitle)}</title>
1084
+ <style>${STYLES}</style>
1085
+ </head>
1086
+ <body>
1087
+ <div class="transcript-container">
1088
+ ${headerHtml}
1089
+ <main>
1090
+ ${messagesHtml}
1091
+ </main>
1092
+ </div>
1093
+ <script>${SCRIPT}</script>
1094
+ </body>
1095
+ </html>`;
1096
+ }