import type { Theme } from "@earendil-works/pi-coding-agent"; import { Key, matchesKey, truncateToWidth, type Component, type TUI, visibleWidth, wrapTextWithAnsi, } from "@earendil-works/pi-tui"; import { getTaskUpdatedAt, type TodoTask } from "./state.ts"; const WIDGET_TASK_LIMIT = 3; /** Return the first non-blank physical line without changing the stored text. */ export function firstDisplayLine(text: string, fallback = "(blank)"): string { for (const line of text.split(/\r\n|\n|\r/)) { if (line.trim().length > 0) return line; } return fallback; } function sortByRecentUpdate(tasks: readonly TodoTask[]): TodoTask[] { return [...tasks].sort((a, b) => getTaskUpdatedAt(b).localeCompare(getTaskUpdatedAt(a))); } export class TodoWidget implements Component { private tasks: readonly TodoTask[]; private readonly tui: TUI; private readonly theme: Theme; constructor(tasks: readonly TodoTask[], tui: TUI, theme: Theme) { this.tasks = tasks; this.tui = tui; this.theme = theme; } update(tasks: readonly TodoTask[]): void { this.tasks = tasks; this.tui.requestRender(); } render(width: number): string[] { if (width <= 0 || this.tasks.length === 0) return []; const visible = sortByRecentUpdate(this.tasks).slice(0, WIDGET_TASK_LIMIT); const heading = this.theme.fg("accent", this.theme.bold(`Todos · ${this.tasks.length} active`)); const hint = this.theme.fg("dim", "Run /todos for details"); const gap = width - visibleWidth(heading) - visibleWidth(hint); const header = gap > 0 ? `${heading}${" ".repeat(gap)}${hint}` : heading; const lines = [truncateToWidth(header, width)]; for (const task of visible) { const title = `${this.theme.fg("accent", `#${task.id}`)} ${firstDisplayLine(task.text)}`; lines.push(truncateToWidth(title, width)); const latest = task.progress.at(-1); if (latest) { lines.push(truncateToWidth(` ${this.theme.fg("muted", "↳")} ${firstDisplayLine(latest.text)}`, width)); } } const hidden = this.tasks.length - visible.length; if (hidden > 0) { lines.push(truncateToWidth(this.theme.fg("dim", `… ${hidden} more`), width)); } return lines; } invalidate(): void { // Rendering is stateless; the current theme is applied on every render. } } function wrapWithIndent(text: string, width: number, firstIndent: string, restIndent = firstIndent): string[] { const firstWidth = Math.max(1, width - firstIndent.length); const restWidth = Math.max(1, width - restIndent.length); const physicalLines = text.split(/\r\n|\n|\r/); const result: string[] = []; for (const physicalLine of physicalLines) { if (physicalLine.length === 0) { result.push(firstIndent); continue; } const wrapped = wrapTextWithAnsi(physicalLine, result.length === 0 ? firstWidth : restWidth); for (const line of wrapped) { const indent = result.length === 0 ? firstIndent : restIndent; result.push(indent + line); } } return result.length > 0 ? result : [firstIndent]; } /** Read-only, scrollable full-screen view used by /todos. */ export class TodoViewer implements Component { private readonly tasks: readonly TodoTask[]; private readonly tui: TUI; private readonly theme: Theme; private readonly onClose: () => void; private offset = 0; private pageSize = 1; private maxOffset = 0; private cachedWidth?: number; private cachedContent?: string[]; constructor(tasks: readonly TodoTask[], tui: TUI, theme: Theme, onClose: () => void) { this.tasks = tasks; this.tui = tui; this.theme = theme; this.onClose = onClose; } handleInput(data: string): void { if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c"))) { this.onClose(); return; } let next = this.offset; if (matchesKey(data, Key.up)) next -= 1; else if (matchesKey(data, Key.down)) next += 1; else if (matchesKey(data, Key.pageUp)) next -= this.pageSize; else if (matchesKey(data, Key.pageDown)) next += this.pageSize; else if (matchesKey(data, Key.home)) next = 0; else if (matchesKey(data, Key.end)) next = this.maxOffset; else return; this.offset = Math.max(0, Math.min(this.maxOffset, next)); this.tui.requestRender(); } render(width: number): string[] { if (width <= 0) return []; const content = this.getContent(width); const viewportHeight = Math.max(6, this.tui.terminal.rows - 4); this.pageSize = Math.max(1, viewportHeight - 4); this.maxOffset = Math.max(0, content.length - this.pageSize); this.offset = Math.max(0, Math.min(this.maxOffset, this.offset)); const activeCount = this.tasks.filter((task) => task.completedAt === undefined && task.cancelledAt === undefined).length; const title = this.theme.fg("accent", this.theme.bold(`Todos · ${activeCount} active`)); const border = this.theme.fg("borderMuted", "─".repeat(Math.max(0, width))); const body = content.slice(this.offset, this.offset + this.pageSize); const position = this.maxOffset > 0 ? ` · ${this.offset + 1}-${Math.min(content.length, this.offset + body.length)}/${content.length}` : ""; const help = this.theme.fg("dim", `↑↓ scroll · PgUp/PgDn · Esc close${position}`); return [ truncateToWidth(title, width), truncateToWidth(border, width, ""), ...body.map((line) => truncateToWidth(line, width, "")), truncateToWidth(border, width, ""), truncateToWidth(help, width), ]; } invalidate(): void { this.cachedWidth = undefined; this.cachedContent = undefined; } private getContent(width: number): string[] { if (this.cachedContent && this.cachedWidth === width) return this.cachedContent; const innerWidth = Math.max(1, width - 2); const active = this.tasks.filter((task) => task.completedAt === undefined && task.cancelledAt === undefined); const completed = this.tasks.filter((task) => task.completedAt !== undefined); const cancelled = this.tasks.filter((task) => task.cancelledAt !== undefined); const lines: string[] = []; lines.push(this.theme.fg("accent", this.theme.bold(`Active Tasks (${active.length})`)), ""); if (active.length === 0) { lines.push(this.theme.fg("dim", " No unfinished tasks."), ""); } for (const task of active) { lines.push(this.theme.fg("accent", `#${task.id}`)); lines.push(...wrapWithIndent(task.text, width, " ")); if (task.progress.length > 0) { lines.push("", this.theme.fg("muted", ` Progress (${task.progress.length})`)); for (const [index, progress] of task.progress.entries()) { const prefix = ` ${index + 1}. `; lines.push(...wrapWithIndent(progress.text, width, prefix, " ".repeat(prefix.length))); } } lines.push(""); } lines.push(this.theme.fg("accent", this.theme.bold(`Completed Tasks (${completed.length})`)), ""); if (completed.length === 0) { lines.push(this.theme.fg("dim", " No completed tasks.")); } else { for (const task of completed) { const line = `${this.theme.fg("success", "✓")} ${this.theme.fg("accent", `#${task.id}`)} ${firstDisplayLine(task.text)}`; lines.push(truncateToWidth(line, innerWidth)); } } lines.push("", this.theme.fg("accent", this.theme.bold(`Cancelled Tasks (${cancelled.length})`)), ""); if (cancelled.length === 0) { lines.push(this.theme.fg("dim", " No cancelled tasks.")); } else { for (const task of cancelled) { const line = `${this.theme.fg("warning", "✗")} ${this.theme.fg("accent", `#${task.id}`)} ${firstDisplayLine(task.text)}`; lines.push(truncateToWidth(line, innerWidth)); } } this.cachedWidth = width; this.cachedContent = lines; return lines; } }