import type { Component } from "@earendil-works/pi-tui"; import { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui"; import type { Theme } from "@earendil-works/pi-coding-agent"; import { MAX_WIDGET_TASK_ROWS } from "./limits.ts"; import type { Task, TaskStats, TaskToolDetails } from "./types.ts"; export const STATUS_ICONS = { pending: "○", "in-progress": "◉", blocked: "!", done: "✓", } as const; const IN_PROGRESS_WIDGET_FRAMES = ["◐", "◓", "◑", "◒"] as const; interface DescendantStats { done: number; total: number; } interface TreeRow { task: Task; prefix: string; depth: number; siblingIndex: number; collapsed?: DescendantStats; } 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, connectRoots = false): 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, siblingIndex: number) => { const ancestors = ancestorHasNext.map((hasNext) => (hasNext ? "│ " : " ")).join(""); const connector = depth === 0 && !connectRoots ? "" : isLast ? "└─ " : "├─ "; 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}`, depth, siblingIndex, ...(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 && !connectRoots ? [] : [...ancestorHasNext, !isLast]; visit(child, childAncestors, index === children.length - 1, depth + 1, index); }); }; const roots = tasks.filter((task) => task.parentId === null); roots.forEach((root, index) => visit(root, [], index === roots.length - 1, 0, index)); 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); return task.name; } function styleStatusIcon(task: Task, theme: Theme, inProgressFrame?: number): string { const frameIndex = inProgressFrame === undefined ? undefined : Math.abs(Math.trunc(inProgressFrame)); const icon = task.status === "in-progress" && frameIndex !== undefined ? IN_PROGRESS_WIDGET_FRAMES[frameIndex % IN_PROGRESS_WIDGET_FRAMES.length] : STATUS_ICONS[task.status]; if (task.status === "done") return theme.fg("success", icon); if (task.status === "in-progress") return theme.fg("warning", icon); if (task.status === "blocked") return theme.fg("error", icon); return theme.fg("dim", icon); } export function formatDuration(durationMs: number): string { const safeMs = Number.isFinite(durationMs) && durationMs >= 0 ? durationMs : 0; const totalSeconds = Math.floor(safeMs / 1000); const hours = Math.floor(totalSeconds / 3600); const minutes = Math.floor((totalSeconds % 3600) / 60); const seconds = totalSeconds % 60; const minuteClock = [minutes, seconds].map((part) => String(part).padStart(2, "0")).join(":"); return hours === 0 ? minuteClock : `${String(hours).padStart(2, "0")}:${minuteClock}`; } function renderTaskLineWithSuffix( task: Task, prefix: string, theme: Theme, width: number, suffix?: string, identifier = `${task.id}.`, identifierBeforeStatus = false, inProgressFrame?: number, ): string { const status = styleStatusIcon(task, theme, inProgressFrame); const styledIdentifier = identifier.length === 0 ? "" : theme.fg("accent", identifier); const identifierFits = !identifierBeforeStatus || visibleWidth(prefix) + visibleWidth(styledIdentifier) + 1 + visibleWidth(status) <= width; const visibleIdentifier = identifierFits ? styledIdentifier : ""; const markers = identifierBeforeStatus ? [visibleIdentifier, status] : [status, visibleIdentifier]; const leading = theme.fg("dim", prefix) + markers.filter((marker) => marker.length > 0).join(" "); const styledName = styleTaskName(task, theme); const renderMain = (targetWidth: number): string => { const safeWidth = Math.max(1, targetWidth); if (!identifierBeforeStatus) return truncateToWidth(`${leading} ${styledName}`, safeWidth); const availableNameWidth = safeWidth - visibleWidth(leading) - 1; if (availableNameWidth < 1) return truncateToWidth(leading, safeWidth); return `${leading} ${truncateToWidth(styledName, availableNameWidth)}`; }; 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 renderMain(width); const trailingWidth = visibleWidth(trailing); const minimumTaskWidth = Math.max(suffix === undefined ? 6 : 12, visibleWidth(leading)); if (width <= trailingWidth + minimumTaskWidth) return renderMain(width); const taskWidth = Math.max(1, width - trailingWidth); return renderMain(taskWidth) + trailing; } export function renderTaskLine(task: Task, prefix: string, theme: Theme, width: number): string { return renderTaskLineWithSuffix(task, prefix, theme, width); } const STATUS_ICON_WIDTH = 1; const TRUNCATED_STATUS_WIDTH = STATUS_ICON_WIDTH + 3; function indentDeepestLeaf( prefix: string, depth: number, maximumDepth: number, width: number, minimumContentWidth: number, ): string { if (depth === 0 || depth !== maximumDepth) return prefix; const indentedPrefix = `${prefix} `; return visibleWidth(indentedPrefix) + minimumContentWidth <= width ? indentedPrefix : prefix; } export function renderTree(tasks: Task[], theme: Theme, width: number, padding = " "): string[] { const maximumDepth = maximumTaskDepth(tasks); return orderedTreeRows(tasks).map(({ task, prefix, depth }) => renderTaskLine( task, indentDeepestLeaf(`${padding}${prefix}`, depth, maximumDepth, width, TRUNCATED_STATUS_WIDTH), theme, width, ), ); } function toRomanNumeral(value: number): string { const numerals: Array<[number, string]> = [ [100, "C"], [90, "XC"], [50, "L"], [40, "XL"], [10, "X"], [9, "IX"], [5, "V"], [4, "IV"], [1, "I"], ]; let remaining = value; let result = ""; for (const [amount, numeral] of numerals) { while (remaining >= amount) { result += numeral; remaining -= amount; } } return result; } function maximumTaskDepth(tasks: Task[]): number { const byId = new Map(tasks.map((task) => [task.id, task])); let maximumDepth = 0; for (const task of tasks) { let depth = 0; let parentId = task.parentId; while (parentId !== null) { depth += 1; parentId = byId.get(parentId)?.parentId ?? null; } maximumDepth = Math.max(maximumDepth, depth); } return maximumDepth; } function widgetOutlineLabel(depth: number, siblingIndex: number, maximumDepth: number): string { if (depth === maximumDepth) return ""; if (maximumDepth === 2 && depth === 0) return `${toRomanNumeral(siblingIndex + 1)}.`; return `${siblingIndex + 1}.`; } export function renderWidgetTree( tasks: Task[], theme: Theme, width: number, padding = " ", inProgressFrame?: number, maxBodyRows?: number, ): string[] { const rows = buildTreeRows(tasks, true, true); const maximumDepth = maximumTaskDepth(tasks); const outputLimit = maxBodyRows === undefined ? MAX_WIDGET_TASK_ROWS : Math.max(1, Math.floor(Number.isFinite(maxBodyRows) ? maxBodyRows : 1)); const hasOverflow = rows.length > outputLimit; // The legacy, standalone widget allows twelve task rows plus one summary. // A combined panel treats maxBodyRows as a strict total body-line budget. const taskRowLimit = maxBodyRows === undefined ? outputLimit : hasOverflow ? Math.max(0, outputLimit - 1) : outputLimit; const visibleRows = rows.slice(0, taskRowLimit); const lines = visibleRows.map(({ task, prefix, depth, siblingIndex, collapsed }) => { const summary = collapsed === undefined ? undefined : `${collapsed.done}/${collapsed.total} done`; const identifier = widgetOutlineLabel(depth, siblingIndex, maximumDepth); return renderTaskLineWithSuffix( task, indentDeepestLeaf(`${padding}${prefix}`, depth, maximumDepth, width, STATUS_ICON_WIDTH), theme, width, summary, identifier, true, inProgressFrame, ); }); if (!hasOverflow) return lines; const hiddenTasks = rows.slice(taskRowLimit).reduce( (count, row) => count + 1 + (row.collapsed?.total ?? 0), 0, ); const label = `${padding}… ${hiddenTasks} more task${hiddenTasks === 1 ? "" : "s"}`; return [...lines, truncateToWidth(theme.fg("dim", label), Math.max(1, width))]; } export function progressLabel(stats: TaskStats): string { const blocked = stats.blocked > 0 ? ` · !${stats.blocked}` : ""; return ` Todos ${stats.done}/${stats.total}${blocked}`; } export function renderProgressHeader(stats: TaskStats, theme: Theme, width: number): string { const label = progressLabel(stats); const available = width - visibleWidth(label) - 2; if (available < 5) return truncateToWidth(theme.fg("accent", label), Math.max(1, width)); const barWidth = Math.min(20, available); const filled = stats.total === 0 ? 0 : Math.round((stats.done / stats.total) * barWidth); const filledColor = stats.done === stats.total ? "success" : "accent"; const bar = theme.fg(filledColor, theme.bold("━".repeat(filled))) + theme.fg("dim", "─".repeat(barWidth - filled)); return truncateToWidth(`${theme.fg("accent", label)} ${bar}`, Math.max(1, width)); } function compactProgressBar(stats: TaskStats, theme: Theme): string { const width = 8; const filled = stats.total === 0 ? 0 : Math.round((stats.done / stats.total) * width); const filledColor = stats.done === stats.total ? "success" : "accent"; return ( theme.fg(filledColor, theme.bold("━".repeat(filled))) + theme.fg("borderMuted", "─".repeat(width - filled)) ); } /** Render the compact, unboxed Todo header and task rows. */ export function renderTodoPanel( tasks: Task[], theme: Theme, width: number, inProgressFrame?: number, maxBodyRows?: number, ): string[] { if (!Number.isFinite(width) || width <= 0) return []; const safeWidth = Math.max(1, Math.floor(width)); const stats = snapshotStats(tasks); const title = theme.fg("accent", "Todos"); const count = theme.fg("muted", `${stats.done}/${stats.total}${stats.blocked > 0 ? ` · !${stats.blocked}` : ""}`); const fullHeader = `${title} ${count} ${compactProgressBar(stats, theme)}`; const compactHeader = `${title} ${count}`; const header = visibleWidth(fullHeader) <= safeWidth ? fullHeader : truncateToWidth(compactHeader, safeWidth); return [header, ...renderWidgetTree(tasks, theme, safeWidth, " ", inProgressFrame, maxBodyRows)]; } class TaskResultComponent implements Component { private readonly build: (width: number) => string[]; constructor(build: (width: number) => string[]) { this.build = build; } render(width: number): string[] { return this.build(Math.max(1, width)).map((line) => truncateToWidth(line, Math.max(1, width))); } invalidate(): void {} } function firstText(content: Array<{ type: string; text?: string }>): string { return content.find((item) => item.type === "text")?.text ?? ""; } function summaryFor(details: TaskToolDetails): string { const stats = snapshotStats(details.snapshot.tasks); const progress = `${stats.done}/${stats.total} done`; switch (details.action) { case "add": case "append": return `${details.affected.length} added · ${progress}`; case "replace": return stats.total === 0 ? "task tree cleared" : `${stats.total} installed · ${progress}`; case "update": return `${details.affected.length} updated · ${progress}`; case "remove": return `${details.affected.length} removed · ${progress}`; case "list": return `${stats.total} task${stats.total === 1 ? "" : "s"} · ${progress}`; case "get": return `${details.affected.length} task${details.affected.length === 1 ? "" : "s"}`; } } export function snapshotStats(tasks: Task[]): TaskStats { return { total: tasks.length, pending: tasks.filter((task) => task.status === "pending").length, inProgress: tasks.filter((task) => task.status === "in-progress").length, blocked: tasks.filter((task) => task.status === "blocked").length, done: tasks.filter((task) => task.status === "done").length, }; } function renderFlatTasks(tasks: Task[], theme: Theme, width: number): string[] { return tasks.map((task) => renderTaskLine(task, " ", theme, width)); } function renderTaskDetails(tasks: Task[], theme: Theme, width: number): string[] { const lines: string[] = []; for (const [index, task] of tasks.entries()) { if (index > 0) lines.push(""); lines.push(renderTaskLine(task, " ", theme, width)); const parent = task.parentId === null ? "root" : `#${task.parentId}`; const children = task.childrenIds.length === 0 ? "none" : task.childrenIds.map((id) => `#${id}`).join(", "); lines.push(truncateToWidth(theme.fg("dim", ` Parent ${parent} · Children ${children}`), width)); if (task.description) { const prefix = " "; const descriptionWidth = Math.max(1, width - visibleWidth(prefix)); for (const sourceLine of task.description.split("\n")) { const wrapped = wrapTextWithAnsi(theme.fg("muted", sourceLine), descriptionWidth); if (wrapped.length === 0) lines.push(prefix); else lines.push(...wrapped.map((line) => `${prefix}${line}`)); } } } return lines; } function isValidTaskToolDetails(details: unknown): details is TaskToolDetails { if (!details || typeof details !== "object") return false; const d = details as Partial; return ( typeof d.action === "string" && Array.isArray(d.snapshot?.tasks) && Array.isArray(d.affected) ); } export function renderToolResult( result: { content: Array<{ type: string; text?: string }>; details?: unknown; isError?: boolean }, expanded: boolean, theme: Theme, ): Component { const raw = firstText(result.content); const details = result.details as TaskToolDetails | undefined; if (result.isError || !isValidTaskToolDetails(details)) { const rawLines = raw.split("\n").filter(Boolean); return new TaskResultComponent((width) => { const lines = expanded ? rawLines : rawLines.slice(0, 1); return lines.map((line, index) => theme.fg("error", `${index === 0 ? "✗ " : " "}${line}`)); }); } return new TaskResultComponent((width) => { const lines = [theme.fg("success", "✓ ") + theme.fg("muted", summaryFor(details))]; if (!expanded || details.affected.length === 0) return lines; if (details.action === "list") return [...lines, ...renderTree(details.affected, theme, width)]; if (details.action === "get") return [...lines, ...renderTaskDetails(details.affected, theme, width)]; return [...lines, ...renderFlatTasks(details.affected, theme, width)]; }); }