pub const VIEWER_HTML: &str = "<!doctype html>\n<html lang=\"en\">\n <head>\n <meta charset=\"UTF-8\" />\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />\n <title>ACP Trace Viewer</title>\n <style>\n * {\n box-sizing: border-box;\n margin: 0;\n padding: 0;\n }\n\n body {\n font-family:\n -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto,\n \"Helvetica Neue\", Arial, sans-serif;\n background: #1e1e1e;\n color: #d4d4d4;\n height: 100vh;\n display: flex;\n flex-direction: column;\n }\n\n header {\n background: #252526;\n padding: 12px 20px;\n border-bottom: 1px solid #3c3c3c;\n display: flex;\n align-items: center;\n gap: 20px;\n }\n\n header h1 {\n font-size: 16px;\n font-weight: 500;\n color: #cccccc;\n }\n\n .controls {\n display: flex;\n gap: 12px;\n align-items: center;\n }\n\n .controls label {\n font-size: 13px;\n color: #9d9d9d;\n display: flex;\n align-items: center;\n gap: 6px;\n }\n\n .controls input[type=\"checkbox\"] {\n accent-color: #0078d4;\n }\n\n .main-container {\n display: flex;\n flex-direction: column;\n flex: 1;\n overflow: hidden;\n }\n\n #diagram-container {\n flex: 1;\n overflow: auto;\n padding: 20px;\n }\n\n #diagram {\n min-width: 100%;\n }\n\n #detail-panel {\n height: 200px;\n background: #252526;\n border-top: 1px solid #3c3c3c;\n display: none;\n flex-direction: column;\n flex-shrink: 0;\n position: relative;\n }\n\n #resize-handle {\n position: absolute;\n top: 0;\n left: 0;\n right: 0;\n height: 6px;\n cursor: ns-resize;\n background: transparent;\n }\n\n #resize-handle:hover {\n background: #0078d4;\n }\n\n #detail-panel.visible {\n display: flex;\n }\n\n #detail-panel header {\n padding: 10px 16px;\n border-bottom: 1px solid #3c3c3c;\n display: flex;\n justify-content: space-between;\n align-items: center;\n }\n\n #detail-panel header h2 {\n font-size: 14px;\n font-weight: 500;\n }\n\n #detail-panel .close-btn {\n background: none;\n border: none;\n color: #9d9d9d;\n cursor: pointer;\n font-size: 18px;\n padding: 4px 8px;\n }\n\n #detail-panel .close-btn:hover {\n color: #ffffff;\n }\n\n #detail-content {\n flex: 1;\n overflow: auto;\n padding: 16px;\n }\n\n #detail-content pre {\n font-family:\n \"SF Mono\", Monaco, \"Cascadia Code\", \"Roboto Mono\", Consolas,\n monospace;\n font-size: 12px;\n line-height: 1.5;\n white-space: pre-wrap;\n word-break: break-all;\n }\n\n /* SVG styles */\n .swimlane-header {\n font-size: 12px;\n font-weight: 600;\n fill: #cccccc;\n }\n\n .swimlane-line {\n stroke: #3c3c3c;\n stroke-width: 1;\n }\n\n .active-span {\n stroke-width: 4;\n opacity: 0.6;\n }\n\n .message-arrow {\n stroke-width: 2;\n fill: none;\n cursor: pointer;\n }\n\n .message-arrow:hover {\n stroke-width: 3;\n }\n\n .hit-area {\n stroke: transparent;\n stroke-width: 16;\n cursor: pointer;\n }\n\n .message-arrow.response {\n stroke-dasharray: 5, 3;\n }\n\n .arrow-head {\n fill: currentColor;\n }\n\n .message-label {\n font-size: 11px;\n fill: #d4d4d4;\n cursor: pointer;\n }\n\n .message-label:hover {\n fill: #ffffff;\n }\n\n .delta-time {\n font-size: 9px;\n fill: #6d6d6d;\n }\n\n .content-preview {\n font-size: 10px;\n color: #9d9d9d;\n font-style: italic;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n text-align: center;\n }\n\n .selected .message-arrow {\n stroke-width: 3;\n }\n\n .selected .message-label {\n fill: #ffffff;\n font-weight: 600;\n }\n\n /* Loading state */\n .loading {\n display: flex;\n align-items: center;\n justify-content: center;\n height: 100%;\n color: #9d9d9d;\n }\n\n .error-message {\n color: #ef5350;\n padding: 20px;\n }\n </style>\n </head>\n <body>\n <header>\n <h1>ACP Trace Viewer</h1>\n <div class=\"controls\">\n <label>\n <input type=\"checkbox\" id=\"show-acp\" checked />\n ACP\n </label>\n <label>\n <input type=\"checkbox\" id=\"show-mcp\" checked />\n MCP\n </label>\n <label>\n <input type=\"checkbox\" id=\"show-responses\" checked />\n Responses\n </label>\n <label>\n Width\n <input\n type=\"range\"\n id=\"swimlane-width\"\n min=\"80\"\n max=\"300\"\n value=\"150\"\n />\n </label>\n </div>\n </header>\n\n <div class=\"main-container\">\n <div id=\"diagram-container\">\n <div class=\"loading\">Loading trace...</div>\n </div>\n\n <div id=\"detail-panel\">\n <div id=\"resize-handle\"></div>\n <header>\n <h2 id=\"detail-title\">Message Details</h2>\n <button class=\"close-btn\" onclick=\"closeDetailPanel()\">\n ×\n </button>\n </header>\n <div id=\"detail-content\">\n <pre id=\"detail-json\"></pre>\n </div>\n </div>\n </div>\n\n <script>\n // Configuration\n let SWIMLANE_WIDTH = 150;\n const ROW_HEIGHT = 40;\n const HEADER_HEIGHT = 40;\n const PADDING = 20;\n\n // State\n let events = [];\n let components = [];\n let selectedEvent = null;\n\n // Fetch and render events\n async function loadEvents() {\n try {\n const response = await fetch(\"/events\");\n if (!response.ok) {\n throw new Error(\n `HTTP ${response.status}: ${await response.text()}`,\n );\n }\n events = await response.json();\n\n // Extract unique components in order of appearance\n const componentSet = new Set();\n for (const event of events) {\n if (event.from) componentSet.add(event.from);\n if (event.to) componentSet.add(event.to);\n }\n components = Array.from(componentSet);\n\n // Sort to put client first, agent last\n components.sort((a, b) => {\n if (a === \"client\") return -1;\n if (b === \"client\") return 1;\n if (a === \"agent\") return 1;\n if (b === \"agent\") return -1;\n return a.localeCompare(b);\n });\n\n render();\n } catch (error) {\n document.getElementById(\"diagram-container\").innerHTML =\n `<div class=\"error-message\">Failed to load trace: ${error.message}</div>`;\n }\n }\n\n // Rainbow color palette for request/response pairs\n const RAINBOW_COLORS = [\n \"#ff6b6b\", // red\n \"#ffa94d\", // orange\n \"#ffd43b\", // yellow\n \"#69db7c\", // green\n \"#4dabf7\", // blue\n \"#9775fa\", // purple\n \"#f783ac\", // pink\n \"#20c997\", // teal\n \"#a9e34b\", // lime\n \"#e599f7\", // violet\n ];\n\n function getColor(index) {\n return RAINBOW_COLORS[index % RAINBOW_COLORS.length];\n }\n\n // Render the sequence diagram\n function render() {\n const container = document.getElementById(\"diagram-container\");\n const showAcp = document.getElementById(\"show-acp\").checked;\n const showMcp = document.getElementById(\"show-mcp\").checked;\n const showResponses =\n document.getElementById(\"show-responses\").checked;\n\n // Filter events based on checkboxes\n const filteredEvents = events.filter((event) => {\n if (event.type === \"response\" && !showResponses)\n return false;\n if (event.protocol === \"acp\" && !showAcp) return false;\n if (event.protocol === \"mcp\" && !showMcp) return false;\n // Responses inherit protocol from their request - for now show based on response checkbox\n if (event.type === \"response\") return showResponses;\n return true;\n });\n\n // Build request/response pairs and assign colors\n const requestMap = new Map(); // id -> { request, requestIndex, response, responseIndex, color }\n let colorIndex = 0;\n\n filteredEvents.forEach((event, i) => {\n if (event.type === \"request\") {\n const key = `${event.to}:${JSON.stringify(event.id)}`;\n requestMap.set(key, {\n request: event,\n requestIndex: i,\n response: null,\n responseIndex: -1,\n color: getColor(colorIndex++),\n });\n } else if (event.type === \"response\") {\n const key = `${event.from}:${JSON.stringify(event.id)}`;\n if (requestMap.has(key)) {\n const pair = requestMap.get(key);\n pair.response = event;\n pair.responseIndex = i;\n }\n }\n });\n\n // Create a map from event index to its pair info\n const eventToPair = new Map();\n requestMap.forEach((pair) => {\n eventToPair.set(pair.requestIndex, pair);\n if (pair.responseIndex >= 0) {\n eventToPair.set(pair.responseIndex, pair);\n }\n });\n\n const width = components.length * SWIMLANE_WIDTH + PADDING * 2;\n const height =\n HEADER_HEIGHT +\n filteredEvents.length * ROW_HEIGHT +\n PADDING * 2;\n\n let svg = `<svg id=\"diagram\" width=\"${width}\" height=\"${height}\" xmlns=\"http://www.w3.org/2000/svg\">`;\n\n // Draw swimlane headers and base lines\n components.forEach((comp, i) => {\n const x = PADDING + i * SWIMLANE_WIDTH + SWIMLANE_WIDTH / 2;\n svg += `<text x=\"${x}\" y=\"${PADDING + 15}\" text-anchor=\"middle\" class=\"swimlane-header\">${comp}</text>`;\n // Draw vertical line\n svg += `<line x1=\"${x}\" y1=\"${HEADER_HEIGHT}\" x2=\"${x}\" y2=\"${height}\" class=\"swimlane-line\"/>`;\n });\n\n // Draw active spans (thickened timeline between request and response)\n requestMap.forEach((pair) => {\n if (pair.response && pair.responseIndex >= 0) {\n const component = pair.request.to;\n const compIndex = components.indexOf(component);\n if (compIndex >= 0) {\n const x =\n PADDING +\n compIndex * SWIMLANE_WIDTH +\n SWIMLANE_WIDTH / 2;\n const y1 =\n HEADER_HEIGHT +\n pair.requestIndex * ROW_HEIGHT +\n ROW_HEIGHT / 2;\n const y2 =\n HEADER_HEIGHT +\n pair.responseIndex * ROW_HEIGHT +\n ROW_HEIGHT / 2;\n svg += `<line x1=\"${x}\" y1=\"${y1}\" x2=\"${x}\" y2=\"${y2}\" class=\"active-span\" style=\"stroke: ${pair.color}\"/>`;\n }\n }\n });\n\n // Track last event index for each component to show delta times\n const lastEventIndex = new Map(); // component -> { index, ts }\n\n // First pass: record which components are involved in each event\n const eventComponents = filteredEvents.map((event) => {\n const involved = new Set();\n if (event.from) involved.add(event.from);\n if (event.to) involved.add(event.to);\n return involved;\n });\n\n // Draw delta times on component timelines\n filteredEvents.forEach((event, i) => {\n const involved = eventComponents[i];\n const ts = event.ts || 0;\n\n involved.forEach((component) => {\n const compIndex = components.indexOf(component);\n if (compIndex < 0) return;\n\n const last = lastEventIndex.get(component);\n if (last && ts > last.ts) {\n const delta = ts - last.ts;\n const x =\n PADDING +\n compIndex * SWIMLANE_WIDTH +\n SWIMLANE_WIDTH / 2;\n const y1 =\n HEADER_HEIGHT +\n last.index * ROW_HEIGHT +\n ROW_HEIGHT / 2 +\n 8;\n const y2 =\n HEADER_HEIGHT +\n i * ROW_HEIGHT +\n ROW_HEIGHT / 2 -\n 8;\n const yMid = (y1 + y2) / 2;\n\n svg += `<text x=\"${x + 8}\" y=\"${yMid + 3}\" text-anchor=\"start\" class=\"delta-time\">${formatDelta(delta)}</text>`;\n }\n\n // Update last event for this component\n lastEventIndex.set(component, { index: i, ts });\n });\n });\n\n // Draw messages\n filteredEvents.forEach((event, i) => {\n const y = HEADER_HEIGHT + i * ROW_HEIGHT + ROW_HEIGHT / 2;\n const pair = eventToPair.get(i);\n const pairColor = pair ? pair.color : \"#4fc3f7\";\n\n if (\n event.type === \"request\" ||\n event.type === \"notification\"\n ) {\n const fromX =\n PADDING +\n components.indexOf(event.from) * SWIMLANE_WIDTH +\n SWIMLANE_WIDTH / 2;\n const toX =\n PADDING +\n components.indexOf(event.to) * SWIMLANE_WIDTH +\n SWIMLANE_WIDTH / 2;\n const direction = toX > fromX ? 1 : -1;\n const arrowX = toX - direction * 8;\n\n const isMcp = event.protocol === \"mcp\";\n\n svg += `<g class=\"message-group\" data-index=\"${events.indexOf(event)}\" onclick=\"selectEvent(${events.indexOf(event)})\">`;\n\n // Arrow line\n svg += `<line x1=\"${fromX}\" y1=\"${y}\" x2=\"${arrowX}\" y2=\"${y}\" class=\"message-arrow\" style=\"stroke: ${pairColor}\"/>`;\n\n // Arrow head - triangle for ACP, circle for MCP\n if (isMcp) {\n svg += `<circle cx=\"${toX - direction * 4}\" cy=\"${y}\" r=\"4\" style=\"fill: ${pairColor}\"/>`;\n } else {\n svg += `<polygon points=\"${toX},${y} ${arrowX},${y - 5} ${arrowX},${y + 5}\" class=\"arrow-head\" style=\"fill: ${pairColor}\"/>`;\n }\n\n // Label\n const labelX = (fromX + toX) / 2;\n const label = event.method || event.type;\n svg += `<text x=\"${labelX}\" y=\"${y - 8}\" text-anchor=\"middle\" class=\"message-label\" style=\"fill: ${pairColor}\">${truncate(label, 20)}</text>`;\n\n // Content preview for various message types\n const contentPreview = getContentPreview(event);\n if (contentPreview) {\n const contentWidth = Math.abs(toX - fromX);\n const contentX = Math.min(fromX, toX);\n svg += `<foreignObject x=\"${contentX}\" y=\"${y + 4}\" width=\"${contentWidth}\" height=\"18\">\n <div xmlns=\"http://www.w3.org/1999/xhtml\" class=\"content-preview\">${escapeHtml(contentPreview)}</div>\n </foreignObject>`;\n }\n\n svg += `</g>`;\n } else if (event.type === \"response\") {\n const fromX =\n PADDING +\n components.indexOf(event.from) * SWIMLANE_WIDTH +\n SWIMLANE_WIDTH / 2;\n const toX =\n PADDING +\n components.indexOf(event.to) * SWIMLANE_WIDTH +\n SWIMLANE_WIDTH / 2;\n const direction = toX > fromX ? 1 : -1;\n const arrowX = toX - direction * 8;\n\n const color = event.is_error ? \"#ef5350\" : pairColor;\n\n svg += `<g class=\"message-group\" data-index=\"${events.indexOf(event)}\" onclick=\"selectEvent(${events.indexOf(event)})\">`;\n\n // Invisible wider hit area for easier clicking\n svg += `<line x1=\"${fromX}\" y1=\"${y}\" x2=\"${toX}\" y2=\"${y}\" class=\"hit-area\"/>`;\n\n // Dashed arrow line\n svg += `<line x1=\"${fromX}\" y1=\"${y}\" x2=\"${arrowX}\" y2=\"${y}\" class=\"message-arrow response\" style=\"stroke: ${color}\"/>`;\n\n // Arrow head\n svg += `<polygon points=\"${toX},${y} ${arrowX},${y - 5} ${arrowX},${y + 5}\" class=\"arrow-head\" style=\"fill: ${color}\"/>`;\n\n svg += `</g>`;\n }\n });\n\n svg += `</svg>`;\n container.innerHTML = svg;\n }\n\n function truncate(str, maxLen) {\n if (str.length <= maxLen) return str;\n return str.substring(0, maxLen - 1) + \"\\u2026\";\n }\n\n function formatDelta(seconds) {\n if (seconds < 0.001) return \"<1ms\";\n if (seconds < 1) return Math.round(seconds * 1000) + \"ms\";\n return seconds.toFixed(2) + \"s\";\n }\n\n function escapeHtml(str) {\n return str\n .replace(/&/g, \"&\")\n .replace(/</g, \"<\")\n .replace(/>/g, \">\")\n .replace(/\"/g, \""\");\n }\n\n function getContentPreview(event) {\n // session/prompt - show the prompt text\n if (event.method === \"session/prompt\") {\n const promptText = event.params?.prompt?.[0]?.text;\n if (promptText) return promptText;\n }\n\n // session/update - varies by update type\n if (event.method === \"session/update\") {\n const update = event.params?.update;\n if (!update) return null;\n\n switch (update.sessionUpdate) {\n case \"agent_message_chunk\":\n return update.content?.text;\n\n case \"tool_call\":\n const toolName =\n update._meta?.claudeCode?.toolName ||\n update.title;\n return toolName ? `\u{1f4e6} ${toolName}` : null;\n\n case \"tool_call_update\":\n const tool = update._meta?.claudeCode?.toolName;\n const status = update.status;\n if (tool && status) return `${status}: ${tool}`;\n return status;\n\n case \"available_commands_update\":\n const count = update.availableCommands?.length;\n return count ? `${count} commands` : null;\n }\n }\n\n return null;\n }\n\n function selectEvent(index) {\n selectedEvent = events[index];\n\n // Update selection visual\n document\n .querySelectorAll(\".message-group\")\n .forEach((g) => g.classList.remove(\"selected\"));\n const group = document.querySelector(\n `.message-group[data-index=\"${index}\"]`,\n );\n if (group) group.classList.add(\"selected\");\n\n // Show detail panel\n const panel = document.getElementById(\"detail-panel\");\n const title = document.getElementById(\"detail-title\");\n const json = document.getElementById(\"detail-json\");\n\n panel.classList.add(\"visible\");\n\n if (selectedEvent.type === \"response\") {\n title.textContent = selectedEvent.is_error\n ? \"Error Response\"\n : \"Response\";\n } else {\n title.textContent =\n selectedEvent.method || selectedEvent.type;\n }\n\n json.textContent = JSON.stringify(selectedEvent, null, 2);\n }\n\n function closeDetailPanel() {\n document\n .getElementById(\"detail-panel\")\n .classList.remove(\"visible\");\n document\n .querySelectorAll(\".message-group\")\n .forEach((g) => g.classList.remove(\"selected\"));\n selectedEvent = null;\n }\n\n // Event listeners\n document\n .getElementById(\"show-acp\")\n .addEventListener(\"change\", render);\n document\n .getElementById(\"show-mcp\")\n .addEventListener(\"change\", render);\n document\n .getElementById(\"show-responses\")\n .addEventListener(\"change\", render);\n document\n .getElementById(\"swimlane-width\")\n .addEventListener(\"input\", (e) => {\n SWIMLANE_WIDTH = parseInt(e.target.value, 10);\n render();\n });\n\n // Resize handle for detail panel\n const resizeHandle = document.getElementById(\"resize-handle\");\n const detailPanel = document.getElementById(\"detail-panel\");\n let isResizing = false;\n\n resizeHandle.addEventListener(\"mousedown\", (e) => {\n isResizing = true;\n document.body.style.cursor = \"ns-resize\";\n document.body.style.userSelect = \"none\";\n e.preventDefault();\n });\n\n document.addEventListener(\"mousemove\", (e) => {\n if (!isResizing) return;\n const containerRect = document\n .querySelector(\".main-container\")\n .getBoundingClientRect();\n const newHeight = containerRect.bottom - e.clientY;\n detailPanel.style.height =\n Math.max(\n 100,\n Math.min(newHeight, containerRect.height - 100),\n ) + \"px\";\n });\n\n document.addEventListener(\"mouseup\", () => {\n if (isResizing) {\n isResizing = false;\n document.body.style.cursor = \"\";\n document.body.style.userSelect = \"\";\n }\n });\n\n // Initial load\n loadEvents();\n\n // Live polling - check for updates every 500ms\n let livePolling = true;\n async function pollForUpdates() {\n if (!livePolling) return;\n\n try {\n const response = await fetch(\"/events\");\n if (response.ok) {\n const newEvents = await response.json();\n // Only re-render if event count changed\n if (newEvents.length !== events.length) {\n events = newEvents;\n\n // Re-extract components\n const componentSet = new Set();\n for (const event of events) {\n if (event.from) componentSet.add(event.from);\n if (event.to) componentSet.add(event.to);\n }\n components = Array.from(componentSet);\n components.sort((a, b) => {\n if (a === \"client\") return -1;\n if (b === \"client\") return 1;\n if (a === \"agent\") return 1;\n if (b === \"agent\") return -1;\n return a.localeCompare(b);\n });\n\n render();\n\n // Auto-scroll to bottom to show new events\n const container =\n document.getElementById(\"diagram-container\");\n container.scrollTop = container.scrollHeight;\n }\n }\n } catch (e) {\n // Ignore polling errors\n }\n\n setTimeout(pollForUpdates, 500);\n }\n\n // Start polling after initial load\n setTimeout(pollForUpdates, 500);\n </script>\n </body>\n</html>\n";Expand description
The HTML viewer page (embedded at compile time).