From 91a019de29667a1329dda2debcd7b5ef80f2d507 Mon Sep 17 00:00:00 2001 From: JB Date: Wed, 26 Aug 2026 17:04:19 -0400 Subject: [PATCH] Adapt todo widget hierarchy --- README.md | 12 +++++----- src/ui.ts | 36 +++++++++++++++++++++------- tests/ui.test.ts | 61 +++++++++++++++++++++++++++++++----------------- 3 files changed, 73 insertions(+), 36 deletions(-) diff --git a/README.md b/README.md index 8c03337..98bc9fe 100644 --- a/README.md +++ b/README.md @@ -98,15 +98,15 @@ 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. 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. +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. ```text Todos 2/6 · !1 ━━━━━──────── - I. ◐ Build auth · 12:08 - ├─ 1. ! Resolve token issue · 03:41 - │ └─ ○ Inspect provider response - └─ 2. ○ Add middleware - II. ✓ Define schema · 04:31 + ├─ I. ◐ Build auth · 12:08 + │ ├─ 1. ! Resolve token issue · 03:41 + │ │ └─ ○ Inspect provider response + │ └─ 2. ○ Add middleware + └─ II. ✓ Define schema · 04:31 ``` Tool calls remain compact in the conversation. Expand a tool result with Pi's normal tool-output shortcut to inspect affected tasks or full descriptions. diff --git a/src/ui.ts b/src/ui.ts index 3cf06b1..a4c86e1 100644 --- a/src/ui.ts +++ b/src/ui.ts @@ -48,14 +48,14 @@ function descendantStats(task: Task, byId: Map, memo: Map [task.id, task])); const memo = new Map(); const rows: TreeRow[] = []; const visit = (task: Task, ancestorHasNext: boolean[], isLast: boolean, depth: number, siblingIndex: number) => { const ancestors = ancestorHasNext.map((hasNext) => (hasNext ? "│ " : " ")).join(""); - const connector = depth === 0 ? "" : isLast ? "└─ " : "├─ "; + const connector = depth === 0 && !connectRoots ? "" : isLast ? "└─ " : "├─ "; const stats = collapseCompleted ? descendantStats(task, byId, memo) : undefined; const collapsed = stats && stats.total > 0 && stats.done === stats.total ? stats : undefined; rows.push({ task, prefix: `${ancestors}${connector}`, depth, siblingIndex, ...(collapsed ? { collapsed } : {}) }); @@ -63,7 +63,7 @@ function buildTreeRows(tasks: Task[], collapseCompleted: boolean): TreeRow[] { const children = task.childrenIds.map((id) => byId.get(id)).filter((child): child is Task => child !== undefined); children.forEach((child, index) => { - const childAncestors = depth === 0 ? [] : [...ancestorHasNext, !isLast]; + const childAncestors = depth === 0 && !connectRoots ? [] : [...ancestorHasNext, !isLast]; visit(child, childAncestors, index === children.length - 1, depth + 1, index); }); }; @@ -178,10 +178,27 @@ function toRomanNumeral(value: number): string { return result; } -function widgetOutlineLabel(depth: number, siblingIndex: number): string { - if (depth === 0) return `${toRomanNumeral(siblingIndex + 1)}.`; - if (depth === 1) return `${siblingIndex + 1}.`; - return ""; +function maximumTaskDepth(tasks: Task[]): number { + const byId = new Map(tasks.map((task) => [task.id, task])); + let maximumDepth = 0; + + for (const task of tasks) { + let depth = 0; + let parentId = task.parentId; + while (parentId !== null) { + depth += 1; + parentId = byId.get(parentId)?.parentId ?? null; + } + maximumDepth = Math.max(maximumDepth, depth); + } + + return maximumDepth; +} + +function widgetOutlineLabel(depth: number, siblingIndex: number, maximumDepth: number): string { + if (depth === maximumDepth) return ""; + if (maximumDepth === 2 && depth === 0) return `${toRomanNumeral(siblingIndex + 1)}.`; + return `${siblingIndex + 1}.`; } export function renderWidgetTree( @@ -191,11 +208,12 @@ export function renderWidgetTree( padding = " ", inProgressFrame?: number, ): string[] { - const rows = buildTreeRows(tasks, true); + const rows = buildTreeRows(tasks, true, true); + const maximumDepth = maximumTaskDepth(tasks); const visibleRows = rows.slice(0, MAX_WIDGET_TASK_ROWS); const lines = visibleRows.map(({ task, prefix, depth, siblingIndex, collapsed }) => { const summary = collapsed === undefined ? undefined : `${collapsed.done}/${collapsed.total} done`; - const identifier = widgetOutlineLabel(depth, siblingIndex); + const identifier = widgetOutlineLabel(depth, siblingIndex, maximumDepth); return renderTaskLineWithSuffix(task, `${padding}${prefix}`, theme, width, summary, identifier, true, inProgressFrame); }); if (rows.length <= MAX_WIDGET_TASK_ROWS) return lines; diff --git a/tests/ui.test.ts b/tests/ui.test.ts index a7e7715..8cbd07e 100644 --- a/tests/ui.test.ts +++ b/tests/ui.test.ts @@ -92,11 +92,11 @@ test("collapses completed widget subtrees while keeping list trees expanded", () const widgetLines = renderWidgetTree(state.list(), plainTheme as never, 80).map(stripAnsi); assert.deepEqual(widgetLines, [ - " I. ○ Release", - " ├─ 1. ○ Completed branch · 2/2 done", - " └─ 2. ○ Open branch", - " └─ ○ Open leaf", - " II. ○ Pending parent · 1/1 done", + " ├─ I. ○ Release", + " │ ├─ 1. ○ Completed branch · 2/2 done", + " │ └─ 2. ○ Open branch", + " │ └─ ○ Open leaf", + " └─ II. ○ Pending parent · 1/1 done", ]); const fullTree = renderTree(state.list(), plainTheme as never, 80).map(stripAnsi); @@ -109,7 +109,26 @@ test("collapses completed widget subtrees while keeping list trees expanded", () assert.ok(reopened.some((line) => line.includes("Done two"))); }); -test("uses sibling-local Roman and numeric widget labels while leaving leaf sub-items unlabeled", () => { +test("uses only the outline levels needed while leaving leaf tasks unlabeled", () => { + const flatState = new TaskState(() => 0); + flatState.add([{ name: "First leaf" }, { name: "Second leaf" }]); + assert.deepEqual(renderWidgetTree(flatState.list(), plainTheme as never, 80).map(stripAnsi), [ + " ├─ ○ First leaf", + " └─ ○ Second leaf", + ]); + + const twoLevelState = new TaskState(() => 0); + twoLevelState.add([ + { name: "First group", children: [{ name: "First nested leaf" }] }, + { name: "Second group", children: [{ name: "Second nested leaf" }] }, + ]); + assert.deepEqual(renderWidgetTree(twoLevelState.list(), plainTheme as never, 80).map(stripAnsi), [ + " ├─ 1. ○ First group", + " │ └─ ○ First nested leaf", + " └─ 2. ○ Second group", + " └─ ○ Second nested leaf", + ]); + const state = new TaskState(() => 0); state.add([ { name: "Category one" }, @@ -127,15 +146,15 @@ test("uses sibling-local Roman and numeric widget labels while leaving leaf sub- const lines = renderWidgetTree(state.list(), plainTheme as never, 80).map(stripAnsi); assert.deepEqual(lines, [ - " I. ○ Category one", - " II. ○ Category two", - " III. ○ Category three", - " IV. ○ Category four", - " ├─ 1. ○ First item", - " │ └─ ○ Leaf sub-item", - " └─ 2. ○ Second item", - " V. ○ Category five", - " └─ 1. ○ Reset item numbering", + " ├─ I. ○ Category one", + " ├─ II. ○ Category two", + " ├─ III. ○ Category three", + " ├─ IV. ○ Category four", + " │ ├─ 1. ○ First item", + " │ │ └─ ○ Leaf sub-item", + " │ └─ 2. ○ Second item", + " └─ V. ○ Category five", + " └─ 1. ○ Reset item numbering", ]); const narrow = renderWidgetTree(state.list(), plainTheme as never, 18); @@ -143,7 +162,7 @@ test("uses sibling-local Roman and numeric widget labels while leaving leaf sub- const statusPriority = renderWidgetTree(state.list(), plainTheme as never, 6).map(stripAnsi); assert.match(statusPriority[3] ?? "", /○/); - const childStatusPriority = renderWidgetTree(state.list(), plainTheme as never, 8).map(stripAnsi); + const childStatusPriority = renderWidgetTree(state.list(), plainTheme as never, 10).map(stripAnsi); assert.match(childStatusPriority[4] ?? "", /○/); const timedState = new TaskState(() => 0); @@ -194,11 +213,11 @@ test("renumbers widget labels after task reordering and reparenting", () => { const lines = renderWidgetTree(state.list(), plainTheme as never, 80).map(stripAnsi); assert.deepEqual(lines, [ - " I. ○ Category B", - " ├─ 1. ○ A2", - " └─ 2. ○ B1", - " II. ○ Category A", - " └─ 1. ○ A1", + " ├─ 1. ○ Category B", + " │ ├─ ○ A2", + " │ └─ ○ B1", + " └─ 2. ○ Category A", + " └─ ○ A1", ]); });