diff --git a/pi/extensions/pi-task/README.md b/pi/extensions/pi-task/README.md new file mode 100644 index 0000000..da3d85f --- /dev/null +++ b/pi/extensions/pi-task/README.md @@ -0,0 +1,24 @@ +# pi-task + +A small, session-scoped task list for Pi. It adds one agent tool, `task`, a +compact widget above the editor, and a progress indicator in the footer. + +The tool accepts the complete ordered task list, like Claude's todo tool or +Codex's plan tool. The agent adds, updates, removes, or reorders tasks by +submitting a new list. Omitting the list reads the current state; submitting an +empty list clears it. + +Statuses are `pending`, `in_progress`, and `completed`. At most one task can be +in progress at a time. + +State is stored in Pi tool-result details. It survives reloads and compaction, +follows session branches correctly, and does not create project files. A new Pi +session starts with an empty list. + +While work is active, the widget uses themed symbols and colors to distinguish +the current task (`●`) from pending tasks (`○`). The footer shows compact +overall progress. When every task is complete, Pi displays a success +notification and removes both the widget and footer indicator to reclaim space. +The completed list remains available in session history. + +Run `/tasks` to refresh the task UI manually. diff --git a/pi/extensions/pi-task/index.ts b/pi/extensions/pi-task/index.ts new file mode 100644 index 0000000..af79f34 --- /dev/null +++ b/pi/extensions/pi-task/index.ts @@ -0,0 +1,179 @@ +import type { + ExtensionAPI, + ExtensionContext, + Theme, +} from "@earendil-works/pi-coding-agent"; +import { StringEnum } from "@earendil-works/pi-ai"; +import { Text } from "@earendil-works/pi-tui"; +import { Type } from "typebox"; + +type TaskStatus = "pending" | "in_progress" | "completed"; + +interface Task { + text: string; + status: TaskStatus; +} + +interface TaskDetails { + tasks: Task[]; +} + +const TaskParams = Type.Object({ + tasks: Type.Optional( + Type.Array( + Type.Object({ + text: Type.String({ description: "Short, outcome-oriented task description" }), + status: StringEnum(["pending", "in_progress", "completed"] as const), + }), + { + description: + "Complete ordered task list. Submit the full list to add, update, reorder, or remove tasks. Omit to read it.", + }, + ), + ), +}); + +function cloneTasks(tasks: Task[]): Task[] { + return tasks.map((task) => ({ ...task })); +} + +function formatTask(task: Task): string { + const marker = { + pending: "○", + in_progress: "●", + completed: "✓", + }[task.status]; + + return `${marker} ${task.text}`; +} + +function formatThemedTask(task: Task, theme: Theme): string { + const marker = { + pending: theme.fg("dim", "○"), + in_progress: theme.fg("accent", "●"), + completed: theme.fg("success", "✓"), + }[task.status]; + const text = task.status === "pending" ? theme.fg("muted", task.text) : theme.fg("text", task.text); + + return `${marker} ${text}`; +} + +export default function (pi: ExtensionAPI) { + let tasks: Task[] = []; + + const refreshTaskUI = (ctx: ExtensionContext) => { + if (!ctx.hasUI) return; + + const completed = tasks.filter((task) => task.status === "completed").length; + const remaining = tasks.filter((task) => task.status !== "completed"); + + if (remaining.length === 0) { + ctx.ui.setWidget("pi-task", undefined); + ctx.ui.setStatus("pi-task", undefined); + return; + } + + ctx.ui.setStatus( + "pi-task", + ctx.ui.theme.fg("accent", `● Tasks ${completed}/${tasks.length}`), + ); + ctx.ui.setWidget("pi-task", (_tui, theme) => { + const visible = remaining.slice(0, 7); + const lines = [ + `${theme.fg("accent", theme.bold("Tasks"))} ${theme.fg("muted", `${completed}/${tasks.length}`)}`, + ...visible.map((task) => formatThemedTask(task, theme)), + ]; + const hidden = remaining.length - visible.length; + if (hidden > 0) lines.push(theme.fg("dim", `… ${hidden} more`)); + + return new Text(lines.join("\n"), 0, 0); + }); + }; + + const reconstructState = (ctx: ExtensionContext) => { + tasks = []; + + for (const entry of ctx.sessionManager.getBranch()) { + if (entry.type !== "message") continue; + const message = entry.message; + if (message.role !== "toolResult" || message.toolName !== "task") continue; + + const saved = message.details as TaskDetails | undefined; + if (saved && Array.isArray(saved.tasks)) tasks = cloneTasks(saved.tasks); + } + + refreshTaskUI(ctx); + }; + + pi.on("session_start", async (_event, ctx) => reconstructState(ctx)); + pi.on("session_tree", async (_event, ctx) => reconstructState(ctx)); + + pi.registerTool({ + name: "task", + label: "Task", + description: + "Read or replace the complete ordered task list. Submit the full list to add, update, reorder, remove, or clear tasks. At most one task may be in_progress.", + promptSnippet: "Maintain an ordered task list for multi-step work", + promptGuidelines: [ + "Use task for work with multiple meaningful steps; skip it for simple one-step requests.", + "Keep task items short and outcome-oriented, and submit the complete ordered list whenever it changes.", + "Keep exactly one task in_progress while actively working, and complete all tasks before finishing.", + ], + parameters: TaskParams, + + async execute(_toolCallId, params, _signal, _onUpdate, ctx) { + const wasComplete = tasks.length > 0 && tasks.every((task) => task.status === "completed"); + + if (params.tasks !== undefined) { + const normalized = params.tasks.map((task) => ({ + text: task.text.trim(), + status: task.status, + })); + + if (normalized.some((task) => task.text.length === 0)) { + throw new Error("Task text cannot be empty"); + } + + const active = normalized.filter((task) => task.status === "in_progress").length; + if (active > 1) { + throw new Error("Only one task may be in_progress"); + } + + tasks = normalized; + } + + const isComplete = tasks.length > 0 && tasks.every((task) => task.status === "completed"); + refreshTaskUI(ctx); + if (ctx.hasUI && isComplete && !wasComplete) { + ctx.ui.notify(`✓ All ${tasks.length} tasks completed`, "info"); + } + + return { + content: [ + { + type: "text", + text: tasks.length > 0 ? tasks.map(formatTask).join("\n") : "No tasks", + }, + ], + details: { tasks: cloneTasks(tasks) } satisfies TaskDetails, + }; + }, + }); + + pi.registerCommand("tasks", { + description: "Refresh and show the current task list", + handler: async (_args, ctx) => { + refreshTaskUI(ctx); + if (ctx.hasUI) { + const remaining = tasks.filter((task) => task.status !== "completed").length; + const message = + tasks.length === 0 + ? "No tasks" + : remaining === 0 + ? `✓ All ${tasks.length} tasks completed; task UI is hidden` + : `${remaining} task(s) remaining; task UI refreshed`; + ctx.ui.notify(message, "info"); + } + }, + }); +}