diff --git a/internal/ui/dialog/actions.go b/internal/ui/dialog/actions.go index a9b785eaf1ce7a2d11b24245bd3c51b166da680b..ecf81432410c31a523d221221e00c50d9862b9ac 100644 --- a/internal/ui/dialog/actions.go +++ b/internal/ui/dialog/actions.go @@ -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 { diff --git a/internal/ui/dialog/api_key_input.go b/internal/ui/dialog/api_key_input.go new file mode 100644 index 0000000000000000000000000000000000000000..57e4df189899426a72e92435044615fa178266c6 --- /dev/null +++ b/internal/ui/dialog/api_key_input.go @@ -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, + } +} diff --git a/internal/ui/dialog/models.go b/internal/ui/dialog/models.go index 41d0efe8d21d0dce6fc6ace138a88304dea1123a..c12c78e1f4753c01f80653bb6ee5e5013fc9ea09 100644 --- a/internal/ui/dialog/models.go +++ b/internal/ui/dialog/models.go @@ -189,6 +189,7 @@ func (m *Models) HandleMsg(msg tea.Msg) Action { } return ActionSelectModel{ + Provider: modelItem.prov, Model: modelItem.SelectedModel(), ModelType: modelItem.SelectedModelType(), } diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index 2c32c6774a6a83dbb2c858f97239a00521f5fd38..620636ad717100db01e3524c500147f7bd8576ee 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -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 diff --git a/internal/ui/styles/styles.go b/internal/ui/styles/styles.go index 12cfd52124fa8fa45488dd34d8556072372f4d8f..1bb6648117e1413950fb8a68dc9a9b3f3b90b89d 100644 --- a/internal/ui/styles/styles.go +++ b/internal/ui/styles/styles.go @@ -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)