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

📦 Turbo Core f3ade8d · on v1.0.1 · k33g · 5h ago
picker.go · 321 lines · 9.7 KBGo Blame HistoryRaw
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
package acp

import (
	"strings"

	"github.com/gdamore/tcell/v2"

	"rickub.com/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
}