From 35e50c33f4f743c38262ed77106a4d8ad0d185e3 Mon Sep 17 00:00:00 2001 From: "Brent E. Edwards" Date: Wed, 1 Apr 2026 19:50:11 +0000 Subject: [PATCH] feat(tui): implement full conversation stream block type catalog Add a typed conversation-stream block catalog covering the 10 specification block types and render TUI conversation content through the new stream widget. Extend Behave and Robot coverage for the catalog, rendering helpers, and welcome-block app wiring. ISSUES CLOSED: #1006 --- CHANGELOG.md | 7 + features/steps/tui_app_coverage_steps.py | 4 + .../tui_conversation_stream_coverage_steps.py | 115 ++++++++++ features/tui_app_coverage.feature | 1 + .../tui_conversation_stream_coverage.feature | 37 +++ robot/tui_smoke.robot | 20 ++ src/cleveragents/tui/app.py | 35 ++- src/cleveragents/tui/widgets/__init__.py | 2 + .../tui/widgets/conversation_stream.py | 217 ++++++++++++++++++ 9 files changed, 430 insertions(+), 8 deletions(-) create mode 100644 features/steps/tui_conversation_stream_coverage_steps.py create mode 100644 features/tui_conversation_stream_coverage.feature create mode 100644 src/cleveragents/tui/widgets/conversation_stream.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 36aab2df1..71ff406e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,13 @@ template-copy/fallback delegation paths, and the existing-empty-DB branch in `features/fast_init_upgrade.feature`. Uses race-safe temp-path allocation (`mkstemp`/`mkdtemp`) throughout new fast-init test steps. (#733) +- Added a typed TUI conversation-stream block catalog covering the 10 + specification block types: Welcome, UserInput, ActorResponse, + ActorThought, ToolCall, PlanProgress, DiffView, TerminalEmbed, + ShellResult, and Note. The TUI app now renders conversation content + through the typed stream model, with Behave and Robot coverage for the + catalog and rendering helpers. (#1006) + - Expanded the TUI slash command overlay catalog to include 67 commands across 14 groups, aligned with the specification command reference for session, persona, scope, plan, project, registry/config, context, and utility flows. diff --git a/features/steps/tui_app_coverage_steps.py b/features/steps/tui_app_coverage_steps.py index ad9227bd2..3b691623b 100644 --- a/features/steps/tui_app_coverage_steps.py +++ b/features/steps/tui_app_coverage_steps.py @@ -115,12 +115,14 @@ def _install_mock_textual(context): sys.modules[key] = mod # Reload widget modules so they pick up the mock Static/Input base class + import cleveragents.tui.widgets.conversation_stream as cs_mod import cleveragents.tui.widgets.help_panel_overlay as hp_mod import cleveragents.tui.widgets.persona_bar as pb_mod import cleveragents.tui.widgets.prompt as prompt_mod import cleveragents.tui.widgets.reference_picker as rp_mod import cleveragents.tui.widgets.slash_command_overlay as sco_mod + importlib.reload(cs_mod) importlib.reload(hp_mod) importlib.reload(pb_mod) importlib.reload(prompt_mod) @@ -143,12 +145,14 @@ def _restore_modules(context): sys.modules[key] = val # Reload widget modules so they pick up the real Static/Input base class again + import cleveragents.tui.widgets.conversation_stream as cs_mod import cleveragents.tui.widgets.help_panel_overlay as hp_mod import cleveragents.tui.widgets.persona_bar as pb_mod import cleveragents.tui.widgets.prompt as prompt_mod import cleveragents.tui.widgets.reference_picker as rp_mod import cleveragents.tui.widgets.slash_command_overlay as sco_mod + importlib.reload(cs_mod) importlib.reload(hp_mod) importlib.reload(pb_mod) importlib.reload(prompt_mod) diff --git a/features/steps/tui_conversation_stream_coverage_steps.py b/features/steps/tui_conversation_stream_coverage_steps.py new file mode 100644 index 000000000..253aaca22 --- /dev/null +++ b/features/steps/tui_conversation_stream_coverage_steps.py @@ -0,0 +1,115 @@ +"""Step definitions for tui_conversation_stream_coverage.feature.""" + +from __future__ import annotations + +from behave import given, then, when + +from cleveragents.tui.widgets.conversation_stream import ( + CONVERSATION_BLOCK_CATALOG, + EXPANDABLE_BLOCK_TYPES, + ConversationBlock, + ConversationBlockType, + ConversationStream, + conversation_block_names, + conversation_block_spec, + render_conversation_blocks, +) + + +@when("I inspect the TUI conversation block catalog") +def step_inspect_block_catalog(context): + context.block_catalog = CONVERSATION_BLOCK_CATALOG + + +@then("the conversation block catalog should contain {count:d} entries") +def step_block_catalog_count(context, count): + assert len(context.block_catalog) == count + + +@then("the conversation block names should be in the spec order") +def step_block_name_order(context): + assert conversation_block_names() == [ + "Welcome", + "UserInput", + "ActorResponse", + "ActorThought", + "ToolCall", + "PlanProgress", + "DiffView", + "TerminalEmbed", + "ShellResult", + "Note", + ] + + +@then('the expandable conversation block types should be "{expected}"') +def step_expandable_block_types(context, expected): + ordered = [ + spec.block_type.value + for spec in CONVERSATION_BLOCK_CATALOG + if spec.block_type in EXPANDABLE_BLOCK_TYPES + ] + assert ", ".join(ordered) == expected + + +@when('I look up the conversation block spec for "{name}"') +def step_lookup_block_spec(context, name): + context.block_spec = conversation_block_spec(ConversationBlockType(name)) + + +@then('the block spec visual treatment should contain "{text}"') +def step_block_spec_visual_treatment(context, text): + assert text in context.block_spec.visual_treatment + + +@then('the block spec source should contain "{text}"') +def step_block_spec_source(context, text): + assert text in context.block_spec.source + + +@when("I render a conversation stream with one block of each catalog type") +def step_render_full_block_catalog(context): + blocks = [ + ConversationBlock.welcome(), + ConversationBlock.user_input("hello from the prompt"), + ConversationBlock(ConversationBlockType.ACTOR_RESPONSE, "working on it"), + ConversationBlock(ConversationBlockType.ACTOR_THOUGHT, "considering options"), + ConversationBlock( + ConversationBlockType.TOOL_CALL, + "status: ok", + title="local/read-file", + ), + ConversationBlock(ConversationBlockType.PLAN_PROGRESS, "strategize -> execute"), + ConversationBlock(ConversationBlockType.DIFF_VIEW, "+ added line"), + ConversationBlock(ConversationBlockType.TERMINAL_EMBED, "build output"), + ConversationBlock.shell_result("ls", "README.md"), + ConversationBlock.note("budget exceeded", variant="warning"), + ] + context.rendered_conversation = render_conversation_blocks(blocks) + + +@then('the rendered conversation should contain "{text}"') +def step_rendered_conversation_contains(context, text): + assert text in context.rendered_conversation + + +@given("a fresh TUI conversation stream widget") +def step_fresh_conversation_widget(context): + context.conversation_widget = ConversationStream() + + +@when('I append a note block saying "{text}"') +def step_append_note_block(context, text): + context.conversation_widget.append_block( + ConversationBlock.note(text, variant="warning") + ) + + +@then('the conversation stream widget text should contain "{text}"') +def step_conversation_widget_text_contains(context, text): + assert text in context.conversation_widget._text + + +@when("I replace the conversation stream with a welcome block") +def step_replace_stream_with_welcome(context): + context.conversation_widget.set_blocks([ConversationBlock.welcome()]) diff --git a/features/tui_app_coverage.feature b/features/tui_app_coverage.feature index 2392dca85..d95f78cad 100644 --- a/features/tui_app_coverage.feature +++ b/features/tui_app_coverage.feature @@ -67,6 +67,7 @@ Feature: TUI App Coverage And the help panel should be hidden on mount And the reference picker should have suggestions initialised And the slash overlay should have commands initialised + And the conversation widget should contain "Welcome to CleverAgents" # --- action_help method (lines 123-125) --- diff --git a/features/tui_conversation_stream_coverage.feature b/features/tui_conversation_stream_coverage.feature new file mode 100644 index 000000000..05a825e0c --- /dev/null +++ b/features/tui_conversation_stream_coverage.feature @@ -0,0 +1,37 @@ +Feature: TUI Conversation Stream Coverage + Scenarios exercising the conversation stream block catalog, + renderer, and widget helper methods. + + Scenario: conversation block catalog exposes all 10 block types in spec order + When I inspect the TUI conversation block catalog + Then the conversation block catalog should contain 10 entries + And the conversation block names should be in the spec order + + Scenario: expandable block types match the specification + When I inspect the TUI conversation block catalog + Then the expandable conversation block types should be "ActorThought, ToolCall, DiffView" + + Scenario: catalog lookup returns the expected spec metadata + When I look up the conversation block spec for "ToolCall" + Then the block spec visual treatment should contain "Expandable" + And the block spec source should contain "tool.invoked" + + Scenario: the renderer can format one block of each supported type + When I render a conversation stream with one block of each catalog type + Then the rendered conversation should contain "[Welcome]" + And the rendered conversation should contain "[UserInput]" + And the rendered conversation should contain "[ActorResponse]" + And the rendered conversation should contain "[ActorThought]" + And the rendered conversation should contain "[ToolCall local/read-file]" + And the rendered conversation should contain "[PlanProgress]" + And the rendered conversation should contain "[DiffView]" + And the rendered conversation should contain "[TerminalEmbed]" + And the rendered conversation should contain "[ShellResult]" + And the rendered conversation should contain "[Note:warning]" + + Scenario: conversation stream widget append and replace operations refresh text + Given a fresh TUI conversation stream widget + When I append a note block saying "watch the budget" + Then the conversation stream widget text should contain "watch the budget" + When I replace the conversation stream with a welcome block + Then the conversation stream widget text should contain "Welcome to CleverAgents" diff --git a/robot/tui_smoke.robot b/robot/tui_smoke.robot index c2ed219f9..e7952a79e 100644 --- a/robot/tui_smoke.robot +++ b/robot/tui_smoke.robot @@ -62,3 +62,23 @@ TUI Help Panel Context Switching ${result}= Run Process ${PYTHON} -c ${script} shell=False stderr=STDOUT Should Be Equal As Integers ${result.rc} 0 Should Contain ${result.stdout} tui-help-panel-ok + +TUI Conversation Block Catalog + ${script}= Catenate SEPARATOR=\n + ... from cleveragents.tui.widgets.conversation_stream import ConversationBlock + ... from cleveragents.tui.widgets.conversation_stream import ConversationBlockType + ... from cleveragents.tui.widgets.conversation_stream import conversation_block_names + ... from cleveragents.tui.widgets.conversation_stream import render_conversation_blocks + ... assert conversation_block_names() == ["Welcome", "UserInput", "ActorResponse", "ActorThought", "ToolCall", "PlanProgress", "DiffView", "TerminalEmbed", "ShellResult", "Note"] + ... rendered = render_conversation_blocks([ + ... ConversationBlock.welcome(), + ... ConversationBlock(ConversationBlockType.TOOL_CALL, "status: ok", title="local/read-file"), + ... ConversationBlock.note("warning emitted", variant="warning"), + ... ]) + ... assert "[Welcome]" in rendered + ... assert "[ToolCall local/read-file]" in rendered + ... assert "[Note:warning]" in rendered + ... print("tui-conversation-blocks-ok") + ${result}= Run Process ${PYTHON} -c ${script} shell=False stderr=STDOUT + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} tui-conversation-blocks-ok diff --git a/src/cleveragents/tui/app.py b/src/cleveragents/tui/app.py index 516a9cb49..d6f275867 100644 --- a/src/cleveragents/tui/app.py +++ b/src/cleveragents/tui/app.py @@ -11,6 +11,10 @@ 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.slash_catalog import slash_command_names +from cleveragents.tui.widgets.conversation_stream import ( + ConversationBlock, + ConversationStream, +) from cleveragents.tui.widgets.help_panel_overlay import ( HelpPanelOverlay, resolve_help_context, @@ -55,7 +59,7 @@ class SessionView: """Minimal per-session TUI view model.""" session_id: str - transcript: list[str] + transcript: list[ConversationBlock] class _CommandRouter(Protocol): @@ -102,12 +106,17 @@ if _TEXTUAL_AVAILABLE: super().__init__() self._command_router = command_router self._persona_state = persona_state - self._session = SessionView(session_id="default", transcript=[]) + self._session = SessionView( + session_id="default", + transcript=[ConversationBlock.welcome()], + ) def compose(self) -> Any: yield _Header(show_clock=True) with _Vertical(id="main-column"): - yield _Static("CleverAgents TUI", id="conversation") + yield ConversationStream( + id="conversation", blocks=self._session.transcript + ) yield HelpPanelOverlay(id="help-panel") yield ReferencePickerOverlay(id="reference-picker") yield SlashCommandOverlay(id="slash-overlay") @@ -118,6 +127,7 @@ if _TEXTUAL_AVAILABLE: yield _Footer() def on_mount(self) -> None: + self._sync_conversation() self._refresh_persona_bar() help_panel = self.query_one("#help-panel", HelpPanelOverlay) help_panel.hide() @@ -149,6 +159,14 @@ if _TEXTUAL_AVAILABLE: scope_text=scope_text, ) + def _sync_conversation(self) -> None: + conversation = self.query_one("#conversation", ConversationStream) + conversation.set_blocks(self._session.transcript) + + def _append_block(self, block: ConversationBlock) -> None: + self._session.transcript.append(block) + self._sync_conversation() + def on_input_submitted(self, event: InputSubmittedEvent) -> None: del event prompt = self.query_one("#prompt", PromptInput) @@ -167,21 +185,22 @@ if _TEXTUAL_AVAILABLE: ), ) result = mode_router.process(text) - conversation = self.query_one("#conversation", _Static) if result.mode == InputMode.COMMAND: - conversation.update(result.command_result or "") + self._append_block(ConversationBlock.note(result.command_result or "")) self._refresh_persona_bar() return if result.mode == InputMode.SHELL: shell = result.shell_result if shell is None: - conversation.update("(no shell output)") + self._append_block(ConversationBlock.note("(no shell output)")) return output = ( shell.stdout.strip() or shell.stderr.strip() or "(empty output)" ) - conversation.update(f"$ {shell.command}\n{output}") + self._append_block( + ConversationBlock.shell_result(shell.command, output) + ) return preview = result.expanded_text @@ -190,7 +209,7 @@ if _TEXTUAL_AVAILABLE: ref_picker.set_suggestions( text, suggestions(text.replace("@", "").strip()) ) - conversation.update(preview) + self._append_block(ConversationBlock.user_input(preview)) _ResolvedTuiApp = _TextualCleverAgentsTuiApp diff --git a/src/cleveragents/tui/widgets/__init__.py b/src/cleveragents/tui/widgets/__init__.py index 51776ce71..dfcdcbf8f 100644 --- a/src/cleveragents/tui/widgets/__init__.py +++ b/src/cleveragents/tui/widgets/__init__.py @@ -1,5 +1,6 @@ """Widget collection for CleverAgents TUI.""" +from cleveragents.tui.widgets.conversation_stream import ConversationStream from cleveragents.tui.widgets.help_panel_overlay import HelpPanelOverlay from cleveragents.tui.widgets.persona_bar import PersonaBar from cleveragents.tui.widgets.prompt import PromptInput, PromptSubmitted @@ -7,6 +8,7 @@ from cleveragents.tui.widgets.reference_picker import ReferencePickerOverlay from cleveragents.tui.widgets.slash_command_overlay import SlashCommandOverlay __all__ = [ + "ConversationStream", "HelpPanelOverlay", "PersonaBar", "PromptInput", diff --git a/src/cleveragents/tui/widgets/conversation_stream.py b/src/cleveragents/tui/widgets/conversation_stream.py new file mode 100644 index 000000000..6ab23dd21 --- /dev/null +++ b/src/cleveragents/tui/widgets/conversation_stream.py @@ -0,0 +1,217 @@ +"""Conversation stream block catalog and text renderer.""" + +from __future__ import annotations + +import importlib +from collections.abc import Iterable +from dataclasses import dataclass +from enum import StrEnum +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 ConversationBlockType(StrEnum): + """Supported conversation stream block types from the TUI spec.""" + + WELCOME = "Welcome" + USER_INPUT = "UserInput" + ACTOR_RESPONSE = "ActorResponse" + ACTOR_THOUGHT = "ActorThought" + TOOL_CALL = "ToolCall" + PLAN_PROGRESS = "PlanProgress" + DIFF_VIEW = "DiffView" + TERMINAL_EMBED = "TerminalEmbed" + SHELL_RESULT = "ShellResult" + NOTE = "Note" + + +@dataclass(frozen=True, slots=True) +class ConversationBlockSpec: + """Static specification metadata for one block type.""" + + block_type: ConversationBlockType + visual_treatment: str + source: str + expandable: bool = False + + +CONVERSATION_BLOCK_CATALOG: tuple[ConversationBlockSpec, ...] = ( + ConversationBlockSpec( + ConversationBlockType.WELCOME, + "ASCII art + instructions in $text-success", + "App startup (first message)", + ), + ConversationBlockSpec( + ConversationBlockType.USER_INPUT, + "Left border $secondary, 15% background tint, Markdown", + "User prompt submission", + ), + ConversationBlockSpec( + ConversationBlockType.ACTOR_RESPONSE, + "Streaming Markdown with syntax-highlighted code fences", + "session.message events", + ), + ConversationBlockSpec( + ConversationBlockType.ACTOR_THOUGHT, + "$primary-muted 20% bg, max 10 lines (expandable), italic", + "Actor reasoning", + expandable=True, + ), + ConversationBlockSpec( + ConversationBlockType.TOOL_CALL, + "Expandable: icon + status pill header, collapsible content", + "tool.invoked / tool.completed", + expandable=True, + ), + ConversationBlockSpec( + ConversationBlockType.PLAN_PROGRESS, + "Grid layout with status icons per step", + "Plan phase changes", + ), + ConversationBlockSpec( + ConversationBlockType.DIFF_VIEW, + "Unified or side-by-side diff, syntax highlighting", + "Tool results with diffs", + expandable=True, + ), + ConversationBlockSpec( + ConversationBlockType.TERMINAL_EMBED, + "Bordered terminal, $primary 50% border, green/red tint", + "Shell or tool terminal output", + ), + ConversationBlockSpec( + ConversationBlockType.SHELL_RESULT, + "Left border $primary, 4% foreground bg", + "User shell command (!) output", + ), + ConversationBlockSpec( + ConversationBlockType.NOTE, + "Semantic: info ($primary), warning ($warning), error ($error)", + "System notifications", + ), +) + +_CATALOG_BY_TYPE = {spec.block_type: spec for spec in CONVERSATION_BLOCK_CATALOG} +EXPANDABLE_BLOCK_TYPES = frozenset( + spec.block_type for spec in CONVERSATION_BLOCK_CATALOG if spec.expandable +) + +DEFAULT_WELCOME_TEXT = "Welcome to CleverAgents\nType message, /command, or !shell ..." + + +@dataclass(slots=True) +class ConversationBlock: + """Single rendered block in the conversation stream.""" + + block_type: ConversationBlockType + text: str + title: str = "" + variant: str = "" + + @classmethod + def welcome(cls, text: str = DEFAULT_WELCOME_TEXT) -> ConversationBlock: + return cls(block_type=ConversationBlockType.WELCOME, text=text) + + @classmethod + def user_input(cls, text: str) -> ConversationBlock: + return cls(block_type=ConversationBlockType.USER_INPUT, text=text) + + @classmethod + def note(cls, text: str, *, variant: str = "info") -> ConversationBlock: + return cls(block_type=ConversationBlockType.NOTE, text=text, variant=variant) + + @classmethod + def shell_result(cls, command: str, output: str) -> ConversationBlock: + body = f"$ {command}\n{output}" if output else f"$ {command}\n(empty output)" + return cls(block_type=ConversationBlockType.SHELL_RESULT, text=body) + + +def conversation_block_names() -> list[str]: + """Return the spec-defined block type names in display order.""" + + return [spec.block_type.value for spec in CONVERSATION_BLOCK_CATALOG] + + +def conversation_block_spec(block_type: ConversationBlockType) -> ConversationBlockSpec: + """Return catalog metadata for a specific block type.""" + + return _CATALOG_BY_TYPE[block_type] + + +def render_conversation_block(block: ConversationBlock) -> str: + """Render one block into a plain-text representation.""" + + spec = conversation_block_spec(block.block_type) + header = f"[{spec.block_type.value}" + if block.variant: + header += f":{block.variant}" + if block.title: + header += f" {block.title}" + header += "]" + body = block.text.strip("\n") + if not body and block.block_type is ConversationBlockType.WELCOME: + body = DEFAULT_WELCOME_TEXT + return header if not body else f"{header}\n{body}" + + +def render_conversation_blocks(blocks: Iterable[ConversationBlock]) -> str: + """Render a full conversation stream into plain text.""" + + rendered = [render_conversation_block(block) for block in blocks] + return "\n\n".join(rendered) + + +class ConversationStream(_StaticBase): + """Static conversation stream widget backed by typed blocks.""" + + def __init__( + self, + *args: object, + blocks: Iterable[ConversationBlock] | None = None, + **kwargs: object, + ) -> None: + super().__init__(*args, **kwargs) + self._blocks = list(blocks or []) + self._text = "" + self.sync_text() + + @property + def blocks(self) -> list[ConversationBlock]: + """Return a shallow copy of the current block list.""" + + return list(self._blocks) + + def set_blocks(self, blocks: Iterable[ConversationBlock]) -> None: + """Replace the conversation stream with a new block sequence.""" + + self._blocks = list(blocks) + self.sync_text() + + def append_block(self, block: ConversationBlock) -> None: + """Append a block to the stream and refresh the rendered text.""" + + self._blocks.append(block) + self.sync_text() + + def sync_text(self) -> None: + """Re-render the current block list into the widget text.""" + + self._text = render_conversation_blocks(self._blocks) + self.update(self._text) -- 2.52.0