diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index bf3c0ecdf6..c973cf57f9 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -154,6 +154,15 @@ 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(), + 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({ @@ -220,7 +229,7 @@ export namespace Config { ) await fs.unlink(path.join(Global.Path.config, "config")) }) - .catch(() => {}) + .catch(() => { }) return result }) diff --git a/packages/opencode/src/provider/models.ts b/packages/opencode/src/provider/models.ts index 5b255ecbdf..ec21f7f7c0 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/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 2fdf0c183f..75855174c9 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -39,15 +39,15 @@ 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 { autoload: 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 { autoload: true, options: { @@ -395,6 +395,50 @@ 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() + + // 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 + + // 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 <= threshold) { + 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 e8775921a5..2b03b7b0cf 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" @@ -23,24 +22,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 { @@ -89,9 +93,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 @@ -174,11 +179,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 } } @@ -189,10 +194,15 @@ func (a *App) InitializeProvider() tea.Cmd { currentModel = defaultModel } + // Initialize turbo model based on config or defaults + turboProvider, turboModel := findTurboModel(a.State, a.Config, providers, currentProvider, currentModel) + // TODO: handle no provider or model setup, yet return ModelSelectedMsg{ - Provider: *currentProvider, - Model: *currentModel, + MainProvider: *currentProvider, + MainModel: *currentModel, + TurboProvider: *turboProvider, + TurboModel: *turboModel, } } } @@ -209,6 +219,44 @@ func getDefaultModel(response *client.PostProviderListResponse, provider client. return nil } +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 + } + } + } + } + } + + // 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 { FilePath string FileName string @@ -247,8 +295,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) @@ -265,10 +313,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) @@ -345,8 +401,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 0ac3978a8a..ade299b10b 100644 --- a/packages/tui/internal/components/chat/editor.go +++ b/packages/tui/internal/components/chat/editor.go @@ -142,8 +142,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 52ece493fe..96098f2d6b 100644 --- a/packages/tui/internal/components/dialog/models.go +++ b/packages/tui/internal/components/dialog/models.go @@ -11,7 +11,6 @@ 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" @@ -21,8 +20,24 @@ import ( ) const ( - numVisibleModels = 6 - maxDialogWidth = 40 + numVisibleModels = 10 + paneWidth = 40 + totalDialogWidth = paneWidth*2 + 3 // 2 panes + divider +) + +type ActivePane int + +const ( + MainModelPane ActivePane = iota + TurboModelPane +) + +type SortMode int + +const ( + SortByName SortMode = iota + SortByLastUpdated + SortByReleaseDate ) // ModelDialog interface for the model selection dialog @@ -33,43 +48,125 @@ 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] + turboCostThreshold float32 + + // Main model selection + mainProvider client.ProviderInfo + mainSelectedIdx int + mainScrollOffset int + + // Turbo model selection + turboProvider client.ProviderInfo + turboSelectedIdx int + turboScrollOffset int + + // UI state + activePane ActivePane + sortMode SortMode + 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 + Sort 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"), + ), + Sort: key.NewBinding( + key.WithKeys("s"), + key.WithHelp("s", "change sort mode"), ), 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.turboCostThreshold) { + m.turboSelectedIdx = i + break + } + } + } + return nil } @@ -77,32 +174,42 @@ 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.Sort): + m.cycleSortMode() 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 +220,570 @@ 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 { - return strings.Compare(a.Name, b.Name) - }) +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 { + 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) + }) + } + return models } +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 + } + + // Keep selection visible + if m.turboSelectedIdx >= m.turboScrollOffset+numVisibleModels { + m.turboScrollOffset = m.turboSelectedIdx - (numVisibleModels - 1) + } + } +} + func (m *modelDialog) switchProvider(offset int) { - newOffset := m.hScrollOffset + offset + 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.updateModalTitle() + } 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 + } +} + +func (m *modelDialog) switchPane() { + if m.activePane == MainModelPane { + m.activePane = TurboModelPane + } else { + m.activePane = MainModelPane + } +} + +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() +} - if newOffset < 0 { - newOffset = len(m.availableProviders) - 1 +func (m *modelDialog) getModelDate(model client.ModelInfo, useLastUpdated bool) string { + if useLastUpdated && model.LastUpdated != nil { + return *model.LastUpdated } - if newOffset >= len(m.availableProviders) { - newOffset = 0 + 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" } +} - 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) updateModalTitle() { + title := fmt.Sprintf("Select Models - (Sort: %s)", m.getSortModeString()) + m.modal.SetTitle(title) } 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, _ 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, m.turboCostThreshold) + + // Build model display name + modelName := model.Name + + // Build capability indicators + var capabilities []string + if isTurbo { + 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 + + // 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 { + itemStyle = itemStyle. + Background(t.Primary()). + Foreground(t.BackgroundElement()). + Bold(true) + } else { + itemStyle = itemStyle. + Foreground(t.Accent()). + Bold(true) + } + } + + modelItems = append(modelItems, itemStyle.Render(displayText)) + } + + // Pad to ensure consistent height + for len(modelItems) < numVisibleModels { + modelItems = append(modelItems, lipgloss.NewStyle(). + Width(paneWidth). + Background(t.BackgroundElement()). + 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 = lipgloss.NewStyle(). + Width(paneWidth). + Background(t.BackgroundElement()). + 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 += "↓ " + } + } + + // 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 = "← → (switch provider) " + indicator = "← " + indicator + "→" + } + + // Add tab hint + if indicator != "" { + indicator += " • [Tab] Switch pane • [S] Sort" + } else { + indicator = "[S] Sort" } + + // Add emoji legend + legend := "⚡ turbo • 🧠 reasoning • 🔧 tools" + if indicator != "" { + indicator += " • " + legend + } else { + indicator = legend + } + if indicator == "" { - return "" + return lipgloss.NewStyle(). + Background(t.BackgroundElement()). + Width(maxWidth). + Render(" ") } - t := theme.CurrentTheme() return styles.NewStyle(). Foreground(t.TextMuted()). 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 - } - - m.modelList = list.NewStringList(modelNames, numVisibleModels, "No models available", true) - m.modelList.SetMaxWidth(maxDialogWidth) +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 +} - 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 - } - } +func (m *modelDialog) Render(background string) string { + if m.modal != nil { + return m.modal.Render(m.View(), background) } + return "" } -func (m *modelDialog) Render(background string) string { - return m.modal.Render(m.View(), background) +func (m *modelDialog) IsVisible() bool { + return m.modal != nil } -func (s *modelDialog) Close() tea.Cmd { - return 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] + + // 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, - hScrollOffset: hScrollOffset, + turboCostThreshold: turboCostThreshold, + mainProvider: mainProvider, + turboProvider: turboProvider, hScrollPossible: len(availableProviders) > 1, - provider: currentProvider, - modal: modal.New( - modal.WithTitle(fmt.Sprintf("Select %s Model", currentProvider.Name)), - modal.WithMaxWidth(maxDialogWidth+4), - ), + activePane: MainModelPane, + sortMode: SortByName, + modal: modal.New(modal.WithTitle("Select Models")), } - dialog.setupModelsForProvider(currentProvider.Id) + // Initialize will set up the selections based on current models + dialog.Init() + dialog.updateModalTitle() + 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 fb5ff8ced8..7e5d50ac66 100644 --- a/packages/tui/internal/components/status/status.go +++ b/packages/tui/internal/components/status/status.go @@ -97,7 +97,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 29db8657e2..31c6eb477f 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 500ab56d49..899ce292a1 100644 --- a/packages/tui/internal/tui/tui.go +++ b/packages/tui/internal/tui/tui.go @@ -344,10 +344,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 c7fd98a8cc..7dff3ac7b6 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 78d499e7ff..835d44554c 100644 --- a/packages/tui/pkg/client/gen/openapi.json +++ b/packages/tui/pkg/client/gen/openapi.json @@ -1344,6 +1344,15 @@ "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" + }, + "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": { @@ -1423,6 +1432,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})?$" } } } @@ -1658,6 +1675,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 4ef9b77e3c..0a949e42bf 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"` @@ -91,6 +93,12 @@ 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"` } // ConfigInfo_Mcp_AdditionalProperties defines model for Config.Info.mcp.AdditionalProperties. @@ -443,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"` }