elements.go

  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}