/** * Mission Control TUI Tasks Panel * * Right panel displaying: * - Tasks for the selected phase * - Status icons including spinner for in-progress * - Navigation indicator * * Icons (Nerd Font): * - Done: \uf058 (󰗠) check * - In Progress: \uf1ce (󰑐) spinner/sync * - Pending: \uf096 (󰄱) empty box * - Failed: \uf057 (󰅙) times */ import type { Theme } from "@mariozechner/pi-coding-agent"; import type { Task, Phase } from "../state.js"; import { truncateToWidth } from "@mariozechner/pi-tui"; import { missionSuccess, missionWarning, strike } from "./styles.js"; export interface TasksPanelProps { phase: Phase | null; tasks: Task[]; selectedIndex: number; focused: boolean; } /** * Render a single task line * Styling: selected=white bold, running=orange/warning, unselected non-running=gray/muted */ function renderTaskLine( task: Task, index: number, selectedIndex: number, width: number, theme: Theme, focused: boolean ): string { const isSelected = focused && index === selectedIndex; const isRunning = task.status === "in_progress"; const baseText = `Task ${index + 1}: ${task.name}`; // Apply styling based on selection and running state let styled: string; if (task.status === "removed") { const removedText = strike(baseText); styled = isSelected ? theme.fg("text", theme.bold(removedText)) : theme.fg("muted", removedText); } else if (isSelected) { styled = theme.fg("text", theme.bold(baseText)); } else if (isRunning) { styled = missionWarning(baseText); } else if (task.status === "done") { styled = missionSuccess(baseText); } else if (task.status === "failed") { styled = theme.fg("error", baseText); } else { styled = theme.fg("muted", baseText); } return truncateToWidth(styled, width); } /** * Render the tasks panel */ export function renderTasksPanel( width: number, height: number, props: TasksPanelProps, theme: Theme ): string[] { const { phase, tasks, selectedIndex, focused } = props; const lines: string[] = []; const header = theme.fg("text", theme.bold("TASKS")); lines.push(truncateToWidth(header, width)); if (tasks.length === 0) { const emptyMsg = phase ? theme.fg("muted", " No tasks in this phase") : theme.fg("muted", " Select a phase to view tasks"); lines.push(truncateToWidth(emptyMsg, width)); } else { // Calculate visible range const availableHeight = height - 1; let startIdx = 0; let endIdx = tasks.length; if (tasks.length > availableHeight) { const halfHeight = Math.floor(availableHeight / 2); startIdx = Math.max(0, selectedIndex - halfHeight); endIdx = Math.min(tasks.length, startIdx + availableHeight); if (endIdx - startIdx < availableHeight) { startIdx = Math.max(0, endIdx - availableHeight); } } // Render visible tasks for (let i = startIdx; i < endIdx; i++) { const task = tasks[i]; const line = renderTaskLine(task, i, selectedIndex, width, theme, focused); lines.push(line); } } // Pad to height while (lines.length < height) { lines.push(""); } return lines.slice(0, height); } /** * Tasks panel component class * Tracks per-phase selection state to avoid auto-selecting when switching phases */ export class TasksPanelComponent { private props: TasksPanelProps; private cachedWidth?: number; private cachedHeight?: number; private cachedLines?: string[]; private phaseSelections: Map = new Map(); // phase id -> selected index constructor(phase: Phase | null = null, focused = false) { this.props = { phase, tasks: phase?.tasks ?? [], selectedIndex: -1, focused }; } update(phase: Phase | null, selectedIndex?: number): void { const prevPhaseId = this.props.phase?.id; // Save current selection for previous phase if (prevPhaseId && this.props.tasks.length > 0) { this.phaseSelections.set(prevPhaseId, this.props.selectedIndex); } this.props.phase = phase; this.props.tasks = phase?.tasks ?? []; if (selectedIndex !== undefined) { this.props.selectedIndex = Math.max(-1, Math.min(selectedIndex, this.props.tasks.length - 1)); } else { const phaseId = phase?.id; if (phaseId && this.phaseSelections.has(phaseId)) { const savedIndex = this.phaseSelections.get(phaseId)!; this.props.selectedIndex = Math.max(-1, Math.min(savedIndex, this.props.tasks.length - 1)); } else if (this.props.focused && this.props.tasks.length > 0) { const inProgressIdx = this.props.tasks.findIndex(t => t.status === "in_progress"); this.props.selectedIndex = inProgressIdx !== -1 ? inProgressIdx : 0; } else { this.props.selectedIndex = -1; } } this.invalidate(); } setFocused(focused: boolean): void { this.props.focused = focused; if (focused && this.props.selectedIndex < 0 && this.props.tasks.length > 0) { const inProgressIdx = this.props.tasks.findIndex(t => t.status === "in_progress"); this.props.selectedIndex = inProgressIdx !== -1 ? inProgressIdx : 0; } this.invalidate(); } isFocused(): boolean { return this.props.focused; } getSelectedIndex(): number { return this.props.selectedIndex; } getSelectedTask(): Task | undefined { if (this.props.selectedIndex < 0) return undefined; return this.props.tasks[this.props.selectedIndex]; } navigateUp(): void { if (this.props.selectedIndex < 0 && this.props.tasks.length > 0) { this.props.selectedIndex = 0; this.invalidate(); return; } if (this.props.selectedIndex > 0) { this.props.selectedIndex--; this.invalidate(); } } navigateDown(): void { if (this.props.selectedIndex < 0 && this.props.tasks.length > 0) { this.props.selectedIndex = 0; this.invalidate(); return; } if (this.props.selectedIndex < this.props.tasks.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 = renderTasksPanel(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; } }