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
+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()