feat(tui): implement SessionsScreen with active/saved session listing #1236

Closed
brent.edwards wants to merge 1 commits from feature/m8-tui-sessions-screen into master
8 changed files with 453 additions and 4 deletions
+5
View File
@@ -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
+109
View File
@@ -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
+51 -1
View File
@@ -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) ---
1
@@ -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"
+217 -2
View File
@@ -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"),
Outdated
Review

Global single-letter bindings are risky. The d, a, j, k keys are bound at the App level. While Textual's Input widget captures keys when focused, if focus ever leaves the prompt (click on conversation, notification, etc.), pressing d will silently delete a session.

Consider either:

  1. Adding if not self._sessions_visible: return guards to action_delete_selected_session and action_archive_selected_session (you already have the if not self._saved_sessions guard, but that doesn't prevent the action from running when the overlay is hidden)
  2. Moving these bindings to the SessionsOverlay widget so they're only active when it has focus
**Global single-letter bindings are risky.** The `d`, `a`, `j`, `k` keys are bound at the App level. While Textual's Input widget captures keys when focused, if focus ever leaves the prompt (click on conversation, notification, etc.), pressing `d` will silently delete a session. Consider either: 1. Adding `if not self._sessions_visible: return` guards to `action_delete_selected_session` and `action_archive_selected_session` (you already have the `if not self._saved_sessions` guard, but that doesn't prevent the action from running when the overlay is hidden) 2. Moving these bindings to the `SessionsOverlay` widget so they're only active when it has focus
("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
Outdated
Review

The action_archive_selected_session method uses a different guard pattern than action_delete_selected_session and action_resume_saved_session. Those two use early-return for the empty case:

if not self._saved_sessions:
    conversation.update("No saved sessions to delete")
    return

But this method puts the empty-case message at the bottom after the main logic. For consistency and readability, consider using the same early-return pattern.

The `action_archive_selected_session` method uses a different guard pattern than `action_delete_selected_session` and `action_resume_saved_session`. Those two use early-return for the empty case: ```python if not self._saved_sessions: conversation.update("No saved sessions to delete") return ``` But this method puts the empty-case message at the bottom after the main logic. For consistency and readability, consider using the same early-return pattern.
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)
+8
View File
@@ -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;
+6 -1
View File
@@ -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
+2
View File
@@ -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",
]
@@ -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))