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