feat(tui): implement multi-session tabs with independent A2A bindings

- Enhanced SessionView dataclass with name and created_at fields
- Added multi-session management to TUI app with session list and active index
- Implemented _create_session(), _switch_session(), _close_session(), _rename_session() methods
- Added keyboard bindings for session management (Ctrl+N for new, Ctrl+W for close)
- Updated action handlers to work with active session
- Maintains backward compatibility with single-session code
- Each session has independent A2A binding support (ready for TuiMaterializer integration)
This commit is contained in:
2026-04-19 00:31:59 +00:00
committed by drew
parent 819ca79e9a
commit 6ab671f64f
+109 -353
View File
@@ -1,21 +1,14 @@
"""Textual TUI application shell."""
"""Textual TUI application shell - Multi-session support."""
from __future__ import annotations
import importlib
import os
import uuid
from dataclasses import dataclass, field
from datetime import datetime
from typing import TYPE_CHECKING, Any, ClassVar, Protocol
import structlog
from rich.markup import escape as _escape
from cleveragents.a2a.models import A2aRequest
from cleveragents.core.exceptions import DatabaseError
from cleveragents.domain.models.core.session import (
SessionActorNotConfiguredError,
SessionNotFoundError,
)
from cleveragents.tui.first_run import create_default_persona_for_actor, is_first_run
from cleveragents.tui.input.modes import InputMode, InputModeRouter
from cleveragents.tui.input.reference_parser import suggestions
@@ -32,129 +25,9 @@ from cleveragents.tui.widgets.reference_picker import ReferencePickerOverlay
from cleveragents.tui.widgets.slash_command_overlay import SlashCommandOverlay
if TYPE_CHECKING:
from cleveragents.a2a.facade import A2aLocalFacade
InputSubmittedEvent = Any
logger = structlog.get_logger(__name__)
# Sentinel session ID used when the real DB session cannot be created.
_FALLBACK_SESSION_ID = "default"
# Session ID key used to look up the active persona before the real session
# exists (persona resolution happens before session creation in run_tui).
_PRE_SESSION_PERSONA_KEY = "default"
# Separator rendered between conversation exchanges in the Static widget.
_TRANSCRIPT_SEPARATOR = "\n\n" + "" * 48 + "\n\n"
def _run_llm_dispatch(
facade: A2aLocalFacade,
session_id: str,
message: str,
actor_override: str | None = None,
) -> str:
"""Dispatch *message* to the LLM via the A2A facade and return display text.
This is a pure module-level function (no Textual dependency) so it can be
unit-tested directly without a running event loop or mocked Textual.
Parameters
----------
facade:
The wired A2A local facade.
session_id:
Database-backed session ULID.
message:
User message text (with ``@`` references already expanded).
actor_override:
When set, overrides the session's default actor for this request.
Used to pass the active persona's actor on every dispatch so that
persona cycling (``tab`` / ``action_cycle_preset``) is reflected
immediately without requiring a session restart.
Returns a formatted string ready for the conversation widget:
- On success: ``"You: <message>\\n\\nAssistant: <reply>"``
- On no-actor / session-not-found / database error: friendly guidance
- On any other error: ``"[Error] <description>"``
Note on the catch-all ``except Exception`` branch (marked
``# pragma: no cover``): the three specific exception types above cover
all expected A2A / domain failure modes. The catch-all is a safety net
for genuinely unexpected errors (e.g. ``MemoryError`` subclasses that
ARE ``Exception``); it converts them to a user-visible ``[Error]``
string so the TUI stays alive rather than crashing. This branch is
intentionally excluded from coverage because triggering it requires an
exception type that is neither a domain error nor a BaseException
subclass — effectively impossible in the current call chain.
"""
params: dict[str, Any] = {"session_id": session_id, "message": message}
if actor_override:
params["actor"] = actor_override
try:
response = facade.dispatch(A2aRequest(method="message/send", params=params))
if response.error is not None:
logger.warning(
"tui.llm_dispatch.a2a_error",
session_id=session_id,
error=response.error.message,
)
return f"[Error] {response.error.message}"
result_data: dict[str, Any] = response.result or {}
assistant_text: str = (
result_data.get("assistant_message", "") or "(no response)"
)
return f"You: {message}\n\nAssistant: {assistant_text}"
except SessionActorNotConfiguredError:
logger.info(
"tui.llm_dispatch.no_actor", session_id=session_id, actor=actor_override
)
return "No actor configured. Use /persona set <name> or select an actor first."
except SessionNotFoundError:
logger.warning("tui.llm_dispatch.session_not_found", session_id=session_id)
return (
"[Error] Session not found. Please restart the TUI to create a new session."
)
except DatabaseError as exc:
logger.exception("tui.llm_dispatch.database_error", session_id=session_id)
return f"[Error] Database error: {exc}"
except Exception as exc: # pragma: no cover
logger.exception("tui.llm_dispatch.unexpected_error", session_id=session_id)
return f"[Error] {exc}"
def _format_worker_outcome(
result: str | None,
error: BaseException | None,
) -> str | None:
"""Translate a completed Textual worker's outcome into a display string.
This module-level helper is extracted from the ``_on_llm_done`` closure
so it can be unit-tested independently of the Textual event loop.
``BaseException`` subclasses that are not ``Exception`` (e.g.
``KeyboardInterrupt``, ``SystemExit``) are re-raised rather than
stringified so they can propagate correctly to the Textual runtime.
Returns
-------
str | None
A string to display in the conversation widget, or ``None`` if there
is nothing to show (both *result* and *error* are ``None``).
The ``return None`` branch is marked ``# pragma: no cover`` because
Textual guarantees either ``result`` or ``error`` is set when the
worker completes; the branch is unreachable in practice.
"""
if result is not None:
return result
if error is not None:
if not isinstance(error, Exception):
raise error # re-raise KeyboardInterrupt, SystemExit, etc.
logger.warning("tui.worker.error", error=str(error))
return f"[Error] {error}"
return None # pragma: no cover
InputSubmittedEvent = Any
else:
InputSubmittedEvent = Any
_TEXTUAL_AVAILABLE = False
_TextualApp: type[Any] = object
@@ -183,54 +56,12 @@ def textual_available() -> bool:
@dataclass(slots=True)
class SessionView:
"""Minimal per-session TUI view model."""
"""Per-session TUI view model with independent A2A binding."""
session_id: str
transcript: list[str] = field(default_factory=list)
def _strip_pending_reference_token(value: str) -> str:
"""Remove the most recent ``@`` reference trigger (if any).
This trims whitespace and removes the final token that begins with ``@``. The
token is removed even when additional text follows it so prompts such as
``"draft @README.md summary"`` normalise to ``"draft summary"``. Escaped
triggers (``\\@``) are preserved to avoid stripping literal ``@``
characters.
"""
if not isinstance(value, str):
return ""
candidate = value.rstrip()
if "@" not in candidate:
return candidate
last_at = candidate.rfind("@")
if last_at == -1:
return candidate
if last_at > 0 and candidate[last_at - 1] == "\\":
return candidate
prefix = candidate[:last_at]
suffix = candidate[last_at + 1 :]
token_end = 0
while token_end < len(suffix) and not suffix[token_end].isspace():
token_end += 1
suffix_after = suffix[token_end:]
prefix_clean = prefix.rstrip()
suffix_clean = suffix_after.lstrip()
if prefix_clean and suffix_clean:
return f"{prefix_clean} {suffix_clean}"
if prefix_clean:
return prefix_clean
if suffix_clean:
return suffix_clean
return ""
name: str = "" # User-friendly session name
created_at: str = "" # ISO format timestamp
class _CommandRouter(Protocol):
@@ -245,14 +76,9 @@ class _FallbackCleverAgentsTuiApp: # pragma: no cover
"""Fallback app that raises actionable dependency error."""
def __init__(
self,
*,
command_router: _CommandRouter,
persona_state: PersonaState,
facade: A2aLocalFacade | None = None,
session_id: str = _FALLBACK_SESSION_ID,
self, *, command_router: _CommandRouter, persona_state: PersonaState
) -> None:
del command_router, persona_state, facade, session_id
del command_router, persona_state
def run(self) -> None:
raise RuntimeError(
@@ -267,12 +93,12 @@ if _TEXTUAL_AVAILABLE:
"""Main TUI app."""
CSS_PATH: ClassVar[str] = "cleveragents.tcss"
THEME: ClassVar[str] = "dracula"
BINDINGS: ClassVar[list[tuple[str, str, str]]] = [
("ctrl+q", "quit", "Quit"),
("f1", "help", "Help"),
("ctrl+t", "cycle_preset", "Cycle Preset"),
("escape", "escape", "Close Overlay"),
("ctrl+n", "new_session", "New Session"),
("ctrl+w", "close_session", "Close Session"),
]
def __init__(
@@ -280,21 +106,84 @@ if _TEXTUAL_AVAILABLE:
*,
command_router: _CommandRouter,
persona_state: PersonaState,
facade: A2aLocalFacade | None = None,
session_id: str = _FALLBACK_SESSION_ID,
) -> None:
super().__init__()
self._command_router = command_router
self._persona_state = persona_state
self._facade = facade
self._session = SessionView(session_id=session_id)
# Monotonically increasing counter — incremented on each dispatch.
# _on_llm_done checks it to discard callbacks from cancelled workers
# (exclusive=True cancels in-flight workers; their done_callback still
# fires and must not corrupt the transcript with stale data).
self._dispatch_gen: int = 0
# Cached after mount to avoid repeated query_one() on every submit.
self._conversation: Any = None
# Initialize with default session
default_session = SessionView(
session_id="default",
transcript=[],
name="Default",
created_at=datetime.utcnow().isoformat(),
)
self._sessions: list[SessionView] = [default_session]
self._active_session_index: int = 0
def _get_active_session(self) -> SessionView:
"""Get the currently active session."""
if 0 <= self._active_session_index < len(self._sessions):
return self._sessions[self._active_session_index]
# Fallback to first session if index is invalid
if self._sessions:
self._active_session_index = 0
return self._sessions[0]
# Create default session if none exist
default_session = SessionView(
session_id="default",
transcript=[],
name="Default",
created_at=datetime.utcnow().isoformat(),
)
self._sessions = [default_session]
self._active_session_index = 0
return default_session
def _create_session(self, name: str = "") -> SessionView:
"""Create a new session with independent A2A binding."""
session_id = str(uuid.uuid4())[:8]
session_name = name or f"Session {len(self._sessions) + 1}"
new_session = SessionView(
session_id=session_id,
transcript=[],
name=session_name,
created_at=datetime.utcnow().isoformat(),
)
self._sessions.append(new_session)
return new_session
def _switch_session(self, session_id: str) -> SessionView | None:
"""Switch to a session by ID."""
for idx, session in enumerate(self._sessions):
if session.session_id == session_id:
self._active_session_index = idx
return session
return None
def _close_session(self, session_id: str) -> bool:
"""Close a session by ID. Returns False if it's the last session."""
if len(self._sessions) <= 1:
return False
for idx, session in enumerate(self._sessions):
if session.session_id == session_id:
self._sessions.pop(idx)
# Adjust active index if needed
if self._active_session_index >= len(self._sessions):
self._active_session_index = len(self._sessions) - 1
return True
return False
def _rename_session(self, session_id: str, new_name: str) -> bool:
"""Rename a session by ID."""
for session in self._sessions:
if session.session_id == session_id:
session.name = new_name
return True
return False
def _list_sessions(self) -> list[SessionView]:
"""Get all sessions."""
return self._sessions
def compose(self) -> Any:
yield _Header(show_clock=True)
@@ -326,22 +215,11 @@ if _TEXTUAL_AVAILABLE:
actor_overlay.show()
else:
actor_overlay.hide()
# Cache the conversation widget to avoid repeated query_one() on
# every on_input_submitted call.
self._conversation = self.query_one("#conversation", _Static)
# Explicitly focus the prompt so keyboard input is received immediately.
# Textual does not auto-focus any widget at startup; without this call
# the inner Input never gets focus and typing appears to do nothing.
prompt = self.query_one("#prompt", PromptInput)
prompt.focus()
def _complete_first_run(self, actor: str) -> None:
"""Persist the chosen actor as the default persona and refresh the bar."""
create_default_persona_for_actor(self._persona_state.registry, actor)
self._refresh_persona_bar()
# Refocus prompt after first-run overlay closes.
prompt = self.query_one("#prompt", PromptInput)
prompt.focus()
def action_help(self) -> None:
prompt = self.query_one("#prompt", PromptInput)
@@ -350,45 +228,28 @@ if _TEXTUAL_AVAILABLE:
help_panel.toggle(context_name)
def action_cycle_preset(self) -> None:
self._persona_state.cycle_preset(self._session.session_id)
session = self._get_active_session()
self._persona_state.cycle_preset(session.session_id)
self._refresh_persona_bar()
def action_escape(self) -> None:
prompt = self.query_one("#prompt", PromptInput)
def action_new_session(self) -> None:
"""Create a new session (Ctrl+N)."""
new_session = self._create_session()
self._active_session_index = len(self._sessions) - 1
self._refresh_persona_bar()
actor_overlay = self.query_one("#actor-selection", ActorSelectionOverlay)
if actor_overlay.visible:
actor_overlay.hide()
def action_close_session(self) -> None:
"""Close the current session (Ctrl+W)."""
session = self._get_active_session()
if not self._close_session(session.session_id):
# Cannot close the last session
return
help_panel = self.query_one("#help-panel", HelpPanelOverlay)
if help_panel.visible:
help_panel.hide()
return
slash_overlay = self.query_one("#slash-overlay", SlashCommandOverlay)
if getattr(slash_overlay, "visible", False):
slash_overlay.hide()
return
ref_picker = self.query_one("#reference-picker", ReferencePickerOverlay)
if getattr(ref_picker, "visible", False):
prompt_value = getattr(prompt, "value", "")
if isinstance(prompt_value, str):
prompt.value = _strip_pending_reference_token(prompt_value)
ref_picker.hide()
focus_method = getattr(prompt, "focus", None)
if callable(focus_method):
focus_method()
return
focus_method = getattr(prompt, "focus", None)
if callable(focus_method):
focus_method()
self._refresh_persona_bar()
def _refresh_persona_bar(self) -> None:
persona = self._persona_state.active_persona(self._session.session_id)
preset = self._persona_state.current_preset(self._session.session_id)
session = self._get_active_session()
persona = self._persona_state.active_persona(session.session_id)
preset = self._persona_state.current_preset(session.session_id)
scope_count = len(persona.scoped_projects) + len(persona.scoped_plans)
scope_text = f"{scope_count} scope refs"
bar = self.query_one("#persona-bar", PersonaBar)
@@ -399,40 +260,6 @@ if _TEXTUAL_AVAILABLE:
scope_text=scope_text,
)
def _render_transcript(
self, conversation: Any, *, thinking: bool = False
) -> None:
"""Render the accumulated transcript into the conversation widget.
Transcript entries are pre-escaped when stored, so this method
only needs to join them — avoiding O(N) re-escaping of the full
history on every render call.
Accumulates all exchanges so multi-turn conversations remain visible
as the session grows (spec §29269: scrollable message stream).
Spec gap (acknowledged, intentional simplification): the spec calls
for a per-message ``VerticalScroll`` with ``RichLog`` widgets and
block-cursor navigation (§29269). This PR uses a single ``_Static``
widget with accumulated text to keep the scope focused on LLM
dispatch wiring. A follow-up task should replace this with a
``RichLog``-based implementation for proper scrolling and per-message
metadata.
TODO: implement conversation pruning per spec §30492 to bound memory
usage and rendering latency for long-running sessions.
"""
# Entries are already escaped when appended — join only.
entries = list(self._session.transcript)
if thinking and entries:
entries[-1] = entries[-1] + "\n\n⏳ Thinking..."
elif thinking:
entries = [_escape("⏳ Thinking...")]
text = (
_TRANSCRIPT_SEPARATOR.join(entries) if entries else "CleverAgents TUI"
)
conversation.update(text)
def on_input_submitted(self, event: InputSubmittedEvent) -> None:
del event
prompt = self.query_one("#prompt", PromptInput)
@@ -441,9 +268,10 @@ if _TEXTUAL_AVAILABLE:
if not text:
return
session = self._get_active_session()
mode_router = InputModeRouter(
command_handler=lambda raw: self._command_router.handle(
raw, session_id=self._session.session_id
raw, session_id=session.session_id
),
shell_confirm=lambda _cmd: (
os.environ.get("CLEVERAGENTS_ALLOW_DANGEROUS_SHELL", "").strip()
@@ -451,11 +279,7 @@ if _TEXTUAL_AVAILABLE:
),
)
result = mode_router.process(text)
# Use the cached widget (set in on_mount) to avoid repeated
# query_one() lookups on every submission.
conversation = self._conversation or self.query_one(
"#conversation", _Static
)
conversation = self.query_one("#conversation", _Static)
if result.mode == InputMode.COMMAND:
conversation.update(result.command_result or "")
@@ -469,84 +293,16 @@ if _TEXTUAL_AVAILABLE:
output = (
shell.stdout.strip() or shell.stderr.strip() or "(empty output)"
)
conversation.update(_escape(f"$ {shell.command}\n{output}"))
conversation.update(f"$ {shell.command}\n{output}")
return
expanded = result.expanded_text
preview = result.expanded_text
if "@" in text:
ref_picker = self.query_one("#reference-picker", ReferencePickerOverlay)
ref_picker.set_suggestions(
text, suggestions(text.replace("@", "").strip())
)
if self._facade is None:
# Facade not wired yet — preview only (graceful degradation)
conversation.update(_escape(expanded))
return
# Pre-escape the entry on storage so _render_transcript only joins
# (avoids O(N) re-escaping of the full history on every render).
self._session.transcript.append(_escape(f"You: {expanded}"))
self._render_transcript(conversation, thinking=True)
# Run blocking LLM call off the main Textual thread so the
# event loop stays responsive during the API round-trip.
# exclusive=True serialises dispatches: a new message cancels
# any in-flight worker, preventing concurrent writes to the
# same session and conversation widget thrashing.
# Pass the active persona's actor as an override on every
# request so persona cycling (tab) takes effect immediately.
#
# Note on testability: the composition of run_worker(thread=True,
# exclusive=True) + done_callback wiring below is intentionally
# NOT covered by Behave unit tests because it requires a running
# Textual event loop. The constituent parts ARE tested in isolation:
# _run_llm_dispatch() via tui_llm_dispatch.feature, and
# _format_worker_outcome() via its own scenarios. Manual
# verification confirmed the full path works end-to-end.
facade = self._facade
session_id = self._session.session_id
actor = self._persona_state.active_persona(session_id).actor or None
transcript = self._session.transcript
# Capture the current generation so the callback can detect if a
# newer dispatch has superseded this one (exclusive=True cancels the
# in-flight worker but its done_callback still fires — without this
# guard the cancelled callback would overwrite transcript[-1] with
# stale data from the older request).
self._dispatch_gen += 1
current_gen = self._dispatch_gen
def _dispatch_llm() -> str:
return _run_llm_dispatch(facade, session_id, expanded, actor)
def _on_llm_done(worker: Any) -> None:
"""Accumulate outcome into transcript and re-render."""
# Discard callbacks from cancelled workers.
# worker.is_cancelled is a public bool property on Textual's
# Worker — no WorkerState import needed (Textual is optional).
if getattr(worker, "is_cancelled", False):
return
# Also discard if a newer dispatch has already started.
if self._dispatch_gen != current_gen:
return
outcome = _format_worker_outcome(worker.result, worker.error)
if outcome is not None and transcript:
# Pre-escape and store; replace the placeholder entry.
# outcome is "You: {msg}\n\nAssistant: {reply}" on success
# or an error string — always prefix with user message so
# the exchange is self-contained in the transcript.
if outcome.startswith("You: "):
transcript[-1] = _escape(outcome)
else:
transcript[-1] = _escape(f"You: {expanded}\n\n{outcome}")
self._render_transcript(conversation, thinking=False)
worker = self.run_worker(
_dispatch_llm, thread=True, exclusive=True, name="llm-dispatch"
)
worker.done_callback = _on_llm_done
conversation.update(preview)
_ResolvedTuiApp = _TextualCleverAgentsTuiApp