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>
This commit is contained in:
2026-09-25 19:50:10 -06:00
co-authored by Claude Opus 5.5
parent 54030a7b31
commit 3977804ae3
144 changed files with 5477 additions and 14736 deletions
+2
View File
@@ -0,0 +1,2 @@
"""PySide6 front end. Imported only when the window is wanted, so the
terminal client works without Qt installed."""
+106
View File
@@ -0,0 +1,106 @@
"""The seam between a turn running on a worker thread and the window.
`Session` and `Tools` were written against a terminal-shaped UI (tool_call,
tool_result, confirm, assistant_chunk, …). `Bridge` is that same shape, but
every call becomes a Qt signal — delivered to the window on the GUI thread
through a queued connection — so the whole turn logic is shared, unchanged,
between the terminal and the window.
The one call that has to *return* something is confirm(): the worker blocks on
an Event until the user clicks Allow/Deny in the window, which calls answer()
from the GUI thread. close() releases a pending wait so the app can quit mid
approval without hanging the worker.
"""
from __future__ import annotations
import threading
from PySide6.QtCore import QObject, Signal
class Bridge(QObject):
busy = Signal(bool)
chunk = Signal(str) # one streamed sentence
reply_ended = Signal() # the streamed block is complete
reply = Signal(str) # a whole (non-streamed) reply
holding_line = Signal(str) # "give me a sec" while a tool runs
tool_started = Signal(int, str, str) # id, name, argument
tool_output = Signal(int, str, bool) # id, text, is_error
tool_diff = Signal(int, str, str) # id, old, new
approval_needed = Signal(int, str) # id, question
notice = Signal(str, str) # level (info/warn/error), text
def __init__(self, parent=None):
super().__init__(parent)
self._tool_id = 0
self._answer = "no"
self._answered = threading.Event()
self._closed = False
# ── the terminal-shaped surface Session and Tools call ───────────────
def start_spinner(self, label: str = "Thinking") -> None:
self.busy.emit(True)
def stop_spinner(self) -> None:
self.busy.emit(False)
def assistant_chunk(self, sentence: str) -> None:
if sentence.strip():
self.chunk.emit(sentence.strip())
def end_reply(self) -> None:
self.reply_ended.emit()
def assistant(self, text: str) -> None:
if text.strip():
self.reply.emit(text.strip())
def holding(self, text: str) -> None:
self.holding_line.emit(text.strip())
def tool_call(self, name: str, argument: str) -> None:
self._tool_id += 1
self.tool_started.emit(self._tool_id, name, argument)
def tool_result(self, text: str, error: bool = False) -> None:
self.tool_output.emit(self._tool_id, text, error)
def diff(self, old: str, new: str) -> None:
self.tool_diff.emit(self._tool_id, old, new)
def confirm(self, question: str) -> str:
if self._closed:
return "no"
self._answered.clear()
self._answer = "no"
self.approval_needed.emit(self._tool_id, question)
self._answered.wait()
return self._answer
def info(self, text: str) -> None:
self.notice.emit("info", text)
def warn(self, text: str) -> None:
self.notice.emit("warn", text)
def error(self, text: str) -> None:
self.notice.emit("error", text)
def line(self, text: str = "") -> None:
if text.strip():
self.notice.emit("info", text.strip())
def style(self, text: str, *_names: str) -> str:
return text
# ── called from the GUI thread ───────────────────────────────────────
def answer(self, value: str) -> None:
self._answer = value if value in ("yes", "no", "always") else "no"
self._answered.set()
def close(self) -> None:
self._closed = True
self.answer("no")
+235
View File
@@ -0,0 +1,235 @@
"""The Chats dialog: every conversation this user has with Bolt, across the
console, the pet and the phone, newest first, and a New Chat button.
Network calls never run on the UI thread. Each one is a `ChatTask` on the
global QThreadPool that calls server_client synchronously and hands the
result back through `ChatSignals`, the same signal-based hand-off the turn
worker uses. The list is fetched when the dialog opens and on Refresh; there
is no polling.
Picking or creating a chat doesn't switch anything here. The dialog emits
`chosen(session_id, label)` and the window does the switch, because the
window owns the transcript that has to be cleared along with it.
"""
from __future__ import annotations
from typing import Callable, Optional
from PySide6.QtCore import QObject, QRunnable, QSize, Qt, QThreadPool, Signal
from PySide6.QtWidgets import (
QDialog, QHBoxLayout, QLabel, QListWidget, QListWidgetItem, QPushButton,
QVBoxLayout, QWidget,
)
from .. import config, server_client
from ..chats import clean_preview, local_time, relative_time
from . import icons, theme
# ── off-thread calls ────────────────────────────────────────────────────────
class ChatSignals(QObject):
loaded = Signal(list) # chats, newest first
created = Signal(str) # the new session_id
failed = Signal(str, str) # operation ("list" / "create"), message
class ChatTask(QRunnable):
"""Runs one server_client call on the thread pool and reports back."""
def __init__(self, operation: str, call: Callable, signals: ChatSignals):
super().__init__()
self.operation, self.call, self.signals = operation, call, signals
def run(self) -> None:
try:
result = self.call()
except Exception as exc: # ServerError, or anything unexpected
self.signals.failed.emit(self.operation, str(exc))
return
if self.operation == "list":
self.signals.loaded.emit(list(result))
else:
self.signals.created.emit(str(result))
def run_in_pool(operation: str, call: Callable, signals: ChatSignals) -> None:
QThreadPool.globalInstance().start(ChatTask(operation, call, signals))
# ── the dialog ──────────────────────────────────────────────────────────────
class ChatRow(QWidget):
"""Label and time on the first line, the last message under it."""
def __init__(self, chat: dict, current: bool, parent=None):
super().__init__(parent)
outer = QVBoxLayout(self)
outer.setContentsMargins(14, 9, 14, 9)
outer.setSpacing(2)
top = QHBoxLayout()
top.setSpacing(8)
title = QLabel(str(chat.get("label") or chat.get("session_id") or "Untitled"))
title.setObjectName("ChatTitle")
top.addWidget(title, 1)
if current:
badge = QLabel("Current")
badge.setObjectName("Pill")
badge.setProperty("kind", "waiting")
top.addWidget(badge)
when = QLabel(relative_time(chat.get("last_active", "")))
when.setObjectName("ChatTime")
top.addWidget(when)
outer.addLayout(top)
preview = QLabel(clean_preview(chat.get("last_message", "")) or "—")
preview.setObjectName("ChatPreview")
outer.addWidget(preview)
for widget in (title, when, preview):
widget.setAttribute(Qt.WA_TransparentForMouseEvents)
class ChatListDialog(QDialog):
"""`chosen(session_id, label)` fires for a picked chat and for a newly
created one (label "New chat"); the window does the actual switch."""
chosen = Signal(str, str)
def __init__(self, parent=None, list_chats: Callable = None, create_chat: Callable = None):
super().__init__(parent)
self.setObjectName("ChatDialog")
self.setWindowTitle("Chats")
self.setModal(True)
self.resize(560, 580)
self._list_chats = list_chats or server_client.list_chats
self._create_chat = create_chat or server_client.create_chat
self.signals = ChatSignals()
self.signals.loaded.connect(self._populate)
self.signals.created.connect(self._created)
self.signals.failed.connect(self._failed)
col = QVBoxLayout(self)
col.setContentsMargins(18, 18, 18, 16)
col.setSpacing(12)
head = QHBoxLayout()
head.setSpacing(10)
head.addWidget(icons.IconLabel("bolt", theme.ACCENT_INK, 24, tile=theme.ACCENT))
heading = QLabel("Chats")
heading.setObjectName("Wordmark")
head.addWidget(heading)
head.addStretch(1)
self.status = QLabel("")
self.status.setObjectName("Hint")
head.addWidget(self.status)
col.addLayout(head)
sub = QLabel("Every conversation with Bolt, from any device. A new chat starts with "
"fresh history; Bolt still remembers you.")
sub.setObjectName("HeroSub")
sub.setWordWrap(True)
col.addWidget(sub)
self.list = QListWidget()
self.list.setObjectName("ChatList")
self.list.setUniformItemSizes(False)
self.list.setVerticalScrollMode(QListWidget.ScrollPerPixel)
self.list.itemActivated.connect(self._open_item) # Enter / double-click
self.list.currentItemChanged.connect(lambda *_: self._sync_buttons())
col.addWidget(self.list, 1)
buttons = QHBoxLayout()
buttons.setSpacing(8)
self.refresh_button = QPushButton("Refresh")
self.refresh_button.setObjectName("Ghost")
self.refresh_button.clicked.connect(self.refresh)
buttons.addWidget(self.refresh_button)
buttons.addStretch(1)
self.new_button = QPushButton("New Chat")
self.new_button.setObjectName("Secondary")
self.new_button.clicked.connect(self.new_chat)
buttons.addWidget(self.new_button)
self.open_button = QPushButton("Open")
self.open_button.setObjectName("Primary")
self.open_button.clicked.connect(lambda: self._open_item(self.list.currentItem()))
buttons.addWidget(self.open_button)
col.addLayout(buttons)
for button in (self.refresh_button, self.new_button, self.open_button):
button.setCursor(Qt.PointingHandCursor)
self._busy = False
self._sync_buttons()
# ── loading ──────────────────────────────────────────────────────────
def showEvent(self, event) -> None:
super().showEvent(event)
theme.dark_title_bar(self) # same caption colour as the main window on Windows
self.refresh()
def refresh(self) -> None:
if self._busy:
return
self._set_busy(True, "Loading…")
run_in_pool("list", self._list_chats, self.signals)
def _populate(self, chats: list) -> None:
self._set_busy(False, "")
self.list.clear()
current = None
for chat in chats:
session_id = str(chat.get("session_id") or "")
if not session_id:
continue
is_current = session_id == config.SESSION_ID
item = QListWidgetItem()
item.setData(Qt.UserRole, session_id)
item.setData(Qt.UserRole + 1, str(chat.get("label") or session_id))
item.setToolTip(f"{session_id}\nLast active {local_time(chat.get('last_active', ''))}")
row = ChatRow(chat, is_current)
item.setSizeHint(QSize(0, row.sizeHint().height()))
self.list.addItem(item)
self.list.setItemWidget(item, row)
if is_current:
current = item
if self.list.count() == 0:
self.status.setText("No chats yet")
self.list.setCurrentItem(current or self.list.item(0))
self._sync_buttons()
def _failed(self, operation: str, message: str) -> None:
self._set_busy(False, "")
what = "load chats" if operation == "list" else "start a new chat"
self.status.setText(f"Couldn't {what}")
self.status.setToolTip(message)
self.status.setStyleSheet(f"color: {theme.RED};")
# ── actions ──────────────────────────────────────────────────────────
def new_chat(self) -> None:
if self._busy:
return
self._set_busy(True, "Starting a new chat…")
run_in_pool("create", self._create_chat, self.signals)
def _created(self, session_id: str) -> None:
# A new chat isn't listed until it's used, so there's nothing to show:
# hand it to the window and close.
self._set_busy(False, "")
self.chosen.emit(session_id, "New chat")
self.accept()
def _open_item(self, item: Optional[QListWidgetItem]) -> None:
if item is None or self._busy:
return
self.chosen.emit(item.data(Qt.UserRole), item.data(Qt.UserRole + 1))
self.accept()
def _set_busy(self, busy: bool, status: str) -> None:
self._busy = busy
self.status.setText(status)
self.status.setToolTip("")
self.status.setStyleSheet("")
self._sync_buttons()
def _sync_buttons(self) -> None:
self.refresh_button.setEnabled(not self._busy)
self.new_button.setEnabled(not self._busy)
self.open_button.setEnabled(not self._busy and self.list.currentItem() is not None)
+219
View File
@@ -0,0 +1,219 @@
"""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)
+196
View File
@@ -0,0 +1,196 @@
"""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
+699
View File
@@ -0,0 +1,699 @@
"""The pieces the window is built from: messages, tool cards, the composer,
the header controls and the empty state. Styling lives in theme.stylesheet();
these set object names and properties for it to hook onto."""
from __future__ import annotations
import html
import math
import re
from PySide6.QtCore import QEvent, QRectF, QSize, Qt, QTimer, Signal
from PySide6.QtGui import QColor, QFontDatabase, QPainter, QTextCursor, QTextDocument, QTextOption
from PySide6.QtWidgets import (
QButtonGroup, QFrame, QHBoxLayout, QLabel, QPlainTextEdit, QPushButton,
QSizePolicy, QVBoxLayout, QWidget,
)
from . import icons, theme
# Tool name (as tools.py reports it) -> icon.
TOOL_ICONS = {
"Shell": "shell", "Read": "read", "List": "list", "Write": "write",
"Edit": "edit", "Pet": "blocked", "File": "file",
}
COLLAPSED_LINES = 6
def _label(text: str = "", name: str = "", wrap: bool = True, selectable: bool = False) -> QLabel:
label = QLabel(text)
if name:
label.setObjectName(name)
label.setWordWrap(wrap)
if selectable:
label.setTextInteractionFlags(Qt.TextSelectableByMouse | Qt.LinksAccessibleByMouse)
label.setCursor(Qt.IBeamCursor)
return label
def repolish(widget: QWidget) -> None:
"""Re-apply the stylesheet after a dynamic property changed."""
widget.style().unpolish(widget)
widget.style().polish(widget)
widget.update()
def markdown_html(text: str, font) -> str:
"""Markdown -> the HTML QLabel renders, with code in the app's mono font
and a warm tint instead of Qt's generic 'monospace' (usually Courier)."""
doc = QTextDocument()
doc.setDefaultFont(font)
doc.setMarkdown(text)
out = doc.toHtml()
code_style = f"font-family:'{theme.mono_font_family()}'; color:{theme.CODE};"
# Qt writes the platform's fixed font for code: 'Courier New' on Windows,
# 'monospace' elsewhere.
for fixed in {QFontDatabase.systemFont(QFontDatabase.FixedFont).family(), "monospace", "Courier New"}:
out = out.replace(f"font-family:'{fixed}';", code_style)
return out.replace("font-family:'Sans Serif';", "")
# ── transcript entries ──────────────────────────────────────────────────────
class UserMessage(QWidget):
def __init__(self, text: str, parent=None):
super().__init__(parent)
row = QHBoxLayout(self)
row.setContentsMargins(80, 6, 0, 6)
row.addStretch(1)
bubble = QFrame()
bubble.setObjectName("UserBubble")
inner = QVBoxLayout(bubble)
inner.setContentsMargins(16, 10, 16, 10)
label = _label(text, selectable=True)
label.setTextFormat(Qt.PlainText)
# A word-wrapped QLabel's size hint is a guess that wraps short text
# early; give it the width its longest line actually needs.
widest = max(label.fontMetrics().horizontalAdvance(line) for line in text.splitlines() or [""])
label.setMinimumWidth(min(widest + 4, 588))
inner.addWidget(label)
bubble.setMaximumWidth(620)
bubble.setSizePolicy(QSizePolicy.Maximum, QSizePolicy.Preferred)
row.addWidget(bubble)
class AssistantMessage(QWidget):
"""Bolt's reply. Streamed sentences are appended; a sentence that starts a
list item or heading goes on its own line so the markdown still renders."""
_BLOCK_START = re.compile(r"^(?:[-*+] |\d+[.)] |#{1,6} |```|>)")
def __init__(self, text: str = "", parent=None):
super().__init__(parent)
self._text = ""
row = QHBoxLayout(self)
row.setContentsMargins(0, 8, 0, 8)
row.setSpacing(14)
avatar = icons.IconLabel("bolt", theme.ACCENT_INK, 26, tile=theme.ACCENT)
row.addWidget(avatar, 0, Qt.AlignTop)
self.label = _label(name="AssistantText", selectable=True)
self.label.setTextFormat(Qt.RichText)
self.label.setOpenExternalLinks(True)
self.label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred)
row.addWidget(self.label, 1)
if text:
self.set_text(text)
def set_text(self, text: str) -> None:
self._text = text
self.label.setText(markdown_html(text, self.label.font()))
def append(self, sentence: str) -> None:
if not self._text:
joiner = ""
elif self._BLOCK_START.match(sentence) or self._text.endswith("\n"):
joiner = "\n\n" if not self._text.endswith("\n") else ""
else:
joiner = " "
self.set_text(self._text + joiner + sentence)
def text(self) -> str:
return self._text
class HoldingLine(QWidget):
def __init__(self, text: str, parent=None):
super().__init__(parent)
row = QHBoxLayout(self)
row.setContentsMargins(40, 0, 0, 2)
row.addWidget(_label(text, "Holding"))
class Notice(QWidget):
def __init__(self, level: str, text: str, parent=None):
super().__init__(parent)
row = QHBoxLayout(self)
row.setContentsMargins(40, 4, 0, 4)
label = _label(text, "NoticeError" if level == "error" else "Notice", selectable=True)
label.setTextFormat(Qt.PlainText)
if level == "warn":
label.setStyleSheet(f"color: {theme.ACCENT};")
row.addWidget(label, 1)
class Pill(QLabel):
TEXT = {"running": "Running", "ok": "Done", "error": "Failed",
"waiting": "Needs approval", "declined": "Declined"}
def __init__(self, kind: str = "running", parent=None):
super().__init__(parent)
self.setObjectName("Pill")
self.set_kind(kind)
def set_kind(self, kind: str) -> None:
self.setProperty("kind", kind)
self.setText(self.TEXT.get(kind, kind))
repolish(self)
class _ClickableFrame(QFrame):
clicked = Signal()
def mouseReleaseEvent(self, event) -> None:
if event.button() == Qt.LeftButton and self.rect().contains(event.position().toPoint()):
self.clicked.emit()
super().mouseReleaseEvent(event)
class ToolCard(QFrame):
"""One relayed command: header (icon, name, argument, status), optional
diff, output that expands on click, and an inline approval bar."""
answered = Signal(str) # yes / no / always
def __init__(self, name: str, argument: str, parent=None):
super().__init__(parent)
self.setObjectName("ToolCard")
self._output = ""
self._expanded = False
self._error = False
outer = QVBoxLayout(self)
outer.setContentsMargins(0, 0, 0, 0)
outer.setSpacing(0)
self.header = _ClickableFrame()
self.header.setObjectName("ToolHeader")
self.header.setCursor(Qt.PointingHandCursor)
head = QHBoxLayout(self.header)
head.setContentsMargins(12, 9, 12, 9)
head.setSpacing(10)
self.icon = icons.IconLabel(TOOL_ICONS.get(name, "shell"), theme.TEXT_DIM, 16)
head.addWidget(self.icon)
title = _label(name, "ToolName", wrap=False)
head.addWidget(title)
shown = argument if len(argument) <= 110 else argument[:107] + "..."
arg = _label(shown, "ToolArg", wrap=False)
arg.setTextFormat(Qt.PlainText)
arg.setToolTip(argument)
arg.setSizePolicy(QSizePolicy.Ignored, QSizePolicy.Preferred)
head.addWidget(arg, 1)
self.pill = Pill("running")
head.addWidget(self.pill)
self.chevron = icons.IconLabel("chevron", theme.TEXT_FAINT, 14)
self.chevron.hide()
head.addWidget(self.chevron)
self.header.clicked.connect(self.toggle)
outer.addWidget(self.header)
self.diff = _label(name="ToolOutput", wrap=True, selectable=True)
self.diff.setTextFormat(Qt.RichText)
self.diff.hide()
outer.addWidget(self.diff)
self.output = _label(name="ToolOutput", wrap=True, selectable=True)
self.output.setTextFormat(Qt.PlainText)
self.output.hide()
outer.addWidget(self.output)
self.approval = QFrame()
self.approval.setObjectName("ApprovalBar")
bar = QHBoxLayout(self.approval)
bar.setContentsMargins(14, 10, 10, 10)
bar.setSpacing(8)
self.question = _label("", "ApprovalQuestion", wrap=False)
bar.addWidget(self.question, 1)
for text, value, style in (("Deny", "no", "Ghost"), ("Always allow", "always", "Secondary"),
("Allow", "yes", "Primary")):
button = QPushButton(text)
button.setObjectName(style)
button.setCursor(Qt.PointingHandCursor)
button.clicked.connect(lambda _=False, v=value: self._answer(v))
bar.addWidget(button)
if value == "yes":
self.allow_button = button
self.approval.hide()
outer.addWidget(self.approval)
# ── state ────────────────────────────────────────────────────────────
def set_result(self, text: str, error: bool) -> None:
if self.pill.property("kind") == "declined":
return
self._output, self._error = text.rstrip(), error
self.pill.set_kind("error" if error else "ok")
self.icon.set_icon(self.icon._name, theme.RED if error else theme.TEXT_DIM)
self._render_output()
def set_diff(self, old: str, new: str, limit: int = 40) -> None:
rows = []
for sign, text, fg, bg in (("-", old, theme.RED, theme.DIFF_DEL_BG),
("+", new, theme.GREEN, theme.DIFF_ADD_BG)):
lines = text.splitlines() or [""]
for line in lines[:limit]:
rows.append(
f'<tr><td style="background:{bg}; color:{fg}; padding:1px 8px; '
f'white-space:pre;">{sign} {html.escape(line) or "&nbsp;"}</td></tr>')
if len(lines) > limit:
rows.append(f'<tr><td style="color:{theme.TEXT_FAINT}; padding:1px 8px;">'
f' … {len(lines) - limit} more lines</td></tr>')
self.diff.setText('<table width="100%" cellspacing="0" cellpadding="0">' + "".join(rows) + "</table>")
self.diff.show()
def ask(self, question: str) -> None:
self.question.setText(question)
self.pill.set_kind("waiting")
self.setProperty("state", "waiting")
repolish(self)
self.approval.show()
self.allow_button.setFocus()
def _answer(self, value: str) -> None:
self.approval.hide()
self.setProperty("state", "")
repolish(self)
if value == "no":
self.pill.set_kind("declined")
else:
self.pill.set_kind("running")
self.answered.emit(value)
def toggle(self) -> None:
if self._output:
self._expanded = not self._expanded
self._render_output()
def _render_output(self) -> None:
lines = align_columns(self._output.splitlines())
if not lines:
self.output.hide()
self.chevron.hide()
return
long = len(lines) > COLLAPSED_LINES
shown = lines if (self._expanded or not long) else lines[:COLLAPSED_LINES]
text = "\n".join(shown)
if long and not self._expanded:
text += f"\n… {len(lines) - COLLAPSED_LINES} more lines — click to expand"
self.output.setText(text)
self.output.setStyleSheet(f"color: {theme.RED};" if self._error else "")
self.output.show()
self.chevron.setVisible(long)
def align_columns(lines):
"""Tab-separated output (filectl list's "name<TAB>size") into padded
columns — a QLabel's tab stops don't line up with anything."""
rows = [line.split(" ") for line in lines]
if not any(len(r) > 1 for r in rows):
return lines
width = max(len(r[0]) for r in rows if len(r) > 1)
return [r[0].ljust(width + 2) + " ".join(r[1:]) if len(r) > 1 else r[0] for r in rows]
class ThinkingIndicator(QWidget):
"""Three dots breathing in sequence, next to a quiet 'Thinking'."""
def __init__(self, parent=None):
super().__init__(parent)
self.setFixedHeight(34)
self._phase = 0.0
self._timer = QTimer(self)
self._timer.setInterval(40)
self._timer.timeout.connect(self._tick)
self.label = "Thinking"
def start(self) -> None:
self._phase = 0.0
self.show()
self._timer.start()
def stop(self) -> None:
self._timer.stop()
self.hide()
def _tick(self) -> None:
self._phase += 0.12
self.update()
def paintEvent(self, _event) -> None:
p = QPainter(self)
p.setRenderHint(QPainter.Antialiasing)
p.setPen(Qt.NoPen)
x, y = 50.0, self.height() / 2
for i in range(3):
level = (math.sin(self._phase - i * 0.9) + 1) / 2
colour = QColor(theme.ACCENT)
colour.setAlphaF(0.25 + 0.75 * level)
p.setBrush(colour)
r = 2.6 + 1.0 * level
p.drawEllipse(QRectF(x + i * 12 - r, y - r, 2 * r, 2 * r))
p.setPen(QColor(theme.TEXT_FAINT))
p.drawText(QRectF(x + 34, 0, 300, self.height()), Qt.AlignVCenter | Qt.AlignLeft, self.label)
# ── composer ────────────────────────────────────────────────────────────────
class _SendButton(QPushButton):
def __init__(self, parent=None):
super().__init__(parent)
self.setFixedSize(34, 34)
self.setCursor(Qt.PointingHandCursor)
self.setToolTip("Send (Enter)")
self.setFlat(True)
def paintEvent(self, _event) -> None:
p = QPainter(self)
p.setRenderHint(QPainter.Antialiasing)
p.setPen(Qt.NoPen)
enabled = self.isEnabled()
fill = QColor(theme.ACCENT_HI if self.underMouse() and enabled else theme.ACCENT)
if not enabled:
fill = QColor(theme.BORDER_HI)
p.setBrush(fill)
p.drawEllipse(QRectF(self.rect()).adjusted(1, 1, -1, -1))
icons.paint(p, "send", QRectF(self.rect()).adjusted(8, 8, -8, -8),
theme.ACCENT_INK if enabled else theme.TEXT_FAINT)
class MicButton(QPushButton):
"""Dictation toggle. idle: a quiet mic outline. recording: red, with a
ring that swells with your voice, and a stop square. finishing: an amber
arc while the last words come back. unavailable: faint, and the tooltip
says what's missing."""
TIPS = {
"idle": "Dictate (Ctrl+M)\nClick, talk, click again (or pause) to stop",
"recording": "Listening. Click or pause to stop, Esc to cancel",
"finishing": "Transcribing...",
}
def __init__(self, parent=None):
super().__init__(parent)
self.setFixedSize(34, 34)
self.setCursor(Qt.PointingHandCursor)
self.setFlat(True)
self.state = "idle"
self._level = 0.0
self._phase = 0.0
self._timer = QTimer(self)
self._timer.setInterval(33)
self._timer.timeout.connect(self._tick)
self.set_state("idle")
def set_state(self, state: str, reason: str = "") -> None:
self.state = state
self.setToolTip(reason if state == "unavailable" else self.TIPS.get(state, ""))
if state in ("recording", "finishing"):
self._timer.start()
else:
self._timer.stop()
self._level = 0.0
self.update()
def set_level(self, level: float) -> None:
# Fast attack, slow release, so the ring breathes instead of flickering.
self._level = level if level > self._level else self._level * 0.85 + level * 0.15
def _tick(self) -> None:
self._phase += 0.2
self._level *= 0.97
self.update()
def paintEvent(self, _event) -> None:
p = QPainter(self)
p.setRenderHint(QPainter.Antialiasing)
rect = QRectF(self.rect()).adjusted(1, 1, -1, -1)
centre = rect.center()
if self.state == "recording":
ring = QColor(theme.RED)
ring.setAlphaF(0.22 + 0.3 * self._level)
p.setPen(Qt.NoPen)
p.setBrush(ring)
p.drawEllipse(centre, 16.0, 16.0)
p.setBrush(QColor(theme.RED))
radius = 10.5 + 3.5 * self._level
p.drawEllipse(centre, radius, radius)
icons.paint(p, "stop", QRectF(centre.x() - 8, centre.y() - 8, 16, 16), "#ffffff")
return
if self.state == "finishing":
pen = QColor(theme.ACCENT)
p.setPen(icons._pen(pen, 2.2))
p.drawArc(rect.adjusted(4, 4, -4, -4), int(-self._phase * 16 * 57), 100 * 16)
icons.paint(p, "mic", rect.adjusted(9, 9, -9, -9), theme.TEXT_DIM)
return
if self.state == "idle" and self.underMouse():
p.setPen(Qt.NoPen)
p.setBrush(QColor(theme.SURFACE_HI))
p.drawEllipse(rect)
colour = theme.TEXT_FAINT if self.state == "unavailable" else (
theme.TEXT if self.underMouse() else theme.TEXT_DIM)
icons.paint(p, "mic", rect.adjusted(8, 8, -8, -8), colour)
class _ComposerEdit(QPlainTextEdit):
submitted = Signal()
def keyPressEvent(self, event) -> None:
if event.key() in (Qt.Key_Return, Qt.Key_Enter) and not (event.modifiers() & Qt.ShiftModifier):
self.submitted.emit()
return
super().keyPressEvent(event)
class Composer(QFrame):
"""Rounded input that grows with its text. Enter sends, Shift+Enter
starts a new line. While a turn runs you can keep typing; sending waits."""
submitted = Signal(str)
MAX_LINES = 9
def __init__(self, parent=None):
super().__init__(parent)
self.setObjectName("Composer")
row = QHBoxLayout(self)
row.setContentsMargins(18, 10, 10, 10)
row.setSpacing(10)
self.edit = _ComposerEdit()
self.edit.setObjectName("ComposerEdit")
self.edit.setPlaceholderText("Ask Bolt to read, change or run something…")
self.edit.setWordWrapMode(QTextOption.WrapAtWordBoundaryOrAnywhere)
self.edit.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded)
self.edit.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
self.edit.document().setDocumentMargin(2)
self.edit.textChanged.connect(self._resize)
self.edit.submitted.connect(self._submit)
self.edit.installEventFilter(self)
row.addWidget(self.edit, 1, Qt.AlignVCenter)
self.mic = MicButton()
row.addWidget(self.mic, 0, Qt.AlignBottom)
self._dictation_prefix = None # composer text from before a recording
self.send = _SendButton()
self.send.clicked.connect(self._submit)
row.addWidget(self.send, 0, Qt.AlignBottom)
self._busy = False
self._resize()
self._sync_button()
self.edit.textChanged.connect(self._sync_button)
def eventFilter(self, obj, event) -> bool:
if obj is self.edit and event.type() in (QEvent.FocusIn, QEvent.FocusOut):
self.setProperty("focused", event.type() == QEvent.FocusIn)
repolish(self)
return False
def set_busy(self, busy: bool) -> None:
self._busy = busy
self._sync_button()
def set_text(self, text: str) -> None:
self.edit.setPlainText(text)
cursor = self.edit.textCursor()
cursor.movePosition(QTextCursor.MoveOperation.End)
self.edit.setTextCursor(cursor)
self.edit.setFocus()
# ── dictation: the words appear in the box as you speak ──────────────
def begin_dictation(self) -> None:
self._dictation_prefix = self.edit.toPlainText().rstrip()
self.edit.setReadOnly(True) # typing into text that's being rewritten would be lost
self._sync_button()
def show_dictation(self, final: str, interim: str = "") -> None:
if self._dictation_prefix is None:
return
spoken = " ".join(part for part in (final, interim) if part)
self._show(" ".join(part for part in (self._dictation_prefix, spoken) if part))
def end_dictation(self, text: str) -> None:
if self._dictation_prefix is None:
return
self._show(" ".join(part for part in (self._dictation_prefix, text.strip()) if part))
self._dictation_prefix = None
self.edit.setReadOnly(False)
self.edit.setFocus()
self._sync_button()
@property
def dictating(self) -> bool:
return self._dictation_prefix is not None
def _show(self, text: str) -> None:
self.edit.setPlainText(text)
cursor = self.edit.textCursor()
cursor.movePosition(QTextCursor.MoveOperation.End)
self.edit.setTextCursor(cursor)
def _sync_button(self) -> None:
ready = not self._busy and not self.dictating and bool(self.edit.toPlainText().strip())
self.send.setEnabled(ready)
self.send.update()
def _resize(self) -> None:
line = self.edit.fontMetrics().lineSpacing()
lines = max(1, min(self.MAX_LINES, int(self.edit.document().size().height())))
self.edit.setFixedHeight(int(lines * line + 8))
def _submit(self) -> None:
text = self.edit.toPlainText().strip()
if not text or self._busy or self.dictating:
return
self.edit.clear()
self.submitted.emit(text)
# ── header ──────────────────────────────────────────────────────────────────
class StatusDot(QWidget):
COLOURS = {"unknown": theme.TEXT_FAINT, "ok": theme.GREEN, "error": theme.RED}
def __init__(self, parent=None):
super().__init__(parent)
self.setFixedSize(QSize(10, 10))
self._state = "unknown"
def set_state(self, state: str, tooltip: str) -> None:
self._state = state
self.setToolTip(tooltip)
self.update()
def paintEvent(self, _event) -> None:
p = QPainter(self)
p.setRenderHint(QPainter.Antialiasing)
p.setPen(Qt.NoPen)
p.setBrush(QColor(self.COLOURS.get(self._state, theme.TEXT_FAINT)))
p.drawEllipse(QRectF(1, 1, 8, 8))
class FolderChip(QPushButton):
"""A pill button with a painted icon on the left (the folder by default)."""
def __init__(self, parent=None, icon_name: str = "folder", text: str = ""):
super().__init__(text, parent)
self._icon_name = icon_name
self.setObjectName("Chip")
self.setCursor(Qt.PointingHandCursor)
self.setFixedHeight(30)
def set_path(self, path: str) -> None:
name = path.rstrip("\\/").replace("\\", "/").split("/")[-1] or path
self.setText(name)
self.setToolTip(f"Working directory: {path}\nClick to change")
def paintEvent(self, event) -> None:
super().paintEvent(event)
p = QPainter(self)
paint_colour = theme.TEXT if self.underMouse() else theme.TEXT_DIM
if not self.isEnabled():
paint_colour = theme.TEXT_FAINT
icons.paint(p, self._icon_name, QRectF(10, (self.height() - 15) / 2, 15, 15), paint_colour)
class ModeSwitch(QFrame):
"""Segmented control: ask before changes, or run everything."""
changed = Signal(str)
def __init__(self, mode: str, parent=None):
super().__init__(parent)
self.setObjectName("Segment")
self.setFixedHeight(30)
row = QHBoxLayout(self)
row.setContentsMargins(2, 2, 2, 2)
row.setSpacing(2)
self._group = QButtonGroup(self)
self.buttons = {}
for value, text, tip in (
("ask", "Ask first", "Confirm shell commands, writes and edits"),
("auto", "Auto", "Run everything Bolt asks for without confirming"),
):
button = QPushButton(text)
button.setObjectName("SegmentButton")
button.setCheckable(True)
button.setCursor(Qt.PointingHandCursor)
button.setToolTip(tip)
if value == "auto":
button.setProperty("danger", True)
self._group.addButton(button)
row.addWidget(button)
self.buttons[value] = button
button.clicked.connect(lambda _=False, v=value: self.changed.emit(v))
self.set_mode(mode)
def set_mode(self, mode: str) -> None:
self.buttons.get(mode, self.buttons["ask"]).setChecked(True)
# ── empty state ─────────────────────────────────────────────────────────────
SUGGESTIONS = (
"What's in this folder?",
"Summarise the README",
"Git status and recent commits",
"Find TODO comments in this project",
)
class EmptyState(QWidget):
picked = Signal(str)
def __init__(self, parent=None):
super().__init__(parent)
col = QVBoxLayout(self)
col.setContentsMargins(0, 0, 0, 0)
col.addStretch(3)
col.addWidget(icons.IconLabel("bolt", theme.ACCENT_INK, 52, tile=theme.ACCENT), 0, Qt.AlignHCenter)
col.addSpacing(18)
hero = _label("What are we working on?", "Hero")
hero.setAlignment(Qt.AlignHCenter)
col.addWidget(hero)
self.sub = _label("", "HeroSub")
self.sub.setAlignment(Qt.AlignHCenter)
col.addWidget(self.sub)
col.addSpacing(26)
grid = QVBoxLayout()
grid.setSpacing(10)
for i in range(0, len(SUGGESTIONS), 2):
pair = QHBoxLayout()
pair.setSpacing(10)
for text in SUGGESTIONS[i:i + 2]:
button = QPushButton(text)
button.setObjectName("Suggestion")
button.setCursor(Qt.PointingHandCursor)
button.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
button.clicked.connect(lambda _=False, t=text: self.picked.emit(t))
pair.addWidget(button)
grid.addLayout(pair)
wrap = QHBoxLayout()
wrap.addStretch(1)
holder = QWidget()
holder.setLayout(grid)
holder.setMaximumWidth(560)
holder.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
wrap.addWidget(holder, 4)
wrap.addStretch(1)
col.addLayout(wrap)
col.addStretch(4)
def set_folder(self, path: str) -> None:
self.sub.setText(f"Bolt can read, edit and run things in {path}")
+528
View File
@@ -0,0 +1,528 @@
"""The main window and the worker thread a turn runs on.
Turn logic is `console.Session` — the same object the terminal uses — driven
on a QThread with a `Bridge` standing in for the terminal. The window only
ever reacts to Bridge signals, so nothing here knows about HTTP or tools.
"""
from __future__ import annotations
import os
import sys
import threading
from typing import Optional
from PySide6.QtCore import QObject, Qt, QThread, QTimer, Signal, Slot
from PySide6.QtGui import QFont, QKeySequence, QShortcut
from PySide6.QtWidgets import (
QApplication, QFileDialog, QFrame, QHBoxLayout, QLabel, QMainWindow,
QScrollArea, QSizePolicy, QVBoxLayout, QWidget,
)
from .. import __version__, config, dictation, server_client
from ..console import Session
from ..tools import Tools
from . import icons, theme
from .bridge import Bridge
from .chat_list_window import ChatListDialog, ChatSignals, run_in_pool
from .widgets import (
AssistantMessage, Composer, EmptyState, FolderChip, HoldingLine, ModeSwitch,
Notice, StatusDot, ThinkingIndicator, ToolCard, UserMessage,
)
class TurnRunner(QObject):
"""Lives on the worker thread; runs one Session.send per request."""
finished = Signal()
def __init__(self, session: Session):
super().__init__()
self.session = session
@Slot(str)
def run(self, text: str) -> None:
try:
self.session.send(text)
except Exception as exc: # never let a bug kill the worker thread
self.session.ui.error(f"Unexpected error: {exc}")
self.session.ui.stop_spinner()
finally:
self.finished.emit()
class HealthProbe(QObject):
done = Signal(bool, str)
def start(self) -> None:
threading.Thread(target=self._probe, daemon=True).start()
def _probe(self) -> None:
try:
server_client.check_health(timeout=8)
self.done.emit(True, f"Connected to {config.SERVER_URL}")
except Exception as exc:
self.done.emit(False, f"Can't reach {config.SERVER_URL}: {exc}")
class DictationSignals(QObject):
"""Dictation calls back on background threads; these queue it onto the
GUI thread."""
update = Signal(str, str) # final text so far, current interim guess
level = Signal(float) # 0..1 mic loudness
done = Signal(str, str) # text, error ("" when none)
stopping = Signal() # recording over, transcript on its way
class Column(QWidget):
"""Centres its content in a reading column of at most COLUMN_WIDTH."""
def __init__(self, parent=None):
super().__init__(parent)
row = QHBoxLayout(self)
row.setContentsMargins(24, 0, 24, 0)
row.addStretch(1)
self.inner = QWidget()
self.inner.setMaximumWidth(theme.COLUMN_WIDTH)
self.inner.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred)
row.addWidget(self.inner, 100)
row.addStretch(1)
self.layout_ = QVBoxLayout(self.inner)
self.layout_.setContentsMargins(0, 0, 0, 0)
class MainWindow(QMainWindow):
submit_turn = Signal(str)
HINT = "Enter to send · Shift+Enter for a new line · Ctrl+M dictate · Ctrl+N new chat"
def __init__(self, mode: Optional[str] = None, session: Optional[Session] = None,
dictation_factory=None, list_chats=None, create_chat=None):
super().__init__()
# Injectable so tests never hit the network; the real ones run on the pool.
self._list_chats = list_chats or server_client.list_chats
self._create_chat = create_chat or server_client.create_chat
self._dictation_factory = dictation_factory or dictation.Dictation
self._dictation = None
self.setWindowTitle("Bolt")
self.setWindowIcon(icons.app_icon())
self.resize(1040, 800)
self.setMinimumSize(620, 480)
self.bridge = Bridge()
if session is None:
session = Session(self.bridge, tools=Tools(self.bridge, mode=mode or config.APPROVAL_MODE))
self.session = session
self._cards = {}
self._assistant: Optional[AssistantMessage] = None
self._busy = False
self._stick_to_bottom = True
root = QWidget()
root.setObjectName("Root")
self.setCentralWidget(root)
page = QVBoxLayout(root)
page.setContentsMargins(0, 0, 0, 0)
page.setSpacing(0)
page.addWidget(self._build_header())
# transcript
self.scroll = QScrollArea()
self.scroll.setWidgetResizable(True)
self.scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
transcript = QWidget()
transcript.setObjectName("Transcript")
body = QVBoxLayout(transcript)
body.setContentsMargins(0, 18, 0, 18)
self.column = Column()
body.addWidget(self.column)
body.addStretch(1)
self.entries = self.column.layout_
self.entries.setSpacing(6)
self.empty = EmptyState()
self.empty.picked.connect(self._send)
self.entries.addWidget(self.empty)
self.thinking = ThinkingIndicator()
self.thinking.hide()
self.entries.addWidget(self.thinking)
self.scroll.setWidget(transcript)
self.scroll.verticalScrollBar().rangeChanged.connect(self._follow)
self.scroll.verticalScrollBar().valueChanged.connect(self._track_scroll)
page.addWidget(self.scroll, 1)
# composer
dock = Column()
dock.layout_.setContentsMargins(0, 6, 0, 14)
dock.layout_.setSpacing(6)
self.composer = Composer()
self.composer.submitted.connect(self._send)
dock.layout_.addWidget(self.composer)
self.hint = QLabel(self.HINT)
self.hint.setObjectName("Hint")
self.hint.setAlignment(Qt.AlignHCenter)
dock.layout_.addWidget(self.hint)
self._hint_timer = QTimer(self)
self._hint_timer.setSingleShot(True)
self._hint_timer.timeout.connect(lambda: self.hint.setText(self.HINT))
page.addWidget(dock)
QShortcut(QKeySequence("Ctrl+L"), self, activated=self.clear_transcript)
QShortcut(QKeySequence("Ctrl+O"), self, activated=self.choose_folder)
QShortcut(QKeySequence("Ctrl+N"), self, activated=self.start_new_chat)
QShortcut(QKeySequence("Ctrl+K"), self, activated=self.open_chats)
self.chat_signals = ChatSignals()
self.chat_signals.created.connect(lambda session_id: self.switch_chat(session_id, "New chat"))
self.chat_signals.failed.connect(self._new_chat_failed)
self._creating_chat = False
QShortcut(QKeySequence("Ctrl+M"), self, activated=self.toggle_dictation)
QShortcut(QKeySequence("Escape"), self, activated=self.cancel_dictation)
self.dictation_signals = DictationSignals()
self.dictation_signals.update.connect(self.composer.show_dictation)
self.dictation_signals.level.connect(self.composer.mic.set_level)
self.dictation_signals.done.connect(self._dictation_done)
self.dictation_signals.stopping.connect(
lambda: self._dictation is not None and self.composer.mic.set_state("finishing"))
self.composer.mic.clicked.connect(self.toggle_dictation)
self._mic_unavailable = dictation.unavailable_reason()
if self._mic_unavailable:
self.composer.mic.set_state("unavailable", self._mic_unavailable)
self._wire_bridge()
self._start_worker()
self._refresh_folder()
self.health = HealthProbe()
self.health.done.connect(self._health_done)
QTimer.singleShot(0, self.health.start)
self.composer.edit.setFocus()
# ── construction ─────────────────────────────────────────────────────
def _build_header(self) -> QWidget:
header = QFrame()
header.setObjectName("Header")
header.setFixedHeight(56)
row = QHBoxLayout(header)
row.setContentsMargins(18, 0, 18, 0)
row.setSpacing(10)
row.addWidget(icons.IconLabel("bolt", theme.ACCENT_INK, 26, tile=theme.ACCENT))
word = QLabel("Bolt")
word.setObjectName("Wordmark")
row.addWidget(word)
sub = QLabel(f"Console {__version__}")
sub.setObjectName("WordmarkSub")
row.addWidget(sub)
row.addSpacing(6)
self.status = StatusDot()
self.status.set_state("unknown", "Checking the server…")
row.addWidget(self.status)
row.addStretch(1)
self.chats = FolderChip(icon_name="chats", text="Chats")
self.chats.setToolTip("All your chats with Bolt, from every device (Ctrl+K)")
self.chats.clicked.connect(self.open_chats)
row.addWidget(self.chats)
self.new_chat = FolderChip(icon_name="plus", text="New chat")
self.new_chat.setToolTip("Start a fresh conversation (Ctrl+N)\n"
"Fresh history; Bolt still remembers you")
self.new_chat.clicked.connect(self.start_new_chat)
row.addWidget(self.new_chat)
self.folder = FolderChip()
self.folder.clicked.connect(self.choose_folder)
row.addWidget(self.folder)
self.mode = ModeSwitch(self.session.tools.mode)
self.mode.changed.connect(self._set_mode)
row.addWidget(self.mode)
return header
def _wire_bridge(self) -> None:
b = self.bridge
b.busy.connect(self._set_busy)
b.chunk.connect(self._on_chunk)
b.reply_ended.connect(self._end_assistant)
b.reply.connect(self._on_reply)
b.holding_line.connect(lambda text: self._add(HoldingLine(text)))
b.tool_started.connect(self._on_tool_started)
b.tool_output.connect(self._on_tool_output)
b.tool_diff.connect(self._on_tool_diff)
b.approval_needed.connect(self._on_approval)
b.notice.connect(lambda level, text: self._add(Notice(level, text)))
def _start_worker(self) -> None:
self.thread_ = QThread(self)
self.runner = TurnRunner(self.session)
self.runner.moveToThread(self.thread_)
self.submit_turn.connect(self.runner.run)
self.runner.finished.connect(self._turn_finished)
self.thread_.start()
# ── transcript helpers ───────────────────────────────────────────────
def _add(self, widget: QWidget) -> None:
if self.empty.isVisible():
self.empty.hide()
# Always insert above the thinking indicator, which stays last.
self.entries.insertWidget(self.entries.count() - 1, widget)
def _track_scroll(self, value: int) -> None:
bar = self.scroll.verticalScrollBar()
self._stick_to_bottom = value >= bar.maximum() - 40
def _follow(self, _min: int, maximum: int) -> None:
if self._stick_to_bottom:
self.scroll.verticalScrollBar().setValue(maximum)
def clear_transcript(self) -> None:
if self._busy:
return
keep = (self.empty, self.thinking)
for i in reversed(range(self.entries.count())):
widget = self.entries.itemAt(i).widget()
if widget is not None and widget not in keep:
self.entries.takeAt(i)
widget.deleteLater()
self._cards.clear()
self._assistant = None
self.empty.show()
# ── user actions ─────────────────────────────────────────────────────
# ── chats ────────────────────────────────────────────────────────────
def start_new_chat(self) -> None:
"""Ask the server for a fresh chat id, off the UI thread; the switch
happens when it arrives (switch_chat)."""
if self._busy or self._creating_chat:
return
self._creating_chat = True
self.new_chat.setEnabled(False)
self._flash_hint("Starting a new chat...")
run_in_pool("create", self._create_chat, self.chat_signals)
def _new_chat_failed(self, _operation: str, message: str) -> None:
self._creating_chat = False
self.new_chat.setEnabled(not self._busy)
self._flash_hint(f"Couldn't start a new chat: {message}")
def open_chats(self) -> None:
if self._busy:
self._flash_hint("Wait for Bolt to finish before switching chats.")
return
dialog = ChatListDialog(self, list_chats=self._list_chats, create_chat=self._create_chat)
dialog.chosen.connect(self.switch_chat)
self.chat_dialog = dialog # kept for tests; the dialog is modal
dialog.open()
def switch_chat(self, session_id: str, label: str = "") -> None:
"""Point every server call at *session_id* and start the local
transcript over: there's no endpoint for a past chat's messages, so
the window can't show them (Bolt still has them server-side)."""
self._creating_chat = False
self.new_chat.setEnabled(not self._busy)
if self._busy:
return
note = self.session.switch_to(session_id)
self.clear_transcript()
self.chats.setToolTip(f"All your chats with Bolt, from every device (Ctrl+K)\n{note}")
self._flash_hint(f"Now in: {label or session_id}")
def _send(self, text: str) -> None:
text = text.strip()
if not text or self._busy:
return
self._stick_to_bottom = True
self._add(UserMessage(text))
self._assistant = None
self._set_busy(True)
self.submit_turn.emit(text)
# ── dictation ────────────────────────────────────────────────────────
def toggle_dictation(self) -> None:
if self._mic_unavailable:
self._flash_hint(self._mic_unavailable)
return
if self._dictation is not None:
if self.composer.mic.state == "recording":
self.composer.mic.set_state("finishing")
self._dictation.stop()
return
sig = self.dictation_signals
recording = self._dictation_factory(
sig.update.emit, sig.level.emit, lambda text, error: sig.done.emit(text, error or ""),
silence_seconds=config.DICTATION_SILENCE_SECONDS, on_stopping=sig.stopping.emit,
)
self.composer.begin_dictation()
try:
recording.start()
except Exception as exc:
self.composer.end_dictation("")
self._flash_hint(f"Couldn't open the microphone: {exc}")
return
self._dictation = recording
self.composer.mic.set_state("recording")
if not recording.live:
self._flash_hint("Live transcription is unavailable; the words will appear when you stop.")
def cancel_dictation(self) -> None:
if self._dictation is not None:
self._dictation.cancel()
def _dictation_done(self, text: str, error: str) -> None:
self._dictation = None
self.composer.mic.set_state("unavailable" if self._mic_unavailable else "idle",
self._mic_unavailable or "")
self.composer.end_dictation(text)
if error:
self._flash_hint(error)
def _flash_hint(self, text: str, seconds: float = 6.0) -> None:
self.hint.setText(text)
self._hint_timer.start(int(seconds * 1000))
def choose_folder(self) -> None:
if self._busy:
return
path = QFileDialog.getExistingDirectory(self, "Working directory for Bolt", os.getcwd())
if path:
try:
os.chdir(path)
except OSError as exc:
self._add(Notice("error", f"Couldn't switch to {path}: {exc}"))
return
self._refresh_folder()
if not self.empty.isVisible():
self._add(Notice("info", f"Working directory is now {os.getcwd()}"))
def _refresh_folder(self) -> None:
cwd = os.getcwd()
self.folder.set_path(cwd)
self.empty.set_folder(cwd)
def _set_mode(self, mode: str) -> None:
self.session.tools.mode = mode
# ── bridge slots ─────────────────────────────────────────────────────
def _set_busy(self, busy: bool) -> None:
self._busy = busy
self.composer.set_busy(busy)
self.folder.setEnabled(not busy)
self.new_chat.setEnabled(not busy and not self._creating_chat)
self.chats.setEnabled(not busy)
if busy:
self.thinking.start()
else:
self.thinking.stop()
def _turn_finished(self) -> None:
self._set_busy(False)
self._assistant = None
# Pick up a mode switched by "Always allow" on the worker side.
self.mode.set_mode(self.session.tools.mode)
self.composer.edit.setFocus()
def _on_chunk(self, sentence: str) -> None:
if self._assistant is None:
self._assistant = AssistantMessage()
self._add(self._assistant)
self._assistant.append(sentence)
def _end_assistant(self) -> None:
self._assistant = None
def _on_reply(self, text: str) -> None:
self._add(AssistantMessage(text))
self._assistant = None
def _on_tool_started(self, tool_id: int, name: str, argument: str) -> None:
self._assistant = None # text after a tool call starts a new block
card = ToolCard(name, argument)
card.answered.connect(self._on_answer)
self._cards[tool_id] = card
self._add(card)
def _on_tool_output(self, tool_id: int, text: str, error: bool) -> None:
card = self._cards.get(tool_id)
if card is not None:
card.set_result(text, error)
def _on_tool_diff(self, tool_id: int, old: str, new: str) -> None:
card = self._cards.get(tool_id)
if card is not None:
card.set_diff(old, new)
def _on_approval(self, tool_id: int, question: str) -> None:
card = self._cards.get(tool_id)
if card is None:
self.bridge.answer("no")
return
self.thinking.stop()
card.ask(question)
self._stick_to_bottom = True
QTimer.singleShot(0, lambda: self.scroll.ensureWidgetVisible(card, 0, 40))
QApplication.alert(self) # flash the taskbar if the window is behind others
def _on_answer(self, value: str) -> None:
if value == "always":
self.mode.set_mode("auto")
self.thinking.start()
self.bridge.answer(value)
def _health_done(self, ok: bool, detail: str) -> None:
self.status.set_state("ok" if ok else "error", detail)
# ── window chrome ────────────────────────────────────────────────────
def resizeEvent(self, event) -> None:
super().resizeEvent(event)
# The transcript grows from the top; give the empty state the whole
# viewport so its stretches can centre it.
self.empty.setMinimumHeight(max(0, self.scroll.viewport().height() - 40))
def showEvent(self, event) -> None:
super().showEvent(event)
theme.dark_title_bar(self)
def closeEvent(self, event) -> None:
self.cancel_dictation()
self.bridge.close()
self.thread_.quit()
self.thread_.wait(1500)
super().closeEvent(event)
def make_app(argv=None) -> QApplication:
app = QApplication.instance() or QApplication(argv or sys.argv)
app.setApplicationName("Bolt Console")
app.setWindowIcon(icons.app_icon())
app.setStyle("Fusion") # a neutral base, so the stylesheet decides the look
app.setPalette(theme.palette())
font = QFont(theme.ui_font_family())
font.setPointSizeF(10.5)
font.setHintingPreference(QFont.PreferNoHinting)
app.setFont(font)
app.setStyleSheet(theme.stylesheet())
return app
def run(argv=None) -> int:
if sys.platform == "win32":
try: # own taskbar group + icon instead of python.exe's
import ctypes
ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID("TheMajesticNetwork.BoltConsole")
except Exception:
pass
argv = list(sys.argv[1:] if argv is None else argv)
app = make_app()
if not config.is_configured():
from PySide6.QtWidgets import QMessageBox
QMessageBox.critical(
None, "Bolt Console",
"Missing " + ", ".join(config.missing_config())
+ ".\n\nCopy .env.example to .env next to the app and fill them in.")
return 2
window = MainWindow(mode="auto" if "--auto" in argv else None)
window.show()
return app.exec()