Rename task widget to todos

This commit is contained in:
jb
2026-08-26 16:40:05 -04:00
parent b654c3c81b
commit 1a437f50b4
6 changed files with 48 additions and 12 deletions
+2 -2
View File
@@ -98,10 +98,10 @@ A task starts timing the first time it enters `in-progress`. Leaving that state
## Pi Widget
The widget appears above Pi's editor while tasks exist. Run `/tasks` 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 `◐ ◓ ◑ ◒` on the existing one-second timer refresh; 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. 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 ━━━━━────────
Todos 2/6 · !1 ━━━━━────────
I. ◐ Build auth · 12:08
├─ 1. ! Resolve token issue · 03:41
│ └─ ○ Inspect provider response
+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");
}
},
});
+1 -1
View File
@@ -210,7 +210,7 @@ export function renderWidgetTree(
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 {
+2 -2
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) {
+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");
+6 -2
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);
@@ -353,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);