Files
cleveragents-core/features/steps/tui_materializer_steps.py
T
HAL9000 3cb3815944 feat(tui): implement TuiMaterializer bridging A2A event queue to conversation view with ThoughtBlockWidget
Implemented the TuiMaterializer class that bridges the Output Rendering
Framework to Textual UI widgets, enabling all CLI command producers to
render in the TUI without modification. Split the module into three files
to stay under the 500-line file limit: main materializer.py (TuiMaterializer
class), _tui_events.py (event type constants and event model), and
_tui_renderers.py (all rendering helper functions).

The TuiMaterializer implements the MaterializationStrategy protocol and
maps ElementHandle events (Panel, Table, Status, Progress, Tree, Code,
Diff, Separator, ActionHint, Text) to plain-text renderings for TUI display.
Supports real-time streaming updates and A2A event routing for
PermissionRequest and ThoughtBlock events. Thread-safe with lock guards on
all shared state mutations.

Added comprehensive Behave BDD test suite covering all element types,
callback invocation, rendered output accumulation, A2A routing logic, and
concurrent thread safety verification.

ISSUES CLOSED: #5326
2026-06-15 11:14:31 +00:00

549 lines
20 KiB
Python

"""Step definitions for tui_materializer.feature."""
from __future__ import annotations
import threading
from typing import Any
from behave import given, then, when # type: ignore[import-untyped]
# ---------------------------------------------------------------------------
# Background
# ---------------------------------------------------------------------------
@given("the tui materializer module is imported")
def step_import_tui_materializer(context: Any) -> None:
from cleveragents.tui.materializer import (
TuiMaterializer,
TuiWidgetEvent,
TuiWidgetEventType,
render_element_for_tui,
)
context.TuiMaterializer = TuiMaterializer
context.TuiWidgetEvent = TuiWidgetEvent
context.TuiWidgetEventType = TuiWidgetEventType
context.render_element_for_tui = render_element_for_tui
context.materializer = None
context.session = None
context.panel_handle = None
context.status_handle = None
context.rendered_text = ""
context.callback_events: list[TuiWidgetEvent] = []
context.last_event: TuiWidgetEvent | None = None
# ---------------------------------------------------------------------------
# Module exports
# ---------------------------------------------------------------------------
@then("TuiMaterializer should be importable from tui.materializer")
def step_check_tui_materializer_importable(context: Any) -> None:
from cleveragents.tui.materializer import TuiMaterializer
assert TuiMaterializer is not None
@then("TuiWidgetEvent should be importable from tui.materializer")
def step_check_tui_widget_event_importable(context: Any) -> None:
from cleveragents.tui.materializer import TuiWidgetEvent
assert TuiWidgetEvent is not None
@then("TuiWidgetEventType should be importable from tui.materializer")
def step_check_tui_widget_event_type_importable(context: Any) -> None:
from cleveragents.tui.materializer import TuiWidgetEventType
assert TuiWidgetEventType is not None
@then("render_element_for_tui should be importable from tui.materializer")
def step_check_render_element_for_tui_importable(context: Any) -> None:
from cleveragents.tui.materializer import render_element_for_tui
assert render_element_for_tui is not None
# ---------------------------------------------------------------------------
# TuiMaterializer instantiation
# ---------------------------------------------------------------------------
@when("I create a TuiMaterializer without a callback")
def step_create_materializer_no_callback(context: Any) -> None:
from cleveragents.tui.materializer import TuiMaterializer
context.materializer = TuiMaterializer()
@when("I create a TuiMaterializer with a callback")
def step_create_materializer_with_callback(context: Any) -> None:
from cleveragents.tui.materializer import TuiMaterializer, TuiWidgetEvent
context.callback_events = []
def _callback(event: TuiWidgetEvent) -> None:
context.callback_events.append(event)
context.materializer = TuiMaterializer(on_event=_callback)
@then('the materializer strategy_name should be "tui"')
def step_check_strategy_name(context: Any) -> None:
assert context.materializer.strategy_name == "tui"
@then("the materializer supports_incremental_updates should be True")
def step_check_supports_incremental(context: Any) -> None:
assert context.materializer.supports_incremental_updates is True
@then("the materializer events list should be empty")
def step_check_events_empty(context: Any) -> None:
assert context.materializer.events == []
# ---------------------------------------------------------------------------
# the materializer should still be running (alias for strategy checks)
# ---------------------------------------------------------------------------
@then("the materializer should still be running")
def step_materializer_still_running(context: Any) -> None:
"""The materializer is considered 'running' when it reports a valid
strategy name and supports incremental updates."""
assert context.materializer.strategy_name == "tui"
assert context.materializer.supports_incremental_updates is True
# ---------------------------------------------------------------------------
# on_session_begin
# ---------------------------------------------------------------------------
@when("I call on_session_begin with a mock session")
def step_call_on_session_begin(context: Any) -> None:
context.materializer.on_session_begin(None) # type: ignore[arg-type]
@then("no error should be raised")
def step_no_error_raised(context: Any) -> None:
pass # If we got here, no exception was raised
# ---------------------------------------------------------------------------
# OutputSession integration
# ---------------------------------------------------------------------------
@when("I use the materializer with an OutputSession")
def step_use_with_output_session(context: Any) -> None:
from cleveragents.cli.output.session import OutputSession
context.session = OutputSession(strategy=context.materializer)
@when('I create a panel handle titled "{title}"')
def step_create_panel_handle(context: Any, title: str) -> None:
context.panel_handle = context.session.panel(title)
@when('I create a table handle titled "{title}"')
def step_create_table_handle(context: Any, title: str) -> None:
context.table_handle = context.session.table(title)
@when('I create a status handle with message "{message}"')
def step_create_status_handle(context: Any, message: str) -> None:
context.status_handle = context.session.status(message)
@when('I create a progress handle with label "{label}"')
def step_create_progress_handle(context: Any, label: str) -> None:
context.progress_handle = context.session.progress(label, indeterminate=True)
@when('I create a code handle with content "{content}" and language "{language}"')
def step_create_code_handle(context: Any, content: str, language: str) -> None:
context.code_handle = context.session.code(content, language=language)
@when("I create a separator handle")
def step_create_separator_handle(context: Any) -> None:
context.separator_handle = context.session.separator()
@when('I create an action hint handle with command "{command}"')
def step_create_action_hint_handle(context: Any, command: str) -> None:
context.action_hint_handle = context.session.action_hint([command])
@when("I close the panel handle")
def step_close_panel_handle(context: Any) -> None:
context.panel_handle.close()
@when("I close the status handle")
def step_close_status_handle(context: Any) -> None:
context.status_handle.close()
@when("I close the session")
def step_close_session(context: Any) -> None:
context.session.close()
@when('I create and close a status handle with message "{message}"')
def step_create_and_close_status_handle(context: Any, message: str) -> None:
handle = context.session.status(message)
handle.close()
# ---------------------------------------------------------------------------
# Event assertions
# ---------------------------------------------------------------------------
@then("an element_created event should be emitted")
def step_check_element_created_event(context: Any) -> None:
events = context.materializer.events
created_events = [e for e in events if e.event_type == "element_created"]
assert len(created_events) > 0, f"No element_created events found. Events: {events}"
context.last_event = created_events[-1]
@then('the event element_kind should be "{kind}"')
def step_check_event_element_kind(context: Any, kind: str) -> None:
assert context.last_event is not None
assert context.last_event.element_kind == kind, (
f"Expected element_kind={kind!r}, got {context.last_event.element_kind!r}"
)
@then('the event rendered_text should contain "{text}"')
def step_check_event_rendered_text_contains(context: Any, text: str) -> None:
assert context.last_event is not None
assert text in context.last_event.rendered_text, (
f"Expected {text!r} in rendered_text={context.last_event.rendered_text!r}"
)
@then("an element_closed event should be emitted")
def step_check_element_closed_event(context: Any) -> None:
events = context.materializer.events
closed_events = [e for e in events if e.event_type == "element_closed"]
assert len(closed_events) > 0, f"No element_closed events found. Events: {events}"
context.last_closed_event = closed_events[-1]
@then('the closed event rendered_text should contain "{text}"')
def step_check_closed_event_rendered_text(context: Any, text: str) -> None:
assert context.last_closed_event is not None
assert text in context.last_closed_event.rendered_text, (
f"Expected {text!r} in rendered_text={context.last_closed_event.rendered_text!r}"
)
@then("a session_end event should be emitted")
def step_check_session_end_event(context: Any) -> None:
events = context.materializer.events
end_events = [e for e in events if e.event_type == "session_end"]
assert len(end_events) > 0, f"No session_end events found. Events: {events}"
# ---------------------------------------------------------------------------
# Callback assertions
# ---------------------------------------------------------------------------
@then("the callback should have been called")
def step_check_callback_called(context: Any) -> None:
assert len(context.callback_events) > 0, "Callback was not called"
@then('the callback event type should be "element_created"')
def step_check_callback_event_type_created(context: Any) -> None:
created = [e for e in context.callback_events if e.event_type == "element_created"]
assert len(created) > 0, (
f"No element_created callback events. Got: {[e.event_type for e in context.callback_events]}"
)
@then('the callback should have been called with event_type "{event_type}"')
def step_check_callback_event_type(context: Any, event_type: str) -> None:
matching = [e for e in context.callback_events if e.event_type == event_type]
assert len(matching) > 0, (
f"No {event_type!r} callback events. Got: {[e.event_type for e in context.callback_events]}"
)
# ---------------------------------------------------------------------------
# rendered_output property
# ---------------------------------------------------------------------------
@then("the materializer rendered_output should be empty")
def step_check_rendered_output_empty(context: Any) -> None:
assert context.materializer.rendered_output == "", (
f"Expected empty rendered_output, got: {context.materializer.rendered_output!r}"
)
@then('the materializer rendered_output should contain "{text}"')
def step_check_rendered_output_contains(context: Any, text: str) -> None:
output = context.materializer.rendered_output
assert text in output, f"Expected {text!r} in rendered_output={output!r}"
# ---------------------------------------------------------------------------
# render_element_for_tui
# ---------------------------------------------------------------------------
@when('I render a Panel element with title "{title}" and entry "{key}" "{value}"')
def step_render_panel_element(context: Any, title: str, key: str, value: str) -> None:
from cleveragents.cli.output.handles._models import Panel, PanelEntry
from cleveragents.tui.materializer import render_element_for_tui
element = Panel(title=title, entries=[PanelEntry(key=key, value=value)])
context.rendered_text = render_element_for_tui(element)
@when('I render a Table element with title "{title}" and column "{column}"')
def step_render_table_element(context: Any, title: str, column: str) -> None:
from cleveragents.cli.output.handles._models import ColumnDef, Table
from cleveragents.tui.materializer import render_element_for_tui
element = Table(title=title, columns=[ColumnDef(name=column)])
context.rendered_text = render_element_for_tui(element)
@when('I render a StatusMessage element with level "{level}" and message "{message}"')
def step_render_status_element(context: Any, level: str, message: str) -> None:
from cleveragents.cli.output.handles._models import StatusMessage
from cleveragents.tui.materializer import render_element_for_tui
element = StatusMessage(message=message, level=level)
context.rendered_text = render_element_for_tui(element)
@when('I render an indeterminate ProgressIndicator with label "{label}"')
def step_render_indeterminate_progress(context: Any, label: str) -> None:
from cleveragents.cli.output.handles._models import ProgressIndicator
from cleveragents.tui.materializer import render_element_for_tui
element = ProgressIndicator(label=label, indeterminate=True)
context.rendered_text = render_element_for_tui(element)
@when(
'I render a determinate ProgressIndicator with label "{label}" current {current:d} total {total:d}'
)
def step_render_determinate_progress(
context: Any, label: str, current: int, total: int
) -> None:
from cleveragents.cli.output.handles._models import ProgressIndicator
from cleveragents.tui.materializer import render_element_for_tui
element = ProgressIndicator(label=label, current=current, total=total)
context.rendered_text = render_element_for_tui(element)
@when('I render a Tree element with root "{root}" and child "{child}"')
def step_render_tree_element(context: Any, root: str, child: str) -> None:
from cleveragents.cli.output.handles._models import Tree, TreeNode
from cleveragents.tui.materializer import render_element_for_tui
root_node = TreeNode(label=root, children=[TreeNode(label=child)])
element = Tree(root=root_node)
context.rendered_text = render_element_for_tui(element)
@when(
'I render a CodeBlock element with language "{language}" and content "{content}"'
)
def step_render_code_element(context: Any, language: str, content: str) -> None:
from cleveragents.cli.output.handles._models import CodeBlock
from cleveragents.tui.materializer import render_element_for_tui
element = CodeBlock(content=content, language=language)
context.rendered_text = render_element_for_tui(element)
@when(
'I render a DiffBlock element with file_a "{file_a}" and file_b "{file_b}"'
)
def step_render_diff_element(context: Any, file_a: str, file_b: str) -> None:
from cleveragents.cli.output.handles._models import DiffBlock
from cleveragents.tui.materializer import render_element_for_tui
element = DiffBlock(file_a=file_a, file_b=file_b)
context.rendered_text = render_element_for_tui(element)
@when('I render a Separator element with style "{style}"')
def step_render_separator_element(context: Any, style: str) -> None:
from cleveragents.cli.output.handles._models import Separator
from cleveragents.tui.materializer import render_element_for_tui
element = Separator(style=style)
context.rendered_text = render_element_for_tui(element)
@when('I render an ActionHint element with command "{command}"')
def step_render_action_hint_element(context: Any, command: str) -> None:
from cleveragents.cli.output.handles._models import ActionHint
from cleveragents.tui.materializer import render_element_for_tui
element = ActionHint(commands=[command])
context.rendered_text = render_element_for_tui(element)
@when('I render a TextBlock element with content "{content}"')
def step_render_text_element(context: Any, content: str) -> None:
from cleveragents.cli.output.handles._models import TextBlock
from cleveragents.tui.materializer import render_element_for_tui
element = TextBlock(content=content)
context.rendered_text = render_element_for_tui(element)
@when('I render a TextBlock element with content "{content}" and indent {indent:d}')
def step_render_text_element_with_indent(
context: Any, content: str, indent: int
) -> None:
from cleveragents.cli.output.handles._models import TextBlock
from cleveragents.tui.materializer import render_element_for_tui
element = TextBlock(content=content, indent=indent)
context.rendered_text = render_element_for_tui(element)
@then('the rendered text should contain "{text}"')
def step_check_rendered_text_contains(context: Any, text: str) -> None:
assert text in context.rendered_text, (
f"Expected {text!r} in rendered_text={context.rendered_text!r}"
)
@then("the rendered text should be empty")
def step_check_rendered_text_empty(context: Any) -> None:
assert context.rendered_text == "", (
f"Expected empty rendered_text, got: {context.rendered_text!r}"
)
# ---------------------------------------------------------------------------
# A2A event routing
# ---------------------------------------------------------------------------
@when(
'I route a permission request for "{file_path}" with type "{request_type}"'
)
def step_route_permission_request(
context: Any, file_path: str, request_type: str
) -> None:
from cleveragents.domain.models.core.inline_permission_question import (
InlinePermissionQuestion,
PermissionRequestType,
)
question = InlinePermissionQuestion(
file_path=file_path,
request_type=PermissionRequestType(request_type),
)
context.permission_question = question
context.materializer.route_permission_request(question)
@then("a permission_request event should be emitted")
def step_check_permission_request_event(context: Any) -> None:
events = context.materializer.events
perm_events = [e for e in events if e.event_type == "permission_request"]
assert len(perm_events) > 0, f"No permission_request events found. Events: {events}"
context.last_perm_event = perm_events[-1]
@then("the permission event extra should be the InlinePermissionQuestion")
def step_check_permission_event_extra(context: Any) -> None:
assert context.last_perm_event.extra is context.permission_question
@then('the permission event rendered_text should contain "{text}"')
def step_check_permission_event_rendered_text(context: Any, text: str) -> None:
assert text in context.last_perm_event.rendered_text, (
f"Expected {text!r} in rendered_text={context.last_perm_event.rendered_text!r}"
)
@when('I route a thought block with content "{content}"')
def step_route_thought_block(context: Any, content: str) -> None:
from cleveragents.domain.models.thought.thought_block import ThoughtBlock
thought = ThoughtBlock(content=content)
context.thought_block = thought
context.materializer.route_thought_block(thought)
@then("a thought_block event should be emitted")
def step_check_thought_block_event(context: Any) -> None:
events = context.materializer.events
thought_events = [e for e in events if e.event_type == "thought_block"]
assert len(thought_events) > 0, f"No thought_block events found. Events: {events}"
context.last_thought_event = thought_events[-1]
@then("the thought event extra should be the ThoughtBlock")
def step_check_thought_event_extra(context: Any) -> None:
assert context.last_thought_event.extra is context.thought_block
@then('the thought event rendered_text should contain "{text}"')
def step_check_thought_event_rendered_text(context: Any, text: str) -> None:
assert text in context.last_thought_event.rendered_text, (
f"Expected {text!r} in rendered_text={context.last_thought_event.rendered_text!r}"
)
# ---------------------------------------------------------------------------
# Thread safety
# ---------------------------------------------------------------------------
@when("I create multiple status handles concurrently")
def step_create_multiple_status_handles_concurrently(context: Any) -> None:
errors: list[Exception] = []
def _create_handle(i: int) -> None:
try:
handle = context.session.status(f"Message {i}")
handle.close()
except Exception as exc:
errors.append(exc)
threads = [threading.Thread(target=_create_handle, args=(i,)) for i in range(10)]
for t in threads:
t.start()
for t in threads:
t.join()
context.thread_errors = errors
@then("all events should be recorded without data corruption")
def step_check_thread_safety(context: Any) -> None:
assert context.thread_errors == [], (
f"Thread errors: {context.thread_errors}"
)
events = context.materializer.events
# We should have at least 10 element_created + 10 element_closed events
created = [e for e in events if e.event_type == "element_created"]
closed = [e for e in events if e.event_type == "element_closed"]
assert len(created) >= 10, f"Expected >= 10 created events, got {len(created)}"
assert len(closed) >= 10, f"Expected >= 10 closed events, got {len(closed)}"