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>
107 lines
3.7 KiB
Python
107 lines
3.7 KiB
Python
"""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")
|