package acp import ( "strings" "github.com/gdamore/tcell/v2" "codeberg.org/turbo-editors/turbo-core/ui" ) // The two characters that open the picker: "/" at the very start of the box // lists the agent's commands, "@" anywhere lists the project's files. // // They are the characters Zed uses for the same two things, so an agent's own // documentation — "type /web to search" — is true in this editor as well. const ( commandRune = '/' mentionRune = '@' ) // Picker geometry. Eight rows, like the completion popup: a list that covered // the conversation would hide the very reply the command is about. const ( pickerRows = 8 pickerMinWidth = 24 pickerMaxChoices = 200 ) // Choice is one line of the picker: what it shows, and what choosing it types. // // acp.Choice{Label: "/web", Detail: "Search the web", Hint: "query", Insert: "/web "} // acp.Choice{Label: "@app/menus.go", Insert: "@app/menus.go "} type Choice struct { // Label is what is matched against the word being typed, marker included. Label string // Detail is the agent's description of a command; "" for a file. Detail string // Hint is what the agent suggests typing after a command, or "". Hint string // Insert replaces the word being typed when the choice is taken. It ends // in a space when something is expected to follow. Insert string } // picker is the popup's own state: which line is highlighted, how far the // list is scrolled, and whether Escape put it away. // // The list itself is not stored. It is a function of the word under the // cursor and of what the session and the editor know right now, and it is // recomputed by refresh on every key and every frame — the same bargain the // transcript's layout makes, for the same reason: nothing has to be kept in // step. type picker struct { selected int top int // typed is the word the list was last computed for. When it changes the // highlight goes back to the top, because the list under it did. typed string // dismissed says Escape closed the popup, and it stays closed until the // text changes. Without it, Escape would close a popup that reopened on // the next frame. dismissed bool // files is the project's files, fetched once when an "@" word opens the // popup and dropped when it closes. Walking a project on every keystroke // would make the box stutter. files []Mention // bounds is where the popup was last drawn, in the view's own // coordinates, so that a click can be matched to a line. bounds ui.Rect } // CommandChoices returns the commands whose name begins with prefix, in the // agent's order. Matching ignores case, so "/Co" finds "/compact". // // acp.CommandChoices(session.Commands(), "co") func CommandChoices(commands []Command, prefix string) []Choice { wanted := strings.ToLower(prefix) var out []Choice for _, command := range commands { if !strings.HasPrefix(strings.ToLower(command.Name), wanted) { continue } insert := string(commandRune) + command.Name if command.TakesInput() { insert += " " } out = append(out, Choice{ Label: string(commandRune) + command.Name, Detail: command.Description, Hint: command.Hint(), Insert: insert, }) } return out } // FileChoices returns the files whose path contains prefix, ignoring case. // Files whose own name begins with it come first, because "@sc" is far more // often the start of scanner.go than the middle of some directory. // // The list is capped: a picker offering two thousand lines is a list to // scroll, not a list to choose from, and typing one more letter is faster. // // acp.FileChoices(files, "scan") func FileChoices(files []Mention, prefix string) []Choice { wanted := strings.ToLower(prefix) var first, rest []Choice for _, file := range files { lower := strings.ToLower(file.Name) if !strings.Contains(lower, wanted) { continue } choice := Choice{ Label: string(mentionRune) + file.Name, Insert: string(mentionRune) + file.Name + " ", } if strings.HasPrefix(baseName(lower), wanted) { first = append(first, choice) } else { rest = append(rest, choice) } } out := append(first, rest...) if len(out) > pickerMaxChoices { out = out[:pickerMaxChoices] } return out } // baseName is the last segment of a slash-separated path. func baseName(path string) string { if slash := strings.LastIndexByte(path, '/'); slash >= 0 { return path[slash+1:] } return path } // typedWord returns the word the cursor is at the end of on the current line // — from the space before it to the cursor — and where it starts, in runes. func (v *View) typedWord() (start int, word string) { runes := v.runes() column := min(v.column, len(runes)) start = column for start > 0 && !isSpace(runes[start-1]) { start-- } return start, string(runes[start:column]) } // choices returns what the picker should list right now, and nothing when it // should not be open: the focus is elsewhere, Escape closed it, or the word // under the cursor does not begin with a marker in the place that marker means // something. func (v *View) choices() []Choice { if !v.onInput || v.picker.dismissed { return nil } start, word := v.typedWord() switch { case word == "": return nil case word[0] == commandRune && v.cursor == 0 && start == 0: return CommandChoices(v.session.Commands(), word[1:]) case word[0] == mentionRune && v.Files != nil: return FileChoices(v.fileList(), word[1:]) } return nil } // fileList returns the project's files, walking the project the first time // and reusing the answer while the popup stays open. func (v *View) fileList() []Mention { if v.picker.files == nil { v.picker.files = v.Files() if v.picker.files == nil { v.picker.files = []Mention{} // asked and answered: nothing } } return v.picker.files } // refresh recomputes the list, and resets the highlight when the word under // the cursor changed. It is the one entry point both the keys and the drawing // use, so they can never disagree about which line is highlighted. func (v *View) refresh() []Choice { choices := v.choices() _, word := v.typedWord() if len(choices) == 0 || !strings.HasPrefix(word, string(mentionRune)) { v.picker.files = nil } if word != v.picker.typed { v.picker.typed = word v.picker.selected = 0 v.picker.top = 0 } if len(choices) > 0 { v.picker.selected = min(v.picker.selected, len(choices)-1) } return choices } // Choices returns what the picker is listing, or nothing when it is closed. // // Exported so that a test — or an editor — can ask what the popup offers // without reading it back off a screen. func (v *View) Choices() []Choice { return v.refresh() } // handlePickerKey works the popup while it is open, and reports whether the // key was its. // // A printable character is never its: it falls through to the box, the word // grows, and the list narrows on the next frame. Enter is its only when // taking the highlighted choice would change the text; on a word that is // already complete, Enter falls through and sends. func (v *View) handlePickerKey(ev *tcell.EventKey) bool { choices := v.refresh() if len(choices) == 0 { return false } switch ev.Key() { case tcell.KeyUp: v.movePick(-1, len(choices)) case tcell.KeyDown: v.movePick(+1, len(choices)) case tcell.KeyPgUp: v.movePick(-pickerRows, len(choices)) case tcell.KeyPgDn: v.movePick(+pickerRows, len(choices)) case tcell.KeyTab: v.take(choices[v.picker.selected]) case tcell.KeyEnter: return v.complete(choices[v.picker.selected]) case tcell.KeyEscape: v.picker.dismissed = true default: return false } return true } // movePick moves the highlight, clamping at either end rather than wrapping: // a list that wraps makes it easy to sail past the entry you wanted. func (v *View) movePick(by, count int) { v.picker.selected = min(max(v.picker.selected+by, 0), count-1) rows := min(count, pickerRows) v.picker.top = min(v.picker.top, v.picker.selected) if v.picker.selected >= v.picker.top+rows { v.picker.top = v.picker.selected - rows + 1 } } // complete takes the choice when the word is not yet the choice, and reports // whether it did. A word that already reads exactly as the label is finished: // Enter on it should send, not add a space. func (v *View) complete(choice Choice) bool { if _, word := v.typedWord(); word == choice.Label { return false } v.take(choice) return true } // take replaces the word being typed with the choice's text and puts the // cursor after it. func (v *View) take(choice Choice) { start, _ := v.typedWord() runes := v.runes() column := min(v.column, len(runes)) updated := append([]rune{}, runes[:start]...) updated = append(updated, []rune(choice.Insert)...) updated = append(updated, runes[column:]...) v.input[v.cursor] = string(updated) v.column = start + len([]rune(choice.Insert)) } // clickPick takes the line under a click on the popup, and reports whether the // click was on it at all. The coordinates are absolute, as every mouse event's // are. func (v *View) clickPick(x, y int) bool { choices := v.refresh() bounds := v.picker.bounds.Move(v.Bounds().X, v.Bounds().Y) if len(choices) == 0 || bounds.IsEmpty() || !bounds.Contains(x, y) { return false } index := v.picker.top + y - bounds.Y - 1 if index >= 0 && index < len(choices) { v.picker.selected = index v.take(choices[index]) } return true } // mentions returns the files the text names, out of the ones the picker // offered. A word that merely begins with "@" and matches no file is left as // text: an email address in a prompt is not a file. func (v *View) mentions(text string) []Mention { if v.Files == nil || !strings.ContainsRune(text, mentionRune) { return nil } var out []Mention for _, file := range v.fileList() { if strings.Contains(text, string(mentionRune)+file.Name) { out = append(out, file) } } return out }