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:
@@ -0,0 +1,2 @@
|
||||
"""PySide6 front end. Imported only when the window is wanted, so the
|
||||
terminal client works without Qt installed."""
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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 " "}</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}")
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user