/** * Mission Control TUI Phases Panel * * Left panel displaying: * - List of phases with status icons * - Navigation indicator (arrow) for selected phase * - Keyboard navigation support * * Icons (Nerd Font): * - Done: \uf058 (󰗠) check * - In Progress: \uf192 () dot-circle * - Pending: \uf096 (󰄱) empty box * - Selected: \uf054 (󰁔) arrow */ import type { Theme } from "@mariozechner/pi-coding-agent"; import type { Phase } from "../state.js"; import { truncateToWidth } from "@mariozechner/pi-tui"; import { missionSuccess, missionWarning, strike } from "./styles.js"; export interface PhasesPanelProps { phases: Phase[]; selectedIndex: number; focused: boolean; } /** * Render a single phase line * Styling: selected=white bold, running=orange/warning, unselected non-running=gray/muted */ function renderPhaseLine( phase: Phase, index: number, selectedIndex: number, width: number, theme: Theme, focused: boolean ): string { const isSelected = index === selectedIndex; const isRunning = phase.status === "in_progress"; const baseText = `Phase ${index + 1}: ${phase.name}`; let styled: string; if (phase.status === "removed") { const removedText = strike(baseText); styled = isSelected && focused ? theme.fg("text", theme.bold(removedText)) : theme.fg("muted", removedText); } else if (isSelected && focused) { styled = theme.fg("text", theme.bold(baseText)); } else if (isRunning) { styled = missionWarning(baseText); } else if (phase.status === "done") { styled = missionSuccess(baseText); } else { styled = theme.fg("muted", baseText); } // Truncate to fit return truncateToWidth(styled, width); } /** * Render the phases panel */ export function renderPhasesPanel( width: number, height: number, props: PhasesPanelProps, theme: Theme ): string[] { const { phases, selectedIndex, focused } = props; const lines: string[] = []; // Header const header = theme.fg("text", theme.bold("PHASES")); lines.push(truncateToWidth(header, width)); if (phases.length === 0) { const emptyMsg = theme.fg("muted", " No phases yet"); lines.push(truncateToWidth(emptyMsg, width)); } else { // Calculate visible range if list is taller than available height const availableHeight = height - 1; let startIdx = 0; let endIdx = phases.length; if (phases.length > availableHeight) { // Scroll to keep selected item visible const halfHeight = Math.floor(availableHeight / 2); startIdx = Math.max(0, selectedIndex - halfHeight); endIdx = Math.min(phases.length, startIdx + availableHeight); // Adjust start if we're near the end if (endIdx - startIdx < availableHeight) { startIdx = Math.max(0, endIdx - availableHeight); } } // Render visible phases for (let i = startIdx; i < endIdx; i++) { const phase = phases[i]; const line = renderPhaseLine(phase, i, selectedIndex, width, theme, focused); lines.push(line); } } // Pad to height while (lines.length < height) { lines.push(""); } return lines.slice(0, height); } /** * Phases panel component class */ export class PhasesPanelComponent { private props: PhasesPanelProps; private cachedWidth?: number; private cachedHeight?: number; private cachedLines?: string[]; constructor(phases: Phase[] = [], focused = false) { this.props = { phases, selectedIndex: 0, focused }; } update(phases: Phase[], selectedIndex?: number): void { this.props.phases = phases; if (selectedIndex !== undefined) { // Clamp to valid range this.props.selectedIndex = Math.max(0, Math.min(selectedIndex, phases.length - 1)); } else if (this.props.selectedIndex >= phases.length && phases.length > 0) { this.props.selectedIndex = phases.length - 1; } this.invalidate(); } setFocused(focused: boolean): void { this.props.focused = focused; this.invalidate(); } isFocused(): boolean { return this.props.focused; } getSelectedIndex(): number { return this.props.selectedIndex; } getSelectedPhase(): Phase | undefined { return this.props.phases[this.props.selectedIndex]; } navigateUp(): void { if (this.props.selectedIndex > 0) { this.props.selectedIndex--; this.invalidate(); } } navigateDown(): void { if (this.props.selectedIndex < this.props.phases.length - 1) { this.props.selectedIndex++; this.invalidate(); } } selectFirstInProgress(): void { const idx = this.props.phases.findIndex(p => p.status === "in_progress"); if (idx !== -1) { this.props.selectedIndex = idx; this.invalidate(); } } render(width: number, height: number, theme: Theme): string[] { if (this.cachedLines && this.cachedWidth === width && this.cachedHeight === height) { return this.cachedLines; } this.cachedLines = renderPhasesPanel(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; } }