/** * Single-file HTML report renderer — pure function over {@link RenderInput}. * * Zero external dependencies: one document with embedded CSS and a few lines * of inline script (theme toggle + print expansion). Sections: KPI cards, * turn timeline, tool ranking, error-highlighted transcript, print-friendly. * * v1.0.0 extras: JSON syntax highlighting for tool arguments/results, * bilingual labels (`lang: 'en' | 'zh'`), native tooltips on charts, and an * error-jump anchor that links to the first failed tool result. * * @module dsh-session-export/render/html */ import type { ContentBlock } from '@deepseek-ai/dsh-llm' import type { RenderInput } from '../types.ts' import { asAssistant, asToolResult } from '../types.ts' import { formatDuration } from '../stats.ts' import { parseToolArguments, renderToolDiff } from './diff.ts' import { truncate } from '../util/truncate.ts' /** Report label language; English is the default. */ export type ReportLang = 'en' | 'zh' export interface HtmlRenderOptions { readonly argCharLimit: number readonly resultCharLimit: number /** UI label language for the report (default `'en'`). */ readonly lang?: ReportLang } export const defaultHtmlOptions: HtmlRenderOptions = { argCharLimit: 512, resultCharLimit: 2048, } function escapeHtml(text: string): string { return text .replaceAll('&', '&') .replaceAll('<', '<') .replaceAll('>', '>') .replaceAll('"', '"') } function fmtTime(epochMs: number): string { return new Date(epochMs).toISOString().replace('T', ' ').replace(/\.\d+Z$/, 'Z') } function fmtTokens(n: number): string { if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M` if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k` return String(n) } function id8(id: string): string { return id.replace(/^session-/, '').slice(0, 8) } // ─── i18n ──────────────────────────────────────────────────────────────────── type LabelKey = | 'title' | 'messages' | 'toolCalls' | 'tokensIn' | 'tokensOut' | 'duration' | 'turns' | 'cost' | 'failedNote' | 'sparkline' | 'turnTimeline' | 'turn' | 'toolCallsSection' | 'transcript' | 'user' | 'assistant' | 'toolResult' | 'result' | 'reasoning' | 'toolFailed' | 'jumpError' | 'footer' | 'theme' | 'tipMessages' | 'tipToolCalls' | 'tipTokens' | 'tipDuration' | 'tipTurns' | 'tipCost' | 'tipTurn' | 'tipSpark' | 'tipTool' | 'entriesCount' | 'matches' | 'copyLabel' | 'copiedLabel' | 'searchPlaceholder' | 'tocTop' | 'tocTimeline' | 'tocTools' | 'tocErrors' const LABELS: Record = { title: { en: 'DSH session {id} — transcript', zh: 'DSH 会话 {id} — 转录报告' }, messages: { en: 'Messages', zh: '消息' }, toolCalls: { en: 'Tool calls', zh: '工具调用' }, tokensIn: { en: 'Tokens in', zh: '输入 token' }, tokensOut: { en: 'Tokens out', zh: '输出 token' }, duration: { en: 'Duration', zh: '时长' }, turns: { en: 'Turns', zh: '轮次' }, cost: { en: 'Cost ≈', zh: '成本 ≈' }, failedNote: { en: '{n} failed', zh: '{n} 次失败' }, sparkline: { en: 'Output tokens per assistant message', zh: '每条助手消息的输出 token' }, turnTimeline: { en: 'Turn timeline', zh: '轮次时间轴' }, turn: { en: 'Turn {n}', zh: '第 {n} 轮' }, toolCallsSection: { en: 'Tool calls', zh: '工具调用' }, transcript: { en: 'Transcript', zh: '转录' }, user: { en: 'User', zh: '用户' }, assistant: { en: 'Assistant', zh: '助手' }, toolResult: { en: 'Tool result', zh: '工具结果' }, result: { en: 'result', zh: '结果' }, reasoning: { en: 'Reasoning', zh: '推理' }, toolFailed: { en: 'Tool failed:', zh: '工具失败:' }, jumpError: { en: '⚠ {n} failed · jump to first', zh: '⚠ {n} 次失败 · 跳到第一个' }, footer: { en: 'Generated by {g} at {t} — print to PDF for archival copies.', zh: '由 {g} 于 {t} 生成 — 打印为 PDF 可存档。', }, theme: { en: '◐ theme', zh: '◐ 主题' }, tipMessages: { en: 'Surface entries in log order', zh: '按日志顺序的表面条目数' }, tipToolCalls: { en: 'Tool calls issued by the assistant', zh: '助手发出的工具调用' }, tipTokens: { en: 'Sum of per-message usage records', zh: '按消息 usage 记录累加' }, tipDuration: { en: 'First entry → last entry, wall clock', zh: '首条 → 末条的墙钟时长' }, tipTurns: { en: 'User messages that started a turn', zh: '开启新一轮的用户消息数' }, tipCost: { en: 'Estimate from the configured price table', zh: '按配置价格表估算' }, tipTurn: { en: 'Turn {n} · {d}', zh: '第 {n} 轮 · {d}' }, tipSpark: { en: '#{n} · {v} tokens', zh: '第 {n} 条 · {v} token' }, tipTool: { en: '{n} calls', zh: '{n} 次调用' }, entriesCount: { en: '{n} entries', zh: '{n} 条' }, matches: { en: '{n} matches', zh: '{n} 条命中' }, copyLabel: { en: 'copy', zh: '复制' }, copiedLabel: { en: '✓ copied', zh: '✓ 已复制' }, searchPlaceholder: { en: 'Search transcript… (press /)', zh: '搜索转录…(按 / 聚焦)' }, tocTop: { en: 'Top', zh: '顶部' }, tocTimeline: { en: 'Timeline', zh: '时间轴' }, tocTools: { en: 'Tools', zh: '工具' }, tocErrors: { en: '{n} failed', zh: '{n} 次失败' }, } function t(key: LabelKey, lang: ReportLang): string { return LABELS[key][lang] } function tf(key: LabelKey, lang: ReportLang, vars: Readonly>): string { let out = LABELS[key][lang] for (const [name, value] of Object.entries(vars)) { out = out.replaceAll(`{${name}}`, String(value)) } return out } // ─── JSON syntax highlighting (pattern-based, no dependency) ───────────────── const JSON_TOKEN = /("(?:[^"\\]|\\.)*")(\s*:)?|-?\b\d+(?:\.\d+)?(?:[eE][+-]?\d+)?\b|\b(?:true|false)\b|\bnull\b/g /** True when the full text parses as JSON (highlight gate; truncated views still render). */ function looksLikeJson(text: string): boolean { if (text.length === 0) return false try { JSON.parse(text) return true } catch { return false } } /** Wrap JSON tokens in colored spans; every segment is HTML-escaped. */ function highlightJson(text: string): string { let out = '' let last = 0 for (const match of text.matchAll(JSON_TOKEN)) { const index = match.index ?? 0 const token = match[0] ?? '' out += escapeHtml(text.slice(last, index)) if (match[2] !== undefined) { out += `${escapeHtml(match[1] ?? '')}${escapeHtml(match[2])}` } else if (token.startsWith('"')) { out += `${escapeHtml(token)}` } else if (token === 'true' || token === 'false') { out += `${token}` } else if (token === 'null') { out += 'null' } else { out += `${escapeHtml(token)}` } last = index + token.length } out += escapeHtml(text.slice(last)) return out } /** Truncated, escaped code block — JSON gets token colors, everything else stays plain. */ function renderCode(raw: string, limit: number): string { const shown = truncate(raw, limit) return looksLikeJson(raw) ? `${highlightJson(shown)}` : `${escapeHtml(shown)}` } // ─── sections ──────────────────────────────────────────────────────────────── /** KPI card row: the numbers that describe the session. */ function renderKpiGrid(input: RenderInput, lang: ReportLang): string { const stats = input.stats const cards: Array<{ readonly label: LabelKey; readonly value: string; readonly note?: string; readonly tip: LabelKey }> = [ { label: 'messages', value: String(input.totals.messages), tip: 'tipMessages' }, { label: 'toolCalls', value: String(input.totals.toolCalls), note: stats !== undefined && stats.failedToolCalls > 0 ? tf('failedNote', lang, { n: stats.failedToolCalls }) : undefined, tip: 'tipToolCalls', }, { label: 'tokensIn', value: fmtTokens(input.totals.inputTokens), tip: 'tipTokens' }, { label: 'tokensOut', value: fmtTokens(input.totals.outputTokens), tip: 'tipTokens' }, { label: 'duration', value: stats?.durationMs !== undefined && stats.durationMs !== null ? formatDuration(stats.durationMs) : '—', tip: 'tipDuration', }, { label: 'turns', value: stats !== undefined ? String(stats.turns) : '—', tip: 'tipTurns' }, ] const cost = stats?.cost if (cost !== undefined) { cards.push({ label: 'cost', value: `${cost.currency}${cost.total >= 0.01 ? cost.total.toFixed(2) : cost.total.toFixed(4)}`, tip: 'tipCost', }) } const spark = stats !== undefined ? renderSparklineSvg(stats.perAssistantTokens, lang) : '' return [ '
', ...cards.map( ({ label, value, note, tip }) => `
${escapeHtml(t(label, lang))}
${escapeHtml(value)}${ note !== undefined ? ` ${escapeHtml(note)}` : '' }
`, ), spark !== '' ? `
${escapeHtml(t('sparkline', lang))}
${spark}
` : '', '
', ].join('\n') } /** Inline SVG bar sparkline: no JS, scales to card width, one tooltip per bar. */ function renderSparklineSvg(series: readonly number[], lang: ReportLang): string { if (series.length < 2) return '' const max = Math.max(...series) const W = 320 const H = 40 const gap = 2 const barWidth = Math.max(1, (W - gap * (series.length - 1)) / series.length) const bars = series .map((value, index) => { const h = max === 0 ? 1 : Math.max(1, (value / max) * (H - 2)) const x = index * (barWidth + gap) const y = H - h const tip = escapeHtml(tf('tipSpark', lang, { n: index + 1, v: value })) return `` }) .join('') return `${bars}` } /** A turn span: contiguous entries from one user message to the next (or EOF). */ interface TurnSpan { readonly from: number readonly to: number // inclusive readonly startTime: number readonly endTime: number } /** Shared turn boundaries for the timeline, the TOC, and the folded transcript. */ function computeTurnSpans(entries: RenderInput['entries']): TurnSpan[] { const spans: TurnSpan[] = [] let start = 0 for (let i = 1; i < entries.length; i += 1) { const message = entries[i]?.message if (message !== undefined && message.role === 'user' && message.source.kind !== 'tool') { spans.push({ from: start, to: i - 1, startTime: entries[start]?.time ?? 0, endTime: entries[i - 1]?.time ?? 0, }) start = i } } if (start < entries.length) { spans.push({ from: start, to: entries.length - 1, startTime: entries[start]?.time ?? 0, endTime: entries[entries.length - 1]?.time ?? 0, }) } return spans } function isEntryError(entry: RenderInput['entries'][number]): boolean { return ( entry.error !== undefined || (asToolResult(entry.message)?.content.some((block) => block.isError === true) ?? false) ) } /** Turn timeline: one horizontal bar per user→next-user span, colored by duration share. */ function renderTimeline(input: RenderInput, lang: ReportLang): string { const durationMs = input.stats?.durationMs const spans = computeTurnSpans(input.entries) if (spans.length < 2 || durationMs === undefined || durationMs === null) return '' const totalMs = durationMs const rows = spans.map((span, index) => { const ms = Math.max(0, span.endTime - span.startTime) const pct = totalMs === 0 ? 0 : (ms / totalMs) * 100 const hue = 200 - Math.min(160, (pct / 100) * 160) const tip = escapeHtml(tf('tipTurn', lang, { n: index + 1, d: formatDuration(ms) })) return `
${escapeHtml(tf('turn', lang, { n: index + 1 }))}
${escapeHtml(formatDuration(ms))}
` }) return `

