presentation/chat/
chat_history_widget.py 348 T01 mạch hội thoại (từ ui/chat_view.py)
chat_bubble_style.py 202 T01 cách vẽ bong bóng, diff, đường thời gian
composer_widget.py 364 T02 thanh công cụ quanh ô nhập
chat_input_box.py 328 T02 ô nhập: Ctrl+Enter, dán ảnh, popup /skill
attachment_picker.py 215 T03 đọc tệp đính kèm + chặn theo chính sách
chat_output_panel.py 186 T05 theo dõi thư mục output, hiện tệp mới
chat_turn_runner.py 281 T06 chạy một lượt
chat_event_stream.py 228 T06 nhận sự kiện phát về từ luồng nền
chat_session_store.py 413 T06 lưu/nạp phiên, đếm token, nối lại lượt
chat_agents.py 246 T06 chọn agent, skill, định tuyến model
chat_panel_layout.py 148 T06 bố cục hai cột
chat_helpers.py 53 T06 hàm và bảng tra dùng chung
ui/chat_panel.py 345 __init__ + trạng thái
ui/chat_view.py 10 vỏ chuyển tiếp
ui/composer.py 11 vỏ chuyển tiếp
R08-T04 KHÔNG LÀM ĐƯỢC: plan đòi audio_recorder_widget.py, nhưng trong repo
KHÔNG CÓ chức năng ghi âm nào — grep 'audio|record|voice|micro' toàn ui/ chỉ
ra chữ 'record' trong nghĩa 'ghi lại transcript'. Không có gì để tách, và tôi
không dựng một widget mới nhân danh refactor. Giống hệt trường hợp
connector_settings_widget.py ở T07.
_start_turn (144 dòng) và _on_event (127) để nguyên có chủ ý: cái đầu dựng
trọn ngữ cảnh một lượt rồi giao cho luồng nền, cái sau phân nhánh theo loại sự
kiện. Cắt nhỏ thì phải chuyền hàng chục biến trạng thái qua lại, đọc khó hơn.
Hai lỗi tự gây, cả hai đều do script:
* regex bỏ import cũ chỉ cắt DÒNG ĐẦU của một import nhiều dòng, để lại phần
đuôi mồ côi -> IndentationError.
* _build_layout dùng biến 'root' vốn cục bộ trong __init__. Bộ test bắt được
cái này (2 bài integration đỏ), không phải checker — vì nó là lỗi dựng
widget, không phải lỗi hình học.
756 test xanh. 24/24 checker qua.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
203 lines
7.7 KiB
Python
203 lines
7.7 KiB
Python
"""Cách vẽ một bong bóng chat: màu, đường thời gian, diff, trạng thái — R08-T01.
|
|
|
|
Tách khỏi ``chat_history_widget.py``: đây là phần quyết định TRÔNG THẾ NÀO,
|
|
còn file kia quyết định HIỆN CÁI GÌ.
|
|
|
|
``diff_to_html`` tô màu phần thêm/bớt khi agent sửa file; ``_TimelineGutter``
|
|
vẽ đường dọc nối các lượt, ``ThinkingIndicator`` là ba chấm lúc chờ.
|
|
"""
|
|
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 ...ui.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))
|