diff --git a/internal/app/app.go b/internal/app/app.go index 943f1b24e4a..05aba6915b4 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" @@ -95,6 +96,29 @@ 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() + + if err != nil { + slog.Error("Failed to create setup agent", "error", err) + return nil, err + } + + 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, @@ -107,12 +131,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 70c05ac0250..f9fc2128da2 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -178,9 +178,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/llm/agent/setup-agent.go b/internal/llm/agent/setup-agent.go new file mode 100644 index 00000000000..9db7b30424c --- /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 +} diff --git a/internal/llm/models/models.go b/internal/llm/models/models.go index bfdd0d2d8f5..c3ce39eb59a 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 @@ -52,3 +54,84 @@ func init() { maps.Copy(SupportedModels, XAIModels) 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) { + if providerLabels != nil && providerList != nil { + return providerList, providerLabels + } + + 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[ProviderVertexAI] = "Vertex AI" + providerLabels[ProviderXAI] = "xAI" + + providerList = make([]ModelProvider, 0, len(providerLabels)) + providerList = append(providerList, ProviderAnthropic) + providerList = append(providerList, ProviderAzure) + // 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) + // FIXME: Re-add when the setup wizard supports it + // 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() map[ModelProvider][]Model { + if modelsByProvider != nil { + return modelsByProvider + } + + providers, _ := AvailableProviders() + + 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 modelsByProvider +} diff --git a/internal/setup/setup.go b/internal/setup/setup.go new file mode 100644 index 00000000000..0fa4fea83ed --- /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 212ad5529c1..2d06e4d0286 100644 --- a/internal/tui/components/chat/editor.go +++ b/internal/tui/components/chat/editor.go @@ -15,6 +15,7 @@ import ( "github.com/charmbracelet/lipgloss" "github.com/sst/opencode/internal/app" "github.com/sst/opencode/internal/message" + "github.com/sst/opencode/internal/setup" "github.com/sst/opencode/internal/status" "github.com/sst/opencode/internal/tui/components/dialog" "github.com/sst/opencode/internal/tui/image" @@ -317,14 +318,22 @@ func (m *editorCmp) View() string { Bold(true). Foreground(t.Primary()) + // Only show textarea if setup is complete + prefix := "" + textarea := "" + if setup.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), ) } diff --git a/internal/tui/components/dialog/setup.go b/internal/tui/components/dialog/setup.go new file mode 100644 index 00000000000..bc345d45bd9 --- /dev/null +++ b/internal/tui/components/dialog/setup.go @@ -0,0 +1,427 @@ +package dialog + +import ( + "github.com/charmbracelet/bubbles/cursor" + "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/components/llm" + "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 +} + +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 + keys setupMapping + modelList llm.ModelList + providerList llm.ProviderList + selectedModelIdx int + selectedProviderIdx int + step SetupStep + textInput textinput.Model + textInputError string + width int +} + +type setupMapping struct { + Up key.Binding + Down key.Binding + Enter key.Binding + Escape 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 (s *setupDialogCmp) Init() tea.Cmd { + return tea.Batch(textinput.Blink) +} + +func (s *setupDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case cursor.BlinkMsg: + if s.step == InputApiKey { + // textinput.Update() does not work to make the cursor blink + // we need to manually toggle the blink state + s.textInput.Cursor.Blink = !s.textInput.Cursor.Blink + 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 + } + + if s.step == SelectProvider { + switch { + case key.Matches(msg, setupKeys.Enter): + s.modelList.SetProvider(s.providerList.GetSelectedProvider().Name) + s.step = SelectModel + case key.Matches(msg, setupKeys.Escape): + s.step = Start + } + + 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.Enter): + s.step = InputApiKey + s.textInput.Focus() + case key.Matches(msg, setupKeys.Escape): + s.step = SelectProvider + } + + s.modelList, cmd = s.modelList.Update(msg) + cmds = append(cmds, cmd) + + return s, nil + } + + if s.step == InputApiKey { + switch { + case key.Matches(msg, setupKeys.Escape): + s.step = SelectModel + + case key.Matches(msg, setupKeys.Enter): + if s.textInput.Value() == "" { + s.textInputError = "Field cannot be empty" + return s, nil + } + + return s, util.CmdHandler(CloseSetupDialogMsg{ + Provider: s.providerList.GetSelectedProvider().Name, + Model: s.modelList.GetSelectedModel().Model, + APIKey: s.textInput.Value(), + }) + } + + s.textInput, cmd = s.textInput.Update(msg) + cmds = append(cmds, cmd) + + return s, tea.Batch(cmds...) + } + } + + return s, nil +} + +func (s *setupDialogCmp) View() string { + switch s.step { + default: + return s.RenderSetupStep() + case Start: + return s.RenderSetupStep() + case SelectProvider: + return s.RenderSelectProviderStep() + case SelectModel: + return s.RenderSelectModelStep() + case InputApiKey: + return s.RenderInputApiKeyStep() + } +} + +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 (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()) + 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(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, + key1, + space, + desc1, + sep, + key2, + space, + desc2, + ) +} + +func (s *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 := "Let's get you set up with your preferred AI provider, model, and 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, + s.renderAndPadLine(title, width), + "", + s.renderAndPadLine(line1, width), + "", + s.renderAndPadLine(line2, width), + "", + s.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 (s *setupDialogCmp) RenderSelectProviderStep() string { + t := theme.CurrentTheme() + baseStyle := styles.BaseStyle() + + maxWidth := 36 + helpText := s.renderHelp() + helpWidth := lipgloss.Width(helpText) + maxWidth = max(maxWidth, helpWidth) + + // 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("Select Provider") + + content := lipgloss.JoinVertical( + lipgloss.Left, + title, + baseStyle.Width(maxWidth).Render(""), + s.providerList.View(), + baseStyle.Width(maxWidth).Render(""), + 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 (s *setupDialogCmp) RenderSelectModelStep() string { + t := theme.CurrentTheme() + baseStyle := styles.BaseStyle() + + maxWidth := 36 + helpText := s.renderHelp() + helpWidth := lipgloss.Width(helpText) + maxWidth = max(maxWidth, helpWidth) + + // 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("Select Model") + + content := lipgloss.JoinVertical( + lipgloss.Left, + title, + baseStyle.Width(maxWidth).Render(""), + s.modelList.View(), + baseStyle.Width(maxWidth).Render(""), + 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 (s *setupDialogCmp) RenderInputApiKeyStep() string { + t := theme.CurrentTheme() + baseStyle := styles.BaseStyle() + + // Calculate width needed for content + maxWidth := 60 // Width for explanation text + + helpText := s.renderHelp() + 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(s.textInput.View()) + + errorStyle := baseStyle.Foreground(t.Error()).PaddingLeft(1) + errorText := "" + if s.textInputError != "" { + errorText = errorStyle.Render(s.renderAndPadLine(s.textInputError, maxWidth-1)) + } + + maxWidth = min(maxWidth, s.width-10) + + content := lipgloss.JoinVertical( + lipgloss.Left, + title, + inputField, + errorText, + 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 (s *setupDialogCmp) BindingKeys() []key.Binding { + return layout.KeyMapToSlice(setupKeys) +} + +func NewSetupDialogCmp() SetupDialog { + 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()) + + listWidth := new(int) + *listWidth = 36 + + return &setupDialogCmp{ + keys: setupKeys, + modelList: llm.NewModelList(llm.NewModelListOptions{ + InitialProvider: models.ProviderAnthropic, + Width: listWidth, + }), + providerList: llm.NewProviderList(llm.NewProviderListOptions{ + Width: listWidth, + }), + 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/components/llm/model-list.go b/internal/tui/components/llm/model-list.go new file mode 100644 index 00000000000..697056b88f3 --- /dev/null +++ b/internal/tui/components/llm/model-list.go @@ -0,0 +1,113 @@ +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 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() + + 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) + + 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 = 24 + 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 00000000000..cee6e835e5e --- /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, + } +} diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 56be0461970..08ea9155a5f 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -3,6 +3,7 @@ package tui import ( "context" "fmt" + "github.com/sst/opencode/internal/setup" "log/slog" "strings" @@ -83,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"), @@ -126,6 +127,9 @@ type appModel struct { showSessionDialog bool sessionDialog dialog.SessionDialog + showSetupDialog bool + setupDialog dialog.SetupDialog + showCommandDialog bool commandDialog dialog.CommandDialog commands []dialog.Command @@ -144,7 +148,7 @@ type appModel struct { showMultiArgumentsDialog bool multiArgumentsDialog dialog.MultiArgumentsDialogCmp - + showToolsDialog bool toolsDialog dialog.ToolsDialog } @@ -162,6 +166,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() @@ -175,14 +181,18 @@ func (a appModel) Init() tea.Cmd { cmd = a.toolsDialog.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 !setup.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...) @@ -203,6 +213,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) @@ -283,6 +294,11 @@ 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: @@ -292,6 +308,23 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return a, nil + case dialog.CloseSetupDialogMsg: + a.showSetupDialog = false + + // Complete setup + setup.CompleteSetup(msg.Provider, msg.Model, msg.APIKey) + + // Reinitialize the model dialog + a.modelDialog.Init() + + // Reinitialize the primary agent + a.app.InitializePrimaryAgent() + + // Initialize project + _, cmd = a.Update(dialog.CloseInitDialogMsg{Initialize: true}) + + return a, cmd + case dialog.CloseCommandDialogMsg: a.showCommandDialog = false return a, nil @@ -299,11 +332,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 @@ -334,6 +367,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 { @@ -411,6 +448,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 } @@ -436,7 +476,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 { @@ -457,7 +497,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") @@ -478,7 +518,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 } @@ -489,17 +529,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 { @@ -522,6 +562,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 { @@ -555,7 +600,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 @@ -574,7 +619,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 @@ -642,6 +687,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) @@ -681,7 +736,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) @@ -716,13 +771,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 } @@ -861,6 +916,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 @@ -931,7 +1001,7 @@ func (a appModel) View() string { true, ) } - + if a.showToolsDialog { overlay := a.toolsDialog.View() row := lipgloss.Height(appView) / 2 @@ -959,6 +1029,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(),