Skip to content

Commit 5155c8f

Browse files
committed
Merge branch 'tauri-desktop'
2 parents beb6bf8 + 669d4ad commit 5155c8f

85 files changed

Lines changed: 15244 additions & 1 deletion

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.gitignore‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
!/.github
66
!/cecli
77
!/benchmark
8+
!/desktop
89
!/docker
910
!/requirements
1011
!/scripts

‎desktop/.gitignore‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
node_modules/
2+
__pycache__/
3+
src-tauri/target/
4+
src-tauri/gen/
5+
dist/
6+
*.log

‎desktop/CONTRACT.md‎

Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
# desktop bridge contract
2+
3+
A localhost-only browser UI for cecli that talks to `cecli --acp` over stdio.
4+
The Python bridge and the HTTP/SSE payloads below are the contract; the UI is a
5+
Lit client built from `ui/` with Vite.
6+
7+
## Runtime layout
8+
9+
```
10+
desktop/
11+
server.py Python 3.10+ stdlib only; HTTP + SSE <-> ACP stdio bridge
12+
ui/index.html Vite entry document
13+
ui/vite.config.js root=ui/, dev proxy for /api, build output ../static
14+
ui/src/main.js module entry; starts the SSE session
15+
ui/src/core/ store, reducers, SSE events, snapshot, API, markdown, format
16+
ui/src/components/ Lit light-DOM components (no Shadow DOM)
17+
ui/src/styles/ Tailwind/daisyUI entry and component-scoped CSS
18+
static/index.html generated by `npm run ui:build`
19+
static/assets/ generated, content-hashed JS and CSS bundles
20+
tests/ optional test fixtures
21+
README.md user documentation
22+
```
23+
24+
The UI uses Lit, Tailwind CSS v4 and daisyUI. Vite compiles `ui/` into
25+
`static/`; the bridge serves `static/index.html` at `/` and the hashed bundles
26+
under `/static/assets/`. Run `npm run ui:build` in `desktop/` after changing
27+
`ui/`. Python needs no Node at runtime once `static/` has been built, and the
28+
page makes no external requests: all framework code is bundled.
29+
30+
The server never requires third-party packages. The browser app never loads
31+
external fonts, scripts, or styles.
32+
33+
## ACP facts the bridge relies on
34+
35+
cecli speaks newline-delimited JSON-RPC 2.0 on stdin/stdout when launched as
36+
`cecli --acp`. stdout carries protocol only; stderr carries logs.
37+
38+
Handshake order:
39+
40+
1. `initialize` request `{"protocolVersion": 1, "clientCapabilities": {}}`
41+
2. `session/new` request `{"cwd": "<absolute workspace>", "mcpServers": []}`
42+
the session/new response is deferred until the coder is ready and contains
43+
`{"sessionId": "...", "configOptions": [...], "modes": {...}}`.
44+
3. `session/prompt` request `{"sessionId": "...", "prompt": [{"type": "text", "text": "..."}]}`
45+
The response `{"stopReason": "end_turn" | "cancelled"}` arrives when the turn ends.
46+
4. `session/cancel` notification `{"sessionId": "..."}` (no id, no response).
47+
5. `session/set_config_option` request `{"sessionId": "...", "configId": "mode", "value": "..."}`
48+
Config options look like `{"id", "name", "category", "type": "select",
49+
"currentValue", "options": [{"value", "name", "description"} | {"group", "name", "options": [...]}]}`.
50+
51+
Server-to-client requests: `session/request_permission` with
52+
`{"sessionId", "toolCall": {"toolCallId", "content": [...]}, "options": [{"optionId", "name", "kind"}]}`.
53+
Kinds are `allow_once`, `allow_always`, `reject_once`, `reject_always`.
54+
Reply with result `{"outcome": {"outcome": "selected", "optionId": "<id>"}}`
55+
or `{"outcome": {"outcome": "cancelled"}}`.
56+
57+
Notifications: `session/update` with `{"sessionId", "update": {...}}`. Update kinds:
58+
59+
- `agent_message_chunk` `{content: {type: "text", text}}` — append to assistant text
60+
- `agent_thought_chunk` `{content: {type: "text", text}}` — append to reasoning text
61+
- `tool_call` `{toolCallId, title, kind, status, rawInput?, content?, locations?}`
62+
- `tool_call_update` `{toolCallId, status?, title?, content?, kind?}`
63+
- `tool_call_content_chunk` `{toolCallId, content: {type: "text", text}}`
64+
- `plan` `{entries: [{content, priority, status}]}` where status is
65+
`pending` | `in_progress` | `completed`
66+
- `usage_update` `{used, size, cost?: {amount, currency}}`
67+
- `config_option_update` `{configOptions}`
68+
- `current_mode_update` `{currentModeId}`
69+
- `state_update` `{state, stopReason?}` where state is `running` | `idle` | `requires_action`
70+
- others (`session_message`, `subagent_update`, ...) must be ignored gracefully.
71+
72+
Tool `content` entries look like `{"type": "content", "content": {"type": "text", "text": "```\n...\n```"}}`.
73+
Treat tool content as untrusted text.
74+
75+
## Server CLI
76+
77+
```
78+
python3 desktop/server.py [--host 127.0.0.1] [--port 8765] [--workspace DIR]
79+
[--cecli PATH] [--open] [-- extra cecli args...]
80+
```
81+
82+
- `--workspace` defaults to the current directory and is resolved to an absolute path.
83+
- `--cecli` defaults to `$CECLI_BIN`, then `shutil.which("cecli")`, then `"cecli"`.
84+
- The child command is `[cecli, "--acp"] + extra_args`, launched with `cwd=workspace`.
85+
- Bind loopback only by default; warn if `--host` is not a loopback address.
86+
- Print the listening URL on startup.
87+
- Handle SIGINT/SIGTERM by terminating the child and exiting cleanly.
88+
89+
## HTTP API
90+
91+
All responses are JSON unless noted. Unknown paths return 404.
92+
93+
| Method | Path | Body | Response |
94+
|--------|-------------------|----------------------------------------|----------|
95+
| GET | `/` | - | index.html |
96+
| GET | `/static/<file>` | - | static file (path traversal denied) |
97+
| GET | `/api/health` | - | `{"ok": true, "status": "..."}` |
98+
| GET | `/api/state` | - | snapshot, see below |
99+
| GET | `/api/events` | - | SSE stream, see below |
100+
| POST | `/api/prompt` | `{"text": "..."}` | 202 `{"ok": true}`; 409 if busy/not ready; 400 if empty |
101+
| POST | `/api/cancel` | - | 202 `{"ok": true}` |
102+
| POST | `/api/new_session`| `{"cwd": "..."}` optional | 202; 409 if busy |
103+
| POST | `/api/permission` | `{"requestId": 7, "optionId": "y"}` or `{"requestId": 7, "optionId": null}` | 200 `{"ok": true}`; 404 unknown requestId |
104+
| POST | `/api/config` | `{"configId": "mode", "value": "..."}`| 202 `{"ok": true}` |
105+
| POST | `/api/restart` | - | 202, kills and respawns the child |
106+
107+
State snapshot:
108+
109+
```json
110+
{
111+
"status": "starting | ready | exited",
112+
"busy": false,
113+
"workspace": "/abs/path",
114+
"sessionId": "...",
115+
"configOptions": [],
116+
"modes": {},
117+
"agent": {"name": "cecli", "version": "..."},
118+
"permissions": [{"requestId": 7, "toolCall": {}, "options": []}],
119+
"clients": 1
120+
}
121+
```
122+
123+
## SSE events
124+
125+
`GET /api/events` responds with `Content-Type: text/event-stream`,
126+
`Cache-Control: no-cache`, `Connection: keep-alive`, `X-Accel-Buffering: no`,
127+
sends `retry: 2000`, then JSON on unnamed `data:` lines:
128+
129+
```
130+
data: {"type": "status", "status": "ready"}
131+
132+
```
133+
134+
Event types:
135+
136+
- `status` — `{status: "starting"|"ready"|"exited", detail?, agent?}`
137+
- `session` — `{sessionId, configOptions, modes}`
138+
- `update` — `{sessionId, update}` (raw ACP update, pass-through)
139+
- `permission` — `{requestId, sessionId, toolCall, options}`
140+
- `permission_resolved` — `{requestId, optionId}` (`optionId` null when dismissed)
141+
- `prompt_done` — `{stopReason}`
142+
- `log` — `{message, level}` (child stderr, server notices)
143+
- `error` — `{message}`
144+
145+
On connect, immediately send the current `status` and, if a session exists, a
146+
`session` event so a late or reconnecting client can render itself.
147+
Pending permission requests are replayed the same way, so reloading the page
148+
during an approval does not strand the turn.
149+
A heartbeat comment `: ping` goes out every 15 seconds. Remove dead clients.
150+
151+
## Behaviour requirements
152+
153+
- One cecli child and one session per server process. Start the child on
154+
demand (first API use) or at boot; either is fine, but `/api/state` must
155+
report the truth.
156+
- Queue nothing: a prompt while busy is rejected with 409. The UI disables send.
157+
- When the child exits, resolve open permission requests as cancelled, emit
158+
`status exited`, and reject further prompts with 409/503.
159+
- `session/cancel` must be a notification (no id), then the outstanding
160+
`session/prompt` response reports `cancelled`.
161+
- Permission request ids are the JSON-RPC ids, which may be numbers or strings.
162+
- Requests from the child that are not `session/request_permission` must be
163+
answered with a JSON-RPC error (method not found) rather than hanging.
164+
- Never block the event loop on subprocess I/O; use asyncio subprocesses.
165+
- Cap request bodies (e.g. 1 MiB) and return 413 beyond that.
166+
- Reject `Transfer-Encoding` requests (chunked bodies are unsupported) with 400.
167+
- Require `Content-Type: application/json` on `/api/*` POSTs and return 415 otherwise.
168+
- Reject requests whose `Host` is not the bound host or a loopback name, and
169+
reject cross-origin `Origin` values, both with 403. These stop DNS-rebinding
170+
reads and plain-text cross-site POSTs against the local bridge.
171+
- Escape nothing server-side; the UI escapes all text it renders.
172+
173+
## UI requirements
174+
175+
- Clean, light chat layout: soft lavender wash, white surfaces, subtle borders,
176+
and a centred single-column transcript. No sidebar or always-open settings panel.
177+
- Header: brand, workspace path, connection status. Before the first turn the
178+
transcript shows a large empty state with the cecli ASCII wordmark (the same
179+
art as the cecli.dev hero, without its tagline), a short greeting, and example
180+
prompt chips.
181+
- Control strip under the composer: config select chips for the ACP config
182+
options (mode, model, effort, ...), a compact context-usage badge, and icon
183+
buttons for logs, new chat, and restart. Logs open as a popover above the
184+
strip. Responsive down to 720px; usable at 380px, where the strip wraps.
185+
- Transcript items: user message, assistant message (streamed, small blinking
186+
cursor while busy), collapsible thoughts, tool cards with status chips and
187+
expandable output, plan checklist, permission cards with option buttons,
188+
error notices. Auto-scroll unless the user scrolled up.
189+
- Composer: Enter sends, Shift+Enter newlines, disabled while busy; send button
190+
becomes Stop while a turn runs. Show stop/retry affordances.
191+
- Render a safe subset of markdown: fenced code, inline code, bold, italic,
192+
headings, links (http/https only), ordered/unordered lists, blockquotes.
193+
Always escape HTML first.
194+
- No external network requests from the page at runtime. Lit, daisyUI and
195+
Tailwind are bundled at build time; there is no CDN.
196+
- Reconnect to `/api/events` automatically with backoff; resync state on open.
197+
- Keyboard accessible; permission buttons reachable by tab; `Esc` cancels a turn
198+
when the composer is not focused.

