diff --git a/internal/ui/dialog/actions.go b/internal/ui/dialog/actions.go index a63d14ac6fbe66bb70d365c9b03a58a0da932fd5..a4cfd66d695472e579fabf458ad5fc570af85b7e 100644 --- a/internal/ui/dialog/actions.go +++ b/internal/ui/dialog/actions.go @@ -11,6 +11,7 @@ import ( "github.com/charmbracelet/crush/internal/commands" "github.com/charmbracelet/crush/internal/config" "github.com/charmbracelet/crush/internal/message" + "github.com/charmbracelet/crush/internal/oauth" "github.com/charmbracelet/crush/internal/permission" "github.com/charmbracelet/crush/internal/session" "github.com/charmbracelet/crush/internal/ui/common" @@ -81,6 +82,29 @@ type ( } ) +// Messages for OAuth2 device flow dialog. +type ( + // ActionInitiateOAuth is sent when the device auth is initiated + // successfully. + ActionInitiateOAuth struct { + DeviceCode string + UserCode string + ExpiresIn int + VerificationURL string + Interval int + } + + // ActionCompleteOAuth is sent when the device flow completes successfully. + ActionCompleteOAuth struct { + Token *oauth.Token + } + + // ActionOAuthErrored is sent when the device flow encounters an error. + ActionOAuthErrored struct { + Error error + } +) + // 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 index e28dea2b823143d176d796c8775e8024df61d0bb..430f7b4629294faa83bad9b5b90ca363ceb6f1b7 100644 --- a/internal/ui/dialog/api_key_input.go +++ b/internal/ui/dialog/api_key_input.go @@ -54,7 +54,7 @@ type APIKeyInput struct { 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) { +func NewAPIKeyInput(com *common.Common, provider catwalk.Provider, model config.SelectedModel, modelType config.SelectedModelType) (*APIKeyInput, tea.Cmd) { t := com.Styles m := APIKeyInput{} diff --git a/internal/ui/dialog/models.go b/internal/ui/dialog/models.go index 543e610d013e58a71447814aedd22841aaa6bf2a..77aeab22380f89455f83760428fac128fd4fc28b 100644 --- a/internal/ui/dialog/models.go +++ b/internal/ui/dialog/models.go @@ -454,10 +454,16 @@ func getFilteredProviders(cfg *config.Config) ([]catwalk.Provider, error) { if err != nil { return nil, fmt.Errorf("failed to get providers: %w", err) } - filteredProviders := []catwalk.Provider{} + var filteredProviders []catwalk.Provider for _, p := range providers { - hasAPIKeyEnv := strings.HasPrefix(p.APIKey, "$") - if hasAPIKeyEnv && p.ID != catwalk.InferenceProviderAzure { + var ( + isAzure = p.ID == catwalk.InferenceProviderAzure + isCopilot = p.ID == catwalk.InferenceProviderCopilot + isHyper = string(p.ID) == "hyper" + hasAPIKeyEnv = strings.HasPrefix(p.APIKey, "$") + _, isConfigured = cfg.Providers.Get(string(p.ID)) + ) + if isAzure || isCopilot || isHyper || hasAPIKeyEnv || isConfigured { filteredProviders = append(filteredProviders, p) } } diff --git a/internal/ui/dialog/oauth.go b/internal/ui/dialog/oauth.go new file mode 100644 index 0000000000000000000000000000000000000000..ae5a2ab25a1ec596ba50ea6b3a0d03f560f1b10d --- /dev/null +++ b/internal/ui/dialog/oauth.go @@ -0,0 +1,369 @@ +package dialog + +import ( + "context" + "fmt" + "strings" + + "charm.land/bubbles/v2/help" + "charm.land/bubbles/v2/key" + "charm.land/bubbles/v2/spinner" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/charmbracelet/catwalk/pkg/catwalk" + "github.com/charmbracelet/crush/internal/config" + "github.com/charmbracelet/crush/internal/oauth" + "github.com/charmbracelet/crush/internal/ui/common" + "github.com/charmbracelet/crush/internal/uiutil" + uv "github.com/charmbracelet/ultraviolet" + "github.com/pkg/browser" +) + +type OAuthProvider interface { + name() string + initiateAuth() tea.Msg + startPolling(deviceCode string, expiresIn int) tea.Cmd + stopPolling() tea.Msg +} + +// OAuthState represents the current state of the device flow. +type OAuthState int + +const ( + OAuthStateInitializing OAuthState = iota + OAuthStateDisplay + OAuthStateSuccess + OAuthStateError +) + +// OAuthID is the identifier for the model selection dialog. +const OAuthID = "oauth" + +// OAuth handles the OAuth flow authentication. +type OAuth struct { + com *common.Common + + provider catwalk.Provider + model config.SelectedModel + modelType config.SelectedModelType + oAuthProvider OAuthProvider + + State OAuthState + + spinner spinner.Model + help help.Model + keyMap struct { + Copy key.Binding + Submit key.Binding + Close key.Binding + } + + width int + deviceCode string + userCode string + verificationURL string + expiresIn int + interval int + token *oauth.Token + cancelFunc context.CancelFunc +} + +var _ Dialog = (*OAuth)(nil) + +// newOAuth creates a new device flow component. +func newOAuth(com *common.Common, provider catwalk.Provider, model config.SelectedModel, modelType config.SelectedModelType, oAuthProvider OAuthProvider) (*OAuth, tea.Cmd) { + t := com.Styles + + m := OAuth{} + m.com = com + m.provider = provider + m.model = model + m.modelType = modelType + m.oAuthProvider = oAuthProvider + m.width = 60 + m.State = OAuthStateInitializing + + m.spinner = spinner.New( + spinner.WithSpinner(spinner.Dot), + spinner.WithStyle(t.Base.Foreground(t.GreenLight)), + ) + + m.help = help.New() + m.help.Styles = t.DialogHelpStyles() + + m.keyMap.Copy = key.NewBinding( + key.WithKeys("c"), + key.WithHelp("c", "copy code"), + ) + m.keyMap.Submit = key.NewBinding( + key.WithKeys("enter", "ctrl+y"), + key.WithHelp("enter", "copy & open"), + ) + m.keyMap.Close = CloseKey + + return &m, tea.Batch(m.spinner.Tick, m.oAuthProvider.initiateAuth) +} + +// ID implements Dialog. +func (m *OAuth) ID() string { + return OAuthID +} + +// HandleMsg handles messages and state transitions. +func (m *OAuth) HandleMsg(msg tea.Msg) Action { + switch msg := msg.(type) { + case spinner.TickMsg: + switch m.State { + case OAuthStateInitializing, OAuthStateDisplay: + var cmd tea.Cmd + m.spinner, cmd = m.spinner.Update(msg) + if cmd != nil { + return ActionCmd{cmd} + } + } + + case tea.KeyPressMsg: + switch { + case key.Matches(msg, m.keyMap.Copy): + cmd := m.copyCode() + return ActionCmd{cmd} + + case key.Matches(msg, m.keyMap.Submit): + switch m.State { + case OAuthStateSuccess: + return m.saveKeyAndContinue() + + default: + cmd := m.copyCodeAndOpenURL() + return ActionCmd{cmd} + } + + case key.Matches(msg, m.keyMap.Close): + switch m.State { + case OAuthStateSuccess: + return m.saveKeyAndContinue() + + default: + return ActionClose{} + } + } + + case ActionInitiateOAuth: + m.deviceCode = msg.DeviceCode + m.userCode = msg.UserCode + m.expiresIn = msg.ExpiresIn + m.verificationURL = msg.VerificationURL + m.interval = msg.Interval + m.State = OAuthStateDisplay + return ActionCmd{m.oAuthProvider.startPolling(msg.DeviceCode, msg.ExpiresIn)} + + case ActionCompleteOAuth: + m.State = OAuthStateSuccess + m.token = msg.Token + return ActionCmd{m.oAuthProvider.stopPolling} + + case ActionOAuthErrored: + m.State = OAuthStateError + cmd := tea.Batch(m.oAuthProvider.stopPolling, uiutil.ReportError(msg.Error)) + return ActionCmd{cmd} + } + return nil +} + +// View renders the device flow dialog. +func (m *OAuth) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor { + var ( + t = m.com.Styles + dialogStyle = t.Dialog.View.Width(m.width) + view = dialogStyle.Render(m.dialogContent()) + ) + DrawCenterCursor(scr, area, view, nil) + return nil +} + +func (m *OAuth) dialogContent() string { + var ( + t = m.com.Styles + helpStyle = t.Dialog.HelpView + ) + + switch m.State { + case OAuthStateInitializing: + return m.innerDialogContent() + + default: + elements := []string{ + m.headerContent(), + m.innerDialogContent(), + helpStyle.Render(m.help.View(m)), + } + return strings.Join(elements, "\n") + } +} + +func (m *OAuth) headerContent() string { + var ( + 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("Authenticate with "+m.oAuthProvider.name()), m.width-headerOffset) +} + +func (m *OAuth) innerDialogContent() string { + var ( + t = m.com.Styles + whiteStyle = lipgloss.NewStyle().Foreground(t.White) + primaryStyle = lipgloss.NewStyle().Foreground(t.Primary) + greenStyle = lipgloss.NewStyle().Foreground(t.GreenLight) + linkStyle = lipgloss.NewStyle().Foreground(t.GreenDark).Underline(true) + errorStyle = lipgloss.NewStyle().Foreground(t.Error) + mutedStyle = lipgloss.NewStyle().Foreground(t.FgMuted) + ) + + switch m.State { + case OAuthStateInitializing: + return lipgloss.NewStyle(). + Margin(1, 1). + Width(m.width - 2). + Align(lipgloss.Center). + Render( + greenStyle.Render(m.spinner.View()) + + mutedStyle.Render("Initializing..."), + ) + + case OAuthStateDisplay: + instructions := lipgloss.NewStyle(). + Margin(0, 1). + Width(m.width - 2). + Render( + whiteStyle.Render("Press ") + + primaryStyle.Render("enter") + + whiteStyle.Render(" to copy the code below and open the browser."), + ) + + codeBox := lipgloss.NewStyle(). + Width(m.width-2). + Height(7). + Align(lipgloss.Center, lipgloss.Center). + Background(t.BgBaseLighter). + Margin(0, 1). + Render( + lipgloss.NewStyle(). + Bold(true). + Foreground(t.White). + Render(m.userCode), + ) + + link := linkStyle.Hyperlink(m.verificationURL, "id=oauth-verify").Render(m.verificationURL) + url := mutedStyle. + Margin(0, 1). + Width(m.width - 2). + Render("Browser not opening? Refer to\n" + link) + + waiting := lipgloss.NewStyle(). + Margin(0, 1). + Width(m.width - 2). + Render( + greenStyle.Render(m.spinner.View()) + mutedStyle.Render("Verifying..."), + ) + + return lipgloss.JoinVertical( + lipgloss.Left, + "", + instructions, + "", + codeBox, + "", + url, + "", + waiting, + "", + ) + + case OAuthStateSuccess: + return greenStyle. + Margin(1). + Width(m.width - 2). + Render("Authentication successful!") + + case OAuthStateError: + return lipgloss.NewStyle(). + Margin(1). + Width(m.width - 2). + Render(errorStyle.Render("Authentication failed.")) + + default: + return "" + } +} + +// FullHelp returns the full help view. +func (m *OAuth) FullHelp() [][]key.Binding { + return [][]key.Binding{m.ShortHelp()} +} + +// ShortHelp returns the full help view. +func (m *OAuth) ShortHelp() []key.Binding { + switch m.State { + case OAuthStateError: + return []key.Binding{m.keyMap.Close} + + case OAuthStateSuccess: + return []key.Binding{ + key.NewBinding( + key.WithKeys("finish", "ctrl+y", "esc"), + key.WithHelp("enter", "finish"), + ), + } + + default: + return []key.Binding{ + m.keyMap.Copy, + m.keyMap.Submit, + m.keyMap.Close, + } + } +} + +func (d *OAuth) copyCode() tea.Cmd { + if d.State != OAuthStateDisplay { + return nil + } + return tea.Sequence( + tea.SetClipboard(d.userCode), + uiutil.ReportInfo("Code copied to clipboard"), + ) +} + +func (d *OAuth) copyCodeAndOpenURL() tea.Cmd { + if d.State != OAuthStateDisplay { + return nil + } + return tea.Sequence( + tea.SetClipboard(d.userCode), + func() tea.Msg { + if err := browser.OpenURL(d.verificationURL); err != nil { + return ActionOAuthErrored{fmt.Errorf("failed to open browser: %w", err)} + } + return nil + }, + uiutil.ReportInfo("Code copied and URL opened"), + ) +} + +func (m *OAuth) saveKeyAndContinue() Action { + cfg := m.com.Config() + + err := cfg.SetProviderAPIKey(string(m.provider.ID), m.token) + if err != nil { + return ActionCmd{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/oauth_copilot.go b/internal/ui/dialog/oauth_copilot.go new file mode 100644 index 0000000000000000000000000000000000000000..19e389b38a965c4c22ba1b2080b029975aaedc19 --- /dev/null +++ b/internal/ui/dialog/oauth_copilot.go @@ -0,0 +1,72 @@ +package dialog + +import ( + "context" + "fmt" + "time" + + tea "charm.land/bubbletea/v2" + "github.com/charmbracelet/catwalk/pkg/catwalk" + "github.com/charmbracelet/crush/internal/config" + "github.com/charmbracelet/crush/internal/oauth/copilot" + "github.com/charmbracelet/crush/internal/ui/common" +) + +func NewOAuthCopilot(com *common.Common, provider catwalk.Provider, model config.SelectedModel, modelType config.SelectedModelType) (*OAuth, tea.Cmd) { + return newOAuth(com, provider, model, modelType, &OAuthCopilot{}) +} + +type OAuthCopilot struct { + deviceCode *copilot.DeviceCode + cancelFunc func() +} + +var _ OAuthProvider = (*OAuthCopilot)(nil) + +func (m *OAuthCopilot) name() string { + return "GitHub Copilot" +} + +func (m *OAuthCopilot) initiateAuth() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + deviceCode, err := copilot.RequestDeviceCode(ctx) + if err != nil { + return ActionOAuthErrored{Error: fmt.Errorf("failed to initiate device auth: %w", err)} + } + + m.deviceCode = deviceCode + + return ActionInitiateOAuth{ + DeviceCode: deviceCode.DeviceCode, + UserCode: deviceCode.UserCode, + VerificationURL: deviceCode.VerificationURI, + ExpiresIn: deviceCode.ExpiresIn, + Interval: deviceCode.Interval, + } +} + +func (m *OAuthCopilot) startPolling(deviceCode string, expiresIn int) tea.Cmd { + return func() tea.Msg { + ctx, cancel := context.WithCancel(context.Background()) + m.cancelFunc = cancel + + token, err := copilot.PollForToken(ctx, m.deviceCode) + if err != nil { + if ctx.Err() != nil { + return nil // cancelled, don't report error. + } + return ActionOAuthErrored{Error: err} + } + + return ActionCompleteOAuth{Token: token} + } +} + +func (m *OAuthCopilot) stopPolling() tea.Msg { + if m.cancelFunc != nil { + m.cancelFunc() + } + return nil +} diff --git a/internal/ui/dialog/oauth_hyper.go b/internal/ui/dialog/oauth_hyper.go new file mode 100644 index 0000000000000000000000000000000000000000..478960b0df10f62d88b65450de360f4db6d6cd0c --- /dev/null +++ b/internal/ui/dialog/oauth_hyper.go @@ -0,0 +1,90 @@ +package dialog + +import ( + "context" + "fmt" + "time" + + tea "charm.land/bubbletea/v2" + "github.com/charmbracelet/catwalk/pkg/catwalk" + "github.com/charmbracelet/crush/internal/config" + "github.com/charmbracelet/crush/internal/oauth/hyper" + "github.com/charmbracelet/crush/internal/ui/common" +) + +func NewOAuthHyper(com *common.Common, provider catwalk.Provider, model config.SelectedModel, modelType config.SelectedModelType) (*OAuth, tea.Cmd) { + return newOAuth(com, provider, model, modelType, &OAuthHyper{}) +} + +type OAuthHyper struct { + cancelFunc func() +} + +var _ OAuthProvider = (*OAuthHyper)(nil) + +func (m *OAuthHyper) name() string { + return "Hyper" +} + +func (m *OAuthHyper) initiateAuth() tea.Msg { + minimumWait := 750 * time.Millisecond + startTime := time.Now() + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + authResp, err := hyper.InitiateDeviceAuth(ctx) + + ellapsed := time.Since(startTime) + if ellapsed < minimumWait { + time.Sleep(minimumWait - ellapsed) + } + + if err != nil { + return ActionOAuthErrored{fmt.Errorf("failed to initiate device auth: %w", err)} + } + + return ActionInitiateOAuth{ + DeviceCode: authResp.DeviceCode, + UserCode: authResp.UserCode, + ExpiresIn: authResp.ExpiresIn, + VerificationURL: authResp.VerificationURL, + } +} + +func (m *OAuthHyper) startPolling(deviceCode string, expiresIn int) tea.Cmd { + return func() tea.Msg { + ctx, cancel := context.WithCancel(context.Background()) + m.cancelFunc = cancel + + refreshToken, err := hyper.PollForToken(ctx, deviceCode, expiresIn) + if err != nil { + if ctx.Err() != nil { + return nil + } + return ActionOAuthErrored{err} + } + + token, err := hyper.ExchangeToken(ctx, refreshToken) + if err != nil { + return ActionOAuthErrored{fmt.Errorf("token exchange failed: %w", err)} + } + + introspect, err := hyper.IntrospectToken(ctx, token.AccessToken) + if err != nil { + return ActionOAuthErrored{fmt.Errorf("token introspection failed: %w", err)} + } + if !introspect.Active { + return ActionOAuthErrored{fmt.Errorf("access token is not active")} + } + + return ActionCompleteOAuth{token} + } +} + +func (m *OAuthHyper) stopPolling() tea.Msg { + if m.cancelFunc != nil { + m.cancelFunc() + } + return nil +} diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index f11ad75454dd18f3a710f925da52309935290c37..f40a25204ce167029ee09dc1dbd0fcf012c2a94e 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -378,6 +378,8 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { cmds = append(cmds, m.appendSessionMessage(msg.Payload)) case pubsub.UpdatedEvent: cmds = append(cmds, m.updateSessionMessage(msg.Payload)) + case pubsub.DeletedEvent: + m.chat.RemoveMessage(msg.Payload.ID) } case pubsub.Event[history.File]: cmds = append(cmds, m.handleFileEvent(msg.Payload)) @@ -902,13 +904,24 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd { cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait before summarizing session...")) break } - err := m.com.App.AgentCoordinator.Summarize(context.Background(), msg.SessionID) - if err != nil { - cmds = append(cmds, uiutil.ReportError(err)) - } + cmds = append(cmds, func() tea.Msg { + err := m.com.App.AgentCoordinator.Summarize(context.Background(), msg.SessionID) + if err != nil { + return uiutil.ReportError(err)() + } + return nil + }) + m.dialog.CloseDialog(dialog.CommandsID) case dialog.ActionToggleHelp: m.status.ToggleHelp() m.dialog.CloseDialog(dialog.CommandsID) + case dialog.ActionExternalEditor: + if m.session != nil && m.com.App.AgentCoordinator.IsSessionBusy(m.session.ID) { + cmds = append(cmds, uiutil.ReportWarn("Agent is working, please wait...")) + break + } + cmds = append(cmds, m.openEditor(m.textarea.Value())) + m.dialog.CloseDialog(dialog.CommandsID) case dialog.ActionToggleCompactMode: cmds = append(cmds, m.toggleCompactMode()) m.dialog.CloseDialog(dialog.CommandsID) @@ -933,10 +946,20 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd { break } - _, isProviderConfigured := cfg.Providers.Get(msg.Model.Provider) - if !isProviderConfigured { + var ( + providerID = msg.Model.Provider + isCopilot = providerID == string(catwalk.InferenceProviderCopilot) + isConfigured = func() bool { _, ok := cfg.Providers.Get(providerID); return ok } + ) + + // Attempt to import GitHub Copilot tokens from VSCode if available. + if isCopilot && !isConfigured() { + config.Get().ImportCopilot() + } + + if !isConfigured() { m.dialog.CloseDialog(dialog.ModelsID) - if cmd := m.openAPIKeyInputDialog(msg.Provider, msg.Model, msg.ModelType); cmd != nil { + if cmd := m.openAuthenticationDialog(msg.Provider, msg.Model, msg.ModelType); cmd != nil { cmds = append(cmds, cmd) } break @@ -952,6 +975,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.OAuthID) m.dialog.CloseDialog(dialog.ModelsID) // TODO CHANGE case dialog.ActionPermissionResponse: @@ -1027,19 +1051,28 @@ func substituteArgs(content string, args map[string]string) string { return content } -// 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 +func (m *UI) openAuthenticationDialog(provider catwalk.Provider, model config.SelectedModel, modelType config.SelectedModelType) tea.Cmd { + var ( + dlg dialog.Dialog + cmd tea.Cmd + ) + + switch provider.ID { + case "hyper": + dlg, cmd = dialog.NewOAuthHyper(m.com, provider, model, modelType) + case catwalk.InferenceProviderCopilot: + dlg, cmd = dialog.NewOAuthCopilot(m.com, provider, model, modelType) + default: + dlg, cmd = dialog.NewAPIKeyInput(m.com, provider, model, modelType) } - apiKeyInputDialog, err := dialog.NewAPIKeyInput(m.com, provider, model, modelType) - if err != nil { - return uiutil.ReportError(err) + if m.dialog.ContainsDialog(dlg.ID()) { + m.dialog.BringToFront(dlg.ID()) + return nil } - m.dialog.OpenDialog(apiKeyInputDialog) - return nil + + m.dialog.OpenDialog(dlg) + return cmd } func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd { diff --git a/internal/ui/styles/styles.go b/internal/ui/styles/styles.go index 5c822b862bf76f1826ad4e367b1665a09ce467a5..97711efaa7b951f13aa01a0a823ee7514919b136 100644 --- a/internal/ui/styles/styles.go +++ b/internal/ui/styles/styles.go @@ -173,12 +173,14 @@ type Styles struct { FgSubtle color.Color Border color.Color BorderColor color.Color // Border focus color + Error color.Color Warning color.Color Info color.Color White color.Color BlueLight color.Color Blue color.Color BlueDark color.Color + GreenLight color.Color Green color.Color GreenDark color.Color Red color.Color @@ -461,6 +463,7 @@ func DefaultStyles() Styles { borderFocus = charmtone.Charple // Status + error = charmtone.Sriracha warning = charmtone.Zest info = charmtone.Malibu @@ -475,8 +478,9 @@ func DefaultStyles() Styles { yellow = charmtone.Mustard // citron = charmtone.Citron - green = charmtone.Julep - greenDark = charmtone.Guac + greenLight = charmtone.Bok + green = charmtone.Julep + greenDark = charmtone.Guac // greenLight = charmtone.Bok red = charmtone.Coral @@ -507,12 +511,14 @@ func DefaultStyles() Styles { s.FgSubtle = fgSubtle s.Border = border s.BorderColor = borderFocus + s.Error = error s.Warning = warning s.Info = info s.White = white s.BlueLight = blueLight s.Blue = blue s.BlueDark = blueDark + s.GreenLight = greenLight s.Green = green s.GreenDark = greenDark s.Red = red