diff --git a/CHANGELOG.md b/CHANGELOG.md index 254dc27ea..ef9086bf6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/features/steps/tui_app_coverage_steps.py b/features/steps/tui_app_coverage_steps.py index 3400557ce..50f8c8b3b 100644 --- a/features/steps/tui_app_coverage_steps.py +++ b/features/steps/tui_app_coverage_steps.py @@ -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) diff --git a/features/tui_app_coverage.feature b/features/tui_app_coverage.feature index edc72ab60..436138549 100644 --- a/features/tui_app_coverage.feature +++ b/features/tui_app_coverage.feature @@ -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" diff --git a/robot/tui_smoke.robot b/robot/tui_smoke.robot index 48f828648..b43bf7229 100644 --- a/robot/tui_smoke.robot +++ b/robot/tui_smoke.robot @@ -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 diff --git a/src/cleveragents/tui/app.py b/src/cleveragents/tui/app.py index 214a058a1..35d8766f7 100644 --- a/src/cleveragents/tui/app.py +++ b/src/cleveragents/tui/app.py @@ -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 diff --git a/src/cleveragents/tui/cleveragents.tcss b/src/cleveragents/tui/cleveragents.tcss index 003bf851a..704411dd4 100644 --- a/src/cleveragents/tui/cleveragents.tcss +++ b/src/cleveragents/tui/cleveragents.tcss @@ -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; diff --git a/src/cleveragents/tui/widgets/__init__.py b/src/cleveragents/tui/widgets/__init__.py index bdf0efcb0..3ead15238 100644 --- a/src/cleveragents/tui/widgets/__init__.py +++ b/src/cleveragents/tui/widgets/__init__.py @@ -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", diff --git a/src/cleveragents/tui/widgets/actor_thought_block.py b/src/cleveragents/tui/widgets/actor_thought_block.py new file mode 100644 index 000000000..edc97a215 --- /dev/null +++ b/src/cleveragents/tui/widgets/actor_thought_block.py @@ -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)