/** * Mission Control TUI Past Runs List * * Displays archived/completed runs in idle state: * - Run ID with date * - Status color * - Completion percentage * - Keyboard selection */ import type { Theme } from "@mariozechner/pi-coding-agent"; import type { Run, RunStatus } from "../state.js"; import { truncateToWidth } from "@mariozechner/pi-tui"; import { missionSuccess, missionWarning } from "./styles.js"; const ICON_SELECTED = "> "; const ICON_UNSELECTED = " "; export interface PastRunItem { runId: string; status: RunStatus; startedAt: string; completedTasks: number; totalTasks: number; durationSeconds?: number; // Total duration if run finished } export interface PastRunsProps { runs: PastRunItem[]; selectedIndex: number; } /** * Apply Mission Control status colors for past runs. */ function styleStatus(status: RunStatus, text: string, theme: Theme): string { switch (status) { case "done": return missionSuccess(text); case "failed": return theme.fg("error", text); case "in_progress": return missionWarning(text); case "paused": return missionWarning(text); default: return theme.fg("muted", text); } } /** * Format date as numeric Y-M-D 24h format */ function formatDate(isoDate: string): string { try { const date = new Date(isoDate); const y = date.getFullYear(); const m = (date.getMonth() + 1).toString().padStart(2, "0"); const d = date.getDate().toString().padStart(2, "0"); const h = date.getHours().toString().padStart(2, "0"); const min = date.getMinutes().toString().padStart(2, "0"); return `${y}-${m}-${d} ${h}:${min}`; } catch { return isoDate; } } /** * Format duration in HH:MM:SS */ function formatDuration(seconds: number): string { const h = Math.floor(seconds / 3600).toString().padStart(2, "0"); const m = Math.floor((seconds % 3600) / 60).toString().padStart(2, "0"); const s = (seconds % 60).toString().padStart(2, "0"); return `${h}:${m}:${s}`; } /** * Extract runs from Run objects */ export function extractPastRuns(runs: Run[]): PastRunItem[] { return runs.map(run => { let completed = 0; let total = 0; for (const phase of run.phases) { for (const task of phase.tasks) { if (task.status === "removed") continue; total++; if (task.status === "done") { completed++; } } } // Calculate duration if run has finished let durationSeconds: number | undefined; if (run.finish_at) { const start = new Date(run.started_at).getTime(); const end = new Date(run.finish_at).getTime(); durationSeconds = Math.round((end - start) / 1000); } return { runId: run.run_id, status: run.status, startedAt: run.started_at, completedTasks: completed, totalTasks: total, durationSeconds }; }).sort((a, b) => new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime()); } /** * Render a single past run line * Format: "> run-id (YYYY-MM-DD HH:MM) [duration] 45% (5/11)" */ function renderPastRunLine( run: PastRunItem, index: number, selectedIndex: number, width: number, theme: Theme ): string { const isSelected = index === selectedIndex; const date = formatDate(run.startedAt); const percent = run.totalTasks > 0 ? Math.round((run.completedTasks / run.totalTasks) * 100) : 0; // Include duration if available const durationStr = run.durationSeconds ? ` [${formatDuration(run.durationSeconds)}]` : ""; const selectIndicator = isSelected ? ICON_SELECTED : ICON_UNSELECTED; const baseText = `${selectIndicator}${run.runId} (${date})${durationStr} ${percent}% (${run.completedTasks}/${run.totalTasks})`; // Apply styling let styled: string; if (isSelected) { styled = theme.fg("text", theme.bold(baseText)); } else { styled = styleStatus(run.status, baseText, theme); } return truncateToWidth(styled, width); } /** * Render the past runs list */ export function renderPastRuns( width: number, height: number, props: PastRunsProps, theme: Theme ): string[] { const { runs, selectedIndex } = props; const lines: string[] = []; // Header const header = theme.fg("text", theme.bold("PAST RUNS")); lines.push(truncateToWidth(header, width)); lines.push(""); // spacer if (runs.length === 0) { const emptyMsg = theme.fg("muted", " No past runs"); lines.push(truncateToWidth(emptyMsg, width)); } else { // Calculate visible range const availableHeight = height - 2; let startIdx = 0; let endIdx = runs.length; if (runs.length > availableHeight) { const halfHeight = Math.floor(availableHeight / 2); startIdx = Math.max(0, selectedIndex - halfHeight); endIdx = Math.min(runs.length, startIdx + availableHeight); if (endIdx - startIdx < availableHeight) { startIdx = Math.max(0, endIdx - availableHeight); } } // Render visible runs for (let i = startIdx; i < endIdx; i++) { const run = runs[i]; const line = renderPastRunLine(run, i, selectedIndex, width, theme); lines.push(line); } } // Pad to height while (lines.length < height) { lines.push(""); } return lines.slice(0, height); } /** * Past runs component class */ export class PastRunsComponent { private props: PastRunsProps; private cachedWidth?: number; private cachedHeight?: number; private cachedLines?: string[]; constructor(runs: PastRunItem[] = []) { this.props = { runs, selectedIndex: 0 }; } updateRuns(runs: PastRunItem[], selectedIndex?: number): void { this.props.runs = runs; if (selectedIndex !== undefined) { this.props.selectedIndex = Math.max(0, Math.min(selectedIndex, runs.length - 1)); } else if (this.props.selectedIndex >= runs.length && runs.length > 0) { this.props.selectedIndex = runs.length - 1; } this.invalidate(); } getSelectedIndex(): number { return this.props.selectedIndex; } getSelectedRun(): PastRunItem | undefined { return this.props.runs[this.props.selectedIndex]; } navigateUp(): void { if (this.props.selectedIndex > 0) { this.props.selectedIndex--; this.invalidate(); } } navigateDown(): void { if (this.props.selectedIndex < this.props.runs.length - 1) { this.props.selectedIndex++; this.invalidate(); } } render(width: number, height: number, theme: Theme): string[] { if (this.cachedLines && this.cachedWidth === width && this.cachedHeight === height) { return this.cachedLines; } this.cachedLines = renderPastRuns(width, height, this.props, theme); this.cachedWidth = width; this.cachedHeight = height; return this.cachedLines; } invalidate(): void { this.cachedWidth = undefined; this.cachedHeight = undefined; this.cachedLines = undefined; } }