diff --git a/src/components/CliOnlyScreen.test.tsx b/src/components/CliOnlyScreen.test.tsx index d8c718a4d..4e53f4748 100644 --- a/src/components/CliOnlyScreen.test.tsx +++ b/src/components/CliOnlyScreen.test.tsx @@ -12,6 +12,10 @@ import { CommandKey, isTuiCommandSupported } from "../router"; afterEach(cleanupScreens); +// Commands that close the TUI and run instead of showing their help (see +// CommandHandoffScreen); tui.test covers them. +const HANDED_OFF = new Set(["agentcore dev"]); + // 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. @@ -20,7 +24,8 @@ function cliOnlyCommands( path: string[] = [], ): [string[], Command][] { const here = [...path, command.name()]; - const own: [string[], Command][] = isTuiCommandSupported(command) ? [] : [[here, command]]; + const own: [string[], Command][] = + isTuiCommandSupported(command) || HANDED_OFF.has(here.join(" ")) ? [] : [[here, command]]; return [ ...own, ...command.commands diff --git a/src/components/CommandHandoffScreen.tsx b/src/components/CommandHandoffScreen.tsx new file mode 100644 index 000000000..7d0f1159f --- /dev/null +++ b/src/components/CommandHandoffScreen.tsx @@ -0,0 +1,21 @@ +import { useEffect } from "react"; +import { useApp } from "ink"; +import type { ScreenProps } from "../handlers/types"; +import { TuiHandoffKey } from "../tui/handoff"; + +export interface CommandHandoffScreenProps extends ScreenProps { + // path is the command's path, e.g. ["agentcore", "dev"]. + path: string[]; +} + +// CommandHandoffScreen stands in for a command that takes over the terminal +// itself (e.g. `dev`): selecting it closes the TUI and runs the command with no +// arguments, as if the user had typed it. +export function CommandHandoffScreen({ ctx, path }: CommandHandoffScreenProps) { + const { exit } = useApp(); + useEffect(() => { + ctx.value(TuiHandoffKey)?.(path.slice(1)); + exit(); + }, [ctx, path, exit]); + return null; +} diff --git a/src/components/Root.tsx b/src/components/Root.tsx index aceb2282c..927c86ac7 100644 --- a/src/components/Root.tsx +++ b/src/components/Root.tsx @@ -123,6 +123,7 @@ import { GatewayInvokeScreen } from "../handlers/gateway/invoke/screen.tsx"; import { GatewayPolicyGenerateScreen } from "../handlers/gateway/policy/screen.tsx"; import { RouterScreen } from "./RouterScreen.tsx"; import { CommandFallbackScreen } from "./CliOnlyScreen.tsx"; +import { CommandHandoffScreen } from "./CommandHandoffScreen.tsx"; import { ProjectResourceCreateScreen } from "./ProjectResourceCreateScreen.tsx"; import { BuildProjectScreen } from "../handlers/project/build/screen.tsx"; import { DeployProjectScreen } from "../handlers/project/deploy/screen.tsx"; @@ -255,10 +256,17 @@ function PinnedRegion({ ctx, core }: ScreenProps) { ); } +const DEV_PATH = ["agentcore", "dev"]; + function RouteTable({ ctx, core }: ScreenProps) { return ( } /> + {/* `dev` owns the terminal, so selecting it closes the TUI and runs it. */} + } + /> } diff --git a/src/components/RouterScreen.test.tsx b/src/components/RouterScreen.test.tsx index d1b5d5eb1..8ca6c8f5b 100644 --- a/src/components/RouterScreen.test.tsx +++ b/src/components/RouterScreen.test.tsx @@ -85,14 +85,14 @@ describe("menu rendering", () => { r.unmount(); }); - test("selecting a listed command without a screen opens its help", async () => { + test("selecting a command without a screen opens its help", async () => { const r = renderScreen("/agentcore"); await waitForText(r.lastFrame, "type to choose a command"); - await r.write("dev"); - await waitForText(r.lastFrame, "❯ dev"); + await r.write("update"); + await waitForText(r.lastFrame, "❯ update"); await r.press("return"); - await waitForText(r.lastFrame, "agentcore dev [options]"); + await waitForText(r.lastFrame, "agentcore update [options]"); r.unmount(); }); diff --git a/src/handlers/index.tsx b/src/handlers/index.tsx index c89523d8f..badaf0cc5 100644 --- a/src/handlers/index.tsx +++ b/src/handlers/index.tsx @@ -102,7 +102,8 @@ export function createRootHandler(core: Core, config: RootHandlerConfig): Router root.handler(createUpdateHandler(io)); // These have no screen of their own but belong with the commands around - // them, so the menu keeps them in place; selecting one opens its help. + // them, so the menu keeps them in place; selecting one opens its help, except + // dev, which closes the TUI and runs (see CommandHandoffScreen). root.listInMenu("dev", "log", "traces", "export", "payment"); // Invoking with no subcommand launches the interactive TUI. diff --git a/src/tui/handoff.ts b/src/tui/handoff.ts new file mode 100644 index 000000000..1355cfbf7 --- /dev/null +++ b/src/tui/handoff.ts @@ -0,0 +1,32 @@ +import type { Command } from "commander"; +import { contextKey } from "../router"; + +// RequestTuiHandoff asks the TUI to close and then run a command, given as its +// path below the root (e.g. ["dev"]), exactly as if it had been typed. +export type RequestTuiHandoff = (commandPath: string[]) => void; + +// Screens use this to leave the TUI for a command that owns the terminal +// itself, such as `dev`. The command runs only after Ink restores the normal +// terminal buffer. +export const TuiHandoffKey = contextKey("tui.handoff"); + +// handoffArgs builds the user arguments (Commander's `from: "user"`) that run +// `commandPath` under `root`, carrying +// over the global flags the user gave when launching the TUI (e.g. --region) +// so the handed-off command runs with the same settings. +export function handoffArgs(root: Command, commandPath: string[]): string[] { + const globals: string[] = []; + for (const option of root.options) { + const name = option.attributeName(); + if (root.getOptionValueSource(name) !== "cli" || !option.long) continue; + const value: unknown = root.getOptionValue(name); + if (option.isBoolean()) { + if (value === !option.negate) globals.push(option.long); + } else if (Array.isArray(value)) { + globals.push(option.long, ...value.map(String)); + } else if (value !== undefined) { + globals.push(option.long, String(value)); + } + } + return [...commandPath, ...globals]; +} diff --git a/src/tui/index.tsx b/src/tui/index.tsx index 418644041..726f25fca 100644 --- a/src/tui/index.tsx +++ b/src/tui/index.tsx @@ -15,6 +15,7 @@ import { JsonKey } from "../handlers/keys"; import { ExitCode, InvalidEnvironmentError } from "../errors"; import { TuiExitMessageKey } from "./exitMessage"; import { createResizeGate } from "./resize"; +import { TuiHandoffKey, handoffArgs } from "./handoff"; // renderJson pretty-prints a value as indented JSON. It is the output // counterpart to renderTui: handlers call it to emit machine-readable results @@ -56,9 +57,14 @@ export async function renderTuiAt( } let exitMessage: string | undefined; - const tuiContext = ctx.withValue(TuiExitMessageKey, (message) => { - exitMessage = message; - }); + let handoff: string[] | undefined; + const tuiContext = ctx + .withValue(TuiExitMessageKey, (message) => { + exitMessage = message; + }) + .withValue(TuiHandoffKey, (commandPath) => { + handoff = commandPath; + }); // alternateScreen switches the terminal to its alternate buffer so the TUI // takes over the screen and the prior scrollback is restored on exit (like Vim). @@ -79,6 +85,13 @@ export async function renderTuiAt( if (exitMessage !== undefined) { io.stdout.write(exitMessage.endsWith("\n") ? exitMessage : `${exitMessage}\n`); } + if (handoff !== undefined) { + // Re-dispatch through the same Commander tree so the command runs with its + // full middleware stack, exactly as `agentcore ` would. + let root = ctx.require(CommandKey); + while (root.parent) root = root.parent; + await root.parseAsync(handoffArgs(root, handoff), { from: "user" }); + } } // renderTui builds the root DefaultHandle that mounts the Ink React tree. It diff --git a/src/tui/tui.test.tsx b/src/tui/tui.test.tsx index 6af737c13..30b946434 100644 --- a/src/tui/tui.test.tsx +++ b/src/tui/tui.test.tsx @@ -1,9 +1,12 @@ import { test, expect, describe } from "bun:test"; +import { Command } from "commander"; import { createRootHandler } from "../handlers"; -import { ExitCode, InvalidEnvironmentError } from "../errors"; +import { ExitCode, InvalidEnvironmentError, ProjectStateError } from "../errors"; import { renderJson } from "./index"; +import { handoffArgs } from "./handoff"; import { createSilentLogger, + inTempDirectory, TestCoreClient, TestGlobalConfigAccessor, testIO, @@ -114,6 +117,45 @@ describe("TUI stream boundary", () => { }); }); +describe("TUI handoff", () => { + test("selecting dev closes the TUI and runs the dev command", async () => { + const { cleanup } = await inTempDirectory(); + try { + const { streams, stdin } = ttyTestIO(); + const root = createRootHandler(new TestCoreClient(), { + io: streams.io, + logger: createSilentLogger(), + globalConfigAccessor: new TestGlobalConfigAccessor(), + }); + const routePromise = root.route(["node", "agentcore", "--region", "us-west-2"]); + await waitFor(() => streams.stdout().includes("type to choose a command")); + + stdin.write("dev"); + await waitFor(() => streams.stdout().includes("❯ dev")); + stdin.write("\r"); + + // Outside a project, dev's own project check is what fails: proof the + // TUI handed off to the dev command rather than showing its help. + const error = await routePromise.catch((error) => error); + expect(error).toBeInstanceOf(ProjectStateError); + } finally { + await cleanup(); + } + }); + + test("carries over global flags given on the command line", async () => { + const root = new Command("agentcore") + .option("--region ") + .option("--debug") + .option("--json") + .option("--profile ", "", "default"); + root.action(() => {}); + await root.parseAsync(["--region", "eu-west-1", "--debug"], { from: "user" }); + + expect(handoffArgs(root, ["dev"])).toEqual(["dev", "--region", "eu-west-1", "--debug"]); + }); +}); + describe("TUI resize", () => { const ERASE_SCREEN = "\u001B[2J\u001B[H";