diff --git a/src/components/Root.tsx b/src/components/Root.tsx index 2102251ef..2ca786968 100644 --- a/src/components/Root.tsx +++ b/src/components/Root.tsx @@ -256,7 +256,8 @@ function PinnedRegion({ ctx, core }: ScreenProps) { const DEV_PATH = ["agentcore", "dev"]; function RouteTable({ ctx, core }: ScreenProps) { - const inProject = ctx.value(ProjectDetectedKey) ?? ctx.value(ProjectKey) !== undefined; + const inProject = + ctx.value(ProjectDetectedKey) ?? (ctx.value(ProjectKey) !== undefined ? true : undefined); return ( diff --git a/src/components/RouterScreen.test.tsx b/src/components/RouterScreen.test.tsx index d0924928b..48ecc46d1 100644 --- a/src/components/RouterScreen.test.tsx +++ b/src/components/RouterScreen.test.tsx @@ -12,6 +12,7 @@ import { } from "../testing"; import { glyphs } from "./ui/_core"; import { createProjectHandlers } from "../handlers/project"; +import type { Project } from "../handlers/project/types"; afterEach(cleanupScreens); @@ -86,6 +87,20 @@ describe("menu rendering", () => { r.unmount(); }); + test("a direct root screen render does not resolve a project from the test cwd", async () => { + const core = new TestCoreClient(); + let resolveCalls = 0; + core.projectManager.resolve = async () => { + resolveCalls++; + throw new Error("unexpected filesystem project lookup"); + }; + + const r = renderScreen("/agentcore", { core }); + await waitForText(r.lastFrame, "❯ create"); + expect(resolveCalls).toBe(0); + r.unmount(); + }); + test("lists the resources alphabetically under a resources divider after the project commands", async () => { const r = renderScreen("/agentcore", { withContext: inProjectContext }); await waitForText(r.lastFrame, "❯ add"); @@ -313,11 +328,28 @@ describe("navigation", () => { await r.press("escape"); // Back at the root menu (breadcrumb no longer includes harness). - await waitForText(r.lastFrame, "the platform for production AI agents"); + await waitForText(r.lastFrame, "❯ create"); expect(r.lastFrame()).toContain("❯ create"); r.unmount(); }); + test("project detection waits until a nested launch navigates to the root menu", async () => { + const core = new TestCoreClient(); + let resolveCalls = 0; + core.projectManager.resolve = async () => { + resolveCalls++; + return {} as Project; + }; + const r = renderScreen("/agentcore/harness", { core }); + await waitForText(r.lastFrame, "agentcore → harness"); + expect(resolveCalls).toBe(0); + + await r.press("escape"); + await waitForText(r.lastFrame, "❯ add"); + expect(resolveCalls).toBe(1); + r.unmount(); + }); + test("esc at the root menu is a no-op (no parent to go to)", async () => { const r = renderScreen("/agentcore"); await waitForText(r.lastFrame, "❯ create"); diff --git a/src/handlers/project/ProjectGate.tsx b/src/handlers/project/ProjectGate.tsx index cd20b6afd..a401f4725 100644 --- a/src/handlers/project/ProjectGate.tsx +++ b/src/handlers/project/ProjectGate.tsx @@ -36,6 +36,19 @@ export function useProject(core: Core, seed?: Project): UseQueryResult }); } +export function useProjectDetected(core: Core, seed?: boolean): UseQueryResult { + const from = process.cwd(); + return useQuery({ + queryKey: ["project-detected", from], + queryFn: async () => + (await core.projectManager.resolve({ + filePath: from, + })) !== undefined, + gcTime: 0, + ...(seed !== undefined && { initialData: seed, staleTime: Infinity }), + }); +} + export function projectTargetsQueryKey(project: Project) { return ["project-targets", project.rootPath] as const; } @@ -56,7 +69,7 @@ export interface LoadingFrameProps { // query is whatever the screen is waiting on. query: Pick; loadingLabel: string; - onBack: () => void; + onBack?: () => void; } // LoadingFrame is the spinner-or-error a screen shows before its data arrives: @@ -69,7 +82,7 @@ export function LoadingFrame({ onBack, }: LoadingFrameProps) { useInput((input, key) => { - if (key.escape) onBack(); + if (key.escape) onBack?.(); if (query.isError && input === "r") void query.refetch(); }); @@ -79,7 +92,7 @@ export function LoadingFrame({ description={description} keyHints={[ ...(query.isError ? [{ key: "r", label: "retry" }] : []), - { key: "esc", label: "back" }, + ...(onBack ? [{ key: "esc", label: "back" }] : []), { key: "ctrl+c", label: "quit" }, ]} > diff --git a/src/handlers/runtime/invoke/invoke.screen.test.tsx b/src/handlers/runtime/invoke/invoke.screen.test.tsx index 13e792513..3e03e4b0e 100644 --- a/src/handlers/runtime/invoke/invoke.screen.test.tsx +++ b/src/handlers/runtime/invoke/invoke.screen.test.tsx @@ -112,6 +112,7 @@ describe("Runtime invoke routing", () => { .setGetResponse({ agentRuntimeArn: RUNTIME_ARN } as GetAgentRuntimeResponse); const screen = renderScreen("/agentcore", { core }); + await waitForText(screen.lastFrame, "type to choose a command"); await screen.write("runtime"); await screen.press("return"); await waitForText(screen.lastFrame, "agentcore → runtime → inspect AgentCore Runtimes"); diff --git a/src/handlers/screen.tsx b/src/handlers/screen.tsx index e9079d866..b120f3d33 100644 --- a/src/handlers/screen.tsx +++ b/src/handlers/screen.tsx @@ -2,9 +2,10 @@ import { Text, useApp } from "ink"; import { useEffect } from "react"; import { CommandKey } from "../router"; import { BrandBanner } from "../components/BrandBanner"; -import { RouterScreen } from "../components/RouterScreen"; +import { resolveCommand, RouterScreen } from "../components/RouterScreen"; import { glyphs } from "../components/ui/_core.js"; import type { ScreenProps } from "./types"; +import { LoadingFrame, useProjectDetected } from "./project/ProjectGate"; const NO_PROJECT_HINTS = { create: `${glyphs.leftArrow} start here` }; const NO_PROJECT_ALERT = "No project detected - create a new project to get started"; @@ -24,18 +25,31 @@ const PROJECT_REQUIRED_OPTIONS = [ const NO_PROJECT_HIDDEN_OPTIONS = [...PROJECT_REQUIRED_OPTIONS, "eval"]; export interface RootScreenProps extends ScreenProps { - inProject: boolean; + inProject?: boolean; } export function RootScreen({ inProject, ...props }: RootScreenProps) { + const projectDetected = useProjectDetected(props.core, inProject); + if (projectDetected.data === undefined) { + const command = resolveCommand(props.ctx.require(CommandKey), ["agentcore"]); + return ( + + ); + } + return ( } path={["agentcore"]} - optionHints={inProject ? undefined : NO_PROJECT_HINTS} - alert={inProject ? undefined : NO_PROJECT_ALERT} - hiddenOptions={inProject ? PROJECT_HIDDEN_OPTIONS : NO_PROJECT_HIDDEN_OPTIONS} + optionHints={projectDetected.data ? undefined : NO_PROJECT_HINTS} + alert={projectDetected.data ? undefined : NO_PROJECT_ALERT} + hiddenOptions={projectDetected.data ? PROJECT_HIDDEN_OPTIONS : NO_PROJECT_HIDDEN_OPTIONS} /> ); } diff --git a/src/testing/renderScreen.tsx b/src/testing/renderScreen.tsx index 555889281..589cd430c 100644 --- a/src/testing/renderScreen.tsx +++ b/src/testing/renderScreen.tsx @@ -12,6 +12,7 @@ import { tick, waitFor } from "./timing"; import { createSilentLogger } from "./logging"; import { TestGlobalConfigAccessor } from "./globalConfig"; import { DEFAULT_GLOBAL_CONFIG, type GlobalConfig } from "../globalConfig"; +import { ProjectDetectedKey } from "../handlers/project/context"; // TUI test harness. // @@ -146,8 +147,14 @@ export function cleanupScreens(): void { // and returns handles to read frames and send input. export function renderScreen(path: string, options: RenderScreenOptions = {}): RenderScreenResult { const core = options.core ?? new TestCoreClient(); - const base = + let base = options.ctx ?? baseContext(core, options.endpointUrl, options.platform, options.globalConfig); + const pathname = new URL(path, "http://localhost").pathname.replace(/\/+$/, ""); + if (options.ctx === undefined && pathname === "/agentcore") { + // Production resolves project presence before mounting an initial root menu. + // Seed the same default here so screen tests do not inspect the test process cwd. + base = base.withValue(ProjectDetectedKey, false); + } const ctx = options.withContext?.(base) ?? base; const queryClient = options.queryClient ?? testQueryClient(); diff --git a/src/tui/index.tsx b/src/tui/index.tsx index c5f9bc392..079bcd1f3 100644 --- a/src/tui/index.tsx +++ b/src/tui/index.tsx @@ -42,6 +42,11 @@ export interface JsonRenderer { // the withJsonRenderer middleware at the root; read by any leaf that emits JSON. export const JsonRendererKey: ContextKey = contextKey("json.renderer"); +function isRootMenuPath(path: string): boolean { + const pathname = new URL(path, "http://localhost").pathname.replace(/\/+$/, ""); + return pathname === "/agentcore"; +} + // renderTuiAt mounts the Ink React tree at an explicit route path and resolves // once the app exits. Handlers use it to deep-link into a TUI screen (e.g. // `invoke --id X --session-id Y` opens the chat at that harness and session). @@ -61,13 +66,16 @@ export async function renderTuiAt( let exitMessage: string | undefined; let handoff: string[] | undefined; - const projectDetected = - ctx.value(ProjectKey) !== undefined || - (await core.projectManager.resolve({ - filePath: process.cwd(), - })) !== undefined; - const tuiContext = ctx - .withValue(ProjectDetectedKey, projectDetected) + let tuiContext = ctx; + if (isRootMenuPath(path)) { + const projectDetected = + ctx.value(ProjectKey) !== undefined || + (await core.projectManager.resolve({ + filePath: process.cwd(), + })) !== undefined; + tuiContext = tuiContext.withValue(ProjectDetectedKey, projectDetected); + } + tuiContext = tuiContext .withValue(TuiExitMessageKey, (message) => { exitMessage = message; }) diff --git a/src/tui/tui.test.tsx b/src/tui/tui.test.tsx index 24c3bcc59..9d289ab61 100644 --- a/src/tui/tui.test.tsx +++ b/src/tui/tui.test.tsx @@ -233,6 +233,28 @@ describe("TUI launch", () => { const CREATE_ROW = "create a new AgentCore project"; const BANNER = "No project detected - create a new project to get started"; + test("an unrelated TUI command does not resolve the current project", async () => { + const core = new TestCoreClient(); + let resolveCalls = 0; + core.projectManager.resolve = async () => { + resolveCalls++; + throw new Error("malformed agentcore.json"); + }; + const { streams, stdin } = ttyTestIO(); + const root = createRootHandler(core, { + io: streams.io, + logger: createSilentLogger(), + globalConfigAccessor: new TestGlobalConfigAccessor(), + }); + + const routePromise = root.route(["node", "agentcore", "runtime"]); + await waitFor(() => streams.stdout().includes("inspect AgentCore Runtimes")); + expect(resolveCalls).toBe(0); + + stdin.write(String.fromCharCode(3)); + await expect(routePromise).resolves.toBeUndefined(); + }); + async function launchRootMenu(project: Project | undefined): Promise { const core = new TestCoreClient(); core.projectManager.resolve = async () => project;