@@ -0,0 +1,346 @@
+// Package logo renders a Crush wordmark in a stylized way.
+package logo
+
+import (
+	"fmt"
+	"image/color"
+	"strings"
+
+	"github.com/MakeNowJust/heredoc"
+	"github.com/charmbracelet/crush/internal/tui/styles"
+	"github.com/charmbracelet/lipgloss/v2"
+	"github.com/charmbracelet/x/ansi"
+	"github.com/charmbracelet/x/exp/slice"
+)
+
+// letterform represents a letterform. It can be stretched horizontally by
+// a given amount via the boolean argument.
+type letterform func(bool) string
+
+const diag = `β±`
+
+// Opts are the options for rendering the Crush title art.
+type Opts struct {
+	FieldColor   color.Color // diagonal lines
+	TitleColorA  color.Color // left gradient ramp point
+	TitleColorB  color.Color // right gradient ramp point
+	CharmColor   color.Color // Charmβ’ text color
+	VersionColor color.Color // Version text color
+	Width        int         // width of the rendered logo, used for truncation
+}
+
+// Render renders the Crush logo. Set the argument to true to render the narrow
+// version, intended for use in a sidebar.
+//
+// The compact argument determines whether it renders compact for the sidebar
+// or wider for the main pane.
+func Render(version string, compact bool, o Opts) string {
+	const charm = " Charmβ’"
+
+	fg := func(c color.Color, s string) string {
+		return lipgloss.NewStyle().Foreground(c).Render(s)
+	}
+
+	// Title.
+	const spacing = 1
+	letterforms := []letterform{
+		letterC,
+		letterR,
+		letterU,
+		letterSStylized,
+		letterH,
+	}
+	stretchIndex := -1 // -1 means no stretching.
+	if !compact {
+		stretchIndex = cachedRandN(len(letterforms))
+	}
+
+	crush := renderWord(spacing, stretchIndex, letterforms...)
+	crushWidth := lipgloss.Width(crush)
+	b := new(strings.Builder)
+	for r := range strings.SplitSeq(crush, "\n") {
+		fmt.Fprintln(b, styles.ApplyForegroundGrad(r, o.TitleColorA, o.TitleColorB))
+	}
+	crush = b.String()
+
+	// Charm and version.
+	metaRowGap := 1
+	maxVersionWidth := crushWidth - lipgloss.Width(charm) - metaRowGap
+	version = ansi.Truncate(version, maxVersionWidth, "β¦") // truncate version if too long.
+	gap := max(0, crushWidth-lipgloss.Width(charm)-lipgloss.Width(version))
+	metaRow := fg(o.CharmColor, charm) + strings.Repeat(" ", gap) + fg(o.VersionColor, version)
+
+	// Join the meta row and big Crush title.
+	crush = strings.TrimSpace(metaRow + "\n" + crush)
+
+	// Narrow version.
+	if compact {
+		field := fg(o.FieldColor, strings.Repeat(diag, crushWidth))
+		return strings.Join([]string{field, field, crush, field, ""}, "\n")
+	}
+
+	fieldHeight := lipgloss.Height(crush)
+
+	// Left field.
+	const leftWidth = 6
+	leftFieldRow := fg(o.FieldColor, strings.Repeat(diag, leftWidth))
+	leftField := new(strings.Builder)
+	for range fieldHeight {
+		fmt.Fprintln(leftField, leftFieldRow)
+	}
+
+	// Right field.
+	rightWidth := max(15, o.Width-crushWidth-leftWidth-2) // 2 for the gap.
+	const stepDownAt = 0
+	rightField := new(strings.Builder)
+	for i := range fieldHeight {
+		width := rightWidth
+		if i >= stepDownAt {
+			width = rightWidth - (i - stepDownAt)
+		}
+		fmt.Fprint(rightField, fg(o.FieldColor, strings.Repeat(diag, width)), "\n")
+	}
+
+	// Return the wide version.
+	const hGap = " "
+	logo := lipgloss.JoinHorizontal(lipgloss.Top, leftField.String(), hGap, crush, hGap, rightField.String())
+	if o.Width > 0 {
+		// Truncate the logo to the specified width.
+		lines := strings.Split(logo, "\n")
+		for i, line := range lines {
+			lines[i] = ansi.Truncate(line, o.Width, "")
+		}
+		logo = strings.Join(lines, "\n")
+	}
+	return logo
+}
+
+// SmallRender renders a smaller version of the Crush logo, suitable for
+// smaller windows or sidebar usage.
+func SmallRender(width int) string {
+	t := styles.CurrentTheme()
+	title := t.S().Base.Foreground(t.Secondary).Render("Charmβ’")
+	title = fmt.Sprintf("%s %s", title, styles.ApplyBoldForegroundGrad("Crush", t.Secondary, t.Primary))
+	remainingWidth := width - lipgloss.Width(title) - 1 // 1 for the space after "Crush"
+	if remainingWidth > 0 {
+		lines := strings.Repeat("β±", remainingWidth)
+		title = fmt.Sprintf("%s %s", title, t.S().Base.Foreground(t.Primary).Render(lines))
+	}
+	return title
+}
+
+// renderWord renders letterforms to fork a word. stretchIndex is the index of
+// the letter to stretch, or -1 if no letter should be stretched.
+func renderWord(spacing int, stretchIndex int, letterforms ...letterform) string {
+	if spacing < 0 {
+		spacing = 0
+	}
+
+	renderedLetterforms := make([]string, len(letterforms))
+
+	// pick one letter randomly to stretch
+	for i, letter := range letterforms {
+		renderedLetterforms[i] = letter(i == stretchIndex)
+	}
+
+	if spacing > 0 {
+		// Add spaces between the letters and render.
+		renderedLetterforms = slice.Intersperse(renderedLetterforms, strings.Repeat(" ", spacing))
+	}
+	return strings.TrimSpace(
+		lipgloss.JoinHorizontal(lipgloss.Top, renderedLetterforms...),
+	)
+}
+
+// letterC renders the letter C in a stylized way. It takes an integer that
+// determines how many cells to stretch the letter. If the stretch is less than
+// 1, it defaults to no stretching.
+func letterC(stretch bool) string {
+	// Here's what we're making:
+	//
+	// βββββ
+	// β
+	//	ββββ
+
+	left := heredoc.Doc(`
+		β
+		β
+	`)
+	right := heredoc.Doc(`
+		β
+
+		β
+	`)
+	return joinLetterform(
+		left,
+		stretchLetterformPart(right, letterformProps{
+			stretch:    stretch,
+			width:      4,
+			minStretch: 7,
+			maxStretch: 12,
+		}),
+	)
+}
+
+// letterH renders the letter H in a stylized way. It takes an integer that
+// determines how many cells to stretch the letter. If the stretch is less than
+// 1, it defaults to no stretching.
+func letterH(stretch bool) string {
+	// Here's what we're making:
+	//
+	// β   β
+	// βββββ
+	// β   β
+
+	side := heredoc.Doc(`
+		β
+		β
+		β`)
+	middle := heredoc.Doc(`
+
+		β
+	`)
+	return joinLetterform(
+		side,
+		stretchLetterformPart(middle, letterformProps{
+			stretch:    stretch,
+			width:      3,
+			minStretch: 8,
+			maxStretch: 12,
+		}),
+		side,
+	)
+}
+
+// letterR renders the letter R in a stylized way. It takes an integer that
+// determines how many cells to stretch the letter. If the stretch is less than
+// 1, it defaults to no stretching.
+func letterR(stretch bool) string {
+	// Here's what we're making:
+	//
+	// βββββ
+	// βββββ
+	// β   β
+
+	left := heredoc.Doc(`
+		β
+		β
+		β
+	`)
+	center := heredoc.Doc(`
+		β
+		β
+	`)
+	right := heredoc.Doc(`
+		β
+		β
+		β
+	`)
+	return joinLetterform(
+		left,
+		stretchLetterformPart(center, letterformProps{
+			stretch:    stretch,
+			width:      3,
+			minStretch: 7,
+			maxStretch: 12,
+		}),
+		right,
+	)
+}
+
+// letterSStylized renders the letter S in a stylized way, more so than
+// [letterS]. It takes an integer that determines how many cells to stretch the
+// letter. If the stretch is less than 1, it defaults to no stretching.
+func letterSStylized(stretch bool) string {
+	// Here's what we're making:
+	//
+	// ββββββ
+	// ββββββ
+	// βββββ
+
+	left := heredoc.Doc(`
+		β
+		β
+		β
+	`)
+	center := heredoc.Doc(`
+		β
+		β
+		β
+	`)
+	right := heredoc.Doc(`
+		β
+		β
+	`)
+	return joinLetterform(
+		left,
+		stretchLetterformPart(center, letterformProps{
+			stretch:    stretch,
+			width:      3,
+			minStretch: 7,
+			maxStretch: 12,
+		}),
+		right,
+	)
+}
+
+// letterU renders the letter U in a stylized way. It takes an integer that
+// determines how many cells to stretch the letter. If the stretch is less than
+// 1, it defaults to no stretching.
+func letterU(stretch bool) string {
+	// Here's what we're making:
+	//
+	// β   β
+	// β   β
+	//	βββ
+
+	side := heredoc.Doc(`
+		β
+		β
+	`)
+	middle := heredoc.Doc(`
+
+
+		β
+	`)
+	return joinLetterform(
+		side,
+		stretchLetterformPart(middle, letterformProps{
+			stretch:    stretch,
+			width:      3,
+			minStretch: 7,
+			maxStretch: 12,
+		}),
+		side,
+	)
+}
+
+func joinLetterform(letters ...string) string {
+	return lipgloss.JoinHorizontal(lipgloss.Top, letters...)
+}
+
+// letterformProps defines letterform stretching properties.
+// for readability.
+type letterformProps struct {
+	width      int
+	minStretch int
+	maxStretch int
+	stretch    bool
+}
+
+// stretchLetterformPart is a helper function for letter stretching. If randomize
+// is false the minimum number will be used.
+func stretchLetterformPart(s string, p letterformProps) string {
+	if p.maxStretch < p.minStretch {
+		p.minStretch, p.maxStretch = p.maxStretch, p.minStretch
+	}
+	n := p.width
+	if p.stretch {
+		n = cachedRandN(p.maxStretch-p.minStretch) + p.minStretch //nolint:gosec
+	}
+	parts := make([]string, n)
+	for i := range parts {
+		parts[i] = s
+	}
+	return lipgloss.JoinHorizontal(lipgloss.Top, parts...)
+}
  
  
  
    
    @@ -0,0 +1,81 @@