‎desktop/README.md‎

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
# cecli desktop
2+
3+
A local, clean chat interface for [cecli](../README.md). The browser
4+
talks to a tiny Python bridge on localhost, and the bridge talks to a real
5+
`cecli --acp` process over newline-delimited JSON-RPC on stdio. No model SDKs,
6+
and no external assets at runtime. The UI is a Lit single-page app built from
7+
`ui/` with Vite, Tailwind CSS and daisyUI.
8+
9+
```
10+
browser <-- HTTP + SSE --> desktop/server.py <-- ACP / stdio --> cecli --acp
11+
```
12+
13+
## Run it
14+
15+
From the repository root:
16+
17+
```bash
18+
python3 desktop/server.py --workspace /path/to/your/project
19+
```
20+
21+
Then open <http://127.0.0.1:8765>. The workspace defaults to the current
22+
directory, and that directory is the project the agent edits. A built UI ships
23+
in `static/`; after changing `ui/`, rebuild it with `cd desktop && npm install &&
24+
npm run ui:build`.
25+
26+
Useful flags:
27+
28+
```bash
29+
python3 desktop/server.py \
30+
--workspace ~/code/my-app \
31+
--port 8765 \
32+
--cecli /home/dwash/cecli/venv/bin/cecli \
33+
--open
34+
```
35+
36+
`--cecli` falls back to `$CECLI_BIN`, then `cecli` on `PATH`. Anything after
37+
`--` is forwarded to cecli, for example `-- --model gpt-5 --yes`.
38+
39+
> The server binds loopback only unless you pass `--host`. Treat it as a local
40+
> tool: it can run shell commands through the agent in the chosen workspace.
41+
> API calls must be JSON with a trusted `Host`/`Origin`, which blocks cross-site
42+
> and DNS-rebinding requests, but there is still no authentication on the port.
43+
44+
## Tauri desktop app (optional)
45+
46+
`src-tauri/` wraps the same bridge in a Tauri v2 window: the Rust shell starts
47+
`server.py` on a free loopback port, waits for it to answer, loads it in the
48+
system webview, and kills the Python child on exit. The UI, JSON API, and SSE
49+
stream are unchanged, and the page gets no Tauri IPC access.
50+
51+
Prerequisites (Rust, WebKitGTK development headers, Node 20.19+ or 22.12+), environment
52+
overrides, and packaging notes are in [TAURI.md](TAURI.md).
53+
54+
```bash
55+
cd desktop
56+
npm install
57+
npm run gen:icons
58+
npm run dev # or: npm run build (both rebuild the UI first)
59+
```
60+
61+
The workspace defaults to the app's working directory (Tauri dev uses the repo
62+
root); set
63+
`CECLI_DESKTOP_WORKSPACE` to choose a project, and `CECLI_DESKTOP_CECLI` to
64+
pick a specific cecli executable.
65+
66+
## What the UI gives you
67+
68+
- Streaming assistant text and collapsible reasoning, rendered as safe markdown.
69+
- Live tool cards (read, edit, execute, search, think) with status and output.
70+
- Plan checklists from the agent's todo updates.
71+
- Inline permission prompts for commands and edits, with the same options the
72+
terminal UI offers (yes / yes to all / skip / no).
73+
- The cecli ASCII wordmark, centred before the first turn.
74+
- A control strip under the composer: mode/model/thinking-effort chips from the
75+
ACP config options, a compact context-usage badge, and icon actions for logs,
76+
new chat, and restart.
77+
- Stop / cancel; send becomes a stop button while a turn runs.
78+
79+
## Requirements
80+
81+
- Python 3.10+ on the machine running the bridge. Only the standard library is
82+
used (`asyncio`, `http`, `json`, `subprocess`), so there is nothing to install.
83+
- A working `cecli` installation and whatever API keys your normal `cecli`
84+
session uses.
85+
- Node 20.19+ or 22.12+ and npm only when rebuilding the UI or the Tauri app.
86+
The Python bridge and the committed `static/` output need no Node at runtime.
87+
88+
## Troubleshooting
89+
90+
- **Connection dot stays amber.** The agent is still starting or configuring the
91+
project. Watch the logs drawer in the header; cecli's stderr is mirrored there.
92+
- **`cecli exited` status.** Restart from the header or rerun the server. Run
93+
`cecli --acp` in the workspace by hand to see its startup errors.
94+
- **Nothing renders.** Check the browser console and <http://127.0.0.1:8765/api/state>.
95+
- **Permissions never appear.** The agent only asks when it needs approval; if
96+
you launched cecli with `--yes`, it will not prompt at all.
97+
98+
## Development
99+
100+
```bash
101+
cd desktop
102+
npm install
103+
npm run ui:build # Vite -> desktop/static
104+
npm run ui:dev # optional: Vite dev server, proxies /api to :8765
105+
npm run ui:check # fail if static/ does not match ui/ sources
106+
npm run test:ui # node:test reducers, snapshots, session actions
107+
cd ..
108+
python3 -m py_compile desktop/server.py
109+
python3 desktop/tests/test_bridge.py -v # 20 tests, fake ACP agent, no model calls
110+
```
111+
112+
`npm run ui:dev` serves `ui/` on Vite's port and proxies `/api` to an already
113+
running bridge at <http://127.0.0.1:8765>; production always uses the Vite
114+
build in `static/`. Run `npm run ui:check` before committing UI changes: it
115+
compares the source hash recorded by the last build and exits non-zero if
116+
`static/` is stale.
117+
118+
`ui/` is the only hand-edited frontend source; `static/index.html` and the
119+
content-hashed `static/assets/` bundles are build output. The build records a
120+
hash of `ui/` plus `package.json` in `static/.build-hash`, so `npm run ui:check`
121+
catches a forgotten rebuild. The bridge denies dotfiles, so that hash file is
122+
never served.
123+
124+
Before release, walk the built UI against the fake agent
125+
(`desktop/tests/fake_acp_agent.py`): send a prompt, watch text, thoughts, a tool
126+
card and a plan stream in, approve and reject a permission, cancel a turn, reload
127+
during a permission prompt, and resize to 380 px. All of that exercises the SSE
128+
replay, reducer and responsive paths without model calls.
129+
130+
The wire protocol and API are documented in [CONTRACT.md](CONTRACT.md).

0 commit comments

Comments
 (0)