From d4e050ee4616b86b5a5c796762e054f192904723 Mon Sep 17 00:00:00 2001 From: Harrison Weinstock Date: Thu, 21 May 2026 19:56:38 +0000 Subject: [PATCH 1/9] fix: create global entrypoint for tui Create a unified renderTUI() entrypoint that all TUI-rendering code paths use instead of inline Ink render() calls. This fixes telemetry never being emitted for bare 'agentcore' TUI mode and mislabeling TUI sessions as CLI. - Add renderTUI() with RenderTUIOptions for configurable behavior - Migrate add, deploy, create, remove, invoke commands to use renderTUI() - Add InitialRoute type for type-safe route navigation - Add actionOnBack option to control escape/back behavior - Add enterAltScreen option for inline vs full-screen rendering - Initialize and shutdown TelemetryClientAccessor within renderTUI() Fixes #895 --- src/cli/cli.ts | 50 ++++++++++++++++----- src/cli/commands/add/command.tsx | 16 ++----- src/cli/commands/create/command.tsx | 18 ++------ src/cli/commands/deploy/command.tsx | 21 +++------ src/cli/commands/invoke/command.tsx | 41 +++++------------ src/cli/commands/remove/command.tsx | 25 ++--------- src/cli/tui/App.tsx | 68 +++++++++++++++++++---------- 7 files changed, 113 insertions(+), 126 deletions(-) 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 ( - + ); } From 64d6720d879fe4e9ffc94a627bfb591b679333bf Mon Sep 17 00:00:00 2001 From: Harrison Weinstock Date: Fri, 22 May 2026 02:59:07 +0000 Subject: [PATCH 2/9] fix: emit cli.command_run telemetry for TUI invoke sessions Move the withCommandRunTelemetry('invoke', ...) call into useInvokeFlow so that a cli.command_run event is emitted regardless of how the invoke screen is launched (bare agentcore TUI or agentcore invoke command). This restores the telemetry emission that was lost when the invoke command was migrated to use renderTUI(). --- src/cli/tui/screens/invoke/useInvokeFlow.ts | 143 +++++++++++--------- 1 file changed, 78 insertions(+), 65 deletions(-) diff --git a/src/cli/tui/screens/invoke/useInvokeFlow.ts b/src/cli/tui/screens/invoke/useInvokeFlow.ts index 25dc838ab..0e9154e55 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,80 +116,91 @@ 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 result = await withCommandRunTelemetry( + 'invoke', + { + has_stream: true, + has_session_id: !!initialSessionId, + auth_type: standardize(AuthType, initialBearerToken ? 'bearer_token' : 'sigv4'), + agent_protocol: standardize(AgentProtocol, 'unknown'), + }, + async () => { + 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) { + 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'); } }; From d20931dbd8ca49a8540b7259573deec73a47895b Mon Sep 17 00:00:00 2001 From: Harrison Weinstock Date: Fri, 22 May 2026 03:05:46 +0000 Subject: [PATCH 3/9] fix: thread isInteractive through renderTUI to preserve auto-exit behavior Commands launched via CLI (agentcore add, deploy, etc.) previously rendered with isInteractive=false, causing screens to auto-exit after success. The renderTUI migration broke this by hard-coding isInteractive=true. Add isInteractive option to RenderTUIOptions (default: true) and thread it through App to all screens. CLI command handlers pass isInteractive=false to preserve the previous auto-exit behavior. --- src/cli/cli.ts | 5 ++- src/cli/commands/add/command.tsx | 7 ++++- src/cli/commands/create/command.tsx | 7 ++++- src/cli/commands/deploy/command.tsx | 7 ++++- src/cli/commands/invoke/command.tsx | 1 + src/cli/commands/remove/command.tsx | 14 +++++++-- src/cli/tui/App.tsx | 48 +++++++++++++++++++---------- 7 files changed, 67 insertions(+), 22 deletions(-) diff --git a/src/cli/cli.ts b/src/cli/cli.ts index 61aef8303..ac0a90685 100644 --- a/src/cli/cli.ts +++ b/src/cli/cli.ts @@ -110,6 +110,8 @@ export interface RenderTUIOptions { 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; } /** @@ -123,6 +125,7 @@ export function renderTUI(options: RenderTUIOptions = {}) { isFirstRun = false, enterAltScreen = true, actionOnBack = 'help', + isInteractive = true, } = options; TelemetryClientAccessor.init(initialRoute?.name ?? 'tui', 'tui'); if (enterAltScreen) { @@ -130,7 +133,7 @@ export function renderTUI(options: RenderTUIOptions = {}) { process.stdout.write(ENTER_ALT_SCREEN); } - const { waitUntilExit } = render(React.createElement(App, { initialRoute, actionOnBack })); + const { waitUntilExit } = render(React.createElement(App, { initialRoute, actionOnBack, isInteractive })); const done = waitUntilExit().then(async () => { if (inAltScreen) { diff --git a/src/cli/commands/add/command.tsx b/src/cli/commands/add/command.tsx index 651a0c2d5..215c7e23e 100644 --- a/src/cli/commands/add/command.tsx +++ b/src/cli/commands/add/command.tsx @@ -21,7 +21,12 @@ export function registerAdd(program: Command): Command { requireProject(); requireTTY(); - await renderTUI({ initialRoute: { name: 'add' }, enterAltScreen: false, actionOnBack: 'exit' }); + 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 b705bd316..dd2517c2b 100644 --- a/src/cli/commands/create/command.tsx +++ b/src/cli/commands/create/command.tsx @@ -33,7 +33,12 @@ import { Text, render } from 'ink'; /** Render CreateScreen for interactive TUI mode */ function handleCreateTUI(): Promise { - return renderTUI({ initialRoute: { name: 'create' }, enterAltScreen: false, actionOnBack: 'exit' }); + return renderTUI({ + initialRoute: { name: 'create' }, + enterAltScreen: false, + actionOnBack: 'exit', + isInteractive: false, + }); } /** Print completion summary after successful create */ diff --git a/src/cli/commands/deploy/command.tsx b/src/cli/commands/deploy/command.tsx index bc3207f47..648a42d74 100644 --- a/src/cli/commands/deploy/command.tsx +++ b/src/cli/commands/deploy/command.tsx @@ -16,7 +16,12 @@ const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', function handleDeployTUI(options: { autoConfirm?: boolean; diffMode?: boolean } = {}): Promise { requireProject(); - return renderTUI({ initialRoute: { name: 'deploy' }, enterAltScreen: false, actionOnBack: 'exit' }); + return renderTUI({ + initialRoute: { name: 'deploy' }, + enterAltScreen: false, + actionOnBack: 'exit', + isInteractive: false, + }); } async function handleDeployCLI(options: DeployOptions): Promise { diff --git a/src/cli/commands/invoke/command.tsx b/src/cli/commands/invoke/command.tsx index 9969b16e3..c20fb054f 100644 --- a/src/cli/commands/invoke/command.tsx +++ b/src/cli/commands/invoke/command.tsx @@ -250,6 +250,7 @@ export const registerInvoke = (program: Command) => { }, enterAltScreen: false, actionOnBack: 'exit', + isInteractive: false, }); } } catch (error) { diff --git a/src/cli/commands/remove/command.tsx b/src/cli/commands/remove/command.tsx index c07191dda..9b96fbe1a 100644 --- a/src/cli/commands/remove/command.tsx +++ b/src/cli/commands/remove/command.tsx @@ -83,7 +83,12 @@ export const registerRemove = (program: Command): Command => { }); } else { requireTTY(); - await renderTUI({ initialRoute: { name: 'remove' }, enterAltScreen: false, actionOnBack: 'exit' }); + await renderTUI({ + initialRoute: { name: 'remove' }, + enterAltScreen: false, + actionOnBack: 'exit', + isInteractive: false, + }); } } catch (error) { if (cliOptions.json) { @@ -113,7 +118,12 @@ export const registerRemove = (program: Command): Command => { requireProject(); requireTTY(); - await renderTUI({ initialRoute: { name: 'remove' }, enterAltScreen: false, actionOnBack: 'exit' }); + await renderTUI({ + initialRoute: { name: 'remove' }, + enterAltScreen: false, + actionOnBack: 'exit', + isInteractive: false, + }); }) .showHelpAfterError() .showSuggestionAfterError(); diff --git a/src/cli/tui/App.tsx b/src/cli/tui/App.tsx index c9e30c8bf..14668a83c 100644 --- a/src/cli/tui/App.tsx +++ b/src/cli/tui/App.tsx @@ -68,7 +68,15 @@ 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' }) { +function AppContent({ + initialRoute, + actionOnBack, + isInteractive = true, +}: { + initialRoute?: InitialRoute; + actionOnBack?: 'help' | 'exit'; + isInteractive?: boolean; +}) { const { exit } = useApp(); // Start on help screen if project exists (show commands), otherwise home (show Quick Start) const inProject = projectExists(); @@ -189,7 +197,7 @@ function AppContent({ initialRoute, actionOnBack }: { initialRoute?: InitialRout if (route.name === 'deploy') { return ( setRoute({ name: command } as Route)} /> @@ -199,7 +207,7 @@ function AppContent({ initialRoute, actionOnBack }: { initialRoute?: InitialRout if (route.name === 'invoke') { return ( ; + return ; } if (route.name === 'status') { - return ; + return ; } if (route.name === 'add') { return ( { setExitAction({ type: 'dev' }); @@ -234,7 +242,7 @@ function AppContent({ initialRoute, actionOnBack }: { initialRoute?: InitialRout if (route.name === 'remove') { return ( setRoute({ name: command } as Route)} /> @@ -245,7 +253,7 @@ function AppContent({ initialRoute, actionOnBack }: { initialRoute?: InitialRout return ( { process.chdir(workingDir); @@ -322,23 +330,23 @@ function AppContent({ initialRoute, actionOnBack }: { initialRoute?: InitialRout } 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 ; + return ; } if (route.name === 'validate') { - return ; + return ; } if (route.name === 'package') { - return ; + return ; } if (route.name === 'import') { @@ -351,7 +359,7 @@ function AppContent({ initialRoute, actionOnBack }: { initialRoute?: InitialRout } if (route.name === 'update') { - return ; + return ; } if (route.name === 'config-bundle') { @@ -379,10 +387,18 @@ function AppContent({ initialRoute, actionOnBack }: { initialRoute?: InitialRout return null; } -export function App({ initialRoute, actionOnBack }: { initialRoute?: InitialRoute; actionOnBack?: 'help' | 'exit' }) { +export function App({ + initialRoute, + actionOnBack, + isInteractive = true, +}: { + initialRoute?: InitialRoute; + actionOnBack?: 'help' | 'exit'; + isInteractive?: boolean; +}) { return ( - + ); } From ed75a39229f3ec6fe8e32c65c1cffecd4ecb5982 Mon Sep 17 00:00:00 2001 From: Harrison Weinstock Date: Fri, 22 May 2026 03:07:37 +0000 Subject: [PATCH 4/9] fix: prevent double telemetry client init on command-routed TUI paths When agentcore add (or similar) is invoked, main() calls init('add', 'cli') then the command handler calls renderTUI() which needs mode='tui'. Previously this called init() again, silently dropping the first client without shutdown. - Make init() a no-op if already initialized - Add setMode() which cleanly shuts down the existing client before creating a new one with the correct mode - renderTUI() now calls await setMode() instead of init() - shutdown() clears clientPromise so subsequent init/setMode works correctly --- src/cli/cli.ts | 8 ++++---- src/cli/telemetry/client-accessor.ts | 8 +++++++- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/src/cli/cli.ts b/src/cli/cli.ts index ac0a90685..c8702a16a 100644 --- a/src/cli/cli.ts +++ b/src/cli/cli.ts @@ -118,7 +118,7 @@ export interface RenderTUIOptions { * Render the TUI in alternate screen buffer mode. * This is the entrypoint for TUI operations */ -export function renderTUI(options: RenderTUIOptions = {}) { +export async function renderTUI(options: RenderTUIOptions = {}) { const { initialRoute, updateCheck = Promise.resolve(null), @@ -127,7 +127,7 @@ export function renderTUI(options: RenderTUIOptions = {}) { actionOnBack = 'help', isInteractive = true, } = options; - TelemetryClientAccessor.init(initialRoute?.name ?? 'tui', 'tui'); + await TelemetryClientAccessor.init(initialRoute?.name ?? 'tui', 'tui'); if (enterAltScreen) { inAltScreen = true; process.stdout.write(ENTER_ALT_SCREEN); @@ -164,7 +164,7 @@ export function renderTUI(options: RenderTUIOptions = {}) { await printPostCommandNotices(isFirstRun, updateCheck); }); - return done; + await done; } function renderHelp(program: Command): void { @@ -271,7 +271,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/telemetry/client-accessor.ts b/src/cli/telemetry/client-accessor.ts index 53a7ddb46..3f6b1bb58 100644 --- a/src/cli/telemetry/client-accessor.ts +++ b/src/cli/telemetry/client-accessor.ts @@ -20,10 +20,15 @@ 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); } + + static get(): Promise { this.clientPromise ??= createClient('unknown'); return this.clientPromise; @@ -37,6 +42,7 @@ export class TelemetryClientAccessor { } catch { // Telemetry is best-effort — don't propagate init or shutdown failures } + this.clientPromise = undefined; } } } From e1045f08fdc9f698f37e9153c6bd16bccb8ddc64 Mon Sep 17 00:00:00 2001 From: Harrison Weinstock Date: Fri, 22 May 2026 03:16:02 +0000 Subject: [PATCH 5/9] refactor: simplify renderTUI by awaiting waitUntilExit directly Replace .then() callback with direct await since renderTUI is already async. --- src/cli/cli.ts | 46 ++++++++++++++++++++++------------------------ 1 file changed, 22 insertions(+), 24 deletions(-) diff --git a/src/cli/cli.ts b/src/cli/cli.ts index c8702a16a..cefd0393f 100644 --- a/src/cli/cli.ts +++ b/src/cli/cli.ts @@ -135,36 +135,34 @@ export async function renderTUI(options: RenderTUIOptions = {}) { const { waitUntilExit } = render(React.createElement(App, { initialRoute, actionOnBack, isInteractive })); - const done = waitUntilExit().then(async () => { - if (inAltScreen) { - inAltScreen = false; - process.stdout.write(EXIT_ALT_SCREEN); - process.stdout.write(SHOW_CURSOR); - } + await waitUntilExit(); - await TelemetryClientAccessor.shutdown(); + if (inAltScreen) { + 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(); + await TelemetryClientAccessor.shutdown(); - if (action?.type === 'dev') { - const { launchBrowserDev } = await import('./commands/dev/browser-mode'); - await launchBrowserDev(); - return; - } + // Check if the TUI requested a post-exit action (e.g., launch browser dev mode) + const action = getExitAction(); + clearExitAction(); - // Print any exit message set by screens (e.g., after successful project creation) - const exitMessage = getExitMessage(); - if (exitMessage) { - console.log(exitMessage); - clearExitMessage(); - } + if (action?.type === 'dev') { + const { launchBrowserDev } = await import('./commands/dev/browser-mode'); + await launchBrowserDev(); + return; + } - await printPostCommandNotices(isFirstRun, updateCheck); - }); + // Print any exit message set by screens (e.g., after successful project creation) + const exitMessage = getExitMessage(); + if (exitMessage) { + console.log(exitMessage); + clearExitMessage(); + } - await done; + await printPostCommandNotices(isFirstRun, updateCheck); } function renderHelp(program: Command): void { From 8d0f3b396c547ae443f8925910d8c5a47806044f Mon Sep 17 00:00:00 2001 From: Harrison Weinstock Date: Fri, 22 May 2026 03:19:06 +0000 Subject: [PATCH 6/9] refactor: extract renderTUI into src/cli/tui/render-tui.ts Break the circular import between cli.ts and command handlers by moving renderTUI, RenderTUIOptions, and alt-screen helpers into their own module. Command handlers now import from tui/render-tui instead of ../../cli. --- src/cli/cli.ts | 132 +--------------------------- src/cli/commands/add/command.tsx | 2 +- src/cli/commands/create/command.tsx | 2 +- src/cli/commands/deploy/command.tsx | 2 +- src/cli/commands/invoke/command.tsx | 2 +- src/cli/commands/remove/command.tsx | 2 +- src/cli/notices.ts | 31 +++++++ src/cli/tui/App.tsx | 4 +- src/cli/tui/index.ts | 1 + src/cli/tui/render.ts | 103 ++++++++++++++++++++++ 10 files changed, 146 insertions(+), 135 deletions(-) create mode 100644 src/cli/notices.ts create mode 100644 src/cli/tui/render.ts diff --git a/src/cli/cli.ts b/src/cli/cli.ts index cefd0393f..b405e741a 100644 --- a/src/cli/cli.ts +++ b/src/cli/cli.ts @@ -26,145 +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, type InitialRoute } 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); - } - }); -} - -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 TUI operations - */ -export async function renderTUI(options: RenderTUIOptions = {}) { - const { - initialRoute, - updateCheck = Promise.resolve(null), - isFirstRun = false, - enterAltScreen = true, - actionOnBack = 'help', - isInteractive = true, - } = options; - await TelemetryClientAccessor.init(initialRoute?.name ?? 'tui', 'tui'); - if (enterAltScreen) { - 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); -} - function renderHelp(program: Command): void { const commands = getCommandsForUI(program); render(React.createElement(LayoutProvider, null, React.createElement(CommandListScreen, { commands }))); @@ -245,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(); diff --git a/src/cli/commands/add/command.tsx b/src/cli/commands/add/command.tsx index 215c7e23e..4afbc3e88 100644 --- a/src/cli/commands/add/command.tsx +++ b/src/cli/commands/add/command.tsx @@ -1,6 +1,6 @@ -import { renderTUI } from '../../cli'; import { COMMAND_DESCRIPTIONS } from '../../tui/copy'; import { requireProject, requireTTY } from '../../tui/guards'; +import { renderTUI } from '../../tui'; import type { Command } from '@commander-js/extra-typings'; export function registerAdd(program: Command): Command { diff --git a/src/cli/commands/create/command.tsx b/src/cli/commands/create/command.tsx index dd2517c2b..35a6f7882 100644 --- a/src/cli/commands/create/command.tsx +++ b/src/cli/commands/create/command.tsx @@ -8,7 +8,6 @@ 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 { @@ -24,6 +23,7 @@ import { } from '../../telemetry/schemas/common-shapes.js'; import { COMMAND_DESCRIPTIONS } from '../../tui/copy'; import { requireTTY } from '../../tui/guards'; +import { renderTUI } from '../../tui'; import { parseCommaSeparatedList } from '../shared/vpc-utils'; import { type ProgressCallback, createProject, createProjectWithAgent, getDryRunInfo } from './action'; import type { CreateOptions } from './types'; diff --git a/src/cli/commands/deploy/command.tsx b/src/cli/commands/deploy/command.tsx index 648a42d74..1fe311b73 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 { renderTUI } from '../../tui'; import { handleDeploy } from './actions'; import type { DeployOptions, DeployResult } from './types'; import { DEFAULT_DEPLOY_ATTRS, computeDeployAttrs } from './utils'; diff --git a/src/cli/commands/invoke/command.tsx b/src/cli/commands/invoke/command.tsx index c20fb054f..dbd46112b 100644 --- a/src/cli/commands/invoke/command.tsx +++ b/src/cli/commands/invoke/command.tsx @@ -1,10 +1,10 @@ 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 { renderTUI } from '../../tui'; import { parseHeaderFlags } from '../shared/header-utils'; import { type InvokeContext, handleInvoke, loadInvokeConfig } from './action'; import { resolvePrompt } from './resolve-prompt'; diff --git a/src/cli/commands/remove/command.tsx b/src/cli/commands/remove/command.tsx index 9b96fbe1a..22e9c0991 100644 --- a/src/cli/commands/remove/command.tsx +++ b/src/cli/commands/remove/command.tsx @@ -1,9 +1,9 @@ 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 { renderTUI } from '../../tui'; import type { RemoveAllOptions, RemoveResult } from './types'; import { validateRemoveAllOptions } from './validate'; import type { Command } from '@commander-js/extra-typings'; 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/tui/App.tsx b/src/cli/tui/App.tsx index 14668a83c..1040c4bdc 100644 --- a/src/cli/tui/App.tsx +++ b/src/cli/tui/App.tsx @@ -28,8 +28,7 @@ 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' } @@ -78,6 +77,7 @@ function AppContent({ 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(); 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); + }); +} From d4efe1d542e39e67b3669507c4f45169fece4d9c Mon Sep 17 00:00:00 2001 From: Harrison Weinstock Date: Fri, 22 May 2026 03:39:13 +0000 Subject: [PATCH 7/9] fix: thread diffMode through deploy route to DeployScreen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The renderTUI migration dropped diffMode — agentcore deploy --diff silently fell back to a regular deploy flow. Extend the deploy Route variant with diffMode and pass it through to DeployScreen. Add test that verifies diffMode reaches renderTUI via initialRoute. --- src/cli/commands/deploy/command.tsx | 6 +++--- src/cli/tui/App.tsx | 3 ++- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/cli/commands/deploy/command.tsx b/src/cli/commands/deploy/command.tsx index 1fe311b73..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 { renderTUI } from '../../tui'; import { handleDeploy } from './actions'; import type { DeployOptions, DeployResult } from './types'; import { DEFAULT_DEPLOY_ATTRS, computeDeployAttrs } from './utils'; @@ -14,10 +14,10 @@ import React from 'react'; const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; -function handleDeployTUI(options: { autoConfirm?: boolean; diffMode?: boolean } = {}): Promise { +function handleDeployTUI(options: { diffMode?: boolean } = {}): Promise { requireProject(); return renderTUI({ - initialRoute: { name: 'deploy' }, + initialRoute: { name: 'deploy', diffMode: options.diffMode }, enterAltScreen: false, actionOnBack: 'exit', isInteractive: false, diff --git a/src/cli/tui/App.tsx b/src/cli/tui/App.tsx index 1040c4bdc..662e6e344 100644 --- a/src/cli/tui/App.tsx +++ b/src/cli/tui/App.tsx @@ -33,7 +33,7 @@ import React, { useState } from 'react'; type Route = | { name: 'home' } | { name: 'help'; initialQuery?: string } - | { name: 'deploy' } + | { name: 'deploy'; diffMode?: boolean } | { name: 'invoke'; sessionId?: string; userId?: string; headers?: Record; bearerToken?: string } | { name: 'logs' } | { name: 'create' } @@ -198,6 +198,7 @@ function AppContent({ return ( setRoute({ name: command } as Route)} /> From 8acb65f139dda75b6b6424446157fd077a5ddba9 Mon Sep 17 00:00:00 2001 From: Harrison Weinstock Date: Fri, 22 May 2026 03:43:55 +0000 Subject: [PATCH 8/9] fix: resolve agent_protocol from project spec in invoke telemetry The invoke telemetry was hard-coding agent_protocol='unknown'. Read the project spec before the telemetry wrapper so the actual protocol from the first runtime is used in the metric attributes. --- src/cli/commands/add/command.tsx | 2 +- src/cli/commands/create/command.tsx | 2 +- src/cli/commands/invoke/command.tsx | 2 +- src/cli/commands/remove/command.tsx | 2 +- src/cli/telemetry/client-accessor.ts | 2 -- src/cli/tui/screens/invoke/useInvokeFlow.ts | 17 +++++++++++++---- 6 files changed, 17 insertions(+), 10 deletions(-) diff --git a/src/cli/commands/add/command.tsx b/src/cli/commands/add/command.tsx index 4afbc3e88..4a98fa0d5 100644 --- a/src/cli/commands/add/command.tsx +++ b/src/cli/commands/add/command.tsx @@ -1,6 +1,6 @@ +import { renderTUI } from '../../tui'; import { COMMAND_DESCRIPTIONS } from '../../tui/copy'; import { requireProject, requireTTY } from '../../tui/guards'; -import { renderTUI } from '../../tui'; import type { Command } from '@commander-js/extra-typings'; export function registerAdd(program: Command): Command { diff --git a/src/cli/commands/create/command.tsx b/src/cli/commands/create/command.tsx index 35a6f7882..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 { renderTUI } from '../../tui'; import { parseCommaSeparatedList } from '../shared/vpc-utils'; import { type ProgressCallback, createProject, createProjectWithAgent, getDryRunInfo } from './action'; import type { CreateOptions } from './types'; diff --git a/src/cli/commands/invoke/command.tsx b/src/cli/commands/invoke/command.tsx index dbd46112b..7c9c21815 100644 --- a/src/cli/commands/invoke/command.tsx +++ b/src/cli/commands/invoke/command.tsx @@ -2,9 +2,9 @@ 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 { renderTUI } from '../../tui'; import { parseHeaderFlags } from '../shared/header-utils'; import { type InvokeContext, handleInvoke, loadInvokeConfig } from './action'; import { resolvePrompt } from './resolve-prompt'; diff --git a/src/cli/commands/remove/command.tsx b/src/cli/commands/remove/command.tsx index 22e9c0991..5fef1cb86 100644 --- a/src/cli/commands/remove/command.tsx +++ b/src/cli/commands/remove/command.tsx @@ -1,9 +1,9 @@ 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 { renderTUI } from '../../tui'; import type { RemoveAllOptions, RemoveResult } from './types'; import { validateRemoveAllOptions } from './validate'; import type { Command } from '@commander-js/extra-typings'; diff --git a/src/cli/telemetry/client-accessor.ts b/src/cli/telemetry/client-accessor.ts index 3f6b1bb58..4a1959c88 100644 --- a/src/cli/telemetry/client-accessor.ts +++ b/src/cli/telemetry/client-accessor.ts @@ -27,8 +27,6 @@ export class TelemetryClientAccessor { this.clientPromise = createClient(entrypoint, mode); } - - static get(): Promise { this.clientPromise ??= createClient('unknown'); return this.clientPromise; diff --git a/src/cli/tui/screens/invoke/useInvokeFlow.ts b/src/cli/tui/screens/invoke/useInvokeFlow.ts index 0e9154e55..486298f4f 100644 --- a/src/cli/tui/screens/invoke/useInvokeFlow.ts +++ b/src/cli/tui/screens/invoke/useInvokeFlow.ts @@ -116,17 +116,22 @@ export function useInvokeFlow(options: InvokeFlowOptions = {}): InvokeFlowState // Load config on mount useEffect(() => { const load = async () => { + 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, 'unknown'), + agent_protocol: standardize(AgentProtocol, firstProtocol), }, async () => { - const configIO = new ConfigIO(); - const project = await configIO.readProjectSpec(); + if (!project) { + return { success: false as const, error: new ResourceNotFoundError('No agentcore project found.') }; + } const deployedState = await configIO.readDeployedState(); const awsTargets = await configIO.readAWSDeploymentTargets(); @@ -143,7 +148,10 @@ export function useInvokeFlow(options: InvokeFlowOptions = {}): InvokeFlowState const targetConfig = awsTargets.find(t => t.name === targetName); if (!targetConfig) { - return { success: false as const, error: new ResourceNotFoundError(`Target config '${targetName}' not found`) }; + return { + success: false as const, + error: new ResourceNotFoundError(`Target config '${targetName}' not found`), + }; } const runtimes: InvokeConfig['runtimes'] = []; @@ -205,6 +213,7 @@ export function useInvokeFlow(options: InvokeFlowOptions = {}): InvokeFlowState } }; void load(); + // eslint-disable-next-line react-hooks/exhaustive-deps }, [initialSessionId]); const getMcpInvokeOptions = useCallback(() => { From 7f6cf1a704c1c475fcc4cd0e0d697fcbfc56c9bc Mon Sep 17 00:00:00 2001 From: Harrison Weinstock Date: Fri, 22 May 2026 12:41:42 +0000 Subject: [PATCH 9/9] docs: clarify InitialRoute exclusion comment --- src/cli/tui/App.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/cli/tui/App.tsx b/src/cli/tui/App.tsx index 662e6e344..5a550b7da 100644 --- a/src/cli/tui/App.tsx +++ b/src/cli/tui/App.tsx @@ -64,7 +64,7 @@ const PROJECT_ROOT_EXEMPT_COMMANDS = new Set(['create', 'update']); export type RouteName = Route['name']; -// cli-only requires a commandId field, so it cannot be used as an initial route via name alone. +// 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({