Files
harness-tools/extensions/todo-herdr/src/ui.ts
T

423 lines
16 KiB
TypeScript

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<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, connectRoots = false): 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, 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<TaskToolDetails>;
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)];
});
}