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.
This commit is contained in:
2026-08-17 22:48:48 +02:00
parent 98eb07bce6
commit 202288784c
+53 -5
View File
@@ -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);
}