Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions openhands_cli/stores/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
)
from openhands_cli.stores.cli_settings import (
DEFAULT_MAX_REFINEMENT_ITERATIONS,
VALID_THEMES,
CliSettings,
CriticSettings,
)
Expand All @@ -16,5 +17,6 @@
"CriticSettings",
"DEFAULT_MAX_REFINEMENT_ITERATIONS",
"MissingEnvironmentVariablesError",
"VALID_THEMES",
"check_and_warn_env_vars",
]
15 changes: 15 additions & 0 deletions openhands_cli/stores/cli_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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."""
Expand Down
26 changes: 23 additions & 3 deletions openhands_cli/tui/modals/settings/components/cli_settings_tab.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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")

Expand All @@ -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(
Expand All @@ -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,
}
6 changes: 3 additions & 3 deletions openhands_cli/tui/textual_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
29 changes: 29 additions & 0 deletions tests/tui/modals/settings/test_cli_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand All @@ -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",
[
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down
41 changes: 39 additions & 2 deletions tests/tui/modals/settings/test_cli_settings_tab.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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"