Compare commits
10
Commits
57c3354211
...
6826854b66
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6826854b66 | ||
|
|
6c6b9ec01b | ||
|
|
98f3904189 | ||
|
|
cd74f91e2a | ||
|
|
ff497e23b3 | ||
|
|
7783e7166a | ||
|
|
8af1c53c83 | ||
|
|
28d7e66d8a | ||
|
|
7971cf1a09 | ||
|
|
2ff0a314ed |
@@ -1,3 +1,5 @@
|
||||
## Forked from [leset0ng/pi-todo-herdr](https://github.com/leset0ng/pi-todo-herdr)
|
||||
|
||||
# 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.
|
||||
@@ -10,6 +12,7 @@ Hierarchical, session-local task tracking for [Pi](https://pi.dev), with atomic
|
||||
- Parent/child task trees with generated integer IDs and atomic append/replace modes
|
||||
- Atomic batch mutations: every requested change succeeds or none of it is applied
|
||||
- Four states: `pending`, `in-progress`, `blocked`, and `done`
|
||||
- Cumulative active-time tracking that pauses outside `in-progress` and survives session restore
|
||||
- Session-branch persistence across reload, resume, fork, tree navigation, and context compaction
|
||||
- A concise first-action workflow prompt plus a one-shot post-compaction recovery reminder
|
||||
- Compact Pi widget with hierarchy, status colors, and an adaptive progress bar
|
||||
@@ -46,17 +49,17 @@ pi remove npm:pi-todo-herdr
|
||||
|
||||
## Usage
|
||||
|
||||
For large, multi-stage work, the extension instructs Pi to call `set_tasks` before exploration as the only tool in the first batch. Smaller work remains untracked. Pi then updates task states at each real transition instead of reconstructing progress at the end.
|
||||
For work with three or more distinct steps, the extension instructs Pi to explore enough to understand the scope, then call `set_tasks` before implementation. Smaller work remains untracked. Pi then updates task states at each real transition instead of reconstructing progress at the end.
|
||||
|
||||
After automatic or manual context compaction, the extension sends one short hidden reminder only when unfinished tasks exist. Pi calls `list_task` to recover the authoritative current IDs and statuses; the full tree is not injected on every model call.
|
||||
|
||||
| Tool | Parameters | Behavior |
|
||||
| --- | --- | --- |
|
||||
| `set_tasks` | `{ mode, tasks, force? }` | `append` extends the tree; `replace` atomically installs a new tree or clears it with `[]`. Replacing unfinished work requires `force: true`. |
|
||||
| `update_task` | `{ tasks: [...] }` | Atomically patches multiple tasks. `parentId: null` moves to the root; `position` is a zero-based sibling index. |
|
||||
| `set_tasks` | `{ mode, tasks, force }` | `append` extends the tree with `force: false`; `replace` atomically installs a new tree or clears it with `[]`. Replacing unfinished work requires `force: true`. |
|
||||
| `update_task` | `{ tasks: [...] }` | Atomically patches multiple tasks. Use `null` for unchanged text/status, `parentId: -1` for unchanged or `0` to move to root, and `position: -1` to keep the current order. |
|
||||
| `rm_task` | `{ ids: [...], reason }` | Removes only mistaken, duplicate, or canceled 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. |
|
||||
| `get_task` | `{ ids: [...] }` | Returns complete task details, including parent/child IDs and timing for tasks that have started. |
|
||||
|
||||
Example append call:
|
||||
|
||||
@@ -68,18 +71,27 @@ Example append call:
|
||||
"name": "Implement authentication",
|
||||
"description": "Add session validation and route protection.",
|
||||
"status": "in-progress",
|
||||
"parentId": null,
|
||||
"children": [
|
||||
{
|
||||
"name": "Add auth middleware",
|
||||
"description": "Validate the session before protected handlers run."
|
||||
"description": "Validate the session before protected handlers run.",
|
||||
"status": "pending",
|
||||
"parentId": null,
|
||||
"children": []
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
],
|
||||
"force": false
|
||||
}
|
||||
```
|
||||
|
||||
Descriptions are optional, but useful scope, constraints, and done criteria improve recovery through `get_task`. IDs are not reused by append/remove operations; `replace` deliberately rebuilds IDs from `1`. Replace forbids `parentId`, so new hierarchy must use nested `children`. Multiple tasks may be `in-progress`. A parent can be marked `done` only when all descendants are also `done`.
|
||||
Task input keys are explicit so strict tool-schema bridges do not invent values for omitted properties. Use `description: null` when no detail is needed, `status: "pending"` for the default state, `parentId: null` for root tasks and nested children, and `children: []` for leaves. `prepareArguments` still expands legacy calls that omit these fields.
|
||||
|
||||
IDs are not reused by append/remove operations; `replace` deliberately rebuilds IDs from `1`. Replace forbids non-null `parentId`, so new hierarchy must use nested `children`. Multiple tasks may be `in-progress`. A parent can be marked `done` only when all descendants are also `done`.
|
||||
|
||||
A task starts timing the first time it enters `in-progress`. Leaving that state records a stop and pauses the timer; entering it again resumes with a new active segment while preserving the cumulative duration. Repeated updates with the same status, as well as name, description, hierarchy, and position changes, do not reset timing. For started tasks, `get_task` includes the first `startedAt` timestamp, a `stoppedAt` timestamp while paused, and cumulative `durationMs`. Timestamps use ISO 8601.
|
||||
|
||||
## Pi Widget
|
||||
|
||||
@@ -87,23 +99,23 @@ The widget appears above Pi's editor while tasks exist. Run `/tasks` to hide or
|
||||
|
||||
```text
|
||||
Tasks 2/6 · !1 ━━━━━────────
|
||||
◉ 1. Build auth
|
||||
├─ ! 3. Resolve token issue
|
||||
◉ 1. Build auth · 12:08
|
||||
├─ ! 3. Resolve token issue · 03:41
|
||||
└─ ○ 4. Add middleware
|
||||
✓ 2. Define schema
|
||||
✓ 2. Define schema · 04:31
|
||||
```
|
||||
|
||||
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 use a separate `task_count` token so Herdr can truncate long task names responsively while keeping the remaining count visible, for example `#7 Implement auth · +2`.
|
||||
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 use a separate `task_count` token so Herdr can truncate long task names responsively while keeping the remaining count visible, for example `#7 Implement auth · +2`. The `task_progress` token reports aggregate completion as `done/total` while tasks remain unfinished and is omitted once all tasks are complete.
|
||||
|
||||
Add `$task` and `$task_count` to the same Herdr agent sidebar row in `~/.config/herdr/config.toml`:
|
||||
Add `$task`, `$task_count`, and `$task_progress` to the Herdr agent sidebar in `~/.config/herdr/config.toml`:
|
||||
|
||||
```toml
|
||||
[ui.sidebar.agents]
|
||||
rows = [["state_icon", "workspace", "tab"], ["agent", "$ask", "$ask_count"], ["$task", "$task_count"]]
|
||||
rows = [["state_icon", "workspace", "tab"], ["$ask", "$ask_count"], ["$task_progress", "$task", "$task_count"]]
|
||||
```
|
||||
|
||||
Apply the change to a running Herdr server:
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "pi-todo-herdr",
|
||||
"version": "0.2.0",
|
||||
"version": "0.2.5",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "pi-todo-herdr",
|
||||
"version": "0.2.0",
|
||||
"version": "0.2.5",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@earendil-works/pi-ai": "^0.81.1",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "pi-todo-herdr",
|
||||
"version": "0.2.0",
|
||||
"version": "0.2.5",
|
||||
"description": "Hierarchical task tools for Pi with a live widget and Herdr sidebar integration.",
|
||||
"type": "module",
|
||||
"main": "./index.ts",
|
||||
|
||||
+5
-2
@@ -1,10 +1,11 @@
|
||||
import net from "node:net";
|
||||
import type { Task } from "./types.ts";
|
||||
import { orderedTreeRows } from "./ui.ts";
|
||||
import { orderedTreeRows, snapshotStats } from "./ui.ts";
|
||||
|
||||
const SOURCE = "pi-todo-herdr";
|
||||
const TASK_TOKEN = "task";
|
||||
const COUNT_TOKEN = "task_count";
|
||||
const PROGRESS_TOKEN = "task_progress";
|
||||
let reportSeq = Date.now() * 1000;
|
||||
|
||||
function nextSeq(): number {
|
||||
@@ -27,6 +28,7 @@ function hasDescendantWithStatus(task: Task, status: Task["status"], byId: Map<n
|
||||
|
||||
export function currentTaskTokens(tasks: Task[]): Record<string, string | null> {
|
||||
const byId = new Map(tasks.map((task) => [task.id, task]));
|
||||
const stats = snapshotStats(tasks);
|
||||
const ordered = orderedTreeRows(tasks).map(({ task }) => task);
|
||||
const inProgress = ordered.filter(
|
||||
(task) => task.status === "in-progress" && !hasDescendantWithStatus(task, "in-progress", byId),
|
||||
@@ -38,6 +40,7 @@ export function currentTaskTokens(tasks: Task[]): Record<string, string | null>
|
||||
return {
|
||||
[TASK_TOKEN]: candidates.length > 0 ? `#${candidates[0]!.id} ${candidates[0]!.name}` : null,
|
||||
[COUNT_TOKEN]: candidates.length > 1 ? `+${candidates.length - 1}` : null,
|
||||
[PROGRESS_TOKEN]: stats.total > 0 && stats.done < stats.total ? `${stats.done}/${stats.total}` : null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -97,5 +100,5 @@ export function reportCurrentTask(tasks: Task[]): Promise<void> {
|
||||
}
|
||||
|
||||
export function clearCurrentTask(): Promise<void> {
|
||||
return reportTokens({ [TASK_TOKEN]: null, [COUNT_TOKEN]: null });
|
||||
return reportTokens({ [TASK_TOKEN]: null, [COUNT_TOKEN]: null, [PROGRESS_TOKEN]: null });
|
||||
}
|
||||
|
||||
+2
-2
@@ -6,8 +6,8 @@ export const COMPACTION_REMINDER =
|
||||
"Active tasks exist. Call list_task now; treat its result as authoritative.";
|
||||
|
||||
export const TASK_PROMPT_GUIDELINES = [
|
||||
"For large, multi-stage work, call set_tasks before exploration as the only first-batch tool. Use append by default and replace only for unrelated finished or explicitly abandoned work. Start one task in-progress; skip smaller work.",
|
||||
"Use update_task when tracked work starts, blocks, or finishes; reconcile the tree before the final response.",
|
||||
"For work with 3+ distinct steps, explore enough to understand the scope, then call set_tasks before implementation; otherwise skip tracking. Start one task in-progress.",
|
||||
"Use update_task when tasks start, block, or finish; keep statuses accurate before the final response.",
|
||||
];
|
||||
|
||||
export function formatCompactTaskTree(tasks: Task[]): string {
|
||||
|
||||
+222
-31
@@ -6,12 +6,14 @@ import type {
|
||||
TaskSnapshot,
|
||||
TaskStats,
|
||||
TaskStatus,
|
||||
TaskTimingSnapshot,
|
||||
TaskToolDetails,
|
||||
} from "./types.ts";
|
||||
import { TaskValidationError } from "./types.ts";
|
||||
|
||||
const MUTATION_TOOLS = new Set(["add_task", "set_tasks", "update_task", "rm_task"]);
|
||||
const VALID_STATUSES = new Set<TaskStatus>(["pending", "in-progress", "blocked", "done"]);
|
||||
const MAX_DATE_MS = 8_640_000_000_000_000;
|
||||
|
||||
interface TaskRecord {
|
||||
id: number;
|
||||
@@ -19,6 +21,10 @@ interface TaskRecord {
|
||||
description?: string;
|
||||
status: TaskStatus;
|
||||
parentId: number | null;
|
||||
startedAt?: number;
|
||||
activeStartedAt?: number;
|
||||
stoppedAt?: number;
|
||||
accumulatedMs: number;
|
||||
}
|
||||
|
||||
function cloneRecords(records: TaskRecord[]): TaskRecord[] {
|
||||
@@ -33,13 +39,74 @@ function childIds(records: TaskRecord[], parentId: number | null): number[] {
|
||||
return records.filter((task) => task.parentId === parentId).map((task) => task.id);
|
||||
}
|
||||
|
||||
function publicTasks(records: TaskRecord[]): Task[] {
|
||||
function snapshotTasks(records: TaskRecord[]): Task[] {
|
||||
return records.map((task) => ({
|
||||
...task,
|
||||
id: task.id,
|
||||
name: task.name,
|
||||
...(task.description !== undefined ? { description: task.description } : {}),
|
||||
status: task.status,
|
||||
parentId: task.parentId,
|
||||
childrenIds: childIds(records, task.id),
|
||||
}));
|
||||
}
|
||||
|
||||
function durationMs(task: TaskRecord, now: number): number {
|
||||
const activeMs = task.activeStartedAt === undefined ? 0 : Math.max(0, now - task.activeStartedAt);
|
||||
return task.accumulatedMs + activeMs;
|
||||
}
|
||||
|
||||
function publicTasks(records: TaskRecord[], now: number): Task[] {
|
||||
const byId = indexRecords(records);
|
||||
return snapshotTasks(records).map((task) => {
|
||||
const record = byId.get(task.id);
|
||||
if (!record || record.startedAt === undefined) return task;
|
||||
return {
|
||||
...task,
|
||||
startedAt: new Date(record.startedAt).toISOString(),
|
||||
...(record.stoppedAt !== undefined ? { stoppedAt: new Date(record.stoppedAt).toISOString() } : {}),
|
||||
durationMs: durationMs(record, now),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function timingSnapshots(records: TaskRecord[]): TaskTimingSnapshot[] {
|
||||
const timings: TaskTimingSnapshot[] = [];
|
||||
for (const task of records) {
|
||||
if (task.startedAt === undefined) continue;
|
||||
timings.push({
|
||||
taskId: task.id,
|
||||
startedAt: task.startedAt,
|
||||
...(task.activeStartedAt !== undefined ? { activeStartedAt: task.activeStartedAt } : {}),
|
||||
...(task.stoppedAt !== undefined ? { stoppedAt: task.stoppedAt } : {}),
|
||||
accumulatedMs: task.accumulatedMs,
|
||||
});
|
||||
}
|
||||
return timings;
|
||||
}
|
||||
|
||||
function startTimer(task: TaskRecord, now: number): void {
|
||||
const startedAt = Math.max(now, task.stoppedAt ?? task.startedAt ?? 0);
|
||||
task.startedAt ??= startedAt;
|
||||
task.activeStartedAt = startedAt;
|
||||
task.stoppedAt = undefined;
|
||||
}
|
||||
|
||||
function stopTimer(task: TaskRecord, now: number): void {
|
||||
if (task.activeStartedAt === undefined) return;
|
||||
const stoppedAt = Math.max(now, task.activeStartedAt);
|
||||
task.accumulatedMs += stoppedAt - task.activeStartedAt;
|
||||
task.activeStartedAt = undefined;
|
||||
task.stoppedAt = stoppedAt;
|
||||
}
|
||||
|
||||
function isNonNegativeSafeInteger(value: unknown): value is number {
|
||||
return Number.isSafeInteger(value) && (value as number) >= 0;
|
||||
}
|
||||
|
||||
function isValidTimestamp(value: unknown): value is number {
|
||||
return isNonNegativeSafeInteger(value) && value <= MAX_DATE_MS;
|
||||
}
|
||||
|
||||
function collectDescendantIds(records: TaskRecord[], rootId: number): number[] {
|
||||
const result: number[] = [];
|
||||
const visit = (id: number) => {
|
||||
@@ -67,6 +134,38 @@ function validateGraph(records: TaskRecord[]): string[] {
|
||||
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 (!isNonNegativeSafeInteger(task.accumulatedMs)) {
|
||||
errors.push(`task ${task.id}: accumulatedMs must be a non-negative safe integer`);
|
||||
}
|
||||
if (task.startedAt === undefined) {
|
||||
if (task.activeStartedAt !== undefined || task.stoppedAt !== undefined || task.accumulatedMs !== 0) {
|
||||
errors.push(`task ${task.id}: timer fields require startedAt`);
|
||||
}
|
||||
if (task.status === "in-progress") errors.push(`task ${task.id}: in-progress task is missing timer data`);
|
||||
} else {
|
||||
if (!isValidTimestamp(task.startedAt)) {
|
||||
errors.push(`task ${task.id}: startedAt must be a valid non-negative timestamp`);
|
||||
}
|
||||
if (task.activeStartedAt !== undefined && !isValidTimestamp(task.activeStartedAt)) {
|
||||
errors.push(`task ${task.id}: activeStartedAt must be a valid non-negative timestamp`);
|
||||
}
|
||||
if (task.stoppedAt !== undefined && !isValidTimestamp(task.stoppedAt)) {
|
||||
errors.push(`task ${task.id}: stoppedAt must be a valid non-negative timestamp`);
|
||||
}
|
||||
if (task.activeStartedAt !== undefined && task.activeStartedAt < task.startedAt) {
|
||||
errors.push(`task ${task.id}: activeStartedAt must not precede startedAt`);
|
||||
}
|
||||
if (task.stoppedAt !== undefined && task.stoppedAt < task.startedAt) {
|
||||
errors.push(`task ${task.id}: stoppedAt must not precede startedAt`);
|
||||
}
|
||||
if (task.status === "in-progress") {
|
||||
if (task.activeStartedAt === undefined) errors.push(`task ${task.id}: active timer is missing its segment start`);
|
||||
if (task.stoppedAt !== undefined) errors.push(`task ${task.id}: active timer must not have stoppedAt`);
|
||||
} else {
|
||||
if (task.activeStartedAt !== undefined) errors.push(`task ${task.id}: inactive task has an active timer segment`);
|
||||
if (task.stoppedAt === undefined) errors.push(`task ${task.id}: paused timer is missing stoppedAt`);
|
||||
}
|
||||
}
|
||||
if (task.parentId !== null && !byId.has(task.parentId)) {
|
||||
errors.push(`task ${task.id}: parent ${task.parentId} does not exist`);
|
||||
}
|
||||
@@ -153,8 +252,17 @@ export class TaskState {
|
||||
private records: TaskRecord[] = [];
|
||||
private nextId = 1;
|
||||
|
||||
constructor(private readonly now: () => number = Date.now) {}
|
||||
|
||||
private currentTime(): number {
|
||||
const now = Math.floor(this.now());
|
||||
if (!isValidTimestamp(now)) throw new Error("task clock must return a valid non-negative timestamp");
|
||||
return now;
|
||||
}
|
||||
|
||||
list(): Task[] {
|
||||
return publicTasks(this.records);
|
||||
const now = this.records.some((task) => task.startedAt !== undefined) ? this.currentTime() : 0;
|
||||
return publicTasks(this.records, now);
|
||||
}
|
||||
|
||||
get(ids: number[]): Task[] {
|
||||
@@ -163,7 +271,12 @@ export class TaskState {
|
||||
}
|
||||
|
||||
snapshot(): TaskSnapshot {
|
||||
return { tasks: this.list(), nextId: this.nextId };
|
||||
const timings = timingSnapshots(this.records);
|
||||
return {
|
||||
tasks: snapshotTasks(this.records),
|
||||
nextId: this.nextId,
|
||||
...(timings.length > 0 ? { timings } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
stats(): TaskStats {
|
||||
@@ -183,6 +296,7 @@ export class TaskState {
|
||||
|
||||
const working = cloneRecords(this.records);
|
||||
const existingIds = new Set(working.map((task) => task.id));
|
||||
const now = this.currentTime();
|
||||
let nextId = this.nextId;
|
||||
const addedIds: number[] = [];
|
||||
|
||||
@@ -193,17 +307,23 @@ export class TaskState {
|
||||
}
|
||||
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 (draft.description !== undefined && (typeof draft.description !== "string" || !draft.description.trim())) {
|
||||
if (
|
||||
draft.description !== undefined &&
|
||||
draft.description !== null &&
|
||||
(typeof draft.description !== "string" || !draft.description.trim())
|
||||
) {
|
||||
errors.push(`${path}.description must not be empty`);
|
||||
}
|
||||
if (draft.status !== undefined && !VALID_STATUSES.has(draft.status)) {
|
||||
if (draft.status !== undefined && draft.status !== null && !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 (nested && draft.parentId !== undefined && draft.parentId !== null) {
|
||||
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 (!nested && draft.parentId !== undefined && draft.parentId !== null) {
|
||||
if (!Number.isInteger(draft.parentId) || !existingIds.has(draft.parentId)) {
|
||||
errors.push(`${path}.parentId ${draft.parentId} does not exist`);
|
||||
} else {
|
||||
@@ -212,16 +332,20 @@ export class TaskState {
|
||||
}
|
||||
|
||||
const id = nextId++;
|
||||
const status = draft.status ?? "pending";
|
||||
addedIds.push(id);
|
||||
working.push({
|
||||
id,
|
||||
name: typeof draft.name === "string" ? draft.name.trim() : "",
|
||||
...(typeof draft.description === "string" ? { description: draft.description.trim() } : {}),
|
||||
status: draft.status ?? "pending",
|
||||
status,
|
||||
parentId,
|
||||
accumulatedMs: 0,
|
||||
...(status === "in-progress" ? { startedAt: now, activeStartedAt: now } : {}),
|
||||
});
|
||||
|
||||
for (const [index, child] of (draft.children ?? []).entries()) {
|
||||
const children = Array.isArray(draft.children) ? draft.children : [];
|
||||
for (const [index, child] of children.entries()) {
|
||||
append(child, id, `${path}.children[${index}]`, true);
|
||||
}
|
||||
};
|
||||
@@ -237,7 +361,7 @@ export class TaskState {
|
||||
|
||||
set(mode: "append" | "replace", drafts: AddTaskDraft[], force?: boolean): Task[] {
|
||||
if (mode === "append") {
|
||||
if (force !== undefined) throw new TaskValidationError(["force is allowed only in replace mode"]);
|
||||
if (force === true) throw new TaskValidationError(["force must be false in append mode"]);
|
||||
return this.add(drafts);
|
||||
}
|
||||
if (mode !== "replace") throw new TaskValidationError(["mode must be append or replace"]);
|
||||
@@ -248,11 +372,13 @@ export class TaskState {
|
||||
errors.push("replace requires force when unfinished tasks exist");
|
||||
}
|
||||
drafts.forEach((draft, index) => {
|
||||
if (draft?.parentId !== undefined) errors.push(`tasks[${index}].parentId is not allowed in replace mode`);
|
||||
if (draft?.parentId !== undefined && draft.parentId !== null) {
|
||||
errors.push(`tasks[${index}].parentId is not allowed in replace mode`);
|
||||
}
|
||||
});
|
||||
if (errors.length > 0) throw new TaskValidationError(errors);
|
||||
|
||||
const replacement = new TaskState();
|
||||
const replacement = new TaskState(this.now);
|
||||
const affected = drafts.length === 0 ? [] : replacement.add(drafts);
|
||||
this.restore(replacement.snapshot());
|
||||
return affected;
|
||||
@@ -268,6 +394,7 @@ export class TaskState {
|
||||
if (duplicateIds.length > 0) errors.push(`duplicate task patches: ${[...new Set(duplicateIds)].join(", ")}`);
|
||||
|
||||
const working = cloneRecords(this.records);
|
||||
const now = this.currentTime();
|
||||
for (const [index, patch] of patches.entries()) {
|
||||
const path = `tasks[${index}]`;
|
||||
const task = working.find((candidate) => candidate.id === patch.id);
|
||||
@@ -276,32 +403,49 @@ export class TaskState {
|
||||
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())) {
|
||||
const hasNameChange = patch.name !== undefined && patch.name !== null;
|
||||
const hasDescriptionChange = patch.description !== undefined && patch.description !== null;
|
||||
const hasStatusChange = patch.status !== undefined && patch.status !== null;
|
||||
const hasParentChange = patch.parentId !== undefined && patch.parentId !== -1;
|
||||
const hasPositionChange = patch.position !== undefined && patch.position !== null && patch.position !== -1;
|
||||
if (!hasNameChange && !hasDescriptionChange && !hasStatusChange && !hasParentChange && !hasPositionChange) {
|
||||
errors.push(`${path} must include at least one field to update`);
|
||||
}
|
||||
if (hasNameChange && (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())) {
|
||||
if (hasDescriptionChange && (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 (hasStatusChange && !VALID_STATUSES.has(patch.status as TaskStatus)) errors.push(`${path}.status is invalid`);
|
||||
if (
|
||||
hasParentChange &&
|
||||
patch.parentId !== null &&
|
||||
patch.parentId !== 0 &&
|
||||
(!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;
|
||||
if (patch.name !== undefined && patch.name !== null) task.name = patch.name.trim();
|
||||
if (patch.description !== undefined && patch.description !== null) task.description = patch.description.trim();
|
||||
if (patch.status !== undefined && patch.status !== null && patch.status !== task.status) {
|
||||
if (task.status === "in-progress" && patch.status !== "in-progress") stopTimer(task, now);
|
||||
if (task.status !== "in-progress" && patch.status === "in-progress") startTimer(task, now);
|
||||
task.status = patch.status;
|
||||
}
|
||||
if (
|
||||
patch.parentId !== undefined &&
|
||||
patch.parentId !== -1 &&
|
||||
(patch.parentId === null ||
|
||||
patch.parentId === 0 ||
|
||||
working.some((item) => item.id === patch.parentId))
|
||||
) {
|
||||
task.parentId = patch.parentId === 0 ? null : patch.parentId;
|
||||
moveToSiblingEnd(working, task.id);
|
||||
}
|
||||
if (patch.position !== undefined) {
|
||||
if (patch.position !== undefined && patch.position !== null && patch.position !== -1) {
|
||||
const positionError = insertAtSiblingPosition(working, task.id, patch.position);
|
||||
if (positionError) errors.push(`${path}.position: ${positionError}`);
|
||||
}
|
||||
@@ -325,7 +469,11 @@ export class TaskState {
|
||||
removedIds.add(id);
|
||||
for (const descendantId of collectDescendantIds(this.records, id)) removedIds.add(descendantId);
|
||||
}
|
||||
const removed = this.list().filter((task) => removedIds.has(task.id));
|
||||
const now = this.currentTime();
|
||||
for (const task of this.records) {
|
||||
if (removedIds.has(task.id) && task.status === "in-progress") stopTimer(task, now);
|
||||
}
|
||||
const removed = publicTasks(this.records, now).filter((task) => removedIds.has(task.id));
|
||||
this.records = this.records.filter((task) => !removedIds.has(task.id));
|
||||
return removed;
|
||||
}
|
||||
@@ -334,13 +482,56 @@ export class TaskState {
|
||||
if (!snapshot || !Array.isArray(snapshot.tasks) || !Number.isInteger(snapshot.nextId)) {
|
||||
throw new TaskValidationError(["invalid task snapshot"]);
|
||||
}
|
||||
if (snapshot.timings !== undefined && !Array.isArray(snapshot.timings)) {
|
||||
throw new TaskValidationError(["invalid task snapshot timings"]);
|
||||
}
|
||||
|
||||
const records = snapshot.tasks.map(({ id, name, description, status, parentId }) => ({
|
||||
id,
|
||||
name,
|
||||
description,
|
||||
status,
|
||||
parentId,
|
||||
accumulatedMs: 0,
|
||||
}));
|
||||
const byId = indexRecords(records);
|
||||
const timingErrors: string[] = [];
|
||||
const timedIds = new Set<number>();
|
||||
|
||||
for (const [index, timing] of (snapshot.timings ?? []).entries()) {
|
||||
const path = `timings[${index}]`;
|
||||
if (!timing || typeof timing !== "object") {
|
||||
timingErrors.push(`${path} must be an object`);
|
||||
continue;
|
||||
}
|
||||
if (!Number.isInteger(timing.taskId) || timing.taskId < 1) {
|
||||
timingErrors.push(`${path}.taskId must be a positive integer`);
|
||||
continue;
|
||||
}
|
||||
if (timedIds.has(timing.taskId)) {
|
||||
timingErrors.push(`${path}.taskId ${timing.taskId} is duplicated`);
|
||||
continue;
|
||||
}
|
||||
timedIds.add(timing.taskId);
|
||||
const task = byId.get(timing.taskId);
|
||||
if (!task) {
|
||||
timingErrors.push(`${path}.taskId ${timing.taskId} does not exist`);
|
||||
continue;
|
||||
}
|
||||
task.startedAt = timing.startedAt;
|
||||
task.activeStartedAt = timing.activeStartedAt;
|
||||
task.stoppedAt = timing.stoppedAt;
|
||||
task.accumulatedMs = timing.accumulatedMs;
|
||||
}
|
||||
|
||||
if (timingErrors.length > 0) throw new TaskValidationError(timingErrors);
|
||||
if (snapshot.timings === undefined && records.some((task) => task.status === "in-progress")) {
|
||||
const now = this.currentTime();
|
||||
for (const task of records) {
|
||||
if (task.status === "in-progress") startTimer(task, now);
|
||||
}
|
||||
}
|
||||
|
||||
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}`);
|
||||
|
||||
+157
-36
@@ -1,7 +1,7 @@
|
||||
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, type Static } from "typebox";
|
||||
import { formatCompactTaskTree, TASK_PROMPT_GUIDELINES } from "./prompt.ts";
|
||||
import type { TaskState } from "./state.ts";
|
||||
import type { AddTaskDraft, Task, TaskPatch, TaskToolDetails } from "./types.ts";
|
||||
@@ -9,28 +9,83 @@ import { TaskValidationError } from "./types.ts";
|
||||
import { renderToolResult } from "./ui.ts";
|
||||
|
||||
const TaskStatusSchema = StringEnum(["pending", "in-progress", "blocked", "done"] as const, {
|
||||
description: "Task status. Defaults to pending.",
|
||||
description: "Task status.",
|
||||
});
|
||||
|
||||
const SetTaskModeSchema = StringEnum(["append", "replace"] as const);
|
||||
const RemovalReasonSchema = StringEnum(["mistaken", "duplicate", "canceled"] as const);
|
||||
|
||||
/**
|
||||
* TypeBox's Type.Cyclic emits bare local refs ("AddTask") instead of URI
|
||||
* references ("#/$defs/AddTask"). Strict JSON Schema gateways (e.g. the
|
||||
* moonshot-flavored validator behind Console Go) reject those with a 400.
|
||||
* Rewrite every local $ref in place to the #/$defs/ form.
|
||||
*/
|
||||
function normalizeLocalRefs<T>(schema: T): T {
|
||||
if (Array.isArray(schema)) {
|
||||
for (const item of schema) normalizeLocalRefs(item);
|
||||
return schema;
|
||||
}
|
||||
if (schema && typeof schema === "object") {
|
||||
for (const [key, value] of Object.entries(schema)) {
|
||||
if (key === "$ref" && typeof value === "string" && !value.startsWith("#") && !value.includes("://")) {
|
||||
(schema as Record<string, unknown>)[key] = `#/$defs/${value}`;
|
||||
} else {
|
||||
normalizeLocalRefs(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
return schema;
|
||||
}
|
||||
|
||||
/**
|
||||
* TypeBox's Type.Cyclic nests $defs inside the referenced item schema, but
|
||||
* refs like "#/$defs/AddTask" resolve from the document root. Strict gateways
|
||||
* (moonshot-flavored) reject a root-style ref when the definition is nested.
|
||||
* Hoist every $defs entry to the schema root and drop the synthetic $id, which
|
||||
* would otherwise change fragment resolution inside the definition.
|
||||
*/
|
||||
function hoistDefsToRoot<T>(schema: T): T {
|
||||
const defs = new Map<string, unknown>();
|
||||
function walk(node: unknown): void {
|
||||
if (Array.isArray(node)) {
|
||||
for (const item of node) walk(item);
|
||||
return;
|
||||
}
|
||||
if (!node || typeof node !== "object") return;
|
||||
const obj = node as Record<string, unknown>;
|
||||
const nested = obj["$defs"];
|
||||
if (nested && typeof nested === "object") {
|
||||
for (const [name, def] of Object.entries(nested)) {
|
||||
if (!defs.has(name)) defs.set(name, def);
|
||||
}
|
||||
delete obj["$defs"];
|
||||
}
|
||||
for (const value of Object.values(obj)) walk(value);
|
||||
}
|
||||
walk(schema);
|
||||
for (const def of defs.values()) {
|
||||
if (def && typeof def === "object") {
|
||||
delete (def as Record<string, unknown>)["$id"];
|
||||
}
|
||||
}
|
||||
if (defs.size > 0) (schema as Record<string, unknown>)["$defs"] = Object.fromEntries(defs);
|
||||
return schema;
|
||||
}
|
||||
|
||||
const AddTaskSchema = Type.Cyclic(
|
||||
{
|
||||
AddTask: Type.Object(
|
||||
{
|
||||
name: Type.String({ minLength: 1 }),
|
||||
description: Type.Optional(
|
||||
Type.String({
|
||||
minLength: 1,
|
||||
description: "Detailed scope, constraints, and done criteria. Include when useful.",
|
||||
}),
|
||||
),
|
||||
status: Type.Optional(TaskStatusSchema),
|
||||
parentId: Type.Optional(
|
||||
Type.Integer({ minimum: 1, description: "Existing parent ID; append mode and top-level tasks only." }),
|
||||
),
|
||||
children: Type.Optional(Type.Array(Type.Ref("AddTask"))),
|
||||
description: Type.Union([Type.String({ minLength: 1 }), Type.Null()], {
|
||||
description: "Detailed scope, constraints, and done criteria; use null when no detail is needed.",
|
||||
}),
|
||||
status: TaskStatusSchema,
|
||||
parentId: Type.Union([Type.Integer({ minimum: 1 }), Type.Null()], {
|
||||
description: "Existing parent ID for a top-level append; use null for roots, nested children, and replace mode.",
|
||||
}),
|
||||
children: Type.Array(Type.Ref("AddTask")),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
),
|
||||
@@ -38,33 +93,43 @@ const AddTaskSchema = Type.Cyclic(
|
||||
"AddTask",
|
||||
);
|
||||
|
||||
export const SetTaskParams = Type.Object(
|
||||
{
|
||||
mode: SetTaskModeSchema,
|
||||
tasks: Type.Array(AddTaskSchema, {
|
||||
description: "Tasks to append or install. May be empty only in replace mode.",
|
||||
}),
|
||||
force: Type.Optional(
|
||||
Type.Boolean({ description: "Use only when the user explicitly abandons unfinished tasks." }),
|
||||
export const SetTaskParams = hoistDefsToRoot(
|
||||
normalizeLocalRefs(
|
||||
Type.Object(
|
||||
{
|
||||
mode: SetTaskModeSchema,
|
||||
tasks: Type.Array(AddTaskSchema, {
|
||||
description: "Tasks to append or install. May be empty only in replace mode.",
|
||||
}),
|
||||
force: Type.Boolean({
|
||||
description: "Set true only when the user explicitly abandons unfinished tasks; otherwise use false.",
|
||||
}),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
),
|
||||
},
|
||||
{ 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." }),
|
||||
),
|
||||
name: Type.Union([Type.String({ minLength: 1 }), Type.Null()], {
|
||||
description: "New name, or null to leave it unchanged.",
|
||||
}),
|
||||
description: Type.Union([Type.String({ minLength: 1 }), Type.Null()], {
|
||||
description: "New description, or null to leave it unchanged.",
|
||||
}),
|
||||
status: Type.Union([TaskStatusSchema, Type.Null()], {
|
||||
description: "New status, or null to leave it unchanged.",
|
||||
}),
|
||||
parentId: Type.Integer({
|
||||
minimum: -1,
|
||||
description: "New parent ID; use -1 to leave it unchanged and 0 to move to root.",
|
||||
}),
|
||||
position: Type.Integer({
|
||||
minimum: -1,
|
||||
description: "Zero-based position among final siblings, or -1 to leave it unchanged.",
|
||||
}),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
@@ -93,6 +158,60 @@ export const IdsParams = Type.Object({ ids: TaskIdsSchema }, { additionalPropert
|
||||
|
||||
export const ListTaskParams = Type.Object({}, { additionalProperties: false });
|
||||
|
||||
function prepareTaskDraft(value: unknown): unknown {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return value;
|
||||
const input = value as Record<string, unknown>;
|
||||
const children =
|
||||
input.children === undefined
|
||||
? []
|
||||
: Array.isArray(input.children)
|
||||
? input.children.map(prepareTaskDraft)
|
||||
: input.children;
|
||||
return {
|
||||
...input,
|
||||
description: input.description === undefined ? null : input.description,
|
||||
status: input.status === undefined ? "pending" : input.status,
|
||||
parentId: input.parentId === undefined ? null : input.parentId,
|
||||
children,
|
||||
};
|
||||
}
|
||||
|
||||
function prepareSetTaskArguments(value: unknown): Static<typeof SetTaskParams> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return value as Static<typeof SetTaskParams>;
|
||||
}
|
||||
const input = value as Record<string, unknown>;
|
||||
return {
|
||||
...input,
|
||||
tasks: Array.isArray(input.tasks) ? input.tasks.map(prepareTaskDraft) : input.tasks,
|
||||
force: input.force === undefined ? false : input.force,
|
||||
} as Static<typeof SetTaskParams>;
|
||||
}
|
||||
|
||||
function prepareTaskPatch(value: unknown): unknown {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return value;
|
||||
const input = value as Record<string, unknown>;
|
||||
return {
|
||||
...input,
|
||||
name: input.name === undefined ? null : input.name,
|
||||
description: input.description === undefined ? null : input.description,
|
||||
status: input.status === undefined ? null : input.status,
|
||||
parentId: input.parentId === undefined ? -1 : input.parentId === null ? 0 : input.parentId,
|
||||
position: input.position === undefined || input.position === null ? -1 : input.position,
|
||||
};
|
||||
}
|
||||
|
||||
function prepareUpdateTaskArguments(value: unknown): Static<typeof UpdateTaskParams> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return value as Static<typeof UpdateTaskParams>;
|
||||
}
|
||||
const input = value as Record<string, unknown>;
|
||||
return {
|
||||
...input,
|
||||
tasks: Array.isArray(input.tasks) ? input.tasks.map(prepareTaskPatch) : input.tasks,
|
||||
} as Static<typeof UpdateTaskParams>;
|
||||
}
|
||||
|
||||
interface SetTaskInput {
|
||||
mode: "append" | "replace";
|
||||
tasks: AddTaskDraft[];
|
||||
@@ -161,9 +280,10 @@ export function registerTaskTools(pi: ExtensionAPI, state: TaskState, onChange:
|
||||
name: "set_tasks",
|
||||
label: "Set Tasks",
|
||||
description:
|
||||
"Atomically append tasks or replace the task tree. Replace may clear with an empty array; force is required to discard unfinished tasks.",
|
||||
"Atomically append tasks or replace the task tree. Pass force false unless unfinished work is explicitly discarded.",
|
||||
promptGuidelines: [...TASK_PROMPT_GUIDELINES],
|
||||
parameters: SetTaskParams,
|
||||
prepareArguments: prepareSetTaskArguments,
|
||||
async execute(_toolCallId, params: SetTaskInput, _signal, _onUpdate, ctx) {
|
||||
try {
|
||||
const affected = state.set(params.mode, params.tasks, params.force);
|
||||
@@ -188,8 +308,9 @@ export function registerTaskTools(pi: ExtensionAPI, state: TaskState, onChange:
|
||||
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.",
|
||||
"Atomically patch tasks. Use null for unchanged text/status, parentId -1 for unchanged or 0 for root, and position -1 for unchanged.",
|
||||
parameters: UpdateTaskParams,
|
||||
prepareArguments: prepareUpdateTaskArguments,
|
||||
async execute(_toolCallId, params: UpdateTaskInput, _signal, _onUpdate, ctx) {
|
||||
try {
|
||||
const affected = state.update(params.tasks);
|
||||
|
||||
+19
-7
@@ -7,28 +7,40 @@ export interface Task {
|
||||
status: TaskStatus;
|
||||
parentId: number | null;
|
||||
childrenIds: number[];
|
||||
startedAt?: string;
|
||||
stoppedAt?: string;
|
||||
durationMs?: number;
|
||||
}
|
||||
|
||||
export interface TaskTimingSnapshot {
|
||||
taskId: number;
|
||||
startedAt: number;
|
||||
activeStartedAt?: number;
|
||||
stoppedAt?: number;
|
||||
accumulatedMs: number;
|
||||
}
|
||||
|
||||
export interface TaskSnapshot {
|
||||
tasks: Task[];
|
||||
nextId: number;
|
||||
timings?: TaskTimingSnapshot[];
|
||||
}
|
||||
|
||||
export interface AddTaskDraft {
|
||||
name: string;
|
||||
description?: string;
|
||||
status?: TaskStatus;
|
||||
parentId?: number;
|
||||
description?: string | null;
|
||||
status?: TaskStatus | null;
|
||||
parentId?: number | null;
|
||||
children?: AddTaskDraft[];
|
||||
}
|
||||
|
||||
export interface TaskPatch {
|
||||
id: number;
|
||||
name?: string;
|
||||
description?: string;
|
||||
status?: TaskStatus;
|
||||
name?: string | null;
|
||||
description?: string | null;
|
||||
status?: TaskStatus | null;
|
||||
parentId?: number | null;
|
||||
position?: number;
|
||||
position?: number | null;
|
||||
}
|
||||
|
||||
export interface TaskStats {
|
||||
|
||||
@@ -10,19 +10,52 @@ export const STATUS_ICONS = {
|
||||
done: "✓",
|
||||
} as const;
|
||||
|
||||
interface DescendantStats {
|
||||
done: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
interface TreeRow {
|
||||
task: Task;
|
||||
prefix: string;
|
||||
collapsed?: DescendantStats;
|
||||
}
|
||||
|
||||
export function orderedTreeRows(tasks: Task[]): TreeRow[] {
|
||||
function descendantStats(task: Task, byId: Map<number, Task>, memo: Map<number, DescendantStats>): DescendantStats {
|
||||
const cached = memo.get(task.id);
|
||||
if (cached) return cached;
|
||||
|
||||
let done = 0;
|
||||
let total = 0;
|
||||
for (const childId of task.childrenIds) {
|
||||
const child = byId.get(childId);
|
||||
if (!child) continue;
|
||||
|
||||
total += 1;
|
||||
if (child.status === "done") done += 1;
|
||||
const nested = descendantStats(child, byId, memo);
|
||||
done += nested.done;
|
||||
total += nested.total;
|
||||
}
|
||||
|
||||
const stats = { done, total };
|
||||
memo.set(task.id, stats);
|
||||
return stats;
|
||||
}
|
||||
|
||||
function buildTreeRows(tasks: Task[], collapseCompleted: boolean): TreeRow[] {
|
||||
const byId = new Map(tasks.map((task) => [task.id, task]));
|
||||
const memo = new Map<number, DescendantStats>();
|
||||
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 stats = collapseCompleted ? descendantStats(task, byId, memo) : undefined;
|
||||
const collapsed = stats && stats.total > 0 && stats.done === stats.total ? stats : undefined;
|
||||
rows.push({ task, prefix: `${ancestors}${connector}`, ...(collapsed ? { collapsed } : {}) });
|
||||
if (collapsed) return;
|
||||
|
||||
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];
|
||||
@@ -35,6 +68,10 @@ export function orderedTreeRows(tasks: Task[]): TreeRow[] {
|
||||
return rows;
|
||||
}
|
||||
|
||||
export function orderedTreeRows(tasks: Task[]): TreeRow[] {
|
||||
return buildTreeRows(tasks, false);
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -49,7 +86,23 @@ function styleStatusIcon(task: Task, theme: Theme): string {
|
||||
return theme.fg("dim", icon);
|
||||
}
|
||||
|
||||
export function renderTaskLine(task: Task, prefix: string, theme: Theme, width: number): string {
|
||||
export function formatDuration(durationMs: number): string {
|
||||
const safeMs = Number.isFinite(durationMs) && durationMs >= 0 ? durationMs : 0;
|
||||
const totalSeconds = Math.floor(safeMs / 1000);
|
||||
const hours = Math.floor(totalSeconds / 3600);
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
const minuteClock = [minutes, seconds].map((part) => String(part).padStart(2, "0")).join(":");
|
||||
return hours === 0 ? minuteClock : `${String(hours).padStart(2, "0")}:${minuteClock}`;
|
||||
}
|
||||
|
||||
function renderTaskLineWithSuffix(
|
||||
task: Task,
|
||||
prefix: string,
|
||||
theme: Theme,
|
||||
width: number,
|
||||
suffix?: string,
|
||||
): string {
|
||||
const line =
|
||||
theme.fg("dim", prefix) +
|
||||
styleStatusIcon(task, theme) +
|
||||
@@ -57,13 +110,36 @@ export function renderTaskLine(task: Task, prefix: string, theme: Theme, width:
|
||||
theme.fg("accent", `${task.id}.`) +
|
||||
" " +
|
||||
styleTaskName(task, theme);
|
||||
return truncateToWidth(line, Math.max(1, width));
|
||||
const trailing = [
|
||||
suffix === undefined ? undefined : theme.fg("dim", ` · ${suffix}`),
|
||||
task.durationMs === undefined ? undefined : theme.fg("dim", ` · ${formatDuration(task.durationMs)}`),
|
||||
]
|
||||
.filter((part): part is string => part !== undefined)
|
||||
.join("");
|
||||
if (trailing.length === 0) return truncateToWidth(line, Math.max(1, width));
|
||||
|
||||
const trailingWidth = visibleWidth(trailing);
|
||||
const minimumTaskWidth = suffix === undefined ? 6 : 12;
|
||||
if (width <= trailingWidth + minimumTaskWidth) return truncateToWidth(line, Math.max(1, width));
|
||||
const taskWidth = Math.max(1, width - trailingWidth);
|
||||
return truncateToWidth(line, taskWidth) + trailing;
|
||||
}
|
||||
|
||||
export function renderTaskLine(task: Task, prefix: string, theme: Theme, width: number): string {
|
||||
return renderTaskLineWithSuffix(task, prefix, theme, 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 renderWidgetTree(tasks: Task[], theme: Theme, width: number, padding = " "): string[] {
|
||||
return buildTreeRows(tasks, true).map(({ task, prefix, collapsed }) => {
|
||||
const summary = collapsed === undefined ? undefined : `${collapsed.done}/${collapsed.total} done`;
|
||||
return renderTaskLineWithSuffix(task, `${padding}${prefix}`, theme, width, summary);
|
||||
});
|
||||
}
|
||||
|
||||
export function progressLabel(stats: TaskStats): string {
|
||||
const blocked = stats.blocked > 0 ? ` · !${stats.blocked}` : "";
|
||||
return ` Tasks ${stats.done}/${stats.total}${blocked}`;
|
||||
@@ -182,7 +258,7 @@ export function renderToolResult(
|
||||
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 === "list") return [...lines, ...renderTree(details.affected, theme, width)];
|
||||
if (details.action === "get") return [...lines, ...renderTaskDetails(details.affected, theme, width)];
|
||||
return [...lines, ...renderFlatTasks(details.affected, theme, width)];
|
||||
});
|
||||
|
||||
+26
-9
@@ -1,25 +1,42 @@
|
||||
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
||||
import type { TaskState } from "./state.ts";
|
||||
import { renderProgressHeader, renderTree } from "./ui.ts";
|
||||
import { renderProgressHeader, renderWidgetTree } from "./ui.ts";
|
||||
|
||||
const WIDGET_ID = "pi-todo-herdr";
|
||||
const TIMER_REFRESH_MS = 1000;
|
||||
|
||||
export function clearTaskWidget(ctx: ExtensionContext): void {
|
||||
ctx.ui.setWidget(WIDGET_ID, undefined);
|
||||
}
|
||||
|
||||
export function updateTaskWidget(state: TaskState, ctx: ExtensionContext, visible: boolean): void {
|
||||
export function updateTaskWidget(
|
||||
state: TaskState,
|
||||
ctx: ExtensionContext,
|
||||
visible: boolean,
|
||||
refreshMs = TIMER_REFRESH_MS,
|
||||
): 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 {},
|
||||
}));
|
||||
const hasActiveTimer = tasks.some((task) => task.status === "in-progress" && task.startedAt !== undefined);
|
||||
ctx.ui.setWidget(WIDGET_ID, (tui, theme) => {
|
||||
const timer = hasActiveTimer
|
||||
? setInterval(() => tui.requestRender(), Math.max(1, refreshMs))
|
||||
: undefined;
|
||||
timer?.unref();
|
||||
|
||||
return {
|
||||
render(width: number): string[] {
|
||||
const currentTasks = state.list();
|
||||
return [renderProgressHeader(state.stats(), theme, width), ...renderWidgetTree(currentTasks, theme, width)];
|
||||
},
|
||||
invalidate(): void {},
|
||||
dispose(): void {
|
||||
if (timer !== undefined) clearInterval(timer);
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -10,14 +10,17 @@ import {
|
||||
} from "../src/prompt.ts";
|
||||
import { TaskState } from "../src/state.ts";
|
||||
|
||||
test("keeps task guidance short while requiring planning before large work", () => {
|
||||
test("keeps task guidance short with planning after exploration", () => {
|
||||
assert.equal(TASK_PROMPT_GUIDELINES.length, 2);
|
||||
const prompt = TASK_PROMPT_GUIDELINES.join("\n");
|
||||
assert.match(prompt, /large, multi-stage work/);
|
||||
assert.match(prompt, /set_tasks before exploration as the only first-batch tool/);
|
||||
assert.match(prompt, /Start one task in-progress; skip smaller work/);
|
||||
assert.match(prompt, /Use update_task when tracked work starts, blocks, or finishes/);
|
||||
assert.ok(prompt.split(/\s+/).length < 60);
|
||||
assert.match(prompt, /work with 3\+ distinct steps/);
|
||||
assert.match(prompt, /explore enough to understand the scope/);
|
||||
assert.match(prompt, /then call set_tasks before implementation/);
|
||||
assert.match(prompt, /otherwise skip tracking/);
|
||||
assert.match(prompt, /Start one task in-progress/);
|
||||
assert.match(prompt, /update_task when tasks start, block, or finish/);
|
||||
assert.match(prompt, /keep statuses accurate before the final response/);
|
||||
assert.ok(prompt.split(/\s+/).length < 40);
|
||||
});
|
||||
|
||||
test("uses a compact one-shot recovery reminder without embedding task data", () => {
|
||||
|
||||
+168
-8
@@ -27,7 +27,7 @@ test("adds a nested batch with generated preorder IDs and derived relations", ()
|
||||
assert.equal(state.snapshot().nextId, 4);
|
||||
});
|
||||
|
||||
test("rejects invalid nested parentId atomically", () => {
|
||||
test("rejects invalid nested parentId atomically while accepting null sentinels", () => {
|
||||
const state = new TaskState();
|
||||
state.add([{ name: "Existing", description: "Existing" }]);
|
||||
const before = state.snapshot();
|
||||
@@ -44,6 +44,16 @@ test("rejects invalid nested parentId atomically", () => {
|
||||
TaskValidationError,
|
||||
);
|
||||
assert.deepEqual(state.snapshot(), before);
|
||||
|
||||
const added = state.add([
|
||||
{
|
||||
name: "Nullable parent",
|
||||
parentId: null,
|
||||
children: [{ name: "Implicit child", parentId: null }],
|
||||
},
|
||||
]);
|
||||
assert.equal(added[0]?.parentId, null);
|
||||
assert.equal(added[1]?.parentId, added[0]?.id);
|
||||
});
|
||||
|
||||
test("enforces done descendants against the final atomic update state", () => {
|
||||
@@ -68,6 +78,144 @@ test("enforces done descendants against the final atomic update state", () => {
|
||||
assert.deepEqual(state.list().map((task) => task.status), ["in-progress", "in-progress"]);
|
||||
});
|
||||
|
||||
test("preserves and accumulates timing across repeated updates and status changes", () => {
|
||||
const startedAt = Date.UTC(2026, 0, 1);
|
||||
let now = startedAt;
|
||||
const state = new TaskState(() => now);
|
||||
|
||||
const [started] = state.add([{ name: "Timed", status: "in-progress" }]);
|
||||
assert.equal(started?.startedAt, new Date(startedAt).toISOString());
|
||||
assert.equal(started?.stoppedAt, undefined);
|
||||
assert.equal(started?.durationMs, 0);
|
||||
|
||||
now += 5_000;
|
||||
const [repeated] = state.update([{ id: 1, name: "Renamed", status: "in-progress" }]);
|
||||
assert.equal(repeated?.startedAt, started?.startedAt);
|
||||
assert.equal(repeated?.stoppedAt, undefined);
|
||||
assert.equal(repeated?.durationMs, 5_000);
|
||||
|
||||
now += 2_000;
|
||||
const [paused] = state.update([{ id: 1, status: "blocked" }]);
|
||||
assert.equal(paused?.startedAt, started?.startedAt);
|
||||
assert.equal(paused?.stoppedAt, new Date(now).toISOString());
|
||||
assert.equal(paused?.durationMs, 7_000);
|
||||
|
||||
now += 10_000;
|
||||
assert.equal(state.get([1])[0]?.durationMs, 7_000);
|
||||
const [resumed] = state.update([{ id: 1, status: "in-progress" }]);
|
||||
assert.equal(resumed?.startedAt, started?.startedAt);
|
||||
assert.equal(resumed?.stoppedAt, undefined);
|
||||
assert.equal(resumed?.durationMs, 7_000);
|
||||
|
||||
now += 3_000;
|
||||
const [done] = state.update([{ id: 1, status: "done" }]);
|
||||
assert.equal(done?.startedAt, started?.startedAt);
|
||||
assert.equal(done?.stoppedAt, new Date(now).toISOString());
|
||||
assert.equal(done?.durationMs, 10_000);
|
||||
assert.deepEqual(state.snapshot().timings, [
|
||||
{ taskId: 1, startedAt, stoppedAt: now, accumulatedMs: 10_000 },
|
||||
]);
|
||||
});
|
||||
|
||||
test("does not stop a timer when an atomic update fails", () => {
|
||||
const startedAt = Date.UTC(2026, 0, 1);
|
||||
let now = startedAt;
|
||||
const state = new TaskState(() => now);
|
||||
state.add([
|
||||
{
|
||||
name: "Parent",
|
||||
status: "in-progress",
|
||||
children: [{ name: "Unfinished child" }],
|
||||
},
|
||||
]);
|
||||
|
||||
now += 5_000;
|
||||
assert.throws(() => state.update([{ id: 1, status: "done" }]), /unfinished descendants/);
|
||||
const afterFailure = state.get([1])[0];
|
||||
assert.equal(afterFailure?.stoppedAt, undefined);
|
||||
assert.equal(afterFailure?.durationMs, 5_000);
|
||||
|
||||
now += 2_000;
|
||||
assert.equal(state.get([1])[0]?.durationMs, 7_000);
|
||||
});
|
||||
|
||||
test("restores timers and starts legacy in-progress snapshots at load time", () => {
|
||||
const startedAt = Date.UTC(2026, 0, 1);
|
||||
let now = startedAt;
|
||||
const source = new TaskState(() => now);
|
||||
source.add([{ name: "Persisted", status: "in-progress" }]);
|
||||
now += 2_500;
|
||||
source.update([{ id: 1, status: "pending" }]);
|
||||
|
||||
now += 20_000;
|
||||
const restored = new TaskState(() => now);
|
||||
restored.restore(source.snapshot());
|
||||
assert.equal(restored.get([1])[0]?.startedAt, new Date(startedAt).toISOString());
|
||||
assert.equal(restored.get([1])[0]?.stoppedAt, new Date(startedAt + 2_500).toISOString());
|
||||
assert.equal(restored.get([1])[0]?.durationMs, 2_500);
|
||||
|
||||
const legacy = new TaskState(() => now);
|
||||
legacy.restore({
|
||||
tasks: [
|
||||
{
|
||||
id: 1,
|
||||
name: "Legacy active",
|
||||
status: "in-progress",
|
||||
parentId: null,
|
||||
childrenIds: [],
|
||||
},
|
||||
],
|
||||
nextId: 2,
|
||||
});
|
||||
assert.equal(legacy.get([1])[0]?.startedAt, new Date(now).toISOString());
|
||||
assert.equal(legacy.get([1])[0]?.durationMs, 0);
|
||||
assert.equal(legacy.snapshot().timings?.[0]?.activeStartedAt, now);
|
||||
});
|
||||
|
||||
test("rejects out-of-range persisted timestamps without replacing state", () => {
|
||||
const state = new TaskState(() => 0);
|
||||
state.add([{ name: "Keep" }]);
|
||||
const before = state.snapshot();
|
||||
|
||||
assert.throws(
|
||||
() =>
|
||||
state.restore({
|
||||
tasks: [
|
||||
{
|
||||
id: 1,
|
||||
name: "Corrupt timer",
|
||||
status: "in-progress",
|
||||
parentId: null,
|
||||
childrenIds: [],
|
||||
},
|
||||
],
|
||||
nextId: 2,
|
||||
timings: [
|
||||
{
|
||||
taskId: 1,
|
||||
startedAt: Number.MAX_SAFE_INTEGER,
|
||||
activeStartedAt: Number.MAX_SAFE_INTEGER,
|
||||
accumulatedMs: 0,
|
||||
},
|
||||
],
|
||||
}),
|
||||
/startedAt must be a valid non-negative timestamp/,
|
||||
);
|
||||
assert.deepEqual(state.snapshot(), before);
|
||||
});
|
||||
|
||||
test("finalizes an active timer in removed task details", () => {
|
||||
let now = Date.UTC(2026, 0, 1);
|
||||
const state = new TaskState(() => now);
|
||||
state.add([{ name: "Canceled", status: "in-progress" }]);
|
||||
now += 4_000;
|
||||
|
||||
const [removed] = state.remove([1]);
|
||||
assert.equal(removed?.stoppedAt, new Date(now).toISOString());
|
||||
assert.equal(removed?.durationMs, 4_000);
|
||||
assert.deepEqual(state.list(), []);
|
||||
});
|
||||
|
||||
test("rejects cycles and duplicate patches without partial changes", () => {
|
||||
const state = new TaskState();
|
||||
state.add([{ name: "Root", description: "Root", children: [{ name: "Child", description: "Child" }] }]);
|
||||
@@ -170,23 +318,31 @@ test("loads the latest valid mutation snapshot from a session branch", () => {
|
||||
assert.deepEqual(state.list().map((task) => task.name), ["First", "Second"]);
|
||||
});
|
||||
|
||||
test("supports optional descriptions and rejects invalid provided values", () => {
|
||||
test("supports nullable descriptions and reports invalid children as validation errors", () => {
|
||||
const state = new TaskState();
|
||||
assert.throws(() => state.add([{ name: "Bad\nname", description: "Valid" }]), /single line/);
|
||||
assert.throws(() => state.add([{ name: "Blank description", description: " " }]), /must not be empty/);
|
||||
assert.throws(
|
||||
() => state.add([{ name: "Invalid children", children: "not-an-array" } as never]),
|
||||
/children must be an array/,
|
||||
);
|
||||
state.add([
|
||||
{ name: "No description" },
|
||||
{ name: "Null description", description: null },
|
||||
{ name: "Detailed", description: "Line one\nLine two" },
|
||||
]);
|
||||
assert.equal(state.list()[0]?.description, undefined);
|
||||
assert.equal(state.list()[1]?.description, "Line one\nLine two");
|
||||
assert.equal(state.list()[1]?.description, undefined);
|
||||
assert.equal(state.list()[2]?.description, "Line one\nLine two");
|
||||
});
|
||||
|
||||
test("set append rejects force and still requires at least one task", () => {
|
||||
test("set append accepts false force and rejects true force", () => {
|
||||
const state = new TaskState();
|
||||
assert.throws(() => state.set("append", [], undefined), /tasks must not be empty/);
|
||||
assert.throws(() => state.set("append", [{ name: "Task" }], false), /force is allowed only in replace mode/);
|
||||
assert.deepEqual(state.snapshot(), { tasks: [], nextId: 1 });
|
||||
assert.throws(() => state.set("append", [], false), /tasks must not be empty/);
|
||||
state.set("append", [{ name: "Task" }], false);
|
||||
const before = state.snapshot();
|
||||
assert.throws(() => state.set("append", [{ name: "Other" }], true), /force must be false in append mode/);
|
||||
assert.deepEqual(state.snapshot(), before);
|
||||
});
|
||||
|
||||
test("set replace is atomic, guarded, and resets IDs", () => {
|
||||
@@ -201,7 +357,11 @@ test("set replace is atomic, guarded, and resets IDs", () => {
|
||||
);
|
||||
assert.deepEqual(state.snapshot(), before);
|
||||
|
||||
const replaced = state.set("replace", [{ name: "New", children: [{ name: "Child" }] }], true);
|
||||
const replaced = state.set(
|
||||
"replace",
|
||||
[{ name: "New", parentId: null, children: [{ name: "Child", parentId: null }] }],
|
||||
true,
|
||||
);
|
||||
assert.deepEqual(replaced.map((task) => task.id), [1, 2]);
|
||||
assert.deepEqual(state.list().map((task) => task.name), ["New", "Child"]);
|
||||
assert.equal(state.snapshot().nextId, 3);
|
||||
|
||||
+139
-1
@@ -1,5 +1,6 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { validateToolArguments } from "../node_modules/@earendil-works/pi-ai/dist/utils/validation.js";
|
||||
import { TaskState } from "../src/state.ts";
|
||||
import { registerTaskTools } from "../src/tools.ts";
|
||||
|
||||
@@ -18,7 +19,9 @@ function registeredTools(state = new TaskState()) {
|
||||
}
|
||||
|
||||
async function execute(tool: any, params: unknown) {
|
||||
return tool.execute("call", params, undefined, undefined, {});
|
||||
const prepared = tool.prepareArguments?.(params) ?? params;
|
||||
const validated = validateToolArguments(tool, { name: tool.name, arguments: prepared } as never);
|
||||
return tool.execute("call", validated, undefined, undefined, {});
|
||||
}
|
||||
|
||||
test("registers five focused tools without the old add_task alias", () => {
|
||||
@@ -31,6 +34,102 @@ test("registers five focused tools without the old add_task alias", () => {
|
||||
assert.deepEqual(Object.keys(tools.get("get_task").parameters.properties), ["ids"]);
|
||||
});
|
||||
|
||||
test("set_tasks schema hoists $defs to the root with #/$defs/ refs", () => {
|
||||
const { tools } = registeredTools();
|
||||
const params = tools.get("set_tasks").parameters;
|
||||
const itemSchema = params.properties.tasks.items;
|
||||
// $defs must live at the document root: root-style refs resolve from there,
|
||||
// and moonshot-flavored validators reject nested definitions (Console Go).
|
||||
assert.ok(params.$defs.AddTask, "$defs hoisted to the parameter schema root");
|
||||
assert.deepEqual(params.required, ["mode", "tasks", "force"]);
|
||||
assert.deepEqual(params.$defs.AddTask.required, ["name", "description", "status", "parentId", "children"]);
|
||||
assert.equal(itemSchema.$defs, undefined, "no $defs nested inside tasks items");
|
||||
assert.equal(itemSchema.$ref, "#/$defs/AddTask");
|
||||
assert.equal(params.$defs.AddTask.properties.children.items.$ref, "#/$defs/AddTask");
|
||||
assert.equal(params.$defs.AddTask.$id, undefined, "synthetic $id stripped");
|
||||
const serialized = JSON.stringify(params);
|
||||
assert.ok(!/"\$ref":"(?!\#)/.test(serialized), "no bare $ref values remain");
|
||||
});
|
||||
|
||||
test("strict task schemas normalize legacy optional arguments", () => {
|
||||
const { tools } = registeredTools();
|
||||
const setTool = tools.get("set_tasks");
|
||||
const updateTool = tools.get("update_task");
|
||||
|
||||
assert.deepEqual(updateTool.parameters.properties.tasks.items.required, [
|
||||
"id",
|
||||
"name",
|
||||
"description",
|
||||
"status",
|
||||
"parentId",
|
||||
"position",
|
||||
]);
|
||||
assert.deepEqual(setTool.prepareArguments({
|
||||
mode: "append",
|
||||
tasks: [{ name: "Parent", children: [{ name: "Child" }] }],
|
||||
}), {
|
||||
mode: "append",
|
||||
tasks: [
|
||||
{
|
||||
name: "Parent",
|
||||
description: null,
|
||||
status: "pending",
|
||||
parentId: null,
|
||||
children: [
|
||||
{
|
||||
name: "Child",
|
||||
description: null,
|
||||
status: "pending",
|
||||
parentId: null,
|
||||
children: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
force: false,
|
||||
});
|
||||
assert.deepEqual(updateTool.prepareArguments({ tasks: [{ id: 1, status: "done" }] }), {
|
||||
tasks: [
|
||||
{
|
||||
id: 1,
|
||||
name: null,
|
||||
description: null,
|
||||
status: "done",
|
||||
parentId: -1,
|
||||
position: -1,
|
||||
},
|
||||
],
|
||||
});
|
||||
assert.equal(updateTool.prepareArguments({ tasks: [{ id: 1, parentId: null }] }).tasks[0].parentId, 0);
|
||||
|
||||
const validated = validateToolArguments(updateTool, {
|
||||
name: "update_task",
|
||||
arguments: updateTool.prepareArguments({ tasks: [{ id: 1, status: "done" }] }),
|
||||
} as never);
|
||||
assert.equal(validated.tasks[0].parentId, -1);
|
||||
assert.equal(validated.tasks[0].position, -1);
|
||||
});
|
||||
|
||||
test("validated update sentinels preserve order and map legacy null parent to root", async () => {
|
||||
const harness = registeredTools();
|
||||
await execute(harness.tools.get("set_tasks"), {
|
||||
mode: "append",
|
||||
tasks: [
|
||||
{ name: "Parent", children: [{ name: "Child" }] },
|
||||
{ name: "Sibling" },
|
||||
],
|
||||
});
|
||||
|
||||
await execute(harness.tools.get("update_task"), { tasks: [{ id: 3, status: "blocked" }] });
|
||||
assert.deepEqual(
|
||||
harness.state.list().filter((task) => task.parentId === null).map((task) => task.id),
|
||||
[1, 3],
|
||||
);
|
||||
|
||||
await execute(harness.tools.get("update_task"), { tasks: [{ id: 2, parentId: null }] });
|
||||
assert.equal(harness.state.get([2])[0]?.parentId, null);
|
||||
});
|
||||
|
||||
test("set_tasks appends optional-description tasks and persists a complete snapshot", async () => {
|
||||
const harness = registeredTools();
|
||||
const result = await execute(harness.tools.get("set_tasks"), {
|
||||
@@ -113,3 +212,42 @@ test("list is compact and get returns available details for multiple IDs", async
|
||||
assert.equal(parsed[1].description, undefined);
|
||||
assert.equal(parsed[1].parentId, 1);
|
||||
});
|
||||
|
||||
test("get_task exposes cumulative timing while list output stays compact", async () => {
|
||||
const startedAt = Date.UTC(2026, 0, 1);
|
||||
let now = startedAt;
|
||||
const harness = registeredTools(new TaskState(() => now));
|
||||
const added = await execute(harness.tools.get("set_tasks"), {
|
||||
mode: "append",
|
||||
tasks: [{ name: "Timed", status: "in-progress" }],
|
||||
});
|
||||
assert.equal(added.content[0].text, "Added 1 task: 1. 0/1 done.");
|
||||
assert.equal(added.details.snapshot.timings[0].activeStartedAt, startedAt);
|
||||
|
||||
now += 65_432;
|
||||
const list = await execute(harness.tools.get("list_task"), {});
|
||||
assert.equal(list.content[0].text, "1 [in-progress] Timed");
|
||||
|
||||
const active = JSON.parse((await execute(harness.tools.get("get_task"), { ids: [1] })).content[0].text);
|
||||
assert.deepEqual(active[0], {
|
||||
id: 1,
|
||||
name: "Timed",
|
||||
status: "in-progress",
|
||||
parentId: null,
|
||||
childrenIds: [],
|
||||
startedAt: new Date(startedAt).toISOString(),
|
||||
durationMs: 65_432,
|
||||
});
|
||||
|
||||
now += 568;
|
||||
const updated = await execute(harness.tools.get("update_task"), {
|
||||
tasks: [{ id: 1, status: "blocked" }],
|
||||
});
|
||||
assert.equal(updated.content[0].text, "Updated 1 task: 1. 0/1 done.");
|
||||
|
||||
now += 10_000;
|
||||
const paused = JSON.parse((await execute(harness.tools.get("get_task"), { ids: [1] })).content[0].text);
|
||||
assert.equal(paused[0].startedAt, new Date(startedAt).toISOString());
|
||||
assert.equal(paused[0].stoppedAt, new Date(startedAt + 66_000).toISOString());
|
||||
assert.equal(paused[0].durationMs, 66_000);
|
||||
});
|
||||
|
||||
+137
-5
@@ -4,10 +4,12 @@ import { visibleWidth } from "@earendil-works/pi-tui";
|
||||
import { currentTaskLabel, currentTaskTokens } from "../src/herdr.ts";
|
||||
import { TaskState } from "../src/state.ts";
|
||||
import {
|
||||
formatDuration,
|
||||
renderProgressHeader,
|
||||
renderTaskLine,
|
||||
renderToolResult,
|
||||
renderTree,
|
||||
renderWidgetTree,
|
||||
snapshotStats,
|
||||
} from "../src/ui.ts";
|
||||
import { updateTaskWidget } from "../src/widget.ts";
|
||||
@@ -23,7 +25,7 @@ function stripAnsi(text: string): string {
|
||||
}
|
||||
|
||||
function fixture(): TaskState {
|
||||
const state = new TaskState();
|
||||
const state = new TaskState(() => 0);
|
||||
state.add([
|
||||
{
|
||||
name: "Build auth",
|
||||
@@ -58,7 +60,7 @@ test("renders an adaptive progress bar and stays within width", () => {
|
||||
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",
|
||||
" ◉ 1. Build auth · 00:00",
|
||||
" ├─ ! 2. Resolve token issue",
|
||||
" │ └─ ○ 3. Inspect provider response",
|
||||
" └─ ○ 4. Add middleware",
|
||||
@@ -66,6 +68,71 @@ test("renders lightweight connectors without a detached root continuation", () =
|
||||
]);
|
||||
});
|
||||
|
||||
test("collapses completed widget subtrees while keeping list trees expanded", () => {
|
||||
const state = new TaskState(() => 0);
|
||||
state.add([
|
||||
{
|
||||
name: "Release",
|
||||
children: [
|
||||
{
|
||||
name: "Completed branch",
|
||||
children: [
|
||||
{ name: "Done one", status: "done" },
|
||||
{ name: "Done two", status: "done" },
|
||||
],
|
||||
},
|
||||
{ name: "Open branch", children: [{ name: "Open leaf" }] },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Pending parent",
|
||||
children: [{ name: "Finished child", status: "done" }],
|
||||
},
|
||||
]);
|
||||
|
||||
const widgetLines = renderWidgetTree(state.list(), plainTheme as never, 80).map(stripAnsi);
|
||||
assert.deepEqual(widgetLines, [
|
||||
" ○ 1. Release",
|
||||
" ├─ ○ 2. Completed branch · 2/2 done",
|
||||
" └─ ○ 5. Open branch",
|
||||
" └─ ○ 6. Open leaf",
|
||||
" ○ 7. Pending parent · 1/1 done",
|
||||
]);
|
||||
|
||||
const fullTree = renderTree(state.list(), plainTheme as never, 80).map(stripAnsi);
|
||||
assert.ok(fullTree.some((line) => line.includes("Done one")));
|
||||
assert.ok(fullTree.some((line) => line.includes("Finished child")));
|
||||
|
||||
state.update([{ id: 3, status: "pending" }]);
|
||||
const reopened = renderWidgetTree(state.list(), plainTheme as never, 80).map(stripAnsi);
|
||||
assert.ok(reopened.some((line) => line.includes("Done one")));
|
||||
assert.ok(reopened.some((line) => line.includes("Done two")));
|
||||
});
|
||||
|
||||
test("hides a completed widget summary before squeezing the task name", () => {
|
||||
const state = new TaskState(() => 0);
|
||||
state.add([{ name: "A long parent task name", children: [{ name: "Done", status: "done" }] }]);
|
||||
|
||||
const line = renderWidgetTree(state.list(), plainTheme as never, 20)[0] ?? "";
|
||||
assert.ok(visibleWidth(line) <= 20);
|
||||
assert.doesNotMatch(stripAnsi(line), /done$/);
|
||||
});
|
||||
|
||||
test("formats compact timers and adds hours only when needed", () => {
|
||||
assert.equal(formatDuration(0), "00:00");
|
||||
assert.equal(formatDuration(65_432), "01:05");
|
||||
assert.equal(formatDuration(3_599_999), "59:59");
|
||||
assert.equal(formatDuration(3_600_000), "01:00:00");
|
||||
|
||||
let now = 0;
|
||||
const state = new TaskState(() => now);
|
||||
state.add([{ name: "Timed", status: "in-progress" }]);
|
||||
now = 65_432;
|
||||
const line = stripAnsi(renderTaskLine(state.get([1])[0]!, " ", plainTheme as never, 24));
|
||||
assert.match(line, / · 01:05$/);
|
||||
assert.ok(visibleWidth(line) <= 24);
|
||||
});
|
||||
|
||||
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));
|
||||
@@ -106,7 +173,11 @@ test("selects the most specific in-progress task and compresses parallel leaves"
|
||||
{ name: "Parallel", description: "Parallel", status: "in-progress" },
|
||||
]);
|
||||
assert.equal(currentTaskLabel(state.list()), "#2 Child +1");
|
||||
assert.deepEqual(currentTaskTokens(state.list()), { task: "#2 Child", task_count: "+1" });
|
||||
assert.deepEqual(currentTaskTokens(state.list()), {
|
||||
task: "#2 Child",
|
||||
task_count: "+1",
|
||||
task_progress: "0/3",
|
||||
});
|
||||
});
|
||||
|
||||
test("uses blocked tasks only when nothing is in progress", () => {
|
||||
@@ -116,10 +187,26 @@ test("uses blocked tasks only when nothing is in progress", () => {
|
||||
{ name: "Blocked two", description: "Two", status: "blocked" },
|
||||
]);
|
||||
assert.equal(currentTaskLabel(state.list()), "#1 Blocked one +1");
|
||||
assert.deepEqual(currentTaskTokens(state.list()), { task: "#1 Blocked one", task_count: "+1" });
|
||||
assert.deepEqual(currentTaskTokens(state.list()), {
|
||||
task: "#1 Blocked one",
|
||||
task_count: "+1",
|
||||
task_progress: "0/2",
|
||||
});
|
||||
state.update([{ id: 1, status: "done" }, { id: 2, status: "done" }]);
|
||||
assert.equal(currentTaskLabel(state.list()), null);
|
||||
assert.deepEqual(currentTaskTokens(state.list()), { task: null, task_count: null });
|
||||
assert.deepEqual(currentTaskTokens(state.list()), {
|
||||
task: null,
|
||||
task_count: null,
|
||||
task_progress: null,
|
||||
});
|
||||
});
|
||||
|
||||
test("clears sidebar progress for an empty task tree", () => {
|
||||
assert.deepEqual(currentTaskTokens([]), {
|
||||
task: null,
|
||||
task_count: null,
|
||||
task_progress: null,
|
||||
});
|
||||
});
|
||||
|
||||
test("hides an empty widget and renders a populated one", () => {
|
||||
@@ -147,6 +234,51 @@ test("hides an empty widget and renders a populated one", () => {
|
||||
assert.ok(lines.every((line) => visibleWidth(line) <= 30));
|
||||
});
|
||||
|
||||
test("live widgets request rerenders and dispose their interval", async () => {
|
||||
let now = 0;
|
||||
const state = new TaskState(() => now);
|
||||
state.add([{ name: "Timed", status: "in-progress" }]);
|
||||
|
||||
let widget: unknown;
|
||||
const ctx = {
|
||||
ui: {
|
||||
setWidget: (_id: string, content: unknown) => {
|
||||
widget = content;
|
||||
},
|
||||
},
|
||||
};
|
||||
updateTaskWidget(state, ctx as never, true, 5);
|
||||
|
||||
let renderRequests = 0;
|
||||
const component = (
|
||||
widget as unknown as (
|
||||
tui: { requestRender(): void },
|
||||
theme: unknown,
|
||||
) => { render(width: number): string[]; dispose?(): void }
|
||||
)(
|
||||
{
|
||||
requestRender() {
|
||||
renderRequests += 1;
|
||||
},
|
||||
},
|
||||
plainTheme,
|
||||
);
|
||||
|
||||
try {
|
||||
assert.match(stripAnsi(component.render(40)[1] ?? ""), / · 00:00$/);
|
||||
now = 65_000;
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
assert.ok(renderRequests > 0);
|
||||
assert.match(stripAnsi(component.render(40)[1] ?? ""), / · 01:05$/);
|
||||
} finally {
|
||||
component.dispose?.();
|
||||
}
|
||||
|
||||
const requestsAfterDispose = renderRequests;
|
||||
await new Promise((resolve) => setTimeout(resolve, 15));
|
||||
assert.equal(renderRequests, requestsAfterDispose);
|
||||
});
|
||||
|
||||
test("tool results collapse summaries and expand themed details", () => {
|
||||
const state = fixture();
|
||||
const details = { action: "list" as const, snapshot: state.snapshot(), affected: state.list() };
|
||||
|
||||
Reference in New Issue
Block a user