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)