@gguf/claw
Version:
Multi-channel AI gateway with extensible messaging integrations
1,513 lines (1,354 loc) • 61.6 kB
JavaScript
(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;
}
/**
* 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">[${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">[${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: ${entry.modelId}]</span>`;
case "thinking_level_change":
return labelHtml + `<span class="tree-muted">[thinking: ${entry.thinkingLevel}]</span>`;
default:
return labelHtml + `<span class="tree-muted">[${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) => `<img src="data:${img.mimeType};base64,${img.data}" class="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 += `<img src="data:${img.mimeType};base64,${img.data}" class="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