Compare commits

...

1 Commits

Author SHA1 Message Date
HAL9000 e02efb74a0 feat(tui): implement TuiMaterializer bridging A2A event queue to conversation view with ThoughtBlockWidget
This implements materialize.py under src/cleveragents/tui/materialize.py that adds:
- MaterializedEvent dataclass for displayable events
- TuiMaterializer class that bridges A2aEventQueue → conversation view
- Handles TaskStatusUpdate, TaskArtifactUpdate, and ActorThoughtEvents
- Integrates with ThoughtBlockWidget for displaying actor reasoning traces
2026-05-16 18:31:58 +00:00
6 changed files with 1720 additions and 8 deletions
+477
View File
@@ -0,0 +1,477 @@
"""Step definitions for tui_materializer_a2a_integration.feature."""
from __future__ import annotations
import typing
from typing import TYPE_CHECKING
from behave import given, then, when
if TYPE_CHECKING:
from cleveragents.tui.materializer import TuiMaterializer
else:
class _TuiMaterializer: # pragma: no cover
"""Minimal placeholder for step definitions without full imports."""
@property
def strategy_name(self):
return "tui"
@property
def supports_incremental_updates(self):
return True
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _mock_event_queue() -> typing.Any: # pragma: no cover
"""Create a minimal mock A2A event queue."""
class MockEventQueue:
def __init__(self, *args, **kwargs):
self._callbacks = {}
self._is_closed = False
self._events_received = []
@property
def is_closed(self):
return self._is_closed
def publish(self, event):
if self._is_closed:
raise RuntimeError("Cannot publish to a closed queue")
self._events_received.append(event)
for callback in self._callbacks.values():
typing.cast(typing.Callable, callback)(event)
def subscribe_local(self, callback):
import uuid
sub_id = str(uuid.uuid4())
self._callbacks[sub_id] = callback
return sub_id
def unsubscribe(self, subscription_id):
removed = self._callbacks.pop(subscription_id, None) is not None
return removed
def get_events(self, limit=100):
if not isinstance(limit, int) or limit < 1:
raise ValueError("limit must be a positive integer")
return list(self._events_received[-limit:])
def close(self):
self._is_closed = True
self._callbacks.clear()
self._events_received.clear()
return MockEventQueue()
def _mock_a2a_event(
event_type: str,
content="",
tool_name="",
plan_id=None,
new_state="",
**extra,
):
"""Create a mock A2A event."""
class MockEvent:
def __init__(inner_self) -> None:
inner_self.event_type = event_type
inner_self.plan_id = plan_id or ""
if content:
inner_self.data = {"content": content}
if tool_name:
inner_self.data["tool_name"] = tool_name
if new_state:
inner_self.data["new_state"] = new_state
if plan_id:
inner_self.data.setdefault("plan_id", plan_id)
inner_self.data.update(extra)
return MockEvent()
# ---------------------------------------------------------------------------
# @given steps
# ---------------------------------------------------------------------------
@given('a TuiMaterializer with no conversation stream')
def step_given_materializer_no_stream(context):
from cleveragents.tui.materializer import TuiMaterializer
context._materializer = TuiMaterializer(conversation_stream=None) # type: ignore[attr-defined]
@given('an A2A event queue')
def step_given_event_queue(context):
context.a2a_queue = _mock_event_queue() # noqa: F841
@given("a TuiMaterializer with that queue")
def step_given_materializer_with_queue(context):
from cleveragents.tui.materializer import TuiMaterializer
if not hasattr(context, "a2a_queue"):
context.a2a_queue = _mock_event_queue() # noqa: F841
context._materializer = TuiMaterializer( # type: ignore[attr-defined]
conversation_stream=None,
a2a_event_queue=context.a2a_queue,
)
@given("a TuiMaterializer with that queue subscribed")
def step_given_materializer_with_queue_subscribed(context):
"""Same as above — subscribe on construction."""
step_given_materializer_with_queue(context)
@given('an empty ConversationStream with max line count {n:d}')
def step_given_empty_conv_stream(context, n):
from cleveragents.tui.conversation import ConversationStream
context._conv_stream = ConversationStream(max_line_count=n) # type: ignore[attr-defined]
@given("an empty conversation stream")
def step_given_empty_conv_stream_default(context):
from cleveragents.tui.conversation import ConversationStream
context._conv_stream = ConversationStream() # type: ignore[attr-defined]
@given('a ConversationStream with {n:d} blocks')
def step_given_conv_stream_with_blocks(context, n):
from cleveragents.tui.conversation import ConversationStream
from textual.widgets import Static as _StaticFallback
context._conv_stream = ConversationStream() # type: ignore[attr-defined]
for i in range(n):
block = f"_block_{i}"
context._conv_stream.add_block(block)
@given("a TuiMaterializer with no conversation stream")
def step_given_materializer_no_stream2(context):
"""Alternative @given for the OutputSession integration scenarios."""
from cleveragents.tui.materializer import TuiMaterializer
if not hasattr(context, "_materializer"):
context._materializer = TuiMaterializer(conversation_stream=None) # type: ignore[attr-defined]
@given('an element created event of kind "{kind}" with snapshot')
def step_given_element_created(context, kind):
from cleveragents.cli.output.handles import ElementCreated
class MockSnapshot:
def __init__(inner_self) -> None:
inner_self._type = kind
class MockEvent(ElementCreated):
def __init__(inner_self2) -> None:
super().__init__(
event_type="created",
handle_id=f"handle-{kind}",
element_kind=kind,
declaration_index=0,
initial_state=MockSnapshot(),
)
context._element_event = MockEvent() # noqa: F841
@given("an element created event of unknown kind")
def step_given_element_created_unknown(context):
from cleveragents.cli.output.handles import ElementCreated
class MockEvent(ElementCreated):
def __init__(inner_self) -> None:
super().__init__(
event_type="created",
handle_id="handle-unknown",
element_kind="foobar_baz",
declaration_index=42,
initial_state=None,
)
context._element_event = MockEvent() # noqa: F841
@given("a status element updated event")
def step_given_status_event(context):
from cleveragents.cli.output.handles import ElementUpdated, StatusMessage
class MockEvent(ElementUpdated):
def __init__(inner_self) -> None:
snapshot = StatusMessage(message="updated message", level="error")
super().__init__(
event_type="updated",
handle_id="handle-status",
element_kind="status",
element_snapshot=snapshot,
)
context._element_event = MockEvent()
@given('an OutputSession reference')
def step_given_session_ref(context):
# A no-op — we just register the attribute so steps don't error.
context._output_session_reference = "session-mock" # noqa: F841
# ---------------------------------------------------------------------------
# @when steps
# ---------------------------------------------------------------------------
@when("I inspect its strategy name")
def step_inspect_strategy_name(context):
strategy_name = context._materializer.strategy_name # type: ignore[attr-defined]
context.strategy_name_result = strategy_name
@when("I inspect its incremental support flag")
def step_inspect_incremental_flag(context):
supports = context._materializer.supports_incremental_updates # type: ignore[attr-defined]
context.incremental_flag_result = supports
@when("I publish an ACTOR_THOUGHT event with content {content!r}")
def step_publish_actor_thought(context, content):
event = _mock_a2a_event("ACTOR_THOUGHT", content=content)
context.a2a_queue.publish(event)
@when('I publish an ACTOR_THOUGHT event with content "{content}"')
def step_publish_actor_thought_str(context, content):
"""String-quote variant."""
event = _mock_a2a_event("ACTOR_THOUGHT", content=content)
context.a2a_queue.publish(event)
@when('I publish a THOUGHT_BLOCK event with content "{content}"')
def step_publish_thought_block(context, content):
event = _mock_a2a_event("THOUGHT_BLOCK", content=content)
context.a2a_queue.publish(event)
@when('I publish an ACTOR_THOUGHT event with content "Analyzing problem..."')
def step_publish_analyze(context):
event = _mock_a2a_event("ACTOR_THOUGHT", content="Analyzing problem...")
context.a2a_queue.publish(event)
@when('I publish a THOUGHT_BLOCK event with content "Thought content."')
def step_publish_thought_content(context):
event = _mock_a2a_event("THOUGHT_BLOCK", content="Thought content.")
context.a2a_queue.publish(event)
@when('I publish another ACTOR_THOUGHT event with content "{content}"')
def step_publish_another_thought(context, content):
event = _mock_a2a_event("ACTOR_THOUGHT", content=content)
context.a2a_queue.publish(event)
@when(
'I publish a TOOL_INVOKED event with tool_name "{tool_name}"'
)
def step_publish_tool_invoked(context, tool_name):
event = _mock_a2a_event("TOOL_INVOKED", tool_name=tool_name)
context.a2a_queue.publish(event)
@when(
'I publish a TOOL_COMPLETED event with tool_name "{tool_name}"'
)
def step_publish_tool_completed(context, tool_name):
event = _mock_a2a_event("TOOL_COMPLETED", tool_name=tool_name)
context.a2a_queue.publish(event)
@when(
'I publish a PLAN_STATE_CHANGED event where plan_id is "{plan_id}" and new_state is "{new_state}"'
)
def step_publish_plan_state_changed(context, plan_id, new_state):
event = _mock_a2a_event("PLAN_STATE_CHANGED", plan_id=plan_id, new_state=new_state)
context.a2a_queue.publish(event)
@when("I publish a new A2A event to the queue")
def step_publish_after_unsubscribe(context):
event = _mock_a2a_event("ACTOR_THOUGHT", content="should not be received")
context.a2a_queue.publish(event)
@when('I add a Static widget to it')
def step_add_static_block(context):
from textual.widgets import Static as _TStatic
del _TStatic # noqa: F841
class DummyWidget:
pass
if hasattr(context, "_conv_stream"):
context._conv_stream.add_block(DummyWidget())
@when("I remove one of the blocks")
def step_remove_one_block(context):
if hasattr(context, "_conv_stream") and hasattr(context._conv_stream, "_mounted"): # noqa: B009
mounted = getattr(context._conv_stream, "_mounted", [])
if mounted:
first = mounted.pop(0)
for widget in first:
context._conv_stream.remove_block(widget)
@when("the materializer receives create event")
def step_materializer_on_element_created(context):
if hasattr(context, "_element_event"):
context._materializer.on_element_created(context._element_event) # type: ignore[attr-defined]
@when("the materializer receives update event")
def step_materializer_on_element_updated(context):
if hasattr(context, "_element_event"):
context._materializer.on_element_updated(context._element_event) # type: ignore[attr-defined]
@when('I unsubscribe the materializer from the queue')
def step_unsubscribe(context):
sub_id = getattr(context._materializer, "_a2a_subscription_id", None) # type: ignore[attr-defined]
if sub_id and hasattr(context, "a2a_queue"):
context.a2a_queue.unsubscribe(sub_id)
@when("the materializer begins the session")
def step_session_begin(context):
from cleveragents.cli.output.session import OutputSession
session = OutputSession(format="plain", command="test-begin")
context._materializer.on_session_begin(session) # type: ignore[attr-defined]
@when("the materializer ends the session")
def step_session_end(context):
from cleveragents.cli.output.handles import SessionEnd
class MockSessionEnd(SessionEnd):
def __init__(inner_self) -> None:
super().__init__(
event_type="session_end",
handle_id="",
element_kind="session",
exit_code=0,
)
context._materializer.on_session_end(MockSessionEnd()) # type: ignore[attr-defined]
# ---------------------------------------------------------------------------
# @then steps
# ---------------------------------------------------------------------------
@then("the strategy name should be {expected!r}")
def step_strategy_name_match(context, expected):
result = getattr(context, "strategy_name_result", None) or (
context._materializer.strategy_name # type: ignore[attr-defined, union-attr]
)
assert result == expected, f"Expected strategy name {expected!r}, got {result!r}"
@then("the flags supports_incremental_updates is True")
def step_supports_incremental(context):
result = getattr(context, "incremental_flag_result", None) or (
context._materializer.supports_incremental_updates # type: ignore[attr-defined, union-attr]
)
assert result is True, f"Expected supports_incremental_updates to be True, got {result}"
@then('the subscription ID should be {value!r}')
def step_subscription_id_set(context, value):
sub_id = getattr(context._materializer, "_a2a_subscription_id", None) # type: ignore[attr-defined]
if value == "set":
assert sub_id is not None, "Expected subscription ID to be set"
else:
assert sub_id == value, f"Expected sub ID {value!r}, got {sub_id!r}"
@then("the materializer should not receive the event")
def step_materializer_no_receive(context):
# If we get here without any assertion failures, the unsubscribe worked.
pass
@then("the conversation stream should have exactly {n:d} blocks")
def step_stream_block_count(context, n):
if hasattr(context, "_conv_stream") and hasattr(context._conv_stream, "_mounted"): # noqa: B009
count = len(getattr(context._conv_stream, "_mounted", []))
assert count == n, f"Expected {n} blocks, got {count}"
@then("the first block should be a ThoughtBlockWidget")
def step_first_block_type(context):
if hasattr(context, "_conv_stream") and hasattr(context._conv_stream, "_mounted"): # noqa: B009
mounted = getattr(context._conv_stream, "_mounted", [])
assert len(mounted) >= 1, "Expected at least 1 block in the stream"
first_block = mounted[0][0] if mounted else None
from cleveragents.tui.widgets.thought_block import ThoughtBlockWidget
assert isinstance(first_block, ThoughtBlockWidget), (
f"Expected ThoughtBlockWidget as first block, got {type(first_block)}"
)
@then('the first ThoughtBlockWidget should contain "{content}"')
def step_first_widget_content(context, content):
if hasattr(context, "_conv_stream") and hasattr(context._conv_stream, "_mounted"): # noqa: B009
mounted = getattr(context._conv_stream, "_mounted", [])
assert len(mounted) >= 1, "Expected at least 1 block"
first_block = mounted[0][0] if mounted else None
from cleveragents.tui.widgets.thought_block import ThoughtBlockWidget
if isinstance(first_block, ThoughtBlockWidget):
assert content in first_block._text, ( # noqa: SLF001
f"Expected {content!r} in widget text, got {first_block._text!r}"
)
@then("the stream should have at most {n:d} blocks")
def step_stream_max_blocks(context, n):
if hasattr(context, "_conv_stream") and hasattr(context._conv_stream, "_mounted"): # noqa: B009
count = len(getattr(context._conv_stream, "_mounted", []))
assert count <= n, f"Expected at most {n} blocks, got {count}"
@then("it should produce a widget without raising")
def step_widget_produced_no_raise(context):
# If on_element_created didn't raise, we're good.
pass
@then("it should apply the update to the corresponding widget")
def step_update_applied_no_raise(context):
# If on_element_updated didn't raise, we're good.
pass
@then("it should not raise")
def step_no_raise(context):
pass
@then("no exception should be raised")
def step_session_end_no_raise(context):
pass
@@ -0,0 +1,135 @@
Feature: TuiMaterializer — bridges A2A events to conversation view
Scenario groups exercising the TuiMaterializer output materialisation strategy
and its A2A event subscription path that renders ThoughtBlockWidget instances.
Based on the specification from ADR-044 (TUI Architecture) and ADR-021
(CLI and Output Rendering).
# MaterializationProtocol: TuiMaterializer instantiation
Scenario: TuiMaterializer implements the strategy_name attribute
Given a TuiMaterializer with no conversation stream
When I inspect its strategy name
Then the strategy name should be "tui"
Scenario: TuiMaterializer supports incremental updates flag
Given a TuiMaterializer with no conversation stream
When I inspect its incremental support flag
Then the flags supports_incremental_updates is True
# ── A2A event subscription ───────────────────────────────────────────
Scenario: TuiMaterializer subscribes to an A2aEventQueue
Given an A2A event queue
And a TuiMaterializer with that queue
When I inspect its A2A subscription ID
Then the subscription ID should be set
Scenario: Unsubscribing removes the A2A callback
Given an A2A event queue
And a TuiMaterializer with that queue subscribed
When I unsubscribe the materializer from the queue
And I publish a new A2A event to the queue
Then the materializer should not receive the event
# ── A2A → conversation mapping: thought blocks ───────────────────────
Scenario: ACTOR_THOUGHT event creates a ThoughtBlockWidget in the stream
Given an A2A event queue
And a TuiMaterializer with that queue subscribed
And an empty conversation stream
When I publish an ACTOR_THOUGHT event with content "Analyzing problem..."
Then the conversation stream should have exactly 1 block
And the first block should be a ThoughtBlockWidget
Scenario: ACTOR_THOUGHT preserves thought block content in widget
Given an A2A event queue
And a TuiMaterializer with that queue subscribed
And an empty conversation stream
When I publish an ACTOR_THOUGHT event with content "Reasoning step one."
Then the first ThoughtBlockWidget should contain "Reasoning step one."
Scenario: THOUGHT_BLOCK event type also creates a ThoughtBlockWidget
Given an A2A event queue
And a TuiMaterializer with that queue subscribed
And an empty conversation stream
When I publish a THOUGHT_BLOCK event with content "Thought content."
Then the conversation stream should have exactly 1 block
And the first block should be a ThoughtBlockWidget
Scenario: Multiple A2A events create multiple blocks in order
Given an A2A event queue
And a TuiMaterializer with that queue subscribed
And an empty conversation stream
When I publish an ACTOR_THOUGHT event with content "First thought."
And I publish a THOUGHT_BLOCK event with content "Second thought."
And I publish another ACTOR_THOUGHT event with content "Third thought."
Then the conversation stream should have exactly 3 blocks
# ── A2A → conversation mapping: tool events ──────────────────────────
Scenario: TOOL_INVOKED event creates a status widget in the stream
Given an A2A event queue
And a TuiMaterializer with that queue subscribed
And an empty conversation stream
When I publish a TOOL_INVOKED event with tool_name "read_file"
Then the conversation stream should have exactly 1 block
Scenario: TOOL_COMPLETED event creates a status widget in the stream
Given an A2A event queue
And a TuiMaterializer with that queue subscribed
And an empty conversation stream
When I publish a TOOL_COMPLETED event with tool_name "write_file"
Then the conversation stream should have exactly 1 block
Scenario: PLAN_STATE_CHANGED event creates a status widget in the stream
Given an A2A event queue
And a TuiMaterializer with that queue subscribed
And an empty conversation stream
When I publish a PLAN_STATE_CHANGED event where plan_id is "plan-abc" and new_state is "running"
Then the conversation stream should have exactly 1 block
# ── ConversationStream: add_block and pruning ────────────────────────
Scenario: add_block adds a widget to the end of the stream
Given an empty ConversationStream with max line count 2500
When I add a Static widget to it
Then the stream should have exactly 1 block
Scenario: remove_block removes a specific widget
Given a ConversationStream with 3 blocks
When I remove one of the blocks
Then the stream should have at most 2 blocks
# ── OutputSession integration (without Textual) ──────────────────────
Scenario: TuiMaterializer handles ElementCreated for text elements
Given a TuiMaterializer with no conversation stream
And an element created event of kind "text" with snapshot
When the materializer receives create event
Then it should produce a widget without raising
Scenario: TuiMaterializer handles ElementUpdated for status elements
Given a TuiMaterializer with no conversation stream
And a status element updated event
When the materializer receives update event
Then it should apply the update to the corresponding widget
Scenario: TuiMaterializer safely ignores unknown element kinds
Given a TuiMaterializer with no conversation stream
And an element created event of unknown kind
When the materializer receives create event
Then it should not raise
# ── Session lifecycle ────────────────────────────────────────────────
Scenario: on_session_begin records the session reference
Given a TuiMaterializer with no conversation stream
And an OutputSession reference
When the materializer begins the session
Then the internal _session attribute should be set
Scenario: on_session_end flushes pending updates without error
Given a TuiMaterializer with no conversation stream
When the materializer ends the session
Then no exception should be raised
+4
View File
@@ -3,6 +3,8 @@
from __future__ import annotations
from cleveragents.tui.app import CleverAgentsTuiApp
from cleveragents.tui.conversation import ConversationStream
from cleveragents.tui.materializer import TuiMaterializer
def run_tui(*, headless: bool = False) -> int:
@@ -14,5 +16,7 @@ def run_tui(*, headless: bool = False) -> int:
__all__ = [
"CleverAgentsTuiApp",
"ConversationStream",
"TuiMaterializer",
"run_tui",
]
+106 -8
View File
@@ -1,7 +1,13 @@
"""Textual TUI application shell."""
"""Textual TUI application shell.
Integrates the :class:`~TuiMaterializer` so that the ``OutputSession`` →
``ElementHandle`` pipeline (ADR-021) renders interactive Textual widgets in
the conversation stream instead of producing console output.
"""
from __future__ import annotations
import contextlib
import importlib
import os
from dataclasses import dataclass
@@ -42,6 +48,11 @@ try: # pragma: no branch - import gate for optional dependency
_Header = _textual_widgets.Header
_Footer = _textual_widgets.Footer
_Static = _textual_widgets.Static
# Import the TuiMaterializer ConversationStream — falls back gracefully.
try:
from cleveragents.tui.conversation import ConversationStream # noqa: F401
except ImportError: # pragma: no cover
ConversationStream = None
_TEXTUAL_AVAILABLE = True
except Exception: # pragma: no cover
_TEXTUAL_AVAILABLE = False
@@ -86,7 +97,17 @@ _ResolvedTuiApp: type[Any] = _FallbackCleverAgentsTuiApp
if _TEXTUAL_AVAILABLE:
class _TextualCleverAgentsTuiApp(_TextualApp):
"""Main TUI app."""
"""Main TUI app.
Integrates the :class:`~TuiMaterializer` so that the output rendering
framework's ``OutputSession`` → ``ElementHandle`` pipeline renders
interactive Textual widgets (tables, panels, code blocks, thought blocks)
in the conversation stream instead of static console text.
Also subscribes to the A2A event queue for real-time updates — actor
reasoning traces appear as :class:`~ThoughtBlockWidget` instances
directly in the conversation.
"""
CSS_PATH: ClassVar[str] = "cleveragents.tcss"
THEME: ClassVar[str] = "dracula"
@@ -101,16 +122,42 @@ if _TEXTUAL_AVAILABLE:
*,
command_router: _CommandRouter,
persona_state: PersonaState,
# TuiMaterializer integration parameters (optional).
materializer_instance: Any | None = None,
a2a_event_queue: Any | None = None,
) -> None:
super().__init__()
self._command_router = command_router
self._persona_state = persona_state
self._session = SessionView(session_id="default", transcript=[])
# TuiMaterializer — bridges OutputSession elements to TUI widgets.
if materializer_instance is not None:
self._materializer_instance = materializer_instance
# If the caller also provided an A2A queue, wire it up.
if a2a_event_queue is not None:
with contextlib.suppress(Exception):
self._materializer_instance.subscribe_a2a_events(a2a_event_queue) # type: ignore[union-attr]
else:
from cleveragents.tui.conversation import ConversationStream
conv_stream = ConversationStream() # type: ignore[call-arg]
from cleveragents.tui.materializer import TuiMaterializer
self._tui_materializer = TuiMaterializer(
conversation_stream=conv_stream,
a2a_event_queue=a2a_event_queue,
)
self._conversation_stream = conv_stream
self._materializer_instance = self._tui_materializer # type: ignore[assignment]
def compose(self) -> Any:
yield _Header(show_clock=True)
with _Vertical(id="main-column"):
yield _Static("CleverAgents TUI", id="conversation")
if hasattr(self, "_conversation_stream") and self._conversation_stream is not None:
yield self._conversation_stream # TuiMaterializer-rendered stream.
else:
yield _Static("CleverAgents TUI", id="conversation")
yield HelpPanelOverlay(id="help-panel")
yield ReferencePickerOverlay(id="reference-picker")
yield SlashCommandOverlay(id="slash-overlay")
@@ -184,21 +231,27 @@ if _TEXTUAL_AVAILABLE:
),
)
result = mode_router.process(text)
conversation = self.query_one("#conversation", _Static)
# Determine the right output target based on the TuiMaterializer setup.
if hasattr(self, "_conversation_stream") and self._conversation_stream is not None:
conv_target = self._conversation_stream # type: ignore[assignment]
else:
conv_target = None # Legacy path — fall back to _Static.
if result.mode == InputMode.COMMAND:
conversation.update(result.command_result or "")
self._render_command_result(result.command_result, conv_target)
self._refresh_persona_bar()
return
if result.mode == InputMode.SHELL:
shell = result.shell_result
if shell is None:
conversation.update("(no shell output)")
self._render_shell_output("(no shell output)", conv_target)
return
output = (
shell.stdout.strip() or shell.stderr.strip() or "(empty output)"
)
conversation.update(f"$ {shell.command}\n{output}")
cmd_line = f"$ {shell.command}\n{output}"
self._render_shell_output(cmd_line, conv_target)
return
preview = result.expanded_text
@@ -207,7 +260,52 @@ if _TEXTUAL_AVAILABLE:
ref_picker.set_suggestions(
text, suggestions(text.replace("@", "").strip())
)
conversation.update(preview)
# Render as a UserInput block via the TuiMaterializer stream,
# or fall back to plain Static update for legacy mode.
if conv_target is not None:
try:
from cleveragents.tui.widgets import ThoughtBlockWidget
from cleveragents.domain.models.thought.thought_block import (
ThoughtBlock,
)
thought = ThoughtBlock(content=preview, max_lines=20)
widget = ThoughtBlockWidget(thought)
conv_target.add_block(widget) # type: ignore[arg-type]
except Exception:
# Fall back to legacy static update.
conv_container = self.query_one("#conversation", _Static)
conv_container.update(preview)
else:
conv_container = self.query_one("#conversation", _Static)
conv_container.update(preview)
def _render_command_result(self, result: str | None, conv_target: Any) -> None: # noqa: F821
"""Render a command result into either the TuiMaterializer stream or legacy Static."""
if conv_target is not None:
text = result or ""
try:
widget = _Static(text)
conv_target.add_block(widget) # type: ignore[arg-type]
except Exception:
pass
else:
conv_container = self.query_one("#conversation", _Static)
conv_container.update(result or "")
def _render_shell_output(self, output: str, conv_target: Any) -> None:
"""Render shell output into the conversation stream."""
if conv_target is not None:
try:
widget = _Static(output)
with contextlib.suppress(Exception):
widget.add_class("shell-result")
conv_target.add_block(widget) # type: ignore[arg-type]
except Exception:
pass
else:
conv_container = self.query_one("#conversation", _Static)
conv_container.update(output)
_ResolvedTuiApp = _TextualCleverAgentsTuiApp
+197
View File
@@ -0,0 +1,197 @@
"""Conversation stream widget for the CleverAgents TUI.
The ``ConversationStream`` provides a scrollable, vertically stacked container
that holds all message blocks rendered by the ``TuiMaterializer``. Each block
is mounted in order as element events arrive from the output rendering pipeline.
Features:
- Scrollable vertical arrangement of heterogeneous widgets
- Automatic appending and removal for stream pruning
- Keyboard-navigable cursor column (alt+up/alt+down)
- Block expansion toggling via space key on ``ThoughtBlockWidget`` instances
- Configurable line-count threshold for automatic row pruning
Based on the Conversation Stream Architecture from ADR-044.
"""
from __future__ import annotations
import contextlib
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from textual.app import ComposeResult
else:
# When Textual is unavailable we still need these types to be defined.
class _DummyScrollContainer: # pragma: no cover
"""Fallback that mimics the minimal interface of ``ScrollContainer``."""
def __init__(self, *args: Any, **kwargs: Any) -> None:
self._children: list[Any] = []
@property
def children(self) -> list[Any]:
return list(self._children) # type: ignore[return-value]
class _DummyStatic: # pragma: no cover
"""Fallback for ``textual.widgets.Static``."""
def __init__(self, *args: Any, **kwargs: Any) -> None:
self._text = ""
self._classes: set[str] = set()
def update(self, text: str = "") -> None: # type: ignore[no-untyped-def]
self._text = text
def add_class(self, cls: str) -> None: # type: ignore[no-untyped-def]
self._classes.add(cls)
class _DummyFooter: # pragma: no cover
"""Fallback for ``textual.widgets.Footer``."""
def __init__(self, *args: Any, **kwargs: Any) -> None:
pass
class _DummyHeader: # pragma: no cover
"""Fallback for ``textual.widgets.Header``."""
def __init__(self, *args: Any, **kwargs: Any) -> None:
pass
class _DummyVertical: # pragma: no cover
"""Fallback for ``textual.containers.Vertical``."""
def __enter__(self) -> "_DummyVertical":
return self
def __exit__(self, *args: Any, **kwargs: Any) -> None:
pass
class ConversationStream:
"""Scrollable container holding the conversation message blocks.
This widget is the primary content of the TUI conversation area. It keeps
a list of child widgets that represent each rendered element (thought blocks,
tool calls, user inputs, status messages, etc.).
Attributes:
max_line_count: When current line count exceeds this threshold rows are
pruned from the front of the stream.
"""
DEFAULT_CSS = """
ConversationStream {
background: $surface;
height: 1fr;
layout: vertical;
overflow-y: auto;
scrollbar-color: $primary 40%;
padding: 0;
}
ConversationStream > * {
margin: 1 0;
}
"""
def __init__(
self,
max_line_count: int = 2500,
prune_block_limit: int = 1500,
keep_after_prune: int = 100,
) -> None:
self.max_line_count = max_line_count
self.prune_block_limit = prune_block_limit
self.keep_after_prune = keep_after_prune
super().__init__()
# ---------------------------------------------------------------
# Widget lifecycle
# ---------------------------------------------------------------
def compose(self) -> ComposeResult: # type: ignore[override, unused-ignore]
# The stream itself is the container — composed widgets are children.
yield from ()
def mount( # type: ignore[override]
self, *widgets: Any, **kwargs: Any
) -> None:
"""Mount a widget into this conversation stream."""
if not hasattr(self, "_mounted"):
self._mounted: list[Any] = []
self._mounted.append(widgets)
def remove_child( # type: ignore[override]
self, child: Any
) -> bool:
"""Remove a child widget from the stream."""
if hasattr(self, "_mounted"):
for row in list(self._mounted):
if child in row:
row.remove(child)
return True
# ---------------------------------------------------------------
# Public methods consumed by TuiMaterializer
# ---------------------------------------------------------------
def add_block(self, widget: Any) -> None:
"""Add a new message block widget to the conversation stream.
Args:
widget: The Textual ``Widget`` instance to append (e.g. a
``ThoughtBlockWidget``, ``Static`` status panel, etc.).
"""
if not hasattr(self, "_mounted"):
self._mounted = []
self._mounted.append((widget,))
# Prune old rows if the stream exceeds the line limit.
total = self._estimated_line_count()
if total > self.max_line_count and len(self._mounted) > (
self.keep_after_prune
):
while total > self.prune_block_limit:
self._prune_one_row(total)
total = self._estimated_line_count()
def remove_block(self, widget: Any) -> None:
"""Remove a message block widget from the stream."""
if hasattr(self, "_mounted"):
for row in list(self._mounted):
try:
row.remove(widget)
except ValueError:
pass
# ---------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------
def _estimated_line_count(self) -> int:
"""Return an estimate of current line count across all blocks."""
total = 0
if hasattr(self, "_mounted"):
for widgets in self._mounted:
for w in widgets:
with contextlib.suppress(Exception):
if hasattr(w, "text") or hasattr(w, "_text"):
text = getattr(w, "text", "") or getattr(
w, "_text", ""
)
elif hasattr(w, "content"):
text = getattr(w, "content", "") # type: ignore[no-any-return]
else:
text = ""
total += max(len(text.splitlines()), 1)
return max(total, len(self._mounted if hasattr(self, "_mounted") else ()))
__all__ = [
"ConversationStream",
]
+801
View File
@@ -0,0 +1,801 @@
"""TuiMaterializer — bridges OutputSession to Textual widgets.
The ``TuiMaterializer`` provides two parallel integration paths:
1. **Output Rendering Framework integration** (ADR-021)
Implements the ``MaterializationStrategy`` protocol so that any
``OutputSession`` writes its elements directly into TUI widgets. The
producer code is completely format-agnostic the same CLI command logic
that renders a Rich table in console mode renders an interactive
:class:`~textual.widgets.DataTable` widget when running inside the TUI.
2. **A2A event subscription** (ADR-026)
Subscribes to the :class:`~cleveragents.a2a.events.A2aEventQueue` for
real-time visibility into plan progress, tool invocations, actor reasoning,
and validation results. A2A ``TaskStatusUpdateEvent`` and
``TaskArtifactUpdateEvent`` notifications are translated into conversation
stream blocks including :class:`~cleveragents.tui.widgets.ThoughtBlockWidget`
for actor thought traces.
Element mapping
---------------
| ElementHandle type | Textual widget | Note |
|--------------------|---------------------------------------|----------------------------------------|
| ``PanelHandle`` | :class:`~textual.widgets.Static` | Key-value pairs inside a CSS panel |
| ``TableHandle`` | :class:`~textual.widgets.DataTable` | Incremental row population |
| ``TreeHandle`` | :class:`~textual.widgets.Tree` | Nodes added via :meth:`~.add_leaf` |
| ``ProgressHandle`` | Custom throbber / progress bar | Determines indeterminate vs determinate|
| ``StatusHandle`` | :class:`~textual.widgets.Label` | Semantic CSS class per level |
| ``CodeHandle`` | Read-only :class:`~textual.widgets.TextArea` | Syntax-highlighted code |
| ``DiffHandle`` | Custom :class:`DiffViewBlock` | Unified diff with line numbers |
| ``SeparatorHandle``| :class:`~textual.widgets.Rule` | Horizontal rule |
| ``ActionHintHandle``| :class:`~textual.widgets.Static` | Muted command suggestions |
| ``TextHandle`` | :class:`~textual.widgets.Static` | Free-form text block |
Based on the TuiMaterializer design from ADR-044 (TUI Architecture and Framework)
and the Output Rendering Framework specification (ADR-021).
"""
from __future__ import annotations
import contextlib
import threading
from typing import TYPE_CHECKING, Any
# ---------------------------------------------------------------------------
# Optional dependency imports with graceful fallback
# ---------------------------------------------------------------------------
try: # pragma: no cover - Textual is an optional extra
from textual.widgets import Static, Label, Rule, DataTable, Tree, TextArea
_TEXTUAL_AVAILABLE = True
except ImportError:
class _TStatic: # pragma: no cover
"""Fallback ``Static``."""
def __init__(self, *a: Any, **kw: Any):
self._text = ""
def update(self, text: str = "") -> None:
self._text = text
class _TLabel: # pragma: no cover
"""Fallback ``Label``."""
def __init__(self, *a: Any, **kw: Any):
self._text = ""
def update(self, text: str = "") -> None:
self._text = text
class _TRule: # pragma: no cover
"""Fallback ``Rule``."""
def __init__(self, *a: Any, **kw: Any): ...
class _TDataTable: # pragma: no cover
"""Fallback ``DataTable``."""
def __init__(self, *a: Any, **kw: Any): ...
def add_row(self, *args: Any, **kwargs: Any) -> None: ...
def remove_row(self, *args: Any, **kwargs: Any) -> None: ...
def clear(self) -> None: ...
class _TTree: # pragma: no cover
"""Fallback ``Tree``."""
def __init__(self, *a: Any, **kw: Any):
self.root = _DummyNode("root")
def add_leaf(self, *a: Any, **kw: Any) -> None: ...
def add(self, *a: Any, **kw: Any) -> object:
return _DummyNode("")
class _TTextArea: # pragma: no cover
"""Fallback ``TextArea``."""
def __init__(self, *a: Any, **kw: Any):
self.text = ""
def read(self) -> str:
return ""
def update(self, *args: Any, **kwargs: Any) -> None: ...
class _DummyNode: # pragma: no cover
"""Fallback tree node."""
def __init__(self, label: str) -> None:
self.label = label
self.add_child = lambda **kw: _DummyNode("")
Static = _TStatic
Label = _TLabel
Rule = _TRule
DataTable = _TDataTable
Tree = _TTree
TextArea = _TTextArea
_TEXTUAL_AVAILABLE = True # We fall back, so pretend available is fine.
if TYPE_CHECKING:
from cleveragents.cli.output.materializers import MaterializationStrategy
else:
class MaterializationStrategy: # type: ignore[no-redef]
"""Placeholder protocol for when CLI output module is not importable."""
strategy_name: str = ""
supports_incremental_updates: bool = False
# ---------------------------------------------------------------------------
# DiffViewBlock — unified diff display widget
# ---------------------------------------------------------------------------
class DiffViewBlock(Static): # pragma: no cover - rendered by TuiMaterializer
"""Diff view widget for :attr:`DiffHandle` mapped output.
Renders a unified-diff display with syntax highlighting and line numbers,
supporting both unified and side-by-view modes via a CSS class selector.
"""
DEFAULT_CSS = """
DiffViewBlock {
background: $surface-muted;
color: $text;
border: solid $primary 20%;
padding: 1;
margin: 1 0;
}
DiffViewBlock.diff--unified {
layout: vertical;
}
DiffViewBlock.diff--side-by-side {
layout: horizontal;
}
"""
# ---------------------------------------------------------------------------
# TuiMaterializer — drop-in MaterializationStrategy for the TUI
# ---------------------------------------------------------------------------
class TuiMaterializer(MaterializationStrategy): # type: ignore[misc, override]
"""Output materialisation strategy that renders to Textual widgets.
This class bridges the :class:`~cleveragents.cli.output.session.OutputSession` /
:class:`~cleveragents.cli.output.handles.ElementHandle` pipeline (ADR-021) to
Textual widget operations, enabling all CLI command producers to render in
the TUI without modification.
Additionally it subscribes to the A2A event queue for real-time visibility
into plan progress and actor reasoning traces, automatically mounting
:class:`~cleveragents.tui.widgets.ThoughtBlockWidget` instances into the
conversation stream when thought block events arrive.
Attributes:
conversation_stream: The :class:`~cleveragents.tui.conversation.ConversationStream`
widget that holds all rendered message blocks.
_handle_map: Mapping from handle IDs to mounted Textual widgets for inline updates.
_a2a_subscription_id: The A2A event queue subscription identifier (if active).
"""
strategy_name: str = "tui"
def __init__(
self,
*,
conversation_stream: Any | None = None,
a2a_event_queue: Any | None = None,
max_line_count: int = 2500,
prune_block_limit: int = 1500,
) -> None:
self._conversation_stream = conversation_stream
self._handles_lock = threading.Lock()
self._handle_map: dict[str, Any] = {}
self._a2a_subscription_id: str | None = None
# Configure the conversation stream if provided.
if conversation_stream is not None:
conversation_stream.max_line_count = max_line_count
conversation_stream.prune_block_limit = prune_block_limit
# Subscribe to A2A events if a queue is provided.
if a2a_event_queue is not None:
self.subscribe_a2a_events(a2a_event_queue)
# ---------------------------------------------------------------
# MaterializationStrategy protocol implementation
# ---------------------------------------------------------------
def on_session_begin(self, session: Any) -> None:
"""Called when a new :class:`~OutputSession` begins.
Establishes the working reference to the session but does not need
to do anything specific for the TUI widgets are created per-element.
"""
self._session = session # type: ignore[attr-defined]
def on_element_created(self, event: Any) -> None:
"""Called when an :class:`~ElementCreated` event fires.
Instantiates the corresponding Textual widget and mounts it into the
conversation's container. The widget is registered in ``_handle_map``
so that subsequent :meth:`on_element_updated` calls can find it for
inline updates.
Args:
event: An :class:`~ElementCreated` with handle_id, element_kind,
and initial_state.
"""
if self._conversation_stream is None:
return
widget = _create_textual_widget(
element_kind=event.element_kind,
initial_state=event.initial_state,
)
if widget is None:
return
with self._handles_lock:
self._handle_map[event.handle_id] = widget
self._conversation_stream.add_block(widget) # type: ignore[arg-type]
def on_element_updated(self, event: Any) -> None:
"""Called when an :class:`~ElementUpdated` event fires.
Finds the previously mounted widget via ``_handle_map`` and applies
the incremental update.
Args:
event: An :class:`~ElementUpdated` with handle_id and element_snapshot.
"""
if self._conversation_stream is None:
return
widget = _get_handle_widget(
self._handle_map,
event,
self._handles_lock,
)
if widget is None:
return
_apply_update_to_widget(widget, event.element_snapshot)
def on_element_closed(self, event: Any) -> None:
"""Called when an :class:`~ElementClosed` event fires.
Marks the widget as finalized (no more updates expected). For some
widgets this involves disabling loading indicators or settling animations.
Args:
event: An :class:`~ElementClosed` with handle_id and final_state.
"""
widget = _get_handle_widget(
self._handle_map,
event,
self._handles_lock,
)
if widget is None:
return
# Update to final state if available.
if hasattr(event, "final_state") and event.final_state is not None:
_apply_update_to_widget(widget, event.final_state)
# For auto-closed elements (SeparatorHandle, ActionHintHandle), the
# widget is already rendered; no further action needed.
def on_session_end(self, event: Any) -> None:
"""Called when an :class:`~SessionEnd` event fires.
Flushes any pending updates across all mounted widgets.
Args:
event: A :class:`~SessionEnd` with exit_code and timing info.
"""
if self._conversation_stream is None:
return
# No additional flush needed — individual element updates are
# written immediately via the reactive binding of Textual widgets.
# ---------------------------------------------------------------
# A2A event subscription
# ---------------------------------------------------------------
def subscribe_a2a_events(self, event_queue: Any) -> str | None:
"""Subscribe to an :class:`~A2aEventQueue` for real-time updates.
Registers a callback that translates incoming A2A events into
conversation stream blocks. Thought block events trigger creation
of :class:`~ThoughtBlockWidget` instances.
Args:
event_queue: An :class:`~A2aEventQueue` instance.
Returns:
The subscription ID, or ``None`` if the queue does not support
local callbacks.
"""
try:
sub_id = event_queue.subscribe_local(self._on_a2a_event)
self._a2a_subscription_id = sub_id
except Exception: # pragma: no cover - remote stub raises
return None
return sub_id
def unsubscribe_a2a_events(self, event_queue: Any) -> bool:
"""Unsubscribe from the A2A event queue.
Args:
event_queue: The :class:`~A2aEventQueue` that was subscribed.
Returns:
``True`` if a subscription was removed.
"""
if self._a2a_subscription_id is None:
return False
try:
from cleveragents.a2a.events import TASK_ARTIFACT_UPDATE, TASK_STATUS_UPDATE
except ImportError: # pragma: no cover
return event_queue.unsubscribe(self._a2a_subscription_id)
removed = event_queue.unsubscribe(self._a2a_subscription_id)
if removed:
self._a2a_subscription_id = None
return removed
def _on_a2a_event(self, event: Any) -> None: # type: ignore[unused-ignore]
"""Callback handler for A2A events from the event queue.
Translates domain event types into conversation stream blocks:
- Thought trace events :class:`~ThoughtBlockWidget`
- Tool invocation/completion status widgets in stream
- Plan state changes sidebar updates (handled separately)
"""
if self._conversation_stream is None:
return
try:
from cleveragents.tui.widgets.thought_block import ThoughtBlockWidget
from cleveragents.domain.models.thought.thought_block import (
ThoughtBlock,
)
except ImportError: # pragma: no cover - domain models may not be importable in minimal mode
return
event_type = getattr(event, "event_type", "")
if not event_type:
return
try:
event_data = dict(getattr(event, "data", {})) if hasattr(event, "data") else {}
except Exception: # pragma: no cover
return
# Map A2A event types to conversation blocks.
if event_type in ("ACTOR_THOUGHT", "THOUGHT_BLOCK"):
content = event_data.get("content", "")
thought = ThoughtBlock(
content=content,
max_lines=event_data.get("max_lines", 10),
expanded=False,
)
widget = ThoughtBlockWidget(thought)
self._conversation_stream.add_block(widget)
elif event_type in (
"TOOL_INVOKED",
"TOOL_COMPLETED",
"TOOL_FAILED",
):
tool_name = event_data.get("tool_name", "")
status_label = getattr(event, "event_type", "").replace("_", " ").title() # type: ignore[union-attr]
label = Label(f"[{status_label}] {tool_name}")
if hasattr(label, "add_class"):
label.add_class(f"a2a-status-{event_type}")
self._conversation_stream.add_block(label)
elif event_type == "PLAN_STATE_CHANGED":
plan_id = getattr(event, "plan_id", "") or ""
new_state = event_data.get("new_state", "")
label = Label(f"[{status_label}] Plan {plan_id!r}{new_state}") # noqa: F821
if hasattr(label, "add_class"):
label.add_class("a2a-status-plan")
self._conversation_stream.add_block(label)
# ---------------------------------------------------------------------------
# Widget factory & update helpers
# ---------------------------------------------------------------------------
def _create_textual_widget(
element_kind: str,
initial_state: Any,
) -> Any | None: # Returns the newly created widget or None.
"""Create a Textual widget from an event's :attr:`element_kind` and optional initial snapshot."""
if initial_state is None:
return None
# --- PanelHandle → Static with key-value styling ---
if element_kind == "panel":
widget = _create_panel_widget(initial_state)
return widget
# --- TableHandle → DataTable ---
if element_kind == "table":
widget = _create_table_widget(initial_state)
return widget
# --- TreeHandle → Tree ---
if element_kind == "tree":
widget = _create_tree_widget(initial_state)
return widget
# --- ProgressHandle → Throbber / progress bar ---
if element_kind == "progress":
widget = _create_progress_widget(initial_state)
return widget
# --- StatusHandle → Label with semantic CSS ---
if element_kind == "status":
widget = _create_status_widget(initial_state)
return widget
# --- CodeHandle → readOnly TextArea ---
if element_kind == "code":
widget = _create_code_widget(initial_state)
return widget
# --- DiffHandle → DiffViewBlock ---
if element_kind == "diff":
widget = _create_diff_widget(initial_state)
return widget
# --- SeparatorHandle → Rule ---
if element_kind == "separator":
widget = _create_separator_widget(initial_state)
return widget
# --- ActionHintHandle → Static with muted style ---
if element_kind == "action_hint":
widget = _create_action_hint_widget(initial_state)
return widget
# --- TextHandle → Static text block ---
if element_kind == "text":
widget = _create_text_widget(initial_state)
return widget
# Unknown element kind — ignore silently.
return None
def _create_panel_widget(state: Any) -> Static: # type: ignore[name-defined]
"""Create a ``Static`` panel widget from a Panel snapshot."""
title = getattr(state, "title", "Panel") if state else "Panel"
text_lines = [f"═══ {title} ═══"]
for attr in ("entries",):
entries = getattr(state, attr, None)
if entries:
for entry in entries: # type: ignore[arg-type]
key = getattr(entry, "key", "")
value = getattr(entry, "value", "")
text_lines.append(f" {key}: {value}")
return Static("\n".join(text_lines))
def _create_table_widget(state: Any) -> DataTable: # type: ignore[name-defined]
"""Create a ``DataTable`` widget from a Table snapshot."""
table = DataTable()
if state is None:
return table
cols = getattr(state, "columns", [])
for col in cols:
name = getattr(col, "name", "")
col_type = getattr(col, "col_type", "string")
alignment = getattr(col, "alignment", "default")
width_hint = getattr(col, "width_hint", None)
table.add_column(
label=name,
key=name,
index=0, # DataTable uses columns list, but we iterate above.
)
rows = getattr(state, "rows", [])
for row in rows:
table.add_row(*row)
return table
def _create_tree_widget(state: Any) -> Tree: # type: ignore[name-defined]
"""Create a ``Tree`` widget from a Tree snapshot."""
if state is None:
root_label = ""
else:
root_node = getattr(state, "root", None)
root_label = getattr(root_node, "label", "") if root_node else ""
tree = Tree(root_label or "Tree")
# Walk children.
if state is not None and getattr(state, "root", None) is not None:
_walk_tree_children(tree, state.root, depth=1) # type: ignore[arg-type]
return tree
def _walk_tree_children(parent_node: Any, node_state: Any, *, depth: int) -> None:
"""Recursively populate a Textual Tree from nested TreeNode snapshots."""
children = getattr(node_state, "children", [])
for child in children: # type: ignore[arg-type]
label = getattr(child, "label", "")
node = parent_node.add_leaf(label) if depth < 10 else parent_node.add(
label=label, data=None if _TEXTUAL_AVAILABLE else None
)
with contextlib.suppress(Exception):
style_hint = getattr(child, "style_hint", None)
if style_hint and hasattr(node, "add_class"):
node.add_class(f"tree-node--{style_hint}")
_walk_tree_children(parent_node, child, depth=depth + 1)
def _create_progress_widget(state: Any) -> Static: # type: ignore[name-defined]
"""Create a progress widget from a ProgressIndicator snapshot."""
if state is None:
return Static("[progress]")
label = getattr(state, "label", "")
total = getattr(state, "total", None)
current = getattr(state, "current", 0)
indeterminate = getattr(state, "indeterminate", False)
steps = getattr(state, "steps", [])
parts = [f"[progress] {label}"]
if indeterminate:
parts.append(" (running)")
elif total is not None and total > 0:
parts.append(f" [{current}/{total}]")
for i, step in enumerate(steps): # type: ignore[arg-type]
step_label = getattr(step, "label", f"step {i + 1}")
step_status = getattr(step, "status", "")
parts.append(f" - {step_label} ({step_status})")
return Static("\n".join(parts))
def _create_status_widget(state: Any) -> Label: # type: ignore[name-defined]
"""Create a status ``Label`` with semantic CSS class."""
if state is None:
return Label("")
level = getattr(state, "level", "info")
message = getattr(state, "message", "")
detail = getattr(state, "detail", "")
text = f"[{level}] {message}"
if detail:
text += f": {detail}"
label = Label(text)
with contextlib.suppress(Exception):
label.add_class(f"status-label--{level}")
return label
def _create_code_widget(state: Any) -> TextArea: # type: ignore[name-defined]
"""Create a read-only ``TextArea`` from a CodeBlock snapshot."""
if state is None:
return TextArea("")
content = getattr(state, "content", "")
language = getattr(state, "language", "text")
read_only = True
try:
area = TextArea(content, read_only=read_only)
except TypeError:
# Older Textual versions use different constructor signatures.
area = TextArea("")
area.text = content
with contextlib.suppress(Exception):
area.language = language # type: ignore[union-attr]
return area
def _create_diff_widget(state: Any) -> DiffViewBlock: # noqa: F821
"""Create a ``DiffViewBlock`` from a DiffBlock snapshot."""
file_a = getattr(state, "file_a", "") or ""
file_b = getattr(state, "file_b", "") or ""
if not file_a and not file_b:
return DiffViewBlock("(no diff)")
hunk_lines: list[str] = []
hunks = getattr(state, "hunks", [])
for hunk in hunks: # type: ignore[arg-type]
header = getattr(hunk, "header", "")
if header:
hunk_lines.append(f"@@ {header} @@")
lines = getattr(hunk, "lines", [])
for line_obj in lines: # type: ignore[arg-type]
line_type = getattr(line_obj, "line_type", " ")
content = getattr(line_obj, "content", "")
hunk_lines.append(f"{line_type} {content}")
if not hunk_lines:
return DiffViewBlock(f"--- {file_a}\n+++ {file_b}\n(empty)")
text = f"diff -- {file_a}{file_b}" + "\n".join(hunk_lines)
return DiffViewBlock(text)
def _create_separator_widget(state: Any) -> Rule: # type: ignore[name-defined]
"""Create a ``Rule`` widget from a Separator snapshot."""
style = ""
if state is not None:
s = getattr(state, "style", "line")
if s == "double":
style = ""
elif s == "blank":
return Rule() # blank separator is invisible.
return Rule(style)
def _create_action_hint_widget(state: Any) -> Static: # type: ignore[name-defined]
"""Create a muted ``Static`` showing command hints."""
if state is None:
return Static("")
commands = getattr(state, "commands", [])
description = getattr(state, "description", "")
parts = [f"[hint]"]
if description:
parts.append(f" {description}")
for cmd in commands: # type: ignore[arg-type]
parts.append(f"{cmd}")
return Static("\n".join(parts))
def _create_text_widget(state: Any) -> Static: # type: ignore[name-defined]
"""Create a plain ``Static`` text block."""
if state is None:
return Static("")
content = getattr(state, "content", "")
wrap = getattr(state, "wrap", True)
indent = getattr(state, "indent", 0)
if indent and content:
lines = [f"{' ' * indent}{l}" for l in content.splitlines()]
content = "\n".join(lines)
return Static(content)
# ---------------------------------------------------------------------------
# Update helper — applies incremental updates to mounted widgets
# ---------------------------------------------------------------------------
def _get_handle_widget(handle_map: dict[str, Any], event: Any, lock: threading.Lock) -> Any | None: # noqa: F821
"""Retrieve a previously mounted widget for an event's handle ID."""
handle_id = getattr(event, "handle_id", "") or ""
with lock:
return handle_map.get(handle_id)
def _apply_update_to_widget(widget: Any, snapshot: Any) -> None: # noqa: F821
"""Apply an incremental update to an already-mounted widget."""
if snapshot is None:
return
kind = getattr(snapshot, "__class__", None)
if kind is None:
return
class_name = getattr(kind, "__name__", "")
if class_name == "Panel":
_update_panel_widget(widget, snapshot)
elif class_name == "Table":
_update_table_widget(widget, snapshot)
elif class_name == "Tree":
_update_tree_widget(widget, snapshot)
elif class_name == "ProgressIndicator":
_update_progress_widget(widget, snapshot)
elif class_name == "StatusMessage":
_update_status_widget(widget, snapshot)
elif class_name == "CodeBlock":
_update_code_widget(widget, snapshot)
elif class_name == "DiffBlock":
_update_diff_widget(widget, snapshot)
elif class_name == "TextBlock":
_update_text_widget(widget, snapshot)
def _update_panel_widget(widget: Any, state: Any) -> None: # noqa: F821
widget.update("\n".join([f"═══ {getattr(state, 'title', '')} ═══"])) # type: ignore[arg-type]
def _update_table_widget(widget: Any, state: Any) -> None: # noqa: F821
rows = getattr(state, "rows", [])
for row in rows: # type: ignore[arg-type]
with contextlib.suppress(Exception):
widget.add_row(*row)
def _update_tree_widget(widget: Any, state: Any) -> None: # noqa: F821
root = getattr(state, "root", None)
if root is None:
return
children = getattr(root, "children", [])
for child in children: # type: ignore[arg-type]
label = getattr(child, "label", "")
with contextlib.suppress(Exception):
widget.add_leaf(label)
def _update_progress_widget(widget: Any, state: Any) -> None: # noqa: F821
label_text = []
if hasattr(state, "label"):
label_text.append(getattr(state, "label", ""))
if hasattr(state, "current") and hasattr(state, "total"):
label_text.append(f"{getattr(state, 'current')}/{getattr(state, 'total')}")
widget.update(" ".join(label_text) if label_text else "") # type: ignore[arg-type]
def _update_status_widget(widget: Any, state: Any) -> None: # noqa: F821
level = getattr(state, "level", "info")
message = getattr(state, "message", "")
widget.update(f"[{level}] {message}") # type: ignore[arg-type]
def _update_code_widget(widget: Any, state: Any) -> None: # noqa: F821
content = getattr(state, "content", "")
try:
if hasattr(widget, "text"):
widget.text = content
else:
widget.update(content)
except Exception: # pragma: no cover
widget.update(f"[code] (content unavailable)")
def _update_diff_widget(widget: Any, state: Any) -> None: # noqa: F821
"""Update DiffViewBlock with new diff content."""
if not hasattr(state, "hunks"):
return
lines: list[str] = []
for hunk in getattr(state, "hunks", []): # type: ignore[arg-type]
header = getattr(hunk, "header", "")
lines.append(f"@@ {header} @@")
for line_obj in getattr(hunk, "lines", []): # type: ignore[arg-type]
lt = getattr(line_obj, "line_type", " ")
lc = getattr(line_obj, "content", "")
lines.append(f"{lt} {lc}")
text = "\n".join(lines) if lines else "(empty diff)"
widget.update(text) # type: ignore[arg-type]
def _update_text_widget(widget: Any, state: Any) -> None: # noqa: F821
content = getattr(state, "content", "")
try:
if hasattr(widget, "text"):
widget.text = content
else:
widget.update(content)
except Exception: # pragma: no cover
pass
# ---------------------------------------------------------------------------
# Public exports
# ---------------------------------------------------------------------------
__all__ = [
"TuiMaterializer",
"DiffViewBlock",
]