Compare commits

...
10 Commits
Author SHA1 Message Date
jb 6826854b66 Add forked reference in readme 2026-08-26 11:59:45 -04:00
lesetong 6c6b9ec01b Hide completed progress in Herdr 2026-08-24 10:27:00 +08:00
lesetong 98f3904189 Collapse completed todo subtrees in widget 2026-08-24 10:23:43 +08:00
lesetong cd74f91e2a feat: show todo progress in Herdr sidebar 2026-08-24 10:10:31 +08:00
lesetong ff497e23b3 fix: plan tasks after initial exploration 2026-08-24 09:58:33 +08:00
lesetong 7783e7166a Update package.json 2026-08-11 13:36:25 +08:00
lesetong 8af1c53c83 Make task schema fields explicit with null sentinels 2026-08-11 13:36:09 +08:00
lesetong 28d7e66d8a fix: hoist $defs to the schema root for set_tasks
Refs like "#/$defs/AddTask" resolve from the document root, but
Type.Cyclic nested the $defs inside tasks.items, so the moonshot-flavored
gateway reported "$defs not found for reference". Hoist all $defs entries
to the parameter schema root and strip the synthetic $id that would change
fragment resolution. Update the regression test to pin the layout.

Bump version to 0.2.3.
2026-08-10 00:48:36 +08:00
lesetong 7971cf1a09 fix: emit #/$defs/ refs in set_tasks schema
TypeBox Type.Cyclic generates bare local refs ("AddTask") which strict
moonshot-flavored JSON Schema gateways (Console Go upstream) reject with
HTTP 400. Rewrite all local $ref values to the #/$defs/ form after
building the schema, and add a regression test asserting the ref format.

