/** * run-preflight.tsx — Ink preflight confirmation screen. * * Replaces the neo-blessed tuiPreflightConfirm() with an Ink-based version. * Shows tasks being launched, agent assignments, and allows the user to * reject specialized agents (in monitored mode) or cycle registry modes. * * Layout: * ┌──────────────────────────────────────────┐ * │ LAUNCH PREFLIGHT mode: auto │ * ├──────────────────────────────────────────┤ * │ Task ID Agent Status │ * │ > my-task frontend-dev ✓ │ * │ other-task generalist ✓ │ * ├──────────────────────────────────────────┤ * │ Enter: launch Tab: cycle mode x: reject│ * │ Esc: cancel │ * └──────────────────────────────────────────┘ */ import React, { useState, useCallback } from "react"; import { render as inkRender, Box, Text, useInput } from "ink"; import type { AgentResolution } from "../lib/agent-registry"; import { isSpecializedAgent } from "../lib/agent-registry"; import type { Task } from "../lib/tasks"; import type { AgentRegistryMode, WomboConfig } from "../config"; // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- export interface PreflightResult { /** Whether the user confirmed and wants to proceed */ proceed: boolean; /** * Final agent assignments after user edits. * Task IDs mapped to their resolutions. Rejected agents are replaced * with generalist fallbacks (name: null). */ agents: Map; /** Final registry mode (may differ from config if user changed it) */ mode: AgentRegistryMode; } interface PreflightRow { taskId: string; taskTitle: string; agentName: string; agentType: string | null; isSpecialized: boolean; rejected: boolean; } interface InkPreflightResult { proceed: boolean; agents: Map; mode: AgentRegistryMode; } // --------------------------------------------------------------------------- // Component // --------------------------------------------------------------------------- interface PreflightViewProps { rows: PreflightRow[]; initialMode: AgentRegistryMode; taskCount: number; agents: Map; onFinish: (result: InkPreflightResult) => void; } function PreflightView({ rows: initialRows, initialMode, taskCount, agents, onFinish, }: PreflightViewProps): React.ReactElement { const [rows, setRows] = useState(initialRows); const [selectedIndex, setSelectedIndex] = useState(0); const [mode, setMode] = useState(initialMode); const finish = useCallback( (proceed: boolean) => { const finalAgents = new Map(agents); for (const row of rows) { if (row.rejected) { finalAgents.set(row.taskId, { taskId: row.taskId, name: null, rawContent: null, fromCache: false, agentType: null, }); } } onFinish({ proceed, agents: finalAgents, mode }); }, [rows, agents, mode, onFinish] ); useInput((input, key) => { // Escape / Ctrl-C — cancel if (key.escape || (input === "c" && key.ctrl)) { finish(false); return; } // Enter — launch if (key.return) { finish(true); return; } // Tab — cycle mode if (key.tab) { const modes: AgentRegistryMode[] = ["auto", "monitored", "disabled"]; setMode((prev) => { const idx = modes.indexOf(prev); return modes[(idx + 1) % modes.length]; }); return; } // Up/k if (key.upArrow || input === "k") { setSelectedIndex((i) => Math.max(0, i - 1)); return; } // Down/j if (key.downArrow || input === "j") { setSelectedIndex((i) => Math.min(rows.length - 1, i + 1)); return; } // x — reject (monitored mode only) if (input === "x" && mode === "monitored") { setRows((prev) => { const next = [...prev]; const row = next[selectedIndex]; if (row && row.isSpecialized) { next[selectedIndex] = { ...row, rejected: !row.rejected }; } return next; }); return; } }); const modeColor = mode === "auto" ? "green" : mode === "monitored" ? "yellow" : "red"; const specializedCount = rows.filter((r) => r.isSpecialized && !r.rejected).length; const rejectedCount = rows.filter((r) => r.rejected).length; return ( {/* Header */} LAUNCH PREFLIGHT mode: {mode} {taskCount} task(s) ready to launch {/* Separator */} {"─".repeat(60)} {/* Task rows */} {rows.map((row, i) => { const isSelected = i === selectedIndex; const cursor = isSelected ? ">" : " "; const taskId = row.taskId.length > 28 ? row.taskId.slice(0, 27) + "…" : row.taskId; let agentDisplay: React.ReactElement; if (row.rejected) { agentDisplay = (rejected); } else if (row.isSpecialized) { agentDisplay = {row.agentName.slice(0, 20)}; } else { agentDisplay = generalist; } const statusIcon = row.rejected ? ( ) : ( ); return ( {" "}{cursor}{" "} {taskId} {agentDisplay} {statusIcon} ); })} {/* Separator */} {"─".repeat(60)} {/* Status bar */} Enter : launch Tab : cycle mode Esc : cancel {mode === "monitored" && ( <> x : reject agent )} {" "}Specialized: {specializedCount} Generalist: {rows.length - specializedCount} Rejected: {rejectedCount} ); } // --------------------------------------------------------------------------- // Public API // --------------------------------------------------------------------------- /** * Ink-based preflight confirmation. Drop-in replacement for the blessed * tuiPreflightConfirm(). */ export function inkPreflightConfirm( tasks: Task[], agents: Map, config: WomboConfig ): Promise { return new Promise((resolve) => { const rows: PreflightRow[] = tasks.map((task) => { const resolution = agents.get(task.id); const specialized = resolution && isSpecializedAgent(resolution); return { taskId: task.id, taskTitle: task.title, agentName: specialized ? resolution.name : "generalist", agentType: specialized ? resolution.agentType : null, isSpecialized: !!specialized, rejected: false, }; }); const instance = inkRender( { instance.unmount(); resolve(result); }} /> ); }); } // --------------------------------------------------------------------------- // Console Preflight (fallback for --no-tui / non-TTY) // --------------------------------------------------------------------------- /** * Console-based preflight confirmation. * Displays the launch plan and asks for y/n confirmation. * Does NOT support interactive agent rejection (that's TUI-only). */ export async function consolePreflightConfirm( tasks: Task[], agents: Map, config: WomboConfig ): Promise { const mode = config.agentRegistry.mode; console.log(`\n${"═".repeat(60)}`); console.log(` LAUNCH PREFLIGHT`); console.log(`${"═".repeat(60)}`); console.log(` Registry mode: ${mode}`); console.log(` Tasks: ${tasks.length}`); console.log(`${"─".repeat(60)}`); for (const task of tasks) { const resolution = agents.get(task.id); const agentLabel = resolution && isSpecializedAgent(resolution) ? `${resolution.name} (${resolution.fromCache ? "cached" : "fetched"})` : "generalist"; console.log(` ${task.id.padEnd(30)} → ${agentLabel}`); } console.log(`${"─".repeat(60)}`); // In non-interactive environments (piped stdin), just proceed if (!process.stdin.isTTY) { console.log(` Non-interactive mode — proceeding automatically.\n`); return { proceed: true, agents, mode }; } const answer = await new Promise((resolve) => { process.stdout.write(" Proceed? [Y/n] "); process.stdin.setEncoding("utf-8"); process.stdin.resume(); process.stdin.once("data", (data) => { process.stdin.pause(); resolve(data.toString().trim().toLowerCase()); }); }); const proceed = answer === "" || answer === "y" || answer === "yes"; if (!proceed) { console.log(" Launch cancelled.\n"); } return { proceed, agents, mode }; }