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,