diff --git a/extensions/todo.ts b/extensions/todo.ts index 89b147c..61166f2 100644 --- a/extensions/todo.ts +++ b/extensions/todo.ts @@ -8,6 +8,15 @@ * 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. + * + * LOCAL DELTAS vs upstream examples/extensions/todo.ts (keep this list current, + * so reconciling a future upstream version stays mechanical): + * 1. `ctx.mode !== "tui"` -> `!ctx.hasUI` in the /todos command (API change). + * 2. `edit` action (id + text) - rewrite an item's text in place, keeping its + * id and done status. Added 2026-08-17: without it, correcting one item in + * a long-lived list means either a "patch" item that describes a DIFFERENT + * item, or clear + re-add of everything. Both were hit in practice on + * 2026-08-17 while tracking a 17-item fleet plan. */ import { StringEnum } from "@earendil-works/pi-ai"; @@ -22,16 +31,16 @@ interface Todo { } interface TodoDetails { - action: "list" | "add" | "toggle" | "clear"; + action: "list" | "add" | "toggle" | "edit" | "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)" })), + action: StringEnum(["list", "add", "toggle", "edit", "clear"] as const), + text: Type.Optional(Type.String({ description: "Todo text (for add, or the replacement text for edit)" })), + id: Type.Optional(Type.Number({ description: "Todo ID (for toggle and edit)" })), }); /** @@ -136,7 +145,8 @@ export default function (pi: ExtensionAPI) { pi.registerTool({ name: "todo", label: "Todo", - description: "Manage a todo list. Actions: list, add (text), toggle (id), clear", + description: + "Manage a todo list. Actions: list, add (text), toggle (id), edit (id + text, replaces the text and keeps the done status), clear", parameters: TodoParams, async execute(_toolCallId, params, _signal, _onUpdate, _ctx) { @@ -195,6 +205,38 @@ export default function (pi: ExtensionAPI) { }; } + case "edit": { + if (params.id === undefined || !params.text) { + const missing = params.id === undefined ? "id" : "text"; + return { + content: [{ type: "text", text: `Error: ${missing} required for edit` }], + details: { + action: "edit", + todos: [...todos], + nextId, + error: `${missing} required`, + } as TodoDetails, + }; + } + const target = todos.find((t) => t.id === params.id); + if (!target) { + return { + content: [{ type: "text", text: `Todo #${params.id} not found` }], + details: { + action: "edit", + todos: [...todos], + nextId, + error: `#${params.id} not found`, + } as TodoDetails, + }; + } + target.text = params.text; + return { + content: [{ type: "text", text: `Todo #${target.id} updated: ${target.text}` }], + details: { action: "edit", todos: [...todos], nextId } as TodoDetails, + }; + } + case "clear": { const count = todos.length; todos = []; @@ -274,6 +316,12 @@ export default function (pi: ExtensionAPI) { return new Text(theme.fg("success", "✓ ") + theme.fg("muted", msg), 0, 0); } + case "edit": { + 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); }