diff --git a/src/components/ConfirmAction.tsx b/src/components/ConfirmAction.tsx index 78007c8f2..383a80913 100644 --- a/src/components/ConfirmAction.tsx +++ b/src/components/ConfirmAction.tsx @@ -6,8 +6,9 @@ import { Spinner } from "./ui/spinner"; import { Confirm } from "./ui/confirm"; import { TaskList, type Task } from "./ui/task-list"; import { KeyValueTable } from "./KeyValueTable"; +import { SuccessBody } from "./SuccessBody"; import { darkTheme, glyphs } from "./ui/_core.js"; -import { driveProgress, type ProgressEvent } from "../tui/progress"; +import { driveProgress, isProgressGenerator, type ProgressResult } from "../tui/progress"; const theme = darkTheme; @@ -41,7 +42,7 @@ export interface ConfirmActionProps { // optionally with a title overriding successTitle for an outcome only known // afterwards. A progress generator (what runWithProgress drives) may be // returned instead; its steps render as a live TaskList while it runs. - action: () => Promise | AsyncGenerator; + action: () => ProgressResult; // successTitle heads the success panel (e.g. "Harness deleted") unless the // action's result carries its own. successTitle: string; @@ -201,60 +202,6 @@ export function ConfirmAction({ ); } -// A promise has no Symbol.asyncIterator, so this is a safe discriminator. -function isProgressGenerator( - result: Promise | AsyncGenerator, -): result is AsyncGenerator { - return ( - typeof (result as AsyncGenerator)[Symbol.asyncIterator] === - "function" - ); -} - -function SuccessBody({ - title, - rows, - nextSteps, - onDone, - doneLabel, -}: { - title: string; - rows: SummaryRows; - nextSteps?: string[]; - onDone: () => void; - doneLabel: string; -}) { - useInput((_input, key) => { - if (key.return || key.escape) onDone(); - }); - - return ( - - - {glyphs.check} {title} - - {Object.keys(rows).length > 0 && ( - - - - )} - {nextSteps !== undefined && nextSteps.length > 0 && ( - - next steps - {nextSteps.map((step) => ( - {` ${step}`} - ))} - - )} - - - press enter to {doneLabel} - - - - ); -} - function ErrorBody({ message, onBack }: { message: string; onBack: () => void }) { useInput((_input, key) => { if (key.escape || key.return) onBack(); 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}} + + )} } /> + } + /> {/* 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/SuccessBody.tsx b/src/components/SuccessBody.tsx new file mode 100644 index 000000000..6bc26de7e --- /dev/null +++ b/src/components/SuccessBody.tsx @@ -0,0 +1,57 @@ +import { Box, Text, useInput } from "ink"; +import { KeyValueTable } from "./KeyValueTable"; +import { darkTheme, glyphs } from "./ui/_core.js"; + +const theme = darkTheme; + +export interface SuccessBodyProps { + title: string; + rows?: Record; + nextSteps?: string[]; + hint?: string; + onDone: () => void; + doneLabel?: string; +} + +export function SuccessBody({ + title, + rows = {}, + nextSteps, + hint, + onDone, + doneLabel = "continue", +}: SuccessBodyProps) { + useInput((_input, key) => { + if (key.return || key.escape) onDone(); + }); + + return ( + + + {glyphs.check} {title} + + {Object.keys(rows).length > 0 && ( + + + + )} + {nextSteps !== undefined && nextSteps.length > 0 && ( + + next steps + {nextSteps.map((step) => ( + {` ${step}`} + ))} + + )} + + + {hint ?? ( + <> + press enter to {doneLabel} + + )} + + + + ); +} diff --git a/src/components/wizard/Step.tsx b/src/components/wizard/Step.tsx new file mode 100644 index 000000000..25ea1b941 --- /dev/null +++ b/src/components/wizard/Step.tsx @@ -0,0 +1,29 @@ +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 { + // React reserves `key`, so this cannot use that prop name. + stepKey: string; + title?: string; + prompt?: string; + children: ReactNode; +} + +// 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. A step needing related inputs should use one compound field. +export function Step({ prompt, children }: StepProps) { + return ( + + {prompt !== undefined && {prompt}} + {children} + + ); +} + +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..fa243a303 --- /dev/null +++ b/src/components/wizard/Wizard.tsx @@ -0,0 +1,188 @@ +import { Children, useCallback, useRef, useState, type ReactNode } from "react"; +import { Box, useApp } from "ink"; +import { AgentCoreCLIError } from "../../errors"; +import { ErrorPanel } from "../ErrorPanel"; +import { Layout } from "../Layout"; +import { SuccessBody } from "../SuccessBody"; +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 { driveProgress, isProgressGenerator, type ProgressResult } from "../../tui/progress"; +import { isStepElement } from "./Step"; +import { WizardProvider, type KeyHint, type WizardControls } from "./context"; + +export type WizardSubmitResult = ProgressResult; + +type Phase = + | { kind: "form" } + | { kind: "running" } + | { kind: "success" } + | { kind: "error"; error: AgentCoreCLIError }; + +export interface WizardProps { + breadcrumb: string[]; + description?: string; + children: ReactNode; + onSubmit: () => WizardSubmitResult; + onCancel: () => void; + runningLabel: string; + successLabel: string; + successHint?: string; + successNextSteps?: string[]; + onDone?: () => void; + doneLabel?: string; +} + +export function Wizard({ + breadcrumb, + description, + children, + onSubmit, + onCancel, + runningLabel, + successLabel, + successHint, + successNextSteps, + onDone, + doneLabel = "continue", +}: WizardProps) { + const { exit } = useApp(); + + const [phase, setPhase] = useState({ kind: "form" }); + const [tasks, setTasks] = useState([]); + const [hints, setHints] = useState([{ key: "enter", label: "continue" }]); + + const stepElements = Children.toArray(children).filter(isStepElement); + const steps: StepperStep[] = stepElements.map((element) => ({ + key: element.props.stepKey, + title: element.props.title ?? element.props.stepKey, + })); + const seen = new Set(); + for (const step of steps) { + if (seen.has(step.key)) { + throw new AgentCoreCLIError(`duplicate `, { + name: "DuplicateWizardStepError", + meta: { stepKey: step.key }, + }); + } + seen.add(step.key); + } + + const [stepKey, setStepKey] = useState(() => steps[0]?.key ?? ""); + + const found = steps.findIndex((step) => step.key === stepKey); + const index = found === -1 ? 0 : found; + const activeStep = stepElements[index]; + const isLast = index === steps.length - 1; + + const submitting = useRef(false); + + const submit = useCallback(async () => { + if (submitting.current) return; + submitting.current = true; + setPhase({ kind: "running" }); + setTasks([]); + try { + const result = onSubmit(); + if (isProgressGenerator(result)) await driveProgress(result, setTasks); + else await result; + setPhase({ kind: "success" }); + } catch (error) { + setPhase({ + kind: "error", + error: AgentCoreCLIError.fromError(error), + }); + } finally { + submitting.current = false; + } + }, [onSubmit]); + + const controls: WizardControls = { + 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); + }, + }; + + const retryable = tasks.length === 0; + + return ( + + + {phase.kind === "form" && ( + <> + + step.key)} + /> + + + {activeStep} + + )} + + {phase.kind !== "form" && ( + + + {phase.kind === "running" && tasks.length === 0 && } + {phase.kind === "success" && ( + exit())} + doneLabel={doneLabel} + /> + )} + {phase.kind === "error" && ( + void submit() : undefined} + onBack={() => setPhase({ kind: "form" })} + /> + )} + + )} + + + ); +} + +function footerHints( + phase: Phase, + fieldHints: KeyHint[], + retryable: boolean, + doneLabel: string, +): KeyHint[] { + if (phase.kind === "running") return [{ key: "ctrl+c", label: "quit" }]; + if (phase.kind === "success") return [{ key: "enter", label: doneLabel }]; + 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" }]; +} diff --git a/src/components/wizard/context.tsx b/src/components/wizard/context.tsx new file mode 100644 index 000000000..8698b3b63 --- /dev/null +++ b/src/components/wizard/context.tsx @@ -0,0 +1,37 @@ +import { createContext, useContext, useEffect, useRef } from "react"; + +export interface KeyHint { + key: string; + label: string; +} + +export interface WizardControls { + advance: () => void; + back: () => void; + isLast: boolean; + 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; +} + +export function useKeyHints(hints: KeyHint[]): void { + const { setHints } = useWizard(); + + 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..0d4796570 --- /dev/null +++ b/src/components/wizard/fields.tsx @@ -0,0 +1,180 @@ +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; + +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]!; + const path = issue.path.join("."); + return path === "" ? issue.message : `${path}: ${issue.message}`; +} + +interface ValidateOptions { + label: string; + required: boolean; + schema?: z.ZodType; +} + +function validateEntry( + value: string, + { label, required, schema }: ValidateOptions, +): string | undefined { + if (value.trim() === "") return required ? `${label} is required` : undefined; + return schema ? firstIssue(schema, value) : undefined; +} + +export interface TextFieldProps { + label: string; + help?: string; + placeholder?: string; + value: string; + onChange: (value: string) => void; + required?: boolean; + schema?: z.ZodType; + live?: boolean; +} + +export function TextField({ + label, + help = "", + placeholder = "", + value, + onChange, + required = false, + schema, + live = false, +}: TextFieldProps) { + const { advance, back, isLast } = useWizard(); + const [error, setError] = useState(); + + useKeyHints([{ key: "enter", label: isLast ? "submit" : "continue" }]); + + useInput((_input, key) => { + if (key.escape) { + back(); + return; + } + if (!key.return) return; + + const issue = validateEntry(value, { label, required, schema }); + if (issue !== undefined) { + setError(issue); + return; + } + setError(undefined); + advance(); + }); + + return ( + + { + onChange(next); + setError( + live && next.trim() !== "" + ? validateEntry(next, { label, required, schema }) + : undefined, + ); + }} + /> + {error !== undefined && {error}} + + ); +} + +export interface Choice { + value: T; + label: string; + description?: string; +} + +export interface ChoiceFieldProps { + help?: string; + choices: Choice[]; + value: T; + onChange: (value: T) => void; +} + +export function ChoiceField({ help = "", choices, value, onChange }: ChoiceFieldProps) { + const { advance, back, isLast } = useWizard(); + + useKeyHints([ + { key: "↑↓", label: "navigate" }, + { key: "enter", label: isLast ? "submit" : "continue" }, + ]); + + const found = choices.findIndex((choice) => choice.value === value); + const index = found === -1 ? 0 : found; + + useInput((_input, key) => { + if (key.escape) { + back(); + return; + } + if (key.upArrow) { + onChange(choices[Math.max(0, index - 1)]!.value); + return; + } + if (key.downArrow) { + onChange(choices[Math.min(choices.length - 1, index + 1)]!.value); + return; + } + if (key.return) advance(); + }); + + return ( + ({ + label: choice.label, + description: choice.description ?? "", + }))} + focusedIndex={index} + /> + ); +} + +export interface SummaryProps { + items: Record; +} + +export function Summary({ items }: SummaryProps) { + const { advance, back } = useWizard(); + + useKeyHints([{ key: "enter", label: "submit" }]); + + useInput((_input, key) => { + if (key.escape) { + back(); + return; + } + if (key.return) advance(); + }); + + return ( + + + + ); +} diff --git a/src/components/wizard/index.ts b/src/components/wizard/index.ts new file mode 100644 index 000000000..4af773775 --- /dev/null +++ b/src/components/wizard/index.ts @@ -0,0 +1,12 @@ +export { Wizard, type WizardProps, type WizardSubmitResult } from "./Wizard"; +export { Step, type StepProps } from "./Step"; +export { useWizard, useKeyHints, type KeyHint, type WizardControls } from "./context"; +export { + TextField, + ChoiceField, + Summary, + type Choice, + type TextFieldProps, + type ChoiceFieldProps, + type SummaryProps, +} from "./fields"; diff --git a/src/components/wizard/wizard.test.tsx b/src/components/wizard/wizard.test.tsx new file mode 100644 index 000000000..313a205a2 --- /dev/null +++ b/src/components/wizard/wizard.test.tsx @@ -0,0 +1,357 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { useState } from "react"; +import z from "zod"; +import { render } from "ink-testing-library"; +import { render as inkRender } from "ink"; +import { cleanupScreens, keys, tick, ttyTestIO, waitFor } from "../../testing"; +import { AgentCoreCLIError } from "../../errors"; +import { Wizard, type WizardSubmitResult } from "./Wizard"; +import { Step } from "./Step"; +import { ChoiceField, Summary, TextField } from "./fields"; + +afterEach(cleanupScreens); + +// The wizard shell is exercised through a synthetic flow rather than one of the +// real screens, so these tests describe the shell's own behaviour: how it +// derives steps from children, moves between them, and reports outcomes. + +interface HarnessOptions { + onSubmit?: () => WizardSubmitResult; + onCancel?: () => void; + onDone?: () => void; +} + +// A schema with a shape a stray space breaks, the way a resource-name schema +// does: it is what makes "validated as typed" observable. +const NAME_SCHEMA = z.string().regex(/^[A-Za-z]+$/, "letters only"); + +const YES_NO = [ + { value: false, label: "no", description: "skip the extra question" }, + { value: true, label: "yes", description: "ask the extra question" }, +]; + +// TestWizard has one conditional step, so the branch behaviour under test is +// expressed the way a screen expresses it: `{condition && }`. +function TestWizard({ onSubmit, onCancel, onDone }: HarnessOptions) { + const [name, setName] = useState(""); + const [wantsExtra, setWantsExtra] = useState(false); + const [extra, setExtra] = useState(""); + + return ( + {})} + onSubmit={onSubmit ?? (async () => {})} + onDone={onDone} + runningLabel="working…" + successLabel="all done" + successHint="enter exits" + > + + + + + + + + + {wantsExtra && ( + + + + )} + + + + + + ); +} + +interface Driver { + lastFrame: () => string | undefined; + write: (input: string) => Promise; + press: (key: keyof typeof keys) => Promise; + // pressTwice delivers two discrete key events in one drain, with no render in + // between — what a fast typist produces. A single "\r\r" chunk would not do: + // Ink reports a multi-character chunk with key.return false, so it never + // reaches a return handler at all. + pressTwice: (key: keyof typeof keys) => Promise; + unmount: () => void; +} + +function drive(options: HarnessOptions = {}): Driver { + const instance = render(<>); + Object.defineProperties(instance.stdout, { + columns: { configurable: true, value: 100 }, + rows: { configurable: true, value: 40 }, + }); + instance.rerender(); + + return { + lastFrame: instance.lastFrame, + write: async (input) => { + await tick(); + instance.stdin.write(input); + await tick(); + }, + press: async (key) => { + await tick(); + instance.stdin.write(keys[key]); + await tick(); + }, + pressTwice: async (key) => { + await tick(); + instance.stdin.write(keys[key]); + instance.stdin.write(keys[key]); + await tick(); + }, + unmount: instance.unmount, + }; +} + +function waitForFrame(driver: Driver, text: string): Promise { + return waitFor(() => (driver.lastFrame() ?? "").includes(text), 1000); +} + +describe("Wizard shell", () => { + test("derives the stepper from its Step children", async () => { + const d = drive(); + + await waitForFrame(d, "what is your name?"); + const frame = d.lastFrame()!; + expect(frame).toContain("● name"); + expect(frame).toContain("○ branch"); + expect(frame).toContain("○ review"); + // The conditional step is not offered while its condition is false. + expect(frame).not.toContain("○ extra"); + d.unmount(); + }); + + test("a step appears mid-flow when its condition turns true", async () => { + const d = drive(); + + await waitForFrame(d, "what is your name?"); + await d.write("Ada"); + await d.press("return"); + + await waitForFrame(d, "want the extra question?"); + expect(d.lastFrame()).not.toContain("○ extra"); + + // Choosing "yes" inserts the step between here and review. + await d.press("down"); + await waitForFrame(d, "○ extra"); + await d.press("return"); + + await waitForFrame(d, "the extra question"); + d.unmount(); + }); + + test("enter advances and esc goes back, keeping answers", async () => { + const d = drive(); + + await waitForFrame(d, "what is your name?"); + await d.write("Ada"); + await d.press("return"); + + await waitForFrame(d, "want the extra question?"); + await d.press("escape"); + + await waitForFrame(d, "what is your name?"); + expect(d.lastFrame()).toContain("Ada"); + d.unmount(); + }); + + test("esc on the first step cancels out of the wizard", async () => { + let cancelled = 0; + const d = drive({ onCancel: () => cancelled++ }); + + await waitForFrame(d, "what is your name?"); + await d.press("escape"); + + expect(cancelled).toBe(1); + d.unmount(); + }); + + test("the footer hints come from the active field", async () => { + const d = drive(); + + // A text field offers enter; a choice field also offers the arrows. + await waitForFrame(d, "what is your name?"); + expect(d.lastFrame()).toContain("[enter] continue"); + expect(d.lastFrame()).not.toContain("[↑↓] choose"); + + await d.write("Ada"); + await d.press("return"); + + await waitForFrame(d, "want the extra question?"); + expect(d.lastFrame()).toContain("[↑↓] navigate"); + d.unmount(); + }); + + test("enter on the last step submits and reports success", async () => { + let submits = 0; + const d = drive({ + onSubmit: async () => { + submits++; + }, + }); + + await waitForFrame(d, "what is your name?"); + await d.write("Ada"); + await d.press("return"); + await waitForFrame(d, "want the extra question?"); + await d.press("return"); + await waitForFrame(d, "review"); + expect(d.lastFrame()).toContain("[enter] submit"); + await d.press("return"); + + await waitForFrame(d, "✔ all done"); + expect(submits).toBe(1); + d.unmount(); + }); + + test("a streamed submit renders its steps through the shared TaskList", async () => { + // The pauses let Ink paint between events: a generator that runs to + // completion in one batch would only ever produce the final frame, and the + // tail under a running step is exactly what that frame no longer shows. + const pause = () => new Promise((resolve) => setTimeout(resolve, 5)); + async function* progress() { + yield { type: "step", message: "wrote agentcore.json" } as const; + await pause(); + yield { type: "output", line: "a line tailing the running step" } as const; + await pause(); + yield { type: "step", message: "updated the deploy target" } as const; + } + const d = drive({ onSubmit: () => progress() }); + + await waitForFrame(d, "what is your name?"); + await d.write("Ada"); + await d.press("return"); + await waitForFrame(d, "want the extra question?"); + await d.press("return"); + await waitForFrame(d, "review"); + await d.press("return"); + + // An output line tails the step it belongs to while that step runs, and + // collapses with it — TaskList's behaviour everywhere else in the CLI. + await waitForFrame(d, "│ a line tailing the running step"); + + await waitForFrame(d, "✔ all done"); + const frame = d.lastFrame()!; + expect(frame).toContain("✓ wrote agentcore.json"); + expect(frame).toContain("✓ updated the deploy target"); + expect(frame).not.toContain("a line tailing the running step"); + d.unmount(); + }); + + test("a buffered second enter does not submit twice", async () => { + let submits = 0; + const d = drive({ + onSubmit: async () => { + submits++; + await new Promise((resolve) => setTimeout(resolve, 20)); + }, + }); + + await waitForFrame(d, "what is your name?"); + await d.write("Ada"); + await d.press("return"); + await waitForFrame(d, "want the extra question?"); + await d.press("return"); + await waitForFrame(d, "review"); + await d.pressTwice("return"); + + await waitForFrame(d, "✔ all done"); + expect(submits).toBe(1); + d.unmount(); + }); + + test("reports a failure and returns to the form", async () => { + const d = drive({ + onSubmit: () => Promise.reject(new Error("the service said no")), + }); + + await waitForFrame(d, "what is your name?"); + await d.write("Ada"); + await d.press("return"); + await waitForFrame(d, "want the extra question?"); + await d.press("return"); + await waitForFrame(d, "review"); + await d.press("return"); + + await waitForFrame(d, "✗ the service said no"); + await d.press("escape"); + + // Back on the review step, with the answers intact. + await waitForFrame(d, "review"); + expect(d.lastFrame()).toContain("Ada"); + d.unmount(); + }); + + test("a field validates what it would submit, not a trimmed copy of it", async () => { + let submitted: string | undefined; + const d = drive({ + onSubmit: async () => { + submitted = "reached"; + }, + }); + + await waitForFrame(d, "what is your name?"); + await d.write(" Ada "); + await d.press("return"); + + // The step keeps the value as typed, so it must refuse it here rather than + // pass a trimmed copy and submit the padded one. + await waitForFrame(d, "letters only"); + expect(d.lastFrame()).toContain("what is your name?"); + expect(submitted).toBeUndefined(); + d.unmount(); + }); + + test("a required field blocks the step until it is filled", async () => { + const d = drive(); + + await waitForFrame(d, "what is your name?"); + await d.press("return"); + + await waitForFrame(d, "name is required"); + expect(d.lastFrame()).toContain("what is your name?"); + d.unmount(); + }); +}); + +describe("Wizard authoring guards", () => { + // Ink's own render rather than ink-testing-library: a render-time throw + // reaches Ink's error boundary and rejects waitUntilExit, which the testing + // library does not expose. + test("two steps sharing a step key are rejected at render", async () => { + const { streams } = ttyTestIO(); + const { waitUntilExit } = inkRender( + {}} + onSubmit={async () => {}} + runningLabel="working…" + successLabel="all done" + > + + + + + + + , + { stdin: streams.io.stdin, stdout: streams.io.stdout, stderr: streams.io.stderr }, + ); + + const error = await waitUntilExit().then( + () => undefined, + (caught: unknown) => caught, + ); + expect(error).toBeInstanceOf(AgentCoreCLIError); + expect((error as AgentCoreCLIError).source).toBe("internal"); + expect((error as Error).message).toBe('duplicate '); + }); +}); diff --git a/src/handlers/project/ProjectGate.tsx b/src/handlers/project/ProjectGate.tsx index b7437f08c..54cb7eb97 100644 --- a/src/handlers/project/ProjectGate.tsx +++ b/src/handlers/project/ProjectGate.tsx @@ -11,6 +11,10 @@ import type { Project } from "./types"; const theme = darkTheme; +export function projectQueryKey(from = process.cwd()) { + return ["project", from] as const; +} + // useProject resolves the project enclosing the cwd for a TUI screen. Screens // resolve it themselves because withProject wraps `handle` only, and navigating // between screens never executes a command — ProjectKey is set only when the @@ -18,7 +22,7 @@ const theme = darkTheme; export function useProject(core: Core, seed?: Project): UseQueryResult { const from = process.cwd(); return useQuery({ - queryKey: ["project", from], + queryKey: projectQueryKey(from), queryFn: async () => { const project = await core.projectManager.resolve({ filePath: from }); if (!project) throw new ProjectStateError(projectNotFoundMessage(from)); diff --git a/src/handlers/project/add/add.screen.test.tsx b/src/handlers/project/add/add.screen.test.tsx new file mode 100644 index 000000000..ae78dce64 --- /dev/null +++ b/src/handlers/project/add/add.screen.test.tsx @@ -0,0 +1,73 @@ +import { test, expect, describe, afterEach } from "bun:test"; +import { + renderScreen, + waitForText, + cleanupScreens, + compiledRootCommand, + menuEntries, +} from "../../../testing"; + +afterEach(cleanupScreens); + +// addSubcommands reads the resources off the compiled Commander tree, so a +// `project add` resource added later is covered without editing this file. +// `help` is Commander's own, not one of ours. +function addSubcommands(): string[] { + const root = compiledRootCommand(); + const project = root.commands.find((command) => command.name() === "project")!; + const add = project.commands.find((command) => command.name() === "add")!; + return add.commands.map((command) => command.name()).filter((name) => name !== "help"); +} + +// The resources with a wizard. Everything else is listed below the menu's +// "command line only" divider and opens its help instead. +const WITH_SCREENS = ["runtime"]; + +describe("project add menu", () => { + test("lists every add resource", async () => { + const r = renderScreen("/agentcore/project/add"); + + await waitForText(r.lastFrame, "add project resources"); + const frame = r.lastFrame()!; + for (const command of addSubcommands()) { + expect(frame).toContain(command); + } + r.unmount(); + }); + + test("the resources with a wizard are listed above the divider", async () => { + const r = renderScreen("/agentcore/project/add"); + + await waitForText(r.lastFrame, "command line only"); + const { screens, cliOnly } = menuEntries(r.lastFrame()!); + expect(screens.toSorted()).toEqual(WITH_SCREENS.toSorted()); + expect(cliOnly.toSorted()).toEqual( + addSubcommands() + .filter((command) => !WITH_SCREENS.includes(command)) + .toSorted(), + ); + r.unmount(); + }); + + test("is reachable from the project menu", async () => { + const r = renderScreen("/agentcore/project"); + + await waitForText(r.lastFrame, "agentcore → project"); + await r.write("add"); + await waitForText(r.lastFrame, "❯ add"); + await r.press("return"); + + await waitForText(r.lastFrame, "agentcore → project → add"); + r.unmount(); + }); + + test("esc returns to the project menu", async () => { + const r = renderScreen("/agentcore/project/add"); + + await waitForText(r.lastFrame, "agentcore → project → add"); + await r.press("escape"); + + await waitForText(r.lastFrame, "manage an AgentCore project"); + r.unmount(); + }); +}); diff --git a/src/handlers/project/add/index.ts b/src/handlers/project/add/index.ts index 02f0fce60..bf0d5fb1a 100644 --- a/src/handlers/project/add/index.ts +++ b/src/handlers/project/add/index.ts @@ -1,5 +1,7 @@ -import { withProject } from "../../../middleware/"; +import { withProject, withTuiWhenInteractive } from "../../../middleware/"; import { Router } from "../../../router"; +import { renderTui } from "../../../tui"; +import type { Core } from "../../types"; import { createAddConfigBundleHandler } from "./config-bundle"; import { createAddCredentialsHandler } from "./credentials"; import { createAddHarnessHandler } from "./harness"; @@ -17,9 +19,24 @@ import type { AddProjectResourceConfig } from "./types"; import { createAddPaymentConnectorHandler } from "./payment-connector"; import { createAddPaymentManagerHandler } from "./payment-manager"; -export function createAddProjectResourceHandler(config: AddProjectResourceConfig): Router { - const projectAdd = new Router("add", "add project resources"); - projectAdd.use(withProject({ projectManager: config.projectManager, cwd: process.cwd() })); +export function createAddProjectResourceHandler( + config: AddProjectResourceConfig, + core: Core, +): Router { + // The resources with a wizard of their own. Every other resource is listed in + // the add menu as command line only and opens its help instead (see + // CliOnlyScreen). + const projectAdd = new Router("add", "add project resources").supportedTuiCommands("runtime"); + projectAdd.default(renderTui(core, config.io)); + // withProject first, so it is the outermost wrapper: a resource added outside + // a project gets the CLI's own not-found guidance, and the resolved project + // seeds the wizard through ProjectKey. withTuiWhenInteractive then opens that + // wizard for a bare `add ` on a TTY; it is inert for a resource + // declared command-line only above, and for flags, --json and non-TTY runs. + projectAdd.use( + withProject({ projectManager: config.projectManager, cwd: process.cwd() }), + withTuiWhenInteractive(core, config.io), + ); projectAdd.handler(createAddConfigBundleHandler(config)); projectAdd.handler(createAddHarnessHandler(config)); projectAdd.handler(createAddMemoryHandler(config)); diff --git a/src/handlers/project/add/runtime/index.test.ts b/src/handlers/project/add/runtime/index.test.ts index 931a02178..be067af34 100644 --- a/src/handlers/project/add/runtime/index.test.ts +++ b/src/handlers/project/add/runtime/index.test.ts @@ -418,7 +418,7 @@ describe("project add runtime", () => { "invalid JSON in --network-config", ["--name", "my_agent", ...template, "--network-config", "{bad}"], ], - ["runtime names are limited in length", ["--name", "x".repeat(43)]], + ["runtime names are limited in length", ["--name", "x".repeat(49)]], ])("%s", async (_label, flags) => { const { cleanup } = await initProject(); cleanups.push(cleanup); diff --git a/src/handlers/project/add/runtime/index.ts b/src/handlers/project/add/runtime/index.ts index fec3ad924..c1260864f 100644 --- a/src/handlers/project/add/runtime/index.ts +++ b/src/handlers/project/add/runtime/index.ts @@ -3,7 +3,7 @@ import { createHandler, flag, ProjectKey } from "../../../../router"; import type { AddProjectResourceConfig } from "../types"; import { parseJsonFlag, parseTags } from "../../../utils"; import { InputValidationError } from "../../../../errors"; -import { type EnvVar } from "../../../../projectSchemas/runtime"; +import { AgentNameSchema, type EnvVar } from "../../../../projectSchemas/runtime"; import { RuntimeAuthorizerTypeSchema } from "../../../../projectSchemas/auth"; import { NetworkModeSchema } from "../../../../projectSchemas/constants"; import { SourceResolver } from "../../../../io"; @@ -13,7 +13,7 @@ import { getDefaultMemorySpec, resolveRuntimeTemplateShortcut, } from "../../shortcuts"; -import { ModelProviderSchema, type ScaffoldRuntimeInput } from "../../types"; +import { ModelProviderSchema, type AddResourceInput, type ScaffoldRuntimeInput } from "../../types"; import { RuntimeResourceConfigSchema, type ImportBedrockAgentInput } from "./types"; import { importScaffoldRuntimeInput, @@ -22,12 +22,37 @@ import { import { RegionKey } from "../../../keys"; import { addProjectResource } from "../shared"; +// The infrastructure settings that arrive as JSON documents. They are parsed +// but not yet validated when an entry point assembles a runtime, so they are +// `unknown` until toAddRuntimeInput runs the schema over them. +type JsonRuntimeField = + | "networkConfig" + | "authorizerConfiguration" + | "lifecycleConfiguration" + | "filesystemConfigurations"; + +/** A runtime resource as an entry point assembles it, before validation. */ +export type RuntimeInput = Omit, JsonRuntimeField> & + Partial>; + +/** + * toAddRuntimeInput is the one place a runtime is validated and wrapped for + * {@link ProjectManager.addResource}. Both the flag handler and the wizard call + * it, so neither can accept a runtime the other would reject. + */ +export function toAddRuntimeInput(input: RuntimeInput): AddResourceInput { + const result = RuntimeResourceConfigSchema.safeParse(input); + if (!result.success) + throw new InputValidationError(z.prettifyError(result.error), { cause: result.error }); + return { resourceType: "runtime", resourceConfig: result.data }; +} + export const createAddRuntimeHandler = (config: AddProjectResourceConfig) => createHandler({ name: "runtime", description: "add a Runtime to the current project", flags: [ - flag("name", "the name of the Runtime", z.string().max(42).optional()), + flag("name", "the name of the Runtime", AgentNameSchema.optional()), flag("description", "an optional description of the Runtime", z.string().optional()), flag( "type", @@ -220,19 +245,12 @@ export const createAddRuntimeHandler = (config: AddProjectResourceConfig) => importBedrockAgent, }; - const result = RuntimeResourceConfigSchema.safeParse(runtimeInput); - if (!result.success) - throw new InputValidationError(z.prettifyError(result.error), { cause: result.error }); - const project = ctx.require(ProjectKey); await addProjectResource( ctx, config, project, - { - resourceType: "runtime", - resourceConfig: result.data, - }, + toAddRuntimeInput(runtimeInput), `added runtime '${flags.name}' to '${project.name}'`, { notes }, ); diff --git a/src/handlers/project/add/runtime/runtime.screen.test.tsx b/src/handlers/project/add/runtime/runtime.screen.test.tsx new file mode 100644 index 000000000..ac3ab07f7 --- /dev/null +++ b/src/handlers/project/add/runtime/runtime.screen.test.tsx @@ -0,0 +1,352 @@ +import { test, expect, describe, afterEach } from "bun:test"; +import { join } from "node:path"; +import { QueryClient } from "@tanstack/react-query"; +import { + renderScreen, + waitForText, + waitForFlatText, + flatFrame, + cleanupScreens, + createSilentLogger, + TestCoreClient, + TestGlobalConfigAccessor, + testIO, + ttyTestIO, + waitFor, + type RenderScreenResult, +} from "../../../../testing"; +import { createRootHandler } from "../../../index"; +import { InputValidationError, InvalidEnvironmentError } from "../../../../errors"; +import type { AppIO } from "../../../../io"; +import { createGatewayProjectTestHarness } from "../gateway-test-support"; +import { projectQueryKey } from "../../ProjectGate"; +import type { Project } from "../../types"; + +const { cleanup, inProject, projectSpec, run } = + createGatewayProjectTestHarness("add-runtime-wizard"); + +afterEach(cleanup); +afterEach(cleanupScreens); + +// selectTemplate moves the choice list onto a named template rather than +// pressing a fixed number of arrows, so adding or reordering a template does +// not silently point this test at a different one. +async function selectTemplate(r: RenderScreenResult, template: string): Promise { + for (let i = 0; i < 20; i++) { + // The trailing space keeps a name from matching a longer one that starts + // with it — agent-python-strands against agent-python-strands-container. + if (flatFrame(r.lastFrame).includes(`● ${template} `)) return; + await r.press("down"); + } + throw new Error(`template ${template} was never selected`); +} + +// templateRows returns the choice list in the order it is drawn. +function templateRows(frame: string): string[] { + return ( + frame + .split("\n") + .map((line) => line.replace(/[│┃|]/g, " ").replace(/\s+/g, " ").trim()) + // A radio row starts with its marker; the stepper's markers sit mid-line. + .filter((line) => /^[●○] /.test(line)) + ); +} + +async function runtimeInSpec(projectRoot: string, name: string) { + const spec = await projectSpec(projectRoot); + return spec.runtimes.find((candidate: { name: string }) => candidate.name === name); +} + +describe("project add runtime wizard", () => { + test("collects a name and a template, then writes the runtime", async () => { + const projectRoot = await inProject(); + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false, gcTime: Infinity, staleTime: Infinity } }, + }); + const r = renderScreen("/agentcore/project/add/runtime", { queryClient }); + + await waitForText(r.lastFrame, "what should this runtime be called?"); + await r.write("orders_agent"); + await r.press("return"); + + // The default template is the one the flag path scaffolds without + // --template, and it is the first row, so the step opens at the top of its + // list rather than partway down it. + await waitForText(r.lastFrame, "choose a template"); + expect(templateRows(r.lastFrame()!)[0]).toStartWith("● agent-python-minimal "); + await r.press("return"); + + await waitForText(r.lastFrame, "this runtime will be added to agentcore.json"); + const review = flatFrame(r.lastFrame); + expect(review).toContain("runtime orders_agent"); + expect(review).toContain("template agent-python-minimal"); + await r.press("return"); + + await waitForText(r.lastFrame, "added runtime 'orders_agent' to 'TestProject'"); + expect(r.lastFrame()).toContain("[enter] go back"); + + expect(await runtimeInSpec(projectRoot, "orders_agent")).toMatchObject({ + build: "CodeZip", + entrypoint: "main.py", + codeLocation: "app/orders_agent", + runtimeVersion: "PYTHON_3_14", + }); + expect(await Bun.file(join(projectRoot, "app", "orders_agent", "main.py")).exists()).toBe(true); + expect( + queryClient + .getQueryData(projectQueryKey()) + ?.spec.runtimes.some((runtime) => runtime.name === "orders_agent"), + ).toBe(true); + + // Enter on the success panel returns to the add menu instead of tearing the + // TUI down, so another resource can be added straight away. + await r.press("return"); + await waitForText(r.lastFrame, "add project resources"); + r.unmount(); + }); + + test("scaffolds the template the user picks", async () => { + const projectRoot = await inProject(); + const r = renderScreen("/agentcore/project/add/runtime"); + + await waitForText(r.lastFrame, "what should this runtime be called?"); + await r.write("packing_agent"); + await r.press("return"); + + await waitForText(r.lastFrame, "choose a template"); + await selectTemplate(r, "agent-python-strands-container"); + await r.press("return"); + + await waitForFlatText(r.lastFrame, "build Container"); + await r.press("return"); + + await waitForText(r.lastFrame, "added runtime 'packing_agent' to 'TestProject'"); + + const runtime = await runtimeInSpec(projectRoot, "packing_agent"); + expect(runtime).toMatchObject({ build: "Container", codeLocation: "app/packing_agent" }); + // The wizard does not ask for a description; --description still sets one. + expect(runtime.description).toBeUndefined(); + expect(await Bun.file(join(projectRoot, "app", "packing_agent", "Dockerfile")).exists()).toBe( + true, + ); + r.unmount(); + }); + + test("a blank name is refused", async () => { + await inProject(); + const r = renderScreen("/agentcore/project/add/runtime"); + + await waitForText(r.lastFrame, "what should this runtime be called?"); + await r.press("return"); + + await waitForText(r.lastFrame, "Name is required"); + expect(r.lastFrame()).not.toContain("choose a template"); + r.unmount(); + }); + + test("a name that breaks the schema's pattern is rejected as it is typed", async () => { + await inProject(); + const r = renderScreen("/agentcore/project/add/runtime"); + + await waitForText(r.lastFrame, "what should this runtime be called?"); + // No enter: the name is checked while it is being typed, so the rule is + // stated before the user has finished getting it wrong. + await r.write("1agent"); + + await waitForText(r.lastFrame, "Must begin with a letter"); + r.unmount(); + }); + + test("accepts a 48-character name", async () => { + await inProject(); + const r = renderScreen("/agentcore/project/add/runtime"); + + await waitForText(r.lastFrame, "what should this runtime be called?"); + await r.write("a".repeat(48)); + await r.press("return"); + + await waitForText(r.lastFrame, "choose a template"); + r.unmount(); + }); + + test("rejects a name longer than 48 characters", async () => { + await inProject(); + const r = renderScreen("/agentcore/project/add/runtime"); + + await waitForText(r.lastFrame, "what should this runtime be called?"); + await r.write("a".repeat(49)); + + await waitForText(r.lastFrame, "Must be at most 48 characters"); + r.unmount(); + }); + + test("a name that is only valid once trimmed is refused, not silently trimmed", async () => { + const projectRoot = await inProject(); + const r = renderScreen("/agentcore/project/add/runtime"); + + await waitForText(r.lastFrame, "what should this runtime be called?"); + await r.write(" orders_agent "); + await r.press("return"); + + // Still on the name step: the value the step would submit is the value it + // checked, so a padded name cannot reach the project spec. + await waitForText(r.lastFrame, "Must begin with a letter"); + expect(r.lastFrame()).not.toContain("choose a template"); + expect(await runtimeInSpec(projectRoot, " orders_agent ")).toBeUndefined(); + r.unmount(); + }); + + test("a rejected add reports itself and hands the form back", async () => { + const projectRoot = await inProject(); + // The name is taken, so addResource refuses it — the realistic failure, and + // one the user can fix without starting over. + await run(["add", "runtime", "--name", "orders_agent"]); + const r = renderScreen("/agentcore/project/add/runtime"); + + await waitForText(r.lastFrame, "what should this runtime be called?"); + await r.write("orders_agent"); + await r.press("return"); + await waitForText(r.lastFrame, "choose a template"); + await r.press("return"); + await waitForText(r.lastFrame, "this runtime will be added to agentcore.json"); + await r.press("return"); + + await waitForFlatText(r.lastFrame, "a runtime with name 'orders_agent' already exists"); + // esc, not a dead TUI: the failure is reported inside the wizard, which then + // returns to the form with every answer still in it. + await r.press("escape"); + await waitForText(r.lastFrame, "this runtime will be added to agentcore.json"); + expect(flatFrame(r.lastFrame)).toContain("runtime orders_agent"); + + // The refused add wrote nothing beyond the runtime that was already there. + expect((await projectSpec(projectRoot)).runtimes).toHaveLength(2); + r.unmount(); + }, 15000); + + test("esc on the first step returns to the add menu", async () => { + await inProject(); + const r = renderScreen("/agentcore/project/add/runtime"); + + await waitForText(r.lastFrame, "what should this runtime be called?"); + await r.press("escape"); + + await waitForText(r.lastFrame, "add project resources"); + r.unmount(); + }); +}); + +// These drive the real CLI entrypoint rather than mounting the screen, because +// what they cover is the routing in front of it: a bare `project add runtime` +// has to reach the wizard, and everything else has to stay headless. +describe("project add runtime dispatch", () => { + function buildRoot(io: AppIO) { + return createRootHandler(new TestCoreClient(), { + io, + logger: createSilentLogger(), + globalConfigAccessor: new TestGlobalConfigAccessor(), + }); + } + + const MISSING_NAME = "required option '--name ' not specified"; + + async function routeError(io: AppIO, args: string[]): Promise { + return buildRoot(io) + .route(["node", "agentcore", "project", "add", "runtime", ...args]) + .then( + () => undefined, + (caught: unknown) => caught, + ); + } + + test("bare add runtime in a TTY session opens the wizard", async () => { + await inProject(); + const { streams, stdin } = ttyTestIO(); + + // outcome never rejects, so a mid-pump failure cannot trip bun's + // unhandled-rejection detection before the final assertion. + const outcome = buildRoot(streams.io) + .route(["node", "agentcore", "project", "add", "runtime"]) + .then( + () => ({ ok: true as const }), + (error: unknown) => ({ ok: false as const, error }), + ); + let settled = false; + void outcome.finally(() => { + settled = true; + }); + + // The wizard never finishes on its own; Ctrl+C (re-sent until the app + // reacts, slowly enough that repeats cannot coalesce into one chunk) closes + // it and resolves the route cleanly. The headless branch would instead + // reject with the missing --name usage error. + await waitFor( + () => { + if (!settled) stdin.write("\x03"); + return settled; + }, + 5000, + 150, + ); + expect(await outcome).toEqual({ ok: true }); + expect(streams.stderr()).not.toContain("required option"); + }, 10000); + + test("bare project add routes to the resource menu", async () => { + await inProject(); + const io = testIO(); + + const error = await buildRoot(io.io) + .route(["node", "agentcore", "project", "add"]) + .then( + () => undefined, + (caught: unknown) => caught, + ); + + expect(error).toBeInstanceOf(InvalidEnvironmentError); + expect(io.stdout()).toBe(""); + }); + + test("bare add runtime without a TTY stays headless and reports the missing --name", async () => { + await inProject(); + + const error = await routeError(testIO().io, []); + + expect(error).toBeInstanceOf(InputValidationError); + expect((error as Error).message).toContain(MISSING_NAME); + }); + + test("any user-supplied flag stays headless even in a TTY", async () => { + await inProject(); + + const error = await routeError(ttyTestIO().streams.io, ["--description", "an agent"]); + + expect(error).toBeInstanceOf(InputValidationError); + expect((error as Error).message).toContain(MISSING_NAME); + }); + + test("--json stays headless even in a TTY", async () => { + await inProject(); + + const error = await routeError(ttyTestIO().streams.io, ["--json"]); + + expect(error).toBeInstanceOf(InputValidationError); + expect((error as Error).message).toContain(MISSING_NAME); + }); + + test("flag-driven add runtime still runs headless in a TTY session", async () => { + const projectRoot = await inProject(); + const { streams } = ttyTestIO(); + + await buildRoot(streams.io).route([ + "node", + "agentcore", + "project", + "add", + "runtime", + "--name", + "flag_agent", + ]); + + expect(await runtimeInSpec(projectRoot, "flag_agent")).toBeDefined(); + }, 10000); +}); diff --git a/src/handlers/project/add/runtime/screen.tsx b/src/handlers/project/add/runtime/screen.tsx new file mode 100644 index 000000000..c7a609063 --- /dev/null +++ b/src/handlers/project/add/runtime/screen.tsx @@ -0,0 +1,135 @@ +import { useState } from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import { useNavigate } from "react-router"; +import { ProjectKey } from "../../../../router"; +import { AgentNameSchema } from "../../../../projectSchemas/runtime"; +import type { ScreenProps } from "../../../types"; +import type { Project } from "../../types"; +import { ProjectGate, projectQueryKey } from "../../ProjectGate"; +import { + ChoiceField, + Step, + Summary, + TextField, + Wizard, + type Choice, +} from "../../../../components/wizard"; +import { + RUNTIME_TEMPLATE_SHORTCUTS, + RUNTIME_TEMPLATE_SHORTCUT_NAMES, + resolveRuntimeTemplateShortcut, + type RuntimeTemplateShortcutName, +} from "../../shortcuts"; +import { toAddRuntimeInput, type RuntimeInput } from "./index"; + +const BREADCRUMB = ["agentcore", "project", "add", "runtime"]; +const DESCRIPTION = "add a Runtime to the current project"; +const ADD_MENU = "/agentcore/project/add"; + +const DEFAULT_TEMPLATE: RuntimeTemplateShortcutName = "agent-python-minimal"; + +const TEMPLATE_CHOICES: Choice[] = [ + DEFAULT_TEMPLATE, + ...RUNTIME_TEMPLATE_SHORTCUT_NAMES.filter((template) => template !== DEFAULT_TEMPLATE), +].map((template) => ({ + value: template, + label: template, + description: RUNTIME_TEMPLATE_SHORTCUTS[template].description, +})); + +interface RuntimeFormValues { + name: string; + template: RuntimeTemplateShortcutName; +} + +export function toRuntimeInput(values: RuntimeFormValues): RuntimeInput { + return { + name: values.name, + envVars: [], + scaffoldRuntimeInput: resolveRuntimeTemplateShortcut(values.template, { + runtimeName: values.name, + }), + }; +} + +function summaryOf(values: RuntimeFormValues): Record { + const template = RUNTIME_TEMPLATE_SHORTCUTS[values.template]; + return { + runtime: values.name, + template: values.template, + language: template.language, + build: template.build, + }; +} + +export function AddRuntimeScreen({ ctx, core }: ScreenProps) { + const navigate = useNavigate(); + return ( + navigate(ADD_MENU)} + > + {(project) => } + + ); +} + +function AddRuntimeWizard({ project, core }: { project: Project; core: ScreenProps["core"] }) { + const navigate = useNavigate(); + const queryClient = useQueryClient(); + const [values, setValues] = useState({ + name: "", + template: DEFAULT_TEMPLATE, + }); + const set = (update: Partial) => + setValues((current) => ({ ...current, ...update })); + + return ( + navigate(ADD_MENU)} + onSubmit={async function* () { + const updated = yield* core.projectManager.addResource( + project, + toAddRuntimeInput(toRuntimeInput(values)), + ); + queryClient.setQueryData(projectQueryKey(), updated); + return updated; + }} + runningLabel={`adding runtime ${values.name}…`} + successLabel={`added runtime '${values.name}' to '${project.name}'`} + onDone={() => navigate(ADD_MENU)} + doneLabel="go back" + > + + set({ name })} + required + schema={AgentNameSchema} + live + /> + + + + set({ template })} + /> + + + + + + + ); +} diff --git a/src/handlers/project/create/screen.tsx b/src/handlers/project/create/screen.tsx index 1ba2469e4..8f43e977e 100644 --- a/src/handlers/project/create/screen.tsx +++ b/src/handlers/project/create/screen.tsx @@ -1,5 +1,5 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { Box, Text, useApp, useInput } from "ink"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { Box, Text, useInput } from "ink"; import { ScrollView, type ScrollViewRef } from "ink-scroll-view"; import { useNavigate } from "react-router"; import { ProjectNameSchema } from "../../../projectSchemas/project"; @@ -16,17 +16,19 @@ import { type TemplateName, } from "../shortcuts"; import { HARNESS_DEFAULT_MODEL_IDS, resolveScaffoldHarnessInput } from "./index"; -import { Layout } from "../../../components/Layout"; -import { ErrorPanel } from "../../../components/ErrorPanel"; import { FormTextInput } from "../../../components/FormTextInput"; import { FormRadioGroup, type FormRadioOption } from "../../../components/FormRadioGroup"; -import { KeyValueTable } from "../../../components/KeyValueTable"; -import { Stepper, type Step } from "../../../components/ui/stepper"; -import { Spinner } from "../../../components/ui/spinner"; -import { TaskList, type Task } from "../../../components/ui/task-list"; -import { Divider } from "../../../components/ui/divider"; -import { driveProgress } from "../../../tui/progress"; -import { darkTheme, glyphs } from "../../../components/ui/_core.js"; +import { + ChoiceField, + Step, + Summary, + TextField, + Wizard, + useKeyHints, + useWizard, + type Choice, +} from "../../../components/wizard"; +import { darkTheme } from "../../../components/ui/_core.js"; const theme = darkTheme; @@ -104,14 +106,14 @@ function emptyCreateProjectForm(): CreateProjectFormValues { }; } -const PROJECT_KIND_OPTIONS: { kind: ProjectKind; label: string; description: string }[] = [ +const PROJECT_KIND_CHOICES: Choice[] = [ { - kind: "agent", + value: "agent", label: "agent code", description: "generate runnable agent code from a template", }, { - kind: "harness", + value: "harness", label: "harness", description: "a managed agent configured by spec — no agent-loop code to maintain", }, @@ -119,8 +121,8 @@ const PROJECT_KIND_OPTIONS: { kind: ProjectKind; label: string; description: str const DEFAULT_TEMPLATE: TemplateName = "agent-python-strands"; -const TEMPLATE_OPTIONS = PROJECT_TEMPLATE_NAMES.map((template) => ({ - template, +const TEMPLATE_CHOICES: Choice[] = PROJECT_TEMPLATE_NAMES.map((template) => ({ + value: template, label: template, description: template === EMPTY_TEMPLATE_NAME @@ -189,10 +191,7 @@ function providerLabel(provider: HarnessModelProvider): string { return MODEL_PROVIDERS.find((candidate) => candidate.provider === provider)!.label; } -// ─── wizard shell ───────────────────────────────────────────────────────────── - -type WizardPhase = - { kind: "form" } | { kind: "running" } | { kind: "success" } | { kind: "error"; error: Error }; +// ─── wizard ─────────────────────────────────────────────────────────────────── // ProjectCreateScreen is the interactive flow behind a bare `agentcore project // create`: name → type → (model | template) → review, then the @@ -202,304 +201,85 @@ type WizardPhase = // working directory, npm install and git init included. export function ProjectCreateScreen({ ctx, core }: ScreenProps) { const navigate = useNavigate(); - const { exit } = useApp(); - const [values, setValues] = useState(emptyCreateProjectForm); - const [stepIndex, setStepIndex] = useState(0); - const [phase, setPhase] = useState({ kind: "form" }); - const [tasks, setTasks] = useState([]); - - // The step list is dynamic: the branch chosen on the type step decides - // whether the model or the template question follows. - const steps: Step[] = useMemo(() => { - const branch: Step[] = - values.kind === "harness" - ? [{ key: "model", title: "model" }] - : [{ key: "template", title: "template" }]; - return [ - { key: "name", title: "name" }, - { key: "type", title: "type" }, - ...branch, - { key: "review", title: "review" }, - ]; - }, [values.kind]); - - const stepKey = steps[stepIndex]!.key; + const patch = (update: Partial) => setValues((current) => ({ ...current, ...update })); - const next = () => setStepIndex((i) => Math.min(steps.length - 1, i + 1)); - const back = () => { - // Esc from the first step leaves the wizard for the project menu, the - // same place RouterScreen's esc goes; deeper steps step backwards. - if (stepIndex === 0) navigate("/agentcore/project"); - else setStepIndex((i) => i - 1); - }; - - const submit = async () => { - let input: CreateProjectInput; - try { - assertProjectPathFits(values.name, ctx.require(PlatformKey)); - input = buildCreateInput(values); - } catch (error) { - setPhase({ kind: "error", error: toError(error) }); - return; - } - setPhase({ kind: "running" }); - setTasks([]); - try { - await driveProgress(core.projectManager.create(input), setTasks); - setPhase({ kind: "success" }); - } catch (error) { - setPhase({ kind: "error", error: toError(error) }); - } - }; - return ( - navigate("/agentcore/project")} + onSubmit={() => { + // Both of these throw before anything is written, so the wizard reports + // them the way it reports a failed create — with the retry still on + // offer, because nothing has to be cleaned up first. + assertProjectPathFits(values.name, ctx.require(PlatformKey)); + return core.projectManager.create(buildCreateInput(values)); + }} + runningLabel={`creating ${values.name}…`} + successLabel={`project created in ./${values.name}`} + successNextSteps={[`cd ${values.name}`, "agentcore project deploy"]} + successHint="enter exits" + doneLabel="exit" > - - {phase.kind === "form" && ( - <> - - step.key)} - /> - - - - - )} - {phase.kind !== "form" && ( - - - {phase.kind === "running" && tasks.length === 0 && ( - - )} - {phase.kind === "success" && ( - exit()} /> - )} - {phase.kind === "error" && ( - setPhase({ kind: "form" })} - /> - )} - - )} - - - ); -} - -function toError(error: unknown): Error { - return error instanceof Error ? error : new Error(String(error)); -} - -// A retry is only offered while nothing has been written yet: once a step has -// run, the scaffolded directory exists and re-submitting would fail on it. -function hintsFor( - stepKey: string, - phase: WizardPhase, - retryable: boolean, -): { key: string; label: string }[] { - if (phase.kind === "running") return [{ key: "ctrl+c", label: "quit" }]; - if (phase.kind === "success") return [{ key: "enter", label: "exit" }]; - if (phase.kind === "error") - return [ - ...(retryable ? [{ key: "r", label: "retry" }] : []), - { key: "esc", label: "back" }, - { key: "ctrl+c", label: "quit" }, - ]; - const base = [ - { key: "esc", label: "back" }, - { key: "ctrl+c", label: "quit" }, - ]; - switch (stepKey) { - case "name": - return [{ key: "enter", label: "continue" }, ...base]; - case "model": - return [{ key: "↑↓", label: "navigate" }, { key: "enter", label: "continue" }, ...base]; - case "type": - case "template": - case "memory": - return [{ key: "↑↓", label: "navigate" }, { key: "enter", label: "continue" }, ...base]; - case "review": - return [{ key: "enter", label: "create" }, ...base]; - default: - return base; - } -} - -// ─── steps ──────────────────────────────────────────────────────────────────── - -interface WizardStepProps { - stepKey: string; - values: CreateProjectFormValues; - patch: (update: Partial) => void; - onNext: () => void; - onBack: () => void; - onSubmit: () => void; -} - -function WizardStep({ stepKey, values, patch, onNext, onBack, onSubmit }: WizardStepProps) { - switch (stepKey) { - case "name": - return ( - + {/* The label is the schema's own subject, so a blank name is refused + with the message the flag-driven path prints for it. */} + patch({ name })} - onNext={onNext} - onBack={onBack} - /> - ); - case "type": - return ( - option.kind === values.kind)} - onSelect={(index) => patch({ kind: PROJECT_KIND_OPTIONS[index]!.kind })} - onNext={onNext} - onBack={onBack} - /> - ); - case "model": - return ( - patch({ model })} - onNext={onNext} - onBack={onBack} + schema={ProjectNameSchema} + required + live /> - ); - case "template": - return ( - option.template === values.template)} - onSelect={(index) => patch({ template: TEMPLATE_OPTIONS[index]!.template })} - onNext={onNext} - onBack={onBack} + + + + patch({ kind })} /> - ); - case "review": - return ; - default: - return null; - } -} - -// NameStep validates against ProjectNameSchema — the schema the flag-driven -// path enforces — showing the schema's own messages inline as the user types. -function NameStep({ - value, - onChange, - onNext, - onBack, -}: { - value: string; - onChange: (value: string) => void; - onNext: () => void; - onBack: () => void; -}) { - const [submitted, setSubmitted] = useState(false); - - const validation = ProjectNameSchema.safeParse(value); - const showError = !validation.success && (value !== "" || submitted); - const errorMessage = showError ? validation.error.issues[0]?.message : undefined; - - useInput((_input, key) => { - if (key.escape) { - onBack(); - return; - } - if (key.return) { - if (validation.success) onNext(); - else setSubmitted(true); - } - }); - - return ( - - { - onChange(next); - setSubmitted(false); - }} - /> - {errorMessage && {errorMessage}} - + + + {values.kind === "harness" && ( + + patch({ model })} /> + + )} + + {values.kind === "agent" && ( + + patch({ template })} + /> + + )} + + + + + + enter scaffolds the project, installs dependencies, and initializes git + + + + ); } -// RadioStep is a single-choice step: the parent owns the selection, this owns -// the arrow/enter/esc handling around a FormRadioGroup. -function RadioStep({ - name, - helpText, - options, - focusedIndex, - onSelect, - onNext, - onBack, -}: { - name: string; - helpText: string; - options: FormRadioOption[]; - focusedIndex: number; - onSelect: (index: number) => void; - onNext: () => void; - onBack: () => void; -}) { - useInput((_input, key) => { - if (key.escape) { - onBack(); - return; - } - if (key.upArrow) { - onSelect(Math.max(0, focusedIndex - 1)); - return; - } - if (key.downArrow) { - onSelect(Math.min(options.length - 1, focusedIndex + 1)); - return; - } - if (key.return) onNext(); - }); - - return ( - - - - ); -} +// ─── the model step ─────────────────────────────────────────────────────────── type ModelFieldKey = keyof ProjectModelConfig; @@ -558,17 +338,18 @@ function modelFields(provider: HarnessModelProvider): ModelField[] { return fields; } -function ModelStep({ +// ModelField is a compound field: one useInput over a provider list and the +// per-provider inputs the choice reveals. The wizard shell has no notion of +// focus, so the two levels are managed here — the provider list until enter, +// then the fields, with esc stepping back out. +function ModelField({ value, onChange, - onNext, - onBack, }: { value: ProjectModelValues; onChange: (value: ProjectModelValues) => void; - onNext: () => void; - onBack: () => void; }) { + const { advance, back } = useWizard(); const providerIndex = MODEL_PROVIDERS.findIndex((option) => option.provider === value.provider); const fields = modelFields(value.provider); const config = value.configs[value.provider]; @@ -597,10 +378,15 @@ function ModelStep({ keepFocusedFieldVisible(); }, [keepFocusedFieldVisible, value.provider, error]); + useKeyHints([ + { key: "↑↓", label: "navigate" }, + { key: "enter", label: "continue" }, + ]); + useInput((_input, key) => { if (focusedField === null) { if (key.escape) { - onBack(); + back(); return; } if (key.upArrow || key.downArrow) { @@ -648,7 +434,7 @@ function ModelStep({ setError(fields[missing]!.requiredError); return; } - onNext(); + advance(); } }); @@ -658,7 +444,7 @@ function ModelStep({ })); return ( - + ); } - -function ReviewStep({ - values, - onSubmit, - onBack, -}: { - values: CreateProjectFormValues; - onSubmit: () => void; - onBack: () => void; -}) { - useInput((_input, key) => { - if (key.escape) { - onBack(); - return; - } - if (key.return) onSubmit(); - }); - - return ( - - this project will be created - - - - - - enter scaffolds the project, installs dependencies, and initializes git - - - - ); -} - -// ─── result panels ──────────────────────────────────────────────────────────── - -function SuccessPanel({ name, onContinue }: { name: string; onContinue: () => void }) { - useInput((_input, key) => { - if (key.return || key.escape) onContinue(); - }); - - return ( - - - {glyphs.check} project created in ./{name} - - - next steps - {` cd ${name}`} - {" agentcore project deploy"} - - enter exits - - ); -} diff --git a/src/handlers/project/index.ts b/src/handlers/project/index.ts index e50a4f040..39a26c39d 100644 --- a/src/handlers/project/index.ts +++ b/src/handlers/project/index.ts @@ -1,10 +1,10 @@ -import { Router, type Handler } from "../../router"; +import { Router } from "../../router"; import { checkPort, openBrowser, startHttpServer, watchFile, type AppIO } from "../../io"; import { CodeZipDevRunner } from "../../core/dev/codezip"; import { ContainerDevRunner } from "../../core/dev/container"; import { InspectorAssets } from "../../core/dev/inspectorAssets"; import { startOtelCollector } from "../../core/dev/otel/collector"; -import { withProject, withTuiOnEmptyFlagsAndArgs } from "../../middleware"; +import { withProject, withTuiWhenInteractive } from "../../middleware"; import { renderTui } from "../../tui"; import type { Core } from "../types"; import { createCreateProjectHandler } from "./create"; @@ -27,15 +27,17 @@ type ProjectHandlerConfig = { export function createProjectHandler({ core, io }: ProjectHandlerConfig): Router { const projectManager: ProjectManager = core.projectManager; const config = { projectManager, io, bedrockAgentImporter: core.bedrockAgentImporter }; - // The subcommands with a screen of their own. Every other subcommand — and - // everything beneath a group like `add` — is listed in the menu as command - // line only and opens its help instead (see CliOnlyScreen). + // The subcommands with a screen of their own. Every other subcommand is + // listed in the menu as command line only and opens its help instead (see + // CliOnlyScreen). `add` is a group: it has the resource menu, and which of + // its resources have a wizard is declared on that router. const project = new Router("project", "manage an AgentCore project").supportedTuiCommands( "create", "invoke", "build", "deploy", "status", + "add", ); // Without a default, a bare `agentcore project` falls back to Commander's help @@ -43,30 +45,11 @@ export function createProjectHandler({ core, io }: ProjectHandlerConfig): Router project.default(renderTui(core, io)); // A bare `agentcore project create` in an interactive session opens the TUI - // create wizard; any user-supplied flag or --json keeps the headless handler. - // The TTY gate wraps the middleware (rather than living inside it) so a - // piped/CI invocation also stays headless and reports the missing --name as - // a usage error instead of renderTui's "interactive mode requires a TTY". - const createProject = createCreateProjectHandler({ - projectManager, - io, - }); - const createProjectWithWizard = withTuiOnEmptyFlagsAndArgs(core, io)(createProject); - const isInteractive = () => io.stdin.isTTY === true && io.stdout.isTTY === true; - const createProjectDispatch: Handler = { - name: () => createProject.name(), - description: () => createProject.description(), - flags: () => createProject.flags(), - arguments: () => createProject.arguments(), - doesSupportTui: () => createProject.doesSupportTui(), - children: () => createProject.children(), - handle: (ctx, flags, args) => - isInteractive() - ? createProjectWithWizard.handle(ctx, flags, args) - : createProject.handle(ctx, flags, args), - }; - project.handler(createProjectDispatch); - project.handler(createAddProjectResourceHandler(config)); + // create wizard; any user-supplied flag, --json, or a non-TTY invocation keeps + // the headless handler (see withTuiWhenInteractive). + const tuiWhenInteractive = withTuiWhenInteractive(core, io); + project.handler(tuiWhenInteractive(createCreateProjectHandler({ projectManager, io }))); + project.handler(createAddProjectResourceHandler(config, core)); project.handler(createExportProjectResourceHandler({ projectManager, core, io })); project.handler( withProject({ projectManager: config.projectManager })( @@ -101,24 +84,14 @@ export function createProjectHandler({ core, io }: ProjectHandlerConfig): Router project.handler(createProjectInvokeHandler(core, io)); // A bare `agentcore project status` in an interactive session opens the TUI // linked-resources screen; any user-supplied flag, --json, or a non-TTY - // invocation keeps the headless JSON report (same dispatch shape as create). - // withProject stays outermost so the not-found guidance outside a project is - // the CLI's own, and the resolved project seeds the screen via ProjectKey. - const statusProject = createStatusProjectHandler({ projectManager: config.projectManager }); - const statusProjectWithTui = withTuiOnEmptyFlagsAndArgs(core, io)(statusProject); - const statusProjectDispatch: Handler = { - name: () => statusProject.name(), - description: () => statusProject.description(), - flags: () => statusProject.flags(), - arguments: () => statusProject.arguments(), - doesSupportTui: () => statusProject.doesSupportTui(), - children: () => statusProject.children(), - handle: (ctx, flags, args) => - isInteractive() - ? statusProjectWithTui.handle(ctx, flags, args) - : statusProject.handle(ctx, flags, args), - }; - project.handler(withProject({ projectManager: config.projectManager })(statusProjectDispatch)); + // invocation keeps the headless JSON report. withProject stays outermost so + // the not-found guidance outside a project is the CLI's own, and the resolved + // project seeds the screen via ProjectKey. + project.handler( + withProject({ projectManager: config.projectManager })( + tuiWhenInteractive(createStatusProjectHandler({ projectManager: config.projectManager })), + ), + ); // withProject wraps only the commands that require an existing project, so // `create` (which refuses to nest inside one) stays unaffected. project.handler( diff --git a/src/handlers/project/project.screen.test.tsx b/src/handlers/project/project.screen.test.tsx index af24a228f..25248f4a6 100644 --- a/src/handlers/project/project.screen.test.tsx +++ b/src/handlers/project/project.screen.test.tsx @@ -75,7 +75,7 @@ describe("project menu: command-line-only subcommands", () => { const r = renderScreen("/agentcore/project"); await waitForText(r.lastFrame, "command line only"); - const withScreens = ["create", "deploy", "invoke", "build", "status"]; + const withScreens = ["create", "deploy", "invoke", "build", "status", "add"]; const { screens, cliOnly } = menuEntries(r.lastFrame()!); expect(screens.toSorted()).toEqual(withScreens.toSorted()); expect(cliOnly.toSorted()).toEqual( @@ -126,8 +126,10 @@ describe("project menu: command-line-only subcommands", () => { r.unmount(); }); + // These three exercise the help viewport, so they need a command-line-only + // resource whose help is longer than the terminal: `add payment-manager`. test("growing the terminal after scrolling to the bottom pulls the content back into view", async () => { - const r = renderScreen("/agentcore/project/add/runtime"); + const r = renderScreen("/agentcore/project/add/payment-manager"); await r.resize(80, 24); await waitForText(r.lastFrame, "this command runs from the command line"); for (let i = 0; i < 80; i++) await r.press("down"); @@ -138,12 +140,12 @@ describe("project menu: command-line-only subcommands", () => { // reflows the content, which would mask a clamp that read a stale height. await r.resize(80, 120); await waitForText(r.lastFrame, "this command runs from the command line"); - expect(r.lastFrame()).toContain("--role-arn"); + expect(r.lastFrame()).toContain("--default-spend-limit"); r.unmount(); }); test("a key that fills its column still stands clear of its value", async () => { - const r = renderScreen("/agentcore/project/add/runtime"); + const r = renderScreen("/agentcore/project/add/payment-manager"); await r.resize(40, 60); // Narrow enough that the intro wraps and the key column hits its cap. await waitForFlatText(r.lastFrame, "this command runs from the command line"); @@ -157,7 +159,7 @@ describe("project menu: command-line-only subcommands", () => { }); test("every option is reachable on a small terminal", async () => { - const r = renderScreen("/agentcore/project/add/runtime"); + const r = renderScreen("/agentcore/project/add/payment-manager"); await r.resize(80, 24); await waitForText(r.lastFrame, "this command runs from the command line"); @@ -170,7 +172,7 @@ describe("project menu: command-line-only subcommands", () => { await r.press("down"); collect(); } - const compiled = projectCommand("add", "runtime"); + const compiled = projectCommand("add", "payment-manager"); for (const option of compiled.options) { if (option.long && option.long !== "--help") expect(seen).toContain(option.long); } diff --git a/src/handlers/project/shortcuts.test.ts b/src/handlers/project/shortcuts.test.ts index f6a047a30..c9e9577a9 100644 --- a/src/handlers/project/shortcuts.test.ts +++ b/src/handlers/project/shortcuts.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { PROJECT_TEMPLATE_NAMES } from "./shortcuts"; +import { getDefaultMemorySpec, PROJECT_TEMPLATE_NAMES } from "./shortcuts"; describe("template order", () => { test("groups by protocol, then language, framework, and build, with empty last", () => { @@ -17,3 +17,10 @@ describe("template order", () => { ]); }); }); + +test("default memory names fit the service limit for long runtime names", () => { + const memory = getDefaultMemorySpec("a".repeat(48)); + + expect(memory.name).toBe(`${"a".repeat(42)}Memory`); + expect(memory.name).toHaveLength(48); +}); diff --git a/src/handlers/project/shortcuts.ts b/src/handlers/project/shortcuts.ts index 7c4e2f505..f1a235b41 100644 --- a/src/handlers/project/shortcuts.ts +++ b/src/handlers/project/shortcuts.ts @@ -2,6 +2,7 @@ import z from "zod"; import { DEFAULT_EPISODIC_REFLECTION_NAMESPACE_TEMPLATES, DEFAULT_STRATEGY_NAMESPACE_TEMPLATES, + MEMORY_NAME_MAX_LENGTH, type Memory, } from "../../projectSchemas/memory"; import { InputValidationError } from "../../errors"; @@ -9,8 +10,10 @@ import { ScaffoldRuntimeInputSchema, type ModelProvider, type ScaffoldRuntimeInp /** The default memory that templates ship with. */ export function getDefaultMemorySpec(runtimeName: string): Memory { + const suffix = "Memory"; + const name = `${runtimeName.slice(0, MEMORY_NAME_MAX_LENGTH - suffix.length)}${suffix}`; return { - name: `${runtimeName}Memory`, + name, eventExpiryDuration: 30, strategies: (["SEMANTIC", "USER_PREFERENCE", "SUMMARIZATION", "EPISODIC"] as const).map( (type) => ({ diff --git a/src/middleware/index.tsx b/src/middleware/index.tsx index 9a87c9d68..90d47c98c 100644 --- a/src/middleware/index.tsx +++ b/src/middleware/index.tsx @@ -1,5 +1,5 @@ export { withRegion } from "./withRegion"; -export { withTuiOnEmptyFlagsAndArgs } from "./withTuiOnEmptyFlagsAndArgs"; +export { withTuiOnEmptyFlagsAndArgs, withTuiWhenInteractive } from "./withTuiOnEmptyFlagsAndArgs"; export { withJsonRenderer } from "./withJsonRenderer"; export { withLogging } from "./withLogging"; export { withGlobalConfigAccessor } from "./withGlobalConfigAccessor"; diff --git a/src/middleware/withTuiOnEmptyFlagsAndArgs.tsx b/src/middleware/withTuiOnEmptyFlagsAndArgs.tsx index 4574b0aa6..bd89ca433 100644 --- a/src/middleware/withTuiOnEmptyFlagsAndArgs.tsx +++ b/src/middleware/withTuiOnEmptyFlagsAndArgs.tsx @@ -54,3 +54,27 @@ export function withTuiOnEmptyFlagsAndArgs(core: Core, io: AppIO): Middleware { }, }); } + +// withTuiWhenInteractive is withTuiOnEmptyFlagsAndArgs behind a TTY gate: a bare +// invocation opens the TUI only in an interactive session. The gate sits here +// rather than inside renderTui so that a piped or CI run stays headless and +// reports a missing required flag as the usage error it is, instead of +// renderTui's "interactive mode requires a TTY". +export function withTuiWhenInteractive(core: Core, io: AppIO): Middleware { + const withTui = withTuiOnEmptyFlagsAndArgs(core, io); + const isInteractive = () => io.stdin.isTTY === true && io.stdout.isTTY === true; + + return (h) => { + const interactive = withTui(h); + return { + name: () => h.name(), + description: () => h.description(), + flags: () => h.flags(), + arguments: () => h.arguments(), + doesSupportTui: () => h.doesSupportTui(), + children: () => h.children(), + handle: (ctx, flags, args) => + isInteractive() ? interactive.handle(ctx, flags, args) : h.handle(ctx, flags, args), + }; + }; +} diff --git a/src/projectSchemas/memory.ts b/src/projectSchemas/memory.ts index 722429681..3a36e2d5f 100644 --- a/src/projectSchemas/memory.ts +++ b/src/projectSchemas/memory.ts @@ -95,10 +95,11 @@ export const MemoryStrategySchema = z export type MemoryStrategy = z.infer; export const MemoryTypeSchema = z.literal("AgentCoreMemory"); export type MemoryType = z.infer; +export const MEMORY_NAME_MAX_LENGTH = 48; export const MemoryNameSchema = z .string() .min(1, "Name is required") - .max(48) + .max(MEMORY_NAME_MAX_LENGTH) .regex( /^[a-zA-Z][a-zA-Z0-9_]{0,47}$/, "Must begin with a letter and contain only alphanumeric characters and underscores (max 48 chars)", diff --git a/src/projectSchemas/runtime.ts b/src/projectSchemas/runtime.ts index d5bd8e2e5..5e626e417 100644 --- a/src/projectSchemas/runtime.ts +++ b/src/projectSchemas/runtime.ts @@ -16,7 +16,7 @@ import { z } from "zod"; export const AgentNameSchema = z .string() .min(1, "Name is required") - .max(48) + .max(48, "Must be at most 48 characters") .regex( /^[a-zA-Z][a-zA-Z0-9_]{0,47}$/, "Must begin with a letter and contain only alphanumeric characters and underscores (max 48 chars)", diff --git a/src/tui/progress.tsx b/src/tui/progress.tsx index 6de58faf4..a7851bc27 100644 --- a/src/tui/progress.tsx +++ b/src/tui/progress.tsx @@ -10,6 +10,14 @@ import type { AppIO } from "../io"; */ export type ProgressEvent = { type: "step"; message: string } | { type: "output"; line: string }; +export type ProgressResult = Promise | AsyncGenerator; + +export function isProgressGenerator( + result: ProgressResult, +): result is AsyncGenerator { + return typeof (result as AsyncGenerator)[Symbol.asyncIterator] === "function"; +} + export type RunWithProgressOptions = { io: AppIO; /** Lines of live output kept under the running step (default 5). */