Skip to content
10 changes: 6 additions & 4 deletions src/cli/tui/App.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -196,8 +196,10 @@ function AppContent() {

export function App() {
return (
<LayoutProvider>
<AppContent />
</LayoutProvider>
<ThemeProvider>
<LayoutProvider>
<AppContent />
</LayoutProvider>
</ThemeProvider>
);
}
117 changes: 117 additions & 0 deletions src/cli/tui/context/ThemeContext.tsx
Original file line number Diff line number Diff line change
@@ -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<ThemeContextValue>(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 (
* <Box>
* <Text color={colors.text.primary}>Hello</Text>
* <Text color={colors.status.success}>Success!</Text>
* </Box>
* );
* }
* ```
*/
// 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 (
* <ThemeProvider initialPreference="system">
* <MyApp />
* </ThemeProvider>
* );
* }
* ```
*/
export function ThemeProvider({ children, initialPreference = 'system' }: ThemeProviderProps) {
const [preference, setPreferenceState] = useState<ThemePreference>(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<ThemeContextValue>(
() => ({
preference,
mode,
colors,
setPreference,
}),
[preference, mode, colors, setPreference]
);

return <ThemeContext.Provider value={contextValue}>{children}</ThemeContext.Provider>;
}
180 changes: 180 additions & 0 deletions src/cli/tui/context/__tests__/ThemeContext.test.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Text>
preference:{preference} mode:{mode} primary:{colors.text.primary}
</Text>
);
}

// Test component that can change theme
function ThemeChanger({ onReady }: { onReady: (setPreference: (p: 'light' | 'dark' | 'system') => void) => void }) {

Check failure on line 20 in src/cli/tui/context/__tests__/ThemeContext.test.tsx

View workflow job for this annotation

GitHub Actions / lint

'ThemeChanger' is defined but never used. Allowed unused vars must match /^_/u
const { setPreference, mode } = useTheme();
React.useEffect(() => {
onReady(setPreference);
}, [onReady, setPreference]);
return <Text>mode:{mode}</Text>;
}

describe('ThemeContext', () => {
describe('ThemeProvider', () => {
it('provides default system preference', () => {
const { lastFrame } = render(
<ThemeProvider>
<ThemeDisplay />
</ThemeProvider>
);

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(
<ThemeProvider initialPreference="dark">
<ThemeDisplay />
</ThemeProvider>
);

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(
<ThemeProvider initialPreference="light">
<ThemeDisplay />
</ThemeProvider>
);

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(<ThemeDisplay />);

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'];

Check failure on line 108 in src/cli/tui/context/__tests__/ThemeContext.test.tsx

View workflow job for this annotation

GitHub Actions / lint

["COLORFGBG"] is better written in dot notation
delete process.env['TERM_PROGRAM'];

Check failure on line 109 in src/cli/tui/context/__tests__/ThemeContext.test.tsx

View workflow job for this annotation

GitHub Actions / lint

["TERM_PROGRAM"] is better written in dot notation
delete process.env['COLOR_SCHEME'];

Check failure on line 110 in src/cli/tui/context/__tests__/ThemeContext.test.tsx

View workflow job for this annotation

GitHub Actions / lint

["COLOR_SCHEME"] is better written in dot notation
});

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

Check failure on line 122 in src/cli/tui/context/__tests__/ThemeContext.test.tsx

View workflow job for this annotation

GitHub Actions / lint

["COLORFGBG"] is better written in dot notation
expect(detectSystemColorScheme()).toBe('dark');
});

it('detects light mode from COLORFGBG with light background', () => {
process.env['COLORFGBG'] = '0;15'; // Black text on white background

Check failure on line 127 in src/cli/tui/context/__tests__/ThemeContext.test.tsx

View workflow job for this annotation

GitHub Actions / lint

["COLORFGBG"] is better written in dot notation
expect(detectSystemColorScheme()).toBe('light');
});

it('detects light mode from COLOR_SCHEME environment variable', () => {
process.env['COLOR_SCHEME'] = 'light';

Check failure on line 132 in src/cli/tui/context/__tests__/ThemeContext.test.tsx

View workflow job for this annotation

GitHub Actions / lint

["COLOR_SCHEME"] is better written in dot notation
expect(detectSystemColorScheme()).toBe('light');
});

it('detects dark mode from COLOR_SCHEME environment variable', () => {
process.env['COLOR_SCHEME'] = 'dark';

Check failure on line 137 in src/cli/tui/context/__tests__/ThemeContext.test.tsx

View workflow job for this annotation

GitHub Actions / lint

["COLOR_SCHEME"] is better written in dot notation
expect(detectSystemColorScheme()).toBe('dark');
});

it('handles invalid COLORFGBG gracefully', () => {
process.env['COLORFGBG'] = 'invalid';

Check failure on line 142 in src/cli/tui/context/__tests__/ThemeContext.test.tsx

View workflow job for this annotation

GitHub Actions / lint

["COLORFGBG"] is better written in dot notation
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);
});
});
1 change: 1 addition & 0 deletions src/cli/tui/context/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export { LayoutProvider, useLayout, buildLogo } from './LayoutContext';
export { ThemeProvider, useTheme, type ThemeContextValue, type ThemeProviderProps } from './ThemeContext';
Loading
Loading