From c52972de147f79737eb8e6a5400d7b5049d6db69 Mon Sep 17 00:00:00 2001 From: Tejas Kashinath Date: Mon, 28 Sep 2026 19:45:45 +0000 Subject: [PATCH 1/2] feat(tui): grey out project commands when no project is detected --- src/components/RouterScreen.tsx | 10 ++++++++-- src/handlers/index.tsx | 5 ++--- src/handlers/project/index.ts | 7 +++++-- src/handlers/root.test.tsx | 17 +++++++++++++++++ src/router/index.tsx | 1 + src/router/router.test.ts | 16 ++++++++++++++++ src/router/router.tsx | 31 ++++++++++++++++++++++++++++--- 7 files changed, 77 insertions(+), 10 deletions(-) diff --git a/src/components/RouterScreen.tsx b/src/components/RouterScreen.tsx index edc61aa971..a7aa4655de 100644 --- a/src/components/RouterScreen.tsx +++ b/src/components/RouterScreen.tsx @@ -2,7 +2,7 @@ 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, ProjectKey, isProjectRequired, isTuiCommandSupported } from "../router"; import { Layout } from "./Layout"; import { Alert } from "./ui/alert"; import { Divider } from "./ui/divider"; @@ -10,6 +10,7 @@ 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 { useProject } from "../handlers/project/ProjectGate"; const theme = darkTheme; const PLACEHOLDER = "type to choose a command"; @@ -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 { @@ -86,6 +88,8 @@ export function RouterScreen(props: RouterScreenProps) { } function CommandMenu({ + ctx, + core, path, tuiOnlyCommands = [], optionHints, @@ -93,6 +97,7 @@ function CommandMenu({ command, }: RouterScreenProps & { command: Command }) { const navigate = useNavigate(); + const noProject = useProject(core, ctx.value(ProjectKey)).isError; const { isRawModeSupported } = useStdin(); const { exit } = useApp(); @@ -111,6 +116,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 @@ -232,7 +238,7 @@ function CommandMenu({ color={ isHl ? theme.colors.focus - : o.cliOnly + : o.cliOnly || (o.projectRequired && noProject) ? theme.colors.muted : theme.colors.text } diff --git a/src/handlers/index.tsx b/src/handlers/index.tsx index 964c3d1c46..4065244d48 100644 --- a/src/handlers/index.tsx +++ b/src/handlers/index.tsx @@ -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)); diff --git a/src/handlers/project/index.ts b/src/handlers/project/index.ts index f552223274..818d78a0d3 100644 --- a/src/handlers/project/index.ts +++ b/src/handlers/project/index.ts @@ -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({ @@ -75,5 +78,5 @@ export function createProjectHandlers(core: Core, io: AppIO): Handler[] { }), ]; - return [createHandler, ...projectBoundHandlers]; + return { createHandler, projectBoundHandlers }; } diff --git a/src/handlers/root.test.tsx b/src/handlers/root.test.tsx index 7a27dd5915..d1d6b3fb7e 100644 --- a/src/handlers/root.test.tsx +++ b/src/handlers/root.test.tsx @@ -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, @@ -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: () => {} }); diff --git a/src/router/index.tsx b/src/router/index.tsx index b0d7fe9e0f..92c4a11180 100644 --- a/src/router/index.tsx +++ b/src/router/index.tsx @@ -12,6 +12,7 @@ export { type DefaultHandlerProvider, isDefaultHandlerProvider, isTuiCommandSupported, + isProjectRequired, commandParameterDetails, } from "./router"; export { diff --git a/src/router/router.test.ts b/src/router/router.test.ts index e7134d1084..a7d8999f13 100644 --- a/src/router/router.test.ts +++ b/src/router/router.test.ts @@ -13,6 +13,7 @@ import { flag, globalFlag, isTuiCommandSupported, + isProjectRequired, type Context, type Handler, type Middleware, @@ -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 () => { diff --git a/src/router/router.tsx b/src/router/router.tsx index ac528ed096..2df582c2a6 100644 --- a/src/router/router.tsx +++ b/src/router/router.tsx @@ -34,7 +34,10 @@ export const ProjectKey = contextKey("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()); } } @@ -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() @@ -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({ @@ -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 @@ -293,6 +305,7 @@ export class Router implements Handler, MiddlewareProvider, DefaultHandlerProvid private globalFlags: GlobalFlag[] = []; private defaultHandle?: DefaultHandle; private tuiCommandNames?: ReadonlySet; + private readonly projectCommandNames = new Set(); private cliVersion?: string; constructor( @@ -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. From bb2fa429725058f6b5a42fc8c5d6c4bc9f8deff4 Mon Sep 17 00:00:00 2001 From: Tejas Kashinath Date: Mon, 28 Sep 2026 20:40:57 +0000 Subject: [PATCH 2/2] refactor(tui): share one project-detected lookup between the root banner and the menu --- src/components/RouterScreen.tsx | 7 +++---- src/handlers/project/ProjectGate.tsx | 10 ++++++++++ src/handlers/screen.tsx | 13 ++----------- 3 files changed, 15 insertions(+), 15 deletions(-) diff --git a/src/components/RouterScreen.tsx b/src/components/RouterScreen.tsx index a7aa4655de..ffa8a59a22 100644 --- a/src/components/RouterScreen.tsx +++ b/src/components/RouterScreen.tsx @@ -2,7 +2,7 @@ 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, ProjectKey, isProjectRequired, isTuiCommandSupported } from "../router"; +import { CommandKey, isProjectRequired, isTuiCommandSupported } from "../router"; import { Layout } from "./Layout"; import { Alert } from "./ui/alert"; import { Divider } from "./ui/divider"; @@ -10,7 +10,7 @@ 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 { useProject } from "../handlers/project/ProjectGate"; +import { useNoProjectDetected } from "../handlers/project/ProjectGate"; const theme = darkTheme; const PLACEHOLDER = "type to choose a command"; @@ -88,7 +88,6 @@ export function RouterScreen(props: RouterScreenProps) { } function CommandMenu({ - ctx, core, path, tuiOnlyCommands = [], @@ -97,7 +96,7 @@ function CommandMenu({ command, }: RouterScreenProps & { command: Command }) { const navigate = useNavigate(); - const noProject = useProject(core, ctx.value(ProjectKey)).isError; + const noProject = useNoProjectDetected(core); const { isRawModeSupported } = useStdin(); const { exit } = useApp(); diff --git a/src/handlers/project/ProjectGate.tsx b/src/handlers/project/ProjectGate.tsx index 54cb7eb970..8fbaa1ab7e 100644 --- a/src/handlers/project/ProjectGate.tsx +++ b/src/handlers/project/ProjectGate.tsx @@ -35,6 +35,16 @@ export function useProject(core: Core, seed?: Project): UseQueryResult }); } +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; diff --git a/src/handlers/screen.tsx b/src/handlers/screen.tsx index 5aec6e54dc..68b29a7460 100644 --- a/src/handlers/screen.tsx +++ b/src/handlers/screen.tsx @@ -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 (