From cc7823eb458057ae209868777f51f4910b01c059 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Fri, 2 Jan 2026 23:04:01 +0100 Subject: [PATCH] Change /todos to open in external editor instead of custom TUI --- .../coding-agent/examples/hooks/todo/index.ts | 142 ++++++++---------- 1 file changed, 64 insertions(+), 78 deletions(-) diff --git a/packages/coding-agent/examples/hooks/todo/index.ts b/packages/coding-agent/examples/hooks/todo/index.ts index 352bfc6e..7ea46f26 100644 --- a/packages/coding-agent/examples/hooks/todo/index.ts +++ b/packages/coding-agent/examples/hooks/todo/index.ts @@ -1,12 +1,14 @@ /** * 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. + * Registers a /todos command that opens the todo list in an external editor. */ +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; @@ -21,76 +23,6 @@ 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. @@ -116,19 +48,73 @@ 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 on the current branch", + description: "Show all todos in external editor", handler: async (_args, ctx) => { if (!ctx.hasUI) { ctx.ui.notify("/todos requires interactive mode", "error"); return; } - const todos = getTodos(ctx); + const editorCmd = process.env.VISUAL || process.env.EDITOR; + if (!editorCmd) { + ctx.ui.notify("No $VISUAL or $EDITOR set", "error"); + return; + } - await ctx.ui.custom((_tui, theme, done) => { - return new TodoListComponent(todos, theme, () => done()); - }); + 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 + } + } }, }); }