Files
Bolt-Console/CLAUDE.md
T
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

123 lines
8.6 KiB
Markdown

# 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
```bash
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 `ChatTask`s 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.