diff --git a/packages/coding-agent/examples/hooks/todo/index.ts b/packages/coding-agent/examples/hooks/todo/index.ts index 7ea46f26..352bfc6e 100644 --- a/packages/coding-agent/examples/hooks/todo/index.ts +++ b/packages/coding-agent/examples/hooks/todo/index.ts @@ -1,14 +1,12 @@ /** * Todo Hook - Companion to the todo custom tool * - * Registers a /todos command that opens the todo list in an external editor. + * Registers a /todos command that displays all todos on the current branch + * with a nice custom UI. */ -import { spawnSync } from "node:child_process"; -import * as fs from "node:fs"; -import * as os from "node:os"; -import * as path from "node:path"; import type { HookAPI } from "@mariozechner/pi-coding-agent"; +import { isCtrlC, isEscape, truncateToWidth } from "@mariozechner/pi-tui"; interface Todo { id: number; @@ -23,6 +21,76 @@ interface TodoDetails { 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. @@ -48,73 +116,19 @@ export default function (pi: HookAPI) { return todos; } - /** - * Format todos as markdown for display in editor. - */ - function formatTodos(todos: Todo[]): string { - const lines: string[] = []; - lines.push("# Todos"); - lines.push(""); - - if (todos.length === 0) { - lines.push("No todos yet. Ask the agent to add some!"); - } else { - const done = todos.filter((t) => t.done).length; - lines.push(`${done}/${todos.length} completed`); - lines.push(""); - - for (const todo of todos) { - const check = todo.done ? "[x]" : "[ ]"; - lines.push(`- ${check} #${todo.id}: ${todo.text}`); - } - } - - lines.push(""); - return lines.join("\n"); - } - pi.registerCommand("todos", { - description: "Show all todos in external editor", + 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 editorCmd = process.env.VISUAL || process.env.EDITOR; - if (!editorCmd) { - ctx.ui.notify("No $VISUAL or $EDITOR set", "error"); - return; - } - const todos = getTodos(ctx); - const content = formatTodos(todos); - const tmpFile = path.join(os.tmpdir(), `pi-todos-${Date.now()}.md`); - try { - fs.writeFileSync(tmpFile, content, "utf-8"); - - // Use custom() to get access to tui for stop/start - await ctx.ui.custom((tui, _theme, done) => { - tui.stop(); - - const [editor, ...editorArgs] = editorCmd.split(" "); - spawnSync(editor, [...editorArgs, tmpFile], { stdio: "inherit" }); - - tui.start(); - tui.requestRender(); - done(undefined); - - // Return a minimal component (never rendered since we call done immediately) - return { render: () => [] }; - }); - } finally { - try { - fs.unlinkSync(tmpFile); - } catch { - // Ignore cleanup errors - } - } + await ctx.ui.custom((_tui, theme, done) => { + return new TodoListComponent(todos, theme, () => done()); + }); }, }); }