feat(hyper): show remaining hypercredits in the sidebar (#2766)

Andrey Nering created

Change summary

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(-)

Detailed changes

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
+}

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 {

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

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:

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

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,