diff --git a/README.md b/README.md index 6e759f2..7e9f4c7 100644 --- a/README.md +++ b/README.md @@ -107,13 +107,13 @@ Tool calls remain compact in the conversation. Expand a tool result with Pi's no ## Herdr Integration -When Pi runs inside Herdr, the extension reports the most specific `in-progress` task as a `task` pane metadata token. If nothing is in progress, it falls back to a blocked task. Parallel current tasks use a separate `task_count` token so Herdr can truncate long task names responsively while keeping the remaining count visible, for example `#7 Implement auth · +2`. +When Pi runs inside Herdr, the extension reports the most specific `in-progress` task as a `task` pane metadata token. If nothing is in progress, it falls back to a blocked task. Parallel current tasks use a separate `task_count` token so Herdr can truncate long task names responsively while keeping the remaining count visible, for example `#7 Implement auth · +2`. The `task_progress` token reports aggregate completion as `done/total`, including `N/N` when all tasks are complete. -Add `$task` and `$task_count` to the same Herdr agent sidebar row in `~/.config/herdr/config.toml`: +Add `$task`, `$task_count`, and `$task_progress` to the Herdr agent sidebar in `~/.config/herdr/config.toml`: ```toml [ui.sidebar.agents] -rows = [["state_icon", "workspace", "tab"], ["agent", "$ask", "$ask_count"], ["$task", "$task_count"]] +rows = [["state_icon", "workspace", "tab"], ["$ask", "$ask_count"], ["$task_progress", "$task", "$task_count"]] ``` Apply the change to a running Herdr server: diff --git a/src/herdr.ts b/src/herdr.ts index f78bfe9..c9a6259 100644 --- a/src/herdr.ts +++ b/src/herdr.ts @@ -1,10 +1,11 @@ import net from "node:net"; import type { Task } from "./types.ts"; -import { orderedTreeRows } from "./ui.ts"; +import { orderedTreeRows, snapshotStats } from "./ui.ts"; const SOURCE = "pi-todo-herdr"; const TASK_TOKEN = "task"; const COUNT_TOKEN = "task_count"; +const PROGRESS_TOKEN = "task_progress"; let reportSeq = Date.now() * 1000; function nextSeq(): number { @@ -27,6 +28,7 @@ function hasDescendantWithStatus(task: Task, status: Task["status"], byId: Map { const byId = new Map(tasks.map((task) => [task.id, task])); + const stats = snapshotStats(tasks); const ordered = orderedTreeRows(tasks).map(({ task }) => task); const inProgress = ordered.filter( (task) => task.status === "in-progress" && !hasDescendantWithStatus(task, "in-progress", byId), @@ -38,6 +40,7 @@ export function currentTaskTokens(tasks: Task[]): Record return { [TASK_TOKEN]: candidates.length > 0 ? `#${candidates[0]!.id} ${candidates[0]!.name}` : null, [COUNT_TOKEN]: candidates.length > 1 ? `+${candidates.length - 1}` : null, + [PROGRESS_TOKEN]: stats.total > 0 ? `${stats.done}/${stats.total}` : null, }; } @@ -97,5 +100,5 @@ export function reportCurrentTask(tasks: Task[]): Promise { } export function clearCurrentTask(): Promise { - return reportTokens({ [TASK_TOKEN]: null, [COUNT_TOKEN]: null }); + return reportTokens({ [TASK_TOKEN]: null, [COUNT_TOKEN]: null, [PROGRESS_TOKEN]: null }); } diff --git a/tests/ui.test.ts b/tests/ui.test.ts index 11a5dec..894b4d5 100644 --- a/tests/ui.test.ts +++ b/tests/ui.test.ts @@ -122,7 +122,11 @@ test("selects the most specific in-progress task and compresses parallel leaves" { name: "Parallel", description: "Parallel", status: "in-progress" }, ]); assert.equal(currentTaskLabel(state.list()), "#2 Child +1"); - assert.deepEqual(currentTaskTokens(state.list()), { task: "#2 Child", task_count: "+1" }); + assert.deepEqual(currentTaskTokens(state.list()), { + task: "#2 Child", + task_count: "+1", + task_progress: "0/3", + }); }); test("uses blocked tasks only when nothing is in progress", () => { @@ -132,10 +136,26 @@ test("uses blocked tasks only when nothing is in progress", () => { { name: "Blocked two", description: "Two", status: "blocked" }, ]); assert.equal(currentTaskLabel(state.list()), "#1 Blocked one +1"); - assert.deepEqual(currentTaskTokens(state.list()), { task: "#1 Blocked one", task_count: "+1" }); + assert.deepEqual(currentTaskTokens(state.list()), { + task: "#1 Blocked one", + task_count: "+1", + task_progress: "0/2", + }); state.update([{ id: 1, status: "done" }, { id: 2, status: "done" }]); assert.equal(currentTaskLabel(state.list()), null); - assert.deepEqual(currentTaskTokens(state.list()), { task: null, task_count: null }); + assert.deepEqual(currentTaskTokens(state.list()), { + task: null, + task_count: null, + task_progress: "2/2", + }); +}); + +test("clears sidebar progress for an empty task tree", () => { + assert.deepEqual(currentTaskTokens([]), { + task: null, + task_count: null, + task_progress: null, + }); }); test("hides an empty widget and renders a populated one", () => {