// Agent windows: a coding agent running as a child process, in a window of the // editor's own, talking the Agent Client Protocol. package app import ( "fmt" "os" "path/filepath" "strings" "sync" "rickub.com/turbo-editors/turbo-core/acp" "rickub.com/turbo-editors/turbo-core/buffer" "rickub.com/turbo-editors/turbo-core/ui" ) // agentWindow is one open conversation: the session, and the view drawing it. type agentWindow struct { session *acp.Session view *acp.View } // NewAgent opens a window on one of the agents in the project's acp.toml. // // The agent is started when the window opens and stopped when it closes, so // its working directory, its environment and its conversation all belong to // that window. Opening the same agent twice gives two independent sessions, // the way two terminals are two shells. func (a *App) NewAgent(name string) { list, err := a.loadAgents() if err != nil { a.ShowMessage("Agents", err.Error()) return } agent, ok := list.ByName(name) if !ok { a.ShowMessage("Agents", fmt.Sprintf("No agent called %q is configured.\n\nAgent ▸ Agent status lists the ones that are.", name)) return } // The callbacks run on the connection's reading goroutine, so none of them // draws: they record a fact and ask the event loop to come round. They are // options rather than fields because Start begins that goroutine. session, err := acp.Start(agent, a.projectRoot(), acp.Options{ Client: a.clientInfo(), OnUpdate: a.wake, OnPermission: a.recordPermission, ReadTextFile: a.readForAgent, WriteTextFile: a.writeForAgent, }) if err != nil { a.ShowMessage("Agents", err.Error()) return } view := acp.NewView(session) view.OnCopy = a.copyFromAgent view.Files = a.projectFiles window := ui.NewWindow(view.Title(), view) window.SetBounds(a.newWindowBounds()) window.OnClose = func() bool { a.closeAgent(window); return true } a.desktop.Add(window) a.windowsOpened++ a.agents[window] = &agentWindow{session: session, view: view} } // copyFromAgent puts text an agent produced onto **both** clipboards. // // The editor's own, so it can be pasted into a file with Shift-Ins; and the // system's, through the terminal, so it can be pasted anywhere else. "Copy // this so I can use it elsewhere" usually means elsewhere entirely — another // window, a browser, a message — and a clipboard that only works inside this // editor would answer the smaller half of the request. // // The system half is OSC 52, which a terminal may not implement and may // refuse for security. Nothing checks: there is no reply to check, the // editor's own clipboard has the text either way, and a message promising // something that did not happen is worse than one that stays quiet. func (a *App) copyFromAgent(text string) { a.clipboard.SetText(text) if a.screen != nil { a.screen.SetClipboard([]byte(text)) } a.Message(fmt.Sprintf("Copied %s", plural(len(strings.Split(text, "\n")), "line"))) } // clientInfo is what the editor calls itself in an agent's handshake. // // An agent logs it, and "turbo-go" in the logs of a session that was actually // Turbo Rust is the kind of small lie that costs somebody an afternoon — the // same reasoning the language server client already follows. func (a *App) clientInfo() acp.Implementation { return acp.Implementation{Name: a.profile.Slug, Title: a.profile.Name} } // projectRoot is where the project's own files live, which is where an agent // runs unless it named a directory of its own. func (a *App) projectRoot() string { if working, err := os.Getwd(); err == nil { return working } return "." } // loadAgents reads the user's agents and the project's. func (a *App) loadAgents() (acp.List, error) { return acp.Load(a.profile, a.projectRoot()) } // closeAgent ends a conversation and takes its window away. // // A permission still waiting is cancelled rather than left: the agent would // otherwise sit for ever on an answer that can no longer be given. func (a *App) closeAgent(window *ui.Window) { open, ok := a.agents[window] if !ok { return } a.dropPermissionsOf(open.session) _ = open.session.Close() delete(a.agents, window) a.desktop.Remove(window) a.completion.Hide() } // closeAgents ends every conversation, which is what leaving the editor does. func (a *App) closeAgents() { for window, open := range a.agents { a.dropPermissionsOf(open.session) _ = open.session.Close() delete(a.agents, window) } } // isAgentWindow reports whether a window holds a conversation rather than a // file, so the file actions leave it alone. func (a *App) isAgentWindow(window *ui.Window) bool { _, ok := a.agents[window] return ok } // activeAgent returns the conversation in the front window, or nil. func (a *App) activeAgent() *agentWindow { window := a.desktop.Active() if window == nil { return nil } return a.agents[window] } // refreshAgentTitles keeps each window's title in step with what its agent is // doing. // // It runs on every turn of the event loop rather than on a notification, // because the state changes on the session's goroutine and nothing else would // notice — the same reason terminal titles are refreshed this way. func (a *App) refreshAgentTitles() { for window, open := range a.agents { if title := open.view.Title(); title != window.Title() { window.SetTitle(title) } } } // CancelAgentTurn stops the turn running in the front window. func (a *App) CancelAgentTurn() { if open := a.activeAgent(); open != nil { open.session.Cancel() } } // agentTurnRunning reports whether there is a turn to cancel, which is what // greys the menu item out. func (a *App) agentTurnRunning() bool { open := a.activeAgent() return open != nil && open.session.Running() } // readForAgent answers fs/read_text_file with the text the editor can see. // // A file open with unsaved changes is answered from its **buffer**. The // alternative is an agent reviewing the version you have just moved past, // which is wrong precisely when you are most likely to be asking — the same // bargain completion has made since the editor learnt to talk to gopls. // // It runs on the session's goroutine, so it must not touch the desktop; it // reads what the buffers hold and nothing else. func (a *App) readForAgent(path string) (string, error) { a.agentFiles.Lock() defer a.agentFiles.Unlock() if buf := a.modifiedBufferFor(path); buf != nil { return buf.Text(), nil } data, err := os.ReadFile(path) if err != nil { return "", err } return string(data), nil } // writeForAgent answers fs/write_text_file. // // An open file is written into its buffer and left **unsaved**, so the change // is in front of you, undoable with Ctrl-Z and yours to keep with F2. An agent // quietly rewriting a file under a window you have open would be the worst // possible version of this. func (a *App) writeForAgent(path, content string) error { a.agentFiles.Lock() defer a.agentFiles.Unlock() if buf := a.bufferFor(path); buf != nil { buf.SetText(content) a.wake() return nil } return os.WriteFile(path, []byte(content), 0o644) } // bufferFor returns the buffer editing a path, if one is open. func (a *App) bufferFor(path string) *buffer.Buffer { wanted, err := filepath.Abs(path) if err != nil { wanted = path } for _, window := range a.desktop.Windows() { view, ok := editorViewOf(window) if !ok || view.Buffer().Path() == "" { continue } if open, err := filepath.Abs(view.Buffer().Path()); err == nil && open == wanted { return view.Buffer() } } return nil } // modifiedBufferFor returns the buffer editing a path only when it holds // unsaved work. A saved buffer and the file on disk say the same thing, and // reading the file is the cheaper of the two. func (a *App) modifiedBufferFor(path string) *buffer.Buffer { buf := a.bufferFor(path) if buf == nil || !buf.Modified() { return nil } return buf } // agentFileLock guards the buffers against being read by a session's goroutine // while the event loop is writing them. // // It is a plain mutex rather than a channel because what it protects is two // short reads of already-open buffers, on a path that runs once per tool call // rather than once per keystroke. type agentFileLock = sync.Mutex // AgentStatus reports what the editor knows about the agents. // // It is the answer to "why will my agent not start": what was read, what each // command line came out as, and whatever the agent itself said on its standard // error, which is where a misconfigured model endpoint reports itself. func (a *App) AgentStatus() { a.ShowMessage("Agent status", a.agentReport()) } // agentReport is the text of the status dialog, built as a value so that it // can be tested without opening one. func (a *App) agentReport() string { var out []string out = append(out, "Agents file: "+acp.ProjectPath(a.profile, a.projectRoot())) if user := acp.UserPath(a.profile); user != "" { out = append(out, "Your own: "+user) } out = append(out, "") list, err := a.loadAgents() switch { case err != nil: out = append(out, "The agents could not be read:", " "+err.Error()) case list.Len() == 0: out = append(out, "No agents are configured.", "Agent ▸ Create agents file writes one to start from.") default: out = append(out, fmt.Sprintf("%d configured:", list.Len())) for _, agent := range list.Agents() { out = append(out, " "+agent.Name, " "+agent.CommandLine()) } } if open := a.activeAgent(); open != nil { out = append(out, "") out = append(out, a.sessionReport(open)...) } return strings.Join(out, "\n") } // sessionReport describes the conversation in the front window. func (a *App) sessionReport(open *agentWindow) []string { session := open.session out := []string{"This window — " + session.Agent().Name + ":"} if info := session.AgentInfo(); info.Name != "" { out = append(out, " program: "+strings.TrimSpace(info.Name+" "+info.Version)) } switch { case session.Err() != nil: out = append(out, " stopped: "+session.Err().Error()) case !session.Ready(): out = append(out, " starting…") case session.Running(): out = append(out, " a turn is running") default: out = append(out, " ready") } if used, size := session.Usage(); size > 0 { out = append(out, fmt.Sprintf(" context: %d of %d", used, size)) } if unknown := session.Unknown(); unknown > 0 { out = append(out, fmt.Sprintf(" %d updates this editor does not understand", unknown)) } if unreadable := session.Unreadable(); unreadable != "" { out = append(out, " the last one it could not read — "+unreadable) out = append(out, " set "+acp.TraceEnv+"= and start the editor again to see the wire") } if commands := session.Commands(); len(commands) > 0 { out = append(out, " commands — type / in the box to pick one:") for _, command := range commands { out = append(out, " "+commandLine(command)) } } if log := session.Log(); len(log) > 0 { out = append(out, "", "What it said on standard error:") for _, line := range lastLines(log, 8) { out = append(out, " "+line) } } return out } // commandLine is one command as the status dialog lists it: its name, what // it does, and what it wants after its name. func commandLine(command acp.Command) string { line := "/" + command.Name if command.Description != "" { line += " — " + command.Description } if hint := command.Hint(); hint != "" { line += " <" + hint + ">" } return line } // lastLines returns at most n lines from the end. func lastLines(lines []string, n int) []string { if len(lines) <= n { return lines } return lines[len(lines)-n:] } // CreateAgentsFile writes a starter acp.toml for the project and opens it. // // It is opened rather than merely written because the example in it is how the // format is learnt, and pointing it at your own agent is the first thing // anybody does. A project that already has one is opened unchanged. func (a *App) CreateAgentsFile() { create := func(project string) (string, error) { return acp.Create(a.profile, project) } a.createProjectFile("Agents", create, acp.ErrExists) } // hasNoAgentsFile reports whether the project has yet to be given one, which is // what greys out the item that writes it. func (a *App) hasNoAgentsFile() bool { return !acp.Exists(a.profile, a.projectRoot()) }