From db21b6d2888e2911c2732f818727564783961f3d Mon Sep 17 00:00:00 2001 From: Jonatan Bakucz Date: Tue, 4 Aug 2026 13:45:37 +0200 Subject: [PATCH 1/2] Add color theme setting with light and auto modes - Add workspace-scoped theme setting (dark/light/auto, default auto) - Ship dark + light palettes in src/tui/theme.ts; colors is now a mutable object so all views re-read the active palette - Auto mode probes the terminal via OSC 11 and falls back to COLORFGBG, then dark, if the terminal doesn't respond in 100ms - Wire the setting into the TUI config screen (hot-applies on save) and the CLI: 'leetcode config --theme ' plus the interactive prompt --- src/commands/config.ts | 32 +++++- src/index.ts | 2 + src/storage/config.ts | 13 ++- src/storage/workspaces.ts | 3 +- src/tui/commands/effects.ts | 9 ++ src/tui/index.ts | 7 ++ src/tui/screens/config/index.ts | 10 ++ src/tui/screens/config/view.ts | 8 +- src/tui/theme.ts | 171 +++++++++++++++++++++++++++++++- src/types.ts | 3 + 10 files changed, 253 insertions(+), 5 deletions(-) diff --git a/src/commands/config.ts b/src/commands/config.ts index 330ab49..a4be130 100644 --- a/src/commands/config.ts +++ b/src/commands/config.ts @@ -10,6 +10,7 @@ import { normalizeLeetCodeSiteInput, SUPPORTED_LEETCODE_SITES, } from '../utils/site.js'; +import { normalizeThemeInput, SUPPORTED_THEMES } from '../tui/theme.js'; interface ConfigOptions { lang?: string; @@ -17,12 +18,20 @@ interface ConfigOptions { workdir?: string; repo?: string | boolean; site?: string; + theme?: string; } export async function configCommand(options: ConfigOptions): Promise { const hasRepoOption = options.repo !== undefined; - if (!options.lang && !options.editor && !options.workdir && !hasRepoOption && !options.site) { + if ( + !options.lang && + !options.editor && + !options.workdir && + !hasRepoOption && + !options.site && + !options.theme + ) { await showCurrentConfig(); return; } @@ -96,12 +105,24 @@ export async function configCommand(options: ConfigOptions): Promise { config.setSite(normalizedSite); console.log(chalk.green(`✓ Site set to ${normalizedSite}`)); } + + if (options.theme) { + const normalizedTheme = normalizeThemeInput(options.theme); + if (!normalizedTheme) { + console.log(chalk.red(`Unsupported theme: ${options.theme}`)); + console.log(chalk.gray(`Supported: ${SUPPORTED_THEMES.join(', ')}`)); + return; + } + config.setTheme(normalizedTheme); + console.log(chalk.green(`✓ Theme set to ${normalizedTheme}`)); + } } export async function configInteractiveCommand(): Promise { const currentConfig = config.getConfig(); const workspace = config.getActiveWorkspace(); const currentSite = normalizeLeetCodeSiteInput(currentConfig.site ?? '') ?? DEFAULT_LEETCODE_SITE; + const currentTheme = currentConfig.theme ?? 'auto'; console.log(); console.log(chalk.bold.cyan(`📁 Configuring workspace: ${workspace}`)); @@ -125,6 +146,13 @@ export async function configInteractiveCommand(): Promise { })), default: currentSite, }, + { + type: 'list', + name: 'theme', + message: 'Color theme:', + choices: SUPPORTED_THEMES.map((t) => ({ name: t, value: t })), + default: currentTheme, + }, { type: 'input', name: 'editor', @@ -148,6 +176,7 @@ export async function configInteractiveCommand(): Promise { config.setLanguage(answers.language); config.setEditor(answers.editor); config.setWorkDir(answers.workDir); + config.setTheme(answers.theme); if (answers.repo) { config.setRepo(answers.repo); } else { @@ -198,6 +227,7 @@ async function showCurrentConfig(): Promise { console.log(); console.log(chalk.gray('Language: '), chalk.white(currentConfig.language)); console.log(chalk.gray('Site: '), chalk.white(site)); + console.log(chalk.gray('Theme: '), chalk.white(currentConfig.theme ?? 'auto')); console.log(chalk.gray('Editor: '), chalk.white(currentConfig.editor ?? '(not set)')); console.log(chalk.gray('Work Dir: '), chalk.white(currentConfig.workDir)); console.log(chalk.gray('Repo URL: '), chalk.white(currentConfig.repo ?? '(not set)')); diff --git a/src/index.ts b/src/index.ts index c814df1..84a5c86 100644 --- a/src/index.ts +++ b/src/index.ts @@ -451,6 +451,7 @@ program .option('-e, --editor ', 'Set editor command') .option('-w, --workdir ', 'Set working directory for solutions') .option('-r, --repo [url]', 'Set Git repository URL (omit value to clear)') + .option('--theme ', 'Set color theme (dark, light, auto)') .option('-i, --interactive', 'Interactive configuration') .addHelpText( 'after', @@ -463,6 +464,7 @@ ${chalk.yellow('Examples:')} ${chalk.cyan('$ leetcode config -w ~/leetcode')} Set solutions folder ${chalk.cyan('$ leetcode config -r https://...')} Set git repository ${chalk.cyan('$ leetcode config --repo')} Clear git repository + ${chalk.cyan('$ leetcode config --theme light')} Use light color theme ${chalk.cyan('$ leetcode config -i')} Interactive setup ${chalk.gray(`Supported languages: ${getSupportedLanguagesLabel()}`)} diff --git a/src/storage/config.ts b/src/storage/config.ts index 2c22b1a..506978e 100644 --- a/src/storage/config.ts +++ b/src/storage/config.ts @@ -1,9 +1,11 @@ // Configuration management - delegates to workspace storage import { join } from 'path'; -import type { LeetCodeSite, SupportedLanguage, UserConfig } from '../types.js'; +import type { LeetCodeSite, SupportedLanguage, ThemeName, UserConfig } from '../types.js'; import { workspaceStorage } from './workspaces.js'; import { DEFAULT_LEETCODE_SITE, normalizeLeetCodeSiteInput } from '../utils/site.js'; +const DEFAULT_THEME: ThemeName = 'auto'; + export const config = { getConfig(): UserConfig { const wsConfig = workspaceStorage.getConfig(); @@ -13,6 +15,7 @@ export const config = { workDir: wsConfig.workDir, repo: wsConfig.syncRepo, site: normalizeLeetCodeSiteInput(wsConfig.site ?? '') ?? DEFAULT_LEETCODE_SITE, + theme: wsConfig.theme ?? DEFAULT_THEME, }; }, @@ -36,6 +39,10 @@ export const config = { workspaceStorage.setConfig({ site }); }, + setTheme(theme: ThemeName): void { + workspaceStorage.setConfig({ theme }); + }, + deleteRepo(): void { const wsConfig = workspaceStorage.getConfig(); delete wsConfig.syncRepo; @@ -64,6 +71,10 @@ export const config = { ); }, + getTheme(): ThemeName { + return workspaceStorage.getConfig().theme ?? DEFAULT_THEME; + }, + getPath(): string { return join(workspaceStorage.getWorkspaceDir(), 'config.json'); }, diff --git a/src/storage/workspaces.ts b/src/storage/workspaces.ts index 0247cff..6655c23 100644 --- a/src/storage/workspaces.ts +++ b/src/storage/workspaces.ts @@ -3,7 +3,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'; import { join } from 'path'; import { homedir } from 'os'; import { isValidWorkspaceName } from '../utils/validation.js'; -import type { LeetCodeSite } from '../types.js'; +import type { LeetCodeSite, ThemeName } from '../types.js'; export interface WorkspaceConfig { workDir: string; @@ -11,6 +11,7 @@ export interface WorkspaceConfig { editor?: string; syncRepo?: string; site?: LeetCodeSite; + theme?: ThemeName; } export interface WorkspaceRegistry { diff --git a/src/tui/commands/effects.ts b/src/tui/commands/effects.ts index 8434abe..d002a9f 100644 --- a/src/tui/commands/effects.ts +++ b/src/tui/commands/effects.ts @@ -18,6 +18,7 @@ import { requestExit } from '../runtime.js'; import got from 'got'; import { configureLeetCodeClientSite } from '../../utils/auth.js'; import { normalizeLeetCodeSiteInput } from '../../utils/site.js'; +import { applyTheme, normalizeThemeInput } from '../theme.js'; const RELEASES_URL = 'https://raw.githubusercontent.com/night-slayer18/leetcode-cli/main/docs/releases.md'; @@ -266,6 +267,14 @@ function saveConfig(key: string, value: string): void { case 'repo': config.setRepo(value); break; + case 'theme': { + const theme = normalizeThemeInput(value); + if (theme) { + config.setTheme(theme); + applyTheme(theme); + } + break; + } } } diff --git a/src/tui/index.ts b/src/tui/index.ts index 1d7c64d..3ae207d 100644 --- a/src/tui/index.ts +++ b/src/tui/index.ts @@ -1,5 +1,7 @@ import { runApp } from './runtime.js'; import { createInitialModel } from './types.js'; +import { applyTheme, primeAutoTheme } from './theme.js'; +import { config } from '../storage/config.js'; interface LaunchOptions { username?: string; @@ -7,6 +9,11 @@ interface LaunchOptions { export async function launchTUI(options: LaunchOptions = {}): Promise { const { username } = options; + const theme = config.getTheme(); + if (theme === 'auto') { + await primeAutoTheme(); + } + applyTheme(theme); const initialModel = createInitialModel(username); await runApp(initialModel); } diff --git a/src/tui/screens/config/index.ts b/src/tui/screens/config/index.ts index a0f886e..a1b6914 100644 --- a/src/tui/screens/config/index.ts +++ b/src/tui/screens/config/index.ts @@ -2,6 +2,7 @@ import type { ConfigScreenModel, ConfigMsg, Command } from '../../types.js'; import { Cmd } from '../../types.js'; import { config } from '../../../storage/config.js'; import { DEFAULT_LEETCODE_SITE, normalizeLeetCodeSiteInput } from '../../../utils/site.js'; +import { normalizeThemeInput, SUPPORTED_THEMES } from '../../theme.js'; type ConfigOption = ConfigScreenModel['options'][number]; @@ -19,6 +20,12 @@ function buildOptions(currentConfig: ReturnType): Confi description: 'Target site for API operations (leetcode.com or leetcode.cn)', value: currentConfig.site || DEFAULT_LEETCODE_SITE, }, + { + id: 'theme', + label: 'Color Theme', + description: `Color palette for the TUI (${SUPPORTED_THEMES.join(', ')})`, + value: currentConfig.theme ?? 'auto', + }, { id: 'editor', label: 'Editor Command', @@ -55,6 +62,9 @@ function validate(option: ConfigOption, value: string): string | null { if (option.id === 'site' && !normalizeLeetCodeSiteInput(trimmed)) { return 'Site must be leetcode.com or leetcode.cn'; } + if (option.id === 'theme' && !normalizeThemeInput(trimmed)) { + return `Theme must be one of: ${SUPPORTED_THEMES.join(', ')}`; + } return null; } diff --git a/src/tui/screens/config/view.ts b/src/tui/screens/config/view.ts index d1a1a08..ecf7d47 100644 --- a/src/tui/screens/config/view.ts +++ b/src/tui/screens/config/view.ts @@ -14,6 +14,7 @@ import { colors, borders, icons } from '../../theme.js'; const EXAMPLES: Record = { language: 'Example: typescript, python3, cpp, sql', site: 'Example: leetcode.com or leetcode.cn', + theme: 'Example: dark, light, auto', editor: 'Example: code, zed, vim, nvim', workdir: 'Example: /Users/name/leetcode', repo: 'Example: https://github.com/user/leetcode.git', @@ -105,7 +106,12 @@ function renderOptionDetails(model: ConfigScreenModel, width: number): string[] lines.push(chalk.hex(colors.textMuted)(truncate(EXAMPLES[option.id] || '', paneWidth - 2))); lines.push(''); - if (option.id === 'language' || option.id === 'workdir' || option.id === 'site') { + if ( + option.id === 'language' || + option.id === 'workdir' || + option.id === 'site' || + option.id === 'theme' + ) { lines.push(chalk.hex(colors.textMuted)('Validation: required')); } else { lines.push(chalk.hex(colors.textMuted)('Validation: optional')); diff --git a/src/tui/theme.ts b/src/tui/theme.ts index 2df81e0..ce8dc0d 100644 --- a/src/tui/theme.ts +++ b/src/tui/theme.ts @@ -1,4 +1,30 @@ -export const colors = { +export type ThemeName = 'dark' | 'light' | 'auto'; + +export const SUPPORTED_THEMES: readonly ThemeName[] = ['dark', 'light', 'auto']; + +export interface Palette { + primary: string; + primaryDark: string; + secondary: string; + success: string; + warning: string; + error: string; + info: string; + text: string; + textMuted: string; + textDim: string; + bg: string; + panel: string; + border: string; + borderFocus: string; + textBright: string; + bgHighlight: string; + cyan: string; + orange: string; + purple: string; +} + +export const darkPalette: Palette = { primary: '#00B8D4', primaryDark: '#006064', secondary: '#FF4081', @@ -20,6 +46,149 @@ export const colors = { purple: '#9C27B0', }; +export const lightPalette: Palette = { + primary: '#00838F', + primaryDark: '#004D5A', + secondary: '#C2185B', + success: '#1B7A2E', + warning: '#B26A00', + error: '#C62828', + info: '#1565C0', + text: '#1C1C1C', + textMuted: '#4A5860', + textDim: '#78909C', + bg: '#FFFFFF', + panel: '#F5F7F8', + border: '#B0BEC5', + borderFocus: '#00838F', + textBright: '#000000', + bgHighlight: '#E1ECF0', + cyan: '#00838F', + orange: '#E65100', + purple: '#6A1B9A', +}; + +/** + * The active palette. Consumers destructure `colors` at import time, so + * we keep the same object identity and mutate fields when the theme changes. + */ +export const colors: Palette = { ...darkPalette }; + +let cachedAutoTheme: 'dark' | 'light' | null = null; + +function parseOsc11(response: string): 'dark' | 'light' | null { + // Terminals reply with: \x1b]11;rgb:RRRR/GGGG/BBBB\x07 (or ST \x1b\\) + const match = response.match(/rgb:([0-9a-f]+)\/([0-9a-f]+)\/([0-9a-f]+)/i); + if (!match) return null; + + const scale = (h: string) => { + const n = Number.parseInt(h, 16); + if (!Number.isFinite(n)) return NaN; + // Normalize regardless of channel width (2, 4, ... hex digits). + return n / (16 ** h.length - 1); + }; + const r = scale(match[1]); + const g = scale(match[2]); + const b = scale(match[3]); + if (![r, g, b].every(Number.isFinite)) return null; + + // Perceived luminance (Rec. 709). + const luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b; + return luminance > 0.5 ? 'light' : 'dark'; +} + +/** + * Ask the terminal for its background color via OSC 11. Times out quickly so + * unsupported terminals fall through to COLORFGBG / dark. + */ +export async function detectTerminalTheme(timeoutMs = 100): Promise<'dark' | 'light' | null> { + const { stdin, stdout } = process; + if (!stdin.isTTY || !stdout.isTTY) return null; + + return new Promise((resolve) => { + let buffer = ''; + let done = false; + const wasRaw = stdin.isRaw; + + const finish = (result: 'dark' | 'light' | null) => { + if (done) return; + done = true; + clearTimeout(timer); + stdin.removeListener('data', onData); + try { + stdin.setRawMode(wasRaw); + } catch { + // ignore + } + if (!wasRaw) stdin.pause(); + resolve(result); + }; + + const onData = (chunk: Buffer) => { + buffer += chunk.toString('utf8'); + const parsed = parseOsc11(buffer); + if (parsed) finish(parsed); + else if (buffer.length > 128) finish(null); + }; + + try { + stdin.setRawMode(true); + } catch { + resolve(null); + return; + } + stdin.resume(); + stdin.on('data', onData); + + const timer = setTimeout(() => finish(null), timeoutMs); + stdout.write('\x1b]11;?\x1b\\'); + }); +} + +function resolveFromEnv(): 'dark' | 'light' | null { + // COLORFGBG is set by rxvt-style terminals as "fg;bg" (e.g. "15;0" dark, "0;15" light). + const raw = process.env.COLORFGBG; + if (!raw) return null; + const parts = raw.split(';'); + const bg = parts[parts.length - 1]; + const n = Number.parseInt(bg, 10); + if (!Number.isFinite(n)) return null; + return n >= 7 && n <= 15 ? 'light' : 'dark'; +} + +/** + * Run terminal-theme detection once and cache it. Call this before + * `applyTheme('auto')` so the async probe can inform the palette choice. + */ +export async function primeAutoTheme(): Promise { + if (cachedAutoTheme) return; + const detected = await detectTerminalTheme(); + cachedAutoTheme = detected ?? resolveFromEnv() ?? 'dark'; +} + +function resolveAuto(): 'dark' | 'light' { + return cachedAutoTheme ?? resolveFromEnv() ?? 'dark'; +} + +export function resolveTheme(name: ThemeName | undefined): 'dark' | 'light' { + if (name === 'light') return 'light'; + if (name === 'dark') return 'dark'; + return resolveAuto(); +} + +export function applyTheme(name: ThemeName | undefined): void { + const resolved = resolveTheme(name); + const source = resolved === 'light' ? lightPalette : darkPalette; + Object.assign(colors, source); +} + +export function normalizeThemeInput(input: string): ThemeName | null { + const normalized = input.trim().toLowerCase(); + return (SUPPORTED_THEMES as readonly string[]).includes(normalized) + ? (normalized as ThemeName) + : null; +} + export const icons = { check: '✔', cross: '✖', diff --git a/src/types.ts b/src/types.ts index aa7be76..8698ac9 100644 --- a/src/types.ts +++ b/src/types.ts @@ -177,10 +177,13 @@ export type SupportedLanguage = export type LeetCodeSite = 'leetcode.com' | 'leetcode.cn'; +export type ThemeName = 'dark' | 'light' | 'auto'; + export interface UserConfig { language: SupportedLanguage; editor?: string; workDir: string; repo?: string; site?: LeetCodeSite; + theme?: ThemeName; } From c336b471ab29ef402687b5a51d9f3dd85f8d82a6 Mon Sep 17 00:00:00 2001 From: Jonatan Bakucz Date: Tue, 4 Aug 2026 13:50:58 +0200 Subject: [PATCH 2/2] Add theme tests and README documentation --- README.md | 9 ++++ src/__tests__/tui/theme.test.ts | 90 +++++++++++++++++++++++++++++++++ 2 files changed, 99 insertions(+) create mode 100644 src/__tests__/tui/theme.test.ts diff --git a/README.md b/README.md index 2d7e41a..2c365af 100644 --- a/README.md +++ b/README.md @@ -427,8 +427,17 @@ leetcode config --lang sql leetcode config --editor code leetcode config --workdir ~/leetcode leetcode config --repo https://github.com/username/leetcode-solutions.git + +# Pick a color theme for the TUI (dark, light, or auto) +leetcode config --theme light +leetcode config --theme dark +leetcode config --theme auto ``` +The `--theme` setting is workspace-scoped. `auto` (the default) probes the +terminal for its background color via OSC 11 and falls back to `COLORFGBG`, +then to `dark`, if the terminal doesn't reply. + ## Folder Structure Solution files are automatically organized by difficulty and category: diff --git a/src/__tests__/tui/theme.test.ts b/src/__tests__/tui/theme.test.ts new file mode 100644 index 0000000..fa92344 --- /dev/null +++ b/src/__tests__/tui/theme.test.ts @@ -0,0 +1,90 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { + applyTheme, + colors, + darkPalette, + lightPalette, + normalizeThemeInput, + resolveTheme, + SUPPORTED_THEMES, +} from '../../tui/theme.js'; + +const ORIGINAL_COLORFGBG = process.env.COLORFGBG; + +describe('TUI theme', () => { + beforeEach(() => { + delete process.env.COLORFGBG; + applyTheme('dark'); + }); + + afterEach(() => { + if (ORIGINAL_COLORFGBG === undefined) { + delete process.env.COLORFGBG; + } else { + process.env.COLORFGBG = ORIGINAL_COLORFGBG; + } + applyTheme('dark'); + }); + + describe('normalizeThemeInput', () => { + it('accepts every supported theme name', () => { + for (const name of SUPPORTED_THEMES) { + expect(normalizeThemeInput(name)).toBe(name); + } + }); + + it('is case-insensitive and trims whitespace', () => { + expect(normalizeThemeInput(' LIGHT ')).toBe('light'); + expect(normalizeThemeInput('Dark')).toBe('dark'); + expect(normalizeThemeInput('AUTO')).toBe('auto'); + }); + + it('returns null for unsupported input', () => { + expect(normalizeThemeInput('solarized')).toBeNull(); + expect(normalizeThemeInput('')).toBeNull(); + }); + }); + + describe('resolveTheme', () => { + it('returns the explicit palette for dark/light', () => { + expect(resolveTheme('dark')).toBe('dark'); + expect(resolveTheme('light')).toBe('light'); + }); + + it('falls back to dark when auto has no signal', () => { + expect(resolveTheme('auto')).toBe('dark'); + expect(resolveTheme(undefined)).toBe('dark'); + }); + + it('detects light terminals via COLORFGBG background code >= 7', () => { + process.env.COLORFGBG = '0;15'; + expect(resolveTheme('auto')).toBe('light'); + }); + + it('detects dark terminals via COLORFGBG background code < 7', () => { + process.env.COLORFGBG = '15;0'; + expect(resolveTheme('auto')).toBe('dark'); + }); + }); + + describe('applyTheme', () => { + it('swaps the mutable colors palette to light', () => { + applyTheme('light'); + expect(colors.bg).toBe(lightPalette.bg); + expect(colors.text).toBe(lightPalette.text); + }); + + it('restores the dark palette', () => { + applyTheme('light'); + applyTheme('dark'); + expect(colors.bg).toBe(darkPalette.bg); + expect(colors.text).toBe(darkPalette.text); + }); + + it('keeps the same object identity so importers keep working', () => { + const before = colors; + applyTheme('light'); + expect(colors).toBe(before); + }); + }); +});