Skip to content

feat(tui): add user-defined custom theme system with disk persistence#2531

Closed
HUQIANTAO wants to merge 1 commit into
Hmbown:mainfrom
HUQIANTAO:feat/custom-user-themes
Closed

feat(tui): add user-defined custom theme system with disk persistence#2531
HUQIANTAO wants to merge 1 commit into
Hmbown:mainfrom
HUQIANTAO:feat/custom-user-themes

Conversation

@HUQIANTAO
Copy link
Copy Markdown
Contributor

@HUQIANTAO HUQIANTAO commented Jun 1, 2026

Summary

Implements a complete custom-theme subsystem enabling users to define, preview, save, and delete personal TUI themes that persist across application updates. Custom themes are stored as JSON files under ~/.codewhale/themes/ and loaded at startup alongside the 12 built-in presets.

This PR implements the forward-looking plan described in #2527 (sidebar theme-switch fix and theme architecture guide).

Merge order: this PR must be merged after #2527. The two branches touch adjacent lines in theme_picker.rs, palette.rs, color_compat.rs, and ui.rs. Merging #2527 first ensures a clean linear history with no conflicts in the ThemeId type signature changes.

Motivation

The current theme system supports 12 built-in presets selected from a compile-time ThemeId enum. Users with specific visual preferences (accessibility needs, contrast requirements, personal aesthetic, brand alignment) cannot adjust colors beyond picking one of the fixed options. This PR introduces an end-to-end workflow for user-defined themes: describe a theme in natural language, have CodeWhale generate the complete colour definition, preview it live, save it to disk, and manage it alongside built-in presets.

Usage

Creating a custom theme

/theme-describe A warm amber library theme with dark brown background, cream text, soft gold accents, moderate contrast dark mode

Aliases for the same command: /theme-create, /theme-new, /theme-generate.

CodeWhale receives a structured prompt containing the full 40-field UiTheme reference alongside the user's description. The AI generates a complete JSON theme definition with all colour slots populated. The user then saves the JSON to ~/.codewhale/themes/<name>.json, restarts CodeWhale, and the theme appears in the /theme picker after the built-in entries, marked with a ✎ prefix.

Selecting a custom theme

Open the theme picker (/theme with no argument), arrow down past the built-in entries, and press Enter on the desired custom theme. The selection is saved to settings.toml and persists across sessions.

Deleting a custom theme

Two paths are provided:

  1. Theme picker Delete key: in the /theme picker, custom theme rows display a [Del] affordance in the bottom hint bar. Pressing Delete or Backspace on a highlighted custom theme removes the JSON file from disk, unregisters the theme from the in-memory registry, and removes its row from the picker. Built-in themes ignore the Delete key.

  2. CLI command: /theme delete <name> performs the same operation. If the deleted theme was currently active, the app falls back to the System theme to prevent a blank UI state.

Architecture

Storage

Custom themes live as JSON files under ~/.codewhale/themes/<name>.json. Each file is a complete UiThemeCustom definition with all 40 colour fields serialized as hex strings ("#RRGGBB"). The mode field accepts dark, light, grayscale, or solarized-light. Unspecified fields default to the Whale dark palette value, ensuring forward compatibility when future versions add new fields.

Serialization layer (custom_theme.rs, new)

A dedicated UiThemeCustom serde struct bridges the JSON representation and the internal UiTheme. Every colour field carries a #[serde(default)] with a Whale dark palette fallback so partial JSON files remain loadable. The color_to_hex() and hex_to_color() conversion functions handle Color::Rgb round-tripping with graceful fallback for non-RGB variants (Color::Reset, named ANSI colors). Atomic file I/O (write to temp file, rename) prevents corruption on crash.

Runtime loading

At application startup (ui.rs:run()), palette::load_custom_themes() scans the themes directory and populates a static CUSTOM_THEMES: RwLock<HashMap<String, UiTheme>> registry. This call is placed before App::new() so custom theme names persisted in settings.toml resolve correctly during app construction.

ThemeId extension

ThemeId gains a Custom(String) variant carrying the theme's file-name stem. The existing 12 enum variants remain unchanged, preserving all existing match arms. from_name() checks the custom registry as a fallback after built-in names. name(), display_name(), tagline(), and ui_theme() all handle the Custom case. is_custom() enables targeted UI affordances (delete hint, pencil prefix).

Since Custom(String) prevents ThemeId from deriving Copy, the following downstream functions were updated to accept &ThemeId:

  • palette::theme_remap_active(), adapt_fg_for_theme(), adapt_bg_for_theme()
  • color_compat::adapt_cell_colors() and its test callers
  • color_compat::ColorCompatBackend::draw() — clones theme_id into closure

