423 lines
16 KiB
TypeScript
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)];
|
|
});
|
|
}
|