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