"""Chat panel widget for the TUI."""
from __future__ import annotations
import json
from typing import Any
from rich.console import Group, RenderableType
from rich.text import Text
from textual.app import ComposeResult
from textual.containers import ScrollableContainer
from textual.widgets import Static
from clients.terminal.tui.chat_model import ChatItem, ChatModel
from clients.terminal.tui.renderers import default_registry
from clients.terminal.tui.settings import get_tui_settings
from clients.terminal.tui.themes import get_active_theme
def _item_msg(item: ChatItem) -> dict[str, Any]:
"""Map a ChatItem to the msg dict its renderer expects."""
kind = item.kind
if kind == "user_message":
return {"type": "user_message", "content": item.content}
if kind == "assistant_message":
return {"type": "assistant_message", "content": item.content}
if kind == "thinking_block":
return {
"type": "thinking_block",
"content": item.content,
"is_subagent": item.meta.get("is_subagent", False),
}
if kind == "tool_started":
return {"type": "tool_started", **item.meta}
if kind == "tool_call":
return {"type": "tool_call", **item.meta}
if kind == "error":
return {"type": "error", "message": item.content}
if kind == "status":
return {"type": "status", "content": item.content}
if kind == "planning_status":
return {
"type": "planning_status",
"label": item.content,
"phase": item.meta.get("phase"),
"is_subagent": item.meta.get("is_subagent", False),
}
if kind == "plan_ready":
return {
"type": "plan_ready",
"plan": item.content,
"is_subagent": item.meta.get("is_subagent", False),
}
return {"type": "plain", "content": item.content}
def _signature(msg: dict[str, Any]) -> str:
"""Stable serialized form of a renderer msg, for cache invalidation.
Only an item whose rendered inputs changed should miss the cache; this
captures content + meta so in-place mutations (e.g. a streaming assistant
bubble growing token by token, or a rolling planning_status line) invalidate
exactly their own entry.
"""
return json.dumps(msg, sort_keys=True, default=str)
class ChatPanel(ScrollableContainer):
"""Scrollable conversation panel."""
DEFAULT_CSS = """
ChatPanel {
border: solid $tui-border;
background: $tui-surface;
color: $tui-text;
padding: 0 1;
height: 1fr;
width: 2fr;
}
ChatPanel Static {
color: $tui-text;
}
"""
def __init__(self) -> None:
super().__init__()
self._model = ChatModel()
self._registry = default_registry()
self._items_container = Static("")
# Renderable cache keyed by id(item) -> (signature, renderable). On a
# per-token stream_delta only the streaming item's signature changes,
# so every other visible item is reused from here instead of being
# rebuilt (and re-parsed as markdown) on every token.
self._chat_render_cache: dict[int, tuple[str, RenderableType]] = {}
def compose(self) -> ComposeResult:
yield self._items_container
def on_mount(self) -> None:
self._items_container.styles.height = "auto"
def add_user_message(self, text: str) -> None:
self._model.add_user_message(text)
self._refresh()
def load_history(self, messages: list[dict]) -> None:
"""Populate the chat panel with a session's stored messages on resume."""
self._model.load_history(messages)
self._chat_render_cache.clear()
self._refresh()
def handle_ws_event(self, msg: dict) -> None:
self._model.handle_ws_event(msg)
self._refresh()
def clear(self) -> None:
"""Reset the chat model and redraw an empty conversation."""
self._model.items.clear()
self._model._current_assistant = None
self._model._current_thinking = None
self._chat_render_cache.clear()
self._refresh()
def _max_visible_items(self) -> int:
return get_tui_settings().max_visible_items
def _truncation_hint(self, hidden: int) -> RenderableType:
theme = get_active_theme()
return Text(
f"… {hidden} earlier messages not shown",
style=theme.text.hex,
)
def _refresh(self) -> None:
items = self._model.items
limit = self._max_visible_items()
hidden = max(0, len(items) - limit)
# Slice the last `limit` items when over the cap; otherwise keep all.
visible = items[-limit:] if hidden else items
renderables: list[RenderableType] = []
if hidden:
renderables.append(self._truncation_hint(hidden))
seen_ids: set[int] = set()
for item in visible:
seen_ids.add(id(item))
msg = _item_msg(item)
sig = _signature(msg)
cached = self._chat_render_cache.get(id(item))
if cached is not None and cached[0] == sig:
renderables.append(cached[1])
else:
rendered = self._registry.render(msg)
self._chat_render_cache[id(item)] = (sig, rendered)
renderables.append(rendered)
# Drop cache entries for items no longer in the model so memory stays
# bounded by the live history, not by everything ever shown.
stale = [key for key in self._chat_render_cache if key not in seen_ids]
for key in stale:
self._chat_render_cache.pop(key, None)
self._items_container.update(Group(*renderables))
self.scroll_end(animate=False)