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:
+53
-5
@@ -8,6 +8,15 @@
|
|||||||
* State is stored in tool result details (not external files), which allows
|
* State is stored in tool result details (not external files), which allows
|
||||||
* proper branching - when you branch, the todo state is automatically
|
* proper branching - when you branch, the todo state is automatically
|
||||||
* correct for that point in history.
|
* 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";
|
import { StringEnum } from "@earendil-works/pi-ai";
|
||||||
@@ -22,16 +31,16 @@ interface Todo {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface TodoDetails {
|
interface TodoDetails {
|
||||||
action: "list" | "add" | "toggle" | "clear";
|
action: "list" | "add" | "toggle" | "edit" | "clear";
|
||||||
todos: Todo[];
|
todos: Todo[];
|
||||||
nextId: number;
|
nextId: number;
|
||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TodoParams = Type.Object({
|
const TodoParams = Type.Object({
|
||||||
action: StringEnum(["list", "add", "toggle", "clear"] as const),
|
action: StringEnum(["list", "add", "toggle", "edit", "clear"] as const),
|
||||||
text: Type.Optional(Type.String({ description: "Todo text (for add)" })),
|
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)" })),
|
id: Type.Optional(Type.Number({ description: "Todo ID (for toggle and edit)" })),
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -136,7 +145,8 @@ export default function (pi: ExtensionAPI) {
|
|||||||
pi.registerTool({
|
pi.registerTool({
|
||||||
name: "todo",
|
name: "todo",
|
||||||
label: "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,
|
parameters: TodoParams,
|
||||||
|
|
||||||
async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
|
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": {
|
case "clear": {
|
||||||
const count = todos.length;
|
const count = todos.length;
|
||||||
todos = [];
|
todos = [];
|
||||||
@@ -274,6 +316,12 @@ export default function (pi: ExtensionAPI) {
|
|||||||
return new Text(theme.fg("success", "✓ ") + theme.fg("muted", msg), 0, 0);
|
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":
|
case "clear":
|
||||||
return new Text(theme.fg("success", "✓ ") + theme.fg("muted", "Cleared all todos"), 0, 0);
|
return new Text(theme.fg("success", "✓ ") + theme.fg("muted", "Cleared all todos"), 0, 0);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user