diff --git a/features/steps/tui_thought_block_steps.py b/features/steps/tui_thought_block_steps.py new file mode 100644 index 000000000..d27123ce0 --- /dev/null +++ b/features/steps/tui_thought_block_steps.py @@ -0,0 +1,220 @@ +"""Step definitions for tui_thought_block.feature.""" + +from __future__ import annotations + +from behave import then, when + +from cleveragents.domain.models.thought.thought_block import ThoughtBlock +from cleveragents.tui.widgets.thought_block import ThoughtBlockWidget + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _make_lines(n: int) -> str: + """Return a string with *n* numbered lines.""" + return "\n".join(f"Line {i + 1}" for i in range(n)) + + +# --------------------------------------------------------------------------- +# ThoughtBlock domain model steps +# --------------------------------------------------------------------------- + + +@when('I create a thought block with content "{content}"') +def step_create_thought_block(context, content): + context.thought = ThoughtBlock(content=content) + + +@when('I create a thought block with content "{content}" and max_lines {n:d}') +def step_create_thought_block_custom_max(context, content, n): + context.thought = ThoughtBlock(content=content, max_lines=n) + + +@when("I create a thought block with {n:d} lines of content") +def step_create_thought_block_n_lines(context, n): + context.thought = ThoughtBlock(content=_make_lines(n)) + + +@when("I create a thought block with exactly {n:d} lines of content") +def step_create_thought_block_exactly_n_lines(context, n): + context.thought = ThoughtBlock(content=_make_lines(n)) + + +@when("I create a thought block with empty content") +def step_create_thought_block_empty(context): + context.thought = ThoughtBlock(content="") + + +@when("I expand the thought block") +def step_expand_thought_block(context): + context.thought.expand() + + +@when("I collapse the thought block") +def step_collapse_thought_block(context): + context.thought.collapse() + + +@when("I toggle the thought block") +def step_toggle_thought_block(context): + context.thought.toggle() + + +@then('the thought block content should be "{expected}"') +def step_thought_block_content(context, expected): + assert context.thought.content == expected, ( + f"Expected content {expected!r}, got {context.thought.content!r}" + ) + + +@then("the thought block max_lines should be {n:d}") +def step_thought_block_max_lines(context, n): + assert context.thought.max_lines == n, ( + f"Expected max_lines {n}, got {context.thought.max_lines}" + ) + + +@then("the thought block should be collapsed by default") +def step_thought_block_collapsed_default(context): + assert context.thought.expanded is False, "Expected thought block to be collapsed" + + +@then("the thought block should be collapsed") +def step_thought_block_collapsed(context): + assert context.thought.expanded is False, "Expected thought block to be collapsed" + + +@then("the thought block should be expanded") +def step_thought_block_expanded(context): + assert context.thought.expanded is True, "Expected thought block to be expanded" + + +@then("the thought block should not be truncated") +def step_thought_block_not_truncated(context): + assert context.thought.is_truncated() is False, ( + "Expected thought block to not be truncated" + ) + + +@then("the thought block should be truncated") +def step_thought_block_truncated(context): + assert context.thought.is_truncated() is True, ( + "Expected thought block to be truncated" + ) + + +@then("the thought block visible lines count should be {n:d}") +def step_thought_block_visible_lines_count(context, n): + actual = len(context.thought.visible_lines()) + assert actual == n, f"Expected {n} visible lines, got {actual}" + + +@then("the thought block hidden line count should be {n:d}") +def step_thought_block_hidden_line_count(context, n): + actual = context.thought.hidden_line_count() + assert actual == n, f"Expected {n} hidden lines, got {actual}" + + +@then("the thought block lines count should be {n:d}") +def step_thought_block_lines_count(context, n): + actual = len(context.thought.lines()) + assert actual == n, f"Expected {n} lines, got {actual}" + + +@then('the rendered text should contain "{text}"') +def step_rendered_text_contains(context, text): + rendered = context.thought.rendered_text() + assert text in rendered, f"Expected {text!r} in rendered text, got: {rendered!r}" + + +@then('the rendered text should not contain "{text}"') +def step_rendered_text_not_contains(context, text): + rendered = context.thought.rendered_text() + assert text not in rendered, ( + f"Expected {text!r} NOT in rendered text, got: {rendered!r}" + ) + + +@then("the rendered text should be empty") +def step_rendered_text_empty(context): + rendered = context.thought.rendered_text() + assert rendered == "", f"Expected empty rendered text, got: {rendered!r}" + + +# --------------------------------------------------------------------------- +# ThoughtBlockWidget steps +# --------------------------------------------------------------------------- + + +@when('I create a thought block widget with content "{content}"') +def step_create_widget(context, content): + thought = ThoughtBlock(content=content) + context.widget = ThoughtBlockWidget(thought=thought) + + +@when("I create a thought block widget with {n:d} lines of content") +def step_create_widget_n_lines(context, n): + thought = ThoughtBlock(content=_make_lines(n)) + context.widget = ThoughtBlockWidget(thought=thought) + + +@when("I create a thought block widget with empty content") +def step_create_widget_empty(context): + thought = ThoughtBlock(content="") + context.widget = ThoughtBlockWidget(thought=thought) + + +@when("I toggle the thought block widget") +def step_toggle_widget(context): + context.widget.toggle() + + +@when("I expand the thought block widget") +def step_expand_widget(context): + context.widget.expand() + + +@when("I collapse the thought block widget") +def step_collapse_widget(context): + context.widget.collapse() + + +@then('the widget thought content should be "{expected}"') +def step_widget_thought_content(context, expected): + assert context.widget.thought.content == expected, ( + f"Expected widget thought content {expected!r}, " + f"got {context.widget.thought.content!r}" + ) + + +@then("the widget should be collapsed") +def step_widget_collapsed(context): + assert context.widget.is_expanded is False, "Expected widget to be collapsed" + + +@then("the widget should be expanded") +def step_widget_expanded(context): + assert context.widget.is_expanded is True, "Expected widget to be expanded" + + +@then('the widget display text should contain "{text}"') +def step_widget_display_text_contains(context, text): + assert text in context.widget._text, ( + f"Expected {text!r} in widget text, got: {context.widget._text!r}" + ) + + +@then('the widget CSS classes should contain "{cls}"') +def step_widget_css_class_contains(context, cls): + assert cls in context.widget._classes, ( + f"Expected CSS class {cls!r} in {context.widget._classes}" + ) + + +@then('the widget CSS classes should not contain "{cls}"') +def step_widget_css_class_not_contains(context, cls): + assert cls not in context.widget._classes, ( + f"Expected CSS class {cls!r} NOT in {context.widget._classes}" + ) diff --git a/features/tui_thought_block.feature b/features/tui_thought_block.feature new file mode 100644 index 000000000..b2c9de8c8 --- /dev/null +++ b/features/tui_thought_block.feature @@ -0,0 +1,127 @@ +Feature: TUI Actor Thought Block + Scenarios exercising the ThoughtBlock domain model and ThoughtBlockWidget. + + # ── Domain model: ThoughtBlock ────────────────────────────────────── + + Scenario: Create a thought block with content + When I create a thought block with content "I need to analyze the code." + Then the thought block content should be "I need to analyze the code." + And the thought block max_lines should be 10 + And the thought block should be collapsed by default + + Scenario: Create a thought block with custom max_lines + When I create a thought block with content "short" and max_lines 5 + Then the thought block max_lines should be 5 + + Scenario: Thought block with content under max_lines is not truncated + When I create a thought block with 5 lines of content + Then the thought block should not be truncated + And the thought block visible lines count should be 5 + + Scenario: Thought block with content over max_lines is truncated when collapsed + When I create a thought block with 15 lines of content + Then the thought block should be truncated + And the thought block visible lines count should be 10 + And the thought block hidden line count should be 5 + + Scenario: Expand a thought block shows all lines + When I create a thought block with 15 lines of content + And I expand the thought block + Then the thought block should not be truncated + And the thought block visible lines count should be 15 + And the thought block should be expanded + + Scenario: Collapse a thought block hides excess lines + When I create a thought block with 15 lines of content + And I expand the thought block + And I collapse the thought block + Then the thought block should be truncated + And the thought block visible lines count should be 10 + And the thought block should be collapsed + + Scenario: Toggle expands a collapsed thought block + When I create a thought block with 15 lines of content + And I toggle the thought block + Then the thought block should be expanded + + Scenario: Toggle collapses an expanded thought block + When I create a thought block with 15 lines of content + And I expand the thought block + And I toggle the thought block + Then the thought block should be collapsed + + Scenario: Rendered text includes truncation indicator when collapsed + When I create a thought block with 15 lines of content + Then the rendered text should contain "space to expand" + + Scenario: Rendered text does not include truncation indicator when expanded + When I create a thought block with 15 lines of content + And I expand the thought block + Then the rendered text should not contain "space to expand" + + Scenario: Empty thought block handling + When I create a thought block with empty content + Then the thought block lines count should be 0 + And the thought block should not be truncated + And the rendered text should be empty + + Scenario: Thought block with exactly max_lines is not truncated + When I create a thought block with exactly 10 lines of content + Then the thought block should not be truncated + And the thought block visible lines count should be 10 + + # ── Widget: ThoughtBlockWidget ─────────────────────────────────────── + + Scenario: ThoughtBlockWidget wraps a thought block domain model + When I create a thought block widget with content "Actor is reasoning." + Then the widget thought content should be "Actor is reasoning." + And the widget should be collapsed + + Scenario: ThoughtBlockWidget displays collapsed indicator when collapsed + When I create a thought block widget with 15 lines of content + Then the widget display text should contain "▶" + + Scenario: ThoughtBlockWidget displays expanded indicator when expanded + When I create a thought block widget with 15 lines of content + And I toggle the thought block widget + Then the widget display text should contain "▼" + + Scenario: ThoughtBlockWidget toggle expands the widget + When I create a thought block widget with 15 lines of content + And I toggle the thought block widget + Then the widget should be expanded + + Scenario: ThoughtBlockWidget toggle collapses an expanded widget + When I create a thought block widget with 15 lines of content + And I toggle the thought block widget + And I toggle the thought block widget + Then the widget should be collapsed + + Scenario: ThoughtBlockWidget expand method expands the widget + When I create a thought block widget with 15 lines of content + And I expand the thought block widget + Then the widget should be expanded + + Scenario: ThoughtBlockWidget collapse method collapses the widget + When I create a thought block widget with 15 lines of content + And I expand the thought block widget + And I collapse the thought block widget + Then the widget should be collapsed + + Scenario: ThoughtBlockWidget muted CSS class is applied + When I create a thought block widget with content "test" + Then the widget CSS classes should contain "thought-block" + + Scenario: ThoughtBlockWidget collapsed CSS class is applied when collapsed + When I create a thought block widget with content "test" + Then the widget CSS classes should contain "thought-block--collapsed" + + Scenario: ThoughtBlockWidget expanded CSS class is applied when expanded + When I create a thought block widget with 15 lines of content + And I expand the thought block widget + Then the widget CSS classes should contain "thought-block--expanded" + And the widget CSS classes should not contain "thought-block--collapsed" + + Scenario: ThoughtBlockWidget empty content shows empty indicator + When I create a thought block widget with empty content + Then the widget display text should contain "empty thought" diff --git a/src/cleveragents/domain/models/thought/__init__.py b/src/cleveragents/domain/models/thought/__init__.py new file mode 100644 index 000000000..7312eaec5 --- /dev/null +++ b/src/cleveragents/domain/models/thought/__init__.py @@ -0,0 +1,5 @@ +"""Thought block domain model for actor reasoning traces.""" + +from cleveragents.domain.models.thought.thought_block import ThoughtBlock + +__all__ = ["ThoughtBlock"] diff --git a/src/cleveragents/domain/models/thought/thought_block.py b/src/cleveragents/domain/models/thought/thought_block.py new file mode 100644 index 000000000..cc80ab1bc --- /dev/null +++ b/src/cleveragents/domain/models/thought/thought_block.py @@ -0,0 +1,77 @@ +"""ThoughtBlock domain model representing actor reasoning traces.""" + +from __future__ import annotations + +from dataclasses import dataclass, field + +_DEFAULT_MAX_LINES: int = 10 + + +@dataclass +class ThoughtBlock: + """Represents an actor reasoning/thought trace block. + + Attributes: + content: The full text content of the thought. + max_lines: Maximum number of lines shown when collapsed (default 10). + expanded: Whether the block is currently expanded to show full content. + """ + + content: str + max_lines: int = field(default=_DEFAULT_MAX_LINES) + expanded: bool = field(default=False) + + def lines(self) -> list[str]: + """Return all content lines.""" + if not self.content: + return [] + return self.content.splitlines() + + def visible_lines(self) -> list[str]: + """Return lines visible in the current state. + + When collapsed, returns up to ``max_lines`` lines. + When expanded, returns all lines. + """ + all_lines = self.lines() + if self.expanded or len(all_lines) <= self.max_lines: + return all_lines + return all_lines[: self.max_lines] + + def is_truncated(self) -> bool: + """Return True when content is truncated in collapsed state.""" + return not self.expanded and len(self.lines()) > self.max_lines + + def hidden_line_count(self) -> int: + """Return the number of lines hidden when collapsed.""" + all_lines = self.lines() + if self.expanded or len(all_lines) <= self.max_lines: + return 0 + return len(all_lines) - self.max_lines + + def toggle(self) -> None: + """Toggle the expanded/collapsed state.""" + self.expanded = not self.expanded + + def expand(self) -> None: + """Expand the block to show full content.""" + self.expanded = True + + def collapse(self) -> None: + """Collapse the block to show only max_lines.""" + self.expanded = False + + def rendered_text(self) -> str: + """Return the text to display given the current state. + + Appends a truncation indicator when content is truncated. + """ + visible = self.visible_lines() + if not visible: + return "" + text = "\n".join(visible) + if self.is_truncated(): + hidden = self.hidden_line_count() + plural = "s" if hidden != 1 else "" + text += f"\n... ({hidden} more line{plural} — space to expand)" + return text diff --git a/src/cleveragents/tui/widgets/__init__.py b/src/cleveragents/tui/widgets/__init__.py index 51776ce71..e518e8e99 100644 --- a/src/cleveragents/tui/widgets/__init__.py +++ b/src/cleveragents/tui/widgets/__init__.py @@ -5,6 +5,7 @@ from cleveragents.tui.widgets.persona_bar import PersonaBar from cleveragents.tui.widgets.prompt import PromptInput, PromptSubmitted from cleveragents.tui.widgets.reference_picker import ReferencePickerOverlay from cleveragents.tui.widgets.slash_command_overlay import SlashCommandOverlay +from cleveragents.tui.widgets.thought_block import ThoughtBlockWidget __all__ = [ "HelpPanelOverlay", @@ -13,4 +14,5 @@ __all__ = [ "PromptSubmitted", "ReferencePickerOverlay", "SlashCommandOverlay", + "ThoughtBlockWidget", ] diff --git a/src/cleveragents/tui/widgets/thought_block.py b/src/cleveragents/tui/widgets/thought_block.py new file mode 100644 index 000000000..2174d1b78 --- /dev/null +++ b/src/cleveragents/tui/widgets/thought_block.py @@ -0,0 +1,158 @@ +"""ThoughtBlockWidget — muted, expandable actor reasoning trace widget.""" + +from __future__ import annotations + +import contextlib +import importlib +from typing import Any + +from cleveragents.domain.models.thought.thought_block import ThoughtBlock + + +def _load_static_base() -> type[Any]: + try: + return importlib.import_module("textual.widgets").Static + except Exception: # pragma: no cover + + class _FallbackStatic: + def __init__(self, *args: object, **kwargs: object) -> None: + self._text = "" + self._classes: set[str] = set() + + def update(self, text: str) -> None: + self._text = text + + def add_class(self, cls: str) -> None: + self._classes.add(cls) + + def remove_class(self, cls: str) -> None: + self._classes.discard(cls) + + return _FallbackStatic + + +_StaticBase = _load_static_base() + +# CSS class applied to the widget for muted styling +_CSS_CLASS_MUTED = "thought-block" +_CSS_CLASS_EXPANDED = "thought-block--expanded" +_CSS_CLASS_COLLAPSED = "thought-block--collapsed" + +# Indicator characters matching the spec +_INDICATOR_COLLAPSED = "▶" +_INDICATOR_EXPANDED = "▼" + + +class ThoughtBlockWidget(_StaticBase): + """Widget that renders an actor thought block with muted styling. + + The widget wraps a :class:`~cleveragents.domain.models.thought.ThoughtBlock` + domain model and provides: + + - Collapsed view showing at most ``max_lines`` lines with a truncation + indicator when content exceeds that limit. + - Expandable view showing the full content. + - Muted styling via the ``thought-block`` CSS class. + - Toggle via :meth:`toggle` (bound to ``space`` in the conversation + stream) or :meth:`expand` / :meth:`collapse`. + """ + + DEFAULT_CSS = """ + ThoughtBlockWidget { + background: $primary 20%; + color: $text-muted; + border: solid $primary 30%; + padding: 0 1; + margin: 0 0 1 0; + } + ThoughtBlockWidget.thought-block--expanded { + max-height: 100vh; + } + ThoughtBlockWidget.thought-block--collapsed { + max-height: 10; + } + """ + + def __init__( + self, + thought: ThoughtBlock, + *args: object, + **kwargs: object, + ) -> None: + super().__init__(*args, **kwargs) + self._thought = thought + self._text = "" + self._classes: set[str] = {_CSS_CLASS_MUTED} + self._refresh_display() + + # ------------------------------------------------------------------ + # Public API + # ------------------------------------------------------------------ + + @property + def thought(self) -> ThoughtBlock: + """Return the underlying domain model.""" + return self._thought + + @property + def is_expanded(self) -> bool: + """Return whether the widget is currently expanded.""" + return self._thought.expanded + + def toggle(self) -> None: + """Toggle between expanded and collapsed states.""" + self._thought.toggle() + self._refresh_display() + + def expand(self) -> None: + """Expand the widget to show full content.""" + self._thought.expand() + self._refresh_display() + + def collapse(self) -> None: + """Collapse the widget to show only max_lines.""" + self._thought.collapse() + self._refresh_display() + + # ------------------------------------------------------------------ + # Internal helpers + # ------------------------------------------------------------------ + + def _indicator(self) -> str: + return _INDICATOR_EXPANDED if self._thought.expanded else _INDICATOR_COLLAPSED + + def _refresh_display(self) -> None: + """Recompute the rendered text and update CSS classes.""" + rendered = self._thought.rendered_text() + indicator = self._indicator() + if rendered: + self._text = f"{indicator} {rendered}" + else: + self._text = f"{indicator} (empty thought)" + + # Update CSS state classes + if self._thought.expanded: + self._classes.discard(_CSS_CLASS_COLLAPSED) + self._classes.add(_CSS_CLASS_EXPANDED) + else: + self._classes.discard(_CSS_CLASS_EXPANDED) + self._classes.add(_CSS_CLASS_COLLAPSED) + + with contextlib.suppress(Exception): # pragma: no cover + self.update(self._text) + + # ------------------------------------------------------------------ + # Textual event handlers (no-ops when Textual is unavailable) + # ------------------------------------------------------------------ + + def on_click(self) -> None: # pragma: no cover + """Toggle expand/collapse on mouse click.""" + self.toggle() + + def on_key(self, event: Any) -> None: # pragma: no cover + """Toggle on space key press.""" + try: + if event.key == "space": + self.toggle() + except AttributeError: + pass