feat(tui): implement Settings screen and session management screen #10651

Merged
HAL9000 merged 8 commits from feat/v370/tui-settings-sessions-screens into master 2026-06-13 21:11:30 +00:00
6 changed files with 739 additions and 0 deletions
@@ -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]
)
@@ -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"
+9
View File
@@ -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].
+5
View File
@@ -0,0 +1,5 @@
"""TUI screen widgets for CleverAgents."""
from __future__ import annotations
__all__ = ["SessionManagementScreen", "SettingsScreen"]
@@ -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)
+170
View File
@@ -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)