Skip to content

Commit bce4cf9

Browse files
committed
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.
1 parent ce9a039 commit bce4cf9

10 files changed

Lines changed: 146 additions & 135 deletions

File tree

src/cli/cli.ts

Lines changed: 4 additions & 128 deletions
Original file line numberDiff line numberDiff line change
@@ -26,145 +26,21 @@ import { registerTraces } from './commands/traces';
2626
import { registerUpdate } from './commands/update';
2727
import { registerValidate } from './commands/validate';
2828
import { PACKAGE_VERSION } from './constants';
29+
import { printPostCommandNotices, printTelemetryNotice } from './notices';
2930
import { ALL_PRIMITIVES } from './primitives';
3031
import { TelemetryClientAccessor } from './telemetry';
31-
import { App, type InitialRoute } from './tui/App';
32+
import { renderTUI, setupAltScreenCleanup } from './tui';
3233
import { LayoutProvider } from './tui/context';
3334
import { COMMAND_DESCRIPTIONS } from './tui/copy';
34-
import { clearExitAction, getExitAction } from './tui/exit-action';
3535
import { clearExitMessage, getExitMessage } from './tui/exit-message';
3636
import { requireTTY } from './tui/guards';
3737
import { CommandListScreen } from './tui/screens/home';
3838
import { getCommandsForUI } from './tui/utils';
39-
import { type UpdateCheckResult, checkForUpdate, printUpdateNotification } from './update-notifier';
39+
import { checkForUpdate } from './update-notifier';
4040
import { Command } from '@commander-js/extra-typings';
4141
import { render } from 'ink';
4242
import React from 'react';
4343

