(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 ``;
}
/**
* 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 ? `[${escapeHtml(label)}] ` : "";
switch (entry.type) {
case "message": {
const msg = entry.message;
if (msg.role === "user") {
const content = truncate(normalize(extractContent(msg.content)));
return labelHtml + `user: ${escapeHtml(content)}`;
}
if (msg.role === "assistant") {
const textContent = truncate(normalize(extractContent(msg.content)));
if (textContent) {
return (
labelHtml +
`assistant: ${escapeHtml(textContent)}`
);
}
if (msg.stopReason === "aborted") {
return (
labelHtml +
`assistant: (aborted)`
);
}
if (msg.errorMessage) {
return (
labelHtml +
`assistant: ${escapeHtml(truncate(msg.errorMessage))}`
);
}
return (
labelHtml +
`assistant: (no text)`
);
}
if (msg.role === "toolResult") {
const toolCall = msg.toolCallId ? toolCallMap.get(msg.toolCallId) : null;
if (toolCall) {
return (
labelHtml +
`${escapeHtml(formatToolCall(toolCall.name, toolCall.arguments))}`
);
}
return labelHtml + `[${escapeHtml(msg.toolName || "tool")}]`;
}
if (msg.role === "bashExecution") {
const cmd = truncate(normalize(msg.command || ""));
return labelHtml + `[bash]: ${escapeHtml(cmd)}`;
}
return labelHtml + `[${escapeHtml(msg.role)}]`;
}
case "compaction":
return (
labelHtml +
`[compaction: ${Math.round(entry.tokensBefore / 1000)}k tokens]`
);
case "branch_summary": {
const summary = truncate(normalize(entry.summary || ""));
return (
labelHtml +
`[branch summary]: ${escapeHtml(summary)}`
);
}
case "custom_message": {
const content =
typeof entry.content === "string" ? entry.content : extractContent(entry.content);
return (
labelHtml +
`[${escapeHtml(entry.customType)}]: ${escapeHtml(truncate(normalize(content)))}`
);
}
case "model_change":
return labelHtml + `[model: ${escapeHtml(entry.modelId)}]`;
case "thinking_level_change":
return labelHtml + `[thinking: ${escapeHtml(entry.thinkingLevel)}]`;
default:
return labelHtml + `[${escapeHtml(entry.type)}]`;
}
}
// ============================================================
// 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 `
${highlighted}${escapeHtml(output)}${escapeHtml(JSON.stringify(args, null, 2))}${highlighted}`;
},
// Text content: escape HTML tags
text(token) {
return escapeHtmlTags(escapeHtml(token.text));
},
// Inline code: escape HTML
codespan(token) {
return `${escapeHtml(token.text)}`;
},
// 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");
}
})();