feat: responsive layout

Kujtim Hoxha created

Change summary

internal/tui/components/chat/header/header.go        | 125 ++++++++++++++
internal/tui/components/chat/sidebar/sidebar.go      |  12 
internal/tui/components/core/helpers.go              |   8 
internal/tui/components/core/layout/container.go     |   4 
internal/tui/components/core/layout/layout.go        |   2 
internal/tui/components/core/layout/split.go         |  27 ++
internal/tui/components/dialogs/commands/commands.go |  23 ++
internal/tui/page/chat/chat.go                       | 119 ++++++++++++
internal/tui/styles/theme.go                         |  27 +++
9 files changed, 314 insertions(+), 33 deletions(-)

Detailed changes

internal/tui/components/chat/header/header.go πŸ”—

@@ -0,0 +1,125 @@
+package header
+
+import (
+	"fmt"
+	"strings"
+
+	tea "github.com/charmbracelet/bubbletea/v2"
+	"github.com/charmbracelet/crush/internal/config"
+	"github.com/charmbracelet/crush/internal/fsext"
+	"github.com/charmbracelet/crush/internal/llm/models"
+	"github.com/charmbracelet/crush/internal/lsp"
+	"github.com/charmbracelet/crush/internal/lsp/protocol"
+	"github.com/charmbracelet/crush/internal/session"
+	"github.com/charmbracelet/crush/internal/tui/styles"
+	"github.com/charmbracelet/crush/internal/tui/util"
+	"github.com/charmbracelet/lipgloss/v2"
+)
+
+type Header interface {
+	util.Model
+	SetSession(session session.Session)
+}
+
+type header struct {
+	width       int
+	session     session.Session
+	lspClients  map[string]*lsp.Client
+	detailsOpen bool
+}
+
+func New(lspClients map[string]*lsp.Client) Header {
+	return &header{
+		lspClients: lspClients,
+		width:      0,
+	}
+}
+
+func (h *header) Init() tea.Cmd {
+	return nil
+}
+
+func (p *header) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+	switch msg := msg.(type) {
+	case tea.WindowSizeMsg:
+		p.width = msg.Width - 2
+	}
+	return p, nil
+}
+
+func (p *header) View() tea.View {
+	if p.session.ID == "" {
+		return tea.NewView("")
+	}
+
+	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
+	if remainingWidth > 0 {
+		char := "β•±"
+		lines := strings.Repeat(char, remainingWidth)
+		parts = append(parts, t.S().Base.Foreground(t.Primary).Render(lines), " ")
+	}
+
+	parts = append(parts, details)
+
+	content := t.S().Base.Padding(0, 1).Render(
+		lipgloss.JoinHorizontal(
+			lipgloss.Left,
+			parts...,
+		),
+	)
+	return tea.NewView(content)
+}
+
+func (h *header) details() string {
+	t := styles.CurrentTheme()
+	cwd := fsext.DirTrim(fsext.PrettyPath(config.WorkingDirectory()), 4)
+	parts := []string{
+		t.S().Muted.Render(cwd),
+	}
+
+	errorCount := 0
+	for _, l := range h.lspClients {
+		for _, diagnostics := range l.GetDiagnostics() {
+			for _, diagnostic := range diagnostics {
+				if diagnostic.Severity == protocol.SeverityError {
+					errorCount++
+				}
+			}
+		}
+	}
+
+	if errorCount > 0 {
+		parts = append(parts, t.S().Error.Render(fmt.Sprintf("%s%d", styles.ErrorIcon, errorCount)))
+	}
+
+	cfg := config.Get()
+	agentCfg := cfg.Agents[config.AgentCoder]
+	selectedModelID := agentCfg.Model
+	model := models.SupportedModels[selectedModelID]
+
+	percentage := (float64(h.session.CompletionTokens+h.session.PromptTokens) / float64(model.ContextWindow)) * 100
+	formattedPercentage := t.S().Muted.Render(fmt.Sprintf("%d%%", int(percentage)))
+	parts = append(parts, formattedPercentage)
+
+	if h.detailsOpen {
+		parts = append(parts, t.S().Muted.Render("ctrl+d")+t.S().Subtle.Render(" close"))
+	} else {
+		parts = append(parts, t.S().Muted.Render("ctrl+d")+t.S().Subtle.Render(" open"))
+	}
+	dot := t.S().Subtle.Render(" β€’ ")
+	return strings.Join(parts, dot)
+}
+
+// SetSession implements Header.
+func (h *header) SetSession(session session.Session) {
+	h.session = session
+}

