elements.go 6.0 KB

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