/** * run-quest-picker.tsx — Standalone launcher for the QuestPickerView. * * Creates and destroys its own Ink render instance, returning a Promise * that resolves with the user's action (select quest, plan, genesis, etc.). * * Manages all data loading (quests, tasks, usage) and state (selection index, * toggle active, delete, create) internally — the QuestPickerView is a * pure view component that receives everything via props. */ import React, { useState, useCallback, useEffect, useRef } from "react"; import { getStableStdin } from "./bun-stdin"; import { render } from "ink"; import { QuestPickerView, type QuestSummary } from "./quest-picker"; import type { Quest } from "../lib/quest"; import { QUEST_STATUS_ORDER, getQuestTaskIds } from "../lib/quest"; import { loadAllQuests, saveQuest, deleteQuest } from "../lib/quest-store"; import { loadTasks, getDoneTaskIds, loadArchive } from "../lib/tasks"; import { loadUsageRecords, totalUsage, groupBy as groupUsageBy } from "../lib/token-usage"; import type { UsageTotals } from "../lib/token-usage"; import type { WomboConfig } from "../config"; import type { ErrandSpec } from "../lib/errand-planner"; // --------------------------------------------------------------------------- // Action Type (matches the old QuestPickerAction) // --------------------------------------------------------------------------- export type QuestPickerAction = | { type: "select"; questId: string | null } | { type: "plan"; questId: string } | { type: "genesis"; vision: string } | { type: "errand"; spec: ErrandSpec } | { type: "wishlist" } | { type: "onboarding" } | { type: "quit" }; // --------------------------------------------------------------------------- // Options // --------------------------------------------------------------------------- export interface RunQuestPickerOptions { projectRoot: string; config: WomboConfig; } // --------------------------------------------------------------------------- // Stateful Wrapper Component // --------------------------------------------------------------------------- function QuestPickerApp({ projectRoot, config, onAction, }: { projectRoot: string; config: WomboConfig; onAction: (action: QuestPickerAction) => void; }) { const [quests, setQuests] = useState([]); const [totalTaskCount, setTotalTaskCount] = useState(0); const [selectedIndex, setSelectedIndex] = useState(0); const [questUsage, setQuestUsage] = useState>(new Map()); const [overallUsage, setOverallUsage] = useState(null); // Load data on mount const loadData = useCallback(() => { const allQuests = loadAllQuests(projectRoot); const tasksData = loadTasks(projectRoot, config); const archiveData = loadArchive(projectRoot, config); const doneIds = getDoneTaskIds(tasksData, archiveData.tasks); setTotalTaskCount(tasksData.tasks.length); // Sort quests: by status order (active first) const sorted = [...allQuests].sort((a, b) => { return (QUEST_STATUS_ORDER[a.status] ?? 99) - (QUEST_STATUS_ORDER[b.status] ?? 99); }); // Build summaries const summaries: QuestSummary[] = sorted.map((quest) => { const questTids = getQuestTaskIds(quest.id, tasksData.tasks); const totalTasks = questTids.length; const doneTasks = questTids.filter((id) => doneIds.has(id)).length; const completionPct = totalTasks > 0 ? Math.round((doneTasks / totalTasks) * 100) : 0; return { quest, totalTasks, doneTasks, completionPct }; }); setQuests(summaries); // Load token usage try { const records = loadUsageRecords(projectRoot); if (records.length > 0) { setQuestUsage(groupUsageBy(records, "quest_id")); setOverallUsage(totalUsage(records)); } } catch { // Non-critical } }, [projectRoot, config]); useEffect(() => { loadData(); }, [loadData]); // Handlers const handleSelect = useCallback( (questId: string | null) => { onAction({ type: "select", questId }); }, [onAction] ); const handleQuit = useCallback(() => { onAction({ type: "quit" }); }, [onAction]); const handlePlan = useCallback(() => { if (selectedIndex === 0 || !quests[selectedIndex - 1]) return; const summary = quests[selectedIndex - 1]; onAction({ type: "plan", questId: summary.quest.id }); }, [selectedIndex, quests, onAction]); const handleGenesis = useCallback(() => { // The old blessed version showed an input modal for vision text. // With Ink, we'll signal "genesis" and the parent (tui.ts) handles // prompting for vision text. For now, pass a placeholder action // that the parent can handle by launching a vision prompt flow. onAction({ type: "genesis", vision: "" }); }, [onAction]); const handleErrand = useCallback(() => { // Signal the parent to show the errand wizard onAction({ type: "errand", spec: { description: "" } }); }, [onAction]); const handleWishlist = useCallback(() => { onAction({ type: "wishlist" }); }, [onAction]); const handleOnboarding = useCallback(() => { onAction({ type: "onboarding" }); }, [onAction]); const handleToggleActive = useCallback(() => { if (selectedIndex === 0) return; const summary = quests[selectedIndex - 1]; if (!summary) return; const quest = summary.quest; if (quest.status === "active") { quest.status = "paused"; } else if (quest.status === "draft" || quest.status === "paused" || quest.status === "planning") { quest.status = "active"; if (!quest.started_at) { quest.started_at = new Date().toISOString(); } } else { return; // completed/abandoned can't toggle } saveQuest(projectRoot, quest); loadData(); }, [selectedIndex, quests, projectRoot, loadData]); const handleDelete = useCallback(() => { if (selectedIndex === 0) return; const summary = quests[selectedIndex - 1]; if (!summary) return; // Delete immediately (the old flow used a confirm dialog, but // since we're inside Ink, we'll just delete — the user pressed D // intentionally). For safety, we could add a ConfirmDialog overlay, // but keeping it simple for now matches the integration scope. deleteQuest(projectRoot, summary.quest.id); loadData(); // Clamp selection const maxIdx = quests.length; // quests.length because "All Tasks" is index 0 if (selectedIndex >= maxIdx) { setSelectedIndex(Math.max(0, maxIdx - 1)); } }, [selectedIndex, quests, projectRoot, loadData]); const handleCreate = useCallback(() => { // Signal parent to run the quest wizard // The parent will call runQuestWizardInk and then come back onAction({ type: "select", questId: "__create__" }); }, [onAction]); return ( ); } // --------------------------------------------------------------------------- // Standalone Launcher // --------------------------------------------------------------------------- /** * Run the quest picker as a standalone Ink instance. * Returns the user's chosen action. */ export function runQuestPickerInk( opts: RunQuestPickerOptions ): Promise { const { projectRoot, config } = opts; return new Promise((resolve) => { let instance: ReturnType; const handleAction = (action: QuestPickerAction) => { instance.unmount(); resolve(action); }; process.stdin.resume(); // keep event loop alive between renders instance = render( , { exitOnCtrlC: false, stdin: getStableStdin() } ); }); }