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