diff --git a/AGENTS.md b/AGENTS.md index 1e3a5ee..c8536c8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,6 +22,7 @@ extensions/ git-checkpoint.ts # Git stash checkpoint per turn, restorable on /fork notify.ts # Native terminal notification when agent finishes ext-toggle.ts # /ext slash command — list & toggle extensions at runtime + todo.ts # `todo` tool for the agent + /todos for the user (copy of upstream example) install.sh # Idempotent installer — symlinks extensions/ into ~/.pi/agent/extensions/ package.json # pi package manifest — enables `pi install /path` as an alternative README.md # User-facing docs. @@ -220,6 +221,31 @@ session. No flags, no `agent_*` event handlers — fully passive until `/ext` is invoked. +### `todo.ts` + +Unchanged copy of upstream `examples/extensions/todo.ts` from +`pi-coding-agent` (the homebrew install at `/opt/homebrew/Cellar/...`). +Provides the agent with a `todo` tool (actions: list/add/toggle/clear) +and registers `/todos` for the user to inspect the list. + +**Why copied, not symlinked:** symlinking would point at +`/opt/homebrew/Cellar/pi-coding-agent//libexec/...` which +rotates on every brew upgrade — fragile. Copy keeps it stable; we lose +upstream updates but gain reproducibility. + +**State persistence:** the agent stores todo state in tool result +`details`, not an external file. Two useful properties: state survives +`pi --continue`/`--resume` because it lives in the session JSONL, and +`/fork` correctly forks the todo list along with the conversation. + +**Refresh from upstream when needed:** + +```bash +cp /opt/homebrew/Cellar/pi-coding-agent/*/libexec/lib/node_modules/@mariozechner/pi-coding-agent/examples/extensions/todo.ts \ + ~/src/src_local/pi-extensions/extensions/todo.ts +# review diff, then commit +``` + No framework. Manual: ```bash diff --git a/README.md b/README.md index 9ba7698..30a47db 100644 --- a/README.md +++ b/README.md @@ -193,6 +193,16 @@ A footer line shows pending changes (e.g. `pending: notify→off, foo→on`) so 3. In a running pi session, `/reload` is enough; no restart needed 4. (or, with `ext-toggle` installed: `/ext` to disable noisy ones at runtime) +### `todo.ts` + +Gives the agent a `todo` tool (actions: `list` / `add` / `toggle` / `clear`) so it can externalize a multi-step plan and tick items off as it works. Also registers `/todos` so you can inspect the current list at any time. + +State lives in the session's tool result `details`, not an external file. So: +- `pi --continue` / `--resume` brings the todos back with the conversation. +- `/fork` forks the todo list along with the branch — each branch has its own state. + +This is a verbatim copy of the upstream `examples/extensions/todo.ts` shipped with `pi-coding-agent`. Refresh from upstream when desired (see `AGENTS.md`). + Each extension is a TypeScript module loaded by [jiti](https://github.com/unjs/jiti) — no compilation step. See the [pi extensions docs](https://github.com/mariozechner/pi-coding-agent/blob/main/docs/extensions.md) and the [built-in examples](https://github.com/mariozechner/pi-coding-agent/tree/main/examples/extensions) for the API surface. --- diff --git a/extensions/todo.ts b/extensions/todo.ts new file mode 100644 index 0000000..47c84be --- /dev/null +++ b/extensions/todo.ts @@ -0,0 +1,297 @@ +/** + * Todo Extension - Demonstrates state management via session entries + * + * This extension: + * - Registers a `todo` tool for the LLM to manage todos + * - Registers a `/todos` command for users to view the list + * + * State is stored 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. + */ + +import { StringEnum } from "@mariozechner/pi-ai"; +import type { ExtensionAPI, ExtensionContext, Theme } from "@mariozechner/pi-coding-agent"; +import { matchesKey, Text, truncateToWidth } from "@mariozechner/pi-tui"; +import { Type } from "typebox"; + +interface Todo { + id: number; + text: string; + done: boolean; +} + +interface TodoDetails { + action: "list" | "add" | "toggle" | "clear"; + todos: Todo[]; + nextId: number; + error?: string; +} + +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)" })), +}); + +/** + * UI component for the /todos command + */ +class TodoListComponent { + private todos: Todo[]; + private theme: Theme; + private onClose: () => void; + private cachedWidth?: number; + private cachedLines?: string[]; + + constructor(todos: Todo[], theme: Theme, onClose: () => void) { + this.todos = todos; + this.theme = theme; + this.onClose = onClose; + } + + handleInput(data: string): void { + if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) { + this.onClose(); + } + } + + render(width: number): string[] { + if (this.cachedLines && this.cachedWidth === width) { + return this.cachedLines; + } + + const lines: string[] = []; + const th = this.theme; + + 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 { + const done = this.todos.filter((t) => t.done).length; + const total = this.todos.length; + lines.push(truncateToWidth(` ${th.fg("muted", `${done}/${total} completed`)}`, width)); + lines.push(""); + + 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); + lines.push(truncateToWidth(` ${check} ${id} ${text}`, 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: ExtensionAPI) { + // 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 = (ctx: ExtensionContext) => { + todos = []; + nextId = 1; + + for (const entry of ctx.sessionManager.getBranch()) { + if (entry.type !== "message") continue; + const msg = entry.message; + if (msg.role !== "toolResult" || msg.toolName !== "todo") continue; + + const details = msg.details as TodoDetails | undefined; + if (details) { + todos = details.todos; + nextId = details.nextId; + } + } + }; + + // Reconstruct state on session events + pi.on("session_start", async (_event, ctx) => reconstructState(ctx)); + pi.on("session_tree", async (_event, ctx) => reconstructState(ctx)); + + // Register the todo tool for the LLM + pi.registerTool({ + name: "todo", + label: "Todo", + description: "Manage a todo list. Actions: list, add (text), toggle (id), clear", + parameters: TodoParams, + + async execute(_toolCallId, params, _signal, _onUpdate, _ctx) { + 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 } as TodoDetails, + }; + + case "add": { + if (!params.text) { + return { + content: [{ type: "text", text: "Error: text required for add" }], + details: { action: "add", todos: [...todos], nextId, error: "text required" } as TodoDetails, + }; + } + 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 } as TodoDetails, + }; + } + + 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" } as TodoDetails, + }; + } + 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`, + } as TodoDetails, + }; + } + todo.done = !todo.done; + return { + content: [{ type: "text", text: `Todo #${todo.id} ${todo.done ? "completed" : "uncompleted"}` }], + details: { action: "toggle", todos: [...todos], nextId } as TodoDetails, + }; + } + + case "clear": { + const count = todos.length; + todos = []; + nextId = 1; + return { + content: [{ type: "text", text: `Cleared ${count} todos` }], + details: { action: "clear", todos: [], nextId: 1 } as TodoDetails, + }; + } + + default: + return { + content: [{ type: "text", text: `Unknown action: ${params.action}` }], + details: { + action: "list", + todos: [...todos], + nextId, + error: `unknown action: ${params.action}`, + } as TodoDetails, + }; + } + }, + + renderCall(args, theme, _context) { + 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, _context) { + const details = result.details as TodoDetails | undefined; + if (!details) { + const text = result.content[0]; + return new Text(text?.type === "text" ? text.text : "", 0, 0); + } + + 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); + } + }, + }); + + // Register the /todos command for users + 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; + } + + await ctx.ui.custom((_tui, theme, _kb, done) => { + return new TodoListComponent(todos, theme, () => done()); + }); + }, + }); +}