Initial release
This commit is contained in:
@@ -0,0 +1,92 @@
|
||||
import net from "node:net";
|
||||
import type { Task } from "./types.ts";
|
||||
import { orderedTreeRows } from "./ui.ts";
|
||||
|
||||
const SOURCE = "pi-todo-herdr";
|
||||
const TOKEN = "task";
|
||||
let reportSeq = Date.now() * 1000;
|
||||
|
||||
function nextSeq(): number {
|
||||
reportSeq += 1;
|
||||
return reportSeq;
|
||||
}
|
||||
|
||||
export function isHerdrEnvironment(): boolean {
|
||||
return process.env.HERDR_ENV === "1" && !!process.env.HERDR_SOCKET_PATH && !!process.env.HERDR_PANE_ID;
|
||||
}
|
||||
|
||||
function hasDescendantWithStatus(task: Task, status: Task["status"], byId: Map<number, Task>): boolean {
|
||||
for (const childId of task.childrenIds) {
|
||||
const child = byId.get(childId);
|
||||
if (!child) continue;
|
||||
if (child.status === status || hasDescendantWithStatus(child, status, byId)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function currentTaskLabel(tasks: Task[]): string | null {
|
||||
const byId = new Map(tasks.map((task) => [task.id, task]));
|
||||
const ordered = orderedTreeRows(tasks).map(({ task }) => task);
|
||||
const inProgress = ordered.filter(
|
||||
(task) => task.status === "in-progress" && !hasDescendantWithStatus(task, "in-progress", byId),
|
||||
);
|
||||
const candidates =
|
||||
inProgress.length > 0
|
||||
? inProgress
|
||||
: ordered.filter((task) => task.status === "blocked" && !hasDescendantWithStatus(task, "blocked", byId));
|
||||
if (candidates.length === 0) return null;
|
||||
const suffix = candidates.length > 1 ? ` +${candidates.length - 1}` : "";
|
||||
return `#${candidates[0]!.id} ${candidates[0]!.name}${suffix}`;
|
||||
}
|
||||
|
||||
function sendAttempt(request: unknown, timeoutMs: number): Promise<boolean> {
|
||||
if (!isHerdrEnvironment()) return Promise.resolve(true);
|
||||
const socketPath = process.env.HERDR_SOCKET_PATH!;
|
||||
const endpoint = process.platform === "win32" ? `\\\\.\\pipe\\${socketPath}` : socketPath;
|
||||
|
||||
return new Promise((resolve) => {
|
||||
let settled = false;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
const socket = net.createConnection(endpoint);
|
||||
const finish = (delivered: boolean) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
if (timer) clearTimeout(timer);
|
||||
socket.destroy();
|
||||
resolve(delivered);
|
||||
};
|
||||
socket.on("error", () => finish(false));
|
||||
socket.on("connect", () => socket.write(`${JSON.stringify(request)}\n`));
|
||||
socket.on("data", () => finish(true));
|
||||
socket.on("end", () => finish(false));
|
||||
timer = setTimeout(() => finish(false), timeoutMs);
|
||||
timer.unref?.();
|
||||
});
|
||||
}
|
||||
|
||||
async function send(request: unknown): Promise<void> {
|
||||
if (await sendAttempt(request, 500)) return;
|
||||
await sendAttempt(request, 1500);
|
||||
}
|
||||
|
||||
async function reportToken(value: string | null): Promise<void> {
|
||||
if (!isHerdrEnvironment()) return;
|
||||
await send({
|
||||
id: `${SOURCE}:${Date.now()}:${Math.random().toString(36).slice(2)}`,
|
||||
method: "pane.report_metadata",
|
||||
params: {
|
||||
pane_id: process.env.HERDR_PANE_ID,
|
||||
source: SOURCE,
|
||||
tokens: { [TOKEN]: value },
|
||||
seq: nextSeq(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function reportCurrentTask(tasks: Task[]): Promise<void> {
|
||||
return reportToken(currentTaskLabel(tasks));
|
||||
}
|
||||
|
||||
export function clearCurrentTask(): Promise<void> {
|
||||
return reportToken(null);
|
||||
}
|
||||
+343
@@ -0,0 +1,343 @@
|
||||
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
||||
import type {
|
||||
AddTaskDraft,
|
||||
Task,
|
||||
TaskPatch,
|
||||
TaskSnapshot,
|
||||
TaskStats,
|
||||
TaskStatus,
|
||||
TaskToolDetails,
|
||||
} from "./types.ts";
|
||||
import { TaskValidationError } from "./types.ts";
|
||||
|
||||
const MUTATION_TOOLS = new Set(["add_task", "update_task", "rm_task"]);
|
||||
const VALID_STATUSES = new Set<TaskStatus>(["pending", "in-progress", "blocked", "done"]);
|
||||
|
||||
interface TaskRecord {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string;
|
||||
status: TaskStatus;
|
||||
parentId: number | null;
|
||||
}
|
||||
|
||||
function cloneRecords(records: TaskRecord[]): TaskRecord[] {
|
||||
return records.map((task) => ({ ...task }));
|
||||
}
|
||||
|
||||
function indexRecords(records: TaskRecord[]): Map<number, TaskRecord> {
|
||||
return new Map(records.map((task) => [task.id, task]));
|
||||
}
|
||||
|
||||
function childIds(records: TaskRecord[], parentId: number | null): number[] {
|
||||
return records.filter((task) => task.parentId === parentId).map((task) => task.id);
|
||||
}
|
||||
|
||||
function publicTasks(records: TaskRecord[]): Task[] {
|
||||
return records.map((task) => ({
|
||||
...task,
|
||||
childrenIds: childIds(records, task.id),
|
||||
}));
|
||||
}
|
||||
|
||||
function collectDescendantIds(records: TaskRecord[], rootId: number): number[] {
|
||||
const result: number[] = [];
|
||||
const visit = (id: number) => {
|
||||
for (const childId of childIds(records, id)) {
|
||||
result.push(childId);
|
||||
visit(childId);
|
||||
}
|
||||
};
|
||||
visit(rootId);
|
||||
return result;
|
||||
}
|
||||
|
||||
function validateGraph(records: TaskRecord[]): string[] {
|
||||
const errors: string[] = [];
|
||||
const byId = indexRecords(records);
|
||||
const ids = new Set<number>();
|
||||
|
||||
for (const task of records) {
|
||||
if (!Number.isInteger(task.id) || task.id < 1) errors.push(`task ${task.id}: id must be a positive integer`);
|
||||
if (ids.has(task.id)) errors.push(`task ${task.id}: duplicate id`);
|
||||
ids.add(task.id);
|
||||
if (!task.name.trim()) errors.push(`task ${task.id}: name must not be empty`);
|
||||
if (/[\r\n]/.test(task.name)) errors.push(`task ${task.id}: name must be a single line`);
|
||||
if (!task.description.trim()) errors.push(`task ${task.id}: description must not be empty`);
|
||||
if (!VALID_STATUSES.has(task.status)) errors.push(`task ${task.id}: invalid status '${task.status}'`);
|
||||
if (task.parentId !== null && !byId.has(task.parentId)) {
|
||||
errors.push(`task ${task.id}: parent ${task.parentId} does not exist`);
|
||||
}
|
||||
}
|
||||
|
||||
const visiting = new Set<number>();
|
||||
const visited = new Set<number>();
|
||||
const visit = (id: number): boolean => {
|
||||
if (visiting.has(id)) return true;
|
||||
if (visited.has(id)) return false;
|
||||
visiting.add(id);
|
||||
const parentId = byId.get(id)?.parentId;
|
||||
if (parentId !== null && parentId !== undefined && visit(parentId)) return true;
|
||||
visiting.delete(id);
|
||||
visited.add(id);
|
||||
return false;
|
||||
};
|
||||
|
||||
for (const task of records) {
|
||||
if (visit(task.id)) {
|
||||
errors.push(`task ${task.id}: parentId creates a cycle`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length > 0) return errors;
|
||||
|
||||
for (const task of records) {
|
||||
if (task.status !== "done") continue;
|
||||
const unfinished = collectDescendantIds(records, task.id).filter((id) => byId.get(id)?.status !== "done");
|
||||
if (unfinished.length > 0) {
|
||||
errors.push(`task ${task.id}: done task has unfinished descendants ${unfinished.join(", ")}`);
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
function insertAtSiblingPosition(records: TaskRecord[], id: number, position: number): string | undefined {
|
||||
const byId = indexRecords(records);
|
||||
const task = byId.get(id);
|
||||
if (!task) return `task ${id}: does not exist`;
|
||||
|
||||
const siblings = childIds(records, task.parentId).filter((siblingId) => siblingId !== id);
|
||||
if (!Number.isInteger(position) || position < 0 || position > siblings.length) {
|
||||
return `task ${id}: position must be between 0 and ${siblings.length}`;
|
||||
}
|
||||
|
||||
const recordIndex = records.findIndex((record) => record.id === id);
|
||||
const [record] = records.splice(recordIndex, 1);
|
||||
if (!record) return `task ${id}: does not exist`;
|
||||
|
||||
if (position < siblings.length) {
|
||||
const targetIndex = records.findIndex((candidate) => candidate.id === siblings[position]);
|
||||
records.splice(targetIndex, 0, record);
|
||||
return;
|
||||
}
|
||||
|
||||
if (siblings.length === 0) {
|
||||
records.push(record);
|
||||
return;
|
||||
}
|
||||
|
||||
const lastSiblingIndex = records.findIndex((candidate) => candidate.id === siblings[siblings.length - 1]);
|
||||
records.splice(lastSiblingIndex + 1, 0, record);
|
||||
}
|
||||
|
||||
function moveToSiblingEnd(records: TaskRecord[], id: number): void {
|
||||
const task = records.find((candidate) => candidate.id === id);
|
||||
if (!task) return;
|
||||
const siblings = childIds(records, task.parentId).filter((siblingId) => siblingId !== id);
|
||||
const currentIndex = records.findIndex((candidate) => candidate.id === id);
|
||||
const [record] = records.splice(currentIndex, 1);
|
||||
if (!record) return;
|
||||
if (siblings.length === 0) {
|
||||
records.push(record);
|
||||
return;
|
||||
}
|
||||
const lastSiblingIndex = records.findIndex((candidate) => candidate.id === siblings[siblings.length - 1]);
|
||||
records.splice(lastSiblingIndex + 1, 0, record);
|
||||
}
|
||||
|
||||
export class TaskState {
|
||||
private records: TaskRecord[] = [];
|
||||
private nextId = 1;
|
||||
|
||||
list(): Task[] {
|
||||
return publicTasks(this.records);
|
||||
}
|
||||
|
||||
get(ids: number[]): Task[] {
|
||||
const byId = new Map(this.list().map((task) => [task.id, task]));
|
||||
return ids.map((id) => byId.get(id)).filter((task): task is Task => task !== undefined);
|
||||
}
|
||||
|
||||
snapshot(): TaskSnapshot {
|
||||
return { tasks: this.list(), nextId: this.nextId };
|
||||
}
|
||||
|
||||
stats(): TaskStats {
|
||||
const tasks = this.records;
|
||||
return {
|
||||
total: tasks.length,
|
||||
pending: tasks.filter((task) => task.status === "pending").length,
|
||||
inProgress: tasks.filter((task) => task.status === "in-progress").length,
|
||||
blocked: tasks.filter((task) => task.status === "blocked").length,
|
||||
done: tasks.filter((task) => task.status === "done").length,
|
||||
};
|
||||
}
|
||||
|
||||
add(drafts: AddTaskDraft[]): Task[] {
|
||||
const errors: string[] = [];
|
||||
if (!Array.isArray(drafts) || drafts.length === 0) throw new TaskValidationError(["tasks must not be empty"]);
|
||||
|
||||
const working = cloneRecords(this.records);
|
||||
const existingIds = new Set(working.map((task) => task.id));
|
||||
let nextId = this.nextId;
|
||||
const addedIds: number[] = [];
|
||||
|
||||
const append = (draft: AddTaskDraft, implicitParentId: number | null, path: string, nested: boolean) => {
|
||||
if (!draft || typeof draft !== "object") {
|
||||
errors.push(`${path}: task must be an object`);
|
||||
return;
|
||||
}
|
||||
if (typeof draft.name !== "string" || !draft.name.trim()) errors.push(`${path}.name must not be empty`);
|
||||
if (typeof draft.name === "string" && /[\r\n]/.test(draft.name)) errors.push(`${path}.name must be a single line`);
|
||||
if (typeof draft.description !== "string" || !draft.description.trim()) {
|
||||
errors.push(`${path}.description must not be empty`);
|
||||
}
|
||||
if (draft.status !== undefined && !VALID_STATUSES.has(draft.status)) {
|
||||
errors.push(`${path}.status is invalid`);
|
||||
}
|
||||
if (nested && draft.parentId !== undefined) errors.push(`${path}.parentId is not allowed inside children`);
|
||||
if (draft.children !== undefined && !Array.isArray(draft.children)) errors.push(`${path}.children must be an array`);
|
||||
|
||||
let parentId = implicitParentId;
|
||||
if (!nested && draft.parentId !== undefined) {
|
||||
if (!Number.isInteger(draft.parentId) || !existingIds.has(draft.parentId)) {
|
||||
errors.push(`${path}.parentId ${draft.parentId} does not exist`);
|
||||
} else {
|
||||
parentId = draft.parentId;
|
||||
}
|
||||
}
|
||||
|
||||
const id = nextId++;
|
||||
addedIds.push(id);
|
||||
working.push({
|
||||
id,
|
||||
name: typeof draft.name === "string" ? draft.name.trim() : "",
|
||||
description: typeof draft.description === "string" ? draft.description.trim() : "",
|
||||
status: draft.status ?? "pending",
|
||||
parentId,
|
||||
});
|
||||
|
||||
for (const [index, child] of (draft.children ?? []).entries()) {
|
||||
append(child, id, `${path}.children[${index}]`, true);
|
||||
}
|
||||
};
|
||||
|
||||
drafts.forEach((draft, index) => append(draft, null, `tasks[${index}]`, false));
|
||||
errors.push(...validateGraph(working));
|
||||
if (errors.length > 0) throw new TaskValidationError([...new Set(errors)]);
|
||||
|
||||
this.records = working;
|
||||
this.nextId = nextId;
|
||||
return this.get(addedIds);
|
||||
}
|
||||
|
||||
update(patches: TaskPatch[]): Task[] {
|
||||
const errors: string[] = [];
|
||||
if (!Array.isArray(patches) || patches.length === 0) throw new TaskValidationError(["tasks must not be empty"]);
|
||||
|
||||
const duplicateIds = patches
|
||||
.map((patch) => patch.id)
|
||||
.filter((id, index, ids) => ids.indexOf(id) !== index);
|
||||
if (duplicateIds.length > 0) errors.push(`duplicate task patches: ${[...new Set(duplicateIds)].join(", ")}`);
|
||||
|
||||
const working = cloneRecords(this.records);
|
||||
for (const [index, patch] of patches.entries()) {
|
||||
const path = `tasks[${index}]`;
|
||||
const task = working.find((candidate) => candidate.id === patch.id);
|
||||
if (!task) {
|
||||
errors.push(`${path}.id ${patch.id} does not exist`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const hasChange = ["name", "description", "status", "parentId", "position"].some((key) =>
|
||||
Object.prototype.hasOwnProperty.call(patch, key),
|
||||
);
|
||||
if (!hasChange) errors.push(`${path} must include at least one field to update`);
|
||||
if (patch.name !== undefined && (typeof patch.name !== "string" || !patch.name.trim())) {
|
||||
errors.push(`${path}.name must not be empty`);
|
||||
}
|
||||
if (typeof patch.name === "string" && /[\r\n]/.test(patch.name)) errors.push(`${path}.name must be a single line`);
|
||||
if (patch.description !== undefined && (typeof patch.description !== "string" || !patch.description.trim())) {
|
||||
errors.push(`${path}.description must not be empty`);
|
||||
}
|
||||
if (patch.status !== undefined && !VALID_STATUSES.has(patch.status)) errors.push(`${path}.status is invalid`);
|
||||
if (patch.parentId !== undefined && patch.parentId !== null) {
|
||||
if (!Number.isInteger(patch.parentId) || !working.some((candidate) => candidate.id === patch.parentId)) {
|
||||
errors.push(`${path}.parentId ${patch.parentId} does not exist`);
|
||||
}
|
||||
}
|
||||
|
||||
if (patch.name !== undefined) task.name = patch.name.trim();
|
||||
if (patch.description !== undefined) task.description = patch.description.trim();
|
||||
if (patch.status !== undefined) task.status = patch.status;
|
||||
if (patch.parentId !== undefined && (patch.parentId === null || working.some((item) => item.id === patch.parentId))) {
|
||||
task.parentId = patch.parentId;
|
||||
moveToSiblingEnd(working, task.id);
|
||||
}
|
||||
if (patch.position !== undefined) {
|
||||
const positionError = insertAtSiblingPosition(working, task.id, patch.position);
|
||||
if (positionError) errors.push(`${path}.position: ${positionError}`);
|
||||
}
|
||||
}
|
||||
|
||||
errors.push(...validateGraph(working));
|
||||
if (errors.length > 0) throw new TaskValidationError([...new Set(errors)]);
|
||||
|
||||
this.records = working;
|
||||
return this.get(patches.map((patch) => patch.id));
|
||||
}
|
||||
|
||||
remove(ids: number[]): Task[] {
|
||||
if (!Array.isArray(ids) || ids.length === 0) throw new TaskValidationError(["ids must not be empty"]);
|
||||
const uniqueIds = [...new Set(ids)];
|
||||
const missing = uniqueIds.filter((id) => !this.records.some((task) => task.id === id));
|
||||
if (missing.length > 0) throw new TaskValidationError([`task ids do not exist: ${missing.join(", ")}`]);
|
||||
|
||||
const removedIds = new Set<number>();
|
||||
for (const id of uniqueIds) {
|
||||
removedIds.add(id);
|
||||
for (const descendantId of collectDescendantIds(this.records, id)) removedIds.add(descendantId);
|
||||
}
|
||||
const removed = this.list().filter((task) => removedIds.has(task.id));
|
||||
this.records = this.records.filter((task) => !removedIds.has(task.id));
|
||||
return removed;
|
||||
}
|
||||
|
||||
restore(snapshot: TaskSnapshot): void {
|
||||
if (!snapshot || !Array.isArray(snapshot.tasks) || !Number.isInteger(snapshot.nextId)) {
|
||||
throw new TaskValidationError(["invalid task snapshot"]);
|
||||
}
|
||||
const records = snapshot.tasks.map(({ id, name, description, status, parentId }) => ({
|
||||
id,
|
||||
name,
|
||||
description,
|
||||
status,
|
||||
parentId,
|
||||
}));
|
||||
const errors = validateGraph(records);
|
||||
const maxId = records.reduce((max, task) => Math.max(max, task.id), 0);
|
||||
if (snapshot.nextId <= maxId) errors.push(`nextId must be greater than ${maxId}`);
|
||||
if (errors.length > 0) throw new TaskValidationError(errors);
|
||||
this.records = cloneRecords(records);
|
||||
this.nextId = snapshot.nextId;
|
||||
}
|
||||
|
||||
loadFromSession(ctx: ExtensionContext): void {
|
||||
this.records = [];
|
||||
this.nextId = 1;
|
||||
for (const entry of ctx.sessionManager.getBranch()) {
|
||||
if (entry.type !== "message") continue;
|
||||
const message = entry.message;
|
||||
if (message.role !== "toolResult" || !MUTATION_TOOLS.has(message.toolName)) continue;
|
||||
const details = message.details as TaskToolDetails | undefined;
|
||||
if (!details?.snapshot) continue;
|
||||
try {
|
||||
this.restore(details.snapshot);
|
||||
} catch {
|
||||
// Ignore corrupt historical snapshots and keep the latest valid state.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+259
@@ -0,0 +1,259 @@
|
||||
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 } from "typebox";
|
||||
import type { TaskState } from "./state.ts";
|
||||
import type { AddTaskDraft, Task, TaskPatch, TaskToolDetails } from "./types.ts";
|
||||
import { TaskValidationError } from "./types.ts";
|
||||
import { orderedTreeRows, renderToolResult } from "./ui.ts";
|
||||
|
||||
const TaskStatusSchema = StringEnum(["pending", "in-progress", "blocked", "done"] as const, {
|
||||
description: "Task status. Defaults to pending when omitted during add.",
|
||||
});
|
||||
|
||||
const AddTaskSchema = Type.Cyclic(
|
||||
{
|
||||
AddTask: Type.Object(
|
||||
{
|
||||
name: Type.String({ minLength: 1 }),
|
||||
description: Type.String({ minLength: 1 }),
|
||||
status: Type.Optional(TaskStatusSchema),
|
||||
parentId: Type.Optional(
|
||||
Type.Integer({ minimum: 1, description: "Existing parent ID. Allowed only on top-level tasks." }),
|
||||
),
|
||||
children: Type.Optional(Type.Array(Type.Ref("AddTask"))),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
),
|
||||
},
|
||||
"AddTask",
|
||||
);
|
||||
|
||||
export const AddTaskParams = Type.Object(
|
||||
{
|
||||
tasks: Type.Array(AddTaskSchema, { minItems: 1, description: "Tasks to add in one atomic batch." }),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
const TaskPatchSchema = Type.Object(
|
||||
{
|
||||
id: Type.Integer({ minimum: 1 }),
|
||||
name: Type.Optional(Type.String({ minLength: 1 })),
|
||||
description: Type.Optional(Type.String({ minLength: 1 })),
|
||||
status: Type.Optional(TaskStatusSchema),
|
||||
parentId: Type.Optional(
|
||||
Type.Union([Type.Integer({ minimum: 1 }), Type.Null()], {
|
||||
description: "New parent ID, or null to move the task to the root.",
|
||||
}),
|
||||
),
|
||||
position: Type.Optional(
|
||||
Type.Integer({ minimum: 0, description: "Zero-based insertion index among the task's final siblings." }),
|
||||
),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
export const UpdateTaskParams = Type.Object(
|
||||
{
|
||||
tasks: Type.Array(TaskPatchSchema, { minItems: 1, description: "Task patches applied atomically in array order." }),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
export const IdsParams = Type.Object(
|
||||
{
|
||||
ids: Type.Array(Type.Integer({ minimum: 1 }), {
|
||||
minItems: 1,
|
||||
description: "Task IDs to process in one call. Duplicate IDs are deduplicated.",
|
||||
}),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
export const ListTaskParams = Type.Object({}, { additionalProperties: false });
|
||||
|
||||
interface AddTaskInput {
|
||||
tasks: AddTaskDraft[];
|
||||
}
|
||||
|
||||
interface UpdateTaskInput {
|
||||
tasks: TaskPatch[];
|
||||
}
|
||||
|
||||
interface IdsInput {
|
||||
ids: number[];
|
||||
}
|
||||
|
||||
type ChangeHandler = (ctx: ExtensionContext) => void;
|
||||
|
||||
function taskIds(tasks: Task[]): string {
|
||||
return tasks.map((task) => task.id).join(", ");
|
||||
}
|
||||
|
||||
function compactTree(tasks: Task[]): string {
|
||||
if (tasks.length === 0) return "No tasks.";
|
||||
return orderedTreeRows(tasks)
|
||||
.map(({ task, prefix }) => `${prefix}${task.id} [${task.status}] ${task.name}`)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function details(action: TaskToolDetails["action"], state: TaskState, affected: Task[]): TaskToolDetails {
|
||||
return { action, snapshot: state.snapshot(), affected };
|
||||
}
|
||||
|
||||
function mutationText(verb: string, affected: Task[], state: TaskState): string {
|
||||
const stats = state.stats();
|
||||
return `${verb} ${affected.length} task${affected.length === 1 ? "" : "s"}: ${taskIds(affected)}. ${stats.done}/${stats.total} done.`;
|
||||
}
|
||||
|
||||
function throwToolError(label: string, error: unknown): never {
|
||||
if (error instanceof TaskValidationError) {
|
||||
const count = error.errors.length;
|
||||
throw new Error(
|
||||
`${label} failed · ${count} validation error${count === 1 ? "" : "s"}\n${error.errors.map((item) => `- ${item}`).join("\n")}`,
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
function renderCall(title: string, count: number | undefined, theme: Theme): Text {
|
||||
const suffix = count === undefined ? "" : theme.fg("dim", ` (${count})`);
|
||||
return new Text(theme.fg("toolTitle", theme.bold(title)) + suffix, 0, 0);
|
||||
}
|
||||
|
||||
function notifyChange(onChange: ChangeHandler, ctx: ExtensionContext): void {
|
||||
try {
|
||||
onChange(ctx);
|
||||
} catch (error) {
|
||||
console.error("[pi-todo-herdr] failed to refresh task UI:", error);
|
||||
}
|
||||
}
|
||||
|
||||
export function registerTaskTools(pi: ExtensionAPI, state: TaskState, onChange: ChangeHandler): void {
|
||||
pi.registerTool({
|
||||
name: "add_task",
|
||||
label: "Add Tasks",
|
||||
description:
|
||||
"Atomically add one or more tasks. Use nested children for new descendants and parentId on top-level items for an existing parent.",
|
||||
promptGuidelines: [
|
||||
"Use add_task for complex multi-step work; use update_task immediately when tasks start, block, resume, or finish; skip task tracking for simple one-step work.",
|
||||
],
|
||||
parameters: AddTaskParams,
|
||||
async execute(_toolCallId, params: AddTaskInput, _signal, _onUpdate, ctx) {
|
||||
try {
|
||||
const affected = state.add(params.tasks);
|
||||
notifyChange(onChange, ctx);
|
||||
return {
|
||||
content: [{ type: "text" as const, text: mutationText("Added", affected, state) }],
|
||||
details: details("add", state, affected),
|
||||
};
|
||||
} catch (error) {
|
||||
throwToolError("Add", error);
|
||||
}
|
||||
},
|
||||
renderCall(args: AddTaskInput, theme: Theme) {
|
||||
return renderCall("Add Tasks", args.tasks?.length, theme);
|
||||
},
|
||||
renderResult(result, { expanded }, theme) {
|
||||
return renderToolResult(result, expanded, theme);
|
||||
},
|
||||
});
|
||||
|
||||
pi.registerTool({
|
||||
name: "update_task",
|
||||
label: "Update Tasks",
|
||||
description:
|
||||
"Atomically patch one or more tasks. Fields are optional except id; parentId null moves to root and position is a zero-based sibling index.",
|
||||
parameters: UpdateTaskParams,
|
||||
async execute(_toolCallId, params: UpdateTaskInput, _signal, _onUpdate, ctx) {
|
||||
try {
|
||||
const affected = state.update(params.tasks);
|
||||
notifyChange(onChange, ctx);
|
||||
return {
|
||||
content: [{ type: "text" as const, text: mutationText("Updated", affected, state) }],
|
||||
details: details("update", state, affected),
|
||||
};
|
||||
} catch (error) {
|
||||
throwToolError("Update", error);
|
||||
}
|
||||
},
|
||||
renderCall(args: UpdateTaskInput, theme: Theme) {
|
||||
return renderCall("Update Tasks", args.tasks?.length, theme);
|
||||
},
|
||||
renderResult(result, { expanded }, theme) {
|
||||
return renderToolResult(result, expanded, theme);
|
||||
},
|
||||
});
|
||||
|
||||
pi.registerTool({
|
||||
name: "rm_task",
|
||||
label: "Remove Tasks",
|
||||
description: "Atomically remove multiple task IDs. Removing a parent also removes its entire descendant subtree.",
|
||||
parameters: IdsParams,
|
||||
async execute(_toolCallId, params: IdsInput, _signal, _onUpdate, ctx) {
|
||||
try {
|
||||
const affected = state.remove(params.ids);
|
||||
notifyChange(onChange, ctx);
|
||||
return {
|
||||
content: [{ type: "text" as const, text: mutationText("Removed", affected, state) }],
|
||||
details: details("remove", state, affected),
|
||||
};
|
||||
} catch (error) {
|
||||
throwToolError("Remove", error);
|
||||
}
|
||||
},
|
||||
renderCall(args: IdsInput, theme: Theme) {
|
||||
return renderCall("Remove Tasks", args.ids?.length, theme);
|
||||
},
|
||||
renderResult(result, { expanded }, theme) {
|
||||
return renderToolResult(result, expanded, theme);
|
||||
},
|
||||
});
|
||||
|
||||
pi.registerTool({
|
||||
name: "list_task",
|
||||
label: "List Tasks",
|
||||
description: "List the full task tree compactly with id, name, and status.",
|
||||
parameters: ListTaskParams,
|
||||
async execute() {
|
||||
const affected = state.list();
|
||||
return {
|
||||
content: [{ type: "text" as const, text: compactTree(affected) }],
|
||||
details: details("list", state, affected),
|
||||
};
|
||||
},
|
||||
renderCall(_args, theme: Theme) {
|
||||
return renderCall("List Tasks", undefined, theme);
|
||||
},
|
||||
renderResult(result, { expanded }, theme) {
|
||||
return renderToolResult(result, expanded, theme);
|
||||
},
|
||||
});
|
||||
|
||||
pi.registerTool({
|
||||
name: "get_task",
|
||||
label: "Get Tasks",
|
||||
description: "Get complete details for one or more task IDs, including parent and child IDs.",
|
||||
parameters: IdsParams,
|
||||
async execute(_toolCallId, params: IdsInput) {
|
||||
const uniqueIds = [...new Set(params.ids)];
|
||||
const affected = state.get(uniqueIds);
|
||||
const found = new Set(affected.map((task) => task.id));
|
||||
const missing = uniqueIds.filter((id) => !found.has(id));
|
||||
if (missing.length > 0) {
|
||||
throwToolError("Get", new TaskValidationError([`task ids do not exist: ${missing.join(", ")}`]));
|
||||
}
|
||||
return {
|
||||
content: [{ type: "text" as const, text: JSON.stringify(affected) }],
|
||||
details: details("get", state, affected),
|
||||
};
|
||||
},
|
||||
renderCall(args: IdsInput, theme: Theme) {
|
||||
return renderCall("Get Tasks", args.ids?.length, theme);
|
||||
},
|
||||
renderResult(result, { expanded }, theme) {
|
||||
return renderToolResult(result, expanded, theme);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
export type TaskStatus = "pending" | "in-progress" | "blocked" | "done";
|
||||
|
||||
export interface Task {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string;
|
||||
status: TaskStatus;
|
||||
parentId: number | null;
|
||||
childrenIds: number[];
|
||||
}
|
||||
|
||||
export interface TaskSnapshot {
|
||||
tasks: Task[];
|
||||
nextId: number;
|
||||
}
|
||||
|
||||
export interface AddTaskDraft {
|
||||
name: string;
|
||||
description: string;
|
||||
status?: TaskStatus;
|
||||
parentId?: number;
|
||||
children?: AddTaskDraft[];
|
||||
}
|
||||
|
||||
export interface TaskPatch {
|
||||
id: number;
|
||||
name?: string;
|
||||
description?: string;
|
||||
status?: TaskStatus;
|
||||
parentId?: number | null;
|
||||
position?: number;
|
||||
}
|
||||
|
||||
export interface TaskStats {
|
||||
total: number;
|
||||
pending: number;
|
||||
inProgress: number;
|
||||
blocked: number;
|
||||
done: number;
|
||||
}
|
||||
|
||||
export interface TaskToolDetails {
|
||||
action: "add" | "update" | "remove" | "list" | "get";
|
||||
snapshot: TaskSnapshot;
|
||||
affected: Task[];
|
||||
errors?: string[];
|
||||
}
|
||||
|
||||
export class TaskValidationError extends Error {
|
||||
readonly errors: string[];
|
||||
|
||||
constructor(errors: string[]) {
|
||||
super(errors.join("\n"));
|
||||
this.name = "TaskValidationError";
|
||||
this.errors = errors;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
import type { Component } from "@earendil-works/pi-tui";
|
||||
import { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
||||
import type { Theme } from "@earendil-works/pi-coding-agent";
|
||||
import type { Task, TaskStats, TaskToolDetails } from "./types.ts";
|
||||
|
||||
export const STATUS_ICONS = {
|
||||
pending: "○",
|
||||
"in-progress": "◉",
|
||||
blocked: "!",
|
||||
done: "✓",
|
||||
} as const;
|
||||
|
||||
interface TreeRow {
|
||||
task: Task;
|
||||
prefix: string;
|
||||
}
|
||||
|
||||
export function orderedTreeRows(tasks: Task[]): TreeRow[] {
|
||||
const byId = new Map(tasks.map((task) => [task.id, task]));
|
||||
const rows: TreeRow[] = [];
|
||||
|
||||
const visit = (task: Task, ancestorHasNext: boolean[], isLast: boolean, depth: number) => {
|
||||
const ancestors = ancestorHasNext.map((hasNext) => (hasNext ? "│ " : " ")).join("");
|
||||
const connector = depth === 0 ? "" : isLast ? "└─ " : "├─ ";
|
||||
rows.push({ task, prefix: `${ancestors}${connector}` });
|
||||
const children = task.childrenIds.map((id) => byId.get(id)).filter((child): child is Task => child !== undefined);
|
||||
children.forEach((child, index) => {
|
||||
const childAncestors = depth === 0 ? [] : [...ancestorHasNext, !isLast];
|
||||
visit(child, childAncestors, index === children.length - 1, depth + 1);
|
||||
});
|
||||
};
|
||||
|
||||
const roots = tasks.filter((task) => task.parentId === null);
|
||||
roots.forEach((root, index) => visit(root, [], index === roots.length - 1, 0));
|
||||
return rows;
|
||||
}
|
||||
|
||||
function styleTaskName(task: Task, theme: Theme): string {
|
||||
if (task.status === "done") return theme.fg("dim", theme.strikethrough(task.name));
|
||||
if (task.status === "in-progress") return theme.fg("warning", task.name);
|
||||
return task.name;
|
||||
}
|
||||
|
||||
function styleStatusIcon(task: Task, theme: Theme): string {
|
||||
const icon = STATUS_ICONS[task.status];
|
||||
if (task.status === "done") return theme.fg("success", icon);
|
||||
if (task.status === "in-progress") return theme.fg("warning", icon);
|
||||
if (task.status === "blocked") return theme.fg("error", icon);
|
||||
return theme.fg("dim", icon);
|
||||
}
|
||||
|
||||
export function renderTaskLine(task: Task, prefix: string, theme: Theme, width: number): string {
|
||||
const line =
|
||||
theme.fg("dim", prefix) +
|
||||
styleStatusIcon(task, theme) +
|
||||
" " +
|
||||
theme.fg("accent", `${task.id}.`) +
|
||||
" " +
|
||||
styleTaskName(task, theme);
|
||||
return truncateToWidth(line, Math.max(1, width));
|
||||
}
|
||||
|
||||
export function renderTree(tasks: Task[], theme: Theme, width: number, padding = " "): string[] {
|
||||
return orderedTreeRows(tasks).map(({ task, prefix }) => renderTaskLine(task, `${padding}${prefix}`, theme, width));
|
||||
}
|
||||
|
||||
export function progressLabel(stats: TaskStats): string {
|
||||
const blocked = stats.blocked > 0 ? ` · !${stats.blocked}` : "";
|
||||
return ` Tasks ${stats.done}/${stats.total}${blocked}`;
|
||||
}
|
||||
|
||||
export function renderProgressHeader(stats: TaskStats, theme: Theme, width: number): string {
|
||||
const label = progressLabel(stats);
|
||||
const available = width - visibleWidth(label) - 2;
|
||||
if (available < 5) return truncateToWidth(theme.fg("accent", label), Math.max(1, width));
|
||||
|
||||
const barWidth = Math.min(20, available);
|
||||
const filled = stats.total === 0 ? 0 : Math.round((stats.done / stats.total) * barWidth);
|
||||
const filledColor = stats.done === stats.total ? "success" : "accent";
|
||||
const bar =
|
||||
theme.fg(filledColor, theme.bold("━".repeat(filled))) + theme.fg("dim", "─".repeat(barWidth - filled));
|
||||
return truncateToWidth(`${theme.fg("accent", label)} ${bar}`, Math.max(1, width));
|
||||
}
|
||||
|
||||
class TaskResultComponent implements Component {
|
||||
constructor(
|
||||
private readonly build: (width: number) => string[],
|
||||
) {}
|
||||
|
||||
render(width: number): string[] {
|
||||
return this.build(Math.max(1, width)).map((line) => truncateToWidth(line, Math.max(1, width)));
|
||||
}
|
||||
|
||||
invalidate(): void {}
|
||||
}
|
||||
|
||||
function firstText(content: Array<{ type: string; text?: string }>): string {
|
||||
return content.find((item) => item.type === "text")?.text ?? "";
|
||||
}
|
||||
|
||||
function summaryFor(details: TaskToolDetails): string {
|
||||
const stats = snapshotStats(details.snapshot.tasks);
|
||||
const progress = `${stats.done}/${stats.total} done`;
|
||||
switch (details.action) {
|
||||
case "add":
|
||||
return `${details.affected.length} added · ${progress}`;
|
||||
case "update":
|
||||
return `${details.affected.length} updated · ${progress}`;
|
||||
case "remove":
|
||||
return `${details.affected.length} removed · ${progress}`;
|
||||
case "list":
|
||||
return `${stats.total} task${stats.total === 1 ? "" : "s"} · ${progress}`;
|
||||
case "get":
|
||||
return `${details.affected.length} task${details.affected.length === 1 ? "" : "s"}`;
|
||||
}
|
||||
}
|
||||
|
||||
export function snapshotStats(tasks: Task[]): TaskStats {
|
||||
return {
|
||||
total: tasks.length,
|
||||
pending: tasks.filter((task) => task.status === "pending").length,
|
||||
inProgress: tasks.filter((task) => task.status === "in-progress").length,
|
||||
blocked: tasks.filter((task) => task.status === "blocked").length,
|
||||
done: tasks.filter((task) => task.status === "done").length,
|
||||
};
|
||||
}
|
||||
|
||||
function renderFlatTasks(tasks: Task[], theme: Theme, width: number): string[] {
|
||||
return tasks.map((task) => renderTaskLine(task, " ", theme, width));
|
||||
}
|
||||
|
||||
function renderTaskDetails(tasks: Task[], theme: Theme, width: number): string[] {
|
||||
const lines: string[] = [];
|
||||
for (const [index, task] of tasks.entries()) {
|
||||
if (index > 0) lines.push("");
|
||||
lines.push(renderTaskLine(task, " ", theme, width));
|
||||
const parent = task.parentId === null ? "root" : `#${task.parentId}`;
|
||||
const children = task.childrenIds.length === 0 ? "none" : task.childrenIds.map((id) => `#${id}`).join(", ");
|
||||
lines.push(truncateToWidth(theme.fg("dim", ` Parent ${parent} · Children ${children}`), width));
|
||||
const prefix = " ";
|
||||
const descriptionWidth = Math.max(1, width - visibleWidth(prefix));
|
||||
for (const sourceLine of task.description.split("\n")) {
|
||||
const wrapped = wrapTextWithAnsi(theme.fg("muted", sourceLine), descriptionWidth);
|
||||
if (wrapped.length === 0) lines.push(prefix);
|
||||
else lines.push(...wrapped.map((line) => `${prefix}${line}`));
|
||||
}
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
export function renderToolResult(
|
||||
result: { content: Array<{ type: string; text?: string }>; details?: unknown; isError?: boolean },
|
||||
expanded: boolean,
|
||||
theme: Theme,
|
||||
): Component {
|
||||
const raw = firstText(result.content);
|
||||
const details = result.details as TaskToolDetails | undefined;
|
||||
|
||||
if (result.isError || !details) {
|
||||
const rawLines = raw.split("\n").filter(Boolean);
|
||||
return new TaskResultComponent((width) => {
|
||||
const lines = expanded ? rawLines : rawLines.slice(0, 1);
|
||||
return lines.map((line, index) => theme.fg("error", `${index === 0 ? "✗ " : " "}${line}`));
|
||||
});
|
||||
}
|
||||
|
||||
return new TaskResultComponent((width) => {
|
||||
const lines = [theme.fg("success", "✓ ") + theme.fg("muted", summaryFor(details))];
|
||||
if (!expanded || details.affected.length === 0) return lines;
|
||||
if (details.action === "list") return [...lines, ...renderTree(details.snapshot.tasks, theme, width)];
|
||||
if (details.action === "get") return [...lines, ...renderTaskDetails(details.affected, theme, width)];
|
||||
return [...lines, ...renderFlatTasks(details.affected, theme, width)];
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
||||
import type { TaskState } from "./state.ts";
|
||||
import { renderProgressHeader, renderTree } from "./ui.ts";
|
||||
|
||||
const WIDGET_ID = "pi-todo-herdr";
|
||||
|
||||
export function clearTaskWidget(ctx: ExtensionContext): void {
|
||||
ctx.ui.setWidget(WIDGET_ID, undefined);
|
||||
}
|
||||
|
||||
export function updateTaskWidget(state: TaskState, ctx: ExtensionContext, visible: boolean): void {
|
||||
const tasks = state.list();
|
||||
if (!visible || tasks.length === 0) {
|
||||
clearTaskWidget(ctx);
|
||||
return;
|
||||
}
|
||||
|
||||
const stats = state.stats();
|
||||
ctx.ui.setWidget(WIDGET_ID, (_tui, theme) => ({
|
||||
render(width: number): string[] {
|
||||
return [renderProgressHeader(stats, theme, width), ...renderTree(tasks, theme, width)];
|
||||
},
|
||||
invalidate(): void {},
|
||||
}));
|
||||
}
|
||||
Reference in New Issue
Block a user