v0.50.1 a637010

model_profiles_test.go RawHistoryFolder

1package modes
2
3import (
4	"context"
5	"errors"
6	"os"
7	"path/filepath"
8	"reflect"
9	"strings"
10	"testing"
11
12	"github.com/bnema/zut/packages/core"
13	"github.com/bnema/zut/packages/provider"
14	"github.com/bnema/zut/packages/tui"
15)
16
17type profileSettingsStore struct {
18	SettingsStore
19	profiles map[int]QuickModelShortcut
20	active   int
21	err      error
22}
23
24func (s *profileSettingsStore) SetModelProfile(slot int, p QuickModelShortcut, active int) error {
25	if s.err != nil {
26		return s.err
27	}
28	s.profiles[slot], s.active = p, active
29	return nil
30}
31
32func newProfileInteractive(t *testing.T) (*Interactive, *profileSettingsStore) {
33	t.Helper()
34	s := &profileSettingsStore{profiles: make(map[int]QuickModelShortcut)}
35	i := NewInteractive(InteractiveConfig{Theme: tui.Dark, Provider: "openai", Model: "gpt-5.6-sol", Reasoning: "high", SettingsStore: s,
36		QuickModelShortcuts: []QuickModelShortcut{{Provider: "openai", Model: "gpt-5.6-sol", Reasoning: "high"}},
37	})
38	i.rend = nil
39	i.agent = core.NewAgent(nil, i.cfg.Model, "", nil)
40	i.agent.Reasoning = i.cfg.Reasoning
41	return i, s
42}
43
44func selectProfileModel(t *testing.T, i *Interactive, prov, model string) {
45	t.Helper()
46	m, err := provider.FindModel(prov, model)
47	if err != nil {
48		t.Fatal(err)
49	}
50	i.modelDialog.view = []provider.Model{m}
51	i.modelDialog.cursor = 0
52	i.handleKey(context.Background(), tui.Key{Kind: tui.KeyEnter})
53}
54
55func TestModelProfileRecallAndAutosave(t *testing.T) {
56	i, s := newProfileInteractive(t)
57	i.handleKey(context.Background(), tui.Key{Kind: tui.KeyRune, Rune: '&', Ctrl: true})
58	if i.cfg.ActiveModelProfile != 1 || s.active != 1 || s.profiles[1].Reasoning != "high" {
59		t.Fatalf("capture: cfg=%+v store=%+v", i.cfg.QuickModelShortcuts, s)
60	}
61	if i.modelDialog.Active() || !strings.Contains(i.statusOK, "profile 1 active") {
62		t.Fatal(i.statusOK)
63	}
64	i.applyReasoningSetting("max")
65	if s.profiles[1].Reasoning != "max" {
66		t.Fatal("reasoning not saved to active slot")
67	}
68	i.applyQuickModelShortcut(2)
69	selectProfileModel(t, i, "openai", "gpt-5.5")
70	i.applyReasoningSetting("")
71	i.applyModelSelection("openai", "gpt-5.5")
72	if s.profiles[2].Model != "gpt-5.5" || s.profiles[2].Reasoning != "" {
73		t.Fatal(s.profiles)
74	}
75	i.runSlash(context.Background(), "/profile 1")
76	if i.cfg.Model != "gpt-5.6-sol" || i.agent.Model != i.cfg.Model || i.cfg.Reasoning != "max" || i.agent.Reasoning != "max" || s.active != 1 {
77		t.Fatalf("recall failed: model=%s reasoning=%s active=%d", i.cfg.Model, i.cfg.Reasoning, s.active)
78	}
79	if s.profiles[2].Model != "gpt-5.5" {
80		t.Fatal("recall overwrote previous slot")
81	}
82}
83
84func TestModelProfileRestoresFastModePerSlot(t *testing.T) {
85	i, s := newProfileInteractive(t)
86	enabled := true
87	i.cfg.FastMode = &enabled
88	i.agent.SetFastMode(true)
89	i.cfg.QuickModelShortcuts = []QuickModelShortcut{
90		{Provider: "openai", Model: "gpt-5.6-sol", Reasoning: "high", FastMode: true},
91		{Provider: "openai", Model: "gpt-5.5", FastMode: false},
92	}
93
94	i.applyQuickModelShortcut(1)
95	if !i.agent.FastModeEnabled() {
96		t.Fatal("profile 1 did not restore fast mode")
97	}
98	i.applyQuickModelShortcut(2)
99	if i.agent.FastModeEnabled() || i.cfg.FastMode == nil || *i.cfg.FastMode || s.profiles[2].FastMode {
100		t.Fatalf("profile 2 did not restore fast mode off: agent=%v cfg=%v store=%+v", i.agent.FastModeEnabled(), i.cfg.FastMode, s)
101	}
102	i.applyQuickModelShortcut(1)
103	if !i.agent.FastModeEnabled() || s.profiles[1].FastMode == false {
104		t.Fatalf("profile 1 did not restore fast mode on: agent=%v store=%+v", i.agent.FastModeEnabled(), s)
105	}
106}
107
108func TestModelProfileFastModeToggleAutosavesActiveSlot(t *testing.T) {
109	i, s := newProfileInteractive(t)
110	var changed []bool
111	i.cfg.OnFastModeChanged = func(enabled bool) { changed = append(changed, enabled) }
112	i.applyQuickModelShortcut(1)
113	changed = nil
114	i.applySettingToggle("fast_mode", true)
115	if i.cfg.FastMode == nil || !*i.cfg.FastMode || !i.agent.FastModeEnabled() || !s.profiles[1].FastMode || !reflect.DeepEqual(changed, []bool{true}) {
116		t.Fatalf("fast mode enable was not applied to active profile: cfg=%v agent=%v store=%+v", i.cfg.FastMode, i.agent.FastModeEnabled(), s)
117	}
118	i.applySettingToggle("fast_mode", false)
119	if i.cfg.FastMode == nil || *i.cfg.FastMode || i.agent.FastModeEnabled() || s.profiles[1].FastMode || !reflect.DeepEqual(changed, []bool{true, false}) {
120		t.Fatalf("fast mode disable was not applied to active profile: cfg=%v agent=%v store=%+v", i.cfg.FastMode, i.agent.FastModeEnabled(), s)
121	}
122}
123
124func TestModelProfileEmptyShortcutOpensPickerAndCancelPreservesSelection(t *testing.T) {
125	i, s := newProfileInteractive(t)
126	i.applyQuickModelShortcut(1)
127	i.handleKey(context.Background(), tui.Key{Kind: tui.KeyRune, Rune: 'é', Ctrl: true})
128	if !i.modelDialog.Active() || i.quickModelAssign != 2 || i.cfg.ActiveModelProfile != 1 || len(s.profiles) != 1 {
129		t.Fatal("empty shortcut did not open a non-mutating picker")
130	}
131	i.handleKey(context.Background(), tui.Key{Kind: tui.KeyEsc})
132	if i.quickModelActivate || i.quickModelAssign != 0 || i.cfg.ActiveModelProfile != 1 || len(s.profiles) != 1 {
133		t.Fatal("cancellation changed profile state")
134	}
135	i.runSlash(context.Background(), "/profile 2")
136	selectProfileModel(t, i, "openai", "gpt-5.5")
137	if i.cfg.ActiveModelProfile != 2 || s.active != 2 || s.profiles[2].Model != "gpt-5.5" || i.modelDialog.Active() {
138		t.Fatalf("picker did not save and activate: %+v", s)
139	}
140}
141
142func TestModelProfileDefaultSlotAndActiveSettingsEdits(t *testing.T) {
143	i, s := newProfileInteractive(t)
144	if i.cfg.ActiveModelProfile != 1 {
145		t.Fatal("missing active slot did not default to 1")
146	}
147	i.openQuickModelPicker(1)
148	i.modelDialog.reasoning = "low"
149	selectProfileModel(t, i, "openai", "gpt-5.5")
150	if s.active != 1 || s.profiles[1].Model != "gpt-5.5" || s.profiles[1].Reasoning != "low" || i.agent.Model != "gpt-5.5" || i.agent.Reasoning != "low" {
151		t.Fatalf("active settings edit did not apply and save: %+v", s)
152	}
153	fresh := NewInteractive(InteractiveConfig{Theme: tui.Dark, Provider: "openai", Model: "gpt-5.6-sol", Reasoning: "high", SettingsStore: s})
154	fresh.rend = nil
155	if fresh.cfg.ActiveModelProfile != 1 {
156		t.Fatal("fresh configuration has no default slot")
157	}
158	fresh.applyReasoningSetting("low")
159	if s.active != 1 || s.profiles[1].Model != "gpt-5.6-sol" || s.profiles[1].Reasoning != "low" {
160		t.Fatal("default slot edits not saved")
161	}
162}
163
164func TestModelProfileCrossProviderCarriesTranscriptAndUsage(t *testing.T) {
165	i, s := newProfileInteractive(t)
166	msgs := []provider.Message{{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "preserve me"}}}}
167	i.agent.SetMessages(msgs)
168	i.agent.SeedCost(provider.Usage{InputTokens: 123})
169	i.cfg.QuickModelShortcuts = []QuickModelShortcut{{Provider: "google", Model: "gemini-2.0-flash", Reasoning: "high"}}
170	var changed string
171	i.cfg.OnReasoningChanged = func(level string) { changed = level }
172	i.cfg.BuildAgentFor = func(p, m string) (*core.Agent, string, string, error) {
173		return core.NewAgent(nil, m, "", nil), p, m, nil
174	}
175	transitions := 0
176	i.cfg.SessionTransition = func(fn func()) { transitions++; fn() }
177	i.applyQuickModelShortcut(1)
178	if transitions != 1 || i.cfg.Provider != "google" || i.cfg.Reasoning != "" || changed != "" || s.profiles[1].Reasoning != "" {
179		t.Fatalf("cross-provider state: provider=%s reasoning=%s store=%+v", i.cfg.Provider, i.cfg.Reasoning, s)
180	}
181	if !reflect.DeepEqual(i.agent.Messages(), msgs) || i.agent.Cost().InputTokens != 123 {
182		t.Fatal("lost transcript or usage")
183	}
184}
185
186func TestModelProfileFailureDoesNotSelectOrOverwrite(t *testing.T) {
187	for _, failure := range []string{"busy", "unknown", "build", "save"} {
188		t.Run(failure, func(t *testing.T) {
189			i, s := newProfileInteractive(t)
190			i.applyQuickModelShortcut(1)
191			before := s.profiles[1]
192			i.cfg.QuickModelShortcuts = append(i.cfg.QuickModelShortcuts, QuickModelShortcut{Provider: "google", Model: "gemini-2.0-flash"})
193			switch failure {
194			case "busy":
195				i.busy = true
196			case "unknown":
197				i.cfg.QuickModelShortcuts[1].Model = "does-not-exist"
198			case "build":
199				i.cfg.BuildAgentFor = func(string, string) (*core.Agent, string, string, error) {
200					return nil, "", "", errors.New("build failed")
201				}
202			case "save":
203				i.cfg.QuickModelShortcuts[1] = QuickModelShortcut{Provider: i.cfg.Provider, Model: i.cfg.Model, Reasoning: "low"}
204				s.err = errors.New("disk full")
205			}
206			i.applyQuickModelShortcut(2)
207			if s.active != 1 || s.profiles[1] != before || i.statusErr == "" {
208				t.Fatalf("failed switch changed persistence: %+v / %s", s, i.statusErr)
209			}
210			if failure != "save" && (i.cfg.ActiveModelProfile != 1 || i.cfg.Reasoning != "high") {
211				t.Fatal("failed switch changed live profile")
212			}
213			if failure == "save" && (i.cfg.ActiveModelProfile != 0 || !strings.Contains(i.statusErr, "model switched; profile not saved")) {
214				t.Fatal("save failure misrepresents persistence")
215			}
216		})
217	}
218}
219
220func TestModelProfileSaveFailureReportsRetainedModelPersistence(t *testing.T) {
221	for _, manual := range []bool{false, true} {
222		i, s := newProfileInteractive(t)
223		i.applyQuickModelShortcut(1)
224		before := s.profiles[1]
225		i.cfg.QuickModelShortcuts = append(i.cfg.QuickModelShortcuts, QuickModelShortcut{Provider: "openai", Model: "gpt-5.5", Reasoning: "low"})
226		var persistedModel string
227		i.cfg.PersistModel = func(_, model string) { persistedModel = model }
228		s.err = errors.New("disk full")
229		if manual {
230			i.applyModelSelection("openai", "gpt-5.5")
231		} else {
232			i.applyQuickModelShortcut(2)
233		}
234		if persistedModel != "gpt-5.5" || i.agent.Model != persistedModel {
235			t.Fatal("live model and model/session persistence must agree")
236		}
237		if s.active != 1 || s.profiles[1] != before || i.cfg.ActiveModelProfile != 0 || i.statusOK != "" || !strings.Contains(i.statusErr, "model switched; profile not saved") {
238			t.Fatalf("failed favorite save misreported: %s", i.statusErr)
239		}
240	}
241}
242
243type legacyProfileSettingsStore struct {
244	SettingsStore
245	shortcut  QuickModelShortcut
246	reasoning string
247	fastMode  bool
248}
249
250func (s *legacyProfileSettingsStore) SetQuickModelShortcut(_ int, prov, model string) error {
251	s.shortcut = QuickModelShortcut{Provider: prov, Model: model}
252	return nil
253}
254func (s *legacyProfileSettingsStore) SetReasoning(level string) error {
255	s.reasoning = level
256	return nil
257}
258func (s *legacyProfileSettingsStore) SetFastMode(enabled bool) error {
259	s.fastMode = enabled
260	return nil
261}
262
263func TestModelProfileLegacySettingsStoreCompatibility(t *testing.T) {
264	s := &legacyProfileSettingsStore{}
265	i := NewInteractive(InteractiveConfig{Theme: tui.Dark, Provider: "openai", Model: "gpt-5.6-sol", Reasoning: "high", SettingsStore: s})
266	i.rend = nil
267	i.agent = core.NewAgent(nil, i.cfg.Model, "", nil)
268	if i.cfg.ActiveModelProfile != 0 {
269		t.Fatal("legacy store cannot persist an active profile")
270	}
271	i.applyReasoningSetting("low")
272	i.applySettingToggle("fast_mode", true)
273	i.setQuickModelProfile(2, QuickModelShortcut{Provider: "openai", Model: "gpt-5.5"})
274	i.applyQuickModelShortcut(2)
275	if s.reasoning != "low" || !s.fastMode || !i.agent.FastModeEnabled() || s.shortcut.Model != "gpt-5.5" || i.agent.Model != "gpt-5.5" || i.cfg.ActiveModelProfile != 0 {
276		t.Fatal("legacy reasoning/model-only settings no longer work")
277	}
278}
279
280func TestModelProfileAssignmentAndReasoningSaveFailure(t *testing.T) {
281	i, s := newProfileInteractive(t)
282	i.applyQuickModelShortcut(1)
283	before := i.cfg.QuickModelShortcuts[0]
284	s.err = errors.New("read-only config")
285	i.setQuickModelProfile(1, QuickModelShortcut{})
286	i.applyReasoningSetting("low")
287	if i.cfg.QuickModelShortcuts[0] != before || i.cfg.ActiveModelProfile != 1 || i.agent.Reasoning != "high" || i.cfg.Reasoning != "high" {
288		t.Fatal("save failure changed selection")
289	}
290	s.err = nil
291	i.setQuickModelProfile(1, QuickModelShortcut{})
292	if i.cfg.ActiveModelProfile != 0 || s.active != 0 || s.profiles[1].Model != "" || i.agent.Model != before.Model {
293		t.Fatal("clear should detach without changing live model")
294	}
295}
296
297func TestModelProfilePickerReasoningIsLocalUntilAssigned(t *testing.T) {
298	i, s := newProfileInteractive(t)
299	i.applyQuickModelShortcut(1)
300	i.openQuickModelPicker(2)
301	i.modelDialog.view = []provider.Model{{Provider: "openai", ID: "gpt-5.6-sol", Reasoning: true}}
302	i.modelDialog.cursor = 0
303	i.handleKey(context.Background(), tui.Key{Kind: tui.KeyLeft})
304	if i.cfg.Reasoning != "high" || s.profiles[1].Reasoning != "high" {
305		t.Fatal("picker changed active reasoning")
306	}
307	want := i.modelDialog.reasoning
308	i.handleKey(context.Background(), tui.Key{Kind: tui.KeyEnter})
309	if s.profiles[2].Reasoning != want || i.cfg.ActiveModelProfile != 1 || i.cfg.Reasoning != "high" {
310		t.Fatalf("assignment=%+v reasoning=%s", s, want)
311	}
312}
313
314func TestModelProfileRestoresActiveIndicatorOnlyForMatchingSelection(t *testing.T) {
315	p := QuickModelShortcut{Provider: "openai", Model: "gpt-5.6-sol", Reasoning: "high"}
316	for _, override := range []bool{false, true} {
317		cfg := InteractiveConfig{Theme: tui.Dark, Provider: p.Provider, Model: p.Model, Reasoning: p.Reasoning, QuickModelShortcuts: []QuickModelShortcut{p}, ActiveModelProfile: 1}
318		want := 1
319		if override {
320			cfg.Model = "gpt-5.5"
321			want = 0
322		}
323		i := NewInteractive(cfg)
324		if i.cfg.ActiveModelProfile != want {
325			t.Fatalf("override=%v active=%d", override, i.cfg.ActiveModelProfile)
326		}
327	}
328}
329
330func TestModelProfileShortcutLayouts(t *testing.T) {
331	for _, row := range []string{"123456789", "&é\"'(-è_ç"} {
332		for idx, r := range []rune(row) {
333			if got := quickModelShortcutSlot(tui.Key{Kind: tui.KeyRune, Rune: r, Ctrl: true}); got != idx+1 {
334				t.Fatalf("Ctrl+%c = %d", r, got)
335			}
336			for _, key := range []tui.Key{{Kind: tui.KeyRune, Rune: r}, {Kind: tui.KeyRune, Rune: r, Ctrl: true, Alt: true}, {Kind: tui.KeyPaste, Paste: string(r)}} {
337				if quickModelShortcutSlot(key) != 0 {
338					t.Fatalf("unexpected shortcut: %+v", key)
339				}
340			}
341		}
342	}
343}
344
345func TestModelProfileCommandRejectsInvalidSlots(t *testing.T) {
346	for _, command := range []string{"/profile", "/profile 0", "/profile 10", "/profile x", "/profile 1 extra"} {
347		i, s := newProfileInteractive(t)
348		i.runSlash(context.Background(), command)
349		if !strings.Contains(i.statusErr, "usage:") || s.active != 0 {
350			t.Fatalf("%s: %s", command, i.statusErr)
351		}
352	}
353}
354
355func 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
368func 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
378func 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
390func 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
400func 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
410func 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
429func 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
443func 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
462func 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
471func 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