Add a chat list and server-minted new chats on top of the desk API's
GET/POST /desk/chats:
- server_client.list_chats() / create_chat(), same shape and error
handling as list_outbox_files().
- Switching is config.set_session(id): a plain reassignment of the
SESSION_ID every call reads, persisted to session.json. The window
refuses to switch mid-turn rather than locking.
- gui/chat_list_window.py: a Chats dialog (Ctrl+K) with a QListWidget,
populated on open, with Refresh and New Chat. Network calls run as
QRunnables on the global QThreadPool and come back through Signals,
never from a slot. The header New chat (Ctrl+N) uses the same path.
- Switching clears the local transcript; there is no endpoint for a
past chat's messages. Bolt's memory is per user, so it carries over.
- Terminal: /chats, /chat <n|id>, and /new (server-minted id, local
fallback).
This commit also carries the uncommitted work since 54030a7 that turned
this Bolt-Pet fork into Bolt Console: the pet, wake word, TTS and screen
features removed; a Claude Code-style terminal client; the PySide6
window with tool cards, approvals and diffs; mic dictation through the
server's /desk/stt relay; filectl/JSON repairs; and detection of
invented tool results.
Build output (build/, dist/, Release/, *.spec) is now ignored.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
197 lines
8.0 KiB
Python
197 lines
8.0 KiB
Python
"""Colours, fonts and the application stylesheet.
|
|
|
|
One dark theme, tuned by hand: near-black charcoal surfaces a step apart, a
|
|
single warm accent (the bolt's amber), and colour used only where it carries
|
|
meaning — green for done, red for failed, amber for "needs you".
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import sys
|
|
|
|
from PySide6.QtGui import QColor, QFont, QFontDatabase, QPalette
|
|
|
|
BG = "#0e1014" # window
|
|
SURFACE = "#15181e" # cards, composer
|
|
SURFACE_HI = "#1b1f27" # hover / user bubble
|
|
BORDER = "#252a34"
|
|
BORDER_HI = "#343b48"
|
|
TEXT = "#e7e9ee"
|
|
TEXT_DIM = "#9aa1ae"
|
|
TEXT_FAINT = "#6b7280"
|
|
ACCENT = "#f5b83d" # the bolt
|
|
ACCENT_HI = "#ffc95c"
|
|
ACCENT_INK = "#1a1305" # text on accent
|
|
GREEN = "#4ec98a"
|
|
RED = "#f06a6a"
|
|
BLUE = "#7aa7ff"
|
|
CODE = "#e8c98f" # inline code in replies
|
|
DIFF_ADD_BG = "#12291d"
|
|
DIFF_DEL_BG = "#301719"
|
|
|
|
RADIUS = 12
|
|
COLUMN_WIDTH = 820 # the transcript's reading column
|
|
|
|
|
|
def _first_available(families, fallback: str) -> str:
|
|
installed = set(QFontDatabase.families())
|
|
for family in families:
|
|
if family in installed:
|
|
return family
|
|
return fallback
|
|
|
|
|
|
def ui_font_family() -> str:
|
|
return _first_available(
|
|
["Segoe UI Variable Text", "Inter", "SF Pro Text", "Segoe UI", "Cantarell", "Ubuntu"],
|
|
QFont().defaultFamily(),
|
|
)
|
|
|
|
|
|
def mono_font_family() -> str:
|
|
return _first_available(
|
|
["Cascadia Code", "Cascadia Mono", "JetBrains Mono", "SF Mono", "Consolas",
|
|
"DejaVu Sans Mono", "Menlo"],
|
|
"monospace",
|
|
)
|
|
|
|
|
|
def mono_font(size: float = 9.5) -> QFont:
|
|
font = QFont(mono_font_family())
|
|
font.setPointSizeF(size)
|
|
font.setStyleHint(QFont.Monospace)
|
|
return font
|
|
|
|
|
|
def palette() -> QPalette:
|
|
pal = QPalette()
|
|
for role, colour in (
|
|
(QPalette.Window, BG), (QPalette.Base, SURFACE), (QPalette.AlternateBase, SURFACE_HI),
|
|
(QPalette.Text, TEXT), (QPalette.WindowText, TEXT), (QPalette.ButtonText, TEXT),
|
|
(QPalette.Button, SURFACE), (QPalette.Highlight, "#3a3222"),
|
|
(QPalette.HighlightedText, TEXT), (QPalette.Link, BLUE),
|
|
(QPalette.PlaceholderText, TEXT_FAINT), (QPalette.ToolTipBase, SURFACE_HI),
|
|
(QPalette.ToolTipText, TEXT),
|
|
):
|
|
pal.setColor(role, QColor(colour))
|
|
return pal
|
|
|
|
|
|
def stylesheet() -> str:
|
|
mono = mono_font_family()
|
|
return f"""
|
|
QWidget {{ color: {TEXT}; font-size: 10.5pt; }}
|
|
QMainWindow, #Root {{ background: {BG}; }}
|
|
QToolTip {{ background: {SURFACE_HI}; color: {TEXT}; border: 1px solid {BORDER_HI};
|
|
border-radius: 6px; padding: 5px 8px; }}
|
|
|
|
/* header */
|
|
#Header {{ background: {BG}; border-bottom: 1px solid {BORDER}; }}
|
|
#Wordmark {{ font-size: 12pt; font-weight: 600; letter-spacing: 0.3px; }}
|
|
#WordmarkSub {{ color: {TEXT_FAINT}; font-size: 9pt; }}
|
|
#Chip {{ background: {SURFACE}; border: 1px solid {BORDER}; border-radius: 15px;
|
|
padding: 5px 12px 5px 30px; color: {TEXT_DIM}; text-align: left; }}
|
|
#Chip:hover {{ border-color: {BORDER_HI}; color: {TEXT}; background: {SURFACE_HI}; }}
|
|
#Chip:disabled {{ color: {TEXT_FAINT}; }}
|
|
#Segment {{ background: {SURFACE}; border: 1px solid {BORDER}; border-radius: 15px; }}
|
|
#SegmentButton {{ background: transparent; border: none; border-radius: 12px;
|
|
padding: 4px 14px; color: {TEXT_DIM}; font-weight: 500; }}
|
|
#SegmentButton:hover {{ color: {TEXT}; }}
|
|
#SegmentButton:checked {{ background: {SURFACE_HI}; color: {TEXT}; }}
|
|
#SegmentButton[danger="true"]:checked {{ background: #3a2c12; color: {ACCENT_HI}; }}
|
|
|
|
/* transcript */
|
|
QScrollArea {{ background: transparent; border: none; }}
|
|
#Transcript {{ background: {BG}; }}
|
|
QScrollBar:vertical {{ background: transparent; width: 10px; margin: 4px 2px; }}
|
|
QScrollBar::handle:vertical {{ background: {BORDER}; border-radius: 3px; min-height: 40px; }}
|
|
QScrollBar::handle:vertical:hover {{ background: {BORDER_HI}; }}
|
|
QScrollBar::add-line, QScrollBar::sub-line, QScrollBar::add-page, QScrollBar::sub-page {{
|
|
background: none; height: 0; }}
|
|
QScrollBar:horizontal {{ height: 0; }}
|
|
|
|
#UserBubble {{ background: {SURFACE_HI}; border: 1px solid {BORDER}; border-radius: 16px; }}
|
|
#AssistantText {{ color: {TEXT}; }}
|
|
#Holding {{ color: {TEXT_FAINT}; font-style: italic; }}
|
|
#Notice {{ color: {TEXT_DIM}; font-size: 9.5pt; }}
|
|
#NoticeError {{ color: {RED}; }}
|
|
|
|
#ToolCard {{ background: {SURFACE}; border: 1px solid {BORDER}; border-radius: {RADIUS}px; }}
|
|
#ToolCard[state="waiting"] {{ border-color: #5a4418; }}
|
|
#ToolHeader {{ background: transparent; border: none; border-radius: {RADIUS}px; }}
|
|
#ToolHeader:hover {{ background: {SURFACE_HI}; }}
|
|
#ToolName {{ font-weight: 600; }}
|
|
#ToolArg {{ color: {TEXT_DIM}; font-family: "{mono}"; font-size: 9.5pt; }}
|
|
#ToolOutput {{ background: transparent; color: {TEXT_DIM}; border: none; border-top: 1px solid {BORDER};
|
|
font-family: "{mono}"; font-size: 9.2pt; padding: 10px 14px; }}
|
|
#Pill {{ border-radius: 9px; padding: 1px 9px; font-size: 8.5pt; font-weight: 600; }}
|
|
#Pill[kind="running"] {{ background: #1c2433; color: {BLUE}; }}
|
|
#Pill[kind="ok"] {{ background: #13271d; color: {GREEN}; }}
|
|
#Pill[kind="error"] {{ background: #2e1618; color: {RED}; }}
|
|
#Pill[kind="waiting"] {{ background: #33280f; color: {ACCENT}; }}
|
|
#Pill[kind="declined"] {{ background: #22252c; color: {TEXT_DIM}; }}
|
|
|
|
#ApprovalBar {{ border-top: 1px solid {BORDER}; background: transparent; }}
|
|
#ApprovalQuestion {{ color: {TEXT}; }}
|
|
#Primary {{ background: {ACCENT}; color: {ACCENT_INK}; border: none; border-radius: 8px;
|
|
padding: 6px 16px; font-weight: 600; }}
|
|
#Primary:hover {{ background: {ACCENT_HI}; }}
|
|
#Secondary {{ background: {SURFACE_HI}; color: {TEXT}; border: 1px solid {BORDER_HI};
|
|
border-radius: 8px; padding: 6px 14px; }}
|
|
#Secondary:hover {{ border-color: {TEXT_FAINT}; }}
|
|
#Ghost {{ background: transparent; color: {TEXT_DIM}; border: none; padding: 6px 10px; }}
|
|
#Ghost:hover {{ color: {TEXT}; }}
|
|
|
|
/* empty state */
|
|
#Hero {{ font-size: 19pt; font-weight: 600; }}
|
|
#HeroSub {{ color: {TEXT_DIM}; }}
|
|
#Suggestion {{ background: {SURFACE}; border: 1px solid {BORDER}; border-radius: 12px;
|
|
padding: 12px 14px; color: {TEXT_DIM}; text-align: left; }}
|
|
#Suggestion:hover {{ border-color: {BORDER_HI}; color: {TEXT}; background: {SURFACE_HI}; }}
|
|
|
|
/* composer */
|
|
#Composer {{ background: {SURFACE}; border: 1px solid {BORDER}; border-radius: 18px; }}
|
|
#Composer[focused="true"] {{ border-color: {BORDER_HI}; }}
|
|
#ComposerEdit {{ background: transparent; border: none; padding: 0; selection-background-color: #3a3222; }}
|
|
#Hint {{ color: {TEXT_FAINT}; font-size: 8.8pt; }}
|
|
|
|
/* chats dialog */
|
|
QDialog#ChatDialog {{ background: {BG}; }}
|
|
#ChatList {{ background: {SURFACE}; border: 1px solid {BORDER}; border-radius: {RADIUS}px;
|
|
padding: 4px; outline: none; }}
|
|
#ChatList::item {{ border-radius: 9px; margin: 1px 0; }}
|
|
#ChatList::item:hover {{ background: {SURFACE_HI}; }}
|
|
#ChatList::item:selected {{ background: #262017; border: 1px solid #4a3a18; }}
|
|
#ChatTitle {{ font-weight: 600; }}
|
|
#ChatTime {{ color: {TEXT_FAINT}; font-size: 9pt; }}
|
|
#ChatPreview {{ color: {TEXT_DIM}; font-size: 9.5pt; }}
|
|
"""
|
|
|
|
|
|
def dark_title_bar(window) -> None:
|
|
"""Windows 11: dark caption, painted the same colour as the window so the
|
|
title bar and the app read as one surface. Silently does nothing on other
|
|
platforms or older Windows."""
|
|
if sys.platform != "win32":
|
|
return
|
|
try:
|
|
import ctypes
|
|
from ctypes import wintypes
|
|
|
|
hwnd = wintypes.HWND(int(window.winId()))
|
|
dwm = ctypes.windll.dwmapi
|
|
|
|
def attr(key: int, value: int) -> None:
|
|
data = ctypes.c_int(value)
|
|
dwm.DwmSetWindowAttribute(hwnd, key, ctypes.byref(data), ctypes.sizeof(data))
|
|
|
|
attr(20, 1) # DWMWA_USE_IMMERSIVE_DARK_MODE
|
|
colour = BG.lstrip("#")
|
|
r, g, b = int(colour[0:2], 16), int(colour[2:4], 16), int(colour[4:6], 16)
|
|
attr(35, (b << 16) | (g << 8) | r) # DWMWA_CAPTION_COLOR (COLORREF is 0x00BBGGRR)
|
|
border = BORDER.lstrip("#")
|
|
r, g, b = int(border[0:2], 16), int(border[2:4], 16), int(border[4:6], 16)
|
|
attr(34, (b << 16) | (g << 8) | r) # DWMWA_BORDER_COLOR
|
|
except Exception:
|
|
pass
|