${escapeHtml(t('turnTimeline', lang))}

${rows.join('')}
` } /** Stable anchor id for the tool-call block at entry seq / content index. */ function toolCallAnchor(seq: number, blockIndex: number): string { return `tc-${seq}-${blockIndex}` } /** * First transcript occurrence of each tool name, in entry order, as an * anchor id — the target the ranking bars link to (#7). */ function firstToolCallAnchors(input: RenderInput): Map { const first = new Map() for (const entry of input.entries) { const blocks = entry.message.content as readonly ContentBlock[] blocks.forEach((block, index) => { if (block.type === 'tool-call' && !first.has(block.name)) { first.set(block.name, toolCallAnchor(entry.seq, index)) } }) } return first } /** Tool ranking bars; each links to the first call of that tool (#7). */ function renderToolRanking(input: RenderInput, lang: ReportLang): string { const breakdown = input.stats?.toolBreakdown ?? [] if (breakdown.length === 0) return '' const first = firstToolCallAnchors(input) const max = breakdown[0]?.calls ?? 1 const rows = breakdown.slice(0, 8).map((tool) => { const pct = (tool.calls / max) * 100 const fail = tool.failures > 0 ? ` ${tool.failures}✗` : '' const tip = escapeHtml(`${tool.name} · ${tf('tipTool', lang, { n: tool.calls })}`) const link = first.get(tool.name) const name = link !== undefined ? `${escapeHtml(tool.name)}` : `${escapeHtml(tool.name)}` return `
${name}
${tool.calls}${fail}
` }) return `

