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.

agents.go · 384 lines · 12.1 KBGo Blame HistoryRaw
🛟 Updated. 28d5985 k33g yesterday1// 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 yesterday13 "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 yesterday16)
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
📦 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 12h ago60 view.OnPaste = a.clipboard.Text
🛟 Updated. 28d5985 k33g yesterday61 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.
83func (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.
96func (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.
102func (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.
110func (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.
118func (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.
132func (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.
142func (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.
148func (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.
162func (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.
171func (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.
179func (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.
193func (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.
214func (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.
227func (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.
248func (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.
262type 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.
269func (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.
275func (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.
305func (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.
351func 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.
363func 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.
375func (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.
382func (a *App) hasNoAgentsFile() bool {
383 return !acp.Exists(a.profile, a.projectRoot())
384}