diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index e57237b7..e051046c 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,16 @@ ## [Unreleased] +### Added + +- **Custom tools**: Extend pi with custom tools written in TypeScript. Tools can provide custom TUI rendering, interact with users via `pi.ui` (select, confirm, input, notify), and maintain state across sessions via `onSession` callback. See [docs/custom-tools.md](docs/custom-tools.md) and [examples/custom-tools/](examples/custom-tools/). ([#190](https://github.com/badlogic/pi-mono/issues/190)) + +- **Hook and tool examples**: Added `examples/hooks/` and `examples/custom-tools/` with working examples. Examples are now bundled in npm and binary releases. + +### Breaking Changes + +- **Hooks**: Replaced `session_start` and `session_switch` events with unified `session` event. Use `event.reason` (`"start" | "switch" | "clear"`) to distinguish. Event now includes `entries` array for state reconstruction. + ## [0.22.5] - 2025-12-17 ### Fixed diff --git a/packages/coding-agent/README.md b/packages/coding-agent/README.md index 429fe8ba..822e8207 100644 --- a/packages/coding-agent/README.md +++ b/packages/coding-agent/README.md @@ -27,7 +27,8 @@ Works on Linux, macOS, and Windows (requires bash; see [Windows Setup](#windows- - [Themes](#themes) - [Custom Slash Commands](#custom-slash-commands) - [Skills](#skills) - - [Hooks](#hooks)(#hooks) + - [Hooks](#hooks) + - [Custom Tools](#custom-tools) - [Settings File](#settings-file) - [CLI Reference](#cli-reference) - [Tools](#tools) @@ -588,7 +589,8 @@ import * as fs from "node:fs"; import type { HookAPI } from "@mariozechner/pi-coding-agent/hooks"; export default function (pi: HookAPI) { - pi.on("session_start", async () => { + pi.on("session", async (event) => { + if (event.reason !== "start") return; fs.watch("/tmp/trigger.txt", () => { const content = fs.readFileSync("/tmp/trigger.txt", "utf-8").trim(); if (content) pi.send(content); @@ -599,6 +601,56 @@ export default function (pi: HookAPI) { See [Hooks Documentation](docs/hooks.md) for full API reference. +See [examples/hooks/](examples/hooks/) for working examples including permission gates, git checkpointing, and path protection. + +### Custom Tools + +Custom tools extend pi with new capabilities beyond the built-in tools. They are TypeScript modules that define tools with optional custom TUI rendering. + +**Tool locations:** +- Global: `~/.pi/agent/tools/*.ts` +- Project: `.pi/tools/*.ts` +- CLI: `--tool ` +- Settings: `customTools` array in `settings.json` + +**Quick example:** + +```typescript +import { Type } from "@sinclair/typebox"; +import type { CustomToolFactory } from "@mariozechner/pi-coding-agent"; + +const factory: CustomToolFactory = (pi) => ({ + name: "greet", + label: "Greeting", + description: "Generate a greeting", + parameters: Type.Object({ + name: Type.String({ description: "Name to greet" }), + }), + + async execute(toolCallId, params) { + return { + content: [{ type: "text", text: `Hello, ${params.name}!` }], + details: { greeted: params.name }, + }; + }, +}); + +export default factory; +``` + +**Features:** +- Access to `pi.cwd`, `pi.exec()`, `pi.ui` (select/confirm/input dialogs) +- Session lifecycle via `onSession` callback (for state reconstruction) +- Custom rendering via `renderCall()` and `renderResult()` methods +- Streaming results via `onUpdate` callback +- Abort handling via `signal` parameter +- Cleanup via `dispose()` method +- Multiple tools from one factory (return an array) + +See [Custom Tools Documentation](docs/custom-tools.md) for the full API reference, TUI component guide, and examples. + +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 `~/.pi/agent/settings.json` stores persistent preferences: diff --git a/packages/coding-agent/docs/custom-tools.md b/packages/coding-agent/docs/custom-tools.md new file mode 100644 index 00000000..66beb71d --- /dev/null +++ b/packages/coding-agent/docs/custom-tools.md @@ -0,0 +1,341 @@ +# Custom Tools + +Custom tools extend pi with new capabilities beyond the built-in read/write/edit/bash tools. They are TypeScript modules that define one or more tools with optional custom rendering for the TUI. + +## Quick Start + +Create a file `~/.pi/agent/tools/hello.ts`: + +```typescript +import { Type } from "@sinclair/typebox"; +import type { CustomToolFactory } from "@mariozechner/pi-coding-agent"; + +const factory: CustomToolFactory = (pi) => ({ + name: "hello", + label: "Hello", + description: "A simple greeting tool", + parameters: Type.Object({ + name: Type.String({ description: "Name to greet" }), + }), + + async execute(toolCallId, params) { + return { + content: [{ type: "text", text: `Hello, ${params.name}!` }], + details: { greeted: params.name }, + }; + }, +}); + +export default factory; +``` + +The tool is automatically discovered and available in your next pi session. + +## Tool Locations + +| Location | Scope | Auto-discovered | +|----------|-------|-----------------| +| `~/.pi/agent/tools/*.ts` | Global (all projects) | Yes | +| `.pi/tools/*.ts` | Project-local | Yes | +| `settings.json` `customTools` array | Configured paths | Yes | +| `--tool ` CLI flag | One-off/debugging | No | + +**Priority:** Later sources win on name conflicts. CLI `--tool` takes highest priority. + +**Reserved names:** Custom tools cannot use built-in tool names (`read`, `write`, `edit`, `bash`, `grep`, `find`, `ls`). + +## Tool Definition + +```typescript +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"; + +const factory: CustomToolFactory = (pi) => ({ + name: "my_tool", + label: "My Tool", + description: "What this tool does (be specific for LLM)", + parameters: Type.Object({ + // Use StringEnum for string enums (Google API compatible) + action: StringEnum(["list", "add", "remove"] as const), + text: Type.Optional(Type.String()), + }), + + async execute(toolCallId, params, signal, onUpdate) { + // signal - AbortSignal for cancellation + // onUpdate - Callback for streaming partial results + return { + content: [{ type: "text", text: "Result for LLM" }], + details: { /* structured data for rendering */ }, + }; + }, + + // Optional: Session lifecycle callback + onSession(event) { /* reconstruct state from entries */ }, + + // Optional: Custom rendering + renderCall(args, theme) { /* return Component */ }, + renderResult(result, options, theme) { /* return Component */ }, + + // Optional: Cleanup on session end + dispose() { /* save state, close connections */ }, +}); + +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 + +The factory receives a `ToolAPI` object (named `pi` by convention): + +```typescript +interface ToolAPI { + cwd: string; // Current working directory + exec(command: string, args: string[]): Promise; + ui: { + select(title: string, options: string[]): Promise; + confirm(title: string, message: string): Promise; + input(title: string, placeholder?: string): Promise; + notify(message: string, type?: "info" | "warning" | "error"): void; + }; + hasUI: boolean; // false in --print or --mode rpc +} +``` + +Always check `pi.hasUI` before using UI methods. + +## Session Lifecycle + +Tools can implement `onSession` to react to session changes: + +```typescript +interface ToolSessionEvent { + entries: SessionEntry[]; // All session entries + sessionFile: string | null; // Current session file + previousSessionFile: string | null; // Previous session file + reason: "start" | "switch" | "branch" | "clear"; +} +``` + +**Reasons:** +- `start`: Initial session load on startup +- `switch`: User switched to a different session (`/session`) +- `branch`: User branched from a previous message (`/branch`) +- `clear`: User cleared the session (`/clear`) + +### State Management Pattern + +Tools that maintain state should store it in `details` of their results, not external files. This allows branching to work correctly, as the state is reconstructed from the session history. + +```typescript +interface MyToolDetails { + items: string[]; +} + +const factory: CustomToolFactory = (pi) => { + // In-memory state + let items: string[] = []; + + // Reconstruct state from session entries + const reconstructState = (event: ToolSessionEvent) => { + items = []; + for (const entry of event.entries) { + if (entry.type !== "message") continue; + const msg = entry.message; + if (msg.role !== "toolResult") continue; + if (msg.toolName !== "my_tool") continue; + + const details = msg.details as MyToolDetails | undefined; + if (details) { + items = details.items; + } + } + }; + + return { + name: "my_tool", + label: "My Tool", + description: "...", + parameters: Type.Object({ ... }), + + onSession: reconstructState, + + async execute(toolCallId, params) { + // Modify items... + items.push("new item"); + + return { + content: [{ type: "text", text: "Added item" }], + // Store current state in details for reconstruction + details: { items: [...items] }, + }; + }, + }; +}; +``` + +This pattern ensures: +- When user branches, state is correct for that point in history +- When user switches sessions, state matches that session +- When user clears, state resets + +## Custom Rendering + +Custom tools can provide `renderCall` and `renderResult` methods to control how they appear in the TUI. Both are optional. + +### How It Works + +Tool output is wrapped in a `Box` component that handles: +- Padding (1 character horizontal, 1 line vertical) +- Background color based on state (pending/success/error) + +Your render methods return `Component` instances (typically `Text`) that go inside this box. Use `Text(content, 0, 0)` since the Box handles padding. + +### renderCall + +Renders the tool call (before/during execution): + +```typescript +renderCall(args, theme) { + let text = theme.fg("toolTitle", theme.bold("my_tool ")); + text += theme.fg("muted", args.action); + if (args.text) { + text += " " + theme.fg("dim", `"${args.text}"`); + } + return new Text(text, 0, 0); +} +``` + +Called when: +- Tool call starts (may have partial args during streaming) +- Args are updated during streaming + +### renderResult + +Renders the tool result: + +```typescript +renderResult(result, { expanded, isPartial }, theme) { + const { details } = result; + + // Handle streaming/partial results + if (isPartial) { + return new Text(theme.fg("warning", "Processing..."), 0, 0); + } + + // Handle errors + if (details?.error) { + return new Text(theme.fg("error", `Error: ${details.error}`), 0, 0); + } + + // Normal result + let text = theme.fg("success", "✓ ") + theme.fg("muted", "Done"); + + // Support expanded view (Ctrl+O) + if (expanded && details?.items) { + for (const item of details.items) { + text += "\n" + theme.fg("dim", ` ${item}`); + } + } + + return new Text(text, 0, 0); +} +``` + +**Options:** +- `expanded`: User pressed Ctrl+O to expand +- `isPartial`: Result is from `onUpdate` (streaming), not final + +### Best Practices + +1. **Use `Text` with padding `(0, 0)`** - The Box handles padding +2. **Use `\n` for multi-line content** - Not multiple Text components +3. **Handle `isPartial`** - Show progress during streaming +4. **Support `expanded`** - Show more detail when user requests +5. **Use theme colors** - For consistent appearance +6. **Keep it compact** - Show summary by default, details when expanded + +### Theme Colors + +```typescript +// Foreground +theme.fg("toolTitle", text) // Tool names +theme.fg("accent", text) // Highlights +theme.fg("success", text) // Success +theme.fg("error", text) // Errors +theme.fg("warning", text) // Warnings +theme.fg("muted", text) // Secondary text +theme.fg("dim", text) // Tertiary text +theme.fg("toolOutput", text) // Output content + +// Styles +theme.bold(text) +theme.italic(text) +``` + +### Fallback Behavior + +If `renderCall` or `renderResult` is not defined or throws an error: +- `renderCall`: Shows tool name +- `renderResult`: Shows raw text output from `content` + +## Execute Function + +```typescript +async execute(toolCallId, args, signal, onUpdate) { + // Type assertion for params (TypeBox schema doesn't flow through) + const params = args as { action: "list" | "add"; text?: string }; + + // Check for abort + if (signal?.aborted) { + return { content: [...], details: { status: "aborted" } }; + } + + // Stream progress + onUpdate?.({ + content: [{ type: "text", text: "Working..." }], + details: { progress: 50 }, + }); + + // Return final result + return { + content: [{ type: "text", text: "Done" }], // Sent to LLM + details: { data: result }, // For rendering only + }; +} +``` + +## Multiple Tools from One File + +Return an array to share state between related tools: + +```typescript +const factory: CustomToolFactory = (pi) => { + // Shared state + let connection = null; + + return [ + { name: "db_connect", ... }, + { name: "db_query", ... }, + { + name: "db_close", + dispose() { connection?.close(); } + }, + ]; +}; +``` + +## Examples + +See [`examples/custom-tools/todo.ts`](../examples/custom-tools/todo.ts) for a complete example with: +- `onSession` for state reconstruction +- Custom `renderCall` and `renderResult` +- Proper branching support via details storage + +Test with: +```bash +pi --tool packages/coding-agent/examples/custom-tools/todo.ts +``` diff --git a/packages/coding-agent/docs/hooks.md b/packages/coding-agent/docs/hooks.md index 03614d44..c08f1bd3 100644 --- a/packages/coding-agent/docs/hooks.md +++ b/packages/coding-agent/docs/hooks.md @@ -43,8 +43,8 @@ A hook is a TypeScript file that exports a default function. The function receiv import type { HookAPI } from "@mariozechner/pi-coding-agent/hooks"; export default function (pi: HookAPI) { - pi.on("session_start", async (event, ctx) => { - ctx.ui.notify(`Session: ${ctx.sessionFile ?? "ephemeral"}`, "info"); + pi.on("session", async (event, ctx) => { + ctx.ui.notify(`Session ${event.reason}: ${ctx.sessionFile ?? "ephemeral"}`, "info"); }); } ``` @@ -76,7 +76,7 @@ Hooks are loaded using [jiti](https://github.com/unjs/jiti), so TypeScript works ``` pi starts │ - ├─► session_start + ├─► session (reason: "start") │ ▼ user sends prompt ─────────────────────────────────────────┐ @@ -98,36 +98,54 @@ user sends prompt ──────────────────── │ user sends another prompt ◄────────────────────────────────┘ -user branches or switches session +user branches (/branch) │ - └─► session_switch + ├─► branch (BEFORE branch, can control) + └─► session (reason: "switch", AFTER branch) + +user switches session (/session) + │ + └─► session (reason: "switch") + +user clears session (/clear) + │ + └─► session (reason: "clear") ``` A **turn** is one LLM response plus any tool calls. Complex tasks loop through multiple turns until the LLM responds without calling tools. -### session_start +### session -Fired once when pi starts. +Fired on startup and when session changes. ```typescript -pi.on("session_start", async (event, ctx) => { - // ctx.sessionFile: string | null - // ctx.hasUI: boolean +pi.on("session", async (event, ctx) => { + // event.entries: SessionEntry[] - all session entries + // event.sessionFile: string | null - current session file + // event.previousSessionFile: string | null - previous session file + // event.reason: "start" | "switch" | "clear" }); ``` -### session_switch +**Reasons:** +- `start`: Initial session load on startup +- `switch`: User switched sessions (`/session`) or branched (`/branch`) +- `clear`: User cleared the session (`/clear`) -Fired when session changes (`/branch` or session switch). +### branch + +Fired BEFORE a branch happens. Can control branch behavior. ```typescript -pi.on("session_switch", async (event, ctx) => { - // event.newSessionFile: string | null (null in --no-session mode) - // event.previousSessionFile: string | null (null in --no-session mode) - // event.reason: "branch" | "switch" +pi.on("branch", async (event, ctx) => { + // event.targetTurnIndex: number + // event.entries: SessionEntry[] + return { skipConversationRestore: true }; // or undefined }); ``` +Note: After branch completes, a `session` event fires with `reason: "switch"`. + ### agent_start / agent_end Fired once per user prompt. @@ -192,18 +210,6 @@ pi.on("tool_result", async (event, ctx) => { }); ``` -### branch - -Fired when user branches via `/branch`. - -```typescript -pi.on("branch", async (event, ctx) => { - // event.targetTurnIndex: number - // event.entries: SessionEntry[] - return { skipConversationRestore: true }; // or undefined -}); -``` - ## Context API Every event handler receives a context object with these methods: @@ -309,7 +315,9 @@ import * as fs from "node:fs"; import type { HookAPI } from "@mariozechner/pi-coding-agent/hooks"; export default function (pi: HookAPI) { - pi.on("session_start", async (event, ctx) => { + pi.on("session", async (event, ctx) => { + if (event.reason !== "start") return; + // Watch a trigger file const triggerFile = "/tmp/agent-trigger.txt"; @@ -339,7 +347,9 @@ import * as http from "node:http"; import type { HookAPI } from "@mariozechner/pi-coding-agent/hooks"; export default function (pi: HookAPI) { - pi.on("session_start", async (event, ctx) => { + 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); @@ -563,7 +573,7 @@ Key behaviors: Mode initialization: -> hookRunner.setUIContext(ctx, hasUI) -> hookRunner.setSessionFile(path) - -> hookRunner.emit({ type: "session_start" }) + -> hookRunner.emit({ type: "session", reason: "start", ... }) User sends prompt: -> AgentSession.prompt() @@ -581,9 +591,19 @@ User sends prompt: -> [repeat if more tool calls] -> hookRunner.emit({ type: "agent_end", messages }) -Branch or session switch: - -> AgentSession.branch() or AgentSession.switchSession() - -> hookRunner.emit({ type: "session_switch", ... }) +Branch: + -> AgentSession.branch() + -> hookRunner.emit({ type: "branch", ... }) # BEFORE branch + -> [branch happens] + -> hookRunner.emit({ type: "session", reason: "switch", ... }) # AFTER + +Session switch: + -> AgentSession.switchSession() + -> hookRunner.emit({ type: "session", reason: "switch", ... }) + +Clear: + -> AgentSession.reset() + -> hookRunner.emit({ type: "session", reason: "clear", ... }) ``` ## UI Context by Mode diff --git a/packages/coding-agent/examples/custom-tools/README.md b/packages/coding-agent/examples/custom-tools/README.md new file mode 100644 index 00000000..522f193e --- /dev/null +++ b/packages/coding-agent/examples/custom-tools/README.md @@ -0,0 +1,101 @@ +# Custom Tools Examples + +Example custom tools for pi-coding-agent. + +## Examples + +### hello.ts +Minimal example showing the basic structure of a custom tool. + +### question.ts +Demonstrates `pi.ui.select()` for asking the user questions with options. + +### todo.ts +Full-featured example demonstrating: +- `onSession` for state reconstruction from session history +- Custom `renderCall` and `renderResult` +- Proper branching support via details storage +- State management without external files + +## Usage + +```bash +# Test directly +pi --tool examples/custom-tools/todo.ts + +# Or copy to tools directory for persistent use +cp todo.ts ~/.pi/agent/tools/ +``` + +Then in pi: +``` +> add a todo "test custom tools" +> list todos +> toggle todo #1 +> clear todos +``` + +## Writing Custom Tools + +See [docs/custom-tools.md](../../docs/custom-tools.md) for full documentation. + +### Key Points + +**Factory pattern:** +```typescript +import { Type } from "@sinclair/typebox"; +import { StringEnum } from "@mariozechner/pi-ai"; +import { Text } from "@mariozechner/pi-tui"; +import type { CustomToolFactory } from "@mariozechner/pi-coding-agent"; + +const factory: CustomToolFactory = (pi) => ({ + name: "my_tool", + label: "My Tool", + description: "Tool description for LLM", + parameters: Type.Object({ + action: StringEnum(["list", "add"] as const), + }), + + // Called on session start/switch/branch/clear + onSession(event) { + // Reconstruct state from event.entries + }, + + async execute(toolCallId, params) { + return { + content: [{ type: "text", text: "Result" }], + details: { /* for rendering and state reconstruction */ }, + }; + }, +}); + +export default factory; +``` + +**Custom rendering:** +```typescript +renderCall(args, theme) { + return new Text( + theme.fg("toolTitle", theme.bold("my_tool ")) + args.action, + 0, 0 // No padding - Box handles it + ); +}, + +renderResult(result, { expanded, isPartial }, theme) { + if (isPartial) { + return new Text(theme.fg("warning", "Working..."), 0, 0); + } + return new Text(theme.fg("success", "✓ Done"), 0, 0); +}, +``` + +**Use StringEnum for string parameters** (required for Google API compatibility): +```typescript +import { StringEnum } from "@mariozechner/pi-ai"; + +// Good +action: StringEnum(["list", "add"] as const) + +// Bad - doesn't work with Google +action: Type.Union([Type.Literal("list"), Type.Literal("add")]) +``` diff --git a/packages/coding-agent/examples/custom-tools/hello.ts b/packages/coding-agent/examples/custom-tools/hello.ts new file mode 100644 index 00000000..a599e756 --- /dev/null +++ b/packages/coding-agent/examples/custom-tools/hello.ts @@ -0,0 +1,20 @@ +import { Type } from "@sinclair/typebox"; +import type { CustomToolFactory } from "@mariozechner/pi-coding-agent"; + +const factory: CustomToolFactory = (pi) => ({ + name: "hello", + label: "Hello", + description: "A simple greeting tool", + parameters: Type.Object({ + name: Type.String({ description: "Name to greet" }), + }), + + async execute(toolCallId, params) { + return { + content: [{ type: "text", text: `Hello, ${params.name}!` }], + details: { greeted: params.name }, + }; + }, +}); + +export default factory; \ No newline at end of file diff --git a/packages/coding-agent/examples/custom-tools/question.ts b/packages/coding-agent/examples/custom-tools/question.ts new file mode 100644 index 00000000..c21add67 --- /dev/null +++ b/packages/coding-agent/examples/custom-tools/question.ts @@ -0,0 +1,83 @@ +/** + * Question Tool - Let the LLM ask the user a question with options + */ + +import { Type } from "@sinclair/typebox"; +import { Text } from "@mariozechner/pi-tui"; +import type { CustomAgentTool, CustomToolFactory } from "@mariozechner/pi-coding-agent"; + +interface QuestionDetails { + question: string; + options: string[]; + answer: string | null; +} + +const QuestionParams = Type.Object({ + question: Type.String({ description: "The question to ask the user" }), + options: Type.Array(Type.String(), { description: "Options for the user to choose from" }), +}); + +const factory: CustomToolFactory = (pi) => { + const tool: CustomAgentTool = { + name: "question", + label: "Question", + 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) { + if (!pi.hasUI) { + return { + content: [{ type: "text", text: "Error: UI not available (running in non-interactive mode)" }], + details: { question: params.question, options: params.options, answer: null }, + }; + } + + if (params.options.length === 0) { + return { + content: [{ type: "text", text: "Error: No options provided" }], + details: { question: params.question, options: [], answer: null }, + }; + } + + const answer = await pi.ui.select(params.question, params.options); + + if (answer === null) { + return { + content: [{ type: "text", text: "User cancelled the selection" }], + details: { question: params.question, options: params.options, answer: null }, + }; + } + + return { + content: [{ type: "text", text: `User selected: ${answer}` }], + details: { question: params.question, options: params.options, answer }, + }; + }, + + renderCall(args, theme) { + let text = theme.fg("toolTitle", theme.bold("question ")) + theme.fg("muted", args.question); + if (args.options?.length) { + text += "\n" + theme.fg("dim", ` Options: ${args.options.join(", ")}`); + } + return new Text(text, 0, 0); + }, + + renderResult(result, _options, theme) { + const { details } = result; + if (!details) { + const text = result.content[0]; + return new Text(text?.type === "text" ? text.text : "", 0, 0); + } + + if (details.answer === null) { + return new Text(theme.fg("warning", "Cancelled"), 0, 0); + } + + return new Text(theme.fg("success", "✓ ") + theme.fg("accent", details.answer), 0, 0); + }, + }; + + return tool; +}; + +export default factory; diff --git a/packages/coding-agent/examples/custom-tools/todo.ts b/packages/coding-agent/examples/custom-tools/todo.ts new file mode 100644 index 00000000..aa14003e --- /dev/null +++ b/packages/coding-agent/examples/custom-tools/todo.ts @@ -0,0 +1,192 @@ +/** + * Todo Tool - Demonstrates state management via session entries + * + * This tool stores state in tool result details (not external files), + * which allows proper branching - when you branch, the todo state + * is automatically correct for that point in history. + * + * The onSession callback reconstructs state by scanning past tool results. + */ + +import { Type } from "@sinclair/typebox"; +import { StringEnum } from "@mariozechner/pi-ai"; +import { Text } from "@mariozechner/pi-tui"; +import type { CustomAgentTool, CustomToolFactory, ToolSessionEvent } from "@mariozechner/pi-coding-agent"; + +interface Todo { + id: number; + text: string; + done: boolean; +} + +// State stored in tool result details +interface TodoDetails { + action: "list" | "add" | "toggle" | "clear"; + todos: Todo[]; + nextId: number; + error?: string; +} + +// Define schema separately for proper type inference +const TodoParams = Type.Object({ + action: StringEnum(["list", "add", "toggle", "clear"] as const), + text: Type.Optional(Type.String({ description: "Todo text (for add)" })), + id: Type.Optional(Type.Number({ description: "Todo ID (for toggle)" })), +}); + +const factory: CustomToolFactory = (_pi) => { + // In-memory state (reconstructed from session on load) + let todos: Todo[] = []; + let nextId = 1; + + /** + * Reconstruct state from session entries. + * Scans tool results for this tool and applies them in order. + */ + const reconstructState = (event: ToolSessionEvent) => { + todos = []; + nextId = 1; + + for (const entry of event.entries) { + if (entry.type !== "message") continue; + const msg = entry.message; + + // Tool results have role "toolResult" + if (msg.role !== "toolResult") continue; + if (msg.toolName !== "todo") continue; + + const details = msg.details as TodoDetails | undefined; + if (details) { + todos = details.todos; + nextId = details.nextId; + } + } + }; + + const tool: CustomAgentTool = { + name: "todo", + label: "Todo", + description: "Manage a todo list. Actions: list, add (text), toggle (id), clear", + parameters: TodoParams, + + // Called on session start/switch/branch/clear + onSession: reconstructState, + + async execute(_toolCallId, params) { + switch (params.action) { + case "list": + return { + content: [{ type: "text", text: todos.length ? todos.map((t) => `[${t.done ? "x" : " "}] #${t.id}: ${t.text}`).join("\n") : "No todos" }], + details: { action: "list", todos: [...todos], nextId }, + }; + + case "add": + if (!params.text) { + return { + content: [{ type: "text", text: "Error: text required for add" }], + details: { action: "add", todos: [...todos], nextId, error: "text required" }, + }; + } + const newTodo: Todo = { id: nextId++, text: params.text, done: false }; + todos.push(newTodo); + return { + content: [{ type: "text", text: `Added todo #${newTodo.id}: ${newTodo.text}` }], + details: { action: "add", todos: [...todos], nextId }, + }; + + case "toggle": + if (params.id === undefined) { + return { + content: [{ type: "text", text: "Error: id required for toggle" }], + details: { action: "toggle", todos: [...todos], nextId, error: "id required" }, + }; + } + const todo = todos.find((t) => t.id === params.id); + if (!todo) { + return { + content: [{ type: "text", text: `Todo #${params.id} not found` }], + details: { action: "toggle", todos: [...todos], nextId, error: `#${params.id} not found` }, + }; + } + todo.done = !todo.done; + return { + content: [{ type: "text", text: `Todo #${todo.id} ${todo.done ? "completed" : "uncompleted"}` }], + details: { action: "toggle", todos: [...todos], nextId }, + }; + + case "clear": + const count = todos.length; + todos = []; + nextId = 1; + return { + content: [{ type: "text", text: `Cleared ${count} todos` }], + details: { action: "clear", todos: [], nextId: 1 }, + }; + + default: + return { + content: [{ type: "text", text: `Unknown action: ${params.action}` }], + details: { action: "list", todos: [...todos], nextId, error: `unknown action: ${params.action}` }, + }; + } + }, + + renderCall(args, theme) { + let text = theme.fg("toolTitle", theme.bold("todo ")) + theme.fg("muted", args.action); + if (args.text) text += " " + theme.fg("dim", `"${args.text}"`); + if (args.id !== undefined) text += " " + theme.fg("accent", `#${args.id}`); + return new Text(text, 0, 0); + }, + + renderResult(result, { expanded }, theme) { + const { details } = result; + if (!details) { + const text = result.content[0]; + return new Text(text?.type === "text" ? text.text : "", 0, 0); + } + + // Error + if (details.error) { + return new Text(theme.fg("error", `Error: ${details.error}`), 0, 0); + } + + const todoList = details.todos; + + switch (details.action) { + case "list": + if (todoList.length === 0) { + return new Text(theme.fg("dim", "No todos"), 0, 0); + } + let listText = theme.fg("muted", `${todoList.length} todo(s):`); + const display = expanded ? todoList : todoList.slice(0, 5); + for (const t of display) { + const check = t.done ? theme.fg("success", "✓") : theme.fg("dim", "○"); + const itemText = t.done ? theme.fg("dim", t.text) : theme.fg("muted", t.text); + listText += "\n" + check + " " + theme.fg("accent", `#${t.id}`) + " " + itemText; + } + if (!expanded && todoList.length > 5) { + listText += "\n" + theme.fg("dim", `... ${todoList.length - 5} more`); + } + return new Text(listText, 0, 0); + + case "add": { + const added = todoList[todoList.length - 1]; + return new Text(theme.fg("success", "✓ Added ") + theme.fg("accent", `#${added.id}`) + " " + theme.fg("muted", added.text), 0, 0); + } + + case "toggle": { + const text = result.content[0]; + const msg = text?.type === "text" ? text.text : ""; + return new Text(theme.fg("success", "✓ ") + theme.fg("muted", msg), 0, 0); + } + + case "clear": + return new Text(theme.fg("success", "✓ ") + theme.fg("muted", "Cleared all todos"), 0, 0); + } + }, + }; + + return tool; +}; + +export default factory; diff --git a/packages/coding-agent/examples/hooks/README.md b/packages/coding-agent/examples/hooks/README.md new file mode 100644 index 00000000..68c557ba --- /dev/null +++ b/packages/coding-agent/examples/hooks/README.md @@ -0,0 +1,76 @@ +# Hooks Examples + +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/). + +## Usage + +```bash +# Test directly +pi --hook examples/hooks/permission-gate.ts + +# Or copy to hooks directory for persistent use +cp permission-gate.ts ~/.pi/agent/hooks/ +``` + +## 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" | "switch" | "clear" + // ctx.ui, ctx.exec, ctx.cwd, ctx.sessionFile, ctx.hasUI + }); + + pi.on("tool_call", async (event, ctx) => { + // Can block tool execution + if (dangerous) { + return { block: true, reason: "Blocked" }; + } + return undefined; + }); + + pi.on("tool_result", async (event, ctx) => { + // Can modify result + return { result: "modified result" }; + }); +} +``` + +**Available events:** +- `session` - startup, session switch, clear +- `branch` - before branching (can skip conversation restore) +- `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/git-checkpoint.ts b/packages/coding-agent/examples/hooks/git-checkpoint.ts new file mode 100644 index 00000000..eebd9913 --- /dev/null +++ b/packages/coding-agent/examples/hooks/git-checkpoint.ts @@ -0,0 +1,48 @@ +/** + * Git Checkpoint Hook + * + * Creates git stash checkpoints at each turn so /branch can restore code state. + * When branching, offers to restore code to that point in history. + */ + +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 ctx.exec("git", ["stash", "create"]); + const ref = stdout.trim(); + if (ref) { + checkpoints.set(event.turnIndex, ref); + } + }); + + pi.on("branch", async (event, ctx) => { + const ref = checkpoints.get(event.targetTurnIndex); + if (!ref) return undefined; + + if (!ctx.hasUI) { + // In non-interactive mode, don't restore automatically + return undefined; + } + + const choice = await ctx.ui.select("Restore code state?", [ + "Yes, restore code to that point", + "No, keep current code", + ]); + + if (choice?.startsWith("Yes")) { + await ctx.exec("git", ["stash", "apply", ref]); + ctx.ui.notify("Code restored to checkpoint", "info"); + } + + return undefined; + }); + + pi.on("agent_end", async () => { + // Clear checkpoints after agent completes + checkpoints.clear(); + }); +} diff --git a/packages/coding-agent/examples/hooks/permission-gate.ts b/packages/coding-agent/examples/hooks/permission-gate.ts new file mode 100644 index 00000000..8affcf80 --- /dev/null +++ b/packages/coding-agent/examples/hooks/permission-gate.ts @@ -0,0 +1,38 @@ +/** + * Permission Gate Hook + * + * Prompts for confirmation before running potentially dangerous bash commands. + * Patterns checked: rm -rf, sudo, chmod/chown 777 + */ + +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, + ]; + + pi.on("tool_call", async (event, ctx) => { + if (event.toolName !== "bash") return undefined; + + const command = event.input.command as string; + const isDangerous = dangerousPatterns.some((p) => p.test(command)); + + if (isDangerous) { + if (!ctx.hasUI) { + // In non-interactive mode, block by default + return { block: true, reason: "Dangerous command blocked (no UI for confirmation)" }; + } + + 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" }; + } + } + + return undefined; + }); +} diff --git a/packages/coding-agent/examples/hooks/protected-paths.ts b/packages/coding-agent/examples/hooks/protected-paths.ts new file mode 100644 index 00000000..7aec0d46 --- /dev/null +++ b/packages/coding-agent/examples/hooks/protected-paths.ts @@ -0,0 +1,30 @@ +/** + * Protected Paths Hook + * + * Blocks write and edit operations to protected paths. + * Useful for preventing accidental modifications to sensitive files. + */ + +import type { HookAPI } from "@mariozechner/pi-coding-agent/hooks"; + +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; + } + + const path = event.input.path as string; + const isProtected = protectedPaths.some((p) => path.includes(p)); + + if (isProtected) { + if (ctx.hasUI) { + ctx.ui.notify(`Blocked write to protected path: ${path}`, "warning"); + } + return { block: true, reason: `Path "${path}" is protected` }; + } + + return undefined; + }); +} diff --git a/packages/coding-agent/package.json b/packages/coding-agent/package.json index 54ca0998..b3ba63a6 100644 --- a/packages/coding-agent/package.json +++ b/packages/coding-agent/package.json @@ -25,6 +25,7 @@ "files": [ "dist", "docs", + "examples", "CHANGELOG.md" ], "scripts": { @@ -32,9 +33,9 @@ "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/", + "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/", "dev": "tsgo -p tsconfig.build.json --watch --preserveWatchOutput", - "check": "tsgo --noEmit", + "check": "tsgo --noEmit && tsc -p tsconfig.examples.json", "test": "vitest --run", "prepublishOnly": "npm run clean && npm run build" }, diff --git a/packages/coding-agent/src/cli/args.ts b/packages/coding-agent/src/cli/args.ts index e6a5d018..bc68953b 100644 --- a/packages/coding-agent/src/cli/args.ts +++ b/packages/coding-agent/src/cli/args.ts @@ -26,6 +26,7 @@ export interface Args { models?: string[]; tools?: ToolName[]; hooks?: string[]; + customTools?: string[]; print?: boolean; export?: string; noSkills?: boolean; @@ -109,6 +110,9 @@ export function parseArgs(args: string[]): Args { } else if (arg === "--hook" && i + 1 < args.length) { result.hooks = result.hooks ?? []; result.hooks.push(args[++i]); + } else if (arg === "--tool" && i + 1 < args.length) { + result.customTools = result.customTools ?? []; + result.customTools.push(args[++i]); } else if (arg === "--no-skills") { result.noSkills = true; } else if (arg === "--list-models") { @@ -151,6 +155,7 @@ ${chalk.bold("Options:")} Available: read, bash, edit, write, grep, find, ls --thinking Set thinking level: off, minimal, low, medium, high, xhigh --hook Load a hook file (can be used multiple times) + --tool Load a custom tool file (can be used multiple times) --no-skills Disable skills discovery and loading --export Export session file to HTML and exit --list-models [search] List available models (with optional fuzzy search) diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index 1c55fba5..2c6bc761 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -19,6 +19,7 @@ import { isContextOverflow } from "@mariozechner/pi-ai"; import { getModelsPath } from "../config.js"; import { type BashResult, executeBash as executeBashCommand } from "./bash-executor.js"; import { calculateContextTokens, compact, shouldCompact } from "./compaction.js"; +import type { LoadedCustomTool, SessionEvent as ToolSessionEvent } from "./custom-tools/index.js"; import { exportSessionToHtml } from "./export-html.js"; import type { BranchEventResult, HookRunner, TurnEndEvent, TurnStartEvent } from "./hooks/index.js"; import type { BashExecutionMessage } from "./messages.js"; @@ -52,6 +53,8 @@ export interface AgentSessionConfig { fileCommands?: FileSlashCommand[]; /** Hook runner (created in main.ts with wrapped tools) */ hookRunner?: HookRunner | null; + /** Custom tools for session lifecycle events */ + customTools?: LoadedCustomTool[]; } /** Options for AgentSession.prompt() */ @@ -132,6 +135,9 @@ export class AgentSession { private _hookRunner: HookRunner | null = null; private _turnIndex = 0; + // Custom tools for session lifecycle + private _customTools: LoadedCustomTool[] = []; + constructor(config: AgentSessionConfig) { this.agent = config.agent; this.sessionManager = config.sessionManager; @@ -139,6 +145,7 @@ export class AgentSession { this._scopedModels = config.scopedModels ?? []; this._fileCommands = config.fileCommands ?? []; this._hookRunner = config.hookRunner ?? null; + this._customTools = config.customTools ?? []; } // ========================================================================= @@ -465,12 +472,29 @@ export class AgentSession { * Listeners are preserved and will continue receiving events. */ async reset(): Promise { + const previousSessionFile = this.sessionFile; + this._disconnectFromAgent(); await this.abort(); this.agent.reset(); this.sessionManager.reset(); this._queuedMessages = []; this._reconnectToAgent(); + + // Emit session event with reason "clear" to hooks + if (this._hookRunner) { + this._hookRunner.setSessionFile(this.sessionFile); + await this._hookRunner.emit({ + type: "session", + entries: [], + sessionFile: this.sessionFile, + previousSessionFile, + reason: "clear", + }); + } + + // Emit session event to custom tools + await this._emitToolSessionEvent("clear", previousSessionFile); } // ========================================================================= @@ -1086,19 +1110,25 @@ export class AgentSession { // Set new session this.sessionManager.setSessionFile(sessionPath); - // Emit session_switch event + // Reload messages + const entries = this.sessionManager.loadEntries(); + const loaded = loadSessionFromEntries(entries); + + // Emit session event to hooks if (this._hookRunner) { this._hookRunner.setSessionFile(sessionPath); await this._hookRunner.emit({ - type: "session_switch", - newSessionFile: sessionPath, + type: "session", + entries, + sessionFile: sessionPath, previousSessionFile, reason: "switch", }); } - // Reload messages - const loaded = loadSessionFromEntries(this.sessionManager.loadEntries()); + // Emit session event to custom tools + await this._emitToolSessionEvent("switch", previousSessionFile); + this.agent.replaceMessages(loaded.messages); // Restore model if saved @@ -1163,19 +1193,25 @@ export class AgentSession { this.sessionManager.setSessionFile(newSessionFile); } - // Emit session_switch event (in --no-session mode, both files are null) + // Reload messages from entries (works for both file and in-memory mode) + const newEntries = this.sessionManager.loadEntries(); + const loaded = loadSessionFromEntries(newEntries); + + // Emit session event to hooks (in --no-session mode, both files are null) if (this._hookRunner) { this._hookRunner.setSessionFile(newSessionFile); await this._hookRunner.emit({ - type: "session_switch", - newSessionFile, + type: "session", + entries: newEntries, + sessionFile: newSessionFile, previousSessionFile, - reason: "branch", + reason: "switch", }); } - // Reload messages from entries (works for both file and in-memory mode) - const loaded = loadSessionFromEntries(this.sessionManager.loadEntries()); + // Emit session event to custom tools (with reason "branch") + await this._emitToolSessionEvent("branch", previousSessionFile); + this.agent.replaceMessages(loaded.messages); return { selectedText, skipped: false }; @@ -1313,4 +1349,36 @@ export class AgentSession { get hookRunner(): HookRunner | null { return this._hookRunner; } + + /** + * Get custom tools (for setting UI context in modes). + */ + get customTools(): LoadedCustomTool[] { + return this._customTools; + } + + /** + * Emit session event to all custom tools. + * Called on session switch, branch, and clear. + */ + private async _emitToolSessionEvent( + reason: ToolSessionEvent["reason"], + previousSessionFile: string | null, + ): Promise { + const event: ToolSessionEvent = { + entries: this.sessionManager.loadEntries(), + sessionFile: this.sessionFile, + previousSessionFile, + reason, + }; + for (const { tool } of this._customTools) { + if (tool.onSession) { + try { + await tool.onSession(event); + } catch (_err) { + // Silently ignore tool errors during session events + } + } + } + } } diff --git a/packages/coding-agent/src/core/custom-tools/index.ts b/packages/coding-agent/src/core/custom-tools/index.ts new file mode 100644 index 00000000..2bfe0ead --- /dev/null +++ b/packages/coding-agent/src/core/custom-tools/index.ts @@ -0,0 +1,16 @@ +/** + * Custom tools module. + */ + +export { discoverAndLoadCustomTools, loadCustomTools } from "./loader.js"; +export type { + CustomAgentTool, + CustomToolFactory, + CustomToolsLoadResult, + ExecResult, + LoadedCustomTool, + RenderResultOptions, + SessionEvent, + ToolAPI, + ToolUIContext, +} from "./types.js"; diff --git a/packages/coding-agent/src/core/custom-tools/loader.ts b/packages/coding-agent/src/core/custom-tools/loader.ts new file mode 100644 index 00000000..77ba3cac --- /dev/null +++ b/packages/coding-agent/src/core/custom-tools/loader.ts @@ -0,0 +1,258 @@ +/** + * Custom tool loader - loads TypeScript tool modules using jiti. + */ + +import { spawn } from "node:child_process"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { createJiti } from "jiti"; +import { getAgentDir } from "../../config.js"; +import type { HookUIContext } from "../hooks/types.js"; +import type { CustomToolFactory, CustomToolsLoadResult, ExecResult, LoadedCustomTool, ToolAPI } from "./types.js"; + +const UNICODE_SPACES = /[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g; + +function normalizeUnicodeSpaces(str: string): string { + return str.replace(UNICODE_SPACES, " "); +} + +function expandPath(p: string): string { + const normalized = normalizeUnicodeSpaces(p); + if (normalized.startsWith("~/")) { + return path.join(os.homedir(), normalized.slice(2)); + } + if (normalized.startsWith("~")) { + return path.join(os.homedir(), normalized.slice(1)); + } + return normalized; +} + +/** + * Resolve tool path. + * - Absolute paths used as-is + * - Paths starting with ~ expanded to home directory + * - Relative paths resolved from cwd + */ +function resolveToolPath(toolPath: string, cwd: string): string { + const expanded = expandPath(toolPath); + + if (path.isAbsolute(expanded)) { + return expanded; + } + + // Relative paths resolved from cwd + return path.resolve(cwd, expanded); +} + +/** + * Execute a command and return stdout/stderr/code. + */ +async function execCommand(command: string, args: string[], cwd: string): Promise { + return new Promise((resolve) => { + const proc = spawn(command, args, { + cwd, + shell: false, + stdio: ["ignore", "pipe", "pipe"], + }); + + let stdout = ""; + let stderr = ""; + + proc.stdout.on("data", (data) => { + stdout += data.toString(); + }); + + proc.stderr.on("data", (data) => { + stderr += data.toString(); + }); + + proc.on("close", (code) => { + resolve({ + stdout, + stderr, + code: code ?? 0, + }); + }); + + proc.on("error", (err) => { + resolve({ + stdout, + stderr: stderr || err.message, + code: 1, + }); + }); + }); +} + +/** + * Create a no-op UI context for headless modes. + */ +function createNoOpUIContext(): HookUIContext { + return { + select: async () => null, + confirm: async () => false, + input: async () => null, + notify: () => {}, + }; +} + +/** + * Load a single tool module using jiti. + */ +async function loadTool( + toolPath: string, + cwd: string, + sharedApi: ToolAPI, +): Promise<{ tools: LoadedCustomTool[] | null; error: string | null }> { + const resolvedPath = resolveToolPath(toolPath, cwd); + + try { + // Create jiti instance for TypeScript/ESM loading + const jiti = createJiti(import.meta.url); + + // Import the module + const module = await jiti.import(resolvedPath, { default: true }); + const factory = module as CustomToolFactory; + + if (typeof factory !== "function") { + return { tools: null, error: "Tool must export a default function" }; + } + + // Call factory with shared API + const result = await factory(sharedApi); + + // Handle single tool or array of tools + const toolsArray = Array.isArray(result) ? result : [result]; + + const loadedTools: LoadedCustomTool[] = toolsArray.map((tool) => ({ + path: toolPath, + resolvedPath, + tool, + })); + + return { tools: loadedTools, error: null }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return { tools: null, error: `Failed to load tool: ${message}` }; + } +} + +/** + * Load all tools from configuration. + * @param paths - Array of tool file paths + * @param cwd - Current working directory for resolving relative paths + * @param builtInToolNames - Names of built-in tools to check for conflicts + */ +export async function loadCustomTools( + paths: string[], + cwd: string, + builtInToolNames: string[], +): Promise { + const tools: LoadedCustomTool[] = []; + const errors: Array<{ path: string; error: string }> = []; + const seenNames = new Set(builtInToolNames); + + // Shared API object - all tools get the same instance + const sharedApi: ToolAPI = { + cwd, + exec: (command: string, args: string[]) => execCommand(command, args, cwd), + ui: createNoOpUIContext(), + hasUI: false, + }; + + for (const toolPath of paths) { + const { tools: loadedTools, error } = await loadTool(toolPath, cwd, sharedApi); + + if (error) { + errors.push({ path: toolPath, error }); + continue; + } + + if (loadedTools) { + for (const loadedTool of loadedTools) { + // Check for name conflicts + if (seenNames.has(loadedTool.tool.name)) { + errors.push({ + path: toolPath, + error: `Tool name "${loadedTool.tool.name}" conflicts with existing tool`, + }); + continue; + } + + seenNames.add(loadedTool.tool.name); + tools.push(loadedTool); + } + } + } + + return { + tools, + errors, + setUIContext(uiContext, hasUI) { + sharedApi.ui = uiContext; + sharedApi.hasUI = hasUI; + }, + }; +} + +/** + * Discover tool files from a directory. + * Returns all .ts files in the directory (non-recursive). + */ +function discoverToolsInDir(dir: string): string[] { + if (!fs.existsSync(dir)) { + return []; + } + + try { + const entries = fs.readdirSync(dir, { withFileTypes: true }); + return entries.filter((e) => e.isFile() && e.name.endsWith(".ts")).map((e) => path.join(dir, e.name)); + } catch { + return []; + } +} + +/** + * Discover and load tools from standard locations: + * 1. ~/.pi/agent/tools/*.ts (global) + * 2. cwd/.pi/tools/*.ts (project-local) + * + * Plus any explicitly configured paths from settings or CLI. + * + * @param configuredPaths - Explicit paths from settings.json and CLI --tool flags + * @param cwd - Current working directory + * @param builtInToolNames - Names of built-in tools to check for conflicts + */ +export async function discoverAndLoadCustomTools( + configuredPaths: string[], + cwd: string, + builtInToolNames: string[], +): Promise { + const allPaths: string[] = []; + const seen = new Set(); + + // Helper to add paths without duplicates + const addPaths = (paths: string[]) => { + for (const p of paths) { + const resolved = path.resolve(p); + if (!seen.has(resolved)) { + seen.add(resolved); + allPaths.push(p); + } + } + }; + + // 1. Global tools: ~/.pi/agent/tools/ + const globalToolsDir = path.join(getAgentDir(), "tools"); + addPaths(discoverToolsInDir(globalToolsDir)); + + // 2. Project-local tools: cwd/.pi/tools/ + const localToolsDir = path.join(cwd, ".pi", "tools"); + addPaths(discoverToolsInDir(localToolsDir)); + + // 3. Explicitly configured paths (can override/add) + addPaths(configuredPaths.map((p) => resolveToolPath(p, cwd))); + + return loadCustomTools(allPaths, cwd, builtInToolNames); +} diff --git a/packages/coding-agent/src/core/custom-tools/types.ts b/packages/coding-agent/src/core/custom-tools/types.ts new file mode 100644 index 00000000..66e18ff1 --- /dev/null +++ b/packages/coding-agent/src/core/custom-tools/types.ts @@ -0,0 +1,90 @@ +/** + * Custom tool types. + * + * Custom tools are TypeScript modules that define additional tools for the agent. + * They can provide custom rendering for tool calls and results in the TUI. + */ + +import type { AgentTool, AgentToolResult } 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 { HookUIContext } from "../hooks/types.js"; +import type { SessionEntry } from "../session-manager.js"; + +/** Alias for clarity */ +export type ToolUIContext = HookUIContext; + +export interface ExecResult { + stdout: string; + stderr: string; + code: number; +} + +/** API passed to custom tool factory (stable across session changes) */ +export interface ToolAPI { + /** Current working directory */ + cwd: string; + /** Execute a command */ + exec(command: string, args: string[]): Promise; + /** UI methods for user interaction (select, confirm, input, notify) */ + ui: ToolUIContext; + /** Whether UI is available (false in print/RPC mode) */ + hasUI: boolean; +} + +/** Session event passed to onSession callback */ +export interface SessionEvent { + /** All session entries (including pre-compaction history) */ + entries: SessionEntry[]; + /** Current session file path, or null in --no-session mode */ + sessionFile: string | null; + /** Previous session file path, or null for "start" and "clear" */ + previousSessionFile: string | null; + /** Reason for the session event */ + reason: "start" | "switch" | "branch" | "clear"; +} + +/** Rendering options passed to renderResult */ +export interface RenderResultOptions { + /** Whether the result view is expanded */ + expanded: boolean; + /** Whether this is a partial/streaming result */ + isPartial: boolean; +} + +/** Custom tool with optional lifecycle and rendering methods */ +export interface CustomAgentTool + extends AgentTool { + /** Called on session start/switch/branch/clear - use to reconstruct state from entries */ + onSession?: (event: SessionEvent) => 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; + /** Called when session ends - cleanup resources */ + dispose?: () => Promise | void; +} + +/** Factory function that creates a custom tool or array of tools */ +export type CustomToolFactory = ( + pi: ToolAPI, +) => CustomAgentTool | CustomAgentTool[] | Promise; + +/** Loaded custom tool with metadata */ +export interface LoadedCustomTool { + /** Original path (as specified) */ + path: string; + /** Resolved absolute path */ + resolvedPath: string; + /** The tool instance */ + tool: CustomAgentTool; +} + +/** Result from loading custom tools */ +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; +} diff --git a/packages/coding-agent/src/core/hooks/index.ts b/packages/coding-agent/src/core/hooks/index.ts index 8cf1be90..739603e1 100644 --- a/packages/coding-agent/src/core/hooks/index.ts +++ b/packages/coding-agent/src/core/hooks/index.ts @@ -13,8 +13,7 @@ export type { HookEventContext, HookFactory, HookUIContext, - SessionStartEvent, - SessionSwitchEvent, + SessionEvent, ToolCallEvent, ToolCallEventResult, ToolResultEvent, diff --git a/packages/coding-agent/src/core/hooks/types.ts b/packages/coding-agent/src/core/hooks/types.ts index d8cb043e..546897fc 100644 --- a/packages/coding-agent/src/core/hooks/types.ts +++ b/packages/coding-agent/src/core/hooks/types.ts @@ -73,25 +73,20 @@ export interface HookEventContext { // ============================================================================ /** - * Event data for session_start event. - * Fired once when the coding agent starts up. + * Event data for session event. + * Fired on startup and when session changes (switch or clear). + * Note: branch has its own event that fires BEFORE the branch happens. */ -export interface SessionStartEvent { - type: "session_start"; -} - -/** - * Event data for session_switch event. - * Fired when the session changes (branch or session switch). - */ -export interface SessionSwitchEvent { - type: "session_switch"; - /** New session file path, or null in --no-session mode */ - newSessionFile: string | null; - /** Previous session file path, or null in --no-session mode */ +export interface SessionEvent { + type: "session"; + /** All session entries (including pre-compaction history) */ + entries: SessionEntry[]; + /** Current session file path, or null in --no-session mode */ + sessionFile: string | null; + /** Previous session file path, or null for "start" and "clear" */ previousSessionFile: string | null; - /** Reason for the switch */ - reason: "branch" | "switch"; + /** Reason for the session event */ + reason: "start" | "switch" | "clear"; } /** @@ -176,8 +171,7 @@ export interface BranchEvent { * Union of all hook event types. */ export type HookEvent = - | SessionStartEvent - | SessionSwitchEvent + | SessionEvent | AgentStartEvent | AgentEndEvent | TurnStartEvent @@ -235,8 +229,7 @@ export type HookHandler = (event: E, ctx: HookEventContext) => Prom * Hooks use pi.on() to subscribe to events and pi.send() to inject messages. */ export interface HookAPI { - on(event: "session_start", handler: HookHandler): void; - on(event: "session_switch", handler: HookHandler): void; + on(event: "session", handler: HookHandler): void; on(event: "agent_start", handler: HookHandler): void; on(event: "agent_end", handler: HookHandler): void; on(event: "turn_start", handler: HookHandler): void; diff --git a/packages/coding-agent/src/core/index.ts b/packages/coding-agent/src/core/index.ts index 48759028..117d96b6 100644 --- a/packages/coding-agent/src/core/index.ts +++ b/packages/coding-agent/src/core/index.ts @@ -13,6 +13,18 @@ export { type SessionStats, } from "./agent-session.js"; export { type BashExecutorOptions, type BashResult, executeBash } from "./bash-executor.js"; +export { + type CustomAgentTool, + type CustomToolFactory, + type CustomToolsLoadResult, + discoverAndLoadCustomTools, + type ExecResult, + type LoadedCustomTool, + loadCustomTools, + type RenderResultOptions, + type ToolAPI, + type ToolUIContext, +} from "./custom-tools/index.js"; export { type HookAPI, type HookError, diff --git a/packages/coding-agent/src/core/settings-manager.ts b/packages/coding-agent/src/core/settings-manager.ts index 4cb6b8e5..46480bc6 100644 --- a/packages/coding-agent/src/core/settings-manager.ts +++ b/packages/coding-agent/src/core/settings-manager.ts @@ -36,6 +36,7 @@ export interface Settings { 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; } @@ -231,6 +232,15 @@ export class SettingsManager { this.save(); } + getCustomToolPaths(): string[] { + return this.settings.customTools ?? []; + } + + setCustomToolPaths(paths: string[]): void { + this.settings.customTools = paths; + this.save(); + } + getSkillsEnabled(): boolean { return this.settings.skills?.enabled ?? true; } diff --git a/packages/coding-agent/src/index.ts b/packages/coding-agent/src/index.ts index 0226ca2e..e35dd122 100644 --- a/packages/coding-agent/src/index.ts +++ b/packages/coding-agent/src/index.ts @@ -22,6 +22,19 @@ export { getLastAssistantUsage, shouldCompact, } from "./core/compaction.js"; +// Custom tools +export type { + CustomAgentTool, + CustomToolFactory, + CustomToolsLoadResult, + ExecResult, + LoadedCustomTool, + RenderResultOptions, + SessionEvent as ToolSessionEvent, + ToolAPI, + ToolUIContext, +} from "./core/custom-tools/index.js"; +export { discoverAndLoadCustomTools, loadCustomTools } from "./core/custom-tools/index.js"; // Hook system types export type { AgentEndEvent, @@ -33,8 +46,7 @@ export type { HookEventContext, HookFactory, HookUIContext, - SessionStartEvent, - SessionSwitchEvent, + SessionEvent, ToolCallEvent, ToolCallEventResult, ToolResultEvent, diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index 0d08ebc1..7be2f093 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -10,6 +10,7 @@ import { listModels } from "./cli/list-models.js"; import { selectSession } from "./cli/session-picker.js"; import { getModelsPath, VERSION } from "./config.js"; import { AgentSession } from "./core/agent-session.js"; +import { discoverAndLoadCustomTools, type LoadedCustomTool } from "./core/custom-tools/index.js"; import { exportFromFile } from "./core/export-html.js"; import { discoverAndLoadHooks, HookRunner, wrapToolsWithHooks } from "./core/hooks/index.js"; import { messageTransformer } from "./core/messages.js"; @@ -53,11 +54,13 @@ async function runInteractiveMode( modelFallbackMessage: string | null, versionCheckPromise: Promise, initialMessages: string[], + customTools: LoadedCustomTool[], + setToolUIContext: (uiContext: import("./core/hooks/types.js").HookUIContext, hasUI: boolean) => void, initialMessage?: string, initialAttachments?: Attachment[], fdPath: string | null = null, ): Promise { - const mode = new InteractiveMode(session, version, changelogMarkdown, fdPath); + const mode = new InteractiveMode(session, version, changelogMarkdown, customTools, setToolUIContext, fdPath); // Initialize TUI (subscribes to agent events internally) await mode.init(); @@ -317,6 +320,30 @@ export async function main(args: string[]) { selectedTools = wrapToolsWithHooks(selectedTools, hookRunner); } + // Discover and load custom tools from: + // 1. ~/.pi/agent/tools/*.ts (global) + // 2. cwd/.pi/tools/*.ts (project-local) + // 3. Explicit paths in settings.json + // 4. CLI --tool flags + const configuredToolPaths = [...settingsManager.getCustomToolPaths(), ...(parsed.customTools ?? [])]; + const builtInToolNames = Object.keys(allTools); + const { + tools: loadedCustomTools, + errors: toolErrors, + setUIContext: setToolUIContext, + } = await discoverAndLoadCustomTools(configuredToolPaths, cwd, builtInToolNames); + + // Report custom tool loading errors + for (const { path, error } of toolErrors) { + console.error(chalk.red(`Failed to load custom tool "${path}": ${error}`)); + } + + // Add custom tools to selected tools + if (loadedCustomTools.length > 0) { + const customToolInstances = loadedCustomTools.map((lt) => lt.tool); + selectedTools = [...selectedTools, ...customToolInstances] as typeof selectedTools; + } + // Create agent const agent = new Agent({ initialState: { @@ -373,6 +400,7 @@ export async function main(args: string[]) { scopedModels, fileCommands, hookRunner, + customTools: loadedCustomTools, }); // Route to appropriate mode @@ -406,6 +434,8 @@ export async function main(args: string[]) { modelFallbackMessage, versionCheckPromise, parsed.messages, + loadedCustomTools, + setToolUIContext, initialMessage, initialAttachments, fdPath, 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 36f4aaf6..8bd10424 100644 --- a/packages/coding-agent/src/modes/interactive/components/tool-execution.ts +++ b/packages/coding-agent/src/modes/interactive/components/tool-execution.ts @@ -1,5 +1,6 @@ import * as os from "node:os"; import { + Box, Container, getCapabilities, getImageDimensions, @@ -9,6 +10,7 @@ import { Text, } from "@mariozechner/pi-tui"; import stripAnsi from "strip-ansi"; +import type { CustomAgentTool } from "../../../core/custom-tools/types.js"; import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize } from "../../../core/tools/truncate.js"; import { theme } from "../theme/theme.js"; @@ -38,27 +40,37 @@ export interface ToolExecutionOptions { * Component that renders a tool call with its result (updateable) */ export class ToolExecutionComponent extends Container { - private contentText: Text; + private contentBox: Box; + private contentText: Text; // For built-in tools private imageComponents: Image[] = []; private toolName: string; private args: any; private expanded = false; private showImages: boolean; private isPartial = true; + private customTool?: CustomAgentTool; private result?: { content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>; isError: boolean; details?: any; }; - constructor(toolName: string, args: any, options: ToolExecutionOptions = {}) { + constructor(toolName: string, args: any, options: ToolExecutionOptions = {}, customTool?: CustomAgentTool) { super(); this.toolName = toolName; this.args = args; this.showImages = options.showImages ?? true; + this.customTool = customTool; + this.addChild(new Spacer(1)); - this.contentText = new Text("", 1, 1, (text: string) => theme.bg("toolPendingBg", text)); - this.addChild(this.contentText); + + // Box wraps content with padding and background + this.contentBox = new Box(1, 1, (text: string) => theme.bg("toolPendingBg", text)); + this.addChild(this.contentBox); + + // Text component for built-in tool rendering + this.contentText = new Text("", 0, 0); + this.updateDisplay(); } @@ -91,15 +103,66 @@ export class ToolExecutionComponent extends Container { } private updateDisplay(): void { + // Set background based on state const bgFn = this.isPartial ? (text: string) => theme.bg("toolPendingBg", text) : this.result?.isError ? (text: string) => theme.bg("toolErrorBg", text) : (text: string) => theme.bg("toolSuccessBg", text); - this.contentText.setCustomBgFn(bgFn); - this.contentText.setText(this.formatToolExecution()); + this.contentBox.setBgFn(bgFn); + this.contentBox.clear(); + // Check for custom tool rendering + if (this.customTool) { + // Render call component + if (this.customTool.renderCall) { + try { + const callComponent = this.customTool.renderCall(this.args, theme); + if (callComponent) { + this.contentBox.addChild(callComponent); + } + } catch { + // Fall back to default on error + this.contentBox.addChild(new Text(theme.fg("toolTitle", theme.bold(this.toolName)), 0, 0)); + } + } else { + // No custom renderCall, show tool name + this.contentBox.addChild(new Text(theme.fg("toolTitle", theme.bold(this.toolName)), 0, 0)); + } + + // Render result component if we have a result + if (this.result && this.customTool.renderResult) { + try { + const resultComponent = this.customTool.renderResult( + { content: this.result.content as any, details: this.result.details }, + { expanded: this.expanded, isPartial: this.isPartial }, + theme, + ); + if (resultComponent) { + this.contentBox.addChild(resultComponent); + } + } catch { + // Fall back to showing raw output on error + const output = this.getTextOutput(); + if (output) { + this.contentBox.addChild(new Text(theme.fg("toolOutput", output), 0, 0)); + } + } + } else if (this.result) { + // Has result but no custom renderResult + const output = this.getTextOutput(); + if (output) { + this.contentBox.addChild(new Text(theme.fg("toolOutput", output), 0, 0)); + } + } + } else { + // Built-in tool: use existing formatToolExecution + this.contentText.setText(this.formatToolExecution()); + this.contentBox.addChild(this.contentText); + } + + // Handle images (same for both custom and built-in) for (const img of this.imageComponents) { this.removeChild(img); } @@ -110,7 +173,6 @@ export class ToolExecutionComponent extends Container { const caps = getCapabilities(); for (const img of imageBlocks) { - // Show inline image only if terminal supports it AND user setting allows it if (caps.images && this.showImages && img.data && img.mimeType) { this.addChild(new Spacer(1)); const imageComponent = new Image( @@ -142,7 +204,6 @@ export class ToolExecutionComponent extends Container { .join("\n"); const caps = getCapabilities(); - // Show text fallback if terminal doesn't support images OR if user disabled inline images if (imageBlocks.length > 0 && (!caps.images || !this.showImages)) { const imageIndicators = imageBlocks .map((img: any) => { @@ -159,7 +220,6 @@ export class ToolExecutionComponent extends Container { private formatToolExecution(): string { let text = ""; - // Format based on tool type if (this.toolName === "bash") { const command = this.args?.command || ""; text = theme.fg("toolTitle", theme.bold(`$ ${command || theme.fg("toolOutput", "...")}`)); @@ -180,7 +240,6 @@ export class ToolExecutionComponent extends Container { displayLines.map((line: string) => theme.fg("toolOutput", line)).join("\n"); } - // Show truncation warning at the bottom (outside collapsed area) const truncation = this.result.details?.truncation; const fullOutputPath = this.result.details?.fullOutputPath; if (truncation?.truncated || fullOutputPath) { @@ -205,7 +264,6 @@ export class ToolExecutionComponent extends Container { const offset = this.args?.offset; const limit = this.args?.limit; - // Build path display with offset/limit suffix (in warning color if offset/limit used) let pathDisplay = path ? theme.fg("accent", path) : theme.fg("toolOutput", "..."); if (offset !== undefined || limit !== undefined) { const startLine = offset ?? 1; @@ -228,7 +286,6 @@ export class ToolExecutionComponent extends Container { text += theme.fg("toolOutput", `\n... (${remaining} more lines)`); } - // Show truncation warning at the bottom (outside collapsed area) const truncation = this.result.details?.truncation; if (truncation?.truncated) { if (truncation.firstLineExceedsLimit) { @@ -269,7 +326,6 @@ export class ToolExecutionComponent extends Container { text += ` (${totalLines} lines)`; } - // Show first 10 lines of content if available if (fileContent) { const maxLines = this.expanded ? lines.length : 10; const displayLines = lines.slice(0, maxLines); @@ -288,14 +344,12 @@ export class ToolExecutionComponent extends Container { (path ? theme.fg("accent", path) : theme.fg("toolOutput", "...")); if (this.result) { - // Show error message if it's an error if (this.result.isError) { const errorText = this.getTextOutput(); if (errorText) { text += "\n\n" + theme.fg("error", errorText); } } else if (this.result.details?.diff) { - // Show diff if available const diffLines = this.result.details.diff.split("\n"); const coloredLines = diffLines.map((line: string) => { if (line.startsWith("+")) { @@ -332,7 +386,6 @@ export class ToolExecutionComponent extends Container { } } - // Show truncation warning at the bottom (outside collapsed area) const entryLimit = this.result.details?.entryLimitReached; const truncation = this.result.details?.truncation; if (entryLimit || truncation?.truncated) { @@ -374,7 +427,6 @@ export class ToolExecutionComponent extends Container { } } - // Show truncation warning at the bottom (outside collapsed area) const resultLimit = this.result.details?.resultLimitReached; const truncation = this.result.details?.truncation; if (resultLimit || truncation?.truncated) { @@ -420,7 +472,6 @@ export class ToolExecutionComponent extends Container { } } - // Show truncation warning at the bottom (outside collapsed area) const matchLimit = this.result.details?.matchLimitReached; const truncation = this.result.details?.truncation; const linesTruncated = this.result.details?.linesTruncated; @@ -439,7 +490,7 @@ export class ToolExecutionComponent extends Container { } } } else { - // Generic tool + // Generic tool (shouldn't reach here for custom tools) text = theme.fg("toolTitle", theme.bold(this.toolName)); const content = JSON.stringify(this.args, null, 2); diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 0c2cad11..35023b58 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -26,6 +26,7 @@ import { import { exec } from "child_process"; import { APP_NAME, getDebugLogPath, getOAuthPath } 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 { HookUIContext } from "../../core/hooks/index.js"; import { isBashExecutionMessage } from "../../core/messages.js"; import { invalidateOAuthCache } from "../../core/model-config.js"; @@ -113,6 +114,9 @@ export class InteractiveMode { private hookSelector: HookSelectorComponent | null = null; private hookInput: HookInputComponent | null = null; + // Custom tools for custom rendering + private customTools: Map; + // Convenience accessors private get agent() { return this.session.agent; @@ -128,11 +132,14 @@ export class InteractiveMode { session: AgentSession, version: string, changelogMarkdown: string | null = null, + customTools: LoadedCustomTool[] = [], + private setToolUIContext: (uiContext: HookUIContext, hasUI: boolean) => void = () => {}, fdPath: string | null = null, ) { this.session = session; this.version = version; this.changelogMarkdown = changelogMarkdown; + this.customTools = new Map(customTools.map((ct) => [ct.tool.name, ct])); this.ui = new TUI(new ProcessTerminal()); this.chatContainer = new Container(); this.pendingMessagesContainer = new Container(); @@ -263,7 +270,7 @@ export class InteractiveMode { this.isInitialized = true; // Initialize hooks with TUI-based UI context - await this.initHooks(); + await this.initHooksAndCustomTools(); // Subscribe to agent events this.subscribeToAgent(); @@ -288,7 +295,7 @@ export class InteractiveMode { /** * Initialize the hook system with TUI-based UI context. */ - private async initHooks(): Promise { + private async initHooksAndCustomTools(): Promise { // Show loaded project context files const contextFiles = loadProjectContextFiles(); if (contextFiles.length > 0) { @@ -305,13 +312,37 @@ export class InteractiveMode { this.chatContainer.addChild(new Spacer(1)); } + // Show loaded custom tools + if (this.customTools.size > 0) { + const toolList = Array.from(this.customTools.values()) + .map((ct) => theme.fg("dim", ` ${ct.tool.name} (${ct.path})`)) + .join("\n"); + this.chatContainer.addChild(new Text(theme.fg("muted", "Loaded custom tools:\n") + toolList, 0, 0)); + this.chatContainer.addChild(new Spacer(1)); + } + + // Load session entries if any + const entries = this.session.sessionManager.loadEntries(); + + // Set TUI-based UI context for custom tools + const uiContext = this.createHookUIContext(); + this.setToolUIContext(uiContext, true); + + // Notify custom tools of session start + await this.emitToolSessionEvent({ + entries, + sessionFile: this.session.sessionFile, + previousSessionFile: null, + reason: "start", + }); + const hookRunner = this.session.hookRunner; if (!hookRunner) { return; // No hooks loaded } - // Set TUI-based UI context on the hook runner - hookRunner.setUIContext(this.createHookUIContext(), true); + // Set UI context on hook runner + hookRunner.setUIContext(uiContext, true); hookRunner.setSessionFile(this.session.sessionFile); // Subscribe to hook errors @@ -332,8 +363,38 @@ export class InteractiveMode { this.chatContainer.addChild(new Spacer(1)); } - // Emit session_start event - await hookRunner.emit({ type: "session_start" }); + // Emit session event + await hookRunner.emit({ + type: "session", + entries, + sessionFile: this.session.sessionFile, + previousSessionFile: null, + reason: "start", + }); + } + + /** + * Emit session event to all custom tools. + */ + private async emitToolSessionEvent(event: ToolSessionEvent): Promise { + for (const { tool } of this.customTools.values()) { + if (tool.onSession) { + try { + await tool.onSession(event); + } catch (err) { + this.showToolError(tool.name, err instanceof Error ? err.message : String(err)); + } + } + } + } + + /** + * Show a tool error in the chat. + */ + private showToolError(toolName: string, error: string): void { + const errorText = new Text(theme.fg("error", `Tool "${toolName}" error: ${error}`), 1, 0); + this.chatContainer.addChild(errorText); + this.ui.requestRender(); } /** @@ -708,9 +769,14 @@ export class InteractiveMode { if (content.type === "toolCall") { if (!this.pendingTools.has(content.id)) { this.chatContainer.addChild(new Text("", 0, 0)); - const component = new ToolExecutionComponent(content.name, content.arguments, { - showImages: this.settingsManager.getShowImages(), - }); + const component = new ToolExecutionComponent( + content.name, + content.arguments, + { + showImages: this.settingsManager.getShowImages(), + }, + this.customTools.get(content.name)?.tool, + ); this.chatContainer.addChild(component); this.pendingTools.set(content.id, component); } else { @@ -750,9 +816,14 @@ export class InteractiveMode { case "tool_execution_start": { if (!this.pendingTools.has(event.toolCallId)) { - const component = new ToolExecutionComponent(event.toolName, event.args, { - showImages: this.settingsManager.getShowImages(), - }); + const component = new ToolExecutionComponent( + event.toolName, + event.args, + { + showImages: this.settingsManager.getShowImages(), + }, + this.customTools.get(event.toolName)?.tool, + ); this.chatContainer.addChild(component); this.pendingTools.set(event.toolCallId, component); this.ui.requestRender(); @@ -984,9 +1055,14 @@ export class InteractiveMode { for (const content of assistantMsg.content) { if (content.type === "toolCall") { - const component = new ToolExecutionComponent(content.name, content.arguments, { - showImages: this.settingsManager.getShowImages(), - }); + const component = new ToolExecutionComponent( + content.name, + content.arguments, + { + showImages: this.settingsManager.getShowImages(), + }, + this.customTools.get(content.name)?.tool, + ); this.chatContainer.addChild(component); if (assistantMsg.stopReason === "aborted" || assistantMsg.stopReason === "error") { @@ -1307,6 +1383,7 @@ export class InteractiveMode { this.ui.requestRender(); return; } + this.chatContainer.clear(); this.isFirstUserMessage = true; this.renderInitialMessages(this.session.state); @@ -1353,7 +1430,7 @@ export class InteractiveMode { this.streamingComponent = null; this.pendingTools.clear(); - // Switch session via AgentSession + // Switch session via AgentSession (emits hook and tool session events) await this.session.switchSession(sessionPath); // Clear and re-render the chat @@ -1560,7 +1637,7 @@ export class InteractiveMode { } this.statusContainer.clear(); - // Reset via session + // Reset via session (emits hook and tool session events) await this.session.reset(); // Clear UI state diff --git a/packages/coding-agent/src/modes/print-mode.ts b/packages/coding-agent/src/modes/print-mode.ts index ae52cb79..fa65cf4e 100644 --- a/packages/coding-agent/src/modes/print-mode.ts +++ b/packages/coding-agent/src/modes/print-mode.ts @@ -27,6 +27,9 @@ export async function runPrintMode( initialMessage?: string, initialAttachments?: Attachment[], ): Promise { + // Load entries once for session start events + const entries = session.sessionManager.loadEntries(); + // 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; @@ -40,8 +43,30 @@ export async function runPrintMode( hookRunner.setSendHandler(() => { console.error("Warning: pi.send() is not supported in print mode"); }); - // Emit session_start event - await hookRunner.emit({ type: "session_start" }); + // Emit session event + await hookRunner.emit({ + type: "session", + entries, + sessionFile: session.sessionFile, + previousSessionFile: null, + reason: "start", + }); + } + + // Emit session start event to custom tools (no UI in print mode) + for (const { tool } of session.customTools) { + if (tool.onSession) { + try { + await tool.onSession({ + entries, + sessionFile: session.sessionFile, + previousSessionFile: null, + reason: "start", + }); + } catch (_err) { + // Silently ignore tool errors + } + } } // Always subscribe to enable session persistence via _handleAgentEvent diff --git a/packages/coding-agent/src/modes/rpc/rpc-mode.ts b/packages/coding-agent/src/modes/rpc/rpc-mode.ts index f4ecd23f..f7b2ee6f 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-mode.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-mode.ts @@ -120,6 +120,9 @@ export async function runRpcMode(session: AgentSession): Promise { }, }); + // Load entries once for session start events + const entries = session.sessionManager.loadEntries(); + // Set up hooks with RPC-based UI context const hookRunner = session.hookRunner; if (hookRunner) { @@ -139,8 +142,31 @@ export async function runRpcMode(session: AgentSession): Promise { }); } }); - // Emit session_start event - await hookRunner.emit({ type: "session_start" }); + // Emit session event + await hookRunner.emit({ + type: "session", + entries, + sessionFile: session.sessionFile, + previousSessionFile: null, + reason: "start", + }); + } + + // Emit session start event to custom tools + // Note: Tools get no-op UI context in RPC mode (host handles UI via protocol) + for (const { tool } of session.customTools) { + if (tool.onSession) { + try { + await tool.onSession({ + entries, + sessionFile: session.sessionFile, + previousSessionFile: null, + reason: "start", + }); + } catch (_err) { + // Silently ignore tool errors + } + } } // Output all agent events as JSON diff --git a/packages/coding-agent/tsconfig.examples.json b/packages/coding-agent/tsconfig.examples.json new file mode 100644 index 00000000..a94bb560 --- /dev/null +++ b/packages/coding-agent/tsconfig.examples.json @@ -0,0 +1,14 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "noEmit": true, + "baseUrl": ".", + "paths": { + "@mariozechner/pi-coding-agent": ["./src/index.ts"], + "@mariozechner/pi-coding-agent/hooks": ["./src/core/hooks/index.ts"] + }, + "skipLibCheck": true + }, + "include": ["examples/**/*.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/tui/README.md b/packages/tui/README.md index f44d8e6e..f8066ac7 100644 --- a/packages/tui/README.md +++ b/packages/tui/README.md @@ -8,7 +8,7 @@ Minimal terminal UI framework with differential rendering and synchronized outpu - **Synchronized Output**: Uses CSI 2026 for atomic screen updates (no flicker) - **Bracketed Paste Mode**: Handles large pastes correctly with markers for >10 line pastes - **Component-based**: Simple Component interface with render() method -- **Built-in Components**: Text, Input, Editor, Markdown, Loader, SelectList, Spacer, Image +- **Built-in Components**: Text, Input, Editor, Markdown, Loader, SelectList, Spacer, Image, Box, Container - **Inline Images**: Renders images in terminals that support Kitty or iTerm2 graphics protocols - **Autocomplete Support**: File paths and slash commands @@ -75,6 +75,20 @@ container.addChild(component); container.removeChild(component); ``` +### Box + +Container that applies padding and background color to all children. + +```typescript +const box = new Box( + 1, // paddingX (default: 1) + 1, // paddingY (default: 1) + (text) => chalk.bgGray(text) // optional background function +); +box.addChild(new Text("Content", 0, 0)); +box.setBgFn((text) => chalk.bgBlue(text)); // Change background dynamically +``` + ### Text Displays multi-line text with word wrapping and padding. diff --git a/packages/tui/src/components/box.ts b/packages/tui/src/components/box.ts new file mode 100644 index 00000000..ede2aa14 --- /dev/null +++ b/packages/tui/src/components/box.ts @@ -0,0 +1,96 @@ +import type { Component } from "../tui.js"; +import { applyBackgroundToLine, visibleWidth } from "../utils.js"; + +/** + * Box component - a container that applies padding and background to all children + */ +export class Box implements Component { + children: Component[] = []; + private paddingX: number; + private paddingY: number; + private bgFn?: (text: string) => string; + + constructor(paddingX = 1, paddingY = 1, bgFn?: (text: string) => string) { + this.paddingX = paddingX; + this.paddingY = paddingY; + this.bgFn = bgFn; + } + + addChild(component: Component): void { + this.children.push(component); + } + + removeChild(component: Component): void { + const index = this.children.indexOf(component); + if (index !== -1) { + this.children.splice(index, 1); + } + } + + clear(): void { + this.children = []; + } + + setBgFn(bgFn?: (text: string) => string): void { + this.bgFn = bgFn; + } + + invalidate(): void { + for (const child of this.children) { + child.invalidate?.(); + } + } + + render(width: number): string[] { + if (this.children.length === 0) { + return []; + } + + const contentWidth = Math.max(1, width - this.paddingX * 2); + const leftPad = " ".repeat(this.paddingX); + + // Render all children + const childLines: string[] = []; + for (const child of this.children) { + const lines = child.render(contentWidth); + for (const line of lines) { + childLines.push(leftPad + line); + } + } + + if (childLines.length === 0) { + return []; + } + + // Apply background and padding + const result: string[] = []; + + // Top padding + for (let i = 0; i < this.paddingY; i++) { + result.push(this.applyBg("", width)); + } + + // Content + for (const line of childLines) { + result.push(this.applyBg(line, width)); + } + + // Bottom padding + for (let i = 0; i < this.paddingY; i++) { + result.push(this.applyBg("", width)); + } + + return result; + } + + private applyBg(line: string, width: number): string { + const visLen = visibleWidth(line); + const padNeeded = Math.max(0, width - visLen); + const padded = line + " ".repeat(padNeeded); + + if (this.bgFn) { + return applyBackgroundToLine(padded, width, this.bgFn); + } + return padded; + } +} diff --git a/packages/tui/src/index.ts b/packages/tui/src/index.ts index 5dfb1ced..ed3daab7 100644 --- a/packages/tui/src/index.ts +++ b/packages/tui/src/index.ts @@ -8,6 +8,7 @@ export { type SlashCommand, } from "./autocomplete.js"; // Components +export { Box } from "./components/box.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"; diff --git a/tsconfig.json b/tsconfig.json index d5873ae2..c021dc41 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -25,5 +25,5 @@ "@mariozechner/pi-agent-old/*": ["./packages/agent-old/src/*"] } }, - "include": ["packages/*/src/**/*", "packages/*/test/**/*"] + "include": ["packages/*/src/**/*", "packages/*/test/**/*", "packages/coding-agent/examples/**/*"] }