178 lines
6.4 KiB
TypeScript
178 lines
6.4 KiB
TypeScript
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";
|
|
import { TaskStore, type Task, type TaskDetails } from "./state.js";
|
|
|
|
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 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) {
|
|
const store = new TaskStore();
|
|
let turnsSinceTask = 0;
|
|
let reminderDue = false;
|
|
|
|
const refreshTaskUI = (ctx: ExtensionContext) => {
|
|
if (!ctx.hasUI || ctx.mode !== "tui") return;
|
|
|
|
const tasks = store.read();
|
|
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) => {
|
|
store.restore(ctx.sessionManager.getBranch());
|
|
turnsSinceTask = 0;
|
|
reminderDue = store.read().some((task) => task.status !== "completed");
|
|
refreshTaskUI(ctx);
|
|
};
|
|
|
|
pi.on("session_start", async (_event, ctx) => reconstructState(ctx));
|
|
pi.on("session_tree", async (_event, ctx) => reconstructState(ctx));
|
|
|
|
pi.on("turn_start", async () => { turnsSinceTask++; });
|
|
pi.on("session_compact", async () => {
|
|
if (store.read().some((task) => task.status !== "completed")) reminderDue = true;
|
|
});
|
|
pi.on("context", async (event) => {
|
|
const open = store.read().filter((task) => task.status !== "completed");
|
|
if (open.length === 0 || (!reminderDue && turnsSinceTask < 2)) return;
|
|
reminderDue = false;
|
|
turnsSinceTask = 0;
|
|
const shown = open.slice(0, 7).map((task) => `${task.status}: ${task.text.replace(/\s+/g, " ").slice(0, 120)}`);
|
|
const more = open.length > shown.length ? `; ${open.length - shown.length} more open` : "";
|
|
return {
|
|
messages: [
|
|
...event.messages,
|
|
{
|
|
role: "user" as const,
|
|
content: [{ type: "text" as const, text: `Task status reminder (${open.length} open): ${shown.join("; ")}${more}. Check actual work, then call task to update the complete list before reporting completion. Do not mark unfinished work complete.` }],
|
|
timestamp: Date.now(),
|
|
},
|
|
],
|
|
};
|
|
});
|
|
pi.on("agent_settled", async (_event, ctx) => {
|
|
const tasks = store.read();
|
|
const open = tasks.filter((task) => task.status !== "completed").length;
|
|
if (open > 0 && ctx.mode === "tui") {
|
|
ctx.ui.notify(`${open} task(s) still open. If work is complete, update the task list.`, "warning");
|
|
}
|
|
});
|
|
|
|
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. Mark each task completed after verifying it.",
|
|
"Before reporting work complete, call task with the full updated list; do not leave finished work in_progress.",
|
|
],
|
|
parameters: TaskParams,
|
|
|
|
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
const { tasks, wasComplete } = await store.call(params.tasks, signal);
|
|
turnsSinceTask = 0;
|
|
reminderDue = false;
|
|
const isComplete = tasks.length > 0 && tasks.every((task) => task.status === "completed");
|
|
// Rendering must not turn a committed task result into an error result.
|
|
try {
|
|
refreshTaskUI(ctx);
|
|
if (ctx.hasUI && isComplete && !wasComplete) {
|
|
ctx.ui.notify(`✓ All ${tasks.length} tasks completed`, "info");
|
|
}
|
|
} catch { /* Keep the saved result even if the UI is unavailable. */ }
|
|
|
|
return {
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: tasks.length > 0 ? tasks.map(formatTask).join("\n") : "No tasks",
|
|
},
|
|
],
|
|
details: { tasks } satisfies TaskDetails,
|
|
};
|
|
},
|
|
});
|
|
|
|
pi.registerCommand("tasks", {
|
|
description: "Refresh and show the current task list",
|
|
handler: async (_args, ctx) => {
|
|
refreshTaskUI(ctx);
|
|
if (ctx.hasUI) {
|
|
const tasks = store.read();
|
|
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");
|
|
}
|
|
},
|
|
});
|
|
}
|