Collapse completed todo subtrees in widget

This commit is contained in:
lesetong
2026-08-24 10:23:43 +08:00
parent cd74f91e2a
commit 98f3904189
3 changed files with 122 additions and 11 deletions
+69 -9
View File
@@ -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
View File
@@ -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 {