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) {
73 t := h.com.Styles
74 if width != h.width || compact != h.compact {
75 h.logo = renderLogo(h.com.Styles, compact, h.com.IsHyper(), width)
76 }
77
78 h.width = width
79 h.compact = compact
80
81 if !compact || session == nil {
82 uv.NewStyledString(h.logo).Draw(scr, area)
83 return
84 }
85
86 if session.ID == "" {
87 return
88 }
89
90 var b strings.Builder
91 b.WriteString(h.compactLogo)
92
93 availDetailWidth := width - leftPadding - rightPadding - lipgloss.Width(b.String()) - minHeaderDiags - diagToDetailsSpacing
94 lspErrorCount := 0
95 for _, info := range h.com.Workspace.LSPGetStates() {
96 lspErrorCount += info.DiagnosticCount
97 }
98 details := renderHeaderDetails(
99 h.com,
100 session,
101 lspErrorCount,
102 detailsOpen,
103 availDetailWidth,
104 )
105
106 remainingWidth := width -
107 lipgloss.Width(b.String()) -
108 lipgloss.Width(details) -
109 leftPadding -
110 rightPadding -
111 diagToDetailsSpacing
112
113 if remainingWidth > 0 {
114 b.WriteString(t.Header.Diagonals.Render(
115 strings.Repeat(headerDiag, max(minHeaderDiags, remainingWidth)),
116 ))
117 b.WriteString(" ")
118 }
119
120 b.WriteString(details)
121
122 view := uv.NewStyledString(
123 t.Header.Wrapper.Padding(0, rightPadding, 0, leftPadding).Render(b.String()))
124 view.Draw(scr, area)
125}
126
127// renderHeaderDetails renders the details section of the header.
128func renderHeaderDetails(
129 com *common.Common,
130 session *session.Session,
131 lspErrorCount int,
132 detailsOpen bool,
133 availWidth int,
134) string {
135 t := com.Styles
136
137 var parts []string
138
139 if lspErrorCount > 0 {
140 parts = append(parts, t.LSP.ErrorDiagnostic.Render(fmt.Sprintf("%s%d", styles.LSPErrorIcon, lspErrorCount)))
141 }
142
143 agentCfg := com.Config().Agents[config.AgentCoder]
144 model := com.Config().GetModelByType(agentCfg.Model)
145 if model != nil && model.ContextWindow > 0 {
146 percentage := (float64(session.CompletionTokens+session.PromptTokens) / float64(model.ContextWindow)) * 100
147 formattedPercentage := t.Header.Percentage.Render(fmt.Sprintf("%d%%", int(percentage)))
148 parts = append(parts, formattedPercentage)
149 }
150
151 const keystroke = "ctrl+d"
152 if detailsOpen {
153 parts = append(parts, t.Header.Keystroke.Render(keystroke)+t.Header.KeystrokeTip.Render(" close"))
154 } else {
155 parts = append(parts, t.Header.Keystroke.Render(keystroke)+t.Header.KeystrokeTip.Render(" open "))
156 }
157
158 dot := t.Header.Separator.Render(" β’ ")
159 metadata := strings.Join(parts, dot)
160 metadata = dot + metadata
161
162 const dirTrimLimit = 4
163 cwd := fsext.DirTrim(fsext.PrettyPath(com.Workspace.WorkingDir()), dirTrimLimit)
164 cwd = t.Header.WorkingDir.Render(cwd)
165
166 result := cwd + metadata
167 return ansi.Truncate(result, max(0, availWidth), "β¦")
168}