Dynamic theme list

The selectable_themes() function replaces the static SELECTABLE_THEMES constant as the picker's data source. It returns built-in themes followed by custom themes in alphabetical order. The deprecated SELECTABLE_THEMES constant is retained for backward compatibility.

Guided creation flow (/theme-describe)

The theme_describe() command constructs a structured prompt containing the complete UiTheme field reference (all 40 slots grouped by semantic role) alongside the user's natural-language description. The prompt instructs the AI to output only valid JSON with no markdown wrapping, no code fences, and no explanatory text so the response is machine-parseable. The command is routed through AppAction::SendMessage, which dispatches it through the normal conversation flow.

Deletion

Both the picker Delete key and /theme delete <name> CLI path delegate to custom_theme::delete_custom_theme() for file removal and palette::unregister_custom_theme() for in-memory cleanup. The active-theme fallback to System prevents edge cases where the deleted theme name lingers in app.theme_id after the underlying JSON is gone.

Settings integration

settings.rs validate() now accepts custom theme names (1 to 64 characters) in addition to the built-in list. normalize_settings_theme() returns a String rather than &'static str to accommodate custom names without leaking memory. config_ui.rs UiThemeValue gained the complete set of missing built-in variants (Terminal, Claude, SolarizedLight) alongside a Custom(String) variant, and its as_setting() method returns String.

Version resilience

Custom themes are stored in ~/.codewhale/themes/, a directory outside the application bundle. The CodeWhale updater and installer never touch this directory. If a future version adds new colour fields to UiTheme, the UiThemeCustom deserializer applies its per-field #[serde(default)] fallbacks and the existing fields continue to work without user intervention.

Commands reference

Command Behaviour
/theme Open the interactive picker (arrow keys preview, Enter saves, Esc reverts)
/theme <name> Switch to a known theme by its settings name
/theme-describe <description> Generate a custom theme from a natural-language description
/theme-create <description> Alias for /theme-describe
/theme-new <description> Alias for /theme-describe
/theme-generate <description> Alias for /theme-describe
/theme delete <name> Delete a custom theme from disk and registry
/theme new Print usage instructions for theme creation

Files changed

File Change
crates/tui/src/custom_theme.rs New. UiThemeCustom serde type with 40 per-field #[serde(default)] fallbacks, load_custom_themes(), save_custom_theme(), delete_custom_theme(), color_to_hex()/hex_to_color(), parse_mode()/mode_to_string(), atomic file I/O with temp+rename, UNIX/Windows home-dir resolution, 6 unit tests
crates/tui/src/palette.rs ThemeId::Custom(String) variant, CUSTOM_THEMES static RwLock registry, load/register/unregister_custom_theme(), selectable_themes() dynamic list, is_custom(), name()/display_name()/tagline() return String, ui_theme() resolves Custom, from_name() checks custom registry fallback, theme_remap_active/adapt_fg_for_theme/adapt_bg_for_theme accept &ThemeId, from_setting() uses .as_ref().map()
crates/tui/src/tui/theme_picker.rs Key change: Vec<ThemeId> list from selectable_themes() replacing static SELECTABLE_THEMES, Delete/Backspace key handler with file+registry cleanup, [Del] affordance in bottom hint bar, ✎ prefix for Custom display names, all indexing refactored to .themes field, ui_theme_for() takes &ThemeId, 10 unit tests
crates/tui/src/commands/config.rs theme_describe() command building structured prompt with full UiTheme reference, updated /theme new message referencing real command, /theme delete <name> handler with active-theme System fallback, delete_custom_theme() helper
crates/tui/src/commands/mod.rs CommandInfo entry for theme-describe with aliases theme-create/theme-new/theme-generate, routing match arm
crates/tui/src/localization.rs CmdThemeDescribe message ID and translations in all 7 supported languages (en, ja, zh-Hans, pt-BR, es-419, vi, ko placeholder pattern)
crates/tui/src/settings.rs normalize_settings_theme() returns String, validate() accepts 1-64 char custom names and rejects empty or overly long names, test update: tui_prefs_validate_accepts_unknown_theme_as_custom replaces tui_prefs_validate_rejects_unknown_theme, new tui_prefs_validate_rejects_overly_long_theme_name test
crates/tui/src/config_ui.rs UiThemeValue gains Terminal, Claude, SolarizedLight, Custom(String) variants; as_setting() returns String; from_setting() accepts unknown names as Custom; schema_contains_typed_enums test updated for oneOf schema structure
crates/tui/src/tui/ui.rs load_custom_themes() call placed before App::new(), app.theme_id.clone() for set_theme
crates/tui/src/tui/color_compat.rs adapt_cell_colors() takes &ThemeId, draw() clones theme_id into closure, test callers pass &ThemeId::*
crates/tui/src/main.rs mod custom_theme; declaration

