diff --git a/extensions/openclaw-zh-cn-ui/README.md b/extensions/openclaw-zh-cn-ui/README.md index 7cd811f79b..192c7a2fd8 100644 --- a/extensions/openclaw-zh-cn-ui/README.md +++ b/extensions/openclaw-zh-cn-ui/README.md @@ -3,23 +3,25 @@ 在你的项目中导入: ```javascript -const translations = require('./translations/zh-CN.json'); -console.log(translations['Save']); // 输出:保存 +const translations = require("./translations/zh-CN.json"); +console.log(translations["Save"]); // 输出:保存 ``` ## 继续翻译工作 -1. **提取 OpenClaw 界面字符串** +1. **提取 OpenClaw 界面字符串** + ```bash node scripts/extract-strings.js ``` -2. **过滤真正的界面文本** +2. **过滤真正的界面文本** + ```bash node scripts/filter-real-ui.js ``` -3. **翻译剩余的字符串** +3. **翻译剩余的字符串** - 编辑 `translations/ui-only.json` ## 🛠️ 工具说明 @@ -64,10 +66,12 @@ extensions/openclaw-zh-cn-ui/ ## 📈 路线图 ### 短期目标 + - 完成剩余翻译 - 提交 Pull Request ### 长期目标 + - 支持更多语言 - 创建翻译平台 diff --git a/extensions/whatsapp/src/channel.send-options.test.ts b/extensions/whatsapp/src/channel.send-options.test.ts index c5df32163c..f50bdc3142 100644 --- a/extensions/whatsapp/src/channel.send-options.test.ts +++ b/extensions/whatsapp/src/channel.send-options.test.ts @@ -2,7 +2,9 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { whatsappPlugin } from "./channel.js"; // Mock runtime -const mockSendMessageWhatsApp = vi.fn().mockResolvedValue({ messageId: "123", toJid: "123@s.whatsapp.net" }); +const mockSendMessageWhatsApp = vi + .fn() + .mockResolvedValue({ messageId: "123", toJid: "123@s.whatsapp.net" }); vi.mock("./runtime.js", () => ({ getWhatsAppRuntime: () => ({ @@ -35,7 +37,7 @@ describe("whatsappPlugin.outbound.sendText", () => { "http://example.com", expect.objectContaining({ linkPreview: false, - }) + }), ); }); @@ -50,7 +52,7 @@ describe("whatsappPlugin.outbound.sendText", () => { "hello", expect.objectContaining({ linkPreview: undefined, - }) + }), ); }); }); diff --git a/scripts/cron_usage_report.ts b/scripts/cron_usage_report.ts index e02162fc17..827106d3ce 100644 --- a/scripts/cron_usage_report.ts +++ b/scripts/cron_usage_report.ts @@ -73,10 +73,18 @@ async function listJsonlFiles(dir: string): Promise { function safeParseLine(line: string): CronRunLogEntry | null { try { const obj = JSON.parse(line) as Partial | null; - if (!obj || typeof obj !== "object") return null; - if (obj.action !== "finished") return null; - if (typeof obj.ts !== "number" || !Number.isFinite(obj.ts)) return null; - if (typeof obj.jobId !== "string" || !obj.jobId.trim()) return null; + if (!obj || typeof obj !== "object") { + return null; + } + if (obj.action !== "finished") { + return null; + } + if (typeof obj.ts !== "number" || !Number.isFinite(obj.ts)) { + return null; + } + if (typeof obj.jobId !== "string" || !obj.jobId.trim()) { + return null; + } return obj as CronRunLogEntry; } catch { return null; @@ -91,7 +99,8 @@ export async function main() { const args = parseArgs(process.argv); const store = typeof args.store === "string" ? args.store : undefined; const runsDirArg = typeof args.runsDir === "string" ? args.runsDir : undefined; - const runsDir = runsDirArg ?? (store ? path.join(path.dirname(path.resolve(store)), "runs") : null); + const runsDir = + runsDirArg ?? (store ? path.join(path.dirname(path.resolve(store)), "runs") : null); if (!runsDir) { usageAndExit(2); } @@ -138,19 +147,31 @@ export async function main() { for (const file of files) { const raw = await fs.readFile(file, "utf-8").catch(() => ""); - if (!raw.trim()) continue; + if (!raw.trim()) { + continue; + } const lines = raw.split("\n"); for (const line of lines) { const entry = safeParseLine(line.trim()); - if (!entry) continue; - if (entry.ts < fromMs || entry.ts > toMs) continue; - if (filterJobId && entry.jobId !== filterJobId) continue; + if (!entry) { + continue; + } + if (entry.ts < fromMs || entry.ts > toMs) { + continue; + } + if (filterJobId && entry.jobId !== filterJobId) { + continue; + } const model = (entry.model ?? "").trim() || ""; - if (filterModel && model !== filterModel) continue; + if (filterModel && model !== filterModel) { + continue; + } const jobId = entry.jobId; const usage = entry.usage; - const hasUsage = Boolean(usage && (usage.total_tokens ?? usage.input_tokens ?? usage.output_tokens) !== undefined); + const hasUsage = Boolean( + usage && (usage.total_tokens ?? usage.input_tokens ?? usage.output_tokens) !== undefined, + ); const jobAgg = (totalsByJob[jobId] ??= { jobId, @@ -219,8 +240,12 @@ export async function main() { console.log(`Cron usage report`); console.log(` runsDir: ${runsDir}`); console.log(` window: ${new Date(fromMs).toISOString()} → ${new Date(toMs).toISOString()}`); - if (filterJobId) console.log(` filter jobId: ${filterJobId}`); - if (filterModel) console.log(` filter model: ${filterModel}`); + if (filterJobId) { + console.log(` filter jobId: ${filterJobId}`); + } + if (filterModel) { + console.log(` filter model: ${filterModel}`); + } console.log(""); if (rows.length === 0) { diff --git a/src/agents/pi-embedded-runner/extra-params.zai-tool-stream.test.ts b/src/agents/pi-embedded-runner/extra-params.zai-tool-stream.test.ts index d1903ef9a8..569816339c 100644 --- a/src/agents/pi-embedded-runner/extra-params.zai-tool-stream.test.ts +++ b/src/agents/pi-embedded-runner/extra-params.zai-tool-stream.test.ts @@ -1,6 +1,5 @@ -import { describe, expect, it, vi } from "vitest"; import type { StreamFn } from "@mariozechner/pi-agent-core"; -import type { Context, Model } from "@mariozechner/pi-ai"; +import { describe, expect, it, vi } from "vitest"; import { applyExtraParamsToAgent } from "./extra-params.js"; // Mock streamSimple for testing @@ -13,7 +12,6 @@ vi.mock("@mariozechner/pi-ai", () => ({ describe("extra-params: Z.AI tool_stream support", () => { it("should inject tool_stream=true for zai provider by default", () => { - const capturedPayloads: unknown[] = []; const mockStreamFn: StreamFn = vi.fn((model, context, options) => { // Capture the payload that would be sent options?.onPayload?.({ model: model.id, messages: [] }); @@ -24,7 +22,7 @@ describe("extra-params: Z.AI tool_stream support", () => { content: [{ type: "text", text: "ok" }], stopReason: "stop", }), - } as any; + } as unknown as ReturnType; }); const agent = { streamFn: mockStreamFn }; @@ -34,7 +32,12 @@ describe("extra-params: Z.AI tool_stream support", () => { }, }; - applyExtraParamsToAgent(agent, cfg as any, "zai", "glm-5"); + applyExtraParamsToAgent( + agent, + cfg as unknown as Parameters[1], + "zai", + "glm-5", + ); // The streamFn should be wrapped expect(agent.streamFn).toBeDefined(); @@ -42,33 +45,44 @@ describe("extra-params: Z.AI tool_stream support", () => { }); it("should not inject tool_stream for non-zai providers", () => { - const mockStreamFn: StreamFn = vi.fn(() => ({ - push: vi.fn(), - result: vi.fn().mockResolvedValue({ - role: "assistant", - content: [{ type: "text", text: "ok" }], - stopReason: "stop", - }), - } as any)); + const mockStreamFn: StreamFn = vi.fn( + () => + ({ + push: vi.fn(), + result: vi.fn().mockResolvedValue({ + role: "assistant", + content: [{ type: "text", text: "ok" }], + stopReason: "stop", + }), + }) as unknown as ReturnType, + ); const agent = { streamFn: mockStreamFn }; const cfg = {}; - applyExtraParamsToAgent(agent, cfg as any, "anthropic", "claude-opus-4-6"); + applyExtraParamsToAgent( + agent, + cfg as unknown as Parameters[1], + "anthropic", + "claude-opus-4-6", + ); // Should remain unchanged (except for OpenAI wrapper) expect(agent.streamFn).toBeDefined(); }); it("should allow disabling tool_stream via params", () => { - const mockStreamFn: StreamFn = vi.fn(() => ({ - push: vi.fn(), - result: vi.fn().mockResolvedValue({ - role: "assistant", - content: [{ type: "text", text: "ok" }], - stopReason: "stop", - }), - } as any)); + const mockStreamFn: StreamFn = vi.fn( + () => + ({ + push: vi.fn(), + result: vi.fn().mockResolvedValue({ + role: "assistant", + content: [{ type: "text", text: "ok" }], + stopReason: "stop", + }), + }) as unknown as ReturnType, + ); const agent = { streamFn: mockStreamFn }; const cfg = { @@ -85,7 +99,12 @@ describe("extra-params: Z.AI tool_stream support", () => { }, }; - applyExtraParamsToAgent(agent, cfg as any, "zai", "glm-5"); + applyExtraParamsToAgent( + agent, + cfg as unknown as Parameters[1], + "zai", + "glm-5", + ); // The tool_stream wrapper should be applied but with enabled=false // In this case, it should just return the underlying streamFn diff --git a/src/agents/session-tool-result-guard.ts b/src/agents/session-tool-result-guard.ts index 4ff9035a11..0f82cd2d48 100644 --- a/src/agents/session-tool-result-guard.ts +++ b/src/agents/session-tool-result-guard.ts @@ -1,10 +1,10 @@ import type { AgentMessage } from "@mariozechner/pi-agent-core"; +import type { TextContent } from "@mariozechner/pi-ai"; +import type { SessionManager } from "@mariozechner/pi-coding-agent"; import type { PluginHookBeforeMessageWriteEvent, PluginHookBeforeMessageWriteResult, } from "../plugins/types.js"; -import type { TextContent } from "@mariozechner/pi-ai"; -import type { SessionManager } from "@mariozechner/pi-coding-agent"; import { emitSessionTranscriptUpdate } from "../sessions/transcript-events.js"; import { HARD_MAX_TOOL_RESULT_CHARS } from "./pi-embedded-runner/tool-result-truncation.js"; import { makeMissingToolResult, sanitizeToolCallInputs } from "./session-transcript-repair.js"; @@ -132,10 +132,16 @@ export function installSessionToolResultGuard( * or null if the message should be blocked. */ const applyBeforeWriteHook = (msg: AgentMessage): AgentMessage | null => { - if (!beforeWrite) return msg; + if (!beforeWrite) { + return msg; + } const result = beforeWrite({ message: msg }); - if (result?.block) return null; - if (result?.message) return result.message; + if (result?.block) { + return null; + } + if (result?.message) { + return result.message; + } return msg; }; @@ -192,7 +198,9 @@ export function installSessionToolResultGuard( isSynthetic: false, }), ); - if (!persisted) return undefined; + if (!persisted) { + return undefined; + } return originalAppend(persisted as never); } @@ -213,7 +221,9 @@ export function installSessionToolResultGuard( } const finalMessage = applyBeforeWriteHook(persistMessage(nextMessage)); - if (!finalMessage) return undefined; + if (!finalMessage) { + return undefined; + } const result = originalAppend(finalMessage as never); const sessionFile = ( diff --git a/src/auto-reply/reply/commands-export-session.ts b/src/auto-reply/reply/commands-export-session.ts index eb2f7e3a92..16e2bbc4ca 100644 --- a/src/auto-reply/reply/commands-export-session.ts +++ b/src/auto-reply/reply/commands-export-session.ts @@ -97,10 +97,10 @@ function generateHtml(sessionData: SessionData): string { // Build CSS with theme variables const css = templateCss - .replace("{{THEME_VARS}}", themeVars) - .replace("{{BODY_BG}}", bodyBg) - .replace("{{CONTAINER_BG}}", containerBg) - .replace("{{INFO_BG}}", infoBg); + .replace("/* {{THEME_VARS}} */", themeVars.trim()) + .replace("/* {{BODY_BG_DECL}} */", `--body-bg: ${bodyBg};`) + .replace("/* {{CONTAINER_BG_DECL}} */", `--container-bg: ${containerBg};`) + .replace("/* {{INFO_BG_DECL}} */", `--info-bg: ${infoBg};`); return template .replace("{{CSS}}", css) @@ -234,7 +234,7 @@ export async function buildExportSessionReply(params: HandleCommandsParams): Pro const args = parseExportArgs(params.command.commandBodyNormalized); // 1. Resolve session file - const sessionEntry = params.sessionEntry as SessionEntry | undefined; + const sessionEntry = params.sessionEntry; if (!sessionEntry?.sessionId) { return { text: "❌ No active session found." }; } diff --git a/src/auto-reply/reply/commands-mesh.ts b/src/auto-reply/reply/commands-mesh.ts index 553807603a..5ec809f5c6 100644 --- a/src/auto-reply/reply/commands-mesh.ts +++ b/src/auto-reply/reply/commands-mesh.ts @@ -31,7 +31,7 @@ function trimMeshPlanCache() { return; } const oldest = [...meshPlanCache.entries()] - .sort((a, b) => a[1].createdAt - b[1].createdAt) + .toSorted((a, b) => a[1].createdAt - b[1].createdAt) .slice(0, meshPlanCache.size - MAX_CACHED_MESH_PLANS); for (const [key] of oldest) { meshPlanCache.delete(key); @@ -110,7 +110,10 @@ function putCachedPlan(params: Parameters[0], plan: MeshPlanShap trimMeshPlanCache(); } -function getCachedPlan(params: Parameters[0], planId: string): MeshPlanShape | null { +function getCachedPlan( + params: Parameters[0], + planId: string, +): MeshPlanShape | null { return meshPlanCache.get(cacheKeyForPlan(params, planId))?.plan ?? null; } @@ -190,7 +193,9 @@ export const handleMeshCommand: CommandHandler = async (params, allowTextCommand return null; } if (!params.command.isAuthorizedSender) { - logVerbose(`Ignoring /mesh from unauthorized sender: ${params.command.senderId || ""}`); + logVerbose( + `Ignoring /mesh from unauthorized sender: ${params.command.senderId || ""}`, + ); return { shouldContinue: false }; } if (!parsed.ok) { diff --git a/src/auto-reply/reply/export-html/template.css b/src/auto-reply/reply/export-html/template.css index 6ef5d39766..69ef9765ae 100644 --- a/src/auto-reply/reply/export-html/template.css +++ b/src/auto-reply/reply/export-html/template.css @@ -1,971 +1,1060 @@ - :root { - {{THEME_VARS}} - --body-bg: {{BODY_BG}}; - --container-bg: {{CONTAINER_BG}}; - --info-bg: {{INFO_BG}}; - } - - * { margin: 0; padding: 0; box-sizing: border-box; } - - :root { - --line-height: 18px; /* 12px font * 1.5 */ - } - - body { - font-family: ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, Consolas, 'DejaVu Sans Mono', monospace; - font-size: 12px; - line-height: var(--line-height); - color: var(--text); - background: var(--body-bg); - } - - #app { - display: flex; - min-height: 100vh; - } - - /* Sidebar */ - #sidebar { - width: 400px; - background: var(--container-bg); - flex-shrink: 0; - display: flex; - flex-direction: column; - position: sticky; - top: 0; - height: 100vh; - border-right: 1px solid var(--dim); - } - - .sidebar-header { - padding: 8px 12px; - flex-shrink: 0; - } - - .sidebar-controls { - padding: 8px 8px 4px 8px; - } - - .sidebar-search { - width: 100%; - box-sizing: border-box; - padding: 4px 8px; - font-size: 11px; - font-family: inherit; - background: var(--body-bg); - color: var(--text); - border: 1px solid var(--dim); - border-radius: 3px; - } - - .sidebar-filters { - display: flex; - padding: 4px 8px 8px 8px; - gap: 4px; - align-items: center; - flex-wrap: wrap; - } - - .sidebar-search:focus { - outline: none; - border-color: var(--accent); - } - - .sidebar-search::placeholder { - color: var(--muted); - } - - .filter-btn { - padding: 3px 8px; - font-size: 10px; - font-family: inherit; - background: transparent; - color: var(--muted); - border: 1px solid var(--dim); - border-radius: 3px; - cursor: pointer; - } - - .filter-btn:hover { - color: var(--text); - border-color: var(--text); - } - - .filter-btn.active { - background: var(--accent); - color: var(--body-bg); - border-color: var(--accent); - } - - .sidebar-close { - display: none; - padding: 3px 8px; - font-size: 12px; - font-family: inherit; - background: transparent; - color: var(--muted); - border: 1px solid var(--dim); - border-radius: 3px; - cursor: pointer; - margin-left: auto; - } - - .sidebar-close:hover { - color: var(--text); - border-color: var(--text); - } - - .tree-container { - flex: 1; - overflow: auto; - padding: 4px 0; - } - - .tree-node { - padding: 0 8px; - cursor: pointer; - display: flex; - align-items: baseline; - font-size: 11px; - line-height: 13px; - white-space: nowrap; - } - - .tree-node:hover { - background: var(--selectedBg); - } - - .tree-node.active { - background: var(--selectedBg); - } - - .tree-node.active .tree-content { - font-weight: bold; - } - - .tree-node.in-path { - background: color-mix(in srgb, var(--accent) 10%, transparent); - } - - .tree-node:not(.in-path) { - opacity: 0.5; - } - - .tree-node:not(.in-path):hover { - opacity: 1; - } - - .tree-prefix { - color: var(--muted); - flex-shrink: 0; - font-family: monospace; - white-space: pre; - } - - .tree-marker { - color: var(--accent); - flex-shrink: 0; - } - - .tree-content { - color: var(--text); - } - - .tree-role-user { - color: var(--accent); - } - - .tree-role-assistant { - color: var(--success); - } - - .tree-role-tool { - color: var(--muted); - } - - .tree-muted { - color: var(--muted); - } - - .tree-error { - color: var(--error); - } - - .tree-compaction { - color: var(--borderAccent); - } - - .tree-branch-summary { - color: var(--warning); - } - - .tree-custom-message { - color: var(--customMessageLabel); - } - - .tree-status { - padding: 4px 12px; - font-size: 10px; - color: var(--muted); - flex-shrink: 0; - } - - /* Main content */ - #content { - flex: 1; - overflow-y: auto; - padding: var(--line-height) calc(var(--line-height) * 2); - display: flex; - flex-direction: column; - align-items: center; - } - - #content > * { - width: 100%; - max-width: 800px; - } - - /* Help bar */ - .help-bar { - font-size: 11px; - color: var(--warning); - margin-bottom: var(--line-height); - display: flex; - align-items: center; - gap: 12px; - } - - .download-json-btn { - font-size: 10px; - padding: 2px 8px; - background: var(--container-bg); - border: 1px solid var(--border); - border-radius: 3px; - color: var(--text); - cursor: pointer; - font-family: inherit; - } - - .download-json-btn:hover { - background: var(--hover); - border-color: var(--borderAccent); - } - - /* Header */ - .header { - background: var(--container-bg); - border-radius: 4px; - padding: var(--line-height); - margin-bottom: var(--line-height); - } - - .header h1 { - font-size: 12px; - font-weight: bold; - color: var(--borderAccent); - margin-bottom: var(--line-height); - } - - .header-info { - display: flex; - flex-direction: column; - gap: 0; - font-size: 11px; - } - - .info-item { - color: var(--dim); - display: flex; - align-items: baseline; - } - - .info-label { - font-weight: 600; - margin-right: 8px; - min-width: 100px; - } - - .info-value { - color: var(--text); - flex: 1; - } - - /* Messages */ - #messages { - display: flex; - flex-direction: column; - gap: var(--line-height); - } - - .message-timestamp { - font-size: 10px; - color: var(--dim); - opacity: 0.8; - } - - .user-message { - background: var(--userMessageBg); - color: var(--userMessageText); - padding: var(--line-height); - border-radius: 4px; - position: relative; - } - - .assistant-message { - padding: 0; - position: relative; - } - - /* Copy link button - appears on hover */ - .copy-link-btn { - position: absolute; - top: 8px; - right: 8px; - width: 28px; - height: 28px; - padding: 6px; - background: var(--container-bg); - border: 1px solid var(--dim); - border-radius: 4px; - color: var(--muted); - cursor: pointer; - opacity: 0; - transition: opacity 0.15s, background 0.15s, color 0.15s; - display: flex; - align-items: center; - justify-content: center; - z-index: 10; - } - - .user-message:hover .copy-link-btn, - .assistant-message:hover .copy-link-btn { - opacity: 1; - } - - .copy-link-btn:hover { - background: var(--accent); - color: var(--body-bg); - border-color: var(--accent); - } - - .copy-link-btn.copied { - background: var(--success, #22c55e); - color: white; - border-color: var(--success, #22c55e); - } - - /* Highlight effect for deep-linked messages */ - .user-message.highlight, - .assistant-message.highlight { - animation: highlight-pulse 2s ease-out; - } - - @keyframes highlight-pulse { - 0% { - box-shadow: 0 0 0 3px var(--accent); - } - 100% { - box-shadow: 0 0 0 0 transparent; - } - } - - .assistant-message > .message-timestamp { - padding-left: var(--line-height); - } - - .assistant-text { - padding: var(--line-height); - padding-bottom: 0; - } - - .message-timestamp + .assistant-text, - .message-timestamp + .thinking-block { - padding-top: 0; - } - - .thinking-block + .assistant-text { - padding-top: 0; - } - - .thinking-text { - padding: var(--line-height); - color: var(--thinkingText); - font-style: italic; - white-space: pre-wrap; - } - - .message-timestamp + .thinking-block .thinking-text, - .message-timestamp + .thinking-block .thinking-collapsed { - padding-top: 0; - } - - .thinking-collapsed { - display: none; - padding: var(--line-height); - color: var(--thinkingText); - font-style: italic; - } - - /* Tool execution */ - .tool-execution { - padding: var(--line-height); - border-radius: 4px; - } - - .tool-execution + .tool-execution { - margin-top: var(--line-height); - } - - .assistant-text + .tool-execution { - margin-top: var(--line-height); - } - - .tool-execution.pending { background: var(--toolPendingBg); } - .tool-execution.success { background: var(--toolSuccessBg); } - .tool-execution.error { background: var(--toolErrorBg); } - - .tool-header, .tool-name { - font-weight: bold; - } - - .tool-path { - color: var(--accent); - word-break: break-all; - } - - .line-numbers { - color: var(--warning); - } - - .line-count { - color: var(--dim); - } - - .tool-command { - font-weight: bold; - white-space: pre-wrap; - word-wrap: break-word; - overflow-wrap: break-word; - word-break: break-word; - } - - .tool-output { - margin-top: var(--line-height); - color: var(--toolOutput); - word-wrap: break-word; - overflow-wrap: break-word; - word-break: break-word; - font-family: inherit; - overflow-x: auto; - } - - .tool-output > div, - .output-preview, - .output-full { - margin: 0; - padding: 0; - line-height: var(--line-height); - } - - .tool-output pre { - margin: 0; - padding: 0; - font-family: inherit; - color: inherit; - white-space: pre-wrap; - word-wrap: break-word; - overflow-wrap: break-word; - } - - .tool-output code { - padding: 0; - background: none; - color: var(--text); - } - - .tool-output.expandable { - cursor: pointer; - } - - .tool-output.expandable:hover { - opacity: 0.9; - } - - .tool-output.expandable .output-full { - display: none; - } - - .tool-output.expandable.expanded .output-preview { - display: none; - } - - .tool-output.expandable.expanded .output-full { - display: block; - } - - .ansi-line { - white-space: pre-wrap; - } - - .tool-images { - } - - .tool-image { - max-width: 100%; - max-height: 500px; - border-radius: 4px; - margin: var(--line-height) 0; - } - - .expand-hint { - color: var(--toolOutput); - } - - /* Diff */ - .tool-diff { - font-size: 11px; - overflow-x: auto; - white-space: pre; - } - - .diff-added { color: var(--toolDiffAdded); } - .diff-removed { color: var(--toolDiffRemoved); } - .diff-context { color: var(--toolDiffContext); } - - /* Model change */ - .model-change { - padding: 0 var(--line-height); - color: var(--dim); - font-size: 11px; - } - - .model-name { - color: var(--borderAccent); - font-weight: bold; - } - - /* Compaction / Branch Summary - matches customMessage colors from TUI */ - .compaction { - background: var(--customMessageBg); - border-radius: 4px; - padding: var(--line-height); - cursor: pointer; - } - - .compaction-label { - color: var(--customMessageLabel); - font-weight: bold; - } - - .compaction-collapsed { - color: var(--customMessageText); - } - - .compaction-content { - display: none; - color: var(--customMessageText); - white-space: pre-wrap; - margin-top: var(--line-height); - } - - .compaction.expanded .compaction-collapsed { - display: none; - } - - .compaction.expanded .compaction-content { - display: block; - } - - /* System prompt */ - .system-prompt { - background: var(--customMessageBg); - padding: var(--line-height); - border-radius: 4px; - margin-bottom: var(--line-height); - } - - .system-prompt.expandable { - cursor: pointer; - } - - .system-prompt-header { - font-weight: bold; - color: var(--customMessageLabel); - } - - .system-prompt-preview { - color: var(--customMessageText); - white-space: pre-wrap; - word-wrap: break-word; - font-size: 11px; - margin-top: var(--line-height); - } - - .system-prompt-expand-hint { - color: var(--muted); - font-style: italic; - margin-top: 4px; - } - - .system-prompt-full { - display: none; - color: var(--customMessageText); - white-space: pre-wrap; - word-wrap: break-word; - font-size: 11px; - margin-top: var(--line-height); - } - - .system-prompt.expanded .system-prompt-preview, - .system-prompt.expanded .system-prompt-expand-hint { - display: none; - } - - .system-prompt.expanded .system-prompt-full { - display: block; - } - - .system-prompt.provider-prompt { - border-left: 3px solid var(--warning); - } - - .system-prompt-note { - font-size: 10px; - font-style: italic; - color: var(--muted); - margin-top: 4px; - } - - /* Tools list */ - .tools-list { - background: var(--customMessageBg); - padding: var(--line-height); - border-radius: 4px; - margin-bottom: var(--line-height); - } - - .tools-header { - font-weight: bold; - color: var(--customMessageLabel); - margin-bottom: var(--line-height); - } - - .tool-item { - font-size: 11px; - } - - .tool-item-name { - font-weight: bold; - color: var(--text); - } - - .tool-item-desc { - color: var(--dim); - } - - .tool-params-hint { - color: var(--muted); - font-style: italic; - } - - .tool-item:has(.tool-params-hint) { - cursor: pointer; - } - - .tool-params-hint::after { - content: '[click to show parameters]'; - } - - .tool-item.params-expanded .tool-params-hint::after { - content: '[hide parameters]'; - } - - .tool-params-content { - display: none; - margin-top: 4px; - margin-left: 12px; - padding-left: 8px; - border-left: 1px solid var(--dim); - } - - .tool-item.params-expanded .tool-params-content { - display: block; - } - - .tool-param { - margin-bottom: 4px; - font-size: 11px; - } - - .tool-param-name { - font-weight: bold; - color: var(--text); - } - - .tool-param-type { - color: var(--dim); - font-style: italic; - } - - .tool-param-required { - color: var(--warning, #e8a838); - font-size: 10px; - } - - .tool-param-optional { - color: var(--dim); - font-size: 10px; - } - - .tool-param-desc { - color: var(--dim); - margin-left: 8px; - } - - /* Hook/custom messages */ - .hook-message { - background: var(--customMessageBg); - color: var(--customMessageText); - padding: var(--line-height); - border-radius: 4px; - } - - .hook-type { - color: var(--customMessageLabel); - font-weight: bold; - } - - /* Branch summary */ - .branch-summary { - background: var(--customMessageBg); - padding: var(--line-height); - border-radius: 4px; - } - - .branch-summary-header { - font-weight: bold; - color: var(--borderAccent); - } - - /* Error */ - .error-text { - color: var(--error); - padding: 0 var(--line-height); - } - .tool-error { - color: var(--error); - } - - /* Images */ - .message-images { - margin-bottom: 12px; - } - - .message-image { - max-width: 100%; - max-height: 400px; - border-radius: 4px; - margin: var(--line-height) 0; - } - - /* Markdown content */ - .markdown-content h1, - .markdown-content h2, - .markdown-content h3, - .markdown-content h4, - .markdown-content h5, - .markdown-content h6 { - color: var(--mdHeading); - margin: var(--line-height) 0 0 0; - font-weight: bold; - } - - .markdown-content h1 { font-size: 1em; } - .markdown-content h2 { font-size: 1em; } - .markdown-content h3 { font-size: 1em; } - .markdown-content h4 { font-size: 1em; } - .markdown-content h5 { font-size: 1em; } - .markdown-content h6 { font-size: 1em; } - .markdown-content p { margin: 0; } - .markdown-content p + p { margin-top: var(--line-height); } - - .markdown-content a { - color: var(--mdLink); - text-decoration: underline; - } - - .markdown-content code { - background: rgba(128, 128, 128, 0.2); - color: var(--mdCode); - padding: 0 4px; - border-radius: 3px; - font-family: inherit; - } - - .markdown-content pre { - background: transparent; - margin: var(--line-height) 0; - overflow-x: auto; - } - - .markdown-content pre code { - display: block; - background: none; - color: var(--text); - } - - .markdown-content blockquote { - border-left: 3px solid var(--mdQuoteBorder); - padding-left: var(--line-height); - margin: var(--line-height) 0; - color: var(--mdQuote); - font-style: italic; - } - - .markdown-content ul, - .markdown-content ol { - margin: var(--line-height) 0; - padding-left: calc(var(--line-height) * 2); - } - - .markdown-content li { margin: 0; } - .markdown-content li::marker { color: var(--mdListBullet); } - - .markdown-content hr { - border: none; - border-top: 1px solid var(--mdHr); - margin: var(--line-height) 0; - } - - .markdown-content table { - border-collapse: collapse; - margin: 0.5em 0; - width: 100%; - } - - .markdown-content th, - .markdown-content td { - border: 1px solid var(--mdCodeBlockBorder); - padding: 6px 10px; - text-align: left; - } - - .markdown-content th { - background: rgba(128, 128, 128, 0.1); - font-weight: bold; - } - - .markdown-content img { - max-width: 100%; - border-radius: 4px; - } - - /* Syntax highlighting */ - .hljs { background: transparent; color: var(--text); } - .hljs-comment, .hljs-quote { color: var(--syntaxComment); } - .hljs-keyword, .hljs-selector-tag { color: var(--syntaxKeyword); } - .hljs-number, .hljs-literal { color: var(--syntaxNumber); } - .hljs-string, .hljs-doctag { color: var(--syntaxString); } - /* Function names: hljs v11 uses .hljs-title.function_ compound class */ - .hljs-function, .hljs-title, .hljs-title.function_, .hljs-section, .hljs-name { color: var(--syntaxFunction); } - /* Types: hljs v11 uses .hljs-title.class_ for class names */ - .hljs-type, .hljs-class, .hljs-title.class_, .hljs-built_in { color: var(--syntaxType); } - .hljs-attr, .hljs-variable, .hljs-variable.language_, .hljs-params, .hljs-property { color: var(--syntaxVariable); } - .hljs-meta, .hljs-meta .hljs-keyword, .hljs-meta .hljs-string { color: var(--syntaxKeyword); } - .hljs-operator { color: var(--syntaxOperator); } - .hljs-punctuation { color: var(--syntaxPunctuation); } - .hljs-subst { color: var(--text); } - - /* Footer */ - .footer { - margin-top: 48px; - padding: 20px; - text-align: center; - color: var(--dim); - font-size: 10px; - } - - /* Mobile */ - #hamburger { - display: none; - position: fixed; - top: 10px; - left: 10px; - z-index: 100; - padding: 3px 8px; - font-size: 12px; - font-family: inherit; - background: transparent; - color: var(--muted); - border: 1px solid var(--dim); - border-radius: 3px; - cursor: pointer; - } - - #hamburger:hover { - color: var(--text); - border-color: var(--text); - } - - - - #sidebar-overlay { - display: none; - position: fixed; - top: 0; - left: 0; - right: 0; - bottom: 0; - background: rgba(0, 0, 0, 0.5); - z-index: 98; - } - - @media (max-width: 900px) { - #sidebar { - position: fixed; - left: -400px; - width: 400px; - top: 0; - bottom: 0; - height: 100vh; - z-index: 99; - transition: left 0.3s; - } - - #sidebar.open { - left: 0; - } - - #sidebar-overlay.open { - display: block; - } - - #hamburger { - display: block; - } - - .sidebar-close { - display: block; - } - - #content { - padding: var(--line-height) 16px; - } - - #content > * { - max-width: 100%; - } - } - - @media (max-width: 500px) { - #sidebar { - width: 100vw; - left: -100vw; - } - } - - @media print { - #sidebar, #sidebar-toggle { display: none !important; } - body { background: white; color: black; } - #content { max-width: none; } - } +:root { + /* {{THEME_VARS}} */ + /* {{BODY_BG_DECL}} */ + /* {{CONTAINER_BG_DECL}} */ + /* {{INFO_BG_DECL}} */ +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +:root { + --line-height: 18px; /* 12px font * 1.5 */ +} + +body { + font-family: + ui-monospace, "Cascadia Code", "Source Code Pro", Menlo, Consolas, "DejaVu Sans Mono", monospace; + font-size: 12px; + line-height: var(--line-height); + color: var(--text); + background: var(--body-bg); +} + +#app { + display: flex; + min-height: 100vh; +} + +/* Sidebar */ +#sidebar { + width: 400px; + background: var(--container-bg); + flex-shrink: 0; + display: flex; + flex-direction: column; + position: sticky; + top: 0; + height: 100vh; + border-right: 1px solid var(--dim); +} + +.sidebar-header { + padding: 8px 12px; + flex-shrink: 0; +} + +.sidebar-controls { + padding: 8px 8px 4px 8px; +} + +.sidebar-search { + width: 100%; + box-sizing: border-box; + padding: 4px 8px; + font-size: 11px; + font-family: inherit; + background: var(--body-bg); + color: var(--text); + border: 1px solid var(--dim); + border-radius: 3px; +} + +.sidebar-filters { + display: flex; + padding: 4px 8px 8px 8px; + gap: 4px; + align-items: center; + flex-wrap: wrap; +} + +.sidebar-search:focus { + outline: none; + border-color: var(--accent); +} + +.sidebar-search::placeholder { + color: var(--muted); +} + +.filter-btn { + padding: 3px 8px; + font-size: 10px; + font-family: inherit; + background: transparent; + color: var(--muted); + border: 1px solid var(--dim); + border-radius: 3px; + cursor: pointer; +} + +.filter-btn:hover { + color: var(--text); + border-color: var(--text); +} + +.filter-btn.active { + background: var(--accent); + color: var(--body-bg); + border-color: var(--accent); +} + +.sidebar-close { + display: none; + padding: 3px 8px; + font-size: 12px; + font-family: inherit; + background: transparent; + color: var(--muted); + border: 1px solid var(--dim); + border-radius: 3px; + cursor: pointer; + margin-left: auto; +} + +.sidebar-close:hover { + color: var(--text); + border-color: var(--text); +} + +.tree-container { + flex: 1; + overflow: auto; + padding: 4px 0; +} + +.tree-node { + padding: 0 8px; + cursor: pointer; + display: flex; + align-items: baseline; + font-size: 11px; + line-height: 13px; + white-space: nowrap; +} + +.tree-node:hover { + background: var(--selectedBg); +} + +.tree-node.active { + background: var(--selectedBg); +} + +.tree-node.active .tree-content { + font-weight: bold; +} + +.tree-node.in-path { + background: color-mix(in srgb, var(--accent) 10%, transparent); +} + +.tree-node:not(.in-path) { + opacity: 0.5; +} + +.tree-node:not(.in-path):hover { + opacity: 1; +} + +.tree-prefix { + color: var(--muted); + flex-shrink: 0; + font-family: monospace; + white-space: pre; +} + +.tree-marker { + color: var(--accent); + flex-shrink: 0; +} + +.tree-content { + color: var(--text); +} + +.tree-role-user { + color: var(--accent); +} + +.tree-role-assistant { + color: var(--success); +} + +.tree-role-tool { + color: var(--muted); +} + +.tree-muted { + color: var(--muted); +} + +.tree-error { + color: var(--error); +} + +.tree-compaction { + color: var(--borderAccent); +} + +.tree-branch-summary { + color: var(--warning); +} + +.tree-custom-message { + color: var(--customMessageLabel); +} + +.tree-status { + padding: 4px 12px; + font-size: 10px; + color: var(--muted); + flex-shrink: 0; +} + +/* Main content */ +#content { + flex: 1; + overflow-y: auto; + padding: var(--line-height) calc(var(--line-height) * 2); + display: flex; + flex-direction: column; + align-items: center; +} + +#content > * { + width: 100%; + max-width: 800px; +} + +/* Help bar */ +.help-bar { + font-size: 11px; + color: var(--warning); + margin-bottom: var(--line-height); + display: flex; + align-items: center; + gap: 12px; +} + +.download-json-btn { + font-size: 10px; + padding: 2px 8px; + background: var(--container-bg); + border: 1px solid var(--border); + border-radius: 3px; + color: var(--text); + cursor: pointer; + font-family: inherit; +} + +.download-json-btn:hover { + background: var(--hover); + border-color: var(--borderAccent); +} + +/* Header */ +.header { + background: var(--container-bg); + border-radius: 4px; + padding: var(--line-height); + margin-bottom: var(--line-height); +} + +.header h1 { + font-size: 12px; + font-weight: bold; + color: var(--borderAccent); + margin-bottom: var(--line-height); +} + +.header-info { + display: flex; + flex-direction: column; + gap: 0; + font-size: 11px; +} + +.info-item { + color: var(--dim); + display: flex; + align-items: baseline; +} + +.info-label { + font-weight: 600; + margin-right: 8px; + min-width: 100px; +} + +.info-value { + color: var(--text); + flex: 1; +} + +/* Messages */ +#messages { + display: flex; + flex-direction: column; + gap: var(--line-height); +} + +.message-timestamp { + font-size: 10px; + color: var(--dim); + opacity: 0.8; +} + +.user-message { + background: var(--userMessageBg); + color: var(--userMessageText); + padding: var(--line-height); + border-radius: 4px; + position: relative; +} + +.assistant-message { + padding: 0; + position: relative; +} + +/* Copy link button - appears on hover */ +.copy-link-btn { + position: absolute; + top: 8px; + right: 8px; + width: 28px; + height: 28px; + padding: 6px; + background: var(--container-bg); + border: 1px solid var(--dim); + border-radius: 4px; + color: var(--muted); + cursor: pointer; + opacity: 0; + transition: + opacity 0.15s, + background 0.15s, + color 0.15s; + display: flex; + align-items: center; + justify-content: center; + z-index: 10; +} + +.user-message:hover .copy-link-btn, +.assistant-message:hover .copy-link-btn { + opacity: 1; +} + +.copy-link-btn:hover { + background: var(--accent); + color: var(--body-bg); + border-color: var(--accent); +} + +.copy-link-btn.copied { + background: var(--success, #22c55e); + color: white; + border-color: var(--success, #22c55e); +} + +/* Highlight effect for deep-linked messages */ +.user-message.highlight, +.assistant-message.highlight { + animation: highlight-pulse 2s ease-out; +} + +@keyframes highlight-pulse { + 0% { + box-shadow: 0 0 0 3px var(--accent); + } + 100% { + box-shadow: 0 0 0 0 transparent; + } +} + +.assistant-message > .message-timestamp { + padding-left: var(--line-height); +} + +.assistant-text { + padding: var(--line-height); + padding-bottom: 0; +} + +.message-timestamp + .assistant-text, +.message-timestamp + .thinking-block { + padding-top: 0; +} + +.thinking-block + .assistant-text { + padding-top: 0; +} + +.thinking-text { + padding: var(--line-height); + color: var(--thinkingText); + font-style: italic; + white-space: pre-wrap; +} + +.message-timestamp + .thinking-block .thinking-text, +.message-timestamp + .thinking-block .thinking-collapsed { + padding-top: 0; +} + +.thinking-collapsed { + display: none; + padding: var(--line-height); + color: var(--thinkingText); + font-style: italic; +} + +/* Tool execution */ +.tool-execution { + padding: var(--line-height); + border-radius: 4px; +} + +.tool-execution + .tool-execution { + margin-top: var(--line-height); +} + +.assistant-text + .tool-execution { + margin-top: var(--line-height); +} + +.tool-execution.pending { + background: var(--toolPendingBg); +} +.tool-execution.success { + background: var(--toolSuccessBg); +} +.tool-execution.error { + background: var(--toolErrorBg); +} + +.tool-header, +.tool-name { + font-weight: bold; +} + +.tool-path { + color: var(--accent); + word-break: break-all; +} + +.line-numbers { + color: var(--warning); +} + +.line-count { + color: var(--dim); +} + +.tool-command { + font-weight: bold; + white-space: pre-wrap; + word-wrap: break-word; + overflow-wrap: break-word; + word-break: break-word; +} + +.tool-output { + margin-top: var(--line-height); + color: var(--toolOutput); + word-wrap: break-word; + overflow-wrap: break-word; + word-break: break-word; + font-family: inherit; + overflow-x: auto; +} + +.tool-output > div, +.output-preview, +.output-full { + margin: 0; + padding: 0; + line-height: var(--line-height); +} + +.tool-output pre { + margin: 0; + padding: 0; + font-family: inherit; + color: inherit; + white-space: pre-wrap; + word-wrap: break-word; + overflow-wrap: break-word; +} + +.tool-output code { + padding: 0; + background: none; + color: var(--text); +} + +.tool-output.expandable { + cursor: pointer; +} + +.tool-output.expandable:hover { + opacity: 0.9; +} + +.tool-output.expandable .output-full { + display: none; +} + +.tool-output.expandable.expanded .output-preview { + display: none; +} + +.tool-output.expandable.expanded .output-full { + display: block; +} + +.ansi-line { + white-space: pre-wrap; +} + +.tool-images { +} + +.tool-image { + max-width: 100%; + max-height: 500px; + border-radius: 4px; + margin: var(--line-height) 0; +} + +.expand-hint { + color: var(--toolOutput); +} + +/* Diff */ +.tool-diff { + font-size: 11px; + overflow-x: auto; + white-space: pre; +} + +.diff-added { + color: var(--toolDiffAdded); +} +.diff-removed { + color: var(--toolDiffRemoved); +} +.diff-context { + color: var(--toolDiffContext); +} + +/* Model change */ +.model-change { + padding: 0 var(--line-height); + color: var(--dim); + font-size: 11px; +} + +.model-name { + color: var(--borderAccent); + font-weight: bold; +} + +/* Compaction / Branch Summary - matches customMessage colors from TUI */ +.compaction { + background: var(--customMessageBg); + border-radius: 4px; + padding: var(--line-height); + cursor: pointer; +} + +.compaction-label { + color: var(--customMessageLabel); + font-weight: bold; +} + +.compaction-collapsed { + color: var(--customMessageText); +} + +.compaction-content { + display: none; + color: var(--customMessageText); + white-space: pre-wrap; + margin-top: var(--line-height); +} + +.compaction.expanded .compaction-collapsed { + display: none; +} + +.compaction.expanded .compaction-content { + display: block; +} + +/* System prompt */ +.system-prompt { + background: var(--customMessageBg); + padding: var(--line-height); + border-radius: 4px; + margin-bottom: var(--line-height); +} + +.system-prompt.expandable { + cursor: pointer; +} + +.system-prompt-header { + font-weight: bold; + color: var(--customMessageLabel); +} + +.system-prompt-preview { + color: var(--customMessageText); + white-space: pre-wrap; + word-wrap: break-word; + font-size: 11px; + margin-top: var(--line-height); +} + +.system-prompt-expand-hint { + color: var(--muted); + font-style: italic; + margin-top: 4px; +} + +.system-prompt-full { + display: none; + color: var(--customMessageText); + white-space: pre-wrap; + word-wrap: break-word; + font-size: 11px; + margin-top: var(--line-height); +} + +.system-prompt.expanded .system-prompt-preview, +.system-prompt.expanded .system-prompt-expand-hint { + display: none; +} + +.system-prompt.expanded .system-prompt-full { + display: block; +} + +.system-prompt.provider-prompt { + border-left: 3px solid var(--warning); +} + +.system-prompt-note { + font-size: 10px; + font-style: italic; + color: var(--muted); + margin-top: 4px; +} + +/* Tools list */ +.tools-list { + background: var(--customMessageBg); + padding: var(--line-height); + border-radius: 4px; + margin-bottom: var(--line-height); +} + +.tools-header { + font-weight: bold; + color: var(--customMessageLabel); + margin-bottom: var(--line-height); +} + +.tool-item { + font-size: 11px; +} + +.tool-item-name { + font-weight: bold; + color: var(--text); +} + +.tool-item-desc { + color: var(--dim); +} + +.tool-params-hint { + color: var(--muted); + font-style: italic; +} + +.tool-item:has(.tool-params-hint) { + cursor: pointer; +} + +.tool-params-hint::after { + content: "[click to show parameters]"; +} + +.tool-item.params-expanded .tool-params-hint::after { + content: "[hide parameters]"; +} + +.tool-params-content { + display: none; + margin-top: 4px; + margin-left: 12px; + padding-left: 8px; + border-left: 1px solid var(--dim); +} + +.tool-item.params-expanded .tool-params-content { + display: block; +} + +.tool-param { + margin-bottom: 4px; + font-size: 11px; +} + +.tool-param-name { + font-weight: bold; + color: var(--text); +} + +.tool-param-type { + color: var(--dim); + font-style: italic; +} + +.tool-param-required { + color: var(--warning, #e8a838); + font-size: 10px; +} + +.tool-param-optional { + color: var(--dim); + font-size: 10px; +} + +.tool-param-desc { + color: var(--dim); + margin-left: 8px; +} + +/* Hook/custom messages */ +.hook-message { + background: var(--customMessageBg); + color: var(--customMessageText); + padding: var(--line-height); + border-radius: 4px; +} + +.hook-type { + color: var(--customMessageLabel); + font-weight: bold; +} + +/* Branch summary */ +.branch-summary { + background: var(--customMessageBg); + padding: var(--line-height); + border-radius: 4px; +} + +.branch-summary-header { + font-weight: bold; + color: var(--borderAccent); +} + +/* Error */ +.error-text { + color: var(--error); + padding: 0 var(--line-height); +} +.tool-error { + color: var(--error); +} + +/* Images */ +.message-images { + margin-bottom: 12px; +} + +.message-image { + max-width: 100%; + max-height: 400px; + border-radius: 4px; + margin: var(--line-height) 0; +} + +/* Markdown content */ +.markdown-content h1, +.markdown-content h2, +.markdown-content h3, +.markdown-content h4, +.markdown-content h5, +.markdown-content h6 { + color: var(--mdHeading); + margin: var(--line-height) 0 0 0; + font-weight: bold; +} + +.markdown-content h1 { + font-size: 1em; +} +.markdown-content h2 { + font-size: 1em; +} +.markdown-content h3 { + font-size: 1em; +} +.markdown-content h4 { + font-size: 1em; +} +.markdown-content h5 { + font-size: 1em; +} +.markdown-content h6 { + font-size: 1em; +} +.markdown-content p { + margin: 0; +} +.markdown-content p + p { + margin-top: var(--line-height); +} + +.markdown-content a { + color: var(--mdLink); + text-decoration: underline; +} + +.markdown-content code { + background: rgba(128, 128, 128, 0.2); + color: var(--mdCode); + padding: 0 4px; + border-radius: 3px; + font-family: inherit; +} + +.markdown-content pre { + background: transparent; + margin: var(--line-height) 0; + overflow-x: auto; +} + +.markdown-content pre code { + display: block; + background: none; + color: var(--text); +} + +.markdown-content blockquote { + border-left: 3px solid var(--mdQuoteBorder); + padding-left: var(--line-height); + margin: var(--line-height) 0; + color: var(--mdQuote); + font-style: italic; +} + +.markdown-content ul, +.markdown-content ol { + margin: var(--line-height) 0; + padding-left: calc(var(--line-height) * 2); +} + +.markdown-content li { + margin: 0; +} +.markdown-content li::marker { + color: var(--mdListBullet); +} + +.markdown-content hr { + border: none; + border-top: 1px solid var(--mdHr); + margin: var(--line-height) 0; +} + +.markdown-content table { + border-collapse: collapse; + margin: 0.5em 0; + width: 100%; +} + +.markdown-content th, +.markdown-content td { + border: 1px solid var(--mdCodeBlockBorder); + padding: 6px 10px; + text-align: left; +} + +.markdown-content th { + background: rgba(128, 128, 128, 0.1); + font-weight: bold; +} + +.markdown-content img { + max-width: 100%; + border-radius: 4px; +} + +/* Syntax highlighting */ +.hljs { + background: transparent; + color: var(--text); +} +.hljs-comment, +.hljs-quote { + color: var(--syntaxComment); +} +.hljs-keyword, +.hljs-selector-tag { + color: var(--syntaxKeyword); +} +.hljs-number, +.hljs-literal { + color: var(--syntaxNumber); +} +.hljs-string, +.hljs-doctag { + color: var(--syntaxString); +} +/* Function names: hljs v11 uses .hljs-title.function_ compound class */ +.hljs-function, +.hljs-title, +.hljs-title.function_, +.hljs-section, +.hljs-name { + color: var(--syntaxFunction); +} +/* Types: hljs v11 uses .hljs-title.class_ for class names */ +.hljs-type, +.hljs-class, +.hljs-title.class_, +.hljs-built_in { + color: var(--syntaxType); +} +.hljs-attr, +.hljs-variable, +.hljs-variable.language_, +.hljs-params, +.hljs-property { + color: var(--syntaxVariable); +} +.hljs-meta, +.hljs-meta .hljs-keyword, +.hljs-meta .hljs-string { + color: var(--syntaxKeyword); +} +.hljs-operator { + color: var(--syntaxOperator); +} +.hljs-punctuation { + color: var(--syntaxPunctuation); +} +.hljs-subst { + color: var(--text); +} + +/* Footer */ +.footer { + margin-top: 48px; + padding: 20px; + text-align: center; + color: var(--dim); + font-size: 10px; +} + +/* Mobile */ +#hamburger { + display: none; + position: fixed; + top: 10px; + left: 10px; + z-index: 100; + padding: 3px 8px; + font-size: 12px; + font-family: inherit; + background: transparent; + color: var(--muted); + border: 1px solid var(--dim); + border-radius: 3px; + cursor: pointer; +} + +#hamburger:hover { + color: var(--text); + border-color: var(--text); +} + +#sidebar-overlay { + display: none; + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.5); + z-index: 98; +} + +@media (max-width: 900px) { + #sidebar { + position: fixed; + left: -400px; + width: 400px; + top: 0; + bottom: 0; + height: 100vh; + z-index: 99; + transition: left 0.3s; + } + + #sidebar.open { + left: 0; + } + + #sidebar-overlay.open { + display: block; + } + + #hamburger { + display: block; + } + + .sidebar-close { + display: block; + } + + #content { + padding: var(--line-height) 16px; + } + + #content > * { + max-width: 100%; + } +} + +@media (max-width: 500px) { + #sidebar { + width: 100vw; + left: -100vw; + } +} + +@media print { + #sidebar, + #sidebar-toggle { + display: none !important; + } + body { + background: white; + color: black; + } + #content { + max-width: none; + } +} diff --git a/src/auto-reply/reply/export-html/template.html b/src/auto-reply/reply/export-html/template.html index 42f2a45b03..d1fa419826 100644 --- a/src/auto-reply/reply/export-html/template.html +++ b/src/auto-reply/reply/export-html/template.html @@ -1,54 +1,88 @@ - + - - - - Session Export - - - - - -
- -
-
-
-
-
-
-
- + - - + + - - + + - - - + + + diff --git a/src/auto-reply/reply/export-html/template.js b/src/auto-reply/reply/export-html/template.js index d97bb04d24..f4f19a6d25 100644 --- a/src/auto-reply/reply/export-html/template.js +++ b/src/auto-reply/reply/export-html/template.js @@ -1,1586 +1,1820 @@ - (function() { - 'use strict'; +(function () { + "use strict"; - // ============================================================ - // DATA LOADING - // ============================================================ + // ============================================================ + // 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; + 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 - // ============================================================ + // ============================================================ + // 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; + // 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 - // ============================================================ + // ============================================================ + // DATA STRUCTURES + // ============================================================ - // Entry lookup by ID - const byId = new Map(); - for (const entry of entries) { - byId.set(entry.id, entry); - } + // 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 }); - } - } + // 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); + // 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); } } + } - // ============================================================ - // TREE DATA PREPARATION (no DOM, pure data) - // ============================================================ + // 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); - /** - * Build tree structure from flat entries. - * Returns array of root nodes, each with { entry, children, label }. - */ - function buildTree() { - const nodeMap = new Map(); - const roots = []; + return roots; + } - // Create nodes - for (const entry of entries) { - nodeMap.set(entry.id, { - entry, - children: [], - label: labelMap.get(entry.id) - }); + /** + * 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); - // 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); - } - } - } + // Stack: [node, indent, justBranched, showConnector, isLast, gutters, isVirtualRootChild] + const stack = []; - // 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); + // Add roots (prioritize branch containing active leaf) + const orderedRoots = [...roots].toSorted( + (a, b) => Number(containsActive.get(b)) - Number(containsActive.get(a)), + ); + for (let i = orderedRoots.length - 1; i >= 0; i--) { + const isLast = i === orderedRoots.length - 1; + stack.push([ + orderedRoots[i], + multipleRoots ? 1 : 0, + multipleRoots, + multipleRoots, + isLast, + [], + multipleRoots, + ]); + } - return roots; + while (stack.length > 0) { + const [node, indent, justBranched, showConnector, isLast, gutters, isVirtualRootChild] = + stack.pop(); + + result.push({ + node, + indent, + showConnector, + isLast, + gutters, + isVirtualRootChild, + multipleRoots, + }); + + const children = node.children; + const multipleChildren = children.length > 1; + + // Order children (active branch first) + const orderedChildren = [...children].toSorted( + (a, b) => Number(containsActive.get(b)) - Number(containsActive.get(a)), + ); + + // Calculate child indent (matches tree-selector.ts) + let childIndent; + if (multipleChildren) { + // Parent branches: children get +1 + childIndent = indent + 1; + } else if (justBranched && indent > 0) { + // First generation after a branch: +1 for visual grouping + childIndent = indent + 1; + } else { + // Single-child chain: stay flat + childIndent = indent; } - /** - * Build 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; + // 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, + ]); } + } - /** - * 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 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(" "); } - return path; + } else { + prefixChars.push(" "); } + } + return prefixChars.join(""); + } - // Tree node lookup for finding leaves - let treeNodeMap = null; + // ============================================================ + // FILTERING (pure data) + // ============================================================ - /** - * 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); + 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; } - - 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; } + } + return false; + } - /** - * 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; + 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 ""; + } - // 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; + 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)); } - 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)) + 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), ); - 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]); - } + 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; + } - while (stack.length > 0) { - const [node, indent, justBranched, showConnector, isLast, gutters, isVirtualRootChild] = stack.pop(); + return parts.join(" ").toLowerCase(); + } - result.push({ node, indent, showConnector, isLast, gutters, isVirtualRootChild, multipleRoots }); + /** + * Filter flat nodes based on current filterMode and searchQuery. + */ + function filterNodes(flatNodes, currentLeafId) { + const searchTokens = searchQuery.toLowerCase().split(/\s+/).filter(Boolean); - const children = node.children; - const multipleChildren = children.length > 1; + const filtered = flatNodes.filter((flatNode) => { + const entry = flatNode.node.entry; + const label = flatNode.node.label; + const isCurrentLeaf = entry.id === currentLeafId; - // 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; + // Always show current leaf + if (isCurrentLeaf) { + return true; } - /** - * 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(' '); - } + // 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; } - return prefixChars.join(''); } - // ============================================================ - // FILTERING (pure data) - // ============================================================ + // Apply filter mode + const isSettingsEntry = ["label", "custom", "model_change", "thinking_level_change"].includes( + entry.type, + ); + let passesFilter = true; - let filterMode = 'default'; - let searchQuery = ''; + 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; + } - 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; - } - } + if (!passesFilter) { 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(''); + // Apply search filter + if (searchTokens.length > 0) { + const nodeText = getSearchableText(entry, label); + if (!searchTokens.every((t) => nodeText.includes(t))) { + return false; } - return ''; } - function getSearchableText(entry, label) { - const parts = []; - if (label) parts.push(label); + return true; + }); - 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; + // 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; + } - return parts.join(' ').toLowerCase(); + // 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; } - /** - * Filter flat nodes based on current filterMode and searchQuery. - */ - function filterNodes(flatNodes, currentLeafId) { - const searchTokens = searchQuery.toLowerCase().split(/\s+/).filter(Boolean); + // Update this node's visual properties + flatNode.indent = indent; + flatNode.showConnector = showConnector; + flatNode.isLast = isLast; + flatNode.gutters = gutters; + flatNode.isVirtualRootChild = isVirtualRootChild; + flatNode.multipleRoots = multipleRoots; - const filtered = flatNodes.filter(flatNode => { - const entry = flatNode.node.entry; - const label = flatNode.node.label; - const isCurrentLeaf = entry.id === currentLeafId; + // Get visible children of this node + const children = visibleChildren.get(nodeId) || []; + const multipleChildren = children.length > 1; - // Always show current leaf - if (isCurrentLeaf) return true; + // 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; + } - // 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; + // 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)}` + ); } - - // 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 (msg.stopReason === "aborted") { + return ( + labelHtml + + `assistant: (aborted)` + ); } - - 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; + 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 + `[${msg.toolName || "tool"}]`; + } + if (msg.role === "bashExecution") { + const cmd = truncate(normalize(msg.command || "")); + return labelHtml + `[bash]: ${escapeHtml(cmd)}`; + } + return labelHtml + `[${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: ${entry.modelId}]`; + case "thinking_level_change": + return labelHtml + `[thinking: ${entry.thinkingLevel}]`; + default: + return labelHtml + `[${entry.type}]`; + } + } - return true; + // ============================================================ + // 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); }); - // Recalculate visual structure based on visible tree - recalculateVisualStructure(filtered, flatNodes); - - return filtered; + container.appendChild(div); } - /** - * 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; + 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; - const visibleIds = new Set(filteredNodes.map(n => n.node.entry.id)); + node.classList.toggle("in-path", isOnPath); + node.classList.toggle("active", isTarget); - // 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 - ]); - } + const marker = node.querySelector(".tree-marker"); + if (marker) { + marker.textContent = isOnPath ? "•" : " "; } } + } - // ============================================================ - // TREE DISPLAY TEXT (pure data -> string) - // ============================================================ + document.getElementById("tree-status").textContent = + `${filtered.length} / ${flatNodes.length} entries`; - 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; + // Scroll active node into view after layout + setTimeout(() => { + const activeNode = container.querySelector(".tree-node.active"); + if (activeNode) { + activeNode.scrollIntoView({ block: "nearest" }); } + }, 0); + } - 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 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 escapeHtml(text) { - const div = document.createElement('div'); - div.textContent = text; - return div.innerHTML; + 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); } - /** - * 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 + `[${msg.toolName || 'tool'}]`; - } - if (msg.role === 'bashExecution') { - const cmd = truncate(normalize(msg.command || '')); - return labelHtml + `[bash]: ${escapeHtml(cmd)}`; - } - return labelHtml + `[${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: ${entry.modelId}]`; - case 'thinking_level_change': - return labelHtml + `[thinking: ${entry.thinkingLevel}]`; - default: - return labelHtml + `[${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 ? '•' : ' '; - } - } + if (remaining > 0) { + const previewCode = displayLines.join("\n"); + let previewHighlighted; + try { + previewHighlighted = hljs.highlight(previewCode, { language: lang }).value; + } catch { + previewHighlighted = escapeHtml(previewCode); } - 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 `