fix: truncate long paths in compact header (#773)

Christian Rocha created

* fix(lint): normalize method pointer receiver names
* chore(compact-header): bake var as const
* chore(compact-header): reduce idential consts
* chore(compact-header): reduce allocs and function calls
* chore(compact-header): remove magic numbers
* chore(compact-header): further reduce allocs and function calls
* fix(compact-header): truncate path if it's too long
* chore: go mod tidy
* fix(compact-header): color cwd
* chore(compact-header): improve a var name

Change summary

go.sum                                        | 10 --
internal/tui/components/chat/header/header.go | 95 ++++++++++++--------
2 files changed, 58 insertions(+), 47 deletions(-)

Detailed changes

go.sum 🔗

@@ -78,16 +78,10 @@ github.com/charlievieth/fastwalk v1.0.12 h1:pwfxe1LajixViQqo7EFLXU2+mQxb6OaO0CeN
 github.com/charlievieth/fastwalk v1.0.12/go.mod h1:yGy1zbxog41ZVMcKA/i8ojXLFsuayX5VvwhQVoj9PBI=
 github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250807144536-86f332629539 h1:ptsaiaVl07xdCCosnjs04J7qTymdV0GkQ42qf404dC0=
 github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250807144536-86f332629539/go.mod h1:6HamsBKWqEC/FVHuQMHgQL+knPyvHH55HwJDHl/adMw=
-github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4.0.20250813191918-4ea1703d4181 h1:JVO5KiuVuoyCxfUJC3xs+1hvz89S8ziPih5rEqgPWAs=
-github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4.0.20250813191918-4ea1703d4181/go.mod h1:rNVGP9g4DZiJprF5jr52Xtmq+DiLu0CPXl8nvddz/Y4=
-github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4.0.20250813201422-d4d69f63338d h1:My+32EIDUjemBI17YRTfK7W999nhESxX8/gZQq2IevE=
-github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4.0.20250813201422-d4d69f63338d/go.mod h1:rNVGP9g4DZiJprF5jr52Xtmq+DiLu0CPXl8nvddz/Y4=
 github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4.0.20250813213544-5cc219db8892 h1:lqoYD2DrKhSdC9xCr59JMXtbbdR5/AZ6xfd/G8eOQJM=
 github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4.0.20250813213544-5cc219db8892/go.mod h1:TUpoECaG4/3CwFx5lTlXNpR87Yo7gOwGqucnHGfAm20=
 github.com/charmbracelet/catwalk v0.4.6 h1:Y0JDq5V4agK8oO3lKC/hhInrYXePGwZPNo8I1Lk08jc=
 github.com/charmbracelet/catwalk v0.4.6/go.mod h1:WnKgNPmQHuMyk7GtwAQwl+ezHusfH40IvzML2qwUGwc=
-github.com/charmbracelet/colorprofile v0.3.1 h1:k8dTHMd7fgw4bnFd7jXTLZrSU/CQrKnL3m+AxCzDz40=
-github.com/charmbracelet/colorprofile v0.3.1/go.mod h1:/GkGusxNs8VB/RSOh3fu0TJmQ4ICMMPApIIVn0KszZ0=
 github.com/charmbracelet/colorprofile v0.3.2 h1:9J27WdztfJQVAQKX2WOlSSRB+5gaKqqITmrvb1uTIiI=
 github.com/charmbracelet/colorprofile v0.3.2/go.mod h1:mTD5XzNeWHj8oqHb+S1bssQb7vIHbepiebQ2kPKVKbI=
 github.com/charmbracelet/fang v0.3.1-0.20250711140230-d5ebb8c1d674 h1:+Cz+VfxD5DO+JT1LlswXWhre0HYLj6l2HW8HVGfMuC0=
@@ -98,8 +92,6 @@ github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.3.0.20250721205738-ea66aa652ee0
 github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.3.0.20250721205738-ea66aa652ee0/go.mod h1:XIuqKpZTUXtVyeyiN1k9Tc/U7EzfaDnVc34feFHfBws=
 github.com/charmbracelet/log/v2 v2.0.0-20250226163916-c379e29ff706 h1:WkwO6Ks3mSIGnGuSdKl9qDSyfbYK50z2wc2gGMggegE=
 github.com/charmbracelet/log/v2 v2.0.0-20250226163916-c379e29ff706/go.mod h1:mjJGp00cxcfvD5xdCa+bso251Jt4owrQvuimJtVmEmM=
-github.com/charmbracelet/ultraviolet v0.0.0-20250811142928-f69b0392d22a h1:uxbFdz1bP7G/aDuKg047p+6nzqh1AI/1HgkyhWjI1NY=
-github.com/charmbracelet/ultraviolet v0.0.0-20250811142928-f69b0392d22a/go.mod h1:tvF2zaoXYOmuLtUMLJSWcNfgGfe3302CTKkRf+vYZqo=
 github.com/charmbracelet/ultraviolet v0.0.0-20250813213450-50737e162af5 h1:7FlxuSTw5paY5Km8AK1WwfSVjAIOW4UiZI6Okva83pY=
 github.com/charmbracelet/ultraviolet v0.0.0-20250813213450-50737e162af5/go.mod h1:uQXXTlOPWiN05pLfSdajBj5FaaszPUrrr9qRFmmQ79M=
 github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ=
@@ -384,8 +376,6 @@ golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
 golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
 golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
 golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
-golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
-golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
 golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
 golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
 golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=

internal/tui/components/chat/header/header.go 🔗

@@ -14,6 +14,7 @@ import (
 	"github.com/charmbracelet/crush/internal/tui/styles"
 	"github.com/charmbracelet/crush/internal/tui/util"
 	"github.com/charmbracelet/lipgloss/v2"
+	"github.com/charmbracelet/x/ansi"
 )
 
 type Header interface {
@@ -42,56 +43,65 @@ func (h *header) Init() tea.Cmd {
 	return nil
 }
 
-func (p *header) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+func (h *header) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	switch msg := msg.(type) {
 	case pubsub.Event[session.Session]:
 		if msg.Type == pubsub.UpdatedEvent {
-			if p.session.ID == msg.Payload.ID {
-				p.session = msg.Payload
+			if h.session.ID == msg.Payload.ID {
+				h.session = msg.Payload
 			}
 		}
 	}
-	return p, nil
+	return h, nil
 }
 
-func (p *header) View() string {
-	if p.session.ID == "" {
+func (h *header) View() string {
+	if h.session.ID == "" {
 		return ""
 	}
 
+	const (
+		gap          = " "
+		diag         = "╱"
+		minDiags     = 3
+		leftPadding  = 1
+		rightPadding = 1
+	)
+
 	t := styles.CurrentTheme()
-	details := p.details()
-	parts := []string{
-		t.S().Base.Foreground(t.Secondary).Render("Charm™"),
-		" ",
-		styles.ApplyBoldForegroundGrad("CRUSH", t.Secondary, t.Primary),
-		" ",
-	}
 
-	remainingWidth := p.width - lipgloss.Width(strings.Join(parts, "")) - lipgloss.Width(details) - 2
+	var b strings.Builder
+
+	b.WriteString(t.S().Base.Foreground(t.Secondary).Render("Charm™"))
+	b.WriteString(gap)
+	b.WriteString(styles.ApplyBoldForegroundGrad("CRUSH", t.Secondary, t.Primary))
+	b.WriteString(gap)
+
+	availDetailWidth := h.width - leftPadding - rightPadding - lipgloss.Width(b.String()) - minDiags
+	details := h.details(availDetailWidth)
+
+	remainingWidth := h.width -
+		lipgloss.Width(b.String()) -
+		lipgloss.Width(details) -
+		leftPadding -
+		rightPadding
+
 	if remainingWidth > 0 {
-		char := "╱"
-		lines := strings.Repeat(char, remainingWidth)
-		parts = append(parts, t.S().Base.Foreground(t.Primary).Render(lines), " ")
+		b.WriteString(t.S().Base.Foreground(t.Primary).Render(
+			strings.Repeat(diag, max(minDiags, remainingWidth)),
+		))
+		b.WriteString(gap)
 	}
 
-	parts = append(parts, details)
+	b.WriteString(details)
 
-	content := t.S().Base.Padding(0, 1).Render(
-		lipgloss.JoinHorizontal(
-			lipgloss.Left,
-			parts...,
-		),
-	)
-	return content
+	return t.S().Base.Padding(0, rightPadding, 0, leftPadding).Render(b.String())
 }
 
-func (h *header) details() string {
-	t := styles.CurrentTheme()
-	cwd := fsext.DirTrim(fsext.PrettyPath(config.Get().WorkingDir()), 4)
-	parts := []string{
-		t.S().Muted.Render(cwd),
-	}
+func (h *header) details(availWidth int) string {
+	s := styles.CurrentTheme().S()
+
+	var parts []string
 
 	errorCount := 0
 	for _, l := range h.lspClients {
@@ -105,22 +115,33 @@ func (h *header) details() string {
 	}
 
 	if errorCount > 0 {
-		parts = append(parts, t.S().Error.Render(fmt.Sprintf("%s%d", styles.ErrorIcon, errorCount)))
+		parts = append(parts, s.Error.Render(fmt.Sprintf("%s%d", styles.ErrorIcon, errorCount)))
 	}
 
 	agentCfg := config.Get().Agents["coder"]
 	model := config.Get().GetModelByType(agentCfg.Model)
 	percentage := (float64(h.session.CompletionTokens+h.session.PromptTokens) / float64(model.ContextWindow)) * 100
-	formattedPercentage := t.S().Muted.Render(fmt.Sprintf("%d%%", int(percentage)))
+	formattedPercentage := s.Muted.Render(fmt.Sprintf("%d%%", int(percentage)))
 	parts = append(parts, formattedPercentage)
 
+	const keystroke = "ctrl+d"
 	if h.detailsOpen {
-		parts = append(parts, t.S().Muted.Render("ctrl+d")+t.S().Subtle.Render(" close"))
+		parts = append(parts, s.Muted.Render(keystroke)+s.Subtle.Render(" close"))
 	} else {
-		parts = append(parts, t.S().Muted.Render("ctrl+d")+t.S().Subtle.Render(" open "))
+		parts = append(parts, s.Muted.Render(keystroke)+s.Subtle.Render(" open "))
 	}
-	dot := t.S().Subtle.Render(" • ")
-	return strings.Join(parts, dot)
+
+	dot := s.Subtle.Render(" • ")
+	metadata := strings.Join(parts, dot)
+	metadata = dot + metadata
+
+	// Truncate cwd if necessary, and insert it at the beginning.
+	const dirTrimLimit = 4
+	cwd := fsext.DirTrim(fsext.PrettyPath(config.Get().WorkingDir()), dirTrimLimit)
+	cwd = ansi.Truncate(cwd, max(0, availWidth-lipgloss.Width(metadata)), "…")
+	cwd = s.Muted.Render(cwd)
+
+	return cwd + metadata
 }
 
 func (h *header) SetDetailsOpen(open bool) {