Files
oh-my-pi/packages/coding-agent/src/export/html/template.generated.ts
T
can1357 ecd1554eba feat: renamed subagent handoff flow to use yield instead of submit_result
- Renamed subagent completion flow from `submit_result` to `yield` across SDK tools, prompts, and docs.
- Updated executor/task handling to require and parse `yield` calls, replacing legacy submit-result extraction and state flags.
- Added `subagent-yield-reminder` and updated system prompts to require `yield` with `result.data` or `result.error`.
- Renamed hidden-tool and registration plumbing to `yield`, including discovery helpers and renderer/test surface.
2026-04-26 00:29:52 +02:00

3 lines
98 KiB
TypeScript

// Auto-generated by scripts/generate-template.ts - DO NOT EDIT
export const TEMPLATE = "<!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>Session Export</title>\n <style>*{margin:0;padding:0;box-sizing:border-box;}:root{--line-height:18px;--sidebar-width:400px;--sidebar-min-width:240px;--sidebar-max-width:840px;--sidebar-resizer-width:6px;}body{font-family:ui-monospace,'Cascadia Code','Source Code Pro',Menlo,Consolas,'DejaVu Sans Mono',monospace;font-size:12px;line-height:var(--line-height);color:var(--text);background:var(--body-bg);}body.sidebar-resizing{cursor:col-resize;user-select:none;}#app{display:flex;min-height:100vh;}#sidebar{width:var(--sidebar-width);min-width:var(--sidebar-width);max-width:var(--sidebar-width);background:var(--container-bg);flex-shrink:0;display:flex;flex-direction:column;position:sticky;top:0;height:100vh;border-right:1px solid var(--dim);}.sidebar-header{padding:8px 12px;flex-shrink:0;}.sidebar-controls{padding:8px 8px 4px 8px;}.sidebar-search{width:100%;box-sizing:border-box;padding:4px 8px;font-size:11px;font-family:inherit;background:var(--body-bg);color:var(--text);border:1px solid var(--dim);border-radius:3px;}.sidebar-filters{display:flex;padding:4px 8px 8px 8px;gap:4px;align-items:center;flex-wrap:wrap;}.sidebar-search:focus{outline:none;border-color:var(--accent);}.sidebar-search::placeholder{color:var(--muted);}.filter-btn{padding:3px 8px;font-size:10px;font-family:inherit;background:transparent;color:var(--muted);border:1px solid var(--dim);border-radius:3px;cursor:pointer;}.filter-btn:hover{color:var(--text);border-color:var(--text);}.filter-btn.active{background:var(--accent);color:var(--body-bg);border-color:var(--accent);}.sidebar-close{display:none;padding:3px 8px;font-size:12px;font-family:inherit;background:transparent;color:var(--muted);border:1px solid var(--dim);border-radius:3px;cursor:pointer;margin-left:auto;}.sidebar-close:hover{color:var(--text);border-color:var(--text);}.tree-container{flex:1;overflow:auto;padding:4px 0;}.tree-node{padding:0 8px;cursor:pointer;display:flex;align-items:baseline;font-size:11px;line-height:13px;white-space:nowrap;}.tree-node:hover{background:var(--selectedBg);}.tree-node.active{background:var(--selectedBg);}.tree-node.active .tree-content{font-weight:bold;}.tree-node.in-path{background:color-mix(in srgb,var(--accent) 10%,transparent);}.tree-node:not(.in-path){opacity:0.5;}.tree-node:not(.in-path):hover{opacity:1;}.tree-prefix{color:var(--muted);flex-shrink:0;font-family:monospace;white-space:pre;}.tree-marker{color:var(--accent);flex-shrink:0;}.tree-content{color:var(--text);}.tree-role-user{color:var(--accent);}.tree-role-assistant{color:var(--success);}.tree-role-tool{color:var(--muted);}.tree-muted{color:var(--muted);}.tree-error{color:var(--error);}.tree-compaction{color:var(--borderAccent);}.tree-branch-summary{color:var(--warning);}.tree-custom-message{color:var(--customMessageLabel);}.tree-status{padding:4px 12px;font-size:10px;color:var(--muted);flex-shrink:0;}#sidebar-resizer{width:var(--sidebar-resizer-width);flex-shrink:0;position:sticky;top:0;height:100vh;cursor:col-resize;touch-action:none;background:transparent;border-right:1px solid transparent;}#sidebar-resizer:hover,body.sidebar-resizing #sidebar-resizer{background:var(--selectedBg);border-right-color:var(--dim);}#content{flex:1;min-width:0;flex:1;overflow-y:auto;padding:var(--line-height) calc(var(--line-height) * 2);display:flex;flex-direction:column;align-items:center;}#content > *{width:100%;max-width:800px;}.help-bar{font-size:11px;color:var(--warning);margin-bottom:var(--line-height);}.header{background:var(--container-bg);border-radius:4px;padding:var(--line-height);margin-bottom:var(--line-height);}.header h1{font-size:12px;font-weight:bold;color:var(--borderAccent);margin-bottom:var(--line-height);}.header-info{display:flex;flex-direction:column;gap:0;font-size:11px;}.info-item{color:var(--dim);display:flex;align-items:baseline;}.info-label{font-weight:600;margin-right:8px;min-width:100px;}.info-value{color:var(--text);flex:1;}#messages{display:flex;flex-direction:column;gap:var(--line-height);}.message-timestamp{font-size:10px;color:var(--dim);opacity:0.8;}.user-message{background:var(--userMessageBg);color:var(--userMessageText);padding:var(--line-height);border-radius:4px;position:relative;}.assistant-message{padding:0;position:relative;}.copy-link-btn{position:absolute;top:8px;right:8px;width:28px;height:28px;padding:6px;background:var(--container-bg);border:1px solid var(--dim);border-radius:4px;color:var(--muted);cursor:pointer;opacity:0;transition:opacity 0.15s,background 0.15s,color 0.15s;display:flex;align-items:center;justify-content:center;z-index:10;}.user-message:hover .copy-link-btn,.assistant-message:hover .copy-link-btn{opacity:1;}.copy-link-btn:hover{background:var(--accent);color:var(--body-bg);border-color:var(--accent);}.copy-link-btn.copied{background:var(--success,#22c55e);color:white;border-color:var(--success,#22c55e);}.user-message.highlight,.assistant-message.highlight{animation:highlight-pulse 2s ease-out;}@keyframes highlight-pulse{0%{box-shadow:0 0 0 3px var(--accent);}100%{box-shadow:0 0 0 0 transparent;}}.assistant-message > .message-timestamp{padding-left:var(--line-height);}.assistant-text{padding:var(--line-height);padding-bottom:0;}.message-timestamp + .assistant-text,.message-timestamp + .thinking-block{padding-top:0;}.thinking-block + .assistant-text{padding-top:0;}.thinking-text{padding:var(--line-height);color:var(--thinkingText);font-style:italic;white-space:pre-wrap;}.message-timestamp + .thinking-block .thinking-text,.message-timestamp + .thinking-block .thinking-collapsed{padding-top:0;}.thinking-collapsed{display:none;padding:var(--line-height);color:var(--thinkingText);font-style:italic;}.tool-execution{padding:var(--line-height);border-radius:4px;}.tool-execution + .tool-execution{margin-top:var(--line-height);}.tool-execution.pending{background:var(--toolPendingBg);}.tool-execution.success{background:var(--toolSuccessBg);}.tool-execution.error{background:var(--toolErrorBg);}.tool-header,.tool-name{font-weight:bold;}.tool-path{color:var(--accent);word-break:break-all;}.line-numbers{color:var(--warning);}.line-count{color:var(--dim);}.tool-command{font-weight:bold;white-space:pre-wrap;word-wrap:break-word;overflow-wrap:break-word;word-break:break-word;}.tool-output{margin-top:var(--line-height);color:var(--toolOutput);word-wrap:break-word;overflow-wrap:break-word;word-break:break-word;font-family:inherit;overflow-x:auto;}.tool-output > div,.output-preview,.output-full{margin:0;padding:0;line-height:var(--line-height);}.tool-output pre{margin:0;padding:0;font-family:inherit;color:inherit;white-space:pre-wrap;word-wrap:break-word;overflow-wrap:break-word;}.tool-output code{padding:0;background:none;color:var(--text);}.tool-output.expandable{cursor:pointer;}.tool-output.expandable:hover{opacity:0.9;}.tool-output.expandable .output-full{display:none;}.tool-output.expandable.expanded .output-preview{display:none;}.tool-output.expandable.expanded .output-full{display:block;}.ansi-line{white-space:pre-wrap;}.tool-images{}.tool-image{max-width:100%;max-height:500px;border-radius:4px;margin:var(--line-height) 0;}.expand-hint{color:var(--toolOutput);}.tool-diff{font-size:11px;overflow-x:auto;white-space:pre;}.diff-added{color:var(--toolDiffAdded);}.diff-removed{color:var(--toolDiffRemoved);}.diff-context{color:var(--toolDiffContext);}.model-change{padding:0 var(--line-height);color:var(--dim);font-size:11px;}.model-name{color:var(--borderAccent);font-weight:bold;}.codex-bridge-toggle{color:var(--muted);cursor:pointer;text-decoration:underline;font-size:10px;}.codex-bridge-toggle:hover{color:var(--accent);}.codex-bridge-content{display:none;margin-top:8px;padding:8px;background:var(--exportCardBg,var(--container-bg));border-radius:4px;font-size:11px;max-height:300px;overflow:auto;}.codex-bridge-content pre{margin:0;white-space:pre-wrap;word-break:break-word;color:var(--muted);}.model-change.show-bridge .codex-bridge-content{display:block;}.compaction{background:var(--customMessageBg);border-radius:4px;padding:var(--line-height);cursor:pointer;}.compaction-label{color:var(--customMessageLabel);font-weight:bold;}.compaction-collapsed{color:var(--customMessageText);}.compaction-content{display:none;color:var(--customMessageText);white-space:pre-wrap;margin-top:var(--line-height);}.compaction.expanded .compaction-collapsed{display:none;}.compaction.expanded .compaction-content{display:block;}.system-prompt{background:var(--customMessageBg);padding:var(--line-height);border-radius:4px;margin-bottom:var(--line-height);}.system-prompt-header{font-weight:bold;color:var(--customMessageLabel);}.system-prompt-content{color:var(--customMessageText);white-space:pre-wrap;word-wrap:break-word;font-size:11px;max-height:200px;overflow-y:auto;margin-top:var(--line-height);}.system-prompt.provider-prompt{border-left:3px solid var(--warning);}.system-prompt-note{font-size:10px;font-style:italic;color:var(--muted);margin-top:4px;}.tools-list{background:var(--customMessageBg);padding:var(--line-height);border-radius:4px;margin-bottom:var(--line-height);}.tools-header{font-weight:bold;color:var(--warning);margin-bottom:var(--line-height);}.tool-item{font-size:11px;}.tool-item-name{font-weight:bold;color:var(--text);}.tool-item-desc{color:var(--dim);}.hook-message{background:var(--customMessageBg);color:var(--customMessageText);padding:var(--line-height);border-radius:4px;}.hook-type{color:var(--customMessageLabel);font-weight:bold;}.branch-summary{background:var(--customMessageBg);padding:var(--line-height);border-radius:4px;}.branch-summary-header{font-weight:bold;color:var(--borderAccent);}.error-text{color:var(--error);padding:0 var(--line-height);}.tool-error{color:var(--error);}.tool-meta{margin-top:4px;}.tool-badge{display:inline-block;padding:0 6px;margin-right:4px;border-radius:3px;background:rgba(255,255,255,0.06);color:var(--dim);font-size:11px;font-weight:normal;vertical-align:baseline;}.tool-pattern{color:var(--warning);}.tool-args{margin-top:4px;color:var(--toolOutput);}.tool-arg{display:block;line-height:var(--line-height);white-space:pre-wrap;word-break:break-word;}.tool-arg-key{color:var(--dim);}.tool-arg-val{color:var(--text);}.tool-cell{margin-top:var(--line-height);}.tool-cell-title{color:var(--dim);font-size:11px;margin-bottom:2px;}.todo-tree{margin-top:var(--line-height);}.todo-phase{margin-top:6px;color:var(--accent);font-weight:bold;}.todo-task{padding-left:12px;line-height:var(--line-height);}.todo-icon{display:inline-block;width:14px;text-align:center;color:var(--dim);}.todo-completed{color:var(--toolDiffAdded);}.todo-completed .todo-icon{color:var(--toolDiffAdded);}.todo-in_progress{color:var(--warning);}.todo-in_progress .todo-icon{color:var(--warning);}.todo-abandoned{color:var(--toolDiffRemoved);}.todo-abandoned .todo-icon{color:var(--toolDiffRemoved);}.todo-pending{color:var(--toolOutput);}.message-images{margin-bottom:12px;}.message-image{max-width:100%;max-height:400px;border-radius:4px;margin:var(--line-height) 0;}.markdown-content h1,.markdown-content h2,.markdown-content h3,.markdown-content h4,.markdown-content h5,.markdown-content h6{color:var(--mdHeading);margin:var(--line-height) 0 0 0;font-weight:bold;}.markdown-content h1{font-size:1em;}.markdown-content h2{font-size:1em;}.markdown-content h3{font-size:1em;}.markdown-content h4{font-size:1em;}.markdown-content h5{font-size:1em;}.markdown-content h6{font-size:1em;}.markdown-content p{margin:0;}.markdown-content p + p{margin-top:var(--line-height);}.markdown-content a{color:var(--mdLink);text-decoration:underline;}.markdown-content code{background:rgba(128,128,128,0.2);color:var(--mdCode);padding:0 4px;border-radius:3px;font-family:inherit;}.markdown-content pre{background:transparent;margin:var(--line-height) 0;overflow-x:auto;}.markdown-content pre code{display:block;background:none;color:var(--text);}.markdown-content blockquote{border-left:3px solid var(--mdQuoteBorder);padding-left:var(--line-height);margin:var(--line-height) 0;color:var(--mdQuote);font-style:italic;}.markdown-content ul,.markdown-content ol{margin:var(--line-height) 0;padding-left:calc(var(--line-height) * 2);}.markdown-content li{margin:0;}.markdown-content li::marker{color:var(--mdListBullet);}.markdown-content hr{border:none;border-top:1px solid var(--mdHr);margin:var(--line-height) 0;}.markdown-content table{border-collapse:collapse;margin:0.5em 0;width:100%;}.markdown-content th,.markdown-content td{border:1px solid var(--mdCodeBlockBorder);padding:6px 10px;text-align:left;}.markdown-content th{background:rgba(128,128,128,0.1);font-weight:bold;}.markdown-content img{max-width:100%;border-radius:4px;}.hljs{background:transparent;color:var(--text);}.hljs-comment,.hljs-quote{color:var(--syntaxComment);}.hljs-keyword,.hljs-selector-tag{color:var(--syntaxKeyword);}.hljs-number,.hljs-literal{color:var(--syntaxNumber);}.hljs-string,.hljs-doctag{color:var(--syntaxString);}.hljs-function,.hljs-title,.hljs-title.function_,.hljs-section,.hljs-name{color:var(--syntaxFunction);}.hljs-type,.hljs-class,.hljs-title.class_,.hljs-built_in{color:var(--syntaxType);}.hljs-attr,.hljs-variable,.hljs-variable.language_,.hljs-params,.hljs-property{color:var(--syntaxVariable);}.hljs-meta,.hljs-meta .hljs-keyword,.hljs-meta .hljs-string{color:var(--syntaxKeyword);}.hljs-operator{color:var(--syntaxOperator);}.hljs-punctuation{color:var(--syntaxPunctuation);}.hljs-subst{color:var(--text);}.footer{margin-top:48px;padding:20px;text-align:center;color:var(--dim);font-size:10px;}#hamburger{display:none;position:fixed;top:10px;left:10px;z-index:100;padding:3px 8px;font-size:12px;font-family:inherit;background:transparent;color:var(--muted);border:1px solid var(--dim);border-radius:3px;cursor:pointer;}#hamburger:hover{color:var(--text);border-color:var(--text);}#sidebar-overlay{display:none;position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.5);z-index:98;}@media (max-width:900px){#sidebar{position:fixed;transform:translateX(-100%);width:min(var(--sidebar-width),100vw);min-width:0;max-width:100vw;top:0;bottom:0;height:100vh;z-index:99;transition:transform 0.3s;}#sidebar.open{transform:translateX(0);}#sidebar-overlay.open{display:block;}#hamburger{display:block;}.sidebar-close{display:block;}#sidebar-resizer{display:none;}#content{padding:var(--line-height) 16px;}#content > *{max-width:100%;}}@media print{#sidebar,#sidebar-toggle,#sidebar-resizer{display:none !important;}body{background:white;color:black;}#content{max-width:none;}}</style>\n <theme-vars/>\n</head>\n<body>\n <button id=\"hamburger\" title=\"Open sidebar\"><svg width=\"14\" height=\"14\" viewBox=\"0 0 24 24\" fill=\"currentColor\" stroke=\"none\"><circle cx=\"6\" cy=\"6\" r=\"2.5\"/><circle cx=\"6\" cy=\"18\" r=\"2.5\"/><circle cx=\"18\" cy=\"12\" r=\"2.5\"/><rect x=\"5\" y=\"6\" width=\"2\" height=\"12\"/><path d=\"M6 12h10c1 0 2 0 2-2V8\"/></svg></button>\n <div id=\"sidebar-overlay\"></div>\n <div id=\"app\">\n <aside id=\"sidebar\">\n <div class=\"sidebar-header\">\n <div class=\"sidebar-controls\">\n <input type=\"text\" class=\"sidebar-search\" id=\"tree-search\" placeholder=\"Search...\">\n </div>\n <div class=\"sidebar-filters\">\n <button class=\"filter-btn active\" data-filter=\"default\" title=\"Hide settings entries\">Default</button>\n <button class=\"filter-btn\" data-filter=\"no-tools\" title=\"Default minus tool results\">No-tools</button>\n <button class=\"filter-btn\" data-filter=\"user-only\" title=\"Only user messages\">User</button>\n <button class=\"filter-btn\" data-filter=\"labeled-only\" title=\"Only labeled entries\">Labeled</button>\n <button class=\"filter-btn\" data-filter=\"all\" title=\"Show everything\">All</button>\n <button class=\"sidebar-close\" id=\"sidebar-close\" title=\"Close\">✕</button>\n </div>\n </div>\n <div class=\"tree-container\" id=\"tree-container\"></div>\n <div class=\"tree-status\" id=\"tree-status\"></div>\n </aside>\n <div id=\"sidebar-resizer\" role=\"separator\" aria-orientation=\"vertical\" aria-label=\"Resize session tree sidebar\"></div>\n <main id=\"content\">\n <div id=\"header-container\"></div>\n <div id=\"messages\"></div>\n </main>\n <div id=\"image-modal\" class=\"image-modal\">\n <img id=\"modal-image\" src=\"\" alt=\"\">\n </div>\n </div>\n\n <script id=\"session-data\" type=\"application/json\">{{SESSION_DATA}}</script>\n <script src=\"https://cdnjs.cloudflare.com/ajax/libs/marked/15.0.4/marked.min.js\" integrity=\"sha512-VmLxPVdDGeR+F0DzUHVqzHwaR4ZSSh1g/7aYXwKT1PAGVxunOEcysta+4H5Utvmpr2xExEPybZ8q+iM9F1tGdw==\" crossorigin=\"anonymous\" referrerpolicy=\"no-referrer\"></script>\n <script src=\"https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js\" integrity=\"sha512-D9gUyxqja7hBtkWpPWGt9wfbfaMGVt9gnyCvYa+jojwwPHLCzUm5i8rpk7vD7wNee9bA35eYIjobYPaQuKS1MQ==\" crossorigin=\"anonymous\" referrerpolicy=\"no-referrer\"></script>\n <script> (function() {\n 'use strict';\n\n // ============================================================\n // DATA LOADING\n // ============================================================\n\n const base64 = document.getElementById('session-data').textContent;\n const binary = atob(base64);\n const bytes = new Uint8Array(binary.length);\n for (let i = 0; i < binary.length; i++) {\n bytes[i] = binary.charCodeAt(i);\n }\n const data = JSON.parse(new TextDecoder('utf-8').decode(bytes));\n const { header, entries, leafId: defaultLeafId, systemPrompt, tools } = data;\n\n // ============================================================\n // URL PARAMETER HANDLING\n // ============================================================\n\n // Parse URL parameters for deep linking: leafId and targetId\n // Check for injected params (when loaded in iframe via srcdoc) or use window.location\n const injectedParams = document.querySelector('meta[name=\"pi-url-params\"]');\n const searchString = injectedParams ? injectedParams.content : window.location.search.substring(1);\n const urlParams = new URLSearchParams(searchString);\n const urlLeafId = urlParams.get('leafId');\n const urlTargetId = urlParams.get('targetId');\n // Use URL leafId if provided, otherwise fall back to session default\n const leafId = urlLeafId || defaultLeafId;\n\n // ============================================================\n // DATA STRUCTURES\n // ============================================================\n\n // Entry lookup by ID\n const byId = new Map();\n for (const entry of entries) {\n byId.set(entry.id, entry);\n }\n\n // Tool call lookup (toolCallId -> {name, arguments})\n const toolCallMap = new Map();\n for (const entry of entries) {\n if (entry.type === 'message' && entry.message.role === 'assistant') {\n const content = entry.message.content;\n if (Array.isArray(content)) {\n for (const block of content) {\n if (block.type === 'toolCall') {\n toolCallMap.set(block.id, { name: block.name, arguments: block.arguments });\n }\n }\n }\n }\n }\n\n // Label lookup (entryId -> label string)\n // Labels are stored in 'label' entries that reference their target via targetId\n const labelMap = new Map();\n for (const entry of entries) {\n if (entry.type === 'label' && entry.targetId && entry.label) {\n labelMap.set(entry.targetId, entry.label);\n }\n }\n\n // ============================================================\n // TREE DATA PREPARATION (no DOM, pure data)\n // ============================================================\n\n /**\n * Build tree structure from flat entries.\n * Returns array of root nodes, each with { entry, children, label }.\n */\n function buildTree() {\n const nodeMap = new Map();\n const roots = [];\n\n // Create nodes\n for (const entry of entries) {\n nodeMap.set(entry.id, { \n entry, \n children: [],\n label: labelMap.get(entry.id)\n });\n }\n\n // Build parent-child relationships\n for (const entry of entries) {\n const node = nodeMap.get(entry.id);\n if (entry.parentId === null || entry.parentId === undefined || entry.parentId === entry.id) {\n roots.push(node);\n } else {\n const parent = nodeMap.get(entry.parentId);\n if (parent) {\n parent.children.push(node);\n } else {\n roots.push(node);\n }\n }\n }\n\n // Sort children by timestamp\n function sortChildren(node) {\n node.children.sort((a, b) =>\n new Date(a.entry.timestamp).getTime() - new Date(b.entry.timestamp).getTime()\n );\n node.children.forEach(sortChildren);\n }\n roots.forEach(sortChildren);\n\n return roots;\n }\n\n /**\n * Build set of entry IDs on path from root to target.\n */\n function buildActivePathIds(targetId) {\n const ids = new Set();\n let current = byId.get(targetId);\n while (current) {\n ids.add(current.id);\n // Stop if no parent or self-referencing (root)\n if (!current.parentId || current.parentId === current.id) {\n break;\n }\n current = byId.get(current.parentId);\n }\n return ids;\n }\n\n /**\n * Get array of entries from root to target (the conversation path).\n */\n function getPath(targetId) {\n const path = [];\n let current = byId.get(targetId);\n while (current) {\n path.unshift(current);\n // Stop if no parent or self-referencing (root)\n if (!current.parentId || current.parentId === current.id) {\n break;\n }\n current = byId.get(current.parentId);\n }\n return path;\n }\n\n /**\n * Flatten tree into list with indentation and connector info.\n * Returns array of { node, indent, showConnector, isLast, gutters, isVirtualRootChild, multipleRoots }.\n * Matches tree-selector.ts logic exactly.\n */\n function flattenTree(roots, activePathIds) {\n const result = [];\n const multipleRoots = roots.length > 1;\n\n // Mark which subtrees contain the active leaf\n const containsActive = new Map();\n function markActive(node) {\n let has = activePathIds.has(node.entry.id);\n for (const child of node.children) {\n if (markActive(child)) has = true;\n }\n containsActive.set(node, has);\n return has;\n }\n roots.forEach(markActive);\n\n // Stack: [node, indent, justBranched, showConnector, isLast, gutters, isVirtualRootChild]\n const stack = [];\n\n // Add roots (prioritize branch containing active leaf)\n const orderedRoots = [...roots].sort((a, b) => \n Number(containsActive.get(b)) - Number(containsActive.get(a))\n );\n for (let i = orderedRoots.length - 1; i >= 0; i--) {\n const isLast = i === orderedRoots.length - 1;\n stack.push([orderedRoots[i], multipleRoots ? 1 : 0, multipleRoots, multipleRoots, isLast, [], multipleRoots]);\n }\n\n while (stack.length > 0) {\n const [node, indent, justBranched, showConnector, isLast, gutters, isVirtualRootChild] = stack.pop();\n\n result.push({ node, indent, showConnector, isLast, gutters, isVirtualRootChild, multipleRoots });\n\n const children = node.children;\n const multipleChildren = children.length > 1;\n\n // Order children (active branch first)\n const orderedChildren = [...children].sort((a, b) => \n Number(containsActive.get(b)) - Number(containsActive.get(a))\n );\n\n // Calculate child indent (matches tree-selector.ts)\n let childIndent;\n if (multipleChildren) {\n // Parent branches: children get +1\n childIndent = indent + 1;\n } else if (justBranched && indent > 0) {\n // First generation after a branch: +1 for visual grouping\n childIndent = indent + 1;\n } else {\n // Single-child chain: stay flat\n childIndent = indent;\n }\n\n // Build gutters for children\n const connectorDisplayed = showConnector && !isVirtualRootChild;\n const currentDisplayIndent = multipleRoots ? Math.max(0, indent - 1) : indent;\n const connectorPosition = Math.max(0, currentDisplayIndent - 1);\n const childGutters = connectorDisplayed\n ? [...gutters, { position: connectorPosition, show: !isLast }]\n : gutters;\n\n // Add children in reverse order for stack\n for (let i = orderedChildren.length - 1; i >= 0; i--) {\n const childIsLast = i === orderedChildren.length - 1;\n stack.push([orderedChildren[i], childIndent, multipleChildren, multipleChildren, childIsLast, childGutters, false]);\n }\n }\n\n return result;\n }\n\n /**\n * Build ASCII prefix string for tree node.\n */\n function buildTreePrefix(flatNode) {\n const { indent, showConnector, isLast, gutters, isVirtualRootChild, multipleRoots } = flatNode;\n const displayIndent = multipleRoots ? Math.max(0, indent - 1) : indent;\n const connector = showConnector && !isVirtualRootChild ? (isLast ? '└─ ' : '├─ ') : '';\n const connectorPosition = connector ? displayIndent - 1 : -1;\n\n const totalChars = displayIndent * 3;\n const prefixChars = [];\n for (let i = 0; i < totalChars; i++) {\n const level = Math.floor(i / 3);\n const posInLevel = i % 3;\n\n const gutter = gutters.find(g => g.position === level);\n if (gutter) {\n prefixChars.push(posInLevel === 0 ? (gutter.show ? '│' : ' ') : ' ');\n } else if (connector && level === connectorPosition) {\n if (posInLevel === 0) {\n prefixChars.push(isLast ? '└' : '├');\n } else if (posInLevel === 1) {\n prefixChars.push('─');\n } else {\n prefixChars.push(' ');\n }\n } else {\n prefixChars.push(' ');\n }\n }\n return prefixChars.join('');\n }\n\n // ============================================================\n // FILTERING (pure data)\n // ============================================================\n\n let filterMode = 'default';\n let searchQuery = '';\n\n function hasTextContent(content) {\n if (typeof content === 'string') return content.trim().length > 0;\n if (Array.isArray(content)) {\n for (const c of content) {\n if (c.type === 'text' && c.text && c.text.trim().length > 0) return true;\n }\n }\n return false;\n }\n\n function extractContent(content) {\n if (typeof content === 'string') return content;\n if (Array.isArray(content)) {\n return content\n .filter(c => c.type === 'text' && c.text)\n .map(c => c.text)\n .join('');\n }\n return '';\n }\n\n function getSearchableText(entry, label) {\n const parts = [];\n if (label) parts.push(label);\n\n switch (entry.type) {\n case 'message': {\n const msg = entry.message;\n parts.push(msg.role);\n if (msg.content) parts.push(extractContent(msg.content));\n if (msg.role === 'bashExecution' && msg.command) parts.push(msg.command);\n if (msg.role === 'jsExecution' && msg.code) parts.push(msg.code);\n break;\n }\n case 'custom_message':\n parts.push(entry.customType);\n parts.push(typeof entry.content === 'string' ? entry.content : extractContent(entry.content));\n break;\n case 'compaction':\n parts.push('compaction');\n break;\n case 'branch_summary':\n parts.push('branch summary', entry.summary);\n break;\n case 'model_change':\n parts.push('model', entry.model);\n break;\n case 'thinking_level_change':\n parts.push('thinking', entry.thinkingLevel);\n break;\n case 'mode_change':\n parts.push('mode', entry.mode);\n break;\n }\n\n return parts.join(' ').toLowerCase();\n }\n\n /**\n * Filter flat nodes based on current filterMode and searchQuery.\n */\n function filterNodes(flatNodes, currentLeafId) {\n const searchTokens = searchQuery.toLowerCase().split(/\\s+/).filter(Boolean);\n\n return flatNodes.filter(flatNode => {\n const entry = flatNode.node.entry;\n const label = flatNode.node.label;\n const isCurrentLeaf = entry.id === currentLeafId;\n\n // Always show current leaf\n if (isCurrentLeaf) return true;\n\n // Hide assistant messages with only tool calls (no text) unless error/aborted\n if (entry.type === 'message' && entry.message.role === 'assistant') {\n const msg = entry.message;\n const hasText = hasTextContent(msg.content);\n const isErrorOrAborted = msg.stopReason && msg.stopReason !== 'stop' && msg.stopReason !== 'toolUse';\n if (!hasText && !isErrorOrAborted) return false;\n }\n\n // Apply filter mode\n const isSettingsEntry = ['label', 'custom', 'model_change', 'thinking_level_change', 'mode_change', 'ttsr_injection', 'session_init'].includes(entry.type);\n let passesFilter = true;\n\n switch (filterMode) {\n case 'user-only':\n passesFilter = entry.type === 'message' && entry.message.role === 'user';\n break;\n case 'no-tools':\n passesFilter = !isSettingsEntry && !(entry.type === 'message' && entry.message.role === 'toolResult');\n break;\n case 'labeled-only':\n passesFilter = label !== undefined;\n break;\n case 'all':\n passesFilter = true;\n break;\n default: // 'default'\n passesFilter = !isSettingsEntry;\n break;\n }\n\n if (!passesFilter) return false;\n\n // Apply search filter\n if (searchTokens.length > 0) {\n const nodeText = getSearchableText(entry, label);\n if (!searchTokens.every(t => nodeText.includes(t))) return false;\n }\n\n return true;\n });\n }\n\n // ============================================================\n // TREE DISPLAY TEXT (pure data -> string)\n // ============================================================\n\n function shortenPath(p) {\n if (typeof p !== 'string') return '';\n if (p.startsWith('/Users/')) {\n const parts = p.split('/');\n if (parts.length > 2) return '~' + p.slice(('/Users/' + parts[2]).length);\n }\n if (p.startsWith('/home/')) {\n const parts = p.split('/');\n if (parts.length > 2) return '~' + p.slice(('/home/' + parts[2]).length);\n }\n return p;\n }\n\n function formatToolCall(name, args) {\n switch (name) {\n case 'read': {\n const path = shortenPath(String(args.path || args.file_path || ''));\n const offset = args.offset;\n const limit = args.limit;\n let display = path;\n if (offset !== undefined || limit !== undefined) {\n const start = offset ?? 1;\n const end = limit !== undefined ? start + limit - 1 : '';\n display += `:${start}${end ? `-${end}` : ''}`;\n }\n return `[read: ${display}]`;\n }\n case 'write':\n return `[write: ${shortenPath(String(args.path || args.file_path || ''))}]`;\n case 'edit':\n return `[edit: ${shortenPath(String(args.path || args.file_path || ''))}]`;\n case 'bash': {\n const rawCmd = String(args.command || '');\n const cmd = rawCmd.replace(/[\\n\\t]/g, ' ').trim().slice(0, 50);\n return `[bash: ${cmd}${rawCmd.length > 50 ? '...' : ''}]`;\n }\n case 'grep':\n return `[grep: /${args.pattern || ''}/ in ${shortenPath(String(args.path || '.'))}]`;\n case 'find':\n return `[find: ${args.pattern || ''} in ${shortenPath(String(args.path || '.'))}]`;\n case 'ls':\n return `[ls: ${shortenPath(String(args.path || '.'))}]`;\n default: {\n const argsStr = JSON.stringify(args).slice(0, 40);\n return `[${name}: ${argsStr}${JSON.stringify(args).length > 40 ? '...' : ''}]`;\n }\n }\n }\n\n function escapeHtml(text) {\n const div = document.createElement('div');\n div.textContent = text;\n return div.innerHTML;\n }\n\n /**\n * Truncate string to maxLen chars, append \"...\" if truncated.\n */\n function truncate(s, maxLen = 100) {\n if (s.length <= maxLen) return s;\n return s.slice(0, maxLen) + '...';\n }\n\n /**\n * Get display text for tree node (returns HTML string).\n */\n function getTreeNodeDisplayHtml(entry, label) {\n const normalize = s => s.replace(/[\\n\\t]/g, ' ').trim();\n const labelHtml = label ? `<span class=\"tree-label\">[${escapeHtml(label)}]</span> ` : '';\n\n switch (entry.type) {\n case 'message': {\n const msg = entry.message;\n if (msg.role === 'user') {\n const content = truncate(normalize(extractContent(msg.content)));\n return labelHtml + `<span class=\"tree-role-user\">user:</span> ${escapeHtml(content)}`;\n }\n if (msg.role === 'assistant') {\n const textContent = truncate(normalize(extractContent(msg.content)));\n if (textContent) {\n return labelHtml + `<span class=\"tree-role-assistant\">assistant:</span> ${escapeHtml(textContent)}`;\n }\n if (msg.stopReason === 'aborted') {\n return labelHtml + `<span class=\"tree-role-assistant\">assistant:</span> <span class=\"tree-muted\">(aborted)</span>`;\n }\n if (msg.errorMessage) {\n return labelHtml + `<span class=\"tree-role-assistant\">assistant:</span> <span class=\"tree-error\">${escapeHtml(truncate(msg.errorMessage))}</span>`;\n }\n return labelHtml + `<span class=\"tree-role-assistant\">assistant:</span> <span class=\"tree-muted\">(no text)</span>`;\n }\n if (msg.role === 'toolResult') {\n const toolCall = msg.toolCallId ? toolCallMap.get(msg.toolCallId) : null;\n if (toolCall) {\n return labelHtml + `<span class=\"tree-role-tool\">${escapeHtml(formatToolCall(toolCall.name, toolCall.arguments))}</span>`;\n }\n return labelHtml + `<span class=\"tree-role-tool\">[${msg.toolName || 'tool'}]</span>`;\n }\n if (msg.role === 'bashExecution') {\n const cmd = truncate(normalize(msg.command || ''));\n return labelHtml + `<span class=\"tree-role-tool\">[bash]:</span> ${escapeHtml(cmd)}`;\n }\n if (msg.role === 'jsExecution') {\n const code = truncate(normalize(msg.code || ''));\n return labelHtml + `<span class=\"tree-role-tool\">[js]:</span> ${escapeHtml(code)}`;\n }\n return labelHtml + `<span class=\"tree-muted\">[${msg.role}]</span>`;\n }\n case 'compaction':\n return labelHtml + `<span class=\"tree-compaction\">[compaction: ${Math.round(entry.tokensBefore/1000)}k tokens]</span>`;\n case 'branch_summary': {\n const summary = truncate(normalize(entry.summary || ''));\n return labelHtml + `<span class=\"tree-branch-summary\">[branch summary]:</span> ${escapeHtml(summary)}`;\n }\n case 'custom_message': {\n const content = typeof entry.content === 'string' ? entry.content : extractContent(entry.content);\n return labelHtml + `<span class=\"tree-custom\">[${escapeHtml(entry.customType)}]:</span> ${escapeHtml(truncate(normalize(content)))}`;\n }\n case 'model_change':\n return labelHtml + `<span class=\"tree-muted\">[model: ${escapeHtml(entry.model)}]</span>`;\n case 'thinking_level_change':\n return labelHtml + `<span class=\"tree-muted\">[thinking: ${entry.thinkingLevel}]</span>`;\n case 'mode_change':\n return labelHtml + `<span class=\"tree-muted\">[mode: ${escapeHtml(entry.mode)}]</span>`;\n default:\n return labelHtml + `<span class=\"tree-muted\">[${entry.type}]</span>`;\n }\n }\n\n // ============================================================\n // TREE RENDERING (DOM manipulation)\n // ============================================================\n\n let currentLeafId = leafId;\n let currentTargetId = urlTargetId || leafId;\n let treeRendered = false;\n\n function renderTree() {\n const tree = buildTree();\n const activePathIds = buildActivePathIds(currentLeafId);\n const flatNodes = flattenTree(tree, activePathIds);\n const filtered = filterNodes(flatNodes, currentLeafId);\n const container = document.getElementById('tree-container');\n\n // Full render only on first call or when filter/search changes\n if (!treeRendered) {\n container.innerHTML = '';\n\n for (const flatNode of filtered) {\n const entry = flatNode.node.entry;\n const isOnPath = activePathIds.has(entry.id);\n const isTarget = entry.id === currentTargetId;\n\n const div = document.createElement('div');\n div.className = 'tree-node';\n if (isOnPath) div.classList.add('in-path');\n if (isTarget) div.classList.add('active');\n div.dataset.id = entry.id;\n\n const prefix = buildTreePrefix(flatNode);\n const prefixSpan = document.createElement('span');\n prefixSpan.className = 'tree-prefix';\n prefixSpan.textContent = prefix;\n\n const marker = document.createElement('span');\n marker.className = 'tree-marker';\n marker.textContent = isOnPath ? '•' : ' ';\n\n const content = document.createElement('span');\n content.className = 'tree-content';\n content.innerHTML = getTreeNodeDisplayHtml(entry, flatNode.node.label);\n\n div.appendChild(prefixSpan);\n div.appendChild(marker);\n div.appendChild(content);\n div.addEventListener('click', () => navigateTo(entry.id));\n\n container.appendChild(div);\n }\n\n treeRendered = true;\n } else {\n // Just update markers and classes\n const nodes = container.querySelectorAll('.tree-node');\n for (const node of nodes) {\n const id = node.dataset.id;\n const isOnPath = activePathIds.has(id);\n const isTarget = id === currentTargetId;\n\n node.classList.toggle('in-path', isOnPath);\n node.classList.toggle('active', isTarget);\n\n const marker = node.querySelector('.tree-marker');\n if (marker) {\n marker.textContent = isOnPath ? '•' : ' ';\n }\n }\n }\n\n document.getElementById('tree-status').textContent = `${filtered.length} / ${flatNodes.length} entries`;\n\n // Scroll active node into view after layout\n setTimeout(() => {\n const activeNode = container.querySelector('.tree-node.active');\n if (activeNode) {\n activeNode.scrollIntoView({ block: 'nearest' });\n }\n }, 0);\n }\n\n function forceTreeRerender() {\n treeRendered = false;\n renderTree();\n }\n\n // ============================================================\n // MESSAGE RENDERING\n // ============================================================\n\n function formatTokens(count) {\n if (count < 1000) return count.toString();\n if (count < 10000) return (count / 1000).toFixed(1) + 'k';\n if (count < 1000000) return Math.round(count / 1000) + 'k';\n return (count / 1000000).toFixed(1) + 'M';\n }\n\n function formatTimestamp(ts) {\n if (!ts) return '';\n const date = new Date(ts);\n return date.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', second: '2-digit' });\n }\n\n function replaceTabs(text) {\n return text.replace(/\\t/g, ' ');\n }\n\n /** Safely coerce value to string for display. Returns null if invalid type. */\n function str(value) {\n if (typeof value === 'string') return value;\n if (value == null) return '';\n return null;\n }\n\n function getLanguageFromPath(filePath) {\n const ext = filePath.split('.').pop()?.toLowerCase();\n const extToLang = {\n ts: 'typescript', tsx: 'typescript', js: 'javascript', jsx: 'javascript',\n py: 'python', rb: 'ruby', rs: 'rust', go: 'go', java: 'java',\n c: 'c', cpp: 'cpp', h: 'c', hpp: 'cpp', cs: 'csharp',\n php: 'php', sh: 'bash', bash: 'bash', zsh: 'bash',\n sql: 'sql', html: 'html', css: 'css', scss: 'scss',\n json: 'json', yaml: 'yaml', yml: 'yaml', xml: 'xml',\n md: 'markdown', dockerfile: 'dockerfile'\n };\n return extToLang[ext];\n }\n\n function findToolResult(toolCallId) {\n for (const entry of entries) {\n if (entry.type === 'message' && entry.message.role === 'toolResult') {\n if (entry.message.toolCallId === toolCallId) {\n return entry.message;\n }\n }\n }\n return null;\n }\n\n function formatExpandableOutput(text, maxLines, lang) {\n text = replaceTabs(text);\n const lines = text.split('\\n');\n const displayLines = lines.slice(0, maxLines);\n const remaining = lines.length - maxLines;\n\n if (lang) {\n let highlighted;\n try {\n highlighted = hljs.highlight(text, { language: lang }).value;\n } catch {\n highlighted = escapeHtml(text);\n }\n\n if (remaining > 0) {\n const previewCode = displayLines.join('\\n');\n let previewHighlighted;\n try {\n previewHighlighted = hljs.highlight(previewCode, { language: lang }).value;\n } catch {\n previewHighlighted = escapeHtml(previewCode);\n }\n\n return `<div class=\"tool-output expandable\" onclick=\"this.classList.toggle('expanded')\">\n <div class=\"output-preview\"><pre><code class=\"hljs\">${previewHighlighted}</code></pre>\n <div class=\"expand-hint\">... (${remaining} more lines)</div></div>\n <div class=\"output-full\"><pre><code class=\"hljs\">${highlighted}</code></pre></div></div>`;\n }\n\n return `<div class=\"tool-output\"><pre><code class=\"hljs\">${highlighted}</code></pre></div>`;\n }\n\n // Plain text output\n if (remaining > 0) {\n let out = '<div class=\"tool-output expandable\" onclick=\"this.classList.toggle(\\'expanded\\')\">';\n out += '<div class=\"output-preview\">';\n for (const line of displayLines) {\n out += `<div>${escapeHtml(replaceTabs(line))}</div>`;\n }\n out += `<div class=\"expand-hint\">... (${remaining} more lines)</div></div>`;\n out += '<div class=\"output-full\">';\n for (const line of lines) {\n out += `<div>${escapeHtml(replaceTabs(line))}</div>`;\n }\n out += '</div></div>';\n return out;\n }\n\n let out = '<div class=\"tool-output\">';\n for (const line of displayLines) {\n out += `<div>${escapeHtml(replaceTabs(line))}</div>`;\n }\n out += '</div>';\n return out;\n }\n\n // ============================================================\n // TOOL CALL RENDERING\n // ============================================================\n\n // Shared helpers for per-tool renderers.\n function toolHead(label, pathHtml, badges) {\n let html = '<div class=\"tool-header\"><span class=\"tool-name\">' + escapeHtml(label) + '</span>';\n if (pathHtml) html += ' <span class=\"tool-path\">' + pathHtml + '</span>';\n if (badges) {\n for (const badge of badges) {\n if (badge != null && badge !== '') {\n html += ' <span class=\"tool-badge\">' + escapeHtml(String(badge)) + '</span>';\n }\n }\n }\n html += '</div>';\n return html;\n }\n\n function invalidArgHtml() {\n return '<span class=\"tool-error\">[invalid arg]</span>';\n }\n\n function pathDisplay(filePath, offset, limit) {\n if (filePath == null) return invalidArgHtml();\n let html = escapeHtml(shortenPath(filePath || ''));\n if (offset !== undefined || limit !== undefined) {\n const start = offset == null ? 1 : offset;\n const end = limit !== undefined ? start + limit - 1 : '';\n html += '<span class=\"line-numbers\">:' + start + (end ? '-' + end : '') + '</span>';\n }\n return html;\n }\n\n function codeBlock(code, lang) {\n if (code == null || code === '') return '';\n const text = String(code);\n let highlighted;\n try {\n highlighted = lang ? hljs.highlight(text, { language: lang }).value : escapeHtml(text);\n } catch {\n highlighted = escapeHtml(text);\n }\n return '<div class=\"tool-output\"><pre><code class=\"hljs\">' + highlighted + '</code></pre></div>';\n }\n\n // Per-tool renderers. Each accepts (name, args, result, ctx) and returns the inner HTML.\n function renderBash(name, args, result, ctx) {\n const command = str(args.command);\n const cwd = str(args.cwd);\n const env = args.env && typeof args.env === 'object' ? args.env : null;\n const cmdDisplay = command === null ? invalidArgHtml() : escapeHtml(command || '...');\n let prefix = '';\n if (env) {\n for (const [k, v] of Object.entries(env)) {\n prefix += escapeHtml(k) + '=' + escapeHtml(String(v)) + ' ';\n }\n }\n let html = '<div class=\"tool-command\">$ ' + prefix + cmdDisplay + '</div>';\n const badges = [];\n if (cwd) badges.push('cwd=' + shortenPath(cwd));\n if (args.timeout) badges.push('timeout=' + args.timeout + 's');\n if (args.pty) badges.push('pty');\n if (args.head) badges.push('head=' + args.head);\n if (args.tail) badges.push('tail=' + args.tail);\n if (badges.length) {\n html += '<div class=\"tool-meta\">' + badges.map(b => '<span class=\"tool-badge\">' + escapeHtml(b) + '</span>').join(' ') + '</div>';\n }\n if (result) {\n html += ctx.renderResultImages();\n const output = ctx.getResultText().trim();\n if (output) html += formatExpandableOutput(output, 5);\n }\n return html;\n }\n\n function renderJsLike(name, args, result, ctx) {\n const lang = name === 'python' ? 'python' : 'javascript';\n const badges = [];\n if (args.cwd) badges.push('cwd=' + shortenPath(String(args.cwd)));\n if (args.timeout) badges.push('timeout=' + args.timeout + 's');\n if (args.reset) badges.push('reset');\n let html = toolHead(name, '', badges);\n const cells = Array.isArray(args.cells) ? args.cells : null;\n if (!cells) {\n html += '<div class=\"tool-error\">[missing cells]</div>';\n } else {\n for (const cell of cells) {\n html += '<div class=\"tool-cell\">';\n if (cell && cell.title) html += '<div class=\"tool-cell-title\">' + escapeHtml(String(cell.title)) + '</div>';\n const code = cell && typeof cell.code === 'string' ? cell.code : '';\n html += codeBlock(code, lang);\n html += '</div>';\n }\n }\n if (result) {\n html += ctx.renderResultImages();\n const output = ctx.getResultText();\n if (output) html += formatExpandableOutput(output, 10);\n }\n return html;\n }\n\n function renderRead(name, args, result, ctx) {\n const filePath = str(args.file_path == null ? args.path : args.file_path);\n let pathHtml = pathDisplay(filePath, args.offset, args.limit);\n if (args.sel) pathHtml += '<span class=\"line-numbers\">:' + escapeHtml(String(args.sel)) + '</span>';\n let html = toolHead('read', pathHtml);\n if (result) {\n html += ctx.renderResultImages();\n const output = ctx.getResultText();\n const lang = filePath ? getLanguageFromPath(filePath) : null;\n if (output) html += formatExpandableOutput(output, 10, lang);\n }\n return html;\n }\n\n function renderWrite(name, args, result, ctx) {\n const filePath = str(args.file_path == null ? args.path : args.file_path);\n const content = str(args.content);\n const pathHtml = filePath === null ? invalidArgHtml() : escapeHtml(shortenPath(filePath || ''));\n const lineCount = (content != null && content !== '') ? content.split('\\n').length : 0;\n const badges = lineCount > 10 ? ['(' + lineCount + ' lines)'] : null;\n let html = toolHead('write', pathHtml, badges);\n if (content === null) {\n html += '<div class=\"tool-error\">[invalid content arg - expected string]</div>';\n } else if (content) {\n const lang = filePath ? getLanguageFromPath(filePath) : null;\n html += formatExpandableOutput(content, 10, lang);\n }\n if (result) {\n const output = ctx.getResultText().trim();\n if (output) html += '<div class=\"tool-output\"><div>' + escapeHtml(output) + '</div></div>';\n }\n return html;\n }\n\n function renderEdit(name, args, result, ctx) {\n const filePath = str(args.file_path == null ? args.path : args.file_path);\n const pathHtml = filePath === null ? invalidArgHtml() : escapeHtml(shortenPath(filePath || ''));\n let html = toolHead('edit', pathHtml);\n if (Array.isArray(args.edits)) {\n html += '<div class=\"tool-args\">';\n for (const e of args.edits) {\n const op = e && typeof e.op === 'string' ? e.op : '?';\n const sel = e && typeof e.sel === 'string' ? e.sel : '?';\n html += '<div class=\"tool-arg\"><span class=\"tool-arg-key\">' + escapeHtml(op) + '</span> <span class=\"tool-arg-val\">' + escapeHtml(sel) + '</span></div>';\n }\n html += '</div>';\n }\n if (result?.details?.diff) {\n const diffLines = String(result.details.diff).split('\\n');\n html += '<div class=\"tool-diff\">';\n for (const line of diffLines) {\n const cls = line.match(/^\\+/) ? 'diff-added' : line.match(/^-/) ? 'diff-removed' : 'diff-context';\n html += '<div class=\"' + cls + '\">' + escapeHtml(replaceTabs(line)) + '</div>';\n }\n html += '</div>';\n } else if (result) {\n const output = ctx.getResultText().trim();\n if (output) html += '<div class=\"tool-output\"><pre>' + escapeHtml(output) + '</pre></div>';\n }\n return html;\n }\n\n function renderAstEdit(name, args, result, ctx) {\n const lang = args.lang || null;\n const pathHtml = args.path ? escapeHtml(shortenPath(String(args.path))) : '';\n const badges = [];\n if (lang) badges.push(lang);\n if (args.glob) badges.push('glob=' + args.glob);\n if (args.sel) badges.push('sel=' + args.sel);\n let html = toolHead('ast_edit', pathHtml, badges);\n if (Array.isArray(args.ops)) {\n for (const op of args.ops) {\n html += '<div class=\"tool-cell\">';\n html += '<div class=\"tool-cell-title\">pattern</div>';\n html += codeBlock(String(op?.pat == null ? '' : op.pat), lang);\n html += '<div class=\"tool-cell-title\">replacement</div>';\n html += codeBlock(String(op?.out == null ? '' : op.out), lang);\n html += '</div>';\n }\n }\n if (result) {\n const output = ctx.getResultText();\n if (output) html += formatExpandableOutput(output, 10);\n }\n return html;\n }\n\n function renderAstGrep(name, args, result, ctx) {\n const lang = args.lang || null;\n const pathHtml = args.path ? escapeHtml(shortenPath(String(args.path))) : '';\n const badges = [];\n if (lang) badges.push(lang);\n if (args.glob) badges.push('glob=' + args.glob);\n if (args.sel) badges.push('sel=' + args.sel);\n let html = toolHead('ast_grep', pathHtml, badges);\n if (Array.isArray(args.pat)) {\n for (const pat of args.pat) {\n html += '<div class=\"tool-cell\">' + codeBlock(String(pat == null ? '' : pat), lang) + '</div>';\n }\n }\n if (result) {\n const output = ctx.getResultText();\n if (output) html += formatExpandableOutput(output, 10);\n }\n return html;\n }\n\n function renderGrep(name, args, result, ctx) {\n const pattern = str(args.pattern);\n const pathHtml = args.path ? escapeHtml(shortenPath(String(args.path))) : escapeHtml('.');\n const patHtml = pattern === null ? invalidArgHtml() : escapeHtml(pattern);\n let head = '<span class=\"tool-name\">grep</span> <span class=\"tool-pattern\">/' + patHtml + '/</span>';\n head += ' <span class=\"tool-arg-key\">in</span> <span class=\"tool-path\">' + pathHtml + '</span>';\n const badges = [];\n if (args.glob) badges.push('glob=' + args.glob);\n if (args.type) badges.push('type=' + args.type);\n if (args.i) badges.push('i');\n if (args.multiline) badges.push('multiline');\n for (const b of badges) head += ' <span class=\"tool-badge\">' + escapeHtml(b) + '</span>';\n let html = '<div class=\"tool-header\">' + head + '</div>';\n if (result) {\n const output = ctx.getResultText();\n if (output) html += formatExpandableOutput(output, 10);\n }\n return html;\n }\n\n function renderFind(name, args, result, ctx) {\n const pattern = str(args.pattern);\n const patHtml = pattern === null ? invalidArgHtml() : escapeHtml(pattern);\n const badges = args.limit ? ['limit=' + args.limit] : null;\n let html = toolHead('find', '<span class=\"tool-pattern\">' + patHtml + '</span>', badges);\n if (result) {\n const output = ctx.getResultText();\n if (output) html += formatExpandableOutput(output, 10);\n }\n return html;\n }\n\n function renderLsp(name, args, result, ctx) {\n const action = str(args.action) || '?';\n let head = '<span class=\"tool-name\">lsp</span> <span class=\"tool-badge\">' + escapeHtml(action) + '</span>';\n if (args.file && args.file !== '*') {\n head += ' <span class=\"tool-path\">' + escapeHtml(shortenPath(String(args.file))) + '</span>';\n } else if (args.file === '*') {\n head += ' <span class=\"tool-badge\">workspace</span>';\n }\n if (args.line) head += '<span class=\"line-numbers\">:' + args.line + '</span>';\n if (args.symbol) head += ' <span class=\"tool-arg-val\">' + escapeHtml(String(args.symbol)) + '</span>';\n if (args.query) head += ' <span class=\"tool-arg-key\">query=</span><span class=\"tool-arg-val\">' + escapeHtml(String(args.query)) + '</span>';\n if (args.new_name) head += ' <span class=\"tool-arg-key\">→</span> <span class=\"tool-arg-val\">' + escapeHtml(String(args.new_name)) + '</span>';\n let html = '<div class=\"tool-header\">' + head + '</div>';\n if (result) {\n const output = ctx.getResultText();\n if (output) html += formatExpandableOutput(output, 12);\n }\n return html;\n }\n\n function renderTodoWrite(name, args, result, ctx) {\n let html = toolHead('todo_write');\n const ops = Array.isArray(args.ops) ? args.ops : null;\n if (ops) {\n html += '<div class=\"tool-args\">';\n for (const op of ops) {\n const t = op && op.op ? op.op : '?';\n let line = '<span class=\"tool-arg-key\">' + escapeHtml(t) + '</span>';\n if (op?.id) line += ' <span class=\"tool-arg-val\">' + escapeHtml(String(op.id)) + '</span>';\n if (op?.status) line += ' <span class=\"tool-badge\">' + escapeHtml(String(op.status)) + '</span>';\n if (op?.content) line += ' ' + escapeHtml(truncate(String(op.content), 80));\n if (op?.task && typeof op.task === 'object' && op.task.content) line += ' ' + escapeHtml(truncate(String(op.task.content), 80));\n html += '<div class=\"tool-arg\">' + line + '</div>';\n }\n html += '</div>';\n }\n const phases = result?.details?.phases;\n if (Array.isArray(phases)) {\n html += '<div class=\"todo-tree\">';\n for (const phase of phases) {\n html += '<div class=\"todo-phase\">' + escapeHtml(String(phase.name || '')) + '</div>';\n if (Array.isArray(phase.tasks)) {\n for (const task of phase.tasks) {\n const status = task.status || 'pending';\n const icon = status === 'completed' ? '✓' : status === 'in_progress' ? '→' : status === 'abandoned' ? '✕' : '○';\n html += '<div class=\"todo-task todo-' + status + '\"><span class=\"todo-icon\">' + icon + '</span> ' + escapeHtml(String(task.content || '')) + '</div>';\n }\n }\n }\n html += '</div>';\n } else if (result) {\n const output = ctx.getResultText();\n if (output) html += formatExpandableOutput(output, 8);\n }\n return html;\n }\n\n function renderTask(name, args, result, ctx) {\n const agent = str(args.agent) || '?';\n const tasks = Array.isArray(args.tasks) ? args.tasks : [];\n const badges = ['agent=' + agent, tasks.length + ' subtask' + (tasks.length === 1 ? '' : 's')];\n if (args.isolated) badges.push('isolated');\n let html = toolHead('task', '', badges);\n if (tasks.length) {\n html += '<div class=\"tool-args\">';\n for (const t of tasks) {\n const id = t?.id ? escapeHtml(String(t.id)) : '?';\n const desc = t?.description ? escapeHtml(String(t.description)) : '';\n html += '<div class=\"tool-arg\"><span class=\"tool-arg-key\">' + id + '</span> ' + desc + '</div>';\n }\n html += '</div>';\n }\n if (result) {\n const output = ctx.getResultText();\n if (output) html += formatExpandableOutput(output, 12);\n }\n return html;\n }\n\n function renderWebSearch(name, args, result, ctx) {\n const query = str(args.query);\n const queryHtml = query === null ? invalidArgHtml() : escapeHtml(query);\n const badges = [];\n if (args.recency) badges.push('recency=' + args.recency);\n if (args.limit) badges.push('limit=' + args.limit);\n let html = toolHead('web_search', '<span class=\"tool-pattern\">' + queryHtml + '</span>', badges);\n if (result) {\n const output = ctx.getResultText();\n if (output) html += formatExpandableOutput(output, 12, 'markdown');\n }\n return html;\n }\n\n function renderFetch(name, args, result, ctx) {\n const url = str(args.url) || '';\n const badges = args.method ? [String(args.method)] : null;\n let html = toolHead('fetch', '<span class=\"tool-path\">' + escapeHtml(url) + '</span>', badges);\n if (result) {\n const output = ctx.getResultText();\n if (output) html += formatExpandableOutput(output, 10);\n }\n return html;\n }\n\n function renderDebug(name, args, result, ctx) {\n const action = str(args.action) || '?';\n const badges = [];\n if (args.adapter) badges.push(args.adapter);\n if (args.program) badges.push('program=' + shortenPath(String(args.program)));\n if (args.file) badges.push('file=' + shortenPath(String(args.file)));\n if (args.line) badges.push('line=' + args.line);\n let head = '<span class=\"tool-name\">debug</span> <span class=\"tool-badge\">' + escapeHtml(action) + '</span>';\n for (const b of badges) head += ' <span class=\"tool-badge\">' + escapeHtml(String(b)) + '</span>';\n let html = '<div class=\"tool-header\">' + head + '</div>';\n if (args.expression) html += codeBlock(String(args.expression));\n if (result) {\n const output = ctx.getResultText();\n if (output) html += formatExpandableOutput(output, 10);\n }\n return html;\n }\n\n function renderPuppeteer(name, args, result, ctx) {\n const action = str(args.action) || '?';\n const badges = [];\n if (args.url) badges.push(String(args.url));\n if (args.selector) badges.push('selector=' + args.selector);\n if (args.element_id != null) badges.push('id=' + args.element_id);\n let head = '<span class=\"tool-name\">puppeteer</span> <span class=\"tool-badge\">' + escapeHtml(action) + '</span>';\n for (const b of badges) head += ' <span class=\"tool-badge\">' + escapeHtml(String(b)) + '</span>';\n let html = '<div class=\"tool-header\">' + head + '</div>';\n if (args.script) html += codeBlock(String(args.script), 'javascript');\n if (args.text) html += '<div class=\"tool-output\"><div>' + escapeHtml(String(args.text)) + '</div></div>';\n if (result) {\n html += ctx.renderResultImages();\n const output = ctx.getResultText();\n if (output) html += formatExpandableOutput(output, 10);\n }\n return html;\n }\n\n function renderInspectImage(name, args, result, ctx) {\n const p = str(args.path == null ? args.url : args.path) || '';\n let html = toolHead('inspect_image', escapeHtml(shortenPath(p)));\n if (result) {\n html += ctx.renderResultImages();\n const output = ctx.getResultText();\n if (output) html += formatExpandableOutput(output, 8);\n }\n return html;\n }\n\n function renderGenerateImage(name, args, result, ctx) {\n const subject = str(args.subject) || '';\n const badges = args.aspect_ratio ? [String(args.aspect_ratio)] : null;\n let html = toolHead('generate_image', '', badges);\n if (subject) html += '<div class=\"tool-output\"><div>' + escapeHtml(subject) + '</div></div>';\n if (result) {\n html += ctx.renderResultImages();\n }\n return html;\n }\n\n function renderAsk(name, args, result, ctx) {\n let html = toolHead('ask');\n const questions = Array.isArray(args.questions) ? args.questions : null;\n if (questions) {\n html += '<div class=\"tool-args\">';\n for (const q of questions) {\n html += '<div class=\"tool-arg\"><span class=\"tool-arg-key\">Q:</span> ' + escapeHtml(String(q?.question || '')) + '</div>';\n if (Array.isArray(q?.options)) {\n for (const opt of q.options) {\n html += '<div class=\"tool-arg\"><span class=\"tool-arg-key\"> -</span> ' + escapeHtml(String(opt?.label || '')) + '</div>';\n }\n }\n }\n html += '</div>';\n }\n if (result) {\n const output = ctx.getResultText();\n if (output) html += formatExpandableOutput(output, 8);\n }\n return html;\n }\n\n function renderExitPlanMode(name, args, result, ctx) {\n const badges = args.title ? [String(args.title)] : null;\n let html = toolHead('exit_plan_mode', '', badges);\n if (result) {\n const output = ctx.getResultText();\n if (output) html += formatExpandableOutput(output, 8);\n }\n return html;\n }\n\n function renderResolve(name, args, result, ctx) {\n const action = str(args.action) || '?';\n let html = toolHead('resolve', '', [action]);\n if (args.reason) html += '<div class=\"tool-output\"><div>' + escapeHtml(String(args.reason)) + '</div></div>';\n if (result) {\n const output = ctx.getResultText();\n if (output) html += formatExpandableOutput(output, 6);\n }\n return html;\n }\n\n function renderGh(name, args, result, ctx) {\n const badges = [];\n if (args.repo) badges.push(String(args.repo));\n if (args.issue) badges.push('#' + args.issue);\n if (args.pr) badges.push('PR ' + args.pr);\n if (args.branch) badges.push('branch=' + args.branch);\n if (args.query) badges.push('query=' + args.query);\n if (args.run) badges.push('run=' + args.run);\n let html = toolHead(name, '', badges);\n if (result) {\n const output = ctx.getResultText();\n if (output) html += formatExpandableOutput(output, 12, 'markdown');\n }\n return html;\n }\n\n function renderMermaid(name, args, result, ctx) {\n let html = toolHead('render_mermaid');\n const code = args.code || args.source;\n if (code) html += codeBlock(String(code), 'mermaid');\n if (result) {\n html += ctx.renderResultImages();\n const output = ctx.getResultText();\n if (output) html += formatExpandableOutput(output, 6);\n }\n return html;\n }\n\n function renderYield(name, args, result, ctx) {\n let html = toolHead('yield');\n if (args.data !== undefined) {\n html += '<div class=\"tool-output\"><pre>' + escapeHtml(JSON.stringify(args.data, null, 2)) + '</pre></div>';\n }\n if (result) {\n const output = ctx.getResultText();\n if (output) html += formatExpandableOutput(output, 6);\n }\n return html;\n }\n\n function renderReportFinding(name, args, result, ctx) {\n const badges = [];\n if (args.priority) badges.push('priority=' + args.priority);\n if (args.confidence != null) badges.push('confidence=' + args.confidence);\n if (args.file_path) badges.push(shortenPath(String(args.file_path)));\n let html = toolHead('report_finding', args.title ? escapeHtml(String(args.title)) : '', badges);\n if (args.body) html += '<div class=\"tool-output\"><div>' + escapeHtml(String(args.body)) + '</div></div>';\n return html;\n }\n\n function renderReportToolIssue(name, args, result, ctx) {\n const pathHtml = args.tool ? '<span class=\"tool-badge\">' + escapeHtml(String(args.tool)) + '</span>' : '';\n let html = toolHead('report_tool_issue', pathHtml);\n if (args.report) html += '<div class=\"tool-output\"><div>' + escapeHtml(String(args.report)) + '</div></div>';\n return html;\n }\n\n function renderCalc(name, args, result, ctx) {\n let html = toolHead('calc');\n const exprs = args.expressions || (args.expression ? [args.expression] : []);\n for (const e of exprs) html += codeBlock(String(e), 'plaintext');\n if (result) {\n const output = ctx.getResultText();\n if (output) html += formatExpandableOutput(output, 6);\n }\n return html;\n }\n\n function renderPoll(name, args, result, ctx) {\n const badges = [];\n const jobs = Array.isArray(args.jobs) ? args.jobs : Array.isArray(args.jobIds) ? args.jobIds : [];\n if (jobs.length > 0) badges.push(jobs.length + ' job' + (jobs.length === 1 ? '' : 's'));\n let html = toolHead('poll', '', badges);\n if (result) {\n const output = ctx.getResultText();\n if (output) html += formatExpandableOutput(output, 8);\n }\n return html;\n }\n\n function renderCancelJob(name, args, result, ctx) {\n let html = toolHead('cancel_job', args.jobId ? escapeHtml(String(args.jobId)) : '');\n if (result) {\n const output = ctx.getResultText();\n if (output) html += formatExpandableOutput(output, 4);\n }\n return html;\n }\n\n function renderGenericTool(name, args, result, ctx) {\n let html = toolHead(name);\n const argText = JSON.stringify(args, null, 2);\n if (argText && argText !== '{}') {\n html += '<div class=\"tool-output\"><pre>' + escapeHtml(argText) + '</pre></div>';\n }\n if (result) {\n html += ctx.renderResultImages();\n const output = ctx.getResultText();\n if (output) html += formatExpandableOutput(output, 10);\n }\n return html;\n }\n\n const TOOL_RENDERERS = {\n bash: renderBash,\n js: renderJsLike,\n python: renderJsLike,\n notebook: renderJsLike,\n read: renderRead,\n write: renderWrite,\n edit: renderEdit,\n ast_edit: renderAstEdit,\n ast_grep: renderAstGrep,\n grep: renderGrep,\n find: renderFind,\n lsp: renderLsp,\n todo_write: renderTodoWrite,\n task: renderTask,\n web_search: renderWebSearch,\n fetch: renderFetch,\n debug: renderDebug,\n puppeteer: renderPuppeteer,\n inspect_image: renderInspectImage,\n generate_image: renderGenerateImage,\n ask: renderAsk,\n exit_plan_mode: renderExitPlanMode,\n resolve: renderResolve,\n gh_repo_view: renderGh,\n gh_issue_view: renderGh,\n gh_pr_view: renderGh,\n gh_pr_diff: renderGh,\n gh_pr_checkout: renderGh,\n gh_pr_push: renderGh,\n gh_run_watch: renderGh,\n gh_search_issues: renderGh,\n gh_search_prs: renderGh,\n render_mermaid: renderMermaid,\n yield: renderYield,\n report_finding: renderReportFinding,\n report_tool_issue: renderReportToolIssue,\n calc: renderCalc,\n calculator: renderCalc,\n await: renderPoll,\n poll: renderPoll,\n cancel_job: renderCancelJob,\n };\n\n function renderToolCall(call) {\n const result = findToolResult(call.id);\n const isError = result?.isError || false;\n const statusClass = result ? (isError ? 'error' : 'success') : 'pending';\n const args = call.arguments || {};\n const name = call.name;\n\n const ctx = {\n getResultText: () => {\n if (!result) return '';\n const textBlocks = result.content.filter(c => c.type === 'text');\n return textBlocks.map(c => c.text).join('\\n');\n },\n getResultImages: () => {\n if (!result) return [];\n return result.content.filter(c => c.type === 'image');\n },\n renderResultImages: () => {\n if (!result) return '';\n const images = result.content.filter(c => c.type === 'image');\n if (images.length === 0) return '';\n return '<div class=\"tool-images\">' +\n images.map(img => '<img src=\"data:' + img.mimeType + ';base64,' + img.data + '\" class=\"tool-image\" />').join('') +\n '</div>';\n },\n };\n\n const renderer = TOOL_RENDERERS[name] || renderGenericTool;\n let html = '<div class=\"tool-execution ' + statusClass + '\">';\n try {\n html += renderer(name, args, result, ctx);\n } catch (err) {\n html += renderGenericTool(name, args, result, ctx);\n }\n html += '</div>';\n return html;\n }\n\n\n /**\n * Build a shareable URL for a specific message.\n * URL format: base?gistId&leafId=<leafId>&targetId=<entryId>\n */\n function buildShareUrl(entryId) {\n // Check for injected base URL (used when loaded in iframe via srcdoc)\n const baseUrlMeta = document.querySelector('meta[name=\"pi-share-base-url\"]');\n const baseUrl = baseUrlMeta ? baseUrlMeta.content : window.location.href.split('?')[0];\n\n const url = new URL(window.location.href);\n // Find the gist ID (first query param without value, e.g., ?abc123)\n const gistId = Array.from(url.searchParams.keys()).find(k => !url.searchParams.get(k));\n\n // Build the share URL\n const params = new URLSearchParams();\n params.set('leafId', currentLeafId);\n params.set('targetId', entryId);\n\n // If we have an injected base URL (iframe context), use it directly\n if (baseUrlMeta) {\n return `${baseUrl}&${params.toString()}`;\n }\n\n // Otherwise build from current location (direct file access)\n url.search = gistId ? `?${gistId}&${params.toString()}` : `?${params.toString()}`;\n return url.toString();\n }\n\n /**\n * Copy text to clipboard with visual feedback.\n * Uses navigator.clipboard with fallback to execCommand for HTTP contexts.\n */\n async function copyToClipboard(text, button) {\n let success = false;\n try {\n if (navigator.clipboard && navigator.clipboard.writeText) {\n await navigator.clipboard.writeText(text);\n success = true;\n }\n } catch {\n // Clipboard API failed, try fallback\n }\n\n // Fallback for HTTP or when Clipboard API is unavailable\n if (!success) {\n try {\n const textarea = document.createElement('textarea');\n textarea.value = text;\n textarea.style.position = 'fixed';\n textarea.style.opacity = '0';\n document.body.appendChild(textarea);\n textarea.select();\n success = document.execCommand('copy');\n document.body.removeChild(textarea);\n } catch {\n }\n }\n\n if (success && button) {\n const originalHtml = button.innerHTML;\n button.innerHTML = '✓';\n button.classList.add('copied');\n setTimeout(() => {\n button.innerHTML = originalHtml;\n button.classList.remove('copied');\n }, 1500);\n }\n }\n\n /**\n * Render the copy-link button HTML for a message.\n */\n function renderCopyLinkButton(entryId) {\n return `<button class=\"copy-link-btn\" data-entry-id=\"${entryId}\" title=\"Copy link to this message\">\n <svg width=\"14\" height=\"14\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\">\n <path d=\"M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71\"/>\n <path d=\"M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71\"/>\n </svg>\n </button>`;\n }\n\n function renderEntry(entry) {\n const ts = formatTimestamp(entry.timestamp);\n const tsHtml = ts ? `<div class=\"message-timestamp\">${ts}</div>` : '';\n const entryId = `entry-${entry.id}`;\n const copyBtnHtml = renderCopyLinkButton(entry.id);\n\n if (entry.type === 'message') {\n const msg = entry.message;\n\n if (msg.role === 'user') {\n let html = `<div class=\"user-message\" id=\"${entryId}\">${copyBtnHtml}${tsHtml}`;\n const content = msg.content;\n\n if (Array.isArray(content)) {\n const images = content.filter(c => c.type === 'image');\n if (images.length > 0) {\n html += '<div class=\"message-images\">';\n for (const img of images) {\n html += `<img src=\"data:${img.mimeType};base64,${img.data}\" class=\"message-image\" />`;\n }\n html += '</div>';\n }\n }\n\n const text = typeof content === 'string' ? content : \n content.filter(c => c.type === 'text').map(c => c.text).join('\\n');\n if (text.trim()) {\n html += `<div class=\"markdown-content\">${safeMarkedParse(text)}</div>`;\n }\n html += '</div>';\n return html;\n }\n\n if (msg.role === 'assistant') {\n let html = `<div class=\"assistant-message\" id=\"${entryId}\">${copyBtnHtml}${tsHtml}`;\n\n for (const block of msg.content) {\n if (block.type === 'text' && block.text.trim()) {\n html += `<div class=\"assistant-text markdown-content\">${safeMarkedParse(block.text)}</div>`;\n } else if (block.type === 'thinking' && block.thinking.trim()) {\n html += `<div class=\"thinking-block\">\n <div class=\"thinking-text\">${escapeHtml(block.thinking)}</div>\n <div class=\"thinking-collapsed\">Thinking ...</div>\n </div>`;\n }\n }\n\n for (const block of msg.content) {\n if (block.type === 'toolCall') {\n html += renderToolCall(block);\n }\n }\n\n if (msg.stopReason === 'aborted') {\n html += '<div class=\"error-text\">Aborted</div>';\n } else if (msg.stopReason === 'error') {\n html += `<div class=\"error-text\">Error: ${escapeHtml(msg.errorMessage || 'Unknown error')}</div>`;\n }\n\n html += '</div>';\n return html;\n }\n\n if (msg.role === 'bashExecution') {\n const isError = msg.cancelled || (msg.exitCode !== 0 && msg.exitCode !== null);\n let html = `<div class=\"tool-execution ${isError ? 'error' : 'success'}\" id=\"${entryId}\">${tsHtml}`;\n html += `<div class=\"tool-command\">$ ${escapeHtml(msg.command)}</div>`;\n if (msg.output) html += formatExpandableOutput(msg.output, 10);\n if (msg.cancelled) {\n html += '<div style=\"color: var(--warning)\">(cancelled)</div>';\n } else if (msg.exitCode !== 0 && msg.exitCode !== null) {\n html += `<div style=\"color: var(--error)\">(exit ${msg.exitCode})</div>`;\n }\n html += '</div>';\n return html;\n }\n\n if (msg.role === 'jsExecution') {\n const isError = msg.cancelled || (msg.exitCode !== 0 && msg.exitCode !== null);\n let html = `<div class=\"tool-execution ${isError ? 'error' : 'success'}\" id=\"${entryId}\">${tsHtml}`;\n html += `<div class=\"tool-command\">$ ${escapeHtml(msg.code)}</div>`;\n if (msg.output) html += formatExpandableOutput(msg.output, 10);\n if (msg.cancelled) {\n html += '<div style=\"color: var(--warning)\">(cancelled)</div>';\n } else if (msg.exitCode !== 0 && msg.exitCode !== null) {\n html += `<div style=\"color: var(--error)\">(exit ${msg.exitCode})</div>`;\n }\n html += '</div>';\n return html;\n }\n\n if (msg.role === 'toolResult') return '';\n }\n\n if (entry.type === 'model_change') {\n const html = `<div class=\"model-change\" id=\"${entryId}\">${tsHtml}Switched to model: <span class=\"model-name\">${escapeHtml(entry.model)}</span></div>`;\n return html;\n }\n\n if (entry.type === 'thinking_level_change') {\n const html = `<div class=\"thinking-change\" id=\"${entryId}\">${tsHtml}Thinking level: <span class=\"thinking-level\">${escapeHtml(entry.thinkingLevel)}</span></div>`;\n return html;\n }\n\n\n if (entry.type === 'compaction') {\n return `<div class=\"compaction\" id=\"${entryId}\" onclick=\"this.classList.toggle('expanded')\">\n <div class=\"compaction-label\">[compaction]</div>\n <div class=\"compaction-collapsed\">Compacted from ${entry.tokensBefore.toLocaleString()} tokens</div>\n <div class=\"compaction-content\"><strong>Compacted from ${entry.tokensBefore.toLocaleString()} tokens</strong>\\n\\n${escapeHtml(entry.summary)}</div>\n </div>`;\n }\n\n if (entry.type === 'branch_summary') {\n return `<div class=\"branch-summary\" id=\"${entryId}\">${tsHtml}\n <div class=\"branch-summary-header\">Branch Summary</div>\n <div class=\"markdown-content\">${safeMarkedParse(entry.summary)}</div>\n </div>`;\n }\n\n if (entry.type === 'custom_message' && entry.display) {\n return `<div class=\"hook-message\" id=\"${entryId}\">${tsHtml}\n <div class=\"hook-type\">[${escapeHtml(entry.customType)}]</div>\n <div class=\"markdown-content\">${safeMarkedParse(typeof entry.content === 'string' ? entry.content : JSON.stringify(entry.content))}</div>\n </div>`;\n }\n\n return '';\n }\n\n // ============================================================\n // HEADER / STATS\n // ============================================================\n\n function computeStats(entryList) {\n let userMessages = 0, assistantMessages = 0, toolResults = 0;\n let customMessages = 0, compactions = 0, branchSummaries = 0, toolCalls = 0;\n const tokens = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };\n const cost = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };\n const models = new Set();\n\n for (const entry of entryList) {\n if (entry.type === 'message') {\n const msg = entry.message;\n if (msg.role === 'user') userMessages++;\n if (msg.role === 'assistant') {\n assistantMessages++;\n if (msg.model) models.add(msg.provider ? `${msg.provider}/${msg.model}` : msg.model);\n if (msg.usage) {\n tokens.input += msg.usage.input || 0;\n tokens.output += msg.usage.output || 0;\n tokens.cacheRead += msg.usage.cacheRead || 0;\n tokens.cacheWrite += msg.usage.cacheWrite || 0;\n if (msg.usage.cost) {\n cost.input += msg.usage.cost.input || 0;\n cost.output += msg.usage.cost.output || 0;\n cost.cacheRead += msg.usage.cost.cacheRead || 0;\n cost.cacheWrite += msg.usage.cost.cacheWrite || 0;\n }\n }\n toolCalls += msg.content.filter(c => c.type === 'toolCall').length;\n }\n if (msg.role === 'toolResult') toolResults++;\n } else if (entry.type === 'compaction') {\n compactions++;\n } else if (entry.type === 'branch_summary') {\n branchSummaries++;\n } else if (entry.type === 'custom_message') {\n customMessages++;\n }\n }\n\n return { userMessages, assistantMessages, toolResults, customMessages, compactions, branchSummaries, toolCalls, tokens, cost, models: Array.from(models) };\n }\n\n const globalStats = computeStats(entries);\n\n function renderHeader() {\n const totalCost = globalStats.cost.input + globalStats.cost.output + globalStats.cost.cacheRead + globalStats.cost.cacheWrite;\n\n const tokenParts = [];\n if (globalStats.tokens.input) tokenParts.push(`↑${formatTokens(globalStats.tokens.input)}`);\n if (globalStats.tokens.output) tokenParts.push(`↓${formatTokens(globalStats.tokens.output)}`);\n if (globalStats.tokens.cacheRead) tokenParts.push(`R${formatTokens(globalStats.tokens.cacheRead)}`);\n if (globalStats.tokens.cacheWrite) tokenParts.push(`W${formatTokens(globalStats.tokens.cacheWrite)}`);\n\n const msgParts = [];\n if (globalStats.userMessages) msgParts.push(`${globalStats.userMessages} user`);\n if (globalStats.assistantMessages) msgParts.push(`${globalStats.assistantMessages} assistant`);\n if (globalStats.toolResults) msgParts.push(`${globalStats.toolResults} tool results`);\n if (globalStats.customMessages) msgParts.push(`${globalStats.customMessages} custom`);\n if (globalStats.compactions) msgParts.push(`${globalStats.compactions} compactions`);\n if (globalStats.branchSummaries) msgParts.push(`${globalStats.branchSummaries} branch summaries`);\n\n let html = `\n <div class=\"header\">\n <h1>Session: ${escapeHtml(header?.id || 'unknown')}</h1>\n <div class=\"help-bar\">Ctrl+T toggle thinking · Ctrl+O toggle tools</div>\n <div class=\"header-info\">\n <div class=\"info-item\"><span class=\"info-label\">Date:</span><span class=\"info-value\">${header?.timestamp ? new Date(header.timestamp).toLocaleString() : 'unknown'}</span></div>\n <div class=\"info-item\"><span class=\"info-label\">Models:</span><span class=\"info-value\">${globalStats.models.join(', ') || 'unknown'}</span></div>\n <div class=\"info-item\"><span class=\"info-label\">Messages:</span><span class=\"info-value\">${msgParts.join(', ') || '0'}</span></div>\n <div class=\"info-item\"><span class=\"info-label\">Tool Calls:</span><span class=\"info-value\">${globalStats.toolCalls}</span></div>\n <div class=\"info-item\"><span class=\"info-label\">Tokens:</span><span class=\"info-value\">${tokenParts.join(' ') || '0'}</span></div>\n <div class=\"info-item\"><span class=\"info-label\">Cost:</span><span class=\"info-value\">${totalCost.toFixed(3)}</span></div>\n </div>\n </div>`;\n\n if (systemPrompt) {\n html += `<div class=\"system-prompt\">\n <div class=\"system-prompt-header\">System Prompt</div>\n <div class=\"system-prompt-content\">${escapeHtml(systemPrompt)}</div>\n </div>`;\n }\n\n if (tools && tools.length > 0) {\n html += `<div class=\"tools-list\">\n <div class=\"tools-header\">Available Tools</div>\n <div class=\"tools-content\">\n ${tools.map(t => `<div class=\"tool-item\"><span class=\"tool-item-name\">${escapeHtml(t.name)}</span> - <span class=\"tool-item-desc\">${escapeHtml(t.description)}</span></div>`).join('')}\n </div>\n </div>`;\n }\n\n return html;\n }\n\n // ============================================================\n // NAVIGATION\n // ============================================================\n\n // Cache for rendered entry DOM nodes\n const entryCache = new Map();\n\n function renderEntryToNode(entry) {\n // Check cache first\n if (entryCache.has(entry.id)) {\n return entryCache.get(entry.id).cloneNode(true);\n }\n\n // Render to HTML string, then parse to node\n const html = renderEntry(entry);\n if (!html) return null;\n\n const template = document.createElement('template');\n template.innerHTML = html;\n const node = template.content.firstElementChild;\n\n // Cache the node\n if (node) {\n entryCache.set(entry.id, node.cloneNode(true));\n }\n return node;\n }\n\n function navigateTo(targetId, scrollMode = 'target', scrollToEntryId = null) {\n currentLeafId = targetId;\n currentTargetId = scrollToEntryId || targetId;\n const path = getPath(targetId);\n\n renderTree();\n\n document.getElementById('header-container').innerHTML = renderHeader();\n\n // Build messages using cached DOM nodes\n const messagesEl = document.getElementById('messages');\n const fragment = document.createDocumentFragment();\n\n for (const entry of path) {\n const node = renderEntryToNode(entry);\n if (node) {\n fragment.appendChild(node);\n }\n }\n\n messagesEl.innerHTML = '';\n messagesEl.appendChild(fragment);\n\n // Attach click handlers for copy-link buttons\n messagesEl.querySelectorAll('.copy-link-btn').forEach(btn => {\n btn.addEventListener('click', (e) => {\n e.stopPropagation();\n const entryId = btn.dataset.entryId;\n const shareUrl = buildShareUrl(entryId);\n copyToClipboard(shareUrl, btn);\n });\n });\n\n // Use setTimeout(0) to ensure DOM is fully laid out before scrolling\n setTimeout(() => {\n const content = document.getElementById('content');\n if (scrollMode === 'bottom') {\n content.scrollTop = content.scrollHeight;\n } else if (scrollMode === 'target') {\n const scrollTargetId = scrollToEntryId || targetId;\n const targetEl = document.getElementById(`entry-${scrollTargetId}`);\n if (targetEl) {\n targetEl.scrollIntoView({ block: 'center' });\n if (scrollToEntryId) {\n targetEl.classList.add('highlight');\n setTimeout(() => targetEl.classList.remove('highlight'), 2000);\n }\n }\n }\n }, 0);\n }\n\n // ============================================================\n // INITIALIZATION\n // ============================================================\n\n // Escape HTML tags in text (but not code blocks)\n function escapeHtmlTags(text) {\n return text.replace(/<(?=[a-zA-Z\\/])/g, '&lt;');\n }\n\n // Configure marked with syntax highlighting and HTML escaping for text\n marked.use({\n breaks: true,\n gfm: true,\n renderer: {\n // Code blocks: syntax highlight, no HTML escaping\n code(token) {\n const code = token.text;\n const lang = token.lang;\n let highlighted;\n if (lang && hljs.getLanguage(lang)) {\n try {\n highlighted = hljs.highlight(code, { language: lang }).value;\n } catch {\n highlighted = escapeHtml(code);\n }\n } else {\n // Auto-detect language if not specified\n try {\n highlighted = hljs.highlightAuto(code).value;\n } catch {\n highlighted = escapeHtml(code);\n }\n }\n return `<pre><code class=\"hljs\">${highlighted}</code></pre>`;\n },\n // Text content: escape HTML tags\n text(token) {\n return escapeHtmlTags(escapeHtml(token.text));\n },\n // Inline code: escape HTML\n codespan(token) {\n return `<code>${escapeHtml(token.text)}</code>`;\n }\n }\n });\n\n // Simple marked parse (escaping handled in renderers)\n function safeMarkedParse(text) {\n return marked.parse(text);\n }\n\n // Search input\n const searchInput = document.getElementById('tree-search');\n searchInput.addEventListener('input', (e) => {\n searchQuery = e.target.value;\n forceTreeRerender();\n });\n\n // Filter buttons\n document.querySelectorAll('.filter-btn').forEach(btn => {\n btn.addEventListener('click', () => {\n document.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active'));\n btn.classList.add('active');\n filterMode = btn.dataset.filter;\n forceTreeRerender();\n });\n });\n\n // Sidebar toggle\n const sidebar = document.getElementById('sidebar');\n const overlay = document.getElementById('sidebar-overlay');\n const hamburger = document.getElementById('hamburger');\n const sidebarResizer = document.getElementById('sidebar-resizer');\n const SIDEBAR_WIDTH_STORAGE_KEY = 'pi-share:v1:sidebar-width';\n const MIN_CONTENT_WIDTH = 320;\n\n function isMobileLayout() {\n return window.matchMedia('(max-width: 900px)').matches;\n }\n\n function getSidebarBounds() {\n const rootStyles = getComputedStyle(document.documentElement);\n const minWidth = parseFloat(rootStyles.getPropertyValue('--sidebar-min-width')) || 240;\n const maxWidth = parseFloat(rootStyles.getPropertyValue('--sidebar-max-width')) || 720;\n const viewportMaxWidth = window.innerWidth - MIN_CONTENT_WIDTH;\n return {\n minWidth,\n maxWidth: Math.max(minWidth, Math.min(maxWidth, viewportMaxWidth))\n };\n }\n\n function clampSidebarWidth(width) {\n const { minWidth, maxWidth } = getSidebarBounds();\n return Math.max(minWidth, Math.min(maxWidth, width));\n }\n\n function applySidebarWidth(width) {\n document.documentElement.style.setProperty('--sidebar-width', `${Math.round(clampSidebarWidth(width))}px`);\n }\n\n function loadSidebarWidth() {\n try {\n const raw = localStorage.getItem(SIDEBAR_WIDTH_STORAGE_KEY);\n if (raw === null) return null;\n const width = Number(raw);\n return Number.isFinite(width) ? width : null;\n } catch {\n return null;\n }\n }\n\n function saveSidebarWidth(width) {\n try {\n localStorage.setItem(SIDEBAR_WIDTH_STORAGE_KEY, String(Math.round(clampSidebarWidth(width))));\n } catch {\n // Ignore storage failures (e.g. private browsing restrictions)\n }\n }\n\n function setupSidebarResize() {\n const savedWidth = loadSidebarWidth();\n if (savedWidth !== null) {\n applySidebarWidth(savedWidth);\n }\n\n if (!sidebarResizer) return;\n\n let cleanupDrag = null;\n\n const stopDrag = (pointerId) => {\n if (cleanupDrag) {\n cleanupDrag(pointerId);\n cleanupDrag = null;\n }\n };\n\n sidebarResizer.addEventListener('pointerdown', (e) => {\n if (isMobileLayout()) return;\n\n e.preventDefault();\n const startX = e.clientX;\n const startWidth = sidebar.getBoundingClientRect().width;\n document.body.classList.add('sidebar-resizing');\n sidebarResizer.setPointerCapture?.(e.pointerId);\n\n const onPointerMove = (event) => {\n applySidebarWidth(startWidth + (event.clientX - startX));\n };\n\n cleanupDrag = (pointerIdToRelease) => {\n document.body.classList.remove('sidebar-resizing');\n sidebarResizer.releasePointerCapture?.(pointerIdToRelease);\n window.removeEventListener('pointermove', onPointerMove);\n window.removeEventListener('pointerup', onPointerUp);\n window.removeEventListener('pointercancel', onPointerCancel);\n saveSidebarWidth(sidebar.getBoundingClientRect().width);\n };\n\n const onPointerUp = (event) => stopDrag(event.pointerId);\n const onPointerCancel = (event) => stopDrag(event.pointerId);\n\n window.addEventListener('pointermove', onPointerMove);\n window.addEventListener('pointerup', onPointerUp);\n window.addEventListener('pointercancel', onPointerCancel);\n });\n\n sidebarResizer.addEventListener('dblclick', () => {\n if (isMobileLayout()) return;\n applySidebarWidth(400);\n saveSidebarWidth(400);\n });\n\n window.addEventListener('resize', () => {\n if (isMobileLayout()) return;\n applySidebarWidth(sidebar.getBoundingClientRect().width);\n });\n }\n\n setupSidebarResize();\n\n hamburger.addEventListener('click', () => {\n sidebar.classList.add('open');\n overlay.classList.add('open');\n hamburger.style.display = 'none';\n });\n\n const closeSidebar = () => {\n sidebar.classList.remove('open');\n overlay.classList.remove('open');\n hamburger.style.display = '';\n };\n\n overlay.addEventListener('click', closeSidebar);\n document.getElementById('sidebar-close').addEventListener('click', closeSidebar);\n\n // Toggle states\n let thinkingExpanded = true;\n let toolOutputsExpanded = false;\n\n const toggleThinking = () => {\n thinkingExpanded = !thinkingExpanded;\n document.querySelectorAll('.thinking-text').forEach(el => {\n el.style.display = thinkingExpanded ? '' : 'none';\n });\n document.querySelectorAll('.thinking-collapsed').forEach(el => {\n el.style.display = thinkingExpanded ? 'none' : 'block';\n });\n };\n\n const toggleToolOutputs = () => {\n toolOutputsExpanded = !toolOutputsExpanded;\n document.querySelectorAll('.tool-output.expandable').forEach(el => {\n el.classList.toggle('expanded', toolOutputsExpanded);\n });\n document.querySelectorAll('.compaction').forEach(el => {\n el.classList.toggle('expanded', toolOutputsExpanded);\n });\n };\n\n // Keyboard shortcuts\n document.addEventListener('keydown', (e) => {\n if (e.key === 'Escape') {\n searchInput.value = '';\n searchQuery = '';\n navigateTo(leafId, 'bottom');\n }\n if (e.ctrlKey && e.key === 't') {\n e.preventDefault();\n toggleThinking();\n }\n if (e.ctrlKey && e.key === 'o') {\n e.preventDefault();\n toggleToolOutputs();\n }\n });\n\n // Initial render\n // If URL has targetId, scroll to that specific message; otherwise stay at top\n if (leafId) {\n if (urlTargetId && byId.has(urlTargetId)) {\n navigateTo(leafId, 'target', urlTargetId);\n } else {\n navigateTo(leafId, 'none');\n }\n } else if (entries.length > 0) {\n // Fallback: use last entry if no leafId\n navigateTo(entries[entries.length - 1].id, 'none');\n }\n })();\n</script>\n</body>\n</html>\n";