@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.
Files changed (116) hide show
  1. package/CHANGELOG.md +64 -34
  2. package/README.md +100 -100
  3. package/docs/compaction.md +8 -8
  4. package/docs/config-usage.md +113 -0
  5. package/docs/custom-tools.md +8 -8
  6. package/docs/extension-loading.md +58 -58
  7. package/docs/hooks.md +11 -11
  8. package/docs/rpc.md +4 -4
  9. package/docs/sdk.md +14 -14
  10. package/docs/session-tree-plan.md +1 -1
  11. package/docs/session.md +2 -2
  12. package/docs/skills.md +16 -16
  13. package/docs/theme.md +9 -9
  14. package/docs/tui.md +1 -1
  15. package/examples/README.md +1 -1
  16. package/examples/custom-tools/README.md +4 -4
  17. package/examples/custom-tools/subagent/README.md +13 -13
  18. package/examples/custom-tools/subagent/agents.ts +2 -2
  19. package/examples/custom-tools/subagent/index.ts +5 -5
  20. package/examples/hooks/README.md +3 -3
  21. package/examples/hooks/auto-commit-on-exit.ts +1 -1
  22. package/examples/hooks/custom-compaction.ts +1 -1
  23. package/examples/sdk/01-minimal.ts +1 -1
  24. package/examples/sdk/04-skills.ts +1 -1
  25. package/examples/sdk/05-tools.ts +1 -1
  26. package/examples/sdk/08-slash-commands.ts +1 -1
  27. package/examples/sdk/09-api-keys-and-oauth.ts +2 -2
  28. package/examples/sdk/README.md +2 -2
  29. package/package.json +16 -12
  30. package/src/capability/context-file.ts +40 -0
  31. package/src/capability/extension.ts +48 -0
  32. package/src/capability/hook.ts +40 -0
  33. package/src/capability/index.ts +616 -0
  34. package/src/capability/instruction.ts +37 -0
  35. package/src/capability/mcp.ts +52 -0
  36. package/src/capability/prompt.ts +35 -0
  37. package/src/capability/rule.ts +52 -0
  38. package/src/capability/settings.ts +35 -0
  39. package/src/capability/skill.ts +49 -0
  40. package/src/capability/slash-command.ts +40 -0
  41. package/src/capability/system-prompt.ts +35 -0
  42. package/src/capability/tool.ts +38 -0
  43. package/src/capability/types.ts +166 -0
  44. package/src/cli/args.ts +2 -2
  45. package/src/cli/plugin-cli.ts +24 -19
  46. package/src/cli/update-cli.ts +10 -10
  47. package/src/config.ts +290 -6
  48. package/src/core/auth-storage.ts +32 -9
  49. package/src/core/bash-executor.ts +1 -1
  50. package/src/core/custom-commands/loader.ts +44 -50
  51. package/src/core/custom-tools/index.ts +1 -0
  52. package/src/core/custom-tools/loader.ts +67 -69
  53. package/src/core/custom-tools/types.ts +10 -1
  54. package/src/core/export-html/index.ts +9 -9
  55. package/src/core/export-html/template.generated.ts +2 -0
  56. package/src/core/hooks/loader.ts +13 -42
  57. package/src/core/index.ts +0 -1
  58. package/src/core/logger.ts +7 -7
  59. package/src/core/mcp/client.ts +1 -1
  60. package/src/core/mcp/config.ts +94 -146
  61. package/src/core/mcp/index.ts +0 -4
  62. package/src/core/mcp/loader.ts +26 -22
  63. package/src/core/mcp/manager.ts +18 -23
  64. package/src/core/mcp/tool-bridge.ts +9 -1
  65. package/src/core/mcp/types.ts +2 -0
  66. package/src/core/model-registry.ts +25 -8
  67. package/src/core/plugins/installer.ts +1 -1
  68. package/src/core/plugins/loader.ts +17 -11
  69. package/src/core/plugins/manager.ts +2 -2
  70. package/src/core/plugins/paths.ts +12 -7
  71. package/src/core/plugins/types.ts +3 -3
  72. package/src/core/sdk.ts +48 -27
  73. package/src/core/session-manager.ts +4 -4
  74. package/src/core/settings-manager.ts +45 -21
  75. package/src/core/skills.ts +222 -293
  76. package/src/core/slash-commands.ts +34 -165
  77. package/src/core/system-prompt.ts +58 -65
  78. package/src/core/timings.ts +2 -2
  79. package/src/core/tools/lsp/config.ts +38 -17
  80. package/src/core/tools/task/artifacts.ts +1 -1
  81. package/src/core/tools/task/commands.ts +30 -107
  82. package/src/core/tools/task/discovery.ts +54 -66
  83. package/src/core/tools/task/executor.ts +9 -9
  84. package/src/core/tools/task/index.ts +10 -10
  85. package/src/core/tools/task/model-resolver.ts +27 -25
  86. package/src/core/tools/task/types.ts +2 -2
  87. package/src/core/tools/web-fetch.ts +3 -3
  88. package/src/core/tools/web-search/auth.ts +40 -34
  89. package/src/core/tools/web-search/index.ts +1 -1
  90. package/src/core/tools/web-search/providers/anthropic.ts +1 -1
  91. package/src/discovery/agents-md.ts +75 -0
  92. package/src/discovery/builtin.ts +646 -0
  93. package/src/discovery/claude.ts +623 -0
  94. package/src/discovery/cline.ts +102 -0
  95. package/src/discovery/codex.ts +571 -0
  96. package/src/discovery/cursor.ts +264 -0
  97. package/src/discovery/gemini.ts +368 -0
  98. package/src/discovery/github.ts +120 -0
  99. package/src/discovery/helpers.test.ts +127 -0
  100. package/src/discovery/helpers.ts +249 -0
  101. package/src/discovery/index.ts +84 -0
  102. package/src/discovery/mcp-json.ts +127 -0
  103. package/src/discovery/vscode.ts +99 -0
  104. package/src/discovery/windsurf.ts +216 -0
  105. package/src/main.ts +14 -13
  106. package/src/migrations.ts +24 -3
  107. package/src/modes/interactive/components/hook-editor.ts +1 -1
  108. package/src/modes/interactive/components/plugin-settings.ts +1 -1
  109. package/src/modes/interactive/components/settings-defs.ts +38 -2
  110. package/src/modes/interactive/components/settings-selector.ts +1 -0
  111. package/src/modes/interactive/components/welcome.ts +2 -2
  112. package/src/modes/interactive/interactive-mode.ts +211 -16
  113. package/src/modes/interactive/theme/theme-schema.json +1 -1
  114. package/src/utils/clipboard.ts +1 -1
  115. package/src/utils/shell-snapshot.ts +2 -2
  116. 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 { getAgentDir } from "../../config";
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
- ): Promise<{ tools: LoadedCustomTool[] | null; error: string | null }> {
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 paths - Array of tool file paths
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
- paths: string[],
144
+ pathsWithSources: ToolPathWithSource[],
118
145
  cwd: string,
119
146
  builtInToolNames: string[],
120
147
  ): Promise<CustomToolsLoadResult> {
121
148
  const tools: LoadedCustomTool[] = [];
122
- const errors: Array<{ path: string; error: string }> = [];
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 paths) {
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({ path: toolPath, error });
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 tool files from a directory.
174
- * Only loads index.ts files from subdirectories (e.g., tools/mytool/index.ts).
175
- */
176
- function discoverToolsInDir(dir: string): string[] {
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 allPaths: string[] = [];
215
+ const allPathsWithSources: ToolPathWithSource[] = [];
222
216
  const seen = new Set<string>();
223
217
 
224
218
  // Helper to add paths without duplicates
225
- const addPaths = (paths: string[]) => {
226
- for (const p of paths) {
227
- const resolved = path.resolve(p);
228
- if (!seen.has(resolved)) {
229
- seen.add(resolved);
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. Global tools: agentDir/tools/
236
- const globalToolsDir = path.join(agentDir, "tools");
237
- addPaths(discoverToolsInDir(globalToolsDir));
238
-
239
- // 2. Project-local tools: cwd/.pi/tools/
240
- const localToolsDir = path.join(cwd, ".pi", "tools");
241
- addPaths(discoverToolsInDir(localToolsDir));
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
- // 3. Plugin tools: ~/.pi/plugins/node_modules/*/
244
- addPaths(getAllPluginToolPaths(cwd));
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
- // 4. Explicitly configured paths (can override/add)
247
- addPaths(configuredPaths.map((p) => resolveToolPath(p, cwd)));
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(allPaths, cwd, builtInToolNames);
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: Array<{ path: string; error: string }>;
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
- // Bun macro: bundles HTML+CSS+JS at compile time, evaluated at bundle time
9
- import { getTemplate } from "./template.macro" with { type: "macro" };
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
- async function generateHtml(sessionData: SessionData, themeName?: string): Promise<string> {
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 template
110
- .replace("<theme-vars/>", `<style>:root { ${themeVars} }</style>`)
111
- .replace("{{SESSION_DATA}}", sessionDataBase64);
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 = await generateHtml(sessionData, opts.themeName);
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 = await generateHtml(sessionData, opts.themeName);
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, '&lt;');\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";
@@ -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 { getAgentDir } from "../../config";
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 hook files from a directory.
258
- * Returns all .ts files (and symlinks to .ts files) in the directory (non-recursive).
259
- */
260
- function discoverHooksInDir(dir: string): string[] {
261
- if (!fs.existsSync(dir)) {
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. Global hooks: agentDir/hooks/
303
- const globalHooksDir = path.join(agentDir, "hooks");
304
- addPaths(discoverHooksInDir(globalHooksDir));
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
- // 4. Explicitly configured paths (can override/add)
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
@@ -38,7 +38,6 @@ export {
38
38
  export {
39
39
  createMCPManager,
40
40
  discoverAndLoadMCPTools,
41
- expandEnvVars,
42
41
  loadAllMCPConfigs,
43
42
  type MCPConfigFile,
44
43
  type MCPLoadResult,
@@ -1,7 +1,7 @@
1
1
  /**
2
- * Centralized file logger for pi.
2
+ * Centralized file logger for omp.
3
3
  *
4
- * Logs to ~/.pi/logs/ with size-based rotation, supporting concurrent pi instances.
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 (~/.pi/logs/) */
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: "pi.%DATE%.log",
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 pi.
76
+ * Centralized logger for omp.
77
77
  *
78
- * Logs to ~/.pi/logs/pi.YYYY-MM-DD.log with size-based rotation.
79
- * Safe for concurrent access from multiple pi instances.
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
@@ -30,7 +30,7 @@ const CONNECTION_TIMEOUT_MS = 30_000;
30
30
 
31
31
  /** Client info sent during initialization */
32
32
  const CLIENT_INFO = {
33
- name: "pi-coding-agent",
33
+ name: "omp-coding-agent",
34
34
  version: "1.0.0",
35
35
  };
36
36