|
|
|
@@ -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 contextlib
|
|
|
|
|
import threading
|
|
|
|
|
import time
|
|
|
|
|
from typing import Any, ClassVar
|
|
|
|
|
|
|
|
|
|
from cleveragents.cli.output.handles._models import (
|
|
|
|
|
ActionHint,
|
|
|
|
|
CodeBlock,
|
|
|
|
|
DiffBlock,
|
|
|
|
|
DiffHunk,
|
|
|
|
|
DiffLine,
|
|
|
|
|
ElementClosed,
|
|
|
|
|
ElementCreated,
|
|
|
|
|
ElementUpdated,
|
|
|
|
|
Panel,
|
|
|
|
|
ProgressIndicator,
|
|
|
|
|
Separator,
|
|
|
|
|
SessionEnd,
|
|
|
|
|
StatusMessage,
|
|
|
|
|
Table,
|
|
|
|
|
TextBlock,
|
|
|
|
|
Tree,
|
|
|
|
|
TreeNode,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
from textual.widgets import Static # type: ignore[import-untyped] # noqa: F401
|
|
|
|
|
|
|
|
|
|
_A2A_AVAILABLE = True
|
|
|
|
|
except ImportError:
|
|
|
|
|
_A2A_AVAILABLE = False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# 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__ = ("closed", "declared_at", "element_type", "handle_id", "widget")
|
|
|
|
|
|
|
|
|
|
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:
|
|
|
|
|
with contextlib.suppress(Exception):
|
|
|
|
|
widget.add_column(col.name)
|
|
|
|
|
|
|
|
|
|
for row in table.rows:
|
|
|
|
|
with contextlib.suppress(TypeError):
|
|
|
|
|
widget.add_row(*[str(v) for v in row.values()])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
with contextlib.suppress(TypeError):
|
|
|
|
|
widget.add_row(*[str(v) for v in row_dict.values()])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# 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[TreeNode]) -> 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
|
|
|
|
|
with contextlib.suppress(AttributeError):
|
|
|
|
|
widget.update_label(status_msg.message)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# 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:
|
|
|
|
|
if line.line_type == "add":
|
|
|
|
|
prefix = "+"
|
|
|
|
|
elif line.line_type == "remove":
|
|
|
|
|
prefix = "-"
|
|
|
|
|
else:
|
|
|
|
|
prefix = " "
|
|
|
|
|
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}"
|
|
|
|
|
with contextlib.suppress(Exception):
|
|
|
|
|
widget.update(rendered)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# 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:
|
|
|
|
|
with contextlib.suppress(Exception):
|
|
|
|
|
self._event_queue.unsubscribe(self._sub_id)
|
|
|
|
|
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: ClassVar[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 ( # type: ignore[import-untyped]
|
|
|
|
|
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 # type: ignore[import-untyped]
|
|
|
|
|
|
|
|
|
|
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 # type: ignore[import-untyped]
|
|
|
|
|
|
|
|
|
|
widget = TwTree(f"[b]{tree.root.label}[/b]")
|
|
|
|
|
_populate_tree(widget, tree)
|
|
|
|
|
return widget
|
|
|
|
|
except ImportError: # 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 # type: ignore[import-untyped]
|
|
|
|
|
|
|
|
|
|
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 ( # type: ignore[import-untyped]
|
|
|
|
|
ProgressBar as TwProgressBar,
|
|
|
|
|
)
|
|
|
|
|
from textual.widgets import ( # type: ignore[import-untyped]
|
|
|
|
|
Throbber as TwThrobber,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
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 ( # type: ignore[import-untyped]
|
|
|
|
|
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 ( # type: ignore[import-untyped]
|
|
|
|
|
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 # type: ignore[import-untyped]
|
|
|
|
|
|
|
|
|
|
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 ( # type: ignore[import-untyped]
|
|
|
|
|
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:
|
|
|
|
|
with contextlib.suppress(Exception):
|
|
|
|
|
fn(widget, snapshot) # type: ignore[arg-type]
|
|
|
|
|
|
|
|
|
|
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" and (
|
|
|
|
|
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( # type: ignore[call-arg]
|
|
|
|
|
type=ln.get("type", "context"),
|
|
|
|
|
content=ln.get("content", ""),
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
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":
|
|
|
|
|
with contextlib.suppress(Exception):
|
|
|
|
|
_display_status(tui_widget.widget, status_msg)
|
|
|
|
|
|
|
|
|
|
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",
|
|
|
|
|
"_A2aEventSubscriber",
|
|
|
|
|
"_DiffViewStub",
|
|
|
|
|
"_add_table_row",
|
|
|
|
|
"_build_panel_display",
|
|
|
|
|
"_display_status",
|
|
|
|
|
"_init_table_display",
|
|
|
|
|
"_populate_tree",
|
|
|
|
|
"_render_action_hint",
|
|
|
|
|
"_render_code",
|
|
|
|
|
"_render_diff",
|
|
|
|
|
"_render_separator",
|
|
|
|
|
"_render_text",
|
|
|
|
|
"create_tui_materializer",
|
|
|
|
|
]
|