From 6ce4fbceecccd8a00da7476b9a11aa073d07f6d4 Mon Sep 17 00:00:00 2001 From: Andrey Nering Date: Thu, 30 Apr 2026 18:52:16 -0300 Subject: [PATCH] feat(hyper): show remaining hypercredits in the sidebar (#2766) --- internal/agent/hyper/provider.go | 41 +++++++++++++++++++++++++ internal/ui/common/elements.go | 33 +++++++++++++++++++- internal/ui/model/sidebar.go | 2 +- internal/ui/model/ui.go | 52 ++++++++++++++++++++++++++++++-- internal/ui/styles/quickstyle.go | 2 ++ internal/ui/styles/styles.go | 11 ++++--- 6 files changed, 133 insertions(+), 8 deletions(-) diff --git a/internal/agent/hyper/provider.go b/internal/agent/hyper/provider.go index 913f1d5e6cfedd563c11bb60c81655919be978dc..add31508f6c7f73263e044d28646e5ac6eb4e394 100644 --- a/internal/agent/hyper/provider.go +++ b/internal/agent/hyper/provider.go @@ -3,12 +3,16 @@ package hyper import ( "cmp" + "context" _ "embed" "encoding/json" + "fmt" "log/slog" + "net/http" "os" "strconv" "sync" + "time" "charm.land/catwalk/pkg/catwalk" ) @@ -46,6 +50,8 @@ var Embedded = sync.OnceValue(func() catwalk.Provider { const ( // Name is the default name of this meta provider. Name = "hyper" + // DisplayName is the display name of Hyper. + DisplayName = "Charm Hyper" // defaultBaseURL is the default proxy URL. defaultBaseURL = "https://hyper.charm.land" ) @@ -54,3 +60,38 @@ const ( var BaseURL = sync.OnceValue(func() string { return cmp.Or(os.Getenv("HYPER_URL"), defaultBaseURL) }) + +// FetchCredits calls the Hyper /v1/credits endpoint and returns the remaining +// credits count. +func FetchCredits(ctx context.Context, apiKey string) (int, error) { + req, err := http.NewRequestWithContext( + ctx, + http.MethodGet, + BaseURL()+"/v1/credits", + nil, + ) + if err != nil { + return 0, fmt.Errorf("could not create request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+apiKey) + + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Do(req) + if err != nil { + return 0, fmt.Errorf("failed to make request: %w", err) + } + defer resp.Body.Close() //nolint:errcheck + + if resp.StatusCode != http.StatusOK { + return 0, fmt.Errorf("unexpected status code: %d", resp.StatusCode) + } + + var result struct { + Balance int `json:"balance"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return 0, fmt.Errorf("failed to decode response: %w", err) + } + + return result.Balance, nil +} diff --git a/internal/ui/common/elements.go b/internal/ui/common/elements.go index 652d4734345397cdbb3a7e3160f842ca6a5cca74..d4f948efbea7f29952ccad83affc526a62a82ddb 100644 --- a/internal/ui/common/elements.go +++ b/internal/ui/common/elements.go @@ -4,9 +4,11 @@ import ( "cmp" "fmt" "image/color" + "strconv" "strings" "charm.land/lipgloss/v2" + "github.com/charmbracelet/crush/internal/agent/hyper" "github.com/charmbracelet/crush/internal/home" "github.com/charmbracelet/crush/internal/ui/styles" "github.com/charmbracelet/x/ansi" @@ -38,7 +40,7 @@ type ModelContextInfo struct { // ModelInfo renders model information including name, provider, reasoning // settings, and optional context usage/cost. -func ModelInfo(t *styles.Styles, modelName, providerName, reasoningInfo string, context *ModelContextInfo, width int) string { +func ModelInfo(t *styles.Styles, modelName, providerName, reasoningInfo string, context *ModelContextInfo, width int, hyperCredits *int) string { modelIcon := t.ModelInfo.Icon.Render(styles.ModelIcon) modelName = t.ModelInfo.Name.Render(modelName) @@ -76,6 +78,13 @@ func ModelInfo(t *styles.Styles, modelName, providerName, reasoningInfo string, parts = append(parts, lipgloss.NewStyle().PaddingLeft(2).Render(formattedInfo)) } + if providerName == hyper.DisplayName && hyperCredits != nil { + hcInfo := t.ModelInfo.HypercreditIcon.Render(styles.HypercreditIcon) + hcInfo += " " + hcInfo += t.ModelInfo.HypercreditText.Render(fmt.Sprintf("%s Hypercredits", formatCredits(*hyperCredits))) + parts = append(parts, "", hcInfo) + } + return lipgloss.NewStyle().Width(width).Render( lipgloss.JoinVertical(lipgloss.Left, parts...), ) @@ -115,6 +124,28 @@ func formatTokensAndCost(t *styles.Styles, tokens, contextWindow int64, cost flo return fmt.Sprintf("%s %s", formattedTokens, formattedCost) } +// formatCredits formats an integer with comma separators for thousands. +func formatCredits(n int) string { + s := strconv.FormatInt(int64(n), 10) + if n < 1000 { + return s + } + // Calculate how many digits before the first comma. + firstGroup := len(s) % 3 + if firstGroup == 0 { + firstGroup = 3 + } + var b []byte + for i := 0; i < len(s); i++ { + if i > 0 && i == firstGroup { + b = append(b, ',') + firstGroup += 3 + } + b = append(b, s[i]) + } + return string(b) +} + // StatusOpts defines options for rendering a status line with icon, title, // description, and optional extra content. type StatusOpts struct { diff --git a/internal/ui/model/sidebar.go b/internal/ui/model/sidebar.go index 5e04b45badcdad7016f236ed1362e4e9c99441d5..7e81531499fb3c73f4778646decdc16877b59614 100644 --- a/internal/ui/model/sidebar.go +++ b/internal/ui/model/sidebar.go @@ -53,7 +53,7 @@ func (m *UI) modelInfo(width int) string { if model != nil { modelName = model.CatwalkCfg.Name } - return common.ModelInfo(m.com.Styles, modelName, providerName, reasoningInfo, modelContext, width) + return common.ModelInfo(m.com.Styles, modelName, providerName, reasoningInfo, modelContext, width, m.hyperCredits) } // getDynamicHeightLimits will give us the num of items to show in each section based on the height diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index 8ffe3b69df8d657011839bf5aa289808d1ef79e4..aa0acbf24b276f3931b7fa8b9e701ba331b46bce 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -25,6 +25,7 @@ import ( tea "charm.land/bubbletea/v2" "charm.land/catwalk/pkg/catwalk" "charm.land/lipgloss/v2" + "github.com/charmbracelet/crush/internal/agent/hyper" "github.com/charmbracelet/crush/internal/agent/notify" agenttools "github.com/charmbracelet/crush/internal/agent/tools" "github.com/charmbracelet/crush/internal/agent/tools/mcp" @@ -151,6 +152,11 @@ type ( sessionFilesUpdatesMsg struct { sessionFiles []SessionFile } + // creditsUpdatedMsg is sent when the remaining Hyper credits have been + // fetched from the API. + creditsUpdatedMsg struct { + credits int + } ) // UI represents the main user interface model. @@ -264,6 +270,9 @@ type UI struct { // mouse highlighting related state lastClickTime time.Time + // hyperCredits is the remaining Hyper credits, updated after each prompt. + hyperCredits *int + // Prompt history for up/down navigation through previous messages. promptHistory struct { messages []string @@ -386,6 +395,9 @@ func (m *UI) Init() tea.Cmd { if cmd := m.loadInitialSession(); cmd != nil { cmds = append(cmds, cmd) } + if m.com.IsHyper() { + cmds = append(cmds, m.fetchHyperCredits()) + } return tea.Batch(cmds...) } @@ -854,6 +866,8 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if cmd := m.handleSelectModel(msg.action); cmd != nil { cmds = append(cmds, cmd) } + case creditsUpdatedMsg: + m.hyperCredits = &msg.credits case util.InfoMsg: if msg.Type == util.InfoTypeError { slog.Error("Error reported", "error", msg.Msg) @@ -1575,6 +1589,33 @@ func (m *UI) refreshHyperAndRetrySelect(msg dialog.ActionSelectModel) tea.Cmd { } } +// fetchHyperCredits returns a command that asynchronously fetches the +// remaining Hyper credits from the API. +func (m *UI) fetchHyperCredits() tea.Cmd { + return func() tea.Msg { + cfg := m.com.Config() + if cfg == nil { + return nil + } + providerCfg, ok := cfg.Providers.Get(hyper.Name) + if !ok { + return nil + } + apiKey, err := m.com.Workspace.Resolver().ResolveValue(providerCfg.APIKey) + if err != nil || apiKey == "" { + return nil + } + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + credits, err := hyper.FetchCredits(ctx, apiKey) + if err != nil { + slog.Error("Failed to fetch Hyper credits", "error", err) + return nil + } + return creditsUpdatedMsg{credits: credits} + } +} + // handleSelectModel performs the model selection after any provider // pre-checks (such as a silent Hyper OAuth refresh) have completed. func (m *UI) handleSelectModel(msg dialog.ActionSelectModel) tea.Cmd { @@ -1656,6 +1697,8 @@ func (m *UI) handleSelectModel(msg dialog.ActionSelectModel) tea.Cmd { if err := m.com.Workspace.InitCoderAgent(context.TODO()); err != nil { cmds = append(cmds, util.ReportError(err)) } + } else if m.com.IsHyper() { + cmds = append(cmds, m.fetchHyperCredits()) } return tea.Batch(cmds...) @@ -3352,10 +3395,15 @@ func (m *UI) handlePermissionNotification(notification permission.PermissionNoti func (m *UI) handleAgentNotification(n notify.Notification) tea.Cmd { switch n.Type { case notify.TypeAgentFinished: - return m.sendNotification(notification.Notification{ + var cmds []tea.Cmd + cmds = append(cmds, m.sendNotification(notification.Notification{ Title: "Crush is waiting...", Message: fmt.Sprintf("Agent's turn completed in \"%s\"", n.SessionTitle), - }) + })) + if m.com.IsHyper() { + cmds = append(cmds, m.fetchHyperCredits()) + } + return tea.Batch(cmds...) case notify.TypeReAuthenticate: return m.handleReAuthenticate(n.ProviderID) default: diff --git a/internal/ui/styles/quickstyle.go b/internal/ui/styles/quickstyle.go index 2276ec509e79e987bac1be3c71db5b9d07517c0b..b4fc0f7bec1b595adbc3cd30e1502e2ada8d18c7 100644 --- a/internal/ui/styles/quickstyle.go +++ b/internal/ui/styles/quickstyle.go @@ -763,6 +763,8 @@ func quickStyle(o quickStyleOpts) Styles { s.ModelInfo.TokenCount = lipgloss.NewStyle().Foreground(o.fgMostSubtle) s.ModelInfo.TokenPercentage = lipgloss.NewStyle().Foreground(o.fgMoreSubtle) s.ModelInfo.Cost = lipgloss.NewStyle().Foreground(o.fgMoreSubtle) + s.ModelInfo.HypercreditIcon = lipgloss.NewStyle().Foreground(charmtone.Dolly) + s.ModelInfo.HypercreditText = lipgloss.NewStyle().Foreground(o.fgMoreSubtle) // ResourceGroup s.Resource.DefaultTitleFg = o.fgMoreSubtle diff --git a/internal/ui/styles/styles.go b/internal/ui/styles/styles.go index ecde74258c127992e5a995adf74a69588cefe7a3..1319b20bf3418ae4e021704b5ab11ffaa7c2deb2 100644 --- a/internal/ui/styles/styles.go +++ b/internal/ui/styles/styles.go @@ -17,10 +17,11 @@ import ( ) const ( - CheckIcon string = "✓" - SpinnerIcon string = "⋯" - LoadingIcon string = "⟳" - ModelIcon string = "◇" + CheckIcon string = "✓" + SpinnerIcon string = "⋯" + LoadingIcon string = "⟳" + ModelIcon string = "◇" + HypercreditIcon string = "◆" ArrowRightIcon string = "→" @@ -189,6 +190,8 @@ type Styles struct { TokenCount lipgloss.Style // "(42K)" token count TokenPercentage lipgloss.Style // "42%" percent of context window Cost lipgloss.Style // "$0.42" cost readout + HypercreditIcon lipgloss.Style // Hypercredit icon (◆) + HypercreditText lipgloss.Style // Remaining Hypercredits text } // Resource styles the LSP/MCP/skills sidebar lists: their heading,