/** * Plan Tracker Extension * * A native pi tool for tracking plan progress. * State is stored in tool result details for proper branching support. * Shows a persistent TUI widget above the editor. */ import { StringEnum } from "@earendil-works/pi-ai"; import type { ExtensionAPI, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent"; import { Text } from "@earendil-works/pi-tui"; import { type Static, Type } from "@sinclair/typebox"; type TaskStatus = "pending" | "in_progress" | "complete"; interface Task { name: string; status: TaskStatus; } interface PlanTrackerDetails { action: "init" | "update" | "status" | "clear"; tasks: Task[]; error?: string; } const PlanTrackerParams = Type.Object({ action: StringEnum(["init", "update", "status", "clear"] as const, { description: "Action to perform", }), tasks: Type.Optional( Type.Array(Type.String(), { description: "Task names (for init)", }), ), index: Type.Optional( Type.Integer({ minimum: 0, description: "Task index, 0-based (for update)", }), ), status: Type.Optional( StringEnum(["pending", "in_progress", "complete"] as const, { description: "New status (for update)", }), ), }); export type PlanTrackerInput = Static; function formatWidget(tasks: Task[], theme: Theme): string { if (tasks.length === 0) return ""; const complete = tasks.filter((t) => t.status === "complete").length; const icons = tasks .map((t) => { switch (t.status) { case "complete": return theme.fg("success", "✓"); case "in_progress": return theme.fg("warning", "→"); default: return theme.fg("dim", "○"); } }) .join(""); // Find current task (first in_progress, or first pending) const current = tasks.find((t) => t.status === "in_progress") ?? tasks.find((t) => t.status === "pending"); const currentName = current ? ` ${current.name}` : ""; return `${theme.fg("muted", "Tasks:")} ${icons} ${theme.fg("muted", `(${complete}/${tasks.length})`)}${currentName}`; } function formatStatus(tasks: Task[]): string { if (tasks.length === 0) return "No plan active."; const complete = tasks.filter((t) => t.status === "complete").length; const inProgress = tasks.filter((t) => t.status === "in_progress").length; const pending = tasks.filter((t) => t.status === "pending").length; const lines: string[] = []; lines.push(`Plan: ${complete}/${tasks.length} complete (${inProgress} in progress, ${pending} pending)`); lines.push(""); for (let i = 0; i < tasks.length; i++) { const t = tasks[i]; const icon = t.status === "complete" ? "✓" : t.status === "in_progress" ? "→" : "○"; lines.push(` ${icon} [${i}] ${t.name}`); } return lines.join("\n"); } export default function (pi: ExtensionAPI) { let tasks: Task[] = []; const reconstructState = (ctx: ExtensionContext) => { tasks = []; for (const entry of ctx.sessionManager.getBranch()) { if (entry.type !== "message") continue; const msg = entry.message; if (msg.role !== "toolResult" || msg.toolName !== "plan_tracker") continue; const details = msg.details as PlanTrackerDetails | undefined; if (details && !details.error) { tasks = details.tasks.map((t) => ({ ...t })); } } }; const updateWidget = (ctx: ExtensionContext) => { if (!ctx.hasUI) return; if (tasks.length === 0) { ctx.ui.setWidget("plan_tracker", undefined); } else { ctx.ui.setWidget("plan_tracker", (_tui, theme) => { return new Text(formatWidget(tasks, theme), 0, 0); }); } }; // Reconstruct state + widget on session events for (const event of ["session_start", "session_switch", "session_fork", "session_tree"] as const) { pi.on(event, async (_event, ctx) => { reconstructState(ctx); updateWidget(ctx); }); } pi.registerTool({ name: "plan_tracker", label: "Plan Tracker", description: "Track progress while EXECUTING an implementation plan (the implement phase) or a verify-phase conformance fix wave. Actions: init (set task list), update (change task status), status (show current state), clear (remove plan). Do NOT use for brainstorming, research, or planning checklists: those phases are open-ended and a bounded task list misrepresents them as a fixed N-step process.", parameters: PlanTrackerParams, async execute(_toolCallId, params, _signal, _onUpdate, ctx) { switch (params.action) { case "init": { if (!params.tasks || params.tasks.length === 0) { return { content: [{ type: "text", text: "Error: tasks array required for init" }], details: { action: "init", tasks: tasks.map((t) => ({ ...t })), error: "tasks required", } as PlanTrackerDetails, }; } tasks = params.tasks.map((name) => ({ name, status: "pending" as TaskStatus })); updateWidget(ctx); return { content: [ { type: "text", text: `Plan initialized with ${tasks.length} tasks.\n${formatStatus(tasks)}`, }, ], details: { action: "init", tasks: tasks.map((t) => ({ ...t })) } as PlanTrackerDetails, }; } case "update": { if (params.index === undefined || !params.status) { return { content: [{ type: "text", text: "Error: index and status required for update" }], details: { action: "update", tasks: tasks.map((t) => ({ ...t })), error: "index and status required", } as PlanTrackerDetails, }; } if (tasks.length === 0) { return { content: [{ type: "text", text: "Error: no plan active. Use init first." }], details: { action: "update", tasks: [], error: "no plan active", } as PlanTrackerDetails, }; } if (params.index < 0 || params.index >= tasks.length) { return { content: [ { type: "text", text: `Error: index ${params.index} out of range (0-${tasks.length - 1})`, }, ], details: { action: "update", tasks: tasks.map((t) => ({ ...t })), error: `index ${params.index} out of range`, } as PlanTrackerDetails, }; } tasks[params.index].status = params.status; updateWidget(ctx); return { content: [ { type: "text", text: `Task ${params.index} "${tasks[params.index].name}" → ${params.status}\n${formatStatus(tasks)}`, }, ], details: { action: "update", tasks: tasks.map((t) => ({ ...t })) } as PlanTrackerDetails, }; } case "status": { return { content: [{ type: "text", text: formatStatus(tasks) }], details: { action: "status", tasks: tasks.map((t) => ({ ...t })) } as PlanTrackerDetails, }; } case "clear": { const count = tasks.length; tasks = []; updateWidget(ctx); return { content: [ { type: "text", text: count > 0 ? `Plan cleared (${count} tasks removed).` : "No plan was active.", }, ], details: { action: "clear", tasks: [] } as PlanTrackerDetails, }; } default: return { content: [{ type: "text", text: `Unknown action: ${params.action}` }], details: { action: "status", tasks: tasks.map((t) => ({ ...t })), error: `unknown action`, } as PlanTrackerDetails, }; } }, renderCall(args, theme) { let text = theme.fg("toolTitle", theme.bold("plan_tracker ")); text += theme.fg("muted", args.action); if (args.action === "update" && args.index !== undefined) { text += ` ${theme.fg("accent", `[${args.index}]`)}`; if (args.status) text += ` → ${theme.fg("dim", args.status)}`; } if (args.action === "init" && args.tasks) { text += ` ${theme.fg("dim", `(${args.tasks.length} tasks)`)}`; } return new Text(text, 0, 0); }, renderResult(result, _options, theme) { const details = result.details as PlanTrackerDetails | undefined; if (!details) { const text = result.content[0]; return new Text(text?.type === "text" ? text.text : "", 0, 0); } if (details.error) { return new Text(theme.fg("error", `Error: ${details.error}`), 0, 0); } const taskList = details.tasks; switch (details.action) { case "init": return new Text( theme.fg("success", "✓ ") + theme.fg("muted", `Plan initialized with ${taskList.length} tasks`), 0, 0, ); case "update": { const complete = taskList.filter((t) => t.status === "complete").length; return new Text( theme.fg("success", "✓ ") + theme.fg("muted", `Updated (${complete}/${taskList.length} complete)`), 0, 0, ); } case "status": { if (taskList.length === 0) { return new Text(theme.fg("dim", "No plan active"), 0, 0); } const complete = taskList.filter((t) => t.status === "complete").length; let text = theme.fg("muted", `${complete}/${taskList.length} complete`); for (const t of taskList) { const icon = t.status === "complete" ? theme.fg("success", "✓") : t.status === "in_progress" ? theme.fg("warning", "→") : theme.fg("dim", "○"); text += `\n${icon} ${theme.fg("muted", t.name)}`; } return new Text(text, 0, 0); } case "clear": return new Text(theme.fg("success", "✓ ") + theme.fg("muted", "Plan cleared"), 0, 0); default: return new Text(theme.fg("dim", "Done"), 0, 0); } }, }); }