add pi task extension

This commit is contained in:
Rasyidan Akbar F. 2026-07-13 17:02:04 +07:00
commit c36b59e155
2 changed files with 203 additions and 0 deletions

View file

@ -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.

View file

@ -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");
}
},
});
}