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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
155 changes: 155 additions & 0 deletions src/components/CliOnlyScreen.test.tsx
Original file line number Diff line number Diff line change
@@ -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();
});
});
27 changes: 17 additions & 10 deletions src/components/CliOnlyScreen.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -115,28 +115,35 @@ function Section({ title, children }: { title: string; children: React.ReactNode
return (
<Box flexDirection="column" marginTop={1}>
<Text color={theme.colors.text}>{title}</Text>
<Box paddingLeft={2}>{children}</Box>
{/* 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. */}
<Box paddingLeft={2} flexDirection="column">
{children}
</Box>
</Box>
);
}

// 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 <RouterScreen {...props} path={basePath} showCliOnly />;
}
const isExactCommand =
resolved.length === path.length && resolved.every((segment, index) => segment === path[index]);

if (!isExactCommand) return unknownFallback;

return command.commands.length > 0 ? (
<RouterScreen {...props} path={resolved} showCliOnly />
<RouterScreen {...props} path={resolved} />
) : (
<CliOnlyScreen {...props} path={resolved} />
);
Expand Down
16 changes: 10 additions & 6 deletions src/components/Root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -772,15 +772,19 @@ export function Root({ path, ctx, core, queryClient }: RootProps) {
path="agentcore/project/create"
element={<ProjectCreateScreen ctx={ctx} core={core} />}
/>
{/* 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. */}
<Route
path="agentcore/project/*"
path="*"
element={
<CommandFallbackScreen ctx={ctx} core={core} basePath={["agentcore", "project"]} />
<CommandFallbackScreen
ctx={ctx}
core={core}
unknownFallback={<HelpScreen ctx={ctx} core={core} />}
/>
}
/>
<Route path="*" element={<HelpScreen ctx={ctx} core={core} />} />

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are we sure we want to remove the HelpScreen fallback?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm good question. I think we should preserve it because if an unknown or a new route is introduced, we should have that as a fallback. Let me revert it

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

added help screen back

</Routes>
</MemoryRouter>
</QueryClientProvider>
Expand Down
22 changes: 9 additions & 13 deletions src/components/RouterScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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);
Expand Down
21 changes: 8 additions & 13 deletions src/handlers/eval/ab-test/ab-test.screen.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
TestCoreClient,
waitFor,
waitForText,
menuEntries,
} from "../../../testing";

afterEach(cleanupScreens);
Expand Down Expand Up @@ -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"],
});
});
});

Expand Down
2 changes: 1 addition & 1 deletion src/handlers/eval/batch-evaluation/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
11 changes: 6 additions & 5 deletions src/handlers/eval/batch-insights/batch-insights.screen.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
TestCoreClient,
waitFor,
waitForText,
menuEntries,
} from "../../../testing";

afterEach(cleanupScreens);
Expand Down Expand Up @@ -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"],
});
});
});

Expand Down
16 changes: 7 additions & 9 deletions src/handlers/eval/config-bundle/config-bundle.screen.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
TestCoreClient,
waitFor,
waitForText,
menuEntries,
} from "../../../testing";

afterEach(cleanupScreens);
Expand Down Expand Up @@ -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: [] });
});
});

Expand Down
Loading
Loading