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