Collapse completed todo subtrees in widget
This commit is contained in:
@@ -10,19 +10,52 @@ export const STATUS_ICONS = {
|
||||
done: "✓",
|
||||
} as const;
|
||||
|
||||
interface DescendantStats {
|
||||
done: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
interface TreeRow {
|
||||
task: Task;
|
||||
prefix: string;
|
||||
collapsed?: DescendantStats;
|
||||
}
|
||||
|
||||
export function orderedTreeRows(tasks: Task[]): TreeRow[] {
|
||||
function descendantStats(task: Task, byId: Map<number, Task>, memo: Map<number, DescendantStats>): DescendantStats {
|
||||
const cached = memo.get(task.id);
|
||||
if (cached) return cached;
|
||||
|
||||
let done = 0;
|
||||
let total = 0;
|
||||
for (const childId of task.childrenIds) {
|
||||
const child = byId.get(childId);
|
||||
if (!child) continue;
|
||||
|
||||
total += 1;
|
||||
if (child.status === "done") done += 1;
|
||||
const nested = descendantStats(child, byId, memo);
|
||||
done += nested.done;
|
||||
total += nested.total;
|
||||
}
|
||||
|
||||
const stats = { done, total };
|
||||
memo.set(task.id, stats);
|
||||
return stats;
|
||||
}
|
||||
|
||||
function buildTreeRows(tasks: Task[], collapseCompleted: boolean): TreeRow[] {
|
||||
const byId = new Map(tasks.map((task) => [task.id, task]));
|
||||
const memo = new Map<number, DescendantStats>();
|
||||
const rows: TreeRow[] = [];
|
||||
|
||||
const visit = (task: Task, ancestorHasNext: boolean[], isLast: boolean, depth: number) => {
|
||||
const ancestors = ancestorHasNext.map((hasNext) => (hasNext ? "│ " : " ")).join("");
|
||||
const connector = depth === 0 ? "" : isLast ? "└─ " : "├─ ";
|
||||
rows.push({ task, prefix: `${ancestors}${connector}` });
|
||||
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}`, ...(collapsed ? { collapsed } : {}) });
|
||||
if (collapsed) return;
|
||||
|
||||
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];
|
||||
@@ -35,6 +68,10 @@ export function orderedTreeRows(tasks: Task[]): TreeRow[] {
|
||||
return rows;
|
||||
}
|
||||
|
||||
export function orderedTreeRows(tasks: Task[]): TreeRow[] {
|
||||
return buildTreeRows(tasks, false);
|
||||
}
|
||||
|
||||
function styleTaskName(task: Task, theme: Theme): string {
|
||||
if (task.status === "done") return theme.fg("dim", theme.strikethrough(task.name));
|
||||
if (task.status === "in-progress") return theme.fg("warning", task.name);
|
||||
@@ -59,7 +96,13 @@ export function formatDuration(durationMs: number): string {
|
||||
return hours === 0 ? minuteClock : `${String(hours).padStart(2, "0")}:${minuteClock}`;
|
||||
}
|
||||
|
||||
export function renderTaskLine(task: Task, prefix: string, theme: Theme, width: number): string {
|
||||
function renderTaskLineWithSuffix(
|
||||
task: Task,
|
||||
prefix: string,
|
||||
theme: Theme,
|
||||
width: number,
|
||||
suffix?: string,
|
||||
): string {
|
||||
const line =
|
||||
theme.fg("dim", prefix) +
|
||||
styleStatusIcon(task, theme) +
|
||||
@@ -67,19 +110,36 @@ export function renderTaskLine(task: Task, prefix: string, theme: Theme, width:
|
||||
theme.fg("accent", `${task.id}.`) +
|
||||
" " +
|
||||
styleTaskName(task, theme);
|
||||
if (task.durationMs === undefined) return truncateToWidth(line, Math.max(1, width));
|
||||
const trailing = [
|
||||
suffix === undefined ? undefined : theme.fg("dim", ` · ${suffix}`),
|
||||
task.durationMs === undefined ? undefined : theme.fg("dim", ` · ${formatDuration(task.durationMs)}`),
|
||||
]
|
||||
.filter((part): part is string => part !== undefined)
|
||||
.join("");
|
||||
if (trailing.length === 0) return truncateToWidth(line, Math.max(1, width));
|
||||
|
||||
const duration = theme.fg("dim", ` · ${formatDuration(task.durationMs)}`);
|
||||
const durationWidth = visibleWidth(duration);
|
||||
if (width <= durationWidth + 6) return truncateToWidth(line, Math.max(1, width));
|
||||
const taskWidth = Math.max(1, width - durationWidth);
|
||||
return truncateToWidth(line, taskWidth) + duration;
|
||||
const trailingWidth = visibleWidth(trailing);
|
||||
const minimumTaskWidth = suffix === undefined ? 6 : 12;
|
||||
if (width <= trailingWidth + minimumTaskWidth) return truncateToWidth(line, Math.max(1, width));
|
||||
const taskWidth = Math.max(1, width - trailingWidth);
|
||||
return truncateToWidth(line, taskWidth) + trailing;
|
||||
}
|
||||
|
||||
export function renderTaskLine(task: Task, prefix: string, theme: Theme, width: number): string {
|
||||
return renderTaskLineWithSuffix(task, prefix, theme, width);
|
||||
}
|
||||
|
||||
export function renderTree(tasks: Task[], theme: Theme, width: number, padding = " "): string[] {
|
||||
return orderedTreeRows(tasks).map(({ task, prefix }) => renderTaskLine(task, `${padding}${prefix}`, theme, width));
|
||||
}
|
||||
|
||||
export function renderWidgetTree(tasks: Task[], theme: Theme, width: number, padding = " "): string[] {
|
||||
return buildTreeRows(tasks, true).map(({ task, prefix, collapsed }) => {
|
||||
const summary = collapsed === undefined ? undefined : `${collapsed.done}/${collapsed.total} done`;
|
||||
return renderTaskLineWithSuffix(task, `${padding}${prefix}`, theme, width, summary);
|
||||
});
|
||||
}
|
||||
|
||||
export function progressLabel(stats: TaskStats): string {
|
||||
const blocked = stats.blocked > 0 ? ` · !${stats.blocked}` : "";
|
||||
return ` Tasks ${stats.done}/${stats.total}${blocked}`;
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
||||
import type { TaskState } from "./state.ts";
|
||||
import { renderProgressHeader, renderTree } from "./ui.ts";
|
||||
import { renderProgressHeader, renderWidgetTree } from "./ui.ts";
|
||||
|
||||
const WIDGET_ID = "pi-todo-herdr";
|
||||
const TIMER_REFRESH_MS = 1000;
|
||||
@@ -31,7 +31,7 @@ export function updateTaskWidget(
|
||||
return {
|
||||
render(width: number): string[] {
|
||||
const currentTasks = state.list();
|
||||
return [renderProgressHeader(state.stats(), theme, width), ...renderTree(currentTasks, theme, width)];
|
||||
return [renderProgressHeader(state.stats(), theme, width), ...renderWidgetTree(currentTasks, theme, width)];
|
||||
},
|
||||
invalidate(): void {},
|
||||
dispose(): void {
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
renderTaskLine,
|
||||
renderToolResult,
|
||||
renderTree,
|
||||
renderWidgetTree,
|
||||
snapshotStats,
|
||||
} from "../src/ui.ts";
|
||||
import { updateTaskWidget } from "../src/widget.ts";
|
||||
@@ -67,6 +68,56 @@ test("renders lightweight connectors without a detached root continuation", () =
|
||||
]);
|
||||
});
|
||||
|
||||
test("collapses completed widget subtrees while keeping list trees expanded", () => {
|
||||
const state = new TaskState(() => 0);
|
||||
state.add([
|
||||
{
|
||||
name: "Release",
|
||||
children: [
|
||||
{
|
||||
name: "Completed branch",
|
||||
children: [
|
||||
{ name: "Done one", status: "done" },
|
||||
{ name: "Done two", status: "done" },
|
||||
],
|
||||
},
|
||||
{ name: "Open branch", children: [{ name: "Open leaf" }] },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Pending parent",
|
||||
children: [{ name: "Finished child", status: "done" }],
|
||||
},
|
||||
]);
|
||||
|
||||
const widgetLines = renderWidgetTree(state.list(), plainTheme as never, 80).map(stripAnsi);
|
||||
assert.deepEqual(widgetLines, [
|
||||
" ○ 1. Release",
|
||||
" ├─ ○ 2. Completed branch · 2/2 done",
|
||||
" └─ ○ 5. Open branch",
|
||||
" └─ ○ 6. Open leaf",
|
||||
" ○ 7. Pending parent · 1/1 done",
|
||||
]);
|
||||
|
||||
const fullTree = renderTree(state.list(), plainTheme as never, 80).map(stripAnsi);
|
||||
assert.ok(fullTree.some((line) => line.includes("Done one")));
|
||||
assert.ok(fullTree.some((line) => line.includes("Finished child")));
|
||||
|
||||
state.update([{ id: 3, status: "pending" }]);
|
||||
const reopened = renderWidgetTree(state.list(), plainTheme as never, 80).map(stripAnsi);
|
||||
assert.ok(reopened.some((line) => line.includes("Done one")));
|
||||
assert.ok(reopened.some((line) => line.includes("Done two")));
|
||||
});
|
||||
|
||||
test("hides a completed widget summary before squeezing the task name", () => {
|
||||
const state = new TaskState(() => 0);
|
||||
state.add([{ name: "A long parent task name", children: [{ name: "Done", status: "done" }] }]);
|
||||
|
||||
const line = renderWidgetTree(state.list(), plainTheme as never, 20)[0] ?? "";
|
||||
assert.ok(visibleWidth(line) <= 20);
|
||||
assert.doesNotMatch(stripAnsi(line), /done$/);
|
||||
});
|
||||
|
||||
test("formats compact timers and adds hours only when needed", () => {
|
||||
assert.equal(formatDuration(0), "00:00");
|
||||
assert.equal(formatDuration(65_432), "01:05");
|
||||
|
||||
Reference in New Issue
Block a user