Animate active task indicators
This commit is contained in:
@@ -98,11 +98,11 @@ 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 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
|
```text
|
||||||
Tasks 2/6 · !1 ━━━━━────────
|
Tasks 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
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ export const STATUS_ICONS = {
|
|||||||
done: "✓",
|
done: "✓",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
const IN_PROGRESS_WIDGET_FRAMES = ["◐", "◓", "◑", "◒"] as const;
|
||||||
|
|
||||||
interface DescendantStats {
|
interface DescendantStats {
|
||||||
done: number;
|
done: number;
|
||||||
total: number;
|
total: number;
|
||||||
@@ -81,8 +83,12 @@ function styleTaskName(task: Task, theme: Theme): string {
|
|||||||
return task.name;
|
return task.name;
|
||||||
}
|
}
|
||||||
|
|
||||||
function styleStatusIcon(task: Task, theme: Theme): string {
|
function styleStatusIcon(task: Task, theme: Theme, inProgressFrame?: number): string {
|
||||||
const icon = STATUS_ICONS[task.status];
|
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 === "done") return theme.fg("success", icon);
|
||||||
if (task.status === "in-progress") return theme.fg("warning", icon);
|
if (task.status === "in-progress") return theme.fg("warning", icon);
|
||||||
if (task.status === "blocked") return theme.fg("error", icon);
|
if (task.status === "blocked") return theme.fg("error", icon);
|
||||||
@@ -107,8 +113,9 @@ function renderTaskLineWithSuffix(
|
|||||||
suffix?: string,
|
suffix?: string,
|
||||||
identifier = `${task.id}.`,
|
identifier = `${task.id}.`,
|
||||||
identifierBeforeStatus = false,
|
identifierBeforeStatus = false,
|
||||||
|
inProgressFrame?: number,
|
||||||
): string {
|
): string {
|
||||||
const status = styleStatusIcon(task, theme);
|
const status = styleStatusIcon(task, theme, inProgressFrame);
|
||||||
const styledIdentifier = identifier.length === 0 ? "" : theme.fg("accent", identifier);
|
const styledIdentifier = identifier.length === 0 ? "" : theme.fg("accent", identifier);
|
||||||
const identifierFits =
|
const identifierFits =
|
||||||
!identifierBeforeStatus ||
|
!identifierBeforeStatus ||
|
||||||
@@ -177,13 +184,19 @@ function widgetOutlineLabel(depth: number, siblingIndex: number): string {
|
|||||||
return "";
|
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 rows = buildTreeRows(tasks, true);
|
||||||
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);
|
||||||
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;
|
if (rows.length <= MAX_WIDGET_TASK_ROWS) return lines;
|
||||||
|
|
||||||
|
|||||||
+9
-2
@@ -23,15 +23,22 @@ export function updateTaskWidget(
|
|||||||
|
|
||||||
const hasActiveTimer = tasks.some((task) => task.status === "in-progress" && task.startedAt !== undefined);
|
const hasActiveTimer = tasks.some((task) => task.status === "in-progress" && task.startedAt !== undefined);
|
||||||
ctx.ui.setWidget(WIDGET_ID, (tui, theme) => {
|
ctx.ui.setWidget(WIDGET_ID, (tui, theme) => {
|
||||||
|
let inProgressFrame = 0;
|
||||||
const timer = hasActiveTimer
|
const timer = hasActiveTimer
|
||||||
? setInterval(() => tui.requestRender(), Math.max(1, refreshMs))
|
? setInterval(() => {
|
||||||
|
inProgressFrame += 1;
|
||||||
|
tui.requestRender();
|
||||||
|
}, Math.max(1, refreshMs))
|
||||||
: undefined;
|
: undefined;
|
||||||
timer?.unref();
|
timer?.unref();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
render(width: number): string[] {
|
render(width: number): string[] {
|
||||||
const currentTasks = state.list();
|
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 {},
|
invalidate(): void {},
|
||||||
dispose(): void {
|
dispose(): void {
|
||||||
|
|||||||
+33
-3
@@ -156,6 +156,31 @@ test("uses sibling-local Roman and numeric widget labels while leaving leaf sub-
|
|||||||
assert.match(timedChild, /◉/);
|
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", () => {
|
test("renumbers widget labels after task reordering and reparenting", () => {
|
||||||
const state = new TaskState(() => 0);
|
const state = new TaskState(() => 0);
|
||||||
state.add([
|
state.add([
|
||||||
@@ -344,7 +369,9 @@ test("live widgets request rerenders and dispose their interval", async () => {
|
|||||||
updateTaskWidget(state, ctx as never, true, 5);
|
updateTaskWidget(state, ctx as never, true, 5);
|
||||||
|
|
||||||
let renderRequests = 0;
|
let renderRequests = 0;
|
||||||
const component = (
|
const observedFrames = new Set<string>();
|
||||||
|
let component: { render(width: number): string[]; dispose?(): void };
|
||||||
|
component = (
|
||||||
widget as unknown as (
|
widget as unknown as (
|
||||||
tui: { requestRender(): void },
|
tui: { requestRender(): void },
|
||||||
theme: unknown,
|
theme: unknown,
|
||||||
@@ -353,17 +380,20 @@ test("live widgets request rerenders and dispose their interval", async () => {
|
|||||||
{
|
{
|
||||||
requestRender() {
|
requestRender() {
|
||||||
renderRequests += 1;
|
renderRequests += 1;
|
||||||
|
const frame = stripAnsi(component.render(40)[1] ?? "").match(/[◐◓◑◒]/)?.[0];
|
||||||
|
if (frame !== undefined) observedFrames.add(frame);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
plainTheme,
|
plainTheme,
|
||||||
);
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
assert.match(stripAnsi(component.render(40)[1] ?? ""), / · 00:00$/);
|
assert.match(stripAnsi(component.render(40)[1] ?? ""), /◐.* · 00:00$/);
|
||||||
now = 65_000;
|
now = 65_000;
|
||||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||||
assert.ok(renderRequests > 0);
|
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 {
|
} finally {
|
||||||
component.dispose?.();
|
component.dispose?.();
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user