From e9d94987fa892e1d1226808b34c3e2cdfdad8931 Mon Sep 17 00:00:00 2001 From: Nicolas Borges Date: Mon, 28 Sep 2026 15:05:09 -0400 Subject: [PATCH 1/7] feat(tui): guide first-time users to project creation --- src/components/RouterScreen.test.tsx | 48 +++++++++++++++++++++++++++- src/components/RouterScreen.tsx | 33 +++++++++++++++++-- src/components/ui/_core.ts | 2 ++ src/handlers/screen.tsx | 27 ++++++++++++++-- src/index.ts | 9 +++--- src/router/index.tsx | 1 + src/router/router.tsx | 2 ++ src/testing/renderScreen.tsx | 10 +++++- 8 files changed, 120 insertions(+), 12 deletions(-) diff --git a/src/components/RouterScreen.test.tsx b/src/components/RouterScreen.test.tsx index 8ca6c8f5b5..c47ea7df57 100644 --- a/src/components/RouterScreen.test.tsx +++ b/src/components/RouterScreen.test.tsx @@ -1,6 +1,16 @@ import { test, expect, describe, afterEach } from "bun:test"; import { PACKAGE_VERSION } from "../constants"; -import { cleanupScreens, menuEntries, renderScreen, tick, waitForText } from "../testing"; +import { + cleanupScreens, + menuEntries, + renderScreen, + TestCoreClient, + tick, + waitForText, +} from "../testing"; +import { DEFAULT_GLOBAL_CONFIG } from "../globalConfig"; +import type { Project } from "../handlers/project/types"; +import { glyphs } from "./ui/_core"; afterEach(cleanupScreens); @@ -171,6 +181,42 @@ describe("menu rendering", () => { expect(r.lastFrame()).toContain("❯ create"); r.unmount(); }); + + test("marks create as the starting point on the first run only", async () => { + const startHere = `${glyphs.leftArrow} start here`; + const firstRun = renderScreen("/agentcore", { + globalConfig: { ...DEFAULT_GLOBAL_CONFIG, isFirstRun: true }, + }); + await waitForText(firstRun.lastFrame, startHere); + expect(firstRun.lastFrame()).toContain("create"); + firstRun.unmount(); + + const returning = renderScreen("/agentcore"); + await waitForText(returning.lastFrame, "type to choose a command"); + expect(returning.lastFrame()).not.toContain(startHere); + returning.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.indexOf("type to choose a command")).toBeLessThan(frame.indexOf(banner)); + expect(frame.indexOf(banner)).toBeLessThan(frame.indexOf("create")); + withoutProject.unmount(); + + const core = new TestCoreClient(); + core.projectManager.resolve = async () => ({}) as Project; + const withProject = renderScreen("/agentcore", { core }); + await waitForText(withProject.lastFrame, "type to choose a command"); + await tick(20); + expect(withProject.lastFrame()).not.toContain(banner); + withProject.unmount(); + }); }); describe("filtering", () => { diff --git a/src/components/RouterScreen.tsx b/src/components/RouterScreen.tsx index 614567abd0..9deabd217d 100644 --- a/src/components/RouterScreen.tsx +++ b/src/components/RouterScreen.tsx @@ -2,6 +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 stringWidth from "string-width"; import { CommandKey, commandMenuSectionStart, @@ -58,6 +59,7 @@ export function commandPath(command: Command): string[] { interface Option { name: string; description: string; + hint?: string; // cliOnly marks a subcommand without a screen; it is listed under a divider // and opens its help instead. cliOnly: boolean; @@ -80,6 +82,10 @@ export interface RouterScreenProps extends ScreenProps { // tuiOnlyCommands are navigable informational flows that intentionally do // not exist in the CLI command tree. tuiOnlyCommands?: TuiOnlyCommand[]; + // optionHints adds short contextual guidance alongside selected menu options. + optionHints?: Readonly>; + // alert is optional guidance rendered between the filter and menu options. + alert?: string; } // RouterScreen renders the interactive command menu for a Router node: a filter @@ -101,6 +107,8 @@ function CommandMenu({ banner, path, tuiOnlyCommands = [], + optionHints, + alert, command, }: RouterScreenProps & { command: Command }) { const navigate = useNavigate(); @@ -131,6 +139,7 @@ function CommandMenu({ return { name: c.name(), description: c.description(), + hint: optionHints?.[c.name()], cliOnly, section: cliOnly ? CLI_ONLY_SECTION : sectionOf(index), }; @@ -138,13 +147,13 @@ function CommandMenu({ const actualNames = new Set(actual.map((option) => option.name)); const tuiOnly = tuiOnlyCommands .filter((option) => !actualNames.has(option.name)) - .map((option) => ({ ...option, cliOnly: false })); + .map((option) => ({ ...option, hint: optionHints?.[option.name], cliOnly: false })); return [ ...tuiOnly, ...actual.filter((option) => !option.cliOnly), ...actual.filter((option) => option.cliOnly), ]; - }, [command, tuiOnlyCommands]); + }, [command, optionHints, tuiOnlyCommands]); const [query, setQuery] = useState(""); const [index, setIndex] = useState(0); @@ -221,6 +230,7 @@ function CommandMenu({ > {({ columns, contentRows }) => ( void; } +function alertRows(alert: string | undefined, columns: number): number { + if (!alert) return 0; + const contentColumns = Math.max(1, columns - 2); + return 2 + Math.ceil(stringWidth(alert) / contentColumns); +} + function CommandMenuBody({ + alert, columns, contentRows, filtered, @@ -260,7 +278,7 @@ function CommandMenuBody({ onQueryChange, }: CommandMenuBodyProps) { const sections = useMemo(() => filtered.map((option) => option.section), [filtered]); - const menuHeight = Math.max(0, contentRows - FILTER_ROWS); + const menuHeight = Math.max(0, contentRows - FILTER_ROWS - alertRows(alert, columns)); const windowStart = Math.max(0, highlight - Math.floor(menuHeight / 2)); const view = scrollWindow({ sections, @@ -283,6 +301,14 @@ function CommandMenuBody({ + {alert && ( + + + {alert} + + + )} + {filtered.length === 0 ? ( @@ -330,6 +356,7 @@ function CommandMenuBody({ {option.name.padEnd(nameWidth)} {option.description} + {option.hint && {option.hint}} ); }) diff --git a/src/components/ui/_core.ts b/src/components/ui/_core.ts index f976e9b1f0..a4d403895b 100644 --- a/src/components/ui/_core.ts +++ b/src/components/ui/_core.ts @@ -111,6 +111,7 @@ const unicode = unicodeSupported(process.env, process.platform); const unicodeGlyphs = { pointer: "❯", + leftArrow: "←", check: "✔", cross: "✗", done: "✓", @@ -128,6 +129,7 @@ const unicodeGlyphs = { const asciiGlyphs: typeof unicodeGlyphs = { pointer: ">", + leftArrow: "<-", check: "*", cross: "x", done: "*", diff --git a/src/handlers/screen.tsx b/src/handlers/screen.tsx index 420171cb4f..6d666b2759 100644 --- a/src/handlers/screen.tsx +++ b/src/handlers/screen.tsx @@ -1,12 +1,35 @@ import { Text, useApp } from "ink"; import { useEffect } from "react"; -import { CommandKey } from "../router"; +import { useQuery } from "@tanstack/react-query"; +import { CommandKey, FirstRunKey } from "../router"; import { BrandBanner } from "../components/BrandBanner"; import { RouterScreen } from "../components/RouterScreen"; +import { glyphs } from "../components/ui/_core.js"; import type { ScreenProps } from "./types"; +const FIRST_RUN_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) { - return } path={["agentcore"]} />; + const from = process.cwd(); + const projectDetected = useQuery({ + queryKey: ["project-detected", from], + queryFn: async () => + (await props.core.projectManager.resolve({ + filePath: from, + })) !== undefined, + gcTime: 0, + }); + + return ( + } + path={["agentcore"]} + optionHints={props.ctx.value(FirstRunKey) ? FIRST_RUN_HINTS : undefined} + alert={projectDetected.data === false ? NO_PROJECT_BANNER : undefined} + /> + ); } // HelpScreen is the final safety net for a route that does not resolve to an diff --git a/src/index.ts b/src/index.ts index 6ebebaa6cb..68b2f6e4ab 100644 --- a/src/index.ts +++ b/src/index.ts @@ -20,7 +20,7 @@ import { DefaultGlobalConfigAccessor } from "./globalConfig"; import { DefaultTelemetryClient, printFirstRunNotice } from "./telemetry"; import { AgentCoreCLIError } from "./errors"; import { PACKAGE_VERSION } from "./constants"; -import { CommandRunMetricEventKey, ValueContext } from "./router"; +import { CommandRunMetricEventKey, FirstRunKey, ValueContext } from "./router"; process.exit( await runWithExitCode(async (argv: string[]) => { @@ -86,10 +86,9 @@ process.exit( globalConfigAccessor, }); - const context = ValueContext.EmptyContext().withValue( - CommandRunMetricEventKey, - commandRunMetricEvent, - ); + const context = ValueContext.EmptyContext() + .withValue(CommandRunMetricEventKey, commandRunMetricEvent) + .withValue(FirstRunKey, globalConfig.isFirstRun ?? false); // Handle the request await rootHandler.route(argv, context); diff --git a/src/router/index.tsx b/src/router/index.tsx index c76085a67f..5b006b3c4b 100644 --- a/src/router/index.tsx +++ b/src/router/index.tsx @@ -7,6 +7,7 @@ export { PlatformKey, GlobalConfigAccessorKey, CommandRunMetricEventKey, + FirstRunKey, ProjectKey, type DefaultHandle, type DefaultHandlerProvider, diff --git a/src/router/router.tsx b/src/router/router.tsx index cb3b7b2b52..38d006f980 100644 --- a/src/router/router.tsx +++ b/src/router/router.tsx @@ -27,6 +27,8 @@ export const PlatformKey = contextKey("platform"); export const CommandRunMetricEventKey = contextKey>("commandRunMetricEvent"); +export const FirstRunKey = contextKey("firstRun"); + export const GlobalConfigAccessorKey: ContextKey = contextKey("globalConfigAccessor"); export const ProjectKey = contextKey("project"); diff --git a/src/testing/renderScreen.tsx b/src/testing/renderScreen.tsx index 70c32990db..bf674ec9d7 100644 --- a/src/testing/renderScreen.tsx +++ b/src/testing/renderScreen.tsx @@ -1,7 +1,14 @@ import { render, cleanup } from "ink-testing-library"; import { QueryClient } from "@tanstack/react-query"; import type { Command } from "commander"; -import { ValueContext, compile, CommandKey, PlatformKey, type Context } from "../router"; +import { + ValueContext, + compile, + CommandKey, + FirstRunKey, + PlatformKey, + type Context, +} from "../router"; import { RegionKey, JsonKey, DebugKey, EndpointKey } from "../handlers/keys"; import { JsonRendererKey } from "../tui"; import { createRootHandler } from "../handlers"; @@ -54,6 +61,7 @@ function baseContext( ): Context { return ValueContext.EmptyContext() .withValue(CommandKey, compiledRootCommand(core, globalConfig)) + .withValue(FirstRunKey, globalConfig.isFirstRun ?? false) .withValue(RegionKey, "us-east-1") .withValue(PlatformKey, platform) .withValue(EndpointKey, endpointUrl) From c6a1c055a2508834fdf114683bd51500b1aaba98 Mon Sep 17 00:00:00 2001 From: Nicolas Borges Date: Mon, 28 Sep 2026 15:07:29 -0400 Subject: [PATCH 2/7] feat(tui): guide users when no project is detected --- src/components/RouterScreen.test.tsx | 32 ++++++++++++++++------------ src/components/RouterScreen.tsx | 16 ++++++-------- src/components/ui/alert/Alert.tsx | 25 ++++++++++++++++++++++ src/components/ui/alert/index.ts | 1 + src/handlers/screen.tsx | 11 +++++----- src/index.ts | 9 ++++---- src/router/index.tsx | 1 - src/router/router.tsx | 2 -- src/testing/renderScreen.tsx | 10 +-------- 9 files changed, 62 insertions(+), 45 deletions(-) create mode 100644 src/components/ui/alert/Alert.tsx create mode 100644 src/components/ui/alert/index.ts diff --git a/src/components/RouterScreen.test.tsx b/src/components/RouterScreen.test.tsx index c47ea7df57..fabbe418e9 100644 --- a/src/components/RouterScreen.test.tsx +++ b/src/components/RouterScreen.test.tsx @@ -8,7 +8,6 @@ import { tick, waitForText, } from "../testing"; -import { DEFAULT_GLOBAL_CONFIG } from "../globalConfig"; import type { Project } from "../handlers/project/types"; import { glyphs } from "./ui/_core"; @@ -182,29 +181,34 @@ describe("menu rendering", () => { r.unmount(); }); - test("marks create as the starting point on the first run only", async () => { + test("marks create as the starting point only when no project is detected", async () => { const startHere = `${glyphs.leftArrow} start here`; - const firstRun = renderScreen("/agentcore", { - globalConfig: { ...DEFAULT_GLOBAL_CONFIG, isFirstRun: true }, - }); - await waitForText(firstRun.lastFrame, startHere); - expect(firstRun.lastFrame()).toContain("create"); - firstRun.unmount(); - - const returning = renderScreen("/agentcore"); - await waitForText(returning.lastFrame, "type to choose a command"); - expect(returning.lastFrame()).not.toContain(startHere); - returning.unmount(); + + 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 projectCore = new TestCoreClient(); + projectCore.projectManager.resolve = async () => ({}) as Project; + const withProject = renderScreen("/agentcore", { core: projectCore }); + await waitForText(withProject.lastFrame, "type to choose a command"); + await tick(20); + expect(withProject.lastFrame()).not.toContain(startHere); + 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 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(); diff --git a/src/components/RouterScreen.tsx b/src/components/RouterScreen.tsx index 9deabd217d..77aaecbee9 100644 --- a/src/components/RouterScreen.tsx +++ b/src/components/RouterScreen.tsx @@ -16,6 +16,7 @@ import { shouldHideBrandBanner, } from "./BrandBanner"; 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"; @@ -262,8 +263,9 @@ interface CommandMenuBodyProps { function alertRows(alert: string | undefined, columns: number): number { if (!alert) return 0; - const contentColumns = Math.max(1, columns - 2); - return 2 + Math.ceil(stringWidth(alert) / contentColumns); + // Two border/padding columns sit on each side of the alert content. + const contentColumns = Math.max(1, columns - 4); + return 2 + Math.ceil(stringWidth(`${glyphs.info} ${alert}`) / contentColumns); } function CommandMenuBody({ @@ -301,13 +303,7 @@ function CommandMenuBody({ - {alert && ( - - - {alert} - - - )} + {alert && {alert}} {filtered.length === 0 ? ( @@ -356,7 +352,7 @@ function CommandMenuBody({ {option.name.padEnd(nameWidth)} {option.description} - {option.hint && {option.hint}} + {option.hint && {option.hint}} ); }) diff --git a/src/components/ui/alert/Alert.tsx b/src/components/ui/alert/Alert.tsx new file mode 100644 index 0000000000..f5f918c440 --- /dev/null +++ b/src/components/ui/alert/Alert.tsx @@ -0,0 +1,25 @@ +import React from "react"; +import { Box, Text } from "ink"; +import { darkTheme, glyphs, type InkUITheme } from "../_core.js"; + +export interface AlertProps { + children: React.ReactNode; + theme?: InkUITheme; +} + +/** + * Compact informational callout for inline TUI guidance. + */ +export function Alert({ children, theme = darkTheme }: AlertProps) { + return ( + + {glyphs.info} + {children} + + ); +} diff --git a/src/components/ui/alert/index.ts b/src/components/ui/alert/index.ts new file mode 100644 index 0000000000..a46aa905d0 --- /dev/null +++ b/src/components/ui/alert/index.ts @@ -0,0 +1 @@ +export { Alert, type AlertProps } from "./Alert"; diff --git a/src/handlers/screen.tsx b/src/handlers/screen.tsx index 6d666b2759..7d650f7606 100644 --- a/src/handlers/screen.tsx +++ b/src/handlers/screen.tsx @@ -1,14 +1,14 @@ import { Text, useApp } from "ink"; import { useEffect } from "react"; import { useQuery } from "@tanstack/react-query"; -import { CommandKey, FirstRunKey } from "../router"; +import { CommandKey } from "../router"; import { BrandBanner } from "../components/BrandBanner"; import { RouterScreen } from "../components/RouterScreen"; import { glyphs } from "../components/ui/_core.js"; import type { ScreenProps } from "./types"; -const FIRST_RUN_HINTS = { create: `${glyphs.leftArrow} start here` }; -const NO_PROJECT_BANNER = "No project detected -- create a new project to get started"; +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(); @@ -20,14 +20,15 @@ export function RootScreen(props: ScreenProps) { })) !== undefined, gcTime: 0, }); + const noProjectDetected = projectDetected.data === false; return ( } path={["agentcore"]} - optionHints={props.ctx.value(FirstRunKey) ? FIRST_RUN_HINTS : undefined} - alert={projectDetected.data === false ? NO_PROJECT_BANNER : undefined} + optionHints={noProjectDetected ? NO_PROJECT_HINTS : undefined} + alert={noProjectDetected ? NO_PROJECT_BANNER : undefined} /> ); } diff --git a/src/index.ts b/src/index.ts index 68b2f6e4ab..6ebebaa6cb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -20,7 +20,7 @@ import { DefaultGlobalConfigAccessor } from "./globalConfig"; import { DefaultTelemetryClient, printFirstRunNotice } from "./telemetry"; import { AgentCoreCLIError } from "./errors"; import { PACKAGE_VERSION } from "./constants"; -import { CommandRunMetricEventKey, FirstRunKey, ValueContext } from "./router"; +import { CommandRunMetricEventKey, ValueContext } from "./router"; process.exit( await runWithExitCode(async (argv: string[]) => { @@ -86,9 +86,10 @@ process.exit( globalConfigAccessor, }); - const context = ValueContext.EmptyContext() - .withValue(CommandRunMetricEventKey, commandRunMetricEvent) - .withValue(FirstRunKey, globalConfig.isFirstRun ?? false); + const context = ValueContext.EmptyContext().withValue( + CommandRunMetricEventKey, + commandRunMetricEvent, + ); // Handle the request await rootHandler.route(argv, context); diff --git a/src/router/index.tsx b/src/router/index.tsx index 5b006b3c4b..c76085a67f 100644 --- a/src/router/index.tsx +++ b/src/router/index.tsx @@ -7,7 +7,6 @@ export { PlatformKey, GlobalConfigAccessorKey, CommandRunMetricEventKey, - FirstRunKey, ProjectKey, type DefaultHandle, type DefaultHandlerProvider, diff --git a/src/router/router.tsx b/src/router/router.tsx index 38d006f980..cb3b7b2b52 100644 --- a/src/router/router.tsx +++ b/src/router/router.tsx @@ -27,8 +27,6 @@ export const PlatformKey = contextKey("platform"); export const CommandRunMetricEventKey = contextKey>("commandRunMetricEvent"); -export const FirstRunKey = contextKey("firstRun"); - export const GlobalConfigAccessorKey: ContextKey = contextKey("globalConfigAccessor"); export const ProjectKey = contextKey("project"); diff --git a/src/testing/renderScreen.tsx b/src/testing/renderScreen.tsx index bf674ec9d7..70c32990db 100644 --- a/src/testing/renderScreen.tsx +++ b/src/testing/renderScreen.tsx @@ -1,14 +1,7 @@ import { render, cleanup } from "ink-testing-library"; import { QueryClient } from "@tanstack/react-query"; import type { Command } from "commander"; -import { - ValueContext, - compile, - CommandKey, - FirstRunKey, - PlatformKey, - type Context, -} from "../router"; +import { ValueContext, compile, CommandKey, PlatformKey, type Context } from "../router"; import { RegionKey, JsonKey, DebugKey, EndpointKey } from "../handlers/keys"; import { JsonRendererKey } from "../tui"; import { createRootHandler } from "../handlers"; @@ -61,7 +54,6 @@ function baseContext( ): Context { return ValueContext.EmptyContext() .withValue(CommandKey, compiledRootCommand(core, globalConfig)) - .withValue(FirstRunKey, globalConfig.isFirstRun ?? false) .withValue(RegionKey, "us-east-1") .withValue(PlatformKey, platform) .withValue(EndpointKey, endpointUrl) From 5526de31894d9ac524d36710febb75776761ff44 Mon Sep 17 00:00:00 2001 From: Tejas Kashinath <42380254+tejaskash@users.noreply.github.com> Date: Mon, 28 Sep 2026 17:03:33 -0400 Subject: [PATCH 3/7] feat(tui): grey out project commands when no project is detected (#2437) * feat(tui): grey out project commands when no project is detected * refactor(tui): share one project-detected lookup between the root banner and the menu --- src/components/RouterScreen.tsx | 11 ++++++++- src/handlers/index.tsx | 5 ++-- src/handlers/project/ProjectGate.tsx | 10 ++++++++ src/handlers/project/index.ts | 7 ++++-- src/handlers/root.test.tsx | 17 +++++++++++++ src/handlers/screen.tsx | 13 ++-------- src/router/index.tsx | 1 + src/router/router.test.ts | 16 +++++++++++++ src/router/router.tsx | 36 +++++++++++++++++++++++++--- 9 files changed, 96 insertions(+), 20 deletions(-) diff --git a/src/components/RouterScreen.tsx b/src/components/RouterScreen.tsx index 77aaecbee9..4891cee6c9 100644 --- a/src/components/RouterScreen.tsx +++ b/src/components/RouterScreen.tsx @@ -7,6 +7,7 @@ import { CommandKey, commandMenuSectionStart, isListedInMenu, + isProjectRequired, isTuiCommandSupported, } from "../router"; import { @@ -23,6 +24,7 @@ import { darkTheme, glyphs } from "./ui/_core.js"; import type { ScreenProps } from "../handlers/types"; import { RegionPinContext } from "../handlers/utils"; import { scrollWindow } from "./scrollWindow"; +import { useNoProjectDetected } from "../handlers/project/ProjectGate"; const theme = darkTheme; const PLACEHOLDER = "type to choose a command"; @@ -66,6 +68,7 @@ interface Option { cliOnly: boolean; // section is the divider title this option is listed under, if any. section?: string; + projectRequired?: boolean; } export interface TuiOnlyCommand { @@ -105,6 +108,7 @@ export function RouterScreen(props: RouterScreenProps) { } function CommandMenu({ + core, banner, path, tuiOnlyCommands = [], @@ -113,6 +117,7 @@ function CommandMenu({ command, }: RouterScreenProps & { command: Command }) { const navigate = useNavigate(); + const noProject = useNoProjectDetected(core); const { isRawModeSupported } = useStdin(); const { exit } = useApp(); @@ -143,6 +148,7 @@ function CommandMenu({ hint: optionHints?.[c.name()], cliOnly, section: cliOnly ? CLI_ONLY_SECTION : sectionOf(index), + projectRequired: isProjectRequired(c), }; }); const actualNames = new Set(actual.map((option) => option.name)); @@ -238,6 +244,7 @@ function CommandMenu({ highlight={highlight} isRawModeSupported={Boolean(isRawModeSupported)} nameWidth={nameWidth} + noProject={noProject} query={query} onQueryChange={(value) => { setQuery(value); @@ -257,6 +264,7 @@ interface CommandMenuBodyProps { highlight: number; isRawModeSupported: boolean; nameWidth: number; + noProject: boolean; query: string; onQueryChange: (value: string) => void; } @@ -276,6 +284,7 @@ function CommandMenuBody({ highlight, isRawModeSupported, nameWidth, + noProject, query, onQueryChange, }: CommandMenuBodyProps) { @@ -344,7 +353,7 @@ function CommandMenuBody({ color={ isHighlighted ? theme.colors.focus - : option.cliOnly + : option.cliOnly || (option.projectRequired && noProject) ? theme.colors.muted : theme.colors.text } diff --git a/src/handlers/index.tsx b/src/handlers/index.tsx index badaf0cc5d..b0f43e2bff 100644 --- a/src/handlers/index.tsx +++ b/src/handlers/index.tsx @@ -84,9 +84,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); root.menuSection("resources"); root.handler(createEvalHandler(core, io)); diff --git a/src/handlers/project/ProjectGate.tsx b/src/handlers/project/ProjectGate.tsx index cd20b6afd1..ffe9c9a696 100644 --- a/src/handlers/project/ProjectGate.tsx +++ b/src/handlers/project/ProjectGate.tsx @@ -50,6 +50,16 @@ export function useProjectTargets( }); } +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/project/index.ts b/src/handlers/project/index.ts index c3b5ad8ca8..3962feefd0 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({ @@ -76,5 +79,5 @@ export function createProjectHandlers(core: Core, io: AppIO): Handler[] { createExportProjectResourceHandler({ projectManager, core, io }), ]; - return [createHandler, ...projectBoundHandlers]; + return { createHandler, projectBoundHandlers }; } diff --git a/src/handlers/root.test.tsx b/src/handlers/root.test.tsx index 671dc209fe..9b112730fe 100644 --- a/src/handlers/root.test.tsx +++ b/src/handlers/root.test.tsx @@ -1,5 +1,6 @@ import { test, expect, describe } from "bun:test"; import { createRootHandler } from "./index"; +import { isProjectRequired } from "../router"; import { compiledRootCommand, createSilentLogger, @@ -59,6 +60,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", + "remove", + "dev", + "build", + "deploy", + "status", + "invoke", + "log", + "traces", + "export", + ]); + }); + test("--help lists commands in the root menu order", () => { const command = compiledRootCommand(); expect(helpCommandNames(command.helpInformation())).toEqual([ diff --git a/src/handlers/screen.tsx b/src/handlers/screen.tsx index 7d650f7606..50894ec4b0 100644 --- a/src/handlers/screen.tsx +++ b/src/handlers/screen.tsx @@ -1,26 +1,17 @@ import { Text, useApp } from "ink"; import { useEffect } from "react"; -import { useQuery } from "@tanstack/react-query"; import { CommandKey } from "../router"; import { BrandBanner } from "../components/BrandBanner"; 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 ( { + 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 cb3b7b2b52..ddc4190a20 100644 --- a/src/router/router.tsx +++ b/src/router/router.tsx @@ -41,7 +41,10 @@ class RoutedCommand extends Command { // menu instead of moving it under the "cli" divider. listedInMenu = false; - constructor(readonly handler: Handler) { + constructor( + readonly handler: Handler, + readonly projectRequired: boolean, + ) { super(handler.name()); } } @@ -58,6 +61,10 @@ export function isListedInMenu(command: Command): boolean { return command instanceof RoutedCommand && command.listedInMenu; } +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() @@ -221,10 +228,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({ @@ -285,7 +293,16 @@ export function compile( const childTuiSupported = effectiveTuiSupport && (!isTuiChildSupportProvider(node) || node.supportsTuiCommand(child.name())); - const childCommand = compile(child, ctx, nextStack, childGlobals, childTuiSupported); + const childProjectRequired = + projectRequired || (node instanceof Router && node.commandRequiresProject(child.name())); + const childCommand = compile( + child, + ctx, + nextStack, + childGlobals, + childTuiSupported, + childProjectRequired, + ); if (isMenuLayoutProvider(node) && childCommand instanceof RoutedCommand) { childCommand.menuSectionStart = node.menuSectionStartOf(child.name()); childCommand.listedInMenu = node.isListedInMenu(child.name()); @@ -325,6 +342,7 @@ export class Router implements Handler, MiddlewareProvider, DefaultHandlerProvid private sectionStarts = new Map(); private pendingSection?: string; private menuListed = new Set(); + private readonly projectCommandNames = new Set(); private cliVersion?: string; constructor( @@ -392,6 +410,18 @@ export class Router implements Handler, MiddlewareProvider, DefaultHandlerProvid return this.menuListed.has(commandName); } + 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 3f4338ce34c538527388a0b00affb4d000fb77f2 Mon Sep 17 00:00:00 2001 From: Tejas Kashinath Date: Tue, 29 Sep 2026 21:50:28 +0000 Subject: [PATCH 4/7] feat(tui): hide create from the root menu inside a project --- src/components/RouterScreen.test.tsx | 7 +++---- src/components/RouterScreen.tsx | 10 ++++++---- src/handlers/project/ProjectGate.tsx | 4 ++-- src/handlers/screen.tsx | 7 +++++-- 4 files changed, 16 insertions(+), 12 deletions(-) diff --git a/src/components/RouterScreen.test.tsx b/src/components/RouterScreen.test.tsx index fabbe418e9..cf99976c15 100644 --- a/src/components/RouterScreen.test.tsx +++ b/src/components/RouterScreen.test.tsx @@ -181,7 +181,7 @@ describe("menu rendering", () => { r.unmount(); }); - test("marks create as the starting point only when no project is detected", async () => { + 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(); @@ -194,9 +194,8 @@ describe("menu rendering", () => { const projectCore = new TestCoreClient(); projectCore.projectManager.resolve = async () => ({}) as Project; const withProject = renderScreen("/agentcore", { core: projectCore }); - await waitForText(withProject.lastFrame, "type to choose a command"); - await tick(20); - expect(withProject.lastFrame()).not.toContain(startHere); + await waitForText(withProject.lastFrame, "❯ add"); + expect(menuEntries(withProject.lastFrame()!).screens).not.toContain("create"); withProject.unmount(); }); diff --git a/src/components/RouterScreen.tsx b/src/components/RouterScreen.tsx index 4891cee6c9..788e97ef15 100644 --- a/src/components/RouterScreen.tsx +++ b/src/components/RouterScreen.tsx @@ -24,7 +24,7 @@ import { darkTheme, glyphs } from "./ui/_core.js"; import type { ScreenProps } from "../handlers/types"; import { RegionPinContext } from "../handlers/utils"; import { scrollWindow } from "./scrollWindow"; -import { useNoProjectDetected } from "../handlers/project/ProjectGate"; +import { useProjectDetected } from "../handlers/project/ProjectGate"; const theme = darkTheme; const PLACEHOLDER = "type to choose a command"; @@ -90,6 +90,7 @@ export interface RouterScreenProps extends ScreenProps { optionHints?: Readonly>; // alert is optional guidance rendered between the filter and menu options. alert?: string; + hiddenOptions?: readonly string[]; } // RouterScreen renders the interactive command menu for a Router node: a filter @@ -114,10 +115,11 @@ function CommandMenu({ tuiOnlyCommands = [], optionHints, alert, + hiddenOptions, command, }: RouterScreenProps & { command: Command }) { const navigate = useNavigate(); - const noProject = useNoProjectDetected(core); + const noProject = useProjectDetected(core) === false; const { isRawModeSupported } = useStdin(); const { exit } = useApp(); @@ -159,8 +161,8 @@ function CommandMenu({ ...tuiOnly, ...actual.filter((option) => !option.cliOnly), ...actual.filter((option) => option.cliOnly), - ]; - }, [command, optionHints, tuiOnlyCommands]); + ].filter((option) => !hiddenOptions?.includes(option.name)); + }, [command, optionHints, hiddenOptions, tuiOnlyCommands]); const [query, setQuery] = useState(""); const [index, setIndex] = useState(0); diff --git a/src/handlers/project/ProjectGate.tsx b/src/handlers/project/ProjectGate.tsx index ffe9c9a696..dfb3f5e0a3 100644 --- a/src/handlers/project/ProjectGate.tsx +++ b/src/handlers/project/ProjectGate.tsx @@ -50,14 +50,14 @@ export function useProjectTargets( }); } -export function useNoProjectDetected(core: Core): boolean { +export function useProjectDetected(core: Core): boolean | undefined { 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; + return detected.data; } export interface LoadingFrameProps { diff --git a/src/handlers/screen.tsx b/src/handlers/screen.tsx index 50894ec4b0..8238162f40 100644 --- a/src/handlers/screen.tsx +++ b/src/handlers/screen.tsx @@ -5,13 +5,15 @@ import { BrandBanner } from "../components/BrandBanner"; import { RouterScreen } from "../components/RouterScreen"; import { glyphs } from "../components/ui/_core.js"; import type { ScreenProps } from "./types"; -import { useNoProjectDetected } from "./project/ProjectGate"; +import { useProjectDetected } 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"; +const PROJECT_HIDDEN_OPTIONS = ["create"]; export function RootScreen(props: ScreenProps) { - const noProjectDetected = useNoProjectDetected(props.core); + const projectDetected = useProjectDetected(props.core); + const noProjectDetected = projectDetected === false; return ( ); } From c59ada9d7e796e4b9dadab2b2b182d4c52b268ef Mon Sep 17 00:00:00 2001 From: Tejas Kashinath Date: Tue, 29 Sep 2026 22:00:57 +0000 Subject: [PATCH 5/7] fix(tui): resolve the project before the first frame so the root menu never shifts --- src/components/Root.tsx | 14 +++--------- src/handlers/project/ProjectGate.tsx | 13 ++++++----- src/tui/index.tsx | 24 +++++++++++++------- src/tui/tui.test.tsx | 34 ++++++++++++++++++++++++++++ 4 files changed, 60 insertions(+), 25 deletions(-) diff --git a/src/components/Root.tsx b/src/components/Root.tsx index 927c86ac79..3205d2f9a2 100644 --- a/src/components/Root.tsx +++ b/src/components/Root.tsx @@ -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"; @@ -155,25 +155,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 ( - + (await core.projectManager.resolve({ filePath: from })) !== undefined, - gcTime: 0, }); - return detected.data; +} + +export function useProjectDetected(core: Core): boolean | undefined { + return useQuery(projectDetectedQuery(core)).data; } export interface LoadingFrameProps { diff --git a/src/tui/index.tsx b/src/tui/index.tsx index 726f25fca2..bfe5592ef1 100644 --- a/src/tui/index.tsx +++ b/src/tui/index.tsx @@ -1,4 +1,5 @@ import { render } from "ink"; +import { QueryClient } from "@tanstack/react-query"; import { Root } from "../components/Root"; import { type Context, @@ -12,6 +13,7 @@ import { import type { AppIO } from "../io"; import type { Core } from "../handlers/types"; import { JsonKey } from "../handlers/keys"; +import { projectDetectedQuery } from "../handlers/project/ProjectGate"; import { ExitCode, InvalidEnvironmentError } from "../errors"; import { TuiExitMessageKey } from "./exitMessage"; import { createResizeGate } from "./resize"; @@ -68,15 +70,21 @@ export async function renderTuiAt( // 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). + const queryClient = new QueryClient(); + await queryClient.prefetchQuery(projectDetectedQuery(core)); + const resizeGate = createResizeGate(io.stdout); - const { waitUntilExit } = render(, { - stdin: io.stdin, - stdout: resizeGate.stdout, - stderr: io.stderr, - interactive: true, - alternateScreen: true, - incrementalRendering: false, - }); + const { waitUntilExit } = render( + , + { + stdin: io.stdin, + stdout: resizeGate.stdout, + stderr: io.stderr, + interactive: true, + alternateScreen: true, + incrementalRendering: false, + }, + ); try { await waitUntilExit(); } finally { diff --git a/src/tui/tui.test.tsx b/src/tui/tui.test.tsx index 30b946434d..41a0da666f 100644 --- a/src/tui/tui.test.tsx +++ b/src/tui/tui.test.tsx @@ -4,6 +4,7 @@ import { createRootHandler } from "../handlers"; import { ExitCode, InvalidEnvironmentError, ProjectStateError } from "../errors"; import { renderJson } from "./index"; import { handoffArgs } from "./handoff"; +import type { Project } from "../handlers/project/types"; import { createSilentLogger, inTempDirectory, @@ -225,3 +226,36 @@ describe("TUI resize", () => { await expect(routePromise).resolves.toBeUndefined(); }); }); + +describe("TUI launch", () => { + const CREATE_ROW = "create a new AgentCore project"; + const BANNER = "No project detected - create a new project to get started"; + + async function launchRootMenu(project: Project | undefined): Promise { + const core = new TestCoreClient(); + core.projectManager.resolve = async () => project; + const { streams, stdin } = ttyTestIO(); + const root = createRootHandler(core, { + io: streams.io, + logger: createSilentLogger(), + globalConfigAccessor: new TestGlobalConfigAccessor(), + }); + const routePromise = root.route(["node", "agentcore"]); + await waitFor(() => streams.stdout().includes("add project resources")); + stdin.write(String.fromCharCode(3)); + await routePromise; + return streams.stdout(); + } + + test("the first frame outside a project already carries the banner", async () => { + const out = await launchRootMenu(undefined); + expect(out.indexOf(BANNER)).toBeGreaterThan(-1); + expect(out.indexOf(BANNER)).toBeLessThan(out.indexOf(CREATE_ROW)); + }); + + test("create is never drawn inside a project", async () => { + const out = await launchRootMenu({} as Project); + expect(out).not.toContain(CREATE_ROW); + expect(out).not.toContain(BANNER); + }); +}); From 8b94627fc899c47493694df56e6bcf8d67277af3 Mon Sep 17 00:00:00 2001 From: Tejas Kashinath Date: Tue, 29 Sep 2026 22:58:04 +0000 Subject: [PATCH 6/7] feat(tui): outside a project show only create; inside, everything but create --- src/components/CliOnlyScreen.test.tsx | 8 +-- src/components/RouterScreen.test.tsx | 45 +++++------- src/components/RouterScreen.tsx | 13 ++-- src/handlers/project/add/add.screen.test.tsx | 3 +- .../project/status/status.screen.test.tsx | 1 + src/handlers/screen.tsx | 13 +++- src/testing/index.tsx | 7 +- src/testing/projects.ts | 7 ++ src/tui/tui.test.tsx | 70 ++++++++++++------- 9 files changed, 98 insertions(+), 69 deletions(-) diff --git a/src/components/CliOnlyScreen.test.tsx b/src/components/CliOnlyScreen.test.tsx index 4e53f47489..7ac5e00f53 100644 --- a/src/components/CliOnlyScreen.test.tsx +++ b/src/components/CliOnlyScreen.test.tsx @@ -7,6 +7,7 @@ import { menuEntries, renderScreen, waitForText, + inProjectCore, } from "../testing"; import { CommandKey, isTuiCommandSupported } from "../router"; @@ -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", { core: inProjectCore() }); - await waitForText(r.lastFrame, "── cli "); + await waitForText(r.lastFrame, "❯ add"); expect(menuEntries(r.lastFrame()!)).toEqual({ screens: [ - "create", "add", "remove", "dev", @@ -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("/"), { core: inProjectCore() }); // 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); diff --git a/src/components/RouterScreen.test.tsx b/src/components/RouterScreen.test.tsx index cf99976c15..e5c67307f8 100644 --- a/src/components/RouterScreen.test.tsx +++ b/src/components/RouterScreen.test.tsx @@ -2,13 +2,13 @@ import { test, expect, describe, afterEach } from "bun:test"; import { PACKAGE_VERSION } from "../constants"; import { cleanupScreens, + inProjectCore, menuEntries, renderScreen, TestCoreClient, tick, waitForText, } from "../testing"; -import type { Project } from "../handlers/project/types"; import { glyphs } from "./ui/_core"; afterEach(cleanupScreens); @@ -52,12 +52,12 @@ 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"); 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, @@ -80,11 +80,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", { core: inProjectCore() }); + 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"], @@ -118,7 +118,7 @@ describe("menu rendering", () => { }); test("filtering to the project workflow leaves no resources divider", async () => { - const r = renderScreen("/agentcore"); + const r = renderScreen("/agentcore", { core: inProjectCore() }); await waitForText(r.lastFrame, "type to choose a command"); await r.write("dep"); @@ -191,9 +191,7 @@ describe("menu rendering", () => { expect(withoutProject.lastFrame()).toContain("create"); withoutProject.unmount(); - const projectCore = new TestCoreClient(); - projectCore.projectManager.resolve = async () => ({}) as Project; - const withProject = renderScreen("/agentcore", { core: projectCore }); + const withProject = renderScreen("/agentcore", { core: inProjectCore() }); await waitForText(withProject.lastFrame, "❯ add"); expect(menuEntries(withProject.lastFrame()!).screens).not.toContain("create"); withProject.unmount(); @@ -212,9 +210,7 @@ describe("menu rendering", () => { expect(frame.indexOf(banner)).toBeLessThan(frame.indexOf("create")); withoutProject.unmount(); - const core = new TestCoreClient(); - core.projectManager.resolve = async () => ({}) as Project; - const withProject = renderScreen("/agentcore", { core }); + const withProject = renderScreen("/agentcore", { core: inProjectCore() }); await waitForText(withProject.lastFrame, "type to choose a command"); await tick(20); expect(withProject.lastFrame()).not.toContain(banner); @@ -258,10 +254,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", { core: inProjectCore() }); await waitForText(r.lastFrame, "❯ add"); await r.press("down"); @@ -328,8 +321,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", { core: inProjectCore() }); + await waitForText(r.lastFrame, "❯ add"); const frame = r.lastFrame()!; r.unmount(); const titles = new Map(); @@ -355,8 +348,8 @@ describe("short terminals", () => { } async function shortMenu() { - const r = renderScreen("/agentcore"); - await waitForText(r.lastFrame, "❯ create"); + const r = renderScreen("/agentcore", { core: inProjectCore() }); + await waitForText(r.lastFrame, "❯ add"); await r.resize(100, ROWS); return r; } @@ -417,7 +410,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); @@ -466,8 +459,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", { core: inProjectCore() }); + await waitForText(r.lastFrame, "❯ add"); const target = full.names.indexOf("harness"); for (let i = 0; i < target; i++) await r.press("down"); @@ -484,8 +477,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", { core: inProjectCore() }); + await waitForText(r.lastFrame, "❯ add"); await r.resize(40, ROWS); for (let i = 0; i < full.names.length; i++) { diff --git a/src/components/RouterScreen.tsx b/src/components/RouterScreen.tsx index 788e97ef15..d443314b77 100644 --- a/src/components/RouterScreen.tsx +++ b/src/components/RouterScreen.tsx @@ -156,13 +156,15 @@ function CommandMenu({ const actualNames = new Set(actual.map((option) => option.name)); const tuiOnly = tuiOnlyCommands .filter((option) => !actualNames.has(option.name)) - .map((option) => ({ ...option, hint: optionHints?.[option.name], cliOnly: false })); + .map((option): Option => ({ ...option, hint: optionHints?.[option.name], cliOnly: false })); return [ ...tuiOnly, ...actual.filter((option) => !option.cliOnly), ...actual.filter((option) => option.cliOnly), - ].filter((option) => !hiddenOptions?.includes(option.name)); - }, [command, optionHints, hiddenOptions, tuiOnlyCommands]); + ].filter( + (option) => !hiddenOptions?.includes(option.name) && !(option.projectRequired && noProject), + ); + }, [command, optionHints, hiddenOptions, noProject, tuiOnlyCommands]); const [query, setQuery] = useState(""); const [index, setIndex] = useState(0); @@ -246,7 +248,6 @@ function CommandMenu({ highlight={highlight} isRawModeSupported={Boolean(isRawModeSupported)} nameWidth={nameWidth} - noProject={noProject} query={query} onQueryChange={(value) => { setQuery(value); @@ -266,7 +267,6 @@ interface CommandMenuBodyProps { highlight: number; isRawModeSupported: boolean; nameWidth: number; - noProject: boolean; query: string; onQueryChange: (value: string) => void; } @@ -286,7 +286,6 @@ function CommandMenuBody({ highlight, isRawModeSupported, nameWidth, - noProject, query, onQueryChange, }: CommandMenuBodyProps) { @@ -355,7 +354,7 @@ function CommandMenuBody({ color={ isHighlighted ? theme.colors.focus - : option.cliOnly || (option.projectRequired && noProject) + : option.cliOnly ? theme.colors.muted : theme.colors.text } diff --git a/src/handlers/project/add/add.screen.test.tsx b/src/handlers/project/add/add.screen.test.tsx index 9df953028a..f7a2f6f49a 100644 --- a/src/handlers/project/add/add.screen.test.tsx +++ b/src/handlers/project/add/add.screen.test.tsx @@ -5,6 +5,7 @@ import { cleanupScreens, compiledRootCommand, menuEntries, + inProjectCore, } from "../../../testing"; afterEach(cleanupScreens); @@ -61,7 +62,7 @@ describe("project add menu", () => { }); test("is reachable from the root menu", async () => { - const r = renderScreen("/agentcore"); + const r = renderScreen("/agentcore", { core: inProjectCore() }); await waitForText(r.lastFrame, "the platform for production AI agents"); await r.write("add"); diff --git a/src/handlers/project/status/status.screen.test.tsx b/src/handlers/project/status/status.screen.test.tsx index b8bbffdc2d..5ffb92b7e1 100644 --- a/src/handlers/project/status/status.screen.test.tsx +++ b/src/handlers/project/status/status.screen.test.tsx @@ -306,6 +306,7 @@ describe("project status screen", () => { test("several targets: asks which, keeps the choice and its region across a detail page, esc returns to the choice, and a menu unpins", async () => { const value = core(RUNTIME_RESOURCES, [TARGET, STAGING]); + value.projectManager.resolve = async () => RUNTIME_PROJECT; const screen = renderStatus(value); await waitForText(screen.lastFrame, "choose a deployment target"); diff --git a/src/handlers/screen.tsx b/src/handlers/screen.tsx index 8238162f40..22102b2b28 100644 --- a/src/handlers/screen.tsx +++ b/src/handlers/screen.tsx @@ -8,8 +8,9 @@ import type { ScreenProps } from "./types"; import { useProjectDetected } 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"; +const NO_PROJECT_ALERT = "No project detected - create a new project to get started"; const PROJECT_HIDDEN_OPTIONS = ["create"]; +const NO_PROJECT_HIDDEN_OPTIONS = ["eval"]; export function RootScreen(props: ScreenProps) { const projectDetected = useProjectDetected(props.core); @@ -21,8 +22,14 @@ export function RootScreen(props: ScreenProps) { banner={} path={["agentcore"]} optionHints={noProjectDetected ? NO_PROJECT_HINTS : undefined} - alert={noProjectDetected ? NO_PROJECT_BANNER : undefined} - hiddenOptions={projectDetected ? PROJECT_HIDDEN_OPTIONS : undefined} + alert={noProjectDetected ? NO_PROJECT_ALERT : undefined} + hiddenOptions={ + projectDetected + ? PROJECT_HIDDEN_OPTIONS + : noProjectDetected + ? NO_PROJECT_HIDDEN_OPTIONS + : undefined + } /> ); } diff --git a/src/testing/index.tsx b/src/testing/index.tsx index 3ec0dbb448..bd4e45245d 100644 --- a/src/testing/index.tsx +++ b/src/testing/index.tsx @@ -36,5 +36,10 @@ export { export { createSilentLogger, assertLogsMatch, type LogQuery } from "./logging"; export { TestGlobalConfigAccessor } from "./globalConfig"; export { inTempDirectory, type TempDirectory } from "./fs"; -export { initProject, type InitProjectOptions, type InitializedProject } from "./projects"; +export { + initProject, + inProjectCore, + type InitProjectOptions, + type InitializedProject, +} from "./projects"; export { expectError } from "./errors"; diff --git a/src/testing/projects.ts b/src/testing/projects.ts index 8857cd4f74..a4a661ff25 100644 --- a/src/testing/projects.ts +++ b/src/testing/projects.ts @@ -1,6 +1,7 @@ import { join } from "node:path"; import { createRootHandler, type RootHandlerConfig } from "../handlers"; import type { Core } from "../handlers/types"; +import type { Project } from "../handlers/project/types"; import { inTempDirectory } from "./fs"; import { createSilentLogger } from "./logging"; import { testIO } from "./testIO"; @@ -60,3 +61,9 @@ export async function initProject(options: InitProjectOptions = {}): Promise ({}) as Project; + return core; +} diff --git a/src/tui/tui.test.tsx b/src/tui/tui.test.tsx index 41a0da666f..24c3bcc59b 100644 --- a/src/tui/tui.test.tsx +++ b/src/tui/tui.test.tsx @@ -1,13 +1,13 @@ import { test, expect, describe } from "bun:test"; import { Command } from "commander"; import { createRootHandler } from "../handlers"; -import { ExitCode, InvalidEnvironmentError, ProjectStateError } from "../errors"; +import { ExitCode, InputValidationError, InvalidEnvironmentError } from "../errors"; import { renderJson } from "./index"; import { handoffArgs } from "./handoff"; import type { Project } from "../handlers/project/types"; import { createSilentLogger, - inTempDirectory, + inProjectCore, TestCoreClient, TestGlobalConfigAccessor, testIO, @@ -120,28 +120,30 @@ 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")); + const core = new TestCoreClient(); + core.projectManager.resolve = async () => + ({ + name: "test-project", + rootPath: process.cwd(), + spec: { runtimes: [] } as unknown as Project["spec"], + }) as Project; + const { streams, stdin } = ttyTestIO(); + const root = createRootHandler(core, { + 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"); + 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(); - } + // The project has no runtimes, so dev's own validation 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(InputValidationError); }); test("carries over global flags given on the command line", async () => { @@ -168,7 +170,7 @@ describe("TUI resize", () => { test("shrinking redraws once after resizing settles", async () => { const { streams, stdin } = ttyTestIO(120, 40); - const root = createRootHandler(new TestCoreClient(), { + const root = createRootHandler(inProjectCore(), { io: streams.io, logger: createSilentLogger(), globalConfigAccessor: new TestGlobalConfigAccessor(), @@ -202,7 +204,7 @@ describe("TUI resize", () => { test("repaints when a shrink gesture returns to the cached size", async () => { const { streams, stdin } = ttyTestIO(100, 40); - const root = createRootHandler(new TestCoreClient(), { + const root = createRootHandler(inProjectCore(), { io: streams.io, logger: createSilentLogger(), globalConfigAccessor: new TestGlobalConfigAccessor(), @@ -241,21 +243,35 @@ describe("TUI launch", () => { globalConfigAccessor: new TestGlobalConfigAccessor(), }); const routePromise = root.route(["node", "agentcore"]); - await waitFor(() => streams.stdout().includes("add project resources")); + await waitFor(() => streams.stdout().includes("read/write global config values")); stdin.write(String.fromCharCode(3)); await routePromise; return streams.stdout(); } - test("the first frame outside a project already carries the banner", async () => { + test("outside a project the first frame shows create under the banner and hides project commands and eval", async () => { const out = await launchRootMenu(undefined); expect(out.indexOf(BANNER)).toBeGreaterThan(-1); expect(out.indexOf(BANNER)).toBeLessThan(out.indexOf(CREATE_ROW)); + for (const hidden of [ + "add project resources", + "deploy the project to AWS", + "evaluate and optimize", + ]) { + expect(out).not.toContain(hidden); + } }); - test("create is never drawn inside a project", async () => { + test("inside a project create is never drawn and the rest of the menu is", async () => { const out = await launchRootMenu({} as Project); expect(out).not.toContain(CREATE_ROW); expect(out).not.toContain(BANNER); + for (const shown of [ + "add project resources", + "deploy the project to AWS", + "evaluate and optimize", + ]) { + expect(out).toContain(shown); + } }); }); From 044cf0cb799ce9c199b81c2b562595a1d173c771 Mon Sep 17 00:00:00 2001 From: Nicolas Borges Date: Tue, 29 Sep 2026 22:20:13 -0400 Subject: [PATCH 7/7] fix(tui): filter project commands outside the router --- src/components/CliOnlyScreen.test.tsx | 8 ++--- src/components/Root.tsx | 10 ++++-- src/components/RouterScreen.test.tsx | 27 ++++++++------ src/components/RouterScreen.tsx | 12 ++----- src/handlers/index.tsx | 5 +-- src/handlers/project/ProjectGate.tsx | 13 +------ src/handlers/project/add/add.screen.test.tsx | 4 +-- .../project/buildDeploy.screen.test.tsx | 13 +++++-- src/handlers/project/context.ts | 5 +++ src/handlers/project/index.ts | 7 ++-- src/handlers/root.test.tsx | 17 --------- src/handlers/screen.tsx | 34 ++++++++++-------- src/router/index.tsx | 1 - src/router/router.test.ts | 16 --------- src/router/router.tsx | 36 ++----------------- src/testing/index.tsx | 1 + src/testing/projects.ts | 6 ++++ src/tui/index.tsx | 10 ++++-- 18 files changed, 93 insertions(+), 132 deletions(-) create mode 100644 src/handlers/project/context.ts diff --git a/src/components/CliOnlyScreen.test.tsx b/src/components/CliOnlyScreen.test.tsx index 7ac5e00f53..c72e90372e 100644 --- a/src/components/CliOnlyScreen.test.tsx +++ b/src/components/CliOnlyScreen.test.tsx @@ -7,7 +7,7 @@ import { menuEntries, renderScreen, waitForText, - inProjectCore, + inProjectContext, } from "../testing"; import { CommandKey, isTuiCommandSupported } from "../router"; @@ -39,7 +39,7 @@ const CLI_ONLY = cliOnlyCommands(); describe("menus list command-line-only subcommands below a divider", () => { test("the root menu", async () => { - const r = renderScreen("/agentcore", { core: inProjectCore() }); + const r = renderScreen("/agentcore", { withContext: inProjectContext }); await waitForText(r.lastFrame, "❯ add"); expect(menuEntries(r.lastFrame()!)).toEqual({ @@ -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("/"), { core: inProjectCore() }); + 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); @@ -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); diff --git a/src/components/Root.tsx b/src/components/Root.tsx index 3205d2f9a2..f97d60c73c 100644 --- a/src/components/Root.tsx +++ b/src/components/Root.tsx @@ -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"). @@ -251,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 ( - } /> + } + /> {/* `dev` owns the terminal, so selecting it closes the TUI and runs it. */} { const entries = menuEntries(frame); expect(entries.screens).toContain("create"); 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, @@ -80,7 +87,7 @@ describe("menu rendering", () => { }); test("lists the resources alphabetically under a resources divider after the project commands", async () => { - const r = renderScreen("/agentcore", { core: inProjectCore() }); + const r = renderScreen("/agentcore", { withContext: inProjectContext }); await waitForText(r.lastFrame, "❯ add"); expect(menuGroups(r.lastFrame()!)).toEqual([ @@ -118,7 +125,7 @@ describe("menu rendering", () => { }); test("filtering to the project workflow leaves no resources divider", async () => { - const r = renderScreen("/agentcore", { core: inProjectCore() }); + const r = renderScreen("/agentcore", { withContext: inProjectContext }); await waitForText(r.lastFrame, "type to choose a command"); await r.write("dep"); @@ -191,7 +198,7 @@ describe("menu rendering", () => { expect(withoutProject.lastFrame()).toContain("create"); withoutProject.unmount(); - const withProject = renderScreen("/agentcore", { core: inProjectCore() }); + const withProject = renderScreen("/agentcore", { withContext: inProjectContext }); await waitForText(withProject.lastFrame, "❯ add"); expect(menuEntries(withProject.lastFrame()!).screens).not.toContain("create"); withProject.unmount(); @@ -210,7 +217,7 @@ describe("menu rendering", () => { expect(frame.indexOf(banner)).toBeLessThan(frame.indexOf("create")); withoutProject.unmount(); - const withProject = renderScreen("/agentcore", { core: inProjectCore() }); + const withProject = renderScreen("/agentcore", { withContext: inProjectContext }); await waitForText(withProject.lastFrame, "type to choose a command"); await tick(20); expect(withProject.lastFrame()).not.toContain(banner); @@ -254,7 +261,7 @@ describe("filtering", () => { describe("navigation", () => { test("down arrow moves the highlight to the next option", async () => { - const r = renderScreen("/agentcore", { core: inProjectCore() }); + const r = renderScreen("/agentcore", { withContext: inProjectContext }); await waitForText(r.lastFrame, "❯ add"); await r.press("down"); @@ -321,7 +328,7 @@ describe("short terminals", () => { // fullMenu renders the root menu at the default height, where every option fits. async function fullMenu() { - const r = renderScreen("/agentcore", { core: inProjectCore() }); + const r = renderScreen("/agentcore", { withContext: inProjectContext }); await waitForText(r.lastFrame, "❯ add"); const frame = r.lastFrame()!; r.unmount(); @@ -348,7 +355,7 @@ describe("short terminals", () => { } async function shortMenu() { - const r = renderScreen("/agentcore", { core: inProjectCore() }); + const r = renderScreen("/agentcore", { withContext: inProjectContext }); await waitForText(r.lastFrame, "❯ add"); await r.resize(100, ROWS); return r; @@ -459,7 +466,7 @@ describe("short terminals", () => { test("resizing shorter keeps the highlight visible", async () => { const full = await fullMenu(); - const r = renderScreen("/agentcore", { core: inProjectCore() }); + const r = renderScreen("/agentcore", { withContext: inProjectContext }); await waitForText(r.lastFrame, "❯ add"); const target = full.names.indexOf("harness"); @@ -477,7 +484,7 @@ describe("short terminals", () => { test("accounts for a wrapped header when scrolling a narrow terminal", async () => { const full = await fullMenu(); - const r = renderScreen("/agentcore", { core: inProjectCore() }); + const r = renderScreen("/agentcore", { withContext: inProjectContext }); await waitForText(r.lastFrame, "❯ add"); await r.resize(40, ROWS); diff --git a/src/components/RouterScreen.tsx b/src/components/RouterScreen.tsx index d443314b77..0c7805322d 100644 --- a/src/components/RouterScreen.tsx +++ b/src/components/RouterScreen.tsx @@ -7,7 +7,6 @@ import { CommandKey, commandMenuSectionStart, isListedInMenu, - isProjectRequired, isTuiCommandSupported, } from "../router"; import { @@ -24,7 +23,6 @@ import { darkTheme, glyphs } from "./ui/_core.js"; import type { ScreenProps } from "../handlers/types"; import { RegionPinContext } from "../handlers/utils"; import { scrollWindow } from "./scrollWindow"; -import { useProjectDetected } from "../handlers/project/ProjectGate"; const theme = darkTheme; const PLACEHOLDER = "type to choose a command"; @@ -68,7 +66,6 @@ interface Option { cliOnly: boolean; // section is the divider title this option is listed under, if any. section?: string; - projectRequired?: boolean; } export interface TuiOnlyCommand { @@ -109,7 +106,6 @@ export function RouterScreen(props: RouterScreenProps) { } function CommandMenu({ - core, banner, path, tuiOnlyCommands = [], @@ -119,7 +115,6 @@ function CommandMenu({ command, }: RouterScreenProps & { command: Command }) { const navigate = useNavigate(); - const noProject = useProjectDetected(core) === false; const { isRawModeSupported } = useStdin(); const { exit } = useApp(); @@ -150,7 +145,6 @@ function CommandMenu({ hint: optionHints?.[c.name()], cliOnly, section: cliOnly ? CLI_ONLY_SECTION : sectionOf(index), - projectRequired: isProjectRequired(c), }; }); const actualNames = new Set(actual.map((option) => option.name)); @@ -161,10 +155,8 @@ function CommandMenu({ ...tuiOnly, ...actual.filter((option) => !option.cliOnly), ...actual.filter((option) => option.cliOnly), - ].filter( - (option) => !hiddenOptions?.includes(option.name) && !(option.projectRequired && noProject), - ); - }, [command, optionHints, hiddenOptions, noProject, tuiOnlyCommands]); + ].filter((option) => !hiddenOptions?.includes(option.name)); + }, [command, optionHints, hiddenOptions, tuiOnlyCommands]); const [query, setQuery] = useState(""); const [index, setIndex] = useState(0); diff --git a/src/handlers/index.tsx b/src/handlers/index.tsx index b0f43e2bff..badaf0cc5d 100644 --- a/src/handlers/index.tsx +++ b/src/handlers/index.tsx @@ -84,8 +84,9 @@ 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. - const { createHandler, projectBoundHandlers } = createProjectHandlers(core, io); - root.handler(createHandler).projectHandlers(...projectBoundHandlers); + createProjectHandlers(core, io).forEach((handler) => { + root.handler(handler); + }); root.menuSection("resources"); root.handler(createEvalHandler(core, io)); diff --git a/src/handlers/project/ProjectGate.tsx b/src/handlers/project/ProjectGate.tsx index b099a125fa..cd20b6afd1 100644 --- a/src/handlers/project/ProjectGate.tsx +++ b/src/handlers/project/ProjectGate.tsx @@ -1,5 +1,5 @@ import React from "react"; -import { queryOptions, useQuery, type UseQueryResult } from "@tanstack/react-query"; +import { useQuery, type UseQueryResult } from "@tanstack/react-query"; import { Box, Text, useInput } from "ink"; import { Layout } from "../../components/Layout"; import { Spinner } from "../../components/ui/spinner"; @@ -50,17 +50,6 @@ export function useProjectTargets( }); } -export function projectDetectedQuery(core: Core, from = process.cwd()) { - return queryOptions({ - queryKey: ["project-detected", from], - queryFn: async () => (await core.projectManager.resolve({ filePath: from })) !== undefined, - }); -} - -export function useProjectDetected(core: Core): boolean | undefined { - return useQuery(projectDetectedQuery(core)).data; -} - export interface LoadingFrameProps { breadcrumb: string[]; description?: string; diff --git a/src/handlers/project/add/add.screen.test.tsx b/src/handlers/project/add/add.screen.test.tsx index f7a2f6f49a..1fcd2f68a2 100644 --- a/src/handlers/project/add/add.screen.test.tsx +++ b/src/handlers/project/add/add.screen.test.tsx @@ -5,7 +5,7 @@ import { cleanupScreens, compiledRootCommand, menuEntries, - inProjectCore, + inProjectContext, } from "../../../testing"; afterEach(cleanupScreens); @@ -62,7 +62,7 @@ describe("project add menu", () => { }); test("is reachable from the root menu", async () => { - const r = renderScreen("/agentcore", { core: inProjectCore() }); + const r = renderScreen("/agentcore", { withContext: inProjectContext }); await waitForText(r.lastFrame, "the platform for production AI agents"); await r.write("add"); diff --git a/src/handlers/project/buildDeploy.screen.test.tsx b/src/handlers/project/buildDeploy.screen.test.tsx index 5931542ea0..c740f13f93 100644 --- a/src/handlers/project/buildDeploy.screen.test.tsx +++ b/src/handlers/project/buildDeploy.screen.test.tsx @@ -7,6 +7,7 @@ import { cleanupScreens, flatFrame, initProject, + inProjectContext, inTempDirectory, renderScreen, TestCoreClient, @@ -245,7 +246,11 @@ describe("project deploy screen", () => { queries: { retry: false, gcTime: Infinity, staleTime: 0 }, }, }); - const r = renderScreen("/agentcore/deploy", { core, queryClient }); + const r = renderScreen("/agentcore/deploy", { + core, + queryClient, + withContext: inProjectContext, + }); await waitForText(r.lastFrame, "✔ Deployed project 'orders' to target 'default'"); expect(deploys).toHaveLength(1); @@ -273,7 +278,11 @@ describe("project deploy screen", () => { queries: { retry: false, gcTime: Infinity, staleTime: 0 }, }, }); - const r = renderScreen("/agentcore/deploy", { core, queryClient }); + const r = renderScreen("/agentcore/deploy", { + core, + queryClient, + withContext: inProjectContext, + }); await waitForText(r.lastFrame, "✔ Deployed project 'orders' to target 'default'"); expect(deploys).toHaveLength(1); diff --git a/src/handlers/project/context.ts b/src/handlers/project/context.ts new file mode 100644 index 0000000000..8d6a5b6c05 --- /dev/null +++ b/src/handlers/project/context.ts @@ -0,0 +1,5 @@ +import { contextKey } from "../../router"; + +// ProjectDetectedKey records whether the TUI was launched from inside a project +// without pinning a Project object that could become stale while the TUI is open. +export const ProjectDetectedKey = contextKey("project.detected"); diff --git a/src/handlers/project/index.ts b/src/handlers/project/index.ts index 3962feefd0..c3b5ad8ca8 100644 --- a/src/handlers/project/index.ts +++ b/src/handlers/project/index.ts @@ -20,10 +20,7 @@ import { createProjectInvokeHandler } from "./invoke"; import { createProjectLogHandler } from "./log"; import { createProjectTracesHandler } from "./traces"; -export function createProjectHandlers( - core: Core, - io: AppIO, -): { createHandler: Handler; projectBoundHandlers: Handler[] } { +export function createProjectHandlers(core: Core, io: AppIO): Handler[] { const projectManager: ProjectManager = core.projectManager; const createHandler = createCreateProjectHandler({ @@ -79,5 +76,5 @@ export function createProjectHandlers( createExportProjectResourceHandler({ projectManager, core, io }), ]; - return { createHandler, projectBoundHandlers }; + return [createHandler, ...projectBoundHandlers]; } diff --git a/src/handlers/root.test.tsx b/src/handlers/root.test.tsx index 9b112730fe..671dc209fe 100644 --- a/src/handlers/root.test.tsx +++ b/src/handlers/root.test.tsx @@ -1,6 +1,5 @@ import { test, expect, describe } from "bun:test"; import { createRootHandler } from "./index"; -import { isProjectRequired } from "../router"; import { compiledRootCommand, createSilentLogger, @@ -60,22 +59,6 @@ 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", - "remove", - "dev", - "build", - "deploy", - "status", - "invoke", - "log", - "traces", - "export", - ]); - }); - test("--help lists commands in the root menu order", () => { const command = compiledRootCommand(); expect(helpCommandNames(command.helpInformation())).toEqual([ diff --git a/src/handlers/screen.tsx b/src/handlers/screen.tsx index 22102b2b28..e9079d8668 100644 --- a/src/handlers/screen.tsx +++ b/src/handlers/screen.tsx @@ -5,31 +5,37 @@ import { BrandBanner } from "../components/BrandBanner"; import { RouterScreen } from "../components/RouterScreen"; import { glyphs } from "../components/ui/_core.js"; import type { ScreenProps } from "./types"; -import { useProjectDetected } from "./project/ProjectGate"; const NO_PROJECT_HINTS = { create: `${glyphs.leftArrow} start here` }; const NO_PROJECT_ALERT = "No project detected - create a new project to get started"; const PROJECT_HIDDEN_OPTIONS = ["create"]; -const NO_PROJECT_HIDDEN_OPTIONS = ["eval"]; +const PROJECT_REQUIRED_OPTIONS = [ + "add", + "remove", + "dev", + "build", + "deploy", + "status", + "invoke", + "log", + "traces", + "export", +]; +const NO_PROJECT_HIDDEN_OPTIONS = [...PROJECT_REQUIRED_OPTIONS, "eval"]; -export function RootScreen(props: ScreenProps) { - const projectDetected = useProjectDetected(props.core); - const noProjectDetected = projectDetected === false; +export interface RootScreenProps extends ScreenProps { + inProject: boolean; +} +export function RootScreen({ inProject, ...props }: RootScreenProps) { return ( } path={["agentcore"]} - optionHints={noProjectDetected ? NO_PROJECT_HINTS : undefined} - alert={noProjectDetected ? NO_PROJECT_ALERT : undefined} - hiddenOptions={ - projectDetected - ? PROJECT_HIDDEN_OPTIONS - : noProjectDetected - ? NO_PROJECT_HIDDEN_OPTIONS - : undefined - } + optionHints={inProject ? undefined : NO_PROJECT_HINTS} + alert={inProject ? undefined : NO_PROJECT_ALERT} + hiddenOptions={inProject ? PROJECT_HIDDEN_OPTIONS : NO_PROJECT_HIDDEN_OPTIONS} /> ); } diff --git a/src/router/index.tsx b/src/router/index.tsx index e98e2ac8ec..c76085a67f 100644 --- a/src/router/index.tsx +++ b/src/router/index.tsx @@ -14,7 +14,6 @@ export { isTuiCommandSupported, commandMenuSectionStart, isListedInMenu, - isProjectRequired, commandParameterDetails, } from "./router"; export { diff --git a/src/router/router.test.ts b/src/router/router.test.ts index f6c4816a49..210bba5511 100644 --- a/src/router/router.test.ts +++ b/src/router/router.test.ts @@ -15,7 +15,6 @@ import { isTuiCommandSupported, commandMenuSectionStart, isListedInMenu, - isProjectRequired, type Context, type Handler, type Middleware, @@ -159,21 +158,6 @@ 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 ddc4190a20..cb3b7b2b52 100644 --- a/src/router/router.tsx +++ b/src/router/router.tsx @@ -41,10 +41,7 @@ class RoutedCommand extends Command { // menu instead of moving it under the "cli" divider. listedInMenu = false; - constructor( - readonly handler: Handler, - readonly projectRequired: boolean, - ) { + constructor(readonly handler: Handler) { super(handler.name()); } } @@ -61,10 +58,6 @@ export function isListedInMenu(command: Command): boolean { return command instanceof RoutedCommand && command.listedInMenu; } -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() @@ -228,11 +221,10 @@ 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, projectRequired); + const c = new RoutedCommand(compiledNode); c.addHelpCommand(false); const defaultHelp = c.createHelp(); c.configureHelp({ @@ -293,16 +285,7 @@ export function compile( const childTuiSupported = effectiveTuiSupport && (!isTuiChildSupportProvider(node) || node.supportsTuiCommand(child.name())); - const childProjectRequired = - projectRequired || (node instanceof Router && node.commandRequiresProject(child.name())); - const childCommand = compile( - child, - ctx, - nextStack, - childGlobals, - childTuiSupported, - childProjectRequired, - ); + const childCommand = compile(child, ctx, nextStack, childGlobals, childTuiSupported); if (isMenuLayoutProvider(node) && childCommand instanceof RoutedCommand) { childCommand.menuSectionStart = node.menuSectionStartOf(child.name()); childCommand.listedInMenu = node.isListedInMenu(child.name()); @@ -342,7 +325,6 @@ export class Router implements Handler, MiddlewareProvider, DefaultHandlerProvid private sectionStarts = new Map(); private pendingSection?: string; private menuListed = new Set(); - private readonly projectCommandNames = new Set(); private cliVersion?: string; constructor( @@ -410,18 +392,6 @@ export class Router implements Handler, MiddlewareProvider, DefaultHandlerProvid return this.menuListed.has(commandName); } - 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. diff --git a/src/testing/index.tsx b/src/testing/index.tsx index bd4e45245d..f0596d7519 100644 --- a/src/testing/index.tsx +++ b/src/testing/index.tsx @@ -38,6 +38,7 @@ export { TestGlobalConfigAccessor } from "./globalConfig"; export { inTempDirectory, type TempDirectory } from "./fs"; export { initProject, + inProjectContext, inProjectCore, type InitProjectOptions, type InitializedProject, diff --git a/src/testing/projects.ts b/src/testing/projects.ts index a4a661ff25..683c6baac6 100644 --- a/src/testing/projects.ts +++ b/src/testing/projects.ts @@ -2,6 +2,8 @@ import { join } from "node:path"; import { createRootHandler, type RootHandlerConfig } from "../handlers"; import type { Core } from "../handlers/types"; import type { Project } from "../handlers/project/types"; +import { ProjectDetectedKey } from "../handlers/project/context"; +import type { Context } from "../router"; import { inTempDirectory } from "./fs"; import { createSilentLogger } from "./logging"; import { testIO } from "./testIO"; @@ -67,3 +69,7 @@ export function inProjectCore(): TestCoreClient { core.projectManager.resolve = async () => ({}) as Project; return core; } + +export function inProjectContext(ctx: Context): Context { + return ctx.withValue(ProjectDetectedKey, true); +} diff --git a/src/tui/index.tsx b/src/tui/index.tsx index bfe5592ef1..c5f9bc392b 100644 --- a/src/tui/index.tsx +++ b/src/tui/index.tsx @@ -9,11 +9,12 @@ import { contextKey, type ContextKey, CommandRunMetricEventKey, + ProjectKey, } from "../router"; import type { AppIO } from "../io"; import type { Core } from "../handlers/types"; import { JsonKey } from "../handlers/keys"; -import { projectDetectedQuery } from "../handlers/project/ProjectGate"; +import { ProjectDetectedKey } from "../handlers/project/context"; import { ExitCode, InvalidEnvironmentError } from "../errors"; import { TuiExitMessageKey } from "./exitMessage"; import { createResizeGate } from "./resize"; @@ -60,7 +61,13 @@ export async function renderTuiAt( let exitMessage: string | undefined; let handoff: string[] | undefined; + const projectDetected = + ctx.value(ProjectKey) !== undefined || + (await core.projectManager.resolve({ + filePath: process.cwd(), + })) !== undefined; const tuiContext = ctx + .withValue(ProjectDetectedKey, projectDetected) .withValue(TuiExitMessageKey, (message) => { exitMessage = message; }) @@ -71,7 +78,6 @@ export async function renderTuiAt( // 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). const queryClient = new QueryClient(); - await queryClient.prefetchQuery(projectDetectedQuery(core)); const resizeGate = createResizeGate(io.stdout); const { waitUntilExit } = render(