/** * review-list.tsx — Shared ReviewList Ink component for genesis and plan review. * * Renders a split-pane layout with: * - Header: title, subtitle, accepted/rejected counts, issue counts * - Left pane: selectable list of items with accept/reject indicators * - Right pane: detail view for the selected item * - Status bar: keybind hints * * Supports keyboard navigation: * Space — toggle accept/reject for the selected item * E — edit selected item (opens edit modal) * Shift+K/J — move selected item up/down in order * A — approve plan (with confirmation) * V — view validation issues * R — toggle all accept/reject * Q / Escape — cancel and discard * * The component is parameterized by ReviewListConfig to support both * genesis review (quests) and plan review (tasks). */ import React, { useState, useCallback } from "react"; import { Box, Text, useInput } from "ink"; import type { ReviewItem, ReviewListConfig, ReviewValidationIssue, } from "./review-list-types"; import { PRIORITY_ABBREV, PRIORITY_COLORS, DIFFICULTY_COLORS, } from "./review-list-types"; import { createReviewState, toggleAccept, toggleAll, moveItem, selectItem, updateItem, getAcceptedItems, getCounts, type ReviewState, } from "./use-review-list"; // --------------------------------------------------------------------------- // Props // --------------------------------------------------------------------------- export interface ReviewListProps { /** Initial items to display in the review list. */ items: ReviewItem[]; /** Configuration for labels, callbacks, and edit fields. */ config: ReviewListConfig; } // --------------------------------------------------------------------------- // Sub-components // --------------------------------------------------------------------------- /** Header showing title, subtitle, and counts. */ function ReviewHeader({ config, accepted, rejected, issues, }: { config: ReviewListConfig; accepted: number; rejected: number; issues: ReviewValidationIssue[]; }): React.ReactElement { const errors = issues.filter((i) => i.level === "error"); const warnings = issues.filter((i) => i.level === "warning"); return ( wombo-combo {config.title} | {accepted} accepted {rejected > 0 && ( <> {rejected} rejected )} {config.subtitle && {config.subtitle}} {errors.length > 0 && ( <> {errors.length} error{errors.length !== 1 ? "s" : ""} )} {warnings.length > 0 && ( <> {warnings.length} warning{warnings.length !== 1 ? "s" : ""} )} ); } /** A single item row in the list pane. */ function ListItemRow({ item, index, isSelected, }: { item: ReviewItem; index: number; isSelected: boolean; }): React.ReactElement { const num = `${index + 1}.`.padEnd(4); const pColor = PRIORITY_COLORS[item.priority] ?? "white"; const pAbbr = PRIORITY_ABBREV[item.priority] ?? item.priority.slice(0, 4).toUpperCase(); const maxIdLen = 24; const displayId = item.id.length > maxIdLen ? item.id.slice(0, maxIdLen - 1) + "\u2026" : item.id.padEnd(maxIdLen); if (!item.accepted) { return ( {isSelected ? ">" : " "} \u2718 {num} {item.id} REJECTED ); } return ( {isSelected ? ">" : " "} \u2714 {num} {displayId} {pAbbr} {item.dependsOn.length > 0 && ( \u2192{item.dependsOn.length} )} ); } /** Detail pane for the selected item. */ function DetailPane({ item, allItems, config, }: { item: ReviewItem | undefined; allItems: ReviewItem[]; config: ReviewListConfig; }): React.ReactElement { if (!item) { return ( No {config.itemLabel} selected ); } const pColor = PRIORITY_COLORS[item.priority] ?? "white"; const dColor = DIFFICULTY_COLORS[item.difficulty] ?? "white"; return ( {item.title} {/* Status */} Status: {item.accepted ? ( ACCEPTED ) : ( REJECTED )} {/* Priority */} Priority: {item.priority} {/* Difficulty */} Difficulty: {item.difficulty} {/* Extra detail fields */} {item.detailFields.map((field, i) => ( {field.label.padEnd(11)} {field.value} ))} {/* Dependencies */} {item.dependsOn.length > 0 && ( Depends on: {item.dependsOn.map((dep) => { const depItem = allItems.find((i) => i.id === dep); const icon = depItem ? depItem.accepted ? "\u2714" : "\u2718" : "?"; const iconColor = depItem ? depItem.accepted ? "green" : "red" : "yellow"; return ( {icon} {dep} {!depItem && (unknown)} ); })} )} {/* Detail sections */} {item.detailSections.map((section, i) => { if (section.items.length === 0) return null; return ( {section.label}: {section.items.map((text, j) => ( {section.prefix ?? ""} {text} ))} ); })} {/* Validation issues for this item */} {config.issues.filter((i) => i.itemId === item.id).length > 0 && ( Validation Issues: {config.issues .filter((i) => i.itemId === item.id) .map((issue, i) => ( {issue.level === "error" ? "\u2718" : "\u26A0"} {issue.message} ))} )} ); } /** Status bar showing keybind hints. */ function StatusBar({ config, accepted, hasErrors, }: { config: ReviewListConfig; accepted: number; hasErrors: boolean; }): React.ReactElement { return ( Keys: Space toggle E edit Shift+J/K reorder R toggle all V validation A approve Q cancel {accepted} {accepted !== 1 ? config.itemLabelPlural : config.itemLabel} will be created on approval {hasErrors && ( | Plan has validation errors )} ); } /** Validation issues popup content. */ function ValidationPopup({ issues, onClose, }: { issues: ReviewValidationIssue[]; onClose: () => void; }): React.ReactElement { useInput((input, key) => { if (key.escape || key.return || input === "q") { onClose(); } }); const errors = issues.filter((i) => i.level === "error"); const warnings = issues.filter((i) => i.level === "warning"); return ( 0 ? "red" : "yellow"} paddingX={1}> 0 ? "red" : "yellow"}> Validation Issues {issues.length === 0 ? ( No validation issues found. The plan looks good! ) : ( <> {errors.length > 0 && ( Errors ({errors.length}): {errors.map((e, i) => ( \u2718 {e.itemId ? `[${e.itemId}] ` : ""}{e.message} ))} )} {warnings.length > 0 && ( Warnings ({warnings.length}): {warnings.map((w, i) => ( \u26A0 {w.itemId ? `[${w.itemId}] ` : ""}{w.message} ))} )} )} Press Esc or Enter to close ); } /** Message popup (e.g., "no items accepted"). */ function MessagePopup({ title, message, color, onClose, }: { title: string; message: string; color: string; onClose: () => void; }): React.ReactElement { useInput((input, key) => { if (key.escape || key.return || input === "q") { onClose(); } }); return ( {title} {message} Press Esc or Enter to close ); } /** Confirm popup for approve action. */ function ConfirmPopup({ title, message, onConfirm, onCancel, }: { title: string; message: string; onConfirm: () => void; onCancel: () => void; }): React.ReactElement { useInput((input, key) => { if (input === "y" || input === "Y") { onConfirm(); } else if (input === "n" || input === "N" || key.escape) { onCancel(); } }); return ( {title} {message} Y \u2014 Confirm | N / Esc \u2014 Cancel ); } // --------------------------------------------------------------------------- // Modal types // --------------------------------------------------------------------------- type ModalState = | { type: "none" } | { type: "validation" } | { type: "message"; title: string; message: string; color: string } | { type: "confirm"; title: string; message: string; onConfirm: () => void } | { type: "edit" }; // --------------------------------------------------------------------------- // Main Component // --------------------------------------------------------------------------- /** * ReviewList — shared Ink component for genesis and plan review screens. * * Renders a split-pane review interface with list, detail, and status bar. * All behavior is driven by the config prop. */ export function ReviewList({ items, config }: ReviewListProps): React.ReactElement { const [state, setState] = useState(() => createReviewState(items) ); const [modal, setModal] = useState({ type: "none" }); const counts = getCounts(state); const selectedItem = state.items[state.selectedIndex]; const hasErrors = config.issues.some((i) => i.level === "error"); // ----------------------------------------------------------------------- // Input handling // ----------------------------------------------------------------------- const handleInput = useCallback( (input: string, key: import("ink").Key) => { // Block input when a modal is open if (modal.type !== "none") return; // Q / Escape — cancel if (input === "q" || key.escape) { config.onCancel(); return; } // Space — toggle accept/reject if (input === " ") { setState((s) => toggleAccept(s)); return; } // R — toggle all if (input === "r") { setState((s) => toggleAll(s)); return; } // A — approve if (input === "a") { const accepted = getAcceptedItems(state); if (accepted.length === 0) { setModal({ type: "message", title: `No ${config.itemLabelPlural} Accepted`, message: `You must accept at least one ${config.itemLabel} before approving the plan.\nUse Space to toggle ${config.itemLabelPlural}, or R to accept all.`, color: "yellow", }); return; } // Check for broken dependencies const acceptedIds = new Set(accepted.map((i) => i.id)); const brokenDeps: string[] = []; for (const item of accepted) { for (const dep of item.dependsOn) { if (!acceptedIds.has(dep)) { brokenDeps.push(`"${item.id}" depends on rejected ${config.itemLabel} "${dep}"`); } } } if (brokenDeps.length > 0) { setModal({ type: "message", title: "Broken Dependencies", message: `The following accepted ${config.itemLabelPlural} depend on rejected ${config.itemLabelPlural}:\n\n${brokenDeps.map((b) => ` - ${b}`).join("\n")}\n\nEither accept the dependencies or remove the depends_on references by editing the ${config.itemLabelPlural} (E key).`, color: "red", }); return; } setModal({ type: "confirm", title: config.approveTitle, message: config.approveBody(accepted.length), onConfirm: () => { config.onApprove(accepted); }, }); return; } // V — validation issues if (input === "v") { setModal({ type: "validation" }); return; } // E — edit if (input === "e") { setModal({ type: "edit" }); return; } // Shift+K — move up if (input === "K") { setState((s) => moveItem(s, -1)); return; } // Shift+J — move down if (input === "J") { setState((s) => moveItem(s, 1)); return; } // Arrow keys for navigation if (key.upArrow) { setState((s) => selectItem(s, s.selectedIndex - 1)); return; } if (key.downArrow) { setState((s) => selectItem(s, s.selectedIndex + 1)); return; } }, [modal, state, config] ); useInput(handleInput, { isActive: modal.type === "none" }); // ----------------------------------------------------------------------- // Render // ----------------------------------------------------------------------- // Modal overlays if (modal.type === "validation") { return ( setModal({ type: "none" })} /> ); } if (modal.type === "message") { return ( setModal({ type: "none" })} /> ); } if (modal.type === "confirm") { return ( setModal({ type: "none" })} /> ); } if (modal.type === "edit") { // Edit modal is handled by ReviewEditModal component (separate file) // For now, close immediately if no edit fields defined if (config.editFields.length === 0 || !selectedItem) { setModal({ type: "none" }); return Loading...; } // Import and render the edit modal return ( { setState((s) => updateItem(s, s.selectedIndex, updatedItem)); setModal({ type: "none" }); }} onCancel={() => setModal({ type: "none" })} /> ); } return ( {/* Header */} {/* Main content: list + detail */} {/* Left pane: item list */} {config.listLabel} {state.items.length === 0 ? ( No {config.itemLabelPlural} in plan ) : ( state.items.map((item, index) => ( )) )} {/* Right pane: detail */} {/* Status bar */} ); } // --------------------------------------------------------------------------- // Inline Edit Modal (simple version — full version in review-edit-modal.tsx) // --------------------------------------------------------------------------- /** Inline edit modal that steps through editable fields. */ function ReviewEditModalInline({ item, config, onDone, onCancel, }: { item: ReviewItem; config: ReviewListConfig; onDone: (updated: ReviewItem) => void; onCancel: () => void; }): React.ReactElement { const [fieldIdx, setFieldIdx] = useState(0); const [currentItem, setCurrentItem] = useState({ ...item }); const [inputValue, setInputValue] = useState(""); const field = config.editFields[fieldIdx]; // Initialize input value for current field React.useEffect(() => { if (field) { setInputValue(config.getEditFieldValue(currentItem, field.key)); } }, [fieldIdx]); const nextField = useCallback(() => { if (fieldIdx >= config.editFields.length - 1) { onDone(currentItem); } else { setFieldIdx((i) => i + 1); } }, [fieldIdx, config.editFields.length, currentItem, onDone]); const applyAndNext = useCallback( (value: string) => { if (field) { const updated = config.setEditFieldValue(currentItem, field.key, value); setCurrentItem(updated); } nextField(); }, [field, currentItem, config, nextField] ); useInput((input, key) => { if (!field) return; // Escape — skip field / cancel edit if (key.escape) { nextField(); return; } if (field.type === "select" && field.options) { // For select fields, use up/down to change selection and Enter to confirm if (key.return || input === " ") { applyAndNext(inputValue); return; } if (key.upArrow) { const options = field.options; const currentIdx = options.findIndex((o) => o.value === inputValue); const newIdx = currentIdx <= 0 ? options.length - 1 : currentIdx - 1; setInputValue(options[newIdx].value); return; } if (key.downArrow) { const options = field.options; const currentIdx = options.findIndex((o) => o.value === inputValue); const newIdx = currentIdx >= options.length - 1 ? 0 : currentIdx + 1; setInputValue(options[newIdx].value); return; } return; } // Text/textarea fields if (key.return && field.type !== "textarea") { applyAndNext(inputValue); return; } // Ctrl+S to submit textarea if (key.ctrl && input === "s") { applyAndNext(inputValue); return; } if (key.backspace) { setInputValue((v) => v.slice(0, -1)); return; } if (key.return && field.type === "textarea") { setInputValue((v) => v + "\n"); return; } if (input && !key.ctrl && !key.meta) { setInputValue((v) => v + input); } }); if (!field) { return Loading...; } const stepLabel = `Field ${fieldIdx + 1}/${config.editFields.length}`; return ( Edit: {item.id} {stepLabel} \u2014 {field.label} {field.hint && {field.hint}} Enter to save, Esc to skip {field.type === "select" && field.options ? ( // Select list field.options.map((opt) => ( {opt.value === inputValue ? "\u276F " : " "} {opt.label} )) ) : ( // Text/textarea input {inputValue || " "} )} ); }