// The picker's keyboard state machine (search versus browsing, per-row effort // picks, cap toggles) needs catalog shapes the e2e fake provider cannot serve, // so these tests check what enter sends for each state. package tui import ( "strings" "testing" "albedo/cli/internal/config" "albedo/cli/internal/daemon" tea "charm.land/bubbletea/v2" "github.com/charmbracelet/x/ansi" ) var pickerProfiles = config.Profiles{ Active: "work", Providers: map[string]config.Settings{ "work": {Extension: "openai", BaseURL: "https://work.example/v1", Model: "gpt-5", Protocol: "responses"}, "codex": {Extension: "codex", Model: "gpt-5-codex", Protocol: "responses"}, }, } var reasoning = []string{"low", "medium", "high"} func loadedPicker(t *testing.T) ModelPickerModel { t.Helper() m := NewModelPickerModel(nil, pickerProfiles, "gpt-5", "work", "high") m.SetSize(120, 30) m, _ = m.Update(modelCatalogLoadedMsg{Generation: m.Generation, Profile: "work", Models: []daemon.Model{ {ID: "gpt-4.1", Context: 1_047_576}, {ID: "gpt-5", Efforts: reasoning, Context: 400_000, Output: 128_000, Input: []string{"text", "image"}}, {ID: "o4-mini", Efforts: []string{"low", "medium"}}, }}) m, _ = m.Update(modelCatalogLoadedMsg{Generation: m.Generation, Profile: "codex", Models: []daemon.Model{ {ID: "gpt-5-codex", Efforts: []string{"minimal", "low", "medium", "high"}}, }}) return m } func pickerKey(m ModelPickerModel, keys ...tea.KeyPressMsg) ModelPickerModel { for _, k := range keys { m, _ = m.Update(k) } return m } func typed(s string) tea.KeyPressMsg { return tea.KeyPressMsg{Code: tea.KeyExtended, Text: s} } func chosen(t *testing.T, m ModelPickerModel) ModelPickerSelectMsg { t.Helper() _, cmd := m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) if cmd == nil { t.Fatal("enter chose nothing") } msg, ok := cmd().(ModelPickerSelectMsg) if !ok { t.Fatalf("enter sent %T", cmd()) } return msg } func rowIDs(m ModelPickerModel) []string { ids := make([]string, len(m.rows)) for i, r := range m.rows { ids[i] = r.profile + "/" + r.model.ID if r.typed { ids[i] = "typed:" + ids[i] } } return ids } func TestModelPickerArrowsStepEffortAndEnterSendsIt(t *testing.T) { m := loadedPicker(t) if got := chosen(t, m); got.Effort != "high" || got.Model != "gpt-5" || got.Provider != "work" { t.Fatalf("unchanged choice %+v, want the session's high effort", got) } m = pickerKey(m, tea.KeyPressMsg{Code: tea.KeyLeft}, tea.KeyPressMsg{Code: tea.KeyLeft}, tea.KeyPressMsg{Code: tea.KeyLeft}) if got := chosen(t, m); got.Effort != "low" { t.Fatalf("effort %q after stepping down past the end, want low", got.Effort) } m = pickerKey(m, tea.KeyPressMsg{Code: tea.KeyRight}) if got := chosen(t, m); got.Effort != "medium" { t.Fatalf("effort %q, want medium", got.Effort) } // Another row lacking the session's level shows the daemon's default, // and the first row keeps its pick. m = pickerKey(m, tea.KeyPressMsg{Code: tea.KeyDown}, tea.KeyPressMsg{Code: tea.KeyDown}) if got := chosen(t, m); got.Model != "o4-mini" || got.Effort != "medium" { t.Fatalf("choice %+v, want o4-mini at medium", got) } m = pickerKey(m, tea.KeyPressMsg{Code: tea.KeyUp}, tea.KeyPressMsg{Code: tea.KeyUp}) if got := chosen(t, m); got.Effort != "medium" { t.Fatalf("first row forgot its pick: %+v", got) } // A model without levels sends none. m = pickerKey(m, tea.KeyPressMsg{Code: tea.KeyDown}, tea.KeyPressMsg{Code: tea.KeyRight}) if got := chosen(t, m); got.Model != "gpt-4.1" || got.Effort != "" { t.Fatalf("choice %+v, want gpt-4.1 without effort", got) } } func TestModelPickerArrowsEditASearchUntilYouBrowse(t *testing.T) { m := loadedPicker(t) m = pickerKey(m, typed("codex")) if got := strings.Join(rowIDs(m), " "); got != "codex/gpt-5-codex typed:work/codex typed:codex/codex" { t.Fatalf("search rows %q", got) } m = pickerKey(m, tea.KeyPressMsg{Code: tea.KeyLeft}, typed("x")) if m.search.Value() != "codexx" { t.Fatalf("left arrow did not reach the search: %q", m.search.Value()) } m = pickerKey(m, tea.KeyPressMsg{Code: tea.KeyBackspace}, tea.KeyPressMsg{Code: tea.KeyUp}, tea.KeyPressMsg{Code: tea.KeyLeft}) if got := chosen(t, m); got.Model != "gpt-5-codex" || got.Effort != "medium" { t.Fatalf("after browsing, left should lower high to medium: %+v", got) } } func TestModelPickerOffersATypedIDToEveryProfileMissingIt(t *testing.T) { m := pickerKey(loadedPicker(t), typed("gpt-5-codex")) got := strings.Join(rowIDs(m), " ") if got != "codex/gpt-5-codex typed:work/gpt-5-codex" { t.Fatalf("rows %q", got) } m = pickerKey(m, tea.KeyPressMsg{Code: tea.KeyDown}) if got := chosen(t, m); got.Model != "gpt-5-codex" || got.Provider != "work" || got.Effort != "" { t.Fatalf("typed choice %+v", got) } } func TestModelPickerViewFitsEveryWidth(t *testing.T) { for _, size := range [][2]int{{30, 10}, {60, 20}, {80, 24}, {100, 30}, {160, 40}} { m := loadedPicker(t) m.SetSize(size[0], size[1]) view := m.View() lines := strings.Split(view, "\n") if len(lines) > size[1] { t.Fatalf("%v: %d lines", size, len(lines)) } for _, line := range lines { if w := ansi.StringWidth(line); w > size[0] { t.Fatalf("%v: line is %d cells wide: %q", size, w, ansi.Strip(line)) } } } } func TestModelPickerTabRaisesAModelsContextCap(t *testing.T) { m := NewModelPickerModel(nil, pickerProfiles, "gpt-5-codex", "codex", "") m.SetSize(140, 30) m, _ = m.Update(modelCatalogLoadedMsg{Generation: m.Generation, Profile: "codex", Models: []daemon.Model{ {ID: "gpt-6-astra", Context: 272_000, MaxContext: 872_000, Efforts: reasoning}, {ID: "gpt-6-sol", CapKey: "codex/gpt-6-sol", Context: 272_000, MaxContext: 872_000, Raised: true}, {ID: "gpt-5.5", Context: 272_000}, }}) m = pickerKey(m, typed("astra")) if view := ansi.Strip(m.View()); !strings.Contains(view, "tab raises to 872k") || !strings.Contains(view, "272k tokens") { t.Fatalf("astra should offer to raise its cap:\n%s", view) } m = pickerKey(m, tea.KeyPressMsg{Code: tea.KeyTab}) if view := ansi.Strip(m.View()); !strings.Contains(view, "872k tokens") || !strings.Contains(view, "tab restores 272k") { t.Fatalf("a raised cap should show the larger window:\n%s", view) } if got := chosen(t, m).RaiseCap; got == nil || !*got { t.Fatalf("enter should raise astra's cap, got %v", got) } // Flipping back leaves the saved cap alone. m = pickerKey(m, tea.KeyPressMsg{Code: tea.KeyTab}) if got := chosen(t, m).RaiseCap; got != nil { t.Fatalf("an unchanged cap should send nothing, got %v", *got) } // A saved raise shows as raised, and tab restores the default. sol := pickerKey(m, tea.KeyPressMsg{Code: tea.KeyBackspace}, tea.KeyPressMsg{Code: tea.KeyBackspace}, tea.KeyPressMsg{Code: tea.KeyBackspace}, tea.KeyPressMsg{Code: tea.KeyBackspace}, tea.KeyPressMsg{Code: tea.KeyBackspace}, typed("6-sol")) if view := ansi.Strip(sol.View()); !strings.Contains(view, "tab restores 272k") { t.Fatalf("sol's saved raise should show:\n%s", view) } sol = pickerKey(sol, tea.KeyPressMsg{Code: tea.KeyTab}) if got := chosen(t, sol).RaiseCap; got == nil || *got { t.Fatalf("tab should restore sol's default window, got %v", got) } // Nothing to raise, nothing to toggle. older := pickerKey(sol, tea.KeyPressMsg{Code: tea.KeyBackspace}, tea.KeyPressMsg{Code: tea.KeyBackspace}, tea.KeyPressMsg{Code: tea.KeyBackspace}, tea.KeyPressMsg{Code: tea.KeyBackspace}, tea.KeyPressMsg{Code: tea.KeyBackspace}, typed("5.5"), tea.KeyPressMsg{Code: tea.KeyTab}) if view := ansi.Strip(older.View()); strings.Contains(view, "tab raises") || strings.Contains(view, "tab cap") { t.Fatalf("gpt-5.5 has no cap to raise:\n%s", view) } if got := chosen(t, older).RaiseCap; got != nil { t.Fatalf("gpt-5.5 should send no cap, got %v", *got) } }