From 441c8fe8fdbec0ac55d87a8b18b3374c9b45cabc Mon Sep 17 00:00:00 2001 From: gitikavj Date: Wed, 2 Sep 2026 21:27:16 +0000 Subject: [PATCH 1/6] feat(project): build and deploy screens that render the CLI's own progress steps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `project build` and `project deploy` open from the TUI menu instead of reporting "not implemented". Each screen runs the very generator the command runs — projectManager.build / projectManager.deploy — and renders its step events through the same TaskList runWithProgress renders inline on the command line, so the two paths show identical steps, glyphs and output tails. Nothing about progress is re-implemented: the event→task fold is extracted from runWithProgress into applyProgressEvent / settleProgress and both renderers call it. The screens are ConfirmAction instances — the existing confirm→run→ success|error body behind `harness delete` — which learns to accept a progress generator as its action and shows the TaskList while it streams. Its summary/success rows now align on the longest label rather than a fixed 8 columns. The command's other behaviours carry over from the same source: builtMessage / deployedMessage / teardownQuestion are exported from the handlers and shown on the screens; declaresNothingDeployable decides whether the confirmation is the teardown question, and confirming it is the pre-answered decision the backend consults, as with --yes. useProject / ProjectGate resolve the enclosing project for a screen the user navigated to, using withProject's now-exported not-found message; ProjectInvokePickerScreen drops its inline copy of both. --- src/components/ConfirmAction.tsx | 102 ++++++-- src/components/Root.tsx | 12 +- src/handlers/project/ProjectGate.tsx | 87 +++++++ src/handlers/project/build/index.ts | 9 +- src/handlers/project/build/screen.tsx | 57 +++++ .../project/buildDeploy.screen.test.tsx | 230 ++++++++++++++++++ src/handlers/project/deploy/index.ts | 41 +++- src/handlers/project/deploy/screen.tsx | 100 ++++++++ src/handlers/project/invoke/screen.tsx | 38 +-- src/handlers/project/project.screen.test.tsx | 11 +- src/middleware/withProject.tsx | 18 +- src/testing/index.tsx | 2 + src/testing/renderScreen.tsx | 16 ++ src/tui/progress.test.tsx | 44 +++- src/tui/progress.tsx | 67 +++-- 15 files changed, 730 insertions(+), 104 deletions(-) create mode 100644 src/handlers/project/ProjectGate.tsx create mode 100644 src/handlers/project/build/screen.tsx create mode 100644 src/handlers/project/buildDeploy.screen.test.tsx create mode 100644 src/handlers/project/deploy/screen.tsx diff --git a/src/components/ConfirmAction.tsx b/src/components/ConfirmAction.tsx index cc5541842..835fdf394 100644 --- a/src/components/ConfirmAction.tsx +++ b/src/components/ConfirmAction.tsx @@ -4,7 +4,9 @@ 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 { darkTheme } from "./ui/_core.js"; +import { applyProgressEvent, settleProgress, type ProgressEvent } from "../tui/progress"; const theme = darkTheme; @@ -16,6 +18,9 @@ export interface SummaryRow { export interface ConfirmActionProps { // breadcrumb labels the screen. breadcrumb: string[]; + // description is shown dimmed after the breadcrumb, e.g. the command's own + // description so the header matches `--help`. + description?: string; // title heads the summary overlay (usually the resource name). title: string; // rows describe the resource the action applies to. @@ -26,14 +31,21 @@ export interface ConfirmActionProps { isPending: boolean; error: Error | null; // action performs the confirmed operation and resolves to result rows shown - // on the success panel. - action: () => Promise; + // on the success panel. A long-running operation may instead return a + // progress generator — the same AsyncGenerator runWithProgress + // drives for the headless command — and its steps render as a live task list + // while it runs, exactly as they do on the command line. + action: () => Promise | AsyncGenerator; // successTitle heads the success panel (e.g. "Harness deleted"). successTitle: string; - // runningLabel is the spinner label while the action runs. + // runningLabel is the spinner label while the action runs, shown until the + // action's first progress step arrives (or throughout, for a plain promise). runningLabel: string; // onDone is called when the user acknowledges the success panel. onDone: () => void; + // onCancel runs when the confirmation is declined or esc is pressed; defaults + // to popping the router history, which suits a screen reached from a picker. + onCancel?: () => void; } type Phase = @@ -47,6 +59,7 @@ type Phase = // while the action runs, and a success/error panel. Cancel and esc pop back. export function ConfirmAction({ breadcrumb, + description, title, rows, message, @@ -56,15 +69,37 @@ export function ConfirmAction({ successTitle, runningLabel, onDone, + onCancel, }: ConfirmActionProps) { const navigate = useNavigate(); + const cancel = onCancel ?? (() => navigate(-1)); const [phase, setPhase] = useState({ kind: "confirm" }); + // tasks is the step list a progress-reporting action builds up. It stays on + // screen through success and error, as the headless command leaves its + // completed steps in scrollback above the final line. + const [tasks, setTasks] = useState([]); const run = async () => { setPhase({ kind: "running" }); + setTasks([]); try { - setPhase({ kind: "success", rows: await action() }); + const result = action(); + let rows: SummaryRow[]; + if (isProgressGenerator(result)) { + let next = await result.next(); + while (!next.done) { + const event = next.value; + setTasks((current) => applyProgressEvent(current, event)); + next = await result.next(); + } + rows = next.value; + } else { + rows = await result; + } + setTasks((current) => settleProgress(current, "done")); + setPhase({ kind: "success", rows }); } catch (err) { + setTasks((current) => settleProgress(current, "failed")); setPhase({ kind: "error", message: err instanceof Error ? err.message : String(err) }); } }; @@ -84,11 +119,11 @@ export function ConfirmAction({ ]; return ( - + {isPending ? ( ) : error ? ( - navigate(-1)} /> + ) : ( {title} - {rows.map((row) => ( - - {row.label.padEnd(8)} - {row.value} - - ))} + {phase.kind === "confirm" && ( - navigate(-1)} - /> + )} - {phase.kind === "running" && } + {phase.kind !== "confirm" && tasks.length > 0 && ( + + + + )} + {phase.kind === "running" && tasks.length === 0 && } {phase.kind === "success" && ( )} @@ -128,6 +158,33 @@ 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" + ); +} + +// SummaryRows aligns values on a column one past the longest label, so a +// label longer than the old fixed width (a stack output name, say) still has a +// gap before its value. +function SummaryRows({ rows }: { rows: SummaryRow[] }) { + const width = rows.reduce((max, row) => Math.max(max, row.label.length), 0) + 2; + return ( + <> + {rows.map((row) => ( + + {row.label.padEnd(width)} + {row.value} + + ))} + + ); +} + function SuccessBody({ title, rows, @@ -147,12 +204,7 @@ function SuccessBody({ ✔ {title} - {rows.map((row) => ( - - {row.label.padEnd(8)} - {row.value} - - ))} + 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/handlers/project/ProjectGate.tsx b/src/handlers/project/ProjectGate.tsx new file mode 100644 index 000000000..94590914e --- /dev/null +++ b/src/handlers/project/ProjectGate.tsx @@ -0,0 +1,87 @@ +import React, { useEffect, useState } from "react"; +import { Box, Text } from "ink"; +import { Layout } from "../../components/Layout"; +import { Spinner } from "../../components/ui/spinner"; +import { darkTheme } from "../../components/ui/_core.js"; +import { projectNotFoundMessage } from "../../middleware/withProject"; +import type { Core } from "../types"; +import type { Project } from "./types"; + +const theme = darkTheme; + +export interface UseProjectResult { + project?: Project; + error?: string; +} + +// useProject resolves the project enclosing the working directory for a TUI +// screen. Screens need their own resolution because withProject wraps `handle` +// only: middleware runs when a command executes, and navigating between TUI +// screens never executes one, so ProjectKey is set only when the launching +// command happened to be a project command. When it was, pass it as `seed` and +// no resolution happens. The not-found guidance is withProject's own. +export function useProject(core: Core, seed?: Project): UseProjectResult { + const [project, setProject] = useState(seed); + const [error, setError] = useState(); + + useEffect(() => { + if (project !== undefined) return; + let active = true; + const from = process.cwd(); + void core.projectManager + .resolve({ filePath: from }) + .then((resolved) => { + if (!active) return; + if (!resolved) { + setError(projectNotFoundMessage(from)); + return; + } + setProject(resolved); + }) + .catch((cause: unknown) => { + if (active) setError(cause instanceof Error ? cause.message : String(cause)); + }); + return () => { + active = false; + }; + }, [core.projectManager, project]); + + return { project, error }; +} + +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; + // 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, children }: ProjectGateProps) { + const { project, error } = useProject(core, seed); + + if (project !== undefined) return children(project); + + return ( + + + {error === undefined ? ( + + ) : ( + ✗ {error} + )} + + + ); +} 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..77bb26a4f --- /dev/null +++ b/src/handlers/project/build/screen.tsx @@ -0,0 +1,57 @@ +import { useNavigate } from "react-router"; +import { useApp } from "ink"; +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"; + +// BuildProjectScreen is `agentcore project build` from the menu. It runs the +// same projectManager.build generator the command runs, and ConfirmAction +// renders its steps through the same TaskList runWithProgress renders on the +// command line — the TUI is a frame around the CLI's own progress, not a +// second progress UI. +export function BuildProjectScreen({ ctx, core }: ScreenProps) { + return ( + + {(project) => } + + ); +} + +function BuildConfirm({ project, core }: { project: Project; core: ScreenProps["core"] }) { + const navigate = useNavigate(); + const { exit } = useApp(); + + return ( + exit()} + onCancel={() => navigate("/agentcore/project")} + /> + ); +} diff --git a/src/handlers/project/buildDeploy.screen.test.tsx b/src/handlers/project/buildDeploy.screen.test.tsx new file mode 100644 index 000000000..1177b63af --- /dev/null +++ b/src/handlers/project/buildDeploy.screen.test.tsx @@ -0,0 +1,230 @@ +import { afterEach, describe, expect, test } from "bun:test"; +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. */ +async function inProject(core: TestCoreClient, options: { empty?: 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"); + await writeFile( + join(projectRoot, "agentcore", "aws-targets.json"), + JSON.stringify([{ name: "default", account: "111122223333", region: "us-east-1" }]), + ); + 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("confirms, then 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 }); + + await waitForText(r.lastFrame, "Build project 'orders'?"); + expect(r.lastFrame()).toContain("agentcore → project → build"); + await r.write("y"); + + // 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("✓ Synthesizing CloudFormation templates"); + expect(frame).toContain("✓ Deploying stack"); + expect(frame).not.toContain("cdk synth"); + 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, "Build project 'orders'?"); + await r.write("y"); + + 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"); + r.unmount(); + }); + + test("declining returns to the project menu", async () => { + const core = new TestCoreClient({ backends: { CDK: fakeBackend().backend } }); + await inProject(core); + const r = renderScreen("/agentcore/project/build", { core }); + + await waitForText(r.lastFrame, "Build project 'orders'?"); + await r.write("n"); + 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"); + r.unmount(); + }); +}); + +describe("project deploy screen", () => { + test("shows the target, then the backend's steps, then the CLI's own success line and outputs", async () => { + const { backend, deploys } = fakeBackend(); + const core = new TestCoreClient({ backends: { CDK: backend } }); + await inProject(core); + const r = renderScreen("/agentcore/project/deploy", { core }); + + await waitForText(r.lastFrame, "Deploy project 'orders' to target 'default'?"); + expect(flatFrame(r.lastFrame)).toContain("account 111122223333/us-east-1"); + await r.write("y"); + + await waitForText(r.lastFrame, "✔ Project deployed"); + const frame = flatFrame(r.lastFrame); + expect(frame).toContain("✓ Synthesizing CloudFormation templates"); + expect(frame).toContain("✓ Deploying stack"); + expect(frame).toContain("Deployed project 'orders' to target 'default'"); + expect(frame).toContain("RuntimeArn arn:runtime"); + + // A project with resources never confirms a teardown, as on the command line. + expect(deploys).toHaveLength(1); + expect(deploys[0]!.confirmed).toBe(false); + expect(deploys[0]!.input.target.name).toBe("default"); + 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, "✔ Project removed"); + expect(flatFrame(r.lastFrame)).toContain("Removed project 'orders' from target 'default'"); + expect(deploys[0]!.confirmed).toBe(true); + 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, "Deploy project 'orders' to target 'default'?"); + await r.write("y"); + + await waitForText(r.lastFrame, "✗ stack rolled back"); + expect(r.lastFrame()).toContain("✕ Deploying stack"); + r.unmount(); + }); +}); diff --git a/src/handlers/project/deploy/index.ts b/src/handlers/project/deploy/index.ts index 2e63fc08a..3f2e770e6 100644 --- a/src/handlers/project/deploy/index.ts +++ b/src/handlers/project/deploy/index.ts @@ -15,6 +15,33 @@ 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 + * exact stack name is not known yet; the target coordinates identify what would + * be deleted. + */ +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 +99,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 +116,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 +183,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..c9c3885ac --- /dev/null +++ b/src/handlers/project/deploy/screen.tsx @@ -0,0 +1,100 @@ +import { useQuery } from "@tanstack/react-query"; +import { useNavigate } from "react-router"; +import { useApp } from "ink"; +import { ConfirmAction, type SummaryRow } from "../../../components/ConfirmAction"; +import { DEFAULT_TARGET_NAME } from "../../../projectSchemas/aws-targets"; +import { ProjectKey, type Context } from "../../../router"; +import { RegionKey } from "../../keys"; +import type { ScreenProps } from "../../types"; +import { 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"; + +// DeployProjectScreen is `agentcore project deploy` from the menu. It runs the +// same projectManager.deploy generator the command runs, and ConfirmAction +// renders its steps through the same TaskList runWithProgress renders on the +// command line. The teardown question the command asks over readline is asked +// here as the confirmation itself. +export function DeployProjectScreen({ ctx, core }: ScreenProps) { + return ( + + {(project) => } + + ); +} + +function DeployConfirm({ + project, + ctx, + core, +}: { + project: Project; + ctx: Context; + core: ScreenProps["core"]; +}) { + const navigate = useNavigate(); + const { exit } = useApp(); + const region = ctx.require(RegionKey); + const targetName = DEFAULT_TARGET_NAME; + + // The target is resolved up front, as the command does, because the teardown + // question is only asked when there is a target whose stack could be removed. + const target = useQuery({ + queryKey: ["project-target", project.rootPath, targetName], + queryFn: () => core.projectManager.resolveTarget(project, { target: targetName }), + }); + + // Once the progress UI is up nothing may block on input, so the teardown + // decision is settled by the confirmation the user is about to answer: when + // the project declares nothing deployable, confirming *is* confirming the + // teardown. Otherwise the backend's own zero-resource check reports the + // "re-run with --yes" error, as it does for a non-interactive deploy. + const teardown = target.data !== undefined && declaresNothingDeployable(project); + const message = teardown + ? teardownQuestion(project.name, target.data!) + : `Deploy project '${project.name}' to target '${targetName}'?`; + + return ( + teardown, + }); + const rows: SummaryRow[] = [ + { label: "result", value: deployedMessage(project, targetName, result) }, + ]; + for (const [key, value] of Object.entries(result.outputs)) rows.push({ label: key, value }); + return rows; + }} + successTitle={teardown ? "Project removed" : "Project deployed"} + runningLabel="deploying…" + onDone={() => exit()} + onCancel={() => navigate("/agentcore/project")} + /> + ); +} diff --git a/src/handlers/project/invoke/screen.tsx b/src/handlers/project/invoke/screen.tsx index 00ba1386a..f3c13e4d0 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 { 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 ?? 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/middleware/withProject.tsx b/src/middleware/withProject.tsx index 95d1e1b6b..5b70f7478 100644 --- a/src/middleware/withProject.tsx +++ b/src/middleware/withProject.tsx @@ -15,6 +15,18 @@ interface WithProjectConfig { * * @param config - Contains the {@link ProjectManager} and an optional `cwd` to search from. */ +/** + * The guidance printed when no project encloses `from`. Exported so TUI screens + * that resolve the project themselves (see useProject) say the same thing. + */ +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 +41,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..a12a6fe5c 100644 --- a/src/tui/progress.test.tsx +++ b/src/tui/progress.test.tsx @@ -1,6 +1,11 @@ import { describe, expect, test } from "bun:test"; import { testIO } from "../testing"; -import { runWithProgress, type ProgressEvent } from "./progress"; +import { + applyProgressEvent, + 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 +140,40 @@ 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([]); + }); +}); diff --git a/src/tui/progress.tsx b/src/tui/progress.tsx index af1c327b1..c1ca066e8 100644 --- a/src/tui/progress.tsx +++ b/src/tui/progress.tsx @@ -24,6 +24,44 @@ 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. + * Pure, so every renderer of progress — the inline TaskList below, a TUI + * screen's running phase — reads events the same way and shows the same steps. + */ +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 into a live step list and resolves with the * generator's return value. @@ -57,7 +95,7 @@ export async function runWithProgress( } const tailLines = options.tailLines ?? DEFAULT_TAIL_LINES; - const tasks: Task[] = []; + let 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,41 +108,22 @@ export async function runWithProgress( exitOnCtrlC: false, patchConsole: false, }); - const draw = () => instance.rerender(); - const current = () => tasks[tasks.length - 1]; + const draw = () => instance.rerender(); 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); - } + tasks = applyProgressEvent(tasks, next.value, tailLines); draw(); next = await generator.next(); } - const last = current(); - if (last) { - last.state = "done"; - last.tail = []; - } + tasks = settleProgress(tasks, "done"); 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"; + tasks = settleProgress(tasks, "failed"); draw(); throw error; } finally { From 61c7175285bd89a0159b4b2efe9ee6bd838ad96b Mon Sep 17 00:00:00 2001 From: gitikavj Date: Wed, 2 Sep 2026 21:54:14 +0000 Subject: [PATCH 2/6] fix(project): derive the deploy outcome from the result; centralize progress driving MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-ups on the build/deploy screens: - The deploy success title came from the preflight declaresNothingDeployable heuristic, which the backend's post-synth count can disagree with, so the screen could say "Project removed" over "Deployed project…". The action now returns its own title from result.tornDown via deployedMessage, the same line the command prints; ConfirmAction's action may return { title, rows } for outcomes only known after running. - ProjectGate's resolution error offered no way off but ctl+c. It takes an onBack and handles esc, advertised in the footer. - ConfirmAction's rows are rendered by KeyValueTable instead of a second longest-label renderer. - The generator drain lives once, in driveProgress; runWithProgress and ConfirmAction each pass only how to draw the tasks. - The running phase no longer advertises esc, since nothing listens for it mid-action. --- src/components/ConfirmAction.tsx | 89 +++++++++---------- src/handlers/project/ProjectGate.tsx | 46 ++++++++-- src/handlers/project/build/screen.tsx | 7 +- .../project/buildDeploy.screen.test.tsx | 28 +++++- src/handlers/project/deploy/screen.tsx | 20 +++-- src/tui/progress.test.tsx | 34 +++++++ src/tui/progress.tsx | 52 +++++++---- 7 files changed, 189 insertions(+), 87 deletions(-) diff --git a/src/components/ConfirmAction.tsx b/src/components/ConfirmAction.tsx index 835fdf394..3b46b2329 100644 --- a/src/components/ConfirmAction.tsx +++ b/src/components/ConfirmAction.tsx @@ -5,8 +5,9 @@ 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 { applyProgressEvent, settleProgress, type ProgressEvent } from "../tui/progress"; +import { driveProgress, type ProgressEvent } from "../tui/progress"; const theme = darkTheme; @@ -15,6 +16,8 @@ export interface SummaryRow { value: string; } +export type ActionResult = SummaryRow[] | { title: string; rows: SummaryRow[] }; + export interface ConfirmActionProps { // breadcrumb labels the screen. breadcrumb: string[]; @@ -30,13 +33,16 @@ export interface ConfirmActionProps { // 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. A long-running operation may instead return a - // progress generator — the same AsyncGenerator runWithProgress - // drives for the headless command — and its steps render as a live task list - // while it runs, exactly as they do on the command line. - action: () => Promise | AsyncGenerator; - // successTitle heads the success panel (e.g. "Harness deleted"). + // action performs the confirmed operation and resolves to what the success + // panel shows: result rows, optionally under a title that replaces + // successTitle — for an outcome only known once the action has run. A + // long-running operation may instead return a progress generator — the same + // AsyncGenerator runWithProgress drives for the headless + // command — and its steps render as a live task list while it runs, exactly + // as they do on the command line. + 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, shown until the // action's first progress step arrives (or throughout, for a plain promise). @@ -51,7 +57,7 @@ export interface ConfirmActionProps { type Phase = | { kind: "confirm" } | { kind: "running" } - | { kind: "success"; rows: SummaryRow[] } + | { kind: "success"; title: string; rows: SummaryRow[] } | { kind: "error"; message: string }; // ConfirmAction is the shared destructive-action screen body: a summary overlay @@ -84,22 +90,14 @@ export function ConfirmAction({ setTasks([]); try { const result = action(); - let rows: SummaryRow[]; - if (isProgressGenerator(result)) { - let next = await result.next(); - while (!next.done) { - const event = next.value; - setTasks((current) => applyProgressEvent(current, event)); - next = await result.next(); - } - rows = next.value; - } else { - rows = await result; - } - setTasks((current) => settleProgress(current, "done")); - setPhase({ kind: "success", rows }); + const outcome = isProgressGenerator(result) + ? await driveProgress(result, setTasks) + : await result; + const { title, rows } = Array.isArray(outcome) + ? { title: successTitle, rows: outcome } + : outcome; + setPhase({ kind: "success", title, rows }); } catch (err) { - setTasks((current) => settleProgress(current, "failed")); setPhase({ kind: "error", message: err instanceof Error ? err.message : String(err) }); } }; @@ -113,10 +111,14 @@ export function ConfirmAction({ ] : phase.kind === "success" ? [{ key: "enter", label: "continue" }] - : [ - { key: "esc", label: "back" }, - { key: "ctl+c", label: "quit" }, - ]; + : phase.kind === "running" + ? // Nothing listens for esc mid-action: an operation in flight is + // not abandoned by leaving the screen. + [{ key: "ctl+c", label: "quit" }] + : [ + { key: "esc", label: "back" }, + { key: "ctl+c", label: "quit" }, + ]; return ( @@ -134,7 +136,7 @@ export function ConfirmAction({ marginBottom={1} > {title} - + {phase.kind === "confirm" && ( @@ -147,7 +149,7 @@ export function ConfirmAction({ )} {phase.kind === "running" && tasks.length === 0 && } {phase.kind === "success" && ( - + )} {phase.kind === "error" && ( setPhase({ kind: "confirm" })} /> @@ -160,29 +162,18 @@ export function ConfirmAction({ // A promise has no Symbol.asyncIterator, so this is a safe discriminator. function isProgressGenerator( - result: Promise | AsyncGenerator, -): result is AsyncGenerator { + result: Promise | AsyncGenerator, +): result is AsyncGenerator { return ( - typeof (result as AsyncGenerator)[Symbol.asyncIterator] === + typeof (result as AsyncGenerator)[Symbol.asyncIterator] === "function" ); } -// SummaryRows aligns values on a column one past the longest label, so a -// label longer than the old fixed width (a stack output name, say) still has a -// gap before its value. -function SummaryRows({ rows }: { rows: SummaryRow[] }) { - const width = rows.reduce((max, row) => Math.max(max, row.label.length), 0) + 2; - return ( - <> - {rows.map((row) => ( - - {row.label.padEnd(width)} - {row.value} - - ))} - - ); +// KeyValueTable takes a record; rows are kept as a list here so callers can +// order them. +function toItems(rows: SummaryRow[]): Record { + return Object.fromEntries(rows.map((row) => [row.label, row.value])); } function SuccessBody({ @@ -204,7 +195,7 @@ function SuccessBody({ ✔ {title} - + diff --git a/src/handlers/project/ProjectGate.tsx b/src/handlers/project/ProjectGate.tsx index 94590914e..6adb52448 100644 --- a/src/handlers/project/ProjectGate.tsx +++ b/src/handlers/project/ProjectGate.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useState } from "react"; -import { Box, Text } from "ink"; +import { Box, Text, useInput } from "ink"; import { Layout } from "../../components/Layout"; import { Spinner } from "../../components/ui/spinner"; import { darkTheme } from "../../components/ui/_core.js"; @@ -56,6 +56,9 @@ export interface ProjectGateProps { // 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 runs on esc when resolution fails, so the user is not left with quit + // as the only way off the error. + 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. @@ -64,11 +67,31 @@ export interface ProjectGateProps { // 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, children }: ProjectGateProps) { +export function ProjectGate({ + core, + breadcrumb, + description, + seed, + onBack, + children, +}: ProjectGateProps) { const { project, error } = useProject(core, seed); if (project !== undefined) return children(project); - + if (error !== undefined) { + return ( + + + + ); + } return ( - {error === undefined ? ( - - ) : ( - ✗ {error} - )} + ); } + +function ResolutionError({ message, onBack }: { message: string; onBack: () => void }) { + useInput((_input, key) => { + if (key.escape) onBack(); + }); + return ( + + ✗ {message} + + ); +} diff --git a/src/handlers/project/build/screen.tsx b/src/handlers/project/build/screen.tsx index 77bb26a4f..2a79ff418 100644 --- a/src/handlers/project/build/screen.tsx +++ b/src/handlers/project/build/screen.tsx @@ -9,6 +9,7 @@ import { builtMessage } from "./index"; const BREADCRUMB = ["agentcore", "project", "build"]; const DESCRIPTION = "build the project's deployable artifacts"; +const PROJECT_MENU = "/agentcore/project"; // BuildProjectScreen is `agentcore project build` from the menu. It runs the // same projectManager.build generator the command runs, and ConfirmAction @@ -16,12 +17,14 @@ const DESCRIPTION = "build the project's deployable artifacts"; // command line — the TUI is a frame around the CLI's own progress, not a // second progress UI. export function BuildProjectScreen({ ctx, core }: ScreenProps) { + const navigate = useNavigate(); return ( navigate(PROJECT_MENU)} > {(project) => } @@ -46,12 +49,12 @@ function BuildConfirm({ project, core }: { project: Project; core: ScreenProps[" error={null} action={async function* () { yield* core.projectManager.build(project); - return [{ label: "result", value: builtMessage(project) }]; + return []; }} successTitle={builtMessage(project)} runningLabel="building…" onDone={() => exit()} - onCancel={() => navigate("/agentcore/project")} + onCancel={() => navigate(PROJECT_MENU)} /> ); } diff --git a/src/handlers/project/buildDeploy.screen.test.tsx b/src/handlers/project/buildDeploy.screen.test.tsx index 1177b63af..62973666b 100644 --- a/src/handlers/project/buildDeploy.screen.test.tsx +++ b/src/handlers/project/buildDeploy.screen.test.tsx @@ -166,6 +166,9 @@ describe("project build screen", () => { 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(); }); }); @@ -181,12 +184,13 @@ describe("project deploy screen", () => { expect(flatFrame(r.lastFrame)).toContain("account 111122223333/us-east-1"); await r.write("y"); - await waitForText(r.lastFrame, "✔ Project deployed"); + await waitForText(r.lastFrame, "✔ Deployed project 'orders' to target 'default'"); const frame = flatFrame(r.lastFrame); expect(frame).toContain("✓ Synthesizing CloudFormation templates"); expect(frame).toContain("✓ Deploying stack"); - expect(frame).toContain("Deployed project 'orders' to target 'default'"); expect(frame).toContain("RuntimeArn arn:runtime"); + // esc is not offered mid-action; here the action has finished. + expect(frame).toContain("[enter] continue"); // A project with resources never confirms a teardown, as on the command line. expect(deploys).toHaveLength(1); @@ -208,12 +212,28 @@ describe("project deploy screen", () => { ); await r.write("y"); - await waitForText(r.lastFrame, "✔ Project removed"); - expect(flatFrame(r.lastFrame)).toContain("Removed project 'orders' from target 'default'"); + 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, "Deploy project 'orders' to target 'default'?"); + await r.write("y"); + + 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 } }); diff --git a/src/handlers/project/deploy/screen.tsx b/src/handlers/project/deploy/screen.tsx index c9c3885ac..43145a28e 100644 --- a/src/handlers/project/deploy/screen.tsx +++ b/src/handlers/project/deploy/screen.tsx @@ -12,6 +12,7 @@ import { declaresNothingDeployable, deployedMessage, teardownQuestion } from "./ const BREADCRUMB = ["agentcore", "project", "deploy"]; const DESCRIPTION = "deploy the project to AWS"; +const PROJECT_MENU = "/agentcore/project"; // DeployProjectScreen is `agentcore project deploy` from the menu. It runs the // same projectManager.deploy generator the command runs, and ConfirmAction @@ -19,12 +20,14 @@ const DESCRIPTION = "deploy the project to AWS"; // command line. The teardown question the command asks over readline is asked // here as the confirmation itself. export function DeployProjectScreen({ ctx, core }: ScreenProps) { + const navigate = useNavigate(); return ( navigate(PROJECT_MENU)} > {(project) => } @@ -85,16 +88,19 @@ function DeployConfirm({ region, confirmTeardown: async () => teardown, }); - const rows: SummaryRow[] = [ - { label: "result", value: deployedMessage(project, targetName, result) }, - ]; - for (const [key, value] of Object.entries(result.outputs)) rows.push({ label: key, value }); - return rows; + // The outcome comes from the result, as the command's own line does: + // the preflight heuristic above only decides what to ask, and the + // backend's post-synth count can disagree with it. + const rows: SummaryRow[] = Object.entries(result.outputs).map(([label, value]) => ({ + label, + value, + })); + return { title: deployedMessage(project, targetName, result), rows }; }} - successTitle={teardown ? "Project removed" : "Project deployed"} + successTitle="Deploy finished" runningLabel="deploying…" onDone={() => exit()} - onCancel={() => navigate("/agentcore/project")} + onCancel={() => navigate(PROJECT_MENU)} /> ); } diff --git a/src/tui/progress.test.tsx b/src/tui/progress.test.tsx index a12a6fe5c..68e8b87d4 100644 --- a/src/tui/progress.test.tsx +++ b/src/tui/progress.test.tsx @@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test"; import { testIO } from "../testing"; import { applyProgressEvent, + driveProgress, runWithProgress, settleProgress, type ProgressEvent, @@ -177,3 +178,36 @@ describe("applyProgressEvent / settleProgress", () => { 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 c1ca066e8..06c9a20d7 100644 --- a/src/tui/progress.tsx +++ b/src/tui/progress.tsx @@ -62,6 +62,34 @@ export function settleProgress(tasks: readonly Task[], state: "done" | "failed") 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 the generator's return value. On failure the running task is + * marked failed and the error rethrown unchanged. This is the one place a + * generator becomes tasks; every renderer — the inline TaskList below, a TUI + * screen — supplies only how to draw them. + */ +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. @@ -95,7 +123,6 @@ export async function runWithProgress( } const tailLines = options.tailLines ?? DEFAULT_TAIL_LINES; - let 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(, { @@ -108,24 +135,15 @@ export async function runWithProgress( exitOnCtrlC: false, patchConsole: false, }); - const draw = () => instance.rerender(); + // On failure the failed step keeps its tail: the last frame stays in + // scrollback above the error message runWithExitCode prints after the rethrow. try { - let next = await generator.next(); - while (!next.done) { - tasks = applyProgressEvent(tasks, next.value, tailLines); - draw(); - next = await generator.next(); - } - tasks = settleProgress(tasks, "done"); - 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. - tasks = settleProgress(tasks, "failed"); - draw(); - throw error; + return await driveProgress( + generator, + (tasks) => instance.rerender(), + tailLines, + ); } finally { instance.unmount(); await instance.waitUntilExit(); From 0afdcd3ac775a41cfbd8bcfcf4ab4631a86a3682 Mon Sep 17 00:00:00 2001 From: gitikavj Date: Thu, 3 Sep 2026 06:08:21 +0000 Subject: [PATCH 3/6] feat(project): build runs at once, deploy picks a target; no confirmation unless tearing down MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Shaped by trying the screens on a real project: - build starts as soon as the screen opens — it changes nothing outside the project — and shows no header. Done, it suggests `agentcore project deploy` and enter returns to the project menu instead of exiting. - deploy confirms only when the spec declares nothing deployable, the one case the command asks its readline question; otherwise it deploys at once. With several targets in aws-targets.json it first asks which (a DataTable of target/account/region, the TUI's stand-in for --target); with one or none it uses that or `default`. ProjectManager gains listTargets for this; resolveTarget delegates to it. - deploy no longer errors on a fresh project: resolveTarget returning undefined (no aws-targets.json yet) was fed straight to useQuery, which treats undefined data as an error. The manager's "Created default deployment target" step now streams through as it does on the CLI. - the header is just project/target; stack outputs are not listed (the command prints them only with --json); the success hint reads "go back". ConfirmAction: message, title and rows are optional (no question → run when ready; no header → no box), doneLabel names where enter leads, nextSteps lists follow-up commands, and an error without a confirmation to return to leaves instead of re-running. --- src/components/ConfirmAction.tsx | 100 ++++++++--- src/core/project/manager.tsx | 10 +- src/handlers/project/build/screen.tsx | 16 +- .../project/buildDeploy.screen.test.tsx | 109 ++++++++---- src/handlers/project/deploy/screen.tsx | 167 +++++++++++++----- src/handlers/project/types.ts | 6 + 6 files changed, 288 insertions(+), 120 deletions(-) diff --git a/src/components/ConfirmAction.tsx b/src/components/ConfirmAction.tsx index 3b46b2329..a4c64ec9a 100644 --- a/src/components/ConfirmAction.tsx +++ b/src/components/ConfirmAction.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useEffect, useState } from "react"; import { Box, Text, useInput } from "ink"; import { useNavigate } from "react-router"; import { Layout } from "./Layout"; @@ -25,11 +25,14 @@ export interface ConfirmActionProps { // description so the header matches `--help`. 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, for an action whose breadcrumb says it all. + rows?: SummaryRow[]; + // message is the yes/no question (destructive actions default to No). Omit it + // to skip the confirmation and run the action as soon as the summary loads — + // for an operation that is safe to start without asking, like a build. + message?: string; // isPending / error reflect the summary fetch backing the overlay. isPending: boolean; error: Error | null; @@ -47,8 +50,14 @@ export interface ConfirmActionProps { // runningLabel is the spinner label while the action runs, shown until the // action's first progress step arrives (or throughout, for a plain promise). runningLabel: string; - // onDone is called when the user acknowledges the success panel. + // nextSteps are commands suggested under the success panel, as the create + // wizard suggests `agentcore project deploy`. + nextSteps?: string[]; + // onDone is called when the user acknowledges the success panel; doneLabel + // says where that leads ("continue" by default, "go back" for a screen that + // returns to a menu). onDone: () => void; + doneLabel?: string; // onCancel runs when the confirmation is declined or esc is pressed; defaults // to popping the router history, which suits a screen reached from a picker. onCancel?: () => void; @@ -67,19 +76,22 @@ export function ConfirmAction({ breadcrumb, description, title, - rows, + rows = [], message, isPending, error, action, successTitle, runningLabel, + nextSteps, onDone, + doneLabel = "continue", onCancel, }: ConfirmActionProps) { const navigate = useNavigate(); const cancel = onCancel ?? (() => navigate(-1)); const [phase, setPhase] = useState({ kind: "confirm" }); + const confirms = message !== undefined; // tasks is the step list a progress-reporting action builds up. It stays on // screen through success and error, as the headless command leaves its // completed steps in scrollback above the final line. @@ -102,6 +114,13 @@ export function ConfirmAction({ } }; + // Without a question there is nothing to wait for: run once the summary is + // ready. Keyed on isPending/error so it fires exactly once, when they settle. + useEffect(() => { + if (!confirms && !isPending && !error && phase.kind === "confirm") void run(); + // eslint-disable-next-line react-hooks/exhaustive-deps -- run is recreated each render; the phase guard makes this idempotent + }, [confirms, isPending, error, phase.kind]); + const hints = phase.kind === "confirm" ? [ @@ -110,7 +129,7 @@ export function ConfirmAction({ { key: "ctl+c", label: "quit" }, ] : phase.kind === "success" - ? [{ key: "enter", label: "continue" }] + ? [{ key: "enter", label: doneLabel }] : phase.kind === "running" ? // Nothing listens for esc mid-action: an operation in flight is // not abandoned by leaving the screen. @@ -128,18 +147,20 @@ export function ConfirmAction({ ) : ( - - {title} - - - - {phase.kind === "confirm" && ( + {(title !== undefined || rows.length > 0) && ( + + {title !== undefined && {title}} + {rows.length > 0 && } + + )} + + {phase.kind === "confirm" && confirms && ( )} {phase.kind !== "confirm" && tasks.length > 0 && ( @@ -149,10 +170,21 @@ export function ConfirmAction({ )} {phase.kind === "running" && tasks.length === 0 && } {phase.kind === "success" && ( - + )} {phase.kind === "error" && ( - setPhase({ kind: "confirm" })} /> + // With a confirmation, esc returns to the question to try again; + // without one, returning would run again, so it leaves instead. + setPhase({ kind: "confirm" }) : cancel} + /> )} )} @@ -179,11 +211,15 @@ function toItems(rows: SummaryRow[]): Record { function SuccessBody({ title, rows, + nextSteps, onDone, + doneLabel, }: { title: string; rows: SummaryRow[]; + nextSteps?: string[]; onDone: () => void; + doneLabel: string; }) { useInput((_input, key) => { if (key.return || key.escape) onDone(); @@ -194,12 +230,22 @@ function SuccessBody({ ✔ {title} - - - + {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/core/project/manager.tsx b/src/core/project/manager.tsx index 82dbfe265..ce718c768 100644 --- a/src/core/project/manager.tsx +++ b/src/core/project/manager.tsx @@ -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/project/build/screen.tsx b/src/handlers/project/build/screen.tsx index 2a79ff418..fff06d23d 100644 --- a/src/handlers/project/build/screen.tsx +++ b/src/handlers/project/build/screen.tsx @@ -1,5 +1,4 @@ import { useNavigate } from "react-router"; -import { useApp } from "ink"; import { ConfirmAction } from "../../../components/ConfirmAction"; import { ProjectKey } from "../../../router"; import type { ScreenProps } from "../../types"; @@ -15,7 +14,7 @@ const PROJECT_MENU = "/agentcore/project"; // same projectManager.build generator the command runs, and ConfirmAction // renders its steps through the same TaskList runWithProgress renders on the // command line — the TUI is a frame around the CLI's own progress, not a -// second progress UI. +// second progress UI. Once done, enter returns to the project menu. export function BuildProjectScreen({ ctx, core }: ScreenProps) { const navigate = useNavigate(); return ( @@ -33,18 +32,13 @@ export function BuildProjectScreen({ ctx, core }: ScreenProps) { function BuildConfirm({ project, core }: { project: Project; core: ScreenProps["core"] }) { const navigate = useNavigate(); - const { exit } = useApp(); + // No confirmation: a build changes nothing outside the project directory, + // so it starts as soon as the screen opens, as the command does. return ( exit()} + nextSteps={["agentcore project deploy"]} + onDone={() => 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 index 62973666b..f5152e4df 100644 --- a/src/handlers/project/buildDeploy.screen.test.tsx +++ b/src/handlers/project/buildDeploy.screen.test.tsx @@ -74,7 +74,12 @@ afterEach(async () => { }); /** Scaffolds project 'orders' with a default target and cds into it. */ -async function inProject(core: TestCoreClient, options: { empty?: boolean } = {}): Promise { +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); @@ -94,10 +99,15 @@ async function inProject(core: TestCoreClient, options: { empty?: boolean } = {} "--skip-git", ]); const projectRoot = join(process.cwd(), "orders"); - await writeFile( - join(projectRoot, "agentcore", "aws-targets.json"), - JSON.stringify([{ name: "default", account: "111122223333", region: "us-east-1" }]), - ); + 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( @@ -110,23 +120,26 @@ async function inProject(core: TestCoreClient, options: { empty?: boolean } = {} } describe("project build screen", () => { - test("confirms, then renders the backend's steps as the CLI does, then the CLI's own success line", async () => { + 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 }); - await waitForText(r.lastFrame, "Build project 'orders'?"); - expect(r.lastFrame()).toContain("agentcore → project → build"); - await r.write("y"); - // 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"); + expect(frame).not.toContain("(y/N)"); 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(); }); @@ -136,24 +149,14 @@ describe("project build screen", () => { await inProject(core); const r = renderScreen("/agentcore/project/build", { core }); - await waitForText(r.lastFrame, "Build project 'orders'?"); - await r.write("y"); - 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"); - r.unmount(); - }); - - test("declining returns to the project menu", async () => { - const core = new TestCoreClient({ backends: { CDK: fakeBackend().backend } }); - await inProject(core); - const r = renderScreen("/agentcore/project/build", { core }); - - await waitForText(r.lastFrame, "Build project 'orders'?"); - await r.write("n"); + // 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(); }); @@ -174,31 +177,67 @@ describe("project build screen", () => { }); describe("project deploy screen", () => { - test("shows the target, then the backend's steps, then the CLI's own success line and outputs", async () => { + 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 }); - await waitForText(r.lastFrame, "Deploy project 'orders' to target 'default'?"); - expect(flatFrame(r.lastFrame)).toContain("account 111122223333/us-east-1"); - await r.write("y"); - + // 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"); - expect(frame).toContain("RuntimeArn arn:runtime"); - // esc is not offered mid-action; here the action has finished. - expect(frame).toContain("[enter] continue"); + // Stack outputs are not listed, as the command prints them only with --json. + expect(frame).not.toContain("RuntimeArn"); + expect(frame).toContain("[enter] go back"); - // A project with resources never confirms a teardown, as on the command line. + // …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("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("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 } }); @@ -226,9 +265,6 @@ describe("project deploy screen", () => { await inProject(core); const r = renderScreen("/agentcore/project/deploy", { core }); - await waitForText(r.lastFrame, "Deploy project 'orders' to target 'default'?"); - await r.write("y"); - await waitForText(r.lastFrame, "✔ Removed project 'orders' from target 'default'"); expect(r.lastFrame()).not.toContain("Deployed project"); r.unmount(); @@ -240,9 +276,6 @@ describe("project deploy screen", () => { await inProject(core); const r = renderScreen("/agentcore/project/deploy", { core }); - await waitForText(r.lastFrame, "Deploy project 'orders' to target 'default'?"); - await r.write("y"); - await waitForText(r.lastFrame, "✗ stack rolled back"); expect(r.lastFrame()).toContain("✕ Deploying stack"); r.unmount(); diff --git a/src/handlers/project/deploy/screen.tsx b/src/handlers/project/deploy/screen.tsx index 43145a28e..d20258864 100644 --- a/src/handlers/project/deploy/screen.tsx +++ b/src/handlers/project/deploy/screen.tsx @@ -1,8 +1,12 @@ +import { useState } from "react"; import { useQuery } from "@tanstack/react-query"; +import { Box, Text } from "ink"; import { useNavigate } from "react-router"; -import { useApp } from "ink"; -import { ConfirmAction, type SummaryRow } from "../../../components/ConfirmAction"; -import { DEFAULT_TARGET_NAME } from "../../../projectSchemas/aws-targets"; +import { ConfirmAction } from "../../../components/ConfirmAction"; +import { Layout } from "../../../components/Layout"; +import { DataTable, type DataTableColumn } from "../../../components/ui/data-table"; +import { Spinner } from "../../../components/ui/spinner"; +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"; @@ -17,8 +21,8 @@ const PROJECT_MENU = "/agentcore/project"; // DeployProjectScreen is `agentcore project deploy` from the menu. It runs the // same projectManager.deploy generator the command runs, and ConfirmAction // renders its steps through the same TaskList runWithProgress renders on the -// command line. The teardown question the command asks over readline is asked -// here as the confirmation itself. +// command line. With one target (or none yet) it deploys there; with several, +// it asks which — the TUI's stand-in for --target. export function DeployProjectScreen({ ctx, core }: ScreenProps) { const navigate = useNavigate(); return ( @@ -29,12 +33,20 @@ export function DeployProjectScreen({ ctx, core }: ScreenProps) { seed={ctx.value(ProjectKey)} onBack={() => navigate(PROJECT_MENU)} > - {(project) => } + {(project) => } ); } -function DeployConfirm({ +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, @@ -44,44 +56,117 @@ function DeployConfirm({ core: ScreenProps["core"]; }) { const navigate = useNavigate(); - const { exit } = useApp(); - const region = ctx.require(RegionKey); - const targetName = DEFAULT_TARGET_NAME; + const [chosen, setChosen] = useState(); - // The target is resolved up front, as the command does, because the teardown - // question is only asked when there is a target whose stack could be removed. - const target = useQuery({ - queryKey: ["project-target", project.rootPath, targetName], - queryFn: () => core.projectManager.resolveTarget(project, { target: targetName }), + // The declared targets decide whether there is anything to choose. A fresh + // project has no aws-targets.json yet: the list is empty, and deploy + // provisions `default` on first run, as the command does. + const targets = useQuery({ + queryKey: ["project-targets", project.rootPath], + queryFn: () => core.projectManager.listTargets(project), }); - // Once the progress UI is up nothing may block on input, so the teardown - // decision is settled by the confirmation the user is about to answer: when - // the project declares nothing deployable, confirming *is* confirming the - // teardown. Otherwise the backend's own zero-resource check reports the - // "re-run with --yes" error, as it does for a non-interactive deploy. - const teardown = target.data !== undefined && declaresNothingDeployable(project); - const message = teardown - ? teardownQuestion(project.name, target.data!) - : `Deploy project '${project.name}' to target '${targetName}'?`; + if (targets.isPending || targets.isError) { + return ( + + + {targets.isError ? ( + {(targets.error as Error).message} + ) : ( + + )} + + + ); + } + + 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); + + // A deploy is confirmed only when it would tear the stack down — the same + // rule as the command, which asks its readline question in exactly that case + // and otherwise just deploys. Once the progress UI is up nothing may block on + // input, so the answer here is the pre-answered decision the backend + // consults; when the backend's own count disagrees with this preflight, it + // reports the "re-run with --yes" error as it does for a non-interactive run. + const teardown = target !== undefined && declaresNothingDeployable(project); return ( ({ - label, - value, - })); - return { title: deployedMessage(project, targetName, result), rows }; + // backend's post-synth count can disagree with it. Stack outputs are + // not listed — the command prints them only with --json. + return { title: deployedMessage(project, targetName, result), rows: [] }; }} successTitle="Deploy finished" runningLabel="deploying…" - onDone={() => exit()} - onCancel={() => navigate(PROJECT_MENU)} + onDone={() => navigate(PROJECT_MENU)} + doneLabel="go back" + onCancel={onCancel} /> ); } 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 From b932bd695b34ccf6009f8fc5f7367539da834af5 Mon Sep 17 00:00:00 2001 From: gitikavj Date: Thu, 3 Sep 2026 06:19:12 +0000 Subject: [PATCH 4/6] chore(project): trim comments on the build/deploy screens to what the code doesn't say --- src/components/ConfirmAction.tsx | 46 ++++++++++---------------- src/handlers/project/ProjectGate.tsx | 13 +++----- src/handlers/project/build/screen.tsx | 10 ++---- src/handlers/project/deploy/index.ts | 3 +- src/handlers/project/deploy/screen.tsx | 30 +++++++---------- src/middleware/withProject.tsx | 5 +-- src/tui/progress.tsx | 12 +++---- 7 files changed, 43 insertions(+), 76 deletions(-) diff --git a/src/components/ConfirmAction.tsx b/src/components/ConfirmAction.tsx index a4c64ec9a..8de85f31a 100644 --- a/src/components/ConfirmAction.tsx +++ b/src/components/ConfirmAction.tsx @@ -21,45 +21,38 @@ export type ActionResult = SummaryRow[] | { title: string; rows: SummaryRow[] }; export interface ConfirmActionProps { // breadcrumb labels the screen. breadcrumb: string[]; - // description is shown dimmed after the breadcrumb, e.g. the command's own - // description so the header matches `--help`. + // 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. With neither title nor - // rows the overlay is omitted, for an action whose breadcrumb says it all. + // rows the overlay is omitted. rows?: SummaryRow[]; // message is the yes/no question (destructive actions default to No). Omit it - // to skip the confirmation and run the action as soon as the summary loads — - // for an operation that is safe to start without asking, like a build. + // to skip the confirmation and run as soon as the summary loads. message?: string; // isPending / error reflect the summary fetch backing the overlay. isPending: boolean; error: Error | null; - // action performs the confirmed operation and resolves to what the success - // panel shows: result rows, optionally under a title that replaces - // successTitle — for an outcome only known once the action has run. A - // long-running operation may instead return a progress generator — the same - // AsyncGenerator runWithProgress drives for the headless - // command — and its steps render as a live task list while it runs, exactly - // as they do on the command line. + // 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, shown until the - // action's first progress step arrives (or throughout, for a plain promise). + // runningLabel is the spinner label while the action runs, until its first + // progress step arrives. runningLabel: string; - // nextSteps are commands suggested under the success panel, as the create - // wizard suggests `agentcore project deploy`. + // nextSteps are commands suggested under the success panel. nextSteps?: string[]; // onDone is called when the user acknowledges the success panel; doneLabel - // says where that leads ("continue" by default, "go back" for a screen that - // returns to a menu). + // 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, which suits a screen reached from a picker. + // to popping the router history. onCancel?: () => void; } @@ -92,9 +85,8 @@ export function ConfirmAction({ const cancel = onCancel ?? (() => navigate(-1)); const [phase, setPhase] = useState({ kind: "confirm" }); const confirms = message !== undefined; - // tasks is the step list a progress-reporting action builds up. It stays on - // screen through success and error, as the headless command leaves its - // completed steps in scrollback above the final line. + // 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 () => { @@ -114,11 +106,10 @@ export function ConfirmAction({ } }; - // Without a question there is nothing to wait for: run once the summary is - // ready. Keyed on isPending/error so it fires exactly once, when they settle. + // Without a question, run once the summary is ready. useEffect(() => { if (!confirms && !isPending && !error && phase.kind === "confirm") void run(); - // eslint-disable-next-line react-hooks/exhaustive-deps -- run is recreated each render; the phase guard makes this idempotent + // eslint-disable-next-line react-hooks/exhaustive-deps -- the phase guard makes this run once }, [confirms, isPending, error, phase.kind]); const hints = @@ -179,8 +170,7 @@ export function ConfirmAction({ /> )} {phase.kind === "error" && ( - // With a confirmation, esc returns to the question to try again; - // without one, returning would run again, so it leaves instead. + // Without a confirmation, returning to it would run again. setPhase({ kind: "confirm" }) : cancel} @@ -202,8 +192,6 @@ function isProgressGenerator( ); } -// KeyValueTable takes a record; rows are kept as a list here so callers can -// order them. function toItems(rows: SummaryRow[]): Record { return Object.fromEntries(rows.map((row) => [row.label, row.value])); } diff --git a/src/handlers/project/ProjectGate.tsx b/src/handlers/project/ProjectGate.tsx index 6adb52448..d577426c6 100644 --- a/src/handlers/project/ProjectGate.tsx +++ b/src/handlers/project/ProjectGate.tsx @@ -14,12 +14,10 @@ export interface UseProjectResult { error?: string; } -// useProject resolves the project enclosing the working directory for a TUI -// screen. Screens need their own resolution because withProject wraps `handle` -// only: middleware runs when a command executes, and navigating between TUI -// screens never executes one, so ProjectKey is set only when the launching -// command happened to be a project command. When it was, pass it as `seed` and -// no resolution happens. The not-found guidance is withProject's own. +// 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): UseProjectResult { const [project, setProject] = useState(seed); const [error, setError] = useState(); @@ -56,8 +54,7 @@ export interface ProjectGateProps { // 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 runs on esc when resolution fails, so the user is not left with quit - // as the only way off the error. + // onBack runs on esc when resolution fails. 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 diff --git a/src/handlers/project/build/screen.tsx b/src/handlers/project/build/screen.tsx index fff06d23d..6eb7f07f1 100644 --- a/src/handlers/project/build/screen.tsx +++ b/src/handlers/project/build/screen.tsx @@ -10,11 +10,8 @@ const BREADCRUMB = ["agentcore", "project", "build"]; const DESCRIPTION = "build the project's deployable artifacts"; const PROJECT_MENU = "/agentcore/project"; -// BuildProjectScreen is `agentcore project build` from the menu. It runs the -// same projectManager.build generator the command runs, and ConfirmAction -// renders its steps through the same TaskList runWithProgress renders on the -// command line — the TUI is a frame around the CLI's own progress, not a -// second progress UI. Once done, enter returns to the project menu. +// 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 ( @@ -33,8 +30,7 @@ export function BuildProjectScreen({ ctx, core }: ScreenProps) { function BuildConfirm({ project, core }: { project: Project; core: ScreenProps["core"] }) { const navigate = useNavigate(); - // No confirmation: a build changes nothing outside the project directory, - // so it starts as soon as the screen opens, as the command does. + // No confirmation: a build changes nothing outside the project directory. return ( (); - // The declared targets decide whether there is anything to choose. A fresh - // project has no aws-targets.json yet: the list is empty, and deploy - // provisions `default` on first run, as the command does. + // 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), @@ -148,12 +145,10 @@ function DeployConfirm({ const navigate = useNavigate(); const region = ctx.require(RegionKey); - // A deploy is confirmed only when it would tear the stack down — the same - // rule as the command, which asks its readline question in exactly that case - // and otherwise just deploys. Once the progress UI is up nothing may block on - // input, so the answer here is the pre-answered decision the backend - // consults; when the backend's own count disagrees with this preflight, it - // reports the "re-run with --yes" error as it does for a non-interactive run. + // 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 ( @@ -173,10 +168,9 @@ function DeployConfirm({ region, confirmTeardown: async () => teardown, }); - // The outcome comes from the result, as the command's own line does: - // the preflight heuristic above only decides what to ask, and the - // backend's post-synth count can disagree with it. Stack outputs are - // not listed — the command prints them only with --json. + // 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" diff --git a/src/middleware/withProject.tsx b/src/middleware/withProject.tsx index 5b70f7478..f2166151f 100644 --- a/src/middleware/withProject.tsx +++ b/src/middleware/withProject.tsx @@ -15,10 +15,7 @@ interface WithProjectConfig { * * @param config - Contains the {@link ProjectManager} and an optional `cwd` to search from. */ -/** - * The guidance printed when no project encloses `from`. Exported so TUI screens - * that resolve the project themselves (see useProject) say the same thing. - */ +/** 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 ` + diff --git a/src/tui/progress.tsx b/src/tui/progress.tsx index 06c9a20d7..6de58faf4 100644 --- a/src/tui/progress.tsx +++ b/src/tui/progress.tsx @@ -27,8 +27,6 @@ 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. - * Pure, so every renderer of progress — the inline TaskList below, a TUI - * screen's running phase — reads events the same way and shows the same steps. */ export function applyProgressEvent( tasks: readonly Task[], @@ -64,10 +62,8 @@ export function settleProgress(tasks: readonly Task[], state: "done" | "failed") /** * Drains a progress generator, reporting the task list after every change, and - * resolves with the generator's return value. On failure the running task is - * marked failed and the error rethrown unchanged. This is the one place a - * generator becomes tasks; every renderer — the inline TaskList below, a TUI - * screen — supplies only how to draw them. + * 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, @@ -136,8 +132,8 @@ export async function runWithProgress( patchConsole: false, }); - // On failure the failed step keeps its tail: the last frame stays in - // scrollback above the error message runWithExitCode prints after the rethrow. + // A failed step keeps its tail: the last frame stays in scrollback above the + // error runWithExitCode prints after the rethrow. try { return await driveProgress( generator, From 8dd11f05289a8395320a11f1ce210fb3d35c72d9 Mon Sep 17 00:00:00 2001 From: gitikavj Date: Thu, 3 Sep 2026 06:39:30 +0000 Subject: [PATCH 5/6] refactor(project): explicit ConfirmAction trigger, useQuery-backed useProject, recoverable target loading Review follow-ups on #2172: - ConfirmAction takes `trigger: {kind:"confirm"; message} | {kind:"immediate"}` instead of inferring immediacy from a missing message. The initial phase follows the trigger, so an immediate action never paints a y/n footer. - useProject is a useQuery (seed as initialData, never refetched); ProjectGate and the invoke screen keep their shape. LoadingFrame is the shared spinner-or-error with esc back and r retry, used by ProjectGate and by the deploy screen's target loading, which previously offered only ctl+c. - The create wizard drives its progress through driveProgress and renders TaskList, dropping its own event list. --- src/components/ConfirmAction.tsx | 50 ++++--- src/handlers/harness/delete/screen.tsx | 5 +- .../harness/endpoint/delete/screen.tsx | 5 +- src/handlers/project/ProjectGate.tsx | 132 +++++++++--------- src/handlers/project/build/screen.tsx | 1 + .../project/buildDeploy.screen.test.tsx | 42 +++++- .../project/create/create.screen.test.tsx | 23 +-- src/handlers/project/create/screen.tsx | 26 ++-- src/handlers/project/deploy/screen.tsx | 28 ++-- src/handlers/project/invoke/screen.tsx | 4 +- 10 files changed, 174 insertions(+), 142 deletions(-) diff --git a/src/components/ConfirmAction.tsx b/src/components/ConfirmAction.tsx index 8de85f31a..59a7b65e8 100644 --- a/src/components/ConfirmAction.tsx +++ b/src/components/ConfirmAction.tsx @@ -18,6 +18,11 @@ export interface SummaryRow { export type ActionResult = SummaryRow[] | { title: string; rows: SummaryRow[] }; +// 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[]; @@ -28,9 +33,7 @@ export interface ConfirmActionProps { // rows describe the resource the action applies to. With neither title nor // rows the overlay is omitted. rows?: SummaryRow[]; - // message is the yes/no question (destructive actions default to No). Omit it - // to skip the confirmation and run as soon as the summary loads. - message?: string; + trigger: ActionTrigger; // isPending / error reflect the summary fetch backing the overlay. isPending: boolean; error: Error | null; @@ -56,8 +59,11 @@ export interface ConfirmActionProps { onCancel?: () => void; } +// "confirm" waits on the question; "idle" waits on the summary for an immediate +// trigger. Neither survives the first run. type Phase = | { kind: "confirm" } + | { kind: "idle" } | { kind: "running" } | { kind: "success"; title: string; rows: SummaryRow[] } | { kind: "error"; message: string }; @@ -70,7 +76,7 @@ export function ConfirmAction({ description, title, rows = [], - message, + trigger, isPending, error, action, @@ -83,8 +89,9 @@ export function ConfirmAction({ }: ConfirmActionProps) { const navigate = useNavigate(); const cancel = onCancel ?? (() => navigate(-1)); - const [phase, setPhase] = useState({ kind: "confirm" }); - const confirms = message !== undefined; + const [phase, setPhase] = useState({ + kind: trigger.kind === "confirm" ? "confirm" : "idle", + }); // tasks is the step list a progress-reporting action builds up; it stays on // screen through success and error. const [tasks, setTasks] = useState([]); @@ -106,11 +113,11 @@ export function ConfirmAction({ } }; - // Without a question, run once the summary is ready. + // An immediate trigger runs once the summary is ready. useEffect(() => { - if (!confirms && !isPending && !error && phase.kind === "confirm") void run(); + if (phase.kind === "idle" && !isPending && !error) void run(); // eslint-disable-next-line react-hooks/exhaustive-deps -- the phase guard makes this run once - }, [confirms, isPending, error, phase.kind]); + }, [phase.kind, isPending, error]); const hints = phase.kind === "confirm" @@ -121,14 +128,14 @@ export function ConfirmAction({ ] : phase.kind === "success" ? [{ key: "enter", label: doneLabel }] - : phase.kind === "running" - ? // Nothing listens for esc mid-action: an operation in flight is - // not abandoned by leaving the screen. - [{ key: "ctl+c", label: "quit" }] - : [ + : 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 ( @@ -151,8 +158,13 @@ export function ConfirmAction({ )} - {phase.kind === "confirm" && confirms && ( - + {phase.kind === "confirm" && trigger.kind === "confirm" && ( + )} {phase.kind !== "confirm" && tasks.length > 0 && ( @@ -170,10 +182,10 @@ export function ConfirmAction({ /> )} {phase.kind === "error" && ( - // Without a confirmation, returning to it would run again. + // Without a question to return to, returning would run again. setPhase({ kind: "confirm" }) : cancel} + onBack={trigger.kind === "confirm" ? () => setPhase({ kind: "confirm" }) : cancel} /> )} diff --git a/src/handlers/harness/delete/screen.tsx b/src/handlers/harness/delete/screen.tsx index 4bb46915f..5041ad922 100644 --- a/src/handlers/harness/delete/screen.tsx +++ b/src/handlers/harness/delete/screen.tsx @@ -44,7 +44,10 @@ function DeleteConfirm({ ctx, core, harnessId }: ScreenProps & { harnessId: stri { label: "status", value: harness?.status ?? "-" }, { label: "version", value: harness?.harnessVersion ?? "-" }, ]} - message={`Delete harness ${harness?.harnessName ?? harnessId}? This permanently removes the harness, its versions, and its endpoints.`} + trigger={{ + kind: "confirm", + message: `Delete harness ${harness?.harnessName ?? harnessId}? This permanently removes the harness, its versions, and its endpoints.`, + }} isPending={detail.isPending} error={detail.isError ? (detail.error as Error) : null} action={async () => { diff --git a/src/handlers/harness/endpoint/delete/screen.tsx b/src/handlers/harness/endpoint/delete/screen.tsx index 122ccf987..251c178c5 100644 --- a/src/handlers/harness/endpoint/delete/screen.tsx +++ b/src/handlers/harness/endpoint/delete/screen.tsx @@ -62,7 +62,10 @@ function DeleteConfirm({ { label: "status", value: endpoint?.status ?? "-" }, { label: "target", value: endpoint?.targetVersion ?? "-" }, ]} - message={`Delete endpoint ${endpointName}? Callers using it will lose access.`} + trigger={{ + kind: "confirm", + message: `Delete endpoint ${endpointName}? Callers using it will lose access.`, + }} isPending={detail.isPending} error={detail.isError ? (detail.error as Error) : null} action={async () => { diff --git a/src/handlers/project/ProjectGate.tsx b/src/handlers/project/ProjectGate.tsx index d577426c6..06f184f2a 100644 --- a/src/handlers/project/ProjectGate.tsx +++ b/src/handlers/project/ProjectGate.tsx @@ -1,50 +1,77 @@ -import React, { useEffect, useState } from "react"; +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; -export interface UseProjectResult { - project?: Project; - error?: string; -} - // 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): UseProjectResult { - const [project, setProject] = useState(seed); - const [error, setError] = useState(); +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; + }, + // 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; +} - useEffect(() => { - if (project !== undefined) return; - let active = true; - const from = process.cwd(); - void core.projectManager - .resolve({ filePath: from }) - .then((resolved) => { - if (!active) return; - if (!resolved) { - setError(projectNotFoundMessage(from)); - return; - } - setProject(resolved); - }) - .catch((cause: unknown) => { - if (active) setError(cause instanceof Error ? cause.message : String(cause)); - }); - return () => { - active = false; - }; - }, [core.projectManager, project]); +// 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 { project, error }; + return ( + + + {query.isError ? ( + ✗ {(query.error as Error).message} + ) : ( + + )} + + + ); } export interface ProjectGateProps { @@ -54,7 +81,6 @@ export interface ProjectGateProps { // 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 runs on esc when resolution fails. 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 @@ -72,43 +98,15 @@ export function ProjectGate({ onBack, children, }: ProjectGateProps) { - const { project, error } = useProject(core, seed); - - if (project !== undefined) return children(project); - if (error !== undefined) { - return ( - - - - ); - } + const project = useProject(core, seed); + if (project.data !== undefined) return children(project.data); return ( - - - - - - ); -} - -function ResolutionError({ message, onBack }: { message: string; onBack: () => void }) { - useInput((_input, key) => { - if (key.escape) onBack(); - }); - return ( - - ✗ {message} - + query={project} + loadingLabel="loading project…" + onBack={onBack} + /> ); } diff --git a/src/handlers/project/build/screen.tsx b/src/handlers/project/build/screen.tsx index 6eb7f07f1..5e8d40196 100644 --- a/src/handlers/project/build/screen.tsx +++ b/src/handlers/project/build/screen.tsx @@ -35,6 +35,7 @@ function BuildConfirm({ project, core }: { project: Project; core: ScreenProps[" { await waitForText(r.lastFrame, "✔ Built project 'orders'"); const frame = r.lastFrame()!; expect(frame).toContain("agentcore → project → build"); - expect(frame).not.toContain("(y/N)"); + // 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"); @@ -202,6 +205,43 @@ describe("project deploy screen", () => { 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'"); + expect(attempts).toBe(2); + 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 } }); 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/screen.tsx b/src/handlers/project/deploy/screen.tsx index 6c4299267..8f1d1905a 100644 --- a/src/handlers/project/deploy/screen.tsx +++ b/src/handlers/project/deploy/screen.tsx @@ -1,16 +1,14 @@ import { useState } from "react"; import { useQuery } from "@tanstack/react-query"; -import { Box, Text } from "ink"; 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 { Spinner } from "../../../components/ui/spinner"; 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 { ProjectGate } from "../ProjectGate"; +import { LoadingFrame, ProjectGate } from "../ProjectGate"; import type { Project } from "../types"; import { declaresNothingDeployable, deployedMessage, teardownQuestion } from "./index"; @@ -63,21 +61,15 @@ function DeployTarget({ queryFn: () => core.projectManager.listTargets(project), }); - if (targets.isPending || targets.isError) { + if (targets.data === undefined) { return ( - - - {targets.isError ? ( - {(targets.error as Error).message} - ) : ( - - )} - - + query={targets} + loadingLabel="reading deployment targets…" + onBack={() => navigate(PROJECT_MENU)} + /> ); } @@ -159,7 +151,11 @@ function DeployConfirm({ { label: "project", value: project.name }, { label: "target", value: targetName }, ]} - message={teardown ? teardownQuestion(project.name, target) : undefined} + trigger={ + teardown + ? { kind: "confirm", message: teardownQuestion(project.name, target) } + : { kind: "immediate" } + } isPending={false} error={null} action={async function* () { diff --git a/src/handlers/project/invoke/screen.tsx b/src/handlers/project/invoke/screen.tsx index f3c13e4d0..07a509876 100644 --- a/src/handlers/project/invoke/screen.tsx +++ b/src/handlers/project/invoke/screen.tsx @@ -37,11 +37,11 @@ export function ProjectInvokePickerScreen({ ctx, core }: ScreenProps) { const navigate = useNavigate(); // The project comes from the launch context when a project command opened // the TUI, and is resolved from the cwd otherwise. - const { project, error: projectError } = useProject(core, ctx.value(ProjectKey)); + const { data: project, error: projectError } = useProject(core, ctx.value(ProjectKey)); const [deployed, setDeployed] = useState(); const [destination, setDestination] = useState(); const [deployedError, setDeployedError] = useState(); - const error = projectError ?? deployedError; + const error = projectError?.message ?? deployedError; useEffect(() => { if (!project) return; From ef7fb215a2260aa72f2acb7f3b908da3c456f90b Mon Sep 17 00:00:00 2001 From: gitikavj Date: Thu, 3 Sep 2026 16:46:51 +0000 Subject: [PATCH 6/6] fix(project): address build and deploy TUI review feedback --- src/components/ConfirmAction.test.tsx | 36 +++++++++ src/components/ConfirmAction.tsx | 79 ++++++++++--------- src/core/project/manager.tsx | 2 +- src/handlers/harness/delete/screen.tsx | 20 ++--- .../harness/endpoint/delete/screen.tsx | 20 ++--- src/handlers/project/ProjectGate.tsx | 1 + src/handlers/project/build/screen.tsx | 2 +- .../project/buildDeploy.screen.test.tsx | 60 +++++++++++++- src/handlers/project/deploy/screen.tsx | 10 +-- 9 files changed, 165 insertions(+), 65 deletions(-) create mode 100644 src/components/ConfirmAction.test.tsx 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 59a7b65e8..a9407e85c 100644 --- a/src/components/ConfirmAction.tsx +++ b/src/components/ConfirmAction.tsx @@ -11,12 +11,12 @@ import { driveProgress, type ProgressEvent } from "../tui/progress"; const theme = darkTheme; -export interface SummaryRow { - label: string; - value: string; -} +export type SummaryRows = Record; -export type ActionResult = SummaryRow[] | { title: string; rows: SummaryRow[] }; +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 @@ -32,7 +32,7 @@ export interface ConfirmActionProps { title?: string; // rows describe the resource the action applies to. With neither title nor // rows the overlay is omitted. - rows?: SummaryRow[]; + rows?: SummaryRows; trigger: ActionTrigger; // isPending / error reflect the summary fetch backing the overlay. isPending: boolean; @@ -59,14 +59,14 @@ export interface ConfirmActionProps { onCancel?: () => void; } -// "confirm" waits on the question; "idle" waits on the summary for an immediate -// trigger. Neither survives the first run. +// "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: "idle" } + | { kind: "waiting" } + | { kind: "confirm"; message: string } | { kind: "running" } - | { kind: "success"; title: string; 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 @@ -75,7 +75,7 @@ export function ConfirmAction({ breadcrumb, description, title, - rows = [], + rows = {}, trigger, isPending, error, @@ -89,14 +89,12 @@ export function ConfirmAction({ }: ConfirmActionProps) { const navigate = useNavigate(); const cancel = onCancel ?? (() => navigate(-1)); - const [phase, setPhase] = useState({ - kind: trigger.kind === "confirm" ? "confirm" : "idle", - }); + 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 { @@ -104,20 +102,25 @@ export function ConfirmAction({ const outcome = isProgressGenerator(result) ? await driveProgress(result, setTasks) : await result; - const { title, rows } = Array.isArray(outcome) - ? { title: successTitle, rows: outcome } - : outcome; - setPhase({ kind: "success", title, rows }); + 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, + }); } }; - // An immediate trigger runs once the summary is ready. + // 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 === "idle" && !isPending && !error) void run(); + 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]); + }, [phase.kind, isPending, error, trigger.kind]); const hints = phase.kind === "confirm" @@ -145,7 +148,7 @@ export function ConfirmAction({ ) : ( - {(title !== undefined || rows.length > 0) && ( + {(title !== undefined || Object.keys(rows).length > 0) && ( {title !== undefined && {title}} - {rows.length > 0 && } + {Object.keys(rows).length > 0 && } )} - {phase.kind === "confirm" && trigger.kind === "confirm" && ( + {phase.kind === "confirm" && ( run(phase.message)} onCancel={cancel} /> )} @@ -185,7 +188,11 @@ export function ConfirmAction({ // Without a question to return to, returning would run again. setPhase({ kind: "confirm" }) : cancel} + onBack={ + phase.retryMessage !== undefined + ? () => setPhase({ kind: "confirm", message: phase.retryMessage! }) + : cancel + } /> )} @@ -204,10 +211,6 @@ function isProgressGenerator( ); } -function toItems(rows: SummaryRow[]): Record { - return Object.fromEntries(rows.map((row) => [row.label, row.value])); -} - function SuccessBody({ title, rows, @@ -216,7 +219,7 @@ function SuccessBody({ doneLabel, }: { title: string; - rows: SummaryRow[]; + rows: SummaryRows; nextSteps?: string[]; onDone: () => void; doneLabel: string; @@ -230,9 +233,9 @@ function SuccessBody({ ✔ {title} - {rows.length > 0 && ( + {Object.keys(rows).length > 0 && ( - + )} {nextSteps !== undefined && nextSteps.length > 0 && ( diff --git a/src/core/project/manager.tsx b/src/core/project/manager.tsx index ce718c768..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); diff --git a/src/handlers/harness/delete/screen.tsx b/src/handlers/harness/delete/screen.tsx index 5041ad922..9d74bd921 100644 --- a/src/handlers/harness/delete/screen.tsx +++ b/src/handlers/harness/delete/screen.tsx @@ -39,11 +39,11 @@ 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 251c178c5..cabb5036b 100644 --- a/src/handlers/harness/endpoint/delete/screen.tsx +++ b/src/handlers/harness/endpoint/delete/screen.tsx @@ -57,11 +57,11 @@ function DeleteConfirm({ 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 }), diff --git a/src/handlers/project/build/screen.tsx b/src/handlers/project/build/screen.tsx index 5e8d40196..2257f0805 100644 --- a/src/handlers/project/build/screen.tsx +++ b/src/handlers/project/build/screen.tsx @@ -40,7 +40,7 @@ function BuildConfirm({ project, core }: { project: Project; core: ScreenProps[" error={null} action={async function* () { yield* core.projectManager.build(project); - return []; + return { rows: {} }; }} successTitle={builtMessage(project)} runningLabel="building…" diff --git a/src/handlers/project/buildDeploy.screen.test.tsx b/src/handlers/project/buildDeploy.screen.test.tsx index 4079c4a4d..052b153e9 100644 --- a/src/handlers/project/buildDeploy.screen.test.tsx +++ b/src/handlers/project/buildDeploy.screen.test.tsx @@ -1,4 +1,5 @@ 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"; @@ -224,7 +225,8 @@ describe("project deploy screen", () => { await r.write("r"); await waitForText(r.lastFrame, "✔ Deployed project 'orders' to target 'default'"); - expect(attempts).toBe(2); + // Loading retries once, then deploy itself re-reads through listTargets. + expect(attempts).toBe(3); r.unmount(); }); @@ -261,6 +263,62 @@ describe("project deploy screen", () => { 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({ diff --git a/src/handlers/project/deploy/screen.tsx b/src/handlers/project/deploy/screen.tsx index 8f1d1905a..8d3c55bcc 100644 --- a/src/handlers/project/deploy/screen.tsx +++ b/src/handlers/project/deploy/screen.tsx @@ -59,9 +59,10 @@ function DeployTarget({ const targets = useQuery({ queryKey: ["project-targets", project.rootPath], queryFn: () => core.projectManager.listTargets(project), + gcTime: 0, }); - if (targets.data === undefined) { + if (targets.data === undefined || targets.isFetching || targets.isError) { return (