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
7 changes: 6 additions & 1 deletion src/components/CliOnlyScreen.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand Down
21 changes: 21 additions & 0 deletions src/components/CommandHandoffScreen.tsx
Original file line number Diff line number Diff line change
@@ -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;
}
8 changes: 8 additions & 0 deletions src/components/Root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -255,10 +256,17 @@ function PinnedRegion({ ctx, core }: ScreenProps) {
);
}

const DEV_PATH = ["agentcore", "dev"];

function RouteTable({ ctx, core }: ScreenProps) {
return (
<Routes>
<Route path="agentcore" element={<RootScreen ctx={ctx} core={core} />} />
{/* `dev` owns the terminal, so selecting it closes the TUI and runs it. */}
<Route
path="agentcore/dev"
element={<CommandHandoffScreen ctx={ctx} core={core} path={DEV_PATH} />}
/>
<Route
path="agentcore/invoke"
element={<ProjectInvokePickerScreen ctx={ctx} core={core} />}
Expand Down
8 changes: 4 additions & 4 deletions src/components/RouterScreen.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});

Expand Down
3 changes: 2 additions & 1 deletion src/handlers/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
32 changes: 32 additions & 0 deletions src/tui/handoff.ts
Original file line number Diff line number Diff line change
@@ -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<RequestTuiHandoff>("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];
}
19 changes: 16 additions & 3 deletions src/tui/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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).
Expand All @@ -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 <command>` 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
Expand Down
44 changes: 43 additions & 1 deletion src/tui/tui.test.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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 <region>")
.option("--debug")
.option("--json")
.option("--profile <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";

Expand Down
Loading