From 4127f4fa8f81b9573652b80261b8a27ab54eccb2 Mon Sep 17 00:00:00 2001 From: Amolith Date: Sun, 2 Nov 2025 12:28:17 -0700 Subject: [PATCH] feat(recently-used): track recently used models 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 --- internal/config/config.go | 48 +++++++++++++ internal/config/load.go | 3 + .../tui/components/dialogs/models/list.go | 67 ++++++++++++++++--- internal/tui/tui.go | 5 +- 4 files changed, 112 insertions(+), 11 deletions(-) diff --git a/internal/config/config.go b/internal/config/config.go index cbb2f20bcb2090063a3520548020f04d839d2614..fc3b24b9d8ab41ebc86f2a45025d4e34bc0043a1 100644 --- a/internal/config/config.go +++ b/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", diff --git a/internal/config/load.go b/internal/config/load.go index f85f5581ae3ec9546d225ff9eda62d293f149f6b..d8081039fb592d4c301c39a8a2d88bd02ab15d0d 100644 --- a/internal/config/load.go +++ b/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) } diff --git a/internal/tui/components/dialogs/models/list.go b/internal/tui/components/dialogs/models/list.go index c584881480257dd873bc6c64062255d2641f058d..ceb22e653b97d99ecaf66ae52ff921d01762c132 100644 --- a/internal/tui/components/dialogs/models/list.go +++ b/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) diff --git a/internal/tui/tui.go b/internal/tui/tui.go index a803605c9e998ba13dc0667723059cc5ff4a3511..2f48ab26876d65d9d0f7eb60b9dcfb0268fb87dd 100644 --- a/internal/tui/tui.go +++ b/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())