import type { Theme } from "@earendil-works/pi-coding-agent"; import type { Component, TUI } from "@earendil-works/pi-tui"; import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui"; import type { ObserverStatus, TodoNode, TodoStatus } from "./types.ts"; interface TreeRow { todo: TodoNode; prefix: string; hasChildren: boolean; } export class TodoPanel implements Component { private todos: TodoNode[]; private status: ObserverStatus = { kind: "idle" }; constructor( private readonly tui: TUI, private readonly theme: Theme, initialTodos: TodoNode[], ) { this.todos = initialTodos; } setTodos(todos: TodoNode[]): void { this.todos = todos; this.tui.requestRender(); } setStatus(status: ObserverStatus): void { this.status = status; this.tui.requestRender(); } invalidate(): void { // Theme functions are evaluated on every render, so there is no cache to clear. } render(width: number): string[] { if (width <= 0) return []; const th = this.theme; const lines: string[] = []; const counts = this.countStatuses(); // Keep status glyphs in separate padded cells. Several terminal fonts let // these symbols visually overhang when placed directly beside a digit. const countText = [ th.fg("success", `✓ ${counts.completed}`), th.fg("warning", `◐ ${counts.in_progress}`), th.fg("text", `○ ${counts.pending}`), counts.blocked > 0 ? th.fg("error", `! ${counts.blocked}`) : "", ] .filter(Boolean) .join(" "); lines.push(this.row(th.fg("accent", th.bold(" TODOS")) + " " + countText, width, "top")); lines.push(this.row("", width)); if (this.todos.length === 0) { lines.push(this.row(th.fg("dim", " No todos inferred yet."), width)); } else { for (const treeRow of this.buildRows()) { const icon = this.statusIcon(treeRow.todo.status); const text = this.todoText(treeRow.todo); const marker = treeRow.hasChildren ? "╮" : "─"; lines.push(this.row(` ${th.fg("borderMuted", treeRow.prefix + marker)} ${icon} ${text}`, width)); } } lines.push(this.row("", width)); lines.push(this.row(this.observerLine(), width, "bottom")); return lines; } private row(content: string, width: number, edge?: "top" | "bottom"): string { if (width === 1) return this.theme.fg("border", "│"); const border = edge === "top" ? "┌" : edge === "bottom" ? "└" : "│"; const available = width - 1; const clipped = truncateToWidth(content, available, ""); return this.theme.fg("border", border) + clipped + " ".repeat(Math.max(0, available - visibleWidth(clipped))); } private observerLine(): string { const th = this.theme; switch (this.status.kind) { case "observing": return ` ${th.fg("warning", "◐")} ${th.fg("dim", this.status.message ?? "observer updating…")}`; case "error": return ` ${th.fg("error", "!")} ${th.fg("error", this.status.message)}`; case "idle": return ` ${th.fg("success", "●")} ${th.fg("dim", this.status.message ?? "observer ready")}`; } } private statusIcon(status: TodoStatus): string { switch (status) { case "completed": return this.theme.fg("success", "✓"); case "in_progress": return this.theme.fg("warning", "◐"); case "blocked": return this.theme.fg("error", "!"); case "pending": return this.theme.fg("muted", "○"); } } private todoText(todo: TodoNode): string { if (todo.status === "completed") return this.theme.fg("dim", this.theme.strikethrough(todo.text)); if (todo.status === "blocked") return this.theme.fg("error", todo.text); if (todo.status === "in_progress") return this.theme.fg("text", this.theme.bold(todo.text)); return this.theme.fg("text", todo.text); } private countStatuses(): Record { const counts: Record = { pending: 0, in_progress: 0, blocked: 0, completed: 0 }; for (const todo of this.todos) counts[todo.status]++; return counts; } private buildRows(): TreeRow[] { const byId = new Map(this.todos.map((todo) => [todo.id, todo])); const children = new Map(); const roots: TodoNode[] = []; for (const todo of this.todos) { if (!todo.parentId || !byId.has(todo.parentId)) { roots.push(todo); continue; } const siblings = children.get(todo.parentId) ?? []; siblings.push(todo); children.set(todo.parentId, siblings); } const rows: TreeRow[] = []; const visit = (todo: TodoNode, ancestorPrefix: string, connector: "" | "├" | "╰") => { const descendants = children.get(todo.id) ?? []; rows.push({ todo, prefix: ancestorPrefix + connector, hasChildren: descendants.length > 0 }); const childPrefix = ancestorPrefix + (connector === "├" ? "│ " : connector === "╰" ? " " : ""); for (let index = 0; index < descendants.length; index++) { const isLast = index === descendants.length - 1; visit(descendants[index]!, childPrefix, isLast ? "╰" : "├"); } }; for (const root of roots) visit(root, "", ""); return rows; } }