"""Chat panel widget for the TUI."""
from __future__ import annotations
import json
from typing import Any
from rich.console import RenderableType
from rich.segment import Segment
from rich.text import Text
from textual.app import ComposeResult
from textual.containers import ScrollableContainer
from textual.geometry import Offset
from textual.selection import Selection
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),
}
if kind == "turn_meta":
return {"type": "turn_meta", "elapsed_seconds": item.meta.get("elapsed_seconds")}
if kind == "context_summary":
return {
"type": "context_summary",
"content": item.content,
"messages_before": item.meta.get("messages_before"),
"messages_after": item.meta.get("messages_after"),
}
if kind == "recall":
return {"type": "recall_update", **item.meta}
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 re-render; 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 widget.
"""
return json.dumps(msg, sort_keys=True, default=str)
class _ChatItemView(Static):
"""One chat item as its own widget.
Making each message a distinct widget is what keeps streaming cheap: a
per-token ``stream_delta`` only re-renders *this* widget (via
``Static.update``), not the whole conversation — Textual refreshes just the
changed region instead of re-laying-out every visible item.
"""
DEFAULT_CSS = """
_ChatItemView {
height: auto;
width: 1fr;
padding: 0;
}
"""
def __init__(self, renderable: RenderableType, *, item: ChatItem, signature: str) -> None:
super().__init__(renderable)
self._item: ChatItem = item
self._signature: str = signature
# The raw rich renderable (Panel/Text/Group/...) this widget shows, kept
# alongside Textual's wrapped Visual so get_selection can re-render it to
# text and strip the bubble chrome. Updated together with the Visual.
self._rich_renderable: RenderableType = renderable
def maybe_update(self, item: ChatItem, registry) -> bool:
"""Re-render only if the item's signature changed. Returns True if updated."""
msg = _item_msg(item)
sig = _signature(msg)
if sig == self._signature:
return False
self._item = item
self._signature = sig
rendered = registry.render(msg)
self._rich_renderable = rendered
self.update(rendered)
return True
# ── text selection ───────────────────────────────────────────────────────
def _rendered_lines(self) -> list[str]:
"""Re-render this item's renderable to plain text lines, the same way
Textual draws it (same console, same width → identical line layout to
what is on screen). Used by get_selection to align mouse offsets with
the rendered characters before stripping the bubble chrome.
"""
renderable = self._rich_renderable
if renderable is None or not self.size.width:
return []
app = self.app
console = app.console
width = self.size.width
options = app.console_options.update(highlight=False, width=width, height=None)
segments = console.render(renderable, options.update_width(width))
return ["".join(seg.text for seg in line) for line in Segment.split_lines(segments)]
@staticmethod
def _denoise(lines: list[str]) -> tuple[list[str], int, bool]:
"""Split rendered lines into clean content + the column content starts at.
Chat items render rich ``Panel``s (rounded borders + "Navi"/"You"
titles). The outer panel adds one top and one bottom border line and a
``│ `` / `` │`` gutter on every content line; stripping those gives the
selectable text the user actually wants to copy. Non-panel renderables
(plain ``Text``/``Group`` — status lines, filesystem tool output,
turn_meta) have no chrome: their lines are kept verbatim with no column
shift, so the selection maps 1:1 onto what is on screen.
Returns ``(content_lines, content_col, is_panel)``: ``content_lines``
is the clean text (one entry per content row, trailing panel padding
stripped), ``content_col`` is how far in a rendered line the content
begins (0 for plain, pad+2 for a panel), ``is_panel`` flags the layout.
"""
n = len(lines)
def is_border(line: str) -> bool:
s = line.lstrip(" ")
return bool(s) and s[0] in "╭╮╰╯┌┐└┘"
if n >= 3 and is_border(lines[0]) and is_border(lines[-1]):
spans: list[tuple[int, str]] | None = []
for line in lines[1:-1]:
left = line.find("│")
if left < 0:
spans = None
break
right = line.rfind("│")
content_col = left + 2
end = right if right > left else len(line)
spans.append((content_col, line[content_col:end].rstrip()))
if spans and all(s[0] == spans[0][0] for s in spans):
return [s[1] for s in spans], spans[0][0], True
return list(lines), 0, False
def get_selection(self, selection: Selection) -> tuple[str, str] | None:
"""Clean, chrome-free text under the selection.
The base ``Widget.get_selection`` only extracts text from widgets whose
render is ``Text``/``Content``; our chat items render rich ``Panel``s, so
it returns ``None`` and a drag-select over a message copies nothing. We
re-render the item to text (line layout identical to the screen), strip
the outer panel borders and gutters, and remap the mouse offsets onto the
clean content so a partial-line selection still lands on the right
characters. ``Ctrl+C`` then copies the result via OSC 52.
"""
lines = self._rendered_lines()
if not lines:
return None
content_lines, content_col, is_panel = self._denoise(lines)
if not content_lines:
return None
n_render = len(lines)
n_content = len(content_lines)
def map_point(offset: Offset | None) -> Offset | None:
if offset is None:
return None
y, x = offset.y, offset.x
if is_panel:
if y <= 0:
return Offset(x=0, y=0)
if y >= n_render - 1:
return Offset(x=len(content_lines[-1]), y=n_content - 1)
cy = y - 1
else:
cy = y
cy = max(0, min(cy, n_content - 1))
cx = max(0, x - content_col)
cx = min(cx, len(content_lines[cy]))
return Offset(x=cx, y=cy)
try:
mapped = Selection(map_point(selection.start), map_point(selection.end))
text = mapped.extract("\n".join(content_lines))
except Exception:
return None
return text, "\n"
class ChatPanel(ScrollableContainer):
"""Scrollable conversation panel.
Each visible message is a separate ``_ChatItemView`` child widget rather
than one big ``Static`` rebuilt on every event. On a per-token stream the
only widget that re-renders is the streaming bubble; everything else is
untouched. Items are mounted/unmounted incrementally as the model grows and
as the visible-window cap truncates the oldest.
"""
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()
# Truncation hint shown at the top when the history exceeds the cap.
self._hint_view = Static("", id="chat-truncation-hint")
self._hint_view.styles.display = "none"
# Number of older items currently truncated out of view (0 = none).
self._hidden_count = 0
# item -> view widget, keyed by id(item). Only holds visible items, so
# it stays bounded by the window cap, not by total history length.
self._widgets: dict[int, _ChatItemView] = {}
def compose(self) -> ComposeResult:
yield self._hint_view
def add_user_message(self, text: str) -> None:
self._model.add_user_message(text)
self._sync()
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._rebuild_all()
def handle_ws_event(self, msg: dict) -> None:
self._model.handle_ws_event(msg)
self._sync()
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._rebuild_all()
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 _rebuild_all(self) -> None:
"""Unmount every item widget and re-sync from scratch.
Used on session switch / clear, where the model is replaced wholesale:
the surviving-vs-new diff is meaningless when nothing survives.
"""
for widget in self._widgets.values():
widget.remove()
self._widgets.clear()
self._sync()
def _sync(self) -> None:
"""Reconcile mounted widgets with the model's visible window.
The model only ever appends new items and pops/purges existing ones —
it never reorders survivors — so new widgets always belong at the end
and a simple mount-at-end keeps the DOM in the right order. Surviving
items keep their widget and are only re-rendered if their signature
changed (in-place mutation, e.g. a growing stream).
"""
# Stick-to-bottom: only auto-follow new content if the user was already
# at the bottom. Without this, scroll_end() below yanks the view back
# down on every streamed token, so it is impossible to scroll up and
# read earlier messages while the agent responds. Capture this BEFORE
# any DOM change (truncation-hint toggle, mount, update) — those grow
# max_scroll_y, so is_vertical_scroll_end checked afterwards would be
# false for the "content just grew" case too and we'd never follow.
# Auto-follow resumes on its own when the user scrolls back to bottom.
stick_to_bottom = self.is_vertical_scroll_end
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
visible_ids = {id(it) for it in visible}
# Truncation hint at the top of the window.
self._hidden_count = hidden
if hidden:
self._hint_view.update(self._truncation_hint(hidden))
self._hint_view.styles.display = "block"
else:
self._hint_view.styles.display = "none"
# Drop widgets whose item left the visible window (truncated out, or
# purged from the model by stream_end / plan_ready).
for key in [k for k in self._widgets if k not in visible_ids]:
widget = self._widgets.pop(key)
widget.remove()
# Walk the visible window in order: mount new items (at the end) and
# re-render changed ones in place.
for item in visible:
key = id(item)
widget = self._widgets.get(key)
if widget is None:
msg = _item_msg(item)
widget = _ChatItemView(
self._registry.render(msg),
item=item,
signature=_signature(msg),
)
self._widgets[key] = widget
self.mount(widget)
else:
widget.maybe_update(item, self._registry)
if stick_to_bottom:
self.scroll_end(animate=False)