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