diff --git a/CHANGELOG.md b/CHANGELOG.md index 36aab2df1..c49a14ea5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,11 @@ 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 SessionsScreen workflow (`ctrl+s`) with active/saved session + listing, saved-session resume (`ctrl+r`), and delete/archive actions (`d`/`a`). + Added session overlay rendering widget, persistence-backed saved-session + loading via DI `session_service`, and Behave coverage scenarios for open/list/ + resume/delete/archive behavior in TUI app coverage tests. (#998) - 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..c4164afff 100644 --- a/features/steps/tui_app_coverage_steps.py +++ b/features/steps/tui_app_coverage_steps.py @@ -17,6 +17,7 @@ import os import shutil import sys import tempfile +from datetime import datetime, timedelta from pathlib import Path from types import ModuleType, SimpleNamespace from unittest.mock import MagicMock, patch @@ -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.sessions_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.sessions_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 @@ -510,3 +515,107 @@ def step_alias_check(context): assert ( context._tui_app_mod.CleverAgentsTuiApp.__name__ == "_TextualCleverAgentsTuiApp" ) + + +class _FakeSavedSession: + def __init__( + self, + session_id: str, + actor_name: str, + message_count: int, + updated_at: datetime, + ) -> None: + self.session_id = session_id + self.actor_name = actor_name + self.message_count = message_count + self.updated_at = updated_at + + +class _FakeSessionService: + def __init__(self, sessions: list[_FakeSavedSession]) -> None: + self._sessions = list(sessions) + self.deleted_ids: list[str] = [] + + def list(self) -> list[_FakeSavedSession]: + return list(self._sessions) + + def delete(self, session_id: str) -> None: + self.deleted_ids.append(session_id) + self._sessions = [s for s in self._sessions if s.session_id != session_id] + + +@given("a mock session service with 2 saved sessions") +def step_create_mock_session_service(context): + now = datetime.now() + context._tui_session_service = _FakeSessionService( + [ + _FakeSavedSession( + session_id="saved-alpha", + actor_name="local/orchestrator", + message_count=3, + updated_at=now - timedelta(minutes=2), + ), + _FakeSavedSession( + session_id="saved-beta", + actor_name="local/reviewer", + message_count=5, + updated_at=now - timedelta(minutes=10), + ), + ] + ) + + +@when("I instantiate the Textual TUI app with session service") +def step_instantiate_app_with_session_service(context): + AppClass = context._tui_app_mod._ResolvedTuiApp + context._tui_app = AppClass( + command_router=context._tui_cmd_router, + persona_state=context._tui_persona_state, + session_service=context._tui_session_service, + ) + + +@when("I call action_toggle_sessions on the app") +def step_call_action_toggle_sessions(context): + context._tui_app.action_toggle_sessions() + + +def _sessions_overlay_text(context) -> str: + from cleveragents.tui.widgets.sessions_overlay import SessionsOverlay + + overlay = context._tui_app.query_one("#sessions-overlay", SessionsOverlay) + return getattr(overlay, "_text", "") + + +@then('the sessions overlay should contain "{text}"') +def step_sessions_overlay_contains(context, text): + rendered = _sessions_overlay_text(context) + assert text in rendered, f"Expected '{text}' in sessions overlay: {rendered}" + + +@then('the sessions overlay should not contain "{text}"') +def step_sessions_overlay_not_contains(context, text): + rendered = _sessions_overlay_text(context) + assert text not in rendered, ( + f"Did not expect '{text}' in sessions overlay: {rendered}" + ) + + +@when("I call action_resume_saved_session on the app") +def step_call_action_resume_saved_session(context): + context._tui_app.action_resume_saved_session() + + +@when("I call action_delete_selected_session on the app") +def step_call_action_delete_selected_session(context): + context._tui_app.action_delete_selected_session() + + +@when("I call action_archive_selected_session on the app") +def step_call_action_archive_selected_session(context): + context._tui_app.action_archive_selected_session() + + +@then('the session service delete calls should include "{session_id}"') +def step_session_service_delete_called(context, session_id): + assert session_id in context._tui_session_service.deleted_ids diff --git a/features/tui_app_coverage.feature b/features/tui_app_coverage.feature index 2392dca85..a106fa28f 100644 --- a/features/tui_app_coverage.feature +++ b/features/tui_app_coverage.feature @@ -47,7 +47,7 @@ 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 9 key bindings # --- compose method (lines 102-112) --- @@ -196,3 +196,53 @@ 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)" + + # --- SessionsScreen hotkeys and workflows --- + + Scenario: sessions screen opens via ctrl+s action + Given a mock command router and persona state + And a mock session service with 2 saved sessions + When I instantiate the Textual TUI app with session service + And I call on_mount on the app + And I call action_toggle_sessions on the app + Then the sessions overlay should contain "Active Sessions" + And the sessions overlay should contain "Saved Sessions" + + Scenario: sessions screen lists active and saved sessions + Given a mock command router and persona state + And a mock session service with 2 saved sessions + When I instantiate the Textual TUI app with session service + And I call on_mount on the app + And I call action_toggle_sessions on the app + Then the sessions overlay should contain "default" + And the sessions overlay should contain "saved-alpha" + + Scenario: resume workflow promotes selected saved session + Given a mock command router and persona state + And a mock session service with 2 saved sessions + When I instantiate the Textual TUI app with session service + And I call on_mount on the app + And I call action_toggle_sessions on the app + And I call action_resume_saved_session on the app + Then the app should have a _session with session_id "saved-alpha" + And the conversation widget should contain "Resumed session: saved-alpha" + + Scenario: delete session action removes selected saved session + Given a mock command router and persona state + And a mock session service with 2 saved sessions + When I instantiate the Textual TUI app with session service + And I call on_mount on the app + And I call action_toggle_sessions on the app + And I call action_delete_selected_session on the app + Then the session service delete calls should include "saved-alpha" + And the sessions overlay should not contain "saved-alpha" + + Scenario: archive session action hides selected saved session + Given a mock command router and persona state + And a mock session service with 2 saved sessions + When I instantiate the Textual TUI app with session service + And I call on_mount on the app + And I call action_toggle_sessions on the app + And I call action_archive_selected_session on the app + Then the sessions overlay should not contain "saved-alpha" + And the conversation widget should contain "Archived session: saved-alpha" diff --git a/src/cleveragents/tui/app.py b/src/cleveragents/tui/app.py index 516a9cb49..349f2dad8 100644 --- a/src/cleveragents/tui/app.py +++ b/src/cleveragents/tui/app.py @@ -5,6 +5,7 @@ from __future__ import annotations import importlib import os from dataclasses import dataclass +from datetime import datetime from typing import TYPE_CHECKING, Any, ClassVar, Protocol from cleveragents.tui.input.modes import InputMode, InputModeRouter @@ -18,6 +19,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.sessions_overlay import SessionsOverlay from cleveragents.tui.widgets.slash_command_overlay import SlashCommandOverlay if TYPE_CHECKING: @@ -58,6 +60,37 @@ class SessionView: transcript: list[str] +@dataclass(slots=True) +class SavedSessionView: + """Saved-session row for the sessions overlay.""" + + session_id: str + actor_name: str + prompt_count: int + updated_at: datetime + + +class _SessionRecord(Protocol): + """Shape of session records returned by session service list().""" + + session_id: str + actor_name: str | None + message_count: int + updated_at: datetime + + +class _SessionService(Protocol): + """Protocol for session persistence operations used by the TUI.""" + + def list(self) -> list[_SessionRecord]: + """List saved sessions.""" + ... + + def delete(self, session_id: str) -> None: + """Delete a saved session by ID.""" + ... + + class _CommandRouter(Protocol): """Router protocol used by the TUI input-mode command handler.""" @@ -70,9 +103,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, + session_service: _SessionService | None = None, ) -> None: - del command_router, persona_state + del command_router, persona_state, session_service def run(self) -> None: raise RuntimeError( @@ -91,6 +128,12 @@ if _TEXTUAL_AVAILABLE: ("ctrl+q", "quit", "Quit"), ("f1", "help", "Help"), ("ctrl+t", "cycle_preset", "Cycle Preset"), + ("ctrl+s", "toggle_sessions", "Sessions"), + ("ctrl+r", "resume_saved_session", "Resume Saved"), + ("d", "delete_selected_session", "Delete Session"), + ("a", "archive_selected_session", "Archive Session"), + ("j", "sessions_next", "Next Session"), + ("k", "sessions_previous", "Previous Session"), ] def __init__( @@ -98,17 +141,25 @@ if _TEXTUAL_AVAILABLE: *, command_router: _CommandRouter, persona_state: PersonaState, + session_service: _SessionService | None = None, ) -> None: super().__init__() self._command_router = command_router self._persona_state = persona_state + self._session_service = session_service self._session = SessionView(session_id="default", transcript=[]) + self._active_session_ids = [self._session.session_id] + self._saved_sessions: list[SavedSessionView] = [] + self._archived_session_ids: set[str] = set() + self._sessions_visible = False + self._selected_saved_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 SessionsOverlay(id="sessions-overlay") yield ReferencePickerOverlay(id="reference-picker") yield SlashCommandOverlay(id="slash-overlay") yield PromptInput( @@ -125,6 +176,8 @@ if _TEXTUAL_AVAILABLE: ref_picker.set_suggestions("", []) slash = self.query_one("#slash-overlay", SlashCommandOverlay) slash.set_commands("", slash_command_names()) + self._refresh_saved_sessions() + self._render_sessions_overlay() def action_help(self) -> None: prompt = self.query_one("#prompt", PromptInput) @@ -149,6 +202,168 @@ if _TEXTUAL_AVAILABLE: scope_text=scope_text, ) + @staticmethod + def _relative_time(value: datetime) -> str: + delta_seconds = int((datetime.now() - value).total_seconds()) + if delta_seconds < 60: + return "just now" + minutes = delta_seconds // 60 + if minutes < 60: + return f"{minutes}m ago" + hours = minutes // 60 + if hours < 24: + return f"{hours}h ago" + return value.strftime("%b %d %H:%M") + + def _refresh_saved_sessions(self) -> None: + if self._session_service is None: + self._saved_sessions = [] + self._selected_saved_index = 0 + return + + records = self._session_service.list() + filtered: list[SavedSessionView] = [] + for record in records: + if record.session_id in self._active_session_ids: + continue + if record.session_id in self._archived_session_ids: + continue + filtered.append( + SavedSessionView( + session_id=record.session_id, + actor_name=record.actor_name or "(none)", + prompt_count=record.message_count, + updated_at=record.updated_at, + ) + ) + + self._saved_sessions = filtered + if not self._saved_sessions: + self._selected_saved_index = 0 + elif self._selected_saved_index >= len(self._saved_sessions): + self._selected_saved_index = len(self._saved_sessions) - 1 + + def _active_lines(self) -> list[str]: + lines: list[str] = [] + for session_id in self._active_session_ids: + marker = "*" if session_id == self._session.session_id else " " + lines.append(f"{marker} {session_id}") + if not lines: + lines.append("(none)") + return lines + + def _saved_lines(self) -> list[str]: + lines: list[str] = [] + for item in self._saved_sessions: + updated = self._relative_time(item.updated_at) + lines.append( + " | ".join( + [ + item.session_id, + item.actor_name, + f"{item.prompt_count} prompts", + updated, + ] + ) + ) + if not lines: + lines.append("(none)") + return lines + + def _render_sessions_overlay(self) -> None: + overlay = self.query_one("#sessions-overlay", SessionsOverlay) + selected_saved_id = None + if self._saved_sessions: + selected_saved_id = self._saved_sessions[ + self._selected_saved_index + ].session_id + overlay.set_content( + visible=self._sessions_visible, + active_lines=self._active_lines(), + saved_lines=self._saved_lines(), + selected_saved_id=selected_saved_id, + ) + + def action_toggle_sessions(self) -> None: + self._sessions_visible = not self._sessions_visible + if self._sessions_visible: + self._refresh_saved_sessions() + self._render_sessions_overlay() + + def action_sessions_next(self) -> None: + if not self._saved_sessions: + return + self._selected_saved_index = (self._selected_saved_index + 1) % len( + self._saved_sessions + ) + self._render_sessions_overlay() + + def action_sessions_previous(self) -> None: + if not self._saved_sessions: + return + self._selected_saved_index = (self._selected_saved_index - 1) % len( + self._saved_sessions + ) + self._render_sessions_overlay() + + def action_resume_saved_session(self) -> None: + conversation = self.query_one("#conversation", _Static) + if not self._saved_sessions: + conversation.update("No saved sessions to resume") + return + + selected = self._saved_sessions[self._selected_saved_index] + self._session.session_id = selected.session_id + if selected.session_id not in self._active_session_ids: + self._active_session_ids.append(selected.session_id) + self._saved_sessions = [ + item + for item in self._saved_sessions + if item.session_id != selected.session_id + ] + if self._selected_saved_index >= len(self._saved_sessions): + self._selected_saved_index = max(0, len(self._saved_sessions) - 1) + self._refresh_persona_bar() + self._render_sessions_overlay() + conversation.update(f"Resumed session: {selected.session_id}") + + def action_delete_selected_session(self) -> None: + conversation = self.query_one("#conversation", _Static) + if not self._saved_sessions: + conversation.update("No saved sessions to delete") + return + + selected = self._saved_sessions[self._selected_saved_index] + if self._session_service is not None: + self._session_service.delete(selected.session_id) + self._saved_sessions = [ + item + for item in self._saved_sessions + if item.session_id != selected.session_id + ] + if self._selected_saved_index >= len(self._saved_sessions): + self._selected_saved_index = max(0, len(self._saved_sessions) - 1) + self._render_sessions_overlay() + conversation.update(f"Deleted session: {selected.session_id}") + + def action_archive_selected_session(self) -> None: + conversation = self.query_one("#conversation", _Static) + if self._saved_sessions: + selected = self._saved_sessions[self._selected_saved_index] + self._archived_session_ids.add(selected.session_id) + self._saved_sessions = [ + item + for item in self._saved_sessions + if item.session_id != selected.session_id + ] + if self._selected_saved_index >= len(self._saved_sessions): + self._selected_saved_index = max(0, len(self._saved_sessions) - 1) + self._render_sessions_overlay() + conversation.update(f"Archived session: {selected.session_id}") + return + + conversation.update("No saved sessions to archive") + def on_input_submitted(self, event: InputSubmittedEvent) -> None: del event prompt = self.query_one("#prompt", PromptInput) diff --git a/src/cleveragents/tui/cleveragents.tcss b/src/cleveragents/tui/cleveragents.tcss index 94438c763..00bc2de0f 100644 --- a/src/cleveragents/tui/cleveragents.tcss +++ b/src/cleveragents/tui/cleveragents.tcss @@ -22,6 +22,14 @@ Screen { background: $panel-lighten-1; } +#sessions-overlay { + height: auto; + max-height: 14; + padding: 0 1; + border: round $warning; + color: $text; +} + #reference-picker { height: auto; max-height: 8; diff --git a/src/cleveragents/tui/commands.py b/src/cleveragents/tui/commands.py index 1134e995e..144d698b7 100644 --- a/src/cleveragents/tui/commands.py +++ b/src/cleveragents/tui/commands.py @@ -53,6 +53,7 @@ def run_tui(*, headless: bool = False) -> int: container = get_container() registry = container.persona_registry() state = container.persona_state(registry=registry) + session_service = container.session_service() router = TuiCommandRouter(persona_registry=registry, persona_state=state) if headless: @@ -67,6 +68,10 @@ def run_tui(*, headless: bool = False) -> int: print(json.dumps(payload, indent=2)) return 0 - app = CleverAgentsTuiApp(command_router=router, persona_state=state) + app = CleverAgentsTuiApp( + command_router=router, + persona_state=state, + session_service=session_service, + ) app.run() return 0 diff --git a/src/cleveragents/tui/widgets/__init__.py b/src/cleveragents/tui/widgets/__init__.py index 51776ce71..3b259da89 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.sessions_overlay import SessionsOverlay from cleveragents.tui.widgets.slash_command_overlay import SlashCommandOverlay __all__ = [ @@ -12,5 +13,6 @@ __all__ = [ "PromptInput", "PromptSubmitted", "ReferencePickerOverlay", + "SessionsOverlay", "SlashCommandOverlay", ] diff --git a/src/cleveragents/tui/widgets/sessions_overlay.py b/src/cleveragents/tui/widgets/sessions_overlay.py new file mode 100644 index 000000000..249abf40d --- /dev/null +++ b/src/cleveragents/tui/widgets/sessions_overlay.py @@ -0,0 +1,55 @@ +"""Sessions 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 SessionsOverlay(_StaticBase): + """Renderable sessions overlay with active and saved sections.""" + + def set_content( + self, + *, + visible: bool, + active_lines: list[str], + saved_lines: list[str], + selected_saved_id: str | None, + ) -> None: + if not visible: + self.update("") + return + + lines = ["Sessions", "", "Active Sessions"] + lines.extend(f" {line}" for line in active_lines) + lines.append("") + lines.append("Saved Sessions") + + for line in saved_lines: + marker = ( + "*" if selected_saved_id and line.startswith(selected_saved_id) else " " + ) + lines.append(f"{marker} {line}") + + lines.append("") + lines.append("enter Switch | ctrl+r Resume saved | d Delete | a Archive") + self.update("\n".join(lines))