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>
8.6 KiB
CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
What this is
A PySide6 desktop app (default) and a terminal REPL (--cli), both Claude Code-style, on top of an
external Bolt server's desk API. It uses the same
brain, memory, tools and persona as the server's Discord bot and its other desk clients. There is
no import dependency on the server repo: it's a standalone HTTP client configured through its
own .env. It was forked from Bolt-Pet with the desktop pet, wake word, STT/TTS, screen
context/OCR, notifications, auto-updater and self-restart removed.
Pipeline: typed line → + cwd/OS note → POST /desk/converse_stream (or /desk/converse) → [server relays a command: filectl file op, or a shell command, after y/n approval] → /desk/tool_result → … → reply printed.
Commands
run.bat / run-cli.bat / ./run.sh [--cli] # window / terminal; creates .venv; keeps the caller's cwd
python -m bolt_console --doctor [--deep]
python -m bolt_console -p "message" # one-shot
.venv/Scripts/python -m pytest tests # (.venv/bin on Linux/macOS)
Runtime dependencies: requests, plus PySide6 for the window only. bolt_console.gui is imported
lazily, so --cli works without Qt. The local venv is Python 3.9, so no match, no
runtime X | Y unions, and no builtin generics outside annotations.
Architecture (bolt_console/)
config.py: a small hand-written.envparser feeding module constants. Nothing else readsos.environ.SESSION_IDdefaults topet-console-<host>. Thepet-prefix is deliberate: the server advertisesfilectlto pet sessions, and-consolekeeps this a separate conversation from a Bolt-Pet on the same machine.new_session()(New chat //new) rotates to<base>-<timestamp>and remembers it in~/.cache/bolt-console/session.json.DESK_SESSION_IDin.envoverrides the remembered id. Tests must monkeypatchconfig.STATE_FILE.server_client.py: the desk protocol._relay()is the shared command → tool_result loop.converse_stream()hands eachsaysentence over as it arrives and finishes a tool call through_relay. It raisesServerErroronly if nothing was shown yet, which lets the caller fall back toconverse()invisibly.Reply.streamedmeans "already on screen, don't print again".run_local_commandruns inos.getcwd()with stdin closed andCREATE_NO_WINDOW, because under pythonw every cmd.exe would otherwise flash a console.tools.py:Tools, theon_commandcallable. It routesfilectltofile_ops, answerspetctl/dialoguectlwithPET_UNAVAILABLE(the server may still emit them), and treats anything else as a shell command. Approval: reads and lists never ask. Shell, write and edit ask viaui.confirmunlessmode == "auto", and "always" switches the session to auto. A decline returnsDECLINEDto the model rather than raising, so the turn still completes.file_ops.py:filectl {json}list/read/write/edit. It is a single-line JSON wire format because the server'scommandmarker only captures to the first newline.editmust match exactly once. Parsing (parse) is separate from I/O (execute).relay_json.py: lenient JSON for model-typed payloads. It tries strict parsing first, then named repairs (fences, smart quotes, invalid backslash escapes, stray quotes, trailing commas, Python literals, single quotes), and accepts a repair only if the result parses. Repairs are always reported back to the model, never silent. The backslash repair only touches invalid escapes, soC:\tempstill parses as a tab (that's ambiguous, so it's left alone).ui.py:Terminalhandles plain ANSI rendering, the spinner thread, tool-call/result lines, diffs,confirmandread_message. All output goes through_emitunder the same lock the spinner draws under. The spinner redraws by erasing the current line, so output is always written as whole lines (seeassistant_chunk). Glyphs fall back to ASCII on non-UTF-8 streams.console.py:Session(one turn, delivered-file download, slash commands,!local shell, the loop) andrun()(argv). Collaborators are injected so tests can drive it.gui/: the window.bridge.Bridgeimplements the same surface asui.Terminal(tool_call, tool_result, confirm, assistant_chunk, …) with each call emitting a Qt signal, so the window reusesconsole.SessionandToolsunchanged.window.TurnRunnerrunsSession.sendon a QThread.Bridge.confirmblocks the worker on an Event until a ToolCard's Allow/Deny callsanswer(), andclose()releases it as "no".window.MainWindowreacts only to Bridge signals.widgets.pyholds the transcript pieces (ToolCard, AssistantMessage, Composer, …).theme.pyholds the colours and the one stylesheet (widgets set object names and dynamic properties for it, thenrepolish()).icons.pydraws every icon with QPainter, so there are no asset files.markdown_htmlswaps Qt's fixed font for code (Courier New on Windows) with the app's mono font.dictation.py: the mic button. It streams 16 kHz int16 to the server's/desk/sttwebsocket (Bolt-Pet's route: the server relays to Deepgram) and buffers every frame. If the live socket never opened or died,_fallback()sends the whole buffer once from an empty transcript, so a half-live session's words don't come back twice. A healthy socket is never re-sent, even when it heard nothing, because that would pay twice. Stops on click, onsilence_secondsafter speech, or onmax_seconds. No Qt and no numpy (the array module does RMS), so it's identical in the Linux single file. The mic and socket are injected. The window turns the callbacks intoDictationSignals, and the composer is read-only while a recording rewrites it. PySide6 6.10 needsQTextCursor.MoveOperation.End, notcursor.End.- Chats:
server_client.list_chats()/create_chat()wrapGET/POST /desk/chats(the server mintschat-…ids; a chat is only listed once used). Switching isconfig.set_session(id): a plain reassignment of theSESSION_IDevery call reads, persisted tosession.json, and deliberately not locked. The window refuses to switch mid-turn instead.gui/chat_list_window.pyruns list/create asChatTasks onQThreadPool.globalInstance()and hands results back throughChatSignals, never calling the network from a slot. The header New chat uses the same pool.Session.new_conversation()(terminal/new) is blocking and falls back to a local id if the server can't mint one.chats.pyholds the Qt-free row helpers (the preview drops the[Bolt Console: …]and[on screen right now: …]notes). file_delivery.py: saves files the server queues viadeliver_files. The name is reduced to its basename and never overwrites an existing file.Session.check_deliveriesruns after every turn.doctor.py: independent checks, each reporting ok/warn/fail plus what to do about it. A check never raises.
Testing conventions
tests/test_gui.py sets QT_QPA_PLATFORM=offscreen itself and drives real turns through
MainWindow and its worker thread against the loopback server (pump_until processes events).
Offscreen Qt has no font database, so check the look by rendering with the real platform plus
WA_DontShowOnScreen and grab(), not offscreen.
Tests cover logic with injected fakes (FakeUI, FakeShell, a Terminal writing to StringIO
with a scripted input_fn). tests/test_console.py is the end-to-end file: a real threaded HTTP
server on a loopback port driven through the real server_client, Tools and Terminal. Add a
case there when a bug crosses module boundaries. Tests that embed a path in JSON must use
json.dumps(str(path)), because Windows backslashes are otherwise invalid escapes.
Fabricated tool results
The model sometimes writes tool output as prose instead of emitting a command. The tool marker
syntax in console.annotate() is load-bearing: vague wording ("use filectl") measurably caused
this against the live server, and spelling out command: ... fixed it. console.fabricated()
flags a reply shaped like tool output when Tools.calls didn't move, and the next message carries
CORRECTION. A polluted session keeps copying its own history, so the remedy is New chat.
Security notes
Approved shell commands, writes and edits run as the user with no sandbox. auto mode removes the
prompt. That is the same "full desktop control" trust model as the server's other desk clients,
which is why ask is the default here. Keep DESK_API_KEY private.