diff --git a/src/components/CliOnlyScreen.tsx b/src/components/CliOnlyScreen.tsx new file mode 100644 index 000000000..979aef758 --- /dev/null +++ b/src/components/CliOnlyScreen.tsx @@ -0,0 +1,149 @@ +import { useRef } from "react"; +import { Box, Text, useInput, useWindowSize } from "ink"; +import { ScrollView, type ScrollViewRef } from "ink-scroll-view"; +import type { Command } from "commander"; +import { useLocation, useNavigate } from "react-router"; +import { CommandKey, commandParameterDetails } from "../router"; +import type { ScreenProps } from "../handlers/types"; +import { Layout } from "./Layout"; +import { KeyValueTable } from "./KeyValueTable"; +import { RouterScreen, resolveCommand } from "./RouterScreen"; +import { darkTheme } from "./ui/_core.js"; + +const theme = darkTheme; + +export interface CliOnlyScreenProps extends ScreenProps { + // path is the command's path, e.g. ["agentcore", "project", "dev"]. + path: string[]; +} + +// CliOnlyScreen stands in for a command that has no screen of its own: it says +// so, and shows the command's help — usage, arguments, options, parameter +// details — from the same Commander help `--help` prints, so the two cannot +// differ. The body scrolls; esc returns to the parent menu. +export function CliOnlyScreen({ ctx, path }: CliOnlyScreenProps) { + const navigate = useNavigate(); + const scroll = useRef(null); + // Subscribing to the window size re-renders this screen on a resize. Layout + // re-renders on its own, but its children are the same elements, so without + // this the ScrollView is never re-rendered, never re-measures, and never + // reports the size change the clamp below responds to. + useWindowSize(); + const command = resolveCommand(ctx.require(CommandKey), path); + const help = command.createHelp(); + + // ScrollView's scrollBy clamps to the content height, not to the last full + // page, so this stops at the bottom rather than scrolling the text off. It + // also runs with no delta when the viewport or content changes size, so an + // offset that was the bottom of a small terminal is pulled back once the + // terminal grows. Those callbacks fire before the ScrollView stores the new + // size, so they pass it in; the ref's own getters would report the old one. + const scrollBy = (delta = 0, size: { viewport?: number; content?: number } = {}) => { + const view = scroll.current; + if (!view) return; + const viewport = size.viewport ?? view.getViewportHeight(); + const content = size.content ?? view.getContentHeight(); + const bottom = Math.max(0, content - viewport); + view.scrollTo(Math.max(0, Math.min(view.getScrollOffset() + delta, bottom))); + }; + + useInput((input, key) => { + if (key.escape) navigate("/" + path.slice(0, -1).join("/")); + else if (key.upArrow || input === "k") scrollBy(-1); + else if (key.downArrow || input === "j") scrollBy(1); + else if (key.pageUp) scrollBy(-(scroll.current?.getViewportHeight() ?? 0)); + else if (key.pageDown) scrollBy(scroll.current?.getViewportHeight() ?? 0); + }); + + const table = (rows: [string, string][]) => Object.fromEntries(rows); + // --help is Commander's own and means nothing on a screen that is the help. + const options = table( + help + .visibleOptions(command) + .filter((option) => option.long !== "--help") + .map((option) => [help.optionTerm(option), help.optionDescription(option)]), + ); + const args = table( + help + .visibleArguments(command) + .map((argument) => [help.argumentTerm(argument), help.argumentDescription(argument)]), + ); + const details = commandParameterDetails(command); + + return ( + + + scrollBy(0, { viewport: height })} + onContentHeightChange={(height) => scrollBy(0, { content: height })} + > + this command runs from the command line + + {` ${help.commandUsage(command)}`} + {Object.keys(args).length > 0 && ( +
+ +
+ )} + {Object.keys(options).length > 0 && ( +
+ +
+ )} + {details !== undefined && ( + // formatParameterDetails already carries its own heading and layout. + {details.trim()} + )} +
+
+
+ ); +} + +function Section({ title, children }: { title: string; children: React.ReactNode }) { + return ( + + {title} + {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[] }) { + 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 ? ( + + ) : ( + + ); +} + +function commandPath(command: Command): string[] { + const names: string[] = []; + for (let cur: Command | null = command; cur; cur = cur.parent) names.unshift(cur.name()); + return names; +} diff --git a/src/components/KeyValueTable.tsx b/src/components/KeyValueTable.tsx index 36d3c2a00..d4dbe49c1 100644 --- a/src/components/KeyValueTable.tsx +++ b/src/components/KeyValueTable.tsx @@ -8,24 +8,36 @@ export interface KeyValueTableProps { items: Record; } -export function KeyValueTable({ items }: KeyValueTableProps) { - const longestKeyLen = Object.keys(items).reduce((prev, cur) => { - if (cur.length > prev) { - return cur.length; - } else { - return prev; - } - }, 0); +// The key column takes at most this share of the table, so a long key (an +// option with a long placeholder, say) leaves room for its value to wrap +// legibly instead of squeezing it into the margin. The gap is inside the +// column, so a key that fills the cap still stands clear of its value. Capped +// by layout rather than by reading the terminal width: a resize re-lays out +// without re-rendering, so a width computed in render would go stale. +const MAX_KEY_SHARE = "50%"; +const GAP = 2; - const columnWidth = longestKeyLen + 2; +export function KeyValueTable({ items }: KeyValueTableProps) { + const longestKeyLen = Object.keys(items).reduce((max, key) => Math.max(max, key.length), 0); + // Two boxes rather than one padded string, so a value that wraps continues + // under itself, not under the key. return ( {Object.entries(items).map(([key, value]) => ( - - {key.padEnd(columnWidth)} - {value} - + + + {key} + + + {value} + + ))} ); diff --git a/src/components/Root.tsx b/src/components/Root.tsx index 8c797dbdb..ac2f37b14 100644 --- a/src/components/Root.tsx +++ b/src/components/Root.tsx @@ -108,7 +108,8 @@ import { GatewayRuleListScreen } from "../handlers/gateway/rule/list/screen.tsx" import { GatewayRuleGetScreen } from "../handlers/gateway/rule/get/screen.tsx"; import { GatewayInvokeScreen } from "../handlers/gateway/invoke/screen.tsx"; import { GatewayPolicyGenerateScreen } from "../handlers/gateway/policy/screen.tsx"; -import { ProjectScreen, ProjectCommandNotImplementedScreen } from "../handlers/project/screen.tsx"; +import { ProjectScreen } from "../handlers/project/screen.tsx"; +import { CommandFallbackScreen } from "./CliOnlyScreen.tsx"; import { BuildProjectScreen } from "../handlers/project/build/screen.tsx"; import { DeployProjectScreen } from "../handlers/project/deploy/screen.tsx"; import { ProjectCreateScreen } from "../handlers/project/create/screen.tsx"; @@ -116,11 +117,6 @@ import { ProjectInvokePickerScreen } from "../handlers/project/invoke/screen.tsx import { RootScreen, HelpScreen } from "../handlers/screen.tsx"; import type { Context } from "../router"; -// PROJECT_COMMANDS are the `agentcore project` subcommands that are listed in -// the menu but have no screen of their own yet (`create` has the wizard). Each -// is routed explicitly so selecting it reports "not implemented" error -const PROJECT_COMMANDS = ["add", "export", "remove", "dev", "status"] as const; - export interface RootProps { // path is the command path to the executing node (e.g. "/agentcore"). path: string; @@ -776,15 +772,14 @@ export function Root({ path, ctx, core, queryClient }: RootProps) { path="agentcore/project/create" element={} /> - {PROJECT_COMMANDS.map((command) => ( - - } - /> - ))} + {/* Every other project command: 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 b7af2e3f9..701ae3b39 100644 --- a/src/components/RouterScreen.tsx +++ b/src/components/RouterScreen.tsx @@ -24,7 +24,7 @@ function rootCommand(c: Command): Command { // handler, so `CommandKey` is pinned to whichever command *launched* the TUI — // we walk up to the root and back down the path to recover the screen's own // command regardless of where the app started. -function resolveCommand(launch: Command, path: string[]): Command { +export function resolveCommand(launch: Command, path: string[]): Command { let cur = rootCommand(launch); for (let i = 1; i < path.length; i++) { const next = cur.commands.find((c) => c.name() === path[i]); @@ -37,6 +37,9 @@ function resolveCommand(launch: Command, path: string[]): Command { interface Option { name: string; description: string; + // cliOnly marks a subcommand without a screen; it is listed under a divider + // and opens its help instead. + cliOnly: boolean; } export interface RouterScreenProps extends ScreenProps { @@ -44,25 +47,33 @@ 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 }: RouterScreenProps) { +export function RouterScreen({ ctx, path, showCliOnly = false }: RouterScreenProps) { const navigate = useNavigate(); const { isRawModeSupported } = useStdin(); const { exit } = useApp(); const command = resolveCommand(ctx.require(CommandKey), path); - const options: Option[] = useMemo( - () => - command.commands - .filter(isTuiCommandSupported) - .map((c) => ({ name: c.name(), description: c.description() })), - [command], - ); + // 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), + })); + return [...all.filter((o) => !o.cliOnly), ...all.filter((o) => o.cliOnly)]; + }, [command, showCliOnly]); const [query, setQuery] = useState(""); const [index, setIndex] = useState(0); @@ -158,14 +169,28 @@ export function RouterScreen({ ctx, path }: RouterScreenProps) { ) : ( filtered.map((o, i) => { const isHl = i === highlight; + // The divider sits above the first command-line-only option. + const startsCliOnly = o.cliOnly && !filtered[i - 1]?.cliOnly; return ( - - {isHl ? "❯ " : " "} - - {o.name.padEnd(nameWidth)} - - {o.description} - + + {startsCliOnly && } + + {isHl ? "❯ " : " "} + + {o.name.padEnd(nameWidth)} + + {o.description} + + ); }) )} diff --git a/src/handlers/project/buildDeploy.screen.test.tsx b/src/handlers/project/buildDeploy.screen.test.tsx index 052b153e9..abf7e1430 100644 --- a/src/handlers/project/buildDeploy.screen.test.tsx +++ b/src/handlers/project/buildDeploy.screen.test.tsx @@ -59,6 +59,9 @@ function fakeBackend(options: FakeBackendOptions = {}) { async resolveDeployedResources() { return []; }, + async resolveProjectResources() { + return []; + }, }; return { backend, deploys }; } diff --git a/src/handlers/project/index.ts b/src/handlers/project/index.ts index 91805662e..0d4b22794 100644 --- a/src/handlers/project/index.ts +++ b/src/handlers/project/index.ts @@ -27,7 +27,15 @@ type ProjectHandlerConfig = { export function createProjectHandler({ core, io }: ProjectHandlerConfig): Router { const projectManager: ProjectManager = core.projectManager; const config = { projectManager, io, bedrockAgentImporter: core.bedrockAgentImporter }; - const project = new Router("project", "manage an AgentCore project"); + // The subcommands with a screen of their own. Every other subcommand — and + // everything beneath a group like `add` — is listed in the menu as command + // line only and opens its help instead (see CliOnlyScreen). + const project = new Router("project", "manage an AgentCore project").supportedTuiCommands( + "create", + "invoke", + "build", + "deploy", + ); // Without a default, a bare `agentcore project` falls back to Commander's help // and a usage exit code instead of the menu every sibling router opens. diff --git a/src/handlers/project/project.screen.test.tsx b/src/handlers/project/project.screen.test.tsx index 03ad42de8..90fcf55ee 100644 --- a/src/handlers/project/project.screen.test.tsx +++ b/src/handlers/project/project.screen.test.tsx @@ -1,16 +1,15 @@ import { test, expect, describe, afterEach } from "bun:test"; import { renderScreen, + waitForFlatText, waitForText, cleanupScreens, createSilentLogger, TestCoreClient, TestGlobalConfigAccessor, testIO, - ttyTestIO, } from "../../testing"; -import { renderTuiAt } from "../../tui"; -import { InvalidEnvironmentError, NotImplementedError } from "../../errors"; +import { InvalidEnvironmentError } from "../../errors"; import { compile, ValueContext } from "../../router"; import { ExitCode } from "../../runnable"; import { createRootHandler } from "../index"; @@ -69,51 +68,163 @@ describe("project menu", () => { }); }); -describe("project subcommands without a screen", () => { - // renderTuiAt rather than renderScreen: ink-testing-library exposes no - // waitUntilExit, so it cannot observe the rejection under test. - // - // Reading the cases off the router also guards Root's hand-written - // PROJECT_COMMANDS: an unrouted subcommand hits the catch-all, which resolves - // instead of rejecting. Frames can't detect that — the catch-all exits before - // painting, so it and this screen both render empty. Subcommands with a real - // screen are excluded. - const WITH_SCREENS = ["create", "invoke", "build", "deploy"]; +// projectCommand resolves a compiled project subcommand by path, for reading +// the help the CLI-only screen must match. +function projectCommand(...path: string[]) { + const root = compile( + createRootHandler(new TestCoreClient(), { + io: testIO().io, + logger: createSilentLogger(), + globalConfigAccessor: new TestGlobalConfigAccessor(), + }), + ValueContext.EmptyContext(), + ); + let command = root.commands.find((c) => c.name() === "project")!; + for (const name of path) command = command.commands.find((c) => c.name() === name)!; + 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); + } + r.unmount(); + }); + test.each(projectSubcommands().filter((command) => !WITH_SCREENS.includes(command)))( - "%s tears down the TUI with NotImplementedError", + "%s opens its help instead of an error, and esc returns to the menu", async (command) => { - const { streams } = ttyTestIO(); + const r = renderScreen(`/agentcore/project/${command}`); + const compiled = projectCommand(command); - const rendering = renderTuiAt( - `/agentcore/project/${command}`, - ValueContext.EmptyContext(), - new TestCoreClient(), - streams.io, - ); + 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 expect(rendering).rejects.toThrow(NotImplementedError); - await expect(rendering).rejects.toThrow(`'agentcore project ${command}'`); + await r.press("escape"); + await waitForText(r.lastFrame, "manage an AgentCore project"); + r.unmount(); }, ); - test("the error names the command to run instead", async () => { - const { streams } = ttyTestIO(); + test("a group drills down to its leaves' help and back", async () => { + const r = renderScreen("/agentcore/project/add"); - const caught: unknown = await renderTuiAt( - "/agentcore/project/status", - ValueContext.EmptyContext(), - new TestCoreClient(), - streams.io, - ).then( - () => undefined, - (error: unknown) => error, - ); + await waitForText(r.lastFrame, "agentcore → project → add"); + await r.write("gateway"); + await waitForText(r.lastFrame, "❯ gateway"); + await r.press("return"); + + await waitForText(r.lastFrame, "agentcore → project → add → gateway"); + const frame = r.lastFrame()!.replace(/\s+/g, " "); + expect(frame).toContain("this command runs from the command line"); + expect(frame).toContain("agentcore project add gateway [options]"); + expect(frame).toContain("--authorizer-type"); - expect(caught).toBeInstanceOf(NotImplementedError); - const error = caught as NotImplementedError; - expect(error.message).toContain("agentcore project status --help"); - // Surfaces as a plain CLI failure, not a crash. - expect(error.exitCode).toBe(1); + await r.press("escape"); + await waitForText(r.lastFrame, "agentcore → project → add"); + r.unmount(); + }); + + test("help longer than the terminal scrolls, and the parameter details are reachable", async () => { + // `add memory` has ten options plus a long --strategies write-up, which + // `--help` appends as "Parameter details"; at 80×24 most of it is below + // the fold. + const r = renderScreen("/agentcore/project/add/memory"); + await r.resize(80, 24); + await waitForText(r.lastFrame, "this command runs from the command line"); + expect(r.lastFrame()).not.toContain("reflectionNamespaceTemplates"); + + // Scroll to the end: the write-up's example is the last thing on the page. + for (let i = 0; i < 80; i++) await r.press("down"); + const bottom = r.lastFrame()!.replace(/\s+/g, " "); + expect(bottom).toContain('"reflectionNamespaceTemplates": ["/episodes/{actorId}"]'); + // …and the heading was on the way. + expect(r.frames.some((frame) => frame.includes("Parameter details:"))).toBe(true); + + for (let i = 0; i < 80; i++) await r.press("up"); + await waitForText(r.lastFrame, "this command runs from the command line"); + r.unmount(); + }); + + test("growing the terminal after scrolling to the bottom pulls the content back into view", async () => { + const r = renderScreen("/agentcore/project/add/runtime"); + await r.resize(80, 24); + await waitForText(r.lastFrame, "this command runs from the command line"); + for (let i = 0; i < 80; i++) await r.press("down"); + expect(r.lastFrame()).not.toContain("this command runs from the command line"); + + // Tall enough for the whole help: the offset must fall back to the top + // rather than leave a mostly blank viewport. Height only — a width change + // reflows the content, which would mask a clamp that read a stale height. + await r.resize(80, 120); + await waitForText(r.lastFrame, "this command runs from the command line"); + expect(r.lastFrame()).toContain("--role-arn"); + r.unmount(); + }); + + test("a key that fills its column still stands clear of its value", async () => { + const r = renderScreen("/agentcore/project/add/runtime"); + await r.resize(40, 60); + // Narrow enough that the intro wraps and the key column hits its cap. + await waitForFlatText(r.lastFrame, "this command runs from the command line"); + const lines = r.lastFrame()!.split("\n"); + // "--description " wraps within the capped column… + expect(lines.some((line) => /^\s+\s{2,}\S/.test(line))).toBe(true); + // …and no line runs a key straight into its value (checked case-insensitively; + // this is a terminal layout check, not an HTML filter). + expect(lines.some((line) => /<[a-z-]+>[a-z]/i.test(line))).toBe(false); + r.unmount(); + }); + + test("every option is reachable on a small terminal", async () => { + const r = renderScreen("/agentcore/project/add/runtime"); + await r.resize(80, 24); + await waitForText(r.lastFrame, "this command runs from the command line"); + + const seen = new Set(); + const collect = () => { + for (const match of r.lastFrame()!.matchAll(/--[a-z][a-z-]*/g)) seen.add(match[0]); + }; + collect(); + for (let i = 0; i < 60; i++) { + await r.press("down"); + collect(); + } + const compiled = projectCommand("add", "runtime"); + for (const option of compiled.options) { + if (option.long && option.long !== "--help") expect(seen).toContain(option.long); + } + r.unmount(); + }); + + test("an unknown project path falls back to the project menu", async () => { + const r = renderScreen("/agentcore/project/no-such-command"); + await waitForText(r.lastFrame, "manage an AgentCore project"); + r.unmount(); }); }); diff --git a/src/handlers/project/screen.tsx b/src/handlers/project/screen.tsx index 9b56c61e2..076102e56 100644 --- a/src/handlers/project/screen.tsx +++ b/src/handlers/project/screen.tsx @@ -1,37 +1,8 @@ -import { useEffect } from "react"; -import { useApp } from "ink"; import { RouterScreen } from "../../components/RouterScreen"; -import { NotImplementedError } from "../../errors"; import type { ScreenProps } from "../types"; +// ProjectScreen is the `agentcore project` menu. Subcommands without a screen +// are listed below a divider and open their help. export function ProjectScreen(props: ScreenProps) { - return ; -} - -export interface ProjectCommandNotImplementedScreenProps extends ScreenProps { - // command is the project subcommand the user selected, e.g. "deploy". - command: string; -} - -// ProjectCommandNotImplementedScreen is the landing screen for a project -// subcommand that is listed in the menu but has no screen yet. -// -// exit(error) rejects the waitUntilExit() that renderTuiAt awaits, so the TUI -// tears down and the error takes the normal CLI path. Throwing during render -// would surface a React stack trace instead. -export function ProjectCommandNotImplementedScreen({ - command, -}: ProjectCommandNotImplementedScreenProps) { - const { exit } = useApp(); - - useEffect(() => { - exit( - new NotImplementedError( - `'agentcore project ${command}' has no interactive screen yet; ` + - `run 'agentcore project ${command} --help' to use it from the command line`, - ), - ); - }, [exit, command]); - - return null; + return ; } diff --git a/src/router/index.tsx b/src/router/index.tsx index f8c1dbd2a..9f4ed3625 100644 --- a/src/router/index.tsx +++ b/src/router/index.tsx @@ -11,6 +11,7 @@ export { type DefaultHandlerProvider, isDefaultHandlerProvider, isTuiCommandSupported, + commandParameterDetails, } from "./router"; export { type Handler, diff --git a/src/router/router.tsx b/src/router/router.tsx index e33133ad2..4a823cbf2 100644 --- a/src/router/router.tsx +++ b/src/router/router.tsx @@ -36,6 +36,16 @@ export function isTuiCommandSupported(command: Command): boolean { return command instanceof RoutedCommand ? command.handler.doesSupportTui() : true; } +// commandParameterDetails is the "Parameter details" section `--help` appends +// for flags with long-form documentation; undefined when the command has none. +// Commander emits added help text only on outputHelp, so helpInformation() +// does not include it and a TUI rendering of the help must ask for it. +export function commandParameterDetails(command: Command): string | undefined { + return command instanceof RoutedCommand + ? formatParameterDetails(command.handler.flags()) + : undefined; +} + interface TuiChildSupportProvider { supportsTuiCommand(commandName: string): boolean; }