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	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}