Bump version to 0.2.2.
2026-08-10 00:45:54 +08:00
lesetong 2ff0a314ed feat: add task duration tracking 2026-07-28 17:39:45 +08:00
14 changed files with 993 additions and 128 deletions
+25 -13
View File
@@ -1,3 +1,5 @@
## Forked from [leset0ng/pi-todo-herdr](https://github.com/leset0ng/pi-todo-herdr)
# 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. 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 - 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 - Atomic batch mutations: every requested change succeeds or none of it is applied
- Four states: `pending`, `in-progress`, `blocked`, and `done` - 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 - 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 - 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 - Compact Pi widget with hierarchy, status colors, and an adaptive progress bar
@@ -46,17 +49,17 @@ pi remove npm:pi-todo-herdr
## Usage ## 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. 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 | | 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`. | | `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. `parentId: null` moves to the root; `position` is a zero-based sibling index. | | `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. | | `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. | | `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: Example append call:
@@ -68,18 +71,27 @@ Example append call:
"name": "Implement authentication", "name": "Implement authentication",
"description": "Add session validation and route protection.", "description": "Add session validation and route protection.",
"status": "in-progress", "status": "in-progress",
"parentId": null,
"children": [ "children": [
{ {
"name": "Add auth middleware", "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 ## Pi Widget
@@ -87,23 +99,23 @@ The widget appears above Pi's editor while tasks exist. Run `/tasks` to hide or
```text ```text
Tasks 2/6 · !1 ━━━━━──────── Tasks 2/6 · !1 ━━━━━────────
◉ 1. Build auth ◉ 1. Build auth · 12:08
├─ ! 3. Resolve token issue ├─ ! 3. Resolve token issue · 03:41
└─ ○ 4. Add middleware └─ ○ 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. 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 ## 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 ```toml
[ui.sidebar.agents] [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: Apply the change to a running Herdr server:
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "pi-todo-herdr", "name": "pi-todo-herdr",
"version": "0.2.0", "version": "0.2.5",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "pi-todo-herdr", "name": "pi-todo-herdr",
"version": "0.2.0", "version": "0.2.5",
"license": "MIT", "license": "MIT",
"devDependencies": { "devDependencies": {
"@earendil-works/pi-ai": "^0.81.1", "@earendil-works/pi-ai": "^0.81.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "pi-todo-herdr", "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.", "description": "Hierarchical task tools for Pi with a live widget and Herdr sidebar integration.",
"type": "module", "type": "module",
"main": "./index.ts", "main": "./index.ts",
+5 -2
View File
@@ -1,10 +1,11 @@
import net from "node:net"; import net from "node:net";
import type { Task } from "./types.ts"; import type { Task } from "./types.ts";
import { orderedTreeRows } from "./ui.ts"; import { orderedTreeRows, snapshotStats } from "./ui.ts";
const SOURCE = "pi-todo-herdr"; const SOURCE = "pi-todo-herdr";
const TASK_TOKEN = "task"; const TASK_TOKEN = "task";
const COUNT_TOKEN = "task_count"; const COUNT_TOKEN = "task_count";
const PROGRESS_TOKEN = "task_progress";
let reportSeq = Date.now() * 1000; let reportSeq = Date.now() * 1000;
function nextSeq(): number { 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> { export function currentTaskTokens(tasks: Task[]): Record<string, string | null> {
const byId = new Map(tasks.map((task) => [task.id, task])); const byId = new Map(tasks.map((task) => [task.id, task]));
const stats = snapshotStats(tasks);
const ordered = orderedTreeRows(tasks).map(({ task }) => task); const ordered = orderedTreeRows(tasks).map(({ task }) => task);
const inProgress = ordered.filter( const inProgress = ordered.filter(
(task) => task.status === "in-progress" && !hasDescendantWithStatus(task, "in-progress", byId), (task) => task.status === "in-progress" && !hasDescendantWithStatus(task, "in-progress", byId),
@@ -38,6 +40,7 @@ export function currentTaskTokens(tasks: Task[]): Record<string, string | null>
return { return {
[TASK_TOKEN]: candidates.length > 0 ? `#${candidates[0]!.id} ${candidates[0]!.name}` : null, [TASK_TOKEN]: candidates.length > 0 ? `#${candidates[0]!.id} ${candidates[0]!.name}` : null,
[COUNT_TOKEN]: candidates.length > 1 ? `+${candidates.length - 1}` : 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> { 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
View File
@@ -6,8 +6,8 @@ export const COMPACTION_REMINDER =
"Active tasks exist. Call list_task now; treat its result as authoritative."; "Active tasks exist. Call list_task now; treat its result as authoritative.";
export const TASK_PROMPT_GUIDELINES = [ 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.", "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 tracked work starts, blocks, or finishes; reconcile the tree before the final response.", "Use update_task when tasks start, block, or finish; keep statuses accurate before the final response.",
]; ];
export function formatCompactTaskTree(tasks: Task[]): string { export function formatCompactTaskTree(tasks: Task[]): string {
+221 -30
View File
@@ -6,12 +6,14 @@ import type {
TaskSnapshot, TaskSnapshot,
TaskStats, TaskStats,
TaskStatus, TaskStatus,
TaskTimingSnapshot,
TaskToolDetails, TaskToolDetails,
} from "./types.ts"; } from "./types.ts";
import { TaskValidationError } from "./types.ts"; import { TaskValidationError } from "./types.ts";
const MUTATION_TOOLS = new Set(["add_task", "set_tasks", "update_task", "rm_task"]); 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 VALID_STATUSES = new Set<TaskStatus>(["pending", "in-progress", "blocked", "done"]);
const MAX_DATE_MS = 8_640_000_000_000_000;
interface TaskRecord { interface TaskRecord {
id: number; id: number;
@@ -19,6 +21,10 @@ interface TaskRecord {
description?: string; description?: string;
status: TaskStatus; status: TaskStatus;
parentId: number | null; parentId: number | null;
startedAt?: number;
activeStartedAt?: number;
stoppedAt?: number;
accumulatedMs: number;
} }
function cloneRecords(records: TaskRecord[]): TaskRecord[] { 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); 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) => ({ 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), 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[] { function collectDescendantIds(records: TaskRecord[], rootId: number): number[] {
const result: number[] = []; const result: number[] = [];
const visit = (id: number) => { const visit = (id: number) => {
@@ -67,6 +134,38 @@ function validateGraph(records: TaskRecord[]): string[] {
errors.push(`task ${task.id}: description must not be empty`); 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 (!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)) { if (task.parentId !== null && !byId.has(task.parentId)) {
errors.push(`task ${task.id}: parent ${task.parentId} does not exist`); errors.push(`task ${task.id}: parent ${task.parentId} does not exist`);
} }
@@ -153,8 +252,17 @@ export class TaskState {
private records: TaskRecord[] = []; private records: TaskRecord[] = [];
private nextId = 1; 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[] { 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[] { get(ids: number[]): Task[] {
@@ -163,7 +271,12 @@ export class TaskState {
} }
snapshot(): TaskSnapshot { 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 { stats(): TaskStats {
@@ -183,6 +296,7 @@ export class TaskState {
const working = cloneRecords(this.records); const working = cloneRecords(this.records);
const existingIds = new Set(working.map((task) => task.id)); const existingIds = new Set(working.map((task) => task.id));
const now = this.currentTime();
let nextId = this.nextId; let nextId = this.nextId;
const addedIds: number[] = []; 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" || !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.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`); 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`); 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`); if (draft.children !== undefined && !Array.isArray(draft.children)) errors.push(`${path}.children must be an array`);
let parentId = implicitParentId; 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)) { if (!Number.isInteger(draft.parentId) || !existingIds.has(draft.parentId)) {
errors.push(`${path}.parentId ${draft.parentId} does not exist`); errors.push(`${path}.parentId ${draft.parentId} does not exist`);
} else { } else {
@@ -212,16 +332,20 @@ export class TaskState {
} }
const id = nextId++; const id = nextId++;
const status = draft.status ?? "pending";
addedIds.push(id); addedIds.push(id);
working.push({ working.push({
id, id,
name: typeof draft.name === "string" ? draft.name.trim() : "", name: typeof draft.name === "string" ? draft.name.trim() : "",
...(typeof draft.description === "string" ? { description: draft.description.trim() } : {}), ...(typeof draft.description === "string" ? { description: draft.description.trim() } : {}),
status: draft.status ?? "pending", status,
parentId, 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); append(child, id, `${path}.children[${index}]`, true);
} }
}; };
@@ -237,7 +361,7 @@ export class TaskState {
set(mode: "append" | "replace", drafts: AddTaskDraft[], force?: boolean): Task[] { set(mode: "append" | "replace", drafts: AddTaskDraft[], force?: boolean): Task[] {
if (mode === "append") { 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); return this.add(drafts);
} }
if (mode !== "replace") throw new TaskValidationError(["mode must be append or replace"]); 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"); errors.push("replace requires force when unfinished tasks exist");
} }
drafts.forEach((draft, index) => { 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); 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); const affected = drafts.length === 0 ? [] : replacement.add(drafts);
this.restore(replacement.snapshot()); this.restore(replacement.snapshot());
return affected; return affected;
@@ -268,6 +394,7 @@ export class TaskState {
if (duplicateIds.length > 0) errors.push(`duplicate task patches: ${[...new Set(duplicateIds)].join(", ")}`); if (duplicateIds.length > 0) errors.push(`duplicate task patches: ${[...new Set(duplicateIds)].join(", ")}`);
const working = cloneRecords(this.records); const working = cloneRecords(this.records);
const now = this.currentTime();
for (const [index, patch] of patches.entries()) { for (const [index, patch] of patches.entries()) {
const path = `tasks[${index}]`; const path = `tasks[${index}]`;
const task = working.find((candidate) => candidate.id === patch.id); const task = working.find((candidate) => candidate.id === patch.id);
@@ -276,32 +403,49 @@ export class TaskState {
continue; continue;
} }
const hasChange = ["name", "description", "status", "parentId", "position"].some((key) => const hasNameChange = patch.name !== undefined && patch.name !== null;
Object.prototype.hasOwnProperty.call(patch, key), const hasDescriptionChange = patch.description !== undefined && patch.description !== null;
); const hasStatusChange = patch.status !== undefined && patch.status !== null;
if (!hasChange) errors.push(`${path} must include at least one field to update`); const hasParentChange = patch.parentId !== undefined && patch.parentId !== -1;
if (patch.name !== undefined && (typeof patch.name !== "string" || !patch.name.trim())) { 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`); 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 (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`); errors.push(`${path}.description must not be empty`);
} }
if (patch.status !== undefined && !VALID_STATUSES.has(patch.status)) errors.push(`${path}.status is invalid`); if (hasStatusChange && !VALID_STATUSES.has(patch.status as TaskStatus)) errors.push(`${path}.status is invalid`);
if (patch.parentId !== undefined && patch.parentId !== null) { if (
if (!Number.isInteger(patch.parentId) || !working.some((candidate) => candidate.id === patch.parentId)) { 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`); errors.push(`${path}.parentId ${patch.parentId} does not exist`);
} }
}
if (patch.name !== undefined) task.name = patch.name.trim(); if (patch.name !== undefined && patch.name !== null) task.name = patch.name.trim();
if (patch.description !== undefined) task.description = patch.description.trim(); if (patch.description !== undefined && patch.description !== null) task.description = patch.description.trim();
if (patch.status !== undefined) task.status = patch.status; if (patch.status !== undefined && patch.status !== null && patch.status !== task.status) {
if (patch.parentId !== undefined && (patch.parentId === null || working.some((item) => item.id === patch.parentId))) { if (task.status === "in-progress" && patch.status !== "in-progress") stopTimer(task, now);
task.parentId = patch.parentId; 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); 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); const positionError = insertAtSiblingPosition(working, task.id, patch.position);
if (positionError) errors.push(`${path}.position: ${positionError}`); if (positionError) errors.push(`${path}.position: ${positionError}`);
} }
@@ -325,7 +469,11 @@ export class TaskState {
removedIds.add(id); removedIds.add(id);
for (const descendantId of collectDescendantIds(this.records, id)) removedIds.add(descendantId); 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)); this.records = this.records.filter((task) => !removedIds.has(task.id));
return removed; return removed;
} }
@@ -334,13 +482,56 @@ export class TaskState {
if (!snapshot || !Array.isArray(snapshot.tasks) || !Number.isInteger(snapshot.nextId)) { if (!snapshot || !Array.isArray(snapshot.tasks) || !Number.isInteger(snapshot.nextId)) {
throw new TaskValidationError(["invalid task snapshot"]); 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 }) => ({ const records = snapshot.tasks.map(({ id, name, description, status, parentId }) => ({
id, id,
name, name,
description, description,
status, status,
parentId, 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 errors = validateGraph(records);
const maxId = records.reduce((max, task) => Math.max(max, task.id), 0); 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 (snapshot.nextId <= maxId) errors.push(`nextId must be greater than ${maxId}`);
+149 -28
View File
@@ -1,7 +1,7 @@
import { StringEnum } from "@earendil-works/pi-ai"; import { StringEnum } from "@earendil-works/pi-ai";
import type { ExtensionAPI, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent"; import type { ExtensionAPI, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
import { Text } from "@earendil-works/pi-tui"; 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 { formatCompactTaskTree, TASK_PROMPT_GUIDELINES } from "./prompt.ts";
import type { TaskState } from "./state.ts"; import type { TaskState } from "./state.ts";
import type { AddTaskDraft, Task, TaskPatch, TaskToolDetails } from "./types.ts"; import type { AddTaskDraft, Task, TaskPatch, TaskToolDetails } from "./types.ts";
@@ -9,28 +9,83 @@ import { TaskValidationError } from "./types.ts";
import { renderToolResult } from "./ui.ts"; import { renderToolResult } from "./ui.ts";
const TaskStatusSchema = StringEnum(["pending", "in-progress", "blocked", "done"] as const, { 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 SetTaskModeSchema = StringEnum(["append", "replace"] as const);
const RemovalReasonSchema = StringEnum(["mistaken", "duplicate", "canceled"] 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( const AddTaskSchema = Type.Cyclic(
{ {
AddTask: Type.Object( AddTask: Type.Object(
{ {
name: Type.String({ minLength: 1 }), name: Type.String({ minLength: 1 }),
description: Type.Optional( description: Type.Union([Type.String({ minLength: 1 }), Type.Null()], {
Type.String({ description: "Detailed scope, constraints, and done criteria; use null when no detail is needed.",
minLength: 1,
description: "Detailed scope, constraints, and done criteria. Include when useful.",
}), }),
), status: TaskStatusSchema,
status: Type.Optional(TaskStatusSchema), parentId: Type.Union([Type.Integer({ minimum: 1 }), Type.Null()], {
parentId: Type.Optional( description: "Existing parent ID for a top-level append; use null for roots, nested children, and replace mode.",
Type.Integer({ minimum: 1, description: "Existing parent ID; append mode and top-level tasks only." }), }),
), children: Type.Array(Type.Ref("AddTask")),
children: Type.Optional(Type.Array(Type.Ref("AddTask"))),
}, },
{ additionalProperties: false }, { additionalProperties: false },
), ),
@@ -38,33 +93,43 @@ const AddTaskSchema = Type.Cyclic(
"AddTask", "AddTask",
); );
export const SetTaskParams = Type.Object( export const SetTaskParams = hoistDefsToRoot(
normalizeLocalRefs(
Type.Object(
{ {
mode: SetTaskModeSchema, mode: SetTaskModeSchema,
tasks: Type.Array(AddTaskSchema, { tasks: Type.Array(AddTaskSchema, {
description: "Tasks to append or install. May be empty only in replace mode.", description: "Tasks to append or install. May be empty only in replace mode.",
}), }),
force: Type.Optional( force: Type.Boolean({
Type.Boolean({ description: "Use only when the user explicitly abandons unfinished tasks." }), description: "Set true only when the user explicitly abandons unfinished tasks; otherwise use false.",
), }),
}, },
{ additionalProperties: false }, { additionalProperties: false },
),
),
); );
const TaskPatchSchema = Type.Object( const TaskPatchSchema = Type.Object(
{ {
id: Type.Integer({ minimum: 1 }), id: Type.Integer({ minimum: 1 }),
name: Type.Optional(Type.String({ minLength: 1 })), name: Type.Union([Type.String({ minLength: 1 }), Type.Null()], {
description: Type.Optional(Type.String({ minLength: 1 })), description: "New name, or null to leave it unchanged.",
status: Type.Optional(TaskStatusSchema), }),
parentId: Type.Optional( description: Type.Union([Type.String({ minLength: 1 }), Type.Null()], {
Type.Union([Type.Integer({ minimum: 1 }), Type.Null()], { description: "New description, or null to leave it unchanged.",
description: "New parent ID, or null to move the task to the root.", }),
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.",
}), }),
),
position: Type.Optional(
Type.Integer({ minimum: 0, description: "Zero-based insertion index among the task's final siblings." }),
),
}, },
{ additionalProperties: false }, { additionalProperties: false },
); );
@@ -93,6 +158,60 @@ export const IdsParams = Type.Object({ ids: TaskIdsSchema }, { additionalPropert
export const ListTaskParams = Type.Object({}, { additionalProperties: false }); 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 { interface SetTaskInput {
mode: "append" | "replace"; mode: "append" | "replace";
tasks: AddTaskDraft[]; tasks: AddTaskDraft[];
@@ -161,9 +280,10 @@ export function registerTaskTools(pi: ExtensionAPI, state: TaskState, onChange:
name: "set_tasks", name: "set_tasks",
label: "Set Tasks", label: "Set Tasks",
description: 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], promptGuidelines: [...TASK_PROMPT_GUIDELINES],
parameters: SetTaskParams, parameters: SetTaskParams,
prepareArguments: prepareSetTaskArguments,
async execute(_toolCallId, params: SetTaskInput, _signal, _onUpdate, ctx) { async execute(_toolCallId, params: SetTaskInput, _signal, _onUpdate, ctx) {
try { try {
const affected = state.set(params.mode, params.tasks, params.force); 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", name: "update_task",
label: "Update Tasks", label: "Update Tasks",
description: 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, parameters: UpdateTaskParams,
prepareArguments: prepareUpdateTaskArguments,
async execute(_toolCallId, params: UpdateTaskInput, _signal, _onUpdate, ctx) { async execute(_toolCallId, params: UpdateTaskInput, _signal, _onUpdate, ctx) {
try { try {
const affected = state.update(params.tasks); const affected = state.update(params.tasks);
+19 -7
View File
@@ -7,28 +7,40 @@ export interface Task {
status: TaskStatus; status: TaskStatus;
parentId: number | null; parentId: number | null;
childrenIds: number[]; childrenIds: number[];
startedAt?: string;
stoppedAt?: string;
durationMs?: number;
}
export interface TaskTimingSnapshot {
taskId: number;
startedAt: number;
activeStartedAt?: number;
stoppedAt?: number;
accumulatedMs: number;
} }
export interface TaskSnapshot { export interface TaskSnapshot {
tasks: Task[]; tasks: Task[];
nextId: number; nextId: number;
timings?: TaskTimingSnapshot[];
} }
export interface AddTaskDraft { export interface AddTaskDraft {
name: string; name: string;
description?: string; description?: string | null;
status?: TaskStatus; status?: TaskStatus | null;
parentId?: number; parentId?: number | null;
children?: AddTaskDraft[]; children?: AddTaskDraft[];
} }
export interface TaskPatch { export interface TaskPatch {
id: number; id: number;
name?: string; name?: string | null;
description?: string; description?: string | null;
status?: TaskStatus; status?: TaskStatus | null;
parentId?: number | null; parentId?: number | null;
position?: number; position?: number | null;
} }
export interface TaskStats { export interface TaskStats {
+81 -5
View File
@@ -10,19 +10,52 @@ export const STATUS_ICONS = {
done: "✓", done: "✓",
} as const; } as const;
interface DescendantStats {
done: number;
total: number;
}
interface TreeRow { interface TreeRow {
task: Task; task: Task;
prefix: string; 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 byId = new Map(tasks.map((task) => [task.id, task]));
const memo = new Map<number, DescendantStats>();
const rows: TreeRow[] = []; const rows: TreeRow[] = [];
const visit = (task: Task, ancestorHasNext: boolean[], isLast: boolean, depth: number) => { const visit = (task: Task, ancestorHasNext: boolean[], isLast: boolean, depth: number) => {
const ancestors = ancestorHasNext.map((hasNext) => (hasNext ? "│ " : " ")).join(""); const ancestors = ancestorHasNext.map((hasNext) => (hasNext ? "│ " : " ")).join("");
const connector = depth === 0 ? "" : isLast ? "└─ " : "├─ "; 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); const children = task.childrenIds.map((id) => byId.get(id)).filter((child): child is Task => child !== undefined);
children.forEach((child, index) => { children.forEach((child, index) => {
const childAncestors = depth === 0 ? [] : [...ancestorHasNext, !isLast]; const childAncestors = depth === 0 ? [] : [...ancestorHasNext, !isLast];
@@ -35,6 +68,10 @@ export function orderedTreeRows(tasks: Task[]): TreeRow[] {
return rows; return rows;
} }
export function orderedTreeRows(tasks: Task[]): TreeRow[] {
return buildTreeRows(tasks, false);
}
function styleTaskName(task: Task, theme: Theme): string { function styleTaskName(task: Task, theme: Theme): string {
if (task.status === "done") return theme.fg("dim", theme.strikethrough(task.name)); if (task.status === "done") return theme.fg("dim", theme.strikethrough(task.name));
if (task.status === "in-progress") return theme.fg("warning", 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); 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 = const line =
theme.fg("dim", prefix) + theme.fg("dim", prefix) +
styleStatusIcon(task, theme) + styleStatusIcon(task, theme) +
@@ -57,13 +110,36 @@ export function renderTaskLine(task: Task, prefix: string, theme: Theme, width:
theme.fg("accent", `${task.id}.`) + theme.fg("accent", `${task.id}.`) +
" " + " " +
styleTaskName(task, theme); 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[] { export function renderTree(tasks: Task[], theme: Theme, width: number, padding = " "): string[] {
return orderedTreeRows(tasks).map(({ task, prefix }) => renderTaskLine(task, `${padding}${prefix}`, theme, width)); 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 { export function progressLabel(stats: TaskStats): string {
const blocked = stats.blocked > 0 ? ` · !${stats.blocked}` : ""; const blocked = stats.blocked > 0 ? ` · !${stats.blocked}` : "";
return ` Tasks ${stats.done}/${stats.total}${blocked}`; return ` Tasks ${stats.done}/${stats.total}${blocked}`;
@@ -182,7 +258,7 @@ export function renderToolResult(
return new TaskResultComponent((width) => { return new TaskResultComponent((width) => {
const lines = [theme.fg("success", "✓ ") + theme.fg("muted", summaryFor(details))]; const lines = [theme.fg("success", "✓ ") + theme.fg("muted", summaryFor(details))];
if (!expanded || details.affected.length === 0) return lines; 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)]; if (details.action === "get") return [...lines, ...renderTaskDetails(details.affected, theme, width)];
return [...lines, ...renderFlatTasks(details.affected, theme, width)]; return [...lines, ...renderFlatTasks(details.affected, theme, width)];
}); });
+23 -6
View File
@@ -1,25 +1,42 @@
import type { ExtensionContext } from "@earendil-works/pi-coding-agent"; import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
import type { TaskState } from "./state.ts"; 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 WIDGET_ID = "pi-todo-herdr";
const TIMER_REFRESH_MS = 1000;
export function clearTaskWidget(ctx: ExtensionContext): void { export function clearTaskWidget(ctx: ExtensionContext): void {
ctx.ui.setWidget(WIDGET_ID, undefined); 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(); const tasks = state.list();
if (!visible || tasks.length === 0) { if (!visible || tasks.length === 0) {
clearTaskWidget(ctx); clearTaskWidget(ctx);
return; return;
} }
const stats = state.stats(); const hasActiveTimer = tasks.some((task) => task.status === "in-progress" && task.startedAt !== undefined);
ctx.ui.setWidget(WIDGET_ID, (_tui, theme) => ({ 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[] { render(width: number): string[] {
return [renderProgressHeader(stats, theme, width), ...renderTree(tasks, theme, width)]; const currentTasks = state.list();
return [renderProgressHeader(state.stats(), theme, width), ...renderWidgetTree(currentTasks, theme, width)];
}, },
invalidate(): void {}, invalidate(): void {},
})); dispose(): void {
if (timer !== undefined) clearInterval(timer);
},
};
});
} }
+9 -6
View File
@@ -10,14 +10,17 @@ import {
} from "../src/prompt.ts"; } from "../src/prompt.ts";
import { TaskState } from "../src/state.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); assert.equal(TASK_PROMPT_GUIDELINES.length, 2);
const prompt = TASK_PROMPT_GUIDELINES.join("\n"); const prompt = TASK_PROMPT_GUIDELINES.join("\n");
assert.match(prompt, /large, multi-stage work/); assert.match(prompt, /work with 3\+ distinct steps/);
assert.match(prompt, /set_tasks before exploration as the only first-batch tool/); assert.match(prompt, /explore enough to understand the scope/);
assert.match(prompt, /Start one task in-progress; skip smaller work/); assert.match(prompt, /then call set_tasks before implementation/);
assert.match(prompt, /Use update_task when tracked work starts, blocks, or finishes/); assert.match(prompt, /otherwise skip tracking/);
assert.ok(prompt.split(/\s+/).length < 60); 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", () => { test("uses a compact one-shot recovery reminder without embedding task data", () => {
+168 -8
View File
@@ -27,7 +27,7 @@ test("adds a nested batch with generated preorder IDs and derived relations", ()
assert.equal(state.snapshot().nextId, 4); 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(); const state = new TaskState();
state.add([{ name: "Existing", description: "Existing" }]); state.add([{ name: "Existing", description: "Existing" }]);
const before = state.snapshot(); const before = state.snapshot();
@@ -44,6 +44,16 @@ test("rejects invalid nested parentId atomically", () => {
TaskValidationError, TaskValidationError,
); );
assert.deepEqual(state.snapshot(), before); 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", () => { 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"]); 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", () => { test("rejects cycles and duplicate patches without partial changes", () => {
const state = new TaskState(); const state = new TaskState();
state.add([{ name: "Root", description: "Root", children: [{ name: "Child", description: "Child" }] }]); 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"]); 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(); const state = new TaskState();
assert.throws(() => state.add([{ name: "Bad\nname", description: "Valid" }]), /single line/); 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: "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([ state.add([
{ name: "No description" }, { name: "No description" },
{ name: "Null description", description: null },
{ name: "Detailed", description: "Line one\nLine two" }, { name: "Detailed", description: "Line one\nLine two" },
]); ]);
assert.equal(state.list()[0]?.description, undefined); 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(); const state = new TaskState();
assert.throws(() => state.set("append", [], undefined), /tasks must not be empty/); assert.throws(() => state.set("append", [], false), /tasks must not be empty/);
assert.throws(() => state.set("append", [{ name: "Task" }], false), /force is allowed only in replace mode/); state.set("append", [{ name: "Task" }], false);
assert.deepEqual(state.snapshot(), { tasks: [], nextId: 1 }); 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", () => { 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); 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(replaced.map((task) => task.id), [1, 2]);
assert.deepEqual(state.list().map((task) => task.name), ["New", "Child"]); assert.deepEqual(state.list().map((task) => task.name), ["New", "Child"]);
assert.equal(state.snapshot().nextId, 3); assert.equal(state.snapshot().nextId, 3);
+139 -1
View File
@@ -1,5 +1,6 @@
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import test from "node:test"; 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 { TaskState } from "../src/state.ts";
import { registerTaskTools } from "../src/tools.ts"; import { registerTaskTools } from "../src/tools.ts";
@@ -18,7 +19,9 @@ function registeredTools(state = new TaskState()) {
} }
async function execute(tool: any, params: unknown) { 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", () => { 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"]); 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 () => { test("set_tasks appends optional-description tasks and persists a complete snapshot", async () => {
const harness = registeredTools(); const harness = registeredTools();
const result = await execute(harness.tools.get("set_tasks"), { 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].description, undefined);
assert.equal(parsed[1].parentId, 1); 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
View File
@@ -4,10 +4,12 @@ import { visibleWidth } from "@earendil-works/pi-tui";
import { currentTaskLabel, currentTaskTokens } from "../src/herdr.ts"; import { currentTaskLabel, currentTaskTokens } from "../src/herdr.ts";
import { TaskState } from "../src/state.ts"; import { TaskState } from "../src/state.ts";
import { import {
formatDuration,
renderProgressHeader, renderProgressHeader,
renderTaskLine, renderTaskLine,
renderToolResult, renderToolResult,
renderTree, renderTree,
renderWidgetTree,
snapshotStats, snapshotStats,
} from "../src/ui.ts"; } from "../src/ui.ts";
import { updateTaskWidget } from "../src/widget.ts"; import { updateTaskWidget } from "../src/widget.ts";
@@ -23,7 +25,7 @@ function stripAnsi(text: string): string {
} }
function fixture(): TaskState { function fixture(): TaskState {
const state = new TaskState(); const state = new TaskState(() => 0);
state.add([ state.add([
{ {
name: "Build auth", 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", () => { test("renders lightweight connectors without a detached root continuation", () => {
const output = renderTree(fixture().list(), plainTheme as never, 80).map(stripAnsi); const output = renderTree(fixture().list(), plainTheme as never, 80).map(stripAnsi);
assert.deepEqual(output, [ assert.deepEqual(output, [
" ◉ 1. Build auth", " ◉ 1. Build auth · 00:00",
" ├─ ! 2. Resolve token issue", " ├─ ! 2. Resolve token issue",
" │ └─ ○ 3. Inspect provider response", " │ └─ ○ 3. Inspect provider response",
" └─ ○ 4. Add middleware", " └─ ○ 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", () => { test("truncates every task row at narrow widths", () => {
const lines = renderTree(fixture().list(), plainTheme as never, 20); const lines = renderTree(fixture().list(), plainTheme as never, 20);
assert.ok(lines.every((line) => visibleWidth(line) <= 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" }, { name: "Parallel", description: "Parallel", status: "in-progress" },
]); ]);
assert.equal(currentTaskLabel(state.list()), "#2 Child +1"); 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", () => { 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" }, { name: "Blocked two", description: "Two", status: "blocked" },
]); ]);
assert.equal(currentTaskLabel(state.list()), "#1 Blocked one +1"); 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" }]); state.update([{ id: 1, status: "done" }, { id: 2, status: "done" }]);
assert.equal(currentTaskLabel(state.list()), null); 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", () => { 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)); 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", () => { test("tool results collapse summaries and expand themed details", () => {
const state = fixture(); const state = fixture();
const details = { action: "list" as const, snapshot: state.snapshot(), affected: state.list() }; const details = { action: "list" as const, snapshot: state.snapshot(), affected: state.list() };