@oh-my-pi/pi-coding-agent 2.2.1337 → 3.0.1337
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +64 -34
- package/README.md +100 -100
- package/docs/compaction.md +8 -8
- package/docs/config-usage.md +113 -0
- package/docs/custom-tools.md +8 -8
- package/docs/extension-loading.md +58 -58
- package/docs/hooks.md +11 -11
- package/docs/rpc.md +4 -4
- package/docs/sdk.md +14 -14
- package/docs/session-tree-plan.md +1 -1
- package/docs/session.md +2 -2
- package/docs/skills.md +16 -16
- package/docs/theme.md +9 -9
- package/docs/tui.md +1 -1
- package/examples/README.md +1 -1
- package/examples/custom-tools/README.md +4 -4
- package/examples/custom-tools/subagent/README.md +13 -13
- package/examples/custom-tools/subagent/agents.ts +2 -2
- package/examples/custom-tools/subagent/index.ts +5 -5
- package/examples/hooks/README.md +3 -3
- package/examples/hooks/auto-commit-on-exit.ts +1 -1
- package/examples/hooks/custom-compaction.ts +1 -1
- package/examples/sdk/01-minimal.ts +1 -1
- package/examples/sdk/04-skills.ts +1 -1
- package/examples/sdk/05-tools.ts +1 -1
- package/examples/sdk/08-slash-commands.ts +1 -1
- package/examples/sdk/09-api-keys-and-oauth.ts +2 -2
- package/examples/sdk/README.md +2 -2
- package/package.json +16 -12
- package/src/capability/context-file.ts +40 -0
- package/src/capability/extension.ts +48 -0
- package/src/capability/hook.ts +40 -0
- package/src/capability/index.ts +616 -0
- package/src/capability/instruction.ts +37 -0
- package/src/capability/mcp.ts +52 -0
- package/src/capability/prompt.ts +35 -0
- package/src/capability/rule.ts +52 -0
- package/src/capability/settings.ts +35 -0
- package/src/capability/skill.ts +49 -0
- package/src/capability/slash-command.ts +40 -0
- package/src/capability/system-prompt.ts +35 -0
- package/src/capability/tool.ts +38 -0
- package/src/capability/types.ts +166 -0
- package/src/cli/args.ts +2 -2
- package/src/cli/plugin-cli.ts +24 -19
- package/src/cli/update-cli.ts +10 -10
- package/src/config.ts +290 -6
- package/src/core/auth-storage.ts +32 -9
- package/src/core/bash-executor.ts +1 -1
- package/src/core/custom-commands/loader.ts +44 -50
- package/src/core/custom-tools/index.ts +1 -0
- package/src/core/custom-tools/loader.ts +67 -69
- package/src/core/custom-tools/types.ts +10 -1
- package/src/core/export-html/index.ts +9 -9
- package/src/core/export-html/template.generated.ts +2 -0
- package/src/core/hooks/loader.ts +13 -42
- package/src/core/index.ts +0 -1
- package/src/core/logger.ts +7 -7
- package/src/core/mcp/client.ts +1 -1
- package/src/core/mcp/config.ts +94 -146
- package/src/core/mcp/index.ts +0 -4
- package/src/core/mcp/loader.ts +26 -22
- package/src/core/mcp/manager.ts +18 -23
- package/src/core/mcp/tool-bridge.ts +9 -1
- package/src/core/mcp/types.ts +2 -0
- package/src/core/model-registry.ts +25 -8
- package/src/core/plugins/installer.ts +1 -1
- package/src/core/plugins/loader.ts +17 -11
- package/src/core/plugins/manager.ts +2 -2
- package/src/core/plugins/paths.ts +12 -7
- package/src/core/plugins/types.ts +3 -3
- package/src/core/sdk.ts +48 -27
- package/src/core/session-manager.ts +4 -4
- package/src/core/settings-manager.ts +45 -21
- package/src/core/skills.ts +222 -293
- package/src/core/slash-commands.ts +34 -165
- package/src/core/system-prompt.ts +58 -65
- package/src/core/timings.ts +2 -2
- package/src/core/tools/lsp/config.ts +38 -17
- package/src/core/tools/task/artifacts.ts +1 -1
- package/src/core/tools/task/commands.ts +30 -107
- package/src/core/tools/task/discovery.ts +54 -66
- package/src/core/tools/task/executor.ts +9 -9
- package/src/core/tools/task/index.ts +10 -10
- package/src/core/tools/task/model-resolver.ts +27 -25
- package/src/core/tools/task/types.ts +2 -2
- package/src/core/tools/web-fetch.ts +3 -3
- package/src/core/tools/web-search/auth.ts +40 -34
- package/src/core/tools/web-search/index.ts +1 -1
- package/src/core/tools/web-search/providers/anthropic.ts +1 -1
- package/src/discovery/agents-md.ts +75 -0
- package/src/discovery/builtin.ts +646 -0
- package/src/discovery/claude.ts +623 -0
- package/src/discovery/cline.ts +102 -0
- package/src/discovery/codex.ts +571 -0
- package/src/discovery/cursor.ts +264 -0
- package/src/discovery/gemini.ts +368 -0
- package/src/discovery/github.ts +120 -0
- package/src/discovery/helpers.test.ts +127 -0
- package/src/discovery/helpers.ts +249 -0
- package/src/discovery/index.ts +84 -0
- package/src/discovery/mcp-json.ts +127 -0
- package/src/discovery/vscode.ts +99 -0
- package/src/discovery/windsurf.ts +216 -0
- package/src/main.ts +14 -13
- package/src/migrations.ts +24 -3
- package/src/modes/interactive/components/hook-editor.ts +1 -1
- package/src/modes/interactive/components/plugin-settings.ts +1 -1
- package/src/modes/interactive/components/settings-defs.ts +38 -2
- package/src/modes/interactive/components/settings-selector.ts +1 -0
- package/src/modes/interactive/components/welcome.ts +2 -2
- package/src/modes/interactive/interactive-mode.ts +211 -16
- package/src/modes/interactive/theme/theme-schema.json +1 -1
- package/src/utils/clipboard.ts +1 -1
- package/src/utils/shell-snapshot.ts +2 -2
- package/src/utils/shell.ts +7 -7
|
@@ -5,11 +5,11 @@
|
|
|
5
5
|
* to avoid import resolution issues with custom tools loaded from user directories.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import * as fs from "node:fs";
|
|
9
8
|
import * as os from "node:os";
|
|
10
9
|
import * as path from "node:path";
|
|
11
10
|
import * as typebox from "@sinclair/typebox";
|
|
12
|
-
import {
|
|
11
|
+
import { toolCapability } from "../../capability/tool";
|
|
12
|
+
import { type CustomTool, loadSync } from "../../discovery";
|
|
13
13
|
import * as piCodingAgent from "../../index";
|
|
14
14
|
import { theme } from "../../modes/interactive/theme/theme";
|
|
15
15
|
import type { ExecOptions } from "../exec";
|
|
@@ -73,6 +73,13 @@ function createNoOpUIContext(): HookUIContext {
|
|
|
73
73
|
};
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
+
/** Error with source metadata */
|
|
77
|
+
interface ToolLoadError {
|
|
78
|
+
path: string;
|
|
79
|
+
error: string;
|
|
80
|
+
source?: { provider: string; providerName: string; level: "user" | "project" };
|
|
81
|
+
}
|
|
82
|
+
|
|
76
83
|
/**
|
|
77
84
|
* Load a single tool module using native Bun import.
|
|
78
85
|
*/
|
|
@@ -80,15 +87,28 @@ async function loadTool(
|
|
|
80
87
|
toolPath: string,
|
|
81
88
|
cwd: string,
|
|
82
89
|
sharedApi: CustomToolAPI,
|
|
83
|
-
|
|
90
|
+
source?: { provider: string; providerName: string; level: "user" | "project" },
|
|
91
|
+
): Promise<{ tools: LoadedCustomTool[] | null; error: ToolLoadError | null }> {
|
|
84
92
|
const resolvedPath = resolveToolPath(toolPath, cwd);
|
|
85
93
|
|
|
94
|
+
// Skip declarative tool files (.md, .json) - these are metadata only, not executable modules
|
|
95
|
+
if (resolvedPath.endsWith(".md") || resolvedPath.endsWith(".json")) {
|
|
96
|
+
return {
|
|
97
|
+
tools: null,
|
|
98
|
+
error: {
|
|
99
|
+
path: toolPath,
|
|
100
|
+
error: "Declarative tool files (.md, .json) cannot be loaded as executable modules",
|
|
101
|
+
source,
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
86
106
|
try {
|
|
87
107
|
const module = await import(resolvedPath);
|
|
88
108
|
const factory = (module.default ?? module) as CustomToolFactory;
|
|
89
109
|
|
|
90
110
|
if (typeof factory !== "function") {
|
|
91
|
-
return { tools: null, error: "Tool must export a default function" };
|
|
111
|
+
return { tools: null, error: { path: toolPath, error: "Tool must export a default function", source } };
|
|
92
112
|
}
|
|
93
113
|
|
|
94
114
|
const toolResult = await factory(sharedApi);
|
|
@@ -98,28 +118,35 @@ async function loadTool(
|
|
|
98
118
|
path: toolPath,
|
|
99
119
|
resolvedPath,
|
|
100
120
|
tool,
|
|
121
|
+
source,
|
|
101
122
|
}));
|
|
102
123
|
|
|
103
124
|
return { tools: loadedTools, error: null };
|
|
104
125
|
} catch (err) {
|
|
105
126
|
const message = err instanceof Error ? err.message : String(err);
|
|
106
|
-
return { tools: null, error: `Failed to load tool: ${message}
|
|
127
|
+
return { tools: null, error: { path: toolPath, error: `Failed to load tool: ${message}`, source } };
|
|
107
128
|
}
|
|
108
129
|
}
|
|
109
130
|
|
|
131
|
+
/** Tool path with optional source metadata */
|
|
132
|
+
interface ToolPathWithSource {
|
|
133
|
+
path: string;
|
|
134
|
+
source?: { provider: string; providerName: string; level: "user" | "project" };
|
|
135
|
+
}
|
|
136
|
+
|
|
110
137
|
/**
|
|
111
138
|
* Load all tools from configuration.
|
|
112
|
-
* @param
|
|
139
|
+
* @param pathsWithSources - Array of tool paths with optional source metadata
|
|
113
140
|
* @param cwd - Current working directory for resolving relative paths
|
|
114
141
|
* @param builtInToolNames - Names of built-in tools to check for conflicts
|
|
115
142
|
*/
|
|
116
143
|
export async function loadCustomTools(
|
|
117
|
-
|
|
144
|
+
pathsWithSources: ToolPathWithSource[],
|
|
118
145
|
cwd: string,
|
|
119
146
|
builtInToolNames: string[],
|
|
120
147
|
): Promise<CustomToolsLoadResult> {
|
|
121
148
|
const tools: LoadedCustomTool[] = [];
|
|
122
|
-
const errors:
|
|
149
|
+
const errors: ToolLoadError[] = [];
|
|
123
150
|
const seenNames = new Set<string>(builtInToolNames);
|
|
124
151
|
|
|
125
152
|
// Shared API object - all tools get the same instance
|
|
@@ -134,11 +161,11 @@ export async function loadCustomTools(
|
|
|
134
161
|
pi: piCodingAgent,
|
|
135
162
|
};
|
|
136
163
|
|
|
137
|
-
for (const toolPath of
|
|
138
|
-
const { tools: loadedTools, error } = await loadTool(toolPath, cwd, sharedApi);
|
|
164
|
+
for (const { path: toolPath, source } of pathsWithSources) {
|
|
165
|
+
const { tools: loadedTools, error } = await loadTool(toolPath, cwd, sharedApi, source);
|
|
139
166
|
|
|
140
167
|
if (error) {
|
|
141
|
-
errors.push(
|
|
168
|
+
errors.push(error);
|
|
142
169
|
continue;
|
|
143
170
|
}
|
|
144
171
|
|
|
@@ -149,6 +176,7 @@ export async function loadCustomTools(
|
|
|
149
176
|
errors.push({
|
|
150
177
|
path: toolPath,
|
|
151
178
|
error: `Tool name "${loadedTool.tool.name}" conflicts with existing tool`,
|
|
179
|
+
source,
|
|
152
180
|
});
|
|
153
181
|
continue;
|
|
154
182
|
}
|
|
@@ -170,81 +198,51 @@ export async function loadCustomTools(
|
|
|
170
198
|
}
|
|
171
199
|
|
|
172
200
|
/**
|
|
173
|
-
* Discover
|
|
174
|
-
*
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
if (!fs.existsSync(dir)) {
|
|
178
|
-
return [];
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
const tools: string[] = [];
|
|
182
|
-
|
|
183
|
-
try {
|
|
184
|
-
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
185
|
-
|
|
186
|
-
for (const entry of entries) {
|
|
187
|
-
if (entry.isDirectory() || entry.isSymbolicLink()) {
|
|
188
|
-
// Check for index.ts in subdirectory
|
|
189
|
-
const indexPath = path.join(dir, entry.name, "index.ts");
|
|
190
|
-
if (fs.existsSync(indexPath)) {
|
|
191
|
-
tools.push(indexPath);
|
|
192
|
-
}
|
|
193
|
-
}
|
|
194
|
-
}
|
|
195
|
-
} catch {
|
|
196
|
-
return [];
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
return tools;
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
/**
|
|
203
|
-
* Discover and load tools from standard locations:
|
|
204
|
-
* 1. agentDir/tools/*.ts (global)
|
|
205
|
-
* 2. cwd/.pi/tools/*.ts (project-local)
|
|
206
|
-
* 3. Installed plugins (~/.pi/plugins/node_modules/*)
|
|
207
|
-
*
|
|
208
|
-
* Plus any explicitly configured paths from settings or CLI.
|
|
201
|
+
* Discover and load tools from standard locations via capability system:
|
|
202
|
+
* 1. User and project tools discovered by capability providers
|
|
203
|
+
* 2. Installed plugins (~/.omp/plugins/node_modules/*)
|
|
204
|
+
* 3. Explicitly configured paths from settings or CLI
|
|
209
205
|
*
|
|
210
206
|
* @param configuredPaths - Explicit paths from settings.json and CLI --tool flags
|
|
211
207
|
* @param cwd - Current working directory
|
|
212
208
|
* @param builtInToolNames - Names of built-in tools to check for conflicts
|
|
213
|
-
* @param agentDir - Agent config directory. Default: from getAgentDir()
|
|
214
209
|
*/
|
|
215
210
|
export async function discoverAndLoadCustomTools(
|
|
216
211
|
configuredPaths: string[],
|
|
217
212
|
cwd: string,
|
|
218
213
|
builtInToolNames: string[],
|
|
219
|
-
agentDir: string = getAgentDir(),
|
|
220
214
|
): Promise<CustomToolsLoadResult> {
|
|
221
|
-
const
|
|
215
|
+
const allPathsWithSources: ToolPathWithSource[] = [];
|
|
222
216
|
const seen = new Set<string>();
|
|
223
217
|
|
|
224
218
|
// Helper to add paths without duplicates
|
|
225
|
-
const
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
allPaths.push(p);
|
|
231
|
-
}
|
|
219
|
+
const addPath = (p: string, source?: { provider: string; providerName: string; level: "user" | "project" }) => {
|
|
220
|
+
const resolved = path.resolve(p);
|
|
221
|
+
if (!seen.has(resolved)) {
|
|
222
|
+
seen.add(resolved);
|
|
223
|
+
allPathsWithSources.push({ path: p, source });
|
|
232
224
|
}
|
|
233
225
|
};
|
|
234
226
|
|
|
235
|
-
// 1.
|
|
236
|
-
const
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
227
|
+
// 1. Discover tools via capability system (user + project from all providers)
|
|
228
|
+
const discoveredTools = loadSync<CustomTool>(toolCapability.id, { cwd });
|
|
229
|
+
for (const tool of discoveredTools.items) {
|
|
230
|
+
addPath(tool.path, {
|
|
231
|
+
provider: tool._source.provider,
|
|
232
|
+
providerName: tool._source.providerName,
|
|
233
|
+
level: tool.level,
|
|
234
|
+
});
|
|
235
|
+
}
|
|
242
236
|
|
|
243
|
-
//
|
|
244
|
-
|
|
237
|
+
// 2. Plugin tools: ~/.omp/plugins/node_modules/*/
|
|
238
|
+
for (const pluginPath of getAllPluginToolPaths(cwd)) {
|
|
239
|
+
addPath(pluginPath, { provider: "plugin", providerName: "Plugin", level: "user" });
|
|
240
|
+
}
|
|
245
241
|
|
|
246
|
-
//
|
|
247
|
-
|
|
242
|
+
// 3. Explicitly configured paths (can override/add)
|
|
243
|
+
for (const configPath of configuredPaths) {
|
|
244
|
+
addPath(resolveToolPath(configPath, cwd), { provider: "config", providerName: "Config", level: "project" });
|
|
245
|
+
}
|
|
248
246
|
|
|
249
|
-
return loadCustomTools(
|
|
247
|
+
return loadCustomTools(allPathsWithSources, cwd, builtInToolNames);
|
|
250
248
|
}
|
|
@@ -163,12 +163,21 @@ export interface LoadedCustomTool {
|
|
|
163
163
|
resolvedPath: string;
|
|
164
164
|
/** The original custom tool instance */
|
|
165
165
|
tool: CustomTool;
|
|
166
|
+
/** Source metadata (provider and level) */
|
|
167
|
+
source?: { provider: string; providerName: string; level: "user" | "project" };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Error with source metadata */
|
|
171
|
+
export interface ToolLoadError {
|
|
172
|
+
path: string;
|
|
173
|
+
error: string;
|
|
174
|
+
source?: { provider: string; providerName: string; level: "user" | "project" };
|
|
166
175
|
}
|
|
167
176
|
|
|
168
177
|
/** Result from loading custom tools */
|
|
169
178
|
export interface CustomToolsLoadResult {
|
|
170
179
|
tools: LoadedCustomTool[];
|
|
171
|
-
errors:
|
|
180
|
+
errors: ToolLoadError[];
|
|
172
181
|
/** Update the UI context for all loaded tools. Call when mode initializes. */
|
|
173
182
|
setUIContext(uiContext: CustomToolUIContext, hasUI: boolean): void;
|
|
174
183
|
}
|
|
@@ -5,8 +5,8 @@ import { APP_NAME } from "../../config";
|
|
|
5
5
|
import { getResolvedThemeColors, getThemeExportColors } from "../../modes/interactive/theme/theme";
|
|
6
6
|
import { SessionManager } from "../session-manager";
|
|
7
7
|
|
|
8
|
-
//
|
|
9
|
-
import {
|
|
8
|
+
// Pre-generated template (created by scripts/generate-template.ts at publish time)
|
|
9
|
+
import { TEMPLATE } from "./template.generated";
|
|
10
10
|
|
|
11
11
|
export interface ExportOptions {
|
|
12
12
|
outputPath?: string;
|
|
@@ -101,14 +101,14 @@ interface SessionData {
|
|
|
101
101
|
}
|
|
102
102
|
|
|
103
103
|
/** Generate HTML from bundled template with runtime substitutions. */
|
|
104
|
-
|
|
104
|
+
function generateHtml(sessionData: SessionData, themeName?: string): string {
|
|
105
105
|
const themeVars = generateThemeVars(themeName);
|
|
106
106
|
const sessionDataBase64 = Buffer.from(JSON.stringify(sessionData)).toString("base64");
|
|
107
|
-
const template = await getTemplate();
|
|
108
107
|
|
|
109
|
-
return
|
|
110
|
-
|
|
111
|
-
|
|
108
|
+
return TEMPLATE.replace("<theme-vars/>", `<style>:root { ${themeVars} }</style>`).replace(
|
|
109
|
+
"{{SESSION_DATA}}",
|
|
110
|
+
sessionDataBase64,
|
|
111
|
+
);
|
|
112
112
|
}
|
|
113
113
|
|
|
114
114
|
/** Export session to HTML using SessionManager and AgentState. */
|
|
@@ -131,7 +131,7 @@ export async function exportSessionToHtml(
|
|
|
131
131
|
tools: state?.tools?.map((t) => ({ name: t.name, description: t.description })),
|
|
132
132
|
};
|
|
133
133
|
|
|
134
|
-
const html =
|
|
134
|
+
const html = generateHtml(sessionData, opts.themeName);
|
|
135
135
|
const outputPath = opts.outputPath || `${APP_NAME}-session-${basename(sessionFile, ".jsonl")}.html`;
|
|
136
136
|
|
|
137
137
|
writeFileSync(outputPath, html, "utf8");
|
|
@@ -151,7 +151,7 @@ export async function exportFromFile(inputPath: string, options?: ExportOptions
|
|
|
151
151
|
leafId: sm.getLeafId(),
|
|
152
152
|
};
|
|
153
153
|
|
|
154
|
-
const html =
|
|
154
|
+
const html = generateHtml(sessionData, opts.themeName);
|
|
155
155
|
const outputPath = opts.outputPath || `${APP_NAME}-session-${basename(inputPath, ".jsonl")}.html`;
|
|
156
156
|
|
|
157
157
|
writeFileSync(outputPath, html, "utf8");
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
// Auto-generated by scripts/generate-template.ts - DO NOT EDIT
|
|
2
|
+
export const TEMPLATE = "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n <meta charset=\"UTF-8\">\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n <title>Session Export</title>\n <style>*{margin:0;padding:0;box-sizing:border-box;}:root{--line-height:18px;}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{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{}.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;}#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{font-size:11px;color:var(--warning);margin-bottom:var(--line-height);}.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{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;}.assistant-message{padding:0;}.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{padding:var(--line-height);border-radius:4px;}.tool-execution + .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;}.tool-images{}.tool-image{max-width:100%;max-height:500px;border-radius:4px;margin:var(--line-height) 0;}.expand-hint{color:var(--toolOutput);}.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{padding:0 var(--line-height);color:var(--dim);font-size:11px;}.model-name{color:var(--borderAccent);font-weight:bold;}.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{background:var(--customMessageBg);padding:var(--line-height);border-radius:4px;margin-bottom:var(--line-height);}.system-prompt-header{font-weight:bold;color:var(--customMessageLabel);}.system-prompt-content{color:var(--customMessageText);white-space:pre-wrap;word-wrap:break-word;font-size:11px;max-height:200px;overflow-y:auto;margin-top:var(--line-height);}.tools-list{background:var(--customMessageBg);padding:var(--line-height);border-radius:4px;margin-bottom:var(--line-height);}.tools-header{font-weight:bold;color:var(--warning);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);}.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{background:var(--customMessageBg);padding:var(--line-height);border-radius:4px;}.branch-summary-header{font-weight:bold;color:var(--borderAccent);}.error-text{color:var(--error);padding:0 var(--line-height);}.message-images{margin-bottom:12px;}.message-image{max-width:100%;max-height:400px;border-radius:4px;margin:var(--line-height) 0;}.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;}.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);}.hljs-function,.hljs-title,.hljs-title.function_,.hljs-section,.hljs-name{color:var(--syntaxFunction);}.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{margin-top:48px;padding:20px;text-align:center;color:var(--dim);font-size:10px;}#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;}}</style>\n <theme-vars/>\n</head>\n<body>\n <button id=\"hamburger\" title=\"Open sidebar\"><svg width=\"14\" height=\"14\" viewBox=\"0 0 24 24\" fill=\"currentColor\" stroke=\"none\"><circle cx=\"6\" cy=\"6\" r=\"2.5\"/><circle cx=\"6\" cy=\"18\" r=\"2.5\"/><circle cx=\"18\" cy=\"12\" r=\"2.5\"/><rect x=\"5\" y=\"6\" width=\"2\" height=\"12\"/><path d=\"M6 12h10c1 0 2 0 2-2V8\"/></svg></button>\n <div id=\"sidebar-overlay\"></div>\n <div id=\"app\">\n <aside id=\"sidebar\">\n <div class=\"sidebar-header\">\n <div class=\"sidebar-controls\">\n <input type=\"text\" class=\"sidebar-search\" id=\"tree-search\" placeholder=\"Search...\">\n </div>\n <div class=\"sidebar-filters\">\n <button class=\"filter-btn active\" data-filter=\"default\" title=\"Hide settings entries\">Default</button>\n <button class=\"filter-btn\" data-filter=\"no-tools\" title=\"Default minus tool results\">No-tools</button>\n <button class=\"filter-btn\" data-filter=\"user-only\" title=\"Only user messages\">User</button>\n <button class=\"filter-btn\" data-filter=\"labeled-only\" title=\"Only labeled entries\">Labeled</button>\n <button class=\"filter-btn\" data-filter=\"all\" title=\"Show everything\">All</button>\n <button class=\"sidebar-close\" id=\"sidebar-close\" title=\"Close\">✕</button>\n </div>\n </div>\n <div class=\"tree-container\" id=\"tree-container\"></div>\n <div class=\"tree-status\" id=\"tree-status\"></div>\n </aside>\n <main id=\"content\">\n <div id=\"header-container\"></div>\n <div id=\"messages\"></div>\n </main>\n <div id=\"image-modal\" class=\"image-modal\">\n <img id=\"modal-image\" src=\"\" alt=\"\">\n </div>\n </div>\n\n <script id=\"session-data\" type=\"application/json\">{{SESSION_DATA}}</script>\n <script src=\"https://cdnjs.cloudflare.com/ajax/libs/marked/15.0.4/marked.min.js\" integrity=\"sha512-VmLxPVdDGeR+F0DzUHVqzHwaR4ZSSh1g/7aYXwKT1PAGVxunOEcysta+4H5Utvmpr2xExEPybZ8q+iM9F1tGdw==\" crossorigin=\"anonymous\" referrerpolicy=\"no-referrer\"></script>\n <script src=\"https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js\" integrity=\"sha512-D9gUyxqja7hBtkWpPWGt9wfbfaMGVt9gnyCvYa+jojwwPHLCzUm5i8rpk7vD7wNee9bA35eYIjobYPaQuKS1MQ==\" crossorigin=\"anonymous\" referrerpolicy=\"no-referrer\"></script>\n <script> (function() {\n 'use strict';\n\n // ============================================================\n // DATA LOADING\n // ============================================================\n\n const base64 = document.getElementById('session-data').textContent;\n const binary = atob(base64);\n const bytes = new Uint8Array(binary.length);\n for (let i = 0; i < binary.length; i++) {\n bytes[i] = binary.charCodeAt(i);\n }\n const data = JSON.parse(new TextDecoder('utf-8').decode(bytes));\n const { header, entries, leafId, systemPrompt, tools } = data;\n\n // ============================================================\n // DATA STRUCTURES\n // ============================================================\n\n // Entry lookup by ID\n const byId = new Map();\n for (const entry of entries) {\n byId.set(entry.id, entry);\n }\n\n // Tool call lookup (toolCallId -> {name, arguments})\n const toolCallMap = new Map();\n for (const entry of entries) {\n if (entry.type === 'message' && entry.message.role === 'assistant') {\n const content = entry.message.content;\n if (Array.isArray(content)) {\n for (const block of content) {\n if (block.type === 'toolCall') {\n toolCallMap.set(block.id, { name: block.name, arguments: block.arguments });\n }\n }\n }\n }\n }\n\n // Label lookup (entryId -> label string)\n // Labels are stored in 'label' entries that reference their target via parentId\n const labelMap = new Map();\n for (const entry of entries) {\n if (entry.type === 'label' && entry.parentId && entry.label) {\n labelMap.set(entry.parentId, entry.label);\n }\n }\n\n // ============================================================\n // TREE DATA PREPARATION (no DOM, pure data)\n // ============================================================\n\n /**\n * Build tree structure from flat entries.\n * Returns array of root nodes, each with { entry, children, label }.\n */\n function buildTree() {\n const nodeMap = new Map();\n const roots = [];\n\n // Create nodes\n for (const entry of entries) {\n nodeMap.set(entry.id, { \n entry, \n children: [],\n label: labelMap.get(entry.id)\n });\n }\n\n // Build parent-child relationships\n for (const entry of entries) {\n const node = nodeMap.get(entry.id);\n if (entry.parentId === null || entry.parentId === undefined || entry.parentId === entry.id) {\n roots.push(node);\n } else {\n const parent = nodeMap.get(entry.parentId);\n if (parent) {\n parent.children.push(node);\n } else {\n roots.push(node);\n }\n }\n }\n\n // Sort children by timestamp\n function sortChildren(node) {\n node.children.sort((a, b) =>\n new Date(a.entry.timestamp).getTime() - new Date(b.entry.timestamp).getTime()\n );\n node.children.forEach(sortChildren);\n }\n roots.forEach(sortChildren);\n\n return roots;\n }\n\n /**\n * Build set of entry IDs on path from root to target.\n */\n function buildActivePathIds(targetId) {\n const ids = new Set();\n let current = byId.get(targetId);\n while (current) {\n ids.add(current.id);\n // Stop if no parent or self-referencing (root)\n if (!current.parentId || current.parentId === current.id) {\n break;\n }\n current = byId.get(current.parentId);\n }\n return ids;\n }\n\n /**\n * Get array of entries from root to target (the conversation path).\n */\n function getPath(targetId) {\n const path = [];\n let current = byId.get(targetId);\n while (current) {\n path.unshift(current);\n // Stop if no parent or self-referencing (root)\n if (!current.parentId || current.parentId === current.id) {\n break;\n }\n current = byId.get(current.parentId);\n }\n return path;\n }\n\n /**\n * Flatten tree into list with indentation and connector info.\n * Returns array of { node, indent, showConnector, isLast, gutters, isVirtualRootChild, multipleRoots }.\n * Matches tree-selector.ts logic exactly.\n */\n function flattenTree(roots, activePathIds) {\n const result = [];\n const multipleRoots = roots.length > 1;\n\n // Mark which subtrees contain the active leaf\n const containsActive = new Map();\n function markActive(node) {\n let has = activePathIds.has(node.entry.id);\n for (const child of node.children) {\n if (markActive(child)) has = true;\n }\n containsActive.set(node, has);\n return has;\n }\n roots.forEach(markActive);\n\n // Stack: [node, indent, justBranched, showConnector, isLast, gutters, isVirtualRootChild]\n const stack = [];\n\n // Add roots (prioritize branch containing active leaf)\n const orderedRoots = [...roots].sort((a, b) => \n Number(containsActive.get(b)) - Number(containsActive.get(a))\n );\n for (let i = orderedRoots.length - 1; i >= 0; i--) {\n const isLast = i === orderedRoots.length - 1;\n stack.push([orderedRoots[i], multipleRoots ? 1 : 0, multipleRoots, multipleRoots, isLast, [], multipleRoots]);\n }\n\n while (stack.length > 0) {\n const [node, indent, justBranched, showConnector, isLast, gutters, isVirtualRootChild] = stack.pop();\n\n result.push({ node, indent, showConnector, isLast, gutters, isVirtualRootChild, multipleRoots });\n\n const children = node.children;\n const multipleChildren = children.length > 1;\n\n // Order children (active branch first)\n const orderedChildren = [...children].sort((a, b) => \n Number(containsActive.get(b)) - Number(containsActive.get(a))\n );\n\n // Calculate child indent (matches tree-selector.ts)\n let childIndent;\n if (multipleChildren) {\n // Parent branches: children get +1\n childIndent = indent + 1;\n } else if (justBranched && indent > 0) {\n // First generation after a branch: +1 for visual grouping\n childIndent = indent + 1;\n } else {\n // Single-child chain: stay flat\n childIndent = indent;\n }\n\n // Build gutters for children\n const connectorDisplayed = showConnector && !isVirtualRootChild;\n const currentDisplayIndent = multipleRoots ? Math.max(0, indent - 1) : indent;\n const connectorPosition = Math.max(0, currentDisplayIndent - 1);\n const childGutters = connectorDisplayed\n ? [...gutters, { position: connectorPosition, show: !isLast }]\n : gutters;\n\n // Add children in reverse order for stack\n for (let i = orderedChildren.length - 1; i >= 0; i--) {\n const childIsLast = i === orderedChildren.length - 1;\n stack.push([orderedChildren[i], childIndent, multipleChildren, multipleChildren, childIsLast, childGutters, false]);\n }\n }\n\n return result;\n }\n\n /**\n * Build ASCII prefix string for tree node.\n */\n function buildTreePrefix(flatNode) {\n const { indent, showConnector, isLast, gutters, isVirtualRootChild, multipleRoots } = flatNode;\n const displayIndent = multipleRoots ? Math.max(0, indent - 1) : indent;\n const connector = showConnector && !isVirtualRootChild ? (isLast ? '└─ ' : '├─ ') : '';\n const connectorPosition = connector ? displayIndent - 1 : -1;\n\n const totalChars = displayIndent * 3;\n const prefixChars = [];\n for (let i = 0; i < totalChars; i++) {\n const level = Math.floor(i / 3);\n const posInLevel = i % 3;\n\n const gutter = gutters.find(g => g.position === level);\n if (gutter) {\n prefixChars.push(posInLevel === 0 ? (gutter.show ? '│' : ' ') : ' ');\n } else if (connector && level === connectorPosition) {\n if (posInLevel === 0) {\n prefixChars.push(isLast ? '└' : '├');\n } else if (posInLevel === 1) {\n prefixChars.push('─');\n } else {\n prefixChars.push(' ');\n }\n } else {\n prefixChars.push(' ');\n }\n }\n return prefixChars.join('');\n }\n\n // ============================================================\n // FILTERING (pure data)\n // ============================================================\n\n let filterMode = 'default';\n let searchQuery = '';\n\n function hasTextContent(content) {\n if (typeof content === 'string') return content.trim().length > 0;\n if (Array.isArray(content)) {\n for (const c of content) {\n if (c.type === 'text' && c.text && c.text.trim().length > 0) return true;\n }\n }\n return false;\n }\n\n function extractContent(content) {\n if (typeof content === 'string') return content;\n if (Array.isArray(content)) {\n return content\n .filter(c => c.type === 'text' && c.text)\n .map(c => c.text)\n .join('');\n }\n return '';\n }\n\n function getSearchableText(entry, label) {\n const parts = [];\n if (label) parts.push(label);\n\n switch (entry.type) {\n case 'message': {\n const msg = entry.message;\n parts.push(msg.role);\n if (msg.content) parts.push(extractContent(msg.content));\n if (msg.role === 'bashExecution' && msg.command) parts.push(msg.command);\n break;\n }\n case 'custom_message':\n parts.push(entry.customType);\n parts.push(typeof entry.content === 'string' ? entry.content : extractContent(entry.content));\n break;\n case 'compaction':\n parts.push('compaction');\n break;\n case 'branch_summary':\n parts.push('branch summary', entry.summary);\n break;\n case 'model_change':\n parts.push('model', entry.modelId);\n break;\n case 'thinking_level_change':\n parts.push('thinking', entry.thinkingLevel);\n break;\n }\n\n return parts.join(' ').toLowerCase();\n }\n\n /**\n * Filter flat nodes based on current filterMode and searchQuery.\n */\n function filterNodes(flatNodes, currentLeafId) {\n const searchTokens = searchQuery.toLowerCase().split(/\\s+/).filter(Boolean);\n\n return flatNodes.filter(flatNode => {\n const entry = flatNode.node.entry;\n const label = flatNode.node.label;\n const isCurrentLeaf = entry.id === currentLeafId;\n\n // Always show current leaf\n if (isCurrentLeaf) return true;\n\n // Hide assistant messages with only tool calls (no text) unless error/aborted\n if (entry.type === 'message' && entry.message.role === 'assistant') {\n const msg = entry.message;\n const hasText = hasTextContent(msg.content);\n const isErrorOrAborted = msg.stopReason && msg.stopReason !== 'stop' && msg.stopReason !== 'toolUse';\n if (!hasText && !isErrorOrAborted) return false;\n }\n\n // Apply filter mode\n const isSettingsEntry = ['label', 'custom', 'model_change', 'thinking_level_change'].includes(entry.type);\n let passesFilter = true;\n\n switch (filterMode) {\n case 'user-only':\n passesFilter = entry.type === 'message' && entry.message.role === 'user';\n break;\n case 'no-tools':\n passesFilter = !isSettingsEntry && !(entry.type === 'message' && entry.message.role === 'toolResult');\n break;\n case 'labeled-only':\n passesFilter = label !== undefined;\n break;\n case 'all':\n passesFilter = true;\n break;\n default: // 'default'\n passesFilter = !isSettingsEntry;\n break;\n }\n\n if (!passesFilter) return false;\n\n // Apply search filter\n if (searchTokens.length > 0) {\n const nodeText = getSearchableText(entry, label);\n if (!searchTokens.every(t => nodeText.includes(t))) return false;\n }\n\n return true;\n });\n }\n\n // ============================================================\n // TREE DISPLAY TEXT (pure data -> string)\n // ============================================================\n\n function shortenPath(p) {\n if (p.startsWith('/Users/')) {\n const parts = p.split('/');\n if (parts.length > 2) return '~' + p.slice(('/Users/' + parts[2]).length);\n }\n if (p.startsWith('/home/')) {\n const parts = p.split('/');\n if (parts.length > 2) return '~' + p.slice(('/home/' + parts[2]).length);\n }\n return p;\n }\n\n function formatToolCall(name, args) {\n switch (name) {\n case 'read': {\n const path = shortenPath(String(args.path || args.file_path || ''));\n const offset = args.offset;\n const limit = args.limit;\n let display = path;\n if (offset !== undefined || limit !== undefined) {\n const start = offset ?? 1;\n const end = limit !== undefined ? start + limit - 1 : '';\n display += `:${start}${end ? `-${end}` : ''}`;\n }\n return `[read: ${display}]`;\n }\n case 'write':\n return `[write: ${shortenPath(String(args.path || args.file_path || ''))}]`;\n case 'edit':\n return `[edit: ${shortenPath(String(args.path || args.file_path || ''))}]`;\n case 'bash': {\n const rawCmd = String(args.command || '');\n const cmd = rawCmd.replace(/[\\n\\t]/g, ' ').trim().slice(0, 50);\n return `[bash: ${cmd}${rawCmd.length > 50 ? '...' : ''}]`;\n }\n case 'grep':\n return `[grep: /${args.pattern || ''}/ in ${shortenPath(String(args.path || '.'))}]`;\n case 'find':\n return `[find: ${args.pattern || ''} in ${shortenPath(String(args.path || '.'))}]`;\n case 'ls':\n return `[ls: ${shortenPath(String(args.path || '.'))}]`;\n default: {\n const argsStr = JSON.stringify(args).slice(0, 40);\n return `[${name}: ${argsStr}${JSON.stringify(args).length > 40 ? '...' : ''}]`;\n }\n }\n }\n\n function escapeHtml(text) {\n const div = document.createElement('div');\n div.textContent = text;\n return div.innerHTML;\n }\n\n /**\n * Truncate string to maxLen chars, append \"...\" if truncated.\n */\n function truncate(s, maxLen = 100) {\n if (s.length <= maxLen) return s;\n return s.slice(0, maxLen) + '...';\n }\n\n /**\n * Get display text for tree node (returns HTML string).\n */\n function getTreeNodeDisplayHtml(entry, label) {\n const normalize = s => s.replace(/[\\n\\t]/g, ' ').trim();\n const labelHtml = label ? `<span class=\"tree-label\">[${escapeHtml(label)}]</span> ` : '';\n\n switch (entry.type) {\n case 'message': {\n const msg = entry.message;\n if (msg.role === 'user') {\n const content = truncate(normalize(extractContent(msg.content)));\n return labelHtml + `<span class=\"tree-role-user\">user:</span> ${escapeHtml(content)}`;\n }\n if (msg.role === 'assistant') {\n const textContent = truncate(normalize(extractContent(msg.content)));\n if (textContent) {\n return labelHtml + `<span class=\"tree-role-assistant\">assistant:</span> ${escapeHtml(textContent)}`;\n }\n if (msg.stopReason === 'aborted') {\n return labelHtml + `<span class=\"tree-role-assistant\">assistant:</span> <span class=\"tree-muted\">(aborted)</span>`;\n }\n if (msg.errorMessage) {\n return labelHtml + `<span class=\"tree-role-assistant\">assistant:</span> <span class=\"tree-error\">${escapeHtml(truncate(msg.errorMessage))}</span>`;\n }\n return labelHtml + `<span class=\"tree-role-assistant\">assistant:</span> <span class=\"tree-muted\">(no text)</span>`;\n }\n if (msg.role === 'toolResult') {\n const toolCall = msg.toolCallId ? toolCallMap.get(msg.toolCallId) : null;\n if (toolCall) {\n return labelHtml + `<span class=\"tree-role-tool\">${escapeHtml(formatToolCall(toolCall.name, toolCall.arguments))}</span>`;\n }\n return labelHtml + `<span class=\"tree-role-tool\">[${msg.toolName || 'tool'}]</span>`;\n }\n if (msg.role === 'bashExecution') {\n const cmd = truncate(normalize(msg.command || ''));\n return labelHtml + `<span class=\"tree-role-tool\">[bash]:</span> ${escapeHtml(cmd)}`;\n }\n return labelHtml + `<span class=\"tree-muted\">[${msg.role}]</span>`;\n }\n case 'compaction':\n return labelHtml + `<span class=\"tree-compaction\">[compaction: ${Math.round(entry.tokensBefore/1000)}k tokens]</span>`;\n case 'branch_summary': {\n const summary = truncate(normalize(entry.summary || ''));\n return labelHtml + `<span class=\"tree-branch-summary\">[branch summary]:</span> ${escapeHtml(summary)}`;\n }\n case 'custom_message': {\n const content = typeof entry.content === 'string' ? entry.content : extractContent(entry.content);\n return labelHtml + `<span class=\"tree-custom\">[${escapeHtml(entry.customType)}]:</span> ${escapeHtml(truncate(normalize(content)))}`;\n }\n case 'model_change':\n return labelHtml + `<span class=\"tree-muted\">[model: ${entry.modelId}]</span>`;\n case 'thinking_level_change':\n return labelHtml + `<span class=\"tree-muted\">[thinking: ${entry.thinkingLevel}]</span>`;\n default:\n return labelHtml + `<span class=\"tree-muted\">[${entry.type}]</span>`;\n }\n }\n\n // ============================================================\n // TREE RENDERING (DOM manipulation)\n // ============================================================\n\n let currentLeafId = leafId;\n let treeRendered = false;\n\n function renderTree() {\n const tree = buildTree();\n const activePathIds = buildActivePathIds(currentLeafId);\n const flatNodes = flattenTree(tree, activePathIds);\n const filtered = filterNodes(flatNodes, currentLeafId);\n const container = document.getElementById('tree-container');\n\n // Full render only on first call or when filter/search changes\n if (!treeRendered) {\n container.innerHTML = '';\n\n for (const flatNode of filtered) {\n const entry = flatNode.node.entry;\n const isOnPath = activePathIds.has(entry.id);\n const isLeaf = entry.id === currentLeafId;\n\n const div = document.createElement('div');\n div.className = 'tree-node';\n if (isOnPath) div.classList.add('in-path');\n if (isLeaf) div.classList.add('active');\n div.dataset.id = entry.id;\n\n const prefix = buildTreePrefix(flatNode);\n const prefixSpan = document.createElement('span');\n prefixSpan.className = 'tree-prefix';\n prefixSpan.textContent = prefix;\n\n const marker = document.createElement('span');\n marker.className = 'tree-marker';\n marker.textContent = isOnPath ? '•' : ' ';\n\n const content = document.createElement('span');\n content.className = 'tree-content';\n content.innerHTML = getTreeNodeDisplayHtml(entry, flatNode.node.label);\n\n div.appendChild(prefixSpan);\n div.appendChild(marker);\n div.appendChild(content);\n div.addEventListener('click', () => navigateTo(entry.id));\n\n container.appendChild(div);\n }\n\n treeRendered = true;\n } else {\n // Just update markers and classes\n const nodes = container.querySelectorAll('.tree-node');\n for (const node of nodes) {\n const id = node.dataset.id;\n const isOnPath = activePathIds.has(id);\n const isLeaf = id === currentLeafId;\n\n node.classList.toggle('in-path', isOnPath);\n node.classList.toggle('active', isLeaf);\n\n const marker = node.querySelector('.tree-marker');\n if (marker) {\n marker.textContent = isOnPath ? '•' : ' ';\n }\n }\n }\n\n document.getElementById('tree-status').textContent = `${filtered.length} / ${flatNodes.length} entries`;\n\n // Scroll active node into view after layout\n setTimeout(() => {\n const activeNode = container.querySelector('.tree-node.active');\n if (activeNode) {\n activeNode.scrollIntoView({ block: 'nearest' });\n }\n }, 0);\n }\n\n function forceTreeRerender() {\n treeRendered = false;\n renderTree();\n }\n\n // ============================================================\n // MESSAGE RENDERING\n // ============================================================\n\n function formatTokens(count) {\n if (count < 1000) return count.toString();\n if (count < 10000) return (count / 1000).toFixed(1) + 'k';\n if (count < 1000000) return Math.round(count / 1000) + 'k';\n return (count / 1000000).toFixed(1) + 'M';\n }\n\n function formatTimestamp(ts) {\n if (!ts) return '';\n const date = new Date(ts);\n return date.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', second: '2-digit' });\n }\n\n function replaceTabs(text) {\n return text.replace(/\\t/g, ' ');\n }\n\n function getLanguageFromPath(filePath) {\n const ext = filePath.split('.').pop()?.toLowerCase();\n const extToLang = {\n ts: 'typescript', tsx: 'typescript', js: 'javascript', jsx: 'javascript',\n py: 'python', rb: 'ruby', rs: 'rust', go: 'go', java: 'java',\n c: 'c', cpp: 'cpp', h: 'c', hpp: 'cpp', cs: 'csharp',\n php: 'php', sh: 'bash', bash: 'bash', zsh: 'bash',\n sql: 'sql', html: 'html', css: 'css', scss: 'scss',\n json: 'json', yaml: 'yaml', yml: 'yaml', xml: 'xml',\n md: 'markdown', dockerfile: 'dockerfile'\n };\n return extToLang[ext];\n }\n\n function findToolResult(toolCallId) {\n for (const entry of entries) {\n if (entry.type === 'message' && entry.message.role === 'toolResult') {\n if (entry.message.toolCallId === toolCallId) {\n return entry.message;\n }\n }\n }\n return null;\n }\n\n function formatExpandableOutput(text, maxLines, lang) {\n text = replaceTabs(text);\n const lines = text.split('\\n');\n const displayLines = lines.slice(0, maxLines);\n const remaining = lines.length - maxLines;\n\n if (lang) {\n let highlighted;\n try {\n highlighted = hljs.highlight(text, { language: lang }).value;\n } catch {\n highlighted = escapeHtml(text);\n }\n\n if (remaining > 0) {\n const previewCode = displayLines.join('\\n');\n let previewHighlighted;\n try {\n previewHighlighted = hljs.highlight(previewCode, { language: lang }).value;\n } catch {\n previewHighlighted = escapeHtml(previewCode);\n }\n\n return `<div class=\"tool-output expandable\" onclick=\"this.classList.toggle('expanded')\">\n <div class=\"output-preview\"><pre><code class=\"hljs\">${previewHighlighted}</code></pre>\n <div class=\"expand-hint\">... (${remaining} more lines)</div></div>\n <div class=\"output-full\"><pre><code class=\"hljs\">${highlighted}</code></pre></div></div>`;\n }\n\n return `<div class=\"tool-output\"><pre><code class=\"hljs\">${highlighted}</code></pre></div>`;\n }\n\n // Plain text output\n if (remaining > 0) {\n let out = '<div class=\"tool-output expandable\" onclick=\"this.classList.toggle(\\'expanded\\')\">';\n out += '<div class=\"output-preview\">';\n for (const line of displayLines) {\n out += `<div>${escapeHtml(replaceTabs(line))}</div>`;\n }\n out += `<div class=\"expand-hint\">... (${remaining} more lines)</div></div>`;\n out += '<div class=\"output-full\">';\n for (const line of lines) {\n out += `<div>${escapeHtml(replaceTabs(line))}</div>`;\n }\n out += '</div></div>';\n return out;\n }\n\n let out = '<div class=\"tool-output\">';\n for (const line of displayLines) {\n out += `<div>${escapeHtml(replaceTabs(line))}</div>`;\n }\n out += '</div>';\n return out;\n }\n\n function renderToolCall(call) {\n const result = findToolResult(call.id);\n const isError = result?.isError || false;\n const statusClass = result ? (isError ? 'error' : 'success') : 'pending';\n\n const getResultText = () => {\n if (!result) return '';\n const textBlocks = result.content.filter(c => c.type === 'text');\n return textBlocks.map(c => c.text).join('\\n');\n };\n\n const getResultImages = () => {\n if (!result) return [];\n return result.content.filter(c => c.type === 'image');\n };\n\n const renderResultImages = () => {\n const images = getResultImages();\n if (images.length === 0) return '';\n return '<div class=\"tool-images\">' + \n images.map(img => `<img src=\"data:${img.mimeType};base64,${img.data}\" class=\"tool-image\" />`).join('') + \n '</div>';\n };\n\n let html = `<div class=\"tool-execution ${statusClass}\">`;\n const args = call.arguments || {};\n const name = call.name;\n\n switch (name) {\n case 'bash': {\n const command = args.command || '';\n html += `<div class=\"tool-command\">$ ${escapeHtml(command)}</div>`;\n if (result) {\n const output = getResultText().trim();\n if (output) html += formatExpandableOutput(output, 5);\n }\n break;\n }\n case 'read': {\n const filePath = args.file_path || args.path || '';\n const offset = args.offset;\n const limit = args.limit;\n const lang = getLanguageFromPath(filePath);\n\n let pathHtml = escapeHtml(shortenPath(filePath));\n if (offset !== undefined || limit !== undefined) {\n const startLine = offset ?? 1;\n const endLine = limit !== undefined ? startLine + limit - 1 : '';\n pathHtml += `<span class=\"line-numbers\">:${startLine}${endLine ? '-' + endLine : ''}</span>`;\n }\n\n html += `<div class=\"tool-header\"><span class=\"tool-name\">read</span> <span class=\"tool-path\">${pathHtml}</span></div>`;\n if (result) {\n html += renderResultImages();\n const output = getResultText();\n if (output) html += formatExpandableOutput(output, 10, lang);\n }\n break;\n }\n case 'write': {\n const filePath = args.file_path || args.path || '';\n const content = args.content || '';\n const lines = content.split('\\n');\n const lang = getLanguageFromPath(filePath);\n\n html += `<div class=\"tool-header\"><span class=\"tool-name\">write</span> <span class=\"tool-path\">${escapeHtml(shortenPath(filePath))}</span>`;\n if (lines.length > 10) html += ` <span class=\"line-count\">(${lines.length} lines)</span>`;\n html += '</div>';\n\n if (content) html += formatExpandableOutput(content, 10, lang);\n if (result) {\n const output = getResultText().trim();\n if (output) html += `<div class=\"tool-output\"><div>${escapeHtml(output)}</div></div>`;\n }\n break;\n }\n case 'edit': {\n const filePath = args.file_path || args.path || '';\n html += `<div class=\"tool-header\"><span class=\"tool-name\">edit</span> <span class=\"tool-path\">${escapeHtml(shortenPath(filePath))}</span></div>`;\n\n if (result?.details?.diff) {\n const diffLines = result.details.diff.split('\\n');\n html += '<div class=\"tool-diff\">';\n for (const line of diffLines) {\n const cls = line.match(/^\\+/) ? 'diff-added' : line.match(/^-/) ? 'diff-removed' : 'diff-context';\n html += `<div class=\"${cls}\">${escapeHtml(replaceTabs(line))}</div>`;\n }\n html += '</div>';\n } else if (result) {\n const output = getResultText().trim();\n if (output) html += `<div class=\"tool-output\"><pre>${escapeHtml(output)}</pre></div>`;\n }\n break;\n }\n default: {\n html += `<div class=\"tool-header\"><span class=\"tool-name\">${escapeHtml(name)}</span></div>`;\n html += `<div class=\"tool-output\"><pre>${escapeHtml(JSON.stringify(args, null, 2))}</pre></div>`;\n if (result) {\n const output = getResultText();\n if (output) html += formatExpandableOutput(output, 10);\n }\n }\n }\n\n html += '</div>';\n return html;\n }\n\n function renderEntry(entry) {\n const ts = formatTimestamp(entry.timestamp);\n const tsHtml = ts ? `<div class=\"message-timestamp\">${ts}</div>` : '';\n const entryId = `entry-${entry.id}`;\n\n if (entry.type === 'message') {\n const msg = entry.message;\n\n if (msg.role === 'user') {\n let html = `<div class=\"user-message\" id=\"${entryId}\">${tsHtml}`;\n const content = msg.content;\n\n if (Array.isArray(content)) {\n const images = content.filter(c => c.type === 'image');\n if (images.length > 0) {\n html += '<div class=\"message-images\">';\n for (const img of images) {\n html += `<img src=\"data:${img.mimeType};base64,${img.data}\" class=\"message-image\" />`;\n }\n html += '</div>';\n }\n }\n\n const text = typeof content === 'string' ? content : \n content.filter(c => c.type === 'text').map(c => c.text).join('\\n');\n if (text.trim()) {\n html += `<div class=\"markdown-content\">${safeMarkedParse(text)}</div>`;\n }\n html += '</div>';\n return html;\n }\n\n if (msg.role === 'assistant') {\n let html = `<div class=\"assistant-message\" id=\"${entryId}\">${tsHtml}`;\n\n for (const block of msg.content) {\n if (block.type === 'text' && block.text.trim()) {\n html += `<div class=\"assistant-text markdown-content\">${safeMarkedParse(block.text)}</div>`;\n } else if (block.type === 'thinking' && block.thinking.trim()) {\n html += `<div class=\"thinking-block\">\n <div class=\"thinking-text\">${escapeHtml(block.thinking)}</div>\n <div class=\"thinking-collapsed\">Thinking ...</div>\n </div>`;\n }\n }\n\n for (const block of msg.content) {\n if (block.type === 'toolCall') {\n html += renderToolCall(block);\n }\n }\n\n if (msg.stopReason === 'aborted') {\n html += '<div class=\"error-text\">Aborted</div>';\n } else if (msg.stopReason === 'error') {\n html += `<div class=\"error-text\">Error: ${escapeHtml(msg.errorMessage || 'Unknown error')}</div>`;\n }\n\n html += '</div>';\n return html;\n }\n\n if (msg.role === 'bashExecution') {\n const isError = msg.cancelled || (msg.exitCode !== 0 && msg.exitCode !== null);\n let html = `<div class=\"tool-execution ${isError ? 'error' : 'success'}\" id=\"${entryId}\">${tsHtml}`;\n html += `<div class=\"tool-command\">$ ${escapeHtml(msg.command)}</div>`;\n if (msg.output) html += formatExpandableOutput(msg.output, 10);\n if (msg.cancelled) {\n html += '<div style=\"color: var(--warning)\">(cancelled)</div>';\n } else if (msg.exitCode !== 0 && msg.exitCode !== null) {\n html += `<div style=\"color: var(--error)\">(exit ${msg.exitCode})</div>`;\n }\n html += '</div>';\n return html;\n }\n\n if (msg.role === 'toolResult') return '';\n }\n\n if (entry.type === 'model_change') {\n return `<div class=\"model-change\" id=\"${entryId}\">${tsHtml}Switched to model: <span class=\"model-name\">${escapeHtml(entry.provider)}/${escapeHtml(entry.modelId)}</span></div>`;\n }\n\n if (entry.type === 'compaction') {\n return `<div class=\"compaction\" id=\"${entryId}\" onclick=\"this.classList.toggle('expanded')\">\n <div class=\"compaction-label\">[compaction]</div>\n <div class=\"compaction-collapsed\">Compacted from ${entry.tokensBefore.toLocaleString()} tokens</div>\n <div class=\"compaction-content\"><strong>Compacted from ${entry.tokensBefore.toLocaleString()} tokens</strong>\\n\\n${escapeHtml(entry.summary)}</div>\n </div>`;\n }\n\n if (entry.type === 'branch_summary') {\n return `<div class=\"branch-summary\" id=\"${entryId}\">${tsHtml}\n <div class=\"branch-summary-header\">Branch Summary</div>\n <div class=\"markdown-content\">${safeMarkedParse(entry.summary)}</div>\n </div>`;\n }\n\n if (entry.type === 'custom_message' && entry.display) {\n return `<div class=\"hook-message\" id=\"${entryId}\">${tsHtml}\n <div class=\"hook-type\">[${escapeHtml(entry.customType)}]</div>\n <div class=\"markdown-content\">${safeMarkedParse(typeof entry.content === 'string' ? entry.content : JSON.stringify(entry.content))}</div>\n </div>`;\n }\n\n return '';\n }\n\n // ============================================================\n // HEADER / STATS\n // ============================================================\n\n function computeStats(entryList) {\n let userMessages = 0, assistantMessages = 0, toolResults = 0;\n let customMessages = 0, compactions = 0, branchSummaries = 0, toolCalls = 0;\n const tokens = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };\n const cost = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };\n const models = new Set();\n\n for (const entry of entryList) {\n if (entry.type === 'message') {\n const msg = entry.message;\n if (msg.role === 'user') userMessages++;\n if (msg.role === 'assistant') {\n assistantMessages++;\n if (msg.model) models.add(msg.provider ? `${msg.provider}/${msg.model}` : msg.model);\n if (msg.usage) {\n tokens.input += msg.usage.input || 0;\n tokens.output += msg.usage.output || 0;\n tokens.cacheRead += msg.usage.cacheRead || 0;\n tokens.cacheWrite += msg.usage.cacheWrite || 0;\n if (msg.usage.cost) {\n cost.input += msg.usage.cost.input || 0;\n cost.output += msg.usage.cost.output || 0;\n cost.cacheRead += msg.usage.cost.cacheRead || 0;\n cost.cacheWrite += msg.usage.cost.cacheWrite || 0;\n }\n }\n toolCalls += msg.content.filter(c => c.type === 'toolCall').length;\n }\n if (msg.role === 'toolResult') toolResults++;\n } else if (entry.type === 'compaction') {\n compactions++;\n } else if (entry.type === 'branch_summary') {\n branchSummaries++;\n } else if (entry.type === 'custom_message') {\n customMessages++;\n }\n }\n\n return { userMessages, assistantMessages, toolResults, customMessages, compactions, branchSummaries, toolCalls, tokens, cost, models: Array.from(models) };\n }\n\n const globalStats = computeStats(entries);\n\n function renderHeader() {\n const totalCost = globalStats.cost.input + globalStats.cost.output + globalStats.cost.cacheRead + globalStats.cost.cacheWrite;\n\n const tokenParts = [];\n if (globalStats.tokens.input) tokenParts.push(`↑${formatTokens(globalStats.tokens.input)}`);\n if (globalStats.tokens.output) tokenParts.push(`↓${formatTokens(globalStats.tokens.output)}`);\n if (globalStats.tokens.cacheRead) tokenParts.push(`R${formatTokens(globalStats.tokens.cacheRead)}`);\n if (globalStats.tokens.cacheWrite) tokenParts.push(`W${formatTokens(globalStats.tokens.cacheWrite)}`);\n\n const msgParts = [];\n if (globalStats.userMessages) msgParts.push(`${globalStats.userMessages} user`);\n if (globalStats.assistantMessages) msgParts.push(`${globalStats.assistantMessages} assistant`);\n if (globalStats.toolResults) msgParts.push(`${globalStats.toolResults} tool results`);\n if (globalStats.customMessages) msgParts.push(`${globalStats.customMessages} custom`);\n if (globalStats.compactions) msgParts.push(`${globalStats.compactions} compactions`);\n if (globalStats.branchSummaries) msgParts.push(`${globalStats.branchSummaries} branch summaries`);\n\n let html = `\n <div class=\"header\">\n <h1>Session: ${escapeHtml(header?.id || 'unknown')}</h1>\n <div class=\"help-bar\">Ctrl+T toggle thinking · Ctrl+O toggle tools</div>\n <div class=\"header-info\">\n <div class=\"info-item\"><span class=\"info-label\">Date:</span><span class=\"info-value\">${header?.timestamp ? new Date(header.timestamp).toLocaleString() : 'unknown'}</span></div>\n <div class=\"info-item\"><span class=\"info-label\">Models:</span><span class=\"info-value\">${globalStats.models.join(', ') || 'unknown'}</span></div>\n <div class=\"info-item\"><span class=\"info-label\">Messages:</span><span class=\"info-value\">${msgParts.join(', ') || '0'}</span></div>\n <div class=\"info-item\"><span class=\"info-label\">Tool Calls:</span><span class=\"info-value\">${globalStats.toolCalls}</span></div>\n <div class=\"info-item\"><span class=\"info-label\">Tokens:</span><span class=\"info-value\">${tokenParts.join(' ') || '0'}</span></div>\n <div class=\"info-item\"><span class=\"info-label\">Cost:</span><span class=\"info-value\">${totalCost.toFixed(3)}</span></div>\n </div>\n </div>`;\n\n if (systemPrompt) {\n html += `<div class=\"system-prompt\">\n <div class=\"system-prompt-header\">System Prompt</div>\n <div class=\"system-prompt-content\">${escapeHtml(systemPrompt)}</div>\n </div>`;\n }\n\n if (tools && tools.length > 0) {\n html += `<div class=\"tools-list\">\n <div class=\"tools-header\">Available Tools</div>\n <div class=\"tools-content\">\n ${tools.map(t => `<div class=\"tool-item\"><span class=\"tool-item-name\">${escapeHtml(t.name)}</span> - <span class=\"tool-item-desc\">${escapeHtml(t.description)}</span></div>`).join('')}\n </div>\n </div>`;\n }\n\n return html;\n }\n\n // ============================================================\n // NAVIGATION\n // ============================================================\n\n // Cache for rendered entry DOM nodes\n const entryCache = new Map();\n\n function renderEntryToNode(entry) {\n // Check cache first\n if (entryCache.has(entry.id)) {\n return entryCache.get(entry.id).cloneNode(true);\n }\n\n // Render to HTML string, then parse to node\n const html = renderEntry(entry);\n if (!html) return null;\n\n const template = document.createElement('template');\n template.innerHTML = html;\n const node = template.content.firstElementChild;\n\n // Cache the node\n if (node) {\n entryCache.set(entry.id, node.cloneNode(true));\n }\n return node;\n }\n\n function navigateTo(targetId, scrollMode = 'target') {\n currentLeafId = targetId;\n const path = getPath(targetId);\n\n renderTree();\n\n document.getElementById('header-container').innerHTML = renderHeader();\n\n // Build messages using cached DOM nodes\n const messagesEl = document.getElementById('messages');\n const fragment = document.createDocumentFragment();\n\n for (const entry of path) {\n const node = renderEntryToNode(entry);\n if (node) {\n fragment.appendChild(node);\n }\n }\n\n messagesEl.innerHTML = '';\n messagesEl.appendChild(fragment);\n\n // Use setTimeout(0) to ensure DOM is fully laid out before scrolling\n setTimeout(() => {\n const content = document.getElementById('content');\n if (scrollMode === 'bottom') {\n content.scrollTop = content.scrollHeight;\n } else if (scrollMode === 'target') {\n const targetEl = document.getElementById(`entry-${targetId}`);\n if (targetEl) {\n targetEl.scrollIntoView({ block: 'center' });\n }\n }\n }, 0);\n }\n\n // ============================================================\n // INITIALIZATION\n // ============================================================\n\n // Escape HTML tags in text (but not code blocks)\n function escapeHtmlTags(text) {\n return text.replace(/<(?=[a-zA-Z\\/])/g, '<');\n }\n\n // Configure marked with syntax highlighting and HTML escaping for text\n marked.use({\n breaks: true,\n gfm: true,\n renderer: {\n // Code blocks: syntax highlight, no HTML escaping\n code(token) {\n const code = token.text;\n const lang = token.lang;\n let highlighted;\n if (lang && hljs.getLanguage(lang)) {\n try {\n highlighted = hljs.highlight(code, { language: lang }).value;\n } catch {\n highlighted = escapeHtml(code);\n }\n } else {\n // Auto-detect language if not specified\n try {\n highlighted = hljs.highlightAuto(code).value;\n } catch {\n highlighted = escapeHtml(code);\n }\n }\n return `<pre><code class=\"hljs\">${highlighted}</code></pre>`;\n },\n // Text content: escape HTML tags\n text(token) {\n return escapeHtmlTags(escapeHtml(token.text));\n },\n // Inline code: escape HTML\n codespan(token) {\n return `<code>${escapeHtml(token.text)}</code>`;\n }\n }\n });\n\n // Simple marked parse (escaping handled in renderers)\n function safeMarkedParse(text) {\n return marked.parse(text);\n }\n\n // Search input\n const searchInput = document.getElementById('tree-search');\n searchInput.addEventListener('input', (e) => {\n searchQuery = e.target.value;\n forceTreeRerender();\n });\n\n // Filter buttons\n document.querySelectorAll('.filter-btn').forEach(btn => {\n btn.addEventListener('click', () => {\n document.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active'));\n btn.classList.add('active');\n filterMode = btn.dataset.filter;\n forceTreeRerender();\n });\n });\n\n // Sidebar toggle\n const sidebar = document.getElementById('sidebar');\n const overlay = document.getElementById('sidebar-overlay');\n const hamburger = document.getElementById('hamburger');\n\n hamburger.addEventListener('click', () => {\n sidebar.classList.add('open');\n overlay.classList.add('open');\n hamburger.style.display = 'none';\n });\n\n const closeSidebar = () => {\n sidebar.classList.remove('open');\n overlay.classList.remove('open');\n hamburger.style.display = '';\n };\n\n overlay.addEventListener('click', closeSidebar);\n document.getElementById('sidebar-close').addEventListener('click', closeSidebar);\n\n // Toggle states\n let thinkingExpanded = true;\n let toolOutputsExpanded = false;\n\n const toggleThinking = () => {\n thinkingExpanded = !thinkingExpanded;\n document.querySelectorAll('.thinking-text').forEach(el => {\n el.style.display = thinkingExpanded ? '' : 'none';\n });\n document.querySelectorAll('.thinking-collapsed').forEach(el => {\n el.style.display = thinkingExpanded ? 'none' : 'block';\n });\n };\n\n const toggleToolOutputs = () => {\n toolOutputsExpanded = !toolOutputsExpanded;\n document.querySelectorAll('.tool-output.expandable').forEach(el => {\n el.classList.toggle('expanded', toolOutputsExpanded);\n });\n document.querySelectorAll('.compaction').forEach(el => {\n el.classList.toggle('expanded', toolOutputsExpanded);\n });\n };\n\n // Keyboard shortcuts\n document.addEventListener('keydown', (e) => {\n if (e.key === 'Escape') {\n searchInput.value = '';\n searchQuery = '';\n navigateTo(leafId, 'bottom');\n }\n if (e.ctrlKey && e.key === 't') {\n e.preventDefault();\n toggleThinking();\n }\n if (e.ctrlKey && e.key === 'o') {\n e.preventDefault();\n toggleToolOutputs();\n }\n });\n\n // Initial render - don't scroll, stay at top\n if (leafId) {\n navigateTo(leafId, 'none');\n } else if (entries.length > 0) {\n // Fallback: use last entry if no leafId\n navigateTo(entries[entries.length - 1].id, 'none');\n }\n })();\n</script>\n</body>\n</html>\n";
|
package/src/core/hooks/loader.ts
CHANGED
|
@@ -2,15 +2,15 @@
|
|
|
2
2
|
* Hook loader - loads TypeScript hook modules using native Bun import.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import * as fs from "node:fs";
|
|
6
5
|
import * as os from "node:os";
|
|
7
6
|
import * as path from "node:path";
|
|
8
7
|
import * as typebox from "@sinclair/typebox";
|
|
9
|
-
import {
|
|
8
|
+
import { hookCapability } from "../../capability/hook";
|
|
9
|
+
import type { Hook } from "../../discovery";
|
|
10
|
+
import { loadSync } from "../../discovery";
|
|
10
11
|
import * as piCodingAgent from "../../index";
|
|
11
12
|
import { logger } from "../logger";
|
|
12
13
|
import type { HookMessage } from "../messages";
|
|
13
|
-
import { getAllPluginHookPaths } from "../plugins/loader";
|
|
14
14
|
import type { SessionManager } from "../session-manager";
|
|
15
15
|
import { execCommand } from "./runner";
|
|
16
16
|
import type { ExecOptions, HookAPI, HookFactory, HookMessageRenderer, RegisteredCommand } from "./types";
|
|
@@ -254,37 +254,15 @@ export async function loadHooks(paths: string[], cwd: string): Promise<LoadHooks
|
|
|
254
254
|
}
|
|
255
255
|
|
|
256
256
|
/**
|
|
257
|
-
* Discover
|
|
258
|
-
*
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
return [];
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
try {
|
|
266
|
-
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
267
|
-
return entries
|
|
268
|
-
.filter((e) => (e.isFile() || e.isSymbolicLink()) && e.name.endsWith(".ts"))
|
|
269
|
-
.map((e) => path.join(dir, e.name));
|
|
270
|
-
} catch {
|
|
271
|
-
return [];
|
|
272
|
-
}
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
/**
|
|
276
|
-
* Discover and load hooks from standard locations:
|
|
277
|
-
* 1. agentDir/hooks/*.ts (global)
|
|
278
|
-
* 2. cwd/.pi/hooks/*.ts (project-local)
|
|
279
|
-
* 3. Installed plugins (~/.pi/plugins/node_modules/*)
|
|
257
|
+
* Discover and load hooks from all registered providers.
|
|
258
|
+
* Uses the capability API to discover hook paths from:
|
|
259
|
+
* 1. OMP native configs (.omp/.pi hooks/)
|
|
260
|
+
* 2. Installed plugins
|
|
261
|
+
* 3. Other editor/IDE configurations
|
|
280
262
|
*
|
|
281
263
|
* Plus any explicitly configured paths from settings.
|
|
282
264
|
*/
|
|
283
|
-
export async function discoverAndLoadHooks(
|
|
284
|
-
configuredPaths: string[],
|
|
285
|
-
cwd: string,
|
|
286
|
-
agentDir: string = getAgentDir(),
|
|
287
|
-
): Promise<LoadHooksResult> {
|
|
265
|
+
export async function discoverAndLoadHooks(configuredPaths: string[], cwd: string): Promise<LoadHooksResult> {
|
|
288
266
|
const allPaths: string[] = [];
|
|
289
267
|
const seen = new Set<string>();
|
|
290
268
|
|
|
@@ -299,18 +277,11 @@ export async function discoverAndLoadHooks(
|
|
|
299
277
|
}
|
|
300
278
|
};
|
|
301
279
|
|
|
302
|
-
// 1.
|
|
303
|
-
const
|
|
304
|
-
addPaths(
|
|
305
|
-
|
|
306
|
-
// 2. Project-local hooks: cwd/.pi/hooks/
|
|
307
|
-
const localHooksDir = path.join(cwd, ".pi", "hooks");
|
|
308
|
-
addPaths(discoverHooksInDir(localHooksDir));
|
|
309
|
-
|
|
310
|
-
// 3. Plugin hooks: ~/.pi/plugins/node_modules/*/
|
|
311
|
-
addPaths(getAllPluginHookPaths(cwd));
|
|
280
|
+
// 1. Discover hooks via capability API
|
|
281
|
+
const discovered = loadSync<Hook>(hookCapability.id, { cwd });
|
|
282
|
+
addPaths(discovered.items.map((hook) => hook.path));
|
|
312
283
|
|
|
313
|
-
//
|
|
284
|
+
// 2. Explicitly configured paths (can override/add)
|
|
314
285
|
addPaths(configuredPaths.map((p) => resolveHookPath(p, cwd)));
|
|
315
286
|
|
|
316
287
|
return loadHooks(allPaths, cwd);
|
package/src/core/index.ts
CHANGED
package/src/core/logger.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Centralized file logger for
|
|
2
|
+
* Centralized file logger for omp.
|
|
3
3
|
*
|
|
4
|
-
* Logs to ~/.
|
|
4
|
+
* Logs to ~/.omp/logs/ with size-based rotation, supporting concurrent omp instances.
|
|
5
5
|
* Each log entry includes process.pid for traceability.
|
|
6
6
|
*/
|
|
7
7
|
|
|
@@ -12,7 +12,7 @@ import winston from "winston";
|
|
|
12
12
|
import DailyRotateFile from "winston-daily-rotate-file";
|
|
13
13
|
import { CONFIG_DIR_NAME } from "../config";
|
|
14
14
|
|
|
15
|
-
/** Get the logs directory (~/.
|
|
15
|
+
/** Get the logs directory (~/.omp/logs/) */
|
|
16
16
|
function getLogsDir(): string {
|
|
17
17
|
return join(homedir(), CONFIG_DIR_NAME, "logs");
|
|
18
18
|
}
|
|
@@ -49,7 +49,7 @@ const logFormat = winston.format.combine(
|
|
|
49
49
|
/** Size-based rotating file transport */
|
|
50
50
|
const fileTransport = new DailyRotateFile({
|
|
51
51
|
dirname: ensureLogsDir(),
|
|
52
|
-
filename: "
|
|
52
|
+
filename: "omp.%DATE%.log",
|
|
53
53
|
datePattern: "YYYY-MM-DD",
|
|
54
54
|
maxSize: "10m",
|
|
55
55
|
maxFiles: 5,
|
|
@@ -73,10 +73,10 @@ export interface Logger {
|
|
|
73
73
|
}
|
|
74
74
|
|
|
75
75
|
/**
|
|
76
|
-
* Centralized logger for
|
|
76
|
+
* Centralized logger for omp.
|
|
77
77
|
*
|
|
78
|
-
* Logs to ~/.
|
|
79
|
-
* Safe for concurrent access from multiple
|
|
78
|
+
* Logs to ~/.omp/logs/omp.YYYY-MM-DD.log with size-based rotation.
|
|
79
|
+
* Safe for concurrent access from multiple omp instances.
|
|
80
80
|
*
|
|
81
81
|
* @example
|
|
82
82
|
* ```typescript
|