The Gitea repo was initialised from an earlier snapshot, so main and the machine this runs on had drifted apart in 153 files before any UI work started. This commit brings the branch up to the local tree as it stood on 2026-08-15 21:31 (from cowork_local.7z), so the redesign that follows shows up as its own reviewable diff instead of being mixed in with the pre-existing divergence. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
508 lines
20 KiB
Python
508 lines
20 KiB
Python
"""Scrollable chat transcript built from message bubbles."""
|
|
from __future__ import annotations
|
|
|
|
import html
|
|
from pathlib import Path
|
|
|
|
from PySide6.QtCore import QPointF, Qt, QTimer, Signal
|
|
from PySide6.QtGui import QColor, QPainter, QPen, QPixmap
|
|
from PySide6.QtWidgets import (
|
|
QFrame, QHBoxLayout, QLabel, QPushButton, QScrollArea, QTextBrowser,
|
|
QVBoxLayout, QWidget,
|
|
)
|
|
|
|
from ..i18n import on_language_changed, tr
|
|
from ..theme import palette, resolve_theme
|
|
from ..config import CONFIG_DIR
|
|
from .osutil import is_image, open_folder, open_path
|
|
|
|
|
|
def _app_theme() -> str:
|
|
"""Resolve the current app theme (light or dark) from config."""
|
|
try:
|
|
import json
|
|
with open(CONFIG_DIR / "config.json", "r", encoding="utf-8") as f:
|
|
data = json.load(f)
|
|
return resolve_theme(data.get("theme", "dark"))
|
|
except Exception: # noqa: BLE001
|
|
return "dark"
|
|
|
|
|
|
def _p():
|
|
"""Design tokens for the theme in effect right now."""
|
|
return palette(_app_theme())
|
|
|
|
|
|
def _dot_color(role: str) -> str:
|
|
"""Timeline dot colour for a message role."""
|
|
p = _p()
|
|
return {
|
|
"user": p.role_user, "assistant": p.role_assistant, "tool": p.role_tool,
|
|
"error": p.role_error, "success": p.role_result,
|
|
}.get(role, p.text_faint)
|
|
|
|
|
|
class _TimelineGutter(QWidget):
|
|
"""The left rail of the point-conversation: a vertical connector line with a
|
|
role-colored dot near the top, so stacked messages read as a timeline
|
|
(Claude-Code style) instead of separate boxes."""
|
|
|
|
def __init__(self, role: str):
|
|
super().__init__()
|
|
self._role = role
|
|
self.setFixedWidth(22)
|
|
|
|
def set_role(self, role: str) -> None:
|
|
self._role = role
|
|
self.update()
|
|
|
|
def paintEvent(self, _e): # noqa: N802
|
|
p = QPainter(self)
|
|
p.setRenderHint(QPainter.Antialiasing)
|
|
tok = _p()
|
|
x = 11.0
|
|
cy = 15.0
|
|
# connector line (faint) running the full height → continuous rail
|
|
p.setPen(QPen(QColor(tok.border), 2))
|
|
p.drawLine(int(x), 0, int(x), self.height())
|
|
# a background ring lifts the dot off the line
|
|
p.setPen(Qt.NoPen)
|
|
p.setBrush(QColor(tok.bg))
|
|
p.drawEllipse(QPointF(x, cy), 7.5, 7.5)
|
|
p.setBrush(QColor(_dot_color(self._role)))
|
|
p.drawEllipse(QPointF(x, cy), 4.5, 4.5)
|
|
|
|
|
|
def _diff_legend(diff_text: str) -> str:
|
|
"""A small badge pair labeling what the colors mean: 'Before → After' for
|
|
an edit, or a single 'Added'/'Removed' badge for a pure create/delete —
|
|
so the before/after distinction is explicit, not just implied by color."""
|
|
has_add = any(ln.startswith("+") and not ln.startswith("+++") for ln in diff_text.splitlines())
|
|
has_del = any(ln.startswith("-") and not ln.startswith("---") for ln in diff_text.splitlines())
|
|
p = _p()
|
|
|
|
def pill(bg: str, fg: str, key: str) -> str:
|
|
return (f'<span style="background:{bg}; color:{fg}; padding:1px 8px; '
|
|
f'border-radius:4px; font-weight:600;">{html.escape(tr(key))}</span>')
|
|
|
|
if has_add and has_del:
|
|
badge = (pill(p.diff_del_bg, p.diff_del_fg, "chat.diff_before")
|
|
+ f'<span style="color:{p.text_muted};"> → </span>'
|
|
+ pill(p.diff_add_bg, p.diff_add_fg, "chat.diff_after"))
|
|
elif has_add:
|
|
badge = pill(p.diff_add_bg, p.diff_add_fg, "chat.diff_added")
|
|
elif has_del:
|
|
badge = pill(p.diff_del_bg, p.diff_del_fg, "chat.diff_removed")
|
|
else:
|
|
return ""
|
|
return f'<div style="margin-bottom:6px;">{badge}</div>'
|
|
|
|
|
|
def diff_to_html(diff_text: str) -> str:
|
|
"""Render a unified diff with GitHub/Claude-Code-style line coloring —
|
|
additions green, deletions red, hunk headers highlighted — plus an
|
|
explicit Before/After (or Added/Removed) legend, instead of a flat text
|
|
block, so a before/after edit reads at a glance. A brand-new file (an
|
|
empty 'before') naturally renders as all-green, which is exactly what
|
|
``difflib.unified_diff`` already produces for it."""
|
|
legend = _diff_legend(diff_text)
|
|
p = _p()
|
|
rows = []
|
|
for ln in diff_text.splitlines():
|
|
esc = html.escape(ln) if ln else " "
|
|
if ln.startswith(("+++", "---")):
|
|
rows.append(f'<div style="color:{p.text_muted};">{esc}</div>')
|
|
elif ln.startswith("@@"):
|
|
rows.append(f'<div style="color:{p.accent};">{esc}</div>')
|
|
elif ln.startswith("+"):
|
|
rows.append(f'<div style="background:{p.diff_add_bg}; color:{p.diff_add_fg};">{esc}</div>')
|
|
elif ln.startswith("-"):
|
|
rows.append(f'<div style="background:{p.diff_del_bg}; color:{p.diff_del_fg};">{esc}</div>')
|
|
else:
|
|
rows.append(f"<div>{esc}</div>")
|
|
body = "".join(rows) or "(no textual change)"
|
|
return (f'{legend}<div style="font-family:{p.font_mono}; font-size:12.5px; '
|
|
f'white-space:pre-wrap;">{body}</div>')
|
|
|
|
|
|
def format_status_line(base: str, ticks: int) -> str:
|
|
"""Animated status line for the working indicator, e.g. ``🤖 Running..`` and,
|
|
once the wait is a few seconds long, ``🤖 Running. · 5s`` — so a slow
|
|
synthesis clearly reads as still running. ``ticks`` advances every 500 ms."""
|
|
dots = "." * (ticks % 4)
|
|
secs = ticks // 2
|
|
suffix = f" · {secs}s" if secs >= 3 else ""
|
|
return f"{base}{dots}{suffix}"
|
|
|
|
|
|
class ThinkingIndicator(QWidget):
|
|
"""A small animated 'the agent is working' line shown while waiting for a
|
|
result, so a long wait never looks like a frozen / empty screen.
|
|
|
|
Renders a bot icon + status (e.g. ``🤖 Running…``) and, once the wait passes
|
|
a few seconds, the elapsed time — so a long synthesis clearly reads as still
|
|
running rather than stuck."""
|
|
|
|
def __init__(self):
|
|
super().__init__()
|
|
lay = QHBoxLayout(self)
|
|
lay.setContentsMargins(14, 2, 14, 4)
|
|
lay.setSpacing(0)
|
|
self._label = QLabel("")
|
|
self._label.setObjectName("hint")
|
|
lay.addWidget(self._label)
|
|
lay.addStretch(1)
|
|
self._base_key = "chat.running"
|
|
self._override: str | None = None
|
|
self._ticks = 0
|
|
self._timer = QTimer(self)
|
|
self._timer.setInterval(500)
|
|
self._timer.timeout.connect(self._tick)
|
|
self.setVisible(False)
|
|
on_language_changed(self._render)
|
|
|
|
def start(self, label_key: str = "chat.running") -> None:
|
|
self._base_key = label_key
|
|
self._override = None
|
|
self._ticks = 0
|
|
self._render()
|
|
self.setVisible(True)
|
|
if not self._timer.isActive():
|
|
self._timer.start()
|
|
|
|
def set_label(self, label_key: str) -> None:
|
|
if label_key != self._base_key:
|
|
self._base_key = label_key
|
|
self._override = None
|
|
self._render()
|
|
|
|
def set_progress_text(self, text: str) -> None:
|
|
"""Show an already-formatted, literal status line (e.g. a live "reading
|
|
page 12/40" or streamed command-output detail) instead of a translated
|
|
key — used for fine-grained progress within a single step."""
|
|
self._override = text
|
|
self._render()
|
|
|
|
def stop(self) -> None:
|
|
self._timer.stop()
|
|
self._override = None
|
|
self.setVisible(False)
|
|
|
|
def _tick(self) -> None:
|
|
self._ticks += 1
|
|
self._render()
|
|
|
|
def _render(self) -> None:
|
|
base = self._override if self._override is not None else tr(self._base_key)
|
|
self._label.setText(format_status_line(base, self._ticks))
|
|
|
|
|
|
class MessageBubble(QFrame):
|
|
"""One message; assistant/tool bubbles render markdown via QTextBrowser."""
|
|
|
|
def __init__(self, role: str, title: str = "", collapsible: bool = False,
|
|
collapsed: bool = True):
|
|
super().__init__()
|
|
self.role = role
|
|
self._text = ""
|
|
self._collapsible = collapsible
|
|
self._title = title
|
|
self._head = None
|
|
# Point-conversation layout: [dot rail][content column].
|
|
outer = QHBoxLayout(self)
|
|
outer.setContentsMargins(0, 0, 0, 0)
|
|
outer.setSpacing(6)
|
|
self._gutter = _TimelineGutter(role)
|
|
outer.addWidget(self._gutter)
|
|
content = QWidget()
|
|
lay = QVBoxLayout(content)
|
|
lay.setContentsMargins(2, 4, 8, 8)
|
|
lay.setSpacing(4)
|
|
self._content_layout = lay
|
|
outer.addWidget(content, 1)
|
|
|
|
if title:
|
|
if collapsible:
|
|
# Clickable header that folds long tool output away to keep the
|
|
# transcript short. Collapsed by default; click to expand.
|
|
self._head = QPushButton(title)
|
|
self._head.setCursor(Qt.PointingHandCursor)
|
|
self._head.setStyleSheet(
|
|
"QPushButton { text-align:left; border:none; background:transparent;"
|
|
f" font-weight:600; color:{_p().text_muted}; padding:0; }}")
|
|
self._head.clicked.connect(self._toggle_body)
|
|
lay.addWidget(self._head)
|
|
else:
|
|
head = QLabel(title)
|
|
head.setStyleSheet(f"font-weight:600; color:{_p().text_muted};")
|
|
lay.addWidget(head)
|
|
|
|
self.body = QTextBrowser()
|
|
self.body.setOpenExternalLinks(True)
|
|
self.body.setFrameShape(QFrame.NoFrame)
|
|
# Text color adapts to theme.
|
|
self._apply_theme_styles(role)
|
|
lay.addWidget(self.body)
|
|
|
|
self._apply_style(role)
|
|
if collapsible and collapsed:
|
|
self.body.setVisible(False)
|
|
if collapsible:
|
|
self._update_head()
|
|
|
|
def _toggle_body(self) -> None:
|
|
self.body.setVisible(not self.body.isVisible())
|
|
if self.body.isVisible():
|
|
self._autosize()
|
|
self._update_head()
|
|
|
|
def _update_head(self) -> None:
|
|
if not self._head:
|
|
return
|
|
expanded = self.body.isVisible()
|
|
arrow = "▾" if expanded else "▸"
|
|
preview = ""
|
|
if not expanded and self._text.strip():
|
|
first = self._text.strip().splitlines()[0]
|
|
if len(first) > 70:
|
|
first = first[:70] + "…"
|
|
preview = f" {first}"
|
|
self._head.setText(f"{arrow} {self._title}{preview}")
|
|
|
|
def _current_theme(self) -> str:
|
|
"""Resolve the current app theme (light or dark)."""
|
|
return _app_theme()
|
|
|
|
def _apply_theme_styles(self, role: str) -> None:
|
|
"""Apply text color to the body QTextBrowser based on current theme + role."""
|
|
p = _p()
|
|
text_color = {
|
|
"success": p.success,
|
|
"error": p.danger,
|
|
"tool": p.text_muted, # secondary, like Claude's steps
|
|
}.get(role, p.text)
|
|
self.body.setStyleSheet(f"background: transparent; border: none; color: {text_color};")
|
|
|
|
def _apply_style(self, role: str) -> None:
|
|
"""Flat timeline row — no bubble box; the left dot/rail conveys role and
|
|
structure (Claude-Code style). The user's own message gets a faint tint
|
|
so questions are easy to pick out when scanning."""
|
|
p = _p()
|
|
if role == "user":
|
|
self.setStyleSheet(
|
|
f"QFrame {{ background: {p.surface}; border: none; "
|
|
f"border-radius: {p.radius}px; }}")
|
|
else:
|
|
self.setStyleSheet("QFrame { background: transparent; border: none; }")
|
|
|
|
def apply_theme(self) -> None:
|
|
"""Re-apply theme-dependent styles so existing rows adapt when the app
|
|
theme switches (light ↔ dark)."""
|
|
self._apply_theme_styles(self.role)
|
|
self._apply_style(self.role)
|
|
self._gutter.set_role(self.role)
|
|
|
|
def chat_view(self):
|
|
"""Walk up the parent chain to find the enclosing ChatView, if any."""
|
|
p = self.parent()
|
|
while p is not None:
|
|
if isinstance(p, ChatView):
|
|
return p
|
|
p = p.parent()
|
|
return None
|
|
|
|
def append_delta(self, delta: str) -> None:
|
|
self._text += delta
|
|
self.set_markdown(self._text)
|
|
|
|
def set_markdown(self, text: str) -> None:
|
|
self._text = text
|
|
self.body.setMarkdown(text)
|
|
self._autosize()
|
|
if self._collapsible:
|
|
self._update_head()
|
|
|
|
def set_plain(self, text: str) -> None:
|
|
self._text = text
|
|
self.body.setPlainText(text)
|
|
self._autosize()
|
|
if self._collapsible:
|
|
self._update_head()
|
|
|
|
def append_plain(self, delta: str) -> None:
|
|
self._text += delta
|
|
self.set_plain(self._text)
|
|
|
|
def set_diff(self, diff_text: str) -> None:
|
|
"""Render a unified diff (see :func:`diff_to_html`) with colored
|
|
before/after lines instead of a flat text block."""
|
|
self._text = diff_text
|
|
self.body.setHtml(diff_to_html(diff_text))
|
|
self._autosize()
|
|
if self._collapsible:
|
|
self._update_head()
|
|
|
|
def add_usage(self, text: str) -> None:
|
|
"""A small muted token/cost footer under the message (↓in ↑out ▤ctx $cost),
|
|
like Claude Code. Replaces any previous usage line on this bubble."""
|
|
existing = getattr(self, "_usage_lbl", None)
|
|
if existing is not None:
|
|
existing.setText(text)
|
|
return
|
|
lbl = QLabel(text)
|
|
lbl.setObjectName("faint")
|
|
lbl.setStyleSheet(f"color: {_p().text_faint}; font-size: 11px;")
|
|
self._usage_lbl = lbl
|
|
self._content_layout.addWidget(lbl)
|
|
|
|
def add_delete_link(self, callback) -> None:
|
|
link = QLabel(f'<a href="#del" style="color:{_p().danger};">{tr("chat.delete_link")}</a>')
|
|
link.setToolTip(tr("chat.delete_tooltip"))
|
|
link.linkActivated.connect(lambda *_: callback())
|
|
self._content_layout.addWidget(link)
|
|
|
|
def add_folder_link(self, folder: str, label: str | None = None) -> None:
|
|
label = label or tr("chat.open_workspace")
|
|
link = QLabel(f'<a href="#open" style="color:{_p().accent};">{label}</a>')
|
|
link.setToolTip(str(folder))
|
|
link.linkActivated.connect(lambda *_: open_folder(folder))
|
|
self._content_layout.addWidget(link)
|
|
|
|
def add_attachments(self, paths) -> None:
|
|
"""Show attached files: images as thumbnails, others as clickable links."""
|
|
for p in paths:
|
|
path = str(p)
|
|
name = Path(path).name
|
|
if is_image(path):
|
|
pix = QPixmap(path)
|
|
if not pix.isNull():
|
|
thumb = QLabel()
|
|
thumb.setPixmap(pix.scaledToWidth(min(320, pix.width()), Qt.SmoothTransformation))
|
|
thumb.setToolTip(name)
|
|
thumb.setCursor(Qt.PointingHandCursor)
|
|
self._content_layout.addWidget(thumb)
|
|
continue
|
|
file_link = QLabel(f'<a href="#open" style="color:{_p().accent};">{name}</a>')
|
|
file_link.setToolTip(path)
|
|
file_link.linkActivated.connect(lambda *_a, fp=path: open_path(fp))
|
|
self._content_layout.addWidget(file_link)
|
|
|
|
def _autosize(self) -> None:
|
|
width = self.body.viewport().width()
|
|
if width <= 0:
|
|
width = 560 # sensible default before the widget is laid out
|
|
self.body.document().setTextWidth(width)
|
|
height = int(self.body.document().size().height()) + 12
|
|
self.body.setFixedHeight(max(28, min(height, 1200)))
|
|
|
|
def resizeEvent(self, event): # noqa: N802 - re-flow on width change
|
|
super().resizeEvent(event)
|
|
self._autosize()
|
|
|
|
|
|
class ChatView(QScrollArea):
|
|
"""Scrollable chat transcript.
|
|
|
|
Emits ``theme_changed`` (via the apply_theme method) so every child
|
|
``MessageBubble`` can re-apply its theme-aware inline styles when the
|
|
app switches between light and dark modes."""
|
|
|
|
def __init__(self):
|
|
super().__init__()
|
|
self.setWidgetResizable(True)
|
|
self._container = QWidget()
|
|
self._lay = QVBoxLayout(self._container)
|
|
self._lay.setContentsMargins(12, 12, 12, 12)
|
|
self._lay.setSpacing(10)
|
|
self._lay.addStretch(1)
|
|
self.setWidget(self._container)
|
|
|
|
def apply_theme(self) -> None:
|
|
"""Ask every MessageBubble inside this view to re-apply theme styles.
|
|
|
|
Called from ``ChatPanel.apply_theme`` whenever the app theme changes."""
|
|
for i in range(self._lay.count()):
|
|
item = self._lay.itemAt(i)
|
|
w = item.widget() if item else None
|
|
if isinstance(w, MessageBubble):
|
|
w.apply_theme()
|
|
|
|
def _add(self, bubble: MessageBubble) -> MessageBubble:
|
|
# insert before the trailing stretch
|
|
self._lay.insertWidget(self._lay.count() - 1, bubble)
|
|
self._scroll_to_bottom()
|
|
return bubble
|
|
|
|
def add_user(self, text: str) -> MessageBubble:
|
|
b = MessageBubble("user", tr("chat.you"))
|
|
b.set_plain(text)
|
|
return self._add(b)
|
|
|
|
def add_assistant(self, title: str | None = None) -> MessageBubble:
|
|
b = MessageBubble("assistant", title or tr("chat.assistant"))
|
|
return self._add(b)
|
|
|
|
def add_tool(self, title: str, body: str, ok: bool = True) -> MessageBubble:
|
|
# Tool steps (run command, generated code/diff, output) are collapsible to
|
|
# keep the transcript short — collapsed when OK, expanded on error.
|
|
b = MessageBubble("tool" if ok else "error", title, collapsible=True, collapsed=ok)
|
|
b.set_plain(body)
|
|
return self._add(b)
|
|
|
|
def add_diff(self, title: str, diff_text: str, ok: bool = True) -> MessageBubble:
|
|
"""Like :meth:`add_tool`, but renders ``diff_text`` as a colored
|
|
before/after diff (see :func:`diff_to_html`) instead of flat text."""
|
|
b = MessageBubble("tool" if ok else "error", title, collapsible=True, collapsed=ok)
|
|
b.set_diff(diff_text)
|
|
return self._add(b)
|
|
|
|
def add_plan(self, body: str) -> MessageBubble:
|
|
"""The task plan shown INLINE in the timeline (never a pop-up or side
|
|
panel) — a permanent, always-expanded row whose steps tick off as they
|
|
complete. The agent re-sends the full list on each update; the caller
|
|
updates this same row in place via ``set_plain``."""
|
|
b = MessageBubble("tool", tr("widgets.plan_title"), collapsible=False)
|
|
b.set_plain(body)
|
|
return self._add(b)
|
|
|
|
def add_reasoning(self, title: str | None = None) -> MessageBubble:
|
|
# The model's private reasoning — a collapsed, collapsible box so the user
|
|
# can see it's thinking (and expand to read) without it flooding the chat.
|
|
b = MessageBubble("tool", title or tr("chat.thinking"), collapsible=True, collapsed=True)
|
|
return self._add(b)
|
|
|
|
def add_error(self, text: str) -> MessageBubble:
|
|
b = MessageBubble("error", tr("chat.error"))
|
|
b.set_plain(text)
|
|
return self._add(b)
|
|
|
|
def add_status(self, text: str) -> MessageBubble:
|
|
"""A small one-line status marker in the transcript (e.g. '✅ Đã hoàn thành')."""
|
|
b = MessageBubble("tool", "")
|
|
b.set_plain(text)
|
|
return self._add(b)
|
|
|
|
def add_success(self, text: str) -> MessageBubble:
|
|
"""Like :meth:`add_status`, but styled green — used for the "turn done"
|
|
marker so completion reads as an unmistakable success signal."""
|
|
b = MessageBubble("success", "")
|
|
b.set_plain(text)
|
|
return self._add(b)
|
|
|
|
def clear(self) -> None:
|
|
while self._lay.count() > 1:
|
|
item = self._lay.takeAt(0)
|
|
w = item.widget()
|
|
if w:
|
|
w.deleteLater()
|
|
|
|
def scroll_to_bottom(self) -> None:
|
|
"""Scroll to the newest message, deferred so freshly-added bubbles have
|
|
finished sizing (their height is computed after layout)."""
|
|
QTimer.singleShot(0, self._scroll_to_bottom)
|
|
QTimer.singleShot(80, self._scroll_to_bottom)
|
|
|
|
def _scroll_to_bottom(self) -> None:
|
|
bar = self.verticalScrollBar()
|
|
bar.setValue(bar.maximum())
|