Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1a437f50b4 | ||
|
|
b654c3c81b | ||
|
|
01b5f3ddee | ||
|
|
ae0ca47236 |
@@ -1,3 +1,4 @@
|
||||
node_modules/
|
||||
*.tgz
|
||||
.DS_Store
|
||||
.pi/
|
||||
|
||||
@@ -11,6 +11,7 @@ Hierarchical, session-local task tracking for [Pi](https://pi.dev), with atomic
|
||||
- Five focused tools: `set_tasks`, `update_task`, `rm_task`, `list_task`, and `get_task`
|
||||
- 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
|
||||
- Bounded task input and widget output to keep sessions and terminals responsive
|
||||
- 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
|
||||
@@ -89,20 +90,23 @@ Example append call:
|
||||
|
||||
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.
|
||||
|
||||
To keep model-generated input safe and bounded, a session may contain up to 200 tasks. One mutation may affect up to 50 explicitly supplied tasks. The widget header acts as a virtual root above three stored task levels: categories, items, and leaf sub-items. Names are limited to 200 Unicode characters and descriptions to 4,000 Unicode characters. Names and descriptions reject terminal controls and bidirectional override characters; descriptions may still contain line feeds for normal multi-line detail.
|
||||
|
||||
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
|
||||
|
||||
The widget appears above Pi's editor while tasks exist. Run `/tasks` to hide or show it for the current Pi runtime.
|
||||
The widget appears above Pi's editor while todos exist. Run `/todo` to hide or show it for the current Pi runtime. Its header is the virtual task-tree root. Root tasks render as Roman-numeral categories, their children as numbered items, and leaf sub-items use indentation without another label. Internal task IDs remain unchanged and visible in tool output. In-progress circles rotate through `◐ ◓ ◑ ◒` twice per second; other status symbols remain still. The widget shows at most 12 task rows plus a compact count of hidden tasks, so larger plans do not crowd the editor out of an 80×24 terminal.
|
||||
|
||||
```text
|
||||
Tasks 2/6 · !1 ━━━━━────────
|
||||
◉ 1. Build auth · 12:08
|
||||
├─ ! 3. Resolve token issue · 03:41
|
||||
└─ ○ 4. Add middleware
|
||||
✓ 2. Define schema · 04:31
|
||||
Todos 2/6 · !1 ━━━━━────────
|
||||
I. ◐ Build auth · 12:08
|
||||
├─ 1. ! Resolve token issue · 03:41
|
||||
│ └─ ○ Inspect provider response
|
||||
└─ 2. ○ Add middleware
|
||||
II. ✓ 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.
|
||||
@@ -128,6 +132,8 @@ Metadata failures never block task operations. The extension does not change Her
|
||||
|
||||
## Development
|
||||
|
||||
Development tests use Node's built-in TypeScript type stripping and require Node.js 22.18 or newer. The extension itself retains its Node.js 20 runtime requirement because Pi loads TypeScript extensions through its own loader.
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run typecheck
|
||||
|
||||
@@ -42,22 +42,22 @@ export default function piTodoHerdr(pi: ExtensionAPI): void {
|
||||
|
||||
registerTaskTools(pi, state, refresh);
|
||||
|
||||
pi.registerCommand("tasks", {
|
||||
description: "Show or hide the task tree widget",
|
||||
pi.registerCommand("todo", {
|
||||
description: "Show or hide the todo tree widget",
|
||||
handler: async (_args, ctx) => {
|
||||
if (state.list().length === 0) {
|
||||
clearTaskWidget(ctx);
|
||||
ctx.ui.notify("No tasks yet.", "info");
|
||||
ctx.ui.notify("No todos yet.", "info");
|
||||
return;
|
||||
}
|
||||
|
||||
widgetVisible = !widgetVisible;
|
||||
if (widgetVisible) {
|
||||
updateTaskWidget(state, ctx, true);
|
||||
ctx.ui.notify("Task widget shown.", "info");
|
||||
ctx.ui.notify("Todo widget shown.", "info");
|
||||
} else {
|
||||
clearTaskWidget(ctx);
|
||||
ctx.ui.notify("Task widget hidden.", "info");
|
||||
ctx.ui.notify("Todo widget hidden.", "info");
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
+6
-7
@@ -20,18 +20,17 @@
|
||||
"productivity"
|
||||
],
|
||||
"author": {
|
||||
"name": "lesetong",
|
||||
"email": "oscarlbw@qq.com",
|
||||
"url": "https://github.com/leset0ng"
|
||||
"name": "JB",
|
||||
"url": "https://git.jb.codes/jb"
|
||||
},
|
||||
"license": "MIT",
|
||||
"homepage": "https://github.com/leset0ng/pi-todo-herdr#readme",
|
||||
"homepage": "https://git.jb.codes/jb/pi-todo-herdr#readme",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/leset0ng/pi-todo-herdr.git"
|
||||
"url": "git+ssh://git@git.jb.codes:7722/jb/pi-todo-herdr.git"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/leset0ng/pi-todo-herdr/issues"
|
||||
"url": "https://git.jb.codes/jb/pi-todo-herdr/issues"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
@@ -40,7 +39,7 @@
|
||||
"access": "public"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "node --experimental-transform-types --test tests/*.test.ts",
|
||||
"test": "node --test tests/*.test.ts",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"prepublishOnly": "npm run typecheck && npm test"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
export const MAX_TASKS = 200;
|
||||
export const MAX_TASKS_PER_MUTATION = 50;
|
||||
export const MAX_TASK_DEPTH = 3;
|
||||
export const MAX_TASK_NAME_LENGTH = 200;
|
||||
export const MAX_TASK_DESCRIPTION_LENGTH = 4_000;
|
||||
export const MAX_WIDGET_TASK_ROWS = 12;
|
||||
|
||||
export const TASK_NAME_PATTERN =
|
||||
"^[^\\u0000-\\u001F\\u007F-\\u009F\\u061C\\u200E\\u200F\\u202A-\\u202E\\u2066-\\u206F]*$";
|
||||
export const TASK_DESCRIPTION_PATTERN =
|
||||
"^[^\\u0000-\\u0009\\u000B-\\u001F\\u007F-\\u009F\\u061C\\u200E\\u200F\\u202A-\\u202E\\u2066-\\u206F]*$";
|
||||
|
||||
const UNSAFE_TASK_TEXT = /[\u0000-\u0009\u000B-\u001F\u007F-\u009F\u061C\u200E\u200F\u202A-\u202E\u2066-\u206F]/u;
|
||||
|
||||
export function hasUnsafeTaskText(value: string): boolean {
|
||||
return UNSAFE_TASK_TEXT.test(value);
|
||||
}
|
||||
|
||||
export function taskTextLength(value: string): number {
|
||||
return [...value].length;
|
||||
}
|
||||
+137
-20
@@ -1,4 +1,13 @@
|
||||
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
||||
import {
|
||||
hasUnsafeTaskText,
|
||||
MAX_TASK_DEPTH,
|
||||
MAX_TASK_DESCRIPTION_LENGTH,
|
||||
MAX_TASK_NAME_LENGTH,
|
||||
MAX_TASKS,
|
||||
MAX_TASKS_PER_MUTATION,
|
||||
taskTextLength,
|
||||
} from "./limits.ts";
|
||||
import type {
|
||||
AddTaskDraft,
|
||||
Task,
|
||||
@@ -52,7 +61,7 @@ function snapshotTasks(records: TaskRecord[]): Task[] {
|
||||
|
||||
function durationMs(task: TaskRecord, now: number): number {
|
||||
const activeMs = task.activeStartedAt === undefined ? 0 : Math.max(0, now - task.activeStartedAt);
|
||||
return task.accumulatedMs + activeMs;
|
||||
return Math.min(MAX_DATE_MS, task.accumulatedMs + activeMs);
|
||||
}
|
||||
|
||||
function publicTasks(records: TaskRecord[], now: number): Task[] {
|
||||
@@ -94,7 +103,7 @@ function startTimer(task: TaskRecord, now: number): void {
|
||||
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.accumulatedMs = Math.min(MAX_DATE_MS, task.accumulatedMs + stoppedAt - task.activeStartedAt);
|
||||
task.activeStartedAt = undefined;
|
||||
task.stoppedAt = stoppedAt;
|
||||
}
|
||||
@@ -125,17 +134,29 @@ function validateGraph(records: TaskRecord[]): string[] {
|
||||
const ids = new Set<number>();
|
||||
|
||||
for (const task of records) {
|
||||
if (!Number.isInteger(task.id) || task.id < 1) errors.push(`task ${task.id}: id must be a positive integer`);
|
||||
if (!Number.isSafeInteger(task.id) || task.id < 1) {
|
||||
errors.push(`task ${task.id}: id must be a positive safe integer`);
|
||||
}
|
||||
if (ids.has(task.id)) errors.push(`task ${task.id}: duplicate id`);
|
||||
ids.add(task.id);
|
||||
if (!task.name.trim()) errors.push(`task ${task.id}: name must not be empty`);
|
||||
if (/[\r\n]/.test(task.name)) errors.push(`task ${task.id}: name must be a single line`);
|
||||
if (task.description !== undefined && !task.description.trim()) {
|
||||
errors.push(`task ${task.id}: description must not be empty`);
|
||||
if (hasUnsafeTaskText(task.name)) errors.push(`task ${task.id}: name contains an unsafe control character`);
|
||||
if (taskTextLength(task.name) > MAX_TASK_NAME_LENGTH) {
|
||||
errors.push(`task ${task.id}: name must be at most ${MAX_TASK_NAME_LENGTH} characters`);
|
||||
}
|
||||
if (task.description !== undefined) {
|
||||
if (!task.description.trim()) errors.push(`task ${task.id}: description must not be empty`);
|
||||
if (taskTextLength(task.description) > MAX_TASK_DESCRIPTION_LENGTH) {
|
||||
errors.push(`task ${task.id}: description must be at most ${MAX_TASK_DESCRIPTION_LENGTH} characters`);
|
||||
}
|
||||
if (hasUnsafeTaskText(task.description)) {
|
||||
errors.push(`task ${task.id}: description contains an unsafe control character`);
|
||||
}
|
||||
}
|
||||
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 (!isNonNegativeSafeInteger(task.accumulatedMs) || task.accumulatedMs > MAX_DATE_MS) {
|
||||
errors.push(`task ${task.id}: accumulatedMs must be an integer between 0 and ${MAX_DATE_MS}`);
|
||||
}
|
||||
if (task.startedAt === undefined) {
|
||||
if (task.activeStartedAt !== undefined || task.stoppedAt !== undefined || task.accumulatedMs !== 0) {
|
||||
@@ -193,6 +214,22 @@ function validateGraph(records: TaskRecord[]): string[] {
|
||||
|
||||
if (errors.length > 0) return errors;
|
||||
|
||||
const depths = new Map<number, number>();
|
||||
const depthOf = (task: TaskRecord): number => {
|
||||
const cached = depths.get(task.id);
|
||||
if (cached !== undefined) return cached;
|
||||
const parent = task.parentId === null ? undefined : byId.get(task.parentId);
|
||||
const depth = parent === undefined ? 1 : depthOf(parent) + 1;
|
||||
depths.set(task.id, depth);
|
||||
return depth;
|
||||
};
|
||||
for (const task of records) {
|
||||
if (depthOf(task) > MAX_TASK_DEPTH) {
|
||||
errors.push(`task ${task.id}: depth must not exceed ${MAX_TASK_DEPTH} levels`);
|
||||
}
|
||||
}
|
||||
if (errors.length > 0) return errors;
|
||||
|
||||
for (const task of records) {
|
||||
if (task.status !== "done") continue;
|
||||
const unfinished = collectDescendantIds(records, task.id).filter((id) => byId.get(id)?.status !== "done");
|
||||
@@ -251,8 +288,11 @@ function moveToSiblingEnd(records: TaskRecord[], id: number): void {
|
||||
export class TaskState {
|
||||
private records: TaskRecord[] = [];
|
||||
private nextId = 1;
|
||||
private readonly now: () => number;
|
||||
|
||||
constructor(private readonly now: () => number = Date.now) {}
|
||||
constructor(now: () => number = Date.now) {
|
||||
this.now = now;
|
||||
}
|
||||
|
||||
private currentTime(): number {
|
||||
const now = Math.floor(this.now());
|
||||
@@ -293,26 +333,60 @@ export class TaskState {
|
||||
add(drafts: AddTaskDraft[]): Task[] {
|
||||
const errors: string[] = [];
|
||||
if (!Array.isArray(drafts) || drafts.length === 0) throw new TaskValidationError(["tasks must not be empty"]);
|
||||
if (drafts.length > MAX_TASKS_PER_MUTATION) {
|
||||
throw new TaskValidationError([`tasks must contain at most ${MAX_TASKS_PER_MUTATION} tasks per mutation`]);
|
||||
}
|
||||
|
||||
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[] = [];
|
||||
let mutationTaskCount = 0;
|
||||
|
||||
const append = (draft: AddTaskDraft, implicitParentId: number | null, path: string, nested: boolean) => {
|
||||
const append = (
|
||||
draft: AddTaskDraft,
|
||||
implicitParentId: number | null,
|
||||
path: string,
|
||||
nested: boolean,
|
||||
depth: number,
|
||||
) => {
|
||||
mutationTaskCount += 1;
|
||||
if (mutationTaskCount > MAX_TASKS_PER_MUTATION) {
|
||||
errors.push(`tasks must contain at most ${MAX_TASKS_PER_MUTATION} tasks per mutation`);
|
||||
return;
|
||||
}
|
||||
if (depth > MAX_TASK_DEPTH) {
|
||||
errors.push(`${path}: depth must not exceed ${MAX_TASK_DEPTH} levels`);
|
||||
return;
|
||||
}
|
||||
if (working.length >= MAX_TASKS) {
|
||||
errors.push(`task session is limited to ${MAX_TASKS} tasks`);
|
||||
return;
|
||||
}
|
||||
if (!draft || typeof draft !== "object") {
|
||||
errors.push(`${path}: task must be an object`);
|
||||
return;
|
||||
}
|
||||
if (typeof draft.name !== "string" || !draft.name.trim()) errors.push(`${path}.name must not be empty`);
|
||||
if (typeof draft.name === "string" && /[\r\n]/.test(draft.name)) errors.push(`${path}.name must be a single line`);
|
||||
if (
|
||||
draft.description !== undefined &&
|
||||
draft.description !== null &&
|
||||
(typeof draft.description !== "string" || !draft.description.trim())
|
||||
) {
|
||||
errors.push(`${path}.description must not be empty`);
|
||||
if (typeof draft.name === "string" && hasUnsafeTaskText(draft.name)) {
|
||||
errors.push(`${path}.name contains an unsafe control character`);
|
||||
}
|
||||
if (typeof draft.name === "string" && taskTextLength(draft.name.trim()) > MAX_TASK_NAME_LENGTH) {
|
||||
errors.push(`${path}.name must be at most ${MAX_TASK_NAME_LENGTH} characters`);
|
||||
}
|
||||
if (draft.description !== undefined && draft.description !== null) {
|
||||
if (typeof draft.description !== "string" || !draft.description.trim()) {
|
||||
errors.push(`${path}.description must not be empty`);
|
||||
} else {
|
||||
if (hasUnsafeTaskText(draft.description)) {
|
||||
errors.push(`${path}.description contains an unsafe control character`);
|
||||
}
|
||||
if (taskTextLength(draft.description.trim()) > MAX_TASK_DESCRIPTION_LENGTH) {
|
||||
errors.push(`${path}.description must be at most ${MAX_TASK_DESCRIPTION_LENGTH} characters`);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (draft.status !== undefined && draft.status !== null && !VALID_STATUSES.has(draft.status)) {
|
||||
errors.push(`${path}.status is invalid`);
|
||||
@@ -331,6 +405,10 @@ export class TaskState {
|
||||
}
|
||||
}
|
||||
|
||||
if (nextId >= Number.MAX_SAFE_INTEGER) {
|
||||
errors.push("no safe task IDs remain");
|
||||
return;
|
||||
}
|
||||
const id = nextId++;
|
||||
const status = draft.status ?? "pending";
|
||||
addedIds.push(id);
|
||||
@@ -346,11 +424,21 @@ export class TaskState {
|
||||
|
||||
const children = Array.isArray(draft.children) ? draft.children : [];
|
||||
for (const [index, child] of children.entries()) {
|
||||
append(child, id, `${path}.children[${index}]`, true);
|
||||
if (mutationTaskCount >= MAX_TASKS_PER_MUTATION) {
|
||||
errors.push(`tasks must contain at most ${MAX_TASKS_PER_MUTATION} tasks per mutation`);
|
||||
break;
|
||||
}
|
||||
append(child, id, `${path}.children[${index}]`, true, depth + 1);
|
||||
}
|
||||
};
|
||||
|
||||
drafts.forEach((draft, index) => append(draft, null, `tasks[${index}]`, false));
|
||||
for (const [index, draft] of drafts.entries()) {
|
||||
if (mutationTaskCount >= MAX_TASKS_PER_MUTATION) {
|
||||
errors.push(`tasks must contain at most ${MAX_TASKS_PER_MUTATION} tasks per mutation`);
|
||||
break;
|
||||
}
|
||||
append(draft, null, `tasks[${index}]`, false, 1);
|
||||
}
|
||||
errors.push(...validateGraph(working));
|
||||
if (errors.length > 0) throw new TaskValidationError([...new Set(errors)]);
|
||||
|
||||
@@ -387,6 +475,9 @@ export class TaskState {
|
||||
update(patches: TaskPatch[]): Task[] {
|
||||
const errors: string[] = [];
|
||||
if (!Array.isArray(patches) || patches.length === 0) throw new TaskValidationError(["tasks must not be empty"]);
|
||||
if (patches.length > MAX_TASKS_PER_MUTATION) {
|
||||
throw new TaskValidationError([`tasks must contain at most ${MAX_TASKS_PER_MUTATION} patches`]);
|
||||
}
|
||||
|
||||
const duplicateIds = patches
|
||||
.map((patch) => patch.id)
|
||||
@@ -415,9 +506,23 @@ export class TaskState {
|
||||
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" && hasUnsafeTaskText(patch.name)) {
|
||||
errors.push(`${path}.name contains an unsafe control character`);
|
||||
}
|
||||
if (typeof patch.name === "string" && taskTextLength(patch.name.trim()) > MAX_TASK_NAME_LENGTH) {
|
||||
errors.push(`${path}.name must be at most ${MAX_TASK_NAME_LENGTH} characters`);
|
||||
}
|
||||
if (hasDescriptionChange && (typeof patch.description !== "string" || !patch.description.trim())) {
|
||||
errors.push(`${path}.description must not be empty`);
|
||||
}
|
||||
if (typeof patch.description === "string") {
|
||||
if (hasUnsafeTaskText(patch.description)) {
|
||||
errors.push(`${path}.description contains an unsafe control character`);
|
||||
}
|
||||
if (taskTextLength(patch.description.trim()) > MAX_TASK_DESCRIPTION_LENGTH) {
|
||||
errors.push(`${path}.description must be at most ${MAX_TASK_DESCRIPTION_LENGTH} characters`);
|
||||
}
|
||||
}
|
||||
if (hasStatusChange && !VALID_STATUSES.has(patch.status as TaskStatus)) errors.push(`${path}.status is invalid`);
|
||||
if (
|
||||
hasParentChange &&
|
||||
@@ -460,6 +565,9 @@ export class TaskState {
|
||||
|
||||
remove(ids: number[]): Task[] {
|
||||
if (!Array.isArray(ids) || ids.length === 0) throw new TaskValidationError(["ids must not be empty"]);
|
||||
if (ids.length > MAX_TASKS_PER_MUTATION) {
|
||||
throw new TaskValidationError([`ids must contain at most ${MAX_TASKS_PER_MUTATION} entries`]);
|
||||
}
|
||||
const uniqueIds = [...new Set(ids)];
|
||||
const missing = uniqueIds.filter((id) => !this.records.some((task) => task.id === id));
|
||||
if (missing.length > 0) throw new TaskValidationError([`task ids do not exist: ${missing.join(", ")}`]);
|
||||
@@ -479,12 +587,21 @@ export class TaskState {
|
||||
}
|
||||
|
||||
restore(snapshot: TaskSnapshot): void {
|
||||
if (!snapshot || !Array.isArray(snapshot.tasks) || !Number.isInteger(snapshot.nextId)) {
|
||||
if (!snapshot || !Array.isArray(snapshot.tasks)) {
|
||||
throw new TaskValidationError(["invalid task snapshot"]);
|
||||
}
|
||||
if (!Number.isSafeInteger(snapshot.nextId) || snapshot.nextId < 1) {
|
||||
throw new TaskValidationError(["nextId must be a positive safe integer"]);
|
||||
}
|
||||
if (snapshot.timings !== undefined && !Array.isArray(snapshot.timings)) {
|
||||
throw new TaskValidationError(["invalid task snapshot timings"]);
|
||||
}
|
||||
if (snapshot.tasks.length > MAX_TASKS) {
|
||||
throw new TaskValidationError([`task session is limited to ${MAX_TASKS} tasks`]);
|
||||
}
|
||||
if ((snapshot.timings?.length ?? 0) > MAX_TASKS) {
|
||||
throw new TaskValidationError([`task snapshot timings are limited to ${MAX_TASKS} entries`]);
|
||||
}
|
||||
|
||||
const records = snapshot.tasks.map(({ id, name, description, status, parentId }) => ({
|
||||
id,
|
||||
@@ -504,8 +621,8 @@ export class TaskState {
|
||||
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`);
|
||||
if (!Number.isSafeInteger(timing.taskId) || timing.taskId < 1) {
|
||||
timingErrors.push(`${path}.taskId must be a positive safe integer`);
|
||||
continue;
|
||||
}
|
||||
if (timedIds.has(timing.taskId)) {
|
||||
|
||||
+72
-16
@@ -2,6 +2,14 @@ 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, type Static } from "typebox";
|
||||
import {
|
||||
MAX_TASK_DEPTH,
|
||||
MAX_TASK_DESCRIPTION_LENGTH,
|
||||
MAX_TASK_NAME_LENGTH,
|
||||
MAX_TASKS_PER_MUTATION,
|
||||
TASK_DESCRIPTION_PATTERN,
|
||||
TASK_NAME_PATTERN,
|
||||
} from "./limits.ts";
|
||||
import { formatCompactTaskTree, TASK_PROMPT_GUIDELINES } from "./prompt.ts";
|
||||
import type { TaskState } from "./state.ts";
|
||||
import type { AddTaskDraft, Task, TaskPatch, TaskToolDetails } from "./types.ts";
|
||||
@@ -77,15 +85,29 @@ const AddTaskSchema = Type.Cyclic(
|
||||
{
|
||||
AddTask: Type.Object(
|
||||
{
|
||||
name: Type.String({ minLength: 1 }),
|
||||
description: Type.Union([Type.String({ minLength: 1 }), Type.Null()], {
|
||||
description: "Detailed scope, constraints, and done criteria; use null when no detail is needed.",
|
||||
name: Type.String({
|
||||
minLength: 1,
|
||||
maxLength: MAX_TASK_NAME_LENGTH,
|
||||
pattern: TASK_NAME_PATTERN,
|
||||
}),
|
||||
description: Type.Union(
|
||||
[
|
||||
Type.String({
|
||||
minLength: 1,
|
||||
maxLength: MAX_TASK_DESCRIPTION_LENGTH,
|
||||
pattern: TASK_DESCRIPTION_PATTERN,
|
||||
}),
|
||||
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")),
|
||||
children: Type.Array(Type.Ref("AddTask"), { maxItems: MAX_TASKS_PER_MUTATION }),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
),
|
||||
@@ -99,6 +121,7 @@ export const SetTaskParams = hoistDefsToRoot(
|
||||
{
|
||||
mode: SetTaskModeSchema,
|
||||
tasks: Type.Array(AddTaskSchema, {
|
||||
maxItems: MAX_TASKS_PER_MUTATION,
|
||||
description: "Tasks to append or install. May be empty only in replace mode.",
|
||||
}),
|
||||
force: Type.Boolean({
|
||||
@@ -113,12 +136,24 @@ export const SetTaskParams = hoistDefsToRoot(
|
||||
const TaskPatchSchema = Type.Object(
|
||||
{
|
||||
id: Type.Integer({ minimum: 1 }),
|
||||
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.",
|
||||
}),
|
||||
name: Type.Union(
|
||||
[
|
||||
Type.String({ minLength: 1, maxLength: MAX_TASK_NAME_LENGTH, pattern: TASK_NAME_PATTERN }),
|
||||
Type.Null(),
|
||||
],
|
||||
{ description: "New name, or null to leave it unchanged." },
|
||||
),
|
||||
description: Type.Union(
|
||||
[
|
||||
Type.String({
|
||||
minLength: 1,
|
||||
maxLength: MAX_TASK_DESCRIPTION_LENGTH,
|
||||
pattern: TASK_DESCRIPTION_PATTERN,
|
||||
}),
|
||||
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.",
|
||||
}),
|
||||
@@ -136,13 +171,18 @@ const TaskPatchSchema = Type.Object(
|
||||
|
||||
export const UpdateTaskParams = Type.Object(
|
||||
{
|
||||
tasks: Type.Array(TaskPatchSchema, { minItems: 1, description: "Task patches applied atomically in array order." }),
|
||||
tasks: Type.Array(TaskPatchSchema, {
|
||||
minItems: 1,
|
||||
maxItems: MAX_TASKS_PER_MUTATION,
|
||||
description: "Task patches applied atomically in array order.",
|
||||
}),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
const TaskIdsSchema = Type.Array(Type.Integer({ minimum: 1 }), {
|
||||
minItems: 1,
|
||||
maxItems: MAX_TASKS_PER_MUTATION,
|
||||
description: "Task IDs to process. Duplicate IDs are deduplicated.",
|
||||
});
|
||||
|
||||
@@ -158,14 +198,23 @@ export const IdsParams = Type.Object({ ids: TaskIdsSchema }, { additionalPropert
|
||||
|
||||
export const ListTaskParams = Type.Object({}, { additionalProperties: false });
|
||||
|
||||
function prepareTaskDraft(value: unknown): unknown {
|
||||
interface DraftPreparationState {
|
||||
count: number;
|
||||
}
|
||||
|
||||
function prepareTaskDraft(value: unknown, depth: number, state: DraftPreparationState): unknown {
|
||||
state.count += 1;
|
||||
if (depth > MAX_TASK_DEPTH || state.count > MAX_TASKS_PER_MUTATION) return {};
|
||||
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
|
||||
.slice(0, MAX_TASKS_PER_MUTATION + 1)
|
||||
.map((child) => prepareTaskDraft(child, depth + 1, state))
|
||||
: input.children;
|
||||
return {
|
||||
...input,
|
||||
@@ -181,9 +230,14 @@ function prepareSetTaskArguments(value: unknown): Static<typeof SetTaskParams> {
|
||||
return value as Static<typeof SetTaskParams>;
|
||||
}
|
||||
const input = value as Record<string, unknown>;
|
||||
const state: DraftPreparationState = { count: 0 };
|
||||
return {
|
||||
...input,
|
||||
tasks: Array.isArray(input.tasks) ? input.tasks.map(prepareTaskDraft) : input.tasks,
|
||||
tasks: Array.isArray(input.tasks)
|
||||
? input.tasks
|
||||
.slice(0, MAX_TASKS_PER_MUTATION + 1)
|
||||
.map((task) => prepareTaskDraft(task, 1, state))
|
||||
: input.tasks,
|
||||
force: input.force === undefined ? false : input.force,
|
||||
} as Static<typeof SetTaskParams>;
|
||||
}
|
||||
@@ -208,7 +262,9 @@ function prepareUpdateTaskArguments(value: unknown): Static<typeof UpdateTaskPar
|
||||
const input = value as Record<string, unknown>;
|
||||
return {
|
||||
...input,
|
||||
tasks: Array.isArray(input.tasks) ? input.tasks.map(prepareTaskPatch) : input.tasks,
|
||||
tasks: Array.isArray(input.tasks)
|
||||
? input.tasks.slice(0, MAX_TASKS_PER_MUTATION + 1).map(prepareTaskPatch)
|
||||
: input.tasks,
|
||||
} as Static<typeof UpdateTaskParams>;
|
||||
}
|
||||
|
||||
@@ -280,7 +336,7 @@ export function registerTaskTools(pi: ExtensionAPI, state: TaskState, onChange:
|
||||
name: "set_tasks",
|
||||
label: "Set Tasks",
|
||||
description:
|
||||
"Atomically append tasks or replace the task tree. Pass force false unless unfinished work is explicitly discarded.",
|
||||
`Atomically append tasks or replace the task tree. Supply at most ${MAX_TASKS_PER_MUTATION} total tasks across ${MAX_TASK_DEPTH} stored levels: root categories, child items, and leaf sub-items beneath the widget's virtual root. Pass force false unless unfinished work is explicitly discarded.`,
|
||||
promptGuidelines: [...TASK_PROMPT_GUIDELINES],
|
||||
parameters: SetTaskParams,
|
||||
prepareArguments: prepareSetTaskArguments,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Component } from "@earendil-works/pi-tui";
|
||||
import { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
||||
import type { Theme } from "@earendil-works/pi-coding-agent";
|
||||
import { MAX_WIDGET_TASK_ROWS } from "./limits.ts";
|
||||
import type { Task, TaskStats, TaskToolDetails } from "./types.ts";
|
||||
|
||||
export const STATUS_ICONS = {
|
||||
@@ -10,6 +11,8 @@ export const STATUS_ICONS = {
|
||||
done: "✓",
|
||||
} as const;
|
||||
|
||||
const IN_PROGRESS_WIDGET_FRAMES = ["◐", "◓", "◑", "◒"] as const;
|
||||
|
||||
interface DescendantStats {
|
||||
done: number;
|
||||
total: number;
|
||||
@@ -18,6 +21,8 @@ interface DescendantStats {
|
||||
interface TreeRow {
|
||||
task: Task;
|
||||
prefix: string;
|
||||
depth: number;
|
||||
siblingIndex: number;
|
||||
collapsed?: DescendantStats;
|
||||
}
|
||||
|
||||
@@ -48,23 +53,23 @@ function buildTreeRows(tasks: Task[], collapseCompleted: boolean): TreeRow[] {
|
||||
const memo = new Map<number, DescendantStats>();
|
||||
const rows: TreeRow[] = [];
|
||||
|
||||
const visit = (task: Task, ancestorHasNext: boolean[], isLast: boolean, depth: number) => {
|
||||
const visit = (task: Task, ancestorHasNext: boolean[], isLast: boolean, depth: number, siblingIndex: number) => {
|
||||
const ancestors = ancestorHasNext.map((hasNext) => (hasNext ? "│ " : " ")).join("");
|
||||
const connector = depth === 0 ? "" : isLast ? "└─ " : "├─ ";
|
||||
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 } : {}) });
|
||||
rows.push({ task, prefix: `${ancestors}${connector}`, depth, siblingIndex, ...(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];
|
||||
visit(child, childAncestors, index === children.length - 1, depth + 1);
|
||||
visit(child, childAncestors, index === children.length - 1, depth + 1, index);
|
||||
});
|
||||
};
|
||||
|
||||
const roots = tasks.filter((task) => task.parentId === null);
|
||||
roots.forEach((root, index) => visit(root, [], index === roots.length - 1, 0));
|
||||
roots.forEach((root, index) => visit(root, [], index === roots.length - 1, 0, index));
|
||||
return rows;
|
||||
}
|
||||
|
||||
@@ -78,8 +83,12 @@ function styleTaskName(task: Task, theme: Theme): string {
|
||||
return task.name;
|
||||
}
|
||||
|
||||
function styleStatusIcon(task: Task, theme: Theme): string {
|
||||
const icon = STATUS_ICONS[task.status];
|
||||
function styleStatusIcon(task: Task, theme: Theme, inProgressFrame?: number): string {
|
||||
const frameIndex = inProgressFrame === undefined ? undefined : Math.abs(Math.trunc(inProgressFrame));
|
||||
const icon =
|
||||
task.status === "in-progress" && frameIndex !== undefined
|
||||
? IN_PROGRESS_WIDGET_FRAMES[frameIndex % IN_PROGRESS_WIDGET_FRAMES.length]
|
||||
: STATUS_ICONS[task.status];
|
||||
if (task.status === "done") return theme.fg("success", icon);
|
||||
if (task.status === "in-progress") return theme.fg("warning", icon);
|
||||
if (task.status === "blocked") return theme.fg("error", icon);
|
||||
@@ -102,27 +111,40 @@ function renderTaskLineWithSuffix(
|
||||
theme: Theme,
|
||||
width: number,
|
||||
suffix?: string,
|
||||
identifier = `${task.id}.`,
|
||||
identifierBeforeStatus = false,
|
||||
inProgressFrame?: number,
|
||||
): string {
|
||||
const line =
|
||||
theme.fg("dim", prefix) +
|
||||
styleStatusIcon(task, theme) +
|
||||
" " +
|
||||
theme.fg("accent", `${task.id}.`) +
|
||||
" " +
|
||||
styleTaskName(task, theme);
|
||||
const status = styleStatusIcon(task, theme, inProgressFrame);
|
||||
const styledIdentifier = identifier.length === 0 ? "" : theme.fg("accent", identifier);
|
||||
const identifierFits =
|
||||
!identifierBeforeStatus ||
|
||||
visibleWidth(prefix) + visibleWidth(styledIdentifier) + 1 + visibleWidth(status) <= width;
|
||||
const visibleIdentifier = identifierFits ? styledIdentifier : "";
|
||||
const markers = identifierBeforeStatus ? [visibleIdentifier, status] : [status, visibleIdentifier];
|
||||
const leading = theme.fg("dim", prefix) + markers.filter((marker) => marker.length > 0).join(" ");
|
||||
const styledName = styleTaskName(task, theme);
|
||||
const renderMain = (targetWidth: number): string => {
|
||||
const safeWidth = Math.max(1, targetWidth);
|
||||
if (!identifierBeforeStatus) return truncateToWidth(`${leading} ${styledName}`, safeWidth);
|
||||
|
||||
const availableNameWidth = safeWidth - visibleWidth(leading) - 1;
|
||||
if (availableNameWidth < 1) return truncateToWidth(leading, safeWidth);
|
||||
return `${leading} ${truncateToWidth(styledName, availableNameWidth)}`;
|
||||
};
|
||||
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));
|
||||
if (trailing.length === 0) return renderMain(width);
|
||||
|
||||
const trailingWidth = visibleWidth(trailing);
|
||||
const minimumTaskWidth = suffix === undefined ? 6 : 12;
|
||||
if (width <= trailingWidth + minimumTaskWidth) return truncateToWidth(line, Math.max(1, width));
|
||||
const minimumTaskWidth = Math.max(suffix === undefined ? 6 : 12, visibleWidth(leading));
|
||||
if (width <= trailingWidth + minimumTaskWidth) return renderMain(width);
|
||||
const taskWidth = Math.max(1, width - trailingWidth);
|
||||
return truncateToWidth(line, taskWidth) + trailing;
|
||||
return renderMain(taskWidth) + trailing;
|
||||
}
|
||||
|
||||
export function renderTaskLine(task: Task, prefix: string, theme: Theme, width: number): string {
|
||||
@@ -133,16 +155,62 @@ export function renderTree(tasks: Task[], theme: Theme, width: number, padding =
|
||||
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 }) => {
|
||||
function toRomanNumeral(value: number): string {
|
||||
const numerals: Array<[number, string]> = [
|
||||
[100, "C"],
|
||||
[90, "XC"],
|
||||
[50, "L"],
|
||||
[40, "XL"],
|
||||
[10, "X"],
|
||||
[9, "IX"],
|
||||
[5, "V"],
|
||||
[4, "IV"],
|
||||
[1, "I"],
|
||||
];
|
||||
let remaining = value;
|
||||
let result = "";
|
||||
for (const [amount, numeral] of numerals) {
|
||||
while (remaining >= amount) {
|
||||
result += numeral;
|
||||
remaining -= amount;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function widgetOutlineLabel(depth: number, siblingIndex: number): string {
|
||||
if (depth === 0) return `${toRomanNumeral(siblingIndex + 1)}.`;
|
||||
if (depth === 1) return `${siblingIndex + 1}.`;
|
||||
return "";
|
||||
}
|
||||
|
||||
export function renderWidgetTree(
|
||||
tasks: Task[],
|
||||
theme: Theme,
|
||||
width: number,
|
||||
padding = " ",
|
||||
inProgressFrame?: number,
|
||||
): string[] {
|
||||
const rows = buildTreeRows(tasks, true);
|
||||
const visibleRows = rows.slice(0, MAX_WIDGET_TASK_ROWS);
|
||||
const lines = visibleRows.map(({ task, prefix, depth, siblingIndex, collapsed }) => {
|
||||
const summary = collapsed === undefined ? undefined : `${collapsed.done}/${collapsed.total} done`;
|
||||
return renderTaskLineWithSuffix(task, `${padding}${prefix}`, theme, width, summary);
|
||||
const identifier = widgetOutlineLabel(depth, siblingIndex);
|
||||
return renderTaskLineWithSuffix(task, `${padding}${prefix}`, theme, width, summary, identifier, true, inProgressFrame);
|
||||
});
|
||||
if (rows.length <= MAX_WIDGET_TASK_ROWS) return lines;
|
||||
|
||||
const hiddenTasks = rows.slice(MAX_WIDGET_TASK_ROWS).reduce(
|
||||
(count, row) => count + 1 + (row.collapsed?.total ?? 0),
|
||||
0,
|
||||
);
|
||||
const label = `${padding}… ${hiddenTasks} more task${hiddenTasks === 1 ? "" : "s"}`;
|
||||
return [...lines, truncateToWidth(theme.fg("dim", label), Math.max(1, width))];
|
||||
}
|
||||
|
||||
export function progressLabel(stats: TaskStats): string {
|
||||
const blocked = stats.blocked > 0 ? ` · !${stats.blocked}` : "";
|
||||
return ` Tasks ${stats.done}/${stats.total}${blocked}`;
|
||||
return ` Todos ${stats.done}/${stats.total}${blocked}`;
|
||||
}
|
||||
|
||||
export function renderProgressHeader(stats: TaskStats, theme: Theme, width: number): string {
|
||||
@@ -159,9 +227,11 @@ export function renderProgressHeader(stats: TaskStats, theme: Theme, width: numb
|
||||
}
|
||||
|
||||
class TaskResultComponent implements Component {
|
||||
constructor(
|
||||
private readonly build: (width: number) => string[],
|
||||
) {}
|
||||
private readonly build: (width: number) => string[];
|
||||
|
||||
constructor(build: (width: number) => string[]) {
|
||||
this.build = build;
|
||||
}
|
||||
|
||||
render(width: number): string[] {
|
||||
return this.build(Math.max(1, width)).map((line) => truncateToWidth(line, Math.max(1, width)));
|
||||
|
||||
+11
-4
@@ -3,7 +3,7 @@ import type { TaskState } from "./state.ts";
|
||||
import { renderProgressHeader, renderWidgetTree } from "./ui.ts";
|
||||
|
||||
const WIDGET_ID = "pi-todo-herdr";
|
||||
const TIMER_REFRESH_MS = 1000;
|
||||
export const TODO_ANIMATION_INTERVAL_MS = 500;
|
||||
|
||||
export function clearTaskWidget(ctx: ExtensionContext): void {
|
||||
ctx.ui.setWidget(WIDGET_ID, undefined);
|
||||
@@ -13,7 +13,7 @@ export function updateTaskWidget(
|
||||
state: TaskState,
|
||||
ctx: ExtensionContext,
|
||||
visible: boolean,
|
||||
refreshMs = TIMER_REFRESH_MS,
|
||||
refreshMs = TODO_ANIMATION_INTERVAL_MS,
|
||||
): void {
|
||||
const tasks = state.list();
|
||||
if (!visible || tasks.length === 0) {
|
||||
@@ -23,15 +23,22 @@ export function updateTaskWidget(
|
||||
|
||||
const hasActiveTimer = tasks.some((task) => task.status === "in-progress" && task.startedAt !== undefined);
|
||||
ctx.ui.setWidget(WIDGET_ID, (tui, theme) => {
|
||||
let inProgressFrame = 0;
|
||||
const timer = hasActiveTimer
|
||||
? setInterval(() => tui.requestRender(), Math.max(1, refreshMs))
|
||||
? setInterval(() => {
|
||||
inProgressFrame += 1;
|
||||
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)];
|
||||
return [
|
||||
renderProgressHeader(state.stats(), theme, width),
|
||||
...renderWidgetTree(currentTasks, theme, width, " ", inProgressFrame),
|
||||
];
|
||||
},
|
||||
invalidate(): void {},
|
||||
dispose(): void {
|
||||
|
||||
@@ -10,6 +10,38 @@ import {
|
||||
} from "../src/prompt.ts";
|
||||
import { TaskState } from "../src/state.ts";
|
||||
|
||||
test("registers the conflict-free /todo command", async () => {
|
||||
type RegisteredCommand = {
|
||||
description: string;
|
||||
handler(args: string, ctx: unknown): Promise<void>;
|
||||
};
|
||||
const commands = new Map<string, RegisteredCommand>();
|
||||
const pi = {
|
||||
on() {},
|
||||
registerTool() {},
|
||||
registerCommand(name: string, command: RegisteredCommand) {
|
||||
commands.set(name, command);
|
||||
},
|
||||
};
|
||||
|
||||
piTodoHerdr(pi as never);
|
||||
|
||||
assert.deepEqual([...commands.keys()], ["todo"]);
|
||||
const todo = commands.get("todo");
|
||||
assert.equal(todo?.description, "Show or hide the todo tree widget");
|
||||
|
||||
const notifications: string[] = [];
|
||||
await todo?.handler("", {
|
||||
ui: {
|
||||
setWidget() {},
|
||||
notify(message: string) {
|
||||
notifications.push(message);
|
||||
},
|
||||
},
|
||||
});
|
||||
assert.deepEqual(notifications, ["No todos yet."]);
|
||||
});
|
||||
|
||||
test("keeps task guidance short with planning after exploration", () => {
|
||||
assert.equal(TASK_PROMPT_GUIDELINES.length, 2);
|
||||
const prompt = TASK_PROMPT_GUIDELINES.join("\n");
|
||||
|
||||
@@ -172,6 +172,50 @@ test("restores timers and starts legacy in-progress snapshots at load time", ()
|
||||
assert.equal(legacy.snapshot().timings?.[0]?.activeStartedAt, now);
|
||||
});
|
||||
|
||||
test("rejects unsafe restored identifiers and oversized timing collections", () => {
|
||||
const state = new TaskState(() => 0);
|
||||
state.add([{ name: "Keep" }]);
|
||||
const before = state.snapshot();
|
||||
const task = { id: 1, name: "Task", status: "pending" as const, parentId: null, childrenIds: [] };
|
||||
|
||||
assert.throws(
|
||||
() => state.restore({ tasks: [{ ...task, id: Number.MAX_SAFE_INTEGER + 1 }], nextId: Number.MAX_SAFE_INTEGER }),
|
||||
/id must be a positive safe integer/,
|
||||
);
|
||||
assert.throws(
|
||||
() => state.restore({ tasks: [task], nextId: Number.MAX_SAFE_INTEGER + 1 }),
|
||||
/nextId must be a positive safe integer/,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
state.restore({
|
||||
tasks: [task],
|
||||
nextId: 2,
|
||||
timings: Array.from({ length: 201 }, () => ({ taskId: 1, startedAt: 0, accumulatedMs: 0 })),
|
||||
}),
|
||||
/timings are limited to 200 entries/,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
state.restore({
|
||||
tasks: [task],
|
||||
nextId: 2,
|
||||
timings: [{ taskId: 1, startedAt: 0, stoppedAt: 0, accumulatedMs: Number.MAX_SAFE_INTEGER }],
|
||||
}),
|
||||
/accumulatedMs must be an integer between/,
|
||||
);
|
||||
assert.deepEqual(state.snapshot(), before);
|
||||
});
|
||||
|
||||
test("rejects additions when no safe generated task ID remains", () => {
|
||||
const state = new TaskState(() => 0);
|
||||
state.restore({ tasks: [], nextId: Number.MAX_SAFE_INTEGER });
|
||||
const before = state.snapshot();
|
||||
|
||||
assert.throws(() => state.add([{ name: "Unsafe ID" }]), /no safe task IDs remain/);
|
||||
assert.deepEqual(state.snapshot(), before);
|
||||
});
|
||||
|
||||
test("rejects out-of-range persisted timestamps without replacing state", () => {
|
||||
const state = new TaskState(() => 0);
|
||||
state.add([{ name: "Keep" }]);
|
||||
@@ -318,6 +362,93 @@ test("loads the latest valid mutation snapshot from a session branch", () => {
|
||||
assert.deepEqual(state.list().map((task) => task.name), ["First", "Second"]);
|
||||
});
|
||||
|
||||
test("rejects terminal controls and bidirectional overrides without changing state", () => {
|
||||
const state = new TaskState();
|
||||
state.add([{ name: "Safe", description: "Line one\nLine two" }]);
|
||||
const before = state.snapshot();
|
||||
|
||||
for (const name of ["Escape\u001b[2J", "Tab\tname", "Arabic mark\u061cname", "Override\u202eevil"]) {
|
||||
assert.throws(() => state.add([{ name }]), /unsafe control/);
|
||||
assert.deepEqual(state.snapshot(), before);
|
||||
}
|
||||
|
||||
assert.throws(() => state.update([{ id: 1, description: "Clipboard\u009d52;c;payload" }]), /unsafe control/);
|
||||
assert.deepEqual(state.snapshot(), before);
|
||||
});
|
||||
|
||||
test("enforces task name and description length limits by Unicode character", () => {
|
||||
const state = new TaskState();
|
||||
state.add([{ name: "😀".repeat(200), description: "d".repeat(4_000) }]);
|
||||
const before = state.snapshot();
|
||||
|
||||
assert.throws(() => state.add([{ name: "n".repeat(201) }]), /name must be at most 200 characters/);
|
||||
assert.throws(() => state.update([{ id: 1, description: "d".repeat(4_001) }]), /description must be at most 4000 characters/);
|
||||
assert.deepEqual(state.snapshot(), before);
|
||||
});
|
||||
|
||||
test("enforces per-mutation and per-session task limits atomically", () => {
|
||||
const state = new TaskState();
|
||||
const batch = Array.from({ length: 50 }, (_, index) => ({ name: `Task ${index + 1}` }));
|
||||
|
||||
assert.throws(() => state.add([...batch, { name: "Too many" }]), /at most 50 tasks/);
|
||||
assert.equal(state.list().length, 0);
|
||||
|
||||
for (let index = 0; index < 4; index += 1) state.add(batch);
|
||||
assert.equal(state.list().length, 200);
|
||||
const before = state.snapshot();
|
||||
assert.throws(() => state.add([{ name: "Session overflow" }]), /session is limited to 200 tasks/);
|
||||
assert.deepEqual(state.snapshot(), before);
|
||||
|
||||
assert.throws(
|
||||
() =>
|
||||
new TaskState().restore({
|
||||
tasks: Array.from({ length: 201 }, (_, index) => ({
|
||||
id: index + 1,
|
||||
name: `Restored ${index + 1}`,
|
||||
status: "pending" as const,
|
||||
parentId: null,
|
||||
childrenIds: [],
|
||||
})),
|
||||
nextId: 202,
|
||||
}),
|
||||
/session is limited to 200 tasks/,
|
||||
);
|
||||
});
|
||||
|
||||
test("allows three stored levels beneath the virtual root and rejects deeper task trees atomically", () => {
|
||||
const nested = (depth: number) => {
|
||||
let task: { name: string; children?: Array<{ name: string; children?: unknown[] }> } = { name: `Level ${depth}` };
|
||||
for (let level = depth - 1; level >= 1; level -= 1) {
|
||||
task = { name: `Level ${level}`, children: [task] };
|
||||
}
|
||||
return task;
|
||||
};
|
||||
|
||||
const state = new TaskState();
|
||||
state.add([nested(3) as never, { name: "Other category" }]);
|
||||
assert.equal(state.list().length, 4);
|
||||
const before = state.snapshot();
|
||||
assert.throws(() => state.add([nested(4) as never]), /depth must not exceed 3 levels/);
|
||||
assert.deepEqual(state.snapshot(), before);
|
||||
assert.throws(() => state.update([{ id: 4, parentId: 3 }]), /depth must not exceed 3 levels/);
|
||||
assert.deepEqual(state.snapshot(), before);
|
||||
|
||||
assert.throws(
|
||||
() =>
|
||||
state.restore({
|
||||
tasks: [
|
||||
{ id: 1, name: "Category", status: "pending", parentId: null, childrenIds: [2] },
|
||||
{ id: 2, name: "Item", status: "pending", parentId: 1, childrenIds: [3] },
|
||||
{ id: 3, name: "Leaf", status: "pending", parentId: 2, childrenIds: [4] },
|
||||
{ id: 4, name: "Too deep", status: "pending", parentId: 3, childrenIds: [] },
|
||||
],
|
||||
nextId: 5,
|
||||
}),
|
||||
/depth must not exceed 3 levels/,
|
||||
);
|
||||
assert.deepEqual(state.snapshot(), before);
|
||||
});
|
||||
|
||||
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/);
|
||||
|
||||
@@ -51,6 +51,72 @@ test("set_tasks schema hoists $defs to the root with #/$defs/ refs", () => {
|
||||
assert.ok(!/"\$ref":"(?!\#)/.test(serialized), "no bare $ref values remain");
|
||||
});
|
||||
|
||||
test("task schemas publish text and batch limits", () => {
|
||||
const { tools } = registeredTools();
|
||||
const setParams = tools.get("set_tasks").parameters;
|
||||
const addTask = setParams.$defs.AddTask;
|
||||
const updateTask = tools.get("update_task").parameters.properties.tasks.items;
|
||||
|
||||
assert.equal(setParams.properties.tasks.maxItems, 50);
|
||||
assert.equal(addTask.properties.children.maxItems, 50);
|
||||
assert.equal(addTask.properties.name.maxLength, 200);
|
||||
assert.match(addTask.properties.name.pattern, /061C/i);
|
||||
assert.equal(addTask.properties.description.anyOf[0].maxLength, 4_000);
|
||||
assert.match(addTask.properties.description.anyOf[0].pattern, /061C/i);
|
||||
assert.equal(tools.get("update_task").parameters.properties.tasks.maxItems, 50);
|
||||
assert.equal(updateTask.properties.name.anyOf[0].maxLength, 200);
|
||||
assert.equal(updateTask.properties.description.anyOf[0].maxLength, 4_000);
|
||||
assert.equal(tools.get("rm_task").parameters.properties.ids.maxItems, 50);
|
||||
assert.equal(tools.get("get_task").parameters.properties.ids.maxItems, 50);
|
||||
|
||||
const setTool = tools.get("set_tasks");
|
||||
const validUnicode = setTool.prepareArguments({
|
||||
mode: "append",
|
||||
tasks: [{ name: "😀".repeat(200), description: "line one\nline two" }],
|
||||
});
|
||||
assert.doesNotThrow(() =>
|
||||
validateToolArguments(setTool, { name: setTool.name, arguments: validUnicode } as never),
|
||||
);
|
||||
const unsafeText = setTool.prepareArguments({ mode: "append", tasks: [{ name: "unsafe\u061cname" }] });
|
||||
assert.throws(
|
||||
() => validateToolArguments(setTool, { name: setTool.name, arguments: unsafeText } as never),
|
||||
/validation failed/i,
|
||||
);
|
||||
});
|
||||
|
||||
test("set_tasks preparation bounds hostile recursive input before validation", () => {
|
||||
const { tools } = registeredTools();
|
||||
const setTool = tools.get("set_tasks");
|
||||
let nested: Record<string, unknown> = { name: "Leaf" };
|
||||
for (let depth = 0; depth < 10_000; depth += 1) nested = { name: `Level ${depth}`, children: [nested] };
|
||||
|
||||
const prepared = setTool.prepareArguments({ mode: "append", tasks: [nested] });
|
||||
assert.throws(
|
||||
() => validateToolArguments(setTool, { name: setTool.name, arguments: prepared } as never),
|
||||
/validation failed/i,
|
||||
);
|
||||
|
||||
const oversized = setTool.prepareArguments({
|
||||
mode: "append",
|
||||
tasks: Array.from({ length: 1_000 }, (_, index) => ({ name: `Task ${index}` })),
|
||||
});
|
||||
assert.equal(oversized.tasks.length, 51);
|
||||
assert.throws(
|
||||
() => validateToolArguments(setTool, { name: setTool.name, arguments: oversized } as never),
|
||||
/validation failed/i,
|
||||
);
|
||||
|
||||
const updateTool = tools.get("update_task");
|
||||
const oversizedUpdate = updateTool.prepareArguments({
|
||||
tasks: Array.from({ length: 1_000 }, (_, index) => ({ id: index + 1 })),
|
||||
});
|
||||
assert.equal(oversizedUpdate.tasks.length, 51);
|
||||
assert.throws(
|
||||
() => validateToolArguments(updateTool, { name: updateTool.name, arguments: oversizedUpdate } as never),
|
||||
/validation failed/i,
|
||||
);
|
||||
});
|
||||
|
||||
test("strict task schemas normalize legacy optional arguments", () => {
|
||||
const { tools } = registeredTools();
|
||||
const setTool = tools.get("set_tasks");
|
||||
|
||||
+138
-10
@@ -12,7 +12,7 @@ import {
|
||||
renderWidgetTree,
|
||||
snapshotStats,
|
||||
} from "../src/ui.ts";
|
||||
import { updateTaskWidget } from "../src/widget.ts";
|
||||
import { TODO_ANIMATION_INTERVAL_MS, updateTaskWidget } from "../src/widget.ts";
|
||||
|
||||
const plainTheme = {
|
||||
fg: (_color: string, text: string) => text,
|
||||
@@ -49,7 +49,7 @@ function fixture(): TaskState {
|
||||
test("renders an adaptive progress bar and stays within width", () => {
|
||||
const stats = fixture().stats();
|
||||
const wide = stripAnsi(renderProgressHeader(stats, plainTheme as never, 40));
|
||||
assert.match(wide, /^ Tasks 1\/5 · !1 ━{4}─{16}$/);
|
||||
assert.match(wide, /^ Todos 1\/5 · !1 ━{4}─{16}$/);
|
||||
assert.ok(visibleWidth(wide) <= 40);
|
||||
|
||||
const narrow = renderProgressHeader(stats, plainTheme as never, 12);
|
||||
@@ -92,11 +92,11 @@ test("collapses completed widget subtrees while keeping list trees expanded", ()
|
||||
|
||||
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",
|
||||
" I. ○ Release",
|
||||
" ├─ 1. ○ Completed branch · 2/2 done",
|
||||
" └─ 2. ○ Open branch",
|
||||
" └─ ○ Open leaf",
|
||||
" II. ○ Pending parent · 1/1 done",
|
||||
]);
|
||||
|
||||
const fullTree = renderTree(state.list(), plainTheme as never, 80).map(stripAnsi);
|
||||
@@ -109,6 +109,125 @@ test("collapses completed widget subtrees while keeping list trees expanded", ()
|
||||
assert.ok(reopened.some((line) => line.includes("Done two")));
|
||||
});
|
||||
|
||||
test("uses sibling-local Roman and numeric widget labels while leaving leaf sub-items unlabeled", () => {
|
||||
const state = new TaskState(() => 0);
|
||||
state.add([
|
||||
{ name: "Category one" },
|
||||
{ name: "Category two" },
|
||||
{ name: "Category three" },
|
||||
{
|
||||
name: "Category four",
|
||||
children: [
|
||||
{ name: "First item", children: [{ name: "Leaf sub-item" }] },
|
||||
{ name: "Second item" },
|
||||
],
|
||||
},
|
||||
{ name: "Category five", children: [{ name: "Reset item numbering" }] },
|
||||
]);
|
||||
|
||||
const lines = renderWidgetTree(state.list(), plainTheme as never, 80).map(stripAnsi);
|
||||
assert.deepEqual(lines, [
|
||||
" I. ○ Category one",
|
||||
" II. ○ Category two",
|
||||
" III. ○ Category three",
|
||||
" IV. ○ Category four",
|
||||
" ├─ 1. ○ First item",
|
||||
" │ └─ ○ Leaf sub-item",
|
||||
" └─ 2. ○ Second item",
|
||||
" V. ○ Category five",
|
||||
" └─ 1. ○ Reset item numbering",
|
||||
]);
|
||||
|
||||
const narrow = renderWidgetTree(state.list(), plainTheme as never, 18);
|
||||
assert.ok(narrow.every((line) => visibleWidth(line) <= 18));
|
||||
|
||||
const statusPriority = renderWidgetTree(state.list(), plainTheme as never, 6).map(stripAnsi);
|
||||
assert.match(statusPriority[3] ?? "", /○/);
|
||||
const childStatusPriority = renderWidgetTree(state.list(), plainTheme as never, 8).map(stripAnsi);
|
||||
assert.match(childStatusPriority[4] ?? "", /○/);
|
||||
|
||||
const timedState = new TaskState(() => 0);
|
||||
timedState.add([
|
||||
{ name: "Timed category", status: "in-progress", children: [{ name: "Timed item", status: "in-progress" }] },
|
||||
]);
|
||||
const timedRoot = stripAnsi(renderWidgetTree(timedState.list(), plainTheme as never, 15)[0] ?? "");
|
||||
const timedChild = stripAnsi(renderWidgetTree(timedState.list(), plainTheme as never, 18)[1] ?? "");
|
||||
assert.match(timedRoot, /◉/);
|
||||
assert.match(timedChild, /◉/);
|
||||
});
|
||||
|
||||
test("animates only in-progress widget circles while preserving narrow-width status markers", () => {
|
||||
const state = new TaskState(() => 0);
|
||||
state.add([
|
||||
{ name: "Active", status: "in-progress" },
|
||||
{ name: "Pending" },
|
||||
{ name: "Blocked", status: "blocked" },
|
||||
{ name: "Done", status: "done" },
|
||||
]);
|
||||
|
||||
const orbitFrames = ["◐", "◓", "◑", "◒"];
|
||||
for (const [frame, icon] of orbitFrames.entries()) {
|
||||
const lines = renderWidgetTree(state.list(), plainTheme as never, 80, " ", frame).map(stripAnsi);
|
||||
assert.match(lines[0] ?? "", new RegExp(icon));
|
||||
assert.match(lines[1] ?? "", /○/);
|
||||
assert.match(lines[2] ?? "", /!/);
|
||||
assert.match(lines[3] ?? "", /✓/);
|
||||
|
||||
const narrow = renderWidgetTree(state.list(), plainTheme as never, 6, " ", frame).map(stripAnsi);
|
||||
assert.match(narrow[0] ?? "", new RegExp(icon));
|
||||
assert.ok(narrow.every((line) => visibleWidth(line) <= 6));
|
||||
}
|
||||
|
||||
assert.match(stripAnsi(renderTree(state.list(), plainTheme as never, 80)[0] ?? ""), /◉/);
|
||||
});
|
||||
|
||||
test("renumbers widget labels after task reordering and reparenting", () => {
|
||||
const state = new TaskState(() => 0);
|
||||
state.add([
|
||||
{ name: "Category A", children: [{ name: "A1" }, { name: "A2" }] },
|
||||
{ name: "Category B", children: [{ name: "B1" }] },
|
||||
]);
|
||||
state.update([
|
||||
{ id: 4, position: 0 },
|
||||
{ id: 3, parentId: 4, position: 0 },
|
||||
]);
|
||||
|
||||
const lines = renderWidgetTree(state.list(), plainTheme as never, 80).map(stripAnsi);
|
||||
assert.deepEqual(lines, [
|
||||
" I. ○ Category B",
|
||||
" ├─ 1. ○ A2",
|
||||
" └─ 2. ○ B1",
|
||||
" II. ○ Category A",
|
||||
" └─ 1. ○ A1",
|
||||
]);
|
||||
});
|
||||
|
||||
test("caps the widget at twelve task rows with an honest overflow summary", () => {
|
||||
const state = new TaskState(() => 0);
|
||||
state.add(Array.from({ length: 20 }, (_, index) => ({ name: `Task ${index + 1}` })));
|
||||
|
||||
const lines = renderWidgetTree(state.list(), plainTheme as never, 60).map(stripAnsi);
|
||||
assert.equal(lines.length, 13);
|
||||
assert.match(lines[0] ?? "", /Task 1/);
|
||||
assert.match(lines[11] ?? "", /Task 12/);
|
||||
assert.equal(lines[12], " … 8 more tasks");
|
||||
assert.ok(lines.every((line) => visibleWidth(line) <= 60));
|
||||
|
||||
const collapsedOverflow = new TaskState(() => 0);
|
||||
collapsedOverflow.add([
|
||||
...Array.from({ length: 12 }, (_, index) => ({ name: `Visible ${index + 1}` })),
|
||||
{
|
||||
name: "Hidden completed branch",
|
||||
children: [
|
||||
{ name: "Hidden done one", status: "done" as const },
|
||||
{ name: "Hidden done two", status: "done" as const },
|
||||
],
|
||||
},
|
||||
]);
|
||||
const overflowLines = renderWidgetTree(collapsedOverflow.list(), plainTheme as never, 60).map(stripAnsi);
|
||||
assert.equal(overflowLines[12], " … 3 more tasks");
|
||||
});
|
||||
|
||||
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" }] }]);
|
||||
@@ -234,6 +353,10 @@ test("hides an empty widget and renders a populated one", () => {
|
||||
assert.ok(lines.every((line) => visibleWidth(line) <= 30));
|
||||
});
|
||||
|
||||
test("animates active todos twice per second", () => {
|
||||
assert.equal(TODO_ANIMATION_INTERVAL_MS, 500);
|
||||
});
|
||||
|
||||
test("live widgets request rerenders and dispose their interval", async () => {
|
||||
let now = 0;
|
||||
const state = new TaskState(() => now);
|
||||
@@ -250,7 +373,9 @@ test("live widgets request rerenders and dispose their interval", async () => {
|
||||
updateTaskWidget(state, ctx as never, true, 5);
|
||||
|
||||
let renderRequests = 0;
|
||||
const component = (
|
||||
const observedFrames = new Set<string>();
|
||||
let component: { render(width: number): string[]; dispose?(): void };
|
||||
component = (
|
||||
widget as unknown as (
|
||||
tui: { requestRender(): void },
|
||||
theme: unknown,
|
||||
@@ -259,17 +384,20 @@ test("live widgets request rerenders and dispose their interval", async () => {
|
||||
{
|
||||
requestRender() {
|
||||
renderRequests += 1;
|
||||
const frame = stripAnsi(component.render(40)[1] ?? "").match(/[◐◓◑◒]/)?.[0];
|
||||
if (frame !== undefined) observedFrames.add(frame);
|
||||
},
|
||||
},
|
||||
plainTheme,
|
||||
);
|
||||
|
||||
try {
|
||||
assert.match(stripAnsi(component.render(40)[1] ?? ""), / · 00:00$/);
|
||||
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$/);
|
||||
assert.ok(observedFrames.size > 0);
|
||||
assert.match(stripAnsi(component.render(40)[1] ?? ""), /[◐◓◑◒].* · 01:05$/);
|
||||
} finally {
|
||||
component.dispose?.();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user