/**
* 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 || " "}
)}
);
}