Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 40 additions & 10 deletions src/cli/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -99,19 +99,47 @@ function printPostCommandNotices(isFirstRun: boolean, updateCheck: Promise<Updat
});
}

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<UpdateCheckResult | null>;
/** 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<UpdateCheckResult | null>, 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();
Expand All @@ -132,6 +160,8 @@ function renderTUI(updateCheck: Promise<UpdateCheckResult | null>, isFirstRun: b

await printPostCommandNotices(isFirstRun, updateCheck);
});

return done;
}

function renderHelp(program: Command): void {
Expand Down Expand Up @@ -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;
}

Expand Down
16 changes: 3 additions & 13 deletions src/cli/commands/add/command.tsx
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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();
Expand All @@ -23,15 +21,7 @@ export function registerAdd(program: Command): Command {
requireProject();
requireTTY();

const { clear, unmount } = render(
<AddFlow
isInteractive={false}
onExit={() => {
clear();
unmount();
}}
/>
);
await renderTUI({ initialRoute: { name: 'add' }, enterAltScreen: false, actionOnBack: 'exit' });
});

// Subcommands (agent, memory, credential, gateway, gateway-target) are registered
Expand Down
18 changes: 4 additions & 14 deletions src/cli/commands/create/command.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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';
Expand All @@ -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(
<CreateScreen
cwd={cwd}
isInteractive={false}
onExit={() => {
unmount();
process.exit(0);
}}
/>
);
function handleCreateTUI(): Promise<void> {
return renderTUI({ initialRoute: { name: 'create' }, enterAltScreen: false, actionOnBack: 'exit' });
}

/** Print completion summary after successful create */
Expand Down Expand Up @@ -293,7 +283,7 @@ export const registerCreate = (program: Command) => {
await handleCreateCLI(options as CreateOptions);
} else {
requireTTY();
handleCreateTUI();
await handleCreateTUI();
}
} catch (error) {
render(<Text color="red">Error: {getErrorMessage(error)}</Text>);
Expand Down
21 changes: 5 additions & 16 deletions src/cli/commands/deploy/command.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -14,20 +14,9 @@

const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];

function handleDeployTUI(options: { autoConfirm?: boolean; diffMode?: boolean } = {}): void {
function handleDeployTUI(options: { autoConfirm?: boolean; diffMode?: boolean } = {}): Promise<void> {

Check failure on line 17 in src/cli/commands/deploy/command.tsx

View workflow job for this annotation

GitHub Actions / lint

'options' is assigned a value but never used. Allowed unused args must match /^_/u

Check failure on line 17 in src/cli/commands/deploy/command.tsx

View workflow job for this annotation

GitHub Actions / lint

'options' is assigned a value but never used. Allowed unused args must match /^_/u
requireProject();

const { unmount } = render(
<DeployScreen
isInteractive={false}
autoConfirm={options.autoConfirm}
diffMode={options.diffMode}
onExit={() => {
unmount();
process.exit(0);
}}
/>
);
return renderTUI({ initialRoute: { name: 'deploy' }, enterAltScreen: false, actionOnBack: 'exit' });
}

async function handleDeployCLI(options: DeployOptions): Promise<void> {
Expand Down Expand Up @@ -208,10 +197,10 @@
} 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) {
Expand Down
41 changes: 12 additions & 29 deletions src/cli/commands/invoke/command.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,17 @@
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';
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 = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];

Expand Down Expand Up @@ -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<Result> => {
const { waitUntilExit, unmount } = render(
<InvokeScreen
isInteractive={true}
onExit={() => unmount()}
initialSessionId={cliOptions.sessionId}
initialUserId={cliOptions.userId}
initialHeaders={headers}
initialBearerToken={cliOptions.bearerToken}
/>
);
await waitUntilExit();
return { success: true };
}
);
if (!tuiResult.success) {
render(<Text color="red">Error: {getErrorMessage(tuiResult.error)}</Text>);
process.exit(1);
}
enterAltScreen: false,
actionOnBack: 'exit',
});
}
} catch (error) {
if (cliOptions.json) {
Expand Down
25 changes: 4 additions & 21 deletions src/cli/commands/remove/command.tsx
Original file line number Diff line number Diff line change
@@ -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<RemoveResult> {
try {
Expand Down Expand Up @@ -84,15 +83,7 @@ export const registerRemove = (program: Command): Command => {
});
} else {
requireTTY();
const { unmount } = render(
<RemoveAllScreen
isInteractive={false}
onExit={() => {
unmount();
process.exit(0);
}}
/>
);
await renderTUI({ initialRoute: { name: 'remove' }, enterAltScreen: false, actionOnBack: 'exit' });
}
} catch (error) {
if (cliOptions.json) {
Expand All @@ -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();
Expand All @@ -122,15 +113,7 @@ export const registerRemove = (program: Command): Command => {
requireProject();
requireTTY();

const { clear, unmount } = render(
<RemoveFlow
isInteractive={false}
onExit={() => {
clear();
unmount();
}}
/>
);
await renderTUI({ initialRoute: { name: 'remove' }, enterAltScreen: false, actionOnBack: 'exit' });
})
.showHelpAfterError()
.showSuggestionAfterError();
Expand Down
Loading
Loading