From 8ebc4bcebec8a5b6754c8db8255faccac31a30ef Mon Sep 17 00:00:00 2001 From: paulbettner Date: Mon, 29 Dec 2025 03:46:01 -0500 Subject: [PATCH 001/124] tui: coalesce sequential status messages (+ tests) --- .../src/modes/interactive/interactive-mode.ts | 29 +++++++++- .../test/interactive-mode-status.test.ts | 57 +++++++++++++++++++ 2 files changed, 83 insertions(+), 3 deletions(-) create mode 100644 packages/coding-agent/test/interactive-mode-status.test.ts diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index f0e1c01c..c45685d3 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -76,6 +76,10 @@ export class InteractiveMode { private lastEscapeTime = 0; private changelogMarkdown: string | null = null; + // Status line tracking (for mutating immediately-sequential status updates) + private lastStatusSpacer: Spacer | null = null; + private lastStatusText: Text | null = null; + // Streaming message tracking private streamingComponent: AssistantMessageComponent | null = null; @@ -984,10 +988,29 @@ export class InteractiveMode { return textBlocks.map((c) => (c as { text: string }).text).join(""); } - /** Show a status message in the chat */ + /** + * Show a status message in the chat. + * + * If multiple status messages are emitted back-to-back (without anything else being added to the chat), + * we update the previous status line instead of appending new ones to avoid log spam. + */ private showStatus(message: string): void { - this.chatContainer.addChild(new Spacer(1)); - this.chatContainer.addChild(new Text(theme.fg("dim", message), 1, 0)); + const children = this.chatContainer.children; + const last = children.length > 0 ? children[children.length - 1] : undefined; + const secondLast = children.length > 1 ? children[children.length - 2] : undefined; + + if (last && secondLast && last === this.lastStatusText && secondLast === this.lastStatusSpacer) { + this.lastStatusText.setText(theme.fg("dim", message)); + this.ui.requestRender(); + return; + } + + const spacer = new Spacer(1); + const text = new Text(theme.fg("dim", message), 1, 0); + this.chatContainer.addChild(spacer); + this.chatContainer.addChild(text); + this.lastStatusSpacer = spacer; + this.lastStatusText = text; this.ui.requestRender(); } diff --git a/packages/coding-agent/test/interactive-mode-status.test.ts b/packages/coding-agent/test/interactive-mode-status.test.ts new file mode 100644 index 00000000..610249b0 --- /dev/null +++ b/packages/coding-agent/test/interactive-mode-status.test.ts @@ -0,0 +1,57 @@ +import { Container } from "@mariozechner/pi-tui"; +import { beforeAll, describe, expect, test, vi } from "vitest"; +import { InteractiveMode } from "../src/modes/interactive/interactive-mode.js"; +import { initTheme } from "../src/modes/interactive/theme/theme.js"; + +function renderLastLine(container: Container, width = 120): string { + const last = container.children[container.children.length - 1]; + if (!last) return ""; + return last.render(width).join("\n"); +} + +describe("InteractiveMode.showStatus", () => { + beforeAll(() => { + // showStatus uses the global theme instance + initTheme("dark"); + }); + + test("coalesces immediately-sequential status messages", () => { + const fakeThis: any = { + chatContainer: new Container(), + ui: { requestRender: vi.fn() }, + lastStatusSpacer: null, + lastStatusText: null, + }; + + (InteractiveMode as any).prototype.showStatus.call(fakeThis, "STATUS_ONE"); + expect(fakeThis.chatContainer.children).toHaveLength(2); + expect(renderLastLine(fakeThis.chatContainer)).toContain("STATUS_ONE"); + + (InteractiveMode as any).prototype.showStatus.call(fakeThis, "STATUS_TWO"); + // second status updates the previous line instead of appending + expect(fakeThis.chatContainer.children).toHaveLength(2); + expect(renderLastLine(fakeThis.chatContainer)).toContain("STATUS_TWO"); + expect(renderLastLine(fakeThis.chatContainer)).not.toContain("STATUS_ONE"); + }); + + test("appends a new status line if something else was added in between", () => { + const fakeThis: any = { + chatContainer: new Container(), + ui: { requestRender: vi.fn() }, + lastStatusSpacer: null, + lastStatusText: null, + }; + + (InteractiveMode as any).prototype.showStatus.call(fakeThis, "STATUS_ONE"); + expect(fakeThis.chatContainer.children).toHaveLength(2); + + // Something else gets added to the chat in between status updates + fakeThis.chatContainer.addChild({ render: () => ["OTHER"], invalidate: () => {} }); + expect(fakeThis.chatContainer.children).toHaveLength(3); + + (InteractiveMode as any).prototype.showStatus.call(fakeThis, "STATUS_TWO"); + // adds spacer + text + expect(fakeThis.chatContainer.children).toHaveLength(5); + expect(renderLastLine(fakeThis.chatContainer)).toContain("STATUS_TWO"); + }); +}); From 568150f18bfa2d472484eb6d15b1fe8afc999d8c Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Wed, 31 Dec 2025 12:05:24 +0100 Subject: [PATCH 002/124] Rework custom tools API with CustomToolContext - CustomAgentTool renamed to CustomTool - ToolAPI renamed to CustomToolAPI - ToolContext renamed to CustomToolContext - ToolSessionEvent renamed to CustomToolSessionEvent - Added CustomToolContext parameter to execute() and onSession() - CustomToolFactory now returns CustomTool for type compatibility - dispose() replaced with onSession({ reason: 'shutdown' }) - Added wrapCustomTool() to convert CustomTool to AgentTool - Session exposes setToolUIContext() instead of leaking internals - Fix ToolExecutionComponent to sync with toolOutputExpanded state - Update all custom tool examples for new API --- .../examples/custom-tools/hello/index.ts | 5 +- .../examples/custom-tools/question/index.ts | 4 +- .../examples/custom-tools/subagent/index.ts | 10 +- .../examples/custom-tools/todo/index.ts | 14 +- .../examples/hooks/custom-compaction.ts | 3 +- .../coding-agent/examples/sdk/05-tools.ts | 4 +- .../examples/sdk/12-full-control.ts | 7 +- .../coding-agent/src/core/agent-session.ts | 33 +++-- .../core/compaction/branch-summarization.ts | 4 +- .../src/core/custom-tools/index.ts | 12 +- .../src/core/custom-tools/loader.ts | 8 +- .../src/core/custom-tools/types.ts | 130 ++++++++++++------ .../src/core/custom-tools/wrapper.ts | 28 ++++ .../coding-agent/src/core/hooks/runner.ts | 38 +---- packages/coding-agent/src/core/hooks/types.ts | 22 +-- packages/coding-agent/src/core/index.ts | 6 +- packages/coding-agent/src/core/sdk.ts | 40 ++++-- .../coding-agent/src/core/session-manager.ts | 32 +++-- packages/coding-agent/src/index.ts | 9 +- .../interactive/components/tool-execution.ts | 6 +- .../src/modes/interactive/interactive-mode.ts | 92 ++++++------- packages/coding-agent/src/modes/print-mode.ts | 40 +++--- .../coding-agent/src/modes/rpc/rpc-mode.ts | 43 +++--- .../test/compaction-hooks-example.test.ts | 14 +- .../test/compaction-hooks.test.ts | 11 +- .../test/session-manager/save-entry.test.ts | 2 +- .../session-manager/tree-traversal.test.ts | 8 +- 27 files changed, 336 insertions(+), 289 deletions(-) create mode 100644 packages/coding-agent/src/core/custom-tools/wrapper.ts diff --git a/packages/coding-agent/examples/custom-tools/hello/index.ts b/packages/coding-agent/examples/custom-tools/hello/index.ts index f9057fad..c2bf07b8 100644 --- a/packages/coding-agent/examples/custom-tools/hello/index.ts +++ b/packages/coding-agent/examples/custom-tools/hello/index.ts @@ -10,9 +10,10 @@ const factory: CustomToolFactory = (_pi) => ({ }), async execute(_toolCallId, params) { + const { name } = params as { name: string }; return { - content: [{ type: "text", text: `Hello, ${params.name}!` }], - details: { greeted: params.name }, + content: [{ type: "text", text: `Hello, ${name}!` }], + details: { greeted: name }, }; }, }); diff --git a/packages/coding-agent/examples/custom-tools/question/index.ts b/packages/coding-agent/examples/custom-tools/question/index.ts index 76c068ca..6949efcc 100644 --- a/packages/coding-agent/examples/custom-tools/question/index.ts +++ b/packages/coding-agent/examples/custom-tools/question/index.ts @@ -2,7 +2,7 @@ * Question Tool - Let the LLM ask the user a question with options */ -import type { CustomAgentTool, CustomToolFactory } from "@mariozechner/pi-coding-agent"; +import type { CustomTool, CustomToolFactory } from "@mariozechner/pi-coding-agent"; import { Text } from "@mariozechner/pi-tui"; import { Type } from "@sinclair/typebox"; @@ -18,7 +18,7 @@ const QuestionParams = Type.Object({ }); const factory: CustomToolFactory = (pi) => { - const tool: CustomAgentTool = { + const tool: CustomTool = { name: "question", label: "Question", description: "Ask the user a question and let them pick from options. Use when you need user input to proceed.", diff --git a/packages/coding-agent/examples/custom-tools/subagent/index.ts b/packages/coding-agent/examples/custom-tools/subagent/index.ts index 67a2d526..c9fd89e2 100644 --- a/packages/coding-agent/examples/custom-tools/subagent/index.ts +++ b/packages/coding-agent/examples/custom-tools/subagent/index.ts @@ -20,10 +20,10 @@ import type { AgentToolResult } from "@mariozechner/pi-agent-core"; import type { Message } from "@mariozechner/pi-ai"; import { StringEnum } from "@mariozechner/pi-ai"; import { - type CustomAgentTool, + type CustomTool, + type CustomToolAPI, type CustomToolFactory, getMarkdownTheme, - type ToolAPI, } from "@mariozechner/pi-coding-agent"; import { Container, Markdown, Spacer, Text } from "@mariozechner/pi-tui"; import { Type } from "@sinclair/typebox"; @@ -224,7 +224,7 @@ function writePromptToTempFile(agentName: string, prompt: string): { dir: string type OnUpdateCallback = (partial: AgentToolResult) => void; async function runSingleAgent( - pi: ToolAPI, + pi: CustomToolAPI, agents: AgentConfig[], agentName: string, task: string, @@ -411,7 +411,7 @@ const SubagentParams = Type.Object({ }); const factory: CustomToolFactory = (pi) => { - const tool: CustomAgentTool = { + const tool: CustomTool = { name: "subagent", label: "Subagent", get description() { @@ -433,7 +433,7 @@ const factory: CustomToolFactory = (pi) => { }, parameters: SubagentParams, - async execute(_toolCallId, params, signal, onUpdate) { + async execute(_toolCallId, params, signal, onUpdate, _ctx) { const agentScope: AgentScope = params.agentScope ?? "user"; const discovery = discoverAgents(pi.cwd, agentScope); const agents = discovery.agents; diff --git a/packages/coding-agent/examples/custom-tools/todo/index.ts b/packages/coding-agent/examples/custom-tools/todo/index.ts index d6da1b17..6b4d1feb 100644 --- a/packages/coding-agent/examples/custom-tools/todo/index.ts +++ b/packages/coding-agent/examples/custom-tools/todo/index.ts @@ -9,7 +9,12 @@ */ import { StringEnum } from "@mariozechner/pi-ai"; -import type { CustomAgentTool, CustomToolFactory, ToolSessionEvent } from "@mariozechner/pi-coding-agent"; +import type { + CustomTool, + CustomToolContext, + CustomToolFactory, + CustomToolSessionEvent, +} from "@mariozechner/pi-coding-agent"; import { Text } from "@mariozechner/pi-tui"; import { Type } from "@sinclair/typebox"; @@ -43,11 +48,12 @@ const factory: CustomToolFactory = (_pi) => { * Reconstruct state from session entries. * Scans tool results for this tool and applies them in order. */ - const reconstructState = (event: ToolSessionEvent) => { + const reconstructState = (_event: CustomToolSessionEvent, ctx: CustomToolContext) => { todos = []; nextId = 1; - for (const entry of event.entries) { + // Use getBranch() to get entries on the current branch + for (const entry of ctx.sessionManager.getBranch()) { if (entry.type !== "message") continue; const msg = entry.message; @@ -63,7 +69,7 @@ const factory: CustomToolFactory = (_pi) => { } }; - const tool: CustomAgentTool = { + const tool: CustomTool = { name: "todo", label: "Todo", description: "Manage a todo list. Actions: list, add (text), toggle (id), clear", diff --git a/packages/coding-agent/examples/hooks/custom-compaction.ts b/packages/coding-agent/examples/hooks/custom-compaction.ts index be7795b2..3dd0176b 100644 --- a/packages/coding-agent/examples/hooks/custom-compaction.ts +++ b/packages/coding-agent/examples/hooks/custom-compaction.ts @@ -14,7 +14,6 @@ */ import { complete, getModel } from "@mariozechner/pi-ai"; -import type { CompactionEntry } from "@mariozechner/pi-coding-agent"; import { convertToLlm } from "@mariozechner/pi-coding-agent"; import type { HookAPI } from "@mariozechner/pi-coding-agent/hooks"; @@ -22,7 +21,7 @@ export default function (pi: HookAPI) { pi.on("session_before_compact", async (event, ctx) => { ctx.ui.notify("Custom compaction hook triggered", "info"); - const { preparation, branchEntries, signal } = event; + const { preparation, branchEntries: _, signal } = event; const { messagesToSummarize, turnPrefixMessages, tokensBefore, firstKeptEntryId, previousSummary } = preparation; // Use Gemini Flash for summarization (cheaper/faster than most conversation models) diff --git a/packages/coding-agent/examples/sdk/05-tools.ts b/packages/coding-agent/examples/sdk/05-tools.ts index 7772c1dc..f7939688 100644 --- a/packages/coding-agent/examples/sdk/05-tools.ts +++ b/packages/coding-agent/examples/sdk/05-tools.ts @@ -11,7 +11,7 @@ import { Type } from "@sinclair/typebox"; import { bashTool, // read, bash, edit, write - uses process.cwd() - type CustomAgentTool, + type CustomTool, createAgentSession, createBashTool, createCodingTools, // Factory: creates tools for specific cwd @@ -55,7 +55,7 @@ await createAgentSession({ console.log("Specific tools with custom cwd session created"); // Inline custom tool (needs TypeBox schema) -const weatherTool: CustomAgentTool = { +const weatherTool: CustomTool = { name: "get_weather", label: "Get Weather", description: "Get current weather for a city", diff --git a/packages/coding-agent/examples/sdk/12-full-control.ts b/packages/coding-agent/examples/sdk/12-full-control.ts index bdf6a478..5dbe7718 100644 --- a/packages/coding-agent/examples/sdk/12-full-control.ts +++ b/packages/coding-agent/examples/sdk/12-full-control.ts @@ -12,7 +12,7 @@ import { getModel } from "@mariozechner/pi-ai"; import { Type } from "@sinclair/typebox"; import { AuthStorage, - type CustomAgentTool, + type CustomTool, createAgentSession, createBashTool, createReadTool, @@ -42,7 +42,7 @@ const auditHook: HookFactory = (api) => { }; // Inline custom tool -const statusTool: CustomAgentTool = { +const statusTool: CustomTool = { name: "status", label: "Status", description: "Get system status", @@ -68,15 +68,12 @@ const cwd = process.cwd(); const { session } = await createAgentSession({ cwd, agentDir: "/tmp/my-agent", - model, thinkingLevel: "off", authStorage, modelRegistry, - systemPrompt: `You are a minimal assistant. Available: read, bash, status. Be concise.`, - // Use factory functions with the same cwd to ensure path resolution works correctly tools: [createReadTool(cwd), createBashTool(cwd)], customTools: [{ tool: statusTool }], diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index 6916d8e0..e2d63e09 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -27,7 +27,7 @@ import { prepareCompaction, shouldCompact, } from "./compaction/index.js"; -import type { LoadedCustomTool, SessionEvent as ToolSessionEvent } from "./custom-tools/index.js"; +import type { CustomToolContext, CustomToolSessionEvent, LoadedCustomTool } from "./custom-tools/index.js"; import { exportSessionToHtml } from "./export-html.js"; import type { HookContext, @@ -698,7 +698,7 @@ export class AgentSession { } // Emit session event to custom tools - await this.emitToolSessionEvent("new", previousSessionFile); + await this.emitCustomToolSessionEvent("new", previousSessionFile); return true; } @@ -895,7 +895,7 @@ export class AgentSession { throw new Error(`No API key for ${this.model.provider}`); } - const pathEntries = this.sessionManager.getPath(); + const pathEntries = this.sessionManager.getBranch(); const settings = this.settingsManager.getCompactionSettings(); const preparation = prepareCompaction(pathEntries, settings); @@ -1068,7 +1068,7 @@ export class AgentSession { return; } - const pathEntries = this.sessionManager.getPath(); + const pathEntries = this.sessionManager.getBranch(); const preparation = prepareCompaction(pathEntries, settings); if (!preparation) { @@ -1473,7 +1473,7 @@ export class AgentSession { } // Emit session event to custom tools - await this.emitToolSessionEvent("switch", previousSessionFile); + await this.emitCustomToolSessionEvent("switch", previousSessionFile); this.agent.replaceMessages(sessionContext.messages); @@ -1550,7 +1550,7 @@ export class AgentSession { } // Emit session event to custom tools (with reason "branch") - await this.emitToolSessionEvent("branch", previousSessionFile); + await this.emitCustomToolSessionEvent("branch", previousSessionFile); if (!skipConversationRestore) { this.agent.replaceMessages(sessionContext.messages); @@ -1720,7 +1720,7 @@ export class AgentSession { } // Emit to custom tools - await this.emitToolSessionEvent("tree", this.sessionFile); + await this.emitCustomToolSessionEvent("tree", this.sessionFile); this._branchSummaryAbortController = undefined; return { editorText, cancelled: false, summaryEntry }; @@ -1877,20 +1877,23 @@ export class AgentSession { * Emit session event to all custom tools. * Called on session switch, branch, tree navigation, and shutdown. */ - async emitToolSessionEvent( - reason: ToolSessionEvent["reason"], + async emitCustomToolSessionEvent( + reason: CustomToolSessionEvent["reason"], previousSessionFile?: string | undefined, ): Promise { - const event: ToolSessionEvent = { - entries: this.sessionManager.getEntries(), - sessionFile: this.sessionFile, - previousSessionFile, - reason, + if (!this._customTools) return; + + const event: CustomToolSessionEvent = { reason, previousSessionFile }; + const ctx: CustomToolContext = { + sessionManager: this.sessionManager, + modelRegistry: this._modelRegistry, + model: this.agent.state.model, }; + for (const { tool } of this._customTools) { if (tool.onSession) { try { - await tool.onSession(event); + await tool.onSession(event, ctx); } catch (_err) { // Silently ignore tool errors during session events } diff --git a/packages/coding-agent/src/core/compaction/branch-summarization.ts b/packages/coding-agent/src/core/compaction/branch-summarization.ts index 8bca45ff..d3b4f59d 100644 --- a/packages/coding-agent/src/core/compaction/branch-summarization.ts +++ b/packages/coding-agent/src/core/compaction/branch-summarization.ts @@ -102,8 +102,8 @@ export function collectEntriesForBranchSummary( } // Find common ancestor (deepest node that's on both paths) - const oldPath = new Set(session.getPath(oldLeafId).map((e) => e.id)); - const targetPath = session.getPath(targetId); + const oldPath = new Set(session.getBranch(oldLeafId).map((e) => e.id)); + const targetPath = session.getBranch(targetId); // targetPath is root-first, so iterate backwards to find deepest common ancestor let commonAncestorId: string | null = null; diff --git a/packages/coding-agent/src/core/custom-tools/index.ts b/packages/coding-agent/src/core/custom-tools/index.ts index d78b6858..adb0b705 100644 --- a/packages/coding-agent/src/core/custom-tools/index.ts +++ b/packages/coding-agent/src/core/custom-tools/index.ts @@ -4,14 +4,18 @@ export { discoverAndLoadCustomTools, loadCustomTools } from "./loader.js"; export type { + AgentToolResult, AgentToolUpdateCallback, - CustomAgentTool, + CustomTool, + CustomToolAPI, + CustomToolContext, CustomToolFactory, + CustomToolResult, + CustomToolSessionEvent, CustomToolsLoadResult, + CustomToolUIContext, ExecResult, LoadedCustomTool, RenderResultOptions, - SessionEvent, - ToolAPI, - ToolUIContext, } from "./types.js"; +export { wrapCustomTool, wrapCustomTools } from "./wrapper.js"; diff --git a/packages/coding-agent/src/core/custom-tools/loader.ts b/packages/coding-agent/src/core/custom-tools/loader.ts index f4480611..0c51ff95 100644 --- a/packages/coding-agent/src/core/custom-tools/loader.ts +++ b/packages/coding-agent/src/core/custom-tools/loader.ts @@ -17,7 +17,7 @@ import { getAgentDir, isBunBinary } from "../../config.js"; import type { ExecOptions } from "../exec.js"; import { execCommand } from "../exec.js"; import type { HookUIContext } from "../hooks/types.js"; -import type { CustomToolFactory, CustomToolsLoadResult, LoadedCustomTool, ToolAPI } from "./types.js"; +import type { CustomToolAPI, CustomToolFactory, CustomToolsLoadResult, LoadedCustomTool } from "./types.js"; // Create require function to resolve module paths at runtime const require = createRequire(import.meta.url); @@ -104,7 +104,7 @@ function createNoOpUIContext(): HookUIContext { */ async function loadToolWithBun( resolvedPath: string, - sharedApi: ToolAPI, + sharedApi: CustomToolAPI, ): Promise<{ tools: LoadedCustomTool[] | null; error: string | null }> { try { // Try to import directly - will work for tools without @mariozechner/* imports @@ -149,7 +149,7 @@ async function loadToolWithBun( async function loadTool( toolPath: string, cwd: string, - sharedApi: ToolAPI, + sharedApi: CustomToolAPI, ): Promise<{ tools: LoadedCustomTool[] | null; error: string | null }> { const resolvedPath = resolveToolPath(toolPath, cwd); @@ -209,7 +209,7 @@ export async function loadCustomTools( const seenNames = new Set(builtInToolNames); // Shared API object - all tools get the same instance - const sharedApi: ToolAPI = { + const sharedApi: CustomToolAPI = { cwd, exec: (command: string, args: string[], options?: ExecOptions) => execCommand(command, args, options?.cwd ?? cwd, options), diff --git a/packages/coding-agent/src/core/custom-tools/types.ts b/packages/coding-agent/src/core/custom-tools/types.ts index 7bf99407..dc9cfd75 100644 --- a/packages/coding-agent/src/core/custom-tools/types.ts +++ b/packages/coding-agent/src/core/custom-tools/types.ts @@ -5,45 +5,56 @@ * They can provide custom rendering for tool calls and results in the TUI. */ -import type { AgentTool, AgentToolResult, AgentToolUpdateCallback } from "@mariozechner/pi-agent-core"; +import type { AgentToolResult, AgentToolUpdateCallback } from "@mariozechner/pi-agent-core"; +import type { Model } from "@mariozechner/pi-ai"; import type { Component } from "@mariozechner/pi-tui"; import type { Static, TSchema } from "@sinclair/typebox"; import type { Theme } from "../../modes/interactive/theme/theme.js"; import type { ExecOptions, ExecResult } from "../exec.js"; import type { HookUIContext } from "../hooks/types.js"; -import type { SessionEntry } from "../session-manager.js"; +import type { ModelRegistry } from "../model-registry.js"; +import type { ReadonlySessionManager } from "../session-manager.js"; /** Alias for clarity */ -export type ToolUIContext = HookUIContext; +export type CustomToolUIContext = HookUIContext; /** Re-export for custom tools to use in execute signature */ -export type { AgentToolUpdateCallback }; +export type { AgentToolResult, AgentToolUpdateCallback }; // Re-export for backward compatibility export type { ExecOptions, ExecResult } from "../exec.js"; /** API passed to custom tool factory (stable across session changes) */ -export interface ToolAPI { +export interface CustomToolAPI { /** Current working directory */ cwd: string; /** Execute a command */ exec(command: string, args: string[], options?: ExecOptions): Promise; - /** UI methods for user interaction (select, confirm, input, notify) */ - ui: ToolUIContext; + /** UI methods for user interaction (select, confirm, input, notify, custom) */ + ui: CustomToolUIContext; /** Whether UI is available (false in print/RPC mode) */ hasUI: boolean; } +/** + * Context passed to tool execute and onSession callbacks. + * Provides access to session state and model information. + */ +export interface CustomToolContext { + /** Session manager (read-only) */ + sessionManager: ReadonlySessionManager; + /** Model registry - use for API key resolution and model retrieval */ + modelRegistry: ModelRegistry; + /** Current model (may be undefined if no model is selected yet) */ + model: Model | undefined; +} + /** Session event passed to onSession callback */ -export interface SessionEvent { - /** All session entries (including pre-compaction history) */ - entries: SessionEntry[]; - /** Current session file path, or undefined in --no-session mode */ - sessionFile: string | undefined; - /** Previous session file path, or undefined for "start", "new", and "shutdown" */ - previousSessionFile: string | undefined; +export interface CustomToolSessionEvent { /** Reason for the session event */ reason: "start" | "switch" | "branch" | "new" | "tree" | "shutdown"; + /** Previous session file path, or undefined for "start", "new", and "shutdown" */ + previousSessionFile: string | undefined; } /** Rendering options passed to renderResult */ @@ -54,58 +65,89 @@ export interface RenderResultOptions { isPartial: boolean; } +export type CustomToolResult = AgentToolResult; + /** - * Custom tool with optional lifecycle and rendering methods. + * Custom tool definition. * - * The execute signature inherited from AgentTool includes an optional onUpdate callback - * for streaming progress updates during long-running operations: - * - The callback emits partial results to subscribers (e.g. TUI/RPC), not to the LLM. - * - Partial updates should use the same TDetails type as the final result (use a union if needed). + * Custom tools are standalone - they don't extend AgentTool directly. + * When loaded, they are wrapped in an AgentTool for the agent to use. + * + * The execute callback receives a ToolContext with access to session state, + * model registry, and current model. * * @example * ```typescript - * type Details = - * | { status: "running"; step: number; total: number } - * | { status: "done"; count: number }; + * const factory: CustomToolFactory = (pi) => ({ + * name: "my_tool", + * label: "My Tool", + * description: "Does something useful", + * parameters: Type.Object({ input: Type.String() }), * - * async execute(toolCallId, params, signal, onUpdate) { - * const items = params.items || []; - * for (let i = 0; i < items.length; i++) { - * onUpdate?.({ - * content: [{ type: "text", text: `Step ${i + 1}/${items.length}...` }], - * details: { status: "running", step: i + 1, total: items.length }, - * }); - * await processItem(items[i], signal); + * async execute(toolCallId, params, signal, onUpdate, ctx) { + * // Access session state via ctx.sessionManager + * // Access model registry via ctx.modelRegistry + * // Current model via ctx.model + * return { content: [{ type: "text", text: "Done" }] }; + * }, + * + * onSession(event, ctx) { + * if (event.reason === "shutdown") { + * // Cleanup + * } + * // Reconstruct state from ctx.sessionManager.getEntries() * } - * return { content: [{ type: "text", text: "Done" }], details: { status: "done", count: items.length } }; - * } + * }); * ``` - * - * Progress updates are rendered via renderResult with isPartial: true. */ -export interface CustomAgentTool - extends AgentTool { +export interface CustomTool { + /** Tool name (used in LLM tool calls) */ + name: string; + /** Human-readable label for UI */ + label: string; + /** Description for LLM */ + description: string; + /** Parameter schema (TypeBox) */ + parameters: TParams; + + /** + * Execute the tool. + * @param toolCallId - Unique ID for this tool call + * @param params - Parsed parameters matching the schema + * @param signal - AbortSignal for cancellation + * @param onUpdate - Callback for streaming partial results (for UI, not LLM) + * @param ctx - Context with session manager, model registry, and current model + */ + execute( + toolCallId: string, + params: Static, + signal: AbortSignal | undefined, + onUpdate: AgentToolUpdateCallback | undefined, + ctx: CustomToolContext, + ): Promise>; + /** Called on session lifecycle events - use to reconstruct state or cleanup resources */ - onSession?: (event: SessionEvent) => void | Promise; + onSession?: (event: CustomToolSessionEvent, ctx: CustomToolContext) => void | Promise; /** Custom rendering for tool call display - return a Component */ renderCall?: (args: Static, theme: Theme) => Component; + /** Custom rendering for tool result display - return a Component */ - renderResult?: (result: AgentToolResult, options: RenderResultOptions, theme: Theme) => Component; + renderResult?: (result: CustomToolResult, options: RenderResultOptions, theme: Theme) => Component; } /** Factory function that creates a custom tool or array of tools */ export type CustomToolFactory = ( - pi: ToolAPI, -) => CustomAgentTool | CustomAgentTool[] | Promise; + pi: CustomToolAPI, +) => CustomTool | CustomTool[] | Promise | CustomTool[]>; -/** Loaded custom tool with metadata */ +/** Loaded custom tool with metadata and wrapped AgentTool */ export interface LoadedCustomTool { /** Original path (as specified) */ path: string; /** Resolved absolute path */ resolvedPath: string; - /** The tool instance */ - tool: CustomAgentTool; + /** The original custom tool instance */ + tool: CustomTool; } /** Result from loading custom tools */ @@ -113,5 +155,5 @@ export interface CustomToolsLoadResult { tools: LoadedCustomTool[]; errors: Array<{ path: string; error: string }>; /** Update the UI context for all loaded tools. Call when mode initializes. */ - setUIContext(uiContext: ToolUIContext, hasUI: boolean): void; + setUIContext(uiContext: CustomToolUIContext, hasUI: boolean): void; } diff --git a/packages/coding-agent/src/core/custom-tools/wrapper.ts b/packages/coding-agent/src/core/custom-tools/wrapper.ts new file mode 100644 index 00000000..253f6092 --- /dev/null +++ b/packages/coding-agent/src/core/custom-tools/wrapper.ts @@ -0,0 +1,28 @@ +/** + * Wraps CustomTool instances into AgentTool for use with the agent. + */ + +import type { AgentTool } from "@mariozechner/pi-agent-core"; +import type { CustomTool, CustomToolContext, LoadedCustomTool } from "./types.js"; + +/** + * Wrap a CustomTool into an AgentTool. + * The wrapper injects the ToolContext into execute calls. + */ +export function wrapCustomTool(tool: CustomTool, getContext: () => CustomToolContext): AgentTool { + return { + name: tool.name, + label: tool.label, + description: tool.description, + parameters: tool.parameters, + execute: (toolCallId, params, signal, onUpdate) => + tool.execute(toolCallId, params, signal, onUpdate, getContext()), + }; +} + +/** + * Wrap all loaded custom tools into AgentTools. + */ +export function wrapCustomTools(loadedTools: LoadedCustomTool[], getContext: () => CustomToolContext): AgentTool[] { + return loadedTools.map((lt) => wrapCustomTool(lt.tool, getContext)); +} diff --git a/packages/coding-agent/src/core/hooks/runner.ts b/packages/coding-agent/src/core/hooks/runner.ts index 04a7eae3..998e39b7 100644 --- a/packages/coding-agent/src/core/hooks/runner.ts +++ b/packages/coding-agent/src/core/hooks/runner.ts @@ -108,20 +108,12 @@ export class HookRunner { hasUI?: boolean; }): void { this.getModel = options.getModel; - this.setSendMessageHandler(options.sendMessageHandler); - this.setAppendEntryHandler(options.appendEntryHandler); - if (options.uiContext) { - this.setUIContext(options.uiContext, options.hasUI ?? false); + for (const hook of this.hooks) { + hook.setSendMessageHandler(options.sendMessageHandler); + hook.setAppendEntryHandler(options.appendEntryHandler); } - } - - /** - * Set the UI context for hooks. - * Call this when the mode initializes and UI is available. - */ - setUIContext(uiContext: HookUIContext, hasUI: boolean): void { - this.uiContext = uiContext; - this.hasUI = hasUI; + this.uiContext = options.uiContext ?? noOpUIContext; + this.hasUI = options.hasUI ?? false; } /** @@ -145,26 +137,6 @@ export class HookRunner { return this.hooks.map((h) => h.path); } - /** - * Set the send message handler for all hooks' pi.sendMessage(). - * Call this when the mode initializes. - */ - setSendMessageHandler(handler: SendMessageHandler): void { - for (const hook of this.hooks) { - hook.setSendMessageHandler(handler); - } - } - - /** - * Set the append entry handler for all hooks' pi.appendEntry(). - * Call this when the mode initializes. - */ - setAppendEntryHandler(handler: AppendEntryHandler): void { - for (const hook of this.hooks) { - hook.setAppendEntryHandler(handler); - } - } - /** * Subscribe to hook errors. * @returns Unsubscribe function diff --git a/packages/coding-agent/src/core/hooks/types.ts b/packages/coding-agent/src/core/hooks/types.ts index 2d3cd141..879b111c 100644 --- a/packages/coding-agent/src/core/hooks/types.ts +++ b/packages/coding-agent/src/core/hooks/types.ts @@ -13,27 +13,7 @@ import type { CompactionPreparation, CompactionResult } from "../compaction/inde import type { ExecOptions, ExecResult } from "../exec.js"; import type { HookMessage } from "../messages.js"; import type { ModelRegistry } from "../model-registry.js"; -import type { BranchSummaryEntry, CompactionEntry, SessionEntry, SessionManager } from "../session-manager.js"; - -/** - * Read-only view of SessionManager for hooks. - * Hooks should use pi.sendMessage() and pi.appendEntry() for writes. - */ -export type ReadonlySessionManager = Pick< - SessionManager, - | "getCwd" - | "getSessionDir" - | "getSessionId" - | "getSessionFile" - | "getLeafId" - | "getLeafEntry" - | "getEntry" - | "getLabel" - | "getPath" - | "getHeader" - | "getEntries" - | "getTree" ->; +import type { BranchSummaryEntry, CompactionEntry, ReadonlySessionManager, SessionEntry } from "../session-manager.js"; import type { EditToolDetails } from "../tools/edit.js"; import type { diff --git a/packages/coding-agent/src/core/index.ts b/packages/coding-agent/src/core/index.ts index 55e51d99..4b15f6fe 100644 --- a/packages/coding-agent/src/core/index.ts +++ b/packages/coding-agent/src/core/index.ts @@ -14,16 +14,16 @@ export { export { type BashExecutorOptions, type BashResult, executeBash } from "./bash-executor.js"; export type { CompactionResult } from "./compaction/index.js"; export { - type CustomAgentTool, + type CustomTool, + type CustomToolAPI, type CustomToolFactory, type CustomToolsLoadResult, + type CustomToolUIContext, discoverAndLoadCustomTools, type ExecResult, type LoadedCustomTool, loadCustomTools, type RenderResultOptions, - type ToolAPI, - type ToolUIContext, } from "./custom-tools/index.js"; export { type HookAPI, diff --git a/packages/coding-agent/src/core/sdk.ts b/packages/coding-agent/src/core/sdk.ts index 089691d3..d2779ced 100644 --- a/packages/coding-agent/src/core/sdk.ts +++ b/packages/coding-agent/src/core/sdk.ts @@ -35,8 +35,13 @@ import { join } from "path"; import { getAgentDir } from "../config.js"; import { AgentSession } from "./agent-session.js"; import { AuthStorage } from "./auth-storage.js"; -import { discoverAndLoadCustomTools, type LoadedCustomTool } from "./custom-tools/index.js"; -import type { CustomAgentTool } from "./custom-tools/types.js"; +import { + type CustomToolsLoadResult, + discoverAndLoadCustomTools, + type LoadedCustomTool, + wrapCustomTools, +} from "./custom-tools/index.js"; +import type { CustomTool } from "./custom-tools/types.js"; import { discoverAndLoadHooks, HookRunner, type LoadedHook, wrapToolsWithHooks } from "./hooks/index.js"; import type { HookFactory } from "./hooks/types.js"; import { convertToLlm } from "./messages.js"; @@ -99,7 +104,7 @@ export interface CreateAgentSessionOptions { /** Built-in tools to use. Default: codingTools [read, bash, edit, write] */ tools?: Tool[]; /** Custom tools (replaces discovery). */ - customTools?: Array<{ path?: string; tool: CustomAgentTool }>; + customTools?: Array<{ path?: string; tool: CustomTool }>; /** Additional custom tool paths to load (merged with discovery). */ additionalCustomToolPaths?: string[]; @@ -127,17 +132,14 @@ export interface CreateAgentSessionResult { /** The created session */ session: AgentSession; /** Custom tools result (for UI context setup in interactive mode) */ - customToolsResult: { - tools: LoadedCustomTool[]; - setUIContext: (uiContext: any, hasUI: boolean) => void; - }; + customToolsResult: CustomToolsLoadResult; /** Warning if session was restored with a different model than saved */ modelFallbackMessage?: string; } // Re-exports -export type { CustomAgentTool } from "./custom-tools/types.js"; +export type { CustomTool } from "./custom-tools/types.js"; export type { HookAPI, HookFactory } from "./hooks/types.js"; export type { Settings, SkillsSettings } from "./settings-manager.js"; export type { Skill } from "./skills.js"; @@ -219,7 +221,7 @@ export async function discoverHooks( export async function discoverCustomTools( cwd?: string, agentDir?: string, -): Promise> { +): Promise> { const resolvedCwd = cwd ?? process.cwd(); const resolvedAgentDir = agentDir ?? getDefaultAgentDir(); @@ -507,7 +509,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} const builtInTools = options.tools ?? createCodingTools(cwd); time("createCodingTools"); - let customToolsResult: { tools: LoadedCustomTool[]; setUIContext: (ctx: any, hasUI: boolean) => void }; + let customToolsResult: CustomToolsLoadResult; if (options.customTools !== undefined) { // Use provided custom tools const loadedTools: LoadedCustomTool[] = options.customTools.map((ct) => ({ @@ -517,17 +519,17 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} })); customToolsResult = { tools: loadedTools, + errors: [], setUIContext: () => {}, }; } else { // Discover custom tools, merging with additional paths const configuredPaths = [...settingsManager.getCustomToolPaths(), ...(options.additionalCustomToolPaths ?? [])]; - const result = await discoverAndLoadCustomTools(configuredPaths, cwd, Object.keys(allTools), agentDir); + customToolsResult = await discoverAndLoadCustomTools(configuredPaths, cwd, Object.keys(allTools), agentDir); time("discoverAndLoadCustomTools"); - for (const { path, error } of result.errors) { + for (const { path, error } of customToolsResult.errors) { console.error(`Failed to load custom tool "${path}": ${error}`); } - customToolsResult = result; } let hookRunner: HookRunner | undefined; @@ -549,7 +551,15 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} } } - let allToolsArray: Tool[] = [...builtInTools, ...customToolsResult.tools.map((lt) => lt.tool as unknown as Tool)]; + // Wrap custom tools with context getter (agent is assigned below, accessed at execute time) + let agent: Agent; + const wrappedCustomTools = wrapCustomTools(customToolsResult.tools, () => ({ + sessionManager, + modelRegistry, + model: agent.state.model, + })); + + let allToolsArray: Tool[] = [...builtInTools, ...wrappedCustomTools]; time("combineTools"); if (hookRunner) { allToolsArray = wrapToolsWithHooks(allToolsArray, hookRunner) as Tool[]; @@ -581,7 +591,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} const slashCommands = options.slashCommands ?? discoverSlashCommands(cwd, agentDir); time("discoverSlashCommands"); - const agent = new Agent({ + agent = new Agent({ initialState: { systemPrompt, model, diff --git a/packages/coding-agent/src/core/session-manager.ts b/packages/coding-agent/src/core/session-manager.ts index c3e17714..dc47f749 100644 --- a/packages/coding-agent/src/core/session-manager.ts +++ b/packages/coding-agent/src/core/session-manager.ts @@ -159,19 +159,21 @@ export interface SessionInfo { allMessagesText: string; } -/** - * Read-only interface for SessionManager. - * Used by compaction/summarization utilities that only need to read session data. - */ -export interface ReadonlySessionManager { - getLeafId(): string | null; - getEntry(id: string): SessionEntry | undefined; - getPath(fromId?: string): SessionEntry[]; - getEntries(): SessionEntry[]; - getChildren(parentId: string): SessionEntry[]; - getTree(): SessionTreeNode[]; - getLabel(id: string): string | undefined; -} +export type ReadonlySessionManager = Pick< + SessionManager, + | "getCwd" + | "getSessionDir" + | "getSessionId" + | "getSessionFile" + | "getLeafId" + | "getLeafEntry" + | "getEntry" + | "getLabel" + | "getBranch" + | "getHeader" + | "getEntries" + | "getTree" +>; /** Generate a unique short ID (8 hex chars, collision-checked) */ function generateId(byId: { has(id: string): boolean }): string { @@ -772,7 +774,7 @@ export class SessionManager { * Includes all entry types (messages, compaction, model changes, etc.). * Use buildSessionContext() to get the resolved messages for the LLM. */ - getPath(fromId?: string): SessionEntry[] { + getBranch(fromId?: string): SessionEntry[] { const path: SessionEntry[] = []; const startId = fromId ?? this.leafId; let current = startId ? this.byId.get(startId) : undefined; @@ -908,7 +910,7 @@ export class SessionManager { * Returns the new session file path, or undefined if not persisting. */ createBranchedSession(leafId: string): string | undefined { - const path = this.getPath(leafId); + const path = this.getBranch(leafId); if (path.length === 0) { throw new Error(`Entry ${leafId} not found`); } diff --git a/packages/coding-agent/src/index.ts b/packages/coding-agent/src/index.ts index 1dae4688..4efaacd4 100644 --- a/packages/coding-agent/src/index.ts +++ b/packages/coding-agent/src/index.ts @@ -35,15 +35,16 @@ export { // Custom tools export type { AgentToolUpdateCallback, - CustomAgentTool, + CustomTool, + CustomToolAPI, + CustomToolContext, CustomToolFactory, + CustomToolSessionEvent, CustomToolsLoadResult, + CustomToolUIContext, ExecResult, LoadedCustomTool, RenderResultOptions, - SessionEvent as ToolSessionEvent, - ToolAPI, - ToolUIContext, } from "./core/custom-tools/index.js"; export { discoverAndLoadCustomTools, loadCustomTools } from "./core/custom-tools/index.js"; export type * from "./core/hooks/index.js"; diff --git a/packages/coding-agent/src/modes/interactive/components/tool-execution.ts b/packages/coding-agent/src/modes/interactive/components/tool-execution.ts index 7124c84b..4f6bfac7 100644 --- a/packages/coding-agent/src/modes/interactive/components/tool-execution.ts +++ b/packages/coding-agent/src/modes/interactive/components/tool-execution.ts @@ -11,7 +11,7 @@ import { type TUI, } from "@mariozechner/pi-tui"; import stripAnsi from "strip-ansi"; -import type { CustomAgentTool } from "../../../core/custom-tools/types.js"; +import type { CustomTool } from "../../../core/custom-tools/types.js"; import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize } from "../../../core/tools/truncate.js"; import { getLanguageFromPath, highlightCode, theme } from "../theme/theme.js"; import { renderDiff } from "./diff.js"; @@ -55,7 +55,7 @@ export class ToolExecutionComponent extends Container { private expanded = false; private showImages: boolean; private isPartial = true; - private customTool?: CustomAgentTool; + private customTool?: CustomTool; private ui: TUI; private result?: { content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>; @@ -67,7 +67,7 @@ export class ToolExecutionComponent extends Container { toolName: string, args: any, options: ToolExecutionOptions = {}, - customTool: CustomAgentTool | undefined, + customTool: CustomTool | undefined, ui: TUI, ) { super(); diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index b6ea952d..809eca88 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -26,7 +26,7 @@ import { import { exec, spawnSync } from "child_process"; import { APP_NAME, getAuthPath, getDebugLogPath } from "../../config.js"; import type { AgentSession, AgentSessionEvent } from "../../core/agent-session.js"; -import type { LoadedCustomTool, SessionEvent as ToolSessionEvent } from "../../core/custom-tools/index.js"; +import type { CustomToolSessionEvent, LoadedCustomTool } from "../../core/custom-tools/index.js"; import type { HookUIContext } from "../../core/hooks/index.js"; import { createCompactionSummaryMessage } from "../../core/messages.js"; import { type SessionContext, SessionManager } from "../../core/session-manager.js"; @@ -350,19 +350,20 @@ export class InteractiveMode { this.chatContainer.addChild(new Spacer(1)); } - // Load session entries if any - const entries = this.session.sessionManager.getEntries(); - - // Set TUI-based UI context for custom tools - const uiContext = this.createHookUIContext(); + // Create and set hook & tool UI context + const uiContext: HookUIContext = { + select: (title, options) => this.showHookSelector(title, options), + confirm: (title, message) => this.showHookConfirm(title, message), + input: (title, placeholder) => this.showHookInput(title, placeholder), + notify: (message, type) => this.showHookNotify(message, type), + custom: (component) => this.showHookCustom(component), + }; this.setToolUIContext(uiContext, true); // Notify custom tools of session start - await this.emitToolSessionEvent({ - entries, - sessionFile: this.session.sessionFile, - previousSessionFile: undefined, + await this.emitCustomToolSessionEvent({ reason: "start", + previousSessionFile: undefined, }); const hookRunner = this.session.hookRunner; @@ -370,34 +371,35 @@ export class InteractiveMode { return; // No hooks loaded } - // Set UI context on hook runner - hookRunner.setUIContext(uiContext, true); + hookRunner.initialize({ + getModel: () => this.session.model, + sendMessageHandler: (message, triggerTurn) => { + const wasStreaming = this.session.isStreaming; + this.session + .sendHookMessage(message, triggerTurn) + .then(() => { + // For non-streaming cases with display=true, update UI + // (streaming cases update via message_end event) + if (!wasStreaming && message.display) { + this.rebuildChatFromMessages(); + } + }) + .catch((err) => { + this.showError(`Hook sendMessage failed: ${err instanceof Error ? err.message : String(err)}`); + }); + }, + appendEntryHandler: (customType, data) => { + this.sessionManager.appendCustomEntry(customType, data); + }, + uiContext, + hasUI: true, + }); // Subscribe to hook errors hookRunner.onError((error) => { this.showHookError(error.hookPath, error.error); }); - // Set up handlers for pi.sendMessage() and pi.appendEntry() - hookRunner.setSendMessageHandler((message, triggerTurn) => { - const wasStreaming = this.session.isStreaming; - this.session - .sendHookMessage(message, triggerTurn) - .then(() => { - // For non-streaming cases with display=true, update UI - // (streaming cases update via message_end event) - if (!wasStreaming && message.display) { - this.rebuildChatFromMessages(); - } - }) - .catch((err) => { - this.showError(`Hook sendMessage failed: ${err instanceof Error ? err.message : String(err)}`); - }); - }); - hookRunner.setAppendEntryHandler((customType, data) => { - this.sessionManager.appendCustomEntry(customType, data); - }); - // Show loaded hooks const hookPaths = hookRunner.getHookPaths(); if (hookPaths.length > 0) { @@ -415,11 +417,15 @@ export class InteractiveMode { /** * Emit session event to all custom tools. */ - private async emitToolSessionEvent(event: ToolSessionEvent): Promise { + private async emitCustomToolSessionEvent(event: CustomToolSessionEvent): Promise { for (const { tool } of this.customTools.values()) { if (tool.onSession) { try { - await tool.onSession(event); + await tool.onSession(event, { + sessionManager: this.session.sessionManager, + modelRegistry: this.session.modelRegistry, + model: this.session.model, + }); } catch (err) { this.showToolError(tool.name, err instanceof Error ? err.message : String(err)); } @@ -436,19 +442,6 @@ export class InteractiveMode { this.ui.requestRender(); } - /** - * Create the UI context for hooks. - */ - private createHookUIContext(): HookUIContext { - return { - select: (title, options) => this.showHookSelector(title, options), - confirm: (title, message) => this.showHookConfirm(title, message), - input: (title, placeholder) => this.showHookInput(title, placeholder), - notify: (message, type) => this.showHookNotify(message, type), - custom: (component) => this.showHookCustom(component), - }; - } - /** * Show a selector for hooks. */ @@ -861,6 +854,7 @@ export class InteractiveMode { this.customTools.get(content.name)?.tool, this.ui, ); + component.setExpanded(this.toolOutputExpanded); this.chatContainer.addChild(component); this.pendingTools.set(content.id, component); } else { @@ -909,6 +903,7 @@ export class InteractiveMode { this.customTools.get(event.toolName)?.tool, this.ui, ); + component.setExpanded(this.toolOutputExpanded); this.chatContainer.addChild(component); this.pendingTools.set(event.toolCallId, component); this.ui.requestRender(); @@ -1158,6 +1153,7 @@ export class InteractiveMode { this.customTools.get(content.name)?.tool, this.ui, ); + component.setExpanded(this.toolOutputExpanded); this.chatContainer.addChild(component); if (message.stopReason === "aborted" || message.stopReason === "error") { @@ -1251,7 +1247,7 @@ export class InteractiveMode { } // Emit shutdown event to custom tools - await this.session.emitToolSessionEvent("shutdown"); + await this.session.emitCustomToolSessionEvent("shutdown"); this.stop(); process.exit(0); diff --git a/packages/coding-agent/src/modes/print-mode.ts b/packages/coding-agent/src/modes/print-mode.ts index fbf3037a..b0aec7fa 100644 --- a/packages/coding-agent/src/modes/print-mode.ts +++ b/packages/coding-agent/src/modes/print-mode.ts @@ -26,25 +26,24 @@ export async function runPrintMode( initialMessage?: string, initialImages?: ImageContent[], ): Promise { - // Load entries once for session start events - const entries = session.sessionManager.getEntries(); - // Hook runner already has no-op UI context by default (set in main.ts) // Set up hooks for print mode (no UI) const hookRunner = session.hookRunner; if (hookRunner) { + hookRunner.initialize({ + getModel: () => session.model, + sendMessageHandler: (message, triggerTurn) => { + session.sendHookMessage(message, triggerTurn).catch((e) => { + console.error(`Hook sendMessage failed: ${e instanceof Error ? e.message : String(e)}`); + }); + }, + appendEntryHandler: (customType, data) => { + session.sessionManager.appendCustomEntry(customType, data); + }, + }); hookRunner.onError((err) => { console.error(`Hook error (${err.hookPath}): ${err.error}`); }); - // Set up handlers - sendHookMessage handles queuing/direct append as needed - hookRunner.setSendMessageHandler((message, triggerTurn) => { - session.sendHookMessage(message, triggerTurn).catch((e) => { - console.error(`Hook sendMessage failed: ${e instanceof Error ? e.message : String(e)}`); - }); - }); - hookRunner.setAppendEntryHandler((customType, data) => { - session.sessionManager.appendCustomEntry(customType, data); - }); // Emit session_start event await hookRunner.emit({ type: "session_start", @@ -55,12 +54,17 @@ export async function runPrintMode( for (const { tool } of session.customTools) { if (tool.onSession) { try { - await tool.onSession({ - entries, - sessionFile: session.sessionFile, - previousSessionFile: undefined, - reason: "start", - }); + await tool.onSession( + { + reason: "start", + previousSessionFile: undefined, + }, + { + sessionManager: session.sessionManager, + modelRegistry: session.modelRegistry, + model: session.model, + }, + ); } catch (_err) { // Silently ignore tool errors } diff --git a/packages/coding-agent/src/modes/rpc/rpc-mode.ts b/packages/coding-agent/src/modes/rpc/rpc-mode.ts index de378612..e9fabf2a 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-mode.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-mode.ts @@ -125,25 +125,25 @@ export async function runRpcMode(session: AgentSession): Promise { }, }); - // Load entries once for session start events - const entries = session.sessionManager.getEntries(); - // Set up hooks with RPC-based UI context const hookRunner = session.hookRunner; if (hookRunner) { - hookRunner.setUIContext(createHookUIContext(), false); + hookRunner.initialize({ + getModel: () => session.agent.state.model, + sendMessageHandler: (message, triggerTurn) => { + session.sendHookMessage(message, triggerTurn).catch((e) => { + output(error(undefined, "hook_send", e.message)); + }); + }, + appendEntryHandler: (customType, data) => { + session.sessionManager.appendCustomEntry(customType, data); + }, + uiContext: createHookUIContext(), + hasUI: false, + }); hookRunner.onError((err) => { output({ type: "hook_error", hookPath: err.hookPath, event: err.event, error: err.error }); }); - // Set up handlers for pi.sendMessage() and pi.appendEntry() - hookRunner.setSendMessageHandler((message, triggerTurn) => { - session.sendHookMessage(message, triggerTurn).catch((e) => { - output(error(undefined, "hook_send", e.message)); - }); - }); - hookRunner.setAppendEntryHandler((customType, data) => { - session.sessionManager.appendCustomEntry(customType, data); - }); // Emit session_start event await hookRunner.emit({ type: "session_start", @@ -155,12 +155,17 @@ export async function runRpcMode(session: AgentSession): Promise { for (const { tool } of session.customTools) { if (tool.onSession) { try { - await tool.onSession({ - entries, - sessionFile: session.sessionFile, - previousSessionFile: undefined, - reason: "start", - }); + await tool.onSession( + { + previousSessionFile: undefined, + reason: "start", + }, + { + sessionManager: session.sessionManager, + modelRegistry: session.modelRegistry, + model: session.model, + }, + ); } catch (_err) { // Silently ignore tool errors } diff --git a/packages/coding-agent/test/compaction-hooks-example.test.ts b/packages/coding-agent/test/compaction-hooks-example.test.ts index deceaeb5..64e6cec6 100644 --- a/packages/coding-agent/test/compaction-hooks-example.test.ts +++ b/packages/coding-agent/test/compaction-hooks-example.test.ts @@ -11,17 +11,11 @@ describe("Documentation example", () => { const exampleHook = (pi: HookAPI) => { pi.on("session_before_compact", async (event: SessionBeforeCompactEvent, ctx) => { // All these should be accessible on the event - const { preparation, branchEntries, signal } = event; + const { preparation, branchEntries } = event; // sessionManager, modelRegistry, and model come from ctx - const { sessionManager, modelRegistry, model } = ctx; - const { - messagesToSummarize, - turnPrefixMessages, - tokensBefore, - firstKeptEntryId, - isSplitTurn, - previousSummary, - } = preparation; + const { sessionManager, modelRegistry } = ctx; + const { messagesToSummarize, turnPrefixMessages, tokensBefore, firstKeptEntryId, isSplitTurn } = + preparation; // Verify types expect(Array.isArray(messagesToSummarize)).toBe(true); diff --git a/packages/coding-agent/test/compaction-hooks.test.ts b/packages/coding-agent/test/compaction-hooks.test.ts index b0b65511..d1fce41e 100644 --- a/packages/coding-agent/test/compaction-hooks.test.ts +++ b/packages/coding-agent/test/compaction-hooks.test.ts @@ -99,16 +99,19 @@ describe.skipIf(!API_KEY)("Compaction hooks", () => { const modelRegistry = new ModelRegistry(authStorage); hookRunner = new HookRunner(hooks, tempDir, sessionManager, modelRegistry); - hookRunner.setUIContext( - { + hookRunner.initialize({ + getModel: () => session.model, + sendMessageHandler: async () => {}, + appendEntryHandler: async () => {}, + uiContext: { select: async () => undefined, confirm: async () => false, input: async () => undefined, notify: () => {}, custom: () => ({ close: () => {}, requestRender: () => {} }), }, - false, - ); + hasUI: false, + }); session = new AgentSession({ agent, diff --git a/packages/coding-agent/test/session-manager/save-entry.test.ts b/packages/coding-agent/test/session-manager/save-entry.test.ts index 2a618986..45015321 100644 --- a/packages/coding-agent/test/session-manager/save-entry.test.ts +++ b/packages/coding-agent/test/session-manager/save-entry.test.ts @@ -42,7 +42,7 @@ describe("SessionManager.saveCustomEntry", () => { expect(customEntry.parentId).toBe(msgId); // Tree structure should be correct - const path = session.getPath(); + const path = session.getBranch(); expect(path).toHaveLength(3); expect(path[0].id).toBe(msgId); expect(path[1].id).toBe(customId); diff --git a/packages/coding-agent/test/session-manager/tree-traversal.test.ts b/packages/coding-agent/test/session-manager/tree-traversal.test.ts index 5fe7610a..fe244710 100644 --- a/packages/coding-agent/test/session-manager/tree-traversal.test.ts +++ b/packages/coding-agent/test/session-manager/tree-traversal.test.ts @@ -122,14 +122,14 @@ describe("SessionManager append and tree traversal", () => { describe("getPath", () => { it("returns empty array for empty session", () => { const session = SessionManager.inMemory(); - expect(session.getPath()).toEqual([]); + expect(session.getBranch()).toEqual([]); }); it("returns single entry path", () => { const session = SessionManager.inMemory(); const id = session.appendMessage(userMsg("hello")); - const path = session.getPath(); + const path = session.getBranch(); expect(path).toHaveLength(1); expect(path[0].id).toBe(id); }); @@ -142,7 +142,7 @@ describe("SessionManager append and tree traversal", () => { const id3 = session.appendThinkingLevelChange("high"); const id4 = session.appendMessage(userMsg("3")); - const path = session.getPath(); + const path = session.getBranch(); expect(path).toHaveLength(4); expect(path.map((e) => e.id)).toEqual([id1, id2, id3, id4]); }); @@ -155,7 +155,7 @@ describe("SessionManager append and tree traversal", () => { const _id3 = session.appendMessage(userMsg("3")); const _id4 = session.appendMessage(assistantMsg("4")); - const path = session.getPath(id2); + const path = session.getBranch(id2); expect(path).toHaveLength(2); expect(path.map((e) => e.id)).toEqual([id1, id2]); }); From 4c9c453646ce5d380a16cd40a9bb5ec9c68a8213 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Wed, 31 Dec 2025 12:10:37 +0100 Subject: [PATCH 003/124] Update CHANGELOG, README, and custom-tools.md for new CustomTool API - Add custom tools API rework to CHANGELOG breaking changes - Update docs/custom-tools.md with new types and signatures - Update README quick example with correct execute signature --- packages/coding-agent/CHANGELOG.md | 12 +++- packages/coding-agent/README.md | 7 ++- packages/coding-agent/docs/custom-tools.md | 65 ++++++++++++++-------- 3 files changed, 58 insertions(+), 26 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index c7f0aa00..1372460e 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -45,7 +45,17 @@ - **SessionManager**: - `getSessionFile()` now returns `string | undefined` (undefined for in-memory sessions) - **Themes**: Custom themes must add `selectedBg`, `customMessageBg`, `customMessageText`, `customMessageLabel` color tokens (50 total) -- **Custom tools**: `dispose()` method removed from `CustomAgentTool`. Use `onSession` with `reason: "shutdown"` instead for cleanup. `SessionEvent.reason` now includes `"shutdown"`. +- **Custom tools API**: + - `CustomAgentTool` renamed to `CustomTool` + - `ToolAPI` renamed to `CustomToolAPI` + - `ToolContext` renamed to `CustomToolContext` + - `ToolSessionEvent` renamed to `CustomToolSessionEvent` + - `execute()` signature changed: now takes `(toolCallId, params, signal, onUpdate, ctx: CustomToolContext)` + - `onSession()` signature changed: now takes `(event: CustomToolSessionEvent, ctx: CustomToolContext)` + - `CustomToolSessionEvent` simplified: only has `reason` and `previousSessionFile` (use `ctx.sessionManager.getBranch()` to get entries) + - `CustomToolContext` provides `sessionManager: ReadonlySessionManager`, `modelRegistry`, and `model` + - `dispose()` method removed - use `onSession` with `reason: "shutdown"` for cleanup + - `CustomToolFactory` return type changed to `CustomTool` for type compatibility - **Renamed exports**: - `messageTransformer` → `convertToLlm` - `SessionContext` alias `LoadedSession` removed (use `SessionContext` directly) diff --git a/packages/coding-agent/README.md b/packages/coding-agent/README.md index b9c13c83..e1b55bed 100644 --- a/packages/coding-agent/README.md +++ b/packages/coding-agent/README.md @@ -659,10 +659,11 @@ const factory: CustomToolFactory = (pi) => ({ name: Type.String({ description: "Name to greet" }), }), - async execute(toolCallId, params) { + async execute(toolCallId, params, signal, onUpdate, ctx) { + const { name } = params as { name: string }; return { - content: [{ type: "text", text: `Hello, ${params.name}!` }], - details: { greeted: params.name }, + content: [{ type: "text", text: `Hello, ${name}!` }], + details: { greeted: name }, }; }, }); diff --git a/packages/coding-agent/docs/custom-tools.md b/packages/coding-agent/docs/custom-tools.md index d10861b4..020c1414 100644 --- a/packages/coding-agent/docs/custom-tools.md +++ b/packages/coding-agent/docs/custom-tools.md @@ -36,10 +36,11 @@ const factory: CustomToolFactory = (pi) => ({ name: Type.String({ description: "Name to greet" }), }), - async execute(toolCallId, params) { + async execute(toolCallId, params, signal, onUpdate, ctx) { + const { name } = params as { name: string }; return { - content: [{ type: "text", text: `Hello, ${params.name}!` }], - details: { greeted: params.name }, + content: [{ type: "text", text: `Hello, ${name}!` }], + details: { greeted: name }, }; }, }); @@ -82,7 +83,7 @@ Custom tools can import from these packages (automatically resolved by pi): | Package | Purpose | |---------|---------| | `@sinclair/typebox` | Schema definitions (`Type.Object`, `Type.String`, etc.) | -| `@mariozechner/pi-coding-agent` | Types (`CustomToolFactory`, `ToolSessionEvent` (alias for `SessionEvent`), etc.) | +| `@mariozechner/pi-coding-agent` | Types (`CustomToolFactory`, `CustomTool`, `CustomToolContext`, etc.) | | `@mariozechner/pi-ai` | AI utilities (`StringEnum` for Google-compatible enums) | | `@mariozechner/pi-tui` | TUI components (`Text`, `Box`, etc. for custom rendering) | @@ -94,7 +95,12 @@ Node.js built-in modules (`node:fs`, `node:path`, etc.) are also available. import { Type } from "@sinclair/typebox"; import { StringEnum } from "@mariozechner/pi-ai"; import { Text } from "@mariozechner/pi-tui"; -import type { CustomToolFactory, ToolSessionEvent } from "@mariozechner/pi-coding-agent"; +import type { + CustomTool, + CustomToolContext, + CustomToolFactory, + CustomToolSessionEvent, +} from "@mariozechner/pi-coding-agent"; const factory: CustomToolFactory = (pi) => ({ name: "my_tool", @@ -106,9 +112,10 @@ const factory: CustomToolFactory = (pi) => ({ text: Type.Optional(Type.String()), }), - async execute(toolCallId, params, signal, onUpdate) { + async execute(toolCallId, params, signal, onUpdate, ctx) { // signal - AbortSignal for cancellation // onUpdate - Callback for streaming partial results + // ctx - CustomToolContext with sessionManager, modelRegistry, model return { content: [{ type: "text", text: "Result for LLM" }], details: { /* structured data for rendering */ }, @@ -116,12 +123,12 @@ const factory: CustomToolFactory = (pi) => ({ }, // Optional: Session lifecycle callback - onSession(event) { + onSession(event, ctx) { if (event.reason === "shutdown") { // Cleanup resources (close connections, save state, etc.) return; } - // Reconstruct state from entries for other events + // Reconstruct state from ctx.sessionManager.getBranch() }, // Optional: Custom rendering @@ -134,12 +141,12 @@ export default factory; **Important:** Use `StringEnum` from `@mariozechner/pi-ai` instead of `Type.Union`/`Type.Literal` for string enums. The latter doesn't work with Google's API. -## ToolAPI Object +## CustomToolAPI Object -The factory receives a `ToolAPI` object (named `pi` by convention): +The factory receives a `CustomToolAPI` object (named `pi` by convention): ```typescript -interface ToolAPI { +interface CustomToolAPI { cwd: string; // Current working directory exec(command: string, args: string[], options?: ExecOptions): Promise; ui: ToolUIContext; @@ -174,7 +181,7 @@ Always check `pi.hasUI` before using UI methods. Pass the `signal` from `execute` to `pi.exec` to support cancellation: ```typescript -async execute(toolCallId, params, signal) { +async execute(toolCallId, params, signal, onUpdate, ctx) { const result = await pi.exec("long-running-command", ["arg"], { signal }); if (result.killed) { return { content: [{ type: "text", text: "Cancelled" }] }; @@ -183,16 +190,28 @@ async execute(toolCallId, params, signal) { } ``` +## CustomToolContext + +The `execute` and `onSession` callbacks receive a `CustomToolContext`: + +```typescript +interface CustomToolContext { + sessionManager: ReadonlySessionManager; // Read-only access to session + modelRegistry: ModelRegistry; // For API key resolution + model: Model | undefined; // Current model (may be undefined) +} +``` + +Use `ctx.sessionManager.getBranch()` to get entries on the current branch for state reconstruction. + ## Session Lifecycle Tools can implement `onSession` to react to session changes: ```typescript -interface SessionEvent { - entries: SessionEntry[]; // All session entries - sessionFile: string | undefined; // Current session file (undefined with --no-session) - previousSessionFile: string | undefined; // Previous session file - reason: "start" | "switch" | "branch" | "new" | "tree"; +interface CustomToolSessionEvent { + reason: "start" | "switch" | "branch" | "new" | "tree" | "shutdown"; + previousSessionFile: string | undefined; } ``` @@ -218,9 +237,11 @@ const factory: CustomToolFactory = (pi) => { let items: string[] = []; // Reconstruct state from session entries - const reconstructState = (event: ToolSessionEvent) => { + const reconstructState = (event: CustomToolSessionEvent, ctx: CustomToolContext) => { + if (event.reason === "shutdown") return; + items = []; - for (const entry of event.entries) { + for (const entry of ctx.sessionManager.getBranch()) { if (entry.type !== "message") continue; const msg = entry.message; if (msg.role !== "toolResult") continue; @@ -241,7 +262,7 @@ const factory: CustomToolFactory = (pi) => { onSession: reconstructState, - async execute(toolCallId, params) { + async execute(toolCallId, params, signal, onUpdate, ctx) { // Modify items... items.push("new item"); @@ -363,7 +384,7 @@ If `renderCall` or `renderResult` is not defined or throws an error: ## Execute Function ```typescript -async execute(toolCallId, args, signal, onUpdate) { +async execute(toolCallId, args, signal, onUpdate, ctx) { // Type assertion for params (TypeBox schema doesn't flow through) const params = args as { action: "list" | "add"; text?: string }; @@ -395,7 +416,7 @@ const factory: CustomToolFactory = (pi) => { // Shared state let connection = null; - const handleSession = (event: ToolSessionEvent) => { + const handleSession = (event: CustomToolSessionEvent, ctx: CustomToolContext) => { if (event.reason === "shutdown") { connection?.close(); } From 19c4182c21da00594d99db1d5f5e77e84262adf6 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Wed, 31 Dec 2025 12:14:28 +0100 Subject: [PATCH 004/124] Reorder execute params: (toolCallId, params, onUpdate, ctx, signal?) Optional signal now at the end for cleaner API --- packages/coding-agent/CHANGELOG.md | 2 +- packages/coding-agent/README.md | 2 +- packages/coding-agent/docs/custom-tools.md | 10 +++++----- .../coding-agent/examples/custom-tools/hello/index.ts | 2 +- .../examples/custom-tools/question/index.ts | 2 +- .../examples/custom-tools/subagent/index.ts | 2 +- .../coding-agent/examples/custom-tools/todo/index.ts | 2 +- packages/coding-agent/src/core/custom-tools/types.ts | 6 +++--- packages/coding-agent/src/core/custom-tools/wrapper.ts | 2 +- 9 files changed, 15 insertions(+), 15 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 1372460e..ba5451bd 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -50,7 +50,7 @@ - `ToolAPI` renamed to `CustomToolAPI` - `ToolContext` renamed to `CustomToolContext` - `ToolSessionEvent` renamed to `CustomToolSessionEvent` - - `execute()` signature changed: now takes `(toolCallId, params, signal, onUpdate, ctx: CustomToolContext)` + - `execute()` signature changed: now takes `(toolCallId, params, onUpdate, ctx: CustomToolContext, signal?)` - `onSession()` signature changed: now takes `(event: CustomToolSessionEvent, ctx: CustomToolContext)` - `CustomToolSessionEvent` simplified: only has `reason` and `previousSessionFile` (use `ctx.sessionManager.getBranch()` to get entries) - `CustomToolContext` provides `sessionManager: ReadonlySessionManager`, `modelRegistry`, and `model` diff --git a/packages/coding-agent/README.md b/packages/coding-agent/README.md index e1b55bed..0289bb38 100644 --- a/packages/coding-agent/README.md +++ b/packages/coding-agent/README.md @@ -659,7 +659,7 @@ const factory: CustomToolFactory = (pi) => ({ name: Type.String({ description: "Name to greet" }), }), - async execute(toolCallId, params, signal, onUpdate, ctx) { + async execute(toolCallId, params, onUpdate, ctx, signal) { const { name } = params as { name: string }; return { content: [{ type: "text", text: `Hello, ${name}!` }], diff --git a/packages/coding-agent/docs/custom-tools.md b/packages/coding-agent/docs/custom-tools.md index 020c1414..68c4d01a 100644 --- a/packages/coding-agent/docs/custom-tools.md +++ b/packages/coding-agent/docs/custom-tools.md @@ -36,7 +36,7 @@ const factory: CustomToolFactory = (pi) => ({ name: Type.String({ description: "Name to greet" }), }), - async execute(toolCallId, params, signal, onUpdate, ctx) { + async execute(toolCallId, params, onUpdate, ctx, signal) { const { name } = params as { name: string }; return { content: [{ type: "text", text: `Hello, ${name}!` }], @@ -112,7 +112,7 @@ const factory: CustomToolFactory = (pi) => ({ text: Type.Optional(Type.String()), }), - async execute(toolCallId, params, signal, onUpdate, ctx) { + async execute(toolCallId, params, onUpdate, ctx, signal) { // signal - AbortSignal for cancellation // onUpdate - Callback for streaming partial results // ctx - CustomToolContext with sessionManager, modelRegistry, model @@ -181,7 +181,7 @@ Always check `pi.hasUI` before using UI methods. Pass the `signal` from `execute` to `pi.exec` to support cancellation: ```typescript -async execute(toolCallId, params, signal, onUpdate, ctx) { +async execute(toolCallId, params, onUpdate, ctx, signal) { const result = await pi.exec("long-running-command", ["arg"], { signal }); if (result.killed) { return { content: [{ type: "text", text: "Cancelled" }] }; @@ -262,7 +262,7 @@ const factory: CustomToolFactory = (pi) => { onSession: reconstructState, - async execute(toolCallId, params, signal, onUpdate, ctx) { + async execute(toolCallId, params, onUpdate, ctx, signal) { // Modify items... items.push("new item"); @@ -384,7 +384,7 @@ If `renderCall` or `renderResult` is not defined or throws an error: ## Execute Function ```typescript -async execute(toolCallId, args, signal, onUpdate, ctx) { +async execute(toolCallId, args, onUpdate, ctx, signal) { // Type assertion for params (TypeBox schema doesn't flow through) const params = args as { action: "list" | "add"; text?: string }; diff --git a/packages/coding-agent/examples/custom-tools/hello/index.ts b/packages/coding-agent/examples/custom-tools/hello/index.ts index c2bf07b8..e72e7f05 100644 --- a/packages/coding-agent/examples/custom-tools/hello/index.ts +++ b/packages/coding-agent/examples/custom-tools/hello/index.ts @@ -9,7 +9,7 @@ const factory: CustomToolFactory = (_pi) => ({ name: Type.String({ description: "Name to greet" }), }), - async execute(_toolCallId, params) { + async execute(_toolCallId, params, _onUpdate, _ctx, _signal) { const { name } = params as { name: string }; return { content: [{ type: "text", text: `Hello, ${name}!` }], diff --git a/packages/coding-agent/examples/custom-tools/question/index.ts b/packages/coding-agent/examples/custom-tools/question/index.ts index 6949efcc..e75e8c45 100644 --- a/packages/coding-agent/examples/custom-tools/question/index.ts +++ b/packages/coding-agent/examples/custom-tools/question/index.ts @@ -24,7 +24,7 @@ const factory: CustomToolFactory = (pi) => { description: "Ask the user a question and let them pick from options. Use when you need user input to proceed.", parameters: QuestionParams, - async execute(_toolCallId, params) { + async execute(_toolCallId, params, _onUpdate, _ctx, _signal) { if (!pi.hasUI) { return { content: [{ type: "text", text: "Error: UI not available (running in non-interactive mode)" }], diff --git a/packages/coding-agent/examples/custom-tools/subagent/index.ts b/packages/coding-agent/examples/custom-tools/subagent/index.ts index c9fd89e2..3361e6d6 100644 --- a/packages/coding-agent/examples/custom-tools/subagent/index.ts +++ b/packages/coding-agent/examples/custom-tools/subagent/index.ts @@ -433,7 +433,7 @@ const factory: CustomToolFactory = (pi) => { }, parameters: SubagentParams, - async execute(_toolCallId, params, signal, onUpdate, _ctx) { + async execute(_toolCallId, params, onUpdate, _ctx, signal) { const agentScope: AgentScope = params.agentScope ?? "user"; const discovery = discoverAgents(pi.cwd, agentScope); const agents = discovery.agents; diff --git a/packages/coding-agent/examples/custom-tools/todo/index.ts b/packages/coding-agent/examples/custom-tools/todo/index.ts index 6b4d1feb..a20bf3de 100644 --- a/packages/coding-agent/examples/custom-tools/todo/index.ts +++ b/packages/coding-agent/examples/custom-tools/todo/index.ts @@ -78,7 +78,7 @@ const factory: CustomToolFactory = (_pi) => { // Called on session start/switch/branch/clear onSession: reconstructState, - async execute(_toolCallId, params) { + async execute(_toolCallId, params, _onUpdate, _ctx, _signal) { switch (params.action) { case "list": return { diff --git a/packages/coding-agent/src/core/custom-tools/types.ts b/packages/coding-agent/src/core/custom-tools/types.ts index dc9cfd75..a4baf9df 100644 --- a/packages/coding-agent/src/core/custom-tools/types.ts +++ b/packages/coding-agent/src/core/custom-tools/types.ts @@ -84,7 +84,7 @@ export type CustomToolResult = AgentToolResult; * description: "Does something useful", * parameters: Type.Object({ input: Type.String() }), * - * async execute(toolCallId, params, signal, onUpdate, ctx) { + * async execute(toolCallId, params, onUpdate, ctx, signal) { * // Access session state via ctx.sessionManager * // Access model registry via ctx.modelRegistry * // Current model via ctx.model @@ -114,16 +114,16 @@ export interface CustomTool { * Execute the tool. * @param toolCallId - Unique ID for this tool call * @param params - Parsed parameters matching the schema - * @param signal - AbortSignal for cancellation * @param onUpdate - Callback for streaming partial results (for UI, not LLM) * @param ctx - Context with session manager, model registry, and current model + * @param signal - Optional abort signal for cancellation */ execute( toolCallId: string, params: Static, - signal: AbortSignal | undefined, onUpdate: AgentToolUpdateCallback | undefined, ctx: CustomToolContext, + signal?: AbortSignal, ): Promise>; /** Called on session lifecycle events - use to reconstruct state or cleanup resources */ diff --git a/packages/coding-agent/src/core/custom-tools/wrapper.ts b/packages/coding-agent/src/core/custom-tools/wrapper.ts index 253f6092..b24ee028 100644 --- a/packages/coding-agent/src/core/custom-tools/wrapper.ts +++ b/packages/coding-agent/src/core/custom-tools/wrapper.ts @@ -16,7 +16,7 @@ export function wrapCustomTool(tool: CustomTool, getContext: () => CustomToolCon description: tool.description, parameters: tool.parameters, execute: (toolCallId, params, signal, onUpdate) => - tool.execute(toolCallId, params, signal, onUpdate, getContext()), + tool.execute(toolCallId, params, onUpdate, getContext(), signal), }; } From 679343de55f464d6a9644aebdb4d2e982b0913cf Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Wed, 31 Dec 2025 12:32:08 +0100 Subject: [PATCH 005/124] Add compaction.md and rewrite hooks.md - New compaction.md covers auto-compaction and branch summarization - Explains cut points, split turns, data model, file tracking - Documents session_before_compact and session_before_tree hooks - Rewritten hooks.md matches actual API (separate event names) - Correct ctx.ui.custom() signature (returns handle, not callback) - Documents all session events including tree events - Adds sessionManager and modelRegistry usage - Updates all examples to use correct API --- packages/coding-agent/docs/compaction.md | 329 ++++++ packages/coding-agent/docs/hooks.md | 1320 +++++++--------------- 2 files changed, 759 insertions(+), 890 deletions(-) create mode 100644 packages/coding-agent/docs/compaction.md diff --git a/packages/coding-agent/docs/compaction.md b/packages/coding-agent/docs/compaction.md new file mode 100644 index 00000000..a1753a4e --- /dev/null +++ b/packages/coding-agent/docs/compaction.md @@ -0,0 +1,329 @@ +# Compaction & Branch Summarization + +LLMs have limited context windows. When conversations grow too long, pi uses compaction to summarize older content while preserving recent work. This page covers both auto-compaction and branch summarization. + +## Overview + +Pi has two summarization mechanisms: + +| Mechanism | Trigger | Purpose | +|-----------|---------|---------| +| Compaction | Context exceeds threshold, or `/compact` | Summarize old messages to free up context | +| Branch summarization | `/tree` navigation | Preserve context when switching branches | + +Both use the same structured summary format and track file operations cumulatively. + +## Compaction + +### When It Triggers + +Auto-compaction triggers when: + +``` +contextTokens > contextWindow - reserveTokens +``` + +By default, `reserveTokens` is 16384 tokens. This leaves room for the LLM's response. + +You can also trigger manually with `/compact [instructions]`, where optional instructions focus the summary. + +### How It Works + +1. **Find cut point**: Walk backwards from newest message, accumulating token estimates until `keepRecentTokens` (default 20k) is reached +2. **Extract messages**: Collect messages from previous compaction (or start) up to cut point +3. **Generate summary**: Call LLM to summarize with structured format +4. **Append entry**: Save `CompactionEntry` with summary and `firstKeptEntryId` +5. **Reload**: Session reloads, using summary + messages from `firstKeptEntryId` onwards + +``` +Before compaction: + + entry: 0 1 2 3 4 5 6 7 8 9 + ┌─────┬─────┬─────┬─────┬──────┬─────┬─────┬──────┬──────┬─────┐ + │ hdr │ usr │ ass │ tool │ usr │ ass │ tool │ tool │ ass │ tool│ + └─────┴─────┴─────┴──────┴─────┴─────┴──────┴──────┴─────┴─────┘ + └────────┬───────┘ └──────────────┬──────────────┘ + messagesToSummarize kept messages + ↑ + firstKeptEntryId (entry 4) + +After compaction (new entry appended): + + entry: 0 1 2 3 4 5 6 7 8 9 10 + ┌─────┬─────┬─────┬─────┬──────┬─────┬─────┬──────┬──────┬─────┬─────┐ + │ hdr │ usr │ ass │ tool │ usr │ ass │ tool │ tool │ ass │ tool│ cmp │ + └─────┴─────┴─────┴──────┴─────┴─────┴──────┴──────┴─────┴─────┴─────┘ + └──────────┬──────┘ └──────────────────────┬───────────────────┘ + not sent to LLM sent to LLM + ↑ + starts from firstKeptEntryId + +What the LLM sees: + + ┌────────┬─────────┬─────┬─────┬──────┬──────┬─────┬──────┐ + │ system │ summary │ usr │ ass │ tool │ tool │ ass │ tool │ + └────────┴─────────┴─────┴─────┴──────┴──────┴─────┴──────┘ + ↑ ↑ └─────────────────┬────────────────┘ + prompt from cmp messages from firstKeptEntryId +``` + +### Split Turns + +A "turn" starts with a user message and includes all assistant responses and tool calls until the next user message. Normally, compaction cuts at turn boundaries. + +When a single turn exceeds `keepRecentTokens`, the cut point lands mid-turn at an assistant message. This is a "split turn": + +``` +Split turn (one huge turn exceeds budget): + + entry: 0 1 2 3 4 5 6 7 8 + ┌─────┬─────┬─────┬──────┬─────┬──────┬──────┬─────┬──────┐ + │ hdr │ usr │ ass │ tool │ ass │ tool │ tool │ ass │ tool │ + └─────┴─────┴─────┴──────┴─────┴──────┴──────┴─────┴──────┘ + ↑ ↑ + turnStartIndex = 1 firstKeptEntryId = 7 + │ │ + └──── turnPrefixMessages (1-6) ───────┘ + └── kept (7-8) + + isSplitTurn = true + messagesToSummarize = [] (no complete turns before) + turnPrefixMessages = [usr, ass, tool, ass, tool, tool] +``` + +For split turns, pi generates two summaries and merges them: +1. **History summary**: Previous context (if any) +2. **Turn prefix summary**: The early part of the split turn + +### Cut Point Rules + +Valid cut points are: +- User messages +- Assistant messages +- BashExecution messages +- Hook messages (custom_message, branch_summary) + +Never cut at tool results (they must stay with their tool call). + +### CompactionEntry Structure + +```typescript +interface CompactionEntry { + type: "compaction"; + id: string; + parentId: string; + timestamp: number; + summary: string; + firstKeptEntryId: string; + tokensBefore: number; + fromHook?: boolean; + details?: CompactionDetails; +} + +interface CompactionDetails { + readFiles: string[]; + modifiedFiles: string[]; +} +``` + +## Branch Summarization + +### When It Triggers + +When you use `/tree` to navigate to a different branch, pi offers to summarize the work you're leaving. This preserves context so you can return later. + +### How It Works + +1. **Find common ancestor**: Deepest node shared by old and new positions +2. **Collect entries**: Walk from old leaf back to common ancestor +3. **Prepare with budget**: Include messages up to token budget (newest first) +4. **Generate summary**: Call LLM with structured format +5. **Append entry**: Save `BranchSummaryEntry` at navigation point + +``` +Tree before navigation: + + ┌─ B ─ C ─ D (old leaf, being abandoned) + A ───┤ + └─ E ─ F (target) + +Common ancestor: A +Entries to summarize: B, C, D + +After navigation with summary: + + ┌─ B ─ C ─ D ─ [summary of B,C,D] + A ───┤ + └─ E ─ F (new leaf) +``` + +### Cumulative File Tracking + +Branch summaries track files cumulatively. When generating a new summary, pi extracts file operations from: +- Tool calls in the messages being summarized +- Previous branch summary `details` (if any) + +This means nested summaries accumulate file tracking across the entire abandoned branch. + +### BranchSummaryEntry Structure + +```typescript +interface BranchSummaryEntry { + type: "branch_summary"; + id: string; + parentId: string; + timestamp: number; + summary: string; + fromId: string; // Entry we navigated from + fromHook?: boolean; + details?: BranchSummaryDetails; +} + +interface BranchSummaryDetails { + readFiles: string[]; + modifiedFiles: string[]; +} +``` + +## Summary Format + +Both compaction and branch summarization use the same structured format: + +```markdown +## Goal +[What the user is trying to accomplish] + +## Constraints & Preferences +- [Requirements mentioned by user] + +## Progress +### Done +- [x] [Completed tasks] + +### In Progress +- [ ] [Current work] + +### Blocked +- [Issues, if any] + +## Key Decisions +- **[Decision]**: [Rationale] + +## Next Steps +1. [What should happen next] + +## Critical Context +- [Data needed to continue] + + +path/to/file1.ts +path/to/file2.ts + + + +path/to/changed.ts + +``` + +### Message Serialization + +Before summarization, messages are serialized to text: + +``` +[User]: What they said +[Assistant thinking]: Internal reasoning +[Assistant]: Response text +[Assistant tool calls]: read(path="foo.ts"); edit(path="bar.ts", ...) +[Tool result]: Output from tool +``` + +This prevents the model from treating it as a conversation to continue. + +## Custom Summarization via Hooks + +Hooks can intercept and customize both compaction and branch summarization. + +### session_before_compact + +Fired before auto-compaction or `/compact`. Can cancel or provide custom summary. + +```typescript +pi.on("session_before_compact", async (event, ctx) => { + const { preparation, branchEntries, customInstructions, signal } = event; + + // preparation.messagesToSummarize - messages to summarize + // preparation.turnPrefixMessages - split turn prefix (if isSplitTurn) + // preparation.previousSummary - previous compaction summary + // preparation.fileOps - extracted file operations + // preparation.tokensBefore - context tokens before compaction + // preparation.firstKeptEntryId - where kept messages start + // preparation.settings - compaction settings + + // branchEntries - all entries on current branch (for custom state) + // signal - AbortSignal (pass to LLM calls) + + // Cancel: + return { cancel: true }; + + // Custom summary: + return { + compaction: { + summary: "Your summary...", + firstKeptEntryId: preparation.firstKeptEntryId, + tokensBefore: preparation.tokensBefore, + details: { /* custom data */ }, + } + }; +}); +``` + +See [examples/hooks/custom-compaction.ts](../examples/hooks/custom-compaction.ts) for a complete example using a different model. + +### session_before_tree + +Fired before `/tree` navigation with summarization. Can cancel or provide custom summary. + +```typescript +pi.on("session_before_tree", async (event, ctx) => { + const { preparation, signal } = event; + + // preparation.targetId - where we're navigating to + // preparation.oldLeafId - current position (being abandoned) + // preparation.commonAncestorId - shared ancestor + // preparation.entriesToSummarize - entries to summarize + // preparation.userWantsSummary - whether user chose to summarize + + // Cancel navigation: + return { cancel: true }; + + // Custom summary (only if userWantsSummary): + return { + summary: { + summary: "Your summary...", + details: { /* custom data */ }, + } + }; +}); +``` + +## Settings + +Configure compaction in `~/.pi/agent/settings.json`: + +```json +{ + "compaction": { + "enabled": true, + "reserveTokens": 16384, + "keepRecentTokens": 20000 + } +} +``` + +| Setting | Default | Description | +|---------|---------|-------------| +| `enabled` | `true` | Enable auto-compaction | +| `reserveTokens` | `16384` | Tokens to reserve for LLM response | +| `keepRecentTokens` | `20000` | Recent tokens to keep (not summarized) | + +Disable auto-compaction with `"enabled": false`. You can still compact manually with `/compact`. diff --git a/packages/coding-agent/docs/hooks.md b/packages/coding-agent/docs/hooks.md index 68274e29..8f65959d 100644 --- a/packages/coding-agent/docs/hooks.md +++ b/packages/coding-agent/docs/hooks.md @@ -1,108 +1,110 @@ # Hooks -Hooks are TypeScript modules that extend the coding agent's behavior by subscribing to lifecycle events. They can intercept tool calls, prompt the user for input, modify results, and more. +Hooks are TypeScript modules that extend pi's behavior by subscribing to lifecycle events. They can intercept tool calls, prompt the user, modify results, inject messages, and more. **Example use cases:** -- Block dangerous commands (permission gates for `rm -rf`, `sudo`, etc.) -- Checkpoint code state (git stash at each turn, restore on `/branch`) -- Protect paths (block writes to `.env`, `node_modules/`, etc.) -- Modify tool output (filter or transform results before the LLM sees them) -- Inject messages from external sources (file watchers, webhooks, CI systems) +- Block dangerous commands (permission gates for `rm -rf`, `sudo`) +- Checkpoint code state (git stash at each turn, restore on branch) +- Protect paths (block writes to `.env`, `node_modules/`) +- Inject messages from external sources (file watchers, webhooks) +- Custom slash commands and UI components See [examples/hooks/](../examples/hooks/) for working implementations. -## Hook Locations +## Quick Start -Hooks are automatically discovered from two locations: - -1. **Global hooks**: `~/.pi/agent/hooks/*.ts` -2. **Project hooks**: `/.pi/hooks/*.ts` - -All `.ts` files in these directories are loaded automatically. Project hooks let you define project-specific behavior (similar to `.pi/AGENTS.md`). - -You can also load a specific hook file directly using the `--hook` flag: - -```bash -pi --hook ./my-hook.ts -``` - -This is useful for testing hooks without placing them in the standard directories. - -### Additional Configuration - -You can also add explicit hook paths in `~/.pi/agent/settings.json`: - -```json -{ - "hooks": [ - "/path/to/custom/hook.ts" - ], - "hookTimeout": 30000 -} -``` - -- `hooks`: Additional hook file paths (supports `~` expansion) -- `hookTimeout`: Timeout in milliseconds for hook operations (default: 30000). Does not apply to `tool_call` events, which have no timeout since they may prompt the user. - -## Available Imports - -Hooks can import from these packages (automatically resolved by pi): - -| Package | Purpose | -|---------|---------| -| `@mariozechner/pi-coding-agent/hooks` | Hook types (`HookAPI`, etc.) | -| `@mariozechner/pi-coding-agent` | Additional types if needed | -| `@mariozechner/pi-ai` | AI utilities (`ToolResultMessage`, etc.) | -| `@mariozechner/pi-tui` | TUI components (for advanced use cases) | -| `@sinclair/typebox` | Schema definitions | - -Node.js built-in modules (`node:fs`, `node:path`, etc.) are also available. - -## Writing a Hook - -A hook is a TypeScript file that exports a default function. The function receives a `HookAPI` object used to subscribe to events. +Create `~/.pi/agent/hooks/my-hook.ts`: ```typescript import type { HookAPI } from "@mariozechner/pi-coding-agent/hooks"; export default function (pi: HookAPI) { - pi.on("session", async (event, ctx) => { - ctx.ui.notify(`Session ${event.reason}: ${ctx.sessionManager.getSessionFile() ?? "ephemeral"}`, "info"); + pi.on("session_start", async (_event, ctx) => { + ctx.ui.notify("Hook loaded!", "info"); + }); + + pi.on("tool_call", async (event, ctx) => { + if (event.toolName === "bash" && event.input.command?.includes("rm -rf")) { + const ok = await ctx.ui.confirm("Dangerous!", "Allow rm -rf?"); + if (!ok) return { block: true, reason: "Blocked by user" }; + } }); } ``` -### Setup - -Create a hooks directory: +Test with `--hook` flag: ```bash -# Global hooks -mkdir -p ~/.pi/agent/hooks - -# Or project-local hooks -mkdir -p .pi/hooks +pi --hook ./my-hook.ts ``` -Then create `.ts` files directly in these directories. Hooks are loaded using [jiti](https://github.com/unjs/jiti), so TypeScript works without compilation. The import from `@mariozechner/pi-coding-agent/hooks` resolves to the globally installed package automatically. +## Hook Locations + +Hooks are auto-discovered from: + +| Location | Scope | +|----------|-------| +| `~/.pi/agent/hooks/*.ts` | Global (all projects) | +| `.pi/hooks/*.ts` | Project-local | + +Additional paths via `settings.json`: + +```json +{ + "hooks": ["/path/to/hook.ts"], + "hookTimeout": 30000 +} +``` + +The `hookTimeout` (default 30s) applies to most events. `tool_call` has no timeout since it may prompt the user. + +## Available Imports + +| Package | Purpose | +|---------|---------| +| `@mariozechner/pi-coding-agent/hooks` | Hook types (`HookAPI`, `HookContext`, events) | +| `@mariozechner/pi-coding-agent` | Additional types if needed | +| `@mariozechner/pi-ai` | AI utilities | +| `@mariozechner/pi-tui` | TUI components | + +Node.js built-ins (`node:fs`, `node:path`, etc.) are also available. + +## Writing a Hook + +A hook exports a default function that receives `HookAPI`: + +```typescript +import type { HookAPI } from "@mariozechner/pi-coding-agent/hooks"; + +export default function (pi: HookAPI) { + // Subscribe to events + pi.on("event_name", async (event, ctx) => { + // Handle event + }); +} +``` + +Hooks are loaded via [jiti](https://github.com/unjs/jiti), so TypeScript works without compilation. ## Events -### Lifecycle +### Lifecycle Overview ``` pi starts │ - ├─► session (reason: "start") - │ - ▼ + └─► session_start + │ + ▼ user sends prompt ─────────────────────────────────────────┐ │ │ + ├─► before_agent_start (can inject message) │ ├─► agent_start │ │ │ │ ┌─── turn (repeats while LLM calls tools) ───┐ │ │ │ │ │ │ ├─► turn_start │ │ + │ ├─► context (can modify messages) │ │ │ │ │ │ │ │ LLM responds, may call tools: │ │ │ │ ├─► tool_call (can block) │ │ @@ -115,225 +117,232 @@ user sends prompt ──────────────────── │ user sends another prompt ◄────────────────────────────────┘ -user branches (/branch) - │ - ├─► session (reason: "before_branch", can cancel) - └─► session (reason: "branch", AFTER branch) +/new (new session) + ├─► session_before_new (can cancel) + └─► session_new -user switches session (/resume) - │ - ├─► session (reason: "before_switch", can cancel) - └─► session (reason: "switch", AFTER switch) +/resume (switch session) + ├─► session_before_switch (can cancel) + └─► session_switch -user starts new session (/new) - │ - ├─► session (reason: "before_new", can cancel) - └─► session (reason: "new", AFTER new session starts) +/branch + ├─► session_before_branch (can cancel) + └─► session_branch -context compaction (auto or /compact) - │ - ├─► session (reason: "before_compact", can cancel or provide custom summary) - └─► session (reason: "compact", AFTER compaction) +/compact or auto-compaction + ├─► session_before_compact (can cancel or customize) + └─► session_compact -user exits (double Ctrl+C or Ctrl+D) - │ - └─► session (reason: "shutdown") +/tree navigation + ├─► session_before_tree (can cancel or customize) + └─► session_tree + +exit (Ctrl+C, Ctrl+D) + └─► session_shutdown ``` -A **turn** is one LLM response plus any tool calls. Complex tasks loop through multiple turns until the LLM responds without calling tools. +### Session Events -### session +#### session_start -Fired on session lifecycle events. The `before_*` variants fire before the action and can be cancelled by returning `{ cancel: true }`. +Fired on initial session load. ```typescript -pi.on("session", async (event, ctx) => { - // Access session file: ctx.sessionManager.getSessionFile() (undefined with --no-session) - // event.previousSessionFile: string | undefined - previous session file (for switch events) - // event.reason: "start" | "before_switch" | "switch" | "before_new" | "new" | - // "before_branch" | "branch" | "before_compact" | "compact" | "shutdown" - // event.targetTurnIndex: number - only for "before_branch" and "branch" - - // Cancel a before_* action: - if (event.reason === "before_new") { - return { cancel: true }; - } - - // For before_branch only: create branch but skip conversation restore - // (useful for checkpoint hooks that restore files separately) - if (event.reason === "before_branch") { - return { skipConversationRestore: true }; - } +pi.on("session_start", async (_event, ctx) => { + ctx.ui.notify(`Session: ${ctx.sessionManager.getSessionFile() ?? "ephemeral"}`, "info"); }); ``` -**Reasons:** -- `start`: Initial session load on startup -- `before_switch` / `switch`: User switched sessions (`/resume`) -- `before_new` / `new`: User started a new session (`/new`) -- `before_branch` / `branch`: User branched the session (`/branch`) -- `before_compact` / `compact`: Context compaction (auto or `/compact`) -- `shutdown`: Process is exiting (double Ctrl+C, Ctrl+D, or SIGTERM) +#### session_before_switch / session_switch -For `before_branch` and `branch` events, `event.targetTurnIndex` contains the entry index being branched from. - -#### Custom Compaction - -The `before_compact` event lets you implement custom compaction strategies. Understanding the data model: - -**How default compaction works:** - -When context exceeds the threshold, pi finds a "cut point" that keeps recent turns (configurable via `settings.json` `compaction.keepRecentTokens`, default 20k): - -``` -Legend: - hdr = header usr = user message ass = assistant message - tool = tool result cmp = compaction entry bash = bashExecution -``` - -``` -Session entries (before compaction): - - index: 0 1 2 3 4 5 6 7 8 9 10 - ┌─────┬─────┬─────┬─────┬──────┬─────┬─────┬──────┬──────┬─────┬──────┐ - │ hdr │ cmp │ usr │ ass │ tool │ usr │ ass │ tool │ tool │ ass │ tool │ - └─────┴─────┴─────┴─────┴──────┴─────┴─────┴──────┴──────┴─────┴──────┘ - ↑ └───────┬───────┘ └────────────┬────────────┘ - previousSummary messagesToSummarize kept (firstKeptEntryId = "...") - ↑ - firstKeptEntryIndex = 5 - -After compaction (new entry appended): - - index: 0 1 2 3 4 5 6 7 8 9 10 11 - ┌─────┬─────┬─────┬─────┬──────┬─────┬─────┬──────┬──────┬─────┬──────┬─────┐ - │ hdr │ cmp │ usr │ ass │ tool │ usr │ ass │ tool │ tool │ ass │ tool │ cmp │ - └─────┴─────┴─────┴─────┴──────┴─────┴─────┴──────┴──────┴─────┴──────┴─────┘ - └──────────┬───────────┘ └────────────────────────┬─────────────────┘ - not sent to LLM sent to LLM - ↑ - firstKeptEntryId = "..." - (stored in new cmp) -``` - -The session file is append-only. When loading, the session loader finds the latest compaction entry, uses its summary, then loads messages starting from `firstKeptEntryIndex`. The cut point is always a user, assistant, or bashExecution message (never a tool result, which must stay with its tool call). - -``` -What gets sent to the LLM as context: - - 5 6 7 8 9 10 - ┌────────┬─────────┬─────┬─────┬──────┬──────┬─────┬──────┐ - │ system │ summary │ usr │ ass │ tool │ tool │ ass │ tool │ - └────────┴─────────┴─────┴─────┴──────┴──────┴─────┴──────┘ - ↑ └─────────────────┬────────────────┘ - from new cmp's messages from - summary firstKeptEntryIndex onwards -``` - -**Split turns:** When a single turn is too large, the cut point may land mid-turn at an assistant message. In this case `cutPoint.isSplitTurn = true`: - -``` -Split turn example (one huge turn that exceeds keepRecentTokens): - - index: 0 1 2 3 4 5 6 7 8 9 - ┌─────┬─────┬─────┬──────┬─────┬──────┬──────┬─────┬──────┬─────┐ - │ hdr │ usr │ ass │ tool │ ass │ tool │ tool │ ass │ tool │ ass │ - └─────┴─────┴─────┴──────┴─────┴──────┴──────┴─────┴──────┴─────┘ - ↑ ↑ - turnStartIndex = 1 firstKeptEntryIndex = 7 - │ │ (must be usr/ass/bash, not tool) - └──────── turnPrefixMessages ───────────┘ (idx 1-6, summarized separately) - └── kept messages (idx 7-9) - - isSplitTurn = true - messagesToSummarize = [] (no complete turns before this one) - turnPrefixMessages = [usr idx 1, ass idx 2, tool idx 3, ass idx 4, tool idx 5, tool idx 6] - -The default compaction generates TWO summaries that get merged: -1. History summary (previousSummary + messagesToSummarize) -2. Turn prefix summary (turnPrefixMessages) -``` - -See [src/core/compaction.ts](../src/core/compaction.ts) for the full implementation. - -**Event fields:** - -| Field | Description | -|-------|-------------| -| `preparation` | Compaction preparation with `firstKeptEntryId`, `messagesToSummarize`, `turnPrefixMessages`, `isSplitTurn`, `previousSummary`, `fileOps`, `tokensBefore`, `settings`. | -| `branchEntries` | All entries on current branch (root to leaf). Use to find previous compactions or hook state. | -| `customInstructions` | Optional focus for summary (from `/compact `). | -| `signal` | AbortSignal for cancellation. Pass to LLM calls and check periodically. | - -Access session entries via `ctx.sessionManager.getEntries()`, API keys via `ctx.modelRegistry.getApiKey(model)`, and the current model via `ctx.model`. - -Custom compaction hooks should honor the abort signal by passing it to `complete()` calls. This allows users to cancel compaction (e.g., via Ctrl+C during `/compact`). - -**Returning custom compaction:** +Fired when switching sessions via `/resume`. ```typescript -return { - compaction: { - summary: "Your summary...", - firstKeptEntryId: preparation.firstKeptEntryId, - tokensBefore: preparation.tokensBefore, - details: { /* optional hook-specific data */ }, - } -}; +pi.on("session_before_switch", async (event, ctx) => { + // event.targetSessionFile - session we're switching to + return { cancel: true }; // Cancel the switch +}); + +pi.on("session_switch", async (event, ctx) => { + // event.previousSessionFile - session we came from +}); ``` -The `details` field persists hook-specific metadata (e.g., artifact index, version markers) in the compaction entry. +#### session_before_new / session_new -See [examples/hooks/custom-compaction.ts](../examples/hooks/custom-compaction.ts) for a complete example. +Fired when starting a new session via `/new`. -**After compaction (`compact` event):** -- `event.compactionEntry`: The saved compaction entry -- `event.tokensBefore`: Token count before compaction -- `event.fromHook`: Whether the compaction entry was provided by a hook +```typescript +pi.on("session_before_new", async (_event, ctx) => { + const ok = await ctx.ui.confirm("Clear?", "Delete all messages?"); + if (!ok) return { cancel: true }; +}); -### agent_start / agent_end +pi.on("session_new", async (_event, ctx) => { + // New session started +}); +``` + +#### session_before_branch / session_branch + +Fired when branching via `/branch`. + +```typescript +pi.on("session_before_branch", async (event, ctx) => { + // event.entryIndex - entry index being branched from + + return { cancel: true }; // Cancel branch + // OR + return { skipConversationRestore: true }; // Branch but don't rewind messages +}); + +pi.on("session_branch", async (event, ctx) => { + // event.previousSessionFile - previous session file +}); +``` + +The `skipConversationRestore` option is useful for checkpoint hooks that restore code state separately. + +#### session_before_compact / session_compact + +Fired on compaction. See [compaction.md](compaction.md) for details. + +```typescript +pi.on("session_before_compact", async (event, ctx) => { + const { preparation, branchEntries, customInstructions, signal } = event; + + // Cancel: + return { cancel: true }; + + // Custom summary: + return { + compaction: { + summary: "...", + firstKeptEntryId: preparation.firstKeptEntryId, + tokensBefore: preparation.tokensBefore, + } + }; +}); + +pi.on("session_compact", async (event, ctx) => { + // event.compactionEntry - the saved compaction + // event.fromHook - whether hook provided it +}); +``` + +#### session_before_tree / session_tree + +Fired on `/tree` navigation. See [compaction.md](compaction.md) for details. + +```typescript +pi.on("session_before_tree", async (event, ctx) => { + const { preparation, signal } = event; + // preparation.targetId, oldLeafId, commonAncestorId, entriesToSummarize, userWantsSummary + + return { cancel: true }; + // OR (if userWantsSummary): + return { summary: { summary: "...", details: {} } }; +}); + +pi.on("session_tree", async (event, ctx) => { + // event.newLeafId, oldLeafId, summaryEntry, fromHook +}); +``` + +#### session_shutdown + +Fired on exit (Ctrl+C, Ctrl+D, SIGTERM). + +```typescript +pi.on("session_shutdown", async (_event, ctx) => { + // Cleanup, save state, etc. +}); +``` + +### Agent Events + +#### before_agent_start + +Fired after user submits prompt, before agent loop. Can inject a persistent message. + +```typescript +pi.on("before_agent_start", async (event, ctx) => { + // event.prompt - user's prompt text + // event.images - attached images (if any) + + return { + message: { + customType: "my-hook", + content: "Additional context for the LLM", + display: true, // Show in TUI + } + }; +}); +``` + +The injected message is persisted as `CustomMessageEntry` and sent to the LLM. + +#### agent_start / agent_end Fired once per user prompt. ```typescript -pi.on("agent_start", async (event, ctx) => {}); +pi.on("agent_start", async (_event, ctx) => {}); pi.on("agent_end", async (event, ctx) => { - // event.messages: AppMessage[] - new messages from this prompt + // event.messages - messages from this prompt }); ``` -### turn_start / turn_end +#### turn_start / turn_end -Fired for each turn within an agent loop. +Fired for each turn (one LLM response + tool calls). ```typescript pi.on("turn_start", async (event, ctx) => { - // event.turnIndex: number - // event.timestamp: number + // event.turnIndex, event.timestamp }); pi.on("turn_end", async (event, ctx) => { - // event.turnIndex: number - // event.message: AppMessage - assistant's response - // event.toolResults: ToolResultMessage[] - tool results from this turn + // event.turnIndex + // event.message - assistant's response + // event.toolResults - tool results from this turn }); ``` -### tool_call +#### context -Fired before tool executes. **Can block.** No timeout (user prompts can take any time). +Fired before each LLM call. Modify messages non-destructively (session unchanged). + +```typescript +pi.on("context", async (event, ctx) => { + // event.messages - deep copy, safe to modify + + // Filter or transform messages + const filtered = event.messages.filter(m => !shouldPrune(m)); + return { messages: filtered }; +}); +``` + +### Tool Events + +#### tool_call + +Fired before tool executes. **Can block.** No timeout. ```typescript pi.on("tool_call", async (event, ctx) => { - // event.toolName: string (built-in or custom tool name) - // event.toolCallId: string - // event.input: Record - return { block: true, reason: "..." }; // or undefined to allow + // event.toolName - "bash", "read", "write", "edit", etc. + // event.toolCallId + // event.input - tool parameters + + if (shouldBlock(event)) { + return { block: true, reason: "Not allowed" }; + } }); ``` -Built-in tool inputs: +Tool inputs: - `bash`: `{ command, timeout? }` - `read`: `{ path, offset?, limit? }` - `write`: `{ path, content }` @@ -342,584 +351,242 @@ Built-in tool inputs: - `find`: `{ pattern, path?, limit? }` - `grep`: `{ pattern, path?, glob?, ignoreCase?, literal?, context?, limit? }` -Custom tools are also intercepted with their own names and input schemas. - -### tool_result +#### tool_result Fired after tool executes. **Can modify result.** ```typescript pi.on("tool_result", async (event, ctx) => { - // event.toolName: string - // event.toolCallId: string - // event.input: Record - // event.content: (TextContent | ImageContent)[] - // event.details: tool-specific (see below) - // event.isError: boolean - - // Return modified content/details, or undefined to keep original - return { content: [...], details: {...} }; -}); -``` - -The event type is a discriminated union based on `toolName`. Use the provided type guards to narrow `details` to the correct type: - -```typescript -import { isBashToolResult, type HookAPI } from "@mariozechner/pi-coding-agent/hooks"; - -export default function (pi: HookAPI) { - pi.on("tool_result", async (event, ctx) => { - if (isBashToolResult(event)) { - // event.details is BashToolDetails | undefined - if (event.details?.truncation?.truncated) { - // Access full output from temp file - const fullPath = event.details.fullOutputPath; - } - } - }); -} -``` - -Available type guards: `isBashToolResult`, `isReadToolResult`, `isEditToolResult`, `isWriteToolResult`, `isGrepToolResult`, `isFindToolResult`, `isLsToolResult`. - -#### Tool Details Types - -Each built-in tool has a typed `details` field. Types are exported from `@mariozechner/pi-coding-agent`: - -| Tool | Details Type | Source | -|------|-------------|--------| -| `bash` | `BashToolDetails` | `src/core/tools/bash.ts` | -| `read` | `ReadToolDetails` | `src/core/tools/read.ts` | -| `edit` | `undefined` | - | -| `write` | `undefined` | - | -| `grep` | `GrepToolDetails` | `src/core/tools/grep.ts` | -| `find` | `FindToolDetails` | `src/core/tools/find.ts` | -| `ls` | `LsToolDetails` | `src/core/tools/ls.ts` | - -Common fields in details: -- `truncation?: TruncationResult` - present when output was truncated -- `fullOutputPath?: string` - path to temp file with full output (bash only) - -`TruncationResult` contains: -- `truncated: boolean` - whether truncation occurred -- `truncatedBy: "lines" | "bytes" | null` - which limit was hit -- `totalLines`, `totalBytes` - original size -- `outputLines`, `outputBytes` - truncated size - -Custom tools use `CustomToolResultEvent` with `details: unknown`. Create your own type guard to get full type safety: - -```typescript -import { - isBashToolResult, - type CustomToolResultEvent, - type HookAPI, - type ToolResultEvent, -} from "@mariozechner/pi-coding-agent/hooks"; - -interface MyCustomToolDetails { - someField: string; -} - -// Type guard that narrows both toolName and details -function isMyCustomToolResult(e: ToolResultEvent): e is CustomToolResultEvent & { - toolName: "my-custom-tool"; - details: MyCustomToolDetails; -} { - return e.toolName === "my-custom-tool"; -} - -export default function (pi: HookAPI) { - pi.on("tool_result", async (event, ctx) => { - // Built-in tool: use provided type guard - if (isBashToolResult(event)) { - if (event.details?.fullOutputPath) { - console.log(`Full output at: ${event.details.fullOutputPath}`); - } - } - - // Custom tool: use your own type guard - if (isMyCustomToolResult(event)) { - // event.details is now MyCustomToolDetails - console.log(event.details.someField); - } - }); -} -``` - -**Note:** If you modify `content`, you should also update `details` accordingly. The TUI uses `details` (e.g., truncation info) for rendering, so inconsistent values will cause display issues. - -### context - -Fired before each LLM call, allowing non-destructive message modification. The original session is not modified. - -```typescript -pi.on("context", async (event, ctx) => { - // event.messages: AgentMessage[] (deep copy, safe to modify) + // event.toolName, event.toolCallId, event.input + // event.content - array of TextContent | ImageContent + // event.details - tool-specific (see below) + // event.isError - // Return modified messages, or undefined to keep original - return { messages: modifiedMessages }; + // Modify result: + return { content: [...], details: {...}, isError: false }; }); ``` -Use case: Dynamic context pruning without modifying session history. +Use type guards for typed details: ```typescript -export default function (pi: HookAPI) { - pi.on("context", async (event, ctx) => { - // Find all pruning decisions stored as custom entries - const entries = ctx.sessionManager.getEntries(); - const pruningRules = entries - .filter(e => e.type === "custom" && e.customType === "prune-rules") - .flatMap(e => e.data as PruneRule[]); - - // Apply pruning to messages (e.g., truncate old tool results) - const prunedMessages = applyPruning(event.messages, pruningRules); - return { messages: prunedMessages }; - }); -} -``` +import { isBashToolResult } from "@mariozechner/pi-coding-agent/hooks"; -### before_agent_start - -Fired once when user submits a prompt, before `agent_start`. Allows injecting a message that gets persisted. - -```typescript -pi.on("before_agent_start", async (event, ctx) => { - // event.userMessage: the user's message - - // Return a message to inject, or undefined to skip - return { - message: { - customType: "context-injection", - content: "Additional context...", - display: true, // Show in TUI +pi.on("tool_result", async (event, ctx) => { + if (isBashToolResult(event)) { + // event.details is BashToolDetails | undefined + if (event.details?.truncation?.truncated) { + // Full output at event.details.fullOutputPath } - }; + } }); ``` -The injected message is: -- Persisted to session as a `CustomMessageEntry` -- Sent to the LLM as a user message -- Visible in TUI (if `display: true`) +Available guards: `isBashToolResult`, `isReadToolResult`, `isEditToolResult`, `isWriteToolResult`, `isGrepToolResult`, `isFindToolResult`, `isLsToolResult`. -## Context API +## HookContext -Every event handler receives a context object with these methods: +Every handler receives `ctx: HookContext`: -### ctx.ui.select(title, options) +### ctx.ui -Show a selector dialog. Returns the selected option or `null` if cancelled. +UI methods for user interaction: ```typescript -const choice = await ctx.ui.select("Pick one:", ["Option A", "Option B"]); -if (choice === "Option A") { - // ... -} -``` +// Select from options +const choice = await ctx.ui.select("Pick one:", ["A", "B", "C"]); +// Returns selected string or undefined if cancelled -### ctx.ui.confirm(title, message) +// Confirm dialog +const ok = await ctx.ui.confirm("Delete?", "This cannot be undone"); +// Returns true or false -Show a confirmation dialog. Returns `true` if confirmed, `false` otherwise. +// Text input +const name = await ctx.ui.input("Name:", "placeholder"); +// Returns string or undefined if cancelled -```typescript -const confirmed = await ctx.ui.confirm("Delete file?", "This cannot be undone."); -if (confirmed) { - // ... -} -``` +// Notification +ctx.ui.notify("Done!", "info"); // "info" | "warning" | "error" -### ctx.ui.input(title, placeholder?) - -Show a text input dialog. Returns the input string or `null` if cancelled. - -```typescript -const name = await ctx.ui.input("Enter name:", "default value"); -``` - -### ctx.ui.notify(message, type?) - -Show a notification. Type can be `"info"`, `"warning"`, or `"error"`. - -```typescript -ctx.ui.notify("Operation complete", "info"); -ctx.ui.notify("Something went wrong", "error"); -``` - -### ctx.ui.custom(component, done) - -Show a custom TUI component with keyboard focus. Call `done()` when finished. - -```typescript -import { Container, Text } from "@mariozechner/pi-tui"; - -const myComponent = new Container(0, 0, [ - new Text("Custom UI - press ESC to close", 0, 0), -]); - -ctx.ui.custom(myComponent, () => { - // Cleanup when component is dismissed -}); -``` - -See `examples/hooks/snake.ts` for a complete example with keyboard handling. - -### ctx.sessionManager - -Access to the session manager for reading session state. - -```typescript -const entries = ctx.sessionManager.getEntries(); -const path = ctx.sessionManager.getPath(); -const tree = ctx.sessionManager.getTree(); -const label = ctx.sessionManager.getLabel(entryId); -``` - -### ctx.modelRegistry - -Access to model registry for model discovery and API keys. - -```typescript -const apiKey = ctx.modelRegistry.getApiKey(model); -const models = ctx.modelRegistry.getAvailableModels(); -``` - -### ctx.cwd - -The current working directory. - -```typescript -console.log(`Working in: ${ctx.cwd}`); -``` - -### ctx.model - -The current model, or `undefined` if no model is selected yet. - -```typescript -if (ctx.model) { - const apiKey = ctx.modelRegistry.getApiKey(ctx.model); - // Use for LLM calls -} -``` - -### ctx.sessionManager.getSessionFile() - -Path to the current session file, or `undefined` when running with `--no-session` (ephemeral mode). - -```typescript -const sessionFile = ctx.sessionManager.getSessionFile(); -if (sessionFile) { - console.log(`Session: ${sessionFile}`); -} +// Custom component with keyboard focus +const handle = ctx.ui.custom(myComponent); +// Returns { close: () => void, requestRender: () => void } +// Component can implement handleInput(data: string) for keyboard +// Call handle.close() when done ``` ### ctx.hasUI -Whether interactive UI is available. `false` in print and RPC modes. +`false` in print mode (`-p`) and RPC mode. Always check before using `ctx.ui`: ```typescript if (ctx.hasUI) { - const choice = await ctx.ui.select("Pick:", ["A", "B"]); + const choice = await ctx.ui.select(...); } else { - // Fall back to default behavior + // Default behavior } ``` -## Hook API Methods +### ctx.cwd -The `pi` object provides methods for interacting with the agent: +Current working directory. + +### ctx.sessionManager + +Read-only access to session state: + +```typescript +// Get all entries (excludes header) +const entries = ctx.sessionManager.getEntries(); + +// Get current branch (root to leaf) +const branch = ctx.sessionManager.getBranch(); + +// Get specific entry by ID +const entry = ctx.sessionManager.getEntry(id); + +// Get session file (undefined with --no-session) +const file = ctx.sessionManager.getSessionFile(); + +// Get tree structure +const tree = ctx.sessionManager.getTree(); + +// Get entry label +const label = ctx.sessionManager.getLabel(entryId); +``` + +Use `pi.sendMessage()` or `pi.appendEntry()` for writes. + +### ctx.modelRegistry + +Access to models and API keys: + +```typescript +// Get API key for a model +const apiKey = await ctx.modelRegistry.getApiKey(model); + +// Get available models +const models = ctx.modelRegistry.getAvailableModels(); +``` + +### ctx.model + +Current model, or `undefined` if none selected yet. Use for LLM calls in hooks: + +```typescript +if (ctx.model) { + const apiKey = await ctx.modelRegistry.getApiKey(ctx.model); + // Use with @mariozechner/pi-ai complete() +} +``` + +## HookAPI Methods + +### pi.on(event, handler) + +Subscribe to events. See [Events](#events) for all event types. ### pi.sendMessage(message, triggerTurn?) -Inject a message into the session. Creates a `CustomMessageEntry` (not a user message). - -```typescript -pi.sendMessage(message: HookMessage, triggerTurn?: boolean): void - -// HookMessage structure: -interface HookMessage { - customType: string; // Your hook's identifier - content: string | (TextContent | ImageContent)[]; - display: boolean; // true = show in TUI, false = hidden - details?: unknown; // Hook-specific metadata (not sent to LLM) -} -``` - -- If `triggerTurn` is true (default), starts an agent turn after injecting -- If streaming, message is queued until current turn ends -- Messages are persisted to session and sent to LLM as user messages +Inject a message into the session. Creates `CustomMessageEntry` (participates in LLM context). ```typescript pi.sendMessage({ - customType: "my-hook", - content: "External trigger: build failed", - display: true, -}, true); // Trigger agent response + customType: "my-hook", // Your hook's identifier + content: "Message text", // string or (TextContent | ImageContent)[] + display: true, // Show in TUI + details: { ... }, // Optional metadata (not sent to LLM) +}, triggerTurn); // If true, triggers LLM response ``` ### pi.appendEntry(customType, data?) -Persist hook state to session. Does NOT participate in LLM context. +Persist hook state. Creates `CustomEntry` (does NOT participate in LLM context). ```typescript -pi.appendEntry(customType: string, data?: unknown): void -``` +// Save state +pi.appendEntry("my-hook-state", { count: 42 }); -Use for storing state that survives session reload. Scan entries on reload to reconstruct state: - -```typescript -pi.on("session", async (event, ctx) => { - if (event.reason === "start" || event.reason === "switch") { - const entries = ctx.sessionManager.getEntries(); - for (const entry of entries) { - if (entry.type === "custom" && entry.customType === "my-hook") { - // Reconstruct state from entry.data - } +// Restore on reload +pi.on("session_start", async (_event, ctx) => { + for (const entry of ctx.sessionManager.getEntries()) { + if (entry.type === "custom" && entry.customType === "my-hook-state") { + // Reconstruct from entry.data } } }); - -// Later, save state -pi.appendEntry("my-hook", { count: 42 }); ``` ### pi.registerCommand(name, options) -Register a custom slash command. - -```typescript -pi.registerCommand(name: string, options: { - description?: string; - handler: (args: string, ctx: HookContext) => Promise; -}): void -``` - -The handler receives: -- `args`: Everything after `/commandname` (e.g., `/stats foo` → `"foo"`) -- `ctx.ui`: UI methods (select, confirm, input, notify, custom) -- `ctx.hasUI`: Whether interactive UI is available -- `ctx.cwd`: Current working directory -- `ctx.model`: Current model (may be undefined) -- `ctx.sessionManager`: Session access -- `ctx.modelRegistry`: Model access +Register a custom slash command: ```typescript pi.registerCommand("stats", { description: "Show session statistics", handler: async (args, ctx) => { - const entries = ctx.sessionManager.getEntries(); - const messages = entries.filter(e => e.type === "message").length; - ctx.ui.notify(`${messages} messages in session`, "info"); + // args = everything after /stats + const count = ctx.sessionManager.getEntries().length; + ctx.ui.notify(`${count} entries`, "info"); } }); ``` -To prompt the LLM after a command, use `pi.sendMessage()` with `triggerTurn: true`. +To trigger LLM after command, call `pi.sendMessage(..., true)`. ### pi.registerMessageRenderer(customType, renderer) -Register a custom TUI renderer for `CustomMessageEntry` messages. - -```typescript -pi.registerMessageRenderer(customType: string, renderer: HookMessageRenderer): void - -type HookMessageRenderer = ( - message: HookMessage, - options: { expanded: boolean; width: number }, - theme: Theme -) => Component | null; -``` - -Return a TUI Component for the inner content. Pi wraps it in a styled box. +Custom TUI rendering for `CustomMessageEntry`: ```typescript import { Text } from "@mariozechner/pi-tui"; pi.registerMessageRenderer("my-hook", (message, options, theme) => { + // message.content, message.details + // options.expanded (user pressed Ctrl+O) return new Text(theme.fg("accent", `[MY-HOOK] ${message.content}`), 0, 0); }); ``` ### pi.exec(command, args, options?) -Execute a shell command. +Execute a shell command: ```typescript -const result = await pi.exec(command: string, args: string[], options?: { - signal?: AbortSignal; - timeout?: number; -}): Promise; +const result = await pi.exec("git", ["status"], { + signal, // AbortSignal + timeout, // Milliseconds +}); -interface ExecResult { - stdout: string; - stderr: string; - code: number; - killed?: boolean; // True if killed by signal/timeout -} +// result.stdout, result.stderr, result.code, result.killed ``` -```typescript -const result = await pi.exec("git", ["status"]); -if (result.code === 0) { - console.log(result.stdout); -} - -// With timeout -const result = await pi.exec("slow-command", [], { timeout: 5000 }); -if (result.killed) { - console.log("Command timed out"); -} -``` - -## Sending Messages (Examples) - -### Example: File Watcher - -```typescript -import * as fs from "node:fs"; -import type { HookAPI } from "@mariozechner/pi-coding-agent/hooks"; - -export default function (pi: HookAPI) { - pi.on("session", async (event, ctx) => { - if (event.reason !== "start") return; - - const triggerFile = "/tmp/agent-trigger.txt"; - - fs.watch(triggerFile, () => { - try { - const content = fs.readFileSync(triggerFile, "utf-8").trim(); - if (content) { - pi.sendMessage({ - customType: "file-trigger", - content: `External trigger: ${content}`, - display: true, - }, true); - fs.writeFileSync(triggerFile, ""); - } - } catch { - // File might not exist yet - } - }); - - ctx.ui.notify("Watching /tmp/agent-trigger.txt", "info"); - }); -} -``` - -### Example: HTTP Webhook - -```typescript -import * as http from "node:http"; -import type { HookAPI } from "@mariozechner/pi-coding-agent/hooks"; - -export default function (pi: HookAPI) { - pi.on("session", async (event, ctx) => { - if (event.reason !== "start") return; - - const server = http.createServer((req, res) => { - let body = ""; - req.on("data", chunk => body += chunk); - req.on("end", () => { - pi.sendMessage({ - customType: "webhook", - content: body || "Webhook triggered", - display: true, - }, true); - res.writeHead(200); - res.end("OK"); - }); - }); - - server.listen(3333, () => { - ctx.ui.notify("Webhook listening on http://localhost:3333", "info"); - }); - }); -} -``` - -**Note:** `pi.sendMessage()` is not supported in print mode (single-shot execution). - ## Examples -### Shitty Permission Gate +### Permission Gate ```typescript import type { HookAPI } from "@mariozechner/pi-coding-agent/hooks"; export default function (pi: HookAPI) { - const dangerousPatterns = [ - /\brm\s+(-rf?|--recursive)/i, - /\bsudo\b/i, - /\b(chmod|chown)\b.*777/i, - ]; + const dangerous = [/\brm\s+(-rf?|--recursive)/i, /\bsudo\b/i]; pi.on("tool_call", async (event, ctx) => { - if (event.toolName !== "bash") return undefined; + if (event.toolName !== "bash") return; - const command = event.input.command as string; - const isDangerous = dangerousPatterns.some((p) => p.test(command)); - - if (isDangerous) { - const choice = await ctx.ui.select( - `⚠️ Dangerous command:\n\n ${command}\n\nAllow?`, - ["Yes", "No"] - ); - - if (choice !== "Yes") { - return { block: true, reason: "Blocked by user" }; + const cmd = event.input.command as string; + if (dangerous.some(p => p.test(cmd))) { + if (!ctx.hasUI) { + return { block: true, reason: "Dangerous (no UI)" }; } + const ok = await ctx.ui.confirm("Dangerous!", `Allow: ${cmd}?`); + if (!ok) return { block: true, reason: "Blocked by user" }; } - - return undefined; }); } ``` -### Git Checkpointing - -Stash code state at each turn so `/branch` can restore it. - -```typescript -import type { HookAPI } from "@mariozechner/pi-coding-agent/hooks"; - -export default function (pi: HookAPI) { - const checkpoints = new Map(); - - pi.on("turn_start", async (event, ctx) => { - // Create a git stash entry before LLM makes changes - const { stdout } = await pi.exec("git", ["stash", "create"]); - const ref = stdout.trim(); - if (ref) { - checkpoints.set(event.turnIndex, ref); - } - }); - - pi.on("session", async (event, ctx) => { - // Only handle before_branch events - if (event.reason !== "before_branch") return; - - const ref = checkpoints.get(event.targetTurnIndex); - if (!ref) return; - - const choice = await ctx.ui.select("Restore code state?", [ - "Yes, restore code to that point", - "No, keep current code", - ]); - - if (choice?.startsWith("Yes")) { - await pi.exec("git", ["stash", "apply", ref]); - ctx.ui.notify("Code restored to checkpoint", "info"); - } - }); - - pi.on("agent_end", async () => { - checkpoints.clear(); - }); -} -``` - -### Block Writes to Certain Paths +### Protected Paths ```typescript import type { HookAPI } from "@mariozechner/pi-coding-agent/hooks"; @@ -928,196 +595,69 @@ export default function (pi: HookAPI) { const protectedPaths = [".env", ".git/", "node_modules/"]; pi.on("tool_call", async (event, ctx) => { - if (event.toolName !== "write" && event.toolName !== "edit") { - return undefined; - } + if (event.toolName !== "write" && event.toolName !== "edit") return; const path = event.input.path as string; - const isProtected = protectedPaths.some((p) => path.includes(p)); - - if (isProtected) { - ctx.ui.notify(`Blocked write to protected path: ${path}`, "warning"); - return { block: true, reason: `Path "${path}" is protected` }; + if (protectedPaths.some(p => path.includes(p))) { + ctx.ui.notify(`Blocked: ${path}`, "warning"); + return { block: true, reason: `Protected: ${path}` }; } - - return undefined; }); } ``` -### Custom Compaction - -Use a different model for summarization, or implement your own compaction strategy. - -See [examples/hooks/custom-compaction.ts](../examples/hooks/custom-compaction.ts) and the [Custom Compaction](#custom-compaction) section above for details. - -## Mode Behavior - -Hooks behave differently depending on the run mode: - -| Mode | UI Methods | Notes | -|------|-----------|-------| -| Interactive | Full TUI dialogs | User can interact normally | -| RPC | JSON protocol | Host application handles UI | -| Print (`-p`) | No-op (returns null/false) | Hooks run but can't prompt | - -In print mode, `select()` returns `null`, `confirm()` returns `false`, and `input()` returns `null`. Design hooks to handle these cases gracefully. - -## Error Handling - -- If a hook throws an error, it's logged and the agent continues -- If a `tool_call` hook throws an error, the tool is **blocked** (fail-safe) -- Other events have a timeout (default 30s); timeout errors are logged but don't block -- Hook errors are displayed in the UI with the hook path and error message - -## Debugging - -To debug a hook: - -1. Open VS Code in your hooks directory -2. Open a **JavaScript Debug Terminal** (Ctrl+Shift+P → "JavaScript Debug Terminal") -3. Set breakpoints in your hook file -4. Run `pi --hook ./my-hook.ts` in the debug terminal - -The `--hook` flag loads a hook directly without needing to modify `settings.json` or place files in the standard hook directories. - ---- - -# Internals - -## Discovery and Loading - -Hooks are discovered and loaded at startup in `main.ts`: - -``` -main.ts - -> discoverAndLoadHooks(configuredPaths, cwd) [loader.ts] - -> discoverHooksInDir(~/.pi/agent/hooks/) # global hooks - -> discoverHooksInDir(cwd/.pi/hooks/) # project hooks - -> merge with configuredPaths (deduplicated) - -> for each path: - -> jiti.import(path) # TypeScript support via jiti - -> hookFactory(hookAPI) # calls pi.on() to register handlers - -> returns LoadedHook { path, handlers: Map } -``` - -## Tool Wrapping - -Tools (built-in and custom) are wrapped with hook callbacks after tool discovery/selection, before the agent is created: - -``` -main.ts - -> wrapToolsWithHooks(tools, hookRunner) [tool-wrapper.ts] - -> returns new tools with wrapped execute() functions -``` - -The wrapped `execute()` function: - -1. Checks `hookRunner.hasHandlers("tool_call")` -2. If yes, calls `hookRunner.emitToolCall(event)` (no timeout) -3. If result has `block: true`, throws an error -4. Otherwise, calls the original `tool.execute()` -5. Checks `hookRunner.hasHandlers("tool_result")` -6. If yes, calls `hookRunner.emit(event)` (with timeout) -7. Returns (possibly modified) result - -## HookRunner - -The `HookRunner` class manages hook execution: +### Git Checkpoint ```typescript -class HookRunner { - constructor(hooks: LoadedHook[], cwd: string, timeout?: number) +import type { HookAPI } from "@mariozechner/pi-coding-agent/hooks"; - setUIContext(ctx: HookUIContext, hasUI: boolean): void - setSessionFile(path: string | null): void - onError(listener): () => void - hasHandlers(eventType: string): boolean - emit(event: HookEvent): Promise - emitToolCall(event: ToolCallEvent): Promise +export default function (pi: HookAPI) { + const checkpoints = new Map(); + + pi.on("turn_start", async (event) => { + const { stdout } = await pi.exec("git", ["stash", "create"]); + if (stdout.trim()) checkpoints.set(event.turnIndex, stdout.trim()); + }); + + pi.on("session_before_branch", async (event, ctx) => { + const ref = checkpoints.get(event.entryIndex); + if (!ref || !ctx.hasUI) return; + + const ok = await ctx.ui.confirm("Restore?", "Restore code to checkpoint?"); + if (ok) { + await pi.exec("git", ["stash", "apply", ref]); + ctx.ui.notify("Code restored", "info"); + } + }); + + pi.on("agent_end", () => checkpoints.clear()); } ``` -Key behaviors: -- `emit()` has a timeout (default 30s) for safety -- `emitToolCall()` has **no timeout** (user prompts can take any time) -- Errors in `emit()` are caught, logged via `onError()`, and execution continues -- Errors in `emitToolCall()` propagate, causing the tool to be blocked (fail-safe) +### Custom Command -## Event Flow +See [examples/hooks/snake.ts](../examples/hooks/snake.ts) for a complete example with `registerCommand()`, `ui.custom()`, and session persistence. -``` -Mode initialization: - -> hookRunner.setUIContext(ctx, hasUI) - -> hookRunner.setSessionFile(path) - -> hookRunner.emit({ type: "session", reason: "start", ... }) +## Mode Behavior -User sends prompt: - -> AgentSession.prompt() - -> hookRunner.emit({ type: "agent_start" }) - -> hookRunner.emit({ type: "turn_start", turnIndex }) - -> agent loop: - -> LLM generates tool calls - -> For each tool call: - -> wrappedTool.execute() - -> hookRunner.emitToolCall({ type: "tool_call", ... }) - -> [if not blocked] originalTool.execute() - -> hookRunner.emit({ type: "tool_result", ... }) - -> LLM generates response - -> hookRunner.emit({ type: "turn_end", ... }) - -> [repeat if more tool calls] - -> hookRunner.emit({ type: "agent_end", messages }) +| Mode | UI Methods | Notes | +|------|-----------|-------| +| Interactive | Full TUI | Normal operation | +| RPC | JSON protocol | Host handles UI | +| Print (`-p`) | No-op (returns null/false) | Hooks run but can't prompt | -Branch: - -> AgentSession.branch() - -> hookRunner.emit({ type: "session", reason: "before_branch", ... }) # can cancel - -> [if not cancelled: branch happens] - -> hookRunner.emit({ type: "session", reason: "branch", ... }) +In print mode, `select()` returns `undefined`, `confirm()` returns `false`, `input()` returns `undefined`. Design hooks to handle this. -Session switch: - -> AgentSession.switchSession() - -> hookRunner.emit({ type: "session", reason: "before_switch", ... }) # can cancel - -> [if not cancelled: switch happens] - -> hookRunner.emit({ type: "session", reason: "switch", ... }) +## Error Handling -Clear: - -> AgentSession.reset() - -> hookRunner.emit({ type: "session", reason: "before_new", ... }) # can cancel - -> [if not cancelled: new session starts] - -> hookRunner.emit({ type: "session", reason: "new", ... }) +- Hook errors are logged, agent continues +- `tool_call` errors block the tool (fail-safe) +- Timeout errors (default 30s) are logged but don't block +- Errors display in UI with hook path and message -Shutdown (interactive mode): - -> handleCtrlC() or handleCtrlD() - -> hookRunner.emit({ type: "session", reason: "shutdown", ... }) - -> process.exit(0) -``` +## Debugging -## UI Context by Mode - -Each mode provides its own `HookUIContext` implementation: - -**Interactive Mode** (`interactive-mode.ts`): -- `select()` -> `HookSelectorComponent` (TUI list selector) -- `confirm()` -> `HookSelectorComponent` with Yes/No options -- `input()` -> `HookInputComponent` (TUI text input) -- `notify()` -> Adds text to chat container - -**RPC Mode** (`rpc-mode.ts`): -- All methods send JSON requests via stdout -- Waits for JSON responses via stdin -- Host application renders UI and sends responses - -**Print Mode** (`print-mode.ts`): -- All methods return null/false immediately -- `notify()` is a no-op - -## File Structure - -``` -packages/coding-agent/src/core/hooks/ -├── index.ts # Public exports -├── types.ts # Event types, HookAPI, contexts -├── loader.ts # jiti-based hook loading -├── runner.ts # HookRunner class -└── tool-wrapper.ts # Tool wrapping for interception -``` +1. Open VS Code in hooks directory +2. Open JavaScript Debug Terminal (Ctrl+Shift+P → "JavaScript Debug Terminal") +3. Set breakpoints +4. Run `pi --hook ./my-hook.ts` From dfc63a7bac276e9fa98d114adc5959584a3e4b05 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Wed, 31 Dec 2025 12:35:45 +0100 Subject: [PATCH 006/124] Note settings.json for compaction settings in compaction.md --- packages/coding-agent/docs/compaction.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/coding-agent/docs/compaction.md b/packages/coding-agent/docs/compaction.md index a1753a4e..168ce198 100644 --- a/packages/coding-agent/docs/compaction.md +++ b/packages/coding-agent/docs/compaction.md @@ -23,13 +23,13 @@ Auto-compaction triggers when: contextTokens > contextWindow - reserveTokens ``` -By default, `reserveTokens` is 16384 tokens. This leaves room for the LLM's response. +By default, `reserveTokens` is 16384 tokens (configurable in `~/.pi/agent/settings.json`). This leaves room for the LLM's response. You can also trigger manually with `/compact [instructions]`, where optional instructions focus the summary. ### How It Works -1. **Find cut point**: Walk backwards from newest message, accumulating token estimates until `keepRecentTokens` (default 20k) is reached +1. **Find cut point**: Walk backwards from newest message, accumulating token estimates until `keepRecentTokens` (default 20k, configurable in `~/.pi/agent/settings.json`) is reached 2. **Extract messages**: Collect messages from previous compaction (or start) up to cut point 3. **Generate summary**: Call LLM to summarize with structured format 4. **Append entry**: Save `CompactionEntry` with summary and `firstKeptEntryId` From 67af9d707fdffdef8c652a238a5fc74caec385b1 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Wed, 31 Dec 2025 12:36:28 +0100 Subject: [PATCH 007/124] Clarify that compaction/branch details are hook-customizable --- packages/coding-agent/docs/compaction.md | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/packages/coding-agent/docs/compaction.md b/packages/coding-agent/docs/compaction.md index 168ce198..65b61701 100644 --- a/packages/coding-agent/docs/compaction.md +++ b/packages/coding-agent/docs/compaction.md @@ -108,7 +108,7 @@ Never cut at tool results (they must stay with their tool call). ### CompactionEntry Structure ```typescript -interface CompactionEntry { +interface CompactionEntry { type: "compaction"; id: string; parentId: string; @@ -116,16 +116,20 @@ interface CompactionEntry { summary: string; firstKeptEntryId: string; tokensBefore: number; - fromHook?: boolean; - details?: CompactionDetails; + fromHook?: boolean; // true if hook provided the compaction + details?: T; // hook-specific data } +// Default compaction uses this for details: interface CompactionDetails { readFiles: string[]; modifiedFiles: string[]; } ``` +Hooks can store any JSON-serializable data in `details`. The default compaction tracks file operations, but custom compaction hooks can use their own structure. +``` + ## Branch Summarization ### When It Triggers @@ -168,23 +172,26 @@ This means nested summaries accumulate file tracking across the entire abandoned ### BranchSummaryEntry Structure ```typescript -interface BranchSummaryEntry { +interface BranchSummaryEntry { type: "branch_summary"; id: string; parentId: string; timestamp: number; summary: string; - fromId: string; // Entry we navigated from - fromHook?: boolean; - details?: BranchSummaryDetails; + fromId: string; // Entry we navigated from + fromHook?: boolean; // true if hook provided the summary + details?: T; // hook-specific data } +// Default branch summarization uses this for details: interface BranchSummaryDetails { readFiles: string[]; modifiedFiles: string[]; } ``` +Same as compaction, hooks can store custom data in `details`. + ## Summary Format Both compaction and branch summarization use the same structured format: From d103af4ca2b5c3f58cfddd209368479cb69e6e29 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Wed, 31 Dec 2025 12:38:25 +0100 Subject: [PATCH 008/124] Fix: cumulative file tracking applies to both compaction and branch summarization --- packages/coding-agent/docs/compaction.md | 23 +++++++++++------------ 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/packages/coding-agent/docs/compaction.md b/packages/coding-agent/docs/compaction.md index 65b61701..d8f09b9c 100644 --- a/packages/coding-agent/docs/compaction.md +++ b/packages/coding-agent/docs/compaction.md @@ -128,13 +128,12 @@ interface CompactionDetails { ``` Hooks can store any JSON-serializable data in `details`. The default compaction tracks file operations, but custom compaction hooks can use their own structure. -``` ## Branch Summarization ### When It Triggers -When you use `/tree` to navigate to a different branch, pi offers to summarize the work you're leaving. This preserves context so you can return later. +When you use `/tree` to navigate to a different branch, pi offers to summarize the work you're leaving. This injects context from the left branch into the new branch. ### How It Works @@ -163,11 +162,11 @@ After navigation with summary: ### Cumulative File Tracking -Branch summaries track files cumulatively. When generating a new summary, pi extracts file operations from: +Both compaction and branch summarization track files cumulatively. When generating a summary, pi extracts file operations from: - Tool calls in the messages being summarized -- Previous branch summary `details` (if any) +- Previous compaction or branch summary `details` (if any) -This means nested summaries accumulate file tracking across the entire abandoned branch. +This means file tracking accumulates across multiple compactions or nested branch summaries, preserving the full history of read and modified files. ### BranchSummaryEntry Structure @@ -257,7 +256,7 @@ Fired before auto-compaction or `/compact`. Can cancel or provide custom summary ```typescript pi.on("session_before_compact", async (event, ctx) => { const { preparation, branchEntries, customInstructions, signal } = event; - + // preparation.messagesToSummarize - messages to summarize // preparation.turnPrefixMessages - split turn prefix (if isSplitTurn) // preparation.previousSummary - previous compaction summary @@ -265,13 +264,13 @@ pi.on("session_before_compact", async (event, ctx) => { // preparation.tokensBefore - context tokens before compaction // preparation.firstKeptEntryId - where kept messages start // preparation.settings - compaction settings - + // branchEntries - all entries on current branch (for custom state) // signal - AbortSignal (pass to LLM calls) - + // Cancel: return { cancel: true }; - + // Custom summary: return { compaction: { @@ -293,16 +292,16 @@ Fired before `/tree` navigation with summarization. Can cancel or provide custom ```typescript pi.on("session_before_tree", async (event, ctx) => { const { preparation, signal } = event; - + // preparation.targetId - where we're navigating to // preparation.oldLeafId - current position (being abandoned) // preparation.commonAncestorId - shared ancestor // preparation.entriesToSummarize - entries to summarize // preparation.userWantsSummary - whether user chose to summarize - + // Cancel navigation: return { cancel: true }; - + // Custom summary (only if userWantsSummary): return { summary: { From 57b066f135dbc803d1f78ad48e08db3b348819a4 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Wed, 31 Dec 2025 12:41:53 +0100 Subject: [PATCH 009/124] Add source file references to compaction.md and clarify session_before_tree behavior --- packages/coding-agent/docs/compaction.md | 55 ++++++++++++++++-------- 1 file changed, 37 insertions(+), 18 deletions(-) diff --git a/packages/coding-agent/docs/compaction.md b/packages/coding-agent/docs/compaction.md index d8f09b9c..86836d3b 100644 --- a/packages/coding-agent/docs/compaction.md +++ b/packages/coding-agent/docs/compaction.md @@ -2,6 +2,13 @@ LLMs have limited context windows. When conversations grow too long, pi uses compaction to summarize older content while preserving recent work. This page covers both auto-compaction and branch summarization. +**Source files:** +- [`src/core/compaction/compaction.ts`](../src/core/compaction/compaction.ts) - Auto-compaction logic +- [`src/core/compaction/branch-summarization.ts`](../src/core/compaction/branch-summarization.ts) - Branch summarization +- [`src/core/compaction/utils.ts`](../src/core/compaction/utils.ts) - Shared utilities (file tracking, serialization) +- [`src/core/session-manager.ts`](../src/core/session-manager.ts) - Entry types (`CompactionEntry`, `BranchSummaryEntry`) +- [`src/core/hooks/types.ts`](../src/core/hooks/types.ts) - Hook event types + ## Overview Pi has two summarization mechanisms: @@ -23,13 +30,13 @@ Auto-compaction triggers when: contextTokens > contextWindow - reserveTokens ``` -By default, `reserveTokens` is 16384 tokens (configurable in `~/.pi/agent/settings.json`). This leaves room for the LLM's response. +By default, `reserveTokens` is 16384 tokens (configurable in `~/.pi/agent/settings.json` or `/.pi/settings.json`). This leaves room for the LLM's response. You can also trigger manually with `/compact [instructions]`, where optional instructions focus the summary. ### How It Works -1. **Find cut point**: Walk backwards from newest message, accumulating token estimates until `keepRecentTokens` (default 20k, configurable in `~/.pi/agent/settings.json`) is reached +1. **Find cut point**: Walk backwards from newest message, accumulating token estimates until `keepRecentTokens` (default 20k, configurable in `~/.pi/agent/settings.json` or `/.pi/settings.json`) is reached 2. **Extract messages**: Collect messages from previous compaction (or start) up to cut point 3. **Generate summary**: Call LLM to summarize with structured format 4. **Append entry**: Save `CompactionEntry` with summary and `firstKeptEntryId` @@ -107,6 +114,8 @@ Never cut at tool results (they must stay with their tool call). ### CompactionEntry Structure +Defined in [`src/core/session-manager.ts`](../src/core/session-manager.ts): + ```typescript interface CompactionEntry { type: "compaction"; @@ -120,7 +129,7 @@ interface CompactionEntry { details?: T; // hook-specific data } -// Default compaction uses this for details: +// Default compaction uses this for details (from compaction.ts): interface CompactionDetails { readFiles: string[]; modifiedFiles: string[]; @@ -129,6 +138,8 @@ interface CompactionDetails { Hooks can store any JSON-serializable data in `details`. The default compaction tracks file operations, but custom compaction hooks can use their own structure. +See [`prepareCompaction()`](../src/core/compaction/compaction.ts) and [`compact()`](../src/core/compaction/compaction.ts) for the implementation. + ## Branch Summarization ### When It Triggers @@ -170,6 +181,8 @@ This means file tracking accumulates across multiple compactions or nested branc ### BranchSummaryEntry Structure +Defined in [`src/core/session-manager.ts`](../src/core/session-manager.ts): + ```typescript interface BranchSummaryEntry { type: "branch_summary"; @@ -182,7 +195,7 @@ interface BranchSummaryEntry { details?: T; // hook-specific data } -// Default branch summarization uses this for details: +// Default branch summarization uses this for details (from branch-summarization.ts): interface BranchSummaryDetails { readFiles: string[]; modifiedFiles: string[]; @@ -191,6 +204,8 @@ interface BranchSummaryDetails { Same as compaction, hooks can store custom data in `details`. +See [`collectEntriesForBranchSummary()`](../src/core/compaction/branch-summarization.ts), [`prepareBranchEntries()`](../src/core/compaction/branch-summarization.ts), and [`generateBranchSummary()`](../src/core/compaction/branch-summarization.ts) for the implementation. + ## Summary Format Both compaction and branch summarization use the same structured format: @@ -233,7 +248,7 @@ path/to/changed.ts ### Message Serialization -Before summarization, messages are serialized to text: +Before summarization, messages are serialized to text via [`serializeConversation()`](../src/core/compaction/utils.ts): ``` [User]: What they said @@ -247,11 +262,11 @@ This prevents the model from treating it as a conversation to continue. ## Custom Summarization via Hooks -Hooks can intercept and customize both compaction and branch summarization. +Hooks can intercept and customize both compaction and branch summarization. See [`src/core/hooks/types.ts`](../src/core/hooks/types.ts) for event type definitions. ### session_before_compact -Fired before auto-compaction or `/compact`. Can cancel or provide custom summary. +Fired before auto-compaction or `/compact`. Can cancel or provide custom summary. See `SessionBeforeCompactEvent` and `CompactionPreparation` in the types file. ```typescript pi.on("session_before_compact", async (event, ctx) => { @@ -287,7 +302,7 @@ See [examples/hooks/custom-compaction.ts](../examples/hooks/custom-compaction.ts ### session_before_tree -Fired before `/tree` navigation with summarization. Can cancel or provide custom summary. +Fired before `/tree` navigation. Always fires regardless of whether user chose to summarize. Can cancel navigation or provide custom summary. ```typescript pi.on("session_before_tree", async (event, ctx) => { @@ -296,25 +311,29 @@ pi.on("session_before_tree", async (event, ctx) => { // preparation.targetId - where we're navigating to // preparation.oldLeafId - current position (being abandoned) // preparation.commonAncestorId - shared ancestor - // preparation.entriesToSummarize - entries to summarize + // preparation.entriesToSummarize - entries that would be summarized // preparation.userWantsSummary - whether user chose to summarize - // Cancel navigation: + // Cancel navigation entirely: return { cancel: true }; - // Custom summary (only if userWantsSummary): - return { - summary: { - summary: "Your summary...", - details: { /* custom data */ }, - } - }; + // Provide custom summary (only used if userWantsSummary is true): + if (preparation.userWantsSummary) { + return { + summary: { + summary: "Your summary...", + details: { /* custom data */ }, + } + }; + } }); ``` +See `SessionBeforeTreeEvent` and `TreePreparation` in the types file. + ## Settings -Configure compaction in `~/.pi/agent/settings.json`: +Configure compaction in `~/.pi/agent/settings.json` or `/.pi/settings.json`: ```json { From bab343b8bc595de87fc34c92e8c594a02e72c4c0 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Wed, 31 Dec 2025 12:44:52 +0100 Subject: [PATCH 010/124] Update hooks.md: clarify session_before_tree, document all sessionManager methods --- packages/coding-agent/docs/hooks.md | 40 +++++++++++++++-------------- 1 file changed, 21 insertions(+), 19 deletions(-) diff --git a/packages/coding-agent/docs/hooks.md b/packages/coding-agent/docs/hooks.md index 8f65959d..ea79bc60 100644 --- a/packages/coding-agent/docs/hooks.md +++ b/packages/coding-agent/docs/hooks.md @@ -232,15 +232,16 @@ pi.on("session_compact", async (event, ctx) => { #### session_before_tree / session_tree -Fired on `/tree` navigation. See [compaction.md](compaction.md) for details. +Fired on `/tree` navigation. Always fires regardless of user's summarization choice. See [compaction.md](compaction.md) for details. ```typescript pi.on("session_before_tree", async (event, ctx) => { const { preparation, signal } = event; - // preparation.targetId, oldLeafId, commonAncestorId, entriesToSummarize, userWantsSummary + // preparation.targetId, oldLeafId, commonAncestorId, entriesToSummarize + // preparation.userWantsSummary - whether user chose to summarize return { cancel: true }; - // OR (if userWantsSummary): + // OR provide custom summary (only used if userWantsSummary is true): return { summary: { summary: "...", details: {} } }; }); @@ -433,26 +434,27 @@ Current working directory. ### ctx.sessionManager -Read-only access to session state: +Read-only access to session state. See `ReadonlySessionManager` in [`src/core/session-manager.ts`](../src/core/session-manager.ts). ```typescript -// Get all entries (excludes header) -const entries = ctx.sessionManager.getEntries(); +// Session info +ctx.sessionManager.getCwd() // Working directory +ctx.sessionManager.getSessionDir() // Session directory (~/.pi/agent/sessions) +ctx.sessionManager.getSessionId() // Current session ID +ctx.sessionManager.getSessionFile() // Session file path (undefined with --no-session) -// Get current branch (root to leaf) -const branch = ctx.sessionManager.getBranch(); +// Entries +ctx.sessionManager.getEntries() // All entries (excludes header) +ctx.sessionManager.getHeader() // Session header entry +ctx.sessionManager.getEntry(id) // Specific entry by ID +ctx.sessionManager.getLabel(id) // Entry label (if any) -// Get specific entry by ID -const entry = ctx.sessionManager.getEntry(id); - -// Get session file (undefined with --no-session) -const file = ctx.sessionManager.getSessionFile(); - -// Get tree structure -const tree = ctx.sessionManager.getTree(); - -// Get entry label -const label = ctx.sessionManager.getLabel(entryId); +// Tree navigation +ctx.sessionManager.getBranch() // Current branch (root to leaf) +ctx.sessionManager.getBranch(leafId) // Specific branch +ctx.sessionManager.getTree() // Full tree structure +ctx.sessionManager.getLeafId() // Current leaf entry ID +ctx.sessionManager.getLeafEntry() // Current leaf entry ``` Use `pi.sendMessage()` or `pi.appendEntry()` for writes. From 88e39471eaa4f46808f882a75402190253af2f95 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Wed, 31 Dec 2025 12:57:54 +0100 Subject: [PATCH 011/124] Remove hook execution timeouts - Remove timeout logic from HookRunner - Remove hookTimeout from Settings interface - Remove getHookTimeout/setHookTimeout methods - Update CHANGELOG.md and hooks.md Timeouts were inconsistently applied and caused issues with legitimate slow operations (LLM calls, user prompts). Users can use Ctrl+C to abort hung hooks. --- packages/coding-agent/CHANGELOG.md | 1 + packages/coding-agent/docs/hooks.md | 9 ++-- .../coding-agent/src/core/hooks/runner.ts | 48 ++----------------- packages/coding-agent/src/core/sdk.ts | 5 +- .../coding-agent/src/core/settings-manager.ts | 10 ---- 5 files changed, 10 insertions(+), 63 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index ba5451bd..e7b346a4 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -42,6 +42,7 @@ - Renderers return inner content; the TUI wraps it in a styled Box - New types: `HookMessage`, `RegisteredCommand`, `HookContext` - Handler types renamed: `SendHandler` → `SendMessageHandler`, new `AppendEntryHandler` + - Removed `hookTimeout` setting - hooks no longer have execution timeouts (use Ctrl+C to abort hung hooks) - **SessionManager**: - `getSessionFile()` now returns `string | undefined` (undefined for in-memory sessions) - **Themes**: Custom themes must add `selectedBg`, `customMessageBg`, `customMessageText`, `customMessageLabel` color tokens (50 total) diff --git a/packages/coding-agent/docs/hooks.md b/packages/coding-agent/docs/hooks.md index ea79bc60..7e720f6e 100644 --- a/packages/coding-agent/docs/hooks.md +++ b/packages/coding-agent/docs/hooks.md @@ -51,13 +51,10 @@ Additional paths via `settings.json`: ```json { - "hooks": ["/path/to/hook.ts"], - "hookTimeout": 30000 + "hooks": ["/path/to/hook.ts"] } ``` -The `hookTimeout` (default 30s) applies to most events. `tool_call` has no timeout since it may prompt the user. - ## Available Imports | Package | Purpose | @@ -329,7 +326,7 @@ pi.on("context", async (event, ctx) => { #### tool_call -Fired before tool executes. **Can block.** No timeout. +Fired before tool executes. **Can block.** ```typescript pi.on("tool_call", async (event, ctx) => { @@ -654,8 +651,8 @@ In print mode, `select()` returns `undefined`, `confirm()` returns `false`, `inp - Hook errors are logged, agent continues - `tool_call` errors block the tool (fail-safe) -- Timeout errors (default 30s) are logged but don't block - Errors display in UI with hook path and message +- If a hook hangs, use Ctrl+C to abort ## Debugging diff --git a/packages/coding-agent/src/core/hooks/runner.ts b/packages/coding-agent/src/core/hooks/runner.ts index 998e39b7..da15fc85 100644 --- a/packages/coding-agent/src/core/hooks/runner.ts +++ b/packages/coding-agent/src/core/hooks/runner.ts @@ -25,11 +25,6 @@ import type { ToolResultEventResult, } from "./types.js"; -/** - * Default timeout for hook execution (30 seconds). - */ -const DEFAULT_TIMEOUT = 30000; - /** * Listener for hook errors. */ @@ -38,20 +33,6 @@ export type HookErrorListener = (error: HookError) => void; // Re-export execCommand for backward compatibility export { execCommand } from "../exec.js"; -/** - * Create a promise that rejects after a timeout. - */ -function createTimeout(ms: number): { promise: Promise; clear: () => void } { - let timeoutId: NodeJS.Timeout; - const promise = new Promise((_, reject) => { - timeoutId = setTimeout(() => reject(new Error(`Hook timed out after ${ms}ms`)), ms); - }); - return { - promise, - clear: () => clearTimeout(timeoutId), - }; -} - /** No-op UI context used when no UI is available */ const noOpUIContext: HookUIContext = { select: async () => undefined, @@ -71,24 +52,16 @@ export class HookRunner { private cwd: string; private sessionManager: SessionManager; private modelRegistry: ModelRegistry; - private timeout: number; private errorListeners: Set = new Set(); private getModel: () => Model | undefined = () => undefined; - constructor( - hooks: LoadedHook[], - cwd: string, - sessionManager: SessionManager, - modelRegistry: ModelRegistry, - timeout: number = DEFAULT_TIMEOUT, - ) { + constructor(hooks: LoadedHook[], cwd: string, sessionManager: SessionManager, modelRegistry: ModelRegistry) { this.hooks = hooks; this.uiContext = noOpUIContext; this.hasUI = false; this.cwd = cwd; this.sessionManager = sessionManager; this.modelRegistry = modelRegistry; - this.timeout = timeout; } /** @@ -262,16 +235,7 @@ export class HookRunner { for (const handler of handlers) { try { - // No timeout for session_before_compact events (like tool_call, they may take a while) - let handlerResult: unknown; - - if (event.type === "session_before_compact") { - handlerResult = await handler(event, ctx); - } else { - const timeout = createTimeout(this.timeout); - handlerResult = await Promise.race([handler(event, ctx), timeout.promise]); - timeout.clear(); - } + const handlerResult = await handler(event, ctx); // For session before_* events, capture the result (for cancellation) if (this.isSessionBeforeEvent(event.type) && handlerResult) { @@ -348,9 +312,7 @@ export class HookRunner { for (const handler of handlers) { try { const event: ContextEvent = { type: "context", messages: currentMessages }; - const timeout = createTimeout(this.timeout); - const handlerResult = await Promise.race([handler(event, ctx), timeout.promise]); - timeout.clear(); + const handlerResult = await handler(event, ctx); if (handlerResult && (handlerResult as ContextEventResult).messages) { currentMessages = (handlerResult as ContextEventResult).messages!; @@ -387,9 +349,7 @@ export class HookRunner { for (const handler of handlers) { try { const event: BeforeAgentStartEvent = { type: "before_agent_start", prompt, images }; - const timeout = createTimeout(this.timeout); - const handlerResult = await Promise.race([handler(event, ctx), timeout.promise]); - timeout.clear(); + const handlerResult = await handler(event, ctx); // Take the first message returned if (handlerResult && (handlerResult as BeforeAgentStartEventResult).message && !result) { diff --git a/packages/coding-agent/src/core/sdk.ts b/packages/coding-agent/src/core/sdk.ts index d2779ced..e2eb11e0 100644 --- a/packages/coding-agent/src/core/sdk.ts +++ b/packages/coding-agent/src/core/sdk.ts @@ -313,7 +313,6 @@ export function loadSettings(cwd?: string, agentDir?: string): Settings { shellPath: manager.getShellPath(), collapseChangelog: manager.getCollapseChangelog(), hooks: manager.getHookPaths(), - hookTimeout: manager.getHookTimeout(), customTools: manager.getCustomToolPaths(), skills: manager.getSkillsSettings(), terminal: { showImages: manager.getShowImages() }, @@ -536,7 +535,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} if (options.hooks !== undefined) { if (options.hooks.length > 0) { const loadedHooks = createLoadedHooksFromDefinitions(options.hooks); - hookRunner = new HookRunner(loadedHooks, cwd, sessionManager, modelRegistry, settingsManager.getHookTimeout()); + hookRunner = new HookRunner(loadedHooks, cwd, sessionManager, modelRegistry); } } else { // Discover hooks, merging with additional paths @@ -547,7 +546,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} console.error(`Failed to load hook "${path}": ${error}`); } if (hooks.length > 0) { - hookRunner = new HookRunner(hooks, cwd, sessionManager, modelRegistry, settingsManager.getHookTimeout()); + hookRunner = new HookRunner(hooks, cwd, sessionManager, modelRegistry); } } diff --git a/packages/coding-agent/src/core/settings-manager.ts b/packages/coding-agent/src/core/settings-manager.ts index 3a116d63..4231655f 100644 --- a/packages/coding-agent/src/core/settings-manager.ts +++ b/packages/coding-agent/src/core/settings-manager.ts @@ -48,7 +48,6 @@ export interface Settings { shellPath?: string; // Custom shell path (e.g., for Cygwin users on Windows) collapseChangelog?: boolean; // Show condensed changelog after update (use /changelog for full) hooks?: string[]; // Array of hook file paths - hookTimeout?: number; // Timeout for hook execution in ms (default: 30000) customTools?: string[]; // Array of custom tool file paths skills?: SkillsSettings; terminal?: TerminalSettings; @@ -322,15 +321,6 @@ export class SettingsManager { this.save(); } - getHookTimeout(): number { - return this.settings.hookTimeout ?? 30000; - } - - setHookTimeout(timeout: number): void { - this.globalSettings.hookTimeout = timeout; - this.save(); - } - getCustomToolPaths(): string[] { return [...(this.settings.customTools ?? [])]; } From 29e0ed9cd19a597b53b58eea369303263ca499fe Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Wed, 31 Dec 2025 13:02:28 +0100 Subject: [PATCH 012/124] Improve hooks.md UI documentation - Add 'Key capabilities' section highlighting UI features - Expand ctx.ui docs with custom component details - Reference snake.ts example for custom UI --- packages/coding-agent/docs/hooks.md | 63 +++++++++++++++++++---------- 1 file changed, 42 insertions(+), 21 deletions(-) diff --git a/packages/coding-agent/docs/hooks.md b/packages/coding-agent/docs/hooks.md index 7e720f6e..92fb90b4 100644 --- a/packages/coding-agent/docs/hooks.md +++ b/packages/coding-agent/docs/hooks.md @@ -2,14 +2,21 @@ Hooks are TypeScript modules that extend pi's behavior by subscribing to lifecycle events. They can intercept tool calls, prompt the user, modify results, inject messages, and more. -**Example use cases:** -- Block dangerous commands (permission gates for `rm -rf`, `sudo`) -- Checkpoint code state (git stash at each turn, restore on branch) -- Protect paths (block writes to `.env`, `node_modules/`) -- Inject messages from external sources (file watchers, webhooks) -- Custom slash commands and UI components +**Key capabilities:** +- **User interaction** - Hooks can prompt users via `ctx.ui` (select, confirm, input, notify) +- **Custom UI components** - Full TUI components with keyboard input via `ctx.ui.custom()` +- **Custom slash commands** - Register commands like `/mycommand` via `pi.registerCommand()` +- **Event interception** - Block or modify tool calls, inject context, customize compaction +- **Session persistence** - Store hook state that survives restarts via `pi.appendEntry()` -See [examples/hooks/](../examples/hooks/) for working implementations. +**Example use cases:** +- Permission gates (confirm before `rm -rf`, `sudo`, etc.) +- Git checkpointing (stash at each turn, restore on `/branch`) +- Path protection (block writes to `.env`, `node_modules/`) +- External integrations (file watchers, webhooks, CI triggers) +- Interactive tools (games, wizards, custom dialogs) + +See [examples/hooks/](../examples/hooks/) for working implementations, including a [snake game](../examples/hooks/snake.ts) demonstrating custom UI. ## Quick Start @@ -187,7 +194,7 @@ Fired when branching via `/branch`. ```typescript pi.on("session_before_branch", async (event, ctx) => { // event.entryIndex - entry index being branched from - + return { cancel: true }; // Cancel branch // OR return { skipConversationRestore: true }; // Branch but don't rewind messages @@ -207,10 +214,10 @@ Fired on compaction. See [compaction.md](compaction.md) for details. ```typescript pi.on("session_before_compact", async (event, ctx) => { const { preparation, branchEntries, customInstructions, signal } = event; - + // Cancel: return { cancel: true }; - + // Custom summary: return { compaction: { @@ -236,7 +243,7 @@ pi.on("session_before_tree", async (event, ctx) => { const { preparation, signal } = event; // preparation.targetId, oldLeafId, commonAncestorId, entriesToSummarize // preparation.userWantsSummary - whether user chose to summarize - + return { cancel: true }; // OR provide custom summary (only used if userWantsSummary is true): return { summary: { summary: "...", details: {} } }; @@ -267,7 +274,7 @@ Fired after user submits prompt, before agent loop. Can inject a persistent mess pi.on("before_agent_start", async (event, ctx) => { // event.prompt - user's prompt text // event.images - attached images (if any) - + return { message: { customType: "my-hook", @@ -315,7 +322,7 @@ Fired before each LLM call. Modify messages non-destructively (session unchanged ```typescript pi.on("context", async (event, ctx) => { // event.messages - deep copy, safe to modify - + // Filter or transform messages const filtered = event.messages.filter(m => !shouldPrune(m)); return { messages: filtered }; @@ -333,7 +340,7 @@ pi.on("tool_call", async (event, ctx) => { // event.toolName - "bash", "read", "write", "edit", etc. // event.toolCallId // event.input - tool parameters - + if (shouldBlock(event)) { return { block: true, reason: "Not allowed" }; } @@ -359,7 +366,7 @@ pi.on("tool_result", async (event, ctx) => { // event.content - array of TextContent | ImageContent // event.details - tool-specific (see below) // event.isError - + // Modify result: return { content: [...], details: {...}, isError: false }; }); @@ -388,7 +395,9 @@ Every handler receives `ctx: HookContext`: ### ctx.ui -UI methods for user interaction: +UI methods for user interaction. Hooks can prompt users and even render custom TUI components. + +**Built-in dialogs:** ```typescript // Select from options @@ -403,19 +412,31 @@ const ok = await ctx.ui.confirm("Delete?", "This cannot be undone"); const name = await ctx.ui.input("Name:", "placeholder"); // Returns string or undefined if cancelled -// Notification +// Notification (non-blocking) ctx.ui.notify("Done!", "info"); // "info" | "warning" | "error" +``` -// Custom component with keyboard focus +**Custom components:** + +For full control, render your own TUI component with keyboard focus: + +```typescript const handle = ctx.ui.custom(myComponent); // Returns { close: () => void, requestRender: () => void } -// Component can implement handleInput(data: string) for keyboard -// Call handle.close() when done ``` +Your component can: +- Implement `handleInput(data: string)` to receive keyboard input +- Implement `render(width: number): string[]` to render lines +- Implement `invalidate()` to clear cached render +- Call `handle.requestRender()` to trigger re-render +- Call `handle.close()` when done to restore normal UI + +See [examples/hooks/snake.ts](../examples/hooks/snake.ts) for a complete example with game loop, keyboard handling, and state persistence. + ### ctx.hasUI -`false` in print mode (`-p`) and RPC mode. Always check before using `ctx.ui`: +`false` in print mode (`-p`), JSON print mode, and RPC mode. Always check before using `ctx.ui`: ```typescript if (ctx.hasUI) { From 20fbf40facd150890f4f0761cca58d43a9f6e6b0 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Wed, 31 Dec 2025 13:05:59 +0100 Subject: [PATCH 013/124] Add tui.md and improve TUI documentation - New tui.md covers component system for hooks and custom tools - Update hooks.md intro with 'Key capabilities' highlighting UI - Update custom-tools.md intro with 'Key capabilities' highlighting UI - Reference tui.md from both docs --- packages/coding-agent/docs/custom-tools.md | 18 +- packages/coding-agent/docs/hooks.md | 2 +- packages/coding-agent/docs/tui.md | 324 +++++++++++++++++++++ 3 files changed, 337 insertions(+), 7 deletions(-) create mode 100644 packages/coding-agent/docs/tui.md diff --git a/packages/coding-agent/docs/custom-tools.md b/packages/coding-agent/docs/custom-tools.md index 68c4d01a..74b908bd 100644 --- a/packages/coding-agent/docs/custom-tools.md +++ b/packages/coding-agent/docs/custom-tools.md @@ -2,12 +2,18 @@ Custom tools are additional tools that the LLM can call directly, just like the built-in `read`, `write`, `edit`, and `bash` tools. They are TypeScript modules that define callable functions with parameters, return values, and optional TUI rendering. +**Key capabilities:** +- **User interaction** - Prompt users via `pi.ui` (select, confirm, input dialogs) +- **Custom rendering** - Control how tool calls and results appear via `renderCall`/`renderResult` +- **TUI components** - Render custom components with `pi.ui.custom()` (see [tui.md](tui.md)) +- **State management** - Persist state in tool result `details` for proper branching support +- **Streaming results** - Send partial updates via `onUpdate` callback + **Example use cases:** -- Ask the user questions with selectable options -- Maintain state across calls (todo lists, connection pools) -- Custom TUI rendering (progress indicators, structured output) -- Integrate external services with proper error handling -- Tools that need user confirmation before proceeding +- Interactive dialogs (questions with selectable options) +- Stateful tools (todo lists, connection pools) +- Rich output rendering (progress indicators, structured views) +- External service integrations with confirmation flows **When to use custom tools vs. alternatives:** @@ -283,7 +289,7 @@ This pattern ensures: ## Custom Rendering -Custom tools can provide `renderCall` and `renderResult` methods to control how they appear in the TUI. Both are optional. +Custom tools can provide `renderCall` and `renderResult` methods to control how they appear in the TUI. Both are optional. See [tui.md](tui.md) for the full component API. ### How It Works diff --git a/packages/coding-agent/docs/hooks.md b/packages/coding-agent/docs/hooks.md index 92fb90b4..e8c3c856 100644 --- a/packages/coding-agent/docs/hooks.md +++ b/packages/coding-agent/docs/hooks.md @@ -432,7 +432,7 @@ Your component can: - Call `handle.requestRender()` to trigger re-render - Call `handle.close()` when done to restore normal UI -See [examples/hooks/snake.ts](../examples/hooks/snake.ts) for a complete example with game loop, keyboard handling, and state persistence. +See [examples/hooks/snake.ts](../examples/hooks/snake.ts) for a complete example with game loop, keyboard handling, and state persistence. See [tui.md](tui.md) for the full component API. ### ctx.hasUI diff --git a/packages/coding-agent/docs/tui.md b/packages/coding-agent/docs/tui.md new file mode 100644 index 00000000..3dc6894c --- /dev/null +++ b/packages/coding-agent/docs/tui.md @@ -0,0 +1,324 @@ +# TUI Components + +Hooks and custom tools can render custom TUI components for interactive user interfaces. This page covers the component system and available building blocks. + +**Source:** [`@mariozechner/pi-tui`](https://github.com/badlogic/pi-mono/tree/main/packages/tui) + +## Component Interface + +All components implement: + +```typescript +interface Component { + render(width: number): string[]; + handleInput?(data: string): void; + invalidate?(): void; +} +``` + +| Method | Description | +|--------|-------------| +| `render(width)` | Return array of strings (one per line). Each line **must not exceed `width`**. | +| `handleInput?(data)` | Receive keyboard input when component has focus. | +| `invalidate?()` | Clear cached render state. | + +## Using Components + +**In hooks** via `ctx.ui.custom()`: + +```typescript +pi.on("session_start", async (_event, ctx) => { + const handle = ctx.ui.custom(myComponent); + // handle.requestRender() - trigger re-render + // handle.close() - restore normal UI +}); +``` + +**In custom tools** via `pi.ui.custom()`: + +```typescript +async execute(toolCallId, params, onUpdate, ctx, signal) { + const handle = pi.ui.custom(myComponent); + // ... + handle.close(); +} +``` + +## Built-in Components + +Import from `@mariozechner/pi-tui`: + +```typescript +import { Text, Box, Container, Spacer, Markdown } from "@mariozechner/pi-tui"; +``` + +### Text + +Multi-line text with word wrapping. + +```typescript +const text = new Text( + "Hello World", // content + 1, // paddingX (default: 1) + 1, // paddingY (default: 1) + (s) => bgGray(s) // optional background function +); +text.setText("Updated"); +``` + +### Box + +Container with padding and background color. + +```typescript +const box = new Box( + 1, // paddingX + 1, // paddingY + (s) => bgGray(s) // background function +); +box.addChild(new Text("Content", 0, 0)); +box.setBgFn((s) => bgBlue(s)); +``` + +### Container + +Groups child components vertically. + +```typescript +const container = new Container(); +container.addChild(component1); +container.addChild(component2); +container.removeChild(component1); +``` + +### Spacer + +Empty vertical space. + +```typescript +const spacer = new Spacer(2); // 2 empty lines +``` + +### Markdown + +Renders markdown with syntax highlighting. + +```typescript +const md = new Markdown( + "# Title\n\nSome **bold** text", + 1, // paddingX + 1, // paddingY + theme // MarkdownTheme (see below) +); +md.setText("Updated markdown"); +``` + +### Image + +Renders images in supported terminals (Kitty, iTerm2, Ghostty, WezTerm). + +```typescript +const image = new Image( + base64Data, // base64-encoded image + "image/png", // MIME type + theme, // ImageTheme + { maxWidthCells: 80, maxHeightCells: 24 } +); +``` + +## Keyboard Input + +Use key detection helpers: + +```typescript +import { + isEnter, isEscape, isTab, + isArrowUp, isArrowDown, isArrowLeft, isArrowRight, + isCtrlC, isCtrlO, isBackspace, isDelete, + // ... and more +} from "@mariozechner/pi-tui"; + +handleInput(data: string) { + if (isArrowUp(data)) { + this.selectedIndex--; + } else if (isEnter(data)) { + this.onSelect?.(this.selectedIndex); + } else if (isEscape(data)) { + this.onCancel?.(); + } +} +``` + +## Line Width + +**Critical:** Each line from `render()` must not exceed the `width` parameter. + +```typescript +import { visibleWidth, truncateToWidth } from "@mariozechner/pi-tui"; + +render(width: number): string[] { + // Truncate long lines + return [truncateToWidth(this.text, width)]; +} +``` + +Utilities: +- `visibleWidth(str)` - Get display width (ignores ANSI codes) +- `truncateToWidth(str, width, ellipsis?)` - Truncate with optional ellipsis +- `wrapTextWithAnsi(str, width)` - Word wrap preserving ANSI codes + +## Creating Custom Components + +Example: Interactive selector + +```typescript +import { + isEnter, isEscape, isArrowUp, isArrowDown, + truncateToWidth, visibleWidth +} from "@mariozechner/pi-tui"; + +class MySelector { + private items: string[]; + private selected = 0; + private cachedWidth?: number; + private cachedLines?: string[]; + + public onSelect?: (item: string) => void; + public onCancel?: () => void; + + constructor(items: string[]) { + this.items = items; + } + + handleInput(data: string): void { + if (isArrowUp(data) && this.selected > 0) { + this.selected--; + this.invalidate(); + } else if (isArrowDown(data) && this.selected < this.items.length - 1) { + this.selected++; + this.invalidate(); + } else if (isEnter(data)) { + this.onSelect?.(this.items[this.selected]); + } else if (isEscape(data)) { + this.onCancel?.(); + } + } + + render(width: number): string[] { + if (this.cachedLines && this.cachedWidth === width) { + return this.cachedLines; + } + + this.cachedLines = this.items.map((item, i) => { + const prefix = i === this.selected ? "> " : " "; + return truncateToWidth(prefix + item, width); + }); + this.cachedWidth = width; + return this.cachedLines; + } + + invalidate(): void { + this.cachedWidth = undefined; + this.cachedLines = undefined; + } +} +``` + +Usage in a hook: + +```typescript +pi.registerCommand("pick", { + description: "Pick an item", + handler: async (args, ctx) => { + const items = ["Option A", "Option B", "Option C"]; + const selector = new MySelector(items); + + let handle: { close: () => void; requestRender: () => void }; + + await new Promise((resolve) => { + selector.onSelect = (item) => { + ctx.ui.notify(`Selected: ${item}`, "info"); + handle.close(); + resolve(); + }; + selector.onCancel = () => { + handle.close(); + resolve(); + }; + handle = ctx.ui.custom(selector); + }); + } +}); +``` + +## Theming + +Components accept theme objects for styling. Use ANSI color functions (e.g., from `chalk` or pi's theme): + +```typescript +// In hooks, use the theme from renderResult/renderCall +renderResult(result, options, theme) { + return new Text(theme.fg("success", "Done!"), 0, 0); +} + +// For custom components, define your own theme interface +interface MyTheme { + selected: (s: string) => string; + normal: (s: string) => string; +} +``` + +### MarkdownTheme + +```typescript +interface MarkdownTheme { + heading: (text: string) => string; + link: (text: string) => string; + linkUrl: (text: string) => string; + code: (text: string) => string; + codeBlock: (text: string) => string; + codeBlockBorder: (text: string) => string; + quote: (text: string) => string; + quoteBorder: (text: string) => string; + hr: (text: string) => string; + listBullet: (text: string) => string; + bold: (text: string) => string; + italic: (text: string) => string; + strikethrough: (text: string) => string; + underline: (text: string) => string; + highlightCode?: (code: string, lang?: string) => string[]; +} +``` + +## Performance + +Cache rendered output when possible: + +```typescript +class CachedComponent { + private cachedWidth?: number; + private cachedLines?: string[]; + + render(width: number): string[] { + if (this.cachedLines && this.cachedWidth === width) { + return this.cachedLines; + } + // ... compute lines ... + this.cachedWidth = width; + this.cachedLines = lines; + return lines; + } + + invalidate(): void { + this.cachedWidth = undefined; + this.cachedLines = undefined; + } +} +``` + +Call `invalidate()` when state changes, then `handle.requestRender()` to trigger re-render. + +## Examples + +- **Snake game**: [examples/hooks/snake.ts](../examples/hooks/snake.ts) - Full game with keyboard input, game loop, state persistence +- **Custom tool rendering**: [examples/custom-tools/todo/](../examples/custom-tools/todo/) - Custom `renderCall` and `renderResult` From feca0976eb31f160f6a52d21b42752a80375ca9e Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Wed, 31 Dec 2025 13:06:52 +0100 Subject: [PATCH 014/124] Update tui.md theming section to show getMarkdownTheme usage --- packages/coding-agent/docs/tui.md | 47 +++++++++++++++---------------- 1 file changed, 22 insertions(+), 25 deletions(-) diff --git a/packages/coding-agent/docs/tui.md b/packages/coding-agent/docs/tui.md index 3dc6894c..3f74f213 100644 --- a/packages/coding-agent/docs/tui.md +++ b/packages/coding-agent/docs/tui.md @@ -253,40 +253,37 @@ pi.registerCommand("pick", { ## Theming -Components accept theme objects for styling. Use ANSI color functions (e.g., from `chalk` or pi's theme): +Components accept theme objects for styling. + +**In `renderCall`/`renderResult`**, use the `theme` parameter: ```typescript -// In hooks, use the theme from renderResult/renderCall renderResult(result, options, theme) { + // Use theme for foreground colors return new Text(theme.fg("success", "Done!"), 0, 0); } - -// For custom components, define your own theme interface -interface MyTheme { - selected: (s: string) => string; - normal: (s: string) => string; -} ``` -### MarkdownTheme +Available theme colors: `"toolTitle"`, `"accent"`, `"success"`, `"error"`, `"warning"`, `"muted"`, `"dim"`, `"toolOutput"`. + +**For Markdown**, use `getMarkdownTheme()`: ```typescript -interface MarkdownTheme { - heading: (text: string) => string; - link: (text: string) => string; - linkUrl: (text: string) => string; - code: (text: string) => string; - codeBlock: (text: string) => string; - codeBlockBorder: (text: string) => string; - quote: (text: string) => string; - quoteBorder: (text: string) => string; - hr: (text: string) => string; - listBullet: (text: string) => string; - bold: (text: string) => string; - italic: (text: string) => string; - strikethrough: (text: string) => string; - underline: (text: string) => string; - highlightCode?: (code: string, lang?: string) => string[]; +import { getMarkdownTheme } from "@mariozechner/pi-coding-agent"; +import { Markdown } from "@mariozechner/pi-tui"; + +renderResult(result, options, theme) { + const mdTheme = getMarkdownTheme(); + return new Markdown(result.details.markdown, 0, 0, mdTheme); +} +``` + +**For custom components**, define your own theme interface: + +```typescript +interface MyTheme { + selected: (s: string) => string; + normal: (s: string) => string; } ``` From 4a37760f17ba15a9acfc026aeec881289b83b887 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Wed, 31 Dec 2025 13:09:19 +0100 Subject: [PATCH 015/124] Fix tui.md theme colors - add comprehensive list --- packages/coding-agent/docs/tui.md | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/packages/coding-agent/docs/tui.md b/packages/coding-agent/docs/tui.md index 3f74f213..0e463047 100644 --- a/packages/coding-agent/docs/tui.md +++ b/packages/coding-agent/docs/tui.md @@ -259,12 +259,32 @@ Components accept theme objects for styling. ```typescript renderResult(result, options, theme) { - // Use theme for foreground colors + // Use theme.fg() for foreground colors return new Text(theme.fg("success", "Done!"), 0, 0); + + // Use theme.bg() for background colors + const styled = theme.bg("toolPendingBg", theme.fg("accent", "text")); } ``` -Available theme colors: `"toolTitle"`, `"accent"`, `"success"`, `"error"`, `"warning"`, `"muted"`, `"dim"`, `"toolOutput"`. +**Foreground colors** (`theme.fg(color, text)`): + +| Category | Colors | +|----------|--------| +| General | `text`, `accent`, `muted`, `dim` | +| Status | `success`, `error`, `warning` | +| Borders | `border`, `borderAccent`, `borderMuted` | +| Messages | `userMessageText`, `customMessageText`, `customMessageLabel` | +| Tools | `toolTitle`, `toolOutput` | +| Diffs | `toolDiffAdded`, `toolDiffRemoved`, `toolDiffContext` | +| Markdown | `mdHeading`, `mdLink`, `mdLinkUrl`, `mdCode`, `mdCodeBlock`, `mdCodeBlockBorder`, `mdQuote`, `mdQuoteBorder`, `mdHr`, `mdListBullet` | +| Syntax | `syntaxComment`, `syntaxKeyword`, `syntaxFunction`, `syntaxVariable`, `syntaxString`, `syntaxNumber`, `syntaxType`, `syntaxOperator`, `syntaxPunctuation` | +| Thinking | `thinkingOff`, `thinkingMinimal`, `thinkingLow`, `thinkingMedium`, `thinkingHigh`, `thinkingXhigh` | +| Modes | `bashMode` | + +**Background colors** (`theme.bg(color, text)`): + +`selectedBg`, `userMessageBg`, `customMessageBg`, `toolPendingBg`, `toolSuccessBg`, `toolErrorBg` **For Markdown**, use `getMarkdownTheme()`: From d1465fa0cafcc739a3d1e31efd409914c1c389c5 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Wed, 31 Dec 2025 13:13:12 +0100 Subject: [PATCH 016/124] Add examples path to system prompt Agent can now find examples at the documented path for hooks, custom tools, and SDK usage. --- packages/coding-agent/src/config.ts | 5 +++++ packages/coding-agent/src/core/system-prompt.ts | 6 ++++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/coding-agent/src/config.ts b/packages/coding-agent/src/config.ts index b5c6af3c..7eeeb007 100644 --- a/packages/coding-agent/src/config.ts +++ b/packages/coding-agent/src/config.ts @@ -75,6 +75,11 @@ export function getDocsPath(): string { return resolve(join(getPackageDir(), "docs")); } +/** Get path to examples directory */ +export function getExamplesPath(): string { + return resolve(join(getPackageDir(), "examples")); +} + /** Get path to CHANGELOG.md */ export function getChangelogPath(): string { return resolve(join(getPackageDir(), "CHANGELOG.md")); diff --git a/packages/coding-agent/src/core/system-prompt.ts b/packages/coding-agent/src/core/system-prompt.ts index 7df751e5..56eebe05 100644 --- a/packages/coding-agent/src/core/system-prompt.ts +++ b/packages/coding-agent/src/core/system-prompt.ts @@ -5,7 +5,7 @@ import chalk from "chalk"; import { existsSync, readFileSync } from "fs"; import { join, resolve } from "path"; -import { getAgentDir, getDocsPath, getReadmePath } from "../config.js"; +import { getAgentDir, getDocsPath, getExamplesPath, getReadmePath } from "../config.js"; import type { SkillsSettings } from "./settings-manager.js"; import { formatSkillsForPrompt, loadSkills, type Skill } from "./skills.js"; import type { ToolName } from "./tools/index.js"; @@ -202,9 +202,10 @@ export function buildSystemPrompt(options: BuildSystemPromptOptions = {}): strin return prompt; } - // Get absolute paths to documentation + // Get absolute paths to documentation and examples const readmePath = getReadmePath(); const docsPath = getDocsPath(); + const examplesPath = getExamplesPath(); // Build tools list based on selected tools const tools = selectedTools || (["read", "bash", "edit", "write"] as ToolName[]); @@ -279,6 +280,7 @@ ${guidelines} Documentation: - Main documentation: ${readmePath} - Additional docs: ${docsPath} +- Examples: ${examplesPath} - When asked about: custom models/providers (README sufficient), themes (docs/theme.md), skills (docs/skills.md), hooks (docs/hooks.md), custom tools (docs/custom-tools.md), RPC (docs/rpc.md)`; if (appendSection) { From 57dc16d9b946de4c31198c68c5b356ecdf9f3bb5 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Wed, 31 Dec 2025 13:14:06 +0100 Subject: [PATCH 017/124] Add category hints to examples path in system prompt --- packages/coding-agent/src/core/system-prompt.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/coding-agent/src/core/system-prompt.ts b/packages/coding-agent/src/core/system-prompt.ts index 56eebe05..f801cf94 100644 --- a/packages/coding-agent/src/core/system-prompt.ts +++ b/packages/coding-agent/src/core/system-prompt.ts @@ -280,7 +280,7 @@ ${guidelines} Documentation: - Main documentation: ${readmePath} - Additional docs: ${docsPath} -- Examples: ${examplesPath} +- Examples: ${examplesPath} (hooks, custom tools, SDK) - When asked about: custom models/providers (README sufficient), themes (docs/theme.md), skills (docs/skills.md), hooks (docs/hooks.md), custom tools (docs/custom-tools.md), RPC (docs/rpc.md)`; if (appendSection) { From 84b663276d4e87178127ecdf9968434e9e7e18eb Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Wed, 31 Dec 2025 13:16:44 +0100 Subject: [PATCH 018/124] Add creation hints to docs and update system prompt - System prompt now instructs to read docs AND examples, follow cross-refs - Each doc starts with 'pi can create X. Ask it to build one.' --- packages/coding-agent/docs/custom-tools.md | 2 ++ packages/coding-agent/docs/hooks.md | 2 ++ packages/coding-agent/docs/skills.md | 2 ++ packages/coding-agent/docs/theme.md | 2 ++ packages/coding-agent/docs/tui.md | 2 ++ packages/coding-agent/src/core/system-prompt.ts | 2 +- 6 files changed, 11 insertions(+), 1 deletion(-) diff --git a/packages/coding-agent/docs/custom-tools.md b/packages/coding-agent/docs/custom-tools.md index 74b908bd..8f428efb 100644 --- a/packages/coding-agent/docs/custom-tools.md +++ b/packages/coding-agent/docs/custom-tools.md @@ -1,3 +1,5 @@ +> pi can create custom tools. Ask it to build one for your use case. + # Custom Tools Custom tools are additional tools that the LLM can call directly, just like the built-in `read`, `write`, `edit`, and `bash` tools. They are TypeScript modules that define callable functions with parameters, return values, and optional TUI rendering. diff --git a/packages/coding-agent/docs/hooks.md b/packages/coding-agent/docs/hooks.md index e8c3c856..2cde1a0c 100644 --- a/packages/coding-agent/docs/hooks.md +++ b/packages/coding-agent/docs/hooks.md @@ -1,3 +1,5 @@ +> pi can create hooks. Ask it to build one for your use case. + # Hooks Hooks are TypeScript modules that extend pi's behavior by subscribing to lifecycle events. They can intercept tool calls, prompt the user, modify results, inject messages, and more. diff --git a/packages/coding-agent/docs/skills.md b/packages/coding-agent/docs/skills.md index a32a2397..c2685d97 100644 --- a/packages/coding-agent/docs/skills.md +++ b/packages/coding-agent/docs/skills.md @@ -1,3 +1,5 @@ +> pi can create skills. Ask it to build one for your use case. + # Skills Skills are self-contained capability packages that the agent loads on-demand. A skill provides specialized workflows, setup instructions, helper scripts, and reference documentation for specific tasks. diff --git a/packages/coding-agent/docs/theme.md b/packages/coding-agent/docs/theme.md index ac3fa237..bc6064f1 100644 --- a/packages/coding-agent/docs/theme.md +++ b/packages/coding-agent/docs/theme.md @@ -1,3 +1,5 @@ +> pi can create themes. Ask it to build one for your use case. + # Pi Coding Agent Themes Themes allow you to customize the colors used throughout the coding agent TUI. diff --git a/packages/coding-agent/docs/tui.md b/packages/coding-agent/docs/tui.md index 0e463047..297f6cf5 100644 --- a/packages/coding-agent/docs/tui.md +++ b/packages/coding-agent/docs/tui.md @@ -1,3 +1,5 @@ +> pi can create TUI components. Ask it to build one for your use case. + # TUI Components Hooks and custom tools can render custom TUI components for interactive user interfaces. This page covers the component system and available building blocks. diff --git a/packages/coding-agent/src/core/system-prompt.ts b/packages/coding-agent/src/core/system-prompt.ts index f801cf94..07e9cbbb 100644 --- a/packages/coding-agent/src/core/system-prompt.ts +++ b/packages/coding-agent/src/core/system-prompt.ts @@ -281,7 +281,7 @@ Documentation: - Main documentation: ${readmePath} - Additional docs: ${docsPath} - Examples: ${examplesPath} (hooks, custom tools, SDK) -- When asked about: custom models/providers (README sufficient), themes (docs/theme.md), skills (docs/skills.md), hooks (docs/hooks.md), custom tools (docs/custom-tools.md), RPC (docs/rpc.md)`; +- When asked to create hooks, custom tools, themes, or skills: read the relevant docs AND examples, follow all .md cross-references`; if (appendSection) { prompt += appendSection; From dbdb99c486cabc705bfe7e3c863464a60b4b9bef Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Wed, 31 Dec 2025 13:17:25 +0100 Subject: [PATCH 019/124] Fix system prompt: add specific doc/example paths for each topic --- packages/coding-agent/src/core/system-prompt.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/coding-agent/src/core/system-prompt.ts b/packages/coding-agent/src/core/system-prompt.ts index 07e9cbbb..30e7d1f2 100644 --- a/packages/coding-agent/src/core/system-prompt.ts +++ b/packages/coding-agent/src/core/system-prompt.ts @@ -281,7 +281,8 @@ Documentation: - Main documentation: ${readmePath} - Additional docs: ${docsPath} - Examples: ${examplesPath} (hooks, custom tools, SDK) -- When asked to create hooks, custom tools, themes, or skills: read the relevant docs AND examples, follow all .md cross-references`; +- When asked to create: hooks (docs/hooks.md, examples/hooks/), custom tools (docs/custom-tools.md, docs/tui.md, examples/custom-tools/), themes (docs/theme.md), skills (docs/skills.md) +- Always read the doc, examples, AND follow .md cross-references before implementing`; if (appendSection) { prompt += appendSection; From 67a1b9581c310044912be67e8c11fc560c1e8189 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Wed, 31 Dec 2025 13:18:05 +0100 Subject: [PATCH 020/124] Add custom models/providers to system prompt doc references --- packages/coding-agent/src/core/system-prompt.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/coding-agent/src/core/system-prompt.ts b/packages/coding-agent/src/core/system-prompt.ts index 30e7d1f2..da9b00da 100644 --- a/packages/coding-agent/src/core/system-prompt.ts +++ b/packages/coding-agent/src/core/system-prompt.ts @@ -281,7 +281,7 @@ Documentation: - Main documentation: ${readmePath} - Additional docs: ${docsPath} - Examples: ${examplesPath} (hooks, custom tools, SDK) -- When asked to create: hooks (docs/hooks.md, examples/hooks/), custom tools (docs/custom-tools.md, docs/tui.md, examples/custom-tools/), themes (docs/theme.md), skills (docs/skills.md) +- When asked to create: custom models/providers (README.md), hooks (docs/hooks.md, examples/hooks/), custom tools (docs/custom-tools.md, docs/tui.md, examples/custom-tools/), themes (docs/theme.md), skills (docs/skills.md) - Always read the doc, examples, AND follow .md cross-references before implementing`; if (appendSection) { From 75269add9644540b3142bbdfeda69bfd8e7f5f9b Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Wed, 31 Dec 2025 13:21:55 +0100 Subject: [PATCH 021/124] Export serializeConversation and document in compaction.md Shows how to convert messages to text for custom summarization. --- packages/coding-agent/docs/compaction.md | 32 ++++++++++++++++++++++++ packages/coding-agent/src/index.ts | 1 + 2 files changed, 33 insertions(+) diff --git a/packages/coding-agent/docs/compaction.md b/packages/coding-agent/docs/compaction.md index 86836d3b..bb11867b 100644 --- a/packages/coding-agent/docs/compaction.md +++ b/packages/coding-agent/docs/compaction.md @@ -298,6 +298,38 @@ pi.on("session_before_compact", async (event, ctx) => { }); ``` +#### Converting Messages to Text + +To generate a summary with your own model, convert messages to text using `serializeConversation`: + +```typescript +import { serializeConversation } from "@mariozechner/pi-coding-agent"; + +pi.on("session_before_compact", async (event, ctx) => { + const { preparation } = event; + + // Convert messages to readable text format + const conversationText = serializeConversation(preparation.messagesToSummarize); + // Returns: + // [User]: message text + // [Assistant thinking]: thinking content + // [Assistant]: response text + // [Assistant tool calls]: read(path="..."); bash(command="...") + // [Tool result]: output text + + // Now send to your model for summarization + const summary = await myModel.summarize(conversationText); + + return { + compaction: { + summary, + firstKeptEntryId: preparation.firstKeptEntryId, + tokensBefore: preparation.tokensBefore, + } + }; +}); +``` + See [examples/hooks/custom-compaction.ts](../examples/hooks/custom-compaction.ts) for a complete example using a different model. ### session_before_tree diff --git a/packages/coding-agent/src/index.ts b/packages/coding-agent/src/index.ts index 4efaacd4..0eeaef4c 100644 --- a/packages/coding-agent/src/index.ts +++ b/packages/coding-agent/src/index.ts @@ -30,6 +30,7 @@ export { generateSummary, getLastAssistantUsage, prepareBranchEntries, + serializeConversation, shouldCompact, } from "./core/compaction/index.js"; // Custom tools From 027d39aa33d818dcfb6bed02cd57c10951860601 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Wed, 31 Dec 2025 13:24:23 +0100 Subject: [PATCH 022/124] Update custom-compaction example to use serializeConversation Also fix docs to show convertToLlm is needed first. --- packages/coding-agent/docs/compaction.md | 8 +++++--- .../examples/hooks/custom-compaction.ts | 15 +++++++++------ 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/packages/coding-agent/docs/compaction.md b/packages/coding-agent/docs/compaction.md index bb11867b..66055c47 100644 --- a/packages/coding-agent/docs/compaction.md +++ b/packages/coding-agent/docs/compaction.md @@ -303,13 +303,15 @@ pi.on("session_before_compact", async (event, ctx) => { To generate a summary with your own model, convert messages to text using `serializeConversation`: ```typescript -import { serializeConversation } from "@mariozechner/pi-coding-agent"; +import { convertToLlm, serializeConversation } from "@mariozechner/pi-coding-agent"; pi.on("session_before_compact", async (event, ctx) => { const { preparation } = event; - // Convert messages to readable text format - const conversationText = serializeConversation(preparation.messagesToSummarize); + // Convert AgentMessage[] to Message[], then serialize to text + const conversationText = serializeConversation( + convertToLlm(preparation.messagesToSummarize) + ); // Returns: // [User]: message text // [Assistant thinking]: thinking content diff --git a/packages/coding-agent/examples/hooks/custom-compaction.ts b/packages/coding-agent/examples/hooks/custom-compaction.ts index 3dd0176b..32b965b4 100644 --- a/packages/coding-agent/examples/hooks/custom-compaction.ts +++ b/packages/coding-agent/examples/hooks/custom-compaction.ts @@ -14,7 +14,7 @@ */ import { complete, getModel } from "@mariozechner/pi-ai"; -import { convertToLlm } from "@mariozechner/pi-coding-agent"; +import { convertToLlm, serializeConversation } from "@mariozechner/pi-coding-agent"; import type { HookAPI } from "@mariozechner/pi-coding-agent/hooks"; export default function (pi: HookAPI) { @@ -46,21 +46,20 @@ export default function (pi: HookAPI) { "info", ); - // Transform app messages to pi-ai package format - const transformedMessages = convertToLlm(allMessages); + // Convert messages to readable text format + const conversationText = serializeConversation(convertToLlm(allMessages)); // Include previous summary context if available const previousContext = previousSummary ? `\n\nPrevious session summary for context:\n${previousSummary}` : ""; // Build messages that ask for a comprehensive summary const summaryMessages = [ - ...transformedMessages, { role: "user" as const, content: [ { type: "text" as const, - text: `You are a conversation summarizer. Create a comprehensive summary of this entire conversation that captures:${previousContext} + text: `You are a conversation summarizer. Create a comprehensive summary of this conversation that captures:${previousContext} 1. The main goals and objectives discussed 2. Key decisions made and their rationale @@ -71,7 +70,11 @@ export default function (pi: HookAPI) { Be thorough but concise. The summary will replace the ENTIRE conversation history, so include all information needed to continue the work effectively. -Format the summary as structured markdown with clear sections.`, +Format the summary as structured markdown with clear sections. + + +${conversationText} +`, }, ], timestamp: Date.now(), From 8e1e99ca0522e2e727ab995b7a4f35d6ae3d0617 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Wed, 31 Dec 2025 13:47:34 +0100 Subject: [PATCH 023/124] Change branch() to use entryId instead of entryIndex - AgentSession.branch(entryId: string) now takes entry ID - SessionBeforeBranchEvent.entryId replaces entryIndex - getUserMessagesForBranching() returns entryId - Update RPC types and client - Update UserMessageSelectorComponent - Update hook examples and tests - Update docs (hooks.md, sdk.md) --- packages/coding-agent/docs/hooks.md | 18 ++++++++++----- packages/coding-agent/docs/sdk.md | 17 +++++++------- .../examples/hooks/confirm-destructive.ts | 2 +- .../examples/hooks/git-checkpoint.ts | 17 +++++++++----- .../coding-agent/src/core/agent-session.ts | 22 +++++++++---------- packages/coding-agent/src/core/hooks/types.ts | 4 ++-- .../components/user-message-selector.ts | 8 +++---- .../src/modes/interactive/interactive-mode.ts | 6 ++--- .../coding-agent/src/modes/rpc/rpc-client.ts | 8 +++---- .../coding-agent/src/modes/rpc/rpc-mode.ts | 2 +- .../coding-agent/src/modes/rpc/rpc-types.ts | 4 ++-- .../test/agent-session-branching.test.ts | 6 ++--- 12 files changed, 64 insertions(+), 50 deletions(-) diff --git a/packages/coding-agent/docs/hooks.md b/packages/coding-agent/docs/hooks.md index 2cde1a0c..dba42e0b 100644 --- a/packages/coding-agent/docs/hooks.md +++ b/packages/coding-agent/docs/hooks.md @@ -195,7 +195,7 @@ Fired when branching via `/branch`. ```typescript pi.on("session_before_branch", async (event, ctx) => { - // event.entryIndex - entry index being branched from + // event.entryId - ID of the entry being branched from return { cancel: true }; // Cancel branch // OR @@ -634,15 +634,23 @@ export default function (pi: HookAPI) { import type { HookAPI } from "@mariozechner/pi-coding-agent/hooks"; export default function (pi: HookAPI) { - const checkpoints = new Map(); + const checkpoints = new Map(); + let currentEntryId: string | undefined; - pi.on("turn_start", async (event) => { + pi.on("tool_result", async (_event, ctx) => { + const leaf = ctx.sessionManager.getLeafEntry(); + if (leaf) currentEntryId = leaf.id; + }); + + pi.on("turn_start", async () => { const { stdout } = await pi.exec("git", ["stash", "create"]); - if (stdout.trim()) checkpoints.set(event.turnIndex, stdout.trim()); + if (stdout.trim() && currentEntryId) { + checkpoints.set(currentEntryId, stdout.trim()); + } }); pi.on("session_before_branch", async (event, ctx) => { - const ref = checkpoints.get(event.entryIndex); + const ref = checkpoints.get(event.entryId); if (!ref || !ctx.hasUI) return; const ok = await ctx.ui.confirm("Restore?", "Restore code to checkpoint?"); diff --git a/packages/coding-agent/docs/sdk.md b/packages/coding-agent/docs/sdk.md index 0a9eccdc..6363fa1e 100644 --- a/packages/coding-agent/docs/sdk.md +++ b/packages/coding-agent/docs/sdk.md @@ -99,11 +99,12 @@ interface AgentSession { isStreaming: boolean; // Session management - newSession(): Promise; // Returns false if cancelled by hook + reset(): Promise; // Returns false if cancelled by hook switchSession(sessionPath: string): Promise; - // Branching (tree-based) - branch(entryId: string): Promise<{ cancelled: boolean }>; + // Branching + branch(entryId: string): Promise<{ selectedText: string; cancelled: boolean }>; // Creates new session file + navigateTree(targetId: string, options?: { summarize?: boolean }): Promise<{ editorText?: string; cancelled: boolean }>; // In-place navigation // Hook message injection sendHookMessage(message: HookMessage, triggerTurn?: boolean): void; @@ -400,10 +401,10 @@ const { session } = await createAgentSession({ ```typescript import { Type } from "@sinclair/typebox"; -import { createAgentSession, discoverCustomTools, type CustomAgentTool } from "@mariozechner/pi-coding-agent"; +import { createAgentSession, discoverCustomTools, type CustomTool } from "@mariozechner/pi-coding-agent"; // Inline custom tool -const myTool: CustomAgentTool = { +const myTool: CustomTool = { name: "my_tool", label: "My Tool", description: "Does something useful", @@ -793,7 +794,7 @@ import { readTool, bashTool, type HookFactory, - type CustomAgentTool, + type CustomTool, } from "@mariozechner/pi-coding-agent"; // Set up auth storage (custom location) @@ -816,7 +817,7 @@ const auditHook: HookFactory = (api) => { }; // Inline tool -const statusTool: CustomAgentTool = { +const statusTool: CustomTool = { name: "status", label: "Status", description: "Get system status", @@ -932,7 +933,7 @@ createGrepTool, createFindTool, createLsTool // Types type CreateAgentSessionOptions type CreateAgentSessionResult -type CustomAgentTool +type CustomTool type HookFactory type Skill type FileSlashCommand diff --git a/packages/coding-agent/examples/hooks/confirm-destructive.ts b/packages/coding-agent/examples/hooks/confirm-destructive.ts index 75c5ee0c..ef189b23 100644 --- a/packages/coding-agent/examples/hooks/confirm-destructive.ts +++ b/packages/coding-agent/examples/hooks/confirm-destructive.ts @@ -45,7 +45,7 @@ export default function (pi: HookAPI) { pi.on("session_before_branch", async (event, ctx) => { if (!ctx.hasUI) return; - const choice = await ctx.ui.select(`Branch from turn ${event.entryIndex}?`, [ + const choice = await ctx.ui.select(`Branch from entry ${event.entryId.slice(0, 8)}?`, [ "Yes, create branch", "No, stay in current session", ]); diff --git a/packages/coding-agent/examples/hooks/git-checkpoint.ts b/packages/coding-agent/examples/hooks/git-checkpoint.ts index 87c8f0b5..6190be0d 100644 --- a/packages/coding-agent/examples/hooks/git-checkpoint.ts +++ b/packages/coding-agent/examples/hooks/git-checkpoint.ts @@ -8,19 +8,26 @@ import type { HookAPI } from "@mariozechner/pi-coding-agent/hooks"; export default function (pi: HookAPI) { - const checkpoints = new Map(); + const checkpoints = new Map(); + let currentEntryId: string | undefined; - pi.on("turn_start", async (event) => { + // Track the current entry ID when user messages are saved + pi.on("tool_result", async (_event, ctx) => { + const leaf = ctx.sessionManager.getLeafEntry(); + if (leaf) currentEntryId = leaf.id; + }); + + pi.on("turn_start", async () => { // Create a git stash entry before LLM makes changes const { stdout } = await pi.exec("git", ["stash", "create"]); const ref = stdout.trim(); - if (ref) { - checkpoints.set(event.turnIndex, ref); + if (ref && currentEntryId) { + checkpoints.set(currentEntryId, ref); } }); pi.on("session_before_branch", async (event, ctx) => { - const ref = checkpoints.get(event.entryIndex); + const ref = checkpoints.get(event.entryId); if (!ref) return; if (!ctx.hasUI) { diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index e2d63e09..3e5db9aa 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -1498,21 +1498,20 @@ export class AgentSession { } /** - * Create a branch from a specific entry index. + * Create a branch from a specific entry. * Emits before_branch/branch session events to hooks. * - * @param entryIndex Index into session entries to branch from + * @param entryId ID of the entry to branch from * @returns Object with: * - selectedText: The text of the selected user message (for editor pre-fill) * - cancelled: True if a hook cancelled the branch */ - async branch(entryIndex: number): Promise<{ selectedText: string; cancelled: boolean }> { + async branch(entryId: string): Promise<{ selectedText: string; cancelled: boolean }> { const previousSessionFile = this.sessionFile; - const entries = this.sessionManager.getEntries(); - const selectedEntry = entries[entryIndex]; + const selectedEntry = this.sessionManager.getEntry(entryId); if (!selectedEntry || selectedEntry.type !== "message" || selectedEntry.message.role !== "user") { - throw new Error("Invalid entry index for branching"); + throw new Error("Invalid entry ID for branching"); } const selectedText = this._extractUserMessageText(selectedEntry.message.content); @@ -1523,7 +1522,7 @@ export class AgentSession { if (this._hookRunner?.hasHandlers("session_before_branch")) { const result = (await this._hookRunner.emit({ type: "session_before_branch", - entryIndex: entryIndex, + entryId, })) as SessionBeforeBranchResult | undefined; if (result?.cancel) { @@ -1729,18 +1728,17 @@ export class AgentSession { /** * Get all user messages from session for branch selector. */ - getUserMessagesForBranching(): Array<{ entryIndex: number; text: string }> { + getUserMessagesForBranching(): Array<{ entryId: string; text: string }> { const entries = this.sessionManager.getEntries(); - const result: Array<{ entryIndex: number; text: string }> = []; + const result: Array<{ entryId: string; text: string }> = []; - for (let i = 0; i < entries.length; i++) { - const entry = entries[i]; + for (const entry of entries) { if (entry.type !== "message") continue; if (entry.message.role !== "user") continue; const text = this._extractUserMessageText(entry.message.content); if (text) { - result.push({ entryIndex: i, text }); + result.push({ entryId: entry.id, text }); } } diff --git a/packages/coding-agent/src/core/hooks/types.ts b/packages/coding-agent/src/core/hooks/types.ts index 879b111c..6acc843a 100644 --- a/packages/coding-agent/src/core/hooks/types.ts +++ b/packages/coding-agent/src/core/hooks/types.ts @@ -121,8 +121,8 @@ export interface SessionNewEvent { /** Fired before branching a session (can be cancelled) */ export interface SessionBeforeBranchEvent { type: "session_before_branch"; - /** Index of the entry in the session (SessionManager.getEntries()) to branch from */ - entryIndex: number; + /** ID of the entry to branch from */ + entryId: string; } /** Fired after branching a session */ diff --git a/packages/coding-agent/src/modes/interactive/components/user-message-selector.ts b/packages/coding-agent/src/modes/interactive/components/user-message-selector.ts index 18cd769c..8a8f2152 100644 --- a/packages/coding-agent/src/modes/interactive/components/user-message-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/user-message-selector.ts @@ -14,7 +14,7 @@ import { theme } from "../theme/theme.js"; import { DynamicBorder } from "./dynamic-border.js"; interface UserMessageItem { - index: number; // Index in the full messages array + id: string; // Entry ID in the session text: string; // The message text timestamp?: string; // Optional timestamp if available } @@ -25,7 +25,7 @@ interface UserMessageItem { class UserMessageList implements Component { private messages: UserMessageItem[] = []; private selectedIndex: number = 0; - public onSelect?: (messageIndex: number) => void; + public onSelect?: (entryId: string) => void; public onCancel?: () => void; private maxVisible: number = 10; // Max messages visible @@ -101,7 +101,7 @@ class UserMessageList implements Component { else if (isEnter(keyData)) { const selected = this.messages[this.selectedIndex]; if (selected && this.onSelect) { - this.onSelect(selected.index); + this.onSelect(selected.id); } } // Escape - cancel @@ -125,7 +125,7 @@ class UserMessageList implements Component { export class UserMessageSelectorComponent extends Container { private messageList: UserMessageList; - constructor(messages: UserMessageItem[], onSelect: (messageIndex: number) => void, onCancel: () => void) { + constructor(messages: UserMessageItem[], onSelect: (entryId: string) => void, onCancel: () => void) { super(); // Add header diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 809eca88..42ef8be6 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -1570,9 +1570,9 @@ export class InteractiveMode { this.showSelector((done) => { const selector = new UserMessageSelectorComponent( - userMessages.map((m) => ({ index: m.entryIndex, text: m.text })), - async (entryIndex) => { - const result = await this.session.branch(entryIndex); + userMessages.map((m) => ({ id: m.entryId, text: m.text })), + async (entryId) => { + const result = await this.session.branch(entryId); if (result.cancelled) { // Hook cancelled the branch done(); diff --git a/packages/coding-agent/src/modes/rpc/rpc-client.ts b/packages/coding-agent/src/modes/rpc/rpc-client.ts index 0249ca11..7877ab36 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-client.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-client.ts @@ -326,17 +326,17 @@ export class RpcClient { * Branch from a specific message. * @returns Object with `text` (the message text) and `cancelled` (if hook cancelled) */ - async branch(entryIndex: number): Promise<{ text: string; cancelled: boolean }> { - const response = await this.send({ type: "branch", entryIndex }); + async branch(entryId: string): Promise<{ text: string; cancelled: boolean }> { + const response = await this.send({ type: "branch", entryId }); return this.getData(response); } /** * Get messages available for branching. */ - async getBranchMessages(): Promise> { + async getBranchMessages(): Promise> { const response = await this.send({ type: "get_branch_messages" }); - return this.getData<{ messages: Array<{ entryIndex: number; text: string }> }>(response).messages; + return this.getData<{ messages: Array<{ entryId: string; text: string }> }>(response).messages; } /** diff --git a/packages/coding-agent/src/modes/rpc/rpc-mode.ts b/packages/coding-agent/src/modes/rpc/rpc-mode.ts index e9fabf2a..09a0fde6 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-mode.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-mode.ts @@ -347,7 +347,7 @@ export async function runRpcMode(session: AgentSession): Promise { } case "branch": { - const result = await session.branch(command.entryIndex); + const result = await session.branch(command.entryId); return success(id, "branch", { text: result.selectedText, cancelled: result.cancelled }); } diff --git a/packages/coding-agent/src/modes/rpc/rpc-types.ts b/packages/coding-agent/src/modes/rpc/rpc-types.ts index 5feead90..aa525687 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-types.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-types.ts @@ -53,7 +53,7 @@ export type RpcCommand = | { id?: string; type: "get_session_stats" } | { id?: string; type: "export_html"; outputPath?: string } | { id?: string; type: "switch_session"; sessionPath: string } - | { id?: string; type: "branch"; entryIndex: number } + | { id?: string; type: "branch"; entryId: string } | { id?: string; type: "get_branch_messages" } | { id?: string; type: "get_last_assistant_text" } @@ -150,7 +150,7 @@ export type RpcResponse = type: "response"; command: "get_branch_messages"; success: true; - data: { messages: Array<{ entryIndex: number; text: string }> }; + data: { messages: Array<{ entryId: string; text: string }> }; } | { id?: string; diff --git a/packages/coding-agent/test/agent-session-branching.test.ts b/packages/coding-agent/test/agent-session-branching.test.ts index 71b78ab7..33f70853 100644 --- a/packages/coding-agent/test/agent-session-branching.test.ts +++ b/packages/coding-agent/test/agent-session-branching.test.ts @@ -83,7 +83,7 @@ describe.skipIf(!API_KEY)("AgentSession branching", () => { expect(userMessages[0].text).toBe("Say hello"); // Branch from the first message - const result = await session.branch(userMessages[0].entryIndex); + const result = await session.branch(userMessages[0].entryId); expect(result.selectedText).toBe("Say hello"); expect(result.cancelled).toBe(false); @@ -113,7 +113,7 @@ describe.skipIf(!API_KEY)("AgentSession branching", () => { expect(session.messages.length).toBeGreaterThan(0); // Branch from the first message - const result = await session.branch(userMessages[0].entryIndex); + const result = await session.branch(userMessages[0].entryId); expect(result.selectedText).toBe("Say hi"); expect(result.cancelled).toBe(false); @@ -143,7 +143,7 @@ describe.skipIf(!API_KEY)("AgentSession branching", () => { // Branch from second message (keeps first message + response) const secondMessage = userMessages[1]; - const result = await session.branch(secondMessage.entryIndex); + const result = await session.branch(secondMessage.entryId); expect(result.selectedText).toBe("Say two"); // After branching, should have first user message + assistant response From 8fb936853b1ccf7dcc0eedd6d474ec5fd82451ca Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Wed, 31 Dec 2025 13:48:22 +0100 Subject: [PATCH 024/124] Add sdk.md intro, update CHANGELOG for branch() change --- packages/coding-agent/CHANGELOG.md | 1 + packages/coding-agent/docs/sdk.md | 2 ++ 2 files changed, 3 insertions(+) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index e7b346a4..76307cc8 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -57,6 +57,7 @@ - `CustomToolContext` provides `sessionManager: ReadonlySessionManager`, `modelRegistry`, and `model` - `dispose()` method removed - use `onSession` with `reason: "shutdown"` for cleanup - `CustomToolFactory` return type changed to `CustomTool` for type compatibility +- **AgentSession.branch()**: Now takes `entryId: string` instead of `entryIndex: number`. `SessionBeforeBranchEvent.entryId` replaces `entryIndex`. `getUserMessagesForBranching()` returns `{ entryId, text }` instead of `{ entryIndex, text }`. - **Renamed exports**: - `messageTransformer` → `convertToLlm` - `SessionContext` alias `LoadedSession` removed (use `SessionContext` directly) diff --git a/packages/coding-agent/docs/sdk.md b/packages/coding-agent/docs/sdk.md index 6363fa1e..bd86f465 100644 --- a/packages/coding-agent/docs/sdk.md +++ b/packages/coding-agent/docs/sdk.md @@ -1,3 +1,5 @@ +> pi can help you use the SDK. Ask it to build an integration for your use case. + # SDK The SDK provides programmatic access to pi's agent capabilities. Use it to embed pi in other applications, build custom interfaces, or integrate with automated workflows. From ee64d294877d1797f936c68e583c1ad4163f91c6 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Wed, 31 Dec 2025 13:50:01 +0100 Subject: [PATCH 025/124] Fix sdk.md and rpc.md to match actual API - Remove incorrect prompt(AppMessage) overload - Change AppMessage to AgentMessage - Change null to undefined for optional returns - sendHookMessage returns Promise - Update rpc.md for entryId change --- packages/coding-agent/docs/rpc.md | 6 +++--- packages/coding-agent/docs/sdk.md | 13 ++++++------- 2 files changed, 9 insertions(+), 10 deletions(-) diff --git a/packages/coding-agent/docs/rpc.md b/packages/coding-agent/docs/rpc.md index 491049c6..bf1276f0 100644 --- a/packages/coding-agent/docs/rpc.md +++ b/packages/coding-agent/docs/rpc.md @@ -491,7 +491,7 @@ If a hook cancelled the switch: Create a new branch from a previous user message. Can be cancelled by a `before_branch` hook. Returns the text of the message being branched from. ```json -{"type": "branch", "entryIndex": 2} +{"type": "branch", "entryId": "abc123"} ``` Response: @@ -530,8 +530,8 @@ Response: "success": true, "data": { "messages": [ - {"entryIndex": 0, "text": "First prompt..."}, - {"entryIndex": 2, "text": "Second prompt..."} + {"entryId": "abc123", "text": "First prompt..."}, + {"entryId": "def456", "text": "Second prompt..."} ] } } diff --git a/packages/coding-agent/docs/sdk.md b/packages/coding-agent/docs/sdk.md index bd86f465..9a8354dc 100644 --- a/packages/coding-agent/docs/sdk.md +++ b/packages/coding-agent/docs/sdk.md @@ -78,7 +78,6 @@ The session manages the agent lifecycle, message history, and event streaming. interface AgentSession { // Send a prompt and wait for completion prompt(text: string, options?: PromptOptions): Promise; - prompt(message: AppMessage): Promise; // For HookMessage, etc. // Subscribe to events (returns unsubscribe function) subscribe(listener: (event: AgentSessionEvent) => void): () => void; @@ -90,14 +89,14 @@ interface AgentSession { // Model control setModel(model: Model): Promise; setThinkingLevel(level: ThinkingLevel): void; - cycleModel(): Promise; - cycleThinkingLevel(): ThinkingLevel | null; + cycleModel(): Promise; + cycleThinkingLevel(): ThinkingLevel | undefined; // State access agent: Agent; - model: Model | null; + model: Model | undefined; thinkingLevel: ThinkingLevel; - messages: AppMessage[]; + messages: AgentMessage[]; isStreaming: boolean; // Session management @@ -109,7 +108,7 @@ interface AgentSession { navigateTree(targetId: string, options?: { summarize?: boolean }): Promise<{ editorText?: string; cancelled: boolean }>; // In-place navigation // Hook message injection - sendHookMessage(message: HookMessage, triggerTurn?: boolean): void; + sendHookMessage(message: HookMessage, triggerTurn?: boolean): Promise; // Compaction compact(customInstructions?: string): Promise; @@ -131,7 +130,7 @@ The `Agent` class (from `@mariozechner/pi-agent-core`) handles the core LLM inte // Access current state const state = session.agent.state; -// state.messages: AppMessage[] - conversation history +// state.messages: AgentMessage[] - conversation history // state.model: Model - current model // state.thinkingLevel: ThinkingLevel - current thinking level // state.systemPrompt: string - system prompt From 17ed3fa605d11bb7cf2111f36f6594855a2d02b2 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Wed, 31 Dec 2025 13:53:45 +0100 Subject: [PATCH 026/124] Update rpc.md to match actual implementation - AppMessage -> AgentMessage - compact response shows full CompactionResult fields - auto_compaction_start includes reason field - auto_compaction_end includes willRetry field - Fix source file references --- packages/coding-agent/docs/rpc.md | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/packages/coding-agent/docs/rpc.md b/packages/coding-agent/docs/rpc.md index bf1276f0..6b968dfc 100644 --- a/packages/coding-agent/docs/rpc.md +++ b/packages/coding-agent/docs/rpc.md @@ -145,7 +145,7 @@ Response: } ``` -Messages are `AppMessage` objects (see [Message Types](#message-types)). +Messages are `AgentMessage` objects (see [Message Types](#message-types)). ### Model @@ -289,8 +289,10 @@ Response: "command": "compact", "success": true, "data": { + "summary": "Summary of conversation...", + "firstKeptEntryId": "abc123", "tokensBefore": 150000, - "summary": "Summary of conversation..." + "details": {} } } ``` @@ -618,7 +620,7 @@ A turn consists of one assistant response plus any resulting tool calls and resu ### message_start / message_end -Emitted when a message begins and completes. The `message` field contains an `AppMessage`. +Emitted when a message begins and completes. The `message` field contains an `AgentMessage`. ```json {"type": "message_start", "message": {...}} @@ -717,20 +719,27 @@ Use `toolCallId` to correlate events. The `partialResult` in `tool_execution_upd Emitted when automatic compaction runs (when context is nearly full). ```json -{"type": "auto_compaction_start"} +{"type": "auto_compaction_start", "reason": "threshold"} ``` +The `reason` field is `"threshold"` (context getting large) or `"overflow"` (context exceeded limit). + ```json { "type": "auto_compaction_end", "result": { + "summary": "Summary of conversation...", + "firstKeptEntryId": "abc123", "tokensBefore": 150000, - "summary": "Summary of conversation..." + "details": {} }, - "aborted": false + "aborted": false, + "willRetry": false } ``` +If `reason` was `"overflow"` and compaction succeeds, `willRetry` is `true` and the agent will automatically retry the prompt. + If compaction was aborted, `result` is `null` and `aborted` is `true`. ### auto_retry_start / auto_retry_end @@ -806,7 +815,7 @@ Parse errors: Source files: - [`packages/ai/src/types.ts`](../../ai/src/types.ts) - `Model`, `UserMessage`, `AssistantMessage`, `ToolResultMessage` -- [`packages/agent/src/types.ts`](../../agent/src/types.ts) - `AppMessage`, `Attachment`, `AgentEvent` +- [`packages/agent/src/types.ts`](../../agent/src/types.ts) - `AgentMessage`, `AgentEvent` - [`src/core/messages.ts`](../src/core/messages.ts) - `BashExecutionMessage` - [`src/modes/rpc/rpc-types.ts`](../src/modes/rpc/rpc-types.ts) - RPC command/response types From b72cf47409950e149a329a60200799eb5c3ffc2c Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Wed, 31 Dec 2025 14:11:55 +0100 Subject: [PATCH 027/124] Rewrite CHANGELOG.md with migration guides - Session Tree: brief description, reference session.md - Hooks Migration: type renames, event changes, API changes - Custom Tools Migration: type renames, execute signature, context object - SDK Migration: type changes, branching API, exports - RPC Migration: entryId, AgentMessage, compaction events - Structured Compaction: output format, file tracking - Interactive Mode: /tree, labels, themes, settings - Keep Fixed section with external contributions --- packages/coding-agent/CHANGELOG.md | 226 ++++++++++++++++++----------- 1 file changed, 138 insertions(+), 88 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 76307cc8..ae2836ce 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,103 +2,153 @@ ## [Unreleased] -### Breaking Changes +This release introduces session trees for in-place branching, major API changes to hooks and custom tools, and structured compaction with file tracking. -- **Session tree structure (v2)**: Sessions now store entries as a tree with `id`/`parentId` fields, enabling in-place branching without creating new files. Existing v1 sessions are auto-migrated on load. -- **SessionManager API**: - - `saveXXX()` renamed to `appendXXX()` (e.g., `appendMessage`, `appendCompaction`) - - `branchInPlace()` renamed to `branch()` - - `reset()` renamed to `newSession()` - - `createBranchedSessionFromEntries(entries, index)` replaced with `createBranchedSession(leafId)` - - `saveCompaction(entry)` replaced with `appendCompaction(summary, firstKeptEntryId, tokensBefore)` - - `getEntries()` now excludes the session header (use `getHeader()` separately) - - New methods: `getTree()`, `getPath()`, `getLeafUuid()`, `getLeafEntry()`, `getEntry()`, `branchWithSummary()` - - New `appendCustomEntry(customType, data)` for hooks to store custom data (not in LLM context) - - New `appendCustomMessageEntry(customType, content, display, details?)` for hooks to inject messages into LLM context -- **Compaction API**: - - `CompactionEntry` and `CompactionResult` are now generic with optional `details?: T` for hook-specific data - - `compact()` now returns `CompactionResult` (`{ summary, firstKeptEntryId, tokensBefore, details? }`) instead of `CompactionEntry` - - `appendCompaction()` now accepts optional `details` parameter - - `CompactionEntry.firstKeptEntryIndex` replaced with `firstKeptEntryId` - - `prepareCompaction(pathEntries, settings)` now takes path entries (from `getPath()`) and settings only - - `CompactionPreparation` restructured: removed `cutPoint`, `messagesToKeep`, `boundaryStart`; added `turnPrefixMessages`, `isSplitTurn`, `previousSummary`, `fileOps`, `settings` - - `compact(preparation, model, apiKey, customInstructions?, signal?)` now takes preparation and execution context separately -- **Hook types**: - - `HookEventContext` renamed to `HookContext` - - `HookContext` now has `sessionManager`, `modelRegistry`, and `model` (current model, may be undefined) - - `HookCommandContext` removed - `RegisteredCommand.handler` now takes `(args: string, ctx: HookContext)` - - `before_compact` event: removed `previousCompactions` and `model`, added `branchEntries: SessionEntry[]` (hooks extract what they need) - - `before_tree` event: removed `model` (use `ctx.model` instead) - - `before_switch` event now has `targetSessionFile`, `switch` event has `previousSessionFile` - - Removed `resolveApiKey` (use `modelRegistry.getApiKey(model)`) - - Hooks can return `compaction.details` to store custom data (e.g., ArtifactIndex for structured compaction) -- **Hook API**: - - `pi.send(text, attachments?)` replaced with `pi.sendMessage(message, triggerTurn?)` which creates `CustomMessageEntry` instead of user messages - - New `pi.appendEntry(customType, data?)` to persist hook state (does NOT participate in LLM context) - - New `pi.registerCommand(name, options)` to register custom slash commands - - New `pi.registerMessageRenderer(customType, renderer)` to register custom renderers for hook messages - - New `pi.exec(command, args, options?)` to execute shell commands (moved from `HookEventContext`/`HookCommandContext`) - - `HookMessageRenderer` type: `(message: HookMessage, options, theme) => Component | null` - - Renderers return inner content; the TUI wraps it in a styled Box - - New types: `HookMessage`, `RegisteredCommand`, `HookContext` - - Handler types renamed: `SendHandler` → `SendMessageHandler`, new `AppendEntryHandler` - - Removed `hookTimeout` setting - hooks no longer have execution timeouts (use Ctrl+C to abort hung hooks) -- **SessionManager**: - - `getSessionFile()` now returns `string | undefined` (undefined for in-memory sessions) -- **Themes**: Custom themes must add `selectedBg`, `customMessageBg`, `customMessageText`, `customMessageLabel` color tokens (50 total) -- **Custom tools API**: - - `CustomAgentTool` renamed to `CustomTool` - - `ToolAPI` renamed to `CustomToolAPI` - - `ToolContext` renamed to `CustomToolContext` - - `ToolSessionEvent` renamed to `CustomToolSessionEvent` - - `execute()` signature changed: now takes `(toolCallId, params, onUpdate, ctx: CustomToolContext, signal?)` - - `onSession()` signature changed: now takes `(event: CustomToolSessionEvent, ctx: CustomToolContext)` - - `CustomToolSessionEvent` simplified: only has `reason` and `previousSessionFile` (use `ctx.sessionManager.getBranch()` to get entries) - - `CustomToolContext` provides `sessionManager: ReadonlySessionManager`, `modelRegistry`, and `model` - - `dispose()` method removed - use `onSession` with `reason: "shutdown"` for cleanup - - `CustomToolFactory` return type changed to `CustomTool` for type compatibility -- **AgentSession.branch()**: Now takes `entryId: string` instead of `entryIndex: number`. `SessionBeforeBranchEvent.entryId` replaces `entryIndex`. `getUserMessagesForBranching()` returns `{ entryId, text }` instead of `{ entryIndex, text }`. -- **Renamed exports**: - - `messageTransformer` → `convertToLlm` - - `SessionContext` alias `LoadedSession` removed (use `SessionContext` directly) -- **Removed exports**: - - `createSummaryMessage()` - replaced by internal compaction logic - - `SUMMARY_PREFIX`, `SUMMARY_SUFFIX` - no longer used - - `Attachment` type - use `ImageContent` from `@mariozechner/pi-ai` instead -- **New exports**: - - `BranchSummaryEntry`, `CustomEntry`, `CustomMessageEntry`, `LabelEntry` - new session entry types - - `SessionEntryBase`, `FileEntry` - base types for session entries - - `CURRENT_SESSION_VERSION`, `migrateSessionEntries` - session migration utilities - - `BranchPreparation`, `BranchSummaryResult`, `CollectEntriesResult`, `GenerateBranchSummaryOptions` - branch summarization types - - `FileOperations`, `collectEntriesForBranchSummary`, `prepareBranchEntries`, `generateBranchSummary` - branch summarization utilities - - `CompactionPreparation`, `CompactionDetails` - compaction preparation types - - `ReadonlySessionManager` - read-only session manager interface for hooks - - `HookMessage`, `HookContext`, `HookMessageRenderOptions` - hook types - - `isHookMessage`, `createHookMessage` - hook message utilities +### Session Tree + +Sessions now use a tree structure with `id`/`parentId` fields. This enables in-place branching: navigate to any previous point with `/tree`, continue from there, and switch between branches while preserving all history in a single file. + +**Existing sessions are automatically migrated** (v1 → v2) on first load. No manual action required. + +New entry types: `BranchSummaryEntry` (context from abandoned branches), `CustomEntry` (hook state), `CustomMessageEntry` (hook-injected messages), `LabelEntry` (bookmarks). + +See [docs/session.md](docs/session.md) for the file format and `SessionManager` API. + +### Hooks Migration + +The hooks API has been restructured with more granular events and better session access. + +**Type renames:** +- `HookEventContext` → `HookContext` +- `HookCommandContext` removed (use `HookContext` for command handlers) + +**Event changes:** +- The monolithic `session` event is now split into granular events: `session_start`, `session_before_switch`, `session_switch`, `session_before_new`, `session_new`, `session_before_branch`, `session_branch`, `session_before_compact`, `session_compact`, `session_before_tree`, `session_tree`, `session_shutdown` +- New `before_agent_start` event: inject messages before the agent loop starts +- New `context` event: modify messages non-destructively before each LLM call +- Session entries are no longer passed in events. Use `ctx.sessionManager.getEntries()` or `ctx.sessionManager.getBranch()` instead + +**API changes:** +- `pi.send(text, attachments?)` → `pi.sendMessage(message, triggerTurn?)` (creates `CustomMessageEntry`) +- New `pi.appendEntry(customType, data?)` for hook state persistence (not in LLM context) +- New `pi.registerCommand(name, options)` for custom slash commands +- New `pi.registerMessageRenderer(customType, renderer)` for custom TUI rendering +- New `ctx.ui.custom(component)` for full TUI component rendering with keyboard focus +- `ctx.exec()` moved to `pi.exec()` +- `ctx.sessionFile` → `ctx.sessionManager.getSessionFile()` +- New `ctx.modelRegistry` and `ctx.model` for API key resolution + +**Removed:** +- `hookTimeout` setting (hooks no longer have timeouts; use Ctrl+C to abort) +- `resolveApiKey` parameter (use `ctx.modelRegistry.getApiKey(model)`) + +See [docs/hooks.md](docs/hooks.md) and [examples/hooks/](examples/hooks/) for the current API. + +### Custom Tools Migration + +The custom tools API has been restructured to mirror the hooks pattern with a context object. + +**Type renames:** +- `CustomAgentTool` → `CustomTool` +- `ToolAPI` → `CustomToolAPI` +- `ToolContext` → `CustomToolContext` +- `ToolSessionEvent` → `CustomToolSessionEvent` + +**Execute signature changed:** +```typescript +// Before (v0.30.2) +execute(toolCallId, params, signal, onUpdate) + +// After +execute(toolCallId, params, onUpdate, ctx, signal?) +``` + +The new `ctx: CustomToolContext` provides `sessionManager`, `modelRegistry`, and `model`. + +**Session event changes:** +- `CustomToolSessionEvent` now only has `reason` and `previousSessionFile` +- Session entries are no longer in the event. Use `ctx.sessionManager.getBranch()` to reconstruct state +- New `reason: "tree"` for `/tree` navigation, `reason: "shutdown"` for cleanup +- `dispose()` method removed. Use `onSession` with `reason: "shutdown"` for cleanup + +See [docs/custom-tools.md](docs/custom-tools.md) and [examples/custom-tools/](examples/custom-tools/) for the current API. + +### SDK Migration + +**Type changes:** +- `CustomAgentTool` → `CustomTool` +- `AppMessage` → `AgentMessage` +- `sessionFile` returns `string | undefined` (was `string | null`) +- `model` returns `Model | undefined` (was `Model | null`) + +**Branching API:** +- `branch(entryIndex: number)` → `branch(entryId: string)` +- `getUserMessagesForBranching()` returns `{ entryId, text }` instead of `{ entryIndex, text }` +- `reset()` and `switchSession()` now return `Promise` (false if cancelled by hook) +- New `navigateTree(targetId, options?)` for in-place tree navigation + +**Hook integration:** +- New `sendHookMessage(message, triggerTurn?)` for hook message injection + +**Renamed exports:** +- `messageTransformer` → `convertToLlm` +- `SessionContext` alias `LoadedSession` removed + +See [docs/sdk.md](docs/sdk.md) and [examples/sdk/](examples/sdk/) for the current API. + +### RPC Migration + +**Branching commands:** +- `branch` command: `entryIndex` → `entryId` +- `get_branch_messages` response: `entryIndex` → `entryId` + +**Type changes:** +- Messages are now `AgentMessage` (was `AppMessage`) + +**Compaction events:** +- `auto_compaction_start` now includes `reason` field (`"threshold"` or `"overflow"`) +- `auto_compaction_end` now includes `willRetry` field +- `compact` response includes full `CompactionResult` (`summary`, `firstKeptEntryId`, `tokensBefore`, `details`) + +See [docs/rpc.md](docs/rpc.md) for the current protocol. + +### Structured Compaction + +Compaction and branch summarization now use a structured output format: +- Clear sections: Goal, Progress, Key Information, File Operations +- File tracking: `readFiles` and `modifiedFiles` arrays in `details`, accumulated across compactions +- Conversations are serialized to text before summarization to prevent the model from "continuing" them + +The `before_compact` and `before_tree` hook events allow custom compaction implementations. See [docs/compaction.md](docs/compaction.md). + +### Interactive Mode + +**`/tree` command:** +- Navigate the full session tree in-place +- Search by typing, page with ←/→ +- Filter modes (Ctrl+O): default → no-tools → user-only → labeled-only → all +- Press `l` to label entries as bookmarks +- Selecting a branch generates a summary and switches context + +**Entry labels:** +- Bookmark any entry via `/tree` → select → `l` +- Labels appear in tree view and persist as `LabelEntry` + +**Theme additions:** +- `selectedBg`: background for selected items +- `customMessageBg`, `customMessageText`, `customMessageLabel`: hook message styling + +**Settings:** +- `enabledModels`: whitelist models in `settings.json` (same format as `--models` CLI) ### Added -- **`/tree` command**: Navigate the session tree in-place. Shows full tree structure with labels, supports search (type to filter), page navigation (←/→), and filter modes (Ctrl+O cycles: default → no-tools → user-only → labeled-only → all, Shift+Ctrl+O cycles backwards). Selecting a branch generates a summary and switches context. Press `l` to label entries. -- **`context` hook event**: Fires before each LLM call, allowing hooks to non-destructively modify messages. Returns `{ messages }` to override. Useful for dynamic context pruning without modifying session history. -- **`before_agent_start` hook event**: Fires once when user submits a prompt, before `agent_start`. Hooks can return `{ message }` to inject a `CustomMessageEntry` that gets persisted and sent to the LLM. -- **`ui.custom()` for hooks**: Show arbitrary TUI components with keyboard focus. Call `done()` when finished: `ctx.ui.custom(component, done)`. -- **Branch summarization**: When switching branches via `/tree`, generates a summary of the abandoned branch including file operations (read/modified files). Summaries are stored as `BranchSummaryEntry` with cumulative file tracking in `details`. -- **Structured compaction**: Both compaction and branch summarization now use structured output format with clear sections (Goal, Progress, Key Information, File Operations). Conversations are serialized to text before summarization to prevent the model from "continuing" conversations. -- **File tracking in summaries**: Compaction and branch summaries now track `readFiles` and `modifiedFiles` arrays in the `details` field, accumulated across multiple compactions/summaries. This provides cumulative file operation history. -- **`selectedBg` theme color**: Background color for selected/active lines in tree selector and other components. -- **Entry labels**: Label any session entry with `/tree` → select entry → press `l`. Labels appear in tree view and are persisted as `LabelEntry` in the session. Use `labeled-only` filter mode to show only labeled entries. -- **`enabledModels` setting**: Configure whitelisted models in `settings.json` (same format as `--models` CLI flag). CLI `--models` takes precedence over the setting. -- **Snake game example hook**: Added `examples/hooks/snake.ts` demonstrating `ui.custom()`, `registerCommand()`, and session persistence. +- **Snake game example hook**: Demonstrates `ui.custom()`, `registerCommand()`, and session persistence. See [examples/hooks/snake.ts](examples/hooks/snake.ts). ### Changed - **Entry IDs**: Session entries now use short 8-character hex IDs instead of full UUIDs - **API key priority**: `ANTHROPIC_OAUTH_TOKEN` now takes precedence over `ANTHROPIC_API_KEY` -- **New entry types**: `BranchSummaryEntry` for branch context, `CustomEntry` for hook state persistence, `CustomMessageEntry` for hook-injected context messages, `LabelEntry` for user-defined bookmarks -- **TUI**: `CustomMessageEntry` renders with purple styling (customMessageBg, customMessageText, customMessageLabel theme colors). Entries with `display: false` are hidden. -- **AgentSession**: New `sendHookMessage(message, triggerTurn?)` method for hooks to inject messages. Handles queuing during streaming, direct append when idle, and optional turn triggering. -- **HookMessage**: New message type with `role: "hookMessage"` for hook-injected messages in agent events. Use `isHookMessage(msg)` type guard to identify them. These are converted to user messages for LLM context via `messageTransformer`. -- **Agent.prompt()**: Now accepts `AppMessage` directly (in addition to `string, attachments?`) for custom message types like `HookMessage`. ### Fixed From b0d68c23d938c6cda30c56af3df6b78bd4dcf18f Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Wed, 31 Dec 2025 14:13:30 +0100 Subject: [PATCH 028/124] Update README.md for new APIs - Add /tree to slash commands table - Expand Branching section with /tree in-place navigation - Update hooks example: pi.send() -> pi.sendMessage(), session -> session_start - Remove hookTimeout from settings (no longer exists) --- packages/coding-agent/README.md | 25 ++++++++++++++++++------- 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/packages/coding-agent/README.md b/packages/coding-agent/README.md index 0289bb38..b3d380ca 100644 --- a/packages/coding-agent/README.md +++ b/packages/coding-agent/README.md @@ -193,6 +193,7 @@ The agent reads, writes, and edits files, and executes commands via bash. | `/session` | Show session info: path, message counts, token usage, cost | | `/hotkeys` | Show all keyboard shortcuts | | `/changelog` | Display full version history | +| `/tree` | Navigate session tree in-place (search, filter, label entries) | | `/branch` | Create new conversation branch from a previous message | | `/resume` | Switch to a different session (interactive selector) | | `/login` | OAuth login for subscription-based models | @@ -343,7 +344,14 @@ Compaction does not create a new session, but continues the existing one, with a ### Branching -Use `/branch` to explore alternative conversation paths: +**In-place navigation (`/tree`):** Navigate the session tree without creating new files. Select any previous point, continue from there, and switch between branches while preserving all history. + +- Search by typing, page with ←/→ +- Filter modes (Ctrl+O): default → no-tools → user-only → labeled-only → all +- Press `l` to label entries as bookmarks +- Selecting a branch generates a summary and switches context + +**Create new session (`/branch`):** Branch to a new session file: 1. Opens selector showing all your user messages 2. Select a message to branch from @@ -612,18 +620,23 @@ export default function (pi: HookAPI) { **Sending messages from hooks:** -Use `pi.send(text, attachments?)` to inject messages into the session. If the agent is streaming, the message is queued; otherwise a new agent loop starts immediately. +Use `pi.sendMessage(message, triggerTurn?)` to inject messages into the session. Messages are persisted as `CustomMessageEntry` and sent to the LLM. If the agent is streaming, the message is queued; otherwise a new agent loop starts if `triggerTurn` is true. ```typescript import * as fs from "node:fs"; import type { HookAPI } from "@mariozechner/pi-coding-agent/hooks"; export default function (pi: HookAPI) { - pi.on("session", async (event) => { - if (event.reason !== "start") return; + pi.on("session_start", async () => { fs.watch("/tmp/trigger.txt", () => { const content = fs.readFileSync("/tmp/trigger.txt", "utf-8").trim(); - if (content) pi.send(content); + if (content) { + pi.sendMessage({ + customType: "file-trigger", + content, + display: true, + }, true); // triggerTurn: start agent loop + } }); }); } @@ -722,7 +735,6 @@ Global `~/.pi/agent/settings.json` stores persistent preferences: "showImages": true }, "hooks": ["/path/to/hook.ts"], - "hookTimeout": 30000, "customTools": ["/path/to/tool.ts"] } ``` @@ -747,7 +759,6 @@ Global `~/.pi/agent/settings.json` stores persistent preferences: | `retry.baseDelayMs` | Base delay for exponential backoff | `2000` | | `terminal.showImages` | Render images inline (supported terminals) | `true` | | `hooks` | Additional hook file paths | `[]` | -| `hookTimeout` | Timeout for hook operations (ms) | `30000` | | `customTools` | Additional custom tool file paths | `[]` | --- From ccfdd58619e43eb538579148b3a7986d8330a52e Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Wed, 31 Dec 2025 14:24:21 +0100 Subject: [PATCH 029/124] Clarify tree events are new in CHANGELOG.md - session_before_tree/session_tree are new events, not renamed - reason: tree and shutdown are new reasons for custom tools --- packages/coding-agent/CHANGELOG.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index ae2836ce..03c4da62 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -23,7 +23,8 @@ The hooks API has been restructured with more granular events and better session - `HookCommandContext` removed (use `HookContext` for command handlers) **Event changes:** -- The monolithic `session` event is now split into granular events: `session_start`, `session_before_switch`, `session_switch`, `session_before_new`, `session_new`, `session_before_branch`, `session_branch`, `session_before_compact`, `session_compact`, `session_before_tree`, `session_tree`, `session_shutdown` +- The monolithic `session` event is now split into granular events: `session_start`, `session_before_switch`, `session_switch`, `session_before_new`, `session_new`, `session_before_branch`, `session_branch`, `session_before_compact`, `session_compact`, `session_shutdown` +- New `session_before_tree` and `session_tree` events for `/tree` navigation (hook can provide custom branch summary) - New `before_agent_start` event: inject messages before the agent loop starts - New `context` event: modify messages non-destructively before each LLM call - Session entries are no longer passed in events. Use `ctx.sessionManager.getEntries()` or `ctx.sessionManager.getBranch()` instead @@ -68,7 +69,7 @@ The new `ctx: CustomToolContext` provides `sessionManager`, `modelRegistry`, and **Session event changes:** - `CustomToolSessionEvent` now only has `reason` and `previousSessionFile` - Session entries are no longer in the event. Use `ctx.sessionManager.getBranch()` to reconstruct state -- New `reason: "tree"` for `/tree` navigation, `reason: "shutdown"` for cleanup +- New reasons: `"tree"` (for `/tree` navigation) and `"shutdown"` (for cleanup on exit) - `dispose()` method removed. Use `onSession` with `reason: "shutdown"` for cleanup See [docs/custom-tools.md](docs/custom-tools.md) and [examples/custom-tools/](examples/custom-tools/) for the current API. From b0b8336a5f266eeaa0e821055a294da777c99b04 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Wed, 31 Dec 2025 14:24:47 +0100 Subject: [PATCH 030/124] Document Attachment type removal in CHANGELOG.md - SDK: Attachment removed, use ImageContent from @mariozechner/pi-ai - RPC: prompt command attachments field replaced with images field --- packages/coding-agent/CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 03c4da62..771b787f 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -81,6 +81,7 @@ See [docs/custom-tools.md](docs/custom-tools.md) and [examples/custom-tools/](ex - `AppMessage` → `AgentMessage` - `sessionFile` returns `string | undefined` (was `string | null`) - `model` returns `Model | undefined` (was `Model | null`) +- `Attachment` type removed. Use `ImageContent` from `@mariozechner/pi-ai` instead. Add images directly to message content arrays. **Branching API:** - `branch(entryIndex: number)` → `branch(entryId: string)` @@ -105,6 +106,7 @@ See [docs/sdk.md](docs/sdk.md) and [examples/sdk/](examples/sdk/) for the curren **Type changes:** - Messages are now `AgentMessage` (was `AppMessage`) +- `prompt` command: `attachments` field replaced with `images` field using `ImageContent` format **Compaction events:** - `auto_compaction_start` now includes `reason` field (`"threshold"` or `"overflow"`) From 2f2d5ffa52ff4963c785fece38790418aed54cea Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Wed, 31 Dec 2025 14:27:45 +0100 Subject: [PATCH 031/124] Specify AgentSession for branching API in CHANGELOG.md --- packages/coding-agent/CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 771b787f..d5ff5c9b 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -68,7 +68,7 @@ The new `ctx: CustomToolContext` provides `sessionManager`, `modelRegistry`, and **Session event changes:** - `CustomToolSessionEvent` now only has `reason` and `previousSessionFile` -- Session entries are no longer in the event. Use `ctx.sessionManager.getBranch()` to reconstruct state +- Session entries are no longer in the event. Use `ctx.sessionManager.getBranch()` or `ctx.sessionManager.getEntries()` to reconstruct state - New reasons: `"tree"` (for `/tree` navigation) and `"shutdown"` (for cleanup on exit) - `dispose()` method removed. Use `onSession` with `reason: "shutdown"` for cleanup @@ -83,7 +83,7 @@ See [docs/custom-tools.md](docs/custom-tools.md) and [examples/custom-tools/](ex - `model` returns `Model | undefined` (was `Model | null`) - `Attachment` type removed. Use `ImageContent` from `@mariozechner/pi-ai` instead. Add images directly to message content arrays. -**Branching API:** +**AgentSession branching API:** - `branch(entryIndex: number)` → `branch(entryId: string)` - `getUserMessagesForBranching()` returns `{ entryId, text }` instead of `{ entryIndex, text }` - `reset()` and `switchSession()` now return `Promise` (false if cancelled by hook) From 1b078a3f79b8fb5f636115483a36be57b57956fb Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Wed, 31 Dec 2025 14:28:44 +0100 Subject: [PATCH 032/124] Document SessionManager and ModelRegistry in CHANGELOG.md - SessionManager: method renames, new tree/append/branch methods - ModelRegistry: new class for model discovery and API key resolution --- packages/coding-agent/CHANGELOG.md | 37 ++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index d5ff5c9b..3428eb89 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -92,6 +92,43 @@ See [docs/custom-tools.md](docs/custom-tools.md) and [examples/custom-tools/](ex **Hook integration:** - New `sendHookMessage(message, triggerTurn?)` for hook message injection +**SessionManager API:** +- Method renames: `saveXXX()` → `appendXXX()` (e.g., `appendMessage`, `appendCompaction`) +- `branchInPlace()` → `branch()` +- `reset()` → `newSession()` +- `createBranchedSessionFromEntries(entries, index)` → `createBranchedSession(leafId)` +- `saveCompaction(entry)` → `appendCompaction(summary, firstKeptEntryId, tokensBefore, details?)` +- `getEntries()` now excludes the session header (use `getHeader()` separately) +- `getSessionFile()` returns `string | undefined` (undefined for in-memory sessions) +- New tree methods: `getTree()`, `getPath()`, `getLeafId()`, `getLeafEntry()`, `getEntry()`, `getChildren()`, `getLabel()` +- New append methods: `appendCustomEntry()`, `appendCustomMessageEntry()`, `appendLabelChange()` +- New branch methods: `branch(entryId)`, `branchWithSummary()` + +**ModelRegistry (new):** + +`ModelRegistry` is a new class that manages model discovery and API key resolution. It combines built-in models with custom models from `models.json` and resolves API keys via `AuthStorage`. + +```typescript +import { discoverAuthStorage, discoverModels } from "@mariozechner/pi-coding-agent"; + +const authStorage = discoverAuthStorage(); // ~/.pi/agent/auth.json +const modelRegistry = discoverModels(authStorage); // + ~/.pi/agent/models.json + +// Get all models (built-in + custom) +const allModels = modelRegistry.getAll(); + +// Get only models with valid API keys +const available = await modelRegistry.getAvailable(); + +// Find specific model +const model = modelRegistry.find("anthropic", "claude-sonnet-4-20250514"); + +// Get API key for a model +const apiKey = await modelRegistry.getApiKey(model); +``` + +This replaces the old `resolveApiKey` callback pattern. Hooks and custom tools access it via `ctx.modelRegistry`. + **Renamed exports:** - `messageTransformer` → `convertToLlm` - `SessionContext` alias `LoadedSession` removed From 410659d45ef03cbb451407cea9a35d6a3f627435 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Wed, 31 Dec 2025 14:30:08 +0100 Subject: [PATCH 033/124] Fix SessionManager method name: getPath -> getBranch --- packages/coding-agent/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 3428eb89..ea5a5ea5 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -100,7 +100,7 @@ See [docs/custom-tools.md](docs/custom-tools.md) and [examples/custom-tools/](ex - `saveCompaction(entry)` → `appendCompaction(summary, firstKeptEntryId, tokensBefore, details?)` - `getEntries()` now excludes the session header (use `getHeader()` separately) - `getSessionFile()` returns `string | undefined` (undefined for in-memory sessions) -- New tree methods: `getTree()`, `getPath()`, `getLeafId()`, `getLeafEntry()`, `getEntry()`, `getChildren()`, `getLabel()` +- New tree methods: `getTree()`, `getBranch()`, `getLeafId()`, `getLeafEntry()`, `getEntry()`, `getChildren()`, `getLabel()` - New append methods: `appendCustomEntry()`, `appendCustomMessageEntry()`, `appendLabelChange()` - New branch methods: `branch(entryId)`, `branchWithSummary()` From f0ab8db40fc790c47fdaba5d36705d7c3bdecfca Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Wed, 31 Dec 2025 14:34:30 +0100 Subject: [PATCH 034/124] Expand pi.sendMessage and registerMessageRenderer docs in hooks.md - sendMessage: document storage timing, LLM context, TUI display - registerMessageRenderer: document renderer signature, return null for default --- packages/coding-agent/CHANGELOG.md | 2 +- packages/coding-agent/docs/hooks.md | 42 +++++++++++++++++++++++++---- 2 files changed, 38 insertions(+), 6 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index ea5a5ea5..1e3878e5 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -168,7 +168,7 @@ The `before_compact` and `before_tree` hook events allow custom compaction imple - Search by typing, page with ←/→ - Filter modes (Ctrl+O): default → no-tools → user-only → labeled-only → all - Press `l` to label entries as bookmarks -- Selecting a branch generates a summary and switches context +- Selecting a branch switches context and optionally injects a summary of the abandoned branch **Entry labels:** - Bookmark any entry via `/tree` → select → `l` diff --git a/packages/coding-agent/docs/hooks.md b/packages/coding-agent/docs/hooks.md index dba42e0b..51629704 100644 --- a/packages/coding-agent/docs/hooks.md +++ b/packages/coding-agent/docs/hooks.md @@ -510,7 +510,7 @@ Subscribe to events. See [Events](#events) for all event types. ### pi.sendMessage(message, triggerTurn?) -Inject a message into the session. Creates `CustomMessageEntry` (participates in LLM context). +Inject a message into the session. Creates a `CustomMessageEntry` that participates in the LLM context. ```typescript pi.sendMessage({ @@ -521,6 +521,20 @@ pi.sendMessage({ }, triggerTurn); // If true, triggers LLM response ``` +**Storage and timing:** +- The message is appended to the session file immediately as a `CustomMessageEntry` +- If the agent is currently streaming, the message is queued and appended after the current turn +- If `triggerTurn` is true and the agent is idle, a new agent loop starts + +**LLM context:** +- `CustomMessageEntry` is converted to a user message when building context for the LLM +- Only `content` is sent to the LLM; `details` is for rendering/state only + +**TUI display:** +- If `display: true`, the message appears in the chat with purple styling (customMessageBg, customMessageText, customMessageLabel theme colors) +- If `display: false`, the message is hidden from the TUI but still sent to the LLM +- Use `pi.registerMessageRenderer()` to customize how your messages render (see below) + ### pi.appendEntry(customType, data?) Persist hook state. Creates `CustomEntry` (does NOT participate in LLM context). @@ -558,18 +572,36 @@ To trigger LLM after command, call `pi.sendMessage(..., true)`. ### pi.registerMessageRenderer(customType, renderer) -Custom TUI rendering for `CustomMessageEntry`: +Register a custom TUI renderer for `CustomMessageEntry` messages with your `customType`. Without a custom renderer, messages display with default purple styling showing the content as-is. ```typescript import { Text } from "@mariozechner/pi-tui"; pi.registerMessageRenderer("my-hook", (message, options, theme) => { - // message.content, message.details - // options.expanded (user pressed Ctrl+O) - return new Text(theme.fg("accent", `[MY-HOOK] ${message.content}`), 0, 0); + // message.content - the message content (string or content array) + // message.details - your custom metadata + // options.expanded - true if user pressed Ctrl+O + + const prefix = theme.fg("accent", `[${message.details?.label ?? "INFO"}] `); + const text = typeof message.content === "string" + ? message.content + : message.content.map(c => c.type === "text" ? c.text : "[image]").join(""); + + return new Text(prefix + theme.fg("text", text), 0, 0); }); ``` +**Renderer signature:** +```typescript +type HookMessageRenderer = ( + message: CustomMessageEntry, + options: { expanded: boolean }, + theme: Theme +) => Component | null; +``` + +Return `null` to use default rendering. The returned component is wrapped in a styled Box by the TUI. See [tui.md](tui.md) for component details. + ### pi.exec(command, args, options?) Execute a shell command: From 116fbad24c39657e84d5062f548b40dfec3c9aa7 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Wed, 31 Dec 2025 14:35:39 +0100 Subject: [PATCH 035/124] Expand theme changes section in CHANGELOG.md - Mark as breaking for custom themes - Explain that custom themes must add new tokens or fail to load - Note total color count increased from 46 to 50 - Reference theme.md and built-in themes --- packages/coding-agent/CHANGELOG.md | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 1e3878e5..c891fbc0 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -174,12 +174,18 @@ The `before_compact` and `before_tree` hook events allow custom compaction imple - Bookmark any entry via `/tree` → select → `l` - Labels appear in tree view and persist as `LabelEntry` -**Theme additions:** -- `selectedBg`: background for selected items -- `customMessageBg`, `customMessageText`, `customMessageLabel`: hook message styling +**Theme changes (breaking for custom themes):** + +Custom themes must add these new color tokens or they will fail to load: +- `selectedBg`: background for selected/highlighted items in tree selector and other components +- `customMessageBg`: background for hook-injected messages (`CustomMessageEntry`) +- `customMessageText`: text color for hook messages +- `customMessageLabel`: label color for hook messages (the `[customType]` prefix) + +Total color count increased from 46 to 50. See [docs/theme.md](docs/theme.md) for the full color list and copy values from the built-in dark/light themes. **Settings:** -- `enabledModels`: whitelist models in `settings.json` (same format as `--models` CLI) +- `enabledModels`: allowlist models in `settings.json` (same format as `--models` CLI) ### Added From dc5466becc4df0931a07a31f9ac3a7a711468a29 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Wed, 31 Dec 2025 14:39:51 +0100 Subject: [PATCH 036/124] Restructure README.md - Add session tree intro to Sessions section - Move Themes, Custom Slash Commands, Skills, Hooks, Custom Tools to new Extensions top-level section - Keep Settings File under Configuration --- packages/coding-agent/README.md | 141 +++++++++++++++++--------------- 1 file changed, 75 insertions(+), 66 deletions(-) diff --git a/packages/coding-agent/README.md b/packages/coding-agent/README.md index b3d380ca..ff53fb61 100644 --- a/packages/coding-agent/README.md +++ b/packages/coding-agent/README.md @@ -25,12 +25,13 @@ Works on Linux, macOS, and Windows (requires bash; see [Windows Setup](#windows- - [Project Context Files](#project-context-files) - [Custom System Prompt](#custom-system-prompt) - [Custom Models and Providers](#custom-models-and-providers) + - [Settings File](#settings-file) +- [Extensions](#extensions) - [Themes](#themes) - [Custom Slash Commands](#custom-slash-commands) - [Skills](#skills) - [Hooks](#hooks) - [Custom Tools](#custom-tools) - - [Settings File](#settings-file) - [CLI Reference](#cli-reference) - [Tools](#tools) - [Programmatic Usage](#programmatic-usage) @@ -292,6 +293,10 @@ Toggle inline images via `/settings` or set `terminal.showImages: false` in sett ## Sessions +Sessions are stored as JSONL files with a **tree structure**. Each entry has an `id` and `parentId`, enabling in-place branching: navigate to any previous point with `/tree`, continue from there, and switch between branches while preserving all history in a single file. Old linear sessions are auto-migrated on load. + +See [docs/session.md](docs/session.md) for the file format and programmatic API. + ### Session Management Sessions auto-save to `~/.pi/agent/sessions/` organized by working directory. @@ -481,6 +486,75 @@ Add custom models (Ollama, vLLM, LM Studio, etc.) via `~/.pi/agent/models.json`: > pi can help you create custom provider and model configurations. +### Settings File + +Settings are loaded from two locations and merged: + +1. **Global:** `~/.pi/agent/settings.json` - user preferences +2. **Project:** `/.pi/settings.json` - project-specific overrides (version control friendly) + +Project settings override global settings. For nested objects, individual keys merge. Settings changed via TUI (model, thinking level, etc.) are saved to global preferences only. + +Global `~/.pi/agent/settings.json` stores persistent preferences: + +```json +{ + "theme": "dark", + "defaultProvider": "anthropic", + "defaultModel": "claude-sonnet-4-20250514", + "defaultThinkingLevel": "medium", + "enabledModels": ["claude-sonnet", "gpt-4o", "gemini-2.5-pro:high"], + "queueMode": "one-at-a-time", + "shellPath": "C:\\path\\to\\bash.exe", + "hideThinkingBlock": false, + "collapseChangelog": false, + "compaction": { + "enabled": true, + "reserveTokens": 16384, + "keepRecentTokens": 20000 + }, + "skills": { + "enabled": true + }, + "retry": { + "enabled": true, + "maxRetries": 3, + "baseDelayMs": 2000 + }, + "terminal": { + "showImages": true + }, + "hooks": ["/path/to/hook.ts"], + "customTools": ["/path/to/tool.ts"] +} +``` + +| Setting | Description | Default | +|---------|-------------|---------| +| `theme` | Color theme name | auto-detected | +| `defaultProvider` | Default model provider | - | +| `defaultModel` | Default model ID | - | +| `defaultThinkingLevel` | Thinking level: `off`, `minimal`, `low`, `medium`, `high`, `xhigh` | - | +| `enabledModels` | Model patterns for cycling (same as `--models` CLI flag) | - | +| `queueMode` | Message queue mode: `all` or `one-at-a-time` | `one-at-a-time` | +| `shellPath` | Custom bash path (Windows) | auto-detected | +| `hideThinkingBlock` | Hide thinking blocks in output (Ctrl+T to toggle) | `false` | +| `collapseChangelog` | Show condensed changelog after update | `false` | +| `compaction.enabled` | Enable auto-compaction | `true` | +| `compaction.reserveTokens` | Tokens to reserve before compaction triggers | `16384` | +| `compaction.keepRecentTokens` | Recent tokens to keep after compaction | `20000` | +| `skills.enabled` | Enable skills discovery | `true` | +| `retry.enabled` | Auto-retry on transient errors | `true` | +| `retry.maxRetries` | Maximum retry attempts | `3` | +| `retry.baseDelayMs` | Base delay for exponential backoff | `2000` | +| `terminal.showImages` | Render images inline (supported terminals) | `true` | +| `hooks` | Additional hook file paths | `[]` | +| `customTools` | Additional custom tool file paths | `[]` | + +--- + +## Extensions + ### Themes Built-in themes: `dark` (default), `light`. Auto-detected on first run. @@ -696,71 +770,6 @@ export default factory; > See [examples/custom-tools/](examples/custom-tools/) for working examples including a todo list with session state management and a question tool with UI interaction. -### Settings File - -Settings are loaded from two locations and merged: - -1. **Global:** `~/.pi/agent/settings.json` - user preferences -2. **Project:** `/.pi/settings.json` - project-specific overrides (version control friendly) - -Project settings override global settings. For nested objects, individual keys merge. Settings changed via TUI (model, thinking level, etc.) are saved to global preferences only. - -Global `~/.pi/agent/settings.json` stores persistent preferences: - -```json -{ - "theme": "dark", - "defaultProvider": "anthropic", - "defaultModel": "claude-sonnet-4-20250514", - "defaultThinkingLevel": "medium", - "enabledModels": ["claude-sonnet", "gpt-4o", "gemini-2.5-pro:high"], - "queueMode": "one-at-a-time", - "shellPath": "C:\\path\\to\\bash.exe", - "hideThinkingBlock": false, - "collapseChangelog": false, - "compaction": { - "enabled": true, - "reserveTokens": 16384, - "keepRecentTokens": 20000 - }, - "skills": { - "enabled": true - }, - "retry": { - "enabled": true, - "maxRetries": 3, - "baseDelayMs": 2000 - }, - "terminal": { - "showImages": true - }, - "hooks": ["/path/to/hook.ts"], - "customTools": ["/path/to/tool.ts"] -} -``` - -| Setting | Description | Default | -|---------|-------------|---------| -| `theme` | Color theme name | auto-detected | -| `defaultProvider` | Default model provider | - | -| `defaultModel` | Default model ID | - | -| `defaultThinkingLevel` | Thinking level: `off`, `minimal`, `low`, `medium`, `high`, `xhigh` | - | -| `enabledModels` | Model patterns for cycling (same as `--models` CLI flag) | - | -| `queueMode` | Message queue mode: `all` or `one-at-a-time` | `one-at-a-time` | -| `shellPath` | Custom bash path (Windows) | auto-detected | -| `hideThinkingBlock` | Hide thinking blocks in output (Ctrl+T to toggle) | `false` | -| `collapseChangelog` | Show condensed changelog after update | `false` | -| `compaction.enabled` | Enable auto-compaction | `true` | -| `compaction.reserveTokens` | Tokens to reserve before compaction triggers | `16384` | -| `compaction.keepRecentTokens` | Recent tokens to keep after compaction | `20000` | -| `skills.enabled` | Enable skills discovery | `true` | -| `retry.enabled` | Auto-retry on transient errors | `true` | -| `retry.maxRetries` | Maximum retry attempts | `3` | -| `retry.baseDelayMs` | Base delay for exponential backoff | `2000` | -| `terminal.showImages` | Render images inline (supported terminals) | `true` | -| `hooks` | Additional hook file paths | `[]` | -| `customTools` | Additional custom tool file paths | `[]` | - --- ## CLI Reference From 0fa558154c7c8355d351766429f7c8d72da308a3 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Wed, 31 Dec 2025 14:44:51 +0100 Subject: [PATCH 037/124] Update README.md compaction and branching sections - Simplify compaction section, link to docs/compaction.md for details - Clarify that branch summaries are optional (user is prompted) - Change /branch to /tree in compaction note --- packages/coding-agent/README.md | 16 +++++----------- 1 file changed, 5 insertions(+), 11 deletions(-) diff --git a/packages/coding-agent/README.md b/packages/coding-agent/README.md index ff53fb61..58278f2f 100644 --- a/packages/coding-agent/README.md +++ b/packages/coding-agent/README.md @@ -293,7 +293,7 @@ Toggle inline images via `/settings` or set `terminal.showImages: false` in sett ## Sessions -Sessions are stored as JSONL files with a **tree structure**. Each entry has an `id` and `parentId`, enabling in-place branching: navigate to any previous point with `/tree`, continue from there, and switch between branches while preserving all history in a single file. Old linear sessions are auto-migrated on load. +Sessions are stored as JSONL files with a **tree structure**. Each entry has an `id` and `parentId`, enabling in-place branching: navigate to any previous point with `/tree`, continue from there, and switch between branches while preserving all history in a single file. See [docs/session.md](docs/session.md) for the file format and programmatic API. @@ -325,14 +325,6 @@ Long sessions can exhaust context windows. Compaction summarizes older messages When disabled, neither case triggers automatic compaction (use `/compact` manually if needed). -**How it works:** -1. Cut point calculated to keep ~20k tokens of recent messages -2. Messages before cut point are summarized -3. Summary replaces old messages as "context handoff" -4. Previous compaction summaries chain into new ones - -Compaction does not create a new session, but continues the existing one, with a marker in the `.jsonl` file that encodes the compaction point. - **Configuration** (`~/.pi/agent/settings.json`): ```json @@ -345,7 +337,9 @@ Compaction does not create a new session, but continues the existing one, with a } ``` -> **Note:** Compaction is lossy. The agent loses full conversation access afterward. Size tasks to avoid context limits when possible. For critical context, ask the agent to write a summary to a file, iterate on it until it covers everything, then start a new session with that file. The full session history is preserved in the JSONL file; use `/branch` to revisit any previous point. +> **Note:** Compaction is lossy. The agent loses full conversation access afterward. Size tasks to avoid context limits when possible. For critical context, ask the agent to write a summary to a file, iterate on it until it covers everything, then start a new session with that file. The full session history is preserved in the JSONL file; use `/tree` to revisit any previous point. + +See [docs/compaction.md](docs/compaction.md) for how compaction works internally and how to customize it via hooks. ### Branching @@ -354,7 +348,7 @@ Compaction does not create a new session, but continues the existing one, with a - Search by typing, page with ←/→ - Filter modes (Ctrl+O): default → no-tools → user-only → labeled-only → all - Press `l` to label entries as bookmarks -- Selecting a branch generates a summary and switches context +- When switching branches, you're prompted whether to generate a summary of the abandoned branch (messages up to the common ancestor) **Create new session (`/branch`):** Branch to a new session file: From 98c85bf36a6f29a49182e3d315cfebd405288922 Mon Sep 17 00:00:00 2001 From: Aliou Diallo Date: Tue, 30 Dec 2025 13:52:03 +0100 Subject: [PATCH 038/124] fix(coding-agent): save initial model and thinking level to session When creating a new session, initial model and thinking level were set on the agent but never saved to session file. This caused --resume to default thinking level to 'off'. fixes #342 --- packages/coding-agent/CHANGELOG.md | 1 + packages/coding-agent/src/core/sdk.ts | 6 ++++++ 2 files changed, 7 insertions(+) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index c891fbc0..bfcf1124 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -198,6 +198,7 @@ Total color count increased from 46 to 50. See [docs/theme.md](docs/theme.md) fo ### Fixed +- **Resuming session resets thinking level to off**: Initial model and thinking level were not saved to session file, causing `--resume`/`--continue` to default to `off`. ([#342](https://github.com/badlogic/pi-mono/issues/342)) - **Edit tool fails on Windows due to CRLF line endings**: Files with CRLF line endings now match correctly when LLMs send LF-only text. Line endings are normalized before matching and restored to original style on write. ([#355](https://github.com/badlogic/pi-mono/issues/355) by [@Pratham-Dubey](https://github.com/Pratham-Dubey)) - **Use bash instead of sh on Unix**: Fixed shell commands using `/bin/sh` instead of `/bin/bash` on Unix systems. ([#328](https://github.com/badlogic/pi-mono/pull/328) by [@dnouri](https://github.com/dnouri)) - **OAuth login URL clickable**: Made OAuth login URLs clickable in terminal. ([#349](https://github.com/badlogic/pi-mono/pull/349) by [@Cursivez](https://github.com/Cursivez)) diff --git a/packages/coding-agent/src/core/sdk.ts b/packages/coding-agent/src/core/sdk.ts index e2eb11e0..733bcec5 100644 --- a/packages/coding-agent/src/core/sdk.ts +++ b/packages/coding-agent/src/core/sdk.ts @@ -621,6 +621,12 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} // Restore messages if session has existing data if (hasExistingSession) { agent.replaceMessages(existingSession.messages); + } else { + // Save initial model and thinking level for new sessions so they can be restored on resume + if (model) { + sessionManager.appendModelChange(model.provider, model.id); + } + sessionManager.appendThinkingLevelChange(thinkingLevel); } const session = new AgentSession({ From 02d0d6e192d5f5ef92bf46031c37f5380a87f4b7 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Wed, 31 Dec 2025 17:12:55 +0100 Subject: [PATCH 039/124] Fix hook tool_result event not emitted for tool errors Tools are supposed to throw on error. What needs fixing is that we need to report tool_result for erroneous tool executions as well. Fixes #374 --- packages/coding-agent/CHANGELOG.md | 1 + .../src/core/hooks/tool-wrapper.ts | 56 ++++++++++++------- 2 files changed, 37 insertions(+), 20 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index c891fbc0..ac5ea235 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -198,6 +198,7 @@ Total color count increased from 46 to 50. See [docs/theme.md](docs/theme.md) fo ### Fixed +- **Hook `tool_result` event ignores errors from custom tools**: The `tool_result` hook event was never emitted when tools threw errors, and always had `isError: false` for successful executions. Now emits the event with correct `isError` value in both success and error cases. ([#374](https://github.com/badlogic/pi-mono/issues/374) by [@nicobailon](https://github.com/nicobailon)) - **Edit tool fails on Windows due to CRLF line endings**: Files with CRLF line endings now match correctly when LLMs send LF-only text. Line endings are normalized before matching and restored to original style on write. ([#355](https://github.com/badlogic/pi-mono/issues/355) by [@Pratham-Dubey](https://github.com/Pratham-Dubey)) - **Use bash instead of sh on Unix**: Fixed shell commands using `/bin/sh` instead of `/bin/bash` on Unix systems. ([#328](https://github.com/badlogic/pi-mono/pull/328) by [@dnouri](https://github.com/dnouri)) - **OAuth login URL clickable**: Made OAuth login URLs clickable in terminal. ([#349](https://github.com/badlogic/pi-mono/pull/349) by [@Cursivez](https://github.com/Cursivez)) diff --git a/packages/coding-agent/src/core/hooks/tool-wrapper.ts b/packages/coding-agent/src/core/hooks/tool-wrapper.ts index c3499d9f..28c718f0 100644 --- a/packages/coding-agent/src/core/hooks/tool-wrapper.ts +++ b/packages/coding-agent/src/core/hooks/tool-wrapper.ts @@ -46,30 +46,46 @@ export function wrapToolWithHooks(tool: AgentTool, hookRunner: HookRu } // Execute the actual tool, forwarding onUpdate for progress streaming - const result = await tool.execute(toolCallId, params, signal, onUpdate); + try { + const result = await tool.execute(toolCallId, params, signal, onUpdate); - // Emit tool_result event - hooks can modify the result - if (hookRunner.hasHandlers("tool_result")) { - const resultResult = (await hookRunner.emit({ - type: "tool_result", - toolName: tool.name, - toolCallId, - input: params, - content: result.content, - details: result.details, - isError: false, - })) as ToolResultEventResult | undefined; + // Emit tool_result event - hooks can modify the result + if (hookRunner.hasHandlers("tool_result")) { + const resultResult = (await hookRunner.emit({ + type: "tool_result", + toolName: tool.name, + toolCallId, + input: params, + content: result.content, + details: result.details, + isError: false, + })) as ToolResultEventResult | undefined; - // Apply modifications if any - if (resultResult) { - return { - content: resultResult.content ?? result.content, - details: (resultResult.details ?? result.details) as T, - }; + // Apply modifications if any + if (resultResult) { + return { + content: resultResult.content ?? result.content, + details: (resultResult.details ?? result.details) as T, + }; + } } - } - return result; + return result; + } catch (err) { + // Emit tool_result event for errors so hooks can observe failures + if (hookRunner.hasHandlers("tool_result")) { + await hookRunner.emit({ + type: "tool_result", + toolName: tool.name, + toolCallId, + input: params, + content: [{ type: "text", text: err instanceof Error ? err.message : String(err) }], + details: undefined, + isError: true, + }); + } + throw err; // Re-throw original error for agent-loop + } }, }; } From 6f7c10e3237b166b9b94ef160a082afb274436a6 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Thu, 1 Jan 2026 00:04:56 +0100 Subject: [PATCH 040/124] Add setEditorText/getEditorText to hook UI context, improve custom() API - Add setEditorText() and getEditorText() to HookUIContext for prompt generator pattern - custom() now accepts async factories for fire-and-forget work - Add CancellableLoader component to tui package - Add BorderedLoader component for hooks with cancel UI - Export HookAPI, HookContext, HookFactory from main package - Update all examples to import from packages instead of relative paths - Update hooks.md and custom-tools.md documentation fixes #350 --- packages/agent/README.md | 16 +++ packages/coding-agent/docs/custom-tools.md | 23 ++++ packages/coding-agent/docs/hooks.md | 53 +++++--- .../coding-agent/examples/hooks/README.md | 98 ++++----------- .../examples/hooks/auto-commit-on-exit.ts | 2 +- .../examples/hooks/confirm-destructive.ts | 3 +- .../examples/hooks/custom-compaction.ts | 2 +- .../examples/hooks/dirty-repo-guard.ts | 2 +- .../examples/hooks/file-trigger.ts | 2 +- .../examples/hooks/git-checkpoint.ts | 2 +- .../examples/hooks/permission-gate.ts | 2 +- .../examples/hooks/protected-paths.ts | 2 +- packages/coding-agent/examples/hooks/qna.ts | 119 ++++++++++++++++++ packages/coding-agent/examples/hooks/snake.ts | 36 +++--- .../coding-agent/examples/sdk/01-minimal.ts | 2 +- .../examples/sdk/02-custom-model.ts | 2 +- .../examples/sdk/03-custom-prompt.ts | 2 +- .../coding-agent/examples/sdk/04-skills.ts | 2 +- .../coding-agent/examples/sdk/05-tools.ts | 4 +- .../coding-agent/examples/sdk/06-hooks.ts | 2 +- .../examples/sdk/07-context-files.ts | 2 +- .../examples/sdk/08-slash-commands.ts | 7 +- .../examples/sdk/09-api-keys-and-oauth.ts | 2 +- .../coding-agent/examples/sdk/10-settings.ts | 2 +- .../coding-agent/examples/sdk/11-sessions.ts | 2 +- .../examples/sdk/12-full-control.ts | 4 +- .../src/core/custom-tools/loader.ts | 4 +- .../coding-agent/src/core/hooks/runner.ts | 4 +- packages/coding-agent/src/core/hooks/types.ts | 46 ++++++- packages/coding-agent/src/core/sdk.ts | 2 +- packages/coding-agent/src/index.ts | 6 + .../interactive/components/bordered-loader.ts | 41 ++++++ .../src/modes/interactive/interactive-mode.ts | 55 ++++---- .../coding-agent/src/modes/rpc/rpc-mode.ts | 20 ++- .../coding-agent/src/modes/rpc/rpc-types.ts | 3 +- .../test/compaction-hooks.test.ts | 4 +- packages/tui/README.md | 20 +++ .../tui/src/components/cancellable-loader.ts | 39 ++++++ packages/tui/src/index.ts | 1 + 39 files changed, 477 insertions(+), 163 deletions(-) create mode 100644 packages/coding-agent/examples/hooks/qna.ts create mode 100644 packages/coding-agent/src/modes/interactive/components/bordered-loader.ts create mode 100644 packages/tui/src/components/cancellable-loader.ts diff --git a/packages/agent/README.md b/packages/agent/README.md index 99e455c7..44206fb0 100644 --- a/packages/agent/README.md +++ b/packages/agent/README.md @@ -298,6 +298,22 @@ const readFileTool: AgentTool = { agent.setTools([readFileTool]); ``` +### Error Handling + +**Throw an error** when a tool fails. Do not return error messages as content. + +```typescript +execute: async (toolCallId, params, signal, onUpdate) => { + if (!fs.existsSync(params.path)) { + throw new Error(`File not found: ${params.path}`); + } + // Return content only on success + return { content: [{ type: "text", text: "..." }] }; +} +``` + +Thrown errors are caught by the agent and reported to the LLM as tool errors with `isError: true`. + ## Proxy Usage For browser apps that proxy through a backend: diff --git a/packages/coding-agent/docs/custom-tools.md b/packages/coding-agent/docs/custom-tools.md index 8f428efb..4070b463 100644 --- a/packages/coding-agent/docs/custom-tools.md +++ b/packages/coding-agent/docs/custom-tools.md @@ -198,6 +198,29 @@ async execute(toolCallId, params, onUpdate, ctx, signal) { } ``` +### Error Handling + +**Throw an error** when the tool fails. Do not return an error message as content. + +```typescript +async execute(toolCallId, params, onUpdate, ctx, signal) { + const { path } = params as { path: string }; + + // Throw on error - pi will catch it and report to the LLM + if (!fs.existsSync(path)) { + throw new Error(`File not found: ${path}`); + } + + // Return content only on success + return { content: [{ type: "text", text: "Success" }] }; +} +``` + +Thrown errors are: +- Reported to the LLM as tool errors (with `isError: true`) +- Emitted to hooks via `tool_result` event (hooks can inspect `event.isError`) +- Displayed in the TUI with error styling + ## CustomToolContext The `execute` and `onSession` callbacks receive a `CustomToolContext`: diff --git a/packages/coding-agent/docs/hooks.md b/packages/coding-agent/docs/hooks.md index 51629704..76531459 100644 --- a/packages/coding-agent/docs/hooks.md +++ b/packages/coding-agent/docs/hooks.md @@ -25,7 +25,7 @@ See [examples/hooks/](../examples/hooks/) for working implementations, including Create `~/.pi/agent/hooks/my-hook.ts`: ```typescript -import type { HookAPI } from "@mariozechner/pi-coding-agent/hooks"; +import type { HookAPI } from "@mariozechner/pi-coding-agent"; export default function (pi: HookAPI) { pi.on("session_start", async (_event, ctx) => { @@ -80,7 +80,7 @@ Node.js built-ins (`node:fs`, `node:path`, etc.) are also available. A hook exports a default function that receives `HookAPI`: ```typescript -import type { HookAPI } from "@mariozechner/pi-coding-agent/hooks"; +import type { HookAPI } from "@mariozechner/pi-coding-agent"; export default function (pi: HookAPI) { // Subscribe to events @@ -360,14 +360,20 @@ Tool inputs: #### tool_result -Fired after tool executes. **Can modify result.** +Fired after tool executes (including errors). **Can modify result.** + +Check `event.isError` to distinguish successful executions from failures. ```typescript pi.on("tool_result", async (event, ctx) => { // event.toolName, event.toolCallId, event.input // event.content - array of TextContent | ImageContent // event.details - tool-specific (see below) - // event.isError + // event.isError - true if the tool threw an error + + if (event.isError) { + // Handle error case + } // Modify result: return { content: [...], details: {...}, isError: false }; @@ -377,7 +383,7 @@ pi.on("tool_result", async (event, ctx) => { Use type guards for typed details: ```typescript -import { isBashToolResult } from "@mariozechner/pi-coding-agent/hooks"; +import { isBashToolResult } from "@mariozechner/pi-coding-agent"; pi.on("tool_result", async (event, ctx) => { if (isBashToolResult(event)) { @@ -416,25 +422,40 @@ const name = await ctx.ui.input("Name:", "placeholder"); // Notification (non-blocking) ctx.ui.notify("Done!", "info"); // "info" | "warning" | "error" + +// Set the core input editor text (pre-fill prompts, generated content) +ctx.ui.setEditorText("Generated prompt text here..."); + +// Get current editor text +const currentText = ctx.ui.getEditorText(); ``` **Custom components:** -For full control, render your own TUI component with keyboard focus: +Show a custom TUI component with keyboard focus: ```typescript -const handle = ctx.ui.custom(myComponent); -// Returns { close: () => void, requestRender: () => void } +import { BorderedLoader } from "@mariozechner/pi-coding-agent"; + +const result = await ctx.ui.custom((tui, theme, done) => { + const loader = new BorderedLoader(tui, theme, "Working..."); + loader.onAbort = () => done(null); + + doWork(loader.signal).then(done).catch(() => done(null)); + + return loader; +}); ``` Your component can: - Implement `handleInput(data: string)` to receive keyboard input - Implement `render(width: number): string[]` to render lines - Implement `invalidate()` to clear cached render -- Call `handle.requestRender()` to trigger re-render -- Call `handle.close()` when done to restore normal UI +- Implement `dispose()` for cleanup when closed +- Call `tui.requestRender()` to trigger re-render +- Call `done(result)` when done to restore normal UI -See [examples/hooks/snake.ts](../examples/hooks/snake.ts) for a complete example with game loop, keyboard handling, and state persistence. See [tui.md](tui.md) for the full component API. +See [examples/hooks/qna.ts](../examples/hooks/qna.ts) for a loader pattern and [examples/hooks/snake.ts](../examples/hooks/snake.ts) for a game. See [tui.md](tui.md) for the full component API. ### ctx.hasUI @@ -568,6 +589,8 @@ pi.registerCommand("stats", { }); ``` +For long-running commands (e.g., LLM calls), use `ctx.ui.custom()` with a loader. See [examples/hooks/qna.ts](../examples/hooks/qna.ts). + To trigger LLM after command, call `pi.sendMessage(..., true)`. ### pi.registerMessageRenderer(customType, renderer) @@ -620,7 +643,7 @@ const result = await pi.exec("git", ["status"], { ### Permission Gate ```typescript -import type { HookAPI } from "@mariozechner/pi-coding-agent/hooks"; +import type { HookAPI } from "@mariozechner/pi-coding-agent"; export default function (pi: HookAPI) { const dangerous = [/\brm\s+(-rf?|--recursive)/i, /\bsudo\b/i]; @@ -643,7 +666,7 @@ export default function (pi: HookAPI) { ### Protected Paths ```typescript -import type { HookAPI } from "@mariozechner/pi-coding-agent/hooks"; +import type { HookAPI } from "@mariozechner/pi-coding-agent"; export default function (pi: HookAPI) { const protectedPaths = [".env", ".git/", "node_modules/"]; @@ -663,7 +686,7 @@ export default function (pi: HookAPI) { ### Git Checkpoint ```typescript -import type { HookAPI } from "@mariozechner/pi-coding-agent/hooks"; +import type { HookAPI } from "@mariozechner/pi-coding-agent"; export default function (pi: HookAPI) { const checkpoints = new Map(); @@ -708,7 +731,7 @@ See [examples/hooks/snake.ts](../examples/hooks/snake.ts) for a complete example | RPC | JSON protocol | Host handles UI | | Print (`-p`) | No-op (returns null/false) | Hooks run but can't prompt | -In print mode, `select()` returns `undefined`, `confirm()` returns `false`, `input()` returns `undefined`. Design hooks to handle this. +In print mode, `select()` returns `undefined`, `confirm()` returns `false`, `input()` returns `undefined`, `getEditorText()` returns `""`, and `setEditorText()` is a no-op. Design hooks to handle this by checking `ctx.hasUI`. ## Error Handling diff --git a/packages/coding-agent/examples/hooks/README.md b/packages/coding-agent/examples/hooks/README.md index d9785070..cab8d80d 100644 --- a/packages/coding-agent/examples/hooks/README.md +++ b/packages/coding-agent/examples/hooks/README.md @@ -2,97 +2,53 @@ Example hooks for pi-coding-agent. -## Examples - -### permission-gate.ts -Prompts for confirmation before running dangerous bash commands (rm -rf, sudo, chmod 777, etc.). - -### git-checkpoint.ts -Creates git stash checkpoints at each turn, allowing code restoration when branching. - -### protected-paths.ts -Blocks writes to protected paths (.env, .git/, node_modules/). - -### file-trigger.ts -Watches a trigger file and injects its contents into the conversation. Useful for external systems (CI, file watchers, webhooks) to send messages to the agent. - -### confirm-destructive.ts -Prompts for confirmation before destructive session actions (clear, switch, branch). Demonstrates how to cancel `before_*` session events. - -### dirty-repo-guard.ts -Prevents session changes when there are uncommitted git changes. Blocks clear/switch/branch until you commit. - -### auto-commit-on-exit.ts -Automatically commits changes when the agent exits (shutdown event). Uses the last assistant message to generate a commit message. - -### custom-compaction.ts -Custom context compaction that summarizes the entire conversation instead of keeping recent turns. Uses the `before_compact` hook event to intercept compaction and generate a comprehensive summary using `complete()` from the AI package. Useful when you want maximum context window space at the cost of losing exact conversation history. - ## Usage ```bash -# Test directly +# Load a hook with --hook flag pi --hook examples/hooks/permission-gate.ts -# Or copy to hooks directory for persistent use +# Or copy to hooks directory for auto-discovery cp permission-gate.ts ~/.pi/agent/hooks/ ``` +## Examples + +| Hook | Description | +|------|-------------| +| `permission-gate.ts` | Prompts for confirmation before dangerous bash commands (rm -rf, sudo, etc.) | +| `git-checkpoint.ts` | Creates git stash checkpoints at each turn for code restoration on branch | +| `protected-paths.ts` | Blocks writes to protected paths (.env, .git/, node_modules/) | +| `file-trigger.ts` | Watches a trigger file and injects contents into conversation | +| `confirm-destructive.ts` | Confirms before destructive session actions (clear, switch, branch) | +| `dirty-repo-guard.ts` | Prevents session changes with uncommitted git changes | +| `auto-commit-on-exit.ts` | Auto-commits on exit using last assistant message for commit message | +| `custom-compaction.ts` | Custom compaction that summarizes entire conversation | +| `qna.ts` | Extracts questions from last response into editor via `ctx.ui.setEditorText()` | +| `snake.ts` | Snake game with custom UI, keyboard handling, and session persistence | + ## Writing Hooks See [docs/hooks.md](../../docs/hooks.md) for full documentation. -### Key Points - -**Hook structure:** ```typescript import type { HookAPI } from "@mariozechner/pi-coding-agent/hooks"; export default function (pi: HookAPI) { - pi.on("session", async (event, ctx) => { - // event.reason: "start" | "before_switch" | "switch" | "before_clear" | "clear" | - // "before_branch" | "branch" | "shutdown" - // event.targetTurnIndex: number (only for before_branch/branch) - // ctx.ui, ctx.exec, ctx.cwd, ctx.sessionFile, ctx.hasUI - - // Cancel before_* actions: - if (event.reason === "before_clear") { - return { cancel: true }; - } - return undefined; - }); - + // Subscribe to events pi.on("tool_call", async (event, ctx) => { - // Can block tool execution - if (dangerous) { - return { block: true, reason: "Blocked" }; + if (event.toolName === "bash" && event.input.command?.includes("rm -rf")) { + const ok = await ctx.ui.confirm("Dangerous!", "Allow rm -rf?"); + if (!ok) return { block: true, reason: "Blocked by user" }; } - return undefined; }); - pi.on("tool_result", async (event, ctx) => { - // Can modify result - return { result: "modified result" }; + // Register custom commands + pi.registerCommand("hello", { + description: "Say hello", + handler: async (args, ctx) => { + ctx.ui.notify("Hello!", "info"); + }, }); } ``` - -**Available events:** -- `session` - lifecycle events with before/after variants (can cancel before_* actions) -- `agent_start` / `agent_end` - per user prompt -- `turn_start` / `turn_end` - per LLM turn -- `tool_call` - before tool execution (can block) -- `tool_result` - after tool execution (can modify) - -**UI methods:** -```typescript -const choice = await ctx.ui.select("Title", ["Option A", "Option B"]); -const confirmed = await ctx.ui.confirm("Title", "Are you sure?"); -const input = await ctx.ui.input("Title", "placeholder"); -ctx.ui.notify("Message", "info"); // or "warning", "error" -``` - -**Sending messages:** -```typescript -pi.send("Message to inject into conversation"); -``` diff --git a/packages/coding-agent/examples/hooks/auto-commit-on-exit.ts b/packages/coding-agent/examples/hooks/auto-commit-on-exit.ts index bfcc37f7..598ecdc2 100644 --- a/packages/coding-agent/examples/hooks/auto-commit-on-exit.ts +++ b/packages/coding-agent/examples/hooks/auto-commit-on-exit.ts @@ -5,7 +5,7 @@ * Uses the last assistant message to generate a commit message. */ -import type { HookAPI } from "@mariozechner/pi-coding-agent/hooks"; +import type { HookAPI } from "@mariozechner/pi-coding-agent"; export default function (pi: HookAPI) { pi.on("session_shutdown", async (_event, ctx) => { diff --git a/packages/coding-agent/examples/hooks/confirm-destructive.ts b/packages/coding-agent/examples/hooks/confirm-destructive.ts index ef189b23..219c7b2f 100644 --- a/packages/coding-agent/examples/hooks/confirm-destructive.ts +++ b/packages/coding-agent/examples/hooks/confirm-destructive.ts @@ -5,8 +5,7 @@ * Demonstrates how to cancel session events using the before_* events. */ -import type { SessionMessageEntry } from "@mariozechner/pi-coding-agent"; -import type { HookAPI } from "@mariozechner/pi-coding-agent/hooks"; +import type { HookAPI, SessionMessageEntry } from "@mariozechner/pi-coding-agent"; export default function (pi: HookAPI) { pi.on("session_before_new", async (_event, ctx) => { diff --git a/packages/coding-agent/examples/hooks/custom-compaction.ts b/packages/coding-agent/examples/hooks/custom-compaction.ts index 32b965b4..5f413e03 100644 --- a/packages/coding-agent/examples/hooks/custom-compaction.ts +++ b/packages/coding-agent/examples/hooks/custom-compaction.ts @@ -14,8 +14,8 @@ */ import { complete, getModel } from "@mariozechner/pi-ai"; +import type { HookAPI } from "@mariozechner/pi-coding-agent"; import { convertToLlm, serializeConversation } from "@mariozechner/pi-coding-agent"; -import type { HookAPI } from "@mariozechner/pi-coding-agent/hooks"; export default function (pi: HookAPI) { pi.on("session_before_compact", async (event, ctx) => { diff --git a/packages/coding-agent/examples/hooks/dirty-repo-guard.ts b/packages/coding-agent/examples/hooks/dirty-repo-guard.ts index a0031d57..1d7fdc7e 100644 --- a/packages/coding-agent/examples/hooks/dirty-repo-guard.ts +++ b/packages/coding-agent/examples/hooks/dirty-repo-guard.ts @@ -5,7 +5,7 @@ * Useful to ensure work is committed before switching context. */ -import type { HookAPI, HookContext } from "@mariozechner/pi-coding-agent/hooks"; +import type { HookAPI, HookContext } from "@mariozechner/pi-coding-agent"; async function checkDirtyRepo(pi: HookAPI, ctx: HookContext, action: string): Promise<{ cancel: boolean } | undefined> { // Check for uncommitted changes diff --git a/packages/coding-agent/examples/hooks/file-trigger.ts b/packages/coding-agent/examples/hooks/file-trigger.ts index 4363bdce..e3f69b1f 100644 --- a/packages/coding-agent/examples/hooks/file-trigger.ts +++ b/packages/coding-agent/examples/hooks/file-trigger.ts @@ -9,7 +9,7 @@ */ import * as fs from "node:fs"; -import type { HookAPI } from "@mariozechner/pi-coding-agent/hooks"; +import type { HookAPI } from "@mariozechner/pi-coding-agent"; export default function (pi: HookAPI) { pi.on("session_start", async (_event, ctx) => { diff --git a/packages/coding-agent/examples/hooks/git-checkpoint.ts b/packages/coding-agent/examples/hooks/git-checkpoint.ts index 6190be0d..1ea89449 100644 --- a/packages/coding-agent/examples/hooks/git-checkpoint.ts +++ b/packages/coding-agent/examples/hooks/git-checkpoint.ts @@ -5,7 +5,7 @@ * When branching, offers to restore code to that point in history. */ -import type { HookAPI } from "@mariozechner/pi-coding-agent/hooks"; +import type { HookAPI } from "@mariozechner/pi-coding-agent"; export default function (pi: HookAPI) { const checkpoints = new Map(); diff --git a/packages/coding-agent/examples/hooks/permission-gate.ts b/packages/coding-agent/examples/hooks/permission-gate.ts index 6ebe459a..c3619fd0 100644 --- a/packages/coding-agent/examples/hooks/permission-gate.ts +++ b/packages/coding-agent/examples/hooks/permission-gate.ts @@ -5,7 +5,7 @@ * Patterns checked: rm -rf, sudo, chmod/chown 777 */ -import type { HookAPI } from "@mariozechner/pi-coding-agent/hooks"; +import type { HookAPI } from "@mariozechner/pi-coding-agent"; export default function (pi: HookAPI) { const dangerousPatterns = [/\brm\s+(-rf?|--recursive)/i, /\bsudo\b/i, /\b(chmod|chown)\b.*777/i]; diff --git a/packages/coding-agent/examples/hooks/protected-paths.ts b/packages/coding-agent/examples/hooks/protected-paths.ts index 7aec0d46..8431d2fb 100644 --- a/packages/coding-agent/examples/hooks/protected-paths.ts +++ b/packages/coding-agent/examples/hooks/protected-paths.ts @@ -5,7 +5,7 @@ * Useful for preventing accidental modifications to sensitive files. */ -import type { HookAPI } from "@mariozechner/pi-coding-agent/hooks"; +import type { HookAPI } from "@mariozechner/pi-coding-agent"; export default function (pi: HookAPI) { const protectedPaths = [".env", ".git/", "node_modules/"]; diff --git a/packages/coding-agent/examples/hooks/qna.ts b/packages/coding-agent/examples/hooks/qna.ts new file mode 100644 index 00000000..92bb14d7 --- /dev/null +++ b/packages/coding-agent/examples/hooks/qna.ts @@ -0,0 +1,119 @@ +/** + * Q&A extraction hook - extracts questions from assistant responses + * + * Demonstrates the "prompt generator" pattern: + * 1. /qna command gets the last assistant message + * 2. Shows a spinner while extracting (hides editor) + * 3. Loads the result into the editor for user to fill in answers + */ + +import { complete, type UserMessage } from "@mariozechner/pi-ai"; +import type { HookAPI } from "@mariozechner/pi-coding-agent"; +import { BorderedLoader } from "@mariozechner/pi-coding-agent"; + +const SYSTEM_PROMPT = `You are a question extractor. Given text from a conversation, extract any questions that need answering and format them for the user to fill in. + +Output format: +- List each question on its own line, prefixed with "Q: " +- After each question, add a blank line for the answer prefixed with "A: " +- If no questions are found, output "No questions found in the last message." + +Example output: +Q: What is your preferred database? +A: + +Q: Should we use TypeScript or JavaScript? +A: + +Keep questions in the order they appeared. Be concise.`; + +export default function (pi: HookAPI) { + pi.registerCommand("qna", { + description: "Extract questions from last assistant message into editor", + handler: async (_args, ctx) => { + if (!ctx.hasUI) { + ctx.ui.notify("qna requires interactive mode", "error"); + return; + } + + if (!ctx.model) { + ctx.ui.notify("No model selected", "error"); + return; + } + + // Find the last assistant message on the current branch + const branch = ctx.sessionManager.getBranch(); + let lastAssistantText: string | undefined; + + for (let i = branch.length - 1; i >= 0; i--) { + const entry = branch[i]; + if (entry.type === "message") { + const msg = entry.message; + if ("role" in msg && msg.role === "assistant") { + if (msg.stopReason !== "stop") { + ctx.ui.notify(`Last assistant message incomplete (${msg.stopReason})`, "error"); + return; + } + const textParts = msg.content + .filter((c): c is { type: "text"; text: string } => c.type === "text") + .map((c) => c.text); + if (textParts.length > 0) { + lastAssistantText = textParts.join("\n"); + break; + } + } + } + } + + if (!lastAssistantText) { + ctx.ui.notify("No assistant messages found", "error"); + return; + } + + // Run extraction with loader UI + const result = await ctx.ui.custom((tui, theme, done) => { + const loader = new BorderedLoader(tui, theme, `Extracting questions using ${ctx.model!.id}...`); + loader.onAbort = () => done(null); + + // Do the work + const doExtract = async () => { + const apiKey = await ctx.modelRegistry.getApiKey(ctx.model!); + const userMessage: UserMessage = { + role: "user", + content: [{ type: "text", text: lastAssistantText! }], + timestamp: Date.now(), + }; + + const response = await complete( + ctx.model!, + { systemPrompt: SYSTEM_PROMPT, messages: [userMessage] }, + { apiKey, signal: loader.signal }, + ); + + if (response.stopReason === "aborted") { + return null; + } + + return response.content + .filter((c): c is { type: "text"; text: string } => c.type === "text") + .map((c) => c.text) + .join("\n"); + }; + + doExtract() + .then(done) + .catch(() => done(null)); + + return loader; + }); + + if (result === null) { + ctx.ui.notify("Cancelled", "info"); + return; + } + + ctx.ui.setEditorText(result); + ctx.ui.notify("Questions loaded. Edit and submit when ready.", "info"); + }, + }); +} diff --git a/packages/coding-agent/examples/hooks/snake.ts b/packages/coding-agent/examples/hooks/snake.ts index 0837e185..c90cb151 100644 --- a/packages/coding-agent/examples/hooks/snake.ts +++ b/packages/coding-agent/examples/hooks/snake.ts @@ -2,8 +2,8 @@ * Snake game hook - play snake with /snake command */ +import type { HookAPI } from "@mariozechner/pi-coding-agent"; import { isArrowDown, isArrowLeft, isArrowRight, isArrowUp, isEscape, visibleWidth } from "@mariozechner/pi-tui"; -import type { HookAPI } from "../../src/core/hooks/types.js"; const GAME_WIDTH = 40; const GAME_HEIGHT = 15; @@ -56,7 +56,7 @@ class SnakeComponent { private interval: ReturnType | null = null; private onClose: () => void; private onSave: (state: GameState | null) => void; - private requestRender: () => void; + private tui: { requestRender: () => void }; private cachedLines: string[] = []; private cachedWidth = 0; private version = 0; @@ -64,11 +64,12 @@ class SnakeComponent { private paused: boolean; constructor( + tui: { requestRender: () => void }, onClose: () => void, onSave: (state: GameState | null) => void, - requestRender: () => void, savedState?: GameState, ) { + this.tui = tui; if (savedState && !savedState.gameOver) { // Resume from saved state, start paused this.state = savedState; @@ -84,7 +85,6 @@ class SnakeComponent { } this.onClose = onClose; this.onSave = onSave; - this.requestRender = requestRender; } private startGame(): void { @@ -92,7 +92,7 @@ class SnakeComponent { if (!this.state.gameOver) { this.tick(); this.version++; - this.requestRender(); + this.tui.requestRender(); } }, TICK_MS); } @@ -196,7 +196,7 @@ class SnakeComponent { this.state.highScore = highScore; this.onSave(null); // Clear saved state on restart this.version++; - this.requestRender(); + this.tui.requestRender(); } } @@ -327,19 +327,17 @@ export default function (pi: HookAPI) { } } - let ui: { close: () => void; requestRender: () => void } | null = null; - - const component = new SnakeComponent( - () => ui?.close(), - (state) => { - // Save or clear state - pi.appendEntry(SNAKE_SAVE_TYPE, state); - }, - () => ui?.requestRender(), - savedState, - ); - - ui = ctx.ui.custom(component); + await ctx.ui.custom((tui, _theme, done) => { + return new SnakeComponent( + tui, + () => done(undefined), + (state) => { + // Save or clear state + pi.appendEntry(SNAKE_SAVE_TYPE, state); + }, + savedState, + ); + }); }, }); } diff --git a/packages/coding-agent/examples/sdk/01-minimal.ts b/packages/coding-agent/examples/sdk/01-minimal.ts index b257fccc..80045132 100644 --- a/packages/coding-agent/examples/sdk/01-minimal.ts +++ b/packages/coding-agent/examples/sdk/01-minimal.ts @@ -5,7 +5,7 @@ * from cwd and ~/.pi/agent. Model chosen from settings or first available. */ -import { createAgentSession } from "../../src/index.js"; +import { createAgentSession } from "@mariozechner/pi-coding-agent"; const { session } = await createAgentSession(); diff --git a/packages/coding-agent/examples/sdk/02-custom-model.ts b/packages/coding-agent/examples/sdk/02-custom-model.ts index 54c3f8fc..5d5bf656 100644 --- a/packages/coding-agent/examples/sdk/02-custom-model.ts +++ b/packages/coding-agent/examples/sdk/02-custom-model.ts @@ -5,7 +5,7 @@ */ import { getModel } from "@mariozechner/pi-ai"; -import { createAgentSession, discoverAuthStorage, discoverModels } from "../../src/index.js"; +import { createAgentSession, discoverAuthStorage, discoverModels } from "@mariozechner/pi-coding-agent"; // Set up auth storage and model registry const authStorage = discoverAuthStorage(); diff --git a/packages/coding-agent/examples/sdk/03-custom-prompt.ts b/packages/coding-agent/examples/sdk/03-custom-prompt.ts index 9f19d67c..37698f46 100644 --- a/packages/coding-agent/examples/sdk/03-custom-prompt.ts +++ b/packages/coding-agent/examples/sdk/03-custom-prompt.ts @@ -4,7 +4,7 @@ * Shows how to replace or modify the default system prompt. */ -import { createAgentSession, SessionManager } from "../../src/index.js"; +import { createAgentSession, SessionManager } from "@mariozechner/pi-coding-agent"; // Option 1: Replace prompt entirely const { session: session1 } = await createAgentSession({ diff --git a/packages/coding-agent/examples/sdk/04-skills.ts b/packages/coding-agent/examples/sdk/04-skills.ts index 42d1bd8f..bf04633f 100644 --- a/packages/coding-agent/examples/sdk/04-skills.ts +++ b/packages/coding-agent/examples/sdk/04-skills.ts @@ -5,7 +5,7 @@ * Discover, filter, merge, or replace them. */ -import { createAgentSession, discoverSkills, SessionManager, type Skill } from "../../src/index.js"; +import { createAgentSession, discoverSkills, SessionManager, type Skill } from "@mariozechner/pi-coding-agent"; // Discover all skills from cwd/.pi/skills, ~/.pi/agent/skills, etc. const allSkills = discoverSkills(); diff --git a/packages/coding-agent/examples/sdk/05-tools.ts b/packages/coding-agent/examples/sdk/05-tools.ts index f7939688..09592cbf 100644 --- a/packages/coding-agent/examples/sdk/05-tools.ts +++ b/packages/coding-agent/examples/sdk/05-tools.ts @@ -8,7 +8,6 @@ * tools resolve paths relative to your cwd, not process.cwd(). */ -import { Type } from "@sinclair/typebox"; import { bashTool, // read, bash, edit, write - uses process.cwd() type CustomTool, @@ -21,7 +20,8 @@ import { readOnlyTools, // read, grep, find, ls - uses process.cwd() readTool, SessionManager, -} from "../../src/index.js"; +} from "@mariozechner/pi-coding-agent"; +import { Type } from "@sinclair/typebox"; // Read-only mode (no edit/write) - uses process.cwd() await createAgentSession({ diff --git a/packages/coding-agent/examples/sdk/06-hooks.ts b/packages/coding-agent/examples/sdk/06-hooks.ts index 93bfe98d..d0a7a07f 100644 --- a/packages/coding-agent/examples/sdk/06-hooks.ts +++ b/packages/coding-agent/examples/sdk/06-hooks.ts @@ -4,7 +4,7 @@ * Hooks intercept agent events for logging, blocking, or modification. */ -import { createAgentSession, type HookFactory, SessionManager } from "../../src/index.js"; +import { createAgentSession, type HookFactory, SessionManager } from "@mariozechner/pi-coding-agent"; // Logging hook const loggingHook: HookFactory = (api) => { diff --git a/packages/coding-agent/examples/sdk/07-context-files.ts b/packages/coding-agent/examples/sdk/07-context-files.ts index f2460c22..61aa871a 100644 --- a/packages/coding-agent/examples/sdk/07-context-files.ts +++ b/packages/coding-agent/examples/sdk/07-context-files.ts @@ -4,7 +4,7 @@ * Context files provide project-specific instructions loaded into the system prompt. */ -import { createAgentSession, discoverContextFiles, SessionManager } from "../../src/index.js"; +import { createAgentSession, discoverContextFiles, SessionManager } from "@mariozechner/pi-coding-agent"; // Discover AGENTS.md files walking up from cwd const discovered = discoverContextFiles(); diff --git a/packages/coding-agent/examples/sdk/08-slash-commands.ts b/packages/coding-agent/examples/sdk/08-slash-commands.ts index 5415eeaa..8c8dc08b 100644 --- a/packages/coding-agent/examples/sdk/08-slash-commands.ts +++ b/packages/coding-agent/examples/sdk/08-slash-commands.ts @@ -4,7 +4,12 @@ * File-based commands that inject content when invoked with /commandname. */ -import { createAgentSession, discoverSlashCommands, type FileSlashCommand, SessionManager } from "../../src/index.js"; +import { + createAgentSession, + discoverSlashCommands, + type FileSlashCommand, + SessionManager, +} from "@mariozechner/pi-coding-agent"; // Discover commands from cwd/.pi/commands/ and ~/.pi/agent/commands/ const discovered = discoverSlashCommands(); diff --git a/packages/coding-agent/examples/sdk/09-api-keys-and-oauth.ts b/packages/coding-agent/examples/sdk/09-api-keys-and-oauth.ts index 98e05e39..22cbf98b 100644 --- a/packages/coding-agent/examples/sdk/09-api-keys-and-oauth.ts +++ b/packages/coding-agent/examples/sdk/09-api-keys-and-oauth.ts @@ -11,7 +11,7 @@ import { discoverModels, ModelRegistry, SessionManager, -} from "../../src/index.js"; +} from "@mariozechner/pi-coding-agent"; // Default: discoverAuthStorage() uses ~/.pi/agent/auth.json // discoverModels() loads built-in + custom models from ~/.pi/agent/models.json diff --git a/packages/coding-agent/examples/sdk/10-settings.ts b/packages/coding-agent/examples/sdk/10-settings.ts index db11641c..a5451e2e 100644 --- a/packages/coding-agent/examples/sdk/10-settings.ts +++ b/packages/coding-agent/examples/sdk/10-settings.ts @@ -4,7 +4,7 @@ * Override settings using SettingsManager. */ -import { createAgentSession, loadSettings, SessionManager, SettingsManager } from "../../src/index.js"; +import { createAgentSession, loadSettings, SessionManager, SettingsManager } from "@mariozechner/pi-coding-agent"; // Load current settings (merged global + project) const settings = loadSettings(); diff --git a/packages/coding-agent/examples/sdk/11-sessions.ts b/packages/coding-agent/examples/sdk/11-sessions.ts index 7a883fb4..f1bbd047 100644 --- a/packages/coding-agent/examples/sdk/11-sessions.ts +++ b/packages/coding-agent/examples/sdk/11-sessions.ts @@ -4,7 +4,7 @@ * Control session persistence: in-memory, new file, continue, or open specific. */ -import { createAgentSession, SessionManager } from "../../src/index.js"; +import { createAgentSession, SessionManager } from "@mariozechner/pi-coding-agent"; // In-memory (no persistence) const { session: inMemory } = await createAgentSession({ diff --git a/packages/coding-agent/examples/sdk/12-full-control.ts b/packages/coding-agent/examples/sdk/12-full-control.ts index 5dbe7718..8ae7f5a4 100644 --- a/packages/coding-agent/examples/sdk/12-full-control.ts +++ b/packages/coding-agent/examples/sdk/12-full-control.ts @@ -9,7 +9,6 @@ */ import { getModel } from "@mariozechner/pi-ai"; -import { Type } from "@sinclair/typebox"; import { AuthStorage, type CustomTool, @@ -20,7 +19,8 @@ import { ModelRegistry, SessionManager, SettingsManager, -} from "../../src/index.js"; +} from "@mariozechner/pi-coding-agent"; +import { Type } from "@sinclair/typebox"; // Custom auth storage location const authStorage = new AuthStorage("/tmp/my-agent/auth.json"); diff --git a/packages/coding-agent/src/core/custom-tools/loader.ts b/packages/coding-agent/src/core/custom-tools/loader.ts index 0c51ff95..b7c38472 100644 --- a/packages/coding-agent/src/core/custom-tools/loader.ts +++ b/packages/coding-agent/src/core/custom-tools/loader.ts @@ -90,7 +90,9 @@ function createNoOpUIContext(): HookUIContext { confirm: async () => false, input: async () => undefined, notify: () => {}, - custom: () => ({ close: () => {}, requestRender: () => {} }), + custom: async () => undefined as never, + setEditorText: () => {}, + getEditorText: () => "", }; } diff --git a/packages/coding-agent/src/core/hooks/runner.ts b/packages/coding-agent/src/core/hooks/runner.ts index da15fc85..6be8b759 100644 --- a/packages/coding-agent/src/core/hooks/runner.ts +++ b/packages/coding-agent/src/core/hooks/runner.ts @@ -39,7 +39,9 @@ const noOpUIContext: HookUIContext = { confirm: async () => false, input: async () => undefined, notify: () => {}, - custom: () => ({ close: () => {}, requestRender: () => {} }), + custom: async () => undefined as never, + setEditorText: () => {}, + getEditorText: () => "", }; /** diff --git a/packages/coding-agent/src/core/hooks/types.ts b/packages/coding-agent/src/core/hooks/types.ts index 6acc843a..65ab9c0d 100644 --- a/packages/coding-agent/src/core/hooks/types.ts +++ b/packages/coding-agent/src/core/hooks/types.ts @@ -7,7 +7,7 @@ import type { AgentMessage } from "@mariozechner/pi-agent-core"; import type { ImageContent, Message, Model, TextContent, ToolResultMessage } from "@mariozechner/pi-ai"; -import type { Component } from "@mariozechner/pi-tui"; +import type { Component, TUI } from "@mariozechner/pi-tui"; import type { Theme } from "../../modes/interactive/theme/theme.js"; import type { CompactionPreparation, CompactionResult } from "../compaction/index.js"; import type { ExecOptions, ExecResult } from "../exec.js"; @@ -59,12 +59,48 @@ export interface HookUIContext { /** * Show a custom component with keyboard focus. - * The component receives keyboard input via handleInput() if implemented. + * The factory receives TUI, theme, and a done() callback to close the component. + * Can be async for fire-and-forget work (don't await the work, just start it). * - * @param component - Component to display (implement handleInput for keyboard, dispose for cleanup) - * @returns Object with close() to restore normal UI and requestRender() to trigger redraw + * @param factory - Function that creates the component. Call done() when finished. + * @returns Promise that resolves with the value passed to done() + * + * @example + * // Sync factory + * const result = await ctx.ui.custom((tui, theme, done) => { + * const component = new MyComponent(tui, theme); + * component.onFinish = (value) => done(value); + * return component; + * }); + * + * // Async factory with fire-and-forget work + * const result = await ctx.ui.custom(async (tui, theme, done) => { + * const loader = new CancellableLoader(tui, theme.fg("accent"), theme.fg("muted"), "Working..."); + * loader.onAbort = () => done(null); + * doWork(loader.signal).then(done); // Don't await - fire and forget + * return loader; + * }); */ - custom(component: Component & { dispose?(): void }): { close: () => void; requestRender: () => void }; + custom( + factory: ( + tui: TUI, + theme: Theme, + done: (result: T) => void, + ) => (Component & { dispose?(): void }) | Promise, + ): Promise; + + /** + * Set the text in the core input editor. + * Use this to pre-fill the input box with generated content (e.g., prompt templates, extracted questions). + * @param text - Text to set in the editor + */ + setEditorText(text: string): void; + + /** + * Get the current text from the core input editor. + * @returns Current editor text + */ + getEditorText(): string; } /** diff --git a/packages/coding-agent/src/core/sdk.ts b/packages/coding-agent/src/core/sdk.ts index e2eb11e0..a43eeaaa 100644 --- a/packages/coding-agent/src/core/sdk.ts +++ b/packages/coding-agent/src/core/sdk.ts @@ -140,7 +140,7 @@ export interface CreateAgentSessionResult { // Re-exports export type { CustomTool } from "./custom-tools/types.js"; -export type { HookAPI, HookFactory } from "./hooks/types.js"; +export type { HookAPI, HookContext, HookFactory } from "./hooks/types.js"; export type { Settings, SkillsSettings } from "./settings-manager.js"; export type { Skill } from "./skills.js"; export type { FileSlashCommand } from "./slash-commands.js"; diff --git a/packages/coding-agent/src/index.ts b/packages/coding-agent/src/index.ts index 0eeaef4c..f9265440 100644 --- a/packages/coding-agent/src/index.ts +++ b/packages/coding-agent/src/index.ts @@ -88,6 +88,10 @@ export { discoverSkills, discoverSlashCommands, type FileSlashCommand, + // Hook types + type HookAPI, + type HookContext, + type HookFactory, loadSettings, // Pre-built tools (use process.cwd()) readOnlyTools, @@ -150,5 +154,7 @@ export { } from "./core/tools/index.js"; // Main entry point export { main } from "./main.js"; +// UI components for hooks +export { BorderedLoader } from "./modes/interactive/components/bordered-loader.js"; // Theme utilities for custom tools export { getMarkdownTheme } from "./modes/interactive/theme/theme.js"; diff --git a/packages/coding-agent/src/modes/interactive/components/bordered-loader.ts b/packages/coding-agent/src/modes/interactive/components/bordered-loader.ts new file mode 100644 index 00000000..811ef9f7 --- /dev/null +++ b/packages/coding-agent/src/modes/interactive/components/bordered-loader.ts @@ -0,0 +1,41 @@ +import { CancellableLoader, Container, Spacer, Text, type TUI } from "@mariozechner/pi-tui"; +import type { Theme } from "../theme/theme.js"; +import { DynamicBorder } from "./dynamic-border.js"; + +/** Loader wrapped with borders for hook UI */ +export class BorderedLoader extends Container { + private loader: CancellableLoader; + + constructor(tui: TUI, theme: Theme, message: string) { + super(); + this.addChild(new DynamicBorder()); + this.addChild(new Spacer(1)); + this.loader = new CancellableLoader( + tui, + (s) => theme.fg("accent", s), + (s) => theme.fg("muted", s), + message, + ); + this.addChild(this.loader); + this.addChild(new Spacer(1)); + this.addChild(new Text(theme.fg("muted", "esc cancel"), 1, 0)); + this.addChild(new Spacer(1)); + this.addChild(new DynamicBorder()); + } + + get signal(): AbortSignal { + return this.loader.signal; + } + + set onAbort(fn: (() => void) | undefined) { + this.loader.onAbort = fn; + } + + handleInput(data: string): void { + this.loader.handleInput(data); + } + + dispose(): void { + this.loader.dispose(); + } +} diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 42ef8be6..eb520f46 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -54,7 +54,15 @@ import { ToolExecutionComponent } from "./components/tool-execution.js"; import { TreeSelectorComponent } from "./components/tree-selector.js"; import { UserMessageComponent } from "./components/user-message.js"; import { UserMessageSelectorComponent } from "./components/user-message-selector.js"; -import { getAvailableThemes, getEditorTheme, getMarkdownTheme, onThemeChange, setTheme, theme } from "./theme/theme.js"; +import { + getAvailableThemes, + getEditorTheme, + getMarkdownTheme, + onThemeChange, + setTheme, + type Theme, + theme, +} from "./theme/theme.js"; /** Interface for components that can be expanded/collapsed */ interface Expandable { @@ -356,7 +364,9 @@ export class InteractiveMode { confirm: (title, message) => this.showHookConfirm(title, message), input: (title, placeholder) => this.showHookInput(title, placeholder), notify: (message, type) => this.showHookNotify(message, type), - custom: (component) => this.showHookCustom(component), + custom: (factory) => this.showHookCustom(factory), + setEditorText: (text) => this.editor.setText(text), + getEditorText: () => this.editor.getText(), }; this.setToolUIContext(uiContext, true); @@ -537,38 +547,37 @@ export class InteractiveMode { /** * Show a custom component with keyboard focus. - * Returns a function to call when done. */ - private showHookCustom(component: Component & { dispose?(): void }): { - close: () => void; - requestRender: () => void; - } { - // Store current editor content + private async showHookCustom( + factory: ( + tui: TUI, + theme: Theme, + done: (result: T) => void, + ) => (Component & { dispose?(): void }) | Promise, + ): Promise { const savedText = this.editor.getText(); - // Replace editor with custom component - this.editorContainer.clear(); - this.editorContainer.addChild(component); - this.ui.setFocus(component); - this.ui.requestRender(); + return new Promise((resolve) => { + let component: Component & { dispose?(): void }; - // Return control object - return { - close: () => { - // Call dispose if available + const close = (result: T) => { component.dispose?.(); - - // Restore editor this.editorContainer.clear(); this.editorContainer.addChild(this.editor); this.editor.setText(savedText); this.ui.setFocus(this.editor); this.ui.requestRender(); - }, - requestRender: () => { + resolve(result); + }; + + Promise.resolve(factory(this.ui, theme, close)).then((c) => { + component = c; + this.editorContainer.clear(); + this.editorContainer.addChild(component); + this.ui.setFocus(component); this.ui.requestRender(); - }, - }; + }); + }); } /** diff --git a/packages/coding-agent/src/modes/rpc/rpc-mode.ts b/packages/coding-agent/src/modes/rpc/rpc-mode.ts index 09a0fde6..75f33db6 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-mode.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-mode.ts @@ -119,9 +119,25 @@ export async function runRpcMode(session: AgentSession): Promise { } as RpcHookUIRequest); }, - custom() { + async custom() { // Custom UI not supported in RPC mode - return { close: () => {}, requestRender: () => {} }; + return undefined as never; + }, + + setEditorText(text: string): void { + // Fire and forget - host can implement editor control + output({ + type: "hook_ui_request", + id: crypto.randomUUID(), + method: "set_editor_text", + text, + } as RpcHookUIRequest); + }, + + getEditorText(): string { + // Synchronous method can't wait for RPC response + // Host should track editor state locally if needed + return ""; }, }); diff --git a/packages/coding-agent/src/modes/rpc/rpc-types.ts b/packages/coding-agent/src/modes/rpc/rpc-types.ts index aa525687..8ab21247 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-types.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-types.ts @@ -181,7 +181,8 @@ export type RpcHookUIRequest = method: "notify"; message: string; notifyType?: "info" | "warning" | "error"; - }; + } + | { type: "hook_ui_request"; id: string; method: "set_editor_text"; text: string }; // ============================================================================ // Hook UI Commands (stdin) diff --git a/packages/coding-agent/test/compaction-hooks.test.ts b/packages/coding-agent/test/compaction-hooks.test.ts index d1fce41e..a4fd1eea 100644 --- a/packages/coding-agent/test/compaction-hooks.test.ts +++ b/packages/coding-agent/test/compaction-hooks.test.ts @@ -108,7 +108,9 @@ describe.skipIf(!API_KEY)("Compaction hooks", () => { confirm: async () => false, input: async () => undefined, notify: () => {}, - custom: () => ({ close: () => {}, requestRender: () => {} }), + custom: async () => undefined as never, + setEditorText: () => {}, + getEditorText: () => "", }, hasUI: false, }); diff --git a/packages/tui/README.md b/packages/tui/README.md index bea046d4..c93a13b2 100644 --- a/packages/tui/README.md +++ b/packages/tui/README.md @@ -247,6 +247,26 @@ loader.setMessage("Still loading..."); loader.stop(); ``` +### CancellableLoader + +Extends Loader with Escape key handling and an AbortSignal for cancelling async operations. + +```typescript +const loader = new CancellableLoader( + tui, // TUI instance for render updates + (s) => chalk.cyan(s), // spinner color function + (s) => chalk.gray(s), // message color function + "Working..." // message +); +loader.onAbort = () => done(null); // Called when user presses Escape +doAsyncWork(loader.signal).then(done); +``` + +**Properties:** +- `signal: AbortSignal` - Aborted when user presses Escape +- `aborted: boolean` - Whether the loader was aborted +- `onAbort?: () => void` - Callback when user presses Escape + ### SelectList Interactive selection list with keyboard navigation. diff --git a/packages/tui/src/components/cancellable-loader.ts b/packages/tui/src/components/cancellable-loader.ts new file mode 100644 index 00000000..8e2621da --- /dev/null +++ b/packages/tui/src/components/cancellable-loader.ts @@ -0,0 +1,39 @@ +import { isEscape } from "../keys.js"; +import { Loader } from "./loader.js"; + +/** + * Loader that can be cancelled with Escape. + * Extends Loader with an AbortSignal for cancelling async operations. + * + * @example + * const loader = new CancellableLoader(tui, cyan, dim, "Working..."); + * loader.onAbort = () => done(null); + * doWork(loader.signal).then(done); + */ +export class CancellableLoader extends Loader { + private abortController = new AbortController(); + + /** Called when user presses Escape */ + onAbort?: () => void; + + /** AbortSignal that is aborted when user presses Escape */ + get signal(): AbortSignal { + return this.abortController.signal; + } + + /** Whether the loader was aborted */ + get aborted(): boolean { + return this.abortController.signal.aborted; + } + + handleInput(data: string): void { + if (isEscape(data)) { + this.abortController.abort(); + this.onAbort?.(); + } + } + + dispose(): void { + this.stop(); + } +} diff --git a/packages/tui/src/index.ts b/packages/tui/src/index.ts index 8fcff1e5..d5a16207 100644 --- a/packages/tui/src/index.ts +++ b/packages/tui/src/index.ts @@ -9,6 +9,7 @@ export { } from "./autocomplete.js"; // Components export { Box } from "./components/box.js"; +export { CancellableLoader } from "./components/cancellable-loader.js"; export { Editor, type EditorTheme } from "./components/editor.js"; export { Image, type ImageOptions, type ImageTheme } from "./components/image.js"; export { Input } from "./components/input.js"; From 506e63a969949251cd5a883c32e5b6b90877a226 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Thu, 1 Jan 2026 00:25:04 +0100 Subject: [PATCH 041/124] Add thinkingText theme token, fix streaming toggle bug - Add configurable thinkingText color for thinking blocks (defaults to muted) - Make 'Thinking...' label italic when collapsed - Fix Ctrl+T during streaming hiding the current message - Track streamingMessage to properly re-render on toggle Based on #366 by @paulbettner --- packages/coding-agent/CHANGELOG.md | 2 + packages/coding-agent/docs/theme.md | 2 + .../components/assistant-message.ts | 7 ++-- .../src/modes/interactive/interactive-mode.ts | 39 ++++++++++++------- .../src/modes/interactive/theme/dark.json | 1 + .../src/modes/interactive/theme/light.json | 1 + .../src/modes/interactive/theme/theme.ts | 2 + 7 files changed, 36 insertions(+), 18 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 84caa259..4872c4b7 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -190,6 +190,7 @@ Total color count increased from 46 to 50. See [docs/theme.md](docs/theme.md) fo ### Added - **Snake game example hook**: Demonstrates `ui.custom()`, `registerCommand()`, and session persistence. See [examples/hooks/snake.ts](examples/hooks/snake.ts). +- **`thinkingText` theme token**: Configurable color for thinking block text. ([#366](https://github.com/badlogic/pi-mono/pull/366) by [@paulbettner](https://github.com/paulbettner)) ### Changed @@ -198,6 +199,7 @@ Total color count increased from 46 to 50. See [docs/theme.md](docs/theme.md) fo ### Fixed +- **Toggling thinking blocks during streaming shows nothing**: Pressing Ctrl+T while streaming would hide the current message until streaming completed. - **Resuming session resets thinking level to off**: Initial model and thinking level were not saved to session file, causing `--resume`/`--continue` to default to `off`. ([#342](https://github.com/badlogic/pi-mono/issues/342) by [@aliou](https://github.com/aliou)) - **Hook `tool_result` event ignores errors from custom tools**: The `tool_result` hook event was never emitted when tools threw errors, and always had `isError: false` for successful executions. Now emits the event with correct `isError` value in both success and error cases. ([#374](https://github.com/badlogic/pi-mono/issues/374) by [@nicobailon](https://github.com/nicobailon)) - **Edit tool fails on Windows due to CRLF line endings**: Files with CRLF line endings now match correctly when LLMs send LF-only text. Line endings are normalized before matching and restored to original style on write. ([#355](https://github.com/badlogic/pi-mono/issues/355) by [@Pratham-Dubey](https://github.com/Pratham-Dubey)) diff --git a/packages/coding-agent/docs/theme.md b/packages/coding-agent/docs/theme.md index bc6064f1..aba7643b 100644 --- a/packages/coding-agent/docs/theme.md +++ b/packages/coding-agent/docs/theme.md @@ -22,6 +22,7 @@ Every theme must define all color tokens. There are no optional colors. | `muted` | Secondary/dimmed text | Metadata, descriptions, output | | `dim` | Very dimmed text | Less important info, placeholders | | `text` | Default text color | Main content (usually `""`) | +| `thinkingText` | Thinking block text | Assistant reasoning traces | ### Backgrounds & Content Text (11 colors) @@ -119,6 +120,7 @@ Themes are defined in JSON files with the following structure: "colors": { "accent": "blue", "muted": "gray", + "thinkingText": "gray", "text": "", ... } diff --git a/packages/coding-agent/src/modes/interactive/components/assistant-message.ts b/packages/coding-agent/src/modes/interactive/components/assistant-message.ts index 8757e76c..01c919f3 100644 --- a/packages/coding-agent/src/modes/interactive/components/assistant-message.ts +++ b/packages/coding-agent/src/modes/interactive/components/assistant-message.ts @@ -53,16 +53,15 @@ export class AssistantMessageComponent extends Container { if (this.hideThinkingBlock) { // Show static "Thinking..." label when hidden - this.contentContainer.addChild(new Text(theme.fg("muted", "Thinking..."), 1, 0)); + this.contentContainer.addChild(new Text(theme.italic(theme.fg("thinkingText", "Thinking...")), 1, 0)); if (hasTextAfter) { this.contentContainer.addChild(new Spacer(1)); } } else { - // Thinking traces in muted color, italic - // Use Markdown component with default text style for consistent styling + // Thinking traces in thinkingText color, italic this.contentContainer.addChild( new Markdown(content.thinking.trim(), 1, 0, getMarkdownTheme(), { - color: (text: string) => theme.fg("muted", text), + color: (text: string) => theme.fg("thinkingText", text), italic: true, }), ); diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index eb520f46..ccb21042 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -93,6 +93,7 @@ export class InteractiveMode { // Streaming message tracking private streamingComponent: AssistantMessageComponent | undefined = undefined; + private streamingMessage: AssistantMessage | undefined = undefined; // Tool execution tracking: toolCallId -> component private pendingTools = new Map(); @@ -839,18 +840,19 @@ export class InteractiveMode { this.ui.requestRender(); } else if (event.message.role === "assistant") { this.streamingComponent = new AssistantMessageComponent(undefined, this.hideThinkingBlock); + this.streamingMessage = event.message; this.chatContainer.addChild(this.streamingComponent); - this.streamingComponent.updateContent(event.message); + this.streamingComponent.updateContent(this.streamingMessage); this.ui.requestRender(); } break; case "message_update": if (this.streamingComponent && event.message.role === "assistant") { - const assistantMsg = event.message as AssistantMessage; - this.streamingComponent.updateContent(assistantMsg); + this.streamingMessage = event.message; + this.streamingComponent.updateContent(this.streamingMessage); - for (const content of assistantMsg.content) { + for (const content of this.streamingMessage.content) { if (content.type === "toolCall") { if (!this.pendingTools.has(content.id)) { this.chatContainer.addChild(new Text("", 0, 0)); @@ -881,12 +883,14 @@ export class InteractiveMode { case "message_end": if (event.message.role === "user") break; if (this.streamingComponent && event.message.role === "assistant") { - const assistantMsg = event.message as AssistantMessage; - this.streamingComponent.updateContent(assistantMsg); + this.streamingMessage = event.message; + this.streamingComponent.updateContent(this.streamingMessage); - if (assistantMsg.stopReason === "aborted" || assistantMsg.stopReason === "error") { + if (this.streamingMessage.stopReason === "aborted" || this.streamingMessage.stopReason === "error") { const errorMessage = - assistantMsg.stopReason === "aborted" ? "Operation aborted" : assistantMsg.errorMessage || "Error"; + this.streamingMessage.stopReason === "aborted" + ? "Operation aborted" + : this.streamingMessage.errorMessage || "Error"; for (const [, component] of this.pendingTools.entries()) { component.updateResult({ content: [{ type: "text", text: errorMessage }], @@ -896,6 +900,7 @@ export class InteractiveMode { this.pendingTools.clear(); } this.streamingComponent = undefined; + this.streamingMessage = undefined; this.footer.invalidate(); } this.ui.requestRender(); @@ -948,6 +953,7 @@ export class InteractiveMode { if (this.streamingComponent) { this.chatContainer.removeChild(this.streamingComponent); this.streamingComponent = undefined; + this.streamingMessage = undefined; } this.pendingTools.clear(); this.ui.requestRender(); @@ -1329,14 +1335,17 @@ export class InteractiveMode { this.hideThinkingBlock = !this.hideThinkingBlock; this.settingsManager.setHideThinkingBlock(this.hideThinkingBlock); - for (const child of this.chatContainer.children) { - if (child instanceof AssistantMessageComponent) { - child.setHideThinkingBlock(this.hideThinkingBlock); - } - } - + // Rebuild chat from session messages this.chatContainer.clear(); this.rebuildChatFromMessages(); + + // If streaming, re-add the streaming component with updated visibility and re-render + if (this.streamingComponent && this.streamingMessage) { + this.streamingComponent.setHideThinkingBlock(this.hideThinkingBlock); + this.streamingComponent.updateContent(this.streamingMessage); + this.chatContainer.addChild(this.streamingComponent); + } + this.showStatus(`Thinking blocks: ${this.hideThinkingBlock ? "hidden" : "visible"}`); } @@ -1738,6 +1747,7 @@ export class InteractiveMode { // Clear UI state this.pendingMessagesContainer.clear(); this.streamingComponent = undefined; + this.streamingMessage = undefined; this.pendingTools.clear(); // Switch session via AgentSession (emits hook and tool session events) @@ -2004,6 +2014,7 @@ export class InteractiveMode { this.chatContainer.clear(); this.pendingMessagesContainer.clear(); this.streamingComponent = undefined; + this.streamingMessage = undefined; this.pendingTools.clear(); this.chatContainer.addChild(new Spacer(1)); diff --git a/packages/coding-agent/src/modes/interactive/theme/dark.json b/packages/coding-agent/src/modes/interactive/theme/dark.json index f55be9f7..069e32fd 100644 --- a/packages/coding-agent/src/modes/interactive/theme/dark.json +++ b/packages/coding-agent/src/modes/interactive/theme/dark.json @@ -29,6 +29,7 @@ "muted": "gray", "dim": "dimGray", "text": "", + "thinkingText": "gray", "selectedBg": "selectedBg", "userMessageBg": "userMsgBg", diff --git a/packages/coding-agent/src/modes/interactive/theme/light.json b/packages/coding-agent/src/modes/interactive/theme/light.json index a4276853..138af303 100644 --- a/packages/coding-agent/src/modes/interactive/theme/light.json +++ b/packages/coding-agent/src/modes/interactive/theme/light.json @@ -28,6 +28,7 @@ "muted": "mediumGray", "dim": "dimGray", "text": "", + "thinkingText": "mediumGray", "selectedBg": "selectedBg", "userMessageBg": "userMsgBg", diff --git a/packages/coding-agent/src/modes/interactive/theme/theme.ts b/packages/coding-agent/src/modes/interactive/theme/theme.ts index c0d8bf66..0121e199 100644 --- a/packages/coding-agent/src/modes/interactive/theme/theme.ts +++ b/packages/coding-agent/src/modes/interactive/theme/theme.ts @@ -34,6 +34,7 @@ const ThemeJsonSchema = Type.Object({ muted: ColorValueSchema, dim: ColorValueSchema, text: ColorValueSchema, + thinkingText: ColorValueSchema, // Backgrounds & Content Text (11 colors) selectedBg: ColorValueSchema, userMessageBg: ColorValueSchema, @@ -98,6 +99,7 @@ export type ThemeColor = | "muted" | "dim" | "text" + | "thinkingText" | "userMessageText" | "customMessageText" | "customMessageLabel" From a2afa490f145bd4ee29d00c812fa3f6d757b0ab6 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Thu, 1 Jan 2026 00:28:37 +0100 Subject: [PATCH 042/124] Coalesce sequential status messages Rapidly changing settings no longer spams the chat log with multiple status lines. fixes #365 --- packages/coding-agent/CHANGELOG.md | 1 + .../src/modes/interactive/interactive-mode.ts | 4 ++-- .../coding-agent/test/interactive-mode-status.test.ts | 8 ++++---- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 4872c4b7..82caffbd 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -199,6 +199,7 @@ Total color count increased from 46 to 50. See [docs/theme.md](docs/theme.md) fo ### Fixed +- **Status messages spam chat log**: Rapidly changing settings (e.g., thinking level via Shift+Tab) would add multiple status lines. Sequential status updates now coalesce into a single line. ([#365](https://github.com/badlogic/pi-mono/pull/365) by [@paulbettner](https://github.com/paulbettner)) - **Toggling thinking blocks during streaming shows nothing**: Pressing Ctrl+T while streaming would hide the current message until streaming completed. - **Resuming session resets thinking level to off**: Initial model and thinking level were not saved to session file, causing `--resume`/`--continue` to default to `off`. ([#342](https://github.com/badlogic/pi-mono/issues/342) by [@aliou](https://github.com/aliou)) - **Hook `tool_result` event ignores errors from custom tools**: The `tool_result` hook event was never emitted when tools threw errors, and always had `isError: false` for successful executions. Now emits the event with correct `isError` value in both success and error cases. ([#374](https://github.com/badlogic/pi-mono/issues/374) by [@nicobailon](https://github.com/nicobailon)) diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index fdca3981..1190e201 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -92,8 +92,8 @@ export class InteractiveMode { private changelogMarkdown: string | undefined = undefined; // Status line tracking (for mutating immediately-sequential status updates) - private lastStatusSpacer: Spacer | null = null; - private lastStatusText: Text | null = null; + private lastStatusSpacer: Spacer | undefined = undefined; + private lastStatusText: Text | undefined = undefined; // Streaming message tracking private streamingComponent: AssistantMessageComponent | undefined = undefined; diff --git a/packages/coding-agent/test/interactive-mode-status.test.ts b/packages/coding-agent/test/interactive-mode-status.test.ts index 610249b0..bab9085e 100644 --- a/packages/coding-agent/test/interactive-mode-status.test.ts +++ b/packages/coding-agent/test/interactive-mode-status.test.ts @@ -19,8 +19,8 @@ describe("InteractiveMode.showStatus", () => { const fakeThis: any = { chatContainer: new Container(), ui: { requestRender: vi.fn() }, - lastStatusSpacer: null, - lastStatusText: null, + lastStatusSpacer: undefined, + lastStatusText: undefined, }; (InteractiveMode as any).prototype.showStatus.call(fakeThis, "STATUS_ONE"); @@ -38,8 +38,8 @@ describe("InteractiveMode.showStatus", () => { const fakeThis: any = { chatContainer: new Container(), ui: { requestRender: vi.fn() }, - lastStatusSpacer: null, - lastStatusText: null, + lastStatusSpacer: undefined, + lastStatusText: undefined, }; (InteractiveMode as any).prototype.showStatus.call(fakeThis, "STATUS_ONE"); From 7369128b3aedc64533ff2ef090efd81809544d74 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Thu, 1 Jan 2026 00:37:59 +0100 Subject: [PATCH 043/124] Footer shows full session stats after compaction FooterComponent now iterates over all session entries for cumulative token usage and cost, not just post-compaction messages. fixes #322 --- packages/coding-agent/CHANGELOG.md | 1 + .../modes/interactive/components/footer.ts | 46 ++++++++----------- .../src/modes/interactive/interactive-mode.ts | 24 +++++----- 3 files changed, 33 insertions(+), 38 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 82caffbd..189a3ece 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -199,6 +199,7 @@ Total color count increased from 46 to 50. See [docs/theme.md](docs/theme.md) fo ### Fixed +- **Footer shows full session stats**: Token usage and cost now include all messages, not just those after compaction. ([#322](https://github.com/badlogic/pi-mono/issues/322)) - **Status messages spam chat log**: Rapidly changing settings (e.g., thinking level via Shift+Tab) would add multiple status lines. Sequential status updates now coalesce into a single line. ([#365](https://github.com/badlogic/pi-mono/pull/365) by [@paulbettner](https://github.com/paulbettner)) - **Toggling thinking blocks during streaming shows nothing**: Pressing Ctrl+T while streaming would hide the current message until streaming completed. - **Resuming session resets thinking level to off**: Initial model and thinking level were not saved to session file, causing `--resume`/`--continue` to default to `off`. ([#342](https://github.com/badlogic/pi-mono/issues/342) by [@aliou](https://github.com/aliou)) diff --git a/packages/coding-agent/src/modes/interactive/components/footer.ts b/packages/coding-agent/src/modes/interactive/components/footer.ts index 05e7a766..347404dc 100644 --- a/packages/coding-agent/src/modes/interactive/components/footer.ts +++ b/packages/coding-agent/src/modes/interactive/components/footer.ts @@ -1,9 +1,8 @@ -import type { AgentState } from "@mariozechner/pi-agent-core"; import type { AssistantMessage } from "@mariozechner/pi-ai"; import { type Component, visibleWidth } from "@mariozechner/pi-tui"; import { existsSync, type FSWatcher, readFileSync, watch } from "fs"; import { dirname, join } from "path"; -import type { ModelRegistry } from "../../../core/model-registry.js"; +import type { AgentSession } from "../../../core/agent-session.js"; import { theme } from "../theme/theme.js"; /** @@ -30,16 +29,14 @@ function findGitHeadPath(): string | null { * Footer component that shows pwd, token stats, and context usage */ export class FooterComponent implements Component { - private state: AgentState; - private modelRegistry: ModelRegistry; + private session: AgentSession; private cachedBranch: string | null | undefined = undefined; // undefined = not checked yet, null = not in git repo, string = branch name private gitWatcher: FSWatcher | null = null; private onBranchChange: (() => void) | null = null; private autoCompactEnabled: boolean = true; - constructor(state: AgentState, modelRegistry: ModelRegistry) { - this.state = state; - this.modelRegistry = modelRegistry; + constructor(session: AgentSession) { + this.session = session; } setAutoCompactEnabled(enabled: boolean): void { @@ -89,10 +86,6 @@ export class FooterComponent implements Component { } } - updateState(state: AgentState): void { - this.state = state; - } - invalidate(): void { // Invalidate cached branch so it gets re-read on next render this.cachedBranch = undefined; @@ -132,26 +125,27 @@ export class FooterComponent implements Component { } render(width: number): string[] { - // Calculate cumulative usage from all assistant messages + const state = this.session.state; + + // Calculate cumulative usage from ALL session entries (not just post-compaction messages) let totalInput = 0; let totalOutput = 0; let totalCacheRead = 0; let totalCacheWrite = 0; let totalCost = 0; - for (const message of this.state.messages) { - if (message.role === "assistant") { - const assistantMsg = message as AssistantMessage; - totalInput += assistantMsg.usage.input; - totalOutput += assistantMsg.usage.output; - totalCacheRead += assistantMsg.usage.cacheRead; - totalCacheWrite += assistantMsg.usage.cacheWrite; - totalCost += assistantMsg.usage.cost.total; + for (const entry of this.session.sessionManager.getEntries()) { + if (entry.type === "message" && entry.message.role === "assistant") { + totalInput += entry.message.usage.input; + totalOutput += entry.message.usage.output; + totalCacheRead += entry.message.usage.cacheRead; + totalCacheWrite += entry.message.usage.cacheWrite; + totalCost += entry.message.usage.cost.total; } } // Get last assistant message for context percentage calculation (skip aborted messages) - const lastAssistantMessage = this.state.messages + const lastAssistantMessage = state.messages .slice() .reverse() .find((m) => m.role === "assistant" && m.stopReason !== "aborted") as AssistantMessage | undefined; @@ -163,7 +157,7 @@ export class FooterComponent implements Component { lastAssistantMessage.usage.cacheRead + lastAssistantMessage.usage.cacheWrite : 0; - const contextWindow = this.state.model?.contextWindow || 0; + const contextWindow = state.model?.contextWindow || 0; const contextPercentValue = contextWindow > 0 ? (contextTokens / contextWindow) * 100 : 0; const contextPercent = contextPercentValue.toFixed(1); @@ -209,7 +203,7 @@ export class FooterComponent implements Component { if (totalCacheWrite) statsParts.push(`W${formatTokens(totalCacheWrite)}`); // Show cost with "(sub)" indicator if using OAuth subscription - const usingSubscription = this.state.model ? this.modelRegistry.isUsingOAuth(this.state.model) : false; + const usingSubscription = state.model ? this.session.modelRegistry.isUsingOAuth(state.model) : false; if (totalCost || usingSubscription) { const costStr = `$${totalCost.toFixed(3)}${usingSubscription ? " (sub)" : ""}`; statsParts.push(costStr); @@ -231,12 +225,12 @@ export class FooterComponent implements Component { let statsLeft = statsParts.join(" "); // Add model name on the right side, plus thinking level if model supports it - const modelName = this.state.model?.id || "no-model"; + const modelName = state.model?.id || "no-model"; // Add thinking level hint if model supports reasoning and thinking is enabled let rightSide = modelName; - if (this.state.model?.reasoning) { - const thinkingLevel = this.state.thinkingLevel || "off"; + if (state.model?.reasoning) { + const thinkingLevel = state.thinkingLevel || "off"; if (thinkingLevel !== "off") { rightSide = `${modelName} • ${thinkingLevel}`; } diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 1190e201..1a48fdf2 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -6,7 +6,7 @@ import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; -import type { AgentMessage, AgentState } from "@mariozechner/pi-agent-core"; +import type { AgentMessage } from "@mariozechner/pi-agent-core"; import type { AssistantMessage, Message, OAuthProvider } from "@mariozechner/pi-ai"; import type { SlashCommand } from "@mariozechner/pi-tui"; import { @@ -165,7 +165,7 @@ export class InteractiveMode { this.editor = new CustomEditor(getEditorTheme()); this.editorContainer = new Container(); this.editorContainer.addChild(this.editor); - this.footer = new FooterComponent(session.state, session.modelRegistry); + this.footer = new FooterComponent(session); this.footer.setAutoCompactEnabled(session.autoCompactionEnabled); // Define slash commands for autocomplete @@ -806,16 +806,16 @@ export class InteractiveMode { private subscribeToAgent(): void { this.unsubscribe = this.session.subscribe(async (event) => { - await this.handleEvent(event, this.session.state); + await this.handleEvent(event); }); } - private async handleEvent(event: AgentSessionEvent, state: AgentState): Promise { + private async handleEvent(event: AgentSessionEvent): Promise { if (!this.isInitialized) { await this.init(); } - this.footer.updateState(state); + this.footer.invalidate(); switch (event.type) { case "agent_start": @@ -1013,7 +1013,7 @@ export class InteractiveMode { summary: event.result.summary, timestamp: Date.now(), }); - this.footer.updateState(this.session.state); + this.footer.invalidate(); } this.ui.requestRender(); break; @@ -1173,7 +1173,7 @@ export class InteractiveMode { this.pendingTools.clear(); if (options.updateFooter) { - this.footer.updateState(this.session.state); + this.footer.invalidate(); this.updateEditorBorderColor(); } @@ -1320,7 +1320,7 @@ export class InteractiveMode { if (newLevel === undefined) { this.showStatus("Current model does not support thinking"); } else { - this.footer.updateState(this.session.state); + this.footer.invalidate(); this.updateEditorBorderColor(); this.showStatus(`Thinking level: ${newLevel}`); } @@ -1333,7 +1333,7 @@ export class InteractiveMode { const msg = this.session.scopedModels.length > 0 ? "Only one model in scope" : "Only one model available"; this.showStatus(msg); } else { - this.footer.updateState(this.session.state); + this.footer.invalidate(); this.updateEditorBorderColor(); const thinkingStr = result.model.reasoning && result.thinkingLevel !== "off" ? ` (thinking: ${result.thinkingLevel})` : ""; @@ -1530,7 +1530,7 @@ export class InteractiveMode { }, onThinkingLevelChange: (level) => { this.session.setThinkingLevel(level); - this.footer.updateState(this.session.state); + this.footer.invalidate(); this.updateEditorBorderColor(); }, onThemeChange: (themeName) => { @@ -1583,7 +1583,7 @@ export class InteractiveMode { async (model) => { try { await this.session.setModel(model); - this.footer.updateState(this.session.state); + this.footer.invalidate(); this.updateEditorBorderColor(); done(); this.showStatus(`Model: ${model.id}`); @@ -2172,7 +2172,7 @@ export class InteractiveMode { const msg = createCompactionSummaryMessage(result.summary, result.tokensBefore, new Date().toISOString()); this.addMessageToChat(msg); - this.footer.updateState(this.session.state); + this.footer.invalidate(); } catch (error) { const message = error instanceof Error ? error.message : String(error); if (message === "Compaction cancelled" || (error instanceof Error && error.name === "AbortError")) { From 46bb5dcde8a7eb8aa0a94ba387310c6a8afca232 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Thu, 1 Jan 2026 02:04:04 +0100 Subject: [PATCH 044/124] fix: enabledModels now supports glob patterns for OAuth providers Added glob pattern support (e.g., github-copilot/*, *sonnet*) to --models and enabledModels. Patterns are matched against both provider/modelId and just modelId, so *sonnet* works without requiring anthropic/*sonnet*. The existing fuzzy substring matching for non-glob patterns is preserved. fixes #337 --- packages/coding-agent/CHANGELOG.md | 1 + packages/coding-agent/README.md | 9 +++-- packages/coding-agent/src/cli/args.ts | 6 +++- .../coding-agent/src/core/model-resolver.ts | 36 +++++++++++++++++++ 4 files changed, 48 insertions(+), 4 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 189a3ece..88a79802 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -211,6 +211,7 @@ Total color count increased from 46 to 50. See [docs/theme.md](docs/theme.md) fo - **Session file validation**: `findMostRecentSession()` now validates session headers before returning, preventing non-session JSONL files from being loaded - **Compaction error handling**: `generateSummary()` and `generateTurnPrefixSummary()` now throw on LLM errors instead of returning empty strings - **Compaction with branched sessions**: Fixed compaction incorrectly including entries from abandoned branches, causing token overflow errors. Compaction now uses `sessionManager.getPath()` to work only on the current branch path, eliminating 80+ lines of duplicate entry collection logic between `prepareCompaction()` and `compact()` +- **enabledModels glob patterns**: `--models` and `enabledModels` now support glob patterns like `github-copilot/*` or `*sonnet*`. Previously, patterns were only matched literally or via substring search. ([#337](https://github.com/badlogic/pi-mono/issues/337)) ## [0.30.2] - 2025-12-26 diff --git a/packages/coding-agent/README.md b/packages/coding-agent/README.md index 58278f2f..2f734254 100644 --- a/packages/coding-agent/README.md +++ b/packages/coding-agent/README.md @@ -497,7 +497,7 @@ Global `~/.pi/agent/settings.json` stores persistent preferences: "defaultProvider": "anthropic", "defaultModel": "claude-sonnet-4-20250514", "defaultThinkingLevel": "medium", - "enabledModels": ["claude-sonnet", "gpt-4o", "gemini-2.5-pro:high"], + "enabledModels": ["anthropic/*", "*gpt*", "gemini-2.5-pro:high"], "queueMode": "one-at-a-time", "shellPath": "C:\\path\\to\\bash.exe", "hideThinkingBlock": false, @@ -529,7 +529,7 @@ Global `~/.pi/agent/settings.json` stores persistent preferences: | `defaultProvider` | Default model provider | - | | `defaultModel` | Default model ID | - | | `defaultThinkingLevel` | Thinking level: `off`, `minimal`, `low`, `medium`, `high`, `xhigh` | - | -| `enabledModels` | Model patterns for cycling (same as `--models` CLI flag) | - | +| `enabledModels` | Model patterns for cycling. Supports glob patterns (`github-copilot/*`, `*sonnet*`) and fuzzy matching. Same as `--models` CLI flag | - | | `queueMode` | Message queue mode: `all` or `one-at-a-time` | `one-at-a-time` | | `shellPath` | Custom bash path (Windows) | auto-detected | | `hideThinkingBlock` | Hide thinking blocks in output (Ctrl+T to toggle) | `false` | @@ -788,7 +788,7 @@ pi [options] [@files...] [messages...] | `--session-dir ` | Directory for session storage and lookup | | `--continue`, `-c` | Continue most recent session | | `--resume`, `-r` | Select session to resume | -| `--models ` | Comma-separated patterns for Ctrl+P cycling (e.g., `sonnet:high,haiku:low`) | +| `--models ` | Comma-separated patterns for Ctrl+P cycling. Supports glob patterns (e.g., `anthropic/*`, `*sonnet*:high`) and fuzzy matching (e.g., `sonnet,haiku:low`) | | `--tools ` | Comma-separated tool list (default: `read,bash,edit,write`) | | `--thinking ` | Thinking level: `off`, `minimal`, `low`, `medium`, `high` | | `--hook ` | Load a hook file (can be used multiple times) | @@ -840,6 +840,9 @@ pi --provider openai --model gpt-4o "Help me refactor" # Model cycling with thinking levels pi --models sonnet:high,haiku:low +# Limit to specific provider with glob pattern +pi --models "github-copilot/*" + # Read-only mode pi --tools read,grep,find,ls -p "Review the architecture" diff --git a/packages/coding-agent/src/cli/args.ts b/packages/coding-agent/src/cli/args.ts index dda8b805..7094ab54 100644 --- a/packages/coding-agent/src/cli/args.ts +++ b/packages/coding-agent/src/cli/args.ts @@ -158,7 +158,8 @@ ${chalk.bold("Options:")} --session Use specific session file --session-dir Directory for session storage and lookup --no-session Don't save session (ephemeral) - --models Comma-separated model patterns for quick cycling with Ctrl+P + --models Comma-separated model patterns for Ctrl+P cycling + Supports globs (anthropic/*, *sonnet*) and fuzzy matching --tools Comma-separated list of tools to enable (default: read,bash,edit,write) Available: read, bash, edit, write, grep, find, ls --thinking Set thinking level: off, minimal, low, medium, high, xhigh @@ -196,6 +197,9 @@ ${chalk.bold("Examples:")} # Limit model cycling to specific models ${APP_NAME} --models claude-sonnet,claude-haiku,gpt-4o + # Limit to a specific provider with glob pattern + ${APP_NAME} --models "github-copilot/*" + # Cycle models with fixed thinking levels ${APP_NAME} --models sonnet:high,haiku:low diff --git a/packages/coding-agent/src/core/model-resolver.ts b/packages/coding-agent/src/core/model-resolver.ts index 981f11f2..d2124413 100644 --- a/packages/coding-agent/src/core/model-resolver.ts +++ b/packages/coding-agent/src/core/model-resolver.ts @@ -5,6 +5,7 @@ import type { ThinkingLevel } from "@mariozechner/pi-agent-core"; import { type Api, type KnownProvider, type Model, modelsAreEqual } from "@mariozechner/pi-ai"; import chalk from "chalk"; +import { minimatch } from "minimatch"; import { isValidThinkingLevel } from "../cli/args.js"; import type { ModelRegistry } from "./model-registry.js"; @@ -172,6 +173,41 @@ export async function resolveModelScope(patterns: string[], modelRegistry: Model const scopedModels: ScopedModel[] = []; for (const pattern of patterns) { + // Check if pattern contains glob characters + if (pattern.includes("*") || pattern.includes("?") || pattern.includes("[")) { + // Extract optional thinking level suffix (e.g., "provider/*:high") + const colonIdx = pattern.lastIndexOf(":"); + let globPattern = pattern; + let thinkingLevel: ThinkingLevel = "off"; + + if (colonIdx !== -1) { + const suffix = pattern.substring(colonIdx + 1); + if (isValidThinkingLevel(suffix)) { + thinkingLevel = suffix; + globPattern = pattern.substring(0, colonIdx); + } + } + + // Match against "provider/modelId" format OR just model ID + // This allows "*sonnet*" to match without requiring "anthropic/*sonnet*" + const matchingModels = availableModels.filter((m) => { + const fullId = `${m.provider}/${m.id}`; + return minimatch(fullId, globPattern, { nocase: true }) || minimatch(m.id, globPattern, { nocase: true }); + }); + + if (matchingModels.length === 0) { + console.warn(chalk.yellow(`Warning: No models match pattern "${pattern}"`)); + continue; + } + + for (const model of matchingModels) { + if (!scopedModels.find((sm) => modelsAreEqual(sm.model, model))) { + scopedModels.push({ model, thinkingLevel }); + } + } + continue; + } + const { model, thinkingLevel, warning } = parseModelPattern(pattern, availableModels); if (warning) { From bbf23bd5f1492b33d1f0c2ed85ec236a4037f19b Mon Sep 17 00:00:00 2001 From: "Mr. Rc" <60568652+HACKE-RC@users.noreply.github.com> Date: Thu, 1 Jan 2026 06:46:29 +0530 Subject: [PATCH 045/124] Fix characters (#372) * Fix cat command * Fix text rendering crash from undefined code points in bash output * Revert unintentional model parameter changes from fix cat command commit --- .../interactive/components/tool-execution.ts | 7 ++-- packages/coding-agent/src/utils/shell.ts | 35 ++++++++++++++++--- packages/tui/src/utils.ts | 7 ++++ 3 files changed, 40 insertions(+), 9 deletions(-) diff --git a/packages/coding-agent/src/modes/interactive/components/tool-execution.ts b/packages/coding-agent/src/modes/interactive/components/tool-execution.ts index 4f6bfac7..837f94fb 100644 --- a/packages/coding-agent/src/modes/interactive/components/tool-execution.ts +++ b/packages/coding-agent/src/modes/interactive/components/tool-execution.ts @@ -13,6 +13,7 @@ import { import stripAnsi from "strip-ansi"; import type { CustomTool } from "../../../core/custom-tools/types.js"; import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize } from "../../../core/tools/truncate.js"; +import { sanitizeBinaryOutput } from "../../../utils/shell.js"; import { getLanguageFromPath, highlightCode, theme } from "../theme/theme.js"; import { renderDiff } from "./diff.js"; import { truncateToVisualLines } from "./visual-truncate.js"; @@ -295,10 +296,8 @@ export class ToolExecutionComponent extends Container { let output = textBlocks .map((c: any) => { - let text = stripAnsi(c.text || "").replace(/\r/g, ""); - text = text.replace(/\x1b./g, ""); - text = text.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f]/g, ""); - return text; + // Use sanitizeBinaryOutput to handle binary data that crashes string-width + return sanitizeBinaryOutput(stripAnsi(c.text || "")).replace(/\r/g, ""); }) .join("\n"); diff --git a/packages/coding-agent/src/utils/shell.ts b/packages/coding-agent/src/utils/shell.ts index b9e45f64..ab838bfd 100644 --- a/packages/coding-agent/src/utils/shell.ts +++ b/packages/coding-agent/src/utils/shell.ts @@ -100,13 +100,38 @@ export function getShellConfig(): { shell: string; args: string[] } { * - Control characters (except tab, newline, carriage return) * - Lone surrogates * - Unicode Format characters (crash string-width due to a bug) + * - Characters with undefined code points */ export function sanitizeBinaryOutput(str: string): string { - // Fast path: use regex to remove problematic characters - // - \p{Format}: Unicode format chars like \u0601 that crash string-width - // - \p{Surrogate}: Lone surrogates from invalid UTF-8 - // - Control chars except \t \n \r - return str.replace(/[\p{Format}\p{Surrogate}]/gu, "").replace(/[\x00-\x08\x0B\x0C\x0E-\x1F]/g, ""); + // Use Array.from to properly iterate over code points (not code units) + // This handles surrogate pairs correctly and catches edge cases where + // codePointAt() might return undefined + return Array.from(str) + .filter((char) => { + // Filter out characters that cause string-width to crash + // This includes: + // - Unicode format characters + // - Lone surrogates (already filtered by Array.from) + // - Control chars except \t \n \r + // - Characters with undefined code points + + const code = char.codePointAt(0); + + // Skip if code point is undefined (edge case with invalid strings) + if (code === undefined) return false; + + // Allow tab, newline, carriage return + if (code === 0x09 || code === 0x0a || code === 0x0d) return true; + + // Filter out control characters (0x00-0x1F, except 0x09, 0x0a, 0x0x0d) + if (code <= 0x1f) return false; + + // Filter out Unicode format characters + if (code >= 0xfff9 && code <= 0xfffb) return false; + + return true; + }) + .join(""); } /** diff --git a/packages/tui/src/utils.ts b/packages/tui/src/utils.ts index 4c311443..dace78c1 100644 --- a/packages/tui/src/utils.ts +++ b/packages/tui/src/utils.ts @@ -4,6 +4,7 @@ import stringWidth from "string-width"; * Calculate the visible width of a string in terminal columns. */ export function visibleWidth(str: string): number { + if (!str) return 0; const normalized = str.replace(/\t/g, " "); return stringWidth(normalized); } @@ -472,6 +473,9 @@ function breakLongWord(word: string, width: number, tracker: AnsiCodeTracker): s } const grapheme = seg.value; + // Skip empty graphemes to avoid issues with string-width calculation + if (!grapheme) continue; + const graphemeWidth = visibleWidth(grapheme); if (currentWidth + graphemeWidth > width) { @@ -576,6 +580,9 @@ export function truncateToWidth(text: string, maxWidth: number, ellipsis: string } const grapheme = seg.value; + // Skip empty graphemes to avoid issues with string-width calculation + if (!grapheme) continue; + const graphemeWidth = visibleWidth(grapheme); if (currentWidth + graphemeWidth > targetWidth) { From a07347755550632ace6ac1fd16196a9fdbabd51d Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Thu, 1 Jan 2026 02:16:51 +0100 Subject: [PATCH 046/124] Add changelog entries for #372 --- packages/coding-agent/CHANGELOG.md | 4 ++++ packages/tui/CHANGELOG.md | 1 + 2 files changed, 5 insertions(+) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 88a79802..d939a074 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Crash when displaying bash output containing Unicode format characters like U+0600-U+0604 ([#372](https://github.com/badlogic/pi-mono/pull/372) by [@HACKE-RC](https://github.com/HACKE-RC)) + This release introduces session trees for in-place branching, major API changes to hooks and custom tools, and structured compaction with file tracking. ### Session Tree diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index d9ba7270..3e066be9 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -16,6 +16,7 @@ ### Fixed - Markdown component now renders HTML tags as plain text instead of silently dropping them ([#359](https://github.com/badlogic/pi-mono/issues/359)) +- Crash in `visibleWidth()` and grapheme iteration when encountering undefined code points ([#372](https://github.com/badlogic/pi-mono/pull/372) by [@HACKE-RC](https://github.com/HACKE-RC)) ## [0.29.0] - 2025-12-25 From 256fa575fbfb81a22e55238049f64b3d3d7c5b55 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Thu, 1 Jan 2026 03:36:47 +0100 Subject: [PATCH 047/124] WIP: Rewrite export-html with tree sidebar, client-side rendering - Add tree sidebar with search and filter (Default/All/Labels) - Client-side markdown/syntax highlighting via vendored marked.js + highlight.js - Base64 encode session data to avoid escaping issues - Reuse theme.ts color tokens via getResolvedThemeColors() - Sticky sidebar, responsive mobile layout with overlay - Click tree node to scroll to message - Keyboard shortcuts: Esc to reset, Ctrl/Cmd+F to search --- packages/ai/src/models.generated.ts | 22 +- packages/coding-agent/package.json | 4 +- packages/coding-agent/src/config.ts | 15 + .../coding-agent/src/core/agent-session.ts | 2 +- packages/coding-agent/src/core/export-html.ts | 1431 -------------- .../src/core/export-html/index.ts | 130 ++ .../src/core/export-html/template.html | 1731 +++++++++++++++++ .../core/export-html/vendor/highlight.min.js | 1213 ++++++++++++ .../src/core/export-html/vendor/marked.min.js | 6 + packages/coding-agent/src/main.ts | 2 +- .../src/modes/interactive/theme/theme.ts | 85 + 11 files changed, 3195 insertions(+), 1446 deletions(-) delete mode 100644 packages/coding-agent/src/core/export-html.ts create mode 100644 packages/coding-agent/src/core/export-html/index.ts create mode 100644 packages/coding-agent/src/core/export-html/template.html create mode 100644 packages/coding-agent/src/core/export-html/vendor/highlight.min.js create mode 100644 packages/coding-agent/src/core/export-html/vendor/marked.min.js diff --git a/packages/ai/src/models.generated.ts b/packages/ai/src/models.generated.ts index f7fb6aff..37b56f3f 100644 --- a/packages/ai/src/models.generated.ts +++ b/packages/ai/src/models.generated.ts @@ -3263,7 +3263,7 @@ export const MODELS = { cacheWrite: 0, }, contextWindow: 163840, - maxTokens: 163840, + maxTokens: 65536, } satisfies Model<"openai-completions">, "deepseek/deepseek-r1-distill-llama-70b": { id: "deepseek/deepseek-r1-distill-llama-70b", @@ -3563,13 +3563,13 @@ export const MODELS = { reasoning: false, input: ["text", "image"], cost: { - input: 0.04, - output: 0.15, + input: 0.036, + output: 0.064, cacheRead: 0, cacheWrite: 0, }, - contextWindow: 96000, - maxTokens: 96000, + contextWindow: 131072, + maxTokens: 4096, } satisfies Model<"openai-completions">, "google/gemma-3-27b-it:free": { id: "google/gemma-3-27b-it:free", @@ -5297,8 +5297,8 @@ export const MODELS = { reasoning: true, input: ["text"], cost: { - input: 0.039, - output: 0.19, + input: 0.02, + output: 0.09999999999999999, cacheRead: 0, cacheWrite: 0, }, @@ -5348,8 +5348,8 @@ export const MODELS = { reasoning: true, input: ["text"], cost: { - input: 0.03, - output: 0.14, + input: 0.016, + output: 0.06, cacheRead: 0, cacheWrite: 0, }, @@ -5994,8 +5994,8 @@ export const MODELS = { reasoning: false, input: ["text"], cost: { - input: 0.09, - output: 1.1, + input: 0.06, + output: 0.6, cacheRead: 0, cacheWrite: 0, }, diff --git a/packages/coding-agent/package.json b/packages/coding-agent/package.json index f17340f9..c22f5d89 100644 --- a/packages/coding-agent/package.json +++ b/packages/coding-agent/package.json @@ -32,8 +32,8 @@ "clean": "rm -rf dist", "build": "tsgo -p tsconfig.build.json && chmod +x dist/cli.js && npm run copy-assets", "build:binary": "npm run build && bun build --compile ./dist/cli.js --outfile dist/pi && npm run copy-binary-assets", - "copy-assets": "mkdir -p dist/modes/interactive/theme && cp src/modes/interactive/theme/*.json dist/modes/interactive/theme/", - "copy-binary-assets": "cp package.json dist/ && cp README.md dist/ && cp CHANGELOG.md dist/ && mkdir -p dist/theme && cp src/modes/interactive/theme/*.json dist/theme/ && cp -r docs dist/ && cp -r examples dist/", + "copy-assets": "mkdir -p dist/modes/interactive/theme && cp src/modes/interactive/theme/*.json dist/modes/interactive/theme/ && mkdir -p dist/core/export-html/vendor && cp src/core/export-html/template.html dist/core/export-html/ && cp src/core/export-html/vendor/*.js dist/core/export-html/vendor/", + "copy-binary-assets": "cp package.json dist/ && cp README.md dist/ && cp CHANGELOG.md dist/ && mkdir -p dist/theme && cp src/modes/interactive/theme/*.json dist/theme/ && mkdir -p dist/export-html/vendor && cp src/core/export-html/template.html dist/export-html/ && cp src/core/export-html/vendor/*.js dist/export-html/vendor/ && cp -r docs dist/ && cp -r examples dist/", "test": "vitest --run", "prepublishOnly": "npm run clean && npm run build" }, diff --git a/packages/coding-agent/src/config.ts b/packages/coding-agent/src/config.ts index 7eeeb007..202eb6b6 100644 --- a/packages/coding-agent/src/config.ts +++ b/packages/coding-agent/src/config.ts @@ -60,6 +60,21 @@ export function getThemesDir(): string { return join(packageDir, srcOrDist, "modes", "interactive", "theme"); } +/** + * Get path to HTML export template directory (shipped with package) + * - For Bun binary: export-html/ next to executable + * - For Node.js (dist/): dist/core/export-html/ + * - For tsx (src/): src/core/export-html/ + */ +export function getExportTemplateDir(): string { + if (isBunBinary) { + return join(dirname(process.execPath), "export-html"); + } + const packageDir = getPackageDir(); + const srcOrDist = existsSync(join(packageDir, "src")) ? "src" : "dist"; + return join(packageDir, srcOrDist, "core", "export-html"); +} + /** Get path to package.json */ export function getPackageJsonPath(): string { return join(getPackageDir(), "package.json"); diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index 3e5db9aa..bfc4c2c5 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -28,7 +28,7 @@ import { shouldCompact, } from "./compaction/index.js"; import type { CustomToolContext, CustomToolSessionEvent, LoadedCustomTool } from "./custom-tools/index.js"; -import { exportSessionToHtml } from "./export-html.js"; +import { exportSessionToHtml } from "./export-html/index.js"; import type { HookContext, HookRunner, diff --git a/packages/coding-agent/src/core/export-html.ts b/packages/coding-agent/src/core/export-html.ts deleted file mode 100644 index c6f3139f..00000000 --- a/packages/coding-agent/src/core/export-html.ts +++ /dev/null @@ -1,1431 +0,0 @@ -import type { AgentMessage, AgentState } from "@mariozechner/pi-agent-core"; -import type { AssistantMessage, ImageContent, Message, ToolResultMessage, UserMessage } from "@mariozechner/pi-ai"; -import { existsSync, readFileSync, writeFileSync } from "fs"; -import hljs from "highlight.js"; -import { marked } from "marked"; -import { homedir } from "os"; -import * as path from "path"; -import { basename } from "path"; -import { APP_NAME, getCustomThemesDir, getThemesDir, VERSION } from "../config.js"; -import type { SessionManager } from "./session-manager.js"; - -// ============================================================================ -// Types -// ============================================================================ - -interface MessageEvent { - type: "message"; - message: Message; - timestamp?: number; -} - -interface ModelChangeEvent { - type: "model_change"; - provider: string; - modelId: string; - timestamp?: number; -} - -interface CompactionEvent { - type: "compaction"; - timestamp: string; - summary: string; - tokensBefore: number; -} - -type SessionEvent = MessageEvent | ModelChangeEvent | CompactionEvent; - -interface ParsedSessionData { - sessionId: string; - timestamp: string; - systemPrompt?: string; - modelsUsed: Set; - messages: Message[]; - toolResultsMap: Map; - sessionEvents: SessionEvent[]; - tokenStats: { input: number; output: number; cacheRead: number; cacheWrite: number }; - costStats: { input: number; output: number; cacheRead: number; cacheWrite: number }; - tools?: { name: string; description: string }[]; - contextWindow?: number; - isStreamingFormat?: boolean; -} - -// ============================================================================ -// Theme Types and Loading -// ============================================================================ - -interface ThemeJson { - name: string; - vars?: Record; - colors: Record; -} - -interface ThemeColors { - // Core UI - accent: string; - border: string; - borderAccent: string; - success: string; - error: string; - warning: string; - muted: string; - dim: string; - text: string; - // Backgrounds - userMessageBg: string; - userMessageText: string; - toolPendingBg: string; - toolSuccessBg: string; - toolErrorBg: string; - toolOutput: string; - // Markdown - mdHeading: string; - mdLink: string; - mdLinkUrl: string; - mdCode: string; - mdCodeBlock: string; - mdCodeBlockBorder: string; - mdQuote: string; - mdQuoteBorder: string; - mdHr: string; - mdListBullet: string; - // Diffs - toolDiffAdded: string; - toolDiffRemoved: string; - toolDiffContext: string; - // Syntax highlighting - syntaxComment: string; - syntaxKeyword: string; - syntaxFunction: string; - syntaxVariable: string; - syntaxString: string; - syntaxNumber: string; - syntaxType: string; - syntaxOperator: string; - syntaxPunctuation: string; -} - -/** Resolve a theme color value, following variable references until we get a final value. */ -function resolveColorValue( - value: string | number, - vars: Record, - defaultValue: string, - visited = new Set(), -): string { - if (value === "") return defaultValue; - if (typeof value !== "string") return defaultValue; - if (visited.has(value)) return defaultValue; - if (!(value in vars)) return value; // Return as-is (hex colors work in CSS) - visited.add(value); - return resolveColorValue(vars[value], vars, defaultValue, visited); -} - -/** Load theme JSON from built-in or custom themes directory. */ -function loadThemeJson(name: string): ThemeJson | undefined { - // Try built-in themes first - const themesDir = getThemesDir(); - const builtinPath = path.join(themesDir, `${name}.json`); - if (existsSync(builtinPath)) { - try { - return JSON.parse(readFileSync(builtinPath, "utf-8")) as ThemeJson; - } catch { - return undefined; - } - } - - // Try custom themes - const customThemesDir = getCustomThemesDir(); - const customPath = path.join(customThemesDir, `${name}.json`); - if (existsSync(customPath)) { - try { - return JSON.parse(readFileSync(customPath, "utf-8")) as ThemeJson; - } catch { - return undefined; - } - } - - return undefined; -} - -/** Build complete theme colors object, resolving theme JSON values against defaults. */ -function getThemeColors(themeName?: string): ThemeColors { - const isLight = isLightTheme(themeName); - - // Default colors based on theme type - const defaultColors: ThemeColors = isLight - ? { - // Light theme defaults - accent: "rgb(95, 135, 135)", - border: "rgb(95, 135, 175)", - borderAccent: "rgb(95, 135, 135)", - success: "rgb(135, 175, 135)", - error: "rgb(175, 95, 95)", - warning: "rgb(215, 175, 95)", - muted: "rgb(108, 108, 108)", - dim: "rgb(138, 138, 138)", - text: "rgb(0, 0, 0)", - userMessageBg: "rgb(232, 232, 232)", - userMessageText: "rgb(0, 0, 0)", - toolPendingBg: "rgb(232, 232, 240)", - toolSuccessBg: "rgb(232, 240, 232)", - toolErrorBg: "rgb(240, 232, 232)", - toolOutput: "rgb(108, 108, 108)", - mdHeading: "rgb(215, 175, 95)", - mdLink: "rgb(95, 135, 175)", - mdLinkUrl: "rgb(138, 138, 138)", - mdCode: "rgb(95, 135, 135)", - mdCodeBlock: "rgb(135, 175, 135)", - mdCodeBlockBorder: "rgb(108, 108, 108)", - mdQuote: "rgb(108, 108, 108)", - mdQuoteBorder: "rgb(108, 108, 108)", - mdHr: "rgb(108, 108, 108)", - mdListBullet: "rgb(135, 175, 135)", - toolDiffAdded: "rgb(135, 175, 135)", - toolDiffRemoved: "rgb(175, 95, 95)", - toolDiffContext: "rgb(108, 108, 108)", - syntaxComment: "rgb(0, 128, 0)", - syntaxKeyword: "rgb(0, 0, 255)", - syntaxFunction: "rgb(121, 94, 38)", - syntaxVariable: "rgb(0, 16, 128)", - syntaxString: "rgb(163, 21, 21)", - syntaxNumber: "rgb(9, 134, 88)", - syntaxType: "rgb(38, 127, 153)", - syntaxOperator: "rgb(0, 0, 0)", - syntaxPunctuation: "rgb(0, 0, 0)", - } - : { - // Dark theme defaults - accent: "rgb(138, 190, 183)", - border: "rgb(95, 135, 255)", - borderAccent: "rgb(0, 215, 255)", - success: "rgb(181, 189, 104)", - error: "rgb(204, 102, 102)", - warning: "rgb(255, 255, 0)", - muted: "rgb(128, 128, 128)", - dim: "rgb(102, 102, 102)", - text: "rgb(229, 229, 231)", - userMessageBg: "rgb(52, 53, 65)", - userMessageText: "rgb(229, 229, 231)", - toolPendingBg: "rgb(40, 40, 50)", - toolSuccessBg: "rgb(40, 50, 40)", - toolErrorBg: "rgb(60, 40, 40)", - toolOutput: "rgb(128, 128, 128)", - mdHeading: "rgb(240, 198, 116)", - mdLink: "rgb(129, 162, 190)", - mdLinkUrl: "rgb(102, 102, 102)", - mdCode: "rgb(138, 190, 183)", - mdCodeBlock: "rgb(181, 189, 104)", - mdCodeBlockBorder: "rgb(128, 128, 128)", - mdQuote: "rgb(128, 128, 128)", - mdQuoteBorder: "rgb(128, 128, 128)", - mdHr: "rgb(128, 128, 128)", - mdListBullet: "rgb(138, 190, 183)", - toolDiffAdded: "rgb(181, 189, 104)", - toolDiffRemoved: "rgb(204, 102, 102)", - toolDiffContext: "rgb(128, 128, 128)", - syntaxComment: "rgb(106, 153, 85)", - syntaxKeyword: "rgb(86, 156, 214)", - syntaxFunction: "rgb(220, 220, 170)", - syntaxVariable: "rgb(156, 220, 254)", - syntaxString: "rgb(206, 145, 120)", - syntaxNumber: "rgb(181, 206, 168)", - syntaxType: "rgb(78, 201, 176)", - syntaxOperator: "rgb(212, 212, 212)", - syntaxPunctuation: "rgb(212, 212, 212)", - }; - - if (!themeName) return defaultColors; - - const themeJson = loadThemeJson(themeName); - if (!themeJson) return defaultColors; - - const vars = themeJson.vars || {}; - const colors = themeJson.colors; - - const resolve = (key: keyof ThemeColors): string => { - const value = colors[key]; - if (value === undefined) return defaultColors[key]; - return resolveColorValue(value, vars, defaultColors[key]); - }; - - return { - accent: resolve("accent"), - border: resolve("border"), - borderAccent: resolve("borderAccent"), - success: resolve("success"), - error: resolve("error"), - warning: resolve("warning"), - muted: resolve("muted"), - dim: resolve("dim"), - text: resolve("text"), - userMessageBg: resolve("userMessageBg"), - userMessageText: resolve("userMessageText"), - toolPendingBg: resolve("toolPendingBg"), - toolSuccessBg: resolve("toolSuccessBg"), - toolErrorBg: resolve("toolErrorBg"), - toolOutput: resolve("toolOutput"), - mdHeading: resolve("mdHeading"), - mdLink: resolve("mdLink"), - mdLinkUrl: resolve("mdLinkUrl"), - mdCode: resolve("mdCode"), - mdCodeBlock: resolve("mdCodeBlock"), - mdCodeBlockBorder: resolve("mdCodeBlockBorder"), - mdQuote: resolve("mdQuote"), - mdQuoteBorder: resolve("mdQuoteBorder"), - mdHr: resolve("mdHr"), - mdListBullet: resolve("mdListBullet"), - toolDiffAdded: resolve("toolDiffAdded"), - toolDiffRemoved: resolve("toolDiffRemoved"), - toolDiffContext: resolve("toolDiffContext"), - syntaxComment: resolve("syntaxComment"), - syntaxKeyword: resolve("syntaxKeyword"), - syntaxFunction: resolve("syntaxFunction"), - syntaxVariable: resolve("syntaxVariable"), - syntaxString: resolve("syntaxString"), - syntaxNumber: resolve("syntaxNumber"), - syntaxType: resolve("syntaxType"), - syntaxOperator: resolve("syntaxOperator"), - syntaxPunctuation: resolve("syntaxPunctuation"), - }; -} - -/** Check if theme is a light theme (currently only matches "light" exactly). */ -function isLightTheme(themeName?: string): boolean { - return themeName === "light"; -} - -// ============================================================================ -// Utility functions -// ============================================================================ - -function escapeHtml(text: string): string { - return text - .replace(/&/g, "&") - .replace(//g, ">") - .replace(/"/g, """) - .replace(/'/g, "'"); -} - -function shortenPath(path: string): string { - const home = homedir(); - return path.startsWith(home) ? `~${path.slice(home.length)}` : path; -} - -function replaceTabs(text: string): string { - return text.replace(/\t/g, " "); -} - -function formatTimestamp(timestamp: number | string | undefined): string { - if (!timestamp) return ""; - const date = new Date(typeof timestamp === "string" ? timestamp : timestamp); - return date.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit", second: "2-digit" }); -} - -/** Highlight code using highlight.js. Returns HTML with syntax highlighting spans. */ -function highlightCode(code: string, lang?: string): string { - if (!lang) { - return escapeHtml(code); - } - try { - // Check if language is supported - if (hljs.getLanguage(lang)) { - return hljs.highlight(code, { language: lang, ignoreIllegals: true }).value; - } - // Try common aliases - const aliases: Record = { - ts: "typescript", - js: "javascript", - py: "python", - rb: "ruby", - sh: "bash", - yml: "yaml", - md: "markdown", - }; - const aliasedLang = aliases[lang]; - if (aliasedLang && hljs.getLanguage(aliasedLang)) { - return hljs.highlight(code, { language: aliasedLang, ignoreIllegals: true }).value; - } - } catch { - // Fall through to escaped output - } - return escapeHtml(code); -} - -/** Get language from file path extension. */ -function getLanguageFromPath(filePath: string): string | undefined { - const ext = filePath.split(".").pop()?.toLowerCase(); - if (!ext) return undefined; - - const extToLang: Record = { - ts: "typescript", - tsx: "typescript", - js: "javascript", - jsx: "javascript", - mjs: "javascript", - cjs: "javascript", - py: "python", - rb: "ruby", - rs: "rust", - go: "go", - java: "java", - kt: "kotlin", - swift: "swift", - c: "c", - h: "c", - cpp: "cpp", - cc: "cpp", - cxx: "cpp", - hpp: "cpp", - cs: "csharp", - php: "php", - sh: "bash", - bash: "bash", - zsh: "bash", - fish: "bash", - ps1: "powershell", - sql: "sql", - html: "html", - htm: "html", - xml: "xml", - css: "css", - scss: "scss", - sass: "scss", - less: "less", - json: "json", - yaml: "yaml", - yml: "yaml", - toml: "toml", - ini: "ini", - md: "markdown", - markdown: "markdown", - dockerfile: "dockerfile", - makefile: "makefile", - cmake: "cmake", - lua: "lua", - r: "r", - scala: "scala", - clj: "clojure", - cljs: "clojure", - ex: "elixir", - exs: "elixir", - erl: "erlang", - hrl: "erlang", - hs: "haskell", - ml: "ocaml", - mli: "ocaml", - fs: "fsharp", - fsx: "fsharp", - vue: "vue", - svelte: "xml", - tf: "hcl", - hcl: "hcl", - proto: "protobuf", - graphql: "graphql", - gql: "graphql", - }; - - return extToLang[ext]; -} - -/** Render markdown to HTML server-side with TUI-style code block formatting and syntax highlighting. */ -function renderMarkdown(text: string): string { - // Custom renderer for code blocks to match TUI style - const renderer = new marked.Renderer(); - renderer.code = ({ text: code, lang }: { text: string; lang?: string }) => { - const language = lang || ""; - const highlighted = highlightCode(code, lang); - return ( - '
' + - `
\`\`\`${language}
` + - `
${highlighted}
` + - '' + - "
" - ); - }; - - // Configure marked for safe rendering - marked.setOptions({ - breaks: true, - gfm: true, - }); - - // Parse markdown (marked escapes HTML by default in newer versions) - return marked.parse(text, { renderer }) as string; -} - -function formatExpandableOutput(lines: string[], maxLines: number, lang?: string): string { - const displayLines = lines.slice(0, maxLines); - const remaining = lines.length - maxLines; - - // If language is provided, highlight the entire code block - if (lang) { - const code = lines.join("\n"); - const highlighted = highlightCode(code, lang); - - if (remaining > 0) { - // For expandable, we need preview and full versions - const previewCode = displayLines.join("\n"); - const previewHighlighted = highlightCode(previewCode, lang); - - let out = '`; - return out; - } - - return `
${highlighted}
`; - } - - // No language - plain text output - if (remaining > 0) { - let out = '"; - return out; - } - - let out = '
'; - for (const line of displayLines) { - out += `
${escapeHtml(replaceTabs(line))}
`; - } - out += "
"; - return out; -} - -// ============================================================================ -// Parsing functions -// ============================================================================ - -function parseSessionManagerFormat(lines: string[]): ParsedSessionData { - const data: ParsedSessionData = { - sessionId: "unknown", - timestamp: new Date().toISOString(), - modelsUsed: new Set(), - messages: [], - toolResultsMap: new Map(), - sessionEvents: [], - tokenStats: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - costStats: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - }; - - for (const line of lines) { - let entry: { type: string; [key: string]: unknown }; - try { - entry = JSON.parse(line) as { type: string; [key: string]: unknown }; - } catch { - continue; - } - - switch (entry.type) { - case "session": - data.sessionId = (entry.id as string) || "unknown"; - data.timestamp = (entry.timestamp as string) || data.timestamp; - data.systemPrompt = entry.systemPrompt as string | undefined; - if (entry.modelId) { - const modelInfo = entry.provider ? `${entry.provider}/${entry.modelId}` : (entry.modelId as string); - data.modelsUsed.add(modelInfo); - } - break; - - case "message": { - const message = entry.message as Message; - data.messages.push(message); - data.sessionEvents.push({ - type: "message", - message, - timestamp: entry.timestamp as number | undefined, - }); - - if (message.role === "toolResult") { - const toolResult = message as ToolResultMessage; - data.toolResultsMap.set(toolResult.toolCallId, toolResult); - } else if (message.role === "assistant") { - const assistantMsg = message as AssistantMessage; - if (assistantMsg.usage) { - data.tokenStats.input += assistantMsg.usage.input || 0; - data.tokenStats.output += assistantMsg.usage.output || 0; - data.tokenStats.cacheRead += assistantMsg.usage.cacheRead || 0; - data.tokenStats.cacheWrite += assistantMsg.usage.cacheWrite || 0; - if (assistantMsg.usage.cost) { - data.costStats.input += assistantMsg.usage.cost.input || 0; - data.costStats.output += assistantMsg.usage.cost.output || 0; - data.costStats.cacheRead += assistantMsg.usage.cost.cacheRead || 0; - data.costStats.cacheWrite += assistantMsg.usage.cost.cacheWrite || 0; - } - } - } - break; - } - - case "model_change": - data.sessionEvents.push({ - type: "model_change", - provider: entry.provider as string, - modelId: entry.modelId as string, - timestamp: entry.timestamp as number | undefined, - }); - if (entry.modelId) { - const modelInfo = entry.provider ? `${entry.provider}/${entry.modelId}` : (entry.modelId as string); - data.modelsUsed.add(modelInfo); - } - break; - - case "compaction": - data.sessionEvents.push({ - type: "compaction", - timestamp: entry.timestamp as string, - summary: entry.summary as string, - tokensBefore: entry.tokensBefore as number, - }); - break; - } - } - - return data; -} - -function parseStreamingEventFormat(lines: string[]): ParsedSessionData { - const data: ParsedSessionData = { - sessionId: "unknown", - timestamp: new Date().toISOString(), - modelsUsed: new Set(), - messages: [], - toolResultsMap: new Map(), - sessionEvents: [], - tokenStats: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - costStats: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - isStreamingFormat: true, - }; - - let timestampSet = false; - - for (const line of lines) { - let entry: { type: string; message?: Message }; - try { - entry = JSON.parse(line) as { type: string; message?: Message }; - } catch { - continue; - } - - if (entry.type === "message_end" && entry.message) { - const msg = entry.message; - data.messages.push(msg); - data.sessionEvents.push({ - type: "message", - message: msg, - timestamp: (msg as { timestamp?: number }).timestamp, - }); - - if (msg.role === "toolResult") { - const toolResult = msg as ToolResultMessage; - data.toolResultsMap.set(toolResult.toolCallId, toolResult); - } else if (msg.role === "assistant") { - const assistantMsg = msg as AssistantMessage; - if (assistantMsg.model) { - const modelInfo = assistantMsg.provider - ? `${assistantMsg.provider}/${assistantMsg.model}` - : assistantMsg.model; - data.modelsUsed.add(modelInfo); - } - if (assistantMsg.usage) { - data.tokenStats.input += assistantMsg.usage.input || 0; - data.tokenStats.output += assistantMsg.usage.output || 0; - data.tokenStats.cacheRead += assistantMsg.usage.cacheRead || 0; - data.tokenStats.cacheWrite += assistantMsg.usage.cacheWrite || 0; - if (assistantMsg.usage.cost) { - data.costStats.input += assistantMsg.usage.cost.input || 0; - data.costStats.output += assistantMsg.usage.cost.output || 0; - data.costStats.cacheRead += assistantMsg.usage.cost.cacheRead || 0; - data.costStats.cacheWrite += assistantMsg.usage.cost.cacheWrite || 0; - } - } - } - - if (!timestampSet && (msg as { timestamp?: number }).timestamp) { - data.timestamp = new Date((msg as { timestamp: number }).timestamp).toISOString(); - timestampSet = true; - } - } - } - - data.sessionId = `stream-${data.timestamp.replace(/[:.]/g, "-")}`; - return data; -} - -function detectFormat(lines: string[]): "session-manager" | "streaming-events" | "unknown" { - for (const line of lines) { - try { - const entry = JSON.parse(line) as { type: string }; - if (entry.type === "session") return "session-manager"; - if (entry.type === "agent_start" || entry.type === "message_start" || entry.type === "turn_start") { - return "streaming-events"; - } - } catch {} - } - return "unknown"; -} - -function parseSessionFile(content: string): ParsedSessionData { - const lines = content - .trim() - .split("\n") - .filter((l) => l.trim()); - - if (lines.length === 0) { - throw new Error("Empty session file"); - } - - const format = detectFormat(lines); - if (format === "unknown") { - throw new Error("Unknown session file format"); - } - - return format === "session-manager" ? parseSessionManagerFormat(lines) : parseStreamingEventFormat(lines); -} - -// ============================================================================ -// HTML formatting functions -// ============================================================================ - -function formatToolExecution( - toolName: string, - args: Record, - result: ToolResultMessage | undefined, - colors: ThemeColors, -): { html: string; bgColor: string } { - let html = ""; - const isError = result?.isError || false; - const bgColor = result ? (isError ? colors.toolErrorBg : colors.toolSuccessBg) : colors.toolPendingBg; - - const getTextOutput = (): string => { - if (!result) return ""; - const textBlocks = result.content.filter((c) => c.type === "text"); - return textBlocks.map((c) => (c as { type: "text"; text: string }).text).join("\n"); - }; - - switch (toolName) { - case "bash": { - const command = (args?.command as string) || ""; - html = `
$ ${escapeHtml(command || "...")}
`; - if (result) { - const output = getTextOutput().trim(); - if (output) { - html += formatExpandableOutput(output.split("\n"), 5); - } - } - break; - } - - case "read": { - const filePath = (args?.file_path as string) || (args?.path as string) || ""; - const shortenedPath = shortenPath(filePath); - const offset = args?.offset as number | undefined; - const limit = args?.limit as number | undefined; - const lang = getLanguageFromPath(filePath); - - // Build path display with offset/limit suffix - let pathHtml = escapeHtml(shortenedPath || "..."); - if (offset !== undefined || limit !== undefined) { - const startLine = offset ?? 1; - const endLine = limit !== undefined ? startLine + limit - 1 : ""; - pathHtml += `:${startLine}${endLine ? `-${endLine}` : ""}`; - } - - html = `
read ${pathHtml}
`; - if (result) { - const output = getTextOutput(); - if (output) { - html += formatExpandableOutput(output.split("\n"), 10, lang); - } - } - break; - } - - case "write": { - const filePath = (args?.file_path as string) || (args?.path as string) || ""; - const shortenedPath = shortenPath(filePath); - const fileContent = (args?.content as string) || ""; - const lines = fileContent ? fileContent.split("\n") : []; - const lang = getLanguageFromPath(filePath); - - html = `
write ${escapeHtml(shortenedPath || "...")}`; - if (lines.length > 10) { - html += ` (${lines.length} lines)`; - } - html += "
"; - - if (fileContent) { - html += formatExpandableOutput(lines, 10, lang); - } - if (result) { - const output = getTextOutput().trim(); - if (output) { - html += `
${escapeHtml(output)}
`; - } - } - break; - } - - case "edit": { - const path = shortenPath((args?.file_path as string) || (args?.path as string) || ""); - html = `
edit ${escapeHtml(path || "...")}
`; - - if (result?.details?.diff) { - const diffLines = result.details.diff.split("\n"); - html += '
'; - for (const line of diffLines) { - if (line.startsWith("+")) { - html += `
${escapeHtml(line)}
`; - } else if (line.startsWith("-")) { - html += `
${escapeHtml(line)}
`; - } else { - html += `
${escapeHtml(line)}
`; - } - } - html += "
"; - } - if (result) { - const output = getTextOutput().trim(); - if (output) { - html += `
${escapeHtml(output)}
`; - } - } - break; - } - - default: { - html = `
${escapeHtml(toolName)}
`; - html += `
${escapeHtml(JSON.stringify(args, null, 2))}
`; - if (result) { - const output = getTextOutput(); - if (output) { - html += `
${escapeHtml(output)}
`; - } - } - } - } - - return { html, bgColor }; -} - -function formatMessage( - message: AgentMessage, - toolResultsMap: Map, - colors: ThemeColors, -): string { - let html = ""; - const timestamp = (message as { timestamp?: number }).timestamp; - const timestampHtml = timestamp ? `
${formatTimestamp(timestamp)}
` : ""; - - switch (message.role) { - case "bashExecution": { - const isError = - message.cancelled || - (message.exitCode !== 0 && message.exitCode !== null && message.exitCode !== undefined); - - html += `
`; - html += timestampHtml; - html += `
$ ${escapeHtml(message.command)}
`; - - if (message.output) { - const lines = message.output.split("\n"); - html += formatExpandableOutput(lines, 10); - } - - if (message.cancelled) { - html += `
(cancelled)
`; - } else if (message.exitCode !== 0 && message.exitCode !== null && message.exitCode !== undefined) { - html += `
(exit ${message.exitCode})
`; - } - - if (message.truncated && message.fullOutputPath) { - html += `
Output truncated. Full output: ${escapeHtml(message.fullOutputPath)}
`; - } - - html += `
`; - break; - } - case "user": { - const userMsg = message as UserMessage; - let textContent = ""; - const images: ImageContent[] = []; - - if (typeof userMsg.content === "string") { - textContent = userMsg.content; - } else { - for (const block of userMsg.content) { - if (block.type === "text") { - textContent += block.text; - } else if (block.type === "image") { - images.push(block as ImageContent); - } - } - } - - html += `
${timestampHtml}`; - - // Render images first - if (images.length > 0) { - html += `
`; - for (const img of images) { - html += `User uploaded image`; - } - html += `
`; - } - - // Render text as markdown (server-side) - if (textContent.trim()) { - html += `
${renderMarkdown(textContent)}
`; - } - - html += `
`; - break; - } - case "assistant": { - html += timestampHtml ? `
${timestampHtml}` : ""; - - for (const content of message.content) { - if (content.type === "text" && content.text.trim()) { - // Render markdown server-side - html += `
${renderMarkdown(content.text)}
`; - } else if (content.type === "thinking" && content.thinking.trim()) { - html += `
${escapeHtml(content.thinking.trim()).replace(/\n/g, "
")}
`; - } - } - - for (const content of message.content) { - if (content.type === "toolCall") { - const toolResult = toolResultsMap.get(content.id); - const { html: toolHtml, bgColor } = formatToolExecution( - content.name, - content.arguments as Record, - toolResult, - colors, - ); - html += `
${toolHtml}
`; - } - } - - const hasToolCalls = message.content.some((c) => c.type === "toolCall"); - if (!hasToolCalls) { - if (message.stopReason === "aborted") { - html += '
Aborted
'; - } else if (message.stopReason === "error") { - html += `
Error: ${escapeHtml(message.errorMessage || "Unknown error")}
`; - } - } - - if (timestampHtml) { - html += "
"; - } - break; - } - case "toolResult": - // Tool results are rendered inline with tool calls - break; - case "hookMessage": - // Hook messages with display:true shown as info boxes - if (message.display) { - const content = typeof message.content === "string" ? message.content : JSON.stringify(message.content); - html += `
${timestampHtml}
[${escapeHtml(message.customType)}]
${renderMarkdown(content)}
`; - } - break; - case "compactionSummary": - // Rendered separately via formatCompaction - break; - case "branchSummary": - // Rendered as compaction-like summary - html += `
Branch Summary
${escapeHtml(message.summary).replace(/\n/g, "
")}
`; - break; - default: { - // Exhaustive check - const _exhaustive: never = message; - } - } - - return html; -} - -function formatModelChange(event: ModelChangeEvent): string { - const timestamp = formatTimestamp(event.timestamp); - const timestampHtml = timestamp ? `
${timestamp}
` : ""; - const modelInfo = `${event.provider}/${event.modelId}`; - return `
${timestampHtml}
Switched to model: ${escapeHtml(modelInfo)}
`; -} - -function formatCompaction(event: CompactionEvent): string { - const timestamp = formatTimestamp(event.timestamp); - const timestampHtml = timestamp ? `
${timestamp}
` : ""; - const summaryHtml = escapeHtml(event.summary).replace(/\n/g, "
"); - - return `
-
- ${timestampHtml} -
- - Context compacted from ${event.tokensBefore.toLocaleString()} tokens - (click to expand summary) -
-
-
-
-
Summary sent to model
-
${summaryHtml}
-
-
-
`; -} - -// ============================================================================ -// HTML generation -// ============================================================================ - -function generateHtml(data: ParsedSessionData, filename: string, colors: ThemeColors, isLight: boolean): string { - const userMessages = data.messages.filter((m) => m.role === "user").length; - const assistantMessages = data.messages.filter((m) => m.role === "assistant").length; - - let toolCallsCount = 0; - for (const message of data.messages) { - if (message.role === "assistant") { - toolCallsCount += (message as AssistantMessage).content.filter((c) => c.type === "toolCall").length; - } - } - - const lastAssistantMessage = data.messages - .slice() - .reverse() - .find((m) => m.role === "assistant" && (m as AssistantMessage).stopReason !== "aborted") as - | AssistantMessage - | undefined; - - const contextTokens = lastAssistantMessage - ? lastAssistantMessage.usage.input + - lastAssistantMessage.usage.output + - lastAssistantMessage.usage.cacheRead + - lastAssistantMessage.usage.cacheWrite - : 0; - - const lastModel = lastAssistantMessage?.model || "unknown"; - const lastProvider = lastAssistantMessage?.provider || ""; - const lastModelInfo = lastProvider ? `${lastProvider}/${lastModel}` : lastModel; - - const contextWindow = data.contextWindow || 0; - const contextPercent = contextWindow > 0 ? ((contextTokens / contextWindow) * 100).toFixed(1) : undefined; - - let messagesHtml = ""; - for (const event of data.sessionEvents) { - switch (event.type) { - case "message": - if (event.message.role !== "toolResult") { - messagesHtml += formatMessage(event.message, data.toolResultsMap, colors); - } - break; - case "model_change": - messagesHtml += formatModelChange(event); - break; - case "compaction": - messagesHtml += formatCompaction(event); - break; - } - } - - const systemPromptHtml = data.systemPrompt - ? `
-
System Prompt
-
${escapeHtml(data.systemPrompt)}
-
` - : ""; - - const toolsHtml = data.tools - ? `
-
Available Tools
-
- ${data.tools.map((tool) => `
${escapeHtml(tool.name)} - ${escapeHtml(tool.description)}
`).join("")} -
-
` - : ""; - - const streamingNotice = data.isStreamingFormat - ? `
- Note: This session was reconstructed from raw agent event logs, which do not contain system prompt or tool definitions. -
` - : ""; - - const contextUsageText = contextPercent - ? `${contextTokens.toLocaleString()} / ${contextWindow.toLocaleString()} tokens (${contextPercent}%) - ${escapeHtml(lastModelInfo)}` - : `${contextTokens.toLocaleString()} tokens (last turn) - ${escapeHtml(lastModelInfo)}`; - - // Compute body background based on theme - const bodyBg = isLight ? "rgb(248, 248, 248)" : "rgb(24, 24, 30)"; - const containerBg = isLight ? "rgb(255, 255, 255)" : "rgb(30, 30, 36)"; - const compactionBg = isLight ? "rgb(255, 248, 220)" : "rgb(60, 55, 35)"; - const systemPromptBg = isLight ? "rgb(255, 250, 230)" : "rgb(60, 55, 40)"; - const streamingNoticeBg = isLight ? "rgb(250, 245, 235)" : "rgb(50, 45, 35)"; - const modelChangeBg = isLight ? "rgb(240, 240, 250)" : "rgb(40, 40, 50)"; - const userBashBg = isLight ? "rgb(255, 250, 240)" : "rgb(50, 48, 35)"; - const userBashErrorBg = isLight ? "rgb(255, 245, 235)" : "rgb(60, 45, 35)"; - - return ` - - - - - Session Export - ${escapeHtml(filename)} - - - -
-
-

${APP_NAME} v${VERSION}

-
-
Session:${escapeHtml(data.sessionId)}
-
Date:${new Date(data.timestamp).toLocaleString()}
-
Models:${ - Array.from(data.modelsUsed) - .map((m) => escapeHtml(m)) - .join(", ") || "unknown" - }
-
-
- -
-

Messages

-
-
User:${userMessages}
-
Assistant:${assistantMessages}
-
Tool Calls:${toolCallsCount}
-
-
- -
-

Tokens & Cost

-
-
Input:${data.tokenStats.input.toLocaleString()} tokens
-
Output:${data.tokenStats.output.toLocaleString()} tokens
-
Cache Read:${data.tokenStats.cacheRead.toLocaleString()} tokens
-
Cache Write:${data.tokenStats.cacheWrite.toLocaleString()} tokens
-
Total:${(data.tokenStats.input + data.tokenStats.output + data.tokenStats.cacheRead + data.tokenStats.cacheWrite).toLocaleString()} tokens
-
Input Cost:$${data.costStats.input.toFixed(4)}
-
Output Cost:$${data.costStats.output.toFixed(4)}
-
Cache Read Cost:$${data.costStats.cacheRead.toFixed(4)}
-
Cache Write Cost:$${data.costStats.cacheWrite.toFixed(4)}
-
Total Cost:$${(data.costStats.input + data.costStats.output + data.costStats.cacheRead + data.costStats.cacheWrite).toFixed(4)}
-
Context Usage:${contextUsageText}
-
-
- - ${systemPromptHtml} - ${toolsHtml} - ${streamingNotice} - -
- ${messagesHtml} -
- - -
- -`; -} - -// ============================================================================ -// Public API -// ============================================================================ - -export interface ExportOptions { - outputPath?: string; - themeName?: string; -} - -/** - * Export session to HTML using SessionManager and AgentState. - * Used by TUI's /export command. - * @param sessionManager The session manager - * @param state The agent state - * @param options Export options including output path and theme name - */ -export function exportSessionToHtml( - sessionManager: SessionManager, - state: AgentState, - options?: ExportOptions | string, -): string { - // Handle backwards compatibility: options can be just the output path string - const opts: ExportOptions = typeof options === "string" ? { outputPath: options } : options || {}; - - const sessionFile = sessionManager.getSessionFile(); - if (!sessionFile) { - throw new Error("Cannot export in-memory session to HTML"); - } - const content = readFileSync(sessionFile, "utf8"); - const data = parseSessionFile(content); - - // Enrich with data from AgentState (tools, context window) - data.tools = state.tools.map((t: { name: string; description: string }) => ({ - name: t.name, - description: t.description, - })); - data.contextWindow = state.model?.contextWindow; - if (!data.systemPrompt) { - data.systemPrompt = state.systemPrompt; - } - - let outputPath = opts.outputPath; - if (!outputPath) { - const sessionBasename = basename(sessionFile, ".jsonl"); - outputPath = `${APP_NAME}-session-${sessionBasename}.html`; - } - - const colors = getThemeColors(opts.themeName); - const isLight = isLightTheme(opts.themeName); - const html = generateHtml(data, basename(sessionFile), colors, isLight); - writeFileSync(outputPath, html, "utf8"); - return outputPath; -} - -/** - * Export session file to HTML (standalone, without AgentState). - * Auto-detects format: session manager format or streaming event format. - * Used by CLI for exporting arbitrary session files. - * @param inputPath Path to the session file - * @param options Export options including output path and theme name - */ -export function exportFromFile(inputPath: string, options?: ExportOptions | string): string { - // Handle backwards compatibility: options can be just the output path string - const opts: ExportOptions = typeof options === "string" ? { outputPath: options } : options || {}; - - if (!existsSync(inputPath)) { - throw new Error(`File not found: ${inputPath}`); - } - - const content = readFileSync(inputPath, "utf8"); - const data = parseSessionFile(content); - - let outputPath = opts.outputPath; - if (!outputPath) { - const inputBasename = basename(inputPath, ".jsonl"); - outputPath = `${APP_NAME}-session-${inputBasename}.html`; - } - - const colors = getThemeColors(opts.themeName); - const isLight = isLightTheme(opts.themeName); - const html = generateHtml(data, basename(inputPath), colors, isLight); - writeFileSync(outputPath, html, "utf8"); - return outputPath; -} diff --git a/packages/coding-agent/src/core/export-html/index.ts b/packages/coding-agent/src/core/export-html/index.ts new file mode 100644 index 00000000..bc28e177 --- /dev/null +++ b/packages/coding-agent/src/core/export-html/index.ts @@ -0,0 +1,130 @@ +import type { AgentState } from "@mariozechner/pi-agent-core"; +import { existsSync, readFileSync, writeFileSync } from "fs"; +import { basename, join } from "path"; +import { APP_NAME, getExportTemplateDir, VERSION } from "../../config.js"; +import { getResolvedThemeColors, isLightTheme } from "../../modes/interactive/theme/theme.js"; +import { SessionManager } from "../session-manager.js"; + +export interface ExportOptions { + outputPath?: string; + themeName?: string; +} + +/** + * Generate CSS custom property declarations from theme colors. + */ +function generateThemeVars(themeName?: string): string { + const colors = getResolvedThemeColors(themeName); + const lines: string[] = []; + for (const [key, value] of Object.entries(colors)) { + lines.push(`--${key}: ${value};`); + } + return lines.join("\n "); +} + +interface SessionData { + header: ReturnType; + entries: ReturnType; + leafId: string | null; + systemPrompt?: string; + tools?: { name: string; description: string }[]; +} + +/** + * Core HTML generation logic shared by both export functions. + */ +function generateHtml(sessionData: SessionData, themeName?: string): string { + const templateDir = getExportTemplateDir(); + const template = readFileSync(join(templateDir, "template.html"), "utf-8"); + const markedJs = readFileSync(join(templateDir, "vendor", "marked.min.js"), "utf-8"); + const hljsJs = readFileSync(join(templateDir, "vendor", "highlight.min.js"), "utf-8"); + + const themeVars = generateThemeVars(themeName); + const light = isLightTheme(themeName); + const bodyBg = light ? "#f8f8f8" : "#18181e"; + const containerBg = light ? "#ffffff" : "#1e1e24"; + + const title = `Session ${sessionData.header?.id ?? "export"} - ${APP_NAME}`; + + // Base64 encode session data to avoid escaping issues + const sessionDataBase64 = Buffer.from(JSON.stringify(sessionData)).toString("base64"); + + return template + .replace("{{TITLE}}", title) + .replace("{{THEME_VARS}}", themeVars) + .replace("{{BODY_BG}}", bodyBg) + .replace("{{CONTAINER_BG}}", containerBg) + .replace("{{SESSION_DATA}}", sessionDataBase64) + .replace("{{MARKED_JS}}", markedJs) + .replace("{{HIGHLIGHT_JS}}", hljsJs) + .replace("{{APP_NAME}}", `${APP_NAME} v${VERSION}`) + .replace("{{GENERATED_DATE}}", new Date().toLocaleString()); +} + +/** + * Export session to HTML using SessionManager and AgentState. + * Used by TUI's /export command. + */ +export function exportSessionToHtml(sm: SessionManager, state?: AgentState, options?: ExportOptions | string): string { + const opts: ExportOptions = typeof options === "string" ? { outputPath: options } : options || {}; + + const sessionFile = sm.getSessionFile(); + if (!sessionFile) { + throw new Error("Cannot export in-memory session to HTML"); + } + if (!existsSync(sessionFile)) { + throw new Error("Nothing to export yet - start a conversation first"); + } + + const sessionData: SessionData = { + header: sm.getHeader(), + entries: sm.getEntries(), + leafId: sm.getLeafId(), + systemPrompt: state?.systemPrompt, + tools: state?.tools?.map((t) => ({ name: t.name, description: t.description })), + }; + + const html = generateHtml(sessionData, opts.themeName); + + let outputPath = opts.outputPath; + if (!outputPath) { + const sessionBasename = basename(sessionFile, ".jsonl"); + outputPath = `${APP_NAME}-session-${sessionBasename}.html`; + } + + writeFileSync(outputPath, html, "utf8"); + return outputPath; +} + +/** + * Export session file to HTML (standalone, without AgentState). + * Used by CLI for exporting arbitrary session files. + */ +export function exportFromFile(inputPath: string, options?: ExportOptions | string): string { + const opts: ExportOptions = typeof options === "string" ? { outputPath: options } : options || {}; + + if (!existsSync(inputPath)) { + throw new Error(`File not found: ${inputPath}`); + } + + const sm = SessionManager.open(inputPath); + + const sessionData: SessionData = { + header: sm.getHeader(), + entries: sm.getEntries(), + leafId: sm.getLeafId(), + systemPrompt: undefined, + tools: undefined, + }; + + const html = generateHtml(sessionData, opts.themeName); + + let outputPath = opts.outputPath; + if (!outputPath) { + const inputBasename = basename(inputPath, ".jsonl"); + outputPath = `${APP_NAME}-session-${inputBasename}.html`; + } + + writeFileSync(outputPath, html, "utf8"); + return outputPath; +} diff --git a/packages/coding-agent/src/core/export-html/template.html b/packages/coding-agent/src/core/export-html/template.html new file mode 100644 index 00000000..12ffd124 --- /dev/null +++ b/packages/coding-agent/src/core/export-html/template.html @@ -0,0 +1,1731 @@ + + + + + + {{TITLE}} + + + + + + + + +
+ +
+
+
+ +
+
+ + + + + + + + + + + diff --git a/packages/coding-agent/src/core/export-html/vendor/highlight.min.js b/packages/coding-agent/src/core/export-html/vendor/highlight.min.js new file mode 100644 index 00000000..5d699ae6 --- /dev/null +++ b/packages/coding-agent/src/core/export-html/vendor/highlight.min.js @@ -0,0 +1,1213 @@ +/*! + Highlight.js v11.9.0 (git: f47103d4f1) + (c) 2006-2023 undefined and other contributors + License: BSD-3-Clause + */ +var hljs=function(){"use strict";function e(n){ +return n instanceof Map?n.clear=n.delete=n.set=()=>{ +throw Error("map is read-only")}:n instanceof Set&&(n.add=n.clear=n.delete=()=>{ +throw Error("set is read-only") +}),Object.freeze(n),Object.getOwnPropertyNames(n).forEach((t=>{ +const a=n[t],i=typeof a;"object"!==i&&"function"!==i||Object.isFrozen(a)||e(a) +})),n}class n{constructor(e){ +void 0===e.data&&(e.data={}),this.data=e.data,this.isMatchIgnored=!1} +ignoreMatch(){this.isMatchIgnored=!0}}function t(e){ +return e.replace(/&/g,"&").replace(//g,">").replace(/"/g,""").replace(/'/g,"'") +}function a(e,...n){const t=Object.create(null);for(const n in e)t[n]=e[n] +;return n.forEach((e=>{for(const n in e)t[n]=e[n]})),t}const i=e=>!!e.scope +;class r{constructor(e,n){ +this.buffer="",this.classPrefix=n.classPrefix,e.walk(this)}addText(e){ +this.buffer+=t(e)}openNode(e){if(!i(e))return;const n=((e,{prefix:n})=>{ +if(e.startsWith("language:"))return e.replace("language:","language-") +;if(e.includes(".")){const t=e.split(".") +;return[`${n}${t.shift()}`,...t.map(((e,n)=>`${e}${"_".repeat(n+1)}`))].join(" ") +}return`${n}${e}`})(e.scope,{prefix:this.classPrefix});this.span(n)} +closeNode(e){i(e)&&(this.buffer+="")}value(){return this.buffer}span(e){ +this.buffer+=``}}const s=(e={})=>{const n={children:[]} +;return Object.assign(n,e),n};class o{constructor(){ +this.rootNode=s(),this.stack=[this.rootNode]}get top(){ +return this.stack[this.stack.length-1]}get root(){return this.rootNode}add(e){ +this.top.children.push(e)}openNode(e){const n=s({scope:e}) +;this.add(n),this.stack.push(n)}closeNode(){ +if(this.stack.length>1)return this.stack.pop()}closeAllNodes(){ +for(;this.closeNode(););}toJSON(){return JSON.stringify(this.rootNode,null,4)} +walk(e){return this.constructor._walk(e,this.rootNode)}static _walk(e,n){ +return"string"==typeof n?e.addText(n):n.children&&(e.openNode(n), +n.children.forEach((n=>this._walk(e,n))),e.closeNode(n)),e}static _collapse(e){ +"string"!=typeof e&&e.children&&(e.children.every((e=>"string"==typeof e))?e.children=[e.children.join("")]:e.children.forEach((e=>{ +o._collapse(e)})))}}class l extends o{constructor(e){super(),this.options=e} +addText(e){""!==e&&this.add(e)}startScope(e){this.openNode(e)}endScope(){ +this.closeNode()}__addSublanguage(e,n){const t=e.root +;n&&(t.scope="language:"+n),this.add(t)}toHTML(){ +return new r(this,this.options).value()}finalize(){ +return this.closeAllNodes(),!0}}function c(e){ +return e?"string"==typeof e?e:e.source:null}function d(e){return b("(?=",e,")")} +function g(e){return b("(?:",e,")*")}function u(e){return b("(?:",e,")?")} +function b(...e){return e.map((e=>c(e))).join("")}function m(...e){const n=(e=>{ +const n=e[e.length-1] +;return"object"==typeof n&&n.constructor===Object?(e.splice(e.length-1,1),n):{} +})(e);return"("+(n.capture?"":"?:")+e.map((e=>c(e))).join("|")+")"} +function p(e){return RegExp(e.toString()+"|").exec("").length-1} +const _=/\[(?:[^\\\]]|\\.)*\]|\(\??|\\([1-9][0-9]*)|\\./ +;function h(e,{joinWith:n}){let t=0;return e.map((e=>{t+=1;const n=t +;let a=c(e),i="";for(;a.length>0;){const e=_.exec(a);if(!e){i+=a;break} +i+=a.substring(0,e.index), +a=a.substring(e.index+e[0].length),"\\"===e[0][0]&&e[1]?i+="\\"+(Number(e[1])+n):(i+=e[0], +"("===e[0]&&t++)}return i})).map((e=>`(${e})`)).join(n)} +const f="[a-zA-Z]\\w*",E="[a-zA-Z_]\\w*",y="\\b\\d+(\\.\\d+)?",N="(-?)(\\b0[xX][a-fA-F0-9]+|(\\b\\d+(\\.\\d*)?|\\.\\d+)([eE][-+]?\\d+)?)",w="\\b(0b[01]+)",v={ +begin:"\\\\[\\s\\S]",relevance:0},O={scope:"string",begin:"'",end:"'", +illegal:"\\n",contains:[v]},k={scope:"string",begin:'"',end:'"',illegal:"\\n", +contains:[v]},x=(e,n,t={})=>{const i=a({scope:"comment",begin:e,end:n, +contains:[]},t);i.contains.push({scope:"doctag", +begin:"[ ]*(?=(TODO|FIXME|NOTE|BUG|OPTIMIZE|HACK|XXX):)", +end:/(TODO|FIXME|NOTE|BUG|OPTIMIZE|HACK|XXX):/,excludeBegin:!0,relevance:0}) +;const r=m("I","a","is","so","us","to","at","if","in","it","on",/[A-Za-z]+['](d|ve|re|ll|t|s|n)/,/[A-Za-z]+[-][a-z]+/,/[A-Za-z][a-z]{2,}/) +;return i.contains.push({begin:b(/[ ]+/,"(",r,/[.]?[:]?([.][ ]|[ ])/,"){3}")}),i +},M=x("//","$"),S=x("/\\*","\\*/"),A=x("#","$");var C=Object.freeze({ +__proto__:null,APOS_STRING_MODE:O,BACKSLASH_ESCAPE:v,BINARY_NUMBER_MODE:{ +scope:"number",begin:w,relevance:0},BINARY_NUMBER_RE:w,COMMENT:x, +C_BLOCK_COMMENT_MODE:S,C_LINE_COMMENT_MODE:M,C_NUMBER_MODE:{scope:"number", +begin:N,relevance:0},C_NUMBER_RE:N,END_SAME_AS_BEGIN:e=>Object.assign(e,{ +"on:begin":(e,n)=>{n.data._beginMatch=e[1]},"on:end":(e,n)=>{ +n.data._beginMatch!==e[1]&&n.ignoreMatch()}}),HASH_COMMENT_MODE:A,IDENT_RE:f, +MATCH_NOTHING_RE:/\b\B/,METHOD_GUARD:{begin:"\\.\\s*"+E,relevance:0}, +NUMBER_MODE:{scope:"number",begin:y,relevance:0},NUMBER_RE:y, +PHRASAL_WORDS_MODE:{ +begin:/\b(a|an|the|are|I'm|isn't|don't|doesn't|won't|but|just|should|pretty|simply|enough|gonna|going|wtf|so|such|will|you|your|they|like|more)\b/ +},QUOTE_STRING_MODE:k,REGEXP_MODE:{scope:"regexp",begin:/\/(?=[^/\n]*\/)/, +end:/\/[gimuy]*/,contains:[v,{begin:/\[/,end:/\]/,relevance:0,contains:[v]}]}, +RE_STARTERS_RE:"!|!=|!==|%|%=|&|&&|&=|\\*|\\*=|\\+|\\+=|,|-|-=|/=|/|:|;|<<|<<=|<=|<|===|==|=|>>>=|>>=|>=|>>>|>>|>|\\?|\\[|\\{|\\(|\\^|\\^=|\\||\\|=|\\|\\||~", +SHEBANG:(e={})=>{const n=/^#![ ]*\// +;return e.binary&&(e.begin=b(n,/.*\b/,e.binary,/\b.*/)),a({scope:"meta",begin:n, +end:/$/,relevance:0,"on:begin":(e,n)=>{0!==e.index&&n.ignoreMatch()}},e)}, +TITLE_MODE:{scope:"title",begin:f,relevance:0},UNDERSCORE_IDENT_RE:E, +UNDERSCORE_TITLE_MODE:{scope:"title",begin:E,relevance:0}});function T(e,n){ +"."===e.input[e.index-1]&&n.ignoreMatch()}function R(e,n){ +void 0!==e.className&&(e.scope=e.className,delete e.className)}function D(e,n){ +n&&e.beginKeywords&&(e.begin="\\b("+e.beginKeywords.split(" ").join("|")+")(?!\\.)(?=\\b|\\s)", +e.__beforeBegin=T,e.keywords=e.keywords||e.beginKeywords,delete e.beginKeywords, +void 0===e.relevance&&(e.relevance=0))}function I(e,n){ +Array.isArray(e.illegal)&&(e.illegal=m(...e.illegal))}function L(e,n){ +if(e.match){ +if(e.begin||e.end)throw Error("begin & end are not supported with match") +;e.begin=e.match,delete e.match}}function B(e,n){ +void 0===e.relevance&&(e.relevance=1)}const $=(e,n)=>{if(!e.beforeMatch)return +;if(e.starts)throw Error("beforeMatch cannot be used with starts") +;const t=Object.assign({},e);Object.keys(e).forEach((n=>{delete e[n] +})),e.keywords=t.keywords,e.begin=b(t.beforeMatch,d(t.begin)),e.starts={ +relevance:0,contains:[Object.assign(t,{endsParent:!0})] +},e.relevance=0,delete t.beforeMatch +},z=["of","and","for","in","not","or","if","then","parent","list","value"],F="keyword" +;function U(e,n,t=F){const a=Object.create(null) +;return"string"==typeof e?i(t,e.split(" ")):Array.isArray(e)?i(t,e):Object.keys(e).forEach((t=>{ +Object.assign(a,U(e[t],n,t))})),a;function i(e,t){ +n&&(t=t.map((e=>e.toLowerCase()))),t.forEach((n=>{const t=n.split("|") +;a[t[0]]=[e,j(t[0],t[1])]}))}}function j(e,n){ +return n?Number(n):(e=>z.includes(e.toLowerCase()))(e)?0:1}const P={},K=e=>{ +console.error(e)},H=(e,...n)=>{console.log("WARN: "+e,...n)},q=(e,n)=>{ +P[`${e}/${n}`]||(console.log(`Deprecated as of ${e}. ${n}`),P[`${e}/${n}`]=!0) +},G=Error();function Z(e,n,{key:t}){let a=0;const i=e[t],r={},s={} +;for(let e=1;e<=n.length;e++)s[e+a]=i[e],r[e+a]=!0,a+=p(n[e-1]) +;e[t]=s,e[t]._emit=r,e[t]._multi=!0}function W(e){(e=>{ +e.scope&&"object"==typeof e.scope&&null!==e.scope&&(e.beginScope=e.scope, +delete e.scope)})(e),"string"==typeof e.beginScope&&(e.beginScope={ +_wrap:e.beginScope}),"string"==typeof e.endScope&&(e.endScope={_wrap:e.endScope +}),(e=>{if(Array.isArray(e.begin)){ +if(e.skip||e.excludeBegin||e.returnBegin)throw K("skip, excludeBegin, returnBegin not compatible with beginScope: {}"), +G +;if("object"!=typeof e.beginScope||null===e.beginScope)throw K("beginScope must be object"), +G;Z(e,e.begin,{key:"beginScope"}),e.begin=h(e.begin,{joinWith:""})}})(e),(e=>{ +if(Array.isArray(e.end)){ +if(e.skip||e.excludeEnd||e.returnEnd)throw K("skip, excludeEnd, returnEnd not compatible with endScope: {}"), +G +;if("object"!=typeof e.endScope||null===e.endScope)throw K("endScope must be object"), +G;Z(e,e.end,{key:"endScope"}),e.end=h(e.end,{joinWith:""})}})(e)}function Q(e){ +function n(n,t){ +return RegExp(c(n),"m"+(e.case_insensitive?"i":"")+(e.unicodeRegex?"u":"")+(t?"g":"")) +}class t{constructor(){ +this.matchIndexes={},this.regexes=[],this.matchAt=1,this.position=0} +addRule(e,n){ +n.position=this.position++,this.matchIndexes[this.matchAt]=n,this.regexes.push([n,e]), +this.matchAt+=p(e)+1}compile(){0===this.regexes.length&&(this.exec=()=>null) +;const e=this.regexes.map((e=>e[1]));this.matcherRe=n(h(e,{joinWith:"|" +}),!0),this.lastIndex=0}exec(e){this.matcherRe.lastIndex=this.lastIndex +;const n=this.matcherRe.exec(e);if(!n)return null +;const t=n.findIndex(((e,n)=>n>0&&void 0!==e)),a=this.matchIndexes[t] +;return n.splice(0,t),Object.assign(n,a)}}class i{constructor(){ +this.rules=[],this.multiRegexes=[], +this.count=0,this.lastIndex=0,this.regexIndex=0}getMatcher(e){ +if(this.multiRegexes[e])return this.multiRegexes[e];const n=new t +;return this.rules.slice(e).forEach((([e,t])=>n.addRule(e,t))), +n.compile(),this.multiRegexes[e]=n,n}resumingScanAtSamePosition(){ +return 0!==this.regexIndex}considerAll(){this.regexIndex=0}addRule(e,n){ +this.rules.push([e,n]),"begin"===n.type&&this.count++}exec(e){ +const n=this.getMatcher(this.regexIndex);n.lastIndex=this.lastIndex +;let t=n.exec(e) +;if(this.resumingScanAtSamePosition())if(t&&t.index===this.lastIndex);else{ +const n=this.getMatcher(0);n.lastIndex=this.lastIndex+1,t=n.exec(e)} +return t&&(this.regexIndex+=t.position+1, +this.regexIndex===this.count&&this.considerAll()),t}} +if(e.compilerExtensions||(e.compilerExtensions=[]), +e.contains&&e.contains.includes("self"))throw Error("ERR: contains `self` is not supported at the top-level of a language. See documentation.") +;return e.classNameAliases=a(e.classNameAliases||{}),function t(r,s){const o=r +;if(r.isCompiled)return o +;[R,L,W,$].forEach((e=>e(r,s))),e.compilerExtensions.forEach((e=>e(r,s))), +r.__beforeBegin=null,[D,I,B].forEach((e=>e(r,s))),r.isCompiled=!0;let l=null +;return"object"==typeof r.keywords&&r.keywords.$pattern&&(r.keywords=Object.assign({},r.keywords), +l=r.keywords.$pattern, +delete r.keywords.$pattern),l=l||/\w+/,r.keywords&&(r.keywords=U(r.keywords,e.case_insensitive)), +o.keywordPatternRe=n(l,!0), +s&&(r.begin||(r.begin=/\B|\b/),o.beginRe=n(o.begin),r.end||r.endsWithParent||(r.end=/\B|\b/), +r.end&&(o.endRe=n(o.end)), +o.terminatorEnd=c(o.end)||"",r.endsWithParent&&s.terminatorEnd&&(o.terminatorEnd+=(r.end?"|":"")+s.terminatorEnd)), +r.illegal&&(o.illegalRe=n(r.illegal)), +r.contains||(r.contains=[]),r.contains=[].concat(...r.contains.map((e=>(e=>(e.variants&&!e.cachedVariants&&(e.cachedVariants=e.variants.map((n=>a(e,{ +variants:null},n)))),e.cachedVariants?e.cachedVariants:X(e)?a(e,{ +starts:e.starts?a(e.starts):null +}):Object.isFrozen(e)?a(e):e))("self"===e?r:e)))),r.contains.forEach((e=>{t(e,o) +})),r.starts&&t(r.starts,s),o.matcher=(e=>{const n=new i +;return e.contains.forEach((e=>n.addRule(e.begin,{rule:e,type:"begin" +}))),e.terminatorEnd&&n.addRule(e.terminatorEnd,{type:"end" +}),e.illegal&&n.addRule(e.illegal,{type:"illegal"}),n})(o),o}(e)}function X(e){ +return!!e&&(e.endsWithParent||X(e.starts))}class V extends Error{ +constructor(e,n){super(e),this.name="HTMLInjectionError",this.html=n}} +const J=t,Y=a,ee=Symbol("nomatch"),ne=t=>{ +const a=Object.create(null),i=Object.create(null),r=[];let s=!0 +;const o="Could not find the language '{}', did you forget to load/include a language module?",c={ +disableAutodetect:!0,name:"Plain text",contains:[]};let p={ +ignoreUnescapedHTML:!1,throwUnescapedHTML:!1,noHighlightRe:/^(no-?highlight)$/i, +languageDetectRe:/\blang(?:uage)?-([\w-]+)\b/i,classPrefix:"hljs-", +cssSelector:"pre code",languages:null,__emitter:l};function _(e){ +return p.noHighlightRe.test(e)}function h(e,n,t){let a="",i="" +;"object"==typeof n?(a=e, +t=n.ignoreIllegals,i=n.language):(q("10.7.0","highlight(lang, code, ...args) has been deprecated."), +q("10.7.0","Please use highlight(code, options) instead.\nhttps://github.com/highlightjs/highlight.js/issues/2277"), +i=e,a=n),void 0===t&&(t=!0);const r={code:a,language:i};x("before:highlight",r) +;const s=r.result?r.result:f(r.language,r.code,t) +;return s.code=r.code,x("after:highlight",s),s}function f(e,t,i,r){ +const l=Object.create(null);function c(){if(!x.keywords)return void S.addText(A) +;let e=0;x.keywordPatternRe.lastIndex=0;let n=x.keywordPatternRe.exec(A),t="" +;for(;n;){t+=A.substring(e,n.index) +;const i=w.case_insensitive?n[0].toLowerCase():n[0],r=(a=i,x.keywords[a]);if(r){ +const[e,a]=r +;if(S.addText(t),t="",l[i]=(l[i]||0)+1,l[i]<=7&&(C+=a),e.startsWith("_"))t+=n[0];else{ +const t=w.classNameAliases[e]||e;g(n[0],t)}}else t+=n[0] +;e=x.keywordPatternRe.lastIndex,n=x.keywordPatternRe.exec(A)}var a +;t+=A.substring(e),S.addText(t)}function d(){null!=x.subLanguage?(()=>{ +if(""===A)return;let e=null;if("string"==typeof x.subLanguage){ +if(!a[x.subLanguage])return void S.addText(A) +;e=f(x.subLanguage,A,!0,M[x.subLanguage]),M[x.subLanguage]=e._top +}else e=E(A,x.subLanguage.length?x.subLanguage:null) +;x.relevance>0&&(C+=e.relevance),S.__addSublanguage(e._emitter,e.language) +})():c(),A=""}function g(e,n){ +""!==e&&(S.startScope(n),S.addText(e),S.endScope())}function u(e,n){let t=1 +;const a=n.length-1;for(;t<=a;){if(!e._emit[t]){t++;continue} +const a=w.classNameAliases[e[t]]||e[t],i=n[t];a?g(i,a):(A=i,c(),A=""),t++}} +function b(e,n){ +return e.scope&&"string"==typeof e.scope&&S.openNode(w.classNameAliases[e.scope]||e.scope), +e.beginScope&&(e.beginScope._wrap?(g(A,w.classNameAliases[e.beginScope._wrap]||e.beginScope._wrap), +A=""):e.beginScope._multi&&(u(e.beginScope,n),A="")),x=Object.create(e,{parent:{ +value:x}}),x}function m(e,t,a){let i=((e,n)=>{const t=e&&e.exec(n) +;return t&&0===t.index})(e.endRe,a);if(i){if(e["on:end"]){const a=new n(e) +;e["on:end"](t,a),a.isMatchIgnored&&(i=!1)}if(i){ +for(;e.endsParent&&e.parent;)e=e.parent;return e}} +if(e.endsWithParent)return m(e.parent,t,a)}function _(e){ +return 0===x.matcher.regexIndex?(A+=e[0],1):(D=!0,0)}function h(e){ +const n=e[0],a=t.substring(e.index),i=m(x,e,a);if(!i)return ee;const r=x +;x.endScope&&x.endScope._wrap?(d(), +g(n,x.endScope._wrap)):x.endScope&&x.endScope._multi?(d(), +u(x.endScope,e)):r.skip?A+=n:(r.returnEnd||r.excludeEnd||(A+=n), +d(),r.excludeEnd&&(A=n));do{ +x.scope&&S.closeNode(),x.skip||x.subLanguage||(C+=x.relevance),x=x.parent +}while(x!==i.parent);return i.starts&&b(i.starts,e),r.returnEnd?0:n.length} +let y={};function N(a,r){const o=r&&r[0];if(A+=a,null==o)return d(),0 +;if("begin"===y.type&&"end"===r.type&&y.index===r.index&&""===o){ +if(A+=t.slice(r.index,r.index+1),!s){const n=Error(`0 width match regex (${e})`) +;throw n.languageName=e,n.badRule=y.rule,n}return 1} +if(y=r,"begin"===r.type)return(e=>{ +const t=e[0],a=e.rule,i=new n(a),r=[a.__beforeBegin,a["on:begin"]] +;for(const n of r)if(n&&(n(e,i),i.isMatchIgnored))return _(t) +;return a.skip?A+=t:(a.excludeBegin&&(A+=t), +d(),a.returnBegin||a.excludeBegin||(A=t)),b(a,e),a.returnBegin?0:t.length})(r) +;if("illegal"===r.type&&!i){ +const e=Error('Illegal lexeme "'+o+'" for mode "'+(x.scope||"")+'"') +;throw e.mode=x,e}if("end"===r.type){const e=h(r);if(e!==ee)return e} +if("illegal"===r.type&&""===o)return 1 +;if(R>1e5&&R>3*r.index)throw Error("potential infinite loop, way more iterations than matches") +;return A+=o,o.length}const w=v(e) +;if(!w)throw K(o.replace("{}",e)),Error('Unknown language: "'+e+'"') +;const O=Q(w);let k="",x=r||O;const M={},S=new p.__emitter(p);(()=>{const e=[] +;for(let n=x;n!==w;n=n.parent)n.scope&&e.unshift(n.scope) +;e.forEach((e=>S.openNode(e)))})();let A="",C=0,T=0,R=0,D=!1;try{ +if(w.__emitTokens)w.__emitTokens(t,S);else{for(x.matcher.considerAll();;){ +R++,D?D=!1:x.matcher.considerAll(),x.matcher.lastIndex=T +;const e=x.matcher.exec(t);if(!e)break;const n=N(t.substring(T,e.index),e) +;T=e.index+n}N(t.substring(T))}return S.finalize(),k=S.toHTML(),{language:e, +value:k,relevance:C,illegal:!1,_emitter:S,_top:x}}catch(n){ +if(n.message&&n.message.includes("Illegal"))return{language:e,value:J(t), +illegal:!0,relevance:0,_illegalBy:{message:n.message,index:T, +context:t.slice(T-100,T+100),mode:n.mode,resultSoFar:k},_emitter:S};if(s)return{ +language:e,value:J(t),illegal:!1,relevance:0,errorRaised:n,_emitter:S,_top:x} +;throw n}}function E(e,n){n=n||p.languages||Object.keys(a);const t=(e=>{ +const n={value:J(e),illegal:!1,relevance:0,_top:c,_emitter:new p.__emitter(p)} +;return n._emitter.addText(e),n})(e),i=n.filter(v).filter(k).map((n=>f(n,e,!1))) +;i.unshift(t);const r=i.sort(((e,n)=>{ +if(e.relevance!==n.relevance)return n.relevance-e.relevance +;if(e.language&&n.language){if(v(e.language).supersetOf===n.language)return 1 +;if(v(n.language).supersetOf===e.language)return-1}return 0})),[s,o]=r,l=s +;return l.secondBest=o,l}function y(e){let n=null;const t=(e=>{ +let n=e.className+" ";n+=e.parentNode?e.parentNode.className:"" +;const t=p.languageDetectRe.exec(n);if(t){const n=v(t[1]) +;return n||(H(o.replace("{}",t[1])), +H("Falling back to no-highlight mode for this block.",e)),n?t[1]:"no-highlight"} +return n.split(/\s+/).find((e=>_(e)||v(e)))})(e);if(_(t))return +;if(x("before:highlightElement",{el:e,language:t +}),e.dataset.highlighted)return void console.log("Element previously highlighted. To highlight again, first unset `dataset.highlighted`.",e) +;if(e.children.length>0&&(p.ignoreUnescapedHTML||(console.warn("One of your code blocks includes unescaped HTML. This is a potentially serious security risk."), +console.warn("https://github.com/highlightjs/highlight.js/wiki/security"), +console.warn("The element with unescaped HTML:"), +console.warn(e)),p.throwUnescapedHTML))throw new V("One of your code blocks includes unescaped HTML.",e.innerHTML) +;n=e;const a=n.textContent,r=t?h(a,{language:t,ignoreIllegals:!0}):E(a) +;e.innerHTML=r.value,e.dataset.highlighted="yes",((e,n,t)=>{const a=n&&i[n]||t +;e.classList.add("hljs"),e.classList.add("language-"+a) +})(e,t,r.language),e.result={language:r.language,re:r.relevance, +relevance:r.relevance},r.secondBest&&(e.secondBest={ +language:r.secondBest.language,relevance:r.secondBest.relevance +}),x("after:highlightElement",{el:e,result:r,text:a})}let N=!1;function w(){ +"loading"!==document.readyState?document.querySelectorAll(p.cssSelector).forEach(y):N=!0 +}function v(e){return e=(e||"").toLowerCase(),a[e]||a[i[e]]} +function O(e,{languageName:n}){"string"==typeof e&&(e=[e]),e.forEach((e=>{ +i[e.toLowerCase()]=n}))}function k(e){const n=v(e) +;return n&&!n.disableAutodetect}function x(e,n){const t=e;r.forEach((e=>{ +e[t]&&e[t](n)}))} +"undefined"!=typeof window&&window.addEventListener&&window.addEventListener("DOMContentLoaded",(()=>{ +N&&w()}),!1),Object.assign(t,{highlight:h,highlightAuto:E,highlightAll:w, +highlightElement:y, +highlightBlock:e=>(q("10.7.0","highlightBlock will be removed entirely in v12.0"), +q("10.7.0","Please use highlightElement now."),y(e)),configure:e=>{p=Y(p,e)}, +initHighlighting:()=>{ +w(),q("10.6.0","initHighlighting() deprecated. Use highlightAll() now.")}, +initHighlightingOnLoad:()=>{ +w(),q("10.6.0","initHighlightingOnLoad() deprecated. Use highlightAll() now.") +},registerLanguage:(e,n)=>{let i=null;try{i=n(t)}catch(n){ +if(K("Language definition for '{}' could not be registered.".replace("{}",e)), +!s)throw n;K(n),i=c} +i.name||(i.name=e),a[e]=i,i.rawDefinition=n.bind(null,t),i.aliases&&O(i.aliases,{ +languageName:e})},unregisterLanguage:e=>{delete a[e] +;for(const n of Object.keys(i))i[n]===e&&delete i[n]}, +listLanguages:()=>Object.keys(a),getLanguage:v,registerAliases:O, +autoDetection:k,inherit:Y,addPlugin:e=>{(e=>{ +e["before:highlightBlock"]&&!e["before:highlightElement"]&&(e["before:highlightElement"]=n=>{ +e["before:highlightBlock"](Object.assign({block:n.el},n)) +}),e["after:highlightBlock"]&&!e["after:highlightElement"]&&(e["after:highlightElement"]=n=>{ +e["after:highlightBlock"](Object.assign({block:n.el},n))})})(e),r.push(e)}, +removePlugin:e=>{const n=r.indexOf(e);-1!==n&&r.splice(n,1)}}),t.debugMode=()=>{ +s=!1},t.safeMode=()=>{s=!0},t.versionString="11.9.0",t.regex={concat:b, +lookahead:d,either:m,optional:u,anyNumberOfTimes:g} +;for(const n in C)"object"==typeof C[n]&&e(C[n]);return Object.assign(t,C),t +},te=ne({});te.newInstance=()=>ne({});var ae=te;const ie=e=>({IMPORTANT:{ +scope:"meta",begin:"!important"},BLOCK_COMMENT:e.C_BLOCK_COMMENT_MODE,HEXCOLOR:{ +scope:"number",begin:/#(([0-9a-fA-F]{3,4})|(([0-9a-fA-F]{2}){3,4}))\b/}, +FUNCTION_DISPATCH:{className:"built_in",begin:/[\w-]+(?=\()/}, +ATTRIBUTE_SELECTOR_MODE:{scope:"selector-attr",begin:/\[/,end:/\]/,illegal:"$", +contains:[e.APOS_STRING_MODE,e.QUOTE_STRING_MODE]},CSS_NUMBER_MODE:{ +scope:"number", +begin:e.NUMBER_RE+"(%|em|ex|ch|rem|vw|vh|vmin|vmax|cm|mm|in|pt|pc|px|deg|grad|rad|turn|s|ms|Hz|kHz|dpi|dpcm|dppx)?", +relevance:0},CSS_VARIABLE:{className:"attr",begin:/--[A-Za-z_][A-Za-z0-9_-]*/} +}),re=["a","abbr","address","article","aside","audio","b","blockquote","body","button","canvas","caption","cite","code","dd","del","details","dfn","div","dl","dt","em","fieldset","figcaption","figure","footer","form","h1","h2","h3","h4","h5","h6","header","hgroup","html","i","iframe","img","input","ins","kbd","label","legend","li","main","mark","menu","nav","object","ol","p","q","quote","samp","section","span","strong","summary","sup","table","tbody","td","textarea","tfoot","th","thead","time","tr","ul","var","video"],se=["any-hover","any-pointer","aspect-ratio","color","color-gamut","color-index","device-aspect-ratio","device-height","device-width","display-mode","forced-colors","grid","height","hover","inverted-colors","monochrome","orientation","overflow-block","overflow-inline","pointer","prefers-color-scheme","prefers-contrast","prefers-reduced-motion","prefers-reduced-transparency","resolution","scan","scripting","update","width","min-width","max-width","min-height","max-height"],oe=["active","any-link","blank","checked","current","default","defined","dir","disabled","drop","empty","enabled","first","first-child","first-of-type","fullscreen","future","focus","focus-visible","focus-within","has","host","host-context","hover","indeterminate","in-range","invalid","is","lang","last-child","last-of-type","left","link","local-link","not","nth-child","nth-col","nth-last-child","nth-last-col","nth-last-of-type","nth-of-type","only-child","only-of-type","optional","out-of-range","past","placeholder-shown","read-only","read-write","required","right","root","scope","target","target-within","user-invalid","valid","visited","where"],le=["after","backdrop","before","cue","cue-region","first-letter","first-line","grammar-error","marker","part","placeholder","selection","slotted","spelling-error"],ce=["align-content","align-items","align-self","all","animation","animation-delay","animation-direction","animation-duration","animation-fill-mode","animation-iteration-count","animation-name","animation-play-state","animation-timing-function","backface-visibility","background","background-attachment","background-blend-mode","background-clip","background-color","background-image","background-origin","background-position","background-repeat","background-size","block-size","border","border-block","border-block-color","border-block-end","border-block-end-color","border-block-end-style","border-block-end-width","border-block-start","border-block-start-color","border-block-start-style","border-block-start-width","border-block-style","border-block-width","border-bottom","border-bottom-color","border-bottom-left-radius","border-bottom-right-radius","border-bottom-style","border-bottom-width","border-collapse","border-color","border-image","border-image-outset","border-image-repeat","border-image-slice","border-image-source","border-image-width","border-inline","border-inline-color","border-inline-end","border-inline-end-color","border-inline-end-style","border-inline-end-width","border-inline-start","border-inline-start-color","border-inline-start-style","border-inline-start-width","border-inline-style","border-inline-width","border-left","border-left-color","border-left-style","border-left-width","border-radius","border-right","border-right-color","border-right-style","border-right-width","border-spacing","border-style","border-top","border-top-color","border-top-left-radius","border-top-right-radius","border-top-style","border-top-width","border-width","bottom","box-decoration-break","box-shadow","box-sizing","break-after","break-before","break-inside","caption-side","caret-color","clear","clip","clip-path","clip-rule","color","column-count","column-fill","column-gap","column-rule","column-rule-color","column-rule-style","column-rule-width","column-span","column-width","columns","contain","content","content-visibility","counter-increment","counter-reset","cue","cue-after","cue-before","cursor","direction","display","empty-cells","filter","flex","flex-basis","flex-direction","flex-flow","flex-grow","flex-shrink","flex-wrap","float","flow","font","font-display","font-family","font-feature-settings","font-kerning","font-language-override","font-size","font-size-adjust","font-smoothing","font-stretch","font-style","font-synthesis","font-variant","font-variant-caps","font-variant-east-asian","font-variant-ligatures","font-variant-numeric","font-variant-position","font-variation-settings","font-weight","gap","glyph-orientation-vertical","grid","grid-area","grid-auto-columns","grid-auto-flow","grid-auto-rows","grid-column","grid-column-end","grid-column-start","grid-gap","grid-row","grid-row-end","grid-row-start","grid-template","grid-template-areas","grid-template-columns","grid-template-rows","hanging-punctuation","height","hyphens","icon","image-orientation","image-rendering","image-resolution","ime-mode","inline-size","isolation","justify-content","left","letter-spacing","line-break","line-height","list-style","list-style-image","list-style-position","list-style-type","margin","margin-block","margin-block-end","margin-block-start","margin-bottom","margin-inline","margin-inline-end","margin-inline-start","margin-left","margin-right","margin-top","marks","mask","mask-border","mask-border-mode","mask-border-outset","mask-border-repeat","mask-border-slice","mask-border-source","mask-border-width","mask-clip","mask-composite","mask-image","mask-mode","mask-origin","mask-position","mask-repeat","mask-size","mask-type","max-block-size","max-height","max-inline-size","max-width","min-block-size","min-height","min-inline-size","min-width","mix-blend-mode","nav-down","nav-index","nav-left","nav-right","nav-up","none","normal","object-fit","object-position","opacity","order","orphans","outline","outline-color","outline-offset","outline-style","outline-width","overflow","overflow-wrap","overflow-x","overflow-y","padding","padding-block","padding-block-end","padding-block-start","padding-bottom","padding-inline","padding-inline-end","padding-inline-start","padding-left","padding-right","padding-top","page-break-after","page-break-before","page-break-inside","pause","pause-after","pause-before","perspective","perspective-origin","pointer-events","position","quotes","resize","rest","rest-after","rest-before","right","row-gap","scroll-margin","scroll-margin-block","scroll-margin-block-end","scroll-margin-block-start","scroll-margin-bottom","scroll-margin-inline","scroll-margin-inline-end","scroll-margin-inline-start","scroll-margin-left","scroll-margin-right","scroll-margin-top","scroll-padding","scroll-padding-block","scroll-padding-block-end","scroll-padding-block-start","scroll-padding-bottom","scroll-padding-inline","scroll-padding-inline-end","scroll-padding-inline-start","scroll-padding-left","scroll-padding-right","scroll-padding-top","scroll-snap-align","scroll-snap-stop","scroll-snap-type","scrollbar-color","scrollbar-gutter","scrollbar-width","shape-image-threshold","shape-margin","shape-outside","speak","speak-as","src","tab-size","table-layout","text-align","text-align-all","text-align-last","text-combine-upright","text-decoration","text-decoration-color","text-decoration-line","text-decoration-style","text-emphasis","text-emphasis-color","text-emphasis-position","text-emphasis-style","text-indent","text-justify","text-orientation","text-overflow","text-rendering","text-shadow","text-transform","text-underline-position","top","transform","transform-box","transform-origin","transform-style","transition","transition-delay","transition-duration","transition-property","transition-timing-function","unicode-bidi","vertical-align","visibility","voice-balance","voice-duration","voice-family","voice-pitch","voice-range","voice-rate","voice-stress","voice-volume","white-space","widows","width","will-change","word-break","word-spacing","word-wrap","writing-mode","z-index"].reverse(),de=oe.concat(le) +;var ge="[0-9](_*[0-9])*",ue=`\\.(${ge})`,be="[0-9a-fA-F](_*[0-9a-fA-F])*",me={ +className:"number",variants:[{ +begin:`(\\b(${ge})((${ue})|\\.)?|(${ue}))[eE][+-]?(${ge})[fFdD]?\\b`},{ +begin:`\\b(${ge})((${ue})[fFdD]?\\b|\\.([fFdD]\\b)?)`},{ +begin:`(${ue})[fFdD]?\\b`},{begin:`\\b(${ge})[fFdD]\\b`},{ +begin:`\\b0[xX]((${be})\\.?|(${be})?\\.(${be}))[pP][+-]?(${ge})[fFdD]?\\b`},{ +begin:"\\b(0|[1-9](_*[0-9])*)[lL]?\\b"},{begin:`\\b0[xX](${be})[lL]?\\b`},{ +begin:"\\b0(_*[0-7])*[lL]?\\b"},{begin:"\\b0[bB][01](_*[01])*[lL]?\\b"}], +relevance:0};function pe(e,n,t){return-1===t?"":e.replace(n,(a=>pe(e,n,t-1)))} +const _e="[A-Za-z$_][0-9A-Za-z$_]*",he=["as","in","of","if","for","while","finally","var","new","function","do","return","void","else","break","catch","instanceof","with","throw","case","default","try","switch","continue","typeof","delete","let","yield","const","class","debugger","async","await","static","import","from","export","extends"],fe=["true","false","null","undefined","NaN","Infinity"],Ee=["Object","Function","Boolean","Symbol","Math","Date","Number","BigInt","String","RegExp","Array","Float32Array","Float64Array","Int8Array","Uint8Array","Uint8ClampedArray","Int16Array","Int32Array","Uint16Array","Uint32Array","BigInt64Array","BigUint64Array","Set","Map","WeakSet","WeakMap","ArrayBuffer","SharedArrayBuffer","Atomics","DataView","JSON","Promise","Generator","GeneratorFunction","AsyncFunction","Reflect","Proxy","Intl","WebAssembly"],ye=["Error","EvalError","InternalError","RangeError","ReferenceError","SyntaxError","TypeError","URIError"],Ne=["setInterval","setTimeout","clearInterval","clearTimeout","require","exports","eval","isFinite","isNaN","parseFloat","parseInt","decodeURI","decodeURIComponent","encodeURI","encodeURIComponent","escape","unescape"],we=["arguments","this","super","console","window","document","localStorage","sessionStorage","module","global"],ve=[].concat(Ne,Ee,ye) +;function Oe(e){const n=e.regex,t=_e,a={begin:/<[A-Za-z0-9\\._:-]+/, +end:/\/[A-Za-z0-9\\._:-]+>|\/>/,isTrulyOpeningTag:(e,n)=>{ +const t=e[0].length+e.index,a=e.input[t] +;if("<"===a||","===a)return void n.ignoreMatch();let i +;">"===a&&(((e,{after:n})=>{const t="",M={ +match:[/const|var|let/,/\s+/,t,/\s*/,/=\s*/,/(async\s*)?/,n.lookahead(x)], +keywords:"async",className:{1:"keyword",3:"title.function"},contains:[f]} +;return{name:"JavaScript",aliases:["js","jsx","mjs","cjs"],keywords:i,exports:{ +PARAMS_CONTAINS:h,CLASS_REFERENCE:y},illegal:/#(?![$_A-z])/, +contains:[e.SHEBANG({label:"shebang",binary:"node",relevance:5}),{ +label:"use_strict",className:"meta",relevance:10, +begin:/^\s*['"]use (strict|asm)['"]/ +},e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,d,g,u,b,m,{match:/\$\d+/},l,y,{ +className:"attr",begin:t+n.lookahead(":"),relevance:0},M,{ +begin:"("+e.RE_STARTERS_RE+"|\\b(case|return|throw)\\b)\\s*", +keywords:"return throw case",relevance:0,contains:[m,e.REGEXP_MODE,{ +className:"function",begin:x,returnBegin:!0,end:"\\s*=>",contains:[{ +className:"params",variants:[{begin:e.UNDERSCORE_IDENT_RE,relevance:0},{ +className:null,begin:/\(\s*\)/,skip:!0},{begin:/\(/,end:/\)/,excludeBegin:!0, +excludeEnd:!0,keywords:i,contains:h}]}]},{begin:/,/,relevance:0},{match:/\s+/, +relevance:0},{variants:[{begin:"<>",end:""},{ +match:/<[A-Za-z0-9\\._:-]+\s*\/>/},{begin:a.begin, +"on:begin":a.isTrulyOpeningTag,end:a.end}],subLanguage:"xml",contains:[{ +begin:a.begin,end:a.end,skip:!0,contains:["self"]}]}]},N,{ +beginKeywords:"while if switch catch for"},{ +begin:"\\b(?!function)"+e.UNDERSCORE_IDENT_RE+"\\([^()]*(\\([^()]*(\\([^()]*\\)[^()]*)*\\)[^()]*)*\\)\\s*\\{", +returnBegin:!0,label:"func.def",contains:[f,e.inherit(e.TITLE_MODE,{begin:t, +className:"title.function"})]},{match:/\.\.\./,relevance:0},O,{match:"\\$"+t, +relevance:0},{match:[/\bconstructor(?=\s*\()/],className:{1:"title.function"}, +contains:[f]},w,{relevance:0,match:/\b[A-Z][A-Z_0-9]+\b/, +className:"variable.constant"},E,k,{match:/\$[(.]/}]}} +const ke=e=>b(/\b/,e,/\w$/.test(e)?/\b/:/\B/),xe=["Protocol","Type"].map(ke),Me=["init","self"].map(ke),Se=["Any","Self"],Ae=["actor","any","associatedtype","async","await",/as\?/,/as!/,"as","borrowing","break","case","catch","class","consume","consuming","continue","convenience","copy","default","defer","deinit","didSet","distributed","do","dynamic","each","else","enum","extension","fallthrough",/fileprivate\(set\)/,"fileprivate","final","for","func","get","guard","if","import","indirect","infix",/init\?/,/init!/,"inout",/internal\(set\)/,"internal","in","is","isolated","nonisolated","lazy","let","macro","mutating","nonmutating",/open\(set\)/,"open","operator","optional","override","postfix","precedencegroup","prefix",/private\(set\)/,"private","protocol",/public\(set\)/,"public","repeat","required","rethrows","return","set","some","static","struct","subscript","super","switch","throws","throw",/try\?/,/try!/,"try","typealias",/unowned\(safe\)/,/unowned\(unsafe\)/,"unowned","var","weak","where","while","willSet"],Ce=["false","nil","true"],Te=["assignment","associativity","higherThan","left","lowerThan","none","right"],Re=["#colorLiteral","#column","#dsohandle","#else","#elseif","#endif","#error","#file","#fileID","#fileLiteral","#filePath","#function","#if","#imageLiteral","#keyPath","#line","#selector","#sourceLocation","#warning"],De=["abs","all","any","assert","assertionFailure","debugPrint","dump","fatalError","getVaList","isKnownUniquelyReferenced","max","min","numericCast","pointwiseMax","pointwiseMin","precondition","preconditionFailure","print","readLine","repeatElement","sequence","stride","swap","swift_unboxFromSwiftValueWithType","transcode","type","unsafeBitCast","unsafeDowncast","withExtendedLifetime","withUnsafeMutablePointer","withUnsafePointer","withVaList","withoutActuallyEscaping","zip"],Ie=m(/[/=\-+!*%<>&|^~?]/,/[\u00A1-\u00A7]/,/[\u00A9\u00AB]/,/[\u00AC\u00AE]/,/[\u00B0\u00B1]/,/[\u00B6\u00BB\u00BF\u00D7\u00F7]/,/[\u2016-\u2017]/,/[\u2020-\u2027]/,/[\u2030-\u203E]/,/[\u2041-\u2053]/,/[\u2055-\u205E]/,/[\u2190-\u23FF]/,/[\u2500-\u2775]/,/[\u2794-\u2BFF]/,/[\u2E00-\u2E7F]/,/[\u3001-\u3003]/,/[\u3008-\u3020]/,/[\u3030]/),Le=m(Ie,/[\u0300-\u036F]/,/[\u1DC0-\u1DFF]/,/[\u20D0-\u20FF]/,/[\uFE00-\uFE0F]/,/[\uFE20-\uFE2F]/),Be=b(Ie,Le,"*"),$e=m(/[a-zA-Z_]/,/[\u00A8\u00AA\u00AD\u00AF\u00B2-\u00B5\u00B7-\u00BA]/,/[\u00BC-\u00BE\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u00FF]/,/[\u0100-\u02FF\u0370-\u167F\u1681-\u180D\u180F-\u1DBF]/,/[\u1E00-\u1FFF]/,/[\u200B-\u200D\u202A-\u202E\u203F-\u2040\u2054\u2060-\u206F]/,/[\u2070-\u20CF\u2100-\u218F\u2460-\u24FF\u2776-\u2793]/,/[\u2C00-\u2DFF\u2E80-\u2FFF]/,/[\u3004-\u3007\u3021-\u302F\u3031-\u303F\u3040-\uD7FF]/,/[\uF900-\uFD3D\uFD40-\uFDCF\uFDF0-\uFE1F\uFE30-\uFE44]/,/[\uFE47-\uFEFE\uFF00-\uFFFD]/),ze=m($e,/\d/,/[\u0300-\u036F\u1DC0-\u1DFF\u20D0-\u20FF\uFE20-\uFE2F]/),Fe=b($e,ze,"*"),Ue=b(/[A-Z]/,ze,"*"),je=["attached","autoclosure",b(/convention\(/,m("swift","block","c"),/\)/),"discardableResult","dynamicCallable","dynamicMemberLookup","escaping","freestanding","frozen","GKInspectable","IBAction","IBDesignable","IBInspectable","IBOutlet","IBSegueAction","inlinable","main","nonobjc","NSApplicationMain","NSCopying","NSManaged",b(/objc\(/,Fe,/\)/),"objc","objcMembers","propertyWrapper","requires_stored_property_inits","resultBuilder","Sendable","testable","UIApplicationMain","unchecked","unknown","usableFromInline","warn_unqualified_access"],Pe=["iOS","iOSApplicationExtension","macOS","macOSApplicationExtension","macCatalyst","macCatalystApplicationExtension","watchOS","watchOSApplicationExtension","tvOS","tvOSApplicationExtension","swift"] +;var Ke=Object.freeze({__proto__:null,grmr_bash:e=>{const n=e.regex,t={},a={ +begin:/\$\{/,end:/\}/,contains:["self",{begin:/:-/,contains:[t]}]} +;Object.assign(t,{className:"variable",variants:[{ +begin:n.concat(/\$[\w\d#@][\w\d_]*/,"(?![\\w\\d])(?![$])")},a]});const i={ +className:"subst",begin:/\$\(/,end:/\)/,contains:[e.BACKSLASH_ESCAPE]},r={ +begin:/<<-?\s*(?=\w+)/,starts:{contains:[e.END_SAME_AS_BEGIN({begin:/(\w+)/, +end:/(\w+)/,className:"string"})]}},s={className:"string",begin:/"/,end:/"/, +contains:[e.BACKSLASH_ESCAPE,t,i]};i.contains.push(s);const o={begin:/\$?\(\(/, +end:/\)\)/,contains:[{begin:/\d+#[0-9a-f]+/,className:"number"},e.NUMBER_MODE,t] +},l=e.SHEBANG({binary:"(fish|bash|zsh|sh|csh|ksh|tcsh|dash|scsh)",relevance:10 +}),c={className:"function",begin:/\w[\w\d_]*\s*\(\s*\)\s*\{/,returnBegin:!0, +contains:[e.inherit(e.TITLE_MODE,{begin:/\w[\w\d_]*/})],relevance:0};return{ +name:"Bash",aliases:["sh"],keywords:{$pattern:/\b[a-z][a-z0-9._-]+\b/, +keyword:["if","then","else","elif","fi","for","while","until","in","do","done","case","esac","function","select"], +literal:["true","false"], +built_in:["break","cd","continue","eval","exec","exit","export","getopts","hash","pwd","readonly","return","shift","test","times","trap","umask","unset","alias","bind","builtin","caller","command","declare","echo","enable","help","let","local","logout","mapfile","printf","read","readarray","source","type","typeset","ulimit","unalias","set","shopt","autoload","bg","bindkey","bye","cap","chdir","clone","comparguments","compcall","compctl","compdescribe","compfiles","compgroups","compquote","comptags","comptry","compvalues","dirs","disable","disown","echotc","echoti","emulate","fc","fg","float","functions","getcap","getln","history","integer","jobs","kill","limit","log","noglob","popd","print","pushd","pushln","rehash","sched","setcap","setopt","stat","suspend","ttyctl","unfunction","unhash","unlimit","unsetopt","vared","wait","whence","where","which","zcompile","zformat","zftp","zle","zmodload","zparseopts","zprof","zpty","zregexparse","zsocket","zstyle","ztcp","chcon","chgrp","chown","chmod","cp","dd","df","dir","dircolors","ln","ls","mkdir","mkfifo","mknod","mktemp","mv","realpath","rm","rmdir","shred","sync","touch","truncate","vdir","b2sum","base32","base64","cat","cksum","comm","csplit","cut","expand","fmt","fold","head","join","md5sum","nl","numfmt","od","paste","ptx","pr","sha1sum","sha224sum","sha256sum","sha384sum","sha512sum","shuf","sort","split","sum","tac","tail","tr","tsort","unexpand","uniq","wc","arch","basename","chroot","date","dirname","du","echo","env","expr","factor","groups","hostid","id","link","logname","nice","nohup","nproc","pathchk","pinky","printenv","printf","pwd","readlink","runcon","seq","sleep","stat","stdbuf","stty","tee","test","timeout","tty","uname","unlink","uptime","users","who","whoami","yes"] +},contains:[l,e.SHEBANG(),c,o,e.HASH_COMMENT_MODE,r,{match:/(\/[a-z._-]+)+/},s,{ +match:/\\"/},{className:"string",begin:/'/,end:/'/},{match:/\\'/},t]}}, +grmr_c:e=>{const n=e.regex,t=e.COMMENT("//","$",{contains:[{begin:/\\\n/}] +}),a="decltype\\(auto\\)",i="[a-zA-Z_]\\w*::",r="("+a+"|"+n.optional(i)+"[a-zA-Z_]\\w*"+n.optional("<[^<>]+>")+")",s={ +className:"type",variants:[{begin:"\\b[a-z\\d_]*_t\\b"},{ +match:/\batomic_[a-z]{3,6}\b/}]},o={className:"string",variants:[{ +begin:'(u8?|U|L)?"',end:'"',illegal:"\\n",contains:[e.BACKSLASH_ESCAPE]},{ +begin:"(u8?|U|L)?'(\\\\(x[0-9A-Fa-f]{2}|u[0-9A-Fa-f]{4,8}|[0-7]{3}|\\S)|.)", +end:"'",illegal:"."},e.END_SAME_AS_BEGIN({ +begin:/(?:u8?|U|L)?R"([^()\\ ]{0,16})\(/,end:/\)([^()\\ ]{0,16})"/})]},l={ +className:"number",variants:[{begin:"\\b(0b[01']+)"},{ +begin:"(-?)\\b([\\d']+(\\.[\\d']*)?|\\.[\\d']+)((ll|LL|l|L)(u|U)?|(u|U)(ll|LL|l|L)?|f|F|b|B)" +},{ +begin:"(-?)(\\b0[xX][a-fA-F0-9']+|(\\b[\\d']+(\\.[\\d']*)?|\\.[\\d']+)([eE][-+]?[\\d']+)?)" +}],relevance:0},c={className:"meta",begin:/#\s*[a-z]+\b/,end:/$/,keywords:{ +keyword:"if else elif endif define undef warning error line pragma _Pragma ifdef ifndef include" +},contains:[{begin:/\\\n/,relevance:0},e.inherit(o,{className:"string"}),{ +className:"string",begin:/<.*?>/},t,e.C_BLOCK_COMMENT_MODE]},d={ +className:"title",begin:n.optional(i)+e.IDENT_RE,relevance:0 +},g=n.optional(i)+e.IDENT_RE+"\\s*\\(",u={ +keyword:["asm","auto","break","case","continue","default","do","else","enum","extern","for","fortran","goto","if","inline","register","restrict","return","sizeof","struct","switch","typedef","union","volatile","while","_Alignas","_Alignof","_Atomic","_Generic","_Noreturn","_Static_assert","_Thread_local","alignas","alignof","noreturn","static_assert","thread_local","_Pragma"], +type:["float","double","signed","unsigned","int","short","long","char","void","_Bool","_Complex","_Imaginary","_Decimal32","_Decimal64","_Decimal128","const","static","complex","bool","imaginary"], +literal:"true false NULL", +built_in:"std string wstring cin cout cerr clog stdin stdout stderr stringstream istringstream ostringstream auto_ptr deque list queue stack vector map set pair bitset multiset multimap unordered_set unordered_map unordered_multiset unordered_multimap priority_queue make_pair array shared_ptr abort terminate abs acos asin atan2 atan calloc ceil cosh cos exit exp fabs floor fmod fprintf fputs free frexp fscanf future isalnum isalpha iscntrl isdigit isgraph islower isprint ispunct isspace isupper isxdigit tolower toupper labs ldexp log10 log malloc realloc memchr memcmp memcpy memset modf pow printf putchar puts scanf sinh sin snprintf sprintf sqrt sscanf strcat strchr strcmp strcpy strcspn strlen strncat strncmp strncpy strpbrk strrchr strspn strstr tanh tan vfprintf vprintf vsprintf endl initializer_list unique_ptr" +},b=[c,s,t,e.C_BLOCK_COMMENT_MODE,l,o],m={variants:[{begin:/=/,end:/;/},{ +begin:/\(/,end:/\)/},{beginKeywords:"new throw return else",end:/;/}], +keywords:u,contains:b.concat([{begin:/\(/,end:/\)/,keywords:u, +contains:b.concat(["self"]),relevance:0}]),relevance:0},p={ +begin:"("+r+"[\\*&\\s]+)+"+g,returnBegin:!0,end:/[{;=]/,excludeEnd:!0, +keywords:u,illegal:/[^\w\s\*&:<>.]/,contains:[{begin:a,keywords:u,relevance:0},{ +begin:g,returnBegin:!0,contains:[e.inherit(d,{className:"title.function"})], +relevance:0},{relevance:0,match:/,/},{className:"params",begin:/\(/,end:/\)/, +keywords:u,relevance:0,contains:[t,e.C_BLOCK_COMMENT_MODE,o,l,s,{begin:/\(/, +end:/\)/,keywords:u,relevance:0,contains:["self",t,e.C_BLOCK_COMMENT_MODE,o,l,s] +}]},s,t,e.C_BLOCK_COMMENT_MODE,c]};return{name:"C",aliases:["h"],keywords:u, +disableAutodetect:!0,illegal:"=]/,contains:[{ +beginKeywords:"final class struct"},e.TITLE_MODE]}]),exports:{preprocessor:c, +strings:o,keywords:u}}},grmr_cpp:e=>{const n=e.regex,t=e.COMMENT("//","$",{ +contains:[{begin:/\\\n/}] +}),a="decltype\\(auto\\)",i="[a-zA-Z_]\\w*::",r="(?!struct)("+a+"|"+n.optional(i)+"[a-zA-Z_]\\w*"+n.optional("<[^<>]+>")+")",s={ +className:"type",begin:"\\b[a-z\\d_]*_t\\b"},o={className:"string",variants:[{ +begin:'(u8?|U|L)?"',end:'"',illegal:"\\n",contains:[e.BACKSLASH_ESCAPE]},{ +begin:"(u8?|U|L)?'(\\\\(x[0-9A-Fa-f]{2}|u[0-9A-Fa-f]{4,8}|[0-7]{3}|\\S)|.)", +end:"'",illegal:"."},e.END_SAME_AS_BEGIN({ +begin:/(?:u8?|U|L)?R"([^()\\ ]{0,16})\(/,end:/\)([^()\\ ]{0,16})"/})]},l={ +className:"number",variants:[{begin:"\\b(0b[01']+)"},{ +begin:"(-?)\\b([\\d']+(\\.[\\d']*)?|\\.[\\d']+)((ll|LL|l|L)(u|U)?|(u|U)(ll|LL|l|L)?|f|F|b|B)" +},{ +begin:"(-?)(\\b0[xX][a-fA-F0-9']+|(\\b[\\d']+(\\.[\\d']*)?|\\.[\\d']+)([eE][-+]?[\\d']+)?)" +}],relevance:0},c={className:"meta",begin:/#\s*[a-z]+\b/,end:/$/,keywords:{ +keyword:"if else elif endif define undef warning error line pragma _Pragma ifdef ifndef include" +},contains:[{begin:/\\\n/,relevance:0},e.inherit(o,{className:"string"}),{ +className:"string",begin:/<.*?>/},t,e.C_BLOCK_COMMENT_MODE]},d={ +className:"title",begin:n.optional(i)+e.IDENT_RE,relevance:0 +},g=n.optional(i)+e.IDENT_RE+"\\s*\\(",u={ +type:["bool","char","char16_t","char32_t","char8_t","double","float","int","long","short","void","wchar_t","unsigned","signed","const","static"], +keyword:["alignas","alignof","and","and_eq","asm","atomic_cancel","atomic_commit","atomic_noexcept","auto","bitand","bitor","break","case","catch","class","co_await","co_return","co_yield","compl","concept","const_cast|10","consteval","constexpr","constinit","continue","decltype","default","delete","do","dynamic_cast|10","else","enum","explicit","export","extern","false","final","for","friend","goto","if","import","inline","module","mutable","namespace","new","noexcept","not","not_eq","nullptr","operator","or","or_eq","override","private","protected","public","reflexpr","register","reinterpret_cast|10","requires","return","sizeof","static_assert","static_cast|10","struct","switch","synchronized","template","this","thread_local","throw","transaction_safe","transaction_safe_dynamic","true","try","typedef","typeid","typename","union","using","virtual","volatile","while","xor","xor_eq"], +literal:["NULL","false","nullopt","nullptr","true"],built_in:["_Pragma"], +_type_hints:["any","auto_ptr","barrier","binary_semaphore","bitset","complex","condition_variable","condition_variable_any","counting_semaphore","deque","false_type","future","imaginary","initializer_list","istringstream","jthread","latch","lock_guard","multimap","multiset","mutex","optional","ostringstream","packaged_task","pair","promise","priority_queue","queue","recursive_mutex","recursive_timed_mutex","scoped_lock","set","shared_future","shared_lock","shared_mutex","shared_timed_mutex","shared_ptr","stack","string_view","stringstream","timed_mutex","thread","true_type","tuple","unique_lock","unique_ptr","unordered_map","unordered_multimap","unordered_multiset","unordered_set","variant","vector","weak_ptr","wstring","wstring_view"] +},b={className:"function.dispatch",relevance:0,keywords:{ +_hint:["abort","abs","acos","apply","as_const","asin","atan","atan2","calloc","ceil","cerr","cin","clog","cos","cosh","cout","declval","endl","exchange","exit","exp","fabs","floor","fmod","forward","fprintf","fputs","free","frexp","fscanf","future","invoke","isalnum","isalpha","iscntrl","isdigit","isgraph","islower","isprint","ispunct","isspace","isupper","isxdigit","labs","launder","ldexp","log","log10","make_pair","make_shared","make_shared_for_overwrite","make_tuple","make_unique","malloc","memchr","memcmp","memcpy","memset","modf","move","pow","printf","putchar","puts","realloc","scanf","sin","sinh","snprintf","sprintf","sqrt","sscanf","std","stderr","stdin","stdout","strcat","strchr","strcmp","strcpy","strcspn","strlen","strncat","strncmp","strncpy","strpbrk","strrchr","strspn","strstr","swap","tan","tanh","terminate","to_underlying","tolower","toupper","vfprintf","visit","vprintf","vsprintf"] +}, +begin:n.concat(/\b/,/(?!decltype)/,/(?!if)/,/(?!for)/,/(?!switch)/,/(?!while)/,e.IDENT_RE,n.lookahead(/(<[^<>]+>|)\s*\(/)) +},m=[b,c,s,t,e.C_BLOCK_COMMENT_MODE,l,o],p={variants:[{begin:/=/,end:/;/},{ +begin:/\(/,end:/\)/},{beginKeywords:"new throw return else",end:/;/}], +keywords:u,contains:m.concat([{begin:/\(/,end:/\)/,keywords:u, +contains:m.concat(["self"]),relevance:0}]),relevance:0},_={className:"function", +begin:"("+r+"[\\*&\\s]+)+"+g,returnBegin:!0,end:/[{;=]/,excludeEnd:!0, +keywords:u,illegal:/[^\w\s\*&:<>.]/,contains:[{begin:a,keywords:u,relevance:0},{ +begin:g,returnBegin:!0,contains:[d],relevance:0},{begin:/::/,relevance:0},{ +begin:/:/,endsWithParent:!0,contains:[o,l]},{relevance:0,match:/,/},{ +className:"params",begin:/\(/,end:/\)/,keywords:u,relevance:0, +contains:[t,e.C_BLOCK_COMMENT_MODE,o,l,s,{begin:/\(/,end:/\)/,keywords:u, +relevance:0,contains:["self",t,e.C_BLOCK_COMMENT_MODE,o,l,s]}] +},s,t,e.C_BLOCK_COMMENT_MODE,c]};return{name:"C++", +aliases:["cc","c++","h++","hpp","hh","hxx","cxx"],keywords:u,illegal:"",keywords:u,contains:["self",s]},{begin:e.IDENT_RE+"::",keywords:u},{ +match:[/\b(?:enum(?:\s+(?:class|struct))?|class|struct|union)/,/\s+/,/\w+/], +className:{1:"keyword",3:"title.class"}}])}},grmr_csharp:e=>{const n={ +keyword:["abstract","as","base","break","case","catch","class","const","continue","do","else","event","explicit","extern","finally","fixed","for","foreach","goto","if","implicit","in","interface","internal","is","lock","namespace","new","operator","out","override","params","private","protected","public","readonly","record","ref","return","scoped","sealed","sizeof","stackalloc","static","struct","switch","this","throw","try","typeof","unchecked","unsafe","using","virtual","void","volatile","while"].concat(["add","alias","and","ascending","async","await","by","descending","equals","from","get","global","group","init","into","join","let","nameof","not","notnull","on","or","orderby","partial","remove","select","set","unmanaged","value|0","var","when","where","with","yield"]), +built_in:["bool","byte","char","decimal","delegate","double","dynamic","enum","float","int","long","nint","nuint","object","sbyte","short","string","ulong","uint","ushort"], +literal:["default","false","null","true"]},t=e.inherit(e.TITLE_MODE,{ +begin:"[a-zA-Z](\\.?\\w)*"}),a={className:"number",variants:[{ +begin:"\\b(0b[01']+)"},{ +begin:"(-?)\\b([\\d']+(\\.[\\d']*)?|\\.[\\d']+)(u|U|l|L|ul|UL|f|F|b|B)"},{ +begin:"(-?)(\\b0[xX][a-fA-F0-9']+|(\\b[\\d']+(\\.[\\d']*)?|\\.[\\d']+)([eE][-+]?[\\d']+)?)" +}],relevance:0},i={className:"string",begin:'@"',end:'"',contains:[{begin:'""'}] +},r=e.inherit(i,{illegal:/\n/}),s={className:"subst",begin:/\{/,end:/\}/, +keywords:n},o=e.inherit(s,{illegal:/\n/}),l={className:"string",begin:/\$"/, +end:'"',illegal:/\n/,contains:[{begin:/\{\{/},{begin:/\}\}/ +},e.BACKSLASH_ESCAPE,o]},c={className:"string",begin:/\$@"/,end:'"',contains:[{ +begin:/\{\{/},{begin:/\}\}/},{begin:'""'},s]},d=e.inherit(c,{illegal:/\n/, +contains:[{begin:/\{\{/},{begin:/\}\}/},{begin:'""'},o]}) +;s.contains=[c,l,i,e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,a,e.C_BLOCK_COMMENT_MODE], +o.contains=[d,l,r,e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,a,e.inherit(e.C_BLOCK_COMMENT_MODE,{ +illegal:/\n/})];const g={variants:[c,l,i,e.APOS_STRING_MODE,e.QUOTE_STRING_MODE] +},u={begin:"<",end:">",contains:[{beginKeywords:"in out"},t] +},b=e.IDENT_RE+"(<"+e.IDENT_RE+"(\\s*,\\s*"+e.IDENT_RE+")*>)?(\\[\\])?",m={ +begin:"@"+e.IDENT_RE,relevance:0};return{name:"C#",aliases:["cs","c#"], +keywords:n,illegal:/::/,contains:[e.COMMENT("///","$",{returnBegin:!0, +contains:[{className:"doctag",variants:[{begin:"///",relevance:0},{ +begin:"\x3c!--|--\x3e"},{begin:""}]}] +}),e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,{className:"meta",begin:"#", +end:"$",keywords:{ +keyword:"if else elif endif define undef warning error line region endregion pragma checksum" +}},g,a,{beginKeywords:"class interface",relevance:0,end:/[{;=]/, +illegal:/[^\s:,]/,contains:[{beginKeywords:"where class" +},t,u,e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE]},{beginKeywords:"namespace", +relevance:0,end:/[{;=]/,illegal:/[^\s:]/, +contains:[t,e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE]},{ +beginKeywords:"record",relevance:0,end:/[{;=]/,illegal:/[^\s:]/, +contains:[t,u,e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE]},{className:"meta", +begin:"^\\s*\\[(?=[\\w])",excludeBegin:!0,end:"\\]",excludeEnd:!0,contains:[{ +className:"string",begin:/"/,end:/"/}]},{ +beginKeywords:"new return throw await else",relevance:0},{className:"function", +begin:"("+b+"\\s+)+"+e.IDENT_RE+"\\s*(<[^=]+>\\s*)?\\(",returnBegin:!0, +end:/\s*[{;=]/,excludeEnd:!0,keywords:n,contains:[{ +beginKeywords:"public private protected static internal protected abstract async extern override unsafe virtual new sealed partial", +relevance:0},{begin:e.IDENT_RE+"\\s*(<[^=]+>\\s*)?\\(",returnBegin:!0, +contains:[e.TITLE_MODE,u],relevance:0},{match:/\(\)/},{className:"params", +begin:/\(/,end:/\)/,excludeBegin:!0,excludeEnd:!0,keywords:n,relevance:0, +contains:[g,a,e.C_BLOCK_COMMENT_MODE] +},e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE]},m]}},grmr_css:e=>{ +const n=e.regex,t=ie(e),a=[e.APOS_STRING_MODE,e.QUOTE_STRING_MODE];return{ +name:"CSS",case_insensitive:!0,illegal:/[=|'\$]/,keywords:{ +keyframePosition:"from to"},classNameAliases:{keyframePosition:"selector-tag"}, +contains:[t.BLOCK_COMMENT,{begin:/-(webkit|moz|ms|o)-(?=[a-z])/ +},t.CSS_NUMBER_MODE,{className:"selector-id",begin:/#[A-Za-z0-9_-]+/,relevance:0 +},{className:"selector-class",begin:"\\.[a-zA-Z-][a-zA-Z0-9_-]*",relevance:0 +},t.ATTRIBUTE_SELECTOR_MODE,{className:"selector-pseudo",variants:[{ +begin:":("+oe.join("|")+")"},{begin:":(:)?("+le.join("|")+")"}] +},t.CSS_VARIABLE,{className:"attribute",begin:"\\b("+ce.join("|")+")\\b"},{ +begin:/:/,end:/[;}{]/, +contains:[t.BLOCK_COMMENT,t.HEXCOLOR,t.IMPORTANT,t.CSS_NUMBER_MODE,...a,{ +begin:/(url|data-uri)\(/,end:/\)/,relevance:0,keywords:{built_in:"url data-uri" +},contains:[...a,{className:"string",begin:/[^)]/,endsWithParent:!0, +excludeEnd:!0}]},t.FUNCTION_DISPATCH]},{begin:n.lookahead(/@/),end:"[{;]", +relevance:0,illegal:/:/,contains:[{className:"keyword",begin:/@-?\w[\w]*(-\w+)*/ +},{begin:/\s/,endsWithParent:!0,excludeEnd:!0,relevance:0,keywords:{ +$pattern:/[a-z-]+/,keyword:"and or not only",attribute:se.join(" ")},contains:[{ +begin:/[a-z-]+(?=:)/,className:"attribute"},...a,t.CSS_NUMBER_MODE]}]},{ +className:"selector-tag",begin:"\\b("+re.join("|")+")\\b"}]}},grmr_diff:e=>{ +const n=e.regex;return{name:"Diff",aliases:["patch"],contains:[{ +className:"meta",relevance:10, +match:n.either(/^@@ +-\d+,\d+ +\+\d+,\d+ +@@/,/^\*\*\* +\d+,\d+ +\*\*\*\*$/,/^--- +\d+,\d+ +----$/) +},{className:"comment",variants:[{ +begin:n.either(/Index: /,/^index/,/={3,}/,/^-{3}/,/^\*{3} /,/^\+{3}/,/^diff --git/), +end:/$/},{match:/^\*{15}$/}]},{className:"addition",begin:/^\+/,end:/$/},{ +className:"deletion",begin:/^-/,end:/$/},{className:"addition",begin:/^!/, +end:/$/}]}},grmr_go:e=>{const n={ +keyword:["break","case","chan","const","continue","default","defer","else","fallthrough","for","func","go","goto","if","import","interface","map","package","range","return","select","struct","switch","type","var"], +type:["bool","byte","complex64","complex128","error","float32","float64","int8","int16","int32","int64","string","uint8","uint16","uint32","uint64","int","uint","uintptr","rune"], +literal:["true","false","iota","nil"], +built_in:["append","cap","close","complex","copy","imag","len","make","new","panic","print","println","real","recover","delete"] +};return{name:"Go",aliases:["golang"],keywords:n,illegal:"{const n=e.regex;return{name:"GraphQL",aliases:["gql"], +case_insensitive:!0,disableAutodetect:!1,keywords:{ +keyword:["query","mutation","subscription","type","input","schema","directive","interface","union","scalar","fragment","enum","on"], +literal:["true","false","null"]}, +contains:[e.HASH_COMMENT_MODE,e.QUOTE_STRING_MODE,e.NUMBER_MODE,{ +scope:"punctuation",match:/[.]{3}/,relevance:0},{scope:"punctuation", +begin:/[\!\(\)\:\=\[\]\{\|\}]{1}/,relevance:0},{scope:"variable",begin:/\$/, +end:/\W/,excludeEnd:!0,relevance:0},{scope:"meta",match:/@\w+/,excludeEnd:!0},{ +scope:"symbol",begin:n.concat(/[_A-Za-z][_0-9A-Za-z]*/,n.lookahead(/\s*:/)), +relevance:0}],illegal:[/[;<']/,/BEGIN/]}},grmr_ini:e=>{const n=e.regex,t={ +className:"number",relevance:0,variants:[{begin:/([+-]+)?[\d]+_[\d_]+/},{ +begin:e.NUMBER_RE}]},a=e.COMMENT();a.variants=[{begin:/;/,end:/$/},{begin:/#/, +end:/$/}];const i={className:"variable",variants:[{begin:/\$[\w\d"][\w\d_]*/},{ +begin:/\$\{(.*?)\}/}]},r={className:"literal", +begin:/\bon|off|true|false|yes|no\b/},s={className:"string", +contains:[e.BACKSLASH_ESCAPE],variants:[{begin:"'''",end:"'''",relevance:10},{ +begin:'"""',end:'"""',relevance:10},{begin:'"',end:'"'},{begin:"'",end:"'"}] +},o={begin:/\[/,end:/\]/,contains:[a,r,i,s,t,"self"],relevance:0 +},l=n.either(/[A-Za-z0-9_-]+/,/"(\\"|[^"])*"/,/'[^']*'/);return{ +name:"TOML, also INI",aliases:["toml"],case_insensitive:!0,illegal:/\S/, +contains:[a,{className:"section",begin:/\[+/,end:/\]+/},{ +begin:n.concat(l,"(\\s*\\.\\s*",l,")*",n.lookahead(/\s*=\s*[^#\s]/)), +className:"attr",starts:{end:/$/,contains:[a,o,r,i,s,t]}}]}},grmr_java:e=>{ +const n=e.regex,t="[\xc0-\u02b8a-zA-Z_$][\xc0-\u02b8a-zA-Z_$0-9]*",a=t+pe("(?:<"+t+"~~~(?:\\s*,\\s*"+t+"~~~)*>)?",/~~~/g,2),i={ +keyword:["synchronized","abstract","private","var","static","if","const ","for","while","strictfp","finally","protected","import","native","final","void","enum","else","break","transient","catch","instanceof","volatile","case","assert","package","default","public","try","switch","continue","throws","protected","public","private","module","requires","exports","do","sealed","yield","permits"], +literal:["false","true","null"], +type:["char","boolean","long","float","int","byte","short","double"], +built_in:["super","this"]},r={className:"meta",begin:"@"+t,contains:[{ +begin:/\(/,end:/\)/,contains:["self"]}]},s={className:"params",begin:/\(/, +end:/\)/,keywords:i,relevance:0,contains:[e.C_BLOCK_COMMENT_MODE],endsParent:!0} +;return{name:"Java",aliases:["jsp"],keywords:i,illegal:/<\/|#/, +contains:[e.COMMENT("/\\*\\*","\\*/",{relevance:0,contains:[{begin:/\w+@/, +relevance:0},{className:"doctag",begin:"@[A-Za-z]+"}]}),{ +begin:/import java\.[a-z]+\./,keywords:"import",relevance:2 +},e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,{begin:/"""/,end:/"""/, +className:"string",contains:[e.BACKSLASH_ESCAPE] +},e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,{ +match:[/\b(?:class|interface|enum|extends|implements|new)/,/\s+/,t],className:{ +1:"keyword",3:"title.class"}},{match:/non-sealed/,scope:"keyword"},{ +begin:[n.concat(/(?!else)/,t),/\s+/,t,/\s+/,/=(?!=)/],className:{1:"type", +3:"variable",5:"operator"}},{begin:[/record/,/\s+/,t],className:{1:"keyword", +3:"title.class"},contains:[s,e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE]},{ +beginKeywords:"new throw return else",relevance:0},{ +begin:["(?:"+a+"\\s+)",e.UNDERSCORE_IDENT_RE,/\s*(?=\()/],className:{ +2:"title.function"},keywords:i,contains:[{className:"params",begin:/\(/, +end:/\)/,keywords:i,relevance:0, +contains:[r,e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,me,e.C_BLOCK_COMMENT_MODE] +},e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE]},me,r]}},grmr_javascript:Oe, +grmr_json:e=>{const n=["true","false","null"],t={scope:"literal", +beginKeywords:n.join(" ")};return{name:"JSON",keywords:{literal:n},contains:[{ +className:"attr",begin:/"(\\.|[^\\"\r\n])*"(?=\s*:)/,relevance:1.01},{ +match:/[{}[\],:]/,className:"punctuation",relevance:0 +},e.QUOTE_STRING_MODE,t,e.C_NUMBER_MODE,e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE], +illegal:"\\S"}},grmr_kotlin:e=>{const n={ +keyword:"abstract as val var vararg get set class object open private protected public noinline crossinline dynamic final enum if else do while for when throw try catch finally import package is in fun override companion reified inline lateinit init interface annotation data sealed internal infix operator out by constructor super tailrec where const inner suspend typealias external expect actual", +built_in:"Byte Short Char Int Long Boolean Float Double Void Unit Nothing", +literal:"true false null"},t={className:"symbol",begin:e.UNDERSCORE_IDENT_RE+"@" +},a={className:"subst",begin:/\$\{/,end:/\}/,contains:[e.C_NUMBER_MODE]},i={ +className:"variable",begin:"\\$"+e.UNDERSCORE_IDENT_RE},r={className:"string", +variants:[{begin:'"""',end:'"""(?=[^"])',contains:[i,a]},{begin:"'",end:"'", +illegal:/\n/,contains:[e.BACKSLASH_ESCAPE]},{begin:'"',end:'"',illegal:/\n/, +contains:[e.BACKSLASH_ESCAPE,i,a]}]};a.contains.push(r);const s={ +className:"meta", +begin:"@(?:file|property|field|get|set|receiver|param|setparam|delegate)\\s*:(?:\\s*"+e.UNDERSCORE_IDENT_RE+")?" +},o={className:"meta",begin:"@"+e.UNDERSCORE_IDENT_RE,contains:[{begin:/\(/, +end:/\)/,contains:[e.inherit(r,{className:"string"}),"self"]}] +},l=me,c=e.COMMENT("/\\*","\\*/",{contains:[e.C_BLOCK_COMMENT_MODE]}),d={ +variants:[{className:"type",begin:e.UNDERSCORE_IDENT_RE},{begin:/\(/,end:/\)/, +contains:[]}]},g=d;return g.variants[1].contains=[d],d.variants[1].contains=[g], +{name:"Kotlin",aliases:["kt","kts"],keywords:n, +contains:[e.COMMENT("/\\*\\*","\\*/",{relevance:0,contains:[{className:"doctag", +begin:"@[A-Za-z]+"}]}),e.C_LINE_COMMENT_MODE,c,{className:"keyword", +begin:/\b(break|continue|return|this)\b/,starts:{contains:[{className:"symbol", +begin:/@\w+/}]}},t,s,o,{className:"function",beginKeywords:"fun",end:"[(]|$", +returnBegin:!0,excludeEnd:!0,keywords:n,relevance:5,contains:[{ +begin:e.UNDERSCORE_IDENT_RE+"\\s*\\(",returnBegin:!0,relevance:0, +contains:[e.UNDERSCORE_TITLE_MODE]},{className:"type",begin://, +keywords:"reified",relevance:0},{className:"params",begin:/\(/,end:/\)/, +endsParent:!0,keywords:n,relevance:0,contains:[{begin:/:/,end:/[=,\/]/, +endsWithParent:!0,contains:[d,e.C_LINE_COMMENT_MODE,c],relevance:0 +},e.C_LINE_COMMENT_MODE,c,s,o,r,e.C_NUMBER_MODE]},c]},{ +begin:[/class|interface|trait/,/\s+/,e.UNDERSCORE_IDENT_RE],beginScope:{ +3:"title.class"},keywords:"class interface trait",end:/[:\{(]|$/,excludeEnd:!0, +illegal:"extends implements",contains:[{ +beginKeywords:"public protected internal private constructor" +},e.UNDERSCORE_TITLE_MODE,{className:"type",begin://,excludeBegin:!0, +excludeEnd:!0,relevance:0},{className:"type",begin:/[,:]\s*/,end:/[<\(,){\s]|$/, +excludeBegin:!0,returnEnd:!0},s,o]},r,{className:"meta",begin:"^#!/usr/bin/env", +end:"$",illegal:"\n"},l]}},grmr_less:e=>{ +const n=ie(e),t=de,a="[\\w-]+",i="("+a+"|@\\{"+a+"\\})",r=[],s=[],o=e=>({ +className:"string",begin:"~?"+e+".*?"+e}),l=(e,n,t)=>({className:e,begin:n, +relevance:t}),c={$pattern:/[a-z-]+/,keyword:"and or not only", +attribute:se.join(" ")},d={begin:"\\(",end:"\\)",contains:s,keywords:c, +relevance:0} +;s.push(e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,o("'"),o('"'),n.CSS_NUMBER_MODE,{ +begin:"(url|data-uri)\\(",starts:{className:"string",end:"[\\)\\n]", +excludeEnd:!0} +},n.HEXCOLOR,d,l("variable","@@?"+a,10),l("variable","@\\{"+a+"\\}"),l("built_in","~?`[^`]*?`"),{ +className:"attribute",begin:a+"\\s*:",end:":",returnBegin:!0,excludeEnd:!0 +},n.IMPORTANT,{beginKeywords:"and not"},n.FUNCTION_DISPATCH);const g=s.concat({ +begin:/\{/,end:/\}/,contains:r}),u={beginKeywords:"when",endsWithParent:!0, +contains:[{beginKeywords:"and not"}].concat(s)},b={begin:i+"\\s*:", +returnBegin:!0,end:/[;}]/,relevance:0,contains:[{begin:/-(webkit|moz|ms|o)-/ +},n.CSS_VARIABLE,{className:"attribute",begin:"\\b("+ce.join("|")+")\\b", +end:/(?=:)/,starts:{endsWithParent:!0,illegal:"[<=$]",relevance:0,contains:s}}] +},m={className:"keyword", +begin:"@(import|media|charset|font-face|(-[a-z]+-)?keyframes|supports|document|namespace|page|viewport|host)\\b", +starts:{end:"[;{}]",keywords:c,returnEnd:!0,contains:s,relevance:0}},p={ +className:"variable",variants:[{begin:"@"+a+"\\s*:",relevance:15},{begin:"@"+a +}],starts:{end:"[;}]",returnEnd:!0,contains:g}},_={variants:[{ +begin:"[\\.#:&\\[>]",end:"[;{}]"},{begin:i,end:/\{/}],returnBegin:!0, +returnEnd:!0,illegal:"[<='$\"]",relevance:0, +contains:[e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,u,l("keyword","all\\b"),l("variable","@\\{"+a+"\\}"),{ +begin:"\\b("+re.join("|")+")\\b",className:"selector-tag" +},n.CSS_NUMBER_MODE,l("selector-tag",i,0),l("selector-id","#"+i),l("selector-class","\\."+i,0),l("selector-tag","&",0),n.ATTRIBUTE_SELECTOR_MODE,{ +className:"selector-pseudo",begin:":("+oe.join("|")+")"},{ +className:"selector-pseudo",begin:":(:)?("+le.join("|")+")"},{begin:/\(/, +end:/\)/,relevance:0,contains:g},{begin:"!important"},n.FUNCTION_DISPATCH]},h={ +begin:a+":(:)?"+`(${t.join("|")})`,returnBegin:!0,contains:[_]} +;return r.push(e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,m,p,h,b,_,u,n.FUNCTION_DISPATCH), +{name:"Less",case_insensitive:!0,illegal:"[=>'/<($\"]",contains:r}}, +grmr_lua:e=>{const n="\\[=*\\[",t="\\]=*\\]",a={begin:n,end:t,contains:["self"] +},i=[e.COMMENT("--(?!"+n+")","$"),e.COMMENT("--"+n,t,{contains:[a],relevance:10 +})];return{name:"Lua",keywords:{$pattern:e.UNDERSCORE_IDENT_RE, +literal:"true false nil", +keyword:"and break do else elseif end for goto if in local not or repeat return then until while", +built_in:"_G _ENV _VERSION __index __newindex __mode __call __metatable __tostring __len __gc __add __sub __mul __div __mod __pow __concat __unm __eq __lt __le assert collectgarbage dofile error getfenv getmetatable ipairs load loadfile loadstring module next pairs pcall print rawequal rawget rawset require select setfenv setmetatable tonumber tostring type unpack xpcall arg self coroutine resume yield status wrap create running debug getupvalue debug sethook getmetatable gethook setmetatable setlocal traceback setfenv getinfo setupvalue getlocal getregistry getfenv io lines write close flush open output type read stderr stdin input stdout popen tmpfile math log max acos huge ldexp pi cos tanh pow deg tan cosh sinh random randomseed frexp ceil floor rad abs sqrt modf asin min mod fmod log10 atan2 exp sin atan os exit setlocale date getenv difftime remove time clock tmpname rename execute package preload loadlib loaded loaders cpath config path seeall string sub upper len gfind rep find match char dump gmatch reverse byte format gsub lower table setn insert getn foreachi maxn foreach concat sort remove" +},contains:i.concat([{className:"function",beginKeywords:"function",end:"\\)", +contains:[e.inherit(e.TITLE_MODE,{ +begin:"([_a-zA-Z]\\w*\\.)*([_a-zA-Z]\\w*:)?[_a-zA-Z]\\w*"}),{className:"params", +begin:"\\(",endsWithParent:!0,contains:i}].concat(i) +},e.C_NUMBER_MODE,e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,{className:"string", +begin:n,end:t,contains:[a],relevance:5}])}},grmr_makefile:e=>{const n={ +className:"variable",variants:[{begin:"\\$\\("+e.UNDERSCORE_IDENT_RE+"\\)", +contains:[e.BACKSLASH_ESCAPE]},{begin:/\$[@%{ +const n={begin:/<\/?[A-Za-z_]/,end:">",subLanguage:"xml",relevance:0},t={ +variants:[{begin:/\[.+?\]\[.*?\]/,relevance:0},{ +begin:/\[.+?\]\(((data|javascript|mailto):|(?:http|ftp)s?:\/\/).*?\)/, +relevance:2},{ +begin:e.regex.concat(/\[.+?\]\(/,/[A-Za-z][A-Za-z0-9+.-]*/,/:\/\/.*?\)/), +relevance:2},{begin:/\[.+?\]\([./?&#].*?\)/,relevance:1},{ +begin:/\[.*?\]\(.*?\)/,relevance:0}],returnBegin:!0,contains:[{match:/\[(?=\])/ +},{className:"string",relevance:0,begin:"\\[",end:"\\]",excludeBegin:!0, +returnEnd:!0},{className:"link",relevance:0,begin:"\\]\\(",end:"\\)", +excludeBegin:!0,excludeEnd:!0},{className:"symbol",relevance:0,begin:"\\]\\[", +end:"\\]",excludeBegin:!0,excludeEnd:!0}]},a={className:"strong",contains:[], +variants:[{begin:/_{2}(?!\s)/,end:/_{2}/},{begin:/\*{2}(?!\s)/,end:/\*{2}/}] +},i={className:"emphasis",contains:[],variants:[{begin:/\*(?![*\s])/,end:/\*/},{ +begin:/_(?![_\s])/,end:/_/,relevance:0}]},r=e.inherit(a,{contains:[] +}),s=e.inherit(i,{contains:[]});a.contains.push(s),i.contains.push(r) +;let o=[n,t];return[a,i,r,s].forEach((e=>{e.contains=e.contains.concat(o) +})),o=o.concat(a,i),{name:"Markdown",aliases:["md","mkdown","mkd"],contains:[{ +className:"section",variants:[{begin:"^#{1,6}",end:"$",contains:o},{ +begin:"(?=^.+?\\n[=-]{2,}$)",contains:[{begin:"^[=-]*$"},{begin:"^",end:"\\n", +contains:o}]}]},n,{className:"bullet",begin:"^[ \t]*([*+-]|(\\d+\\.))(?=\\s+)", +end:"\\s+",excludeEnd:!0},a,i,{className:"quote",begin:"^>\\s+",contains:o, +end:"$"},{className:"code",variants:[{begin:"(`{3,})[^`](.|\\n)*?\\1`*[ ]*"},{ +begin:"(~{3,})[^~](.|\\n)*?\\1~*[ ]*"},{begin:"```",end:"```+[ ]*$"},{ +begin:"~~~",end:"~~~+[ ]*$"},{begin:"`.+?`"},{begin:"(?=^( {4}|\\t))", +contains:[{begin:"^( {4}|\\t)",end:"(\\n)$"}],relevance:0}]},{ +begin:"^[-\\*]{3,}",end:"$"},t,{begin:/^\[[^\n]+\]:/,returnBegin:!0,contains:[{ +className:"symbol",begin:/\[/,end:/\]/,excludeBegin:!0,excludeEnd:!0},{ +className:"link",begin:/:\s*/,end:/$/,excludeBegin:!0}]}]}},grmr_objectivec:e=>{ +const n=/[a-zA-Z@][a-zA-Z0-9_]*/,t={$pattern:n, +keyword:["@interface","@class","@protocol","@implementation"]};return{ +name:"Objective-C",aliases:["mm","objc","obj-c","obj-c++","objective-c++"], +keywords:{"variable.language":["this","super"],$pattern:n, +keyword:["while","export","sizeof","typedef","const","struct","for","union","volatile","static","mutable","if","do","return","goto","enum","else","break","extern","asm","case","default","register","explicit","typename","switch","continue","inline","readonly","assign","readwrite","self","@synchronized","id","typeof","nonatomic","IBOutlet","IBAction","strong","weak","copy","in","out","inout","bycopy","byref","oneway","__strong","__weak","__block","__autoreleasing","@private","@protected","@public","@try","@property","@end","@throw","@catch","@finally","@autoreleasepool","@synthesize","@dynamic","@selector","@optional","@required","@encode","@package","@import","@defs","@compatibility_alias","__bridge","__bridge_transfer","__bridge_retained","__bridge_retain","__covariant","__contravariant","__kindof","_Nonnull","_Nullable","_Null_unspecified","__FUNCTION__","__PRETTY_FUNCTION__","__attribute__","getter","setter","retain","unsafe_unretained","nonnull","nullable","null_unspecified","null_resettable","class","instancetype","NS_DESIGNATED_INITIALIZER","NS_UNAVAILABLE","NS_REQUIRES_SUPER","NS_RETURNS_INNER_POINTER","NS_INLINE","NS_AVAILABLE","NS_DEPRECATED","NS_ENUM","NS_OPTIONS","NS_SWIFT_UNAVAILABLE","NS_ASSUME_NONNULL_BEGIN","NS_ASSUME_NONNULL_END","NS_REFINED_FOR_SWIFT","NS_SWIFT_NAME","NS_SWIFT_NOTHROW","NS_DURING","NS_HANDLER","NS_ENDHANDLER","NS_VALUERETURN","NS_VOIDRETURN"], +literal:["false","true","FALSE","TRUE","nil","YES","NO","NULL"], +built_in:["dispatch_once_t","dispatch_queue_t","dispatch_sync","dispatch_async","dispatch_once"], +type:["int","float","char","unsigned","signed","short","long","double","wchar_t","unichar","void","bool","BOOL","id|0","_Bool"] +},illegal:"/,end:/$/,illegal:"\\n" +},e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE]},{className:"class", +begin:"("+t.keyword.join("|")+")\\b",end:/(\{|$)/,excludeEnd:!0,keywords:t, +contains:[e.UNDERSCORE_TITLE_MODE]},{begin:"\\."+e.UNDERSCORE_IDENT_RE, +relevance:0}]}},grmr_perl:e=>{const n=e.regex,t=/[dualxmsipngr]{0,12}/,a={ +$pattern:/[\w.]+/, +keyword:"abs accept alarm and atan2 bind binmode bless break caller chdir chmod chomp chop chown chr chroot close closedir connect continue cos crypt dbmclose dbmopen defined delete die do dump each else elsif endgrent endhostent endnetent endprotoent endpwent endservent eof eval exec exists exit exp fcntl fileno flock for foreach fork format formline getc getgrent getgrgid getgrnam gethostbyaddr gethostbyname gethostent getlogin getnetbyaddr getnetbyname getnetent getpeername getpgrp getpriority getprotobyname getprotobynumber getprotoent getpwent getpwnam getpwuid getservbyname getservbyport getservent getsockname getsockopt given glob gmtime goto grep gt hex if index int ioctl join keys kill last lc lcfirst length link listen local localtime log lstat lt ma map mkdir msgctl msgget msgrcv msgsnd my ne next no not oct open opendir or ord our pack package pipe pop pos print printf prototype push q|0 qq quotemeta qw qx rand read readdir readline readlink readpipe recv redo ref rename require reset return reverse rewinddir rindex rmdir say scalar seek seekdir select semctl semget semop send setgrent sethostent setnetent setpgrp setpriority setprotoent setpwent setservent setsockopt shift shmctl shmget shmread shmwrite shutdown sin sleep socket socketpair sort splice split sprintf sqrt srand stat state study sub substr symlink syscall sysopen sysread sysseek system syswrite tell telldir tie tied time times tr truncate uc ucfirst umask undef unless unlink unpack unshift untie until use utime values vec wait waitpid wantarray warn when while write x|0 xor y|0" +},i={className:"subst",begin:"[$@]\\{",end:"\\}",keywords:a},r={begin:/->\{/, +end:/\}/},s={variants:[{begin:/\$\d/},{ +begin:n.concat(/[$%@](\^\w\b|#\w+(::\w+)*|\{\w+\}|\w+(::\w*)*)/,"(?![A-Za-z])(?![@$%])") +},{begin:/[$%@][^\s\w{]/,relevance:0}] +},o=[e.BACKSLASH_ESCAPE,i,s],l=[/!/,/\//,/\|/,/\?/,/'/,/"/,/#/],c=(e,a,i="\\1")=>{ +const r="\\1"===i?i:n.concat(i,a) +;return n.concat(n.concat("(?:",e,")"),a,/(?:\\.|[^\\\/])*?/,r,/(?:\\.|[^\\\/])*?/,i,t) +},d=(e,a,i)=>n.concat(n.concat("(?:",e,")"),a,/(?:\\.|[^\\\/])*?/,i,t),g=[s,e.HASH_COMMENT_MODE,e.COMMENT(/^=\w/,/=cut/,{ +endsWithParent:!0}),r,{className:"string",contains:o,variants:[{ +begin:"q[qwxr]?\\s*\\(",end:"\\)",relevance:5},{begin:"q[qwxr]?\\s*\\[", +end:"\\]",relevance:5},{begin:"q[qwxr]?\\s*\\{",end:"\\}",relevance:5},{ +begin:"q[qwxr]?\\s*\\|",end:"\\|",relevance:5},{begin:"q[qwxr]?\\s*<",end:">", +relevance:5},{begin:"qw\\s+q",end:"q",relevance:5},{begin:"'",end:"'", +contains:[e.BACKSLASH_ESCAPE]},{begin:'"',end:'"'},{begin:"`",end:"`", +contains:[e.BACKSLASH_ESCAPE]},{begin:/\{\w+\}/,relevance:0},{ +begin:"-?\\w+\\s*=>",relevance:0}]},{className:"number", +begin:"(\\b0[0-7_]+)|(\\b0x[0-9a-fA-F_]+)|(\\b[1-9][0-9_]*(\\.[0-9_]+)?)|[0_]\\b", +relevance:0},{ +begin:"(\\/\\/|"+e.RE_STARTERS_RE+"|\\b(split|return|print|reverse|grep)\\b)\\s*", +keywords:"split return print reverse grep",relevance:0, +contains:[e.HASH_COMMENT_MODE,{className:"regexp",variants:[{ +begin:c("s|tr|y",n.either(...l,{capture:!0}))},{begin:c("s|tr|y","\\(","\\)")},{ +begin:c("s|tr|y","\\[","\\]")},{begin:c("s|tr|y","\\{","\\}")}],relevance:2},{ +className:"regexp",variants:[{begin:/(m|qr)\/\//,relevance:0},{ +begin:d("(?:m|qr)?",/\//,/\//)},{begin:d("m|qr",n.either(...l,{capture:!0 +}),/\1/)},{begin:d("m|qr",/\(/,/\)/)},{begin:d("m|qr",/\[/,/\]/)},{ +begin:d("m|qr",/\{/,/\}/)}]}]},{className:"function",beginKeywords:"sub", +end:"(\\s*\\(.*?\\))?[;{]",excludeEnd:!0,relevance:5,contains:[e.TITLE_MODE]},{ +begin:"-\\w\\b",relevance:0},{begin:"^__DATA__$",end:"^__END__$", +subLanguage:"mojolicious",contains:[{begin:"^@@.*",end:"$",className:"comment"}] +}];return i.contains=g,r.contains=g,{name:"Perl",aliases:["pl","pm"],keywords:a, +contains:g}},grmr_php:e=>{ +const n=e.regex,t=/(?![A-Za-z0-9])(?![$])/,a=n.concat(/[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*/,t),i=n.concat(/(\\?[A-Z][a-z0-9_\x7f-\xff]+|\\?[A-Z]+(?=[A-Z][a-z0-9_\x7f-\xff])){1,}/,t),r={ +scope:"variable",match:"\\$+"+a},s={scope:"subst",variants:[{begin:/\$\w+/},{ +begin:/\{\$/,end:/\}/}]},o=e.inherit(e.APOS_STRING_MODE,{illegal:null +}),l="[ \t\n]",c={scope:"string",variants:[e.inherit(e.QUOTE_STRING_MODE,{ +illegal:null,contains:e.QUOTE_STRING_MODE.contains.concat(s)}),o,{ +begin:/<<<[ \t]*(?:(\w+)|"(\w+)")\n/,end:/[ \t]*(\w+)\b/, +contains:e.QUOTE_STRING_MODE.contains.concat(s),"on:begin":(e,n)=>{ +n.data._beginMatch=e[1]||e[2]},"on:end":(e,n)=>{ +n.data._beginMatch!==e[1]&&n.ignoreMatch()}},e.END_SAME_AS_BEGIN({ +begin:/<<<[ \t]*'(\w+)'\n/,end:/[ \t]*(\w+)\b/})]},d={scope:"number",variants:[{ +begin:"\\b0[bB][01]+(?:_[01]+)*\\b"},{begin:"\\b0[oO][0-7]+(?:_[0-7]+)*\\b"},{ +begin:"\\b0[xX][\\da-fA-F]+(?:_[\\da-fA-F]+)*\\b"},{ +begin:"(?:\\b\\d+(?:_\\d+)*(\\.(?:\\d+(?:_\\d+)*))?|\\B\\.\\d+)(?:[eE][+-]?\\d+)?" +}],relevance:0 +},g=["false","null","true"],u=["__CLASS__","__DIR__","__FILE__","__FUNCTION__","__COMPILER_HALT_OFFSET__","__LINE__","__METHOD__","__NAMESPACE__","__TRAIT__","die","echo","exit","include","include_once","print","require","require_once","array","abstract","and","as","binary","bool","boolean","break","callable","case","catch","class","clone","const","continue","declare","default","do","double","else","elseif","empty","enddeclare","endfor","endforeach","endif","endswitch","endwhile","enum","eval","extends","final","finally","float","for","foreach","from","global","goto","if","implements","instanceof","insteadof","int","integer","interface","isset","iterable","list","match|0","mixed","new","never","object","or","private","protected","public","readonly","real","return","string","switch","throw","trait","try","unset","use","var","void","while","xor","yield"],b=["Error|0","AppendIterator","ArgumentCountError","ArithmeticError","ArrayIterator","ArrayObject","AssertionError","BadFunctionCallException","BadMethodCallException","CachingIterator","CallbackFilterIterator","CompileError","Countable","DirectoryIterator","DivisionByZeroError","DomainException","EmptyIterator","ErrorException","Exception","FilesystemIterator","FilterIterator","GlobIterator","InfiniteIterator","InvalidArgumentException","IteratorIterator","LengthException","LimitIterator","LogicException","MultipleIterator","NoRewindIterator","OutOfBoundsException","OutOfRangeException","OuterIterator","OverflowException","ParentIterator","ParseError","RangeException","RecursiveArrayIterator","RecursiveCachingIterator","RecursiveCallbackFilterIterator","RecursiveDirectoryIterator","RecursiveFilterIterator","RecursiveIterator","RecursiveIteratorIterator","RecursiveRegexIterator","RecursiveTreeIterator","RegexIterator","RuntimeException","SeekableIterator","SplDoublyLinkedList","SplFileInfo","SplFileObject","SplFixedArray","SplHeap","SplMaxHeap","SplMinHeap","SplObjectStorage","SplObserver","SplPriorityQueue","SplQueue","SplStack","SplSubject","SplTempFileObject","TypeError","UnderflowException","UnexpectedValueException","UnhandledMatchError","ArrayAccess","BackedEnum","Closure","Fiber","Generator","Iterator","IteratorAggregate","Serializable","Stringable","Throwable","Traversable","UnitEnum","WeakReference","WeakMap","Directory","__PHP_Incomplete_Class","parent","php_user_filter","self","static","stdClass"],m={ +keyword:u,literal:(e=>{const n=[];return e.forEach((e=>{ +n.push(e),e.toLowerCase()===e?n.push(e.toUpperCase()):n.push(e.toLowerCase()) +})),n})(g),built_in:b},p=e=>e.map((e=>e.replace(/\|\d+$/,""))),_={variants:[{ +match:[/new/,n.concat(l,"+"),n.concat("(?!",p(b).join("\\b|"),"\\b)"),i],scope:{ +1:"keyword",4:"title.class"}}]},h=n.concat(a,"\\b(?!\\()"),f={variants:[{ +match:[n.concat(/::/,n.lookahead(/(?!class\b)/)),h],scope:{2:"variable.constant" +}},{match:[/::/,/class/],scope:{2:"variable.language"}},{ +match:[i,n.concat(/::/,n.lookahead(/(?!class\b)/)),h],scope:{1:"title.class", +3:"variable.constant"}},{match:[i,n.concat("::",n.lookahead(/(?!class\b)/))], +scope:{1:"title.class"}},{match:[i,/::/,/class/],scope:{1:"title.class", +3:"variable.language"}}]},E={scope:"attr", +match:n.concat(a,n.lookahead(":"),n.lookahead(/(?!::)/))},y={relevance:0, +begin:/\(/,end:/\)/,keywords:m,contains:[E,r,f,e.C_BLOCK_COMMENT_MODE,c,d,_] +},N={relevance:0, +match:[/\b/,n.concat("(?!fn\\b|function\\b|",p(u).join("\\b|"),"|",p(b).join("\\b|"),"\\b)"),a,n.concat(l,"*"),n.lookahead(/(?=\()/)], +scope:{3:"title.function.invoke"},contains:[y]};y.contains.push(N) +;const w=[E,f,e.C_BLOCK_COMMENT_MODE,c,d,_];return{case_insensitive:!1, +keywords:m,contains:[{begin:n.concat(/#\[\s*/,i),beginScope:"meta",end:/]/, +endScope:"meta",keywords:{literal:g,keyword:["new","array"]},contains:[{ +begin:/\[/,end:/]/,keywords:{literal:g,keyword:["new","array"]}, +contains:["self",...w]},...w,{scope:"meta",match:i}] +},e.HASH_COMMENT_MODE,e.COMMENT("//","$"),e.COMMENT("/\\*","\\*/",{contains:[{ +scope:"doctag",match:"@[A-Za-z]+"}]}),{match:/__halt_compiler\(\);/, +keywords:"__halt_compiler",starts:{scope:"comment",end:e.MATCH_NOTHING_RE, +contains:[{match:/\?>/,scope:"meta",endsParent:!0}]}},{scope:"meta",variants:[{ +begin:/<\?php/,relevance:10},{begin:/<\?=/},{begin:/<\?/,relevance:.1},{ +begin:/\?>/}]},{scope:"variable.language",match:/\$this\b/},r,N,f,{ +match:[/const/,/\s/,a],scope:{1:"keyword",3:"variable.constant"}},_,{ +scope:"function",relevance:0,beginKeywords:"fn function",end:/[;{]/, +excludeEnd:!0,illegal:"[$%\\[]",contains:[{beginKeywords:"use" +},e.UNDERSCORE_TITLE_MODE,{begin:"=>",endsParent:!0},{scope:"params", +begin:"\\(",end:"\\)",excludeBegin:!0,excludeEnd:!0,keywords:m, +contains:["self",r,f,e.C_BLOCK_COMMENT_MODE,c,d]}]},{scope:"class",variants:[{ +beginKeywords:"enum",illegal:/[($"]/},{beginKeywords:"class interface trait", +illegal:/[:($"]/}],relevance:0,end:/\{/,excludeEnd:!0,contains:[{ +beginKeywords:"extends implements"},e.UNDERSCORE_TITLE_MODE]},{ +beginKeywords:"namespace",relevance:0,end:";",illegal:/[.']/, +contains:[e.inherit(e.UNDERSCORE_TITLE_MODE,{scope:"title.class"})]},{ +beginKeywords:"use",relevance:0,end:";",contains:[{ +match:/\b(as|const|function)\b/,scope:"keyword"},e.UNDERSCORE_TITLE_MODE]},c,d]} +},grmr_php_template:e=>({name:"PHP template",subLanguage:"xml",contains:[{ +begin:/<\?(php|=)?/,end:/\?>/,subLanguage:"php",contains:[{begin:"/\\*", +end:"\\*/",skip:!0},{begin:'b"',end:'"',skip:!0},{begin:"b'",end:"'",skip:!0 +},e.inherit(e.APOS_STRING_MODE,{illegal:null,className:null,contains:null, +skip:!0}),e.inherit(e.QUOTE_STRING_MODE,{illegal:null,className:null, +contains:null,skip:!0})]}]}),grmr_plaintext:e=>({name:"Plain text", +aliases:["text","txt"],disableAutodetect:!0}),grmr_python:e=>{ +const n=e.regex,t=/[\p{XID_Start}_]\p{XID_Continue}*/u,a=["and","as","assert","async","await","break","case","class","continue","def","del","elif","else","except","finally","for","from","global","if","import","in","is","lambda","match","nonlocal|10","not","or","pass","raise","return","try","while","with","yield"],i={ +$pattern:/[A-Za-z]\w+|__\w+__/,keyword:a, +built_in:["__import__","abs","all","any","ascii","bin","bool","breakpoint","bytearray","bytes","callable","chr","classmethod","compile","complex","delattr","dict","dir","divmod","enumerate","eval","exec","filter","float","format","frozenset","getattr","globals","hasattr","hash","help","hex","id","input","int","isinstance","issubclass","iter","len","list","locals","map","max","memoryview","min","next","object","oct","open","ord","pow","print","property","range","repr","reversed","round","set","setattr","slice","sorted","staticmethod","str","sum","super","tuple","type","vars","zip"], +literal:["__debug__","Ellipsis","False","None","NotImplemented","True"], +type:["Any","Callable","Coroutine","Dict","List","Literal","Generic","Optional","Sequence","Set","Tuple","Type","Union"] +},r={className:"meta",begin:/^(>>>|\.\.\.) /},s={className:"subst",begin:/\{/, +end:/\}/,keywords:i,illegal:/#/},o={begin:/\{\{/,relevance:0},l={ +className:"string",contains:[e.BACKSLASH_ESCAPE],variants:[{ +begin:/([uU]|[bB]|[rR]|[bB][rR]|[rR][bB])?'''/,end:/'''/, +contains:[e.BACKSLASH_ESCAPE,r],relevance:10},{ +begin:/([uU]|[bB]|[rR]|[bB][rR]|[rR][bB])?"""/,end:/"""/, +contains:[e.BACKSLASH_ESCAPE,r],relevance:10},{ +begin:/([fF][rR]|[rR][fF]|[fF])'''/,end:/'''/, +contains:[e.BACKSLASH_ESCAPE,r,o,s]},{begin:/([fF][rR]|[rR][fF]|[fF])"""/, +end:/"""/,contains:[e.BACKSLASH_ESCAPE,r,o,s]},{begin:/([uU]|[rR])'/,end:/'/, +relevance:10},{begin:/([uU]|[rR])"/,end:/"/,relevance:10},{ +begin:/([bB]|[bB][rR]|[rR][bB])'/,end:/'/},{begin:/([bB]|[bB][rR]|[rR][bB])"/, +end:/"/},{begin:/([fF][rR]|[rR][fF]|[fF])'/,end:/'/, +contains:[e.BACKSLASH_ESCAPE,o,s]},{begin:/([fF][rR]|[rR][fF]|[fF])"/,end:/"/, +contains:[e.BACKSLASH_ESCAPE,o,s]},e.APOS_STRING_MODE,e.QUOTE_STRING_MODE] +},c="[0-9](_?[0-9])*",d=`(\\b(${c}))?\\.(${c})|\\b(${c})\\.`,g="\\b|"+a.join("|"),u={ +className:"number",relevance:0,variants:[{ +begin:`(\\b(${c})|(${d}))[eE][+-]?(${c})[jJ]?(?=${g})`},{begin:`(${d})[jJ]?`},{ +begin:`\\b([1-9](_?[0-9])*|0+(_?0)*)[lLjJ]?(?=${g})`},{ +begin:`\\b0[bB](_?[01])+[lL]?(?=${g})`},{begin:`\\b0[oO](_?[0-7])+[lL]?(?=${g})` +},{begin:`\\b0[xX](_?[0-9a-fA-F])+[lL]?(?=${g})`},{begin:`\\b(${c})[jJ](?=${g})` +}]},b={className:"comment",begin:n.lookahead(/# type:/),end:/$/,keywords:i, +contains:[{begin:/# type:/},{begin:/#/,end:/\b\B/,endsWithParent:!0}]},m={ +className:"params",variants:[{className:"",begin:/\(\s*\)/,skip:!0},{begin:/\(/, +end:/\)/,excludeBegin:!0,excludeEnd:!0,keywords:i, +contains:["self",r,u,l,e.HASH_COMMENT_MODE]}]};return s.contains=[l,u,r],{ +name:"Python",aliases:["py","gyp","ipython"],unicodeRegex:!0,keywords:i, +illegal:/(<\/|\?)|=>/,contains:[r,u,{begin:/\bself\b/},{beginKeywords:"if", +relevance:0},l,b,e.HASH_COMMENT_MODE,{match:[/\bdef/,/\s+/,t],scope:{ +1:"keyword",3:"title.function"},contains:[m]},{variants:[{ +match:[/\bclass/,/\s+/,t,/\s*/,/\(\s*/,t,/\s*\)/]},{match:[/\bclass/,/\s+/,t]}], +scope:{1:"keyword",3:"title.class",6:"title.class.inherited"}},{ +className:"meta",begin:/^[\t ]*@/,end:/(?=#)|$/,contains:[u,m,l]}]}}, +grmr_python_repl:e=>({aliases:["pycon"],contains:[{className:"meta.prompt", +starts:{end:/ |$/,starts:{end:"$",subLanguage:"python"}},variants:[{ +begin:/^>>>(?=[ ]|$)/},{begin:/^\.\.\.(?=[ ]|$)/}]}]}),grmr_r:e=>{ +const n=e.regex,t=/(?:(?:[a-zA-Z]|\.[._a-zA-Z])[._a-zA-Z0-9]*)|\.(?!\d)/,a=n.either(/0[xX][0-9a-fA-F]+\.[0-9a-fA-F]*[pP][+-]?\d+i?/,/0[xX][0-9a-fA-F]+(?:[pP][+-]?\d+)?[Li]?/,/(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?[Li]?/),i=/[=!<>:]=|\|\||&&|:::?|<-|<<-|->>|->|\|>|[-+*\/?!$&|:<=>@^~]|\*\*/,r=n.either(/[()]/,/[{}]/,/\[\[/,/[[\]]/,/\\/,/,/) +;return{name:"R",keywords:{$pattern:t, +keyword:"function if in break next repeat else for while", +literal:"NULL NA TRUE FALSE Inf NaN NA_integer_|10 NA_real_|10 NA_character_|10 NA_complex_|10", +built_in:"LETTERS letters month.abb month.name pi T F abs acos acosh all any anyNA Arg as.call as.character as.complex as.double as.environment as.integer as.logical as.null.default as.numeric as.raw asin asinh atan atanh attr attributes baseenv browser c call ceiling class Conj cos cosh cospi cummax cummin cumprod cumsum digamma dim dimnames emptyenv exp expression floor forceAndCall gamma gc.time globalenv Im interactive invisible is.array is.atomic is.call is.character is.complex is.double is.environment is.expression is.finite is.function is.infinite is.integer is.language is.list is.logical is.matrix is.na is.name is.nan is.null is.numeric is.object is.pairlist is.raw is.recursive is.single is.symbol lazyLoadDBfetch length lgamma list log max min missing Mod names nargs nzchar oldClass on.exit pos.to.env proc.time prod quote range Re rep retracemem return round seq_along seq_len seq.int sign signif sin sinh sinpi sqrt standardGeneric substitute sum switch tan tanh tanpi tracemem trigamma trunc unclass untracemem UseMethod xtfrm" +},contains:[e.COMMENT(/#'/,/$/,{contains:[{scope:"doctag",match:/@examples/, +starts:{end:n.lookahead(n.either(/\n^#'\s*(?=@[a-zA-Z]+)/,/\n^(?!#')/)), +endsParent:!0}},{scope:"doctag",begin:"@param",end:/$/,contains:[{ +scope:"variable",variants:[{match:t},{match:/`(?:\\.|[^`\\])+`/}],endsParent:!0 +}]},{scope:"doctag",match:/@[a-zA-Z]+/},{scope:"keyword",match:/\\[a-zA-Z]+/}] +}),e.HASH_COMMENT_MODE,{scope:"string",contains:[e.BACKSLASH_ESCAPE], +variants:[e.END_SAME_AS_BEGIN({begin:/[rR]"(-*)\(/,end:/\)(-*)"/ +}),e.END_SAME_AS_BEGIN({begin:/[rR]"(-*)\{/,end:/\}(-*)"/ +}),e.END_SAME_AS_BEGIN({begin:/[rR]"(-*)\[/,end:/\](-*)"/ +}),e.END_SAME_AS_BEGIN({begin:/[rR]'(-*)\(/,end:/\)(-*)'/ +}),e.END_SAME_AS_BEGIN({begin:/[rR]'(-*)\{/,end:/\}(-*)'/ +}),e.END_SAME_AS_BEGIN({begin:/[rR]'(-*)\[/,end:/\](-*)'/}),{begin:'"',end:'"', +relevance:0},{begin:"'",end:"'",relevance:0}]},{relevance:0,variants:[{scope:{ +1:"operator",2:"number"},match:[i,a]},{scope:{1:"operator",2:"number"}, +match:[/%[^%]*%/,a]},{scope:{1:"punctuation",2:"number"},match:[r,a]},{scope:{ +2:"number"},match:[/[^a-zA-Z0-9._]|^/,a]}]},{scope:{3:"operator"}, +match:[t,/\s+/,/<-/,/\s+/]},{scope:"operator",relevance:0,variants:[{match:i},{ +match:/%[^%]*%/}]},{scope:"punctuation",relevance:0,match:r},{begin:"`",end:"`", +contains:[{begin:/\\./}]}]}},grmr_ruby:e=>{ +const n=e.regex,t="([a-zA-Z_]\\w*[!?=]?|[-+~]@|<<|>>|=~|===?|<=>|[<>]=?|\\*\\*|[-/+%^&*~`|]|\\[\\]=?)",a=n.either(/\b([A-Z]+[a-z0-9]+)+/,/\b([A-Z]+[a-z0-9]+)+[A-Z]+/),i=n.concat(a,/(::\w+)*/),r={ +"variable.constant":["__FILE__","__LINE__","__ENCODING__"], +"variable.language":["self","super"], +keyword:["alias","and","begin","BEGIN","break","case","class","defined","do","else","elsif","end","END","ensure","for","if","in","module","next","not","or","redo","require","rescue","retry","return","then","undef","unless","until","when","while","yield","include","extend","prepend","public","private","protected","raise","throw"], +built_in:["proc","lambda","attr_accessor","attr_reader","attr_writer","define_method","private_constant","module_function"], +literal:["true","false","nil"]},s={className:"doctag",begin:"@[A-Za-z]+"},o={ +begin:"#<",end:">"},l=[e.COMMENT("#","$",{contains:[s] +}),e.COMMENT("^=begin","^=end",{contains:[s],relevance:10 +}),e.COMMENT("^__END__",e.MATCH_NOTHING_RE)],c={className:"subst",begin:/#\{/, +end:/\}/,keywords:r},d={className:"string",contains:[e.BACKSLASH_ESCAPE,c], +variants:[{begin:/'/,end:/'/},{begin:/"/,end:/"/},{begin:/`/,end:/`/},{ +begin:/%[qQwWx]?\(/,end:/\)/},{begin:/%[qQwWx]?\[/,end:/\]/},{ +begin:/%[qQwWx]?\{/,end:/\}/},{begin:/%[qQwWx]?/},{begin:/%[qQwWx]?\//, +end:/\//},{begin:/%[qQwWx]?%/,end:/%/},{begin:/%[qQwWx]?-/,end:/-/},{ +begin:/%[qQwWx]?\|/,end:/\|/},{begin:/\B\?(\\\d{1,3})/},{ +begin:/\B\?(\\x[A-Fa-f0-9]{1,2})/},{begin:/\B\?(\\u\{?[A-Fa-f0-9]{1,6}\}?)/},{ +begin:/\B\?(\\M-\\C-|\\M-\\c|\\c\\M-|\\M-|\\C-\\M-)[\x20-\x7e]/},{ +begin:/\B\?\\(c|C-)[\x20-\x7e]/},{begin:/\B\?\\?\S/},{ +begin:n.concat(/<<[-~]?'?/,n.lookahead(/(\w+)(?=\W)[^\n]*\n(?:[^\n]*\n)*?\s*\1\b/)), +contains:[e.END_SAME_AS_BEGIN({begin:/(\w+)/,end:/(\w+)/, +contains:[e.BACKSLASH_ESCAPE,c]})]}]},g="[0-9](_?[0-9])*",u={className:"number", +relevance:0,variants:[{ +begin:`\\b([1-9](_?[0-9])*|0)(\\.(${g}))?([eE][+-]?(${g})|r)?i?\\b`},{ +begin:"\\b0[dD][0-9](_?[0-9])*r?i?\\b"},{begin:"\\b0[bB][0-1](_?[0-1])*r?i?\\b" +},{begin:"\\b0[oO][0-7](_?[0-7])*r?i?\\b"},{ +begin:"\\b0[xX][0-9a-fA-F](_?[0-9a-fA-F])*r?i?\\b"},{ +begin:"\\b0(_?[0-7])+r?i?\\b"}]},b={variants:[{match:/\(\)/},{ +className:"params",begin:/\(/,end:/(?=\))/,excludeBegin:!0,endsParent:!0, +keywords:r}]},m=[d,{variants:[{match:[/class\s+/,i,/\s+<\s+/,i]},{ +match:[/\b(class|module)\s+/,i]}],scope:{2:"title.class", +4:"title.class.inherited"},keywords:r},{match:[/(include|extend)\s+/,i],scope:{ +2:"title.class"},keywords:r},{relevance:0,match:[i,/\.new[. (]/],scope:{ +1:"title.class"}},{relevance:0,match:/\b[A-Z][A-Z_0-9]+\b/, +className:"variable.constant"},{relevance:0,match:a,scope:"title.class"},{ +match:[/def/,/\s+/,t],scope:{1:"keyword",3:"title.function"},contains:[b]},{ +begin:e.IDENT_RE+"::"},{className:"symbol", +begin:e.UNDERSCORE_IDENT_RE+"(!|\\?)?:",relevance:0},{className:"symbol", +begin:":(?!\\s)",contains:[d,{begin:t}],relevance:0},u,{className:"variable", +begin:"(\\$\\W)|((\\$|@@?)(\\w+))(?=[^@$?])(?![A-Za-z])(?![@$?'])"},{ +className:"params",begin:/\|/,end:/\|/,excludeBegin:!0,excludeEnd:!0, +relevance:0,keywords:r},{begin:"("+e.RE_STARTERS_RE+"|unless)\\s*", +keywords:"unless",contains:[{className:"regexp",contains:[e.BACKSLASH_ESCAPE,c], +illegal:/\n/,variants:[{begin:"/",end:"/[a-z]*"},{begin:/%r\{/,end:/\}[a-z]*/},{ +begin:"%r\\(",end:"\\)[a-z]*"},{begin:"%r!",end:"![a-z]*"},{begin:"%r\\[", +end:"\\][a-z]*"}]}].concat(o,l),relevance:0}].concat(o,l) +;c.contains=m,b.contains=m;const p=[{begin:/^\s*=>/,starts:{end:"$",contains:m} +},{className:"meta.prompt", +begin:"^([>?]>|[\\w#]+\\(\\w+\\):\\d+:\\d+[>*]|(\\w+-)?\\d+\\.\\d+\\.\\d+(p\\d+)?[^\\d][^>]+>)(?=[ ])", +starts:{end:"$",keywords:r,contains:m}}];return l.unshift(o),{name:"Ruby", +aliases:["rb","gemspec","podspec","thor","irb"],keywords:r,illegal:/\/\*/, +contains:[e.SHEBANG({binary:"ruby"})].concat(p).concat(l).concat(m)}}, +grmr_rust:e=>{const n=e.regex,t={className:"title.function.invoke",relevance:0, +begin:n.concat(/\b/,/(?!let|for|while|if|else|match\b)/,e.IDENT_RE,n.lookahead(/\s*\(/)) +},a="([ui](8|16|32|64|128|size)|f(32|64))?",i=["drop ","Copy","Send","Sized","Sync","Drop","Fn","FnMut","FnOnce","ToOwned","Clone","Debug","PartialEq","PartialOrd","Eq","Ord","AsRef","AsMut","Into","From","Default","Iterator","Extend","IntoIterator","DoubleEndedIterator","ExactSizeIterator","SliceConcatExt","ToString","assert!","assert_eq!","bitflags!","bytes!","cfg!","col!","concat!","concat_idents!","debug_assert!","debug_assert_eq!","env!","eprintln!","panic!","file!","format!","format_args!","include_bytes!","include_str!","line!","local_data_key!","module_path!","option_env!","print!","println!","select!","stringify!","try!","unimplemented!","unreachable!","vec!","write!","writeln!","macro_rules!","assert_ne!","debug_assert_ne!"],r=["i8","i16","i32","i64","i128","isize","u8","u16","u32","u64","u128","usize","f32","f64","str","char","bool","Box","Option","Result","String","Vec"] +;return{name:"Rust",aliases:["rs"],keywords:{$pattern:e.IDENT_RE+"!?",type:r, +keyword:["abstract","as","async","await","become","box","break","const","continue","crate","do","dyn","else","enum","extern","false","final","fn","for","if","impl","in","let","loop","macro","match","mod","move","mut","override","priv","pub","ref","return","self","Self","static","struct","super","trait","true","try","type","typeof","unsafe","unsized","use","virtual","where","while","yield"], +literal:["true","false","Some","None","Ok","Err"],built_in:i},illegal:""},t]}}, +grmr_scss:e=>{const n=ie(e),t=le,a=oe,i="@[a-z-]+",r={className:"variable", +begin:"(\\$[a-zA-Z-][a-zA-Z0-9_-]*)\\b",relevance:0};return{name:"SCSS", +case_insensitive:!0,illegal:"[=/|']", +contains:[e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,n.CSS_NUMBER_MODE,{ +className:"selector-id",begin:"#[A-Za-z0-9_-]+",relevance:0},{ +className:"selector-class",begin:"\\.[A-Za-z0-9_-]+",relevance:0 +},n.ATTRIBUTE_SELECTOR_MODE,{className:"selector-tag", +begin:"\\b("+re.join("|")+")\\b",relevance:0},{className:"selector-pseudo", +begin:":("+a.join("|")+")"},{className:"selector-pseudo", +begin:":(:)?("+t.join("|")+")"},r,{begin:/\(/,end:/\)/, +contains:[n.CSS_NUMBER_MODE]},n.CSS_VARIABLE,{className:"attribute", +begin:"\\b("+ce.join("|")+")\\b"},{ +begin:"\\b(whitespace|wait|w-resize|visible|vertical-text|vertical-ideographic|uppercase|upper-roman|upper-alpha|underline|transparent|top|thin|thick|text|text-top|text-bottom|tb-rl|table-header-group|table-footer-group|sw-resize|super|strict|static|square|solid|small-caps|separate|se-resize|scroll|s-resize|rtl|row-resize|ridge|right|repeat|repeat-y|repeat-x|relative|progress|pointer|overline|outside|outset|oblique|nowrap|not-allowed|normal|none|nw-resize|no-repeat|no-drop|newspaper|ne-resize|n-resize|move|middle|medium|ltr|lr-tb|lowercase|lower-roman|lower-alpha|loose|list-item|line|line-through|line-edge|lighter|left|keep-all|justify|italic|inter-word|inter-ideograph|inside|inset|inline|inline-block|inherit|inactive|ideograph-space|ideograph-parenthesis|ideograph-numeric|ideograph-alpha|horizontal|hidden|help|hand|groove|fixed|ellipsis|e-resize|double|dotted|distribute|distribute-space|distribute-letter|distribute-all-lines|disc|disabled|default|decimal|dashed|crosshair|collapse|col-resize|circle|char|center|capitalize|break-word|break-all|bottom|both|bolder|bold|block|bidi-override|below|baseline|auto|always|all-scroll|absolute|table|table-cell)\\b" +},{begin:/:/,end:/[;}{]/,relevance:0, +contains:[n.BLOCK_COMMENT,r,n.HEXCOLOR,n.CSS_NUMBER_MODE,e.QUOTE_STRING_MODE,e.APOS_STRING_MODE,n.IMPORTANT,n.FUNCTION_DISPATCH] +},{begin:"@(page|font-face)",keywords:{$pattern:i,keyword:"@page @font-face"}},{ +begin:"@",end:"[{;]",returnBegin:!0,keywords:{$pattern:/[a-z-]+/, +keyword:"and or not only",attribute:se.join(" ")},contains:[{begin:i, +className:"keyword"},{begin:/[a-z-]+(?=:)/,className:"attribute" +},r,e.QUOTE_STRING_MODE,e.APOS_STRING_MODE,n.HEXCOLOR,n.CSS_NUMBER_MODE] +},n.FUNCTION_DISPATCH]}},grmr_shell:e=>({name:"Shell Session", +aliases:["console","shellsession"],contains:[{className:"meta.prompt", +begin:/^\s{0,3}[/~\w\d[\]()@-]*[>%$#][ ]?/,starts:{end:/[^\\](?=\s*$)/, +subLanguage:"bash"}}]}),grmr_sql:e=>{ +const n=e.regex,t=e.COMMENT("--","$"),a=["true","false","unknown"],i=["bigint","binary","blob","boolean","char","character","clob","date","dec","decfloat","decimal","float","int","integer","interval","nchar","nclob","national","numeric","real","row","smallint","time","timestamp","varchar","varying","varbinary"],r=["abs","acos","array_agg","asin","atan","avg","cast","ceil","ceiling","coalesce","corr","cos","cosh","count","covar_pop","covar_samp","cume_dist","dense_rank","deref","element","exp","extract","first_value","floor","json_array","json_arrayagg","json_exists","json_object","json_objectagg","json_query","json_table","json_table_primitive","json_value","lag","last_value","lead","listagg","ln","log","log10","lower","max","min","mod","nth_value","ntile","nullif","percent_rank","percentile_cont","percentile_disc","position","position_regex","power","rank","regr_avgx","regr_avgy","regr_count","regr_intercept","regr_r2","regr_slope","regr_sxx","regr_sxy","regr_syy","row_number","sin","sinh","sqrt","stddev_pop","stddev_samp","substring","substring_regex","sum","tan","tanh","translate","translate_regex","treat","trim","trim_array","unnest","upper","value_of","var_pop","var_samp","width_bucket"],s=["create table","insert into","primary key","foreign key","not null","alter table","add constraint","grouping sets","on overflow","character set","respect nulls","ignore nulls","nulls first","nulls last","depth first","breadth first"],o=r,l=["abs","acos","all","allocate","alter","and","any","are","array","array_agg","array_max_cardinality","as","asensitive","asin","asymmetric","at","atan","atomic","authorization","avg","begin","begin_frame","begin_partition","between","bigint","binary","blob","boolean","both","by","call","called","cardinality","cascaded","case","cast","ceil","ceiling","char","char_length","character","character_length","check","classifier","clob","close","coalesce","collate","collect","column","commit","condition","connect","constraint","contains","convert","copy","corr","corresponding","cos","cosh","count","covar_pop","covar_samp","create","cross","cube","cume_dist","current","current_catalog","current_date","current_default_transform_group","current_path","current_role","current_row","current_schema","current_time","current_timestamp","current_path","current_role","current_transform_group_for_type","current_user","cursor","cycle","date","day","deallocate","dec","decimal","decfloat","declare","default","define","delete","dense_rank","deref","describe","deterministic","disconnect","distinct","double","drop","dynamic","each","element","else","empty","end","end_frame","end_partition","end-exec","equals","escape","every","except","exec","execute","exists","exp","external","extract","false","fetch","filter","first_value","float","floor","for","foreign","frame_row","free","from","full","function","fusion","get","global","grant","group","grouping","groups","having","hold","hour","identity","in","indicator","initial","inner","inout","insensitive","insert","int","integer","intersect","intersection","interval","into","is","join","json_array","json_arrayagg","json_exists","json_object","json_objectagg","json_query","json_table","json_table_primitive","json_value","lag","language","large","last_value","lateral","lead","leading","left","like","like_regex","listagg","ln","local","localtime","localtimestamp","log","log10","lower","match","match_number","match_recognize","matches","max","member","merge","method","min","minute","mod","modifies","module","month","multiset","national","natural","nchar","nclob","new","no","none","normalize","not","nth_value","ntile","null","nullif","numeric","octet_length","occurrences_regex","of","offset","old","omit","on","one","only","open","or","order","out","outer","over","overlaps","overlay","parameter","partition","pattern","per","percent","percent_rank","percentile_cont","percentile_disc","period","portion","position","position_regex","power","precedes","precision","prepare","primary","procedure","ptf","range","rank","reads","real","recursive","ref","references","referencing","regr_avgx","regr_avgy","regr_count","regr_intercept","regr_r2","regr_slope","regr_sxx","regr_sxy","regr_syy","release","result","return","returns","revoke","right","rollback","rollup","row","row_number","rows","running","savepoint","scope","scroll","search","second","seek","select","sensitive","session_user","set","show","similar","sin","sinh","skip","smallint","some","specific","specifictype","sql","sqlexception","sqlstate","sqlwarning","sqrt","start","static","stddev_pop","stddev_samp","submultiset","subset","substring","substring_regex","succeeds","sum","symmetric","system","system_time","system_user","table","tablesample","tan","tanh","then","time","timestamp","timezone_hour","timezone_minute","to","trailing","translate","translate_regex","translation","treat","trigger","trim","trim_array","true","truncate","uescape","union","unique","unknown","unnest","update","upper","user","using","value","values","value_of","var_pop","var_samp","varbinary","varchar","varying","versioning","when","whenever","where","width_bucket","window","with","within","without","year","add","asc","collation","desc","final","first","last","view"].filter((e=>!r.includes(e))),c={ +begin:n.concat(/\b/,n.either(...o),/\s*\(/),relevance:0,keywords:{built_in:o}} +;return{name:"SQL",case_insensitive:!0,illegal:/[{}]|<\//,keywords:{ +$pattern:/\b[\w\.]+/,keyword:((e,{exceptions:n,when:t}={})=>{const a=t +;return n=n||[],e.map((e=>e.match(/\|\d+$/)||n.includes(e)?e:a(e)?e+"|0":e)) +})(l,{when:e=>e.length<3}),literal:a,type:i, +built_in:["current_catalog","current_date","current_default_transform_group","current_path","current_role","current_schema","current_transform_group_for_type","current_user","session_user","system_time","system_user","current_time","localtime","current_timestamp","localtimestamp"] +},contains:[{begin:n.either(...s),relevance:0,keywords:{$pattern:/[\w\.]+/, +keyword:l.concat(s),literal:a,type:i}},{className:"type", +begin:n.either("double precision","large object","with timezone","without timezone") +},c,{className:"variable",begin:/@[a-z0-9][a-z0-9_]*/},{className:"string", +variants:[{begin:/'/,end:/'/,contains:[{begin:/''/}]}]},{begin:/"/,end:/"/, +contains:[{begin:/""/}]},e.C_NUMBER_MODE,e.C_BLOCK_COMMENT_MODE,t,{ +className:"operator",begin:/[-+*/=%^~]|&&?|\|\|?|!=?|<(?:=>?|<|>)?|>[>=]?/, +relevance:0}]}},grmr_swift:e=>{const n={match:/\s+/,relevance:0 +},t=e.COMMENT("/\\*","\\*/",{contains:["self"]}),a=[e.C_LINE_COMMENT_MODE,t],i={ +match:[/\./,m(...xe,...Me)],className:{2:"keyword"}},r={match:b(/\./,m(...Ae)), +relevance:0},s=Ae.filter((e=>"string"==typeof e)).concat(["_|0"]),o={variants:[{ +className:"keyword", +match:m(...Ae.filter((e=>"string"!=typeof e)).concat(Se).map(ke),...Me)}]},l={ +$pattern:m(/\b\w+/,/#\w+/),keyword:s.concat(Re),literal:Ce},c=[i,r,o],g=[{ +match:b(/\./,m(...De)),relevance:0},{className:"built_in", +match:b(/\b/,m(...De),/(?=\()/)}],u={match:/->/,relevance:0},p=[u,{ +className:"operator",relevance:0,variants:[{match:Be},{match:`\\.(\\.|${Le})+`}] +}],_="([0-9]_*)+",h="([0-9a-fA-F]_*)+",f={className:"number",relevance:0, +variants:[{match:`\\b(${_})(\\.(${_}))?([eE][+-]?(${_}))?\\b`},{ +match:`\\b0x(${h})(\\.(${h}))?([pP][+-]?(${_}))?\\b`},{match:/\b0o([0-7]_*)+\b/ +},{match:/\b0b([01]_*)+\b/}]},E=(e="")=>({className:"subst",variants:[{ +match:b(/\\/,e,/[0\\tnr"']/)},{match:b(/\\/,e,/u\{[0-9a-fA-F]{1,8}\}/)}] +}),y=(e="")=>({className:"subst",match:b(/\\/,e,/[\t ]*(?:[\r\n]|\r\n)/) +}),N=(e="")=>({className:"subst",label:"interpol",begin:b(/\\/,e,/\(/),end:/\)/ +}),w=(e="")=>({begin:b(e,/"""/),end:b(/"""/,e),contains:[E(e),y(e),N(e)] +}),v=(e="")=>({begin:b(e,/"/),end:b(/"/,e),contains:[E(e),N(e)]}),O={ +className:"string", +variants:[w(),w("#"),w("##"),w("###"),v(),v("#"),v("##"),v("###")] +},k=[e.BACKSLASH_ESCAPE,{begin:/\[/,end:/\]/,relevance:0, +contains:[e.BACKSLASH_ESCAPE]}],x={begin:/\/[^\s](?=[^/\n]*\/)/,end:/\//, +contains:k},M=e=>{const n=b(e,/\//),t=b(/\//,e);return{begin:n,end:t, +contains:[...k,{scope:"comment",begin:`#(?!.*${t})`,end:/$/}]}},S={ +scope:"regexp",variants:[M("###"),M("##"),M("#"),x]},A={match:b(/`/,Fe,/`/) +},C=[A,{className:"variable",match:/\$\d+/},{className:"variable", +match:`\\$${ze}+`}],T=[{match:/(@|#(un)?)available/,scope:"keyword",starts:{ +contains:[{begin:/\(/,end:/\)/,keywords:Pe,contains:[...p,f,O]}]}},{ +scope:"keyword",match:b(/@/,m(...je))},{scope:"meta",match:b(/@/,Fe)}],R={ +match:d(/\b[A-Z]/),relevance:0,contains:[{className:"type", +match:b(/(AV|CA|CF|CG|CI|CL|CM|CN|CT|MK|MP|MTK|MTL|NS|SCN|SK|UI|WK|XC)/,ze,"+") +},{className:"type",match:Ue,relevance:0},{match:/[?!]+/,relevance:0},{ +match:/\.\.\./,relevance:0},{match:b(/\s+&\s+/,d(Ue)),relevance:0}]},D={ +begin://,keywords:l,contains:[...a,...c,...T,u,R]};R.contains.push(D) +;const I={begin:/\(/,end:/\)/,relevance:0,keywords:l,contains:["self",{ +match:b(Fe,/\s*:/),keywords:"_|0",relevance:0 +},...a,S,...c,...g,...p,f,O,...C,...T,R]},L={begin://, +keywords:"repeat each",contains:[...a,R]},B={begin:/\(/,end:/\)/,keywords:l, +contains:[{begin:m(d(b(Fe,/\s*:/)),d(b(Fe,/\s+/,Fe,/\s*:/))),end:/:/, +relevance:0,contains:[{className:"keyword",match:/\b_\b/},{className:"params", +match:Fe}]},...a,...c,...p,f,O,...T,R,I],endsParent:!0,illegal:/["']/},$={ +match:[/(func|macro)/,/\s+/,m(A.match,Fe,Be)],className:{1:"keyword", +3:"title.function"},contains:[L,B,n],illegal:[/\[/,/%/]},z={ +match:[/\b(?:subscript|init[?!]?)/,/\s*(?=[<(])/],className:{1:"keyword"}, +contains:[L,B,n],illegal:/\[|%/},F={match:[/operator/,/\s+/,Be],className:{ +1:"keyword",3:"title"}},U={begin:[/precedencegroup/,/\s+/,Ue],className:{ +1:"keyword",3:"title"},contains:[R],keywords:[...Te,...Ce],end:/}/} +;for(const e of O.variants){const n=e.contains.find((e=>"interpol"===e.label)) +;n.keywords=l;const t=[...c,...g,...p,f,O,...C];n.contains=[...t,{begin:/\(/, +end:/\)/,contains:["self",...t]}]}return{name:"Swift",keywords:l, +contains:[...a,$,z,{beginKeywords:"struct protocol class extension enum actor", +end:"\\{",excludeEnd:!0,keywords:l,contains:[e.inherit(e.TITLE_MODE,{ +className:"title.class",begin:/[A-Za-z$_][\u00C0-\u02B80-9A-Za-z$_]*/}),...c] +},F,U,{beginKeywords:"import",end:/$/,contains:[...a],relevance:0 +},S,...c,...g,...p,f,O,...C,...T,R,I]}},grmr_typescript:e=>{ +const n=Oe(e),t=_e,a=["any","void","number","boolean","string","object","never","symbol","bigint","unknown"],i={ +beginKeywords:"namespace",end:/\{/,excludeEnd:!0, +contains:[n.exports.CLASS_REFERENCE]},r={beginKeywords:"interface",end:/\{/, +excludeEnd:!0,keywords:{keyword:"interface extends",built_in:a}, +contains:[n.exports.CLASS_REFERENCE]},s={$pattern:_e, +keyword:he.concat(["type","namespace","interface","public","private","protected","implements","declare","abstract","readonly","enum","override"]), +literal:fe,built_in:ve.concat(a),"variable.language":we},o={className:"meta", +begin:"@"+t},l=(e,n,t)=>{const a=e.contains.findIndex((e=>e.label===n)) +;if(-1===a)throw Error("can not find mode to replace");e.contains.splice(a,1,t)} +;return Object.assign(n.keywords,s), +n.exports.PARAMS_CONTAINS.push(o),n.contains=n.contains.concat([o,i,r]), +l(n,"shebang",e.SHEBANG()),l(n,"use_strict",{className:"meta",relevance:10, +begin:/^\s*['"]use strict['"]/ +}),n.contains.find((e=>"func.def"===e.label)).relevance=0,Object.assign(n,{ +name:"TypeScript",aliases:["ts","tsx","mts","cts"]}),n},grmr_vbnet:e=>{ +const n=e.regex,t=/\d{1,2}\/\d{1,2}\/\d{4}/,a=/\d{4}-\d{1,2}-\d{1,2}/,i=/(\d|1[012])(:\d+){0,2} *(AM|PM)/,r=/\d{1,2}(:\d{1,2}){1,2}/,s={ +className:"literal",variants:[{begin:n.concat(/# */,n.either(a,t),/ *#/)},{ +begin:n.concat(/# */,r,/ *#/)},{begin:n.concat(/# */,i,/ *#/)},{ +begin:n.concat(/# */,n.either(a,t),/ +/,n.either(i,r),/ *#/)}] +},o=e.COMMENT(/'''/,/$/,{contains:[{className:"doctag",begin:/<\/?/,end:/>/}] +}),l=e.COMMENT(null,/$/,{variants:[{begin:/'/},{begin:/([\t ]|^)REM(?=\s)/}]}) +;return{name:"Visual Basic .NET",aliases:["vb"],case_insensitive:!0, +classNameAliases:{label:"symbol"},keywords:{ +keyword:"addhandler alias aggregate ansi as async assembly auto binary by byref byval call case catch class compare const continue custom declare default delegate dim distinct do each equals else elseif end enum erase error event exit explicit finally for friend from function get global goto group handles if implements imports in inherits interface into iterator join key let lib loop me mid module mustinherit mustoverride mybase myclass namespace narrowing new next notinheritable notoverridable of off on operator option optional order overloads overridable overrides paramarray partial preserve private property protected public raiseevent readonly redim removehandler resume return select set shadows shared skip static step stop structure strict sub synclock take text then throw to try unicode until using when where while widening with withevents writeonly yield", +built_in:"addressof and andalso await directcast gettype getxmlnamespace is isfalse isnot istrue like mod nameof new not or orelse trycast typeof xor cbool cbyte cchar cdate cdbl cdec cint clng cobj csbyte cshort csng cstr cuint culng cushort", +type:"boolean byte char date decimal double integer long object sbyte short single string uinteger ulong ushort", +literal:"true false nothing"}, +illegal:"//|\\{|\\}|endif|gosub|variant|wend|^\\$ ",contains:[{ +className:"string",begin:/"(""|[^/n])"C\b/},{className:"string",begin:/"/, +end:/"/,illegal:/\n/,contains:[{begin:/""/}]},s,{className:"number",relevance:0, +variants:[{begin:/\b\d[\d_]*((\.[\d_]+(E[+-]?[\d_]+)?)|(E[+-]?[\d_]+))[RFD@!#]?/ +},{begin:/\b\d[\d_]*((U?[SIL])|[%&])?/},{begin:/&H[\dA-F_]+((U?[SIL])|[%&])?/},{ +begin:/&O[0-7_]+((U?[SIL])|[%&])?/},{begin:/&B[01_]+((U?[SIL])|[%&])?/}]},{ +className:"label",begin:/^\w+:/},o,l,{className:"meta", +begin:/[\t ]*#(const|disable|else|elseif|enable|end|externalsource|if|region)\b/, +end:/$/,keywords:{ +keyword:"const disable else elseif enable end externalsource if region then"}, +contains:[l]}]}},grmr_wasm:e=>{e.regex;const n=e.COMMENT(/\(;/,/;\)/) +;return n.contains.push("self"),{name:"WebAssembly",keywords:{$pattern:/[\w.]+/, +keyword:["anyfunc","block","br","br_if","br_table","call","call_indirect","data","drop","elem","else","end","export","func","global.get","global.set","local.get","local.set","local.tee","get_global","get_local","global","if","import","local","loop","memory","memory.grow","memory.size","module","mut","nop","offset","param","result","return","select","set_global","set_local","start","table","tee_local","then","type","unreachable"] +},contains:[e.COMMENT(/;;/,/$/),n,{match:[/(?:offset|align)/,/\s*/,/=/], +className:{1:"keyword",3:"operator"}},{className:"variable",begin:/\$[\w_]+/},{ +match:/(\((?!;)|\))+/,className:"punctuation",relevance:0},{ +begin:[/(?:func|call|call_indirect)/,/\s+/,/\$[^\s)]+/],className:{1:"keyword", +3:"title.function"}},e.QUOTE_STRING_MODE,{match:/(i32|i64|f32|f64)(?!\.)/, +className:"type"},{className:"keyword", +match:/\b(f32|f64|i32|i64)(?:\.(?:abs|add|and|ceil|clz|const|convert_[su]\/i(?:32|64)|copysign|ctz|demote\/f64|div(?:_[su])?|eqz?|extend_[su]\/i32|floor|ge(?:_[su])?|gt(?:_[su])?|le(?:_[su])?|load(?:(?:8|16|32)_[su])?|lt(?:_[su])?|max|min|mul|nearest|neg?|or|popcnt|promote\/f32|reinterpret\/[fi](?:32|64)|rem_[su]|rot[lr]|shl|shr_[su]|store(?:8|16|32)?|sqrt|sub|trunc(?:_[su]\/f(?:32|64))?|wrap\/i64|xor))\b/ +},{className:"number",relevance:0, +match:/[+-]?\b(?:\d(?:_?\d)*(?:\.\d(?:_?\d)*)?(?:[eE][+-]?\d(?:_?\d)*)?|0x[\da-fA-F](?:_?[\da-fA-F])*(?:\.[\da-fA-F](?:_?[\da-fA-D])*)?(?:[pP][+-]?\d(?:_?\d)*)?)\b|\binf\b|\bnan(?::0x[\da-fA-F](?:_?[\da-fA-D])*)?\b/ +}]}},grmr_xml:e=>{ +const n=e.regex,t=n.concat(/[\p{L}_]/u,n.optional(/[\p{L}0-9_.-]*:/u),/[\p{L}0-9_.-]*/u),a={ +className:"symbol",begin:/&[a-z]+;|&#[0-9]+;|&#x[a-f0-9]+;/},i={begin:/\s/, +contains:[{className:"keyword",begin:/#?[a-z_][a-z1-9_-]+/,illegal:/\n/}] +},r=e.inherit(i,{begin:/\(/,end:/\)/}),s=e.inherit(e.APOS_STRING_MODE,{ +className:"string"}),o=e.inherit(e.QUOTE_STRING_MODE,{className:"string"}),l={ +endsWithParent:!0,illegal:/`]+/}]}]}]};return{ +name:"HTML, XML", +aliases:["html","xhtml","rss","atom","xjb","xsd","xsl","plist","wsf","svg"], +case_insensitive:!0,unicodeRegex:!0,contains:[{className:"meta",begin://,relevance:10,contains:[i,o,s,r,{begin:/\[/,end:/\]/,contains:[{ +className:"meta",begin://,contains:[i,r,o,s]}]}] +},e.COMMENT(//,{relevance:10}),{begin://, +relevance:10},a,{className:"meta",end:/\?>/,variants:[{begin:/<\?xml/, +relevance:10,contains:[o]},{begin:/<\?[a-z][a-z0-9]+/}]},{className:"tag", +begin:/)/,end:/>/,keywords:{name:"style"},contains:[l],starts:{ +end:/<\/style>/,returnEnd:!0,subLanguage:["css","xml"]}},{className:"tag", +begin:/)/,end:/>/,keywords:{name:"script"},contains:[l],starts:{ +end:/<\/script>/,returnEnd:!0,subLanguage:["javascript","handlebars","xml"]}},{ +className:"tag",begin:/<>|<\/>/},{className:"tag", +begin:n.concat(//,/>/,/\s/)))), +end:/\/?>/,contains:[{className:"name",begin:t,relevance:0,starts:l}]},{ +className:"tag",begin:n.concat(/<\//,n.lookahead(n.concat(t,/>/))),contains:[{ +className:"name",begin:t,relevance:0},{begin:/>/,relevance:0,endsParent:!0}]}]} +},grmr_yaml:e=>{ +const n="true false yes no null",t="[\\w#;/?:@&=+$,.~*'()[\\]]+",a={ +className:"string",relevance:0,variants:[{begin:/'/,end:/'/},{begin:/"/,end:/"/ +},{begin:/\S+/}],contains:[e.BACKSLASH_ESCAPE,{className:"template-variable", +variants:[{begin:/\{\{/,end:/\}\}/},{begin:/%\{/,end:/\}/}]}]},i=e.inherit(a,{ +variants:[{begin:/'/,end:/'/},{begin:/"/,end:/"/},{begin:/[^\s,{}[\]]+/}]}),r={ +end:",",endsWithParent:!0,excludeEnd:!0,keywords:n,relevance:0},s={begin:/\{/, +end:/\}/,contains:[r],illegal:"\\n",relevance:0},o={begin:"\\[",end:"\\]", +contains:[r],illegal:"\\n",relevance:0},l=[{className:"attr",variants:[{ +begin:"\\w[\\w :\\/.-]*:(?=[ \t]|$)"},{begin:'"\\w[\\w :\\/.-]*":(?=[ \t]|$)'},{ +begin:"'\\w[\\w :\\/.-]*':(?=[ \t]|$)"}]},{className:"meta",begin:"^---\\s*$", +relevance:10},{className:"string", +begin:"[\\|>]([1-9]?[+-])?[ ]*\\n( +)[^ ][^\\n]*\\n(\\2[^\\n]+\\n?)*"},{ +begin:"<%[%=-]?",end:"[%-]?%>",subLanguage:"ruby",excludeBegin:!0,excludeEnd:!0, +relevance:0},{className:"type",begin:"!\\w+!"+t},{className:"type", +begin:"!<"+t+">"},{className:"type",begin:"!"+t},{className:"type",begin:"!!"+t +},{className:"meta",begin:"&"+e.UNDERSCORE_IDENT_RE+"$"},{className:"meta", +begin:"\\*"+e.UNDERSCORE_IDENT_RE+"$"},{className:"bullet",begin:"-(?=[ ]|$)", +relevance:0},e.HASH_COMMENT_MODE,{beginKeywords:n,keywords:{literal:n}},{ +className:"number", +begin:"\\b[0-9]{4}(-[0-9][0-9]){0,2}([Tt \\t][0-9][0-9]?(:[0-9][0-9]){2})?(\\.[0-9]*)?([ \\t])*(Z|[-+][0-9][0-9]?(:[0-9][0-9])?)?\\b" +},{className:"number",begin:e.C_NUMBER_RE+"\\b",relevance:0},s,o,a],c=[...l] +;return c.pop(),c.push(i),r.contains=c,{name:"YAML",case_insensitive:!0, +aliases:["yml"],contains:l}}});const He=ae;for(const e of Object.keys(Ke)){ +const n=e.replace("grmr_","").replace("_","-");He.registerLanguage(n,Ke[e])} +return He}() +;"object"==typeof exports&&"undefined"!=typeof module&&(module.exports=hljs); \ No newline at end of file diff --git a/packages/coding-agent/src/core/export-html/vendor/marked.min.js b/packages/coding-agent/src/core/export-html/vendor/marked.min.js new file mode 100644 index 00000000..79394fd8 --- /dev/null +++ b/packages/coding-agent/src/core/export-html/vendor/marked.min.js @@ -0,0 +1,6 @@ +/** + * marked v15.0.4 - a markdown parser + * Copyright (c) 2011-2024, Christopher Jeffrey. (MIT Licensed) + * https://github.com/markedjs/marked + */ +!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?t(exports):"function"==typeof define&&define.amd?define(["exports"],t):t((e="undefined"!=typeof globalThis?globalThis:e||self).marked={})}(this,(function(e){"use strict";function t(){return{async:!1,breaks:!1,extensions:null,gfm:!0,hooks:null,pedantic:!1,renderer:null,silent:!1,tokenizer:null,walkTokens:null}}function n(t){e.defaults=t}e.defaults={async:!1,breaks:!1,extensions:null,gfm:!0,hooks:null,pedantic:!1,renderer:null,silent:!1,tokenizer:null,walkTokens:null};const s={exec:()=>null};function r(e,t=""){let n="string"==typeof e?e:e.source;const s={replace:(e,t)=>{let r="string"==typeof t?t:t.source;return r=r.replace(i.caret,"$1"),n=n.replace(e,r),s},getRegex:()=>new RegExp(n,t)};return s}const i={codeRemoveIndent:/^(?: {1,4}| {0,3}\t)/gm,outputLinkReplace:/\\([\[\]])/g,indentCodeCompensation:/^(\s+)(?:```)/,beginningSpace:/^\s+/,endingHash:/#$/,startingSpaceChar:/^ /,endingSpaceChar:/ $/,nonSpaceChar:/[^ ]/,newLineCharGlobal:/\n/g,tabCharGlobal:/\t/g,multipleSpaceGlobal:/\s+/g,blankLine:/^[ \t]*$/,doubleBlankLine:/\n[ \t]*\n[ \t]*$/,blockquoteStart:/^ {0,3}>/,blockquoteSetextReplace:/\n {0,3}((?:=+|-+) *)(?=\n|$)/g,blockquoteSetextReplace2:/^ {0,3}>[ \t]?/gm,listReplaceTabs:/^\t+/,listReplaceNesting:/^ {1,4}(?=( {4})*[^ ])/g,listIsTask:/^\[[ xX]\] /,listReplaceTask:/^\[[ xX]\] +/,anyLine:/\n.*\n/,hrefBrackets:/^<(.*)>$/,tableDelimiter:/[:|]/,tableAlignChars:/^\||\| *$/g,tableRowBlankLine:/\n[ \t]*$/,tableAlignRight:/^ *-+: *$/,tableAlignCenter:/^ *:-+: *$/,tableAlignLeft:/^ *:-+ *$/,startATag:/^/i,startPreScriptTag:/^<(pre|code|kbd|script)(\s|>)/i,endPreScriptTag:/^<\/(pre|code|kbd|script)(\s|>)/i,startAngleBracket:/^$/,pedanticHrefTitle:/^([^'"]*[^\s])\s+(['"])(.*)\2/,unicodeAlphaNumeric:/[\p{L}\p{N}]/u,escapeTest:/[&<>"']/,escapeReplace:/[&<>"']/g,escapeTestNoEncode:/[<>"']|&(?!(#\d{1,7}|#[Xx][a-fA-F0-9]{1,6}|\w+);)/,escapeReplaceNoEncode:/[<>"']|&(?!(#\d{1,7}|#[Xx][a-fA-F0-9]{1,6}|\w+);)/g,unescapeTest:/&(#(?:\d+)|(?:#x[0-9A-Fa-f]+)|(?:\w+));?/gi,caret:/(^|[^\[])\^/g,percentDecode:/%25/g,findPipe:/\|/g,splitPipe:/ \|/,slashPipe:/\\\|/g,carriageReturn:/\r\n|\r/g,spaceLine:/^ +$/gm,notSpaceStart:/^\S*/,endingNewline:/\n$/,listItemRegex:e=>new RegExp(`^( {0,3}${e})((?:[\t ][^\\n]*)?(?:\\n|$))`),nextBulletRegex:e=>new RegExp(`^ {0,${Math.min(3,e-1)}}(?:[*+-]|\\d{1,9}[.)])((?:[ \t][^\\n]*)?(?:\\n|$))`),hrRegex:e=>new RegExp(`^ {0,${Math.min(3,e-1)}}((?:- *){3,}|(?:_ *){3,}|(?:\\* *){3,})(?:\\n+|$)`),fencesBeginRegex:e=>new RegExp(`^ {0,${Math.min(3,e-1)}}(?:\`\`\`|~~~)`),headingBeginRegex:e=>new RegExp(`^ {0,${Math.min(3,e-1)}}#`),htmlBeginRegex:e=>new RegExp(`^ {0,${Math.min(3,e-1)}}<(?:[a-z].*>|!--)`,"i")},l=/^ {0,3}((?:-[\t ]*){3,}|(?:_[ \t]*){3,}|(?:\*[ \t]*){3,})(?:\n+|$)/,o=/(?:[*+-]|\d{1,9}[.)])/,a=r(/^(?!bull |blockCode|fences|blockquote|heading|html)((?:.|\n(?!\s*?\n|bull |blockCode|fences|blockquote|heading|html))+?)\n {0,3}(=+|-+) *(?:\n+|$)/).replace(/bull/g,o).replace(/blockCode/g,/(?: {4}| {0,3}\t)/).replace(/fences/g,/ {0,3}(?:`{3,}|~{3,})/).replace(/blockquote/g,/ {0,3}>/).replace(/heading/g,/ {0,3}#{1,6}/).replace(/html/g,/ {0,3}<[^\n>]+>\n/).getRegex(),c=/^([^\n]+(?:\n(?!hr|heading|lheading|blockquote|fences|list|html|table| +\n)[^\n]+)*)/,h=/(?!\s*\])(?:\\.|[^\[\]\\])+/,p=r(/^ {0,3}\[(label)\]: *(?:\n[ \t]*)?([^<\s][^\s]*|<.*?>)(?:(?: +(?:\n[ \t]*)?| *\n[ \t]*)(title))? *(?:\n+|$)/).replace("label",h).replace("title",/(?:"(?:\\"?|[^"\\])*"|'[^'\n]*(?:\n[^'\n]+)*\n?'|\([^()]*\))/).getRegex(),u=r(/^( {0,3}bull)([ \t][^\n]+?)?(?:\n|$)/).replace(/bull/g,o).getRegex(),g="address|article|aside|base|basefont|blockquote|body|caption|center|col|colgroup|dd|details|dialog|dir|div|dl|dt|fieldset|figcaption|figure|footer|form|frame|frameset|h[1-6]|head|header|hr|html|iframe|legend|li|link|main|menu|menuitem|meta|nav|noframes|ol|optgroup|option|p|param|search|section|summary|table|tbody|td|tfoot|th|thead|title|tr|track|ul",k=/|$))/,f=r("^ {0,3}(?:<(script|pre|style|textarea)[\\s>][\\s\\S]*?(?:[^\\n]*\\n+|$)|comment[^\\n]*(\\n+|$)|<\\?[\\s\\S]*?(?:\\?>\\n*|$)|\\n*|$)|\\n*|$)|)[\\s\\S]*?(?:(?:\\n[ \t]*)+\\n|$)|<(?!script|pre|style|textarea)([a-z][\\w-]*)(?:attribute)*? */?>(?=[ \\t]*(?:\\n|$))[\\s\\S]*?(?:(?:\\n[ \t]*)+\\n|$)|(?=[ \\t]*(?:\\n|$))[\\s\\S]*?(?:(?:\\n[ \t]*)+\\n|$))","i").replace("comment",k).replace("tag",g).replace("attribute",/ +[a-zA-Z:_][\w.:-]*(?: *= *"[^"\n]*"| *= *'[^'\n]*'| *= *[^\s"'=<>`]+)?/).getRegex(),d=r(c).replace("hr",l).replace("heading"," {0,3}#{1,6}(?:\\s|$)").replace("|lheading","").replace("|table","").replace("blockquote"," {0,3}>").replace("fences"," {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list"," {0,3}(?:[*+-]|1[.)]) ").replace("html",")|<(?:script|pre|style|textarea|!--)").replace("tag",g).getRegex(),x={blockquote:r(/^( {0,3}> ?(paragraph|[^\n]*)(?:\n|$))+/).replace("paragraph",d).getRegex(),code:/^((?: {4}| {0,3}\t)[^\n]+(?:\n(?:[ \t]*(?:\n|$))*)?)+/,def:p,fences:/^ {0,3}(`{3,}(?=[^`\n]*(?:\n|$))|~{3,})([^\n]*)(?:\n|$)(?:|([\s\S]*?)(?:\n|$))(?: {0,3}\1[~`]* *(?=\n|$)|$)/,heading:/^ {0,3}(#{1,6})(?=\s|$)(.*)(?:\n+|$)/,hr:l,html:f,lheading:a,list:u,newline:/^(?:[ \t]*(?:\n|$))+/,paragraph:d,table:s,text:/^[^\n]+/},b=r("^ *([^\\n ].*)\\n {0,3}((?:\\| *)?:?-+:? *(?:\\| *:?-+:? *)*(?:\\| *)?)(?:\\n((?:(?! *\\n|hr|heading|blockquote|code|fences|list|html).*(?:\\n|$))*)\\n*|$)").replace("hr",l).replace("heading"," {0,3}#{1,6}(?:\\s|$)").replace("blockquote"," {0,3}>").replace("code","(?: {4}| {0,3}\t)[^\\n]").replace("fences"," {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list"," {0,3}(?:[*+-]|1[.)]) ").replace("html",")|<(?:script|pre|style|textarea|!--)").replace("tag",g).getRegex(),w={...x,table:b,paragraph:r(c).replace("hr",l).replace("heading"," {0,3}#{1,6}(?:\\s|$)").replace("|lheading","").replace("table",b).replace("blockquote"," {0,3}>").replace("fences"," {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list"," {0,3}(?:[*+-]|1[.)]) ").replace("html",")|<(?:script|pre|style|textarea|!--)").replace("tag",g).getRegex()},m={...x,html:r("^ *(?:comment *(?:\\n|\\s*$)|<(tag)[\\s\\S]+? *(?:\\n{2,}|\\s*$)|\\s]*)*?/?> *(?:\\n{2,}|\\s*$))").replace("comment",k).replace(/tag/g,"(?!(?:a|em|strong|small|s|cite|q|dfn|abbr|data|time|code|var|samp|kbd|sub|sup|i|b|u|mark|ruby|rt|rp|bdi|bdo|span|br|wbr|ins|del|img)\\b)\\w+(?!:|[^\\w\\s@]*@)\\b").getRegex(),def:/^ *\[([^\]]+)\]: *]+)>?(?: +(["(][^\n]+[")]))? *(?:\n+|$)/,heading:/^(#{1,6})(.*)(?:\n+|$)/,fences:s,lheading:/^(.+?)\n {0,3}(=+|-+) *(?:\n+|$)/,paragraph:r(c).replace("hr",l).replace("heading"," *#{1,6} *[^\n]").replace("lheading",a).replace("|table","").replace("blockquote"," {0,3}>").replace("|fences","").replace("|list","").replace("|html","").replace("|tag","").getRegex()},y=/^\\([!"#$%&'()*+,\-./:;<=>?@\[\]\\^_`{|}~])/,$=/^( {2,}|\\)\n(?!\s*$)/,R=/[\p{P}\p{S}]/u,S=/[\s\p{P}\p{S}]/u,T=/[^\s\p{P}\p{S}]/u,z=r(/^((?![*_])punctSpace)/,"u").replace(/punctSpace/g,S).getRegex(),A=r(/^(?:\*+(?:((?!\*)punct)|[^\s*]))|^_+(?:((?!_)punct)|([^\s_]))/,"u").replace(/punct/g,R).getRegex(),_=r("^[^_*]*?__[^_*]*?\\*[^_*]*?(?=__)|[^*]+(?=[^*])|(?!\\*)punct(\\*+)(?=[\\s]|$)|notPunctSpace(\\*+)(?!\\*)(?=punctSpace|$)|(?!\\*)punctSpace(\\*+)(?=notPunctSpace)|[\\s](\\*+)(?!\\*)(?=punct)|(?!\\*)punct(\\*+)(?!\\*)(?=punct)|notPunctSpace(\\*+)(?=notPunctSpace)","gu").replace(/notPunctSpace/g,T).replace(/punctSpace/g,S).replace(/punct/g,R).getRegex(),P=r("^[^_*]*?\\*\\*[^_*]*?_[^_*]*?(?=\\*\\*)|[^_]+(?=[^_])|(?!_)punct(_+)(?=[\\s]|$)|notPunctSpace(_+)(?!_)(?=punctSpace|$)|(?!_)punctSpace(_+)(?=notPunctSpace)|[\\s](_+)(?!_)(?=punct)|(?!_)punct(_+)(?!_)(?=punct)","gu").replace(/notPunctSpace/g,T).replace(/punctSpace/g,S).replace(/punct/g,R).getRegex(),I=r(/\\(punct)/,"gu").replace(/punct/g,R).getRegex(),L=r(/^<(scheme:[^\s\x00-\x1f<>]*|email)>/).replace("scheme",/[a-zA-Z][a-zA-Z0-9+.-]{1,31}/).replace("email",/[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+(@)[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+(?![-_])/).getRegex(),B=r(k).replace("(?:--\x3e|$)","--\x3e").getRegex(),C=r("^comment|^|^<[a-zA-Z][\\w-]*(?:attribute)*?\\s*/?>|^<\\?[\\s\\S]*?\\?>|^|^").replace("comment",B).replace("attribute",/\s+[a-zA-Z:_][\w.:-]*(?:\s*=\s*"[^"]*"|\s*=\s*'[^']*'|\s*=\s*[^\s"'=<>`]+)?/).getRegex(),E=/(?:\[(?:\\.|[^\[\]\\])*\]|\\.|`[^`]*`|[^\[\]\\`])*?/,q=r(/^!?\[(label)\]\(\s*(href)(?:\s+(title))?\s*\)/).replace("label",E).replace("href",/<(?:\\.|[^\n<>\\])+>|[^\s\x00-\x1f]*/).replace("title",/"(?:\\"?|[^"\\])*"|'(?:\\'?|[^'\\])*'|\((?:\\\)?|[^)\\])*\)/).getRegex(),Z=r(/^!?\[(label)\]\[(ref)\]/).replace("label",E).replace("ref",h).getRegex(),v=r(/^!?\[(ref)\](?:\[\])?/).replace("ref",h).getRegex(),D={_backpedal:s,anyPunctuation:I,autolink:L,blockSkip:/\[[^[\]]*?\]\((?:\\.|[^\\\(\)]|\((?:\\.|[^\\\(\)])*\))*\)|`[^`]*?`|<[^<>]*?>/g,br:$,code:/^(`+)([^`]|[^`][\s\S]*?[^`])\1(?!`)/,del:s,emStrongLDelim:A,emStrongRDelimAst:_,emStrongRDelimUnd:P,escape:y,link:q,nolink:v,punctuation:z,reflink:Z,reflinkSearch:r("reflink|nolink(?!\\()","g").replace("reflink",Z).replace("nolink",v).getRegex(),tag:C,text:/^(`+|[^`])(?:(?= {2,}\n)|[\s\S]*?(?:(?=[\\":">",'"':""","'":"'"},H=e=>G[e];function X(e,t){if(t){if(i.escapeTest.test(e))return e.replace(i.escapeReplace,H)}else if(i.escapeTestNoEncode.test(e))return e.replace(i.escapeReplaceNoEncode,H);return e}function F(e){try{e=encodeURI(e).replace(i.percentDecode,"%")}catch{return null}return e}function U(e,t){const n=e.replace(i.findPipe,((e,t,n)=>{let s=!1,r=t;for(;--r>=0&&"\\"===n[r];)s=!s;return s?"|":" |"})).split(i.splitPipe);let s=0;if(n[0].trim()||n.shift(),n.length>0&&!n.at(-1)?.trim()&&n.pop(),t)if(n.length>t)n.splice(t);else for(;n.length0)return{type:"space",raw:t[0]}}code(e){const t=this.rules.block.code.exec(e);if(t){const e=t[0].replace(this.rules.other.codeRemoveIndent,"");return{type:"code",raw:t[0],codeBlockStyle:"indented",text:this.options.pedantic?e:J(e,"\n")}}}fences(e){const t=this.rules.block.fences.exec(e);if(t){const e=t[0],n=function(e,t,n){const s=e.match(n.other.indentCodeCompensation);if(null===s)return t;const r=s[1];return t.split("\n").map((e=>{const t=e.match(n.other.beginningSpace);if(null===t)return e;const[s]=t;return s.length>=r.length?e.slice(r.length):e})).join("\n")}(e,t[3]||"",this.rules);return{type:"code",raw:e,lang:t[2]?t[2].trim().replace(this.rules.inline.anyPunctuation,"$1"):t[2],text:n}}}heading(e){const t=this.rules.block.heading.exec(e);if(t){let e=t[2].trim();if(this.rules.other.endingHash.test(e)){const t=J(e,"#");this.options.pedantic?e=t.trim():t&&!this.rules.other.endingSpaceChar.test(t)||(e=t.trim())}return{type:"heading",raw:t[0],depth:t[1].length,text:e,tokens:this.lexer.inline(e)}}}hr(e){const t=this.rules.block.hr.exec(e);if(t)return{type:"hr",raw:J(t[0],"\n")}}blockquote(e){const t=this.rules.block.blockquote.exec(e);if(t){let e=J(t[0],"\n").split("\n"),n="",s="";const r=[];for(;e.length>0;){let t=!1;const i=[];let l;for(l=0;l1,r={type:"list",raw:"",ordered:s,start:s?+n.slice(0,-1):"",loose:!1,items:[]};n=s?`\\d{1,9}\\${n.slice(-1)}`:`\\${n}`,this.options.pedantic&&(n=s?n:"[*+-]");const i=this.rules.other.listItemRegex(n);let l=!1;for(;e;){let n=!1,s="",o="";if(!(t=i.exec(e)))break;if(this.rules.block.hr.test(e))break;s=t[0],e=e.substring(s.length);let a=t[2].split("\n",1)[0].replace(this.rules.other.listReplaceTabs,(e=>" ".repeat(3*e.length))),c=e.split("\n",1)[0],h=!a.trim(),p=0;if(this.options.pedantic?(p=2,o=a.trimStart()):h?p=t[1].length+1:(p=t[2].search(this.rules.other.nonSpaceChar),p=p>4?1:p,o=a.slice(p),p+=t[1].length),h&&this.rules.other.blankLine.test(c)&&(s+=c+"\n",e=e.substring(c.length+1),n=!0),!n){const t=this.rules.other.nextBulletRegex(p),n=this.rules.other.hrRegex(p),r=this.rules.other.fencesBeginRegex(p),i=this.rules.other.headingBeginRegex(p),l=this.rules.other.htmlBeginRegex(p);for(;e;){const u=e.split("\n",1)[0];let g;if(c=u,this.options.pedantic?(c=c.replace(this.rules.other.listReplaceNesting," "),g=c):g=c.replace(this.rules.other.tabCharGlobal," "),r.test(c))break;if(i.test(c))break;if(l.test(c))break;if(t.test(c))break;if(n.test(c))break;if(g.search(this.rules.other.nonSpaceChar)>=p||!c.trim())o+="\n"+g.slice(p);else{if(h)break;if(a.replace(this.rules.other.tabCharGlobal," ").search(this.rules.other.nonSpaceChar)>=4)break;if(r.test(a))break;if(i.test(a))break;if(n.test(a))break;o+="\n"+c}h||c.trim()||(h=!0),s+=u+"\n",e=e.substring(u.length+1),a=g.slice(p)}}r.loose||(l?r.loose=!0:this.rules.other.doubleBlankLine.test(s)&&(l=!0));let u,g=null;this.options.gfm&&(g=this.rules.other.listIsTask.exec(o),g&&(u="[ ] "!==g[0],o=o.replace(this.rules.other.listReplaceTask,""))),r.items.push({type:"list_item",raw:s,task:!!g,checked:u,loose:!1,text:o,tokens:[]}),r.raw+=s}const o=r.items.at(-1);if(!o)return;o.raw=o.raw.trimEnd(),o.text=o.text.trimEnd(),r.raw=r.raw.trimEnd();for(let e=0;e"space"===e.type)),n=t.length>0&&t.some((e=>this.rules.other.anyLine.test(e.raw)));r.loose=n}if(r.loose)for(let e=0;e({text:e,tokens:this.lexer.inline(e),header:!1,align:i.align[t]}))));return i}}lheading(e){const t=this.rules.block.lheading.exec(e);if(t)return{type:"heading",raw:t[0],depth:"="===t[2].charAt(0)?1:2,text:t[1],tokens:this.lexer.inline(t[1])}}paragraph(e){const t=this.rules.block.paragraph.exec(e);if(t){const e="\n"===t[1].charAt(t[1].length-1)?t[1].slice(0,-1):t[1];return{type:"paragraph",raw:t[0],text:e,tokens:this.lexer.inline(e)}}}text(e){const t=this.rules.block.text.exec(e);if(t)return{type:"text",raw:t[0],text:t[0],tokens:this.lexer.inline(t[0])}}escape(e){const t=this.rules.inline.escape.exec(e);if(t)return{type:"escape",raw:t[0],text:t[1]}}tag(e){const t=this.rules.inline.tag.exec(e);if(t)return!this.lexer.state.inLink&&this.rules.other.startATag.test(t[0])?this.lexer.state.inLink=!0:this.lexer.state.inLink&&this.rules.other.endATag.test(t[0])&&(this.lexer.state.inLink=!1),!this.lexer.state.inRawBlock&&this.rules.other.startPreScriptTag.test(t[0])?this.lexer.state.inRawBlock=!0:this.lexer.state.inRawBlock&&this.rules.other.endPreScriptTag.test(t[0])&&(this.lexer.state.inRawBlock=!1),{type:"html",raw:t[0],inLink:this.lexer.state.inLink,inRawBlock:this.lexer.state.inRawBlock,block:!1,text:t[0]}}link(e){const t=this.rules.inline.link.exec(e);if(t){const e=t[2].trim();if(!this.options.pedantic&&this.rules.other.startAngleBracket.test(e)){if(!this.rules.other.endAngleBracket.test(e))return;const t=J(e.slice(0,-1),"\\");if((e.length-t.length)%2==0)return}else{const e=function(e,t){if(-1===e.indexOf(t[1]))return-1;let n=0;for(let s=0;s-1){const n=(0===t[0].indexOf("!")?5:4)+t[1].length+e;t[2]=t[2].substring(0,e),t[0]=t[0].substring(0,n).trim(),t[3]=""}}let n=t[2],s="";if(this.options.pedantic){const e=this.rules.other.pedanticHrefTitle.exec(n);e&&(n=e[1],s=e[3])}else s=t[3]?t[3].slice(1,-1):"";return n=n.trim(),this.rules.other.startAngleBracket.test(n)&&(n=this.options.pedantic&&!this.rules.other.endAngleBracket.test(e)?n.slice(1):n.slice(1,-1)),K(t,{href:n?n.replace(this.rules.inline.anyPunctuation,"$1"):n,title:s?s.replace(this.rules.inline.anyPunctuation,"$1"):s},t[0],this.lexer,this.rules)}}reflink(e,t){let n;if((n=this.rules.inline.reflink.exec(e))||(n=this.rules.inline.nolink.exec(e))){const e=t[(n[2]||n[1]).replace(this.rules.other.multipleSpaceGlobal," ").toLowerCase()];if(!e){const e=n[0].charAt(0);return{type:"text",raw:e,text:e}}return K(n,e,n[0],this.lexer,this.rules)}}emStrong(e,t,n=""){let s=this.rules.inline.emStrongLDelim.exec(e);if(!s)return;if(s[3]&&n.match(this.rules.other.unicodeAlphaNumeric))return;if(!(s[1]||s[2]||"")||!n||this.rules.inline.punctuation.exec(n)){const n=[...s[0]].length-1;let r,i,l=n,o=0;const a="*"===s[0][0]?this.rules.inline.emStrongRDelimAst:this.rules.inline.emStrongRDelimUnd;for(a.lastIndex=0,t=t.slice(-1*e.length+n);null!=(s=a.exec(t));){if(r=s[1]||s[2]||s[3]||s[4]||s[5]||s[6],!r)continue;if(i=[...r].length,s[3]||s[4]){l+=i;continue}if((s[5]||s[6])&&n%3&&!((n+i)%3)){o+=i;continue}if(l-=i,l>0)continue;i=Math.min(i,i+l+o);const t=[...s[0]][0].length,a=e.slice(0,n+s.index+t+i);if(Math.min(n,i)%2){const e=a.slice(1,-1);return{type:"em",raw:a,text:e,tokens:this.lexer.inlineTokens(e)}}const c=a.slice(2,-2);return{type:"strong",raw:a,text:c,tokens:this.lexer.inlineTokens(c)}}}}codespan(e){const t=this.rules.inline.code.exec(e);if(t){let e=t[2].replace(this.rules.other.newLineCharGlobal," ");const n=this.rules.other.nonSpaceChar.test(e),s=this.rules.other.startingSpaceChar.test(e)&&this.rules.other.endingSpaceChar.test(e);return n&&s&&(e=e.substring(1,e.length-1)),{type:"codespan",raw:t[0],text:e}}}br(e){const t=this.rules.inline.br.exec(e);if(t)return{type:"br",raw:t[0]}}del(e){const t=this.rules.inline.del.exec(e);if(t)return{type:"del",raw:t[0],text:t[2],tokens:this.lexer.inlineTokens(t[2])}}autolink(e){const t=this.rules.inline.autolink.exec(e);if(t){let e,n;return"@"===t[2]?(e=t[1],n="mailto:"+e):(e=t[1],n=e),{type:"link",raw:t[0],text:e,href:n,tokens:[{type:"text",raw:e,text:e}]}}}url(e){let t;if(t=this.rules.inline.url.exec(e)){let e,n;if("@"===t[2])e=t[0],n="mailto:"+e;else{let s;do{s=t[0],t[0]=this.rules.inline._backpedal.exec(t[0])?.[0]??""}while(s!==t[0]);e=t[0],n="www."===t[1]?"http://"+t[0]:t[0]}return{type:"link",raw:t[0],text:e,href:n,tokens:[{type:"text",raw:e,text:e}]}}}inlineText(e){const t=this.rules.inline.text.exec(e);if(t){const e=this.lexer.state.inRawBlock;return{type:"text",raw:t[0],text:t[0],escaped:e}}}}class W{tokens;options;state;tokenizer;inlineQueue;constructor(t){this.tokens=[],this.tokens.links=Object.create(null),this.options=t||e.defaults,this.options.tokenizer=this.options.tokenizer||new V,this.tokenizer=this.options.tokenizer,this.tokenizer.options=this.options,this.tokenizer.lexer=this,this.inlineQueue=[],this.state={inLink:!1,inRawBlock:!1,top:!0};const n={other:i,block:j.normal,inline:N.normal};this.options.pedantic?(n.block=j.pedantic,n.inline=N.pedantic):this.options.gfm&&(n.block=j.gfm,this.options.breaks?n.inline=N.breaks:n.inline=N.gfm),this.tokenizer.rules=n}static get rules(){return{block:j,inline:N}}static lex(e,t){return new W(t).lex(e)}static lexInline(e,t){return new W(t).inlineTokens(e)}lex(e){e=e.replace(i.carriageReturn,"\n"),this.blockTokens(e,this.tokens);for(let e=0;e!!(s=n.call({lexer:this},e,t))&&(e=e.substring(s.raw.length),t.push(s),!0))))continue;if(s=this.tokenizer.space(e)){e=e.substring(s.raw.length);const n=t.at(-1);1===s.raw.length&&void 0!==n?n.raw+="\n":t.push(s);continue}if(s=this.tokenizer.code(e)){e=e.substring(s.raw.length);const n=t.at(-1);"paragraph"===n?.type||"text"===n?.type?(n.raw+="\n"+s.raw,n.text+="\n"+s.text,this.inlineQueue.at(-1).src=n.text):t.push(s);continue}if(s=this.tokenizer.fences(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.heading(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.hr(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.blockquote(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.list(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.html(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.def(e)){e=e.substring(s.raw.length);const n=t.at(-1);"paragraph"===n?.type||"text"===n?.type?(n.raw+="\n"+s.raw,n.text+="\n"+s.raw,this.inlineQueue.at(-1).src=n.text):this.tokens.links[s.tag]||(this.tokens.links[s.tag]={href:s.href,title:s.title});continue}if(s=this.tokenizer.table(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.lheading(e)){e=e.substring(s.raw.length),t.push(s);continue}let r=e;if(this.options.extensions?.startBlock){let t=1/0;const n=e.slice(1);let s;this.options.extensions.startBlock.forEach((e=>{s=e.call({lexer:this},n),"number"==typeof s&&s>=0&&(t=Math.min(t,s))})),t<1/0&&t>=0&&(r=e.substring(0,t+1))}if(this.state.top&&(s=this.tokenizer.paragraph(r))){const i=t.at(-1);n&&"paragraph"===i?.type?(i.raw+="\n"+s.raw,i.text+="\n"+s.text,this.inlineQueue.pop(),this.inlineQueue.at(-1).src=i.text):t.push(s),n=r.length!==e.length,e=e.substring(s.raw.length)}else if(s=this.tokenizer.text(e)){e=e.substring(s.raw.length);const n=t.at(-1);"text"===n?.type?(n.raw+="\n"+s.raw,n.text+="\n"+s.text,this.inlineQueue.pop(),this.inlineQueue.at(-1).src=n.text):t.push(s)}else if(e){const t="Infinite loop on byte: "+e.charCodeAt(0);if(this.options.silent){console.error(t);break}throw new Error(t)}}return this.state.top=!0,t}inline(e,t=[]){return this.inlineQueue.push({src:e,tokens:t}),t}inlineTokens(e,t=[]){let n=e,s=null;if(this.tokens.links){const e=Object.keys(this.tokens.links);if(e.length>0)for(;null!=(s=this.tokenizer.rules.inline.reflinkSearch.exec(n));)e.includes(s[0].slice(s[0].lastIndexOf("[")+1,-1))&&(n=n.slice(0,s.index)+"["+"a".repeat(s[0].length-2)+"]"+n.slice(this.tokenizer.rules.inline.reflinkSearch.lastIndex))}for(;null!=(s=this.tokenizer.rules.inline.blockSkip.exec(n));)n=n.slice(0,s.index)+"["+"a".repeat(s[0].length-2)+"]"+n.slice(this.tokenizer.rules.inline.blockSkip.lastIndex);for(;null!=(s=this.tokenizer.rules.inline.anyPunctuation.exec(n));)n=n.slice(0,s.index)+"++"+n.slice(this.tokenizer.rules.inline.anyPunctuation.lastIndex);let r=!1,i="";for(;e;){let s;if(r||(i=""),r=!1,this.options.extensions?.inline?.some((n=>!!(s=n.call({lexer:this},e,t))&&(e=e.substring(s.raw.length),t.push(s),!0))))continue;if(s=this.tokenizer.escape(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.tag(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.link(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.reflink(e,this.tokens.links)){e=e.substring(s.raw.length);const n=t.at(-1);"text"===s.type&&"text"===n?.type?(n.raw+=s.raw,n.text+=s.text):t.push(s);continue}if(s=this.tokenizer.emStrong(e,n,i)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.codespan(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.br(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.del(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.autolink(e)){e=e.substring(s.raw.length),t.push(s);continue}if(!this.state.inLink&&(s=this.tokenizer.url(e))){e=e.substring(s.raw.length),t.push(s);continue}let l=e;if(this.options.extensions?.startInline){let t=1/0;const n=e.slice(1);let s;this.options.extensions.startInline.forEach((e=>{s=e.call({lexer:this},n),"number"==typeof s&&s>=0&&(t=Math.min(t,s))})),t<1/0&&t>=0&&(l=e.substring(0,t+1))}if(s=this.tokenizer.inlineText(l)){e=e.substring(s.raw.length),"_"!==s.raw.slice(-1)&&(i=s.raw.slice(-1)),r=!0;const n=t.at(-1);"text"===n?.type?(n.raw+=s.raw,n.text+=s.text):t.push(s)}else if(e){const t="Infinite loop on byte: "+e.charCodeAt(0);if(this.options.silent){console.error(t);break}throw new Error(t)}}return t}}class Y{options;parser;constructor(t){this.options=t||e.defaults}space(e){return""}code({text:e,lang:t,escaped:n}){const s=(t||"").match(i.notSpaceStart)?.[0],r=e.replace(i.endingNewline,"")+"\n";return s?'
'+(n?r:X(r,!0))+"
\n":"
"+(n?r:X(r,!0))+"
\n"}blockquote({tokens:e}){return`
\n${this.parser.parse(e)}
\n`}html({text:e}){return e}heading({tokens:e,depth:t}){return`${this.parser.parseInline(e)}\n`}hr(e){return"
\n"}list(e){const t=e.ordered,n=e.start;let s="";for(let t=0;t\n"+s+"\n"}listitem(e){let t="";if(e.task){const n=this.checkbox({checked:!!e.checked});e.loose?"paragraph"===e.tokens[0]?.type?(e.tokens[0].text=n+" "+e.tokens[0].text,e.tokens[0].tokens&&e.tokens[0].tokens.length>0&&"text"===e.tokens[0].tokens[0].type&&(e.tokens[0].tokens[0].text=n+" "+X(e.tokens[0].tokens[0].text),e.tokens[0].tokens[0].escaped=!0)):e.tokens.unshift({type:"text",raw:n+" ",text:n+" ",escaped:!0}):t+=n+" "}return t+=this.parser.parse(e.tokens,!!e.loose),`
  • ${t}
  • \n`}checkbox({checked:e}){return"'}paragraph({tokens:e}){return`

    ${this.parser.parseInline(e)}

    \n`}table(e){let t="",n="";for(let t=0;t${s}`),"\n\n"+t+"\n"+s+"
    \n"}tablerow({text:e}){return`\n${e}\n`}tablecell(e){const t=this.parser.parseInline(e.tokens),n=e.header?"th":"td";return(e.align?`<${n} align="${e.align}">`:`<${n}>`)+t+`\n`}strong({tokens:e}){return`${this.parser.parseInline(e)}`}em({tokens:e}){return`${this.parser.parseInline(e)}`}codespan({text:e}){return`${X(e,!0)}`}br(e){return"
    "}del({tokens:e}){return`${this.parser.parseInline(e)}`}link({href:e,title:t,tokens:n}){const s=this.parser.parseInline(n),r=F(e);if(null===r)return s;let i='
    ",i}image({href:e,title:t,text:n}){const s=F(e);if(null===s)return X(n);let r=`${n}{const r=e[s].flat(1/0);n=n.concat(this.walkTokens(r,t))})):e.tokens&&(n=n.concat(this.walkTokens(e.tokens,t)))}}return n}use(...e){const t=this.defaults.extensions||{renderers:{},childTokens:{}};return e.forEach((e=>{const n={...e};if(n.async=this.defaults.async||n.async||!1,e.extensions&&(e.extensions.forEach((e=>{if(!e.name)throw new Error("extension name required");if("renderer"in e){const n=t.renderers[e.name];t.renderers[e.name]=n?function(...t){let s=e.renderer.apply(this,t);return!1===s&&(s=n.apply(this,t)),s}:e.renderer}if("tokenizer"in e){if(!e.level||"block"!==e.level&&"inline"!==e.level)throw new Error("extension level must be 'block' or 'inline'");const n=t[e.level];n?n.unshift(e.tokenizer):t[e.level]=[e.tokenizer],e.start&&("block"===e.level?t.startBlock?t.startBlock.push(e.start):t.startBlock=[e.start]:"inline"===e.level&&(t.startInline?t.startInline.push(e.start):t.startInline=[e.start]))}"childTokens"in e&&e.childTokens&&(t.childTokens[e.name]=e.childTokens)})),n.extensions=t),e.renderer){const t=this.defaults.renderer||new Y(this.defaults);for(const n in e.renderer){if(!(n in t))throw new Error(`renderer '${n}' does not exist`);if(["options","parser"].includes(n))continue;const s=n,r=e.renderer[s],i=t[s];t[s]=(...e)=>{let n=r.apply(t,e);return!1===n&&(n=i.apply(t,e)),n||""}}n.renderer=t}if(e.tokenizer){const t=this.defaults.tokenizer||new V(this.defaults);for(const n in e.tokenizer){if(!(n in t))throw new Error(`tokenizer '${n}' does not exist`);if(["options","rules","lexer"].includes(n))continue;const s=n,r=e.tokenizer[s],i=t[s];t[s]=(...e)=>{let n=r.apply(t,e);return!1===n&&(n=i.apply(t,e)),n}}n.tokenizer=t}if(e.hooks){const t=this.defaults.hooks||new ne;for(const n in e.hooks){if(!(n in t))throw new Error(`hook '${n}' does not exist`);if(["options","block"].includes(n))continue;const s=n,r=e.hooks[s],i=t[s];ne.passThroughHooks.has(n)?t[s]=e=>{if(this.defaults.async)return Promise.resolve(r.call(t,e)).then((e=>i.call(t,e)));const n=r.call(t,e);return i.call(t,n)}:t[s]=(...e)=>{let n=r.apply(t,e);return!1===n&&(n=i.apply(t,e)),n}}n.hooks=t}if(e.walkTokens){const t=this.defaults.walkTokens,s=e.walkTokens;n.walkTokens=function(e){let n=[];return n.push(s.call(this,e)),t&&(n=n.concat(t.call(this,e))),n}}this.defaults={...this.defaults,...n}})),this}setOptions(e){return this.defaults={...this.defaults,...e},this}lexer(e,t){return W.lex(e,t??this.defaults)}parser(e,t){return te.parse(e,t??this.defaults)}parseMarkdown(e){return(t,n)=>{const s={...n},r={...this.defaults,...s},i=this.onError(!!r.silent,!!r.async);if(!0===this.defaults.async&&!1===s.async)return i(new Error("marked(): The async option was set to true by an extension. Remove async: false from the parse options object to return a Promise."));if(null==t)return i(new Error("marked(): input parameter is undefined or null"));if("string"!=typeof t)return i(new Error("marked(): input parameter is of type "+Object.prototype.toString.call(t)+", string expected"));r.hooks&&(r.hooks.options=r,r.hooks.block=e);const l=r.hooks?r.hooks.provideLexer():e?W.lex:W.lexInline,o=r.hooks?r.hooks.provideParser():e?te.parse:te.parseInline;if(r.async)return Promise.resolve(r.hooks?r.hooks.preprocess(t):t).then((e=>l(e,r))).then((e=>r.hooks?r.hooks.processAllTokens(e):e)).then((e=>r.walkTokens?Promise.all(this.walkTokens(e,r.walkTokens)).then((()=>e)):e)).then((e=>o(e,r))).then((e=>r.hooks?r.hooks.postprocess(e):e)).catch(i);try{r.hooks&&(t=r.hooks.preprocess(t));let e=l(t,r);r.hooks&&(e=r.hooks.processAllTokens(e)),r.walkTokens&&this.walkTokens(e,r.walkTokens);let n=o(e,r);return r.hooks&&(n=r.hooks.postprocess(n)),n}catch(e){return i(e)}}}onError(e,t){return n=>{if(n.message+="\nPlease report this to https://github.com/markedjs/marked.",e){const e="

    An error occurred:

    "+X(n.message+"",!0)+"
    ";return t?Promise.resolve(e):e}if(t)return Promise.reject(n);throw n}}}const re=new se;function ie(e,t){return re.parse(e,t)}ie.options=ie.setOptions=function(e){return re.setOptions(e),ie.defaults=re.defaults,n(ie.defaults),ie},ie.getDefaults=t,ie.defaults=e.defaults,ie.use=function(...e){return re.use(...e),ie.defaults=re.defaults,n(ie.defaults),ie},ie.walkTokens=function(e,t){return re.walkTokens(e,t)},ie.parseInline=re.parseInline,ie.Parser=te,ie.parser=te.parse,ie.Renderer=Y,ie.TextRenderer=ee,ie.Lexer=W,ie.lexer=W.lex,ie.Tokenizer=V,ie.Hooks=ne,ie.parse=ie;const le=ie.options,oe=ie.setOptions,ae=ie.use,ce=ie.walkTokens,he=ie.parseInline,pe=ie,ue=te.parse,ge=W.lex;e.Hooks=ne,e.Lexer=W,e.Marked=se,e.Parser=te,e.Renderer=Y,e.TextRenderer=ee,e.Tokenizer=V,e.getDefaults=t,e.lexer=ge,e.marked=ie,e.options=le,e.parse=pe,e.parseInline=he,e.parser=ue,e.setOptions=oe,e.use=ae,e.walkTokens=ce})); diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index 9b67d748..a81afecc 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -17,7 +17,7 @@ import { CONFIG_DIR_NAME, getAgentDir, getModelsPath, VERSION } from "./config.j import type { AgentSession } from "./core/agent-session.js"; import type { LoadedCustomTool } from "./core/custom-tools/index.js"; -import { exportFromFile } from "./core/export-html.js"; +import { exportFromFile } from "./core/export-html/index.js"; import type { HookUIContext } from "./core/index.js"; import type { ModelRegistry } from "./core/model-registry.js"; import { resolveModelScope, type ScopedModel } from "./core/model-resolver.js"; diff --git a/packages/coding-agent/src/modes/interactive/theme/theme.ts b/packages/coding-agent/src/modes/interactive/theme/theme.ts index 0121e199..84b9d02e 100644 --- a/packages/coding-agent/src/modes/interactive/theme/theme.ts +++ b/packages/coding-agent/src/modes/interactive/theme/theme.ts @@ -652,6 +652,91 @@ export function stopThemeWatcher(): void { } } +// ============================================================================ +// HTML Export Helpers +// ============================================================================ + +/** + * Convert a 256-color index to hex string. + * Indices 0-15: basic colors (approximate) + * Indices 16-231: 6x6x6 color cube + * Indices 232-255: grayscale ramp + */ +function ansi256ToHex(index: number): string { + // Basic colors (0-15) - approximate common terminal values + const basicColors = [ + "#000000", + "#800000", + "#008000", + "#808000", + "#000080", + "#800080", + "#008080", + "#c0c0c0", + "#808080", + "#ff0000", + "#00ff00", + "#ffff00", + "#0000ff", + "#ff00ff", + "#00ffff", + "#ffffff", + ]; + if (index < 16) { + return basicColors[index]; + } + + // Color cube (16-231): 6x6x6 = 216 colors + if (index < 232) { + const cubeIndex = index - 16; + const r = Math.floor(cubeIndex / 36); + const g = Math.floor((cubeIndex % 36) / 6); + const b = cubeIndex % 6; + const toHex = (n: number) => (n === 0 ? 0 : 55 + n * 40).toString(16).padStart(2, "0"); + return `#${toHex(r)}${toHex(g)}${toHex(b)}`; + } + + // Grayscale (232-255): 24 shades + const gray = 8 + (index - 232) * 10; + const grayHex = gray.toString(16).padStart(2, "0"); + return `#${grayHex}${grayHex}${grayHex}`; +} + +/** + * Get resolved theme colors as CSS-compatible hex strings. + * Used by HTML export to generate CSS custom properties. + */ +export function getResolvedThemeColors(themeName?: string): Record { + const name = themeName ?? getDefaultTheme(); + const isLight = name === "light"; + const themeJson = loadThemeJson(name); + const resolved = resolveThemeColors(themeJson.colors, themeJson.vars); + + // Default text color for empty values (terminal uses default fg color) + const defaultText = isLight ? "#000000" : "#e5e5e7"; + + const cssColors: Record = {}; + for (const [key, value] of Object.entries(resolved)) { + if (typeof value === "number") { + cssColors[key] = ansi256ToHex(value); + } else if (value === "") { + // Empty means default terminal color - use sensible fallback for HTML + cssColors[key] = defaultText; + } else { + cssColors[key] = value; + } + } + return cssColors; +} + +/** + * Check if a theme is a "light" theme (for CSS that needs light/dark variants). + */ +export function isLightTheme(themeName?: string): boolean { + // Currently just check the name - could be extended to analyze colors + return themeName === "light"; +} + // ============================================================================ // TUI Helpers // ============================================================================ From f53cabe1e38ef1abbb95caf2e8433942b1e1c6d4 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Thu, 1 Jan 2026 03:43:47 +0100 Subject: [PATCH 048/124] Improve export-html styling with derived export colors - Add deriveExportColors() to compute page/card/info backgrounds from userMessageBg - Use luminance detection to adapt colors for light/dark themes - Use info-bg for model-change, compaction, system-prompt sections - Use selectedBg for hover states and summary backgrounds - Add scroll-to-message with highlight when clicking tree nodes - Fix mobile overlay to close sidebar on click - Wider sidebar (400px) with search and filter controls --- .../src/core/export-html/index.ts | 85 ++++++++++++++++++- .../src/core/export-html/template.html | 11 +-- 2 files changed, 87 insertions(+), 9 deletions(-) diff --git a/packages/coding-agent/src/core/export-html/index.ts b/packages/coding-agent/src/core/export-html/index.ts index bc28e177..66fe9a75 100644 --- a/packages/coding-agent/src/core/export-html/index.ts +++ b/packages/coding-agent/src/core/export-html/index.ts @@ -2,7 +2,7 @@ import type { AgentState } from "@mariozechner/pi-agent-core"; import { existsSync, readFileSync, writeFileSync } from "fs"; import { basename, join } from "path"; import { APP_NAME, getExportTemplateDir, VERSION } from "../../config.js"; -import { getResolvedThemeColors, isLightTheme } from "../../modes/interactive/theme/theme.js"; +import { getResolvedThemeColors } from "../../modes/interactive/theme/theme.js"; import { SessionManager } from "../session-manager.js"; export interface ExportOptions { @@ -10,6 +10,72 @@ export interface ExportOptions { themeName?: string; } +/** Parse a color string to RGB values. Supports hex (#RRGGBB) and rgb(r,g,b) formats. */ +function parseColor(color: string): { r: number; g: number; b: number } | undefined { + const hexMatch = color.match(/^#([0-9a-fA-F]{2})([0-9a-fA-F]{2})([0-9a-fA-F]{2})$/); + if (hexMatch) { + return { + r: Number.parseInt(hexMatch[1], 16), + g: Number.parseInt(hexMatch[2], 16), + b: Number.parseInt(hexMatch[3], 16), + }; + } + const rgbMatch = color.match(/^rgb\s*\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)$/); + if (rgbMatch) { + return { + r: Number.parseInt(rgbMatch[1], 10), + g: Number.parseInt(rgbMatch[2], 10), + b: Number.parseInt(rgbMatch[3], 10), + }; + } + return undefined; +} + +/** Calculate relative luminance of a color (0-1, higher = lighter). */ +function getLuminance(r: number, g: number, b: number): number { + const toLinear = (c: number) => { + const s = c / 255; + return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4; + }; + return 0.2126 * toLinear(r) + 0.7152 * toLinear(g) + 0.0722 * toLinear(b); +} + +/** Adjust color brightness. Factor > 1 lightens, < 1 darkens. */ +function adjustBrightness(color: string, factor: number): string { + const parsed = parseColor(color); + if (!parsed) return color; + const adjust = (c: number) => Math.min(255, Math.max(0, Math.round(c * factor))); + return `rgb(${adjust(parsed.r)}, ${adjust(parsed.g)}, ${adjust(parsed.b)})`; +} + +/** Derive export background colors from a base color (e.g., userMessageBg). */ +function deriveExportColors(baseColor: string): { pageBg: string; cardBg: string; infoBg: string } { + const parsed = parseColor(baseColor); + if (!parsed) { + return { + pageBg: "rgb(24, 24, 30)", + cardBg: "rgb(30, 30, 36)", + infoBg: "rgb(60, 55, 40)", + }; + } + + const luminance = getLuminance(parsed.r, parsed.g, parsed.b); + const isLight = luminance > 0.5; + + if (isLight) { + return { + pageBg: adjustBrightness(baseColor, 0.96), + cardBg: baseColor, + infoBg: `rgb(${Math.min(255, parsed.r + 10)}, ${Math.min(255, parsed.g + 5)}, ${Math.max(0, parsed.b - 20)})`, + }; + } + return { + pageBg: adjustBrightness(baseColor, 0.7), + cardBg: adjustBrightness(baseColor, 0.85), + infoBg: `rgb(${Math.min(255, parsed.r + 20)}, ${Math.min(255, parsed.g + 15)}, ${parsed.b})`, + }; +} + /** * Generate CSS custom property declarations from theme colors. */ @@ -19,6 +85,14 @@ function generateThemeVars(themeName?: string): string { for (const [key, value] of Object.entries(colors)) { lines.push(`--${key}: ${value};`); } + + // Add derived export colors + const userMessageBg = colors.userMessageBg || "#343541"; + const exportColors = deriveExportColors(userMessageBg); + lines.push(`--exportPageBg: ${exportColors.pageBg};`); + lines.push(`--exportCardBg: ${exportColors.cardBg};`); + lines.push(`--exportInfoBg: ${exportColors.infoBg};`); + return lines.join("\n "); } @@ -40,9 +114,11 @@ function generateHtml(sessionData: SessionData, themeName?: string): string { const hljsJs = readFileSync(join(templateDir, "vendor", "highlight.min.js"), "utf-8"); const themeVars = generateThemeVars(themeName); - const light = isLightTheme(themeName); - const bodyBg = light ? "#f8f8f8" : "#18181e"; - const containerBg = light ? "#ffffff" : "#1e1e24"; + const colors = getResolvedThemeColors(themeName); + const exportColors = deriveExportColors(colors.userMessageBg || "#343541"); + const bodyBg = exportColors.pageBg; + const containerBg = exportColors.cardBg; + const infoBg = exportColors.infoBg; const title = `Session ${sessionData.header?.id ?? "export"} - ${APP_NAME}`; @@ -54,6 +130,7 @@ function generateHtml(sessionData: SessionData, themeName?: string): string { .replace("{{THEME_VARS}}", themeVars) .replace("{{BODY_BG}}", bodyBg) .replace("{{CONTAINER_BG}}", containerBg) + .replace("{{INFO_BG}}", infoBg) .replace("{{SESSION_DATA}}", sessionDataBase64) .replace("{{MARKED_JS}}", markedJs) .replace("{{HIGHLIGHT_JS}}", hljsJs) diff --git a/packages/coding-agent/src/core/export-html/template.html b/packages/coding-agent/src/core/export-html/template.html index 12ffd124..beb411a6 100644 --- a/packages/coding-agent/src/core/export-html/template.html +++ b/packages/coding-agent/src/core/export-html/template.html @@ -9,6 +9,7 @@ {{THEME_VARS}} --body-bg: {{BODY_BG}}; --container-bg: {{CONTAINER_BG}}; + --info-bg: {{INFO_BG}}; } * { margin: 0; padding: 0; box-sizing: border-box; } @@ -366,7 +367,7 @@ /* Model change */ .model-change { padding: 8px 16px; - background: var(--toolPendingBg); + background: var(--info-bg); border-radius: 4px; color: var(--dim); font-size: 11px; @@ -379,7 +380,7 @@ /* Compaction */ .compaction { - background: var(--customMessageBg); + background: var(--info-bg); border-radius: 4px; overflow: hidden; } @@ -393,7 +394,7 @@ } .compaction-header:hover { - background: rgba(128, 128, 128, 0.1); + background: var(--selectedBg); } .compaction-toggle { @@ -421,7 +422,7 @@ } .compaction-summary { - background: rgba(0, 0, 0, 0.1); + background: var(--selectedBg); border-radius: 4px; padding: 12px; white-space: pre-wrap; @@ -429,7 +430,7 @@ /* System prompt */ .system-prompt { - background: var(--customMessageBg); + background: var(--info-bg); padding: 12px 16px; border-radius: 4px; margin-bottom: 16px; From c8c7e0fba42c5f0c21aade2f5a3d3371f2ec6706 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Thu, 1 Jan 2026 03:46:08 +0100 Subject: [PATCH 049/124] Polish export-html tree styling - Add subtle border between sidebar and content - Use selectedBg for tree node hover state - Add left border accent for active and in-path nodes --- .../coding-agent/src/core/export-html/template.html | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/coding-agent/src/core/export-html/template.html b/packages/coding-agent/src/core/export-html/template.html index beb411a6..92351c6e 100644 --- a/packages/coding-agent/src/core/export-html/template.html +++ b/packages/coding-agent/src/core/export-html/template.html @@ -37,6 +37,7 @@ position: sticky; top: 0; height: 100vh; + border-right: 1px solid var(--dim); } .sidebar-header { @@ -110,17 +111,24 @@ } .tree-node:hover { - background: rgba(128, 128, 128, 0.15); + background: var(--selectedBg); } .tree-node.active { - background: rgba(128, 128, 128, 0.2); + background: var(--selectedBg); + border-left: 2px solid var(--accent); + padding-left: 6px; } .tree-node.active .tree-content { font-weight: bold; } + .tree-node.in-path { + border-left: 2px solid var(--dim); + padding-left: 6px; + } + .tree-prefix { color: var(--dim); flex-shrink: 0; From 55fd8d9fed2f20ffa4f7a51f5e7e5f38b45f2de9 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Thu, 1 Jan 2026 12:41:58 +0100 Subject: [PATCH 050/124] Add image support and mobile UX improvements to export-html - Display images from read tool results (base64 encoded) - Add ellipsis for truncated tree entries - Make mobile hamburger button more subtle - Add X close button in sidebar header on mobile - Hide hamburger when sidebar is open --- .../src/core/export-html/template.html | 90 +++++++++++++++++-- 1 file changed, 82 insertions(+), 8 deletions(-) diff --git a/packages/coding-agent/src/core/export-html/template.html b/packages/coding-agent/src/core/export-html/template.html index 92351c6e..f72759f4 100644 --- a/packages/coding-agent/src/core/export-html/template.html +++ b/packages/coding-agent/src/core/export-html/template.html @@ -95,6 +95,24 @@ 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; @@ -142,6 +160,8 @@ .tree-content { color: var(--text); + overflow: hidden; + text-overflow: ellipsis; } .tree-role-user { @@ -355,6 +375,17 @@ display: block; } + .tool-images { + margin-top: 12px; + } + + .tool-image { + max-width: 100%; + max-height: 500px; + border-radius: 4px; + margin: 4px 0; + } + .expand-hint { color: var(--borderAccent); font-style: italic; @@ -651,15 +682,23 @@ top: 10px; left: 10px; z-index: 100; - background: var(--accent); - color: var(--body-bg); - border: none; - padding: 8px 12px; + background: var(--container-bg); + color: var(--muted); + border: 1px solid var(--dim); + padding: 6px 10px; border-radius: 4px; cursor: pointer; - font-size: 16px; + font-size: 14px; + opacity: 0.8; } + #sidebar-toggle:hover { + opacity: 1; + color: var(--text); + } + + + #sidebar-overlay { display: none; position: fixed; @@ -695,6 +734,10 @@ display: block; } + .sidebar-close { + display: block; + } + #content { padding: 60px 16px 24px; } @@ -732,6 +775,7 @@ +
    @@ -1355,6 +1399,22 @@ return textBlocks.map(c => c.text).join('\n'); }; + const getResultImages = () => { + if (!result) return []; + return result.content.filter(c => c.type === 'image'); + }; + + const renderResultImages = () => { + const images = getResultImages(); + if (images.length === 0) return ''; + let html = '
    '; + for (const img of images) { + html += ``; + } + html += '
    '; + return html; + }; + let html = `
    `; const args = call.arguments || {}; @@ -1388,6 +1448,11 @@ html += `
    read ${pathHtml}
    `; if (result) { + // Check for images first (e.g., reading image files) + const images = getResultImages(); + if (images.length > 0) { + html += renderResultImages(); + } const output = getResultText(); if (output) { html += formatExpandableOutput(output, 10, lang); @@ -1706,16 +1771,25 @@ const sidebar = document.getElementById('sidebar'); const overlay = document.getElementById('sidebar-overlay'); + const sidebarToggle = document.getElementById('sidebar-toggle'); + document.getElementById('sidebar-toggle').addEventListener('click', () => { sidebar.classList.toggle('open'); overlay.classList.toggle('open'); + sidebarToggle.style.display = 'none'; }); - // Close sidebar when clicking overlay - overlay.addEventListener('click', () => { + const closeSidebar = () => { sidebar.classList.remove('open'); overlay.classList.remove('open'); - }); + sidebarToggle.style.display = ''; + }; + + // Close sidebar when clicking overlay + overlay.addEventListener('click', closeSidebar); + + // Close sidebar when clicking close button + document.getElementById('sidebar-close').addEventListener('click', closeSidebar); // Keyboard shortcut: Escape to reset to leaf document.addEventListener('keydown', (e) => { From fcb700701e56beb2eb7d9776d2d1114b96de22c0 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Thu, 1 Jan 2026 13:24:01 +0100 Subject: [PATCH 051/124] Fix export-html styling and behavior - Fix UTF-8 decoding with TextDecoder for base64 session data - Use toolOutput color for expand hints (not borderAccent) - Remove '- click to expand' text to match TUI - Ctrl+O toggles expanded state (not visibility) on tool outputs - Edit diffs always visible (not affected by Ctrl+O) - Remove Ctrl+F override to allow browser search - Replace tabs with 3 spaces in all tool outputs - Fix syntax highlighting default color to use --text - Add more hljs token selectors (property, punctuation, operator) - Compaction uses customMessageBg/Label/Text colors - Model change uses dim color without background - Scroll to end on initial load - Pointer cursor on expandable elements --- .../src/core/export-html/template.html | 180 +++++++++++------- 1 file changed, 112 insertions(+), 68 deletions(-) diff --git a/packages/coding-agent/src/core/export-html/template.html b/packages/coding-agent/src/core/export-html/template.html index f72759f4..4d6ba542 100644 --- a/packages/coding-agent/src/core/export-html/template.html +++ b/packages/coding-agent/src/core/export-html/template.html @@ -214,6 +214,14 @@ max-width: 800px; } + /* Help bar */ + .help-bar { + font-size: 11px; + color: var(--dim); + margin-bottom: 16px; + opacity: 0.7; + } + /* Header */ .header { background: var(--container-bg); @@ -278,6 +286,10 @@ padding: 0; } + .assistant-message > .message-timestamp { + padding-left: 16px; + } + .assistant-text { padding: 12px 16px; } @@ -289,6 +301,13 @@ white-space: pre-wrap; } + .thinking-collapsed { + display: none; + padding: 12px 16px; + color: var(--thinkingText); + font-style: italic; + } + /* Tool execution */ .tool-execution { padding: 12px 16px; @@ -305,7 +324,7 @@ } .tool-path { - color: var(--borderAccent); + color: var(--accent); word-break: break-all; } @@ -353,6 +372,7 @@ .tool-output code { padding: 0; background: none; + color: var(--text); } .tool-output.expandable { @@ -387,9 +407,7 @@ } .expand-hint { - color: var(--borderAccent); - font-style: italic; - margin-top: 4px; + color: var(--toolOutput); } /* Diff */ @@ -397,6 +415,7 @@ margin-top: 12px; font-size: 11px; overflow-x: auto; + white-space: pre; } .diff-added { color: var(--toolDiffAdded); } @@ -406,8 +425,6 @@ /* Model change */ .model-change { padding: 8px 16px; - background: var(--info-bg); - border-radius: 4px; color: var(--dim); font-size: 11px; } @@ -417,59 +434,41 @@ font-weight: bold; } - /* Compaction */ + /* Compaction / Branch Summary - matches customMessage colors from TUI */ .compaction { - background: var(--info-bg); + background: var(--customMessageBg); border-radius: 4px; - overflow: hidden; - } - - .compaction-header { padding: 12px 16px; cursor: pointer; - display: flex; - align-items: center; - gap: 8px; } - .compaction-header:hover { - background: var(--selectedBg); - } - - .compaction-toggle { - color: var(--borderAccent); - font-size: 10px; - transition: transform 0.2s; - } - - .compaction.expanded .compaction-toggle { - transform: rotate(90deg); - } - - .compaction-title { - color: var(--text); + .compaction-label { + color: var(--customMessageLabel); font-weight: bold; } + .compaction-collapsed { + color: var(--customMessageText); + } + .compaction-content { display: none; - padding: 0 16px 16px; + color: var(--customMessageText); + white-space: pre-wrap; + margin-top: 8px; + } + + .compaction.expanded .compaction-collapsed { + display: none; } .compaction.expanded .compaction-content { display: block; } - .compaction-summary { - background: var(--selectedBg); - border-radius: 4px; - padding: 12px; - white-space: pre-wrap; - } - /* System prompt */ .system-prompt { - background: var(--info-bg); + background: var(--customMessageBg); padding: 12px 16px; border-radius: 4px; margin-bottom: 16px; @@ -477,12 +476,12 @@ .system-prompt-header { font-weight: bold; - color: var(--warning); + color: var(--customMessageLabel); margin-bottom: 8px; } .system-prompt-content { - color: var(--dim); + color: var(--customMessageText); white-space: pre-wrap; word-wrap: break-word; font-size: 11px; @@ -656,15 +655,17 @@ } /* Syntax highlighting */ - .hljs { background: transparent; } + .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-title, .hljs-section, .hljs-name { color: var(--syntaxFunction); } .hljs-type, .hljs-class, .hljs-built_in { color: var(--syntaxType); } - .hljs-attr, .hljs-variable, .hljs-params { color: var(--syntaxVariable); } + .hljs-attr, .hljs-variable, .hljs-params, .hljs-property { color: var(--syntaxVariable); } .hljs-meta { color: var(--syntaxKeyword); } + .hljs-punctuation, .hljs-operator { color: var(--syntaxOperator); } + .hljs-subst { color: var(--text); } /* Footer */ .footer { @@ -783,6 +784,7 @@
    +
    Ctrl+T toggle thinking · Ctrl+O toggle tools · Esc reset
    '; out += '
    '; for (const line of lines) { @@ -1493,13 +1504,8 @@ const diffLines = result.details.diff.split('\n'); html += '
    '; for (const line of diffLines) { - if (line.startsWith('+')) { - html += `
    ${escapeHtml(line)}
    `; - } else if (line.startsWith('-')) { - html += `
    ${escapeHtml(line)}
    `; - } else { - html += `
    ${escapeHtml(line)}
    `; - } + const cls = line.match(/^\+/) ? 'diff-added' : line.match(/^-/) ? 'diff-removed' : 'diff-context'; + html += `
    ${escapeHtml(replaceTabs(line))}
    `; } html += '
    '; } @@ -1570,7 +1576,10 @@ if (block.type === 'text' && block.text.trim()) { html += `
    ${renderMarkdown(block.text)}
    `; } else if (block.type === 'thinking' && block.thinking.trim()) { + html += `
    `; html += `
    ${escapeHtml(block.thinking)}
    `; + html += `
    Thinking ...
    `; + html += `
    `; } } @@ -1617,14 +1626,10 @@ } if (entry.type === 'compaction') { - return `
    -
    - - Context compacted from ${entry.tokensBefore.toLocaleString()} tokens -
    -
    -
    ${escapeHtml(entry.summary)}
    -
    + return `
    +
    [compaction]
    +
    Compacted from ${entry.tokensBefore.toLocaleString()} tokens (ctrl+o to expand)
    +
    Compacted from ${entry.tokensBefore.toLocaleString()} tokens\n\n${escapeHtml(entry.summary)}
    `; } @@ -1738,7 +1743,8 @@ if (scrollToTarget) { const targetEl = document.getElementById(`entry-${targetId}`); if (targetEl) { - targetEl.scrollIntoView({ behavior: 'smooth', block: 'center' }); + // Use 'end' to ensure we scroll far enough to see the target + targetEl.scrollIntoView({ behavior: 'smooth', block: 'end' }); // Brief highlight targetEl.style.outline = '2px solid var(--accent)'; setTimeout(() => { targetEl.style.outline = ''; }, 1500); @@ -1791,18 +1797,56 @@ // Close sidebar when clicking close button document.getElementById('sidebar-close').addEventListener('click', closeSidebar); - // Keyboard shortcut: Escape to reset to leaf + // Track toggle states + let thinkingExpanded = true; + let toolOutputsExpanded = true; + + const toggleThinking = () => { + thinkingExpanded = !thinkingExpanded; + document.querySelectorAll('.thinking-text').forEach(el => { + el.style.display = thinkingExpanded ? '' : 'none'; + }); + document.querySelectorAll('.thinking-collapsed').forEach(el => { + el.style.display = thinkingExpanded ? 'none' : ''; + }); + }; + + const toggleToolOutputs = () => { + toolOutputsExpanded = !toolOutputsExpanded; + // Toggle expanded state on expandable tool outputs + document.querySelectorAll('.tool-output.expandable').forEach(el => { + if (toolOutputsExpanded) { + el.classList.add('expanded'); + } else { + el.classList.remove('expanded'); + } + }); + // Toggle compaction/branch-summary expanded state + document.querySelectorAll('.compaction').forEach(el => { + if (toolOutputsExpanded) { + el.classList.add('expanded'); + } else { + el.classList.remove('expanded'); + } + }); + }; + + // Keyboard shortcuts document.addEventListener('keydown', (e) => { if (e.key === 'Escape') { searchInput.value = ''; searchQuery = ''; navigateTo(leafId); } - // Focus search on Ctrl/Cmd+F - if ((e.ctrlKey || e.metaKey) && e.key === 'f') { + // Toggle thinking blocks on Ctrl+T + if (e.ctrlKey && e.key === 't') { e.preventDefault(); - searchInput.focus(); - searchInput.select(); + toggleThinking(); + } + // Toggle tool outputs on Ctrl+O + if (e.ctrlKey && e.key === 'o') { + e.preventDefault(); + toggleToolOutputs(); } }); From f16fa1efb27a8d1e5931fb25bb2799b69294ba5d Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Thu, 1 Jan 2026 14:28:31 +0100 Subject: [PATCH 052/124] Fix export-html spacing to use line-height based system - Add --line-height CSS variable (18px = 12px font * 1.5) - Convert all padding/margin to use var(--line-height) - Remove fractional values for terminal-like consistency - Fix gap between consecutive tool executions - Remove extra padding from error-text and model-change - Fix thinking toggle to show 'Thinking ...' when collapsed - Remove top padding after timestamps in assistant messages - Remove bottom padding from assistant-text --- .../src/core/export-html/template.html | 114 ++++++++++-------- 1 file changed, 65 insertions(+), 49 deletions(-) diff --git a/packages/coding-agent/src/core/export-html/template.html b/packages/coding-agent/src/core/export-html/template.html index 4d6ba542..c604582d 100644 --- a/packages/coding-agent/src/core/export-html/template.html +++ b/packages/coding-agent/src/core/export-html/template.html @@ -14,10 +14,14 @@ * { margin: 0; padding: 0; box-sizing: border-box; } + :root { + --line-height: 18px; /* 12px font * 1.5 */ + } + body { font-family: ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, Consolas, 'DejaVu Sans Mono', monospace; font-size: 12px; - line-height: 1.6; + line-height: var(--line-height); color: var(--text); background: var(--body-bg); } @@ -203,7 +207,7 @@ #content { flex: 1; overflow-y: auto; - padding: 24px 48px; + padding: calc(var(--line-height) * 2) calc(var(--line-height) * 3); display: flex; flex-direction: column; align-items: center; @@ -218,7 +222,7 @@ .help-bar { font-size: 11px; color: var(--dim); - margin-bottom: 16px; + margin-bottom: var(--line-height); opacity: 0.7; } @@ -226,21 +230,21 @@ .header { background: var(--container-bg); border-radius: 4px; - padding: 16px; - margin-bottom: 16px; + padding: var(--line-height); + margin-bottom: var(--line-height); } .header h1 { font-size: 14px; font-weight: bold; color: var(--borderAccent); - margin-bottom: 12px; + margin-bottom: var(--line-height); } .header-info { display: flex; flex-direction: column; - gap: 3px; + gap: 0; font-size: 11px; } @@ -265,20 +269,19 @@ #messages { display: flex; flex-direction: column; - gap: 16px; + gap: var(--line-height); } .message-timestamp { font-size: 10px; color: var(--dim); - margin-bottom: 4px; opacity: 0.8; } .user-message { background: var(--userMessageBg); color: var(--userMessageText); - padding: 12px 16px; + padding: var(--line-height); border-radius: 4px; } @@ -287,32 +290,50 @@ } .assistant-message > .message-timestamp { - padding-left: 16px; + padding-left: var(--line-height); } .assistant-text { - padding: 12px 16px; + 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: 12px 16px; + 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: 12px 16px; + padding: var(--line-height); color: var(--thinkingText); font-style: italic; } /* Tool execution */ .tool-execution { - padding: 12px 16px; + padding: var(--line-height); border-radius: 4px; - margin-top: 8px; + } + + .tool-execution + .tool-execution { + margin-top: var(--line-height); } .tool-execution.pending { background: var(--toolPendingBg); } @@ -345,7 +366,7 @@ } .tool-output { - margin-top: 12px; + margin-top: var(--line-height); color: var(--toolOutput); white-space: pre-wrap; word-wrap: break-word; @@ -356,7 +377,7 @@ } .tool-output > div { - line-height: 1.4; + line-height: var(--line-height); } .tool-output pre { @@ -396,14 +417,13 @@ } .tool-images { - margin-top: 12px; } .tool-image { max-width: 100%; max-height: 500px; border-radius: 4px; - margin: 4px 0; + margin: var(--line-height) 0; } .expand-hint { @@ -412,7 +432,6 @@ /* Diff */ .tool-diff { - margin-top: 12px; font-size: 11px; overflow-x: auto; white-space: pre; @@ -424,7 +443,7 @@ /* Model change */ .model-change { - padding: 8px 16px; + padding: 0 var(--line-height); color: var(--dim); font-size: 11px; } @@ -438,7 +457,7 @@ .compaction { background: var(--customMessageBg); border-radius: 4px; - padding: 12px 16px; + padding: var(--line-height); cursor: pointer; } @@ -455,7 +474,7 @@ display: none; color: var(--customMessageText); white-space: pre-wrap; - margin-top: 8px; + margin-top: var(--line-height); } .compaction.expanded .compaction-collapsed { @@ -469,15 +488,14 @@ /* System prompt */ .system-prompt { background: var(--customMessageBg); - padding: 12px 16px; + padding: var(--line-height); border-radius: 4px; - margin-bottom: 16px; + margin-bottom: var(--line-height); } .system-prompt-header { font-weight: bold; color: var(--customMessageLabel); - margin-bottom: 8px; } .system-prompt-content { @@ -487,24 +505,23 @@ font-size: 11px; max-height: 200px; overflow-y: auto; + margin-top: var(--line-height); } /* Tools list */ .tools-list { background: var(--customMessageBg); - padding: 12px 16px; + padding: var(--line-height); border-radius: 4px; - margin-bottom: 16px; + margin-bottom: var(--line-height); } .tools-header { font-weight: bold; color: var(--warning); - margin-bottom: 8px; } .tool-item { - margin: 4px 0; font-size: 11px; } @@ -521,33 +538,31 @@ .hook-message { background: var(--customMessageBg); color: var(--customMessageText); - padding: 12px 16px; + padding: var(--line-height); border-radius: 4px; } .hook-type { color: var(--customMessageLabel); font-weight: bold; - margin-bottom: 4px; } /* Branch summary */ .branch-summary { background: var(--customMessageBg); - padding: 12px 16px; + padding: var(--line-height); border-radius: 4px; } .branch-summary-header { font-weight: bold; color: var(--borderAccent); - margin-bottom: 8px; } /* Error */ .error-text { color: var(--error); - padding: 12px 16px; + padding: 0 var(--line-height); } /* Images */ @@ -559,7 +574,7 @@ max-width: 100%; max-height: 400px; border-radius: 4px; - margin: 4px 0; + margin: var(--line-height) 0; } /* Markdown content */ @@ -570,7 +585,7 @@ .markdown-content h5, .markdown-content h6 { color: var(--mdHeading); - margin: 1em 0 0.5em 0; + margin: var(--line-height) 0 0 0; font-weight: bold; } @@ -580,7 +595,8 @@ .markdown-content h4 { font-size: 1em; } .markdown-content h5 { font-size: 1em; } .markdown-content h6 { font-size: 1em; } - .markdown-content p { margin: 0.5em 0; } + .markdown-content p { margin: 0; } + .markdown-content p + p { margin-top: var(--line-height); } .markdown-content a { color: var(--mdLink); @@ -590,14 +606,14 @@ .markdown-content code { background: rgba(128, 128, 128, 0.2); color: var(--mdCode); - padding: 2px 6px; + padding: 0 4px; border-radius: 3px; font-family: inherit; } .markdown-content pre { background: transparent; - margin: 0.5em 0; + margin: var(--line-height) 0; overflow-x: auto; } @@ -605,30 +621,30 @@ display: block; background: none; color: var(--mdCodeBlock); - padding: 8px 12px; + padding: var(--line-height); } .markdown-content blockquote { border-left: 3px solid var(--mdQuoteBorder); - padding-left: 12px; - margin: 0.5em 0; + padding-left: var(--line-height); + margin: var(--line-height) 0; color: var(--mdQuote); font-style: italic; } .markdown-content ul, .markdown-content ol { - margin: 0.5em 0; - padding-left: 24px; + margin: var(--line-height) 0; + padding-left: calc(var(--line-height) * 2); } - .markdown-content li { margin: 0.25em 0; } + .markdown-content li { margin: 0; } .markdown-content li::marker { color: var(--mdListBullet); } .markdown-content hr { border: none; border-top: 1px solid var(--mdHr); - margin: 1em 0; + margin: var(--line-height) 0; } .markdown-content table { @@ -1807,7 +1823,7 @@ el.style.display = thinkingExpanded ? '' : 'none'; }); document.querySelectorAll('.thinking-collapsed').forEach(el => { - el.style.display = thinkingExpanded ? 'none' : ''; + el.style.display = thinkingExpanded ? 'none' : 'block'; }); }; From 9e5163c296cb72280dc6ab6378482165dc21d651 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Thu, 1 Jan 2026 17:12:44 +0100 Subject: [PATCH 053/124] Fix export-html header stats and UX MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Header h1 font size now 12px (matches body) - Token/cost stats now computed for all entries, not just current branch - Token display matches footer format: ↑input ↓output Rcache Wcache - Cost display uses 3 decimal places like footer - Scroll to target uses requestAnimationFrame for DOM readiness - Initial load and Escape scroll to bottom, tree clicks scroll to target --- .../src/core/export-html/template.html | 73 ++++++++++++------- 1 file changed, 46 insertions(+), 27 deletions(-) diff --git a/packages/coding-agent/src/core/export-html/template.html b/packages/coding-agent/src/core/export-html/template.html index c604582d..2fae6139 100644 --- a/packages/coding-agent/src/core/export-html/template.html +++ b/packages/coding-agent/src/core/export-html/template.html @@ -235,7 +235,7 @@ } .header h1 { - font-size: 14px; + font-size: 12px; font-weight: bold; color: var(--borderAccent); margin-bottom: var(--line-height); @@ -1287,6 +1287,15 @@ return div.innerHTML; } + // Format token counts (matches footer.ts) + function formatTokens(count) { + if (count < 1000) return count.toString(); + if (count < 10000) return (count / 1000).toFixed(1) + 'k'; + if (count < 1000000) return Math.round(count / 1000) + 'k'; + if (count < 10000000) return (count / 1000000).toFixed(1) + 'M'; + return Math.round(count / 1000000) + 'M'; + } + // Configure marked marked.setOptions({ breaks: true, @@ -1666,8 +1675,8 @@ return ''; } - // Compute stats for current path - function computeStats(path) { + // Compute stats for a list of entries + function computeStats(entryList) { let userMessages = 0; let assistantMessages = 0; let toolCalls = 0; @@ -1675,7 +1684,7 @@ const cost = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }; const models = new Set(); - for (const entry of path) { + for (const entry of entryList) { if (entry.type === 'message') { const msg = entry.message; if (msg.role === 'user') userMessages++; @@ -1704,22 +1713,32 @@ return { userMessages, assistantMessages, toolCalls, tokens, cost, models: Array.from(models) }; } + // Compute global stats once for all entries + const globalStats = computeStats(entries); + // Render header function renderHeader(path) { - const stats = computeStats(path); - const totalTokens = stats.tokens.input + stats.tokens.output + stats.tokens.cacheRead + stats.tokens.cacheWrite; - const totalCost = stats.cost.input + stats.cost.output + stats.cost.cacheRead + stats.cost.cacheWrite; + const pathStats = computeStats(path); + const totalCost = globalStats.cost.input + globalStats.cost.output + globalStats.cost.cacheRead + globalStats.cost.cacheWrite; + + // Format tokens like footer: ↑input ↓output Rcache_read Wcache_write (global stats) + const tokenParts = []; + if (globalStats.tokens.input) tokenParts.push(`↑${formatTokens(globalStats.tokens.input)}`); + if (globalStats.tokens.output) tokenParts.push(`↓${formatTokens(globalStats.tokens.output)}`); + if (globalStats.tokens.cacheRead) tokenParts.push(`R${formatTokens(globalStats.tokens.cacheRead)}`); + if (globalStats.tokens.cacheWrite) tokenParts.push(`W${formatTokens(globalStats.tokens.cacheWrite)}`); + const tokensDisplay = tokenParts.join(' ') || '0'; let html = `

    Session: ${escapeHtml(header?.id || 'unknown')}

    Date:${header?.timestamp ? new Date(header.timestamp).toLocaleString() : 'unknown'}
    -
    Models:${stats.models.join(', ') || 'unknown'}
    -
    Messages:${stats.userMessages} user, ${stats.assistantMessages} assistant
    -
    Tool Calls:${stats.toolCalls}
    -
    Tokens:${totalTokens.toLocaleString()}
    -
    Cost:$${totalCost.toFixed(4)}
    +
    Models:${globalStats.models.join(', ') || 'unknown'}
    +
    Messages:${globalStats.userMessages} user, ${globalStats.assistantMessages} assistant
    +
    Tool Calls:${globalStats.toolCalls}
    +
    Tokens:${tokensDisplay}
    +
    Cost:$${totalCost.toFixed(3)}
    `; @@ -1745,7 +1764,8 @@ } // Navigate to entry - function navigateTo(targetId, scrollToTarget = true) { + // scrollMode: 'target' = scroll to target element, 'bottom' = scroll to bottom, 'none' = no scroll + function navigateTo(targetId, scrollMode = 'target') { currentPath = getPath(targetId); renderTree(); @@ -1755,19 +1775,18 @@ headerContainer.innerHTML = renderHeader(currentPath); messagesContainer.innerHTML = currentPath.map(renderEntry).join(''); - // Scroll to target element - if (scrollToTarget) { - const targetEl = document.getElementById(`entry-${targetId}`); - if (targetEl) { - // Use 'end' to ensure we scroll far enough to see the target - targetEl.scrollIntoView({ behavior: 'smooth', block: 'end' }); - // Brief highlight - targetEl.style.outline = '2px solid var(--accent)'; - setTimeout(() => { targetEl.style.outline = ''; }, 1500); + // Use requestAnimationFrame to wait for DOM render before scrolling + requestAnimationFrame(() => { + const content = document.getElementById('content'); + if (scrollMode === 'bottom') { + content.scrollTop = content.scrollHeight; + } else if (scrollMode === 'target') { + const targetEl = document.getElementById(`entry-${targetId}`); + if (targetEl) { + targetEl.scrollIntoView({ block: 'center' }); + } } - } else { - document.getElementById('content').scrollTop = 0; - } + }); } // Initialize @@ -1852,7 +1871,7 @@ if (e.key === 'Escape') { searchInput.value = ''; searchQuery = ''; - navigateTo(leafId); + navigateTo(leafId, 'bottom'); } // Toggle thinking blocks on Ctrl+T if (e.ctrlKey && e.key === 't') { @@ -1867,7 +1886,7 @@ }); // Initial render - navigateTo(leafId); + navigateTo(leafId, 'bottom'); })(); From a9da0ce3fd1613e8b648bb75adf062c148bf4b17 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Thu, 1 Jan 2026 17:16:47 +0100 Subject: [PATCH 054/124] Fix export-html message stats and help bar - Fix entry type names: branch_summary, custom_message (snake_case) - Fix toolResult role (was 'tool') - Count all entry types: user, assistant, tool results, custom, compactions, branch summaries - Use global stats for tokens/cost (all entries), not just current branch - Make help bar more prominent (12px, full opacity) - Remove Esc shortcut from help bar --- .../src/core/export-html/template.html | 32 +++++++++++++++---- 1 file changed, 26 insertions(+), 6 deletions(-) diff --git a/packages/coding-agent/src/core/export-html/template.html b/packages/coding-agent/src/core/export-html/template.html index 2fae6139..05aeea03 100644 --- a/packages/coding-agent/src/core/export-html/template.html +++ b/packages/coding-agent/src/core/export-html/template.html @@ -220,10 +220,9 @@ /* Help bar */ .help-bar { - font-size: 11px; - color: var(--dim); + font-size: 12px; + color: var(--text); margin-bottom: var(--line-height); - opacity: 0.7; } /* Header */ @@ -800,7 +799,7 @@
    -
    Ctrl+T toggle thinking · Ctrl+O toggle tools · Esc reset
    +
    Ctrl+T toggle thinking · Ctrl+O toggle tools