From 6d42e98f54b8529e74b8ed5f6f63941978035f47 Mon Sep 17 00:00:00 2001 From: gitikavj Date: Thu, 3 Sep 2026 20:03:31 +0000 Subject: [PATCH] feat(tui): command-line-only screens for every command without a TUI Every menu lists its subcommands without a screen below a "command line only" divider, and selecting one opens its help (CliOnlyScreen) instead of hiding it or exiting the TUI. One wildcard route replaces the project-only fallback and HelpScreen. --- src/components/CliOnlyScreen.test.tsx | 153 ++++++++++++++++++ src/components/CliOnlyScreen.tsx | 20 ++- src/components/Root.tsx | 14 +- src/components/RouterScreen.tsx | 22 ++- .../eval/ab-test/ab-test.screen.test.tsx | 21 +-- src/handlers/eval/batch-evaluation/index.tsx | 7 +- .../batch-insights.screen.test.tsx | 11 +- .../config-bundle.screen.test.tsx | 16 +- .../eval/dataset/dataset.screen.test.tsx | 13 +- .../eval/evaluator/evaluator.screen.test.tsx | 13 +- src/handlers/eval/index.tsx | 12 ++ .../online-eval/online-eval.screen.test.tsx | 14 +- .../online-insight.screen.test.tsx | 14 +- src/handlers/gateway/gateway.screen.test.tsx | 41 ++--- src/handlers/help.screen.test.tsx | 36 ----- .../apikey.screen.test.tsx | 12 +- .../oauth2.screen.test.tsx | 12 +- src/handlers/index.tsx | 7 +- src/handlers/project/project.screen.test.tsx | 47 ++---- src/handlers/project/screen.tsx | 5 +- src/handlers/screen.tsx | 17 -- src/testing/index.tsx | 2 + src/testing/renderScreen.tsx | 24 +++ 23 files changed, 314 insertions(+), 219 deletions(-) create mode 100644 src/components/CliOnlyScreen.test.tsx delete mode 100644 src/handlers/help.screen.test.tsx diff --git a/src/components/CliOnlyScreen.test.tsx b/src/components/CliOnlyScreen.test.tsx new file mode 100644 index 000000000..c5c662c95 --- /dev/null +++ b/src/components/CliOnlyScreen.test.tsx @@ -0,0 +1,153 @@ +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 opens the nearest ancestor's menu", async () => { + const r = renderScreen("/agentcore/gateway/no-such-command"); + + await waitForText(r.lastFrame, "inspect AgentCore Gateways"); + expect(menuEntries(r.lastFrame()!).screens).toContain("get"); + 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..964e1540f 100644 --- a/src/components/CliOnlyScreen.tsx +++ b/src/components/CliOnlyScreen.tsx @@ -115,28 +115,26 @@ 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. -export function CommandFallbackScreen({ - basePath, - ...props -}: ScreenProps & { basePath: string[] }) { +// help. An unknown trailing segment resolves to the nearest ancestor. +export function CommandFallbackScreen(props: ScreenProps) { 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 ; - } return command.commands.length > 0 ? ( - + ) : ( ); diff --git a/src/components/Root.tsx b/src/components/Root.tsx index ac2f37b14..35f3d1fa6 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 { RootScreen } from "../handlers/screen.tsx"; import type { Context } from "../router"; export interface RootProps { @@ -772,15 +772,9 @@ 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 command without a screen of its own: a group opens its menu, + a leaf its help, so a command added later needs no route here. */} + } /> 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 3d865cb7c..ac6810e5e 100644 --- a/src/handlers/eval/batch-evaluation/index.tsx +++ b/src/handlers/eval/batch-evaluation/index.tsx @@ -8,11 +8,12 @@ import { createListBatchEvaluationsHandler } from "./list"; import { createEvaluateBatchEvaluationHandler } from "./evaluate"; import { createSimulateBatchEvaluationHandler } from "./simulate"; -// batch-evaluation supports evaluate (start an async job) plus get + list. A bare -// invocation opens the interactive TUI (list → get), matching evaluator and -// online-eval. +// batch-evaluation supports evaluate/simulate (start an async job) plus get + +// list. A bare invocation opens the interactive TUI (list → get), matching +// evaluator and online-eval; evaluate and simulate are command line only. export function createBatchEvaluationHandler(core: Core, io: AppIO): Router { return new Router("batch-evaluation", "run and inspect AgentCore batch evaluations") + .supportedTuiCommands("get", "list") .use(withTuiOnEmptyFlagsAndArgs(core, io)) .default(renderTui(core, io)) .handler(createEvaluateBatchEvaluationHandler(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 0c610ed32..27e413dc4 100644 --- a/src/handlers/eval/index.tsx +++ b/src/handlers/eval/index.tsx @@ -15,7 +15,19 @@ import { createAbTestHandler } from "./ab-test"; import { createRecommendationHandler } from "./recommendation"; export function createEvalHandler(core: Core, io: AppIO): Router { + // ondemand and recommendation have no screens; the menu lists them as + // command line only. return new Router("eval", "evaluate and optimize AgentCore agents") + .supportedTuiCommands( + "evaluator", + "online-eval", + "online-insight", + "dataset", + "batch-evaluation", + "batch-insights", + "config-bundle", + "ab-test", + ) .use(withTuiOnEmptyFlagsAndArgs(core, io)) .default(renderTui(core, io)) .handler(createEvaluatorHandler(core, io)) 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 deleted file mode 100644 index f2792e21b..000000000 --- a/src/handlers/help.screen.test.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import { test, expect, describe, afterEach } from "bun:test"; -import React from "react"; -import { render, cleanup } from "ink-testing-library"; -import { ValueContext, compile, CommandKey } from "../router"; -import { createRootHandler } from "./index"; -import { HelpScreen } from "./screen"; -import { createSilentLogger, TestCoreClient, TestGlobalConfigAccessor, testIO } from "../testing"; - -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. - -describe("HelpScreen", () => { - test("renders the command's help text", () => { - const command = compile( - createRootHandler(new TestCoreClient(), { - io: testIO().io, - logger: createSilentLogger(), - globalConfigAccessor: new TestGlobalConfigAccessor(), - }), - ValueContext.EmptyContext(), - ); - const ctx = ValueContext.EmptyContext().withValue(CommandKey, command); - - 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"); - expect(output).toContain("config"); - }); -}); 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..9c5ddff22 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"); 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..5278d686c 100644 --- a/src/handlers/screen.tsx +++ b/src/handlers/screen.tsx @@ -1,23 +1,6 @@ -import { Text, useApp } from "ink"; -import { CommandKey } from "../router"; -import { useEffect } from "react"; import { RouterScreen } from "../components/RouterScreen"; import type { ScreenProps } from "./types"; export function RootScreen(props: ScreenProps) { return ; } - -export function HelpScreen({ ctx }: ScreenProps) { - const { exit } = useApp(); - const c = ctx.require(CommandKey); - const help = c.createHelp(); - const helpText = help.formatHelp(c, 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 - // eslint-disable-next-line react-hooks/exhaustive-deps - useEffect(exit, []); - - return {helpText}; -} 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; +}