diff --git a/docs/configuration.md b/docs/configuration.md index d32d41c7d..47fa95723 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -14,6 +14,42 @@ AgentCore projects use JSON configuration files in the `agentcore/` directory. --- +## Environment Variables + +### CLI Theme Configuration + +The CLI supports both light and dark mode themes. By default, the CLI attempts to detect your system's color scheme +preference. + +| Variable | Values | Description | +| ----------------- | ------------------------- | ------------------------------------------ | +| `AGENTCORE_THEME` | `light`, `dark`, `system` | Set the CLI color theme (default: `system`) | + +**Examples:** + +```bash +# Force dark mode +export AGENTCORE_THEME=dark + +# Force light mode +export AGENTCORE_THEME=light + +# Auto-detect from system (default) +export AGENTCORE_THEME=system +``` + +**System Detection:** + +When `AGENTCORE_THEME` is set to `system` (or not set), the CLI attempts to detect your terminal's color scheme using: + +- `COLORFGBG` environment variable (common in many terminals) +- `APPLE_INTERFACE_STYLE` on macOS +- `TERMINAL_LIGHT_MODE` custom indicator + +If no indicators are found, the CLI defaults to dark mode (most common for terminal users). + +--- + ## agentcore.json Main project configuration using a **flat resource model**. Agents, memories, and credentials are top-level arrays. diff --git a/src/cli/tui/App.tsx b/src/cli/tui/App.tsx index 23511d9d6..73d62f435 100644 --- a/src/cli/tui/App.tsx +++ b/src/cli/tui/App.tsx @@ -1,6 +1,6 @@ import { getWorkingDirectory } from '../../lib'; import { createProgram } from '../cli'; -import { LayoutProvider } from './context'; +import { LayoutProvider, ThemeProvider } from './context'; import { MissingProjectMessage, WrongDirectoryMessage, getProjectRootMismatch, projectExists } from './guards'; import { PlaceholderScreen } from './screens/PlaceholderScreen'; import { AddFlow } from './screens/add/AddFlow'; @@ -196,8 +196,10 @@ function AppContent() { export function App() { return ( - - - + + + + + ); } diff --git a/src/cli/tui/__tests__/theme.test.ts b/src/cli/tui/__tests__/theme.test.ts new file mode 100644 index 000000000..7e44e6b17 --- /dev/null +++ b/src/cli/tui/__tests__/theme.test.ts @@ -0,0 +1,195 @@ +import { + DARK_THEME, + LIGHT_THEME, + THEME_ENV_VAR, + detectSystemTheme, + getCurrentThemeColors, + getThemeColors, + getThemeMode, +} from '../theme.js'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +describe('theme', () => { + const originalEnv = process.env; + + beforeEach(() => { + // Reset environment before each test + vi.resetModules(); + process.env = { ...originalEnv }; + }); + + afterEach(() => { + process.env = originalEnv; + }); + + describe('getThemeColors', () => { + it('returns dark theme colors for dark mode', () => { + const colors = getThemeColors('dark'); + + expect(colors).toEqual(DARK_THEME); + }); + + it('returns light theme colors for light mode', () => { + const colors = getThemeColors('light'); + + expect(colors).toEqual(LIGHT_THEME); + }); + }); + + describe('getThemeMode', () => { + it('returns dark when AGENTCORE_THEME is set to dark', () => { + process.env[THEME_ENV_VAR] = 'dark'; + + expect(getThemeMode()).toBe('dark'); + }); + + it('returns light when AGENTCORE_THEME is set to light', () => { + process.env[THEME_ENV_VAR] = 'light'; + + expect(getThemeMode()).toBe('light'); + }); + + it('is case insensitive for AGENTCORE_THEME', () => { + process.env[THEME_ENV_VAR] = 'LIGHT'; + + expect(getThemeMode()).toBe('light'); + + process.env[THEME_ENV_VAR] = 'DARK'; + + expect(getThemeMode()).toBe('dark'); + }); + + it('falls back to system detection when AGENTCORE_THEME is system', () => { + process.env[THEME_ENV_VAR] = 'system'; + // Clear other env vars that might affect detection + delete process.env.COLORFGBG; + delete process.env.APPLE_INTERFACE_STYLE; + delete process.env.TERMINAL_LIGHT_MODE; + + // Should default to dark when no system indicators + expect(getThemeMode()).toBe('dark'); + }); + + it('falls back to system detection when AGENTCORE_THEME is not set', () => { + delete process.env[THEME_ENV_VAR]; + delete process.env.COLORFGBG; + delete process.env.APPLE_INTERFACE_STYLE; + delete process.env.TERMINAL_LIGHT_MODE; + + // Should default to dark when no system indicators + expect(getThemeMode()).toBe('dark'); + }); + }); + + describe('detectSystemTheme', () => { + it('returns dark by default when no indicators present', () => { + delete process.env.COLORFGBG; + delete process.env.APPLE_INTERFACE_STYLE; + delete process.env.TERMINAL_LIGHT_MODE; + + expect(detectSystemTheme()).toBe('dark'); + }); + + it('detects light mode from COLORFGBG with light background', () => { + process.env.COLORFGBG = '0;15'; // Black text on white background + + expect(detectSystemTheme()).toBe('light'); + }); + + it('detects dark mode from COLORFGBG with dark background', () => { + process.env.COLORFGBG = '15;0'; // White text on black background + + expect(detectSystemTheme()).toBe('dark'); + }); + + it('detects light mode from APPLE_INTERFACE_STYLE', () => { + process.env.APPLE_INTERFACE_STYLE = 'Light'; + + expect(detectSystemTheme()).toBe('light'); + }); + + it('detects light mode from TERMINAL_LIGHT_MODE=true', () => { + process.env.TERMINAL_LIGHT_MODE = 'true'; + + expect(detectSystemTheme()).toBe('light'); + }); + + it('detects light mode from TERMINAL_LIGHT_MODE=1', () => { + process.env.TERMINAL_LIGHT_MODE = '1'; + + expect(detectSystemTheme()).toBe('light'); + }); + }); + + describe('getCurrentThemeColors', () => { + it('returns dark theme colors when mode is dark', () => { + process.env[THEME_ENV_VAR] = 'dark'; + + const colors = getCurrentThemeColors(); + + expect(colors).toEqual(DARK_THEME); + }); + + it('returns light theme colors when mode is light', () => { + process.env[THEME_ENV_VAR] = 'light'; + + const colors = getCurrentThemeColors(); + + expect(colors).toEqual(LIGHT_THEME); + }); + }); + + describe('theme color palettes', () => { + it('dark theme has all required color categories', () => { + expect(DARK_THEME.status).toBeDefined(); + expect(DARK_THEME.interactive).toBeDefined(); + expect(DARK_THEME.text).toBeDefined(); + }); + + it('light theme has all required color categories', () => { + expect(LIGHT_THEME.status).toBeDefined(); + expect(LIGHT_THEME.interactive).toBeDefined(); + expect(LIGHT_THEME.text).toBeDefined(); + }); + + it('dark theme has all status colors', () => { + expect(DARK_THEME.status.success).toBeDefined(); + expect(DARK_THEME.status.error).toBeDefined(); + expect(DARK_THEME.status.warning).toBeDefined(); + expect(DARK_THEME.status.info).toBeDefined(); + expect(DARK_THEME.status.pending).toBeDefined(); + }); + + it('light theme has all status colors', () => { + expect(LIGHT_THEME.status.success).toBeDefined(); + expect(LIGHT_THEME.status.error).toBeDefined(); + expect(LIGHT_THEME.status.warning).toBeDefined(); + expect(LIGHT_THEME.status.info).toBeDefined(); + expect(LIGHT_THEME.status.pending).toBeDefined(); + }); + + it('dark theme has all interactive colors', () => { + expect(DARK_THEME.interactive.selection).toBeDefined(); + expect(DARK_THEME.interactive.cursor).toBeDefined(); + expect(DARK_THEME.interactive.highlight).toBeDefined(); + }); + + it('light theme has all interactive colors', () => { + expect(LIGHT_THEME.interactive.selection).toBeDefined(); + expect(LIGHT_THEME.interactive.cursor).toBeDefined(); + expect(LIGHT_THEME.interactive.highlight).toBeDefined(); + }); + + it('dark theme has all text colors', () => { + expect(DARK_THEME.text.primary).toBeDefined(); + expect(DARK_THEME.text.muted).toBeDefined(); + expect(DARK_THEME.text.directory).toBeDefined(); + }); + + it('light theme has all text colors', () => { + expect(LIGHT_THEME.text.primary).toBeDefined(); + expect(LIGHT_THEME.text.muted).toBeDefined(); + expect(LIGHT_THEME.text.directory).toBeDefined(); + }); + }); +}); diff --git a/src/cli/tui/context/ThemeContext.tsx b/src/cli/tui/context/ThemeContext.tsx new file mode 100644 index 000000000..9d1f7ce65 --- /dev/null +++ b/src/cli/tui/context/ThemeContext.tsx @@ -0,0 +1,98 @@ +import { + type ThemeColors, + type ThemeMode, + getCurrentThemeColors, + getThemeColors, + getThemeMode, +} from '../theme'; +import React, { type ReactNode, createContext, useContext, useMemo, useState } from 'react'; + +/** + * Theme context value interface + */ +interface ThemeContextValue { + /** Current theme mode ('light' or 'dark') */ + mode: 'light' | 'dark'; + /** Current theme colors */ + colors: ThemeColors; + /** Whether the theme was auto-detected from system */ + isSystemDetected: boolean; + /** Set the theme mode manually */ + setMode: (mode: ThemeMode) => void; +} + +const ThemeContext = createContext({ + mode: 'dark', + colors: getCurrentThemeColors(), + isSystemDetected: true, + setMode: () => {}, +}); + +/** + * Hook to access the current theme context. + * Returns the current theme mode, colors, and a function to change the theme. + * + * @example + * ```tsx + * function MyComponent() { + * const { colors, mode } = useTheme(); + * return Success!; + * } + * ``` + */ +// eslint-disable-next-line react-refresh/only-export-components +export function useTheme(): ThemeContextValue { + return useContext(ThemeContext); +} + +interface ThemeProviderProps { + /** Initial theme mode (defaults to system detection) */ + initialMode?: ThemeMode; + children: ReactNode; +} + +/** + * Theme provider component that manages theme state. + * Wraps the application to provide theme context to all child components. + * + * @example + * ```tsx + * function App() { + * return ( + * + * + * + * ); + * } + * ``` + */ +export function ThemeProvider({ initialMode = 'system', children }: ThemeProviderProps) { + // Resolve initial mode + const resolvedInitialMode = initialMode === 'system' ? getThemeMode() : initialMode; + const [mode, setModeState] = useState<'light' | 'dark'>(resolvedInitialMode); + const [isSystemDetected, setIsSystemDetected] = useState(initialMode === 'system'); + + const setMode = (newMode: ThemeMode) => { + if (newMode === 'system') { + setModeState(getThemeMode()); + setIsSystemDetected(true); + } else { + setModeState(newMode); + setIsSystemDetected(false); + } + }; + + const colors = useMemo(() => getThemeColors(mode), [mode]); + + const value = useMemo( + () => ({ + mode, + colors, + isSystemDetected, + setMode, + }), + [mode, colors, isSystemDetected] + ); + + return {children}; +} diff --git a/src/cli/tui/context/__tests__/ThemeContext.test.tsx b/src/cli/tui/context/__tests__/ThemeContext.test.tsx new file mode 100644 index 000000000..5dd811b4b --- /dev/null +++ b/src/cli/tui/context/__tests__/ThemeContext.test.tsx @@ -0,0 +1,153 @@ +import { DARK_THEME, LIGHT_THEME, THEME_ENV_VAR } from '../../theme.js'; +import { ThemeProvider, useTheme } from '../ThemeContext.js'; +import { render } from 'ink-testing-library'; +import React from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// Test component that uses the theme hook +function ThemeConsumer({ onRender }: { onRender: (theme: ReturnType) => void }) { + const theme = useTheme(); + onRender(theme); + return null; +} + +describe('ThemeContext', () => { + const originalEnv = process.env; + + beforeEach(() => { + vi.resetModules(); + process.env = { ...originalEnv }; + // Clear theme-related env vars + delete process.env[THEME_ENV_VAR]; + delete process.env.COLORFGBG; + delete process.env.APPLE_INTERFACE_STYLE; + delete process.env.TERMINAL_LIGHT_MODE; + }); + + afterEach(() => { + process.env = originalEnv; + }); + + describe('useTheme', () => { + it('provides dark theme colors by default', () => { + let capturedTheme: ReturnType | null = null; + + render( + + (capturedTheme = theme)} /> + + ); + + expect(capturedTheme).not.toBeNull(); + expect(capturedTheme!.mode).toBe('dark'); + expect(capturedTheme!.colors).toEqual(DARK_THEME); + }); + + it('provides light theme colors when initialMode is light', () => { + let capturedTheme: ReturnType | null = null; + + render( + + (capturedTheme = theme)} /> + + ); + + expect(capturedTheme).not.toBeNull(); + expect(capturedTheme!.mode).toBe('light'); + expect(capturedTheme!.colors).toEqual(LIGHT_THEME); + }); + + it('provides dark theme colors when initialMode is dark', () => { + let capturedTheme: ReturnType | null = null; + + render( + + (capturedTheme = theme)} /> + + ); + + expect(capturedTheme).not.toBeNull(); + expect(capturedTheme!.mode).toBe('dark'); + expect(capturedTheme!.colors).toEqual(DARK_THEME); + }); + + it('respects AGENTCORE_THEME environment variable for light mode', () => { + process.env[THEME_ENV_VAR] = 'light'; + let capturedTheme: ReturnType | null = null; + + render( + + (capturedTheme = theme)} /> + + ); + + expect(capturedTheme).not.toBeNull(); + expect(capturedTheme!.mode).toBe('light'); + expect(capturedTheme!.colors).toEqual(LIGHT_THEME); + }); + + it('sets isSystemDetected to true when using system mode', () => { + let capturedTheme: ReturnType | null = null; + + render( + + (capturedTheme = theme)} /> + + ); + + expect(capturedTheme).not.toBeNull(); + expect(capturedTheme!.isSystemDetected).toBe(true); + }); + + it('sets isSystemDetected to false when using explicit mode', () => { + let capturedTheme: ReturnType | null = null; + + render( + + (capturedTheme = theme)} /> + + ); + + expect(capturedTheme).not.toBeNull(); + expect(capturedTheme!.isSystemDetected).toBe(false); + }); + + it('provides setMode function', () => { + let capturedTheme: ReturnType | null = null; + + render( + + (capturedTheme = theme)} /> + + ); + + expect(capturedTheme).not.toBeNull(); + expect(typeof capturedTheme!.setMode).toBe('function'); + }); + }); + + describe('ThemeProvider', () => { + it('renders children', () => { + const { lastFrame } = render( + + Test Content + + ); + + expect(lastFrame()).toContain('Test Content'); + }); + + it('defaults to system mode when no initialMode provided', () => { + let capturedTheme: ReturnType | null = null; + + render( + + (capturedTheme = theme)} /> + + ); + + expect(capturedTheme).not.toBeNull(); + expect(capturedTheme!.isSystemDetected).toBe(true); + }); + }); +}); diff --git a/src/cli/tui/context/index.ts b/src/cli/tui/context/index.ts index 05fe7f29e..cc1823d9a 100644 --- a/src/cli/tui/context/index.ts +++ b/src/cli/tui/context/index.ts @@ -1 +1,2 @@ export { LayoutProvider, useLayout, buildLogo } from './LayoutContext'; +export { ThemeProvider, useTheme } from './ThemeContext'; diff --git a/src/cli/tui/theme.ts b/src/cli/tui/theme.ts index 35a0d7424..b7f13ce36 100644 --- a/src/cli/tui/theme.ts +++ b/src/cli/tui/theme.ts @@ -1,42 +1,230 @@ /** * Centralized color definitions for the TUI. * All color values should be referenced from here to ensure consistency. + * Supports both light and dark mode themes. */ /** + * Theme mode type - either 'light', 'dark', or 'system' (auto-detect) + */ +export type ThemeMode = 'light' | 'dark' | 'system'; + +/** + * Color palette interface for a single theme + */ +export interface ThemeColors { + status: { + success: string; + error: string; + warning: string; + info: string; + pending: string; + }; + interactive: { + selection: string; + cursor: string; + highlight: string; + }; + text: { + primary: string; + muted: string; + directory: string; + }; +} + +/** + * Dark mode color palette - optimized for dark terminal backgrounds + */ +export const DARK_THEME: ThemeColors = { + status: { + success: 'green', + error: 'red', + warning: 'yellow', + info: 'blue', + pending: 'gray', + }, + interactive: { + selection: 'cyan', + cursor: 'white', + highlight: 'cyan', + }, + text: { + primary: 'white', + muted: 'gray', + directory: 'blue', + }, +}; + +/** + * Light mode color palette - optimized for light terminal backgrounds + */ +export const LIGHT_THEME: ThemeColors = { + status: { + success: 'greenBright', + error: 'redBright', + warning: 'yellowBright', + info: 'blueBright', + pending: 'blackBright', + }, + interactive: { + selection: 'cyanBright', + cursor: 'black', + highlight: 'cyanBright', + }, + text: { + primary: 'black', + muted: 'blackBright', + directory: 'blueBright', + }, +}; + +/** + * Environment variable name for theme configuration + */ +export const THEME_ENV_VAR = 'AGENTCORE_THEME'; + +/** + * Detect system color scheme preference. + * Checks common environment variables and terminal settings. + * Returns 'dark' as default since most terminal users prefer dark mode. + */ +export function detectSystemTheme(): 'light' | 'dark' { + // Check COLORFGBG environment variable (format: "fg;bg" where higher bg = light) + const colorFgBg = process.env.COLORFGBG; + if (colorFgBg) { + const parts = colorFgBg.split(';'); + const bg = parseInt(parts[parts.length - 1], 10); + // Background colors 0-6 and 8 are typically dark, 7 and 9-15 are light + if (!isNaN(bg) && (bg === 7 || (bg >= 9 && bg <= 15))) { + return 'light'; + } + } + + // Check macOS appearance (if available via environment) + const appleInterfaceStyle = process.env.APPLE_INTERFACE_STYLE; + if (appleInterfaceStyle?.toLowerCase() === 'light') { + return 'light'; + } + + // Check for explicit light terminal indicators + const termProgram = process.env.TERM_PROGRAM?.toLowerCase(); + const colorTerm = process.env.COLORTERM?.toLowerCase(); + + // Some terminals set specific variables when in light mode + if (process.env.TERMINAL_LIGHT_MODE === 'true' || process.env.TERMINAL_LIGHT_MODE === '1') { + return 'light'; + } + + // Default to dark mode (most common for terminal users) + return 'dark'; +} + +/** + * Get the current theme mode from environment or system detection. + * Priority: AGENTCORE_THEME env var > system detection + */ +export function getThemeMode(): 'light' | 'dark' { + const envTheme = process.env[THEME_ENV_VAR]?.toLowerCase(); + + if (envTheme === 'light') { + return 'light'; + } + + if (envTheme === 'dark') { + return 'dark'; + } + + if (envTheme === 'system' || !envTheme) { + return detectSystemTheme(); + } + + // Invalid value, default to system detection + return detectSystemTheme(); +} + +/** + * Get the theme colors for the specified mode. + */ +export function getThemeColors(mode: 'light' | 'dark'): ThemeColors { + return mode === 'light' ? LIGHT_THEME : DARK_THEME; +} + +/** + * Get the current theme colors based on environment/system settings. + */ +export function getCurrentThemeColors(): ThemeColors { + return getThemeColors(getThemeMode()); +} + +// Legacy exports for backward compatibility +// These use the current theme colors dynamically + +/** + * @deprecated Use useTheme() hook or getCurrentThemeColors() instead * Semantic status colors for indicating state/progress. */ export const STATUS_COLORS = { - success: 'green', - error: 'red', - warning: 'yellow', - info: 'blue', - pending: 'gray', + get success() { + return getCurrentThemeColors().status.success; + }, + get error() { + return getCurrentThemeColors().status.error; + }, + get warning() { + return getCurrentThemeColors().status.warning; + }, + get info() { + return getCurrentThemeColors().status.info; + }, + get pending() { + return getCurrentThemeColors().status.pending; + }, } as const; /** + * @deprecated Use useTheme() hook or getCurrentThemeColors() instead * Colors for interactive elements like selections and highlights. */ export const INTERACTIVE_COLORS = { - selection: 'cyan', - cursor: 'white', - highlight: 'cyan', + get selection() { + return getCurrentThemeColors().interactive.selection; + }, + get cursor() { + return getCurrentThemeColors().interactive.cursor; + }, + get highlight() { + return getCurrentThemeColors().interactive.highlight; + }, } as const; /** + * @deprecated Use useTheme() hook or getCurrentThemeColors() instead * Text colors for general content. */ export const TEXT_COLORS = { - primary: 'white', - muted: 'gray', - directory: 'blue', + get primary() { + return getCurrentThemeColors().text.primary; + }, + get muted() { + return getCurrentThemeColors().text.muted; + }, + get directory() { + return getCurrentThemeColors().text.directory; + }, } as const; /** + * @deprecated Use useTheme() hook or getCurrentThemeColors() instead * Combined theme object for convenient access. */ export const THEME = { - status: STATUS_COLORS, - interactive: INTERACTIVE_COLORS, - text: TEXT_COLORS, + get status() { + return STATUS_COLORS; + }, + get interactive() { + return INTERACTIVE_COLORS; + }, + get text() { + return TEXT_COLORS; + }, } as const;