chore: sync local working copy as of 2026-08-15

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>
This commit is contained in:
NamPDT
2026-08-17 11:38:18 +09:00
co-authored by Claude Opus 5
parent 414eaddca3
commit 291a611737
96 changed files with 12491 additions and 2937 deletions
+2 -2
View File
@@ -28,7 +28,7 @@ from ..core import usage_tracker as ut
from ..core.worker import AgentWorker
from ..i18n import on_language_changed, tr
from ..state import AppContext
from .icons import icon
from .icons import DOT_AMBER, icon
from .widgets import fmt_tokens
_PERIODS = ("day", "week", "month", "year")
@@ -367,7 +367,7 @@ class AccountsTab(QWidget):
label = f"{acc.display_name or acc.username} ({acc.username}) — {tr(f'accounts.role.{acc.role}')}"
item = QTreeWidgetItem([label])
if is_subadmin: # subadmin badge → star icon instead of a ★ glyph
item.setIcon(0, icon("star", color="#f59e0b"))
item.setIcon(0, icon("star", color=DOT_AMBER))
item.setData(0, Qt.UserRole, ("account", acc.username))
if acc.email:
item.setToolTip(0, acc.email)
+13 -11
View File
@@ -20,6 +20,7 @@ from ..core.calendar_grid import (
GRANULARITIES, group_tasks_by_date, month_grid, month_task_counts, shift_period, week_days,
)
from ..i18n import on_language_changed, tr
from ..theme import current_palette
from .icons import icon
_WEEKDAY_KEYS = ("mon", "tue", "wed", "thu", "fri", "sat", "sun")
@@ -56,20 +57,21 @@ class _DayCell(QFrame):
today: bool = False, weekend: bool = False) -> None:
self._date_str = d.isoformat()
self.date_lbl.setText(str(d.day))
num_color = "#0096C7" if today else ("#888" if dim else "")
self.date_lbl.setStyleSheet(f"font-weight:700; color:{num_color};")
# Today = accent border + stronger tint; weekend (Sat/Sun) = a subtle
# darker-blue tint than the base cell. rgba overlays read correctly on
# both light and dark themes.
base_border = "1px solid rgba(128,128,128,0.35)"
p = current_palette()
num_color = p.accent if today else (p.text_faint if dim else p.text)
self.date_lbl.setStyleSheet(f"font-weight:600; color:{num_color};")
# Today is the only cell that gets a filled surface + accent border;
# weekends are set apart by a recessed surface alone, so the eye lands
# on "today" first and on the weekend block only when scanning.
r = p.radius
if today:
css = ("#dayCell { background: rgba(0,150,199,0.22); "
"border: 2px solid #0096C7; border-radius: 6px; }")
css = (f"#dayCell {{ background: {p.accent_soft}; "
f"border: 1px solid {p.accent}; border-radius: {r}px; }}")
elif weekend:
css = ("#dayCell { background: rgba(0,120,182,0.13); "
f"border: {base_border}; border-radius: 6px; }}")
css = (f"#dayCell {{ background: {p.surface}; "
f"border: 1px solid {p.border}; border-radius: {r}px; }}")
else:
css = f"#dayCell {{ border: {base_border}; border-radius: 6px; }}"
css = f"#dayCell {{ border: 1px solid {p.border}; border-radius: {r}px; }}"
self.setStyleSheet(css)
self.list.clear()
for t in tasks:
+2 -1
View File
@@ -24,6 +24,7 @@ from PySide6.QtWidgets import (
from ..core.worker import AgentWorker
from ..i18n import on_language_changed, tr
from ..state import AppContext
from ..theme import current_palette
from .chat_view import ChatView, ThinkingIndicator
from .composer import Composer
from .icons import collapse_right_icon, icon as app_icon
@@ -140,7 +141,7 @@ class ChatPanel(QWidget):
# updated after each turn; cost uses the Monitoring model-price table.
self._usage_total_lbl = QLabel("")
self._usage_total_lbl.setObjectName("hint")
self._usage_total_lbl.setStyleSheet("color: rgba(140,146,152,0.9);")
self._usage_total_lbl.setStyleSheet(f"color: {current_palette().text_faint};")
self.composer.add_bottom_left(self._usage_total_lbl)
# Per-tab "Agent" = which MODEL this tab uses (e.g. deepseek / gemma /
+50 -53
View File
@@ -12,7 +12,7 @@ from PySide6.QtWidgets import (
)
from ..i18n import on_language_changed, tr
from ..theme import ACCENT, resolve_theme
from ..theme import palette, resolve_theme
from ..config import CONFIG_DIR
from .osutil import is_image, open_folder, open_path
@@ -28,11 +28,18 @@ def _app_theme() -> str:
return "dark"
# Timeline dot color per role (reads on both themes — small, saturated).
_DOT = {
"user": "#48CAE4", "assistant": "#48D9A0", "tool": "#9B8FF7",
"error": "#E5484D", "success": "#48D9A0",
}
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):
@@ -52,17 +59,17 @@ class _TimelineGutter(QWidget):
def paintEvent(self, _e): # noqa: N802
p = QPainter(self)
p.setRenderHint(QPainter.Antialiasing)
dark = _app_theme() == "dark"
tok = _p()
x = 11.0
cy = 15.0
# connector line (faint) running the full height → continuous rail
p.setPen(QPen(QColor("#243a56" if dark else "#CBDDEC"), 2))
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("#0A1628" if dark else "#E8F4FD"))
p.setBrush(QColor(tok.bg))
p.drawEllipse(QPointF(x, cy), 7.5, 7.5)
p.setBrush(QColor(_DOT.get(self._role, "#8FB2D4")))
p.setBrush(QColor(_dot_color(self._role)))
p.drawEllipse(QPointF(x, cy), 4.5, 4.5)
@@ -72,18 +79,20 @@ def _diff_legend(diff_text: str) -> str:
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())
before = (f'<span style="background:#3a1620; color:#ff9aa8; padding:1px 8px; '
f'border-radius:4px; font-weight:600;">{html.escape(tr("chat.diff_before"))}</span>')
after = (f'<span style="background:#0d3321; color:#7ee2a8; padding:1px 8px; '
f'border-radius:4px; font-weight:600;">{html.escape(tr("chat.diff_after"))}</span>')
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 = f'{before}<span style="color:#8b8d98;"> → </span>{after}'
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 = (f'<span style="background:#0d3321; color:#7ee2a8; padding:1px 8px; '
f'border-radius:4px; font-weight:600;">{html.escape(tr("chat.diff_added"))}</span>')
badge = pill(p.diff_add_bg, p.diff_add_fg, "chat.diff_added")
elif has_del:
badge = (f'<span style="background:#3a1620; color:#ff9aa8; padding:1px 8px; '
f'border-radius:4px; font-weight:600;">{html.escape(tr("chat.diff_removed"))}</span>')
badge = pill(p.diff_del_bg, p.diff_del_fg, "chat.diff_removed")
else:
return ""
return f'<div style="margin-bottom:6px;">{badge}</div>'
@@ -97,21 +106,22 @@ def diff_to_html(diff_text: str) -> str:
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 "&nbsp;"
if ln.startswith(("+++", "---")):
rows.append(f'<div style="color:#8b8d98;">{esc}</div>')
rows.append(f'<div style="color:{p.text_muted};">{esc}</div>')
elif ln.startswith("@@"):
rows.append(f'<div style="color:#7c8aff;">{esc}</div>')
rows.append(f'<div style="color:{p.accent};">{esc}</div>')
elif ln.startswith("+"):
rows.append(f'<div style="background:#0d3321; color:#7ee2a8;">{esc}</div>')
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:#3a1620; color:#ff9aa8;">{esc}</div>')
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:Consolas,\'Courier New\',monospace; font-size:12.5px; '
return (f'{legend}<div style="font-family:{p.font_mono}; font-size:12.5px; '
f'white-space:pre-wrap;">{body}</div>')
@@ -219,12 +229,12 @@ class MessageBubble(QFrame):
self._head.setCursor(Qt.PointingHandCursor)
self._head.setStyleSheet(
"QPushButton { text-align:left; border:none; background:transparent;"
" font-weight:600; color:#8b8d98; padding:0; }")
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("font-weight:600; color:#8b8d98;")
head.setStyleSheet(f"font-weight:600; color:{_p().text_muted};")
lay.addWidget(head)
self.body = QTextBrowser()
@@ -265,36 +275,23 @@ class MessageBubble(QFrame):
def _apply_theme_styles(self, role: str) -> None:
"""Apply text color to the body QTextBrowser based on current theme + role."""
theme = self._current_theme()
if theme == "light":
if role == "success":
text_color = "#1B7A3D"
elif role == "error":
text_color = "#C0392B"
elif role in ("tool",):
text_color = "#5C6B7A" # muted (secondary) like Claude's steps
else:
text_color = "#1A2332"
else:
if role == "success":
text_color = "#7ee2a8"
elif role == "error":
text_color = "#ff9aa8"
elif role in ("tool",):
text_color = "#9aa6b8"
else:
text_color = "#eceef2"
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."""
theme = self._current_theme()
p = _p()
if role == "user":
tint = "rgba(72,202,228,0.10)" if theme == "dark" else "rgba(72,202,228,0.14)"
self.setStyleSheet(
f"QFrame {{ background: {tint}; border: none; border-radius: 10px; }}")
f"QFrame {{ background: {p.surface}; border: none; "
f"border-radius: {p.radius}px; }}")
else:
self.setStyleSheet("QFrame { background: transparent; border: none; }")
@@ -353,20 +350,20 @@ class MessageBubble(QFrame):
existing.setText(text)
return
lbl = QLabel(text)
lbl.setObjectName("hint")
lbl.setStyleSheet("color: rgba(140,146,152,0.9); font-size: 11px;")
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:#ef6368;">{tr("chat.delete_link")}</a>')
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:{ACCENT};">{label}</a>')
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)
@@ -385,7 +382,7 @@ class MessageBubble(QFrame):
thumb.setCursor(Qt.PointingHandCursor)
self._content_layout.addWidget(thumb)
continue
file_link = QLabel(f'<a href="#open" style="color:{ACCENT};">{name}</a>')
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)
+33 -19
View File
@@ -27,13 +27,20 @@ from ..core.co4e import (
STEP_DONE, STEP_ERROR, STEP_PLANNED, STEP_RUNNING, Edge, Node, Step,
compute_waves, new_edge_id, new_node_id,
)
from ..theme import current_palette
def _status_color(status: str) -> str:
"""Accent colour for a step's run status. Resolved per paint so the canvas
follows a live theme switch."""
p = current_palette()
return {
"idle": p.text_muted, STEP_RUNNING: p.accent, STEP_DONE: p.success,
STEP_ERROR: p.danger, STEP_PLANNED: p.purple, "pending": p.text_faint,
}.get(status, p.text_muted)
CO4E_MIME = "application/x-co4e-step"
_STATUS_COLOR = {
"idle": "#5C8DB8", STEP_RUNNING: "#48CAE4", STEP_DONE: "#48D9A0",
STEP_ERROR: "#E5484D", STEP_PLANNED: "#9B8FF7", "pending": "#7A8DA8",
}
_NODE_W, _NODE_H = 210, 96
_PORT_R = 6 # output port radius (the drag-to-connect handle)
_PORT_HIT = 15 # click tolerance around a port
@@ -63,24 +70,29 @@ class _NodeItem(QGraphicsObject):
return QRectF(1, 1, _NODE_W - 2, _NODE_H - 2)
def paint(self, p, _opt, _widget=None):
tok = current_palette()
step = self.node.data
accent = QColor(_STATUS_COLOR.get(self.status, "#5C8DB8"))
body = QColor("#0D1F35")
border = QColor("#48CAE4") if self.isSelected() else QColor("#1A2D4A")
accent = QColor(_status_color(self.status))
body = QColor(tok.surface_raised)
border = QColor(tok.accent) if self.isSelected() else QColor(tok.border)
p.setRenderHint(p.RenderHint.Antialiasing)
rect = self._card_rect()
path = QPainterPath()
path.addRoundedRect(rect, 10, 10)
radius = float(tok.radius_lg)
path.addRoundedRect(rect, radius, radius)
p.fillPath(path, QBrush(body))
p.setPen(QPen(border, 2 if self.isSelected() else 1))
p.drawPath(path)
# header stripe
# header stripe — a tint of the status colour, not the status colour
# itself, so the card's own text stays the brightest thing on it.
hdr = QRectF(rect.left(), rect.top(), rect.width(), 26)
hpath = QPainterPath()
hpath.addRoundedRect(hdr, 10, 10)
p.fillPath(hpath, QBrush(accent.darker(160)))
hpath.addRoundedRect(hdr, radius, radius)
stripe = QColor(accent)
stripe.setAlpha(48)
p.fillPath(hpath, QBrush(stripe))
# label
p.setPen(QColor("#E0F0FF"))
p.setPen(QColor(tok.text))
f = p.font(); f.setBold(True); f.setPointSize(9); p.setFont(f)
p.drawText(QRectF(10, 4, _NODE_W - 20, 20), Qt.AlignVCenter | Qt.AlignLeft,
_elide(step.label, 26))
@@ -89,7 +101,7 @@ class _NodeItem(QGraphicsObject):
p.setPen(accent)
p.drawText(QRectF(10, 30, _NODE_W - 20, 16), Qt.AlignLeft, step.role)
# body: instructions preview OR sub-agent chips
p.setPen(QColor("#8FB2D4"))
p.setPen(QColor(tok.text_muted))
if step.is_parallel:
preview = "⇉ " + ", ".join(s.agent for s in step.sub_agents) if step.sub_agents else "⇉ (no sub-agents)"
else:
@@ -97,7 +109,7 @@ class _NodeItem(QGraphicsObject):
p.drawText(QRectF(10, 46, _NODE_W - 20, 30), Qt.TextWordWrap | Qt.AlignTop,
_elide(preview, 66))
# footer: model + skills + status dot
p.setPen(QColor("#5C8DB8"))
p.setPen(QColor(tok.text_faint))
foot = []
if step.model:
foot.append(step.model)
@@ -109,7 +121,7 @@ class _NodeItem(QGraphicsObject):
# ---- ports ---------------------------------------------------------
# input port (top-center): hollow. output port (bottom-center): filled —
# the drag handle you pull to wire an edge to another step.
port_col = QColor("#48CAE4")
port_col = QColor(tok.accent)
# input port (left-center): hollow. output port (right-center): filled —
# the drag handle you pull to wire an edge to the next step (left→right).
p.setBrush(QBrush(body)); p.setPen(QPen(port_col, 1.4))
@@ -298,12 +310,13 @@ class _EdgeItem(QGraphicsPathItem):
self._apply_pen()
def _apply_pen(self):
tok = current_palette()
if self.isSelected():
color, w = QColor("#48CAE4"), 3
color, w = QColor(tok.accent), 3
elif self._hover:
color, w = QColor("#6FA8C8"), 3
color, w = QColor(tok.text_muted), 3
else:
color, w = QColor("#3A5A78"), 2
color, w = QColor(tok.border_strong), 2
self.setPen(QPen(color, w, Qt.SolidLine, Qt.RoundCap, Qt.RoundJoin))
def update_path(self, points):
@@ -491,7 +504,8 @@ class Co4ECanvas(QGraphicsView):
self._port_src_pt = scene_pt
self._temp_edge = QGraphicsPathItem()
self._temp_edge.setZValue(3.5) # above nodes + edges while connecting
self._temp_edge.setPen(QPen(QColor("#48CAE4"), 2, Qt.DashLine, Qt.RoundCap))
self._temp_edge.setPen(
QPen(QColor(current_palette().accent), 2, Qt.DashLine, Qt.RoundCap))
self._scene.addItem(self._temp_edge)
def update_port_drag(self, scene_pt: QPointF) -> None:
+12 -8
View File
@@ -34,6 +34,7 @@ from ..core.co4e_builtins import BUILTIN_AGENTS
from ..core.co4e_run_manager import Co4ERunManager
from ..core.worker import AgentWorker
from ..i18n import on_language_changed, tr
from ..theme import current_palette
from .chat_view import ChatView
from .co4e_canvas import CO4E_MIME, Co4ECanvas
from .co4e_config_panel import StepConfigPanel
@@ -564,13 +565,14 @@ class Co4ETab(QWidget):
# Tab colours + layout live in theme.py (QTabBar#flowTabs — theme-aware,
# flush, centred). Here we only style the per-tab close (✕) button, which
# QTabBar places centred on the tab's right (see _add_tab_close_button).
_fp = current_palette()
self.flow_bar.setStyleSheet(
"QPushButton#flowTabClose {"
" border: none; background: transparent; color: #8FB2D4;"
f" border: none; background: transparent; color: {_fp.text_muted};"
" font-size: 13px; font-weight: bold; padding: 0; margin: 0;"
" border-radius: 8px; }"
f" border-radius: {_fp.radius_sm}px; }}"
"QPushButton#flowTabClose:hover {"
" background: rgba(229,72,77,0.18); color: #E5484D; }")
f" background: {_fp.danger_soft}; color: {_fp.danger}; }}")
runs_idx = self.flow_bar.addTab(icon("monitoring"), tr("co4e.runs_tab")) # 0 = Runs
self.flow_bar.setTabButton(runs_idx, QTabBar.RightSide, None) # pinned
self.flow_bar.currentChanged.connect(self._on_flow_tab_changed)
@@ -606,7 +608,7 @@ class Co4ETab(QWidget):
"QScrollArea#flowTabScroll { background: transparent; border: none; }"
"QScrollArea#flowTabScroll QScrollBar:horizontal { height: 8px; background: transparent; margin: 0; }"
"QScrollArea#flowTabScroll QScrollBar::handle:horizontal {"
" background: rgba(143,178,212,0.45); border-radius: 4px; min-width: 30px; }"
f" background: {current_palette().scroll_handle}; border-radius: 4px; min-width: 30px; }}"
"QScrollArea#flowTabScroll QScrollBar::add-line:horizontal,"
"QScrollArea#flowTabScroll QScrollBar::sub-line:horizontal { width: 0; height: 0; }")
flow_row = QHBoxLayout()
@@ -861,7 +863,8 @@ class Co4ETab(QWidget):
# $cost) at the bottom, exactly like Cowork's conversation total.
self._usage_total_lbl = QLabel("")
self._usage_total_lbl.setObjectName("hint")
self._usage_total_lbl.setStyleSheet("color: rgba(140,146,152,0.9); font-size: 11px;")
self._usage_total_lbl.setStyleSheet(
f"color: {current_palette().text_faint}; font-size: 11px;")
crow.addWidget(self._usage_total_lbl)
_inp = QWidget(); row = QHBoxLayout(_inp); row.setContentsMargins(0, 0, 0, 0)
self.chat_input = _ChatInput()
@@ -1331,8 +1334,9 @@ class Co4ETab(QWidget):
# Rebuild the always-fresh Runs table from the manager (single source of truth).
if not hasattr(self, "runs_table"):
return
color = {"running": "#48CAE4", "done": "#48D9A0", "error": "#E5484D",
"stopped": "#8FB2D4"}
p = current_palette()
color = {"running": p.accent, "done": p.success, "error": p.danger,
"stopped": p.text_muted}
dots = {"running": "▶", "done": "✓", "error": "✕", "stopped": "■"}
# Most-recent run at the TOP, oldest at the bottom (manager keeps runs in
# chronological insertion order, so reverse it for display).
@@ -1352,7 +1356,7 @@ class Co4ETab(QWidget):
if c == 0:
it.setData(Qt.UserRole, h.id)
if c == 1:
it.setForeground(_qcolor(color.get(h.status, "#E0F0FF")))
it.setForeground(_qcolor(color.get(h.status, p.text)))
t.setItem(r, c, it)
if h.id == sel_id:
sel_row = r
+5 -1
View File
@@ -20,6 +20,7 @@ from PySide6.QtWidgets import (
from ..config import CONFIG_DIR
from ..i18n import on_language_changed, tr
from ..theme import current_palette
from .icons import icon, IconLabel
@@ -583,7 +584,10 @@ class Composer(QWidget):
for p in self._attachments:
item = QListWidgetItem()
row = QWidget()
row.setStyleSheet("background: rgba(140,146,152,0.18); border-radius: 6px;")
_cp = current_palette()
row.setStyleSheet(
f"background: {_cp.surface_raised}; border: 1px solid {_cp.border};"
f" border-radius: {_cp.radius_sm}px;")
h = QHBoxLayout(row)
h.setContentsMargins(8, 2, 4, 2)
h.setSpacing(4)
+5 -1
View File
@@ -24,6 +24,7 @@ from ..core import usage_tracker as ut
from ..core.worker import AgentWorker
from ..i18n import on_language_changed, tr
from ..state import AppContext
from ..theme import current_palette
from .icons import icon
from .spline_chart import SplineChart
from .widgets import BudgetCard as _BudgetCard
@@ -298,8 +299,11 @@ class DashboardTab(QWidget):
n_points = max(1, len(parts))
refs = []
if prev[mi] > 0:
# Muted on purpose: the comparison line is a reference, not the
# series — it must not compete with the accent-coloured spline.
refs.append((prev[mi] / n_points,
f"{tr(ref_key)} {self._delta_txt(cur[mi], prev[mi])}", "#B08968"))
f"{tr(ref_key)} {self._delta_txt(cur[mi], prev[mi])}",
current_palette().text_muted))
self.chart.set_reference_lines(refs)
self.chart.set_data(pts, fmt, tr(f"dashboard.metric_{metric}"))
self._chart_period_lbl.setText(ut.period_range_label(gran, self._chart_offset))
+27 -24
View File
@@ -33,6 +33,7 @@ from PySide6.QtWidgets import (
from ..core.worker import AgentWorker
from ..i18n import on_language_changed, tr
from ..state import AppContext
from ..theme import current_palette
from .chat_view import ChatView
from .icons import icon
from .libreoffice_view import DOC_SUFFIXES
@@ -89,23 +90,26 @@ class PygmentsHighlighter(QSyntaxHighlighter):
from pygments.token import (
Comment, Error, Keyword, Name, Number, Operator, Punctuation, String,
)
p = current_palette()
# Ordered specific → general: first matching token type wins.
# Colours are resolved when the editor is built, so reopening a file
# after a theme switch re-highlights it in the new theme.
return [
(Comment, _fmt("#6A9955", italic=True)),
(Keyword.Type, _fmt("#4EC9B0")),
(Keyword, _fmt("#569CD6")),
(Name.Function, _fmt("#DCDCAA")),
(Name.Class, _fmt("#4EC9B0")),
(Name.Decorator, _fmt("#DCDCAA")),
(Name.Builtin, _fmt("#4EC9B0")),
(Name.Tag, _fmt("#569CD6")),
(Name.Attribute, _fmt("#9CDCFE")),
(String.Doc, _fmt("#6A9955", italic=True)),
(String, _fmt("#CE9178")),
(Number, _fmt("#B5CEA8")),
(Operator, _fmt("#D4D4D4")),
(Punctuation, _fmt("#D4D4D4")),
(Error, _fmt("#F44747")),
(Comment, _fmt(p.code_comment, italic=True)),
(Keyword.Type, _fmt(p.code_type)),
(Keyword, _fmt(p.code_keyword)),
(Name.Function, _fmt(p.code_func)),
(Name.Class, _fmt(p.code_type)),
(Name.Decorator, _fmt(p.code_func)),
(Name.Builtin, _fmt(p.code_type)),
(Name.Tag, _fmt(p.code_keyword)),
(Name.Attribute, _fmt(p.code_attr)),
(String.Doc, _fmt(p.code_comment, italic=True)),
(String, _fmt(p.code_string)),
(Number, _fmt(p.code_number)),
(Operator, _fmt(p.code_fg)),
(Punctuation, _fmt(p.code_fg)),
(Error, _fmt(p.code_error)),
]
def set_filename(self, filename: str, text: str = "") -> None:
@@ -179,9 +183,7 @@ class CodeEditor(QPlainTextEdit):
font.setStyleHint(QFont.Monospace)
font.setPointSize(10)
self.setFont(font)
self.setStyleSheet(
"#codeEditor { background: #1e1e1e; color: #d4d4d4; border: none; "
"selection-background-color: #264f78; }")
# Surface comes from the central style sheet (#codeEditor) — see theme.py.
self._gutter = _LineNumbers(self)
self.blockCountChanged.connect(lambda _=0: self._update_gutter_width())
self.updateRequest.connect(self._on_update_request)
@@ -210,13 +212,14 @@ class CodeEditor(QPlainTextEdit):
self._gutter.setGeometry(QRect(cr.left(), cr.top(), self.line_number_width(), cr.height()))
def paint_line_numbers(self, event) -> None:
p = current_palette()
painter = QPainter(self._gutter)
painter.fillRect(event.rect(), QColor("#1a1a1a"))
painter.fillRect(event.rect(), QColor(p.code_gutter_bg))
block = self.firstVisibleBlock()
num = block.blockNumber()
top = self.blockBoundingGeometry(block).translated(self.contentOffset()).top()
bottom = top + self.blockBoundingRect(block).height()
painter.setPen(QColor("#858585"))
painter.setPen(QColor(p.code_gutter_fg))
while block.isValid() and top <= event.rect().bottom():
if block.isVisible() and bottom >= event.rect().top():
painter.drawText(0, int(top), self._gutter.width() - 6,
@@ -1096,7 +1099,7 @@ class FolderTab(QWidget):
if n and hasattr(self, "_ai_status"):
self._ai_status.setText("⏳ " + tr("folder.ai_status_running")
+ " · " + tr("folder.ai_queue_count", n=n))
self._ai_status.setStyleSheet("color:#0096C7;")
self._ai_status.setStyleSheet(f"color:{current_palette().accent};")
def _ai_maybe_dequeue(self) -> None:
"""When the pipeline is fully idle, start the next queued instruction."""
@@ -1308,7 +1311,7 @@ class FolderTab(QWidget):
name = target if create else getattr(self, "_ai_running_file", "")
self.status_message.emit(tr("folder.ai_proposed_status", name=name))
self._ai_status.setText("● " + hint)
self._ai_status.setStyleSheet("color:#c77d00;")
self._ai_status.setStyleSheet(f"color:{current_palette().warning};")
def _ai_apply(self) -> None:
"""Confirmed by the user. If the edit GENERATES images, ask the image
@@ -1464,7 +1467,7 @@ class FolderTab(QWidget):
self.ai_send_btn.setEnabled(not busy)
if busy:
self._ai_status.setText("⏳ " + tr("folder.ai_status_running"))
self._ai_status.setStyleSheet("color:#0096C7;")
self._ai_status.setStyleSheet(f"color:{current_palette().accent};")
self.ai_btn.setText(tr("folder.ai_edit") + " ⏳") # visible even when collapsed
else:
self._ai_status.setText("")
@@ -1478,7 +1481,7 @@ class FolderTab(QWidget):
self._ai_maybe_dequeue()
return
self._ai_status.setText("✓ " + tr("folder.ai_status_done"))
self._ai_status.setStyleSheet("color:#1f9d63;")
self._ai_status.setStyleSheet(f"color:{current_palette().success};")
if not self.ai_btn.isChecked() or self._ai_panel.isHidden():
self.ai_btn.setText(tr("folder.ai_edit") + " ✓")
+50 -57
View File
@@ -103,67 +103,58 @@ class HelpAgentWidget(QWidget):
self._apply_state()
# ---- theming ----------------------------------------------------------
def _compute_palette(self) -> Dict[str, str]:
"""Chat-body colours that FOLLOW the app's light/dark theme. The header
is intentionally NOT themed here (it stays a fixed light bar — see
_apply_style), only the conversation area adapts."""
from ..theme import resolve_theme
dark = resolve_theme(getattr(self.ctx.config, "theme", "system")) == "dark"
if dark:
return {
"panel_bg": "#16202b", "text": "#e3ebf5", "log_bg": "#0f1720",
"input_bg": "#1b2733", "border": "#33404d",
"user_bg": "#123a52", "user_label": "#58c0ee",
"bot_bg": "#232f3b", "bot_label": "#6fe3a4",
}
return {
"panel_bg": "#ffffff", "text": "#14212b", "log_bg": "#f7f9fb",
"input_bg": "#ffffff", "border": "#d5d9de",
"user_bg": "#dceff8", "user_label": "#0077B6",
"bot_bg": "#eef1f4", "bot_label": "#2f7d55",
}
def _compute_palette(self):
"""The app's design tokens for the theme in effect. The whole dock —
header included — follows the app theme; a header locked to a light
strip stranded a bright bar in the middle of the dark UI."""
from ..theme import palette
return palette(getattr(self.ctx.config, "theme", "system"))
def apply_theme(self) -> None:
"""Re-style + re-render when the app theme switches (called from
MainWindow._apply_theme). Header stays fixed; chat body re-colours."""
MainWindow._apply_theme). The whole dock re-colours, icons included —
icons are painted bitmaps, so they must be rebuilt, not restyled."""
self._pal = self._compute_palette()
self._apply_style()
muted = self._pal.text_muted
self.edge_tab.setIcon(icon("chevron-left", color=muted))
self.collapse_btn.setIcon(icon("chevron-right", color=muted))
self.min_btn.setIcon(icon("minus", color=muted))
self._render()
def _apply_style(self) -> None:
# The HEADER bar is a FIXED light strip in both themes (per request); only
# the chat body below follows the app's light/dark palette (self._pal).
from ..theme import ACCENT, ACCENT2, GRADIENT
"""The dock owns its own style sheet (it floats above the window, so the
app-wide sheet does not reach it cleanly) but draws every value from the
shared tokens — see theme.py."""
p = self._pal
r, rl = p.radius, p.radius_lg
self.setStyleSheet(f"""
/* Clean rounded app-icon badge (like image 2): a fixed light card
framing the icon — no QPushButton box. */
#helpLauncher {{ background: #e8f2fb; border: 1px solid #d3e3f2;
border-radius: 16px; }}
#helpLauncher:hover {{ background: #dcedfb; }}
#helpCollapseBtn, #helpEdgeTab {{ background: rgba(0,0,0,0.06); border: none;
border-radius: 6px; }}
#helpCollapseBtn:hover, #helpEdgeTab:hover {{ background: rgba(0,0,0,0.14); }}
#helpPanel {{ background: {p['panel_bg']}; border: 1px solid {p['border']};
border-radius: 14px; color: {p['text']}; }}
/* Faint-blue header bar — LOCKED light, dark title, in both themes.
The header AND its child labels set fixed backgrounds so the dark
theme never bleeds into the App-Assistant title strip. */
#helpHeader {{ background: #e8f2fb; border-bottom: 1px solid #d9e6f2;
border-top-left-radius: 14px; border-top-right-radius: 14px; }}
#helpHeader QLabel {{ background: transparent; color: #14212b; }}
#helpTitle {{ color: #14212b; font-weight: 700; font-size: 13px; background: transparent; }}
#helpMinBtn {{ background: transparent; border: none; }}
#helpMinBtn:hover {{ background: rgba(0,0,0,0.10); border-radius: 6px; }}
#helpLog {{ background: {p['log_bg']}; border: none; color: {p['text']}; padding: 4px 6px; }}
#helpInputRow {{ background: {p['panel_bg']}; border-bottom-left-radius: 14px;
border-bottom-right-radius: 14px; }}
#helpInput {{ border: 1px solid {p['border']}; border-radius: 8px; padding: 5px 8px;
background: {p['input_bg']}; color: {p['text']}; }}
#helpInput:focus {{ border: 1px solid {ACCENT}; }}
#helpSendBtn {{ background: {GRADIENT}; border: none; border-radius: 8px; }}
#helpSendBtn:hover {{ background: {ACCENT2}; }}
#helpSendBtn:disabled {{ background: #b7c0c9; }}
/* The app-icon badge that opens the dock: a plain card, no button box. */
#helpLauncher {{ background: {p.surface}; border: 1px solid {p.border};
border-radius: {rl}px; }}
#helpLauncher:hover {{ background: {p.hover}; }}
#helpCollapseBtn, #helpEdgeTab {{ background: {p.surface}; border: none;
border-radius: {r}px; }}
#helpCollapseBtn:hover, #helpEdgeTab:hover {{ background: {p.hover}; }}
#helpPanel {{ background: {p.surface}; border: 1px solid {p.border};
border-radius: {rl}px; color: {p.text}; }}
#helpHeader {{ background: {p.surface}; border-bottom: 1px solid {p.border};
border-top-left-radius: {rl}px; border-top-right-radius: {rl}px; }}
#helpHeader QLabel {{ background: transparent; color: {p.text}; }}
#helpTitle {{ color: {p.text}; font-weight: 600; font-size: 13px;
background: transparent; }}
#helpMinBtn {{ background: transparent; border: none; border-radius: {r}px; }}
#helpMinBtn:hover {{ background: {p.hover}; }}
#helpLog {{ background: {p.sunken}; border: none; color: {p.text};
padding: 4px 6px; }}
#helpInputRow {{ background: {p.surface};
border-bottom-left-radius: {rl}px; border-bottom-right-radius: {rl}px; }}
#helpInput {{ border: 1px solid {p.border}; border-radius: {r}px; padding: 5px 8px;
background: {p.surface_raised}; color: {p.text}; }}
#helpInput:focus {{ border: 1px solid {p.focus_ring}; }}
#helpSendBtn {{ background: {p.accent_solid}; border: none; border-radius: {r}px; }}
#helpSendBtn:hover {{ background: {p.accent_solid_hover}; }}
#helpSendBtn:disabled {{ background: {p.border_strong}; }}
""")
# ---- greeting / labels ------------------------------------------------
@@ -177,7 +168,7 @@ class HelpAgentWidget(QWidget):
# assistant back (chevron points left = "slide out").
self.edge_tab = QPushButton(self)
self.edge_tab.setObjectName("helpEdgeTab")
self.edge_tab.setIcon(icon("chevron-left", color="#5a6570"))
self.edge_tab.setIcon(icon("chevron-left", color=self._pal.text_muted))
self.edge_tab.setCursor(Qt.PointingHandCursor)
self.edge_tab.setToolTip(tr("help_agent.show_tooltip"))
self.edge_tab.clicked.connect(self._show_launcher)
@@ -186,7 +177,7 @@ class HelpAgentWidget(QWidget):
# A left-side chevron collapses the assistant to the edge…
self.collapse_btn = QPushButton(self)
self.collapse_btn.setObjectName("helpCollapseBtn")
self.collapse_btn.setIcon(icon("chevron-right", color="#5a6570"))
self.collapse_btn.setIcon(icon("chevron-right", color=self._pal.text_muted))
self.collapse_btn.setCursor(Qt.PointingHandCursor)
self.collapse_btn.setToolTip(tr("help_agent.hide_tooltip"))
self.collapse_btn.clicked.connect(self._hide_to_edge)
@@ -222,7 +213,7 @@ class HelpAgentWidget(QWidget):
hb.addWidget(self.title, 1)
self.min_btn = QPushButton(header)
self.min_btn.setObjectName("helpMinBtn")
self.min_btn.setIcon(icon("minus", color="#5a6570"))
self.min_btn.setIcon(icon("minus", color=self._pal.text_muted))
self.min_btn.setFixedSize(24, 24)
self.min_btn.setCursor(Qt.PointingHandCursor)
self.min_btn.setToolTip(tr("help_agent.collapse_tooltip"))
@@ -318,17 +309,19 @@ class HelpAgentWidget(QWidget):
p = self._pal
text = (content or "").replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
text = text.replace("\n", "<br>")
# bgcolor= is a solid-only HTML attribute, hence accent_wash (pre-blended)
# rather than the translucent accent_soft used in style sheets.
if who == "user":
align, bg, label_color = "right", p["user_bg"], p["user_label"]
align, bg, label_color = "right", p.accent_wash, p.accent
label = tr("chat.you")
else:
align, bg, label_color = "left", p["bot_bg"], p["bot_label"]
align, bg, label_color = "left", p.surface_raised, p.success
label = tr("help_agent.title")
return (
f'<table width="100%" cellspacing="0" cellpadding="0"><tr>'
f'<td align="{align}">'
f'<table width="80%" cellspacing="0" cellpadding="7" bgcolor="{bg}"><tr>'
f'<td style="color:{p["text"]};">'
f'<td style="color:{p.text};">'
f'<b style="color:{label_color};">{label}</b><br>{text}'
f'</td></tr></table></td></tr></table>'
'<div style="line-height:6px;">&nbsp;</div>' # gap between turns
+24 -12
View File
@@ -21,7 +21,12 @@ from PySide6.QtGui import QBrush, QColor, QIcon, QPainter, QPen, QPixmap
from PySide6.QtSvg import QSvgRenderer
from PySide6.QtWidgets import QComboBox, QHBoxLayout, QLabel, QWidget
_COLOR = "#8b8d98" # neutral grey, visible on both light and dark buttons
def _default_color() -> str:
"""The default icon tint: the theme's muted text colour, so glyphs sit at
the same weight as the labels beside them. Resolved per call — icons are
painted bitmaps, so a theme switch must repaint them, not restyle them."""
from ..theme import current_palette
return current_palette().text_muted
def _hidpi_pixmap(size: int) -> QPixmap:
@@ -230,10 +235,11 @@ def icon_picker_combo(current: str = "") -> QComboBox:
return combo
def icon(name: str, size: int = 16, color: str = _COLOR) -> QIcon:
def icon(name: str, size: int = 16, color: str | None = None) -> QIcon:
"""A flat thin-line icon for ``name`` (see ``_PATHS`` for the full list),
tinted ``color`` — rendered from local SVG data, no image files/network.
Stroke width 1.7 matches the Nova Platform web app's shared icon set."""
color = color or _default_color()
# A user-added custom icon (full SVG under ~/.cowork_local/icons) is rendered
# as-is (keeps its own colours). Then built-in glyphs; then a neutral fallback.
if name not in _PATHS:
@@ -264,9 +270,10 @@ def icon(name: str, size: int = 16, color: str = _COLOR) -> QIcon:
return QIcon(pm)
def _panel_icon(fill_left: bool, size: int = 16, color: str = _COLOR) -> QIcon:
def _panel_icon(fill_left: bool, size: int = 16, color: str | None = None) -> QIcon:
"""A rounded panel split by a divider, with one narrow side filled solid
(the 'sidebar' toggle look)."""
color = color or _default_color()
pm = _hidpi_pixmap(size)
p = QPainter(pm)
p.setRenderHint(QPainter.Antialiasing)
@@ -302,19 +309,24 @@ def collapse_right_icon() -> QIcon:
return _panel_icon(fill_left=False)
def pixmap(name: str, size: int = 16, color: str = _COLOR) -> QPixmap:
def pixmap(name: str, size: int = 16, color: str | None = None) -> QPixmap:
"""The line-icon ``name`` as a QPixmap (for QLabel.setPixmap — QLabel has no
setIcon). Same glyph/renderer as ``icon()``."""
return icon(name, size, color).pixmap(size, size)
# Status-LED colors — a filled dot, the one place a solid glyph (not a line
# Status-LED colours — a filled dot, the one place a solid glyph (not a line
# icon) is the right metaphor for an on/off/running indicator.
DOT_GREEN = "#22c55e"
DOT_RED = "#ef4444"
DOT_AMBER = "#f59e0b"
DOT_BLUE = "#3b82f6"
DOT_GREY = "#9ca3af"
#
# Deliberately the SAME in light and dark. An LED means one thing regardless of
# theme, and these mid-saturation hues clear 3:1 against both #0B0B0C and
# #FFFFFF, so a status dot never has to be re-learned. Everything else in the
# UI goes through theme.palette(); this is the documented exception.
DOT_GREEN = "#2EA043"
DOT_RED = "#E5484D"
DOT_AMBER = "#B7791F"
DOT_BLUE = "#4C7BE8"
DOT_GREY = "#8B8B94"
def dot_icon(color: str = DOT_GREY, size: int = 12) -> QIcon:
@@ -338,7 +350,7 @@ class IconLabel(QWidget):
status labels (lock/unlock, …) keep working."""
def __init__(self, name: str, text: str = "", *, size: int = 16,
color: str = _COLOR, gap: int = 6, parent=None):
color: str | None = None, gap: int = 6, parent=None):
super().__init__(parent)
self._size = size
lay = QHBoxLayout(self)
@@ -357,7 +369,7 @@ class IconLabel(QWidget):
def setText(self, text: str) -> None: # noqa: N802 — QLabel-compatible alias
self._text.setText(text)
def set_icon(self, name: str, color: str = _COLOR) -> None:
def set_icon(self, name: str, color: str | None = None) -> None:
self._icon.setPixmap(pixmap(name, self._size, color))
def text_label(self) -> QLabel:
+3 -1
View File
@@ -36,6 +36,7 @@ from ..core import agent_roles, audit_log
from ..core import usage_tracker as ut
from ..i18n import on_language_changed, tr
from ..state import AppContext
from ..theme import current_palette
from .icons import icon, DOT_GREEN, DOT_RED, DOT_AMBER
from .widgets import BudgetCard, StatCard, fmt_tokens
@@ -801,7 +802,8 @@ class MonitoringTab(QWidget):
mark = f"<span style='color:{DOT_RED};'>✗</span>"
name = event.get("name", "") or event.get("kind", "")
rel = _relative_time(event.get("ts", ""))
suffix = f" <span style='color:#8b8d98;'>— {rel}</span>" if rel else ""
muted = current_palette().text_muted
suffix = f" <span style='color:{muted};'>— {rel}</span>" if rel else ""
return f"{mark} {name}{suffix}"
def _refresh_usage_cards(self) -> None:
+719 -716
View File
File diff suppressed because it is too large Load Diff
+648 -649
View File
File diff suppressed because it is too large Load Diff
+5 -9
View File
@@ -13,7 +13,7 @@ from PySide6.QtCore import QPointF, Qt
from PySide6.QtGui import QBrush, QColor, QLinearGradient, QPainter, QPainterPath, QPen
from PySide6.QtWidgets import QWidget
from ..theme import ACCENT
from ..theme import current_palette
def _endpoint_label_rect(point_x: float, point_y: float, text_width: float,
@@ -75,17 +75,13 @@ class SplineChart(QWidget):
self._refs = list(refs or [])
self.update()
def _dark(self) -> bool:
from .chat_view import _app_theme
return _app_theme() == "dark"
def paintEvent(self, _e): # noqa: N802
p = QPainter(self)
p.setRenderHint(QPainter.Antialiasing)
dark = self._dark()
grid = QColor("#1A2D4A" if dark else "#C7DEEE")
text = QColor("#8FB2D4" if dark else "#5C7A94")
accent = QColor(ACCENT)
tok = current_palette()
grid = QColor(tok.chart_grid)
text = QColor(tok.chart_label)
accent = QColor(tok.accent)
w, h = self.width(), self.height()
pts = self._points
+998 -993
View File
File diff suppressed because it is too large Load Diff
+5 -4
View File
@@ -75,12 +75,13 @@ class TaskEditorDialog(QDialog):
# with a lighter box) — just a light outline, consistent with the rest of
# the app. The combo drop-down popup keeps a solid dark background so its
# items stay readable.
# Inputs in this dense form sit flat on the dialog rather than on their
# own raised surface — the app-wide sheet styles everything else here,
# including the combo popup, so nothing needs a colour override.
self.setStyleSheet(
"QLineEdit, QPlainTextEdit, QListWidget, QComboBox, QAbstractSpinBox {"
" background: transparent; border: 1px solid rgba(140,146,152,0.45);"
" border-radius: 6px; }"
"QListWidget::item { background: transparent; }"
"QComboBox QAbstractItemView { background: #111D32; color: #E0F0FF; }")
" background: transparent; }"
"QListWidget::item { background: transparent; }")
outer = QVBoxLayout(self)
scroll = QScrollArea()
+9 -8
View File
@@ -27,6 +27,7 @@ from PySide6.QtWidgets import (
)
from ..i18n import on_language_changed, tr
from ..theme import current_palette
from .icons import icon
_IS_WIN = sys.platform == "win32"
@@ -75,8 +76,10 @@ class TerminalPanel(QWidget):
# ---- header (always visible; click to expand/collapse) --------------
self._header = QFrame()
self._header.setObjectName("termHeader")
_tp = current_palette()
self._header.setStyleSheet(
"#termHeader { background: rgba(0,0,0,0.06); border-radius: 6px; }")
f"#termHeader {{ background: {_tp.surface};"
f" border-radius: {_tp.radius}px; }}")
hb = QHBoxLayout(self._header)
hb.setContentsMargins(8, 4, 8, 4)
self._toggle_btn = QPushButton()
@@ -107,8 +110,7 @@ class TerminalPanel(QWidget):
mono.setStyleHint(QFont.Monospace)
mono.setPointSize(10)
self.output.setFont(mono)
self.output.setStyleSheet(
"#termOutput { background: #1e1e1e; color: #d4d4d4; border: none; }")
# Surface comes from the central style sheet (#termOutput) — see theme.py.
self.output.setMinimumHeight(160)
bl.addWidget(self.output, 1)
@@ -119,9 +121,7 @@ class TerminalPanel(QWidget):
self.input = _TermInput()
self.input.setObjectName("termInput")
self.input.setFont(mono)
self.input.setStyleSheet(
"#termInput { background: #1e1e1e; color: #d4d4d4; border: 1px solid #3c3c3c; "
"border-radius: 6px; padding: 4px 8px; }")
# Surface comes from the central style sheet (#termInput) — see theme.py.
self.input.returnPressed.connect(self._run_current)
self.input.complete_requested.connect(self._complete)
self.input.history_prev.connect(lambda: self._history_move(-1))
@@ -282,11 +282,12 @@ class TerminalPanel(QWidget):
if not text:
return
from PySide6.QtGui import QColor, QTextCursor
colors = {"cmd": "#4ec9b0", "err": "#f48771", "ok": "#6a9955", "out": "#d4d4d4"}
p = current_palette()
colors = {"cmd": p.code_type, "err": p.code_error, "ok": p.code_comment, "out": p.code_fg}
cursor = self.output.textCursor()
cursor.movePosition(QTextCursor.End)
fmt = cursor.charFormat()
fmt.setForeground(QColor(colors.get(role, "#d4d4d4")))
fmt.setForeground(QColor(colors.get(role, p.code_fg)))
cursor.setCharFormat(fmt)
cursor.insertText(text)
self.output.setTextCursor(cursor)
+32 -28
View File
@@ -8,36 +8,40 @@ from pathlib import Path
from PySide6.QtCore import QEvent, QObject, QPointF, QRectF, Qt, Signal
from PySide6.QtGui import QColor, QPainter, QPen
from PySide6.QtWidgets import (
QAbstractSpinBox, QComboBox, QDoubleSpinBox, QFrame, QGraphicsDropShadowEffect,
QAbstractSpinBox, QComboBox, QDoubleSpinBox, QFrame,
QHBoxLayout, QLabel, QListWidget, QListWidgetItem, QPushButton, QSizePolicy,
QVBoxLayout, QWidget,
)
from ..core.flows import STEP_DONE, STEP_ERROR, STEP_PENDING, STEP_RUNNING
from ..theme import ACCENT
from ..theme import current_palette
from .icons import DOT_BLUE, DOT_GREEN, DOT_GREY, DOT_RED, dot_icon, icon
def _style_card(frame: QFrame) -> None:
"""Give a stat/budget card its surface. Flat by design: the raised surface
plus a hairline is what separates it from the page — the old drop shadow
made a grid of these look like it was hovering off the screen."""
p = current_palette()
frame.setStyleSheet(
f"QFrame {{ background: {p.surface}; border: 1px solid {p.border};"
f" border-radius: {p.radius_lg}px; }}")
class StatCard(QFrame):
"""A titled value card (e.g. token count + its cost as the subtitle) —
shared by Dashboard and Monitoring's token/cost displays."""
def __init__(self):
super().__init__()
self.setFrameShape(QFrame.StyledPanel)
self.setStyleSheet(
"QFrame { border: 1px solid rgba(140,146,152,0.35); border-radius: 16px; }")
shadow = QGraphicsDropShadowEffect(self)
shadow.setBlurRadius(18)
shadow.setOffset(0, 3)
shadow.setColor(QColor(0, 0, 0, 60))
self.setGraphicsEffect(shadow)
self.setFrameShape(QFrame.NoFrame)
_style_card(self)
lay = QVBoxLayout(self)
self.title_lbl = QLabel("")
self.title_lbl.setObjectName("hint")
self.title_lbl.setStyleSheet("border: none;")
self.value_lbl = QLabel("—")
self.value_lbl.setStyleSheet("border: none; font-size: 20px; font-weight: 700;")
self.value_lbl.setStyleSheet("border: none; font-size: 22px; font-weight: 600;")
self.sub_lbl = QLabel("")
self.sub_lbl.setObjectName("hint")
self.sub_lbl.setStyleSheet("border: none;")
@@ -66,20 +70,14 @@ class BudgetCard(QFrame):
def __init__(self):
super().__init__()
self.setFrameShape(QFrame.StyledPanel)
self.setStyleSheet(
"QFrame { border: 1px solid rgba(140,146,152,0.35); border-radius: 16px; }")
shadow = QGraphicsDropShadowEffect(self)
shadow.setBlurRadius(18)
shadow.setOffset(0, 3)
shadow.setColor(QColor(0, 0, 0, 60))
self.setGraphicsEffect(shadow)
self.setFrameShape(QFrame.NoFrame)
_style_card(self)
lay = QVBoxLayout(self)
self.title_lbl = QLabel("")
self.title_lbl.setObjectName("hint")
self.title_lbl.setStyleSheet("border: none;")
self.value_lbl = QLabel("—")
self._value_style = "border: none; font-size: 20px; font-weight: 700;"
self._value_style = "border: none; font-size: 22px; font-weight: 600;"
self.value_lbl.setStyleSheet(self._value_style)
self.sub_lbl = QLabel("")
self.sub_lbl.setObjectName("hint")
@@ -109,7 +107,7 @@ class BudgetCard(QFrame):
self.title_lbl.setText(title)
self.value_lbl.setText(value)
self.value_lbl.setStyleSheet(
self._value_style + (" color: #E5484D;" if warn else ""))
self._value_style + (f" color: {current_palette().danger};" if warn else ""))
self.sub_lbl.setText(sub)
@@ -185,15 +183,16 @@ class CollapseStrip(QWidget):
p = QPainter(self)
p.setRenderHint(QPainter.Antialiasing)
w = self.width()
accent = QColor(ACCENT) if self._hover else QColor("#8b8d98")
tok = current_palette()
accent = QColor(tok.accent) if self._hover else QColor(tok.text_faint)
# A small rounded "button" at the top carries the expand arrow so the
# collapsed panel always shows a clear, clickable affordance.
bw = min(w - 2.0, 16.0)
btn = QRectF((w - bw) / 2.0, 6.0, bw, 18.0)
p.setPen(QPen(QColor(139, 144, 150, 130), 1.0))
p.setBrush(QColor(155, 160, 166, 70) if self._hover else QColor(155, 160, 166, 32))
p.drawRoundedRect(btn, 4.0, 4.0)
p.setPen(QPen(QColor(tok.border_strong), 1.0))
p.setBrush(QColor(tok.hover if self._hover else tok.surface))
p.drawRoundedRect(btn, float(tok.radius_sm), float(tok.radius_sm))
cx = w / 2.0
cy = btn.center().y()
@@ -211,7 +210,7 @@ class CollapseStrip(QWidget):
# thin handle line below the button
p.setPen(Qt.NoPen)
p.setBrush(QColor(155, 160, 166, 90))
p.setBrush(QColor(tok.border_strong))
line_w = 2.0
x = (w - line_w) / 2.0
ltop = btn.bottom() + 6.0
@@ -226,7 +225,12 @@ class PlanSection(QWidget):
close). Hidden until it has steps; updated in place as the agent calls
``update_plan``."""
_COLORS = {STEP_RUNNING: ACCENT, STEP_DONE: "#6fe3a4", STEP_ERROR: "#ef6368"}
@staticmethod
def _step_color(status: str) -> str | None:
"""Row text colour per step status; None leaves the default. Resolved
per call so it follows a live theme switch."""
p = current_palette()
return {STEP_RUNNING: p.accent, STEP_DONE: p.success, STEP_ERROR: p.danger}.get(status)
@staticmethod
def _step_icon(status: str):
@@ -272,7 +276,7 @@ class PlanSection(QWidget):
continue
status = str((s or {}).get("status", STEP_PENDING)).strip().lower()
item = QListWidgetItem(self._step_icon(status), f" {title}")
color = self._COLORS.get(status)
color = self._step_color(status)
if color:
item.setForeground(QColor(color))
self.list.addItem(item)