Something went wrong. Try again.
more up to date at codeberg https://codeberg.org/cowie/tau.git
Something went wrong. Try again.
76 kB · 1851 lines
JavaScript
at main
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852 (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].sort((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].sort((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 ''; }
/** * Parse a skill block from message text. * Returns null if the text doesn't contain a skill block. * Matches the format: <skill name="..." location="...">\n...\n</skill>\n\nuser message */ function parseSkillBlock(text) { const match = text.match(/^<skill name="([^"]+)" location="([^"]+)">\n([\s\S]*?)\n<\/skill>(?:\n\n([\s\S]+))?$/); if (!match) return null; return { name: match[1], location: match[2], content: match[3], userMessage: match[4]?.trim() || undefined, }; }
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) { return String(text) .replace(/&/g, '&') .replace(/</g, '<') .replace(/>/g, '>') .replace(/"/g, '"') .replace(/'/g, '''); }
/** * 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 rawContent = extractContent(msg.content); const skillBlock = parseSkillBlock(rawContent); if (skillBlock) { let treeHtml = labelHtml + `<span class="tree-role-skill">skill:</span> ${escapeHtml(skillBlock.name)}`; if (skillBlock.userMessage) { treeHtml += ` · <span class="tree-role-user">user:</span> ${escapeHtml(truncate(normalize(skillBlock.userMessage)))}`; } return treeHtml; } const content = truncate(normalize(rawContent)); 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', () => { if (window.getSelection().toString()) return; 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="if(window.getSelection().toString())return;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="if(window.getSelection().toString())return;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:${escapeHtml(img.mimeType || 'image/png')};base64,${escapeHtml(img.data || '')}" class="tool-image" />`).join('') + '</div>'; };
const toolDomId = `tool-call-${escapeHtml(call.id)}`; let html = `<div class="tool-execution ${statusClass}" id="${toolDomId}">`; 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; } case 'ls': { const dirPath = str(args.path); const limit = args.limit;
let pathHtml = dirPath === null ? invalidArg : escapeHtml(shortenPath(dirPath || '.')); if (limit !== undefined) { pathHtml += ` <span class="line-count">(limit ${escapeHtml(String(limit))})</span>`; }
html += `<div class="tool-header"><span class="tool-name">ls</span> <span class="tool-path">${pathHtml}</span></div>`; if (result) { const output = getResultText().trim(); if (output) html += formatExpandableOutput(output, 20); } break; } default: { // Check for pre-rendered custom tool HTML const rendered = renderedTools?.[call.id]; if (rendered?.callHtml || rendered?.resultHtmlCollapsed || rendered?.resultHtmlExpanded) { // 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.resultHtmlCollapsed && rendered.resultHtmlExpanded && rendered.resultHtmlCollapsed !== rendered.resultHtmlExpanded) { // Both collapsed and expanded differ - render expandable section html += `<div class="tool-output expandable ansi-rendered" onclick="if(window.getSelection().toString())return;this.classList.toggle('expanded')"> <div class="output-preview">${rendered.resultHtmlCollapsed}</div> <div class="output-full">${rendered.resultHtmlExpanded}</div> </div>`; } else if (rendered.resultHtmlExpanded) { // Only expanded exists (or collapsed is identical) - show directly html += `<div class="tool-output ansi-rendered">${rendered.resultHtmlExpanded}</div>`; } else if (result) { // No pre-rendered result HTML - fallback to JSON 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 (err) { // 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="${escapeHtml(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 entryDomId = `entry-${escapeHtml(entry.id)}`; const copyBtnHtml = renderCopyLinkButton(entry.id);
if (entry.type === 'message') { const msg = entry.message;
if (msg.role === 'user') { const content = msg.content; const text = typeof content === 'string' ? content : content.filter(c => c.type === 'text').map(c => c.text).join('\n'); const skillBlock = parseSkillBlock(text);
if (skillBlock) { // Collect images from content array const images = Array.isArray(content) ? content.filter(c => c.type === 'image') : []; const hasUserContent = skillBlock.userMessage || images.length > 0; let html = `<div class="skill-user-entry" id="${entryDomId}">${copyBtnHtml}${tsHtml}`;
// Skill invocation (collapsed by default, click to expand) html += `<div class="skill-invocation" onclick="if(window.getSelection().toString())return;this.classList.toggle('expanded')"> <div class="skill-invocation-label">[skill] ${escapeHtml(skillBlock.name)}</div> <div class="skill-invocation-collapsed">${escapeHtml(skillBlock.name)} (click to expand)</div> <div class="skill-invocation-content markdown-content">${safeMarkedParse(skillBlock.content)}</div> </div>`;
// User message (separate block if present) if (hasUserContent) { html += '<div class="user-message">'; if (images.length > 0) { html += '<div class="message-images">'; for (const img of images) { html += `<img src="data:${escapeHtml(img.mimeType || 'image/png')};base64,${escapeHtml(img.data || '')}" class="message-image" />`; } html += '</div>'; } if (skillBlock.userMessage) { html += `<div class="markdown-content">${safeMarkedParse(skillBlock.userMessage)}</div>`; } html += '</div>'; }
html += '</div>'; return html; }
// No skill block - normal user message let html = `<div class="user-message" id="${entryDomId}">${copyBtnHtml}${tsHtml}`;
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:${escapeHtml(img.mimeType || 'image/png')};base64,${escapeHtml(img.data || '')}" class="message-image" />`; } html += '</div>'; } }
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="${entryDomId}">${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="${entryDomId}">${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="${entryDomId}">${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="${entryDomId}" onclick="if(window.getSelection().toString())return;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="${entryDomId}">${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="${entryDomId}">${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 class="help-hint">T toggle thinking · O toggle tools</span> <div class="help-actions"> <button type="button" class="header-toggle-btn" data-action="toggle-thinking" title="Toggle thinking (T)">Toggle thinking</button> <button type="button" class="header-toggle-btn" data-action="toggle-tools" title="Toggle tools (O)">Toggle tools</button> <button type="button" class="download-json-btn" onclick="downloadSessionJson()" title="Download session as JSONL">↓ JSONL</button> </div> </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="if(window.getSelection().toString())return;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="if(window.getSelection().toString())return;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 getScrollTargetElementId(entryId) { const entry = byId.get(entryId); if (entry?.type === 'message' && entry.message.role === 'toolResult' && entry.message.toolCallId) { // getElementById() matches the parsed DOM id attribute, whose HTML entities // were already resolved from the escaped id rendered by renderToolCall(). return `tool-call-${entry.message.toolCallId}`; } return `entry-${entryId}`; }
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(); attachHeaderHandlers();
// 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); 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. // Tool result entries are rendered inside their assistant tool-call block, // so route them to the visible tool-call element instead. const scrollTargetId = scrollToEntryId || targetId; const targetEl = document.getElementById(getScrollTargetElementId(scrollTargetId)) || 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 // ============================================================
// Configure marked with syntax highlighting and TUI-compatible HTML handling const strictStrikethroughRegex = /^(~~)(?=[^\s~])((?:\\.|[^\\])*?(?:\\.|[^\s~\\]))\1(?=[^~]|$)/;
marked.use({ breaks: true, gfm: true, tokenizer: { // Treat HTML-like input as plain text so tags are shown verbatim, // matching the TUI markdown renderer. html() { return undefined; }, tag() { return undefined; }, del(src) { const match = strictStrikethroughRegex.exec(src); if (!match) return undefined; return { type: 'del', raw: match[0], text: match[2], tokens: this.lexer.inlineTokens(match[2]) }; } }, renderer: { // Sanitize link URLs to prevent javascript:/vbscript:/data: XSS link(token) { const href = (token.href || '').trim(); if (/^\s*(javascript|vbscript|data):/i.test(href)) { return this.parser.parseInline(token.tokens); } let out = '<a href="' + escapeHtml(href) + '"'; if (token.title) { out += ' title="' + escapeHtml(token.title) + '"'; } out += '>' + this.parser.parseInline(token.tokens) + '</a>'; return out; }, // Sanitize image src URLs image(token) { const href = (token.href || '').trim(); if (/^\s*(javascript|vbscript|data):/i.test(href)) { return escapeHtml(token.text || ''); } let out = '<img src="' + escapeHtml(href) + '" alt="' + escapeHtml(token.text || '') + '"'; if (token.title) { out += ' title="' + escapeHtml(token.title) + '"'; } out += '>'; return out; }, // 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>`; }, // Inline code: escape HTML codespan(token) { return `<code>${escapeHtml(token.text)}</code>`; } } });
// 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'); const sidebarResizer = document.getElementById('sidebar-resizer'); const SIDEBAR_WIDTH_STORAGE_KEY = 'pi-share:v1:sidebar-width'; const MIN_CONTENT_WIDTH = 320;
function isMobileLayout() { return window.matchMedia('(max-width: 900px)').matches; }
function getSidebarBounds() { const rootStyles = getComputedStyle(document.documentElement); const minWidth = parseFloat(rootStyles.getPropertyValue('--sidebar-min-width')) || 240; const maxWidth = parseFloat(rootStyles.getPropertyValue('--sidebar-max-width')) || 720; const viewportMaxWidth = window.innerWidth - MIN_CONTENT_WIDTH; return { minWidth, maxWidth: Math.max(minWidth, Math.min(maxWidth, viewportMaxWidth)) }; }
function clampSidebarWidth(width) { const { minWidth, maxWidth } = getSidebarBounds(); return Math.max(minWidth, Math.min(maxWidth, width)); }
function applySidebarWidth(width) { document.documentElement.style.setProperty('--sidebar-width', `${Math.round(clampSidebarWidth(width))}px`); }
function loadSidebarWidth() { try { const raw = localStorage.getItem(SIDEBAR_WIDTH_STORAGE_KEY); if (raw === null) return null; const width = Number(raw); return Number.isFinite(width) ? width : null; } catch { return null; } }
function saveSidebarWidth(width) { try { localStorage.setItem(SIDEBAR_WIDTH_STORAGE_KEY, String(Math.round(clampSidebarWidth(width)))); } catch { // Ignore storage failures (e.g. private browsing restrictions) } }
function setupSidebarResize() { const savedWidth = loadSidebarWidth(); if (savedWidth !== null) { applySidebarWidth(savedWidth); }
if (!sidebarResizer) return;
let cleanupDrag = null;
const stopDrag = (pointerId) => { if (cleanupDrag) { cleanupDrag(pointerId); cleanupDrag = null; } };
sidebarResizer.addEventListener('pointerdown', (e) => { if (isMobileLayout()) return;
e.preventDefault(); const startX = e.clientX; const startWidth = sidebar.getBoundingClientRect().width; document.body.classList.add('sidebar-resizing'); sidebarResizer.setPointerCapture?.(e.pointerId);
const onPointerMove = (event) => { applySidebarWidth(startWidth + (event.clientX - startX)); };
cleanupDrag = (pointerIdToRelease) => { document.body.classList.remove('sidebar-resizing'); sidebarResizer.releasePointerCapture?.(pointerIdToRelease); window.removeEventListener('pointermove', onPointerMove); window.removeEventListener('pointerup', onPointerUp); window.removeEventListener('pointercancel', onPointerCancel); saveSidebarWidth(sidebar.getBoundingClientRect().width); };
const onPointerUp = (event) => stopDrag(event.pointerId); const onPointerCancel = (event) => stopDrag(event.pointerId);
window.addEventListener('pointermove', onPointerMove); window.addEventListener('pointerup', onPointerUp); window.addEventListener('pointercancel', onPointerCancel); });
sidebarResizer.addEventListener('dblclick', () => { if (isMobileLayout()) return; applySidebarWidth(400); saveSidebarWidth(400); });
window.addEventListener('resize', () => { if (isMobileLayout()) return; applySidebarWidth(sidebar.getBoundingClientRect().width); }); }
setupSidebarResize();
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); }); document.querySelectorAll('.skill-invocation').forEach(el => { el.classList.toggle('expanded', toolOutputsExpanded); }); };
const attachHeaderHandlers = () => { document.querySelector('[data-action="toggle-thinking"]')?.addEventListener('click', toggleThinking); document.querySelector('[data-action="toggle-tools"]')?.addEventListener('click', toggleToolOutputs); };
const isEditableTarget = (element) => { if (!element) return false; const tagName = element.tagName; if (tagName === 'INPUT' || tagName === 'TEXTAREA' || tagName === 'SELECT' || tagName === 'BUTTON') { return true; } return element.isContentEditable || Boolean(element.closest?.('[contenteditable="true"]')); };
// Keyboard shortcuts document.addEventListener('keydown', (e) => { if (e.key === 'Escape') { searchInput.value = ''; searchQuery = ''; navigateTo(leafId, 'bottom'); }
if (isEditableTarget(document.activeElement)) { return; }
const key = e.key.toLowerCase(); if (key === 't') { e.preventDefault(); toggleThinking(); } else if (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'); } })();