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 percentage := (float64(tokens) / float64(contextWindow)) * 100
115
116 formattedCost := t.ModelInfo.Cost.Render(fmt.Sprintf("$%.2f", cost))
117
118 formattedTokens = t.ModelInfo.TokenCount.Render(fmt.Sprintf("(%s)", formattedTokens))
119 percentageText := fmt.Sprintf("%d%%", int(percentage))
120 if estimated {
121 percentageText = "~" + percentageText
122 }
123 formattedPercentage := t.ModelInfo.TokenPercentage.Render(percentageText)
124 formattedTokens = fmt.Sprintf("%s %s", formattedPercentage, formattedTokens)
125 if percentage > 80 {
126 formattedTokens = fmt.Sprintf("%s %s", styles.LSPWarningIcon, formattedTokens)
127 }
128
129 return fmt.Sprintf("%s %s", formattedTokens, formattedCost)
130}
131
132// FormatCredits formats an integer with comma separators for thousands.
133func FormatCredits(n int) string {
134 s := strconv.FormatInt(int64(n), 10)
135 if n < 1000 {
136 return s
137 }
138 // Calculate how many digits before the first comma.
139 firstGroup := len(s) % 3
140 if firstGroup == 0 {
141 firstGroup = 3
142 }
143 var b []byte
144 for i := 0; i < len(s); i++ {
145 if i > 0 && i == firstGroup {
146 b = append(b, ',')
147 firstGroup += 3
148 }
149 b = append(b, s[i])
150 }
151 return string(b)
152}
153
154// StatusOpts defines options for rendering a status line with icon, title,
155// description, and optional extra content.
156type StatusOpts struct {
157 Icon string // if empty no icon will be shown
158 Title string
159 TitleColor color.Color
160 Description string
161 DescriptionColor color.Color
162 ExtraContent string // additional content to append after the description
163}
164
165// Status renders a status line with icon, title, description, and extra
166// content. The description is truncated if it exceeds the available width.
167func Status(t *styles.Styles, opts StatusOpts, width int) string {
168 icon := opts.Icon
169 title := opts.Title
170 description := opts.Description
171
172 titleColor := cmp.Or(opts.TitleColor, t.Resource.DefaultTitleFg)
173 descriptionColor := cmp.Or(opts.DescriptionColor, t.Resource.DefaultDescFg)
174
175 title = t.Resource.RowTitleBase.Foreground(titleColor).Render(title)
176
177 if description != "" {
178 extraContentWidth := lipgloss.Width(opts.ExtraContent)
179 if extraContentWidth > 0 {
180 extraContentWidth += 1
181 }
182 description = ansi.Truncate(description, width-lipgloss.Width(icon)-lipgloss.Width(title)-2-extraContentWidth, "…")
183 description = t.Resource.RowDescBase.Foreground(descriptionColor).Render(description)
184 }
185
186 var content []string
187 if icon != "" {
188 content = append(content, icon)
189 }
190 content = append(content, title)
191 if description != "" {
192 content = append(content, description)
193 }
194 if opts.ExtraContent != "" {
195 content = append(content, opts.ExtraContent)
196 }
197
198 return strings.Join(content, " ")
199}
200
201// Section renders a section header with a title and a horizontal line filling
202// the remaining width.
203func Section(t *styles.Styles, text string, width int, info ...string) string {
204 char := styles.SectionSeparator
205 length := lipgloss.Width(text) + 1
206 remainingWidth := width - length
207
208 var infoText string
209 if len(info) > 0 {
210 infoText = strings.Join(info, " ")
211 if len(infoText) > 0 {
212 infoText = " " + infoText
213 remainingWidth -= lipgloss.Width(infoText)
214 }
215 }
216
217 text = t.Section.Title.Render(text)
218 if remainingWidth > 0 {
219 text = text + " " + t.Section.Line.Render(strings.Repeat(char, remainingWidth)) + infoText
220 }
221 return text
222}
223
224// DialogTitle renders a dialog title with a decorative line filling the
225// remaining width.
226func DialogTitle(t *styles.Styles, title string, width int, fromColor, toColor color.Color) string {
227 char := "╱"
228 length := lipgloss.Width(title) + 1
229 remainingWidth := width - length
230 if remainingWidth > 0 {
231 lines := strings.Repeat(char, remainingWidth)
232 lines = styles.ApplyForegroundGrad(t.Dialog.TitleLineBase, lines, fromColor, toColor)
233 title = title + " " + lines
234 }
235 return title
236}