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