feat(tui): implement SessionsScreen with active/saved session listing #1236
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
+217
-2
@@ -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
|
||||
|
freemo
commented
The 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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
Reference in New Issue
Block a user
Global single-letter bindings are risky. The
d,a,j,kkeys 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.), pressingdwill silently delete a session.Consider either:
if not self._sessions_visible: returnguards toaction_delete_selected_sessionandaction_archive_selected_session(you already have theif not self._saved_sessionsguard, but that doesn't prevent the action from running when the overlay is hidden)SessionsOverlaywidget so they're only active when it has focus