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