diff --git a/CHANGELOG.md b/CHANGELOG.md index 36aab2df1..4fe06a38d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,12 @@ the same path in a subprocess context. Tests simulate the divergent- container condition (fresh ``CLEVERAGENTS_HOME`` with empty database). ASV benchmark measures active-plan filtering overhead. (#1035) +- Implemented TUI SettingsScreen with `F2` / `ctrl+,` toggle, schema-driven + settings rendering, real-time search filtering, JSON persistence to + `~/.config/cleveragents/tui-settings.json`, and immediate in-session apply on + setting changes. Added a dedicated `SettingsStore` schema/persistence module, + a `SettingsOverlay` widget, and Behave coverage scenarios for open/search/ + persistence/immediate-apply behavior. (#995) - Added missing `LspServerConfig` model fields per specification: `description` (max 1000 chars), `transport` (`LspTransport` enum with `stdio`/`tcp`, default `stdio`), `initialization` (dict for LSP diff --git a/features/steps/tui_app_coverage_steps.py b/features/steps/tui_app_coverage_steps.py index ad9227bd2..e8451ff02 100644 --- a/features/steps/tui_app_coverage_steps.py +++ b/features/steps/tui_app_coverage_steps.py @@ -13,6 +13,7 @@ These steps target uncovered lines in cleveragents/tui/app.py: """ import importlib +import json import os import shutil import sys @@ -119,12 +120,14 @@ def _install_mock_textual(context): import cleveragents.tui.widgets.persona_bar as pb_mod import cleveragents.tui.widgets.prompt as prompt_mod import cleveragents.tui.widgets.reference_picker as rp_mod + import cleveragents.tui.widgets.settings_overlay as so_mod import cleveragents.tui.widgets.slash_command_overlay as sco_mod importlib.reload(hp_mod) importlib.reload(pb_mod) importlib.reload(prompt_mod) importlib.reload(rp_mod) + importlib.reload(so_mod) importlib.reload(sco_mod) import cleveragents.tui.app as app_mod @@ -147,12 +150,14 @@ def _restore_modules(context): import cleveragents.tui.widgets.persona_bar as pb_mod import cleveragents.tui.widgets.prompt as prompt_mod import cleveragents.tui.widgets.reference_picker as rp_mod + import cleveragents.tui.widgets.settings_overlay as so_mod import cleveragents.tui.widgets.slash_command_overlay as sco_mod importlib.reload(hp_mod) importlib.reload(pb_mod) importlib.reload(prompt_mod) importlib.reload(rp_mod) + importlib.reload(so_mod) importlib.reload(sco_mod) import cleveragents.tui.app as app_mod @@ -273,6 +278,21 @@ def step_instantiate_app(context): ) +@given("a temporary settings file path for the app") +def step_create_settings_file_path(context): + context._tui_settings_path = Path(context._tui_tmpdir) / "tui-settings.json" + + +@when("I instantiate the Textual TUI app with settings path") +def step_instantiate_app_with_settings_path(context): + AppClass = context._tui_app_mod._ResolvedTuiApp + context._tui_app = AppClass( + command_router=context._tui_cmd_router, + persona_state=context._tui_persona_state, + settings_path=context._tui_settings_path, + ) + + @then('the app should have a _session with session_id "{sid}"') def step_app_session_id(context, sid): assert context._tui_app._session.session_id == sid @@ -301,6 +321,11 @@ def step_bindings_count(context, count): assert len(context._tui_app.BINDINGS) == count +@then('the app bindings should include key "{key}"') +def step_bindings_include_key(context, key): + assert any(binding[0] == key for binding in context._tui_app.BINDINGS) + + # --------------------------------------------------------------------------- # compose (lines 102-112) # --------------------------------------------------------------------------- @@ -510,3 +535,58 @@ def step_alias_check(context): assert ( context._tui_app_mod.CleverAgentsTuiApp.__name__ == "_TextualCleverAgentsTuiApp" ) + + +@when("I call action_toggle_settings on the app") +def step_call_toggle_settings(context): + context._tui_app.action_toggle_settings() + + +@when("I call action_cycle_setting_value on the app") +def step_call_cycle_setting_value(context): + context._tui_app.action_cycle_setting_value() + + +def _settings_overlay_text(context) -> str: + from cleveragents.tui.widgets.settings_overlay import SettingsOverlay + + overlay = context._tui_app.query_one("#settings-overlay", SettingsOverlay) + return getattr(overlay, "_text", "") + + +@then('the settings overlay should contain "{text}"') +def step_settings_overlay_contains(context, text): + rendered = _settings_overlay_text(context) + assert text in rendered, f"Expected '{text}' in settings overlay: {rendered}" + + +@then('the settings overlay should not contain "{text}"') +def step_settings_overlay_not_contains(context, text): + rendered = _settings_overlay_text(context) + assert text not in rendered, ( + f"Did not expect '{text}' in settings overlay: {rendered}" + ) + + +@then("the settings schema should include at least {count:d} settings") +def step_settings_schema_count(context, count): + definitions = context._tui_app._settings_store.definitions() + assert len(definitions) >= count + + +@then("the settings schema should include {count:d} groups") +def step_settings_group_count(context, count): + groups = context._tui_app._settings_store.group_names() + assert len(groups) == count + + +@then("the settings file should exist") +def step_settings_file_exists(context): + assert context._tui_settings_path.exists() + + +@then('the persisted settings should include key "{key}"') +def step_persisted_settings_contains_key(context, key): + with context._tui_settings_path.open(encoding="utf-8") as stream: + payload = json.load(stream) + assert key in payload diff --git a/features/tui_app_coverage.feature b/features/tui_app_coverage.feature index 2392dca85..63fc4fde9 100644 --- a/features/tui_app_coverage.feature +++ b/features/tui_app_coverage.feature @@ -47,7 +47,13 @@ Feature: TUI App Coverage Given a mock command router and persona state When I instantiate the Textual TUI app Then the app class should have CSS_PATH set to "cleveragents.tcss" - And the app class should have 3 key bindings + And the app class should have 8 key bindings + + Scenario: The app class includes settings hotkeys + Given a mock command router and persona state + When I instantiate the Textual TUI app + Then the app bindings should include key "f2" + And the app bindings should include key "ctrl+," # --- compose method (lines 102-112) --- @@ -196,3 +202,51 @@ Feature: TUI App Coverage And I call on_mount on the app And I submit "!true" to the app Then the conversation widget should contain "(empty output)" + + # --- SettingsScreen hotkeys, schema, search, persistence --- + + Scenario: settings screen opens from toggle action + Given a mock command router and persona state + And a temporary settings file path for the app + When I instantiate the Textual TUI app with settings path + And I call on_mount on the app + And I call action_toggle_settings on the app + Then the settings overlay should contain "Settings" + And the settings overlay should contain "Search:" + + Scenario: settings schema includes 30 plus settings across six groups + Given a mock command router and persona state + And a temporary settings file path for the app + When I instantiate the Textual TUI app with settings path + Then the settings schema should include at least 30 settings + And the settings schema should include 6 groups + + Scenario: settings search filters values in real time + Given a mock command router and persona state + And a temporary settings file path for the app + When I instantiate the Textual TUI app with settings path + And I call on_mount on the app + And I call action_toggle_settings on the app + And I submit "flash" to the app + Then the settings overlay should contain "Flash Duration" + And the settings overlay should not contain "Shell Command" + + Scenario: setting changes persist to tui-settings.json + Given a mock command router and persona state + And a temporary settings file path for the app + When I instantiate the Textual TUI app with settings path + And I call on_mount on the app + And I call action_toggle_settings on the app + And I call action_cycle_setting_value on the app + And I call action_toggle_settings on the app + Then the settings file should exist + And the persisted settings should include key "ui.theme" + + Scenario: setting changes apply immediately + Given a mock command router and persona state + And a temporary settings file path for the app + When I instantiate the Textual TUI app with settings path + And I call on_mount on the app + And I call action_toggle_settings on the app + And I call action_cycle_setting_value on the app + Then the conversation widget should contain "Applied setting:" diff --git a/src/cleveragents/tui/app.py b/src/cleveragents/tui/app.py index 516a9cb49..59e9cdf68 100644 --- a/src/cleveragents/tui/app.py +++ b/src/cleveragents/tui/app.py @@ -5,11 +5,17 @@ from __future__ import annotations import importlib import os from dataclasses import dataclass +from pathlib import Path from typing import TYPE_CHECKING, Any, ClassVar, Protocol from cleveragents.tui.input.modes import InputMode, InputModeRouter from cleveragents.tui.input.reference_parser import suggestions from cleveragents.tui.persona.state import PersonaState +from cleveragents.tui.settings import ( + SettingDefinition, + SettingsStore, + SettingValue, +) from cleveragents.tui.slash_catalog import slash_command_names from cleveragents.tui.widgets.help_panel_overlay import ( HelpPanelOverlay, @@ -18,6 +24,7 @@ from cleveragents.tui.widgets.help_panel_overlay import ( from cleveragents.tui.widgets.persona_bar import PersonaBar from cleveragents.tui.widgets.prompt import PromptInput from cleveragents.tui.widgets.reference_picker import ReferencePickerOverlay +from cleveragents.tui.widgets.settings_overlay import SettingsOverlay from cleveragents.tui.widgets.slash_command_overlay import SlashCommandOverlay if TYPE_CHECKING: @@ -70,9 +77,13 @@ class _FallbackCleverAgentsTuiApp: # pragma: no cover """Fallback app that raises actionable dependency error.""" def __init__( - self, *, command_router: _CommandRouter, persona_state: PersonaState + self, + *, + command_router: _CommandRouter, + persona_state: PersonaState, + settings_path: Path | None = None, ) -> None: - del command_router, persona_state + del command_router, persona_state, settings_path def run(self) -> None: raise RuntimeError( @@ -91,6 +102,11 @@ if _TEXTUAL_AVAILABLE: ("ctrl+q", "quit", "Quit"), ("f1", "help", "Help"), ("ctrl+t", "cycle_preset", "Cycle Preset"), + ("f2", "toggle_settings", "Settings"), + ("ctrl+,", "toggle_settings", "Settings"), + ("j", "settings_next", "Next Setting"), + ("k", "settings_previous", "Previous Setting"), + ("enter", "cycle_setting_value", "Cycle Value"), ] def __init__( @@ -98,17 +114,24 @@ if _TEXTUAL_AVAILABLE: *, command_router: _CommandRouter, persona_state: PersonaState, + settings_path: Path | None = None, ) -> None: super().__init__() self._command_router = command_router self._persona_state = persona_state self._session = SessionView(session_id="default", transcript=[]) + self._settings_store = SettingsStore(path=settings_path) + self._settings_values = self._settings_store.load() + self._settings_visible = False + self._settings_search_query = "" + self._settings_selected_index = 0 def compose(self) -> Any: yield _Header(show_clock=True) with _Vertical(id="main-column"): yield _Static("CleverAgents TUI", id="conversation") yield HelpPanelOverlay(id="help-panel") + yield SettingsOverlay(id="settings-overlay") yield ReferencePickerOverlay(id="reference-picker") yield SlashCommandOverlay(id="slash-overlay") yield PromptInput( @@ -125,6 +148,7 @@ if _TEXTUAL_AVAILABLE: ref_picker.set_suggestions("", []) slash = self.query_one("#slash-overlay", SlashCommandOverlay) slash.set_commands("", slash_command_names()) + self._render_settings_overlay() def action_help(self) -> None: prompt = self.query_one("#prompt", PromptInput) @@ -137,6 +161,10 @@ if _TEXTUAL_AVAILABLE: self._refresh_persona_bar() def _refresh_persona_bar(self) -> None: + if not bool(self._settings_values.get("ui.info_bar", True)): + bar = self.query_one("#persona-bar", PersonaBar) + bar.update("") + return persona = self._persona_state.active_persona(self._session.session_id) preset = self._persona_state.current_preset(self._session.session_id) scope_count = len(persona.scoped_projects) + len(persona.scoped_plans) @@ -149,6 +177,104 @@ if _TEXTUAL_AVAILABLE: scope_text=scope_text, ) + def _filtered_settings(self) -> list[SettingDefinition]: + return self._settings_store.filter_definitions(self._settings_search_query) + + def _grouped_setting_lines( + self, definitions: list[SettingDefinition] + ) -> list[tuple[str, list[str]]]: + grouped: dict[str, list[str]] = {} + selected_key = None + if definitions: + selected_key = definitions[self._settings_selected_index].key + for definition in definitions: + marker = "*" if definition.key == selected_key else " " + value = self._settings_values.get(definition.key, definition.default) + line = f"{marker} {definition.label} [{value}] ({definition.key})" + grouped.setdefault(definition.group, []).append(line) + + ordered: list[tuple[str, list[str]]] = [] + for name in self._settings_store.group_names(): + if name in grouped: + ordered.append((name, grouped[name])) + return ordered + + def _render_settings_overlay(self) -> None: + definitions = self._filtered_settings() + if definitions: + self._settings_selected_index = min( + self._settings_selected_index, + len(definitions) - 1, + ) + else: + self._settings_selected_index = 0 + grouped_lines = self._grouped_setting_lines(definitions) + overlay = self.query_one("#settings-overlay", SettingsOverlay) + overlay.set_content( + visible=self._settings_visible, + search_query=self._settings_search_query, + grouped_lines=grouped_lines, + total_count=len(definitions), + ) + + def set_settings_search_query(self, query: str) -> None: + self._settings_search_query = query + self._settings_selected_index = 0 + self._render_settings_overlay() + + def _persist_settings(self) -> None: + self._settings_store.save(self._settings_values) + + def _update_setting(self, key: str, value: SettingValue) -> None: + self._settings_values[key] = value + self._persist_settings() + self._render_settings_overlay() + self._refresh_persona_bar() + conversation = self.query_one("#conversation", _Static) + conversation.update(f"Applied setting: {key}={value}") + + def action_toggle_settings(self) -> None: + self._settings_visible = not self._settings_visible + if not self._settings_visible: + self._persist_settings() + self._render_settings_overlay() + + def action_settings_next(self) -> None: + if not self._settings_visible: + return + definitions = self._filtered_settings() + if not definitions: + return + self._settings_selected_index = (self._settings_selected_index + 1) % len( + definitions + ) + self._render_settings_overlay() + + def action_settings_previous(self) -> None: + if not self._settings_visible: + return + definitions = self._filtered_settings() + if not definitions: + return + self._settings_selected_index = (self._settings_selected_index - 1) % len( + definitions + ) + self._render_settings_overlay() + + def action_cycle_setting_value(self) -> None: + if not self._settings_visible: + return + definitions = self._filtered_settings() + if not definitions: + return + definition = definitions[self._settings_selected_index] + current = self._settings_values.get(definition.key, definition.default) + next_value = self._settings_store.cycle_value( + key=definition.key, + current=current, + ) + self._update_setting(definition.key, next_value) + def on_input_submitted(self, event: InputSubmittedEvent) -> None: del event prompt = self.query_one("#prompt", PromptInput) @@ -157,6 +283,12 @@ if _TEXTUAL_AVAILABLE: if not text: return + if self._settings_visible: + self.set_settings_search_query(text) + conversation = self.query_one("#conversation", _Static) + conversation.update(f"Settings search: {text}") + return + mode_router = InputModeRouter( command_handler=lambda raw: self._command_router.handle( raw, session_id=self._session.session_id diff --git a/src/cleveragents/tui/cleveragents.tcss b/src/cleveragents/tui/cleveragents.tcss index 94438c763..0329988da 100644 --- a/src/cleveragents/tui/cleveragents.tcss +++ b/src/cleveragents/tui/cleveragents.tcss @@ -22,6 +22,14 @@ Screen { background: $panel-lighten-1; } +#settings-overlay { + height: auto; + max-height: 18; + padding: 0 1; + border: round $success; + color: $text; +} + #reference-picker { height: auto; max-height: 8; diff --git a/src/cleveragents/tui/settings.py b/src/cleveragents/tui/settings.py new file mode 100644 index 000000000..e4f8e2aff --- /dev/null +++ b/src/cleveragents/tui/settings.py @@ -0,0 +1,440 @@ +"""Schema-driven TUI settings state and persistence.""" + +from __future__ import annotations + +import json +from dataclasses import dataclass +from pathlib import Path + +SettingValue = bool | int | float | str + + +@dataclass(frozen=True, slots=True) +class SettingDefinition: + """Declarative schema entry for one TUI setting.""" + + key: str + group: str + label: str + kind: str + default: SettingValue + description: str + choices: tuple[str, ...] = () + minimum: float | None = None + maximum: float | None = None + + +SETTINGS_SCHEMA: tuple[SettingDefinition, ...] = ( + SettingDefinition( + key="ui.theme", + group="UI", + label="Theme", + kind="choice", + default="dracula", + choices=("dracula", "monokai", "nord", "solarized-dark", "gruvbox"), + description="Color theme for the entire TUI", + ), + SettingDefinition( + key="ui.column", + group="UI", + label="Column Layout", + kind="bool", + default=True, + description="Enable fixed column width for conversation content", + ), + SettingDefinition( + key="ui.column_width", + group="UI", + label="Column Width", + kind="int", + default=100, + minimum=40, + maximum=300, + description="Maximum column width when column layout is enabled", + ), + SettingDefinition( + key="ui.scrollbar", + group="UI", + label="Scrollbar", + kind="choice", + default="normal", + choices=("normal", "thin", "hidden"), + description="Scrollbar appearance across the TUI", + ), + SettingDefinition( + key="ui.compact_input", + group="UI", + label="Compact Input", + kind="bool", + default=False, + description="Use compact prompt styling", + ), + SettingDefinition( + key="ui.sessions_bar", + group="UI", + label="Sessions Bar", + kind="choice", + default="multiple", + choices=("always", "multiple", "never"), + description="When to show the session tab bar", + ), + SettingDefinition( + key="ui.footer", + group="UI", + label="Footer", + kind="bool", + default=True, + description="Show hotkey footer", + ), + SettingDefinition( + key="ui.info_bar", + group="UI", + label="Info Bar", + kind="bool", + default=True, + description="Show persona and status info bar", + ), + SettingDefinition( + key="ui.status_line", + group="UI", + label="Status Line", + kind="bool", + default=True, + description="Show token/cost status line", + ), + SettingDefinition( + key="ui.flash_duration", + group="UI", + label="Flash Duration", + kind="float", + default=5.0, + minimum=0.5, + maximum=30.0, + description="Seconds before flash messages auto-dismiss", + ), + SettingDefinition( + key="ui.auto_copy", + group="UI", + label="Auto Copy", + kind="bool", + default=True, + description="Auto-copy selected text", + ), + SettingDefinition( + key="ui.prune_low_mark", + group="UI", + label="Prune Low Mark", + kind="int", + default=1500, + minimum=100, + maximum=10000, + description="Target line count after pruning", + ), + SettingDefinition( + key="ui.prune_excess", + group="UI", + label="Prune Excess", + kind="int", + default=1000, + minimum=100, + maximum=5000, + description="Excess lines that trigger pruning", + ), + SettingDefinition( + key="ui.throbber", + group="UI", + label="Throbber", + kind="choice", + default="rainbow", + choices=("rainbow", "quotes"), + description="Loading indicator style", + ), + SettingDefinition( + key="ui.font_size", + group="UI", + label="Font Size", + kind="int", + default=12, + minimum=8, + maximum=24, + description="Preferred monospace font size hint", + ), + SettingDefinition( + key="notifications.desktop", + group="Notifications", + label="Desktop Notifications", + kind="choice", + default="on-blur", + choices=("never", "on-blur", "always"), + description="When to send desktop notifications", + ), + SettingDefinition( + key="notifications.blink_title", + group="Notifications", + label="Blink Terminal Title", + kind="bool", + default=True, + description="Blink terminal title when input is needed", + ), + SettingDefinition( + key="notifications.enable_sounds", + group="Notifications", + label="Enable Sounds", + kind="bool", + default=True, + description="Play sound effects on events", + ), + SettingDefinition( + key="notifications.turn_complete", + group="Notifications", + label="Turn Complete Alerts", + kind="bool", + default=True, + description="Notify when actor turn completes", + ), + SettingDefinition( + key="notifications.hide_low_severity", + group="Notifications", + label="Hide Low Severity", + kind="bool", + default=True, + description="Suppress low-severity notifications", + ), + SettingDefinition( + key="sidebar.auto_hide", + group="Sidebar", + label="Auto Hide", + kind="bool", + default=False, + description="Auto-hide sidebar when unfocused", + ), + SettingDefinition( + key="sidebar.width", + group="Sidebar", + label="Sidebar Width", + kind="int", + default=36, + minimum=24, + maximum=60, + description="Sidebar width in characters", + ), + SettingDefinition( + key="agent.thoughts", + group="Agent", + label="Show Thoughts", + kind="bool", + default=True, + description="Render thought/reasoning blocks", + ), + SettingDefinition( + key="tools.expand", + group="Tools", + label="Auto Expand Tools", + kind="choice", + default="fail", + choices=("never", "always", "success", "fail", "both"), + description="When tool results auto-expand", + ), + SettingDefinition( + key="diff.view", + group="Tools", + label="Diff View", + kind="choice", + default="auto", + choices=("unified", "split", "auto"), + description="Diff presentation mode", + ), + SettingDefinition( + key="shell.command", + group="Shell", + label="Shell Command", + kind="text", + default="/bin/sh", + description="Shell executable for ! mode", + ), + SettingDefinition( + key="shell.startup_commands", + group="Shell", + label="Startup Commands", + kind="text", + default='PS1=""', + description="Commands executed at shell startup", + ), + SettingDefinition( + key="shell.warn_dangerous", + group="Shell", + label="Warn Dangerous", + kind="bool", + default=True, + description="Warn for dangerous shell commands", + ), + SettingDefinition( + key="shell.allow_commands", + group="Shell", + label="Allowed Commands", + kind="text", + default="git,ls,cat", + description="Commands recognized as shell mode", + ), + SettingDefinition( + key="shell.directory_commands", + group="Shell", + label="Directory Commands", + kind="text", + default="cd,rmdir", + description="Commands with directory completion", + ), + SettingDefinition( + key="shell.file_commands", + group="Shell", + label="File Commands", + kind="text", + default="cat,less,head", + description="Commands with file completion", + ), +) + + +DEFAULT_SETTINGS = { + definition.key: definition.default for definition in SETTINGS_SCHEMA +} + + +def default_settings_path() -> Path: + """Return the default settings file path.""" + return Path.home() / ".config" / "cleveragents" / "tui-settings.json" + + +class SettingsStore: + """Persistence and schema utility for TUI settings.""" + + def __init__(self, path: Path | None = None) -> None: + self._path = path or default_settings_path() + self._definitions = SETTINGS_SCHEMA + self._by_key = {definition.key: definition for definition in self._definitions} + + @property + def path(self) -> Path: + """Path to the persisted JSON file.""" + return self._path + + def definitions(self) -> list[SettingDefinition]: + """Return schema definitions in deterministic order.""" + return list(self._definitions) + + def group_names(self) -> list[str]: + """Return unique group names in schema order.""" + names: list[str] = [] + for definition in self._definitions: + if definition.group not in names: + names.append(definition.group) + return names + + def load(self) -> dict[str, SettingValue]: + """Load persisted settings merged with schema defaults.""" + values: dict[str, SettingValue] = dict(DEFAULT_SETTINGS) + if not self._path.exists(): + return values + try: + with self._path.open(encoding="utf-8") as stream: + payload = json.load(stream) + except (OSError, json.JSONDecodeError): + return values + + if not isinstance(payload, dict): + return values + + for key, raw in payload.items(): + definition = self._by_key.get(key) + if definition is None: + continue + values[key] = self._coerce(definition, raw) + return values + + def save(self, values: dict[str, SettingValue]) -> None: + """Persist settings to JSON.""" + self._path.parent.mkdir(parents=True, exist_ok=True) + filtered = { + key: values[key] + for key in DEFAULT_SETTINGS + if key in values and values[key] != DEFAULT_SETTINGS[key] + } + with self._path.open("w", encoding="utf-8") as stream: + json.dump(filtered, stream, indent=2, sort_keys=True) + + def filter_definitions(self, query: str) -> list[SettingDefinition]: + """Filter schema entries by key/label/description.""" + needle = query.strip().lower() + if not needle: + return self.definitions() + matches: list[SettingDefinition] = [] + for definition in self._definitions: + haystack = ( + f"{definition.key} {definition.label} {definition.description}" + ).lower() + if needle in haystack: + matches.append(definition) + return matches + + def cycle_value(self, *, key: str, current: SettingValue) -> SettingValue: + """Return the next value for interactive setting edits.""" + definition = self._by_key[key] + if definition.kind == "bool": + return not bool(current) + if definition.kind == "choice": + values = definition.choices + if not values: + return current + current_value = str(current) + if current_value not in values: + return values[0] + index = values.index(current_value) + return values[(index + 1) % len(values)] + if definition.kind == "int": + value = int(current) + maximum = ( + int(definition.maximum) if definition.maximum is not None else value + ) + minimum = ( + int(definition.minimum) if definition.minimum is not None else value + ) + next_value = value + 1 + if next_value > maximum: + return minimum + return next_value + if definition.kind == "float": + value = float(current) + maximum = ( + float(definition.maximum) if definition.maximum is not None else value + ) + minimum = ( + float(definition.minimum) if definition.minimum is not None else value + ) + next_value = round(value + 0.5, 1) + if next_value > maximum: + return minimum + return next_value + return str(current) + + @staticmethod + def _coerce(definition: SettingDefinition, raw: object) -> SettingValue: + if definition.kind == "bool": + return bool(raw) + if definition.kind == "int": + if isinstance(raw, bool): + return int(definition.default) + if isinstance(raw, int | float): + return int(raw) + return int(definition.default) + if definition.kind == "float": + if isinstance(raw, bool): + return float(definition.default) + if isinstance(raw, int | float): + return float(raw) + return float(definition.default) + if definition.kind == "choice": + candidate = str(raw) + if candidate in definition.choices: + return candidate + return str(definition.default) + return str(raw) diff --git a/src/cleveragents/tui/widgets/__init__.py b/src/cleveragents/tui/widgets/__init__.py index 51776ce71..f38fb273c 100644 --- a/src/cleveragents/tui/widgets/__init__.py +++ b/src/cleveragents/tui/widgets/__init__.py @@ -4,6 +4,7 @@ from cleveragents.tui.widgets.help_panel_overlay import HelpPanelOverlay from cleveragents.tui.widgets.persona_bar import PersonaBar from cleveragents.tui.widgets.prompt import PromptInput, PromptSubmitted from cleveragents.tui.widgets.reference_picker import ReferencePickerOverlay +from cleveragents.tui.widgets.settings_overlay import SettingsOverlay from cleveragents.tui.widgets.slash_command_overlay import SlashCommandOverlay __all__ = [ @@ -12,5 +13,6 @@ __all__ = [ "PromptInput", "PromptSubmitted", "ReferencePickerOverlay", + "SettingsOverlay", "SlashCommandOverlay", ] diff --git a/src/cleveragents/tui/widgets/settings_overlay.py b/src/cleveragents/tui/widgets/settings_overlay.py new file mode 100644 index 000000000..fce99d1c2 --- /dev/null +++ b/src/cleveragents/tui/widgets/settings_overlay.py @@ -0,0 +1,50 @@ +"""Settings screen overlay widget.""" + +from __future__ import annotations + +import importlib +from typing import Any + + +def _load_static_base() -> type[Any]: + try: + return importlib.import_module("textual.widgets").Static + except Exception: # pragma: no cover + + class _FallbackStatic: + def __init__(self, *args: object, **kwargs: object) -> None: + self._text = "" + + def update(self, text: str) -> None: + self._text = text + + return _FallbackStatic + + +_StaticBase = _load_static_base() + + +class SettingsOverlay(_StaticBase): + """Renderable settings screen with grouped schema entries.""" + + def set_content( + self, + *, + visible: bool, + search_query: str, + grouped_lines: list[tuple[str, list[str]]], + total_count: int, + ) -> None: + if not visible: + self.update("") + return + + lines = ["Settings", "", f"Search: {search_query}", ""] + for group_name, group_lines in grouped_lines: + lines.append(group_name) + lines.extend(f" {line}" for line in group_lines) + lines.append("") + + lines.append(f"Matched settings: {total_count}") + lines.append("enter Cycle | type to search | esc/F2/ctrl+, close") + self.update("\n".join(lines))