@@ -2,6 +2,7 @@ package dialog
import (
tea "charm.land/bubbletea/v2"
+ "github.com/charmbracelet/catwalk/pkg/catwalk"
"github.com/charmbracelet/crush/internal/config"
"github.com/charmbracelet/crush/internal/permission"
"github.com/charmbracelet/crush/internal/session"
@@ -25,6 +26,7 @@ type ActionSelectSession struct {
// ActionSelectModel is a message indicating a model has been selected.
type ActionSelectModel struct {
+ Provider catwalk.Provider
Model config.SelectedModel
ModelType config.SelectedModelType
}
@@ -46,6 +48,13 @@ type (
}
)
+// Messages for API key input dialog.
+type (
+ ActionChangeAPIKeyState struct {
+ State APIKeyInputState
+ }
+)
+
// ActionCmd represents an action that carries a [tea.Cmd] to be passed to the
// Bubble Tea program loop.
type ActionCmd struct {
@@ -0,0 +1,302 @@
+package dialog
+
+import (
+ "fmt"
+ "strings"
+ "time"
+
+ "charm.land/bubbles/v2/help"
+ "charm.land/bubbles/v2/key"
+ "charm.land/bubbles/v2/spinner"
+ "charm.land/bubbles/v2/textinput"
+ tea "charm.land/bubbletea/v2"
+ "github.com/charmbracelet/catwalk/pkg/catwalk"
+ "github.com/charmbracelet/crush/internal/config"
+ "github.com/charmbracelet/crush/internal/ui/common"
+ "github.com/charmbracelet/crush/internal/ui/styles"
+ "github.com/charmbracelet/crush/internal/uiutil"
+ uv "github.com/charmbracelet/ultraviolet"
+ "github.com/charmbracelet/x/exp/charmtone"
+)
+
+type APIKeyInputState int
+
+const (
+ APIKeyInputStateInitial APIKeyInputState = iota
+ APIKeyInputStateVerifying
+ APIKeyInputStateVerified
+ APIKeyInputStateError
+)
+
+// APIKeyInputID is the identifier for the model selection dialog.
+const APIKeyInputID = "api_key_input"
+
+// APIKeyInput represents a model selection dialog.
+type APIKeyInput struct {
+ com *common.Common
+
+ provider catwalk.Provider
+ model config.SelectedModel
+ modelType config.SelectedModelType
+
+ width int
+ state APIKeyInputState
+
+ keyMap struct {
+ Submit key.Binding
+ Close key.Binding
+ }
+ input textinput.Model
+ spinner spinner.Model
+ help help.Model
+}
+
+var _ Dialog = (*APIKeyInput)(nil)
+
+// NewAPIKeyInput creates a new Models dialog.
+func NewAPIKeyInput(com *common.Common, provider catwalk.Provider, model config.SelectedModel, modelType config.SelectedModelType) (*APIKeyInput, error) {
+ t := com.Styles
+
+ m := APIKeyInput{}
+ m.com = com
+ m.provider = provider
+ m.model = model
+ m.modelType = modelType
+ m.width = 60
+
+ innerWidth := m.width - t.Dialog.View.GetHorizontalFrameSize() - 2
+
+ m.input = textinput.New()
+ m.input.SetVirtualCursor(false)
+ m.input.Placeholder = "Enter you API key..."
+ m.input.SetStyles(com.Styles.TextInput)
+ m.input.Focus()
+ m.input.SetWidth(innerWidth - t.Dialog.InputPrompt.GetHorizontalFrameSize() - 1) // (1) cursor padding
+
+ m.spinner = spinner.New(
+ spinner.WithSpinner(spinner.Dot),
+ spinner.WithStyle(t.Base.Foreground(t.Green)),
+ )
+
+ m.help = help.New()
+ m.help.Styles = t.DialogHelpStyles()
+
+ m.keyMap.Submit = key.NewBinding(
+ key.WithKeys("enter", "ctrl+y"),
+ key.WithHelp("enter", "submit"),
+ )
+ m.keyMap.Close = CloseKey
+
+ return &m, nil
+}
+
+// ID implements Dialog.
+func (m *APIKeyInput) ID() string {
+ return APIKeyInputID
+}
+
+// Update implements tea.Model.
+func (m *APIKeyInput) HandleMsg(msg tea.Msg) Action {
+ switch msg := msg.(type) {
+ case ActionChangeAPIKeyState:
+ m.state = msg.State
+ switch m.state {
+ case APIKeyInputStateVerifying:
+ cmd := tea.Batch(m.spinner.Tick, m.verifyAPIKey)
+ return ActionCmd{cmd}
+ }
+ case spinner.TickMsg:
+ switch m.state {
+ case APIKeyInputStateVerifying:
+ var cmd tea.Cmd
+ m.spinner, cmd = m.spinner.Update(msg)
+ if cmd != nil {
+ return ActionCmd{cmd}
+ }
+ }
+ case tea.KeyPressMsg:
+ switch {
+ case m.state == APIKeyInputStateVerifying:
+ // do nothing
+ case key.Matches(msg, m.keyMap.Close):
+ switch m.state {
+ case APIKeyInputStateVerified:
+ return m.saveKeyAndContinue()
+ default:
+ return ActionClose{}
+ }
+ case key.Matches(msg, m.keyMap.Submit):
+ switch m.state {
+ case APIKeyInputStateInitial, APIKeyInputStateError:
+ return ActionChangeAPIKeyState{State: APIKeyInputStateVerifying}
+ case APIKeyInputStateVerified:
+ return m.saveKeyAndContinue()
+ }
+ default:
+ var cmd tea.Cmd
+ m.input, cmd = m.input.Update(msg)
+ if cmd != nil {
+ return ActionCmd{cmd}
+ }
+ }
+ case tea.PasteMsg:
+ var cmd tea.Cmd
+ m.input, cmd = m.input.Update(msg)
+ if cmd != nil {
+ return ActionCmd{cmd}
+ }
+ }
+ return nil
+}
+
+// View implements tea.Model.
+func (m *APIKeyInput) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
+ t := m.com.Styles
+
+ textStyle := t.Dialog.SecondaryText
+ helpStyle := t.Dialog.HelpView
+ dialogStyle := t.Dialog.View.Width(m.width)
+ inputStyle := t.Dialog.InputPrompt
+ helpStyle = helpStyle.Width(m.width - dialogStyle.GetHorizontalFrameSize())
+
+ m.input.Prompt = m.spinner.View()
+
+ content := strings.Join([]string{
+ m.headerView(),
+ inputStyle.Render(m.inputView()),
+ textStyle.Render("This will be written in your global configuration:"),
+ textStyle.Render(config.GlobalConfigData()),
+ "",
+ helpStyle.Render(m.help.View(m)),
+ }, "\n")
+
+ view := dialogStyle.Render(content)
+
+ cur := m.Cursor()
+ DrawCenterCursor(scr, area, view, cur)
+ return cur
+}
+
+func (m *APIKeyInput) headerView() string {
+ t := m.com.Styles
+ titleStyle := t.Dialog.Title
+ dialogStyle := t.Dialog.View.Width(m.width)
+
+ headerOffset := titleStyle.GetHorizontalFrameSize() + dialogStyle.GetHorizontalFrameSize()
+ return common.DialogTitle(t, titleStyle.Render(m.dialogTitle()), m.width-headerOffset)
+}
+
+func (m *APIKeyInput) dialogTitle() string {
+ t := m.com.Styles
+ textStyle := t.Dialog.TitleText
+ errorStyle := t.Dialog.TitleError
+ accentStyle := t.Dialog.TitleAccent
+
+ switch m.state {
+ case APIKeyInputStateInitial:
+ return textStyle.Render("Enter your ") + accentStyle.Render(fmt.Sprintf("%s Key", m.provider.Name)) + textStyle.Render(".")
+ case APIKeyInputStateVerifying:
+ return textStyle.Render("Verifying your ") + accentStyle.Render(fmt.Sprintf("%s Key", m.provider.Name)) + textStyle.Render("...")
+ case APIKeyInputStateVerified:
+ return accentStyle.Render(fmt.Sprintf("%s Key", m.provider.Name)) + textStyle.Render(" validated.")
+ case APIKeyInputStateError:
+ return errorStyle.Render("Invalid ") + accentStyle.Render(fmt.Sprintf("%s Key", m.provider.Name)) + errorStyle.Render(". Try again?")
+ }
+ return ""
+}
+
+func (m *APIKeyInput) inputView() string {
+ t := m.com.Styles
+
+ switch m.state {
+ case APIKeyInputStateInitial:
+ m.input.Prompt = "> "
+ m.input.SetStyles(t.TextInput)
+ m.input.Focus()
+ case APIKeyInputStateVerifying:
+ ts := t.TextInput
+ ts.Blurred.Prompt = ts.Focused.Prompt
+
+ m.input.Prompt = m.spinner.View()
+ m.input.SetStyles(ts)
+ m.input.Blur()
+ case APIKeyInputStateVerified:
+ ts := t.TextInput
+ ts.Blurred.Prompt = ts.Focused.Prompt
+
+ m.input.Prompt = styles.CheckIcon + " "
+ m.input.SetStyles(ts)
+ m.input.Blur()
+ case APIKeyInputStateError:
+ ts := t.TextInput
+ ts.Focused.Prompt = ts.Focused.Prompt.Foreground(charmtone.Cherry)
+
+ m.input.Prompt = styles.ErrorIcon + " "
+ m.input.SetStyles(ts)
+ m.input.Focus()
+ }
+ return m.input.View()
+}
+
+// Cursor returns the cursor position relative to the dialog.
+func (c *APIKeyInput) Cursor() *tea.Cursor {
+ return InputCursor(c.com.Styles, c.input.Cursor())
+}
+
+// FullHelp returns the full help view.
+func (m *APIKeyInput) FullHelp() [][]key.Binding {
+ return [][]key.Binding{
+ {
+ m.keyMap.Submit,
+ m.keyMap.Close,
+ },
+ }
+}
+
+// ShortHelp returns the full help view.
+func (m *APIKeyInput) ShortHelp() []key.Binding {
+ return []key.Binding{
+ m.keyMap.Submit,
+ m.keyMap.Close,
+ }
+}
+
+func (m *APIKeyInput) verifyAPIKey() tea.Msg {
+ start := time.Now()
+
+ providerConfig := config.ProviderConfig{
+ ID: string(m.provider.ID),
+ Name: m.provider.Name,
+ APIKey: m.input.Value(),
+ Type: m.provider.Type,
+ BaseURL: m.provider.APIEndpoint,
+ }
+ err := providerConfig.TestConnection(config.Get().Resolver())
+
+ // intentionally wait for at least 750ms to make sure the user sees the spinner
+ elapsed := time.Since(start)
+ minimum := 750 * time.Millisecond
+ if elapsed < minimum {
+ time.Sleep(minimum - elapsed)
+ }
+
+ if err == nil {
+ return ActionChangeAPIKeyState{APIKeyInputStateVerified}
+ }
+ return ActionChangeAPIKeyState{APIKeyInputStateError}
+}
+
+func (m *APIKeyInput) saveKeyAndContinue() tea.Msg {
+ cfg := m.com.Config()
+
+ err := cfg.SetProviderAPIKey(string(m.provider.ID), m.input.Value())
+ if err != nil {
+ return uiutil.ReportError(fmt.Errorf("failed to save API key: %w", err))
+ }
+
+ return ActionSelectModel{
+ Provider: m.provider,
+ Model: m.model,
+ ModelType: m.modelType,
+ }
+}
@@ -20,6 +20,7 @@ import (
"charm.land/bubbles/v2/textarea"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
+ "github.com/charmbracelet/catwalk/pkg/catwalk"
"github.com/charmbracelet/crush/internal/agent/tools/mcp"
"github.com/charmbracelet/crush/internal/app"
"github.com/charmbracelet/crush/internal/config"
@@ -781,14 +782,21 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
break
}
- // TODO: Validate model API and authentication here?
-
cfg := m.com.Config()
if cfg == nil {
cmds = append(cmds, uiutil.ReportError(errors.New("configuration not found")))
break
}
+ _, isProviderConfigured := cfg.Providers.Get(msg.Model.Provider)
+ if !isProviderConfigured {
+ m.dialog.CloseDialog(dialog.ModelsID)
+ if cmd := m.openAPIKeyInputDialog(msg.Provider, msg.Model, msg.ModelType); cmd != nil {
+ cmds = append(cmds, cmd)
+ }
+ break
+ }
+
if err := cfg.UpdatePreferredModel(msg.ModelType, msg.Model); err != nil {
cmds = append(cmds, uiutil.ReportError(err))
}
@@ -798,6 +806,7 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
modelMsg := fmt.Sprintf("%s model changed to %s", msg.ModelType, msg.Model.Model)
cmds = append(cmds, uiutil.ReportInfo(modelMsg))
+ m.dialog.CloseDialog(dialog.APIKeyInputID)
m.dialog.CloseDialog(dialog.ModelsID)
// TODO CHANGE
case dialog.ActionPermissionResponse:
@@ -810,11 +819,28 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
case dialog.PermissionDeny:
m.com.App.Permissions.Deny(msg.Permission)
}
+ default:
+ cmds = append(cmds, uiutil.CmdHandler(msg))
}
return tea.Batch(cmds...)
}
+// openAPIKeyInputDialog opens the API key input dialog.
+func (m *UI) openAPIKeyInputDialog(provider catwalk.Provider, model config.SelectedModel, modelType config.SelectedModelType) tea.Cmd {
+ if m.dialog.ContainsDialog(dialog.APIKeyInputID) {
+ m.dialog.BringToFront(dialog.APIKeyInputID)
+ return nil
+ }
+
+ apiKeyInputDialog, err := dialog.NewAPIKeyInput(m.com, provider, model, modelType)
+ if err != nil {
+ return uiutil.ReportError(err)
+ }
+ m.dialog.OpenDialog(apiKeyInputDialog)
+ return nil
+}
+
func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
var cmds []tea.Cmd
@@ -295,9 +295,14 @@ type Styles struct {
// Dialog styles
Dialog struct {
- Title lipgloss.Style
+ Title lipgloss.Style
+ TitleText lipgloss.Style
+ TitleError lipgloss.Style
+ TitleAccent lipgloss.Style
// View is the main content area style.
- View lipgloss.Style
+ View lipgloss.Style
+ PrimaryText lipgloss.Style
+ SecondaryText lipgloss.Style
// HelpView is the line that contains the help.
HelpView lipgloss.Style
Help struct {
@@ -1158,7 +1163,12 @@ func DefaultStyles() Styles {
// Dialog styles
s.Dialog.Title = base.Padding(0, 1).Foreground(primary)
+ s.Dialog.TitleText = base.Foreground(primary)
+ s.Dialog.TitleError = base.Foreground(red)
+ s.Dialog.TitleAccent = base.Foreground(green).Bold(true)
s.Dialog.View = base.Border(lipgloss.RoundedBorder()).BorderForeground(borderFocus)
+ s.Dialog.PrimaryText = base.Padding(0, 1).Foreground(primary)
+ s.Dialog.SecondaryText = base.Padding(0, 1).Foreground(fgSubtle)
s.Dialog.HelpView = base.Padding(0, 1).AlignHorizontal(lipgloss.Left)
s.Dialog.Help.ShortKey = base.Foreground(fgMuted)
s.Dialog.Help.ShortDesc = base.Foreground(fgSubtle)