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

picker.go · 321 lines · 9.7 KBGo Blame HistoryRaw
🛟 Updated. 28d5985 k33g 14h ago1package acp
2
3import (
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.
16const (
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.
23const (
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 "}
33type 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.
53type 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")
80func 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")
109func 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.
136func 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.
145func (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.
160func (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.
179func (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.
192func (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.
214func (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.
223func (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.
252func (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.
265func (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.
275func (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.
291func (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.
309func (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}