Skip to main content

VIEWER_HTML

Constant VIEWER_HTML 

Source
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                        &times;\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, \"&amp;\")\n                    .replace(/</g, \"&lt;\")\n                    .replace(/>/g, \"&gt;\")\n                    .replace(/\"/g, \"&quot;\");\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).