diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx index bd5d9d60d..7a7d1d937 100644 --- a/src/components/Footer.tsx +++ b/src/components/Footer.tsx @@ -6,7 +6,7 @@ import { KeyHint, type KeyHintProps } from "./ui/key-hint"; // Footer renders a divider above a KeyHint row. It takes the same props as // KeyHint so screens can declare their key hints directly. export const Footer: React.FC = (props) => ( - + diff --git a/src/components/FormRadioGroup.tsx b/src/components/FormRadioGroup.tsx index 3a8dce453..d3b9506ba 100644 --- a/src/components/FormRadioGroup.tsx +++ b/src/components/FormRadioGroup.tsx @@ -35,14 +35,20 @@ export function FormRadioGroup({ name, helpText, options, selectedIndex }: FormR {options.map((option, i) => { const selected = i === selectedIndex; return ( - - - {selected ? "● " : "○ "} - - - {option.label.padEnd(columnWidth)} - - {option.description} + + + + {selected ? "●" : "○"} + + + + + {option.label} + + + + {option.description} + ); })} diff --git a/src/components/Header.tsx b/src/components/Header.tsx index f99ff1743..3ac6f62c6 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -15,7 +15,7 @@ export interface HeaderProps { // Header renders a screen's breadcrumb trail followed by a divider. export const Header: React.FC = ({ breadcrumb, description }) => ( - + {breadcrumb.map((segment, i) => ( diff --git a/src/components/ui/divider/Divider.tsx b/src/components/ui/divider/Divider.tsx index 6546aebb7..c476074e9 100644 --- a/src/components/ui/divider/Divider.tsx +++ b/src/components/ui/divider/Divider.tsx @@ -43,7 +43,7 @@ export const Divider: React.FC = ({ } return ( - + {line} ); diff --git a/src/handlers/project/create/create.screen.test.tsx b/src/handlers/project/create/create.screen.test.tsx index c5f8232f9..76ce353c2 100644 --- a/src/handlers/project/create/create.screen.test.tsx +++ b/src/handlers/project/create/create.screen.test.tsx @@ -79,10 +79,15 @@ describe("project create wizard", () => { expect(r.lastFrame()).toContain("● harness (recommended)"); await r.press("return"); - // Model step: prefilled with the default harness model. - await waitForText(r.lastFrame, "model id"); + // Model step: providers and the selected provider's fields share one page. + await waitForText(r.lastFrame, "choose a model"); + expect(r.lastFrame()).toContain("● bedrock (recommended)"); + expect(r.lastFrame()).toContain("○ openai"); + expect(r.lastFrame()).toContain("○ gemini"); + expect(r.lastFrame()).toContain("○ litellm"); expect(r.lastFrame()).toContain(DEFAULT_MODEL_ID); - await r.press("return"); + await r.press("return"); // focus model id + await r.press("return"); // accept model id // Review: the summary names the project, type, model, and directory. await waitForText(r.lastFrame, "this project will be created"); @@ -131,8 +136,10 @@ describe("project create wizard", () => { await waitForText(r.lastFrame, "what should the project be built around?"); await r.press("return"); - // The cursor starts at the end of the prefilled id; typing appends. - await waitForText(r.lastFrame, "model id"); + // Enter focuses the selected provider's model field. The cursor starts at + // the end of the prefilled id, so typing appends. + await waitForText(r.lastFrame, "choose a model"); + await r.press("return"); await r.write("-test"); await r.press("return"); await waitForText(r.lastFrame, "this project will be created"); @@ -151,6 +158,115 @@ describe("project create wizard", () => { r.unmount(); }, 10000); + test("a provider API key ARN flows through the existing harness input", async () => { + const directory = await inTempDirectory(); + const core = new TestCoreClient(); + const inputs = spyOnCreate(core); + const r = renderScreen("/agentcore/project/create", { core }); + const apiKeyArn = + "arn:aws:bedrock-agentcore:us-east-1:123456789012:token-vault/default/apikeycredentialprovider/OpenAIKey"; + + await waitForText(r.lastFrame, "name your project"); + await r.write("OpenAIApp"); + await r.press("return"); + await waitForText(r.lastFrame, "what should the project be built around?"); + await r.press("return"); + + await waitForText(r.lastFrame, "choose a model"); + await r.press("down"); + expect(r.lastFrame()).toContain("● openai"); + await r.press("return"); // focus model id + expect(r.lastFrame()).toContain("gpt-5"); + await r.press("return"); // focus API key ARN + await r.press("return"); + await waitForText(r.lastFrame, "enter an API key ARN for openai"); + await r.write(apiKeyArn); + await r.press("return"); + + await waitForText(r.lastFrame, "this project will be created"); + const review = r.lastFrame()!; + expect(review).toContain("provider"); + expect(review).toContain("openai"); + expect(review).toContain("model"); + expect(review).toContain("gpt-5"); + expect(review).toContain("api key arn"); + expect(review.replace(/\s/g, "")).toContain(apiKeyArn); + await r.press("return"); + await waitForText(r.lastFrame, "project created in ./OpenAIApp", 5000); + + expect(inputs[0]).toEqual({ + name: "OpenAIApp", + skipInstall: false, + skipGit: false, + scaffoldHarnessInput: { + name: "OpenAIApp", + model: { + provider: "open_ai", + modelId: "gpt-5", + apiKeyArn, + }, + }, + }); + + const root = join(directory, "OpenAIApp"); + const spec = await Bun.file(join(root, "agentcore", "agentcore.json")).json(); + expect(spec.credentials).toEqual([]); + const harness = await Bun.file(join(root, "app", "OpenAIApp", "harness.json")).json(); + expect(harness.model).toEqual({ + provider: "open_ai", + modelId: "gpt-5", + apiKeyArn, + }); + r.unmount(); + }, 10000); + + test("switching providers preserves each provider's model input", async () => { + const r = renderScreen("/agentcore/project/create"); + + await waitForText(r.lastFrame, "name your project"); + await r.write("ProviderApp"); + await r.press("return"); + await r.press("return"); + await waitForText(r.lastFrame, "choose a model"); + + await r.press("down"); // openai + await r.press("return"); // model id + await r.write("-custom"); + await r.press("escape"); // provider list + await r.press("down"); // gemini + expect(r.lastFrame()).toContain("● gemini"); + await r.press("up"); // openai + await r.press("return"); + expect(r.lastFrame()).toContain("gpt-5-custom"); + r.unmount(); + }); + + test("the model picker remains readable in an 80x24 terminal", async () => { + const r = renderScreen("/agentcore/project/create"); + await r.resize(80, 24); + + await waitForText(r.lastFrame, "name your project"); + await r.write("CompactApp"); + await r.press("return"); + await r.press("return"); + await waitForText(r.lastFrame, "choose a model"); + + const frame = r.lastFrame()!; + const lines = frame.split("\n"); + expect(lines[0]).toContain("agentcore → project → create"); + expect(lines[1]).toBe("─".repeat(80)); + expect(lines[2]).toContain("✓ name"); + expect(frame).toContain("● bedrock (recommended)"); + expect(frame).toContain("○ openai"); + expect(frame).toContain("○ gemini"); + expect(frame).toContain("○ litellm"); + expect(frame).toContain("model id"); + expect(frame).toContain(DEFAULT_MODEL_ID); + expect(frame).toContain("[enter] continue"); + expect(frame).toContain("[esc] back"); + r.unmount(); + }); + test("template flow: strands with the default memory choice", async () => { const directory = await inTempDirectory(); const core = new TestCoreClient(); @@ -171,16 +287,27 @@ describe("project create wizard", () => { expect(r.lastFrame()).toContain("hello-world-python"); expect(r.lastFrame()).toContain("hello-world-python-container"); expect(r.lastFrame()).toContain("● strands-python (recommended)"); + const templateFrame = r.lastFrame() ?? ""; + expect(templateFrame.indexOf("strands-python")).toBeLessThan( + templateFrame.indexOf("hello-world-python"), + ); await r.press("return"); // Memory step: asked only for strands; long and short-term preselected. await waitForText(r.lastFrame, "choose a memory configuration"); expect(r.lastFrame()).toContain("● long and short-term"); + const memoryFrame = r.lastFrame() ?? ""; + expect(memoryFrame.indexOf("long and short-term")).toBeLessThan(memoryFrame.indexOf("none")); await r.press("return"); await waitForText(r.lastFrame, "this project will be created"); + const reviewLines = (r.lastFrame() ?? "").split("\n"); + const reviewHeading = reviewLines.findIndex((line) => + line.includes("this project will be created"), + ); + expect(reviewLines[reviewHeading + 1] ?? "").toContain("─"); expect(r.lastFrame()).toContain("strands-python"); - expect(r.lastFrame()).toContain("longAndShortTerm"); + expect(r.lastFrame()).toContain("long and short-term"); await r.press("return"); await waitForText(r.lastFrame, "project created in ./StrandsApp", 5000); @@ -219,8 +346,7 @@ describe("project create wizard", () => { await waitForText(r.lastFrame, "choose a template"); await r.press("return"); // strands-python is preselected await waitForText(r.lastFrame, "choose a memory configuration"); - await r.press("up"); // short-term - await r.press("up"); // none + await r.press("down"); // none await waitForText(r.lastFrame, "● none"); await r.press("return"); await waitForText(r.lastFrame, "this project will be created"); @@ -249,8 +375,7 @@ describe("project create wizard", () => { await r.press("down"); await r.press("return"); await waitForText(r.lastFrame, "choose a template"); - await r.press("up"); // hello-world-python-container - await r.press("up"); // hello-world-python + await r.press("down"); // hello-world-python await waitForText(r.lastFrame, "● hello-world-python "); await r.press("return"); @@ -341,6 +466,53 @@ describe("project create wizard", () => { r.unmount(); }); + test("the spinner follows streamed progress without a blank row", async () => { + const core = new TestCoreClient(); + let release!: () => void; + const held = new Promise((resolve) => { + release = resolve; + }); + core.projectManager.create = () => { + return (async function* () { + yield { message: "syncing dependencies" }; + await held; + throw new Error("stopped"); + })(); + }; + const r = renderScreen("/agentcore/project/create", { core }); + + await waitForText(r.lastFrame, "name your project"); + await r.write("DemoApp"); + await r.press("return"); + await waitForText(r.lastFrame, "what should the project be built around?"); + await r.press("return"); + await waitForText(r.lastFrame, "choose a model"); + await r.press("return"); + await r.press("return"); + await waitForText(r.lastFrame, "this project will be created"); + await r.press("return"); + + await waitFor(() => + r.frames.some( + (frame) => frame.includes("✓ syncing dependencies") && frame.includes("creating DemoApp…"), + ), + ); + const progressFrame = + [...r.frames] + .reverse() + .find( + (frame) => + frame.includes("✓ syncing dependencies") && frame.includes("creating DemoApp…"), + ) ?? ""; + const lines = progressFrame.split("\n"); + const eventLine = lines.findIndex((line) => line.includes("✓ syncing dependencies")); + const spinnerLine = lines.findIndex((line) => line.includes("creating DemoApp…")); + expect(spinnerLine).toBe(eventLine + 1); + + r.unmount(); + release(); + }); + test("an error from create() renders after the streamed progress", async () => { const core = new TestCoreClient(); core.projectManager.create = () => { @@ -356,8 +528,9 @@ describe("project create wizard", () => { await r.press("return"); await waitForText(r.lastFrame, "what should the project be built around?"); await r.press("return"); - await waitForText(r.lastFrame, "model id"); - await r.press("return"); + await waitForText(r.lastFrame, "choose a model"); + await r.press("return"); // focus model id + await r.press("return"); // accept model id await waitForText(r.lastFrame, "this project will be created"); await r.press("return"); @@ -404,8 +577,9 @@ describe("project create wizard", () => { // key that landed before its step's input handler subscribed. await tick(50); stdin.write("DemoApp"); - // One return per step: name → type → model → review → submit. - for (let press = 0; press < 4; press++) { + // One return per step, plus one to enter the model field: + // name → type → provider → model → review → submit. + for (let press = 0; press < 5; press++) { await tick(50); stdin.write("\r"); } diff --git a/src/handlers/project/create/screen.tsx b/src/handlers/project/create/screen.tsx index b51b3ec1c..fd1e73c91 100644 --- a/src/handlers/project/create/screen.tsx +++ b/src/handlers/project/create/screen.tsx @@ -1,7 +1,9 @@ -import { useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Box, Text, useApp, useInput } from "ink"; +import { ScrollView, type ScrollViewRef } from "ink-scroll-view"; import { useNavigate } from "react-router"; import { ProjectNameSchema } from "../../../projectSchemas/project"; +import type { HarnessModelProvider } from "../../../projectSchemas/harness"; import type { ScreenProps } from "../../types"; import type { CreateProjectInput } from "../types"; import { DEFAULT_HARNESS_MODEL } from "../add/harness"; @@ -28,21 +30,75 @@ const theme = darkTheme; // a harness (the default) or scaffolded runtime code. type ProjectKind = "harness" | "agent"; +interface ProjectModelConfig { + modelId: string; + apiKeyArn: string; + apiBase: string; +} + +interface ProjectModelValues { + provider: HarnessModelProvider; + configs: Record; +} + interface CreateProjectFormValues { name: string; kind: ProjectKind; - // modelId configures the harness path; everything else uses defaults. - modelId: string; + model: ProjectModelValues; // template + memory configure the agent path; memory applies to strands only. template: RuntimeTemplateShortcutName; memory: MemoryShortcutName; } +const MODEL_PROVIDERS: { + provider: HarnessModelProvider; + label: string; + description: string; + defaultModelId: string; +}[] = [ + { + provider: "bedrock", + label: "bedrock (recommended)", + description: "an Amazon Bedrock model or inference profile", + defaultModelId: DEFAULT_HARNESS_MODEL.modelId, + }, + { + provider: "open_ai", + label: "openai", + description: "an OpenAI model using an API-key credential ARN", + defaultModelId: "gpt-5", + }, + { + provider: "gemini", + label: "gemini", + description: "a Google Gemini model using an API-key credential ARN", + defaultModelId: "gemini-2.5-flash", + }, + { + provider: "lite_llm", + label: "litellm", + description: "a third-party provider through LiteLLM", + defaultModelId: "anthropic/claude-sonnet-4-5", + }, +]; + +function emptyProjectModel(): ProjectModelValues { + return { + provider: "bedrock", + configs: Object.fromEntries( + MODEL_PROVIDERS.map(({ provider, defaultModelId }) => [ + provider, + { modelId: defaultModelId, apiKeyArn: "", apiBase: "" }, + ]), + ) as Record, + }; +} + function emptyCreateProjectForm(): CreateProjectFormValues { return { name: "", kind: "harness", - modelId: DEFAULT_HARNESS_MODEL.modelId, + model: emptyProjectModel(), template: "strands-python", memory: "longAndShortTerm", }; @@ -66,6 +122,11 @@ const TEMPLATE_OPTIONS: { label: string; description: string; }[] = [ + { + template: "strands-python", + label: "strands-python (recommended)", + description: "Strands agent on Bedrock with memory (CodeZip build)", + }, { template: "hello-world-python", label: "hello-world-python", @@ -76,11 +137,6 @@ const TEMPLATE_OPTIONS: { label: "hello-world-python-container", description: "the hello-world agent packaged as a container image", }, - { - template: "strands-python", - label: "strands-python (recommended)", - description: "Strands agent on Bedrock with memory (CodeZip build)", - }, { template: "py-mcp", label: "py-mcp", @@ -89,32 +145,42 @@ const TEMPLATE_OPTIONS: { ]; const MEMORY_OPTIONS: { memory: MemoryShortcutName; label: string; description: string }[] = [ + { + memory: "longAndShortTerm", + label: "long and short-term", + description: "session events plus long-term memory strategies (recommended)", + }, { memory: "none", label: "none", description: "no memory resources" }, { memory: "shortTerm", label: "short-term", description: "raw session events, 30-day expiry", }, - { - memory: "longAndShortTerm", - label: "long and short-term", - description: "session events plus long-term memory strategies (recommended)", - }, ]; -// buildCreateInput translates the form into the same CreateProjectInput the -// flag-driven `project create` builds: the harness path reuses its -// resolveScaffoldHarnessInput translation and the agent path resolves the same -// template shortcuts, so the wizard cannot drift from the headless CLI. +function selectedModel(values: CreateProjectFormValues): ProjectModelConfig { + return values.model.configs[values.model.provider]; +} + +// buildCreateInput translates the form through the same resolver as the +// flag-driven path, including its existing API-key ARN support. export function buildCreateInput(values: CreateProjectFormValues): CreateProjectInput { if (values.kind === "harness") { + const provider = values.model.provider; + const config = selectedModel(values); return { name: values.name, skipInstall: false, skipGit: false, scaffoldHarnessInput: resolveScaffoldHarnessInput({ name: values.name, - "model-id": values.modelId, + "model-provider": provider, + "model-id": config.modelId.trim(), + "api-key-arn": config.apiKeyArn.trim() || undefined, + "api-base": + provider === "lite_llm" && config.apiBase.trim() !== "" + ? config.apiBase.trim() + : undefined, }), }; } @@ -133,14 +199,35 @@ export function buildCreateInput(values: CreateProjectFormValues): CreateProject // summaryOf renders the review table: what will be created, and where. function summaryOf(values: CreateProjectFormValues): Record { - const base = { project: values.name, directory: `./${values.name}` }; + const base = { project: values.name }; if (values.kind === "harness") { - return { ...base, type: "harness", model: values.modelId }; + const provider = values.model.provider; + const config = selectedModel(values); + return { + ...base, + type: "harness", + provider: providerLabel(provider), + model: config.modelId, + ...(config.apiKeyArn && { "api key arn": config.apiKeyArn }), + ...(config.apiBase && { "api base url": config.apiBase }), + directory: `./${values.name}`, + }; } const withTemplate = { ...base, type: "agent code", template: values.template }; return values.template === "strands-python" - ? { ...withTemplate, memory: values.memory } - : withTemplate; + ? { + ...withTemplate, + memory: MEMORY_OPTIONS.find((option) => option.memory === values.memory)!.label, + directory: `./${values.name}`, + } + : { ...withTemplate, directory: `./${values.name}` }; +} + +function providerLabel(provider: HarnessModelProvider): string { + return MODEL_PROVIDERS.find((candidate) => candidate.provider === provider)!.label.replace( + " (recommended)", + "", + ); } // ─── wizard shell ───────────────────────────────────────────────────────────── @@ -217,7 +304,7 @@ export function ProjectCreateScreen({ core }: ScreenProps) { {phase.kind === "form" && ( <> - + patch({ modelId })} + value={values.model} + onChange={(model) => patch({ model })} onNext={onNext} onBack={onBack} /> @@ -387,7 +475,7 @@ function NameStep({ candidate.provider === provider)!; + const fields: ModelField[] = [ + { + key: "modelId", + name: "model id", + helpText: + provider === "bedrock" + ? "a Bedrock model or inference profile id" + : `the ${providerLabel(provider)} model to use`, + placeholder: option.defaultModelId, + required: true, + requiredError: `enter a model id for ${providerLabel(provider)}`, + }, + ]; + + if (provider !== "bedrock") { + fields.push({ + key: "apiKeyArn", + name: "api key arn", + helpText: + provider === "lite_llm" + ? "optional · an AgentCore Identity API-key credential provider ARN" + : "an AgentCore Identity API-key credential provider ARN", + placeholder: + provider === "lite_llm" + ? "optional" + : "arn:aws:bedrock-agentcore:…:token-vault/…/apikeycredentialprovider/…", + required: provider !== "lite_llm", + requiredError: `enter an API key ARN for ${providerLabel(provider)}`, + }); + } + + if (provider === "lite_llm") { + fields.push({ + key: "apiBase", + name: "api base url", + helpText: "optional · the provider API endpoint", + placeholder: "https://…", + required: false, + requiredError: "", + }); + } + + return fields; +} + function ModelStep({ value, onChange, onNext, onBack, }: { - value: string; - onChange: (value: string) => void; + value: ProjectModelValues; + onChange: (value: ProjectModelValues) => void; onNext: () => void; onBack: () => void; }) { + const providerIndex = MODEL_PROVIDERS.findIndex((option) => option.provider === value.provider); + const fields = modelFields(value.provider); + const config = value.configs[value.provider]; + const [focusedField, setFocusedField] = useState(null); const [error, setError] = useState(null); + const scrollRef = useRef(null); + + const keepFocusedFieldVisible = useCallback(() => { + const scroll = scrollRef.current; + if (!scroll) return; + if (focusedField === null) { + scroll.scrollToTop(); + return; + } + + const position = scroll.getItemPosition(focusedField + 1); + if (!position) return; + const viewportHeight = scroll.getViewportHeight(); + const offset = scroll.getScrollOffset(); + const bottom = position.top + position.height; + if (position.top < offset) scroll.scrollTo(position.top); + else if (bottom > offset + viewportHeight) scroll.scrollTo(bottom - viewportHeight); + }, [focusedField]); + + useEffect(() => { + keepFocusedFieldVisible(); + }, [keepFocusedFieldVisible, value.provider, error]); useInput((_input, key) => { + if (focusedField === null) { + if (key.escape) { + onBack(); + return; + } + if (key.upArrow || key.downArrow) { + const nextIndex = key.upArrow + ? Math.max(0, providerIndex - 1) + : Math.min(MODEL_PROVIDERS.length - 1, providerIndex + 1); + onChange({ ...value, provider: MODEL_PROVIDERS[nextIndex]!.provider }); + setError(null); + return; + } + if (key.return) setFocusedField(0); + return; + } + if (key.escape) { - onBack(); + setFocusedField(null); + setError(null); + return; + } + if (key.upArrow) { + setFocusedField(focusedField === 0 ? null : focusedField - 1); + setError(null); + return; + } + if (key.downArrow) { + setFocusedField(Math.min(fields.length - 1, focusedField + 1)); + setError(null); return; } if (key.return) { - if (value.trim() === "") { - setError("enter a model id"); + const field = fields[focusedField]!; + if (field.required && config[field.key].trim() === "") { + setError(field.requiredError); + return; + } + if (focusedField < fields.length - 1) { + setFocusedField(focusedField + 1); + return; + } + const missing = fields.findIndex( + (candidate) => candidate.required && config[candidate.key].trim() === "", + ); + if (missing >= 0) { + setFocusedField(missing); + setError(fields[missing]!.requiredError); return; } onNext(); } }); + const options: FormRadioOption[] = MODEL_PROVIDERS.map(({ label, description }) => ({ + label, + description, + })); + return ( - - { - onChange(next); - setError(null); - }} - /> - {error && {error}} + + + + {fields.map((field, fieldIndex) => ( + { + onChange({ + ...value, + configs: { + ...value.configs, + [value.provider]: { ...config, [field.key]: next }, + }, + }); + setError(null); + }} + focused={focusedField === fieldIndex} + /> + ))} + {error && ( + + {error} + + )} + ); } @@ -522,9 +769,11 @@ function ReviewStep({ > - - enter scaffolds the project, installs dependencies, and initializes git - + + + enter scaffolds the project, installs dependencies, and initializes git + + ); } @@ -549,13 +798,15 @@ function SuccessPanel({ name, onContinue }: { name: string; onContinue: () => vo }); return ( - + ✔ project created in ./{name} - next steps - {` cd ${name}`} - {" agentcore project deploy"} + + next steps + {` cd ${name}`} + {" agentcore project deploy"} + enter exits );