1package common
2
3import (
4 "cmp"
5 "fmt"
6 "image/color"
7 "strconv"
8 "strings"
9
10 "charm.land/lipgloss/v2"
11 "github.com/charmbracelet/crush/internal/agent/hyper"
12 "github.com/charmbracelet/crush/internal/home"
13 "github.com/charmbracelet/crush/internal/ui/styles"
14 "github.com/charmbracelet/x/ansi"
15 "golang.org/x/text/cases"
16 "golang.org/x/text/language"
17)
18
19// PrettyPath formats a file path with home directory shortening and applies
20// muted styling.
21func PrettyPath(t *styles.Styles, path string, width int) string {
22 formatted := home.Short(path)
23 return t.Sidebar.WorkingDir.Width(width).Render(formatted)
24}
25
26// FormatReasoningEffort formats a reasoning effort level for display.
27func FormatReasoningEffort(effort string) string {
28 if effort == "xhigh" {
29 return "X-High"
30 }
31 return cases.Title(language.English).String(effort)
32}
33
34// ModelContextInfo contains token usage and cost information for a model.
35type ModelContextInfo struct {
36 ContextUsed int64
37 ModelContext int64
38 Cost float64
39 EstimatedUsage bool
40}
41
42// ModelInfo renders model information including name, provider, reasoning
43// settings, and optional context usage/cost.
44func ModelInfo(t *styles.Styles, modelName, providerName, reasoningInfo string, context *ModelContextInfo, width int, hyperCredits *int) string {
45 modelIcon := t.ModelInfo.Icon.Render(styles.ModelIcon)
46 modelName = t.ModelInfo.Name.Render(modelName)
47
48 // Build first line with model name and optionally provider on the same line
49 var firstLine string
50 if providerName != "" {
51 providerInfo := t.ModelInfo.Provider.Render(fmt.Sprintf("via %s", providerName))
52 modelWithProvider := fmt.Sprintf("%s %s %s", modelIcon, modelName, providerInfo)
53
54 // Check if it fits on one line
55 if lipgloss.Width(modelWithProvider) <= width {
56 firstLine = modelWithProvider
57 } else {
58 // If it doesn't fit, put provider on next line
59 firstLine = fmt.Sprintf("%s %s", modelIcon, modelName)
60 }
61 } else {
62 firstLine = fmt.Sprintf("%s %s", modelIcon, modelName)
63 }
64
65 parts := []string{firstLine}
66
67 // If provider didn't fit on first line, add it as second line
68 if providerName != "" && !strings.Contains(firstLine, "via") {
69 providerInfo := fmt.Sprintf("via %s", providerName)
70 parts = append(parts, t.ModelInfo.ProviderFallback.Render(providerInfo))
71 }
72
73 if reasoningInfo != "" {
74 parts = append(parts, t.ModelInfo.Reasoning.Render(reasoningInfo))
75 }
76
77 if context != nil {
78 formattedInfo := formatTokensAndCost(t, context.ContextUsed, context.ModelContext, context.Cost, context.EstimatedUsage)
79 parts = append(parts, lipgloss.NewStyle().PaddingLeft(2).Render(formattedInfo))
80 }
81
82 if providerName == hyper.DisplayName && hyperCredits != nil {
83 hcInfo := t.ModelInfo.HypercreditIcon.Render(styles.HypercreditIcon)
84 hcInfo += " "
85 hcInfo += t.ModelInfo.HypercreditText.Render(fmt.Sprintf("%s Hypercredits", FormatCredits(*hyperCredits)))
86 parts = append(parts, "", hcInfo)
87 }
88
89 return lipgloss.NewStyle().Width(width).Render(
90 lipgloss.JoinVertical(lipgloss.Left, parts...),
91 )
92}
93
94// formatTokensAndCost formats token usage and cost with appropriate units
95// (K/M) and percentage of context window.
96func formatTokensAndCost(t *styles.Styles, tokens, contextWindow int64, cost float64, estimated bool) string {
97 var formattedTokens string
98 switch {
99 case tokens >= 1_000_000:
100 formattedTokens = fmt.Sprintf("%.1fM", float64(tokens)/1_000_000)
101 case tokens >= 1_000:
102 formattedTokens = fmt.Sprintf("%.1fK", float64(tokens)/1_000)
103 default:
104 formattedTokens = fmt.Sprintf("%d", tokens)
105 }
106
107 if strings.HasSuffix(formattedTokens, ".0K") {
108 formattedTokens = strings.Replace(formattedTokens, ".0K", "K", 1)
109 }
110 if strings.HasSuffix(formattedTokens, ".0M") {
111 formattedTokens = strings.Replace(formattedTokens, ".0M", "M", 1)
112 }
113
114 var percentage float64
115 if contextWindow > 0 {
116 percentage = (float64(tokens) / float64(contextWindow)) * 100
117 }
118
119 formattedCost := t.ModelInfo.Cost.Render(fmt.Sprintf("$%.2f", cost))
120
121 formattedTokens = t.ModelInfo.TokenCount.Render(fmt.Sprintf("(%s)", formattedTokens))
122 percentageText := fmt.Sprintf("%d%%", int(percentage))
123 if estimated {
124 percentageText = "~" + percentageText
125 }
126 formattedPercentage := t.ModelInfo.TokenPercentage.Render(percentageText)
127 formattedTokens = fmt.Sprintf("%s %s", formattedPercentage, formattedTokens)
128 if percentage > 80 {
129 formattedTokens = fmt.Sprintf("%s %s", styles.LSPWarningIcon, formattedTokens)
130 }
131
132 return fmt.Sprintf("%s %s", formattedTokens, formattedCost)
133}
134
135// FormatCredits formats an integer with comma separators for thousands.
136func FormatCredits(n int) string {
137 s := strconv.FormatInt(int64(n), 10)
138 if n < 1000 {
139 return s
140 }
141 // Calculate how many digits before the first comma.
142 firstGroup := len(s) % 3
143 if firstGroup == 0 {
144 firstGroup = 3
145 }
146 var b []byte
147 for i := 0; i < len(s); i++ {
148 if i > 0 && i == firstGroup {
149 b = append(b, ',')
150 firstGroup += 3
151 }
152 b = append(b, s[i])
153 }
154 return string(b)
155}
156
157// StatusOpts defines options for rendering a status line with icon, title,
158// description, and optional extra content.
159type StatusOpts struct {
160 Icon string // if empty no icon will be shown
161 Title string
162 TitleColor color.Color
163 Description string
164 DescriptionColor color.Color
165 ExtraContent string // additional content to append after the description
166}
167
168// Status renders a status line with icon, title, description, and extra
169// content. The description is truncated if it exceeds the available width.
170func Status(t *styles.Styles, opts StatusOpts, width int) string {
171 icon := opts.Icon
172 title := opts.Title
173 description := opts.Description
174
175 titleColor := cmp.Or(opts.TitleColor, t.Resource.DefaultTitleFg)
176 descriptionColor := cmp.Or(opts.DescriptionColor, t.Resource.DefaultDescFg)
177
178 title = t.Resource.RowTitleBase.Foreground(titleColor).Render(title)
179
180 if description != "" {
181 extraContentWidth := lipgloss.Width(opts.ExtraContent)
182 if extraContentWidth > 0 {
183 extraContentWidth += 1
184 }
185 description = ansi.Truncate(description, width-lipgloss.Width(icon)-lipgloss.Width(title)-2-extraContentWidth, "…")
186 description = t.Resource.RowDescBase.Foreground(descriptionColor).Render(description)
187 }
188
189 var content []string
190 if icon != "" {
191 content = append(content, icon)
192 }
193 content = append(content, title)
194 if description != "" {
195 content = append(content, description)
196 }
197 if opts.ExtraContent != "" {
198 content = append(content, opts.ExtraContent)
199 }
200
201 return strings.Join(content, " ")
202}
203
204// Section renders a section header with a title and a horizontal line filling
205// the remaining width.
206func Section(t *styles.Styles, text string, width int, info ...string) string {
207 char := styles.SectionSeparator
208 length := lipgloss.Width(text) + 1
209 remainingWidth := width - length
210
211 var infoText string
212 if len(info) > 0 {
213 infoText = strings.Join(info, " ")
214 if len(infoText) > 0 {
215 infoText = " " + infoText
216 remainingWidth -= lipgloss.Width(infoText)
217 }
218 }
219
220 text = t.Section.Title.Render(text)
221 if remainingWidth > 0 {
222 text = text + " " + t.Section.Line.Render(strings.Repeat(char, remainingWidth)) + infoText
223 }
224 return text
225}
226
227// DialogTitle renders a dialog title with a decorative line filling the
228// remaining width.
229func DialogTitle(t *styles.Styles, title string, width int, fromColor, toColor color.Color) string {
230 char := "╱"
231 length := lipgloss.Width(title) + 1
232 remainingWidth := width - length
233 if remainingWidth > 0 {
234 lines := strings.Repeat(char, remainingWidth)
235 lines = styles.ApplyForegroundGrad(t.Dialog.TitleLineBase, lines, fromColor, toColor)
236 title = title + " " + lines
237 }
238 return title
239}