diff --git a/src/cli/cli.ts b/src/cli/cli.ts index 5517afb9c..61aef8303 100644 --- a/src/cli/cli.ts +++ b/src/cli/cli.ts @@ -28,7 +28,7 @@ import { registerValidate } from './commands/validate'; import { PACKAGE_VERSION } from './constants'; import { ALL_PRIMITIVES } from './primitives'; import { TelemetryClientAccessor } from './telemetry'; -import { App } from './tui/App'; +import { App, type InitialRoute } from './tui/App'; import { LayoutProvider } from './tui/context'; import { COMMAND_DESCRIPTIONS } from './tui/copy'; import { clearExitAction, getExitAction } from './tui/exit-action'; @@ -99,19 +99,47 @@ function printPostCommandNotices(isFirstRun: boolean, updateCheck: Promise; + /** Whether this is the first time the CLI has been run. Shows telemetry notice on exit. Default: false */ + isFirstRun?: boolean; + /** Control whether TUI is rendered inline or in alternate screen. Default: true */ + enterAltScreen?: boolean; + /** Behavior when pressing escape/back. 'help' navigates to the help screen, 'exit' exits the app. Default: 'help' */ + actionOnBack?: 'help' | 'exit'; +} + /** * Render the TUI in alternate screen buffer mode. + * This is the entrypoint for TUI operations */ -function renderTUI(updateCheck: Promise, isFirstRun: boolean) { - inAltScreen = true; - process.stdout.write(ENTER_ALT_SCREEN); +export function renderTUI(options: RenderTUIOptions = {}) { + const { + initialRoute, + updateCheck = Promise.resolve(null), + isFirstRun = false, + enterAltScreen = true, + actionOnBack = 'help', + } = options; + TelemetryClientAccessor.init(initialRoute?.name ?? 'tui', 'tui'); + if (enterAltScreen) { + inAltScreen = true; + process.stdout.write(ENTER_ALT_SCREEN); + } - const { waitUntilExit } = render(React.createElement(App)); + const { waitUntilExit } = render(React.createElement(App, { initialRoute, actionOnBack })); - void waitUntilExit().then(async () => { - inAltScreen = false; - process.stdout.write(EXIT_ALT_SCREEN); - process.stdout.write(SHOW_CURSOR); + const done = waitUntilExit().then(async () => { + if (inAltScreen) { + inAltScreen = false; + process.stdout.write(EXIT_ALT_SCREEN); + process.stdout.write(SHOW_CURSOR); + } + + await TelemetryClientAccessor.shutdown(); // Check if the TUI requested a post-exit action (e.g., launch browser dev mode) const action = getExitAction(); @@ -132,6 +160,8 @@ function renderTUI(updateCheck: Promise, isFirstRun: b await printPostCommandNotices(isFirstRun, updateCheck); }); + + return done; } function renderHelp(program: Command): void { @@ -230,7 +260,7 @@ export const main = async (argv: string[]) => { // Show TUI for no arguments, commander handles --help via configureHelp() if (args.length === 0) { requireTTY(); - renderTUI(updateCheck, isFirstRun); + await renderTUI({ updateCheck, isFirstRun }); return; } diff --git a/src/cli/commands/add/command.tsx b/src/cli/commands/add/command.tsx index 934908301..651a0c2d5 100644 --- a/src/cli/commands/add/command.tsx +++ b/src/cli/commands/add/command.tsx @@ -1,9 +1,7 @@ +import { renderTUI } from '../../cli'; import { COMMAND_DESCRIPTIONS } from '../../tui/copy'; import { requireProject, requireTTY } from '../../tui/guards'; -import { AddFlow } from '../../tui/screens/add/AddFlow'; import type { Command } from '@commander-js/extra-typings'; -import { render } from 'ink'; -import React from 'react'; export function registerAdd(program: Command): Command { const addCmd = program @@ -13,7 +11,7 @@ export function registerAdd(program: Command): Command { .showSuggestionAfterError(); // Catch-all argument for invalid subcommands - Commander matches subcommands first - addCmd.argument('[subcommand]').action((subcommand: string | undefined, _options, cmd) => { + addCmd.argument('[subcommand]').action(async (subcommand: string | undefined, _options, cmd) => { if (subcommand) { console.error(`error: '${subcommand}' is not a valid subcommand.`); cmd.outputHelp(); @@ -23,15 +21,7 @@ export function registerAdd(program: Command): Command { requireProject(); requireTTY(); - const { clear, unmount } = render( - { - clear(); - unmount(); - }} - /> - ); + await renderTUI({ initialRoute: { name: 'add' }, enterAltScreen: false, actionOnBack: 'exit' }); }); // Subcommands (agent, memory, credential, gateway, gateway-target) are registered diff --git a/src/cli/commands/create/command.tsx b/src/cli/commands/create/command.tsx index e9a58f520..b705bd316 100644 --- a/src/cli/commands/create/command.tsx +++ b/src/cli/commands/create/command.tsx @@ -8,6 +8,7 @@ import type { TargetLanguage, } from '../../../schema'; import { LIFECYCLE_TIMEOUT_MAX, LIFECYCLE_TIMEOUT_MIN } from '../../../schema'; +import { renderTUI } from '../../cli'; import { getErrorMessage } from '../../errors'; import { runCliCommand } from '../../telemetry/cli-command-run.js'; import { @@ -23,7 +24,6 @@ import { } from '../../telemetry/schemas/common-shapes.js'; import { COMMAND_DESCRIPTIONS } from '../../tui/copy'; import { requireTTY } from '../../tui/guards'; -import { CreateScreen } from '../../tui/screens/create'; import { parseCommaSeparatedList } from '../shared/vpc-utils'; import { type ProgressCallback, createProject, createProjectWithAgent, getDryRunInfo } from './action'; import type { CreateOptions } from './types'; @@ -32,18 +32,8 @@ import type { Command } from '@commander-js/extra-typings'; import { Text, render } from 'ink'; /** Render CreateScreen for interactive TUI mode */ -function handleCreateTUI(): void { - const cwd = getWorkingDirectory(); - const { unmount } = render( - { - unmount(); - process.exit(0); - }} - /> - ); +function handleCreateTUI(): Promise { + return renderTUI({ initialRoute: { name: 'create' }, enterAltScreen: false, actionOnBack: 'exit' }); } /** Print completion summary after successful create */ @@ -293,7 +283,7 @@ export const registerCreate = (program: Command) => { await handleCreateCLI(options as CreateOptions); } else { requireTTY(); - handleCreateTUI(); + await handleCreateTUI(); } } catch (error) { render(Error: {getErrorMessage(error)}); diff --git a/src/cli/commands/deploy/command.tsx b/src/cli/commands/deploy/command.tsx index d735aa4af..bc3207f47 100644 --- a/src/cli/commands/deploy/command.tsx +++ b/src/cli/commands/deploy/command.tsx @@ -1,9 +1,9 @@ import { ConfigIO, serializeResult } from '../../../lib'; +import { renderTUI } from '../../cli'; import { getErrorMessage } from '../../errors'; import { withCommandRunTelemetry } from '../../telemetry/cli-command-run.js'; import { COMMAND_DESCRIPTIONS } from '../../tui/copy'; import { requireProject, requireTTY } from '../../tui/guards'; -import { DeployScreen } from '../../tui/screens/deploy/DeployScreen'; import { handleDeploy } from './actions'; import type { DeployOptions, DeployResult } from './types'; import { DEFAULT_DEPLOY_ATTRS, computeDeployAttrs } from './utils'; @@ -14,20 +14,9 @@ import React from 'react'; const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; -function handleDeployTUI(options: { autoConfirm?: boolean; diffMode?: boolean } = {}): void { +function handleDeployTUI(options: { autoConfirm?: boolean; diffMode?: boolean } = {}): Promise { requireProject(); - - const { unmount } = render( - { - unmount(); - process.exit(0); - }} - /> - ); + return renderTUI({ initialRoute: { name: 'deploy' }, enterAltScreen: false, actionOnBack: 'exit' }); } async function handleDeployCLI(options: DeployOptions): Promise { @@ -208,10 +197,10 @@ export const registerDeploy = (program: Command) => { } else if (cliOptions.diff) { // Diff-only: use TUI with diff mode requireTTY(); - handleDeployTUI({ diffMode: true }); + await handleDeployTUI({ diffMode: true }); } else { requireTTY(); - handleDeployTUI(); + await handleDeployTUI(); } } catch (error) { if (cliOptions.json) { diff --git a/src/cli/commands/invoke/command.tsx b/src/cli/commands/invoke/command.tsx index 1359232c3..9969b16e3 100644 --- a/src/cli/commands/invoke/command.tsx +++ b/src/cli/commands/invoke/command.tsx @@ -1,10 +1,10 @@ -import { type Result, ValidationError, serializeResult } from '../../../lib'; +import { ValidationError, serializeResult } from '../../../lib'; +import { renderTUI } from '../../cli'; import { getErrorMessage } from '../../errors'; import { withCommandRunTelemetry } from '../../telemetry/cli-command-run.js'; import { AgentProtocol, AuthType, standardize } from '../../telemetry/schemas/common-shapes.js'; import { COMMAND_DESCRIPTIONS } from '../../tui/copy'; import { requireProject, requireTTY } from '../../tui/guards'; -import { InvokeScreen } from '../../tui/screens/invoke'; import { parseHeaderFlags } from '../shared/header-utils'; import { type InvokeContext, handleInvoke, loadInvokeConfig } from './action'; import { resolvePrompt } from './resolve-prompt'; @@ -12,7 +12,6 @@ import type { InvokeOptions, InvokeResult } from './types'; import { validateInvokeOptions } from './validate'; import type { Command } from '@commander-js/extra-typings'; import { Text, render } from 'ink'; -import React from 'react'; const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; @@ -241,33 +240,17 @@ export const registerInvoke = (program: Command) => { headers = parseHeaderFlags(cliOptions.header); } - const tuiResult = await withCommandRunTelemetry( - 'invoke', - { - has_stream: true, - has_session_id: !!cliOptions.sessionId, - auth_type: standardize(AuthType, cliOptions.bearerToken ? 'bearer_token' : 'sigv4'), - agent_protocol: standardize(AgentProtocol, resolveProtocol({}, agentProtocol)), + await renderTUI({ + initialRoute: { + name: 'invoke', + sessionId: cliOptions.sessionId, + userId: cliOptions.userId, + headers, + bearerToken: cliOptions.bearerToken, }, - async (): Promise => { - const { waitUntilExit, unmount } = render( - unmount()} - initialSessionId={cliOptions.sessionId} - initialUserId={cliOptions.userId} - initialHeaders={headers} - initialBearerToken={cliOptions.bearerToken} - /> - ); - await waitUntilExit(); - return { success: true }; - } - ); - if (!tuiResult.success) { - render(Error: {getErrorMessage(tuiResult.error)}); - process.exit(1); - } + enterAltScreen: false, + actionOnBack: 'exit', + }); } } catch (error) { if (cliOptions.json) { diff --git a/src/cli/commands/remove/command.tsx b/src/cli/commands/remove/command.tsx index 369a323d7..c07191dda 100644 --- a/src/cli/commands/remove/command.tsx +++ b/src/cli/commands/remove/command.tsx @@ -1,14 +1,13 @@ import { ConfigIO, serializeResult, toError } from '../../../lib'; +import { renderTUI } from '../../cli'; import { getErrorMessage } from '../../errors'; import { runCliCommand } from '../../telemetry/cli-command-run.js'; import { COMMAND_DESCRIPTIONS } from '../../tui/copy'; import { requireProject, requireTTY } from '../../tui/guards'; -import { RemoveAllScreen, RemoveFlow } from '../../tui/screens/remove'; import type { RemoveAllOptions, RemoveResult } from './types'; import { validateRemoveAllOptions } from './validate'; import type { Command } from '@commander-js/extra-typings'; import { Text, render } from 'ink'; -import React from 'react'; async function handleRemoveAll(_options: RemoveAllOptions): Promise { try { @@ -84,15 +83,7 @@ export const registerRemove = (program: Command): Command => { }); } else { requireTTY(); - const { unmount } = render( - { - unmount(); - process.exit(0); - }} - /> - ); + await renderTUI({ initialRoute: { name: 'remove' }, enterAltScreen: false, actionOnBack: 'exit' }); } } catch (error) { if (cliOptions.json) { @@ -112,7 +103,7 @@ export const registerRemove = (program: Command): Command => { // primitive subcommands are registered after this point. removeCommand .argument('[subcommand]') - .action((subcommand: string | undefined, _options, cmd) => { + .action(async (subcommand: string | undefined, _options, cmd) => { if (subcommand) { console.error(`error: '${subcommand}' is not a valid subcommand.`); cmd.outputHelp(); @@ -122,15 +113,7 @@ export const registerRemove = (program: Command): Command => { requireProject(); requireTTY(); - const { clear, unmount } = render( - { - clear(); - unmount(); - }} - /> - ); + await renderTUI({ initialRoute: { name: 'remove' }, enterAltScreen: false, actionOnBack: 'exit' }); }) .showHelpAfterError() .showSuggestionAfterError(); diff --git a/src/cli/tui/App.tsx b/src/cli/tui/App.tsx index 322d0b5a8..c9e30c8bf 100644 --- a/src/cli/tui/App.tsx +++ b/src/cli/tui/App.tsx @@ -35,7 +35,7 @@ type Route = | { name: 'home' } | { name: 'help'; initialQuery?: string } | { name: 'deploy' } - | { name: 'invoke' } + | { name: 'invoke'; sessionId?: string; userId?: string; headers?: Record; bearerToken?: string } | { name: 'logs' } | { name: 'create' } | { name: 'add' } @@ -63,15 +63,28 @@ type Route = // Commands that don't require being at the project root const PROJECT_ROOT_EXEMPT_COMMANDS = new Set(['create', 'update']); -function AppContent() { +export type RouteName = Route['name']; + +// cli-only requires a commandId field, so it cannot be used as an initial route via name alone. +export type InitialRoute = Exclude; + +function AppContent({ initialRoute, actionOnBack }: { initialRoute?: InitialRoute; actionOnBack?: 'help' | 'exit' }) { const { exit } = useApp(); // Start on help screen if project exists (show commands), otherwise home (show Quick Start) const inProject = projectExists(); const wrongDirProjectRoot = getProjectRootMismatch(); - const initialRoute: Route = inProject ? { name: 'help' } : { name: 'home' }; - const [route, setRoute] = useState(initialRoute); + const defaultRoute: Route = inProject ? { name: 'help' } : { name: 'home' }; + const [route, setRoute] = useState(initialRoute ?? defaultRoute); const [helpNotice, setHelpNotice] = useState(null); + const handleBack = () => { + if (actionOnBack === 'exit') { + exit(); + } else { + setRoute({ name: 'help' }); + } + }; + // Get commands from commander program (hide 'create' when in project) const program = createProgram(); const commands = getCommandsForUI(program, { inProject }); @@ -177,29 +190,38 @@ function AppContent() { return ( setRoute({ name: 'help' })} + onExit={handleBack} onNavigate={command => setRoute({ name: command } as Route)} /> ); } if (route.name === 'invoke') { - return setRoute({ name: 'help' })} />; + return ( + + ); } if (route.name === 'logs') { - return setRoute({ name: 'help' })} />; + return ; } if (route.name === 'status') { - return setRoute({ name: 'help' })} />; + return ; } if (route.name === 'add') { return ( setRoute({ name: 'help' })} + onExit={handleBack} onDev={() => { setExitAction({ type: 'dev' }); exit(); @@ -213,7 +235,7 @@ function AppContent() { return ( setRoute({ name: 'help' })} + onExit={handleBack} onNavigate={command => setRoute({ name: command } as Route)} /> ); @@ -224,7 +246,7 @@ function AppContent() { setRoute({ name: 'help' })} + onExit={handleBack} onNavigate={({ command, workingDir }) => { process.chdir(workingDir); setRoute({ name: command } as Route); @@ -239,7 +261,7 @@ function AppContent() { onRunEval={() => setRoute({ name: 'run-eval', from: 'run' })} onRunBatchEval={() => setRoute({ name: 'run-batch-eval', from: 'run' })} onRunRecommendation={() => setRoute({ name: 'recommend', from: 'run' })} - onExit={() => setRoute({ name: 'help' })} + onExit={handleBack} /> ); } @@ -254,7 +276,7 @@ function AppContent() { if (view === 'batch-eval-history') setRoute({ name: 'batch-eval-history' }); if (view === 'online-dashboard') setRoute({ name: 'online-evals' }); }} - onExit={() => setRoute({ name: 'help' })} + onExit={handleBack} /> ); } @@ -285,7 +307,7 @@ function AppContent() { if (view === 'run-recommendation') setRoute({ name: 'recommend', from: 'recommendations-hub' }); if (view === 'recommendation-history') setRoute({ name: 'recommendation-history' }); }} - onExit={() => setRoute({ name: 'help' })} + onExit={handleBack} /> ); } @@ -308,15 +330,15 @@ function AppContent() { } if (route.name === 'fetch-access') { - return setRoute({ name: 'help' })} />; + return ; } if (route.name === 'validate') { - return setRoute({ name: 'help' })} />; + return ; } if (route.name === 'package') { - return setRoute({ name: 'help' })} />; + return ; } if (route.name === 'import') { @@ -329,15 +351,15 @@ function AppContent() { } if (route.name === 'update') { - return setRoute({ name: 'help' })} />; + return ; } if (route.name === 'config-bundle') { - return setRoute({ name: 'help' })} />; + return ; } if (route.name === 'ab-test') { - return setRoute({ name: 'help' })} />; + return ; } if (route.name === 'cli-only') { @@ -348,7 +370,7 @@ function AppContent() { title={route.commandId} description={info.description} examples={info.examples} - onExit={() => setRoute({ name: 'help' })} + onExit={handleBack} /> ); } @@ -357,10 +379,10 @@ function AppContent() { return null; } -export function App() { +export function App({ initialRoute, actionOnBack }: { initialRoute?: InitialRoute; actionOnBack?: 'help' | 'exit' }) { return ( - + ); }