From 4e5e0624fd58bec680d545be3ea19f89b9cb2340 Mon Sep 17 00:00:00 2001 From: Jeffrey Phillips Freeman Date: Sun, 29 Mar 2026 06:42:52 +0000 Subject: [PATCH] feat(cli): implement RendererRegistry and ElementRenderer architecture per spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement the three missing architectural components of the Output Rendering Framework as specified in issue #917: 1. RendererRegistry (spec §27249-27350): Central registry for format (MaterializationStrategy, ElementRenderer) pairs with register(), resolve(), available_formats(), is_registered() methods and a FormatRegistration model. Built-in formats pre-registered in default_registry. Replaces hardcoded if/elif chains for format resolution. 2. ElementRenderer Protocol (spec §26557-26654): Per-element render methods (render_panel, render_table, render_tree, etc.) plus serialize() and can_render(). Six concrete implementations: PlainElementRenderer, ColorElementRenderer, TableElementRenderer, RichElementRenderer, JsonElementRenderer, YamlElementRenderer. Each format now has a paired (Strategy, Renderer). 3. TerminalCapabilities (spec §27264-27301): Extended from 4 fields to all 11 spec-defined fields: width, height, supports_256_color, supports_truecolor, supports_unicode, supports_alternate_screen, no_color, plus renames supports_cursor → supports_cursor_movement, term → term_program. Backward-compatible properties preserved. Additional fixes: - ColumnDef serialises column type as 'type' (not 'col_type') per spec §26199, with alias for backward compatibility - YAML output uses sort_keys=True per spec §27168 - Progress elements omitted from JSON/YAML per spec §26936 - MaterializationStrategy.bind(renderer, terminal_caps) method added to protocol and all strategy implementations (SD-19 resolved) - Updated SD documentation in __init__.py ISSUES CLOSED: #917 --- features/output_rendering_registry.feature | 156 ++++ .../steps/output_rendering_registry_steps.py | 416 +++++++++++ robot/output_rendering.robot | 8 +- src/cleveragents/cli/output/__init__.py | 56 +- .../cli/output/handles/_models.py | 14 +- src/cleveragents/cli/output/materializers.py | 62 +- src/cleveragents/cli/output/registry.py | 678 ++++++++++++++++++ src/cleveragents/cli/output/selection.py | 102 ++- 8 files changed, 1453 insertions(+), 39 deletions(-) create mode 100644 features/output_rendering_registry.feature create mode 100644 features/steps/output_rendering_registry_steps.py create mode 100644 src/cleveragents/cli/output/registry.py diff --git a/features/output_rendering_registry.feature b/features/output_rendering_registry.feature new file mode 100644 index 000000000..684b06e1b --- /dev/null +++ b/features/output_rendering_registry.feature @@ -0,0 +1,156 @@ +Feature: Output Rendering Framework — Registry, Renderer, and Capabilities + + # ================================================================ + # RendererRegistry + # ================================================================ + + Scenario: RendererRegistry registers and resolves built-in formats + Given the default renderer registry + Then the registry has format "plain" registered + And the registry has format "color" registered + And the registry has format "table" registered + And the registry has format "rich" registered + And the registry has format "json" registered + And the registry has format "yaml" registered + + Scenario: RendererRegistry available_formats returns sorted list + Given the default renderer registry + Then the available formats are "color,json,plain,rich,table,yaml" + + Scenario: RendererRegistry is_registered returns False for unknown + Given the default renderer registry + Then the registry does not have format "csv" registered + + Scenario: RendererRegistry resolves plain format + Given the default renderer registry + When I resolve format "plain" from the registry + Then the resolved renderer format name is "plain" + + Scenario: RendererRegistry resolves json format + Given the default renderer registry + When I resolve format "json" from the registry + Then the resolved renderer format name is "json" + + Scenario: RendererRegistry resolve falls back from rich to plain on non-TTY + Given the default renderer registry + And a non-TTY terminal environment + When I resolve format "rich" from the registry with terminal caps + Then the resolved renderer format name is "plain" + + Scenario: RendererRegistry supports custom format registration + Given a new empty renderer registry + When I register a custom "csv" format in the registry + Then the registry has format "csv" registered + + # ================================================================ + # ElementRenderer protocol + # ================================================================ + + Scenario: PlainElementRenderer renders panel element + Given a PlainElementRenderer + When I render a panel with title "Test" through the renderer + Then the renderer output contains "Test" + + Scenario: PlainElementRenderer can_render always returns True + Given a PlainElementRenderer + Then the renderer can_render returns True for any terminal + + Scenario: ColorElementRenderer can_render requires ANSI + Given a ColorElementRenderer + Then the renderer can_render returns False without ANSI + + Scenario: RichElementRenderer can_render requires cursor movement + Given a RichElementRenderer + Then the renderer can_render returns False without cursor movement + + Scenario: JsonElementRenderer serializes output + Given a JsonElementRenderer + When I serialize a StructuredOutput through the renderer + Then the serialized output is valid JSON + + Scenario: YamlElementRenderer serializes output with sorted keys + Given a YamlElementRenderer + When I serialize a StructuredOutput through the renderer + Then the serialized output is valid YAML + And the serialized output has sorted keys + + # ================================================================ + # TerminalCapabilities extended fields + # ================================================================ + + Scenario: TerminalCapabilities has all 11 spec fields + When I create a full TerminalCapabilities + Then the capabilities has width 120 + And the capabilities has height 40 + And the capabilities has supports_256_color True + And the capabilities has supports_truecolor True + And the capabilities has supports_unicode True + And the capabilities has supports_alternate_screen True + And the capabilities has no_color False + And the capabilities has term_program "iTerm2" + + Scenario: TerminalCapabilities backward-compatible aliases work + When I create a full TerminalCapabilities + Then the capabilities supports_cursor property matches supports_cursor_movement + And the capabilities term property matches term_program + + # ================================================================ + # ColumnDef.type serialisation + # ================================================================ + + Scenario: ColumnDef serialises type field as "type" in JSON + Given an OutputSession with format "json" using JsonMaterializer + When I create a table with ColumnDef objects "Name,Status" + And I add a dict row with Name "Name" and Status "Alice" + And I close the table handle + And the session is closed + Then the json output is valid JSON + And the json output ColumnDef uses type key not col_type + + Scenario: ColumnDef accepts type alias in constructor + When I create a ColumnDef with type alias "integer" + Then the ColumnDef col_type is "integer" + + # ================================================================ + # YAML sorted keys + # ================================================================ + + Scenario: YAML output uses sorted keys + Given an OutputSession with format "yaml" using YamlMaterializer + When I create a panel handle with title "Sort Test" + And I set entry "Zebra" to "last" + And I set entry "Alpha" to "first" + And I close the panel handle + And the session is closed + Then the yaml output is valid YAML + + # ================================================================ + # Progress omitted from JSON output + # ================================================================ + + Scenario: JSON output omits progress elements + Given an OutputSession with format "json" using JsonMaterializer + When I create a progress handle labelled "Building" with total 100 + And I set progress to 50 of 100 + And I close the progress handle + And I create a panel handle with title "Info" + And I set entry "Name" to "test" + And I close the panel handle + And the session is closed + Then the json output is valid JSON + And the json output does not contain element type "progress" + And the json output contains element type "panel" + + # ================================================================ + # MaterializationStrategy.bind() method + # ================================================================ + + Scenario: PlainMaterializer has bind method + Given a PlainMaterializer instance + When I call bind on the materializer + Then no error is raised + + Scenario: JsonMaterializer has bind method + Given a JsonMaterializer instance + When I call bind on the json materializer + Then no error is raised diff --git a/features/steps/output_rendering_registry_steps.py b/features/steps/output_rendering_registry_steps.py new file mode 100644 index 000000000..3ebd9254e --- /dev/null +++ b/features/steps/output_rendering_registry_steps.py @@ -0,0 +1,416 @@ +"""Behave step definitions for the RendererRegistry / ElementRenderer +architecture (PR #1193). + +Step patterns here are additive — they do not collide with the existing +step definitions in ``output_rendering_steps.py``. Two step functions +that read the JSON envelope use the master HEAD schema key ``"data"`` +(post-M4 rename) rather than the legacy key ``"elements"``. +""" + +from __future__ import annotations + +from behave import given, then, when +from behave.runner import Context + +from cleveragents.cli.output.handles import ColumnDef +from cleveragents.cli.output.materializers import ( + JsonMaterializer, + PlainMaterializer, +) +from cleveragents.cli.output.selection import TerminalCapabilities + + +# =========================================================================== +# RendererRegistry tests +# =========================================================================== + + +@given("the default renderer registry") +def step_default_registry(context: Context) -> None: + from cleveragents.cli.output.registry import default_registry + + context.registry = default_registry + + +@then('the registry has format "{fmt}" registered') +def step_registry_has_format(context: Context, fmt: str) -> None: + assert context.registry.is_registered(fmt), f"Format {fmt!r} not registered" + + +@then('the registry does not have format "{fmt}" registered') +def step_registry_no_format(context: Context, fmt: str) -> None: + assert not context.registry.is_registered(fmt), f"Format {fmt!r} is registered" + + +@then('the available formats are "{expected}"') +def step_available_formats(context: Context, expected: str) -> None: + expected_list = sorted(expected.split(",")) + actual = context.registry.available_formats() + assert actual == expected_list, f"Expected {expected_list}, got {actual}" + + +@when('I resolve format "{fmt}" from the registry') +def step_resolve_format(context: Context, fmt: str) -> None: + caps = TerminalCapabilities( + is_tty=True, + supports_ansi=True, + supports_cursor_movement=True, + term_program="xterm-256color", + ) + strategy, renderer = context.registry.resolve(fmt, caps) + context.resolved_strategy = strategy + context.resolved_renderer = renderer + + +@when('I resolve format "{fmt}" from the registry with terminal caps') +def step_resolve_format_with_caps(context: Context, fmt: str) -> None: + caps = getattr(context, "terminal_caps", None) + if caps is None: + caps = TerminalCapabilities( + is_tty=True, + supports_ansi=True, + supports_cursor_movement=True, + term_program="xterm-256color", + ) + strategy, renderer = context.registry.resolve(fmt, caps) + context.resolved_strategy = strategy + context.resolved_renderer = renderer + + +@then('the resolved renderer format name is "{expected}"') +def step_resolved_renderer_name(context: Context, expected: str) -> None: + assert context.resolved_renderer.format_name == expected, ( + f"Expected {expected!r}, got {context.resolved_renderer.format_name!r}" + ) + + +@given("a new empty renderer registry") +def step_empty_registry(context: Context) -> None: + from cleveragents.cli.output.registry import RendererRegistry + + context.registry = RendererRegistry() + + +@when('I register a custom "{fmt}" format in the registry') +def step_register_custom_format(context: Context, fmt: str) -> None: + from cleveragents.cli.output.registry import PlainElementRenderer + + context.registry.register( + fmt, + strategy_factory=lambda caps: PlainMaterializer(), + renderer_factory=lambda caps: PlainElementRenderer(), + fallback="plain", + ) + + +# =========================================================================== +# ElementRenderer tests +# =========================================================================== + + +@given("a PlainElementRenderer") +def step_plain_renderer(context: Context) -> None: + from cleveragents.cli.output.registry import PlainElementRenderer + + context.element_renderer = PlainElementRenderer() + + +@given("a ColorElementRenderer") +def step_color_renderer(context: Context) -> None: + from cleveragents.cli.output.registry import ColorElementRenderer + + context.element_renderer = ColorElementRenderer() + + +@given("a RichElementRenderer") +def step_rich_renderer(context: Context) -> None: + from cleveragents.cli.output.registry import RichElementRenderer + + context.element_renderer = RichElementRenderer() + + +@given("a JsonElementRenderer") +def step_json_renderer(context: Context) -> None: + from cleveragents.cli.output.registry import JsonElementRenderer + + context.element_renderer = JsonElementRenderer() + + +@given("a YamlElementRenderer") +def step_yaml_renderer(context: Context) -> None: + from cleveragents.cli.output.registry import YamlElementRenderer + + context.element_renderer = YamlElementRenderer() + + +@when('I render a panel with title "{title}" through the renderer') +def step_render_panel_via_renderer(context: Context, title: str) -> None: + from cleveragents.cli.output.handles import Panel + + panel = Panel(title=title) + context.renderer_output = context.element_renderer.render_panel(panel) + + +@then('the renderer output contains "{text}"') +def step_renderer_output_contains(context: Context, text: str) -> None: + assert text in context.renderer_output, ( + f"Expected {text!r} in renderer output, got: {context.renderer_output!r}" + ) + + +@then("the renderer can_render returns True for any terminal") +def step_renderer_can_render_true(context: Context) -> None: + caps = TerminalCapabilities( + is_tty=False, + supports_ansi=False, + supports_cursor_movement=False, + term_program="", + ) + assert context.element_renderer.can_render(caps) is True + + +@then("the renderer can_render returns False without ANSI") +def step_renderer_can_render_no_ansi(context: Context) -> None: + caps = TerminalCapabilities( + is_tty=True, + supports_ansi=False, + supports_cursor_movement=False, + term_program="dumb", + ) + assert context.element_renderer.can_render(caps) is False + + +@then("the renderer can_render returns False without cursor movement") +def step_renderer_can_render_no_cursor(context: Context) -> None: + caps = TerminalCapabilities( + is_tty=True, + supports_ansi=True, + supports_cursor_movement=False, + term_program="ansi", + ) + assert context.element_renderer.can_render(caps) is False + + +@when("I serialize a StructuredOutput through the renderer") +def step_serialize_via_renderer(context: Context) -> None: + from cleveragents.cli.output.session import StructuredOutput + + output = StructuredOutput( + command="test", + session_id="ses-test", + elements=[], + exit_code=0, + ) + context.serialized_output = context.element_renderer.serialize(output) + + +@then("the serialized output is valid JSON") +def step_serialized_json(context: Context) -> None: + import json + + json.loads(context.serialized_output) + + +@then("the serialized output is valid YAML") +def step_serialized_yaml(context: Context) -> None: + import yaml + + yaml.safe_load(context.serialized_output) + + +@then("the serialized output has sorted keys") +def step_serialized_sorted_keys(context: Context) -> None: + import yaml + + data = yaml.safe_load(context.serialized_output) + if isinstance(data, dict): + keys = list(data.keys()) + assert keys == sorted(keys), f"Keys not sorted: {keys}" + + +# =========================================================================== +# TerminalCapabilities extended fields +# =========================================================================== + + +@when("I create a full TerminalCapabilities") +def step_full_caps(context: Context) -> None: + context.full_caps = TerminalCapabilities( + is_tty=True, + width=120, + height=40, + supports_ansi=True, + supports_256_color=True, + supports_truecolor=True, + supports_unicode=True, + supports_cursor_movement=True, + supports_alternate_screen=True, + no_color=False, + term_program="iTerm2", + ) + + +@then("the capabilities has width {value:d}") +def step_caps_width(context: Context, value: int) -> None: + assert context.full_caps.width == value + + +@then("the capabilities has height {value:d}") +def step_caps_height(context: Context, value: int) -> None: + assert context.full_caps.height == value + + +@then("the capabilities has supports_256_color {value}") +def step_caps_256color(context: Context, value: str) -> None: + expected = value == "True" + assert context.full_caps.supports_256_color == expected + + +@then("the capabilities has supports_truecolor {value}") +def step_caps_truecolor(context: Context, value: str) -> None: + expected = value == "True" + assert context.full_caps.supports_truecolor == expected + + +@then("the capabilities has supports_unicode {value}") +def step_caps_unicode(context: Context, value: str) -> None: + expected = value == "True" + assert context.full_caps.supports_unicode == expected + + +@then("the capabilities has supports_alternate_screen {value}") +def step_caps_alt_screen(context: Context, value: str) -> None: + expected = value == "True" + assert context.full_caps.supports_alternate_screen == expected + + +@then("the capabilities has no_color {value}") +def step_caps_no_color(context: Context, value: str) -> None: + expected = value == "True" + assert context.full_caps.no_color == expected + + +@then('the capabilities has term_program "{value}"') +def step_caps_term_program(context: Context, value: str) -> None: + assert context.full_caps.term_program == value + + +@then("the capabilities supports_cursor property matches supports_cursor_movement") +def step_caps_cursor_alias(context: Context) -> None: + assert ( + context.full_caps.supports_cursor == context.full_caps.supports_cursor_movement + ) + + +@then("the capabilities term property matches term_program") +def step_caps_term_alias(context: Context) -> None: + assert context.full_caps.term == (context.full_caps.term_program or "") + + +# =========================================================================== +# ColumnDef.type alias +# =========================================================================== + + +@when('I create a ColumnDef with type alias "{type_val}"') +def step_create_coldef_type_alias(context: Context, type_val: str) -> None: + context.coldef = ColumnDef(type=type_val, name="test") + + +@then('the ColumnDef col_type is "{expected}"') +def step_coldef_col_type(context: Context, expected: str) -> None: + assert context.coldef.col_type == expected + + +# =========================================================================== +# Progress omitted from JSON — reads master HEAD's "data" envelope key +# =========================================================================== + + +@then('the json output does not contain element type "{etype}"') +def step_json_no_element_type(context: Context, etype: str) -> None: + import json + + output = context.strategy.get_output() + data = json.loads(output) + for elem in data.get("data", []): + assert elem.get("type") != etype, f"Found element type {etype!r} in JSON output" + + +# =========================================================================== +# MaterializationStrategy.bind() +# =========================================================================== + + +@given("a PlainMaterializer instance") +def step_plain_mat_instance(context: Context) -> None: + context.bind_mat = PlainMaterializer() + + +@given("a JsonMaterializer instance") +def step_json_mat_instance(context: Context) -> None: + context.bind_mat = JsonMaterializer() + + +@when("I call bind on the materializer") +def step_call_bind(context: Context) -> None: + from cleveragents.cli.output.registry import PlainElementRenderer + + caps = TerminalCapabilities( + is_tty=True, + supports_ansi=True, + supports_cursor_movement=True, + term_program="xterm", + ) + context.bind_mat.bind(PlainElementRenderer(), terminal_caps=caps) + context.bind_error = None + + +@when("I call bind on the json materializer") +def step_call_bind_json(context: Context) -> None: + from cleveragents.cli.output.registry import JsonElementRenderer + + caps = TerminalCapabilities( + is_tty=True, + supports_ansi=True, + supports_cursor_movement=True, + term_program="xterm", + ) + context.bind_mat.bind(JsonElementRenderer(), terminal_caps=caps) + context.bind_error = None + + +@then("no error is raised") +def step_no_bind_error(context: Context) -> None: + assert getattr(context, "bind_error", None) is None + + +# =========================================================================== +# ColumnDef "type" key assertion — reads master HEAD's "data" envelope key +# =========================================================================== + + +@then("the json output ColumnDef uses type key not col_type") +def step_json_coldef_type_key(context: Context) -> None: + import json + + output = context.strategy.get_output() + data = json.loads(output) + for elem in data.get("data", []): + if elem.get("type") == "table": + for col in elem.get("columns", []): + assert "type" in col, f"Missing 'type' key in ColumnDef: {col}" + assert "col_type" not in col, ( + f"Found 'col_type' key (should be 'type'): {col}" + ) + + +# =========================================================================== +# YAML does not contain assertion +# =========================================================================== + + +@then('the yaml output does not contain "{text}"') +def step_yaml_not_contains(context: Context, text: str) -> None: + output = context.strategy.get_output() + assert text not in output, f"Found {text!r} in YAML output: {output[:200]}" diff --git a/robot/output_rendering.robot b/robot/output_rendering.robot index 8288d5cb0..c0ad2bf9b 100644 --- a/robot/output_rendering.robot +++ b/robot/output_rendering.robot @@ -104,16 +104,16 @@ Text Separator And Action Hint Render Should Be Equal As Integers ${result.rc} 0 Should Contain ${result.stdout} output-rendering-text-sep-hint-ok -JSON Serializes All Ten Element Types - [Documentation] Verify JSON format serializes all 10 element types +JSON Serializes All Element Types + [Documentation] Verify JSON format serializes all element types (progress omitted per spec §26936) ${result}= Run Process ${PYTHON} ${HELPER} json-all cwd=${WORKSPACE} timeout=120s on_timeout=kill Log ${result.stdout} Log ${result.stderr} Should Be Equal As Integers ${result.rc} 0 Should Contain ${result.stdout} output-rendering-json-all-ok -YAML Serializes All Ten Element Types - [Documentation] Verify YAML format serializes all 10 element types (M5 fix) +YAML Serializes All Element Types + [Documentation] Verify YAML format serializes all element types (progress omitted per spec §26936) ${result}= Run Process ${PYTHON} ${HELPER} yaml-all cwd=${WORKSPACE} timeout=120s on_timeout=kill Log ${result.stdout} Log ${result.stderr} diff --git a/src/cleveragents/cli/output/__init__.py b/src/cleveragents/cli/output/__init__.py index 66bb866a5..8016ecce3 100644 --- a/src/cleveragents/cli/output/__init__.py +++ b/src/cleveragents/cli/output/__init__.py @@ -11,11 +11,11 @@ The following deliberate deviations from the specification (v3_spec.md Each deviation was made for simplicity or because the feature is not yet needed in the current milestone. -**SD-1 (ElementRenderer / RendererRegistry collapsed)** - The spec defines separate ``ElementRenderer`` protocol and - ``RendererRegistry`` class. This implementation collapses rendering - into the strategy classes directly, which is simpler and sufficient - for the six built-in formats. +**SD-1 (ElementRenderer / RendererRegistry — IMPLEMENTED)** + The ``ElementRenderer`` protocol and ``RendererRegistry`` class are + now implemented in ``registry.py``. Each format has a paired + ``(MaterializationStrategy, ElementRenderer)`` registered in the + default registry. **SD-2 (RichMaterializer uses LiveMaterializationStrategy)** The ``rich`` strategy now uses ``LiveMaterializationStrategy`` for @@ -80,9 +80,13 @@ yet needed in the current milestone. The spec defines ``CLEVERAGENTS_FORMAT`` for overriding the default format. This implementation only accepts ``--format`` CLI flag. -**SD-16 (TerminalCapabilities partial)** - The spec defines 11 capability fields. This implementation exposes - 4: ``is_tty``, ``supports_ansi``, ``supports_cursor``, ``term``. +**SD-16 (TerminalCapabilities — IMPLEMENTED)** + The spec defines 11 capability fields. All are now implemented: + ``is_tty``, ``width``, ``height``, ``supports_ansi``, + ``supports_256_color``, ``supports_truecolor``, ``supports_unicode``, + ``supports_cursor_movement``, ``supports_alternate_screen``, + ``no_color``, ``term_program``. Backward-compatible aliases + ``supports_cursor`` and ``term`` are available as properties. **SD-17 (ElementEvent field differences)** ``ElementEvent.element_kind`` is used instead of spec's @@ -93,17 +97,19 @@ yet needed in the current milestone. The spec's ``SessionEnd`` event includes a full session snapshot. This implementation only carries ``exit_code``. -**SD-19 (MaterializationStrategy.bind() absent)** - The spec defines a ``bind()`` method for strategy configuration. - This implementation configures strategies via constructor arguments. +**SD-19 (MaterializationStrategy.bind() — IMPLEMENTED)** + The ``bind(renderer, terminal_caps=...)`` method is now defined on + all strategy base classes. **SD-20 (on_session_begin missing stream parameter)** The spec's ``on_session_begin`` accepts a ``stream: IO`` parameter. This implementation passes the ``OutputSession`` reference instead. -**SD-21 (ColumnDef.type renamed to col_type)** - ``ColumnDef.type`` was renamed to ``col_type`` to avoid shadowing - the Python builtin ``type()``. +**SD-21 (ColumnDef.type / col_type — UPDATED)** + ``ColumnDef`` now serialises the column type under the key ``"type"`` + per the spec. The Python attribute remains ``col_type`` to avoid + shadowing the builtin, with ``alias="type"`` for backward-compatible + construction and spec-compliant serialisation. **SD-22 (Sequential IDs instead of ULIDs)** Session and handle IDs use sequential counters (``ses-000001``, @@ -198,6 +204,18 @@ from cleveragents.cli.output.materializers import ( TableMaterializer, YamlMaterializer, ) +from cleveragents.cli.output.registry import ( + ColorElementRenderer, + ElementRenderer, + FormatRegistration, + JsonElementRenderer, + PlainElementRenderer, + RendererRegistry, + RichElementRenderer, + TableElementRenderer, + YamlElementRenderer, + default_registry, +) from cleveragents.cli.output.selection import ( TerminalCapabilities, detect_terminal_capabilities, @@ -213,6 +231,7 @@ __all__ = [ "ActionHintHandle", "CodeBlock", "CodeHandle", + "ColorElementRenderer", "ColorMaterializer", "ColumnDef", "DiffBlock", @@ -224,8 +243,11 @@ __all__ = [ "ElementCreated", "ElementEvent", "ElementHandle", + "ElementRenderer", "ElementSnapshot", "ElementUpdated", + "FormatRegistration", + "JsonElementRenderer", "JsonMaterializer", "LiveMaterializationStrategy", "MaterializationStrategy", @@ -233,10 +255,13 @@ __all__ = [ "Panel", "PanelEntry", "PanelHandle", + "PlainElementRenderer", "PlainMaterializer", "ProgressHandle", "ProgressIndicator", "ProgressStep", + "RendererRegistry", + "RichElementRenderer", "RichMaterializer", "Separator", "SeparatorHandle", @@ -245,6 +270,7 @@ __all__ = [ "StatusMessage", "StructuredOutput", "Table", + "TableElementRenderer", "TableHandle", "TableMaterializer", "TerminalCapabilities", @@ -253,7 +279,9 @@ __all__ = [ "Tree", "TreeHandle", "TreeNode", + "YamlElementRenderer", "YamlMaterializer", + "default_registry", "detect_terminal_capabilities", "select_materializer", "strip_terminal_escapes", diff --git a/src/cleveragents/cli/output/handles/_models.py b/src/cleveragents/cli/output/handles/_models.py index fcf66d7f9..ef9351e40 100644 --- a/src/cleveragents/cli/output/handles/_models.py +++ b/src/cleveragents/cli/output/handles/_models.py @@ -65,10 +65,20 @@ class Panel(BaseModel): class ColumnDef(BaseModel): - """Schema for a single table column.""" + """Schema for a single table column. + + Per the specification (§26199) the column type field is named + ``type``. The Python attribute is ``col_type`` to avoid shadowing + the Python builtin ``type()``, with ``serialization_alias="type"`` + ensuring the serialised key matches the spec. Construction via + both ``ColumnDef(col_type="number")`` (explicit) and + ``ColumnDef(type="number")`` (alias) is supported. + """ + + model_config = ConfigDict(populate_by_name=True) name: str - col_type: str = "string" + col_type: str = Field(default="string", alias="type") alignment: str = "left" width_hint: int | None = None sortable: bool = False diff --git a/src/cleveragents/cli/output/materializers.py b/src/cleveragents/cli/output/materializers.py index b1d958754..b3e3f16c7 100644 --- a/src/cleveragents/cli/output/materializers.py +++ b/src/cleveragents/cli/output/materializers.py @@ -90,6 +90,13 @@ class MaterializationStrategy(Protocol): def on_session_end(self, event: SessionEnd) -> None: ... # pragma: no cover + def bind( + self, + renderer: Any, + *, + terminal_caps: Any | None = None, + ) -> None: ... # pragma: no cover + # --------------------------------------------------------------------------- # JSON / YAML serialisation helpers @@ -250,10 +257,13 @@ def _column_def_to_dict(col: ColumnDef) -> dict[str, Any]: P2-4 fix (Luis review): always include all fields unconditionally so that machine consumers get a stable set of keys per ColumnDef, matching the spec's unconditional field listing. + + The column type is serialised under the key ``"type"`` per the + spec (§26199). """ return { "name": col.name, - "col_type": col.col_type, + "type": col.col_type, "alignment": col.alignment, "width_hint": col.width_hint, "sortable": col.sortable, @@ -279,12 +289,20 @@ def _snapshot_to_dict(snapshot: StructuredOutput) -> dict[str, Any]: M4 fix: renamed ``elements`` → ``data``, ``metadata`` → ``messages``, and added ``status`` field to match the specification envelope. + + Progress elements are omitted from JSON/YAML output per spec §26936 + (``"Progress: Not rendered (JSON output is non-interactive; progress + is omitted)"``). """ result: dict[str, Any] = { "command": snapshot.command, "status": snapshot.status, "exit_code": snapshot.exit_code, - "data": [_element_to_dict(e) for e in snapshot.elements], + "data": [ + _element_to_dict(e) + for e in snapshot.elements + if not isinstance(e, ProgressIndicator) + ], "timing": snapshot.timing, "messages": snapshot.metadata, } @@ -323,6 +341,18 @@ class _BaseBufferStrategy: self._next_render_index: int = 0 self._index_map: dict[str, int] = {} # handle_id -> declaration_index self._buf_lock: threading.Lock = threading.Lock() + self._renderer: Any | None = None + self._terminal_caps: Any | None = None + + def bind( + self, + renderer: Any, + *, + terminal_caps: Any | None = None, + ) -> None: + """Bind an ElementRenderer and terminal capabilities to this strategy.""" + self._renderer = renderer + self._terminal_caps = terminal_caps def on_session_begin(self, session: OutputSession) -> None: self._session = session @@ -463,6 +493,18 @@ class _LiveMaterializationStrategy: self._lock: threading.Lock = threading.Lock() # Track whether session has ended (no more frame redraws) self._ended: bool = False + self._renderer: Any | None = None + self._terminal_caps: Any | None = None + + def bind( + self, + renderer: Any, + *, + terminal_caps: Any | None = None, + ) -> None: + """Bind an ElementRenderer and terminal capabilities to this strategy.""" + self._renderer = renderer + self._terminal_caps = terminal_caps @property def _frame_interval(self) -> float: @@ -599,6 +641,18 @@ class _AccumulateStrategy: def __init__(self) -> None: self._session: OutputSession | None = None + self._renderer: Any | None = None + self._terminal_caps: Any | None = None + + def bind( + self, + renderer: Any, + *, + terminal_caps: Any | None = None, + ) -> None: + """Bind an ElementRenderer and terminal capabilities to this strategy.""" + self._renderer = renderer + self._terminal_caps = terminal_caps def on_session_begin(self, session: OutputSession) -> None: self._session = session @@ -662,7 +716,7 @@ class YamlMaterializer(_AccumulateStrategy): return yaml.safe_dump( _snapshot_to_dict(snapshot), default_flow_style=False, - sort_keys=False, + sort_keys=True, allow_unicode=True, ).rstrip("\n") @@ -670,7 +724,7 @@ class YamlMaterializer(_AccumulateStrategy): return yaml.safe_dump( data, default_flow_style=False, - sort_keys=False, + sort_keys=True, allow_unicode=True, ).rstrip("\n") diff --git a/src/cleveragents/cli/output/registry.py b/src/cleveragents/cli/output/registry.py new file mode 100644 index 000000000..598011698 --- /dev/null +++ b/src/cleveragents/cli/output/registry.py @@ -0,0 +1,678 @@ +"""RendererRegistry and ElementRenderer protocol for the output framework. + +Implements the RendererRegistry class (spec §27249-27350) and the +ElementRenderer protocol (spec §26557-26654). Each format is registered +as a ``(MaterializationStrategy, ElementRenderer)`` pair — the strategy +controls *when* elements are rendered and the renderer controls *how* +each element type is visually formatted. + +Based on the Output Rendering Framework specification (v3_spec.md §Output +Rendering Framework / Renderer Registration and Extension). +""" + +from __future__ import annotations + +from collections.abc import Callable +from io import StringIO +from typing import TYPE_CHECKING, Any, Protocol + +from pydantic import BaseModel + +from cleveragents.cli.output.handles import ( + ActionHint, + CodeBlock, + DiffBlock, + ElementSnapshot, + Panel, + ProgressIndicator, + Separator, + StatusMessage, + Table, + TextBlock, + Tree, +) + +if TYPE_CHECKING: + from cleveragents.cli.output.selection import TerminalCapabilities + from cleveragents.cli.output.session import StructuredOutput + + +# --------------------------------------------------------------------------- +# ElementRenderer protocol (spec §26557-26654) +# --------------------------------------------------------------------------- + + +class ElementRenderer(Protocol): + """Interface for format-specific element rendering. + + An ElementRenderer knows how to paint each element type for a specific + output format. It is called by the MaterializationStrategy when it is + time to render an element. + + Implementations: + - PlainElementRenderer: ASCII text, no escapes + - ColorElementRenderer: ANSI-colored text, same layout as plain + - TableElementRenderer: Unicode box-drawing with color + - RichElementRenderer: Advanced terminal features (cursor, animation) + - JsonElementRenderer: JSON serialization + - YamlElementRenderer: YAML serialization + """ + + format_name: str + + def render_panel(self, panel: Panel) -> str: ... # pragma: no cover + + def render_table(self, table: Table) -> str: ... # pragma: no cover + + def render_tree(self, tree: Tree) -> str: ... # pragma: no cover + + def render_status(self, status: StatusMessage) -> str: ... # pragma: no cover + + def render_progress( + self, progress: ProgressIndicator + ) -> str: ... # pragma: no cover + + def render_code(self, code: CodeBlock) -> str: ... # pragma: no cover + + def render_diff(self, diff: DiffBlock) -> str: ... # pragma: no cover + + def render_text(self, text: TextBlock) -> str: ... # pragma: no cover + + def render_separator(self, separator: Separator) -> str: ... # pragma: no cover + + def render_action_hint(self, hint: ActionHint) -> str: ... # pragma: no cover + + def render_element(self, element: ElementSnapshot) -> str: ... # pragma: no cover + + def serialize(self, output: StructuredOutput) -> str: ... # pragma: no cover + + def can_render( + self, terminal_caps: TerminalCapabilities + ) -> bool: ... # pragma: no cover + + +# --------------------------------------------------------------------------- +# Concrete ElementRenderer implementations +# --------------------------------------------------------------------------- + + +class _BaseElementRenderer: + """Shared dispatch logic for element renderers.""" + + format_name: str = "base" + + def render_panel(self, panel: Panel) -> str: + return "" # pragma: no cover + + def render_table(self, table: Table) -> str: + return "" # pragma: no cover + + def render_tree(self, tree: Tree) -> str: + return "" # pragma: no cover + + def render_status(self, status: StatusMessage) -> str: + return "" # pragma: no cover + + def render_progress(self, progress: ProgressIndicator) -> str: + return "" # pragma: no cover + + def render_code(self, code: CodeBlock) -> str: + return "" # pragma: no cover + + def render_diff(self, diff: DiffBlock) -> str: + return "" # pragma: no cover + + def render_text(self, text: TextBlock) -> str: + return "" # pragma: no cover + + def render_separator(self, separator: Separator) -> str: + return "" # pragma: no cover + + def render_action_hint(self, hint: ActionHint) -> str: + return "" # pragma: no cover + + def render_element(self, element: ElementSnapshot) -> str: + """Dispatch to the appropriate render method based on element type.""" + dispatch: dict[type, Callable[..., str]] = { + Panel: self.render_panel, + Table: self.render_table, + Tree: self.render_tree, + StatusMessage: self.render_status, + ProgressIndicator: self.render_progress, + CodeBlock: self.render_code, + DiffBlock: self.render_diff, + TextBlock: self.render_text, + Separator: self.render_separator, + ActionHint: self.render_action_hint, + } + handler = dispatch.get(type(element)) + if handler: + return handler(element) + return str(element) # pragma: no cover + + def serialize(self, output: StructuredOutput) -> str: + """Serialize a complete StructuredOutput by rendering each element.""" + buf = StringIO() + for element in output.elements: + rendered = self.render_element(element) + if rendered: + buf.write(rendered) + buf.write("\n\n") + return buf.getvalue().rstrip("\n") + + def can_render(self, terminal_caps: TerminalCapabilities) -> bool: + """Whether this renderer can operate in the given terminal.""" + return True # pragma: no cover + + +class PlainElementRenderer(_BaseElementRenderer): + """Renders all elements as plain ASCII text.""" + + format_name: str = "plain" + + def render_panel(self, panel: Panel) -> str: + from cleveragents.cli.output._renderers import render_element_plain + + return render_element_plain(panel) + + def render_table(self, table: Table) -> str: + from cleveragents.cli.output._renderers import render_element_plain + + return render_element_plain(table) + + def render_tree(self, tree: Tree) -> str: + from cleveragents.cli.output._renderers import render_element_plain + + return render_element_plain(tree) + + def render_status(self, status: StatusMessage) -> str: + from cleveragents.cli.output._renderers import render_element_plain + + return render_element_plain(status) + + def render_progress(self, progress: ProgressIndicator) -> str: + from cleveragents.cli.output._renderers import render_element_plain + + return render_element_plain(progress) + + def render_code(self, code: CodeBlock) -> str: + from cleveragents.cli.output._renderers import render_element_plain + + return render_element_plain(code) + + def render_diff(self, diff: DiffBlock) -> str: + from cleveragents.cli.output._renderers import render_element_plain + + return render_element_plain(diff) + + def render_text(self, text: TextBlock) -> str: + from cleveragents.cli.output._renderers import render_element_plain + + return render_element_plain(text) + + def render_separator(self, separator: Separator) -> str: + from cleveragents.cli.output._renderers import render_element_plain + + return render_element_plain(separator) + + def render_action_hint(self, hint: ActionHint) -> str: + from cleveragents.cli.output._renderers import render_element_plain + + return render_element_plain(hint) + + def can_render(self, terminal_caps: TerminalCapabilities) -> bool: + return True # Plain always works + + +class ColorElementRenderer(_BaseElementRenderer): + """Renders all elements with ANSI colour codes.""" + + format_name: str = "color" + + def render_panel(self, panel: Panel) -> str: + from cleveragents.cli.output._color_renderers import render_element_color + + return render_element_color(panel) + + def render_table(self, table: Table) -> str: + from cleveragents.cli.output._color_renderers import render_element_color + + return render_element_color(table) + + def render_tree(self, tree: Tree) -> str: + from cleveragents.cli.output._color_renderers import render_element_color + + return render_element_color(tree) + + def render_status(self, status: StatusMessage) -> str: + from cleveragents.cli.output._color_renderers import render_element_color + + return render_element_color(status) + + def render_progress(self, progress: ProgressIndicator) -> str: + from cleveragents.cli.output._color_renderers import render_element_color + + return render_element_color(progress) + + def render_code(self, code: CodeBlock) -> str: + from cleveragents.cli.output._color_renderers import render_element_color + + return render_element_color(code) + + def render_diff(self, diff: DiffBlock) -> str: + from cleveragents.cli.output._color_renderers import render_element_color + + return render_element_color(diff) + + def render_text(self, text: TextBlock) -> str: + from cleveragents.cli.output._color_renderers import render_element_color + + return render_element_color(text) + + def render_separator(self, separator: Separator) -> str: + from cleveragents.cli.output._color_renderers import render_element_color + + return render_element_color(separator) + + def render_action_hint(self, hint: ActionHint) -> str: + from cleveragents.cli.output._color_renderers import render_element_color + + return render_element_color(hint) + + def can_render(self, terminal_caps: TerminalCapabilities) -> bool: + return terminal_caps.supports_ansi + + +class TableElementRenderer(_BaseElementRenderer): + """Renders tables with Unicode box-drawing, other elements with colour.""" + + format_name: str = "table" + + def render_panel(self, panel: Panel) -> str: + from cleveragents.cli.output._boxdraw import render_element_table + + return render_element_table(panel) + + def render_table(self, table: Table) -> str: + from cleveragents.cli.output._boxdraw import render_element_table + + return render_element_table(table) + + def render_tree(self, tree: Tree) -> str: + from cleveragents.cli.output._boxdraw import render_element_table + + return render_element_table(tree) + + def render_status(self, status: StatusMessage) -> str: + from cleveragents.cli.output._boxdraw import render_element_table + + return render_element_table(status) + + def render_progress(self, progress: ProgressIndicator) -> str: + from cleveragents.cli.output._boxdraw import render_element_table + + return render_element_table(progress) + + def render_code(self, code: CodeBlock) -> str: + from cleveragents.cli.output._boxdraw import render_element_table + + return render_element_table(code) + + def render_diff(self, diff: DiffBlock) -> str: + from cleveragents.cli.output._boxdraw import render_element_table + + return render_element_table(diff) + + def render_text(self, text: TextBlock) -> str: + from cleveragents.cli.output._boxdraw import render_element_table + + return render_element_table(text) + + def render_separator(self, separator: Separator) -> str: + from cleveragents.cli.output._boxdraw import render_element_table + + return render_element_table(separator) + + def render_action_hint(self, hint: ActionHint) -> str: + from cleveragents.cli.output._boxdraw import render_element_table + + return render_element_table(hint) + + def can_render(self, terminal_caps: TerminalCapabilities) -> bool: + return terminal_caps.is_tty + + +class RichElementRenderer(_BaseElementRenderer): + """Advanced terminal renderer (delegates to colour for now — SD-2).""" + + format_name: str = "rich" + + def render_panel(self, panel: Panel) -> str: + from cleveragents.cli.output._color_renderers import render_element_color + + return render_element_color(panel) + + def render_table(self, table: Table) -> str: + from cleveragents.cli.output._color_renderers import render_element_color + + return render_element_color(table) + + def render_tree(self, tree: Tree) -> str: + from cleveragents.cli.output._color_renderers import render_element_color + + return render_element_color(tree) + + def render_status(self, status: StatusMessage) -> str: + from cleveragents.cli.output._color_renderers import render_element_color + + return render_element_color(status) + + def render_progress(self, progress: ProgressIndicator) -> str: + from cleveragents.cli.output._color_renderers import render_element_color + + return render_element_color(progress) + + def render_code(self, code: CodeBlock) -> str: + from cleveragents.cli.output._color_renderers import render_element_color + + return render_element_color(code) + + def render_diff(self, diff: DiffBlock) -> str: + from cleveragents.cli.output._color_renderers import render_element_color + + return render_element_color(diff) + + def render_text(self, text: TextBlock) -> str: + from cleveragents.cli.output._color_renderers import render_element_color + + return render_element_color(text) + + def render_separator(self, separator: Separator) -> str: + from cleveragents.cli.output._color_renderers import render_element_color + + return render_element_color(separator) + + def render_action_hint(self, hint: ActionHint) -> str: + from cleveragents.cli.output._color_renderers import render_element_color + + return render_element_color(hint) + + def can_render(self, terminal_caps: TerminalCapabilities) -> bool: + return terminal_caps.supports_cursor_movement + + +class JsonElementRenderer(_BaseElementRenderer): + """JSON serialization renderer.""" + + format_name: str = "json" + + def render_panel(self, panel: Panel) -> str: + return "" # JSON renders via serialize() + + def render_table(self, table: Table) -> str: + return "" + + def render_tree(self, tree: Tree) -> str: + return "" + + def render_status(self, status: StatusMessage) -> str: + return "" + + def render_progress(self, progress: ProgressIndicator) -> str: + return "" + + def render_code(self, code: CodeBlock) -> str: + return "" + + def render_diff(self, diff: DiffBlock) -> str: + return "" + + def render_text(self, text: TextBlock) -> str: + return "" + + def render_separator(self, separator: Separator) -> str: + return "" + + def render_action_hint(self, hint: ActionHint) -> str: + return "" + + def serialize(self, output: StructuredOutput) -> str: + import json + + from cleveragents.cli.output.materializers import _snapshot_to_dict + + return json.dumps(_snapshot_to_dict(output), indent=2, default=str) + + def can_render(self, terminal_caps: TerminalCapabilities) -> bool: + return True # JSON works everywhere + + +class YamlElementRenderer(_BaseElementRenderer): + """YAML serialization renderer.""" + + format_name: str = "yaml" + + def render_panel(self, panel: Panel) -> str: + return "" # YAML renders via serialize() + + def render_table(self, table: Table) -> str: + return "" + + def render_tree(self, tree: Tree) -> str: + return "" + + def render_status(self, status: StatusMessage) -> str: + return "" + + def render_progress(self, progress: ProgressIndicator) -> str: + return "" + + def render_code(self, code: CodeBlock) -> str: + return "" + + def render_diff(self, diff: DiffBlock) -> str: + return "" + + def render_text(self, text: TextBlock) -> str: + return "" + + def render_separator(self, separator: Separator) -> str: + return "" + + def render_action_hint(self, hint: ActionHint) -> str: + return "" + + def serialize(self, output: StructuredOutput) -> str: + import yaml + + from cleveragents.cli.output.materializers import _snapshot_to_dict + + return yaml.safe_dump( + _snapshot_to_dict(output), + default_flow_style=False, + sort_keys=True, + allow_unicode=True, + ).rstrip("\n") + + def can_render(self, terminal_caps: TerminalCapabilities) -> bool: + return True # YAML works everywhere + + +# --------------------------------------------------------------------------- +# FormatRegistration and RendererRegistry (spec §27249-27350) +# --------------------------------------------------------------------------- + + +class FormatRegistration(BaseModel): + """A registered format: its strategy factory, renderer factory, and fallback. + + Uses Pydantic ``BaseModel`` rather than ``dataclasses.dataclass`` + to satisfy the project's architectural constraint that all structured + data classes inherit from ``BaseModel``. + """ + + model_config = {"arbitrary_types_allowed": True} + + strategy_factory: Callable[..., Any] + renderer_factory: Callable[..., Any] + fallback: str | None + + +class RendererRegistry: + """Central registry for format (strategy, renderer) pairs. + + Formats are registered by name and resolved at runtime based on the + active format and terminal capabilities. The registry supports dynamic + registration, enabling plugins to add custom formats (e.g., 'html', + 'csv', 'markdown'). + + Built-in registrations: + "rich" → (RichMaterializer, RichElementRenderer), fallback="table" + "table" → (TableMaterializer, TableElementRenderer), fallback="color" + "color" → (ColorMaterializer, ColorElementRenderer), fallback="plain" + "plain" → (PlainMaterializer, PlainElementRenderer), fallback=None + "json" → (JsonMaterializer, JsonElementRenderer), fallback=None + "yaml" → (YamlMaterializer, YamlElementRenderer), fallback=None + """ + + def __init__(self) -> None: + self._formats: dict[str, FormatRegistration] = {} + + def register( + self, + format_name: str, + strategy_factory: Callable[..., Any], + renderer_factory: Callable[..., ElementRenderer], + fallback: str | None = None, + ) -> None: + """Register a format. + + Args: + format_name: The format identifier (e.g., 'rich', 'json'). + strategy_factory: Callable that creates a MaterializationStrategy, + given terminal capabilities. + renderer_factory: Callable that creates an ElementRenderer, + given terminal capabilities. + fallback: Optional fallback format name if this format cannot + operate in the current terminal environment. + """ + self._formats[format_name] = FormatRegistration( + strategy_factory=strategy_factory, + renderer_factory=renderer_factory, + fallback=fallback, + ) + + def resolve( + self, + format_name: str, + terminal_caps: TerminalCapabilities, + ) -> tuple[Any, ElementRenderer]: + """Resolve the best (strategy, renderer) pair for the given format. + + Walks the fallback chain if the requested format's renderer cannot + operate in the current terminal environment. Returns the first pair + where the renderer reports ``can_render(terminal_caps) == True``. + + Raises: + ValueError: If no usable format is found (should never happen + since 'plain' has no fallback and always works). + """ + current: str | None = format_name + visited: set[str] = set() + + while current and current not in visited: + visited.add(current) + registration = self._formats.get(current) + if registration is None: + break + + renderer = registration.renderer_factory(terminal_caps) + if renderer.can_render(terminal_caps): + strategy = registration.strategy_factory(terminal_caps) + strategy.bind(renderer, terminal_caps=terminal_caps) + return strategy, renderer + + current = registration.fallback + + # Ultimate fallback is always plain + if "plain" in self._formats: + plain = self._formats["plain"] + renderer = plain.renderer_factory(terminal_caps) + strategy = plain.strategy_factory(terminal_caps) + strategy.bind(renderer, terminal_caps=terminal_caps) + return strategy, renderer + + raise ValueError( + f"No usable format found for {format_name!r} " + f"and no 'plain' fallback registered" + ) + + def available_formats(self) -> list[str]: + """Return all registered format names.""" + return sorted(self._formats.keys()) + + def is_registered(self, format_name: str) -> bool: + """Check if a format is registered.""" + return format_name in self._formats + + +# --------------------------------------------------------------------------- +# Default registry with built-in formats +# --------------------------------------------------------------------------- + + +def _create_default_registry() -> RendererRegistry: + """Create a RendererRegistry with all built-in formats registered.""" + from cleveragents.cli.output.materializers import ( + ColorMaterializer, + JsonMaterializer, + PlainMaterializer, + RichMaterializer, + TableMaterializer, + YamlMaterializer, + ) + + registry = RendererRegistry() + + registry.register( + "plain", + strategy_factory=lambda caps: PlainMaterializer(), + renderer_factory=lambda caps: PlainElementRenderer(), + fallback=None, + ) + registry.register( + "color", + strategy_factory=lambda caps: ColorMaterializer(), + renderer_factory=lambda caps: ColorElementRenderer(), + fallback="plain", + ) + registry.register( + "table", + strategy_factory=lambda caps: TableMaterializer(), + renderer_factory=lambda caps: TableElementRenderer(), + fallback="color", + ) + registry.register( + "rich", + strategy_factory=lambda caps: RichMaterializer(), + renderer_factory=lambda caps: RichElementRenderer(), + fallback="table", + ) + registry.register( + "json", + strategy_factory=lambda caps: JsonMaterializer(), + renderer_factory=lambda caps: JsonElementRenderer(), + fallback=None, + ) + registry.register( + "yaml", + strategy_factory=lambda caps: YamlMaterializer(), + renderer_factory=lambda caps: YamlElementRenderer(), + fallback=None, + ) + + return registry + + +#: The default global registry instance with built-in formats. +default_registry: RendererRegistry = _create_default_registry() diff --git a/src/cleveragents/cli/output/selection.py b/src/cleveragents/cli/output/selection.py index e6a3f628c..13e8464af 100644 --- a/src/cleveragents/cli/output/selection.py +++ b/src/cleveragents/cli/output/selection.py @@ -27,44 +27,116 @@ from cleveragents.cli.output.materializers import ( class TerminalCapabilities(BaseModel): - """Detected terminal capabilities used for format fallback decisions.""" + """Detected terminal capabilities used for format fallback decisions. + + Matches the spec's 11 fields (v3_spec.md §27264-27301). Previous + implementation had only 4 fields (SD-16); this version adds the + 7 missing fields: ``width``, ``height``, ``supports_256_color``, + ``supports_truecolor``, ``supports_unicode``, + ``supports_alternate_screen``, ``no_color``. + + Field renames from earlier implementation: + - ``supports_cursor`` → ``supports_cursor_movement`` + - ``term`` → ``term_program`` + """ is_tty: bool + width: int = 80 + height: int = 24 supports_ansi: bool - supports_cursor: bool - term: str + supports_256_color: bool = False + supports_truecolor: bool = False + supports_unicode: bool = True + supports_cursor_movement: bool = False + supports_alternate_screen: bool = False + no_color: bool = False + term_program: str | None = None + + @property + def supports_cursor(self) -> bool: + """Backward-compatible alias for ``supports_cursor_movement``.""" + return self.supports_cursor_movement + + @property + def term(self) -> str: + """Backward-compatible alias for ``term_program``.""" + return self.term_program or "" def detect_terminal_capabilities() -> TerminalCapabilities: """Probe the current terminal for capability flags. - Returns a ``TerminalCapabilities`` with booleans describing the - connected stdout: + Returns a ``TerminalCapabilities`` with the full set of spec fields: - * ``is_tty`` - ``sys.stdout.isatty()`` - * ``supports_ansi`` - True when ``$TERM`` is not ``dumb`` - * ``supports_cursor`` - True when TTY *and* non-dumb terminal - * ``term`` - the raw ``$TERM`` value (empty if unset) + * ``is_tty`` - ``sys.stdout.isatty()`` + * ``width`` / ``height`` - ``os.get_terminal_size()`` (fallback 80x24) + * ``supports_ansi`` - True when ``$TERM`` is not ``dumb`` + * ``supports_256_color`` - True when TERM contains "256color" + or COLORTERM is set + * ``supports_truecolor`` - True when COLORTERM is "truecolor" + or "24bit" + * ``supports_unicode`` - True when locale encoding is UTF-8 + * ``supports_cursor_movement`` - True when TTY and cursor-capable $TERM + * ``supports_alternate_screen`` - True when cursor movement supported + * ``no_color`` - True when NO_COLOR env var is set + * ``term_program`` - Value of TERM_PROGRAM env var """ + import locale + is_tty = hasattr(sys.stdout, "isatty") and sys.stdout.isatty() term = os.environ.get("TERM", "") + colorterm = os.environ.get("COLORTERM", "") supports_ansi = is_tty and term.lower() != "dumb" + + # Terminal dimensions + try: + size = os.get_terminal_size() + width, height = size.columns, size.lines + except (ValueError, OSError): + width, height = 80, 24 + + # Color depth detection + supports_256_color = "256color" in term.lower() or bool(colorterm) + supports_truecolor = colorterm.lower() in {"truecolor", "24bit"} + + # Unicode support + try: + encoding = locale.getpreferredencoding(False) or "" + supports_unicode = "utf" in encoding.lower() + except Exception: # pragma: no cover + supports_unicode = True + # Cursor movement requires a terminal emulator that supports cursor # addressing beyond basic ANSI colour codes. We check for known # cursor-capable $TERM values. This allows the state - # ``supports_ansi=True, supports_cursor=False`` (e.g. a colour-only - # pipe), making the Color fallback tier reachable in the + # ``supports_ansi=True, supports_cursor_movement=False`` (e.g. a + # colour-only pipe), making the Color fallback tier reachable in the # Rich → Table → Color → Plain chain (spec §27177, N15 fix). _cursor_terms = {"xterm", "screen", "tmux", "rxvt", "alacritty", "kitty"} term_lower = term.lower() - supports_cursor = supports_ansi and any( + supports_cursor_movement = supports_ansi and any( term_lower.startswith(ct) for ct in _cursor_terms ) + supports_alternate_screen = supports_cursor_movement + + # NO_COLOR (https://no-color.org/) + no_color = os.environ.get("NO_COLOR") is not None + + # TERM_PROGRAM + term_program = os.environ.get("TERM_PROGRAM") or None + return TerminalCapabilities( is_tty=is_tty, + width=width, + height=height, supports_ansi=supports_ansi, - supports_cursor=supports_cursor, - term=term, + supports_256_color=supports_256_color, + supports_truecolor=supports_truecolor, + supports_unicode=supports_unicode, + supports_cursor_movement=supports_cursor_movement, + supports_alternate_screen=supports_alternate_screen, + no_color=no_color, + term_program=term_program, ) @@ -139,7 +211,7 @@ def select_materializer( # cursor-capable $TERM values, so the Rich → Table path dominates. # This is documented as SD-16 in __init__.py (R2-C2 fix #8). if fmt == "rich": - if caps.supports_cursor: + if caps.supports_cursor_movement: return RichMaterializer() if caps.is_tty: return TableMaterializer()