diff --git a/src/components/CliOnlyScreen.test.tsx b/src/components/CliOnlyScreen.test.tsx index 4e53f47489..c72e90372e 100644 --- a/src/components/CliOnlyScreen.test.tsx +++ b/src/components/CliOnlyScreen.test.tsx @@ -7,6 +7,7 @@ import { menuEntries, renderScreen, waitForText, + inProjectContext, } from "../testing"; import { CommandKey, isTuiCommandSupported } from "../router"; @@ -38,12 +39,11 @@ const CLI_ONLY = cliOnlyCommands(); describe("menus list command-line-only subcommands below a divider", () => { test("the root menu", async () => { - const r = renderScreen("/agentcore"); + const r = renderScreen("/agentcore", { withContext: inProjectContext }); - await waitForText(r.lastFrame, "── cli "); + await waitForText(r.lastFrame, "❯ add"); expect(menuEntries(r.lastFrame()!)).toEqual({ screens: [ - "create", "add", "remove", "dev", @@ -124,7 +124,7 @@ describe("every command-line-only command opens on screen", () => { test.each(CLI_ONLY.map(([path, command]) => [path.join(" "), path, command] as const))( "%s opens its menu or help, and esc returns to the parent", async (_label, path, command) => { - const r = renderScreen("/" + path.join("/")); + const r = renderScreen("/" + path.join("/"), { withContext: inProjectContext }); // Wide and tall enough that no option term wraps and nothing is below the // fold; scrolling and wrapping have their own tests. await r.resize(220, 200); @@ -291,7 +291,7 @@ describe("option help groups", () => { ); root.addCommand(command); const r = renderScreen("/agentcore/scroll-probe", { - withContext: (ctx) => ctx.withValue(CommandKey, root), + withContext: (ctx) => inProjectContext(ctx.withValue(CommandKey, root)), }); await r.resize(80, 16); diff --git a/src/components/Root.tsx b/src/components/Root.tsx index 927c86ac79..f97d60c73c 100644 --- a/src/components/Root.tsx +++ b/src/components/Root.tsx @@ -7,7 +7,7 @@ import { useLocation, useNavigationType, } from "react-router"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { type QueryClient, QueryClientProvider } from "@tanstack/react-query"; import type { Core, ScreenProps } from "../handlers/types.tsx"; import { HarnessScreen } from "../handlers/harness/screen.tsx"; import { HarnessGetScreen, HarnessGetJsonScreen } from "../handlers/harness/get/screen.tsx"; @@ -142,10 +142,11 @@ import { AddPaymentManagerScreen } from "../handlers/project/add/payment-manager import { AddPaymentConnectorScreen } from "../handlers/project/add/payment-connector/screen.tsx"; import { ProjectStatusScreen } from "../handlers/project/status/screen.tsx"; import { ProjectRemoveScreen } from "../handlers/project/remove/screen.tsx"; +import { ProjectDetectedKey } from "../handlers/project/context.ts"; import { HelpScreen, RootScreen } from "../handlers/screen.tsx"; import { RegionKey } from "../handlers/keys.tsx"; import { RegionPinContext } from "../handlers/utils.tsx"; -import type { Context } from "../router"; +import { ProjectKey, type Context } from "../router"; export interface RootProps { // path is the command path to the executing node (e.g. "/agentcore"). @@ -155,25 +156,17 @@ export interface RootProps { ctx: Context; - // queryClient is an optional override for the react-query client. Production - // leaves it unset (a stable one is created per mount); tests inject one — e.g. - // with retries disabled — to keep behavior deterministic and fast. - queryClient?: QueryClient; + queryClient: QueryClient; } // Root is the top of the Ink React tree, rendered by the `agentcore` default // handler when the CLI is invoked without a subcommand: the MemoryRouter over // the app's routes plus the react-query client every screen fetches through. export function Root({ path, ctx, core, queryClient }: RootProps) { - // Create the QueryClient once per mount; a lazy initializer keeps it stable - // across re-renders (a fresh client would drop the cache and refetch). An - // injected client (tests) takes precedence. - const [defaultQueryClient] = useState(() => new QueryClient()); const [launchSessionConsumed, setLaunchSessionConsumed] = useState(false); - const client = queryClient ?? defaultQueryClient; return ( - + - } /> + } + /> {/* `dev` owns the terminal, so selecting it closes the TUI and runs it. */} { test("lists the current command's subcommands with their descriptions", async () => { const r = renderScreen("/agentcore"); - await waitForText(r.lastFrame, "type to choose a command"); + await waitForText(r.lastFrame, "No project detected"); const frame = r.lastFrame()!; const entries = menuEntries(frame); expect(entries.screens).toContain("create"); - expect(entries.screens).toContain("eval"); + expect(entries.screens).not.toContain("eval"); + const visible = [...entries.screens, ...entries.cliOnly]; + const projectCommands = createProjectHandlers(r.core, testIO().io) + .slice(1) + .map((handler) => handler.name()); + for (const command of projectCommands) expect(visible).not.toContain(command); expect(frame.split("\n").filter((line) => line.includes("❯ "))).toHaveLength(1); expect(new Set([...entries.screens, ...entries.cliOnly]).size).toBe( entries.screens.length + entries.cliOnly.length, @@ -71,11 +87,11 @@ describe("menu rendering", () => { }); test("lists the resources alphabetically under a resources divider after the project commands", async () => { - const r = renderScreen("/agentcore"); - await waitForText(r.lastFrame, "── resources"); + const r = renderScreen("/agentcore", { withContext: inProjectContext }); + await waitForText(r.lastFrame, "❯ add"); expect(menuGroups(r.lastFrame()!)).toEqual([ - { title: undefined, names: PROJECT_WORKFLOW }, + { title: undefined, names: PROJECT_WORKFLOW.slice(1) }, { title: "resources", names: ["eval", "gateway", "harness", "identity", "memory", "payment", "runtime"], @@ -109,7 +125,7 @@ describe("menu rendering", () => { }); test("filtering to the project workflow leaves no resources divider", async () => { - const r = renderScreen("/agentcore"); + const r = renderScreen("/agentcore", { withContext: inProjectContext }); await waitForText(r.lastFrame, "type to choose a command"); await r.write("dep"); @@ -171,6 +187,42 @@ describe("menu rendering", () => { expect(r.lastFrame()).toContain("❯ create"); r.unmount(); }); + + test("marks create as the starting point without a project and hides it inside one", async () => { + const startHere = `${glyphs.leftArrow} start here`; + + const noProjectCore = new TestCoreClient(); + noProjectCore.projectManager.resolve = async () => undefined; + const withoutProject = renderScreen("/agentcore", { core: noProjectCore }); + await waitForText(withoutProject.lastFrame, startHere); + expect(withoutProject.lastFrame()).toContain("create"); + withoutProject.unmount(); + + const withProject = renderScreen("/agentcore", { withContext: inProjectContext }); + await waitForText(withProject.lastFrame, "❯ add"); + expect(menuEntries(withProject.lastFrame()!).screens).not.toContain("create"); + withProject.unmount(); + }); + + test("shows the no-project banner only when no enclosing project is detected", async () => { + const banner = "No project detected - create a new project to get started"; + + const noProjectCore = new TestCoreClient(); + noProjectCore.projectManager.resolve = async () => undefined; + const withoutProject = renderScreen("/agentcore", { core: noProjectCore }); + await waitForText(withoutProject.lastFrame, banner); + const frame = withoutProject.lastFrame()!; + expect(frame).toContain(`${glyphs.info} ${banner}`); + expect(frame.indexOf("type to choose a command")).toBeLessThan(frame.indexOf(banner)); + expect(frame.indexOf(banner)).toBeLessThan(frame.indexOf("create")); + withoutProject.unmount(); + + const withProject = renderScreen("/agentcore", { withContext: inProjectContext }); + await waitForText(withProject.lastFrame, "type to choose a command"); + await tick(20); + expect(withProject.lastFrame()).not.toContain(banner); + withProject.unmount(); + }); }); describe("filtering", () => { @@ -209,10 +261,7 @@ describe("filtering", () => { describe("navigation", () => { test("down arrow moves the highlight to the next option", async () => { - const r = renderScreen("/agentcore"); - await waitForText(r.lastFrame, "❯ create"); - - await r.press("down"); + const r = renderScreen("/agentcore", { withContext: inProjectContext }); await waitForText(r.lastFrame, "❯ add"); await r.press("down"); @@ -279,8 +328,8 @@ describe("short terminals", () => { // fullMenu renders the root menu at the default height, where every option fits. async function fullMenu() { - const r = renderScreen("/agentcore"); - await waitForText(r.lastFrame, "── cli"); + const r = renderScreen("/agentcore", { withContext: inProjectContext }); + await waitForText(r.lastFrame, "❯ add"); const frame = r.lastFrame()!; r.unmount(); const titles = new Map(); @@ -306,8 +355,8 @@ describe("short terminals", () => { } async function shortMenu() { - const r = renderScreen("/agentcore"); - await waitForText(r.lastFrame, "❯ create"); + const r = renderScreen("/agentcore", { withContext: inProjectContext }); + await waitForText(r.lastFrame, "❯ add"); await r.resize(100, ROWS); return r; } @@ -368,7 +417,7 @@ describe("short terminals", () => { await r.press("up"); expectConsistentWindow(r.lastFrame()!, full); } - await waitForText(r.lastFrame, "❯ create"); + await waitForText(r.lastFrame, "❯ add"); frame = r.lastFrame()!; expect(more(frame, "↑")).toBeUndefined(); expect(more(frame, "↓")).toBeGreaterThan(0); @@ -417,8 +466,8 @@ describe("short terminals", () => { test("resizing shorter keeps the highlight visible", async () => { const full = await fullMenu(); - const r = renderScreen("/agentcore"); - await waitForText(r.lastFrame, "❯ create"); + const r = renderScreen("/agentcore", { withContext: inProjectContext }); + await waitForText(r.lastFrame, "❯ add"); const target = full.names.indexOf("harness"); for (let i = 0; i < target; i++) await r.press("down"); @@ -435,8 +484,8 @@ describe("short terminals", () => { test("accounts for a wrapped header when scrolling a narrow terminal", async () => { const full = await fullMenu(); - const r = renderScreen("/agentcore"); - await waitForText(r.lastFrame, "❯ create"); + const r = renderScreen("/agentcore", { withContext: inProjectContext }); + await waitForText(r.lastFrame, "❯ add"); await r.resize(40, ROWS); for (let i = 0; i < full.names.length; i++) { diff --git a/src/components/RouterScreen.tsx b/src/components/RouterScreen.tsx index 614567abd0..0c7805322d 100644 --- a/src/components/RouterScreen.tsx +++ b/src/components/RouterScreen.tsx @@ -2,6 +2,7 @@ import React, { useContext, useEffect, useMemo, useState } from "react"; import { Box, Text, useApp, useInput, useStdin } from "ink"; import type { Command } from "commander"; import { Navigate, useNavigate } from "react-router"; +import stringWidth from "string-width"; import { CommandKey, commandMenuSectionStart, @@ -15,6 +16,7 @@ import { shouldHideBrandBanner, } from "./BrandBanner"; import { Layout } from "./Layout"; +import { Alert } from "./ui/alert"; import { Divider } from "./ui/divider"; import { TextInput } from "./ui/text-input"; import { darkTheme, glyphs } from "./ui/_core.js"; @@ -58,6 +60,7 @@ export function commandPath(command: Command): string[] { interface Option { name: string; description: string; + hint?: string; // cliOnly marks a subcommand without a screen; it is listed under a divider // and opens its help instead. cliOnly: boolean; @@ -80,6 +83,11 @@ export interface RouterScreenProps extends ScreenProps { // tuiOnlyCommands are navigable informational flows that intentionally do // not exist in the CLI command tree. tuiOnlyCommands?: TuiOnlyCommand[]; + // optionHints adds short contextual guidance alongside selected menu options. + optionHints?: Readonly>; + // alert is optional guidance rendered between the filter and menu options. + alert?: string; + hiddenOptions?: readonly string[]; } // RouterScreen renders the interactive command menu for a Router node: a filter @@ -101,6 +109,9 @@ function CommandMenu({ banner, path, tuiOnlyCommands = [], + optionHints, + alert, + hiddenOptions, command, }: RouterScreenProps & { command: Command }) { const navigate = useNavigate(); @@ -131,6 +142,7 @@ function CommandMenu({ return { name: c.name(), description: c.description(), + hint: optionHints?.[c.name()], cliOnly, section: cliOnly ? CLI_ONLY_SECTION : sectionOf(index), }; @@ -138,13 +150,13 @@ function CommandMenu({ const actualNames = new Set(actual.map((option) => option.name)); const tuiOnly = tuiOnlyCommands .filter((option) => !actualNames.has(option.name)) - .map((option) => ({ ...option, cliOnly: false })); + .map((option): Option => ({ ...option, hint: optionHints?.[option.name], cliOnly: false })); return [ ...tuiOnly, ...actual.filter((option) => !option.cliOnly), ...actual.filter((option) => option.cliOnly), - ]; - }, [command, tuiOnlyCommands]); + ].filter((option) => !hiddenOptions?.includes(option.name)); + }, [command, optionHints, hiddenOptions, tuiOnlyCommands]); const [query, setQuery] = useState(""); const [index, setIndex] = useState(0); @@ -221,6 +233,7 @@ function CommandMenu({ > {({ columns, contentRows }) => ( void; } +function alertRows(alert: string | undefined, columns: number): number { + if (!alert) return 0; + // Two border/padding columns sit on each side of the alert content. + const contentColumns = Math.max(1, columns - 4); + return 2 + Math.ceil(stringWidth(`${glyphs.info} ${alert}`) / contentColumns); +} + function CommandMenuBody({ + alert, columns, contentRows, filtered, @@ -260,7 +282,7 @@ function CommandMenuBody({ onQueryChange, }: CommandMenuBodyProps) { const sections = useMemo(() => filtered.map((option) => option.section), [filtered]); - const menuHeight = Math.max(0, contentRows - FILTER_ROWS); + const menuHeight = Math.max(0, contentRows - FILTER_ROWS - alertRows(alert, columns)); const windowStart = Math.max(0, highlight - Math.floor(menuHeight / 2)); const view = scrollWindow({ sections, @@ -283,6 +305,8 @@ function CommandMenuBody({ + {alert && {alert}} + {filtered.length === 0 ? ( @@ -330,6 +354,7 @@ function CommandMenuBody({ {option.name.padEnd(nameWidth)} {option.description} + {option.hint && {option.hint}} ); }) diff --git a/src/components/ui/_core.ts b/src/components/ui/_core.ts index f976e9b1f0..a4d403895b 100644 --- a/src/components/ui/_core.ts +++ b/src/components/ui/_core.ts @@ -111,6 +111,7 @@ const unicode = unicodeSupported(process.env, process.platform); const unicodeGlyphs = { pointer: "❯", + leftArrow: "←", check: "✔", cross: "✗", done: "✓", @@ -128,6 +129,7 @@ const unicodeGlyphs = { const asciiGlyphs: typeof unicodeGlyphs = { pointer: ">", + leftArrow: "<-", check: "*", cross: "x", done: "*", diff --git a/src/components/ui/alert/Alert.tsx b/src/components/ui/alert/Alert.tsx new file mode 100644 index 0000000000..f5f918c440 --- /dev/null +++ b/src/components/ui/alert/Alert.tsx @@ -0,0 +1,25 @@ +import React from "react"; +import { Box, Text } from "ink"; +import { darkTheme, glyphs, type InkUITheme } from "../_core.js"; + +export interface AlertProps { + children: React.ReactNode; + theme?: InkUITheme; +} + +/** + * Compact informational callout for inline TUI guidance. + */ +export function Alert({ children, theme = darkTheme }: AlertProps) { + return ( + + {glyphs.info} + {children} + + ); +} diff --git a/src/components/ui/alert/index.ts b/src/components/ui/alert/index.ts new file mode 100644 index 0000000000..a46aa905d0 --- /dev/null +++ b/src/components/ui/alert/index.ts @@ -0,0 +1 @@ +export { Alert, type AlertProps } from "./Alert"; diff --git a/src/handlers/project/add/add.screen.test.tsx b/src/handlers/project/add/add.screen.test.tsx index 9df953028a..1fcd2f68a2 100644 --- a/src/handlers/project/add/add.screen.test.tsx +++ b/src/handlers/project/add/add.screen.test.tsx @@ -5,6 +5,7 @@ import { cleanupScreens, compiledRootCommand, menuEntries, + inProjectContext, } from "../../../testing"; afterEach(cleanupScreens); @@ -61,7 +62,7 @@ describe("project add menu", () => { }); test("is reachable from the root menu", async () => { - const r = renderScreen("/agentcore"); + const r = renderScreen("/agentcore", { withContext: inProjectContext }); await waitForText(r.lastFrame, "the platform for production AI agents"); await r.write("add"); diff --git a/src/handlers/project/buildDeploy.screen.test.tsx b/src/handlers/project/buildDeploy.screen.test.tsx index 5931542ea0..c740f13f93 100644 --- a/src/handlers/project/buildDeploy.screen.test.tsx +++ b/src/handlers/project/buildDeploy.screen.test.tsx @@ -7,6 +7,7 @@ import { cleanupScreens, flatFrame, initProject, + inProjectContext, inTempDirectory, renderScreen, TestCoreClient, @@ -245,7 +246,11 @@ describe("project deploy screen", () => { queries: { retry: false, gcTime: Infinity, staleTime: 0 }, }, }); - const r = renderScreen("/agentcore/deploy", { core, queryClient }); + const r = renderScreen("/agentcore/deploy", { + core, + queryClient, + withContext: inProjectContext, + }); await waitForText(r.lastFrame, "✔ Deployed project 'orders' to target 'default'"); expect(deploys).toHaveLength(1); @@ -273,7 +278,11 @@ describe("project deploy screen", () => { queries: { retry: false, gcTime: Infinity, staleTime: 0 }, }, }); - const r = renderScreen("/agentcore/deploy", { core, queryClient }); + const r = renderScreen("/agentcore/deploy", { + core, + queryClient, + withContext: inProjectContext, + }); await waitForText(r.lastFrame, "✔ Deployed project 'orders' to target 'default'"); expect(deploys).toHaveLength(1); diff --git a/src/handlers/project/context.ts b/src/handlers/project/context.ts new file mode 100644 index 0000000000..8d6a5b6c05 --- /dev/null +++ b/src/handlers/project/context.ts @@ -0,0 +1,5 @@ +import { contextKey } from "../../router"; + +// ProjectDetectedKey records whether the TUI was launched from inside a project +// without pinning a Project object that could become stale while the TUI is open. +export const ProjectDetectedKey = contextKey("project.detected"); diff --git a/src/handlers/project/status/status.screen.test.tsx b/src/handlers/project/status/status.screen.test.tsx index b8bbffdc2d..5ffb92b7e1 100644 --- a/src/handlers/project/status/status.screen.test.tsx +++ b/src/handlers/project/status/status.screen.test.tsx @@ -306,6 +306,7 @@ describe("project status screen", () => { test("several targets: asks which, keeps the choice and its region across a detail page, esc returns to the choice, and a menu unpins", async () => { const value = core(RUNTIME_RESOURCES, [TARGET, STAGING]); + value.projectManager.resolve = async () => RUNTIME_PROJECT; const screen = renderStatus(value); await waitForText(screen.lastFrame, "choose a deployment target"); diff --git a/src/handlers/screen.tsx b/src/handlers/screen.tsx index 420171cb4f..e9079d8668 100644 --- a/src/handlers/screen.tsx +++ b/src/handlers/screen.tsx @@ -3,10 +3,41 @@ import { useEffect } from "react"; import { CommandKey } from "../router"; import { BrandBanner } from "../components/BrandBanner"; import { RouterScreen } from "../components/RouterScreen"; +import { glyphs } from "../components/ui/_core.js"; import type { ScreenProps } from "./types"; -export function RootScreen(props: ScreenProps) { - return } path={["agentcore"]} />; +const NO_PROJECT_HINTS = { create: `${glyphs.leftArrow} start here` }; +const NO_PROJECT_ALERT = "No project detected - create a new project to get started"; +const PROJECT_HIDDEN_OPTIONS = ["create"]; +const PROJECT_REQUIRED_OPTIONS = [ + "add", + "remove", + "dev", + "build", + "deploy", + "status", + "invoke", + "log", + "traces", + "export", +]; +const NO_PROJECT_HIDDEN_OPTIONS = [...PROJECT_REQUIRED_OPTIONS, "eval"]; + +export interface RootScreenProps extends ScreenProps { + inProject: boolean; +} + +export function RootScreen({ inProject, ...props }: RootScreenProps) { + return ( + } + path={["agentcore"]} + optionHints={inProject ? undefined : NO_PROJECT_HINTS} + alert={inProject ? undefined : NO_PROJECT_ALERT} + hiddenOptions={inProject ? PROJECT_HIDDEN_OPTIONS : NO_PROJECT_HIDDEN_OPTIONS} + /> + ); } // HelpScreen is the final safety net for a route that does not resolve to an diff --git a/src/testing/index.tsx b/src/testing/index.tsx index 3ec0dbb448..f0596d7519 100644 --- a/src/testing/index.tsx +++ b/src/testing/index.tsx @@ -36,5 +36,11 @@ export { export { createSilentLogger, assertLogsMatch, type LogQuery } from "./logging"; export { TestGlobalConfigAccessor } from "./globalConfig"; export { inTempDirectory, type TempDirectory } from "./fs"; -export { initProject, type InitProjectOptions, type InitializedProject } from "./projects"; +export { + initProject, + inProjectContext, + inProjectCore, + type InitProjectOptions, + type InitializedProject, +} from "./projects"; export { expectError } from "./errors"; diff --git a/src/testing/projects.ts b/src/testing/projects.ts index 8857cd4f74..683c6baac6 100644 --- a/src/testing/projects.ts +++ b/src/testing/projects.ts @@ -1,6 +1,9 @@ import { join } from "node:path"; import { createRootHandler, type RootHandlerConfig } from "../handlers"; import type { Core } from "../handlers/types"; +import type { Project } from "../handlers/project/types"; +import { ProjectDetectedKey } from "../handlers/project/context"; +import type { Context } from "../router"; import { inTempDirectory } from "./fs"; import { createSilentLogger } from "./logging"; import { testIO } from "./testIO"; @@ -60,3 +63,13 @@ export async function initProject(options: InitProjectOptions = {}): Promise ({}) as Project; + return core; +} + +export function inProjectContext(ctx: Context): Context { + return ctx.withValue(ProjectDetectedKey, true); +} diff --git a/src/tui/index.tsx b/src/tui/index.tsx index 726f25fca2..c5f9bc392b 100644 --- a/src/tui/index.tsx +++ b/src/tui/index.tsx @@ -1,4 +1,5 @@ import { render } from "ink"; +import { QueryClient } from "@tanstack/react-query"; import { Root } from "../components/Root"; import { type Context, @@ -8,10 +9,12 @@ import { contextKey, type ContextKey, CommandRunMetricEventKey, + ProjectKey, } from "../router"; import type { AppIO } from "../io"; import type { Core } from "../handlers/types"; import { JsonKey } from "../handlers/keys"; +import { ProjectDetectedKey } from "../handlers/project/context"; import { ExitCode, InvalidEnvironmentError } from "../errors"; import { TuiExitMessageKey } from "./exitMessage"; import { createResizeGate } from "./resize"; @@ -58,7 +61,13 @@ 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) .withValue(TuiExitMessageKey, (message) => { exitMessage = message; }) @@ -68,15 +77,20 @@ export async function renderTuiAt( // alternateScreen switches the terminal to its alternate buffer so the TUI // takes over the screen and the prior scrollback is restored on exit (like Vim). + const queryClient = new QueryClient(); + const resizeGate = createResizeGate(io.stdout); - const { waitUntilExit } = render(, { - stdin: io.stdin, - stdout: resizeGate.stdout, - stderr: io.stderr, - interactive: true, - alternateScreen: true, - incrementalRendering: false, - }); + const { waitUntilExit } = render( + , + { + stdin: io.stdin, + stdout: resizeGate.stdout, + stderr: io.stderr, + interactive: true, + alternateScreen: true, + incrementalRendering: false, + }, + ); try { await waitUntilExit(); } finally { diff --git a/src/tui/tui.test.tsx b/src/tui/tui.test.tsx index 30b946434d..24c3bcc59b 100644 --- a/src/tui/tui.test.tsx +++ b/src/tui/tui.test.tsx @@ -1,12 +1,13 @@ import { test, expect, describe } from "bun:test"; import { Command } from "commander"; import { createRootHandler } from "../handlers"; -import { ExitCode, InvalidEnvironmentError, ProjectStateError } from "../errors"; +import { ExitCode, InputValidationError, InvalidEnvironmentError } from "../errors"; import { renderJson } from "./index"; import { handoffArgs } from "./handoff"; +import type { Project } from "../handlers/project/types"; import { createSilentLogger, - inTempDirectory, + inProjectCore, TestCoreClient, TestGlobalConfigAccessor, testIO, @@ -119,28 +120,30 @@ describe("TUI stream boundary", () => { describe("TUI handoff", () => { test("selecting dev closes the TUI and runs the dev command", async () => { - const { cleanup } = await inTempDirectory(); - try { - const { streams, stdin } = ttyTestIO(); - const root = createRootHandler(new TestCoreClient(), { - io: streams.io, - logger: createSilentLogger(), - globalConfigAccessor: new TestGlobalConfigAccessor(), - }); - const routePromise = root.route(["node", "agentcore", "--region", "us-west-2"]); - await waitFor(() => streams.stdout().includes("type to choose a command")); - - stdin.write("dev"); - await waitFor(() => streams.stdout().includes("❯ dev")); - stdin.write("\r"); - - // Outside a project, dev's own project check is what fails: proof the - // TUI handed off to the dev command rather than showing its help. - const error = await routePromise.catch((error) => error); - expect(error).toBeInstanceOf(ProjectStateError); - } finally { - await cleanup(); - } + const core = new TestCoreClient(); + core.projectManager.resolve = async () => + ({ + name: "test-project", + rootPath: process.cwd(), + spec: { runtimes: [] } as unknown as Project["spec"], + }) as Project; + const { streams, stdin } = ttyTestIO(); + const root = createRootHandler(core, { + io: streams.io, + logger: createSilentLogger(), + globalConfigAccessor: new TestGlobalConfigAccessor(), + }); + const routePromise = root.route(["node", "agentcore", "--region", "us-west-2"]); + await waitFor(() => streams.stdout().includes("type to choose a command")); + + stdin.write("dev"); + await waitFor(() => streams.stdout().includes("❯ dev")); + stdin.write("\r"); + + // The project has no runtimes, so dev's own validation fails: proof the + // TUI handed off to the dev command rather than showing its help. + const error = await routePromise.catch((error) => error); + expect(error).toBeInstanceOf(InputValidationError); }); test("carries over global flags given on the command line", async () => { @@ -167,7 +170,7 @@ describe("TUI resize", () => { test("shrinking redraws once after resizing settles", async () => { const { streams, stdin } = ttyTestIO(120, 40); - const root = createRootHandler(new TestCoreClient(), { + const root = createRootHandler(inProjectCore(), { io: streams.io, logger: createSilentLogger(), globalConfigAccessor: new TestGlobalConfigAccessor(), @@ -201,7 +204,7 @@ describe("TUI resize", () => { test("repaints when a shrink gesture returns to the cached size", async () => { const { streams, stdin } = ttyTestIO(100, 40); - const root = createRootHandler(new TestCoreClient(), { + const root = createRootHandler(inProjectCore(), { io: streams.io, logger: createSilentLogger(), globalConfigAccessor: new TestGlobalConfigAccessor(), @@ -225,3 +228,50 @@ describe("TUI resize", () => { await expect(routePromise).resolves.toBeUndefined(); }); }); + +describe("TUI launch", () => { + const CREATE_ROW = "create a new AgentCore project"; + const BANNER = "No project detected - create a new project to get started"; + + async function launchRootMenu(project: Project | undefined): Promise { + const core = new TestCoreClient(); + core.projectManager.resolve = async () => project; + const { streams, stdin } = ttyTestIO(); + const root = createRootHandler(core, { + io: streams.io, + logger: createSilentLogger(), + globalConfigAccessor: new TestGlobalConfigAccessor(), + }); + const routePromise = root.route(["node", "agentcore"]); + await waitFor(() => streams.stdout().includes("read/write global config values")); + stdin.write(String.fromCharCode(3)); + await routePromise; + return streams.stdout(); + } + + test("outside a project the first frame shows create under the banner and hides project commands and eval", async () => { + const out = await launchRootMenu(undefined); + expect(out.indexOf(BANNER)).toBeGreaterThan(-1); + expect(out.indexOf(BANNER)).toBeLessThan(out.indexOf(CREATE_ROW)); + for (const hidden of [ + "add project resources", + "deploy the project to AWS", + "evaluate and optimize", + ]) { + expect(out).not.toContain(hidden); + } + }); + + test("inside a project create is never drawn and the rest of the menu is", async () => { + const out = await launchRootMenu({} as Project); + expect(out).not.toContain(CREATE_ROW); + expect(out).not.toContain(BANNER); + for (const shown of [ + "add project resources", + "deploy the project to AWS", + "evaluate and optimize", + ]) { + expect(out).toContain(shown); + } + }); +});