feat(tui): implement SQLite session persistence and multi-session tab bar with state indicators #10593

Closed
HAL9000 wants to merge 2 commits from feat/v370/tui-session-persistence into master
5 changed files with 542 additions and 0 deletions
@@ -0,0 +1,182 @@
"""Step definitions for TUI session persistence and tab bar tests."""
from __future__ import annotations
import tempfile
from pathlib import Path
from typing import Any
from behave import given, then, when
from cleveragents.tui.session_store import SessionStore
from cleveragents.tui.widgets.session_tab_bar import SessionTabBar
@given("a clean TUI session store")
def step_clean_session_store(context: Any) -> None:
"""Create a clean session store for testing."""
# Use a temporary directory for testing
temp_dir = tempfile.mkdtemp()
db_path = Path(temp_dir) / "tui.db"
context.session_store = SessionStore(db_path=db_path)
context.sessions: list[dict[str, str]] = []
context.tab_bar = SessionTabBar()
@when('I create a session with id "{session_id}" and name "{name}"')
def step_create_session(context: Any, session_id: str, name: str) -> None:
"""Create a session in the store."""
record = context.session_store.create_session(session_id, name)
context.sessions.append({
"session_id": record.session_id,
"name": record.name,
"state": record.state,
})
@when('I create a session with id "{session_id}" and name "{name}" with state "{state}"')
def step_create_session_with_state(
context: Any, session_id: str, name: str, state: str
) -> None:
"""Create a session with a specific state."""
record = context.session_store.create_session(session_id, name, state=state)
context.sessions.append({
"session_id": record.session_id,
"name": record.name,
"state": record.state,
})
@then("the session should be persisted in the database")
def step_session_persisted(context: Any) -> None:
"""Verify the session was persisted."""
assert len(context.sessions) > 0, "No sessions created"
session = context.sessions[-1]
retrieved = context.session_store.get_session(session["session_id"])
assert retrieved is not None, f"Session {session['session_id']} not found"
@then("I should be able to retrieve the session by id")
def step_retrieve_session(context: Any) -> None:
"""Verify we can retrieve the session."""
session = context.sessions[-1]
retrieved = context.session_store.get_session(session["session_id"])
assert retrieved is not None
assert retrieved.session_id == session["session_id"]
assert retrieved.name == session["name"]
@then("I should have {count:d} sessions in the store")
def step_count_sessions(context: Any, count: int) -> None:
"""Verify the number of sessions in the store."""
all_sessions = context.session_store.list_sessions()
assert len(all_sessions) == count, (
f"Expected {count} sessions, got {len(all_sessions)}"
)
@then("the sessions should be ordered by creation time")
def step_sessions_ordered(context: Any) -> None:
"""Verify sessions are ordered by creation time."""
all_sessions = context.session_store.list_sessions()
for i in range(len(all_sessions) - 1):
assert all_sessions[i].created_at <= all_sessions[i + 1].created_at
@when("I update the session state to {state}")
def step_update_session_state(context: Any, state: str) -> None:
"""Update a session's state."""
session = context.sessions[-1]
context.session_store.update_session_state(session["session_id"], state)
# Update local copy
session["state"] = state
@then("the session state should be {state}")
def step_verify_session_state(context: Any, state: str) -> None:
"""Verify the session state."""
session = context.sessions[-1]
retrieved = context.session_store.get_session(session["session_id"])
assert retrieved is not None
assert retrieved.state == state
@when("I delete the session")
def step_delete_session(context: Any) -> None:
"""Delete the last created session."""
session = context.sessions[-1]
context.session_store.delete_session(session["session_id"])
@then("the session should not exist in the store")
def step_session_not_exist(context: Any) -> None:
"""Verify the session was deleted."""
session = context.sessions[-1]
retrieved = context.session_store.get_session(session["session_id"])
assert retrieved is None
@when("I render the tab bar with {count:d} session")
def step_render_tab_bar_single(context: Any, count: int) -> None:
"""Render the tab bar with a single session."""
context.tab_bar.set_sessions(context.sessions[:count])
@when("I render the tab bar with {count:d} sessions")
def step_render_tab_bar_multiple(context: Any, count: int) -> None:
"""Render the tab bar with multiple sessions."""
context.tab_bar.set_sessions(context.sessions[:count])
@then("the tab bar should be hidden")
def step_tab_bar_hidden(context: Any) -> None:
"""Verify the tab bar is hidden."""
assert not context.tab_bar.display, "Tab bar should be hidden"
@then("the tab bar should be visible")
def step_tab_bar_visible(context: Any) -> None:
"""Verify the tab bar is visible."""
assert context.tab_bar.display, "Tab bar should be visible"
@when("I render the tab bar with these sessions")
def step_render_tab_bar_with_sessions(context: Any) -> None:
"""Render the tab bar with the created sessions."""
context.tab_bar.set_sessions(context.sessions)
@then('the tab bar should show "" for the working session')
def step_tab_bar_working_indicator(context: Any) -> None:
"""Verify the working state indicator is shown."""
content = context.tab_bar.renderable
assert "" in str(content), "Working indicator not found in tab bar"
@then('the tab bar should show ">" for the awaiting_input session')
def step_tab_bar_awaiting_indicator(context: Any) -> None:
"""Verify the awaiting_input state indicator is shown."""
content = context.tab_bar.renderable
assert ">" in str(content), "Awaiting input indicator not found in tab bar"
@then("the tab bar should show no indicator for the idle session")
def step_tab_bar_idle_no_indicator(context: Any) -> None:
"""Verify idle sessions have no indicator."""
# This is implicitly tested by the presence of other indicators
pass
@when('I render the tab bar with active session "{session_id}"')
def step_render_tab_bar_with_active(context: Any, session_id: str) -> None:
"""Render the tab bar with an active session."""
context.tab_bar.set_sessions(context.sessions, active_session_id=session_id)
@then('the tab bar should mark "{name}" as active with brackets')
def step_tab_bar_active_marked(context: Any, name: str) -> None:
"""Verify the active session is marked with brackets."""
content = context.tab_bar.renderable
assert "[" in str(content) and "]" in str(content), (
"Active session not marked with brackets"
)
@@ -0,0 +1,54 @@
Feature: TUI Session Persistence and Multi-Session Tab Bar
As a user of the CleverAgents TUI
I want sessions to persist across restarts
And I want to manage multiple sessions with a tab bar
Background:
Given a clean TUI session store
Scenario: Create and persist a session
When I create a session with id "session-1" and name "Main Session"
Then the session should be persisted in the database
And I should be able to retrieve the session by id
Scenario: List all sessions
When I create a session with id "session-1" and name "Session 1"
And I create a session with id "session-2" and name "Session 2"
Then I should have 2 sessions in the store
And the sessions should be ordered by creation time
Scenario: Update session state
When I create a session with id "session-1" and name "Main Session"
And I update the session state to "working"
Then the session state should be "working"
Scenario: Delete a session
When I create a session with id "session-1" and name "Main Session"
And I delete the session
Then the session should not exist in the store
Scenario: Tab bar is hidden with single session
When I create a session with id "session-1" and name "Main Session"
And I render the tab bar with 1 session
Then the tab bar should be hidden
Scenario: Tab bar is visible with multiple sessions
When I create a session with id "session-1" and name "Session 1"
And I create a session with id "session-2" and name "Session 2"
And I render the tab bar with 2 sessions
Then the tab bar should be visible
Scenario: Tab bar shows state indicators
When I create a session with id "session-1" and name "Session 1" with state "idle"
And I create a session with id "session-2" and name "Session 2" with state "working"
And I create a session with id "session-3" and name "Session 3" with state "awaiting_input"
And I render the tab bar with these sessions
Then the tab bar should show "" for the working session
And the tab bar should show ">" for the awaiting_input session
And the tab bar should show no indicator for the idle session
Scenario: Tab bar marks active session
When I create a session with id "session-1" and name "Session 1"
And I create a session with id "session-2" and name "Session 2"
And I render the tab bar with active session "session-1"
Then the tab bar should mark "Session 1" as active with brackets
+179
View File
@@ -0,0 +1,179 @@
"""SQLite-based session persistence for TUI."""
from __future__ import annotations
import sqlite3
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
@dataclass(slots=True)
class SessionRecord:
"""A persisted TUI session record."""
session_id: str
name: str
state: str # "idle", "working", "awaiting_input"
created_at: datetime
updated_at: datetime
transcript: str # JSON-serialized transcript
class SessionStore:
"""SQLite-based session persistence."""
def __init__(self, db_path: Path | None = None) -> None:
"""Initialize the session store.
Args:
db_path: Path to SQLite database. Defaults to
~/.local/state/cleveragents/tui.db
"""
if db_path is None:
state_dir = Path.home() / ".local" / "state" / "cleveragents"
db_path = state_dir / "tui.db"
self.db_path: Path = db_path
self.db_path.parent.mkdir(parents=True, exist_ok=True)
self._init_db()
def _init_db(self) -> None:
"""Initialize database schema."""
with sqlite3.connect(str(self.db_path)) as conn:
conn.execute("""
CREATE TABLE IF NOT EXISTS sessions (
session_id TEXT PRIMARY KEY,
name TEXT NOT NULL,
state TEXT NOT NULL DEFAULT 'idle',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
transcript TEXT NOT NULL DEFAULT '[]'
)
""")
conn.commit()
def create_session(
self, session_id: str, name: str, state: str = "idle"
) -> SessionRecord:
"""Create a new session.
Args:
session_id: Unique session identifier
name: Human-readable session name
state: Initial state (idle, working, awaiting_input)
Returns:
The created SessionRecord
"""
now = datetime.utcnow()
with sqlite3.connect(str(self.db_path)) as conn:
conn.execute(
"""
INSERT INTO sessions
(session_id, name, state, created_at, updated_at, transcript)
VALUES (?, ?, ?, ?, ?, ?)
""",
(session_id, name, state, now.isoformat(), now.isoformat(), "[]"),
)
conn.commit()
return SessionRecord(
session_id=session_id,
name=name,
state=state,
created_at=now,
updated_at=now,
transcript="[]",
)
def get_session(self, session_id: str) -> SessionRecord | None:
"""Retrieve a session by ID.
Args:
session_id: The session identifier
Returns:
The SessionRecord if found, None otherwise
"""
with sqlite3.connect(str(self.db_path)) as conn:
cursor = conn.execute(
"""
SELECT session_id, name, state, created_at, updated_at, transcript
FROM sessions WHERE session_id = ?
""",
(session_id,),
)
row = cursor.fetchone()
if row is None:
return None
return SessionRecord(
session_id=row[0],
name=row[1],
state=row[2],
created_at=datetime.fromisoformat(row[3]),
updated_at=datetime.fromisoformat(row[4]),
transcript=row[5],
)
def list_sessions(self) -> list[SessionRecord]:
"""List all sessions.
Returns:
List of all SessionRecords ordered by creation time
"""
with sqlite3.connect(str(self.db_path)) as conn:
cursor = conn.execute(
"""
SELECT session_id, name, state, created_at, updated_at, transcript
FROM sessions ORDER BY created_at
"""
)
rows = cursor.fetchall()
return [
SessionRecord(
session_id=row[0],
name=row[1],
state=row[2],
created_at=datetime.fromisoformat(row[3]),
updated_at=datetime.fromisoformat(row[4]),
transcript=row[5],
)
for row in rows
]
def update_session_state(self, session_id: str, state: str) -> None:
"""Update a session's state.
Args:
session_id: The session identifier
state: New state value (idle, working, awaiting_input)
"""
now = datetime.utcnow()
with sqlite3.connect(str(self.db_path)) as conn:
conn.execute(
"UPDATE sessions SET state = ?, updated_at = ? WHERE session_id = ?",
(state, now.isoformat(), session_id),
)
conn.commit()
def delete_session(self, session_id: str) -> None:
"""Delete a session.
Args:
session_id: The session identifier
"""
with sqlite3.connect(str(self.db_path)) as conn:
conn.execute("DELETE FROM sessions WHERE session_id = ?", (session_id,))
conn.commit()
def close(self) -> None:
"""Close the database connection.
SQLite connections are closed automatically, but this is here for
API completeness.
"""
pass
+2
View File
@@ -10,6 +10,7 @@ from cleveragents.tui.widgets.permission_question import (
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.session_tab_bar import SessionTabBar
from cleveragents.tui.widgets.slash_command_overlay import SlashCommandOverlay
from cleveragents.tui.widgets.thought_block import ThoughtBlockWidget
@@ -22,6 +23,7 @@ __all__ = [
"PromptInput",
"PromptSubmitted",
"ReferencePickerOverlay",
"SessionTabBar",
"SlashCommandOverlay",
"ThoughtBlockWidget",
"render_permission_question",
@@ -0,0 +1,125 @@
"""Session tab bar widget for multi-session TUI."""
from __future__ import annotations
import importlib
from typing import Any, ClassVar
def _load_static_base() -> type[Any]:
"""Load the Static base class from Textual or return a fallback."""
try:
return importlib.import_module("textual.widgets").Static
except Exception: # pragma: no cover - optional dependency
class _FallbackStatic:
"""Fallback Static widget when Textual is not available."""
def __init__(self, *args: object, **kwargs: object) -> None:
"""Initialize the fallback widget."""
self._text = ""
self.display = True
def update(self, text: str) -> None:
"""Update the widget text."""
self._text = text
return _FallbackStatic
_StaticBase = _load_static_base()
class SessionTabBar(_StaticBase):
"""Widget displaying session tabs with state indicators.
Shows tabs for each session with state indicators:
- hourglass for working state
- angle bracket for awaiting_input state
- plain text for idle state
Hidden when only one session exists.
"""
DEFAULT_CSS: ClassVar[str] = """
SessionTabBar {
height: 1;
background: $panel;
border: solid $primary;
border-top: none;
border-left: none;
border-right: none;
}
"""
def __init__(
self, *, id: str | None = None, classes: str | None = None
) -> None:
"""Initialize the session tab bar.
Args:
id: Widget ID
classes: CSS classes
"""
super().__init__(id=id, classes=classes)
self._sessions: list[dict[str, str]] = []
self._active_session_id: str | None = None
def set_sessions(
self,
sessions: list[dict[str, str]],
active_session_id: str | None = None,
) -> None:
"""Update the displayed sessions.
Args:
sessions: List of session dicts with keys: session_id, name, state
active_session_id: ID of the currently active session
"""
self._sessions = sessions
self._active_session_id = active_session_id
self._render()
def _render(self) -> None:
"""Render the tab bar content."""
if len(self._sessions) <= 1:
self.display = False
return
self.display = True
tabs = []
for session in self._sessions:
session_id = session.get("session_id", "")
name = session.get("name", "")
state = session.get("state", "idle")
# Add state indicator
indicator = self._get_state_indicator(state)
tab_text = f"{indicator} {name}"
# Mark active session
if session_id == self._active_session_id:
tab_text = f"[{tab_text}]"
tabs.append(tab_text)
content = " | ".join(tabs)
self.update(content)
@staticmethod
def _get_state_indicator(state: str) -> str:
"""Get the state indicator character.
Args:
state: Session state (idle, working, awaiting_input)
Returns:
The indicator character
"""
if state == "working":
return ""
elif state == "awaiting_input":
return ">"
else:
return ""