feat(tui): add user-defined custom theme system with disk persistence#2531
feat(tui): add user-defined custom theme system with disk persistence#2531HUQIANTAO wants to merge 1 commit into
Conversation
|
Warning You have reached your daily quota limit. Please wait up to 24 hours and I will start processing your requests again! |
There was a problem hiding this comment.
HUQIANTAO has reached the 50-review limit for trial accounts. To continue receiving code reviews, upgrade your plan.
df81e51 to
45bdde6
Compare
There was a problem hiding this comment.
HUQIANTAO has reached the 50-review limit for trial accounts. To continue receiving code reviews, upgrade your plan.
45bdde6 to
f574cd6
Compare
There was a problem hiding this comment.
HUQIANTAO has reached the 50-review limit for trial accounts. To continue receiving code reviews, upgrade your plan.
f574cd6 to
19bff33
Compare
There was a problem hiding this comment.
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
19bff33 to
b839cf1
Compare
There was a problem hiding this comment.
HUQIANTAO has reached the 50-review limit for trial accounts. To continue receiving code reviews, upgrade your plan.
|
This PR was harvested into v0.8.50. Closing as already landed. |
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).
Motivation
The current theme system supports 12 built-in presets selected from a compile-time
ThemeIdenum. 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
Aliases for the same command:
/theme-create,/theme-new,/theme-generate.CodeWhale receives a structured prompt containing the full 40-field
UiThemereference 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/themepicker after the built-in entries, marked with a ✎ prefix.Selecting a custom theme
Open the theme picker (
/themewith no argument), arrow down past the built-in entries, and press Enter on the desired custom theme. The selection is saved tosettings.tomland persists across sessions.Deleting a custom theme
Two paths are provided:
Theme picker Delete key: in the
/themepicker, 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.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 completeUiThemeCustomdefinition with all 40 colour fields serialized as hex strings ("#RRGGBB"). Themodefield acceptsdark,light,grayscale, orsolarized-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
UiThemeCustomserde struct bridges the JSON representation and the internalUiTheme. Every colour field carries a#[serde(default)]with a Whale dark palette fallback so partial JSON files remain loadable. Thecolor_to_hex()andhex_to_color()conversion functions handleColor::Rgbround-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 astatic CUSTOM_THEMES: RwLock<HashMap<String, UiTheme>>registry. This call is placed beforeApp::new()so custom theme names persisted insettings.tomlresolve correctly during app construction.ThemeId extension
ThemeIdgains aCustom(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(), andui_theme()all handle theCustomcase.is_custom()enables targeted UI affordances (delete hint, pencil prefix).Since
Custom(String)preventsThemeIdfrom derivingCopy, 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 callerscolor_compat::ColorCompatBackend::draw()— clonestheme_idinto closureDynamic theme list
The
selectable_themes()function replaces the staticSELECTABLE_THEMESconstant as the picker's data source. It returns built-in themes followed by custom themes in alphabetical order. The deprecatedSELECTABLE_THEMESconstant is retained for backward compatibility.Guided creation flow (
/theme-describe)The
theme_describe()command constructs a structured prompt containing the completeUiThemefield 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 throughAppAction::SendMessage, which dispatches it through the normal conversation flow.Deletion
Both the picker Delete key and
/theme delete <name>CLI path delegate tocustom_theme::delete_custom_theme()for file removal andpalette::unregister_custom_theme()for in-memory cleanup. The active-theme fallback to System prevents edge cases where the deleted theme name lingers inapp.theme_idafter the underlying JSON is gone.Settings integration
settings.rsvalidate()now accepts custom theme names (1 to 64 characters) in addition to the built-in list.normalize_settings_theme()returns aStringrather than&'static strto accommodate custom names without leaking memory.config_ui.rsUiThemeValuegained the complete set of missing built-in variants (Terminal,Claude,SolarizedLight) alongside aCustom(String)variant, and itsas_setting()method returnsString.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 toUiTheme, theUiThemeCustomdeserializer applies its per-field#[serde(default)]fallbacks and the existing fields continue to work without user intervention.Commands reference
/theme/theme <name>/theme-describe <description>/theme-create <description>/theme-describe/theme-new <description>/theme-describe/theme-generate <description>/theme-describe/theme delete <name>/theme newFiles changed
crates/tui/src/custom_theme.rsUiThemeCustomserde 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 testscrates/tui/src/palette.rsThemeId::Custom(String)variant,CUSTOM_THEMESstaticRwLockregistry,load/register/unregister_custom_theme(),selectable_themes()dynamic list,is_custom(),name()/display_name()/tagline()returnString,ui_theme()resolvesCustom,from_name()checks custom registry fallback,theme_remap_active/adapt_fg_for_theme/adapt_bg_for_themeaccept&ThemeId,from_setting()uses.as_ref().map()crates/tui/src/tui/theme_picker.rsVec<ThemeId>list fromselectable_themes()replacing staticSELECTABLE_THEMES, Delete/Backspace key handler with file+registry cleanup,[Del]affordance in bottom hint bar, ✎ prefix forCustomdisplay names, all indexing refactored to.themesfield,ui_theme_for()takes&ThemeId, 10 unit testscrates/tui/src/commands/config.rstheme_describe()command building structured prompt with full UiTheme reference, updated/theme newmessage referencing real command,/theme delete <name>handler with active-theme System fallback,delete_custom_theme()helpercrates/tui/src/commands/mod.rsCommandInfoentry fortheme-describewith aliasestheme-create/theme-new/theme-generate, routing match armcrates/tui/src/localization.rsCmdThemeDescribemessage ID and translations in all 7 supported languages (en, ja, zh-Hans, pt-BR, es-419, vi, ko placeholder pattern)crates/tui/src/settings.rsnormalize_settings_theme()returnsString,validate()accepts 1-64 char custom names and rejects empty or overly long names, test update:tui_prefs_validate_accepts_unknown_theme_as_customreplacestui_prefs_validate_rejects_unknown_theme, newtui_prefs_validate_rejects_overly_long_theme_nametestcrates/tui/src/config_ui.rsUiThemeValuegainsTerminal,Claude,SolarizedLight,Custom(String)variants;as_setting()returnsString;from_setting()accepts unknown names asCustom;schema_contains_typed_enumstest updated for oneOf schema structurecrates/tui/src/tui/ui.rsload_custom_themes()call placed beforeApp::new(),app.theme_id.clone()forset_themecrates/tui/src/tui/color_compat.rsadapt_cell_colors()takes&ThemeId,draw()clonestheme_idinto closure, test callers pass&ThemeId::*crates/tui/src/main.rsmod custom_theme;declarationTesting
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.