diff --git a/src/components/FormCheckboxMultiSelect.tsx b/src/components/FormCheckboxMultiSelect.tsx index 5055e2d7d..d69dcfe79 100644 --- a/src/components/FormCheckboxMultiSelect.tsx +++ b/src/components/FormCheckboxMultiSelect.tsx @@ -31,10 +31,14 @@ export function FormCheckboxMultiSelect({ return ( - - {name} - {helpText} - + {/* Either row is omitted when empty, so a caller whose surrounding + context already asks the question renders just the checkboxes. */} + {(name !== "" || helpText !== "") && ( + + {name !== "" && {name}} + {helpText !== "" && {helpText}} + + )} - - {name} - {helpText} - + {/* Either row is omitted when empty, so a caller whose surrounding + context already asks the question renders just the options. */} + {(name !== "" || helpText !== "") && ( + + {name !== "" && {name}} + {helpText !== "" && {helpText}} + + )} - - {name} - {helpText} - + {/* Either row is omitted when empty, so a caller whose surrounding + context already asks the question renders just the editor. */} + {(name !== "" || helpText !== "") && ( + + {name !== "" && {name}} + {helpText !== "" && {helpText}} + + )} {hidden > 0 && … (+{hidden} earlier lines)} {visible.length === 0 ? ( diff --git a/src/components/FormTextInput.tsx b/src/components/FormTextInput.tsx index 4c94c3153..456745ba9 100644 --- a/src/components/FormTextInput.tsx +++ b/src/components/FormTextInput.tsx @@ -31,10 +31,14 @@ export function FormTextInput({ }: FormTextInputProps) { return ( - - {name} - {helpText} - + {/* Either row is omitted when empty, so a caller whose surrounding + context already asks the question renders just the input. */} + {(name !== "" || helpText !== "") && ( + + {name !== "" && {name}} + {helpText !== "" && {helpText}} + + )} ))} + } /> + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> + {ADD_COMMANDS.map((command) => ( + + } + /> + ))} } /> diff --git a/src/components/wizard/Prerequisite.tsx b/src/components/wizard/Prerequisite.tsx new file mode 100644 index 000000000..82877e604 --- /dev/null +++ b/src/components/wizard/Prerequisite.tsx @@ -0,0 +1,47 @@ +import { Box, Text, useInput } from "ink"; +import { Layout } from "../Layout"; +import { darkTheme } from "../ui/_core.js"; + +const theme = darkTheme; + +export interface PrerequisiteProps { + breadcrumb: string[]; + description?: string; + // message says what the project lacks, e.g. "this project has no Gateways yet". + message: string; + // command is the CLI command that would add it, shown as the way forward. + command: string; + // onBack runs on esc; the screen the user came from. + onBack: () => void; +} + +// Prerequisite stands in for a wizard whose first question has no possible +// answer — a Policy needs a Policy Engine, a connector needs a Gateway. It +// names what is missing and how to add it, instead of offering an empty picker. +export function Prerequisite({ + breadcrumb, + description, + message, + command, + onBack, +}: PrerequisiteProps) { + useInput((_input, key) => { + if (key.escape) onBack(); + }); + + return ( + + + {message} + add one first with `{command}` + + + ); +} diff --git a/src/components/wizard/Step.tsx b/src/components/wizard/Step.tsx new file mode 100644 index 000000000..a44976d7f --- /dev/null +++ b/src/components/wizard/Step.tsx @@ -0,0 +1,43 @@ +import React from "react"; +import { Box, Text } from "ink"; +import { darkTheme } from "../ui/_core.js"; + +const theme = darkTheme; + +export interface StepProps { + // name is the step's stable key. Position is tracked by key rather than by + // index because branches have different lengths: a conditional step that + // appears or disappears must not shift the user to a different question. + name: string; + // title labels the step in the Stepper; defaults to `name`. + title?: string; + // question is the one-line prompt shown under the Stepper. The Stepper + // already names the step, so the body opens with the question itself. + question?: string; + children: React.ReactNode; +} + +// Step is one page of a : the stepper entry, the question line, and the +// field that collects the answer. +// +// One field per step. Every field registers its own useInput and answers enter, +// esc and the arrows itself; two fields mounted at once would both react to the +// same keystroke. The shell has no notion of focus and is not meant to grow +// one — a step that genuinely needs two related inputs should get a single +// compound field that owns one useInput and manages focus internally. +export function Step({ question, children }: StepProps) { + return ( + + {question !== undefined && {question}} + {children} + + ); +} + +// isStepElement narrows a child to a . React.Children.toArray already +// drops the `false`/`null` that a `{condition && }` branch produces, so +// filtering with this yields exactly the steps that apply to the current +// answers — which is how a wizard branches without a step-list useMemo. +export function isStepElement(child: React.ReactNode): child is React.ReactElement { + return React.isValidElement(child) && child.type === Step; +} diff --git a/src/components/wizard/Wizard.tsx b/src/components/wizard/Wizard.tsx new file mode 100644 index 000000000..59b838757 --- /dev/null +++ b/src/components/wizard/Wizard.tsx @@ -0,0 +1,284 @@ +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { Box, Text, useApp, useInput } from "ink"; +import { Layout } from "../Layout"; +import { Stepper, type Step as StepperStep } from "../ui/stepper"; +import { Divider } from "../ui/divider"; +import { Spinner } from "../ui/spinner"; +import { darkTheme } from "../ui/_core.js"; +import { isStepElement } from "./Step"; +import { WizardProvider, type KeyHint, type WizardControls } from "./context"; + +const theme = darkTheme; + +// ProgressEvent matches ProjectEvent's shape, so a ProjectManager generator can +// be handed to onSubmit unchanged and its messages stream into the event log. +export interface ProgressEvent { + message: string; +} + +// A submit either resolves once (a plain control-plane request) or streams +// progress events (the ProjectManager's async generators). Wizard renders both. +export type WizardSubmitResult = AsyncIterable | Promise; + +type Phase = + { kind: "form" } | { kind: "running" } | { kind: "success" } | { kind: "error"; error: Error }; + +export interface WizardProps { + breadcrumb: string[]; + // description is shown dimmed after the breadcrumb; pass the command's own + // description so the header matches what `--help` prints. + description?: string; + // children are the s. A `{condition && }` branch is dropped from + // the flow while the condition is false. + children: React.ReactNode; + onSubmit: () => WizardSubmitResult; + // onCancel runs when esc is pressed on the first step. + onCancel: () => void; + // runningLabel is the spinner label shown while onSubmit is in flight. + runningLabel: string; + // successLabel is the headline shown once onSubmit resolves. + successLabel: string; + // successHint is an optional dimmed line under successLabel. + successHint?: string; + // onDone runs when the success panel is acknowledged; defaults to tearing the + // TUI down, which is what a one-shot `project add ...` wants. + onDone?: () => void; + // onError decides what a failure does. "exit" rejects the waitUntilExit() + // that renderTuiAt awaits, so the error takes the normal CLI path and the + // process exits nonzero — right for a one-shot command. "retry" reports the + // message and returns to the form, right for a screen the user navigated to. + onError?: "exit" | "retry"; +} + +// Wizard is the shared shell behind every step-based flow: it derives the step +// list from its children, owns position, key handling and the +// form → running → success | error phases, and renders the standard +// Layout + Stepper frame. Screens supply only the questions. +export function Wizard({ + breadcrumb, + description, + children, + onSubmit, + onCancel, + runningLabel, + successLabel, + successHint, + onDone, + onError = "exit", +}: WizardProps) { + const { exit } = useApp(); + + const [phase, setPhase] = useState({ kind: "form" }); + const [events, setEvents] = useState([]); + // Fields publish their hints from an effect, which lands one paint after the + // first render. Seeding with the hint every field shares keeps that first + // frame from showing a footer with no action key in it. + const [hints, setHints] = useState([{ key: "enter", label: "continue" }]); + + const stepElements = useMemo( + () => React.Children.toArray(children).filter(isStepElement), + [children], + ); + + const steps: StepperStep[] = useMemo(() => { + const list = stepElements.map((element) => ({ + key: element.props.name, + title: element.props.title ?? element.props.name, + })); + // Position is keyed by name, so two steps sharing one would make advance() + // land on the first of them forever. Catch that at render time, where the + // author sees it, instead of as a wizard that quietly cannot move on. + const seen = new Set(); + for (const step of list) { + if (seen.has(step.key)) throw new Error(`duplicate `); + seen.add(step.key); + } + return list; + }, [stepElements]); + + const [stepKey, setStepKey] = useState(() => steps[0]?.key ?? ""); + + // Position is a key, not an index, so a branch that adds or removes steps + // does not move the user. The clamp covers the one case a key can vanish: + // a branch closing while its own step is somehow still active. + const found = steps.findIndex((step) => step.key === stepKey); + const index = found === -1 ? 0 : found; + const activeStep = stepElements[index]; + const isLast = index === steps.length - 1; + + // Ink drains buffered keystrokes synchronously, so a second enter can arrive + // before the form unmounts. The ref makes submitting idempotent. + const submitting = useRef(false); + + const submit = useCallback(async () => { + if (submitting.current) return; + submitting.current = true; + setPhase({ kind: "running" }); + try { + const result = onSubmit(); + if (isProgressStream(result)) { + for await (const event of result) { + setEvents((current) => [...current, event.message]); + } + } else { + await result; + } + setPhase({ kind: "success" }); + } catch (error) { + setPhase({ kind: "error", error: toError(error) }); + } finally { + submitting.current = false; + } + }, [onSubmit]); + + const controls: WizardControls = useMemo( + () => ({ + isLast, + setHints, + advance: () => { + if (isLast) { + void submit(); + return; + } + const next = steps[index + 1]; + if (next) setStepKey(next.key); + }, + back: () => { + if (index === 0) { + onCancel(); + return; + } + const previous = steps[index - 1]; + if (previous) setStepKey(previous.key); + }, + }), + [isLast, index, steps, submit, onCancel], + ); + + return ( + + + {phase.kind === "form" && ( + <> + + step.key)} + /> + + + {activeStep} + + )} + + {phase.kind !== "form" && ( + + + {phase.kind === "running" && } + {phase.kind === "success" && ( + exit())} + /> + )} + {phase.kind === "error" && onError === "exit" && } + {phase.kind === "error" && onError === "retry" && ( + setPhase({ kind: "form" })} /> + )} + + )} + + + ); +} + +// isProgressStream distinguishes an async generator from a promise. A promise +// has no Symbol.asyncIterator, so this is a safe discriminator. +function isProgressStream(result: WizardSubmitResult): result is AsyncIterable { + return ( + result !== null && + typeof result === "object" && + typeof (result as AsyncIterable)[Symbol.asyncIterator] === "function" + ); +} + +function toError(error: unknown): Error { + return error instanceof Error ? error : new Error(String(error)); +} + +// footerHints appends the keys that mean the same thing on every step to +// whatever the active field published. +function footerHints(phase: Phase, fieldHints: KeyHint[]): KeyHint[] { + if (phase.kind === "running") return [{ key: "ctl+c", label: "quit" }]; + if (phase.kind === "success") return [{ key: "enter", label: "continue" }]; + if (phase.kind === "error") { + return [ + { key: "esc", label: "back" }, + { key: "ctl+c", label: "quit" }, + ]; + } + return [...fieldHints, { key: "esc", label: "back" }, { key: "ctl+c", label: "quit" }]; +} + +function EventLog({ events }: { events: string[] }) { + return ( + + {events.map((message, i) => ( + + ✓ {message} + + ))} + + ); +} + +function SuccessPanel({ + label, + hint, + onContinue, +}: { + label: string; + hint?: string; + onContinue: () => void; +}) { + useInput((_input, key) => { + if (key.return || key.escape) onContinue(); + }); + + return ( + + + ✔ {label} + + {hint !== undefined && {hint}} + + ); +} + +// ExitOnError tears the TUI down through exit(error): that rejects the +// waitUntilExit() renderTuiAt awaits, so the failure is reported by the normal +// CLI error path instead of as a React stack trace. +function ExitOnError({ error }: { error: Error }) { + const { exit } = useApp(); + + useEffect(() => { + exit(error); + }, [exit, error]); + + return ✗ {error.message}; +} + +function RetryPanel({ error, onBack }: { error: Error; onBack: () => void }) { + useInput((_input, key) => { + if (key.escape || key.return) onBack(); + }); + + return ( + + ✗ {error.message} + esc returns to the form + + ); +} diff --git a/src/components/wizard/context.tsx b/src/components/wizard/context.tsx new file mode 100644 index 000000000..ce9eee284 --- /dev/null +++ b/src/components/wizard/context.tsx @@ -0,0 +1,51 @@ +import React, { useContext, useEffect, useRef } from "react"; + +export interface KeyHint { + key: string; + label: string; +} + +// WizardControls is what a field needs from the wizard around it: where to go +// next, where to go back to, and a way to tell the footer what its keys do. +export interface WizardControls { + // advance moves to the next step, or submits when the active step is last. + advance: () => void; + // back steps to the previous step, or cancels out of the wizard on the first. + back: () => void; + // isLast reports whether the active step is the final one, so a field can + // label its enter hint "submit" rather than "continue". + isLast: boolean; + // setHints replaces the footer's action hints. Fields call it via useKeyHints. + setHints: (hints: KeyHint[]) => void; +} + +const WizardContext = React.createContext(null); + +export const WizardProvider = WizardContext.Provider; + +export function useWizard(): WizardControls { + const controls = useContext(WizardContext); + if (!controls) { + throw new Error("wizard fields must be rendered inside a "); + } + return controls; +} + +// useKeyHints publishes the active field's footer hints. Each field declares +// what its own keys do, so never has to switch on step kind the way +// the hand-written wizards' hintsFor() does. +export function useKeyHints(hints: KeyHint[]): void { + const { setHints } = useWizard(); + + // The caller passes a fresh array literal on every render, so the effect + // runs every render — but publishes only when the content changed. Publishing + // the array itself unconditionally would re-render, publish, and re-render + // again forever; the ref remembers what the footer already shows. + const published = useRef(undefined); + useEffect(() => { + const signature = hints.map((hint) => `${hint.key}:${hint.label}`).join("|"); + if (published.current === signature) return; + published.current = signature; + setHints(hints); + }, [hints, setHints]); +} diff --git a/src/components/wizard/fields.tsx b/src/components/wizard/fields.tsx new file mode 100644 index 000000000..550398a13 --- /dev/null +++ b/src/components/wizard/fields.tsx @@ -0,0 +1,397 @@ +import { useState } from "react"; +import { Box, Text, useInput } from "ink"; +import type z from "zod"; +import { FormTextInput } from "../FormTextInput"; +import { FormRadioGroup } from "../FormRadioGroup"; +import { FormCheckboxMultiSelect } from "../FormCheckboxMultiSelect"; +import { FormTextArea } from "../FormTextArea"; +import { KeyValueTable } from "../KeyValueTable"; +import { darkTheme } from "../ui/_core.js"; +import { useKeyHints, useWizard } from "./context"; + +const theme = darkTheme; + +// Every field owns the key handling for its own step — esc goes back, enter +// advances, arrows move — so a screen never writes that boilerplate again. + +// firstIssue renders the schema's own message, so the wizard rejects exactly +// what the flag-driven path rejects and says the same thing about it. The issue +// path is prefixed when there is one: for a nested value — a component inside a +// components map, say — "expected object, received string" alone does not say +// which key is wrong. +function firstIssue(schema: z.ZodType, value: unknown): string | undefined { + const parsed = schema.safeParse(value); + if (parsed.success) return undefined; + const issue = parsed.error.issues[0]; + if (!issue) return "invalid value"; + const path = issue.path.join("."); + return path === "" ? issue.message : `${path}: ${issue.message}`; +} + +interface ValidateOptions { + label: string; + required: boolean; + schema?: z.ZodType; + // json parses the value before the schema sees it, so a malformed blob is + // reported as bad JSON rather than as a shape the schema cannot read. + json?: boolean; +} + +// validateEntry returns the message that should block the step, or undefined to +// let it advance. Shared by the single-line and multi-line fields so both refuse +// the same input for the same stated reason. +function validateEntry( + value: string, + { label, required, schema, json = false }: ValidateOptions, +): string | undefined { + const trimmed = value.trim(); + if (trimmed === "") return required ? `${label} is required` : undefined; + + let parsed: unknown = trimmed; + if (json) { + try { + parsed = JSON.parse(trimmed); + } catch (cause) { + return `${label} is not valid JSON: ${(cause as Error).message}`; + } + } + return schema ? firstIssue(schema, parsed) : undefined; +} + +export interface TextFieldProps { + // label names the value in validation messages ("