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
+3
View File
@@ -0,0 +1,3 @@
node_modules/
*.tgz
.DS_Store
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 lesetong
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+126
View File
@@ -0,0 +1,126 @@
# pi-todo-herdr
Hierarchical, session-local task tracking for [Pi](https://pi.dev), with atomic batch tools, a live task-tree widget, and optional [Herdr](https://herdr.dev) sidebar integration.
![pi-todo-herdr task tree and Herdr sidebar](https://github.com/leset0ng/pi-todo-herdr/raw/main/media/screenshot.png)
## Features
- Five focused tools: `add_task`, `update_task`, `rm_task`, `list_task`, and `get_task`
- Parent/child task trees with generated, stable integer IDs
- Atomic batch mutations: every requested change succeeds or none of it is applied
- Four states: `pending`, `in-progress`, `blocked`, and `done`
- Session-branch persistence across reload, resume, fork, and tree navigation
- Compact Pi widget with hierarchy, status colors, and an adaptive progress bar
- Optional Herdr metadata showing the current task in the agent sidebar
## Requirements
- Pi 0.81 or newer
- Node.js 20 or newer
- Herdr is optional; all task tools and the Pi widget work without it
## Install
Install the latest version from npm:
```bash
pi install npm:pi-todo-herdr
```
Then start Pi. If Pi is already running, use `/reload` to load the extension.
To try the package for one run without installing it:
```bash
pi -e npm:pi-todo-herdr
```
Update or remove it with Pi's package commands:
```bash
pi update npm:pi-todo-herdr
pi remove npm:pi-todo-herdr
```
## Usage
For complex work, Pi can create and maintain the task tree automatically. The extension adds one short prompt guideline encouraging task tracking for multi-step work without forcing it for trivial requests.
| Tool | Parameters | Behavior |
| --- | --- | --- |
| `add_task` | `{ tasks: [...] }` | Atomically adds multiple tasks. Nested `children` create descendants. |
| `update_task` | `{ tasks: [...] }` | Atomically patches multiple tasks. `parentId: null` moves to the root; `position` is a zero-based sibling index. |
| `rm_task` | `{ ids: [...] }` | Removes multiple tasks. Removing a parent cascades through its descendants. |
| `list_task` | `{}` | Returns the full tree as compact `id/status/name` lines. |
| `get_task` | `{ ids: [...] }` | Returns complete task details, including parent and child IDs. |
Example add call:
```json
{
"tasks": [
{
"name": "Implement authentication",
"description": "Add session validation and route protection.",
"status": "in-progress",
"children": [
{
"name": "Add auth middleware",
"description": "Validate the session before protected handlers run."
}
]
}
]
}
```
Task IDs are generated by the extension and never reused within a session branch. Multiple tasks may be `in-progress`. A parent can be marked `done` only when all of its descendants are also `done`.
## Pi Widget
The widget appears above Pi's editor while tasks exist. Run `/tasks` to hide or show it for the current Pi runtime.
```text
Tasks 2/6 · !1 ━━━━━────────
◉ 1. Build auth
├─ ! 3. Resolve token issue
└─ ○ 4. Add middleware
✓ 2. Define schema
```
Tool calls remain compact in the conversation. Expand a tool result with Pi's normal tool-output shortcut to inspect affected tasks or full descriptions.
## Herdr Integration
When Pi runs inside Herdr, the extension reports the most specific `in-progress` task as a `task` pane metadata token. If nothing is in progress, it falls back to a blocked task. Parallel current tasks are compressed as `#7 Implement auth +2`.
Add `$task` to the Herdr agent sidebar rows in `~/.config/herdr/config.toml`:
```toml
[ui.sidebar.agents]
rows = [["state_icon", "workspace", "tab"], ["agent", "$ask"], ["$task"]]
```
Apply the change to a running Herdr server:
```bash
herdr server reload-config
```
Metadata failures never block task operations. The extension does not change Herdr's agent lifecycle or notification state.
## Development
```bash
npm install
npm run typecheck
npm test
npm pack --dry-run
```
The package has no third-party runtime dependencies. Pi's runtime packages are declared as peers and supplied by Pi.
## License
MIT
+48
View File
@@ -0,0 +1,48 @@
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
import { clearCurrentTask, reportCurrentTask } from "./src/herdr.ts";
import { TaskState } from "./src/state.ts";
import { registerTaskTools } from "./src/tools.ts";
import { clearTaskWidget, updateTaskWidget } from "./src/widget.ts";
export default function piTodoHerdr(pi: ExtensionAPI): void {
const state = new TaskState();
let widgetVisible = true;
const refresh = (ctx: ExtensionContext) => {
updateTaskWidget(state, ctx, widgetVisible);
void reportCurrentTask(state.list());
};
const restore = (ctx: ExtensionContext) => {
state.loadFromSession(ctx);
refresh(ctx);
};
pi.on("session_start", async (_event, ctx) => restore(ctx));
pi.on("session_tree", async (_event, ctx) => restore(ctx));
pi.on("session_shutdown", async (event) => {
if (event.reason === "quit") await clearCurrentTask();
});
registerTaskTools(pi, state, refresh);
pi.registerCommand("tasks", {
description: "Show or hide the task tree widget",
handler: async (_args, ctx) => {
if (state.list().length === 0) {
clearTaskWidget(ctx);
ctx.ui.notify("No tasks yet.", "info");
return;
}
widgetVisible = !widgetVisible;
if (widgetVisible) {
updateTaskWidget(state, ctx, true);
ctx.ui.notify("Task widget shown.", "info");
} else {
clearTaskWidget(ctx);
ctx.ui.notify("Task widget hidden.", "info");
}
},
});
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 202 KiB

+3343
View File
File diff suppressed because it is too large Load Diff
+66
View File
@@ -0,0 +1,66 @@
{
"name": "pi-todo-herdr",
"version": "0.1.0",
"description": "Hierarchical task tools for Pi with a live widget and Herdr sidebar integration.",
"type": "module",
"main": "./index.ts",
"files": [
"index.ts",
"src/**/*",
"README.md",
"LICENSE"
],
"keywords": [
"pi-package",
"pi",
"pi-extension",
"todo",
"task-management",
"herdr",
"productivity"
],
"author": {
"name": "lesetong",
"email": "oscarlbw@qq.com",
"url": "https://github.com/leset0ng"
},
"license": "MIT",
"homepage": "https://github.com/leset0ng/pi-todo-herdr#readme",
"repository": {
"type": "git",
"url": "git+https://github.com/leset0ng/pi-todo-herdr.git"
},
"bugs": {
"url": "https://github.com/leset0ng/pi-todo-herdr/issues"
},
"engines": {
"node": ">=20"
},
"publishConfig": {
"access": "public"
},
"scripts": {
"test": "node --experimental-transform-types --test tests/*.test.ts",
"typecheck": "tsc --noEmit",
"prepublishOnly": "npm run typecheck && npm test"
},
"peerDependencies": {
"@earendil-works/pi-ai": "*",
"@earendil-works/pi-coding-agent": "*",
"@earendil-works/pi-tui": "*",
"typebox": "*"
},
"devDependencies": {
"@earendil-works/pi-ai": "^0.81.1",
"@earendil-works/pi-coding-agent": "^0.81.1",
"@earendil-works/pi-tui": "^0.81.1",
"typebox": "^1.0.13",
"typescript": "^6.0.2"
},
"pi": {
"extensions": [
"./index.ts"
],
"image": "https://github.com/leset0ng/pi-todo-herdr/raw/main/media/screenshot.png"
}
}
+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 {},
}));
}
+178
View File
@@ -0,0 +1,178 @@
import assert from "node:assert/strict";
import test from "node:test";
import { TaskState } from "../src/state.ts";
import { TaskValidationError } from "../src/types.ts";
function names(state: TaskState): string[] {
return state.list().filter((task) => task.parentId === null).map((task) => task.name);
}
test("adds a nested batch with generated preorder IDs and derived relations", () => {
const state = new TaskState();
const added = state.add([
{
name: "Parent",
description: "Parent description",
children: [
{ name: "First child", description: "First" },
{ name: "Second child", description: "Second", status: "blocked" },
],
},
]);
assert.deepEqual(added.map((task) => task.id), [1, 2, 3]);
assert.deepEqual(state.get([1])[0]?.childrenIds, [2, 3]);
assert.equal(state.get([2])[0]?.parentId, 1);
assert.equal(state.get([2])[0]?.status, "pending");
assert.equal(state.snapshot().nextId, 4);
});
test("rejects invalid nested parentId atomically", () => {
const state = new TaskState();
state.add([{ name: "Existing", description: "Existing" }]);
const before = state.snapshot();
assert.throws(
() =>
state.add([
{
name: "Parent",
description: "Parent",
children: [{ name: "Child", description: "Child", parentId: 1 }],
},
]),
TaskValidationError,
);
assert.deepEqual(state.snapshot(), before);
});
test("enforces done descendants against the final atomic update state", () => {
const state = new TaskState();
state.add([{ name: "Parent", description: "Parent", children: [{ name: "Child", description: "Child" }] }]);
const before = state.snapshot();
assert.throws(() => state.update([{ id: 1, status: "done" }]), /unfinished descendants/);
assert.deepEqual(state.snapshot(), before);
state.update([
{ id: 2, status: "done" },
{ id: 1, status: "done" },
]);
assert.deepEqual(state.list().map((task) => task.status), ["done", "done"]);
assert.throws(() => state.update([{ id: 2, status: "in-progress" }]), /unfinished descendants/);
state.update([
{ id: 1, status: "in-progress" },
{ id: 2, status: "in-progress" },
]);
assert.deepEqual(state.list().map((task) => task.status), ["in-progress", "in-progress"]);
});
test("rejects cycles and duplicate patches without partial changes", () => {
const state = new TaskState();
state.add([{ name: "Root", description: "Root", children: [{ name: "Child", description: "Child" }] }]);
const before = state.snapshot();
assert.throws(() => state.update([{ id: 1, parentId: 2 }, { id: 2, name: "Changed" }]), /cycle/);
assert.deepEqual(state.snapshot(), before);
assert.throws(() => state.update([{ id: 2, name: "One" }, { id: 2, name: "Two" }]), /duplicate task patches/);
assert.deepEqual(state.snapshot(), before);
});
test("reorders roots with zero-based sequential positions", () => {
const state = new TaskState();
state.add([
{ name: "A", description: "A" },
{ name: "B", description: "B" },
{ name: "C", description: "C" },
]);
state.update([{ id: 1, position: 2 }]);
assert.deepEqual(names(state), ["B", "C", "A"]);
state.update([
{ id: 1, position: 0 },
{ id: 2, position: 2 },
]);
assert.deepEqual(names(state), ["A", "C", "B"]);
});
test("reparents and positions tasks among their final siblings", () => {
const state = new TaskState();
state.add([
{
name: "One",
description: "One",
children: [
{ name: "A", description: "A" },
{ name: "B", description: "B" },
],
},
{ name: "Two", description: "Two", children: [{ name: "C", description: "C" }] },
]);
state.update([{ id: 3, parentId: 4, position: 0 }]);
assert.deepEqual(state.get([4])[0]?.childrenIds, [3, 5]);
assert.deepEqual(state.get([1])[0]?.childrenIds, [2]);
});
test("removes overlapping parent and child IDs as one cascade", () => {
const state = new TaskState();
state.add([
{
name: "Root",
description: "Root",
children: [{ name: "Child", description: "Child", children: [{ name: "Leaf", description: "Leaf" }] }],
},
{ name: "Keep", description: "Keep" },
]);
const removed = state.remove([1, 2, 1]);
assert.deepEqual(removed.map((task) => task.id), [1, 2, 3]);
assert.deepEqual(state.list().map((task) => task.id), [4]);
assert.equal(state.snapshot().nextId, 5);
const before = state.snapshot();
assert.throws(() => state.remove([999]), /do not exist/);
assert.deepEqual(state.snapshot(), before);
});
test("restores snapshots and never reuses deleted IDs", () => {
const state = new TaskState();
state.add([{ name: "One", description: "One" }, { name: "Two", description: "Two" }]);
state.remove([2]);
const snapshot = state.snapshot();
const restored = new TaskState();
restored.restore(snapshot);
const added = restored.add([{ name: "Three", description: "Three" }]);
assert.equal(added[0]?.id, 3);
});
test("loads the latest valid mutation snapshot from a session branch", () => {
const first = new TaskState();
first.add([{ name: "First", description: "First" }]);
const second = new TaskState();
second.restore(first.snapshot());
second.add([{ name: "Second", description: "Second" }]);
const branch = [first.snapshot(), second.snapshot()].map((snapshot, index) => ({
type: "message",
message: {
role: "toolResult",
toolName: index === 0 ? "add_task" : "update_task",
details: { action: "add", snapshot, affected: [] },
},
}));
const state = new TaskState();
state.loadFromSession({ sessionManager: { getBranch: () => branch } } as never);
assert.deepEqual(state.list().map((task) => task.name), ["First", "Second"]);
});
test("rejects multiline names while allowing multiline descriptions", () => {
const state = new TaskState();
assert.throws(() => state.add([{ name: "Bad\nname", description: "Valid" }]), /single line/);
state.add([{ name: "Valid", description: "Line one\nLine two" }]);
assert.equal(state.list()[0]?.description, "Line one\nLine two");
});
+75
View File
@@ -0,0 +1,75 @@
import assert from "node:assert/strict";
import test from "node:test";
import { TaskState } from "../src/state.ts";
import { registerTaskTools } from "../src/tools.ts";
function registeredTools(state = new TaskState()) {
const tools = new Map<string, any>();
let changes = 0;
const pi = {
registerTool(tool: any) {
tools.set(tool.name, tool);
},
};
registerTaskTools(pi as never, state, () => {
changes += 1;
});
return { tools, state, changes: () => changes };
}
async function execute(tool: any, params: unknown) {
return tool.execute("call", params, undefined, undefined, {});
}
test("registers the five requested tools with minimal top-level parameters", () => {
const { tools } = registeredTools();
assert.deepEqual([...tools.keys()], ["add_task", "update_task", "rm_task", "list_task", "get_task"]);
assert.deepEqual(Object.keys(tools.get("add_task").parameters.properties), ["tasks"]);
assert.deepEqual(Object.keys(tools.get("update_task").parameters.properties), ["tasks"]);
assert.deepEqual(Object.keys(tools.get("rm_task").parameters.properties), ["ids"]);
assert.deepEqual(Object.keys(tools.get("list_task").parameters.properties), []);
assert.deepEqual(Object.keys(tools.get("get_task").parameters.properties), ["ids"]);
});
test("mutations return compact content and complete persistence snapshots", async () => {
const harness = registeredTools();
const result = await execute(harness.tools.get("add_task"), {
tasks: [{ name: "Parent", description: "Parent", children: [{ name: "Child", description: "Child" }] }],
});
assert.equal(result.content[0].text, "Added 2 tasks: 1, 2. 0/2 done.");
assert.equal(result.details.snapshot.tasks.length, 2);
assert.equal(result.details.snapshot.nextId, 3);
assert.equal(harness.changes(), 1);
});
test("tool validation errors are summarized while preserving atomic state", async () => {
const harness = registeredTools();
await execute(harness.tools.get("add_task"), {
tasks: [{ name: "Parent", description: "Parent", children: [{ name: "Child", description: "Child" }] }],
});
const before = harness.state.snapshot();
await assert.rejects(
execute(harness.tools.get("update_task"), { tasks: [{ id: 1, parentId: 2 }] }),
/Update failed · 1 validation error\n- task [12]: parentId creates a cycle/,
);
assert.deepEqual(harness.state.snapshot(), before);
assert.equal(harness.changes(), 1);
});
test("list is compact and get returns full JSON for multiple IDs", async () => {
const harness = registeredTools();
await execute(harness.tools.get("add_task"), {
tasks: [{ name: "Parent", description: "Detailed", children: [{ name: "Child", description: "Child detail" }] }],
});
const list = await execute(harness.tools.get("list_task"), {});
assert.equal(list.content[0].text, "1 [pending] Parent\n└─ 2 [pending] Child");
const get = await execute(harness.tools.get("get_task"), { ids: [1, 2] });
const parsed = JSON.parse(get.content[0].text);
assert.equal(parsed[0].description, "Detailed");
assert.deepEqual(parsed[0].childrenIds, [2]);
assert.equal(parsed[1].parentId, 1);
});
+178
View File
@@ -0,0 +1,178 @@
import assert from "node:assert/strict";
import test from "node:test";
import { visibleWidth } from "@earendil-works/pi-tui";
import { currentTaskLabel } from "../src/herdr.ts";
import { TaskState } from "../src/state.ts";
import {
renderProgressHeader,
renderTaskLine,
renderToolResult,
renderTree,
snapshotStats,
} from "../src/ui.ts";
import { updateTaskWidget } from "../src/widget.ts";
const plainTheme = {
fg: (_color: string, text: string) => text,
bold: (text: string) => text,
strikethrough: (text: string) => `~${text}~`,
};
function stripAnsi(text: string): string {
return text.replace(/\x1b\[[0-9;]*m/g, "");
}
function fixture(): TaskState {
const state = new TaskState();
state.add([
{
name: "Build auth",
description: "Auth work",
status: "in-progress",
children: [
{
name: "Resolve token issue",
description: "Investigate token validation and provider behavior.",
status: "blocked",
children: [{ name: "Inspect provider response", description: "Inspect response" }],
},
{ name: "Add middleware", description: "Middleware" },
],
},
{ name: "Define schema", description: "Schema", status: "done" },
]);
return state;
}
test("renders an adaptive progress bar and stays within width", () => {
const stats = fixture().stats();
const wide = stripAnsi(renderProgressHeader(stats, plainTheme as never, 40));
assert.match(wide, /^ Tasks 1\/5 · !1 ━{4}─{16}$/);
assert.ok(visibleWidth(wide) <= 40);
const narrow = renderProgressHeader(stats, plainTheme as never, 12);
assert.ok(visibleWidth(narrow) <= 12);
assert.doesNotMatch(stripAnsi(narrow), /━|─/);
});
test("renders lightweight connectors without a detached root continuation", () => {
const output = renderTree(fixture().list(), plainTheme as never, 80).map(stripAnsi);
assert.deepEqual(output, [
" ◉ 1. Build auth",
" ├─ ! 2. Resolve token issue",
" │ └─ ○ 3. Inspect provider response",
" └─ ○ 4. Add middleware",
" ✓ 5. ~Define schema~",
]);
});
test("truncates every task row at narrow widths", () => {
const lines = renderTree(fixture().list(), plainTheme as never, 20);
assert.ok(lines.every((line) => visibleWidth(line) <= 20));
assert.ok(lines.some((line) => stripAnsi(line).includes("...")));
});
test("uses semantic icon colors and old-plugin text styling", () => {
const calls: Array<{ color: string; text: string }> = [];
const theme = {
fg: (color: string, text: string) => {
calls.push({ color, text });
return text;
},
bold: (text: string) => text,
strikethrough: (text: string) => `~${text}~`,
};
const tasks = fixture().list();
for (const task of tasks) renderTaskLine(task, "", theme as never, 80);
assert.ok(calls.some((call) => call.color === "dim" && call.text === "○"));
assert.ok(calls.some((call) => call.color === "warning" && call.text === "◉"));
assert.ok(calls.some((call) => call.color === "error" && call.text === "!"));
assert.ok(calls.some((call) => call.color === "success" && call.text === "✓"));
assert.ok(calls.some((call) => call.color === "warning" && call.text === "Build auth"));
assert.ok(calls.some((call) => call.color === "dim" && call.text === "~Define schema~"));
assert.ok(!calls.some((call) => call.color === "error" && call.text === "Resolve token issue"));
});
test("selects the most specific in-progress task and compresses parallel leaves", () => {
const state = new TaskState();
state.add([
{
name: "Parent",
description: "Parent",
status: "in-progress",
children: [{ name: "Child", description: "Child", status: "in-progress" }],
},
{ name: "Parallel", description: "Parallel", status: "in-progress" },
]);
assert.equal(currentTaskLabel(state.list()), "#2 Child +1");
});
test("uses blocked tasks only when nothing is in progress", () => {
const state = new TaskState();
state.add([
{ name: "Blocked one", description: "One", status: "blocked" },
{ name: "Blocked two", description: "Two", status: "blocked" },
]);
assert.equal(currentTaskLabel(state.list()), "#1 Blocked one +1");
state.update([{ id: 1, status: "done" }, { id: 2, status: "done" }]);
assert.equal(currentTaskLabel(state.list()), null);
});
test("hides an empty widget and renders a populated one", () => {
let widget: unknown = "unset";
const ctx = {
ui: {
setWidget: (_id: string, content: unknown) => {
widget = content;
},
},
};
const state = new TaskState();
updateTaskWidget(state, ctx as never, true);
assert.equal(widget, undefined);
state.add([{ name: "Task", description: "Task" }]);
updateTaskWidget(state, ctx as never, true);
assert.equal(typeof widget, "function");
const component = (widget as unknown as (tui: unknown, theme: unknown) => { render(width: number): string[] })(
{},
plainTheme,
);
const lines = component.render(30);
assert.equal(lines.length, 2);
assert.ok(lines.every((line) => visibleWidth(line) <= 30));
});
test("tool results collapse summaries and expand themed details", () => {
const state = fixture();
const details = { action: "list" as const, snapshot: state.snapshot(), affected: state.list() };
const result = { content: [{ type: "text", text: "list" }], details };
const collapsed = renderToolResult(result, false, plainTheme as never).render(80).map(stripAnsi);
assert.deepEqual(collapsed, ["✓ 5 tasks · 1/5 done"]);
const expanded = renderToolResult(result, true, plainTheme as never).render(80).map(stripAnsi);
assert.equal(expanded.length, 6);
assert.match(expanded[1] ?? "", /Build auth/);
});
test("tool errors show one summary line until expanded", () => {
const result = {
content: [{ type: "text", text: "Update failed · 2 validation errors\n- first\n- second" }],
isError: true,
};
const collapsed = renderToolResult(result, false, plainTheme as never).render(80).map(stripAnsi);
assert.deepEqual(collapsed, ["✗ Update failed · 2 validation errors"]);
const expanded = renderToolResult(result, true, plainTheme as never).render(80).map(stripAnsi);
assert.deepEqual(expanded, ["✗ Update failed · 2 validation errors", " - first", " - second"]);
});
test("snapshot stats count every task node", () => {
assert.deepEqual(snapshotStats(fixture().list()), {
total: 5,
pending: 2,
inProgress: 1,
blocked: 1,
done: 1,
});
});
+13
View File
@@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"allowImportingTsExtensions": true,
"strict": true,
"noEmit": true,
"skipLibCheck": true,
"types": ["node"]
},
"include": ["index.ts", "src/**/*.ts", "tests/**/*.ts"]
}