From 7ecdf66406ed978c21ed12feb656d946e7938663 Mon Sep 17 00:00:00 2001 From: gitikavj Date: Wed, 2 Sep 2026 00:11:32 +0000 Subject: [PATCH 1/4] feat(project): add TUI wizards for four `project add` resources Selecting `add` from the project menu reported "no interactive screen yet". It now opens the resource menu, and four of the fifteen resources have a wizard: memory, gateway, policy-engine and config-bundle. The other eleven keep the not-implemented screen and are unchanged on the command line. The three wizards that already existed (project create, HarnessWizard, EndpointWizard) had each hand-rolled the same shell: a step list, a phase machine, esc-goes-back, and a per-step useInput that spends 30 lines asking for one string. That shell is now one component. A screen declares its questions as children and branches with a plain conditional, so a step that does not apply is absent from the flow and from the stepper: {isCustomJwt && ( )} Position is keyed by step name rather than index, because branches have different lengths and a step appearing must not move the user. Screens submit through projectManager.addResource, as project create submits through projectManager.create - not through the handler, whose result goes to a stderr captured at wiring time that Ink's alternate screen would swallow. To keep the two entry points from drifting, the screens reuse the handlers' own helpers rather than copies of them: toDefaultStrategy, EventExpiryDurationSchema, ComponentsSchema, gatewayResourceName and policyEngineResourceName, three of which are newly exported for it. Validation messages come from the flags' own schemas, so the wizard rejects what the flag rejects and says the same thing about it. Required-ness is a field prop, not a schema change: flag schemas stay `.optional()` so Commander cannot reject a bare command before the TUI middleware runs (69aa0c19), and the handler's own throw stays authoritative. Screens resolve the project themselves via ProjectGate. withProject wraps `handle` only, so middleware never runs for a screen the user navigated to and ProjectKey is absent unless the launching command happened to set it. The gate reports the same not-found guidance the CLI prints. The Form* components now omit their label and help rows when passed empty strings, so a field whose already asks the question renders just the control instead of restating itself three times over. Existing callers pass non-empty strings and are unaffected. Two defects the shell's own tests found: the first frame rendered a footer with no action key, because fields publish hints from an effect; and two enter presses in one Ink drain submitted twice. Co-Authored-By: Claude Opus 5 --- src/components/FormCheckboxMultiSelect.tsx | 12 +- src/components/FormRadioGroup.tsx | 12 +- src/components/FormTextArea.tsx | 26 +- src/components/FormTextInput.tsx | 12 +- src/components/Root.tsx | 59 ++- src/components/wizard/Step.tsx | 37 ++ src/components/wizard/Wizard.tsx | 277 ++++++++++++ src/components/wizard/context.tsx | 50 +++ src/components/wizard/fields.tsx | 397 ++++++++++++++++++ src/components/wizard/index.ts | 16 + src/components/wizard/wizard.test.tsx | 287 +++++++++++++ src/handlers/project/ProjectGate.tsx | 107 +++++ src/handlers/project/add/add.screen.test.tsx | 114 +++++ .../configBundle.screen.test.tsx | 176 ++++++++ .../project/add/config-bundle/index.ts | 6 +- .../project/add/config-bundle/screen.tsx | 166 ++++++++ .../add/gateway/gateway.screen.test.tsx | 137 ++++++ src/handlers/project/add/gateway/screen.tsx | 236 +++++++++++ src/handlers/project/add/memory/index.ts | 18 +- .../project/add/memory/memory.screen.test.tsx | 173 ++++++++ src/handlers/project/add/memory/screen.tsx | 159 +++++++ .../policyEngine.screen.test.tsx | 103 +++++ .../project/add/policy-engine/screen.tsx | 190 +++++++++ src/handlers/project/add/screen.tsx | 9 + src/handlers/project/project.screen.test.tsx | 35 +- src/testing/index.tsx | 2 + src/testing/renderScreen.tsx | 17 + 27 files changed, 2784 insertions(+), 49 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/ProjectGate.tsx create mode 100644 src/handlers/project/add/add.screen.test.tsx create mode 100644 src/handlers/project/add/config-bundle/configBundle.screen.test.tsx create mode 100644 src/handlers/project/add/config-bundle/screen.tsx create mode 100644 src/handlers/project/add/gateway/gateway.screen.test.tsx create mode 100644 src/handlers/project/add/gateway/screen.tsx create mode 100644 src/handlers/project/add/memory/memory.screen.test.tsx create mode 100644 src/handlers/project/add/memory/screen.tsx create mode 100644 src/handlers/project/add/policy-engine/policyEngine.screen.test.tsx create mode 100644 src/handlers/project/add/policy-engine/screen.tsx create mode 100644 src/handlers/project/add/screen.tsx 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/Step.tsx b/src/components/wizard/Step.tsx new file mode 100644 index 000000000..f5fb1efea --- /dev/null +++ b/src/components/wizard/Step.tsx @@ -0,0 +1,37 @@ +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. +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..3b3321832 --- /dev/null +++ b/src/components/wizard/Wizard.tsx @@ -0,0 +1,277 @@ +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( + () => + stepElements.map((element) => ({ + key: element.props.name, + title: element.props.title ?? element.props.name, + })), + [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..215bd3cb8 --- /dev/null +++ b/src/components/wizard/context.tsx @@ -0,0 +1,50 @@ +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 is + // keyed on the hints' content and reads the latest array from a ref. Keying + // on the array itself would publish, re-render, and publish again forever. + const latest = useRef(hints); + latest.current = hints; + const signature = hints.map((hint) => `${hint.key}:${hint.label}`).join("|"); + + useEffect(() => { + setHints(latest.current); + }, [signature, 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 ("