feat(tui): implement actor thought block rendering #1238
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user