From a1d23022210de344ecb900e33632e3b360c18f1c Mon Sep 17 00:00:00 2001 From: Saatvik Arya Date: Sat, 21 Jun 2025 08:25:40 +0530 Subject: [PATCH 01/15] feat(provider): add lightweight model configuration and retrieval logic --- packages/opencode/src/config/config.ts | 6 +++- packages/opencode/src/provider/provider.ts | 33 ++++++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index d53f4dda337..197bd9bbf5d 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -154,6 +154,10 @@ export namespace Config { "Model to use in the format of provider/model, eg anthropic/claude-2", ) .optional(), + lightweight_model: z + .string() + .describe("Lightweight model to use for tasks like window title generation") + .optional(), provider: z .record( ModelsDev.Provider.partial().extend({ @@ -194,7 +198,7 @@ export namespace Config { ) await fs.unlink(path.join(Global.Path.config, "config")) }) - .catch(() => {}) + .catch(() => { }) return result }) diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 269afa476a1..8f1ee74b13f 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -386,6 +386,39 @@ export namespace Provider { } } + export async function getLightweightModel(providerID: string): Promise<{ info: ModelsDev.Model; language: LanguageModel } | null> { + const cfg = await Config.get() + + // Check user override + if (cfg.lightweight_model) { + try { + // Parse the lightweight model to get its provider + const { providerID: lightweightProviderID, modelID } = parseModel(cfg.lightweight_model) + return await getModel(lightweightProviderID, modelID) + } catch (e) { + log.warn("Failed to get configured lightweight model", { lightweight_model: cfg.lightweight_model, error: e }) + } + } + + // Default lightweight models by provider + const defaults: Record = { + 'anthropic': 'claude-3-5-haiku-20241022', + 'openai': 'gpt-4o-mini', + 'google': 'gemini-2.5-flash-preview-05-20' + } + + if (defaults[providerID]) { + try { + return await getModel(providerID, defaults[providerID]) + } catch (e) { + log.warn("Failed to get default lightweight model", { providerID, model: defaults[providerID], error: e }) + } + } + + // No lightweight model available + return null + } + const TOOLS = [ BashTool, EditTool, From 02a75e61e380f9317c972a489f781fe55eccc517 Mon Sep 17 00:00:00 2001 From: Saatvik Arya Date: Sat, 21 Jun 2025 08:26:30 +0530 Subject: [PATCH 02/15] tui: generate api client --- packages/tui/pkg/client/gen/openapi.json | 4 ++++ packages/tui/pkg/client/generated-client.go | 5 ++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/tui/pkg/client/gen/openapi.json b/packages/tui/pkg/client/gen/openapi.json index e804accf33c..1e3b85496e2 100644 --- a/packages/tui/pkg/client/gen/openapi.json +++ b/packages/tui/pkg/client/gen/openapi.json @@ -1524,6 +1524,10 @@ "type": "string", "description": "Model to use in the format of provider/model, eg anthropic/claude-2" }, + "lightweight_model": { + "type": "string", + "description": "Lightweight model to use for tasks like window title generation" + }, "provider": { "type": "object", "additionalProperties": { diff --git a/packages/tui/pkg/client/generated-client.go b/packages/tui/pkg/client/generated-client.go index 4ef9b77e3cd..980f8423223 100644 --- a/packages/tui/pkg/client/generated-client.go +++ b/packages/tui/pkg/client/generated-client.go @@ -54,6 +54,9 @@ type ConfigInfo struct { DisabledProviders *[]string `json:"disabled_providers,omitempty"` Keybinds *ConfigKeybinds `json:"keybinds,omitempty"` + // LightweightModel Lightweight model to use for tasks like window title generation + LightweightModel *string `json:"lightweight_model,omitempty"` + // Mcp MCP (Model Context Protocol) server configurations Mcp *map[string]ConfigInfo_Mcp_AdditionalProperties `json:"mcp,omitempty"` @@ -587,7 +590,7 @@ type PostSessionSummarizeJSONRequestBody PostSessionSummarizeJSONBody // PostSessionUnshareJSONRequestBody defines body for PostSessionUnshare for application/json ContentType. type PostSessionUnshareJSONRequestBody PostSessionUnshareJSONBody -// Getter for additional properties for MessageMetadata_Tool_AdditionalProperties. Returns the specified +// Getter for additional properties for MessageInfo_Metadata_Tool_AdditionalProperties. Returns the specified // element and whether it was found func (a MessageMetadata_Tool_AdditionalProperties) Get(fieldName string) (value interface{}, found bool) { if a.AdditionalProperties != nil { From e2a43a77d6d28959c7a1542f9c0490648df87552 Mon Sep 17 00:00:00 2001 From: Saatvik Arya Date: Sat, 21 Jun 2025 15:28:10 +0530 Subject: [PATCH 03/15] feat(provider): enhance model selection with lightweight model support and UI updates --- packages/opencode/src/provider/provider.ts | 32 +- packages/tui/internal/app/app.go | 103 +++-- .../tui/internal/components/chat/editor.go | 15 +- .../tui/internal/components/dialog/models.go | 404 ++++++++++++++---- .../tui/internal/components/status/status.go | 2 +- packages/tui/internal/config/config.go | 8 +- packages/tui/internal/tui/tui.go | 14 +- packages/tui/internal/util/util.go | 13 + packages/tui/pkg/client/generated-client.go | 2 +- 9 files changed, 445 insertions(+), 148 deletions(-) diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 8f1ee74b13f..40708629b5f 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -400,23 +400,25 @@ export namespace Provider { } } - // Default lightweight models by provider - const defaults: Record = { - 'anthropic': 'claude-3-5-haiku-20241022', - 'openai': 'gpt-4o-mini', - 'google': 'gemini-2.5-flash-preview-05-20' - } - - if (defaults[providerID]) { - try { - return await getModel(providerID, defaults[providerID]) - } catch (e) { - log.warn("Failed to get default lightweight model", { providerID, model: defaults[providerID], error: e }) + const providers = await list() + const provider = providers[providerID] + if (!provider) return null + + // Select cheapest model whose cost.output <= 4 + let selected: { info: ModelsDev.Model; language: LanguageModel } | null = null + for (const model of Object.values(provider.info.models)) { + if (model.cost.output <= 4) { + try { + const m = await getModel(providerID, model.id) + if (!selected || m.info.cost.output < selected.info.cost.output) { + selected = m + } + } catch { + // ignore errors and continue searching + } } } - - // No lightweight model available - return null + return selected } const TOOLS = [ diff --git a/packages/tui/internal/app/app.go b/packages/tui/internal/app/app.go index 4c156b68dc9..ce1ee15fe50 100644 --- a/packages/tui/internal/app/app.go +++ b/packages/tui/internal/app/app.go @@ -5,7 +5,6 @@ import ( "fmt" "path/filepath" "sort" - "strings" "time" "log/slog" @@ -22,24 +21,29 @@ import ( var RootPath string type App struct { - Info client.AppInfo - Version string - StatePath string - Config *client.ConfigInfo - Client *client.ClientWithResponses - State *config.State - Provider *client.ProviderInfo - Model *client.ModelInfo - Session *client.SessionInfo - Messages []client.MessageInfo - Commands commands.CommandRegistry + Info client.AppInfo + Version string + StatePath string + Config *client.ConfigInfo + Client *client.ClientWithResponses + State *config.State + MainProvider *client.ProviderInfo + MainModel *client.ModelInfo + LightProvider *client.ProviderInfo + LightModel *client.ModelInfo + Session *client.SessionInfo + Messages []client.MessageInfo + Commands commands.CommandRegistry } type SessionSelectedMsg = *client.SessionInfo type ModelSelectedMsg struct { - Provider client.ProviderInfo - Model client.ModelInfo + MainProvider client.ProviderInfo + MainModel client.ModelInfo + LightweightProvider client.ProviderInfo + LightweightModel client.ModelInfo } + type SessionClearedMsg struct{} type CompactSessionMsg struct{} type SendMsg struct { @@ -88,9 +92,10 @@ func New( appState.Theme = *configInfo.Theme } if configInfo.Model != nil { - splits := strings.Split(*configInfo.Model, "/") - appState.Provider = splits[0] - appState.Model = strings.Join(splits[1:], "/") + appState.MainProvider, appState.MainModel = util.ParseModel(*configInfo.Model) + } + if configInfo.LightweightModel != nil { + appState.LightProvider, appState.LightModel = util.ParseModel(*configInfo.LightweightModel) } // Load themes from all directories @@ -167,11 +172,11 @@ func (a *App) InitializeProvider() tea.Cmd { var currentProvider *client.ProviderInfo var currentModel *client.ModelInfo for _, provider := range providers { - if provider.Id == a.State.Provider { + if provider.Id == a.State.MainProvider { currentProvider = &provider for _, model := range provider.Models { - if model.Id == a.State.Model { + if model.Id == a.State.MainModel { currentModel = &model } } @@ -182,10 +187,40 @@ func (a *App) InitializeProvider() tea.Cmd { currentModel = defaultModel } + // Initialize lightweight model based on config or defaults + lightProvider := currentProvider + lightModel := currentModel + + if a.State.LightProvider != "" && a.State.LightModel != "" { + lightProviderID, lightModelID := a.State.LightProvider, a.State.LightModel + // Find provider/model + for _, provider := range providers { + if provider.Id == lightProviderID { + lightProvider = &provider + for _, model := range provider.Models { + if model.Id == lightModelID { + lightModel = &model + break + } + } + break + } + } + } else { + // Try to find a default lightweight model for the provider + lightModel = getDefaultLightweightModel(*currentProvider) + if lightModel == nil { + // Fall back to the main model + lightModel = currentModel + } + } + // TODO: handle no provider or model setup, yet return ModelSelectedMsg{ - Provider: *currentProvider, - Model: *currentModel, + MainProvider: *currentProvider, + MainModel: *currentModel, + LightweightProvider: *lightProvider, + LightweightModel: *lightModel, } } } @@ -202,6 +237,20 @@ func getDefaultModel(response *client.PostProviderListResponse, provider client. return nil } +func getDefaultLightweightModel(provider client.ProviderInfo) *client.ModelInfo { + // Select the cheapest model whose Cost.Output <= 4 + var selected *client.ModelInfo + for _, model := range provider.Models { + if model.Cost.Output <= 4 { + if selected == nil || model.Cost.Output < selected.Cost.Output { + tmp := model // create copy to take address of loop variable safely + selected = &tmp + } + } + } + return selected +} + type Attachment struct { FilePath string FileName string @@ -240,8 +289,8 @@ func (a *App) InitializeProject(ctx context.Context) tea.Cmd { go func() { response, err := a.Client.PostSessionInitialize(ctx, client.PostSessionInitializeJSONRequestBody{ SessionID: a.Session.Id, - ProviderID: a.Provider.Id, - ModelID: a.Model.Id, + ProviderID: a.MainProvider.Id, + ModelID: a.MainModel.Id, }) if err != nil { slog.Error("Failed to initialize project", "error", err) @@ -260,8 +309,8 @@ func (a *App) CompactSession(ctx context.Context) tea.Cmd { go func() { response, err := a.Client.PostSessionSummarizeWithResponse(ctx, client.PostSessionSummarizeJSONRequestBody{ SessionID: a.Session.Id, - ProviderID: a.Provider.Id, - ModelID: a.Model.Id, + ProviderID: a.MainProvider.Id, + ModelID: a.MainModel.Id, }) if err != nil { slog.Error("Failed to compact session", "error", err) @@ -338,8 +387,8 @@ func (a *App) SendChatMessage(ctx context.Context, text string, attachments []At response, err := a.Client.PostSessionChat(ctx, client.PostSessionChatJSONRequestBody{ SessionID: a.Session.Id, Parts: parts, - ProviderID: a.Provider.Id, - ModelID: a.Model.Id, + ProviderID: a.MainProvider.Id, + ModelID: a.MainModel.Id, }) if err != nil { errormsg := fmt.Sprintf("failed to send message: %v", err) diff --git a/packages/tui/internal/components/chat/editor.go b/packages/tui/internal/components/chat/editor.go index d67a226fec5..d32b71d7cb9 100644 --- a/packages/tui/internal/components/chat/editor.go +++ b/packages/tui/internal/components/chat/editor.go @@ -128,8 +128,19 @@ func (m *editorComponent) Content() string { } model := "" - if m.app.Model != nil { - model = muted(m.app.Provider.Name) + base(" "+m.app.Model.Name) + if m.app.MainModel != nil { + model = muted(m.app.MainProvider.Name) + base(" "+m.app.MainModel.Name) + + // show lightweight model if configured + if m.app.LightModel != nil { + if m.app.LightProvider != nil && m.app.LightProvider.Id == m.app.MainProvider.Id { + // Same provider – show friendly name + model = model + muted(" (⚡"+m.app.LightModel.Name+")") + } else { + // Different provider – show model ID + model = model + muted(" (⚡"+m.app.LightModel.Id+")") + } + } } space := m.width - 2 - lipgloss.Width(model) - lipgloss.Width(hint) diff --git a/packages/tui/internal/components/dialog/models.go b/packages/tui/internal/components/dialog/models.go index 5da3c9eef47..5ba4c92922d 100644 --- a/packages/tui/internal/components/dialog/models.go +++ b/packages/tui/internal/components/dialog/models.go @@ -2,8 +2,6 @@ package dialog import ( "context" - "fmt" - "maps" "slices" "strings" @@ -14,15 +12,24 @@ import ( "github.com/sst/opencode/internal/components/list" "github.com/sst/opencode/internal/components/modal" "github.com/sst/opencode/internal/layout" - "github.com/sst/opencode/internal/styles" + "github.com/sst/opencode/internal/theme" "github.com/sst/opencode/internal/util" "github.com/sst/opencode/pkg/client" ) const ( - numVisibleModels = 6 - maxDialogWidth = 40 + numVisibleModels = 10 + paneWidth = 40 + totalDialogWidth = paneWidth*2 + 3 // 2 panes + divider + maxDialogWidth = 60 +) + +type ActivePane int + +const ( + MainModelPane ActivePane = iota + LightweightModelPane ) // ModelDialog interface for the model selection dialog @@ -33,18 +40,30 @@ type ModelDialog interface { type modelDialog struct { app *app.App availableProviders []client.ProviderInfo - provider client.ProviderInfo - width int - height int - hScrollOffset int - hScrollPossible bool - modal *modal.Modal - modelList list.List[list.StringItem] + + // Main model selection + mainProvider client.ProviderInfo + mainModelList list.List[list.StringItem] + mainHScrollOffset int + + // Lightweight model selection + lightProvider client.ProviderInfo + lightModelList list.List[list.StringItem] + lightHScrollOffset int + + // UI state + activePane ActivePane + width int + height int + hScrollPossible bool + + modal *modal.Modal } type modelKeyMap struct { Left key.Binding Right key.Binding + Tab key.Binding Enter key.Binding Escape key.Binding } @@ -52,24 +71,29 @@ type modelKeyMap struct { var modelKeys = modelKeyMap{ Left: key.NewBinding( key.WithKeys("left", "h"), - key.WithHelp("←", "scroll left"), + key.WithHelp("←", "previous provider"), ), Right: key.NewBinding( key.WithKeys("right", "l"), - key.WithHelp("→", "scroll right"), + key.WithHelp("→", "next provider"), + ), + Tab: key.NewBinding( + key.WithKeys("tab"), + key.WithHelp("tab", "switch pane"), ), Enter: key.NewBinding( key.WithKeys("enter"), - key.WithHelp("enter", "select model"), + key.WithHelp("enter", "save selection"), ), Escape: key.NewBinding( - key.WithKeys("esc"), - key.WithHelp("esc", "close"), + key.WithKeys("escape"), + key.WithHelp("escape", "cancel"), ), } func (m *modelDialog) Init() tea.Cmd { - m.setupModelsForProvider(m.provider.Id) + m.setupModelsForProvider(m.mainProvider.Id, MainModelPane) + m.setupModelsForProvider(m.lightProvider.Id, LightweightModelPane) return nil } @@ -87,22 +111,48 @@ func (m *modelDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.switchProvider(1) } return m, nil + case key.Matches(msg, modelKeys.Tab): + // Switch between main and lightweight model panes + if m.activePane == MainModelPane { + m.activePane = LightweightModelPane + } else { + m.activePane = MainModelPane + } + return m, nil case key.Matches(msg, modelKeys.Enter): - selectedItem, _ := m.modelList.GetSelectedItem() - models := m.models() - var selectedModel client.ModelInfo - for _, model := range models { - if model.Name == string(selectedItem) { - selectedModel = model + // Get selected models from both panes + mainSelectedItem, _ := m.mainModelList.GetSelectedItem() + lightSelectedItem, _ := m.lightModelList.GetSelectedItem() + + mainModels := m.modelsForProvider(m.mainProvider) + lightModels := m.modelsForProvider(m.lightProvider) + + var mainSelectedModel, lightSelectedModel client.ModelInfo + + // Find main model + for _, model := range mainModels { + if model.Name == string(mainSelectedItem) { + mainSelectedModel = model break } } + + // Find lightweight model + for _, model := range lightModels { + if model.Name == string(lightSelectedItem) { + lightSelectedModel = model + break + } + } + return m, tea.Sequence( util.CmdHandler(modal.CloseModalMsg{}), util.CmdHandler( app.ModelSelectedMsg{ - Provider: m.provider, - Model: selectedModel, + MainProvider: m.mainProvider, + MainModel: mainSelectedModel, + LightweightProvider: m.lightProvider, + LightweightModel: lightSelectedModel, }), ) case key.Matches(msg, modelKeys.Escape): @@ -113,113 +163,277 @@ func (m *modelDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.height = msg.Height } - // Update the list component - updatedList, cmd := m.modelList.Update(msg) - m.modelList = updatedList.(list.List[list.StringItem]) - return m, cmd + // Update the active list component + if m.activePane == MainModelPane { + updatedList, cmd := m.mainModelList.Update(msg) + m.mainModelList = updatedList.(list.List[list.StringItem]) + return m, cmd + } else { + updatedList, cmd := m.lightModelList.Update(msg) + m.lightModelList = updatedList.(list.List[list.StringItem]) + return m, cmd + } } -func (m *modelDialog) models() []client.ModelInfo { - models := slices.SortedFunc(maps.Values(m.provider.Models), func(a, b client.ModelInfo) int { +func (m *modelDialog) modelsForProvider(provider client.ProviderInfo) []client.ModelInfo { + var models []client.ModelInfo + for _, model := range provider.Models { + models = append(models, model) + } + slices.SortFunc(models, func(a, b client.ModelInfo) int { return strings.Compare(a.Name, b.Name) }) return models } func (m *modelDialog) switchProvider(offset int) { - newOffset := m.hScrollOffset + offset - - if newOffset < 0 { - newOffset = len(m.availableProviders) - 1 - } - if newOffset >= len(m.availableProviders) { - newOffset = 0 + if m.activePane == MainModelPane { + newOffset := m.mainHScrollOffset + offset + if newOffset < 0 { + newOffset = len(m.availableProviders) - 1 + } else if newOffset >= len(m.availableProviders) { + newOffset = 0 + } + m.mainHScrollOffset = newOffset + m.mainProvider = m.availableProviders[newOffset] + m.setupModelsForProvider(m.mainProvider.Id, MainModelPane) + } else { + newOffset := m.lightHScrollOffset + offset + if newOffset < 0 { + newOffset = len(m.availableProviders) - 1 + } else if newOffset >= len(m.availableProviders) { + newOffset = 0 + } + m.lightHScrollOffset = newOffset + m.lightProvider = m.availableProviders[newOffset] + m.setupModelsForProvider(m.lightProvider.Id, LightweightModelPane) } - - m.hScrollOffset = newOffset - m.provider = m.availableProviders[m.hScrollOffset] - m.modal.SetTitle(fmt.Sprintf("Select %s Model", m.provider.Name)) - m.setupModelsForProvider(m.provider.Id) } -func (m *modelDialog) View() string { - listView := m.modelList.View() - scrollIndicator := m.getScrollIndicators(maxDialogWidth) - return strings.Join([]string{listView, scrollIndicator}, "\n") -} - -func (m *modelDialog) getScrollIndicators(maxWidth int) string { - var indicator string - if m.hScrollPossible { - indicator = "← → (switch provider) " - } - if indicator == "" { - return "" +func (m *modelDialog) setupModelsForProvider(providerId string, pane ActivePane) { + var provider client.ProviderInfo + for _, p := range m.availableProviders { + if p.Id == providerId { + provider = p + break + } } - - t := theme.CurrentTheme() - return styles.BaseStyle(). - Foreground(t.TextMuted()). - Width(maxWidth). - Align(lipgloss.Right). - Render(indicator) -} - -func (m *modelDialog) setupModelsForProvider(providerId string) { - models := m.models() + + models := m.modelsForProvider(provider) modelNames := make([]string, len(models)) for i, model := range models { modelNames[i] = model.Name } - m.modelList = list.NewStringList(modelNames, numVisibleModels, "No models available", true) - m.modelList.SetMaxWidth(maxDialogWidth) - - if m.app.Provider != nil && m.app.Model != nil && m.app.Provider.Id == providerId { - for i, model := range models { - if model.Id == m.app.Model.Id { - m.modelList.SetSelectedIndex(i) - break + newList := list.NewStringList(modelNames, numVisibleModels, "No models available", true) + newList.SetMaxWidth(paneWidth - 2) + + if pane == MainModelPane { + m.mainModelList = newList + m.mainProvider = provider + + // Try to select the current model if it exists + if m.app.MainModel != nil { + for _, model := range models { + if model.Id == m.app.MainModel.Id { + // The list component doesn't expose SetSelectedIdx, so we'll rely on it being set during creation + break + } + } + } + } else { + m.lightModelList = newList + m.lightProvider = provider + + // Try to select the current lightweight model if it exists + if m.app.LightModel != nil { + for _, model := range models { + if model.Id == m.app.LightModel.Id { + // The list component doesn't expose SetSelectedIdx, so we'll rely on it being set during creation + break + } } } } } func (m *modelDialog) Render(background string) string { - return m.modal.Render(m.View(), background) + if m.modal != nil { + var mainPane, lightPane string + + // Main model pane + mainPaneStyle := lipgloss.NewStyle(). + Width(paneWidth). + Height(m.height - 10). + Padding(1). + Border(lipgloss.RoundedBorder()) + + t := theme.CurrentTheme() + if m.activePane == MainModelPane { + mainPaneStyle = mainPaneStyle.BorderForeground(t.Primary()) + } else { + mainPaneStyle = mainPaneStyle.BorderForeground(t.Border()) + } + + mainTitle := lipgloss.NewStyle(). + Bold(true). + Foreground(t.Primary()). + Render("Main Model") + + mainProviderName := lipgloss.NewStyle(). + Foreground(t.Secondary()). + Render(m.mainProvider.Name) + + mainPane = mainPaneStyle.Render( + lipgloss.JoinVertical(lipgloss.Left, + mainTitle, + mainProviderName, + "", + m.mainModelList.View(), + ), + ) + + // Lightweight model pane + lightPaneStyle := lipgloss.NewStyle(). + Width(paneWidth). + Height(m.height - 10). + Padding(1). + Border(lipgloss.RoundedBorder()) + + if m.activePane == LightweightModelPane { + lightPaneStyle = lightPaneStyle.BorderForeground(t.Primary()) + } else { + lightPaneStyle = lightPaneStyle.BorderForeground(t.Border()) + } + + lightTitle := lipgloss.NewStyle(). + Bold(true). + Foreground(t.Primary()). + Render("Lightweight Model") + + lightProviderName := lipgloss.NewStyle(). + Foreground(t.Secondary()). + Render(m.lightProvider.Name) + + lightPane = lightPaneStyle.Render( + lipgloss.JoinVertical(lipgloss.Left, + lightTitle, + lightProviderName, + "", + m.lightModelList.View(), + ), + ) + + // Combine panes + content := lipgloss.JoinHorizontal(lipgloss.Top, mainPane, " ", lightPane) + + // Add help text + helpText := lipgloss.NewStyle(). + Foreground(t.Secondary()). + Render("tab: switch pane • ←/→: change provider • ↑/↓: select model • enter: save • esc: cancel") + + fullContent := lipgloss.JoinVertical(lipgloss.Center, content, "", helpText) + + return m.modal.Render(fullContent, background) + } + return "" } -func (s *modelDialog) Close() tea.Cmd { - return nil +func (m *modelDialog) View() string { + return m.Render("") } +func (m *modelDialog) IsVisible() bool { + return m.modal != nil +} + +func (m *modelDialog) Close() tea.Cmd { + return util.CmdHandler(modal.CloseModalMsg{}) +} + +// NewModelDialog creates a new model selection dialog func NewModelDialog(app *app.App) ModelDialog { - availableProviders, _ := app.ListProviders(context.Background()) - - currentProvider := availableProviders[0] - hScrollOffset := 0 - if app.Provider != nil { - for i, provider := range availableProviders { - if provider.Id == app.Provider.Id { - currentProvider = provider - hScrollOffset = i + availableProviders := getEnabledProviders(app) + + // Set up main model provider + mainProvider := availableProviders[0] + if app.MainProvider != nil { + for _, p := range availableProviders { + if p.Id == app.MainProvider.Id { + mainProvider = p break } } } - + + // Set up lightweight model provider (default to same as main if not set) + lightProvider := mainProvider + if app.LightProvider != nil { + for _, p := range availableProviders { + if p.Id == app.LightProvider.Id { + lightProvider = p + break + } + } + } + dialog := &modelDialog{ app: app, availableProviders: availableProviders, - hScrollOffset: hScrollOffset, + mainProvider: mainProvider, + lightProvider: lightProvider, hScrollPossible: len(availableProviders) > 1, - provider: currentProvider, + activePane: MainModelPane, modal: modal.New( - modal.WithTitle(fmt.Sprintf("Select %s Model", currentProvider.Name)), - modal.WithMaxWidth(maxDialogWidth+4), + modal.WithTitle("Select Models"), + modal.WithMaxWidth(totalDialogWidth+4), ), } - - dialog.setupModelsForProvider(currentProvider.Id) + + // Find initial scroll offsets + for i, p := range availableProviders { + if p.Id == mainProvider.Id { + dialog.mainHScrollOffset = i + } + if p.Id == lightProvider.Id { + dialog.lightHScrollOffset = i + } + } + return dialog } + +func getEnabledProviders(app *app.App) []client.ProviderInfo { + // Get providers from the API + ctx := context.Background() + providersResponse, err := app.Client.PostProviderListWithResponse(ctx) + if err != nil || providersResponse == nil || providersResponse.StatusCode() != 200 { + // Return empty list if we can't get providers + return []client.ProviderInfo{} + } + + var enabledProviders []client.ProviderInfo + + // Get all providers that have models + for _, provider := range providersResponse.JSON200.Providers { + if len(provider.Models) > 0 { + enabledProviders = append(enabledProviders, provider) + } + } + + // Sort providers by name + slices.SortFunc(enabledProviders, func(a, b client.ProviderInfo) int { + return strings.Compare(a.Name, b.Name) + }) + + return enabledProviders +} + +// UpdateModelContext updates the context with selected models +func UpdateModelContext(ctx context.Context, mainProvider client.ProviderInfo, mainModel client.ModelInfo, lightProvider client.ProviderInfo, lightModel client.ModelInfo) context.Context { + ctx = context.WithValue(ctx, "main_provider", mainProvider) + ctx = context.WithValue(ctx, "main_model", mainModel) + ctx = context.WithValue(ctx, "light_provider", lightProvider) + ctx = context.WithValue(ctx, "light_model", lightModel) + return ctx +} \ No newline at end of file diff --git a/packages/tui/internal/components/status/status.go b/packages/tui/internal/components/status/status.go index 62d20708862..0aa83a6898a 100644 --- a/packages/tui/internal/components/status/status.go +++ b/packages/tui/internal/components/status/status.go @@ -95,7 +95,7 @@ func (m statusComponent) View() string { if m.app.Session.Id != "" { tokens := float32(0) cost := float32(0) - contextWindow := m.app.Model.Limit.Context + contextWindow := m.app.MainModel.Limit.Context for _, message := range m.app.Messages { if message.Metadata.Assistant != nil { diff --git a/packages/tui/internal/config/config.go b/packages/tui/internal/config/config.go index 29db8657e2d..9b257d2d5d1 100644 --- a/packages/tui/internal/config/config.go +++ b/packages/tui/internal/config/config.go @@ -11,9 +11,11 @@ import ( ) type State struct { - Theme string `toml:"theme"` - Provider string `toml:"provider"` - Model string `toml:"model"` + Theme string `toml:"theme"` + MainProvider string `toml:"main_provider"` + MainModel string `toml:"main_model"` + LightProvider string `toml:"light_provider"` + LightModel string `toml:"light_model"` } func NewState() *State { diff --git a/packages/tui/internal/tui/tui.go b/packages/tui/internal/tui/tui.go index 503af9feeb2..446ae25bce4 100644 --- a/packages/tui/internal/tui/tui.go +++ b/packages/tui/internal/tui/tui.go @@ -333,10 +333,16 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { a.app.Session = msg a.app.Messages = messages case app.ModelSelectedMsg: - a.app.Provider = &msg.Provider - a.app.Model = &msg.Model - a.app.State.Provider = msg.Provider.Id - a.app.State.Model = msg.Model.Id + a.app.MainProvider = &msg.MainProvider + a.app.MainModel = &msg.MainModel + a.app.LightProvider = &msg.LightweightProvider + a.app.LightModel = &msg.LightweightModel + a.app.State.MainProvider = msg.MainProvider.Id + a.app.State.MainModel = msg.MainModel.Id + a.app.State.LightProvider = msg.LightweightProvider.Id + a.app.State.LightModel = msg.LightweightModel.Id + + // Save state and config a.app.SaveState() case dialog.ThemeSelectedMsg: a.app.State.Theme = msg.ThemeName diff --git a/packages/tui/internal/util/util.go b/packages/tui/internal/util/util.go index c7fd98a8cc3..7dff3ac7b6b 100644 --- a/packages/tui/internal/util/util.go +++ b/packages/tui/internal/util/util.go @@ -35,3 +35,16 @@ func IsWsl() bool { return false } + +func ParseModel(model string) (providerID, modelID string) { + parts := strings.Split(model, "/") + if len(parts) == 0 { + return "", "" + } + + providerID = parts[0] + if len(parts) > 1 { + modelID = strings.Join(parts[1:], "/") + } + return +} diff --git a/packages/tui/pkg/client/generated-client.go b/packages/tui/pkg/client/generated-client.go index 980f8423223..daf9b10c3e7 100644 --- a/packages/tui/pkg/client/generated-client.go +++ b/packages/tui/pkg/client/generated-client.go @@ -590,7 +590,7 @@ type PostSessionSummarizeJSONRequestBody PostSessionSummarizeJSONBody // PostSessionUnshareJSONRequestBody defines body for PostSessionUnshare for application/json ContentType. type PostSessionUnshareJSONRequestBody PostSessionUnshareJSONBody -// Getter for additional properties for MessageInfo_Metadata_Tool_AdditionalProperties. Returns the specified +// Getter for additional properties for MessageMetadata_Tool_AdditionalProperties. Returns the specified // element and whether it was found func (a MessageMetadata_Tool_AdditionalProperties) Get(fieldName string) (value interface{}, found bool) { if a.AdditionalProperties != nil { From 53d3873682b1b6e8b0dfa5108292d4debe7b97a9 Mon Sep 17 00:00:00 2001 From: Saatvik Arya Date: Sat, 21 Jun 2025 15:34:56 +0530 Subject: [PATCH 04/15] fix(tui): update inactive selection styling to use accent color --- .../tui/internal/components/dialog/models.go | 535 +++++++++++------- 1 file changed, 339 insertions(+), 196 deletions(-) diff --git a/packages/tui/internal/components/dialog/models.go b/packages/tui/internal/components/dialog/models.go index 5ba4c92922d..e950c987f9a 100644 --- a/packages/tui/internal/components/dialog/models.go +++ b/packages/tui/internal/components/dialog/models.go @@ -2,6 +2,7 @@ package dialog import ( "context" + "fmt" "slices" "strings" @@ -9,10 +10,8 @@ import ( tea "github.com/charmbracelet/bubbletea/v2" "github.com/charmbracelet/lipgloss/v2" "github.com/sst/opencode/internal/app" - "github.com/sst/opencode/internal/components/list" "github.com/sst/opencode/internal/components/modal" "github.com/sst/opencode/internal/layout" - "github.com/sst/opencode/internal/theme" "github.com/sst/opencode/internal/util" "github.com/sst/opencode/pkg/client" @@ -22,7 +21,6 @@ const ( numVisibleModels = 10 paneWidth = 40 totalDialogWidth = paneWidth*2 + 3 // 2 panes + divider - maxDialogWidth = 60 ) type ActivePane int @@ -40,27 +38,29 @@ type ModelDialog interface { type modelDialog struct { app *app.App availableProviders []client.ProviderInfo - + // Main model selection - mainProvider client.ProviderInfo - mainModelList list.List[list.StringItem] - mainHScrollOffset int - + mainProvider client.ProviderInfo + mainSelectedIdx int + mainScrollOffset int + // Lightweight model selection - lightProvider client.ProviderInfo - lightModelList list.List[list.StringItem] - lightHScrollOffset int - + lightProvider client.ProviderInfo + lightSelectedIdx int + lightScrollOffset int + // UI state activePane ActivePane width int height int hScrollPossible bool - + modal *modal.Modal } type modelKeyMap struct { + Up key.Binding + Down key.Binding Left key.Binding Right key.Binding Tab key.Binding @@ -69,6 +69,14 @@ type modelKeyMap struct { } var modelKeys = modelKeyMap{ + Up: key.NewBinding( + key.WithKeys("up", "k"), + key.WithHelp("↑", "previous model"), + ), + Down: key.NewBinding( + key.WithKeys("down", "j"), + key.WithHelp("↓", "next model"), + ), Left: key.NewBinding( key.WithKeys("left", "h"), key.WithHelp("←", "previous provider"), @@ -92,8 +100,6 @@ var modelKeys = modelKeyMap{ } func (m *modelDialog) Init() tea.Cmd { - m.setupModelsForProvider(m.mainProvider.Id, MainModelPane) - m.setupModelsForProvider(m.lightProvider.Id, LightweightModelPane) return nil } @@ -101,49 +107,27 @@ func (m *modelDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyMsg: switch { + case key.Matches(msg, modelKeys.Up): + m.moveSelectionUp() + case key.Matches(msg, modelKeys.Down): + m.moveSelectionDown() case key.Matches(msg, modelKeys.Left): if m.hScrollPossible { m.switchProvider(-1) } - return m, nil case key.Matches(msg, modelKeys.Right): if m.hScrollPossible { m.switchProvider(1) } - return m, nil case key.Matches(msg, modelKeys.Tab): - // Switch between main and lightweight model panes - if m.activePane == MainModelPane { - m.activePane = LightweightModelPane - } else { - m.activePane = MainModelPane - } - return m, nil + m.switchPane() case key.Matches(msg, modelKeys.Enter): // Get selected models from both panes - mainSelectedItem, _ := m.mainModelList.GetSelectedItem() - lightSelectedItem, _ := m.lightModelList.GetSelectedItem() - - mainModels := m.modelsForProvider(m.mainProvider) - lightModels := m.modelsForProvider(m.lightProvider) - - var mainSelectedModel, lightSelectedModel client.ModelInfo - - // Find main model - for _, model := range mainModels { - if model.Name == string(mainSelectedItem) { - mainSelectedModel = model - break - } - } + mainModels := m.getModelsForProvider(m.mainProvider) + lightModels := m.getModelsForProvider(m.lightProvider) - // Find lightweight model - for _, model := range lightModels { - if model.Name == string(lightSelectedItem) { - lightSelectedModel = model - break - } - } + mainSelectedModel := mainModels[m.mainSelectedIdx] + lightSelectedModel := lightModels[m.lightSelectedIdx] return m, tea.Sequence( util.CmdHandler(modal.CloseModalMsg{}), @@ -163,19 +147,10 @@ func (m *modelDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.height = msg.Height } - // Update the active list component - if m.activePane == MainModelPane { - updatedList, cmd := m.mainModelList.Update(msg) - m.mainModelList = updatedList.(list.List[list.StringItem]) - return m, cmd - } else { - updatedList, cmd := m.lightModelList.Update(msg) - m.lightModelList = updatedList.(list.List[list.StringItem]) - return m, cmd - } + return m, nil } -func (m *modelDialog) modelsForProvider(provider client.ProviderInfo) []client.ModelInfo { +func (m *modelDialog) getModelsForProvider(provider client.ProviderInfo) []client.ModelInfo { var models []client.ModelInfo for _, model := range provider.Models { models = append(models, model) @@ -186,161 +161,311 @@ func (m *modelDialog) modelsForProvider(provider client.ProviderInfo) []client.M return models } -func (m *modelDialog) switchProvider(offset int) { +func (m *modelDialog) moveSelectionUp() { if m.activePane == MainModelPane { - newOffset := m.mainHScrollOffset + offset - if newOffset < 0 { - newOffset = len(m.availableProviders) - 1 - } else if newOffset >= len(m.availableProviders) { - newOffset = 0 + models := m.getModelsForProvider(m.mainProvider) + if m.mainSelectedIdx > 0 { + m.mainSelectedIdx-- + } else { + m.mainSelectedIdx = len(models) - 1 + m.mainScrollOffset = max(0, len(models)-numVisibleModels) + } + + // Keep selection visible + if m.mainSelectedIdx < m.mainScrollOffset { + m.mainScrollOffset = m.mainSelectedIdx } - m.mainHScrollOffset = newOffset - m.mainProvider = m.availableProviders[newOffset] - m.setupModelsForProvider(m.mainProvider.Id, MainModelPane) } else { - newOffset := m.lightHScrollOffset + offset - if newOffset < 0 { - newOffset = len(m.availableProviders) - 1 - } else if newOffset >= len(m.availableProviders) { - newOffset = 0 + models := m.getModelsForProvider(m.lightProvider) + if m.lightSelectedIdx > 0 { + m.lightSelectedIdx-- + } else { + m.lightSelectedIdx = len(models) - 1 + m.lightScrollOffset = max(0, len(models)-numVisibleModels) + } + + // Keep selection visible + if m.lightSelectedIdx < m.lightScrollOffset { + m.lightScrollOffset = m.lightSelectedIdx } - m.lightHScrollOffset = newOffset - m.lightProvider = m.availableProviders[newOffset] - m.setupModelsForProvider(m.lightProvider.Id, LightweightModelPane) } } -func (m *modelDialog) setupModelsForProvider(providerId string, pane ActivePane) { - var provider client.ProviderInfo - for _, p := range m.availableProviders { - if p.Id == providerId { - provider = p - break +func (m *modelDialog) moveSelectionDown() { + if m.activePane == MainModelPane { + models := m.getModelsForProvider(m.mainProvider) + if m.mainSelectedIdx < len(models)-1 { + m.mainSelectedIdx++ + } else { + m.mainSelectedIdx = 0 + m.mainScrollOffset = 0 + } + + // Keep selection visible + if m.mainSelectedIdx >= m.mainScrollOffset+numVisibleModels { + m.mainScrollOffset = m.mainSelectedIdx - (numVisibleModels - 1) + } + } else { + models := m.getModelsForProvider(m.lightProvider) + if m.lightSelectedIdx < len(models)-1 { + m.lightSelectedIdx++ + } else { + m.lightSelectedIdx = 0 + m.lightScrollOffset = 0 + } + + // Keep selection visible + if m.lightSelectedIdx >= m.lightScrollOffset+numVisibleModels { + m.lightScrollOffset = m.lightSelectedIdx - (numVisibleModels - 1) } } - - models := m.modelsForProvider(provider) - modelNames := make([]string, len(models)) - for i, model := range models { - modelNames[i] = model.Name - } +} - newList := list.NewStringList(modelNames, numVisibleModels, "No models available", true) - newList.SetMaxWidth(paneWidth - 2) - - if pane == MainModelPane { - m.mainModelList = newList - m.mainProvider = provider - - // Try to select the current model if it exists - if m.app.MainModel != nil { - for _, model := range models { - if model.Id == m.app.MainModel.Id { - // The list component doesn't expose SetSelectedIdx, so we'll rely on it being set during creation - break - } +func (m *modelDialog) switchProvider(offset int) { + newIdx := 0 + if m.activePane == MainModelPane { + currentIdx := 0 + for i, p := range m.availableProviders { + if p.Id == m.mainProvider.Id { + currentIdx = i + break } } + newIdx = currentIdx + offset + if newIdx < 0 { + newIdx = len(m.availableProviders) - 1 + } else if newIdx >= len(m.availableProviders) { + newIdx = 0 + } + m.mainProvider = m.availableProviders[newIdx] + m.mainSelectedIdx = 0 + m.mainScrollOffset = 0 + // Update modal title like the original when switching main provider + m.modal.SetTitle(fmt.Sprintf("Select Models - %s", m.mainProvider.Name)) } else { - m.lightModelList = newList - m.lightProvider = provider - - // Try to select the current lightweight model if it exists - if m.app.LightModel != nil { - for _, model := range models { - if model.Id == m.app.LightModel.Id { - // The list component doesn't expose SetSelectedIdx, so we'll rely on it being set during creation - break - } + currentIdx := 0 + for i, p := range m.availableProviders { + if p.Id == m.lightProvider.Id { + currentIdx = i + break } } + newIdx = currentIdx + offset + if newIdx < 0 { + newIdx = len(m.availableProviders) - 1 + } else if newIdx >= len(m.availableProviders) { + newIdx = 0 + } + m.lightProvider = m.availableProviders[newIdx] + m.lightSelectedIdx = 0 + m.lightScrollOffset = 0 } } -func (m *modelDialog) Render(background string) string { - if m.modal != nil { - var mainPane, lightPane string - - // Main model pane - mainPaneStyle := lipgloss.NewStyle(). - Width(paneWidth). - Height(m.height - 10). - Padding(1). - Border(lipgloss.RoundedBorder()) - - t := theme.CurrentTheme() - if m.activePane == MainModelPane { - mainPaneStyle = mainPaneStyle.BorderForeground(t.Primary()) - } else { - mainPaneStyle = mainPaneStyle.BorderForeground(t.Border()) +func (m *modelDialog) switchPane() { + if m.activePane == MainModelPane { + m.activePane = LightweightModelPane + } else { + m.activePane = MainModelPane + } +} + +func (m *modelDialog) View() string { + t := theme.CurrentTheme() + + // Base style for the content + baseStyle := lipgloss.NewStyle(). + Background(t.BackgroundElement()). + Foreground(t.Text()) + + // Render main model pane + mainPane := m.renderPane( + "Main Model", + m.mainProvider, + m.mainSelectedIdx, + m.mainScrollOffset, + m.activePane == MainModelPane, + baseStyle, + ) + + // Render lightweight model pane + lightPane := m.renderPane( + "Lightweight Model", + m.lightProvider, + m.lightSelectedIdx, + m.lightScrollOffset, + m.activePane == LightweightModelPane, + baseStyle, + ) + + // Create divider with background + dividerHeight := 1 + numVisibleModels + 1 // 1 header + models + 1 scroll line + dividerLines := make([]string, dividerHeight) + for i := range dividerLines { + dividerLines[i] = "│" + } + divider := lipgloss.NewStyle(). + Background(t.BackgroundElement()). + Foreground(t.TextMuted()). + Render(strings.Join(dividerLines, "\n")) + + // Join panes horizontally + content := lipgloss.JoinHorizontal( + lipgloss.Top, + mainPane, + divider, + lightPane, + ) + + // Apply background to entire content area + content = baseStyle. + Width(totalDialogWidth). + Height(dividerHeight). + Render(content) + + // Scroll indicators like the original dialog + scrollIndicator := m.getScrollIndicators(totalDialogWidth) + + // Final join with consistent background + if scrollIndicator != "" { + return baseStyle. + Width(totalDialogWidth). + Render(lipgloss.JoinVertical( + lipgloss.Left, + content, + scrollIndicator, + )) + } + + return content +} + +func (m *modelDialog) renderPane(title string, provider client.ProviderInfo, selectedIdx, scrollOffset int, isActive bool, baseStyle lipgloss.Style) string { + t := theme.CurrentTheme() + + // Simple header like in the original dialog + headerText := fmt.Sprintf("%s (%s)", title, provider.Name) + headerStyle := lipgloss.NewStyle(). + Width(paneWidth). + Align(lipgloss.Center). + Bold(true). + Background(t.BackgroundElement()) + + if isActive { + headerStyle = headerStyle.Foreground(t.Primary()) + } else { + headerStyle = headerStyle.Foreground(t.TextMuted()) + } + + headerRendered := headerStyle.Render(headerText) + + // Render models + models := m.getModelsForProvider(provider) + endIdx := min(scrollOffset+numVisibleModels, len(models)) + modelItems := make([]string, 0, endIdx-scrollOffset) + + for i := scrollOffset; i < endIdx; i++ { + model := models[i] + isLightweight := isLightweightModel(model) + + // Build model display name + modelName := model.Name + if isLightweight { + modelName = fmt.Sprintf("⚡ %s", modelName) } - - mainTitle := lipgloss.NewStyle(). - Bold(true). - Foreground(t.Primary()). - Render("Main Model") - - mainProviderName := lipgloss.NewStyle(). - Foreground(t.Secondary()). - Render(m.mainProvider.Name) - - mainPane = mainPaneStyle.Render( - lipgloss.JoinVertical(lipgloss.Left, - mainTitle, - mainProviderName, - "", - m.mainModelList.View(), - ), - ) - - // Lightweight model pane - lightPaneStyle := lipgloss.NewStyle(). + + // Apply styling based on selection and pane state + itemStyle := baseStyle.Width(paneWidth) + if i == selectedIdx { + if isActive { + // Active selection - use primary color like the original dialog + itemStyle = itemStyle. + Background(t.Primary()). + Foreground(t.BackgroundElement()). + Bold(true) + } else { + // Inactive selection - use accent color to show selection + itemStyle = itemStyle. + Background(t.BackgroundElement()). + Foreground(t.Accent()). + Bold(true) + } + } + + modelItems = append(modelItems, itemStyle.Render(modelName)) + } + + // Pad to ensure consistent height + for len(modelItems) < numVisibleModels { + modelItems = append(modelItems, baseStyle.Width(paneWidth).Render(" ")) + } + + // Join all models + modelsRendered := strings.Join(modelItems, "\n") + + // Scroll indicator at bottom + scrollIndicator := "" + if scrollOffset > 0 || endIdx < len(models) { + scrollText := fmt.Sprintf("%d-%d of %d", scrollOffset+1, endIdx, len(models)) + scrollIndicator = baseStyle. Width(paneWidth). - Height(m.height - 10). - Padding(1). - Border(lipgloss.RoundedBorder()) - - if m.activePane == LightweightModelPane { - lightPaneStyle = lightPaneStyle.BorderForeground(t.Primary()) - } else { - lightPaneStyle = lightPaneStyle.BorderForeground(t.Border()) + Align(lipgloss.Center). + Foreground(t.TextMuted()). + Render(scrollText) + } else { + scrollIndicator = baseStyle.Width(paneWidth).Render(" ") + } + + // Combine all parts + return lipgloss.JoinVertical( + lipgloss.Left, + headerRendered, + modelsRendered, + scrollIndicator, + ) +} + +func (m *modelDialog) getScrollIndicators(width int) string { + t := theme.CurrentTheme() + + if !m.hScrollPossible { + return "" + } + + // Show provider navigation hint + return lipgloss.NewStyle(). + Width(width). + Align(lipgloss.Center). + Foreground(t.TextMuted()). + Background(t.BackgroundElement()). + Render("← → to switch providers • Tab to switch panes") +} + +func isLightweightModel(model client.ModelInfo) bool { + // Models that are good for lightweight tasks + lightweightModels := []string{ + "gpt-3.5-turbo", + "gpt-4o-mini", + "claude-3-haiku", + "gemini-1.5-flash", + "llama-3.2", + "deepseek-chat", + } + + modelLower := strings.ToLower(model.Id) + for _, lm := range lightweightModels { + if strings.Contains(modelLower, lm) { + return true } - - lightTitle := lipgloss.NewStyle(). - Bold(true). - Foreground(t.Primary()). - Render("Lightweight Model") - - lightProviderName := lipgloss.NewStyle(). - Foreground(t.Secondary()). - Render(m.lightProvider.Name) - - lightPane = lightPaneStyle.Render( - lipgloss.JoinVertical(lipgloss.Left, - lightTitle, - lightProviderName, - "", - m.lightModelList.View(), - ), - ) - - // Combine panes - content := lipgloss.JoinHorizontal(lipgloss.Top, mainPane, " ", lightPane) - - // Add help text - helpText := lipgloss.NewStyle(). - Foreground(t.Secondary()). - Render("tab: switch pane • ←/→: change provider • ↑/↓: select model • enter: save • esc: cancel") - - fullContent := lipgloss.JoinVertical(lipgloss.Center, content, "", helpText) - - return m.modal.Render(fullContent, background) } - return "" + return false } -func (m *modelDialog) View() string { - return m.Render("") +func (m *modelDialog) Render(background string) string { + if m.modal != nil { + return m.modal.Render(m.View(), background) + } + return "" } func (m *modelDialog) IsVisible() bool { @@ -385,18 +510,36 @@ func NewModelDialog(app *app.App) ModelDialog { hScrollPossible: len(availableProviders) > 1, activePane: MainModelPane, modal: modal.New( - modal.WithTitle("Select Models"), - modal.WithMaxWidth(totalDialogWidth+4), + modal.WithTitle(fmt.Sprintf("Select Models - %s", mainProvider.Name)), ), } - // Find initial scroll offsets - for i, p := range availableProviders { - if p.Id == mainProvider.Id { - dialog.mainHScrollOffset = i + // Set initial selections based on current models + if app.MainModel != nil { + models := dialog.getModelsForProvider(mainProvider) + for i, model := range models { + if model.Id == app.MainModel.Id { + dialog.mainSelectedIdx = i + // Adjust scroll position to keep selected model visible + if dialog.mainSelectedIdx >= numVisibleModels { + dialog.mainScrollOffset = dialog.mainSelectedIdx - (numVisibleModels - 1) + } + break + } } - if p.Id == lightProvider.Id { - dialog.lightHScrollOffset = i + } + + if app.LightModel != nil { + models := dialog.getModelsForProvider(lightProvider) + for i, model := range models { + if model.Id == app.LightModel.Id { + dialog.lightSelectedIdx = i + // Adjust scroll position to keep selected model visible + if dialog.lightSelectedIdx >= numVisibleModels { + dialog.lightScrollOffset = dialog.lightSelectedIdx - (numVisibleModels - 1) + } + break + } } } From 053ba202e271ba88532dd9a8873768b91951371e Mon Sep 17 00:00:00 2001 From: Saatvik Arya Date: Sat, 21 Jun 2025 17:43:20 +0530 Subject: [PATCH 05/15] refactor(tui): improve model initialization and rendering logic for better provider handling --- packages/tui/internal/app/app.go | 12 +- .../tui/internal/components/chat/editor.go | 10 +- .../tui/internal/components/dialog/models.go | 223 +++++++++++------- 3 files changed, 146 insertions(+), 99 deletions(-) diff --git a/packages/tui/internal/app/app.go b/packages/tui/internal/app/app.go index ce1ee15fe50..351bd8ef21a 100644 --- a/packages/tui/internal/app/app.go +++ b/packages/tui/internal/app/app.go @@ -307,10 +307,18 @@ func (a *App) InitializeProject(ctx context.Context) tea.Cmd { func (a *App) CompactSession(ctx context.Context) tea.Cmd { go func() { + // Use lightweight model for summarization if available + providerID := a.MainProvider.Id + modelID := a.MainModel.Id + if a.LightProvider != nil && a.LightModel != nil { + providerID = a.LightProvider.Id + modelID = a.LightModel.Id + } + response, err := a.Client.PostSessionSummarizeWithResponse(ctx, client.PostSessionSummarizeJSONRequestBody{ SessionID: a.Session.Id, - ProviderID: a.MainProvider.Id, - ModelID: a.MainModel.Id, + ProviderID: providerID, + ModelID: modelID, }) if err != nil { slog.Error("Failed to compact session", "error", err) diff --git a/packages/tui/internal/components/chat/editor.go b/packages/tui/internal/components/chat/editor.go index d32b71d7cb9..68441aeeda2 100644 --- a/packages/tui/internal/components/chat/editor.go +++ b/packages/tui/internal/components/chat/editor.go @@ -128,17 +128,15 @@ func (m *editorComponent) Content() string { } model := "" - if m.app.MainModel != nil { + if m.app.MainModel != nil && m.app.MainProvider != nil { model = muted(m.app.MainProvider.Name) + base(" "+m.app.MainModel.Name) // show lightweight model if configured - if m.app.LightModel != nil { - if m.app.LightProvider != nil && m.app.LightProvider.Id == m.app.MainProvider.Id { - // Same provider – show friendly name + if m.app.LightModel != nil && m.app.LightProvider != nil { + if m.app.LightProvider.Id == m.app.MainProvider.Id { model = model + muted(" (⚡"+m.app.LightModel.Name+")") } else { - // Different provider – show model ID - model = model + muted(" (⚡"+m.app.LightModel.Id+")") + model = model + muted(" (⚡"+m.app.LightProvider.Name+"/"+m.app.LightModel.Name+")") } } } diff --git a/packages/tui/internal/components/dialog/models.go b/packages/tui/internal/components/dialog/models.go index e950c987f9a..b8fc9303a12 100644 --- a/packages/tui/internal/components/dialog/models.go +++ b/packages/tui/internal/components/dialog/models.go @@ -100,6 +100,56 @@ var modelKeys = modelKeyMap{ } func (m *modelDialog) Init() tea.Cmd { + if len(m.availableProviders) == 0 { + return nil + } + + // Initialize main provider and model + if m.app.MainProvider != nil { + m.mainProvider = *m.app.MainProvider + models := m.getModelsForProvider(m.mainProvider) + for i, model := range models { + if m.app.MainModel != nil && model.Id == m.app.MainModel.Id { + m.mainSelectedIdx = i + // Adjust scroll position to keep selected model visible + if m.mainSelectedIdx >= numVisibleModels { + m.mainScrollOffset = m.mainSelectedIdx - (numVisibleModels - 1) + } + break + } + } + } else { + m.mainProvider = m.availableProviders[0] + } + + // Initialize lightweight provider and model + m.lightProvider = m.mainProvider // Default to same as main + + if m.app.LightProvider != nil && m.app.LightModel != nil { + m.lightProvider = *m.app.LightProvider + + models := m.getModelsForProvider(m.lightProvider) + for i, model := range models { + if model.Id == m.app.LightModel.Id { + m.lightSelectedIdx = i + // Adjust scroll position to keep selected model visible + if m.lightSelectedIdx >= numVisibleModels { + m.lightScrollOffset = m.lightSelectedIdx - (numVisibleModels - 1) + } + break + } + } + } else { + // If no lightweight model is set, try to select a lightweight model by default + models := m.getModelsForProvider(m.lightProvider) + for i, model := range models { + if isLightweightModel(model) { + m.lightSelectedIdx = i + break + } + } + } + return nil } @@ -126,6 +176,10 @@ func (m *modelDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) { mainModels := m.getModelsForProvider(m.mainProvider) lightModels := m.getModelsForProvider(m.lightProvider) + if len(mainModels) == 0 || len(lightModels) == 0 { + return m, nil + } + mainSelectedModel := mainModels[m.mainSelectedIdx] lightSelectedModel := lightModels[m.lightSelectedIdx] @@ -273,6 +327,16 @@ func (m *modelDialog) switchPane() { func (m *modelDialog) View() string { t := theme.CurrentTheme() + // Handle empty providers case + if len(m.availableProviders) == 0 { + emptyStyle := lipgloss.NewStyle(). + Background(t.BackgroundElement()). + Foreground(t.TextMuted()). + Padding(2, 4). + Align(lipgloss.Center) + return emptyStyle.Render("No providers configured. Please configure at least one provider.") + } + // Base style for the content baseStyle := lipgloss.NewStyle(). Background(t.BackgroundElement()). @@ -401,17 +465,30 @@ func (m *modelDialog) renderPane(title string, provider client.ProviderInfo, sel } // Join all models - modelsRendered := strings.Join(modelItems, "\n") + modelList := lipgloss.JoinVertical(lipgloss.Left, modelItems...) + + // Scroll indicator content + scrollIndicatorContent := "" + if len(models) > numVisibleModels { + if scrollOffset > 0 { + scrollIndicatorContent = "↑" + } + if scrollOffset+numVisibleModels < len(models) { + if scrollIndicatorContent != "" { + scrollIndicatorContent += " " + } + scrollIndicatorContent += "↓" + } + } - // Scroll indicator at bottom - scrollIndicator := "" - if scrollOffset > 0 || endIdx < len(models) { - scrollText := fmt.Sprintf("%d-%d of %d", scrollOffset+1, endIdx, len(models)) - scrollIndicator = baseStyle. + var scrollIndicator string + if scrollIndicatorContent != "" { + scrollIndicator = lipgloss.NewStyle(). + Background(t.BackgroundElement()). + Foreground(t.Primary()). Width(paneWidth). Align(lipgloss.Center). - Foreground(t.TextMuted()). - Render(scrollText) + Render(scrollIndicatorContent) } else { scrollIndicator = baseStyle.Width(paneWidth).Render(" ") } @@ -420,25 +497,50 @@ func (m *modelDialog) renderPane(title string, provider client.ProviderInfo, sel return lipgloss.JoinVertical( lipgloss.Left, headerRendered, - modelsRendered, + modelList, scrollIndicator, ) } -func (m *modelDialog) getScrollIndicators(width int) string { +func (m *modelDialog) getScrollIndicators(maxWidth int) string { t := theme.CurrentTheme() - if !m.hScrollPossible { - return "" + var indicator string + + // Check if main models have scroll + mainModels := len(m.mainProvider.Models) + if mainModels > numVisibleModels { + if m.mainScrollOffset > 0 { + indicator += "↑ " + } + if m.mainScrollOffset+numVisibleModels < mainModels { + indicator += "↓ " + } + } + + // Add horizontal scroll indicators + if m.hScrollPossible { + indicator = "← " + indicator + "→" + } + + // Add tab hint + if indicator != "" { + indicator += " • [Tab] Switch pane" + } + + if indicator == "" { + return lipgloss.NewStyle(). + Background(t.BackgroundElement()). + Width(maxWidth). + Render(" ") } - // Show provider navigation hint return lipgloss.NewStyle(). - Width(width). + Width(maxWidth). Align(lipgloss.Center). Foreground(t.TextMuted()). Background(t.BackgroundElement()). - Render("← → to switch providers • Tab to switch panes") + Render(indicator) } func isLightweightModel(model client.ModelInfo) bool { @@ -478,29 +580,20 @@ func (m *modelDialog) Close() tea.Cmd { // NewModelDialog creates a new model selection dialog func NewModelDialog(app *app.App) ModelDialog { - availableProviders := getEnabledProviders(app) - - // Set up main model provider - mainProvider := availableProviders[0] - if app.MainProvider != nil { - for _, p := range availableProviders { - if p.Id == app.MainProvider.Id { - mainProvider = p - break - } - } - } - - // Set up lightweight model provider (default to same as main if not set) - lightProvider := mainProvider - if app.LightProvider != nil { - for _, p := range availableProviders { - if p.Id == app.LightProvider.Id { - lightProvider = p - break - } + availableProviders, _ := app.ListProviders(context.Background()) + + if len(availableProviders) == 0 { + return &modelDialog{ + app: app, + availableProviders: availableProviders, + hScrollPossible: false, + modal: modal.New(modal.WithTitle("Select Models - No Providers Available")), } } + + // Set up initial providers + mainProvider := availableProviders[0] + lightProvider := availableProviders[0] dialog := &modelDialog{ app: app, @@ -513,63 +606,11 @@ func NewModelDialog(app *app.App) ModelDialog { modal.WithTitle(fmt.Sprintf("Select Models - %s", mainProvider.Name)), ), } - - // Set initial selections based on current models - if app.MainModel != nil { - models := dialog.getModelsForProvider(mainProvider) - for i, model := range models { - if model.Id == app.MainModel.Id { - dialog.mainSelectedIdx = i - // Adjust scroll position to keep selected model visible - if dialog.mainSelectedIdx >= numVisibleModels { - dialog.mainScrollOffset = dialog.mainSelectedIdx - (numVisibleModels - 1) - } - break - } - } - } - - if app.LightModel != nil { - models := dialog.getModelsForProvider(lightProvider) - for i, model := range models { - if model.Id == app.LightModel.Id { - dialog.lightSelectedIdx = i - // Adjust scroll position to keep selected model visible - if dialog.lightSelectedIdx >= numVisibleModels { - dialog.lightScrollOffset = dialog.lightSelectedIdx - (numVisibleModels - 1) - } - break - } - } - } - - return dialog -} - -func getEnabledProviders(app *app.App) []client.ProviderInfo { - // Get providers from the API - ctx := context.Background() - providersResponse, err := app.Client.PostProviderListWithResponse(ctx) - if err != nil || providersResponse == nil || providersResponse.StatusCode() != 200 { - // Return empty list if we can't get providers - return []client.ProviderInfo{} - } - var enabledProviders []client.ProviderInfo - - // Get all providers that have models - for _, provider := range providersResponse.JSON200.Providers { - if len(provider.Models) > 0 { - enabledProviders = append(enabledProviders, provider) - } - } + // Initialize will set up the selections based on current models + dialog.Init() - // Sort providers by name - slices.SortFunc(enabledProviders, func(a, b client.ProviderInfo) int { - return strings.Compare(a.Name, b.Name) - }) - - return enabledProviders + return dialog } // UpdateModelContext updates the context with selected models From 581a36e110881e1b411d757c3a9ba91f93502d14 Mon Sep 17 00:00:00 2001 From: Saatvik Arya Date: Wed, 25 Jun 2025 01:06:38 +0530 Subject: [PATCH 06/15] refactor(tui): rename lightweight model to turbo model and update related logic --- packages/opencode/src/config/config.ts | 4 +- packages/opencode/src/provider/provider.ts | 14 +- packages/tui/internal/app/app.go | 60 +++---- .../tui/internal/components/chat/editor.go | 10 +- .../tui/internal/components/dialog/models.go | 162 +++++++++--------- packages/tui/internal/config/config.go | 4 +- packages/tui/internal/tui/tui.go | 8 +- packages/tui/pkg/client/gen/openapi.json | 4 +- packages/tui/pkg/client/generated-client.go | 6 +- 9 files changed, 136 insertions(+), 136 deletions(-) diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 197bd9bbf5d..a04f277b37e 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -154,9 +154,9 @@ export namespace Config { "Model to use in the format of provider/model, eg anthropic/claude-2", ) .optional(), - lightweight_model: z + turbo_model: z .string() - .describe("Lightweight model to use for tasks like window title generation") + .describe("Turbo model to use for tasks like window title generation") .optional(), provider: z .record( diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 40708629b5f..9aaeaa57701 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -386,17 +386,17 @@ export namespace Provider { } } - export async function getLightweightModel(providerID: string): Promise<{ info: ModelsDev.Model; language: LanguageModel } | null> { + export async function getTurboModel(providerID: string): Promise<{ info: ModelsDev.Model; language: LanguageModel } | null> { const cfg = await Config.get() // Check user override - if (cfg.lightweight_model) { + if (cfg.turbo_model) { try { - // Parse the lightweight model to get its provider - const { providerID: lightweightProviderID, modelID } = parseModel(cfg.lightweight_model) - return await getModel(lightweightProviderID, modelID) + // Parse the turbo model to get its provider + const { providerID: turboProviderID, modelID } = parseModel(cfg.turbo_model) + return await getModel(turboProviderID, modelID) } catch (e) { - log.warn("Failed to get configured lightweight model", { lightweight_model: cfg.lightweight_model, error: e }) + log.warn("Failed to get configured turbo model", { turbo_model: cfg.turbo_model, error: e }) } } @@ -404,7 +404,7 @@ export namespace Provider { const provider = providers[providerID] if (!provider) return null - // Select cheapest model whose cost.output <= 4 + // Select cheapest model whose cost.output <= 4 for turbo tasks let selected: { info: ModelsDev.Model; language: LanguageModel } | null = null for (const model of Object.values(provider.info.models)) { if (model.cost.output <= 4) { diff --git a/packages/tui/internal/app/app.go b/packages/tui/internal/app/app.go index 351bd8ef21a..30e3a3337a1 100644 --- a/packages/tui/internal/app/app.go +++ b/packages/tui/internal/app/app.go @@ -29,8 +29,8 @@ type App struct { State *config.State MainProvider *client.ProviderInfo MainModel *client.ModelInfo - LightProvider *client.ProviderInfo - LightModel *client.ModelInfo + TurboProvider *client.ProviderInfo + TurboModel *client.ModelInfo Session *client.SessionInfo Messages []client.MessageInfo Commands commands.CommandRegistry @@ -38,10 +38,10 @@ type App struct { type SessionSelectedMsg = *client.SessionInfo type ModelSelectedMsg struct { - MainProvider client.ProviderInfo - MainModel client.ModelInfo - LightweightProvider client.ProviderInfo - LightweightModel client.ModelInfo + MainProvider client.ProviderInfo + MainModel client.ModelInfo + TurboProvider client.ProviderInfo + TurboModel client.ModelInfo } type SessionClearedMsg struct{} @@ -94,8 +94,8 @@ func New( if configInfo.Model != nil { appState.MainProvider, appState.MainModel = util.ParseModel(*configInfo.Model) } - if configInfo.LightweightModel != nil { - appState.LightProvider, appState.LightModel = util.ParseModel(*configInfo.LightweightModel) + if configInfo.TurboModel != nil { + appState.TurboProvider, appState.TurboModel = util.ParseModel(*configInfo.TurboModel) } // Load themes from all directories @@ -187,19 +187,19 @@ func (a *App) InitializeProvider() tea.Cmd { currentModel = defaultModel } - // Initialize lightweight model based on config or defaults - lightProvider := currentProvider - lightModel := currentModel + // Initialize turbo model based on config or defaults + turboProvider := currentProvider + turboModel := currentModel - if a.State.LightProvider != "" && a.State.LightModel != "" { - lightProviderID, lightModelID := a.State.LightProvider, a.State.LightModel + if a.State.TurboProvider != "" && a.State.TurboModel != "" { + turboProviderID, turboModelID := a.State.TurboProvider, a.State.TurboModel // Find provider/model for _, provider := range providers { - if provider.Id == lightProviderID { - lightProvider = &provider + if provider.Id == turboProviderID { + turboProvider = &provider for _, model := range provider.Models { - if model.Id == lightModelID { - lightModel = &model + if model.Id == turboModelID { + turboModel = &model break } } @@ -207,20 +207,20 @@ func (a *App) InitializeProvider() tea.Cmd { } } } else { - // Try to find a default lightweight model for the provider - lightModel = getDefaultLightweightModel(*currentProvider) - if lightModel == nil { + // Try to find a default turbo model for the provider + turboModel = getDefaultTurboModel(*currentProvider) + if turboModel == nil { // Fall back to the main model - lightModel = currentModel + turboModel = currentModel } } // TODO: handle no provider or model setup, yet return ModelSelectedMsg{ - MainProvider: *currentProvider, - MainModel: *currentModel, - LightweightProvider: *lightProvider, - LightweightModel: *lightModel, + MainProvider: *currentProvider, + MainModel: *currentModel, + TurboProvider: *turboProvider, + TurboModel: *turboModel, } } } @@ -237,7 +237,7 @@ func getDefaultModel(response *client.PostProviderListResponse, provider client. return nil } -func getDefaultLightweightModel(provider client.ProviderInfo) *client.ModelInfo { +func getDefaultTurboModel(provider client.ProviderInfo) *client.ModelInfo { // Select the cheapest model whose Cost.Output <= 4 var selected *client.ModelInfo for _, model := range provider.Models { @@ -307,12 +307,12 @@ func (a *App) InitializeProject(ctx context.Context) tea.Cmd { func (a *App) CompactSession(ctx context.Context) tea.Cmd { go func() { - // Use lightweight model for summarization if available + // Use turbo model for summarization if available providerID := a.MainProvider.Id modelID := a.MainModel.Id - if a.LightProvider != nil && a.LightModel != nil { - providerID = a.LightProvider.Id - modelID = a.LightModel.Id + if a.TurboProvider != nil && a.TurboModel != nil { + providerID = a.TurboProvider.Id + modelID = a.TurboModel.Id } response, err := a.Client.PostSessionSummarizeWithResponse(ctx, client.PostSessionSummarizeJSONRequestBody{ diff --git a/packages/tui/internal/components/chat/editor.go b/packages/tui/internal/components/chat/editor.go index 68441aeeda2..fd4df2464bc 100644 --- a/packages/tui/internal/components/chat/editor.go +++ b/packages/tui/internal/components/chat/editor.go @@ -131,12 +131,12 @@ func (m *editorComponent) Content() string { if m.app.MainModel != nil && m.app.MainProvider != nil { model = muted(m.app.MainProvider.Name) + base(" "+m.app.MainModel.Name) - // show lightweight model if configured - if m.app.LightModel != nil && m.app.LightProvider != nil { - if m.app.LightProvider.Id == m.app.MainProvider.Id { - model = model + muted(" (⚡"+m.app.LightModel.Name+")") + // show turbo model if configured + if m.app.TurboModel != nil && m.app.TurboProvider != nil { + if m.app.TurboProvider.Id == m.app.MainProvider.Id { + model = model + muted(" (⚡"+m.app.TurboModel.Name+")") } else { - model = model + muted(" (⚡"+m.app.LightProvider.Name+"/"+m.app.LightModel.Name+")") + model = model + muted(" (⚡"+m.app.TurboProvider.Name+"/"+m.app.TurboModel.Name+")") } } } diff --git a/packages/tui/internal/components/dialog/models.go b/packages/tui/internal/components/dialog/models.go index b8fc9303a12..f19749be7f7 100644 --- a/packages/tui/internal/components/dialog/models.go +++ b/packages/tui/internal/components/dialog/models.go @@ -27,7 +27,7 @@ type ActivePane int const ( MainModelPane ActivePane = iota - LightweightModelPane + TurboModelPane ) // ModelDialog interface for the model selection dialog @@ -38,23 +38,23 @@ type ModelDialog interface { type modelDialog struct { app *app.App availableProviders []client.ProviderInfo - + // Main model selection - mainProvider client.ProviderInfo - mainSelectedIdx int - mainScrollOffset int - - // Lightweight model selection - lightProvider client.ProviderInfo - lightSelectedIdx int - lightScrollOffset int - + mainProvider client.ProviderInfo + mainSelectedIdx int + mainScrollOffset int + + // Turbo model selection + turboProvider client.ProviderInfo + turboSelectedIdx int + turboScrollOffset int + // UI state activePane ActivePane width int height int hScrollPossible bool - + modal *modal.Modal } @@ -122,29 +122,29 @@ func (m *modelDialog) Init() tea.Cmd { m.mainProvider = m.availableProviders[0] } - // Initialize lightweight provider and model - m.lightProvider = m.mainProvider // Default to same as main + // Initialize turbo provider and model + m.turboProvider = m.mainProvider // Default to same as main - if m.app.LightProvider != nil && m.app.LightModel != nil { - m.lightProvider = *m.app.LightProvider + if m.app.TurboProvider != nil && m.app.TurboModel != nil { + m.turboProvider = *m.app.TurboProvider - models := m.getModelsForProvider(m.lightProvider) + models := m.getModelsForProvider(m.turboProvider) for i, model := range models { - if model.Id == m.app.LightModel.Id { - m.lightSelectedIdx = i + if model.Id == m.app.TurboModel.Id { + m.turboSelectedIdx = i // Adjust scroll position to keep selected model visible - if m.lightSelectedIdx >= numVisibleModels { - m.lightScrollOffset = m.lightSelectedIdx - (numVisibleModels - 1) + if m.turboSelectedIdx >= numVisibleModels { + m.turboScrollOffset = m.turboSelectedIdx - (numVisibleModels - 1) } break } } } else { - // If no lightweight model is set, try to select a lightweight model by default - models := m.getModelsForProvider(m.lightProvider) + // If no turbo model is set, try to select a turbo model by default + models := m.getModelsForProvider(m.turboProvider) for i, model := range models { - if isLightweightModel(model) { - m.lightSelectedIdx = i + if isTurboModel(model) { + m.turboSelectedIdx = i break } } @@ -174,23 +174,23 @@ func (m *modelDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case key.Matches(msg, modelKeys.Enter): // Get selected models from both panes mainModels := m.getModelsForProvider(m.mainProvider) - lightModels := m.getModelsForProvider(m.lightProvider) - - if len(mainModels) == 0 || len(lightModels) == 0 { + turboModels := m.getModelsForProvider(m.turboProvider) + + if len(mainModels) == 0 || len(turboModels) == 0 { return m, nil } - + mainSelectedModel := mainModels[m.mainSelectedIdx] - lightSelectedModel := lightModels[m.lightSelectedIdx] - + turboSelectedModel := turboModels[m.turboSelectedIdx] + return m, tea.Sequence( util.CmdHandler(modal.CloseModalMsg{}), util.CmdHandler( app.ModelSelectedMsg{ - MainProvider: m.mainProvider, - MainModel: mainSelectedModel, - LightweightProvider: m.lightProvider, - LightweightModel: lightSelectedModel, + MainProvider: m.mainProvider, + MainModel: mainSelectedModel, + TurboProvider: m.turboProvider, + TurboModel: turboSelectedModel, }), ) case key.Matches(msg, modelKeys.Escape): @@ -224,23 +224,23 @@ func (m *modelDialog) moveSelectionUp() { m.mainSelectedIdx = len(models) - 1 m.mainScrollOffset = max(0, len(models)-numVisibleModels) } - + // Keep selection visible if m.mainSelectedIdx < m.mainScrollOffset { m.mainScrollOffset = m.mainSelectedIdx } } else { - models := m.getModelsForProvider(m.lightProvider) - if m.lightSelectedIdx > 0 { - m.lightSelectedIdx-- + models := m.getModelsForProvider(m.turboProvider) + if m.turboSelectedIdx > 0 { + m.turboSelectedIdx-- } else { - m.lightSelectedIdx = len(models) - 1 - m.lightScrollOffset = max(0, len(models)-numVisibleModels) + m.turboSelectedIdx = len(models) - 1 + m.turboScrollOffset = max(0, len(models)-numVisibleModels) } - + // Keep selection visible - if m.lightSelectedIdx < m.lightScrollOffset { - m.lightScrollOffset = m.lightSelectedIdx + if m.turboSelectedIdx < m.turboScrollOffset { + m.turboScrollOffset = m.turboSelectedIdx } } } @@ -254,23 +254,23 @@ func (m *modelDialog) moveSelectionDown() { m.mainSelectedIdx = 0 m.mainScrollOffset = 0 } - + // Keep selection visible if m.mainSelectedIdx >= m.mainScrollOffset+numVisibleModels { m.mainScrollOffset = m.mainSelectedIdx - (numVisibleModels - 1) } } else { - models := m.getModelsForProvider(m.lightProvider) - if m.lightSelectedIdx < len(models)-1 { - m.lightSelectedIdx++ + models := m.getModelsForProvider(m.turboProvider) + if m.turboSelectedIdx < len(models)-1 { + m.turboSelectedIdx++ } else { - m.lightSelectedIdx = 0 - m.lightScrollOffset = 0 + m.turboSelectedIdx = 0 + m.turboScrollOffset = 0 } - + // Keep selection visible - if m.lightSelectedIdx >= m.lightScrollOffset+numVisibleModels { - m.lightScrollOffset = m.lightSelectedIdx - (numVisibleModels - 1) + if m.turboSelectedIdx >= m.turboScrollOffset+numVisibleModels { + m.turboScrollOffset = m.turboSelectedIdx - (numVisibleModels - 1) } } } @@ -299,7 +299,7 @@ func (m *modelDialog) switchProvider(offset int) { } else { currentIdx := 0 for i, p := range m.availableProviders { - if p.Id == m.lightProvider.Id { + if p.Id == m.turboProvider.Id { currentIdx = i break } @@ -310,15 +310,15 @@ func (m *modelDialog) switchProvider(offset int) { } else if newIdx >= len(m.availableProviders) { newIdx = 0 } - m.lightProvider = m.availableProviders[newIdx] - m.lightSelectedIdx = 0 - m.lightScrollOffset = 0 + m.turboProvider = m.availableProviders[newIdx] + m.turboSelectedIdx = 0 + m.turboScrollOffset = 0 } } func (m *modelDialog) switchPane() { if m.activePane == MainModelPane { - m.activePane = LightweightModelPane + m.activePane = TurboModelPane } else { m.activePane = MainModelPane } @@ -352,13 +352,13 @@ func (m *modelDialog) View() string { baseStyle, ) - // Render lightweight model pane - lightPane := m.renderPane( - "Lightweight Model", - m.lightProvider, - m.lightSelectedIdx, - m.lightScrollOffset, - m.activePane == LightweightModelPane, + // Render turbo model pane + turboPane := m.renderPane( + "Turbo Model", + m.turboProvider, + m.turboSelectedIdx, + m.turboScrollOffset, + m.activePane == TurboModelPane, baseStyle, ) @@ -378,7 +378,7 @@ func (m *modelDialog) View() string { lipgloss.Top, mainPane, divider, - lightPane, + turboPane, ) // Apply background to entire content area @@ -430,11 +430,11 @@ func (m *modelDialog) renderPane(title string, provider client.ProviderInfo, sel for i := scrollOffset; i < endIdx; i++ { model := models[i] - isLightweight := isLightweightModel(model) + isTurbo := isTurboModel(model) // Build model display name modelName := model.Name - if isLightweight { + if isTurbo { modelName = fmt.Sprintf("⚡ %s", modelName) } @@ -504,7 +504,7 @@ func (m *modelDialog) renderPane(title string, provider client.ProviderInfo, sel func (m *modelDialog) getScrollIndicators(maxWidth int) string { t := theme.CurrentTheme() - + var indicator string // Check if main models have scroll @@ -543,9 +543,9 @@ func (m *modelDialog) getScrollIndicators(maxWidth int) string { Render(indicator) } -func isLightweightModel(model client.ModelInfo) bool { - // Models that are good for lightweight tasks - lightweightModels := []string{ +func isTurboModel(model client.ModelInfo) bool { + // Models that are good for turbo tasks + turboModels := []string{ "gpt-3.5-turbo", "gpt-4o-mini", "claude-3-haiku", @@ -553,9 +553,9 @@ func isLightweightModel(model client.ModelInfo) bool { "llama-3.2", "deepseek-chat", } - + modelLower := strings.ToLower(model.Id) - for _, lm := range lightweightModels { + for _, lm := range turboModels { if strings.Contains(modelLower, lm) { return true } @@ -593,13 +593,13 @@ func NewModelDialog(app *app.App) ModelDialog { // Set up initial providers mainProvider := availableProviders[0] - lightProvider := availableProviders[0] - + turboProvider := availableProviders[0] + dialog := &modelDialog{ app: app, availableProviders: availableProviders, mainProvider: mainProvider, - lightProvider: lightProvider, + turboProvider: turboProvider, hScrollPossible: len(availableProviders) > 1, activePane: MainModelPane, modal: modal.New( @@ -614,10 +614,10 @@ func NewModelDialog(app *app.App) ModelDialog { } // UpdateModelContext updates the context with selected models -func UpdateModelContext(ctx context.Context, mainProvider client.ProviderInfo, mainModel client.ModelInfo, lightProvider client.ProviderInfo, lightModel client.ModelInfo) context.Context { +func UpdateModelContext(ctx context.Context, mainProvider client.ProviderInfo, mainModel client.ModelInfo, turboProvider client.ProviderInfo, turboModel client.ModelInfo) context.Context { ctx = context.WithValue(ctx, "main_provider", mainProvider) ctx = context.WithValue(ctx, "main_model", mainModel) - ctx = context.WithValue(ctx, "light_provider", lightProvider) - ctx = context.WithValue(ctx, "light_model", lightModel) + ctx = context.WithValue(ctx, "turbo_provider", turboProvider) + ctx = context.WithValue(ctx, "turbo_model", turboModel) return ctx -} \ No newline at end of file +} diff --git a/packages/tui/internal/config/config.go b/packages/tui/internal/config/config.go index 9b257d2d5d1..31c6eb477fc 100644 --- a/packages/tui/internal/config/config.go +++ b/packages/tui/internal/config/config.go @@ -14,8 +14,8 @@ type State struct { Theme string `toml:"theme"` MainProvider string `toml:"main_provider"` MainModel string `toml:"main_model"` - LightProvider string `toml:"light_provider"` - LightModel string `toml:"light_model"` + TurboProvider string `toml:"turbo_provider"` + TurboModel string `toml:"turbo_model"` } func NewState() *State { diff --git a/packages/tui/internal/tui/tui.go b/packages/tui/internal/tui/tui.go index 446ae25bce4..18c6fe9b745 100644 --- a/packages/tui/internal/tui/tui.go +++ b/packages/tui/internal/tui/tui.go @@ -335,12 +335,12 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case app.ModelSelectedMsg: a.app.MainProvider = &msg.MainProvider a.app.MainModel = &msg.MainModel - a.app.LightProvider = &msg.LightweightProvider - a.app.LightModel = &msg.LightweightModel + a.app.TurboProvider = &msg.TurboProvider + a.app.TurboModel = &msg.TurboModel a.app.State.MainProvider = msg.MainProvider.Id a.app.State.MainModel = msg.MainModel.Id - a.app.State.LightProvider = msg.LightweightProvider.Id - a.app.State.LightModel = msg.LightweightModel.Id + a.app.State.TurboProvider = msg.TurboProvider.Id + a.app.State.TurboModel = msg.TurboModel.Id // Save state and config a.app.SaveState() diff --git a/packages/tui/pkg/client/gen/openapi.json b/packages/tui/pkg/client/gen/openapi.json index 1e3b85496e2..e46bbad5883 100644 --- a/packages/tui/pkg/client/gen/openapi.json +++ b/packages/tui/pkg/client/gen/openapi.json @@ -1524,9 +1524,9 @@ "type": "string", "description": "Model to use in the format of provider/model, eg anthropic/claude-2" }, - "lightweight_model": { + "turbo_model": { "type": "string", - "description": "Lightweight model to use for tasks like window title generation" + "description": "Turbo model to use for tasks like window title generation" }, "provider": { "type": "object", diff --git a/packages/tui/pkg/client/generated-client.go b/packages/tui/pkg/client/generated-client.go index daf9b10c3e7..d2edb1835df 100644 --- a/packages/tui/pkg/client/generated-client.go +++ b/packages/tui/pkg/client/generated-client.go @@ -54,9 +54,6 @@ type ConfigInfo struct { DisabledProviders *[]string `json:"disabled_providers,omitempty"` Keybinds *ConfigKeybinds `json:"keybinds,omitempty"` - // LightweightModel Lightweight model to use for tasks like window title generation - LightweightModel *string `json:"lightweight_model,omitempty"` - // Mcp MCP (Model Context Protocol) server configurations Mcp *map[string]ConfigInfo_Mcp_AdditionalProperties `json:"mcp,omitempty"` @@ -94,6 +91,9 @@ type ConfigInfo struct { // Theme Theme name to use for the interface Theme *string `json:"theme,omitempty"` + + // TurboModel Turbo model to use for tasks like window title generation + TurboModel *string `json:"turbo_model,omitempty"` } // ConfigInfo_Mcp_AdditionalProperties defines model for Config.Info.mcp.AdditionalProperties. From 6be8dbd1a26754f7354cd6235e655229ecd5b7f9 Mon Sep 17 00:00:00 2001 From: Saatvik Arya Date: Wed, 25 Jun 2025 01:35:54 +0530 Subject: [PATCH 07/15] feat: add configurable turbo model cost threshold MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add turbo_cost_threshold config option (default: 4) - Replace hardcoded turbo model list with dynamic cost-based detection - Update UI to show ⚡ emoji based on configurable threshold - Add isTurboModel() helper function to check model costs - Standardize terminology from 'lightweight' to 'turbo' throughout --- packages/opencode/src/config/config.ts | 5 +++ packages/opencode/src/provider/provider.ts | 13 +++++-- .../tui/internal/components/dialog/models.go | 35 ++++++++----------- packages/tui/pkg/client/gen/openapi.json | 5 +++ packages/tui/pkg/client/generated-client.go | 3 ++ 5 files changed, 38 insertions(+), 23 deletions(-) diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index a04f277b37e..d76bc07fa2f 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -158,6 +158,11 @@ export namespace Config { .string() .describe("Turbo model to use for tasks like window title generation") .optional(), + turbo_cost_threshold: z + .number() + .describe("Maximum output cost for a model to be considered a turbo model (default: 4)") + .default(4) + .optional(), provider: z .record( ModelsDev.Provider.partial().extend({ diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 9aaeaa57701..57a54337c1b 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -386,6 +386,12 @@ export namespace Provider { } } + export async function isTurboModel(model: ModelsDev.Model): Promise { + const cfg = await Config.get() + const threshold = cfg.turbo_cost_threshold ?? 4 + return model.cost.output <= threshold + } + export async function getTurboModel(providerID: string): Promise<{ info: ModelsDev.Model; language: LanguageModel } | null> { const cfg = await Config.get() @@ -404,10 +410,13 @@ export namespace Provider { const provider = providers[providerID] if (!provider) return null - // Select cheapest model whose cost.output <= 4 for turbo tasks + // Use configured threshold or default to 4 + const threshold = cfg.turbo_cost_threshold ?? 4 + + // Select cheapest model whose cost.output <= threshold for turbo tasks let selected: { info: ModelsDev.Model; language: LanguageModel } | null = null for (const model of Object.values(provider.info.models)) { - if (model.cost.output <= 4) { + if (model.cost.output <= threshold) { try { const m = await getModel(providerID, model.id) if (!selected || m.info.cost.output < selected.info.cost.output) { diff --git a/packages/tui/internal/components/dialog/models.go b/packages/tui/internal/components/dialog/models.go index f19749be7f7..c32fc8a1359 100644 --- a/packages/tui/internal/components/dialog/models.go +++ b/packages/tui/internal/components/dialog/models.go @@ -38,6 +38,7 @@ type ModelDialog interface { type modelDialog struct { app *app.App availableProviders []client.ProviderInfo + turboCostThreshold float32 // Main model selection mainProvider client.ProviderInfo @@ -143,7 +144,7 @@ func (m *modelDialog) Init() tea.Cmd { // If no turbo model is set, try to select a turbo model by default models := m.getModelsForProvider(m.turboProvider) for i, model := range models { - if isTurboModel(model) { + if isTurboModel(model, m.turboCostThreshold) { m.turboSelectedIdx = i break } @@ -430,7 +431,7 @@ func (m *modelDialog) renderPane(title string, provider client.ProviderInfo, sel for i := scrollOffset; i < endIdx; i++ { model := models[i] - isTurbo := isTurboModel(model) + isTurbo := isTurboModel(model, m.turboCostThreshold) // Build model display name modelName := model.Name @@ -543,24 +544,9 @@ func (m *modelDialog) getScrollIndicators(maxWidth int) string { Render(indicator) } -func isTurboModel(model client.ModelInfo) bool { - // Models that are good for turbo tasks - turboModels := []string{ - "gpt-3.5-turbo", - "gpt-4o-mini", - "claude-3-haiku", - "gemini-1.5-flash", - "llama-3.2", - "deepseek-chat", - } - - modelLower := strings.ToLower(model.Id) - for _, lm := range turboModels { - if strings.Contains(modelLower, lm) { - return true - } - } - return false +func isTurboModel(model client.ModelInfo, threshold float32) bool { + // A model is considered a turbo model if its output cost is below the threshold + return model.Cost.Output <= threshold } func (m *modelDialog) Render(background string) string { @@ -595,15 +581,22 @@ func NewModelDialog(app *app.App) ModelDialog { mainProvider := availableProviders[0] turboProvider := availableProviders[0] + // Get turbo cost threshold from config or use default + turboCostThreshold := float32(4.0) + if app.Config != nil && app.Config.TurboCostThreshold != nil { + turboCostThreshold = *app.Config.TurboCostThreshold + } + dialog := &modelDialog{ app: app, availableProviders: availableProviders, + turboCostThreshold: turboCostThreshold, mainProvider: mainProvider, turboProvider: turboProvider, hScrollPossible: len(availableProviders) > 1, activePane: MainModelPane, modal: modal.New( - modal.WithTitle(fmt.Sprintf("Select Models - %s", mainProvider.Name)), + modal.WithTitle("Select Models"), ), } diff --git a/packages/tui/pkg/client/gen/openapi.json b/packages/tui/pkg/client/gen/openapi.json index e46bbad5883..a20ebad470d 100644 --- a/packages/tui/pkg/client/gen/openapi.json +++ b/packages/tui/pkg/client/gen/openapi.json @@ -1528,6 +1528,11 @@ "type": "string", "description": "Turbo model to use for tasks like window title generation" }, + "turbo_cost_threshold": { + "type": "number", + "description": "Maximum output cost for a model to be considered a turbo model (default: 4)", + "default": 4 + }, "provider": { "type": "object", "additionalProperties": { diff --git a/packages/tui/pkg/client/generated-client.go b/packages/tui/pkg/client/generated-client.go index d2edb1835df..b03bfc0287d 100644 --- a/packages/tui/pkg/client/generated-client.go +++ b/packages/tui/pkg/client/generated-client.go @@ -92,6 +92,9 @@ type ConfigInfo struct { // Theme Theme name to use for the interface Theme *string `json:"theme,omitempty"` + // TurboCostThreshold Maximum output cost for a model to be considered a turbo model (default: 4) + TurboCostThreshold *float32 `json:"turbo_cost_threshold,omitempty"` + // TurboModel Turbo model to use for tasks like window title generation TurboModel *string `json:"turbo_model,omitempty"` } From f264cad760905b5b1df0ed82c87ece9aaa6adb82 Mon Sep 17 00:00:00 2001 From: Saatvik Arya Date: Wed, 25 Jun 2025 01:46:51 +0530 Subject: [PATCH 08/15] feat(tui): enhance model display with capability indicators and emoji legend - Add capability indicators for turbo, reasoning, and tools in model display - Update rendering logic to right-align capabilities with model name - Introduce emoji legend for quick reference in scroll indicators --- .../tui/internal/components/dialog/models.go | 34 +++++++++++++++++-- 1 file changed, 32 insertions(+), 2 deletions(-) diff --git a/packages/tui/internal/components/dialog/models.go b/packages/tui/internal/components/dialog/models.go index c32fc8a1359..d5bbdcaddfa 100644 --- a/packages/tui/internal/components/dialog/models.go +++ b/packages/tui/internal/components/dialog/models.go @@ -435,9 +435,31 @@ func (m *modelDialog) renderPane(title string, provider client.ProviderInfo, sel // Build model display name modelName := model.Name + + // Build capability indicators + var capabilities []string if isTurbo { - modelName = fmt.Sprintf("⚡ %s", modelName) + capabilities = append(capabilities, "⚡") } + if model.Reasoning { + capabilities = append(capabilities, "🧠") + } + if model.ToolCall { + capabilities = append(capabilities, "🔧") + } + + // Calculate spacing to right-align capabilities + capabilityStr := strings.Join(capabilities, "") + modelNameWidth := lipgloss.Width(modelName) + capabilityWidth := lipgloss.Width(capabilityStr) + availableSpace := paneWidth - modelNameWidth - capabilityWidth - 2 // 2 for padding + + if availableSpace < 1 { + availableSpace = 1 // At least one space + } + + spacer := strings.Repeat(" ", availableSpace) + displayText := modelName + spacer + capabilityStr // Apply styling based on selection and pane state itemStyle := baseStyle.Width(paneWidth) @@ -457,7 +479,7 @@ func (m *modelDialog) renderPane(title string, provider client.ProviderInfo, sel } } - modelItems = append(modelItems, itemStyle.Render(modelName)) + modelItems = append(modelItems, itemStyle.Render(displayText)) } // Pad to ensure consistent height @@ -528,6 +550,14 @@ func (m *modelDialog) getScrollIndicators(maxWidth int) string { if indicator != "" { indicator += " • [Tab] Switch pane" } + + // Add emoji legend + legend := "⚡ turbo • 🧠 reasoning • 🔧 tools" + if indicator != "" { + indicator += " • " + legend + } else { + indicator = legend + } if indicator == "" { return lipgloss.NewStyle(). From 32d11d01b353f826defd7e5a92b40721851fe6ff Mon Sep 17 00:00:00 2001 From: Saatvik Arya Date: Wed, 25 Jun 2025 01:52:41 +0530 Subject: [PATCH 09/15] refactor(provider): comment out model cost initialization in anthropic method --- packages/opencode/src/provider/provider.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 57a54337c1b..a04cf671401 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -42,12 +42,12 @@ export namespace Provider { async anthropic(provider) { const access = await AuthAnthropic.access() if (!access) return false - for (const model of Object.values(provider.models)) { - model.cost = { - input: 0, - output: 0, - } - } + // for (const model of Object.values(provider.models)) { + // model.cost = { + // input: 0, + // output: 0, + // } + // } return { options: { apiKey: "", From ac7c6e819d18419ee0b766f4e2e0732eeebf9656 Mon Sep 17 00:00:00 2001 From: Saatvik Arya Date: Wed, 25 Jun 2025 02:08:39 +0530 Subject: [PATCH 10/15] refactor(tui): streamline turbo model selection logic with new findTurboModel function --- packages/tui/internal/app/app.go | 69 ++++++++++++++++---------------- 1 file changed, 34 insertions(+), 35 deletions(-) diff --git a/packages/tui/internal/app/app.go b/packages/tui/internal/app/app.go index 30e3a3337a1..f9651903933 100644 --- a/packages/tui/internal/app/app.go +++ b/packages/tui/internal/app/app.go @@ -188,32 +188,7 @@ func (a *App) InitializeProvider() tea.Cmd { } // Initialize turbo model based on config or defaults - turboProvider := currentProvider - turboModel := currentModel - - if a.State.TurboProvider != "" && a.State.TurboModel != "" { - turboProviderID, turboModelID := a.State.TurboProvider, a.State.TurboModel - // Find provider/model - for _, provider := range providers { - if provider.Id == turboProviderID { - turboProvider = &provider - for _, model := range provider.Models { - if model.Id == turboModelID { - turboModel = &model - break - } - } - break - } - } - } else { - // Try to find a default turbo model for the provider - turboModel = getDefaultTurboModel(*currentProvider) - if turboModel == nil { - // Fall back to the main model - turboModel = currentModel - } - } + turboProvider, turboModel := findTurboModel(a.State, a.Config, providers, currentProvider, currentModel) // TODO: handle no provider or model setup, yet return ModelSelectedMsg{ @@ -237,18 +212,42 @@ func getDefaultModel(response *client.PostProviderListResponse, provider client. return nil } -func getDefaultTurboModel(provider client.ProviderInfo) *client.ModelInfo { - // Select the cheapest model whose Cost.Output <= 4 - var selected *client.ModelInfo - for _, model := range provider.Models { - if model.Cost.Output <= 4 { - if selected == nil || model.Cost.Output < selected.Cost.Output { - tmp := model // create copy to take address of loop variable safely - selected = &tmp +func findTurboModel(state *config.State, config *client.ConfigInfo, providers []client.ProviderInfo, currentProvider *client.ProviderInfo, currentModel *client.ModelInfo) (*client.ProviderInfo, *client.ModelInfo) { + // If turbo model is configured in state, use it + if state.TurboProvider != "" && state.TurboModel != "" { + for _, provider := range providers { + if provider.Id == state.TurboProvider { + for _, model := range provider.Models { + if model.Id == state.TurboModel { + return &provider, &model + } + } } } } - return selected + + // Get threshold from config or use default + threshold := float32(4.0) + if config != nil && config.TurboCostThreshold != nil { + threshold = *config.TurboCostThreshold + } + + // Find the cheapest model in the current provider that qualifies as turbo + var turboModel *client.ModelInfo + for _, model := range currentProvider.Models { + if model.Cost.Output <= threshold { + if turboModel == nil || model.Cost.Output < turboModel.Cost.Output { + tmp := model + turboModel = &tmp + } + } + } + + // Return turbo model if found, otherwise fall back to main model + if turboModel != nil { + return currentProvider, turboModel + } + return currentProvider, currentModel } type Attachment struct { From ec2462f9ea0bbb1ebef239ecae24277a9b41df70 Mon Sep 17 00:00:00 2001 From: Saatvik Arya Date: Wed, 25 Jun 2025 02:23:56 +0530 Subject: [PATCH 11/15] fix --- packages/opencode/src/provider/provider.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index a04cf671401..4f1cc007d64 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -39,7 +39,7 @@ export namespace Provider { type Source = "env" | "config" | "custom" | "api" const CUSTOM_LOADERS: Record = { - async anthropic(provider) { + async anthropic() { const access = await AuthAnthropic.access() if (!access) return false // for (const model of Object.values(provider.models)) { From 6d2d9652369c9f87b9a62a21c37e7528768f4906 Mon Sep 17 00:00:00 2001 From: Saatvik Arya Date: Sat, 21 Jun 2025 18:17:19 +0530 Subject: [PATCH 12/15] feat(provider): add release_date and last_updated fields to models for enhanced tracking --- packages/opencode/src/provider/models.ts | 12 ++++++++++++ packages/tui/pkg/client/gen/openapi.json | 16 ++++++++++++++++ packages/tui/pkg/client/generated-client.go | 12 ++++++++---- 3 files changed, 36 insertions(+), 4 deletions(-) diff --git a/packages/opencode/src/provider/models.ts b/packages/opencode/src/provider/models.ts index 5b255ecbdfc..ec21f7f7c02 100644 --- a/packages/opencode/src/provider/models.ts +++ b/packages/opencode/src/provider/models.ts @@ -27,6 +27,18 @@ export namespace ModelsDev { }), id: z.string(), options: z.record(z.any()), + release_date: z + .string() + .regex(/^\d{4}-\d{2}(-\d{2})?$/, { + message: "Must be in YYYY-MM or YYYY-MM-DD format", + }) + .optional(), + last_updated: z + .string() + .regex(/^\d{4}-\d{2}(-\d{2})?$/, { + message: "Must be in YYYY-MM or YYYY-MM-DD format", + }) + .optional(), }) .openapi({ ref: "Model.Info", diff --git a/packages/tui/pkg/client/gen/openapi.json b/packages/tui/pkg/client/gen/openapi.json index a20ebad470d..5bee3e309c5 100644 --- a/packages/tui/pkg/client/gen/openapi.json +++ b/packages/tui/pkg/client/gen/openapi.json @@ -1618,6 +1618,14 @@ "options": { "type": "object", "additionalProperties": {} + }, + "release_date": { + "type": "string", + "pattern": "^\\d{4}-\\d{2}(-\\d{2})?$" + }, + "last_updated": { + "type": "string", + "pattern": "^\\d{4}-\\d{2}(-\\d{2})?$" } } } @@ -1866,6 +1874,14 @@ "options": { "type": "object", "additionalProperties": {} + }, + "release_date": { + "type": "string", + "pattern": "^\\d{4}-\\d{2}(-\\d{2})?$" + }, + "last_updated": { + "type": "string", + "pattern": "^\\d{4}-\\d{2}(-\\d{2})?$" } }, "required": [ diff --git a/packages/tui/pkg/client/generated-client.go b/packages/tui/pkg/client/generated-client.go index b03bfc0287d..0a949e42bf7 100644 --- a/packages/tui/pkg/client/generated-client.go +++ b/packages/tui/pkg/client/generated-client.go @@ -73,14 +73,16 @@ type ConfigInfo struct { Input float32 `json:"input"` Output float32 `json:"output"` } `json:"cost,omitempty"` - Id *string `json:"id,omitempty"` - Limit *struct { + Id *string `json:"id,omitempty"` + LastUpdated *string `json:"last_updated,omitempty"` + Limit *struct { Context float32 `json:"context"` Output float32 `json:"output"` } `json:"limit,omitempty"` Name *string `json:"name,omitempty"` Options *map[string]interface{} `json:"options,omitempty"` Reasoning *bool `json:"reasoning,omitempty"` + ReleaseDate *string `json:"release_date,omitempty"` Temperature *bool `json:"temperature,omitempty"` ToolCall *bool `json:"tool_call,omitempty"` } `json:"models"` @@ -449,14 +451,16 @@ type ModelInfo struct { Input float32 `json:"input"` Output float32 `json:"output"` } `json:"cost"` - Id string `json:"id"` - Limit struct { + Id string `json:"id"` + LastUpdated *string `json:"last_updated,omitempty"` + Limit struct { Context float32 `json:"context"` Output float32 `json:"output"` } `json:"limit"` Name string `json:"name"` Options map[string]interface{} `json:"options"` Reasoning bool `json:"reasoning"` + ReleaseDate *string `json:"release_date,omitempty"` Temperature bool `json:"temperature"` ToolCall bool `json:"tool_call"` } From 29e36ec946434d611ee1ae4693a16018ca68935e Mon Sep 17 00:00:00 2001 From: Saatvik Arya Date: Sat, 21 Jun 2025 18:38:38 +0530 Subject: [PATCH 13/15] feat(dialog): implement sorting functionality for model selection with sort mode cycling --- .../tui/internal/components/dialog/models.go | 144 ++++++++++++++++-- 1 file changed, 133 insertions(+), 11 deletions(-) diff --git a/packages/tui/internal/components/dialog/models.go b/packages/tui/internal/components/dialog/models.go index d5bbdcaddfa..1284abfb446 100644 --- a/packages/tui/internal/components/dialog/models.go +++ b/packages/tui/internal/components/dialog/models.go @@ -3,6 +3,7 @@ package dialog import ( "context" "fmt" + "maps" "slices" "strings" @@ -30,6 +31,14 @@ const ( TurboModelPane ) +type SortMode int + +const ( + SortByName SortMode = iota + SortByLastUpdated + SortByReleaseDate +) + // ModelDialog interface for the model selection dialog type ModelDialog interface { layout.Modal @@ -52,6 +61,7 @@ type modelDialog struct { // UI state activePane ActivePane + sortMode SortMode width int height int hScrollPossible bool @@ -65,6 +75,7 @@ type modelKeyMap struct { Left key.Binding Right key.Binding Tab key.Binding + Sort key.Binding Enter key.Binding Escape key.Binding } @@ -90,6 +101,10 @@ var modelKeys = modelKeyMap{ key.WithKeys("tab"), key.WithHelp("tab", "switch pane"), ), + Sort: key.NewBinding( + key.WithKeys("s"), + key.WithHelp("s", "change sort mode"), + ), Enter: key.NewBinding( key.WithKeys("enter"), key.WithHelp("enter", "save selection"), @@ -172,6 +187,8 @@ func (m *modelDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } case key.Matches(msg, modelKeys.Tab): m.switchPane() + case key.Matches(msg, modelKeys.Sort): + m.cycleSortMode() case key.Matches(msg, modelKeys.Enter): // Get selected models from both panes mainModels := m.getModelsForProvider(m.mainProvider) @@ -206,13 +223,79 @@ func (m *modelDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } func (m *modelDialog) getModelsForProvider(provider client.ProviderInfo) []client.ModelInfo { - var models []client.ModelInfo - for _, model := range provider.Models { - models = append(models, model) + models := slices.Collect(maps.Values(provider.Models)) + + switch m.sortMode { + case SortByLastUpdated: + slices.SortFunc(models, func(a, b client.ModelInfo) int { + // Sort by last_updated date (newest first) + aDate := m.getModelDate(a, true) + bDate := m.getModelDate(b, true) + + // Models without dates go to the end + if aDate == "" && bDate == "" { + if cmp := strings.Compare(a.Name, b.Name); cmp != 0 { + return cmp + } + return strings.Compare(a.Id, b.Id) + } + if aDate == "" { + return 1 + } + if bDate == "" { + return -1 + } + + // Compare dates (reverse for newest first) + if cmp := strings.Compare(bDate, aDate); cmp != 0 { + return cmp + } + + // If dates are equal, use name as stable tiebreaker + if cmp := strings.Compare(a.Name, b.Name); cmp != 0 { + return cmp + } + // Final tiebreaker: use ID for absolute stability + return strings.Compare(a.Id, b.Id) + }) + case SortByReleaseDate: + slices.SortFunc(models, func(a, b client.ModelInfo) int { + // Sort by release_date (newest first) + aDate := m.getModelDate(a, false) + bDate := m.getModelDate(b, false) + + // Models without dates go to the end + if aDate == "" && bDate == "" { + if cmp := strings.Compare(a.Name, b.Name); cmp != 0 { + return cmp + } + return strings.Compare(a.Id, b.Id) + } + if aDate == "" { + return 1 + } + if bDate == "" { + return -1 + } + + // Compare dates (reverse for newest first) + if cmp := strings.Compare(bDate, aDate); cmp != 0 { + return cmp + } + + // If dates are equal, use name as stable tiebreaker + if cmp := strings.Compare(a.Name, b.Name); cmp != 0 { + return cmp + } + // Final tiebreaker: use ID for absolute stability + return strings.Compare(a.Id, b.Id) + }) + default: // SortByName + slices.SortFunc(models, func(a, b client.ModelInfo) int { + return strings.Compare(a.Name, b.Name) + }) } - slices.SortFunc(models, func(a, b client.ModelInfo) int { - return strings.Compare(a.Name, b.Name) - }) + return models } @@ -296,7 +379,7 @@ func (m *modelDialog) switchProvider(offset int) { m.mainSelectedIdx = 0 m.mainScrollOffset = 0 // Update modal title like the original when switching main provider - m.modal.SetTitle(fmt.Sprintf("Select Models - %s", m.mainProvider.Name)) + m.updateModalTitle() } else { currentIdx := 0 for i, p := range m.availableProviders { @@ -325,6 +408,43 @@ func (m *modelDialog) switchPane() { } } +func (m *modelDialog) cycleSortMode() { + m.sortMode = (m.sortMode + 1) % 3 + // Reset scroll positions when changing sort mode + m.mainSelectedIdx = 0 + m.mainScrollOffset = 0 + m.turboSelectedIdx = 0 + m.turboScrollOffset = 0 + // Update title to show new sort mode + m.updateModalTitle() +} + +func (m *modelDialog) getModelDate(model client.ModelInfo, useLastUpdated bool) string { + if useLastUpdated && model.LastUpdated != nil { + return *model.LastUpdated + } + if model.ReleaseDate != nil { + return *model.ReleaseDate + } + return "" +} + +func (m *modelDialog) getSortModeString() string { + switch m.sortMode { + case SortByLastUpdated: + return "Last Updated" + case SortByReleaseDate: + return "Release Date" + default: + return "Name" + } +} + +func (m *modelDialog) updateModalTitle() { + title := fmt.Sprintf("Select Models - %s (Sort: %s)", m.mainProvider.Name, m.getSortModeString()) + m.modal.SetTitle(title) +} + func (m *modelDialog) View() string { t := theme.CurrentTheme() @@ -548,7 +668,9 @@ func (m *modelDialog) getScrollIndicators(maxWidth int) string { // Add tab hint if indicator != "" { - indicator += " • [Tab] Switch pane" + indicator += " • [Tab] Switch pane • [S] Sort" + } else { + indicator = "[S] Sort" } // Add emoji legend @@ -625,13 +747,13 @@ func NewModelDialog(app *app.App) ModelDialog { turboProvider: turboProvider, hScrollPossible: len(availableProviders) > 1, activePane: MainModelPane, - modal: modal.New( - modal.WithTitle("Select Models"), - ), + sortMode: SortByName, + modal: modal.New(modal.WithTitle("Select Models")), } // Initialize will set up the selections based on current models dialog.Init() + dialog.updateModalTitle() return dialog } From 3e92d4bfe54faf1e8c84ed0d146fa2959a512486 Mon Sep 17 00:00:00 2001 From: Saatvik Arya Date: Wed, 25 Jun 2025 03:04:48 +0530 Subject: [PATCH 14/15] fix(dialog): clean up whitespace and improve scroll indicators for turbo models --- .../tui/internal/components/dialog/models.go | 39 ++++++++++++------- 1 file changed, 25 insertions(+), 14 deletions(-) diff --git a/packages/tui/internal/components/dialog/models.go b/packages/tui/internal/components/dialog/models.go index 1284abfb446..56deb8a28df 100644 --- a/packages/tui/internal/components/dialog/models.go +++ b/packages/tui/internal/components/dialog/models.go @@ -224,14 +224,14 @@ func (m *modelDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (m *modelDialog) getModelsForProvider(provider client.ProviderInfo) []client.ModelInfo { models := slices.Collect(maps.Values(provider.Models)) - + switch m.sortMode { case SortByLastUpdated: slices.SortFunc(models, func(a, b client.ModelInfo) int { // Sort by last_updated date (newest first) aDate := m.getModelDate(a, true) bDate := m.getModelDate(b, true) - + // Models without dates go to the end if aDate == "" && bDate == "" { if cmp := strings.Compare(a.Name, b.Name); cmp != 0 { @@ -245,12 +245,12 @@ func (m *modelDialog) getModelsForProvider(provider client.ProviderInfo) []clien if bDate == "" { return -1 } - + // Compare dates (reverse for newest first) if cmp := strings.Compare(bDate, aDate); cmp != 0 { return cmp } - + // If dates are equal, use name as stable tiebreaker if cmp := strings.Compare(a.Name, b.Name); cmp != 0 { return cmp @@ -263,7 +263,7 @@ func (m *modelDialog) getModelsForProvider(provider client.ProviderInfo) []clien // Sort by release_date (newest first) aDate := m.getModelDate(a, false) bDate := m.getModelDate(b, false) - + // Models without dates go to the end if aDate == "" && bDate == "" { if cmp := strings.Compare(a.Name, b.Name); cmp != 0 { @@ -277,12 +277,12 @@ func (m *modelDialog) getModelsForProvider(provider client.ProviderInfo) []clien if bDate == "" { return -1 } - + // Compare dates (reverse for newest first) if cmp := strings.Compare(bDate, aDate); cmp != 0 { return cmp } - + // If dates are equal, use name as stable tiebreaker if cmp := strings.Compare(a.Name, b.Name); cmp != 0 { return cmp @@ -295,7 +295,7 @@ func (m *modelDialog) getModelsForProvider(provider client.ProviderInfo) []clien return strings.Compare(a.Name, b.Name) }) } - + return models } @@ -441,7 +441,7 @@ func (m *modelDialog) getSortModeString() string { } func (m *modelDialog) updateModalTitle() { - title := fmt.Sprintf("Select Models - %s (Sort: %s)", m.mainProvider.Name, m.getSortModeString()) + title := fmt.Sprintf("Select Models - (Sort: %s)", m.getSortModeString()) m.modal.SetTitle(title) } @@ -555,7 +555,7 @@ func (m *modelDialog) renderPane(title string, provider client.ProviderInfo, sel // Build model display name modelName := model.Name - + // Build capability indicators var capabilities []string if isTurbo { @@ -567,17 +567,17 @@ func (m *modelDialog) renderPane(title string, provider client.ProviderInfo, sel if model.ToolCall { capabilities = append(capabilities, "🔧") } - + // Calculate spacing to right-align capabilities capabilityStr := strings.Join(capabilities, "") modelNameWidth := lipgloss.Width(modelName) capabilityWidth := lipgloss.Width(capabilityStr) availableSpace := paneWidth - modelNameWidth - capabilityWidth - 2 // 2 for padding - + if availableSpace < 1 { availableSpace = 1 // At least one space } - + spacer := strings.Repeat(" ", availableSpace) displayText := modelName + spacer + capabilityStr @@ -661,6 +661,17 @@ func (m *modelDialog) getScrollIndicators(maxWidth int) string { } } + // Check if turbo models have scroll + turboModels := len(m.turboProvider.Models) + if turboModels > numVisibleModels { + if m.turboScrollOffset > 0 { + indicator += "↑ " + } + if m.turboScrollOffset+numVisibleModels < turboModels { + indicator += "↓ " + } + } + // Add horizontal scroll indicators if m.hScrollPossible { indicator = "← " + indicator + "→" @@ -672,7 +683,7 @@ func (m *modelDialog) getScrollIndicators(maxWidth int) string { } else { indicator = "[S] Sort" } - + // Add emoji legend legend := "⚡ turbo • 🧠 reasoning • 🔧 tools" if indicator != "" { From 0089da817bad0dc3b08f33f1375b6c1de486cb1b Mon Sep 17 00:00:00 2001 From: Saatvik Arya Date: Fri, 27 Jun 2025 13:18:28 +0530 Subject: [PATCH 15/15] refactor: simplify renderPane styling logic in modelDialog --- .../tui/internal/components/dialog/models.go | 24 ++++++++++++------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/packages/tui/internal/components/dialog/models.go b/packages/tui/internal/components/dialog/models.go index d6a18096048..96098f2d6b3 100644 --- a/packages/tui/internal/components/dialog/models.go +++ b/packages/tui/internal/components/dialog/models.go @@ -526,7 +526,7 @@ func (m *modelDialog) View() string { return content } -func (m *modelDialog) renderPane(title string, provider client.ProviderInfo, selectedIdx, scrollOffset int, isActive bool, baseStyle lipgloss.Style) string { +func (m *modelDialog) renderPane(title string, provider client.ProviderInfo, selectedIdx, scrollOffset int, isActive bool, _ lipgloss.Style) string { t := theme.CurrentTheme() // Simple header like in the original dialog @@ -582,19 +582,21 @@ func (m *modelDialog) renderPane(title string, provider client.ProviderInfo, sel spacer := strings.Repeat(" ", availableSpace) displayText := modelName + spacer + capabilityStr - // Apply styling based on selection and pane state - itemStyle := baseStyle.Width(paneWidth) + // Default style for all items + itemStyle := lipgloss.NewStyle(). + Width(paneWidth). + Background(t.BackgroundElement()). + Foreground(t.TextMuted()) + + // Override for selected items if i == selectedIdx { if isActive { - // Active selection - use primary color like the original dialog itemStyle = itemStyle. Background(t.Primary()). Foreground(t.BackgroundElement()). Bold(true) } else { - // Inactive selection - use accent color to show selection itemStyle = itemStyle. - Background(t.BackgroundElement()). Foreground(t.Accent()). Bold(true) } @@ -605,7 +607,10 @@ func (m *modelDialog) renderPane(title string, provider client.ProviderInfo, sel // Pad to ensure consistent height for len(modelItems) < numVisibleModels { - modelItems = append(modelItems, baseStyle.Width(paneWidth).Render(" ")) + modelItems = append(modelItems, lipgloss.NewStyle(). + Width(paneWidth). + Background(t.BackgroundElement()). + Render(" ")) } // Join all models @@ -634,7 +639,10 @@ func (m *modelDialog) renderPane(title string, provider client.ProviderInfo, sel Align(lipgloss.Center). Render(scrollIndicatorContent) } else { - scrollIndicator = baseStyle.Width(paneWidth).Render(" ") + scrollIndicator = lipgloss.NewStyle(). + Width(paneWidth). + Background(t.BackgroundElement()). + Render(" ") } // Combine all parts