Testing

3827 unit tests pass. The single failure (mcp::tests::session_id_captured_from_post_response_and_replayed) is a pre-existing flaky MCP HTTP test present on main and unrelated to this change.

All 44 sidebar tests pass, all 10 theme picker tests pass, all 6 custom_theme module tests pass, all 51 config command tests pass.

@gemini-code-assist
Copy link
Copy Markdown
Contributor

Warning

You have reached your daily quota limit. Please wait up to 24 hours and I will start processing your requests again!

Copy link
Copy Markdown
Contributor

@greptile-apps greptile-apps Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

HUQIANTAO has reached the 50-review limit for trial accounts. To continue receiving code reviews, upgrade your plan.

@HUQIANTAO HUQIANTAO force-pushed the feat/custom-user-themes branch from df81e51 to 45bdde6 Compare June 1, 2026 18:31
Copy link
Copy Markdown
Contributor

@greptile-apps greptile-apps Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

HUQIANTAO has reached the 50-review limit for trial accounts. To continue receiving code reviews, upgrade your plan.

@HUQIANTAO HUQIANTAO force-pushed the feat/custom-user-themes branch from 45bdde6 to f574cd6 Compare June 1, 2026 18:35
Copy link
Copy Markdown
Contributor

@greptile-apps greptile-apps Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

HUQIANTAO has reached the 50-review limit for trial accounts. To continue receiving code reviews, upgrade your plan.

@HUQIANTAO HUQIANTAO force-pushed the feat/custom-user-themes branch from f574cd6 to 19bff33 Compare June 1, 2026 18:44
Copy link
Copy Markdown
Contributor

@greptile-apps greptile-apps Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

HUQIANTAO has reached the 50-review limit for trial accounts. To continue receiving code reviews, upgrade your plan.

Add a custom-theme subsystem enabling users to define, save, preview,
and delete personal themes that survive application updates.

Architecture:
- Custom themes are stored as JSON files under ~/.codewhale/themes/<name>.json
  using the new UiThemeCustom serialization format with hex colour strings.
- ThemeId gains a Custom(String) variant that resolves from the in-memory
  registry (CUSTOM_THEMES RwLock<HashMap>) populated at startup.
- The theme picker now uses a dynamic selectable_themes() list appending
  custom themes after the 12 built-in entries.
- Custom themes display a pencil prefix (✎) and a [Del] hint in the picker.
  Delete/Backspace key removes the file from disk and the entry from the
  registry. Built-in themes are not deletable.
- /theme new guides users through the creation flow.
- /theme delete <name> provides a CLI deletion path with a fallback-to-System
  safety net when the active theme is deleted.

Changes:
- custom_theme.rs: UiThemeCustom (serde), load/save/delete file I/O,
  hex ↔ Color conversion, parse_mode, atomic write, 6 unit tests
- palette.rs: ThemeId::Custom(String), CUSTOM_THEMES registry,
  load/register/unregister_custom_theme(), selectable_themes(),
  name()/display_name()/tagline()/ui_theme() handle Custom,
  adapt_fg_for_theme/adapt_bg_for_theme accept &ThemeId
- theme_picker.rs: dynamic themes Vec, Delete key handler,
  [Del] affordance in bottom hint bar, 10 tests
- commands/config.rs: /theme new flow + /theme delete <name> handler
- settings.rs: validate() and normalize_settings_theme() accept
  plausible custom theme names (1-64 chars)
- config_ui.rs: UiThemeValue gains missing built-in variants +
  Custom(String); as_setting() returns String
- ui.rs: load_custom_themes() called before App::new()
- color_compat.rs: adapt_cell_colors takes &ThemeId
@HUQIANTAO HUQIANTAO force-pushed the feat/custom-user-themes branch from 19bff33 to b839cf1 Compare June 1, 2026 18:54
Copy link
Copy Markdown
Contributor

@greptile-apps greptile-apps Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

HUQIANTAO has reached the 50-review limit for trial accounts. To continue receiving code reviews, upgrade your plan.

@Hmbown
Copy link
Copy Markdown
Owner

Hmbown commented Jun 3, 2026

This PR was harvested into v0.8.50. Closing as already landed.

@Hmbown Hmbown closed this Jun 3, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants