feat(tui): implement full conversation stream block type catalog #1247
@@ -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.
|
||||
|
||||
@@ -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()])
|
||||
@@ -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"
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user