Bolt Console: desktop app with chat list / new chat, dictation, file tools
Add a chat list and server-minted new chats on top of the desk API's
GET/POST /desk/chats:
- server_client.list_chats() / create_chat(), same shape and error
handling as list_outbox_files().
- Switching is config.set_session(id): a plain reassignment of the
SESSION_ID every call reads, persisted to session.json. The window
refuses to switch mid-turn rather than locking.
- gui/chat_list_window.py: a Chats dialog (Ctrl+K) with a QListWidget,
populated on open, with Refresh and New Chat. Network calls run as
QRunnables on the global QThreadPool and come back through Signals,
never from a slot. The header New chat (Ctrl+N) uses the same path.
- Switching clears the local transcript; there is no endpoint for a
past chat's messages. Bolt's memory is per user, so it carries over.
- Terminal: /chats, /chat <n|id>, and /new (server-minted id, local
fallback).
This commit also carries the uncommitted work since 54030a7 that turned
this Bolt-Pet fork into Bolt Console: the pet, wake word, TTS and screen
features removed; a Claude Code-style terminal client; the PySide6
window with tool cards, approvals and diffs; mic dictation through the
server's /desk/stt relay; filectl/JSON repairs; and detection of
invented tool results.
Build output (build/, dist/, Release/, *.spec) is now ignored.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
"""Bolt Console — a terminal client for Bolt's desk API."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
@@ -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())
|
||||
@@ -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 "")
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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}"
|
||||
@@ -0,0 +1,2 @@
|
||||
"""PySide6 front end. Imported only when the window is wanted, so the
|
||||
terminal client works without Qt installed."""
|
||||
@@ -0,0 +1,106 @@
|
||||
"""The seam between a turn running on a worker thread and the window.
|
||||
|
||||
`Session` and `Tools` were written against a terminal-shaped UI (tool_call,
|
||||
tool_result, confirm, assistant_chunk, …). `Bridge` is that same shape, but
|
||||
every call becomes a Qt signal — delivered to the window on the GUI thread
|
||||
through a queued connection — so the whole turn logic is shared, unchanged,
|
||||
between the terminal and the window.
|
||||
|
||||
The one call that has to *return* something is confirm(): the worker blocks on
|
||||
an Event until the user clicks Allow/Deny in the window, which calls answer()
|
||||
from the GUI thread. close() releases a pending wait so the app can quit mid
|
||||
approval without hanging the worker.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
|
||||
from PySide6.QtCore import QObject, Signal
|
||||
|
||||
|
||||
class Bridge(QObject):
|
||||
busy = Signal(bool)
|
||||
chunk = Signal(str) # one streamed sentence
|
||||
reply_ended = Signal() # the streamed block is complete
|
||||
reply = Signal(str) # a whole (non-streamed) reply
|
||||
holding_line = Signal(str) # "give me a sec" while a tool runs
|
||||
tool_started = Signal(int, str, str) # id, name, argument
|
||||
tool_output = Signal(int, str, bool) # id, text, is_error
|
||||
tool_diff = Signal(int, str, str) # id, old, new
|
||||
approval_needed = Signal(int, str) # id, question
|
||||
notice = Signal(str, str) # level (info/warn/error), text
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self._tool_id = 0
|
||||
self._answer = "no"
|
||||
self._answered = threading.Event()
|
||||
self._closed = False
|
||||
|
||||
# ── the terminal-shaped surface Session and Tools call ───────────────
|
||||
|
||||
def start_spinner(self, label: str = "Thinking") -> None:
|
||||
self.busy.emit(True)
|
||||
|
||||
def stop_spinner(self) -> None:
|
||||
self.busy.emit(False)
|
||||
|
||||
def assistant_chunk(self, sentence: str) -> None:
|
||||
if sentence.strip():
|
||||
self.chunk.emit(sentence.strip())
|
||||
|
||||
def end_reply(self) -> None:
|
||||
self.reply_ended.emit()
|
||||
|
||||
def assistant(self, text: str) -> None:
|
||||
if text.strip():
|
||||
self.reply.emit(text.strip())
|
||||
|
||||
def holding(self, text: str) -> None:
|
||||
self.holding_line.emit(text.strip())
|
||||
|
||||
def tool_call(self, name: str, argument: str) -> None:
|
||||
self._tool_id += 1
|
||||
self.tool_started.emit(self._tool_id, name, argument)
|
||||
|
||||
def tool_result(self, text: str, error: bool = False) -> None:
|
||||
self.tool_output.emit(self._tool_id, text, error)
|
||||
|
||||
def diff(self, old: str, new: str) -> None:
|
||||
self.tool_diff.emit(self._tool_id, old, new)
|
||||
|
||||
def confirm(self, question: str) -> str:
|
||||
if self._closed:
|
||||
return "no"
|
||||
self._answered.clear()
|
||||
self._answer = "no"
|
||||
self.approval_needed.emit(self._tool_id, question)
|
||||
self._answered.wait()
|
||||
return self._answer
|
||||
|
||||
def info(self, text: str) -> None:
|
||||
self.notice.emit("info", text)
|
||||
|
||||
def warn(self, text: str) -> None:
|
||||
self.notice.emit("warn", text)
|
||||
|
||||
def error(self, text: str) -> None:
|
||||
self.notice.emit("error", text)
|
||||
|
||||
def line(self, text: str = "") -> None:
|
||||
if text.strip():
|
||||
self.notice.emit("info", text.strip())
|
||||
|
||||
def style(self, text: str, *_names: str) -> str:
|
||||
return text
|
||||
|
||||
# ── called from the GUI thread ───────────────────────────────────────
|
||||
|
||||
def answer(self, value: str) -> None:
|
||||
self._answer = value if value in ("yes", "no", "always") else "no"
|
||||
self._answered.set()
|
||||
|
||||
def close(self) -> None:
|
||||
self._closed = True
|
||||
self.answer("no")
|
||||
@@ -0,0 +1,235 @@
|
||||
"""The Chats dialog: every conversation this user has with Bolt, across the
|
||||
console, the pet and the phone, newest first, and a New Chat button.
|
||||
|
||||
Network calls never run on the UI thread. Each one is a `ChatTask` on the
|
||||
global QThreadPool that calls server_client synchronously and hands the
|
||||
result back through `ChatSignals`, the same signal-based hand-off the turn
|
||||
worker uses. The list is fetched when the dialog opens and on Refresh; there
|
||||
is no polling.
|
||||
|
||||
Picking or creating a chat doesn't switch anything here. The dialog emits
|
||||
`chosen(session_id, label)` and the window does the switch, because the
|
||||
window owns the transcript that has to be cleared along with it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Callable, Optional
|
||||
|
||||
from PySide6.QtCore import QObject, QRunnable, QSize, Qt, QThreadPool, Signal
|
||||
from PySide6.QtWidgets import (
|
||||
QDialog, QHBoxLayout, QLabel, QListWidget, QListWidgetItem, QPushButton,
|
||||
QVBoxLayout, QWidget,
|
||||
)
|
||||
|
||||
from .. import config, server_client
|
||||
from ..chats import clean_preview, local_time, relative_time
|
||||
from . import icons, theme
|
||||
|
||||
# ── off-thread calls ────────────────────────────────────────────────────────
|
||||
|
||||
class ChatSignals(QObject):
|
||||
loaded = Signal(list) # chats, newest first
|
||||
created = Signal(str) # the new session_id
|
||||
failed = Signal(str, str) # operation ("list" / "create"), message
|
||||
|
||||
|
||||
class ChatTask(QRunnable):
|
||||
"""Runs one server_client call on the thread pool and reports back."""
|
||||
|
||||
def __init__(self, operation: str, call: Callable, signals: ChatSignals):
|
||||
super().__init__()
|
||||
self.operation, self.call, self.signals = operation, call, signals
|
||||
|
||||
def run(self) -> None:
|
||||
try:
|
||||
result = self.call()
|
||||
except Exception as exc: # ServerError, or anything unexpected
|
||||
self.signals.failed.emit(self.operation, str(exc))
|
||||
return
|
||||
if self.operation == "list":
|
||||
self.signals.loaded.emit(list(result))
|
||||
else:
|
||||
self.signals.created.emit(str(result))
|
||||
|
||||
|
||||
def run_in_pool(operation: str, call: Callable, signals: ChatSignals) -> None:
|
||||
QThreadPool.globalInstance().start(ChatTask(operation, call, signals))
|
||||
|
||||
|
||||
# ── the dialog ──────────────────────────────────────────────────────────────
|
||||
|
||||
class ChatRow(QWidget):
|
||||
"""Label and time on the first line, the last message under it."""
|
||||
|
||||
def __init__(self, chat: dict, current: bool, parent=None):
|
||||
super().__init__(parent)
|
||||
outer = QVBoxLayout(self)
|
||||
outer.setContentsMargins(14, 9, 14, 9)
|
||||
outer.setSpacing(2)
|
||||
top = QHBoxLayout()
|
||||
top.setSpacing(8)
|
||||
title = QLabel(str(chat.get("label") or chat.get("session_id") or "Untitled"))
|
||||
title.setObjectName("ChatTitle")
|
||||
top.addWidget(title, 1)
|
||||
if current:
|
||||
badge = QLabel("Current")
|
||||
badge.setObjectName("Pill")
|
||||
badge.setProperty("kind", "waiting")
|
||||
top.addWidget(badge)
|
||||
when = QLabel(relative_time(chat.get("last_active", "")))
|
||||
when.setObjectName("ChatTime")
|
||||
top.addWidget(when)
|
||||
outer.addLayout(top)
|
||||
preview = QLabel(clean_preview(chat.get("last_message", "")) or "—")
|
||||
preview.setObjectName("ChatPreview")
|
||||
outer.addWidget(preview)
|
||||
for widget in (title, when, preview):
|
||||
widget.setAttribute(Qt.WA_TransparentForMouseEvents)
|
||||
|
||||
|
||||
class ChatListDialog(QDialog):
|
||||
"""`chosen(session_id, label)` fires for a picked chat and for a newly
|
||||
created one (label "New chat"); the window does the actual switch."""
|
||||
|
||||
chosen = Signal(str, str)
|
||||
|
||||
def __init__(self, parent=None, list_chats: Callable = None, create_chat: Callable = None):
|
||||
super().__init__(parent)
|
||||
self.setObjectName("ChatDialog")
|
||||
self.setWindowTitle("Chats")
|
||||
self.setModal(True)
|
||||
self.resize(560, 580)
|
||||
self._list_chats = list_chats or server_client.list_chats
|
||||
self._create_chat = create_chat or server_client.create_chat
|
||||
self.signals = ChatSignals()
|
||||
self.signals.loaded.connect(self._populate)
|
||||
self.signals.created.connect(self._created)
|
||||
self.signals.failed.connect(self._failed)
|
||||
|
||||
col = QVBoxLayout(self)
|
||||
col.setContentsMargins(18, 18, 18, 16)
|
||||
col.setSpacing(12)
|
||||
head = QHBoxLayout()
|
||||
head.setSpacing(10)
|
||||
head.addWidget(icons.IconLabel("bolt", theme.ACCENT_INK, 24, tile=theme.ACCENT))
|
||||
heading = QLabel("Chats")
|
||||
heading.setObjectName("Wordmark")
|
||||
head.addWidget(heading)
|
||||
head.addStretch(1)
|
||||
self.status = QLabel("")
|
||||
self.status.setObjectName("Hint")
|
||||
head.addWidget(self.status)
|
||||
col.addLayout(head)
|
||||
|
||||
sub = QLabel("Every conversation with Bolt, from any device. A new chat starts with "
|
||||
"fresh history; Bolt still remembers you.")
|
||||
sub.setObjectName("HeroSub")
|
||||
sub.setWordWrap(True)
|
||||
col.addWidget(sub)
|
||||
|
||||
self.list = QListWidget()
|
||||
self.list.setObjectName("ChatList")
|
||||
self.list.setUniformItemSizes(False)
|
||||
self.list.setVerticalScrollMode(QListWidget.ScrollPerPixel)
|
||||
self.list.itemActivated.connect(self._open_item) # Enter / double-click
|
||||
self.list.currentItemChanged.connect(lambda *_: self._sync_buttons())
|
||||
col.addWidget(self.list, 1)
|
||||
|
||||
buttons = QHBoxLayout()
|
||||
buttons.setSpacing(8)
|
||||
self.refresh_button = QPushButton("Refresh")
|
||||
self.refresh_button.setObjectName("Ghost")
|
||||
self.refresh_button.clicked.connect(self.refresh)
|
||||
buttons.addWidget(self.refresh_button)
|
||||
buttons.addStretch(1)
|
||||
self.new_button = QPushButton("New Chat")
|
||||
self.new_button.setObjectName("Secondary")
|
||||
self.new_button.clicked.connect(self.new_chat)
|
||||
buttons.addWidget(self.new_button)
|
||||
self.open_button = QPushButton("Open")
|
||||
self.open_button.setObjectName("Primary")
|
||||
self.open_button.clicked.connect(lambda: self._open_item(self.list.currentItem()))
|
||||
buttons.addWidget(self.open_button)
|
||||
col.addLayout(buttons)
|
||||
for button in (self.refresh_button, self.new_button, self.open_button):
|
||||
button.setCursor(Qt.PointingHandCursor)
|
||||
self._busy = False
|
||||
self._sync_buttons()
|
||||
|
||||
# ── loading ──────────────────────────────────────────────────────────
|
||||
|
||||
def showEvent(self, event) -> None:
|
||||
super().showEvent(event)
|
||||
theme.dark_title_bar(self) # same caption colour as the main window on Windows
|
||||
self.refresh()
|
||||
|
||||
def refresh(self) -> None:
|
||||
if self._busy:
|
||||
return
|
||||
self._set_busy(True, "Loading…")
|
||||
run_in_pool("list", self._list_chats, self.signals)
|
||||
|
||||
def _populate(self, chats: list) -> None:
|
||||
self._set_busy(False, "")
|
||||
self.list.clear()
|
||||
current = None
|
||||
for chat in chats:
|
||||
session_id = str(chat.get("session_id") or "")
|
||||
if not session_id:
|
||||
continue
|
||||
is_current = session_id == config.SESSION_ID
|
||||
item = QListWidgetItem()
|
||||
item.setData(Qt.UserRole, session_id)
|
||||
item.setData(Qt.UserRole + 1, str(chat.get("label") or session_id))
|
||||
item.setToolTip(f"{session_id}\nLast active {local_time(chat.get('last_active', ''))}")
|
||||
row = ChatRow(chat, is_current)
|
||||
item.setSizeHint(QSize(0, row.sizeHint().height()))
|
||||
self.list.addItem(item)
|
||||
self.list.setItemWidget(item, row)
|
||||
if is_current:
|
||||
current = item
|
||||
if self.list.count() == 0:
|
||||
self.status.setText("No chats yet")
|
||||
self.list.setCurrentItem(current or self.list.item(0))
|
||||
self._sync_buttons()
|
||||
|
||||
def _failed(self, operation: str, message: str) -> None:
|
||||
self._set_busy(False, "")
|
||||
what = "load chats" if operation == "list" else "start a new chat"
|
||||
self.status.setText(f"Couldn't {what}")
|
||||
self.status.setToolTip(message)
|
||||
self.status.setStyleSheet(f"color: {theme.RED};")
|
||||
|
||||
# ── actions ──────────────────────────────────────────────────────────
|
||||
|
||||
def new_chat(self) -> None:
|
||||
if self._busy:
|
||||
return
|
||||
self._set_busy(True, "Starting a new chat…")
|
||||
run_in_pool("create", self._create_chat, self.signals)
|
||||
|
||||
def _created(self, session_id: str) -> None:
|
||||
# A new chat isn't listed until it's used, so there's nothing to show:
|
||||
# hand it to the window and close.
|
||||
self._set_busy(False, "")
|
||||
self.chosen.emit(session_id, "New chat")
|
||||
self.accept()
|
||||
|
||||
def _open_item(self, item: Optional[QListWidgetItem]) -> None:
|
||||
if item is None or self._busy:
|
||||
return
|
||||
self.chosen.emit(item.data(Qt.UserRole), item.data(Qt.UserRole + 1))
|
||||
self.accept()
|
||||
|
||||
def _set_busy(self, busy: bool, status: str) -> None:
|
||||
self._busy = busy
|
||||
self.status.setText(status)
|
||||
self.status.setToolTip("")
|
||||
self.status.setStyleSheet("")
|
||||
self._sync_buttons()
|
||||
|
||||
def _sync_buttons(self) -> None:
|
||||
self.refresh_button.setEnabled(not self._busy)
|
||||
self.new_button.setEnabled(not self._busy)
|
||||
self.open_button.setEnabled(not self._busy and self.list.currentItem() is not None)
|
||||
@@ -0,0 +1,219 @@
|
||||
"""Icons drawn with QPainter rather than shipped as files or borrowed from an
|
||||
emoji font — crisp at any DPI, recolourable, and no assets to lose.
|
||||
|
||||
`paint(painter, name, rect, colour)` draws into a rect; `icon(name, colour)`
|
||||
wraps that as a QIcon; `IconLabel` is a tiny widget that shows one.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from PySide6.QtCore import QBuffer, QByteArray, QIODevice, QPointF, QRectF, QSize, Qt
|
||||
from PySide6.QtGui import QColor, QIcon, QImage, QPainter, QPainterPath, QPen, QPixmap
|
||||
from PySide6.QtWidgets import QWidget
|
||||
|
||||
|
||||
def _pen(colour: QColor, width: float) -> QPen:
|
||||
pen = QPen(colour, width)
|
||||
pen.setCapStyle(Qt.RoundCap)
|
||||
pen.setJoinStyle(Qt.RoundJoin)
|
||||
return pen
|
||||
|
||||
|
||||
def paint(p: QPainter, name: str, rect: QRectF, colour) -> None:
|
||||
colour = QColor(colour)
|
||||
p.save()
|
||||
p.setRenderHint(QPainter.Antialiasing)
|
||||
# Everything below is drawn on a 24x24 grid, then scaled into *rect*.
|
||||
side = min(rect.width(), rect.height())
|
||||
p.translate(rect.center().x() - side / 2, rect.center().y() - side / 2)
|
||||
p.scale(side / 24.0, side / 24.0)
|
||||
stroke = _pen(colour, 1.9)
|
||||
|
||||
if name == "bolt":
|
||||
path = QPainterPath()
|
||||
for i, (x, y) in enumerate([(13.5, 2), (4.5, 13.5), (11, 13.5), (9.5, 22),
|
||||
(19.5, 9.5), (13, 9.5), (13.5, 2)]):
|
||||
path.moveTo(x, y) if i == 0 else path.lineTo(x, y)
|
||||
p.setPen(Qt.NoPen)
|
||||
p.setBrush(colour)
|
||||
p.drawPath(path)
|
||||
elif name == "send":
|
||||
p.setPen(_pen(colour, 2.4))
|
||||
p.drawLine(QPointF(12, 19), QPointF(12, 5.5))
|
||||
p.drawLine(QPointF(6.5, 11), QPointF(12, 5.5))
|
||||
p.drawLine(QPointF(17.5, 11), QPointF(12, 5.5))
|
||||
elif name == "folder":
|
||||
p.setPen(stroke)
|
||||
path = QPainterPath()
|
||||
path.moveTo(3, 7)
|
||||
path.lineTo(3, 18.5)
|
||||
path.quadTo(3, 20, 4.5, 20)
|
||||
path.lineTo(19.5, 20)
|
||||
path.quadTo(21, 20, 21, 18.5)
|
||||
path.lineTo(21, 9.5)
|
||||
path.quadTo(21, 8, 19.5, 8)
|
||||
path.lineTo(11.5, 8)
|
||||
path.lineTo(9.5, 5.5)
|
||||
path.lineTo(4.5, 5.5)
|
||||
path.quadTo(3, 5.5, 3, 7)
|
||||
p.drawPath(path)
|
||||
elif name == "shell":
|
||||
p.setPen(stroke)
|
||||
p.drawRoundedRect(QRectF(2.5, 4, 19, 16), 3, 3)
|
||||
p.drawLine(QPointF(7, 10), QPointF(10, 12.5))
|
||||
p.drawLine(QPointF(10, 12.5), QPointF(7, 15))
|
||||
p.drawLine(QPointF(12.5, 15.5), QPointF(17, 15.5))
|
||||
elif name in ("read", "write", "edit"):
|
||||
p.setPen(stroke)
|
||||
page = QPainterPath()
|
||||
page.moveTo(14, 2.5)
|
||||
page.lineTo(6, 2.5)
|
||||
page.quadTo(4.5, 2.5, 4.5, 4)
|
||||
page.lineTo(4.5, 20)
|
||||
page.quadTo(4.5, 21.5, 6, 21.5)
|
||||
page.lineTo(18, 21.5)
|
||||
page.quadTo(19.5, 21.5, 19.5, 20)
|
||||
page.lineTo(19.5, 8)
|
||||
page.closeSubpath()
|
||||
p.drawPath(page)
|
||||
p.drawLine(QPointF(14, 2.5), QPointF(14, 8))
|
||||
p.drawLine(QPointF(14, 8), QPointF(19.5, 8))
|
||||
if name == "read":
|
||||
for y in (12, 15, 18):
|
||||
p.drawLine(QPointF(8, y), QPointF(16, y))
|
||||
elif name == "write":
|
||||
p.drawLine(QPointF(12, 11.5), QPointF(12, 18))
|
||||
p.drawLine(QPointF(8.75, 14.75), QPointF(15.25, 14.75))
|
||||
else:
|
||||
p.drawLine(QPointF(8, 18), QPointF(15.5, 10.5))
|
||||
p.drawLine(QPointF(8, 18), QPointF(8, 15.5))
|
||||
p.drawLine(QPointF(8, 18), QPointF(10.5, 18))
|
||||
elif name == "list":
|
||||
p.setPen(stroke)
|
||||
for y in (6.5, 12, 17.5):
|
||||
p.drawPoint(QPointF(4.5, y))
|
||||
p.drawLine(QPointF(8.5, y), QPointF(20, y))
|
||||
elif name == "blocked":
|
||||
p.setPen(stroke)
|
||||
p.drawEllipse(QRectF(3.5, 3.5, 17, 17))
|
||||
p.drawLine(QPointF(7, 17), QPointF(17, 7))
|
||||
elif name == "chevron":
|
||||
p.setPen(stroke)
|
||||
p.drawLine(QPointF(9, 6), QPointF(15, 12))
|
||||
p.drawLine(QPointF(15, 12), QPointF(9, 18))
|
||||
elif name == "chats":
|
||||
p.setPen(stroke)
|
||||
bubble = QPainterPath()
|
||||
bubble.addRoundedRect(QRectF(3, 4, 18, 13), 4, 4)
|
||||
p.drawPath(bubble)
|
||||
p.drawLine(QPointF(8, 17), QPointF(7, 21))
|
||||
p.drawLine(QPointF(7, 21), QPointF(12, 17))
|
||||
for x in (8, 12, 16):
|
||||
p.drawPoint(QPointF(x, 10.5))
|
||||
elif name == "mic":
|
||||
p.setPen(stroke)
|
||||
p.drawRoundedRect(QRectF(9, 3, 6, 11.5), 3, 3)
|
||||
arc = QPainterPath()
|
||||
arc.moveTo(5.5, 11)
|
||||
arc.cubicTo(5.5, 19, 18.5, 19, 18.5, 11)
|
||||
p.drawPath(arc)
|
||||
p.drawLine(QPointF(12, 17), QPointF(12, 21))
|
||||
elif name == "stop":
|
||||
p.setPen(Qt.NoPen)
|
||||
p.setBrush(colour)
|
||||
p.drawRoundedRect(QRectF(7, 7, 10, 10), 2, 2)
|
||||
elif name == "plus":
|
||||
p.setPen(_pen(colour, 2.1))
|
||||
p.drawLine(QPointF(12, 5), QPointF(12, 19))
|
||||
p.drawLine(QPointF(5, 12), QPointF(19, 12))
|
||||
elif name == "file":
|
||||
paint(p, "read", QRectF(0, 0, 24, 24), colour)
|
||||
p.restore()
|
||||
|
||||
|
||||
def icon(name: str, colour, size: int = 32) -> QIcon:
|
||||
pixmap = QPixmap(size, size)
|
||||
pixmap.fill(Qt.transparent)
|
||||
painter = QPainter(pixmap)
|
||||
paint(painter, name, QRectF(0, 0, size, size), colour)
|
||||
painter.end()
|
||||
return QIcon(pixmap)
|
||||
|
||||
|
||||
APP_ICON_SIZES = (16, 24, 32, 48, 64, 128, 256)
|
||||
|
||||
|
||||
def app_icon_image(size: int) -> QImage:
|
||||
"""An amber rounded square with a dark bolt, at *size* pixels. The one
|
||||
drawing behind both the window icon and the .exe's icon (write_ico)."""
|
||||
image = QImage(size, size, QImage.Format_ARGB32_Premultiplied)
|
||||
image.fill(Qt.transparent)
|
||||
p = QPainter(image)
|
||||
p.setRenderHint(QPainter.Antialiasing)
|
||||
p.setPen(Qt.NoPen)
|
||||
p.setBrush(QColor("#f5b83d"))
|
||||
p.drawRoundedRect(QRectF(0, 0, size, size), size * 0.26, size * 0.26)
|
||||
inset = size * 0.18
|
||||
paint(p, "bolt", QRectF(inset, inset, size - 2 * inset, size - 2 * inset), "#1a1305")
|
||||
p.end()
|
||||
return image
|
||||
|
||||
|
||||
def app_icon() -> QIcon:
|
||||
"""The window/taskbar icon."""
|
||||
result = QIcon()
|
||||
for size in APP_ICON_SIZES:
|
||||
result.addPixmap(QPixmap.fromImage(app_icon_image(size)))
|
||||
return result
|
||||
|
||||
|
||||
def write_ico(path) -> None:
|
||||
"""Save the app icon as a multi-size Windows .ico (PNG-compressed entries,
|
||||
which Windows Vista+ reads at every size), for the frozen .exe."""
|
||||
import struct
|
||||
|
||||
blobs = []
|
||||
for size in APP_ICON_SIZES:
|
||||
data = QByteArray()
|
||||
buffer = QBuffer(data)
|
||||
buffer.open(QIODevice.WriteOnly)
|
||||
app_icon_image(size).save(buffer, "PNG")
|
||||
buffer.close()
|
||||
blobs.append((size, bytes(data)))
|
||||
|
||||
header = struct.pack("<HHH", 0, 1, len(blobs)) # reserved, type 1 = icon, count
|
||||
offset = len(header) + 16 * len(blobs)
|
||||
entries, payload = b"", b""
|
||||
for size, blob in blobs:
|
||||
edge = 0 if size >= 256 else size # 0 means 256 in the ICO format
|
||||
entries += struct.pack("<BBBBHHII", edge, edge, 0, 0, 1, 32, len(blob), offset + len(payload))
|
||||
payload += blob
|
||||
with open(path, "wb") as handle:
|
||||
handle.write(header + entries + payload)
|
||||
|
||||
|
||||
class IconLabel(QWidget):
|
||||
"""A fixed-size painted icon, optionally on a rounded tile."""
|
||||
|
||||
def __init__(self, name: str, colour, size: int = 18, tile=None, parent=None):
|
||||
super().__init__(parent)
|
||||
self._name, self._colour, self._tile = name, QColor(colour), tile
|
||||
self.setFixedSize(QSize(size, size))
|
||||
|
||||
def set_icon(self, name: str, colour=None) -> None:
|
||||
self._name = name
|
||||
if colour is not None:
|
||||
self._colour = QColor(colour)
|
||||
self.update()
|
||||
|
||||
def paintEvent(self, _event) -> None:
|
||||
p = QPainter(self)
|
||||
p.setRenderHint(QPainter.Antialiasing)
|
||||
rect = QRectF(self.rect())
|
||||
if self._tile is not None:
|
||||
p.setPen(Qt.NoPen)
|
||||
p.setBrush(QColor(self._tile))
|
||||
p.drawRoundedRect(rect, rect.width() * 0.28, rect.width() * 0.28)
|
||||
pad = rect.width() * 0.22
|
||||
rect = rect.adjusted(pad, pad, -pad, -pad)
|
||||
paint(p, self._name, rect, self._colour)
|
||||
@@ -0,0 +1,196 @@
|
||||
"""Colours, fonts and the application stylesheet.
|
||||
|
||||
One dark theme, tuned by hand: near-black charcoal surfaces a step apart, a
|
||||
single warm accent (the bolt's amber), and colour used only where it carries
|
||||
meaning — green for done, red for failed, amber for "needs you".
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
|
||||
from PySide6.QtGui import QColor, QFont, QFontDatabase, QPalette
|
||||
|
||||
BG = "#0e1014" # window
|
||||
SURFACE = "#15181e" # cards, composer
|
||||
SURFACE_HI = "#1b1f27" # hover / user bubble
|
||||
BORDER = "#252a34"
|
||||
BORDER_HI = "#343b48"
|
||||
TEXT = "#e7e9ee"
|
||||
TEXT_DIM = "#9aa1ae"
|
||||
TEXT_FAINT = "#6b7280"
|
||||
ACCENT = "#f5b83d" # the bolt
|
||||
ACCENT_HI = "#ffc95c"
|
||||
ACCENT_INK = "#1a1305" # text on accent
|
||||
GREEN = "#4ec98a"
|
||||
RED = "#f06a6a"
|
||||
BLUE = "#7aa7ff"
|
||||
CODE = "#e8c98f" # inline code in replies
|
||||
DIFF_ADD_BG = "#12291d"
|
||||
DIFF_DEL_BG = "#301719"
|
||||
|
||||
RADIUS = 12
|
||||
COLUMN_WIDTH = 820 # the transcript's reading column
|
||||
|
||||
|
||||
def _first_available(families, fallback: str) -> str:
|
||||
installed = set(QFontDatabase.families())
|
||||
for family in families:
|
||||
if family in installed:
|
||||
return family
|
||||
return fallback
|
||||
|
||||
|
||||
def ui_font_family() -> str:
|
||||
return _first_available(
|
||||
["Segoe UI Variable Text", "Inter", "SF Pro Text", "Segoe UI", "Cantarell", "Ubuntu"],
|
||||
QFont().defaultFamily(),
|
||||
)
|
||||
|
||||
|
||||
def mono_font_family() -> str:
|
||||
return _first_available(
|
||||
["Cascadia Code", "Cascadia Mono", "JetBrains Mono", "SF Mono", "Consolas",
|
||||
"DejaVu Sans Mono", "Menlo"],
|
||||
"monospace",
|
||||
)
|
||||
|
||||
|
||||
def mono_font(size: float = 9.5) -> QFont:
|
||||
font = QFont(mono_font_family())
|
||||
font.setPointSizeF(size)
|
||||
font.setStyleHint(QFont.Monospace)
|
||||
return font
|
||||
|
||||
|
||||
def palette() -> QPalette:
|
||||
pal = QPalette()
|
||||
for role, colour in (
|
||||
(QPalette.Window, BG), (QPalette.Base, SURFACE), (QPalette.AlternateBase, SURFACE_HI),
|
||||
(QPalette.Text, TEXT), (QPalette.WindowText, TEXT), (QPalette.ButtonText, TEXT),
|
||||
(QPalette.Button, SURFACE), (QPalette.Highlight, "#3a3222"),
|
||||
(QPalette.HighlightedText, TEXT), (QPalette.Link, BLUE),
|
||||
(QPalette.PlaceholderText, TEXT_FAINT), (QPalette.ToolTipBase, SURFACE_HI),
|
||||
(QPalette.ToolTipText, TEXT),
|
||||
):
|
||||
pal.setColor(role, QColor(colour))
|
||||
return pal
|
||||
|
||||
|
||||
def stylesheet() -> str:
|
||||
mono = mono_font_family()
|
||||
return f"""
|
||||
QWidget {{ color: {TEXT}; font-size: 10.5pt; }}
|
||||
QMainWindow, #Root {{ background: {BG}; }}
|
||||
QToolTip {{ background: {SURFACE_HI}; color: {TEXT}; border: 1px solid {BORDER_HI};
|
||||
border-radius: 6px; padding: 5px 8px; }}
|
||||
|
||||
/* header */
|
||||
#Header {{ background: {BG}; border-bottom: 1px solid {BORDER}; }}
|
||||
#Wordmark {{ font-size: 12pt; font-weight: 600; letter-spacing: 0.3px; }}
|
||||
#WordmarkSub {{ color: {TEXT_FAINT}; font-size: 9pt; }}
|
||||
#Chip {{ background: {SURFACE}; border: 1px solid {BORDER}; border-radius: 15px;
|
||||
padding: 5px 12px 5px 30px; color: {TEXT_DIM}; text-align: left; }}
|
||||
#Chip:hover {{ border-color: {BORDER_HI}; color: {TEXT}; background: {SURFACE_HI}; }}
|
||||
#Chip:disabled {{ color: {TEXT_FAINT}; }}
|
||||
#Segment {{ background: {SURFACE}; border: 1px solid {BORDER}; border-radius: 15px; }}
|
||||
#SegmentButton {{ background: transparent; border: none; border-radius: 12px;
|
||||
padding: 4px 14px; color: {TEXT_DIM}; font-weight: 500; }}
|
||||
#SegmentButton:hover {{ color: {TEXT}; }}
|
||||
#SegmentButton:checked {{ background: {SURFACE_HI}; color: {TEXT}; }}
|
||||
#SegmentButton[danger="true"]:checked {{ background: #3a2c12; color: {ACCENT_HI}; }}
|
||||
|
||||
/* transcript */
|
||||
QScrollArea {{ background: transparent; border: none; }}
|
||||
#Transcript {{ background: {BG}; }}
|
||||
QScrollBar:vertical {{ background: transparent; width: 10px; margin: 4px 2px; }}
|
||||
QScrollBar::handle:vertical {{ background: {BORDER}; border-radius: 3px; min-height: 40px; }}
|
||||
QScrollBar::handle:vertical:hover {{ background: {BORDER_HI}; }}
|
||||
QScrollBar::add-line, QScrollBar::sub-line, QScrollBar::add-page, QScrollBar::sub-page {{
|
||||
background: none; height: 0; }}
|
||||
QScrollBar:horizontal {{ height: 0; }}
|
||||
|
||||
#UserBubble {{ background: {SURFACE_HI}; border: 1px solid {BORDER}; border-radius: 16px; }}
|
||||
#AssistantText {{ color: {TEXT}; }}
|
||||
#Holding {{ color: {TEXT_FAINT}; font-style: italic; }}
|
||||
#Notice {{ color: {TEXT_DIM}; font-size: 9.5pt; }}
|
||||
#NoticeError {{ color: {RED}; }}
|
||||
|
||||
#ToolCard {{ background: {SURFACE}; border: 1px solid {BORDER}; border-radius: {RADIUS}px; }}
|
||||
#ToolCard[state="waiting"] {{ border-color: #5a4418; }}
|
||||
#ToolHeader {{ background: transparent; border: none; border-radius: {RADIUS}px; }}
|
||||
#ToolHeader:hover {{ background: {SURFACE_HI}; }}
|
||||
#ToolName {{ font-weight: 600; }}
|
||||
#ToolArg {{ color: {TEXT_DIM}; font-family: "{mono}"; font-size: 9.5pt; }}
|
||||
#ToolOutput {{ background: transparent; color: {TEXT_DIM}; border: none; border-top: 1px solid {BORDER};
|
||||
font-family: "{mono}"; font-size: 9.2pt; padding: 10px 14px; }}
|
||||
#Pill {{ border-radius: 9px; padding: 1px 9px; font-size: 8.5pt; font-weight: 600; }}
|
||||
#Pill[kind="running"] {{ background: #1c2433; color: {BLUE}; }}
|
||||
#Pill[kind="ok"] {{ background: #13271d; color: {GREEN}; }}
|
||||
#Pill[kind="error"] {{ background: #2e1618; color: {RED}; }}
|
||||
#Pill[kind="waiting"] {{ background: #33280f; color: {ACCENT}; }}
|
||||
#Pill[kind="declined"] {{ background: #22252c; color: {TEXT_DIM}; }}
|
||||
|
||||
#ApprovalBar {{ border-top: 1px solid {BORDER}; background: transparent; }}
|
||||
#ApprovalQuestion {{ color: {TEXT}; }}
|
||||
#Primary {{ background: {ACCENT}; color: {ACCENT_INK}; border: none; border-radius: 8px;
|
||||
padding: 6px 16px; font-weight: 600; }}
|
||||
#Primary:hover {{ background: {ACCENT_HI}; }}
|
||||
#Secondary {{ background: {SURFACE_HI}; color: {TEXT}; border: 1px solid {BORDER_HI};
|
||||
border-radius: 8px; padding: 6px 14px; }}
|
||||
#Secondary:hover {{ border-color: {TEXT_FAINT}; }}
|
||||
#Ghost {{ background: transparent; color: {TEXT_DIM}; border: none; padding: 6px 10px; }}
|
||||
#Ghost:hover {{ color: {TEXT}; }}
|
||||
|
||||
/* empty state */
|
||||
#Hero {{ font-size: 19pt; font-weight: 600; }}
|
||||
#HeroSub {{ color: {TEXT_DIM}; }}
|
||||
#Suggestion {{ background: {SURFACE}; border: 1px solid {BORDER}; border-radius: 12px;
|
||||
padding: 12px 14px; color: {TEXT_DIM}; text-align: left; }}
|
||||
#Suggestion:hover {{ border-color: {BORDER_HI}; color: {TEXT}; background: {SURFACE_HI}; }}
|
||||
|
||||
/* composer */
|
||||
#Composer {{ background: {SURFACE}; border: 1px solid {BORDER}; border-radius: 18px; }}
|
||||
#Composer[focused="true"] {{ border-color: {BORDER_HI}; }}
|
||||
#ComposerEdit {{ background: transparent; border: none; padding: 0; selection-background-color: #3a3222; }}
|
||||
#Hint {{ color: {TEXT_FAINT}; font-size: 8.8pt; }}
|
||||
|
||||
/* chats dialog */
|
||||
QDialog#ChatDialog {{ background: {BG}; }}
|
||||
#ChatList {{ background: {SURFACE}; border: 1px solid {BORDER}; border-radius: {RADIUS}px;
|
||||
padding: 4px; outline: none; }}
|
||||
#ChatList::item {{ border-radius: 9px; margin: 1px 0; }}
|
||||
#ChatList::item:hover {{ background: {SURFACE_HI}; }}
|
||||
#ChatList::item:selected {{ background: #262017; border: 1px solid #4a3a18; }}
|
||||
#ChatTitle {{ font-weight: 600; }}
|
||||
#ChatTime {{ color: {TEXT_FAINT}; font-size: 9pt; }}
|
||||
#ChatPreview {{ color: {TEXT_DIM}; font-size: 9.5pt; }}
|
||||
"""
|
||||
|
||||
|
||||
def dark_title_bar(window) -> None:
|
||||
"""Windows 11: dark caption, painted the same colour as the window so the
|
||||
title bar and the app read as one surface. Silently does nothing on other
|
||||
platforms or older Windows."""
|
||||
if sys.platform != "win32":
|
||||
return
|
||||
try:
|
||||
import ctypes
|
||||
from ctypes import wintypes
|
||||
|
||||
hwnd = wintypes.HWND(int(window.winId()))
|
||||
dwm = ctypes.windll.dwmapi
|
||||
|
||||
def attr(key: int, value: int) -> None:
|
||||
data = ctypes.c_int(value)
|
||||
dwm.DwmSetWindowAttribute(hwnd, key, ctypes.byref(data), ctypes.sizeof(data))
|
||||
|
||||
attr(20, 1) # DWMWA_USE_IMMERSIVE_DARK_MODE
|
||||
colour = BG.lstrip("#")
|
||||
r, g, b = int(colour[0:2], 16), int(colour[2:4], 16), int(colour[4:6], 16)
|
||||
attr(35, (b << 16) | (g << 8) | r) # DWMWA_CAPTION_COLOR (COLORREF is 0x00BBGGRR)
|
||||
border = BORDER.lstrip("#")
|
||||
r, g, b = int(border[0:2], 16), int(border[2:4], 16), int(border[4:6], 16)
|
||||
attr(34, (b << 16) | (g << 8) | r) # DWMWA_BORDER_COLOR
|
||||
except Exception:
|
||||
pass
|
||||
@@ -0,0 +1,699 @@
|
||||
"""The pieces the window is built from: messages, tool cards, the composer,
|
||||
the header controls and the empty state. Styling lives in theme.stylesheet();
|
||||
these set object names and properties for it to hook onto."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import html
|
||||
import math
|
||||
import re
|
||||
|
||||
from PySide6.QtCore import QEvent, QRectF, QSize, Qt, QTimer, Signal
|
||||
from PySide6.QtGui import QColor, QFontDatabase, QPainter, QTextCursor, QTextDocument, QTextOption
|
||||
from PySide6.QtWidgets import (
|
||||
QButtonGroup, QFrame, QHBoxLayout, QLabel, QPlainTextEdit, QPushButton,
|
||||
QSizePolicy, QVBoxLayout, QWidget,
|
||||
)
|
||||
|
||||
from . import icons, theme
|
||||
|
||||
# Tool name (as tools.py reports it) -> icon.
|
||||
TOOL_ICONS = {
|
||||
"Shell": "shell", "Read": "read", "List": "list", "Write": "write",
|
||||
"Edit": "edit", "Pet": "blocked", "File": "file",
|
||||
}
|
||||
COLLAPSED_LINES = 6
|
||||
|
||||
|
||||
def _label(text: str = "", name: str = "", wrap: bool = True, selectable: bool = False) -> QLabel:
|
||||
label = QLabel(text)
|
||||
if name:
|
||||
label.setObjectName(name)
|
||||
label.setWordWrap(wrap)
|
||||
if selectable:
|
||||
label.setTextInteractionFlags(Qt.TextSelectableByMouse | Qt.LinksAccessibleByMouse)
|
||||
label.setCursor(Qt.IBeamCursor)
|
||||
return label
|
||||
|
||||
|
||||
def repolish(widget: QWidget) -> None:
|
||||
"""Re-apply the stylesheet after a dynamic property changed."""
|
||||
widget.style().unpolish(widget)
|
||||
widget.style().polish(widget)
|
||||
widget.update()
|
||||
|
||||
|
||||
def markdown_html(text: str, font) -> str:
|
||||
"""Markdown -> the HTML QLabel renders, with code in the app's mono font
|
||||
and a warm tint instead of Qt's generic 'monospace' (usually Courier)."""
|
||||
doc = QTextDocument()
|
||||
doc.setDefaultFont(font)
|
||||
doc.setMarkdown(text)
|
||||
out = doc.toHtml()
|
||||
code_style = f"font-family:'{theme.mono_font_family()}'; color:{theme.CODE};"
|
||||
# Qt writes the platform's fixed font for code: 'Courier New' on Windows,
|
||||
# 'monospace' elsewhere.
|
||||
for fixed in {QFontDatabase.systemFont(QFontDatabase.FixedFont).family(), "monospace", "Courier New"}:
|
||||
out = out.replace(f"font-family:'{fixed}';", code_style)
|
||||
return out.replace("font-family:'Sans Serif';", "")
|
||||
|
||||
|
||||
# ── transcript entries ──────────────────────────────────────────────────────
|
||||
|
||||
class UserMessage(QWidget):
|
||||
def __init__(self, text: str, parent=None):
|
||||
super().__init__(parent)
|
||||
row = QHBoxLayout(self)
|
||||
row.setContentsMargins(80, 6, 0, 6)
|
||||
row.addStretch(1)
|
||||
bubble = QFrame()
|
||||
bubble.setObjectName("UserBubble")
|
||||
inner = QVBoxLayout(bubble)
|
||||
inner.setContentsMargins(16, 10, 16, 10)
|
||||
label = _label(text, selectable=True)
|
||||
label.setTextFormat(Qt.PlainText)
|
||||
# A word-wrapped QLabel's size hint is a guess that wraps short text
|
||||
# early; give it the width its longest line actually needs.
|
||||
widest = max(label.fontMetrics().horizontalAdvance(line) for line in text.splitlines() or [""])
|
||||
label.setMinimumWidth(min(widest + 4, 588))
|
||||
inner.addWidget(label)
|
||||
bubble.setMaximumWidth(620)
|
||||
bubble.setSizePolicy(QSizePolicy.Maximum, QSizePolicy.Preferred)
|
||||
row.addWidget(bubble)
|
||||
|
||||
|
||||
class AssistantMessage(QWidget):
|
||||
"""Bolt's reply. Streamed sentences are appended; a sentence that starts a
|
||||
list item or heading goes on its own line so the markdown still renders."""
|
||||
|
||||
_BLOCK_START = re.compile(r"^(?:[-*+] |\d+[.)] |#{1,6} |```|>)")
|
||||
|
||||
def __init__(self, text: str = "", parent=None):
|
||||
super().__init__(parent)
|
||||
self._text = ""
|
||||
row = QHBoxLayout(self)
|
||||
row.setContentsMargins(0, 8, 0, 8)
|
||||
row.setSpacing(14)
|
||||
avatar = icons.IconLabel("bolt", theme.ACCENT_INK, 26, tile=theme.ACCENT)
|
||||
row.addWidget(avatar, 0, Qt.AlignTop)
|
||||
self.label = _label(name="AssistantText", selectable=True)
|
||||
self.label.setTextFormat(Qt.RichText)
|
||||
self.label.setOpenExternalLinks(True)
|
||||
self.label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred)
|
||||
row.addWidget(self.label, 1)
|
||||
if text:
|
||||
self.set_text(text)
|
||||
|
||||
def set_text(self, text: str) -> None:
|
||||
self._text = text
|
||||
self.label.setText(markdown_html(text, self.label.font()))
|
||||
|
||||
def append(self, sentence: str) -> None:
|
||||
if not self._text:
|
||||
joiner = ""
|
||||
elif self._BLOCK_START.match(sentence) or self._text.endswith("\n"):
|
||||
joiner = "\n\n" if not self._text.endswith("\n") else ""
|
||||
else:
|
||||
joiner = " "
|
||||
self.set_text(self._text + joiner + sentence)
|
||||
|
||||
def text(self) -> str:
|
||||
return self._text
|
||||
|
||||
|
||||
class HoldingLine(QWidget):
|
||||
def __init__(self, text: str, parent=None):
|
||||
super().__init__(parent)
|
||||
row = QHBoxLayout(self)
|
||||
row.setContentsMargins(40, 0, 0, 2)
|
||||
row.addWidget(_label(text, "Holding"))
|
||||
|
||||
|
||||
class Notice(QWidget):
|
||||
def __init__(self, level: str, text: str, parent=None):
|
||||
super().__init__(parent)
|
||||
row = QHBoxLayout(self)
|
||||
row.setContentsMargins(40, 4, 0, 4)
|
||||
label = _label(text, "NoticeError" if level == "error" else "Notice", selectable=True)
|
||||
label.setTextFormat(Qt.PlainText)
|
||||
if level == "warn":
|
||||
label.setStyleSheet(f"color: {theme.ACCENT};")
|
||||
row.addWidget(label, 1)
|
||||
|
||||
|
||||
class Pill(QLabel):
|
||||
TEXT = {"running": "Running", "ok": "Done", "error": "Failed",
|
||||
"waiting": "Needs approval", "declined": "Declined"}
|
||||
|
||||
def __init__(self, kind: str = "running", parent=None):
|
||||
super().__init__(parent)
|
||||
self.setObjectName("Pill")
|
||||
self.set_kind(kind)
|
||||
|
||||
def set_kind(self, kind: str) -> None:
|
||||
self.setProperty("kind", kind)
|
||||
self.setText(self.TEXT.get(kind, kind))
|
||||
repolish(self)
|
||||
|
||||
|
||||
class _ClickableFrame(QFrame):
|
||||
clicked = Signal()
|
||||
|
||||
def mouseReleaseEvent(self, event) -> None:
|
||||
if event.button() == Qt.LeftButton and self.rect().contains(event.position().toPoint()):
|
||||
self.clicked.emit()
|
||||
super().mouseReleaseEvent(event)
|
||||
|
||||
|
||||
class ToolCard(QFrame):
|
||||
"""One relayed command: header (icon, name, argument, status), optional
|
||||
diff, output that expands on click, and an inline approval bar."""
|
||||
|
||||
answered = Signal(str) # yes / no / always
|
||||
|
||||
def __init__(self, name: str, argument: str, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setObjectName("ToolCard")
|
||||
self._output = ""
|
||||
self._expanded = False
|
||||
self._error = False
|
||||
|
||||
outer = QVBoxLayout(self)
|
||||
outer.setContentsMargins(0, 0, 0, 0)
|
||||
outer.setSpacing(0)
|
||||
|
||||
self.header = _ClickableFrame()
|
||||
self.header.setObjectName("ToolHeader")
|
||||
self.header.setCursor(Qt.PointingHandCursor)
|
||||
head = QHBoxLayout(self.header)
|
||||
head.setContentsMargins(12, 9, 12, 9)
|
||||
head.setSpacing(10)
|
||||
self.icon = icons.IconLabel(TOOL_ICONS.get(name, "shell"), theme.TEXT_DIM, 16)
|
||||
head.addWidget(self.icon)
|
||||
title = _label(name, "ToolName", wrap=False)
|
||||
head.addWidget(title)
|
||||
shown = argument if len(argument) <= 110 else argument[:107] + "..."
|
||||
arg = _label(shown, "ToolArg", wrap=False)
|
||||
arg.setTextFormat(Qt.PlainText)
|
||||
arg.setToolTip(argument)
|
||||
arg.setSizePolicy(QSizePolicy.Ignored, QSizePolicy.Preferred)
|
||||
head.addWidget(arg, 1)
|
||||
self.pill = Pill("running")
|
||||
head.addWidget(self.pill)
|
||||
self.chevron = icons.IconLabel("chevron", theme.TEXT_FAINT, 14)
|
||||
self.chevron.hide()
|
||||
head.addWidget(self.chevron)
|
||||
self.header.clicked.connect(self.toggle)
|
||||
outer.addWidget(self.header)
|
||||
|
||||
self.diff = _label(name="ToolOutput", wrap=True, selectable=True)
|
||||
self.diff.setTextFormat(Qt.RichText)
|
||||
self.diff.hide()
|
||||
outer.addWidget(self.diff)
|
||||
|
||||
self.output = _label(name="ToolOutput", wrap=True, selectable=True)
|
||||
self.output.setTextFormat(Qt.PlainText)
|
||||
self.output.hide()
|
||||
outer.addWidget(self.output)
|
||||
|
||||
self.approval = QFrame()
|
||||
self.approval.setObjectName("ApprovalBar")
|
||||
bar = QHBoxLayout(self.approval)
|
||||
bar.setContentsMargins(14, 10, 10, 10)
|
||||
bar.setSpacing(8)
|
||||
self.question = _label("", "ApprovalQuestion", wrap=False)
|
||||
bar.addWidget(self.question, 1)
|
||||
for text, value, style in (("Deny", "no", "Ghost"), ("Always allow", "always", "Secondary"),
|
||||
("Allow", "yes", "Primary")):
|
||||
button = QPushButton(text)
|
||||
button.setObjectName(style)
|
||||
button.setCursor(Qt.PointingHandCursor)
|
||||
button.clicked.connect(lambda _=False, v=value: self._answer(v))
|
||||
bar.addWidget(button)
|
||||
if value == "yes":
|
||||
self.allow_button = button
|
||||
self.approval.hide()
|
||||
outer.addWidget(self.approval)
|
||||
|
||||
# ── state ────────────────────────────────────────────────────────────
|
||||
|
||||
def set_result(self, text: str, error: bool) -> None:
|
||||
if self.pill.property("kind") == "declined":
|
||||
return
|
||||
self._output, self._error = text.rstrip(), error
|
||||
self.pill.set_kind("error" if error else "ok")
|
||||
self.icon.set_icon(self.icon._name, theme.RED if error else theme.TEXT_DIM)
|
||||
self._render_output()
|
||||
|
||||
def set_diff(self, old: str, new: str, limit: int = 40) -> None:
|
||||
rows = []
|
||||
for sign, text, fg, bg in (("-", old, theme.RED, theme.DIFF_DEL_BG),
|
||||
("+", new, theme.GREEN, theme.DIFF_ADD_BG)):
|
||||
lines = text.splitlines() or [""]
|
||||
for line in lines[:limit]:
|
||||
rows.append(
|
||||
f'<tr><td style="background:{bg}; color:{fg}; padding:1px 8px; '
|
||||
f'white-space:pre;">{sign} {html.escape(line) or " "}</td></tr>')
|
||||
if len(lines) > limit:
|
||||
rows.append(f'<tr><td style="color:{theme.TEXT_FAINT}; padding:1px 8px;">'
|
||||
f' … {len(lines) - limit} more lines</td></tr>')
|
||||
self.diff.setText('<table width="100%" cellspacing="0" cellpadding="0">' + "".join(rows) + "</table>")
|
||||
self.diff.show()
|
||||
|
||||
def ask(self, question: str) -> None:
|
||||
self.question.setText(question)
|
||||
self.pill.set_kind("waiting")
|
||||
self.setProperty("state", "waiting")
|
||||
repolish(self)
|
||||
self.approval.show()
|
||||
self.allow_button.setFocus()
|
||||
|
||||
def _answer(self, value: str) -> None:
|
||||
self.approval.hide()
|
||||
self.setProperty("state", "")
|
||||
repolish(self)
|
||||
if value == "no":
|
||||
self.pill.set_kind("declined")
|
||||
else:
|
||||
self.pill.set_kind("running")
|
||||
self.answered.emit(value)
|
||||
|
||||
def toggle(self) -> None:
|
||||
if self._output:
|
||||
self._expanded = not self._expanded
|
||||
self._render_output()
|
||||
|
||||
def _render_output(self) -> None:
|
||||
lines = align_columns(self._output.splitlines())
|
||||
if not lines:
|
||||
self.output.hide()
|
||||
self.chevron.hide()
|
||||
return
|
||||
long = len(lines) > COLLAPSED_LINES
|
||||
shown = lines if (self._expanded or not long) else lines[:COLLAPSED_LINES]
|
||||
text = "\n".join(shown)
|
||||
if long and not self._expanded:
|
||||
text += f"\n… {len(lines) - COLLAPSED_LINES} more lines — click to expand"
|
||||
self.output.setText(text)
|
||||
self.output.setStyleSheet(f"color: {theme.RED};" if self._error else "")
|
||||
self.output.show()
|
||||
self.chevron.setVisible(long)
|
||||
|
||||
|
||||
def align_columns(lines):
|
||||
"""Tab-separated output (filectl list's "name<TAB>size") into padded
|
||||
columns — a QLabel's tab stops don't line up with anything."""
|
||||
rows = [line.split(" ") for line in lines]
|
||||
if not any(len(r) > 1 for r in rows):
|
||||
return lines
|
||||
width = max(len(r[0]) for r in rows if len(r) > 1)
|
||||
return [r[0].ljust(width + 2) + " ".join(r[1:]) if len(r) > 1 else r[0] for r in rows]
|
||||
|
||||
|
||||
class ThinkingIndicator(QWidget):
|
||||
"""Three dots breathing in sequence, next to a quiet 'Thinking'."""
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setFixedHeight(34)
|
||||
self._phase = 0.0
|
||||
self._timer = QTimer(self)
|
||||
self._timer.setInterval(40)
|
||||
self._timer.timeout.connect(self._tick)
|
||||
self.label = "Thinking"
|
||||
|
||||
def start(self) -> None:
|
||||
self._phase = 0.0
|
||||
self.show()
|
||||
self._timer.start()
|
||||
|
||||
def stop(self) -> None:
|
||||
self._timer.stop()
|
||||
self.hide()
|
||||
|
||||
def _tick(self) -> None:
|
||||
self._phase += 0.12
|
||||
self.update()
|
||||
|
||||
def paintEvent(self, _event) -> None:
|
||||
p = QPainter(self)
|
||||
p.setRenderHint(QPainter.Antialiasing)
|
||||
p.setPen(Qt.NoPen)
|
||||
x, y = 50.0, self.height() / 2
|
||||
for i in range(3):
|
||||
level = (math.sin(self._phase - i * 0.9) + 1) / 2
|
||||
colour = QColor(theme.ACCENT)
|
||||
colour.setAlphaF(0.25 + 0.75 * level)
|
||||
p.setBrush(colour)
|
||||
r = 2.6 + 1.0 * level
|
||||
p.drawEllipse(QRectF(x + i * 12 - r, y - r, 2 * r, 2 * r))
|
||||
p.setPen(QColor(theme.TEXT_FAINT))
|
||||
p.drawText(QRectF(x + 34, 0, 300, self.height()), Qt.AlignVCenter | Qt.AlignLeft, self.label)
|
||||
|
||||
|
||||
# ── composer ────────────────────────────────────────────────────────────────
|
||||
|
||||
class _SendButton(QPushButton):
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setFixedSize(34, 34)
|
||||
self.setCursor(Qt.PointingHandCursor)
|
||||
self.setToolTip("Send (Enter)")
|
||||
self.setFlat(True)
|
||||
|
||||
def paintEvent(self, _event) -> None:
|
||||
p = QPainter(self)
|
||||
p.setRenderHint(QPainter.Antialiasing)
|
||||
p.setPen(Qt.NoPen)
|
||||
enabled = self.isEnabled()
|
||||
fill = QColor(theme.ACCENT_HI if self.underMouse() and enabled else theme.ACCENT)
|
||||
if not enabled:
|
||||
fill = QColor(theme.BORDER_HI)
|
||||
p.setBrush(fill)
|
||||
p.drawEllipse(QRectF(self.rect()).adjusted(1, 1, -1, -1))
|
||||
icons.paint(p, "send", QRectF(self.rect()).adjusted(8, 8, -8, -8),
|
||||
theme.ACCENT_INK if enabled else theme.TEXT_FAINT)
|
||||
|
||||
|
||||
class MicButton(QPushButton):
|
||||
"""Dictation toggle. idle: a quiet mic outline. recording: red, with a
|
||||
ring that swells with your voice, and a stop square. finishing: an amber
|
||||
arc while the last words come back. unavailable: faint, and the tooltip
|
||||
says what's missing."""
|
||||
|
||||
TIPS = {
|
||||
"idle": "Dictate (Ctrl+M)\nClick, talk, click again (or pause) to stop",
|
||||
"recording": "Listening. Click or pause to stop, Esc to cancel",
|
||||
"finishing": "Transcribing...",
|
||||
}
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setFixedSize(34, 34)
|
||||
self.setCursor(Qt.PointingHandCursor)
|
||||
self.setFlat(True)
|
||||
self.state = "idle"
|
||||
self._level = 0.0
|
||||
self._phase = 0.0
|
||||
self._timer = QTimer(self)
|
||||
self._timer.setInterval(33)
|
||||
self._timer.timeout.connect(self._tick)
|
||||
self.set_state("idle")
|
||||
|
||||
def set_state(self, state: str, reason: str = "") -> None:
|
||||
self.state = state
|
||||
self.setToolTip(reason if state == "unavailable" else self.TIPS.get(state, ""))
|
||||
if state in ("recording", "finishing"):
|
||||
self._timer.start()
|
||||
else:
|
||||
self._timer.stop()
|
||||
self._level = 0.0
|
||||
self.update()
|
||||
|
||||
def set_level(self, level: float) -> None:
|
||||
# Fast attack, slow release, so the ring breathes instead of flickering.
|
||||
self._level = level if level > self._level else self._level * 0.85 + level * 0.15
|
||||
|
||||
def _tick(self) -> None:
|
||||
self._phase += 0.2
|
||||
self._level *= 0.97
|
||||
self.update()
|
||||
|
||||
def paintEvent(self, _event) -> None:
|
||||
p = QPainter(self)
|
||||
p.setRenderHint(QPainter.Antialiasing)
|
||||
rect = QRectF(self.rect()).adjusted(1, 1, -1, -1)
|
||||
centre = rect.center()
|
||||
if self.state == "recording":
|
||||
ring = QColor(theme.RED)
|
||||
ring.setAlphaF(0.22 + 0.3 * self._level)
|
||||
p.setPen(Qt.NoPen)
|
||||
p.setBrush(ring)
|
||||
p.drawEllipse(centre, 16.0, 16.0)
|
||||
p.setBrush(QColor(theme.RED))
|
||||
radius = 10.5 + 3.5 * self._level
|
||||
p.drawEllipse(centre, radius, radius)
|
||||
icons.paint(p, "stop", QRectF(centre.x() - 8, centre.y() - 8, 16, 16), "#ffffff")
|
||||
return
|
||||
if self.state == "finishing":
|
||||
pen = QColor(theme.ACCENT)
|
||||
p.setPen(icons._pen(pen, 2.2))
|
||||
p.drawArc(rect.adjusted(4, 4, -4, -4), int(-self._phase * 16 * 57), 100 * 16)
|
||||
icons.paint(p, "mic", rect.adjusted(9, 9, -9, -9), theme.TEXT_DIM)
|
||||
return
|
||||
if self.state == "idle" and self.underMouse():
|
||||
p.setPen(Qt.NoPen)
|
||||
p.setBrush(QColor(theme.SURFACE_HI))
|
||||
p.drawEllipse(rect)
|
||||
colour = theme.TEXT_FAINT if self.state == "unavailable" else (
|
||||
theme.TEXT if self.underMouse() else theme.TEXT_DIM)
|
||||
icons.paint(p, "mic", rect.adjusted(8, 8, -8, -8), colour)
|
||||
|
||||
|
||||
class _ComposerEdit(QPlainTextEdit):
|
||||
submitted = Signal()
|
||||
|
||||
def keyPressEvent(self, event) -> None:
|
||||
if event.key() in (Qt.Key_Return, Qt.Key_Enter) and not (event.modifiers() & Qt.ShiftModifier):
|
||||
self.submitted.emit()
|
||||
return
|
||||
super().keyPressEvent(event)
|
||||
|
||||
|
||||
class Composer(QFrame):
|
||||
"""Rounded input that grows with its text. Enter sends, Shift+Enter
|
||||
starts a new line. While a turn runs you can keep typing; sending waits."""
|
||||
|
||||
submitted = Signal(str)
|
||||
MAX_LINES = 9
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setObjectName("Composer")
|
||||
row = QHBoxLayout(self)
|
||||
row.setContentsMargins(18, 10, 10, 10)
|
||||
row.setSpacing(10)
|
||||
self.edit = _ComposerEdit()
|
||||
self.edit.setObjectName("ComposerEdit")
|
||||
self.edit.setPlaceholderText("Ask Bolt to read, change or run something…")
|
||||
self.edit.setWordWrapMode(QTextOption.WrapAtWordBoundaryOrAnywhere)
|
||||
self.edit.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded)
|
||||
self.edit.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
|
||||
self.edit.document().setDocumentMargin(2)
|
||||
self.edit.textChanged.connect(self._resize)
|
||||
self.edit.submitted.connect(self._submit)
|
||||
self.edit.installEventFilter(self)
|
||||
row.addWidget(self.edit, 1, Qt.AlignVCenter)
|
||||
self.mic = MicButton()
|
||||
row.addWidget(self.mic, 0, Qt.AlignBottom)
|
||||
self._dictation_prefix = None # composer text from before a recording
|
||||
self.send = _SendButton()
|
||||
self.send.clicked.connect(self._submit)
|
||||
row.addWidget(self.send, 0, Qt.AlignBottom)
|
||||
self._busy = False
|
||||
self._resize()
|
||||
self._sync_button()
|
||||
self.edit.textChanged.connect(self._sync_button)
|
||||
|
||||
def eventFilter(self, obj, event) -> bool:
|
||||
if obj is self.edit and event.type() in (QEvent.FocusIn, QEvent.FocusOut):
|
||||
self.setProperty("focused", event.type() == QEvent.FocusIn)
|
||||
repolish(self)
|
||||
return False
|
||||
|
||||
def set_busy(self, busy: bool) -> None:
|
||||
self._busy = busy
|
||||
self._sync_button()
|
||||
|
||||
def set_text(self, text: str) -> None:
|
||||
self.edit.setPlainText(text)
|
||||
cursor = self.edit.textCursor()
|
||||
cursor.movePosition(QTextCursor.MoveOperation.End)
|
||||
self.edit.setTextCursor(cursor)
|
||||
self.edit.setFocus()
|
||||
|
||||
# ── dictation: the words appear in the box as you speak ──────────────
|
||||
|
||||
def begin_dictation(self) -> None:
|
||||
self._dictation_prefix = self.edit.toPlainText().rstrip()
|
||||
self.edit.setReadOnly(True) # typing into text that's being rewritten would be lost
|
||||
self._sync_button()
|
||||
|
||||
def show_dictation(self, final: str, interim: str = "") -> None:
|
||||
if self._dictation_prefix is None:
|
||||
return
|
||||
spoken = " ".join(part for part in (final, interim) if part)
|
||||
self._show(" ".join(part for part in (self._dictation_prefix, spoken) if part))
|
||||
|
||||
def end_dictation(self, text: str) -> None:
|
||||
if self._dictation_prefix is None:
|
||||
return
|
||||
self._show(" ".join(part for part in (self._dictation_prefix, text.strip()) if part))
|
||||
self._dictation_prefix = None
|
||||
self.edit.setReadOnly(False)
|
||||
self.edit.setFocus()
|
||||
self._sync_button()
|
||||
|
||||
@property
|
||||
def dictating(self) -> bool:
|
||||
return self._dictation_prefix is not None
|
||||
|
||||
def _show(self, text: str) -> None:
|
||||
self.edit.setPlainText(text)
|
||||
cursor = self.edit.textCursor()
|
||||
cursor.movePosition(QTextCursor.MoveOperation.End)
|
||||
self.edit.setTextCursor(cursor)
|
||||
|
||||
def _sync_button(self) -> None:
|
||||
ready = not self._busy and not self.dictating and bool(self.edit.toPlainText().strip())
|
||||
self.send.setEnabled(ready)
|
||||
self.send.update()
|
||||
|
||||
def _resize(self) -> None:
|
||||
line = self.edit.fontMetrics().lineSpacing()
|
||||
lines = max(1, min(self.MAX_LINES, int(self.edit.document().size().height())))
|
||||
self.edit.setFixedHeight(int(lines * line + 8))
|
||||
|
||||
def _submit(self) -> None:
|
||||
text = self.edit.toPlainText().strip()
|
||||
if not text or self._busy or self.dictating:
|
||||
return
|
||||
self.edit.clear()
|
||||
self.submitted.emit(text)
|
||||
|
||||
|
||||
# ── header ──────────────────────────────────────────────────────────────────
|
||||
|
||||
class StatusDot(QWidget):
|
||||
COLOURS = {"unknown": theme.TEXT_FAINT, "ok": theme.GREEN, "error": theme.RED}
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setFixedSize(QSize(10, 10))
|
||||
self._state = "unknown"
|
||||
|
||||
def set_state(self, state: str, tooltip: str) -> None:
|
||||
self._state = state
|
||||
self.setToolTip(tooltip)
|
||||
self.update()
|
||||
|
||||
def paintEvent(self, _event) -> None:
|
||||
p = QPainter(self)
|
||||
p.setRenderHint(QPainter.Antialiasing)
|
||||
p.setPen(Qt.NoPen)
|
||||
p.setBrush(QColor(self.COLOURS.get(self._state, theme.TEXT_FAINT)))
|
||||
p.drawEllipse(QRectF(1, 1, 8, 8))
|
||||
|
||||
|
||||
class FolderChip(QPushButton):
|
||||
"""A pill button with a painted icon on the left (the folder by default)."""
|
||||
|
||||
def __init__(self, parent=None, icon_name: str = "folder", text: str = ""):
|
||||
super().__init__(text, parent)
|
||||
self._icon_name = icon_name
|
||||
self.setObjectName("Chip")
|
||||
self.setCursor(Qt.PointingHandCursor)
|
||||
self.setFixedHeight(30)
|
||||
|
||||
def set_path(self, path: str) -> None:
|
||||
name = path.rstrip("\\/").replace("\\", "/").split("/")[-1] or path
|
||||
self.setText(name)
|
||||
self.setToolTip(f"Working directory: {path}\nClick to change")
|
||||
|
||||
def paintEvent(self, event) -> None:
|
||||
super().paintEvent(event)
|
||||
p = QPainter(self)
|
||||
paint_colour = theme.TEXT if self.underMouse() else theme.TEXT_DIM
|
||||
if not self.isEnabled():
|
||||
paint_colour = theme.TEXT_FAINT
|
||||
icons.paint(p, self._icon_name, QRectF(10, (self.height() - 15) / 2, 15, 15), paint_colour)
|
||||
|
||||
|
||||
class ModeSwitch(QFrame):
|
||||
"""Segmented control: ask before changes, or run everything."""
|
||||
|
||||
changed = Signal(str)
|
||||
|
||||
def __init__(self, mode: str, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setObjectName("Segment")
|
||||
self.setFixedHeight(30)
|
||||
row = QHBoxLayout(self)
|
||||
row.setContentsMargins(2, 2, 2, 2)
|
||||
row.setSpacing(2)
|
||||
self._group = QButtonGroup(self)
|
||||
self.buttons = {}
|
||||
for value, text, tip in (
|
||||
("ask", "Ask first", "Confirm shell commands, writes and edits"),
|
||||
("auto", "Auto", "Run everything Bolt asks for without confirming"),
|
||||
):
|
||||
button = QPushButton(text)
|
||||
button.setObjectName("SegmentButton")
|
||||
button.setCheckable(True)
|
||||
button.setCursor(Qt.PointingHandCursor)
|
||||
button.setToolTip(tip)
|
||||
if value == "auto":
|
||||
button.setProperty("danger", True)
|
||||
self._group.addButton(button)
|
||||
row.addWidget(button)
|
||||
self.buttons[value] = button
|
||||
button.clicked.connect(lambda _=False, v=value: self.changed.emit(v))
|
||||
self.set_mode(mode)
|
||||
|
||||
def set_mode(self, mode: str) -> None:
|
||||
self.buttons.get(mode, self.buttons["ask"]).setChecked(True)
|
||||
|
||||
|
||||
# ── empty state ─────────────────────────────────────────────────────────────
|
||||
|
||||
SUGGESTIONS = (
|
||||
"What's in this folder?",
|
||||
"Summarise the README",
|
||||
"Git status and recent commits",
|
||||
"Find TODO comments in this project",
|
||||
)
|
||||
|
||||
|
||||
class EmptyState(QWidget):
|
||||
picked = Signal(str)
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
col = QVBoxLayout(self)
|
||||
col.setContentsMargins(0, 0, 0, 0)
|
||||
col.addStretch(3)
|
||||
col.addWidget(icons.IconLabel("bolt", theme.ACCENT_INK, 52, tile=theme.ACCENT), 0, Qt.AlignHCenter)
|
||||
col.addSpacing(18)
|
||||
hero = _label("What are we working on?", "Hero")
|
||||
hero.setAlignment(Qt.AlignHCenter)
|
||||
col.addWidget(hero)
|
||||
self.sub = _label("", "HeroSub")
|
||||
self.sub.setAlignment(Qt.AlignHCenter)
|
||||
col.addWidget(self.sub)
|
||||
col.addSpacing(26)
|
||||
grid = QVBoxLayout()
|
||||
grid.setSpacing(10)
|
||||
for i in range(0, len(SUGGESTIONS), 2):
|
||||
pair = QHBoxLayout()
|
||||
pair.setSpacing(10)
|
||||
for text in SUGGESTIONS[i:i + 2]:
|
||||
button = QPushButton(text)
|
||||
button.setObjectName("Suggestion")
|
||||
button.setCursor(Qt.PointingHandCursor)
|
||||
button.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
|
||||
button.clicked.connect(lambda _=False, t=text: self.picked.emit(t))
|
||||
pair.addWidget(button)
|
||||
grid.addLayout(pair)
|
||||
wrap = QHBoxLayout()
|
||||
wrap.addStretch(1)
|
||||
holder = QWidget()
|
||||
holder.setLayout(grid)
|
||||
holder.setMaximumWidth(560)
|
||||
holder.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
|
||||
wrap.addWidget(holder, 4)
|
||||
wrap.addStretch(1)
|
||||
col.addLayout(wrap)
|
||||
col.addStretch(4)
|
||||
|
||||
def set_folder(self, path: str) -> None:
|
||||
self.sub.setText(f"Bolt can read, edit and run things in {path}")
|
||||
@@ -0,0 +1,528 @@
|
||||
"""The main window and the worker thread a turn runs on.
|
||||
|
||||
Turn logic is `console.Session` — the same object the terminal uses — driven
|
||||
on a QThread with a `Bridge` standing in for the terminal. The window only
|
||||
ever reacts to Bridge signals, so nothing here knows about HTTP or tools.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
from typing import Optional
|
||||
|
||||
from PySide6.QtCore import QObject, Qt, QThread, QTimer, Signal, Slot
|
||||
from PySide6.QtGui import QFont, QKeySequence, QShortcut
|
||||
from PySide6.QtWidgets import (
|
||||
QApplication, QFileDialog, QFrame, QHBoxLayout, QLabel, QMainWindow,
|
||||
QScrollArea, QSizePolicy, QVBoxLayout, QWidget,
|
||||
)
|
||||
|
||||
from .. import __version__, config, dictation, server_client
|
||||
from ..console import Session
|
||||
from ..tools import Tools
|
||||
from . import icons, theme
|
||||
from .bridge import Bridge
|
||||
from .chat_list_window import ChatListDialog, ChatSignals, run_in_pool
|
||||
from .widgets import (
|
||||
AssistantMessage, Composer, EmptyState, FolderChip, HoldingLine, ModeSwitch,
|
||||
Notice, StatusDot, ThinkingIndicator, ToolCard, UserMessage,
|
||||
)
|
||||
|
||||
|
||||
class TurnRunner(QObject):
|
||||
"""Lives on the worker thread; runs one Session.send per request."""
|
||||
|
||||
finished = Signal()
|
||||
|
||||
def __init__(self, session: Session):
|
||||
super().__init__()
|
||||
self.session = session
|
||||
|
||||
@Slot(str)
|
||||
def run(self, text: str) -> None:
|
||||
try:
|
||||
self.session.send(text)
|
||||
except Exception as exc: # never let a bug kill the worker thread
|
||||
self.session.ui.error(f"Unexpected error: {exc}")
|
||||
self.session.ui.stop_spinner()
|
||||
finally:
|
||||
self.finished.emit()
|
||||
|
||||
|
||||
class HealthProbe(QObject):
|
||||
done = Signal(bool, str)
|
||||
|
||||
def start(self) -> None:
|
||||
threading.Thread(target=self._probe, daemon=True).start()
|
||||
|
||||
def _probe(self) -> None:
|
||||
try:
|
||||
server_client.check_health(timeout=8)
|
||||
self.done.emit(True, f"Connected to {config.SERVER_URL}")
|
||||
except Exception as exc:
|
||||
self.done.emit(False, f"Can't reach {config.SERVER_URL}: {exc}")
|
||||
|
||||
|
||||
class DictationSignals(QObject):
|
||||
"""Dictation calls back on background threads; these queue it onto the
|
||||
GUI thread."""
|
||||
|
||||
update = Signal(str, str) # final text so far, current interim guess
|
||||
level = Signal(float) # 0..1 mic loudness
|
||||
done = Signal(str, str) # text, error ("" when none)
|
||||
stopping = Signal() # recording over, transcript on its way
|
||||
|
||||
|
||||
class Column(QWidget):
|
||||
"""Centres its content in a reading column of at most COLUMN_WIDTH."""
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
row = QHBoxLayout(self)
|
||||
row.setContentsMargins(24, 0, 24, 0)
|
||||
row.addStretch(1)
|
||||
self.inner = QWidget()
|
||||
self.inner.setMaximumWidth(theme.COLUMN_WIDTH)
|
||||
self.inner.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred)
|
||||
row.addWidget(self.inner, 100)
|
||||
row.addStretch(1)
|
||||
self.layout_ = QVBoxLayout(self.inner)
|
||||
self.layout_.setContentsMargins(0, 0, 0, 0)
|
||||
|
||||
|
||||
class MainWindow(QMainWindow):
|
||||
submit_turn = Signal(str)
|
||||
HINT = "Enter to send · Shift+Enter for a new line · Ctrl+M dictate · Ctrl+N new chat"
|
||||
|
||||
def __init__(self, mode: Optional[str] = None, session: Optional[Session] = None,
|
||||
dictation_factory=None, list_chats=None, create_chat=None):
|
||||
super().__init__()
|
||||
# Injectable so tests never hit the network; the real ones run on the pool.
|
||||
self._list_chats = list_chats or server_client.list_chats
|
||||
self._create_chat = create_chat or server_client.create_chat
|
||||
self._dictation_factory = dictation_factory or dictation.Dictation
|
||||
self._dictation = None
|
||||
self.setWindowTitle("Bolt")
|
||||
self.setWindowIcon(icons.app_icon())
|
||||
self.resize(1040, 800)
|
||||
self.setMinimumSize(620, 480)
|
||||
|
||||
self.bridge = Bridge()
|
||||
if session is None:
|
||||
session = Session(self.bridge, tools=Tools(self.bridge, mode=mode or config.APPROVAL_MODE))
|
||||
self.session = session
|
||||
self._cards = {}
|
||||
self._assistant: Optional[AssistantMessage] = None
|
||||
self._busy = False
|
||||
self._stick_to_bottom = True
|
||||
|
||||
root = QWidget()
|
||||
root.setObjectName("Root")
|
||||
self.setCentralWidget(root)
|
||||
page = QVBoxLayout(root)
|
||||
page.setContentsMargins(0, 0, 0, 0)
|
||||
page.setSpacing(0)
|
||||
page.addWidget(self._build_header())
|
||||
|
||||
# transcript
|
||||
self.scroll = QScrollArea()
|
||||
self.scroll.setWidgetResizable(True)
|
||||
self.scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
|
||||
transcript = QWidget()
|
||||
transcript.setObjectName("Transcript")
|
||||
body = QVBoxLayout(transcript)
|
||||
body.setContentsMargins(0, 18, 0, 18)
|
||||
self.column = Column()
|
||||
body.addWidget(self.column)
|
||||
body.addStretch(1)
|
||||
self.entries = self.column.layout_
|
||||
self.entries.setSpacing(6)
|
||||
self.empty = EmptyState()
|
||||
self.empty.picked.connect(self._send)
|
||||
self.entries.addWidget(self.empty)
|
||||
self.thinking = ThinkingIndicator()
|
||||
self.thinking.hide()
|
||||
self.entries.addWidget(self.thinking)
|
||||
self.scroll.setWidget(transcript)
|
||||
self.scroll.verticalScrollBar().rangeChanged.connect(self._follow)
|
||||
self.scroll.verticalScrollBar().valueChanged.connect(self._track_scroll)
|
||||
page.addWidget(self.scroll, 1)
|
||||
|
||||
# composer
|
||||
dock = Column()
|
||||
dock.layout_.setContentsMargins(0, 6, 0, 14)
|
||||
dock.layout_.setSpacing(6)
|
||||
self.composer = Composer()
|
||||
self.composer.submitted.connect(self._send)
|
||||
dock.layout_.addWidget(self.composer)
|
||||
self.hint = QLabel(self.HINT)
|
||||
self.hint.setObjectName("Hint")
|
||||
self.hint.setAlignment(Qt.AlignHCenter)
|
||||
dock.layout_.addWidget(self.hint)
|
||||
self._hint_timer = QTimer(self)
|
||||
self._hint_timer.setSingleShot(True)
|
||||
self._hint_timer.timeout.connect(lambda: self.hint.setText(self.HINT))
|
||||
page.addWidget(dock)
|
||||
|
||||
QShortcut(QKeySequence("Ctrl+L"), self, activated=self.clear_transcript)
|
||||
QShortcut(QKeySequence("Ctrl+O"), self, activated=self.choose_folder)
|
||||
QShortcut(QKeySequence("Ctrl+N"), self, activated=self.start_new_chat)
|
||||
QShortcut(QKeySequence("Ctrl+K"), self, activated=self.open_chats)
|
||||
self.chat_signals = ChatSignals()
|
||||
self.chat_signals.created.connect(lambda session_id: self.switch_chat(session_id, "New chat"))
|
||||
self.chat_signals.failed.connect(self._new_chat_failed)
|
||||
self._creating_chat = False
|
||||
QShortcut(QKeySequence("Ctrl+M"), self, activated=self.toggle_dictation)
|
||||
QShortcut(QKeySequence("Escape"), self, activated=self.cancel_dictation)
|
||||
|
||||
self.dictation_signals = DictationSignals()
|
||||
self.dictation_signals.update.connect(self.composer.show_dictation)
|
||||
self.dictation_signals.level.connect(self.composer.mic.set_level)
|
||||
self.dictation_signals.done.connect(self._dictation_done)
|
||||
self.dictation_signals.stopping.connect(
|
||||
lambda: self._dictation is not None and self.composer.mic.set_state("finishing"))
|
||||
self.composer.mic.clicked.connect(self.toggle_dictation)
|
||||
self._mic_unavailable = dictation.unavailable_reason()
|
||||
if self._mic_unavailable:
|
||||
self.composer.mic.set_state("unavailable", self._mic_unavailable)
|
||||
|
||||
self._wire_bridge()
|
||||
self._start_worker()
|
||||
self._refresh_folder()
|
||||
self.health = HealthProbe()
|
||||
self.health.done.connect(self._health_done)
|
||||
QTimer.singleShot(0, self.health.start)
|
||||
self.composer.edit.setFocus()
|
||||
|
||||
# ── construction ─────────────────────────────────────────────────────
|
||||
|
||||
def _build_header(self) -> QWidget:
|
||||
header = QFrame()
|
||||
header.setObjectName("Header")
|
||||
header.setFixedHeight(56)
|
||||
row = QHBoxLayout(header)
|
||||
row.setContentsMargins(18, 0, 18, 0)
|
||||
row.setSpacing(10)
|
||||
row.addWidget(icons.IconLabel("bolt", theme.ACCENT_INK, 26, tile=theme.ACCENT))
|
||||
word = QLabel("Bolt")
|
||||
word.setObjectName("Wordmark")
|
||||
row.addWidget(word)
|
||||
sub = QLabel(f"Console {__version__}")
|
||||
sub.setObjectName("WordmarkSub")
|
||||
row.addWidget(sub)
|
||||
row.addSpacing(6)
|
||||
self.status = StatusDot()
|
||||
self.status.set_state("unknown", "Checking the server…")
|
||||
row.addWidget(self.status)
|
||||
row.addStretch(1)
|
||||
self.chats = FolderChip(icon_name="chats", text="Chats")
|
||||
self.chats.setToolTip("All your chats with Bolt, from every device (Ctrl+K)")
|
||||
self.chats.clicked.connect(self.open_chats)
|
||||
row.addWidget(self.chats)
|
||||
self.new_chat = FolderChip(icon_name="plus", text="New chat")
|
||||
self.new_chat.setToolTip("Start a fresh conversation (Ctrl+N)\n"
|
||||
"Fresh history; Bolt still remembers you")
|
||||
self.new_chat.clicked.connect(self.start_new_chat)
|
||||
row.addWidget(self.new_chat)
|
||||
self.folder = FolderChip()
|
||||
self.folder.clicked.connect(self.choose_folder)
|
||||
row.addWidget(self.folder)
|
||||
self.mode = ModeSwitch(self.session.tools.mode)
|
||||
self.mode.changed.connect(self._set_mode)
|
||||
row.addWidget(self.mode)
|
||||
return header
|
||||
|
||||
def _wire_bridge(self) -> None:
|
||||
b = self.bridge
|
||||
b.busy.connect(self._set_busy)
|
||||
b.chunk.connect(self._on_chunk)
|
||||
b.reply_ended.connect(self._end_assistant)
|
||||
b.reply.connect(self._on_reply)
|
||||
b.holding_line.connect(lambda text: self._add(HoldingLine(text)))
|
||||
b.tool_started.connect(self._on_tool_started)
|
||||
b.tool_output.connect(self._on_tool_output)
|
||||
b.tool_diff.connect(self._on_tool_diff)
|
||||
b.approval_needed.connect(self._on_approval)
|
||||
b.notice.connect(lambda level, text: self._add(Notice(level, text)))
|
||||
|
||||
def _start_worker(self) -> None:
|
||||
self.thread_ = QThread(self)
|
||||
self.runner = TurnRunner(self.session)
|
||||
self.runner.moveToThread(self.thread_)
|
||||
self.submit_turn.connect(self.runner.run)
|
||||
self.runner.finished.connect(self._turn_finished)
|
||||
self.thread_.start()
|
||||
|
||||
# ── transcript helpers ───────────────────────────────────────────────
|
||||
|
||||
def _add(self, widget: QWidget) -> None:
|
||||
if self.empty.isVisible():
|
||||
self.empty.hide()
|
||||
# Always insert above the thinking indicator, which stays last.
|
||||
self.entries.insertWidget(self.entries.count() - 1, widget)
|
||||
|
||||
def _track_scroll(self, value: int) -> None:
|
||||
bar = self.scroll.verticalScrollBar()
|
||||
self._stick_to_bottom = value >= bar.maximum() - 40
|
||||
|
||||
def _follow(self, _min: int, maximum: int) -> None:
|
||||
if self._stick_to_bottom:
|
||||
self.scroll.verticalScrollBar().setValue(maximum)
|
||||
|
||||
def clear_transcript(self) -> None:
|
||||
if self._busy:
|
||||
return
|
||||
keep = (self.empty, self.thinking)
|
||||
for i in reversed(range(self.entries.count())):
|
||||
widget = self.entries.itemAt(i).widget()
|
||||
if widget is not None and widget not in keep:
|
||||
self.entries.takeAt(i)
|
||||
widget.deleteLater()
|
||||
self._cards.clear()
|
||||
self._assistant = None
|
||||
self.empty.show()
|
||||
|
||||
# ── user actions ─────────────────────────────────────────────────────
|
||||
|
||||
# ── chats ────────────────────────────────────────────────────────────
|
||||
|
||||
def start_new_chat(self) -> None:
|
||||
"""Ask the server for a fresh chat id, off the UI thread; the switch
|
||||
happens when it arrives (switch_chat)."""
|
||||
if self._busy or self._creating_chat:
|
||||
return
|
||||
self._creating_chat = True
|
||||
self.new_chat.setEnabled(False)
|
||||
self._flash_hint("Starting a new chat...")
|
||||
run_in_pool("create", self._create_chat, self.chat_signals)
|
||||
|
||||
def _new_chat_failed(self, _operation: str, message: str) -> None:
|
||||
self._creating_chat = False
|
||||
self.new_chat.setEnabled(not self._busy)
|
||||
self._flash_hint(f"Couldn't start a new chat: {message}")
|
||||
|
||||
def open_chats(self) -> None:
|
||||
if self._busy:
|
||||
self._flash_hint("Wait for Bolt to finish before switching chats.")
|
||||
return
|
||||
dialog = ChatListDialog(self, list_chats=self._list_chats, create_chat=self._create_chat)
|
||||
dialog.chosen.connect(self.switch_chat)
|
||||
self.chat_dialog = dialog # kept for tests; the dialog is modal
|
||||
dialog.open()
|
||||
|
||||
def switch_chat(self, session_id: str, label: str = "") -> None:
|
||||
"""Point every server call at *session_id* and start the local
|
||||
transcript over: there's no endpoint for a past chat's messages, so
|
||||
the window can't show them (Bolt still has them server-side)."""
|
||||
self._creating_chat = False
|
||||
self.new_chat.setEnabled(not self._busy)
|
||||
if self._busy:
|
||||
return
|
||||
note = self.session.switch_to(session_id)
|
||||
self.clear_transcript()
|
||||
self.chats.setToolTip(f"All your chats with Bolt, from every device (Ctrl+K)\n{note}")
|
||||
self._flash_hint(f"Now in: {label or session_id}")
|
||||
|
||||
def _send(self, text: str) -> None:
|
||||
text = text.strip()
|
||||
if not text or self._busy:
|
||||
return
|
||||
self._stick_to_bottom = True
|
||||
self._add(UserMessage(text))
|
||||
self._assistant = None
|
||||
self._set_busy(True)
|
||||
self.submit_turn.emit(text)
|
||||
|
||||
# ── dictation ────────────────────────────────────────────────────────
|
||||
|
||||
def toggle_dictation(self) -> None:
|
||||
if self._mic_unavailable:
|
||||
self._flash_hint(self._mic_unavailable)
|
||||
return
|
||||
if self._dictation is not None:
|
||||
if self.composer.mic.state == "recording":
|
||||
self.composer.mic.set_state("finishing")
|
||||
self._dictation.stop()
|
||||
return
|
||||
sig = self.dictation_signals
|
||||
recording = self._dictation_factory(
|
||||
sig.update.emit, sig.level.emit, lambda text, error: sig.done.emit(text, error or ""),
|
||||
silence_seconds=config.DICTATION_SILENCE_SECONDS, on_stopping=sig.stopping.emit,
|
||||
)
|
||||
self.composer.begin_dictation()
|
||||
try:
|
||||
recording.start()
|
||||
except Exception as exc:
|
||||
self.composer.end_dictation("")
|
||||
self._flash_hint(f"Couldn't open the microphone: {exc}")
|
||||
return
|
||||
self._dictation = recording
|
||||
self.composer.mic.set_state("recording")
|
||||
if not recording.live:
|
||||
self._flash_hint("Live transcription is unavailable; the words will appear when you stop.")
|
||||
|
||||
def cancel_dictation(self) -> None:
|
||||
if self._dictation is not None:
|
||||
self._dictation.cancel()
|
||||
|
||||
def _dictation_done(self, text: str, error: str) -> None:
|
||||
self._dictation = None
|
||||
self.composer.mic.set_state("unavailable" if self._mic_unavailable else "idle",
|
||||
self._mic_unavailable or "")
|
||||
self.composer.end_dictation(text)
|
||||
if error:
|
||||
self._flash_hint(error)
|
||||
|
||||
def _flash_hint(self, text: str, seconds: float = 6.0) -> None:
|
||||
self.hint.setText(text)
|
||||
self._hint_timer.start(int(seconds * 1000))
|
||||
|
||||
def choose_folder(self) -> None:
|
||||
if self._busy:
|
||||
return
|
||||
path = QFileDialog.getExistingDirectory(self, "Working directory for Bolt", os.getcwd())
|
||||
if path:
|
||||
try:
|
||||
os.chdir(path)
|
||||
except OSError as exc:
|
||||
self._add(Notice("error", f"Couldn't switch to {path}: {exc}"))
|
||||
return
|
||||
self._refresh_folder()
|
||||
if not self.empty.isVisible():
|
||||
self._add(Notice("info", f"Working directory is now {os.getcwd()}"))
|
||||
|
||||
def _refresh_folder(self) -> None:
|
||||
cwd = os.getcwd()
|
||||
self.folder.set_path(cwd)
|
||||
self.empty.set_folder(cwd)
|
||||
|
||||
def _set_mode(self, mode: str) -> None:
|
||||
self.session.tools.mode = mode
|
||||
|
||||
# ── bridge slots ─────────────────────────────────────────────────────
|
||||
|
||||
def _set_busy(self, busy: bool) -> None:
|
||||
self._busy = busy
|
||||
self.composer.set_busy(busy)
|
||||
self.folder.setEnabled(not busy)
|
||||
self.new_chat.setEnabled(not busy and not self._creating_chat)
|
||||
self.chats.setEnabled(not busy)
|
||||
if busy:
|
||||
self.thinking.start()
|
||||
else:
|
||||
self.thinking.stop()
|
||||
|
||||
def _turn_finished(self) -> None:
|
||||
self._set_busy(False)
|
||||
self._assistant = None
|
||||
# Pick up a mode switched by "Always allow" on the worker side.
|
||||
self.mode.set_mode(self.session.tools.mode)
|
||||
self.composer.edit.setFocus()
|
||||
|
||||
def _on_chunk(self, sentence: str) -> None:
|
||||
if self._assistant is None:
|
||||
self._assistant = AssistantMessage()
|
||||
self._add(self._assistant)
|
||||
self._assistant.append(sentence)
|
||||
|
||||
def _end_assistant(self) -> None:
|
||||
self._assistant = None
|
||||
|
||||
def _on_reply(self, text: str) -> None:
|
||||
self._add(AssistantMessage(text))
|
||||
self._assistant = None
|
||||
|
||||
def _on_tool_started(self, tool_id: int, name: str, argument: str) -> None:
|
||||
self._assistant = None # text after a tool call starts a new block
|
||||
card = ToolCard(name, argument)
|
||||
card.answered.connect(self._on_answer)
|
||||
self._cards[tool_id] = card
|
||||
self._add(card)
|
||||
|
||||
def _on_tool_output(self, tool_id: int, text: str, error: bool) -> None:
|
||||
card = self._cards.get(tool_id)
|
||||
if card is not None:
|
||||
card.set_result(text, error)
|
||||
|
||||
def _on_tool_diff(self, tool_id: int, old: str, new: str) -> None:
|
||||
card = self._cards.get(tool_id)
|
||||
if card is not None:
|
||||
card.set_diff(old, new)
|
||||
|
||||
def _on_approval(self, tool_id: int, question: str) -> None:
|
||||
card = self._cards.get(tool_id)
|
||||
if card is None:
|
||||
self.bridge.answer("no")
|
||||
return
|
||||
self.thinking.stop()
|
||||
card.ask(question)
|
||||
self._stick_to_bottom = True
|
||||
QTimer.singleShot(0, lambda: self.scroll.ensureWidgetVisible(card, 0, 40))
|
||||
QApplication.alert(self) # flash the taskbar if the window is behind others
|
||||
|
||||
def _on_answer(self, value: str) -> None:
|
||||
if value == "always":
|
||||
self.mode.set_mode("auto")
|
||||
self.thinking.start()
|
||||
self.bridge.answer(value)
|
||||
|
||||
def _health_done(self, ok: bool, detail: str) -> None:
|
||||
self.status.set_state("ok" if ok else "error", detail)
|
||||
|
||||
# ── window chrome ────────────────────────────────────────────────────
|
||||
|
||||
def resizeEvent(self, event) -> None:
|
||||
super().resizeEvent(event)
|
||||
# The transcript grows from the top; give the empty state the whole
|
||||
# viewport so its stretches can centre it.
|
||||
self.empty.setMinimumHeight(max(0, self.scroll.viewport().height() - 40))
|
||||
|
||||
def showEvent(self, event) -> None:
|
||||
super().showEvent(event)
|
||||
theme.dark_title_bar(self)
|
||||
|
||||
def closeEvent(self, event) -> None:
|
||||
self.cancel_dictation()
|
||||
self.bridge.close()
|
||||
self.thread_.quit()
|
||||
self.thread_.wait(1500)
|
||||
super().closeEvent(event)
|
||||
|
||||
|
||||
def make_app(argv=None) -> QApplication:
|
||||
app = QApplication.instance() or QApplication(argv or sys.argv)
|
||||
app.setApplicationName("Bolt Console")
|
||||
app.setWindowIcon(icons.app_icon())
|
||||
app.setStyle("Fusion") # a neutral base, so the stylesheet decides the look
|
||||
app.setPalette(theme.palette())
|
||||
font = QFont(theme.ui_font_family())
|
||||
font.setPointSizeF(10.5)
|
||||
font.setHintingPreference(QFont.PreferNoHinting)
|
||||
app.setFont(font)
|
||||
app.setStyleSheet(theme.stylesheet())
|
||||
return app
|
||||
|
||||
|
||||
def run(argv=None) -> int:
|
||||
if sys.platform == "win32":
|
||||
try: # own taskbar group + icon instead of python.exe's
|
||||
import ctypes
|
||||
|
||||
ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID("TheMajesticNetwork.BoltConsole")
|
||||
except Exception:
|
||||
pass
|
||||
argv = list(sys.argv[1:] if argv is None else argv)
|
||||
app = make_app()
|
||||
if not config.is_configured():
|
||||
from PySide6.QtWidgets import QMessageBox
|
||||
|
||||
QMessageBox.critical(
|
||||
None, "Bolt Console",
|
||||
"Missing " + ", ".join(config.missing_config())
|
||||
+ ".\n\nCopy .env.example to .env next to the app and fill them in.")
|
||||
return 2
|
||||
window = MainWindow(mode="auto" if "--auto" in argv else None)
|
||||
window.show()
|
||||
return app.exec()
|
||||
@@ -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.)"
|
||||
)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user