elements.go 6.3 KB

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