/** * quest-wizard.tsx — Ink component for the 6-step quest creation wizard. * * Migrated from tui-quest-wizard.ts (neo-blessed) to Ink (React-based). * * Steps: ID → Title → Goal → Priority → Difficulty → HITL * * Two render modes: * 1. Overlay: renders as a child component inside an existing Ink tree. * 2. Standalone: use runQuestWizardInk() which creates/destroys its own * Ink instance (see bottom of this file). * * Usage (overlay): * { ... }} * onCancelled={() => { ... }} * checkDuplicateId={(id) => loadQuest(projectRoot, id)?.status ?? null} * saveQuest={(quest) => saveQuest(projectRoot, quest)} * /> * * Usage (standalone): * const quest = await runQuestWizardInk({ * projectRoot: "/path/to/project", * baseBranch: "main", * prefill: { goal: "..." }, * }); */ import React, { useState, useCallback } from "react"; import { Box, Text, useInput } from "ink"; import { TextInput } from "./text-input"; import { SelectInput, type SelectInputItem } from "./select-input"; import { createBlankQuest, type QuestHitlMode, VALID_HITL_MODES } from "../lib/quest"; import { VALID_PRIORITIES, VALID_DIFFICULTIES } from "../lib/task-schema"; import type { Quest } from "../lib/quest"; import type { Priority, Difficulty } from "../lib/tasks"; // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- export interface QuestWizardPrefill { id?: string; title?: string; goal?: string; priority?: Priority; difficulty?: Difficulty; hitlMode?: QuestHitlMode; } export interface QuestWizardProps { /** Base branch for the new quest's branch. */ baseBranch: string; /** Optional pre-filled values for wizard fields. */ prefill?: QuestWizardPrefill; /** Called with the newly created Quest after successful creation. */ onCreated: (quest: Quest) => void; /** Called when user cancels (Escape from step 1). */ onCancelled: () => void; /** * Check if a quest ID already exists. * Returns the quest's status string if it exists, or null if not. * This dependency injection allows testing without filesystem access. */ checkDuplicateId: (id: string) => string | null; /** * Save the quest to persistent storage. * Dependency injection for testing without filesystem. */ saveQuest: (quest: Quest) => void; } // --------------------------------------------------------------------------- // Step definitions // --------------------------------------------------------------------------- type WizardStep = "id" | "title" | "goal" | "priority" | "difficulty" | "hitl"; const STEPS: WizardStep[] = ["id", "title", "goal", "priority", "difficulty", "hitl"]; type WizardPhase = "editing" | "confirmation" | "error"; // --------------------------------------------------------------------------- // Priority item config // --------------------------------------------------------------------------- const PRIORITY_COLORS: Record = { critical: "red", high: "yellow", medium: "white", low: "gray", wishlist: "gray", }; function buildPriorityItems(): SelectInputItem[] { return (VALID_PRIORITIES as readonly Priority[]).map((p) => ({ label: p, value: p, hint: p === "medium" ? "(default)" : undefined, })); } function buildDifficultyItems(): SelectInputItem[] { return (VALID_DIFFICULTIES as readonly Difficulty[]).map((d) => ({ label: d, value: d, hint: d === "medium" ? "(default)" : undefined, })); } const HITL_DESCRIPTIONS: Record = { yolo: "Full autonomy, no interruptions", cautious: "Agent blocks on uncertainty, user answers in TUI", supervised: "Like cautious, but prompt encourages asking often", }; function buildHitlItems(): SelectInputItem[] { return (VALID_HITL_MODES as readonly QuestHitlMode[]).map((m) => ({ label: m, value: m, hint: `${m === "yolo" ? "(default) " : ""}— ${HITL_DESCRIPTIONS[m]}`, })); } // --------------------------------------------------------------------------- // ID validation // --------------------------------------------------------------------------- const KEBAB_CASE_RE = /^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/; // --------------------------------------------------------------------------- // Component // --------------------------------------------------------------------------- /** * QuestWizard — 6-step quest creation wizard rendered as an Ink component. * * Can be used as an overlay (child of existing Ink tree) or standalone * (via runQuestWizardInk). */ export function QuestWizard({ baseBranch, prefill, onCreated, onCancelled, checkDuplicateId, saveQuest: saveQuestFn, }: QuestWizardProps): React.ReactElement { // Wizard state const [stepIndex, setStepIndex] = useState(0); const [phase, setPhase] = useState("editing"); const [error, setError] = useState(null); const [creationError, setCreationError] = useState(null); // Collected values const [questId, setQuestId] = useState(prefill?.id ?? ""); const [questTitle, setQuestTitle] = useState(prefill?.title ?? ""); const [questGoal, setQuestGoal] = useState(prefill?.goal ?? ""); const [questPriority, setQuestPriority] = useState(prefill?.priority ?? "medium"); const [questDifficulty, setQuestDifficulty] = useState(prefill?.difficulty ?? "medium"); const [questHitl, setQuestHitl] = useState(prefill?.hitlMode ?? "yolo"); // Created quest (for confirmation display) const [createdQuest, setCreatedQuest] = useState(null); const currentStep = STEPS[stepIndex]; const stepLabel = `Step ${stepIndex + 1}/${STEPS.length}`; // ----------------------------------------------------------------------- // Navigation // ----------------------------------------------------------------------- const goBack = useCallback(() => { setError(null); if (stepIndex <= 0) { onCancelled(); return; } setStepIndex((prev) => prev - 1); }, [stepIndex, onCancelled]); const advanceOrFinish = useCallback(() => { setError(null); const nextIdx = stepIndex + 1; if (nextIdx >= STEPS.length) { // All steps done — create quest try { const quest = createBlankQuest(questId, questTitle, questGoal, baseBranch, { priority: questPriority, difficulty: questDifficulty, hitlMode: questHitl, }); saveQuestFn(quest); setCreatedQuest(quest); setPhase("confirmation"); onCreated(quest); } catch (err: unknown) { const msg = err instanceof Error ? err.message : String(err); setCreationError(msg); setPhase("error"); } return; } setStepIndex(nextIdx); }, [stepIndex, questId, questTitle, questGoal, baseBranch, questPriority, questDifficulty, questHitl, saveQuestFn, onCreated]); // ----------------------------------------------------------------------- // Text input handlers (ID, Title, Goal) // ----------------------------------------------------------------------- const handleTextSubmit = useCallback( (value: string) => { const trimmed = value.trim(); if (currentStep === "id") { if (!trimmed) { setError("ID cannot be empty"); return; } if (!KEBAB_CASE_RE.test(trimmed)) { setError("ID must be kebab-case (lowercase letters, numbers, hyphens)"); return; } const existing = checkDuplicateId(trimmed); if (existing) { setError(`Quest "${trimmed}" already exists (${existing})`); return; } setQuestId(trimmed); advanceOrFinish(); } else if (currentStep === "title") { if (!trimmed) { setError("Title cannot be empty"); return; } setQuestTitle(trimmed); advanceOrFinish(); } else if (currentStep === "goal") { if (!trimmed) { setError("Goal cannot be empty"); return; } setQuestGoal(trimmed); advanceOrFinish(); } }, [currentStep, checkDuplicateId, advanceOrFinish] ); // ----------------------------------------------------------------------- // Escape key handler for text input steps // ----------------------------------------------------------------------- useInput( (_input, key) => { if ( key.escape && phase === "editing" && (currentStep === "id" || currentStep === "title" || currentStep === "goal") ) { goBack(); } }, { isActive: phase === "editing" && (currentStep === "id" || currentStep === "title" || currentStep === "goal") } ); // ----------------------------------------------------------------------- // Select input handlers (Priority, Difficulty, HITL) // ----------------------------------------------------------------------- const handlePrioritySelect = useCallback( (item: SelectInputItem) => { setQuestPriority(item.value); advanceOrFinish(); }, [advanceOrFinish] ); const handleDifficultySelect = useCallback( (item: SelectInputItem) => { setQuestDifficulty(item.value); advanceOrFinish(); }, [advanceOrFinish] ); const handleHitlSelect = useCallback( (item: SelectInputItem) => { setQuestHitl(item.value); advanceOrFinish(); }, [advanceOrFinish] ); // ----------------------------------------------------------------------- // Error phase — press Escape to cancel // ----------------------------------------------------------------------- useInput( (_input, key) => { if (key.escape && phase === "error") { onCancelled(); } }, { isActive: phase === "error" } ); // ----------------------------------------------------------------------- // Render: Confirmation phase // ----------------------------------------------------------------------- if (phase === "confirmation" && createdQuest) { return ( ✔ Quest created! ID: {createdQuest.id} {createdQuest.title} Priority: {createdQuest.priority} | Difficulty: {createdQuest.difficulty} | HITL: {createdQuest.hitlMode} Branch: quest/{createdQuest.id} | Base: {createdQuest.baseBranch} ); } // ----------------------------------------------------------------------- // Render: Error phase // ----------------------------------------------------------------------- if (phase === "error") { return ( ✘ Failed to create quest {creationError} Press Esc to return ); } // ----------------------------------------------------------------------- // Render: Editing phase (steps) // ----------------------------------------------------------------------- // Build status line showing collected values so far const statusParts: string[] = []; if (questId && stepIndex > 0) statusParts.push(`ID: ${questId}`); if (questTitle && stepIndex > 1) statusParts.push(`Title: ${questTitle}`); if (stepIndex > 3) statusParts.push(`Priority: ${questPriority}`); if (stepIndex > 4) statusParts.push(`Difficulty: ${questDifficulty}`); const statusText = statusParts.join(" | "); // Compute initial index for select inputs based on prefill const priorityInitialIndex = Math.max( 0, (VALID_PRIORITIES as readonly string[]).indexOf(questPriority) ); const difficultyInitialIndex = Math.max( 0, (VALID_DIFFICULTIES as readonly string[]).indexOf(questDifficulty) ); const hitlInitialIndex = Math.max( 0, (VALID_HITL_MODES as readonly string[]).indexOf(questHitl) ); return ( {/* Header: New Quest */} New Quest {/* Step header */} {currentStep === "id" && ( <> {stepLabel} — Quest ID Kebab-case identifier (e.g. auth-overhaul, search-api) Esc to cancel • Ctrl+S to submit )} {currentStep === "title" && ( <> {stepLabel} — Title Human-readable name for the quest Esc to go back • Ctrl+S to submit )} {currentStep === "goal" && ( <> {stepLabel} — Goal What should this quest achieve? Esc to go back • Ctrl+S to submit )} {currentStep === "priority" && ( <> {stepLabel} — Priority Select with Enter, Esc to go back )} {currentStep === "difficulty" && ( <> {stepLabel} — Difficulty Select with Enter, Esc to go back )} {currentStep === "hitl" && ( <> {stepLabel} — HITL Mode Human-in-the-loop mode for agents. Select with Enter, Esc to go back )} {/* Input area */} {currentStep === "id" && ( )} {currentStep === "title" && ( )} {currentStep === "goal" && ( )} {currentStep === "priority" && ( )} {currentStep === "difficulty" && ( )} {currentStep === "hitl" && ( )} {/* Error message */} {error && ( {error} )} {/* Status line */} {statusText && ( {statusText} )} ); }