From 6fad2f61e8294f8e4ac9222e0af06ca27889118d Mon Sep 17 00:00:00 2001 From: Pierre Berube Date: Thu, 15 May 2025 01:55:06 -0600 Subject: [PATCH 01/22] fix: typo --- internal/tui/components/chat/editor.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/tui/components/chat/editor.go b/internal/tui/components/chat/editor.go index dbe55e7f9f5a..6da18d50d531 100644 --- a/internal/tui/components/chat/editor.go +++ b/internal/tui/components/chat/editor.go @@ -68,7 +68,7 @@ var DeleteKeyMaps = DeleteAttachmentKeyMaps{ ), DeleteAllAttachments: key.NewBinding( key.WithKeys("r"), - key.WithHelp("ctrl+r+r", "delete all attchments"), + key.WithHelp("ctrl+r+r", "delete all attachments"), ), } From 25bac4722eae2156c4e52dee8e646f0539a98181 Mon Sep 17 00:00:00 2001 From: Pierre Berube Date: Thu, 15 May 2025 01:56:45 -0600 Subject: [PATCH 02/22] fix: add initialization logic for setup dialog --- internal/app/app.go | 11 ++++++ internal/config/init.go | 21 ++++++++++++ internal/llm/agent/setup-agent.go | 57 +++++++++++++++++++++++++++++++ 3 files changed, 89 insertions(+) create mode 100644 internal/llm/agent/setup-agent.go diff --git a/internal/app/app.go b/internal/app/app.go index 6c2825047512..20e36fccdc32 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -90,6 +90,17 @@ func New(ctx context.Context, conn *sql.DB) (*App, error) { // Initialize LSP clients in the background go app.initLSPClients(ctx) + if config.ShouldShowSetupDialog() { + app.PrimaryAgent, err = agent.NewSetupAgent() + + if err != nil { + slog.Error("Failed to create setup agent", "error", err) + return nil, err + } + + return app, nil + } + app.PrimaryAgent, err = agent.NewAgent( config.AgentPrimary, app.Sessions, diff --git a/internal/config/init.go b/internal/config/init.go index 5f8860f5264a..9e73674ac2e3 100644 --- a/internal/config/init.go +++ b/internal/config/init.go @@ -16,6 +16,27 @@ type ProjectInitFlag struct { Initialized bool `json:"initialized"` } +// ShouldShowSetupDialog checks if the setup dialog should be shown +func ShouldShowSetupDialog() bool { + if cfg == nil || len(cfg.Agents) < 1 { + return true + } + + // Ensure primary agent is set + _, exists := cfg.Agents[AgentPrimary] + if !exists { + return true + } + + // Ensure at least one provider is set + err := validateAgent(cfg, AgentPrimary, cfg.Agents[AgentPrimary]) + if err != nil { + return true + } + + return false +} + // ShouldShowInitDialog checks if the initialization dialog should be shown for the current directory func ShouldShowInitDialog() (bool, error) { if cfg == nil { diff --git a/internal/llm/agent/setup-agent.go b/internal/llm/agent/setup-agent.go new file mode 100644 index 000000000000..9db7b30424cd --- /dev/null +++ b/internal/llm/agent/setup-agent.go @@ -0,0 +1,57 @@ +package agent + +import ( + "context" + "fmt" + "github.com/sst/opencode/internal/config" + "github.com/sst/opencode/internal/llm/models" + "github.com/sst/opencode/internal/llm/provider" + "github.com/sst/opencode/internal/message" +) + +type setupAgent struct { +} + +func NewSetupAgent() (Service, error) { + agent := &setupAgent{} + + return agent, nil +} + +func (a *setupAgent) Cancel(sessionID string) { + +} + +func (a *setupAgent) IsBusy() bool { + return true +} + +func (a *setupAgent) IsSessionBusy(sessionID string) bool { + return true +} + +func (a *setupAgent) Run(ctx context.Context, sessionID string, content string, attachments ...message.Attachment) (<-chan AgentEvent, error) { + return nil, ErrSessionBusy +} + +func (a *setupAgent) GetUsage(ctx context.Context, sessionID string) (*int64, error) { + usage := int64(0) + + return &usage, nil +} + +func (a *setupAgent) EstimateContextWindowUsage(ctx context.Context, sessionID string) (float64, bool, error) { + return 0, false, nil +} + +func (a *setupAgent) TrackUsage(ctx context.Context, sessionID string, model models.Model, usage provider.TokenUsage) error { + return nil +} + +func (a *setupAgent) Update(agentName config.AgentName, modelID models.ModelID) (models.Model, error) { + return models.Model{}, fmt.Errorf("cannot change model while processing requests") +} + +func (a *setupAgent) CompactSession(ctx context.Context, sessionID string, force bool) error { + return nil +} From 92fac03830b71e4bd96df33ee2a1b464b02601ae Mon Sep 17 00:00:00 2001 From: Pierre Berube Date: Thu, 15 May 2025 08:44:35 -0600 Subject: [PATCH 03/22] fix: split out bedrock models --- internal/llm/models/bedrock.go | 25 +++++++++++++++++ internal/llm/models/models.go | 50 ++-------------------------------- 2 files changed, 27 insertions(+), 48 deletions(-) create mode 100644 internal/llm/models/bedrock.go diff --git a/internal/llm/models/bedrock.go b/internal/llm/models/bedrock.go new file mode 100644 index 000000000000..06f825654137 --- /dev/null +++ b/internal/llm/models/bedrock.go @@ -0,0 +1,25 @@ +package models + +const ( + ProviderBedrock ModelProvider = "bedrock" + + // Models + BedrockClaude37Sonnet ModelID = "bedrock.claude-3.7-sonnet" +) + +var BedrockModels = map[ModelID]Model{ + BedrockClaude37Sonnet: { + ID: BedrockClaude37Sonnet, + Name: "Bedrock: Claude 3.7 Sonnet", + Provider: ProviderBedrock, + APIModel: "anthropic.claude-3-7-sonnet-20250219-v1:0", + CostPer1MIn: 3.0, + CostPer1MInCached: 3.75, + CostPer1MOutCached: 0.30, + CostPer1MOut: 15.0, + ContextWindow: 200_000, + DefaultMaxTokens: 50_000, + CanReason: true, + SupportsAttachments: true, + }, +} diff --git a/internal/llm/models/models.go b/internal/llm/models/models.go index 16fd406c895e..31094aab6639 100644 --- a/internal/llm/models/models.go +++ b/internal/llm/models/models.go @@ -22,14 +22,7 @@ type Model struct { SupportsAttachments bool `json:"supports_attachments"` } -// Model IDs -const ( // GEMINI - // Bedrock - BedrockClaude37Sonnet ModelID = "bedrock.claude-3.7-sonnet" -) - const ( - ProviderBedrock ModelProvider = "bedrock" // ForTests ProviderMock ModelProvider = "__mock" ) @@ -45,50 +38,11 @@ var ProviderPopularity = map[ModelProvider]int{ ProviderAzure: 7, } -var SupportedModels = map[ModelID]Model{ - // - // // GEMINI - // GEMINI25: { - // ID: GEMINI25, - // Name: "Gemini 2.5 Pro", - // Provider: ProviderGemini, - // APIModel: "gemini-2.5-pro-exp-03-25", - // CostPer1MIn: 0, - // CostPer1MInCached: 0, - // CostPer1MOutCached: 0, - // CostPer1MOut: 0, - // }, - // - // GRMINI20Flash: { - // ID: GRMINI20Flash, - // Name: "Gemini 2.0 Flash", - // Provider: ProviderGemini, - // APIModel: "gemini-2.0-flash", - // CostPer1MIn: 0.1, - // CostPer1MInCached: 0, - // CostPer1MOutCached: 0.025, - // CostPer1MOut: 0.4, - // }, - // - // // Bedrock - BedrockClaude37Sonnet: { - ID: BedrockClaude37Sonnet, - Name: "Bedrock: Claude 3.7 Sonnet", - Provider: ProviderBedrock, - APIModel: "anthropic.claude-3-7-sonnet-20250219-v1:0", - CostPer1MIn: 3.0, - CostPer1MInCached: 3.75, - CostPer1MOutCached: 0.30, - CostPer1MOut: 15.0, - ContextWindow: 200_000, - DefaultMaxTokens: 50_000, - CanReason: true, - SupportsAttachments: true, - }, -} +var SupportedModels = map[ModelID]Model{} func init() { maps.Copy(SupportedModels, AnthropicModels) + maps.Copy(SupportedModels, BedrockModels) maps.Copy(SupportedModels, OpenAIModels) maps.Copy(SupportedModels, GeminiModels) maps.Copy(SupportedModels, GroqModels) From 1a99269562582e1c25531e2230955ece4bb3e649 Mon Sep 17 00:00:00 2001 From: Pierre Berube Date: Thu, 15 May 2025 09:24:21 -0600 Subject: [PATCH 04/22] feat: add setup complete and update logic --- internal/app/app.go | 2 +- internal/config/config.go | 43 +++++++++++++++++--------- internal/config/init.go | 22 +++---------- internal/tui/components/chat/editor.go | 15 +++++++-- 4 files changed, 47 insertions(+), 35 deletions(-) diff --git a/internal/app/app.go b/internal/app/app.go index 20e36fccdc32..b925ec7f90e8 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -90,7 +90,7 @@ func New(ctx context.Context, conn *sql.DB) (*App, error) { // Initialize LSP clients in the background go app.initLSPClients(ctx) - if config.ShouldShowSetupDialog() { + if !config.IsSetupComplete() { app.PrimaryAgent, err = agent.NewSetupAgent() if err != nil { diff --git a/internal/config/config.go b/internal/config/config.go index f9aba238deef..0a0bc7b401e4 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -75,16 +75,17 @@ type TUIConfig struct { // Config is the main configuration structure for the application. type Config struct { - Data Data `json:"data"` - WorkingDir string `json:"wd,omitempty"` - MCPServers map[string]MCPServer `json:"mcpServers,omitempty"` - Providers map[models.ModelProvider]Provider `json:"providers,omitempty"` - LSP map[string]LSPConfig `json:"lsp,omitempty"` - Agents map[AgentName]Agent `json:"agents,omitempty"` - Debug bool `json:"debug,omitempty"` - DebugLSP bool `json:"debugLSP,omitempty"` - ContextPaths []string `json:"contextPaths,omitempty"` - TUI TUIConfig `json:"tui"` + Data Data `json:"data"` + WorkingDir string `json:"wd,omitempty"` + MCPServers map[string]MCPServer `json:"mcpServers,omitempty"` + Providers map[models.ModelProvider]Provider `json:"providers,omitempty"` + LSP map[string]LSPConfig `json:"lsp,omitempty"` + Agents map[AgentName]Agent `json:"agents,omitempty"` + Debug bool `json:"debug,omitempty"` + DebugLSP bool `json:"debugLSP,omitempty"` + ContextPaths []string `json:"contextPaths,omitempty"` + TUI TUIConfig `json:"tui"` + SetupComplete bool `json:"setupComplete,omit"` } // Application constants @@ -124,10 +125,11 @@ func Load(workingDir string, debug bool, lvl *slog.LevelVar) (*Config, error) { } cfg = &Config{ - WorkingDir: workingDir, - MCPServers: make(map[string]MCPServer), - Providers: make(map[models.ModelProvider]Provider), - LSP: make(map[string]LSPConfig), + WorkingDir: workingDir, + MCPServers: make(map[string]MCPServer), + Providers: make(map[models.ModelProvider]Provider), + LSP: make(map[string]LSPConfig), + SetupComplete: true, } configureViper() @@ -163,6 +165,7 @@ func Load(workingDir string, debug bool, lvl *slog.LevelVar) (*Config, error) { } if cfg.Agents == nil { + cfg.SetupComplete = false cfg.Agents = make(map[AgentName]Agent) } @@ -171,9 +174,21 @@ func Load(workingDir string, debug bool, lvl *slog.LevelVar) (*Config, error) { Model: cfg.Agents[AgentTitle].Model, MaxTokens: 80, } + return cfg, nil } +func Update(updateCfg func(config *Config)) error { + if cfg == nil { + return fmt.Errorf("config not loaded") + } + + return updateCfgFile(func(config *Config) { + updateCfg(config) + cfg = config + }) +} + // configureViper sets up viper's configuration paths and environment variables. func configureViper() { viper.SetConfigName(fmt.Sprintf(".%s", appName)) diff --git a/internal/config/init.go b/internal/config/init.go index 9e73674ac2e3..492fbe2c5fb3 100644 --- a/internal/config/init.go +++ b/internal/config/init.go @@ -16,25 +16,13 @@ type ProjectInitFlag struct { Initialized bool `json:"initialized"` } -// ShouldShowSetupDialog checks if the setup dialog should be shown -func ShouldShowSetupDialog() bool { - if cfg == nil || len(cfg.Agents) < 1 { - return true - } - - // Ensure primary agent is set - _, exists := cfg.Agents[AgentPrimary] - if !exists { - return true - } - - // Ensure at least one provider is set - err := validateAgent(cfg, AgentPrimary, cfg.Agents[AgentPrimary]) - if err != nil { - return true +// IsSetupComplete checks if the setup is complete +func IsSetupComplete() bool { + if cfg == nil { + return false } - return false + return cfg.SetupComplete } // ShouldShowInitDialog checks if the initialization dialog should be shown for the current directory diff --git a/internal/tui/components/chat/editor.go b/internal/tui/components/chat/editor.go index 6da18d50d531..aa87ec7b2681 100644 --- a/internal/tui/components/chat/editor.go +++ b/internal/tui/components/chat/editor.go @@ -2,6 +2,7 @@ package chat import ( "fmt" + "github.com/sst/opencode/internal/config" "os" "os/exec" "slices" @@ -221,14 +222,22 @@ func (m *editorCmp) View() string { Bold(true). Foreground(t.Primary()) + // Only show textarea if setup is complete + prefix := "" + textarea := "" + if config.IsSetupComplete() { + prefix = style.Render(">") + textarea = m.textarea.View() + } + if len(m.attachments) == 0 { - return lipgloss.JoinHorizontal(lipgloss.Top, style.Render(">"), m.textarea.View()) + return lipgloss.JoinHorizontal(lipgloss.Top, prefix, textarea) } m.textarea.SetHeight(m.height - 1) + return lipgloss.JoinVertical(lipgloss.Top, m.attachmentsContent(), - lipgloss.JoinHorizontal(lipgloss.Top, style.Render(">"), - m.textarea.View()), + lipgloss.JoinHorizontal(lipgloss.Top, prefix, textarea), ) } From eff23225a83ab4e3389765fb9e1fd3beb35b6cfe Mon Sep 17 00:00:00 2001 From: Pierre Berube Date: Thu, 15 May 2025 09:25:44 -0600 Subject: [PATCH 05/22] feat: add setup dialog --- internal/tui/components/dialog/setup.go | 516 ++++++++++++++++++++++++ internal/tui/tui.go | 118 +++++- 2 files changed, 631 insertions(+), 3 deletions(-) create mode 100644 internal/tui/components/dialog/setup.go diff --git a/internal/tui/components/dialog/setup.go b/internal/tui/components/dialog/setup.go new file mode 100644 index 000000000000..29a415af8c5b --- /dev/null +++ b/internal/tui/components/dialog/setup.go @@ -0,0 +1,516 @@ +package dialog + +import ( + "github.com/charmbracelet/bubbles/help" + "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/sst/opencode/internal/llm/models" + "github.com/sst/opencode/internal/tui/layout" + "github.com/sst/opencode/internal/tui/styles" + "github.com/sst/opencode/internal/tui/theme" + "github.com/sst/opencode/internal/tui/util" + "strings" +) + +type SetupDialog interface { + tea.Model + layout.Bindings +} + +// AvailableProviders returns a list of all available providers +func AvailableProviders() ([]models.ModelProvider, map[models.ModelProvider]string) { + providerLabels := make(map[models.ModelProvider]string) + providerLabels[models.ProviderAnthropic] = "Anthropic" + providerLabels[models.ProviderAzure] = "Azure" + providerLabels[models.ProviderBedrock] = "Bedrock" + providerLabels[models.ProviderGemini] = "Gemini" + providerLabels[models.ProviderGROQ] = "Groq" + providerLabels[models.ProviderOpenAI] = "OpenAI" + providerLabels[models.ProviderOpenRouter] = "OpenRouter" + providerLabels[models.ProviderXAI] = "xAI" + + providerList := make([]models.ModelProvider, 0, len(providerLabels)) + providerList = append(providerList, models.ProviderAnthropic) + providerList = append(providerList, models.ProviderAzure) + providerList = append(providerList, models.ProviderBedrock) + providerList = append(providerList, models.ProviderGemini) + providerList = append(providerList, models.ProviderGROQ) + providerList = append(providerList, models.ProviderOpenAI) + providerList = append(providerList, models.ProviderOpenRouter) + providerList = append(providerList, models.ProviderXAI) + + return providerList, providerLabels +} + +// AvailableModelsByProvider returns a list of all available models by provider +func AvailableModelsByProvider(provider models.ModelProvider) []models.Model { + var modelMap map[models.ModelID]models.Model + + switch provider { + default: + modelMap = map[models.ModelID]models.Model{} + case models.ProviderAnthropic: + modelMap = models.AnthropicModels + case models.ProviderAzure: + modelMap = models.AzureModels + case models.ProviderBedrock: + modelMap = models.BedrockModels + case models.ProviderGemini: + modelMap = models.GeminiModels + case models.ProviderGROQ: + modelMap = models.GroqModels + case models.ProviderOpenAI: + modelMap = models.OpenAIModels + case models.ProviderOpenRouter: + modelMap = models.OpenRouterModels + case models.ProviderXAI: + modelMap = models.XAIModels + } + + models := make([]models.Model, 0, len(modelMap)) + for _, model := range modelMap { + models = append(models, model) + } + + return models +} + +type SetupStep string + +const ( + Start SetupStep = "start" + SelectProvider SetupStep = "select-provider" + SelectModel SetupStep = "select-model" + InputApiKey SetupStep = "input-api-key" +) + +type setupDialogCmp struct { + currentModel string + currentProvider string + help help.Model + keys setupMapping + models []models.Model + providers []models.ModelProvider + providerLabels map[models.ModelProvider]string + selectedModelIdx int + selectedProviderIdx int + step SetupStep + textInput textinput.Model + width int +} + +type setupMapping struct { + Up key.Binding + Down key.Binding + Enter key.Binding + Escape key.Binding +} + +func (m setupMapping) ShortHelp() []key.Binding { + return []key.Binding{m.Escape, m.Enter} +} + +func (m setupMapping) FullHelp() [][]key.Binding { + return [][]key.Binding{} +} + +var setupKeys = setupMapping{ + Up: key.NewBinding( + key.WithKeys("up"), + key.WithHelp("↑", "prev"), + ), + Down: key.NewBinding( + key.WithKeys("down"), + key.WithHelp("↓", "next"), + ), + Enter: key.NewBinding( + key.WithKeys("enter"), + key.WithHelp("↵", "next"), + ), + Escape: key.NewBinding( + key.WithKeys("esc"), + key.WithHelp("esc", "back"), + ), +} + +func (q *setupDialogCmp) Init() tea.Cmd { + return nil +} + +func (q *setupDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + if q.step == Start && key.Matches(msg, setupKeys.Enter) { + q.step = SelectProvider + return q, nil + } + + if q.step == SelectProvider { + switch { + case key.Matches(msg, setupKeys.Up): + q.selectedProviderIdx-- + if q.selectedProviderIdx < 0 { + q.selectedProviderIdx = len(q.providers) - 1 + } + case key.Matches(msg, setupKeys.Down): + q.selectedProviderIdx++ + if q.selectedProviderIdx >= len(q.providers) { + q.selectedProviderIdx = 0 + } + case key.Matches(msg, setupKeys.Enter): + q.models = AvailableModelsByProvider(q.providers[q.selectedProviderIdx]) + q.step = SelectModel + case key.Matches(msg, setupKeys.Escape): + q.step = Start + } + + return q, nil + } + + if q.step == SelectModel { + switch { + case key.Matches(msg, setupKeys.Up): + q.selectedModelIdx-- + if q.selectedModelIdx < 0 { + q.selectedModelIdx = len(q.providers) - 1 + } + case key.Matches(msg, setupKeys.Down): + q.selectedModelIdx++ + if q.selectedModelIdx >= len(q.providers) { + q.selectedProviderIdx = 0 + } + case key.Matches(msg, setupKeys.Enter): + q.step = InputApiKey + q.textInput.Focus() + case key.Matches(msg, setupKeys.Escape): + q.selectedModelIdx = 0 + q.step = SelectProvider + } + + return q, nil + } + + if q.step == InputApiKey { + switch { + case key.Matches(msg, setupKeys.Escape): + q.step = SelectModel + + case key.Matches(msg, setupKeys.Enter): + return q, util.CmdHandler(CloseSetupDialogMsg{ + Provider: q.providers[q.selectedProviderIdx], + Model: q.models[q.selectedModelIdx], + APIKey: q.textInput.Value(), + }) + } + + var cmd tea.Cmd + var cmds []tea.Cmd + + q.textInput, cmd = q.textInput.Update(msg) + cmds = append(cmds, cmd) + + return q, tea.Batch(cmds...) + } + } + + return q, nil +} + +func (q *setupDialogCmp) View() string { + switch q.step { + default: + return q.RenderSetupStep() + case Start: + return q.RenderSetupStep() + case SelectProvider: + return q.RenderSelectProviderStep() + case SelectModel: + return q.RenderSelectModelStep() + case InputApiKey: + return q.RenderInputApiKeyStep() + } +} + +func (q *setupDialogCmp) renderAndPadLine(text string, width int) string { + t := theme.CurrentTheme() + baseStyle := styles.BaseStyle() + spacerStyle := baseStyle.Background(t.Background()) + return text + spacerStyle.Render(strings.Repeat(" ", width-lipgloss.Width(text))) +} + +func (q *setupDialogCmp) RenderSetupStep() string { + t := theme.CurrentTheme() + baseStyle := styles.BaseStyle() + + nextStyle := baseStyle + nextStyle = nextStyle.Background(t.Primary()).Foreground(t.Background()) + spacerStyle := baseStyle.Background(t.Background()) + + nextButton := nextStyle.Padding(0, 1).Render("Proceed") + + buttons := lipgloss.JoinHorizontal(lipgloss.Left, nextButton) + + line1 := "✨ Welcome to OpenCode" + line2 := "Your AI-powered coding companion is almost ready!" + line3 := "Please complete setup by selecting a provider, model, and entering your API key." + + width := lipgloss.Width(line3) + remainingWidth := width - lipgloss.Width(buttons) + if remainingWidth > 0 { + buttons = spacerStyle.Render(strings.Repeat(" ", remainingWidth)) + buttons + } + + title := baseStyle. + Background(t.Background()). + Foreground(t.Primary()). + Bold(true). + Render("Setup Wizard") + + content := baseStyle.Render( + lipgloss.JoinVertical( + lipgloss.Left, + q.renderAndPadLine(title, width), + "", + q.renderAndPadLine(line1, width), + "", + q.renderAndPadLine(line2, width), + "", + q.renderAndPadLine(line3, width), + "", + buttons, + ), + ) + + return baseStyle.Padding(1, 2). + Border(lipgloss.RoundedBorder()). + BorderBackground(t.Background()). + BorderForeground(t.TextMuted()). + Width(lipgloss.Width(content) + 4). + Render(content) +} + +func (q *setupDialogCmp) RenderSelectProviderStep() string { + t := theme.CurrentTheme() + baseStyle := styles.BaseStyle() + + // Calculate max width needed for provider names + maxWidth := 40 // Minimum width + for _, providerName := range q.providers { + if len(providerName) > maxWidth-4 { // Account for padding + maxWidth = len(providerName) + 4 + } + } + + helpStyle := lipgloss.NewStyle().Foreground(t.TextMuted()) + helpText := helpStyle.Render(q.help.View(q.keys)) + helpWidth := lipgloss.Width(helpText) + maxWidth = max(30, min(maxWidth, q.width-15), helpWidth) // Limit width to avoid overflow + + // Add padding to help + remainingWidth := maxWidth - lipgloss.Width(helpText) + if remainingWidth > 0 { + helpText = strings.Repeat(" ", remainingWidth) + helpText + } + + // Build the provider list + providerItems := make([]string, 0, len(q.providers)) + for i, provider := range q.providers { + itemStyle := baseStyle.Width(maxWidth) + + if i == q.selectedProviderIdx { + itemStyle = itemStyle. + Background(t.Primary()). + Foreground(t.Background()). + Bold(true) + } + + providerItems = append(providerItems, itemStyle.Padding(0, 1).Render(q.providerLabels[provider])) + } + + title := baseStyle. + Foreground(t.Primary()). + Bold(true). + Width(maxWidth). + Padding(0, 1). + Render("Select Provider") + + content := lipgloss.JoinVertical( + lipgloss.Left, + title, + baseStyle.Width(maxWidth).Render(""), + baseStyle.Width(maxWidth).Render(lipgloss.JoinVertical(lipgloss.Left, providerItems...)), + baseStyle.Width(maxWidth).Render("\n\n"), + baseStyle.Width(maxWidth).Render(helpText), + ) + + return baseStyle.Padding(1, 2). + Border(lipgloss.RoundedBorder()). + BorderBackground(t.Background()). + BorderForeground(t.TextMuted()). + Width(lipgloss.Width(content) + 4). + Render(content) +} + +func (q *setupDialogCmp) RenderSelectModelStep() string { + t := theme.CurrentTheme() + baseStyle := styles.BaseStyle() + + // Calculate max width needed for model names + maxWidth := 40 // Minimum width + for _, model := range q.models { + if len(model.Name) > maxWidth-4 { // Account for padding + maxWidth = len(model.Name) + 4 + } + } + + helpStyle := lipgloss.NewStyle().Foreground(t.TextMuted()) + helpText := helpStyle.Render(q.help.View(q.keys)) + helpWidth := lipgloss.Width(helpText) + maxWidth = max(30, maxWidth, helpWidth) // Limit width to avoid overflow + + // Add padding to help + remainingWidth := maxWidth - lipgloss.Width(helpText) + if remainingWidth > 0 { + helpText = strings.Repeat(" ", remainingWidth) + helpText + } + + // Build the model list + modelItems := make([]string, 0, len(q.models)) + for i, model := range q.models { + itemStyle := baseStyle.Width(maxWidth) + + if i == q.selectedModelIdx { + itemStyle = itemStyle. + Background(t.Primary()). + Foreground(t.Background()). + Bold(true) + } + + modelItems = append(modelItems, itemStyle.Padding(0, 1).Render(model.Name)) + } + + title := baseStyle. + Foreground(t.Primary()). + Bold(true). + Width(maxWidth). + Padding(0, 1). + Render("Select Model") + + content := lipgloss.JoinVertical( + lipgloss.Left, + title, + baseStyle.Width(maxWidth).Render(""), + baseStyle.Width(maxWidth).Render(lipgloss.JoinVertical(lipgloss.Left, modelItems...)), + baseStyle.Width(maxWidth).Render("\n\n"), + baseStyle.Width(maxWidth).Render(helpText), + ) + + return baseStyle.Padding(1, 2). + Border(lipgloss.RoundedBorder()). + BorderBackground(t.Background()). + BorderForeground(t.TextMuted()). + Width(lipgloss.Width(content) + 4). + Render(content) +} + +func (q *setupDialogCmp) RenderInputApiKeyStep() string { + t := theme.CurrentTheme() + baseStyle := styles.BaseStyle() + + // Calculate width needed for content + maxWidth := 60 // Width for explanation text + + helpStyle := lipgloss.NewStyle().Foreground(t.TextMuted()) + helpText := helpStyle.Render(q.help.View(q.keys)) + helpWidth := lipgloss.Width(helpText) + maxWidth = max(60, helpWidth) // Limit width to avoid overflow + + // Add padding to help + remainingWidth := maxWidth - lipgloss.Width(helpText) + if remainingWidth > 0 { + helpText = strings.Repeat(" ", remainingWidth) + helpText + } + + title := baseStyle. + Foreground(t.Primary()). + Bold(true). + Width(maxWidth). + Padding(0, 1). + Render("API Key") + + inputField := baseStyle. + Foreground(t.Text()). + Width(maxWidth). + Padding(1, 1). + Render(q.textInput.View()) + + maxWidth = min(maxWidth, q.width-10) + + content := lipgloss.JoinVertical( + lipgloss.Left, + title, + inputField, + baseStyle.Width(maxWidth).Render(helpText), + ) + + return baseStyle.Padding(1, 2). + Border(lipgloss.RoundedBorder()). + BorderBackground(t.Background()). + BorderForeground(t.TextMuted()). + Background(t.Background()). + Width(lipgloss.Width(content) + 4). + Render(content) +} + +func (q *setupDialogCmp) BindingKeys() []key.Binding { + return layout.KeyMapToSlice(setupKeys) +} + +func NewSetupDialogCmp() SetupDialog { + textStyle := lipgloss.NewStyle() + separatorStyle := lipgloss.NewStyle() + + help := help.New() + help.ShowAll = false + help.Styles.Ellipsis = textStyle + help.Styles.FullDesc = textStyle + help.Styles.FullKey = textStyle + help.Styles.FullSeparator = separatorStyle + help.Styles.ShortDesc = textStyle + help.Styles.ShortKey = textStyle + help.Styles.ShortSeparator = separatorStyle + + t := theme.CurrentTheme() + ti := textinput.New() + ti.Placeholder = "Enter API Key..." + ti.Width = 56 + ti.Prompt = "" + ti.PlaceholderStyle = ti.PlaceholderStyle.Background(t.Background()) + ti.PromptStyle = ti.PromptStyle.Background(t.Background()) + ti.TextStyle = ti.TextStyle.Background(t.Background()) + + providers, providerLabels := AvailableProviders() + + return &setupDialogCmp{ + help: help, + keys: setupKeys, + providers: providers, + providerLabels: providerLabels, + step: Start, + textInput: ti, + } +} + +// CloseSetupDialogMsg is a message that is sent when the init dialog is closed. +type CloseSetupDialogMsg struct { + APIKey string + Model models.Model + Provider models.ModelProvider +} + +// ShowSetupDialogMsg is a message that is sent to show the init dialog. +type ShowSetupDialogMsg struct { + Show bool +} diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 0bcdbdcd7c10..3f4da89c82a5 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -3,6 +3,8 @@ package tui import ( "context" "fmt" + "github.com/sst/opencode/internal/llm/agent" + "github.com/sst/opencode/internal/llm/models" "log/slog" "strings" @@ -119,6 +121,9 @@ type appModel struct { showSessionDialog bool sessionDialog dialog.SessionDialog + showSetupDialog bool + setupDialog dialog.SetupDialog + showCommandDialog bool commandDialog dialog.CommandDialog commands []dialog.Command @@ -152,6 +157,8 @@ func (a appModel) Init() tea.Cmd { cmds = append(cmds, cmd) cmd = a.sessionDialog.Init() cmds = append(cmds, cmd) + cmd = a.setupDialog.Init() + cmds = append(cmds, cmd) cmd = a.commandDialog.Init() cmds = append(cmds, cmd) cmd = a.modelDialog.Init() @@ -163,14 +170,18 @@ func (a appModel) Init() tea.Cmd { cmd = a.themeDialog.Init() cmds = append(cmds, cmd) - // Check if we should show the init dialog + // Check if we should show the setup or init dialog cmds = append(cmds, func() tea.Msg { - shouldShow, err := config.ShouldShowInitDialog() + if !config.IsSetupComplete() { + return dialog.ShowSetupDialogMsg{Show: true} + } + + shouldShowInit, err := config.ShouldShowInitDialog() if err != nil { status.Error("Failed to check init status: " + err.Error()) return nil } - return dialog.ShowInitDialogMsg{Show: shouldShow} + return dialog.ShowInitDialogMsg{Show: shouldShowInit} }) return tea.Batch(cmds...) @@ -280,6 +291,74 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return a, nil + case dialog.CloseSetupDialogMsg: + a.showSetupDialog = false + + err := config.Update(func(cfg *config.Config) { + // Add Agent + if cfg.Agents == nil { + cfg.Agents = make(map[config.AgentName]config.Agent) + } + cfg.Agents[config.AgentPrimary] = config.Agent{ + Model: msg.Model.ID, + MaxTokens: msg.Model.DefaultMaxTokens, + } + cfg.Agents[config.AgentTitle] = config.Agent{ + Model: msg.Model.ID, + MaxTokens: 80, + } + cfg.Agents[config.AgentTask] = config.Agent{ + Model: msg.Model.ID, + MaxTokens: msg.Model.DefaultMaxTokens, + } + + // Add Provider + if cfg.Providers == nil { + cfg.Providers = make(map[models.ModelProvider]config.Provider) + } + + cfg.Providers[msg.Provider] = config.Provider{ + APIKey: msg.APIKey, + } + + cfg.SetupComplete = true + }) + if err != nil { + slog.Debug("Failed to update config", "error", err) + panic(err) + } + + // Reinitialize the agent + a.app.PrimaryAgent, err = agent.NewAgent( + config.AgentPrimary, + a.app.Sessions, + a.app.Messages, + agent.PrimaryAgentTools( + a.app.Permissions, + a.app.Sessions, + a.app.Messages, + a.app.History, + a.app.LSPClients, + ), + ) + if err != nil { + slog.Debug("Failed to initialize agent", "error", err) + panic(err) + } + + // Show init dialog if project is not initialized + shouldShowInit, err := config.ShouldShowInitDialog() + if err != nil { + status.Error("Failed to check init status: " + err.Error()) + return a, nil + } + if shouldShowInit { + a.showInitDialog = true + return a, nil + } + + return a, nil + case dialog.CloseCommandDialogMsg: a.showCommandDialog = false return a, nil @@ -314,6 +393,10 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { a.showInitDialog = msg.Show return a, nil + case dialog.ShowSetupDialogMsg: + a.showSetupDialog = msg.Show + return a, nil + case dialog.CloseInitDialogMsg: a.showInitDialog = false if msg.Initialize { @@ -385,6 +468,9 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if a.showSessionDialog { a.showSessionDialog = false } + if a.showSetupDialog { + a.showSetupDialog = false + } if a.showCommandDialog { a.showCommandDialog = false } @@ -555,6 +641,16 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } + if a.showSetupDialog { + d, setupCmd := a.setupDialog.Update(msg) + a.setupDialog = d.(dialog.SetupDialog) + cmds = append(cmds, setupCmd) + // Only block key messages send all other messages down + if _, ok := msg.(tea.KeyMsg); ok { + return a, tea.Batch(cmds...) + } + } + if a.showCommandDialog { d, commandCmd := a.commandDialog.Update(msg) a.commandDialog = d.(dialog.CommandDialog) @@ -744,6 +840,21 @@ func (a appModel) View() string { ) } + if a.showSetupDialog { + overlay := a.setupDialog.View() + row := lipgloss.Height(appView) / 2 + row -= lipgloss.Height(overlay) / 2 + col := lipgloss.Width(appView) / 2 + col -= lipgloss.Width(overlay) / 2 + appView = layout.PlaceOverlay( + col, + row, + overlay, + appView, + true, + ) + } + if a.showModelDialog { overlay := a.modelDialog.View() row := lipgloss.Height(appView) / 2 @@ -827,6 +938,7 @@ func New(app *app.App) tea.Model { help: dialog.NewHelpCmp(), quit: dialog.NewQuitCmp(), sessionDialog: dialog.NewSessionDialogCmp(), + setupDialog: dialog.NewSetupDialogCmp(), commandDialog: dialog.NewCommandDialogCmp(), modelDialog: dialog.NewModelDialogCmp(), permissions: dialog.NewPermissionDialogCmp(), From d4648c21cd281a899e1b4f6521b0e7a1db23bfb6 Mon Sep 17 00:00:00 2001 From: Pierre Berube Date: Thu, 15 May 2025 09:33:19 -0600 Subject: [PATCH 06/22] fix: sort models by alphabetical order --- internal/tui/components/dialog/setup.go | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/internal/tui/components/dialog/setup.go b/internal/tui/components/dialog/setup.go index 29a415af8c5b..781a443d0502 100644 --- a/internal/tui/components/dialog/setup.go +++ b/internal/tui/components/dialog/setup.go @@ -74,6 +74,15 @@ func AvailableModelsByProvider(provider models.ModelProvider) []models.Model { models = append(models, model) } + // Sort models by alphabetical order + for i := 0; i < len(models)-1; i++ { + for j := i + 1; j < len(models); j++ { + if models[i].Name > models[j].Name { + models[i], models[j] = models[j], models[i] + } + } + } + return models } From 735ddc5d5411f6bb825a2a512b4777ffc4654e94 Mon Sep 17 00:00:00 2001 From: Pierre Berube Date: Thu, 15 May 2025 11:02:47 -0600 Subject: [PATCH 07/22] fix: add api key validation & blinking --- internal/tui/components/dialog/setup.go | 23 ++++++++++++++++++++++- internal/tui/tui.go | 1 + 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/internal/tui/components/dialog/setup.go b/internal/tui/components/dialog/setup.go index 781a443d0502..d14f277fe6f0 100644 --- a/internal/tui/components/dialog/setup.go +++ b/internal/tui/components/dialog/setup.go @@ -1,6 +1,7 @@ package dialog import ( + "github.com/charmbracelet/bubbles/cursor" "github.com/charmbracelet/bubbles/help" "github.com/charmbracelet/bubbles/key" "github.com/charmbracelet/bubbles/textinput" @@ -107,6 +108,7 @@ type setupDialogCmp struct { selectedProviderIdx int step SetupStep textInput textinput.Model + textInputError string width int } @@ -145,11 +147,18 @@ var setupKeys = setupMapping{ } func (q *setupDialogCmp) Init() tea.Cmd { - return nil + return tea.Batch(textinput.Blink) } func (q *setupDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { + case cursor.BlinkMsg: + if q.step == InputApiKey { + // textinput.Update() does not work to make the cursor blink + // we need to manually toggle the blink state + q.textInput.Cursor.Blink = !q.textInput.Cursor.Blink + return q, nil + } case tea.KeyMsg: if q.step == Start && key.Matches(msg, setupKeys.Enter) { q.step = SelectProvider @@ -207,6 +216,11 @@ func (q *setupDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { q.step = SelectModel case key.Matches(msg, setupKeys.Enter): + if q.textInput.Value() == "" { + q.textInputError = "Field cannot be empty" + return q, nil + } + return q, util.CmdHandler(CloseSetupDialogMsg{ Provider: q.providers[q.selectedProviderIdx], Model: q.models[q.selectedModelIdx], @@ -455,12 +469,19 @@ func (q *setupDialogCmp) RenderInputApiKeyStep() string { Padding(1, 1). Render(q.textInput.View()) + errorStyle := baseStyle.Foreground(t.Error()).PaddingLeft(1) + errorText := "" + if q.textInputError != "" { + errorText = errorStyle.Render(q.renderAndPadLine(q.textInputError, maxWidth-1)) + } + maxWidth = min(maxWidth, q.width-10) content := lipgloss.JoinVertical( lipgloss.Left, title, inputField, + errorText, baseStyle.Width(maxWidth).Render(helpText), ) diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 3f4da89c82a5..0c720c154989 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -202,6 +202,7 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmd tea.Cmd switch msg := msg.(type) { case cursor.BlinkMsg: + a.setupDialog.Update(msg) return a.updateAllPages(msg) case spinner.TickMsg: return a.updateAllPages(msg) From 98de5ba936848619fc763ec90e958d242f91260d Mon Sep 17 00:00:00 2001 From: Pierre Berube Date: Thu, 15 May 2025 11:30:32 -0600 Subject: [PATCH 08/22] refactor: extract setup in module --- internal/app/app.go | 19 +++++-- internal/config/config.go | 31 ++++++------ internal/config/init.go | 9 ---- internal/setup/setup.go | 69 ++++++++++++++++++++++++++ internal/tui/components/chat/editor.go | 4 +- internal/tui/tui.go | 62 ++++------------------- 6 files changed, 108 insertions(+), 86 deletions(-) create mode 100644 internal/setup/setup.go diff --git a/internal/app/app.go b/internal/app/app.go index b925ec7f90e8..f5d1dddf33ce 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -3,6 +3,7 @@ package app import ( "context" "database/sql" + "github.com/sst/opencode/internal/setup" "maps" "sync" "time" @@ -90,7 +91,7 @@ func New(ctx context.Context, conn *sql.DB) (*App, error) { // Initialize LSP clients in the background go app.initLSPClients(ctx) - if !config.IsSetupComplete() { + if !setup.IsSetupComplete() { app.PrimaryAgent, err = agent.NewSetupAgent() if err != nil { @@ -101,6 +102,15 @@ func New(ctx context.Context, conn *sql.DB) (*App, error) { return app, nil } + app.InitializePrimaryAgent() + + return app, nil +} + +// InitializePrimaryAgent initializes the primary agent with the necessary tools and services +func (app *App) InitializePrimaryAgent() { + var err error + app.PrimaryAgent, err = agent.NewAgent( config.AgentPrimary, app.Sessions, @@ -113,12 +123,11 @@ func New(ctx context.Context, conn *sql.DB) (*App, error) { app.LSPClients, ), ) + if err != nil { - slog.Error("Failed to create primary agent", "error", err) - return nil, err + slog.Error("Failed to initialize primary agent", err) + panic(err) } - - return app, nil } // initTheme sets the application theme based on the configuration diff --git a/internal/config/config.go b/internal/config/config.go index 0a0bc7b401e4..7ecd4bcb8860 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -75,17 +75,16 @@ type TUIConfig struct { // Config is the main configuration structure for the application. type Config struct { - Data Data `json:"data"` - WorkingDir string `json:"wd,omitempty"` - MCPServers map[string]MCPServer `json:"mcpServers,omitempty"` - Providers map[models.ModelProvider]Provider `json:"providers,omitempty"` - LSP map[string]LSPConfig `json:"lsp,omitempty"` - Agents map[AgentName]Agent `json:"agents,omitempty"` - Debug bool `json:"debug,omitempty"` - DebugLSP bool `json:"debugLSP,omitempty"` - ContextPaths []string `json:"contextPaths,omitempty"` - TUI TUIConfig `json:"tui"` - SetupComplete bool `json:"setupComplete,omit"` + Data Data `json:"data"` + WorkingDir string `json:"wd,omitempty"` + MCPServers map[string]MCPServer `json:"mcpServers,omitempty"` + Providers map[models.ModelProvider]Provider `json:"providers,omitempty"` + LSP map[string]LSPConfig `json:"lsp,omitempty"` + Agents map[AgentName]Agent `json:"agents,omitempty"` + Debug bool `json:"debug,omitempty"` + DebugLSP bool `json:"debugLSP,omitempty"` + ContextPaths []string `json:"contextPaths,omitempty"` + TUI TUIConfig `json:"tui"` } // Application constants @@ -125,11 +124,10 @@ func Load(workingDir string, debug bool, lvl *slog.LevelVar) (*Config, error) { } cfg = &Config{ - WorkingDir: workingDir, - MCPServers: make(map[string]MCPServer), - Providers: make(map[models.ModelProvider]Provider), - LSP: make(map[string]LSPConfig), - SetupComplete: true, + WorkingDir: workingDir, + MCPServers: make(map[string]MCPServer), + Providers: make(map[models.ModelProvider]Provider), + LSP: make(map[string]LSPConfig), } configureViper() @@ -165,7 +163,6 @@ func Load(workingDir string, debug bool, lvl *slog.LevelVar) (*Config, error) { } if cfg.Agents == nil { - cfg.SetupComplete = false cfg.Agents = make(map[AgentName]Agent) } diff --git a/internal/config/init.go b/internal/config/init.go index 492fbe2c5fb3..5f8860f5264a 100644 --- a/internal/config/init.go +++ b/internal/config/init.go @@ -16,15 +16,6 @@ type ProjectInitFlag struct { Initialized bool `json:"initialized"` } -// IsSetupComplete checks if the setup is complete -func IsSetupComplete() bool { - if cfg == nil { - return false - } - - return cfg.SetupComplete -} - // ShouldShowInitDialog checks if the initialization dialog should be shown for the current directory func ShouldShowInitDialog() (bool, error) { if cfg == nil { diff --git a/internal/setup/setup.go b/internal/setup/setup.go new file mode 100644 index 000000000000..0fa4fea83ed7 --- /dev/null +++ b/internal/setup/setup.go @@ -0,0 +1,69 @@ +package setup + +import ( + "github.com/sst/opencode/internal/config" + "github.com/sst/opencode/internal/llm/models" + "log/slog" +) + +// Global variable to track if setup is complete +var setupComplete = false + +// IsSetupComplete checks if the setup is complete +func IsSetupComplete() bool { + return setupComplete +} + +func markSetupComplete() { + setupComplete = true +} + +func Init() { + cfg := config.Get() + if cfg == nil || len(cfg.Agents) < 1 { + return + } + + // Ensure primary agent is set + _, exists := cfg.Agents[config.AgentPrimary] + if exists { + markSetupComplete() + } +} + +func CompleteSetup(provider models.ModelProvider, model models.Model, apiKey string) { + err := config.Update(func(cfg *config.Config) { + // Add Agent + if cfg.Agents == nil { + cfg.Agents = make(map[config.AgentName]config.Agent) + } + cfg.Agents[config.AgentPrimary] = config.Agent{ + Model: model.ID, + MaxTokens: model.DefaultMaxTokens, + } + cfg.Agents[config.AgentTitle] = config.Agent{ + Model: model.ID, + MaxTokens: 80, + } + cfg.Agents[config.AgentTask] = config.Agent{ + Model: model.ID, + MaxTokens: model.DefaultMaxTokens, + } + + // Add Provider + if cfg.Providers == nil { + cfg.Providers = make(map[models.ModelProvider]config.Provider) + } + + cfg.Providers[provider] = config.Provider{ + APIKey: apiKey, + } + }) + + if err != nil { + slog.Debug("Failed to complete setup", "error", err) + panic(err) + } + + markSetupComplete() +} diff --git a/internal/tui/components/chat/editor.go b/internal/tui/components/chat/editor.go index aa87ec7b2681..28dee39ad035 100644 --- a/internal/tui/components/chat/editor.go +++ b/internal/tui/components/chat/editor.go @@ -2,7 +2,7 @@ package chat import ( "fmt" - "github.com/sst/opencode/internal/config" + "github.com/sst/opencode/internal/setup" "os" "os/exec" "slices" @@ -225,7 +225,7 @@ func (m *editorCmp) View() string { // Only show textarea if setup is complete prefix := "" textarea := "" - if config.IsSetupComplete() { + if setup.IsSetupComplete() { prefix = style.Render(">") textarea = m.textarea.View() } diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 0c720c154989..f5ad68413f0d 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -3,8 +3,7 @@ package tui import ( "context" "fmt" - "github.com/sst/opencode/internal/llm/agent" - "github.com/sst/opencode/internal/llm/models" + "github.com/sst/opencode/internal/setup" "log/slog" "strings" @@ -170,9 +169,12 @@ func (a appModel) Init() tea.Cmd { cmd = a.themeDialog.Init() cmds = append(cmds, cmd) + // Checks config to see if setup is complete + setup.Init() + // Check if we should show the setup or init dialog cmds = append(cmds, func() tea.Msg { - if !config.IsSetupComplete() { + if !setup.IsSetupComplete() { return dialog.ShowSetupDialogMsg{Show: true} } @@ -295,57 +297,11 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case dialog.CloseSetupDialogMsg: a.showSetupDialog = false - err := config.Update(func(cfg *config.Config) { - // Add Agent - if cfg.Agents == nil { - cfg.Agents = make(map[config.AgentName]config.Agent) - } - cfg.Agents[config.AgentPrimary] = config.Agent{ - Model: msg.Model.ID, - MaxTokens: msg.Model.DefaultMaxTokens, - } - cfg.Agents[config.AgentTitle] = config.Agent{ - Model: msg.Model.ID, - MaxTokens: 80, - } - cfg.Agents[config.AgentTask] = config.Agent{ - Model: msg.Model.ID, - MaxTokens: msg.Model.DefaultMaxTokens, - } - - // Add Provider - if cfg.Providers == nil { - cfg.Providers = make(map[models.ModelProvider]config.Provider) - } + // Complete setup + setup.CompleteSetup(msg.Provider, msg.Model, msg.APIKey) - cfg.Providers[msg.Provider] = config.Provider{ - APIKey: msg.APIKey, - } - - cfg.SetupComplete = true - }) - if err != nil { - slog.Debug("Failed to update config", "error", err) - panic(err) - } - - // Reinitialize the agent - a.app.PrimaryAgent, err = agent.NewAgent( - config.AgentPrimary, - a.app.Sessions, - a.app.Messages, - agent.PrimaryAgentTools( - a.app.Permissions, - a.app.Sessions, - a.app.Messages, - a.app.History, - a.app.LSPClients, - ), - ) - if err != nil { - slog.Debug("Failed to initialize agent", "error", err) - panic(err) - } + // Reinitialize the primary agent + a.app.InitializePrimaryAgent() // Show init dialog if project is not initialized shouldShowInit, err := config.ShouldShowInitDialog() From 08df5ec2599bbe4265d4cf5d4223ef07e5e5c1ea Mon Sep 17 00:00:00 2001 From: Pierre Berube Date: Thu, 15 May 2025 13:52:51 -0600 Subject: [PATCH 09/22] fix: tweak styles --- internal/tui/components/dialog/setup.go | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/internal/tui/components/dialog/setup.go b/internal/tui/components/dialog/setup.go index d14f277fe6f0..a321f56bd8bf 100644 --- a/internal/tui/components/dialog/setup.go +++ b/internal/tui/components/dialog/setup.go @@ -319,17 +319,17 @@ func (q *setupDialogCmp) RenderSelectProviderStep() string { baseStyle := styles.BaseStyle() // Calculate max width needed for provider names - maxWidth := 40 // Minimum width + maxWidth := 36 for _, providerName := range q.providers { - if len(providerName) > maxWidth-4 { // Account for padding - maxWidth = len(providerName) + 4 + if len(providerName) > maxWidth { + maxWidth = len(providerName) } } helpStyle := lipgloss.NewStyle().Foreground(t.TextMuted()) helpText := helpStyle.Render(q.help.View(q.keys)) helpWidth := lipgloss.Width(helpText) - maxWidth = max(30, min(maxWidth, q.width-15), helpWidth) // Limit width to avoid overflow + maxWidth = max(maxWidth, helpWidth) // Add padding to help remainingWidth := maxWidth - lipgloss.Width(helpText) @@ -381,17 +381,17 @@ func (q *setupDialogCmp) RenderSelectModelStep() string { baseStyle := styles.BaseStyle() // Calculate max width needed for model names - maxWidth := 40 // Minimum width + maxWidth := 36 for _, model := range q.models { - if len(model.Name) > maxWidth-4 { // Account for padding - maxWidth = len(model.Name) + 4 + if len(model.Name) > maxWidth { + maxWidth = len(model.Name) } } helpStyle := lipgloss.NewStyle().Foreground(t.TextMuted()) helpText := helpStyle.Render(q.help.View(q.keys)) helpWidth := lipgloss.Width(helpText) - maxWidth = max(30, maxWidth, helpWidth) // Limit width to avoid overflow + maxWidth = max(maxWidth, helpWidth) // Add padding to help remainingWidth := maxWidth - lipgloss.Width(helpText) From 63d45c41170636a3b20f41778ecfef4f79df17d7 Mon Sep 17 00:00:00 2001 From: Pierre Berube Date: Thu, 15 May 2025 17:31:45 -0600 Subject: [PATCH 10/22] refactor: render help --- internal/tui/components/dialog/setup.go | 63 +++++++++++++------------ 1 file changed, 32 insertions(+), 31 deletions(-) diff --git a/internal/tui/components/dialog/setup.go b/internal/tui/components/dialog/setup.go index a321f56bd8bf..0c6f2f6a6d87 100644 --- a/internal/tui/components/dialog/setup.go +++ b/internal/tui/components/dialog/setup.go @@ -2,7 +2,6 @@ package dialog import ( "github.com/charmbracelet/bubbles/cursor" - "github.com/charmbracelet/bubbles/help" "github.com/charmbracelet/bubbles/key" "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" @@ -99,7 +98,6 @@ const ( type setupDialogCmp struct { currentModel string currentProvider string - help help.Model keys setupMapping models []models.Model providers []models.ModelProvider @@ -119,14 +117,6 @@ type setupMapping struct { Escape key.Binding } -func (m setupMapping) ShortHelp() []key.Binding { - return []key.Binding{m.Escape, m.Enter} -} - -func (m setupMapping) FullHelp() [][]key.Binding { - return [][]key.Binding{} -} - var setupKeys = setupMapping{ Up: key.NewBinding( key.WithKeys("up"), @@ -263,6 +253,33 @@ func (q *setupDialogCmp) renderAndPadLine(text string, width int) string { return text + spacerStyle.Render(strings.Repeat(" ", width-lipgloss.Width(text))) } +func (q *setupDialogCmp) renderHelp() string { + // We have to render the help manually due to artifacting when using help.View(q.keys) + // this is a bug with the bubbletea/help package + t := theme.CurrentTheme() + sepStyle := styles.BaseStyle().Foreground(t.Primary()) + sep := sepStyle.Render(" • ") + + keyStyle := styles.BaseStyle().Foreground(t.Text()) + descStyle := styles.BaseStyle().Foreground(t.TextMuted()) + space := styles.BaseStyle().Foreground(t.Background()).Render(" ") + key1 := keyStyle.Render(q.keys.Escape.Help().Key) + desc1 := descStyle.Render(q.keys.Escape.Help().Desc) + key2 := keyStyle.Render(q.keys.Enter.Help().Key) + desc2 := descStyle.Render(q.keys.Enter.Help().Desc) + + return lipgloss.JoinHorizontal( + lipgloss.Left, + key1, + space, + desc1, + sep, + key2, + space, + desc2, + ) +} + func (q *setupDialogCmp) RenderSetupStep() string { t := theme.CurrentTheme() baseStyle := styles.BaseStyle() @@ -277,7 +294,7 @@ func (q *setupDialogCmp) RenderSetupStep() string { line1 := "✨ Welcome to OpenCode" line2 := "Your AI-powered coding companion is almost ready!" - line3 := "Please complete setup by selecting a provider, model, and entering your API key." + line3 := "Let's get you set up with your preferred AI provider, model, and API key." width := lipgloss.Width(line3) remainingWidth := width - lipgloss.Width(buttons) @@ -326,8 +343,7 @@ func (q *setupDialogCmp) RenderSelectProviderStep() string { } } - helpStyle := lipgloss.NewStyle().Foreground(t.TextMuted()) - helpText := helpStyle.Render(q.help.View(q.keys)) + helpText := q.renderHelp() helpWidth := lipgloss.Width(helpText) maxWidth = max(maxWidth, helpWidth) @@ -388,8 +404,7 @@ func (q *setupDialogCmp) RenderSelectModelStep() string { } } - helpStyle := lipgloss.NewStyle().Foreground(t.TextMuted()) - helpText := helpStyle.Render(q.help.View(q.keys)) + helpText := q.renderHelp() helpWidth := lipgloss.Width(helpText) maxWidth = max(maxWidth, helpWidth) @@ -445,8 +460,7 @@ func (q *setupDialogCmp) RenderInputApiKeyStep() string { // Calculate width needed for content maxWidth := 60 // Width for explanation text - helpStyle := lipgloss.NewStyle().Foreground(t.TextMuted()) - helpText := helpStyle.Render(q.help.View(q.keys)) + helpText := q.renderHelp() helpWidth := lipgloss.Width(helpText) maxWidth = max(60, helpWidth) // Limit width to avoid overflow @@ -499,20 +513,8 @@ func (q *setupDialogCmp) BindingKeys() []key.Binding { } func NewSetupDialogCmp() SetupDialog { - textStyle := lipgloss.NewStyle() - separatorStyle := lipgloss.NewStyle() - - help := help.New() - help.ShowAll = false - help.Styles.Ellipsis = textStyle - help.Styles.FullDesc = textStyle - help.Styles.FullKey = textStyle - help.Styles.FullSeparator = separatorStyle - help.Styles.ShortDesc = textStyle - help.Styles.ShortKey = textStyle - help.Styles.ShortSeparator = separatorStyle - t := theme.CurrentTheme() + ti := textinput.New() ti.Placeholder = "Enter API Key..." ti.Width = 56 @@ -524,7 +526,6 @@ func NewSetupDialogCmp() SetupDialog { providers, providerLabels := AvailableProviders() return &setupDialogCmp{ - help: help, keys: setupKeys, providers: providers, providerLabels: providerLabels, From 3a68df511da6e53ca6f426c7987bfa1f7570ce47 Mon Sep 17 00:00:00 2001 From: Pierre Berube Date: Thu, 15 May 2025 17:33:20 -0600 Subject: [PATCH 11/22] fix: q -> s --- internal/tui/components/dialog/setup.go | 164 ++++++++++++------------ 1 file changed, 82 insertions(+), 82 deletions(-) diff --git a/internal/tui/components/dialog/setup.go b/internal/tui/components/dialog/setup.go index 0c6f2f6a6d87..6523be6f741c 100644 --- a/internal/tui/components/dialog/setup.go +++ b/internal/tui/components/dialog/setup.go @@ -136,125 +136,125 @@ var setupKeys = setupMapping{ ), } -func (q *setupDialogCmp) Init() tea.Cmd { +func (s *setupDialogCmp) Init() tea.Cmd { return tea.Batch(textinput.Blink) } -func (q *setupDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { +func (s *setupDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case cursor.BlinkMsg: - if q.step == InputApiKey { + if s.step == InputApiKey { // textinput.Update() does not work to make the cursor blink // we need to manually toggle the blink state - q.textInput.Cursor.Blink = !q.textInput.Cursor.Blink - return q, nil + s.textInput.Cursor.Blink = !s.textInput.Cursor.Blink + return s, nil } case tea.KeyMsg: - if q.step == Start && key.Matches(msg, setupKeys.Enter) { - q.step = SelectProvider - return q, nil + if s.step == Start && key.Matches(msg, setupKeys.Enter) { + s.step = SelectProvider + return s, nil } - if q.step == SelectProvider { + if s.step == SelectProvider { switch { case key.Matches(msg, setupKeys.Up): - q.selectedProviderIdx-- - if q.selectedProviderIdx < 0 { - q.selectedProviderIdx = len(q.providers) - 1 + s.selectedProviderIdx-- + if s.selectedProviderIdx < 0 { + s.selectedProviderIdx = len(s.providers) - 1 } case key.Matches(msg, setupKeys.Down): - q.selectedProviderIdx++ - if q.selectedProviderIdx >= len(q.providers) { - q.selectedProviderIdx = 0 + s.selectedProviderIdx++ + if s.selectedProviderIdx >= len(s.providers) { + s.selectedProviderIdx = 0 } case key.Matches(msg, setupKeys.Enter): - q.models = AvailableModelsByProvider(q.providers[q.selectedProviderIdx]) - q.step = SelectModel + s.models = AvailableModelsByProvider(s.providers[s.selectedProviderIdx]) + s.step = SelectModel case key.Matches(msg, setupKeys.Escape): - q.step = Start + s.step = Start } - return q, nil + return s, nil } - if q.step == SelectModel { + if s.step == SelectModel { switch { case key.Matches(msg, setupKeys.Up): - q.selectedModelIdx-- - if q.selectedModelIdx < 0 { - q.selectedModelIdx = len(q.providers) - 1 + s.selectedModelIdx-- + if s.selectedModelIdx < 0 { + s.selectedModelIdx = len(s.providers) - 1 } case key.Matches(msg, setupKeys.Down): - q.selectedModelIdx++ - if q.selectedModelIdx >= len(q.providers) { - q.selectedProviderIdx = 0 + s.selectedModelIdx++ + if s.selectedModelIdx >= len(s.providers) { + s.selectedProviderIdx = 0 } case key.Matches(msg, setupKeys.Enter): - q.step = InputApiKey - q.textInput.Focus() + s.step = InputApiKey + s.textInput.Focus() case key.Matches(msg, setupKeys.Escape): - q.selectedModelIdx = 0 - q.step = SelectProvider + s.selectedModelIdx = 0 + s.step = SelectProvider } - return q, nil + return s, nil } - if q.step == InputApiKey { + if s.step == InputApiKey { switch { case key.Matches(msg, setupKeys.Escape): - q.step = SelectModel + s.step = SelectModel case key.Matches(msg, setupKeys.Enter): - if q.textInput.Value() == "" { - q.textInputError = "Field cannot be empty" - return q, nil + if s.textInput.Value() == "" { + s.textInputError = "Field cannot be empty" + return s, nil } - return q, util.CmdHandler(CloseSetupDialogMsg{ - Provider: q.providers[q.selectedProviderIdx], - Model: q.models[q.selectedModelIdx], - APIKey: q.textInput.Value(), + return s, util.CmdHandler(CloseSetupDialogMsg{ + Provider: s.providers[s.selectedProviderIdx], + Model: s.models[s.selectedModelIdx], + APIKey: s.textInput.Value(), }) } var cmd tea.Cmd var cmds []tea.Cmd - q.textInput, cmd = q.textInput.Update(msg) + s.textInput, cmd = s.textInput.Update(msg) cmds = append(cmds, cmd) - return q, tea.Batch(cmds...) + return s, tea.Batch(cmds...) } } - return q, nil + return s, nil } -func (q *setupDialogCmp) View() string { - switch q.step { +func (s *setupDialogCmp) View() string { + switch s.step { default: - return q.RenderSetupStep() + return s.RenderSetupStep() case Start: - return q.RenderSetupStep() + return s.RenderSetupStep() case SelectProvider: - return q.RenderSelectProviderStep() + return s.RenderSelectProviderStep() case SelectModel: - return q.RenderSelectModelStep() + return s.RenderSelectModelStep() case InputApiKey: - return q.RenderInputApiKeyStep() + return s.RenderInputApiKeyStep() } } -func (q *setupDialogCmp) renderAndPadLine(text string, width int) string { +func (s *setupDialogCmp) renderAndPadLine(text string, width int) string { t := theme.CurrentTheme() baseStyle := styles.BaseStyle() spacerStyle := baseStyle.Background(t.Background()) return text + spacerStyle.Render(strings.Repeat(" ", width-lipgloss.Width(text))) } -func (q *setupDialogCmp) renderHelp() string { - // We have to render the help manually due to artifacting when using help.View(q.keys) +func (s *setupDialogCmp) renderHelp() string { + // We have to render the help manually due to artifacting when using help.View(s.keys) // this is a bug with the bubbletea/help package t := theme.CurrentTheme() sepStyle := styles.BaseStyle().Foreground(t.Primary()) @@ -263,10 +263,10 @@ func (q *setupDialogCmp) renderHelp() string { keyStyle := styles.BaseStyle().Foreground(t.Text()) descStyle := styles.BaseStyle().Foreground(t.TextMuted()) space := styles.BaseStyle().Foreground(t.Background()).Render(" ") - key1 := keyStyle.Render(q.keys.Escape.Help().Key) - desc1 := descStyle.Render(q.keys.Escape.Help().Desc) - key2 := keyStyle.Render(q.keys.Enter.Help().Key) - desc2 := descStyle.Render(q.keys.Enter.Help().Desc) + key1 := keyStyle.Render(s.keys.Escape.Help().Key) + desc1 := descStyle.Render(s.keys.Escape.Help().Desc) + key2 := keyStyle.Render(s.keys.Enter.Help().Key) + desc2 := descStyle.Render(s.keys.Enter.Help().Desc) return lipgloss.JoinHorizontal( lipgloss.Left, @@ -280,7 +280,7 @@ func (q *setupDialogCmp) renderHelp() string { ) } -func (q *setupDialogCmp) RenderSetupStep() string { +func (s *setupDialogCmp) RenderSetupStep() string { t := theme.CurrentTheme() baseStyle := styles.BaseStyle() @@ -311,13 +311,13 @@ func (q *setupDialogCmp) RenderSetupStep() string { content := baseStyle.Render( lipgloss.JoinVertical( lipgloss.Left, - q.renderAndPadLine(title, width), + s.renderAndPadLine(title, width), "", - q.renderAndPadLine(line1, width), + s.renderAndPadLine(line1, width), "", - q.renderAndPadLine(line2, width), + s.renderAndPadLine(line2, width), "", - q.renderAndPadLine(line3, width), + s.renderAndPadLine(line3, width), "", buttons, ), @@ -331,19 +331,19 @@ func (q *setupDialogCmp) RenderSetupStep() string { Render(content) } -func (q *setupDialogCmp) RenderSelectProviderStep() string { +func (s *setupDialogCmp) RenderSelectProviderStep() string { t := theme.CurrentTheme() baseStyle := styles.BaseStyle() // Calculate max width needed for provider names maxWidth := 36 - for _, providerName := range q.providers { + for _, providerName := range s.providers { if len(providerName) > maxWidth { maxWidth = len(providerName) } } - helpText := q.renderHelp() + helpText := s.renderHelp() helpWidth := lipgloss.Width(helpText) maxWidth = max(maxWidth, helpWidth) @@ -354,18 +354,18 @@ func (q *setupDialogCmp) RenderSelectProviderStep() string { } // Build the provider list - providerItems := make([]string, 0, len(q.providers)) - for i, provider := range q.providers { + providerItems := make([]string, 0, len(s.providers)) + for i, provider := range s.providers { itemStyle := baseStyle.Width(maxWidth) - if i == q.selectedProviderIdx { + if i == s.selectedProviderIdx { itemStyle = itemStyle. Background(t.Primary()). Foreground(t.Background()). Bold(true) } - providerItems = append(providerItems, itemStyle.Padding(0, 1).Render(q.providerLabels[provider])) + providerItems = append(providerItems, itemStyle.Padding(0, 1).Render(s.providerLabels[provider])) } title := baseStyle. @@ -392,19 +392,19 @@ func (q *setupDialogCmp) RenderSelectProviderStep() string { Render(content) } -func (q *setupDialogCmp) RenderSelectModelStep() string { +func (s *setupDialogCmp) RenderSelectModelStep() string { t := theme.CurrentTheme() baseStyle := styles.BaseStyle() // Calculate max width needed for model names maxWidth := 36 - for _, model := range q.models { + for _, model := range s.models { if len(model.Name) > maxWidth { maxWidth = len(model.Name) } } - helpText := q.renderHelp() + helpText := s.renderHelp() helpWidth := lipgloss.Width(helpText) maxWidth = max(maxWidth, helpWidth) @@ -415,11 +415,11 @@ func (q *setupDialogCmp) RenderSelectModelStep() string { } // Build the model list - modelItems := make([]string, 0, len(q.models)) - for i, model := range q.models { + modelItems := make([]string, 0, len(s.models)) + for i, model := range s.models { itemStyle := baseStyle.Width(maxWidth) - if i == q.selectedModelIdx { + if i == s.selectedModelIdx { itemStyle = itemStyle. Background(t.Primary()). Foreground(t.Background()). @@ -453,14 +453,14 @@ func (q *setupDialogCmp) RenderSelectModelStep() string { Render(content) } -func (q *setupDialogCmp) RenderInputApiKeyStep() string { +func (s *setupDialogCmp) RenderInputApiKeyStep() string { t := theme.CurrentTheme() baseStyle := styles.BaseStyle() // Calculate width needed for content maxWidth := 60 // Width for explanation text - helpText := q.renderHelp() + helpText := s.renderHelp() helpWidth := lipgloss.Width(helpText) maxWidth = max(60, helpWidth) // Limit width to avoid overflow @@ -481,15 +481,15 @@ func (q *setupDialogCmp) RenderInputApiKeyStep() string { Foreground(t.Text()). Width(maxWidth). Padding(1, 1). - Render(q.textInput.View()) + Render(s.textInput.View()) errorStyle := baseStyle.Foreground(t.Error()).PaddingLeft(1) errorText := "" - if q.textInputError != "" { - errorText = errorStyle.Render(q.renderAndPadLine(q.textInputError, maxWidth-1)) + if s.textInputError != "" { + errorText = errorStyle.Render(s.renderAndPadLine(s.textInputError, maxWidth-1)) } - maxWidth = min(maxWidth, q.width-10) + maxWidth = min(maxWidth, s.width-10) content := lipgloss.JoinVertical( lipgloss.Left, @@ -508,7 +508,7 @@ func (q *setupDialogCmp) RenderInputApiKeyStep() string { Render(content) } -func (q *setupDialogCmp) BindingKeys() []key.Binding { +func (s *setupDialogCmp) BindingKeys() []key.Binding { return layout.KeyMapToSlice(setupKeys) } From 455e5c1104d11d993135c420b7174d00b6e88fc9 Mon Sep 17 00:00:00 2001 From: Pierre Berube Date: Fri, 16 May 2025 10:48:52 -0600 Subject: [PATCH 12/22] fix: move model/provider utils --- internal/llm/models/models.go | 67 +++++++++++++++++++++++ internal/tui/components/dialog/setup.go | 71 +------------------------ 2 files changed, 69 insertions(+), 69 deletions(-) diff --git a/internal/llm/models/models.go b/internal/llm/models/models.go index bfdd0d2d8f56..d5938aa64223 100644 --- a/internal/llm/models/models.go +++ b/internal/llm/models/models.go @@ -52,3 +52,70 @@ func init() { maps.Copy(SupportedModels, XAIModels) maps.Copy(SupportedModels, VertexAIGeminiModels) } + +// AvailableProviders returns a list of all available providers +func AvailableProviders() ([]ModelProvider, map[ModelProvider]string) { + providerLabels := make(map[ModelProvider]string) + providerLabels[ProviderAnthropic] = "Anthropic" + providerLabels[ProviderAzure] = "Azure" + providerLabels[ProviderBedrock] = "Bedrock" + providerLabels[ProviderGemini] = "Gemini" + providerLabels[ProviderGROQ] = "Groq" + providerLabels[ProviderOpenAI] = "OpenAI" + providerLabels[ProviderOpenRouter] = "OpenRouter" + providerLabels[ProviderXAI] = "xAI" + + providerList := make([]ModelProvider, 0, len(providerLabels)) + providerList = append(providerList, ProviderAnthropic) + providerList = append(providerList, ProviderAzure) + providerList = append(providerList, ProviderBedrock) + providerList = append(providerList, ProviderGemini) + providerList = append(providerList, ProviderGROQ) + providerList = append(providerList, ProviderOpenAI) + providerList = append(providerList, ProviderOpenRouter) + providerList = append(providerList, ProviderXAI) + + return providerList, providerLabels +} + +// AvailableModelsByProvider returns a list of all available models by provider +func AvailableModelsByProvider(provider ModelProvider) []Model { + var modelMap map[ModelID]Model + + switch provider { + default: + modelMap = map[ModelID]Model{} + case ProviderAnthropic: + modelMap = AnthropicModels + case ProviderAzure: + modelMap = AzureModels + case ProviderBedrock: + modelMap = BedrockModels + case ProviderGemini: + modelMap = GeminiModels + case ProviderGROQ: + modelMap = GroqModels + case ProviderOpenAI: + modelMap = OpenAIModels + case ProviderOpenRouter: + modelMap = OpenRouterModels + case ProviderXAI: + modelMap = XAIModels + } + + models := make([]Model, 0, len(modelMap)) + for _, model := range modelMap { + models = append(models, model) + } + + // Sort models by alphabetical order + for i := 0; i < len(models)-1; i++ { + for j := i + 1; j < len(models); j++ { + if models[i].Name > models[j].Name { + models[i], models[j] = models[j], models[i] + } + } + } + + return models +} diff --git a/internal/tui/components/dialog/setup.go b/internal/tui/components/dialog/setup.go index 6523be6f741c..33ccfca75c1f 100644 --- a/internal/tui/components/dialog/setup.go +++ b/internal/tui/components/dialog/setup.go @@ -19,73 +19,6 @@ type SetupDialog interface { layout.Bindings } -// AvailableProviders returns a list of all available providers -func AvailableProviders() ([]models.ModelProvider, map[models.ModelProvider]string) { - providerLabels := make(map[models.ModelProvider]string) - providerLabels[models.ProviderAnthropic] = "Anthropic" - providerLabels[models.ProviderAzure] = "Azure" - providerLabels[models.ProviderBedrock] = "Bedrock" - providerLabels[models.ProviderGemini] = "Gemini" - providerLabels[models.ProviderGROQ] = "Groq" - providerLabels[models.ProviderOpenAI] = "OpenAI" - providerLabels[models.ProviderOpenRouter] = "OpenRouter" - providerLabels[models.ProviderXAI] = "xAI" - - providerList := make([]models.ModelProvider, 0, len(providerLabels)) - providerList = append(providerList, models.ProviderAnthropic) - providerList = append(providerList, models.ProviderAzure) - providerList = append(providerList, models.ProviderBedrock) - providerList = append(providerList, models.ProviderGemini) - providerList = append(providerList, models.ProviderGROQ) - providerList = append(providerList, models.ProviderOpenAI) - providerList = append(providerList, models.ProviderOpenRouter) - providerList = append(providerList, models.ProviderXAI) - - return providerList, providerLabels -} - -// AvailableModelsByProvider returns a list of all available models by provider -func AvailableModelsByProvider(provider models.ModelProvider) []models.Model { - var modelMap map[models.ModelID]models.Model - - switch provider { - default: - modelMap = map[models.ModelID]models.Model{} - case models.ProviderAnthropic: - modelMap = models.AnthropicModels - case models.ProviderAzure: - modelMap = models.AzureModels - case models.ProviderBedrock: - modelMap = models.BedrockModels - case models.ProviderGemini: - modelMap = models.GeminiModels - case models.ProviderGROQ: - modelMap = models.GroqModels - case models.ProviderOpenAI: - modelMap = models.OpenAIModels - case models.ProviderOpenRouter: - modelMap = models.OpenRouterModels - case models.ProviderXAI: - modelMap = models.XAIModels - } - - models := make([]models.Model, 0, len(modelMap)) - for _, model := range modelMap { - models = append(models, model) - } - - // Sort models by alphabetical order - for i := 0; i < len(models)-1; i++ { - for j := i + 1; j < len(models); j++ { - if models[i].Name > models[j].Name { - models[i], models[j] = models[j], models[i] - } - } - } - - return models -} - type SetupStep string const ( @@ -168,7 +101,7 @@ func (s *setupDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { s.selectedProviderIdx = 0 } case key.Matches(msg, setupKeys.Enter): - s.models = AvailableModelsByProvider(s.providers[s.selectedProviderIdx]) + s.models = models.AvailableModelsByProvider(s.providers[s.selectedProviderIdx]) s.step = SelectModel case key.Matches(msg, setupKeys.Escape): s.step = Start @@ -523,7 +456,7 @@ func NewSetupDialogCmp() SetupDialog { ti.PromptStyle = ti.PromptStyle.Background(t.Background()) ti.TextStyle = ti.TextStyle.Background(t.Background()) - providers, providerLabels := AvailableProviders() + providers, providerLabels := models.AvailableProviders() return &setupDialogCmp{ keys: setupKeys, From 71b017ca8ec01b05b87b965da2abc1e89ab0a4ba Mon Sep 17 00:00:00 2001 From: Pierre Berube Date: Fri, 16 May 2025 10:58:16 -0600 Subject: [PATCH 13/22] fix: show setup dialog again after quit attempt --- internal/tui/tui.go | 42 ++++++++++++++++++++++++------------------ 1 file changed, 24 insertions(+), 18 deletions(-) diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 66edec277d98..ac0474be3d16 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -84,7 +84,7 @@ var keys = keyMap{ key.WithKeys("ctrl+t"), key.WithHelp("ctrl+t", "switch theme"), ), - + Tools: key.NewBinding( key.WithKeys("f9"), key.WithHelp("f9", "show available tools"), @@ -148,7 +148,7 @@ type appModel struct { showMultiArgumentsDialog bool multiArgumentsDialog dialog.MultiArgumentsDialogCmp - + showToolsDialog bool toolsDialog dialog.ToolsDialog } @@ -297,6 +297,7 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case dialog.CloseQuitMsg: a.showQuit = false + return a, nil case dialog.CloseSessionDialogMsg: @@ -335,11 +336,11 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case dialog.CloseThemeDialogMsg: a.showThemeDialog = false return a, nil - + case dialog.CloseToolsDialogMsg: a.showToolsDialog = false return a, nil - + case dialog.ShowToolsDialogMsg: a.showToolsDialog = msg.Show return a, nil @@ -478,7 +479,7 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { a.showThemeDialog = false a.showModelDialog = false a.showFilepicker = false - + // Load sessions and show the dialog sessions, err := a.app.Sessions.List(context.Background()) if err != nil { @@ -499,7 +500,7 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // Close other dialogs a.showToolsDialog = false a.showModelDialog = false - + // Show commands dialog if len(a.commands) == 0 { status.Warn("No commands available") @@ -520,7 +521,7 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { a.showToolsDialog = false a.showThemeDialog = false a.showFilepicker = false - + a.showModelDialog = true return a, nil } @@ -531,17 +532,17 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { a.showToolsDialog = false a.showModelDialog = false a.showFilepicker = false - + a.showThemeDialog = true return a, a.themeDialog.Init() } return a, nil case key.Matches(msg, keys.Tools): // Check if any other dialog is open - if a.currentPage == page.ChatPage && !a.showQuit && !a.showPermissions && - !a.showSessionDialog && !a.showCommandDialog && !a.showThemeDialog && - !a.showFilepicker && !a.showModelDialog && !a.showInitDialog && - !a.showMultiArgumentsDialog { + if a.currentPage == page.ChatPage && !a.showQuit && !a.showPermissions && + !a.showSessionDialog && !a.showCommandDialog && !a.showThemeDialog && + !a.showFilepicker && !a.showModelDialog && !a.showInitDialog && + !a.showMultiArgumentsDialog { // Toggle tools dialog a.showToolsDialog = !a.showToolsDialog if a.showToolsDialog { @@ -564,6 +565,11 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } if a.showQuit { a.showQuit = !a.showQuit + + if !setup.IsSetupComplete() { + a.showSetupDialog = true + } + return a, nil } if a.showHelp { @@ -596,7 +602,7 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return a, nil } a.showHelp = !a.showHelp - + // Close other dialogs if opening help if a.showHelp { a.showToolsDialog = false @@ -614,7 +620,7 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // Toggle filepicker a.showFilepicker = !a.showFilepicker a.filepicker.ToggleFilepicker(a.showFilepicker) - + // Close other dialogs if opening filepicker if a.showFilepicker { a.showToolsDialog = false @@ -731,7 +737,7 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return a, tea.Batch(cmds...) } } - + if a.showToolsDialog { d, toolsCmd := a.toolsDialog.Update(msg) a.toolsDialog = d.(dialog.ToolsDialog) @@ -766,13 +772,13 @@ func getAvailableToolNames(app *app.App) []string { app.History, app.LSPClients, ) - + // Extract tool names var toolNames []string for _, tool := range allTools { toolNames = append(toolNames, tool.Info().Name) } - + return toolNames } @@ -996,7 +1002,7 @@ func (a appModel) View() string { true, ) } - + if a.showToolsDialog { overlay := a.toolsDialog.View() row := lipgloss.Height(appView) / 2 From 4e47ad68cd5632997d0af1652bab8209060ef696 Mon Sep 17 00:00:00 2001 From: Pierre Berube Date: Fri, 16 May 2025 14:26:32 -0600 Subject: [PATCH 14/22] refactor: use simple-list component --- internal/llm/models/models.go | 84 +++++++------ internal/tui/components/dialog/setup.go | 113 +++++------------- internal/tui/components/llm/model-list.go | 119 +++++++++++++++++++ internal/tui/components/llm/provider-list.go | 101 ++++++++++++++++ 4 files changed, 299 insertions(+), 118 deletions(-) create mode 100644 internal/tui/components/llm/model-list.go create mode 100644 internal/tui/components/llm/provider-list.go diff --git a/internal/llm/models/models.go b/internal/llm/models/models.go index d5938aa64223..fe34620f4e06 100644 --- a/internal/llm/models/models.go +++ b/internal/llm/models/models.go @@ -1,6 +1,8 @@ package models -import "maps" +import ( + "maps" +) type ( ModelID string @@ -53,9 +55,16 @@ func init() { maps.Copy(SupportedModels, VertexAIGeminiModels) } +var providerLabels map[ModelProvider]string +var providerList []ModelProvider + // AvailableProviders returns a list of all available providers func AvailableProviders() ([]ModelProvider, map[ModelProvider]string) { - providerLabels := make(map[ModelProvider]string) + if providerLabels != nil && providerList != nil { + return providerList, providerLabels + } + + providerLabels = make(map[ModelProvider]string) providerLabels[ProviderAnthropic] = "Anthropic" providerLabels[ProviderAzure] = "Azure" providerLabels[ProviderBedrock] = "Bedrock" @@ -63,9 +72,10 @@ func AvailableProviders() ([]ModelProvider, map[ModelProvider]string) { providerLabels[ProviderGROQ] = "Groq" providerLabels[ProviderOpenAI] = "OpenAI" providerLabels[ProviderOpenRouter] = "OpenRouter" + providerLabels[ProviderVertexAI] = "Vertex AI" providerLabels[ProviderXAI] = "xAI" - providerList := make([]ModelProvider, 0, len(providerLabels)) + providerList = make([]ModelProvider, 0, len(providerLabels)) providerList = append(providerList, ProviderAnthropic) providerList = append(providerList, ProviderAzure) providerList = append(providerList, ProviderBedrock) @@ -73,49 +83,53 @@ func AvailableProviders() ([]ModelProvider, map[ModelProvider]string) { providerList = append(providerList, ProviderGROQ) providerList = append(providerList, ProviderOpenAI) providerList = append(providerList, ProviderOpenRouter) + providerList = append(providerList, ProviderVertexAI) providerList = append(providerList, ProviderXAI) return providerList, providerLabels } +var modelsByProvider map[ModelProvider][]Model + // AvailableModelsByProvider returns a list of all available models by provider -func AvailableModelsByProvider(provider ModelProvider) []Model { - var modelMap map[ModelID]Model - - switch provider { - default: - modelMap = map[ModelID]Model{} - case ProviderAnthropic: - modelMap = AnthropicModels - case ProviderAzure: - modelMap = AzureModels - case ProviderBedrock: - modelMap = BedrockModels - case ProviderGemini: - modelMap = GeminiModels - case ProviderGROQ: - modelMap = GroqModels - case ProviderOpenAI: - modelMap = OpenAIModels - case ProviderOpenRouter: - modelMap = OpenRouterModels - case ProviderXAI: - modelMap = XAIModels +func AvailableModelsByProvider() map[ModelProvider][]Model { + if modelsByProvider != nil { + return modelsByProvider } - models := make([]Model, 0, len(modelMap)) - for _, model := range modelMap { - models = append(models, model) - } + providers, _ := AvailableProviders() - // Sort models by alphabetical order - for i := 0; i < len(models)-1; i++ { - for j := i + 1; j < len(models); j++ { - if models[i].Name > models[j].Name { - models[i], models[j] = models[j], models[i] + modelsByProviderMap := make(map[ModelProvider]map[ModelID]Model) + modelsByProviderMap[ProviderAnthropic] = AnthropicModels + modelsByProviderMap[ProviderAzure] = AzureModels + modelsByProviderMap[ProviderBedrock] = BedrockModels + modelsByProviderMap[ProviderGemini] = GeminiModels + modelsByProviderMap[ProviderGROQ] = GroqModels + modelsByProviderMap[ProviderOpenAI] = OpenAIModels + modelsByProviderMap[ProviderOpenRouter] = OpenRouterModels + modelsByProviderMap[ProviderVertexAI] = VertexAIGeminiModels + modelsByProviderMap[ProviderXAI] = XAIModels + + modelsByProvider = make(map[ModelProvider][]Model) + + // Add models to the map sorted alphabetically + for _, provider := range providers { + models := make([]Model, 0, len(modelsByProviderMap[provider])) + for _, model := range modelsByProviderMap[provider] { + models = append(models, model) + } + + // Sort models by alphabetical order + for i := 0; i < len(models)-1; i++ { + for j := i + 1; j < len(models); j++ { + if models[i].Name > models[j].Name { + models[i], models[j] = models[j], models[i] + } } } + + modelsByProvider[provider] = models } - return models + return modelsByProvider } diff --git a/internal/tui/components/dialog/setup.go b/internal/tui/components/dialog/setup.go index 33ccfca75c1f..b475fc2c3d95 100644 --- a/internal/tui/components/dialog/setup.go +++ b/internal/tui/components/dialog/setup.go @@ -7,6 +7,7 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/sst/opencode/internal/llm/models" + "github.com/sst/opencode/internal/tui/components/llm" "github.com/sst/opencode/internal/tui/layout" "github.com/sst/opencode/internal/tui/styles" "github.com/sst/opencode/internal/tui/theme" @@ -32,9 +33,8 @@ type setupDialogCmp struct { currentModel string currentProvider string keys setupMapping - models []models.Model - providers []models.ModelProvider - providerLabels map[models.ModelProvider]string + modelList llm.ModelList + providerList llm.ProviderList selectedModelIdx int selectedProviderIdx int step SetupStep @@ -83,6 +83,9 @@ func (s *setupDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return s, nil } case tea.KeyMsg: + var cmd tea.Cmd + var cmds []tea.Cmd + if s.step == Start && key.Matches(msg, setupKeys.Enter) { s.step = SelectProvider return s, nil @@ -90,46 +93,31 @@ func (s *setupDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if s.step == SelectProvider { switch { - case key.Matches(msg, setupKeys.Up): - s.selectedProviderIdx-- - if s.selectedProviderIdx < 0 { - s.selectedProviderIdx = len(s.providers) - 1 - } - case key.Matches(msg, setupKeys.Down): - s.selectedProviderIdx++ - if s.selectedProviderIdx >= len(s.providers) { - s.selectedProviderIdx = 0 - } case key.Matches(msg, setupKeys.Enter): - s.models = models.AvailableModelsByProvider(s.providers[s.selectedProviderIdx]) + s.modelList.SetProvider(s.providerList.GetSelectedProvider().Name) s.step = SelectModel case key.Matches(msg, setupKeys.Escape): s.step = Start } - return s, nil + s.providerList, cmd = s.providerList.Update(msg) + cmds = append(cmds, cmd) + + return s, tea.Batch(cmds...) } if s.step == SelectModel { switch { - case key.Matches(msg, setupKeys.Up): - s.selectedModelIdx-- - if s.selectedModelIdx < 0 { - s.selectedModelIdx = len(s.providers) - 1 - } - case key.Matches(msg, setupKeys.Down): - s.selectedModelIdx++ - if s.selectedModelIdx >= len(s.providers) { - s.selectedProviderIdx = 0 - } case key.Matches(msg, setupKeys.Enter): s.step = InputApiKey s.textInput.Focus() case key.Matches(msg, setupKeys.Escape): - s.selectedModelIdx = 0 s.step = SelectProvider } + s.modelList, cmd = s.modelList.Update(msg) + cmds = append(cmds, cmd) + return s, nil } @@ -145,15 +133,12 @@ func (s *setupDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return s, util.CmdHandler(CloseSetupDialogMsg{ - Provider: s.providers[s.selectedProviderIdx], - Model: s.models[s.selectedModelIdx], + Provider: s.providerList.GetSelectedProvider().Name, + Model: s.modelList.GetSelectedModel().Model, APIKey: s.textInput.Value(), }) } - var cmd tea.Cmd - var cmds []tea.Cmd - s.textInput, cmd = s.textInput.Update(msg) cmds = append(cmds, cmd) @@ -268,14 +253,7 @@ func (s *setupDialogCmp) RenderSelectProviderStep() string { t := theme.CurrentTheme() baseStyle := styles.BaseStyle() - // Calculate max width needed for provider names maxWidth := 36 - for _, providerName := range s.providers { - if len(providerName) > maxWidth { - maxWidth = len(providerName) - } - } - helpText := s.renderHelp() helpWidth := lipgloss.Width(helpText) maxWidth = max(maxWidth, helpWidth) @@ -286,21 +264,6 @@ func (s *setupDialogCmp) RenderSelectProviderStep() string { helpText = strings.Repeat(" ", remainingWidth) + helpText } - // Build the provider list - providerItems := make([]string, 0, len(s.providers)) - for i, provider := range s.providers { - itemStyle := baseStyle.Width(maxWidth) - - if i == s.selectedProviderIdx { - itemStyle = itemStyle. - Background(t.Primary()). - Foreground(t.Background()). - Bold(true) - } - - providerItems = append(providerItems, itemStyle.Padding(0, 1).Render(s.providerLabels[provider])) - } - title := baseStyle. Foreground(t.Primary()). Bold(true). @@ -312,7 +275,7 @@ func (s *setupDialogCmp) RenderSelectProviderStep() string { lipgloss.Left, title, baseStyle.Width(maxWidth).Render(""), - baseStyle.Width(maxWidth).Render(lipgloss.JoinVertical(lipgloss.Left, providerItems...)), + s.providerList.View(), baseStyle.Width(maxWidth).Render("\n\n"), baseStyle.Width(maxWidth).Render(helpText), ) @@ -329,14 +292,7 @@ func (s *setupDialogCmp) RenderSelectModelStep() string { t := theme.CurrentTheme() baseStyle := styles.BaseStyle() - // Calculate max width needed for model names maxWidth := 36 - for _, model := range s.models { - if len(model.Name) > maxWidth { - maxWidth = len(model.Name) - } - } - helpText := s.renderHelp() helpWidth := lipgloss.Width(helpText) maxWidth = max(maxWidth, helpWidth) @@ -347,21 +303,6 @@ func (s *setupDialogCmp) RenderSelectModelStep() string { helpText = strings.Repeat(" ", remainingWidth) + helpText } - // Build the model list - modelItems := make([]string, 0, len(s.models)) - for i, model := range s.models { - itemStyle := baseStyle.Width(maxWidth) - - if i == s.selectedModelIdx { - itemStyle = itemStyle. - Background(t.Primary()). - Foreground(t.Background()). - Bold(true) - } - - modelItems = append(modelItems, itemStyle.Padding(0, 1).Render(model.Name)) - } - title := baseStyle. Foreground(t.Primary()). Bold(true). @@ -373,7 +314,7 @@ func (s *setupDialogCmp) RenderSelectModelStep() string { lipgloss.Left, title, baseStyle.Width(maxWidth).Render(""), - baseStyle.Width(maxWidth).Render(lipgloss.JoinVertical(lipgloss.Left, modelItems...)), + s.modelList.View(), baseStyle.Width(maxWidth).Render("\n\n"), baseStyle.Width(maxWidth).Render(helpText), ) @@ -456,14 +397,20 @@ func NewSetupDialogCmp() SetupDialog { ti.PromptStyle = ti.PromptStyle.Background(t.Background()) ti.TextStyle = ti.TextStyle.Background(t.Background()) - providers, providerLabels := models.AvailableProviders() + listWidth := new(int) + *listWidth = 36 return &setupDialogCmp{ - keys: setupKeys, - providers: providers, - providerLabels: providerLabels, - step: Start, - textInput: ti, + keys: setupKeys, + modelList: llm.NewModelList(llm.NewModelListOptions{ + InitialProvider: models.ProviderAnthropic, + Width: listWidth, + }), + providerList: llm.NewProviderList(llm.NewProviderListOptions{ + Width: listWidth, + }), + step: Start, + textInput: ti, } } diff --git a/internal/tui/components/llm/model-list.go b/internal/tui/components/llm/model-list.go new file mode 100644 index 000000000000..ce0ed24abaf6 --- /dev/null +++ b/internal/tui/components/llm/model-list.go @@ -0,0 +1,119 @@ +package llm + +import ( + "fmt" + tea "github.com/charmbracelet/bubbletea" + "github.com/sst/opencode/internal/llm/models" + utilComponents "github.com/sst/opencode/internal/tui/components/util" + "github.com/sst/opencode/internal/tui/styles" + "github.com/sst/opencode/internal/tui/theme" + "log/slog" +) + +type ModelListItem struct { + Model models.Model +} + +func (p ModelListItem) Render(selected bool, width int) string { + t := theme.CurrentTheme() + baseStyle := styles.BaseStyle() + + descStyle := baseStyle.Width(width).Foreground(t.TextMuted()) + itemStyle := baseStyle.Width(width). + Background(t.Background()). + Foreground(t.Text()) + + if selected { + itemStyle = itemStyle. + Background(t.Primary()). + Foreground(t.Background()). + Bold(true) + descStyle = descStyle. + Background(t.Primary()). + Foreground(t.Background()) + } + + title := itemStyle.Padding(0, 1).Render(p.Model.Name) + return title +} + +type ModelList struct { + list utilComponents.SimpleList[ModelListItem] + models map[models.ModelProvider][]models.Model +} + +func (p *ModelList) View() string { + return p.list.View() +} + +func (p *ModelList) Update(msg tea.Msg) (ModelList, tea.Cmd) { + l, cmd := p.list.Update(msg) + p.list = l.(utilComponents.SimpleList[ModelListItem]) + + return *p, cmd +} + +func BuildListItemsForProvider(provider models.ModelProvider) []ModelListItem { + modelsByProvider := models.AvailableModelsByProvider() + + slog.Debug(fmt.Sprintf("modelsByProvider: %v", modelsByProvider)) + + modelListItems := make([]ModelListItem, 0, len(modelsByProvider[provider])) + for _, model := range modelsByProvider[provider] { + modelListItems = append(modelListItems, ModelListItem{Model: model}) + } + + return modelListItems +} + +func (p *ModelList) SetProvider(provider models.ModelProvider) { + modelListItems := BuildListItemsForProvider(provider) + + slog.Debug(fmt.Sprintf("models: %v", modelListItems)) + + p.list.SetItems(modelListItems) +} + +func (p *ModelList) GetSelectedModel() ModelListItem { + item, _ := p.list.GetSelectedItem() + + return item +} + +type NewModelListOptions struct { + AlphaNumericKeys *bool + FallbackMsg *string + InitialProvider models.ModelProvider + MaxVisibleItems *int + Width *int +} + +func NewModelList(options NewModelListOptions) ModelList { + var maxVisibleItems = 10 + if options.MaxVisibleItems != nil { + maxVisibleItems = *options.MaxVisibleItems + } + + var fallbackMsg = "No models found" + if options.FallbackMsg != nil { + fallbackMsg = *options.FallbackMsg + } + + var useAlphaNumericKeys = false + if options.AlphaNumericKeys != nil { + useAlphaNumericKeys = *options.AlphaNumericKeys + } + + var width = 36 + if options.Width != nil { + width = *options.Width + } + + listItems := BuildListItemsForProvider(options.InitialProvider) + list := utilComponents.NewSimpleList(listItems, maxVisibleItems, fallbackMsg, useAlphaNumericKeys) + list.SetMaxWidth(width) + + return ModelList{ + list: list, + } +} diff --git a/internal/tui/components/llm/provider-list.go b/internal/tui/components/llm/provider-list.go new file mode 100644 index 000000000000..cee6e835e5ef --- /dev/null +++ b/internal/tui/components/llm/provider-list.go @@ -0,0 +1,101 @@ +package llm + +import ( + tea "github.com/charmbracelet/bubbletea" + "github.com/sst/opencode/internal/llm/models" + utilComponents "github.com/sst/opencode/internal/tui/components/util" + "github.com/sst/opencode/internal/tui/styles" + "github.com/sst/opencode/internal/tui/theme" +) + +type ProviderListItem struct { + Label string + Name models.ModelProvider +} + +func (p ProviderListItem) Render(selected bool, width int) string { + t := theme.CurrentTheme() + baseStyle := styles.BaseStyle() + + descStyle := baseStyle.Width(width).Foreground(t.TextMuted()) + itemStyle := baseStyle.Width(width). + Background(t.Background()). + Foreground(t.Text()) + + if selected { + itemStyle = itemStyle. + Background(t.Primary()). + Foreground(t.Background()). + Bold(true) + descStyle = descStyle. + Background(t.Primary()). + Foreground(t.Background()) + } + + title := itemStyle.Padding(0, 1).Render(p.Label) + return title +} + +type ProviderList struct { + list utilComponents.SimpleList[ProviderListItem] +} + +func (p *ProviderList) View() string { + return p.list.View() +} + +func (p *ProviderList) Update(msg tea.Msg) (ProviderList, tea.Cmd) { + l, cmd := p.list.Update(msg) + p.list = l.(utilComponents.SimpleList[ProviderListItem]) + + return *p, cmd +} + +func (p *ProviderList) GetSelectedProvider() ProviderListItem { + item, _ := p.list.GetSelectedItem() + + return item +} + +type NewProviderListOptions struct { + AlphaNumericKeys *bool + FallbackMsg *string + MaxVisibleItems *int + Width *int +} + +func NewProviderList(options NewProviderListOptions) ProviderList { + providers, providerLabels := models.AvailableProviders() + + providerListItems := make([]ProviderListItem, 0, len(providers)) + for _, provider := range providers { + providerListItems = append(providerListItems, ProviderListItem{Label: providerLabels[provider], Name: provider}) + } + + var maxVisibleItems = len(providers) + if options.MaxVisibleItems != nil { + maxVisibleItems = *options.MaxVisibleItems + } + + var fallbackMsg = "No provider found" + if options.FallbackMsg != nil { + fallbackMsg = *options.FallbackMsg + } + + var useAlphaNumericKeys = false + if options.AlphaNumericKeys != nil { + useAlphaNumericKeys = *options.AlphaNumericKeys + } + + var width = 36 + if options.Width != nil { + width = *options.Width + } + + list := utilComponents.NewSimpleList(providerListItems, maxVisibleItems, fallbackMsg, useAlphaNumericKeys) + list.SetMaxWidth(width) + + return ProviderList{ + list: list, + } +} From 6110a0c9a87cba4bc324adbaec2ac84bbd52be09 Mon Sep 17 00:00:00 2001 From: Pierre Berube Date: Fri, 16 May 2025 16:13:00 -0600 Subject: [PATCH 15/22] fix: show setup dialog when no selected in quit dialog --- internal/tui/tui.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/internal/tui/tui.go b/internal/tui/tui.go index ac0474be3d16..28a43e1cd7ee 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -298,6 +298,10 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case dialog.CloseQuitMsg: a.showQuit = false + if !setup.IsSetupComplete() { + a.showSetupDialog = true + } + return a, nil case dialog.CloseSessionDialogMsg: From d18e33b008c37fcfaa96da8d92956a49d0924e84 Mon Sep 17 00:00:00 2001 From: Pierre Berube Date: Fri, 16 May 2025 16:45:08 -0600 Subject: [PATCH 16/22] fix: remove debug --- internal/tui/components/llm/model-list.go | 6 ------ 1 file changed, 6 deletions(-) diff --git a/internal/tui/components/llm/model-list.go b/internal/tui/components/llm/model-list.go index ce0ed24abaf6..5a7c4ae8a927 100644 --- a/internal/tui/components/llm/model-list.go +++ b/internal/tui/components/llm/model-list.go @@ -1,13 +1,11 @@ package llm import ( - "fmt" tea "github.com/charmbracelet/bubbletea" "github.com/sst/opencode/internal/llm/models" utilComponents "github.com/sst/opencode/internal/tui/components/util" "github.com/sst/opencode/internal/tui/styles" "github.com/sst/opencode/internal/tui/theme" - "log/slog" ) type ModelListItem struct { @@ -56,8 +54,6 @@ func (p *ModelList) Update(msg tea.Msg) (ModelList, tea.Cmd) { func BuildListItemsForProvider(provider models.ModelProvider) []ModelListItem { modelsByProvider := models.AvailableModelsByProvider() - slog.Debug(fmt.Sprintf("modelsByProvider: %v", modelsByProvider)) - modelListItems := make([]ModelListItem, 0, len(modelsByProvider[provider])) for _, model := range modelsByProvider[provider] { modelListItems = append(modelListItems, ModelListItem{Model: model}) @@ -69,8 +65,6 @@ func BuildListItemsForProvider(provider models.ModelProvider) []ModelListItem { func (p *ModelList) SetProvider(provider models.ModelProvider) { modelListItems := BuildListItemsForProvider(provider) - slog.Debug(fmt.Sprintf("models: %v", modelListItems)) - p.list.SetItems(modelListItems) } From 5a2c09313e480e9a0b16739d1fb9346c4870af8a Mon Sep 17 00:00:00 2001 From: Pierre Berube Date: Fri, 16 May 2025 17:36:11 -0600 Subject: [PATCH 17/22] fix: temporarily disable bedrock and vertex --- internal/llm/models/models.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/internal/llm/models/models.go b/internal/llm/models/models.go index fe34620f4e06..c3ce39eb59a2 100644 --- a/internal/llm/models/models.go +++ b/internal/llm/models/models.go @@ -78,12 +78,14 @@ func AvailableProviders() ([]ModelProvider, map[ModelProvider]string) { providerList = make([]ModelProvider, 0, len(providerLabels)) providerList = append(providerList, ProviderAnthropic) providerList = append(providerList, ProviderAzure) - providerList = append(providerList, ProviderBedrock) + // FIXME: Re-add when the setup wizard supports it + // providerList = append(providerList, ProviderBedrock) providerList = append(providerList, ProviderGemini) providerList = append(providerList, ProviderGROQ) providerList = append(providerList, ProviderOpenAI) providerList = append(providerList, ProviderOpenRouter) - providerList = append(providerList, ProviderVertexAI) + // FIXME: Re-add when the setup wizard supports it + // providerList = append(providerList, ProviderVertexAI) providerList = append(providerList, ProviderXAI) return providerList, providerLabels From eea5e42c512a6ee8f94277151389acc371ff7efb Mon Sep 17 00:00:00 2001 From: Pierre Berube Date: Fri, 16 May 2025 17:36:29 -0600 Subject: [PATCH 18/22] fix: tweak list styling --- internal/tui/components/dialog/setup.go | 4 ++-- internal/tui/components/llm/model-list.go | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/tui/components/dialog/setup.go b/internal/tui/components/dialog/setup.go index b475fc2c3d95..bc345d45bd9a 100644 --- a/internal/tui/components/dialog/setup.go +++ b/internal/tui/components/dialog/setup.go @@ -276,7 +276,7 @@ func (s *setupDialogCmp) RenderSelectProviderStep() string { title, baseStyle.Width(maxWidth).Render(""), s.providerList.View(), - baseStyle.Width(maxWidth).Render("\n\n"), + baseStyle.Width(maxWidth).Render(""), baseStyle.Width(maxWidth).Render(helpText), ) @@ -315,7 +315,7 @@ func (s *setupDialogCmp) RenderSelectModelStep() string { title, baseStyle.Width(maxWidth).Render(""), s.modelList.View(), - baseStyle.Width(maxWidth).Render("\n\n"), + baseStyle.Width(maxWidth).Render(""), baseStyle.Width(maxWidth).Render(helpText), ) diff --git a/internal/tui/components/llm/model-list.go b/internal/tui/components/llm/model-list.go index 5a7c4ae8a927..697056b88f3f 100644 --- a/internal/tui/components/llm/model-list.go +++ b/internal/tui/components/llm/model-list.go @@ -83,7 +83,7 @@ type NewModelListOptions struct { } func NewModelList(options NewModelListOptions) ModelList { - var maxVisibleItems = 10 + var maxVisibleItems = 24 if options.MaxVisibleItems != nil { maxVisibleItems = *options.MaxVisibleItems } From 2dd9692d853a42990ce38e22291b2c6326edae41 Mon Sep 17 00:00:00 2001 From: Pierre Berube Date: Fri, 16 May 2025 17:55:18 -0600 Subject: [PATCH 19/22] fix: reinitialize model dialog after setup --- internal/tui/tui.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 28a43e1cd7ee..a42f319c33f3 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -317,6 +317,9 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // Complete setup setup.CompleteSetup(msg.Provider, msg.Model, msg.APIKey) + // Reinitialize the model dialog + a.modelDialog.Init() + // Reinitialize the primary agent a.app.InitializePrimaryAgent() From 9e274d06bd1c4151bc44d58d59694e1b179dc170 Mon Sep 17 00:00:00 2001 From: Pierre Berube Date: Sat, 17 May 2025 11:53:08 -0600 Subject: [PATCH 20/22] fix: move setup initialization --- internal/app/app.go | 3 +++ internal/tui/tui.go | 3 --- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/app/app.go b/internal/app/app.go index de94c9f7daca..489fdc6e8c86 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -93,6 +93,9 @@ func New(ctx context.Context, conn *sql.DB) (*App, error) { // Initialize LSP clients in the background go app.initLSPClients(ctx) + // Initialize setup + setup.Init() + if !setup.IsSetupComplete() { app.PrimaryAgent, err = agent.NewSetupAgent() diff --git a/internal/tui/tui.go b/internal/tui/tui.go index a42f319c33f3..8084ff3ca167 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -181,9 +181,6 @@ func (a appModel) Init() tea.Cmd { cmd = a.toolsDialog.Init() cmds = append(cmds, cmd) - // Checks config to see if setup is complete - setup.Init() - // Check if we should show the setup or init dialog cmds = append(cmds, func() tea.Msg { if !setup.IsSetupComplete() { From fabf6396130f95c38a09711b2fb1153ffeb53bd8 Mon Sep 17 00:00:00 2001 From: Pierre Berube Date: Sat, 24 May 2025 00:26:43 -0600 Subject: [PATCH 21/22] fix: auto initialize project --- internal/tui/tui.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/internal/tui/tui.go b/internal/tui/tui.go index d7b01df8c1bb..dd443f14f62e 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -317,6 +317,9 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // Reinitialize the model dialog a.modelDialog.Init() + // Initialize project + a.Update(dialog.CloseInitDialogMsg{Initialize: true}) + // Reinitialize the primary agent a.app.InitializePrimaryAgent() @@ -627,7 +630,7 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { a.showFilepicker = !a.showFilepicker a.filepicker.ToggleFilepicker(a.showFilepicker) a.app.SetFilepickerOpen(a.showFilepicker) - + // Close other dialogs if opening filepicker if a.showFilepicker { a.showToolsDialog = false From 9b81d7bac0738152cc7c74ba3544d01854f2c5f5 Mon Sep 17 00:00:00 2001 From: Pierre Berube Date: Sat, 24 May 2025 23:44:58 -0600 Subject: [PATCH 22/22] fix: auto initialize project --- internal/tui/tui.go | 17 +++-------------- 1 file changed, 3 insertions(+), 14 deletions(-) diff --git a/internal/tui/tui.go b/internal/tui/tui.go index dd443f14f62e..08ea9155a5ff 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -317,24 +317,13 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // Reinitialize the model dialog a.modelDialog.Init() - // Initialize project - a.Update(dialog.CloseInitDialogMsg{Initialize: true}) - // Reinitialize the primary agent a.app.InitializePrimaryAgent() - // Show init dialog if project is not initialized - shouldShowInit, err := config.ShouldShowInitDialog() - if err != nil { - status.Error("Failed to check init status: " + err.Error()) - return a, nil - } - if shouldShowInit { - a.showInitDialog = true - return a, nil - } + // Initialize project + _, cmd = a.Update(dialog.CloseInitDialogMsg{Initialize: true}) - return a, nil + return a, cmd case dialog.CloseCommandDialogMsg: a.showCommandDialog = false