+package model
+
+import (
+	tea "github.com/charmbracelet/bubbletea/v2"
+	"github.com/charmbracelet/crush/internal/ui/common"
+	"github.com/charmbracelet/crush/internal/ui/logo"
+	"github.com/charmbracelet/crush/internal/ui/styles"
+	"github.com/charmbracelet/crush/internal/version"
+	"github.com/charmbracelet/lipgloss/v2"
+)
+
+// SidebarModel is the model for the sidebar UI component.
+type SidebarModel struct {
+	com *common.Common
+
+	// width of the sidebar.
+	width int
+
+	// Cached rendered logo string.
+	logo string
+	// Cached cwd string.
+	cwd string
+
+	// TODO: lsp, files, session
+
+	// Whether to render the sidebar in compact mode.
+	compact bool
+}
+
+// NewSidebarModel creates a new SidebarModel instance.
+func NewSidebarModel(com *common.Common) *SidebarModel {
+	return &SidebarModel{
+		com:     com,
+		compact: true,
+		cwd:     com.Config.WorkingDir(),
+	}
+}
+
+// Init initializes the sidebar model.
+func (m *SidebarModel) Init() tea.Cmd {
+	return nil
+}
+
+// Update updates the sidebar model based on incoming messages.
+func (m *SidebarModel) Update(msg tea.Msg) (*SidebarModel, tea.Cmd) {
+	return m, nil
+}
+
+// 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
+	}
+
+	blocks := []string{
+		m.logo,
+	}
+
+	return s.Render(lipgloss.JoinVertical(
+		lipgloss.Top,
+		blocks...,
+	))
+}
+
+// SetWidth sets the width of the sidebar and updates the logo accordingly.
+func (m *SidebarModel) SetWidth(width int) {
+	m.logo = logoBlock(m.com.Styles, width)
+	m.width = width
+}
+
+func logoBlock(t *styles.Styles, width int) string {
+	return logo.Render(version.Version, true, logo.Opts{
+		FieldColor:   t.LogoFieldColor,
+		TitleColorA:  t.LogoTitleColorA,
+		TitleColorB:  t.LogoTitleColorB,
+		CharmColor:   t.LogoCharmColor,
+		VersionColor: t.LogoVersionColor,
+		Width:        max(0, width-2),
+	})
+}
  
  
  
    
    @@ -36,6 +36,7 @@ type UI struct {
 
 	chat   *ChatModel
 	editor *EditorModel
+	side   *SidebarModel
 	dialog *dialog.Overlay
 	help   help.Model
 
@@ -58,6 +59,7 @@ func New(com *common.Common, app *app.App) *UI {
 		dialog: dialog.NewOverlay(),
 		keyMap: DefaultKeyMap(),
 		editor: NewEditorModel(com, app),
+		side:   NewSidebarModel(com),
 		help:   help.New(),
 	}
 }
