Compare commits

...

1 Commits

Author SHA1 Message Date
brent.edwards 33d9ea211c feat(tui): implement actor thought block rendering
CI / benchmark-publish (pull_request) Has been skipped
CI / build (pull_request) Successful in 19s
CI / helm (pull_request) Successful in 31s
CI / lint (pull_request) Successful in 3m19s
CI / quality (pull_request) Successful in 3m43s
CI / typecheck (pull_request) Successful in 3m56s
CI / security (pull_request) Successful in 4m9s
CI / unit_tests (pull_request) Successful in 9m26s
CI / docker (pull_request) Successful in 1m20s
CI / coverage (pull_request) Successful in 12m27s
CI / e2e_tests (pull_request) Successful in 19m0s
CI / integration_tests (pull_request) Successful in 24m37s
CI / status-check (pull_request) Successful in 1s
CI / benchmark-regression (pull_request) Successful in 55m3s
Add ActorThought block widget with collapsed max-10-line rendering, expand behavior, and app-level visibility toggle binding. Extend Behave/Robot coverage and styling for the new block while wiring it into TUI composition and input flow.

ISSUES CLOSED: #1001
2026-04-01 08:21:08 +00:00
8 changed files with 191 additions and 1 deletions
+4
View File
@@ -2,6 +2,10 @@
## Unreleased
- Added TUI ActorThought block rendering with muted styling, collapsed-by-default
max-10-line view, and keyboard expansion support. Wired the block into the
Textual app layout and added `ctrl+shift+t` visibility toggle, plus Behave
and Robot coverage for truncation/expansion behavior. (#1001)
- Strengthened WF02 trusted-profile integration coverage for automated test
generation. The helper now validates and incorporates mocked provider output
(instead of discarding it), asserts all WF02 invariant conventions (test-only
+48
View File
@@ -10,6 +10,8 @@ These steps target uncovered lines in cleveragents/tui/app.py:
- Lines 131-142: _refresh_persona_bar method
- Lines 144-185: on_input_submitted (all branches)
- Line 189: CleverAgentsTuiApp alias
Also covers ActorThought block behavior from issue #1001.
"""
import importlib
@@ -481,3 +483,49 @@ def step_alias_check(context):
assert (
context._tui_app_mod.CleverAgentsTuiApp.__name__ == "_TextualCleverAgentsTuiApp"
)
# ---------------------------------------------------------------------------
# ActorThought block (issue #1001)
# ---------------------------------------------------------------------------
@given("an ActorThought block widget")
def step_actor_thought_widget(context):
from cleveragents.tui.widgets.actor_thought_block import ActorThoughtBlock
context._actor_thought_block = ActorThoughtBlock()
@when("I set a 14-line thought on the ActorThought block")
def step_set_actor_thought(context):
thought = "\n".join(f"line {index}" for index in range(1, 15))
context._actor_thought_block.set_thought(thought)
@when("I toggle ActorThought expansion")
def step_toggle_actor_thought(context):
context._actor_thought_block.toggle_expanded()
@then('the ActorThought block should contain "{text}"')
def step_actor_thought_contains(context, text):
rendered = context._actor_thought_block.rendered_text()
assert text in rendered, f"Expected '{text}' in '{rendered}'"
@then('the ActorThought block should not contain "{text}"')
def step_actor_thought_not_contains(context, text):
rendered = context._actor_thought_block.rendered_text()
assert text not in rendered, f"Did not expect '{text}' in '{rendered}'"
@then("compose should include an ActorThought block widget")
def step_compose_has_actor_thought_widget(context):
assert any(
item.__class__.__name__ == "ActorThoughtBlock"
for item in context._tui_compose_items
)
@then('the app class should include key binding "{key}"')
def step_binding_exists(context, key):
assert any(binding[0] == key for binding in context._tui_app.BINDINGS)
+23 -1
View File
@@ -47,7 +47,7 @@ Feature: TUI App Coverage
Given a mock command router and persona state
When I instantiate the Textual TUI app
Then the app class should have CSS_PATH set to "cleveragents.tcss"
And the app class should have 3 key bindings
And the app class should have 4 key bindings
# --- compose method (lines 102-112) ---
@@ -170,3 +170,25 @@ Feature: TUI App Coverage
And I call on_mount on the app
And I submit "!true" to the app
Then the conversation widget should contain "(empty output)"
# --- ActorThought block rendering (issue #1001) ---
Scenario: ActorThought block collapses to max 10 lines
Given an ActorThought block widget
When I set a 14-line thought on the ActorThought block
Then the ActorThought block should contain "line 10"
And the ActorThought block should not contain "line 11"
And the ActorThought block should contain "4 more lines - space to expand"
Scenario: ActorThought block expands to full content
Given an ActorThought block widget
When I set a 14-line thought on the ActorThought block
And I toggle ActorThought expansion
Then the ActorThought block should contain "line 14"
Scenario: Textual TUI app includes ActorThought widget and toggle binding
Given a mock command router and persona state
When I instantiate the Textual TUI app
And I call compose on the app
Then compose should include an ActorThought block widget
And the app class should include key binding "ctrl+shift+t"
+18
View File
@@ -40,3 +40,21 @@ TUI Headless Includes Router Help Payload
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} "help"
Should Contain ${result.stdout} /persona
TUI ActorThought Block Truncates And Expands
${script}= Catenate SEPARATOR=\n
... from cleveragents.tui.widgets.actor_thought_block import ActorThoughtBlock
... block = ActorThoughtBlock()
... text = "\\n".join(f"line {index}" for index in range(1, 15))
... block.set_thought(text)
... collapsed = block.rendered_text()
... assert "line 10" in collapsed
... assert "line 11" not in collapsed
... assert "4 more lines - space to expand" in collapsed
... block.toggle_expanded()
... expanded = block.rendered_text()
... assert "line 14" in expanded
... print("actor-thought-block-ok")
${result}= Run Process ${PYTHON} -c ${script} shell=False stderr=STDOUT
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} actor-thought-block-ok
+13
View File
@@ -10,6 +10,7 @@ from typing import TYPE_CHECKING, Any, ClassVar, Protocol
from cleveragents.tui.input.modes import InputMode, InputModeRouter
from cleveragents.tui.input.reference_parser import suggestions
from cleveragents.tui.persona.state import PersonaState
from cleveragents.tui.widgets.actor_thought_block import ActorThoughtBlock
from cleveragents.tui.widgets.persona_bar import PersonaBar
from cleveragents.tui.widgets.prompt import PromptInput
from cleveragents.tui.widgets.reference_picker import ReferencePickerOverlay
@@ -86,6 +87,7 @@ if _TEXTUAL_AVAILABLE:
("ctrl+q", "quit", "Quit"),
("f1", "help", "Help"),
("ctrl+t", "cycle_preset", "Cycle Preset"),
("ctrl+shift+t", "toggle_thoughts", "Toggle Thoughts"),
]
def __init__(
@@ -98,11 +100,13 @@ if _TEXTUAL_AVAILABLE:
self._command_router = command_router
self._persona_state = persona_state
self._session = SessionView(session_id="default", transcript=[])
self._thoughts_visible = True
def compose(self) -> Any:
yield _Header(show_clock=True)
with _Vertical(id="main-column"):
yield _Static("CleverAgents TUI", id="conversation")
yield ActorThoughtBlock(id="actor-thought")
yield ReferencePickerOverlay(id="reference-picker")
yield SlashCommandOverlay(id="slash-overlay")
yield PromptInput(
@@ -119,6 +123,8 @@ if _TEXTUAL_AVAILABLE:
slash.set_commands(
"", ["persona list", "persona set", "session show", "help"]
)
thought_block = self.query_one("#actor-thought", ActorThoughtBlock)
thought_block.set_visible(self._thoughts_visible)
def action_help(self) -> None:
conversation = self.query_one("#conversation", _Static)
@@ -128,6 +134,11 @@ if _TEXTUAL_AVAILABLE:
self._persona_state.cycle_preset(self._session.session_id)
self._refresh_persona_bar()
def action_toggle_thoughts(self) -> None:
self._thoughts_visible = not self._thoughts_visible
thought_block = self.query_one("#actor-thought", ActorThoughtBlock)
thought_block.set_visible(self._thoughts_visible)
def _refresh_persona_bar(self) -> None:
persona = self._persona_state.active_persona(self._session.session_id)
preset = self._persona_state.current_preset(self._session.session_id)
@@ -182,6 +193,8 @@ if _TEXTUAL_AVAILABLE:
ref_picker.set_suggestions(
text, suggestions(text.replace("@", "").strip())
)
thought_block = self.query_one("#actor-thought", ActorThoughtBlock)
thought_block.set_thought(f"I am reasoning about: {preview}")
conversation.update(preview)
_ResolvedTuiApp = _TextualCleverAgentsTuiApp
+11
View File
@@ -14,6 +14,17 @@ Screen {
background: $panel;
}
#actor-thought {
height: auto;
max-height: 10;
padding: 0 1;
margin: 1 0 0 0;
border: round $primary;
background: $primary 20%;
color: $text-muted;
text-style: italic;
}
#reference-picker {
height: auto;
max-height: 8;
+2
View File
@@ -1,11 +1,13 @@
"""Widget collection for CleverAgents TUI."""
from cleveragents.tui.widgets.actor_thought_block import ActorThoughtBlock
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
__all__ = [
"ActorThoughtBlock",
"PersonaBar",
"PromptInput",
"PromptSubmitted",
@@ -0,0 +1,72 @@
"""Actor thought block widget for the conversation stream."""
from __future__ import annotations
import importlib
from typing import Any
def _load_static_base() -> type[Any]:
try:
return importlib.import_module("textual.widgets").Static
except Exception: # pragma: no cover - optional dependency
class _FallbackStatic:
def __init__(self, *args: object, **kwargs: object) -> None:
self._text = ""
def update(self, text: str) -> None:
self._text = text
return _FallbackStatic
_StaticBase = _load_static_base()
class ActorThoughtBlock(_StaticBase):
"""Render actor reasoning in a muted, expandable block."""
def __init__(self, *args: object, max_lines: int = 10, **kwargs: object) -> None:
super().__init__(*args, **kwargs)
self._max_lines = max_lines
self._expanded = False
self._visible = True
self._full_text = ""
self._text = ""
def set_thought(self, thought_text: str) -> None:
"""Set thought text and render in current visibility/expansion mode."""
self._full_text = thought_text
self._render()
def set_visible(self, visible: bool) -> None:
"""Toggle thought block visibility."""
self._visible = visible
self._render()
def toggle_expanded(self) -> None:
"""Toggle between collapsed (max lines) and expanded rendering."""
self._expanded = not self._expanded
self._render()
def rendered_text(self) -> str:
"""Return the latest rendered text payload."""
return self._text
def _render(self) -> None:
if not self._visible or not self._full_text.strip():
self._text = ""
self.update("")
return
all_lines = self._full_text.splitlines() or [self._full_text]
rendered_lines = all_lines
if not self._expanded and len(all_lines) > self._max_lines:
hidden_count = len(all_lines) - self._max_lines
rendered_lines = all_lines[: self._max_lines]
rendered_lines.append(f"({hidden_count} more lines - space to expand)")
body = "\n".join(rendered_lines)
self._text = f"[i][dim]{body}[/dim][/i]"
self.update(self._text)