/** * todo.ts — durable execution checklist tool * * Extracted from the plan extension: the checklist is BUILD-phase execution * state that survives context compaction and session restore (persisted via * appendEntry("todo-state")). It is universal — other tools and workflow * extensions (like plan) drive it — so it lives in pix-core and registers the * `todo` tool. State, persistence, and restore are owned end to end here; the * checklist is seeded by the model via the tool's `set` action. */ import { type CollapseState, tickCollapse } from "@dihak/pix-data/collapse"; import { formatCollapsedToolRow } from "@dihak/pix-pretty/utils"; import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"; import { Text } from "@earendil-works/pi-tui"; import { Type } from "typebox"; import { once } from "./once.ts"; export type TodoStatus = "pending" | "in_progress" | "done" | "blocked"; export interface TodoItem { id: number; text: string; status: TodoStatus; } type TodoAction = "list" | "set" | "add" | "update" | "clear"; interface TodoResultDetails { _type: "todoResult"; action: TodoAction; outcome: "success" | "error"; snapshot: TodoItem[]; } const TODO_GLYPH: Record = { pending: "○", in_progress: "◐", done: "●", blocked: "⊘", }; /** Theme color key per status — drives both glyph and (for active) row tint. */ const TODO_COLOR: Record = { pending: "muted", in_progress: "accent", done: "success", blocked: "error", }; export type TodoTheme = { fg: (color: string, text: string) => string; bold: (text: string) => string; }; /** Footer status key — pix-footer surfaces this via getExtensionStatuses(). */ export const TODO_FOOTER_KEY = "todo"; /** Compact sticky footer segment: `◐ 1/3` / `● 3/3` / `⊘ 0/2 !2`. Hidden when empty. */ export function renderTodoFooterStatus(items: TodoItem[], theme?: TodoTheme): string | undefined { if (!items.length) return undefined; const done = items.filter((t) => t.status === "done").length; const blocked = items.filter((t) => t.status === "blocked").length; const active = items.some((t) => t.status === "in_progress"); const glyph = active ? TODO_GLYPH.in_progress : blocked > 0 && done + blocked === items.length ? TODO_GLYPH.blocked : done === items.length ? TODO_GLYPH.done : TODO_GLYPH.pending; const color = active ? "accent" : blocked > 0 && done + blocked === items.length ? "error" : done === items.length ? "success" : "muted"; const text = blocked > 0 ? `${glyph} ${done}/${items.length} !${blocked}` : `${glyph} ${done}/${items.length}`; return theme ? theme.fg(color, text) : text; } /** One-line shared summary used once a card has collapsed. */ export function renderTodoSummaryLine(items: TodoItem[], theme: TodoTheme): string { if (!items.length) return formatCollapsedToolRow(theme, "todo", "empty"); const done = items.filter((t) => t.status === "done").length; const active = items.find((t) => t.status === "in_progress"); const blocked = items.filter((t) => t.status === "blocked").length; const meta = [`${done}/${items.length} done`, blocked > 0 ? `${blocked} blocked` : ""] .filter(Boolean) .join(" · "); const target = active ? `#${active.id} ${active.text}` : done === items.length ? "complete" : "checklist"; return formatCollapsedToolRow(theme, "todo", target, meta, "success"); } /** Colored checklist for the TUI: glyphs tinted by status, active row bold. */ export function renderTodoLines(items: TodoItem[], theme: TodoTheme): string { if (!items.length) return theme.fg("muted", "(no todos)"); const done = items.filter((t) => t.status === "done").length; const head = theme.fg("accent", `Todos ${done}/${items.length} done:`); const lines = items.map((t) => { const color = TODO_COLOR[t.status]; const glyph = theme.fg(color, TODO_GLYPH[t.status]); const body = `${t.id}. ${t.text}`; // Highlight the in-flight task so the eye lands on it first. const label = t.status === "in_progress" ? theme.bold(theme.fg("accent", body)) : theme.fg(t.status === "done" ? "muted" : "text", body); return `${glyph} ${label}`; }); return `${head}\n${lines.join("\n")}`; } /** * Skip-guard: when marking an item done, check for earlier items still * pending or in_progress. Returns a warning string or "" if none skipped. */ function buildSkipWarning(items: TodoItem[], targetId: number): string { const skipped = items.filter( (o) => o.id < targetId && (o.status === "pending" || o.status === "in_progress"), ); if (skipped.length === 0) return ""; const ids = skipped.map((s) => `#${s.id} (${s.text})`).join(", "); return ( `\n\n\u26a0 Earlier items still incomplete: ${ids}. ` + "Mark each done or blocked before proceeding." ); } const parseItems = (raw: string): string[] => raw .split("\n") .map((l) => l.replace(/^\s*(?:\d+[.)]|[-*•])\s*/, "").trim()) .filter(Boolean); const STATUSES: readonly TodoStatus[] = ["pending", "in_progress", "done", "blocked"]; export interface TodoUpdateOp { id: number; status: TodoStatus; } /** * Parse the batch `updates` string: comma/newline separated `id:status` pairs * (e.g. "3:done, 4:blocked"). Returns an error string on the first bad token so * the model gets one precise correction instead of a silent partial apply. */ export function parseUpdates(raw: string): { ops: TodoUpdateOp[] } | { error: string } { const ops: TodoUpdateOp[] = []; for (const token of raw.split(/[,\n]/)) { const tok = token.trim(); if (!tok) continue; const m = /^#?(\d+)\s*[:=]\s*(\w+)$/.exec(tok); if (!m) return { error: `Bad update token "${tok}" — expected "id:status".` }; const status = m[2] as TodoStatus; if (!STATUSES.includes(status)) return { error: `Bad status "${m[2]}" — expected one of ${STATUSES.join(", ")}.` }; ops.push({ id: Number(m[1]), status }); } return ops.length ? { ops } : { error: 'update requires `updates` ("id:status") or `id`+`status`.' }; } export default function registerTodo(pi: ExtensionAPI): void { once(pi, "pix-todo", () => { let todos: TodoItem[] = []; let nextTodoId = 1; let lastUi: ExtensionContext["ui"] | undefined; function persistTodos() { pi.appendEntry("todo-state", { todos, nextTodoId }); publishFooter(); } /** Sticky footer segment via ctx.ui.setStatus — survives scroll; hidden when empty. */ function publishFooter(ui?: ExtensionContext["ui"]) { if (ui) lastUi = ui; if (!lastUi?.setStatus) return; lastUi.setStatus(TODO_FOOTER_KEY, renderTodoFooterStatus(todos, lastUi.theme as TodoTheme)); } /** Compact next-step hint: what the model should work on now. */ function todoHint(): string { const done = todos.filter((t) => t.status === "done").length; const next = todos.find((t) => t.status === "in_progress") ?? todos.find((t) => t.status === "pending"); if (!next) return `${done}/${todos.length} done — all items closed.`; return `${done}/${todos.length} done — next #${next.id} ${next.text}`; } function todoSummary(): string { if (!todos.length) return "(no todos)"; const done = todos.filter((t) => t.status === "done").length; const lines = todos.map((t) => `${TODO_GLYPH[t.status]} ${t.id}. ${t.text}`); return `Todos ${done}/${todos.length} done:\n${lines.join("\n")}`; } // Durable execution checklist for BUILD mode. Survives context compaction // and session restore. Workflows like plan instruct the model to seed it // from a plan's "Implementation Phases" so it stays anchored to plan.md. pi.registerTool({ name: "todo", label: "Todo", // Avoid the default Box shell's one-cell x padding: Todo owns its compact // result row and should align its status glyph with other compact tools. renderShell: "self", description: "Durable BUILD-phase checklist, survives compaction. Actions: list, set (replace all), add, update (batch id:status), clear.", promptSnippet: "todo(action, items?, updates?, id?, status?, text?) — list|set|add|update|clear. Track multi-step execution progress.", promptGuidelines: [ "Seed a multi-step plan once with `todo(action:'set', items:)`.", "Batch status changes in ONE call: `todo(action:'update', updates:'3:done,4:in_progress')`. Opening an item auto-closes earlier ones, so do not send per-item calls.", ], parameters: Type.Object({ action: Type.Enum(["list", "set", "add", "update", "clear"] as const, { type: "string", description: '"list" shows items; "set" replaces all from items; "add" appends items; "update" changes one or more items by id; "clear" removes all.', }), items: Type.Optional( Type.String({ description: "set/add: newline-separated or numbered todo texts.", }), ), updates: Type.Optional( Type.String({ description: 'update (preferred): comma-separated "id:status" pairs, e.g. "3:done,4:in_progress". Applied in order.', }), ), id: Type.Optional(Type.Number({ description: "update: single target id." })), status: Type.Optional( Type.Enum(["pending", "in_progress", "done", "blocked"] as const, { type: "string", description: 'update: "pending" = not started; "in_progress" = active; "done" = finished; "blocked" = cannot proceed.', }), ), text: Type.Optional(Type.String({ description: "update: replacement text." })), }), // The result already owns the checklist and its collapsed `✓ todo …` row. // Keeping the call renderer empty prevents a duplicate standalone header. renderCall() { return new Text("", 0, 0); }, renderResult(result, options, theme, context) { const details = result.details as TodoResultDetails | undefined; const resultText = result.content .filter((part) => part.type === "text") .map((part) => part.text) .join("\n"); if (context.isError || details?.outcome === "error" || !details) { return new Text(resultText, 0, 0); } const collapsed = tickCollapse( "todo", context.state as CollapseState, context.invalidate, options.expanded, ); const render = collapsed ? renderTodoSummaryLine : renderTodoLines; return new Text(render(details.snapshot, theme as TodoTheme), 0, 0); }, async execute(_id, params, _signal, _onUpdate, ctx) { if (ctx?.ui) lastUi = ctx.ui; const action = params.action as TodoAction; const details = (outcome: TodoResultDetails["outcome"]): TodoResultDetails => ({ _type: "todoResult", action, outcome, snapshot: todos.map((item) => ({ ...item })), }); const ok = (text: string) => ({ content: [{ type: "text" as const, text }], details: details("success"), }); const fail = (text: string) => ({ content: [{ type: "text" as const, text }], details: details("error"), isError: true, }); switch (params.action) { case "list": return ok(todoSummary()); case "set": { const texts = parseItems(params.items ?? ""); if (!texts.length) return fail("set requires non-empty `items`."); nextTodoId = 1; todos = texts.map((text) => ({ id: nextTodoId++, text, status: "pending" as TodoStatus, })); persistTodos(); return ok(todoSummary()); } case "add": { const texts = parseItems(params.items ?? ""); if (!texts.length) return fail("add requires non-empty `items`."); for (const text of texts) todos.push({ id: nextTodoId++, text, status: "pending" }); persistTodos(); return ok(todoSummary()); } case "update": { // Batch form (`updates`) is preferred — one call closes many items. // Single form (`id`+`status`/`text`) stays supported for renames. let ops: TodoUpdateOp[]; if (params.updates) { const parsed = parseUpdates(params.updates as string); if ("error" in parsed) return fail(parsed.error); ops = parsed.ops; } else if (params.id !== undefined && params.status) { ops = [{ id: params.id as number, status: params.status as TodoStatus }]; } else { ops = []; } const missing = ops.filter((o) => !todos.some((t) => t.id === o.id)).map((o) => o.id); if (missing.length) return fail(`No todo with id ${missing.join(", ")}.`); // Text-only / no-op single update: keep the legacy tolerant path. if (!ops.length) { const t = todos.find((x) => x.id === params.id); if (!t) return fail(`No todo with id ${params.id}.`); if (params.text) { t.text = params.text as string; persistTodos(); } return ok(`#${t.id} ${t.status} · ${todoHint()}`); } const autoClosed = new Set(); let skipWarning = ""; for (const op of ops) { const t = todos.find((x) => x.id === op.id) as TodoItem; // Sequential-progress invariant: opening a task means everything // before it is finished. Cascade-close every earlier pending or // in_progress item (ids are sequential) so the model never has to // mark skipped steps done by hand. `blocked` is left untouched. if (op.status === "in_progress") for (const other of todos) if ( other.id < t.id && (other.status === "pending" || other.status === "in_progress") ) { other.status = "done"; autoClosed.add(other.id); } // Only the last op's skip state matters — earlier ops in the same // batch may legitimately still be settling. skipWarning = op.status === "done" ? buildSkipWarning(todos, t.id) : ""; t.status = op.status; } const only = ops.length === 1 ? ops[0] : undefined; if (params.text && only) { const t = todos.find((x) => x.id === only.id) as TodoItem; t.text = params.text as string; } persistTodos(); // Delta-only echo: the model already holds the list; re-sending it on // every update is pure token waste. The TUI card still renders the // full checklist from `details.snapshot`. const applied = ops.map((o) => `#${o.id} ${o.status}`).join(", "); const auto = autoClosed.size ? ` (auto-done ${[...autoClosed].map((i) => `#${i}`).join(",")})` : ""; return ok(`${applied}${auto} · ${todoHint()}${skipWarning}`); } case "clear": todos = []; nextTodoId = 1; persistTodos(); return ok("Todos cleared."); default: return fail(`Unknown action: ${String(params.action)}`); } }, }); // ── Turn-based reminder ───────────────────────────────────────────── // Every TODO_REMINDER_INTERVAL turns, inject the current todo summary // into the system prompt so the model stays aware of pending work and // can't hand-wave or ignore incomplete items. const TODO_REMINDER_INTERVAL = 10; let todoTurnCount = 0; pi.on("before_agent_start", async (event) => { todoTurnCount++; // Only inject when there are active (non-empty) todos if (todos.length === 0) return; // Check if any items are still incomplete const hasIncomplete = todos.some((t) => t.status === "pending" || t.status === "in_progress"); if (!hasIncomplete) return; // Fire on every Nth turn if (todoTurnCount % TODO_REMINDER_INTERVAL !== 0) return; // Incremental reminder: the full checklist is already in the transcript // and in the tool card — inject only the pointer to the next item. const reminder = `Todo — ${todoHint()}`; const existing = event.systemPrompt ?? ""; return { systemPrompt: existing ? `${existing}\n\n${reminder}` : reminder }; }); // Restore the checklist from session entries so it survives restart. pi.on("session_start", async (_event, ctx) => { const entries = ctx.sessionManager.getEntries() as Array<{ type: string; customType?: string; data?: { todos?: TodoItem[]; nextTodoId?: number }; }>; const lastTodo = entries .filter((e) => e.type === "custom" && e.customType === "todo-state") .pop(); if (Array.isArray(lastTodo?.data?.todos)) { todos = lastTodo.data.todos; nextTodoId = lastTodo.data.nextTodoId ?? todos.reduce((m, t) => Math.max(m, t.id + 1), 1); } publishFooter(ctx.ui); }); }); }