From fbe8baf18cf4b875754607d55a475266c994fa7b Mon Sep 17 00:00:00 2001 From: JB Date: Wed, 26 Aug 2026 18:07:46 -0400 Subject: [PATCH] Auto-clear completed todo trees --- README.md | 2 +- index.ts | 32 ++++++++++-- src/auto-clear.ts | 49 ++++++++++++++++++ src/state.ts | 18 ++++--- src/tools.ts | 8 +-- tests/auto-clear.test.ts | 107 +++++++++++++++++++++++++++++++++++++++ tests/prompt.test.ts | 50 ++++++++++++++++++ tests/state.test.ts | 26 +++++++++- tests/tools.test.ts | 8 ++- 9 files changed, 283 insertions(+), 17 deletions(-) create mode 100644 src/auto-clear.ts create mode 100644 tests/auto-clear.test.ts diff --git a/README.md b/README.md index 98bc9fe..f21cd66 100644 --- a/README.md +++ b/README.md @@ -98,7 +98,7 @@ A task starts timing the first time it enters `in-progress`. Leaving that state ## Pi Widget -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, with connectors extending from it through every top-level task so the display always remains a tree. The outline uses only the labels needed by the deepest task: one-level trees render every task as an unlabeled leaf, two-level trees use numbered parents and unlabeled leaves, and three-level trees use Roman-numeral categories, numbered items, and unlabeled leaves. 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. +The widget appears above Pi's editor while todos exist. Run `/todo` to hide or show it for the current Pi runtime. Ten seconds after every task is completed, the task tree is automatically deleted and task IDs reset to `1`; adding or reopening unfinished work during that delay cancels the clear. Its header is the virtual task-tree root, with connectors extending from it through every top-level task so the display always remains a tree. The outline uses only the labels needed by the deepest task: one-level trees render every task as an unlabeled leaf, two-level trees use numbered parents and unlabeled leaves, and three-level trees use Roman-numeral categories, numbered items, and unlabeled leaves. 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 Todos 2/6 · !1 ━━━━━──────── diff --git a/index.ts b/index.ts index 4a65896..37072c6 100644 --- a/index.ts +++ b/index.ts @@ -1,31 +1,57 @@ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"; +import { createCompletedTaskAutoClear } from "./src/auto-clear.ts"; import { clearCurrentTask, reportCurrentTask } from "./src/herdr.ts"; import { COMPACTION_REMINDER, COMPACTION_REMINDER_TYPE, hasUnfinishedTasks, } from "./src/prompt.ts"; -import { TaskState } from "./src/state.ts"; +import { TASK_STATE_ENTRY_TYPE, TaskState } from "./src/state.ts"; import { registerTaskTools } from "./src/tools.ts"; import { clearTaskWidget, updateTaskWidget } from "./src/widget.ts"; export default function piTodoHerdr(pi: ExtensionAPI): void { const state = new TaskState(); let widgetVisible = true; + let activeContext: ExtensionContext | undefined; - const refresh = (ctx: ExtensionContext) => { + const autoClear = createCompletedTaskAutoClear(state, () => { + try { + pi.appendEntry(TASK_STATE_ENTRY_TYPE, { tasks: [], nextId: 1 }); + } catch (error) { + console.error("[pi-todo-herdr] failed to persist automatic task clear; retrying:", error); + return false; + } + + state.set("replace", [], false); + if (activeContext !== undefined) { + try { + updateTaskWidget(state, activeContext, widgetVisible); + void reportCurrentTask(state.list()); + } catch (error) { + console.error("[pi-todo-herdr] failed to refresh task UI after automatic clear:", error); + } + } + return true; + }); + + const refresh = (ctx: ExtensionContext, restartAutoClear = false) => { + activeContext = ctx; updateTaskWidget(state, ctx, widgetVisible); void reportCurrentTask(state.list()); + autoClear.sync(restartAutoClear); }; const restore = (ctx: ExtensionContext) => { state.loadFromSession(ctx); - refresh(ctx); + refresh(ctx, true); }; pi.on("session_start", async (_event, ctx) => restore(ctx)); pi.on("session_tree", async (_event, ctx) => restore(ctx)); pi.on("session_shutdown", async (event) => { + autoClear.cancel(); + activeContext = undefined; if (event.reason === "quit") await clearCurrentTask(); }); pi.on("session_compact", () => { diff --git a/src/auto-clear.ts b/src/auto-clear.ts new file mode 100644 index 0000000..5e9e5fa --- /dev/null +++ b/src/auto-clear.ts @@ -0,0 +1,49 @@ +import type { TaskState } from "./state.ts"; + +export const TODO_AUTO_CLEAR_DELAY_MS = 10_000; + +export interface CompletedTaskAutoClear { + sync(forceRestart?: boolean): void; + cancel(): void; +} + +export function createCompletedTaskAutoClear( + state: TaskState, + tryClear: () => boolean, + delayMs = TODO_AUTO_CLEAR_DELAY_MS, +): CompletedTaskAutoClear { + let timer: ReturnType | undefined; + let scheduledKey: string | undefined; + + const completionKey = () => { + const tasks = state.list(); + if (tasks.length === 0 || tasks.some((task) => task.status !== "done")) return undefined; + return tasks.map((task) => `${task.id}:${task.status}`).join("|"); + }; + + const cancel = () => { + if (timer !== undefined) clearTimeout(timer); + timer = undefined; + scheduledKey = undefined; + }; + + const sync = (forceRestart = false) => { + const nextKey = completionKey(); + if (nextKey === undefined) { + cancel(); + return; + } + if (!forceRestart && timer !== undefined && scheduledKey === nextKey) return; + + cancel(); + scheduledKey = nextKey; + timer = setTimeout(() => { + timer = undefined; + if (completionKey() !== scheduledKey) return; + if (!tryClear()) sync(); + }, Math.max(0, delayMs)); + timer.unref?.(); + }; + + return { sync, cancel }; +} diff --git a/src/state.ts b/src/state.ts index ec0bcac..b7640da 100644 --- a/src/state.ts +++ b/src/state.ts @@ -21,6 +21,7 @@ import type { import { TaskValidationError } from "./types.ts"; const MUTATION_TOOLS = new Set(["add_task", "set_tasks", "update_task", "rm_task"]); +export const TASK_STATE_ENTRY_TYPE = "pi-todo-herdr-state"; const VALID_STATUSES = new Set(["pending", "in-progress", "blocked", "done"]); const MAX_DATE_MS = 8_640_000_000_000_000; @@ -661,13 +662,18 @@ export class TaskState { this.records = []; this.nextId = 1; for (const entry of ctx.sessionManager.getBranch()) { - if (entry.type !== "message") continue; - const message = entry.message; - if (message.role !== "toolResult" || !MUTATION_TOOLS.has(message.toolName)) continue; - const details = message.details as TaskToolDetails | undefined; - if (!details?.snapshot) continue; + let snapshot: TaskSnapshot | undefined; + if (entry.type === "custom" && entry.customType === TASK_STATE_ENTRY_TYPE) { + snapshot = entry.data as TaskSnapshot | undefined; + } else if (entry.type === "message") { + const message = entry.message; + if (message.role === "toolResult" && MUTATION_TOOLS.has(message.toolName)) { + snapshot = (message.details as TaskToolDetails | undefined)?.snapshot; + } + } + if (!snapshot) continue; try { - this.restore(details.snapshot); + this.restore(snapshot); } catch { // Ignore corrupt historical snapshots and keep the latest valid state. } diff --git a/src/tools.ts b/src/tools.ts index 131b2bd..2e460a9 100644 --- a/src/tools.ts +++ b/src/tools.ts @@ -286,7 +286,7 @@ interface RemoveTaskInput extends IdsInput { reason: "mistaken" | "duplicate" | "canceled"; } -type ChangeHandler = (ctx: ExtensionContext) => void; +type ChangeHandler = (ctx: ExtensionContext, restartAutoClear?: boolean) => void; function taskIds(tasks: Task[]): string { return tasks.map((task) => task.id).join(", "); @@ -323,9 +323,9 @@ function renderCall(title: string, count: number | undefined, theme: Theme): Tex return new Text(theme.fg("toolTitle", theme.bold(title)) + suffix, 0, 0); } -function notifyChange(onChange: ChangeHandler, ctx: ExtensionContext): void { +function notifyChange(onChange: ChangeHandler, ctx: ExtensionContext, restartAutoClear = false): void { try { - onChange(ctx); + onChange(ctx, restartAutoClear); } catch (error) { console.error("[pi-todo-herdr] failed to refresh task UI:", error); } @@ -343,7 +343,7 @@ export function registerTaskTools(pi: ExtensionAPI, state: TaskState, onChange: async execute(_toolCallId, params: SetTaskInput, _signal, _onUpdate, ctx) { try { const affected = state.set(params.mode, params.tasks, params.force); - notifyChange(onChange, ctx); + notifyChange(onChange, ctx, params.mode === "replace"); return { content: [{ type: "text" as const, text: setTaskText(params.mode, affected, state) }], details: details(params.mode, state, affected), diff --git a/tests/auto-clear.test.ts b/tests/auto-clear.test.ts new file mode 100644 index 0000000..7981ae8 --- /dev/null +++ b/tests/auto-clear.test.ts @@ -0,0 +1,107 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createCompletedTaskAutoClear, TODO_AUTO_CLEAR_DELAY_MS } from "../src/auto-clear.ts"; +import { TaskState } from "../src/state.ts"; + +function clearState(state: TaskState, onClear: () => void): boolean { + state.set("replace", [], false); + onClear(); + return true; +} + +test("clears the task tree ten seconds after every task remains done", (context) => { + context.mock.timers.enable({ apis: ["setTimeout"] }); + const state = new TaskState(); + state.add([{ name: "Done", status: "done" }]); + let clearCount = 0; + const autoClear = createCompletedTaskAutoClear(state, () => clearState(state, () => { + clearCount += 1; + })); + + autoClear.sync(); + context.mock.timers.tick(TODO_AUTO_CLEAR_DELAY_MS - 1); + assert.equal(state.stats().total, 1); + + context.mock.timers.tick(1); + assert.deepEqual(state.snapshot(), { tasks: [], nextId: 1 }); + assert.equal(clearCount, 1); +}); + +test("cancels a pending clear when unfinished work appears", (context) => { + context.mock.timers.enable({ apis: ["setTimeout"] }); + const state = new TaskState(); + state.add([{ name: "Done", status: "done" }]); + let clearCount = 0; + const autoClear = createCompletedTaskAutoClear(state, () => clearState(state, () => { + clearCount += 1; + })); + + autoClear.sync(); + state.add([{ name: "New work" }]); + autoClear.sync(); + context.mock.timers.tick(TODO_AUTO_CLEAR_DELAY_MS); + + assert.equal(state.stats().total, 2); + assert.equal(clearCount, 0); +}); + +test("preserves the deadline for unrelated refreshes", (context) => { + context.mock.timers.enable({ apis: ["setTimeout"] }); + const state = new TaskState(); + state.add([{ name: "Done", status: "done" }]); + const autoClear = createCompletedTaskAutoClear(state, () => clearState(state, () => {})); + + autoClear.sync(); + context.mock.timers.tick(9_000); + state.update([{ id: 1, name: "Renamed" }]); + autoClear.sync(); + context.mock.timers.tick(1_000); + + assert.equal(state.stats().total, 0); +}); + +test("restarts the delay for changed or restored completed trees", (context) => { + context.mock.timers.enable({ apis: ["setTimeout"] }); + const state = new TaskState(); + state.add([{ name: "Done", status: "done" }]); + const autoClear = createCompletedTaskAutoClear(state, () => clearState(state, () => {})); + + autoClear.sync(); + context.mock.timers.tick(9_000); + state.add([{ name: "Also done", status: "done" }]); + autoClear.sync(); + context.mock.timers.tick(1_000); + assert.equal(state.stats().total, 2); + + context.mock.timers.tick(8_000); + autoClear.sync(true); + context.mock.timers.tick(2_000); + assert.equal(state.stats().total, 2); + + context.mock.timers.tick(8_000); + assert.equal(state.stats().total, 0); +}); + +test("retries after a clear transaction fails and supports shutdown cancellation", (context) => { + context.mock.timers.enable({ apis: ["setTimeout"] }); + const state = new TaskState(); + state.add([{ name: "Done", status: "done" }]); + let attempts = 0; + const autoClear = createCompletedTaskAutoClear(state, () => { + attempts += 1; + if (attempts === 1) return false; + return clearState(state, () => {}); + }); + + autoClear.sync(); + context.mock.timers.tick(TODO_AUTO_CLEAR_DELAY_MS); + assert.equal(state.stats().total, 1); + context.mock.timers.tick(TODO_AUTO_CLEAR_DELAY_MS); + assert.equal(state.stats().total, 0); + + state.add([{ name: "Done again", status: "done" }]); + autoClear.sync(); + autoClear.cancel(); + context.mock.timers.tick(TODO_AUTO_CLEAR_DELAY_MS); + assert.equal(state.stats().total, 1); +}); diff --git a/tests/prompt.test.ts b/tests/prompt.test.ts index a9fbc16..6584063 100644 --- a/tests/prompt.test.ts +++ b/tests/prompt.test.ts @@ -42,6 +42,56 @@ test("registers the conflict-free /todo command", async () => { assert.deepEqual(notifications, ["No todos yet."]); }); +test("persists automatic clears before deletion, retries failures, and cancels on shutdown", async (context) => { + context.mock.timers.enable({ apis: ["setTimeout"] }); + context.mock.method(console, "error", () => {}); + const handlers = new Map unknown>(); + const tools = new Map(); + const entries: Array<{ customType: string; data: unknown }> = []; + let persistAttempts = 0; + const pi = { + on(name: string, handler: (event: any, ctx: any) => unknown) { + handlers.set(name, handler); + }, + registerTool(tool: any) { + tools.set(tool.name, tool); + }, + registerCommand() {}, + appendEntry(customType: string, data: unknown) { + persistAttempts += 1; + if (persistAttempts === 1) throw new Error("disk unavailable"); + entries.push({ customType, data }); + }, + }; + const ctx = { ui: { setWidget() {} } }; + piTodoHerdr(pi as never); + + await tools.get("set_tasks").execute( + "set", + { mode: "append", tasks: [{ name: "Done", status: "done" }], force: false }, + undefined, + undefined, + ctx, + ); + context.mock.timers.tick(10_000); + assert.equal((await tools.get("list_task").execute()).content[0].text, "1 [done] Done"); + + context.mock.timers.tick(10_000); + assert.equal((await tools.get("list_task").execute()).content[0].text, "No tasks."); + assert.deepEqual(entries, [{ customType: "pi-todo-herdr-state", data: { tasks: [], nextId: 1 } }]); + + await tools.get("set_tasks").execute( + "set", + { mode: "append", tasks: [{ name: "Done again", status: "done" }], force: false }, + undefined, + undefined, + ctx, + ); + await handlers.get("session_shutdown")?.({ reason: "reload" }, ctx); + context.mock.timers.tick(10_000); + assert.equal((await tools.get("list_task").execute()).content[0].text, "1 [done] Done again"); +}); + test("keeps task guidance short with planning after exploration", () => { assert.equal(TASK_PROMPT_GUIDELINES.length, 2); const prompt = TASK_PROMPT_GUIDELINES.join("\n"); diff --git a/tests/state.test.ts b/tests/state.test.ts index 0284812..e282396 100644 --- a/tests/state.test.ts +++ b/tests/state.test.ts @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { TaskState } from "../src/state.ts"; +import { TASK_STATE_ENTRY_TYPE, TaskState } from "../src/state.ts"; import { TaskValidationError } from "../src/types.ts"; function names(state: TaskState): string[] { @@ -362,6 +362,30 @@ test("loads the latest valid mutation snapshot from a session branch", () => { assert.deepEqual(state.list().map((task) => task.name), ["First", "Second"]); }); +test("loads a persisted automatic clear after task tool snapshots", () => { + const completed = new TaskState(); + completed.add([{ name: "Completed", status: "done" }]); + const branch = [ + { + type: "message", + message: { + role: "toolResult", + toolName: "update_task", + details: { action: "update", snapshot: completed.snapshot(), affected: [] }, + }, + }, + { + type: "custom", + customType: TASK_STATE_ENTRY_TYPE, + data: { tasks: [], nextId: 1 }, + }, + ]; + + const state = new TaskState(); + state.loadFromSession({ sessionManager: { getBranch: () => branch } } as never); + assert.deepEqual(state.snapshot(), { tasks: [], nextId: 1 }); +}); + test("rejects terminal controls and bidirectional overrides without changing state", () => { const state = new TaskState(); state.add([{ name: "Safe", description: "Line one\nLine two" }]); diff --git a/tests/tools.test.ts b/tests/tools.test.ts index d97a448..ede2c41 100644 --- a/tests/tools.test.ts +++ b/tests/tools.test.ts @@ -7,15 +7,17 @@ import { registerTaskTools } from "../src/tools.ts"; function registeredTools(state = new TaskState()) { const tools = new Map(); let changes = 0; + const autoClearRestarts: boolean[] = []; const pi = { registerTool(tool: any) { tools.set(tool.name, tool); }, }; - registerTaskTools(pi as never, state, () => { + registerTaskTools(pi as never, state, (_ctx, restartAutoClear = false) => { changes += 1; + autoClearRestarts.push(restartAutoClear); }); - return { tools, state, changes: () => changes }; + return { tools, state, changes: () => changes, autoClearRestarts }; } async function execute(tool: any, params: unknown) { @@ -209,6 +211,7 @@ test("set_tasks appends optional-description tasks and persists a complete snaps assert.equal(result.details.snapshot.tasks[0].description, undefined); assert.equal(result.details.snapshot.nextId, 3); assert.equal(harness.changes(), 1); + assert.deepEqual(harness.autoClearRestarts, [false]); }); test("set_tasks atomically replaces or clears the tree and resets IDs", async () => { @@ -232,6 +235,7 @@ test("set_tasks atomically replaces or clears the tree and resets IDs", async () }); assert.equal(cleared.content[0].text, "Cleared task tree. Task IDs reset to 1."); assert.deepEqual(harness.state.snapshot(), { tasks: [], nextId: 1 }); + assert.deepEqual(harness.autoClearRestarts, [false, true, true]); }); test("tool validation errors preserve atomic state", async () => {