| 🛟 Updated. 28d5985 k33g 14h ago | 1 | package acp |
| 2 | |
| 3 | import ( |
| 4 | "strings" |
| 5 | |
| 6 | "github.com/gdamore/tcell/v2" |
| 7 | |
| 8 | "codeberg.org/turbo-editors/turbo-core/ui" |
| 9 | ) |
| 10 | |
| 11 | // The two characters that open the picker: "/" at the very start of the box |
| 12 | // lists the agent's commands, "@" anywhere lists the project's files. |
| 13 | // |
| 14 | // They are the characters Zed uses for the same two things, so an agent's own |
| 15 | // documentation — "type /web to search" — is true in this editor as well. |
| 16 | const ( |
| 17 | commandRune = '/' |
| 18 | mentionRune = '@' |
| 19 | ) |
| 20 | |
| 21 | // Picker geometry. Eight rows, like the completion popup: a list that covered |
| 22 | // the conversation would hide the very reply the command is about. |
| 23 | const ( |
| 24 | pickerRows = 8 |
| 25 | pickerMinWidth = 24 |
| 26 | pickerMaxChoices = 200 |
| 27 | ) |
| 28 | |
| 29 | // Choice is one line of the picker: what it shows, and what choosing it types. |
| 30 | // |
| 31 | // acp.Choice{Label: "/web", Detail: "Search the web", Hint: "query", Insert: "/web "} |
| 32 | // acp.Choice{Label: "@app/menus.go", Insert: "@app/menus.go "} |
| 33 | type Choice struct { |
| 34 | // Label is what is matched against the word being typed, marker included. |
| 35 | Label string |
| 36 | // Detail is the agent's description of a command; "" for a file. |
| 37 | Detail string |
| 38 | // Hint is what the agent suggests typing after a command, or "". |
| 39 | Hint string |
| 40 | // Insert replaces the word being typed when the choice is taken. It ends |
| 41 | // in a space when something is expected to follow. |
| 42 | Insert string |
| 43 | } |
| 44 | |
| 45 | // picker is the popup's own state: which line is highlighted, how far the |
| 46 | // list is scrolled, and whether Escape put it away. |
| 47 | // |
| 48 | // The list itself is not stored. It is a function of the word under the |
| 49 | // cursor and of what the session and the editor know right now, and it is |
| 50 | // recomputed by refresh on every key and every frame — the same bargain the |
| 51 | // transcript's layout makes, for the same reason: nothing has to be kept in |
| 52 | // step. |
| 53 | type picker struct { |
| 54 | selected int |
| 55 | top int |
| 56 | |
| 57 | // typed is the word the list was last computed for. When it changes the |
| 58 | // highlight goes back to the top, because the list under it did. |
| 59 | typed string |
| 60 | |
| 61 | // dismissed says Escape closed the popup, and it stays closed until the |
| 62 | // text changes. Without it, Escape would close a popup that reopened on |
| 63 | // the next frame. |
| 64 | dismissed bool |
| 65 | |
| 66 | // files is the project's files, fetched once when an "@" word opens the |
| 67 | // popup and dropped when it closes. Walking a project on every keystroke |
| 68 | // would make the box stutter. |
| 69 | files []Mention |
| 70 | |
| 71 | // bounds is where the popup was last drawn, in the view's own |
| 72 | // coordinates, so that a click can be matched to a line. |
| 73 | bounds ui.Rect |
| 74 | } |
| 75 | |
| 76 | // CommandChoices returns the commands whose name begins with prefix, in the |
| 77 | // agent's order. Matching ignores case, so "/Co" finds "/compact". |
| 78 | // |
| 79 | // acp.CommandChoices(session.Commands(), "co") |
| 80 | func CommandChoices(commands []Command, prefix string) []Choice { |
| 81 | wanted := strings.ToLower(prefix) |
| 82 | var out []Choice |
| 83 | for _, command := range commands { |
| 84 | if !strings.HasPrefix(strings.ToLower(command.Name), wanted) { |
| 85 | continue |
| 86 | } |
| 87 | insert := string(commandRune) + command.Name |
| 88 | if command.TakesInput() { |
| 89 | insert += " " |
| 90 | } |
| 91 | out = append(out, Choice{ |
| 92 | Label: string(commandRune) + command.Name, |
| 93 | Detail: command.Description, |
| 94 | Hint: command.Hint(), |
| 95 | Insert: insert, |
| 96 | }) |
| 97 | } |
| 98 | return out |
| 99 | } |
| 100 | |
| 101 | // FileChoices returns the files whose path contains prefix, ignoring case. |
| 102 | // Files whose own name begins with it come first, because "@sc" is far more |
| 103 | // often the start of scanner.go than the middle of some directory. |
| 104 | // |
| 105 | // The list is capped: a picker offering two thousand lines is a list to |
| 106 | // scroll, not a list to choose from, and typing one more letter is faster. |
| 107 | // |
| 108 | // acp.FileChoices(files, "scan") |
| 109 | func FileChoices(files []Mention, prefix string) []Choice { |
| 110 | wanted := strings.ToLower(prefix) |
| 111 | var first, rest []Choice |
| 112 | for _, file := range files { |
| 113 | lower := strings.ToLower(file.Name) |
| 114 | if !strings.Contains(lower, wanted) { |
| 115 | continue |
| 116 | } |
| 117 | choice := Choice{ |
| 118 | Label: string(mentionRune) + file.Name, |
| 119 | Insert: string(mentionRune) + file.Name + " ", |
| 120 | } |
| 121 | if strings.HasPrefix(baseName(lower), wanted) { |
| 122 | first = append(first, choice) |
| 123 | } else { |
| 124 | rest = append(rest, choice) |
| 125 | } |
| 126 | } |
| 127 | |
| 128 | out := append(first, rest...) |
| 129 | if len(out) > pickerMaxChoices { |
| 130 | out = out[:pickerMaxChoices] |
| 131 | } |
| 132 | return out |
| 133 | } |
| 134 | |
| 135 | // baseName is the last segment of a slash-separated path. |
| 136 | func baseName(path string) string { |
| 137 | if slash := strings.LastIndexByte(path, '/'); slash >= 0 { |
| 138 | return path[slash+1:] |
| 139 | } |
| 140 | return path |
| 141 | } |
| 142 | |
| 143 | // typedWord returns the word the cursor is at the end of on the current line |
| 144 | // — from the space before it to the cursor — and where it starts, in runes. |
| 145 | func (v *View) typedWord() (start int, word string) { |
| 146 | runes := v.runes() |
| 147 | column := min(v.column, len(runes)) |
| 148 | |
| 149 | start = column |
| 150 | for start > 0 && !isSpace(runes[start-1]) { |
| 151 | start-- |
| 152 | } |
| 153 | return start, string(runes[start:column]) |
| 154 | } |
| 155 | |
| 156 | // choices returns what the picker should list right now, and nothing when it |
| 157 | // should not be open: the focus is elsewhere, Escape closed it, or the word |
| 158 | // under the cursor does not begin with a marker in the place that marker means |
| 159 | // something. |
| 160 | func (v *View) choices() []Choice { |
| 161 | if !v.onInput || v.picker.dismissed { |
| 162 | return nil |
| 163 | } |
| 164 | |
| 165 | start, word := v.typedWord() |
| 166 | switch { |
| 167 | case word == "": |
| 168 | return nil |
| 169 | case word[0] == commandRune && v.cursor == 0 && start == 0: |
| 170 | return CommandChoices(v.session.Commands(), word[1:]) |
| 171 | case word[0] == mentionRune && v.Files != nil: |
| 172 | return FileChoices(v.fileList(), word[1:]) |
| 173 | } |
| 174 | return nil |
| 175 | } |
| 176 | |
| 177 | // fileList returns the project's files, walking the project the first time |
| 178 | // and reusing the answer while the popup stays open. |
| 179 | func (v *View) fileList() []Mention { |
| 180 | if v.picker.files == nil { |
| 181 | v.picker.files = v.Files() |
| 182 | if v.picker.files == nil { |
| 183 | v.picker.files = []Mention{} // asked and answered: nothing |
| 184 | } |
| 185 | } |
| 186 | return v.picker.files |
| 187 | } |
| 188 | |
| 189 | // refresh recomputes the list, and resets the highlight when the word under |
| 190 | // the cursor changed. It is the one entry point both the keys and the drawing |
| 191 | // use, so they can never disagree about which line is highlighted. |
| 192 | func (v *View) refresh() []Choice { |
| 193 | choices := v.choices() |
| 194 | _, word := v.typedWord() |
| 195 | |
| 196 | if len(choices) == 0 || !strings.HasPrefix(word, string(mentionRune)) { |
| 197 | v.picker.files = nil |
| 198 | } |
| 199 | if word != v.picker.typed { |
| 200 | v.picker.typed = word |
| 201 | v.picker.selected = 0 |
| 202 | v.picker.top = 0 |
| 203 | } |
| 204 | if len(choices) > 0 { |
| 205 | v.picker.selected = min(v.picker.selected, len(choices)-1) |
| 206 | } |
| 207 | return choices |
| 208 | } |
| 209 | |
| 210 | // Choices returns what the picker is listing, or nothing when it is closed. |
| 211 | // |
| 212 | // Exported so that a test — or an editor — can ask what the popup offers |
| 213 | // without reading it back off a screen. |
| 214 | func (v *View) Choices() []Choice { return v.refresh() } |
| 215 | |
| 216 | // handlePickerKey works the popup while it is open, and reports whether the |
| 217 | // key was its. |
| 218 | // |
| 219 | // A printable character is never its: it falls through to the box, the word |
| 220 | // grows, and the list narrows on the next frame. Enter is its only when |
| 221 | // taking the highlighted choice would change the text; on a word that is |
| 222 | // already complete, Enter falls through and sends. |
| 223 | func (v *View) handlePickerKey(ev *tcell.EventKey) bool { |
| 224 | choices := v.refresh() |
| 225 | if len(choices) == 0 { |
| 226 | return false |
| 227 | } |
| 228 | |
| 229 | switch ev.Key() { |
| 230 | case tcell.KeyUp: |
| 231 | v.movePick(-1, len(choices)) |
| 232 | case tcell.KeyDown: |
| 233 | v.movePick(+1, len(choices)) |
| 234 | case tcell.KeyPgUp: |
| 235 | v.movePick(-pickerRows, len(choices)) |
| 236 | case tcell.KeyPgDn: |
| 237 | v.movePick(+pickerRows, len(choices)) |
| 238 | case tcell.KeyTab: |
| 239 | v.take(choices[v.picker.selected]) |
| 240 | case tcell.KeyEnter: |
| 241 | return v.complete(choices[v.picker.selected]) |
| 242 | case tcell.KeyEscape: |
| 243 | v.picker.dismissed = true |
| 244 | default: |
| 245 | return false |
| 246 | } |
| 247 | return true |
| 248 | } |
| 249 | |
| 250 | // movePick moves the highlight, clamping at either end rather than wrapping: |
| 251 | // a list that wraps makes it easy to sail past the entry you wanted. |
| 252 | func (v *View) movePick(by, count int) { |
| 253 | v.picker.selected = min(max(v.picker.selected+by, 0), count-1) |
| 254 | |
| 255 | rows := min(count, pickerRows) |
| 256 | v.picker.top = min(v.picker.top, v.picker.selected) |
| 257 | if v.picker.selected >= v.picker.top+rows { |
| 258 | v.picker.top = v.picker.selected - rows + 1 |
| 259 | } |
| 260 | } |
| 261 | |
| 262 | // complete takes the choice when the word is not yet the choice, and reports |
| 263 | // whether it did. A word that already reads exactly as the label is finished: |
| 264 | // Enter on it should send, not add a space. |
| 265 | func (v *View) complete(choice Choice) bool { |
| 266 | if _, word := v.typedWord(); word == choice.Label { |
| 267 | return false |
| 268 | } |
| 269 | v.take(choice) |
| 270 | return true |
| 271 | } |
| 272 | |
| 273 | // take replaces the word being typed with the choice's text and puts the |
| 274 | // cursor after it. |
| 275 | func (v *View) take(choice Choice) { |
| 276 | start, _ := v.typedWord() |
| 277 | runes := v.runes() |
| 278 | column := min(v.column, len(runes)) |
| 279 | |
| 280 | updated := append([]rune{}, runes[:start]...) |
| 281 | updated = append(updated, []rune(choice.Insert)...) |
| 282 | updated = append(updated, runes[column:]...) |
| 283 | |
| 284 | v.input[v.cursor] = string(updated) |
| 285 | v.column = start + len([]rune(choice.Insert)) |
| 286 | } |
| 287 | |
| 288 | // clickPick takes the line under a click on the popup, and reports whether the |
| 289 | // click was on it at all. The coordinates are absolute, as every mouse event's |
| 290 | // are. |
| 291 | func (v *View) clickPick(x, y int) bool { |
| 292 | choices := v.refresh() |
| 293 | bounds := v.picker.bounds.Move(v.Bounds().X, v.Bounds().Y) |
| 294 | if len(choices) == 0 || bounds.IsEmpty() || !bounds.Contains(x, y) { |
| 295 | return false |
| 296 | } |
| 297 | |
| 298 | index := v.picker.top + y - bounds.Y - 1 |
| 299 | if index >= 0 && index < len(choices) { |
| 300 | v.picker.selected = index |
| 301 | v.take(choices[index]) |
| 302 | } |
| 303 | return true |
| 304 | } |
| 305 | |
| 306 | // mentions returns the files the text names, out of the ones the picker |
| 307 | // offered. A word that merely begins with "@" and matches no file is left as |
| 308 | // text: an email address in a prompt is not a file. |
| 309 | func (v *View) mentions(text string) []Mention { |
| 310 | if v.Files == nil || !strings.ContainsRune(text, mentionRune) { |
| 311 | return nil |
| 312 | } |
| 313 | |
| 314 | var out []Mention |
| 315 | for _, file := range v.fileList() { |
| 316 | if strings.Contains(text, string(mentionRune)+file.Name) { |
| 317 | out = append(out, file) |
| 318 | } |
| 319 | } |
| 320 | return out |
| 321 | } |