on main help sign in
feat(hyper): show remaining hypercredits in the sidebar (#2766)
6ce4fbcAndrey Nering
internal/agent/hyper/provider.go+41
@@ -3,12 +3,16 @@ package hyper
3
3
4import (
4import (
5 "cmp"
5 "cmp"
6+ "context"
6 _ "embed"
7 _ "embed"
7 "encoding/json"
8 "encoding/json"
9+ "fmt"
8 "log/slog"
10 "log/slog"
11+ "net/http"
9 "os"
12 "os"
10 "strconv"
13 "strconv"
11 "sync"
14 "sync"
15+ "time"
12
16
13 "charm.land/catwalk/pkg/catwalk"
17 "charm.land/catwalk/pkg/catwalk"
14)
18)
@@ -46,6 +50,8 @@ var Embedded = sync.OnceValue(func() catwalk.Provider {
46const (
50const (
47 // Name is the default name of this meta provider.
51 // Name is the default name of this meta provider.
48 Name = "hyper"
52 Name = "hyper"
53+ // DisplayName is the display name of Hyper.
54+ DisplayName = "Charm Hyper"
49 // defaultBaseURL is the default proxy URL.
55 // defaultBaseURL is the default proxy URL.
50 defaultBaseURL = "https://hyper.charm.land"
56 defaultBaseURL = "https://hyper.charm.land"
51)
57)
@@ -54,3 +60,38 @@ const (
54var BaseURL = sync.OnceValue(func() string {
60var BaseURL = sync.OnceValue(func() string {
55 return cmp.Or(os.Getenv("HYPER_URL"), defaultBaseURL)
61 return cmp.Or(os.Getenv("HYPER_URL"), defaultBaseURL)
56})
62})
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 (
4 "cmp"
4 "cmp"
5 "fmt"
5 "fmt"
6 "image/color"
6 "image/color"
7+ "strconv"
7 "strings"
8 "strings"
8
9
9 "charm.land/lipgloss/v2"
10 "charm.land/lipgloss/v2"
11+ "github.com/charmbracelet/crush/internal/agent/hyper"
10 "github.com/charmbracelet/crush/internal/home"
12 "github.com/charmbracelet/crush/internal/home"
11 "github.com/charmbracelet/crush/internal/ui/styles"
13 "github.com/charmbracelet/crush/internal/ui/styles"
12 "github.com/charmbracelet/x/ansi"
14 "github.com/charmbracelet/x/ansi"
@@ -38,7 +40,7 @@ type ModelContextInfo struct {
38
40
39// ModelInfo renders model information including name, provider, reasoning
41// ModelInfo renders model information including name, provider, reasoning
40// settings, and optional context usage/cost.
42// 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 {
42 modelIcon := t.ModelInfo.Icon.Render(styles.ModelIcon)
44 modelIcon := t.ModelInfo.Icon.Render(styles.ModelIcon)
43 modelName = t.ModelInfo.Name.Render(modelName)
45 modelName = t.ModelInfo.Name.Render(modelName)
44
46
@@ -76,6 +78,13 @@ func ModelInfo(t *styles.Styles, modelName, providerName, reasoningInfo string,
76 parts = append(parts, lipgloss.NewStyle().PaddingLeft(2).Render(formattedInfo))
78 parts = append(parts, lipgloss.NewStyle().PaddingLeft(2).Render(formattedInfo))
77 }
79 }
78
80
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+
79 return lipgloss.NewStyle().Width(width).Render(
88 return lipgloss.NewStyle().Width(width).Render(
80 lipgloss.JoinVertical(lipgloss.Left, parts...),
89 lipgloss.JoinVertical(lipgloss.Left, parts...),
81 )
90 )
@@ -115,6 +124,28 @@ func formatTokensAndCost(t *styles.Styles, tokens, contextWindow int64, cost flo
115 return fmt.Sprintf("%s %s", formattedTokens, formattedCost)
124 return fmt.Sprintf("%s %s", formattedTokens, formattedCost)
116}
125}
117
126
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+
118// StatusOpts defines options for rendering a status line with icon, title,
149// StatusOpts defines options for rendering a status line with icon, title,
119// description, and optional extra content.
150// description, and optional extra content.
120type StatusOpts struct {
151type StatusOpts struct {
internal/ui/model/sidebar.go+1−1
@@ -53,7 +53,7 @@ func (m *UI) modelInfo(width int) string {
53 if model != nil {
53 if model != nil {
54 modelName = model.CatwalkCfg.Name
54 modelName = model.CatwalkCfg.Name
55 }
55 }
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)
57}
57}
58
58
59// getDynamicHeightLimits will give us the num of items to show in each section based on the height
59// 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 (
25 tea "charm.land/bubbletea/v2"
25 tea "charm.land/bubbletea/v2"
26 "charm.land/catwalk/pkg/catwalk"
26 "charm.land/catwalk/pkg/catwalk"
27 "charm.land/lipgloss/v2"
27 "charm.land/lipgloss/v2"
28+ "github.com/charmbracelet/crush/internal/agent/hyper"
28 "github.com/charmbracelet/crush/internal/agent/notify"
29 "github.com/charmbracelet/crush/internal/agent/notify"
29 agenttools "github.com/charmbracelet/crush/internal/agent/tools"
30 agenttools "github.com/charmbracelet/crush/internal/agent/tools"
30 "github.com/charmbracelet/crush/internal/agent/tools/mcp"
31 "github.com/charmbracelet/crush/internal/agent/tools/mcp"
@@ -151,6 +152,11 @@ type (
151 sessionFilesUpdatesMsg struct {
152 sessionFilesUpdatesMsg struct {
152 sessionFiles []SessionFile
153 sessionFiles []SessionFile
153 }
154 }
155+ // creditsUpdatedMsg is sent when the remaining Hyper credits have been
156+ // fetched from the API.
157+ creditsUpdatedMsg struct {
158+ credits int
159+ }
154)
160)
155
161
156// UI represents the main user interface model.
162// UI represents the main user interface model.
@@ -264,6 +270,9 @@ type UI struct {
264 // mouse highlighting related state
270 // mouse highlighting related state
265 lastClickTime time.Time
271 lastClickTime time.Time
266
272
273+ // hyperCredits is the remaining Hyper credits, updated after each prompt.
274+ hyperCredits *int
275+
267 // Prompt history for up/down navigation through previous messages.
276 // Prompt history for up/down navigation through previous messages.
268 promptHistory struct {
277 promptHistory struct {
269 messages []string
278 messages []string
@@ -386,6 +395,9 @@ func (m *UI) Init() tea.Cmd {
386 if cmd := m.loadInitialSession(); cmd != nil {
395 if cmd := m.loadInitialSession(); cmd != nil {
387 cmds = append(cmds, cmd)
396 cmds = append(cmds, cmd)
388 }
397 }
398+ if m.com.IsHyper() {
399+ cmds = append(cmds, m.fetchHyperCredits())
400+ }
389 return tea.Batch(cmds...)
401 return tea.Batch(cmds...)
390}
402}
391
403
@@ -854,6 +866,8 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
854 if cmd := m.handleSelectModel(msg.action); cmd != nil {
866 if cmd := m.handleSelectModel(msg.action); cmd != nil {
855 cmds = append(cmds, cmd)
867 cmds = append(cmds, cmd)
856 }
868 }
869+ case creditsUpdatedMsg:
870+ m.hyperCredits = &msg.credits
857 case util.InfoMsg:
871 case util.InfoMsg:
858 if msg.Type == util.InfoTypeError {
872 if msg.Type == util.InfoTypeError {
859 slog.Error("Error reported", "error", msg.Msg)
873 slog.Error("Error reported", "error", msg.Msg)
@@ -1575,6 +1589,33 @@ func (m *UI) refreshHyperAndRetrySelect(msg dialog.ActionSelectModel) tea.Cmd {
1575 }
1589 }
1576}
1590}
1577
1591
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+
1578// handleSelectModel performs the model selection after any provider
1619// handleSelectModel performs the model selection after any provider
1579// pre-checks (such as a silent Hyper OAuth refresh) have completed.
1620// pre-checks (such as a silent Hyper OAuth refresh) have completed.
1580func (m *UI) handleSelectModel(msg dialog.ActionSelectModel) tea.Cmd {
1621func (m *UI) handleSelectModel(msg dialog.ActionSelectModel) tea.Cmd {
@@ -1656,6 +1697,8 @@ func (m *UI) handleSelectModel(msg dialog.ActionSelectModel) tea.Cmd {
1656 if err := m.com.Workspace.InitCoderAgent(context.TODO()); err != nil {
1697 if err := m.com.Workspace.InitCoderAgent(context.TODO()); err != nil {
1657 cmds = append(cmds, util.ReportError(err))
1698 cmds = append(cmds, util.ReportError(err))
1658 }
1699 }
1700+ } else if m.com.IsHyper() {
1701+ cmds = append(cmds, m.fetchHyperCredits())
1659 }
1702 }
1660
1703
1661 return tea.Batch(cmds...)
1704 return tea.Batch(cmds...)
@@ -3352,10 +3395,15 @@ func (m *UI) handlePermissionNotification(notification permission.PermissionNoti
3352func (m *UI) handleAgentNotification(n notify.Notification) tea.Cmd {
3395func (m *UI) handleAgentNotification(n notify.Notification) tea.Cmd {
3353 switch n.Type {
3396 switch n.Type {
3354 case notify.TypeAgentFinished:
3397 case notify.TypeAgentFinished:
3398+ var cmds []tea.Cmd
3355− return m.sendNotification(notification.Notification{
3399+ cmds = append(cmds, m.sendNotification(notification.Notification{
3356 Title: "Crush is waiting...",
3400 Title: "Crush is waiting...",
3357 Message: fmt.Sprintf("Agent's turn completed in \"%s\"", n.SessionTitle),
3401 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...)
3359 case notify.TypeReAuthenticate:
3407 case notify.TypeReAuthenticate:
3360 return m.handleReAuthenticate(n.ProviderID)
3408 return m.handleReAuthenticate(n.ProviderID)
3361 default:
3409 default:
internal/ui/styles/quickstyle.go+2
@@ -763,6 +763,8 @@ func quickStyle(o quickStyleOpts) Styles {
763 s.ModelInfo.TokenCount = lipgloss.NewStyle().Foreground(o.fgMostSubtle)
763 s.ModelInfo.TokenCount = lipgloss.NewStyle().Foreground(o.fgMostSubtle)
764 s.ModelInfo.TokenPercentage = lipgloss.NewStyle().Foreground(o.fgMoreSubtle)
764 s.ModelInfo.TokenPercentage = lipgloss.NewStyle().Foreground(o.fgMoreSubtle)
765 s.ModelInfo.Cost = lipgloss.NewStyle().Foreground(o.fgMoreSubtle)
765 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)
766
768
767 // ResourceGroup
769 // ResourceGroup
768 s.Resource.DefaultTitleFg = o.fgMoreSubtle
770 s.Resource.DefaultTitleFg = o.fgMoreSubtle
internal/ui/styles/styles.go+7−4
@@ -17,10 +17,11 @@ import (
17)
17)
18
18
19const (
19const (
20− CheckIcon string = "✓"
20+ CheckIcon string = "✓"
21− SpinnerIcon string = "⋯"
21+ SpinnerIcon string = "⋯"
22− LoadingIcon string = "⟳"
22+ LoadingIcon string = "⟳"
23− ModelIcon string = "◇"
23+ ModelIcon string = "◇"
24+ HypercreditIcon string = "◆"
24
25
25 ArrowRightIcon string = "→"
26 ArrowRightIcon string = "→"
26
27
@@ -189,6 +190,8 @@ type Styles struct {
189 TokenCount lipgloss.Style // "(42K)" token count
190 TokenCount lipgloss.Style // "(42K)" token count
190 TokenPercentage lipgloss.Style // "42%" percent of context window
191 TokenPercentage lipgloss.Style // "42%" percent of context window
191 Cost lipgloss.Style // "$0.42" cost readout
192 Cost lipgloss.Style // "$0.42" cost readout
193+ HypercreditIcon lipgloss.Style // Hypercredit icon (◆)
194+ HypercreditText lipgloss.Style // Remaining Hypercredits text
192 }
195 }
193
196
194 // Resource styles the LSP/MCP/skills sidebar lists: their heading,
197 // Resource styles the LSP/MCP/skills sidebar lists: their heading,