refactor: compact mode (#1850)

Kujtim Hoxha created

Change summary

internal/ui/dialog/commands.go |   2 
internal/ui/model/header.go    | 112 ++++++++++++++
internal/ui/model/lsp.go       |   3 
internal/ui/model/mcp.go       |   3 
internal/ui/model/session.go   |  11 +
internal/ui/model/sidebar.go   |   2 
internal/ui/model/ui.go        | 277 +++++++++++++++++++++++++++--------
internal/ui/styles/styles.go   |  34 +++
8 files changed, 366 insertions(+), 78 deletions(-)

Detailed changes

internal/ui/dialog/commands.go πŸ”—

@@ -227,11 +227,11 @@ func (c *Commands) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
 	width := max(0, min(defaultDialogMaxWidth, area.Dx()))
 	height := max(0, min(defaultDialogHeight, area.Dy()))
 	if area.Dx() != c.windowWidth && c.selected == SystemCommands {
+		c.windowWidth = area.Dx()
 		// since some items in the list depend on width (e.g. toggle sidebar command),
 		// we need to reset the command items when width changes
 		c.setCommandItems(c.selected)
 	}
-	c.windowWidth = area.Dx()
 	innerWidth := width - c.com.Styles.Dialog.View.GetHorizontalFrameSize()
 	heightOffset := t.Dialog.Title.GetVerticalFrameSize() + titleContentHeight +
 		t.Dialog.InputPrompt.GetVerticalFrameSize() + inputContentHeight +

internal/ui/model/header.go πŸ”—

@@ -0,0 +1,112 @@
+package model
+
+import (
+	"fmt"
+	"strings"
+
+	"charm.land/lipgloss/v2"
+	"github.com/charmbracelet/crush/internal/config"
+	"github.com/charmbracelet/crush/internal/csync"
+	"github.com/charmbracelet/crush/internal/fsext"
+	"github.com/charmbracelet/crush/internal/lsp"
+	"github.com/charmbracelet/crush/internal/session"
+	"github.com/charmbracelet/crush/internal/ui/common"
+	"github.com/charmbracelet/crush/internal/ui/styles"
+	"github.com/charmbracelet/x/ansi"
+)
+
+const (
+	headerDiag     = "β•±"
+	minHeaderDiags = 3
+	leftPadding    = 1
+	rightPadding   = 1
+)
+
+// renderCompactHeader renders the compact header for the given session.
+func renderCompactHeader(
+	com *common.Common,
+	session *session.Session,
+	lspClients *csync.Map[string, *lsp.Client],
+	detailsOpen bool,
+	width int,
+) string {
+	if session == nil || session.ID == "" {
+		return ""
+	}
+
+	t := com.Styles
+
+	var b strings.Builder
+
+	b.WriteString(t.Header.Charm.Render("Charmβ„’"))
+	b.WriteString(" ")
+	b.WriteString(styles.ApplyBoldForegroundGrad(t, "CRUSH", t.Secondary, t.Primary))
+	b.WriteString(" ")
+
+	availDetailWidth := width - leftPadding - rightPadding - lipgloss.Width(b.String()) - minHeaderDiags
+	details := renderHeaderDetails(com, session, lspClients, detailsOpen, availDetailWidth)
+
+	remainingWidth := width -
+		lipgloss.Width(b.String()) -
+		lipgloss.Width(details) -
+		leftPadding -
+		rightPadding
+
+	if remainingWidth > 0 {
+		b.WriteString(t.Header.Diagonals.Render(
+			strings.Repeat(headerDiag, max(minHeaderDiags, remainingWidth)),
+		))
+		b.WriteString(" ")
+	}
+
+	b.WriteString(details)
+
+	return t.Base.Padding(0, rightPadding, 0, leftPadding).Render(b.String())
+}
+
+// renderHeaderDetails renders the details section of the header.
+func renderHeaderDetails(
+	com *common.Common,
+	session *session.Session,
+	lspClients *csync.Map[string, *lsp.Client],
+	detailsOpen bool,
+	availWidth int,
+) string {
+	t := com.Styles
+
+	var parts []string
+
+	errorCount := 0
+	for l := range lspClients.Seq() {
+		errorCount += l.GetDiagnosticCounts().Error
+	}
+
+	if errorCount > 0 {
+		parts = append(parts, t.LSP.ErrorDiagnostic.Render(fmt.Sprintf("%s%d", styles.ErrorIcon, errorCount)))
+	}
+
+	agentCfg := config.Get().Agents[config.AgentCoder]
+	model := config.Get().GetModelByType(agentCfg.Model)
+	percentage := (float64(session.CompletionTokens+session.PromptTokens) / float64(model.ContextWindow)) * 100
+	formattedPercentage := t.Header.Percentage.Render(fmt.Sprintf("%d%%", int(percentage)))
+	parts = append(parts, formattedPercentage)
+
+	const keystroke = "ctrl+d"
+	if detailsOpen {
+		parts = append(parts, t.Header.Keystroke.Render(keystroke)+t.Header.KeystrokeTip.Render(" close"))
+	} else {
+		parts = append(parts, t.Header.Keystroke.Render(keystroke)+t.Header.KeystrokeTip.Render(" open "))
+	}
+
+	dot := t.Header.Separator.Render(" β€’ ")
+	metadata := strings.Join(parts, dot)
+	metadata = dot + metadata
+
+	const dirTrimLimit = 4
+	cfg := com.Config()
+	cwd := fsext.DirTrim(fsext.PrettyPath(cfg.WorkingDir()), dirTrimLimit)
+	cwd = ansi.Truncate(cwd, max(0, availWidth-lipgloss.Width(metadata)), "…")
+	cwd = t.Header.WorkingDir.Render(cwd)
+
+	return cwd + metadata
+}

internal/ui/model/lsp.go πŸ”—

@@ -72,6 +72,9 @@ func lspDiagnostics(t *styles.Styles, diagnostics map[protocol.DiagnosticSeverit
 // lspList renders a list of LSP clients with their status and diagnostics,
 // truncating to maxItems if needed.
 func lspList(t *styles.Styles, lsps []LSPInfo, width, maxItems int) string {
+	if maxItems <= 0 {
+		return ""
+	}
 	var renderedLsps []string
 	for _, l := range lsps {
 		var icon string

internal/ui/model/mcp.go πŸ”—

@@ -49,6 +49,9 @@ func mcpCounts(t *styles.Styles, counts mcp.Counts) string {
 // mcpList renders a list of MCP clients with their status and counts,
 // truncating to maxItems if needed.
 func mcpList(t *styles.Styles, mcps []mcp.ClientInfo, width, maxItems int) string {
+	if maxItems <= 0 {
+		return ""
+	}
 	var renderedMcps []string
 
 	for _, m := range mcps {

internal/ui/model/session.go πŸ”—

@@ -169,9 +169,13 @@ func (m *UI) handleFileEvent(file history.File) tea.Cmd {
 
 // filesInfo renders the modified files section for the sidebar, showing files
 // with their addition/deletion counts.
-func (m *UI) filesInfo(cwd string, width, maxItems int) string {
+func (m *UI) filesInfo(cwd string, width, maxItems int, isSection bool) string {
 	t := m.com.Styles
-	title := common.Section(t, "Modified Files", width)
+
+	title := t.Subtle.Render("Modified Files")
+	if isSection {
+		title = common.Section(t, "Modified Files", width)
+	}
 	list := t.Subtle.Render("None")
 
 	if len(m.sessionFiles) > 0 {
@@ -184,6 +188,9 @@ func (m *UI) filesInfo(cwd string, width, maxItems int) string {
 // fileList renders a list of files with their diff statistics, truncating to
 // maxItems and showing a "...and N more" message if needed.
 func fileList(t *styles.Styles, cwd string, files []SessionFile, width, maxItems int) string {
+	if maxItems <= 0 {
+		return ""
+	}
 	var renderedFiles []string
 	filesShown := 0
 

internal/ui/model/sidebar.go πŸ”—

@@ -133,7 +133,7 @@ func (m *UI) drawSidebar(scr uv.Screen, area uv.Rectangle) {
 
 	lspSection := m.lspInfo(width, maxLSPs, true)
 	mcpSection := m.mcpInfo(width, maxMCPs, true)
-	filesSection := m.filesInfo(m.com.Config().WorkingDir(), width, maxFiles)
+	filesSection := m.filesInfo(m.com.Config().WorkingDir(), width, maxFiles, true)
 
 	uv.NewStyledString(
 		lipgloss.NewStyle().

internal/ui/model/ui.go πŸ”—

@@ -53,6 +53,15 @@ const maxAttachmentSize = int64(5 * 1024 * 1024)
 // Allowed image formats.
 var allowedImageTypes = []string{".jpg", ".jpeg", ".png"}
 
+// Compact mode breakpoints.
+const (
+	compactModeWidthBreakpoint  = 120
+	compactModeHeightBreakpoint = 30
+)
+
+// Session details panel max height.
+const sessionDetailsMaxHeight = 20
+
 // uiFocusState represents the current focus state of the UI.
 type uiFocusState uint8
 
@@ -71,7 +80,6 @@ const (
 	uiInitialize
 	uiLanding
 	uiChat
-	uiChatCompact
 )
 
 type openEditorMsg struct {
@@ -161,6 +169,16 @@ type UI struct {
 	// custom commands & mcp commands
 	customCommands    []commands.CustomCommand
 	mcpCustomCommands []commands.MCPCustomCommand
+
+	// forceCompactMode tracks whether compact mode is forced by user toggle
+	forceCompactMode bool
+
+	// isCompact tracks whether we're currently in compact layout mode (either
+	// by user toggle or auto-switch based on window size)
+	isCompact bool
+
+	// detailsOpen tracks whether the details panel is open (in compact mode)
+	detailsOpen bool
 }
 
 // New creates a new instance of the [UI] model.
@@ -233,6 +251,9 @@ func New(com *common.Common) *UI {
 	ui.textarea.Placeholder = ui.readyPlaceholder
 	ui.status = status
 
+	// Initialize compact mode from config
+	ui.forceCompactMode = com.Config().Options.TUI.CompactMode
+
 	return ui
 }
 
@@ -284,6 +305,9 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		}
 	case loadSessionMsg:
 		m.state = uiChat
+		if m.forceCompactMode {
+			m.isCompact = true
+		}
 		m.session = msg.session
 		m.sessionFiles = msg.files
 		msgs, err := m.com.App.Messages.List(context.Background(), m.session.ID)
@@ -370,6 +394,7 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		return m, nil
 	case tea.WindowSizeMsg:
 		m.width, m.height = msg.Width, msg.Height
+		m.handleCompactMode(m.width, m.height)
 		m.updateLayoutAndSize()
 	case tea.KeyboardEnhancementsMsg:
 		m.keyenh = msg
@@ -840,6 +865,9 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
 	case dialog.ActionToggleHelp:
 		m.status.ToggleHelp()
 		m.dialog.CloseDialog(dialog.CommandsID)
+	case dialog.ActionToggleCompactMode:
+		cmds = append(cmds, m.toggleCompactMode())
+		m.dialog.CloseDialog(dialog.CommandsID)
 	case dialog.ActionQuit:
 		cmds = append(cmds, tea.Quit)
 	case dialog.ActionInitializeProject:
@@ -938,6 +966,10 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
 				cmds = append(cmds, cmd)
 			}
 			return true
+		case key.Matches(msg, m.keyMap.Chat.Details) && m.isCompact:
+			m.detailsOpen = !m.detailsOpen
+			m.updateLayoutAndSize()
+			return true
 		}
 		return false
 	}
@@ -972,7 +1004,7 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
 	case uiInitialize:
 		cmds = append(cmds, m.updateInitializeView(msg)...)
 		return tea.Batch(cmds...)
-	case uiChat, uiLanding, uiChatCompact:
+	case uiChat, uiLanding:
 		switch m.focus {
 		case uiFocusEditor:
 			// Handle completions if open.
@@ -1070,6 +1102,12 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
 					}
 				}
 
+				// remove the details if they are open when user starts typing
+				if m.detailsOpen {
+					m.detailsOpen = false
+					m.updateLayoutAndSize()
+				}
+
 				ta, cmd := m.textarea.Update(msg)
 				m.textarea = ta
 				cmds = append(cmds, cmd)
@@ -1220,28 +1258,26 @@ func (m *UI) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
 		editor.Draw(scr, layout.editor)
 
 	case uiChat:
-		m.chat.Draw(scr, layout.main)
+		if m.isCompact {
+			header := uv.NewStyledString(m.header)
+			header.Draw(scr, layout.header)
+		} else {
+			m.drawSidebar(scr, layout.sidebar)
+		}
 
-		header := uv.NewStyledString(m.header)
-		header.Draw(scr, layout.header)
-		m.drawSidebar(scr, layout.sidebar)
+		m.chat.Draw(scr, layout.main)
 
-		editor := uv.NewStyledString(m.renderEditorView(scr.Bounds().Dx() - layout.sidebar.Dx()))
+		editorWidth := scr.Bounds().Dx()
+		if !m.isCompact {
+			editorWidth -= layout.sidebar.Dx()
+		}
+		editor := uv.NewStyledString(m.renderEditorView(editorWidth))
 		editor.Draw(scr, layout.editor)
 
-	case uiChatCompact:
-		header := uv.NewStyledString(m.header)
-		header.Draw(scr, layout.header)
-
-		mainView := lipgloss.NewStyle().Width(layout.main.Dx()).
-			Height(layout.main.Dy()).
-			Background(lipgloss.ANSIColor(rand.Intn(256))).
-			Render(" Compact Chat Messages ")
-		main := uv.NewStyledString(mainView)
-		main.Draw(scr, layout.main)
-
-		editor := uv.NewStyledString(m.renderEditorView(scr.Bounds().Dx()))
-		editor.Draw(scr, layout.editor)
+		// Draw details overlay in compact mode when open
+		if m.isCompact && m.detailsOpen {
+			m.drawSessionDetails(scr, layout.sessionDetails)
+		}
 	}
 
 	// Add status and help layer
@@ -1290,6 +1326,10 @@ func (m *UI) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
 			// Don't show cursor if editor is not visible
 			return nil
 		}
+		if m.detailsOpen && m.isCompact {
+			// Don't show cursor if details overlay is open
+			return nil
+		}
 
 		if m.textarea.Focused() {
 			cur := m.textarea.Cursor()
@@ -1537,6 +1577,36 @@ func (m *UI) FullHelp() [][]key.Binding {
 	return binds
 }
 
+// toggleCompactMode toggles compact mode between uiChat and uiChatCompact states.
+func (m *UI) toggleCompactMode() tea.Cmd {
+	m.forceCompactMode = !m.forceCompactMode
+
+	err := m.com.Config().SetCompactMode(m.forceCompactMode)
+	if err != nil {
+		return uiutil.ReportError(err)
+	}
+
+	m.handleCompactMode(m.width, m.height)
+	m.updateLayoutAndSize()
+
+	return nil
+}
+
+// handleCompactMode updates the UI state based on window size and compact mode setting.
+func (m *UI) handleCompactMode(newWidth, newHeight int) {
+	if m.state == uiChat {
+		if m.forceCompactMode {
+			m.isCompact = true
+			return
+		}
+		if newWidth < compactModeWidthBreakpoint || newHeight < compactModeHeightBreakpoint {
+			m.isCompact = true
+		} else {
+			m.isCompact = false
+		}
+	}
+}
+
 // updateLayoutAndSize updates the layout and sizes of UI components.
 func (m *UI) updateLayoutAndSize() {
 	m.layout = m.generateLayout(m.width, m.height)
@@ -1558,11 +1628,11 @@ func (m *UI) updateSize() {
 		m.renderHeader(false, m.layout.header.Dx())
 
 	case uiChat:
-		m.renderSidebarLogo(m.layout.sidebar.Dx())
-
-	case uiChatCompact:
-		// TODO: set the width and heigh of the chat component
-		m.renderHeader(true, m.layout.header.Dx())
+		if m.isCompact {
+			m.renderHeader(true, m.layout.header.Dx())
+		} else {
+			m.renderSidebarLogo(m.layout.sidebar.Dx())
+		}
 	}
 }
 
@@ -1579,8 +1649,7 @@ func (m *UI) generateLayout(w, h int) layout {
 	// The sidebar width
 	sidebarWidth := 30
 	// The header height
-	// TODO: handle compact
-	headerHeight := 4
+	const landingHeaderHeight = 4
 
 	var helpKeyMap help.KeyMap = m
 	if m.status.ShowingAll() {
@@ -1619,7 +1688,7 @@ func (m *UI) generateLayout(w, h int) layout {
 		// ------
 		// help
 
-		headerRect, mainRect := uv.SplitVertical(appRect, uv.Fixed(headerHeight))
+		headerRect, mainRect := uv.SplitVertical(appRect, uv.Fixed(landingHeaderHeight))
 		layout.header = headerRect
 		layout.main = mainRect
 
@@ -1633,7 +1702,7 @@ func (m *UI) generateLayout(w, h int) layout {
 		// editor
 		// ------
 		// help
-		headerRect, mainRect := uv.SplitVertical(appRect, uv.Fixed(headerHeight))
+		headerRect, mainRect := uv.SplitVertical(appRect, uv.Fixed(landingHeaderHeight))
 		mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight))
 		// Remove extra padding from editor (but keep it for header and main)
 		editorRect.Min.X -= 1
@@ -1643,41 +1712,52 @@ func (m *UI) generateLayout(w, h int) layout {
 		layout.editor = editorRect
 
 	case uiChat:
-		// Layout
-		//
-		// ------|---
-		// main  |
-		// ------| side
-		// editor|
-		// ----------
-		// help
-
-		mainRect, sideRect := uv.SplitHorizontal(appRect, uv.Fixed(appRect.Dx()-sidebarWidth))
-		// Add padding left
-		sideRect.Min.X += 1
-		mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight))
-		mainRect.Max.X -= 1 // Add padding right
-		// Add bottom margin to main
-		mainRect.Max.Y -= 1
-		layout.sidebar = sideRect
-		layout.main = mainRect
-		layout.editor = editorRect
-
-	case uiChatCompact:
-		// Layout
-		//
-		// compact-header
-		// ------
-		// main
-		// ------
-		// editor
-		// ------
-		// help
-		headerRect, mainRect := uv.SplitVertical(appRect, uv.Fixed(appRect.Dy()-headerHeight))
-		mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight))
-		layout.header = headerRect
-		layout.main = mainRect
-		layout.editor = editorRect
+		if m.isCompact {
+			// Layout
+			//
+			// compact-header
+			// ------
+			// main
+			// ------
+			// editor
+			// ------
+			// help
+			const compactHeaderHeight = 1
+			headerRect, mainRect := uv.SplitVertical(appRect, uv.Fixed(compactHeaderHeight))
+			detailsHeight := min(sessionDetailsMaxHeight, area.Dy()-1) // One row for the header
+			sessionDetailsArea, _ := uv.SplitVertical(appRect, uv.Fixed(detailsHeight))
+			layout.sessionDetails = sessionDetailsArea
+			layout.sessionDetails.Min.Y += compactHeaderHeight // adjust for header
+			// Add one line gap between header and main content
+			mainRect.Min.Y += 1
+			mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight))
+			mainRect.Max.X -= 1 // Add padding right
+			// Add bottom margin to main
+			mainRect.Max.Y -= 1
+			layout.header = headerRect
+			layout.main = mainRect
+			layout.editor = editorRect
+		} else {
+			// Layout
+			//
+			// ------|---
+			// main  |
+			// ------| side
+			// editor|
+			// ----------
+			// help
+
+			mainRect, sideRect := uv.SplitHorizontal(appRect, uv.Fixed(appRect.Dx()-sidebarWidth))
+			// Add padding left
+			sideRect.Min.X += 1
+			mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight))
+			mainRect.Max.X -= 1 // Add padding right
+			// Add bottom margin to main
+			mainRect.Max.Y -= 1
+			layout.sidebar = sideRect
+			layout.main = mainRect
+			layout.editor = editorRect
+		}
 	}
 
 	if !layout.editor.Empty() {
@@ -1711,6 +1791,9 @@ type layout struct {
 
 	// status is the area for the status view.
 	status uv.Rectangle
+
+	// session details is the area for the session details overlay in compact mode.
+	sessionDetails uv.Rectangle
 }
 
 func (m *UI) openEditor(value string) tea.Cmd {
@@ -1916,8 +1999,11 @@ 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) {
-	// TODO: handle the compact case differently
-	m.header = renderLogo(m.com.Styles, compact, width)
+	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
@@ -1939,8 +2025,13 @@ func (m *UI) sendMessage(content string, attachments []message.Attachment) tea.C
 			return uiutil.ReportError(err)
 		}
 		m.state = uiChat
-		m.session = &newSession
-		cmds = append(cmds, m.loadSession(newSession.ID))
+		if m.forceCompactMode {
+			m.isCompact = true
+		}
+		if newSession.ID != "" {
+			m.session = &newSession
+			cmds = append(cmds, m.loadSession(newSession.ID))
+		}
 	}
 
 	// Capture session ID to avoid race with main goroutine updating m.session.
@@ -2252,6 +2343,56 @@ func (m *UI) pasteIdx() int {
 	return result + 1
 }
 
+// drawSessionDetails draws the session details in compact mode.
+func (m *UI) drawSessionDetails(scr uv.Screen, area uv.Rectangle) {
+	if m.session == nil {
+		return
+	}
+
+	s := m.com.Styles
+
+	width := area.Dx() - s.CompactDetails.View.GetHorizontalFrameSize()
+	height := area.Dy() - s.CompactDetails.View.GetVerticalFrameSize()
+
+	title := s.CompactDetails.Title.Width(width).MaxHeight(2).Render(m.session.Title)
+	blocks := []string{
+		title,
+		"",
+		m.modelInfo(width),
+		"",
+	}
+
+	detailsHeader := lipgloss.JoinVertical(
+		lipgloss.Left,
+		blocks...,
+	)
+
+	version := s.CompactDetails.Version.Foreground(s.Border).Width(width).AlignHorizontal(lipgloss.Right).Render(version.Version)
+
+	remainingHeight := height - lipgloss.Height(detailsHeader) - lipgloss.Height(version)
+
+	const maxSectionWidth = 50
+	sectionWidth := min(maxSectionWidth, width/3-2) // account for 2 spaces
+	maxItemsPerSection := remainingHeight - 3       // Account for section title and spacing
+
+	lspSection := m.lspInfo(sectionWidth, maxItemsPerSection, false)
+	mcpSection := m.mcpInfo(sectionWidth, maxItemsPerSection, false)
+	filesSection := m.filesInfo(m.com.Config().WorkingDir(), sectionWidth, maxItemsPerSection, false)
+	sections := lipgloss.JoinHorizontal(lipgloss.Top, filesSection, " ", lspSection, " ", mcpSection)
+	uv.NewStyledString(
+		s.CompactDetails.View.
+			Width(area.Dx()).
+			Render(
+				lipgloss.JoinVertical(
+					lipgloss.Left,
+					detailsHeader,
+					sections,
+					version,
+				),
+			),
+	).Draw(scr, area)
+}
+
 // renderLogo renders the Crush logo with the given styles and dimensions.
 func renderLogo(t *styles.Styles, compact bool, width int) string {
 	return logo.Render(version.Version, compact, logo.Opts{

internal/ui/styles/styles.go πŸ”—

@@ -69,9 +69,22 @@ type Styles struct {
 	TagError lipgloss.Style
 	TagInfo  lipgloss.Style
 
-	// Headers
-	HeaderTool       lipgloss.Style
-	HeaderToolNested lipgloss.Style
+	// Header
+	Header struct {
+		Charm        lipgloss.Style // Style for "Charmβ„’" label
+		Diagonals    lipgloss.Style // Style for diagonal separators (β•±)
+		Percentage   lipgloss.Style // Style for context percentage
+		Keystroke    lipgloss.Style // Style for keystroke hints (e.g., "ctrl+d")
+		KeystrokeTip lipgloss.Style // Style for keystroke action text (e.g., "open", "close")
+		WorkingDir   lipgloss.Style // Style for current working directory
+		Separator    lipgloss.Style // Style for separator dots (β€’)
+	}
+
+	CompactDetails struct {
+		View    lipgloss.Style
+		Version lipgloss.Style
+		Title   lipgloss.Style
+	}
 
 	// Panels
 	PanelMuted lipgloss.Style
@@ -995,9 +1008,18 @@ func DefaultStyles() Styles {
 	s.TagError = s.TagBase.Background(redDark)
 	s.TagInfo = s.TagBase.Background(blueLight)
 
-	// headers
-	s.HeaderTool = lipgloss.NewStyle().Foreground(blue)
-	s.HeaderToolNested = lipgloss.NewStyle().Foreground(fgHalfMuted)
+	// Compact header styles
+	s.Header.Charm = base.Foreground(secondary)
+	s.Header.Diagonals = base.Foreground(primary)
+	s.Header.Percentage = s.Muted
+	s.Header.Keystroke = s.Muted
+	s.Header.KeystrokeTip = s.Subtle
+	s.Header.WorkingDir = s.Muted
+	s.Header.Separator = s.Subtle
+
+	s.CompactDetails.Title = s.Base
+	s.CompactDetails.View = s.Base.Padding(0, 1, 1, 1).Border(lipgloss.RoundedBorder()).BorderForeground(borderFocus)
+	s.CompactDetails.Version = s.Muted
 
 	// panels
 	s.PanelMuted = s.Muted.Background(bgBaseLighter)