feat(recently-used): track recently used models

Amolith and Crush created

Track up to 5 most recent model selections per type:

- Display recently used models in dedicated UI section
- Persist recent model history in config
- Improve error handling for model preference updates

Co-Authored-By: Crush <crush@charm.land>

Change summary

internal/config/config.go                      | 48 ++++++++++++++
internal/config/load.go                        |  3 
internal/tui/components/dialogs/models/list.go | 67 +++++++++++++++++--
internal/tui/tui.go                            |  5 +
4 files changed, 112 insertions(+), 11 deletions(-)

Detailed changes

internal/config/config.go 🔗

@@ -291,6 +291,8 @@ type Config struct {
 
 	// We currently only support large/small as values here.
 	Models map[SelectedModelType]SelectedModel `json:"models,omitempty" jsonschema:"description=Model configurations for different model types,example={\"large\":{\"model\":\"gpt-4o\",\"provider\":\"openai\"}}"`
+	// Recently used models stored in the data directory config.
+	RecentModels map[SelectedModelType][]SelectedModel `json:"recent_models,omitempty" jsonschema:"description=Recently used models sorted by most recent first"`
 
 	// The providers that are configured
 	Providers *csync.Map[string, ProviderConfig] `json:"providers,omitempty" jsonschema:"description=AI provider configurations"`
@@ -400,6 +402,9 @@ func (c *Config) UpdatePreferredModel(modelType SelectedModelType, model Selecte
 	if err := c.SetConfigField(fmt.Sprintf("models.%s", modelType), model); err != nil {
 		return fmt.Errorf("failed to update preferred model: %w", err)
 	}
+	if err := c.recordRecentModel(modelType, model); err != nil {
+		return err
+	}
 	return nil
 }
 
@@ -467,6 +472,49 @@ func (c *Config) SetProviderAPIKey(providerID, apiKey string) error {
 	return nil
 }
 
+const maxRecentModelsPerType = 5
+
+func (c *Config) recordRecentModel(modelType SelectedModelType, model SelectedModel) error {
+	if model.Provider == "" || model.Model == "" {
+		return nil
+	}
+
+	if c.RecentModels == nil {
+		c.RecentModels = make(map[SelectedModelType][]SelectedModel)
+	}
+
+	eq := func(a, b SelectedModel) bool {
+		return a.Provider == b.Provider && a.Model == b.Model
+	}
+
+	entry := SelectedModel{
+		Provider: model.Provider,
+		Model:    model.Model,
+	}
+
+	current := c.RecentModels[modelType]
+	withoutCurrent := slices.DeleteFunc(slices.Clone(current), func(existing SelectedModel) bool {
+		return eq(existing, entry)
+	})
+
+	updated := append([]SelectedModel{entry}, withoutCurrent...)
+	if len(updated) > maxRecentModelsPerType {
+		updated = updated[:maxRecentModelsPerType]
+	}
+
+	if slices.EqualFunc(current, updated, eq) {
+		return nil
+	}
+
+	c.RecentModels[modelType] = updated
+
+	if err := c.SetConfigField(fmt.Sprintf("recent_models.%s", modelType), updated); err != nil {
+		return fmt.Errorf("failed to persist recent models: %w", err)
+	}
+
+	return nil
+}
+
 func allToolNames() []string {
 	return []string{
 		"agent",

internal/config/load.go 🔗

@@ -339,6 +339,9 @@ func (c *Config) setDefaults(workingDir, dataDir string) {
 	if c.Models == nil {
 		c.Models = make(map[SelectedModelType]SelectedModel)
 	}
+	if c.RecentModels == nil {
+		c.RecentModels = make(map[SelectedModelType][]SelectedModel)
+	}
 	if c.MCP == nil {
 		c.MCP = make(map[string]MCPConfig)
 	}

internal/tui/components/dialogs/models/list.go 🔗

@@ -22,6 +22,13 @@ type ModelListComponent struct {
 	providers []catwalk.Provider
 }
 
+func modelKey(providerID, modelID string) string {
+	if providerID == "" || modelID == "" {
+		return ""
+	}
+	return providerID + ":" + modelID
+}
+
 func NewModelListComponent(keyMap list.KeyMap, inputPlaceholder string, shouldResize bool) *ModelListComponent {
 	t := styles.CurrentTheme()
 	inputStyle := t.S().Base.PaddingLeft(1).PaddingBottom(1)
@@ -104,14 +111,19 @@ func (m *ModelListComponent) SetModelType(modelType int) tea.Cmd {
 	var groups []list.Group[list.CompletionItem[ModelOption]]
 	// first none section
 	selectedItemID := ""
+	itemsByKey := make(map[string]list.CompletionItem[ModelOption])
 
 	cfg := config.Get()
 	var currentModel config.SelectedModel
+	selectedType := config.SelectedModelTypeLarge
 	if m.modelType == LargeModelType {
 		currentModel = cfg.Models[config.SelectedModelTypeLarge]
+		selectedType = config.SelectedModelTypeLarge
 	} else {
 		currentModel = cfg.Models[config.SelectedModelTypeSmall]
+		selectedType = config.SelectedModelTypeSmall
 	}
+	recentItems := cfg.RecentModels[selectedType]
 
 	configuredIcon := t.S().Base.Foreground(t.Success).Render(styles.CheckIcon)
 	configured := fmt.Sprintf("%s %s", configuredIcon, t.S().Subtle.Render("Configured"))
@@ -169,14 +181,17 @@ func (m *ModelListComponent) SetModelType(modelType int) tea.Cmd {
 				Section: section,
 			}
 			for _, model := range configProvider.Models {
-				item := list.NewCompletionItem(model.Name, ModelOption{
+				modelOption := ModelOption{
 					Provider: configProvider,
 					Model:    model,
-				},
-					list.WithCompletionID(
-						fmt.Sprintf("%s:%s", providerConfig.ID, model.ID),
-					),
+				}
+				key := modelKey(string(configProvider.ID), model.ID)
+				item := list.NewCompletionItem(
+					model.Name,
+					modelOption,
+					list.WithCompletionID(key),
 				)
+				itemsByKey[key] = item
 
 				group.Items = append(group.Items, item)
 				if model.ID == currentModel.Model && string(configProvider.ID) == currentModel.Provider {
@@ -239,14 +254,17 @@ func (m *ModelListComponent) SetModelType(modelType int) tea.Cmd {
 			Section: section,
 		}
 		for _, model := range displayProvider.Models {
-			item := list.NewCompletionItem(model.Name, ModelOption{
+			modelOption := ModelOption{
 				Provider: displayProvider,
 				Model:    model,
-			},
-				list.WithCompletionID(
-					fmt.Sprintf("%s:%s", displayProvider.ID, model.ID),
-				),
+			}
+			key := modelKey(string(displayProvider.ID), model.ID)
+			item := list.NewCompletionItem(
+				model.Name,
+				modelOption,
+				list.WithCompletionID(key),
 			)
+			itemsByKey[key] = item
 			group.Items = append(group.Items, item)
 			if model.ID == currentModel.Model && string(displayProvider.ID) == currentModel.Provider {
 				selectedItemID = item.ID()
@@ -255,6 +273,35 @@ func (m *ModelListComponent) SetModelType(modelType int) tea.Cmd {
 		groups = append(groups, group)
 	}
 
+	if len(recentItems) > 0 {
+		recentSection := list.NewItemSection("Recently used")
+		recentGroup := list.Group[list.CompletionItem[ModelOption]]{
+			Section: recentSection,
+		}
+		for _, recent := range recentItems {
+			key := modelKey(recent.Provider, recent.Model)
+			option, ok := itemsByKey[key]
+			if !ok {
+				continue
+			}
+			recentID := fmt.Sprintf("recent::%s", key)
+			item := list.NewCompletionItem(
+				option.Text(),
+				option.Value(),
+				list.WithCompletionID(recentID),
+			)
+			recentGroup.Items = append(recentGroup.Items, item)
+			if recent.Model == currentModel.Model && recent.Provider == currentModel.Provider {
+				selectedItemID = recentID
+			}
+		}
+
+		if len(recentGroup.Items) > 0 {
+			recentGroup.Section.SetInfo(t.S().Base.Foreground(t.FgHalfMuted).Render("Most recent selections"))
+			groups = append([]list.Group[list.CompletionItem[ModelOption]]{recentGroup}, groups...)
+		}
+	}
+
 	var cmds []tea.Cmd
 
 	cmd := m.list.SetGroups(groups)

internal/tui/tui.go 🔗

@@ -225,7 +225,10 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			return a, util.ReportWarn("Agent is busy, please wait...")
 		}
 
-		config.Get().UpdatePreferredModel(msg.ModelType, msg.Model)
+		cfg := config.Get()
+		if err := cfg.UpdatePreferredModel(msg.ModelType, msg.Model); err != nil {
+			return a, util.ReportError(err)
+		}
 
 		go a.app.UpdateAgentModel(context.TODO())