diff --git a/.pi/settings.json b/.pi/settings.json index fb4d6c48..a9626a63 100644 --- a/.pi/settings.json +++ b/.pi/settings.json @@ -1,3 +1,4 @@ { - "customTools": ["packages/coding-agent/examples/custom-tools/todo/index.ts"] + "customTools": ["packages/coding-agent/examples/custom-tools/todo/index.ts"], + "hooks": ["packages/coding-agent/examples/hooks/todo/index.ts"] } diff --git a/packages/coding-agent/docs/hooks.md b/packages/coding-agent/docs/hooks.md index 9f5f9ed0..c5cc9fab 100644 --- a/packages/coding-agent/docs/hooks.md +++ b/packages/coding-agent/docs/hooks.md @@ -465,10 +465,12 @@ const result = await ctx.ui.custom((tui, theme, done) => { doWork(loader.signal).then(done).catch(() => done(null)); - return loader; + return loader; // Return the component directly, do NOT wrap in Box/Container }); ``` +**Important:** Return your component directly from the callback. Do not wrap it in a `Box` or `Container`, as this breaks input handling. + Your component can: - Implement `handleInput(data: string)` to receive keyboard input - Implement `render(width: number): string[]` to render lines diff --git a/packages/coding-agent/examples/README.md b/packages/coding-agent/examples/README.md index c1748c27..5e4937a5 100644 --- a/packages/coding-agent/examples/README.md +++ b/packages/coding-agent/examples/README.md @@ -13,6 +13,12 @@ Example hooks for intercepting tool calls, adding safety gates, and integrating ### [custom-tools/](custom-tools/) Example custom tools that extend the agent's capabilities. +## Tool + Hook Combinations + +Some examples are designed to work together: + +- **todo/** - The [custom tool](custom-tools/todo/) lets the LLM manage a todo list, while the [hook](hooks/todo/) adds a `/todos` command for users to view todos at any time. + ## Documentation - [SDK Reference](sdk/README.md) diff --git a/packages/coding-agent/examples/custom-tools/README.md b/packages/coding-agent/examples/custom-tools/README.md index d3be0636..b665a211 100644 --- a/packages/coding-agent/examples/custom-tools/README.md +++ b/packages/coding-agent/examples/custom-tools/README.md @@ -19,6 +19,8 @@ Full-featured example demonstrating: - Proper branching support via details storage - State management without external files +**Companion hook:** [hooks/todo/](../hooks/todo/) adds a `/todos` command for users to view the todo list. + ### subagent/ Delegate tasks to specialized subagents with isolated context windows. Includes: - `index.ts` - The custom tool (single, parallel, and chain modes) diff --git a/packages/coding-agent/examples/hooks/README.md b/packages/coding-agent/examples/hooks/README.md index 70f84fe8..e084206c 100644 --- a/packages/coding-agent/examples/hooks/README.md +++ b/packages/coding-agent/examples/hooks/README.md @@ -28,6 +28,7 @@ cp permission-gate.ts ~/.pi/agent/hooks/ | `snake.ts` | Snake game with custom UI, keyboard handling, and session persistence | | `status-line.ts` | Shows turn progress in footer via `ctx.ui.setStatus()` with themed colors | | `handoff.ts` | Transfer context to a new focused session via `/handoff ` | +| `todo/` | Adds `/todos` command to view todos managed by the [todo custom tool](../custom-tools/todo/) | ## Writing Hooks diff --git a/packages/coding-agent/examples/hooks/todo/index.ts b/packages/coding-agent/examples/hooks/todo/index.ts new file mode 100644 index 00000000..352bfc6e --- /dev/null +++ b/packages/coding-agent/examples/hooks/todo/index.ts @@ -0,0 +1,134 @@ +/** + * Todo Hook - Companion to the todo custom tool + * + * Registers a /todos command that displays all todos on the current branch + * with a nice custom UI. + */ + +import type { HookAPI } from "@mariozechner/pi-coding-agent"; +import { isCtrlC, isEscape, truncateToWidth } from "@mariozechner/pi-tui"; + +interface Todo { + id: number; + text: string; + done: boolean; +} + +interface TodoDetails { + action: "list" | "add" | "toggle" | "clear"; + todos: Todo[]; + nextId: number; + error?: string; +} + +class TodoListComponent { + private todos: Todo[]; + private theme: { fg: (color: string, text: string) => string }; + private onClose: () => void; + private cachedWidth?: number; + private cachedLines?: string[]; + + constructor(todos: Todo[], theme: { fg: (color: string, text: string) => string }, onClose: () => void) { + this.todos = todos; + this.theme = theme; + this.onClose = onClose; + } + + handleInput(data: string): void { + if (isEscape(data) || isCtrlC(data)) { + this.onClose(); + } + } + + render(width: number): string[] { + if (this.cachedLines && this.cachedWidth === width) { + return this.cachedLines; + } + + const lines: string[] = []; + const th = this.theme; + + // Header + lines.push(""); + const title = th.fg("accent", " Todos "); + const headerLine = + th.fg("borderMuted", "─".repeat(3)) + title + th.fg("borderMuted", "─".repeat(Math.max(0, width - 10))); + lines.push(truncateToWidth(headerLine, width)); + lines.push(""); + + if (this.todos.length === 0) { + lines.push(truncateToWidth(` ${th.fg("dim", "No todos yet. Ask the agent to add some!")}`, width)); + } else { + // Stats + const done = this.todos.filter((t) => t.done).length; + const total = this.todos.length; + const statsText = ` ${th.fg("muted", `${done}/${total} completed`)}`; + lines.push(truncateToWidth(statsText, width)); + lines.push(""); + + // Todo items + for (const todo of this.todos) { + const check = todo.done ? th.fg("success", "✓") : th.fg("dim", "○"); + const id = th.fg("accent", `#${todo.id}`); + const text = todo.done ? th.fg("dim", todo.text) : th.fg("text", todo.text); + const line = ` ${check} ${id} ${text}`; + lines.push(truncateToWidth(line, width)); + } + } + + lines.push(""); + lines.push(truncateToWidth(` ${th.fg("dim", "Press Escape to close")}`, width)); + lines.push(""); + + this.cachedWidth = width; + this.cachedLines = lines; + return lines; + } + + invalidate(): void { + this.cachedWidth = undefined; + this.cachedLines = undefined; + } +} + +export default function (pi: HookAPI) { + /** + * Reconstruct todos from session entries on the current branch. + */ + function getTodos(ctx: { + sessionManager: { + getBranch: () => Array<{ type: string; message?: { role?: string; toolName?: string; details?: unknown } }>; + }; + }): Todo[] { + let todos: Todo[] = []; + + for (const entry of ctx.sessionManager.getBranch()) { + if (entry.type !== "message") continue; + const msg = entry.message; + if (!msg || msg.role !== "toolResult" || msg.toolName !== "todo") continue; + + const details = msg.details as TodoDetails | undefined; + if (details) { + todos = details.todos; + } + } + + return todos; + } + + pi.registerCommand("todos", { + description: "Show all todos on the current branch", + handler: async (_args, ctx) => { + if (!ctx.hasUI) { + ctx.ui.notify("/todos requires interactive mode", "error"); + return; + } + + const todos = getTodos(ctx); + + await ctx.ui.custom((_tui, theme, done) => { + return new TodoListComponent(todos, theme, () => done()); + }); + }, + }); +}