From 9c500a485cee43a184a9d64e26a5f752d1edf32e Mon Sep 17 00:00:00 2001 From: JB Date: Thu, 27 Aug 2026 00:46:54 -0400 Subject: [PATCH] Indent deepest task leaves --- README.md | 4 ++-- src/ui.ts | 37 +++++++++++++++++++++++++++++++++++-- tests/ui.test.ts | 20 ++++++++++++-------- 3 files changed, 49 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index f21cd66..49de699 100644 --- a/README.md +++ b/README.md @@ -98,13 +98,13 @@ 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. Ten seconds after every task is completed, the task tree is automatically deleted and task IDs reset to `1`; adding or reopening unfinished work during that delay cancels the clear. 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. +The widget appears above Pi's editor while todos exist. Run `/todo` to hide or show it for the current Pi runtime. Ten seconds after every task is completed, the task tree is automatically deleted and task IDs reset to `1`; adding or reopening unfinished work during that delay cancels the clear. 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. Content at the deepest leaf level is indented one additional tree step in both the widget and expanded list output so shallower numbered items remain aligned. 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 + │ │ └─ ○ Inspect provider response │ └─ 2. ○ Add middleware └─ II. ✓ Define schema · 04:31 ``` diff --git a/src/ui.ts b/src/ui.ts index a4c86e1..7b1808c 100644 --- a/src/ui.ts +++ b/src/ui.ts @@ -151,8 +151,32 @@ export function renderTaskLine(task: Task, prefix: string, theme: Theme, width: return renderTaskLineWithSuffix(task, prefix, theme, width); } +const STATUS_ICON_WIDTH = 1; +const TRUNCATED_STATUS_WIDTH = STATUS_ICON_WIDTH + 3; + +function indentDeepestLeaf( + prefix: string, + depth: number, + maximumDepth: number, + width: number, + minimumContentWidth: number, +): string { + if (depth === 0 || depth !== maximumDepth) return prefix; + + const indentedPrefix = `${prefix} `; + return visibleWidth(indentedPrefix) + minimumContentWidth <= width ? indentedPrefix : prefix; +} + export function renderTree(tasks: Task[], theme: Theme, width: number, padding = " "): string[] { - return orderedTreeRows(tasks).map(({ task, prefix }) => renderTaskLine(task, `${padding}${prefix}`, theme, width)); + const maximumDepth = maximumTaskDepth(tasks); + return orderedTreeRows(tasks).map(({ task, prefix, depth }) => + renderTaskLine( + task, + indentDeepestLeaf(`${padding}${prefix}`, depth, maximumDepth, width, TRUNCATED_STATUS_WIDTH), + theme, + width, + ), + ); } function toRomanNumeral(value: number): string { @@ -214,7 +238,16 @@ export function renderWidgetTree( const lines = visibleRows.map(({ task, prefix, depth, siblingIndex, collapsed }) => { const summary = collapsed === undefined ? undefined : `${collapsed.done}/${collapsed.total} done`; const identifier = widgetOutlineLabel(depth, siblingIndex, maximumDepth); - return renderTaskLineWithSuffix(task, `${padding}${prefix}`, theme, width, summary, identifier, true, inProgressFrame); + return renderTaskLineWithSuffix( + task, + indentDeepestLeaf(`${padding}${prefix}`, depth, maximumDepth, width, STATUS_ICON_WIDTH), + 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 8cbd07e..63f9660 100644 --- a/tests/ui.test.ts +++ b/tests/ui.test.ts @@ -62,7 +62,7 @@ test("renders lightweight connectors without a detached root continuation", () = assert.deepEqual(output, [ " ◉ 1. Build auth · 00:00", " ├─ ! 2. Resolve token issue", - " │ └─ ○ 3. Inspect provider response", + " │ └─ ○ 3. Inspect provider response", " └─ ○ 4. Add middleware", " ✓ 5. ~Define schema~", ]); @@ -95,7 +95,7 @@ test("collapses completed widget subtrees while keeping list trees expanded", () " ├─ I. ○ Release", " │ ├─ 1. ○ Completed branch · 2/2 done", " │ └─ 2. ○ Open branch", - " │ └─ ○ Open leaf", + " │ └─ ○ Open leaf", " └─ II. ○ Pending parent · 1/1 done", ]); @@ -124,9 +124,9 @@ test("uses only the outline levels needed while leaving leaf tasks unlabeled", ( ]); assert.deepEqual(renderWidgetTree(twoLevelState.list(), plainTheme as never, 80).map(stripAnsi), [ " ├─ 1. ○ First group", - " │ └─ ○ First nested leaf", + " │ └─ ○ First nested leaf", " └─ 2. ○ Second group", - " └─ ○ Second nested leaf", + " └─ ○ Second nested leaf", ]); const state = new TaskState(() => 0); @@ -151,7 +151,7 @@ test("uses only the outline levels needed while leaving leaf tasks unlabeled", ( " ├─ III. ○ Category three", " ├─ IV. ○ Category four", " │ ├─ 1. ○ First item", - " │ │ └─ ○ Leaf sub-item", + " │ │ └─ ○ Leaf sub-item", " │ └─ 2. ○ Second item", " └─ V. ○ Category five", " └─ 1. ○ Reset item numbering", @@ -164,6 +164,10 @@ test("uses only the outline levels needed while leaving leaf tasks unlabeled", ( assert.match(statusPriority[3] ?? "", /○/); const childStatusPriority = renderWidgetTree(state.list(), plainTheme as never, 10).map(stripAnsi); assert.match(childStatusPriority[4] ?? "", /○/); + const leafStatusPriority = renderWidgetTree(state.list(), plainTheme as never, 12).map(stripAnsi); + assert.match(leafStatusPriority[5] ?? "", /○/); + const listLeafStatusPriority = renderTree(state.list(), plainTheme as never, 12).map(stripAnsi); + assert.match(listLeafStatusPriority[5] ?? "", /○/); const timedState = new TaskState(() => 0); timedState.add([ @@ -214,10 +218,10 @@ test("renumbers widget labels after task reordering and reparenting", () => { const lines = renderWidgetTree(state.list(), plainTheme as never, 80).map(stripAnsi); assert.deepEqual(lines, [ " ├─ 1. ○ Category B", - " │ ├─ ○ A2", - " │ └─ ○ B1", + " │ ├─ ○ A2", + " │ └─ ○ B1", " └─ 2. ○ Category A", - " └─ ○ A1", + " └─ ○ A1", ]); });