// Permission dialogs: the agent asking before it acts, answered by a person. package app import ( "strings" "sync" "rickub.com/turbo-editors/turbo-core/acp" "rickub.com/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]) + "…" }