feat(tui): implement actor thought block rendering
CI / integration_tests (push) Has been cancelled
CI / build (push) Has been cancelled
CI / security (push) Has been cancelled
CI / e2e_tests (push) Has been cancelled
CI / typecheck (push) Has been cancelled
CI / quality (push) Has been cancelled
CI / unit_tests (push) Has been cancelled
CI / benchmark-publish (push) Has been cancelled
CI / helm (push) Has been cancelled
CI / status-check (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / coverage (push) Has been cancelled
CI / docker (push) Has been cancelled
CI / benchmark-regression (push) Has been cancelled

Implement ThoughtBlock domain model with configurable max_lines (default 10), expanded state, and helper methods for rendering truncated/full content. Implement ThoughtBlockWidget TUI widget with muted styling, collapsed/expanded state, and expand/collapse toggle. Add 23 Behave BDD scenarios covering domain model and widget behaviour.

Closes #1001

ISSUES CLOSED: #1001

Co-authored-by: Jeffrey Phillips Freeman <the@jeffreyfreeman.me>
Co-committed-by: Jeffrey Phillips Freeman <the@jeffreyfreeman.me>
This commit was merged in pull request #1298.
This commit is contained in:
2026-04-02 17:07:47 +00:00
committed by Forgejo
parent 65aa7a7842
commit cfc9e04a90
6 changed files with 589 additions and 0 deletions
+220
View File
@@ -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}"
)
+127
View File
@@ -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"
@@ -0,0 +1,5 @@
"""Thought block domain model for actor reasoning traces."""
from cleveragents.domain.models.thought.thought_block import ThoughtBlock
__all__ = ["ThoughtBlock"]
@@ -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
+2
View File
@@ -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",
]
@@ -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