| 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 |