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/context/ThemeContext.tsx b/src/cli/tui/context/ThemeContext.tsx
new file mode 100644
index 000000000..30d4f8be7
--- /dev/null
+++ b/src/cli/tui/context/ThemeContext.tsx
@@ -0,0 +1,117 @@
+import React, { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from 'react';
+
+import {
+ type ColorPalette,
+ type ThemeMode,
+ type ThemePreference,
+ getColorPalette,
+ resolveThemeMode,
+} from '../theme.js';
+
+/**
+ * Theme context value interface.
+ */
+export interface ThemeContextValue {
+ /** Current theme preference (light/dark/system) */
+ preference: ThemePreference;
+ /** Resolved theme mode after applying system preference */
+ mode: ThemeMode;
+ /** Color palette for the current theme */
+ colors: ColorPalette;
+ /** Update the theme preference */
+ setPreference: (preference: ThemePreference) => void;
+}
+
+/**
+ * Default theme context value.
+ */
+const defaultThemeContext: ThemeContextValue = {
+ preference: 'system',
+ mode: 'dark',
+ colors: getColorPalette('dark'),
+ setPreference: () => {
+ // No-op for default context
+ },
+};
+
+/**
+ * React context for theme management.
+ */
+const ThemeContext = createContext(defaultThemeContext);
+
+/**
+ * Hook to access the current theme context.
+ *
+ * @returns The current theme context value including colors, mode, and preference setter
+ *
+ * @example
+ * ```tsx
+ * function MyComponent() {
+ * const { colors, mode, setPreference } = useTheme();
+ *
+ * return (
+ *
+ * Hello
+ * Success!
+ *
+ * );
+ * }
+ * ```
+ */
+// eslint-disable-next-line react-refresh/only-export-components
+export function useTheme(): ThemeContextValue {
+ return useContext(ThemeContext);
+}
+
+/**
+ * Props for the ThemeProvider component.
+ */
+export interface ThemeProviderProps {
+ /** Child components to wrap with theme context */
+ children: ReactNode;
+ /** Initial theme preference (defaults to 'system') */
+ initialPreference?: ThemePreference;
+}
+
+/**
+ * Provider component for theme context.
+ * Wraps the application to provide theme-aware colors throughout the component tree.
+ *
+ * @example
+ * ```tsx
+ * function App() {
+ * return (
+ *
+ *
+ *
+ * );
+ * }
+ * ```
+ */
+export function ThemeProvider({ children, initialPreference = 'system' }: ThemeProviderProps) {
+ const [preference, setPreferenceState] = useState(initialPreference);
+
+ // Resolve the actual theme mode based on preference
+ const mode = useMemo(() => resolveThemeMode(preference), [preference]);
+
+ // Get the color palette for the current mode
+ const colors = useMemo(() => getColorPalette(mode), [mode]);
+
+ // Memoized setter to avoid unnecessary re-renders
+ const setPreference = useCallback((newPreference: ThemePreference) => {
+ setPreferenceState(newPreference);
+ }, []);
+
+ // Memoize the context value to prevent unnecessary re-renders
+ const contextValue = useMemo(
+ () => ({
+ preference,
+ mode,
+ colors,
+ setPreference,
+ }),
+ [preference, mode, colors, setPreference]
+ );
+
+ 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..65e36d417
--- /dev/null
+++ b/src/cli/tui/context/__tests__/ThemeContext.test.tsx
@@ -0,0 +1,180 @@
+import { Text } from 'ink';
+import { render } from 'ink-testing-library';
+import React from 'react';
+import { afterEach, beforeEach, describe, expect, it } from 'vitest';
+
+import { DARK_PALETTE, LIGHT_PALETTE, detectSystemColorScheme, getColorPalette, resolveThemeMode } from '../../theme.js';
+import { ThemeProvider, useTheme } from '../ThemeContext.js';
+
+// Test component that displays theme information
+function ThemeDisplay() {
+ const { preference, mode, colors } = useTheme();
+ return (
+
+ preference:{preference} mode:{mode} primary:{colors.text.primary}
+
+ );
+}
+
+// Test component that can change theme
+function ThemeChanger({ onReady }: { onReady: (setPreference: (p: 'light' | 'dark' | 'system') => void) => void }) {
+ const { setPreference, mode } = useTheme();
+ React.useEffect(() => {
+ onReady(setPreference);
+ }, [onReady, setPreference]);
+ return mode:{mode};
+}
+
+describe('ThemeContext', () => {
+ describe('ThemeProvider', () => {
+ it('provides default system preference', () => {
+ const { lastFrame } = render(
+
+
+
+ );
+
+ expect(lastFrame()).toContain('preference:system');
+ expect(lastFrame()).toContain('mode:dark'); // Default system detection returns dark
+ });
+
+ it('provides dark theme when preference is dark', () => {
+ const { lastFrame } = render(
+
+
+
+ );
+
+ expect(lastFrame()).toContain('preference:dark');
+ expect(lastFrame()).toContain('mode:dark');
+ expect(lastFrame()).toContain('primary:white');
+ });
+
+ it('provides light theme when preference is light', () => {
+ const { lastFrame } = render(
+
+
+
+ );
+
+ expect(lastFrame()).toContain('preference:light');
+ expect(lastFrame()).toContain('mode:light');
+ expect(lastFrame()).toContain('primary:black');
+ });
+ });
+
+ describe('useTheme hook outside provider', () => {
+ it('returns default dark theme', () => {
+ const { lastFrame } = render();
+
+ expect(lastFrame()).toContain('preference:system');
+ expect(lastFrame()).toContain('mode:dark');
+ });
+ });
+});
+
+describe('theme utilities', () => {
+ describe('getColorPalette', () => {
+ it('returns light palette for light mode', () => {
+ expect(getColorPalette('light')).toEqual(LIGHT_PALETTE);
+ });
+
+ it('returns dark palette for dark mode', () => {
+ expect(getColorPalette('dark')).toEqual(DARK_PALETTE);
+ });
+ });
+
+ describe('resolveThemeMode', () => {
+ it('returns light for light preference', () => {
+ expect(resolveThemeMode('light')).toBe('light');
+ });
+
+ it('returns dark for dark preference', () => {
+ expect(resolveThemeMode('dark')).toBe('dark');
+ });
+
+ it('detects system preference for system setting', () => {
+ // Default system detection returns dark
+ expect(resolveThemeMode('system')).toBe('dark');
+ });
+ });
+
+ describe('detectSystemColorScheme', () => {
+ const originalEnv = process.env;
+
+ beforeEach(() => {
+ // Reset environment for each test
+ process.env = { ...originalEnv };
+ delete process.env['COLORFGBG'];
+ delete process.env['TERM_PROGRAM'];
+ delete process.env['COLOR_SCHEME'];
+ });
+
+ afterEach(() => {
+ process.env = originalEnv;
+ });
+
+ it('returns dark by default when no environment hints', () => {
+ expect(detectSystemColorScheme()).toBe('dark');
+ });
+
+ it('detects dark mode from COLORFGBG with dark background', () => {
+ process.env['COLORFGBG'] = '15;0'; // White text on black background
+ expect(detectSystemColorScheme()).toBe('dark');
+ });
+
+ it('detects light mode from COLORFGBG with light background', () => {
+ process.env['COLORFGBG'] = '0;15'; // Black text on white background
+ expect(detectSystemColorScheme()).toBe('light');
+ });
+
+ it('detects light mode from COLOR_SCHEME environment variable', () => {
+ process.env['COLOR_SCHEME'] = 'light';
+ expect(detectSystemColorScheme()).toBe('light');
+ });
+
+ it('detects dark mode from COLOR_SCHEME environment variable', () => {
+ process.env['COLOR_SCHEME'] = 'dark';
+ expect(detectSystemColorScheme()).toBe('dark');
+ });
+
+ it('handles invalid COLORFGBG gracefully', () => {
+ process.env['COLORFGBG'] = 'invalid';
+ expect(detectSystemColorScheme()).toBe('dark');
+ });
+ });
+});
+
+describe('color palettes', () => {
+ it('light palette has correct structure', () => {
+ expect(LIGHT_PALETTE.status).toBeDefined();
+ expect(LIGHT_PALETTE.interactive).toBeDefined();
+ expect(LIGHT_PALETTE.text).toBeDefined();
+ expect(LIGHT_PALETTE.border).toBeDefined();
+
+ expect(LIGHT_PALETTE.status.success).toBe('green');
+ expect(LIGHT_PALETTE.status.error).toBe('red');
+ expect(LIGHT_PALETTE.text.primary).toBe('black');
+ expect(LIGHT_PALETTE.interactive.cursor).toBe('black');
+ });
+
+ it('dark palette has correct structure', () => {
+ expect(DARK_PALETTE.status).toBeDefined();
+ expect(DARK_PALETTE.interactive).toBeDefined();
+ expect(DARK_PALETTE.text).toBeDefined();
+ expect(DARK_PALETTE.border).toBeDefined();
+
+ expect(DARK_PALETTE.status.success).toBe('green');
+ expect(DARK_PALETTE.status.error).toBe('red');
+ expect(DARK_PALETTE.text.primary).toBe('white');
+ expect(DARK_PALETTE.interactive.cursor).toBe('white');
+ });
+
+ it('palettes have different primary text colors', () => {
+ expect(LIGHT_PALETTE.text.primary).not.toBe(DARK_PALETTE.text.primary);
+ });
+
+ it('palettes have different cursor colors', () => {
+ expect(LIGHT_PALETTE.interactive.cursor).not.toBe(DARK_PALETTE.interactive.cursor);
+ });
+});
diff --git a/src/cli/tui/context/index.ts b/src/cli/tui/context/index.ts
index 05fe7f29e..48edbd258 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, type ThemeContextValue, type ThemeProviderProps } from './ThemeContext';
diff --git a/src/cli/tui/theme.ts b/src/cli/tui/theme.ts
index 35a0d7424..64e02fc1b 100644
--- a/src/cli/tui/theme.ts
+++ b/src/cli/tui/theme.ts
@@ -1,10 +1,184 @@
/**
* Centralized color definitions for the TUI.
* All color values should be referenced from here to ensure consistency.
+ * Supports both light and dark color schemes.
*/
+/**
+ * Theme preference options.
+ */
+export type ThemePreference = 'light' | 'dark' | 'system';
+
+/**
+ * Resolved theme mode (after system preference is applied).
+ */
+export type ThemeMode = 'light' | 'dark';
+
+/**
+ * Color palette interface for semantic colors.
+ */
+export interface ColorPalette {
+ /** Status colors for indicating state/progress */
+ status: {
+ success: string;
+ error: string;
+ warning: string;
+ info: string;
+ pending: string;
+ };
+ /** Colors for interactive elements */
+ interactive: {
+ selection: string;
+ cursor: string;
+ highlight: string;
+ };
+ /** Text colors for general content */
+ text: {
+ primary: string;
+ secondary: string;
+ muted: string;
+ directory: string;
+ };
+ /** Border colors */
+ border: {
+ default: string;
+ active: string;
+ };
+}
+
+/**
+ * Light mode color palette.
+ * Optimized for terminals with light backgrounds.
+ */
+export const LIGHT_PALETTE: ColorPalette = {
+ status: {
+ success: 'green',
+ error: 'red',
+ warning: 'yellow',
+ info: 'blue',
+ pending: 'gray',
+ },
+ interactive: {
+ selection: 'cyan',
+ cursor: 'black',
+ highlight: 'cyan',
+ },
+ text: {
+ primary: 'black',
+ secondary: 'gray',
+ muted: 'gray',
+ directory: 'blue',
+ },
+ border: {
+ default: 'gray',
+ active: 'cyan',
+ },
+} as const;
+
+/**
+ * Dark mode color palette.
+ * Optimized for terminals with dark backgrounds.
+ */
+export const DARK_PALETTE: ColorPalette = {
+ status: {
+ success: 'green',
+ error: 'red',
+ warning: 'yellow',
+ info: 'blue',
+ pending: 'gray',
+ },
+ interactive: {
+ selection: 'cyan',
+ cursor: 'white',
+ highlight: 'cyan',
+ },
+ text: {
+ primary: 'white',
+ secondary: 'gray',
+ muted: 'gray',
+ directory: 'blue',
+ },
+ border: {
+ default: 'gray',
+ active: 'cyan',
+ },
+} as const;
+
+/**
+ * Get the color palette for a given theme mode.
+ */
+export function getColorPalette(mode: ThemeMode): ColorPalette {
+ return mode === 'light' ? LIGHT_PALETTE : DARK_PALETTE;
+}
+
+/**
+ * Detect system color scheme preference.
+ * Uses environment variables commonly set by terminals to indicate color scheme.
+ *
+ * Detection methods:
+ * 1. COLORFGBG - Format: "fg;bg" where bg > 7 typically indicates dark mode
+ * 2. TERM_PROGRAM specific detection (iTerm2, Apple Terminal, etc.)
+ * 3. Default to dark mode (most common for CLI tools)
+ */
+export function detectSystemColorScheme(): ThemeMode {
+ // Check COLORFGBG environment variable
+ // Format: "foreground;background" where colors are 0-15
+ // Background > 7 typically indicates dark mode
+ const colorFgBg = process.env['COLORFGBG'];
+ if (colorFgBg) {
+ const parts = colorFgBg.split(';');
+ const bg = parts[parts.length - 1];
+ if (bg !== undefined) {
+ const bgNum = parseInt(bg, 10);
+ if (!isNaN(bgNum)) {
+ // Colors 0-7 are typically dark, 8-15 are bright/light
+ // A dark background (0-7) means dark mode
+ // A light background (8-15, especially 15 for white) means light mode
+ return bgNum >= 8 ? 'light' : 'dark';
+ }
+ }
+ }
+
+ // Check for macOS dark mode via TERM_PROGRAM
+ // Note: This is a heuristic and may not be accurate for all terminals
+ const termProgram = process.env['TERM_PROGRAM'];
+ if (termProgram === 'Apple_Terminal') {
+ // Apple Terminal follows system appearance
+ // We can't reliably detect this, so default to dark
+ return 'dark';
+ }
+
+ // Check for explicit dark mode indicators
+ const colorScheme = process.env['COLOR_SCHEME'];
+ if (colorScheme === 'light') {
+ return 'light';
+ }
+ if (colorScheme === 'dark') {
+ return 'dark';
+ }
+
+ // Default to dark mode (most common for CLI tools)
+ return 'dark';
+}
+
+/**
+ * Resolve theme preference to actual theme mode.
+ */
+export function resolveThemeMode(preference: ThemePreference): ThemeMode {
+ if (preference === 'system') {
+ return detectSystemColorScheme();
+ }
+ return preference;
+}
+
+// ============================================================================
+// Legacy exports for backward compatibility
+// These maintain the existing API while the codebase migrates to theme context
+// ============================================================================
+
/**
* Semantic status colors for indicating state/progress.
+ * @deprecated Use useTheme() hook and colors.status instead
*/
export const STATUS_COLORS = {
success: 'green',
@@ -16,6 +190,7 @@ export const STATUS_COLORS = {
/**
* Colors for interactive elements like selections and highlights.
+ * @deprecated Use useTheme() hook and colors.interactive instead
*/
export const INTERACTIVE_COLORS = {
selection: 'cyan',
@@ -25,6 +200,7 @@ export const INTERACTIVE_COLORS = {
/**
* Text colors for general content.
+ * @deprecated Use useTheme() hook and colors.text instead
*/
export const TEXT_COLORS = {
primary: 'white',
@@ -34,6 +210,7 @@ export const TEXT_COLORS = {
/**
* Combined theme object for convenient access.
+ * @deprecated Use useTheme() hook instead
*/
export const THEME = {
status: STATUS_COLORS,