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
10 changes: 5 additions & 5 deletions src/components/CliOnlyScreen.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
menuEntries,
renderScreen,
waitForText,
inProjectContext,
} from "../testing";
import { CommandKey, isTuiCommandSupported } from "../router";

Expand Down Expand Up @@ -38,12 +39,11 @@ const CLI_ONLY = cliOnlyCommands();

describe("menus list command-line-only subcommands below a divider", () => {
test("the root menu", async () => {
const r = renderScreen("/agentcore");
const r = renderScreen("/agentcore", { withContext: inProjectContext });

await waitForText(r.lastFrame, "── cli ");
await waitForText(r.lastFrame, "❯ add");
expect(menuEntries(r.lastFrame()!)).toEqual({
screens: [
"create",
"add",
"remove",
"dev",
Expand Down Expand Up @@ -124,7 +124,7 @@ describe("every command-line-only command opens on screen", () => {
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("/"));
const r = renderScreen("/" + path.join("/"), { withContext: inProjectContext });
// 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);
Expand Down Expand Up @@ -291,7 +291,7 @@ describe("option help groups", () => {
);
root.addCommand(command);
const r = renderScreen("/agentcore/scroll-probe", {
withContext: (ctx) => ctx.withValue(CommandKey, root),
withContext: (ctx) => inProjectContext(ctx.withValue(CommandKey, root)),
});

await r.resize(80, 16);
Expand Down
24 changes: 11 additions & 13 deletions src/components/Root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import {
useLocation,
useNavigationType,
} from "react-router";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { type QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { Core, ScreenProps } from "../handlers/types.tsx";
import { HarnessScreen } from "../handlers/harness/screen.tsx";
import { HarnessGetScreen, HarnessGetJsonScreen } from "../handlers/harness/get/screen.tsx";
Expand Down Expand Up @@ -142,10 +142,11 @@ import { AddPaymentManagerScreen } from "../handlers/project/add/payment-manager
import { AddPaymentConnectorScreen } from "../handlers/project/add/payment-connector/screen.tsx";
import { ProjectStatusScreen } from "../handlers/project/status/screen.tsx";
import { ProjectRemoveScreen } from "../handlers/project/remove/screen.tsx";
import { ProjectDetectedKey } from "../handlers/project/context.ts";
import { HelpScreen, RootScreen } from "../handlers/screen.tsx";
import { RegionKey } from "../handlers/keys.tsx";
import { RegionPinContext } from "../handlers/utils.tsx";
import type { Context } from "../router";
import { ProjectKey, type Context } from "../router";

export interface RootProps {
// path is the command path to the executing node (e.g. "/agentcore").
Expand All @@ -155,25 +156,17 @@ export interface RootProps {

ctx: Context;

// queryClient is an optional override for the react-query client. Production
// leaves it unset (a stable one is created per mount); tests inject one — e.g.
// with retries disabled — to keep behavior deterministic and fast.
queryClient?: QueryClient;
queryClient: QueryClient;
}

// Root is the top of the Ink React tree, rendered by the `agentcore` default
// handler when the CLI is invoked without a subcommand: the MemoryRouter over
// the app's routes plus the react-query client every screen fetches through.
export function Root({ path, ctx, core, queryClient }: RootProps) {
// Create the QueryClient once per mount; a lazy initializer keeps it stable
// across re-renders (a fresh client would drop the cache and refetch). An
// injected client (tests) takes precedence.
const [defaultQueryClient] = useState(() => new QueryClient());
const [launchSessionConsumed, setLaunchSessionConsumed] = useState(false);
const client = queryClient ?? defaultQueryClient;

return (
<QueryClientProvider client={client}>
<QueryClientProvider client={queryClient}>
<RuntimeInvokeLaunchSessionContext.Provider
value={{
consumed: launchSessionConsumed,
Expand Down Expand Up @@ -259,9 +252,14 @@ function PinnedRegion({ ctx, core }: ScreenProps) {
const DEV_PATH = ["agentcore", "dev"];

function RouteTable({ ctx, core }: ScreenProps) {
const inProject = ctx.value(ProjectDetectedKey) ?? ctx.value(ProjectKey) !== undefined;

return (
<Routes>
<Route path="agentcore" element={<RootScreen ctx={ctx} core={core} />} />
<Route
path="agentcore"
element={<RootScreen ctx={ctx} core={core} inProject={inProject} />}
/>
{/* `dev` owns the terminal, so selecting it closes the TUI and runs it. */}
<Route
path="agentcore/dev"
Expand Down
89 changes: 69 additions & 20 deletions src/components/RouterScreen.test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,17 @@
import { test, expect, describe, afterEach } from "bun:test";
import { PACKAGE_VERSION } from "../constants";
import { cleanupScreens, menuEntries, renderScreen, tick, waitForText } from "../testing";
import {
cleanupScreens,
inProjectContext,
menuEntries,
renderScreen,
TestCoreClient,
testIO,
tick,
waitForText,
} from "../testing";
import { glyphs } from "./ui/_core";
import { createProjectHandlers } from "../handlers/project";

afterEach(cleanupScreens);

Expand Down Expand Up @@ -43,12 +54,17 @@ function menuGroups(frame: string): { title: string | undefined; names: string[]
describe("menu rendering", () => {
test("lists the current command's subcommands with their descriptions", async () => {
const r = renderScreen("/agentcore");
await waitForText(r.lastFrame, "type to choose a command");
await waitForText(r.lastFrame, "No project detected");

const frame = r.lastFrame()!;
const entries = menuEntries(frame);
expect(entries.screens).toContain("create");
expect(entries.screens).toContain("eval");
expect(entries.screens).not.toContain("eval");
const visible = [...entries.screens, ...entries.cliOnly];
const projectCommands = createProjectHandlers(r.core, testIO().io)
.slice(1)
.map((handler) => handler.name());
for (const command of projectCommands) expect(visible).not.toContain(command);
expect(frame.split("\n").filter((line) => line.includes("❯ "))).toHaveLength(1);
expect(new Set([...entries.screens, ...entries.cliOnly]).size).toBe(
entries.screens.length + entries.cliOnly.length,
Expand All @@ -71,11 +87,11 @@ describe("menu rendering", () => {
});

test("lists the resources alphabetically under a resources divider after the project commands", async () => {
const r = renderScreen("/agentcore");
await waitForText(r.lastFrame, "── resources");
const r = renderScreen("/agentcore", { withContext: inProjectContext });
await waitForText(r.lastFrame, "❯ add");

expect(menuGroups(r.lastFrame()!)).toEqual([
{ title: undefined, names: PROJECT_WORKFLOW },
{ title: undefined, names: PROJECT_WORKFLOW.slice(1) },
{
title: "resources",
names: ["eval", "gateway", "harness", "identity", "memory", "payment", "runtime"],
Expand Down Expand Up @@ -109,7 +125,7 @@ describe("menu rendering", () => {
});

test("filtering to the project workflow leaves no resources divider", async () => {
const r = renderScreen("/agentcore");
const r = renderScreen("/agentcore", { withContext: inProjectContext });
await waitForText(r.lastFrame, "type to choose a command");

await r.write("dep");
Expand Down Expand Up @@ -171,6 +187,42 @@ describe("menu rendering", () => {
expect(r.lastFrame()).toContain("❯ create");
r.unmount();
});

test("marks create as the starting point without a project and hides it inside one", async () => {
const startHere = `${glyphs.leftArrow} start here`;

const noProjectCore = new TestCoreClient();
noProjectCore.projectManager.resolve = async () => undefined;
const withoutProject = renderScreen("/agentcore", { core: noProjectCore });
await waitForText(withoutProject.lastFrame, startHere);
expect(withoutProject.lastFrame()).toContain("create");
withoutProject.unmount();

const withProject = renderScreen("/agentcore", { withContext: inProjectContext });
await waitForText(withProject.lastFrame, "❯ add");
expect(menuEntries(withProject.lastFrame()!).screens).not.toContain("create");
withProject.unmount();
});

test("shows the no-project banner only when no enclosing project is detected", async () => {
const banner = "No project detected - create a new project to get started";

const noProjectCore = new TestCoreClient();
noProjectCore.projectManager.resolve = async () => undefined;
const withoutProject = renderScreen("/agentcore", { core: noProjectCore });
await waitForText(withoutProject.lastFrame, banner);
const frame = withoutProject.lastFrame()!;
expect(frame).toContain(`${glyphs.info} ${banner}`);
expect(frame.indexOf("type to choose a command")).toBeLessThan(frame.indexOf(banner));
expect(frame.indexOf(banner)).toBeLessThan(frame.indexOf("create"));
withoutProject.unmount();

const withProject = renderScreen("/agentcore", { withContext: inProjectContext });
await waitForText(withProject.lastFrame, "type to choose a command");
await tick(20);
expect(withProject.lastFrame()).not.toContain(banner);
withProject.unmount();
});
});

describe("filtering", () => {
Expand Down Expand Up @@ -209,10 +261,7 @@ describe("filtering", () => {

describe("navigation", () => {
test("down arrow moves the highlight to the next option", async () => {
const r = renderScreen("/agentcore");
await waitForText(r.lastFrame, "❯ create");

await r.press("down");
const r = renderScreen("/agentcore", { withContext: inProjectContext });
await waitForText(r.lastFrame, "❯ add");

await r.press("down");
Expand Down Expand Up @@ -279,8 +328,8 @@ describe("short terminals", () => {

// fullMenu renders the root menu at the default height, where every option fits.
async function fullMenu() {
const r = renderScreen("/agentcore");
await waitForText(r.lastFrame, "── cli");
const r = renderScreen("/agentcore", { withContext: inProjectContext });
await waitForText(r.lastFrame, "❯ add");
const frame = r.lastFrame()!;
r.unmount();
const titles = new Map<string, string | undefined>();
Expand All @@ -306,8 +355,8 @@ describe("short terminals", () => {
}

async function shortMenu() {
const r = renderScreen("/agentcore");
await waitForText(r.lastFrame, "❯ create");
const r = renderScreen("/agentcore", { withContext: inProjectContext });
await waitForText(r.lastFrame, "❯ add");
await r.resize(100, ROWS);
return r;
}
Expand Down Expand Up @@ -368,7 +417,7 @@ describe("short terminals", () => {
await r.press("up");
expectConsistentWindow(r.lastFrame()!, full);
}
await waitForText(r.lastFrame, "❯ create");
await waitForText(r.lastFrame, "❯ add");
frame = r.lastFrame()!;
expect(more(frame, "↑")).toBeUndefined();
expect(more(frame, "↓")).toBeGreaterThan(0);
Expand Down Expand Up @@ -417,8 +466,8 @@ describe("short terminals", () => {

test("resizing shorter keeps the highlight visible", async () => {
const full = await fullMenu();
const r = renderScreen("/agentcore");
await waitForText(r.lastFrame, "❯ create");
const r = renderScreen("/agentcore", { withContext: inProjectContext });
await waitForText(r.lastFrame, "❯ add");

const target = full.names.indexOf("harness");
for (let i = 0; i < target; i++) await r.press("down");
Expand All @@ -435,8 +484,8 @@ describe("short terminals", () => {

test("accounts for a wrapped header when scrolling a narrow terminal", async () => {
const full = await fullMenu();
const r = renderScreen("/agentcore");
await waitForText(r.lastFrame, "❯ create");
const r = renderScreen("/agentcore", { withContext: inProjectContext });
await waitForText(r.lastFrame, "❯ add");
await r.resize(40, ROWS);

for (let i = 0; i < full.names.length; i++) {
Expand Down
Loading
Loading