Auto-clear completed todo trees
This commit is contained in:
@@ -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 ━━━━━────────
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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<typeof setTimeout> | 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 };
|
||||
}
|
||||
+12
-6
@@ -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<TaskStatus>(["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.
|
||||
}
|
||||
|
||||
+4
-4
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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<string, (event: any, ctx: any) => unknown>();
|
||||
const tools = new Map<string, any>();
|
||||
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");
|
||||
|
||||
+25
-1
@@ -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" }]);
|
||||
|
||||
+6
-2
@@ -7,15 +7,17 @@ import { registerTaskTools } from "../src/tools.ts";
|
||||
function registeredTools(state = new TaskState()) {
|
||||
const tools = new Map<string, any>();
|
||||
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 () => {
|
||||
|
||||
Reference in New Issue
Block a user