/** * Mission Control TUI Dashboard * * Main dashboard component that composes: * - Header (run ID, timer, phase) * - Phases panel (left) * - Tasks panel (right) * - Progress bar (bottom) * * Features: * - Keyboard navigation (Up/Down/Left/Right) * - Esc to close * - Enter to init new mission when idle * - Timed refresh for clock/file state * - Overlay support via ctx.ui.custom(..., { overlay: true }) */ import type { Theme } from "@mariozechner/pi-coding-agent"; import type { TUI } from "@mariozechner/pi-tui"; import { matchesKey, Key, truncateToWidth, visibleWidth } from "@mariozechner/pi-tui"; import type { Run, State, Phase, Task } from "../state.js"; import { readState, readRun, listRuns as listAllRuns, type Run as RunType } from "../state.js"; import { HeaderComponent, formatElapsedTime } from "./header.js"; import { PhasesPanelComponent } from "./phases-panel.js"; import { TasksPanelComponent } from "./tasks-panel.js"; import { ProgressBarComponent } from "./progress-bar.js"; import { IdleViewComponent } from "./idle-view.js"; import { PastRunsComponent, extractPastRuns } from "./past-runs.js"; // Panel focus states type PanelFocus = "phases" | "tasks"; type DashboardMode = "idle" | "active"; export interface DashboardProps { onClose: () => void; onInitMission?: () => void; } /** * Mission Control Dashboard Component * * Usage: * ```typescript * const handle = ctx.ui.custom( * (tui, theme, keybindings, done) => new MissionDashboard({ onClose: done }), * { overlay: true } * ); * ``` */ export class MissionDashboard { // Sub-components private header: HeaderComponent; private phasesPanel: PhasesPanelComponent; private tasksPanel: TasksPanelComponent; private progressBar: ProgressBarComponent; private idleView: IdleViewComponent; private pastRuns: PastRunsComponent; // State private mode: DashboardMode = "idle"; private run: Run | null = null; private state: State | null = null; private refreshInterval: NodeJS.Timeout | null = null; private cachedWidth?: number; private cachedLines?: string[]; private tui: TUI | null = null; private theme: Theme | null = null; // Callbacks private onClose: () => void; private onInitMission?: () => void; constructor(props: DashboardProps) { this.onClose = props.onClose; this.onInitMission = props.onInitMission; // Initialize sub-components this.header = new HeaderComponent(null, "idle", true, true); this.phasesPanel = new PhasesPanelComponent([], true); this.tasksPanel = new TasksPanelComponent(null, false); this.progressBar = new ProgressBarComponent(null, ""); this.idleView = new IdleViewComponent({ onInitMission: () => this.handleInitMission(), onClose: () => this.onClose() }); this.pastRuns = new PastRunsComponent(); // Initial data load this.refreshData(); } /** * Start the refresh interval for clock/timer updates */ startRefresh(tui: TUI): void { this.tui = tui; if (this.refreshInterval) { clearInterval(this.refreshInterval); } // Refresh every second for timer updates this.refreshInterval = setInterval(() => { this.refreshData(); tui.requestRender(); }, 1000); } /** * Stop the refresh interval */ stopRefresh(): void { if (this.refreshInterval) { clearInterval(this.refreshInterval); this.refreshInterval = null; } } /** * Refresh data from files */ refreshData(): void { // Read state this.state = readState(); // Determine mode and load run data if (this.state.active_run_id) { this.mode = "active"; const runData = readRun(this.state.active_run_id); if (runData) { const previousSelectedPhaseId = this.phasesPanel.getSelectedPhase()?.id; this.run = runData; this.header.update(this.run, this.state.current_phase, true, true); this.progressBar.update(this.run, this.state.current_status_message); const selectedPhaseIndex = previousSelectedPhaseId ? runData.phases.findIndex((phase) => phase.id === previousSelectedPhaseId) : undefined; if (selectedPhaseIndex !== undefined && selectedPhaseIndex >= 0) { this.phasesPanel.update(runData.phases, selectedPhaseIndex); } else { this.phasesPanel.update(runData.phases); } const selectedPhase = this.phasesPanel.getSelectedPhase(); this.tasksPanel.update(selectedPhase ?? null); } } else { this.mode = "idle"; this.run = null; this.header.update(null, "idle", true, true); // Update idle view with past runs const allRuns = listAllRuns() .filter(r => r.run !== null) .map(r => r.run!); this.idleView.update(allRuns, true, true); const items = extractPastRuns(allRuns); this.pastRuns.updateRuns(items); } this.invalidate(); } /** * Handle init mission action (Enter in idle mode) */ private handleInitMission(): void { if (this.onInitMission) { this.onInitMission(); } } /** * Handle keyboard input */ handleInput(data: string): void { // Global keys if (matchesKey(data, Key.escape)) { this.onClose(); return; } const isEnter = matchesKey(data, Key.enter); const isUp = matchesKey(data, Key.up); const isDown = matchesKey(data, Key.down); const isLeft = matchesKey(data, Key.left); const isRight = matchesKey(data, Key.right); if (this.mode === "idle") { // Idle mode: pass to idle view this.idleView.handleInput(data, isEnter, false, isUp, isDown); this.requestRender(); } else { // Active mode: handle panel navigation if (isLeft) { this.phasesPanel.setFocused(true); this.tasksPanel.setFocused(false); this.requestRender(); } else if (isRight) { this.phasesPanel.setFocused(false); this.tasksPanel.setFocused(true); this.requestRender(); } else if (isUp) { if (this.phasesPanel.isFocused()) { this.phasesPanel.navigateUp(); // Update tasks panel to show tasks for selected phase const phase = this.phasesPanel.getSelectedPhase(); this.tasksPanel.update(phase ?? null); } else { this.tasksPanel.navigateUp(); } this.requestRender(); } else if (isDown) { if (this.phasesPanel.isFocused()) { this.phasesPanel.navigateDown(); // Update tasks panel to show tasks for selected phase const phase = this.phasesPanel.getSelectedPhase(); this.tasksPanel.update(phase ?? null); } else { this.tasksPanel.navigateDown(); } this.requestRender(); } } } /** * Request a re-render from TUI */ private requestRender(): void { if (this.tui) { this.tui.requestRender(); } } /** * Render the dashboard */ render(width: number, theme: Theme): string[] { this.theme = theme; // Use cached if dimensions match if (this.cachedLines && this.cachedWidth === width) { return this.cachedLines; } if (this.mode === "idle") { this.cachedLines = this.renderIdle(width, theme); } else { this.cachedLines = this.renderActive(width, theme); } this.cachedWidth = width; return this.cachedLines; } private renderShortcutLegend(width: number, theme: Theme, mode: DashboardMode): string[] { const items = mode === "idle" ? [ { key: "[ ENTER ]", label: "Init Mission" }, { key: "[ ↑/↓ ]", label: "Past Runs" }, { key: "[ ←/→ ]", label: "Panels" }, { key: "[ ESC ]", label: "Close" }, ] : [ { key: "[ ↑/↓ ]", label: "Lists" }, { key: "[ ←/→ ]", label: "Panels" }, { key: "[ ESC ]", label: "Close" }, ]; const gap = 2; const cellWidth = Math.max(12, Math.floor((width - gap * (items.length - 1)) / items.length)); const formatCell = (text: string) => text.padEnd(cellWidth, " "); const keysLine = items .map((item) => theme.fg("text", theme.bold(formatCell(item.key)))) .join(" ".repeat(gap)); const labelsLine = items .map((item) => theme.fg("dim", formatCell(item.label))) .join(" ".repeat(gap)); return [truncateToWidth(keysLine, width), truncateToWidth(labelsLine, width)]; } /** * Render idle mode */ private renderIdle(width: number, theme: Theme): string[] { const contentHeight = 12; const lines: string[] = []; lines.push(...this.header.render(width, theme)); lines.push(theme.fg("border", "─".repeat(width))); lines.push(...this.renderShortcutLegend(width, theme, "idle")); lines.push(theme.fg("dim", "Elapsed: --:--:--")); lines.push(""); lines.push(...this.idleView.render(width, contentHeight, theme)); lines.push(""); lines.push(...this.progressBar.render(width, theme)); return lines; } /** * Render active mode with a simple 40/60 split */ private renderActive(width: number, theme: Theme): string[] { const lines: string[] = []; const contentHeight = 12; lines.push(...this.header.render(width, theme)); lines.push(theme.fg("border", "─".repeat(width))); lines.push(...this.renderShortcutLegend(width, theme, "active")); lines.push(theme.fg("dim", `Elapsed: ${this.run ? formatElapsedTime(this.run.started_at) : "--:--:--"}`)); lines.push(""); const gap = 4; const availableWidth = Math.max(16, width - gap); let leftWidth: number; let rightWidth: number; if (availableWidth <= 44) { leftWidth = Math.max(8, Math.floor(availableWidth * 0.4)); rightWidth = Math.max(8, availableWidth - leftWidth); } else { leftWidth = Math.floor(availableWidth * 0.4); rightWidth = availableWidth - leftWidth; } const phasesLines = this.phasesPanel.render(leftWidth, contentHeight, theme); const tasksLines = this.tasksPanel.render(rightWidth, contentHeight, theme); for (let i = 0; i < contentHeight; i++) { const leftLine = phasesLines[i] ?? ""; const rightLine = tasksLines[i] ?? ""; const leftPadding = Math.max(0, leftWidth - visibleWidth(leftLine)); lines.push(truncateToWidth(leftLine + " ".repeat(leftPadding + gap) + rightLine, width)); } lines.push(""); lines.push(...this.progressBar.render(width, theme)); return lines; } /** * Invalidate cached render output */ invalidate(): void { this.cachedWidth = undefined; this.cachedLines = undefined; this.header.invalidate(); this.phasesPanel.invalidate(); this.tasksPanel.invalidate(); this.progressBar.invalidate(); this.idleView.invalidate(); this.pastRuns.invalidate(); } /** * Get current mode */ getMode(): DashboardMode { return this.mode; } /** * Get active run (if any) */ getRun(): Run | null { return this.run; } /** * Dispose of resources */ dispose(): void { this.stopRefresh(); } } /** * Open the Mission Control dashboard * * Usage in extension: * ```typescript * pi.registerCommand("mission", { * handler: async (_args, ctx) => { * const result = await ctx.ui.custom( * (tui, theme, _kb, done) => { * const dashboard = new MissionDashboard({ * onClose: () => done(null), * onInitMission: () => { * // Trigger mission_init tool * done("init"); * } * }); * dashboard.startRefresh(tui); * return dashboard; * }, * { overlay: true } * ); * * if (result === "init") { * // Handle init action * } * } * }); * ``` */ export async function openMissionControl( ctx: { ui: { custom: (factory: (tui: TUI, theme: Theme, keybindings: unknown, done: (result: string | null) => void) => MissionDashboard, options: { overlay: boolean }) => Promise; notify: (message: string, type: "info" | "warning" | "error") => void } } ): Promise { await ctx.ui.custom( (tui: TUI, theme: Theme, _keybindings: unknown, done: (result: string | null) => void) => { const dashboard = new MissionDashboard({ onClose: () => { dashboard.dispose(); done(null); }, onInitMission: () => { dashboard.dispose(); done("init"); } }); dashboard.startRefresh(tui); return dashboard; }, { overlay: true } ); } export default MissionDashboard;