add pi task extension
This commit is contained in:
parent
3ac89abb51
commit
c36b59e155
2 changed files with 203 additions and 0 deletions
24
pi/extensions/pi-task/README.md
Normal file
24
pi/extensions/pi-task/README.md
Normal 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.
|
||||||
179
pi/extensions/pi-task/index.ts
Normal file
179
pi/extensions/pi-task/index.ts
Normal 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");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue