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_test.go · 425 lines · 13.3 KBGo Blame HistoryRaw
🛟 Updated. 28d5985 k33g 16h ago1package acp_test
2
3import (
4 "strings"
5 "testing"
6
7 "github.com/gdamore/tcell/v2"
8
9 "codeberg.org/turbo-editors/turbo-core/acp"
10)
11
12// typeText types each rune into the view.
13func typeText(view *acp.View, text string) {
14 for _, r := range text {
15 view.HandleKey(tcell.NewEventKey(tcell.KeyRune, r, tcell.ModNone))
16 }
17}
18
19// press sends one key to the view.
20func press(view *acp.View, key tcell.Key) bool {
21 return view.HandleKey(tcell.NewEventKey(key, 0, tcell.ModNone))
22}
23
24// labels returns what the picker lists, as its labels.
25func labels(view *acp.View) []string {
26 var out []string
27 for _, choice := range view.Choices() {
28 out = append(out, choice.Label)
29 }
30 return out
31}
32
33// withCommands lets the agent announce two commands, one taking input, and
34// waits until the session has them.
35func withCommands(t *testing.T, view *acp.View, agent *fakeAgent) {
36 t.Helper()
37
38 id := agent.handshake()
39 waitFor(t, "ready", view.Session().Ready)
40 agent.update(id, `{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"compact","description":"squash the history"},{"name":"web","description":"Search the web","input":{"hint":"query"}}]}`)
41 waitFor(t, "the commands", func() bool { return len(view.Session().Commands()) == 2 })
42}
43
44// projectFiles is a small project for the "@" picker.
45func projectFiles() []acp.Mention {
46 return []acp.Mention{
47 {Name: "docs/rescan.md", Path: "/src/p/docs/rescan.md"},
48 {Name: "internal/scanner.go", Path: "/src/p/internal/scanner.go"},
49 {Name: "main.go", Path: "/src/p/main.go"},
50 }
51}
52
53func TestCommandChoicesMatchThePrefixIgnoringCase(t *testing.T) {
54 commands := []acp.Command{
55 {Name: "compact", Description: "squash the history"},
56 {Name: "web", Description: "Search the web", Input: &acp.CommandInput{Hint: "query"}},
57 }
58
59 all := acp.CommandChoices(commands, "")
60 if len(all) != 2 {
61 t.Fatalf("an empty prefix lists %d commands, want both", len(all))
62 }
63 if all[0].Insert != "/compact" {
64 t.Errorf("a command taking no input inserts %q, want the name alone", all[0].Insert)
65 }
66 if all[1].Insert != "/web " || all[1].Hint != "query" {
67 t.Errorf("a command taking input became %+v, want a trailing space and the hint", all[1])
68 }
69
70 if got := acp.CommandChoices(commands, "CO"); len(got) != 1 || got[0].Label != "/compact" {
71 t.Errorf("CommandChoices(CO) = %+v, want /compact alone", got)
72 }
73 if got := acp.CommandChoices(commands, "x"); len(got) != 0 {
74 t.Errorf("CommandChoices(x) = %+v, want nothing", got)
75 }
76}
77
78func TestFileChoicesPutNamesThatStartWithThePrefixFirst(t *testing.T) {
79 got := acp.FileChoices(projectFiles(), "sc")
80
81 want := []string{"@internal/scanner.go", "@docs/rescan.md"}
82 if len(got) != len(want) {
83 t.Fatalf("FileChoices(sc) = %+v, want %v", got, want)
84 }
85 for i := range want {
86 if got[i].Label != want[i] {
87 t.Errorf("choice %d is %q, want %q", i, got[i].Label, want[i])
88 }
89 }
90 if got[0].Insert != "@internal/scanner.go " {
91 t.Errorf("Insert = %q, want the name and a space", got[0].Insert)
92 }
93
94 // The cap: a list nobody can choose from is cut, not scrolled.
95 many := make([]acp.Mention, 300)
96 for i := range many {
97 many[i] = acp.Mention{Name: "f" + strings.Repeat("x", i%7) + ".go"}
98 }
99 if got := acp.FileChoices(many, ""); len(got) != 200 {
100 t.Errorf("an empty prefix over 300 files lists %d, want the cap of 200", len(got))
101 }
102}
103
104func TestTypingASlashListsTheAgentsCommands(t *testing.T) {
105 view, agent := newView(t, 60, 14)
106 view.SetFocused(true)
107 withCommands(t, view, agent)
108
109 typeText(view, "/")
110 if got := labels(view); len(got) != 2 {
111 t.Fatalf("Choices() after / = %v, want both commands", got)
112 }
113
114 joined := strings.Join(draw(t, view, 60, 14), "\n")
115 for _, want := range []string{" commands ", "/compact", "squash the history", "/web", "<query>"} {
116 if !strings.Contains(joined, want) {
117 t.Errorf("the popup does not show %q:\n%s", want, joined)
118 }
119 }
120
121 typeText(view, "w")
122 if got := labels(view); len(got) != 1 || got[0] != "/web" {
123 t.Errorf("Choices() after /w = %v, want /web alone", got)
124 }
125}
126
127func TestTabCompletesTheHighlightedCommand(t *testing.T) {
128 view, agent := newView(t, 60, 14)
129 view.SetFocused(true)
130 withCommands(t, view, agent)
131
132 typeText(view, "/co")
133 press(view, tcell.KeyTab)
134 if got := view.Input(); got != "/compact" {
135 t.Errorf("Input() = %q after Tab, want /compact with no space: it takes nothing", got)
136 }
137 if got := view.Choices(); len(got) != 1 {
138 t.Errorf("the popup still lists %d after the word was completed", len(got))
139 }
140
141 view.SetInput("")
142 typeText(view, "/w")
143 press(view, tcell.KeyTab)
144 if got := view.Input(); got != "/web " {
145 t.Errorf("Input() = %q after Tab, want /web and a space for the query", got)
146 }
147 if len(view.Choices()) != 0 {
148 t.Error("the popup is still open after the space")
149 }
150 if len(view.Session().Entries()) != 0 {
151 t.Error("Tab sent the prompt")
152 }
153}
154
155func TestEnterCompletesAnUnfinishedCommandAndSendsAFinishedOne(t *testing.T) {
156 view, agent := newView(t, 60, 14)
157 view.SetFocused(true)
158 withCommands(t, view, agent)
159
160 typeText(view, "/co")
161 press(view, tcell.KeyEnter)
162 if got := view.Input(); got != "/compact" {
163 t.Fatalf("Input() = %q after the first Enter, want the completed command", got)
164 }
165 if len(view.Session().Entries()) != 0 {
166 t.Fatal("the first Enter sent an unfinished command")
167 }
168
169 press(view, tcell.KeyEnter)
170 if got := view.Input(); got != "" {
171 t.Errorf("Input() = %q after the second Enter, want the box emptied", got)
172 }
173 if got := textOf(view.Session().Entries(), acp.EntryUser); got != "/compact" {
174 t.Errorf("the conversation says %q, want /compact", got)
175 }
176 prompt := agent.read()
177 if prompt["method"] != acp.MethodPrompt {
178 t.Fatalf("the client sent %v, want session/prompt", prompt["method"])
179 }
180 blocks := promptBlocks(t, prompt)
181 if len(blocks) != 1 || blocks[0]["text"] != "/compact" {
182 t.Errorf("the command went over the wire as %v, want one text block reading /compact", blocks)
183 }
184}
185
186func TestDownMovesTheHighlightAndEscapePutsTheListAway(t *testing.T) {
187 view, agent := newView(t, 60, 14)
188 view.SetFocused(true)
189 withCommands(t, view, agent)
190
191 typeText(view, "/")
192 press(view, tcell.KeyDown)
193 press(view, tcell.KeyTab)
194 if got := view.Input(); got != "/web " {
195 t.Errorf("Input() = %q after Down and Tab, want the second command", got)
196 }
197
198 view.SetInput("")
199 typeText(view, "/")
200 if !press(view, tcell.KeyEscape) {
201 t.Fatal("Escape was not claimed with the popup open")
202 }
203 if got := view.Choices(); len(got) != 0 {
204 t.Errorf("Choices() = %v after Escape, want the popup closed", got)
205 }
206 if got := view.Input(); got != "/" {
207 t.Errorf("Escape changed the text to %q", got)
208 }
209
210 // It stays closed while the text stands still, and reopens once it moves.
211 if got := view.Choices(); len(got) != 0 {
212 t.Errorf("the popup reopened on the next frame: %v", got)
213 }
214 typeText(view, "c")
215 if got := labels(view); len(got) != 1 || got[0] != "/compact" {
216 t.Errorf("Choices() after typing on = %v, want the popup back", got)
217 }
218}
219
220func TestASlashAwayFromTheStartIsJustText(t *testing.T) {
221 view, agent := newView(t, 60, 14)
222 view.SetFocused(true)
223 withCommands(t, view, agent)
224
225 typeText(view, "run /co")
226 if got := view.Choices(); len(got) != 0 {
227 t.Errorf("Choices() = %v for a slash mid-sentence, want nothing", got)
228 }
229
230 view.SetInput("")
231 press(view, tcell.KeyEnter) // nothing to send
232 typeText(view, "one")
233 view.HandleKey(tcell.NewEventKey(tcell.KeyEnter, 0, tcell.ModAlt))
234 typeText(view, "/co")
235 if got := view.Choices(); len(got) != 0 {
236 t.Errorf("Choices() = %v for a slash on the second line, want nothing", got)
237 }
238}
239
240func TestWithNoCommandsKnownTabStillSwitchesPanes(t *testing.T) {
241 // The agent has announced nothing, so "/" is a character and Tab keeps
242 // its ordinary meaning — otherwise a picker nobody can see would eat it.
243 view, _ := newView(t, 60, 14)
244 view.SetFocused(true)
245
246 typeText(view, "/x")
247 if got := view.Choices(); len(got) != 0 {
248 t.Fatalf("Choices() = %v with no commands, want nothing", got)
249 }
250 press(view, tcell.KeyTab)
251 typeText(view, "y")
252 if got := view.Input(); got != "/x" {
253 t.Errorf("Input() = %q; Tab did not move the focus off the box", got)
254 }
255}
256
257func TestTypingAnAtListsTheProjectsFiles(t *testing.T) {
258 view, _ := newView(t, 60, 14)
259 view.SetFocused(true)
260 view.Files = projectFiles
261
262 typeText(view, "look at @sc")
263 want := []string{"@internal/scanner.go", "@docs/rescan.md"}
264 if got := labels(view); strings.Join(got, ",") != strings.Join(want, ",") {
265 t.Fatalf("Choices() = %v, want %v", got, want)
266 }
267
268 joined := strings.Join(draw(t, view, 60, 14), "\n")
269 if !strings.Contains(joined, " files ") || !strings.Contains(joined, "@internal/scanner.go") {
270 t.Errorf("the popup is not titled files, or does not list the file:\n%s", joined)
271 }
272
273 press(view, tcell.KeyTab)
274 if got := view.Input(); got != "look at @internal/scanner.go " {
275 t.Errorf("Input() = %q after Tab", got)
276 }
277 if len(view.Choices()) != 0 {
278 t.Error("the popup is still open after the mention was completed")
279 }
280}
281
282func TestWithoutAFileListAnAtIsJustACharacter(t *testing.T) {
283 view, _ := newView(t, 60, 14)
284 view.SetFocused(true)
285
286 typeText(view, "@sc")
287 if got := view.Choices(); len(got) != 0 {
288 t.Errorf("Choices() = %v with no Files, want nothing", got)
289 }
290}
291
292// promptBlocks returns the content blocks of a session/prompt the agent read.
293func promptBlocks(t *testing.T, prompt map[string]any) []map[string]any {
294 t.Helper()
295
296 params, _ := prompt["params"].(map[string]any)
297 raw, _ := params["prompt"].([]any)
298 var blocks []map[string]any
299 for _, block := range raw {
300 m, ok := block.(map[string]any)
301 if !ok {
302 t.Fatalf("a prompt block is %T, want an object", block)
303 }
304 blocks = append(blocks, m)
305 }
306 return blocks
307}
308
309// mentioningView returns a view whose session reads files through read, and
310// whose project is projectFiles.
311func mentioningView(t *testing.T, read func(string) (string, error)) (*acp.View, *fakeAgent) {
312 t.Helper()
313
314 session, agent := start(t, acp.Options{ReadTextFile: read})
315 view := acp.NewView(session)
316 view.Files = projectFiles
317 return view, agent
318}
319
320func TestAMentionIsSentAsTheFilesTextWhenTheAgentEmbedsContext(t *testing.T) {
321 var asked string
322 read := func(path string) (string, error) {
323 asked = path
324 return "package internal\n", nil
325 }
326 view, agent := mentioningView(t, read)
327 agent.handshake() // declares embeddedContext
328 waitFor(t, "ready", view.Session().Ready)
329
330 view.SetInput("explain @internal/scanner.go please")
331 view.Send()
332
333 blocks := promptBlocks(t, agent.read())
334 if len(blocks) != 3 {
335 t.Fatalf("the prompt has %d blocks, want text, resource, text: %v", len(blocks), blocks)
336 }
337 if blocks[0]["type"] != "text" || blocks[0]["text"] != "explain " {
338 t.Errorf("block 0 = %v, want the words before the mention", blocks[0])
339 }
340 if blocks[2]["type"] != "text" || blocks[2]["text"] != " please" {
341 t.Errorf("block 2 = %v, want the words after it", blocks[2])
342 }
343
344 resource, _ := blocks[1]["resource"].(map[string]any)
345 if blocks[1]["type"] != "resource" || resource == nil {
346 t.Fatalf("block 1 = %v, want an embedded resource", blocks[1])
347 }
348 if resource["uri"] != "file:///src/p/internal/scanner.go" {
349 t.Errorf("uri = %v", resource["uri"])
350 }
351 if resource["mimeType"] != "text/x-go" {
352 t.Errorf("mimeType = %v, want text/x-go", resource["mimeType"])
353 }
354 if resource["text"] != "package internal\n" {
355 t.Errorf("text = %q, want what the editor read", resource["text"])
356 }
357 if asked != "/src/p/internal/scanner.go" {
358 t.Errorf("the editor was asked for %q", asked)
359 }
360
361 // The conversation keeps what was typed, name and all.
362 if got := textOf(view.Session().Entries(), acp.EntryUser); got != "explain @internal/scanner.go please" {
363 t.Errorf("the conversation says %q", got)
364 }
365}
366
367func TestAMentionIsSentAsALinkWhenTheAgentDoesNotEmbedContext(t *testing.T) {
368 view, agent := mentioningView(t, nil)
369 agent.handshakeWith(`{"promptCapabilities":{"image":false}}`)
370 waitFor(t, "ready", view.Session().Ready)
371 if view.Session().EmbedsContext() {
372 t.Fatal("EmbedsContext() = true for an agent that did not declare it")
373 }
374
375 view.SetInput("@main.go")
376 view.Send()
377
378 blocks := promptBlocks(t, agent.read())
379 if len(blocks) != 1 || blocks[0]["type"] != "resource_link" {
380 t.Fatalf("the prompt is %v, want one resource_link", blocks)
381 }
382 if blocks[0]["uri"] != "file:///src/p/main.go" || blocks[0]["name"] != "main.go" {
383 t.Errorf("the link is %v", blocks[0])
384 }
385 if _, has := blocks[0]["text"]; has {
386 t.Error("a link carries a text field")
387 }
388}
389
390func TestAFileThatCannotBeReadIsSentAsALinkRatherThanDropped(t *testing.T) {
391 read := func(string) (string, error) { return "", errReadFailed }
392 view, agent := mentioningView(t, read)
393 agent.handshake()
394 waitFor(t, "ready", view.Session().Ready)
395
396 view.SetInput("@main.go")
397 view.Send()
398
399 blocks := promptBlocks(t, agent.read())
400 if len(blocks) != 1 || blocks[0]["type"] != "resource_link" {
401 t.Errorf("the prompt is %v, want the mention as a link", blocks)
402 }
403}
404
405func TestAWordThatNamesNoFileStaysText(t *testing.T) {
406 view, agent := mentioningView(t, nil)
407 agent.handshake()
408 waitFor(t, "ready", view.Session().Ready)
409
410 // An address is not a file, and main.gopher is not main.go.
411 view.SetInput("mail @bob.example.com about @main.gopher")
412 view.Send()
413
414 blocks := promptBlocks(t, agent.read())
415 if len(blocks) != 1 || blocks[0]["type"] != "text" {
416 t.Errorf("the prompt is %v, want one text block", blocks)
417 }
418}
419
420// errReadFailed stands in for a file the editor could not read.
421var errReadFailed = errString("cannot read")
422
423type errString string
424
425func (e errString) Error() string { return string(e) }