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
+162
View File
@@ -0,0 +1,162 @@
"""Environment configuration for Bolt Console.
Same lightweight ".env next to the script" pattern as desk_client/bolt_desk.py
in the main tmn-api repo, so this project can be copied anywhere (it does not
import anything from that repo) and configured the same way.
"""
from __future__ import annotations
import os
import platform
import sys
from pathlib import Path
if getattr(sys, "frozen", False):
# PyInstaller build: the .env sits next to the .exe, not inside the bundle.
HERE = Path(sys.executable).resolve().parent
else:
HERE = Path(__file__).resolve().parent.parent # project root (one above bolt_console/)
def _load_env() -> None:
env_path = HERE / ".env"
if not env_path.exists():
return
for line in env_path.read_text(encoding="utf-8").splitlines():
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, _, value = line.partition("=")
os.environ.setdefault(key.strip(), value.strip())
_load_env()
def _flag(name: str, default: str) -> bool:
return os.environ.get(name, default).lower() in ("1", "true", "yes", "on")
def _node_name() -> str:
try:
return platform.node() or "desktop"
except Exception:
return "desktop"
# ── server / identity ───────────────────────────────────────────────────────
# The session id keeps the "pet-" prefix on purpose: the desk API advertises
# the `filectl` read/write/edit tool to pet sessions, and this console is
# built around that tool. "-console" keeps it a separate conversation from a
# Bolt-Pet running on the same machine.
SERVER_URL = os.environ.get("BOLT_SERVER_URL", "").rstrip("/")
API_KEY = os.environ.get("DESK_API_KEY", "")
#
# "New chat" (GUI) / /new (terminal) rotates to a fresh id, remembered in
# STATE_FILE so a restart carries on the same conversation. DESK_SESSION_ID in
# .env pins the session and takes precedence over the remembered one.
STATE_FILE = Path.home() / ".cache" / "bolt-console" / "session.json"
_BASE_SESSION = "pet-console-" + _node_name()
def _remembered_session() -> str:
try:
import json
value = json.loads(STATE_FILE.read_text(encoding="utf-8")).get("session_id")
return str(value) if value else ""
except Exception:
return "" # missing or corrupt state means the default, never a crash
SESSION_ID = os.environ.get("DESK_SESSION_ID") or _remembered_session() or _BASE_SESSION
def set_session(session_id: str) -> str:
"""Make *session_id* the current conversation and remember it for the
next start. A plain reassignment of the module attribute every
server_client call reads; not locked against an in-flight request, which
is fine for a rare, deliberate user action (the window blocks switching
while a turn is running anyway)."""
import json
global SESSION_ID
SESSION_ID = session_id
try:
STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
STATE_FILE.write_text(json.dumps({"session_id": SESSION_ID}), encoding="utf-8")
except OSError:
pass # switched for this run; it just won't survive a restart
return SESSION_ID
def new_session() -> str:
"""A locally made fresh id — the fallback when the server can't mint one
(POST /desk/chats is the normal route; see Session.new_conversation)."""
import time
return set_session(f"{_BASE_SESSION}-{time.strftime('%Y%m%d-%H%M%S')}")
def session_pinned() -> bool:
return bool(os.environ.get("DESK_SESSION_ID"))
# ── relayed commands ────────────────────────────────────────────────────────
COMMAND_TIMEOUT_SECONDS = int(os.environ.get("COMMAND_TIMEOUT_SECONDS", "120"))
# What happens when Bolt wants to change something on this machine:
# ask — shell commands and file writes/edits wait for y/n at the prompt
# (reads and directory listings never ask).
# auto — everything runs without asking, like the pet did.
# Toggle for the current session with /auto and /ask.
APPROVAL_MODE = os.environ.get("APPROVAL_MODE", "ask").strip().lower()
# ── replies ─────────────────────────────────────────────────────────────────
# Print each sentence as the server produces it instead of waiting out the
# whole model call. Falls back to the ordinary request/response path when the
# stream fails before anything was printed.
STREAMING_REPLIES = _flag("STREAMING_REPLIES", "true")
# Append a one-line note (working directory, OS) to each message, so "what's
# in this folder?" has a referent and Bolt knows there's no pet body here.
CONSOLE_CONTEXT = _flag("CONSOLE_CONTEXT", "true")
# ANSI colour. Also off when NO_COLOR is set or stdout isn't a terminal.
COLOR = _flag("COLOR", "true") and "NO_COLOR" not in os.environ
# ── file delivery ───────────────────────────────────────────────────────────
# The server's deliver_files tool (ai/desk_api.py in the main tmn-api repo)
# queues files on this session — e.g. "send me that report" — for the client
# to fetch via GET /desk/files. Downloading a file dequeues it server-side, so
# each one lands here exactly once.
RECEIVE_FILES = _flag("RECEIVE_FILES", "true")
DELIVERED_FILES_DIR = Path(
os.environ.get("DELIVERED_FILES_DIR") or str(Path.home() / "Downloads" / "Bolt")
).expanduser()
# ── dictation (the mic button) ──────────────────────────────────────────────
# Speech goes to the server's /desk/stt relay, like Bolt-Pet. MIC_DEVICE is an
# input index or name substring (blank = system default); a recording stops
# itself after DICTATION_SILENCE_SECONDS of quiet once you've said something.
MIC_DEVICE = os.environ.get("MIC_DEVICE", "")
DICTATION_SILENCE_SECONDS = float(os.environ.get("DICTATION_SILENCE_SECONDS", "3"))
def is_configured() -> bool:
return bool(SERVER_URL and API_KEY)
def missing_config() -> list:
missing = []
if not SERVER_URL:
missing.append("BOLT_SERVER_URL")
if not API_KEY:
missing.append("DESK_API_KEY")
return missing