feat(modes): cycle model profiles with tab and shift-tab (#229)
* feat(modes): cycle model profiles with tab and shift-tab * test(modes): cover tab cycling guards and document tab bindings
- commit
- 88f952707991d01709b4d986dd93b6a348ff2aa7
- author
- bnema · · 2026-09-27 03:41 UTC
- committer
- GitHub · · 2026-09-27 03:41 UTC
- parent
- ba21570
- signature
- GPG Unverified
B5690EEEBB952194The key is unknown
5 files · +174 -10
M README.md +2 -1
| @@ -1004,7 +1004,8 @@ Slash commands also work while the agent is busy. Non-destructive ones (`/help`, | |||
| 1004 | 1004 | |---|---| | |
| 1005 | 1005 | | `enter` | Submit (queued if the agent is busy). | | |
| 1006 | 1006 | | `alt+enter` | Newline. | | |
| 1007 | - | | `tab` | Complete the selected slash command. | | |
| 1007 | + | | `tab` | Complete the selected slash command, file, or path token; otherwise cycle to the next model profile (empty slots open the picker). | | |
| 1008 | + | | `shift+tab` | Cycle to the previous model profile (empty slots open the picker). | | |
| 1008 | 1009 | | `esc` | Cancel the current turn (while busy); clear input (while idle). Two parsed bare `Esc` presses within 500 ms open `/session tree` only when the editor is idle and empty; dialogs, busy/queued work, and modified keys keep their existing precedence. | | |
| 1009 | 1010 | | `ctrl+c` | Clear the input and queue (while idle) or arm the exit hint (while busy). Press again within 2s to exit. Use `esc` to cancel a running turn. | | |
| 1010 | 1011 | | `ctrl+d` | Exit on empty input. | | |
| @@ -1004,7 +1004,8 @@ Slash commands also work while the agent is busy. Non-destructive ones (`/help`, | |||||
| 1004 | |---|---| | 1004 | |---|---| | ||
| 1005 | | `enter` | Submit (queued if the agent is busy). | | 1005 | | `enter` | Submit (queued if the agent is busy). | | ||
| 1006 | | `alt+enter` | Newline. | | 1006 | | `alt+enter` | Newline. | | ||
| 1007 | - | | `tab` | Complete the selected slash command. | | 1007 | + | | `tab` | Complete the selected slash command, file, or path token; otherwise cycle to the next model profile (empty slots open the picker). | |
| 1008 | + | | `shift+tab` | Cycle to the previous model profile (empty slots open the picker). | | |||
| 1008 | | `esc` | Cancel the current turn (while busy); clear input (while idle). Two parsed bare `Esc` presses within 500 ms open `/session tree` only when the editor is idle and empty; dialogs, busy/queued work, and modified keys keep their existing precedence. | | 1009 | | `esc` | Cancel the current turn (while busy); clear input (while idle). Two parsed bare `Esc` presses within 500 ms open `/session tree` only when the editor is idle and empty; dialogs, busy/queued work, and modified keys keep their existing precedence. | | ||
| 1009 | | `ctrl+c` | Clear the input and queue (while idle) or arm the exit hint (while busy). Press again within 2s to exit. Use `esc` to cancel a running turn. | | 1010 | | `ctrl+c` | Clear the input and queue (while idle) or arm the exit hint (while busy). Press again within 2s to exit. Use `esc` to cancel a running turn. | | ||
| 1010 | | `ctrl+d` | Exit on empty input. | | 1011 | | `ctrl+d` | Exit on empty input. | | ||
M packages/agent/modes/help.go +2 -1
| @@ -12,7 +12,8 @@ import ( | |||
| 12 | 12 | var helpKeyRows = [][2]string{ | |
| 13 | 13 | {"enter", "submit the current input"}, | |
| 14 | 14 | {"shift+enter / alt+enter", "insert a newline"}, | |
| 15 | - | {"tab", "complete the highlighted slash command"}, | |
| 15 | + | {"tab", "complete the highlighted slash command; next model profile when idle"}, | |
| 16 | + | {"shift+tab", "previous model profile"}, | |
| 16 | 17 | {"esc", "cancel the current turn (while busy) - clear idle input; two bare Esc presses within 500ms open /session tree"}, | |
| 17 | 18 | {"ctrl+c", "exit (while idle) - cancel the current turn (while busy)"}, | |
| 18 | 19 | {"ctrl+w", "delete previous word"}, | |
| @@ -12,7 +12,8 @@ import ( | |||||
| 12 | var helpKeyRows = [][2]string{ | 12 | var helpKeyRows = [][2]string{ | ||
| 13 | {"enter", "submit the current input"}, | 13 | {"enter", "submit the current input"}, | ||
| 14 | {"shift+enter / alt+enter", "insert a newline"}, | 14 | {"shift+enter / alt+enter", "insert a newline"}, | ||
| 15 | - | {"tab", "complete the highlighted slash command"}, | 15 | + | {"tab", "complete the highlighted slash command; next model profile when idle"}, |
| 16 | + | {"shift+tab", "previous model profile"}, | |||
| 16 | {"esc", "cancel the current turn (while busy) - clear idle input; two bare Esc presses within 500ms open /session tree"}, | 17 | {"esc", "cancel the current turn (while busy) - clear idle input; two bare Esc presses within 500ms open /session tree"}, | ||
| 17 | {"ctrl+c", "exit (while idle) - cancel the current turn (while busy)"}, | 18 | {"ctrl+c", "exit (while idle) - cancel the current turn (while busy)"}, | ||
| 18 | {"ctrl+w", "delete previous word"}, | 19 | {"ctrl+w", "delete previous word"}, | ||
M packages/agent/modes/interactive_input_routing.go +14 -8
| @@ -834,17 +834,23 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||
| 834 | 834 | } | |
| 835 | 835 | } | |
| 836 | 836 | ||
| 837 | - | // Tab-complete a path token in the editor when no popup is open. | |
| 838 | - | // Recognises tokens that look like paths (start with ~, /, ./, ../ | |
| 839 | - | // or contain a slash); shell-style completion expands ~, lists the | |
| 840 | - | // parent dir, and completes the basename to the longest common | |
| 841 | - | // prefix. Single match: full replace and trailing / for dirs. | |
| 842 | - | // No match: no-op. Plain bare words (no slash, no tilde) fall | |
| 843 | - | // through so Tab keeps its current no-op behaviour outside paths. | |
| 844 | - | if k.Kind == tui.KeyTab && !i.suggest.Active(i.ed.Value()) && !i.fileSuggest.Active(i.ed.Value()) { | |
| 837 | + | // Tab cycles model profiles when no popup is open and the token is not | |
| 838 | + | // a path. Path-like tokens keep shell-style completion (paths win); | |
| 839 | + | // plain Tab on anything else moves to the next profile slot, stopping | |
| 840 | + | // on empty slots via the model picker like Ctrl+1..9. Shift-Tab moves | |
| 841 | + | // to the previous slot. Modified chords (ctrl/alt/super) are left alone. | |
| 842 | + | if k.Kind == tui.KeyShiftTab && !k.Ctrl && !k.Alt && !k.Super && !i.suggest.Active(i.ed.Value()) && !i.fileSuggest.Active(i.ed.Value()) { | |
| 843 | + | i.inputHistoryIndex = -1 | |
| 844 | + | i.cycleModelProfile(-1) | |
| 845 | + | return false | |
| 846 | + | } | |
| 847 | + | if k.Kind == tui.KeyTab && !k.Ctrl && !k.Alt && !k.Super && !k.Shift && !i.suggest.Active(i.ed.Value()) && !i.fileSuggest.Active(i.ed.Value()) { | |
| 845 | 848 | if i.tryPathTabComplete() { | |
| 846 | 849 | return false | |
| 847 | 850 | } | |
| 851 | + | i.inputHistoryIndex = -1 | |
| 852 | + | i.cycleModelProfile(+1) | |
| 853 | + | return false | |
| 848 | 854 | } | |
| 849 | 855 | ||
| 850 | 856 | if i.inputHistoryIndex >= 0 && k.Kind != tui.KeyUp && k.Kind != tui.KeyDown { | |
| @@ -834,17 +834,23 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||||
| 834 | } | 834 | } | ||
| 835 | } | 835 | } | ||
| 836 | 836 | ||||
| 837 | - | // Tab-complete a path token in the editor when no popup is open. | 837 | + | // Tab cycles model profiles when no popup is open and the token is not |
| 838 | - | // Recognises tokens that look like paths (start with ~, /, ./, ../ | 838 | + | // a path. Path-like tokens keep shell-style completion (paths win); |
| 839 | - | // or contain a slash); shell-style completion expands ~, lists the | 839 | + | // plain Tab on anything else moves to the next profile slot, stopping |
| 840 | - | // parent dir, and completes the basename to the longest common | 840 | + | // on empty slots via the model picker like Ctrl+1..9. Shift-Tab moves |
| 841 | - | // prefix. Single match: full replace and trailing / for dirs. | 841 | + | // to the previous slot. Modified chords (ctrl/alt/super) are left alone. |
| 842 | - | // No match: no-op. Plain bare words (no slash, no tilde) fall | 842 | + | if k.Kind == tui.KeyShiftTab && !k.Ctrl && !k.Alt && !k.Super && !i.suggest.Active(i.ed.Value()) && !i.fileSuggest.Active(i.ed.Value()) { |
| 843 | - | // through so Tab keeps its current no-op behaviour outside paths. | 843 | + | i.inputHistoryIndex = -1 |
| 844 | - | if k.Kind == tui.KeyTab && !i.suggest.Active(i.ed.Value()) && !i.fileSuggest.Active(i.ed.Value()) { | 844 | + | i.cycleModelProfile(-1) |
| 845 | + | return false | |||
| 846 | + | } | |||
| 847 | + | if k.Kind == tui.KeyTab && !k.Ctrl && !k.Alt && !k.Super && !k.Shift && !i.suggest.Active(i.ed.Value()) && !i.fileSuggest.Active(i.ed.Value()) { | |||
| 845 | if i.tryPathTabComplete() { | 848 | if i.tryPathTabComplete() { | ||
| 846 | return false | 849 | return false | ||
| 847 | } | 850 | } | ||
| 851 | + | i.inputHistoryIndex = -1 | |||
| 852 | + | i.cycleModelProfile(+1) | |||
| 853 | + | return false | |||
| 848 | } | 854 | } | ||
| 849 | 855 | ||||
| 850 | if i.inputHistoryIndex >= 0 && k.Kind != tui.KeyUp && k.Kind != tui.KeyDown { | 856 | if i.inputHistoryIndex >= 0 && k.Kind != tui.KeyUp && k.Kind != tui.KeyDown { | ||
M packages/agent/modes/interactive_settings.go +19 -0
| @@ -582,6 +582,25 @@ func (i *Interactive) applyQuickModelShortcut(slot int) { | |||
| 582 | 582 | } | |
| 583 | 583 | i.activateModelProfile(slot, p) | |
| 584 | 584 | } | |
| 585 | + | ||
| 586 | + | // cycleModelProfile moves to the next (+1) or previous (-1) profile slot | |
| 587 | + | // with wraparound across slots 1-9. It delegates to applyQuickModelShortcut | |
| 588 | + | // so empty slots open the model picker and busy turns show the same guard | |
| 589 | + | // as Ctrl+1..9. With no active profile, Tab starts at slot 1 and Shift-Tab | |
| 590 | + | // at slot 9. | |
| 591 | + | func (i *Interactive) cycleModelProfile(dir int) { | |
| 592 | + | start := i.cfg.ActiveModelProfile | |
| 593 | + | var next int | |
| 594 | + | switch { | |
| 595 | + | case start >= 1 && start <= 9: | |
| 596 | + | next = ((start-1+dir)%9+9)%9 + 1 | |
| 597 | + | case dir < 0: | |
| 598 | + | next = 9 | |
| 599 | + | default: | |
| 600 | + | next = 1 | |
| 601 | + | } | |
| 602 | + | i.applyQuickModelShortcut(next) | |
| 603 | + | } | |
| 585 | 604 | func (i *Interactive) applyQuickModelSetting(key, value string) { | |
| 586 | 605 | slotText := strings.TrimPrefix(key, "quick_model_") | |
| 587 | 606 | slot, err := strconv.Atoi(slotText) | |
| @@ -582,6 +582,25 @@ func (i *Interactive) applyQuickModelShortcut(slot int) { | |||||
| 582 | } | 582 | } | ||
| 583 | i.activateModelProfile(slot, p) | 583 | i.activateModelProfile(slot, p) | ||
| 584 | } | 584 | } | ||
| 585 | + | ||||
| 586 | + | // cycleModelProfile moves to the next (+1) or previous (-1) profile slot | |||
| 587 | + | // with wraparound across slots 1-9. It delegates to applyQuickModelShortcut | |||
| 588 | + | // so empty slots open the model picker and busy turns show the same guard | |||
| 589 | + | // as Ctrl+1..9. With no active profile, Tab starts at slot 1 and Shift-Tab | |||
| 590 | + | // at slot 9. | |||
| 591 | + | func (i *Interactive) cycleModelProfile(dir int) { | |||
| 592 | + | start := i.cfg.ActiveModelProfile | |||
| 593 | + | var next int | |||
| 594 | + | switch { | |||
| 595 | + | case start >= 1 && start <= 9: | |||
| 596 | + | next = ((start-1+dir)%9+9)%9 + 1 | |||
| 597 | + | case dir < 0: | |||
| 598 | + | next = 9 | |||
| 599 | + | default: | |||
| 600 | + | next = 1 | |||
| 601 | + | } | |||
| 602 | + | i.applyQuickModelShortcut(next) | |||
| 603 | + | } | |||
| 585 | func (i *Interactive) applyQuickModelSetting(key, value string) { | 604 | func (i *Interactive) applyQuickModelSetting(key, value string) { | ||
| 586 | slotText := strings.TrimPrefix(key, "quick_model_") | 605 | slotText := strings.TrimPrefix(key, "quick_model_") | ||
| 587 | slot, err := strconv.Atoi(slotText) | 606 | slot, err := strconv.Atoi(slotText) | ||
M packages/agent/modes/model_profiles_test.go +137 -0
| @@ -3,6 +3,8 @@ package modes | |||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | 5 | "errors" | |
| 6 | + | "os" | |
| 7 | + | "path/filepath" | |
| 6 | 8 | "reflect" | |
| 7 | 9 | "strings" | |
| 8 | 10 | "testing" | |
| @@ -349,3 +351,138 @@ func TestModelProfileCommandRejectsInvalidSlots(t *testing.T) { | |||
| 349 | 351 | } | |
| 350 | 352 | } | |
| 351 | 353 | } | |
| 354 | + | ||
| 355 | + | func TestTabCyclesModelProfilesForward(t *testing.T) { | |
| 356 | + | i, s := newProfileInteractive(t) | |
| 357 | + | i.cfg.QuickModelShortcuts = append(i.cfg.QuickModelShortcuts, QuickModelShortcut{Provider: "openai", Model: "gpt-5.5"}) | |
| 358 | + | i.ed.SetValue("hello") | |
| 359 | + | i.handleKey(context.Background(), tui.Key{Kind: tui.KeyTab}) | |
| 360 | + | if i.cfg.ActiveModelProfile != 2 || s.active != 2 || i.cfg.Model != "gpt-5.5" { | |
| 361 | + | t.Fatalf("tab did not cycle to slot 2: active=%d model=%q", i.cfg.ActiveModelProfile, i.cfg.Model) | |
| 362 | + | } | |
| 363 | + | if i.ed.Value() != "hello" { | |
| 364 | + | t.Fatalf("tab modified editor text: %q", i.ed.Value()) | |
| 365 | + | } | |
| 366 | + | } | |
| 367 | + | ||
| 368 | + | func TestTabCyclesModelProfilesWithWraparound(t *testing.T) { | |
| 369 | + | i, s := newProfileInteractive(t) | |
| 370 | + | i.cfg.QuickModelShortcuts = append(i.cfg.QuickModelShortcuts, QuickModelShortcut{Provider: "openai", Model: "gpt-5.5"}) | |
| 371 | + | i.cfg.ActiveModelProfile = 9 | |
| 372 | + | i.handleKey(context.Background(), tui.Key{Kind: tui.KeyTab}) | |
| 373 | + | if i.cfg.ActiveModelProfile != 1 || s.active != 1 { | |
| 374 | + | t.Fatalf("tab did not wrap to slot 1: active=%d", i.cfg.ActiveModelProfile) | |
| 375 | + | } | |
| 376 | + | } | |
| 377 | + | ||
| 378 | + | func TestTabStopsOnEmptySlot(t *testing.T) { | |
| 379 | + | i, _ := newProfileInteractive(t) | |
| 380 | + | i.cfg.QuickModelShortcuts = append(i.cfg.QuickModelShortcuts, QuickModelShortcut{Provider: "openai", Model: "gpt-5.5"}) | |
| 381 | + | i.cfg.ActiveModelProfile = 2 | |
| 382 | + | i.handleKey(context.Background(), tui.Key{Kind: tui.KeyTab}) | |
| 383 | + | // Empty slot 3 opens the picker like Ctrl+3; the active slot is | |
| 384 | + | // unchanged until the user picks a model. | |
| 385 | + | if !i.modelDialog.Active() || i.quickModelAssign != 3 || i.cfg.ActiveModelProfile != 2 { | |
| 386 | + | t.Fatalf("tab did not stop on empty slot 3: active=%d picker=%v assign=%d", i.cfg.ActiveModelProfile, i.modelDialog.Active(), i.quickModelAssign) | |
| 387 | + | } | |
| 388 | + | } | |
| 389 | + | ||
| 390 | + | func TestShiftTabCyclesModelProfilesBackward(t *testing.T) { | |
| 391 | + | i, _ := newProfileInteractive(t) | |
| 392 | + | i.handleKey(context.Background(), tui.Key{Kind: tui.KeyShiftTab}) | |
| 393 | + | // Empty slot 9 opens the picker like Ctrl+9; the active slot is | |
| 394 | + | // unchanged until the user picks a model. | |
| 395 | + | if !i.modelDialog.Active() || i.quickModelAssign != 9 || i.cfg.ActiveModelProfile != 1 { | |
| 396 | + | t.Fatalf("shift-tab did not stop on empty slot 9: active=%d picker=%v assign=%d", i.cfg.ActiveModelProfile, i.modelDialog.Active(), i.quickModelAssign) | |
| 397 | + | } | |
| 398 | + | } | |
| 399 | + | ||
| 400 | + | func TestShiftTabFromActiveProfileGoesBackward(t *testing.T) { | |
| 401 | + | i, s := newProfileInteractive(t) | |
| 402 | + | i.cfg.QuickModelShortcuts = append(i.cfg.QuickModelShortcuts, QuickModelShortcut{Provider: "openai", Model: "gpt-5.5"}) | |
| 403 | + | i.cfg.ActiveModelProfile = 2 | |
| 404 | + | i.handleKey(context.Background(), tui.Key{Kind: tui.KeyShiftTab}) | |
| 405 | + | if i.cfg.ActiveModelProfile != 1 || s.active != 1 { | |
| 406 | + | t.Fatalf("shift-tab did not move to slot 1: active=%d", i.cfg.ActiveModelProfile) | |
| 407 | + | } | |
| 408 | + | } | |
| 409 | + | ||
| 410 | + | func TestTabOnPathKeepsPathCompletion(t *testing.T) { | |
| 411 | + | i, _ := newProfileInteractive(t) | |
| 412 | + | dir := t.TempDir() | |
| 413 | + | if err := os.WriteFile(filepath.Join(dir, "alpha.txt"), []byte("x"), 0o600); err != nil { | |
| 414 | + | t.Fatal(err) | |
| 415 | + | } | |
| 416 | + | i.cfg.CWD = dir | |
| 417 | + | i.cfg.ActiveModelProfile = 0 | |
| 418 | + | i.ed.SetValue("./al") | |
| 419 | + | i.handleKey(context.Background(), tui.Key{Kind: tui.KeyTab}) | |
| 420 | + | // The path token must complete to the single match; profiles stay put. | |
| 421 | + | if i.ed.Value() != "./alpha.txt" { | |
| 422 | + | t.Fatalf("tab did not complete path: %q", i.ed.Value()) | |
| 423 | + | } | |
| 424 | + | if i.modelDialog.Active() || i.cfg.ActiveModelProfile != 0 { | |
| 425 | + | t.Fatalf("tab on path cycled profiles: active=%d picker=%v", i.cfg.ActiveModelProfile, i.modelDialog.Active()) | |
| 426 | + | } | |
| 427 | + | } | |
| 428 | + | ||
| 429 | + | func TestTabWithSlashPopupCompletesCommand(t *testing.T) { | |
| 430 | + | i, _ := newProfileInteractive(t) | |
| 431 | + | i.ed.SetValue("/mod") | |
| 432 | + | i.handleKey(context.Background(), tui.Key{Kind: tui.KeyTab}) | |
| 433 | + | // The slash popup owns Tab while visible: it completes the command | |
| 434 | + | // instead of cycling profiles. | |
| 435 | + | if i.ed.Value() != "/model" { | |
| 436 | + | t.Fatalf("tab did not complete slash command: %q", i.ed.Value()) | |
| 437 | + | } | |
| 438 | + | if i.modelDialog.Active() || i.cfg.ActiveModelProfile != 1 { | |
| 439 | + | t.Fatalf("tab with slash popup cycled profiles: active=%d picker=%v", i.cfg.ActiveModelProfile, i.modelDialog.Active()) | |
| 440 | + | } | |
| 441 | + | } | |
| 442 | + | ||
| 443 | + | func TestTabWithFilePopupKeepsPopup(t *testing.T) { | |
| 444 | + | i, _ := newProfileInteractive(t) | |
| 445 | + | dir := t.TempDir() | |
| 446 | + | if err := os.WriteFile(filepath.Join(dir, "notes.md"), []byte("x"), 0o600); err != nil { | |
| 447 | + | t.Fatal(err) | |
| 448 | + | } | |
| 449 | + | i.cfg.CWD = dir | |
| 450 | + | i.fileSuggest.SetCWD(dir) | |
| 451 | + | i.ed.SetValue("@") | |
| 452 | + | if !i.fileSuggest.Active(i.ed.Value()) { | |
| 453 | + | t.Fatal("file popup did not open for @") | |
| 454 | + | } | |
| 455 | + | i.handleKey(context.Background(), tui.Key{Kind: tui.KeyTab}) | |
| 456 | + | // The file popup owns Tab while visible: no profile cycling. | |
| 457 | + | if i.modelDialog.Active() || i.cfg.ActiveModelProfile != 1 { | |
| 458 | + | t.Fatalf("tab with file popup cycled profiles: active=%d picker=%v", i.cfg.ActiveModelProfile, i.modelDialog.Active()) | |
| 459 | + | } | |
| 460 | + | } | |
| 461 | + | ||
| 462 | + | func TestTabWhileBusyShowsGuard(t *testing.T) { | |
| 463 | + | i, _ := newProfileInteractive(t) | |
| 464 | + | i.busy = true | |
| 465 | + | i.handleKey(context.Background(), tui.Key{Kind: tui.KeyTab}) | |
| 466 | + | if !strings.Contains(i.statusErr, "cannot switch model while a turn is running") { | |
| 467 | + | t.Fatalf("tab while busy did not show guard: %q", i.statusErr) | |
| 468 | + | } | |
| 469 | + | } | |
| 470 | + | ||
| 471 | + | func TestModifiedTabDoesNotCycle(t *testing.T) { | |
| 472 | + | i, _ := newProfileInteractive(t) | |
| 473 | + | for _, k := range []tui.Key{ | |
| 474 | + | {Kind: tui.KeyTab, Ctrl: true}, | |
| 475 | + | {Kind: tui.KeyTab, Alt: true}, | |
| 476 | + | {Kind: tui.KeyTab, Super: true}, | |
| 477 | + | {Kind: tui.KeyTab, Shift: true}, | |
| 478 | + | {Kind: tui.KeyShiftTab, Ctrl: true}, | |
| 479 | + | {Kind: tui.KeyShiftTab, Alt: true}, | |
| 480 | + | {Kind: tui.KeyShiftTab, Super: true}, | |
| 481 | + | } { | |
| 482 | + | i.ed.Clear() | |
| 483 | + | i.handleKey(context.Background(), k) | |
| 484 | + | if i.modelDialog.Active() || i.quickModelAssign != 0 { | |
| 485 | + | t.Fatalf("modified tab cycled profiles: key=%+v assign=%d", k, i.quickModelAssign) | |
| 486 | + | } | |
| 487 | + | } | |
| 488 | + | } | |
| @@ -3,6 +3,8 @@ package modes | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | "errors" | 5 | "errors" | ||
| 6 | + | "os" | |||
| 7 | + | "path/filepath" | |||
| 6 | "reflect" | 8 | "reflect" | ||
| 7 | "strings" | 9 | "strings" | ||
| 8 | "testing" | 10 | "testing" | ||
| @@ -349,3 +351,138 @@ func TestModelProfileCommandRejectsInvalidSlots(t *testing.T) { | |||||
| 349 | } | 351 | } | ||
| 350 | } | 352 | } | ||
| 351 | } | 353 | } | ||
| 354 | + | ||||
| 355 | + | func TestTabCyclesModelProfilesForward(t *testing.T) { | |||
| 356 | + | i, s := newProfileInteractive(t) | |||
| 357 | + | i.cfg.QuickModelShortcuts = append(i.cfg.QuickModelShortcuts, QuickModelShortcut{Provider: "openai", Model: "gpt-5.5"}) | |||
| 358 | + | i.ed.SetValue("hello") | |||
| 359 | + | i.handleKey(context.Background(), tui.Key{Kind: tui.KeyTab}) | |||
| 360 | + | if i.cfg.ActiveModelProfile != 2 || s.active != 2 || i.cfg.Model != "gpt-5.5" { | |||
| 361 | + | t.Fatalf("tab did not cycle to slot 2: active=%d model=%q", i.cfg.ActiveModelProfile, i.cfg.Model) | |||
| 362 | + | } | |||
| 363 | + | if i.ed.Value() != "hello" { | |||
| 364 | + | t.Fatalf("tab modified editor text: %q", i.ed.Value()) | |||
| 365 | + | } | |||
| 366 | + | } | |||
| 367 | + | ||||
| 368 | + | func TestTabCyclesModelProfilesWithWraparound(t *testing.T) { | |||
| 369 | + | i, s := newProfileInteractive(t) | |||
| 370 | + | i.cfg.QuickModelShortcuts = append(i.cfg.QuickModelShortcuts, QuickModelShortcut{Provider: "openai", Model: "gpt-5.5"}) | |||
| 371 | + | i.cfg.ActiveModelProfile = 9 | |||
| 372 | + | i.handleKey(context.Background(), tui.Key{Kind: tui.KeyTab}) | |||
| 373 | + | if i.cfg.ActiveModelProfile != 1 || s.active != 1 { | |||
| 374 | + | t.Fatalf("tab did not wrap to slot 1: active=%d", i.cfg.ActiveModelProfile) | |||
| 375 | + | } | |||
| 376 | + | } | |||
| 377 | + | ||||
| 378 | + | func TestTabStopsOnEmptySlot(t *testing.T) { | |||
| 379 | + | i, _ := newProfileInteractive(t) | |||
| 380 | + | i.cfg.QuickModelShortcuts = append(i.cfg.QuickModelShortcuts, QuickModelShortcut{Provider: "openai", Model: "gpt-5.5"}) | |||
| 381 | + | i.cfg.ActiveModelProfile = 2 | |||
| 382 | + | i.handleKey(context.Background(), tui.Key{Kind: tui.KeyTab}) | |||
| 383 | + | // Empty slot 3 opens the picker like Ctrl+3; the active slot is | |||
| 384 | + | // unchanged until the user picks a model. | |||
| 385 | + | if !i.modelDialog.Active() || i.quickModelAssign != 3 || i.cfg.ActiveModelProfile != 2 { | |||
| 386 | + | t.Fatalf("tab did not stop on empty slot 3: active=%d picker=%v assign=%d", i.cfg.ActiveModelProfile, i.modelDialog.Active(), i.quickModelAssign) | |||
| 387 | + | } | |||
| 388 | + | } | |||
| 389 | + | ||||
| 390 | + | func TestShiftTabCyclesModelProfilesBackward(t *testing.T) { | |||
| 391 | + | i, _ := newProfileInteractive(t) | |||
| 392 | + | i.handleKey(context.Background(), tui.Key{Kind: tui.KeyShiftTab}) | |||
| 393 | + | // Empty slot 9 opens the picker like Ctrl+9; the active slot is | |||
| 394 | + | // unchanged until the user picks a model. | |||
| 395 | + | if !i.modelDialog.Active() || i.quickModelAssign != 9 || i.cfg.ActiveModelProfile != 1 { | |||
| 396 | + | t.Fatalf("shift-tab did not stop on empty slot 9: active=%d picker=%v assign=%d", i.cfg.ActiveModelProfile, i.modelDialog.Active(), i.quickModelAssign) | |||
| 397 | + | } | |||
| 398 | + | } | |||
| 399 | + | ||||
| 400 | + | func TestShiftTabFromActiveProfileGoesBackward(t *testing.T) { | |||
| 401 | + | i, s := newProfileInteractive(t) | |||
| 402 | + | i.cfg.QuickModelShortcuts = append(i.cfg.QuickModelShortcuts, QuickModelShortcut{Provider: "openai", Model: "gpt-5.5"}) | |||
| 403 | + | i.cfg.ActiveModelProfile = 2 | |||
| 404 | + | i.handleKey(context.Background(), tui.Key{Kind: tui.KeyShiftTab}) | |||
| 405 | + | if i.cfg.ActiveModelProfile != 1 || s.active != 1 { | |||
| 406 | + | t.Fatalf("shift-tab did not move to slot 1: active=%d", i.cfg.ActiveModelProfile) | |||
| 407 | + | } | |||
| 408 | + | } | |||
| 409 | + | ||||
| 410 | + | func TestTabOnPathKeepsPathCompletion(t *testing.T) { | |||
| 411 | + | i, _ := newProfileInteractive(t) | |||
| 412 | + | dir := t.TempDir() | |||
| 413 | + | if err := os.WriteFile(filepath.Join(dir, "alpha.txt"), []byte("x"), 0o600); err != nil { | |||
| 414 | + | t.Fatal(err) | |||
| 415 | + | } | |||
| 416 | + | i.cfg.CWD = dir | |||
| 417 | + | i.cfg.ActiveModelProfile = 0 | |||
| 418 | + | i.ed.SetValue("./al") | |||
| 419 | + | i.handleKey(context.Background(), tui.Key{Kind: tui.KeyTab}) | |||
| 420 | + | // The path token must complete to the single match; profiles stay put. | |||
| 421 | + | if i.ed.Value() != "./alpha.txt" { | |||
| 422 | + | t.Fatalf("tab did not complete path: %q", i.ed.Value()) | |||
| 423 | + | } | |||
| 424 | + | if i.modelDialog.Active() || i.cfg.ActiveModelProfile != 0 { | |||
| 425 | + | t.Fatalf("tab on path cycled profiles: active=%d picker=%v", i.cfg.ActiveModelProfile, i.modelDialog.Active()) | |||
| 426 | + | } | |||
| 427 | + | } | |||
| 428 | + | ||||
| 429 | + | func TestTabWithSlashPopupCompletesCommand(t *testing.T) { | |||
| 430 | + | i, _ := newProfileInteractive(t) | |||
| 431 | + | i.ed.SetValue("/mod") | |||
| 432 | + | i.handleKey(context.Background(), tui.Key{Kind: tui.KeyTab}) | |||
| 433 | + | // The slash popup owns Tab while visible: it completes the command | |||
| 434 | + | // instead of cycling profiles. | |||
| 435 | + | if i.ed.Value() != "/model" { | |||
| 436 | + | t.Fatalf("tab did not complete slash command: %q", i.ed.Value()) | |||
| 437 | + | } | |||
| 438 | + | if i.modelDialog.Active() || i.cfg.ActiveModelProfile != 1 { | |||
| 439 | + | t.Fatalf("tab with slash popup cycled profiles: active=%d picker=%v", i.cfg.ActiveModelProfile, i.modelDialog.Active()) | |||
| 440 | + | } | |||
| 441 | + | } | |||
| 442 | + | ||||
| 443 | + | func TestTabWithFilePopupKeepsPopup(t *testing.T) { | |||
| 444 | + | i, _ := newProfileInteractive(t) | |||
| 445 | + | dir := t.TempDir() | |||
| 446 | + | if err := os.WriteFile(filepath.Join(dir, "notes.md"), []byte("x"), 0o600); err != nil { | |||
| 447 | + | t.Fatal(err) | |||
| 448 | + | } | |||
| 449 | + | i.cfg.CWD = dir | |||
| 450 | + | i.fileSuggest.SetCWD(dir) | |||
| 451 | + | i.ed.SetValue("@") | |||
| 452 | + | if !i.fileSuggest.Active(i.ed.Value()) { | |||
| 453 | + | t.Fatal("file popup did not open for @") | |||
| 454 | + | } | |||
| 455 | + | i.handleKey(context.Background(), tui.Key{Kind: tui.KeyTab}) | |||
| 456 | + | // The file popup owns Tab while visible: no profile cycling. | |||
| 457 | + | if i.modelDialog.Active() || i.cfg.ActiveModelProfile != 1 { | |||
| 458 | + | t.Fatalf("tab with file popup cycled profiles: active=%d picker=%v", i.cfg.ActiveModelProfile, i.modelDialog.Active()) | |||
| 459 | + | } | |||
| 460 | + | } | |||
| 461 | + | ||||
| 462 | + | func TestTabWhileBusyShowsGuard(t *testing.T) { | |||
| 463 | + | i, _ := newProfileInteractive(t) | |||
| 464 | + | i.busy = true | |||
| 465 | + | i.handleKey(context.Background(), tui.Key{Kind: tui.KeyTab}) | |||
| 466 | + | if !strings.Contains(i.statusErr, "cannot switch model while a turn is running") { | |||
| 467 | + | t.Fatalf("tab while busy did not show guard: %q", i.statusErr) | |||
| 468 | + | } | |||
| 469 | + | } | |||
| 470 | + | ||||
| 471 | + | func TestModifiedTabDoesNotCycle(t *testing.T) { | |||
| 472 | + | i, _ := newProfileInteractive(t) | |||
| 473 | + | for _, k := range []tui.Key{ | |||
| 474 | + | {Kind: tui.KeyTab, Ctrl: true}, | |||
| 475 | + | {Kind: tui.KeyTab, Alt: true}, | |||
| 476 | + | {Kind: tui.KeyTab, Super: true}, | |||
| 477 | + | {Kind: tui.KeyTab, Shift: true}, | |||
| 478 | + | {Kind: tui.KeyShiftTab, Ctrl: true}, | |||
| 479 | + | {Kind: tui.KeyShiftTab, Alt: true}, | |||
| 480 | + | {Kind: tui.KeyShiftTab, Super: true}, | |||
| 481 | + | } { | |||
| 482 | + | i.ed.Clear() | |||
| 483 | + | i.handleKey(context.Background(), k) | |||
| 484 | + | if i.modelDialog.Active() || i.quickModelAssign != 0 { | |||
| 485 | + | t.Fatalf("modified tab cycled profiles: key=%+v assign=%d", k, i.quickModelAssign) | |||
| 486 | + | } | |||
| 487 | + | } | |||
| 488 | + | } | |||