From b654c3c81b566e9511ae3e462fd5d6308a27603a Mon Sep 17 00:00:00 2001 From: JB Date: Wed, 26 Aug 2026 16:13:58 -0400 Subject: [PATCH] Animate active task indicators --- README.md | 4 ++-- src/ui.ts | 23 ++++++++++++++++++----- src/widget.ts | 11 +++++++++-- tests/ui.test.ts | 36 +++++++++++++++++++++++++++++++++--- 4 files changed, 62 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index e7fdefc..0a978a1 100644 --- a/README.md +++ b/README.md @@ -98,11 +98,11 @@ A task starts timing the first time it enters `in-progress`. Leaving that state ## Pi Widget -The widget appears above Pi's editor while tasks exist. Run `/tasks` 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. 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 tasks exist. Run `/tasks` 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 `◐ ◓ ◑ ◒` on the existing one-second timer refresh; 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 Tasks 2/6 · !1 ━━━━━──────── - I. ◉ Build auth · 12:08 + I. ◐ Build auth · 12:08 ├─ 1. ! Resolve token issue · 03:41 │ └─ ○ Inspect provider response └─ 2. ○ Add middleware diff --git a/src/ui.ts b/src/ui.ts index 89d42df..5725aff 100644 --- a/src/ui.ts +++ b/src/ui.ts @@ -11,6 +11,8 @@ export const STATUS_ICONS = { done: "✓", } as const; +const IN_PROGRESS_WIDGET_FRAMES = ["◐", "◓", "◑", "◒"] as const; + interface DescendantStats { done: number; total: number; @@ -81,8 +83,12 @@ function styleTaskName(task: Task, theme: Theme): string { return task.name; } -function styleStatusIcon(task: Task, theme: Theme): string { - const icon = STATUS_ICONS[task.status]; +function styleStatusIcon(task: Task, theme: Theme, inProgressFrame?: number): string { + const frameIndex = inProgressFrame === undefined ? undefined : Math.abs(Math.trunc(inProgressFrame)); + const icon = + task.status === "in-progress" && frameIndex !== undefined + ? IN_PROGRESS_WIDGET_FRAMES[frameIndex % IN_PROGRESS_WIDGET_FRAMES.length] + : STATUS_ICONS[task.status]; if (task.status === "done") return theme.fg("success", icon); if (task.status === "in-progress") return theme.fg("warning", icon); if (task.status === "blocked") return theme.fg("error", icon); @@ -107,8 +113,9 @@ function renderTaskLineWithSuffix( suffix?: string, identifier = `${task.id}.`, identifierBeforeStatus = false, + inProgressFrame?: number, ): string { - const status = styleStatusIcon(task, theme); + const status = styleStatusIcon(task, theme, inProgressFrame); const styledIdentifier = identifier.length === 0 ? "" : theme.fg("accent", identifier); const identifierFits = !identifierBeforeStatus || @@ -177,13 +184,19 @@ function widgetOutlineLabel(depth: number, siblingIndex: number): string { return ""; } -export function renderWidgetTree(tasks: Task[], theme: Theme, width: number, padding = " "): string[] { +export function renderWidgetTree( + tasks: Task[], + theme: Theme, + width: number, + padding = " ", + inProgressFrame?: number, +): string[] { const rows = buildTreeRows(tasks, true); 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); - return renderTaskLineWithSuffix(task, `${padding}${prefix}`, theme, width, summary, identifier, true); + return renderTaskLineWithSuffix(task, `${padding}${prefix}`, theme, width, summary, identifier, true, inProgressFrame); }); if (rows.length <= MAX_WIDGET_TASK_ROWS) return lines; diff --git a/src/widget.ts b/src/widget.ts index b07a152..61db64c 100644 --- a/src/widget.ts +++ b/src/widget.ts @@ -23,15 +23,22 @@ export function updateTaskWidget( const hasActiveTimer = tasks.some((task) => task.status === "in-progress" && task.startedAt !== undefined); ctx.ui.setWidget(WIDGET_ID, (tui, theme) => { + let inProgressFrame = 0; const timer = hasActiveTimer - ? setInterval(() => tui.requestRender(), Math.max(1, refreshMs)) + ? setInterval(() => { + inProgressFrame += 1; + tui.requestRender(); + }, Math.max(1, refreshMs)) : undefined; timer?.unref(); return { render(width: number): string[] { const currentTasks = state.list(); - return [renderProgressHeader(state.stats(), theme, width), ...renderWidgetTree(currentTasks, theme, width)]; + return [ + renderProgressHeader(state.stats(), theme, width), + ...renderWidgetTree(currentTasks, theme, width, " ", inProgressFrame), + ]; }, invalidate(): void {}, dispose(): void { diff --git a/tests/ui.test.ts b/tests/ui.test.ts index b8c2848..9daf439 100644 --- a/tests/ui.test.ts +++ b/tests/ui.test.ts @@ -156,6 +156,31 @@ test("uses sibling-local Roman and numeric widget labels while leaving leaf sub- assert.match(timedChild, /◉/); }); +test("animates only in-progress widget circles while preserving narrow-width status markers", () => { + const state = new TaskState(() => 0); + state.add([ + { name: "Active", status: "in-progress" }, + { name: "Pending" }, + { name: "Blocked", status: "blocked" }, + { name: "Done", status: "done" }, + ]); + + const orbitFrames = ["◐", "◓", "◑", "◒"]; + for (const [frame, icon] of orbitFrames.entries()) { + const lines = renderWidgetTree(state.list(), plainTheme as never, 80, " ", frame).map(stripAnsi); + assert.match(lines[0] ?? "", new RegExp(icon)); + assert.match(lines[1] ?? "", /○/); + assert.match(lines[2] ?? "", /!/); + assert.match(lines[3] ?? "", /✓/); + + const narrow = renderWidgetTree(state.list(), plainTheme as never, 6, " ", frame).map(stripAnsi); + assert.match(narrow[0] ?? "", new RegExp(icon)); + assert.ok(narrow.every((line) => visibleWidth(line) <= 6)); + } + + assert.match(stripAnsi(renderTree(state.list(), plainTheme as never, 80)[0] ?? ""), /◉/); +}); + test("renumbers widget labels after task reordering and reparenting", () => { const state = new TaskState(() => 0); state.add([ @@ -344,7 +369,9 @@ test("live widgets request rerenders and dispose their interval", async () => { updateTaskWidget(state, ctx as never, true, 5); let renderRequests = 0; - const component = ( + const observedFrames = new Set(); + let component: { render(width: number): string[]; dispose?(): void }; + component = ( widget as unknown as ( tui: { requestRender(): void }, theme: unknown, @@ -353,17 +380,20 @@ test("live widgets request rerenders and dispose their interval", async () => { { requestRender() { renderRequests += 1; + const frame = stripAnsi(component.render(40)[1] ?? "").match(/[◐◓◑◒]/)?.[0]; + if (frame !== undefined) observedFrames.add(frame); }, }, plainTheme, ); try { - assert.match(stripAnsi(component.render(40)[1] ?? ""), / · 00:00$/); + assert.match(stripAnsi(component.render(40)[1] ?? ""), /◐.* · 00:00$/); now = 65_000; await new Promise((resolve) => setTimeout(resolve, 20)); assert.ok(renderRequests > 0); - assert.match(stripAnsi(component.render(40)[1] ?? ""), / · 01:05$/); + assert.ok(observedFrames.size > 0); + assert.match(stripAnsi(component.render(40)[1] ?? ""), /[◐◓◑◒].* · 01:05$/); } finally { component.dispose?.(); }