internal/tui/components/chat/sidebar/sidebar.go πŸ”—

@@ -223,9 +223,9 @@ func (m *sidebarCmp) loadSessionFiles() tea.Msg {
 }
 
 func (m *sidebarCmp) SetSize(width, height int) tea.Cmd {
-	if width < logoBreakpoint && m.width >= logoBreakpoint {
+	if width < logoBreakpoint && (m.width == 0 || m.width >= logoBreakpoint) {
 		m.logo = m.logoBlock(true)
-	} else if width >= logoBreakpoint && m.width < logoBreakpoint {
+	} else if width >= logoBreakpoint && (m.width == 0 || m.width < logoBreakpoint) {
 		m.logo = m.logoBlock(false)
 	}
 
@@ -358,16 +358,16 @@ func (m *sidebarCmp) lspBlock() string {
 
 		errs := []string{}
 		if lspErrs[protocol.SeverityError] > 0 {
-			errs = append(errs, t.S().Base.Foreground(t.Error).Render(fmt.Sprintf("%s%d", styles.ErrorIcon, lspErrs[protocol.SeverityError])))
+			errs = append(errs, t.S().Base.Foreground(t.Error).Render(fmt.Sprintf("%s %d", styles.ErrorIcon, lspErrs[protocol.SeverityError])))
 		}
 		if lspErrs[protocol.SeverityWarning] > 0 {
-			errs = append(errs, t.S().Base.Foreground(t.Warning).Render(fmt.Sprintf("%s%d", styles.WarningIcon, lspErrs[protocol.SeverityWarning])))
+			errs = append(errs, t.S().Base.Foreground(t.Warning).Render(fmt.Sprintf("%s %d", styles.WarningIcon, lspErrs[protocol.SeverityWarning])))
 		}
 		if lspErrs[protocol.SeverityHint] > 0 {
-			errs = append(errs, t.S().Base.Foreground(t.FgHalfMuted).Render(fmt.Sprintf("%s%d", styles.HintIcon, lspErrs[protocol.SeverityHint])))
+			errs = append(errs, t.S().Base.Foreground(t.FgHalfMuted).Render(fmt.Sprintf("%s %d", styles.HintIcon, lspErrs[protocol.SeverityHint])))
 		}
 		if lspErrs[protocol.SeverityInformation] > 0 {
-			errs = append(errs, t.S().Base.Foreground(t.FgHalfMuted).Render(fmt.Sprintf("%s%d", styles.InfoIcon, lspErrs[protocol.SeverityInformation])))
+			errs = append(errs, t.S().Base.Foreground(t.FgHalfMuted).Render(fmt.Sprintf("%s %d", styles.InfoIcon, lspErrs[protocol.SeverityInformation])))
 		}
 
 		lspList = append(lspList,

internal/tui/components/core/helpers.go πŸ”—

@@ -72,11 +72,11 @@ func Status(ops StatusOpts, width int) string {
 	}
 	title = t.S().Base.Foreground(titleColor).Render(title)
 	if description != "" {
-		extraContent := len(ops.ExtraContent)
-		if extraContent > 0 {
-			extraContent += 1
+		extraContentWidth := lipgloss.Width(ops.ExtraContent)
+		if extraContentWidth > 0 {
+			extraContentWidth += 1
 		}
-		description = ansi.Truncate(description, width-lipgloss.Width(icon)-lipgloss.Width(title)-2-extraContent, "…")
+		description = ansi.Truncate(description, width-lipgloss.Width(icon)-lipgloss.Width(title)-2-extraContentWidth, "…")
 	}
 	description = t.S().Base.Foreground(descriptionColor).Render(description)
 

internal/tui/components/core/layout/container.go πŸ”—

@@ -12,7 +12,7 @@ type Container interface {
 	util.Model
 	Sizeable
 	Help
-	Positionable
+	Positional
 	Focusable
 }
 type container struct {
@@ -151,7 +151,7 @@ func (c *container) GetSize() (int, int) {
 func (c *container) SetPosition(x, y int) tea.Cmd {
 	c.x = x
 	c.y = y
-	if positionable, ok := c.content.(Positionable); ok {
+	if positionable, ok := c.content.(Positional); ok {
 		return positionable.SetPosition(x, y)
 	}
 	return nil

internal/tui/components/core/layout/split.go πŸ”—

@@ -3,6 +3,7 @@ package layout
 import (
 	"github.com/charmbracelet/bubbles/v2/key"
 	tea "github.com/charmbracelet/bubbletea/v2"
+	"github.com/charmbracelet/crush/internal/logging"
 	"github.com/charmbracelet/crush/internal/tui/styles"
 	"github.com/charmbracelet/crush/internal/tui/util"
 	"github.com/charmbracelet/lipgloss/v2"
@@ -29,11 +30,14 @@ type SplitPaneLayout interface {
 	ClearBottomPanel() tea.Cmd
 
 	FocusPanel(panel LayoutPanel) tea.Cmd
+	SetOffset(x, y int)
 }
 
 type splitPaneLayout struct {
-	width  int
-	height int
+	width   int
+	height  int
+	xOffset int
+	yOffset int
 
 	ratio         float64
 	verticalRatio float64
@@ -150,6 +154,7 @@ func (s *splitPaneLayout) View() tea.View {
 func (s *splitPaneLayout) SetSize(width, height int) tea.Cmd {
 	s.width = width
 	s.height = height
+	logging.Info("Setting split pane size", "width", width, "height", height)
 
 	var topHeight, bottomHeight int
 	var cmds []tea.Cmd
@@ -194,24 +199,24 @@ func (s *splitPaneLayout) SetSize(width, height int) tea.Cmd {
 	if s.leftPanel != nil {
 		cmd := s.leftPanel.SetSize(leftWidth, topHeight)
 		cmds = append(cmds, cmd)
-		if positionable, ok := s.leftPanel.(Positionable); ok {
-			cmds = append(cmds, positionable.SetPosition(0, 0))
+		if positional, ok := s.leftPanel.(Positional); ok {
+			cmds = append(cmds, positional.SetPosition(s.xOffset, s.yOffset))
 		}
 	}
 
 	if s.rightPanel != nil {
 		cmd := s.rightPanel.SetSize(rightWidth, topHeight)
 		cmds = append(cmds, cmd)
-		if positionable, ok := s.rightPanel.(Positionable); ok {
-			cmds = append(cmds, positionable.SetPosition(leftWidth, 0))
+		if positional, ok := s.rightPanel.(Positional); ok {
+			cmds = append(cmds, positional.SetPosition(s.xOffset+leftWidth, s.yOffset))
 		}
 	}
 
 	if s.bottomPanel != nil {
 		cmd := s.bottomPanel.SetSize(width, bottomHeight)
 		cmds = append(cmds, cmd)
-		if positionable, ok := s.bottomPanel.(Positionable); ok {
-			cmds = append(cmds, positionable.SetPosition(0, topHeight))
+		if positional, ok := s.bottomPanel.(Positional); ok {
+			cmds = append(cmds, positional.SetPosition(s.xOffset, s.yOffset+topHeight))
 		}
 	}
 	return tea.Batch(cmds...)
@@ -308,6 +313,12 @@ func (s *splitPaneLayout) FocusPanel(panel LayoutPanel) tea.Cmd {
 	return tea.Batch(cmds...)
 }
 
+// SetOffset implements SplitPaneLayout.
+func (s *splitPaneLayout) SetOffset(x int, y int) {
+	s.xOffset = x
+	s.yOffset = y
+}
+
 func NewSplitPane(options ...SplitPaneOption) SplitPaneLayout {
 	layout := &splitPaneLayout{
 		ratio:         0.8,

internal/tui/components/dialogs/commands/commands.go πŸ”—

@@ -54,9 +54,10 @@ type commandDialogCmp struct {
 }
 
 type (
-	SwitchSessionsMsg struct{}
-	SwitchModelMsg    struct{}
-	CompactMsg        struct {
+	SwitchSessionsMsg    struct{}
+	SwitchModelMsg       struct{}
+	ToggleCompactModeMsg struct{}
+	CompactMsg           struct {
 		SessionID string
 	}
 )
@@ -111,6 +112,7 @@ func (c *commandDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	case tea.WindowSizeMsg:
 		c.wWidth = msg.Width
 		c.wHeight = msg.Height
+		c.SetCommandType(c.commandType)
 		return c, c.commandList.SetSize(c.listWidth(), c.listHeight())
 	case tea.KeyPressMsg:
 		switch {
@@ -251,8 +253,8 @@ func (c *commandDialogCmp) defaultCommands() []Command {
 	// Only show compact command if there's an active session
 	if c.sessionID != "" {
 		commands = append(commands, Command{
-			ID:          "compact",
-			Title:       "Compact Session",
+			ID:          "Summarize",
+			Title:       "Summarize Session",
 			Description: "Summarize the current session and create a new one with the summary",
 			Handler: func(cmd Command) tea.Cmd {
 				return util.CmdHandler(CompactMsg{
@@ -261,6 +263,17 @@ func (c *commandDialogCmp) defaultCommands() []Command {
 			},
 		})
 	}
+	// Only show toggle compact mode command if window width is larger than compact breakpoint (90)
+	if c.wWidth > 90 && c.sessionID != "" {
+		commands = append(commands, Command{
+			ID:          "toggle_sidebar",
+			Title:       "Toggle Sidebar",
+			Description: "Toggle between compact and normal layout",
+			Handler: func(cmd Command) tea.Cmd {
+				return util.CmdHandler(ToggleCompactModeMsg{})
+			},
+		})
+	}
 
 	return append(commands, []Command{
 		{

internal/tui/page/chat/chat.go πŸ”—

@@ -2,6 +2,7 @@ package chat
 
 import (
 	"context"
+	"strings"
 
 	"github.com/charmbracelet/bubbles/v2/key"
 	tea "github.com/charmbracelet/bubbletea/v2"
@@ -12,6 +13,7 @@ import (
 	"github.com/charmbracelet/crush/internal/session"
 	"github.com/charmbracelet/crush/internal/tui/components/chat"
 	"github.com/charmbracelet/crush/internal/tui/components/chat/editor"
+	"github.com/charmbracelet/crush/internal/tui/components/chat/header"
 	"github.com/charmbracelet/crush/internal/tui/components/chat/sidebar"
 	"github.com/charmbracelet/crush/internal/tui/components/core/layout"
 	"github.com/charmbracelet/crush/internal/tui/components/dialogs/commands"
@@ -21,6 +23,8 @@ import (
 
 var ChatPageID page.PageID = "chat"
 
+const CompactModeBreakpoint = 90 // Width at which the chat page switches to compact mode
+
 type (
 	OpenFilePickerMsg struct{}
 	ChatFocusedMsg    struct {
@@ -34,7 +38,8 @@ type ChatPage interface {
 }
 
 type chatPage struct {
-	app *app.App
+	wWidth, wHeight int // Window dimensions
+	app             *app.App
 
 	layout layout.SplitPaneLayout
 
@@ -43,6 +48,10 @@ type chatPage struct {
 	keyMap KeyMap
 
 	chatFocused bool
+
+	compactMode      bool
+	forceCompactMode bool // Force compact mode regardless of window size
+	header           header.Header
 }
 
 func (p *chatPage) Init() tea.Cmd {
@@ -57,13 +66,55 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	var cmds []tea.Cmd
 	switch msg := msg.(type) {
 	case tea.WindowSizeMsg:
-		cmd := p.layout.SetSize(msg.Width, msg.Height)
+		h, cmd := p.header.Update(msg)
 		cmds = append(cmds, cmd)
+		p.header = h.(header.Header)
+		// the mode is only relevant when there is a  session
+		if p.session.ID != "" {
+			// Only auto-switch to compact mode if not forced
+			if !p.forceCompactMode {
+				if msg.Width <= CompactModeBreakpoint && p.wWidth > CompactModeBreakpoint {
+					p.wWidth = msg.Width
+					p.wHeight = msg.Height
+					cmds = append(cmds, p.setCompactMode(true))
+					return p, tea.Batch(cmds...)
+				} else if msg.Width > CompactModeBreakpoint && p.wWidth <= CompactModeBreakpoint {
+					p.wWidth = msg.Width
+					p.wHeight = msg.Height
+					return p, p.setCompactMode(false)
+				}
+			}
+		}
+		p.wWidth = msg.Width
+		p.wHeight = msg.Height
+		layoutHeight := msg.Height
+		if p.compactMode {
+			// make space for the header
+			layoutHeight -= 1
+		}
+		cmd = p.layout.SetSize(msg.Width, layoutHeight)
+		cmds = append(cmds, cmd)
+		return p, tea.Batch(cmds...)
+
 	case chat.SendMsg:
 		cmd := p.sendMessage(msg.Text, msg.Attachments)
 		if cmd != nil {
 			return p, cmd
 		}
+	case commands.ToggleCompactModeMsg:
+		// Only allow toggling if window width is larger than compact breakpoint
+		if p.wWidth > CompactModeBreakpoint {
+			p.forceCompactMode = !p.forceCompactMode
+			// If force compact mode is enabled, switch to compact mode
+			// If force compact mode is disabled, switch based on window size
+			if p.forceCompactMode {
+				return p, p.setCompactMode(true)
+			} else {
+				// Return to auto mode based on window size
+				shouldBeCompact := p.wWidth <= CompactModeBreakpoint
+				return p, p.setCompactMode(shouldBeCompact)
+			}
+		}
 	case commands.CommandRunCustomMsg:
 		// Check if the agent is busy before executing custom commands
 		if p.app.CoderAgent.IsBusy() {
@@ -82,7 +133,12 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 				cmds = append(cmds, cmd)
 			}
 		}
+		needsModeChange := p.session.ID == ""
 		p.session = msg
+		p.header.SetSession(msg)
+		if needsModeChange && (p.wWidth <= CompactModeBreakpoint || p.forceCompactMode) {
+			cmds = append(cmds, p.setCompactMode(true))
+		}
 	case tea.KeyPressMsg:
 		switch {
 		case key.Matches(msg, p.keyMap.NewSession):
@@ -90,8 +146,8 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			return p, tea.Batch(
 				p.clearMessages(),
 				util.CmdHandler(chat.SessionClearedMsg{}),
+				p.setCompactMode(false),
 			)
-
 		case key.Matches(msg, p.keyMap.AddAttachment):
 			cfg := config.Get()
 			agentCfg := cfg.Agents[config.AgentCoder]
@@ -128,6 +184,9 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	cmds = append(cmds, cmd)
 	p.layout = u.(layout.SplitPaneLayout)
 
+	h, cmd := p.header.Update(msg)
+	cmds = append(cmds, cmd)
+	p.header = h.(header.Header)
 	return p, tea.Batch(cmds...)
 }
 
@@ -139,10 +198,38 @@ func (p *chatPage) setMessages() tea.Cmd {
 	return tea.Batch(p.layout.SetLeftPanel(messagesContainer), messagesContainer.Init())
 }
 
+func (p *chatPage) setSidebar() tea.Cmd {
+	sidebarContainer := sidebarCmp(p.app)
+	sidebarContainer.Init()
+	return p.layout.SetRightPanel(sidebarContainer)
+}
+
 func (p *chatPage) clearMessages() tea.Cmd {
 	return p.layout.ClearLeftPanel()
 }
 
+func (p *chatPage) setCompactMode(compact bool) tea.Cmd {
+	p.compactMode = compact
+	var cmds []tea.Cmd
+	if compact {
+		// add offset for the header
+		p.layout.SetOffset(0, 1)
+		// make space for the header
+		cmds = append(cmds, p.layout.SetSize(p.wWidth, p.wHeight-1))
+		// remove the sidebar
+		cmds = append(cmds, p.layout.ClearRightPanel())
+		return tea.Batch(cmds...)
+	} else {
+		// remove the offset for the header
+		p.layout.SetOffset(0, 0)
+		// restore the original size
+		cmds = append(cmds, p.layout.SetSize(p.wWidth, p.wHeight))
+		// set the sidebar
+		cmds = append(cmds, p.setSidebar())
+		return tea.Batch(cmds...)
+	}
+}
+
 func (p *chatPage) sendMessage(text string, attachments []message.Attachment) tea.Cmd {
 	var cmds []tea.Cmd
 	if p.session.ID == "" {
@@ -175,7 +262,21 @@ func (p *chatPage) GetSize() (int, int) {
 }
 
 func (p *chatPage) View() tea.View {
-	return p.layout.View()
+	if !p.compactMode || p.session.ID == "" {
+		// If not in compact mode or there is no session, we don't show the header
+		return p.layout.View()
+	}
+	layoutView := p.layout.View()
+	chatView := tea.NewView(
+		strings.Join(
+			[]string{
+				p.header.View().String(),
+				layoutView.String(),
+			}, "\n",
+		),
+	)
+	chatView.SetCursor(layoutView.Cursor())
+	return chatView
 }
 
 func (p *chatPage) Bindings() []key.Binding {
@@ -207,22 +308,26 @@ func (p *chatPage) Bindings() []key.Binding {
 	return bindings
 }
 
-func NewChatPage(app *app.App) ChatPage {
-	sidebarContainer := layout.NewContainer(
+func sidebarCmp(app *app.App) layout.Container {
+	return layout.NewContainer(
 		sidebar.NewSidebarCmp(app.History, app.LSPClients),
 		layout.WithPadding(1, 1, 1, 1),
 	)
+}
+
+func NewChatPage(app *app.App) ChatPage {
 	editorContainer := layout.NewContainer(
 		editor.NewEditorCmp(app),
 	)
 	return &chatPage{
 		app: app,
 		layout: layout.NewSplitPane(
-			layout.WithRightPanel(sidebarContainer),
+			layout.WithRightPanel(sidebarCmp(app)),
 			layout.WithBottomPanel(editorContainer),
 			layout.WithFixedBottomHeight(5),
 			layout.WithFixedRightWidth(31),
 		),
 		keyMap: DefaultKeyMap(),
+		header: header.New(app.LSPClients),
 	}
 }

internal/tui/styles/theme.go πŸ”—

@@ -582,6 +582,33 @@ func ApplyForegroundGrad(input string, color1, color2 color.Color) string {
 	return o.String()
 }
 
+// ApplyBoldForegroundGrad renders a given string with a horizontal gradient
+// foreground.
+func ApplyBoldForegroundGrad(input string, color1, color2 color.Color) string {
+	if input == "" {
+		return ""
+	}
+	t := CurrentTheme()
+
+	var o strings.Builder
+	if len(input) == 1 {
+		return t.S().Base.Bold(true).Foreground(color1).Render(input)
+	}
+
+	var clusters []string
+	gr := uniseg.NewGraphemes(input)
+	for gr.Next() {
+		clusters = append(clusters, string(gr.Runes()))
+	}
+
+	ramp := blendColors(len(clusters), color1, color2)
+	for i, c := range ramp {
+		fmt.Fprint(&o, t.S().Base.Bold(true).Foreground(c).Render(clusters[i]))
+	}
+
+	return o.String()
+}
+
 // blendColors returns a slice of colors blended between the given keys.
 // Blending is done in Hcl to stay in gamut.
 func blendColors(size int, stops ...color.Color) []color.Color {