${escapeHtml(t('toolCallsSection', lang))}

${rows.join('')}
` } function renderBlocks(blocks: readonly ContentBlock[], options: HtmlRenderOptions, seq = 0): string[] { const lang = options.lang ?? 'en' const parts: string[] = [] blocks.forEach((block, index) => { if (block.type === 'reasoning') { parts.push( `
${escapeHtml(t('reasoning', lang))}
${escapeHtml(block.text.trim())}
`, ) } else if (block.type === 'text') { parts.push(`
${escapeHtml(block.text.trim())}
`) } else if (block.type === 'tool-call') { const parsed = parseToolArguments(block.arguments) const diff = renderToolDiff(block.name, parsed) const id = ` id="${toolCallAnchor(seq, index)}"` if (diff) { parts.push( `
🔧 ${escapeHtml(block.name)}
${escapeHtml(truncate(diff, options.resultCharLimit))}
`, ) } else { parts.push( `
🔧 ${escapeHtml(block.name)}
${renderCode(block.arguments, options.argCharLimit)}
`, ) } } else if ('content' in block) { const inner = block.content .map((inner2) => (inner2.type === 'text' ? inner2.text : `(${JSON.stringify(inner2.type)})`)) .join('\n') parts.push( `
🧾 ${escapeHtml(t('result', lang))}
${renderCode(inner, options.resultCharLimit)}
`, ) } else { parts.push(`
(${escapeHtml(JSON.stringify(block.type))})
`) } }) return parts } function renderEntry(entry: RenderInput['entries'][number], options: HtmlRenderOptions, errorNo?: number): string { const lang = options.lang ?? 'en' const time = escapeHtml(fmtTime(entry.time)) const message = entry.message const anchor = errorNo !== undefined ? ` id="error-${errorNo}"` : '' // Order matches buildEntries: assistant, then tool-result, then user — // tool-result messages carry role 'user' with a tool source. if (message.role === 'assistant') { const assistant = asAssistant(message) const provenance = assistant !== undefined ? `${assistant.source.provider} / ${assistant.source.model}` : '' const usage = entry.usage !== undefined ? ` · ${fmtTokens(entry.usage.inputTokens)} in / ${fmtTokens(entry.usage.outputTokens)} out` : '' const body = renderBlocks(message.content, options, entry.seq).join('\n') return `
🤖 ${escapeHtml(t('assistant', lang))} ${escapeHtml(provenance)}${escapeHtml(usage)}
${body}
` } const toolResult = asToolResult(message) if (toolResult !== undefined) { const isError = entry.error !== undefined || toolResult.content.some((block) => block.isError === true) const errorLine = entry.error !== undefined ? `
${escapeHtml(t('toolFailed', lang))} ${escapeHtml(entry.error.name)} (${escapeHtml(entry.error.code)})
` : '' const body = renderBlocks(toolResult.content, options, entry.seq).join('\n') return `
🧾 ${escapeHtml(t('toolResult', lang))} ${escapeHtml(id8(String(toolResult.source.callId)))}${isError ? ' ⚠ ERROR' : ''}
${errorLine}${body}
` } const body = renderBlocks(message.content, options, entry.seq).join('\n') return `
👤 ${escapeHtml(t('user', lang))}
${body}
` } function renderFilterNote(input: RenderInput): string { if (input.filterNote === undefined) return '' return `
⚠ ${escapeHtml(input.filterNote)}
` } /** Fold the transcript into per-turn
sections (default open). */ function renderTurns(input: RenderInput, options: HtmlRenderOptions): string { const lang = options.lang ?? 'en' const spans = computeTurnSpans(input.entries) return spans .map((span, index) => { const slice = input.entries.slice(span.from, span.to + 1) const hasError = slice.some(isEntryError) const ms = Math.max(0, span.endTime - span.startTime) const body = slice .map((entry, offset) => renderEntry(entry, options, isEntryError(entry) ? errorNo(input, span.from + offset) : undefined)) .join('\n') const flag = hasError ? ' ⚠' : '' return `
${escapeHtml(tf('turn', lang, { n: index + 1 }))} ${escapeHtml(formatDuration(ms))} · ${escapeHtml(tf('entriesCount', lang, { n: slice.length }))}${flag}${body}
` }) .join('\n') } /** Stable error ordinal for an entry: 1-based position among error entries. */ function errorNo(input: RenderInput, entryIndex: number): number | undefined { let n = 0 for (let i = 0; i <= entryIndex; i += 1) { if (isEntryError(input.entries[i] as never)) n += 1 } return n === 0 || !isEntryError(input.entries[entryIndex] as never) ? undefined : n } /** Sticky toolbar: TOC chips (top/timeline/tools/turns/errors) + live search box. */ function renderToolbar(input: RenderInput, options: HtmlRenderOptions, errorCount: number): string { const lang = options.lang ?? 'en' const spans = computeTurnSpans(input.entries) const enough = input.entries.length >= 12 || spans.length >= 4 if (!enough) return '' const chips = [ `◉`, `⏱`, `🔧`, ...spans.map((_, index) => `T${index + 1}`), ] if (errorCount > 0) { chips.push(`⚠`) } return `` } const CSS = ` :root{color-scheme:light;--bg:#f6f7f9;--card:#fff;--ink:#1a1d21;--muted:#6b7280;--line:#e5e7eb;--accent:#2563eb;--fail:#dc2626;--bar:#93c5fd;--ok:#16a34a;--num:#b45309;--lit:#7c3aed} @media (prefers-color-scheme:dark){:root:not([data-theme=light]){color-scheme:dark;--bg:#111317;--card:#1a1d23;--ink:#e5e7eb;--muted:#9ca3af;--line:#2a2e35;--accent:#60a5fa;--fail:#f87171;--bar:#3b82f6;--ok:#4ade80;--num:#fbbf24;--lit:#a78bfa}} [data-theme=dark]{color-scheme:dark;--bg:#111317;--card:#1a1d23;--ink:#e5e7eb;--muted:#9ca3af;--line:#2a2e35;--accent:#60a5fa;--fail:#f87171;--bar:#3b82f6;--ok:#4ade80;--num:#fbbf24;--lit:#a78bfa} *{box-sizing:border-box} html{scroll-behavior:smooth} body{margin:0 auto;max-width:960px;padding:24px 20px 64px;font:15px/1.55 ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif;background:var(--bg);color:var(--ink)} h1{font-size:22px;margin:0 0 2px} h2{font-size:15px;margin:0 0 12px;color:var(--muted);text-transform:uppercase;letter-spacing:.06em} .meta{color:var(--muted);font-size:13px;margin-bottom:18px} .kpi-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(130px,1fr));gap:10px;margin:18px 0} .kpi{background:var(--card);border:1px solid var(--line);border-radius:10px;padding:12px 14px} .kpi-label{font-size:11px;text-transform:uppercase;letter-spacing:.06em;color:var(--muted)} .kpi-value{font-size:22px;font-weight:700;margin-top:2px} .kpi-note{font-size:12px;color:var(--fail);font-weight:400} .kpi-wide{grid-column:1/-1} .sparkline{width:100%;height:40px;margin-top:8px;fill:var(--accent);opacity:.75} .card{background:var(--card);border:1px solid var(--line);border-radius:10px;padding:16px 18px;margin:14px 0} .tl-row,.tool-row{display:flex;align-items:center;gap:10px;margin:6px 0} .tl-label,.tool-name{width:110px;flex:none;font-size:13px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} a.tool-name{color:inherit;text-decoration:none} a.tool-name:hover,a.tool-name:focus-visible{text-decoration:underline} a.tool-name:focus-visible{outline:2px solid currentColor;outline-offset:2px} .tl-track,.tool-track{flex:1;height:12px;background:var(--line);border-radius:6px;overflow:hidden} .tl-bar,.tool-bar{height:100%;border-radius:6px} .tool-bar{background:var(--accent);opacity:.8} .tl-dur,.tool-count{width:86px;flex:none;text-align:right;font-size:12px;color:var(--muted);font-variant-numeric:tabular-nums} .fail{color:var(--fail);font-weight:700} .jump-error{display:inline-block;margin:0 0 14px;color:var(--fail);text-decoration:none;border:1px solid var(--fail);border-radius:8px;padding:4px 10px;font-size:12px} .jump-error:hover{background:rgba(220,38,38,.1)} .entry{background:var(--card);border:1px solid var(--line);border-left:3px solid var(--line);border-radius:8px;padding:10px 14px;margin:10px 0} .entry.user{border-left-color:#10b981} .entry.assistant{border-left-color:var(--accent)} .entry.tool-result{border-left-color:var(--bar)} .entry.error{border-left-color:var(--fail);border-color:var(--fail)} .entry.error:target{box-shadow:0 0 0 3px rgba(220,38,38,.3)} .entry header{font-size:12px;color:var(--muted);margin-bottom:6px;display:flex;gap:8px;align-items:baseline;flex-wrap:wrap} .entry time{margin-left:auto;font-variant-numeric:tabular-nums} .prov{font-style:italic} .msg-text{white-space:pre-wrap} pre{background:rgba(127,127,127,.08);border-radius:8px;padding:10px 12px;overflow-x:auto;font-size:12.5px;line-height:1.5} pre.diff{font-family:ui-monospace,SFMono-Regular,Menlo,monospace} .hl-key{color:var(--accent);font-weight:600} .hl-str{color:var(--ok)} .hl-num{color:var(--num)} .hl-bool,.hl-null{color:var(--lit)} .hl-punc{color:var(--muted)} details{margin:6px 0} summary{cursor:pointer;font-size:13px;color:var(--muted)} details[open] summary{color:var(--ink)} .error-banner{background:rgba(220,38,38,.1);border:1px solid var(--fail);color:var(--fail);border-radius:8px;padding:8px 12px;margin:6px 0;font-size:13px} .filter-note{background:rgba(217,119,6,.12);border:1px solid #d97706;color:#b45309;border-radius:8px;padding:8px 12px;margin:12px 0;font-size:13px} .turn{border:1px solid var(--line);border-radius:10px;background:var(--card);margin:12px 0;padding:0 12px} .turn>summary{cursor:pointer;padding:10px 2px;font-size:13px;font-weight:600;color:var(--ink);user-select:none;list-style:none;display:flex;gap:10px;align-items:baseline;flex-wrap:wrap} .turn>summary::-webkit-details-marker{display:none} .turn>summary::before{content:'▸';color:var(--muted);transition:transform .12s} .turn[open]>summary::before{transform:rotate(90deg)} .turn-meta{font-weight:400;color:var(--muted);font-variant-numeric:tabular-nums} .toc{position:sticky;top:0;z-index:8;display:flex;align-items:center;gap:6px;flex-wrap:nowrap;overflow-x:auto;background:var(--bg);border-bottom:1px solid var(--line);padding:8px 2px;margin:0 0 14px;scrollbar-width:thin} .toc a{flex:none;font-size:12px;color:var(--muted);text-decoration:none;border:1px solid var(--line);border-radius:6px;padding:2px 8px;background:var(--card)} .toc a:hover{color:var(--accent);border-color:var(--accent)} .toc a.err{color:var(--fail);border-color:var(--fail)} .toc-search{margin-left:auto;flex:none;display:flex;align-items:center;gap:6px} .search-box{font:inherit;font-size:12px;padding:3px 8px;border:1px solid var(--line);border-radius:6px;background:var(--card);color:var(--ink);width:180px} .search-box:focus{outline:1.5px solid var(--accent);outline-offset:0} .search-count{font-size:11px;color:var(--muted);min-width:52px;font-variant-numeric:tabular-nums} .code-wrap{position:relative} .code-wrap pre{margin:0} .copy{position:absolute;top:6px;right:6px;font-size:11px;padding:2px 8px;border:1px solid var(--line);border-radius:6px;background:var(--card);color:var(--muted);cursor:pointer;opacity:0;transition:opacity .12s} .code-wrap:hover .copy,details[open] .copy:focus{opacity:1} .copy:hover{color:var(--accent);border-color:var(--accent)} .hidden{display:none} footer{margin-top:28px;color:var(--muted);font-size:12px} .theme-toggle{position:fixed;top:14px;right:16px;z-index:9;background:var(--card);color:var(--ink);border:1px solid var(--line);border-radius:8px;padding:5px 10px;cursor:pointer;font-size:12px} [data-theme=light]{color-scheme:light} [data-theme=dark]{color-scheme:dark} @media print{.theme-toggle,.jump-error,.toc,.copy{display:none}body{background:#fff;color:#000;max-width:100%}.entry,.card,.kpi{break-inside:avoid;border-color:#bbb}pre{background:#f3f4f6}} ` /** Render the complete single-file HTML report. */ export function renderHtml(input: RenderInput, options?: Partial): string { const opts = { ...defaultHtmlOptions, ...options } const lang = opts.lang ?? 'en' const h = input.header const errorCount = input.entries.filter(isEntryError).length const turns = renderTurns(input, opts) const toolbar = renderToolbar(input, opts, errorCount) const jump = errorCount > 0 ? `${escapeHtml(tf('jumpError', lang, { n: errorCount }))}` : '' const title = tf('title', lang, { id: id8(h.id) }) return ` ${escapeHtml(title)}

${escapeHtml(title)}

${escapeHtml(h.cwd ?? '')} · ${escapeHtml(h.createdAt ? fmtTime(h.createdAt) : '?')}${h.agentPreset !== undefined ? ` · ${escapeHtml(h.agentPreset)}` : ''}
${renderFilterNote(input)} ${renderKpiGrid(input, lang)} ${jump} ${toolbar} ${renderTimeline(input, lang)} ${renderToolRanking(input, lang)}

${escapeHtml(t('transcript', lang))}

${turns}
${escapeHtml(tf('footer', lang, { g: input.generator, t: fmtTime(input.generatedAt) }))}
` }