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 {
+51
View File
@@ -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");