From 202288784cfafc777e0e337336893ada7fc8c2a3 Mon Sep 17 00:00:00 2001 From: Joakim Persson Date: Mon, 17 Aug 2026 22:48:48 +0200 Subject: [PATCH] feat(todo): add an edit action, so correcting one item needs no rebuild The vendored upstream example exposes list/add/toggle/clear and no way to change an item's text. On a long-lived list that leaves two bad options: add a "patch" item that describes a DIFFERENT item, or clear and re-add everything. Both were hit for real on 2026-08-17 while tracking a 17-item fleet plan -- the list ended up with #18 correcting #17, which reads as nonsense in a sidebar that truncates each item to a few words. edit takes id + text, replaces the text, and KEEPS the id and the done status. Id stability is the point: ids are the only handle the palace snapshot of a plan can refer to, so a correction must not renumber anything. nextId is untouched. Error branches match the existing style: distinct messages for a missing id, a missing text, and an unknown id, each returning the full todos array in details so state stays consistent. No pi-atelier change needed. Its tool_result hook only checks that details.todos is a well-shaped array and ignores the action string, so the new action flows through its normalizer and sidebar untouched. Note renderResult's switch has no default case, so an action without a render arm returns undefined; edit adds one. The file is a verbatim vendored copy of upstream's example, so divergence has a cost: the header now carries a numbered LOCAL DELTAS list (the earlier ctx.mode -> ctx.hasUI API fix, and this) to keep reconciling a future upstream version mechanical. Worth offering upstream as a PR -- the example is arguably incomplete without it. Verified live in-container by pointing ~/.pi/agent/extensions/todo.ts at this copy for one session: edit on an unknown id and with no text both error as intended, and editing a completed item preserved both its id and its done state. Symlink since reverted to the image copy. --- extensions/todo.ts | 58 ++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 53 insertions(+), 5 deletions(-) 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); }