Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e02efb74a0 |
@@ -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
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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",
|
||||
]
|
||||
Reference in New Issue
Block a user