diff --git a/src/ui.ts b/src/ui.ts index df354bb..738a486 100644 --- a/src/ui.ts +++ b/src/ui.ts @@ -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, memo: Map): 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(); 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}`; diff --git a/src/widget.ts b/src/widget.ts index 68bf5bb..b07a152 100644 --- a/src/widget.ts +++ b/src/widget.ts @@ -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 { diff --git a/tests/ui.test.ts b/tests/ui.test.ts index 894b4d5..92b8951 100644 --- a/tests/ui.test.ts +++ b/tests/ui.test.ts @@ -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");