From b85a06f0be28bb3ebd0c506e84becc800ce92007 Mon Sep 17 00:00:00 2001 From: gitikavj Date: Fri, 11 Sep 2026 17:08:54 +0000 Subject: [PATCH 1/5] feat(wizard): shared wizard shell, used by project create and add runtime MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds src/components/wizard: a Wizard shell that owns the step list, position, key handling and the form → running → success | error phases, with Step and the TextField/ChoiceField/Summary fields. Fields publish their own key hints and own their step's input handling, so a screen only declares questions. Migrates project create onto it (−380 lines of its own controller) and adds the first add wizard, project add runtime: name → template → description → review. Both paths build their input through one builder — create through resolveScaffoldHarnessInput/resolveRuntimeTemplateShortcut as before, add runtime through the new toAddRuntimeInput — so a value the flag path accepts is not one a wizard rejects. The runtime wizard scaffolds from a template; the JSON infrastructure flags, the model provider/api-key and the Bedrock Agent import stay on the flag path. Also: KeyValueTable grows into the space it is given, so its percentage key cap resolves when it is laid out as a row item, and FormTextInput/ FormRadioGroup render bare when given no name or help text, since the step already asks the question. --- src/components/FormRadioGroup.tsx | 14 +- src/components/FormTextInput.tsx | 12 +- src/components/KeyValueTable.tsx | 7 +- src/components/Root.tsx | 5 + src/components/wizard/Step.tsx | 43 ++ src/components/wizard/Wizard.tsx | 294 +++++++++++ src/components/wizard/context.tsx | 51 ++ src/components/wizard/fields.tsx | 251 ++++++++++ src/components/wizard/index.ts | 12 + src/components/wizard/wizard.test.tsx | 328 ++++++++++++ src/handlers/project/add/add.screen.test.tsx | 73 +++ src/handlers/project/add/index.ts | 5 +- src/handlers/project/add/runtime/index.ts | 36 +- .../add/runtime/runtime.screen.test.tsx | 139 ++++++ src/handlers/project/add/runtime/screen.tsx | 149 ++++++ src/handlers/project/create/screen.tsx | 469 ++++-------------- src/handlers/project/index.ts | 8 +- src/handlers/project/project.screen.test.tsx | 14 +- 18 files changed, 1508 insertions(+), 402 deletions(-) create mode 100644 src/components/wizard/Step.tsx create mode 100644 src/components/wizard/Wizard.tsx create mode 100644 src/components/wizard/context.tsx create mode 100644 src/components/wizard/fields.tsx create mode 100644 src/components/wizard/index.ts create mode 100644 src/components/wizard/wizard.test.tsx create mode 100644 src/handlers/project/add/add.screen.test.tsx create mode 100644 src/handlers/project/add/runtime/runtime.screen.test.tsx create mode 100644 src/handlers/project/add/runtime/screen.tsx diff --git a/src/components/FormRadioGroup.tsx b/src/components/FormRadioGroup.tsx index f3b5ddec6..d977b2c5a 100644 --- a/src/components/FormRadioGroup.tsx +++ b/src/components/FormRadioGroup.tsx @@ -21,7 +21,7 @@ export interface FormRadioGroupProps { // FormRadioGroup renders a column of radio rows. It is fully controlled: the // parent owns the focused index and the key handling that moves it. export function FormRadioGroup({ - name, + name = "", helpText, options, focusedIndex, @@ -31,10 +31,14 @@ export function FormRadioGroup({ return ( - - {name && {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 input. */} + {(name !== "" || helpText !== "") && ( + + {name !== "" && {name}} + {helpText !== "" && {helpText}} + + )} + {Object.entries(items).map(([key, value]) => ( } /> + } + /> {/* Every known command without a screen of its own: a group opens its menu and a leaf its interactive help. Unknown routes retain the help-and-exit fallback. */} diff --git a/src/components/wizard/Step.tsx b/src/components/wizard/Step.tsx new file mode 100644 index 000000000..092dab61f --- /dev/null +++ b/src/components/wizard/Step.tsx @@ -0,0 +1,43 @@ +import { isValidElement, type ReactElement, type ReactNode } 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: 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 . 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: ReactNode): child is ReactElement { + return 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..754d0deae --- /dev/null +++ b/src/components/wizard/Wizard.tsx @@ -0,0 +1,294 @@ +import { Children, useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; +import { Box, Text, useApp, useInput } from "ink"; +import { ErrorPanel } from "../ErrorPanel"; +import { Layout } from "../Layout"; +import { Stepper, type Step as StepperStep } from "../ui/stepper"; +import { Divider } from "../ui/divider"; +import { Spinner } from "../ui/spinner"; +import { TaskList, type Task } from "../ui/task-list"; +import { darkTheme, glyphs } from "../ui/_core.js"; +import { driveProgress, type ProgressEvent } from "../../tui/progress"; +import { isStepElement } from "./Step"; +import { WizardProvider, type KeyHint, type WizardControls } from "./context"; + +const theme = darkTheme; + +// 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 = AsyncGenerator | 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: 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; + // successNextSteps are the commands to run next, listed under successLabel — + // the same block ConfirmAction shows after a successful action. + successNextSteps?: 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, + successNextSteps, + onDone, + onError = "exit", +}: WizardProps) { + const { exit } = useApp(); + + const [phase, setPhase] = useState({ kind: "form" }); + const [tasks, setTasks] = 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(() => 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" }); + setTasks([]); + try { + const result = onSubmit(); + // Same driver as create, build and deploy: driveProgress folds the stream + // into the Task list TaskList draws, so a wizard's steps look like every + // other long-running operation's — spinner on the running step, a tail of + // its output, ✓ once it settles. + if (isProgressStream(result)) await driveProgress(result, setTasks); + 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], + ); + + // A retry is only offered while nothing has been written yet: once a step has + // run, the operation is partly applied and re-submitting would fail on what + // it already did. + const retryable = onError === "retry" && tasks.length === 0; + + return ( + + + {phase.kind === "form" && ( + <> + + step.key)} + /> + + + {activeStep} + + )} + + {phase.kind !== "form" && ( + + + {/* A submit that reports no steps at all — a plain request, or a + stream before its first step — still needs something moving. */} + {phase.kind === "running" && tasks.length === 0 && } + {phase.kind === "success" && ( + exit())} + /> + )} + {phase.kind === "error" && onError === "exit" && } + {phase.kind === "error" && onError === "retry" && ( + void submit() : undefined} + onBack={() => 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 AsyncGenerator { + 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[], retryable: boolean): KeyHint[] { + if (phase.kind === "running") return [{ key: "ctrl+c", label: "quit" }]; + if (phase.kind === "success") return [{ key: "enter", label: "continue" }]; + if (phase.kind === "error") { + return [ + ...(retryable ? [{ key: "r", label: "retry" }] : []), + { key: "esc", label: "back" }, + { key: "ctrl+c", label: "quit" }, + ]; + } + return [...fieldHints, { key: "esc", label: "back" }, { key: "ctrl+c", label: "quit" }]; +} + +function SuccessPanel({ + label, + hint, + nextSteps, + onContinue, +}: { + label: string; + hint?: string; + nextSteps?: string[]; + onContinue: () => void; +}) { + useInput((_input, key) => { + if (key.return || key.escape) onContinue(); + }); + + return ( + + + {glyphs.check} {label} + + {nextSteps !== undefined && nextSteps.length > 0 && ( + + next steps + {nextSteps.map((step) => ( + {` ${step}`} + ))} + + )} + {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 ( + + {glyphs.cross} {error.message} + + ); +} diff --git a/src/components/wizard/context.tsx b/src/components/wizard/context.tsx new file mode 100644 index 000000000..daa3c4cb5 --- /dev/null +++ b/src/components/wizard/context.tsx @@ -0,0 +1,51 @@ +import { createContext, 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 = 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..2bf57b3d2 --- /dev/null +++ b/src/components/wizard/fields.tsx @@ -0,0 +1,251 @@ +import { useState } from "react"; +import { Box, Text, useInput } from "ink"; +import type z from "zod"; +import { FormTextInput } from "../FormTextInput"; +import { FormRadioGroup } from "../FormRadioGroup"; +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. It sits outside TextField so that a further field collecting +// text — a multi-line one, say — refuses the same input for the same stated +// reason rather than growing its own rules. +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 ("