detached 88f9527

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 UnverifiedB5690EEEBB952194 The 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`,
10041004 |---|---|
10051005 | `enter` | Submit (queued if the agent is busy). |
10061006 | `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). |
10081009 | `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. |
10091010 | `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. |
10101011 | `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 (
1212 var helpKeyRows = [][2]string{
1313 {"enter", "submit the current input"},
1414 {"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"},
1617 {"esc", "cancel the current turn (while busy) - clear idle input; two bare Esc presses within 500ms open /session tree"},
1718 {"ctrl+c", "exit (while idle) - cancel the current turn (while busy)"},
1819 {"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) {
834834 }
835835 }
836836
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()) {
845848 if i.tryPathTabComplete() {
846849 return false
847850 }
851+ i.inputHistoryIndex = -1
852+ i.cycleModelProfile(+1)
853+ return false
848854 }
849855
850856 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 the839+ // plain Tab on anything else moves to the next profile slot, stopping
840- // parent dir, and completes the basename to the longest common840+ // 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) fall842+ 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 false849 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) {
582582 }
583583 i.activateModelProfile(slot, p)
584584 }
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+}
585604 func (i *Interactive) applyQuickModelSetting(key, value string) {
586605 slotText := strings.TrimPrefix(key, "quick_model_")
587606 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
33 import (
44 "context"
55 "errors"
6+ "os"
7+ "path/filepath"
68 "reflect"
79 "strings"
810 "testing"
@@ -349,3 +351,138 @@ func TestModelProfileCommandRejectsInvalidSlots(t *testing.T) {
349351 }
350352 }
351353 }
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+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close