diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index d53f4dda..a04f277b 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(), + turbo_model: z + .string() + .describe("Turbo 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 269afa47..9aaeaa57 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -386,6 +386,41 @@ export namespace Provider { } } + export async function getTurboModel(providerID: string): Promise<{ info: ModelsDev.Model; language: LanguageModel } | null> { + const cfg = await Config.get() + + // Check user override + if (cfg.turbo_model) { + try { + // 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 turbo model", { turbo_model: cfg.turbo_model, error: e }) + } + } + + const providers = await list() + const provider = providers[providerID] + if (!provider) return null + + // 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) { + 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 + } + } + } + return selected + } + const TOOLS = [ BashTool, EditTool, diff --git a/packages/tui/internal/app/app.go b/packages/tui/internal/app/app.go index 4c156b68..30e3a333 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 + TurboProvider *client.ProviderInfo + TurboModel *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 + TurboProvider client.ProviderInfo + TurboModel 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.TurboModel != nil { + appState.TurboProvider, appState.TurboModel = util.ParseModel(*configInfo.TurboModel) } // 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 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 + } + } + // TODO: handle no provider or model setup, yet return ModelSelectedMsg{ - Provider: *currentProvider, - Model: *currentModel, + MainProvider: *currentProvider, + MainModel: *currentModel, + TurboProvider: *turboProvider, + TurboModel: *turboModel, } } } @@ -202,6 +237,20 @@ 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 + } + } + } + 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) @@ -258,10 +307,18 @@ func (a *App) InitializeProject(ctx context.Context) tea.Cmd { func (a *App) CompactSession(ctx context.Context) tea.Cmd { go func() { + // Use turbo model for summarization if available + providerID := a.MainProvider.Id + modelID := a.MainModel.Id + if a.TurboProvider != nil && a.TurboModel != nil { + providerID = a.TurboProvider.Id + modelID = a.TurboModel.Id + } + response, err := a.Client.PostSessionSummarizeWithResponse(ctx, client.PostSessionSummarizeJSONRequestBody{ SessionID: a.Session.Id, - ProviderID: a.Provider.Id, - ModelID: a.Model.Id, + ProviderID: providerID, + ModelID: modelID, }) if err != nil { slog.Error("Failed to compact session", "error", err) @@ -338,8 +395,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 d67a226f..fd4df246 100644 --- a/packages/tui/internal/components/chat/editor.go +++ b/packages/tui/internal/components/chat/editor.go @@ -128,8 +128,17 @@ 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 && m.app.MainProvider != nil { + model = muted(m.app.MainProvider.Name) + base(" "+m.app.MainModel.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.TurboProvider.Name+"/"+m.app.TurboModel.Name+")") + } + } } 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 5da3c9ee..f19749be 100644 --- a/packages/tui/internal/components/dialog/models.go +++ b/packages/tui/internal/components/dialog/models.go @@ -3,7 +3,6 @@ package dialog import ( "context" "fmt" - "maps" "slices" "strings" @@ -11,18 +10,24 @@ 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/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 +) + +type ActivePane int + +const ( + MainModelPane ActivePane = iota + TurboModelPane ) // ModelDialog interface for the model selection dialog @@ -33,43 +38,118 @@ 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 + 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 } type modelKeyMap struct { + Up key.Binding + Down key.Binding Left key.Binding Right key.Binding + Tab key.Binding Enter key.Binding Escape key.Binding } 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("←", "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) + 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 turbo provider and model + m.turboProvider = m.mainProvider // Default to same as main + + if m.app.TurboProvider != nil && m.app.TurboModel != nil { + m.turboProvider = *m.app.TurboProvider + + models := m.getModelsForProvider(m.turboProvider) + for i, model := range models { + if model.Id == m.app.TurboModel.Id { + m.turboSelectedIdx = i + // Adjust scroll position to keep selected model visible + if m.turboSelectedIdx >= numVisibleModels { + m.turboScrollOffset = m.turboSelectedIdx - (numVisibleModels - 1) + } + break + } + } + } else { + // 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) { + m.turboSelectedIdx = i + break + } + } + } + return nil } @@ -77,32 +157,40 @@ 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): + m.switchPane() 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 - break - } + // Get selected models from both panes + mainModels := m.getModelsForProvider(m.mainProvider) + turboModels := m.getModelsForProvider(m.turboProvider) + + if len(mainModels) == 0 || len(turboModels) == 0 { + return m, nil } + + mainSelectedModel := mainModels[m.mainSelectedIdx] + turboSelectedModel := turboModels[m.turboSelectedIdx] + return m, tea.Sequence( util.CmdHandler(modal.CloseModalMsg{}), util.CmdHandler( app.ModelSelectedMsg{ - Provider: m.provider, - Model: selectedModel, + MainProvider: m.mainProvider, + MainModel: mainSelectedModel, + TurboProvider: m.turboProvider, + TurboModel: turboSelectedModel, }), ) case key.Matches(msg, modelKeys.Escape): @@ -113,113 +201,423 @@ 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 + return m, nil } -func (m *modelDialog) models() []client.ModelInfo { - models := slices.SortedFunc(maps.Values(m.provider.Models), func(a, b client.ModelInfo) int { +func (m *modelDialog) getModelsForProvider(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 +func (m *modelDialog) moveSelectionUp() { + if m.activePane == MainModelPane { + 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 + } + } else { + models := m.getModelsForProvider(m.turboProvider) + if m.turboSelectedIdx > 0 { + m.turboSelectedIdx-- + } else { + m.turboSelectedIdx = len(models) - 1 + m.turboScrollOffset = max(0, len(models)-numVisibleModels) + } + + // Keep selection visible + if m.turboSelectedIdx < m.turboScrollOffset { + m.turboScrollOffset = m.turboSelectedIdx + } + } +} + +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.turboProvider) + if m.turboSelectedIdx < len(models)-1 { + m.turboSelectedIdx++ + } else { + m.turboSelectedIdx = 0 + m.turboScrollOffset = 0 + } - if newOffset < 0 { - newOffset = len(m.availableProviders) - 1 + // Keep selection visible + if m.turboSelectedIdx >= m.turboScrollOffset+numVisibleModels { + m.turboScrollOffset = m.turboSelectedIdx - (numVisibleModels - 1) + } } - if newOffset >= len(m.availableProviders) { - newOffset = 0 +} + +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 { + currentIdx := 0 + for i, p := range m.availableProviders { + if p.Id == m.turboProvider.Id { + currentIdx = i + break + } + } + newIdx = currentIdx + offset + if newIdx < 0 { + newIdx = len(m.availableProviders) - 1 + } else if newIdx >= len(m.availableProviders) { + newIdx = 0 + } + m.turboProvider = m.availableProviders[newIdx] + m.turboSelectedIdx = 0 + m.turboScrollOffset = 0 } +} - 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) switchPane() { + if m.activePane == MainModelPane { + m.activePane = TurboModelPane + } else { + m.activePane = MainModelPane + } } func (m *modelDialog) View() string { - listView := m.modelList.View() - scrollIndicator := m.getScrollIndicators(maxDialogWidth) - return strings.Join([]string{listView, scrollIndicator}, "\n") + 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()). + Foreground(t.Text()) + + // Render main model pane + mainPane := m.renderPane( + "Main Model", + m.mainProvider, + m.mainSelectedIdx, + m.mainScrollOffset, + m.activePane == MainModelPane, + baseStyle, + ) + + // Render turbo model pane + turboPane := m.renderPane( + "Turbo Model", + m.turboProvider, + m.turboSelectedIdx, + m.turboScrollOffset, + m.activePane == TurboModelPane, + 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, + turboPane, + ) + + // 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] + isTurbo := isTurboModel(model) + + // Build model display name + modelName := model.Name + if isTurbo { + modelName = fmt.Sprintf("⚡ %s", modelName) + } + + // 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 + 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 += "↓" + } + } + + var scrollIndicator string + if scrollIndicatorContent != "" { + scrollIndicator = lipgloss.NewStyle(). + Background(t.BackgroundElement()). + Foreground(t.Primary()). + Width(paneWidth). + Align(lipgloss.Center). + Render(scrollIndicatorContent) + } else { + scrollIndicator = baseStyle.Width(paneWidth).Render(" ") + } + + // Combine all parts + return lipgloss.JoinVertical( + lipgloss.Left, + headerRendered, + modelList, + scrollIndicator, + ) } func (m *modelDialog) getScrollIndicators(maxWidth int) string { + t := theme.CurrentTheme() + 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 = "← → (switch provider) " + indicator = "← " + indicator + "→" } + + // Add tab hint + if indicator != "" { + indicator += " • [Tab] Switch pane" + } + if indicator == "" { - return "" + return lipgloss.NewStyle(). + Background(t.BackgroundElement()). + Width(maxWidth). + Render(" ") } - t := theme.CurrentTheme() - return styles.BaseStyle(). - Foreground(t.TextMuted()). + return lipgloss.NewStyle(). Width(maxWidth). - Align(lipgloss.Right). + Align(lipgloss.Center). + Foreground(t.TextMuted()). + Background(t.BackgroundElement()). Render(indicator) } -func (m *modelDialog) setupModelsForProvider(providerId string) { - models := m.models() - modelNames := make([]string, len(models)) - for i, model := range models { - modelNames[i] = model.Name +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", } - 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 - } + modelLower := strings.ToLower(model.Id) + for _, lm := range turboModels { + if strings.Contains(modelLower, lm) { + return true } } + return false } func (m *modelDialog) Render(background string) string { - return m.modal.Render(m.View(), background) + if m.modal != nil { + return m.modal.Render(m.View(), background) + } + return "" } -func (s *modelDialog) Close() tea.Cmd { - return nil +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 - break - } + 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] + turboProvider := availableProviders[0] + dialog := &modelDialog{ app: app, availableProviders: availableProviders, - hScrollOffset: hScrollOffset, + mainProvider: mainProvider, + turboProvider: turboProvider, 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(fmt.Sprintf("Select Models - %s", mainProvider.Name)), ), } - dialog.setupModelsForProvider(currentProvider.Id) + // Initialize will set up the selections based on current models + dialog.Init() + return dialog } + +// UpdateModelContext updates the context with selected models +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, "turbo_provider", turboProvider) + ctx = context.WithValue(ctx, "turbo_model", turboModel) + return ctx +} diff --git a/packages/tui/internal/components/status/status.go b/packages/tui/internal/components/status/status.go index 62d20708..0aa83a68 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 29db8657..31c6eb47 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"` + 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 503af9fe..18c6fe9b 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.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.TurboProvider = msg.TurboProvider.Id + a.app.State.TurboModel = msg.TurboModel.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 c7fd98a8..7dff3ac7 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/gen/openapi.json b/packages/tui/pkg/client/gen/openapi.json index e804accf..e46bbad5 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" }, + "turbo_model": { + "type": "string", + "description": "Turbo 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 4ef9b77e..d2edb183 100644 --- a/packages/tui/pkg/client/generated-client.go +++ b/packages/tui/pkg/client/generated-client.go @@ -91,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.