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
149 changes: 149 additions & 0 deletions src/components/CliOnlyScreen.tsx
Original file line number Diff line number Diff line change
@@ -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<ScrollViewRef>(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 (
<Layout
breadcrumb={path}
description={help.commandDescription(command)}
keyHints={[
{ key: "↑↓", label: "scroll" },
{ key: "esc", label: "back" },
{ key: "ctl+c", label: "quit" },
]}
>
<Box flexDirection="column" paddingX={1} flexGrow={1} minHeight={0}>
<ScrollView
ref={scroll}
flexGrow={1}
minHeight={0}
onViewportSizeChange={({ height }) => scrollBy(0, { viewport: height })}
onContentHeightChange={(height) => scrollBy(0, { content: height })}
>
<Text color={theme.colors.muted}>this command runs from the command line</Text>
<Text> </Text>
<Text color={theme.colors.primary}>{` ${help.commandUsage(command)}`}</Text>
{Object.keys(args).length > 0 && (
<Section title="arguments">
<KeyValueTable items={args} />
</Section>
)}
{Object.keys(options).length > 0 && (
<Section title="options">
<KeyValueTable items={options} />
</Section>
)}
{details !== undefined && (
// formatParameterDetails already carries its own heading and layout.
<Text color={theme.colors.muted}>{details.trim()}</Text>
)}
</ScrollView>
</Box>
</Layout>
);
}

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>
</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.
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 <RouterScreen {...props} path={basePath} showCliOnly />;
}
return command.commands.length > 0 ? (
<RouterScreen {...props} path={resolved} showCliOnly />
) : (
<CliOnlyScreen {...props} path={resolved} />
);
}

function commandPath(command: Command): string[] {
const names: string[] = [];
for (let cur: Command | null = command; cur; cur = cur.parent) names.unshift(cur.name());
return names;
}
38 changes: 25 additions & 13 deletions src/components/KeyValueTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,24 +8,36 @@ export interface KeyValueTableProps {
items: Record<string, string>;
}

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 (
<Box flexDirection="column">
{Object.entries(items).map(([key, value]) => (
<Text key={key} color={theme.colors.text}>
<Text color={theme.colors.muted}>{key.padEnd(columnWidth)}</Text>
{value}
</Text>
<Box key={key}>
<Box
width={longestKeyLen + GAP}
maxWidth={MAX_KEY_SHARE}
flexShrink={0}
paddingRight={GAP}
>
<Text color={theme.colors.muted}>{key}</Text>
</Box>
<Box flexGrow={1} flexShrink={1}>
<Text color={theme.colors.text}>{value}</Text>
</Box>
</Box>
))}
</Box>
);
Expand Down
25 changes: 10 additions & 15 deletions src/components/Root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -108,19 +108,15 @@ 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";
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;
Expand Down Expand Up @@ -776,15 +772,14 @@ export function Root({ path, ctx, core, queryClient }: RootProps) {
path="agentcore/project/create"
element={<ProjectCreateScreen ctx={ctx} core={core} />}
/>
{PROJECT_COMMANDS.map((command) => (
<Route
key={command}
path={`agentcore/project/${command}`}
element={
<ProjectCommandNotImplementedScreen ctx={ctx} core={core} command={command} />
}
/>
))}
{/* Every other project command: a group opens its menu, a leaf its
help, so a command added later needs no route here. */}
<Route
path="agentcore/project/*"
element={
<CommandFallbackScreen ctx={ctx} core={core} basePath={["agentcore", "project"]} />
}
/>
<Route path="*" element={<HelpScreen ctx={ctx} core={core} />} />
</Routes>
</MemoryRouter>
Expand Down
57 changes: 41 additions & 16 deletions src/components/RouterScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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]);
Expand All @@ -37,32 +37,43 @@ 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 {
// path is the screen's command path, e.g. ["agentcore", "harness"]. The first
// 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);
Expand Down Expand Up @@ -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 (
<Box key={o.name} paddingX={1}>
<Text color={theme.colors.focus}>{isHl ? "❯ " : " "}</Text>
<Text bold={isHl} color={isHl ? theme.colors.focus : theme.colors.text}>
{o.name.padEnd(nameWidth)}
</Text>
<Text color={theme.colors.muted}>{o.description}</Text>
</Box>
<React.Fragment key={o.name}>
{startsCliOnly && <Divider title="command line only" />}
<Box paddingX={1}>
<Text color={theme.colors.focus}>{isHl ? "❯ " : " "}</Text>
<Text
bold={isHl}
color={
isHl
? theme.colors.focus
: o.cliOnly
? theme.colors.muted
: theme.colors.text
}
>
{o.name.padEnd(nameWidth)}
</Text>
<Text color={theme.colors.muted}>{o.description}</Text>
</Box>
</React.Fragment>
);
})
)}
Expand Down
3 changes: 3 additions & 0 deletions src/handlers/project/buildDeploy.screen.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,9 @@ function fakeBackend(options: FakeBackendOptions = {}) {
async resolveDeployedResources() {
return [];
},
async resolveProjectResources() {
return [];
},
};
return { backend, deploys };
}
Expand Down
10 changes: 9 additions & 1 deletion src/handlers/project/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading
Loading