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

agents.go · 383 lines · 12.1 KBGo Blame HistoryRaw
🛟 Updated. 28d5985 k33g 19h ago1// 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
4package app
5
6import (
7 "fmt"
8 "os"
9 "path/filepath"
10 "strings"
11 "sync"
12
📦 Turbo Core f3ade8d k33g 12h ago13 "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 19h ago16)
17
18// agentWindow is one open conversation: the session, and the view drawing it.
19type 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.
30func (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.
82func (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.
95func (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.
101func (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.
109func (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.
117func (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.
131func (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.
141func (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.
147func (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.
161func (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.
170func (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.
178func (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.
192func (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.
213func (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.
226func (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.
247func (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.
261type 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.
268func (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.
274func (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.
304func (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.
350func 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.
362func 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.
374func (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.
381func (a *App) hasNoAgentsFile() bool {
382 return !acp.Exists(a.profile, a.projectRoot())
383}