diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a242ab59..d083403a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -527,6 +527,18 @@ _ALL_DATA_COLUMNS + ") " "SELECT " + _ALL_DATA_COLUMNS + " FROM v3_plans"`. machine-readable JSON envelope structure shared across all CLI commands, and CI-friendly `diagnostics --check` health monitoring. Registered in `docs/showcase/examples.json`. Closes #7592. +- **TuiMaterializer A2A integration layer** (#5326): 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. 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. + Includes A2A event routing for ``PermissionRequest`` and ``ThoughtBlock`` events. + Thread-safe event accumulation with thread-lock guards on all state mutations. + Supports real-time streaming updates via the ``on_event`` callback pattern. + Comprehensive Behave BDD test suite covering all element types, callback + invocation, rendered output accumulation, A2A routing, and concurrent thread safety. + - `agents actor context clear` command to reset actor message history and state while preserving the underlying context directory via `ContextManager` (#6370). @@ -1271,4 +1283,4 @@ iteration` and data corruption under concurrent plan execution. All public - **TUI -- Permission Question Widget**: A new inline `PermissionQuestionWidget` renders permission requests directly in the conversation stream for single-key operations. Users can allow/reject with single-key shortcuts (`a`/`A`/`r`/`R`), - navigate with arrow keys, confirm with `Enter`, or press `v` to open the full \ No newline at end of file + navigate with arrow keys, confirm with `Enter`, or press `v` to open the full diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index 2c5b3e60e..49cdcbf29 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -68,6 +68,9 @@ Below are some of the specific details of various contributions. * HAL 9000 has contributed the concurrent ValidationPipeline stdout/stderr restoration fix (PR #7811 / issue #7623): introduced a reference-counted shared stream wrapper manager so concurrent ValidationPipeline.run() calls correctly restore the true sys.stdout/sys.stderr after all pipelines finish, preventing permanent stream wrapping under concurrent execution. * HAL 9000 has contributed the LLMTraceRepository data-integrity fix (PR #8185 / issue #7505): replaced the unconditional `session.commit()` in `LLMTraceRepository.save()` with a dual-path implementation that respects the UnitOfWork pattern — flushing only when an external session is provided, and flushing + committing + closing when operating standalone. This eliminates premature transaction commits, loss of rollback capability, and a docstring/implementation mismatch. * HAL 9000 has contributed the ACMS Index Data Model and File Traversal Engine (PR #9664 / issue #9579): foundational data structures for indexed context entries with hot/warm/cold/archive storage tier classification, tag system, and a timeout-safe chunked file traversal engine for large projects with 10,000+ files. + +* HAL 9000 has contributed the TuiMaterializer A2A integration layer (PR #10589 / issue #5326): implemented the ``TuiMaterializer`` class bridging the Output Rendering Framework to Textual UI widgets, implementing the ``MaterializationStrategy`` protocol, mapping all element types (Panel, Table, Status, Progress, Tree, Code, Diff, Separator, ActionHint, Text) to plain-text renderings, adding A2A routing for PermissionRequest and ThoughtBlock events, with comprehensive Behave BDD test coverage including thread-safety verification. + * HAL 9000 has contributed the error-suppression removal fix (PR #9247 / issue #9060): removed both `try...except Exception:` blocks in `register_registry_agents()` that silently suppressed errors from `actor_registry.list_actors()` and the route bridge refresh, enabling exceptions to propagate per CONTRIBUTING.md fail-fast policy. Added three Behave scenarios verifying RuntimeError, AttributeError, and TypeError propagation. * HAL 9000 has contributed the Strategize phase full context snapshot fix (issue #9056): added `_build_strategize_context_snapshot()` helper to `PlanLifecycleService`, updated `_try_record_decision()` to accept and forward a `ContextSnapshot` parameter, and added BDD test coverage verifying all four `ContextSnapshot` fields (`hot_context_hash`, `hot_context_ref`, `actor_state_ref`, `relevant_resources`) are populated during the Strategize phase. * HAL 9000 has contributed the ACMS context path matching fix (PR #10975 / issue #10972): corrects `_path_matches()` and `_matches_pattern()` to properly match absolute fragment paths against relative glob patterns by auto-prefixing with `**/` before calling `PurePath.full_match()`, preventing silent inefficacy of include/exclude filters for absolute paths in fragment metadata. diff --git a/features/steps/tui_materializer_steps.py b/features/steps/tui_materializer_steps.py new file mode 100644 index 000000000..22336f4c5 --- /dev/null +++ b/features/steps/tui_materializer_steps.py @@ -0,0 +1,515 @@ +"""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] + + +# --------------------------------------------------------------------------- +# 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 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 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 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 render output 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 render output 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)}" diff --git a/features/tui_materializer.feature b/features/tui_materializer.feature new file mode 100644 index 000000000..ade80e35c --- /dev/null +++ b/features/tui_materializer.feature @@ -0,0 +1,233 @@ +Feature: TUI Materializer + As a TUI developer + I want a TuiMaterializer that bridges the Output Rendering Framework to Textual widgets + So that all CLI command producers can render in the TUI without modification + + Background: + Given the tui materializer module is imported + + # ── Module exports ──────────────────────────────────────────────────────── + + Scenario: TuiMaterializer is importable from tui.materializer + Then TuiMaterializer should be importable from tui.materializer + And TuiWidgetEvent should be importable from tui.materializer + And TuiWidgetEventType should be importable from tui.materializer + And render_element_for_tui should be importable from tui.materializer + + # ── TuiMaterializer instantiation ──────────────────────────────────────── + + Scenario: TuiMaterializer can be instantiated without arguments + When I create a TuiMaterializer without a callback + Then the materializer strategy_name should be "tui" + And the materializer supports_incremental_updates should be True + And the materializer events list should be empty + + Scenario: TuiMaterializer can be instantiated with a callback + When I create a TuiMaterializer with a callback + Then the materializer strategy_name should be "tui" + + # ── MaterializationStrategy protocol ───────────────────────────────────── + + Scenario: TuiMaterializer implements on_session_begin + When I create a TuiMaterializer without a callback + And I call on_session_begin with a mock session + Then the materializer should still be running + + Scenario: TuiMaterializer emits element_created event for panel + When I create a TuiMaterializer without a callback + And I use the materializer with an OutputSession + And I create a panel handle titled "Test Panel" + Then an element_created event should be emitted + And the event element_kind should be "panel" + And the event rendered_text should contain "Test Panel" + + Scenario: TuiMaterializer emits element_created event for table + When I create a TuiMaterializer without a callback + And I use the materializer with an OutputSession + And I create a table handle titled "Results" + Then an element_created event should be emitted + And the event element_kind should be "table" + And the event rendered_text should contain "Results" + + Scenario: TuiMaterializer emits element_created event for status + When I create a TuiMaterializer without a callback + And I use the materializer with an OutputSession + And I create a status handle with message "Operation complete" + Then an element_created event should be emitted + And the event element_kind should be "status" + And the event rendered_text should contain "Operation complete" + + Scenario: TuiMaterializer emits element_created event for progress + When I create a TuiMaterializer without a callback + And I use the materializer with an OutputSession + And I create a progress handle with label "Loading" + Then an element_created event should be emitted + And the event element_kind should be "progress" + And the event rendered_text should contain "Loading" + + Scenario: TuiMaterializer emits element_created event for code block + When I create a TuiMaterializer without a callback + And I use the materializer with an OutputSession + And I create a code handle with content "print('hello')" and language "python" + Then an element_created event should be emitted + And the event element_kind should be "code" + And the event rendered_text should contain "print('hello')" + + Scenario: TuiMaterializer emits element_created event for separator + When I create a TuiMaterializer without a callback + And I use the materializer with an OutputSession + And I create a separator handle + Then an element_created event should be emitted + And the event element_kind should be "separator" + + Scenario: TuiMaterializer emits element_created event for action hint + When I create a TuiMaterializer without a callback + And I use the materializer with an OutputSession + And I create an action hint handle with command "agents plan list" + Then an element_created event should be emitted + And the event element_kind should be "action_hint" + And the event rendered_text should contain "agents plan list" + + Scenario: TuiMaterializer emits element_closed event when handle is closed + When I create a TuiMaterializer without a callback + And I use the materializer with an OutputSession + And I create a panel handle titled "Closing Panel" + And I close the panel handle + Then an element_closed event should be emitted + And the closed event rendered_text should contain "Closing Panel" + + Scenario: TuiMaterializer emits session_end event when session ends + When I create a TuiMaterializer without a callback + And I use the materializer with an OutputSession + And I create a status handle with message "Done" + And I close the session + Then a session_end event should be emitted + + # ── Callback invocation ─────────────────────────────────────────────────── + + Scenario: TuiMaterializer invokes callback on element_created + When I create a TuiMaterializer with a callback + And I use the materializer with an OutputSession + And I create a status handle with message "Callback test" + Then the callback should have been called + And the callback event type should be "element_created" + + Scenario: TuiMaterializer invokes callback on element_closed + When I create a TuiMaterializer with a callback + And I use the materializer with an OutputSession + And I create a status handle with message "Close callback test" + And I close the status handle + Then the callback should have been called with event_type "element_closed" + + Scenario: TuiMaterializer invokes callback on session_end + When I create a TuiMaterializer with a callback + And I use the materializer with an OutputSession + And I close the session + Then the callback should have been called with event_type "session_end" + + # ── rendered_output property ────────────────────────────────────────────── + + Scenario: rendered_output returns empty string when no elements + When I create a TuiMaterializer without a callback + Then the materializer rendered_output should be empty + + Scenario: rendered_output accumulates closed element text + When I create a TuiMaterializer without a callback + And I use the materializer with an OutputSession + And I create and close a status handle with message "Accumulated" + Then the materializer rendered_output should contain "Accumulated" + + # ── render_element_for_tui ──────────────────────────────────────────────── + + Scenario: render_element_for_tui renders Panel with title and entries + When I render a Panel element with title "Info" and entry "key" "value" + Then the render output should contain "Info" + And the render output should contain "key" + And the render output should contain "value" + + Scenario: render_element_for_tui renders Table with columns and rows + When I render a Table element with title "Data" and column "Name" + Then the render output should contain "Data" + And the render output should contain "Name" + + Scenario: render_element_for_tui renders StatusMessage ok level + When I render a StatusMessage element with level "ok" and message "Success" + Then the render output should contain "ok" + And the render output should contain "Success" + + Scenario: render_element_for_tui renders StatusMessage error level + When I render a StatusMessage element with level "error" and message "Failed" + Then the render output should contain "error" + And the render output should contain "Failed" + + Scenario: render_element_for_tui renders indeterminate ProgressIndicator + When I render an indeterminate ProgressIndicator with label "Thinking" + Then the render output should contain "Thinking" + + Scenario: render_element_for_tui renders determinate ProgressIndicator + When I render a determinate ProgressIndicator with label "Loading" current 5 total 10 + Then the render output should contain "Loading" + And the render output should contain "50%" + + Scenario: render_element_for_tui renders Tree with root and children + When I render a Tree element with root "root" and child "child1" + Then the render output should contain "root" + And the render output should contain "child1" + + Scenario: render_element_for_tui renders CodeBlock with language + When I render a CodeBlock element with language "python" and content "x = 1" + Then the render output should contain "python" + And the render output should contain "x = 1" + + Scenario: render_element_for_tui renders DiffBlock with hunks + When I render a DiffBlock element with file_a "old.py" and file_b "new.py" + Then the render output should contain "old.py" + And the render output should contain "new.py" + + Scenario: render_element_for_tui renders Separator line style + When I render a Separator element with style "line" + Then the render output should contain "-" + + Scenario: render_element_for_tui renders Separator blank style + When I render a Separator element with style "blank" + Then the render output should be empty + + Scenario: render_element_for_tui renders Separator double style + When I render a Separator element with style "double" + Then the render output should contain "=" + + Scenario: render_element_for_tui renders ActionHint with commands + When I render an ActionHint element with command "agents plan list" + Then the render output should contain "agents plan list" + + Scenario: render_element_for_tui renders TextBlock content + When I render a TextBlock element with content "Hello world" + Then the render output should contain "Hello world" + + Scenario: render_element_for_tui renders TextBlock with indent + When I render a TextBlock element with content "indented" and indent 4 + Then the render output should contain " indented" + + # ── A2A event routing ───────────────────────────────────────────────────── + + Scenario: route_permission_request emits permission_request event + When I create a TuiMaterializer without a callback + And I route a permission request for "src/main.py" with type "file_write" + Then a permission_request event should be emitted + And the permission event extra should be the InlinePermissionQuestion + And the permission event rendered_text should contain "src/main.py" + + Scenario: route_thought_block emits thought_block event + When I create a TuiMaterializer without a callback + And I route a thought block with content "I am thinking" + Then a thought_block event should be emitted + And the thought event extra should be the ThoughtBlock + And the thought event rendered_text should contain "I am thinking" + + # ── Thread safety ───────────────────────────────────────────────────────── + + Scenario: TuiMaterializer events list is thread-safe + When I create a TuiMaterializer without a callback + And I use the materializer with an OutputSession + And I create multiple status handles concurrently + Then all events should be recorded without data corruption diff --git a/src/cleveragents/tui/_tui_events.py b/src/cleveragents/tui/_tui_events.py new file mode 100644 index 000000000..0f205bcd6 --- /dev/null +++ b/src/cleveragents/tui/_tui_events.py @@ -0,0 +1,92 @@ +"""TUI widget events — event type constants and event model classes. + +This module is a companion to ``materializer.py`` to keep that file +under the 500-line file limit. Re-exported publicly through +``cleveragents.tui.materializer``. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from cleveragents.cli.output.handles._models import ( + ElementSnapshot, + ) + + +# --------------------------------------------------------------------------- +# Event type string constants +# --------------------------------------------------------------------------- + + +class TuiWidgetEventType: + """String constants for TUI widget event types.""" + + ELEMENT_CREATED: str = "element_created" + ELEMENT_UPDATED: str = "element_updated" + ELEMENT_CLOSED: str = "element_closed" + SESSION_END: str = "session_end" + PERMISSION_REQUEST: str = "permission_request" + THOUGHT_BLOCK: str = "thought_block" + + +# --------------------------------------------------------------------------- +# Event class +# --------------------------------------------------------------------------- + + +class TuiWidgetEvent: + """An event emitted by the TuiMaterializer to the host application. + + The host application (e.g. ``_TextualCleverAgentsTuiApp``) subscribes + to these events to update the conversation widget incrementally. + + Attributes: + event_type: One of the ``TuiWidgetEventType`` constants. + handle_id: The handle ID of the element that triggered the event. + element_kind: The element kind (e.g. ``"panel"``, ``"table"``). + rendered_text: Plain-text rendering of the element for display. + element_snapshot: The element snapshot at the time of the event. + extra: Optional extra data (e.g. permission question, thought block). + """ + + __slots__ = ( + "element_kind", + "element_snapshot", + "event_type", + "extra", + "handle_id", + "rendered_text", + ) + + def __init__( + self, + *, + event_type: str, + handle_id: str, + element_kind: str, + rendered_text: str = "", + element_snapshot: ElementSnapshot | None = None, + extra: Any = None, + ) -> None: + self.event_type = event_type + self.handle_id = handle_id + self.element_kind = element_kind + self.rendered_text = rendered_text + self.element_snapshot = element_snapshot + self.extra = extra + + def __repr__(self) -> str: + return ( + f"TuiWidgetEvent(" + f"event_type={self.event_type!r}, " + f"handle_id={self.handle_id!r}, " + f"element_kind={self.element_kind!r})" + ) + + +__all__ = [ + "TuiWidgetEvent", + "TuiWidgetEventType", +] diff --git a/src/cleveragents/tui/_tui_renderers.py b/src/cleveragents/tui/_tui_renderers.py new file mode 100644 index 000000000..377625a6a --- /dev/null +++ b/src/cleveragents/tui/_tui_renderers.py @@ -0,0 +1,204 @@ +"""Plain-text rendering helpers for TUI display. + +This module is a companion to ``materializer.py`` to keep that file +under the 500-line file limit. Re-exported publicly through +``cleveragents.tui.materializer``. +""" + +from __future__ import annotations + +from cleveragents.cli.output.handles._models import ( + ActionHint, + CodeBlock, + DiffBlock, + ElementSnapshot, + Panel, + ProgressIndicator, + Separator, + StatusMessage, + Table, + TextBlock, + Tree, + TreeNode, +) + +# --------------------------------------------------------------------------- +# Rendering helpers +# --------------------------------------------------------------------------- + + +def _render_panel(element: Panel) -> str: + """Render a Panel element as plain text.""" + lines: list[str] = [f"-- {element.title} --"] + for entry in element.entries: + icon_prefix = f"{entry.icon} " if entry.icon else "" + lines.append(f" {icon_prefix}{entry.key}: {entry.value}") + return "\n".join(lines) + + +def _render_table(element: Table) -> str: + """Render a Table element as plain text.""" + lines: list[str] = [] + if element.title: + lines.append(f"-- {element.title} --") + if element.columns: + header = " | ".join(col.name for col in element.columns) + lines.append(header) + lines.append("-" * len(header)) + for row in element.rows: + if element.columns: + cells = [str(row.get(col.name, "")) for col in element.columns] + else: + cells = [str(v) for v in row.values()] + lines.append(" | ".join(cells)) + if element.summary: + lines.append(f"Summary: {element.summary}") + return "\n".join(lines) + + +def _render_status(element: StatusMessage) -> str: + """Render a StatusMessage element as plain text.""" + level_icons: dict[str, str] = { + "ok": "[ok]", + "info": "[info]", + "warn": "[warn]", + "error": "[error]", + } + icon = level_icons.get(element.level, "[*]") + text = f"{icon} {element.message}" + if element.detail: + text += f"\n {element.detail}" + return text + + +def _render_progress(element: ProgressIndicator) -> str: + """Render a ProgressIndicator element as plain text.""" + if element.indeterminate: + return f"[...] {element.label}" + if element.total is not None and element.total > 0 and element.current is not None: + pct = int(element.current / element.total * 100) + bar_width = 20 + filled = int(bar_width * element.current / element.total) + bar = "#" * filled + "." * (bar_width - filled) + return f"{element.label} [{bar}] {pct}%" + if element.steps: + lines = [f"{element.label}:"] + step_icons: dict[str, str] = { + "done": "[done]", + "running": "[run]", + "pending": "[wait]", + } + for step in element.steps: + status_icon = step_icons.get(step.status, "[*]") + lines.append(f" {status_icon} {step.label}") + return "\n".join(lines) + return f"[...] {element.label}" + + +def _render_tree(element: Tree) -> str: + """Render a Tree element as plain text.""" + lines: list[str] = [] + + def _render_node(node: TreeNode, prefix: str, is_last: bool) -> None: + connector = "L-- " if is_last else "+-- " + lines.append(f"{prefix}{connector}{node.label}") + child_prefix = prefix + (" " if is_last else "| ") + for i, child in enumerate(node.children): + _render_node(child, child_prefix, i == len(node.children) - 1) + + root = element.root + lines.append(root.label) + for i, child in enumerate(root.children): + _render_node(child, "", i == len(root.children) - 1) + return "\n".join(lines) + + +def _render_code(element: CodeBlock) -> str: + """Render a CodeBlock element as plain text.""" + lang_hint = element.language or "" + header = f"```{lang_hint}" + footer = "```" + return f"{header}\n{element.content}\n{footer}" + + +def _render_diff(element: DiffBlock) -> str: + """Render a DiffBlock element as plain text.""" + lines: list[str] = [] + if element.file_a or element.file_b: + lines.append(f"--- {element.file_a or '/dev/null'}") + lines.append(f"+++ {element.file_b or '/dev/null'}") + for hunk in element.hunks: + lines.append(hunk.header) + for diff_line in hunk.lines: + prefix = {"add": "+", "remove": "-", "context": " "}.get( + diff_line.line_type, " " + ) + lines.append(f"{prefix}{diff_line.content}") + return "\n".join(lines) + + +def _render_separator(element: Separator) -> str: + """Render a Separator element as plain text.""" + if element.style == "blank": + return "" + if element.style == "double": + return "=" * 40 + return "-" * 40 + + +def _render_action_hint(element: ActionHint) -> str: + """Render an ActionHint element as plain text.""" + lines: list[str] = [] + if element.description: + lines.append(element.description) + for cmd in element.commands: + lines.append(f" $ {cmd}") + return "\n".join(lines) + + +def _render_text(element: TextBlock) -> str: + """Render a TextBlock element as plain text.""" + if element.indent > 0: + indent_str = " " * element.indent + return "\n".join(indent_str + line for line in element.content.splitlines()) + return element.content + + +def render_element_for_tui(element: ElementSnapshot) -> str: + """Render any element snapshot to a plain-text string for TUI display. + + This is the TUI equivalent of ``render_element_plain`` from the CLI + output framework, adapted for Textual widget display. + + Args: + element: The element snapshot to render. + + Returns: + A plain-text string suitable for display in a Textual Static widget. + """ + if isinstance(element, Panel): + return _render_panel(element) + if isinstance(element, Table): + return _render_table(element) + if isinstance(element, StatusMessage): + return _render_status(element) + if isinstance(element, ProgressIndicator): + return _render_progress(element) + if isinstance(element, Tree): + return _render_tree(element) + if isinstance(element, CodeBlock): + return _render_code(element) + if isinstance(element, DiffBlock): + return _render_diff(element) + if isinstance(element, Separator): + return _render_separator(element) + if isinstance(element, ActionHint): + return _render_action_hint(element) + if isinstance(element, TextBlock): + return _render_text(element) + return str(element) # pragma: no cover + + +__all__ = [ + "render_element_for_tui", +] diff --git a/src/cleveragents/tui/materializer.py b/src/cleveragents/tui/materializer.py index 32be03855..6a51c157a 100644 --- a/src/cleveragents/tui/materializer.py +++ b/src/cleveragents/tui/materializer.py @@ -1,862 +1,302 @@ -"""TuiMaterializer -- bridges A2A event queue to Output Rendering Framework. +"""TuiMaterializer — A2A integration layer bridging the Output Rendering +Framework to Textual UI widgets. -The TuiMaterializer is a :class:`MaterializationStrategy` implementation that -maps ``OutputSession`` / ``ElementHandle`` events (created, updated, closed, -session-end) into live Textual widget operations for the CleverAgents TUI. +The ``TuiMaterializer`` implements the ``MaterializationStrategy`` protocol +and maps ``ElementHandle`` events to Textual widget operations, enabling all +CLI command producers to render in the TUI without modification. -It also subscribes to the :class:`A2aEventQueue` so that plan progress events -(TaskStatusUpdateEvent, TaskArtifactUpdateEvent) arrive in real time and are -routed to the appropriate TUI widgets. Producer code is completely unaware of -whether it is driving a CLI Rich terminal, a plain-text pipe, or a Textual -widget tree - it writes to handles identically in all cases. +Based on ADR-044 +TuiMaterializer — Output Rendering Framework Integration +and the M8 specification (v3.7.0). -Widget mapping per ADR-044 :: +Widget mapping (per ADR-044): - +---------------------+----------------------------------+ - | ElementHandle Type | Textual Widget | - +---------------------+----------------------------------+ - | PanelHandle | Static container + Collapsible | - | TableHandle | DataTable | - | TreeHandle | Tree | - | ProgressHandle | ProgressBar / Throbber | - | StatusHandle | Label (semantic CSS class) | - | CodeHandle | Read-only TextArea | - | DiffHandle | Custom DiffView widget | - | SeparatorHandle | Rule | - | ActionHintHandle | Static (muted commands) | - +---------------------+----------------------------------+ ++-------------------+------------------------------------------+ +| ElementHandle | Textual Widget | ++===================+==========================================+ +| PanelHandle | Static container with Collapsible | +| TableHandle | DataTable | +| TreeHandle | Tree | +| ProgressHandle | ProgressBar (det.) / Throbber (indet.) | +| StatusHandle | Label with semantic CSS class | +| CodeHandle | Read-only TextArea with syntax highlight | +| DiffHandle | DiffView (custom Static subclass) | +| SeparatorHandle | Rule | +| ActionHintHandle | Static with muted text | ++-------------------+------------------------------------------+ -Real-time A2A event subscription :: - - A2aEventQueue -> EventBusBridge -> TuiMaterializer widget routes - -The materialiser handles all ElementHandle types from day one - partial -implementation would cause silent failures when CLI commands run in a TUI -context (ADR-044 requirement). - -Based on the Output Rendering Framework specification and ADR-021 / ADR-044. +The event types and rendering helpers have been extracted to companion +modules (_tui_events.py, _tui_renderers.py) to keep this file under the +500-line file limit. Public exports are re-exported here for backwards +compatibility. """ from __future__ import annotations -import contextlib import threading -import time -from typing import Any, ClassVar +from collections.abc import Callable +from typing import TYPE_CHECKING -from cleveragents.cli.output.handles._models import ( - ActionHint, - CodeBlock, - DiffBlock, - DiffHunk, - DiffLine, - ElementClosed, - ElementCreated, - ElementUpdated, - Panel, - ProgressIndicator, - Separator, - SessionEnd, - StatusMessage, - Table, - TextBlock, - Tree, - TreeNode, +# Re-export event types and rendering helpers from companion modules +# so consumers can still do ``from cleveragents.tui.materializer import ...`` +from cleveragents.tui._tui_events import ( + TuiWidgetEvent, + TuiWidgetEventType, ) +from cleveragents.tui._tui_renderers import render_element_for_tui -try: - from textual.widgets import Static # type: ignore[import-untyped] # noqa: F401 +if TYPE_CHECKING: + from cleveragents.cli.output.handles import ( + ElementClosed, + ElementCreated, + ElementUpdated, + SessionEnd, + ) + from cleveragents.cli.output.session import OutputSession + from cleveragents.domain.models.core.inline_permission_question import ( + InlinePermissionQuestion, + ) + from cleveragents.domain.models.thought.thought_block import ThoughtBlock - _A2A_AVAILABLE = True -except ImportError: - _A2A_AVAILABLE = False +__all__ = [ + "TuiMaterializer", + "TuiWidgetEvent", + "TuiWidgetEventType", + "render_element_for_tui", +] # --------------------------------------------------------------------------- -# Reactive element mapping -- each handle kind produces a TUI widget tree +# TuiMaterializer # --------------------------------------------------------------------------- -class _TuiWidget: - """Lightweight descriptor tracking one TUI widget instance and its - associated element handle. - - The materializer maintains a dict of _TuiWidget instances keyed by - ``handle_id`` so it can dispatch incremental updates to the right widget - without reconstructing the entire tree on every event. - """ - - __slots__ = ("closed", "declared_at", "element_type", "handle_id", "widget") - - def __init__( - self, - widget: Any, - handle_id: str, - element_type: str, - declared_at: float, - ) -> None: - self.widget = widget - self.handle_id = handle_id - self.element_type = element_type - self.declared_at = declared_at - self.closed = False - - -# --------------------------------------------------------------------------- -# Reactive panel builder -- maps Panel/PanelEntry -> TUI widgets -# --------------------------------------------------------------------------- - - -def _build_panel_display(widget: Any, panel: Panel) -> None: - """Render a Panel into an existing TUI widget.""" - if not _A2A_AVAILABLE: # pragma: no cover - Textual fallback path - return - - entries_text: list[str] = [] - for entry in panel.entries: - icon_str = f"{entry.icon} " if entry.icon else "" - key_label = ( - f" {icon_str}{entry.key}: {entry.value}" - if entry.style_hint != "error" - else f" :material-check_circle: {icon_str}{entry.key}: {entry.value}" - ) - entries_text.append(key_label) - - rendered = f"[title]{panel.title}[/title]\n" + "\n".join(entries_text) - - if hasattr(widget, "label"): - widget.label = panel.title - if hasattr(widget, "_static"): - widget._static.update(rendered) - - -# --------------------------------------------------------------------------- -# Reactive table builder -- maps Table rows -> DataTable widgets -# --------------------------------------------------------------------------- - - -def _init_table_display(widget: Any, table: Table) -> None: - """Populate a DataTable widget headers and initial rows.""" - if not _A2A_AVAILABLE: # pragma: no cover - return - - for col in table.columns: - with contextlib.suppress(Exception): - widget.add_column(col.name) - - for row in table.rows: - with contextlib.suppress(TypeError): - widget.add_row(*[str(v) for v in row.values()]) - - -def _add_table_row(widget: Any, row_dict: dict[str, Any]) -> None: - """Append a single row to the DataTable.""" - if not _A2A_AVAILABLE: # pragma: no cover - return - with contextlib.suppress(TypeError): - widget.add_row(*[str(v) for v in row_dict.values()]) - - -# --------------------------------------------------------------------------- -# Reactive tree builder -- maps Tree -> textual.widgets.Tree widgets -# --------------------------------------------------------------------------- - - -def _populate_tree(widget: Any, tree: Tree) -> None: - """Add a TreeNode hierarchy to a Textual Tree widget.""" - if not _A2A_AVAILABLE: # pragma: no cover - return - - def _add_node(parent_label: str, children_list: list[TreeNode]) -> None: - if not children_list: - return - for child in children_list: - try: - node = widget.add_node( - parent_label, label=child.label, collapsed=child.collapsed - ) - _add_node(node, child.children) - except Exception: - pass - - if not hasattr(widget, "_added"): - _add_node("root", [tree.root]) - - -# --------------------------------------------------------------------------- -# Reactive progress indicator -- maps ProgressIndicator -> ProgressBar/Throbber -# --------------------------------------------------------------------------- - - -def _update_progress(widget: Any, status_msg: StatusMessage) -> None: - """Write a status message to the progress widget.""" - if not _A2A_AVAILABLE: # pragma: no cover - return - with contextlib.suppress(AttributeError): - widget.update_label(status_msg.message) - - -# --------------------------------------------------------------------------- -# Status message display -- maps StatusMessage -> Label with CSS class -# --------------------------------------------------------------------------- - - -def _display_status(widget: Any, status_message: StatusMessage) -> None: - """Render a StatusMessage as a styled label.""" - if not _A2A_AVAILABLE: # pragma: no cover - return - - widget.update(status_message.message) - - css_classes = { - "ok": "status--ok", - "info": "status--info", - "warn": "status--warn", - "error": "status--error", - } - cls = css_classes.get(status_message.level, "") - if cls: - widget.add_class(cls) - - -# --------------------------------------------------------------------------- -# Code block display -- maps CodeBlock -> read-only TextArea -# --------------------------------------------------------------------------- - - -def _render_code(widget: Any, code_block: CodeBlock) -> None: - """Set the content of a read-only TextArea.""" - if not _A2A_AVAILABLE: # pragma: no cover - return - - try: - widget.read_only = True - if hasattr(widget, "text"): - widget.text = code_block.content - elif hasattr(widget, "update"): - widget.update(code_block.content) - except Exception: - pass - - -# --------------------------------------------------------------------------- -# Diff display -- maps DiffBlock -> custom DiffView widgets -# --------------------------------------------------------------------------- - - -class _DiffViewStub: - """Stubs for the DiffView widget when Textual is not fully available.""" - - def __init__(self, file_a: str | None = None, file_b: str | None = None) -> None: - self.file_a = file_a - self.file_b = file_b - self._hunks: list[str] = [] - - def add_hunk(self, hunk: DiffHunk) -> None: - lines = [hunk.header] - for line in hunk.lines: - if line.line_type == "add": - prefix = "+" - elif line.line_type == "remove": - prefix = "-" - else: - prefix = " " - lines.append(f"{prefix} {line.content}") - self._hunks.append("\n".join(lines)) - - @property - def rendered_text(self) -> str: - return "\n\n".join(self._hunks) - - -def _render_diff(widget: Any, diff_block: DiffBlock) -> None: - """Render a DiffBlock into its Display widget.""" - if not _A2A_AVAILABLE: # pragma: no cover - return - - for hunk in diff_block.hunks: - if hasattr(widget, "add_hunk"): - widget.add_hunk(hunk) - - -# --------------------------------------------------------------------------- -# Separator display -- maps Separator -> Rule widgets -# --------------------------------------------------------------------------- - - -def _render_separator(widget: Any, sep: Separator) -> None: - """Apply the rule style.""" - if not _A2A_AVAILABLE: # pragma: no cover - return - rule_styles = {"line": "=", "blank": "", "double": "="} - char = rule_styles.get(sep.style, "-") - if hasattr(widget, "label"): - widget.label = char * 30 - - -# --------------------------------------------------------------------------- -# Action hint display -- maps ActionHint -> Static with muted commands -# --------------------------------------------------------------------------- - - -def _render_action_hint(widget: Any, hint: ActionHint) -> None: - """Render command suggestions in a muted format.""" - if not _A2A_AVAILABLE: # pragma: no cover - return - cmd_text = ", ".join(f"`{cmd}`" for cmd in hint.commands[:5]) - prefix = f"{hint.description}: " if hint.description else "" - widget.update(f"{prefix}{cmd_text}") - - -# --------------------------------------------------------------------------- -# Text block display -- maps TextBlock -> Static text -# --------------------------------------------------------------------------- - - -def _render_text(widget: Any, text_block: TextBlock) -> None: - """Set the text content.""" - if not _A2A_AVAILABLE: # pragma: no cover - return - - leading = " " * text_block.indent if text_block.indent else "" - rendered = f"{leading}{text_block.content}" - with contextlib.suppress(Exception): - widget.update(rendered) - - -# --------------------------------------------------------------------------- -# Reactive A2A event subscription helper -# --------------------------------------------------------------------------- - - -class _A2aEventSubscriber: - """Subscribes to an :class:`A2aEventQueue` and calls a callback on each - incoming :class:`A2aEvent`. - - Usage:: - - subscriber = _A2aEventSubscriber(event_queue) - subscriber.subscribe(lambda event: materializer._handle_a2a_event(event)) - """ - - def __init__(self, event_queue: Any) -> None: - self._event_queue = event_queue - self._sub_id: str | None = None - - def subscribe(self, callback: Any) -> str | None: - """Register callback with the event queue. Returns subscription ID.""" - if self._event_queue is None: - return None - self._sub_id = self._event_queue.subscribe_local(callback) - return self._sub_id - - def unsubscribe(self) -> None: - """Remove our subscription.""" - if self._event_queue is not None and self._sub_id is not None: - with contextlib.suppress(Exception): - self._event_queue.unsubscribe(self._sub_id) - self._sub_id = None - - -# ============================================================================ -# TuiMaterializer -- the main class -# ============================================================================ - - class TuiMaterializer: - """OutputRendering framework materialization strategy for the Textual TUI. + """A2A integration layer bridging the Output Rendering Framework to + Textual UI widgets. - The TuiMaterializer implements :class:`MaterializationStrategy` protocol and - bridges ``ElementCreated``, ``ElementUpdated``, ``ElementClosed``, and - ``SessionEnd`` events from an ``OutputSession`` into live Textual widget - operations. + The ``TuiMaterializer`` implements the ``MaterializationStrategy`` + protocol and maps ``ElementHandle`` events to Textual widget operations. + It enables all CLI command producers to render in the TUI without + modification, fulfilling the architectural promise of ADR-021 and + ADR-044. - It also supports direct A2A event subscription for real-time plan progress - visibility. + Usage:: - Thread Safety - ------------- - Internal state is protected by ``_lock``. The public event-handling - methods acquire the lock to prevent race conditions between concurrent - producers writing to the same handle. + def on_event(event: TuiWidgetEvent) -> None: + # Update the conversation widget + conversation.update(event.rendered_text) - Parameters - ---------- - event_queue: - An :class:`A2aEventQueue` instance for real-time A2A event - subscription. When None the materializer functions as a pure - ``MaterializationStrategy`` with no A2A integration. - callback_registry: - Optional list of callbacks invoked when session begins / ends. + materializer = TuiMaterializer(on_event=on_event) + with OutputSession(strategy=materializer) as session: + panel = session.panel("Result") + panel.set_entry("status", "ok") + + The materializer is thread-safe. All event callbacks are invoked + from the thread that triggers the element lifecycle event. + + Attributes: + strategy_name: Always ``"tui"``. + supports_incremental_updates: Always ``True`` — the TUI supports + live streaming updates. """ strategy_name: str = "tui" supports_incremental_updates: bool = True - # Mapping from element_kind strings to widget builder and updater names. - _KIND_WIDGET_MAP: ClassVar[dict[str, tuple[str, str | None]]] = { - "panel": ("_build_panel", "_update_panel"), - "table": ("_build_table", "_add_table_row"), - "tree": ("_build_tree", None), - "status": ("_build_status", None), - "progress": ("_build_progress", "_update_progress"), - "code": ("_build_code_block", "_render_code"), - "text": ("_build_text_block", None), - "diff": ("_build_diff", None), - "separator": ("_build_separator", None), - "action_hint": ("_build_action_hint", None), - } - def __init__( self, - event_queue: Any | None = None, - callback_registry: list[Any] | None = None, + *, + on_event: Callable[[TuiWidgetEvent], None] | None = None, ) -> None: - self._event_queue = event_queue - self._callbacks = callback_registry or [] - # Widget registry -- handle_id -> _TuiWidget. - self._widgets: dict[str, _TuiWidget] = {} - # Order-preserving index to handle_id mapping. - self._index_map: dict[int, str] = {} - self._next_index: int = 0 - # Thread safety lock. - self._lock = threading.Lock() - # Reactive A2A event subscriber (lazy). - self._a2a_subscriber: _A2aEventSubscriber | None = None + """Initialise the TuiMaterializer. - def on_session_begin(self, session: Any) -> None: - """Called when a new OutputSession begins. + Args: + on_event: Optional callback invoked for every ``TuiWidgetEvent``. + When ``None``, events are accumulated internally and + accessible via :attr:`events`. + """ + self._on_event = on_event + self._session: OutputSession | None = None + self._events: list[TuiWidgetEvent] = [] + self._index_map: dict[str, int] = {} + self._rendered: dict[int, str] = {} + self._lock: threading.Lock = threading.Lock() - Registers the A2A event subscriber if an event queue was provided. - Invokes all registered callbacks. + # ------------------------------------------------------------------ + # Public properties + # ------------------------------------------------------------------ + + @property + def events(self) -> list[TuiWidgetEvent]: + """Return a snapshot of all events emitted so far (thread-safe).""" + with self._lock: + return list(self._events) + + @property + def rendered_output(self) -> str: + """Return all rendered element text joined by newlines (thread-safe). + + Useful for testing and headless inspection. """ with self._lock: - if self._event_queue is not None and self._a2a_subscriber is None: - self._a2a_subscriber = _A2aEventSubscriber(self._event_queue) + parts = [text for _, text in sorted(self._rendered.items()) if text] + return "\n\n".join(parts) - def _on_a2a_event(event: Any) -> None: - """Callback invoked on each A2A event from the queue.""" - self._handle_a2a_event(event) + # ------------------------------------------------------------------ + # MaterializationStrategy protocol implementation + # ------------------------------------------------------------------ - self._a2a_subscriber.subscribe(_on_a2a_event) - - # Fire begin callbacks. - for cb in self._callbacks or []: - try: - if callable(cb): - cb("session_begin", session) - except Exception: # pylint: disable=broad-except - pass + def on_session_begin(self, session: OutputSession) -> None: + """Called when a new output session begins.""" + self._session = session def on_element_created(self, event: ElementCreated) -> None: - """Handle an element created event. + """Called when a new element handle is created. - Creates the corresponding Textual widget and registers it in the - widget dict keyed by handle_id. + Renders the initial element state and emits a ``TuiWidgetEvent`` + with ``event_type="element_created"``. """ + rendered = "" + if event.initial_state is not None: + rendered = render_element_for_tui(event.initial_state) + with self._lock: - kind = event.element_kind - handle_id = event.handle_id + self._index_map[event.handle_id] = event.declaration_index + self._rendered[event.declaration_index] = rendered - idx = getattr(event, "declaration_index", 0) - self._index_map[idx] = handle_id - self._next_index += 1 - - widget = self._make_widget(kind, event.initial_state) - tui_widget = _TuiWidget( - widget=widget, - handle_id=handle_id, - element_type=kind, - declared_at=time.monotonic(), - ) - self._widgets[handle_id] = tui_widget - - # Fire element-created callbacks. - for cb in self._callbacks or []: - try: - if callable(cb): - cb("element_created", event) - except Exception: # pylint: disable=broad-except - pass + tui_event = TuiWidgetEvent( + event_type=TuiWidgetEventType.ELEMENT_CREATED, + handle_id=event.handle_id, + element_kind=event.element_kind, + rendered_text=rendered, + element_snapshot=event.initial_state, + ) + self._emit(tui_event) def on_element_updated(self, event: ElementUpdated) -> None: - """Handle an incremental element update. + """Called when data is written to an element handle. - Looks up the existing widget by handle_id and applies the update - using a kind-specific updater function. + Re-renders the element and emits a ``TuiWidgetEvent`` with + ``event_type="element_updated"``. """ + rendered = "" + if event.element_snapshot is not None: + rendered = render_element_for_tui(event.element_snapshot) + with self._lock: - widget_tui = self._widgets.get(event.handle_id) - if widget_tui is None or widget_tui.closed: - return + idx = self._index_map.get(event.handle_id) + if idx is not None: + self._rendered[idx] = rendered - kind = event.element_kind - snapshot = getattr(event, "element_snapshot", None) - if snapshot is None: - return - - self._apply_update(widget_tui.widget, kind, snapshot) + tui_event = TuiWidgetEvent( + event_type=TuiWidgetEventType.ELEMENT_UPDATED, + handle_id=event.handle_id, + element_kind=event.element_kind, + rendered_text=rendered, + element_snapshot=event.element_snapshot, + ) + self._emit(tui_event) def on_element_closed(self, event: ElementClosed) -> None: - """Handle element closing. + """Called when an element handle is closed (finalised). - Marks the associated widget as closed and fires a close callback. - The widget is retained in the registry for post-close inspection. + Renders the final element state and emits a ``TuiWidgetEvent`` + with ``event_type="element_closed"``. """ + rendered = "" + if event.final_state is not None: + rendered = render_element_for_tui(event.final_state) + with self._lock: - widget_tui = self._widgets.get(event.handle_id) - if widget_tui is None: - return + idx = self._index_map.get(event.handle_id) + if idx is not None: + self._rendered[idx] = rendered - widget_tui.closed = True - - # Fire close callbacks. - for cb in self._callbacks or []: - try: - if callable(cb): - cb("element_closed", event) - except Exception: # pylint: disable=broad-except - pass + tui_event = TuiWidgetEvent( + event_type=TuiWidgetEventType.ELEMENT_CLOSED, + handle_id=event.handle_id, + element_kind=event.element_kind, + rendered_text=rendered, + element_snapshot=event.final_state, + ) + self._emit(tui_event) def on_session_end(self, event: SessionEnd) -> None: - """Handle session end. + """Called when the output session ends. - Unsubscribes from A2A events and invokes all session-end callbacks. + Emits a ``TuiWidgetEvent`` with ``event_type="session_end"``. """ - with self._lock: - if self._a2a_subscriber is not None: - self._a2a_subscriber.unsubscribe() - self._a2a_subscriber = None - - for cb in self._callbacks or []: - try: - if callable(cb): - cb("session_end", event) - except Exception: # pylint: disable=broad-except - pass + tui_event = TuiWidgetEvent( + event_type=TuiWidgetEventType.SESSION_END, + handle_id=event.handle_id, + element_kind=event.element_kind, + rendered_text=self.rendered_output, + ) + self._emit(tui_event) # ------------------------------------------------------------------ - # Internal widget factory and updater dispatch + # A2A event routing # ------------------------------------------------------------------ - def _make_widget(self, kind: str, element: Any = None) -> Any: - """Create a new Textual widget for the given element kind.""" - factory_name = self._KIND_WIDGET_MAP.get(kind, ("", ""))[0] - builder = getattr(self, factory_name, None) - if builder is not None and element is not None: - return builder(element) - return type("TuiWidget", (), {"_kind": kind, "label": ""}) + def route_permission_request( + self, question: InlinePermissionQuestion + ) -> TuiWidgetEvent: + """Route a permission request event to the TUI. - # -- Element kind-specific builders --- + Creates a ``TuiWidgetEvent`` with ``event_type="permission_request"`` + and the ``InlinePermissionQuestion`` in the ``extra`` field. The + host application should mount a ``PermissionQuestionWidget`` (for + single-file requests) or push the ``PermissionsScreen`` (for + multi-file requests). - def _build_panel(self, panel: Panel) -> Any: - try: - from textual.widgets import ( # type: ignore[import-untyped] - Static as TwStatic, - ) + Args: + question: The permission question to route. - widget = TwStatic(panel.title) - _build_panel_display(widget, panel) - return widget - except ImportError: # pragma: no cover - return type("FallbackPanel", (), {"label": panel.title}) - - def _build_table(self, table: Table) -> Any: - try: - from textual.widgets import DataTable # type: ignore[import-untyped] - - widget = DataTable(title=table.title or "") - _init_table_display(widget, table) - return widget - except ImportError: # pragma: no cover - return type("FallbackTable", (), {"title": str(table.title)}) - - def _build_tree(self, tree: Tree) -> Any: - try: - from textual.widgets import Tree as TwTree # type: ignore[import-untyped] - - widget = TwTree(f"[b]{tree.root.label}[/b]") - _populate_tree(widget, tree) - return widget - except ImportError: # pylint: disable=broad-exception-caught - return type("FallbackTree", (), {"root_label": tree.root.label}) - - def _build_status(self, status_msg: StatusMessage) -> Any: - try: - from textual.widgets import Label as TwLabel # type: ignore[import-untyped] - - widget = TwLabel(status_msg.message) - if status_msg.level == "error": - widget.add_class("tui-status--red") - elif status_msg.level == "warn": - widget.add_class("tui-status--yellow") - elif status_msg.level == "ok": - widget.add_class("tui-status--green") - else: - widget.add_class("tui-status--info") - return widget - except ImportError: # pragma: no cover - return type("FallbackStatus", (), {"message": status_msg.message}) - - def _build_progress(self, progress: ProgressIndicator) -> Any: - try: - from textual.widgets import ( # type: ignore[import-untyped] - ProgressBar as TwProgressBar, - ) - from textual.widgets import ( # type: ignore[import-untyped] - Throbber as TwThrobber, - ) - - if progress.indeterminate or progress.total is None: - widget = TwThrobber(start=False) - else: - widget = TwProgressBar(total=progress.total, finished="idle") - return widget - except ImportError: # pragma: no cover - return type("FallbackProgress", (), {"label": progress.label}) - - def _build_code_block(self, code: CodeBlock) -> Any: - try: - from textual.widgets import ( # type: ignore[import-untyped] - TextArea as TwTextArea, - ) - - widget = TwTextArea(code.content, read_only=True) - return widget - except ImportError: # pragma: no cover - return type("FallbackCode", (), {"_text": code.content}) - - def _build_text_block(self, text: TextBlock) -> Any: - try: - from textual.widgets import ( # type: ignore[import-untyped] - Static as TwStatic, - ) - - widget = TwStatic(text.content) - return widget - except ImportError: # pragma: no cover - return type("FallbackText", (), {"_text": text.content}) - - def _build_diff(self, diff: DiffBlock) -> Any: - try: - widget = _DiffViewStub(diff.file_a, diff.file_b) - for hunk in diff.hunks: - widget.add_hunk(hunk) - return widget - except ImportError: # pragma: no cover - return _DiffViewStub(diff.file_a, diff.file_b) - - def _build_separator(self, sep: Separator) -> Any: - try: - from textual.widgets import Rule as TwRule # type: ignore[import-untyped] - - widget = TwRule() - return widget - except ImportError: # pragma: no cover - return type("FbSep", (), {"_text": "\u2500" * 30}) - - def _build_action_hint(self, hint: ActionHint) -> Any: - try: - from textual.widgets import ( # type: ignore[import-untyped] - Static as TwStatic, - ) - - widget = TwStatic(f"[em]{', '.join(hint.commands)}[/]") - return widget - except ImportError: # pragma: no cover - return type("FbHint", (), {}) - - def _apply_update(self, widget: Any, kind: str, snapshot: Any) -> None: - """Dispatch an incremental update to the correct renderer function.""" - - updater_map: dict[str, Any] = { - "panel": lambda w, s: ( - _build_panel_display(w, s) if isinstance(s, Panel) else None - ), - "table": self._apply_table_update, - "tree": self._apply_tree_update, - "status": _display_status, - "progress": self._apply_progress_update, - "code": _render_code, - "text": _render_text, - "diff": _render_diff, - "separator": _render_separator, - "action_hint": _render_action_hint, - } - - fn = updater_map.get(kind) - if fn is not None: - with contextlib.suppress(Exception): - fn(widget, snapshot) # type: ignore[arg-type] - - def _apply_panel_update(self, widget: Any, snapshot: Any) -> None: - if isinstance(snapshot, Panel): - _build_panel_display(widget, snapshot) - - def _apply_table_update(self, widget: Any, snapshot: Any) -> None: - if isinstance(snapshot, Table): - for row in snapshot.rows: - _add_table_row(widget, row) - - def _apply_tree_update(self, widget: Any, snapshot: Any) -> None: - if isinstance(snapshot, Tree) and snapshot.root.children: - _populate_tree(widget, snapshot) - - def _apply_progress_update(self, widget: Any, snapshot: Any) -> None: - if isinstance(snapshot, StatusMessage): - _update_progress(widget, snapshot) - - # ------------------------------------------------------------------ - # A2A event handling -- real-time plan progress to TUI widgets - # ------------------------------------------------------------------ - - def _handle_a2a_event(self, event: Any) -> None: - """Route an incoming A2A event to the appropriate TUI widget. - - This method is invoked as a callback on every A2aEvent published to - the subscribed A2aEventQueue. + Returns: + The emitted ``TuiWidgetEvent``. """ - with self._lock: - event_type = getattr(event, "event_type", "") - data = getattr(event, "data", None) or {} + tui_event = TuiWidgetEvent( + event_type=TuiWidgetEventType.PERMISSION_REQUEST, + handle_id="", + element_kind="permission_request", + rendered_text=f"Permission required: {question.file_path}", + extra=question, + ) + self._emit(tui_event) + return tui_event - if event_type == "TaskStatusUpdateEvent": - self._render_status_event(data) - elif event_type == "TaskArtifactUpdateEvent": - self._render_artifact_event(data) + def route_thought_block(self, thought: ThoughtBlock) -> TuiWidgetEvent: + """Route a thought block event to the TUI. - def _render_status_event(self, data: dict[str, Any]) -> None: - """Render a status update event into the TUI's conversation widget.""" - phase = str(data.get("phase", "") or "") - state = str(data.get("state", "") or "") - plan_id = str(data.get("plan_id", "") or "") + Creates a ``TuiWidgetEvent`` with ``event_type="thought_block"`` + and the ``ThoughtBlock`` in the ``extra`` field. The host + application should mount a ``ThoughtBlockWidget`` in the + conversation stream. - clean_plan = plan_id.replace("-", "") - for handle_id, tui_widget in self._widgets.items(): - if tui_widget.element_type == "progress" and ( - not clean_plan or clean_plan in handle_id.replace("-", "") - ): - try: - sm = StatusMessage(message=f"Phase: phase={phase}, state={state}") - _update_progress(tui_widget.widget, sm) - except Exception: # pylint: disable=broad-except - pass + Args: + thought: The thought block to route. - def _render_artifact_event(self, data: dict[str, Any]) -> None: - """Render an artifact update event (code, diffs, status).""" - kind = str(data.get("artifact_type", "") or "") - artifact_data = data.get("data", data) - - if kind == "code" or data.get("type") == "code": - content = str(artifact_data.get("content", "")) - for _handle_id, tui_widget in self._widgets.items(): - if tui_widget.element_type == "code": - try: - widget = tui_widget.widget - if hasattr(widget, "text"): - widget.text = content - except Exception: # pylint: disable=broad-except - pass - - elif kind == "diff" or data.get("type") == "diff": - hunks = artifact_data.get("hunks", []) - for _handle_id, tui_widget in self._widgets.items(): - if tui_widget.element_type == "diff": - try: - widget = tui_widget.widget - for hunk in hunks: - if hasattr(widget, "add_hunk"): - dh = DiffHunk(header=str(hunk.get("header", ""))) - for ln in hunk.get("lines", []): - dh.lines.append( - DiffLine( # type: ignore[call-arg] - type=ln.get("type", "context"), - content=ln.get("content", ""), - ) - ) - widget.add_hunk(dh) - except Exception: # pylint: disable=broad-except - pass - - elif kind == "status" or data.get("type") == "status": - status_msg = StatusMessage( - message=str(artifact_data.get("message", "")), - level=str(artifact_data.get("level", "info")), - detail=str(artifact_data.get("detail", "")) or None, - ) - for _handle_id, tui_widget in self._widgets.items(): - if tui_widget.element_type == "status": - with contextlib.suppress(Exception): - _display_status(tui_widget.widget, status_msg) - - def get_widget_registry(self) -> dict[str, _TuiWidget]: - """Return a copy of the widget registry for inspection.""" - with self._lock: - return dict(self._widgets) - - def clear(self) -> None: - """Clear all registered widgets and flush state. - - Used when the TUI switches sessions or closes a plan. + Returns: + The emitted ``TuiWidgetEvent``. """ + tui_event = TuiWidgetEvent( + event_type=TuiWidgetEventType.THOUGHT_BLOCK, + handle_id="", + element_kind="thought_block", + # Runtime type is guaranteed by the caller to be ThoughtBlock. + rendered_text=thought.rendered_text(), + extra=thought, + ) + self._emit(tui_event) + return tui_event + + # ------------------------------------------------------------------ + # Internal helpers + # ------------------------------------------------------------------ + + def _emit(self, event: TuiWidgetEvent) -> None: + """Emit a TuiWidgetEvent to the callback and accumulate it.""" with self._lock: - if self._a2a_subscriber is not None: - self._a2a_subscriber.unsubscribe() - self._a2a_subscriber = None - - self._widgets.clear() - self._index_map.clear() - self._next_index = 0 - - -# ============================================================================ -# Convenience factory -# ============================================================================ - - -def create_tui_materializer( - event_queue: Any | None = None, - callbacks: list[Any] | None = None, -) -> TuiMaterializer: - """Factory function to create a TuiMaterializer with standard wiring. - - Parameters - ---------- - event_queue: - The shared ``A2aEventQueue`` for A2A protocol events. When None the - materializer still works as a pure MaterializationStrategy. - callbacks: - Optional callback list for session-level hooks. - - Returns - ------- - TuiMaterializer - An initialized materializer ready to receive OutputSession events. - """ - return TuiMaterializer( - event_queue=event_queue, - callback_registry=callbacks, - ) - - -# ============================================================================ -# Public exports -# ============================================================================ - - -__all__ = [ - "TuiMaterializer", - "_A2aEventSubscriber", - "_DiffViewStub", - "_add_table_row", - "_build_panel_display", - "_display_status", - "_init_table_display", - "_populate_tree", - "_render_action_hint", - "_render_code", - "_render_diff", - "_render_separator", - "_render_text", - "create_tui_materializer", -] + self._events.append(event) + if self._on_event is not None: + self._on_event(event) diff --git a/vulture_whitelist.py b/vulture_whitelist.py index d6cc15bbd..04b29c54b 100644 --- a/vulture_whitelist.py +++ b/vulture_whitelist.py @@ -261,6 +261,15 @@ ColumnDef # noqa: B018, F821 StatusMessage # noqa: B018, F821 ProgressIndicator # noqa: B018, F821 ProgressStep # noqa: B018, F821 +TreeNode # noqa: B018, F821 +Tree # noqa: B018, F821 +TextBlock # noqa: B018, F821 +CodeBlock # noqa: B018, F821 +DiffLine # noqa: B018, F821 +DiffHunk # noqa: B018, F821 +DiffBlock # noqa: B018, F821 +Separator # noqa: B018, F821 +ActionHint # noqa: B018, F821 LiveMaterializationStrategy # noqa: B018, F821 MaterializationStrategy # noqa: B018, F821 RichMaterializer # noqa: B018, F821 @@ -292,6 +301,19 @@ _snapshot_to_dict # noqa: B018, F821 _create_strategy # noqa: B018, F821 VALID_FORMATS # noqa: B018, F821 +# TUI materializer — bridges Output Rendering Framework to Textual widgets +TuiMaterializer # noqa: B018, F821 +TuiWidgetEvent # noqa: B018, F821 +TuiWidgetEventType # noqa: B018, F821 +render_element_for_tui # noqa: B018, F821 + +# TUI companion modules (split to keep materializer.py < 500 lines) +_tui_events # noqa: B018, F821 +_tui_renderers # noqa: B018, F821 + +# TUI thought block widget — muted expandable actor reasoning trace +ThoughtBlockWidget # noqa: B018, F821 + # Plan apply service — public API for diff/artifacts/apply integration (D0b.apply) PlanApplyService # noqa: B018, F821 _render_diff_plain # noqa: B018, F821