From a8f561a479078b4c0d7bc201d5cf7aadea72615b Mon Sep 17 00:00:00 2001 From: Harrison Weinstock Date: Thu, 21 May 2026 19:56:38 +0000 Subject: [PATCH 01/11] 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 387a802ac..e5cc82eb3 100644 --- a/src/cli/cli.ts +++ b/src/cli/cli.ts @@ -29,7 +29,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'; @@ -100,19 +100,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(); @@ -133,6 +161,8 @@ function renderTUI(updateCheck: Promise, isFirstRun: b await printPostCommandNotices(isFirstRun, updateCheck); }); + + return done; } function renderHelp(program: Command): void { @@ -232,7 +262,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 c4b296089..db0492079 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 { @@ -85,15 +84,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) { @@ -113,7 +104,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(); @@ -123,15 +114,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 62fc7db93..fec628419 100644 --- a/src/cli/tui/App.tsx +++ b/src/cli/tui/App.tsx @@ -36,7 +36,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' } @@ -65,15 +65,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 }); @@ -181,29 +194,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(); @@ -217,7 +239,7 @@ function AppContent() { return ( setRoute({ name: 'help' })} + onExit={handleBack} onNavigate={command => setRoute({ name: command } as Route)} /> ); @@ -228,7 +250,7 @@ function AppContent() { setRoute({ name: 'help' })} + onExit={handleBack} onNavigate={({ command, workingDir }) => { process.chdir(workingDir); setRoute({ name: command } as Route); @@ -243,7 +265,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} /> ); } @@ -258,7 +280,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} /> ); } @@ -289,7 +311,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} /> ); } @@ -312,15 +334,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') { @@ -333,11 +355,11 @@ function AppContent() { } if (route.name === 'update') { - return setRoute({ name: 'help' })} />; + return ; } if (route.name === 'config-bundle') { - return setRoute({ name: 'help' })} />; + return ; } if (route.name === 'dataset') { @@ -345,7 +367,7 @@ function AppContent() { } if (route.name === 'ab-test') { - return setRoute({ name: 'help' })} />; + return ; } if (route.name === 'cli-only') { @@ -356,7 +378,7 @@ function AppContent() { title={route.commandId} description={info.description} examples={info.examples} - onExit={() => setRoute({ name: 'help' })} + onExit={handleBack} /> ); } @@ -365,10 +387,10 @@ function AppContent() { return null; } -export function App() { +export function App({ initialRoute, actionOnBack }: { initialRoute?: InitialRoute; actionOnBack?: 'help' | 'exit' }) { return ( - + ); } From 5cc4c12c68dfc47472fc462b5cb72dbbe2047a00 Mon Sep 17 00:00:00 2001 From: Harrison Weinstock Date: Fri, 22 May 2026 02:59:07 +0000 Subject: [PATCH 02/11] 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 28ea4f3b175db936e206339db26916595a914f4e Mon Sep 17 00:00:00 2001 From: Harrison Weinstock Date: Fri, 22 May 2026 03:05:46 +0000 Subject: [PATCH 03/11] 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 e5cc82eb3..9587498b8 100644 --- a/src/cli/cli.ts +++ b/src/cli/cli.ts @@ -111,6 +111,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; } /** @@ -124,6 +126,7 @@ export function renderTUI(options: RenderTUIOptions = {}) { isFirstRun = false, enterAltScreen = true, actionOnBack = 'help', + isInteractive = true, } = options; TelemetryClientAccessor.init(initialRoute?.name ?? 'tui', 'tui'); if (enterAltScreen) { @@ -131,7 +134,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 db0492079..a3fd0aab2 100644 --- a/src/cli/commands/remove/command.tsx +++ b/src/cli/commands/remove/command.tsx @@ -84,7 +84,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) { @@ -114,7 +119,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 fec628419..d2e22bf78 100644 --- a/src/cli/tui/App.tsx +++ b/src/cli/tui/App.tsx @@ -70,7 +70,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(); @@ -193,7 +201,7 @@ function AppContent({ initialRoute, actionOnBack }: { initialRoute?: InitialRout if (route.name === 'deploy') { return ( setRoute({ name: command } as Route)} /> @@ -203,7 +211,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' }); @@ -238,7 +246,7 @@ function AppContent({ initialRoute, actionOnBack }: { initialRoute?: InitialRout if (route.name === 'remove') { return ( setRoute({ name: command } as Route)} /> @@ -249,7 +257,7 @@ function AppContent({ initialRoute, actionOnBack }: { initialRoute?: InitialRout return ( { process.chdir(workingDir); @@ -326,23 +334,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') { @@ -355,7 +363,7 @@ function AppContent({ initialRoute, actionOnBack }: { initialRoute?: InitialRout } if (route.name === 'update') { - return ; + return ; } if (route.name === 'config-bundle') { @@ -387,10 +395,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 5eecd3e2cc23be961808703a82331857abb1b1b6 Mon Sep 17 00:00:00 2001 From: Harrison Weinstock Date: Fri, 22 May 2026 03:07:37 +0000 Subject: [PATCH 04/11] 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 9587498b8..4d77edb0c 100644 --- a/src/cli/cli.ts +++ b/src/cli/cli.ts @@ -119,7 +119,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), @@ -128,7 +128,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); @@ -165,7 +165,7 @@ export function renderTUI(options: RenderTUIOptions = {}) { await printPostCommandNotices(isFirstRun, updateCheck); }); - return done; + await done; } function renderHelp(program: Command): void { @@ -273,7 +273,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 45c4eb7ff9c22471d193f58a22dfcae5a20ba98c Mon Sep 17 00:00:00 2001 From: Harrison Weinstock Date: Fri, 22 May 2026 03:16:02 +0000 Subject: [PATCH 05/11] 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 4d77edb0c..32f7aac2d 100644 --- a/src/cli/cli.ts +++ b/src/cli/cli.ts @@ -136,36 +136,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 a17e7652a7ff78323be1f9c3174b7b10f0f6bd6e Mon Sep 17 00:00:00 2001 From: Harrison Weinstock Date: Fri, 22 May 2026 03:19:06 +0000 Subject: [PATCH 06/11] 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 32f7aac2d..9ec38ce18 100644 --- a/src/cli/cli.ts +++ b/src/cli/cli.ts @@ -27,145 +27,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 }))); @@ -247,7 +123,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 a3fd0aab2..018ec4411 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 d2e22bf78..7c501c28c 100644 --- a/src/cli/tui/App.tsx +++ b/src/cli/tui/App.tsx @@ -29,8 +29,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' } @@ -80,6 +79,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 554557e0c4626a3ce965bdaeba37aa2a41822882 Mon Sep 17 00:00:00 2001 From: Harrison Weinstock Date: Fri, 22 May 2026 03:39:13 +0000 Subject: [PATCH 07/11] 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 7c501c28c..fe79ce236 100644 --- a/src/cli/tui/App.tsx +++ b/src/cli/tui/App.tsx @@ -34,7 +34,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' } @@ -202,6 +202,7 @@ function AppContent({ return ( setRoute({ name: command } as Route)} /> From 72ac35a05f1e384058cd60ed7bd274fdc4a2d3df Mon Sep 17 00:00:00 2001 From: Harrison Weinstock Date: Fri, 22 May 2026 03:43:55 +0000 Subject: [PATCH 08/11] 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 018ec4411..ff117922e 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 9bad38da3292b92c1385fd16c34a0a739265d18d Mon Sep 17 00:00:00 2001 From: Harrison Weinstock Date: Fri, 22 May 2026 12:41:42 +0000 Subject: [PATCH 09/11] 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 fe79ce236..4ab589db9 100644 --- a/src/cli/tui/App.tsx +++ b/src/cli/tui/App.tsx @@ -66,7 +66,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({ From 6600562e8df4f9c7aff887a6e1cd48f5167e6bcf Mon Sep 17 00:00:00 2001 From: hkobew Date: Tue, 26 May 2026 14:16:16 -0400 Subject: [PATCH 10/11] fix: maintain remove all entrypoint --- src/cli/commands/remove/command.tsx | 2 +- src/cli/tui/App.tsx | 3 ++- src/cli/tui/screens/remove/RemoveFlow.tsx | 5 ++++- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/cli/commands/remove/command.tsx b/src/cli/commands/remove/command.tsx index ff117922e..38e912d79 100644 --- a/src/cli/commands/remove/command.tsx +++ b/src/cli/commands/remove/command.tsx @@ -85,7 +85,7 @@ export const registerRemove = (program: Command): Command => { } else { requireTTY(); await renderTUI({ - initialRoute: { name: 'remove' }, + initialRoute: { name: 'remove', screen: 'all' }, enterAltScreen: false, actionOnBack: 'exit', isInteractive: false, diff --git a/src/cli/tui/App.tsx b/src/cli/tui/App.tsx index 4ab589db9..7adad33a5 100644 --- a/src/cli/tui/App.tsx +++ b/src/cli/tui/App.tsx @@ -40,7 +40,7 @@ type Route = | { name: 'create' } | { name: 'add' } | { name: 'status' } - | { name: 'remove' } + | { name: 'remove'; screen?: 'all' } | { name: 'run' } | { name: 'run-eval'; from?: 'run' | 'evals' } | { name: 'run-batch-eval'; from?: 'run' | 'evals' } @@ -250,6 +250,7 @@ function AppContent({ isInteractive={isInteractive} onExit={handleBack} onNavigate={command => setRoute({ name: command } as Route)} + initialResourceType={route.screen} /> ); } diff --git a/src/cli/tui/screens/remove/RemoveFlow.tsx b/src/cli/tui/screens/remove/RemoveFlow.tsx index a20a7eeac..10340c626 100644 --- a/src/cli/tui/screens/remove/RemoveFlow.tsx +++ b/src/cli/tui/screens/remove/RemoveFlow.tsx @@ -118,7 +118,8 @@ interface RemoveFlowProps { | 'policy' | 'config-bundle' | 'ab-test' - | 'dataset'; + | 'dataset' + | 'all'; /** Initial resource name to auto-select (for CLI --name flag) */ initialResourceName?: string; } @@ -160,6 +161,8 @@ export function RemoveFlow({ return { name: 'select-ab-test' }; case 'runtime-endpoint': return { name: 'select-runtime-endpoint' }; + case 'all': + return { name: 'remove-all' }; default: return { name: 'select' }; } From 4b78fc1b05d2859a4e0850c82b174ae13eaf6495 Mon Sep 17 00:00:00 2001 From: hkobew Date: Tue, 26 May 2026 15:55:42 -0400 Subject: [PATCH 11/11] fix(telemetry): avoid early client shutdown --- src/cli/tui/render.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/cli/tui/render.ts b/src/cli/tui/render.ts index 1bcb3ca61..1d8b6c59f 100644 --- a/src/cli/tui/render.ts +++ b/src/cli/tui/render.ts @@ -57,7 +57,11 @@ export async function renderTUI(options: RenderTUIOptions = {}) { process.stdout.write(SHOW_CURSOR); } - await TelemetryClientAccessor.shutdown(); + // Flush telemetry before blocking process + const telemetryClient = await TelemetryClientAccessor.get(); + if (telemetryClient) { + await telemetryClient.flush(); + } // Check if the TUI requested a post-exit action (e.g., launch browser dev mode) const action = getExitAction();