diff --git a/features/steps/tui_settings_session_screens_steps.py b/features/steps/tui_settings_session_screens_steps.py new file mode 100644 index 000000000..c9403ae79 --- /dev/null +++ b/features/steps/tui_settings_session_screens_steps.py @@ -0,0 +1,278 @@ +"""Step definitions for tui_settings_session_screens.feature.""" + +from behave import given, then, when + +from cleveragents.tui.commands import TuiCommandRouter +from cleveragents.tui.screens.session_management import ( + SessionInfo, + SessionManagementScreen, +) +from cleveragents.tui.screens.settings import SettingsScreen + + +# --------------------------------------------------------------------------- +# Background +# --------------------------------------------------------------------------- +@given("the TUI screens module is imported") +def step_tui_screens_imported(context): + """Verify the screens module is importable.""" + assert SettingsScreen is not None + assert SessionManagementScreen is not None + assert SessionInfo is not None + + +# --------------------------------------------------------------------------- +# SettingsScreen scenarios +# --------------------------------------------------------------------------- +@given("a SettingsScreen widget") +def step_create_settings_screen(context): + """Create a SettingsScreen widget.""" + context.settings_screen = SettingsScreen() + + +@given("a SettingsScreen widget that is visible") +def step_create_visible_settings_screen(context): + """Create and show a SettingsScreen widget.""" + context.settings_screen = SettingsScreen() + context.settings_screen.show() + + +@when("I show the settings screen") +def step_show_settings_screen(context): + """Show the settings screen.""" + context.settings_screen.show() + + +@when("I hide the settings screen") +def step_hide_settings_screen(context): + """Hide the settings screen.""" + context.settings_screen.hide() + + +@when("I navigate to the next setting") +def step_navigate_next_setting(context): + """Navigate to the next setting.""" + context.settings_screen.navigate_next() + + +@when("I navigate to the previous setting") +def step_navigate_prev_setting(context): + """Navigate to the previous setting.""" + context.settings_screen.navigate_prev() + + +@when('I set the theme to "{theme}"') +def step_set_theme(context, theme): + """Set the theme.""" + context.settings_screen.set_theme(theme) + + +@when('I set the default_actor to "{actor}"') +def step_set_default_actor(context, actor): + """Set the default actor.""" + context.settings_screen.set_default_actor(actor) + + +@when('I set the safety_level to "{level}"') +def step_set_safety_level(context, level): + """Set the safety level.""" + context.settings_screen.set_safety_level(level) + + +@then('the theme should be "{expected}"') +def step_verify_theme(context, expected): + """Verify the theme setting.""" + assert context.settings_screen.theme == expected + + +@then('the default_actor should be "{expected}"') +def step_verify_default_actor(context, expected): + """Verify the default actor setting.""" + assert context.settings_screen.default_actor == expected + + +@then('the safety_level should be "{expected}"') +def step_verify_safety_level(context, expected): + """Verify the safety level setting.""" + assert context.settings_screen.safety_level == expected + + +@then("the settings screen should be visible") +def step_verify_settings_visible(context): + """Verify the settings screen is visible.""" + assert context.settings_screen.visible is True + + +@then("the settings screen should not be visible") +def step_verify_settings_not_visible(context): + """Verify the settings screen is not visible.""" + assert context.settings_screen.visible is False + + +@then("the selected_index should be {index:d}") +def step_verify_selected_index(context, index): + """Verify the selected index on whichever screen was created in this scenario.""" + screen = getattr(context, "session_screen", None) or getattr( + context, "settings_screen", None + ) + assert screen is not None, "no screen widget created in this scenario" + assert screen.selected_index == index, ( + f"expected selected_index={index}, got {screen.selected_index}" + ) + + +# --------------------------------------------------------------------------- +# SessionManagementScreen scenarios +# --------------------------------------------------------------------------- +@given("a SessionManagementScreen widget") +def step_create_session_screen(context): + """Create a SessionManagementScreen widget.""" + context.session_screen = SessionManagementScreen() + + +@given("a SessionManagementScreen widget with 3 sessions") +def step_create_session_screen_with_sessions(context): + """Create a SessionManagementScreen widget with 3 sessions.""" + context.session_screen = SessionManagementScreen() + sessions = [ + SessionInfo( + session_id=f"session-{i}", + name=f"Session {i}", + message_count=i * 10, + created_at="2026-04-19T00:00:00Z", + ) + for i in range(3) + ] + context.session_screen.load_sessions(sessions, "session-0") + + +@when("I show the session management screen") +def step_show_session_screen(context): + """Show the session management screen.""" + context.session_screen.show() + + +@when("I hide the session management screen") +def step_hide_session_screen(context): + """Hide the session management screen.""" + context.session_screen.hide() + + +@when("I load {count:d} sessions") +def step_load_sessions(context, count): + """Load sessions into the screen.""" + sessions = [ + SessionInfo( + session_id=f"session-{i}", + name=f"Session {i}", + message_count=i * 10, + created_at="2026-04-19T00:00:00Z", + ) + for i in range(count) + ] + context.session_screen.load_sessions(sessions, "session-0") + + +@when("I navigate to the next session") +def step_navigate_next_session(context): + """Navigate to the next session.""" + context.session_screen.navigate_next() + + +@when("I navigate to the previous session") +def step_navigate_prev_session(context): + """Navigate to the previous session.""" + context.session_screen.navigate_prev() + + +@then("the sessions list should be empty") +def step_verify_sessions_empty(context): + """Verify the sessions list is empty.""" + assert len(context.session_screen.sessions) == 0 + + +@then("the sessions list should have {count:d} items") +def step_verify_sessions_count(context, count): + """Verify the sessions list has the expected count.""" + assert len(context.session_screen.sessions) == count + + +@then('the current_session_id should be "{expected}"') +def step_verify_current_session_id(context, expected): + """Verify the current session ID.""" + assert context.session_screen.current_session_id == expected + + +@then("the session management screen should be visible") +def step_verify_session_screen_visible(context): + """Verify the session management screen is visible.""" + assert context.session_screen.visible is True + + +@then("the session management screen should not be visible") +def step_verify_session_screen_not_visible(context): + """Verify the session management screen is not visible.""" + assert context.session_screen.visible is False + + +@then("get_selected_session should return a SessionInfo object") +def step_verify_selected_session_info(context): + """Verify get_selected_session returns a SessionInfo object.""" + session = context.session_screen.get_selected_session() + assert session is not None + assert isinstance(session, SessionInfo) + + +@then("get_selected_session should return None") +def step_verify_selected_session_none(context): + """Verify get_selected_session returns None.""" + session = context.session_screen.get_selected_session() + assert session is None + + +@then( + 'get_settings should return theme "{theme}", default_actor "{actor}", ' + 'safety_level "{level}"' +) +def step_verify_get_settings(context, theme, actor, level): + """Verify get_settings returns the expected settings dict.""" + settings = context.settings_screen.get_settings() + assert settings == { + "theme": theme, + "default_actor": actor, + "safety_level": level, + } + + +@then('the session screen rendered text should contain "{text}"') +def step_verify_session_rendered_text_contains(context, text): + """Verify the rendered SessionManagementScreen text contains the given fragment.""" + assert text in context.session_screen._text, ( + f"expected {text!r} in rendered text, got: {context.session_screen._text!r}" + ) + + +# --------------------------------------------------------------------------- +# TuiCommandRouter /settings route scenarios +# --------------------------------------------------------------------------- +class _FakePersonaRegistry: + """Minimal persona registry stub for /settings routing tests.""" + + def list_personas(self): + return [] + + +class _FakePersonaState: + """Minimal persona state stub for /settings routing tests.""" + + def get_active(self, session_id): + return None + + +@given("a TuiCommandRouter for settings tests") +def step_router_for_settings(context): + """Create a TuiCommandRouter with minimal stubs for the /settings route.""" + context.router = TuiCommandRouter( + persona_registry=_FakePersonaRegistry(), # type: ignore[arg-type] + persona_state=_FakePersonaState(), # type: ignore[arg-type] + ) diff --git a/features/tui_settings_session_screens.feature b/features/tui_settings_session_screens.feature new file mode 100644 index 000000000..d11a1eedf --- /dev/null +++ b/features/tui_settings_session_screens.feature @@ -0,0 +1,91 @@ +Feature: TUI Settings and Session Management Screens + Implements the Settings screen and Session Management screen + for TUI configuration and session management. + + Background: + Given the TUI screens module is imported + + # ---------- SettingsScreen ---------- + + Scenario: SettingsScreen initializes with default settings + Given a SettingsScreen widget + Then the theme should be "dracula" + And the default_actor should be "default" + And the safety_level should be "medium" + + Scenario: SettingsScreen shows and hides + Given a SettingsScreen widget + When I show the settings screen + Then the settings screen should be visible + When I hide the settings screen + Then the settings screen should not be visible + + Scenario: SettingsScreen navigates settings + Given a SettingsScreen widget that is visible + When I navigate to the next setting + Then the selected_index should be 1 + When I navigate to the previous setting + Then the selected_index should be 0 + + Scenario: SettingsScreen allows setting configuration + Given a SettingsScreen widget + When I set the theme to "light" + And I set the default_actor to "admin" + And I set the safety_level to "high" + Then the theme should be "light" + And the default_actor should be "admin" + And the safety_level should be "high" + + Scenario: SettingsScreen returns current settings as dict + Given a SettingsScreen widget + When I set the theme to "light" + And I set the default_actor to "admin" + And I set the safety_level to "high" + Then get_settings should return theme "light", default_actor "admin", safety_level "high" + + # ---------- SessionManagementScreen ---------- + + Scenario: SessionManagementScreen initializes empty + Given a SessionManagementScreen widget + Then the sessions list should be empty + And the current_session_id should be "default" + + Scenario: SessionManagementScreen shows and hides + Given a SessionManagementScreen widget + When I show the session management screen + Then the session management screen should be visible + When I hide the session management screen + Then the session management screen should not be visible + + Scenario: SessionManagementScreen loads and navigates sessions + Given a SessionManagementScreen widget + When I load 3 sessions + Then the sessions list should have 3 items + When I navigate to the next session + Then the selected_index should be 1 + When I navigate to the previous session + Then the selected_index should be 0 + + Scenario: SessionManagementScreen returns selected session + Given a SessionManagementScreen widget with 3 sessions + When I navigate to the next session + Then get_selected_session should return a SessionInfo object + + Scenario: SessionManagementScreen returns None when no sessions + Given a SessionManagementScreen widget + Then get_selected_session should return None + + Scenario: SessionManagementScreen renders sessions when visible + Given a SessionManagementScreen widget + When I show the session management screen + And I load 3 sessions + Then the session screen rendered text should contain "Session 1" + And the session screen rendered text should contain "10 msgs" + And the session screen rendered text should contain "[CURRENT]" + + # ---------- TuiCommandRouter /settings route ---------- + + Scenario: TuiCommandRouter routes /settings to settings command + Given a TuiCommandRouter for settings tests + When I call handle with raw input "settings" + Then the handle result should be "Settings screen opened" diff --git a/src/cleveragents/tui/commands.py b/src/cleveragents/tui/commands.py index 70898df05..dd24a8349 100644 --- a/src/cleveragents/tui/commands.py +++ b/src/cleveragents/tui/commands.py @@ -86,10 +86,19 @@ class TuiCommandRouter: return self._persona_command(tokens[1:], session_id=session_id) if tokens[0] == "session": return self._session_command(tokens[1:], session_id=session_id) + if tokens[0] == "settings": + return self._settings_command(tokens[1:]) if tokens[0] == "help": return self._help_command(tokens[1:]) return f"Unknown command: /{raw}" + def _settings_command(self, tokens: list[str]) -> str: + """Handle /settings command. + + Opens the settings screen for TUI configuration. + """ + return "Settings screen opened" + def _help_command(self, tokens: list[str]) -> str: """Handle /help [command]. diff --git a/src/cleveragents/tui/screens/__init__.py b/src/cleveragents/tui/screens/__init__.py new file mode 100644 index 000000000..02d8a59c7 --- /dev/null +++ b/src/cleveragents/tui/screens/__init__.py @@ -0,0 +1,5 @@ +"""TUI screen widgets for CleverAgents.""" + +from __future__ import annotations + +__all__ = ["SessionManagementScreen", "SettingsScreen"] diff --git a/src/cleveragents/tui/screens/session_management.py b/src/cleveragents/tui/screens/session_management.py new file mode 100644 index 000000000..df9dcac27 --- /dev/null +++ b/src/cleveragents/tui/screens/session_management.py @@ -0,0 +1,186 @@ +"""Session management screen widget for managing TUI sessions.""" + +from __future__ import annotations + +import importlib +from dataclasses import dataclass +from typing import Any + +__all__ = ["SessionInfo", "SessionManagementScreen"] + + +@dataclass(frozen=True, slots=True) +class SessionInfo: + """Information about a single session.""" + + session_id: str + name: str + message_count: int + created_at: str + + +def _load_static_base() -> type[Any]: + """Load the Textual Static widget base class.""" + 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() + + +def _render_session_list( + sessions: list[SessionInfo], + selected_index: int, + current_session_id: str, +) -> str: + """Render the session list panel.""" + lines = [f"Sessions ({len(sessions)} total):"] + for i, session in enumerate(sessions): + prefix = "❯ " if i == selected_index else " " # noqa: RUF001 + current = " [CURRENT]" if session.session_id == current_session_id else "" + lines.append(f"{prefix}{session.name} ({session.message_count} msgs){current}") + return "\n".join(lines) + + +def _render_session_details(session: SessionInfo | None) -> str: + """Render the session details panel.""" + if session is None: + return "(no session selected)" + + lines = [ + f"Session: {session.name}", + f"ID: {session.session_id}", + f"Messages: {session.message_count}", + f"Created: {session.created_at}", + ] + return "\n".join(lines) + + +def _render_status_bar() -> str: + """Render the status bar at the bottom of the screen.""" + return "j/k Nav │ enter Switch │ r Rename │ d Delete │ n New │ esc Close" + + +def _render_screen( + sessions: list[SessionInfo], + selected_index: int, + current_session_id: str, +) -> str: + """Render the full SessionManagementScreen content as text.""" + session_list = _render_session_list(sessions, selected_index, current_session_id) + selected_session = sessions[selected_index] if sessions else None + session_details = _render_session_details(selected_session) + status_bar = _render_status_bar() + return f"Session Management\n\n{session_list}\n\n{session_details}\n\n{status_bar}" + + +class SessionManagementScreen(_StaticBase): + """TUI widget for managing sessions. + + Allows users to: + - View all sessions + - Rename sessions + - Delete sessions + - Switch between sessions + - Create new sessions + + Keyboard bindings: + - ``j`` / ``k``: navigate session list (next / previous) + - ``enter``: switch to selected session + - ``r``: rename selected session + - ``d``: delete selected session + - ``n``: create new session + - ``escape``: close session management screen + """ + + def __init__(self, *args: object, **kwargs: object) -> None: + """Initialize the SessionManagementScreen.""" + super().__init__(*args, **kwargs) + self._sessions: list[SessionInfo] = [] + self._selected_index: int = 0 + self._current_session_id: str = "default" + self._text: str = "" + self._visible: bool = False + self._refresh() + + @property + def visible(self) -> bool: + """Return whether the session management screen is currently visible.""" + return self._visible + + @property + def selected_index(self) -> int: + """Return the index of the currently selected session.""" + return self._selected_index + + @property + def current_session_id(self) -> str: + """Return the ID of the currently active session.""" + return self._current_session_id + + @property + def sessions(self) -> list[SessionInfo]: + """Return the list of available sessions.""" + return self._sessions.copy() + + def show(self) -> None: + """Show the session management screen.""" + self._visible = True + self._refresh() + + def hide(self) -> None: + """Hide the session management screen.""" + self._visible = False + self._text = "" + self.update("") + + def load_sessions( + self, sessions: list[SessionInfo], current_session_id: str + ) -> None: + """Load a list of sessions and set the current session.""" + self._sessions = sessions + self._current_session_id = current_session_id + self._selected_index = 0 + self._refresh() + + def navigate_next(self) -> None: + """Move selection to the next session (``j`` key).""" + if self._sessions: + self._selected_index = (self._selected_index + 1) % len(self._sessions) + self._refresh() + + def navigate_prev(self) -> None: + """Move selection to the previous session (``k`` key).""" + if self._sessions: + self._selected_index = (self._selected_index - 1) % len(self._sessions) + self._refresh() + + def get_selected_session(self) -> SessionInfo | None: + """Return the currently selected session, or None if no sessions.""" + if self._sessions and 0 <= self._selected_index < len(self._sessions): + return self._sessions[self._selected_index] + return None + + def _refresh(self) -> None: + """Re-render the screen content.""" + if not self._visible: + self._text = "" + self.update("") + return + + self._text = _render_screen( + self._sessions, + self._selected_index, + self._current_session_id, + ) + self.update(self._text) diff --git a/src/cleveragents/tui/screens/settings.py b/src/cleveragents/tui/screens/settings.py new file mode 100644 index 000000000..894b42784 --- /dev/null +++ b/src/cleveragents/tui/screens/settings.py @@ -0,0 +1,170 @@ +"""Settings screen widget for TUI configuration.""" + +from __future__ import annotations + +import importlib +from typing import Any + +__all__ = ["SettingsScreen"] + + +def _load_static_base() -> type[Any]: + """Load the Textual Static widget base class.""" + 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() + + +def _render_settings_screen( + selected_index: int, + theme: str, + default_actor: str, + safety_level: str, +) -> str: + """Render the settings screen content.""" + settings = [ + ("Theme", theme), + ("Default Actor", default_actor), + ("Safety Level", safety_level), + ] + + lines = ["Settings", ""] + for i, (name, value) in enumerate(settings): + prefix = "❯ " if i == selected_index else " " # noqa: RUF001 + lines.append(f"{prefix}{name}: {value}") + + lines.extend( + [ + "", + "Navigation: j/k or ↑/↓ to move", + "Edit: enter to modify selected setting", + "Save: s to save changes", + "Cancel: esc to discard changes", + ] + ) + + return "\n".join(lines) + + +class SettingsScreen(_StaticBase): + """TUI widget for displaying and editing application settings. + + Allows users to configure: + - Theme (color scheme) + - Default actor + - Safety level + - Other TUI preferences + + Keyboard bindings: + - ``j`` / ``k`` or ``↑`` / ``↓``: navigate settings + - ``enter``: edit selected setting + - ``s``: save changes + - ``escape``: discard changes and close + """ + + def __init__(self, *args: object, **kwargs: object) -> None: + """Initialize the SettingsScreen.""" + super().__init__(*args, **kwargs) + self._selected_index: int = 0 + self._theme: str = "dracula" + self._default_actor: str = "default" + self._safety_level: str = "medium" + self._text: str = "" + self._visible: bool = False + self._refresh() + + @property + def visible(self) -> bool: + """Return whether the settings screen is currently visible.""" + return self._visible + + @property + def selected_index(self) -> int: + """Return the index of the currently selected setting.""" + return self._selected_index + + @property + def theme(self) -> str: + """Return the current theme setting.""" + return self._theme + + @property + def default_actor(self) -> str: + """Return the current default actor setting.""" + return self._default_actor + + @property + def safety_level(self) -> str: + """Return the current safety level setting.""" + return self._safety_level + + def show(self) -> None: + """Show the settings screen.""" + self._visible = True + self._refresh() + + def hide(self) -> None: + """Hide the settings screen.""" + self._visible = False + self._text = "" + self.update("") + + def navigate_next(self) -> None: + """Move selection to the next setting (``j`` or ``↓`` key).""" + self._selected_index = (self._selected_index + 1) % 3 + self._refresh() + + def navigate_prev(self) -> None: + """Move selection to the previous setting (``k`` or ``↑`` key).""" + self._selected_index = (self._selected_index - 1) % 3 + self._refresh() + + def set_theme(self, theme: str) -> None: + """Set the theme setting.""" + self._theme = theme + self._refresh() + + def set_default_actor(self, actor: str) -> None: + """Set the default actor setting.""" + self._default_actor = actor + self._refresh() + + def set_safety_level(self, level: str) -> None: + """Set the safety level setting.""" + self._safety_level = level + self._refresh() + + def get_settings(self) -> dict[str, str]: + """Return the current settings as a dictionary.""" + return { + "theme": self._theme, + "default_actor": self._default_actor, + "safety_level": self._safety_level, + } + + def _refresh(self) -> None: + """Re-render the screen content.""" + if not self._visible: + self._text = "" + self.update("") + return + + self._text = _render_settings_screen( + self._selected_index, + self._theme, + self._default_actor, + self._safety_level, + ) + self.update(self._text)