diff --git a/features/steps/tui_app_coverage_steps.py b/features/steps/tui_app_coverage_steps.py index ad9227bd2..e1a2161d6 100644 --- a/features/steps/tui_app_coverage_steps.py +++ b/features/steps/tui_app_coverage_steps.py @@ -10,6 +10,8 @@ These steps target uncovered lines in cleveragents/tui/app.py: - Lines 131-142: _refresh_persona_bar method - Lines 144-185: on_input_submitted (all branches) - Line 189: CleverAgentsTuiApp alias +- action_reload: ctrl+r binding +- action_settings: f2 / ctrl+, binding """ import importlib @@ -119,12 +121,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_screen as ss_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(ss_mod) importlib.reload(sco_mod) import cleveragents.tui.app as app_mod @@ -147,12 +151,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_screen as ss_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(ss_mod) importlib.reload(sco_mod) import cleveragents.tui.app as app_mod @@ -510,3 +516,84 @@ def step_alias_check(context): assert ( context._tui_app_mod.CleverAgentsTuiApp.__name__ == "_TextualCleverAgentsTuiApp" ) + + +# --------------------------------------------------------------------------- +# action_reload (ctrl+r binding) +# --------------------------------------------------------------------------- +@when("I call action_reload on the app") +def step_call_action_reload(context): + context._tui_app.action_reload() + + +@then("the session transcript should be empty after reload") +def step_session_transcript_empty(context): + assert context._tui_app._session.transcript == [] + + +# --------------------------------------------------------------------------- +# action_settings (f2 / ctrl+, binding) +# --------------------------------------------------------------------------- +@when("I call action_settings on the app") +def step_call_action_settings(context): + context._tui_app.action_settings() + + +@then("the settings screen should be visible") +def step_settings_screen_visible(context): + from cleveragents.tui.widgets.settings_screen import SettingsScreen + + settings = context._tui_app.query_one("#settings-screen", SettingsScreen) + assert settings.visible is True + + +@then("the settings screen should be hidden") +def step_settings_screen_hidden(context): + from cleveragents.tui.widgets.settings_screen import SettingsScreen + + settings = context._tui_app.query_one("#settings-screen", SettingsScreen) + assert settings.visible is False + + +@then('the settings screen text should contain "{text}"') +def step_settings_screen_contains(context, text): + from cleveragents.tui.widgets.settings_screen import SettingsScreen + + settings = context._tui_app.query_one("#settings-screen", SettingsScreen) + assert text in settings._text, f"Expected '{text}' in '{settings._text}'" + + +# --------------------------------------------------------------------------- +# BINDINGS assertions +# --------------------------------------------------------------------------- +@then("the app bindings should include ctrl+r for reload") +def step_bindings_include_ctrl_r(context): + bindings = context._tui_app.BINDINGS + keys = [b[0] for b in bindings] + assert "ctrl+r" in keys, f"ctrl+r not found in bindings: {keys}" + reload_binding = next(b for b in bindings if b[0] == "ctrl+r") + assert reload_binding[1] == "reload", ( + f"Expected action 'reload', got '{reload_binding[1]}'" + ) + + +@then("the app bindings should include f2 for settings") +def step_bindings_include_f2(context): + bindings = context._tui_app.BINDINGS + keys = [b[0] for b in bindings] + assert "f2" in keys, f"f2 not found in bindings: {keys}" + settings_binding = next(b for b in bindings if b[0] == "f2") + assert settings_binding[1] == "settings", ( + f"Expected action 'settings', got '{settings_binding[1]}'" + ) + + +@then("the app bindings should include ctrl+comma for settings") +def step_bindings_include_ctrl_comma(context): + bindings = context._tui_app.BINDINGS + keys = [b[0] for b in bindings] + assert "ctrl+comma" in keys, f"ctrl+comma not found in bindings: {keys}" + settings_binding = next(b for b in bindings if b[0] == "ctrl+comma") + assert settings_binding[1] == "settings", ( + f"Expected action 'settings', got '{settings_binding[1]}'" + ) diff --git a/features/tui_app_coverage.feature b/features/tui_app_coverage.feature index 2392dca85..e54d97775 100644 --- a/features/tui_app_coverage.feature +++ b/features/tui_app_coverage.feature @@ -41,13 +41,13 @@ Feature: TUI App Coverage And the app should store the command router And the app should store the persona state - # --- CSS_PATH and BINDINGS class variables (lines 84-89) --- + # --- CSS_PATH and BINDINGS class variables --- Scenario: The Textual TUI app has correct class variables 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 6 key bindings # --- compose method (lines 102-112) --- @@ -196,3 +196,41 @@ 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)" + + # --- action_reload method (ctrl+r binding) --- + + Scenario: action_reload clears the session transcript and refreshes the bar + Given a mock command router and persona state + When I instantiate the Textual TUI app + And I call on_mount on the app + And I call action_reload on the app + Then the session transcript should be empty after reload + And the persona bar content should be refreshed + + # --- action_settings method (f2 / ctrl+, binding) --- + + Scenario: action_settings opens the settings screen + Given a mock command router and persona state + When I instantiate the Textual TUI app + And I call on_mount on the app + And I call action_settings on the app + Then the settings screen should be visible + And the settings screen text should contain "Settings" + + Scenario: action_settings toggles the settings screen closed + Given a mock command router and persona state + When I instantiate the Textual TUI app + And I call on_mount on the app + And I call action_settings on the app + And I call action_settings on the app + Then the settings screen should be hidden + + # --- BINDINGS class variable updated count --- + + Scenario: The Textual TUI app has 6 key bindings including reload and settings + Given a mock command router and persona state + When I instantiate the Textual TUI app + Then the app class should have 6 key bindings + And the app bindings should include ctrl+r for reload + And the app bindings should include f2 for settings + And the app bindings should include ctrl+comma for settings diff --git a/src/cleveragents/tui/app.py b/src/cleveragents/tui/app.py index 4a8c66dcf..e971117ba 100644 --- a/src/cleveragents/tui/app.py +++ b/src/cleveragents/tui/app.py @@ -20,6 +20,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_screen import SettingsScreen from cleveragents.tui.widgets.slash_command_overlay import SlashCommandOverlay if TYPE_CHECKING: @@ -93,6 +94,9 @@ if _TEXTUAL_AVAILABLE: ("ctrl+q", "quit", "Quit"), ("f1", "help", "Help"), ("ctrl+t", "cycle_preset", "Cycle Preset"), + ("ctrl+r", "reload", "Reload"), + ("f2", "settings", "Settings"), + ("ctrl+comma", "settings", "Settings"), ] def __init__( @@ -114,6 +118,7 @@ if _TEXTUAL_AVAILABLE: yield ReferencePickerOverlay(id="reference-picker") yield SlashCommandOverlay(id="slash-overlay") yield ActorSelectionOverlay(id="actor-selection") + yield SettingsScreen(id="settings-screen") yield PromptInput( placeholder="Type message, /command, or !shell ...", id="prompt" ) @@ -132,6 +137,8 @@ if _TEXTUAL_AVAILABLE: slash = self.query_one("#slash-overlay", SlashCommandOverlay) slash.set_commands("", slash_command_specs()) actor_overlay = self.query_one("#actor-selection", ActorSelectionOverlay) + settings = self.query_one("#settings-screen", SettingsScreen) + settings.hide() if first_run: actor_overlay.show() else: @@ -152,6 +159,18 @@ if _TEXTUAL_AVAILABLE: self._persona_state.cycle_preset(self._session.session_id) self._refresh_persona_bar() + def action_reload(self) -> None: + """Reload/refresh the current session or conversation.""" + self._session = SessionView( + session_id=self._session.session_id, transcript=[] + ) + self._refresh_persona_bar() + + def action_settings(self) -> None: + """Open or close the Settings screen.""" + settings = self.query_one("#settings-screen", SettingsScreen) + settings.toggle() + def _refresh_persona_bar(self) -> None: persona = self._persona_state.active_persona(self._session.session_id) preset = self._persona_state.current_preset(self._session.session_id) diff --git a/src/cleveragents/tui/widgets/settings_screen.py b/src/cleveragents/tui/widgets/settings_screen.py new file mode 100644 index 000000000..6433730a5 --- /dev/null +++ b/src/cleveragents/tui/widgets/settings_screen.py @@ -0,0 +1,78 @@ +"""Settings screen overlay for the TUI.""" + +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 - optional dependency + + 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() + +_SETTINGS_CONTENT = ( + "Settings\n" + "\n" + " Theme Light / Dark / Auto\n" + " Font size Small / Medium / Large\n" + " Wrap width 80 / 100 / 120 / Off\n" + " Timestamps On / Off\n" + " Compact mode On / Off\n" + "\n" + "─────────────────────────────────────────────────────\n" + "escape Close settings\n" +) + + +class SettingsScreen(_StaticBase): + """Overlay that displays application settings. + + Opened via the ``f2`` or ``ctrl+,`` key bindings. Dismissed with + ``escape``. + """ + + def __init__(self, *args: object, **kwargs: object) -> None: + super().__init__(*args, **kwargs) + self._visible: bool = False + self._text: str = "" + + @property + def visible(self) -> bool: + """Return whether the settings screen is currently visible.""" + return self._visible + + def show(self) -> None: + """Display the settings screen.""" + self._visible = True + self._text = _SETTINGS_CONTENT + self.update(self._text) + + def hide(self) -> None: + """Hide the settings screen and clear its content.""" + self._visible = False + self._text = "" + self.update("") + + def toggle(self) -> bool: + """Toggle the settings screen visibility. + + Returns ``True`` when the screen ends visible and ``False`` when hidden. + """ + if self._visible: + self.hide() + return False + self.show() + return True