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