| 🛟 Updated. 28d5985 k33g yesterday | 1 | # acp |
| 2 | |
| 3 | A client for the [Agent Client Protocol](https://agentclientprotocol.com): it starts a coding agent as a child process, holds a conversation with it, and keeps a model of that conversation a window can draw. |
| 4 | |
| 5 | The editor holds no API key, knows no provider, and implements no tool-calling loop. All of that is the agent's own configuration, in a file this package does not read. |
| 6 | |
| 7 | ## Layers |
| 8 | |
| 9 | | File | What it is | |
| 10 | | --- | --- | |
| 11 | | `config.go` | `acp.toml`: the agents a project and a user list, merged, with the project's winning | |
| 12 | | `create.go` | The starter file a menu item writes | |
| 13 | | `framing.go` | Newline-delimited messages, as a `jsonrpc.Framer` | |
| 14 | | `protocol.go` | The wire types, and every method name in one place | |
| 15 | | `process.go` | The child: its pipes joined into one stream, its standard error drained | |
| 16 | | `trace.go` | `TURBO_ACP_TRACE=<file>`: every line in both directions, for "what did the agent actually send?" | |
| 17 | | `session.go` | The conversation: handshake, prompts, cancellation, permissions, the file methods | |
| 18 | | `transcript.go` | The conversation as a **value** — no terminal, no colours | |
| 19 | | `session_files.go` | The file half: `fs/read_text_file`, `fs/write_text_file`, and reading a mentioned file's text | |
| 20 | | `mention.go` | A file named with `@`, and the content blocks a prompt becomes — text, `resource`, `resource_link` | |
| 📦 Turbo Core — files rewritten outside reload into their windows; the agent window wraps what you type and keeps a long paste aside as a token (#28); configrepo fetches a shared .turbo-<slug> from a forge URL (#25) fe23288 k33g 11h ago | 21 | | `paste.go` | A paste kept aside: the `[Pasted #1 · 6 lines · 700 chars]` token in the box, and the text the agent gets for it | |
| 🛟 Updated. 28d5985 k33g yesterday | 22 | | `render.go` | Entries into drawn lines: fences, wrapping, styles | |
| 23 | | `view.go` `view_draw.go` `view_events.go` | The widget: the conversation above, a box to type in below | |
| 24 | | `picker.go` `picker_draw.go` | The popup `/` and `@` open in the box: the agent's commands, the project's files | |
| 25 | |
| 26 | `NewSession` takes an `io.ReadWriteCloser`, not a command — the same seam [`lsp`](../lsp/) has, and for the same reason. The whole client is driven from a test against an agent **in the same process**, over `net.Pipe`: real framing, real concurrency, real decoding, no subprocess to reap and no model to reach. `Start` is the thin layer that builds that stream out of a child process's pipes. |
| 27 | |
| 28 | ## Seven things that are easy to get wrong |
| 29 | |
| 30 | **The agent numbers its requests from one, and so do we.** `docker agent` really does send `id: 1` while a call of ours carrying the same id is in flight. The two directions are separate id spaces, which is `jsonrpc`'s business — but it is why this client could not have been built on a connection that shared one map. |
| 31 | |
| 32 | **A permission cannot be answered where it arrives.** `session/request_permission` reaches the reading goroutine, and the answer comes from a dialog somebody has to look at. `Session` records a `*Permission` and wakes the event loop; the dialog opens on the next turn and calls `Answer`. `Answer` and `Cancel` write on a goroutine of their own: an agent that has stopped reading must not be able to freeze the editor on the keystroke that answers it. |
| 33 | |
| 34 | **`content` is two different shapes under one name.** A message chunk carries a single content block; a `tool_call_update` carries an array of them. It is kept as `json.RawMessage` and decoded per kind — decoding eagerly into either breaks on the other, and only once an agent uses a tool. |
| 35 | |
| 36 | **A reply arrives one token at a time.** `"I"`, `" found"`, `" agent"`, `".yaml"`. `Transcript` coalesces a run of chunks into one entry; an entry per chunk could be neither wrapped nor told apart from a fenced code block. |
| 37 | |
| 38 | **A command is a text prompt, not a method.** `available_commands_update` tells the client what the agent answers to — `/web`, `/compact` — and the client sends the command as the text `"/web agent client protocol"`, exactly as Zed does. There is nothing else on the wire, so an agent that documents its commands for Zed has documented them for this editor. The picker that lists them is a convenience over that fact, not a protocol feature. |
| 39 | |
| 📦 Turbo Core — files rewritten outside reload into their windows; the agent window wraps what you type and keeps a long paste aside as a token (#28); configrepo fetches a shared .turbo-<slug> from a forge URL (#25) fe23288 k33g 11h ago | 40 | **The box wraps what you type; the lines are still yours.** `input` holds the lines Alt-Enter made, and `input.go` lays them out as rows at the pane's width on every frame and every arrow key — nothing is stored, so a resize cannot leave the two apart. A row breaks after the last space that fits, keeping that space at the end of the row, so the runes of a line are exactly its rows laid end to end and every cursor position belongs to one row. A row is `W-3` runes wide — prompt, space, and one column kept free so the cursor just past a full row is inside the pane; before this, `TextLimited` cut the line off with `…` and `ShowCursor` hid a cursor that had reached the frame. `↑`/`↓` move by row, not by line: a paragraph wrapped three times is three rows to the eye. The rows shown end with the cursor's, so a prompt taller than the box scrolls under the rule, and the `>` marker goes with the first row rather than standing beside whatever is on top. |
| 41 | |
| 42 | **A long paste is a token in the box and its text on the wire.** `View.Paste` takes what was pasted — from the terminal, bracketed and handed over whole by `app`, or from the editor's clipboard through `OnPaste` on Ctrl-V / Shift-Ins / Edit ▸ Paste — and either types it (one line, up to `pasteInlineLimit` runes: a path, a command) or keeps it in `pastes` and inserts `[Pasted #N · L lines · C chars]` where the cursor is. `Send` calls `Session.PromptWith(text, pastesIn(text), mentions…)`: the transcript keeps the text with the tokens in it, so the exchange stays readable, and `expandPastes` replaces each token by its text **inside the text blocks only, after `blocksFor` has cut the mentions out** — a paste goes byte for byte, so an `@name` inside a pasted log is characters, not a file. A token whose text was deleted with it is simply absent from `pastesIn`. Tokens are found in a line by their text (`tokensIn`), never remembered by position; Backspace after one and Delete before one remove it whole, `←`/`→` step over it, and a move by row that lands inside one is pushed to the nearer edge (`leaveToken`) — a half token stands for nothing. It is drawn in the type style, as a tool call is: a thing the agent will be handed, not words. Numbers run on for the life of the window, so `#1` in an earlier exchange is not `#1` in this one. |
| 43 | |
| 🛟 Updated. 28d5985 k33g yesterday | 44 | **A mention takes the name out of the text.** `@app/menus.go` in the box becomes a content block *in place* — the file's text as a `resource` when the agent declared `promptCapabilities.embeddedContext`, a `resource_link` otherwise — with text blocks either side. The conversation keeps what was typed; the wire does not carry the name twice. |
| 45 | |
| 46 | **Only a fence makes a block code.** No scanner is guessed at from the shape of the text, which is the rule every scanner in this library already follows. A fence naming a language nothing colours is drawn plainly. |
| 47 | |
| 48 | ## Tests |
| 49 | |
| 50 | `go test ./acp/` drives the client against a fake agent written by hand — deliberately not built on this package's own types, because a peer that shared them could not catch the client encoding a field wrongly. The shapes it replays are copied from a recorded conversation with `docker agent` v1.139.0 against a local llama.cpp, not invented. |
| 51 | |
| 52 | The drawing tests use a session whose agent never answers, so the conversation is whatever the test puts into it. A test that asserts on a screen while a live process writes to it passes or fails by luck, and one such test hid a real fault here for a whole session. |