turbo-editors/turbo-corepublic Fork 0
v0.9.0
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.

🛟 Updated. 28d5985 · on v0.9.0 · k33g · 18h ago
agents.go · 383 lines · 12.1 KBGo Blame HistoryRaw
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
// 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"

	"codeberg.org/turbo-editors/turbo-core/acp"
	"codeberg.org/turbo-editors/turbo-core/buffer"
	"codeberg.org/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+"=<file> 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())
}