返回 DeepSeek-Reasonix
complete_test.go
根目录 / internal / cli / complete_test.go
1 package cli
2
3 import (
4 "fmt"
5 "os"
6 "path/filepath"
7 "strings"
8 "testing"
9
10 tea "charm.land/bubbletea/v2"
11
12 "reasonix/internal/agent"
13 "reasonix/internal/command"
14 "reasonix/internal/control"
15 "reasonix/internal/event"
16 "reasonix/internal/provider"
17 "reasonix/internal/skill"
18 )
19
20 // writeAt creates dir/rel (with parents) holding content, for fs-backed tests.
21 func writeAt(t *testing.T, dir, rel, content string) {
22 t.Helper()
23 p := filepath.Join(dir, rel)
24 if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
25 t.Fatal(err)
26 }
27 if err := os.WriteFile(p, []byte(content), 0o644); err != nil {
28 t.Fatal(err)
29 }
30 }
31
32 func TestCustomCommandHintIdentifiesPluginSource(t *testing.T) {
33 got := customCommandHint(command.Command{Description: "Create a plan", Plugin: "pwf", ShortName: "plan"})
34 if got != "plugin pwf · Create a plan" {
35 t.Fatalf("customCommandHint = %q", got)
36 }
37 if got := customCommandHint(command.Command{Description: "Project plan"}); got != "Project plan" {
38 t.Fatalf("project hint changed = %q", got)
39 }
40 }
41
42 func TestSlashCompletionFilterAndAccept(t *testing.T) {
43 m := newTestChatTUI()
44 m.input.SetValue("/co")
45 m.updateCompletion()
46
47 if !m.completion.active || m.completion.kind != compSlash {
48 t.Fatalf("typing /co should open the slash menu: %+v", m.completion)
49 }
50 // /compact and /copy both start with "/co".
51 if len(m.completion.items) != 2 {
52 t.Fatalf("filter = %v, want /compact and /copy", labels(m.completion.items))
53 }
54 if m.completion.items[0].label != "/compact" || m.completion.items[1].label != "/copy" {
55 t.Fatalf("filter = %v, want [/compact /copy]", labels(m.completion.items))
56 }
57
58 m.acceptCompletion()
59 if got := m.input.Value(); got != "/compact " {
60 t.Errorf("accept should fill the input, got %q", got)
61 }
62 if m.completion.active {
63 t.Error("menu should close after accept")
64 }
65 }
66
67 func TestSlashCompletionIncludesCustomCommands(t *testing.T) {
68 m := newTestChatTUI()
69 m.commands = []command.Command{{Name: "review", Description: "review the diff"}}
70 m.input.SetValue("/re")
71 m.updateCompletion()
72
73 if !hasLabel(m.completion.items, "/review") {
74 t.Errorf("custom command should appear in completion: %v", labels(m.completion.items))
75 }
76 }
77
78 func TestSlashCompletionDocsShowsOnlyRuntimeWinner(t *testing.T) {
79 tests := []struct {
80 name string
81 commands []command.Command
82 skills []skill.Skill
83 wantHint string
84 }{
85 {
86 name: "custom command shadows builtin",
87 commands: []command.Command{{Name: "docs", Description: "custom docs"}},
88 wantHint: "custom docs",
89 },
90 {
91 name: "skill shadows builtin",
92 skills: []skill.Skill{{Name: "docs", Description: "docs skill"}},
93 wantHint: "docs skill",
94 },
95 {
96 name: "custom command shadows skill and builtin",
97 commands: []command.Command{{Name: "docs", Description: "custom docs"}},
98 skills: []skill.Skill{{Name: "docs", Description: "docs skill"}},
99 wantHint: "custom docs",
100 },
101 }
102 for _, tt := range tests {
103 t.Run(tt.name, func(t *testing.T) {
104 m := newTestChatTUI()
105 m.commands = tt.commands
106 m.skills = tt.skills
107 var docs []compItem
108 for _, item := range m.slashItems() {
109 if item.label == "/docs" {
110 docs = append(docs, item)
111 }
112 }
113 if len(docs) != 1 || docs[0].hint != tt.wantHint {
114 t.Fatalf("/docs completion entries = %+v, want one entry with hint %q", docs, tt.wantHint)
115 }
116 if !hasLabel(m.slashItems(), "/reasonix:docs") {
117 t.Fatalf("shadowed built-in docs fallback missing: %v", labels(m.slashItems()))
118 }
119 })
120 }
121 }
122
123 func TestSlashCompletionDocsAccountsForHiddenCompatibilityAliases(t *testing.T) {
124 tests := []struct {
125 name string
126 commands []command.Command
127 skills []skill.Skill
128 wantCanonical string
129 }{
130 {
131 name: "hidden plugin command alias",
132 commands: []command.Command{
133 {Name: "docs", Plugin: "manuals", Hidden: true},
134 {Name: "manuals:docs", Plugin: "manuals"},
135 },
136 wantCanonical: "/manuals:docs",
137 },
138 {
139 name: "compatible plugin skill alias",
140 skills: []skill.Skill{{Name: "docs", Plugin: "manuals"}},
141 wantCanonical: "/manuals:docs",
142 },
143 }
144
145 for _, tt := range tests {
146 t.Run(tt.name, func(t *testing.T) {
147 m := newTestChatTUI()
148 m.commands = tt.commands
149 m.skills = tt.skills
150 items := m.slashItems()
151 if hasLabel(items, "/docs") {
152 t.Fatalf("hidden runtime owner left a misleading /docs entry: %v", labels(items))
153 }
154 for _, want := range []string{"/reasonix:docs", tt.wantCanonical} {
155 if !hasLabel(items, want) {
156 t.Fatalf("completion missing %q: %v", want, labels(items))
157 }
158 }
159 })
160 }
161 }
162
163 func TestSlashCompletionDocsDoesNotDisplaceQualifiedCustomCommands(t *testing.T) {
164 m := newTestChatTUI()
165 m.commands = []command.Command{
166 {Name: "docs", Description: "custom docs"},
167 {Name: "reasonix:docs", Description: "qualified custom docs"},
168 {Name: "reasonix:builtin:docs", Description: "second qualified custom docs"},
169 }
170 items := m.slashItems()
171 for _, want := range []string{"/docs", "/reasonix:docs", "/reasonix:builtin:docs", "/reasonix:builtin:docs:2"} {
172 if !hasLabel(items, want) {
173 t.Fatalf("completion displaced %q: %v", want, labels(items))
174 }
175 }
176 }
177
178 func TestCompletionClosesOnSpaceAndNonMatch(t *testing.T) {
179 m := newTestChatTUI()
180
181 m.input.SetValue("/compact ") // space → typing args, not naming a command
182 m.updateCompletion()
183 if m.completion.active {
184 t.Error("menu should close once a space is typed (now entering args)")
185 }
186
187 m.input.SetValue("/zzz") // no command matches
188 m.updateCompletion()
189 if m.completion.active {
190 t.Error("menu should close when nothing matches")
191 }
192
193 m.input.SetValue("hello") // not a slash line
194 m.updateCompletion()
195 if m.completion.active {
196 t.Error("menu should be inactive for non-slash input")
197 }
198 }
199
200 func TestMoveCompletionWraps(t *testing.T) {
201 m := newTestChatTUI()
202 m.completion = completion{active: true, kind: compSlash, items: []compItem{{label: "/a"}, {label: "/b"}, {label: "/c"}}, sel: 0}
203 m.moveCompletion(-1)
204 if m.completion.sel != 2 {
205 t.Errorf("up from first should wrap to last, got %d", m.completion.sel)
206 }
207 m.moveCompletion(1)
208 if m.completion.sel != 0 {
209 t.Errorf("down from last should wrap to first, got %d", m.completion.sel)
210 }
211 }
212
213 func TestActiveAtToken(t *testing.T) {
214 cases := []struct {
215 val string
216 wantTok string
217 wantOK bool
218 wantAt int
219 }{
220 {"@", "", true, 0},
221 {"look at @src/m", "src/m", true, 8},
222 {"@internal/agent/", "internal/agent/", true, 0},
223 {"a@b.com", "", false, 0}, // '@' not whitespace-preceded → not a ref
224 {"@foo bar", "", false, 0}, // cursor token after the space isn't an @ref
225 {"plain text", "", false, 0},
226 {`@docs/my\ file.md`, `docs/my\ file.md`, true, 0}, // escaped space stays in the token
227 {`see @my\ dir/`, `my\ dir/`, true, 4},
228 }
229 for _, c := range cases {
230 at, end, tok, ok := activeAtToken(c.val, len(c.val))
231 if ok != c.wantOK || (ok && (tok != c.wantTok || at != c.wantAt)) {
232 t.Errorf("activeAtToken(%q) = (%d,%d,%q,%v), want (%d,_,%q,%v)", c.val, at, end, tok, ok, c.wantAt, c.wantTok, c.wantOK)
233 }
234 if ok {
235 if end < at || end > len(c.val) || !strings.HasPrefix(c.val[at:end], "@") {
236 t.Errorf("activeAtToken(%q) span [%d,%d) invalid", c.val, at, end)
237 }
238 // At EOF, caret-limited query equals the full token after '@'.
239 fullTok := c.val[at+1 : end]
240 if !strings.HasPrefix(fullTok, tok) {
241 t.Errorf("activeAtToken(%q) query %q is not a prefix of full token %q", c.val, tok, fullTok)
242 }
243 }
244 }
245 }
246
247 // TestFileItemsEscapedSpaces verifies names with spaces complete as
248 // escaped @tokens and that completion can descend through such a directory:
249 // the escaped token is unescaped for filesystem reads.
250 func TestFileItemsEscapedSpaces(t *testing.T) {
251 dir := t.TempDir()
252 writeAt(t, dir, "my file.md", "x")
253 writeAt(t, dir, "my dir/inner.md", "y")
254
255 m := newTestChatTUI()
256 items := m.fileItems(dir + "/")
257 wantFile := "@" + dir + `/my\ file.md`
258 wantDir := "@" + dir + `/my\ dir/`
259 var gotFile, gotDir bool
260 for _, it := range items {
261 gotFile = gotFile || it.insert == wantFile
262 gotDir = gotDir || it.insert == wantDir
263 }
264 if !gotFile || !gotDir {
265 t.Fatalf("inserts should escape spaces, want %q and %q in %v", wantFile, wantDir, labels(items))
266 }
267
268 deeper := m.fileItems(dir + `/my\ dir/`)
269 if !hasLabel(deeper, "inner.md") {
270 t.Fatalf("descending through an escaped dir should list its entries, got %v", labels(deeper))
271 }
272 }
273
274 func TestSplitPathToken(t *testing.T) {
275 cases := []struct{ in, dir, frag string }{
276 {"main", "", "main"},
277 {"internal/age", "internal/", "age"},
278 {"a/b/c", "a/b/", "c"},
279 {"internal/", "internal/", ""},
280 }
281 for _, c := range cases {
282 if d, f := splitPathToken(c.in); d != c.dir || f != c.frag {
283 t.Errorf("splitPathToken(%q) = (%q,%q), want (%q,%q)", c.in, d, f, c.dir, c.frag)
284 }
285 }
286 }
287
288 // TestFileItemsOneLevel verifies @ completion lists exactly one directory level
289 // (no recursion): a subdir shows as a descendable entry, its contents do not.
290 func TestFileItemsOneLevel(t *testing.T) {
291 dir := t.TempDir()
292 writeAt(t, dir, "alpha.go", "x")
293 writeAt(t, dir, "sub/deep.go", "y") // creates sub/ with a file inside
294 writeAt(t, dir, ".hidden", "z")
295
296 m := newTestChatTUI()
297 items := m.fileItems(dir + "/") // token = "<tmp>/", frag = ""
298
299 if !hasLabel(items, "alpha.go") {
300 t.Errorf("file alpha.go should be listed: %v", labels(items))
301 }
302 if !hasLabel(items, "sub/") {
303 t.Errorf("subdir should be listed as 'sub/': %v", labels(items))
304 }
305 if hasLabel(items, "deep.go") {
306 t.Errorf("nested file deep.go must NOT be listed (one level only): %v", labels(items))
307 }
308 if hasLabel(items, ".hidden") {
309 t.Errorf("hidden file should be skipped unless frag starts with '.': %v", labels(items))
310 }
311 // The subdir entry must be a descend (accepting it navigates into it).
312 for _, it := range items {
313 if it.label == "sub/" && !it.descend {
314 t.Error("directory entry should be a descend")
315 }
316 }
317 }
318
319 func TestFileItemsSubdirUsesWorkspaceRoot(t *testing.T) {
320 cwd := t.TempDir()
321 workspace := t.TempDir()
322 writeAt(t, cwd, "src/cwd.go", "wrong")
323 writeAt(t, workspace, "src/workspace.go", "right")
324
325 orig, _ := os.Getwd()
326 defer os.Chdir(orig)
327 if err := os.Chdir(cwd); err != nil {
328 t.Fatal(err)
329 }
330
331 m := newTestChatTUI()
332 m.ctrl = control.New(control.Options{SessionDir: t.TempDir(), WorkspaceRoot: workspace})
333 items := m.fileItems("src/")
334
335 if !hasLabel(items, "workspace.go") {
336 t.Fatalf("workspace file should be listed for @src/: %v", labels(items))
337 }
338 if hasLabel(items, "cwd.go") {
339 t.Fatalf("cwd file should not leak into workspace completion: %v", labels(items))
340 }
341 }
342
343 func TestFileItemsSearchesBasenameAtTopLevel(t *testing.T) {
344 orig, _ := os.Getwd()
345 defer os.Chdir(orig)
346
347 dir := t.TempDir()
348 writeAt(t, dir, "frontend/wailsjs/runtime/runtime.js", "x")
349 writeAt(t, dir, "node_modules/pkg/runtime.js", "noise")
350 if err := os.Chdir(dir); err != nil {
351 t.Fatal(err)
352 }
353
354 m := newTestChatTUI()
355 items := m.fileItems("runtime.js")
356
357 if !hasLabel(items, "frontend/wailsjs/runtime/runtime.js") {
358 t.Fatalf("top-level @runtime.js should offer nested file path, got %v", labels(items))
359 }
360 if hasLabel(items, "node_modules/pkg/runtime.js") {
361 t.Fatalf("file search should skip node_modules noise, got %v", labels(items))
362 }
363 }
364
365 func TestFileItemsSearchRespectsMenuCap(t *testing.T) {
366 orig, _ := os.Getwd()
367 defer os.Chdir(orig)
368
369 dir := t.TempDir()
370 for i := 0; i < maxCompItems; i++ {
371 writeAt(t, dir, filepath.Join("aa-dir-"+fmt.Sprintf("%03d", i), "file.txt"), "x")
372 }
373 writeAt(t, dir, "nested/aa-deep.js", "y")
374 if err := os.Chdir(dir); err != nil {
375 t.Fatal(err)
376 }
377
378 m := newTestChatTUI()
379 items := m.fileItems("aa")
380
381 if len(items) != maxCompItems {
382 t.Fatalf("fileItems should stay capped at %d entries, got %d", maxCompItems, len(items))
383 }
384 if hasLabel(items, "nested/aa-deep.js") {
385 t.Fatalf("search result should not exceed capped menu: %v", labels(items))
386 }
387 }
388
389 func TestFileItemsHiddenWhenDotTyped(t *testing.T) {
390 dir := t.TempDir()
391 writeAt(t, dir, ".hidden", "z")
392 m := newTestChatTUI()
393 items := m.fileItems(dir + "/.") // frag = "." → show hidden
394 if !hasLabel(items, ".hidden") {
395 t.Errorf("hidden file should appear when frag starts with '.': %v", labels(items))
396 }
397 }
398
399 // TestSlashArgCompletionMCPSubcommands proves explicit help syntax opens the
400 // subcommand menu; a bare trailing space stays submit-ready.
401 func TestSlashArgCompletionMCPSubcommands(t *testing.T) {
402 m := newTestChatTUI()
403 m.input.SetValue("/mcp?")
404 m.updateCompletion()
405 if !m.completion.active || m.completion.kind != compSlashArg {
406 t.Fatalf("/mcp? should open the argument menu: %+v", m.completion)
407 }
408 for _, want := range []string{"add", "connect", "remove", "show", "tools", "import"} {
409 if !hasLabel(m.completion.items, want) {
410 t.Errorf("subcommand %q missing: %v", want, labels(m.completion.items))
411 }
412 }
413 if hasLabel(m.completion.items, "list") {
414 t.Errorf("redundant list subcommand should be hidden from /mcp? menu: %v", labels(m.completion.items))
415 }
416 m.acceptCompletion()
417 if got := m.input.Value(); got != "/mcp add " {
418 t.Fatalf("accepting /mcp? subcommand should replace ? with command, got %q", got)
419 }
420
421 m.input.SetValue("/mcp ")
422 m.updateCompletion()
423 if m.completion.active {
424 t.Fatalf("/mcp <space> should not open the argument menu: %+v", m.completion)
425 }
426 }
427
428 // TestSlashArgCompletionMCPFilterAndAccept proves the typed prefix filters the
429 // subcommands and that accepting replaces only the current token (not the line).
430 func TestSlashArgCompletionMCPFilterAndAccept(t *testing.T) {
431 m := newTestChatTUI()
432 m.input.SetValue("/mcp re")
433 m.updateCompletion()
434 if len(m.completion.items) != 1 || m.completion.items[0].label != "remove" {
435 t.Fatalf("/mcp re should filter to remove, got %v", labels(m.completion.items))
436 }
437 m.acceptCompletion()
438 if got := m.input.Value(); got != "/mcp remove " {
439 t.Errorf("accept should replace just the token, got %q want %q", got, "/mcp remove ")
440 }
441 }
442
443 // TestSlashArgCompletionMCPAddFlags proves add offers transport flags once the
444 // token starts with "-", and stays quiet for the free-form server name.
445 func TestSlashArgCompletionMCPAddFlags(t *testing.T) {
446 m := newTestChatTUI()
447 m.input.SetValue("/mcp add myserver --h")
448 m.updateCompletion()
449 if !hasLabel(m.completion.items, "--http") {
450 t.Errorf("--h should offer --http: %v", labels(m.completion.items))
451 }
452
453 m.input.SetValue("/mcp add my")
454 m.updateCompletion()
455 if m.completion.active {
456 t.Error("the free-form server name should not open a menu")
457 }
458 }
459
460 // TestSlashCompletionMCPDoesNotAutoDescend proves accepting "/mcp" keeps the
461 // bare command submit-ready; only an explicitly typed trailing space opens the
462 // subcommand menu.
463 func TestSlashCompletionMCPDoesNotAutoDescend(t *testing.T) {
464 m := newTestChatTUI()
465 m.input.SetValue("/mcp")
466 m.updateCompletion()
467 m.acceptCompletion()
468 if got := m.input.Value(); got != "/mcp" {
469 t.Fatalf("accepting /mcp should keep %q, got %q", "/mcp", got)
470 }
471 if m.completion.active {
472 t.Fatalf("accepting /mcp should not chain into the subcommand menu: %+v", m.completion)
473 }
474 }
475
476 func TestEnterOnExactMCPSubmitsManager(t *testing.T) {
477 isolateUserConfig(t)
478 m := newTestChatTUI()
479 m.input.SetValue("/mcp")
480 m.updateCompletion()
481 if !m.completion.active {
482 t.Fatal("typing /mcp should show slash completion before Enter")
483 }
484 if m.completion.kind == compSlashArg {
485 t.Fatalf("typing exact /mcp should not open subcommand completion: %+v", m.completion)
486 }
487
488 got, _ := m.update(tea.KeyPressMsg{Code: tea.KeyEnter})
489 next := got.(chatTUI)
490 if next.mcp == nil || next.mcp.stage != mcpStageList {
491 t.Fatalf("Enter on exact /mcp should open manager, got %#v", next.mcp)
492 }
493 }
494
495 func TestEnterOnMCPWithTrailingSpaceSubmitsManager(t *testing.T) {
496 isolateUserConfig(t)
497 m := newTestChatTUI()
498 m.input.SetValue("/mcp ")
499 m.updateCompletion()
500 if m.completion.active {
501 t.Fatalf("/mcp <space> should stay submit-ready before Enter: %+v", m.completion)
502 }
503
504 got, _ := m.update(tea.KeyPressMsg{Code: tea.KeyEnter})
505 next := got.(chatTUI)
506 if next.mcp == nil || next.mcp.stage != mcpStageList {
507 t.Fatalf("Enter on bare /mcp arg menu should open manager, got %#v", next.mcp)
508 }
509 }
510
511 func TestEnterOnExactSlashArgSubmitsWhenPrefixAlsoMatches(t *testing.T) {
512 m := newTestChatTUI()
513 m.ctrl = control.New(control.Options{SessionDir: t.TempDir()})
514 m.input.SetValue("/resume 1")
515 m.completion = completion{
516 active: true,
517 kind: compSlashArg,
518 items: []compItem{{label: "1", insert: "1"}, {label: "10", insert: "10"}},
519 sel: 0,
520 replaceFrom: len("/resume "),
521 replaceTo: len("/resume 1"),
522 }
523
524 got, _ := m.update(tea.KeyPressMsg{Code: tea.KeyEnter})
525 next := got.(chatTUI)
526 if next.completion.active {
527 t.Fatalf("Enter on exact selected arg should close completion: %+v", next.completion)
528 }
529 if got := next.input.Value(); got != "" {
530 t.Fatalf("Enter on exact selected arg should submit command, input=%q", got)
531 }
532 }
533
534 // TestSlashArgCompletionRemoveNoHost proves "/mcp remove " stays closed when no
535 // servers are connected (nothing to suggest), rather than showing an empty box.
536 func TestSlashArgCompletionRemoveNoHost(t *testing.T) {
537 m := newTestChatTUI()
538 m.input.SetValue("/mcp remove ")
539 m.updateCompletion()
540 if m.completion.active {
541 t.Error("remove with no connected servers should not open a menu")
542 }
543 }
544
545 func TestSlashArgCompletionSwitchBranches(t *testing.T) {
546 dir := t.TempDir()
547 exec := agent.New(nil, nil, agent.NewSession("sys"), agent.Options{}, event.Discard)
548 exec.Session().Add(provider.Message{Role: provider.RoleUser, Content: "root prompt"})
549 ctrl := control.New(control.Options{Executor: exec, SessionDir: dir, Label: "test"})
550 rootPath := filepath.Join(dir, "root.jsonl")
551 ctrl.SetSessionPath(rootPath)
552 if err := ctrl.Snapshot(); err != nil {
553 t.Fatal(err)
554 }
555
556 child := agent.NewSession("sys")
557 child.Add(provider.Message{Role: provider.RoleUser, Content: "child prompt"})
558 childPath := filepath.Join(dir, "child.jsonl")
559 if err := child.Save(childPath); err != nil {
560 t.Fatal(err)
561 }
562 if err := agent.SaveBranchMeta(childPath, agent.BranchMeta{Name: "experiment", ParentID: agent.BranchID(rootPath)}); err != nil {
563 t.Fatal(err)
564 }
565 pending := agent.NewSession("sys")
566 pending.Add(provider.Message{Role: provider.RoleUser, Content: "pending child prompt"})
567 pendingPath := filepath.Join(dir, "pending.jsonl")
568 if err := pending.Save(pendingPath); err != nil {
569 t.Fatal(err)
570 }
571 if err := agent.SaveBranchMeta(pendingPath, agent.BranchMeta{Name: "exp-pending", ParentID: agent.BranchID(rootPath)}); err != nil {
572 t.Fatal(err)
573 }
574 if err := agent.MarkCleanupPending(pendingPath, "delete"); err != nil {
575 t.Fatal(err)
576 }
577
578 m := newTestChatTUI()
579 m.ctrl = ctrl
580 m.input.SetValue("/switch exp")
581 m.updateCompletion()
582 if !m.completion.active || m.completion.kind != compSlashArg {
583 t.Fatalf("/switch should open branch completion: %+v", m.completion)
584 }
585 if len(m.completion.items) != 1 || m.completion.items[0].label != "child" {
586 t.Fatalf("branch completion = %v, want child", labels(m.completion.items))
587 }
588 }
589
590 func TestSlashArgCompletionLanguage(t *testing.T) {
591 m := newTestChatTUI()
592 m.input.SetValue("/language ")
593 m.updateCompletion()
594 if !m.completion.active || m.completion.kind != compSlashArg {
595 t.Fatalf("/language should open arg completion: %+v", m.completion)
596 }
597 for _, want := range []string{"auto", "en", "zh"} {
598 if !hasLabel(m.completion.items, want) {
599 t.Fatalf("/language completion missing %q: %v", want, labels(m.completion.items))
600 }
601 }
602 }
603
604 func TestSlashArgCompletionCurrency(t *testing.T) {
605 m := newTestChatTUI()
606 m.input.SetValue("/currency ")
607 m.updateCompletion()
608 if !m.completion.active || m.completion.kind != compSlashArg {
609 t.Fatalf("/currency should open arg completion: %+v", m.completion)
610 }
611 for _, want := range []string{"auto", "CNY", "USD"} {
612 if !hasLabel(m.completion.items, want) {
613 t.Fatalf("/currency completion missing %q: %v", want, labels(m.completion.items))
614 }
615 }
616 }
617
618 func TestSlashArgCompletionReasoningLanguage(t *testing.T) {
619 m := newTestChatTUI()
620 m.input.SetValue("/reasoning-language ")
621 m.updateCompletion()
622 if !m.completion.active || m.completion.kind != compSlashArg {
623 t.Fatalf("/reasoning-language should open arg completion: %+v", m.completion)
624 }
625 for _, want := range []string{"auto", "zh", "en"} {
626 if !hasLabel(m.completion.items, want) {
627 t.Fatalf("/reasoning-language completion missing %q: %v", want, labels(m.completion.items))
628 }
629 }
630 if hasLabel(m.completion.items, "中文") {
631 t.Fatalf("/reasoning-language completion should expose only auto|zh|en: %v", labels(m.completion.items))
632 }
633 }
634
635 func labels(items []compItem) []string {
636 out := make([]string, len(items))
637 for i, it := range items {
638 out[i] = it.label
639 }
640 return out
641 }
642
643 func hasLabel(items []compItem, label string) bool {
644 for _, it := range items {
645 if it.label == label {
646 return true
647 }
648 }
649 return false
650 }
651
652 // --- fuzzy matching for / completion ---
653
654 // TestFuzzyFilterSlashSubsequence proves the slash-menu fuzzy filter matches
655 // command labels whose letters appear in order, even when they are not a
656 // prefix: /mdl should surface /model (m-o-d-l) without also pulling in /mcp
657 // (m-c-p is not a subsequence of m-d-l).
658 func TestFuzzyFilterSlashSubsequence(t *testing.T) {
659 m := newTestChatTUI()
660 m.input.SetValue("/mdl")
661 m.updateCompletion()
662
663 if !m.completion.active {
664 t.Fatal("menu should open on a partial / token")
665 }
666 if !hasLabel(m.completion.items, "/model") {
667 t.Errorf("/model should match /mdl as a subsequence: %v", labels(m.completion.items))
668 }
669 if hasLabel(m.completion.items, "/mcp") {
670 t.Errorf("/mcp should NOT match /mdl (m-c-p is not a subsequence of m-d-l): %v", labels(m.completion.items))
671 }
672 }
673
674 // TestFuzzyFilterSlashPrefixFirst proves prefix hits rank ahead of
675 // subsequence-only hits, matching the menu behavior we want: typing "/mo"
676 // should put /model and /mouse (both true "/mo" prefixes) at the top, not
677 // buried after non-prefix matches.
678 func TestFuzzyFilterSlashPrefixFirst(t *testing.T) {
679 m := newTestChatTUI()
680 m.input.SetValue("/mo")
681 m.updateCompletion()
682
683 if !m.completion.active {
684 t.Fatal("menu should open for /mo")
685 }
686 // /model and /mouse are the only built-ins whose label starts with /mo;
687 // slashItems() declares /model first, and the filter is stable, so it
688 // leads.
689 if len(m.completion.items) < 2 || m.completion.items[0].label != "/model" || m.completion.items[1].label != "/mouse" {
690 t.Fatalf("prefix hits /model, /mouse should rank first in declaration order, got %v", labels(m.completion.items))
691 }
692 // Any other built-ins in the list are subsequence-only matches and must
693 // therefore NOT be prefix hits of /mo.
694 for _, it := range m.completion.items[2:] {
695 if strings.HasPrefix(it.label, "/mo") {
696 t.Errorf("%q should not appear after the /mo prefix hits", it.label)
697 }
698 }
699 }
700
701 // TestFuzzyFilterSlashCaseInsensitive proves the subsequence match is
702 // case-insensitive, since users routinely type commands in lowercase while
703 // the menu labels are all lowercase already.
704 func TestFuzzyFilterSlashCaseInsensitive(t *testing.T) {
705 m := newTestChatTUI()
706 m.input.SetValue("/COMP")
707 m.updateCompletion()
708
709 if !hasLabel(m.completion.items, "/compact") {
710 t.Fatalf("uppercase /COMP should still match /compact: %v", labels(m.completion.items))
711 }
712 }
713
714 // TestFuzzyFilterSlashEmptyQueryMatchesAll proves a bare "/" opens the menu
715 // with every command -- the same behavior the old prefix filter had, since
716 // every label trivially starts with "".
717 func TestFuzzyFilterSlashEmptyQueryMatchesAll(t *testing.T) {
718 m := newTestChatTUI()
719 all := len(m.slashItems())
720
721 m.input.SetValue("/")
722 m.updateCompletion()
723
724 if !m.completion.active {
725 t.Fatal("menu should open on a bare /")
726 }
727 if got := len(m.completion.items); got != all {
728 t.Errorf("bare / should list every slash item, got %d want %d", got, all)
729 }
730 }
731
732 // TestFuzzyFilterSlashNoMatchClosesMenu proves the menu still closes when the
733 // query matches nothing -- the contract the existing /zzz test relies on.
734 func TestFuzzyFilterSlashNoMatchClosesMenu(t *testing.T) {
735 m := newTestChatTUI()
736 m.input.SetValue("/xzqzqz")
737 m.updateCompletion()
738
739 if m.completion.active {
740 t.Errorf("menu should close when no command matches: items=%v", labels(m.completion.items))
741 }
742 }
743
744 // TestFuzzyFilterSlashAppliesToCustomCommands proves the fuzzy filter also
745 // covers custom slash commands (not just built-ins) -- the practical payoff,
746 // since users tend to invent short names like /review and type them fast.
747 func TestFuzzyFilterSlashAppliesToCustomCommands(t *testing.T) {
748 m := newTestChatTUI()
749 m.commands = []command.Command{
750 {Name: "review", Description: "review the diff"},
751 {Name: "release-notes", Description: "draft release notes"},
752 }
753 // /rle should match /release-notes (r-l-e in order) but NOT /review
754 // (r-e-v-i-e-w has no 'l' after the initial r).
755 m.input.SetValue("/rle")
756 m.updateCompletion()
757
758 if !hasLabel(m.completion.items, "/release-notes") {
759 t.Errorf("/release-notes should match /rle: %v", labels(m.completion.items))
760 }
761 if hasLabel(m.completion.items, "/review") {
762 t.Errorf("/review should NOT match /rle (r-e-v-i-e-w has no 'l' after r): %v", labels(m.completion.items))
763 }
764 }
765
766 // TestFuzzyFilterSlashAcceptFillsInput proves the end-to-end accept path still
767 // works under the fuzzy filter: typing /compt then Tab should fill the input
768 // with the top-ranked hit, which is /compact.
769 func TestFuzzyFilterSlashAcceptFillsInput(t *testing.T) {
770 m := newTestChatTUI()
771 m.input.SetValue("/compt")
772 m.updateCompletion()
773
774 if !m.completion.active {
775 t.Fatal("menu should open for /compt")
776 }
777 if m.completion.items[0].label != "/compact" {
778 t.Fatalf("/compt should rank /compact first via subsequence match, got %v",
779 labels(m.completion.items))
780 }
781 m.acceptCompletion()
782 if got := m.input.Value(); got != "/compact " {
783 t.Errorf("accept should fill the input with /compact , got %q", got)
784 }
785 }
786
787 // TestSubsequenceMatchUnit covers the matcher directly so future tweaks to the
788 // scoring policy (prefix-first vs. subsequence-only) don't have to re-derive
789 // edge cases from end-to-end tests.
790 func TestSubsequenceMatchUnit(t *testing.T) {
791 cases := []struct {
792 target, query string
793 want bool
794 }{
795 {"", "", true},
796 {"", "a", false},
797 {"/model", "", true},
798 {"/model", "mod", true},
799 {"/model", "mdl", true}, // m-o-d-l in order
800 {"/model", "xz", false},
801 {"/compact", "compt", true},
802 {"/compact", "cmpt", true}, // c then m then p then t
803 {"/branch", "brh", true},
804 {"/branch", "brnch", true},
805 {"/paste-image", "pimg", true}, // p-a-s-t-e-...-i-m-g in order
806 {"/mcp", "mrl", false}, // m-c-p is not a subsequence of m-r-l
807 {"/review", "rle", false}, // r-e-v-i-e-w has no 'l'
808 {"/memory", "memr", true}, // m-e-m-r in order (skip o)
809 }
810 for _, c := range cases {
811 if got := subsequenceMatch(strings.ToLower(c.target), strings.ToLower(c.query)); got != c.want {
812 t.Errorf("subsequenceMatch(%q, %q) = %v, want %v", c.target, c.query, got, c.want)
813 }
814 }
815 }
816
816 lines GO