diff --git a/src/cli/cli.ts b/src/cli/cli.ts index 5517afb9c..b405e741a 100644 --- a/src/cli/cli.ts +++ b/src/cli/cli.ts @@ -26,114 +26,21 @@ import { registerTraces } from './commands/traces'; import { registerUpdate } from './commands/update'; import { registerValidate } from './commands/validate'; import { PACKAGE_VERSION } from './constants'; +import { printPostCommandNotices, printTelemetryNotice } from './notices'; import { ALL_PRIMITIVES } from './primitives'; import { TelemetryClientAccessor } from './telemetry'; -import { App } from './tui/App'; +import { renderTUI, setupAltScreenCleanup } from './tui'; import { LayoutProvider } from './tui/context'; import { COMMAND_DESCRIPTIONS } from './tui/copy'; -import { clearExitAction, getExitAction } from './tui/exit-action'; import { clearExitMessage, getExitMessage } from './tui/exit-message'; import { requireTTY } from './tui/guards'; import { CommandListScreen } from './tui/screens/home'; import { getCommandsForUI } from './tui/utils'; -import { type UpdateCheckResult, checkForUpdate, printUpdateNotification } from './update-notifier'; +import { checkForUpdate } from './update-notifier'; import { Command } from '@commander-js/extra-typings'; import { render } from 'ink'; import React from 'react'; -// ANSI escape sequences -const ENTER_ALT_SCREEN = '\x1B[?1049h\x1B[H'; -const EXIT_ALT_SCREEN = '\x1B[?1049l'; -const SHOW_CURSOR = '\x1B[?25h'; - -// Track if we're in alternate screen mode -let inAltScreen = false; - -/** - * Global terminal cleanup - ensures cursor is always restored on exit. - * Registered once at startup, catches all exit scenarios. - */ -function setupGlobalCleanup() { - const cleanup = () => { - if (inAltScreen) { - process.stdout.write(EXIT_ALT_SCREEN); - } - process.stdout.write(SHOW_CURSOR); - }; - - process.on('exit', cleanup); - process.on('SIGINT', () => { - cleanup(); - process.exit(0); - }); - process.on('SIGTERM', () => { - cleanup(); - process.exit(0); - }); -} - -function printTelemetryNotice(): void { - const yellow = '\x1b[33m'; - const reset = '\x1b[0m'; - process.stderr.write( - [ - '', - `${yellow}The AgentCore CLI will soon begin collecting aggregated, anonymous usage`, - 'analytics to help improve the tool.', - 'To opt out: agentcore telemetry disable', - `To learn more: agentcore telemetry --help${reset}`, - '', - '', - ].join('\n') - ); -} - -function printPostCommandNotices(isFirstRun: boolean, updateCheck: Promise): Promise { - if (isFirstRun) { - printTelemetryNotice(); - } - return updateCheck.then(result => { - if (result?.updateAvailable) { - printUpdateNotification(result); - } - }); -} - -/** - * Render the TUI in alternate screen buffer mode. - */ -function renderTUI(updateCheck: Promise, isFirstRun: boolean) { - inAltScreen = true; - process.stdout.write(ENTER_ALT_SCREEN); - - const { waitUntilExit } = render(React.createElement(App)); - - void waitUntilExit().then(async () => { - inAltScreen = false; - process.stdout.write(EXIT_ALT_SCREEN); - process.stdout.write(SHOW_CURSOR); - - // Check if the TUI requested a post-exit action (e.g., launch browser dev mode) - const action = getExitAction(); - clearExitAction(); - - if (action?.type === 'dev') { - const { launchBrowserDev } = await import('./commands/dev/browser-mode'); - await launchBrowserDev(); - return; - } - - // Print any exit message set by screens (e.g., after successful project creation) - const exitMessage = getExitMessage(); - if (exitMessage) { - console.log(exitMessage); - clearExitMessage(); - } - - await printPostCommandNotices(isFirstRun, updateCheck); - }); -} - function renderHelp(program: Command): void { const commands = getCommandsForUI(program); render(React.createElement(LayoutProvider, null, React.createElement(CommandListScreen, { commands }))); @@ -214,7 +121,7 @@ export function registerCommands(program: Command) { export const main = async (argv: string[]) => { // Register global cleanup handlers once at startup - setupGlobalCleanup(); + setupAltScreenCleanup(); // Generate installationId on first run and show telemetry notice const { created: isFirstRun } = await getOrCreateInstallationId(); @@ -230,7 +137,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; } @@ -238,7 +145,7 @@ export const main = async (argv: string[]) => { printTelemetryNotice(); } - TelemetryClientAccessor.init(args[0] ?? 'unknown'); + await TelemetryClientAccessor.init(args[0] ?? 'unknown'); try { await program.parseAsync(argv); } finally { diff --git a/src/cli/commands/add/command.tsx b/src/cli/commands/add/command.tsx index 934908301..4a98fa0d5 100644 --- a/src/cli/commands/add/command.tsx +++ b/src/cli/commands/add/command.tsx @@ -1,9 +1,7 @@ +import { renderTUI } from '../../tui'; 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,12 @@ export function registerAdd(program: Command): Command { requireProject(); requireTTY(); - const { clear, unmount } = render( - { - clear(); - unmount(); - }} - /> - ); + await renderTUI({ + initialRoute: { name: 'add' }, + enterAltScreen: false, + actionOnBack: 'exit', + isInteractive: false, + }); }); // 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..4c76acfea 100644 --- a/src/cli/commands/create/command.tsx +++ b/src/cli/commands/create/command.tsx @@ -21,9 +21,9 @@ import { BuildType as TelemetryBuildType, standardize, } from '../../telemetry/schemas/common-shapes.js'; +import { renderTUI } from '../../tui'; 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,13 @@ 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', + isInteractive: false, + }); } /** Print completion summary after successful create */ @@ -293,7 +288,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..914308768 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 { getErrorMessage } from '../../errors'; import { withCommandRunTelemetry } from '../../telemetry/cli-command-run.js'; +import { renderTUI } from '../../tui'; 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,14 @@ import React from 'react'; const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; -function handleDeployTUI(options: { autoConfirm?: boolean; diffMode?: boolean } = {}): void { +function handleDeployTUI(options: { diffMode?: boolean } = {}): Promise { requireProject(); - - const { unmount } = render( - { - unmount(); - process.exit(0); - }} - /> - ); + return renderTUI({ + initialRoute: { name: 'deploy', diffMode: options.diffMode }, + enterAltScreen: false, + actionOnBack: 'exit', + isInteractive: false, + }); } async function handleDeployCLI(options: DeployOptions): Promise { @@ -208,10 +202,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..7c9c21815 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 { getErrorMessage } from '../../errors'; import { withCommandRunTelemetry } from '../../telemetry/cli-command-run.js'; import { AgentProtocol, AuthType, standardize } from '../../telemetry/schemas/common-shapes.js'; +import { renderTUI } from '../../tui'; 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,18 @@ 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', + isInteractive: false, + }); } } catch (error) { if (cliOptions.json) { diff --git a/src/cli/commands/remove/command.tsx b/src/cli/commands/remove/command.tsx index 369a323d7..5fef1cb86 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 { getErrorMessage } from '../../errors'; import { runCliCommand } from '../../telemetry/cli-command-run.js'; +import { renderTUI } from '../../tui'; 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,12 @@ export const registerRemove = (program: Command): Command => { }); } else { requireTTY(); - const { unmount } = render( - { - unmount(); - process.exit(0); - }} - /> - ); + await renderTUI({ + initialRoute: { name: 'remove' }, + enterAltScreen: false, + actionOnBack: 'exit', + isInteractive: false, + }); } } catch (error) { if (cliOptions.json) { @@ -112,7 +108,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 +118,12 @@ export const registerRemove = (program: Command): Command => { requireProject(); requireTTY(); - const { clear, unmount } = render( - { - clear(); - unmount(); - }} - /> - ); + await renderTUI({ + initialRoute: { name: 'remove' }, + enterAltScreen: false, + actionOnBack: 'exit', + isInteractive: false, + }); }) .showHelpAfterError() .showSuggestionAfterError(); diff --git a/src/cli/notices.ts b/src/cli/notices.ts new file mode 100644 index 000000000..2a525b94b --- /dev/null +++ b/src/cli/notices.ts @@ -0,0 +1,31 @@ +import { type UpdateCheckResult, printUpdateNotification } from './update-notifier'; + +export function printTelemetryNotice(): void { + const yellow = '\x1b[33m'; + const reset = '\x1b[0m'; + process.stderr.write( + [ + '', + `${yellow}The AgentCore CLI will soon begin collecting aggregated, anonymous usage`, + 'analytics to help improve the tool.', + 'To opt out: agentcore telemetry disable', + `To learn more: agentcore telemetry --help${reset}`, + '', + '', + ].join('\n') + ); +} + +export function printPostCommandNotices( + isFirstRun: boolean, + updateCheck: Promise +): Promise { + if (isFirstRun) { + printTelemetryNotice(); + } + return updateCheck.then(result => { + if (result?.updateAvailable) { + printUpdateNotification(result); + } + }); +} diff --git a/src/cli/telemetry/client-accessor.ts b/src/cli/telemetry/client-accessor.ts index 53a7ddb46..4a1959c88 100644 --- a/src/cli/telemetry/client-accessor.ts +++ b/src/cli/telemetry/client-accessor.ts @@ -20,7 +20,10 @@ import { join } from 'path'; export class TelemetryClientAccessor { private static clientPromise: Promise | undefined; - static init(entrypoint: string, mode: 'cli' | 'tui' = 'cli'): void { + static async init(entrypoint: string, mode: 'cli' | 'tui' = 'cli'): Promise { + if (this.clientPromise) { + await this.shutdown(); + } this.clientPromise = createClient(entrypoint, mode); } @@ -37,6 +40,7 @@ export class TelemetryClientAccessor { } catch { // Telemetry is best-effort — don't propagate init or shutdown failures } + this.clientPromise = undefined; } } } diff --git a/src/cli/tui/App.tsx b/src/cli/tui/App.tsx index 322d0b5a8..5a550b7da 100644 --- a/src/cli/tui/App.tsx +++ b/src/cli/tui/App.tsx @@ -28,14 +28,13 @@ import { getCommandsForUI } from './utils/commands'; import { useApp } from 'ink'; import React, { useState } from 'react'; -// Capture cwd once at app initialization -const cwd = getWorkingDirectory(); +// cwd is captured inside AppContent to avoid calling getWorkingDirectory at import time type Route = | { name: 'home' } | { name: 'help'; initialQuery?: string } - | { name: 'deploy' } - | { name: 'invoke' } + | { name: 'deploy'; diffMode?: boolean } + | { name: 'invoke'; sessionId?: string; userId?: string; headers?: Record; bearerToken?: string } | { name: 'logs' } | { name: 'create' } | { name: 'add' } @@ -63,15 +62,37 @@ 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']; + +// Excluded: cli-only is a TUI-internal screen that tells users to use the CLI — we should never launch the TUI just to show that. +export type InitialRoute = Exclude; + +function AppContent({ + initialRoute, + actionOnBack, + isInteractive = true, +}: { + initialRoute?: InitialRoute; + actionOnBack?: 'help' | 'exit'; + isInteractive?: boolean; +}) { const { exit } = useApp(); + const cwd = getWorkingDirectory(); // 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 }); @@ -176,30 +197,40 @@ function AppContent() { if (route.name === 'deploy') { return ( setRoute({ name: 'help' })} + isInteractive={isInteractive} + diffMode={route.diffMode} + 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' })} + isInteractive={isInteractive} + onExit={handleBack} onDev={() => { setExitAction({ type: 'dev' }); exit(); @@ -212,8 +243,8 @@ function AppContent() { if (route.name === 'remove') { return ( setRoute({ name: 'help' })} + isInteractive={isInteractive} + onExit={handleBack} onNavigate={command => setRoute({ name: command } as Route)} /> ); @@ -223,8 +254,8 @@ function AppContent() { return ( setRoute({ name: 'help' })} + isInteractive={isInteractive} + onExit={handleBack} onNavigate={({ command, workingDir }) => { process.chdir(workingDir); setRoute({ name: command } as Route); @@ -239,7 +270,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 +285,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 +316,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} /> ); } @@ -300,23 +331,23 @@ function AppContent() { } if (route.name === 'eval-runs') { - return setRoute({ name: 'evals' })} />; + return setRoute({ name: 'evals' })} />; } if (route.name === 'online-evals') { - return setRoute({ name: 'evals' })} />; + return setRoute({ name: 'evals' })} />; } 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 +360,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 +379,7 @@ function AppContent() { title={route.commandId} description={info.description} examples={info.examples} - onExit={() => setRoute({ name: 'help' })} + onExit={handleBack} /> ); } @@ -357,10 +388,18 @@ function AppContent() { return null; } -export function App() { +export function App({ + initialRoute, + actionOnBack, + isInteractive = true, +}: { + initialRoute?: InitialRoute; + actionOnBack?: 'help' | 'exit'; + isInteractive?: boolean; +}) { return ( - + ); } diff --git a/src/cli/tui/index.ts b/src/cli/tui/index.ts index a6e315065..c704bc402 100644 --- a/src/cli/tui/index.ts +++ b/src/cli/tui/index.ts @@ -1,5 +1,6 @@ export { App } from './App'; export * from './components'; export * from './hooks'; +export * from './render'; export * from './screens'; export * from './utils'; diff --git a/src/cli/tui/render.ts b/src/cli/tui/render.ts new file mode 100644 index 000000000..1bcb3ca61 --- /dev/null +++ b/src/cli/tui/render.ts @@ -0,0 +1,103 @@ +import { printPostCommandNotices } from '../notices'; +import { TelemetryClientAccessor } from '../telemetry'; +import { type UpdateCheckResult } from '../update-notifier'; +import { App, type InitialRoute } from './App'; +import { clearExitAction, getExitAction } from './exit-action'; +import { clearExitMessage, getExitMessage } from './exit-message'; +import { render } from 'ink'; +import React from 'react'; + +const ENTER_ALT_SCREEN = '\x1B[?1049h\x1B[H'; +const EXIT_ALT_SCREEN = '\x1B[?1049l'; +const SHOW_CURSOR = '\x1B[?25h'; + +let inAltScreen = false; + +export interface RenderTUIOptions { + /** Route to navigate to on launch. If omitted, shows the default home/help screen. */ + initialRoute?: InitialRoute; + /** Promise that resolves with update check result. Used to print update notifications on exit. Default: Promise.resolve(null) */ + 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'; + /** Whether the TUI is running in full interactive mode. When false, screens auto-exit after success. Default: true */ + isInteractive?: boolean; +} + +/** + * Render the TUI in alternate screen buffer mode. + * This is the entrypoint for all TUI operations. + */ +export async function renderTUI(options: RenderTUIOptions = {}) { + const { + initialRoute, + updateCheck = Promise.resolve(null), + isFirstRun = false, + enterAltScreen: useAltScreen = true, + actionOnBack = 'help', + isInteractive = true, + } = options; + await TelemetryClientAccessor.init(initialRoute?.name ?? 'tui', 'tui'); + if (useAltScreen) { + inAltScreen = true; + process.stdout.write(ENTER_ALT_SCREEN); + } + + const { waitUntilExit } = render(React.createElement(App, { initialRoute, actionOnBack, isInteractive })); + + await waitUntilExit(); + + 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(); + clearExitAction(); + + if (action?.type === 'dev') { + const { launchBrowserDev } = await import('../commands/dev/browser-mode'); + await launchBrowserDev(); + return; + } + + // Print any exit message set by screens (e.g., after successful project creation) + const exitMessage = getExitMessage(); + if (exitMessage) { + console.log(exitMessage); + clearExitMessage(); + } + + await printPostCommandNotices(isFirstRun, updateCheck); +} + +/** + * Cleanup handler for alternate screen on process signals. + * Call once at startup. + */ +export function setupAltScreenCleanup() { + const cleanup = () => { + if (inAltScreen) { + process.stdout.write(EXIT_ALT_SCREEN); + } + process.stdout.write(SHOW_CURSOR); + }; + + process.on('exit', cleanup); + process.on('SIGINT', () => { + cleanup(); + process.exit(0); + }); + process.on('SIGTERM', () => { + cleanup(); + process.exit(0); + }); +} diff --git a/src/cli/tui/screens/invoke/useInvokeFlow.ts b/src/cli/tui/screens/invoke/useInvokeFlow.ts index 25dc838ab..486298f4f 100644 --- a/src/cli/tui/screens/invoke/useInvokeFlow.ts +++ b/src/cli/tui/screens/invoke/useInvokeFlow.ts @@ -1,4 +1,4 @@ -import { ConfigIO } from '../../../../lib'; +import { ConfigIO, ResourceNotFoundError } from '../../../../lib'; import type { AgentCoreDeployedState, AwsDeploymentTarget, @@ -25,6 +25,8 @@ import { InvokeLogger } from '../../../logging'; import { formatMcpToolList } from '../../../operations/dev/utils'; import { canFetchRuntimeToken, fetchRuntimeToken } from '../../../operations/fetch-access'; import { generateSessionId } from '../../../operations/session'; +import { withCommandRunTelemetry } from '../../../telemetry/cli-command-run.js'; +import { AgentProtocol, AuthType, standardize } from '../../../telemetry/schemas/common-shapes.js'; import { useCallback, useEffect, useRef, useState } from 'react'; /** Structured message part for rich AGUI event rendering */ @@ -114,84 +116,104 @@ export function useInvokeFlow(options: InvokeFlowOptions = {}): InvokeFlowState // Load config on mount useEffect(() => { const load = async () => { - try { - const configIO = new ConfigIO(); - const project = await configIO.readProjectSpec(); - const deployedState = await configIO.readDeployedState(); - const awsTargets = await configIO.readAWSDeploymentTargets(); - - const targetNames = Object.keys(deployedState.targets); - if (targetNames.length === 0) { - setError('No deployed targets found. Run `agentcore deploy` first.'); - setPhase('error'); - return; - } + const configIO = new ConfigIO(); + const project = await configIO.readProjectSpec().catch(() => undefined); + const firstProtocol = project?.runtimes?.[0]?.protocol ?? 'unknown'; + + const result = await withCommandRunTelemetry( + 'invoke', + { + has_stream: true, + has_session_id: !!initialSessionId, + auth_type: standardize(AuthType, initialBearerToken ? 'bearer_token' : 'sigv4'), + agent_protocol: standardize(AgentProtocol, firstProtocol), + }, + async () => { + if (!project) { + return { success: false as const, error: new ResourceNotFoundError('No agentcore project found.') }; + } + const deployedState = await configIO.readDeployedState(); + const awsTargets = await configIO.readAWSDeploymentTargets(); + + const targetNames = Object.keys(deployedState.targets); + if (targetNames.length === 0) { + return { + success: false as const, + error: new ResourceNotFoundError('No deployed targets found. Run `agentcore deploy` first.'), + }; + } - const targetName = targetNames[0]!; - const targetState = deployedState.targets[targetName]; - const targetConfig = awsTargets.find(t => t.name === targetName); + const targetName = targetNames[0]!; + const targetState = deployedState.targets[targetName]; + const targetConfig = awsTargets.find(t => t.name === targetName); - if (!targetConfig) { - setError(`Target config '${targetName}' not found`); - setPhase('error'); - return; - } + if (!targetConfig) { + return { + success: false as const, + error: new ResourceNotFoundError(`Target config '${targetName}' not found`), + }; + } - const runtimes: InvokeConfig['runtimes'] = []; - const deployedBundles = targetState?.resources?.configBundles ?? {}; - for (const agent of project.runtimes) { - const state = targetState?.resources?.runtimes?.[agent.name]; - if (!state) continue; - - // Build config bundle baggage if a bundle is associated with this agent - let baggage: string | undefined; - const bundleSpec = project.configBundles?.find(b => { - const keys = Object.keys(b.components ?? {}); - return keys.some(k => k === `{{runtime:${agent.name}}}`); - }); - if (bundleSpec) { - const bundleState = deployedBundles[bundleSpec.name]; - if (bundleState?.bundleArn && bundleState?.versionId) { - baggage = `aws.agentcore.configbundle_arn=${encodeURIComponent(bundleState.bundleArn)},aws.agentcore.configbundle_version=${encodeURIComponent(bundleState.versionId)}`; + const runtimes: InvokeConfig['runtimes'] = []; + const deployedBundles = targetState?.resources?.configBundles ?? {}; + for (const agent of project.runtimes) { + const state = targetState?.resources?.runtimes?.[agent.name]; + if (!state) continue; + + // Build config bundle baggage if a bundle is associated with this agent + let baggage: string | undefined; + const bundleSpec = project.configBundles?.find(b => { + const keys = Object.keys(b.components ?? {}); + return keys.some(k => k === `{{runtime:${agent.name}}}`); + }); + if (bundleSpec) { + const bundleState = deployedBundles[bundleSpec.name]; + if (bundleState?.bundleArn && bundleState?.versionId) { + baggage = `aws.agentcore.configbundle_arn=${encodeURIComponent(bundleState.bundleArn)},aws.agentcore.configbundle_version=${encodeURIComponent(bundleState.versionId)}`; + } } + + const supportsTraces = agent.entrypoint?.endsWith('.py') || agent.entrypoint?.includes('.py:') || false; + runtimes.push({ + name: agent.name, + state, + modelProvider: undefined, + networkMode: agent.networkMode, + protocol: agent.protocol, + authorizerType: agent.authorizerType, + baggage, + supportsTraces, + }); } - const supportsTraces = agent.entrypoint?.endsWith('.py') || agent.entrypoint?.includes('.py:') || false; - runtimes.push({ - name: agent.name, - state, - modelProvider: undefined, - networkMode: agent.networkMode, - protocol: agent.protocol, - authorizerType: agent.authorizerType, - baggage, - supportsTraces, - }); - } + if (runtimes.length === 0) { + return { + success: false as const, + error: new ResourceNotFoundError('No deployed agents found. Run `agentcore deploy` first.'), + }; + } - if (runtimes.length === 0) { - setError('No deployed agents found. Run `agentcore deploy` first.'); - setPhase('error'); - return; - } + setConfig({ runtimes, target: targetConfig, targetName, projectName: project.name }); - setConfig({ runtimes, target: targetConfig, targetName, projectName: project.name }); + // Initialize session ID - always generate fresh unless explicitly provided + if (initialSessionId) { + setSessionId(initialSessionId); + } else { + const newId = generateSessionId(); + setSessionId(newId); + } - // Initialize session ID - always generate fresh unless explicitly provided - if (initialSessionId) { - setSessionId(initialSessionId); - } else { - const newId = generateSessionId(); - setSessionId(newId); + setPhase('ready'); + return { success: true as const }; } - - setPhase('ready'); - } catch (err) { - setError(getErrorMessage(err)); + ); + if (!result.success) { + setError(getErrorMessage(result.error)); setPhase('error'); } }; void load(); + // eslint-disable-next-line react-hooks/exhaustive-deps }, [initialSessionId]); const getMcpInvokeOptions = useCallback(() => {