fix(ui): context percentage updates (#2115)

Ayman Bagabas created

* fix(ui): context percentage updates

When the agent is performing tasks, the context percentage in the header
was not updating correctly. This commit fixes the issue by ensuring that
the header always draws the context details.

* fix(ui): always turn off compact mode when going to landing state

Change summary

internal/ui/model/header.go | 62 +++++++++++++++++++++++++++++---------
internal/ui/model/ui.go     | 57 +++++++++++++++++------------------
2 files changed, 75 insertions(+), 44 deletions(-)

Detailed changes

internal/ui/model/header.go 🔗

@@ -12,6 +12,7 @@ import (
 	"github.com/charmbracelet/crush/internal/session"
 	"github.com/charmbracelet/crush/internal/ui/common"
 	"github.com/charmbracelet/crush/internal/ui/styles"
+	uv "github.com/charmbracelet/ultraviolet"
 	"github.com/charmbracelet/x/ansi"
 )
 
@@ -22,29 +23,58 @@ const (
 	rightPadding   = 1
 )
 
-// renderCompactHeader renders the compact header for the given session.
-func renderCompactHeader(
-	com *common.Common,
+type header struct {
+	// cached logo and compact logo
+	logo        string
+	compactLogo string
+
+	com     *common.Common
+	width   int
+	compact bool
+}
+
+// newHeader creates a new header model.
+func newHeader(com *common.Common) *header {
+	h := &header{
+		com: com,
+	}
+	t := com.Styles
+	h.compactLogo = t.Header.Charm.Render("Charm™") + " " +
+		styles.ApplyBoldForegroundGrad(t, "CRUSH", t.Secondary, t.Primary) + " "
+	return h
+}
+
+// drawHeader draws the header for the given session.
+func (h *header) drawHeader(
+	scr uv.Screen,
+	area uv.Rectangle,
 	session *session.Session,
-	lspClients *csync.Map[string, *lsp.Client],
+	compact bool,
 	detailsOpen bool,
 	width int,
-) string {
-	if session == nil || session.ID == "" {
-		return ""
+) {
+	t := h.com.Styles
+	if width != h.width || compact != h.compact {
+		h.logo = renderLogo(h.com.Styles, compact, width)
 	}
 
-	t := com.Styles
+	h.width = width
+	h.compact = compact
 
-	var b strings.Builder
+	if !compact || session == nil || h.com.App == nil {
+		uv.NewStyledString(h.logo).Draw(scr, area)
+		return
+	}
+
+	if session.ID == "" {
+		return
+	}
 
-	b.WriteString(t.Header.Charm.Render("Charm™"))
-	b.WriteString(" ")
-	b.WriteString(styles.ApplyBoldForegroundGrad(t, "CRUSH", t.Secondary, t.Primary))
-	b.WriteString(" ")
+	var b strings.Builder
+	b.WriteString(h.compactLogo)
 
 	availDetailWidth := width - leftPadding - rightPadding - lipgloss.Width(b.String()) - minHeaderDiags
-	details := renderHeaderDetails(com, session, lspClients, detailsOpen, availDetailWidth)
+	details := renderHeaderDetails(h.com, session, h.com.App.LSPClients, detailsOpen, availDetailWidth)
 
 	remainingWidth := width -
 		lipgloss.Width(b.String()) -
@@ -61,7 +91,9 @@ func renderCompactHeader(
 
 	b.WriteString(details)
 
-	return t.Base.Padding(0, rightPadding, 0, leftPadding).Render(b.String())
+	view := uv.NewStyledString(
+		t.Base.Padding(0, rightPadding, 0, leftPadding).Render(b.String()))
+	view.Draw(scr, area)
 }
 
 // renderHeaderDetails renders the details section of the header.

internal/ui/model/ui.go 🔗

@@ -141,8 +141,7 @@ type UI struct {
 	// isCanceling tracks whether the user has pressed escape once to cancel.
 	isCanceling bool
 
-	// header is the last cached header logo
-	header string
+	header *header
 
 	// sendProgressBar instructs the TUI to send progress bar updates to the
 	// terminal.
@@ -261,12 +260,15 @@ func New(com *common.Common) *UI {
 		},
 	)
 
+	header := newHeader(com)
+
 	ui := &UI{
 		com:         com,
 		dialog:      dialog.NewOverlay(),
 		keyMap:      keyMap,
 		textarea:    ta,
 		chat:        ch,
+		header:      header,
 		completions: comp,
 		attachments: attachments,
 		todoSpinner: todoSpinner,
@@ -325,6 +327,10 @@ func (m *UI) Init() tea.Cmd {
 
 // setState changes the UI state and focus.
 func (m *UI) setState(state uiState, focus uiFocusState) {
+	if state == uiLanding {
+		// Always turn off compact mode when going to landing
+		m.isCompact = false
+	}
 	m.state = state
 	m.focus = focus
 	// Changing the state may change layout, so update it.
@@ -1761,6 +1767,18 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
 	return tea.Batch(cmds...)
 }
 
+// drawHeader draws the header section of the UI.
+func (m *UI) drawHeader(scr uv.Screen, area uv.Rectangle) {
+	m.header.drawHeader(
+		scr,
+		area,
+		m.session,
+		m.isCompact,
+		m.detailsOpen,
+		m.width,
+	)
+}
+
 // Draw implements [uv.Drawable] and draws the UI model.
 func (m *UI) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
 	layout := m.generateLayout(area.Dx(), area.Dy())
@@ -1775,22 +1793,19 @@ func (m *UI) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
 
 	switch m.state {
 	case uiOnboarding:
-		header := uv.NewStyledString(m.header)
-		header.Draw(scr, layout.header)
+		m.drawHeader(scr, layout.header)
 
 		// NOTE: Onboarding flow will be rendered as dialogs below, but
 		// positioned at the bottom left of the screen.
 
 	case uiInitialize:
-		header := uv.NewStyledString(m.header)
-		header.Draw(scr, layout.header)
+		m.drawHeader(scr, layout.header)
 
 		main := uv.NewStyledString(m.initializeView())
 		main.Draw(scr, layout.main)
 
 	case uiLanding:
-		header := uv.NewStyledString(m.header)
-		header.Draw(scr, layout.header)
+		m.drawHeader(scr, layout.header)
 		main := uv.NewStyledString(m.landingView())
 		main.Draw(scr, layout.main)
 
@@ -1799,8 +1814,7 @@ func (m *UI) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
 
 	case uiChat:
 		if m.isCompact {
-			header := uv.NewStyledString(m.header)
-			header.Draw(scr, layout.header)
+			m.drawHeader(scr, layout.header)
 		} else {
 			m.drawSidebar(scr, layout.sidebar)
 		}
@@ -2177,14 +2191,9 @@ func (m *UI) updateSize() {
 
 	// Handle different app states
 	switch m.state {
-	case uiOnboarding, uiInitialize, uiLanding:
-		m.renderHeader(false, m.layout.header.Dx())
-
 	case uiChat:
-		if m.isCompact {
-			m.renderHeader(true, m.layout.header.Dx())
-		} else {
-			m.renderSidebarLogo(m.layout.sidebar.Dx())
+		if !m.isCompact {
+			m.cacheSidebarLogo(m.layout.sidebar.Dx())
 		}
 	}
 }
@@ -2590,18 +2599,8 @@ func (m *UI) renderEditorView(width int) string {
 	)
 }
 
-// renderHeader renders and caches the header logo at the specified width.
-func (m *UI) renderHeader(compact bool, width int) {
-	if compact && m.session != nil && m.com.App != nil {
-		m.header = renderCompactHeader(m.com, m.session, m.com.App.LSPClients, m.detailsOpen, width)
-	} else {
-		m.header = renderLogo(m.com.Styles, compact, width)
-	}
-}
-
-// renderSidebarLogo renders and caches the sidebar logo at the specified
-// width.
-func (m *UI) renderSidebarLogo(width int) {
+// cacheSidebarLogo renders and caches the sidebar logo at the specified width.
+func (m *UI) cacheSidebarLogo(width int) {
 	m.sidebarLogo = renderLogo(m.com.Styles, true, width)
 }