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
9 changes: 7 additions & 2 deletions src/components/RouterScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,15 @@ import React, { useContext, useEffect, useMemo, useState } from "react";
import { Box, Text, useApp, useInput, useStdin } from "ink";
import type { Command } from "commander";
import { Navigate, useNavigate } from "react-router";
import { CommandKey, isTuiCommandSupported } from "../router";
import { CommandKey, isProjectRequired, isTuiCommandSupported } from "../router";
import { Layout } from "./Layout";
import { Alert } from "./ui/alert";
import { Divider } from "./ui/divider";
import { TextInput } from "./ui/text-input";
import { darkTheme, glyphs } from "./ui/_core.js";
import type { ScreenProps } from "../handlers/types";
import { RegionPinContext } from "../handlers/utils";
import { useNoProjectDetected } from "../handlers/project/ProjectGate";

const theme = darkTheme;
const PLACEHOLDER = "type to choose a command";
Expand Down Expand Up @@ -49,6 +50,7 @@ interface Option {
// cliOnly marks a subcommand without a screen; it is listed under a divider
// and opens its help instead.
cliOnly: boolean;
projectRequired?: boolean;
}

export interface TuiOnlyCommand {
Expand Down Expand Up @@ -86,13 +88,15 @@ export function RouterScreen(props: RouterScreenProps) {
}

function CommandMenu({
core,
path,
tuiOnlyCommands = [],
optionHints,
banner,
command,
}: RouterScreenProps & { command: Command }) {
const navigate = useNavigate();
const noProject = useNoProjectDetected(core);
const { isRawModeSupported } = useStdin();
const { exit } = useApp();

Expand All @@ -111,6 +115,7 @@ function CommandMenu({
description: c.description(),
hint: optionHints?.[c.name()],
cliOnly: !isTuiCommandSupported(c),
projectRequired: isProjectRequired(c),
}));
const actualNames = new Set(actual.map((option) => option.name));
const tuiOnly = tuiOnlyCommands
Expand Down Expand Up @@ -232,7 +237,7 @@ function CommandMenu({
color={
isHl
? theme.colors.focus
: o.cliOnly
: o.cliOnly || (o.projectRequired && noProject)
? theme.colors.muted
: theme.colors.text
}
Expand Down
5 changes: 2 additions & 3 deletions src/handlers/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -90,9 +90,8 @@ export function createRootHandler(core: Core, config: RootHandlerConfig): Router

// Install sub handlers. Registration order is menu/help order; project is
// the primary workflow, so it goes first.
createProjectHandlers(core, io).forEach((handler) => {
root.handler(handler);
});
const { createHandler, projectBoundHandlers } = createProjectHandlers(core, io);
root.handler(createHandler).projectHandlers(...projectBoundHandlers);
if (globalConfig["imperative-commands"]) {
root.handler(createHarnessHandler(core, io));
root.handler(createIdentityHandler(core, io));
Expand Down
10 changes: 10 additions & 0 deletions src/handlers/project/ProjectGate.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,16 @@ export function useProject(core: Core, seed?: Project): UseQueryResult<Project>
});
}

export function useNoProjectDetected(core: Core): boolean {
const from = process.cwd();
const detected = useQuery({
queryKey: ["project-detected", from],
queryFn: async () => (await core.projectManager.resolve({ filePath: from })) !== undefined,
gcTime: 0,
});
return detected.data === false;
}

export interface LoadingFrameProps {
breadcrumb: string[];
description?: string;
Expand Down
7 changes: 5 additions & 2 deletions src/handlers/project/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,10 @@ import { createProjectInvokeHandler } from "./invoke";
import { createProjectLogHandler } from "./log";
import { createProjectTracesHandler } from "./traces";

export function createProjectHandlers(core: Core, io: AppIO): Handler[] {
export function createProjectHandlers(
core: Core,
io: AppIO,
): { createHandler: Handler; projectBoundHandlers: Handler[] } {
const projectManager: ProjectManager = core.projectManager;

const createHandler = createCreateProjectHandler({
Expand Down Expand Up @@ -75,5 +78,5 @@ export function createProjectHandlers(core: Core, io: AppIO): Handler[] {
}),
];

return [createHandler, ...projectBoundHandlers];
return { createHandler, projectBoundHandlers };
}
17 changes: 17 additions & 0 deletions src/handlers/root.test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { test, expect, describe } from "bun:test";
import { createRootHandler } from "./index";
import { DEFAULT_GLOBAL_CONFIG } from "../globalConfig";
import { isProjectRequired } from "../router";
import {
compiledRootCommand,
createSilentLogger,
Expand Down Expand Up @@ -56,6 +57,22 @@ describe("createRootHandler", () => {
);
});

test("marks the commands that need an enclosing project", () => {
const required = compiledRootCommand().commands.filter(isProjectRequired);
expect(required.map((child) => child.name())).toEqual([
"add",
"export",
"remove",
"dev",
"deploy",
"invoke",
"log",
"traces",
"status",
"build",
]);
});

test.each(STANDALONE_COMMANDS)("rejects disabled %s before command dispatch", async (name) => {
const command = compiledRootCommand();
command.configureOutput({ writeErr: () => {}, writeOut: () => {} });
Expand Down
13 changes: 2 additions & 11 deletions src/handlers/screen.tsx
Original file line number Diff line number Diff line change
@@ -1,25 +1,16 @@
import { Text, useApp } from "ink";
import { useEffect } from "react";
import { useQuery } from "@tanstack/react-query";
import { CommandKey } from "../router";
import { RouterScreen } from "../components/RouterScreen";
import { glyphs } from "../components/ui/_core.js";
import type { ScreenProps } from "./types";
import { useNoProjectDetected } from "./project/ProjectGate";

const NO_PROJECT_HINTS = { create: `${glyphs.leftArrow} start here` };
const NO_PROJECT_BANNER = "No project detected - create a new project to get started";

export function RootScreen(props: ScreenProps) {
const from = process.cwd();
const projectDetected = useQuery({
queryKey: ["project-detected", from],
queryFn: async () =>
(await props.core.projectManager.resolve({
filePath: from,
})) !== undefined,
gcTime: 0,
});
const noProjectDetected = projectDetected.data === false;
const noProjectDetected = useNoProjectDetected(props.core);

return (
<RouterScreen
Expand Down
1 change: 1 addition & 0 deletions src/router/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export {
type DefaultHandlerProvider,
isDefaultHandlerProvider,
isTuiCommandSupported,
isProjectRequired,
commandParameterDetails,
} from "./router";
export {
Expand Down
16 changes: 16 additions & 0 deletions src/router/router.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
flag,
globalFlag,
isTuiCommandSupported,
isProjectRequired,
type Context,
type Handler,
type Middleware,
Expand Down Expand Up @@ -123,6 +124,21 @@ test("a TUI allowlist supports named children and excludes other subtrees", () =
expect(isTuiCommandSupported(deep)).toBe(false);
});

test("project handlers mark themselves and every descendant", () => {
const nested = new Router("nested").handler(leaf("deep", () => {}));
const root = new Router("app").handler(leaf("top", () => {})).projectHandlers(nested);

const command = compile(root, ValueContext.EmptyContext());
const top = command.commands.find((child) => child.name() === "top")!;
const nestedCommand = command.commands.find((child) => child.name() === "nested")!;
const deep = nestedCommand.commands.find((child) => child.name() === "deep")!;

expect(isProjectRequired(command)).toBe(false);
expect(isProjectRequired(top)).toBe(false);
expect(isProjectRequired(nestedCommand)).toBe(true);
expect(isProjectRequired(deep)).toBe(true);
});

// --- default handler (group invoked without a subcommand) ------------------

test("a group's default handler runs when it is invoked without a subcommand", async () => {
Expand Down
31 changes: 28 additions & 3 deletions src/router/router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,10 @@ export const ProjectKey = contextKey<Project>("project");
// RoutedCommand keeps the compiled handler and Commander command tree together.
// TUI consumers can therefore read handler metadata without module-level state.
class RoutedCommand extends Command {
constructor(readonly handler: Handler) {
constructor(
readonly handler: Handler,
readonly projectRequired: boolean,
) {
super(handler.name());
}
}
Expand All @@ -43,6 +46,10 @@ export function isTuiCommandSupported(command: Command): boolean {
return command instanceof RoutedCommand ? command.handler.doesSupportTui() : true;
}

export function isProjectRequired(command: Command): boolean {
return command instanceof RoutedCommand && command.projectRequired;
}

// 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()
Expand Down Expand Up @@ -197,10 +204,11 @@ export function compile(
stack: Middleware[] = [],
inheritedGlobals: GlobalFlag[] = [],
tuiSupported = true,
projectRequired = false,
): Command {
const effectiveTuiSupport = tuiSupported && node.doesSupportTui();
const compiledNode = withEffectiveTuiSupport(node, effectiveTuiSupport);
const c = new RoutedCommand(compiledNode);
const c = new RoutedCommand(compiledNode, projectRequired);
c.addHelpCommand(false);
const defaultHelp = c.createHelp();
c.configureHelp({
Expand Down Expand Up @@ -261,7 +269,11 @@ export function compile(
const childTuiSupported =
effectiveTuiSupport &&
(!isTuiChildSupportProvider(node) || node.supportsTuiCommand(child.name()));
c.addCommand(compile(child, ctx, nextStack, childGlobals, childTuiSupported));
const childProjectRequired =
projectRequired || (node instanceof Router && node.commandRequiresProject(child.name()));
c.addCommand(
compile(child, ctx, nextStack, childGlobals, childTuiSupported, childProjectRequired),
);
}
// A group may also carry a default handler that runs when it is invoked
// without a subcommand. It executes with this group's own middleware and can
Expand Down Expand Up @@ -293,6 +305,7 @@ export class Router implements Handler, MiddlewareProvider, DefaultHandlerProvid
private globalFlags: GlobalFlag[] = [];
private defaultHandle?: DefaultHandle;
private tuiCommandNames?: ReadonlySet<string>;
private readonly projectCommandNames = new Set<string>();
private cliVersion?: string;

constructor(
Expand Down Expand Up @@ -329,6 +342,18 @@ export class Router implements Handler, MiddlewareProvider, DefaultHandlerProvid
return this.tuiCommandNames?.has(commandName) ?? true;
}

projectHandlers(...handlers: Handler[]): this {
for (const handler of handlers) {
this.handler(handler);
this.projectCommandNames.add(handler.name());
}
return this;
}

commandRequiresProject(commandName: string): boolean {
return this.projectCommandNames.has(commandName);
}

// default registers a handler that runs when this group is selected without a
// subcommand (e.g. `agentcore` or `agentcore harness`). It reads group-level
// flags from the context and has no own flags/arguments.
Expand Down
Loading