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

agent_permissions.go · 255 lines · 7.8 KBGo Blame HistoryRaw
🛟 Updated. 28d5985 k33g 17h ago1// Permission dialogs: the agent asking before it acts, answered by a person.
2
3package app
4
5import (
6 "strings"
7 "sync"
8
📦 Turbo Core f3ade8d k33g 9h ago9 "rickub.com/turbo-editors/turbo-core/acp"
10 "rickub.com/turbo-editors/turbo-core/ui"
🛟 Updated. 28d5985 k33g 17h ago11)
12
13// pendingPermissions is a queue of agents waiting for an answer.
14//
15// It is a queue with a lock rather than a field, because it is written from
16// each session's reading goroutine and read by the event loop. The dialog
17// itself is never opened there: opening one belongs to the goroutine that
18// draws, which is why the request is only *recorded* here and acted on by
19// askNextPermission on the next turn of the loop.
20//
21// This is the fourth time this project has reached that conclusion — autosave,
22// the language server's re-announcement and the terminal's redraws are the
23// others — and the reason is always the same: PostEvent is allowed to drop
24// what does not fit, so an event may cause a turn of the loop but must never
25// be the only thing carrying a fact.
26type pendingPermissions struct {
27 mu sync.Mutex
28 waiting []*acp.Permission
29 asking bool // a dialog is up; the next one waits for it
30}
31
32// add records a request and asks the event loop to come round.
33func (p *pendingPermissions) add(permission *acp.Permission) {
34 p.mu.Lock()
35 defer p.mu.Unlock()
36 p.waiting = append(p.waiting, permission)
37}
38
39// take returns the next request to ask about, if there is one and no dialog is
40// already up.
41func (p *pendingPermissions) take() *acp.Permission {
42 p.mu.Lock()
43 defer p.mu.Unlock()
44
45 if p.asking || len(p.waiting) == 0 {
46 return nil
47 }
48 next := p.waiting[0]
49 p.waiting = p.waiting[1:]
50 p.asking = true
51 return next
52}
53
54// done says the dialog has closed, so the next request may be asked about.
55func (p *pendingPermissions) done() {
56 p.mu.Lock()
57 defer p.mu.Unlock()
58 p.asking = false
59}
60
61// drain removes every waiting request and returns them, for a session going
62// away.
63func (p *pendingPermissions) drain() []*acp.Permission {
64 p.mu.Lock()
65 defer p.mu.Unlock()
66
67 out := p.waiting
68 p.waiting = nil
69 return out
70}
71
72// recordPermission is what a session calls when its agent asks to act.
73//
74// It runs on the session's reading goroutine, so it does exactly two things:
75// remembers the request, and wakes the loop.
76func (a *App) recordPermission(permission *acp.Permission) {
77 a.permissions.add(permission)
78 a.wake()
79}
80
81// askNextPermission opens a dialog for the next agent waiting on an answer.
82//
83// It runs on every turn of the event loop and does nothing until there is one,
84// which is what makes it state-driven rather than event-driven.
85func (a *App) askNextPermission() {
86 permission := a.permissions.take()
87 if permission == nil {
88 return
89 }
90 if len(permission.Options) == 0 {
91 // An agent that asks a question with no answers has asked nothing.
92 permission.Cancel()
93 a.permissions.done()
94 return
95 }
96
97 chosen := new(string)
98 dialog := a.permissionDialog(permission, chosen)
99 a.pushModal(dialog, func(ui.Result) {
100 a.answerPermission(permission, *chosen)
101 })
102}
103
104// permissionDialog builds the box, with one button per answer the agent
105// offered. Each button carries its own option id, because the agent decides
106// what the answers are and an id it does not know is as useless as silence.
107//
108// A choice is recorded into chosen rather than returned through ui.Result:
109// that is an outcome — OK, Cancel, No — and not a number, so it cannot say
110// which of four buttons was pressed.
111func (a *App) permissionDialog(permission *acp.Permission, chosen *string) *ui.Dialog {
112 screen := a.screenRect()
113 width := permissionWidth(permission, screen.W)
114
115 lines := permissionLines(permission, width)
116 height := len(lines) + 6
117
118 dialog := ui.NewDialog("Agent")
119 dialog.SetBounds(ui.Rect{W: width, H: height}.CenteredIn(screen))
120
121 for i, line := range lines {
122 label := ui.NewLabel(line)
123 label.SetBounds(dialog.Layout(3, 2+i, width-6, 1))
124 dialog.Add(label)
125 }
126
127 for i, button := range permissionButtons(dialog, permission, chosen) {
128 label := permission.Options[i].Name
129 button.SetBounds(dialog.Layout(buttonX(permission, i, width), height-3, ui.ButtonWidth(label), 1))
130 dialog.Add(button)
131 }
132 return dialog
133}
134
135// permissionWidth is wide enough for every button the agent offered, and for
136// the command a person has to judge — but never wider than the screen.
137//
138// A fixed width clipped the third of three buttons to "Skip t", which is a
139// button whose meaning you cannot read, on a dialog about running a command.
140func permissionWidth(permission *acp.Permission, screenWidth int) int {
141 const minimum = 48
142
143 wanted := max(buttonsWidth(permission)+6, minimum)
144 return min(wanted, max(screenWidth-4, minimum))
145}
146
147// buttonsWidth is how much room the row of buttons needs, gaps included.
148func buttonsWidth(permission *acp.Permission) int {
149 total := 0
150 for i, option := range permission.Options {
151 if i > 0 {
152 total += buttonGap
153 }
154 total += ui.ButtonWidth(option.Name)
155 }
156 return total
157}
158
159// buttonGap is the space between two buttons.
160const buttonGap = 2
161
162// permissionLines are what the box says: the tool, and what it would do.
163func permissionLines(permission *acp.Permission, width int) []string {
164 title := permission.Title
165 if title == "" {
166 title = "The agent wants to act"
167 }
168
169 lines := []string{trimTo(title, width-6)}
170 if permission.Detail != "" {
171 lines = append(lines, "", " "+trimTo(permission.Detail, width-8))
172 }
173 return lines
174}
175
176// permissionButtons builds one button per option, in the agent's own order.
177//
178// The first is the default, because an agent lists its least surprising answer
179// first — "allow once" before "allow always" before "reject".
180func permissionButtons(dialog *ui.Dialog, permission *acp.Permission, chosen *string) []*ui.Button {
181 buttons := make([]*ui.Button, len(permission.Options))
182 for i, option := range permission.Options {
183 id := option.OptionID
184 buttons[i] = ui.NewButton(option.Name, func() {
185 *chosen = id
186 dialog.Close(ui.ResultOK)
187 })
188 }
189 if len(buttons) > 0 {
190 buttons[0].Default = true
191 }
192 return buttons
193}
194
195// buttonX is where one button sits on the row, the whole row centred.
196func buttonX(permission *acp.Permission, at, width int) int {
197 x := max((width-buttonsWidth(permission))/2, 1)
198 for i := 0; i < at; i++ {
199 x += ui.ButtonWidth(permission.Options[i].Name) + buttonGap
200 }
201 return x
202}
203
204// answerPermission tells the agent what was chosen.
205//
206// Escape, and anything else that closes the box without a choice, is the
207// agent's own "no" rather than a cancellation: the turn is still running, and
208// the agent is entitled to carry on without whatever it asked for.
209func (a *App) answerPermission(permission *acp.Permission, chosen string) {
210 defer a.permissions.done()
211
212 if chosen == "" {
213 permission.Answer(permission.RejectOption())
214 return
215 }
216 permission.Answer(chosen)
217}
218
219// dropPermissionsOf cancels whatever a session was waiting on, because its
220// window is going away.
221//
222// Cancelled, not refused: nobody declined anything, the conversation simply
223// ended. The distinction is the protocol's own, and an agent that logs why it
224// stopped should log the truth.
225func (a *App) dropPermissionsOf(*acp.Session) {
226 for _, permission := range a.permissions.drain() {
227 permission.Cancel()
228 }
229}
230
231// permissionSummary describes what is waiting, for the status bar.
232func (a *App) permissionSummary() string {
233 a.permissions.mu.Lock()
234 defer a.permissions.mu.Unlock()
235
236 if len(a.permissions.waiting) == 0 {
237 return ""
238 }
239
240 titles := make([]string, 0, len(a.permissions.waiting))
241 for _, permission := range a.permissions.waiting {
242 titles = append(titles, permission.Title)
243 }
244 return "agent waiting: " + strings.Join(titles, ", ")
245}
246
247// trimTo clips text to a width in runes, so a box-drawing character or an
248// accent counts as the one column it takes rather than its several bytes.
249func trimTo(text string, width int) string {
250 runes := []rune(text)
251 if width < 1 || len(runes) <= width {
252 return text
253 }
254 return string(runes[:width-1]) + "…"
255}