@@ -88,9 +90,7 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		}
 		return m, nil
 	case tea.WindowSizeMsg:
-		m.updateLayout(msg.Width, msg.Height)
-		m.editor.SetSize(m.layout.editor.Dx(), m.layout.editor.Dy())
-		m.help.Width = m.layout.help.Dx()
+		m.updateLayoutAndSize(msg.Width, msg.Height)
 	case tea.KeyPressMsg:
 		if m.dialog.HasDialogs() {
 			m.updateDialogs(msg, &cmds)
@@ -106,7 +106,7 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 				}
 			case key.Matches(msg, m.keyMap.Help):
 				m.help.ShowAll = !m.help.ShowAll
-				m.updateLayout(m.layout.area.Dx(), m.layout.area.Dy())
+				m.updateLayoutAndSize(m.layout.area.Dx(), m.layout.area.Dy())
 			case key.Matches(msg, m.keyMap.Quit):
 				if !m.dialog.ContainsDialog(dialog.QuitDialogID) {
 					m.dialog.AddDialog(dialog.NewQuit(m.com))
@@ -172,12 +172,8 @@ func (m *UI) View() tea.View {
 					Background(lipgloss.ANSIColor(rand.Intn(256))).
 					Render(" Main View "),
 			).X(chatRect.Min.X).Y(chatRect.Min.Y),
-			lipgloss.NewLayer(
-				lipgloss.NewStyle().Width(sideRect.Dx()).
-					Height(sideRect.Dy()).
-					Background(lipgloss.ANSIColor(rand.Intn(256))).
-					Render(" Side View "),
-			).X(sideRect.Min.X).Y(sideRect.Min.Y),
+			lipgloss.NewLayer(m.side.View()).
+				X(sideRect.Min.X).Y(sideRect.Min.Y),
 			lipgloss.NewLayer(m.editor.View()).
 				X(editRect.Min.X).Y(editRect.Min.Y),
 			lipgloss.NewLayer(m.help.View(helpKeyMap)).
@@ -244,9 +240,9 @@ func (m *UI) updateEditor(msg tea.KeyPressMsg, cmds *[]tea.Cmd) {
 	}
 }
 
-// updateLayout updates the layout based on the given terminal width and
-// height given in cells.
-func (m *UI) updateLayout(w, h int) {
+// updateLayoutAndSize updates the layout and sub-models sizes based on the
+// given terminal width and height given in cells.
+func (m *UI) updateLayoutAndSize(w, h int) {
 	// The screen area we're working with
 	area := image.Rect(0, 0, w, h)
 	helpKeyMap := m.focusedKeyMap()
@@ -284,6 +280,11 @@ func (m *UI) updateLayout(w, h int) {
 		sidebar: sideRect,
 		help:    helpRect,
 	}
+
+	// Update sub-model sizes
+	m.side.SetWidth(m.layout.sidebar.Dx())
+	m.editor.SetSize(m.layout.editor.Dx(), m.layout.editor.Dy())
+	m.help.Width = m.layout.help.Dx()
 }
 
 // layout defines the positioning of UI elements.