on main help sign in
feat(hyper): show remaining hypercredits in the sidebar (#2766)
commit6ce4fbceecccd8a00da7476b9a11aa073d07f6d4 authorAndrey Nering <andreynering@users.noreply.github.com> dateThu 30 Apr 2026, 18:52 (6 months ago) parent5d8f924
6 files changed+133−8
internal/agent/hyper/provider.go+41 internal/ui/common/elements.go+32−1 internal/ui/model/sidebar.go+1−1 internal/ui/model/ui.go+50−2 internal/ui/styles/quickstyle.go+2 internal/ui/styles/styles.go+7−4
internal/agent/hyper/provider.go+41
@@ -3,12 +3,16 @@ package hyper
33
44import (
55 "cmp"
6+ "context"
67 _ "embed"
78 "encoding/json"
9+ "fmt"
810 "log/slog"
11+ "net/http"
912 "os"
1013 "strconv"
1114 "sync"
15+ "time"
1216
1317 "charm.land/catwalk/pkg/catwalk"
1418)
@@ -46,6 +50,8 @@ var Embedded = sync.OnceValue(func() catwalk.Provider {
4650const (
4751 // Name is the default name of this meta provider.
4852 Name = "hyper"
53+ // DisplayName is the display name of Hyper.
54+ DisplayName = "Charm Hyper"
4955 // defaultBaseURL is the default proxy URL.
5056 defaultBaseURL = "https://hyper.charm.land"
5157)
@@ -54,3 +60,38 @@ const (
5460var BaseURL = sync.OnceValue(func() string {
5561 return cmp.Or(os.Getenv("HYPER_URL"), defaultBaseURL)
5662})
63+
64+// FetchCredits calls the Hyper /v1/credits endpoint and returns the remaining
65+// credits count.
66+func FetchCredits(ctx context.Context, apiKey string) (int, error) {
67+ req, err := http.NewRequestWithContext(
68+ ctx,
69+ http.MethodGet,
70+ BaseURL()+"/v1/credits",
71+ nil,
72+ )
73+ if err != nil {
74+ return 0, fmt.Errorf("could not create request: %w", err)
75+ }
76+ req.Header.Set("Authorization", "Bearer "+apiKey)
77+
78+ client := &http.Client{Timeout: 10 * time.Second}
79+ resp, err := client.Do(req)
80+ if err != nil {
81+ return 0, fmt.Errorf("failed to make request: %w", err)
82+ }
83+ defer resp.Body.Close() //nolint:errcheck
84+
85+ if resp.StatusCode != http.StatusOK {
86+ return 0, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
87+ }
88+
89+ var result struct {
90+ Balance int `json:"balance"`
91+ }
92+ if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
93+ return 0, fmt.Errorf("failed to decode response: %w", err)
94+ }
95+
96+ return result.Balance, nil
97+}
internal/ui/common/elements.go+32−1
@@ -4,9 +4,11 @@ import (
44 "cmp"
55 "fmt"
66 "image/color"
7+ "strconv"
78 "strings"
89
910 "charm.land/lipgloss/v2"
11+ "github.com/charmbracelet/crush/internal/agent/hyper"
1012 "github.com/charmbracelet/crush/internal/home"
1113 "github.com/charmbracelet/crush/internal/ui/styles"
1214 "github.com/charmbracelet/x/ansi"
@@ -38,7 +40,7 @@ type ModelContextInfo struct {
3840
3941// ModelInfo renders model information including name, provider, reasoning
4042// settings, and optional context usage/cost.
41−func ModelInfo(t *styles.Styles, modelName, providerName, reasoningInfo string, context *ModelContextInfo, width int) string {
43+func ModelInfo(t *styles.Styles, modelName, providerName, reasoningInfo string, context *ModelContextInfo, width int, hyperCredits *int) string {
4244 modelIcon := t.ModelInfo.Icon.Render(styles.ModelIcon)
4345 modelName = t.ModelInfo.Name.Render(modelName)
4446
@@ -76,6 +78,13 @@ func ModelInfo(t *styles.Styles, modelName, providerName, reasoningInfo string,
7678 parts = append(parts, lipgloss.NewStyle().PaddingLeft(2).Render(formattedInfo))
7779 }
7880
81+ if providerName == hyper.DisplayName && hyperCredits != nil {
82+ hcInfo := t.ModelInfo.HypercreditIcon.Render(styles.HypercreditIcon)
83+ hcInfo += " "
84+ hcInfo += t.ModelInfo.HypercreditText.Render(fmt.Sprintf("%s Hypercredits", formatCredits(*hyperCredits)))
85+ parts = append(parts, "", hcInfo)
86+ }
87+
7988 return lipgloss.NewStyle().Width(width).Render(
8089 lipgloss.JoinVertical(lipgloss.Left, parts...),
8190 )
@@ -115,6 +124,28 @@ func formatTokensAndCost(t *styles.Styles, tokens, contextWindow int64, cost flo
115124 return fmt.Sprintf("%s %s", formattedTokens, formattedCost)
116125}
117126
127+// formatCredits formats an integer with comma separators for thousands.
128+func formatCredits(n int) string {
129+ s := strconv.FormatInt(int64(n), 10)
130+ if n < 1000 {
131+ return s
132+ }
133+ // Calculate how many digits before the first comma.
134+ firstGroup := len(s) % 3
135+ if firstGroup == 0 {
136+ firstGroup = 3
137+ }
138+ var b []byte
139+ for i := 0; i < len(s); i++ {
140+ if i > 0 && i == firstGroup {
141+ b = append(b, ',')
142+ firstGroup += 3
143+ }
144+ b = append(b, s[i])
145+ }
146+ return string(b)
147+}
148+
118149// StatusOpts defines options for rendering a status line with icon, title,
119150// description, and optional extra content.
120151type StatusOpts struct {
internal/ui/model/sidebar.go+1−1
@@ -53,7 +53,7 @@ func (m *UI) modelInfo(width int) string {
5353 if model != nil {
5454 modelName = model.CatwalkCfg.Name
5555 }
56− return common.ModelInfo(m.com.Styles, modelName, providerName, reasoningInfo, modelContext, width)
56+ return common.ModelInfo(m.com.Styles, modelName, providerName, reasoningInfo, modelContext, width, m.hyperCredits)
5757}
5858
5959// getDynamicHeightLimits will give us the num of items to show in each section based on the height
internal/ui/model/ui.go+50−2
@@ -25,6 +25,7 @@ import (
2525 tea "charm.land/bubbletea/v2"
2626 "charm.land/catwalk/pkg/catwalk"
2727 "charm.land/lipgloss/v2"
28+ "github.com/charmbracelet/crush/internal/agent/hyper"
2829 "github.com/charmbracelet/crush/internal/agent/notify"
2930 agenttools "github.com/charmbracelet/crush/internal/agent/tools"
3031 "github.com/charmbracelet/crush/internal/agent/tools/mcp"
@@ -151,6 +152,11 @@ type (
151152 sessionFilesUpdatesMsg struct {
152153 sessionFiles []SessionFile
153154 }
155+ // creditsUpdatedMsg is sent when the remaining Hyper credits have been
156+ // fetched from the API.
157+ creditsUpdatedMsg struct {
158+ credits int
159+ }
154160)
155161
156162// UI represents the main user interface model.
@@ -264,6 +270,9 @@ type UI struct {
264270 // mouse highlighting related state
265271 lastClickTime time.Time
266272
273+ // hyperCredits is the remaining Hyper credits, updated after each prompt.
274+ hyperCredits *int
275+
267276 // Prompt history for up/down navigation through previous messages.
268277 promptHistory struct {
269278 messages []string
@@ -386,6 +395,9 @@ func (m *UI) Init() tea.Cmd {
386395 if cmd := m.loadInitialSession(); cmd != nil {
387396 cmds = append(cmds, cmd)
388397 }
398+ if m.com.IsHyper() {
399+ cmds = append(cmds, m.fetchHyperCredits())
400+ }
389401 return tea.Batch(cmds...)
390402}
391403
@@ -854,6 +866,8 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
854866 if cmd := m.handleSelectModel(msg.action); cmd != nil {
855867 cmds = append(cmds, cmd)
856868 }
869+ case creditsUpdatedMsg:
870+ m.hyperCredits = &msg.credits
857871 case util.InfoMsg:
858872 if msg.Type == util.InfoTypeError {
859873 slog.Error("Error reported", "error", msg.Msg)
@@ -1575,6 +1589,33 @@ func (m *UI) refreshHyperAndRetrySelect(msg dialog.ActionSelectModel) tea.Cmd {
15751589 }
15761590}
15771591
1592+// fetchHyperCredits returns a command that asynchronously fetches the
1593+// remaining Hyper credits from the API.
1594+func (m *UI) fetchHyperCredits() tea.Cmd {
1595+ return func() tea.Msg {
1596+ cfg := m.com.Config()
1597+ if cfg == nil {
1598+ return nil
1599+ }
1600+ providerCfg, ok := cfg.Providers.Get(hyper.Name)
1601+ if !ok {
1602+ return nil
1603+ }
1604+ apiKey, err := m.com.Workspace.Resolver().ResolveValue(providerCfg.APIKey)
1605+ if err != nil || apiKey == "" {
1606+ return nil
1607+ }
1608+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
1609+ defer cancel()
1610+ credits, err := hyper.FetchCredits(ctx, apiKey)
1611+ if err != nil {
1612+ slog.Error("Failed to fetch Hyper credits", "error", err)
1613+ return nil
1614+ }
1615+ return creditsUpdatedMsg{credits: credits}
1616+ }
1617+}
1618+
15781619// handleSelectModel performs the model selection after any provider
15791620// pre-checks (such as a silent Hyper OAuth refresh) have completed.
15801621func (m *UI) handleSelectModel(msg dialog.ActionSelectModel) tea.Cmd {
@@ -1656,6 +1697,8 @@ func (m *UI) handleSelectModel(msg dialog.ActionSelectModel) tea.Cmd {
16561697 if err := m.com.Workspace.InitCoderAgent(context.TODO()); err != nil {
16571698 cmds = append(cmds, util.ReportError(err))
16581699 }
1700+ } else if m.com.IsHyper() {
1701+ cmds = append(cmds, m.fetchHyperCredits())
16591702 }
16601703
16611704 return tea.Batch(cmds...)
@@ -3352,10 +3395,15 @@ func (m *UI) handlePermissionNotification(notification permission.PermissionNoti
33523395func (m *UI) handleAgentNotification(n notify.Notification) tea.Cmd {
33533396 switch n.Type {
33543397 case notify.TypeAgentFinished:
3355− return m.sendNotification(notification.Notification{
3398+ var cmds []tea.Cmd
3399+ cmds = append(cmds, m.sendNotification(notification.Notification{
33563400 Title: "Crush is waiting...",
33573401 Message: fmt.Sprintf("Agent's turn completed in \"%s\"", n.SessionTitle),
3358− })
3402+ }))
3403+ if m.com.IsHyper() {
3404+ cmds = append(cmds, m.fetchHyperCredits())
3405+ }
3406+ return tea.Batch(cmds...)
33593407 case notify.TypeReAuthenticate:
33603408 return m.handleReAuthenticate(n.ProviderID)
33613409 default:
internal/ui/styles/quickstyle.go+2
@@ -763,6 +763,8 @@ func quickStyle(o quickStyleOpts) Styles {
763763 s.ModelInfo.TokenCount = lipgloss.NewStyle().Foreground(o.fgMostSubtle)
764764 s.ModelInfo.TokenPercentage = lipgloss.NewStyle().Foreground(o.fgMoreSubtle)
765765 s.ModelInfo.Cost = lipgloss.NewStyle().Foreground(o.fgMoreSubtle)
766+ s.ModelInfo.HypercreditIcon = lipgloss.NewStyle().Foreground(charmtone.Dolly)
767+ s.ModelInfo.HypercreditText = lipgloss.NewStyle().Foreground(o.fgMoreSubtle)
766768
767769 // ResourceGroup
768770 s.Resource.DefaultTitleFg = o.fgMoreSubtle
internal/ui/styles/styles.go+7−4
@@ -17,10 +17,11 @@ import (
1717)
1818
1919const (
20− CheckIcon string = "✓"
21− SpinnerIcon string = "⋯"
22− LoadingIcon string = "⟳"
23− ModelIcon string = "◇"
20+ CheckIcon string = "✓"
21+ SpinnerIcon string = "⋯"
22+ LoadingIcon string = "⟳"
23+ ModelIcon string = "◇"
24+ HypercreditIcon string = "◆"
2425
2526 ArrowRightIcon string = "→"
2627
@@ -189,6 +190,8 @@ type Styles struct {
189190 TokenCount lipgloss.Style // "(42K)" token count
190191 TokenPercentage lipgloss.Style // "42%" percent of context window
191192 Cost lipgloss.Style // "$0.42" cost readout
193+ HypercreditIcon lipgloss.Style // Hypercredit icon (◆)
194+ HypercreditText lipgloss.Style // Remaining Hypercredits text
192195 }
193196
194197 // Resource styles the LSP/MCP/skills sidebar lists: their heading,