diff --git a/openhands_cli/stores/__init__.py b/openhands_cli/stores/__init__.py index 6d052389..6484d89d 100644 --- a/openhands_cli/stores/__init__.py +++ b/openhands_cli/stores/__init__.py @@ -5,6 +5,7 @@ ) from openhands_cli.stores.cli_settings import ( DEFAULT_MAX_REFINEMENT_ITERATIONS, + VALID_THEMES, CliSettings, CriticSettings, ) @@ -16,5 +17,6 @@ "CriticSettings", "DEFAULT_MAX_REFINEMENT_ITERATIONS", "MissingEnvironmentVariablesError", + "VALID_THEMES", "check_and_warn_env_vars", ] diff --git a/openhands_cli/stores/cli_settings.py b/openhands_cli/stores/cli_settings.py index 709ba3e5..ef333317 100644 --- a/openhands_cli/stores/cli_settings.py +++ b/openhands_cli/stores/cli_settings.py @@ -5,6 +5,7 @@ from pathlib import Path from pydantic import BaseModel, field_validator +from textual.theme import BUILTIN_THEMES # Refinement triggers when predicted success probability falls below this threshold @@ -46,13 +47,27 @@ def validate_max_iterations(cls, v: int) -> int: return v +VALID_THEMES: set[str] = {"openhands"} | set(BUILTIN_THEMES.keys()) + + class CliSettings(BaseModel): """Model for CLI-level settings.""" default_cells_expanded: bool = False auto_open_plan_panel: bool = True + theme: str = "openhands" critic: CriticSettings = CriticSettings() + @field_validator("theme") + @classmethod + def validate_theme(cls, v: str) -> str: + """Validate that theme is a known Textual built-in or 'openhands'.""" + if v not in VALID_THEMES: + raise ValueError( + f"Unknown theme '{v}'. Must be one of: {sorted(VALID_THEMES)}" + ) + return v + @classmethod def get_config_path(cls) -> Path: """Get the path to the CLI configuration file.""" diff --git a/openhands_cli/tui/modals/settings/components/cli_settings_tab.py b/openhands_cli/tui/modals/settings/components/cli_settings_tab.py index 815797f0..85f088e3 100644 --- a/openhands_cli/tui/modals/settings/components/cli_settings_tab.py +++ b/openhands_cli/tui/modals/settings/components/cli_settings_tab.py @@ -4,9 +4,9 @@ from textual.app import ComposeResult from textual.containers import Container, Horizontal, VerticalScroll -from textual.widgets import Label, Static, Switch +from textual.widgets import Label, Select, Static, Switch -from openhands_cli.stores.cli_settings import CliSettings +from openhands_cli.stores.cli_settings import VALID_THEMES, CliSettings class SettingsSwitch(Container): @@ -57,6 +57,8 @@ def __init__(self, initial_settings: CliSettings | None = None, **kwargs): def compose(self) -> ComposeResult: """Compose the CLI settings tab content.""" + theme_options = [(name, name) for name in sorted(VALID_THEMES)] + with VerticalScroll(id="cli_settings_content"): yield Static("CLI Settings", classes="form_section_title") @@ -82,11 +84,28 @@ def compose(self) -> ComposeResult: value=self._initial_settings.auto_open_plan_panel, ) + with Container(classes="form_group"): + yield Label("Theme:", classes="form_label") + yield Select( + theme_options, + value=self._initial_settings.theme, + id="theme_select", + classes="form_select", + type_to_search=True, + ) + yield Static( + "Choose the TUI color theme. The custom 'openhands' theme " + "is the default. Textual built-in themes like 'dracula', " + "'nord', 'catppuccin-mocha', etc. are also available.", + classes="form_help", + ) + def get_updated_fields(self) -> dict[str, Any]: """Return only the fields this tab manages. Returns: - Dict with 'default_cells_expanded' and 'auto_open_plan_panel' values. + Dict with 'default_cells_expanded', 'auto_open_plan_panel', and + 'theme' values. """ return { "default_cells_expanded": self.query_one( @@ -95,4 +114,5 @@ def get_updated_fields(self) -> dict[str, Any]: "auto_open_plan_panel": self.query_one( "#auto_open_plan_panel_switch", Switch ).value, + "theme": self.query_one("#theme_select", Select).value, } diff --git a/openhands_cli/tui/textual_app.py b/openhands_cli/tui/textual_app.py index 551ed777..77576529 100644 --- a/openhands_cli/tui/textual_app.py +++ b/openhands_cli/tui/textual_app.py @@ -209,11 +209,11 @@ def __init__( self.plan_panel: PlanSidePanel = PlanSidePanel(self) - # Register the custom theme + # Register the custom theme (always available as an option) self.register_theme(OPENHANDS_THEME) - # Set the theme as active - self.theme = "openhands" + # Set the active theme from CLI settings + self.theme = cli_settings.theme CSS_PATH = "textual_app.tcss" diff --git a/tests/tui/modals/settings/test_cli_settings.py b/tests/tui/modals/settings/test_cli_settings.py index b94b13a8..01a49eb2 100644 --- a/tests/tui/modals/settings/test_cli_settings.py +++ b/tests/tui/modals/settings/test_cli_settings.py @@ -7,6 +7,7 @@ from pydantic import ValidationError from openhands_cli.stores import CliSettings, CriticSettings +from openhands_cli.stores.cli_settings import VALID_THEMES class TestCriticSettingsValidation: @@ -89,6 +90,7 @@ def test_defaults(self): cfg = CliSettings() assert cfg.default_cells_expanded is False assert cfg.auto_open_plan_panel is True + assert cfg.theme == "openhands" assert cfg.critic.enable_critic is True assert cfg.critic.enable_iterative_refinement is False assert cfg.critic.critic_threshold == 0.6 @@ -100,6 +102,31 @@ def test_default_cells_expanded_accepts_bool(self, value: bool): cfg = CliSettings(default_cells_expanded=value) assert cfg.default_cells_expanded is value + @pytest.mark.parametrize("theme", ["openhands", "dracula", "nord", "tokyo-night"]) + def test_theme_accepts_valid_values(self, theme: str): + cfg = CliSettings(theme=theme) + assert cfg.theme == theme + + def test_theme_rejects_unknown_value(self): + with pytest.raises(ValidationError) as exc_info: + CliSettings(theme="nonexistent-theme") + assert "Unknown theme" in str(exc_info.value) + + def test_valid_themes_contains_openhands_and_builtins(self): + assert "openhands" in VALID_THEMES + assert "dracula" in VALID_THEMES + assert "nord" in VALID_THEMES + + def test_theme_roundtrip(self, tmp_path: Path): + config_path = tmp_path / "cli_config.json" + cfg = CliSettings(theme="dracula") + + with patch.object(CliSettings, "get_config_path", return_value=config_path): + cfg.save() + loaded = CliSettings.load() + + assert loaded.theme == "dracula" + @pytest.mark.parametrize( "env_value, expected", [ @@ -186,6 +213,7 @@ def test_save_writes_expected_json_format(self, tmp_path: Path): cfg = CliSettings( default_cells_expanded=False, auto_open_plan_panel=False, + theme="openhands", critic=CriticSettings( enable_critic=False, enable_iterative_refinement=False, @@ -202,6 +230,7 @@ def test_save_writes_expected_json_format(self, tmp_path: Path): { "default_cells_expanded": False, "auto_open_plan_panel": False, + "theme": "openhands", "critic": { "enable_critic": False, "enable_iterative_refinement": False, diff --git a/tests/tui/modals/settings/test_cli_settings_tab.py b/tests/tui/modals/settings/test_cli_settings_tab.py index bb5debc9..afe39bea 100644 --- a/tests/tui/modals/settings/test_cli_settings_tab.py +++ b/tests/tui/modals/settings/test_cli_settings_tab.py @@ -4,7 +4,7 @@ import pytest from textual.app import App, ComposeResult -from textual.widgets import Switch +from textual.widgets import Select, Switch from openhands_cli.stores.cli_settings import CliSettings from openhands_cli.tui.modals.settings.components.cli_settings_tab import ( @@ -123,8 +123,45 @@ async def test_get_updated_fields_returns_only_managed_fields(self): tab = app.query_one(CliSettingsTab) result = tab.get_updated_fields() - # Should only contain the 2 fields this tab manages + # Should contain the 3 fields this tab manages assert set(result.keys()) == { "default_cells_expanded", "auto_open_plan_panel", + "theme", } + + @pytest.mark.asyncio + async def test_compose_renders_theme_select_with_default(self): + """Verify the theme selector is rendered with the default value.""" + app = _TestApp(initial_settings=CliSettings()) + + async with app.run_test(): + tab = app.query_one(CliSettingsTab) + select = tab.query_one("#theme_select", Select) + assert select.value == "openhands" + + @pytest.mark.asyncio + async def test_compose_renders_theme_select_with_custom_value(self): + """Verify the theme selector reflects a non-default initial value.""" + initial = CliSettings(theme="dracula") + app = _TestApp(initial_settings=initial) + + async with app.run_test(): + tab = app.query_one(CliSettingsTab) + select = tab.query_one("#theme_select", Select) + assert select.value == "dracula" + + @pytest.mark.asyncio + async def test_get_updated_fields_reflects_theme_change(self): + """Verify get_updated_fields() captures theme select state.""" + initial = CliSettings(theme="openhands") + app = _TestApp(initial_settings=initial) + + async with app.run_test(): + tab = app.query_one(CliSettingsTab) + select = tab.query_one("#theme_select", Select) + + select.value = "nord" + + result = tab.get_updated_fields() + assert result["theme"] == "nord"