44-
// ANSI escape sequences
45-
const ENTER_ALT_SCREEN = '\x1B[?1049h\x1B[H';
46-
const EXIT_ALT_SCREEN = '\x1B[?1049l';
47-
const SHOW_CURSOR = '\x1B[?25h';
48-
49-
// Track if we're in alternate screen mode
50-
let inAltScreen = false;
51-
52-
/**
53-
* Global terminal cleanup - ensures cursor is always restored on exit.
54-
* Registered once at startup, catches all exit scenarios.
55-
*/
56-
function setupGlobalCleanup() {
57-
const cleanup = () => {
58-
if (inAltScreen) {
59-
process.stdout.write(EXIT_ALT_SCREEN);
60-
}
61-
process.stdout.write(SHOW_CURSOR);
62-
};
63-
64-
process.on('exit', cleanup);
65-
process.on('SIGINT', () => {
66-
cleanup();
67-
process.exit(0);
68-
});
69-
process.on('SIGTERM', () => {
70-
cleanup();
71-
process.exit(0);
72-
});
73-
}
74-
75-
function printTelemetryNotice(): void {
76-
const yellow = '\x1b[33m';
77-
const reset = '\x1b[0m';
78-
process.stderr.write(
79-
[
80-
'',
81-
`${yellow}The AgentCore CLI will soon begin collecting aggregated, anonymous usage`,
82-
'analytics to help improve the tool.',
83-
'To opt out: agentcore telemetry disable',
84-
`To learn more: agentcore telemetry --help${reset}`,
85-
'',
86-
'',
87-
].join('\n')
88-
);
89-
}
90-
91-
function printPostCommandNotices(isFirstRun: boolean, updateCheck: Promise<UpdateCheckResult | null>): Promise<void> {
92-
if (isFirstRun) {
93-
printTelemetryNotice();
94-
}
95-
return updateCheck.then(result => {
96-
if (result?.updateAvailable) {
97-
printUpdateNotification(result);
98-
}
99-
});
100-
}
101-
102-
export interface RenderTUIOptions {
103-
/** Route to navigate to on launch. If omitted, shows the default home/help screen. */
104-
initialRoute?: InitialRoute;
105-
/** Promise that resolves with update check result. Used to print update notifications on exit. Default: Promise.resolve(null) */
106-
updateCheck?: Promise<UpdateCheckResult | null>;
107-
/** Whether this is the first time the CLI has been run. Shows telemetry notice on exit. Default: false */
108-
isFirstRun?: boolean;
109-
/** Control whether TUI is rendered inline or in alternate screen. Default: true */
110-
enterAltScreen?: boolean;
111-
/** Behavior when pressing escape/back. 'help' navigates to the help screen, 'exit' exits the app. Default: 'help' */
112-
actionOnBack?: 'help' | 'exit';
113-
/** Whether the TUI is running in full interactive mode. When false, screens auto-exit after success. Default: true */
114-
isInteractive?: boolean;
115-
}
116-
117-
/**
118-
* Render the TUI in alternate screen buffer mode.
119-
* This is the entrypoint for TUI operations
120-
*/
121-
export async function renderTUI(options: RenderTUIOptions = {}) {
122-
const {
123-
initialRoute,
124-
updateCheck = Promise.resolve(null),
125-
isFirstRun = false,
126-
enterAltScreen = true,
127-
actionOnBack = 'help',
128-
isInteractive = true,
129-
} = options;
130-
await TelemetryClientAccessor.init(initialRoute?.name ?? 'tui', 'tui');
131-
if (enterAltScreen) {
132-
inAltScreen = true;
133-
process.stdout.write(ENTER_ALT_SCREEN);
134-
}
135-
136-
const { waitUntilExit } = render(React.createElement(App, { initialRoute, actionOnBack, isInteractive }));
137-
138-
await waitUntilExit();
139-
140-
if (inAltScreen) {
141-
inAltScreen = false;
142-
process.stdout.write(EXIT_ALT_SCREEN);
143-
process.stdout.write(SHOW_CURSOR);
144-
}
145-
146-
await TelemetryClientAccessor.shutdown();
147-
148-
// Check if the TUI requested a post-exit action (e.g., launch browser dev mode)
149-
const action = getExitAction();
150-
clearExitAction();
151-
152-
if (action?.type === 'dev') {
153-
const { launchBrowserDev } = await import('./commands/dev/browser-mode');
154-
await launchBrowserDev();
155-
return;
156-
}
157-
158-
// Print any exit message set by screens (e.g., after successful project creation)
159-
const exitMessage = getExitMessage();
160-
if (exitMessage) {
161-
console.log(exitMessage);
162-
clearExitMessage();
163-
}
164-
165-
await printPostCommandNotices(isFirstRun, updateCheck);
166-
}
167-
16844
function renderHelp(program: Command): void {
16945
const commands = getCommandsForUI(program);
17046
render(React.createElement(LayoutProvider, null, React.createElement(CommandListScreen, { commands })));
@@ -245,7 +121,7 @@ export function registerCommands(program: Command) {
245121

246122
export const main = async (argv: string[]) => {
247123
// Register global cleanup handlers once at startup
248-
setupGlobalCleanup();
124+
setupAltScreenCleanup();
249125

250126
// Generate installationId on first run and show telemetry notice
251127
const { created: isFirstRun } = await getOrCreateInstallationId();

src/cli/commands/add/command.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
import { renderTUI } from '../../cli';
21
import { COMMAND_DESCRIPTIONS } from '../../tui/copy';
32
import { requireProject, requireTTY } from '../../tui/guards';
3+
import { renderTUI } from '../../tui';
44
import type { Command } from '@commander-js/extra-typings';
55

66
export function registerAdd(program: Command): Command {

src/cli/commands/create/command.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ import type {
88
TargetLanguage,
99
} from '../../../schema';
1010
import { LIFECYCLE_TIMEOUT_MAX, LIFECYCLE_TIMEOUT_MIN } from '../../../schema';
11-
import { renderTUI } from '../../cli';
1211
import { getErrorMessage } from '../../errors';
1312
import { runCliCommand } from '../../telemetry/cli-command-run.js';
1413
import {
@@ -24,6 +23,7 @@ import {
2423
} from '../../telemetry/schemas/common-shapes.js';
2524
import { COMMAND_DESCRIPTIONS } from '../../tui/copy';
2625
import { requireTTY } from '../../tui/guards';
26+
import { renderTUI } from '../../tui';
2727
import { parseCommaSeparatedList } from '../shared/vpc-utils';
2828
import { type ProgressCallback, createProject, createProjectWithAgent, getDryRunInfo } from './action';
2929
import type { CreateOptions } from './types';

src/cli/commands/deploy/command.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
import { ConfigIO, serializeResult } from '../../../lib';
2-
import { renderTUI } from '../../cli';
32
import { getErrorMessage } from '../../errors';
43
import { withCommandRunTelemetry } from '../../telemetry/cli-command-run.js';
54
import { COMMAND_DESCRIPTIONS } from '../../tui/copy';
65
import { requireProject, requireTTY } from '../../tui/guards';
6+
import { renderTUI } from '../../tui';
77
import { handleDeploy } from './actions';
88
import type { DeployOptions, DeployResult } from './types';
99
import { DEFAULT_DEPLOY_ATTRS, computeDeployAttrs } from './utils';

src/cli/commands/invoke/command.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
import { ValidationError, serializeResult } from '../../../lib';
2-
import { renderTUI } from '../../cli';
32
import { getErrorMessage } from '../../errors';
43
import { withCommandRunTelemetry } from '../../telemetry/cli-command-run.js';
54
import { AgentProtocol, AuthType, standardize } from '../../telemetry/schemas/common-shapes.js';
65
import { COMMAND_DESCRIPTIONS } from '../../tui/copy';
76
import { requireProject, requireTTY } from '../../tui/guards';
7+
import { renderTUI } from '../../tui';
88
import { parseHeaderFlags } from '../shared/header-utils';
99
import { type InvokeContext, handleInvoke, loadInvokeConfig } from './action';
1010
import { resolvePrompt } from './resolve-prompt';

src/cli/commands/remove/command.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
import { ConfigIO, serializeResult, toError } from '../../../lib';
2-
import { renderTUI } from '../../cli';
32
import { getErrorMessage } from '../../errors';
43
import { runCliCommand } from '../../telemetry/cli-command-run.js';
54
import { COMMAND_DESCRIPTIONS } from '../../tui/copy';
65
import { requireProject, requireTTY } from '../../tui/guards';
6+
import { renderTUI } from '../../tui';
77
import type { RemoveAllOptions, RemoveResult } from './types';
88
import { validateRemoveAllOptions } from './validate';
99
import type { Command } from '@commander-js/extra-typings';

src/cli/notices.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { type UpdateCheckResult, printUpdateNotification } from './update-notifier';
2+
3+
export function printTelemetryNotice(): void {
4+
const yellow = '\x1b[33m';
5+
const reset = '\x1b[0m';
6+
process.stderr.write(
7+
[
8+
'',
9+
`${yellow}The AgentCore CLI will soon begin collecting aggregated, anonymous usage`,
10+
'analytics to help improve the tool.',
11+
'To opt out: agentcore telemetry disable',
12+
`To learn more: agentcore telemetry --help${reset}`,
13+
'',
14+
'',
15+
].join('\n')
16+
);
17+
}
18+
19+
export function printPostCommandNotices(
20+
isFirstRun: boolean,
21+
updateCheck: Promise<UpdateCheckResult | null>
22+
): Promise<void> {
23+
if (isFirstRun) {
24+
printTelemetryNotice();
25+
}
26+
return updateCheck.then(result => {
27+
if (result?.updateAvailable) {
28+
printUpdateNotification(result);
29+
}
30+
});
31+
}

src/cli/tui/App.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,7 @@ import { getCommandsForUI } from './utils/commands';
2828
import { useApp } from 'ink';
2929
import React, { useState } from 'react';
3030

31-
// Capture cwd once at app initialization
32-
const cwd = getWorkingDirectory();
31+
// cwd is captured inside AppContent to avoid calling getWorkingDirectory at import time
3332

3433
type Route =
3534
| { name: 'home' }
@@ -78,6 +77,7 @@ function AppContent({
7877
isInteractive?: boolean;
7978
}) {
8079
const { exit } = useApp();
80+
const cwd = getWorkingDirectory();
8181
// Start on help screen if project exists (show commands), otherwise home (show Quick Start)
8282
const inProject = projectExists();
8383
const wrongDirProjectRoot = getProjectRootMismatch();

src/cli/tui/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
export { App } from './App';
22
export * from './components';
33
export * from './hooks';
4+
export * from './render';
45
export * from './screens';
56
export * from './utils';

src/cli/tui/render.ts

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import { printPostCommandNotices } from '../notices';
2+
import { TelemetryClientAccessor } from '../telemetry';
3+
import { type UpdateCheckResult } from '../update-notifier';
4+
import { App, type InitialRoute } from './App';
5+
import { clearExitAction, getExitAction } from './exit-action';
6+
import { clearExitMessage, getExitMessage } from './exit-message';
7+
import { render } from 'ink';
8+
import React from 'react';
9+
10+
const ENTER_ALT_SCREEN = '\x1B[?1049h\x1B[H';
11+
const EXIT_ALT_SCREEN = '\x1B[?1049l';
12+
const SHOW_CURSOR = '\x1B[?25h';
13+
14+
let inAltScreen = false;
15+
16+
export interface RenderTUIOptions {
17+
/** Route to navigate to on launch. If omitted, shows the default home/help screen. */
18+
initialRoute?: InitialRoute;
19+
/** Promise that resolves with update check result. Used to print update notifications on exit. Default: Promise.resolve(null) */
20+
updateCheck?: Promise<UpdateCheckResult | null>;
21+
/** Whether this is the first time the CLI has been run. Shows telemetry notice on exit. Default: false */
22+
isFirstRun?: boolean;
23+
/** Control whether TUI is rendered inline or in alternate screen. Default: true */
24+
enterAltScreen?: boolean;
25+
/** Behavior when pressing escape/back. 'help' navigates to the help screen, 'exit' exits the app. Default: 'help' */
26+
actionOnBack?: 'help' | 'exit';
27+
/** Whether the TUI is running in full interactive mode. When false, screens auto-exit after success. Default: true */
28+
isInteractive?: boolean;
29+
}
30+
31+
/**
32+
* Render the TUI in alternate screen buffer mode.
33+
* This is the entrypoint for all TUI operations.
34+
*/
35+
export async function renderTUI(options: RenderTUIOptions = {}) {
36+
const {
37+
initialRoute,
38+
updateCheck = Promise.resolve(null),
39+
isFirstRun = false,
40+
enterAltScreen: useAltScreen = true,
41+
actionOnBack = 'help',
42+
isInteractive = true,
43+
} = options;
44+
await TelemetryClientAccessor.init(initialRoute?.name ?? 'tui', 'tui');
45+
if (useAltScreen) {
46+
inAltScreen = true;
47+
process.stdout.write(ENTER_ALT_SCREEN);
48+
}
49+
50+
const { waitUntilExit } = render(React.createElement(App, { initialRoute, actionOnBack, isInteractive }));
51+
52+
await waitUntilExit();
53+
54+
if (inAltScreen) {
55+
inAltScreen = false;
56+
process.stdout.write(EXIT_ALT_SCREEN);
57+
process.stdout.write(SHOW_CURSOR);
58+
}
59+
60+
await TelemetryClientAccessor.shutdown();
61+
62+
// Check if the TUI requested a post-exit action (e.g., launch browser dev mode)
63+
const action = getExitAction();
64+
clearExitAction();
65+
66+
if (action?.type === 'dev') {
67+
const { launchBrowserDev } = await import('../commands/dev/browser-mode');
68+
await launchBrowserDev();
69+
return;
70+
}
71+
72+
// Print any exit message set by screens (e.g., after successful project creation)
73+
const exitMessage = getExitMessage();
74+
if (exitMessage) {
75+
console.log(exitMessage);
76+
clearExitMessage();
77+
}
78+
79+
await printPostCommandNotices(isFirstRun, updateCheck);
80+
}
81+
82+
/**
83+
* Cleanup handler for alternate screen on process signals.
84+
* Call once at startup.
85+
*/
86+
export function setupAltScreenCleanup() {
87+
const cleanup = () => {
88+
if (inAltScreen) {
89+
process.stdout.write(EXIT_ALT_SCREEN);
90+
}
91+
process.stdout.write(SHOW_CURSOR);
92+
};
93+
94+
process.on('exit', cleanup);
95+
process.on('SIGINT', () => {
96+
cleanup();
97+
process.exit(0);
98+
});
99+
process.on('SIGTERM', () => {
100+
cleanup();
101+
process.exit(0);
102+
});
103+
}

0 commit comments

Comments
 (0)