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) {
 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}