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
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:
@@ -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}"
|
||||
)
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user