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}