Detailed changes
@@ -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 {
@@ -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{}
@@ -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)
}
}
@@ -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,
+ }
+}
@@ -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
+}
@@ -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
+}
@@ -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 {
@@ -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