"""
Chat message block widget.
File: klea_utils/ui/web/nicegui/components/chat_bubble.py
Copyright 2026 Ankur Sinha
Author: Ankur Sinha <sanjay DOT ankur AT gmail DOT com>
"""
from nicegui import ui
from klea_utils.ui.glyphs import UNICODE_GLYPHS
from klea_utils.ui.linkify import linkify_html
#: Browser output is always Unicode-capable, so the web reuses the TUI's glyph
#: table directly (the terminal detection / ASCII fallback does not apply); one
#: shared source keeps the role markers identical in both frontends.
GLYPHS = UNICODE_GLYPHS
#: Recognised transcript roles; anything else falls back to ``agent``.
_ROLES = ("user", "agent", "tool", "system")
#: MIME types rendered as a unified diff (``text/x-diff`` is the de-facto
#: type; ``text/x-patch`` is git's). Rendered as a fenced block, which
#: markdown2/Pygments highlights with its diff lexer.
_DIFF_MIMES = ("text/x-diff", "text/x-patch")
def _is_code_mime(mime: str) -> bool:
"""Return whether *mime* is rendered by ``ui.code`` (not markdown)."""
return mime in _DIFF_MIMES or mime.startswith("text/x-")
def _render_body(
role: str,
mime: str,
data: str,
text: str,
*,
notice: bool = False,
notice_glyph: bool = False,
) -> None:
"""Render a block body according to its role and MIME type.
A **user** message is shown verbatim -- no Markdown decoration, so literal
underscores, ``#``, lists and newlines in the user's input (URLs, paths,
pasted code) are preserved; bare URLs stay clickable. A **notice** is
likewise rendered as plain, escaped text, mirroring the TUI, where a notice
is plain Rich text and only agent/tool bodies go through a Markdown
renderer. The pending glyph normally marks the notice's header line; a
notice *without* a header carries it here instead (``notice_glyph``), so
every notice shows exactly one marker. Other roles use the MIME type:
diffs and code use NiceGUI's native ``ui.code`` (syntax highlighting and a
copy button); text uses ``ui.markdown``.
"""
if role == "user":
ui.html(linkify_html(text), sanitize=True).classes(
"whitespace-pre-wrap break-words"
)
elif notice:
body = f"{GLYPHS.pending} {text}" if notice_glyph else text
ui.html(linkify_html(body), sanitize=True).classes(
"whitespace-pre-wrap break-words"
)
elif mime in _DIFF_MIMES:
ui.code(data or text, language="diff").classes("tool-code")
elif mime == "text/markdown" or not mime:
ui.markdown(data or text, extras=["fenced-code-blocks", "tables", "alerts"])
elif mime.startswith(("image/", "audio/")):
# Inline media rendering is deferred; show the text fallback note.
ui.markdown(text, extras=["fenced-code-blocks", "tables", "alerts"])
elif mime.startswith("text/x-"):
language = mime.split("/", 1)[1][2:]
ui.code(data or text, language=language).classes("tool-code")
else:
ui.markdown(data or text, extras=["fenced-code-blocks", "tables", "alerts"])
[docs]
class ChatBubble(ui.element):
"""A full-width chat transcript block with built-in actions.
All roles span the full chat width (opencode-style); the surface colour
distinguishes them (semantic ``chat-bubble--<role>`` classes in
``theme.py``), so user blocks read as tinted prompts, agent blocks as
plain document text, and tool blocks as neutral blocks. Each block has
an optional header, collapsible text content, a timestamp, a copy
button, and an expand / collapse toggle -- all flowing naturally inside
the block (no CSS hacks).
Usage inside a ``@ui.refreshable``::
ChatBubble(
text="Hello", stamp="12:00", role="user",
collapsed=False, idx=0,
on_expand=lambda: print("toggle"),
on_copy=lambda: print("copy"),
)
"""
def __init__(
self,
text: str,
stamp: str,
role: str,
collapsed: bool,
idx: int,
header: str = "",
mime: str = "",
data: str = "",
meta: dict | None = None,
is_error: bool = False,
on_expand=None,
on_copy=None,
) -> None:
"""Build the block.
:param text: Message text (markdown); also the fallback body.
:param stamp: Timestamp string.
:param role: ``"user"``, ``"agent"``, ``"tool"``, ``"system"`` (a
client command's output), or ``"notice"`` (a compact progress
note, rendered with the system surface); unknown values fall back
to ``"agent"``.
:param collapsed: ``True`` if the text is collapsed to 4 lines.
:param idx: Message index (used for expand/collapse tracking).
:param header: Optional small bold header, e.g. a tool block's
name and change counts.
:param mime: MIME type of ``data``; selects the renderer (``text/x-diff``
-> coloured diff, ``text/x-<lang>`` -> code, media deferred).
:param data: The renderable payload (text or base64).
:param meta: Extra entry metadata (path, language, ...); reserved for
renderers that need it.
:param is_error: Whether a ``tool`` block's result is an error; the
header is rendered in the theme's negative colour.
:param on_expand: Callable with no args, fired on expand/collapse click.
:param on_copy: Callable with no args, fired on copy click.
"""
super().__init__("div")
is_notice = role == "notice"
if is_notice:
# A progress notice is a muted, system-style transcript block. It
# keeps its notice identity for rendering: notices are plain text
# (see ``_render_body``), never Markdown, so underscores and other
# markup in tool names stay literal (mirrors the TUI).
role = "system"
elif role not in _ROLES:
role = "agent"
self.classes("w-full flex flex-col")
with (
self,
ui.element("div").classes(
"flex w-full flex-col rounded-lg p-3 gap-1 "
f"chat-bubble chat-bubble--{role}"
),
):
if header:
failed = role == "tool" and is_error
header_classes = "text-xs font-bold"
header_classes += " tool-header--error" if failed else " text-grey-6"
# Mirror the TUI transcript: the tick / cross are reserved for
# tool-call headers (success/failure), coloured by the same role
# tokens; any other header -- a notice's node label -- takes the
# pending marker, exactly as the TUI's ``_render_notice`` does.
if role == "tool":
marker = GLYPHS.failure if failed else GLYPHS.success
else:
marker = GLYPHS.pending
ui.label(f"{marker} {header}").classes(header_classes)
if role == "tool":
# A tool block collapses to a one-line preview (the header is
# the title), expandable; long diffs/command output stay hidden.
text_cls = "tool-block-collapsed" if collapsed else "msg-expanded"
else:
text_cls = "msg-collapsed" if collapsed else "msg-expanded"
# ``ui.markdown`` bodies get the chat markdown styling; ``ui.code``
# bodies render their own box, so adding ``chat-markdown`` there
# would stack our ``pre``/``code`` rules on top of it. A user
# message is plain text (no markdown styling).
classes = f"text-left {text_cls}"
if role != "user" and not is_notice and not _is_code_mime(mime):
classes += " chat-markdown"
with ui.element("div").classes(classes):
_render_body(
role,
mime,
data,
text,
notice=is_notice,
notice_glyph=is_notice and not header,
)
with ui.row().classes("flex flex-row justify-start items-center gap-1"):
ui.label(stamp).classes("text-xs text-grey-5")
if on_copy:
ui.button(icon="content_copy").props(
"flat dense round size=sm"
).classes("icon-btn").on("click", on_copy)
if on_expand:
icon = "expand_less" if not collapsed else "expand_more"
ui.button(icon=icon).props("flat dense round size=sm").classes(
"icon-btn"
).on("click", on_expand)