diff --git a/src/cli/commands/deploy/progress.ts b/src/cli/commands/deploy/progress.ts index a8dff1cd0..e222de52f 100644 --- a/src/cli/commands/deploy/progress.ts +++ b/src/cli/commands/deploy/progress.ts @@ -1,5 +1,6 @@ import { ConfigIO } from '../../../lib'; import { detectAwsContext } from '../../aws/aws-context'; +import { ANSI } from '../../constants'; import { getErrorMessage } from '../../errors'; import { canSkipDeploy } from '../../operations/deploy/change-detection'; import { handleDeploy } from './actions'; @@ -18,7 +19,7 @@ export function createSpinnerProgress(): SpinnerProgress { if (spinner) { clearInterval(spinner); spinner = undefined; - process.stdout.write('\r\x1b[K'); + process.stdout.write(`\r${ANSI.clearLine}`); } }; @@ -91,7 +92,7 @@ export async function runCliDeploy(): Promise { } console.log(''); } else { - console.warn(`\x1b[33mDeploy failed: ${result.error}. Starting dev server anyway...\x1b[0m`); + console.warn(`${ANSI.yellow}Deploy failed: ${result.error}. Starting dev server anyway...${ANSI.reset}`); if (result.logPath) { console.warn(`Deploy log: ${result.logPath}`); } @@ -99,6 +100,8 @@ export async function runCliDeploy(): Promise { } } catch (deployErr) { cleanup(); - console.warn(`\x1b[33mDeploy failed: ${getErrorMessage(deployErr)}. Starting dev server anyway...\x1b[0m\n`); + console.warn( + `${ANSI.yellow}Deploy failed: ${getErrorMessage(deployErr)}. Starting dev server anyway...${ANSI.reset}\n` + ); } } diff --git a/src/cli/commands/dev/browser-mode.ts b/src/cli/commands/dev/browser-mode.ts index 0a6b0885d..a0a45444f 100644 --- a/src/cli/commands/dev/browser-mode.ts +++ b/src/cli/commands/dev/browser-mode.ts @@ -1,5 +1,6 @@ import { ConfigIO, findConfigRoot, getWorkingDirectory } from '../../../lib'; import type { AgentCoreProjectSpec } from '../../../schema'; +import { ANSI } from '../../constants'; import { isPreviewEnabled } from '../../feature-flags'; import { getDevConfig, getDevSupportedAgents, loadDevEnv, loadProjectConfig } from '../../operations/dev'; import { type OtelCollector, startOtelCollector } from '../../operations/dev/otel'; @@ -312,9 +313,7 @@ export async function runBrowserMode(opts: BrowserModeOptions): Promise { }); } -const ENTER_ALT_SCREEN = '\x1B[?1049h\x1B[H'; -const EXIT_ALT_SCREEN = '\x1B[?1049l'; -const SHOW_CURSOR = '\x1B[?25h'; +const { enterAltScreen: ENTER_ALT_SCREEN, exitAltScreen: EXIT_ALT_SCREEN, showCursor: SHOW_CURSOR } = ANSI; interface TuiPickerResult { agentName?: string; diff --git a/src/cli/commands/import/actions.ts b/src/cli/commands/import/actions.ts index 990fe006f..b8469ce8e 100644 --- a/src/cli/commands/import/actions.ts +++ b/src/cli/commands/import/actions.ts @@ -9,6 +9,7 @@ import type { } from '../../../schema'; import { validateAwsCredentials } from '../../aws/account'; import { arnPrefix } from '../../aws/partition'; +import { ANSI } from '../../constants'; import { ExecLogger } from '../../logging'; import { setupPythonProject } from '../../operations/python/setup'; import { executeCdkImportPipeline } from './import-pipeline'; @@ -315,8 +316,8 @@ export async function handleImport(options: ImportOptions): Promise { const importCmd = program @@ -86,7 +86,7 @@ export const registerImport = (program: Command) => { yes: cliOptions.yes, onProgress: (message: string) => { // Collect warnings for end-of-output display - if (message.includes('Warning') || message.includes('\x1b[33m')) { + if (message.startsWith('Warning')) { warnings.push(message); return; } @@ -144,7 +144,7 @@ export const registerImport = (program: Command) => { console.log(`Log: ${result.logPath}`); } } else { - console.error(`\n\x1b[31m[error]${reset} Import failed: ${result.error.message}`); + console.error(`\n${red}[error]${reset} Import failed: ${result.error.message}`); if (result.logPath) { console.error(`Log: ${result.logPath}`); } diff --git a/src/cli/commands/import/constants.ts b/src/cli/commands/import/constants.ts index 25755dc26..35149fead 100644 --- a/src/cli/commands/import/constants.ts +++ b/src/cli/commands/import/constants.ts @@ -1,16 +1,6 @@ /** Name validation regex used by all import handlers. */ export const NAME_REGEX = /^[a-zA-Z][a-zA-Z0-9_]{0,47}$/; -/** ANSI escape codes for console output. */ -export const ANSI = { - red: '\x1b[31m', - green: '\x1b[32m', - yellow: '\x1b[33m', - cyan: '\x1b[36m', - dim: '\x1b[2m', - reset: '\x1b[0m', -} as const; - /** * CloudFormation resource type to identifier key mapping for IMPORT. */ diff --git a/src/cli/commands/import/import-evaluator.ts b/src/cli/commands/import/import-evaluator.ts index be3450767..99b543117 100644 --- a/src/cli/commands/import/import-evaluator.ts +++ b/src/cli/commands/import/import-evaluator.ts @@ -6,8 +6,8 @@ import { listAllEvaluators, listAllOnlineEvaluationConfigs, } from '../../aws/agentcore-control'; +import { ANSI } from '../../constants'; import { withCommandRunTelemetry } from '../../telemetry/cli-command-run.js'; -import { ANSI } from './constants'; import { failResult, parseAndValidateArn } from './import-utils'; import { executeResourceImport } from './resource-import'; import type { ImportResourceOptions, ImportResourceResult, ResourceImportDescriptor } from './types'; diff --git a/src/cli/commands/import/import-gateway.ts b/src/cli/commands/import/import-gateway.ts index 6f9ec53ab..bdec988e9 100644 --- a/src/cli/commands/import/import-gateway.ts +++ b/src/cli/commands/import/import-gateway.ts @@ -18,9 +18,9 @@ import { listAllGatewayTargets, listAllGateways, } from '../../aws/agentcore-control'; +import { ANSI } from '../../constants'; import { isAccessDeniedError } from '../../errors'; import { withCommandRunTelemetry } from '../../telemetry/cli-command-run.js'; -import { ANSI } from './constants'; import { executeCdkImportPipeline } from './import-pipeline'; import { failResult, diff --git a/src/cli/commands/import/import-memory.ts b/src/cli/commands/import/import-memory.ts index e9f7dc203..8983766de 100644 --- a/src/cli/commands/import/import-memory.ts +++ b/src/cli/commands/import/import-memory.ts @@ -2,8 +2,8 @@ import type { Memory } from '../../../schema'; import { IndexedKeyTypeSchema } from '../../../schema'; import type { MemoryDetail, MemorySummary } from '../../aws/agentcore-control'; import { getMemoryDetail, listAllMemories } from '../../aws/agentcore-control'; +import { ANSI } from '../../constants'; import { withCommandRunTelemetry } from '../../telemetry/cli-command-run.js'; -import { ANSI } from './constants'; import { parseAndValidateArn } from './import-utils'; import { executeResourceImport } from './resource-import'; import type { ImportResourceOptions, ImportResourceResult, ResourceImportDescriptor } from './types'; diff --git a/src/cli/commands/import/import-online-eval.ts b/src/cli/commands/import/import-online-eval.ts index d48a8bb52..fd4da75d6 100644 --- a/src/cli/commands/import/import-online-eval.ts +++ b/src/cli/commands/import/import-online-eval.ts @@ -6,8 +6,8 @@ import { listAllOnlineEvaluationConfigs, } from '../../aws/agentcore-control'; import { arnPrefix } from '../../aws/partition'; +import { ANSI } from '../../constants'; import { withCommandRunTelemetry } from '../../telemetry/cli-command-run.js'; -import { ANSI } from './constants'; import { failResult, findResourceInDeployedState, parseAndValidateArn } from './import-utils'; import { executeResourceImport } from './resource-import'; import type { ImportResourceOptions, ImportResourceResult, ResourceImportDescriptor } from './types'; diff --git a/src/cli/commands/import/import-runtime.ts b/src/cli/commands/import/import-runtime.ts index ce1c607a0..1228aa3b5 100644 --- a/src/cli/commands/import/import-runtime.ts +++ b/src/cli/commands/import/import-runtime.ts @@ -1,8 +1,8 @@ import type { AgentEnvSpec } from '../../../schema'; import type { AgentRuntimeDetail, AgentRuntimeSummary } from '../../aws/agentcore-control'; import { getAgentRuntimeDetail, listAllAgentRuntimes } from '../../aws/agentcore-control'; +import { ANSI } from '../../constants'; import { withCommandRunTelemetry } from '../../telemetry/cli-command-run.js'; -import { ANSI } from './constants'; import { copyAgentSource, failResult, parseAndValidateArn } from './import-utils'; import { executeResourceImport } from './resource-import'; import type { ImportResourceResult, ResourceImportDescriptor, RuntimeImportOptions } from './types'; diff --git a/src/cli/commands/import/import-utils.ts b/src/cli/commands/import/import-utils.ts index 1e84a294c..aac02d6e0 100644 --- a/src/cli/commands/import/import-utils.ts +++ b/src/cli/commands/import/import-utils.ts @@ -1,10 +1,10 @@ import { APP_DIR, ConfigIO, NoProjectError, ValidationError, findConfigRoot } from '../../../lib'; import type { AwsDeploymentTarget } from '../../../schema'; import { detectAccount, validateAwsCredentials } from '../../aws/account'; +import { ANSI } from '../../constants'; import { ExecLogger } from '../../logging'; import { setupPythonProject } from '../../operations/python/setup'; import { getTemplatePath } from '../../templates/templateRoot'; -import { ANSI } from './constants'; import type { ImportResourceOptions, ImportResourceResult, ImportableResourceType } from './types'; import * as fs from 'node:fs'; import * as path from 'node:path'; diff --git a/src/cli/commands/invoke/action.ts b/src/cli/commands/invoke/action.ts index ce3e4b68d..1eca4d9f3 100644 --- a/src/cli/commands/invoke/action.ts +++ b/src/cli/commands/invoke/action.ts @@ -12,6 +12,7 @@ import { mcpListTools, } from '../../aws'; import { invokeHarness } from '../../aws/agentcore-harness'; +import { ANSI } from '../../constants'; import { isPreviewEnabled } from '../../feature-flags'; import { InvokeLogger } from '../../logging'; import { formatMcpToolList } from '../../operations/dev/utils'; @@ -131,7 +132,7 @@ export async function handleInvoke(context: InvokeContext, options: InvokeOption // Warn about VPC mode endpoint requirements if (agentSpec.networkMode === 'VPC') { console.log( - '\x1b[33mWarning: This agent uses VPC network mode. Ensure your VPC endpoints are configured for invocation.\x1b[0m' + `${ANSI.yellow}Warning: This agent uses VPC network mode. Ensure your VPC endpoints are configured for invocation.${ANSI.reset}` ); } diff --git a/src/cli/constants.ts b/src/cli/constants.ts index 85d053817..488560aa5 100644 --- a/src/cli/constants.ts +++ b/src/cli/constants.ts @@ -68,3 +68,33 @@ export const SCHEMA_VERSION = 1; * Default runtime endpoint name used in log group paths and console URLs. */ export const DEFAULT_ENDPOINT_NAME = 'DEFAULT'; + +/** + * Color gating: emit ANSI codes only when both streams are attached to a terminal. + * Uses AND so that redirecting either stream (e.g. `2> log.txt`) disables colors, + * preventing escape codes from leaking into files. + * - FORCE_COLOR: override to enable colors regardless of TTY (e.g. in CI/tests). + * - NO_COLOR: standard (no-color.org) override to strip all color output. + */ +const stylingEnabled = + !!process.env.FORCE_COLOR || (!process.env.NO_COLOR && !!process.stdout.isTTY && !!process.stderr.isTTY); +const style = (code: string) => (stylingEnabled ? code : ''); + +/** ANSI escape codes for console output. Auto-disabled when piped or NO_COLOR is set. */ +export const ANSI = { + red: style('\x1b[31m'), + green: style('\x1b[32m'), + yellow: style('\x1b[33m'), + cyan: style('\x1b[36m'), + dim: style('\x1b[2m'), + reset: style('\x1b[0m'), + // Terminal control sequences (always active — only used when TTY is confirmed) + clearLine: '\x1b[K', + enterAltScreen: '\x1b[?1049h\x1b[H', + exitAltScreen: '\x1b[?1049l', + showCursor: '\x1b[?25h', + // TUI gradient colors + brightYellow: style('\x1b[38;2;255;255;0m'), + mutedYellow: style('\x1b[38;2;255;255;85m'), + darkYellow: style('\x1b[38;2;218;218;0m'), +} as const; diff --git a/src/cli/notices.ts b/src/cli/notices.ts index 2a525b94b..80fa782b2 100644 --- a/src/cli/notices.ts +++ b/src/cli/notices.ts @@ -1,8 +1,8 @@ +import { ANSI } from './constants'; import { type UpdateCheckResult, printUpdateNotification } from './update-notifier'; export function printTelemetryNotice(): void { - const yellow = '\x1b[33m'; - const reset = '\x1b[0m'; + const { yellow, reset } = ANSI; process.stderr.write( [ '', diff --git a/src/cli/tui/render.ts b/src/cli/tui/render.ts index 1d8b6c59f..a199ed28c 100644 --- a/src/cli/tui/render.ts +++ b/src/cli/tui/render.ts @@ -1,3 +1,4 @@ +import { ANSI } from '../constants'; import { printPostCommandNotices } from '../notices'; import { TelemetryClientAccessor } from '../telemetry'; import { type UpdateCheckResult } from '../update-notifier'; @@ -7,9 +8,7 @@ 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'; +const { enterAltScreen: ENTER_ALT_SCREEN, exitAltScreen: EXIT_ALT_SCREEN, showCursor: SHOW_CURSOR } = ANSI; let inAltScreen = false; diff --git a/src/cli/tui/screens/invoke/useInvokeFlow.ts b/src/cli/tui/screens/invoke/useInvokeFlow.ts index 4a467946b..a770cc7ae 100644 --- a/src/cli/tui/screens/invoke/useInvokeFlow.ts +++ b/src/cli/tui/screens/invoke/useInvokeFlow.ts @@ -22,6 +22,7 @@ import { mcpListTools, } from '../../../aws'; import { invokeHarness } from '../../../aws/agentcore-harness'; +import { ANSI } from '../../../constants'; import { getErrorMessage } from '../../../errors'; import { isPreviewEnabled } from '../../../feature-flags'; import { InvokeLogger } from '../../../logging'; @@ -379,7 +380,7 @@ export function useInvokeFlow(options: InvokeFlowOptions = {}): InvokeFlowState const serverName = event.start.toolUse.serverName; const label = serverName ? `${serverName}/${pendingToolName}` : pendingToolName; logger?.logInfo(`Tool call: ${pendingToolName} (id: ${pendingToolUseId})`); - streamingContentRef.current += `\n\x1b[2m${label}`; + streamingContentRef.current += `\n${ANSI.dim}${label}`; const currentContent = streamingContentRef.current; setMessages(prev => { const updated = [...prev]; @@ -391,7 +392,7 @@ export function useInvokeFlow(options: InvokeFlowOptions = {}): InvokeFlowState }); } else if (event.start.type === 'toolResult') { const status = event.start.toolResult.status; - const icon = status === 'error' ? ' \x1b[31m[error]\x1b[0m' : ' [ok]\x1b[0m'; + const icon = status === 'error' ? ` ${ANSI.red}[error]${ANSI.reset}` : ` [ok]${ANSI.reset}`; logger?.logInfo(`Tool result (${pendingToolName}): status=${status ?? 'success'}`); streamingContentRef.current += `${icon}\n`; const currentContent = streamingContentRef.current; @@ -438,7 +439,7 @@ export function useInvokeFlow(options: InvokeFlowOptions = {}): InvokeFlowState if (lastMetadata) { const latency = (lastMetadata.latencyMs / 1000).toFixed(1); - streamingContentRef.current += `\n\x1b[2m${lastMetadata.inputTokens} in / ${lastMetadata.outputTokens} out / ${latency}s\x1b[0m`; + streamingContentRef.current += `\n${ANSI.dim}${lastMetadata.inputTokens} in / ${lastMetadata.outputTokens} out / ${latency}s${ANSI.reset}`; const currentContent = streamingContentRef.current; setMessages(prev => { const updated = [...prev]; diff --git a/src/cli/tui/utils/__tests__/gradient.test.ts b/src/cli/tui/utils/__tests__/gradient.test.ts index bfe1cd5fa..cd83ecf61 100644 --- a/src/cli/tui/utils/__tests__/gradient.test.ts +++ b/src/cli/tui/utils/__tests__/gradient.test.ts @@ -1,38 +1,44 @@ -import { createGradient } from '../gradient.js'; -import { describe, expect, it } from 'vitest'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; describe('createGradient', () => { - it('returns a string containing the original characters', () => { - const result = createGradient('Hello'); - // Strip ANSI escape codes to verify original text - // eslint-disable-next-line no-control-regex - const stripped = result.replace(/\x1b\[[0-9;]*m/g, ''); - expect(stripped).toBe('Hello'); + let originalForceColor: string | undefined; + + beforeAll(() => { + originalForceColor = process.env.FORCE_COLOR; + process.env.FORCE_COLOR = '1'; + }); + + afterAll(() => { + if (originalForceColor === undefined) { + delete process.env.FORCE_COLOR; + } else { + process.env.FORCE_COLOR = originalForceColor; + } }); - it('wraps each character with ANSI color codes', () => { + it('wraps each character with ANSI color codes', async () => { + const { createGradient } = await import('../gradient.js'); const result = createGradient('AB'); - // Should contain escape sequences expect(result).toContain('\x1b['); - // Should contain the reset code expect(result).toContain('\x1b[0m'); }); - it('handles empty string', () => { + it('handles empty string', async () => { + const { createGradient } = await import('../gradient.js'); expect(createGradient('')).toBe(''); }); - it('handles single character', () => { + it('handles single character', async () => { + const { createGradient } = await import('../gradient.js'); const result = createGradient('X'); // eslint-disable-next-line no-control-regex const stripped = result.replace(/\x1b\[[0-9;]*m/g, ''); expect(stripped).toBe('X'); }); - it('produces different colors for characters at different positions', () => { - // With a long enough string, we should see multiple different color codes + it('produces different colors for characters at different positions', async () => { + const { createGradient } = await import('../gradient.js'); const result = createGradient('ABCDEFGHIJKLMNOP'); - // Count distinct escape sequences (excluding reset) // eslint-disable-next-line no-control-regex const codes = result.match(/\x1b\[(?!0m)[0-9;]*m/g) ?? []; const unique = new Set(codes); diff --git a/src/cli/tui/utils/gradient.ts b/src/cli/tui/utils/gradient.ts index f3faac4be..36839f692 100644 --- a/src/cli/tui/utils/gradient.ts +++ b/src/cli/tui/utils/gradient.ts @@ -1,20 +1,21 @@ +import { ANSI } from '../../constants'; + export function createGradient(text: string): string { const colors = [ - '\x1b[33m', // Standard ANSI Yellow (matches Ink's yellow) - '\x1b[38;2;255;255;0m', // Bright Yellow - '\x1b[38;2;255;255;85m', // Slightly muted - '\x1b[38;2;218;218;0m', // Darker yellow - '\x1b[33m', // Back to ANSI Yellow + ANSI.yellow, // Standard ANSI Yellow (matches Ink's yellow) + ANSI.brightYellow, + ANSI.mutedYellow, + ANSI.darkYellow, + ANSI.yellow, // Back to ANSI Yellow ]; - const reset = '\x1b[0m'; const chars = text.split(''); return chars .map((char, i) => { // Distributes the yellow hues across the length of the string const colorIndex = Math.floor((i / chars.length) * (colors.length - 1)); - return colors[colorIndex] + char + reset; + return colors[colorIndex] + char + ANSI.reset; }) .join(''); } diff --git a/src/cli/update-notifier.ts b/src/cli/update-notifier.ts index 4af5c7fb9..447860c72 100644 --- a/src/cli/update-notifier.ts +++ b/src/cli/update-notifier.ts @@ -1,6 +1,6 @@ import { ONE_DAY_MS } from '../lib/time-constants.js'; import { compareVersions, fetchLatestVersion } from './commands/update/action.js'; -import { PACKAGE_VERSION, getDistroConfig } from './constants.js'; +import { ANSI, PACKAGE_VERSION, getDistroConfig } from './constants.js'; import { mkdir, readFile, writeFile } from 'fs/promises'; import { homedir } from 'os'; import { join } from 'path'; @@ -67,9 +67,7 @@ export async function checkForUpdate(): Promise { } export function printUpdateNotification(result: UpdateCheckResult): void { - const yellow = '\x1b[33m'; - const cyan = '\x1b[36m'; - const reset = '\x1b[0m'; + const { yellow, cyan, reset } = ANSI; const { installCommand } = getDistroConfig(); process.stderr.write(