chore: add chat sidebar (#1510)

Kujtim Hoxha created

Change summary

internal/ui/common/elements.go  |  27 ++++
internal/ui/model/files.go      | 211 +++++++++++++++++++++++++++++++++++
internal/ui/model/landing.go    |  35 +----
internal/ui/model/lsp.go        |  23 ++-
internal/ui/model/mcp.go        |  31 ++--
internal/ui/model/onboarding.go |   5 
internal/ui/model/sidebar.go    | 169 ++++++++++++++++++++-------
internal/ui/model/ui.go         |  97 +++++++++++----
internal/ui/styles/styles.go    |  27 +++
9 files changed, 502 insertions(+), 123 deletions(-)

Detailed changes

internal/ui/common/elements.go πŸ”—

@@ -12,17 +12,22 @@ import (
 	"github.com/charmbracelet/x/ansi"
 )
 
+// PrettyPath formats a file path with home directory shortening and applies
+// muted styling.
 func PrettyPath(t *styles.Styles, path string, width int) string {
 	formatted := home.Short(path)
 	return t.Muted.Width(width).Render(formatted)
 }
 
+// ModelContextInfo contains token usage and cost information for a model.
 type ModelContextInfo struct {
 	ContextUsed  int64
 	ModelContext int64
 	Cost         float64
 }
 
+// ModelInfo renders model information including name, reasoning settings, and
+// optional context usage/cost.
 func ModelInfo(t *styles.Styles, modelName string, reasoningInfo string, context *ModelContextInfo, width int) string {
 	modelIcon := t.Subtle.Render(styles.ModelIcon)
 	modelName = t.Base.Render(modelName)
@@ -37,7 +42,8 @@ func ModelInfo(t *styles.Styles, modelName string, reasoningInfo string, context
 	}
 
 	if context != nil {
-		parts = append(parts, formatTokensAndCost(t, context.ContextUsed, context.ModelContext, context.Cost))
+		formattedInfo := formatTokensAndCost(t, context.ContextUsed, context.ModelContext, context.Cost)
+		parts = append(parts, lipgloss.NewStyle().PaddingLeft(2).Render(formattedInfo))
 	}
 
 	return lipgloss.NewStyle().Width(width).Render(
@@ -45,6 +51,8 @@ func ModelInfo(t *styles.Styles, modelName string, reasoningInfo string, context
 	)
 }
 
+// formatTokensAndCost formats token usage and cost with appropriate units
+// (K/M) and percentage of context window.
 func formatTokensAndCost(t *styles.Styles, tokens, contextWindow int64, cost float64) string {
 	var formattedTokens string
 	switch {
@@ -77,6 +85,8 @@ func formatTokensAndCost(t *styles.Styles, tokens, contextWindow int64, cost flo
 	return fmt.Sprintf("%s %s", formattedTokens, formattedCost)
 }
 
+// StatusOpts defines options for rendering a status line with icon, title,
+// description, and optional extra content.
 type StatusOpts struct {
 	Icon             string // if empty no icon will be shown
 	Title            string
@@ -86,6 +96,8 @@ type StatusOpts struct {
 	ExtraContent     string // additional content to append after the description
 }
 
+// Status renders a status line with icon, title, description, and extra
+// content. The description is truncated if it exceeds the available width.
 func Status(t *styles.Styles, opts StatusOpts, width int) string {
 	icon := opts.Icon
 	title := opts.Title
@@ -119,3 +131,16 @@ func Status(t *styles.Styles, opts StatusOpts, width int) string {
 
 	return strings.Join(content, " ")
 }
+
+// Section renders a section header with a title and a horizontal line filling
+// the remaining width.
+func Section(t *styles.Styles, text string, width int) string {
+	char := styles.SectionSeparator
+	length := lipgloss.Width(text) + 1
+	remainingWidth := width - length
+	text = t.Section.Title.Render(text)
+	if remainingWidth > 0 {
+		text = text + " " + t.Section.Line.Render(strings.Repeat(char, remainingWidth))
+	}
+	return text
+}

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

@@ -0,0 +1,211 @@
+package model
+
+import (
+	"context"
+	"fmt"
+	"path/filepath"
+	"slices"
+	"strings"
+
+	tea "charm.land/bubbletea/v2"
+	"charm.land/lipgloss/v2"
+	"github.com/charmbracelet/crush/internal/diff"
+	"github.com/charmbracelet/crush/internal/fsext"
+	"github.com/charmbracelet/crush/internal/history"
+	"github.com/charmbracelet/crush/internal/ui/common"
+	"github.com/charmbracelet/crush/internal/ui/styles"
+	"github.com/charmbracelet/x/ansi"
+)
+
+// SessionFile tracks the first and latest versions of a file in a session,
+// along with the total additions and deletions.
+type SessionFile struct {
+	FirstVersion  history.File
+	LatestVersion history.File
+	Additions     int
+	Deletions     int
+}
+
+// loadSessionFiles loads all files modified during a session and calculates
+// their diff statistics.
+func (m *UI) loadSessionFiles(sessionID string) tea.Cmd {
+	return func() tea.Msg {
+		files, err := m.com.App.History.ListBySession(context.Background(), sessionID)
+		if err != nil {
+			return err
+		}
+		filesByPath := make(map[string][]history.File)
+		for _, f := range files {
+			filesByPath[f.Path] = append(filesByPath[f.Path], f)
+		}
+
+		sessionFiles := make([]SessionFile, 0, len(filesByPath))
+		for _, versions := range filesByPath {
+			if len(versions) == 0 {
+				continue
+			}
+
+			first := versions[0]
+			last := versions[0]
+			for _, v := range versions {
+				if v.Version < first.Version {
+					first = v
+				}
+				if v.Version > last.Version {
+					last = v
+				}
+			}
+
+			_, additions, deletions := diff.GenerateDiff(first.Content, last.Content, first.Path)
+
+			sessionFiles = append(sessionFiles, SessionFile{
+				FirstVersion:  first,
+				LatestVersion: last,
+				Additions:     additions,
+				Deletions:     deletions,
+			})
+		}
+
+		slices.SortFunc(sessionFiles, func(a, b SessionFile) int {
+			if a.LatestVersion.UpdatedAt > b.LatestVersion.UpdatedAt {
+				return -1
+			}
+			if a.LatestVersion.UpdatedAt < b.LatestVersion.UpdatedAt {
+				return 1
+			}
+			return 0
+		})
+
+		return sessionFilesLoadedMsg{
+			files: sessionFiles,
+		}
+	}
+}
+
+// handleFileEvent processes file change events and updates the session file
+// list with new or updated file information.
+func (m *UI) handleFileEvent(file history.File) tea.Cmd {
+	if m.session == nil || file.SessionID != m.session.ID {
+		return nil
+	}
+
+	return func() tea.Msg {
+		existingIdx := -1
+		for i, sf := range m.sessionFiles {
+			if sf.FirstVersion.Path == file.Path {
+				existingIdx = i
+				break
+			}
+		}
+
+		if existingIdx == -1 {
+			newFiles := make([]SessionFile, 0, len(m.sessionFiles)+1)
+			newFiles = append(newFiles, SessionFile{
+				FirstVersion:  file,
+				LatestVersion: file,
+				Additions:     0,
+				Deletions:     0,
+			})
+			newFiles = append(newFiles, m.sessionFiles...)
+
+			return sessionFilesLoadedMsg{files: newFiles}
+		}
+
+		updated := m.sessionFiles[existingIdx]
+
+		if file.Version < updated.FirstVersion.Version {
+			updated.FirstVersion = file
+		}
+
+		if file.Version > updated.LatestVersion.Version {
+			updated.LatestVersion = file
+		}
+
+		_, additions, deletions := diff.GenerateDiff(
+			updated.FirstVersion.Content,
+			updated.LatestVersion.Content,
+			updated.FirstVersion.Path,
+		)
+		updated.Additions = additions
+		updated.Deletions = deletions
+
+		newFiles := make([]SessionFile, 0, len(m.sessionFiles))
+		newFiles = append(newFiles, updated)
+		for i, sf := range m.sessionFiles {
+			if i != existingIdx {
+				newFiles = append(newFiles, sf)
+			}
+		}
+
+		return sessionFilesLoadedMsg{files: newFiles}
+	}
+}
+
+// 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 {
+	t := m.com.Styles
+	title := common.Section(t, "Modified Files", width)
+	list := t.Subtle.Render("None")
+
+	if len(m.sessionFiles) > 0 {
+		list = fileList(t, cwd, m.sessionFiles, width, maxItems)
+	}
+
+	return lipgloss.NewStyle().Width(width).Render(fmt.Sprintf("%s\n\n%s", title, list))
+}
+
+// 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 {
+	var renderedFiles []string
+	filesShown := 0
+
+	var filesWithChanges []SessionFile
+	for _, f := range files {
+		if f.Additions == 0 && f.Deletions == 0 {
+			continue
+		}
+		filesWithChanges = append(filesWithChanges, f)
+	}
+
+	for _, f := range filesWithChanges {
+		// Skip files with no changes
+		if filesShown >= maxItems {
+			break
+		}
+
+		// Build stats string with colors
+		var statusParts []string
+		if f.Additions > 0 {
+			statusParts = append(statusParts, t.Files.Additions.Render(fmt.Sprintf("+%d", f.Additions)))
+		}
+		if f.Deletions > 0 {
+			statusParts = append(statusParts, t.Files.Deletions.Render(fmt.Sprintf("-%d", f.Deletions)))
+		}
+		extraContent := strings.Join(statusParts, " ")
+
+		// Format file path
+		filePath := f.FirstVersion.Path
+		if rel, err := filepath.Rel(cwd, filePath); err == nil {
+			filePath = rel
+		}
+		filePath = fsext.DirTrim(filePath, 2)
+		filePath = ansi.Truncate(filePath, width-(lipgloss.Width(extraContent)-2), "…")
+
+		line := t.Files.Path.Render(filePath)
+		if extraContent != "" {
+			line = fmt.Sprintf("%s %s", line, extraContent)
+		}
+
+		renderedFiles = append(renderedFiles, line)
+		filesShown++
+	}
+
+	if len(filesWithChanges) > maxItems {
+		remaining := len(filesWithChanges) - maxItems
+		renderedFiles = append(renderedFiles, t.Subtle.Render(fmt.Sprintf("…and %d more", remaining)))
+	}
+
+	return lipgloss.JoinVertical(lipgloss.Left, renderedFiles...)
+}

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

@@ -1,18 +1,14 @@
 package model
 
 import (
-	"cmp"
-	"fmt"
-
 	"charm.land/lipgloss/v2"
-	"github.com/charmbracelet/catwalk/pkg/catwalk"
 	"github.com/charmbracelet/crush/internal/agent"
 	"github.com/charmbracelet/crush/internal/ui/common"
 	uv "github.com/charmbracelet/ultraviolet"
-	"golang.org/x/text/cases"
-	"golang.org/x/text/language"
 )
 
+// selectedLargeModel returns the currently selected large language model from
+// the agent coordinator, if one exists.
 func (m *UI) selectedLargeModel() *agent.Model {
 	if m.com.App.AgentCoordinator != nil {
 		model := m.com.App.AgentCoordinator.Model()
@@ -21,6 +17,8 @@ func (m *UI) selectedLargeModel() *agent.Model {
 	return nil
 }
 
+// landingView renders the landing page view showing the current working
+// directory, model information, and LSP/MCP status in a two-column layout.
 func (m *UI) landingView() string {
 	t := m.com.Styles
 	width := m.layout.main.Dx()
@@ -30,34 +28,15 @@ func (m *UI) landingView() string {
 		cwd,
 	}
 
-	model := m.selectedLargeModel()
-	if model != nil && model.CatwalkCfg.CanReason {
-		reasoningInfo := ""
-		providerConfig, ok := m.com.Config().Providers.Get(model.ModelCfg.Provider)
-		if ok {
-			switch providerConfig.Type {
-			case catwalk.TypeAnthropic:
-				if model.ModelCfg.Think {
-					reasoningInfo = "Thinking On"
-				} else {
-					reasoningInfo = "Thinking Off"
-				}
-			default:
-				formatter := cases.Title(language.English, cases.NoLower)
-				reasoningEffort := cmp.Or(model.ModelCfg.ReasoningEffort, model.CatwalkCfg.DefaultReasoningEffort)
-				reasoningInfo = formatter.String(fmt.Sprintf("Reasoning %s", reasoningEffort))
-			}
-			parts = append(parts, "", common.ModelInfo(t, model.CatwalkCfg.Name, reasoningInfo, nil, width))
-		}
-	}
+	parts = append(parts, "", m.modelInfo(width))
 	infoSection := lipgloss.JoinVertical(lipgloss.Left, parts...)
 
 	_, remainingHeightArea := uv.SplitVertical(m.layout.main, uv.Fixed(lipgloss.Height(infoSection)+1))
 
 	mcpLspSectionWidth := min(30, (width-1)/2)
 
-	lspSection := m.lspInfo(t, mcpLspSectionWidth, remainingHeightArea.Dy())
-	mcpSection := m.mcpInfo(t, mcpLspSectionWidth, remainingHeightArea.Dy())
+	lspSection := m.lspInfo(mcpLspSectionWidth, max(1, remainingHeightArea.Dy()), false)
+	mcpSection := m.mcpInfo(mcpLspSectionWidth, max(1, remainingHeightArea.Dy()), false)
 
 	content := lipgloss.JoinHorizontal(lipgloss.Left, lspSection, " ", mcpSection)
 

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

@@ -12,13 +12,17 @@ import (
 	"github.com/charmbracelet/x/powernap/pkg/lsp/protocol"
 )
 
+// LSPInfo wraps LSP client information with diagnostic counts by severity.
 type LSPInfo struct {
 	app.LSPClientInfo
 	Diagnostics map[protocol.DiagnosticSeverity]int
 }
 
-func (m *UI) lspInfo(t *styles.Styles, width, height int) string {
+// lspInfo renders the LSP status section showing active LSP clients and their
+// diagnostic counts.
+func (m *UI) lspInfo(width, maxItems int, isSection bool) string {
 	var lsps []LSPInfo
+	t := m.com.Styles
 
 	for _, state := range m.lspStates {
 		client, ok := m.com.App.LSPClients.Get(state.Name)
@@ -43,15 +47,18 @@ func (m *UI) lspInfo(t *styles.Styles, width, height int) string {
 		lsps = append(lsps, LSPInfo{LSPClientInfo: state, Diagnostics: lspErrs})
 	}
 	title := t.Subtle.Render("LSPs")
+	if isSection {
+		title = common.Section(t, title, width)
+	}
 	list := t.Subtle.Render("None")
 	if len(lsps) > 0 {
-		height = max(0, height-2) // remove title and space
-		list = lspList(t, lsps, width, height)
+		list = lspList(t, lsps, width, maxItems)
 	}
 
 	return lipgloss.NewStyle().Width(width).Render(fmt.Sprintf("%s\n\n%s", title, list))
 }
 
+// lspDiagnostics formats diagnostic counts with appropriate icons and colors.
 func lspDiagnostics(t *styles.Styles, diagnostics map[protocol.DiagnosticSeverity]int) string {
 	errs := []string{}
 	if diagnostics[protocol.SeverityError] > 0 {
@@ -69,7 +76,9 @@ func lspDiagnostics(t *styles.Styles, diagnostics map[protocol.DiagnosticSeverit
 	return strings.Join(errs, " ")
 }
 
-func lspList(t *styles.Styles, lsps []LSPInfo, width, height int) string {
+// 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 {
 	var renderedLsps []string
 	for _, l := range lsps {
 		var icon string
@@ -103,9 +112,9 @@ func lspList(t *styles.Styles, lsps []LSPInfo, width, height int) string {
 		}, width))
 	}
 
-	if len(renderedLsps) > height {
-		visibleItems := renderedLsps[:height-1]
-		remaining := len(renderedLsps) - (height - 1)
+	if len(renderedLsps) > maxItems {
+		visibleItems := renderedLsps[:maxItems-1]
+		remaining := len(renderedLsps) - maxItems
 		visibleItems = append(visibleItems, t.Subtle.Render(fmt.Sprintf("…and %d more", remaining)))
 		return lipgloss.JoinVertical(lipgloss.Left, visibleItems...)
 	}

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

@@ -10,27 +10,29 @@ import (
 	"github.com/charmbracelet/crush/internal/ui/styles"
 )
 
-type MCPInfo struct {
-	mcp.ClientInfo
-}
-
-func (m *UI) mcpInfo(t *styles.Styles, width, height int) string {
-	var mcps []MCPInfo
+// mcpInfo renders the MCP status section showing active MCP clients and their
+// tool/prompt counts.
+func (m *UI) mcpInfo(width, maxItems int, isSection bool) string {
+	var mcps []mcp.ClientInfo
+	t := m.com.Styles
 
 	for _, state := range m.mcpStates {
-		mcps = append(mcps, MCPInfo{ClientInfo: state})
+		mcps = append(mcps, state)
 	}
 
 	title := t.Subtle.Render("MCPs")
+	if isSection {
+		title = common.Section(t, title, width)
+	}
 	list := t.Subtle.Render("None")
 	if len(mcps) > 0 {
-		height = max(0, height-2) // remove title and space
-		list = mcpList(t, mcps, width, height)
+		list = mcpList(t, mcps, width, maxItems)
 	}
 
 	return lipgloss.NewStyle().Width(width).Render(fmt.Sprintf("%s\n\n%s", title, list))
 }
 
+// mcpCounts formats tool and prompt counts for display.
 func mcpCounts(t *styles.Styles, counts mcp.Counts) string {
 	parts := []string{}
 	if counts.Tools > 0 {
@@ -42,8 +44,11 @@ func mcpCounts(t *styles.Styles, counts mcp.Counts) string {
 	return strings.Join(parts, " ")
 }
 
-func mcpList(t *styles.Styles, mcps []MCPInfo, width, height int) 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 {
 	var renderedMcps []string
+
 	for _, m := range mcps {
 		var icon string
 		title := m.Name
@@ -78,9 +83,9 @@ func mcpList(t *styles.Styles, mcps []MCPInfo, width, height int) string {
 		}, width))
 	}
 
-	if len(renderedMcps) > height {
-		visibleItems := renderedMcps[:height-1]
-		remaining := len(renderedMcps) - (height - 1)
+	if len(renderedMcps) > maxItems {
+		visibleItems := renderedMcps[:maxItems-1]
+		remaining := len(renderedMcps) - maxItems
 		visibleItems = append(visibleItems, t.Subtle.Render(fmt.Sprintf("…and %d more", remaining)))
 		return lipgloss.JoinVertical(lipgloss.Left, visibleItems...)
 	}

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

@@ -13,6 +13,7 @@ import (
 	"github.com/charmbracelet/crush/internal/ui/common"
 )
 
+// markProjectInitialized marks the current project as initialized in the config.
 func (m *UI) markProjectInitialized() tea.Msg {
 	// TODO: handle error so we show it in the tui footer
 	err := config.MarkProjectInitialized()
@@ -22,6 +23,7 @@ func (m *UI) markProjectInitialized() tea.Msg {
 	return nil
 }
 
+// updateInitializeView handles keyboard input for the project initialization prompt.
 func (m *UI) updateInitializeView(msg tea.KeyPressMsg) (cmds []tea.Cmd) {
 	switch {
 	case key.Matches(msg, m.keyMap.Initialize.Enter):
@@ -40,6 +42,7 @@ func (m *UI) updateInitializeView(msg tea.KeyPressMsg) (cmds []tea.Cmd) {
 	return cmds
 }
 
+// initializeProject starts project initialization and transitions to the landing view.
 func (m *UI) initializeProject() tea.Cmd {
 	// TODO: initialize the project
 	// for now we just go to the landing page
@@ -49,6 +52,7 @@ func (m *UI) initializeProject() tea.Cmd {
 	return m.markProjectInitialized
 }
 
+// skipInitializeProject skips project initialization and transitions to the landing view.
 func (m *UI) skipInitializeProject() tea.Cmd {
 	// TODO: initialize the project
 	m.state = uiLanding
@@ -57,6 +61,7 @@ func (m *UI) skipInitializeProject() tea.Cmd {
 	return m.markProjectInitialized
 }
 
+// initializeView renders the project initialization prompt with Yes/No buttons.
 func (m *UI) initializeView() string {
 	cfg := m.com.Config()
 	s := m.com.Styles.Initialize

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

@@ -1,67 +1,150 @@
 package model
 
 import (
-	tea "charm.land/bubbletea/v2"
+	"cmp"
+	"fmt"
+
 	"charm.land/lipgloss/v2"
+	"github.com/charmbracelet/catwalk/pkg/catwalk"
 	"github.com/charmbracelet/crush/internal/ui/common"
+	"github.com/charmbracelet/crush/internal/ui/logo"
+	uv "github.com/charmbracelet/ultraviolet"
+	"golang.org/x/text/cases"
+	"golang.org/x/text/language"
 )
 
-// SidebarModel is the model for the sidebar UI component.
-type SidebarModel struct {
-	com *common.Common
+// modelInfo renders the current model information including reasoning
+// settings and context usage/cost for the sidebar.
+func (m *UI) modelInfo(width int) string {
+	model := m.selectedLargeModel()
+	reasoningInfo := ""
+	if model != nil && model.CatwalkCfg.CanReason {
+		providerConfig, ok := m.com.Config().Providers.Get(model.ModelCfg.Provider)
+		if ok {
+			switch providerConfig.Type {
+			case catwalk.TypeAnthropic:
+				if model.ModelCfg.Think {
+					reasoningInfo = "Thinking On"
+				} else {
+					reasoningInfo = "Thinking Off"
+				}
+			default:
+				formatter := cases.Title(language.English, cases.NoLower)
+				reasoningEffort := cmp.Or(model.ModelCfg.ReasoningEffort, model.CatwalkCfg.DefaultReasoningEffort)
+				reasoningInfo = formatter.String(fmt.Sprintf("Reasoning %s", reasoningEffort))
+			}
+		}
+	}
+	var modelContext *common.ModelContextInfo
+	if m.session != nil {
+		modelContext = &common.ModelContextInfo{
+			ContextUsed:  m.session.CompletionTokens + m.session.PromptTokens,
+			Cost:         m.session.Cost,
+			ModelContext: model.CatwalkCfg.ContextWindow,
+		}
+	}
+	return common.ModelInfo(m.com.Styles, model.CatwalkCfg.Name, reasoningInfo, modelContext, width)
+}
+
+// getDynamicHeightLimits will give us the num of items to show in each section based on the hight
+// some items are more important than others.
+func getDynamicHeightLimits(availableHeight int) (maxFiles, maxLSPs, maxMCPs int) {
+	const (
+		minItemsPerSection      = 2
+		defaultMaxFilesShown    = 10
+		defaultMaxLSPsShown     = 8
+		defaultMaxMCPsShown     = 8
+		minAvailableHeightLimit = 10
+	)
+
+	// If we have very little space, use minimum values
+	if availableHeight < minAvailableHeightLimit {
+		return minItemsPerSection, minItemsPerSection, minItemsPerSection
+	}
 
-	// width of the sidebar.
-	width int
+	// Distribute available height among the three sections
+	// Give priority to files, then LSPs, then MCPs
+	totalSections := 3
+	heightPerSection := availableHeight / totalSections
 
-	// Cached rendered logo string.
-	logo string
-	// Cached cwd string.
-	cwd string
+	// Calculate limits for each section, ensuring minimums
+	maxFiles = max(minItemsPerSection, min(defaultMaxFilesShown, heightPerSection))
+	maxLSPs = max(minItemsPerSection, min(defaultMaxLSPsShown, heightPerSection))
+	maxMCPs = max(minItemsPerSection, min(defaultMaxMCPsShown, heightPerSection))
 
-	// TODO: lsp, files, session
+	// If we have extra space, give it to files first
+	remainingHeight := availableHeight - (maxFiles + maxLSPs + maxMCPs)
+	if remainingHeight > 0 {
+		extraForFiles := min(remainingHeight, defaultMaxFilesShown-maxFiles)
+		maxFiles += extraForFiles
+		remainingHeight -= extraForFiles
 
-	// Whether to render the sidebar in compact mode.
-	compact bool
-}
+		if remainingHeight > 0 {
+			extraForLSPs := min(remainingHeight, defaultMaxLSPsShown-maxLSPs)
+			maxLSPs += extraForLSPs
+			remainingHeight -= extraForLSPs
 
-// NewSidebarModel creates a new SidebarModel instance.
-func NewSidebarModel(com *common.Common) *SidebarModel {
-	return &SidebarModel{
-		com:     com,
-		compact: true,
-		cwd:     com.Config().WorkingDir(),
+			if remainingHeight > 0 {
+				maxMCPs += min(remainingHeight, defaultMaxMCPsShown-maxMCPs)
+			}
+		}
 	}
-}
 
-// Init initializes the sidebar model.
-func (m *SidebarModel) Init() tea.Cmd {
-	return nil
+	return maxFiles, maxLSPs, maxMCPs
 }
 
-// Update updates the sidebar model based on incoming messages.
-func (m *SidebarModel) Update(msg tea.Msg) (*SidebarModel, tea.Cmd) {
-	return m, nil
-}
+// sidebar renders the chat sidebar containing session title, working
+// directory, model info, file list, LSP status, and MCP status.
+func (m *UI) drawSidebar(scr uv.Screen, area uv.Rectangle) {
+	const logoHeightBreakpoint = 30
 
-// View renders the sidebar model as a string.
-func (m *SidebarModel) View() string {
-	s := m.com.Styles.SidebarFull
-	if m.compact {
-		s = m.com.Styles.SidebarCompact
-	}
+	t := m.com.Styles
+	width := area.Dx()
+	height := area.Dy()
 
+	title := t.Muted.Width(width).MaxHeight(2).Render(m.session.Title)
+	cwd := common.PrettyPath(t, m.com.Config().WorkingDir(), width)
+	sidebarLogo := m.sidebarLogo
+	if height < logoHeightBreakpoint {
+		sidebarLogo = logo.SmallRender(width)
+	}
 	blocks := []string{
-		m.logo,
+		sidebarLogo,
+		title,
+		"",
+		cwd,
+		"",
+		m.modelInfo(width),
+		"",
 	}
 
-	return s.Render(lipgloss.JoinVertical(
-		lipgloss.Top,
+	sidebarHeader := lipgloss.JoinVertical(
+		lipgloss.Left,
 		blocks...,
-	))
-}
+	)
+
+	_, remainingHeightArea := uv.SplitVertical(m.layout.sidebar, uv.Fixed(lipgloss.Height(sidebarHeader)))
+	remainingHeight := remainingHeightArea.Dy() - 10
+	maxFiles, maxLSPs, maxMCPs := getDynamicHeightLimits(remainingHeight)
+
+	lspSection := m.lspInfo(width, maxLSPs, true)
+	mcpSection := m.mcpInfo(width, maxMCPs, true)
+	filesSection := m.filesInfo(m.com.Config().WorkingDir(), width, maxFiles)
 
-// SetWidth sets the width of the sidebar and updates the logo accordingly.
-func (m *SidebarModel) SetWidth(width int) {
-	m.logo = renderLogo(m.com.Styles, true, width)
-	m.width = width
+	uv.NewStyledString(
+		lipgloss.NewStyle().
+			MaxWidth(width).
+			MaxHeight(height).
+			Render(
+				lipgloss.JoinVertical(
+					lipgloss.Left,
+					sidebarHeader,
+					filesSection,
+					"",
+					lspSection,
+					"",
+					mcpSection,
+				),
+			),
+	).Draw(scr, area)
 }

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

@@ -1,6 +1,7 @@
 package model
 
 import (
+	"context"
 	"image"
 	"math/rand"
 	"os"
@@ -15,6 +16,7 @@ import (
 	"github.com/charmbracelet/crush/internal/agent/tools/mcp"
 	"github.com/charmbracelet/crush/internal/app"
 	"github.com/charmbracelet/crush/internal/config"
+	"github.com/charmbracelet/crush/internal/history"
 	"github.com/charmbracelet/crush/internal/pubsub"
 	"github.com/charmbracelet/crush/internal/session"
 	"github.com/charmbracelet/crush/internal/ui/common"
@@ -48,10 +50,19 @@ const (
 	uiChatCompact
 )
 
+type sessionLoadedMsg struct {
+	sess session.Session
+}
+
+type sessionFilesLoadedMsg struct {
+	files []SessionFile
+}
+
 // UI represents the main user interface model.
 type UI struct {
-	com  *common.Common
-	sess *session.Session
+	com          *common.Common
+	session      *session.Session
+	sessionFiles []SessionFile
 
 	// The width and height of the terminal in cells.
 	width  int
@@ -65,7 +76,6 @@ type UI struct {
 	keyenh tea.KeyboardEnhancementsMsg
 
 	chat   *ChatModel
-	side   *SidebarModel
 	dialog *dialog.Overlay
 	help   help.Model
 
@@ -98,6 +108,9 @@ type UI struct {
 
 	// mcp
 	mcpStates map[string]mcp.ClientInfo
+
+	// sidebarLogo keeps a cached version of the sidebar sidebarLogo.
+	sidebarLogo string
 }
 
 // New creates a new instance of the [UI] model.
@@ -114,7 +127,6 @@ func New(com *common.Common) *UI {
 		com:      com,
 		dialog:   dialog.NewOverlay(),
 		keyMap:   DefaultKeyMap(),
-		side:     NewSidebarModel(com),
 		help:     help.New(),
 		focus:    uiFocusNone,
 		state:    uiConfigure,
@@ -161,6 +173,13 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		if !m.sendProgressBar {
 			m.sendProgressBar = slices.Contains(msg, "WT_SESSION")
 		}
+	case sessionLoadedMsg:
+		m.state = uiChat
+		m.session = &msg.sess
+	case sessionFilesLoadedMsg:
+		m.sessionFiles = msg.files
+	case pubsub.Event[history.File]:
+		cmds = append(cmds, m.handleFileEvent(msg.Payload))
 	case pubsub.Event[app.LSPEvent]:
 		m.lspStates = app.GetLSPStates()
 	case pubsub.Event[mcp.Event]:
@@ -203,6 +222,14 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	return m, tea.Batch(cmds...)
 }
 
+func (m *UI) loadSession(sessionID string) tea.Cmd {
+	return func() tea.Msg {
+		// TODO: handle error
+		session, _ := m.com.App.Sessions.Get(context.Background(), sessionID)
+		return sessionLoadedMsg{session}
+	}
+}
+
 func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) (cmds []tea.Cmd) {
 	if m.dialog.HasDialogs() {
 		return m.updateDialogs(msg)
@@ -249,6 +276,12 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) (cmds []tea.Cmd) {
 func (m *UI) Draw(scr uv.Screen, area uv.Rectangle) {
 	layout := generateLayout(m, area.Dx(), area.Dy())
 
+	// Update cached layout and component sizes if needed.
+	if m.layout != layout {
+		m.layout = layout
+		m.updateSize()
+	}
+
 	// Clear the screen first
 	screen.Clear(scr)
 
@@ -283,13 +316,9 @@ func (m *UI) Draw(scr uv.Screen, area uv.Rectangle) {
 	case uiChat:
 		header := uv.NewStyledString(m.header)
 		header.Draw(scr, layout.header)
-
-		side := uv.NewStyledString(m.side.View())
-		side.Draw(scr, layout.sidebar)
-
+		m.drawSidebar(scr, layout.sidebar)
 		mainView := lipgloss.NewStyle().Width(layout.main.Dx()).
 			Height(layout.main.Dy()).
-			Background(lipgloss.ANSIColor(rand.Intn(256))).
 			Render(" Chat Messages ")
 		main := uv.NewStyledString(mainView)
 		main.Draw(scr, layout.main)
@@ -385,16 +414,16 @@ func (m *UI) ShortHelp() []key.Binding {
 		binds = append(binds, k.Quit)
 	default:
 		// TODO: other states
-		if m.sess == nil {
-			// no session selected
-			binds = append(binds,
-				k.Commands,
-				k.Models,
-				k.Editor.Newline,
-				k.Quit,
-				k.Help,
-			)
-		}
+		// if m.session == nil {
+		// no session selected
+		binds = append(binds,
+			k.Commands,
+			k.Models,
+			k.Editor.Newline,
+			k.Quit,
+			k.Help,
+		)
+		// }
 		// else {
 		// we have a session
 		// }
@@ -436,7 +465,7 @@ func (m *UI) FullHelp() [][]key.Binding {
 				k.Quit,
 			})
 	default:
-		if m.sess == nil {
+		if m.session == nil {
 			// no session selected
 			binds = append(binds,
 				[]key.Binding{
@@ -535,8 +564,7 @@ func (m *UI) updateSize() {
 		m.textarea.SetHeight(m.layout.editor.Dy())
 
 	case uiChat:
-		// TODO: set the width and heigh of the chat component
-		m.side.SetWidth(m.layout.sidebar.Dx())
+		m.renderSidebarLogo(m.layout.sidebar.Dx())
 		m.textarea.SetWidth(m.layout.editor.Dx())
 		m.textarea.SetHeight(m.layout.editor.Dy())
 
@@ -548,7 +576,8 @@ func (m *UI) updateSize() {
 	}
 }
 
-// generateLayout generates a [layout] for the given rectangle.
+// generateLayout calculates the layout rectangles for all UI components based
+// on the current UI state and terminal dimensions.
 func generateLayout(m *UI, w, h int) layout {
 	// The screen area we're working with
 	area := image.Rect(0, 0, w, h)
@@ -558,7 +587,7 @@ func generateLayout(m *UI, w, h int) layout {
 	// The editor height
 	editorHeight := 5
 	// The sidebar width
-	sidebarWidth := 40
+	sidebarWidth := 30
 	// The header height
 	// TODO: handle compact
 	headerHeight := 4
@@ -635,6 +664,8 @@ func generateLayout(m *UI, w, h int) layout {
 		// 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))
 		layout.sidebar = sideRect
 		layout.main = mainRect
@@ -690,6 +721,8 @@ type layout struct {
 	help uv.Rectangle
 }
 
+// setEditorPrompt configures the textarea prompt function based on whether
+// yolo mode is enabled.
 func (m *UI) setEditorPrompt() {
 	if m.com.App.Permissions.SkipRequests() {
 		m.textarea.SetPromptFunc(4, m.yoloPromptFunc)
@@ -698,6 +731,8 @@ func (m *UI) setEditorPrompt() {
 	m.textarea.SetPromptFunc(4, m.normalPromptFunc)
 }
 
+// normalPromptFunc returns the normal editor prompt style ("  > " on first
+// line, "::: " on subsequent lines).
 func (m *UI) normalPromptFunc(info textarea.PromptInfo) string {
 	t := m.com.Styles
 	if info.LineNumber == 0 {
@@ -709,6 +744,8 @@ func (m *UI) normalPromptFunc(info textarea.PromptInfo) string {
 	return t.EditorPromptNormalBlurred.Render()
 }
 
+// yoloPromptFunc returns the yolo mode editor prompt style with warning icon
+// and colored dots.
 func (m *UI) yoloPromptFunc(info textarea.PromptInfo) string {
 	t := m.com.Styles
 	if info.LineNumber == 0 {
@@ -740,16 +777,26 @@ var workingPlaceholders = [...]string{
 	"Thinking...",
 }
 
+// randomizePlaceholders selects random placeholder text for the textarea's
+// ready and working states.
 func (m *UI) randomizePlaceholders() {
 	m.workingPlaceholder = workingPlaceholders[rand.Intn(len(workingPlaceholders))]
 	m.readyPlaceholder = readyPlaceholders[rand.Intn(len(readyPlaceholders))]
 }
 
+// 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)
 }
 
+// renderSidebarLogo renders and caches the sidebar logo at the specified
+// width.
+func (m *UI) renderSidebarLogo(width int) {
+	m.sidebarLogo = renderLogo(m.com.Styles, true, width)
+}
+
+// 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{
 		FieldColor:   t.LogoFieldColor,
@@ -757,6 +804,6 @@ func renderLogo(t *styles.Styles, compact bool, width int) string {
 		TitleColorB:  t.LogoTitleColorB,
 		CharmColor:   t.LogoCharmColor,
 		VersionColor: t.LogoVersionColor,
-		Width:        max(0, width-2),
+		Width:        width,
 	})
 }

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

@@ -31,6 +31,8 @@ const (
 
 	BorderThin  string = "β”‚"
 	BorderThick string = "β–Œ"
+
+	SectionSeparator string = "─"
 )
 
 const (
@@ -122,9 +124,11 @@ type Styles struct {
 	LogoCharmColor   color.Color
 	LogoVersionColor color.Color
 
-	// Sidebar
-	SidebarFull    lipgloss.Style
-	SidebarCompact lipgloss.Style
+	// Section Title
+	Section struct {
+		Title lipgloss.Style
+		Line  lipgloss.Style
+	}
 
 	// Initialize
 	Initialize struct {
@@ -140,6 +144,13 @@ type Styles struct {
 		HintDiagnostic    lipgloss.Style
 		InfoDiagnostic    lipgloss.Style
 	}
+
+	// Files
+	Files struct {
+		Path      lipgloss.Style
+		Additions lipgloss.Style
+		Deletions lipgloss.Style
+	}
 }
 
 func DefaultStyles() Styles {
@@ -575,9 +586,9 @@ func DefaultStyles() Styles {
 	s.LogoCharmColor = secondary
 	s.LogoVersionColor = primary
 
-	// Sidebar
-	s.SidebarFull = lipgloss.NewStyle().Padding(1, 1)
-	s.SidebarCompact = s.SidebarFull.PaddingTop(0)
+	// Section
+	s.Section.Title = s.Subtle
+	s.Section.Line = s.Base.Foreground(charmtone.Charcoal)
 
 	// Initialize
 	s.Initialize.Header = s.Base
@@ -595,6 +606,10 @@ func DefaultStyles() Styles {
 	s.LSP.WarningDiagnostic = s.Base.Foreground(warning)
 	s.LSP.HintDiagnostic = s.Base.Foreground(fgHalfMuted)
 	s.LSP.InfoDiagnostic = s.Base.Foreground(info)
+
+	s.Files.Path = s.Muted
+	s.Files.Additions = s.Base.Foreground(greenDark)
+	s.Files.Deletions = s.Base.Foreground(redDark)
 	return s
 }