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