on main help sign in
feat: recent models section in picker (#1374)

Co-authored-by: Crush <crush@charm.land>

commitfa283472586b87236abf101bff15b42ffde4d600 authorAmolith <amolith@secluded.site> dateWed 12 Nov 2025, 05:03 (1 year ago) parent531f448
7 files changed+757−11
internal/config/config.go+48 internal/config/load.go+3 internal/config/recent_models_test.go+253 internal/tui/components/dialogs/models/list.go+70−10 internal/tui/components/dialogs/models/list_recent_test.go+369 internal/tui/tui.go+4−1 schema.json+10
internal/config/config.go+48
@@ -289,6 +289,8 @@ type Config struct {
289289
290290 // We currently only support large/small as values here.
291291 Models map[SelectedModelType]SelectedModel `json:"models,omitempty" jsonschema:"description=Model configurations for different model types,example={\"large\":{\"model\":\"gpt-4o\",\"provider\":\"openai\"}}"`
292+ // Recently used models stored in the data directory config.
293+ RecentModels map[SelectedModelType][]SelectedModel `json:"recent_models,omitempty" jsonschema:"description=Recently used models sorted by most recent first"`
292294
293295 // The providers that are configured
294296 Providers *csync.Map[string, ProviderConfig] `json:"providers,omitempty" jsonschema:"description=AI provider configurations"`
@@ -398,6 +400,9 @@ func (c *Config) UpdatePreferredModel(modelType SelectedModelType, model Selecte
398400 if err := c.SetConfigField(fmt.Sprintf("models.%s", modelType), model); err != nil {
399401 return fmt.Errorf("failed to update preferred model: %w", err)
400402 }
403+ if err := c.recordRecentModel(modelType, model); err != nil {
404+ return err
405+ }
401406 return nil
402407}
403408
@@ -465,6 +470,49 @@ func (c *Config) SetProviderAPIKey(providerID, apiKey string) error {
465470 return nil
466471}
467472
473+const maxRecentModelsPerType = 5
474+
475+func (c *Config) recordRecentModel(modelType SelectedModelType, model SelectedModel) error {
476+ if model.Provider == "" || model.Model == "" {
477+ return nil
478+ }
479+
480+ if c.RecentModels == nil {
481+ c.RecentModels = make(map[SelectedModelType][]SelectedModel)
482+ }
483+
484+ eq := func(a, b SelectedModel) bool {
485+ return a.Provider == b.Provider && a.Model == b.Model
486+ }
487+
488+ entry := SelectedModel{
489+ Provider: model.Provider,
490+ Model: model.Model,
491+ }
492+
493+ current := c.RecentModels[modelType]
494+ withoutCurrent := slices.DeleteFunc(slices.Clone(current), func(existing SelectedModel) bool {
495+ return eq(existing, entry)
496+ })
497+
498+ updated := append([]SelectedModel{entry}, withoutCurrent...)
499+ if len(updated) > maxRecentModelsPerType {
500+ updated = updated[:maxRecentModelsPerType]
501+ }
502+
503+ if slices.EqualFunc(current, updated, eq) {
504+ return nil
505+ }
506+
507+ c.RecentModels[modelType] = updated
508+
509+ if err := c.SetConfigField(fmt.Sprintf("recent_models.%s", modelType), updated); err != nil {
510+ return fmt.Errorf("failed to persist recent models: %w", err)
511+ }
512+
513+ return nil
514+}
515+
468516func allToolNames() []string {
469517 return []string{
470518 "agent",
internal/config/load.go+3
@@ -329,6 +329,9 @@ func (c *Config) setDefaults(workingDir, dataDir string) {
329329 if c.Models == nil {
330330 c.Models = make(map[SelectedModelType]SelectedModel)
331331 }
332+ if c.RecentModels == nil {
333+ c.RecentModels = make(map[SelectedModelType][]SelectedModel)
334+ }
332335 if c.MCP == nil {
333336 c.MCP = make(map[string]MCPConfig)
334337 }
internal/config/recent_models_test.gonew file+253
@@ -0,0 +1,253 @@
1+package config
2+
3+import (
4+ "encoding/json"
5+ "io/fs"
6+ "os"
7+ "path/filepath"
8+ "testing"
9+
10+ "github.com/stretchr/testify/require"
11+)
12+
13+// readConfigJSON reads and unmarshals the JSON config file at path.
14+func readConfigJSON(t *testing.T, path string) map[string]any {
15+ t.Helper()
16+ baseDir := filepath.Dir(path)
17+ fileName := filepath.Base(path)
18+ b, err := fs.ReadFile(os.DirFS(baseDir), fileName)
19+ require.NoError(t, err)
20+ var out map[string]any
21+ require.NoError(t, json.Unmarshal(b, &out))
22+ return out
23+}
24+
25+// readRecentModels reads the recent_models section from the config file.
26+func readRecentModels(t *testing.T, path string) map[string]any {
27+ t.Helper()
28+ out := readConfigJSON(t, path)
29+ rm, ok := out["recent_models"].(map[string]any)
30+ require.True(t, ok)
31+ return rm
32+}
33+
34+func TestRecordRecentModel_AddsAndPersists(t *testing.T) {
35+ t.Parallel()
36+
37+ dir := t.TempDir()
38+ cfg := &Config{}
39+ cfg.setDefaults(dir, "")
40+ cfg.dataConfigDir = filepath.Join(dir, "config.json")
41+
42+ err := cfg.recordRecentModel(SelectedModelTypeLarge, SelectedModel{Provider: "openai", Model: "gpt-4o"})
43+ require.NoError(t, err)
44+
45+ // in-memory state
46+ require.Len(t, cfg.RecentModels[SelectedModelTypeLarge], 1)
47+ require.Equal(t, "openai", cfg.RecentModels[SelectedModelTypeLarge][0].Provider)
48+ require.Equal(t, "gpt-4o", cfg.RecentModels[SelectedModelTypeLarge][0].Model)
49+
50+ // persisted state
51+ rm := readRecentModels(t, cfg.dataConfigDir)
52+ large, ok := rm[string(SelectedModelTypeLarge)].([]any)
53+ require.True(t, ok)
54+ require.Len(t, large, 1)
55+ item, ok := large[0].(map[string]any)
56+ require.True(t, ok)
57+ require.Equal(t, "openai", item["provider"])
58+ require.Equal(t, "gpt-4o", item["model"])
59+}
60+
61+func TestRecordRecentModel_DedupeAndMoveToFront(t *testing.T) {
62+ t.Parallel()
63+
64+ dir := t.TempDir()
65+ cfg := &Config{}
66+ cfg.setDefaults(dir, "")
67+ cfg.dataConfigDir = filepath.Join(dir, "config.json")
68+
69+ // Add two entries
70+ require.NoError(t, cfg.recordRecentModel(SelectedModelTypeLarge, SelectedModel{Provider: "openai", Model: "gpt-4o"}))
71+ require.NoError(t, cfg.recordRecentModel(SelectedModelTypeLarge, SelectedModel{Provider: "anthropic", Model: "claude"}))
72+ // Re-add first; should move to front and not duplicate
73+ require.NoError(t, cfg.recordRecentModel(SelectedModelTypeLarge, SelectedModel{Provider: "openai", Model: "gpt-4o"}))
74+
75+ got := cfg.RecentModels[SelectedModelTypeLarge]
76+ require.Len(t, got, 2)
77+ require.Equal(t, SelectedModel{Provider: "openai", Model: "gpt-4o"}, got[0])
78+ require.Equal(t, SelectedModel{Provider: "anthropic", Model: "claude"}, got[1])
79+}
80+
81+func TestRecordRecentModel_TrimsToMax(t *testing.T) {
82+ t.Parallel()
83+
84+ dir := t.TempDir()
85+ cfg := &Config{}
86+ cfg.setDefaults(dir, "")
87+ cfg.dataConfigDir = filepath.Join(dir, "config.json")
88+
89+ // Insert 6 unique models; max is 5
90+ entries := []SelectedModel{
91+ {Provider: "p1", Model: "m1"},
92+ {Provider: "p2", Model: "m2"},
93+ {Provider: "p3", Model: "m3"},
94+ {Provider: "p4", Model: "m4"},
95+ {Provider: "p5", Model: "m5"},
96+ {Provider: "p6", Model: "m6"},
97+ }
98+ for _, e := range entries {
99+ require.NoError(t, cfg.recordRecentModel(SelectedModelTypeLarge, e))
100+ }
101+
102+ // in-memory state
103+ got := cfg.RecentModels[SelectedModelTypeLarge]
104+ require.Len(t, got, 5)
105+ // Newest first, capped at 5: p6..p2
106+ require.Equal(t, SelectedModel{Provider: "p6", Model: "m6"}, got[0])
107+ require.Equal(t, SelectedModel{Provider: "p5", Model: "m5"}, got[1])
108+ require.Equal(t, SelectedModel{Provider: "p4", Model: "m4"}, got[2])
109+ require.Equal(t, SelectedModel{Provider: "p3", Model: "m3"}, got[3])
110+ require.Equal(t, SelectedModel{Provider: "p2", Model: "m2"}, got[4])
111+
112+ // persisted state: verify trimmed to 5 and newest-first order
113+ rm := readRecentModels(t, cfg.dataConfigDir)
114+ large, ok := rm[string(SelectedModelTypeLarge)].([]any)
115+ require.True(t, ok)
116+ require.Len(t, large, 5)
117+ // Build provider:model IDs and verify order
118+ var ids []string
119+ for _, v := range large {
120+ m := v.(map[string]any)
121+ ids = append(ids, m["provider"].(string)+":"+m["model"].(string))
122+ }
123+ require.Equal(t, []string{"p6:m6", "p5:m5", "p4:m4", "p3:m3", "p2:m2"}, ids)
124+}
125+
126+func TestRecordRecentModel_SkipsEmptyValues(t *testing.T) {
127+ t.Parallel()
128+
129+ dir := t.TempDir()
130+ cfg := &Config{}
131+ cfg.setDefaults(dir, "")
132+ cfg.dataConfigDir = filepath.Join(dir, "config.json")
133+
134+ // Missing provider
135+ require.NoError(t, cfg.recordRecentModel(SelectedModelTypeLarge, SelectedModel{Provider: "", Model: "m"}))
136+ // Missing model
137+ require.NoError(t, cfg.recordRecentModel(SelectedModelTypeLarge, SelectedModel{Provider: "p", Model: ""}))
138+
139+ _, ok := cfg.RecentModels[SelectedModelTypeLarge]
140+ // Map may be initialized, but should have no entries
141+ if ok {
142+ require.Len(t, cfg.RecentModels[SelectedModelTypeLarge], 0)
143+ }
144+ // No file should be written (stat via fs.FS)
145+ baseDir := filepath.Dir(cfg.dataConfigDir)
146+ fileName := filepath.Base(cfg.dataConfigDir)
147+ _, err := fs.Stat(os.DirFS(baseDir), fileName)
148+ require.True(t, os.IsNotExist(err))
149+}
150+
151+func TestRecordRecentModel_NoPersistOnNoop(t *testing.T) {
152+ t.Parallel()
153+
154+ dir := t.TempDir()
155+ cfg := &Config{}
156+ cfg.setDefaults(dir, "")
157+ cfg.dataConfigDir = filepath.Join(dir, "config.json")
158+
159+ entry := SelectedModel{Provider: "openai", Model: "gpt-4o"}
160+ require.NoError(t, cfg.recordRecentModel(SelectedModelTypeLarge, entry))
161+
162+ baseDir := filepath.Dir(cfg.dataConfigDir)
163+ fileName := filepath.Base(cfg.dataConfigDir)
164+ before, err := fs.ReadFile(os.DirFS(baseDir), fileName)
165+ require.NoError(t, err)
166+
167+ // Get file ModTime to verify no write occurs
168+ stBefore, err := fs.Stat(os.DirFS(baseDir), fileName)
169+ require.NoError(t, err)
170+ beforeMod := stBefore.ModTime()
171+
172+ // Re-record same entry should be a no-op (no write)
173+ require.NoError(t, cfg.recordRecentModel(SelectedModelTypeLarge, entry))
174+
175+ after, err := fs.ReadFile(os.DirFS(baseDir), fileName)
176+ require.NoError(t, err)
177+ require.Equal(t, string(before), string(after))
178+
179+ // Verify ModTime unchanged to ensure truly no write occurred
180+ stAfter, err := fs.Stat(os.DirFS(baseDir), fileName)
181+ require.NoError(t, err)
182+ require.True(t, stAfter.ModTime().Equal(beforeMod), "file ModTime should not change on noop")
183+}
184+
185+func TestUpdatePreferredModel_UpdatesRecents(t *testing.T) {
186+ t.Parallel()
187+
188+ dir := t.TempDir()
189+ cfg := &Config{}
190+ cfg.setDefaults(dir, "")
191+ cfg.dataConfigDir = filepath.Join(dir, "config.json")
192+
193+ sel := SelectedModel{Provider: "openai", Model: "gpt-4o"}
194+ require.NoError(t, cfg.UpdatePreferredModel(SelectedModelTypeSmall, sel))
195+
196+ // in-memory
197+ require.Equal(t, sel, cfg.Models[SelectedModelTypeSmall])
198+ require.Len(t, cfg.RecentModels[SelectedModelTypeSmall], 1)
199+
200+ // persisted (read via fs.FS)
201+ rm := readRecentModels(t, cfg.dataConfigDir)
202+ small, ok := rm[string(SelectedModelTypeSmall)].([]any)
203+ require.True(t, ok)
204+ require.Len(t, small, 1)
205+}
206+
207+func TestRecordRecentModel_TypeIsolation(t *testing.T) {
208+ t.Parallel()
209+
210+ dir := t.TempDir()
211+ cfg := &Config{}
212+ cfg.setDefaults(dir, "")
213+ cfg.dataConfigDir = filepath.Join(dir, "config.json")
214+
215+ // Add models to both large and small types
216+ largeModel := SelectedModel{Provider: "openai", Model: "gpt-4o"}
217+ smallModel := SelectedModel{Provider: "anthropic", Model: "claude"}
218+
219+ require.NoError(t, cfg.recordRecentModel(SelectedModelTypeLarge, largeModel))
220+ require.NoError(t, cfg.recordRecentModel(SelectedModelTypeSmall, smallModel))
221+
222+ // in-memory: verify types maintain separate histories
223+ require.Len(t, cfg.RecentModels[SelectedModelTypeLarge], 1)
224+ require.Len(t, cfg.RecentModels[SelectedModelTypeSmall], 1)
225+ require.Equal(t, largeModel, cfg.RecentModels[SelectedModelTypeLarge][0])
226+ require.Equal(t, smallModel, cfg.RecentModels[SelectedModelTypeSmall][0])
227+
228+ // Add another to large, verify small unchanged
229+ anotherLarge := SelectedModel{Provider: "google", Model: "gemini"}
230+ require.NoError(t, cfg.recordRecentModel(SelectedModelTypeLarge, anotherLarge))
231+
232+ require.Len(t, cfg.RecentModels[SelectedModelTypeLarge], 2)
233+ require.Len(t, cfg.RecentModels[SelectedModelTypeSmall], 1)
234+ require.Equal(t, smallModel, cfg.RecentModels[SelectedModelTypeSmall][0])
235+
236+ // persisted state: verify both types exist with correct lengths and contents
237+ rm := readRecentModels(t, cfg.dataConfigDir)
238+
239+ large, ok := rm[string(SelectedModelTypeLarge)].([]any)
240+ require.True(t, ok)
241+ require.Len(t, large, 2)
242+ // Verify newest first for large type
243+ require.Equal(t, "google", large[0].(map[string]any)["provider"])
244+ require.Equal(t, "gemini", large[0].(map[string]any)["model"])
245+ require.Equal(t, "openai", large[1].(map[string]any)["provider"])
246+ require.Equal(t, "gpt-4o", large[1].(map[string]any)["model"])
247+
248+ small, ok := rm[string(SelectedModelTypeSmall)].([]any)
249+ require.True(t, ok)
250+ require.Len(t, small, 1)
251+ require.Equal(t, "anthropic", small[0].(map[string]any)["provider"])
252+ require.Equal(t, "claude", small[0].(map[string]any)["model"])
253+}
internal/tui/components/dialogs/models/list.go+70−10
@@ -22,6 +22,13 @@ type ModelListComponent struct {
2222 providers []catwalk.Provider
2323}
2424
25+func modelKey(providerID, modelID string) string {
26+ if providerID == "" || modelID == "" {
27+ return ""
28+ }
29+ return providerID + ":" + modelID
30+}
31+
2532func NewModelListComponent(keyMap list.KeyMap, inputPlaceholder string, shouldResize bool) *ModelListComponent {
2633 t := styles.CurrentTheme()
2734 inputStyle := t.S().Base.PaddingLeft(1).PaddingBottom(1)
@@ -104,14 +111,19 @@ func (m *ModelListComponent) SetModelType(modelType int) tea.Cmd {
104111 var groups []list.Group[list.CompletionItem[ModelOption]]
105112 // first none section
106113 selectedItemID := ""
114+ itemsByKey := make(map[string]list.CompletionItem[ModelOption])
107115
108116 cfg := config.Get()
109117 var currentModel config.SelectedModel
118+ selectedType := config.SelectedModelTypeLarge
110119 if m.modelType == LargeModelType {
111120 currentModel = cfg.Models[config.SelectedModelTypeLarge]
121+ selectedType = config.SelectedModelTypeLarge
112122 } else {
113123 currentModel = cfg.Models[config.SelectedModelTypeSmall]
124+ selectedType = config.SelectedModelTypeSmall
114125 }
126+ recentItems := cfg.RecentModels[selectedType]
115127
116128 configuredIcon := t.S().Base.Foreground(t.Success).Render(styles.CheckIcon)
117129 configured := fmt.Sprintf("%s %s", configuredIcon, t.S().Subtle.Render("Configured"))
@@ -169,14 +181,17 @@ func (m *ModelListComponent) SetModelType(modelType int) tea.Cmd {
169181 Section: section,
170182 }
171183 for _, model := range configProvider.Models {
172− item := list.NewCompletionItem(model.Name, ModelOption{
184+ modelOption := ModelOption{
173185 Provider: configProvider,
174186 Model: model,
175− },
176− list.WithCompletionID(
177− fmt.Sprintf("%s:%s", providerConfig.ID, model.ID),
178− ),
187+ }
188+ key := modelKey(string(configProvider.ID), model.ID)
189+ item := list.NewCompletionItem(
190+ model.Name,
191+ modelOption,
192+ list.WithCompletionID(key),
179193 )
194+ itemsByKey[key] = item
180195
181196 group.Items = append(group.Items, item)
182197 if model.ID == currentModel.Model && string(configProvider.ID) == currentModel.Provider {
@@ -239,14 +254,17 @@ func (m *ModelListComponent) SetModelType(modelType int) tea.Cmd {
239254 Section: section,
240255 }
241256 for _, model := range displayProvider.Models {
242− item := list.NewCompletionItem(model.Name, ModelOption{
257+ modelOption := ModelOption{
243258 Provider: displayProvider,
244259 Model: model,
245− },
246− list.WithCompletionID(
247− fmt.Sprintf("%s:%s", displayProvider.ID, model.ID),
248− ),
260+ }
261+ key := modelKey(string(displayProvider.ID), model.ID)
262+ item := list.NewCompletionItem(
263+ model.Name,
264+ modelOption,
265+ list.WithCompletionID(key),
249266 )
267+ itemsByKey[key] = item
250268 group.Items = append(group.Items, item)
251269 if model.ID == currentModel.Model && string(displayProvider.ID) == currentModel.Provider {
252270 selectedItemID = item.ID()
@@ -255,6 +273,48 @@ func (m *ModelListComponent) SetModelType(modelType int) tea.Cmd {
255273 groups = append(groups, group)
256274 }
257275
276+ if len(recentItems) > 0 {
277+ recentSection := list.NewItemSection("Recently used")
278+ recentGroup := list.Group[list.CompletionItem[ModelOption]]{
279+ Section: recentSection,
280+ }
281+ var validRecentItems []config.SelectedModel
282+ for _, recent := range recentItems {
283+ key := modelKey(recent.Provider, recent.Model)
284+ option, ok := itemsByKey[key]
285+ if !ok {
286+ continue
287+ }
288+ validRecentItems = append(validRecentItems, recent)
289+ recentID := fmt.Sprintf("recent::%s", key)
290+ modelOption := option.Value()
291+ providerName := modelOption.Provider.Name
292+ if providerName == "" {
293+ providerName = string(modelOption.Provider.ID)
294+ }
295+ item := list.NewCompletionItem(
296+ modelOption.Model.Name,
297+ option.Value(),
298+ list.WithCompletionID(recentID),
299+ list.WithCompletionShortcut(providerName),
300+ )
301+ recentGroup.Items = append(recentGroup.Items, item)
302+ if recent.Model == currentModel.Model && recent.Provider == currentModel.Provider {
303+ selectedItemID = recentID
304+ }
305+ }
306+
307+ if len(validRecentItems) != len(recentItems) {
308+ if err := cfg.SetConfigField(fmt.Sprintf("recent_models.%s", selectedType), validRecentItems); err != nil {
309+ return util.ReportError(err)
310+ }
311+ }
312+
313+ if len(recentGroup.Items) > 0 {
314+ groups = append([]list.Group[list.CompletionItem[ModelOption]]{recentGroup}, groups...)
315+ }
316+ }
317+
258318 var cmds []tea.Cmd
259319
260320 cmd := m.list.SetGroups(groups)
internal/tui/components/dialogs/models/list_recent_test.gonew file+369
@@ -0,0 +1,369 @@
1+package models
2+
3+import (
4+ "encoding/json"
5+ "io/fs"
6+ "os"
7+ "path/filepath"
8+ "strings"
9+ "testing"
10+
11+ tea "github.com/charmbracelet/bubbletea/v2"
12+ "github.com/charmbracelet/catwalk/pkg/catwalk"
13+ "github.com/charmbracelet/crush/internal/config"
14+ "github.com/charmbracelet/crush/internal/log"
15+ "github.com/charmbracelet/crush/internal/tui/exp/list"
16+ "github.com/stretchr/testify/require"
17+)
18+
19+// execCmdML runs a tea.Cmd through the ModelListComponent's Update loop.
20+func execCmdML(t *testing.T, m *ModelListComponent, cmd tea.Cmd) {
21+ t.Helper()
22+ for cmd != nil {
23+ msg := cmd()
24+ var next tea.Cmd
25+ _, next = m.Update(msg)
26+ cmd = next
27+ }
28+}
29+
30+// readConfigJSON reads and unmarshals the JSON config file at path.
31+func readConfigJSON(t *testing.T, path string) map[string]any {
32+ t.Helper()
33+ baseDir := filepath.Dir(path)
34+ fileName := filepath.Base(path)
35+ b, err := fs.ReadFile(os.DirFS(baseDir), fileName)
36+ require.NoError(t, err)
37+ var out map[string]any
38+ require.NoError(t, json.Unmarshal(b, &out))
39+ return out
40+}
41+
42+// readRecentModels reads the recent_models section from the config file.
43+func readRecentModels(t *testing.T, path string) map[string]any {
44+ t.Helper()
45+ out := readConfigJSON(t, path)
46+ rm, ok := out["recent_models"].(map[string]any)
47+ require.True(t, ok)
48+ return rm
49+}
50+
51+func TestModelList_RecentlyUsedSectionAndPrunesInvalid(t *testing.T) {
52+ // Pre-initialize logger to os.DevNull to prevent file lock on Windows.
53+ log.Setup(os.DevNull, false)
54+
55+ // Isolate config/data paths
56+ cfgDir := t.TempDir()
57+ dataDir := t.TempDir()
58+ t.Setenv("XDG_CONFIG_HOME", cfgDir)
59+ t.Setenv("XDG_DATA_HOME", dataDir)
60+
61+ // Pre-seed config so provider auto-update is disabled and we have recents
62+ confPath := filepath.Join(cfgDir, "crush", "crush.json")
63+ require.NoError(t, os.MkdirAll(filepath.Dir(confPath), 0o755))
64+ initial := map[string]any{
65+ "options": map[string]any{
66+ "disable_provider_auto_update": true,
67+ },
68+ "models": map[string]any{
69+ "large": map[string]any{
70+ "model": "m1",
71+ "provider": "p1",
72+ },
73+ },
74+ "recent_models": map[string]any{
75+ "large": []any{
76+ map[string]any{"model": "m2", "provider": "p1"}, // valid
77+ map[string]any{"model": "x", "provider": "unknown-provider"}, // invalid -> pruned
78+ },
79+ },
80+ }
81+ bts, err := json.Marshal(initial)
82+ require.NoError(t, err)
83+ require.NoError(t, os.WriteFile(confPath, bts, 0o644))
84+
85+ // Also create empty providers.json to prevent loading real providers
86+ dataConfDir := filepath.Join(dataDir, "crush")
87+ require.NoError(t, os.MkdirAll(dataConfDir, 0o755))
88+ emptyProviders := []byte("[]")
89+ require.NoError(t, os.WriteFile(filepath.Join(dataConfDir, "providers.json"), emptyProviders, 0o644))
90+
91+ // Initialize global config instance (no network due to auto-update disabled)
92+ _, err = config.Init(cfgDir, dataDir, false)
93+ require.NoError(t, err)
94+
95+ // Build a small provider set for the list component
96+ provider := catwalk.Provider{
97+ ID: catwalk.InferenceProvider("p1"),
98+ Name: "Provider One",
99+ Models: []catwalk.Model{
100+ {ID: "m1", Name: "Model One", DefaultMaxTokens: 100},
101+ {ID: "m2", Name: "Model Two", DefaultMaxTokens: 100}, // recent
102+ },
103+ }
104+
105+ // Create and initialize the component with our provider set
106+ listKeyMap := list.DefaultKeyMap()
107+ cmp := NewModelListComponent(listKeyMap, "Find your fave", false)
108+ cmp.providers = []catwalk.Provider{provider}
109+ execCmdML(t, cmp, cmp.Init())
110+
111+ // Find all recent items (IDs prefixed with "recent::") and verify pruning
112+ groups := cmp.list.Groups()
113+ require.NotEmpty(t, groups)
114+ var recentItems []list.CompletionItem[ModelOption]
115+ for _, g := range groups {
116+ for _, it := range g.Items {
117+ if strings.HasPrefix(it.ID(), "recent::") {
118+ recentItems = append(recentItems, it)
119+ }
120+ }
121+ }
122+ require.NotEmpty(t, recentItems, "no recent items found")
123+ // Ensure the valid recent (p1:m2) is present and the invalid one is not
124+ foundValid := false
125+ for _, it := range recentItems {
126+ if it.ID() == "recent::p1:m2" {
127+ foundValid = true
128+ }
129+ require.NotEqual(t, "recent::unknown-provider:x", it.ID(), "invalid recent should be pruned")
130+ }
131+ require.True(t, foundValid, "expected valid recent not found")
132+
133+ // Verify original config in cfgDir remains unchanged
134+ origConfPath := filepath.Join(cfgDir, "crush", "crush.json")
135+ afterOrig, err := fs.ReadFile(os.DirFS(filepath.Dir(origConfPath)), filepath.Base(origConfPath))
136+ require.NoError(t, err)
137+ var origParsed map[string]any
138+ require.NoError(t, json.Unmarshal(afterOrig, &origParsed))
139+ origRM := origParsed["recent_models"].(map[string]any)
140+ origLarge := origRM["large"].([]any)
141+ require.Len(t, origLarge, 2, "original config should be unchanged")
142+
143+ // Config should be rewritten with pruned recents in dataDir
144+ dataConf := filepath.Join(dataDir, "crush", "crush.json")
145+ rm := readRecentModels(t, dataConf)
146+ largeAny, ok := rm["large"].([]any)
147+ require.True(t, ok)
148+ // Ensure that only valid recent(s) remain and the invalid one is removed
149+ found := false
150+ for _, v := range largeAny {
151+ m := v.(map[string]any)
152+ require.NotEqual(t, "unknown-provider", m["provider"], "invalid provider should be pruned")
153+ if m["provider"] == "p1" && m["model"] == "m2" {
154+ found = true
155+ }
156+ }
157+ require.True(t, found, "persisted recents should include p1:m2")
158+}
159+
160+func TestModelList_PrunesInvalidModelWithinValidProvider(t *testing.T) {
161+ // Pre-initialize logger to os.DevNull to prevent file lock on Windows.
162+ log.Setup(os.DevNull, false)
163+
164+ // Isolate config/data paths
165+ cfgDir := t.TempDir()
166+ dataDir := t.TempDir()
167+ t.Setenv("XDG_CONFIG_HOME", cfgDir)
168+ t.Setenv("XDG_DATA_HOME", dataDir)
169+
170+ // Pre-seed config with valid provider but one invalid model
171+ confPath := filepath.Join(cfgDir, "crush", "crush.json")
172+ require.NoError(t, os.MkdirAll(filepath.Dir(confPath), 0o755))
173+ initial := map[string]any{
174+ "options": map[string]any{
175+ "disable_provider_auto_update": true,
176+ },
177+ "models": map[string]any{
178+ "large": map[string]any{
179+ "model": "m1",
180+ "provider": "p1",
181+ },
182+ },
183+ "recent_models": map[string]any{
184+ "large": []any{
185+ map[string]any{"model": "m1", "provider": "p1"}, // valid
186+ map[string]any{"model": "missing", "provider": "p1"}, // invalid model
187+ },
188+ },
189+ }
190+ bts, err := json.Marshal(initial)
191+ require.NoError(t, err)
192+ require.NoError(t, os.WriteFile(confPath, bts, 0o644))
193+
194+ // Create empty providers.json
195+ dataConfDir := filepath.Join(dataDir, "crush")
196+ require.NoError(t, os.MkdirAll(dataConfDir, 0o755))
197+ emptyProviders := []byte("[]")
198+ require.NoError(t, os.WriteFile(filepath.Join(dataConfDir, "providers.json"), emptyProviders, 0o644))
199+
200+ // Initialize global config instance
201+ _, err = config.Init(cfgDir, dataDir, false)
202+ require.NoError(t, err)
203+
204+ // Build provider set that only includes m1, not "missing"
205+ provider := catwalk.Provider{
206+ ID: catwalk.InferenceProvider("p1"),
207+ Name: "Provider One",
208+ Models: []catwalk.Model{
209+ {ID: "m1", Name: "Model One", DefaultMaxTokens: 100},
210+ },
211+ }
212+
213+ // Create and initialize component
214+ listKeyMap := list.DefaultKeyMap()
215+ cmp := NewModelListComponent(listKeyMap, "Find your fave", false)
216+ cmp.providers = []catwalk.Provider{provider}
217+ execCmdML(t, cmp, cmp.Init())
218+
219+ // Find all recent items
220+ groups := cmp.list.Groups()
221+ require.NotEmpty(t, groups)
222+ var recentItems []list.CompletionItem[ModelOption]
223+ for _, g := range groups {
224+ for _, it := range g.Items {
225+ if strings.HasPrefix(it.ID(), "recent::") {
226+ recentItems = append(recentItems, it)
227+ }
228+ }
229+ }
230+ require.NotEmpty(t, recentItems, "valid recent should exist")
231+
232+ // Verify the valid recent is present and invalid model is not
233+ foundValid := false
234+ for _, it := range recentItems {
235+ if it.ID() == "recent::p1:m1" {
236+ foundValid = true
237+ }
238+ require.NotEqual(t, "recent::p1:missing", it.ID(), "invalid model should be pruned")
239+ }
240+ require.True(t, foundValid, "valid recent p1:m1 should be present")
241+
242+ // Verify original config in cfgDir remains unchanged
243+ origConfPath := filepath.Join(cfgDir, "crush", "crush.json")
244+ afterOrig, err := fs.ReadFile(os.DirFS(filepath.Dir(origConfPath)), filepath.Base(origConfPath))
245+ require.NoError(t, err)
246+ var origParsed map[string]any
247+ require.NoError(t, json.Unmarshal(afterOrig, &origParsed))
248+ origRM := origParsed["recent_models"].(map[string]any)
249+ origLarge := origRM["large"].([]any)
250+ require.Len(t, origLarge, 2, "original config should be unchanged")
251+
252+ // Config should be rewritten with pruned recents in dataDir
253+ dataConf := filepath.Join(dataDir, "crush", "crush.json")
254+ rm := readRecentModels(t, dataConf)
255+ largeAny, ok := rm["large"].([]any)
256+ require.True(t, ok)
257+ require.Len(t, largeAny, 1, "should only have one valid model")
258+ // Verify only p1:m1 remains
259+ m := largeAny[0].(map[string]any)
260+ require.Equal(t, "p1", m["provider"])
261+ require.Equal(t, "m1", m["model"])
262+}
263+
264+func TestModelKey_EmptyInputs(t *testing.T) {
265+ // Empty provider
266+ require.Equal(t, "", modelKey("", "model"))
267+ // Empty model
268+ require.Equal(t, "", modelKey("provider", ""))
269+ // Both empty
270+ require.Equal(t, "", modelKey("", ""))
271+ // Valid inputs
272+ require.Equal(t, "p:m", modelKey("p", "m"))
273+}
274+
275+func TestModelList_AllRecentsInvalid(t *testing.T) {
276+ // Pre-initialize logger to os.DevNull to prevent file lock on Windows.
277+ log.Setup(os.DevNull, false)
278+
279+ // Isolate config/data paths
280+ cfgDir := t.TempDir()
281+ dataDir := t.TempDir()
282+ t.Setenv("XDG_CONFIG_HOME", cfgDir)
283+ t.Setenv("XDG_DATA_HOME", dataDir)
284+
285+ // Pre-seed config with only invalid recents
286+ confPath := filepath.Join(cfgDir, "crush", "crush.json")
287+ require.NoError(t, os.MkdirAll(filepath.Dir(confPath), 0o755))
288+ initial := map[string]any{
289+ "options": map[string]any{
290+ "disable_provider_auto_update": true,
291+ },
292+ "models": map[string]any{
293+ "large": map[string]any{
294+ "model": "m1",
295+ "provider": "p1",
296+ },
297+ },
298+ "recent_models": map[string]any{
299+ "large": []any{
300+ map[string]any{"model": "x", "provider": "unknown1"},
301+ map[string]any{"model": "y", "provider": "unknown2"},
302+ },
303+ },
304+ }
305+ bts, err := json.Marshal(initial)
306+ require.NoError(t, err)
307+ require.NoError(t, os.WriteFile(confPath, bts, 0o644))
308+
309+ // Also create empty providers.json and data config
310+ dataConfDir := filepath.Join(dataDir, "crush")
311+ require.NoError(t, os.MkdirAll(dataConfDir, 0o755))
312+ emptyProviders := []byte("[]")
313+ require.NoError(t, os.WriteFile(filepath.Join(dataConfDir, "providers.json"), emptyProviders, 0o644))
314+
315+ // Initialize global config instance with isolated dataDir
316+ _, err = config.Init(cfgDir, dataDir, false)
317+ require.NoError(t, err)
318+
319+ // Build provider set (doesn't include unknown1 or unknown2)
320+ provider := catwalk.Provider{
321+ ID: catwalk.InferenceProvider("p1"),
322+ Name: "Provider One",
323+ Models: []catwalk.Model{
324+ {ID: "m1", Name: "Model One", DefaultMaxTokens: 100},
325+ },
326+ }
327+
328+ // Create and initialize component
329+ listKeyMap := list.DefaultKeyMap()
330+ cmp := NewModelListComponent(listKeyMap, "Find your fave", false)
331+ cmp.providers = []catwalk.Provider{provider}
332+ execCmdML(t, cmp, cmp.Init())
333+
334+ // Verify no recent items exist in UI
335+ groups := cmp.list.Groups()
336+ require.NotEmpty(t, groups)
337+ var recentItems []list.CompletionItem[ModelOption]
338+ for _, g := range groups {
339+ for _, it := range g.Items {
340+ if strings.HasPrefix(it.ID(), "recent::") {
341+ recentItems = append(recentItems, it)
342+ }
343+ }
344+ }
345+ require.Empty(t, recentItems, "all invalid recents should be pruned, resulting in no recent section")
346+
347+ // Verify original config in cfgDir remains unchanged
348+ origConfPath := filepath.Join(cfgDir, "crush", "crush.json")
349+ afterOrig, err := fs.ReadFile(os.DirFS(filepath.Dir(origConfPath)), filepath.Base(origConfPath))
350+ require.NoError(t, err)
351+ var origParsed map[string]any
352+ require.NoError(t, json.Unmarshal(afterOrig, &origParsed))
353+ origRM := origParsed["recent_models"].(map[string]any)
354+ origLarge := origRM["large"].([]any)
355+ require.Len(t, origLarge, 2, "original config should be unchanged")
356+
357+ // Config should be rewritten with empty recents in dataDir
358+ dataConf := filepath.Join(dataDir, "crush", "crush.json")
359+ rm := readRecentModels(t, dataConf)
360+ // When all recents are pruned, the value may be nil or an empty array
361+ largeVal := rm["large"]
362+ if largeVal == nil {
363+ // nil is acceptable - means empty
364+ return
365+ }
366+ largeAny, ok := largeVal.([]any)
367+ require.True(t, ok, "large key should be nil or array")
368+ require.Empty(t, largeAny, "persisted recents should be empty after pruning all invalid entries")
369+}
internal/tui/tui.go+4−1
@@ -267,7 +267,10 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
267267 return a, util.ReportWarn("Agent is busy, please wait...")
268268 }
269269
270− config.Get().UpdatePreferredModel(msg.ModelType, msg.Model)
270+ cfg := config.Get()
271+ if err := cfg.UpdatePreferredModel(msg.ModelType, msg.Model); err != nil {
272+ return a, util.ReportError(err)
273+ }
271274
272275 go a.app.UpdateAgentModel(context.TODO())
273276
schema.json+10
@@ -53,6 +53,16 @@
5353 "type": "object",
5454 "description": "Model configurations for different model types"
5555 },
56+ "recent_models": {
57+ "additionalProperties": {
58+ "items": {
59+ "$ref": "#/$defs/SelectedModel"
60+ },
61+ "type": "array"
62+ },
63+ "type": "object",
64+ "description": "Recently used models sorted by most recent first"
65+ },
5666 "providers": {
5767 "additionalProperties": {
5868 "$ref": "#/$defs/ProviderConfig"