Files
themajesticmagicianandClaude Opus 5.5 3977804ae3 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>
2026-09-25 19:50:10 -06:00

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 .env parser feeding module constants. Nothing else reads os.environ. SESSION_ID defaults to pet-console-<host>. The pet- prefix is deliberate: the server advertises filectl to pet sessions, and -console keeps 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_ID in .env overrides the remembered id. Tests must monkeypatch config.STATE_FILE.
  • server_client.py: the desk protocol. _relay() is the shared command → tool_result loop. converse_stream() hands each say sentence over as it arrives and finishes a tool call through _relay. It raises ServerError only if nothing was shown yet, which lets the caller fall back to converse() invisibly. Reply.streamed means "already on screen, don't print again". run_local_command runs in os.getcwd() with stdin closed and CREATE_NO_WINDOW, because under pythonw every cmd.exe would otherwise flash a console.
  • tools.py: Tools, the on_command callable. It routes filectl to file_ops, answers petctl/dialoguectl with PET_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 via ui.confirm unless mode == "auto", and "always" switches the session to auto. A decline returns DECLINED to 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's command marker only captures to the first newline. edit must 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, so C:\temp still parses as a tab (that's ambiguous, so it's left alone).
  • ui.py: Terminal handles plain ANSI rendering, the spinner thread, tool-call/result lines, diffs, confirm and read_message. All output goes through _emit under the same lock the spinner draws under. The spinner redraws by erasing the current line, so output is always written as whole lines (see assistant_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) and run() (argv). Collaborators are injected so tests can drive it.
  • gui/: the window. bridge.Bridge implements the same surface as ui.Terminal (tool_call, tool_result, confirm, assistant_chunk, …) with each call emitting a Qt signal, so the window reuses console.Session and Tools unchanged. window.TurnRunner runs Session.send on a QThread. Bridge.confirm blocks the worker on an Event until a ToolCard's Allow/Deny calls answer(), and close() releases it as "no". window.MainWindow reacts only to Bridge signals. widgets.py holds the transcript pieces (ToolCard, AssistantMessage, Composer, …). theme.py holds the colours and the one stylesheet (widgets set object names and dynamic properties for it, then repolish()). icons.py draws every icon with QPainter, so there are no asset files. markdown_html swaps 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/stt websocket (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, on silence_seconds after speech, or on max_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 into DictationSignals, and the composer is read-only while a recording rewrites it. PySide6 6.10 needs QTextCursor.MoveOperation.End, not cursor.End.
  • Chats: server_client.list_chats()/create_chat() wrap GET/POST /desk/chats (the server mints chat-… ids; a chat is only listed once used). Switching is config.set_session(id): a plain reassignment of the SESSION_ID every call reads, persisted to session.json, and deliberately not locked. The window refuses to switch mid-turn instead. gui/chat_list_window.py runs list/create as ChatTasks on QThreadPool.globalInstance() and hands results back through ChatSignals, 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.py holds 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 via deliver_files. The name is reduced to its basename and never overwrites an existing file. Session.check_deliveries runs 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.