feat(tui): implement full conversation stream block type catalog #1247

Closed
brent.edwards wants to merge 1 commits from feature/m8-tui-conversation-block-catalog into master
9 changed files with 430 additions and 8 deletions
+7
View File
@@ -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.
+4
View File
@@ -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)
@@ -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()])
+1
View File
@@ -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) ---
@@ -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"
+20
View File
@@ -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
+27 -8
View File
@@ -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
+2
View File
@@ -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",
@@ -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)