turbo-editors/turbo-corepublic Fork 0
v0.9.0
Commits
Clone
git clone https://git.rickub.com/turbo-editors/turbo-core.git
git clone ssh://git@rickub.com/turbo-editors/turbo-core.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

🛟 Updated. 28d5985 · on v0.9.0 · k33g · 13h ago
README.md · 47 lines · 4.9 KBmarkdown
Blame HistoryOpen raw

acp

A client for the Agent Client Protocol: 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.

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.

Layers

File What it is
config.go acp.toml: the agents a project and a user list, merged, with the project's winning
create.go The starter file a menu item writes
framing.go Newline-delimited messages, as a jsonrpc.Framer
protocol.go The wire types, and every method name in one place
process.go The child: its pipes joined into one stream, its standard error drained
trace.go TURBO_ACP_TRACE=<file>: every line in both directions, for "what did the agent actually send?"
session.go The conversation: handshake, prompts, cancellation, permissions, the file methods
transcript.go The conversation as a value — no terminal, no colours
session_files.go The file half: fs/read_text_file, fs/write_text_file, and reading a mentioned file's text
mention.go A file named with @, and the content blocks a prompt becomes — text, resource, resource_link
render.go Entries into drawn lines: fences, wrapping, styles
view.go view_draw.go view_events.go The widget: the conversation above, a box to type in below
picker.go picker_draw.go The popup / and @ open in the box: the agent's commands, the project's files

NewSession takes an io.ReadWriteCloser, not a command — the same seam 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.

Seven things that are easy to get wrong

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.

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.

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.

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.

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.

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.

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.

Tests

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.

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.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
# acp

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.

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.

## Layers

| File | What it is |
| --- | --- |
| `config.go` | `acp.toml`: the agents a project and a user list, merged, with the project's winning |
| `create.go` | The starter file a menu item writes |
| `framing.go` | Newline-delimited messages, as a `jsonrpc.Framer` |
| `protocol.go` | The wire types, and every method name in one place |
| `process.go` | The child: its pipes joined into one stream, its standard error drained |
| `trace.go` | `TURBO_ACP_TRACE=<file>`: every line in both directions, for "what did the agent actually send?" |
| `session.go` | The conversation: handshake, prompts, cancellation, permissions, the file methods |
| `transcript.go` | The conversation as a **value** — no terminal, no colours |
| `session_files.go` | The file half: `fs/read_text_file`, `fs/write_text_file`, and reading a mentioned file's text |
| `mention.go` | A file named with `@`, and the content blocks a prompt becomes — text, `resource`, `resource_link` |
| `render.go` | Entries into drawn lines: fences, wrapping, styles |
| `view.go` `view_draw.go` `view_events.go` | The widget: the conversation above, a box to type in below |
| `picker.go` `picker_draw.go` | The popup `/` and `@` open in the box: the agent's commands, the project's files |

`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.

## Seven things that are easy to get wrong

**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.

**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.

**`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.

**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.

**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.

**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.

**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.

## Tests

`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.

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.