Initial release

This commit is contained in:
lesetong
2026-07-22 23:35:04 +08:00
commit 281f3c1615
17 changed files with 5001 additions and 0 deletions
+92
View File
@@ -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
View File
@@ -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
View File
@@ -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);
},
});
}
+57
View File
@@ -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;
}
}
+174
View File
@@ -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)];
});
}
+25
View File
@@ -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 {},
}));
}