elements.go

  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}