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
+3
View File
@@ -0,0 +1,3 @@
"""Bolt Console — a terminal client for Bolt's desk API."""
__version__ = "0.1.0"
+32
View File
@@ -0,0 +1,32 @@
"""Entry point.
python -m bolt_console the window
python -m bolt_console --cli the terminal REPL
python -m bolt_console -p MESSAGE one message in the terminal, then exit
python -m bolt_console --doctor [--deep]
(--auto works with the window and the terminal)
"""
import sys
def main(argv=None):
args = list(sys.argv[1:] if argv is None else argv)
if "--doctor" in args:
from bolt_console.doctor import main as doctor
return doctor(args)
if "--cli" in args or "-p" in args or "-h" in args or "--help" in args:
from bolt_console.console import run
return run([a for a in args if a != "--cli"])
from bolt_console.gui.window import run
return run(args)
if __name__ == "__main__":
sys.exit(main())
+53
View File
@@ -0,0 +1,53 @@
"""Chat-list helpers with no Qt, shared by the Chats dialog and the
terminal's /chats: turning what GET /desk/chats returns into readable rows."""
from __future__ import annotations
import re
from datetime import datetime, timezone
from typing import Optional
# Context notes clients append to what you typed. They're in the stored last
# message, but they aren't what you said, so they stay out of the preview.
# The preview may be cut off mid-note, so an unclosed one runs to the end.
_CONTEXT_NOTES = re.compile(r"\s*\[(?:Bolt Console:|on screen right now:)[^\]]*(?:\]|$)", re.IGNORECASE)
def clean_preview(text: str, limit: int = 110) -> str:
text = " ".join(_CONTEXT_NOTES.sub("", str(text or "")).split())
return text if len(text) <= limit else text[: limit - 1].rstrip() + "…"
def relative_time(iso: str, now: Optional[datetime] = None) -> str:
"""'just now', '12 min ago', '3 h ago', 'yesterday', '4 days ago', or a
date. Unparseable input comes back as it was."""
try:
then = datetime.fromisoformat(str(iso).replace("Z", "+00:00"))
except (TypeError, ValueError):
return str(iso or "")
if then.tzinfo is None:
then = then.replace(tzinfo=timezone.utc)
now = now or datetime.now(timezone.utc)
seconds = (now - then).total_seconds()
if seconds < 60:
return "just now"
if seconds < 3600:
return f"{int(seconds // 60)} min ago"
if seconds < 86400:
return f"{int(seconds // 3600)} h ago"
days = int(seconds // 86400)
if days == 1:
return "yesterday"
if days < 7:
return f"{days} days ago"
return then.astimezone().strftime("%d %b %Y").lstrip("0")
def local_time(iso: str) -> str:
try:
then = datetime.fromisoformat(str(iso).replace("Z", "+00:00"))
if then.tzinfo is None:
then = then.replace(tzinfo=timezone.utc)
return then.astimezone().strftime("%A %d %B %Y, %H:%M")
except (TypeError, ValueError):
return str(iso or "")
+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
+398
View File
@@ -0,0 +1,398 @@
"""The REPL: read a line, send it to Bolt, show the tool calls and the reply.
`Session` holds everything a turn needs and takes its collaborators as
arguments (the terminal, the tool dispatcher, the two converse functions), so
tests drive whole turns without a real server or a real terminal.
"""
from __future__ import annotations
import os
import platform
import re
import subprocess
import sys
from pathlib import Path
from typing import Callable, Optional
from . import __version__, config, file_delivery, server_client
from .chats import clean_preview, relative_time
from .tools import Tools
from .ui import Terminal, enable_windows_ansi
HELP = """\
Commands
/help this list
/exit, /quit leave (Ctrl+D / Ctrl+Z also works)
/clear clear the screen (Bolt's memory lives on the server and is kept)
/cd <dir> change the working directory commands and relative paths use
/pwd show the working directory
/ask ask before shell commands, writes and edits (default)
/auto run everything without asking
/status server, session and settings
/files download anything Bolt has queued for you now
/new start a fresh chat (new history; Bolt still remembers you)
/chats list your chats with Bolt, from every device
/chat <n|id> switch to chat number n from /chats, or a session id
!<command> run a shell command yourself, without sending it to Bolt
End a line with \\ to keep typing on the next one."""
def _os_label() -> str:
try:
return f"{platform.system()} {platform.release()}".strip() or "unknown OS"
except Exception:
return "unknown OS"
def annotate(text: str, cwd: str, os_label: str) -> str:
"""The context note appended to each message: where "here" is, and that
this client is text-only."""
# The exact marker syntax matters. Measured against the live server
# (2026-09-25): "use filectl and shell commands" made the model write
# `filectl: {...}` or a <tool_call> block as prose and then invent the
# output, on both /desk/converse and /desk/converse_stream; spelling out
# `command: ...` got a real relayed command on both.
return (
f"{text}\n\n[Bolt Console: a text terminal on {os_label}; working directory {cwd}. "
"To act on this machine, use your command tool: "
'`command: filectl {"op": "list", "path": "..."}` for files (ops: list, read, write, edit), '
"or `command: <shell command>` for anything else. The console runs it and sends back "
"the real output. Never write a command's output yourself. "
"There is no pet body, voice or screen here.]"
)
# What a reply looks like when it narrates tool output instead of calling a
# tool: the model copies the shape of real results it has seen earlier in the
# conversation ("→ Command result: [filectl] wrote 1200 chars to …"). Seen
# live: three turns in a row of "wrote the files" with no command ever sent.
_FAKE_RESULT = re.compile(
r"(?:→|->)\s*(?:command|deliver files|tool)\s+result\s*:"
r"|\[filectl\]\s+(?:wrote|edited)\b"
r"|\[exit \d+\]",
re.IGNORECASE,
)
FABRICATION_WARNING = (
"Bolt described running commands, but none reached this app, so nothing was run "
"or written. The results in that reply are made up. Bolt will be told on your next message."
)
CORRECTION = (
"[Bolt Console: correction. Your previous reply described command results, but no "
"command reached this machine, so nothing ran and no files were written. Never write "
"results yourself; emit the actual command and wait for its real output.]"
)
def fabricated(text: str, commands_run: int) -> bool:
"""A reply that reads like tool output when no tool ran this turn."""
return commands_run == 0 and bool(_FAKE_RESULT.search(text or ""))
class Session:
def __init__(
self,
ui: Terminal,
tools: Optional[Tools] = None,
converse: Callable = server_client.converse,
converse_stream: Optional[Callable] = server_client.converse_stream,
list_files: Callable = server_client.list_outbox_files,
download_file: Callable = server_client.download_outbox_file,
create_chat: Callable = server_client.create_chat,
list_chats: Callable = server_client.list_chats,
):
self.ui = ui
self.tools = tools or Tools(ui, mode=config.APPROVAL_MODE)
self._converse = converse
self._converse_stream = converse_stream if config.STREAMING_REPLIES else None
self._list_files = list_files
self._download_file = download_file
self._create_chat = create_chat
self._list_chats = list_chats
self._last_chats = [] # what /chats showed, so /chat 3 means that row
self._said = [] # sentences shown this turn
self._correct_next = False # tell Bolt his last reply was fiction
# ── one turn ─────────────────────────────────────────────────────────
def send(self, text: str) -> None:
message = annotate(text, os.getcwd(), _os_label()) if config.CONSOLE_CONTEXT else text
if self._correct_next:
message = f"{CORRECTION}\n\n{message}"
self._correct_next = False
self._said = []
calls_before = self.tools.calls
self.ui.start_spinner()
try:
reply = self._ask(message)
except server_client.ServerError as exc:
self.ui.stop_spinner()
self.ui.error(f"Error: {exc}")
return
except KeyboardInterrupt:
self.ui.stop_spinner()
self.ui.warn("Interrupted. (The server may still finish that turn on its side.)")
return
self.ui.stop_spinner()
if reply.streamed:
self.ui.end_reply()
else:
self.ui.assistant(reply.text)
shown = " ".join(self._said + [reply.text])
if fabricated(shown, self.tools.calls - calls_before):
self.ui.warn(FABRICATION_WARNING)
self._correct_next = True
self.check_deliveries()
def _ask(self, message: str) -> server_client.Reply:
if self._converse_stream is not None:
try:
return self._converse_stream(message, on_say=self._say, on_command=self.tools)
except server_client.ServerError:
pass # nothing was shown yet; the plain path is invisible to the user
return self._converse(message, on_command=self.tools, on_say=self._holding)
def _say(self, sentence: str) -> None:
self._said.append(sentence)
self.ui.assistant_chunk(sentence)
def _holding(self, line: str) -> None:
self._said.append(line)
self.ui.holding(line)
# ── delivered files ──────────────────────────────────────────────────
def check_deliveries(self, announce_empty: bool = False) -> None:
if not config.RECEIVE_FILES:
if announce_empty:
self.ui.info("File delivery is off (RECEIVE_FILES=false).")
return
try:
queued = self._list_files()
except server_client.ServerError as exc:
self.ui.warn(f"Couldn't check for delivered files: {exc}")
return
if not queued and announce_empty:
self.ui.info("Nothing queued.")
for entry in queued:
file_id, name = str(entry.get("id") or ""), str(entry.get("name") or "")
if not file_id:
continue
try:
data = self._download_file(file_id)
path = file_delivery.save(config.DELIVERED_FILES_DIR, name, data)
except (server_client.ServerError, OSError) as exc:
self.ui.warn(f"Couldn't save delivered file {name!r}: {exc}")
continue
self.ui.line(self.ui.style(f"Received {name} -> {path}", "green"))
# ── input dispatch ───────────────────────────────────────────────────
def handle(self, line: str) -> bool:
"""Act on one line of input. Returns False when the user asked to leave."""
text = line.strip()
if not text:
return True
if text.startswith("!"):
self._local_shell(text[1:].strip())
return True
if text.startswith("/"):
return self._slash(text)
self.ui.line()
self.send(text)
return True
def _local_shell(self, command: str) -> None:
if not command:
return
# The user's own command: live output, their terminal, nothing captured
# and nothing sent to Bolt.
try:
code = subprocess.run(command, shell=True, cwd=os.getcwd()).returncode
except KeyboardInterrupt:
self.ui.warn("Interrupted.")
return
if code:
self.ui.info(f"[exit {code}]")
def _slash(self, text: str) -> bool:
name, _, arg = text[1:].partition(" ")
name, arg = name.lower(), arg.strip()
if name in ("exit", "quit", "q"):
return False
if name == "help":
self.ui.line(HELP)
elif name == "clear":
self.ui.line("\033[2J\033[H" if self.ui.color else "\n" * 3)
elif name == "cd":
self._cd(arg)
elif name == "pwd":
self.ui.line(os.getcwd())
elif name in ("auto", "ask"):
self.tools.mode = name
self.ui.info(
"Auto mode: commands, writes and edits run without asking."
if name == "auto" else "Ask mode: commands, writes and edits need your OK."
)
elif name == "status":
self.status()
elif name == "new":
self.ui.info(self.new_conversation())
elif name == "chats":
self.show_chats()
elif name == "chat":
self.pick_chat(arg)
elif name == "files":
self.check_deliveries(announce_empty=True)
else:
self.ui.warn(f"Unknown command /{name} - try /help.")
return True
def switch_to(self, session_id: str) -> str:
"""Make *session_id* the current chat (new or picked from the list).
Server-side history follows the id; Bolt's memory is per user, so it
carries over either way. Returns a one-line note for the UI."""
config.set_session(session_id)
self._correct_next = False # a correction belongs to the chat it was about
note = f"Chat: {session_id}"
if config.session_pinned():
note += " (DESK_SESSION_ID in .env pins a different one after a restart)"
return note
def show_chats(self) -> None:
try:
self._last_chats = self._list_chats()
except server_client.ServerError as exc:
self.ui.error(f"Error: {exc}")
return
if not self._last_chats:
self.ui.info("No chats yet.")
return
for number, chat in enumerate(self._last_chats, 1):
session_id = str(chat.get("session_id") or "")
mark = self.ui.style("*", "green") if session_id == config.SESSION_ID else " "
label = str(chat.get("label") or session_id)
when = relative_time(chat.get("last_active", ""))
self.ui.line(f"{mark}{number:>3} {self.ui.style(label, 'bold')} {self.ui.style(when, 'dim')}")
preview = clean_preview(chat.get("last_message", ""), 90)
if preview:
self.ui.line(" " + self.ui.style(preview, "dim"))
self.ui.info("/chat <number> to switch, /new for a fresh one")
def pick_chat(self, arg: str) -> None:
arg = arg.strip()
if not arg:
self.ui.warn("Usage: /chat <number from /chats> or /chat <session id>")
return
if arg.isdigit():
index = int(arg) - 1
if not 0 <= index < len(self._last_chats):
self.ui.warn("No such number; run /chats first.")
return
chat = self._last_chats[index]
session_id, label = str(chat.get("session_id")), str(chat.get("label") or "")
else:
session_id, label = arg, arg
self.ui.info(f"{self.switch_to(session_id)} ({label})")
def new_conversation(self) -> str:
"""A fresh chat: empty history, so nothing said earlier (including
invented tool results) is there to be copied. The server mints the id
(POST /desk/chats); if it can't, a local id still gives a clean slate.
Blocking — the window runs create_chat on a worker and calls
switch_to() itself instead."""
try:
return "New chat. " + self.switch_to(self._create_chat())
except server_client.ServerError as exc:
note = self.switch_to(config.new_session())
return f"New chat (made locally; the server couldn't: {exc}). {note}"
def _cd(self, arg: str) -> None:
target = Path(arg or str(Path.home())).expanduser()
try:
os.chdir(target)
except OSError as exc:
self.ui.error(f"cd: {exc}")
return
self.ui.info(os.getcwd())
def status(self) -> None:
rows = [
("server", config.SERVER_URL or "(not set)"),
("session", config.SESSION_ID),
("approval", self.tools.mode),
("cwd", os.getcwd()),
("streaming", "on" if self._converse_stream else "off"),
("deliveries", str(config.DELIVERED_FILES_DIR) if config.RECEIVE_FILES else "off"),
]
for key, value in rows:
self.ui.line(f" {self.ui.style(key.ljust(11), 'dim')}{value}")
# ── the loop ─────────────────────────────────────────────────────────
def banner(self) -> None:
ui = self.ui
ui.line(ui.style(f"{ui.glyphs.bullet} Bolt Console", "bold", "cyan") + ui.style(f" v{__version__}", "dim"))
ui.info(f" {config.SERVER_URL} | session {config.SESSION_ID} | approval: {self.tools.mode}")
ui.info(f" cwd: {os.getcwd()}")
ui.info(" /help for commands, /exit to leave")
ui.line()
def loop(self) -> int:
self.banner()
while True:
try:
line = self.ui.read_message()
except EOFError:
self.ui.line()
return 0
except KeyboardInterrupt:
# Ctrl+C at an empty prompt clears the line, like a shell.
self.ui.line()
continue
if not self.handle(line):
return 0
def _usage() -> str:
return (
"usage: python -m bolt_console [--auto] [-p MESSAGE] [--doctor [--deep]]\n"
" -p MESSAGE send one message, print the reply, exit\n"
" --auto don't ask before commands, writes and edits\n"
" --doctor check the install (--deep also contacts the server)"
)
def run(argv: Optional[list] = None) -> int:
argv = list(sys.argv[1:] if argv is None else argv)
if "-h" in argv or "--help" in argv:
print(_usage())
return 0
try:
sys.stdout.reconfigure(errors="replace")
except Exception:
pass
enable_windows_ansi()
ui = Terminal(color=config.COLOR)
if not config.is_configured():
ui.error("Missing " + ", ".join(config.missing_config()) + ". Copy .env.example to .env and fill them in.")
return 2
mode = "auto" if "--auto" in argv else config.APPROVAL_MODE
session = Session(ui, tools=Tools(ui, mode=mode))
if "-p" in argv:
index = argv.index("-p")
message = " ".join(argv[index + 1:]).strip()
if not message:
ui.error("-p needs a message")
return 2
session.send(message)
return 0
return session.loop()
+339
View File
@@ -0,0 +1,339 @@
"""Dictation: speak into the mic, get text in the composer.
Same route as Bolt-Pet: 16 kHz mono PCM streamed over a websocket to the Bolt
server's own `/desk/stt` relay, which forwards it to Deepgram and sends
Deepgram's JSON back untouched. No Deepgram account or key here — auth is the
desk API key — though the server does meter it against the same credits as a
chat turn.
One `Dictation` is one recording:
d = Dictation(on_update, on_level, on_done)
d.start() # mic opens, audio streams up, on_update(final, interim) as words arrive
d.stop() # or silence / the length cap stops it; on_done(text, error) follows
d.cancel() # stop and throw the result away
Design points:
- **Live, with a safety net.** Audio goes up as it's captured so the words
appear while you talk. Every frame is also buffered, and if the socket
never opened or died, stop() opens a fresh one and sends the whole buffer —
Bolt-Pet's one-shot fallback.
- **You decide when it's done**, with help: a click stops it, and so do
`silence_seconds` of quiet after you've said something, or `max_seconds`.
- **No Qt, no numpy.** Callbacks run on background threads; the window turns
them into signals. Audio is raw int16 bytes, and loudness is computed with
the array module. That keeps this identical in the single-file Linux app.
- **Everything with hardware or network is injected** (`connect`,
`open_stream`), so the whole flow is tested without a mic or a server.
"""
from __future__ import annotations
import array
import json
import math
import queue
import sys
import threading
import time
import urllib.parse
from typing import Callable, List, Optional
from . import config
SAMPLE_RATE = 16000
BLOCK_FRAMES = 1280 # 80 ms, same chunking as Bolt-Pet
SPEECH_RMS = 500 # int16 RMS above this counts as talking
def unavailable_reason() -> Optional[str]:
"""None if dictation can work here, otherwise a sentence saying why —
shown as the mic button's tooltip instead of a crash."""
if not config.is_configured():
return "Set BOLT_SERVER_URL and DESK_API_KEY to use dictation"
try:
import websocket # noqa: F401 (websocket-client)
except Exception:
return "Dictation needs websocket-client: pip install websocket-client"
try:
import sounddevice # noqa: F401
except OSError:
return ("Dictation needs the PortAudio library (e.g. sudo apt install libportaudio2)"
if sys.platform.startswith("linux") else "PortAudio isn't available on this system")
except Exception:
return "Dictation needs sounddevice: pip install sounddevice"
return None
def mic_device():
"""MIC_DEVICE from .env: an index (as saved by Bolt-Pet's mic picker) or a
name substring; blank means the system default."""
raw = str(getattr(config, "MIC_DEVICE", "") or "").strip()
if not raw:
return None
return int(raw) if raw.lstrip("-").isdigit() else raw
def connect(sample_rate: int = SAMPLE_RATE):
"""Open the server's /desk/stt websocket for one recording."""
import websocket
query = urllib.parse.urlencode({
"session_id": config.SESSION_ID, "encoding": "linear16", "sample_rate": sample_rate,
})
url = config.SERVER_URL.replace("http", "ws", 1) + "/desk/stt?" + query
return websocket.create_connection(url, header=[f"X-Desk-Api-Key: {config.API_KEY}"], timeout=10)
def open_stream(callback: Callable[[bytes], None], sample_rate: int = SAMPLE_RATE):
"""Start the default (or MIC_DEVICE) microphone, handing raw int16 mono
bytes to *callback* from PortAudio's thread. Returns something with
stop() and close()."""
import sounddevice as sd
stream = sd.RawInputStream(
samplerate=sample_rate, channels=1, dtype="int16", blocksize=BLOCK_FRAMES,
device=mic_device(), callback=lambda data, _frames, _time, _status: callback(bytes(data)),
)
stream.start()
return stream
def rms(chunk: bytes) -> float:
samples = array.array("h")
samples.frombytes(chunk[: len(chunk) - len(chunk) % 2])
if not samples:
return 0.0
return math.sqrt(sum(s * s for s in samples) / len(samples))
def parse_result(message) -> tuple:
"""(text, is_final) from a Deepgram results frame; ("", False) for
anything else. Never raises — it runs on the reader thread."""
try:
if isinstance(message, bytes):
message = message.decode("utf-8", "ignore")
data = json.loads(message)
except (TypeError, ValueError):
return "", False
if not isinstance(data, dict) or data.get("type") not in (None, "Results"):
return "", False
alternatives = (data.get("channel") or {}).get("alternatives") or []
if not alternatives:
return "", False
text = str((alternatives[0] or {}).get("transcript") or "").strip()
return text, bool(data.get("is_final") or data.get("speech_final"))
class Dictation:
def __init__(
self,
on_update: Callable[[str, str], None],
on_level: Callable[[float], None],
on_done: Callable[[str, Optional[str]], None],
*,
connect_fn: Callable = None,
open_stream_fn: Callable = None,
silence_seconds: float = 3.0,
max_seconds: float = 300.0,
sample_rate: int = SAMPLE_RATE,
on_stopping: Callable[[], None] = None,
):
self._on_update, self._on_level, self._on_done = on_update, on_level, on_done
# Recording over (click, silence or cap), transcript not back yet.
self._on_stopping = on_stopping or (lambda: None)
self._connect = connect_fn or connect
self._open_stream = open_stream_fn or open_stream
self._silence_seconds = silence_seconds
self._max_seconds = max_seconds
self._rate = sample_rate
self._frames: "queue.Queue[bytes]" = queue.Queue()
self._buffer: List[bytes] = []
self._finals: List[str] = []
self._interim = ""
self._lock = threading.Lock()
self._stop = threading.Event()
self._cancelled = False
self._socket = None
self._socket_ok = False
self._stream = None
self._reader = None
self._pump = None
# ── control (any thread) ─────────────────────────────────────────────
def start(self) -> None:
"""Open the mic (raises if that fails — nothing to record without it)
and try the live socket (failure there is fine: the fallback has the
audio)."""
try:
self._socket = self._connect(self._rate)
self._socket_ok = True
self._reader = threading.Thread(target=self._read_loop, name="stt-reader", daemon=True)
self._reader.start()
except Exception:
self._socket, self._socket_ok = None, False
try:
self._stream = self._open_stream(self._frames.put, self._rate)
except Exception:
self._close_socket()
raise
self._pump = threading.Thread(target=self._pump_loop, name="stt-pump", daemon=True)
self._pump.start()
def stop(self) -> None:
self._stop.set()
def cancel(self) -> None:
self._cancelled = True
self._stop.set()
@property
def live(self) -> bool:
"""Whether words are arriving as you speak (vs. only after stop)."""
return self._socket_ok
# ── the pump: mic frames -> socket, buffer, level, auto-stop ─────────
def _pump_loop(self) -> None:
started = time.monotonic()
heard_speech, last_speech = False, started
while not self._stop.is_set():
try:
chunk = self._frames.get(timeout=0.1)
except queue.Empty:
chunk = None
now = time.monotonic()
if chunk:
self._buffer.append(chunk)
self._send(chunk)
loudness = rms(chunk)
self._safe(self._on_level, min(1.0, math.sqrt(loudness / 8000.0)))
if loudness >= SPEECH_RMS:
heard_speech, last_speech = True, now
if heard_speech and now - last_speech >= self._silence_seconds:
break
if now - started >= self._max_seconds:
break
self._finish()
def _send(self, chunk: bytes) -> None:
if not self._socket_ok:
return
try:
self._socket.send_binary(chunk)
except Exception:
self._socket_ok = False # the fallback will send the buffer instead
def _finish(self) -> None:
self._stop.set()
self._safe(self._on_stopping)
for step in ("stop", "close"):
try:
getattr(self._stream, step)()
except Exception:
pass
self._safe(self._on_level, 0.0)
if self._cancelled:
self._close_socket()
self._safe(self._on_done, "", None)
return
# Whatever was still queued when we stopped belongs to the recording.
while True:
try:
chunk = self._frames.get_nowait()
except queue.Empty:
break
self._buffer.append(chunk)
self._send(chunk)
# Live socket healthy to the end: its transcript is the answer, even
# an empty one (silence) — re-sending would pay to transcribe it twice.
error = None
if self._socket_ok:
self._flush_socket()
else:
error = self._fallback()
text = self._text()
if not text and error is None:
error = "Didn't catch anything — try again a little closer to the mic."
self._safe(self._on_done, text, error)
def _flush_socket(self) -> None:
"""Deepgram flushes its last results after CloseStream; wait for them,
bounded so a hung socket can't hold the text hostage."""
try:
self._socket.send(json.dumps({"type": "CloseStream"}))
except Exception:
self._socket_ok = False
if self._reader is not None:
self._reader.join(timeout=4)
self._close_socket()
def _fallback(self) -> Optional[str]:
"""The live socket never worked or died part-way: one fresh connection,
the whole buffer, from an empty transcript (a half-live session's words
would otherwise come back twice)."""
self._close_socket()
if self._reader is not None:
self._reader.join(timeout=1)
with self._lock:
self._finals, self._interim = [], ""
if not self._buffer:
return None
try:
self._socket = self._connect(self._rate)
except Exception as exc:
return f"Couldn't reach the transcription server: {exc}"
self._socket_ok = True
self._reader = threading.Thread(target=self._read_loop, name="stt-reader", daemon=True)
self._reader.start()
audio = b"".join(self._buffer)
for offset in range(0, len(audio), BLOCK_FRAMES * 2 * 8):
self._send(audio[offset:offset + BLOCK_FRAMES * 2 * 8])
self._flush_socket()
return None
def _close_socket(self) -> None:
socket, self._socket = self._socket, None
if socket is not None:
try:
socket.close()
except Exception:
pass
# ── the reader: transcripts back ─────────────────────────────────────
def _read_loop(self) -> None:
socket = self._socket
while socket is not None:
try:
message = socket.recv()
except Exception:
break
if not message:
break
text, is_final = parse_result(message)
with self._lock:
if is_final:
if text:
self._finals.append(text)
self._interim = ""
elif text:
self._interim = text
finals, interim = " ".join(self._finals), self._interim
if text:
self._safe(self._on_update, finals, interim)
def _text(self) -> str:
with self._lock:
# An interim that never became final is still better than nothing.
return " ".join(self._finals + ([self._interim] if self._interim else [])).strip()
@staticmethod
def _safe(callback, *args) -> None:
try:
callback(*args)
except Exception:
pass
+163
View File
@@ -0,0 +1,163 @@
"""`python -m bolt_console --doctor` — is this install actually going to work?
Carried over from Bolt-Pet, where a missing port publish, a wrong reverse-proxy
header and a venv whose python was a zero-byte file all presented as "it's
being weird" and each took a conversation to find.
Each check is independent and reports one of three things — ok, a warning
(works, but degraded), or a failure (this will not do what you expect) — and
says *what to do about it* rather than just what is wrong. Nothing here raises:
a doctor that crashes on a broken install is diagnosing the wrong patient.
Deliberately does not touch the network by default — it's the first thing you
reach for when the network is what's broken. `--deep` contacts the server.
"""
from __future__ import annotations
import sys
from dataclasses import dataclass
from typing import Callable, Optional
from . import config
OK, WARN, FAIL = "ok", "warn", "fail"
_MARKS = {OK: " ok ", WARN: " warn ", FAIL: " FAIL "}
@dataclass
class Check:
name: str
status: str
detail: str = ""
fix: str = ""
def line(self) -> str:
text = f"[{_MARKS[self.status]}] {self.name:22} {self.detail}"
if self.fix and self.status != OK:
text += f"\n{'':32}→ {self.fix}"
return text
# ── the checks ──────────────────────────────────────────────────────────────
def check_config() -> Check:
missing = config.missing_config()
if missing:
return Check("server config", FAIL, f"missing {', '.join(missing)}",
"set them in .env — without these the console exits at startup")
return Check("server config", OK, f"{config.SERVER_URL} as {config.SESSION_ID}")
def check_server(deep: bool = False) -> Check:
if not config.SERVER_URL:
return Check("server", FAIL, "no BOLT_SERVER_URL",
"cp .env.example .env and set BOLT_SERVER_URL to your Bolt server")
if not deep:
return Check("server", OK, f"{config.SERVER_URL} (not contacted; --deep to try)")
try:
from . import server_client
health = server_client.check_health(timeout=8)
return Check("server", OK, f"reachable — {health}")
except Exception as exc:
return Check("server", FAIL, f"unreachable: {exc}",
"check the URL, the key, and that the container is up")
def check_streaming_endpoint(deep: bool = False) -> Check:
"""The streamed-reply endpoint is newer than some deployed servers."""
if not config.STREAMING_REPLIES:
return Check("streamed replies", WARN, "disabled (STREAMING_REPLIES=false)")
if not deep:
return Check("streamed replies", OK, "enabled (endpoint not probed)")
try:
import requests
response = requests.post(
f"{config.SERVER_URL}/desk/converse_stream",
json={"session_id": config.SESSION_ID, "text": ""},
headers={"X-Desk-Api-Key": config.API_KEY}, timeout=8, stream=True,
)
if response.status_code == 404:
return Check("streamed replies", WARN, "server has no /desk/converse_stream",
"update the server, or set STREAMING_REPLIES=false to skip the probe")
return Check("streamed replies", OK, f"endpoint answered {response.status_code}")
except Exception as exc:
return Check("streamed replies", WARN, f"probe failed: {exc}")
def check_python() -> Check:
version = ".".join(str(n) for n in sys.version_info[:3])
if sys.version_info < (3, 9):
return Check("python", FAIL, version, "Python 3.9 or newer is required")
return Check("python", OK, f"{version} at {sys.executable}")
def check_approval() -> Check:
mode = config.APPROVAL_MODE
if mode == "auto":
return Check("approval", WARN, "auto — Bolt's commands, writes and edits run without asking",
"APPROVAL_MODE=ask to confirm each one (or /ask for one session)")
if mode != "ask":
return Check("approval", WARN, f"unknown APPROVAL_MODE={mode!r}, treated as ask",
"use ask or auto")
return Check("approval", OK, "ask before commands, writes and edits")
def check_deliveries() -> Check:
if not config.RECEIVE_FILES:
return Check("file delivery", WARN, "disabled (RECEIVE_FILES=false)")
directory = config.DELIVERED_FILES_DIR
probe = directory
while not probe.exists() and probe.parent != probe:
probe = probe.parent
import os
if not os.access(str(probe), os.W_OK):
return Check("file delivery", FAIL, f"can't write under {probe}",
"set DELIVERED_FILES_DIR to a folder you can write to")
return Check("file delivery", OK, str(directory))
CHECKS = (
("python", check_python),
("config", check_config),
("server", check_server),
("stream", check_streaming_endpoint),
("approval", check_approval),
("deliveries", check_deliveries),
)
def run(deep: bool = False) -> list:
results = []
for _name, check in CHECKS:
try:
try:
results.append(check(deep))
except TypeError:
results.append(check())
except Exception as exc: # a broken check must not hide the others
results.append(Check(_name, FAIL, f"the check itself failed: {exc}"))
return results
def main(argv: Optional[list] = None) -> int:
argv = list(argv if argv is not None else sys.argv[1:])
deep = "--deep" in argv
print(f"Bolt Console preflight{' (deep: contacting the server)' if deep else ''}\n")
results = run(deep=deep)
for check in results:
print(check.line())
failures = [c for c in results if c.status == FAIL]
warnings = [c for c in results if c.status == WARN]
print()
if failures:
print(f"{len(failures)} problem(s) will stop this working. Fix those first.")
elif warnings:
print(f"Ready. {len(warnings)} thing(s) degraded but working.")
else:
print("Everything checks out.")
return 1 if failures else 0
+54
View File
@@ -0,0 +1,54 @@
"""Saves files the server queues via its deliver_files tool (ai/desk_api.py
in the main tmn-api repo) to a local downloads folder.
The server side of this is already generic — any desk client can list
GET /desk/files and fetch GET /desk/files/<id> (see server_client.
list_outbox_files / download_outbox_file) — so this module is just the
filesystem half: turn a server-supplied display name into a safe path and
write the bytes.
Pure filename/path logic lives here so it's testable without touching a real
mic/network; the only I/O is the final write in save().
"""
from __future__ import annotations
from pathlib import Path
_FALLBACK_NAME = "delivered_file"
def sanitize_filename(name: str) -> str:
"""Reduce a server-supplied name to a bare filename. Defends against a
delivered name that's actually a path (../../etc, an absolute path, ...)
— Path(...).name strips every directory component, and anything that
collapses to nothing (or "." / "..") falls back to a generic name."""
candidate = Path(str(name or "").strip()).name
if candidate in ("", ".", ".."):
return _FALLBACK_NAME
return candidate
def unique_path(directory: Path, name: str) -> Path:
"""*name* under *directory*, suffixed " (1)", " (2)", ... if that name is
already taken — a delivered file never overwrites an earlier download."""
directory = Path(directory)
directory.mkdir(parents=True, exist_ok=True)
candidate = directory / name
if not candidate.exists():
return candidate
stem, suffix = candidate.stem, candidate.suffix
n = 1
while True:
candidate = directory / f"{stem} ({n}){suffix}"
if not candidate.exists():
return candidate
n += 1
def save(directory: Path, name: str, data: bytes) -> Path:
"""Write *data* under *directory* as *name* (sanitized + uniquified),
returning the path written."""
path = unique_path(directory, sanitize_filename(name))
path.write_bytes(data)
return path
+287
View File
@@ -0,0 +1,287 @@
"""Local file read/edit/write, intercepted from the server-relayed command
channel before it reaches a shell (see tools.Tools.handle). Whatever text the server's
"command" tool sends is just a string this repo is free to interpret before
it ever reaches subprocess — a `filectl` pseudo-command is one such
interpretation, giving the model a way to read/write/edit files on this
machine without constructing a raw shell heredoc, where quoting, `$`,
backticks, and embedded quotes make anything beyond a one-liner failure-prone.
Executing arbitrary commands already works today (that's exactly what
run_local_command/subprocess.run does) — filectl doesn't add that ability,
it only makes the read/write/edit slice of it reliable. It also doesn't
expand what the server can already do to this machine: a relayed shell
command could already overwrite any file the desktop user can write (see the
security notes in the README) — filectl is a safer *path* to the same
capability, not a new capability.
Wire format: `filectl <json>`, where <json> is a single-line, compact JSON
object — critically, ONE LINE. The server's "command" tool marker only
captures the argument up to the next newline (see TOOL_SPECS/
_extract_all_tool_calls in the main repo's ai/agents/default.py — "command"
is not declared multiline), so a marker-delimited multi-line payload (this
module's first design) silently got truncated at the first line no matter
how the prompt worded it. JSON sidesteps that for free: json.dumps() already
encodes embedded newlines as the two characters "\n", not an actual line
break, so arbitrarily multi-line file content still fits on the one physical
line the extractor captures.
filectl {"op": "list", "path": "<dir>", "pattern": "<glob>", "recursive": <bool>}
filectl {"op": "read", "path": "<path>", "start": <line>, "end": <line>}
filectl {"op": "write", "path": "<path>", "content": "<text>"}
filectl {"op": "edit", "path": "<path>", "old": "<text>", "new": "<text>"}
"pattern"/"recursive" (list) and "start"/"end" (read) are optional. `list`
exists even though a real `ls`/`dir` shell command already works, because
this repo is cross-platform (Windows/macOS/Linux) and the model shouldn't
have to guess which listing command applies on this machine — one glob-based
op covers all three. Must be invoked as the argument to the
ordinary `command` tool marker (e.g. `command: filectl {"op": "write", ...}`)
— see the pet-only paragraph in the main repo's ai/desk_api.py
_system_context for the exact instruction the model is given, including the
"keep it one line" requirement.
Pure parsing (parse) is separated from the filesystem I/O (execute) so the
syntax is unit-testable without touching disk.
"""
from __future__ import annotations
import json
from pathlib import Path
from typing import Optional
from . import relay_json
_PREFIXES = ("filectl", "file")
# Keeps a runaway read/write/list from blowing up the tool_result relay (and,
# for read/list, from flooding the model's context with a giant response).
_MAX_READ_BYTES = 200_000
_MAX_WRITE_BYTES = 2_000_000
_MAX_LIST_ENTRIES = 500
HELP = (
'filectl {"op": "list", "path": "<dir>", "pattern": "<glob>", "recursive": <bool>}\n'
'filectl {"op": "read", "path": "<path>", "start": <line>, "end": <line>}\n'
'filectl {"op": "write", "path": "<path>", "content": "<text>"}\n'
'filectl {"op": "edit", "path": "<path>", "old": "<text>", "new": "<text>"}\n'
"Must be all on one line — this rides the single-line \"command\" marker."
)
class FileOpError(Exception):
"""Bad filectl syntax or a filesystem error — reported back to the
server as command output."""
def is_file_command(command: str) -> bool:
stripped = (command or "").strip()
if not stripped:
return False
first_word = stripped.split(None, 1)[0]
return first_word.lower() in _PREFIXES
def parse(command: str) -> Optional[dict]:
"""Parse a `filectl <json>` string into an action dict, or None if this
isn't a filectl command at all (caller should try the next handler, or
fall back to a real shell command). Raises FileOpError on a filectl
command that doesn't make sense."""
if not is_file_command(command):
return None
stripped = command.strip()
_, _, rest = stripped.partition(" ")
rest = rest.strip()
if not rest or rest.lower() in ("help", "-h", "--help"):
return {"action": "help"}
# Lenient on purpose — see relay_json. A stray quote in machine-written
# JSON should not cost a turn, but the repair is reported back so the model
# is told it sent something broken while it can still learn from it.
try:
payload, repairs = relay_json.loads(rest)
except relay_json.RelayJsonError as exc:
raise FileOpError(f"couldn't parse filectl JSON — {exc}\nusage:\n{HELP}") from exc
if not isinstance(payload, dict):
raise FileOpError(f"filectl payload must be a JSON object; usage:\n{HELP}")
op = str(payload.get("op") or "help").lower()
# Carried on the action so execute() can tell the model what it got wrong;
# a silent repair would fix today's call and guarantee tomorrow's.
tag = {"_repairs": repairs} if repairs else {}
if op == "help":
return {"action": "help", **tag}
if op == "list":
path = _required_str(payload, "path")
pattern = payload.get("pattern", "*")
if not isinstance(pattern, str) or not pattern:
raise FileOpError('"pattern" must be a non-empty string')
return {
"action": "list", "path": path,
"pattern": pattern, "recursive": bool(payload.get("recursive")), **tag,
}
if op == "read":
path = _required_str(payload, "path")
return {
"action": "read", "path": path,
"start": _line_number(payload.get("start"), "start"),
"end": _line_number(payload.get("end"), "end"), **tag,
}
if op == "write":
path = _required_str(payload, "path")
if payload.get("content") is None:
raise FileOpError('write needs "content"')
return {"action": "write", "path": path, "content": str(payload["content"]), **tag}
if op == "edit":
path = _required_str(payload, "path")
if payload.get("old") is None or payload.get("new") is None:
raise FileOpError('edit needs "old" and "new"')
old, new = str(payload["old"]), str(payload["new"])
if old == new:
raise FileOpError("old and new text are identical — nothing to edit")
return {"action": "edit", "path": path, "old": old, "new": new, **tag}
raise FileOpError(f"unknown filectl op {op!r}; usage:\n{HELP}")
def _required_str(payload: dict, key: str) -> str:
value = payload.get(key)
if not isinstance(value, str) or not value.strip():
raise FileOpError(f'filectl needs a non-empty "{key}"')
return value
def _line_number(value, label: str) -> Optional[int]:
if value is None:
return None
if isinstance(value, bool) or not isinstance(value, int):
raise FileOpError(f"{label} must be an integer line number, got {value!r}")
return value
def describe(action: dict) -> str:
"""A one-line summary of the action, for logs."""
kind = action.get("action")
if kind == "help":
return "[filectl] help"
return f"[filectl] {kind} {action.get('path', '')}"
def execute(action: dict) -> str:
"""Actually perform a parsed filectl action, returning the text to send
back to the server as the command's output. Raises FileOpError on any
filesystem problem, same as a bad-syntax parse error."""
kind = action.get("action")
if kind == "help":
return HELP
if kind == "list":
output = _do_list(action)
elif kind == "read":
output = _do_read(action)
elif kind == "write":
output = _do_write(action)
elif kind == "edit":
output = _do_edit(action)
else:
output = "[filectl] ok"
return output + relay_json.repair_note(action.get("_repairs") or [])
def _resolve(path_str: str) -> Path:
return Path(path_str).expanduser()
def _do_list(action: dict) -> str:
path = _resolve(action["path"])
if not path.is_dir():
raise FileOpError(f"no such directory: {path}")
pattern = action["pattern"]
glob = path.rglob if action["recursive"] else path.glob
try:
entries = sorted(glob(pattern), key=lambda p: str(p).lower())
except OSError as exc:
raise FileOpError(f"couldn't list {path}: {exc}") from exc
if not entries:
return f"[no entries matching {pattern!r} in {path}]"
truncated = len(entries) > _MAX_LIST_ENTRIES
lines = []
for entry in entries[:_MAX_LIST_ENTRIES]:
rel = entry.relative_to(path)
if entry.is_dir():
lines.append(f"{rel}/")
continue
try:
size = entry.stat().st_size
except OSError:
size = -1
lines.append(f"{rel}\t{size}B")
if truncated:
lines.append(f"... truncated at {_MAX_LIST_ENTRIES} entries (of {len(entries)}) — narrow \"pattern\"")
return "\n".join(lines)
def _do_read(action: dict) -> str:
path = _resolve(action["path"])
if not path.is_file():
raise FileOpError(f"no such file: {path}")
try:
data = path.read_bytes()
except OSError as exc:
raise FileOpError(f"couldn't read {path}: {exc}") from exc
if len(data) > _MAX_READ_BYTES:
raise FileOpError(
f"{path} is {len(data)} bytes, over the {_MAX_READ_BYTES}-byte filectl read limit — "
'pass "start"/"end" to read a slice instead'
)
try:
text = data.decode("utf-8")
except UnicodeDecodeError as exc:
raise FileOpError(f"{path} isn't valid UTF-8 text: {exc}") from exc
lines = text.splitlines()
start = max(1, action.get("start") or 1)
end = min(len(lines), action.get("end") or len(lines))
if not lines:
return "[empty file]"
return "\n".join(f"{i:>6}\t{lines[i - 1]}" for i in range(start, end + 1))
def _do_write(action: dict) -> str:
path = _resolve(action["path"])
content = action["content"]
if len(content.encode("utf-8")) > _MAX_WRITE_BYTES:
raise FileOpError(f"content is over the {_MAX_WRITE_BYTES}-byte filectl write limit")
try:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content, encoding="utf-8")
except OSError as exc:
raise FileOpError(f"couldn't write {path}: {exc}") from exc
return f"[filectl] wrote {len(content)} chars to {path}"
def _do_edit(action: dict) -> str:
path = _resolve(action["path"])
old, new = action["old"], action["new"]
if not path.is_file():
raise FileOpError(f"no such file: {path}")
try:
text = path.read_text(encoding="utf-8")
except OSError as exc:
raise FileOpError(f"couldn't read {path}: {exc}") from exc
count = text.count(old)
if count == 0:
raise FileOpError(f"didn't find that text in {path} — nothing changed")
if count > 1:
raise FileOpError(
f"that text appears {count} times in {path} — filectl edit needs a unique match; "
"include more surrounding context"
)
try:
path.write_text(text.replace(old, new, 1), encoding="utf-8")
except OSError as exc:
raise FileOpError(f"couldn't write {path}: {exc}") from exc
return f"[filectl] edited {path}"
+2
View File
@@ -0,0 +1,2 @@
"""PySide6 front end. Imported only when the window is wanted, so the
terminal client works without Qt installed."""
+106
View File
@@ -0,0 +1,106 @@
"""The seam between a turn running on a worker thread and the window.
`Session` and `Tools` were written against a terminal-shaped UI (tool_call,
tool_result, confirm, assistant_chunk, …). `Bridge` is that same shape, but
every call becomes a Qt signal — delivered to the window on the GUI thread
through a queued connection — so the whole turn logic is shared, unchanged,
between the terminal and the window.
The one call that has to *return* something is confirm(): the worker blocks on
an Event until the user clicks Allow/Deny in the window, which calls answer()
from the GUI thread. close() releases a pending wait so the app can quit mid
approval without hanging the worker.
"""
from __future__ import annotations
import threading
from PySide6.QtCore import QObject, Signal
class Bridge(QObject):
busy = Signal(bool)
chunk = Signal(str) # one streamed sentence
reply_ended = Signal() # the streamed block is complete
reply = Signal(str) # a whole (non-streamed) reply
holding_line = Signal(str) # "give me a sec" while a tool runs
tool_started = Signal(int, str, str) # id, name, argument
tool_output = Signal(int, str, bool) # id, text, is_error
tool_diff = Signal(int, str, str) # id, old, new
approval_needed = Signal(int, str) # id, question
notice = Signal(str, str) # level (info/warn/error), text
def __init__(self, parent=None):
super().__init__(parent)
self._tool_id = 0
self._answer = "no"
self._answered = threading.Event()
self._closed = False
# ── the terminal-shaped surface Session and Tools call ───────────────
def start_spinner(self, label: str = "Thinking") -> None:
self.busy.emit(True)
def stop_spinner(self) -> None:
self.busy.emit(False)
def assistant_chunk(self, sentence: str) -> None:
if sentence.strip():
self.chunk.emit(sentence.strip())
def end_reply(self) -> None:
self.reply_ended.emit()
def assistant(self, text: str) -> None:
if text.strip():
self.reply.emit(text.strip())
def holding(self, text: str) -> None:
self.holding_line.emit(text.strip())
def tool_call(self, name: str, argument: str) -> None:
self._tool_id += 1
self.tool_started.emit(self._tool_id, name, argument)
def tool_result(self, text: str, error: bool = False) -> None:
self.tool_output.emit(self._tool_id, text, error)
def diff(self, old: str, new: str) -> None:
self.tool_diff.emit(self._tool_id, old, new)
def confirm(self, question: str) -> str:
if self._closed:
return "no"
self._answered.clear()
self._answer = "no"
self.approval_needed.emit(self._tool_id, question)
self._answered.wait()
return self._answer
def info(self, text: str) -> None:
self.notice.emit("info", text)
def warn(self, text: str) -> None:
self.notice.emit("warn", text)
def error(self, text: str) -> None:
self.notice.emit("error", text)
def line(self, text: str = "") -> None:
if text.strip():
self.notice.emit("info", text.strip())
def style(self, text: str, *_names: str) -> str:
return text
# ── called from the GUI thread ───────────────────────────────────────
def answer(self, value: str) -> None:
self._answer = value if value in ("yes", "no", "always") else "no"
self._answered.set()
def close(self) -> None:
self._closed = True
self.answer("no")
+235
View File
@@ -0,0 +1,235 @@
"""The Chats dialog: every conversation this user has with Bolt, across the
console, the pet and the phone, newest first, and a New Chat button.
Network calls never run on the UI thread. Each one is a `ChatTask` on the
global QThreadPool that calls server_client synchronously and hands the
result back through `ChatSignals`, the same signal-based hand-off the turn
worker uses. The list is fetched when the dialog opens and on Refresh; there
is no polling.
Picking or creating a chat doesn't switch anything here. The dialog emits
`chosen(session_id, label)` and the window does the switch, because the
window owns the transcript that has to be cleared along with it.
"""
from __future__ import annotations
from typing import Callable, Optional
from PySide6.QtCore import QObject, QRunnable, QSize, Qt, QThreadPool, Signal
from PySide6.QtWidgets import (
QDialog, QHBoxLayout, QLabel, QListWidget, QListWidgetItem, QPushButton,
QVBoxLayout, QWidget,
)
from .. import config, server_client
from ..chats import clean_preview, local_time, relative_time
from . import icons, theme
# ── off-thread calls ────────────────────────────────────────────────────────
class ChatSignals(QObject):
loaded = Signal(list) # chats, newest first
created = Signal(str) # the new session_id
failed = Signal(str, str) # operation ("list" / "create"), message
class ChatTask(QRunnable):
"""Runs one server_client call on the thread pool and reports back."""
def __init__(self, operation: str, call: Callable, signals: ChatSignals):
super().__init__()
self.operation, self.call, self.signals = operation, call, signals
def run(self) -> None:
try:
result = self.call()
except Exception as exc: # ServerError, or anything unexpected
self.signals.failed.emit(self.operation, str(exc))
return
if self.operation == "list":
self.signals.loaded.emit(list(result))
else:
self.signals.created.emit(str(result))
def run_in_pool(operation: str, call: Callable, signals: ChatSignals) -> None:
QThreadPool.globalInstance().start(ChatTask(operation, call, signals))
# ── the dialog ──────────────────────────────────────────────────────────────
class ChatRow(QWidget):
"""Label and time on the first line, the last message under it."""
def __init__(self, chat: dict, current: bool, parent=None):
super().__init__(parent)
outer = QVBoxLayout(self)
outer.setContentsMargins(14, 9, 14, 9)
outer.setSpacing(2)
top = QHBoxLayout()
top.setSpacing(8)
title = QLabel(str(chat.get("label") or chat.get("session_id") or "Untitled"))
title.setObjectName("ChatTitle")
top.addWidget(title, 1)
if current:
badge = QLabel("Current")
badge.setObjectName("Pill")
badge.setProperty("kind", "waiting")
top.addWidget(badge)
when = QLabel(relative_time(chat.get("last_active", "")))
when.setObjectName("ChatTime")
top.addWidget(when)
outer.addLayout(top)
preview = QLabel(clean_preview(chat.get("last_message", "")) or "—")
preview.setObjectName("ChatPreview")
outer.addWidget(preview)
for widget in (title, when, preview):
widget.setAttribute(Qt.WA_TransparentForMouseEvents)
class ChatListDialog(QDialog):
"""`chosen(session_id, label)` fires for a picked chat and for a newly
created one (label "New chat"); the window does the actual switch."""
chosen = Signal(str, str)
def __init__(self, parent=None, list_chats: Callable = None, create_chat: Callable = None):
super().__init__(parent)
self.setObjectName("ChatDialog")
self.setWindowTitle("Chats")
self.setModal(True)
self.resize(560, 580)
self._list_chats = list_chats or server_client.list_chats
self._create_chat = create_chat or server_client.create_chat
self.signals = ChatSignals()
self.signals.loaded.connect(self._populate)
self.signals.created.connect(self._created)
self.signals.failed.connect(self._failed)
col = QVBoxLayout(self)
col.setContentsMargins(18, 18, 18, 16)
col.setSpacing(12)
head = QHBoxLayout()
head.setSpacing(10)
head.addWidget(icons.IconLabel("bolt", theme.ACCENT_INK, 24, tile=theme.ACCENT))
heading = QLabel("Chats")
heading.setObjectName("Wordmark")
head.addWidget(heading)
head.addStretch(1)
self.status = QLabel("")
self.status.setObjectName("Hint")
head.addWidget(self.status)
col.addLayout(head)
sub = QLabel("Every conversation with Bolt, from any device. A new chat starts with "
"fresh history; Bolt still remembers you.")
sub.setObjectName("HeroSub")
sub.setWordWrap(True)
col.addWidget(sub)
self.list = QListWidget()
self.list.setObjectName("ChatList")
self.list.setUniformItemSizes(False)
self.list.setVerticalScrollMode(QListWidget.ScrollPerPixel)
self.list.itemActivated.connect(self._open_item) # Enter / double-click
self.list.currentItemChanged.connect(lambda *_: self._sync_buttons())
col.addWidget(self.list, 1)
buttons = QHBoxLayout()
buttons.setSpacing(8)
self.refresh_button = QPushButton("Refresh")
self.refresh_button.setObjectName("Ghost")
self.refresh_button.clicked.connect(self.refresh)
buttons.addWidget(self.refresh_button)
buttons.addStretch(1)
self.new_button = QPushButton("New Chat")
self.new_button.setObjectName("Secondary")
self.new_button.clicked.connect(self.new_chat)
buttons.addWidget(self.new_button)
self.open_button = QPushButton("Open")
self.open_button.setObjectName("Primary")
self.open_button.clicked.connect(lambda: self._open_item(self.list.currentItem()))
buttons.addWidget(self.open_button)
col.addLayout(buttons)
for button in (self.refresh_button, self.new_button, self.open_button):
button.setCursor(Qt.PointingHandCursor)
self._busy = False
self._sync_buttons()
# ── loading ──────────────────────────────────────────────────────────
def showEvent(self, event) -> None:
super().showEvent(event)
theme.dark_title_bar(self) # same caption colour as the main window on Windows
self.refresh()
def refresh(self) -> None:
if self._busy:
return
self._set_busy(True, "Loading…")
run_in_pool("list", self._list_chats, self.signals)
def _populate(self, chats: list) -> None:
self._set_busy(False, "")
self.list.clear()
current = None
for chat in chats:
session_id = str(chat.get("session_id") or "")
if not session_id:
continue
is_current = session_id == config.SESSION_ID
item = QListWidgetItem()
item.setData(Qt.UserRole, session_id)
item.setData(Qt.UserRole + 1, str(chat.get("label") or session_id))
item.setToolTip(f"{session_id}\nLast active {local_time(chat.get('last_active', ''))}")
row = ChatRow(chat, is_current)
item.setSizeHint(QSize(0, row.sizeHint().height()))
self.list.addItem(item)
self.list.setItemWidget(item, row)
if is_current:
current = item
if self.list.count() == 0:
self.status.setText("No chats yet")
self.list.setCurrentItem(current or self.list.item(0))
self._sync_buttons()
def _failed(self, operation: str, message: str) -> None:
self._set_busy(False, "")
what = "load chats" if operation == "list" else "start a new chat"
self.status.setText(f"Couldn't {what}")
self.status.setToolTip(message)
self.status.setStyleSheet(f"color: {theme.RED};")
# ── actions ──────────────────────────────────────────────────────────
def new_chat(self) -> None:
if self._busy:
return
self._set_busy(True, "Starting a new chat…")
run_in_pool("create", self._create_chat, self.signals)
def _created(self, session_id: str) -> None:
# A new chat isn't listed until it's used, so there's nothing to show:
# hand it to the window and close.
self._set_busy(False, "")
self.chosen.emit(session_id, "New chat")
self.accept()
def _open_item(self, item: Optional[QListWidgetItem]) -> None:
if item is None or self._busy:
return
self.chosen.emit(item.data(Qt.UserRole), item.data(Qt.UserRole + 1))
self.accept()
def _set_busy(self, busy: bool, status: str) -> None:
self._busy = busy
self.status.setText(status)
self.status.setToolTip("")
self.status.setStyleSheet("")
self._sync_buttons()
def _sync_buttons(self) -> None:
self.refresh_button.setEnabled(not self._busy)
self.new_button.setEnabled(not self._busy)
self.open_button.setEnabled(not self._busy and self.list.currentItem() is not None)
+219
View File
@@ -0,0 +1,219 @@
"""Icons drawn with QPainter rather than shipped as files or borrowed from an
emoji font — crisp at any DPI, recolourable, and no assets to lose.
`paint(painter, name, rect, colour)` draws into a rect; `icon(name, colour)`
wraps that as a QIcon; `IconLabel` is a tiny widget that shows one.
"""
from __future__ import annotations
from PySide6.QtCore import QBuffer, QByteArray, QIODevice, QPointF, QRectF, QSize, Qt
from PySide6.QtGui import QColor, QIcon, QImage, QPainter, QPainterPath, QPen, QPixmap
from PySide6.QtWidgets import QWidget
def _pen(colour: QColor, width: float) -> QPen:
pen = QPen(colour, width)
pen.setCapStyle(Qt.RoundCap)
pen.setJoinStyle(Qt.RoundJoin)
return pen
def paint(p: QPainter, name: str, rect: QRectF, colour) -> None:
colour = QColor(colour)
p.save()
p.setRenderHint(QPainter.Antialiasing)
# Everything below is drawn on a 24x24 grid, then scaled into *rect*.
side = min(rect.width(), rect.height())
p.translate(rect.center().x() - side / 2, rect.center().y() - side / 2)
p.scale(side / 24.0, side / 24.0)
stroke = _pen(colour, 1.9)
if name == "bolt":
path = QPainterPath()
for i, (x, y) in enumerate([(13.5, 2), (4.5, 13.5), (11, 13.5), (9.5, 22),
(19.5, 9.5), (13, 9.5), (13.5, 2)]):
path.moveTo(x, y) if i == 0 else path.lineTo(x, y)
p.setPen(Qt.NoPen)
p.setBrush(colour)
p.drawPath(path)
elif name == "send":
p.setPen(_pen(colour, 2.4))
p.drawLine(QPointF(12, 19), QPointF(12, 5.5))
p.drawLine(QPointF(6.5, 11), QPointF(12, 5.5))
p.drawLine(QPointF(17.5, 11), QPointF(12, 5.5))
elif name == "folder":
p.setPen(stroke)
path = QPainterPath()
path.moveTo(3, 7)
path.lineTo(3, 18.5)
path.quadTo(3, 20, 4.5, 20)
path.lineTo(19.5, 20)
path.quadTo(21, 20, 21, 18.5)
path.lineTo(21, 9.5)
path.quadTo(21, 8, 19.5, 8)
path.lineTo(11.5, 8)
path.lineTo(9.5, 5.5)
path.lineTo(4.5, 5.5)
path.quadTo(3, 5.5, 3, 7)
p.drawPath(path)
elif name == "shell":
p.setPen(stroke)
p.drawRoundedRect(QRectF(2.5, 4, 19, 16), 3, 3)
p.drawLine(QPointF(7, 10), QPointF(10, 12.5))
p.drawLine(QPointF(10, 12.5), QPointF(7, 15))
p.drawLine(QPointF(12.5, 15.5), QPointF(17, 15.5))
elif name in ("read", "write", "edit"):
p.setPen(stroke)
page = QPainterPath()
page.moveTo(14, 2.5)
page.lineTo(6, 2.5)
page.quadTo(4.5, 2.5, 4.5, 4)
page.lineTo(4.5, 20)
page.quadTo(4.5, 21.5, 6, 21.5)
page.lineTo(18, 21.5)
page.quadTo(19.5, 21.5, 19.5, 20)
page.lineTo(19.5, 8)
page.closeSubpath()
p.drawPath(page)
p.drawLine(QPointF(14, 2.5), QPointF(14, 8))
p.drawLine(QPointF(14, 8), QPointF(19.5, 8))
if name == "read":
for y in (12, 15, 18):
p.drawLine(QPointF(8, y), QPointF(16, y))
elif name == "write":
p.drawLine(QPointF(12, 11.5), QPointF(12, 18))
p.drawLine(QPointF(8.75, 14.75), QPointF(15.25, 14.75))
else:
p.drawLine(QPointF(8, 18), QPointF(15.5, 10.5))
p.drawLine(QPointF(8, 18), QPointF(8, 15.5))
p.drawLine(QPointF(8, 18), QPointF(10.5, 18))
elif name == "list":
p.setPen(stroke)
for y in (6.5, 12, 17.5):
p.drawPoint(QPointF(4.5, y))
p.drawLine(QPointF(8.5, y), QPointF(20, y))
elif name == "blocked":
p.setPen(stroke)
p.drawEllipse(QRectF(3.5, 3.5, 17, 17))
p.drawLine(QPointF(7, 17), QPointF(17, 7))
elif name == "chevron":
p.setPen(stroke)
p.drawLine(QPointF(9, 6), QPointF(15, 12))
p.drawLine(QPointF(15, 12), QPointF(9, 18))
elif name == "chats":
p.setPen(stroke)
bubble = QPainterPath()
bubble.addRoundedRect(QRectF(3, 4, 18, 13), 4, 4)
p.drawPath(bubble)
p.drawLine(QPointF(8, 17), QPointF(7, 21))
p.drawLine(QPointF(7, 21), QPointF(12, 17))
for x in (8, 12, 16):
p.drawPoint(QPointF(x, 10.5))
elif name == "mic":
p.setPen(stroke)
p.drawRoundedRect(QRectF(9, 3, 6, 11.5), 3, 3)
arc = QPainterPath()
arc.moveTo(5.5, 11)
arc.cubicTo(5.5, 19, 18.5, 19, 18.5, 11)
p.drawPath(arc)
p.drawLine(QPointF(12, 17), QPointF(12, 21))
elif name == "stop":
p.setPen(Qt.NoPen)
p.setBrush(colour)
p.drawRoundedRect(QRectF(7, 7, 10, 10), 2, 2)
elif name == "plus":
p.setPen(_pen(colour, 2.1))
p.drawLine(QPointF(12, 5), QPointF(12, 19))
p.drawLine(QPointF(5, 12), QPointF(19, 12))
elif name == "file":
paint(p, "read", QRectF(0, 0, 24, 24), colour)
p.restore()
def icon(name: str, colour, size: int = 32) -> QIcon:
pixmap = QPixmap(size, size)
pixmap.fill(Qt.transparent)
painter = QPainter(pixmap)
paint(painter, name, QRectF(0, 0, size, size), colour)
painter.end()
return QIcon(pixmap)
APP_ICON_SIZES = (16, 24, 32, 48, 64, 128, 256)
def app_icon_image(size: int) -> QImage:
"""An amber rounded square with a dark bolt, at *size* pixels. The one
drawing behind both the window icon and the .exe's icon (write_ico)."""
image = QImage(size, size, QImage.Format_ARGB32_Premultiplied)
image.fill(Qt.transparent)
p = QPainter(image)
p.setRenderHint(QPainter.Antialiasing)
p.setPen(Qt.NoPen)
p.setBrush(QColor("#f5b83d"))
p.drawRoundedRect(QRectF(0, 0, size, size), size * 0.26, size * 0.26)
inset = size * 0.18
paint(p, "bolt", QRectF(inset, inset, size - 2 * inset, size - 2 * inset), "#1a1305")
p.end()
return image
def app_icon() -> QIcon:
"""The window/taskbar icon."""
result = QIcon()
for size in APP_ICON_SIZES:
result.addPixmap(QPixmap.fromImage(app_icon_image(size)))
return result
def write_ico(path) -> None:
"""Save the app icon as a multi-size Windows .ico (PNG-compressed entries,
which Windows Vista+ reads at every size), for the frozen .exe."""
import struct
blobs = []
for size in APP_ICON_SIZES:
data = QByteArray()
buffer = QBuffer(data)
buffer.open(QIODevice.WriteOnly)
app_icon_image(size).save(buffer, "PNG")
buffer.close()
blobs.append((size, bytes(data)))
header = struct.pack("<HHH", 0, 1, len(blobs)) # reserved, type 1 = icon, count
offset = len(header) + 16 * len(blobs)
entries, payload = b"", b""
for size, blob in blobs:
edge = 0 if size >= 256 else size # 0 means 256 in the ICO format
entries += struct.pack("<BBBBHHII", edge, edge, 0, 0, 1, 32, len(blob), offset + len(payload))
payload += blob
with open(path, "wb") as handle:
handle.write(header + entries + payload)
class IconLabel(QWidget):
"""A fixed-size painted icon, optionally on a rounded tile."""
def __init__(self, name: str, colour, size: int = 18, tile=None, parent=None):
super().__init__(parent)
self._name, self._colour, self._tile = name, QColor(colour), tile
self.setFixedSize(QSize(size, size))
def set_icon(self, name: str, colour=None) -> None:
self._name = name
if colour is not None:
self._colour = QColor(colour)
self.update()
def paintEvent(self, _event) -> None:
p = QPainter(self)
p.setRenderHint(QPainter.Antialiasing)
rect = QRectF(self.rect())
if self._tile is not None:
p.setPen(Qt.NoPen)
p.setBrush(QColor(self._tile))
p.drawRoundedRect(rect, rect.width() * 0.28, rect.width() * 0.28)
pad = rect.width() * 0.22
rect = rect.adjusted(pad, pad, -pad, -pad)
paint(p, self._name, rect, self._colour)
+196
View File
@@ -0,0 +1,196 @@
"""Colours, fonts and the application stylesheet.
One dark theme, tuned by hand: near-black charcoal surfaces a step apart, a
single warm accent (the bolt's amber), and colour used only where it carries
meaning — green for done, red for failed, amber for "needs you".
"""
from __future__ import annotations
import sys
from PySide6.QtGui import QColor, QFont, QFontDatabase, QPalette
BG = "#0e1014" # window
SURFACE = "#15181e" # cards, composer
SURFACE_HI = "#1b1f27" # hover / user bubble
BORDER = "#252a34"
BORDER_HI = "#343b48"
TEXT = "#e7e9ee"
TEXT_DIM = "#9aa1ae"
TEXT_FAINT = "#6b7280"
ACCENT = "#f5b83d" # the bolt
ACCENT_HI = "#ffc95c"
ACCENT_INK = "#1a1305" # text on accent
GREEN = "#4ec98a"
RED = "#f06a6a"
BLUE = "#7aa7ff"
CODE = "#e8c98f" # inline code in replies
DIFF_ADD_BG = "#12291d"
DIFF_DEL_BG = "#301719"
RADIUS = 12
COLUMN_WIDTH = 820 # the transcript's reading column
def _first_available(families, fallback: str) -> str:
installed = set(QFontDatabase.families())
for family in families:
if family in installed:
return family
return fallback
def ui_font_family() -> str:
return _first_available(
["Segoe UI Variable Text", "Inter", "SF Pro Text", "Segoe UI", "Cantarell", "Ubuntu"],
QFont().defaultFamily(),
)
def mono_font_family() -> str:
return _first_available(
["Cascadia Code", "Cascadia Mono", "JetBrains Mono", "SF Mono", "Consolas",
"DejaVu Sans Mono", "Menlo"],
"monospace",
)
def mono_font(size: float = 9.5) -> QFont:
font = QFont(mono_font_family())
font.setPointSizeF(size)
font.setStyleHint(QFont.Monospace)
return font
def palette() -> QPalette:
pal = QPalette()
for role, colour in (
(QPalette.Window, BG), (QPalette.Base, SURFACE), (QPalette.AlternateBase, SURFACE_HI),
(QPalette.Text, TEXT), (QPalette.WindowText, TEXT), (QPalette.ButtonText, TEXT),
(QPalette.Button, SURFACE), (QPalette.Highlight, "#3a3222"),
(QPalette.HighlightedText, TEXT), (QPalette.Link, BLUE),
(QPalette.PlaceholderText, TEXT_FAINT), (QPalette.ToolTipBase, SURFACE_HI),
(QPalette.ToolTipText, TEXT),
):
pal.setColor(role, QColor(colour))
return pal
def stylesheet() -> str:
mono = mono_font_family()
return f"""
QWidget {{ color: {TEXT}; font-size: 10.5pt; }}
QMainWindow, #Root {{ background: {BG}; }}
QToolTip {{ background: {SURFACE_HI}; color: {TEXT}; border: 1px solid {BORDER_HI};
border-radius: 6px; padding: 5px 8px; }}
/* header */
#Header {{ background: {BG}; border-bottom: 1px solid {BORDER}; }}
#Wordmark {{ font-size: 12pt; font-weight: 600; letter-spacing: 0.3px; }}
#WordmarkSub {{ color: {TEXT_FAINT}; font-size: 9pt; }}
#Chip {{ background: {SURFACE}; border: 1px solid {BORDER}; border-radius: 15px;
padding: 5px 12px 5px 30px; color: {TEXT_DIM}; text-align: left; }}
#Chip:hover {{ border-color: {BORDER_HI}; color: {TEXT}; background: {SURFACE_HI}; }}
#Chip:disabled {{ color: {TEXT_FAINT}; }}
#Segment {{ background: {SURFACE}; border: 1px solid {BORDER}; border-radius: 15px; }}
#SegmentButton {{ background: transparent; border: none; border-radius: 12px;
padding: 4px 14px; color: {TEXT_DIM}; font-weight: 500; }}
#SegmentButton:hover {{ color: {TEXT}; }}
#SegmentButton:checked {{ background: {SURFACE_HI}; color: {TEXT}; }}
#SegmentButton[danger="true"]:checked {{ background: #3a2c12; color: {ACCENT_HI}; }}
/* transcript */
QScrollArea {{ background: transparent; border: none; }}
#Transcript {{ background: {BG}; }}
QScrollBar:vertical {{ background: transparent; width: 10px; margin: 4px 2px; }}
QScrollBar::handle:vertical {{ background: {BORDER}; border-radius: 3px; min-height: 40px; }}
QScrollBar::handle:vertical:hover {{ background: {BORDER_HI}; }}
QScrollBar::add-line, QScrollBar::sub-line, QScrollBar::add-page, QScrollBar::sub-page {{
background: none; height: 0; }}
QScrollBar:horizontal {{ height: 0; }}
#UserBubble {{ background: {SURFACE_HI}; border: 1px solid {BORDER}; border-radius: 16px; }}
#AssistantText {{ color: {TEXT}; }}
#Holding {{ color: {TEXT_FAINT}; font-style: italic; }}
#Notice {{ color: {TEXT_DIM}; font-size: 9.5pt; }}
#NoticeError {{ color: {RED}; }}
#ToolCard {{ background: {SURFACE}; border: 1px solid {BORDER}; border-radius: {RADIUS}px; }}
#ToolCard[state="waiting"] {{ border-color: #5a4418; }}
#ToolHeader {{ background: transparent; border: none; border-radius: {RADIUS}px; }}
#ToolHeader:hover {{ background: {SURFACE_HI}; }}
#ToolName {{ font-weight: 600; }}
#ToolArg {{ color: {TEXT_DIM}; font-family: "{mono}"; font-size: 9.5pt; }}
#ToolOutput {{ background: transparent; color: {TEXT_DIM}; border: none; border-top: 1px solid {BORDER};
font-family: "{mono}"; font-size: 9.2pt; padding: 10px 14px; }}
#Pill {{ border-radius: 9px; padding: 1px 9px; font-size: 8.5pt; font-weight: 600; }}
#Pill[kind="running"] {{ background: #1c2433; color: {BLUE}; }}
#Pill[kind="ok"] {{ background: #13271d; color: {GREEN}; }}
#Pill[kind="error"] {{ background: #2e1618; color: {RED}; }}
#Pill[kind="waiting"] {{ background: #33280f; color: {ACCENT}; }}
#Pill[kind="declined"] {{ background: #22252c; color: {TEXT_DIM}; }}
#ApprovalBar {{ border-top: 1px solid {BORDER}; background: transparent; }}
#ApprovalQuestion {{ color: {TEXT}; }}
#Primary {{ background: {ACCENT}; color: {ACCENT_INK}; border: none; border-radius: 8px;
padding: 6px 16px; font-weight: 600; }}
#Primary:hover {{ background: {ACCENT_HI}; }}
#Secondary {{ background: {SURFACE_HI}; color: {TEXT}; border: 1px solid {BORDER_HI};
border-radius: 8px; padding: 6px 14px; }}
#Secondary:hover {{ border-color: {TEXT_FAINT}; }}
#Ghost {{ background: transparent; color: {TEXT_DIM}; border: none; padding: 6px 10px; }}
#Ghost:hover {{ color: {TEXT}; }}
/* empty state */
#Hero {{ font-size: 19pt; font-weight: 600; }}
#HeroSub {{ color: {TEXT_DIM}; }}
#Suggestion {{ background: {SURFACE}; border: 1px solid {BORDER}; border-radius: 12px;
padding: 12px 14px; color: {TEXT_DIM}; text-align: left; }}
#Suggestion:hover {{ border-color: {BORDER_HI}; color: {TEXT}; background: {SURFACE_HI}; }}
/* composer */
#Composer {{ background: {SURFACE}; border: 1px solid {BORDER}; border-radius: 18px; }}
#Composer[focused="true"] {{ border-color: {BORDER_HI}; }}
#ComposerEdit {{ background: transparent; border: none; padding: 0; selection-background-color: #3a3222; }}
#Hint {{ color: {TEXT_FAINT}; font-size: 8.8pt; }}
/* chats dialog */
QDialog#ChatDialog {{ background: {BG}; }}
#ChatList {{ background: {SURFACE}; border: 1px solid {BORDER}; border-radius: {RADIUS}px;
padding: 4px; outline: none; }}
#ChatList::item {{ border-radius: 9px; margin: 1px 0; }}
#ChatList::item:hover {{ background: {SURFACE_HI}; }}
#ChatList::item:selected {{ background: #262017; border: 1px solid #4a3a18; }}
#ChatTitle {{ font-weight: 600; }}
#ChatTime {{ color: {TEXT_FAINT}; font-size: 9pt; }}
#ChatPreview {{ color: {TEXT_DIM}; font-size: 9.5pt; }}
"""
def dark_title_bar(window) -> None:
"""Windows 11: dark caption, painted the same colour as the window so the
title bar and the app read as one surface. Silently does nothing on other
platforms or older Windows."""
if sys.platform != "win32":
return
try:
import ctypes
from ctypes import wintypes
hwnd = wintypes.HWND(int(window.winId()))
dwm = ctypes.windll.dwmapi
def attr(key: int, value: int) -> None:
data = ctypes.c_int(value)
dwm.DwmSetWindowAttribute(hwnd, key, ctypes.byref(data), ctypes.sizeof(data))
attr(20, 1) # DWMWA_USE_IMMERSIVE_DARK_MODE
colour = BG.lstrip("#")
r, g, b = int(colour[0:2], 16), int(colour[2:4], 16), int(colour[4:6], 16)
attr(35, (b << 16) | (g << 8) | r) # DWMWA_CAPTION_COLOR (COLORREF is 0x00BBGGRR)
border = BORDER.lstrip("#")
r, g, b = int(border[0:2], 16), int(border[2:4], 16), int(border[4:6], 16)
attr(34, (b << 16) | (g << 8) | r) # DWMWA_BORDER_COLOR
except Exception:
pass
+699
View File
@@ -0,0 +1,699 @@
"""The pieces the window is built from: messages, tool cards, the composer,
the header controls and the empty state. Styling lives in theme.stylesheet();
these set object names and properties for it to hook onto."""
from __future__ import annotations
import html
import math
import re
from PySide6.QtCore import QEvent, QRectF, QSize, Qt, QTimer, Signal
from PySide6.QtGui import QColor, QFontDatabase, QPainter, QTextCursor, QTextDocument, QTextOption
from PySide6.QtWidgets import (
QButtonGroup, QFrame, QHBoxLayout, QLabel, QPlainTextEdit, QPushButton,
QSizePolicy, QVBoxLayout, QWidget,
)
from . import icons, theme
# Tool name (as tools.py reports it) -> icon.
TOOL_ICONS = {
"Shell": "shell", "Read": "read", "List": "list", "Write": "write",
"Edit": "edit", "Pet": "blocked", "File": "file",
}
COLLAPSED_LINES = 6
def _label(text: str = "", name: str = "", wrap: bool = True, selectable: bool = False) -> QLabel:
label = QLabel(text)
if name:
label.setObjectName(name)
label.setWordWrap(wrap)
if selectable:
label.setTextInteractionFlags(Qt.TextSelectableByMouse | Qt.LinksAccessibleByMouse)
label.setCursor(Qt.IBeamCursor)
return label
def repolish(widget: QWidget) -> None:
"""Re-apply the stylesheet after a dynamic property changed."""
widget.style().unpolish(widget)
widget.style().polish(widget)
widget.update()
def markdown_html(text: str, font) -> str:
"""Markdown -> the HTML QLabel renders, with code in the app's mono font
and a warm tint instead of Qt's generic 'monospace' (usually Courier)."""
doc = QTextDocument()
doc.setDefaultFont(font)
doc.setMarkdown(text)
out = doc.toHtml()
code_style = f"font-family:'{theme.mono_font_family()}'; color:{theme.CODE};"
# Qt writes the platform's fixed font for code: 'Courier New' on Windows,
# 'monospace' elsewhere.
for fixed in {QFontDatabase.systemFont(QFontDatabase.FixedFont).family(), "monospace", "Courier New"}:
out = out.replace(f"font-family:'{fixed}';", code_style)
return out.replace("font-family:'Sans Serif';", "")
# ── transcript entries ──────────────────────────────────────────────────────
class UserMessage(QWidget):
def __init__(self, text: str, parent=None):
super().__init__(parent)
row = QHBoxLayout(self)
row.setContentsMargins(80, 6, 0, 6)
row.addStretch(1)
bubble = QFrame()
bubble.setObjectName("UserBubble")
inner = QVBoxLayout(bubble)
inner.setContentsMargins(16, 10, 16, 10)
label = _label(text, selectable=True)
label.setTextFormat(Qt.PlainText)
# A word-wrapped QLabel's size hint is a guess that wraps short text
# early; give it the width its longest line actually needs.
widest = max(label.fontMetrics().horizontalAdvance(line) for line in text.splitlines() or [""])
label.setMinimumWidth(min(widest + 4, 588))
inner.addWidget(label)
bubble.setMaximumWidth(620)
bubble.setSizePolicy(QSizePolicy.Maximum, QSizePolicy.Preferred)
row.addWidget(bubble)
class AssistantMessage(QWidget):
"""Bolt's reply. Streamed sentences are appended; a sentence that starts a
list item or heading goes on its own line so the markdown still renders."""
_BLOCK_START = re.compile(r"^(?:[-*+] |\d+[.)] |#{1,6} |```|>)")
def __init__(self, text: str = "", parent=None):
super().__init__(parent)
self._text = ""
row = QHBoxLayout(self)
row.setContentsMargins(0, 8, 0, 8)
row.setSpacing(14)
avatar = icons.IconLabel("bolt", theme.ACCENT_INK, 26, tile=theme.ACCENT)
row.addWidget(avatar, 0, Qt.AlignTop)
self.label = _label(name="AssistantText", selectable=True)
self.label.setTextFormat(Qt.RichText)
self.label.setOpenExternalLinks(True)
self.label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred)
row.addWidget(self.label, 1)
if text:
self.set_text(text)
def set_text(self, text: str) -> None:
self._text = text
self.label.setText(markdown_html(text, self.label.font()))
def append(self, sentence: str) -> None:
if not self._text:
joiner = ""
elif self._BLOCK_START.match(sentence) or self._text.endswith("\n"):
joiner = "\n\n" if not self._text.endswith("\n") else ""
else:
joiner = " "
self.set_text(self._text + joiner + sentence)
def text(self) -> str:
return self._text
class HoldingLine(QWidget):
def __init__(self, text: str, parent=None):
super().__init__(parent)
row = QHBoxLayout(self)
row.setContentsMargins(40, 0, 0, 2)
row.addWidget(_label(text, "Holding"))
class Notice(QWidget):
def __init__(self, level: str, text: str, parent=None):
super().__init__(parent)
row = QHBoxLayout(self)
row.setContentsMargins(40, 4, 0, 4)
label = _label(text, "NoticeError" if level == "error" else "Notice", selectable=True)
label.setTextFormat(Qt.PlainText)
if level == "warn":
label.setStyleSheet(f"color: {theme.ACCENT};")
row.addWidget(label, 1)
class Pill(QLabel):
TEXT = {"running": "Running", "ok": "Done", "error": "Failed",
"waiting": "Needs approval", "declined": "Declined"}
def __init__(self, kind: str = "running", parent=None):
super().__init__(parent)
self.setObjectName("Pill")
self.set_kind(kind)
def set_kind(self, kind: str) -> None:
self.setProperty("kind", kind)
self.setText(self.TEXT.get(kind, kind))
repolish(self)
class _ClickableFrame(QFrame):
clicked = Signal()
def mouseReleaseEvent(self, event) -> None:
if event.button() == Qt.LeftButton and self.rect().contains(event.position().toPoint()):
self.clicked.emit()
super().mouseReleaseEvent(event)
class ToolCard(QFrame):
"""One relayed command: header (icon, name, argument, status), optional
diff, output that expands on click, and an inline approval bar."""
answered = Signal(str) # yes / no / always
def __init__(self, name: str, argument: str, parent=None):
super().__init__(parent)
self.setObjectName("ToolCard")
self._output = ""
self._expanded = False
self._error = False
outer = QVBoxLayout(self)
outer.setContentsMargins(0, 0, 0, 0)
outer.setSpacing(0)
self.header = _ClickableFrame()
self.header.setObjectName("ToolHeader")
self.header.setCursor(Qt.PointingHandCursor)
head = QHBoxLayout(self.header)
head.setContentsMargins(12, 9, 12, 9)
head.setSpacing(10)
self.icon = icons.IconLabel(TOOL_ICONS.get(name, "shell"), theme.TEXT_DIM, 16)
head.addWidget(self.icon)
title = _label(name, "ToolName", wrap=False)
head.addWidget(title)
shown = argument if len(argument) <= 110 else argument[:107] + "..."
arg = _label(shown, "ToolArg", wrap=False)
arg.setTextFormat(Qt.PlainText)
arg.setToolTip(argument)
arg.setSizePolicy(QSizePolicy.Ignored, QSizePolicy.Preferred)
head.addWidget(arg, 1)
self.pill = Pill("running")
head.addWidget(self.pill)
self.chevron = icons.IconLabel("chevron", theme.TEXT_FAINT, 14)
self.chevron.hide()
head.addWidget(self.chevron)
self.header.clicked.connect(self.toggle)
outer.addWidget(self.header)
self.diff = _label(name="ToolOutput", wrap=True, selectable=True)
self.diff.setTextFormat(Qt.RichText)
self.diff.hide()
outer.addWidget(self.diff)
self.output = _label(name="ToolOutput", wrap=True, selectable=True)
self.output.setTextFormat(Qt.PlainText)
self.output.hide()
outer.addWidget(self.output)
self.approval = QFrame()
self.approval.setObjectName("ApprovalBar")
bar = QHBoxLayout(self.approval)
bar.setContentsMargins(14, 10, 10, 10)
bar.setSpacing(8)
self.question = _label("", "ApprovalQuestion", wrap=False)
bar.addWidget(self.question, 1)
for text, value, style in (("Deny", "no", "Ghost"), ("Always allow", "always", "Secondary"),
("Allow", "yes", "Primary")):
button = QPushButton(text)
button.setObjectName(style)
button.setCursor(Qt.PointingHandCursor)
button.clicked.connect(lambda _=False, v=value: self._answer(v))
bar.addWidget(button)
if value == "yes":
self.allow_button = button
self.approval.hide()
outer.addWidget(self.approval)
# ── state ────────────────────────────────────────────────────────────
def set_result(self, text: str, error: bool) -> None:
if self.pill.property("kind") == "declined":
return
self._output, self._error = text.rstrip(), error
self.pill.set_kind("error" if error else "ok")
self.icon.set_icon(self.icon._name, theme.RED if error else theme.TEXT_DIM)
self._render_output()
def set_diff(self, old: str, new: str, limit: int = 40) -> None:
rows = []
for sign, text, fg, bg in (("-", old, theme.RED, theme.DIFF_DEL_BG),
("+", new, theme.GREEN, theme.DIFF_ADD_BG)):
lines = text.splitlines() or [""]
for line in lines[:limit]:
rows.append(
f'<tr><td style="background:{bg}; color:{fg}; padding:1px 8px; '
f'white-space:pre;">{sign} {html.escape(line) or "&nbsp;"}</td></tr>')
if len(lines) > limit:
rows.append(f'<tr><td style="color:{theme.TEXT_FAINT}; padding:1px 8px;">'
f' … {len(lines) - limit} more lines</td></tr>')
self.diff.setText('<table width="100%" cellspacing="0" cellpadding="0">' + "".join(rows) + "</table>")
self.diff.show()
def ask(self, question: str) -> None:
self.question.setText(question)
self.pill.set_kind("waiting")
self.setProperty("state", "waiting")
repolish(self)
self.approval.show()
self.allow_button.setFocus()
def _answer(self, value: str) -> None:
self.approval.hide()
self.setProperty("state", "")
repolish(self)
if value == "no":
self.pill.set_kind("declined")
else:
self.pill.set_kind("running")
self.answered.emit(value)
def toggle(self) -> None:
if self._output:
self._expanded = not self._expanded
self._render_output()
def _render_output(self) -> None:
lines = align_columns(self._output.splitlines())
if not lines:
self.output.hide()
self.chevron.hide()
return
long = len(lines) > COLLAPSED_LINES
shown = lines if (self._expanded or not long) else lines[:COLLAPSED_LINES]
text = "\n".join(shown)
if long and not self._expanded:
text += f"\n… {len(lines) - COLLAPSED_LINES} more lines — click to expand"
self.output.setText(text)
self.output.setStyleSheet(f"color: {theme.RED};" if self._error else "")
self.output.show()
self.chevron.setVisible(long)
def align_columns(lines):
"""Tab-separated output (filectl list's "name<TAB>size") into padded
columns — a QLabel's tab stops don't line up with anything."""
rows = [line.split(" ") for line in lines]
if not any(len(r) > 1 for r in rows):
return lines
width = max(len(r[0]) for r in rows if len(r) > 1)
return [r[0].ljust(width + 2) + " ".join(r[1:]) if len(r) > 1 else r[0] for r in rows]
class ThinkingIndicator(QWidget):
"""Three dots breathing in sequence, next to a quiet 'Thinking'."""
def __init__(self, parent=None):
super().__init__(parent)
self.setFixedHeight(34)
self._phase = 0.0
self._timer = QTimer(self)
self._timer.setInterval(40)
self._timer.timeout.connect(self._tick)
self.label = "Thinking"
def start(self) -> None:
self._phase = 0.0
self.show()
self._timer.start()
def stop(self) -> None:
self._timer.stop()
self.hide()
def _tick(self) -> None:
self._phase += 0.12
self.update()
def paintEvent(self, _event) -> None:
p = QPainter(self)
p.setRenderHint(QPainter.Antialiasing)
p.setPen(Qt.NoPen)
x, y = 50.0, self.height() / 2
for i in range(3):
level = (math.sin(self._phase - i * 0.9) + 1) / 2
colour = QColor(theme.ACCENT)
colour.setAlphaF(0.25 + 0.75 * level)
p.setBrush(colour)
r = 2.6 + 1.0 * level
p.drawEllipse(QRectF(x + i * 12 - r, y - r, 2 * r, 2 * r))
p.setPen(QColor(theme.TEXT_FAINT))
p.drawText(QRectF(x + 34, 0, 300, self.height()), Qt.AlignVCenter | Qt.AlignLeft, self.label)
# ── composer ────────────────────────────────────────────────────────────────
class _SendButton(QPushButton):
def __init__(self, parent=None):
super().__init__(parent)
self.setFixedSize(34, 34)
self.setCursor(Qt.PointingHandCursor)
self.setToolTip("Send (Enter)")
self.setFlat(True)
def paintEvent(self, _event) -> None:
p = QPainter(self)
p.setRenderHint(QPainter.Antialiasing)
p.setPen(Qt.NoPen)
enabled = self.isEnabled()
fill = QColor(theme.ACCENT_HI if self.underMouse() and enabled else theme.ACCENT)
if not enabled:
fill = QColor(theme.BORDER_HI)
p.setBrush(fill)
p.drawEllipse(QRectF(self.rect()).adjusted(1, 1, -1, -1))
icons.paint(p, "send", QRectF(self.rect()).adjusted(8, 8, -8, -8),
theme.ACCENT_INK if enabled else theme.TEXT_FAINT)
class MicButton(QPushButton):
"""Dictation toggle. idle: a quiet mic outline. recording: red, with a
ring that swells with your voice, and a stop square. finishing: an amber
arc while the last words come back. unavailable: faint, and the tooltip
says what's missing."""
TIPS = {
"idle": "Dictate (Ctrl+M)\nClick, talk, click again (or pause) to stop",
"recording": "Listening. Click or pause to stop, Esc to cancel",
"finishing": "Transcribing...",
}
def __init__(self, parent=None):
super().__init__(parent)
self.setFixedSize(34, 34)
self.setCursor(Qt.PointingHandCursor)
self.setFlat(True)
self.state = "idle"
self._level = 0.0
self._phase = 0.0
self._timer = QTimer(self)
self._timer.setInterval(33)
self._timer.timeout.connect(self._tick)
self.set_state("idle")
def set_state(self, state: str, reason: str = "") -> None:
self.state = state
self.setToolTip(reason if state == "unavailable" else self.TIPS.get(state, ""))
if state in ("recording", "finishing"):
self._timer.start()
else:
self._timer.stop()
self._level = 0.0
self.update()
def set_level(self, level: float) -> None:
# Fast attack, slow release, so the ring breathes instead of flickering.
self._level = level if level > self._level else self._level * 0.85 + level * 0.15
def _tick(self) -> None:
self._phase += 0.2
self._level *= 0.97
self.update()
def paintEvent(self, _event) -> None:
p = QPainter(self)
p.setRenderHint(QPainter.Antialiasing)
rect = QRectF(self.rect()).adjusted(1, 1, -1, -1)
centre = rect.center()
if self.state == "recording":
ring = QColor(theme.RED)
ring.setAlphaF(0.22 + 0.3 * self._level)
p.setPen(Qt.NoPen)
p.setBrush(ring)
p.drawEllipse(centre, 16.0, 16.0)
p.setBrush(QColor(theme.RED))
radius = 10.5 + 3.5 * self._level
p.drawEllipse(centre, radius, radius)
icons.paint(p, "stop", QRectF(centre.x() - 8, centre.y() - 8, 16, 16), "#ffffff")
return
if self.state == "finishing":
pen = QColor(theme.ACCENT)
p.setPen(icons._pen(pen, 2.2))
p.drawArc(rect.adjusted(4, 4, -4, -4), int(-self._phase * 16 * 57), 100 * 16)
icons.paint(p, "mic", rect.adjusted(9, 9, -9, -9), theme.TEXT_DIM)
return
if self.state == "idle" and self.underMouse():
p.setPen(Qt.NoPen)
p.setBrush(QColor(theme.SURFACE_HI))
p.drawEllipse(rect)
colour = theme.TEXT_FAINT if self.state == "unavailable" else (
theme.TEXT if self.underMouse() else theme.TEXT_DIM)
icons.paint(p, "mic", rect.adjusted(8, 8, -8, -8), colour)
class _ComposerEdit(QPlainTextEdit):
submitted = Signal()
def keyPressEvent(self, event) -> None:
if event.key() in (Qt.Key_Return, Qt.Key_Enter) and not (event.modifiers() & Qt.ShiftModifier):
self.submitted.emit()
return
super().keyPressEvent(event)
class Composer(QFrame):
"""Rounded input that grows with its text. Enter sends, Shift+Enter
starts a new line. While a turn runs you can keep typing; sending waits."""
submitted = Signal(str)
MAX_LINES = 9
def __init__(self, parent=None):
super().__init__(parent)
self.setObjectName("Composer")
row = QHBoxLayout(self)
row.setContentsMargins(18, 10, 10, 10)
row.setSpacing(10)
self.edit = _ComposerEdit()
self.edit.setObjectName("ComposerEdit")
self.edit.setPlaceholderText("Ask Bolt to read, change or run something…")
self.edit.setWordWrapMode(QTextOption.WrapAtWordBoundaryOrAnywhere)
self.edit.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded)
self.edit.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
self.edit.document().setDocumentMargin(2)
self.edit.textChanged.connect(self._resize)
self.edit.submitted.connect(self._submit)
self.edit.installEventFilter(self)
row.addWidget(self.edit, 1, Qt.AlignVCenter)
self.mic = MicButton()
row.addWidget(self.mic, 0, Qt.AlignBottom)
self._dictation_prefix = None # composer text from before a recording
self.send = _SendButton()
self.send.clicked.connect(self._submit)
row.addWidget(self.send, 0, Qt.AlignBottom)
self._busy = False
self._resize()
self._sync_button()
self.edit.textChanged.connect(self._sync_button)
def eventFilter(self, obj, event) -> bool:
if obj is self.edit and event.type() in (QEvent.FocusIn, QEvent.FocusOut):
self.setProperty("focused", event.type() == QEvent.FocusIn)
repolish(self)
return False
def set_busy(self, busy: bool) -> None:
self._busy = busy
self._sync_button()
def set_text(self, text: str) -> None:
self.edit.setPlainText(text)
cursor = self.edit.textCursor()
cursor.movePosition(QTextCursor.MoveOperation.End)
self.edit.setTextCursor(cursor)
self.edit.setFocus()
# ── dictation: the words appear in the box as you speak ──────────────
def begin_dictation(self) -> None:
self._dictation_prefix = self.edit.toPlainText().rstrip()
self.edit.setReadOnly(True) # typing into text that's being rewritten would be lost
self._sync_button()
def show_dictation(self, final: str, interim: str = "") -> None:
if self._dictation_prefix is None:
return
spoken = " ".join(part for part in (final, interim) if part)
self._show(" ".join(part for part in (self._dictation_prefix, spoken) if part))
def end_dictation(self, text: str) -> None:
if self._dictation_prefix is None:
return
self._show(" ".join(part for part in (self._dictation_prefix, text.strip()) if part))
self._dictation_prefix = None
self.edit.setReadOnly(False)
self.edit.setFocus()
self._sync_button()
@property
def dictating(self) -> bool:
return self._dictation_prefix is not None
def _show(self, text: str) -> None:
self.edit.setPlainText(text)
cursor = self.edit.textCursor()
cursor.movePosition(QTextCursor.MoveOperation.End)
self.edit.setTextCursor(cursor)
def _sync_button(self) -> None:
ready = not self._busy and not self.dictating and bool(self.edit.toPlainText().strip())
self.send.setEnabled(ready)
self.send.update()
def _resize(self) -> None:
line = self.edit.fontMetrics().lineSpacing()
lines = max(1, min(self.MAX_LINES, int(self.edit.document().size().height())))
self.edit.setFixedHeight(int(lines * line + 8))
def _submit(self) -> None:
text = self.edit.toPlainText().strip()
if not text or self._busy or self.dictating:
return
self.edit.clear()
self.submitted.emit(text)
# ── header ──────────────────────────────────────────────────────────────────
class StatusDot(QWidget):
COLOURS = {"unknown": theme.TEXT_FAINT, "ok": theme.GREEN, "error": theme.RED}
def __init__(self, parent=None):
super().__init__(parent)
self.setFixedSize(QSize(10, 10))
self._state = "unknown"
def set_state(self, state: str, tooltip: str) -> None:
self._state = state
self.setToolTip(tooltip)
self.update()
def paintEvent(self, _event) -> None:
p = QPainter(self)
p.setRenderHint(QPainter.Antialiasing)
p.setPen(Qt.NoPen)
p.setBrush(QColor(self.COLOURS.get(self._state, theme.TEXT_FAINT)))
p.drawEllipse(QRectF(1, 1, 8, 8))
class FolderChip(QPushButton):
"""A pill button with a painted icon on the left (the folder by default)."""
def __init__(self, parent=None, icon_name: str = "folder", text: str = ""):
super().__init__(text, parent)
self._icon_name = icon_name
self.setObjectName("Chip")
self.setCursor(Qt.PointingHandCursor)
self.setFixedHeight(30)
def set_path(self, path: str) -> None:
name = path.rstrip("\\/").replace("\\", "/").split("/")[-1] or path
self.setText(name)
self.setToolTip(f"Working directory: {path}\nClick to change")
def paintEvent(self, event) -> None:
super().paintEvent(event)
p = QPainter(self)
paint_colour = theme.TEXT if self.underMouse() else theme.TEXT_DIM
if not self.isEnabled():
paint_colour = theme.TEXT_FAINT
icons.paint(p, self._icon_name, QRectF(10, (self.height() - 15) / 2, 15, 15), paint_colour)
class ModeSwitch(QFrame):
"""Segmented control: ask before changes, or run everything."""
changed = Signal(str)
def __init__(self, mode: str, parent=None):
super().__init__(parent)
self.setObjectName("Segment")
self.setFixedHeight(30)
row = QHBoxLayout(self)
row.setContentsMargins(2, 2, 2, 2)
row.setSpacing(2)
self._group = QButtonGroup(self)
self.buttons = {}
for value, text, tip in (
("ask", "Ask first", "Confirm shell commands, writes and edits"),
("auto", "Auto", "Run everything Bolt asks for without confirming"),
):
button = QPushButton(text)
button.setObjectName("SegmentButton")
button.setCheckable(True)
button.setCursor(Qt.PointingHandCursor)
button.setToolTip(tip)
if value == "auto":
button.setProperty("danger", True)
self._group.addButton(button)
row.addWidget(button)
self.buttons[value] = button
button.clicked.connect(lambda _=False, v=value: self.changed.emit(v))
self.set_mode(mode)
def set_mode(self, mode: str) -> None:
self.buttons.get(mode, self.buttons["ask"]).setChecked(True)
# ── empty state ─────────────────────────────────────────────────────────────
SUGGESTIONS = (
"What's in this folder?",
"Summarise the README",
"Git status and recent commits",
"Find TODO comments in this project",
)
class EmptyState(QWidget):
picked = Signal(str)
def __init__(self, parent=None):
super().__init__(parent)
col = QVBoxLayout(self)
col.setContentsMargins(0, 0, 0, 0)
col.addStretch(3)
col.addWidget(icons.IconLabel("bolt", theme.ACCENT_INK, 52, tile=theme.ACCENT), 0, Qt.AlignHCenter)
col.addSpacing(18)
hero = _label("What are we working on?", "Hero")
hero.setAlignment(Qt.AlignHCenter)
col.addWidget(hero)
self.sub = _label("", "HeroSub")
self.sub.setAlignment(Qt.AlignHCenter)
col.addWidget(self.sub)
col.addSpacing(26)
grid = QVBoxLayout()
grid.setSpacing(10)
for i in range(0, len(SUGGESTIONS), 2):
pair = QHBoxLayout()
pair.setSpacing(10)
for text in SUGGESTIONS[i:i + 2]:
button = QPushButton(text)
button.setObjectName("Suggestion")
button.setCursor(Qt.PointingHandCursor)
button.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
button.clicked.connect(lambda _=False, t=text: self.picked.emit(t))
pair.addWidget(button)
grid.addLayout(pair)
wrap = QHBoxLayout()
wrap.addStretch(1)
holder = QWidget()
holder.setLayout(grid)
holder.setMaximumWidth(560)
holder.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
wrap.addWidget(holder, 4)
wrap.addStretch(1)
col.addLayout(wrap)
col.addStretch(4)
def set_folder(self, path: str) -> None:
self.sub.setText(f"Bolt can read, edit and run things in {path}")
+528
View File
@@ -0,0 +1,528 @@
"""The main window and the worker thread a turn runs on.
Turn logic is `console.Session` — the same object the terminal uses — driven
on a QThread with a `Bridge` standing in for the terminal. The window only
ever reacts to Bridge signals, so nothing here knows about HTTP or tools.
"""
from __future__ import annotations
import os
import sys
import threading
from typing import Optional
from PySide6.QtCore import QObject, Qt, QThread, QTimer, Signal, Slot
from PySide6.QtGui import QFont, QKeySequence, QShortcut
from PySide6.QtWidgets import (
QApplication, QFileDialog, QFrame, QHBoxLayout, QLabel, QMainWindow,
QScrollArea, QSizePolicy, QVBoxLayout, QWidget,
)
from .. import __version__, config, dictation, server_client
from ..console import Session
from ..tools import Tools
from . import icons, theme
from .bridge import Bridge
from .chat_list_window import ChatListDialog, ChatSignals, run_in_pool
from .widgets import (
AssistantMessage, Composer, EmptyState, FolderChip, HoldingLine, ModeSwitch,
Notice, StatusDot, ThinkingIndicator, ToolCard, UserMessage,
)
class TurnRunner(QObject):
"""Lives on the worker thread; runs one Session.send per request."""
finished = Signal()
def __init__(self, session: Session):
super().__init__()
self.session = session
@Slot(str)
def run(self, text: str) -> None:
try:
self.session.send(text)
except Exception as exc: # never let a bug kill the worker thread
self.session.ui.error(f"Unexpected error: {exc}")
self.session.ui.stop_spinner()
finally:
self.finished.emit()
class HealthProbe(QObject):
done = Signal(bool, str)
def start(self) -> None:
threading.Thread(target=self._probe, daemon=True).start()
def _probe(self) -> None:
try:
server_client.check_health(timeout=8)
self.done.emit(True, f"Connected to {config.SERVER_URL}")
except Exception as exc:
self.done.emit(False, f"Can't reach {config.SERVER_URL}: {exc}")
class DictationSignals(QObject):
"""Dictation calls back on background threads; these queue it onto the
GUI thread."""
update = Signal(str, str) # final text so far, current interim guess
level = Signal(float) # 0..1 mic loudness
done = Signal(str, str) # text, error ("" when none)
stopping = Signal() # recording over, transcript on its way
class Column(QWidget):
"""Centres its content in a reading column of at most COLUMN_WIDTH."""
def __init__(self, parent=None):
super().__init__(parent)
row = QHBoxLayout(self)
row.setContentsMargins(24, 0, 24, 0)
row.addStretch(1)
self.inner = QWidget()
self.inner.setMaximumWidth(theme.COLUMN_WIDTH)
self.inner.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred)
row.addWidget(self.inner, 100)
row.addStretch(1)
self.layout_ = QVBoxLayout(self.inner)
self.layout_.setContentsMargins(0, 0, 0, 0)
class MainWindow(QMainWindow):
submit_turn = Signal(str)
HINT = "Enter to send · Shift+Enter for a new line · Ctrl+M dictate · Ctrl+N new chat"
def __init__(self, mode: Optional[str] = None, session: Optional[Session] = None,
dictation_factory=None, list_chats=None, create_chat=None):
super().__init__()
# Injectable so tests never hit the network; the real ones run on the pool.
self._list_chats = list_chats or server_client.list_chats
self._create_chat = create_chat or server_client.create_chat
self._dictation_factory = dictation_factory or dictation.Dictation
self._dictation = None
self.setWindowTitle("Bolt")
self.setWindowIcon(icons.app_icon())
self.resize(1040, 800)
self.setMinimumSize(620, 480)
self.bridge = Bridge()
if session is None:
session = Session(self.bridge, tools=Tools(self.bridge, mode=mode or config.APPROVAL_MODE))
self.session = session
self._cards = {}
self._assistant: Optional[AssistantMessage] = None
self._busy = False
self._stick_to_bottom = True
root = QWidget()
root.setObjectName("Root")
self.setCentralWidget(root)
page = QVBoxLayout(root)
page.setContentsMargins(0, 0, 0, 0)
page.setSpacing(0)
page.addWidget(self._build_header())
# transcript
self.scroll = QScrollArea()
self.scroll.setWidgetResizable(True)
self.scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
transcript = QWidget()
transcript.setObjectName("Transcript")
body = QVBoxLayout(transcript)
body.setContentsMargins(0, 18, 0, 18)
self.column = Column()
body.addWidget(self.column)
body.addStretch(1)
self.entries = self.column.layout_
self.entries.setSpacing(6)
self.empty = EmptyState()
self.empty.picked.connect(self._send)
self.entries.addWidget(self.empty)
self.thinking = ThinkingIndicator()
self.thinking.hide()
self.entries.addWidget(self.thinking)
self.scroll.setWidget(transcript)
self.scroll.verticalScrollBar().rangeChanged.connect(self._follow)
self.scroll.verticalScrollBar().valueChanged.connect(self._track_scroll)
page.addWidget(self.scroll, 1)
# composer
dock = Column()
dock.layout_.setContentsMargins(0, 6, 0, 14)
dock.layout_.setSpacing(6)
self.composer = Composer()
self.composer.submitted.connect(self._send)
dock.layout_.addWidget(self.composer)
self.hint = QLabel(self.HINT)
self.hint.setObjectName("Hint")
self.hint.setAlignment(Qt.AlignHCenter)
dock.layout_.addWidget(self.hint)
self._hint_timer = QTimer(self)
self._hint_timer.setSingleShot(True)
self._hint_timer.timeout.connect(lambda: self.hint.setText(self.HINT))
page.addWidget(dock)
QShortcut(QKeySequence("Ctrl+L"), self, activated=self.clear_transcript)
QShortcut(QKeySequence("Ctrl+O"), self, activated=self.choose_folder)
QShortcut(QKeySequence("Ctrl+N"), self, activated=self.start_new_chat)
QShortcut(QKeySequence("Ctrl+K"), self, activated=self.open_chats)
self.chat_signals = ChatSignals()
self.chat_signals.created.connect(lambda session_id: self.switch_chat(session_id, "New chat"))
self.chat_signals.failed.connect(self._new_chat_failed)
self._creating_chat = False
QShortcut(QKeySequence("Ctrl+M"), self, activated=self.toggle_dictation)
QShortcut(QKeySequence("Escape"), self, activated=self.cancel_dictation)
self.dictation_signals = DictationSignals()
self.dictation_signals.update.connect(self.composer.show_dictation)
self.dictation_signals.level.connect(self.composer.mic.set_level)
self.dictation_signals.done.connect(self._dictation_done)
self.dictation_signals.stopping.connect(
lambda: self._dictation is not None and self.composer.mic.set_state("finishing"))
self.composer.mic.clicked.connect(self.toggle_dictation)
self._mic_unavailable = dictation.unavailable_reason()
if self._mic_unavailable:
self.composer.mic.set_state("unavailable", self._mic_unavailable)
self._wire_bridge()
self._start_worker()
self._refresh_folder()
self.health = HealthProbe()
self.health.done.connect(self._health_done)
QTimer.singleShot(0, self.health.start)
self.composer.edit.setFocus()
# ── construction ─────────────────────────────────────────────────────
def _build_header(self) -> QWidget:
header = QFrame()
header.setObjectName("Header")
header.setFixedHeight(56)
row = QHBoxLayout(header)
row.setContentsMargins(18, 0, 18, 0)
row.setSpacing(10)
row.addWidget(icons.IconLabel("bolt", theme.ACCENT_INK, 26, tile=theme.ACCENT))
word = QLabel("Bolt")
word.setObjectName("Wordmark")
row.addWidget(word)
sub = QLabel(f"Console {__version__}")
sub.setObjectName("WordmarkSub")
row.addWidget(sub)
row.addSpacing(6)
self.status = StatusDot()
self.status.set_state("unknown", "Checking the server…")
row.addWidget(self.status)
row.addStretch(1)
self.chats = FolderChip(icon_name="chats", text="Chats")
self.chats.setToolTip("All your chats with Bolt, from every device (Ctrl+K)")
self.chats.clicked.connect(self.open_chats)
row.addWidget(self.chats)
self.new_chat = FolderChip(icon_name="plus", text="New chat")
self.new_chat.setToolTip("Start a fresh conversation (Ctrl+N)\n"
"Fresh history; Bolt still remembers you")
self.new_chat.clicked.connect(self.start_new_chat)
row.addWidget(self.new_chat)
self.folder = FolderChip()
self.folder.clicked.connect(self.choose_folder)
row.addWidget(self.folder)
self.mode = ModeSwitch(self.session.tools.mode)
self.mode.changed.connect(self._set_mode)
row.addWidget(self.mode)
return header
def _wire_bridge(self) -> None:
b = self.bridge
b.busy.connect(self._set_busy)
b.chunk.connect(self._on_chunk)
b.reply_ended.connect(self._end_assistant)
b.reply.connect(self._on_reply)
b.holding_line.connect(lambda text: self._add(HoldingLine(text)))
b.tool_started.connect(self._on_tool_started)
b.tool_output.connect(self._on_tool_output)
b.tool_diff.connect(self._on_tool_diff)
b.approval_needed.connect(self._on_approval)
b.notice.connect(lambda level, text: self._add(Notice(level, text)))
def _start_worker(self) -> None:
self.thread_ = QThread(self)
self.runner = TurnRunner(self.session)
self.runner.moveToThread(self.thread_)
self.submit_turn.connect(self.runner.run)
self.runner.finished.connect(self._turn_finished)
self.thread_.start()
# ── transcript helpers ───────────────────────────────────────────────
def _add(self, widget: QWidget) -> None:
if self.empty.isVisible():
self.empty.hide()
# Always insert above the thinking indicator, which stays last.
self.entries.insertWidget(self.entries.count() - 1, widget)
def _track_scroll(self, value: int) -> None:
bar = self.scroll.verticalScrollBar()
self._stick_to_bottom = value >= bar.maximum() - 40
def _follow(self, _min: int, maximum: int) -> None:
if self._stick_to_bottom:
self.scroll.verticalScrollBar().setValue(maximum)
def clear_transcript(self) -> None:
if self._busy:
return
keep = (self.empty, self.thinking)
for i in reversed(range(self.entries.count())):
widget = self.entries.itemAt(i).widget()
if widget is not None and widget not in keep:
self.entries.takeAt(i)
widget.deleteLater()
self._cards.clear()
self._assistant = None
self.empty.show()
# ── user actions ─────────────────────────────────────────────────────
# ── chats ────────────────────────────────────────────────────────────
def start_new_chat(self) -> None:
"""Ask the server for a fresh chat id, off the UI thread; the switch
happens when it arrives (switch_chat)."""
if self._busy or self._creating_chat:
return
self._creating_chat = True
self.new_chat.setEnabled(False)
self._flash_hint("Starting a new chat...")
run_in_pool("create", self._create_chat, self.chat_signals)
def _new_chat_failed(self, _operation: str, message: str) -> None:
self._creating_chat = False
self.new_chat.setEnabled(not self._busy)
self._flash_hint(f"Couldn't start a new chat: {message}")
def open_chats(self) -> None:
if self._busy:
self._flash_hint("Wait for Bolt to finish before switching chats.")
return
dialog = ChatListDialog(self, list_chats=self._list_chats, create_chat=self._create_chat)
dialog.chosen.connect(self.switch_chat)
self.chat_dialog = dialog # kept for tests; the dialog is modal
dialog.open()
def switch_chat(self, session_id: str, label: str = "") -> None:
"""Point every server call at *session_id* and start the local
transcript over: there's no endpoint for a past chat's messages, so
the window can't show them (Bolt still has them server-side)."""
self._creating_chat = False
self.new_chat.setEnabled(not self._busy)
if self._busy:
return
note = self.session.switch_to(session_id)
self.clear_transcript()
self.chats.setToolTip(f"All your chats with Bolt, from every device (Ctrl+K)\n{note}")
self._flash_hint(f"Now in: {label or session_id}")
def _send(self, text: str) -> None:
text = text.strip()
if not text or self._busy:
return
self._stick_to_bottom = True
self._add(UserMessage(text))
self._assistant = None
self._set_busy(True)
self.submit_turn.emit(text)
# ── dictation ────────────────────────────────────────────────────────
def toggle_dictation(self) -> None:
if self._mic_unavailable:
self._flash_hint(self._mic_unavailable)
return
if self._dictation is not None:
if self.composer.mic.state == "recording":
self.composer.mic.set_state("finishing")
self._dictation.stop()
return
sig = self.dictation_signals
recording = self._dictation_factory(
sig.update.emit, sig.level.emit, lambda text, error: sig.done.emit(text, error or ""),
silence_seconds=config.DICTATION_SILENCE_SECONDS, on_stopping=sig.stopping.emit,
)
self.composer.begin_dictation()
try:
recording.start()
except Exception as exc:
self.composer.end_dictation("")
self._flash_hint(f"Couldn't open the microphone: {exc}")
return
self._dictation = recording
self.composer.mic.set_state("recording")
if not recording.live:
self._flash_hint("Live transcription is unavailable; the words will appear when you stop.")
def cancel_dictation(self) -> None:
if self._dictation is not None:
self._dictation.cancel()
def _dictation_done(self, text: str, error: str) -> None:
self._dictation = None
self.composer.mic.set_state("unavailable" if self._mic_unavailable else "idle",
self._mic_unavailable or "")
self.composer.end_dictation(text)
if error:
self._flash_hint(error)
def _flash_hint(self, text: str, seconds: float = 6.0) -> None:
self.hint.setText(text)
self._hint_timer.start(int(seconds * 1000))
def choose_folder(self) -> None:
if self._busy:
return
path = QFileDialog.getExistingDirectory(self, "Working directory for Bolt", os.getcwd())
if path:
try:
os.chdir(path)
except OSError as exc:
self._add(Notice("error", f"Couldn't switch to {path}: {exc}"))
return
self._refresh_folder()
if not self.empty.isVisible():
self._add(Notice("info", f"Working directory is now {os.getcwd()}"))
def _refresh_folder(self) -> None:
cwd = os.getcwd()
self.folder.set_path(cwd)
self.empty.set_folder(cwd)
def _set_mode(self, mode: str) -> None:
self.session.tools.mode = mode
# ── bridge slots ─────────────────────────────────────────────────────
def _set_busy(self, busy: bool) -> None:
self._busy = busy
self.composer.set_busy(busy)
self.folder.setEnabled(not busy)
self.new_chat.setEnabled(not busy and not self._creating_chat)
self.chats.setEnabled(not busy)
if busy:
self.thinking.start()
else:
self.thinking.stop()
def _turn_finished(self) -> None:
self._set_busy(False)
self._assistant = None
# Pick up a mode switched by "Always allow" on the worker side.
self.mode.set_mode(self.session.tools.mode)
self.composer.edit.setFocus()
def _on_chunk(self, sentence: str) -> None:
if self._assistant is None:
self._assistant = AssistantMessage()
self._add(self._assistant)
self._assistant.append(sentence)
def _end_assistant(self) -> None:
self._assistant = None
def _on_reply(self, text: str) -> None:
self._add(AssistantMessage(text))
self._assistant = None
def _on_tool_started(self, tool_id: int, name: str, argument: str) -> None:
self._assistant = None # text after a tool call starts a new block
card = ToolCard(name, argument)
card.answered.connect(self._on_answer)
self._cards[tool_id] = card
self._add(card)
def _on_tool_output(self, tool_id: int, text: str, error: bool) -> None:
card = self._cards.get(tool_id)
if card is not None:
card.set_result(text, error)
def _on_tool_diff(self, tool_id: int, old: str, new: str) -> None:
card = self._cards.get(tool_id)
if card is not None:
card.set_diff(old, new)
def _on_approval(self, tool_id: int, question: str) -> None:
card = self._cards.get(tool_id)
if card is None:
self.bridge.answer("no")
return
self.thinking.stop()
card.ask(question)
self._stick_to_bottom = True
QTimer.singleShot(0, lambda: self.scroll.ensureWidgetVisible(card, 0, 40))
QApplication.alert(self) # flash the taskbar if the window is behind others
def _on_answer(self, value: str) -> None:
if value == "always":
self.mode.set_mode("auto")
self.thinking.start()
self.bridge.answer(value)
def _health_done(self, ok: bool, detail: str) -> None:
self.status.set_state("ok" if ok else "error", detail)
# ── window chrome ────────────────────────────────────────────────────
def resizeEvent(self, event) -> None:
super().resizeEvent(event)
# The transcript grows from the top; give the empty state the whole
# viewport so its stretches can centre it.
self.empty.setMinimumHeight(max(0, self.scroll.viewport().height() - 40))
def showEvent(self, event) -> None:
super().showEvent(event)
theme.dark_title_bar(self)
def closeEvent(self, event) -> None:
self.cancel_dictation()
self.bridge.close()
self.thread_.quit()
self.thread_.wait(1500)
super().closeEvent(event)
def make_app(argv=None) -> QApplication:
app = QApplication.instance() or QApplication(argv or sys.argv)
app.setApplicationName("Bolt Console")
app.setWindowIcon(icons.app_icon())
app.setStyle("Fusion") # a neutral base, so the stylesheet decides the look
app.setPalette(theme.palette())
font = QFont(theme.ui_font_family())
font.setPointSizeF(10.5)
font.setHintingPreference(QFont.PreferNoHinting)
app.setFont(font)
app.setStyleSheet(theme.stylesheet())
return app
def run(argv=None) -> int:
if sys.platform == "win32":
try: # own taskbar group + icon instead of python.exe's
import ctypes
ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID("TheMajesticNetwork.BoltConsole")
except Exception:
pass
argv = list(sys.argv[1:] if argv is None else argv)
app = make_app()
if not config.is_configured():
from PySide6.QtWidgets import QMessageBox
QMessageBox.critical(
None, "Bolt Console",
"Missing " + ", ".join(config.missing_config())
+ ".\n\nCopy .env.example to .env next to the app and fill them in.")
return 2
window = MainWindow(mode="auto" if "--auto" in argv else None)
window.show()
return app.exec()
+157
View File
@@ -0,0 +1,157 @@
"""Lenient JSON for the relayed command channel — and honest about it.
`filectl` and `dialoguectl` both take a single line of compact JSON, hand-typed
by a language model into a tool marker. Models get that *nearly* right and then
get it wrong in a small, boringly repeatable set of ways:
{"op":"list","path":"/home/x","recursive":false"} ← stray quote after a literal
{"op": "read", "path": "/tmp/a.txt",} ← trailing comma
{'op': 'read', 'path': '/tmp/a.txt'} ← single quotes
{“op”: “read”, “path”: “/tmp/a.txt”} ← smart quotes
{"op": "list", "recursive": False} ← Python literals
```json {"op": "list"} ``` ← fenced
Observed live (2026-07-30): a stray quote after `false` cost an entire desk
turn — the call was rejected, the model re-sent the *identical* line, was
rejected again, and then gave up and told the user "I'll check now" without
ever calling anything. The user got a promise instead of an answer because of
one character.
Strict parsing is the wrong trade here. Nothing about a misplaced quote is
ambiguous, the payload is machine-written and machine-read, and the cost of
refusing is a wasted round trip that the model has already demonstrated it
won't recover from. So: try strict first, then apply narrow repairs, and
accept a repair **only if the result parses**.
Two rules keep this from becoming "guess what they meant":
1. **Repairs are conservative and named.** Each one fixes a known malformation,
is applied in isolation, and is reported by name.
2. **Repairs are never silent.** The caller appends the repair note to the tool
result, so the model is told it sent broken JSON *while it still has the
turn* — the fix works today and teaches within the conversation. Hiding it
would trade a visible failure for an invisible one.
When nothing parses, the error points at the exact character with a caret,
because "Expecting ',' delimiter: char 74" is not something a model can act on
and a pointed-at fragment is.
"""
from __future__ import annotations
import json
import re
from typing import Any, Callable
# Ordered, cheapest and safest first. Each entry is (name, transform); after
# each one the payload is re-parsed, so the first repair that works wins and
# nothing more aggressive gets applied than the input actually needed.
_REPAIRS: tuple[tuple[str, Callable[[str], str]], ...] = (
(
"stripped a markdown code fence",
lambda text: re.sub(r"^\s*```(?:json)?\s*|\s*```\s*$", "", text),
),
(
"replaced smart quotes with straight ones",
lambda text: text.translate(str.maketrans({"“": '"', "”": '"',
"‘": "'", "’": "'"})),
),
(
"doubled backslashes that weren't valid JSON escapes",
# {"path": "C:\Users\x"} -> {"path": "C:\\Users\\x"} — a Windows path
# typed raw. Only *invalid* escapes are touched, and `\\` pairs are
# consumed whole, so nothing that already parsed can change meaning.
lambda text: re.sub(
r'\\([\\/"bfnrtu])|\\',
lambda m: m.group(0) if m.group(1) else "\\\\", text),
),
(
"removed a stray quote after a bare value",
# {"recursive":false"} -> {"recursive":false}
lambda text: re.sub(
r"(:\s*(?:true|false|null|-?\d+(?:\.\d+)?))\s*\"(\s*[,}\]])", r"\1\2", text),
),
(
"removed a trailing comma",
lambda text: re.sub(r",(\s*[}\]])", r"\1", text),
),
(
"converted Python literals (True/False/None) to JSON",
lambda text: re.sub(r"(:\s*)(True|False|None)\b",
lambda m: m.group(1) + {"True": "true", "False": "false",
"None": "null"}[m.group(2)], text),
),
(
"converted single-quoted strings to double-quoted",
lambda text: re.sub(r"'([^'\"]*)'", r'"\1"', text),
),
)
class RelayJsonError(ValueError):
"""Unparseable even after repairs — carries a pointed-at fragment."""
def loads(payload: str) -> tuple[Any, list[str]]:
"""Parse *payload*, repairing common model mistakes.
Returns (data, repairs) where *repairs* names what had to be fixed — empty
when the input was already valid. Raises RelayJsonError with a caret at the
offending character when nothing works."""
text = str(payload or "").strip()
if not text:
raise RelayJsonError("empty payload")
try:
return json.loads(text), []
except json.JSONDecodeError as exc:
# Bound to a plain name: Python deletes the `as` target at the end of
# the except block, so referring to it further down would raise
# UnboundLocalError instead of reporting the parse failure.
first_error = exc
applied: list[str] = []
candidate = text
for name, repair in _REPAIRS:
repaired = repair(candidate)
if repaired == candidate:
continue
candidate = repaired
applied.append(name)
try:
return json.loads(candidate), applied
except json.JSONDecodeError:
continue # keep going: a payload can be broken in more than one way
raise RelayJsonError(point_at(text, first_error))
def point_at(text: str, error: json.JSONDecodeError, width: int = 28) -> str:
"""Show the failure where it happened.
A model can act on "you wrote `false\"}` here"; it cannot act on
"Expecting ',' delimiter: line 1 column 75"."""
position = max(0, min(len(text), getattr(error, "pos", 0)))
start = max(0, position - width)
end = min(len(text), position + width)
fragment = text[start:end]
caret = " " * (position - start) + "^"
lead = "…" if start > 0 else ""
tail = "…" if end < len(text) else ""
return (
f"{error.msg} at character {position}:\n"
f" {lead}{fragment}{tail}\n"
f" {' ' * len(lead)}{caret}"
)
def repair_note(repairs: list[str]) -> str:
"""The line appended to a tool result when repairs were needed.
Phrased as feedback rather than an apology: the model is the author of the
broken JSON and is the one who can stop sending it."""
if not repairs:
return ""
return (
" (note: your JSON was malformed — I " + "; ".join(repairs)
+ " and ran it anyway. Send valid single-line JSON next time.)"
)
+270
View File
@@ -0,0 +1,270 @@
"""HTTP client for Bolt's desk API (ai/desk_api.py on the server).
Protocol is identical to desk_client/bolt_desk.py in the main tmn-api repo —
the console is just another desk client, so it gets the exact same brain,
memory, tools, and persona as Discord chat and the other desk clients:
text -> POST /desk/converse (or /desk/converse_stream)
[server may relay a command back to run on THIS machine]
... -> POST /desk/tool_result (repeat until the server sends a reply)
reply <- returned to caller
Kept dependency-free beyond `requests` so it's easy to unit test with mocks.
"""
from __future__ import annotations
import json
import os
import subprocess
from typing import Callable, NamedTuple, Optional
import requests
from . import config
_MAX_RELAY_HOPS = 16
_MAX_OUTPUT_CHARS = 6000
class ServerError(Exception):
"""Raised when the server responds with an error payload or unreachable."""
class Reply(NamedTuple):
"""One final reply from the desk API."""
text: str
# True when the text already reached the screen sentence by sentence while
# it streamed in — the caller must not print it a second time.
streamed: bool = False
def _headers() -> dict:
return {"X-Desk-Api-Key": config.API_KEY}
def check_health(timeout: float = 10.0) -> dict:
response = requests.get(f"{config.SERVER_URL}/desk/health", headers=_headers(), timeout=timeout)
response.raise_for_status()
return response.json()
def run_local_command(command: str, timeout: Optional[int] = None) -> str:
"""Execute a relayed shell command as the current user, in the console's
working directory (see /cd). Output is captured for the server, so stdin
is closed rather than inherited — a command waiting on input would
otherwise hang on a prompt nobody can see. `sudo` still works: it asks on
the terminal itself, not on stdin."""
timeout = timeout or config.COMMAND_TIMEOUT_SECONDS
try:
completed = subprocess.run(
command, shell=True, capture_output=True, text=True, errors="replace",
stdin=subprocess.DEVNULL, timeout=timeout, cwd=os.getcwd(),
# From the window (pythonw, no console) every cmd.exe would
# otherwise flash a console window. Output is captured either way.
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
)
output = (completed.stdout or "") + (completed.stderr or "")
return f"[exit {completed.returncode}]\n{output}"[:_MAX_OUTPUT_CHARS]
except subprocess.TimeoutExpired:
return f"[command timed out after {timeout}s]"
except Exception as exc:
return f"[command failed: {exc}]"
def _reply_from(payload: dict, streamed: bool = False) -> Reply:
if payload.get("type") == "reply":
return Reply(text=str(payload.get("text") or ""), streamed=streamed)
raise ServerError(str(payload.get("error") or "unknown server response"))
def _relay(payload: dict, on_command, on_say) -> dict:
"""Run every command the server hands back, posting each output to
/desk/tool_result, until the payload is no longer a command."""
for _ in range(_MAX_RELAY_HOPS):
if payload.get("type") != "command":
break
holding = str(payload.get("say") or "").strip()
if holding and on_say is not None:
# Shown *before* the command runs — that's the point of it.
try:
on_say(holding)
except Exception:
pass # a failed acknowledgement must not cost the tool call
output = on_command(str(payload.get("command") or ""))
try:
response = requests.post(
f"{config.SERVER_URL}/desk/tool_result",
json={"session_id": config.SESSION_ID,
"token": payload.get("token"), "output": output},
headers=_headers(), timeout=180,
)
payload = response.json()
except Exception as exc:
raise ServerError(f"couldn't reach the server during tool relay: {exc}") from exc
return payload
def converse(
text: str,
on_command: Callable[[str], str] = run_local_command,
timeout: float = 120.0,
on_say: Optional[Callable[[str], None]] = None,
) -> Reply:
"""Send one turn of conversation, relaying any commands the server sends
back until it produces a final reply.
*on_say* receives the short holding line ("give me a sec") the server can
send alongside a command."""
try:
response = requests.post(
f"{config.SERVER_URL}/desk/converse",
json={"session_id": config.SESSION_ID, "text": text},
headers=_headers(), timeout=timeout,
)
payload = response.json()
except Exception as exc:
raise ServerError(f"couldn't reach the server: {exc}") from exc
return _reply_from(_relay(payload, on_command, on_say))
def converse_stream(
text: str,
on_say: Callable[[str], None],
on_command: Callable[[str], str] = run_local_command,
timeout: float = 180.0,
) -> Reply:
"""Same turn as converse(), but handing each sentence to *on_say* as it
arrives, so the wait is time-to-first-sentence.
Raises ServerError if the stream fails before anything was shown — the
caller then retries with converse(). Once a sentence *has* been shown,
a late failure ends the turn with what was said rather than repeating it.
"""
shown_anything = False
said = []
try:
response = requests.post(
f"{config.SERVER_URL}/desk/converse_stream",
json={"session_id": config.SESSION_ID, "text": text},
headers=_headers(), timeout=timeout, stream=True,
)
response.raise_for_status()
for raw in response.iter_lines(decode_unicode=True):
if not raw:
continue
try:
event = json.loads(raw)
except (TypeError, ValueError):
continue
kind = str(event.get("type") or "")
if kind == "say":
line = str(event.get("text") or "").strip()
if line:
shown_anything = True
said.append(line)
on_say(line)
elif kind == "command":
# The tool loop is request/response, so the rest of the turn
# finishes through the ordinary relay rather than the stream.
response.close()
return _reply_from(_relay(event, on_command, on_say))
elif kind == "reply":
if event.get("already_spoken"):
return _reply_from(event, streamed=True)
return _unsaid(_reply_from(event), said)
elif kind == "error":
raise ServerError(str(event.get("error") or "stream failed"))
except ServerError:
raise
except Exception as exc:
if shown_anything:
return Reply(text="", streamed=True)
raise ServerError(f"streaming failed: {exc}") from exc
raise ServerError("stream ended without a reply")
def _unsaid(reply: Reply, said: list) -> Reply:
"""The part of *reply* not already shown as streamed sentences.
Seen live: the server streams the sentences, then sends the whole text
again in the final reply *without* `already_spoken`, so everything showed
twice. Compared ignoring whitespace, since the stream splits on sentences
and drops the line breaks the full text keeps."""
if not said:
return reply
target = len("".join("".join(said).split())) # non-whitespace characters shown
seen, cut = 0, None
for index, char in enumerate(reply.text):
if not char.isspace():
seen += 1
if seen == target:
cut = index + 1
break
if cut is None or "".join(reply.text[:cut].split()) != "".join("".join(said).split()):
return reply # a different text, not a repeat: show all of it
rest = reply.text[cut:].strip()
return Reply(text=rest, streamed=not rest)
def list_chats(timeout: float = 15.0) -> list:
"""This user's conversations across every client (console, pet, phone),
newest first — each {session_id, label, last_message, last_active}. A
chat only appears once it's been used, so a just-minted one isn't here."""
try:
response = requests.get(
f"{config.SERVER_URL}/desk/chats", headers=_headers(), timeout=timeout,
)
response.raise_for_status()
return list(response.json().get("chats") or [])
except Exception as exc:
raise ServerError(f"couldn't list chats: {exc}") from exc
def create_chat(timeout: float = 15.0) -> str:
"""Mint a brand-new, never-used session id on the server. Bolt's memory is
scoped to the user, not the session, so a new chat is fresh history, not
a memory reset."""
try:
response = requests.post(
f"{config.SERVER_URL}/desk/chats", headers=_headers(), timeout=timeout,
)
response.raise_for_status()
session_id = str(response.json().get("session_id") or "")
except Exception as exc:
raise ServerError(f"couldn't create a chat: {exc}") from exc
if not session_id:
raise ServerError("the server didn't return a session_id for the new chat")
return session_id
def list_outbox_files(timeout: float = 15.0) -> list:
"""Files the server has queued for this session via its deliver_files
tool — each entry has id/name/size. Downloading one dequeues it
server-side, so a file is only ever handed out once."""
try:
response = requests.get(
f"{config.SERVER_URL}/desk/files",
params={"session_id": config.SESSION_ID},
headers=_headers(), timeout=timeout,
)
response.raise_for_status()
return list(response.json().get("files") or [])
except Exception as exc:
raise ServerError(f"couldn't list delivered files: {exc}") from exc
def download_outbox_file(file_id: str, timeout: float = 60.0) -> bytes:
"""Fetches and dequeues one file listed by list_outbox_files()."""
try:
response = requests.get(
f"{config.SERVER_URL}/desk/files/{file_id}",
params={"session_id": config.SESSION_ID},
headers=_headers(), timeout=timeout,
)
response.raise_for_status()
return response.content
except Exception as exc:
raise ServerError(f"couldn't download delivered file {file_id!r}: {exc}") from exc
+201
View File
@@ -0,0 +1,201 @@
"""What happens to a command the server relays back to this machine.
The desk API has one tool channel — a `command` string — and this module is
where the console decides what that string means:
filectl <json> file list/read/write/edit (file_ops.py), no shell
petctl / dialoguectl the pet's body and voice — answered with an
explanation instead of being run, since there is
no pet here and `petctl` isn't a real program
anything else a real shell command, in the console's cwd
Reading never asks. Anything that changes the machine (a shell command, a
write, an edit) asks first unless the session is in auto mode — the pet ran
everything unasked because nobody was sitting at a prompt to answer; a
console has someone right there, so the default is to use them.
"""
from __future__ import annotations
import re
from typing import Callable
from . import file_ops, server_client
# Pseudo-commands from the pet's vocabulary. The server may still emit them —
# the session is a pet session as far as it knows — so they get an answer the
# model can act on rather than "'petctl' is not recognized as a command".
_PET_PREFIXES = ("petctl", "bolt-pet", "dialoguectl")
PET_UNAVAILABLE = (
"[console] {name} isn't available: this is Bolt Console, a text-only terminal "
"client with no pet body, voice, or screen reading. Use filectl for files and "
"ordinary shell commands for everything else."
)
DECLINED = (
"[console] the user declined this {what}, so it did not run. Don't retry it "
"as-is; ask what they'd like instead."
)
def is_pet_command(command: str) -> bool:
parts = (command or "").split(None, 1)
return bool(parts) and parts[0].lower() in _PET_PREFIXES
# The model sometimes writes the tool marker's own label into its argument
# ("command: powershell -Command ...") or wraps the whole thing in backticks.
# Neither is ever part of a real command — cmd.exe answers "'command:' is not
# recognized" — so they're stripped, and the model is told, like relay_json's
# repairs: a silent fix works today and guarantees the same mistake tomorrow.
_LABEL = re.compile(r"^(?:command|cmd)\s*:\s*", re.IGNORECASE)
def clean_command(command: str):
"""Returns (command, repairs) with a stray marker label and a wrapping
pair of backticks removed."""
command = (command or "").strip()
repairs = []
stripped = _LABEL.sub("", command, count=1)
if stripped != command:
repairs.append('removed a leading "command:" label')
command = stripped.strip()
if len(command) > 2 and command[0] == command[-1] == "`" and "`" not in command[1:-1]:
repairs.append("removed wrapping backticks")
command = command[1:-1].strip()
return command, repairs
def repair_note(repairs) -> str:
if not repairs:
return ""
return (
"\n(note: your command was malformed — I " + "; ".join(repairs)
+ " and ran it anyway. Send only the command itself next time.)"
)
class Tools:
"""The `on_command` callable handed to server_client.converse*.
*ui* is a ui.Terminal (or anything with tool_call/tool_result/diff/
confirm); *run_shell* is injectable so tests never spawn a process."""
def __init__(
self,
ui,
mode: str = "ask",
run_shell: Callable[[str], str] = server_client.run_local_command,
):
self.ui = ui
self.mode = "auto" if mode == "auto" else "ask"
self.run_shell = run_shell
# Commands that actually reached this machine — the console compares
# it with what a reply *claims* happened (see console.fabricated).
self.calls = 0
def __call__(self, command: str) -> str:
return self.handle(command)
def handle(self, command: str) -> str:
self.calls += 1
command, repairs = clean_command(command)
if not command:
return "[console] empty command"
return self._dispatch(command) + repair_note(repairs)
def _dispatch(self, command: str) -> str:
if is_pet_command(command):
name = command.split(None, 1)[0]
self.ui.tool_call("Pet", command)
output = PET_UNAVAILABLE.format(name=name)
self.ui.tool_result("not available in the console", error=True)
return output
try:
action = file_ops.parse(command)
except file_ops.FileOpError as exc:
self.ui.tool_call("File", command)
self.ui.tool_result(str(exc), error=True)
return f"[filectl] {exc}"
if action is not None:
return self._file(action)
return self._shell(command)
# ── approval ─────────────────────────────────────────────────────────
def _approved(self, question: str) -> bool:
if self.mode == "auto":
return True
answer = self.ui.confirm(question)
if answer == "always":
self.mode = "auto"
return True
return answer == "yes"
# ── shell ────────────────────────────────────────────────────────────
def _shell(self, command: str) -> str:
self.ui.tool_call("Shell", command)
if not self._approved("Run this command?"):
self.ui.tool_result("declined", error=True)
return DECLINED.format(what="command")
output = self.run_shell(command)
status, _, body = output.partition("\n")
failed = status != "[exit 0]"
# A clean exit shows just the output; a failure keeps its status line.
shown = body.strip() or status
if failed and body.strip():
shown = f"{status}\n{body.strip()}"
self.ui.tool_result(shown, error=failed)
return output
# ── filectl ──────────────────────────────────────────────────────────
def _file(self, action: dict) -> str:
kind = action["action"]
path = action.get("path", "")
if kind == "help":
return file_ops.execute(action)
if kind == "list":
label = path if action["pattern"] == "*" else f"{path}, {action['pattern']}"
self.ui.tool_call("List", label + (", recursive" if action["recursive"] else ""))
elif kind == "read":
span = ""
if action.get("start") or action.get("end"):
span = f", lines {action.get('start') or 1}-{action.get('end') or 'end'}"
self.ui.tool_call("Read", path + span)
elif kind == "write":
lines = action["content"].count("\n") + 1
self.ui.tool_call("Write", path)
self.ui.tool_result(f"{lines} lines, {len(action['content'])} chars")
if not self._approved("Write this file?"):
return self._declined("write")
elif kind == "edit":
self.ui.tool_call("Edit", path)
self.ui.diff(action["old"], action["new"])
if not self._approved("Make this edit?"):
return self._declined("edit")
try:
output = file_ops.execute(action)
except file_ops.FileOpError as exc:
self.ui.tool_result(str(exc), error=True)
return f"[filectl] {exc}"
if kind == "read":
count = output.count("\n") + 1 if output != "[empty file]" else 0
self.ui.tool_result(f"Read {count} lines")
elif kind == "list":
self.ui.tool_result(output)
else:
self.ui.tool_result(output.replace("[filectl] ", ""))
return output
def _declined(self, what: str) -> str:
self.ui.tool_result("declined", error=True)
return DECLINED.format(what=what)
+257
View File
@@ -0,0 +1,257 @@
"""Terminal rendering: colour, the thinking spinner, tool-call lines, prompts.
Plain ANSI and the standard library only, so the console has no dependency
beyond `requests`. Everything writes through `Terminal._emit`, which holds the
same lock the spinner thread draws under — that is what keeps a spinner frame
from landing in the middle of a line of output.
"""
from __future__ import annotations
import itertools
import os
import sys
import threading
import time
from typing import Callable, List, Optional, TextIO
_RESET = "\033[0m"
_STYLES = {
"bold": "\033[1m",
"dim": "\033[2m",
"red": "\033[31m",
"green": "\033[32m",
"yellow": "\033[33m",
"blue": "\033[34m",
"magenta": "\033[35m",
"cyan": "\033[36m",
}
# Output lines shown under a tool call before collapsing to "… +N lines".
TOOL_PREVIEW_LINES = 4
def enable_windows_ansi() -> None:
"""Turn on VT escape processing for the Windows console (off by default
for older hosts). Harmless everywhere else."""
if os.name != "nt":
return
try:
import ctypes
kernel32 = ctypes.windll.kernel32
handle = kernel32.GetStdHandle(-11) # STD_OUTPUT_HANDLE
mode = ctypes.c_uint32()
if kernel32.GetConsoleMode(handle, ctypes.byref(mode)):
kernel32.SetConsoleMode(handle, mode.value | 0x0004) # ENABLE_VIRTUAL_TERMINAL_PROCESSING
except Exception:
pass
def _supports_unicode(stream: TextIO) -> bool:
encoding = (getattr(stream, "encoding", "") or "").lower().replace("-", "")
return encoding.startswith("utf")
class Glyphs:
def __init__(self, unicode: bool):
self.bullet = "●" if unicode else "*"
self.elbow = "⎿" if unicode else "L"
self.ellipsis = "…" if unicode else "..."
self.prompt = "›" if unicode else ">"
self.spinner = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏" if unicode else "|/-\\"
def preview(text: str, limit: int = TOOL_PREVIEW_LINES) -> List[str]:
"""The first *limit* lines of *text*, plus a count of what was cut."""
lines = text.rstrip("\n").splitlines() or [""]
if len(lines) <= limit:
return lines
return lines[:limit] + [f"... +{len(lines) - limit} lines"]
class Terminal:
def __init__(
self,
out: Optional[TextIO] = None,
color: bool = True,
input_fn: Callable[[str], str] = input,
animate: Optional[bool] = None,
):
self.out = out or sys.stdout
tty = bool(getattr(self.out, "isatty", lambda: False)())
self.color = color and tty
self.animate = tty if animate is None else animate
self.glyphs = Glyphs(_supports_unicode(self.out))
self._input = input_fn
self._lock = threading.RLock()
self._spinner_label = ""
self._spinner_started = 0.0
self._spinner_line = False # a spinner frame is currently on screen
self._spinner_thread: Optional[threading.Thread] = None
self._spinner_stop = threading.Event()
self._spinner_paused = False
self._in_reply = False # streamed sentences continue one bulleted block
# ── primitives ───────────────────────────────────────────────────────
def style(self, text: str, *names: str) -> str:
if not self.color or not names:
return text
return "".join(_STYLES[n] for n in names) + text + _RESET
def _clear_spinner_line(self) -> None:
if self._spinner_line:
self.out.write("\r\033[2K" if self.color else "\r" + " " * 60 + "\r")
self._spinner_line = False
def _emit(self, text: str) -> None:
with self._lock:
self._clear_spinner_line()
self.out.write(text)
self.out.flush()
def line(self, text: str = "") -> None:
self._emit(text + "\n")
# ── spinner ──────────────────────────────────────────────────────────
def start_spinner(self, label: str = "Thinking") -> None:
self._spinner_label = label
self._spinner_started = time.monotonic()
self._spinner_paused = False
if not self.animate or self._spinner_thread is not None:
return
self._spinner_stop.clear()
self._spinner_thread = threading.Thread(target=self._spin, daemon=True)
self._spinner_thread.start()
def stop_spinner(self) -> None:
thread = self._spinner_thread
if thread is not None:
self._spinner_stop.set()
thread.join(timeout=1)
self._spinner_thread = None
with self._lock:
self._clear_spinner_line()
self.out.flush()
def _spin(self) -> None:
for frame in itertools.cycle(self.glyphs.spinner):
if self._spinner_stop.wait(0.1):
return
with self._lock:
if self._spinner_paused:
continue
elapsed = int(time.monotonic() - self._spinner_started)
text = f"{frame} {self._spinner_label}{self.glyphs.ellipsis} ({elapsed}s, ctrl+c to interrupt)"
self.out.write("\r" + self.style(text, "magenta"))
self.out.flush()
self._spinner_line = True
# ── conversation ─────────────────────────────────────────────────────
def _bulleted(self, text: str, *bullet_style: str, body_style=()) -> None:
self._in_reply = False
lines = text.splitlines() or [""]
first, rest = lines[0], lines[1:]
out = [self.style(self.glyphs.bullet, *bullet_style) + " " + self.style(first, *body_style)]
out += [" " + self.style(line, *body_style) for line in rest]
self.line("\n".join(out))
def assistant(self, text: str) -> None:
if text.strip():
self._bulleted(text.strip())
self.line()
def assistant_chunk(self, sentence: str) -> None:
"""One streamed sentence. The first opens a bulleted block and the
rest continue it on their own lines — never mid-line, because the
spinner redraws by erasing the current line."""
sentence = sentence.strip()
if not sentence:
return
if self._in_reply:
self.line("\n".join(" " + part for part in sentence.splitlines()))
else:
self._bulleted(sentence)
self._in_reply = True
def end_reply(self) -> None:
self._in_reply = False
self.line()
def holding(self, text: str) -> None:
self._bulleted(text.strip(), "dim", body_style=("dim",))
def tool_call(self, name: str, argument: str) -> None:
argument = argument if len(argument) <= 160 else argument[:157] + "..."
self._bulleted(self.style(name, "bold") + self.style(f"({argument})", "dim"), "green")
def tool_result(self, text: str, error: bool = False) -> None:
lines = preview(text)
elbow = self.glyphs.elbow
rendered = [f" {elbow} " + self.style(lines[0], "red" if error else "dim")]
rendered += [" " + self.style(line, "red" if error else "dim") for line in lines[1:]]
self.line("\n".join(rendered))
def diff(self, old: str, new: str, limit: int = 12) -> None:
rows = []
for sign, text, colour in (("-", old, "red"), ("+", new, "green")):
lines = text.splitlines() or [""]
for line in lines[:limit]:
rows.append(" " + self.style(f"{sign} {line}", colour))
if len(lines) > limit:
rows.append(" " + self.style(f" ... +{len(lines) - limit} lines", "dim"))
self.line("\n".join(rows))
def info(self, text: str) -> None:
self.line(self.style(text, "dim"))
def warn(self, text: str) -> None:
self.line(self.style(text, "yellow"))
def error(self, text: str) -> None:
self.line(self.style(text, "red"))
# ── input ────────────────────────────────────────────────────────────
def ask(self, prompt: str) -> str:
"""Read one line with the spinner held off the screen. EOF and
Ctrl+C both come back as an empty answer — the caller decides what
an empty answer means."""
with self._lock:
self._spinner_paused = True
self._clear_spinner_line()
self.out.flush()
try:
return self._input(prompt)
except (EOFError, KeyboardInterrupt):
self.line()
return ""
finally:
with self._lock:
self._spinner_paused = False
self._spinner_started = time.monotonic()
def confirm(self, question: str) -> str:
"""y / n / a(lways). Anything unrecognised — including Ctrl+C — is no."""
answer = self.ask(
" " + self.style(question, "yellow") + " "
+ self.style("[y]es / [n]o / [a]lways this session", "dim") + " "
).strip().lower()
if answer in ("y", "yes"):
return "yes"
if answer in ("a", "always"):
return "always"
return "no"
def read_message(self) -> str:
"""The main prompt. A trailing backslash continues onto the next line.
Raises EOFError/KeyboardInterrupt for the REPL to handle."""
prompt = self.style(self.glyphs.prompt + " ", "bold", "cyan")
lines = [self._input(prompt)]
while lines[-1].endswith("\\"):
lines[-1] = lines[-1][:-1]
lines.append(self._input(self.style(self.glyphs.ellipsis + " ", "dim")))
return "\n".join(lines)