1package model
2
3import (
4 "fmt"
5 "strings"
6
7 "charm.land/lipgloss/v2"
8 "github.com/charmbracelet/crush/internal/config"
9 "github.com/charmbracelet/crush/internal/fsext"
10 "github.com/charmbracelet/crush/internal/session"
11 "github.com/charmbracelet/crush/internal/ui/common"
12 "github.com/charmbracelet/crush/internal/ui/styles"
13 uv "github.com/charmbracelet/ultraviolet"
14 "github.com/charmbracelet/x/ansi"
15)
16
17const (
18 headerDiag = "β±"
19 minHeaderDiags = 3
20 leftPadding = 1
21 rightPadding = 1
22 diagToDetailsSpacing = 1 // space between diagonal pattern and details section
23)
24
25type header struct {
26 // cached logo and compact logo
27 logo string
28 compactLogo string
29
30 com *common.Common
31 width int
32 compact bool
33}
34
35// newHeader creates a new header model.
36func newHeader(com *common.Common) *header {
37 h := &header{
38 com: com,
39 }
40 h.refresh()
41 return h
42}
43
44// refresh rebuilds cached logo strings using the current styles. Call
45// after the theme changes.
46func (h *header) refresh() {
47 t := h.com.Styles
48 isHyper := h.com.IsHyper()
49 charm := "Charmβ’"
50 if !isHyper {
51 charm = " " + charm
52 }
53 name := "CRUSH"
54 if isHyper {
55 name = "HYPERCRUSH"
56 }
57 h.compactLogo = t.Header.Charm.Render(charm) + " " +
58 styles.ApplyBoldForegroundGrad(t.Header.LogoGradCanvas, name, t.Header.LogoGradFromColor, t.Header.LogoGradToColor) + " "
59 // Force drawHeader to re-render the wide logo on the next frame.
60 h.width = 0
61 h.logo = ""
62}
63
64// drawHeader draws the header for the given session.
65func (h *header) drawHeader(
66 scr uv.Screen,
67 area uv.Rectangle,
68 session *session.Session,
69 compact bool,
70 detailsOpen bool,
71 width int,
72 hyperCredits *int,
73) {
74 t := h.com.Styles
75 if width != h.width || compact != h.compact {
76 h.logo = renderLogo(h.com.Styles, compact, h.com.IsHyper(), width)
77 }
78
79 h.width = width
80 h.compact = compact
81
82 if !compact || session == nil {
83 uv.NewStyledString(h.logo).Draw(scr, area)
84 return
85 }
86
87 if session.ID == "" {
88 return
89 }
90
91 var b strings.Builder
92 b.WriteString(h.compactLogo)
93
94 availDetailWidth := width - leftPadding - rightPadding - lipgloss.Width(b.String()) - minHeaderDiags - diagToDetailsSpacing
95 lspErrorCount := 0
96 for _, info := range h.com.Workspace.LSPGetStates() {
97 lspErrorCount += info.DiagnosticCount
98 }
99 details := renderHeaderDetails(
100 h.com,
101 session,
102 lspErrorCount,
103 detailsOpen,
104 availDetailWidth,
105 hyperCredits,
106 )
107
108 remainingWidth := width -
109 lipgloss.Width(b.String()) -
110 lipgloss.Width(details) -
111 leftPadding -
112 rightPadding -
113 diagToDetailsSpacing
114
115 if remainingWidth > 0 {
116 b.WriteString(t.Header.Diagonals.Render(
117 strings.Repeat(headerDiag, max(minHeaderDiags, remainingWidth)),
118 ))
119 b.WriteString(" ")
120 }
121
122 b.WriteString(details)
123
124 view := uv.NewStyledString(
125 t.Header.Wrapper.Padding(0, rightPadding, 0, leftPadding).Render(b.String()),
126 )
127 view.Draw(scr, area)
128}
129
130// renderHeaderDetails renders the details section of the header.
131func renderHeaderDetails(
132 com *common.Common,
133 session *session.Session,
134 lspErrorCount int,
135 detailsOpen bool,
136 availWidth int,
137 hyperCredits *int,
138) string {
139 t := com.Styles
140
141 var parts []string
142
143 if lspErrorCount > 0 {
144 parts = append(parts, t.LSP.ErrorDiagnostic.Render(fmt.Sprintf("%s%d", styles.LSPErrorIcon, lspErrorCount)))
145 }
146
147 agentCfg := com.Config().Agents[config.AgentCoder]
148 model := com.Config().GetModelByType(agentCfg.Model)
149 if model != nil && model.ContextWindow > 0 {
150 percentage := (float64(session.CompletionTokens+session.PromptTokens) / float64(model.ContextWindow)) * 100
151 percentageText := fmt.Sprintf("%d%%", int(percentage))
152 if session.EstimatedUsage {
153 percentageText = "~" + percentageText
154 }
155 formattedPercentage := t.Header.Percentage.Render(percentageText)
156 parts = append(parts, formattedPercentage)
157 }
158
159 if com.IsHyper() && hyperCredits != nil {
160 hc := t.Header.Hypercredit.Render(styles.HypercreditIcon) + " " + t.Header.Percentage.Render(common.FormatCredits(*hyperCredits))
161 parts = append(parts, hc)
162 }
163
164 const keystroke = "ctrl+d"
165 if detailsOpen {
166 parts = append(parts, t.Header.Keystroke.Render(keystroke)+t.Header.KeystrokeTip.Render(" close"))
167 } else {
168 parts = append(parts, t.Header.Keystroke.Render(keystroke)+t.Header.KeystrokeTip.Render(" open "))
169 }
170
171 dot := t.Header.Separator.Render(" β’ ")
172 metadata := strings.Join(parts, dot)
173 metadata = dot + metadata
174
175 const dirTrimLimit = 4
176 cwd := fsext.DirTrim(fsext.PrettyPath(com.Workspace.WorkingDir()), dirTrimLimit)
177 cwd = t.Header.WorkingDir.Render(cwd)
178
179 result := cwd + metadata
180 return ansi.Truncate(result, max(0, availWidth), "β¦")
181}