turbo-editors/turbo-corepublic Fork 0
d662cebdb65b319885da903daf7eff9ab1bfbb78
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 d662cebdb65b319885da903daf7eff9ab1bfbb78 · k33g · 21h ago
agent_permissions.go · 255 lines · 7.8 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
// Permission dialogs: the agent asking before it acts, answered by a person.

package app

import (
	"strings"
	"sync"

	"codeberg.org/turbo-editors/turbo-core/acp"
	"codeberg.org/turbo-editors/turbo-core/ui"
)

// pendingPermissions is a queue of agents waiting for an answer.
//
// It is a queue with a lock rather than a field, because it is written from
// each session's reading goroutine and read by the event loop. The dialog
// itself is never opened there: opening one belongs to the goroutine that
// draws, which is why the request is only *recorded* here and acted on by
// askNextPermission on the next turn of the loop.
//
// This is the fourth time this project has reached that conclusion — autosave,
// the language server's re-announcement and the terminal's redraws are the
// others — and the reason is always the same: PostEvent is allowed to drop
// what does not fit, so an event may cause a turn of the loop but must never
// be the only thing carrying a fact.
type pendingPermissions struct {
	mu      sync.Mutex
	waiting []*acp.Permission
	asking  bool // a dialog is up; the next one waits for it
}

// add records a request and asks the event loop to come round.
func (p *pendingPermissions) add(permission *acp.Permission) {
	p.mu.Lock()
	defer p.mu.Unlock()
	p.waiting = append(p.waiting, permission)
}

// take returns the next request to ask about, if there is one and no dialog is
// already up.
func (p *pendingPermissions) take() *acp.Permission {
	p.mu.Lock()
	defer p.mu.Unlock()

	if p.asking || len(p.waiting) == 0 {
		return nil
	}
	next := p.waiting[0]
	p.waiting = p.waiting[1:]
	p.asking = true
	return next
}

// done says the dialog has closed, so the next request may be asked about.
func (p *pendingPermissions) done() {
	p.mu.Lock()
	defer p.mu.Unlock()
	p.asking = false
}

// drain removes every waiting request and returns them, for a session going
// away.
func (p *pendingPermissions) drain() []*acp.Permission {
	p.mu.Lock()
	defer p.mu.Unlock()

	out := p.waiting
	p.waiting = nil
	return out
}

// recordPermission is what a session calls when its agent asks to act.
//
// It runs on the session's reading goroutine, so it does exactly two things:
// remembers the request, and wakes the loop.
func (a *App) recordPermission(permission *acp.Permission) {
	a.permissions.add(permission)
	a.wake()
}

// askNextPermission opens a dialog for the next agent waiting on an answer.
//
// It runs on every turn of the event loop and does nothing until there is one,
// which is what makes it state-driven rather than event-driven.
func (a *App) askNextPermission() {
	permission := a.permissions.take()
	if permission == nil {
		return
	}
	if len(permission.Options) == 0 {
		// An agent that asks a question with no answers has asked nothing.
		permission.Cancel()
		a.permissions.done()
		return
	}

	chosen := new(string)
	dialog := a.permissionDialog(permission, chosen)
	a.pushModal(dialog, func(ui.Result) {
		a.answerPermission(permission, *chosen)
	})
}

// permissionDialog builds the box, with one button per answer the agent
// offered. Each button carries its own option id, because the agent decides
// what the answers are and an id it does not know is as useless as silence.
//
// A choice is recorded into chosen rather than returned through ui.Result:
// that is an outcome — OK, Cancel, No — and not a number, so it cannot say
// which of four buttons was pressed.
func (a *App) permissionDialog(permission *acp.Permission, chosen *string) *ui.Dialog {
	screen := a.screenRect()
	width := permissionWidth(permission, screen.W)

	lines := permissionLines(permission, width)
	height := len(lines) + 6

	dialog := ui.NewDialog("Agent")
	dialog.SetBounds(ui.Rect{W: width, H: height}.CenteredIn(screen))

	for i, line := range lines {
		label := ui.NewLabel(line)
		label.SetBounds(dialog.Layout(3, 2+i, width-6, 1))
		dialog.Add(label)
	}

	for i, button := range permissionButtons(dialog, permission, chosen) {
		label := permission.Options[i].Name
		button.SetBounds(dialog.Layout(buttonX(permission, i, width), height-3, ui.ButtonWidth(label), 1))
		dialog.Add(button)
	}
	return dialog
}

