Research – ChatContainer + MessageWidget Refactoring
Date: 2026-01-27 17:45:00 Owner: Claude Phase: Research Git Commit: 601a7c1c Last Updated: 2026-01-27 17:45:00
Goal
Validate the proposed refactoring to replace the current RichLog + streaming_output dual-display system with a unified ChatContainer + MessageWidget architecture. Identify all touch points, dependencies, and risks.
Executive Summary
The proposed refactoring is architecturally sound but involves 33 touch points across 4 files. The current system uses:
RichLogas the permanent chat history (write-only, append-based)streaming_output(Static widget) as an ephemeral overlay for live streaming
The proposed ChatContainer + MessageWidget unifies these into a single scrollable container where messages can be updated in-place during streaming, then finalized. This eliminates the dual-display complexity but requires careful migration of all write sites.
Findings
Current Architecture
┌─────────────────────────────────────────────────────────┐
│ ResourceBar (fixed) │
├─────────────────────────────────────────────────────────┤
│ #viewport Container │
│ ├── RichLog (scrollable, permanent history) │
│ └── LoadingIndicator (overlay) │
├─────────────────────────────────────────────────────────┤
│ #streaming-output Static (ephemeral, docked) │
├─────────────────────────────────────────────────────────┤
│ Editor (fixed) │
│ AutoComplete widgets (overlay) │
├─────────────────────────────────────────────────────────┤
│ StatusBar (fixed) │
└─────────────────────────────────────────────────────────┘
Key Files:
| File | Role |
|---|---|
src/tunacode/ui/app.py |
Main TUI app, owns RichLog and streaming_output |
src/tunacode/ui/renderers/agent_response.py |
Renders streaming vs finalized panels |
src/tunacode/ui/renderers/panels.py |
Tool panel rendering system |
src/tunacode/ui/commands/__init__.py |
Command output to RichLog |
src/tunacode/ui/welcome.py |
Welcome screen to RichLog |
Existing Widget Inventory
Location: src/tunacode/ui/widgets/
| Widget | Base Class | Purpose |
|---|---|---|
Editor |
Input |
Main text input with paste buffer |
ResourceBar |
Static |
Model/token/context display |
StatusBar |
Horizontal |
Action status display |
CommandAutoComplete |
AutoComplete |
Slash command completion |
FileAutoComplete |
AutoComplete |
File path completion |
No existing chat/message widgets - the codebase uses Textual's RichLog directly.
Touch Points Requiring Changes
app.py (24 locations)
Widget Declaration & Instantiation:
# Line 90 - Type annotation
self.rich_log: RichLog
# Line 98 - Constructor
self.rich_log = RichLog(wrap=True, markup=True, highlight=True, auto_scroll=True)
# Line 106 - Composition
yield self.rich_log
Write Operations (21 sites):
| Line | Content Type | Method |
|---|---|---|
| 166 | Debug logs | write(renderable) |
| 182 | Error panel | write(error_renderable) |
| 218 | Error panel | write(error_renderable) |
| 242 | Blank line | write("") |
| 243 | Agent response | write(panel, expand=True) |
| 264 | Blank line | write("") |
| 270 | User message | write(user_block) |
| 282 | Tool panel | write(panel, expand=True) |
| 301 | System notice | write(notice_text) |
| 328 | Replayed user | write(user_block) |
| 330 | Replayed label | write(Text("agent:")) |
| 331 | Replayed agent | write(Markdown(content)) |
| 404 | Shell output | write(renderable) |
Width Access (3 sites):
# Line 265 - User message width
render_width = max(1, self.rich_log.size.width - 2)
# Lines 287, 289 - Tool panel width
self.rich_log.content_region.width
self.rich_log.size.width
Scroll Control (1 site):
# Line 349 - During streaming
self.rich_log.scroll_end()
commands/init.py (11 locations)
| Line | Content | Purpose |
|---|---|---|
| 69 | Table | Help command |
| 79 | clear() |
Clear command |
| 132 | Text | Debug status |
| 163 | Text | Model config error |
| 376 | clear() |
Resume clear |
| 385 | Text | Resume loaded message |
| 411-414 | Text | Version info (4 lines) |
| 454 | Text | Update success |
| 458 | Text | Update error |
| 460 | Text | Update exception |
welcome.py (3 locations)
| Line | Content | Purpose |
|---|---|---|
| 32 | Text | Logo load error |
| 34 | Logo | ASCII art logo |
| 61 | Text | Welcome message |
Streaming Callback Flow
LLM Provider → pydantic-ai events
↓
streaming.py:351 → await streaming_callback(delta_text)
↓
app.py:333 → streaming_callback(chunk)
↓
app.py:339 → self.current_stream_text += chunk (immediate accumulation)
↓
app.py:345-350 → throttle check (100ms)
↓
app.py:356 → render_agent_streaming() → streaming_output.update(panel)
↓
[On completion]
↓
app.py:236-243 → render_agent_response() → rich_log.write(panel, expand=True)
↓
app.py:225-226 → streaming_output.update("") + remove_class("active")
Key Insight: Streaming uses Static.update() (in-place replacement), not RichLog.write() (append). The proposed MessageWidget.append() pattern matches this better.
API Surface Required for ChatContainer
class ChatContainer(ScrollableContainer):
"""Must provide these APIs for compatibility."""
# Core write API
def write(self, renderable: RenderableType, *, expand: bool = False) -> None: ...
# Dimension APIs
@property
def size(self) -> Size: ... # Total widget dimensions
@property
def content_region(self) -> Region: ... # Available content area
# Control APIs
def clear(self) -> None: ...
def scroll_end(self, *, animate: bool = True) -> None: ...
# New streaming APIs
def start_assistant_message(self) -> MessageWidget: ...
def stream_chunk(self, chunk: str) -> None: ...
Width Calculation Anti-Pattern
Critical: expand=True does NOT mean terminal width!
# WRONG assumption
panel_width = terminal_width # NO!
# CORRECT
panel_width = scrollable_content_region.width # Excludes padding + scrollbar (4-8 chars narrower)
The proposed refactoring must preserve this behavior. MessageWidget should use its parent container's content_region.width, not app.size.width.
Proposed Architecture
┌─────────────────────────────────────────────────────────┐
│ ResourceBar (fixed) │
├─────────────────────────────────────────────────────────┤
│ #viewport Container │
│ └── ChatContainer (id="chat-container") │
│ ├── MessageWidget(role="user") │
│ ├── MessageWidget(role="assistant", streaming) │
│ ├── MessageWidget(role="tool") │
│ ├── MessageWidget(role="system") │
│ └── LoadingIndicator (overlay) │
├─────────────────────────────────────────────────────────┤
│ Editor (fixed) │
│ AutoComplete widgets (overlay) │
├─────────────────────────────────────────────────────────┤
│ StatusBar (fixed) │
└─────────────────────────────────────────────────────────┘
Key Change: #streaming-output is eliminated. Streaming happens in-place within MessageWidget.
Migration Mapping
| Current Pattern | New Pattern |
|---|---|
rich_log.write(user_block) |
chat.add_message("user", content) |
rich_log.write(panel, expand=True) |
chat.add_message("assistant").set_content(panel) |
streaming_output.update(panel) |
chat._current_message.append(chunk) |
rich_log.write(tool_panel) |
chat.add_message("tool").set_content(panel) |
rich_log.write(notice) |
chat.add_message("system", notice) |
rich_log.clear() |
chat.clear() |
rich_log.scroll_end() |
chat.scroll_end() |
Effort Estimate
| Task | Files Changed | Complexity | Risk |
|---|---|---|---|
Create widgets/chat.py |
1 new | Low | Low |
Refactor app.py compose/streaming |
1 | Medium | Medium |
Update message handlers in app.py |
1 | Low | Low |
Update commands/__init__.py |
1 | Low | Low |
Update welcome.py |
1 | Low | Low |
Update CSS (.message-* classes) |
1 | Low | Low |
| Update session replay | 1 | Low | Low |
| Testing & edge cases | N/A | Medium | Medium |
Total: ~1 day focused work (as proposed in the ticket)
Risks & Mitigations
Risk 1: Width Calculation Regressions
Risk: Tool panels render too narrow or too wide after migration.
Mitigation:
- Preserve
tool_panel_max_width()logic inapp.py - Pass explicit width to
MessageWidget._render_content()rather than relying onexpand=True
Risk 2: Scroll Behavior Changes
Risk: Auto-scroll during streaming breaks or becomes janky.
Mitigation:
ChatContainer.stream_chunk()must callscroll_end()after each update- Test with long responses that exceed viewport height
Risk 3: Performance Degradation
Risk: Creating new MessageWidget for each message is slower than RichLog.write().
Mitigation:
MessageWidgetshould useStatic.update()internally (same as current streaming_output)- Avoid re-mounting widgets - update content in-place
- Maintain 100ms throttle on display updates
Risk 4: Backward Compatibility
Risk: Commands that access app.rich_log directly will break.
Mitigation:
- Keep
self.rich_logas an alias toself.chatduring transition, or - Update all command files to use new
chatAPI
Knowledge Gaps
- How does
MessageWidget.append()affect screen reader accessibility? - What's the memory impact of keeping many
MessageWidgetinstances vs RichLog internal buffer? - Does
ScrollableContainer.scroll_end()have same signature asRichLog.scroll_end()?
References
Implementation Files
src/tunacode/ui/app.py:90-424- Main TUI app with RichLog usagesrc/tunacode/ui/renderers/agent_response.py:86-223- Streaming vs finalized rendererssrc/tunacode/ui/renderers/panels.py:479-534- Tool panel smart routingsrc/tunacode/ui/commands/__init__.py- Command RichLog writessrc/tunacode/ui/welcome.py:27-61- Welcome screen RichLog writes
Related Research
memory-bank/research/2026-01-27_16-11-11_streaming_and_panels.md- Prior streaming architecture research
Widget References
src/tunacode/ui/widgets/__init__.py- Current widget exportssrc/tunacode/ui/widgets/editor.py- Example of custom widget extending Textual base
CSS Files
src/tunacode/ui/styles/layout.tcss- RichLog and streaming-output stylessrc/tunacode/ui/styles/panels.tcss- Panel stylingsrc/tunacode/ui/styles/theme-nextstep.tcss- NeXTSTEP theme
Recommendations
Start with the minimal implementation from the proposal:
class MessageWidget(Static): def append(self, chunk: str) -> None: self.content += chunk self.update(Panel(Markdown(self.content), title=self.role))Add width parameter to
_render_content():def _render_content(self, width: int | None = None) -> None: panel = render_agent_streaming(self.content, 0, "", width=width) self.update(panel)Preserve compatibility layer initially:
# In app.py @property def rich_log(self) -> ChatContainer: """Backward compat alias.""" return self.chatUpdate tests in parallel with implementation to catch regressions early.
Follow NeXTSTEP-ui skill for any visual changes - the MessageWidget styling must match existing panel aesthetics.