turbo-editors/turbo-corepublic Fork 0
b1c5e36e1d1a12e805a39c391e69028b649e6a87
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.

README.md · 52 lines · 7.3 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
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
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.

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.

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.

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
48
49
50
51
52
# 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` |
| `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 |
| `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.

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

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

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