Files
Bolt-Console/bolt_console/gui/theme.py
T
themajesticmagicianandClaude Opus 5.5 3977804ae3 Bolt Console: desktop app with chat list / new chat, dictation, file tools
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>
2026-09-25 19:50:10 -06:00

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