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:
+109
-353
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user