(function () {
"use strict" ;
// ============================================================
// DATA LOADING
// ============================================================
const base64 = document.getElementById("session-data" ).textContent;
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0 ; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
const data = JSON.parse(new TextDecoder("utf-8" ).decode(bytes));
const { header, entries, leafId: defaultLeafId, systemPrompt, tools, renderedTools } = data;
// ============================================================
// URL PARAMETER HANDLING
// ============================================================
// Parse URL parameters for deep linking: leafId and targetId
// Check for injected params (when loaded in iframe via srcdoc) or use window.location
const injectedParams = document.querySelector('meta[name="pi-url-params"]' );
const searchString = injectedParams
? injectedParams.content
: window.location.search.substring(1 );
const urlParams = new URLSearchParams(searchString);
const urlLeafId = urlParams.get("leafId" );
const urlTargetId = urlParams.get("targetId" );
// Use URL leafId if provided, otherwise fall back to session default
const leafId = urlLeafId || defaultLeafId;
// ============================================================
// DATA STRUCTURES
// ============================================================
// Entry lookup by ID
const byId = new Map();
for (const entry of entries) {
byId.set(entry.id, entry);
}
// Tool call lookup (toolCallId -> {name, arguments})
const toolCallMap = new Map();
for (const entry of entries) {
if (entry.type === "message" && entry.message.role === "assistant" ) {
const content = entry.message.content;
if (Array.isArray(content)) {
for (const block of content) {
if (block.type === "toolCall" ) {
toolCallMap.set(block.id, { name: block.name, arguments: block.arguments });
}
}
}
}
}
// Label lookup (entryId -> label string)
// Labels are stored in 'label' entries that reference their target via targetId
const labelMap = new Map();
for (const entry of entries) {
if (entry.type === "label" && entry.targetId && entry.label) {
labelMap.set(entry.targetId, entry.label);
}
}
// ============================================================
// TREE DATA PREPARATION (no DOM, pure data)
// ============================================================
/**
* Build tree structure from flat entries .
* Returns array of root nodes , each with { entry , children , label } .
*/
function buildTree() {
const nodeMap = new Map();
const roots = [];
// Create nodes
for (const entry of entries) {
nodeMap.set(entry.id, {
entry,
children: [],
label: labelMap.get(entry.id),
});
}
// Build parent-child relationships
for (const entry of entries) {
const node = nodeMap.get(entry.id);
if (entry.parentId === null || entry.parentId === undefined || entry.parentId === entry.id) {
roots.push(node);
} else {
const parent = nodeMap.get(entry.parentId);
if (parent) {
parent.children.push(node);
} else {
roots.push(node);
}
}
}
// Sort children by timestamp
function sortChildren(node) {
node.children.sort(
(a, b) => new Date(a.entry.timestamp).getTime() - new Date(b.entry.timestamp).getTime(),
);
node.children.forEach(sortChildren);
}
roots.forEach(sortChildren);
return roots;
}
/**
* Build set of entry IDs on path from root to target .
*/
function buildActivePathIds(targetId) {
const ids = new Set();
let current = byId.get(targetId);
while (current) {
ids.add(current.id);
// Stop if no parent or self-referencing (root)
if (!current.parentId || current.parentId === current.id) {
break ;
}
current = byId.get(current.parentId);
}
return ids;
}
/**
* Get array of entries from root to target ( the conversation path ) .
*/
function getPath(targetId) {
const path = [];
let current = byId.get(targetId);
while (current) {
path.unshift(current);
// Stop if no parent or self-referencing (root)
if (!current.parentId || current.parentId === current.id) {
break ;
}
current = byId.get(current.parentId);
}
return path;
}
// Tree node lookup for finding leaves
let treeNodeMap = null ;
/**
* Find the newest leaf node reachable from a given node .
* This allows clicking any node in a branch to show the full branch .
* Children are sorted by timestamp , so the newest is always last .
*/
function findNewestLeaf(nodeId) {
// Build tree node map lazily
if (!treeNodeMap) {
treeNodeMap = new Map();
const tree = buildTree();
function mapNodes(node) {
treeNodeMap.set(node.entry.id, node);
node.children.forEach(mapNodes);
}
tree.forEach(mapNodes);
}
const node = treeNodeMap.get(nodeId);
if (!node) {
return nodeId;
}
// Follow the newest (last) child at each level
let current = node;
while (current.children.length > 0 ) {
current = current.children[current.children.length - 1 ];
}
return current.entry.id;
}
/**
* Flatten tree into list with indentation and connector info .
* Returns array of { node , indent , showConnector , isLast , gutters , isVirtualRootChild , multipleRoots } .
* Matches tree - selector . ts logic exactly .
*/
function flattenTree(roots, activePathIds) {
const result = [];
const multipleRoots = roots.length > 1 ;
// Mark which subtrees contain the active leaf
const containsActive = new Map();
function markActive(node) {
let has = activePathIds.has(node.entry.id);
for (const child of node.children) {
if (markActive(child)) {
has = true ;
}
}
containsActive.set(node, has);
return has;
}
roots.forEach(markActive);
// Stack: [node, indent, justBranched, showConnector, isLast, gutters, isVirtualRootChild]
const stack = [];
// Add roots (prioritize branch containing active leaf)
const orderedRoots = [...roots].toSorted(
(a, b) => Number(containsActive.get(b)) - Number(containsActive.get(a)),
);
for (let i = orderedRoots.length - 1 ; i >= 0 ; i--) {
const isLast = i === orderedRoots.length - 1 ;
stack.push([
orderedRoots[i],
multipleRoots ? 1 : 0 ,
multipleRoots,
multipleRoots,
isLast,
[],
multipleRoots,
]);
}
while (stack.length > 0 ) {
const [node, indent, justBranched, showConnector, isLast, gutters, isVirtualRootChild] =
stack.pop();
result.push({
node,
indent,
showConnector,
isLast,
gutters,
isVirtualRootChild,
multipleRoots,
});
const children = node.children;
const multipleChildren = children.length > 1 ;
// Order children (active branch first)
const orderedChildren = [...children].toSorted(
(a, b) => Number(containsActive.get(b)) - Number(containsActive.get(a)),
);
// Calculate child indent (matches tree-selector.ts)
let childIndent;
if (multipleChildren) {
// Parent branches: children get +1
childIndent = indent + 1 ;
} else if (justBranched && indent > 0 ) {
// First generation after a branch: +1 for visual grouping
childIndent = indent + 1 ;
} else {
// Single-child chain: stay flat
childIndent = indent;
}
// Build gutters for children
const connectorDisplayed = showConnector && !isVirtualRootChild;
const currentDisplayIndent = multipleRoots ? Math.max(0 , indent - 1 ) : indent;
const connectorPosition = Math.max(0 , currentDisplayIndent - 1 );
const childGutters = connectorDisplayed
? [...gutters, { position: connectorPosition, show: !isLast }]
: gutters;
// Add children in reverse order for stack
for (let i = orderedChildren.length - 1 ; i >= 0 ; i--) {
const childIsLast = i === orderedChildren.length - 1 ;
stack.push([
orderedChildren[i],
childIndent,
multipleChildren,
multipleChildren,
childIsLast,
childGutters,
false ,
]);
}
}
return result;
}
/**
* Build ASCII prefix string for tree node .
*/
function buildTreePrefix(flatNode) {
const { indent, showConnector, isLast, gutters, isVirtualRootChild, multipleRoots } = flatNode;
const displayIndent = multipleRoots ? Math.max(0 , indent - 1 ) : indent;
const connector = showConnector && !isVirtualRootChild ? (isLast ? "└─ " : "├─ " ) : "" ;
const connectorPosition = connector ? displayIndent - 1 : -1 ;
const totalChars = displayIndent * 3 ;
const prefixChars = [];
for (let i = 0 ; i < totalChars; i++) {
const level = Math.floor(i / 3 );
const posInLevel = i % 3 ;
const gutter = gutters.find((g) => g.position === level);
if (gutter) {
prefixChars.push(posInLevel === 0 ? (gutter.show ? "│" : " " ) : " " );
} else if (connector && level === connectorPosition) {
if (posInLevel === 0 ) {
prefixChars.push(isLast ? "└" : "├" );
} else if (posInLevel === 1 ) {
prefixChars.push("─" );
} else {
prefixChars.push(" " );
}
} else {
prefixChars.push(" " );
}
}
return prefixChars.join("" );
}
// ============================================================
// FILTERING (pure data)
// ============================================================
let filterMode = "default" ;
let searchQuery = "" ;
function hasTextContent(content) {
if (typeof content === "string" ) {
return content.trim().length > 0 ;
}
if (Array.isArray(content)) {
for (const c of content) {
if (c.type === "text" && c.text && c.text.trim().length > 0 ) {
return true ;
}
}
}
return false ;
}
function extractContent(content) {
if (typeof content === "string" ) {
return content;
}
if (Array.isArray(content)) {
return content
.filter((c) => c.type === "text" && c.text)
.map((c) => c.text)
.join("" );
}
return "" ;
}
function getSearchableText(entry, label) {
const parts = [];
if (label) {
parts.push(label);
}
switch (entry.type) {
case "message" : {
const msg = entry.message;
parts.push(msg.role);
if (msg.content) {
parts.push(extractContent(msg.content));
}
if (msg.role === "bashExecution" && msg.command) {
parts.push(msg.command);
}
break ;
}
case "custom_message" :
parts.push(entry.customType);
parts.push(
typeof entry.content === "string" ? entry.content : extractContent(entry.content),
);
break ;
case "compaction" :
parts.push("compaction" );
break ;
case "branch_summary" :
parts.push("branch summary" , entry.summary);
break ;
case "model_change" :
parts.push("model" , entry.modelId);
break ;
case "thinking_level_change" :
parts.push("thinking" , entry.thinkingLevel);
break ;
}
return parts.join(" " ).toLowerCase();
}
/**
* Filter flat nodes based on current filterMode and searchQuery .
*/
function filterNodes(flatNodes, currentLeafId) {
const searchTokens = searchQuery.toLowerCase().split(/\s+/).filter(Boolean );
const filtered = flatNodes.filter((flatNode) => {
const entry = flatNode.node.entry;
const label = flatNode.node.label;
const isCurrentLeaf = entry.id === currentLeafId;
// Always show current leaf
if (isCurrentLeaf) {
return true ;
}
// Hide assistant messages with only tool calls (no text) unless error/aborted
if (entry.type === "message" && entry.message.role === "assistant" ) {
const msg = entry.message;
const hasText = hasTextContent(msg.content);
const isErrorOrAborted =
msg.stopReason && msg.stopReason !== "stop" && msg.stopReason !== "toolUse" ;
if (!hasText && !isErrorOrAborted) {
return false ;
}
}
// Apply filter mode
const isSettingsEntry = ["label" , "custom" , "model_change" , "thinking_level_change" ].includes(
entry.type,
);
let passesFilter = true ;
switch (filterMode) {
case "user-only" :
passesFilter = entry.type === "message" && entry.message.role === "user" ;
break ;
case "no-tools" :
passesFilter =
!isSettingsEntry && !(entry.type === "message" && entry.message.role === "toolResult" );
break ;
case "labeled-only" :
passesFilter = label !== undefined;
break ;
case "all" :
passesFilter = true ;
break ;
default : // 'default'
passesFilter = !isSettingsEntry;
break ;
}
if (!passesFilter) {
return false ;
}
// Apply search filter
if (searchTokens.length > 0 ) {
const nodeText = getSearchableText(entry, label);
if (!searchTokens.every((t) => nodeText.includes(t))) {
return false ;
}
}
return true ;
});
// Recalculate visual structure based on visible tree
recalculateVisualStructure(filtered, flatNodes);
return filtered;
}
/**
* Recompute indentation / connectors for the filtered view
*
* Filtering can hide intermediate entries ; descendants attach to the nearest visible ancestor .
* Keep indentation semantics aligned with flattenTree ( ) so single - child chains don ' t drift right .
*/
function recalculateVisualStructure(filteredNodes, allFlatNodes) {
if (filteredNodes.length === 0 ) {
return ;
}
const visibleIds = new Set(filteredNodes.map((n) => n.node.entry.id));
// Build entry map for parent lookup (using full tree)
const entryMap = new Map();
for (const flatNode of allFlatNodes) {
entryMap.set(flatNode.node.entry.id, flatNode);
}
// Find nearest visible ancestor for a node
function findVisibleAncestor(nodeId) {
let currentId = entryMap.get(nodeId)?.node.entry.parentId;
while (currentId != null ) {
if (visibleIds.has(currentId)) {
return currentId;
}
currentId = entryMap.get(currentId)?.node.entry.parentId;
}
return null ;
}
// Build visible tree structure
const visibleParent = new Map();
const visibleChildren = new Map();
visibleChildren.set(null , []); // root-level nodes
for (const flatNode of filteredNodes) {
const nodeId = flatNode.node.entry.id;
const ancestorId = findVisibleAncestor(nodeId);
visibleParent.set(nodeId, ancestorId);
if (!visibleChildren.has(ancestorId)) {
visibleChildren.set(ancestorId, []);
}
visibleChildren.get(ancestorId).push(nodeId);
}
// Update multipleRoots based on visible roots
const visibleRootIds = visibleChildren.get(null );
const multipleRoots = visibleRootIds.length > 1 ;
// Build a map for quick lookup: nodeId → FlatNode
const filteredNodeMap = new Map();
for (const flatNode of filteredNodes) {
filteredNodeMap.set(flatNode.node.entry.id, flatNode);
}
// DFS traversal of visible tree, applying same indentation rules as flattenTree()
// Stack items: [nodeId, indent, justBranched, showConnector, isLast, gutters, isVirtualRootChild]
const stack = [];
// Add visible roots in reverse order (to process in forward order via stack)
for (let i = visibleRootIds.length - 1 ; i >= 0 ; i--) {
const isLast = i === visibleRootIds.length - 1 ;
stack.push([
visibleRootIds[i],
multipleRoots ? 1 : 0 ,
multipleRoots,
multipleRoots,
isLast,
[],
multipleRoots,
]);
}
while (stack.length > 0 ) {
const [nodeId, indent, justBranched, showConnector, isLast, gutters, isVirtualRootChild] =
stack.pop();
const flatNode = filteredNodeMap.get(nodeId);
if (!flatNode) {
continue ;
}
// Update this node's visual properties
flatNode.indent = indent;
flatNode.showConnector = showConnector;
flatNode.isLast = isLast;
flatNode.gutters = gutters;
flatNode.isVirtualRootChild = isVirtualRootChild;
flatNode.multipleRoots = multipleRoots;
// Get visible children of this node
const children = visibleChildren.get(nodeId) || [];
const multipleChildren = children.length > 1 ;
// Calculate child indent using same rules as flattenTree():
// - Parent branches (multiple children): children get +1
// - Just branched and indent > 0: children get +1 for visual grouping
// - Single-child chain: stay flat
let childIndent;
if (multipleChildren) {
childIndent = indent + 1 ;
} else if (justBranched && indent > 0 ) {
childIndent = indent + 1 ;
} else {
childIndent = indent;
}
// Build gutters for children (same logic as flattenTree)
const connectorDisplayed = showConnector && !isVirtualRootChild;
const currentDisplayIndent = multipleRoots ? Math.max(0 , indent - 1 ) : indent;
const connectorPosition = Math.max(0 , currentDisplayIndent - 1 );
const childGutters = connectorDisplayed
? [...gutters, { position: connectorPosition, show: !isLast }]
: gutters;
// Add children in reverse order (to process in forward order via stack)
for (let i = children.length - 1 ; i >= 0 ; i--) {
const childIsLast = i === children.length - 1 ;
stack.push([
children[i],
childIndent,
multipleChildren,
multipleChildren,
childIsLast,
childGutters,
false ,
]);
}
}
}
// ============================================================
// TREE DISPLAY TEXT (pure data -> string)
// ============================================================
function shortenPath(p) {
if (typeof p !== "string" ) {
return "" ;
}
if (p.startsWith("/Users/" )) {
const parts = p.split("/" );
if (parts.length > 2 ) {
return "~" + p.slice(("/Users/" + parts[2 ]).length);
}
}
if (p.startsWith("/home/" )) {
const parts = p.split("/" );
if (parts.length > 2 ) {
return "~" + p.slice(("/home/" + parts[2 ]).length);
}
}
return p;
}
function formatToolCall(name, args) {
switch (name) {
case "read" : {
const path = shortenPath(String(args.path || args.file_path || "" ));
const offset = args.offset;
const limit = args.limit;
let display = path;
if (offset !== undefined || limit !== undefined) {
const start = offset ?? 1 ;
const end = limit !== undefined ? start + limit - 1 : "" ;
display += `:${start}${end ? `-${end}` : "" }`;
}
return `[read: ${display}]`;
}
case "write" :
return `[write: ${shortenPath(String(args.path || args.file_path || "" ))}]`;
case "edit" :
return `[edit: ${shortenPath(String(args.path || args.file_path || "" ))}]`;
case "bash" : {
const rawCmd = String(args.command || "" );
const cmd = rawCmd
.replace(/[\n\t]/g, " " )
.trim()
.slice(0 , 50 );
return `[bash: ${cmd}${rawCmd.length > 50 ? "..." : "" }]`;
}
case "grep" :
return `[grep: /${args.pattern || "" }/ in ${shortenPath(String(args.path || "." ))}]`;
case "find" :
return `[find: ${args.pattern || "" } in ${shortenPath(String(args.path || "." ))}]`;
case "ls" :
return `[ls: ${shortenPath(String(args.path || "." ))}]`;
default : {
const argsStr = JSON.stringify(args).slice(0 , 40 );
return `[${name}: ${argsStr}${JSON.stringify(args).length > 40 ? "..." : "" }]`;
}
}
}
function escapeHtml(text) {
const div = document.createElement("div" );
div.textContent = text;
return div.innerHTML;
}
function escapeHtmlAttr(text) {
return escapeHtml(text).replaceAll('"' , """ ).replaceAll("'" , "'" );
}
// Validate image fields before interpolating data URLs.
const SAFE_IMAGE_MIME_RE = /^image\/(png|jpeg|gif|webp|svg\+xml|bmp|tiff|avif)$/i;
const SAFE_BASE64_RE = /^[A-Za-z0-9 +/]+={0 ,2 }$/;
function sanitizeImageMimeType(mimeType) {
if (typeof mimeType === "string" && SAFE_IMAGE_MIME_RE.test(mimeType)) {
return mimeType.toLowerCase();
}
return "application/octet-stream" ;
}
function sanitizeImageBase64(data) {
if (typeof data !== "string" ) {
return "" ;
}
const cleaned = data.replace(/\s+/g, "" );
if (!cleaned || cleaned.length % 4 !== 0 || !SAFE_BASE64_RE.test(cleaned)) {
return "" ;
}
return cleaned;
}
function renderDataUrlImage(img, className) {
const mimeType = sanitizeImageMimeType(img?.mimeType);
const base64 = sanitizeImageBase64(img?.data);
if (!base64) {
return "" ;
}
return `<img src="data:${mimeType};base64,${base64}" class ="${className}" />`;
}
/**
* Truncate string to maxLen chars , append " . . . " if truncated .
*/
function truncate(s, maxLen = 100 ) {
if (s.length <= maxLen) {
return s;
}
return s.slice(0 , maxLen) + "..." ;
}
/**
* Get display text for tree node ( returns HTML string ) .
*/
function getTreeNodeDisplayHtml(entry, label) {
const normalize = (s) => s.replace(/[\n\t]/g, " " ).trim();
const labelHtml = label ? `<span class ="tree-label" >[${escapeHtml(label)}]</span> ` : "" ;
switch (entry.type) {
case "message" : {
const msg = entry.message;
if (msg.role === "user" ) {
const content = truncate(normalize(extractContent(msg.content)));
return labelHtml + `<span class ="tree-role-user" >user:</span> ${escapeHtml(content)}`;
}
if (msg.role === "assistant" ) {
const textContent = truncate(normalize(extractContent(msg.content)));
if (textContent) {
return (
labelHtml +
`<span class ="tree-role-assistant" >assistant:</span> ${escapeHtml(textContent)}`
);
}
if (msg.stopReason === "aborted" ) {
return (
labelHtml +
`<span class ="tree-role-assistant" >assistant:</span> <span class ="tree-muted" >(aborted)</span>`
);
}
if (msg.errorMessage) {
return (
labelHtml +
`<span class ="tree-role-assistant" >assistant:</span> <span class ="tree-error" >${escapeHtml(truncate(msg.errorMessage))}</span>`
);
}
return (
labelHtml +
`<span class ="tree-role-assistant" >assistant:</span> <span class ="tree-muted" >(no text)</span>`
);
}
if (msg.role === "toolResult" ) {
const toolCall = msg.toolCallId ? toolCallMap.get(msg.toolCallId) : null ;
if (toolCall) {
return (
labelHtml +
`<span class ="tree-role-tool" >${escapeHtml(formatToolCall(toolCall.name, toolCall.arguments))}</span>`
);
}
return labelHtml + `<span class ="tree-role-tool" >[${escapeHtml(msg.toolName || "tool" )}]</span>`;
}
if (msg.role === "bashExecution" ) {
const cmd = truncate(normalize(msg.command || "" ));
return labelHtml + `<span class ="tree-role-tool" >[bash]:</span> ${escapeHtml(cmd)}`;
}
return labelHtml + `<span class ="tree-muted" >[${escapeHtml(msg.role)}]</span>`;
}
case "compaction" :
return (
labelHtml +
`<span class ="tree-compaction" >[compaction: ${Math.round(entry.tokensBefore / 1000 )}k tokens]</span>`
);
case "branch_summary" : {
const summary = truncate(normalize(entry.summary || "" ));
return (
labelHtml +
`<span class ="tree-branch-summary" >[branch summary]:</span> ${escapeHtml(summary)}`
);
}
case "custom_message" : {
const content =
typeof entry.content === "string" ? entry.content : extractContent(entry.content);
return (
labelHtml +
`<span class ="tree-custom" >[${escapeHtml(entry.customType)}]:</span> ${escapeHtml(truncate(normalize(content)))}`
);
}
case "model_change" :
return labelHtml + `<span class ="tree-muted" >[model: ${escapeHtml(entry.modelId)}]</span>`;
case "thinking_level_change" :
return labelHtml + `<span class ="tree-muted" >[thinking: ${escapeHtml(entry.thinkingLevel)}]</span>`;
default :
return labelHtml + `<span class ="tree-muted" >[${escapeHtml(entry.type)}]</span>`;
}
}
// ============================================================
// TREE RENDERING (DOM manipulation)
// ============================================================
let currentLeafId = leafId;
let currentTargetId = urlTargetId || leafId;
let treeRendered = false ;
function renderTree() {
const tree = buildTree();
const activePathIds = buildActivePathIds(currentLeafId);
const flatNodes = flattenTree(tree, activePathIds);
const filtered = filterNodes(flatNodes, currentLeafId);
const container = document.getElementById("tree-container" );
// Full render only on first call or when filter/search changes
if (!treeRendered) {
container.innerHTML = "" ;
for (const flatNode of filtered) {
const entry = flatNode.node.entry;
const isOnPath = activePathIds.has(entry.id);
const isTarget = entry.id === currentTargetId;
const div = document.createElement("div" );
div.className = "tree-node" ;
if (isOnPath) {
div.classList.add("in-path" );
}
if (isTarget) {
div.classList.add("active" );
}
div.dataset.id = entry.id;
const prefix = buildTreePrefix(flatNode);
const prefixSpan = document.createElement("span" );
prefixSpan.className = "tree-prefix" ;
prefixSpan.textContent = prefix;
const marker = document.createElement("span" );
marker.className = "tree-marker" ;
marker.textContent = isOnPath ? "•" : " " ;
const content = document.createElement("span" );
content.className = "tree-content" ;
content.innerHTML = getTreeNodeDisplayHtml(entry, flatNode.node.label);
div.appendChild(prefixSpan);
div.appendChild(marker);
div.appendChild(content);
// Navigate to the newest leaf through this node, but scroll to the clicked node
div.addEventListener("click" , () => {
const leafId = findNewestLeaf(entry.id);
navigateTo(leafId, "target" , entry.id);
});
container.appendChild(div);
}
treeRendered = true ;
} else {
// Just update markers and classes
const nodes = container.querySelectorAll(".tree-node" );
for (const node of nodes) {
const id = node.dataset.id;
const isOnPath = activePathIds.has(id);
const isTarget = id === currentTargetId;
node.classList.toggle("in-path" , isOnPath);
node.classList.toggle("active" , isTarget);
const marker = node.querySelector(".tree-marker" );
if (marker) {
marker.textContent = isOnPath ? "•" : " " ;
}
}
}
document.getElementById("tree-status" ).textContent =
`${filtered.length} / ${flatNodes.length} entries`;
// Scroll active node into view after layout
setTimeout(() => {
const activeNode = container.querySelector(".tree-node.active" );
if (activeNode) {
activeNode.scrollIntoView({ block: "nearest" });
}
}, 0 );
}
function forceTreeRerender() {
treeRendered = false ;
renderTree();
}
// ============================================================
// MESSAGE RENDERING
// ============================================================
function formatTokens(count) {
if (count < 1000 ) {
return count.toString();
}
if (count < 10000 ) {
return (count / 1000 ).toFixed(1 ) + "k" ;
}
if (count < 1000000 ) {
return Math.round(count / 1000 ) + "k" ;
}
return (count / 1000000 ).toFixed(1 ) + "M" ;
}
function formatTimestamp(ts) {
if (!ts) {
return "" ;
}
const date = new Date(ts);
return date.toLocaleTimeString(undefined, {
hour: "2-digit" ,
minute: "2-digit" ,
second: "2-digit" ,
});
}
function replaceTabs(text) {
return text.replace(/\t/g, " " );
}
/** Safely coerce value to string for display. Returns null if invalid type. */
function str(value) {
if (typeof value === "string" ) {
return value;
}
if (value == null ) {
return "" ;
}
return null ;
}
function getLanguageFromPath(filePath) {
const ext = filePath.split("." ).pop()?.toLowerCase();
const extToLang = {
ts: "typescript" ,
tsx: "typescript" ,
js: "javascript" ,
jsx: "javascript" ,
py: "python" ,
rb: "ruby" ,
rs: "rust" ,
go: "go" ,
java: "java" ,
c: "c" ,
cpp: "cpp" ,
h: "c" ,
hpp: "cpp" ,
cs: "csharp" ,
php: "php" ,
sh: "bash" ,
bash: "bash" ,
zsh: "bash" ,
sql: "sql" ,
html: "html" ,
css: "css" ,
scss: "scss" ,
json: "json" ,
yaml: "yaml" ,
yml: "yaml" ,
xml: "xml" ,
md: "markdown" ,
dockerfile: "dockerfile" ,
};
return extToLang[ext];
}
function findToolResult(toolCallId) {
for (const entry of entries) {
if (entry.type === "message" && entry.message.role === "toolResult" ) {
if (entry.message.toolCallId === toolCallId) {
return entry.message;
}
}
}
return null ;
}
function formatExpandableOutput(text, maxLines, lang) {
text = replaceTabs(text);
const lines = text.split("\n" );
const displayLines = lines.slice(0 , maxLines);
const remaining = lines.length - maxLines;
if (lang) {
let highlighted;
try {
highlighted = hljs.highlight(text, { language: lang }).value;
} catch {
highlighted = escapeHtml(text);
}
if (remaining > 0 ) {
const previewCode = displayLines.join("\n" );
let previewHighlighted;
try {
previewHighlighted = hljs.highlight(previewCode, { language: lang }).value;
} catch {
previewHighlighted = escapeHtml(previewCode);
}
return `<div class ="tool-output expandable" onclick="this.classList.toggle('expanded')" >
<div class ="output-preview" ><pre><code class ="hljs" >${previewHighlighted}</code></pre>
<div class ="expand-hint" >... (${remaining} more lines)</div></div>
<div class ="output-full" ><pre><code class ="hljs" >${highlighted}</code></pre></div></div>`;
}
return `<div class ="tool-output" ><pre><code class ="hljs" >${highlighted}</code></pre></div>`;
}
// Plain text output
if (remaining > 0 ) {
let out =
'<div class="tool-output expandable" onclick="this.classList.toggle(\' expanded\')">' ;
out += '<div class="output-preview">' ;
for (const line of displayLines) {
out += `<div>${escapeHtml(replaceTabs(line))}</div>`;
}
out += `<div class ="expand-hint" >... (${remaining} more lines)</div></div>`;
out += '<div class="output-full">' ;
for (const line of lines) {
out += `<div>${escapeHtml(replaceTabs(line))}</div>`;
}
out += "</div></div>" ;
return out;
}
let out = '<div class="tool-output">' ;
for (const line of displayLines) {
out += `<div>${escapeHtml(replaceTabs(line))}</div>`;
}
out += "</div>" ;
return out;
}
function renderToolCall(call) {
const result = findToolResult(call.id);
const isError = result?.isError || false ;
const statusClass = result ? (isError ? "error" : "success" ) : "pending" ;
const getResultText = () => {
if (!result) {
return "" ;
}
const textBlocks = result.content.filter((c) => c.type === "text" );
return textBlocks.map((c) => c.text).join("\n" );
};
const getResultImages = () => {
if (!result) {
return [];
}
return result.content.filter((c) => c.type === "image" );
};
const renderResultImages = () => {
const images = getResultImages();
if (images.length === 0 ) {
return "" ;
}
return (
'<div class="tool-images">' +
images.map((img) => renderDataUrlImage(img, "tool-image" )).join("" ) +
"</div>"
);
};
let html = `<div class ="tool-execution ${statusClass}" >`;
const args = call.arguments || {};
const name = call.name;
const invalidArg = '<span class="tool-error">[invalid arg]</span>' ;
switch (name) {
case "bash" : {
const command = str(args.command);
const cmdDisplay = command === null ? invalidArg : escapeHtml(command || "..." );
html += `<div class ="tool-command" >$ ${cmdDisplay}</div>`;
if (result) {
const output = getResultText().trim();
if (output) {
html += formatExpandableOutput(output, 5 );
}
}
break ;
}
case "read" : {
const filePath = str(args.file_path ?? args.path);
const offset = args.offset;
const limit = args.limit;
let pathHtml = filePath === null ? invalidArg : escapeHtml(shortenPath(filePath || "" ));
if (filePath !== null && (offset !== undefined || limit !== undefined)) {
const startLine = offset ?? 1 ;
const endLine = limit !== undefined ? startLine + limit - 1 : "" ;
pathHtml += `<span class ="line-numbers" >:${startLine}${endLine ? "-" + endLine : "" }</span>`;
}
html += `<div class ="tool-header" ><span class ="tool-name" >read</span> <span class ="tool-path" >${pathHtml}</span></div>`;
if (result) {
html += renderResultImages();
const output = getResultText();
const lang = filePath ? getLanguageFromPath(filePath) : null ;
if (output) {
html += formatExpandableOutput(output, 10 , lang);
}
}
break ;
}
case "write" : {
const filePath = str(args.file_path ?? args.path);
const content = str(args.content);
html += `<div class ="tool-header" ><span class ="tool-name" >write</span> <span class ="tool-path" >${filePath === null ? invalidArg : escapeHtml(shortenPath(filePath || "" ))}</span>`;
if (content !== null && content) {
const lines = content.split("\n" );
if (lines.length > 10 ) {
html += ` <span class ="line-count" >(${lines.length} lines)</span>`;
}
}
html += "</div>" ;
if (content === null ) {
html += `<div class ="tool-error" >[invalid content arg - expected string]</div>`;
} else if (content) {
const lang = filePath ? getLanguageFromPath(filePath) : null ;
html += formatExpandableOutput(content, 10 , lang);
}
if (result) {
const output = getResultText().trim();
if (output) {
html += `<div class ="tool-output" ><div>${escapeHtml(output)}</div></div>`;
}
}
break ;
}
case "edit" : {
const filePath = str(args.file_path ?? args.path);
html += `<div class ="tool-header" ><span class ="tool-name" >edit</span> <span class ="tool-path" >${filePath === null ? invalidArg : escapeHtml(shortenPath(filePath || "" ))}</span></div>`;
if (result?.details?.diff) {
const diffLines = result.details.diff.split("\n" );
html += '<div class="tool-diff">' ;
for (const line of diffLines) {
const cls = line.match(/^\+/)
? "diff-added"
: line.match(/^-/)
? "diff-removed"
: "diff-context" ;
html += `<div class ="${cls}" >${escapeHtml(replaceTabs(line))}</div>`;
}
html += "</div>" ;
} else if (result) {
const output = getResultText().trim();
if (output) {
html += `<div class ="tool-output" ><pre>${escapeHtml(output)}</pre></div>`;
}
}
break ;
}
default : {
// Check for pre-rendered custom tool HTML
const rendered = renderedTools?.[call.id];
if (rendered?.callHtml || rendered?.resultHtml) {
// Custom tool with pre-rendered HTML from TUI renderer
if (rendered.callHtml) {
html += `<div class ="tool-header ansi-rendered" >${rendered.callHtml}</div>`;
} else {
html += `<div class ="tool-header" ><span class ="tool-name" >${escapeHtml(name)}</span></div>`;
}
if (rendered.resultHtml) {
// Apply same truncation as built-in tools (10 lines)
const lines = rendered.resultHtml.split("\n" );
if (lines.length > 10 ) {
const preview = lines.slice(0 , 10 ).join("\n" );
html += `<div class ="tool-output expandable ansi-rendered" onclick="this.classList.toggle('expanded')" >
<div class ="output-preview" >${preview}<div class ="expand-hint" >... (${lines.length - 10 } more lines)</div></div>
<div class ="output-full" >${rendered.resultHtml}</div>
</div>`;
} else {
html += `<div class ="tool-output ansi-rendered" >${rendered.resultHtml}</div>`;
}
} else if (result) {
// Fallback to JSON for result if no pre-rendered HTML
const output = getResultText();
if (output) {
html += formatExpandableOutput(output, 10 );
}
}
} else {
// Fallback to JSON display (existing behavior)
html += `<div class ="tool-header" ><span class ="tool-name" >${escapeHtml(name)}</span></div>`;
html += `<div class ="tool-output" ><pre>${escapeHtml(JSON.stringify(args, null , 2 ))}</pre></div>`;
if (result) {
const output = getResultText();
if (output) {
html += formatExpandableOutput(output, 10 );
}
}
}
}
}
html += "</div>" ;
return html;
}
/**
* Download the session data as a JSONL file .
* Reconstructs the original format : header line + entry lines .
*/
window.downloadSessionJson = function () {
// Build JSONL content: header first, then all entries
const lines = [];
if (header) {
lines.push(JSON.stringify({ type: "header" , ...header }));
}
for (const entry of entries) {
lines.push(JSON.stringify(entry));
}
const jsonlContent = lines.join("\n" );
// Create download
const blob = new Blob([jsonlContent], { type: "application/x-ndjson" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a" );
a.href = url;
a.download = `${header?.id || "session" }.jsonl`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
};
/**
* Build a shareable URL for a specific message .
* URL format : base ? gistId & leafId = < leafId > & targetId = < entryId >
*/
function buildShareUrl(entryId) {
// Check for injected base URL (used when loaded in iframe via srcdoc)
const baseUrlMeta = document.querySelector('meta[name="pi-share-base-url"]' );
const baseUrl = baseUrlMeta ? baseUrlMeta.content : window.location.href.split("?" )[0 ];
const url = new URL(window.location.href);
// Find the gist ID (first query param without value, e.g., ?abc123)
const gistId = Array.from(url.searchParams.keys()).find((k) => !url.searchParams.get(k));
// Build the share URL
const params = new URLSearchParams();
params.set("leafId" , currentLeafId);
params.set("targetId" , entryId);
// If we have an injected base URL (iframe context), use it directly
if (baseUrlMeta) {
return `${baseUrl}&${params.toString()}`;
}
// Otherwise build from current location (direct file access)
url.search = gistId ? `?${gistId}&${params.toString()}` : `?${params.toString()}`;
return url.toString();
}
/**
* Copy text to clipboard with visual feedback .
* Uses navigator . clipboard with fallback to execCommand for HTTP contexts .
*/
async function copyToClipboard(text, button) {
let success = false ;
try {
if (navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(text);
success = true ;
}
} catch {
// Clipboard API failed, try fallback
}
// Fallback for HTTP or when Clipboard API is unavailable
if (!success) {
try {
const textarea = document.createElement("textarea" );
textarea.value = text;
textarea.style.position = "fixed" ;
textarea.style.opacity = "0" ;
document.body.appendChild(textarea);
textarea.select();
success = document.execCommand("copy" );
document.body.removeChild(textarea);
} catch (err) {
console.error("Failed to copy:" , err);
}
}
if (success && button) {
const originalHtml = button.innerHTML;
button.innerHTML = "✓" ;
button.classList.add("copied" );
setTimeout(() => {
button.innerHTML = originalHtml;
button.classList.remove("copied" );
}, 1500 );
}
}
/**
* Render the copy - link button HTML for a message .
*/
function renderCopyLinkButton(entryId) {
return `<button class ="copy-link-btn" data-entry-id="${entryId}" title="Copy link to this message" >
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" >
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
</svg>
</button>`;
}
function renderEntry(entry) {
const ts = formatTimestamp(entry.timestamp);
const tsHtml = ts ? `<div class ="message-timestamp" >${ts}</div>` : "" ;
const entryId = `entry-${entry.id}`;
const copyBtnHtml = renderCopyLinkButton(entry.id);
if (entry.type === "message" ) {
const msg = entry.message;
if (msg.role === "user" ) {
let html = `<div class ="user-message" id="${entryId}" >${copyBtnHtml}${tsHtml}`;
const content = msg.content;
if (Array.isArray(content)) {
const images = content.filter((c) => c.type === "image" );
if (images.length > 0 ) {
html += '<div class="message-images">' ;
for (const img of images) {
html += renderDataUrlImage(img, "message-image" );
}
html += "</div>" ;
}
}
const text =
typeof content === "string"
? content
: content
.filter((c) => c.type === "text" )
.map((c) => c.text)
.join("\n" );
if (text.trim()) {
html += `<div class ="markdown-content" >${safeMarkedParse(text)}</div>`;
}
html += "</div>" ;
return html;
}
if (msg.role === "assistant" ) {
let html = `<div class ="assistant-message" id="${entryId}" >${copyBtnHtml}${tsHtml}`;
for (const block of msg.content) {
if (block.type === "text" && block.text.trim()) {
html += `<div class ="assistant-text markdown-content" >${safeMarkedParse(block.text)}</div>`;
} else if (block.type === "thinking" && block.thinking.trim()) {
html += `<div class ="thinking-block" >
<div class ="thinking-text" >${escapeHtml(block.thinking)}</div>
<div class ="thinking-collapsed" >Thinking ...</div>
</div>`;
}
}
for (const block of msg.content) {
if (block.type === "toolCall" ) {
html += renderToolCall(block);
}
}
if (msg.stopReason === "aborted" ) {
html += '<div class="error-text">Aborted</div>' ;
} else if (msg.stopReason === "error" ) {
html += `<div class ="error-text" >Error: ${escapeHtml(msg.errorMessage || "Unknown error" )}</div>`;
}
html += "</div>" ;
return html;
}
if (msg.role === "bashExecution" ) {
const isError = msg.cancelled || (msg.exitCode !== 0 && msg.exitCode !== null );
let html = `<div class ="tool-execution ${isError ? " error" : " success"}" id="${entryId}" >${tsHtml}`;
html += `<div class ="tool-command" >$ ${escapeHtml(msg.command)}</div>`;
if (msg.output) {
html += formatExpandableOutput(msg.output, 10 );
}
if (msg.cancelled) {
html += '<div style="color: var(--warning)">(cancelled)</div>' ;
} else if (msg.exitCode !== 0 && msg.exitCode !== null ) {
html += `<div style="color: var(--error)" >(exit ${msg.exitCode})</div>`;
}
html += "</div>" ;
return html;
}
if (msg.role === "toolResult" ) {
return "" ;
}
}
if (entry.type === "model_change" ) {
return `<div class ="model-change" id="${entryId}" >${tsHtml}Switched to model: <span class ="model-name" >${escapeHtml(entry.provider)}/${escapeHtml(entry.modelId)}</span></div>`;
}
if (entry.type === "compaction" ) {
return `<div class ="compaction" id="${entryId}" onclick="this.classList.toggle('expanded')" >
<div class ="compaction-label" >[compaction]</div>
<div class ="compaction-collapsed" >Compacted from ${entry.tokensBefore.toLocaleString()} tokens</div>
<div class ="compaction-content" ><strong>Compacted from ${entry.tokensBefore.toLocaleString()} tokens</strong>\n\n${escapeHtml(entry.summary)}</div>
</div>`;
}
if (entry.type === "branch_summary" ) {
return `<div class ="branch-summary" id="${entryId}" >${tsHtml}
<div class ="branch-summary-header" >Branch Summary</div>
<div class ="markdown-content" >${safeMarkedParse(entry.summary)}</div>
</div>`;
}
if (entry.type === "custom_message" && entry.display) {
return `<div class ="hook-message" id="${entryId}" >${tsHtml}
<div class ="hook-type" >[${escapeHtml(entry.customType)}]</div>
<div class ="markdown-content" >${safeMarkedParse(typeof entry.content === "string" ? entry.content : JSON.stringify(entry.content))}</div>
</div>`;
}
return "" ;
}
// ============================================================
// HEADER / STATS
// ============================================================
function computeStats(entryList) {
let userMessages = 0 ,
assistantMessages = 0 ,
toolResults = 0 ;
let customMessages = 0 ,
compactions = 0 ,
branchSummaries = 0 ,
toolCalls = 0 ;
const tokens = { input: 0 , output: 0 , cacheRead: 0 , cacheWrite: 0 };
const cost = { input: 0 , output: 0 , cacheRead: 0 , cacheWrite: 0 };
const models = new Set();
for (const entry of entryList) {
if (entry.type === "message" ) {
const msg = entry.message;
if (msg.role === "user" ) {
userMessages++;
}
if (msg.role === "assistant" ) {
assistantMessages++;
if (msg.model) {
models.add(msg.provider ? `${msg.provider}/${msg.model}` : msg.model);
}
if (msg.usage) {
tokens.input += msg.usage.input || 0 ;
tokens.output += msg.usage.output || 0 ;
tokens.cacheRead += msg.usage.cacheRead || 0 ;
tokens.cacheWrite += msg.usage.cacheWrite || 0 ;
if (msg.usage.cost) {
cost.input += msg.usage.cost.input || 0 ;
cost.output += msg.usage.cost.output || 0 ;
cost.cacheRead += msg.usage.cost.cacheRead || 0 ;
cost.cacheWrite += msg.usage.cost.cacheWrite || 0 ;
}
}
toolCalls += msg.content.filter((c) => c.type === "toolCall" ).length;
}
if (msg.role === "toolResult" ) {
toolResults++;
}
} else if (entry.type === "compaction" ) {
compactions++;
} else if (entry.type === "branch_summary" ) {
branchSummaries++;
} else if (entry.type === "custom_message" ) {
customMessages++;
}
}
return {
userMessages,
assistantMessages,
toolResults,
customMessages,
compactions,
branchSummaries,
toolCalls,
tokens,
cost,
models: Array.from(models),
};
}
const globalStats = computeStats(entries);
function renderHeader() {
const totalCost =
globalStats.cost.input +
globalStats.cost.output +
globalStats.cost.cacheRead +
globalStats.cost.cacheWrite;
const tokenParts = [];
if (globalStats.tokens.input) {
tokenParts.push(`↑${formatTokens(globalStats.tokens.input)}`);
}
if (globalStats.tokens.output) {
tokenParts.push(`↓${formatTokens(globalStats.tokens.output)}`);
}
if (globalStats.tokens.cacheRead) {
tokenParts.push(`R${formatTokens(globalStats.tokens.cacheRead)}`);
}
if (globalStats.tokens.cacheWrite) {
tokenParts.push(`W${formatTokens(globalStats.tokens.cacheWrite)}`);
}
const msgParts = [];
if (globalStats.userMessages) {
msgParts.push(`${globalStats.userMessages} user`);
}
if (globalStats.assistantMessages) {
msgParts.push(`${globalStats.assistantMessages} assistant`);
}
if (globalStats.toolResults) {
msgParts.push(`${globalStats.toolResults} tool results`);
}
if (globalStats.customMessages) {
msgParts.push(`${globalStats.customMessages} custom`);
}
if (globalStats.compactions) {
msgParts.push(`${globalStats.compactions} compactions`);
}
if (globalStats.branchSummaries) {
msgParts.push(`${globalStats.branchSummaries} branch summaries`);
}
let html = `
<div class ="header" >
<h1>Session: ${escapeHtml(header?.id || "unknown" )}</h1>
<div class ="help-bar" >
<span>Ctrl+T toggle thinking · Ctrl+O toggle tools</span>
<button class ="download-json-btn" onclick="downloadSessionJson()" title="Download session as JSONL" >↓ JSONL</button>
</div>
<div class ="header-info" >
<div class ="info-item" ><span class ="info-label" >Date:</span><span class ="info-value" >${header?.timestamp ? new Date(header.timestamp).toLocaleString() : "unknown" }</span></div>
<div class ="info-item" ><span class ="info-label" >Models:</span><span class ="info-value" >${escapeHtml(globalStats.models.join(", " ) || "unknown" )}</span></div>
<div class ="info-item" ><span class ="info-label" >Messages:</span><span class ="info-value" >${msgParts.join(", " ) || "0" }</span></div>
<div class ="info-item" ><span class ="info-label" >Tool Calls:</span><span class ="info-value" >${globalStats.toolCalls}</span></div>
<div class ="info-item" ><span class ="info-label" >Tokens:</span><span class ="info-value" >${tokenParts.join(" " ) || "0" }</span></div>
<div class ="info-item" ><span class ="info-label" >Cost:</span><span class ="info-value" >$${totalCost.toFixed(3 )}</span></div>
</div>
</div>`;
// Render system prompt (user's base prompt, applies to all providers)
if (systemPrompt) {
const lines = systemPrompt.split("\n" );
const previewLines = 10 ;
if (lines.length > previewLines) {
const preview = lines.slice(0 , previewLines).join("\n" );
const remaining = lines.length - previewLines;
html += `<div class ="system-prompt expandable" onclick="this.classList.toggle('expanded')" >
<div class ="system-prompt-header" >System Prompt</div>
<div class ="system-prompt-preview" >${escapeHtml(preview)}</div>
<div class ="system-prompt-expand-hint" >... (${remaining} more lines, click to expand)</div>
<div class ="system-prompt-full" >${escapeHtml(systemPrompt)}</div>
</div>`;
} else {
html += `<div class ="system-prompt" >
<div class ="system-prompt-header" >System Prompt</div>
<div class ="system-prompt-full" style="display: block" >${escapeHtml(systemPrompt)}</div>
</div>`;
}
}
if (tools && tools.length > 0 ) {
html += `<div class ="tools-list" >
<div class ="tools-header" >Available Tools</div>
<div class ="tools-content" >
${tools
.map((t) => {
const hasParams =
t.parameters &&
typeof t.parameters === "object" &&
t.parameters.properties &&
Object.keys(t.parameters.properties).length > 0 ;
if (!hasParams) {
return `<div class ="tool-item" ><span class ="tool-item-name" >${escapeHtml(t.name)}</span> - <span class ="tool-item-desc" >${escapeHtml(t.description)}</span></div>`;
}
const params = t.parameters;
const properties = params.properties;
const required = params.required || [];
let paramsHtml = "" ;
for (const [name, prop] of Object.entries(properties)) {
const isRequired = required.includes(name);
const typeStr = prop.type || "any" ;
const reqLabel = isRequired
? '<span class="tool-param-required">required</span>'
: '<span class="tool-param-optional">optional</span>' ;
paramsHtml += `<div class ="tool-param" ><span class ="tool-param-name" >${escapeHtml(name)}</span> <span class ="tool-param-type" >${escapeHtml(typeStr)}</span> ${reqLabel}`;
if (prop.description) {
paramsHtml += `<div class ="tool-param-desc" >${escapeHtml(prop.description)}</div>`;
}
paramsHtml += `</div>`;
}
return `<div class ="tool-item" onclick="this.classList.toggle('params-expanded')" ><span class ="tool-item-name" >${escapeHtml(t.name)}</span> - <span class ="tool-item-desc" >${escapeHtml(t.description)}</span> <span class ="tool-params-hint" ></span><div class ="tool-params-content" >${paramsHtml}</div></div>`;
})
.join("" )}
</div>
</div>`;
}
return html;
}
// ============================================================
// NAVIGATION
// ============================================================
// Cache for rendered entry DOM nodes
const entryCache = new Map();
function renderEntryToNode(entry) {
// Check cache first
if (entryCache.has(entry.id)) {
return entryCache.get(entry.id).cloneNode(true );
}
// Render to HTML string, then parse to node
const html = renderEntry(entry);
if (!html) {
return null ;
}
const template = document.createElement("template" );
template.innerHTML = html;
const node = template.content.firstElementChild;
// Cache the node
if (node) {
entryCache.set(entry.id, node.cloneNode(true ));
}
return node;
}
function navigateTo(targetId, scrollMode = "target" , scrollToEntryId = null ) {
currentLeafId = targetId;
currentTargetId = scrollToEntryId || targetId;
const path = getPath(targetId);
renderTree();
document.getElementById("header-container" ).innerHTML = renderHeader();
// Build messages using cached DOM nodes
const messagesEl = document.getElementById("messages" );
const fragment = document.createDocumentFragment();
for (const entry of path) {
const node = renderEntryToNode(entry);
if (node) {
fragment.appendChild(node);
}
}
messagesEl.innerHTML = "" ;
messagesEl.appendChild(fragment);
// Attach click handlers for copy-link buttons
messagesEl.querySelectorAll(".copy-link-btn" ).forEach((btn) => {
btn.addEventListener("click" , (e) => {
e.stopPropagation();
const entryId = btn.dataset.entryId;
const shareUrl = buildShareUrl(entryId);
void copyToClipboard(shareUrl, btn);
});
});
// Use setTimeout(0) to ensure DOM is fully laid out before scrolling
setTimeout(() => {
const content = document.getElementById("content" );
if (scrollMode === "bottom" ) {
content.scrollTop = content.scrollHeight;
} else if (scrollMode === "target" ) {
// If scrollToEntryId is provided, scroll to that specific entry
const scrollTargetId = scrollToEntryId || targetId;
const targetEl = document.getElementById(`entry-${scrollTargetId}`);
if (targetEl) {
targetEl.scrollIntoView({ block: "center" });
// Briefly highlight the target message
if (scrollToEntryId) {
targetEl.classList.add("highlight" );
setTimeout(() => targetEl.classList.remove("highlight" ), 2000 );
}
}
}
}, 0 );
}
// ============================================================
// INITIALIZATION
// ============================================================
// Escape HTML tags in text (but not code blocks)
function escapeHtmlTags(text) {
return text.replace(/<(?=[a-zA-Z/])/g, "<" );
}
const INLINE_DATA_IMAGE_RE = /^data:image\/[a-z0-9 .+-]+;base64,/i;
function normalizeMarkdownImageLabel(text) {
const trimmed = typeof text === "string" ? text.trim() : "" ;
return trimmed || "image" ;
}
function renderMarkdownImage(token) {
const label = normalizeMarkdownImageLabel(token?.text);
const href = typeof token?.href === "string" ? token.href.trim() : "" ;
if (!INLINE_DATA_IMAGE_RE.test(href)) {
return escapeHtml(label);
}
return `<img src="${escapeHtmlAttr(href)}" alt="${escapeHtmlAttr(label)}" >`;
}
// Configure marked with syntax highlighting and HTML escaping for text
marked.use({
breaks: true ,
gfm: true ,
renderer: {
// Code blocks: syntax highlight, no HTML escaping
code(token) {
const code = token.text;
const lang = token.lang;
let highlighted;
if (lang && hljs.getLanguage(lang)) {
try {
highlighted = hljs.highlight(code, { language: lang }).value;
} catch {
highlighted = escapeHtml(code);
}
} else {
// Auto-detect language if not specified
try {
highlighted = hljs.highlightAuto(code).value;
} catch {
highlighted = escapeHtml(code);
}
}
return `<pre><code class ="hljs" >${highlighted}</code></pre>`;
},
// Text content: escape HTML tags
text(token) {
return escapeHtmlTags(escapeHtml(token.text));
},
// Inline code: escape HTML
codespan(token) {
return `<code>${escapeHtml(token.text)}</code>`;
},
// Raw HTML blocks/inline HTML: escape to prevent script execution.
html(token) {
return escapeHtml(token.text);
},
image(token) {
return renderMarkdownImage(token);
},
},
});
// Simple marked parse (escaping handled in renderers)
function safeMarkedParse(text) {
return marked.parse(text);
}
// Search input
const searchInput = document.getElementById("tree-search" );
searchInput.addEventListener("input" , (e) => {
searchQuery = e.target.value;
forceTreeRerender();
});
// Filter buttons
document.querySelectorAll(".filter-btn" ).forEach((btn) => {
btn.addEventListener("click" , () => {
document.querySelectorAll(".filter-btn" ).forEach((b) => b.classList.remove("active" ));
btn.classList.add("active" );
filterMode = btn.dataset.filter;
forceTreeRerender();
});
});
// Sidebar toggle
const sidebar = document.getElementById("sidebar" );
const overlay = document.getElementById("sidebar-overlay" );
const hamburger = document.getElementById("hamburger" );
hamburger.addEventListener("click" , () => {
sidebar.classList.add("open" );
overlay.classList.add("open" );
hamburger.style.display = "none" ;
});
const closeSidebar = () => {
sidebar.classList.remove("open" );
overlay.classList.remove("open" );
hamburger.style.display = "" ;
};
overlay.addEventListener("click" , closeSidebar);
document.getElementById("sidebar-close" ).addEventListener("click" , closeSidebar);
// Toggle states
let thinkingExpanded = true ;
let toolOutputsExpanded = false ;
const toggleThinking = () => {
thinkingExpanded = !thinkingExpanded;
document.querySelectorAll(".thinking-text" ).forEach((el) => {
el.style.display = thinkingExpanded ? "" : "none" ;
});
document.querySelectorAll(".thinking-collapsed" ).forEach((el) => {
el.style.display = thinkingExpanded ? "none" : "block" ;
});
};
const toggleToolOutputs = () => {
toolOutputsExpanded = !toolOutputsExpanded;
document.querySelectorAll(".tool-output.expandable" ).forEach((el) => {
el.classList.toggle("expanded" , toolOutputsExpanded);
});
document.querySelectorAll(".compaction" ).forEach((el) => {
el.classList.toggle("expanded" , toolOutputsExpanded);
});
};
// Keyboard shortcuts
document.addEventListener("keydown" , (e) => {
if (e.key === "Escape" ) {
searchInput.value = "" ;
searchQuery = "" ;
navigateTo(leafId, "bottom" );
}
if (e.ctrlKey && e.key === "t" ) {
e.preventDefault();
toggleThinking();
}
if (e.ctrlKey && e.key === "o" ) {
e.preventDefault();
toggleToolOutputs();
}
});
// Initial render
// If URL has targetId, scroll to that specific message; otherwise stay at top
if (leafId) {
if (urlTargetId && byId.has(urlTargetId)) {
// Deep link: navigate to leaf and scroll to target message
navigateTo(leafId, "target" , urlTargetId);
} else {
navigateTo(leafId, "none" );
}
} else if (entries.length > 0 ) {
// Fallback: use last entry if no leafId
navigateTo(entries[entries.length - 1 ].id, "none" );
}
})();
Messung V0.5 in Prozent C=93 H=89 G=90
¤ Dauer der Verarbeitung: 0.26 Sekunden
¤
*© Formatika GbR, Deutschland