on main help sign in
fix(ui): mark estimated context usage
2736e48Greg Slepak
internal/agent/agent.go+5
@@ -762,6 +762,7 @@ func (a *sessionAgent) Summarize(ctx context.Context, sessionID string, opts fan
762 currentSession.SummaryMessageID = summaryMessage.ID
762 currentSession.SummaryMessageID = summaryMessage.ID
763 currentSession.CompletionTokens = summaryCompletionTokens(usage, summaryMessage)
763 currentSession.CompletionTokens = summaryCompletionTokens(usage, summaryMessage)
764 currentSession.PromptTokens = 0
764 currentSession.PromptTokens = 0
765+ currentSession.EstimatedUsage = usageIsZero(usage)
765 _, err = a.sessions.Save(genCtx, currentSession)
766 _, err = a.sessions.Save(genCtx, currentSession)
766 if err != nil {
767 if err != nil {
767 return err
768 return err
@@ -1139,6 +1140,10 @@ func (a *sessionAgent) openrouterCost(metadata fantasy.ProviderMetadata) *float6
1139}
1140}
1140
1141
1141func (a *sessionAgent) updateSessionUsage(model Model, session *session.Session, usage fantasy.Usage, overrideCost *float64, estimated bool) {
1142func (a *sessionAgent) updateSessionUsage(model Model, session *session.Session, usage fantasy.Usage, overrideCost *float64, estimated bool) {
1143+ if !usageIsZero(usage) {
1144+ session.EstimatedUsage = estimated
1145+ }
1146+
1142 modelConfig := model.CatwalkCfg
1147 modelConfig := model.CatwalkCfg
1143 cost := modelConfig.CostPer1MInCached/1e6*float64(usage.CacheCreationTokens) +
1148 cost := modelConfig.CostPer1MInCached/1e6*float64(usage.CacheCreationTokens) +
1144 modelConfig.CostPer1MOutCached/1e6*float64(usage.CacheReadTokens) +
1149 modelConfig.CostPer1MOutCached/1e6*float64(usage.CacheReadTokens) +
internal/agent/usage_fallback_test.go+2
@@ -212,6 +212,7 @@ func TestUpdateSessionUsageSkipsEstimatedCost(t *testing.T) {
212 require.Equal(t, 1.25, currentSession.Cost)
212 require.Equal(t, 1.25, currentSession.Cost)
213 require.Equal(t, int64(1000), currentSession.PromptTokens)
213 require.Equal(t, int64(1000), currentSession.PromptTokens)
214 require.Equal(t, int64(2000), currentSession.CompletionTokens)
214 require.Equal(t, int64(2000), currentSession.CompletionTokens)
215+ require.True(t, currentSession.EstimatedUsage)
215}
216}
216
217
217func TestUpdateSessionUsageKeepsCountersForZeroUsage(t *testing.T) {
218func TestUpdateSessionUsageKeepsCountersForZeroUsage(t *testing.T) {
@@ -334,4 +335,5 @@ func TestUpdateSessionUsageAddsProviderCost(t *testing.T) {
334 require.Equal(t, 1.3, currentSession.Cost)
335 require.Equal(t, 1.3, currentSession.Cost)
335 require.Equal(t, int64(1000), currentSession.PromptTokens)
336 require.Equal(t, int64(1000), currentSession.PromptTokens)
336 require.Equal(t, int64(2000), currentSession.CompletionTokens)
337 require.Equal(t, int64(2000), currentSession.CompletionTokens)
338+ require.False(t, currentSession.EstimatedUsage)
337}
339}
internal/session/session.go+3
@@ -53,6 +53,7 @@ type Session struct {
53 MessageCount int64
53 MessageCount int64
54 PromptTokens int64
54 PromptTokens int64
55 CompletionTokens int64
55 CompletionTokens int64
56+ EstimatedUsage bool
56 SummaryMessageID string
57 SummaryMessageID string
57 Cost float64
58 Cost float64
58 Todos []Todo
59 Todos []Todo
@@ -199,7 +200,9 @@ func (s *service) Save(ctx context.Context, session Session) (Session, error) {
199 if err != nil {
200 if err != nil {
200 return Session{}, err
201 return Session{}, err
201 }
202 }
203+ estimatedUsage := session.EstimatedUsage
202 session = s.fromDBItem(dbSession)
204 session = s.fromDBItem(dbSession)
205+ session.EstimatedUsage = estimatedUsage
203 s.Publish(pubsub.UpdatedEvent, session)
206 s.Publish(pubsub.UpdatedEvent, session)
204 return session, nil
207 return session, nil
205}
208}
internal/ui/common/elements.go+11−6
@@ -33,9 +33,10 @@ func FormatReasoningEffort(effort string) string {
33
33
34// ModelContextInfo contains token usage and cost information for a model.
34// ModelContextInfo contains token usage and cost information for a model.
35type ModelContextInfo struct {
35type ModelContextInfo struct {
36− ContextUsed int64
36+ ContextUsed int64
37− ModelContext int64
37+ ModelContext int64
38− Cost float64
38+ Cost float64
39+ EstimatedUsage bool
39}
40}
40
41
41// ModelInfo renders model information including name, provider, reasoning
42// ModelInfo renders model information including name, provider, reasoning
@@ -74,7 +75,7 @@ func ModelInfo(t *styles.Styles, modelName, providerName, reasoningInfo string,
74 }
75 }
75
76
76 if context != nil {
77 if context != nil {
77− formattedInfo := formatTokensAndCost(t, context.ContextUsed, context.ModelContext, context.Cost)
78+ formattedInfo := formatTokensAndCost(t, context.ContextUsed, context.ModelContext, context.Cost, context.EstimatedUsage)
78 parts = append(parts, lipgloss.NewStyle().PaddingLeft(2).Render(formattedInfo))
79 parts = append(parts, lipgloss.NewStyle().PaddingLeft(2).Render(formattedInfo))
79 }
80 }
80
81
@@ -92,7 +93,7 @@ func ModelInfo(t *styles.Styles, modelName, providerName, reasoningInfo string,
92
93
93// formatTokensAndCost formats token usage and cost with appropriate units
94// formatTokensAndCost formats token usage and cost with appropriate units
94// (K/M) and percentage of context window.
95// (K/M) and percentage of context window.
95−func formatTokensAndCost(t *styles.Styles, tokens, contextWindow int64, cost float64) string {
96+func formatTokensAndCost(t *styles.Styles, tokens, contextWindow int64, cost float64, estimated bool) string {
96 var formattedTokens string
97 var formattedTokens string
97 switch {
98 switch {
98 case tokens >= 1_000_000:
99 case tokens >= 1_000_000:
@@ -115,7 +116,11 @@ func formatTokensAndCost(t *styles.Styles, tokens, contextWindow int64, cost flo
115 formattedCost := t.ModelInfo.Cost.Render(fmt.Sprintf("$%.2f", cost))
116 formattedCost := t.ModelInfo.Cost.Render(fmt.Sprintf("$%.2f", cost))
116
117
117 formattedTokens = t.ModelInfo.TokenCount.Render(fmt.Sprintf("(%s)", formattedTokens))
118 formattedTokens = t.ModelInfo.TokenCount.Render(fmt.Sprintf("(%s)", formattedTokens))
118− formattedPercentage := t.ModelInfo.TokenPercentage.Render(fmt.Sprintf("%d%%", int(percentage)))
119+ percentageText := fmt.Sprintf("%d%%", int(percentage))
120+ if estimated {
121+ percentageText = t.ModelInfo.EstimatedUsagePrefix.Render("~") + percentageText
122+ }
123+ formattedPercentage := t.ModelInfo.TokenPercentage.Render(percentageText)
119 formattedTokens = fmt.Sprintf("%s %s", formattedPercentage, formattedTokens)
124 formattedTokens = fmt.Sprintf("%s %s", formattedPercentage, formattedTokens)
120 if percentage > 80 {
125 if percentage > 80 {
121 formattedTokens = fmt.Sprintf("%s %s", styles.LSPWarningIcon, formattedTokens)
126 formattedTokens = fmt.Sprintf("%s %s", styles.LSPWarningIcon, formattedTokens)
internal/ui/common/elements_test.gonew file+32
@@ -0,0 +1,32 @@
1+package common
2+
3+import (
4+ "testing"
5+
6+ "github.com/charmbracelet/crush/internal/ui/styles"
7+ "github.com/charmbracelet/x/ansi"
8+ "github.com/stretchr/testify/require"
9+)
10+
11+func TestFormatTokensAndCostPrefixesEstimatedUsage(t *testing.T) {
12+ t.Parallel()
13+
14+ sty := styles.CharmtonePantera()
15+
16+ actual := ansi.Strip(formatTokensAndCost(&sty, 120, 1000, 0, true))
17+
18+ require.Contains(t, actual, "~12%")
19+ require.Contains(t, actual, "(120)")
20+ require.Contains(t, actual, "$0.00")
21+}
22+
23+func TestFormatTokensAndCostOmitsEstimatedPrefix(t *testing.T) {
24+ t.Parallel()
25+
26+ sty := styles.CharmtonePantera()
27+
28+ actual := ansi.Strip(formatTokensAndCost(&sty, 120, 1000, 0, false))
29+
30+ require.Contains(t, actual, "12%")
31+ require.NotContains(t, actual, "~12%")
32+}
internal/ui/model/header.go+5−1
@@ -148,7 +148,11 @@ func renderHeaderDetails(
148 model := com.Config().GetModelByType(agentCfg.Model)
148 model := com.Config().GetModelByType(agentCfg.Model)
149 if model != nil && model.ContextWindow > 0 {
149 if model != nil && model.ContextWindow > 0 {
150 percentage := (float64(session.CompletionTokens+session.PromptTokens) / float64(model.ContextWindow)) * 100
150 percentage := (float64(session.CompletionTokens+session.PromptTokens) / float64(model.ContextWindow)) * 100
151− formattedPercentage := t.Header.Percentage.Render(fmt.Sprintf("%d%%", int(percentage)))
151+ percentageText := fmt.Sprintf("%d%%", int(percentage))
152+ if session.EstimatedUsage {
153+ percentageText = t.ModelInfo.EstimatedUsagePrefix.Render("~") + percentageText
154+ }
155+ formattedPercentage := t.Header.Percentage.Render(percentageText)
152 parts = append(parts, formattedPercentage)
156 parts = append(parts, formattedPercentage)
153 }
157 }
154
158
internal/ui/model/sidebar.go+4−3
@@ -44,9 +44,10 @@ func (m *UI) modelInfo(width int) string {
44 var modelContext *common.ModelContextInfo
44 var modelContext *common.ModelContextInfo
45 if model != nil && m.session != nil {
45 if model != nil && m.session != nil {
46 modelContext = &common.ModelContextInfo{
46 modelContext = &common.ModelContextInfo{
47− ContextUsed: m.session.CompletionTokens + m.session.PromptTokens,
47+ ContextUsed: m.session.CompletionTokens + m.session.PromptTokens,
48− Cost: m.session.Cost,
48+ Cost: m.session.Cost,
49− ModelContext: model.CatwalkCfg.ContextWindow,
49+ ModelContext: model.CatwalkCfg.ContextWindow,
50+ EstimatedUsage: m.session.EstimatedUsage,
50 }
51 }
51 }
52 }
52 var modelName string
53 var modelName string
internal/ui/styles/quickstyle.go+1
@@ -763,6 +763,7 @@ func quickStyle(o quickStyleOpts) Styles {
763 s.ModelInfo.Reasoning = lipgloss.NewStyle().Foreground(o.fgMostSubtle).PaddingLeft(2)
763 s.ModelInfo.Reasoning = lipgloss.NewStyle().Foreground(o.fgMostSubtle).PaddingLeft(2)
764 s.ModelInfo.TokenCount = lipgloss.NewStyle().Foreground(o.fgMostSubtle)
764 s.ModelInfo.TokenCount = lipgloss.NewStyle().Foreground(o.fgMostSubtle)
765 s.ModelInfo.TokenPercentage = lipgloss.NewStyle().Foreground(o.fgMoreSubtle)
765 s.ModelInfo.TokenPercentage = lipgloss.NewStyle().Foreground(o.fgMoreSubtle)
766+ s.ModelInfo.EstimatedUsagePrefix = lipgloss.NewStyle().Foreground(o.fgBase)
766 s.ModelInfo.Cost = lipgloss.NewStyle().Foreground(o.fgMoreSubtle)
767 s.ModelInfo.Cost = lipgloss.NewStyle().Foreground(o.fgMoreSubtle)
767 s.ModelInfo.HypercreditIcon = lipgloss.NewStyle().Foreground(charmtone.Dolly)
768 s.ModelInfo.HypercreditIcon = lipgloss.NewStyle().Foreground(charmtone.Dolly)
768 s.ModelInfo.HypercreditText = lipgloss.NewStyle().Foreground(o.fgMoreSubtle)
769 s.ModelInfo.HypercreditText = lipgloss.NewStyle().Foreground(o.fgMoreSubtle)
internal/ui/styles/styles.go+11−10
@@ -183,16 +183,17 @@ type Styles struct {
183
183
184 // ModelInfo (model name, provider, reasoning, token/cost summary)
184 // ModelInfo (model name, provider, reasoning, token/cost summary)
185 ModelInfo struct {
185 ModelInfo struct {
186− Icon lipgloss.Style // Model icon (◇)
186+ Icon lipgloss.Style // Model icon (◇)
187− Name lipgloss.Style // Model name text
187+ Name lipgloss.Style // Model name text
188− Provider lipgloss.Style // "via <provider>" text
188+ Provider lipgloss.Style // "via <provider>" text
189− ProviderFallback lipgloss.Style // Provider on its own second line
189+ ProviderFallback lipgloss.Style // Provider on its own second line
190− Reasoning lipgloss.Style // Reasoning effort text
190+ Reasoning lipgloss.Style // Reasoning effort text
191− TokenCount lipgloss.Style // "(42K)" token count
191+ TokenCount lipgloss.Style // "(42K)" token count
192− TokenPercentage lipgloss.Style // "42%" percent of context window
192+ TokenPercentage lipgloss.Style // "42%" percent of context window
193+ EstimatedUsagePrefix lipgloss.Style // "~" prefix for estimated usage
193− Cost lipgloss.Style // "$0.42" cost readout
194+ Cost lipgloss.Style // "$0.42" cost readout
194− HypercreditIcon lipgloss.Style // Hypercredit icon (◆)
195+ HypercreditIcon lipgloss.Style // Hypercredit icon (◆)
195− HypercreditText lipgloss.Style // Remaining Hypercredits text
196+ HypercreditText lipgloss.Style // Remaining Hypercredits text
196 }
197 }
197
198
198 // Resource styles the LSP/MCP/skills sidebar lists: their heading,
199 // Resource styles the LSP/MCP/skills sidebar lists: their heading,