Adapt todo widget hierarchy

This commit is contained in:
jb
2026-08-26 17:04:19 -04:00
parent 1a437f50b4
commit 91a019de29
3 changed files with 73 additions and 36 deletions
+6 -6
View File
@@ -98,15 +98,15 @@ A task starts timing the first time it enters `in-progress`. Leaving that state
## Pi Widget ## 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 ```text
Todos 2/6 · !1 ━━━━━──────── Todos 2/6 · !1 ━━━━━────────
I. ◐ Build auth · 12:08 ├─ I. ◐ Build auth · 12:08
├─ 1. ! Resolve token issue · 03:41 │ ├─ 1. ! Resolve token issue · 03:41
│ └─ ○ Inspect provider response │ │ └─ ○ Inspect provider response
└─ 2. ○ Add middleware │ └─ 2. ○ Add middleware
II. ✓ Define schema · 04:31 └─ 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. 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.
+27 -9
View File
@@ -48,14 +48,14 @@ function descendantStats(task: Task, byId: Map<number, Task>, memo: Map<number,
return stats; return stats;
} }
function buildTreeRows(tasks: Task[], collapseCompleted: boolean): TreeRow[] { function buildTreeRows(tasks: Task[], collapseCompleted: boolean, connectRoots = false): TreeRow[] {
const byId = new Map(tasks.map((task) => [task.id, task])); const byId = new Map(tasks.map((task) => [task.id, task]));
const memo = new Map<number, DescendantStats>(); const memo = new Map<number, DescendantStats>();
const rows: TreeRow[] = []; const rows: TreeRow[] = [];
const visit = (task: Task, ancestorHasNext: boolean[], isLast: boolean, depth: number, siblingIndex: number) => { const visit = (task: Task, ancestorHasNext: boolean[], isLast: boolean, depth: number, siblingIndex: number) => {
const ancestors = ancestorHasNext.map((hasNext) => (hasNext ? "│ " : " ")).join(""); 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 stats = collapseCompleted ? descendantStats(task, byId, memo) : undefined;
const collapsed = stats && stats.total > 0 && stats.done === stats.total ? stats : undefined; const collapsed = stats && stats.total > 0 && stats.done === stats.total ? stats : undefined;
rows.push({ task, prefix: `${ancestors}${connector}`, depth, siblingIndex, ...(collapsed ? { collapsed } : {}) }); 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); const children = task.childrenIds.map((id) => byId.get(id)).filter((child): child is Task => child !== undefined);
children.forEach((child, index) => { 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); visit(child, childAncestors, index === children.length - 1, depth + 1, index);
}); });
}; };
@@ -178,10 +178,27 @@ function toRomanNumeral(value: number): string {
return result; return result;
} }
function widgetOutlineLabel(depth: number, siblingIndex: number): string { function maximumTaskDepth(tasks: Task[]): number {
if (depth === 0) return `${toRomanNumeral(siblingIndex + 1)}.`; const byId = new Map(tasks.map((task) => [task.id, task]));
if (depth === 1) return `${siblingIndex + 1}.`; let maximumDepth = 0;
return "";
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( export function renderWidgetTree(
@@ -191,11 +208,12 @@ export function renderWidgetTree(
padding = " ", padding = " ",
inProgressFrame?: number, inProgressFrame?: number,
): string[] { ): 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 visibleRows = rows.slice(0, MAX_WIDGET_TASK_ROWS);
const lines = visibleRows.map(({ task, prefix, depth, siblingIndex, collapsed }) => { const lines = visibleRows.map(({ task, prefix, depth, siblingIndex, collapsed }) => {
const summary = collapsed === undefined ? undefined : `${collapsed.done}/${collapsed.total} done`; 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); return renderTaskLineWithSuffix(task, `${padding}${prefix}`, theme, width, summary, identifier, true, inProgressFrame);
}); });
if (rows.length <= MAX_WIDGET_TASK_ROWS) return lines; if (rows.length <= MAX_WIDGET_TASK_ROWS) return lines;
+40 -21
View File
@@ -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); const widgetLines = renderWidgetTree(state.list(), plainTheme as never, 80).map(stripAnsi);
assert.deepEqual(widgetLines, [ assert.deepEqual(widgetLines, [
" I. ○ Release", " ├─ I. ○ Release",
" ├─ 1. ○ Completed branch · 2/2 done", " │ ├─ 1. ○ Completed branch · 2/2 done",
" └─ 2. ○ Open branch", " │ └─ 2. ○ Open branch",
" └─ ○ Open leaf", " │ └─ ○ Open leaf",
" II. ○ Pending parent · 1/1 done", " └─ II. ○ Pending parent · 1/1 done",
]); ]);
const fullTree = renderTree(state.list(), plainTheme as never, 80).map(stripAnsi); 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"))); 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); const state = new TaskState(() => 0);
state.add([ state.add([
{ name: "Category one" }, { 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); const lines = renderWidgetTree(state.list(), plainTheme as never, 80).map(stripAnsi);
assert.deepEqual(lines, [ assert.deepEqual(lines, [
" I. ○ Category one", " ├─ I. ○ Category one",
" II. ○ Category two", " ├─ II. ○ Category two",
" III. ○ Category three", " ├─ III. ○ Category three",
" IV. ○ Category four", " ├─ IV. ○ Category four",
" ├─ 1. ○ First item", " │ ├─ 1. ○ First item",
" │ └─ ○ Leaf sub-item", " │ │ └─ ○ Leaf sub-item",
" └─ 2. ○ Second item", " │ └─ 2. ○ Second item",
" V. ○ Category five", " └─ V. ○ Category five",
" └─ 1. ○ Reset item numbering", " └─ 1. ○ Reset item numbering",
]); ]);
const narrow = renderWidgetTree(state.list(), plainTheme as never, 18); 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); const statusPriority = renderWidgetTree(state.list(), plainTheme as never, 6).map(stripAnsi);
assert.match(statusPriority[3] ?? "", /○/); 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] ?? "", /○/); assert.match(childStatusPriority[4] ?? "", /○/);
const timedState = new TaskState(() => 0); 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); const lines = renderWidgetTree(state.list(), plainTheme as never, 80).map(stripAnsi);
assert.deepEqual(lines, [ assert.deepEqual(lines, [
" I. ○ Category B", " ├─ 1. ○ Category B",
" ├─ 1. ○ A2", " │ ├─ ○ A2",
" └─ 2. ○ B1", " │ └─ ○ B1",
" II. ○ Category A", " └─ 2. ○ Category A",
" └─ 1. ○ A1", " └─ ○ A1",
]); ]);
}); });