// permissionWidth is wide enough for every button the agent offered, and for
// the command a person has to judge — but never wider than the screen.
//
// A fixed width clipped the third of three buttons to "Skip t", which is a
// button whose meaning you cannot read, on a dialog about running a command.
func permissionWidth(permission *acp.Permission, screenWidth int) int {
	const minimum = 48

	wanted := max(buttonsWidth(permission)+6, minimum)
	return min(wanted, max(screenWidth-4, minimum))
}

// buttonsWidth is how much room the row of buttons needs, gaps included.
func buttonsWidth(permission *acp.Permission) int {
	total := 0
	for i, option := range permission.Options {
		if i > 0 {
			total += buttonGap
		}
		total += ui.ButtonWidth(option.Name)
	}
	return total
}

// buttonGap is the space between two buttons.
const buttonGap = 2

// permissionLines are what the box says: the tool, and what it would do.
func permissionLines(permission *acp.Permission, width int) []string {
	title := permission.Title
	if title == "" {
		title = "The agent wants to act"
	}

	lines := []string{trimTo(title, width-6)}
	if permission.Detail != "" {
		lines = append(lines, "", "  "+trimTo(permission.Detail, width-8))
	}
	return lines
}

// permissionButtons builds one button per option, in the agent's own order.
//
// The first is the default, because an agent lists its least surprising answer
// first — "allow once" before "allow always" before "reject".
func permissionButtons(dialog *ui.Dialog, permission *acp.Permission, chosen *string) []*ui.Button {
	buttons := make([]*ui.Button, len(permission.Options))
	for i, option := range permission.Options {
		id := option.OptionID
		buttons[i] = ui.NewButton(option.Name, func() {
			*chosen = id
			dialog.Close(ui.ResultOK)
		})
	}
	if len(buttons) > 0 {
		buttons[0].Default = true
	}
	return buttons
}

// buttonX is where one button sits on the row, the whole row centred.
func buttonX(permission *acp.Permission, at, width int) int {
	x := max((width-buttonsWidth(permission))/2, 1)
	for i := 0; i < at; i++ {
		x += ui.ButtonWidth(permission.Options[i].Name) + buttonGap
	}
	return x
}

// answerPermission tells the agent what was chosen.
//
// Escape, and anything else that closes the box without a choice, is the
// agent's own "no" rather than a cancellation: the turn is still running, and
// the agent is entitled to carry on without whatever it asked for.
func (a *App) answerPermission(permission *acp.Permission, chosen string) {
	defer a.permissions.done()

	if chosen == "" {
		permission.Answer(permission.RejectOption())
		return
	}
	permission.Answer(chosen)
}

// dropPermissionsOf cancels whatever a session was waiting on, because its
// window is going away.
//
// Cancelled, not refused: nobody declined anything, the conversation simply
// ended. The distinction is the protocol's own, and an agent that logs why it
// stopped should log the truth.
func (a *App) dropPermissionsOf(*acp.Session) {
	for _, permission := range a.permissions.drain() {
		permission.Cancel()
	}
}

// permissionSummary describes what is waiting, for the status bar.
func (a *App) permissionSummary() string {
	a.permissions.mu.Lock()
	defer a.permissions.mu.Unlock()

	if len(a.permissions.waiting) == 0 {
		return ""
	}

	titles := make([]string, 0, len(a.permissions.waiting))
	for _, permission := range a.permissions.waiting {
		titles = append(titles, permission.Title)
	}
	return "agent waiting: " + strings.Join(titles, ", ")
}

// trimTo clips text to a width in runes, so a box-drawing character or an
// accent counts as the one column it takes rather than its several bytes.
func trimTo(text string, width int) string {
	runes := []rune(text)
	if width < 1 || len(runes) <= width {
		return text
	}
	return string(runes[:width-1]) + "…"
}