header.go

  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	view.Draw(scr, area)
127}
128
129// renderHeaderDetails renders the details section of the header.
130func renderHeaderDetails(
131	com *common.Common,
132	session *session.Session,
133	lspErrorCount int,
134	detailsOpen bool,
135	availWidth int,
136	hyperCredits *int,
137) string {
138	t := com.Styles
139
140	var parts []string
141
142	if lspErrorCount > 0 {
143		parts = append(parts, t.LSP.ErrorDiagnostic.Render(fmt.Sprintf("%s%d", styles.LSPErrorIcon, lspErrorCount)))
144	}
145
146	agentCfg := com.Config().Agents[config.AgentCoder]
147	model := com.Config().GetModelByType(agentCfg.Model)
148	if model != nil && model.ContextWindow > 0 {
149		percentage := (float64(session.CompletionTokens+session.PromptTokens) / float64(model.ContextWindow)) * 100
150		formattedPercentage := t.Header.Percentage.Render(fmt.Sprintf("%d%%", int(percentage)))
151		parts = append(parts, formattedPercentage)
152	}
153
154	if com.IsHyper() && hyperCredits != nil {
155		hc := t.Header.Hypercredit.Render(styles.HypercreditIcon) + " " + t.Header.Percentage.Render(common.FormatCredits(*hyperCredits))
156		parts = append(parts, hc)
157	}
158
159	const keystroke = "ctrl+d"
160	if detailsOpen {
161		parts = append(parts, t.Header.Keystroke.Render(keystroke)+t.Header.KeystrokeTip.Render(" close"))
162	} else {
163		parts = append(parts, t.Header.Keystroke.Render(keystroke)+t.Header.KeystrokeTip.Render(" open "))
164	}
165
166	dot := t.Header.Separator.Render(" β€’ ")
167	metadata := strings.Join(parts, dot)
168	metadata = dot + metadata
169
170	const dirTrimLimit = 4
171	cwd := fsext.DirTrim(fsext.PrettyPath(com.Workspace.WorkingDir()), dirTrimLimit)
172	cwd = t.Header.WorkingDir.Render(cwd)
173
174	result := cwd + metadata
175	return ansi.Truncate(result, max(0, availWidth), "…")
176}