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;
+}