v0.42.0 82578de

model_profiles_test.go RawHistoryFolder

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

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 477

↑↓ moveenter openesc close