Compare commits

..
4 Commits
Author SHA1 Message Date
jb 1a437f50b4 Rename task widget to todos 2026-08-26 16:40:05 -04:00
jb b654c3c81b Animate active task indicators 2026-08-26 16:13:58 -04:00
jb 01b5f3ddee Limit and label task hierarchy 2026-08-26 15:58:40 -04:00
jb ae0ca47236 Harden task state and terminal rendering 2026-08-26 15:13:19 -04:00
13 changed files with 726 additions and 92 deletions
+1
View File
@@ -1,3 +1,4 @@
node_modules/
*.tgz
.DS_Store
.pi/
+12 -6
View File
@@ -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
+5 -5
View File
@@ -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
View File
@@ -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"
},
+21
View File
@@ -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
View File
@@ -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
View File
@@ -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,
+94 -24
View File
@@ -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
View File
@@ -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 {
+32
View File
@@ -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");
+131
View File
@@ -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/);
+66
View File
@@ -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
View File
@@ -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?.();
}