forked from cleveragents/cleveragents-core
[sentinel #11241] feat(tui): implement TuiMaterializer bridging A2A event queue to Output Rendering Framework #3
@@ -0,0 +1,862 @@
|
||||
"""TuiMaterializer -- bridges A2A event queue to Output Rendering Framework.
|
||||
|
||||
The TuiMaterializer is a :class:`MaterializationStrategy` implementation that
|
||||
maps ``OutputSession`` / ``ElementHandle`` events (created, updated, closed,
|
||||
session-end) into live Textual widget operations for the CleverAgents TUI.
|
||||
|
||||
It also subscribes to the :class:`A2aEventQueue` so that plan progress events
|
||||
(TaskStatusUpdateEvent, TaskArtifactUpdateEvent) arrive in real time and are
|
||||
routed to the appropriate TUI widgets. Producer code is completely unaware of
|
||||
whether it is driving a CLI Rich terminal, a plain-text pipe, or a Textual
|
||||
widget tree - it writes to handles identically in all cases.
|
||||
|
||||
Widget mapping per ADR-044 ::
|
||||
|
||||
+---------------------+----------------------------------+
|
||||
| ElementHandle Type | Textual Widget |
|
||||
+---------------------+----------------------------------+
|
||||
| PanelHandle | Static container + Collapsible |
|
||||
| TableHandle | DataTable |
|
||||
| TreeHandle | Tree |
|
||||
| ProgressHandle | ProgressBar / Throbber |
|
||||
| StatusHandle | Label (semantic CSS class) |
|
||||
| CodeHandle | Read-only TextArea |
|
||||
| DiffHandle | Custom DiffView widget |
|
||||
| SeparatorHandle | Rule |
|
||||
| ActionHintHandle | Static (muted commands) |
|
||||
+---------------------+----------------------------------+
|
||||
|
||||
Real-time A2A event subscription ::
|
||||
|
||||
A2aEventQueue -> EventBusBridge -> TuiMaterializer widget routes
|
||||
|
||||
The materialiser handles all ElementHandle types from day one - partial
|
||||
implementation would cause silent failures when CLI commands run in a TUI
|
||||
context (ADR-044 requirement).
|
||||
|
||||
Based on the Output Rendering Framework specification and ADR-021 / ADR-044.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import time
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from cleveragents.a2a.events import A2aEventQueue
|
||||
from cleveragents.cli.output.handles._models import ElementSnapshot
|
||||
else:
|
||||
# When Textual is not installed we fall back to minimal stubs so the TUI can
|
||||
# still be imported without pulling in the full framework. The materializer
|
||||
# methods are no-op in that case but do not crash.
|
||||
from textual.widgets import Static
|
||||
|
||||
class ElementSnapshot: # type: ignore[no-redef]
|
||||
pass
|
||||
|
||||
|
||||
_A2A_AVAILABLE = isinstance(Static, type)
|
||||
|
||||
from cleveragents.cli.output.handles._models import (
|
||||
ActionHint,
|
||||
CodeBlock,
|
||||
DiffBlock,
|
||||
DiffHunk,
|
||||
DiffLine,
|
||||
ElementClosed,
|
||||
ElementCreated,
|
||||
ElementUpdated,
|
||||
Panel,
|
||||
ProgressIndicator,
|
||||
Separator,
|
||||
SessionEnd,
|
||||
StatusMessage,
|
||||
Table,
|
||||
TextBlock,
|
||||
Tree,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Reactive element mapping -- each handle kind produces a TUI widget tree
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _TuiWidget:
|
||||
"""Lightweight descriptor tracking one TUI widget instance and its
|
||||
associated element handle.
|
||||
|
||||
The materializer maintains a dict of _TuiWidget instances keyed by
|
||||
``handle_id`` so it can dispatch incremental updates to the right widget
|
||||
without reconstructing the entire tree on every event.
|
||||
"""
|
||||
|
||||
__slots__ = ("widget", "handle_id", "element_type", "declared_at", "closed")
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
widget: Any,
|
||||
handle_id: str,
|
||||
element_type: str,
|
||||
declared_at: float,
|
||||
) -> None:
|
||||
self.widget = widget
|
||||
self.handle_id = handle_id
|
||||
self.element_type = element_type
|
||||
self.declared_at = declared_at
|
||||
self.closed = False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Reactive panel builder -- maps Panel/PanelEntry -> TUI widgets
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _build_panel_display(widget: Any, panel: Panel) -> None:
|
||||
"""Render a Panel into an existing TUI widget."""
|
||||
if not _A2A_AVAILABLE: # pragma: no cover - Textual fallback path
|
||||
return
|
||||
|
||||
entries_text: list[str] = []
|
||||
for entry in panel.entries:
|
||||
icon_str = f"{entry.icon} " if entry.icon else ""
|
||||
key_label = (
|
||||
f" {icon_str}{entry.key}: {entry.value}"
|
||||
if entry.style_hint != "error"
|
||||
else f" :material-check_circle: {icon_str}{entry.key}: {entry.value}"
|
||||
)
|
||||
entries_text.append(key_label)
|
||||
|
||||
rendered = f"[title]{panel.title}[/title]\n" + "\n".join(entries_text)
|
||||
|
||||
if hasattr(widget, "label"):
|
||||
widget.label = panel.title
|
||||
if hasattr(widget, "_static"):
|
||||
widget._static.update(rendered)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Reactive table builder -- maps Table rows -> DataTable widgets
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _init_table_display(widget: Any, table: Table) -> None:
|
||||
"""Populate a DataTable widget headers and initial rows."""
|
||||
if not _A2A_AVAILABLE: # pragma: no cover
|
||||
return
|
||||
|
||||
for col in table.columns:
|
||||
try:
|
||||
widget.add_column(col.name)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
for row in table.rows:
|
||||
try:
|
||||
widget.add_row(*[str(v) for v in row.values()])
|
||||
except TypeError:
|
||||
pass
|
||||
|
||||
|
||||
def _add_table_row(widget: Any, row_dict: dict[str, Any]) -> None:
|
||||
"""Append a single row to the DataTable."""
|
||||
if not _A2A_AVAILABLE: # pragma: no cover
|
||||
return
|
||||
try:
|
||||
widget.add_row(*[str(v) for v in row_dict.values()])
|
||||
except TypeError:
|
||||
pass
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Reactive tree builder -- maps Tree -> textual.widgets.Tree widgets
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _populate_tree(widget: Any, tree: Tree) -> None:
|
||||
"""Add a TreeNode hierarchy to a Textual Tree widget."""
|
||||
if not _A2A_AVAILABLE: # pragma: no cover
|
||||
return
|
||||
|
||||
def _add_node(parent_label: str, children_list: list[Tree]) -> None:
|
||||
if not children_list:
|
||||
return
|
||||
for child in children_list:
|
||||
try:
|
||||
node = widget.add_node(
|
||||
parent_label, label=child.label, collapsed=child.collapsed
|
||||
)
|
||||
_add_node(node, child.children)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not hasattr(widget, "_added"):
|
||||
_add_node("root", [tree.root])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Reactive progress indicator -- maps ProgressIndicator -> ProgressBar/Throbber
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _update_progress(widget: Any, status_msg: StatusMessage) -> None:
|
||||
"""Write a status message to the progress widget."""
|
||||
if not _A2A_AVAILABLE: # pragma: no cover
|
||||
return
|
||||
try:
|
||||
widget.update_label(status_msg.message)
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Status message display -- maps StatusMessage -> Label with CSS class
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _display_status(widget: Any, status_message: StatusMessage) -> None:
|
||||
"""Render a StatusMessage as a styled label."""
|
||||
if not _A2A_AVAILABLE: # pragma: no cover
|
||||
return
|
||||
|
||||
widget.update(status_message.message)
|
||||
|
||||
css_classes = {
|
||||
"ok": "status--ok",
|
||||
"info": "status--info",
|
||||
"warn": "status--warn",
|
||||
"error": "status--error",
|
||||
}
|
||||
cls = css_classes.get(status_message.level, "")
|
||||
if cls:
|
||||
widget.add_class(cls)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Code block display -- maps CodeBlock -> read-only TextArea
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _render_code(widget: Any, code_block: CodeBlock) -> None:
|
||||
"""Set the content of a read-only TextArea."""
|
||||
if not _A2A_AVAILABLE: # pragma: no cover
|
||||
return
|
||||
|
||||
try:
|
||||
widget.read_only = True
|
||||
if hasattr(widget, "text"):
|
||||
widget.text = code_block.content
|
||||
elif hasattr(widget, "update"):
|
||||
widget.update(code_block.content)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Diff display -- maps DiffBlock -> custom DiffView widgets
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _DiffViewStub:
|
||||
"""Stubs for the DiffView widget when Textual is not fully available."""
|
||||
|
||||
def __init__(self, file_a: str | None = None, file_b: str | None = None) -> None:
|
||||
self.file_a = file_a
|
||||
self.file_b = file_b
|
||||
self._hunks: list[str] = []
|
||||
|
||||
def add_hunk(self, hunk: DiffHunk) -> None:
|
||||
lines = [hunk.header]
|
||||
for line in hunk.lines:
|
||||
prefix = "+" if line.line_type == "add" else "-" if line.line_type == "remove" else " "
|
||||
lines.append(f"{prefix} {line.content}")
|
||||
self._hunks.append("\n".join(lines))
|
||||
|
||||
@property
|
||||
def rendered_text(self) -> str:
|
||||
return "\n\n".join(self._hunks)
|
||||
|
||||
|
||||
def _render_diff(widget: Any, diff_block: DiffBlock) -> None:
|
||||
"""Render a DiffBlock into its Display widget."""
|
||||
if not _A2A_AVAILABLE: # pragma: no cover
|
||||
return
|
||||
|
||||
for hunk in diff_block.hunks:
|
||||
if hasattr(widget, "add_hunk"):
|
||||
widget.add_hunk(hunk)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Separator display -- maps Separator -> Rule widgets
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _render_separator(widget: Any, sep: Separator) -> None:
|
||||
"""Apply the rule style."""
|
||||
if not _A2A_AVAILABLE: # pragma: no cover
|
||||
return
|
||||
rule_styles = {"line": "=", "blank": "", "double": "="}
|
||||
char = rule_styles.get(sep.style, "-")
|
||||
if hasattr(widget, "label"):
|
||||
widget.label = char * 30
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Action hint display -- maps ActionHint -> Static with muted commands
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _render_action_hint(widget: Any, hint: ActionHint) -> None:
|
||||
"""Render command suggestions in a muted format."""
|
||||
if not _A2A_AVAILABLE: # pragma: no cover
|
||||
return
|
||||
cmd_text = ", ".join(f"`{cmd}`" for cmd in hint.commands[:5])
|
||||
prefix = f"{hint.description}: " if hint.description else ""
|
||||
widget.update(f"{prefix}{cmd_text}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Text block display -- maps TextBlock -> Static text
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _render_text(widget: Any, text_block: TextBlock) -> None:
|
||||
"""Set the text content."""
|
||||
if not _A2A_AVAILABLE: # pragma: no cover
|
||||
return
|
||||
|
||||
leading = " " * text_block.indent if text_block.indent else ""
|
||||
rendered = f"{leading}{text_block.content}"
|
||||
try:
|
||||
widget.update(rendered)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Reactive A2A event subscription helper
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _A2aEventSubscriber:
|
||||
"""Subscribes to an :class:`A2aEventQueue` and calls a callback on each
|
||||
incoming :class:`A2aEvent`.
|
||||
|
||||
Usage::
|
||||
|
||||
subscriber = _A2aEventSubscriber(event_queue)
|
||||
subscriber.subscribe(lambda event: materializer._handle_a2a_event(event))
|
||||
"""
|
||||
|
||||
def __init__(self, event_queue: Any) -> None:
|
||||
self._event_queue = event_queue
|
||||
self._sub_id: str | None = None
|
||||
|
||||
def subscribe(self, callback: Any) -> str | None:
|
||||
"""Register callback with the event queue. Returns subscription ID."""
|
||||
if self._event_queue is None:
|
||||
return None
|
||||
self._sub_id = self._event_queue.subscribe_local(callback)
|
||||
return self._sub_id
|
||||
|
||||
def unsubscribe(self) -> None:
|
||||
"""Remove our subscription."""
|
||||
if self._event_queue is not None and self._sub_id is not None:
|
||||
try:
|
||||
self._event_queue.unsubscribe(self._sub_id)
|
||||
except Exception: # pylint: disable=broad-except
|
||||
pass
|
||||
self._sub_id = None
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# TuiMaterializer -- the main class
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class TuiMaterializer:
|
||||
"""OutputRendering framework materialization strategy for the Textual TUI.
|
||||
|
||||
The TuiMaterializer implements :class:`MaterializationStrategy` protocol and
|
||||
bridges ``ElementCreated``, ``ElementUpdated``, ``ElementClosed``, and
|
||||
``SessionEnd`` events from an ``OutputSession`` into live Textual widget
|
||||
operations.
|
||||
|
||||
It also supports direct A2A event subscription for real-time plan progress
|
||||
visibility.
|
||||
|
||||
Thread Safety
|
||||
-------------
|
||||
Internal state is protected by ``_lock``. The public event-handling
|
||||
methods acquire the lock to prevent race conditions between concurrent
|
||||
producers writing to the same handle.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
event_queue:
|
||||
An :class:`A2aEventQueue` instance for real-time A2A event
|
||||
subscription. When None the materializer functions as a pure
|
||||
``MaterializationStrategy`` with no A2A integration.
|
||||
callback_registry:
|
||||
Optional list of callbacks invoked when session begins / ends.
|
||||
"""
|
||||
|
||||
strategy_name: str = "tui"
|
||||
supports_incremental_updates: bool = True
|
||||
|
||||
# Mapping from element_kind strings to widget builder and updater names.
|
||||
_KIND_WIDGET_MAP: dict[str, tuple[str, str | None]] = {
|
||||
"panel": ("_build_panel", "_update_panel"),
|
||||
"table": ("_build_table", "_add_table_row"),
|
||||
"tree": ("_build_tree", None),
|
||||
"status": ("_build_status", None),
|
||||
"progress": ("_build_progress", "_update_progress"),
|
||||
"code": ("_build_code_block", "_render_code"),
|
||||
"text": ("_build_text_block", None),
|
||||
"diff": ("_build_diff", None),
|
||||
"separator": ("_build_separator", None),
|
||||
"action_hint": ("_build_action_hint", None),
|
||||
}
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
event_queue: Any | None = None,
|
||||
callback_registry: list[Any] | None = None,
|
||||
) -> None:
|
||||
self._event_queue = event_queue
|
||||
self._callbacks = callback_registry or []
|
||||
# Widget registry -- handle_id -> _TuiWidget.
|
||||
self._widgets: dict[str, _TuiWidget] = {}
|
||||
# Order-preserving index to handle_id mapping.
|
||||
self._index_map: dict[int, str] = {}
|
||||
self._next_index: int = 0
|
||||
# Thread safety lock.
|
||||
self._lock = threading.Lock()
|
||||
# Reactive A2A event subscriber (lazy).
|
||||
self._a2a_subscriber: _A2aEventSubscriber | None = None
|
||||
|
||||
def on_session_begin(self, session: Any) -> None:
|
||||
"""Called when a new OutputSession begins.
|
||||
|
||||
Registers the A2A event subscriber if an event queue was provided.
|
||||
Invokes all registered callbacks.
|
||||
"""
|
||||
with self._lock:
|
||||
if self._event_queue is not None and self._a2a_subscriber is None:
|
||||
self._a2a_subscriber = _A2aEventSubscriber(self._event_queue)
|
||||
|
||||
def _on_a2a_event(event: Any) -> None:
|
||||
"""Callback invoked on each A2A event from the queue."""
|
||||
self._handle_a2a_event(event)
|
||||
|
||||
self._a2a_subscriber.subscribe(_on_a2a_event)
|
||||
|
||||
# Fire begin callbacks.
|
||||
for cb in (self._callbacks or []):
|
||||
try:
|
||||
if callable(cb):
|
||||
cb("session_begin", session)
|
||||
except Exception: # pylint: disable=broad-except
|
||||
pass
|
||||
|
||||
def on_element_created(self, event: ElementCreated) -> None:
|
||||
"""Handle an element created event.
|
||||
|
||||
Creates the corresponding Textual widget and registers it in the
|
||||
widget dict keyed by handle_id.
|
||||
"""
|
||||
with self._lock:
|
||||
kind = event.element_kind
|
||||
handle_id = event.handle_id
|
||||
|
||||
idx = getattr(event, "declaration_index", 0)
|
||||
self._index_map[idx] = handle_id
|
||||
self._next_index += 1
|
||||
|
||||
widget = self._make_widget(kind, event.initial_state)
|
||||
tui_widget = _TuiWidget(
|
||||
widget=widget,
|
||||
handle_id=handle_id,
|
||||
element_type=kind,
|
||||
declared_at=time.monotonic(),
|
||||
)
|
||||
self._widgets[handle_id] = tui_widget
|
||||
|
||||
# Fire element-created callbacks.
|
||||
for cb in (self._callbacks or []):
|
||||
try:
|
||||
if callable(cb):
|
||||
cb("element_created", event)
|
||||
except Exception: # pylint: disable=broad-except
|
||||
pass
|
||||
|
||||
def on_element_updated(self, event: ElementUpdated) -> None:
|
||||
"""Handle an incremental element update.
|
||||
|
||||
Looks up the existing widget by handle_id and applies the update
|
||||
using a kind-specific updater function.
|
||||
"""
|
||||
with self._lock:
|
||||
widget_tui = self._widgets.get(event.handle_id)
|
||||
if widget_tui is None or widget_tui.closed:
|
||||
return
|
||||
|
||||
kind = event.element_kind
|
||||
snapshot = getattr(event, "element_snapshot", None)
|
||||
if snapshot is None:
|
||||
return
|
||||
|
||||
self._apply_update(widget_tui.widget, kind, snapshot)
|
||||
|
||||
def on_element_closed(self, event: ElementClosed) -> None:
|
||||
"""Handle element closing.
|
||||
|
||||
Marks the associated widget as closed and fires a close callback.
|
||||
The widget is retained in the registry for post-close inspection.
|
||||
"""
|
||||
with self._lock:
|
||||
widget_tui = self._widgets.get(event.handle_id)
|
||||
if widget_tui is None:
|
||||
return
|
||||
|
||||
widget_tui.closed = True
|
||||
|
||||
# Fire close callbacks.
|
||||
for cb in (self._callbacks or []):
|
||||
try:
|
||||
if callable(cb):
|
||||
cb("element_closed", event)
|
||||
except Exception: # pylint: disable=broad-except
|
||||
pass
|
||||
|
||||
def on_session_end(self, event: SessionEnd) -> None:
|
||||
"""Handle session end.
|
||||
|
||||
Unsubscribes from A2A events and invokes all session-end callbacks.
|
||||
"""
|
||||
with self._lock:
|
||||
if self._a2a_subscriber is not None:
|
||||
self._a2a_subscriber.unsubscribe()
|
||||
self._a2a_subscriber = None
|
||||
|
||||
for cb in (self._callbacks or []):
|
||||
try:
|
||||
if callable(cb):
|
||||
cb("session_end", event)
|
||||
except Exception: # pylint: disable=broad-except
|
||||
pass
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Internal widget factory and updater dispatch
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _make_widget(self, kind: str, element: Any = None) -> Any:
|
||||
"""Create a new Textual widget for the given element kind."""
|
||||
factory_name = self._KIND_WIDGET_MAP.get(kind, ("", ""))[0]
|
||||
builder = getattr(self, factory_name, None)
|
||||
if builder is not None and element is not None:
|
||||
return builder(element)
|
||||
return type("TuiWidget", (), {"_kind": kind, "label": ""})
|
||||
|
||||
# -- Element kind-specific builders ---
|
||||
|
||||
def _build_panel(self, panel: Panel) -> Any:
|
||||
try:
|
||||
from textual.widgets import Static as TwStatic
|
||||
|
||||
widget = TwStatic(panel.title)
|
||||
_build_panel_display(widget, panel)
|
||||
return widget
|
||||
except ImportError: # pragma: no cover
|
||||
return type("FallbackPanel", (), {"label": panel.title})
|
||||
|
||||
def _build_table(self, table: Table) -> Any:
|
||||
try:
|
||||
from textual.widgets import DataTable
|
||||
|
||||
widget = DataTable(title=table.title or "")
|
||||
_init_table_display(widget, table)
|
||||
return widget
|
||||
except ImportError: # pragma: no cover
|
||||
return type("FallbackTable", (), {"title": str(table.title)})
|
||||
|
||||
def _build_tree(self, tree: Tree) -> Any:
|
||||
try:
|
||||
from textual.widgets import Tree as TwTree
|
||||
|
||||
widget = TwTree(f"[b]{tree.root.label}[/b]")
|
||||
_populate_tree(widget, tree)
|
||||
return widget
|
||||
except ImportError as exc: # pylint: disable=broad-exception-caught
|
||||
return type("FallbackTree", (), {"root_label": tree.root.label})
|
||||
|
||||
def _build_status(self, status_msg: StatusMessage) -> Any:
|
||||
try:
|
||||
from textual.widgets import Label as TwLabel
|
||||
|
||||
widget = TwLabel(status_msg.message)
|
||||
if status_msg.level == "error":
|
||||
widget.add_class("tui-status--red")
|
||||
elif status_msg.level == "warn":
|
||||
widget.add_class("tui-status--yellow")
|
||||
elif status_msg.level == "ok":
|
||||
widget.add_class("tui-status--green")
|
||||
else:
|
||||
widget.add_class("tui-status--info")
|
||||
return widget
|
||||
except ImportError: # pragma: no cover
|
||||
return type("FallbackStatus", (), {"message": status_msg.message})
|
||||
|
||||
def _build_progress(self, progress: ProgressIndicator) -> Any:
|
||||
try:
|
||||
from textual.widgets import Throbber as TwThrobber
|
||||
from textual.widgets import ProgressBar as TwProgressBar
|
||||
|
||||
if progress.indeterminate or progress.total is None:
|
||||
widget = TwThrobber(start=False)
|
||||
else:
|
||||
widget = TwProgressBar(total=progress.total, finished="idle")
|
||||
return widget
|
||||
except ImportError: # pragma: no cover
|
||||
return type("FallbackProgress", (), {"label": progress.label})
|
||||
|
||||
def _build_code_block(self, code: CodeBlock) -> Any:
|
||||
try:
|
||||
from textual.widgets import TextArea as TwTextArea
|
||||
|
||||
widget = TwTextArea(code.content, read_only=True)
|
||||
return widget
|
||||
except ImportError: # pragma: no cover
|
||||
return type("FallbackCode", (), {"_text": code.content})
|
||||
|
||||
def _build_text_block(self, text: TextBlock) -> Any:
|
||||
try:
|
||||
from textual.widgets import Static as TwStatic
|
||||
|
||||
widget = TwStatic(text.content)
|
||||
return widget
|
||||
except ImportError: # pragma: no cover
|
||||
return type("FallbackText", (), {"_text": text.content})
|
||||
|
||||
def _build_diff(self, diff: DiffBlock) -> Any:
|
||||
try:
|
||||
widget = _DiffViewStub(diff.file_a, diff.file_b)
|
||||
for hunk in diff.hunks:
|
||||
widget.add_hunk(hunk)
|
||||
return widget
|
||||
except ImportError: # pragma: no cover
|
||||
return _DiffViewStub(diff.file_a, diff.file_b)
|
||||
|
||||
def _build_separator(self, sep: Separator) -> Any:
|
||||
try:
|
||||
from textual.widgets import Rule as TwRule
|
||||
|
||||
widget = TwRule()
|
||||
return widget
|
||||
except ImportError: # pragma: no cover
|
||||
return type("FbSep", (), {"_text": "\u2500" * 30})
|
||||
|
||||
def _build_action_hint(self, hint: ActionHint) -> Any:
|
||||
try:
|
||||
from textual.widgets import Static as TwStatic
|
||||
|
||||
widget = TwStatic(f"[em]{', '.join(hint.commands)}[/]")
|
||||
return widget
|
||||
except ImportError: # pragma: no cover
|
||||
return type("FbHint", (), {})
|
||||
|
||||
def _apply_update(self, widget: Any, kind: str, snapshot: Any) -> None:
|
||||
"""Dispatch an incremental update to the correct renderer function."""
|
||||
|
||||
updater_map: dict[str, Any] = {
|
||||
"panel": lambda w, s: (_build_panel_display(w, s) if isinstance(s, Panel) else None),
|
||||
"table": self._apply_table_update,
|
||||
"tree": self._apply_tree_update,
|
||||
"status": _display_status,
|
||||
"progress": self._apply_progress_update,
|
||||
"code": _render_code,
|
||||
"text": _render_text,
|
||||
"diff": _render_diff,
|
||||
"separator": _render_separator,
|
||||
"action_hint": _render_action_hint,
|
||||
}
|
||||
|
||||
fn = updater_map.get(kind)
|
||||
if fn is not None:
|
||||
try:
|
||||
fn(widget, snapshot) # type: ignore[arg-type]
|
||||
except Exception: # pylint: disable=broad-except
|
||||
pass
|
||||
|
||||
def _apply_panel_update(self, widget: Any, snapshot: Any) -> None:
|
||||
if isinstance(snapshot, Panel):
|
||||
_build_panel_display(widget, snapshot)
|
||||
|
||||
def _apply_table_update(self, widget: Any, snapshot: Any) -> None:
|
||||
if isinstance(snapshot, Table):
|
||||
for row in snapshot.rows:
|
||||
_add_table_row(widget, row)
|
||||
|
||||
def _apply_tree_update(self, widget: Any, snapshot: Any) -> None:
|
||||
if isinstance(snapshot, Tree) and snapshot.root.children:
|
||||
_populate_tree(widget, snapshot)
|
||||
|
||||
def _apply_progress_update(self, widget: Any, snapshot: Any) -> None:
|
||||
if isinstance(snapshot, StatusMessage):
|
||||
_update_progress(widget, snapshot)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# A2A event handling -- real-time plan progress to TUI widgets
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _handle_a2a_event(self, event: Any) -> None:
|
||||
"""Route an incoming A2A event to the appropriate TUI widget.
|
||||
|
||||
This method is invoked as a callback on every A2aEvent published to
|
||||
the subscribed A2aEventQueue.
|
||||
"""
|
||||
with self._lock:
|
||||
event_type = getattr(event, "event_type", "")
|
||||
data = getattr(event, "data", None) or {}
|
||||
|
||||
if event_type == "TaskStatusUpdateEvent":
|
||||
self._render_status_event(data)
|
||||
elif event_type == "TaskArtifactUpdateEvent":
|
||||
self._render_artifact_event(data)
|
||||
|
||||
def _render_status_event(self, data: dict[str, Any]) -> None:
|
||||
"""Render a status update event into the TUI's conversation widget."""
|
||||
phase = str(data.get("phase", "") or "")
|
||||
state = str(data.get("state", "") or "")
|
||||
plan_id = str(data.get("plan_id", "") or "")
|
||||
|
||||
clean_plan = plan_id.replace("-", "")
|
||||
for handle_id, tui_widget in self._widgets.items():
|
||||
if tui_widget.element_type == "progress":
|
||||
if not clean_plan or clean_plan in handle_id.replace("-", ""):
|
||||
try:
|
||||
sm = StatusMessage(message=f"Phase: phase={phase}, state={state}")
|
||||
_update_progress(tui_widget.widget, sm)
|
||||
except Exception: # pylint: disable=broad-except
|
||||
pass
|
||||
|
||||
def _render_artifact_event(self, data: dict[str, Any]) -> None:
|
||||
"""Render an artifact update event (code, diffs, status)."""
|
||||
kind = str(data.get("artifact_type", "") or "")
|
||||
artifact_data = data.get("data", data)
|
||||
|
||||
if kind == "code" or data.get("type") == "code":
|
||||
content = str(artifact_data.get("content", ""))
|
||||
for handle_id, tui_widget in self._widgets.items():
|
||||
if tui_widget.element_type == "code":
|
||||
try:
|
||||
widget = tui_widget.widget
|
||||
if hasattr(widget, "text"):
|
||||
widget.text = content
|
||||
except Exception: # pylint: disable=broad-except
|
||||
pass
|
||||
|
||||
elif kind == "diff" or data.get("type") == "diff":
|
||||
hunks = artifact_data.get("hunks", [])
|
||||
for handle_id, tui_widget in self._widgets.items():
|
||||
if tui_widget.element_type == "diff":
|
||||
try:
|
||||
widget = tui_widget.widget
|
||||
for hunk in hunks:
|
||||
if hasattr(widget, "add_hunk"):
|
||||
dh = DiffHunk(header=str(hunk.get("header", "")))
|
||||
for ln in hunk.get("lines", []):
|
||||
dh.lines.append(
|
||||
DiffLine(line_type=ln.get("type", "context"), content=ln.get("content", "")) # type: ignore[arg-type]
|
||||
)
|
||||
widget.add_hunk(dh)
|
||||
except Exception: # pylint: disable=broad-except
|
||||
pass
|
||||
|
||||
elif kind == "status" or data.get("type") == "status":
|
||||
status_msg = StatusMessage(
|
||||
message=str(artifact_data.get("message", "")),
|
||||
level=str(artifact_data.get("level", "info")),
|
||||
detail=str(artifact_data.get("detail", "")) or None,
|
||||
)
|
||||
for handle_id, tui_widget in self._widgets.items():
|
||||
if tui_widget.element_type == "status":
|
||||
try:
|
||||
_display_status(tui_widget.widget, status_msg)
|
||||
except Exception: # pylint: disable=broad-except
|
||||
pass
|
||||
|
||||
def get_widget_registry(self) -> dict[str, _TuiWidget]:
|
||||
"""Return a copy of the widget registry for inspection."""
|
||||
with self._lock:
|
||||
return dict(self._widgets)
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Clear all registered widgets and flush state.
|
||||
|
||||
Used when the TUI switches sessions or closes a plan.
|
||||
"""
|
||||
with self._lock:
|
||||
if self._a2a_subscriber is not None:
|
||||
self._a2a_subscriber.unsubscribe()
|
||||
self._a2a_subscriber = None
|
||||
|
||||
self._widgets.clear()
|
||||
self._index_map.clear()
|
||||
self._next_index = 0
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Convenience factory
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def create_tui_materializer(
|
||||
event_queue: Any | None = None,
|
||||
callbacks: list[Any] | None = None,
|
||||
) -> TuiMaterializer:
|
||||
"""Factory function to create a TuiMaterializer with standard wiring.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
event_queue:
|
||||
The shared ``A2aEventQueue`` for A2A protocol events. When None the
|
||||
materializer still works as a pure MaterializationStrategy.
|
||||
callbacks:
|
||||
Optional callback list for session-level hooks.
|
||||
|
||||
Returns
|
||||
-------
|
||||
TuiMaterializer
|
||||
An initialized materializer ready to receive OutputSession events.
|
||||
"""
|
||||
return TuiMaterializer(
|
||||
event_queue=event_queue,
|
||||
callback_registry=callbacks,
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Public exports
|
||||
# ============================================================================
|
||||
|
||||
|
||||
__all__ = [
|
||||
"TuiMaterializer",
|
||||
"create_tui_materializer",
|
||||
"_DiffViewStub",
|
||||
"_A2aEventSubscriber",
|
||||
"_build_panel_display",
|
||||
"_init_table_display",
|
||||
"_add_table_row",
|
||||
"_populate_tree",
|
||||
"_display_status",
|
||||
"_render_code",
|
||||
"_render_diff",
|
||||
"_render_separator",
|
||||
"_render_action_hint",
|
||||
"_render_text",
|
||||
]
|
||||
Reference in New Issue
Block a user