| 🛟 Updated. 28d5985 k33g yesterday | 1 | // Agent windows: a coding agent running as a child process, in a window of the |
| 2 | // editor's own, talking the Agent Client Protocol. |
| 3 | |
| 4 | package app |
| 5 | |
| 6 | import ( |
| 7 | "fmt" |
| 8 | "os" |
| 9 | "path/filepath" |
| 10 | "strings" |
| 11 | "sync" |
| 12 | |
| 📦 Turbo Core f3ade8d k33g yesterday | 13 | "rickub.com/turbo-editors/turbo-core/acp" |
| 14 | "rickub.com/turbo-editors/turbo-core/buffer" |
| 15 | "rickub.com/turbo-editors/turbo-core/ui" |
| 🛟 Updated. 28d5985 k33g yesterday | 16 | ) |
| 17 | |
| 18 | // agentWindow is one open conversation: the session, and the view drawing it. |
| 19 | type agentWindow struct { |
| 20 | session *acp.Session |
| 21 | view *acp.View |
| 22 | } |
| 23 | |
| 24 | // NewAgent opens a window on one of the agents in the project's acp.toml. |
| 25 | // |
| 26 | // The agent is started when the window opens and stopped when it closes, so |
| 27 | // its working directory, its environment and its conversation all belong to |
| 28 | // that window. Opening the same agent twice gives two independent sessions, |
| 29 | // the way two terminals are two shells. |
| 30 | func (a *App) NewAgent(name string) { |
| 31 | list, err := a.loadAgents() |
| 32 | if err != nil { |
| 33 | a.ShowMessage("Agents", err.Error()) |
| 34 | return |
| 35 | } |
| 36 | |
| 37 | agent, ok := list.ByName(name) |
| 38 | if !ok { |
| 39 | a.ShowMessage("Agents", fmt.Sprintf("No agent called %q is configured.\n\nAgent ▸ Agent status lists the ones that are.", name)) |
| 40 | return |
| 41 | } |
| 42 | |
| 43 | // The callbacks run on the connection's reading goroutine, so none of them |
| 44 | // draws: they record a fact and ask the event loop to come round. They are |
| 45 | // options rather than fields because Start begins that goroutine. |
| 46 | session, err := acp.Start(agent, a.projectRoot(), acp.Options{ |
| 47 | Client: a.clientInfo(), |
| 48 | OnUpdate: a.wake, |
| 49 | OnPermission: a.recordPermission, |
| 50 | ReadTextFile: a.readForAgent, |
| 51 | WriteTextFile: a.writeForAgent, |
| 52 | }) |
| 53 | if err != nil { |
| 54 | a.ShowMessage("Agents", err.Error()) |
| 55 | return |
| 56 | } |
| 57 | |
| 58 | view := acp.NewView(session) |
| 59 | view.OnCopy = a.copyFromAgent |
| 📦 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 10h ago | 60 | view.OnPaste = a.clipboard.Text |
| 🛟 Updated. 28d5985 k33g yesterday | 61 | view.Files = a.projectFiles |
| 62 | window := ui.NewWindow(view.Title(), view) |
| 63 | window.SetBounds(a.newWindowBounds()) |
| 64 | window.OnClose = func() bool { a.closeAgent(window); return true } |
| 65 | |
| 66 | a.desktop.Add(window) |
| 67 | a.windowsOpened++ |
| 68 | a.agents[window] = &agentWindow{session: session, view: view} |
| 69 | } |
| 70 | |
| 71 | // copyFromAgent puts text an agent produced onto **both** clipboards. |
| 72 | // |
| 73 | // The editor's own, so it can be pasted into a file with Shift-Ins; and the |
| 74 | // system's, through the terminal, so it can be pasted anywhere else. "Copy |
| 75 | // this so I can use it elsewhere" usually means elsewhere entirely — another |
| 76 | // window, a browser, a message — and a clipboard that only works inside this |
| 77 | // editor would answer the smaller half of the request. |
| 78 | // |
| 79 | // The system half is OSC 52, which a terminal may not implement and may |
| 80 | // refuse for security. Nothing checks: there is no reply to check, the |
| 81 | // editor's own clipboard has the text either way, and a message promising |
| 82 | // something that did not happen is worse than one that stays quiet. |
| 83 | func (a *App) copyFromAgent(text string) { |
| 84 | a.clipboard.SetText(text) |
| 85 | if a.screen != nil { |
| 86 | a.screen.SetClipboard([]byte(text)) |
| 87 | } |
| 88 | a.Message(fmt.Sprintf("Copied %s", plural(len(strings.Split(text, "\n")), "line"))) |
| 89 | } |
| 90 | |
| 91 | // clientInfo is what the editor calls itself in an agent's handshake. |
| 92 | // |
| 93 | // An agent logs it, and "turbo-go" in the logs of a session that was actually |
| 94 | // Turbo Rust is the kind of small lie that costs somebody an afternoon — the |
| 95 | // same reasoning the language server client already follows. |
| 96 | func (a *App) clientInfo() acp.Implementation { |
| 97 | return acp.Implementation{Name: a.profile.Slug, Title: a.profile.Name} |
| 98 | } |
| 99 | |
| 100 | // projectRoot is where the project's own files live, which is where an agent |
| 101 | // runs unless it named a directory of its own. |
| 102 | func (a *App) projectRoot() string { |
| 103 | if working, err := os.Getwd(); err == nil { |
| 104 | return working |
| 105 | } |
| 106 | return "." |
| 107 | } |
| 108 | |
| 109 | // loadAgents reads the user's agents and the project's. |
| 110 | func (a *App) loadAgents() (acp.List, error) { |
| 111 | return acp.Load(a.profile, a.projectRoot()) |
| 112 | } |
| 113 | |
| 114 | // closeAgent ends a conversation and takes its window away. |
| 115 | // |
| 116 | // A permission still waiting is cancelled rather than left: the agent would |
| 117 | // otherwise sit for ever on an answer that can no longer be given. |
| 118 | func (a *App) closeAgent(window *ui.Window) { |
| 119 | open, ok := a.agents[window] |
| 120 | if !ok { |
| 121 | return |
| 122 | } |
| 123 | |
| 124 | a.dropPermissionsOf(open.session) |
| 125 | _ = open.session.Close() |
| 126 | delete(a.agents, window) |
| 127 | a.desktop.Remove(window) |
| 128 | a.completion.Hide() |
| 129 | } |
| 130 | |
| 131 | // closeAgents ends every conversation, which is what leaving the editor does. |
| 132 | func (a *App) closeAgents() { |
| 133 | for window, open := range a.agents { |
| 134 | a.dropPermissionsOf(open.session) |
| 135 | _ = open.session.Close() |
| 136 | delete(a.agents, window) |
| 137 | } |
| 138 | } |
| 139 | |
| 140 | // isAgentWindow reports whether a window holds a conversation rather than a |
| 141 | // file, so the file actions leave it alone. |
| 142 | func (a *App) isAgentWindow(window *ui.Window) bool { |
| 143 | _, ok := a.agents[window] |
| 144 | return ok |
| 145 | } |
| 146 | |
| 147 | // activeAgent returns the conversation in the front window, or nil. |
| 148 | func (a *App) activeAgent() *agentWindow { |
| 149 | window := a.desktop.Active() |
| 150 | if window == nil { |
| 151 | return nil |
| 152 | } |
| 153 | return a.agents[window] |
| 154 | } |
| 155 | |
| 156 | // refreshAgentTitles keeps each window's title in step with what its agent is |
| 157 | // doing. |
| 158 | // |
| 159 | // It runs on every turn of the event loop rather than on a notification, |
| 160 | // because the state changes on the session's goroutine and nothing else would |
| 161 | // notice — the same reason terminal titles are refreshed this way. |
| 162 | func (a *App) refreshAgentTitles() { |
| 163 | for window, open := range a.agents { |
| 164 | if title := open.view.Title(); title != window.Title() { |
| 165 | window.SetTitle(title) |
| 166 | } |
| 167 | } |
| 168 | } |
| 169 | |
| 170 | // CancelAgentTurn stops the turn running in the front window. |
| 171 | func (a *App) CancelAgentTurn() { |
| 172 | if open := a.activeAgent(); open != nil { |
| 173 | open.session.Cancel() |
| 174 | } |
| 175 | } |
| 176 | |
| 177 | // agentTurnRunning reports whether there is a turn to cancel, which is what |
| 178 | // greys the menu item out. |
| 179 | func (a *App) agentTurnRunning() bool { |
| 180 | open := a.activeAgent() |
| 181 | return open != nil && open.session.Running() |
| 182 | } |
| 183 | |
| 184 | // readForAgent answers fs/read_text_file with the text the editor can see. |
| 185 | // |
| 186 | // A file open with unsaved changes is answered from its **buffer**. The |
| 187 | // alternative is an agent reviewing the version you have just moved past, |
| 188 | // which is wrong precisely when you are most likely to be asking — the same |
| 189 | // bargain completion has made since the editor learnt to talk to gopls. |
| 190 | // |
| 191 | // It runs on the session's goroutine, so it must not touch the desktop; it |
| 192 | // reads what the buffers hold and nothing else. |
| 193 | func (a *App) readForAgent(path string) (string, error) { |
| 194 | a.agentFiles.Lock() |
| 195 | defer a.agentFiles.Unlock() |
| 196 | |
| 197 | if buf := a.modifiedBufferFor(path); buf != nil { |
| 198 | return buf.Text(), nil |
| 199 | } |
| 200 | |
| 201 | data, err := os.ReadFile(path) |
| 202 | if err != nil { |
| 203 | return "", err |
| 204 | } |
| 205 | return string(data), nil |
| 206 | } |
| 207 | |
| 208 | // writeForAgent answers fs/write_text_file. |
| 209 | // |
| 210 | // An open file is written into its buffer and left **unsaved**, so the change |
| 211 | // is in front of you, undoable with Ctrl-Z and yours to keep with F2. An agent |
| 212 | // quietly rewriting a file under a window you have open would be the worst |
| 213 | // possible version of this. |
| 214 | func (a *App) writeForAgent(path, content string) error { |
| 215 | a.agentFiles.Lock() |
| 216 | defer a.agentFiles.Unlock() |
| 217 | |
| 218 | if buf := a.bufferFor(path); buf != nil { |
| 219 | buf.SetText(content) |
| 220 | a.wake() |
| 221 | return nil |
| 222 | } |
| 223 | return os.WriteFile(path, []byte(content), 0o644) |
| 224 | } |
| 225 | |
| 226 | // bufferFor returns the buffer editing a path, if one is open. |
| 227 | func (a *App) bufferFor(path string) *buffer.Buffer { |
| 228 | wanted, err := filepath.Abs(path) |
| 229 | if err != nil { |
| 230 | wanted = path |
| 231 | } |
| 232 | |
| 233 | for _, window := range a.desktop.Windows() { |
| 234 | view, ok := editorViewOf(window) |
| 235 | if !ok || view.Buffer().Path() == "" { |
| 236 | continue |
| 237 | } |
| 238 | if open, err := filepath.Abs(view.Buffer().Path()); err == nil && open == wanted { |
| 239 | return view.Buffer() |
| 240 | } |
| 241 | } |
| 242 | return nil |
| 243 | } |
| 244 | |
| 245 | // modifiedBufferFor returns the buffer editing a path only when it holds |
| 246 | // unsaved work. A saved buffer and the file on disk say the same thing, and |
| 247 | // reading the file is the cheaper of the two. |
| 248 | func (a *App) modifiedBufferFor(path string) *buffer.Buffer { |
| 249 | buf := a.bufferFor(path) |
| 250 | if buf == nil || !buf.Modified() { |
| 251 | return nil |
| 252 | } |
| 253 | return buf |
| 254 | } |
| 255 | |
| 256 | // agentFileLock guards the buffers against being read by a session's goroutine |
| 257 | // while the event loop is writing them. |
| 258 | // |
| 259 | // It is a plain mutex rather than a channel because what it protects is two |
| 260 | // short reads of already-open buffers, on a path that runs once per tool call |
| 261 | // rather than once per keystroke. |
| 262 | type agentFileLock = sync.Mutex |
| 263 | |
| 264 | // AgentStatus reports what the editor knows about the agents. |
| 265 | // |
| 266 | // It is the answer to "why will my agent not start": what was read, what each |
| 267 | // command line came out as, and whatever the agent itself said on its standard |
| 268 | // error, which is where a misconfigured model endpoint reports itself. |
| 269 | func (a *App) AgentStatus() { |
| 270 | a.ShowMessage("Agent status", a.agentReport()) |
| 271 | } |
| 272 | |
| 273 | // agentReport is the text of the status dialog, built as a value so that it |
| 274 | // can be tested without opening one. |
| 275 | func (a *App) agentReport() string { |
| 276 | var out []string |
| 277 | |
| 278 | out = append(out, "Agents file: "+acp.ProjectPath(a.profile, a.projectRoot())) |
| 279 | if user := acp.UserPath(a.profile); user != "" { |
| 280 | out = append(out, "Your own: "+user) |
| 281 | } |
| 282 | out = append(out, "") |
| 283 | |
| 284 | list, err := a.loadAgents() |
| 285 | switch { |
| 286 | case err != nil: |
| 287 | out = append(out, "The agents could not be read:", " "+err.Error()) |
| 288 | case list.Len() == 0: |
| 289 | out = append(out, "No agents are configured.", "Agent ▸ Create agents file writes one to start from.") |
| 290 | default: |
| 291 | out = append(out, fmt.Sprintf("%d configured:", list.Len())) |
| 292 | for _, agent := range list.Agents() { |
| 293 | out = append(out, " "+agent.Name, " "+agent.CommandLine()) |
| 294 | } |
| 295 | } |
| 296 | |
| 297 | if open := a.activeAgent(); open != nil { |
| 298 | out = append(out, "") |
| 299 | out = append(out, a.sessionReport(open)...) |
| 300 | } |
| 301 | return strings.Join(out, "\n") |
| 302 | } |
| 303 | |
| 304 | // sessionReport describes the conversation in the front window. |
| 305 | func (a *App) sessionReport(open *agentWindow) []string { |
| 306 | session := open.session |
| 307 | |
| 308 | out := []string{"This window — " + session.Agent().Name + ":"} |
| 309 | if info := session.AgentInfo(); info.Name != "" { |
| 310 | out = append(out, " program: "+strings.TrimSpace(info.Name+" "+info.Version)) |
| 311 | } |
| 312 | switch { |
| 313 | case session.Err() != nil: |
| 314 | out = append(out, " stopped: "+session.Err().Error()) |
| 315 | case !session.Ready(): |
| 316 | out = append(out, " starting…") |
| 317 | case session.Running(): |
| 318 | out = append(out, " a turn is running") |
| 319 | default: |
| 320 | out = append(out, " ready") |
| 321 | } |
| 322 | |
| 323 | if used, size := session.Usage(); size > 0 { |
| 324 | out = append(out, fmt.Sprintf(" context: %d of %d", used, size)) |
| 325 | } |
| 326 | if unknown := session.Unknown(); unknown > 0 { |
| 327 | out = append(out, fmt.Sprintf(" %d updates this editor does not understand", unknown)) |
| 328 | } |
| 329 | if unreadable := session.Unreadable(); unreadable != "" { |
| 330 | out = append(out, " the last one it could not read — "+unreadable) |
| 331 | out = append(out, " set "+acp.TraceEnv+"=<file> and start the editor again to see the wire") |
| 332 | } |
| 333 | if commands := session.Commands(); len(commands) > 0 { |
| 334 | out = append(out, " commands — type / in the box to pick one:") |
| 335 | for _, command := range commands { |
| 336 | out = append(out, " "+commandLine(command)) |
| 337 | } |
| 338 | } |
| 339 | |
| 340 | if log := session.Log(); len(log) > 0 { |
| 341 | out = append(out, "", "What it said on standard error:") |
| 342 | for _, line := range lastLines(log, 8) { |
| 343 | out = append(out, " "+line) |
| 344 | } |
| 345 | } |
| 346 | return out |
| 347 | } |
| 348 | |
| 349 | // commandLine is one command as the status dialog lists it: its name, what |
| 350 | // it does, and what it wants after its name. |
| 351 | func commandLine(command acp.Command) string { |
| 352 | line := "/" + command.Name |
| 353 | if command.Description != "" { |
| 354 | line += " — " + command.Description |
| 355 | } |
| 356 | if hint := command.Hint(); hint != "" { |
| 357 | line += " <" + hint + ">" |
| 358 | } |
| 359 | return line |
| 360 | } |
| 361 | |
| 362 | // lastLines returns at most n lines from the end. |
| 363 | func lastLines(lines []string, n int) []string { |
| 364 | if len(lines) <= n { |
| 365 | return lines |
| 366 | } |
| 367 | return lines[len(lines)-n:] |
| 368 | } |
| 369 | |
| 370 | // CreateAgentsFile writes a starter acp.toml for the project and opens it. |
| 371 | // |
| 372 | // It is opened rather than merely written because the example in it is how the |
| 373 | // format is learnt, and pointing it at your own agent is the first thing |
| 374 | // anybody does. A project that already has one is opened unchanged. |
| 375 | func (a *App) CreateAgentsFile() { |
| 376 | create := func(project string) (string, error) { return acp.Create(a.profile, project) } |
| 377 | a.createProjectFile("Agents", create, acp.ErrExists) |
| 378 | } |
| 379 | |
| 380 | // hasNoAgentsFile reports whether the project has yet to be given one, which is |
| 381 | // what greys out the item that writes it. |
| 382 | func (a *App) hasNoAgentsFile() bool { |
| 383 | return !acp.Exists(a.profile, a.projectRoot()) |
| 384 | } |