From fe05b278a4ddc4e08ccda74da8e40367c9d76da4 Mon Sep 17 00:00:00 2001 From: lesetong Date: Thu, 23 Jul 2026 00:01:01 +0800 Subject: [PATCH] feat: preserve Herdr task counts --- README.md | 6 +++--- package-lock.json | 4 ++-- package.json | 2 +- src/herdr.ts | 27 ++++++++++++++++++--------- tests/ui.test.ts | 5 ++++- 5 files changed, 28 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index ef41c43..4bd4130 100644 --- a/README.md +++ b/README.md @@ -93,13 +93,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 are compressed as `#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`. -Add `$task` to the Herdr agent sidebar rows in `~/.config/herdr/config.toml`: +Add `$task` and `$task_count` to the same Herdr agent sidebar row in `~/.config/herdr/config.toml`: ```toml [ui.sidebar.agents] -rows = [["state_icon", "workspace", "tab"], ["agent", "$ask"], ["$task"]] +rows = [["state_icon", "workspace", "tab"], ["agent", "$ask", "$ask_count"], ["$task", "$task_count"]] ``` Apply the change to a running Herdr server: diff --git a/package-lock.json b/package-lock.json index 65a1fe1..7b10285 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "pi-todo-herdr", - "version": "0.1.0", + "version": "0.1.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pi-todo-herdr", - "version": "0.1.0", + "version": "0.1.1", "license": "MIT", "devDependencies": { "@earendil-works/pi-ai": "^0.81.1", diff --git a/package.json b/package.json index 624de4c..341987c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "pi-todo-herdr", - "version": "0.1.0", + "version": "0.1.1", "description": "Hierarchical task tools for Pi with a live widget and Herdr sidebar integration.", "type": "module", "main": "./index.ts", diff --git a/src/herdr.ts b/src/herdr.ts index ebe80e0..f78bfe9 100644 --- a/src/herdr.ts +++ b/src/herdr.ts @@ -3,7 +3,8 @@ import type { Task } from "./types.ts"; import { orderedTreeRows } from "./ui.ts"; const SOURCE = "pi-todo-herdr"; -const TOKEN = "task"; +const TASK_TOKEN = "task"; +const COUNT_TOKEN = "task_count"; let reportSeq = Date.now() * 1000; function nextSeq(): number { @@ -24,7 +25,7 @@ function hasDescendantWithStatus(task: Task, status: Task["status"], byId: Map { const byId = new Map(tasks.map((task) => [task.id, task])); const ordered = orderedTreeRows(tasks).map(({ task }) => task); const inProgress = ordered.filter( @@ -34,9 +35,17 @@ export function currentTaskLabel(tasks: Task[]): string | null { inProgress.length > 0 ? inProgress : ordered.filter((task) => task.status === "blocked" && !hasDescendantWithStatus(task, "blocked", byId)); - if (candidates.length === 0) return null; - const suffix = candidates.length > 1 ? ` +${candidates.length - 1}` : ""; - return `#${candidates[0]!.id} ${candidates[0]!.name}${suffix}`; + return { + [TASK_TOKEN]: candidates.length > 0 ? `#${candidates[0]!.id} ${candidates[0]!.name}` : null, + [COUNT_TOKEN]: candidates.length > 1 ? `+${candidates.length - 1}` : null, + }; +} + +export function currentTaskLabel(tasks: Task[]): string | null { + const tokens = currentTaskTokens(tasks); + const task = tokens[TASK_TOKEN]; + const count = tokens[COUNT_TOKEN]; + return task ? `${task}${count ? ` ${count}` : ""}` : null; } function sendAttempt(request: unknown, timeoutMs: number): Promise { @@ -69,7 +78,7 @@ async function send(request: unknown): Promise { await sendAttempt(request, 1500); } -async function reportToken(value: string | null): Promise { +async function reportTokens(tokens: Record): Promise { if (!isHerdrEnvironment()) return; await send({ id: `${SOURCE}:${Date.now()}:${Math.random().toString(36).slice(2)}`, @@ -77,16 +86,16 @@ async function reportToken(value: string | null): Promise { params: { pane_id: process.env.HERDR_PANE_ID, source: SOURCE, - tokens: { [TOKEN]: value }, + tokens, seq: nextSeq(), }, }); } export function reportCurrentTask(tasks: Task[]): Promise { - return reportToken(currentTaskLabel(tasks)); + return reportTokens(currentTaskTokens(tasks)); } export function clearCurrentTask(): Promise { - return reportToken(null); + return reportTokens({ [TASK_TOKEN]: null, [COUNT_TOKEN]: null }); } diff --git a/tests/ui.test.ts b/tests/ui.test.ts index 2da4e23..fe3c1e1 100644 --- a/tests/ui.test.ts +++ b/tests/ui.test.ts @@ -1,7 +1,7 @@ import assert from "node:assert/strict"; import test from "node:test"; import { visibleWidth } from "@earendil-works/pi-tui"; -import { currentTaskLabel } from "../src/herdr.ts"; +import { currentTaskLabel, currentTaskTokens } from "../src/herdr.ts"; import { TaskState } from "../src/state.ts"; import { renderProgressHeader, @@ -106,6 +106,7 @@ 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" }); }); test("uses blocked tasks only when nothing is in progress", () => { @@ -115,8 +116,10 @@ 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" }); 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 }); }); test("hides an empty widget and renders a populated one", () => {