Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/components/Root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<Routes>
Expand Down
34 changes: 33 additions & 1 deletion src/components/RouterScreen.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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");
Expand Down
19 changes: 16 additions & 3 deletions src/handlers/project/ProjectGate.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,19 @@ export function useProject(core: Core, seed?: Project): UseQueryResult<Project>
});
}

export function useProjectDetected(core: Core, seed?: boolean): UseQueryResult<boolean> {
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;
}
Expand All @@ -56,7 +69,7 @@ export interface LoadingFrameProps {
// query is whatever the screen is waiting on.
query: Pick<UseQueryResult, "isError" | "error" | "refetch">;
loadingLabel: string;
onBack: () => void;
onBack?: () => void;
}

// LoadingFrame is the spinner-or-error a screen shows before its data arrives:
Expand All @@ -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();
});

Expand All @@ -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" },
]}
>
Expand Down
1 change: 1 addition & 0 deletions src/handlers/runtime/invoke/invoke.screen.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
24 changes: 19 additions & 5 deletions src/handlers/screen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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 (
<LoadingFrame
breadcrumb={["agentcore"]}
description={command.description()}
query={projectDetected}
loadingLabel="checking for a project…"
/>
);
}

return (
<RouterScreen
{...props}
banner={<BrandBanner />}
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}
/>
);
}
Expand Down
9 changes: 8 additions & 1 deletion src/testing/renderScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
//
Expand Down Expand Up @@ -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();

Expand Down
22 changes: 15 additions & 7 deletions src/tui/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,11 @@ export interface JsonRenderer {
// the withJsonRenderer middleware at the root; read by any leaf that emits JSON.
export const JsonRendererKey: ContextKey<JsonRenderer> = contextKey<JsonRenderer>("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).
Expand All @@ -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;
})
Expand Down
22 changes: 22 additions & 0 deletions src/tui/tui.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> {
const core = new TestCoreClient();
core.projectManager.resolve = async () => project;
Expand Down
Loading