diff --git a/src/components/ConfirmAction.test.tsx b/src/components/ConfirmAction.test.tsx
new file mode 100644
index 000000000..85f3df7ae
--- /dev/null
+++ b/src/components/ConfirmAction.test.tsx
@@ -0,0 +1,36 @@
+import React from "react";
+import { afterEach, expect, test } from "bun:test";
+import { cleanup, render } from "ink-testing-library";
+import { MemoryRouter } from "react-router";
+import { waitForText } from "../testing";
+import { ConfirmAction, type ActionTrigger } from "./ConfirmAction";
+
+afterEach(cleanup);
+
+test("resolves the trigger after loading and does not skip a late confirmation", async () => {
+ let calls = 0;
+ const action = async () => {
+ calls += 1;
+ return { rows: {} };
+ };
+ const view = (isPending: boolean, trigger: ActionTrigger) => (
+
+ {}}
+ />
+
+ );
+
+ const screen = render(view(true, { kind: "immediate" }));
+ screen.rerender(view(false, { kind: "confirm", message: "Delete everything?" }));
+
+ await waitForText(screen.lastFrame, "Delete everything?");
+ expect(calls).toBe(0);
+});
diff --git a/src/components/ConfirmAction.tsx b/src/components/ConfirmAction.tsx
index cc5541842..a9407e85c 100644
--- a/src/components/ConfirmAction.tsx
+++ b/src/components/ConfirmAction.tsx
@@ -1,74 +1,127 @@
-import { useState } from "react";
+import { useEffect, useState } from "react";
import { Box, Text, useInput } from "ink";
import { useNavigate } from "react-router";
import { Layout } from "./Layout";
import { Spinner } from "./ui/spinner";
import { Confirm } from "./ui/confirm";
+import { TaskList, type Task } from "./ui/task-list";
+import { KeyValueTable } from "./KeyValueTable";
import { darkTheme } from "./ui/_core.js";
+import { driveProgress, type ProgressEvent } from "../tui/progress";
const theme = darkTheme;
-export interface SummaryRow {
- label: string;
- value: string;
+export type SummaryRows = Record;
+
+export interface ActionResult {
+ title?: string;
+ rows: SummaryRows;
}
+// ActionTrigger says what starts the action: a y/N question the user answers
+// (destructive actions default to No), or nothing — it runs as soon as the
+// summary has loaded, for an operation that is safe to start unasked.
+export type ActionTrigger = { kind: "confirm"; message: string } | { kind: "immediate" };
+
export interface ConfirmActionProps {
// breadcrumb labels the screen.
breadcrumb: string[];
+ // description is shown dimmed after the breadcrumb.
+ description?: string;
// title heads the summary overlay (usually the resource name).
- title: string;
- // rows describe the resource the action applies to.
- rows: SummaryRow[];
- // message is the yes/no question (destructive actions default to No).
- message: string;
+ title?: string;
+ // rows describe the resource the action applies to. With neither title nor
+ // rows the overlay is omitted.
+ rows?: SummaryRows;
+ trigger: ActionTrigger;
// isPending / error reflect the summary fetch backing the overlay.
isPending: boolean;
error: Error | null;
- // action performs the confirmed operation and resolves to result rows shown
- // on the success panel.
- action: () => Promise;
- // successTitle heads the success panel (e.g. "Harness deleted").
+ // action performs the confirmed operation and resolves to the result rows,
+ // 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;
+ // successTitle heads the success panel (e.g. "Harness deleted") unless the
+ // action's result carries its own.
successTitle: string;
- // runningLabel is the spinner label while the action runs.
+ // runningLabel is the spinner label while the action runs, until its first
+ // progress step arrives.
runningLabel: string;
- // onDone is called when the user acknowledges the success panel.
+ // nextSteps are commands suggested under the success panel.
+ nextSteps?: string[];
+ // onDone is called when the user acknowledges the success panel; doneLabel
+ // is the footer's word for it ("continue" by default).
onDone: () => void;
+ doneLabel?: string;
+ // onCancel runs when the confirmation is declined or esc is pressed; defaults
+ // to popping the router history.
+ onCancel?: () => void;
}
+// "waiting" snapshots the trigger once the summary is ready. A confirmation's
+// message then stays fixed through retries, even if a caller's props change.
type Phase =
- | { kind: "confirm" }
+ | { kind: "waiting" }
+ | { kind: "confirm"; message: string }
| { kind: "running" }
- | { kind: "success"; rows: SummaryRow[] }
- | { kind: "error"; message: string };
+ | { kind: "success"; title: string; rows: SummaryRows }
+ | { kind: "error"; message: string; retryMessage?: string };
// ConfirmAction is the shared destructive-action screen body: a summary overlay
// of the target resource, a y/N confirmation (defaulting to No), a spinner
// while the action runs, and a success/error panel. Cancel and esc pop back.
export function ConfirmAction({
breadcrumb,
+ description,
title,
- rows,
- message,
+ rows = {},
+ trigger,
isPending,
error,
action,
successTitle,
runningLabel,
+ nextSteps,
onDone,
+ doneLabel = "continue",
+ onCancel,
}: ConfirmActionProps) {
const navigate = useNavigate();
- const [phase, setPhase] = useState({ kind: "confirm" });
+ const cancel = onCancel ?? (() => navigate(-1));
+ const [phase, setPhase] = useState({ kind: "waiting" });
+ // tasks is the step list a progress-reporting action builds up; it stays on
+ // screen through success and error.
+ const [tasks, setTasks] = useState([]);
- const run = async () => {
+ const run = async (retryMessage?: string) => {
setPhase({ kind: "running" });
+ setTasks([]);
try {
- setPhase({ kind: "success", rows: await action() });
+ const result = action();
+ const outcome = isProgressGenerator(result)
+ ? await driveProgress(result, setTasks)
+ : await result;
+ setPhase({ kind: "success", title: outcome.title ?? successTitle, rows: outcome.rows });
} catch (err) {
- setPhase({ kind: "error", message: err instanceof Error ? err.message : String(err) });
+ setPhase({
+ kind: "error",
+ message: err instanceof Error ? err.message : String(err),
+ retryMessage,
+ });
}
};
+ // Resolve the latest trigger only after the summary is ready. This prevents a
+ // destructive action from inheriting an earlier immediate trigger while its
+ // data was still loading.
+ useEffect(() => {
+ if (phase.kind !== "waiting" || isPending || error) return;
+ if (trigger.kind === "confirm") setPhase({ kind: "confirm", message: trigger.message });
+ else void run();
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- the phase guard makes this run once
+ }, [phase.kind, isPending, error, trigger.kind]);
+
const hints =
phase.kind === "confirm"
? [
@@ -77,50 +130,70 @@ export function ConfirmAction({
{ key: "ctl+c", label: "quit" },
]
: phase.kind === "success"
- ? [{ key: "enter", label: "continue" }]
- : [
- { key: "esc", label: "back" },
- { key: "ctl+c", label: "quit" },
- ];
+ ? [{ key: "enter", label: doneLabel }]
+ : phase.kind === "error"
+ ? [
+ { key: "esc", label: "back" },
+ { key: "ctl+c", label: "quit" },
+ ]
+ : // Nothing listens for esc while the action runs (or is about to):
+ // an operation in flight is not abandoned by leaving the screen.
+ [{ key: "ctl+c", label: "quit" }];
return (
-
+
{isPending ? (
) : error ? (
- navigate(-1)} />
+
) : (
-
- {title}
- {rows.map((row) => (
-
- {row.label.padEnd(8)}
- {row.value}
-
- ))}
-
+ {(title !== undefined || Object.keys(rows).length > 0) && (
+
+ {title !== undefined && {title}}
+ {Object.keys(rows).length > 0 && }
+
+ )}
{phase.kind === "confirm" && (
navigate(-1)}
+ onConfirm={() => run(phase.message)}
+ onCancel={cancel}
/>
)}
- {phase.kind === "running" && }
+ {phase.kind !== "confirm" && tasks.length > 0 && (
+
+
+
+ )}
+ {phase.kind === "running" && tasks.length === 0 && }
{phase.kind === "success" && (
-
+
)}
{phase.kind === "error" && (
- setPhase({ kind: "confirm" })} />
+ // Without a question to return to, returning would run again.
+ setPhase({ kind: "confirm", message: phase.retryMessage! })
+ : cancel
+ }
+ />
)}
)}
@@ -128,14 +201,28 @@ 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: SummaryRow[];
+ rows: SummaryRows;
+ nextSteps?: string[];
onDone: () => void;
+ doneLabel: string;
}) {
useInput((_input, key) => {
if (key.return || key.escape) onDone();
@@ -146,17 +233,22 @@ function SuccessBody({
✔ {title}
-
- {rows.map((row) => (
-
- {row.label.padEnd(8)}
- {row.value}
-
- ))}
-
+ {Object.keys(rows).length > 0 && (
+
+
+
+ )}
+ {nextSteps !== undefined && nextSteps.length > 0 && (
+
+ next steps
+ {nextSteps.map((step) => (
+ {` ${step}`}
+ ))}
+
+ )}
- press enter to continue
+ press enter to {doneLabel}
diff --git a/src/components/Root.tsx b/src/components/Root.tsx
index b05b27bef..f20265659 100644
--- a/src/components/Root.tsx
+++ b/src/components/Root.tsx
@@ -108,6 +108,8 @@ import { GatewayRuleListScreen } from "../handlers/gateway/rule/list/screen.tsx"
import { GatewayRuleGetScreen } from "../handlers/gateway/rule/get/screen.tsx";
import { GatewayInvokeScreen } from "../handlers/gateway/invoke/screen.tsx";
import { ProjectScreen, ProjectCommandNotImplementedScreen } from "../handlers/project/screen.tsx";
+import { BuildProjectScreen } from "../handlers/project/build/screen.tsx";
+import { DeployProjectScreen } from "../handlers/project/deploy/screen.tsx";
import { ProjectCreateScreen } from "../handlers/project/create/screen.tsx";
import { ProjectInvokePickerScreen } from "../handlers/project/invoke/screen.tsx";
import { RootScreen, HelpScreen } from "../handlers/screen.tsx";
@@ -116,7 +118,7 @@ import type { Context } from "../router";
// PROJECT_COMMANDS are the `agentcore project` subcommands that are listed in
// the menu but have no screen of their own yet (`create` has the wizard). Each
// is routed explicitly so selecting it reports "not implemented" error
-const PROJECT_COMMANDS = ["add", "export", "remove", "dev", "deploy", "status", "build"] as const;
+const PROJECT_COMMANDS = ["add", "export", "remove", "dev", "status"] as const;
export interface RootProps {
// path is the command path to the executing node (e.g. "/agentcore").
@@ -749,6 +751,14 @@ export function Root({ path, ctx, core, queryClient }: RootProps) {
element={}
/>
} />
+ }
+ />
+ }
+ />
}
diff --git a/src/core/project/manager.tsx b/src/core/project/manager.tsx
index 82dbfe265..78e994399 100644
--- a/src/core/project/manager.tsx
+++ b/src/core/project/manager.tsx
@@ -873,7 +873,7 @@ export class FsProjectManager implements ProjectManager {
): AsyncGenerator {
const targetsPath = join(project.rootPath, "agentcore", "aws-targets.json");
const fileExists = existsSync(targetsPath);
- const targets = fileExists ? await this.json.read(targetsPath, AwsDeploymentTargetsSchema) : [];
+ const targets = await this.listTargets(project);
let target = targets.find((candidate) => candidate.name === input.target);
@@ -919,13 +919,17 @@ export class FsProjectManager implements ProjectManager {
// A read-only lookup, so callers (e.g. the deploy handler's up-front teardown
// confirmation) can name the target's account and region without triggering
// the default-target provisioning deploy performs.
+ public async listTargets(project: Project): Promise {
+ const targetsPath = join(project.rootPath, "agentcore", "aws-targets.json");
+ if (!existsSync(targetsPath)) return [];
+ return this.json.read(targetsPath, AwsDeploymentTargetsSchema);
+ }
+
public async resolveTarget(
project: Project,
input: ResolveTargetInput,
): Promise {
- const targetsPath = join(project.rootPath, "agentcore", "aws-targets.json");
- if (!existsSync(targetsPath)) return undefined;
- const targets = await this.json.read(targetsPath, AwsDeploymentTargetsSchema);
+ const targets = await this.listTargets(project);
return targets.find((candidate) => candidate.name === input.target);
}
diff --git a/src/handlers/harness/delete/screen.tsx b/src/handlers/harness/delete/screen.tsx
index 4bb46915f..9d74bd921 100644
--- a/src/handlers/harness/delete/screen.tsx
+++ b/src/handlers/harness/delete/screen.tsx
@@ -39,20 +39,25 @@ function DeleteConfirm({ ctx, core, harnessId }: ScreenProps & { harnessId: stri
{
const response = await core.harness.deleteHarness({ harnessId }, opts);
- return [
- { label: "id", value: response.harness?.harnessId ?? harnessId },
- { label: "status", value: response.harness?.status ?? "DELETING" },
- ];
+ return {
+ rows: {
+ id: response.harness?.harnessId ?? harnessId,
+ status: response.harness?.status ?? "DELETING",
+ },
+ };
}}
successTitle="Harness deletion started"
runningLabel="Deleting harness…"
diff --git a/src/handlers/harness/endpoint/delete/screen.tsx b/src/handlers/harness/endpoint/delete/screen.tsx
index 122ccf987..cabb5036b 100644
--- a/src/handlers/harness/endpoint/delete/screen.tsx
+++ b/src/handlers/harness/endpoint/delete/screen.tsx
@@ -57,12 +57,15 @@ function DeleteConfirm({
{
@@ -70,10 +73,12 @@ function DeleteConfirm({
{ harnessId, endpointName },
opts,
);
- return [
- { label: "name", value: response.endpoint?.endpointName ?? endpointName },
- { label: "status", value: response.endpoint?.status ?? "DELETING" },
- ];
+ return {
+ rows: {
+ name: response.endpoint?.endpointName ?? endpointName,
+ status: response.endpoint?.status ?? "DELETING",
+ },
+ };
}}
successTitle="Endpoint deletion started"
runningLabel="Deleting endpoint…"
diff --git a/src/handlers/project/ProjectGate.tsx b/src/handlers/project/ProjectGate.tsx
new file mode 100644
index 000000000..4be2cfc2a
--- /dev/null
+++ b/src/handlers/project/ProjectGate.tsx
@@ -0,0 +1,113 @@
+import React from "react";
+import { useQuery, type UseQueryResult } from "@tanstack/react-query";
+import { Box, Text, useInput } from "ink";
+import { Layout } from "../../components/Layout";
+import { Spinner } from "../../components/ui/spinner";
+import { darkTheme } from "../../components/ui/_core.js";
+import { ProjectStateError } from "../../errors/errors";
+import { projectNotFoundMessage } from "../../middleware/withProject";
+import type { Core } from "../types";
+import type { Project } from "./types";
+
+const theme = darkTheme;
+
+// 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
+// launching command was a project command, in which case pass it as `seed`.
+export function useProject(core: Core, seed?: Project): UseQueryResult {
+ const from = process.cwd();
+ return useQuery({
+ queryKey: ["project", from],
+ queryFn: async () => {
+ const project = await core.projectManager.resolve({ filePath: from });
+ if (!project) throw new ProjectStateError(projectNotFoundMessage(from));
+ return project;
+ },
+ gcTime: 0,
+ // A seeded project is authoritative — it is what the launching command ran
+ // against — so it is never refetched from the cwd.
+ ...(seed && { initialData: seed, staleTime: Infinity }),
+ });
+}
+
+export interface LoadingFrameProps {
+ breadcrumb: string[];
+ description?: string;
+ // query is whatever the screen is waiting on.
+ query: Pick;
+ loadingLabel: string;
+ onBack: () => void;
+}
+
+// LoadingFrame is the spinner-or-error a screen shows before its data arrives:
+// esc leaves, and on an error `r` tries again — the PaginatedTablePicker keys.
+export function LoadingFrame({
+ breadcrumb,
+ description,
+ query,
+ loadingLabel,
+ onBack,
+}: LoadingFrameProps) {
+ useInput((input, key) => {
+ if (key.escape) onBack();
+ if (query.isError && input === "r") void query.refetch();
+ });
+
+ return (
+
+
+ {query.isError ? (
+ ✗ {(query.error as Error).message}
+ ) : (
+
+ )}
+
+
+ );
+}
+
+export interface ProjectGateProps {
+ core: Core;
+ breadcrumb: string[];
+ description?: string;
+ // seed is the project already pinned on the launch context, when the command
+ // that opened the TUI was itself a project command.
+ seed?: Project;
+ onBack: () => void;
+ // children receives the resolved project and returns the screen. It must
+ // return an element rather than call hooks itself — the gate renders a
+ // spinner on the first paint, so a hook called here would change order.
+ children: (project: Project) => React.ReactElement;
+}
+
+// ProjectGate resolves the project before rendering a project screen, showing
+// the same not-found guidance the CLI prints when there is none.
+export function ProjectGate({
+ core,
+ breadcrumb,
+ description,
+ seed,
+ onBack,
+ children,
+}: ProjectGateProps) {
+ const project = useProject(core, seed);
+ if (project.data !== undefined) return children(project.data);
+ return (
+
+ );
+}
diff --git a/src/handlers/project/build/index.ts b/src/handlers/project/build/index.ts
index 8f2f0883c..455493d1d 100644
--- a/src/handlers/project/build/index.ts
+++ b/src/handlers/project/build/index.ts
@@ -4,13 +4,18 @@ import { JsonRendererKey } from "../../../tui";
import { runWithProgress } from "../../../tui/progress";
import { JsonKey } from "../../keys";
import { renderJsonError } from "../../utils";
-import type { ProjectManager } from "../types";
+import type { Project, ProjectManager } from "../types";
type BuildProjectHandlerConfig = {
projectManager: ProjectManager;
io: AppIO;
};
+/** The line both entry points print once a build finishes. */
+export function builtMessage(project: Project): string {
+ return `Built project '${project.name}'`;
+}
+
export const createBuildProjectHandler = (config: BuildProjectHandlerConfig) =>
createHandler({
name: "build",
@@ -35,7 +40,7 @@ export const createBuildProjectHandler = (config: BuildProjectHandlerConfig) =>
throw error;
}
- const message = `Built project '${project.name}'`;
+ const message = builtMessage(project);
config.io.stderr.write(`${message}\n`);
if (jsonOutput) ctx.require(JsonRendererKey).renderJson({ message });
},
diff --git a/src/handlers/project/build/screen.tsx b/src/handlers/project/build/screen.tsx
new file mode 100644
index 000000000..2257f0805
--- /dev/null
+++ b/src/handlers/project/build/screen.tsx
@@ -0,0 +1,53 @@
+import { useNavigate } from "react-router";
+import { ConfirmAction } from "../../../components/ConfirmAction";
+import { ProjectKey } from "../../../router";
+import type { ScreenProps } from "../../types";
+import { ProjectGate } from "../ProjectGate";
+import type { Project } from "../types";
+import { builtMessage } from "./index";
+
+const BREADCRUMB = ["agentcore", "project", "build"];
+const DESCRIPTION = "build the project's deployable artifacts";
+const PROJECT_MENU = "/agentcore/project";
+
+// BuildProjectScreen runs the same projectManager.build generator the command
+// runs; ConfirmAction renders its steps through the same TaskList.
+export function BuildProjectScreen({ ctx, core }: ScreenProps) {
+ const navigate = useNavigate();
+ return (
+ navigate(PROJECT_MENU)}
+ >
+ {(project) => }
+
+ );
+}
+
+function BuildConfirm({ project, core }: { project: Project; core: ScreenProps["core"] }) {
+ const navigate = useNavigate();
+
+ // No confirmation: a build changes nothing outside the project directory.
+ return (
+ navigate(PROJECT_MENU)}
+ doneLabel="go back"
+ onCancel={() => navigate(PROJECT_MENU)}
+ />
+ );
+}
diff --git a/src/handlers/project/buildDeploy.screen.test.tsx b/src/handlers/project/buildDeploy.screen.test.tsx
new file mode 100644
index 000000000..052b153e9
--- /dev/null
+++ b/src/handlers/project/buildDeploy.screen.test.tsx
@@ -0,0 +1,381 @@
+import { afterEach, describe, expect, test } from "bun:test";
+import { QueryClient } from "@tanstack/react-query";
+import { mkdtemp, rm, writeFile } from "node:fs/promises";
+import { join } from "node:path";
+import { tmpdir } from "node:os";
+import type { DeployBackendInput, ProjectBackend } from "../../core/project";
+import { createRootHandler } from "../index";
+import {
+ cleanupScreens,
+ createSilentLogger,
+ flatFrame,
+ renderScreen,
+ TestCoreClient,
+ TestGlobalConfigAccessor,
+ testIO,
+ waitForFlatText,
+ waitForText,
+} from "../../testing";
+import type { DeployResult, Project, ProjectEvent } from "./types";
+
+// The build and deploy screens run the same ProjectManager generators the
+// commands run, so these tests stub the backend exactly as the handler tests
+// do and assert the same steps come out — through the TaskList this time.
+
+const EVENTS: ProjectEvent[] = [
+ { type: "step", message: "Synthesizing CloudFormation templates" },
+ { type: "output", line: "cdk synth: 3 stacks" },
+ { type: "step", message: "Deploying stack" },
+ { type: "output", line: "CREATE_IN_PROGRESS | AWS::IAM::Role" },
+];
+
+type FakeBackendOptions = {
+ events?: ProjectEvent[];
+ failure?: Error;
+ result?: DeployResult;
+};
+
+function fakeBackend(options: FakeBackendOptions = {}) {
+ const deploys: { project: Project; input: DeployBackendInput; confirmed?: boolean }[] = [];
+ const backend: ProjectBackend = {
+ async *build() {
+ yield* options.events ?? EVENTS;
+ if (options.failure) throw options.failure;
+ },
+ async *deploy(project, input) {
+ const call: (typeof deploys)[number] = { project, input };
+ deploys.push(call);
+ call.confirmed = await input.confirmTeardown({
+ projectName: project.name,
+ targetName: input.target.name,
+ resourceDescription: "the stack",
+ account: input.target.account,
+ region: input.target.region,
+ });
+ yield* options.events ?? EVENTS;
+ if (options.failure) throw options.failure;
+ return options.result ?? { outputs: { RuntimeArn: "arn:runtime" } };
+ },
+ async resolveDeployedResources() {
+ return [];
+ },
+ };
+ return { backend, deploys };
+}
+
+const originalCwd = process.cwd();
+const tempDirectories: string[] = [];
+
+afterEach(cleanupScreens);
+afterEach(async () => {
+ process.chdir(originalCwd);
+ await Promise.all(
+ tempDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })),
+ );
+});
+
+/** Scaffolds project 'orders' with a default target and cds into it. */
+const STAGING = { name: "staging", account: "444455556666", region: "eu-west-1" } as const;
+
+async function inProject(
+ core: TestCoreClient,
+ options: { empty?: boolean; targets?: boolean; staging?: boolean } = {},
+): Promise {
+ const directory = await mkdtemp(join(tmpdir(), "agentcore-build-deploy-screen-"));
+ tempDirectories.push(directory);
+ process.chdir(directory);
+ const root = createRootHandler(core, {
+ io: testIO().io,
+ globalConfigAccessor: new TestGlobalConfigAccessor(),
+ logger: createSilentLogger(),
+ });
+ await root.route([
+ "node",
+ "agentcore",
+ "project",
+ "create",
+ "--name",
+ "orders",
+ "--skip-install",
+ "--skip-git",
+ ]);
+ const projectRoot = join(process.cwd(), "orders");
+ if (options.targets !== false) {
+ await writeFile(
+ join(projectRoot, "agentcore", "aws-targets.json"),
+ JSON.stringify([
+ { name: "default", account: "111122223333", region: "us-east-1" },
+ ...(options.staging ? [STAGING] : []),
+ ]),
+ );
+ }
+ if (options.empty) {
+ // What `remove --all` leaves: the up-front signal the deploy asks about.
+ await writeFile(
+ join(projectRoot, "agentcore", "agentcore.json"),
+ JSON.stringify({ name: "orders", version: 1 }),
+ );
+ }
+ process.chdir(projectRoot);
+ return projectRoot;
+}
+
+describe("project build screen", () => {
+ test("starts at once, renders the backend's steps as the CLI does, then the CLI's own success line", async () => {
+ const { backend } = fakeBackend();
+ const core = new TestCoreClient({ backends: { CDK: backend } });
+ await inProject(core);
+ const r = renderScreen("/agentcore/project/build", { core });
+
+ // Both steps settle to ✓, as the inline TaskList leaves them on the
+ // command line; the finished steps' output tails collapse.
+ await waitForText(r.lastFrame, "✔ Built project 'orders'");
+ const frame = r.lastFrame()!;
+ expect(frame).toContain("agentcore → project → build");
+ // No frame — not even the first — advertised a question.
+ expect(r.frames.some((painted) => painted.includes("(y/N)") || painted.includes("y/n"))).toBe(
+ false,
+ );
+ expect(frame).toContain("✓ Synthesizing CloudFormation templates");
+ expect(frame).toContain("✓ Deploying stack");
+ expect(frame).not.toContain("cdk synth");
+ expect(frame).toContain("agentcore project deploy");
+
+ // Enter stays in the TUI: back to the project menu.
+ await r.press("return");
+ await waitForText(r.lastFrame, "manage an AgentCore project");
+ r.unmount();
+ });
+
+ test("a failing step is marked ✕ with its output kept, above the error", async () => {
+ const { backend } = fakeBackend({ failure: new Error("synth exploded") });
+ const core = new TestCoreClient({ backends: { CDK: backend } });
+ await inProject(core);
+ const r = renderScreen("/agentcore/project/build", { core });
+
+ await waitForText(r.lastFrame, "✗ synth exploded");
+ const frame = r.lastFrame()!;
+ expect(frame).toContain("✓ Synthesizing CloudFormation templates");
+ expect(frame).toContain("✕ Deploying stack");
+ expect(frame).toContain("CREATE_IN_PROGRESS | AWS::IAM::Role");
+ // With no confirmation to return to, esc leaves for the project menu
+ // rather than running the build again.
+ await r.press("escape");
+ await waitForText(r.lastFrame, "manage an AgentCore project");
+ r.unmount();
+ });
+
+ test("reports the CLI's own guidance outside a project", async () => {
+ const directory = await mkdtemp(join(tmpdir(), "agentcore-no-project-"));
+ tempDirectories.push(directory);
+ process.chdir(directory);
+ const r = renderScreen("/agentcore/project/build");
+
+ await waitForFlatText(r.lastFrame, "No AgentCore project found");
+ expect(flatFrame(r.lastFrame)).toContain("agentcore project create");
+ // esc is a way off the error, not just ctl+c.
+ await r.press("escape");
+ await waitForText(r.lastFrame, "manage an AgentCore project");
+ r.unmount();
+ });
+});
+
+describe("project deploy screen", () => {
+ test("one target: deploys to it at once, then the CLI's own success line", async () => {
+ const { backend, deploys } = fakeBackend();
+ const core = new TestCoreClient({ backends: { CDK: backend } });
+ await inProject(core);
+ const r = renderScreen("/agentcore/project/deploy", { core });
+
+ // A project with resources is not asked anything, as on the command line.
+ await waitForText(r.lastFrame, "✔ Deployed project 'orders' to target 'default'");
+ const frame = flatFrame(r.lastFrame);
+ expect(frame).not.toContain("(y/N)");
+ expect(frame).toContain("project orders");
+ expect(frame).toContain("target default");
+ expect(frame).toContain("✓ Synthesizing CloudFormation templates");
+ expect(frame).toContain("✓ Deploying stack");
+ // Stack outputs are not listed, as the command prints them only with --json.
+ expect(frame).not.toContain("RuntimeArn");
+ expect(frame).toContain("[enter] go back");
+
+ // …and never confirms a teardown.
+ expect(deploys).toHaveLength(1);
+ expect(deploys[0]!.confirmed).toBe(false);
+ expect(deploys[0]!.input.target.name).toBe("default");
+ r.unmount();
+ });
+
+ test("a target-loading failure offers esc back and r to retry", async () => {
+ const { backend } = fakeBackend();
+ const core = new TestCoreClient({ backends: { CDK: backend } });
+ await inProject(core);
+ let attempts = 0;
+ const listTargets = core.projectManager.listTargets.bind(core.projectManager);
+ core.projectManager.listTargets = async (project) => {
+ attempts += 1;
+ if (attempts === 1) throw new Error("aws-targets.json is unreadable");
+ return listTargets(project);
+ };
+ const r = renderScreen("/agentcore/project/deploy", { core });
+
+ await waitForText(r.lastFrame, "✗ aws-targets.json is unreadable");
+ expect(r.lastFrame()).toContain("[r] retry");
+ expect(r.lastFrame()).toContain("[esc] back");
+
+ await r.write("r");
+ await waitForText(r.lastFrame, "✔ Deployed project 'orders' to target 'default'");
+ // Loading retries once, then deploy itself re-reads through listTargets.
+ expect(attempts).toBe(3);
+ r.unmount();
+ });
+
+ test("esc leaves a target-loading failure for the project menu", async () => {
+ const core = new TestCoreClient({ backends: { CDK: fakeBackend().backend } });
+ await inProject(core);
+ core.projectManager.listTargets = async () => {
+ throw new Error("aws-targets.json is unreadable");
+ };
+ const r = renderScreen("/agentcore/project/deploy", { core });
+
+ await waitForText(r.lastFrame, "✗ aws-targets.json is unreadable");
+ await r.press("escape");
+ await waitForText(r.lastFrame, "manage an AgentCore project");
+ r.unmount();
+ });
+
+ test("several targets: asks which, and deploys to the chosen one", async () => {
+ const { backend, deploys } = fakeBackend();
+ const core = new TestCoreClient({ backends: { CDK: backend } });
+ await inProject(core, { staging: true });
+ const r = renderScreen("/agentcore/project/deploy", { core });
+
+ await waitForText(r.lastFrame, "choose a deployment target");
+ const picker = flatFrame(r.lastFrame);
+ expect(picker).toContain("default 111122223333 us-east-1");
+ expect(picker).toContain("staging 444455556666 eu-west-1");
+ await r.press("down");
+ await r.press("return");
+
+ await waitForText(r.lastFrame, "✔ Deployed project 'orders' to target 'staging'");
+ expect(flatFrame(r.lastFrame)).toContain("target staging");
+ expect(deploys[0]!.input.target).toEqual(STAGING);
+ r.unmount();
+ });
+
+ test("revisiting reads targets afresh before starting another deploy", async () => {
+ const { backend, deploys } = fakeBackend();
+ const core = new TestCoreClient({ backends: { CDK: backend } });
+ const projectRoot = await inProject(core);
+ const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: { retry: false, gcTime: Infinity, staleTime: 0 },
+ },
+ });
+ const r = renderScreen("/agentcore/project/deploy", { core, queryClient });
+
+ await waitForText(r.lastFrame, "✔ Deployed project 'orders' to target 'default'");
+ expect(deploys).toHaveLength(1);
+ await r.press("return");
+ await waitForText(r.lastFrame, "manage an AgentCore project");
+
+ await writeFile(
+ join(projectRoot, "agentcore", "aws-targets.json"),
+ JSON.stringify([{ name: "default", account: "111122223333", region: "us-east-1" }, STAGING]),
+ );
+ await r.write("deploy");
+ await r.press("return");
+
+ await waitForText(r.lastFrame, "choose a deployment target");
+ expect(deploys).toHaveLength(1);
+ r.unmount();
+ });
+
+ test("revisiting resolves the project afresh before deciding whether to confirm teardown", async () => {
+ const { backend, deploys } = fakeBackend();
+ const core = new TestCoreClient({ backends: { CDK: backend } });
+ const projectRoot = await inProject(core);
+ const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: { retry: false, gcTime: Infinity, staleTime: 0 },
+ },
+ });
+ const r = renderScreen("/agentcore/project/deploy", { core, queryClient });
+
+ await waitForText(r.lastFrame, "✔ Deployed project 'orders' to target 'default'");
+ expect(deploys).toHaveLength(1);
+ await r.press("return");
+ await waitForText(r.lastFrame, "manage an AgentCore project");
+
+ await writeFile(
+ join(projectRoot, "agentcore", "agentcore.json"),
+ JSON.stringify({ name: "orders", version: 1 }),
+ );
+ await r.write("deploy");
+ await r.press("return");
+
+ await waitForFlatText(r.lastFrame, "declares no resources to deploy");
+ expect(deploys).toHaveLength(1);
+ r.unmount();
+ });
+
+ test("a fresh project with no aws-targets.json deploys, provisioning the default target as the CLI does", async () => {
+ const { backend, deploys } = fakeBackend();
+ const core = new TestCoreClient({
+ backends: { CDK: backend },
+ resolveAccount: async () => "887863153624",
+ });
+ await inProject(core, { targets: false });
+ const r = renderScreen("/agentcore/project/deploy", { core });
+
+ await waitForText(r.lastFrame, "✔ Deployed project 'orders' to target 'default'");
+ const frame = flatFrame(r.lastFrame);
+ // The manager's own provisioning step streams through like any other.
+ expect(frame).toContain("✓ Created default deployment target: account 887863153624");
+ expect(deploys[0]!.input.target).toMatchObject({ name: "default", account: "887863153624" });
+ r.unmount();
+ });
+
+ test("an empty project asks the CLI's teardown question and confirms it on yes", async () => {
+ const { backend, deploys } = fakeBackend({ result: { outputs: {}, tornDown: true } });
+ const core = new TestCoreClient({ backends: { CDK: backend } });
+ await inProject(core, { empty: true });
+ const r = renderScreen("/agentcore/project/deploy", { core });
+
+ await waitForFlatText(r.lastFrame, "declares no resources to deploy");
+ // Confirm lays its (y/N) inline, so the question wraps around it.
+ expect(flatFrame(r.lastFrame)).toContain(
+ "deployed to target 'default' (111122223333/us-east-1). Continue?",
+ );
+ await r.write("y");
+
+ await waitForText(r.lastFrame, "✔ Removed project 'orders' from target 'default'");
+ expect(deploys[0]!.confirmed).toBe(true);
+ r.unmount();
+ });
+
+ test("the outcome follows the result, not the preflight heuristic", async () => {
+ // The spec declares resources, so no teardown is asked — yet the backend
+ // reports it tore the stack down (nothing synthesized). The title must say
+ // what happened, as the command's own line does.
+ const { backend } = fakeBackend({ result: { outputs: {}, tornDown: true } });
+ const core = new TestCoreClient({ backends: { CDK: backend } });
+ await inProject(core);
+ const r = renderScreen("/agentcore/project/deploy", { core });
+
+ await waitForText(r.lastFrame, "✔ Removed project 'orders' from target 'default'");
+ expect(r.lastFrame()).not.toContain("Deployed project");
+ r.unmount();
+ });
+
+ test("a failing deploy keeps the completed steps above the error", async () => {
+ const { backend } = fakeBackend({ failure: new Error("stack rolled back") });
+ const core = new TestCoreClient({ backends: { CDK: backend } });
+ await inProject(core);
+ const r = renderScreen("/agentcore/project/deploy", { core });
+
+ await waitForText(r.lastFrame, "✗ stack rolled back");
+ expect(r.lastFrame()).toContain("✕ Deploying stack");
+ r.unmount();
+ });
+});
diff --git a/src/handlers/project/create/create.screen.test.tsx b/src/handlers/project/create/create.screen.test.tsx
index c56259bed..5d939f28a 100644
--- a/src/handlers/project/create/create.screen.test.tsx
+++ b/src/handlers/project/create/create.screen.test.tsx
@@ -468,7 +468,7 @@ describe("project create wizard", () => {
r.unmount();
});
- test("the spinner follows streamed progress without a blank row", async () => {
+ test("streamed progress renders as the CLI's step list", async () => {
const core = new TestCoreClient();
let release!: () => void;
const held = new Promise((resolve) => {
@@ -494,22 +494,11 @@ describe("project create wizard", () => {
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);
+ // The running step is the spinner row itself, as on the command line; the
+ // generic "creating…" spinner shows only until the first step arrives.
+ await waitForText(r.lastFrame, "syncing dependencies");
+ expect(r.lastFrame()).not.toContain("creating DemoApp…");
+ expect(r.lastFrame()).not.toContain("✓ syncing dependencies");
r.unmount();
release();
diff --git a/src/handlers/project/create/screen.tsx b/src/handlers/project/create/screen.tsx
index 35501dc3c..f3ff40bd7 100644
--- a/src/handlers/project/create/screen.tsx
+++ b/src/handlers/project/create/screen.tsx
@@ -18,7 +18,9 @@ import { FormRadioGroup, type FormRadioOption } from "../../../components/FormRa
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 } from "../../../components/ui/_core.js";
const theme = darkTheme;
@@ -244,7 +246,7 @@ export function ProjectCreateScreen({ core }: ScreenProps) {
const [values, setValues] = useState(emptyCreateProjectForm);
const [stepIndex, setStepIndex] = useState(0);
const [phase, setPhase] = useState({ kind: "form" });
- const [events, setEvents] = useState([]);
+ const [tasks, setTasks] = useState([]);
// The step list is dynamic: the branch chosen on the type step decides
// whether model or template (and, for strands, memory) questions follow.
@@ -288,9 +290,7 @@ export function ProjectCreateScreen({ core }: ScreenProps) {
}
setPhase({ kind: "running" });
try {
- for await (const event of core.projectManager.create(input)) {
- if (event.type === "step") setEvents((current) => [...current, event.message]);
- }
+ await driveProgress(core.projectManager.create(input), setTasks);
setPhase({ kind: "success" });
} catch (error) {
setPhase({ kind: "error", error: toError(error) });
@@ -322,8 +322,10 @@ export function ProjectCreateScreen({ core }: ScreenProps) {
)}
{phase.kind !== "form" && (
-
- {phase.kind === "running" && }
+
+ {phase.kind === "running" && tasks.length === 0 && (
+
+ )}
{phase.kind === "success" && (
exit()} />
)}
@@ -777,18 +779,6 @@ function ReviewStep({
// ─── result panels ────────────────────────────────────────────────────────────
-function EventLog({ events }: { events: string[] }) {
- return (
-
- {events.map((message, index) => (
-
- ✓ {message}
-
- ))}
-
- );
-}
-
function SuccessPanel({ name, onContinue }: { name: string; onContinue: () => void }) {
useInput((_input, key) => {
if (key.return || key.escape) onContinue();
diff --git a/src/handlers/project/deploy/index.ts b/src/handlers/project/deploy/index.ts
index 2e63fc08a..ab1af114b 100644
--- a/src/handlers/project/deploy/index.ts
+++ b/src/handlers/project/deploy/index.ts
@@ -15,6 +15,32 @@ type DeployProjectHandlerConfig = {
io: AppIO;
};
+/** The line both entry points print once a deploy finishes. */
+export function deployedMessage(
+ project: Project,
+ targetName: string,
+ result: DeployResult,
+): string {
+ return result.tornDown
+ ? `Removed project '${project.name}' from target '${targetName}'`
+ : `Deployed project '${project.name}' to target '${targetName}'`;
+}
+
+/**
+ * The teardown question both entry points ask. Asked before synthesis, so the
+ * target coordinates stand in for the stack name.
+ */
+export function teardownQuestion(
+ projectName: string,
+ target: { name: string; account: string; region: string },
+): string {
+ return (
+ `Project '${projectName}' declares no resources to deploy. ` +
+ `Deploying will delete everything deployed to target ` +
+ `'${target.name}' (${target.account}/${target.region}). Continue?`
+ );
+}
+
export const createDeployProjectHandler = (config: DeployProjectHandlerConfig) =>
createHandler({
name: "deploy",
@@ -72,9 +98,7 @@ export const createDeployProjectHandler = (config: DeployProjectHandlerConfig) =
throw error;
}
- const message = result.tornDown
- ? `Removed project '${project.name}' from target '${flags.target}'`
- : `Deployed project '${project.name}' to target '${flags.target}'`;
+ const message = deployedMessage(project, flags.target, result);
config.io.stderr.write(`${message}\n`);
if (jsonOutput) {
ctx.require(JsonRendererKey).renderJson({ message, ...result });
@@ -91,7 +115,7 @@ export const createDeployProjectHandler = (config: DeployProjectHandlerConfig) =
* CDK app adds one), so the backend's count stays authoritative and this only
* decides whether to ask the user before starting.
*/
-function declaresNothingDeployable(project: Project): boolean {
+export function declaresNothingDeployable(project: Project): boolean {
const { spec } = project;
const collections = [
spec.runtimes,
@@ -158,14 +182,8 @@ async function promptForTeardown(
readline.once("SIGINT", cancel);
readline.once("close", cancel);
});
- // Asked before synthesis, so the exact stack name is not known yet; the
- // target coordinates identify what would be deleted.
const answer = await Promise.race([
- readline.question(
- `Project '${projectName}' declares no resources to deploy.\n` +
- `Deploying will delete everything deployed to target ` +
- `'${target.name}' (${target.account}/${target.region}). Continue? (y/N) `,
- ),
+ readline.question(`${teardownQuestion(projectName, target)} (y/N) `),
cancelled,
]);
return /^(?:y|yes)$/i.test(answer.trim());
diff --git a/src/handlers/project/deploy/screen.tsx b/src/handlers/project/deploy/screen.tsx
new file mode 100644
index 000000000..8d3c55bcc
--- /dev/null
+++ b/src/handlers/project/deploy/screen.tsx
@@ -0,0 +1,177 @@
+import { useState } from "react";
+import { useQuery } from "@tanstack/react-query";
+import { useNavigate } from "react-router";
+import { ConfirmAction } from "../../../components/ConfirmAction";
+import { Layout } from "../../../components/Layout";
+import { DataTable, type DataTableColumn } from "../../../components/ui/data-table";
+import { DEFAULT_TARGET_NAME, type AwsDeploymentTarget } from "../../../projectSchemas/aws-targets";
+import { ProjectKey, type Context } from "../../../router";
+import { RegionKey } from "../../keys";
+import type { ScreenProps } from "../../types";
+import { LoadingFrame, ProjectGate } from "../ProjectGate";
+import type { Project } from "../types";
+import { declaresNothingDeployable, deployedMessage, teardownQuestion } from "./index";
+
+const BREADCRUMB = ["agentcore", "project", "deploy"];
+const DESCRIPTION = "deploy the project to AWS";
+const PROJECT_MENU = "/agentcore/project";
+
+// DeployProjectScreen runs the same projectManager.deploy generator the command
+// runs; ConfirmAction renders its steps through the same TaskList. With several
+// targets it asks which first — the TUI's stand-in for --target.
+export function DeployProjectScreen({ ctx, core }: ScreenProps) {
+ const navigate = useNavigate();
+ return (
+ navigate(PROJECT_MENU)}
+ >
+ {(project) => }
+
+ );
+}
+
+type TargetRow = Record & AwsDeploymentTarget;
+
+const TARGET_COLUMNS = [
+ { key: "name", header: "target", width: 16 },
+ { key: "account", header: "account", width: 14 },
+ { key: "region", header: "region", flex: true },
+] satisfies DataTableColumn[];
+
+function DeployTarget({
+ project,
+ ctx,
+ core,
+}: {
+ project: Project;
+ ctx: Context;
+ core: ScreenProps["core"];
+}) {
+ const navigate = useNavigate();
+ const [chosen, setChosen] = useState();
+
+ // A fresh project has no aws-targets.json yet: the list is empty and deploy
+ // provisions `default` on first run.
+ const targets = useQuery({
+ queryKey: ["project-targets", project.rootPath],
+ queryFn: () => core.projectManager.listTargets(project),
+ gcTime: 0,
+ });
+
+ if (targets.data === undefined || targets.isFetching || targets.isError) {
+ return (
+ navigate(PROJECT_MENU)}
+ />
+ );
+ }
+
+ const declared = targets.data;
+ const targetName =
+ chosen ?? (declared.length <= 1 ? (declared[0]?.name ?? DEFAULT_TARGET_NAME) : undefined);
+
+ if (targetName === undefined) {
+ return (
+
+ setChosen(row.name)}
+ onEscape={() => navigate(PROJECT_MENU)}
+ />
+
+ );
+ }
+
+ return (
+ candidate.name === targetName)}
+ // With a choice behind us, esc returns to it; otherwise to the menu.
+ onCancel={() => (declared.length > 1 ? setChosen(undefined) : navigate(PROJECT_MENU))}
+ />
+ );
+}
+
+function DeployConfirm({
+ project,
+ ctx,
+ core,
+ targetName,
+ target,
+ onCancel,
+}: {
+ project: Project;
+ ctx: Context;
+ core: ScreenProps["core"];
+ targetName: string;
+ target: AwsDeploymentTarget | undefined;
+ onCancel: () => void;
+}) {
+ const navigate = useNavigate();
+ const region = ctx.require(RegionKey);
+
+ // Confirmed only when the deploy would tear the stack down, the one case the
+ // command asks. Nothing may block on input once the progress UI is up, so the
+ // answer is the pre-answered decision the backend consults; if its own count
+ // disagrees with this preflight it reports the "re-run with --yes" error.
+ const teardown = target !== undefined && declaresNothingDeployable(project);
+
+ return (
+ teardown,
+ });
+ // The title follows the result, not the preflight heuristic, which
+ // synthesis can disagree with. Outputs are not listed: the command
+ // prints them only with --json.
+ return { title: deployedMessage(project, targetName, result), rows: {} };
+ }}
+ successTitle="Deploy finished"
+ runningLabel="deploying…"
+ onDone={() => navigate(PROJECT_MENU)}
+ doneLabel="go back"
+ onCancel={onCancel}
+ />
+ );
+}
diff --git a/src/handlers/project/invoke/screen.tsx b/src/handlers/project/invoke/screen.tsx
index 00ba1386a..07a509876 100644
--- a/src/handlers/project/invoke/screen.tsx
+++ b/src/handlers/project/invoke/screen.tsx
@@ -10,7 +10,8 @@ import { HarnessChat } from "../../harness/invoke/screen";
import { RegionKey } from "../../keys";
import { RuntimeInvokeConsole } from "../../runtime/invoke/screen";
import type { ScreenProps } from "../../types";
-import type { Project, ResolvedDeployedResources } from "../types";
+import type { ResolvedDeployedResources } from "../types";
+import { useProject } from "../ProjectGate";
type ProjectInvokableRow = Record & {
resourceType: "runtime" | "harness";
@@ -34,36 +35,13 @@ type Destination =
export function ProjectInvokePickerScreen({ ctx, core }: ScreenProps) {
const navigate = useNavigate();
- const [project, setProject] = useState(() => ctx.value(ProjectKey));
+ // The project comes from the launch context when a project command opened
+ // the TUI, and is resolved from the cwd otherwise.
+ const { data: project, error: projectError } = useProject(core, ctx.value(ProjectKey));
const [deployed, setDeployed] = useState();
const [destination, setDestination] = useState();
- const [error, setError] = useState();
-
- useEffect(() => {
- if (project) return;
- let active = true;
- const from = process.cwd();
- void core.projectManager
- .resolve({ filePath: from })
- .then((resolved) => {
- if (!active) return;
- if (!resolved) {
- setError(
- `No AgentCore project found at ${from} or any parent directory ` +
- `(looked for agentcore/agentcore.json). ` +
- `Run 'agentcore project create' to scaffold one.`,
- );
- return;
- }
- setProject(resolved);
- })
- .catch((cause: unknown) => {
- if (active) setError(cause instanceof Error ? cause.message : String(cause));
- });
- return () => {
- active = false;
- };
- }, [core.projectManager, project]);
+ const [deployedError, setDeployedError] = useState();
+ const error = projectError?.message ?? deployedError;
useEffect(() => {
if (!project) return;
@@ -74,7 +52,7 @@ export function ProjectInvokePickerScreen({ ctx, core }: ScreenProps) {
if (active) setDeployed(resolved);
})
.catch((cause: unknown) => {
- if (active) setError(cause instanceof Error ? cause.message : String(cause));
+ if (active) setDeployedError(cause instanceof Error ? cause.message : String(cause));
});
return () => {
active = false;
diff --git a/src/handlers/project/project.screen.test.tsx b/src/handlers/project/project.screen.test.tsx
index 846c6d3ea..03ad42de8 100644
--- a/src/handlers/project/project.screen.test.tsx
+++ b/src/handlers/project/project.screen.test.tsx
@@ -76,9 +76,10 @@ describe("project subcommands without a screen", () => {
// Reading the cases off the router also guards Root's hand-written
// PROJECT_COMMANDS: an unrouted subcommand hits the catch-all, which resolves
// instead of rejecting. Frames can't detect that — the catch-all exits before
- // painting, so it and this screen both render empty. `create` and `invoke`
- // are excluded because both have real screens.
- test.each(projectSubcommands().filter((command) => command !== "create" && command !== "invoke"))(
+ // painting, so it and this screen both render empty. Subcommands with a real
+ // screen are excluded.
+ const WITH_SCREENS = ["create", "invoke", "build", "deploy"];
+ test.each(projectSubcommands().filter((command) => !WITH_SCREENS.includes(command)))(
"%s tears down the TUI with NotImplementedError",
async (command) => {
const { streams } = ttyTestIO();
@@ -99,7 +100,7 @@ describe("project subcommands without a screen", () => {
const { streams } = ttyTestIO();
const caught: unknown = await renderTuiAt(
- "/agentcore/project/deploy",
+ "/agentcore/project/status",
ValueContext.EmptyContext(),
new TestCoreClient(),
streams.io,
@@ -110,7 +111,7 @@ describe("project subcommands without a screen", () => {
expect(caught).toBeInstanceOf(NotImplementedError);
const error = caught as NotImplementedError;
- expect(error.message).toContain("agentcore project deploy --help");
+ expect(error.message).toContain("agentcore project status --help");
// Surfaces as a plain CLI failure, not a crash.
expect(error.exitCode).toBe(1);
});
diff --git a/src/handlers/project/types.ts b/src/handlers/project/types.ts
index dc2dd279d..93b5a775e 100644
--- a/src/handlers/project/types.ts
+++ b/src/handlers/project/types.ts
@@ -349,6 +349,12 @@ export interface ProjectManager {
/** Deploy the project to one of its configured AWS targets. */
deploy(project: Project, input: DeployProjectInput): AsyncGenerator;
+ /**
+ * The targets aws-targets.json declares, in file order; empty when the file
+ * is absent (deploy then synthesizes the default target on demand).
+ */
+ listTargets(project: Project): Promise;
+
/**
* Look up a target in aws-targets.json without provisioning or requiring it.
* Returns undefined when the file or the named entry is absent — unlike
diff --git a/src/middleware/withProject.tsx b/src/middleware/withProject.tsx
index 95d1e1b6b..f2166151f 100644
--- a/src/middleware/withProject.tsx
+++ b/src/middleware/withProject.tsx
@@ -15,6 +15,15 @@ interface WithProjectConfig {
*
* @param config - Contains the {@link ProjectManager} and an optional `cwd` to search from.
*/
+/** The guidance printed when no project encloses `from`; TUI screens reuse it. */
+export function projectNotFoundMessage(from: string): string {
+ return (
+ `No AgentCore project found at ${from} or any parent directory ` +
+ `(looked for agentcore/agentcore.json). ` +
+ `Run 'agentcore project create' to scaffold one.`
+ );
+}
+
export function withProject(config: WithProjectConfig): Middleware {
return (h) => ({
name: () => h.name(),
@@ -29,11 +38,7 @@ export function withProject(config: WithProjectConfig): Middleware {
const from = config.cwd ?? process.cwd();
const project = await config.projectManager.resolve({ filePath: from });
if (!project) {
- throw new ProjectStateError(
- `No AgentCore project found at ${from} or any parent directory ` +
- `(looked for agentcore/agentcore.json). ` +
- `Run 'agentcore project create' to scaffold one.`,
- );
+ throw new ProjectStateError(projectNotFoundMessage(from));
}
await h.handle(ctx.withValue(ProjectKey, project), flags, args);
},
diff --git a/src/testing/index.tsx b/src/testing/index.tsx
index d06178516..65dbbab2f 100644
--- a/src/testing/index.tsx
+++ b/src/testing/index.tsx
@@ -25,6 +25,8 @@ export {
cleanupScreens,
keys,
waitForText,
+ flatFrame,
+ waitForFlatText,
type RenderScreenOptions,
type RenderScreenResult,
} from "./renderScreen";
diff --git a/src/testing/renderScreen.tsx b/src/testing/renderScreen.tsx
index 1cc7ae47a..84eee7d59 100644
--- a/src/testing/renderScreen.tsx
+++ b/src/testing/renderScreen.tsx
@@ -170,3 +170,19 @@ export function waitForText(
): Promise {
return waitFor(() => (lastFrame() ?? "").includes(text), timeoutMs);
}
+
+// flatFrame collapses a frame's whitespace so text that Ink lays out across
+// columns or lines (a key/value table, a wrapped sentence) can be matched as a
+// single string.
+export function flatFrame(lastFrame: () => string | undefined): string {
+ return (lastFrame() ?? "").replace(/\s+/g, " ");
+}
+
+// waitForFlatText is waitForText against the flattened frame.
+export function waitForFlatText(
+ lastFrame: () => string | undefined,
+ text: string,
+ timeoutMs = 1000,
+): Promise {
+ return waitFor(() => flatFrame(lastFrame).includes(text), timeoutMs);
+}
diff --git a/src/tui/progress.test.tsx b/src/tui/progress.test.tsx
index 0365b33a0..68e8b87d4 100644
--- a/src/tui/progress.test.tsx
+++ b/src/tui/progress.test.tsx
@@ -1,6 +1,12 @@
import { describe, expect, test } from "bun:test";
import { testIO } from "../testing";
-import { runWithProgress, type ProgressEvent } from "./progress";
+import {
+ applyProgressEvent,
+ driveProgress,
+ runWithProgress,
+ settleProgress,
+ type ProgressEvent,
+} from "./progress";
// Ink writes cursor/erase sequences around each frame; the assertions here
// care about frame text, not terminal control. Built without a control-char
@@ -135,3 +141,73 @@ describe("runWithProgress interactive path", () => {
expect(stripAnsi(io.stderr())).toContain("✓ Only step");
});
});
+
+describe("applyProgressEvent / settleProgress", () => {
+ test("a step completes the running task and starts the next", () => {
+ let tasks = applyProgressEvent([], { type: "step", message: "synth" });
+ expect(tasks).toEqual([{ title: "synth", state: "running", tail: [] }]);
+
+ tasks = applyProgressEvent(tasks, { type: "output", line: "one" });
+ tasks = applyProgressEvent(tasks, { type: "step", message: "deploy" });
+ expect(tasks).toEqual([
+ { title: "synth", state: "done", tail: [] },
+ { title: "deploy", state: "running", tail: [] },
+ ]);
+ });
+
+ test("output joins the running task's tail, bounded by tailLines", () => {
+ let tasks = applyProgressEvent([], { type: "step", message: "deploy" });
+ for (const line of ["a", "b", "c"]) {
+ tasks = applyProgressEvent(tasks, { type: "output", line }, 2);
+ }
+ expect(tasks[0]!.tail).toEqual(["b", "c"]);
+ });
+
+ test("output before any step is dropped", () => {
+ expect(applyProgressEvent([], { type: "output", line: "stray" })).toEqual([]);
+ });
+
+ test("settling keeps the tail on failure and clears it on success", () => {
+ let tasks = applyProgressEvent([], { type: "step", message: "deploy" });
+ tasks = applyProgressEvent(tasks, { type: "output", line: "boom" });
+
+ expect(settleProgress(tasks, "failed")).toEqual([
+ { title: "deploy", state: "failed", tail: ["boom"] },
+ ]);
+ expect(settleProgress(tasks, "done")).toEqual([{ title: "deploy", state: "done", tail: [] }]);
+ expect(settleProgress([], "done")).toEqual([]);
+ });
+});
+
+describe("driveProgress", () => {
+ test("reports the task list after each event, settles done, and resolves the return value", async () => {
+ async function* work() {
+ yield { type: "step", message: "one" } as ProgressEvent;
+ yield { type: "output", line: "detail" } as ProgressEvent;
+ return 42;
+ }
+ const frames: string[] = [];
+ const result = await driveProgress(work(), (tasks) =>
+ frames.push(
+ tasks.map((task) => `${task.state}:${task.title}:${task.tail.join(",")}`).join("|"),
+ ),
+ );
+ expect(result).toBe(42);
+ expect(frames).toEqual(["running:one:", "running:one:detail", "done:one:"]);
+ });
+
+ test("settles the running task failed and rethrows unchanged", async () => {
+ const failure = new Error("boom");
+ async function* work() {
+ yield { type: "step", message: "one" } as ProgressEvent;
+ throw failure;
+ }
+ let last: string | undefined;
+ await expect(
+ driveProgress(work(), (tasks) => {
+ last = tasks.map((task) => `${task.state}:${task.title}`).join("|");
+ }),
+ ).rejects.toBe(failure);
+ expect(last).toBe("failed:one");
+ });
+});
diff --git a/src/tui/progress.tsx b/src/tui/progress.tsx
index af1c327b1..6de58faf4 100644
--- a/src/tui/progress.tsx
+++ b/src/tui/progress.tsx
@@ -24,6 +24,68 @@ export type RunWithProgressOptions = {
const DEFAULT_TAIL_LINES = 5;
+/**
+ * Folds one progress event into a task list: a `step` completes the running
+ * task and starts a new one; an `output` line joins the running task's tail.
+ */
+export function applyProgressEvent(
+ tasks: readonly Task[],
+ event: ProgressEvent,
+ tailLines = DEFAULT_TAIL_LINES,
+): Task[] {
+ const current = tasks[tasks.length - 1];
+ if (event.type === "step") {
+ const settled = current
+ ? [...tasks.slice(0, -1), { ...current, state: "done" as const, tail: [] }]
+ : [];
+ return [...settled, { title: event.message, state: "running", tail: [] }];
+ }
+ // An output line before the first step has nowhere to render; the debug log
+ // still has it.
+ if (!current) return [...tasks];
+ return [
+ ...tasks.slice(0, -1),
+ { ...current, tail: [...current.tail, event.line].slice(-tailLines) },
+ ];
+}
+
+/**
+ * Marks the running task finished: `done` when the generator returned (its
+ * tail collapses), `failed` when it threw (the tail stays, so the last output
+ * is visible above the error).
+ */
+export function settleProgress(tasks: readonly Task[], state: "done" | "failed"): Task[] {
+ const current = tasks[tasks.length - 1];
+ if (!current) return [...tasks];
+ return [...tasks.slice(0, -1), { ...current, state, tail: state === "done" ? [] : current.tail }];
+}
+
+/**
+ * Drains a progress generator, reporting the task list after every change, and
+ * resolves with its return value. On failure the running task is marked failed
+ * and the error rethrown unchanged. Renderers supply only how to draw the tasks.
+ */
+export async function driveProgress(
+ generator: AsyncGenerator,
+ onChange: (tasks: Task[]) => void,
+ tailLines = DEFAULT_TAIL_LINES,
+): Promise {
+ let tasks: Task[] = [];
+ try {
+ let next = await generator.next();
+ while (!next.done) {
+ tasks = applyProgressEvent(tasks, next.value, tailLines);
+ onChange(tasks);
+ next = await generator.next();
+ }
+ onChange(settleProgress(tasks, "done"));
+ return next.value;
+ } catch (error) {
+ onChange(settleProgress(tasks, "failed"));
+ throw error;
+ }
+}
+
/**
* Drains a progress generator into a live step list and resolves with the
* generator's return value.
@@ -57,7 +119,6 @@ export async function runWithProgress(
}
const tailLines = options.tailLines ?? DEFAULT_TAIL_LINES;
- const tasks: Task[] = [];
// Ink renders onto its `stdout` option; handing it io.stderr keeps progress
// off the machine-readable stream, same as the plain path.
const instance = render(, {
@@ -70,43 +131,15 @@ export async function runWithProgress(
exitOnCtrlC: false,
patchConsole: false,
});
- const draw = () => instance.rerender();
- const current = () => tasks[tasks.length - 1];
+ // A failed step keeps its tail: the last frame stays in scrollback above the
+ // error runWithExitCode prints after the rethrow.
try {
- let next = await generator.next();
- while (!next.done) {
- const event = next.value;
- if (event.type === "step") {
- const previous = current();
- if (previous) {
- previous.state = "done";
- previous.tail = [];
- }
- tasks.push({ title: event.message, state: "running", tail: [] });
- } else {
- // An output line before the first step has nowhere to render; the
- // debug log still has it.
- const task = current();
- if (task) task.tail = [...task.tail, event.line].slice(-tailLines);
- }
- draw();
- next = await generator.next();
- }
- const last = current();
- if (last) {
- last.state = "done";
- last.tail = [];
- }
- draw();
- return next.value;
- } catch (error) {
- // The failed step keeps its tail: the last frame stays in scrollback above
- // the error message runWithExitCode prints after the rethrow.
- const task = current();
- if (task) task.state = "failed";
- draw();
- throw error;
+ return await driveProgress(
+ generator,
+ (tasks) => instance.rerender(),
+ tailLines,
+ );
} finally {
instance.unmount();
await instance.waitUntilExit();