Initial release
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
*.tgz
|
||||
.DS_Store
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||

|
||||
|
||||
## 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
|
||||
@@ -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 |
Generated
+3343
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 {},
|
||||
}));
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
@@ -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"]
|
||||
}
|
||||
Reference in New Issue
Block a user