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>
220 lines
7.8 KiB
Python
220 lines
7.8 KiB
Python
"""Icons drawn with QPainter rather than shipped as files or borrowed from an
|
|
emoji font — crisp at any DPI, recolourable, and no assets to lose.
|
|
|
|
`paint(painter, name, rect, colour)` draws into a rect; `icon(name, colour)`
|
|
wraps that as a QIcon; `IconLabel` is a tiny widget that shows one.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from PySide6.QtCore import QBuffer, QByteArray, QIODevice, QPointF, QRectF, QSize, Qt
|
|
from PySide6.QtGui import QColor, QIcon, QImage, QPainter, QPainterPath, QPen, QPixmap
|
|
from PySide6.QtWidgets import QWidget
|
|
|
|
|
|
def _pen(colour: QColor, width: float) -> QPen:
|
|
pen = QPen(colour, width)
|
|
pen.setCapStyle(Qt.RoundCap)
|
|
pen.setJoinStyle(Qt.RoundJoin)
|
|
return pen
|
|
|
|
|
|
def paint(p: QPainter, name: str, rect: QRectF, colour) -> None:
|
|
colour = QColor(colour)
|
|
p.save()
|
|
p.setRenderHint(QPainter.Antialiasing)
|
|
# Everything below is drawn on a 24x24 grid, then scaled into *rect*.
|
|
side = min(rect.width(), rect.height())
|
|
p.translate(rect.center().x() - side / 2, rect.center().y() - side / 2)
|
|
p.scale(side / 24.0, side / 24.0)
|
|
stroke = _pen(colour, 1.9)
|
|
|
|
if name == "bolt":
|
|
path = QPainterPath()
|
|
for i, (x, y) in enumerate([(13.5, 2), (4.5, 13.5), (11, 13.5), (9.5, 22),
|
|
(19.5, 9.5), (13, 9.5), (13.5, 2)]):
|
|
path.moveTo(x, y) if i == 0 else path.lineTo(x, y)
|
|
p.setPen(Qt.NoPen)
|
|
p.setBrush(colour)
|
|
p.drawPath(path)
|
|
elif name == "send":
|
|
p.setPen(_pen(colour, 2.4))
|
|
p.drawLine(QPointF(12, 19), QPointF(12, 5.5))
|
|
p.drawLine(QPointF(6.5, 11), QPointF(12, 5.5))
|
|
p.drawLine(QPointF(17.5, 11), QPointF(12, 5.5))
|
|
elif name == "folder":
|
|
p.setPen(stroke)
|
|
path = QPainterPath()
|
|
path.moveTo(3, 7)
|
|
path.lineTo(3, 18.5)
|
|
path.quadTo(3, 20, 4.5, 20)
|
|
path.lineTo(19.5, 20)
|
|
path.quadTo(21, 20, 21, 18.5)
|
|
path.lineTo(21, 9.5)
|
|
path.quadTo(21, 8, 19.5, 8)
|
|
path.lineTo(11.5, 8)
|
|
path.lineTo(9.5, 5.5)
|
|
path.lineTo(4.5, 5.5)
|
|
path.quadTo(3, 5.5, 3, 7)
|
|
p.drawPath(path)
|
|
elif name == "shell":
|
|
p.setPen(stroke)
|
|
p.drawRoundedRect(QRectF(2.5, 4, 19, 16), 3, 3)
|
|
p.drawLine(QPointF(7, 10), QPointF(10, 12.5))
|
|
p.drawLine(QPointF(10, 12.5), QPointF(7, 15))
|
|
p.drawLine(QPointF(12.5, 15.5), QPointF(17, 15.5))
|
|
elif name in ("read", "write", "edit"):
|
|
p.setPen(stroke)
|
|
page = QPainterPath()
|
|
page.moveTo(14, 2.5)
|
|
page.lineTo(6, 2.5)
|
|
page.quadTo(4.5, 2.5, 4.5, 4)
|
|
page.lineTo(4.5, 20)
|
|
page.quadTo(4.5, 21.5, 6, 21.5)
|
|
page.lineTo(18, 21.5)
|
|
page.quadTo(19.5, 21.5, 19.5, 20)
|
|
page.lineTo(19.5, 8)
|
|
page.closeSubpath()
|
|
p.drawPath(page)
|
|
p.drawLine(QPointF(14, 2.5), QPointF(14, 8))
|
|
p.drawLine(QPointF(14, 8), QPointF(19.5, 8))
|
|
if name == "read":
|
|
for y in (12, 15, 18):
|
|
p.drawLine(QPointF(8, y), QPointF(16, y))
|
|
elif name == "write":
|
|
p.drawLine(QPointF(12, 11.5), QPointF(12, 18))
|
|
p.drawLine(QPointF(8.75, 14.75), QPointF(15.25, 14.75))
|
|
else:
|
|
p.drawLine(QPointF(8, 18), QPointF(15.5, 10.5))
|
|
p.drawLine(QPointF(8, 18), QPointF(8, 15.5))
|
|
p.drawLine(QPointF(8, 18), QPointF(10.5, 18))
|
|
elif name == "list":
|
|
p.setPen(stroke)
|
|
for y in (6.5, 12, 17.5):
|
|
p.drawPoint(QPointF(4.5, y))
|
|
p.drawLine(QPointF(8.5, y), QPointF(20, y))
|
|
elif name == "blocked":
|
|
p.setPen(stroke)
|
|
p.drawEllipse(QRectF(3.5, 3.5, 17, 17))
|
|
p.drawLine(QPointF(7, 17), QPointF(17, 7))
|
|
elif name == "chevron":
|
|
p.setPen(stroke)
|
|
p.drawLine(QPointF(9, 6), QPointF(15, 12))
|
|
p.drawLine(QPointF(15, 12), QPointF(9, 18))
|
|
elif name == "chats":
|
|
p.setPen(stroke)
|
|
bubble = QPainterPath()
|
|
bubble.addRoundedRect(QRectF(3, 4, 18, 13), 4, 4)
|
|
p.drawPath(bubble)
|
|
p.drawLine(QPointF(8, 17), QPointF(7, 21))
|
|
p.drawLine(QPointF(7, 21), QPointF(12, 17))
|
|
for x in (8, 12, 16):
|
|
p.drawPoint(QPointF(x, 10.5))
|
|
elif name == "mic":
|
|
p.setPen(stroke)
|
|
p.drawRoundedRect(QRectF(9, 3, 6, 11.5), 3, 3)
|
|
arc = QPainterPath()
|
|
arc.moveTo(5.5, 11)
|
|
arc.cubicTo(5.5, 19, 18.5, 19, 18.5, 11)
|
|
p.drawPath(arc)
|
|
p.drawLine(QPointF(12, 17), QPointF(12, 21))
|
|
elif name == "stop":
|
|
p.setPen(Qt.NoPen)
|
|
p.setBrush(colour)
|
|
p.drawRoundedRect(QRectF(7, 7, 10, 10), 2, 2)
|
|
elif name == "plus":
|
|
p.setPen(_pen(colour, 2.1))
|
|
p.drawLine(QPointF(12, 5), QPointF(12, 19))
|
|
p.drawLine(QPointF(5, 12), QPointF(19, 12))
|
|
elif name == "file":
|
|
paint(p, "read", QRectF(0, 0, 24, 24), colour)
|
|
p.restore()
|
|
|
|
|
|
def icon(name: str, colour, size: int = 32) -> QIcon:
|
|
pixmap = QPixmap(size, size)
|
|
pixmap.fill(Qt.transparent)
|
|
painter = QPainter(pixmap)
|
|
paint(painter, name, QRectF(0, 0, size, size), colour)
|
|
painter.end()
|
|
return QIcon(pixmap)
|
|
|
|
|
|
APP_ICON_SIZES = (16, 24, 32, 48, 64, 128, 256)
|
|
|
|
|
|
def app_icon_image(size: int) -> QImage:
|
|
"""An amber rounded square with a dark bolt, at *size* pixels. The one
|
|
drawing behind both the window icon and the .exe's icon (write_ico)."""
|
|
image = QImage(size, size, QImage.Format_ARGB32_Premultiplied)
|
|
image.fill(Qt.transparent)
|
|
p = QPainter(image)
|
|
p.setRenderHint(QPainter.Antialiasing)
|
|
p.setPen(Qt.NoPen)
|
|
p.setBrush(QColor("#f5b83d"))
|
|
p.drawRoundedRect(QRectF(0, 0, size, size), size * 0.26, size * 0.26)
|
|
inset = size * 0.18
|
|
paint(p, "bolt", QRectF(inset, inset, size - 2 * inset, size - 2 * inset), "#1a1305")
|
|
p.end()
|
|
return image
|
|
|
|
|
|
def app_icon() -> QIcon:
|
|
"""The window/taskbar icon."""
|
|
result = QIcon()
|
|
for size in APP_ICON_SIZES:
|
|
result.addPixmap(QPixmap.fromImage(app_icon_image(size)))
|
|
return result
|
|
|
|
|
|
def write_ico(path) -> None:
|
|
"""Save the app icon as a multi-size Windows .ico (PNG-compressed entries,
|
|
which Windows Vista+ reads at every size), for the frozen .exe."""
|
|
import struct
|
|
|
|
blobs = []
|
|
for size in APP_ICON_SIZES:
|
|
data = QByteArray()
|
|
buffer = QBuffer(data)
|
|
buffer.open(QIODevice.WriteOnly)
|
|
app_icon_image(size).save(buffer, "PNG")
|
|
buffer.close()
|
|
blobs.append((size, bytes(data)))
|
|
|
|
header = struct.pack("<HHH", 0, 1, len(blobs)) # reserved, type 1 = icon, count
|
|
offset = len(header) + 16 * len(blobs)
|
|
entries, payload = b"", b""
|
|
for size, blob in blobs:
|
|
edge = 0 if size >= 256 else size # 0 means 256 in the ICO format
|
|
entries += struct.pack("<BBBBHHII", edge, edge, 0, 0, 1, 32, len(blob), offset + len(payload))
|
|
payload += blob
|
|
with open(path, "wb") as handle:
|
|
handle.write(header + entries + payload)
|
|
|
|
|
|
class IconLabel(QWidget):
|
|
"""A fixed-size painted icon, optionally on a rounded tile."""
|
|
|
|
def __init__(self, name: str, colour, size: int = 18, tile=None, parent=None):
|
|
super().__init__(parent)
|
|
self._name, self._colour, self._tile = name, QColor(colour), tile
|
|
self.setFixedSize(QSize(size, size))
|
|
|
|
def set_icon(self, name: str, colour=None) -> None:
|
|
self._name = name
|
|
if colour is not None:
|
|
self._colour = QColor(colour)
|
|
self.update()
|
|
|
|
def paintEvent(self, _event) -> None:
|
|
p = QPainter(self)
|
|
p.setRenderHint(QPainter.Antialiasing)
|
|
rect = QRectF(self.rect())
|
|
if self._tile is not None:
|
|
p.setPen(Qt.NoPen)
|
|
p.setBrush(QColor(self._tile))
|
|
p.drawRoundedRect(rect, rect.width() * 0.28, rect.width() * 0.28)
|
|
pad = rect.width() * 0.22
|
|
rect = rect.adjusted(pad, pad, -pad, -pad)
|
|
paint(p, self._name, rect, self._colour)
|