feat(cli): implement RendererRegistry and ElementRenderer architecture per spec

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
This commit is contained in:
2026-03-29 06:42:52 +00:00
committed by Forgejo
parent 1855a1c118
commit 4e5e0624fd
8 changed files with 1453 additions and 39 deletions
+156
View File
@@ -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
@@ -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]}"
+4 -4
View File
@@ -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}
+42 -14
View File
@@ -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",
+12 -2
View File
@@ -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
+58 -4
View File
@@ -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")
+678
View File
@@ -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()
+87 -15
View File
@@ -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()