diff --git a/src/components/CliOnlyScreen.test.tsx b/src/components/CliOnlyScreen.test.tsx new file mode 100644 index 000000000..279893fd2 --- /dev/null +++ b/src/components/CliOnlyScreen.test.tsx @@ -0,0 +1,155 @@ +import { test, expect, describe, afterEach } from "bun:test"; +import type { Command } from "commander"; +import { + renderScreen, + waitForText, + cleanupScreens, + createSilentLogger, + menuEntries, + TestCoreClient, + TestGlobalConfigAccessor, + testIO, +} from "../testing"; +import { compile, isTuiCommandSupported, ValueContext } from "../router"; +import { createRootHandler } from "../handlers"; + +afterEach(cleanupScreens); + +function compiledRoot(): Command { + return compile( + createRootHandler(new TestCoreClient(), { + io: testIO().io, + logger: createSilentLogger(), + globalConfigAccessor: new TestGlobalConfigAccessor(), + }), + ValueContext.EmptyContext(), + ); +} + +// cliOnlyCommands walks the compiled Commander tree for every command without +// a screen, so a command added later is covered without a new test. `help` is +// Commander's own, not one of ours. +function cliOnlyCommands(command = compiledRoot(), path: string[] = []): [string[], Command][] { + const here = [...path, command.name()]; + const own: [string[], Command][] = isTuiCommandSupported(command) ? [] : [[here, command]]; + return [ + ...own, + ...command.commands + .filter((child) => child.name() !== "help") + .flatMap((child) => cliOnlyCommands(child, here)), + ]; +} + +const CLI_ONLY = cliOnlyCommands(); + +describe("menus list command-line-only subcommands below a divider", () => { + test("the root menu", async () => { + const r = renderScreen("/agentcore"); + + await waitForText(r.lastFrame, "command line only"); + expect(menuEntries(r.lastFrame()!)).toEqual({ + screens: ["harness", "identity", "runtime", "memory", "gateway", "eval", "project"], + cliOnly: ["feedback", "config", "update"], + }); + r.unmount(); + }); + + test("the eval menu", async () => { + const r = renderScreen("/agentcore/eval"); + + await waitForText(r.lastFrame, "command line only"); + expect(menuEntries(r.lastFrame()!).cliOnly).toEqual(["ondemand", "recommendation"]); + r.unmount(); + }); + + test("a menu whose every subcommand is command line only", async () => { + const r = renderScreen("/agentcore/eval/recommendation"); + + await waitForText(r.lastFrame, "command line only"); + expect(menuEntries(r.lastFrame()!)).toEqual({ + screens: [], + cliOnly: ["start", "get", "list", "delete"], + }); + r.unmount(); + }); + + test("the divider is omitted when nothing is command line only", async () => { + const r = renderScreen("/agentcore/harness"); + + await waitForText(r.lastFrame, "manage agentcore harnesses"); + expect(r.lastFrame()).not.toContain("command line only"); + r.unmount(); + }); +}); + +describe("every command-line-only command opens on screen", () => { + test("there are command-line-only commands to cover", () => { + expect(CLI_ONLY.length).toBeGreaterThan(50); + }); + + 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("/")); + // 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); + const parent = command.parent!; + + if (command.commands.length > 0) { + // A group opens its own menu, with every child under the divider. + await waitForText(r.lastFrame, path.join(" → ")); + await waitForText(r.lastFrame, "command line only"); + expect(menuEntries(r.lastFrame()!).screens).toEqual([]); + } else { + await waitForText(r.lastFrame, "this command runs from the command line"); + const help = command.createHelp(); + const frame = r.lastFrame()!.replace(/\s+/g, " "); + expect(frame).toContain(help.commandUsage(command)); + // Every option but --help, which means nothing on the help itself. + for (const option of help.visibleOptions(command)) { + if (option.long === "--help") expect(frame).not.toContain("--help"); + else expect(frame).toContain(help.optionTerm(option)); + } + for (const argument of help.visibleArguments(command)) { + expect(frame).toContain(help.argumentTerm(argument)); + } + } + + await r.press("escape"); + await waitForText(r.lastFrame, parent.description()); + r.unmount(); + }, + ); +}); + +describe("paths without a screen of their own", () => { + test("an unknown path retains the standard help fallback", async () => { + const r = renderScreen("/agentcore/gateway/no-such-command"); + + await waitForText(() => r.frames.join("\n"), "Usage:"); + const output = r.frames.join("\n"); + expect(output).toContain("harness"); + expect(output).not.toContain("command line only"); + r.unmount(); + }); + + test("a group drills down to a leaf's help and back", async () => { + const r = renderScreen("/agentcore/gateway"); + + await waitForText(r.lastFrame, "command line only"); + await r.write("create"); + await waitForText(r.lastFrame, "❯ create"); + await r.press("return"); + + await waitForText(r.lastFrame, "agentcore → gateway → create"); + const frame = r.lastFrame()!.replace(/\s+/g, " "); + expect(frame).toContain("this command runs from the command line"); + expect(frame).toContain("agentcore gateway create [options]"); + expect(frame).toContain("--authorizer-type"); + + await r.press("escape"); + await waitForText(r.lastFrame, "inspect AgentCore Gateways"); + r.unmount(); + }); +}); diff --git a/src/components/CliOnlyScreen.tsx b/src/components/CliOnlyScreen.tsx index 979aef758..80e40fa74 100644 --- a/src/components/CliOnlyScreen.tsx +++ b/src/components/CliOnlyScreen.tsx @@ -1,4 +1,4 @@ -import { useRef } from "react"; +import { useRef, type ReactNode } from "react"; import { Box, Text, useInput, useWindowSize } from "ink"; import { ScrollView, type ScrollViewRef } from "ink-scroll-view"; import type { Command } from "commander"; @@ -115,28 +115,35 @@ function Section({ title, children }: { title: string; children: React.ReactNode return ( {title} - {children} + {/* A column, so the table stretches to the full width and its key + column's share is a share of the screen, not of the table's own + content. */} + + {children} + ); } // CommandFallbackScreen is the route for any command path Root does not map to // a screen of its own: a command group renders its menu, a leaf renders its -// help. `basePath` is the route prefix the wildcard matched under. +// interactive help. A path that is not an exact command keeps the original +// HelpScreen fallback. export function CommandFallbackScreen({ - basePath, + unknownFallback, ...props -}: ScreenProps & { basePath: string[] }) { +}: ScreenProps & { unknownFallback: ReactNode }) { const { pathname } = useLocation(); const path = pathname.split("/").filter((segment) => segment !== ""); const command = resolveCommand(props.ctx.require(CommandKey), path); - // An unknown trailing segment resolves to the nearest ancestor; show that. const resolved = commandPath(command); - if (resolved.length < basePath.length) { - return ; - } + const isExactCommand = + resolved.length === path.length && resolved.every((segment, index) => segment === path[index]); + + if (!isExactCommand) return unknownFallback; + return command.commands.length > 0 ? ( - + ) : ( ); diff --git a/src/components/Root.tsx b/src/components/Root.tsx index ac2f37b14..d44b90b0c 100644 --- a/src/components/Root.tsx +++ b/src/components/Root.tsx @@ -114,7 +114,7 @@ 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"; +import { HelpScreen, RootScreen } from "../handlers/screen.tsx"; import type { Context } from "../router"; export interface RootProps { @@ -772,15 +772,19 @@ export function Root({ path, ctx, core, queryClient }: RootProps) { path="agentcore/project/create" element={} /> - {/* Every other project command: a group opens its menu, a leaf its - help, so a command added later needs no route here. */} + {/* Every known command without a screen of its own: a group opens its + menu and a leaf its interactive help. Unknown routes retain the + help-and-exit fallback. */} + } + /> } /> - } /> diff --git a/src/components/RouterScreen.tsx b/src/components/RouterScreen.tsx index 701ae3b39..b0c839f7c 100644 --- a/src/components/RouterScreen.tsx +++ b/src/components/RouterScreen.tsx @@ -47,16 +47,14 @@ export interface RouterScreenProps extends ScreenProps { // segment is the app root; the last is the command whose subcommands are the // menu options. path: string[]; - // showCliOnly lists subcommands without a screen below a divider, rather than - // omitting them; selecting one opens its help (see CliOnlyScreen). - showCliOnly?: boolean; } // RouterScreen renders the interactive command menu for a Router node: a filter // input at the top and the node's subcommands (read straight off the Commander // Command) as navigable options below. Selecting an option routes to that -// subcommand's screen. -export function RouterScreen({ ctx, path, showCliOnly = false }: RouterScreenProps) { +// subcommand's screen. Subcommands without a screen are listed below a divider +// and open their help instead (see CliOnlyScreen). +export function RouterScreen({ ctx, path }: RouterScreenProps) { const navigate = useNavigate(); const { isRawModeSupported } = useStdin(); const { exit } = useApp(); @@ -65,15 +63,13 @@ export function RouterScreen({ ctx, path, showCliOnly = false }: RouterScreenPro // Screen-backed commands first, then the command-line-only ones, so the // divider between them falls at one place in the list. const options: Option[] = useMemo(() => { - const all = command.commands - .filter((c) => showCliOnly || isTuiCommandSupported(c)) - .map((c) => ({ - name: c.name(), - description: c.description(), - cliOnly: !isTuiCommandSupported(c), - })); + const all = command.commands.map((c) => ({ + name: c.name(), + description: c.description(), + cliOnly: !isTuiCommandSupported(c), + })); return [...all.filter((o) => !o.cliOnly), ...all.filter((o) => o.cliOnly)]; - }, [command, showCliOnly]); + }, [command]); const [query, setQuery] = useState(""); const [index, setIndex] = useState(0); diff --git a/src/handlers/eval/ab-test/ab-test.screen.test.tsx b/src/handlers/eval/ab-test/ab-test.screen.test.tsx index 6aa3923bb..798cc1328 100644 --- a/src/handlers/eval/ab-test/ab-test.screen.test.tsx +++ b/src/handlers/eval/ab-test/ab-test.screen.test.tsx @@ -6,6 +6,7 @@ import { TestCoreClient, waitFor, waitForText, + menuEntries, } from "../../../testing"; afterEach(cleanupScreens); @@ -62,20 +63,14 @@ function coreWithTests(tests: ABTestSummary[]): TestCoreClient { } describe("ab-test menu", () => { - test("lists only the read commands, not the write commands", async () => { - const r = renderScreen("/agentcore/eval/ab-test"); + test("lists the read-only commands, then the rest as command line only", async () => { + const screen = renderScreen("/agentcore/eval/ab-test"); - await waitForText(r.lastFrame, "list A/B tests"); - const frame = r.lastFrame()!; - expect(frame).toContain("get an A/B test by id"); - for (const write of [ - "pause a running A/B test", - "resume a paused A/B test", - "stop an A/B test", - "delete a stopped A/B test", - ]) { - expect(frame).not.toContain(write); - } + await waitForText(screen.lastFrame, "list A/B tests"); + expect(menuEntries(screen.lastFrame()!)).toEqual({ + screens: ["get", "list"], + cliOnly: ["pause", "resume", "stop", "delete", "config-based", "target-based"], + }); }); }); diff --git a/src/handlers/eval/batch-evaluation/index.tsx b/src/handlers/eval/batch-evaluation/index.tsx index 04170d308..1fe9c9aa5 100644 --- a/src/handlers/eval/batch-evaluation/index.tsx +++ b/src/handlers/eval/batch-evaluation/index.tsx @@ -10,7 +10,7 @@ import { createSimulateBatchEvaluationHandler } from "./simulate"; // batch-evaluation supports evaluate + simulate (start jobs) plus get + list. A // bare invocation opens the interactive TUI (list → get), matching evaluator and -// online-eval; evaluate/simulate are CLI-only and stay out of the TUI menu. +// online-eval; evaluate/simulate appear below the command-line-only divider. export function createBatchEvaluationHandler(core: Core, io: AppIO): Router { return new Router("batch-evaluation", "run and inspect AgentCore batch evaluations") .use(withTuiOnEmptyFlagsAndArgs(core, io)) diff --git a/src/handlers/eval/batch-insights/batch-insights.screen.test.tsx b/src/handlers/eval/batch-insights/batch-insights.screen.test.tsx index 32df004d2..afab1b4d8 100644 --- a/src/handlers/eval/batch-insights/batch-insights.screen.test.tsx +++ b/src/handlers/eval/batch-insights/batch-insights.screen.test.tsx @@ -9,6 +9,7 @@ import { TestCoreClient, waitFor, waitForText, + menuEntries, } from "../../../testing"; afterEach(cleanupScreens); @@ -51,14 +52,14 @@ function coreWithBatchEvaluations(items: BatchEvaluationSummary[]): TestCoreClie } describe("batch-insights menu", () => { - test("offers only read-only commands", async () => { + test("lists the read-only commands, then the rest as command line only", async () => { const screen = renderScreen("/agentcore/eval/batch-insights"); await waitForText(screen.lastFrame, "list batch insights runs"); - const frame = screen.lastFrame()!; - expect(frame).toContain("list"); - expect(frame).toContain("get"); - expect(frame).not.toContain("start an asynchronous batch insights run"); + expect(menuEntries(screen.lastFrame()!)).toEqual({ + screens: ["get", "list"], + cliOnly: ["run"], + }); }); }); diff --git a/src/handlers/eval/config-bundle/config-bundle.screen.test.tsx b/src/handlers/eval/config-bundle/config-bundle.screen.test.tsx index cce6c6912..735bee47e 100644 --- a/src/handlers/eval/config-bundle/config-bundle.screen.test.tsx +++ b/src/handlers/eval/config-bundle/config-bundle.screen.test.tsx @@ -11,6 +11,7 @@ import { TestCoreClient, waitFor, waitForText, + menuEntries, } from "../../../testing"; afterEach(cleanupScreens); @@ -104,27 +105,24 @@ function coreWithBundles(bundles: ConfigurationBundleSummary[]): TestCoreClient } describe("configuration bundle menu", () => { - test("offers only get, list, and version", async () => { + test("lists the read-only commands, then the rest as command line only", async () => { const screen = renderScreen("/agentcore/eval/config-bundle"); await waitForText( screen.lastFrame, "get the latest or a specific configuration bundle version", ); - const frame = screen.lastFrame()!; - expect(frame).toContain("list"); - expect(frame).toContain("version"); - expect(frame).not.toContain("create"); - expect(frame).not.toContain("update"); - expect(frame).not.toContain("delete"); + expect(menuEntries(screen.lastFrame()!)).toEqual({ + screens: ["get", "list", "version"], + cliOnly: ["create", "update", "delete"], + }); }); test("the version menu offers only list", async () => { const screen = renderScreen("/agentcore/eval/config-bundle/version"); await waitForText(screen.lastFrame, "list immutable versions of a configuration bundle"); - expect(screen.lastFrame()).not.toContain("create"); - expect(screen.lastFrame()).not.toContain("delete"); + expect(menuEntries(screen.lastFrame()!)).toEqual({ screens: ["list"], cliOnly: [] }); }); }); diff --git a/src/handlers/eval/dataset/dataset.screen.test.tsx b/src/handlers/eval/dataset/dataset.screen.test.tsx index 1dc6e7862..85f195cb1 100644 --- a/src/handlers/eval/dataset/dataset.screen.test.tsx +++ b/src/handlers/eval/dataset/dataset.screen.test.tsx @@ -6,6 +6,7 @@ import { TestCoreClient, waitFor, waitForText, + menuEntries, } from "../../../testing"; afterEach(cleanupScreens); @@ -52,16 +53,14 @@ function coreWithDatasets(datasets: DatasetSummary[]): TestCoreClient { } describe("dataset menu", () => { - test("offers only the read-only commands", async () => { + test("lists the read-only commands, then the rest as command line only", async () => { const screen = renderScreen("/agentcore/eval/dataset"); await waitForText(screen.lastFrame, "get a dataset's metadata"); - const frame = screen.lastFrame()!; - expect(frame).toContain("list"); - expect(frame).not.toContain("create"); - expect(frame).not.toContain("update"); - expect(frame).not.toContain("publish"); - expect(frame).not.toContain("delete"); + expect(menuEntries(screen.lastFrame()!)).toEqual({ + screens: ["get", "list"], + cliOnly: ["create", "delete", "update", "publish"], + }); }); }); diff --git a/src/handlers/eval/evaluator/evaluator.screen.test.tsx b/src/handlers/eval/evaluator/evaluator.screen.test.tsx index 9a683b448..baf5b0df2 100644 --- a/src/handlers/eval/evaluator/evaluator.screen.test.tsx +++ b/src/handlers/eval/evaluator/evaluator.screen.test.tsx @@ -9,6 +9,7 @@ import { TestCoreClient, waitFor, waitForText, + menuEntries, } from "../../../testing"; afterEach(cleanupScreens); @@ -60,16 +61,14 @@ function coreWithEvaluators(evaluators: EvaluatorSummary[]): TestCoreClient { } describe("evaluator menu", () => { - test("offers only the read-only commands", async () => { + test("lists the read-only commands, then the rest as command line only", async () => { const screen = renderScreen("/agentcore/eval/evaluator"); await waitForText(screen.lastFrame, "get an evaluator by id"); - const frame = screen.lastFrame()!; - expect(frame).toContain("list"); - // Mutating subcommands are omitted so they can't fall through to HelpScreen. - expect(frame).not.toContain("llm-as-a-judge"); - expect(frame).not.toContain("code-based"); - expect(frame).not.toContain("delete"); + expect(menuEntries(screen.lastFrame()!)).toEqual({ + screens: ["get", "list"], + cliOnly: ["llm-as-a-judge", "code-based", "delete"], + }); }); test("the eval root menu shows evaluator and online-eval", async () => { diff --git a/src/handlers/eval/index.tsx b/src/handlers/eval/index.tsx index 38d6d635e..5cf50491f 100644 --- a/src/handlers/eval/index.tsx +++ b/src/handlers/eval/index.tsx @@ -15,33 +15,31 @@ import { createAbTestHandler } from "./ab-test"; import { createRecommendationHandler } from "./recommendation"; export function createEvalHandler(core: Core, io: AppIO): Router { - return ( - new Router("eval", "evaluate and optimize AgentCore agents") - .use(withTuiOnEmptyFlagsAndArgs(core, io)) - .default(renderTui(core, io)) - // Only the groups with an interactive screen belong in the TUI menu. - // ondemand (help-only) and recommendation (no screen) are CLI-only. - .supportedTuiCommands( - "evaluator", - "online-eval", - "online-insight", - "dataset", - "batch-evaluation", - "batch-insights", - "config-bundle", - "ab-test", - ) - .handler(createEvaluatorHandler(core, io)) - .handler(createOnlineEvalHandler(core, io)) - .handler(createOnlineInsightHandler(core, io)) - .handler(createDatasetHandler(core, io)) - .handler(createBatchEvaluationHandler(core, io)) - .handler(createBatchInsightsHandler(core, io)) - .handler(createOnDemandHandler(core, io)) - .handler(createConfigBundleHandler(core, io)) - .handler(createAbTestHandler(core, io)) - .handler(createRecommendationHandler(core, io)) - ); + // Only the groups with an interactive screen are marked TUI-supported; + // ondemand and recommendation are listed below the command-line-only divider. + return new Router("eval", "evaluate and optimize AgentCore agents") + .use(withTuiOnEmptyFlagsAndArgs(core, io)) + .default(renderTui(core, io)) + .supportedTuiCommands( + "evaluator", + "online-eval", + "online-insight", + "dataset", + "batch-evaluation", + "batch-insights", + "config-bundle", + "ab-test", + ) + .handler(createEvaluatorHandler(core, io)) + .handler(createOnlineEvalHandler(core, io)) + .handler(createOnlineInsightHandler(core, io)) + .handler(createDatasetHandler(core, io)) + .handler(createBatchEvaluationHandler(core, io)) + .handler(createBatchInsightsHandler(core, io)) + .handler(createOnDemandHandler(core, io)) + .handler(createConfigBundleHandler(core, io)) + .handler(createAbTestHandler(core, io)) + .handler(createRecommendationHandler(core, io)); } export { EvalScreen } from "./screen.tsx"; diff --git a/src/handlers/eval/online-eval/online-eval.screen.test.tsx b/src/handlers/eval/online-eval/online-eval.screen.test.tsx index 82e25a12c..ab44990fe 100644 --- a/src/handlers/eval/online-eval/online-eval.screen.test.tsx +++ b/src/handlers/eval/online-eval/online-eval.screen.test.tsx @@ -9,6 +9,7 @@ import { TestCoreClient, waitFor, waitForText, + menuEntries, } from "../../../testing"; afterEach(cleanupScreens); @@ -60,17 +61,14 @@ function coreWithConfigs(configs: OnlineEvaluationConfigSummary[]): TestCoreClie } describe("online-eval menu", () => { - test("offers only the read-only commands", async () => { + test("lists the read-only commands, then the rest as command line only", async () => { const screen = renderScreen("/agentcore/eval/online-eval"); await waitForText(screen.lastFrame, "get an online evaluation config by id"); - const frame = screen.lastFrame()!; - expect(frame).toContain("list"); - expect(frame).not.toContain("create"); - expect(frame).not.toContain("update"); - expect(frame).not.toContain("pause"); - expect(frame).not.toContain("resume"); - expect(frame).not.toContain("delete"); + expect(menuEntries(screen.lastFrame()!)).toEqual({ + screens: ["get", "list"], + cliOnly: ["create", "update", "pause", "resume", "delete"], + }); }); }); diff --git a/src/handlers/eval/online-insight/online-insight.screen.test.tsx b/src/handlers/eval/online-insight/online-insight.screen.test.tsx index 50865ae12..2a80cfc1b 100644 --- a/src/handlers/eval/online-insight/online-insight.screen.test.tsx +++ b/src/handlers/eval/online-insight/online-insight.screen.test.tsx @@ -9,6 +9,7 @@ import { TestCoreClient, waitFor, waitForText, + menuEntries, } from "../../../testing"; afterEach(cleanupScreens); @@ -62,17 +63,14 @@ function coreWithConfigs(configs: OnlineEvaluationConfigSummary[]): TestCoreClie } describe("online-insight menu", () => { - test("offers only the read-only commands", async () => { + test("lists the read-only commands, then the rest as command line only", async () => { const screen = renderScreen("/agentcore/eval/online-insight"); await waitForText(screen.lastFrame, "get an online insight config by id"); - const frame = screen.lastFrame()!; - expect(frame).toContain("list"); - expect(frame).not.toContain("create"); - expect(frame).not.toContain("update"); - expect(frame).not.toContain("pause"); - expect(frame).not.toContain("resume"); - expect(frame).not.toContain("delete"); + expect(menuEntries(screen.lastFrame()!)).toEqual({ + screens: ["get", "list"], + cliOnly: ["create", "update", "pause", "resume", "delete"], + }); }); }); diff --git a/src/handlers/gateway/gateway.screen.test.tsx b/src/handlers/gateway/gateway.screen.test.tsx index bcf5f75d1..a9e5db25c 100644 --- a/src/handlers/gateway/gateway.screen.test.tsx +++ b/src/handlers/gateway/gateway.screen.test.tsx @@ -9,7 +9,13 @@ import { type ListGatewaysResponse, type TargetSummary, } from "@aws-sdk/client-bedrock-agentcore-control"; -import { cleanupScreens, renderScreen, TestCoreClient, waitForText } from "../../testing"; +import { + cleanupScreens, + renderScreen, + TestCoreClient, + waitForText, + menuEntries, +} from "../../testing"; afterEach(cleanupScreens); @@ -109,11 +115,10 @@ describe("Gateway menu and list", () => { const screen = renderScreen("/agentcore/gateway"); await waitForText(screen.lastFrame, "inspect AgentCore Gateways"); - const frame = screen.lastFrame()!; - for (const command of ["get", "list", "invoke", "target", "connector", "rule", "policy"]) { - expect(frame).toContain(command); - } - expect(frame).not.toMatch(/\bcreate\b/); + expect(menuEntries(screen.lastFrame()!)).toEqual({ + screens: ["get", "list", "invoke", "target", "connector", "rule", "policy"], + cliOnly: ["create", "update", "delete"], + }); expect(screen.core.gateway.calls).toEqual([]); }); @@ -214,10 +219,10 @@ describe("Gateway Target flow", () => { const screen = renderScreen("/agentcore/gateway/target"); await waitForText(screen.lastFrame, "inspect targets for an AgentCore Gateway"); - const frame = screen.lastFrame()!; - expect(frame).toContain("get"); - expect(frame).toContain("list"); - expect(frame).not.toMatch(/\bcreate\b/); + expect(menuEntries(screen.lastFrame()!)).toEqual({ + screens: ["get", "list"], + cliOnly: ["create", "update", "delete"], + }); expect(screen.core.gateway.calls).toEqual([]); }); @@ -317,10 +322,10 @@ describe("Gateway Connector flow", () => { const screen = renderScreen("/agentcore/gateway/connector"); await waitForText(screen.lastFrame, "inspect connectors configured for an AgentCore Gateway"); - const frame = screen.lastFrame()!; - expect(frame).toContain("get"); - expect(frame).toContain("list"); - expect(frame).not.toMatch(/\bcreate\b/); + expect(menuEntries(screen.lastFrame()!)).toEqual({ + screens: ["get", "list"], + cliOnly: ["create", "update", "delete"], + }); expect(screen.core.gateway.calls).toEqual([]); }); @@ -403,10 +408,10 @@ describe("Gateway Rule flow", () => { const screen = renderScreen("/agentcore/gateway/rule"); await waitForText(screen.lastFrame, "inspect rules for an AgentCore Gateway"); - const frame = screen.lastFrame()!; - expect(frame).toContain("get"); - expect(frame).toContain("list"); - expect(frame).not.toMatch(/\bcreate\b/); + expect(menuEntries(screen.lastFrame()!)).toEqual({ + screens: ["get", "list"], + cliOnly: ["create", "update", "delete"], + }); expect(screen.core.gateway.calls).toEqual([]); }); diff --git a/src/handlers/help.screen.test.tsx b/src/handlers/help.screen.test.tsx index f2792e21b..400dce882 100644 --- a/src/handlers/help.screen.test.tsx +++ b/src/handlers/help.screen.test.tsx @@ -8,11 +8,9 @@ import { createSilentLogger, TestCoreClient, TestGlobalConfigAccessor, testIO } afterEach(cleanup); -// HelpScreen is the `*` fallback route: it prints the current command's help and -// exits. Because it unmounts itself on mount (useEffect(exit)), it is tested in -// isolation here rather than through the mounted app, reading the first frame it -// renders before the exit effect runs. - +// HelpScreen is the final `*` fallback for paths that are not exact commands: +// it prints the launching command's help and exits. Because it unmounts itself +// on mount, test its synchronous first frame in isolation. describe("HelpScreen", () => { test("renders the command's help text", () => { const command = compile( @@ -27,7 +25,6 @@ describe("HelpScreen", () => { const { frames } = render(); - // The help text is produced synchronously on the first render. const output = frames.join("\n"); expect(output).toContain("Usage:"); expect(output).toContain("harness"); diff --git a/src/handlers/identity/api-key-credential-provider/apikey.screen.test.tsx b/src/handlers/identity/api-key-credential-provider/apikey.screen.test.tsx index 13676a053..1ba1b463c 100644 --- a/src/handlers/identity/api-key-credential-provider/apikey.screen.test.tsx +++ b/src/handlers/identity/api-key-credential-provider/apikey.screen.test.tsx @@ -11,6 +11,7 @@ import { tick, waitFor, waitForText, + menuEntries, } from "../../../testing"; afterEach(cleanupScreens); @@ -55,15 +56,14 @@ function coreWithProviders(providers: ApiKeyCredentialProviderItem[]): TestCoreC } describe("API key credential provider menu", () => { - test("offers only the read-only commands", async () => { + test("lists the read-only commands, then the rest as command line only", async () => { const screen = renderScreen("/agentcore/identity/api-key-credential-provider"); await waitForText(screen.lastFrame, "get an API key credential provider"); - const frame = screen.lastFrame()!; - expect(frame).toContain("list"); - expect(frame).not.toContain("create"); - expect(frame).not.toContain("update"); - expect(frame).not.toContain("delete"); + expect(menuEntries(screen.lastFrame()!)).toEqual({ + screens: ["get", "list"], + cliOnly: ["create", "update", "delete"], + }); }); }); diff --git a/src/handlers/identity/oauth2-credential-provider/oauth2.screen.test.tsx b/src/handlers/identity/oauth2-credential-provider/oauth2.screen.test.tsx index 2bf9d4b05..4429deb13 100644 --- a/src/handlers/identity/oauth2-credential-provider/oauth2.screen.test.tsx +++ b/src/handlers/identity/oauth2-credential-provider/oauth2.screen.test.tsx @@ -11,6 +11,7 @@ import { tick, waitFor, waitForText, + menuEntries, } from "../../../testing"; afterEach(cleanupScreens); @@ -58,15 +59,14 @@ function coreWithProviders(providers: Oauth2CredentialProviderItem[]): TestCoreC } describe("OAuth2 credential provider menu", () => { - test("offers only the read-only commands", async () => { + test("lists the read-only commands, then the rest as command line only", async () => { const screen = renderScreen("/agentcore/identity/oauth2-credential-provider"); await waitForText(screen.lastFrame, "get an OAuth2 credential provider"); - const frame = screen.lastFrame()!; - expect(frame).toContain("list"); - expect(frame).not.toContain("create"); - expect(frame).not.toContain("update"); - expect(frame).not.toContain("delete"); + expect(menuEntries(screen.lastFrame()!)).toEqual({ + screens: ["get", "list"], + cliOnly: ["create", "update", "delete"], + }); }); }); diff --git a/src/handlers/index.tsx b/src/handlers/index.tsx index 697277328..15dfbf7b3 100644 --- a/src/handlers/index.tsx +++ b/src/handlers/index.tsx @@ -26,7 +26,12 @@ export interface RootHandlerConfig { export function createRootHandler(core: Core, config: RootHandlerConfig): Router { const { io, logger } = config; - const root = new Router("agentcore", "the platform for production AI agents"); + // The subcommands with screens of their own; the rest (feedback, config, + // update) are listed in the menu as command line only and open their help. + const root = new Router( + "agentcore", + "the platform for production AI agents", + ).supportedTuiCommands("harness", "identity", "runtime", "memory", "gateway", "eval", "project"); // `agentcore --version` prints the build-time package version. root.version(PACKAGE_VERSION); diff --git a/src/handlers/project/project.screen.test.tsx b/src/handlers/project/project.screen.test.tsx index 90fcf55ee..4dbea7401 100644 --- a/src/handlers/project/project.screen.test.tsx +++ b/src/handlers/project/project.screen.test.tsx @@ -5,6 +5,7 @@ import { waitForText, cleanupScreens, createSilentLogger, + menuEntries, TestCoreClient, TestGlobalConfigAccessor, testIO, @@ -84,52 +85,22 @@ function projectCommand(...path: string[]) { return command; } -const WITH_SCREENS = ["create", "invoke", "build", "deploy"]; - describe("project menu: command-line-only subcommands", () => { test("are listed below a divider, after the ones with a screen", async () => { const r = renderScreen("/agentcore/project"); await waitForText(r.lastFrame, "command line only"); - const lines = r.lastFrame()!.split("\n"); - const divider = lines.findIndex((line) => line.includes("command line only")); - const lineOf = (command: string) => - lines.findIndex((line) => new RegExp(`^\\s*(❯ )?\\s*${command}\\s`).test(line)); - for (const command of WITH_SCREENS) expect(lineOf(command)).toBeLessThan(divider); - for (const command of projectSubcommands().filter((c) => !WITH_SCREENS.includes(c))) { - expect(lineOf(command)).toBeGreaterThan(divider); - } + const withScreens = ["create", "deploy", "invoke", "build"]; + const { screens, cliOnly } = menuEntries(r.lastFrame()!); + expect(screens.toSorted()).toEqual(withScreens.toSorted()); + expect(cliOnly.toSorted()).toEqual( + projectSubcommands() + .filter((c) => !withScreens.includes(c)) + .toSorted(), + ); r.unmount(); }); - test.each(projectSubcommands().filter((command) => !WITH_SCREENS.includes(command)))( - "%s opens its help instead of an error, and esc returns to the menu", - async (command) => { - const r = renderScreen(`/agentcore/project/${command}`); - const compiled = projectCommand(command); - - if (compiled.commands.length > 0) { - // A group opens its own menu, with every child under the divider. - await waitForText(r.lastFrame, `agentcore → project → ${command}`); - await waitForText(r.lastFrame, "command line only"); - } else { - await waitForText(r.lastFrame, "this command runs from the command line"); - const help = compiled.createHelp(); - const frame = r.lastFrame()!.replace(/\s+/g, " "); - expect(frame).toContain(help.commandUsage(compiled)); - // Every option but --help, which means nothing on the help itself. - for (const option of help.visibleOptions(compiled)) { - if (option.long === "--help") expect(frame).not.toContain("--help"); - else expect(frame).toContain(help.optionTerm(option)); - } - } - - await r.press("escape"); - await waitForText(r.lastFrame, "manage an AgentCore project"); - r.unmount(); - }, - ); - test("a group drills down to its leaves' help and back", async () => { const r = renderScreen("/agentcore/project/add"); @@ -221,9 +192,10 @@ describe("project menu: command-line-only subcommands", () => { r.unmount(); }); - test("an unknown project path falls back to the project menu", async () => { + test("an unknown project path retains the standard help fallback", async () => { const r = renderScreen("/agentcore/project/no-such-command"); - await waitForText(r.lastFrame, "manage an AgentCore project"); + await waitForText(() => r.frames.join("\n"), "Usage:"); + expect(r.frames.join("\n")).not.toContain("command line only"); r.unmount(); }); }); diff --git a/src/handlers/project/screen.tsx b/src/handlers/project/screen.tsx index 076102e56..49bb60496 100644 --- a/src/handlers/project/screen.tsx +++ b/src/handlers/project/screen.tsx @@ -1,8 +1,7 @@ import { RouterScreen } from "../../components/RouterScreen"; import type { ScreenProps } from "../types"; -// ProjectScreen is the `agentcore project` menu. Subcommands without a screen -// are listed below a divider and open their help. +// ProjectScreen is the `agentcore project` menu. export function ProjectScreen(props: ScreenProps) { - return ; + return ; } diff --git a/src/handlers/screen.tsx b/src/handlers/screen.tsx index 935c7b25d..c5cdbdedc 100644 --- a/src/handlers/screen.tsx +++ b/src/handlers/screen.tsx @@ -1,6 +1,6 @@ import { Text, useApp } from "ink"; -import { CommandKey } from "../router"; import { useEffect } from "react"; +import { CommandKey } from "../router"; import { RouterScreen } from "../components/RouterScreen"; import type { ScreenProps } from "./types"; @@ -8,11 +8,14 @@ export function RootScreen(props: ScreenProps) { return ; } +// HelpScreen is the final safety net for a route that does not resolve to an +// exact command. It prints the launching command's standard Commander help and +// exits the TUI, preserving the original fallback behavior. export function HelpScreen({ ctx }: ScreenProps) { const { exit } = useApp(); - const c = ctx.require(CommandKey); - const help = c.createHelp(); - const helpText = help.formatHelp(c, help); + const command = ctx.require(CommandKey); + const help = command.createHelp(); + const helpText = help.formatHelp(command, help); // Empty deps ensures exit only runs once on mount, not on every re-render. // https://react.dev/reference/react/useEffect#passing-no-dependency-array-at-all diff --git a/src/testing/index.tsx b/src/testing/index.tsx index 65dbbab2f..580731fb7 100644 --- a/src/testing/index.tsx +++ b/src/testing/index.tsx @@ -27,6 +27,8 @@ export { waitForText, flatFrame, waitForFlatText, + menuEntries, + type MenuEntries, type RenderScreenOptions, type RenderScreenResult, } from "./renderScreen"; diff --git a/src/testing/renderScreen.tsx b/src/testing/renderScreen.tsx index 84eee7d59..d8aa079ca 100644 --- a/src/testing/renderScreen.tsx +++ b/src/testing/renderScreen.tsx @@ -186,3 +186,27 @@ export function waitForFlatText( ): Promise { return waitFor(() => flatFrame(lastFrame).includes(text), timeoutMs); } + +// MenuEntries splits a RouterScreen frame into the subcommands listed with a +// screen of their own and those listed below the "command line only" divider. +export interface MenuEntries { + screens: string[]; + cliOnly: string[]; +} + +// menuEntries reads the option names off a rendered RouterScreen frame, in +// display order, partitioned by the divider. +export function menuEntries(frame: string): MenuEntries { + const entries: MenuEntries = { screens: [], cliOnly: [] }; + let belowDivider = false; + for (const line of frame.split("\n")) { + if (line.includes("command line only")) { + belowDivider = true; + continue; + } + // " name description" or " ❯ name description". + const match = /^\s{1,3}(?:❯ )?\s*([a-z][a-z0-9-]*)\s{2,}\S/.exec(line); + if (match) (belowDivider ? entries.cliOnly : entries.screens).push(match[1]!); + } + return entries; +}