From 2d9c2018af4c90b3580a3c52fe155bcfde99c764 Mon Sep 17 00:00:00 2001 From: Mikael Hugo Date: Tue, 5 May 2026 14:55:11 +0200 Subject: [PATCH] chore: clean repo quality gates --- .../src/core/retry-handler.test.ts | 2 +- .../src/modes/interactive/interactive-mode.ts | 5 +- packages/rpc-client/src/rpc-client.ts | 4 +- pkg/dist/core/export-html/template.html | 25 +- pkg/dist/core/export-html/template.js | 3175 +++++++++-------- src/headless.ts | 4 +- src/resources/extensions/sf/doc-checker.d.ts | 28 +- src/resources/extensions/sf/doctor.d.ts | 9 + .../extensions/sf/schedule-launch-banner.d.ts | 2 + .../sf/schedule/schedule-store.d.ts | 37 + .../extensions/sf/trace-collector.d.ts | 4 +- src/resources/extensions/sf/types.d.ts | 6 + 12 files changed, 1812 insertions(+), 1489 deletions(-) create mode 100644 src/resources/extensions/sf/schedule-launch-banner.d.ts create mode 100644 src/resources/extensions/sf/schedule/schedule-store.d.ts diff --git a/packages/pi-coding-agent/src/core/retry-handler.test.ts b/packages/pi-coding-agent/src/core/retry-handler.test.ts index 5e8afa302..f6615a80b 100644 --- a/packages/pi-coding-agent/src/core/retry-handler.test.ts +++ b/packages/pi-coding-agent/src/core/retry-handler.test.ts @@ -417,7 +417,7 @@ describe("RetryHandler — long-context entitlement 429 (#2803)", () => { const expensiveModel = createMockModel("openrouter", "openai/gpt-5-pro"); expensiveModel.maxTokens = 128000; - const { deps, emittedEvents } = createMockDeps({ + const { deps, emittedEvents, onModelChangeFn } = createMockDeps({ model: expensiveModel, markUsageLimitReachedResult: false, fallbackResult: null, diff --git a/packages/pi-coding-agent/src/modes/interactive/interactive-mode.ts b/packages/pi-coding-agent/src/modes/interactive/interactive-mode.ts index 4ae75f29a..681d4dbf6 100644 --- a/packages/pi-coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/pi-coding-agent/src/modes/interactive/interactive-mode.ts @@ -1395,7 +1395,6 @@ export class InteractiveMode { }, }, shutdownHandler: () => { - this.shutdownRequested = true; if (!this.session.isStreaming) { void this.shutdown(); } @@ -1488,7 +1487,9 @@ export class InteractiveMode { abort: () => this.session.abort(), hasPendingMessages: () => this.session.pendingMessageCount > 0, shutdown: () => { - this.shutdownRequested = true; + if (!this.session.isStreaming) { + void this.shutdown(); + } }, getContextUsage: () => this.session.getContextUsage(), compact: (options) => { diff --git a/packages/rpc-client/src/rpc-client.ts b/packages/rpc-client/src/rpc-client.ts index f9661e986..10566ff44 100644 --- a/packages/rpc-client/src/rpc-client.ts +++ b/packages/rpc-client/src/rpc-client.ts @@ -294,7 +294,7 @@ export class RpcClient { if (resolve) { const r = resolve; resolve = null; - r(); + r(undefined); } }; @@ -304,7 +304,7 @@ export class RpcClient { if (resolve) { const r = resolve; resolve = null; - r(); + r(undefined); } }; diff --git a/pkg/dist/core/export-html/template.html b/pkg/dist/core/export-html/template.html index 42f2a45b0..e17d37e8c 100644 --- a/pkg/dist/core/export-html/template.html +++ b/pkg/dist/core/export-html/template.html @@ -5,11 +5,11 @@ Session Export - +
@@ -38,17 +38,16 @@ - + - + - + - diff --git a/pkg/dist/core/export-html/template.js b/pkg/dist/core/export-html/template.js index 85117bdbf..419a0f2a5 100644 --- a/pkg/dist/core/export-html/template.js +++ b/pkg/dist/core/export-html/template.js @@ -1,1583 +1,1840 @@ - (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 ''; - } - - 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 ? `[${escapeHtml(label)}] ` : ''; - - switch (entry.type) { - case 'message': { - const msg = entry.message; - if (msg.role === 'user') { - const content = truncate(normalize(extractContent(msg.content))); - return labelHtml + `user: ${escapeHtml(content)}`; - } - if (msg.role === 'assistant') { - const textContent = truncate(normalize(extractContent(msg.content))); - if (textContent) { - return labelHtml + `assistant: ${escapeHtml(textContent)}`; - } - if (msg.stopReason === 'aborted') { - return labelHtml + `assistant: (aborted)`; - } - if (msg.errorMessage) { - return labelHtml + `assistant: ${escapeHtml(truncate(msg.errorMessage))}`; - } - return labelHtml + `assistant: (no text)`; - } - if (msg.role === 'toolResult') { - const toolCall = msg.toolCallId ? toolCallMap.get(msg.toolCallId) : null; - if (toolCall) { - return labelHtml + `${escapeHtml(formatToolCall(toolCall.name, toolCall.arguments))}`; - } - return labelHtml + `[${escapeHtml(msg.toolName || 'tool')}]`; - } - if (msg.role === 'bashExecution') { - const cmd = truncate(normalize(msg.command || '')); - return labelHtml + `[bash]: ${escapeHtml(cmd)}`; - } - return labelHtml + `[${escapeHtml(msg.role)}]`; - } - case 'compaction': - return labelHtml + `[compaction: ${Math.round(entry.tokensBefore/1000)}k tokens]`; - case 'branch_summary': { - const summary = truncate(normalize(entry.summary || '')); - return labelHtml + `[branch summary]: ${escapeHtml(summary)}`; - } - case 'custom_message': { - const content = typeof entry.content === 'string' ? entry.content : extractContent(entry.content); - return labelHtml + `[${escapeHtml(entry.customType)}]: ${escapeHtml(truncate(normalize(content)))}`; - } - case 'model_change': - return labelHtml + `[model: ${escapeHtml(entry.modelId)}]`; - case 'thinking_level_change': - return labelHtml + `[thinking: ${escapeHtml(entry.thinkingLevel)}]`; - default: - return labelHtml + `[${escapeHtml(entry.type)}]`; - } - } - - // ============================================================ - // TREE RENDERING (DOM manipulation) - // ============================================================ - - let currentLeafId = leafId; - let currentTargetId = urlTargetId || leafId; - let treeRendered = false; - - function renderTree() { - const tree = buildTree(); - const activePathIds = buildActivePathIds(currentLeafId); - const flatNodes = flattenTree(tree, activePathIds); - const filtered = filterNodes(flatNodes, currentLeafId); - const container = document.getElementById('tree-container'); - - // Full render only on first call or when filter/search changes - if (!treeRendered) { - container.innerHTML = ''; - - for (const flatNode of filtered) { - const entry = flatNode.node.entry; - const isOnPath = activePathIds.has(entry.id); - const isTarget = entry.id === currentTargetId; - - const div = document.createElement('div'); - div.className = 'tree-node'; - if (isOnPath) div.classList.add('in-path'); - if (isTarget) div.classList.add('active'); - div.dataset.id = entry.id; - - const prefix = buildTreePrefix(flatNode); - const prefixSpan = document.createElement('span'); - prefixSpan.className = 'tree-prefix'; - prefixSpan.textContent = prefix; - - const marker = document.createElement('span'); - marker.className = 'tree-marker'; - marker.textContent = isOnPath ? '•' : ' '; - - const content = document.createElement('span'); - content.className = 'tree-content'; - content.innerHTML = getTreeNodeDisplayHtml(entry, flatNode.node.label); - - div.appendChild(prefixSpan); - div.appendChild(marker); - div.appendChild(content); - // Navigate to the newest leaf through this node, but scroll to the clicked node - div.addEventListener('click', () => { - const leafId = findNewestLeaf(entry.id); - navigateTo(leafId, 'target', entry.id); - }); - - container.appendChild(div); - } - - treeRendered = true; - } else { - // Just update markers and classes - const nodes = container.querySelectorAll('.tree-node'); - for (const node of nodes) { - const id = node.dataset.id; - const isOnPath = activePathIds.has(id); - const isTarget = id === currentTargetId; - - node.classList.toggle('in-path', isOnPath); - node.classList.toggle('active', isTarget); - - const marker = node.querySelector('.tree-marker'); - if (marker) { - marker.textContent = isOnPath ? '•' : ' '; - } - } - } - - document.getElementById('tree-status').textContent = `${filtered.length} / ${flatNodes.length} entries`; - - // Scroll active node into view after layout - setTimeout(() => { - const activeNode = container.querySelector('.tree-node.active'); - if (activeNode) { - activeNode.scrollIntoView({ block: 'nearest' }); - } - }, 0); - } - - function forceTreeRerender() { - treeRendered = false; - renderTree(); - } - - // ============================================================ - // MESSAGE RENDERING - // ============================================================ - - function formatTokens(count) { - if (count < 1000) return count.toString(); - if (count < 10000) return (count / 1000).toFixed(1) + 'k'; - if (count < 1000000) return Math.round(count / 1000) + 'k'; - return (count / 1000000).toFixed(1) + 'M'; - } - - function formatTimestamp(ts) { - if (!ts) return ''; - const date = new Date(ts); - return date.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', second: '2-digit' }); - } - - function replaceTabs(text) { - return text.replace(/\t/g, ' '); - } - - /** Safely coerce value to string for display. Returns null if invalid type. */ - function str(value) { - if (typeof value === 'string') return value; - if (value == null) return ''; - return null; - } - - function getLanguageFromPath(filePath) { - const ext = filePath.split('.').pop()?.toLowerCase(); - const extToLang = { - ts: 'typescript', tsx: 'typescript', js: 'javascript', jsx: 'javascript', - py: 'python', rb: 'ruby', rs: 'rust', go: 'go', java: 'java', - c: 'c', cpp: 'cpp', h: 'c', hpp: 'cpp', cs: 'csharp', - php: 'php', sh: 'bash', bash: 'bash', zsh: 'bash', - sql: 'sql', html: 'html', css: 'css', scss: 'scss', - json: 'json', yaml: 'yaml', yml: 'yaml', xml: 'xml', - md: 'markdown', dockerfile: 'dockerfile' - }; - return extToLang[ext]; - } - - function findToolResult(toolCallId) { - for (const entry of entries) { - if (entry.type === 'message' && entry.message.role === 'toolResult') { - if (entry.message.toolCallId === toolCallId) { - return entry.message; - } - } - } - return null; - } - - function formatExpandableOutput(text, maxLines, lang) { - text = replaceTabs(text); - const lines = text.split('\n'); - const displayLines = lines.slice(0, maxLines); - const remaining = lines.length - maxLines; - - if (lang) { - let highlighted; - try { - highlighted = hljs.highlight(text, { language: lang }).value; - } catch { - highlighted = escapeHtml(text); - } - - if (remaining > 0) { - const previewCode = displayLines.join('\n'); - let previewHighlighted; - try { - previewHighlighted = hljs.highlight(previewCode, { language: lang }).value; - } catch { - previewHighlighted = escapeHtml(previewCode); - } - - return `