feat(ui): initial chat ui implementation

Ayman Bagabas created

Change summary

internal/ui/common/markdown.go |  27 ++
internal/ui/list/list.go       |  24 -
internal/ui/model/chat.go      | 331 +++++++++++++++++++++++++++++------
internal/ui/model/keys.go      |  29 +++
internal/ui/model/ui.go        | 145 ++++++++++-----
internal/ui/styles/styles.go   |  42 ++++
6 files changed, 472 insertions(+), 126 deletions(-)

Detailed changes

internal/ui/common/markdown.go 🔗

@@ -0,0 +1,27 @@
+package common
+
+import (
+	"github.com/charmbracelet/crush/internal/ui/styles"
+	"github.com/charmbracelet/glamour/v2"
+	gstyles "github.com/charmbracelet/glamour/v2/styles"
+)
+
+// MarkdownRenderer returns a glamour [glamour.TermRenderer] configured with
+// the given styles and width.
+func MarkdownRenderer(t *styles.Styles, width int) *glamour.TermRenderer {
+	r, _ := glamour.NewTermRenderer(
+		glamour.WithStyles(t.Markdown),
+		glamour.WithWordWrap(width),
+	)
+	return r
+}
+
+// PlainMarkdownRenderer returns a glamour [glamour.TermRenderer] with no colors
+// (plain text with structure) and the given width.
+func PlainMarkdownRenderer(width int) *glamour.TermRenderer {
+	r, _ := glamour.NewTermRenderer(
+		glamour.WithStyles(gstyles.ASCIIStyleConfig),
+		glamour.WithWordWrap(width),
+	)
+	return r
+}

internal/ui/list/list.go 🔗

@@ -5,6 +5,7 @@ import (
 
 	uv "github.com/charmbracelet/ultraviolet"
 	"github.com/charmbracelet/ultraviolet/screen"
+	"github.com/charmbracelet/x/exp/ordered"
 )
 
 // List is a scrollable list component that implements uv.Drawable.
@@ -160,7 +161,7 @@ func (l *List) renderViewport() string {
 		for i := range lines {
 			lines[i] = emptyLine
 		}
-		return strings.Join(lines, "\r\n")
+		return strings.Join(lines, "\n")
 	}
 	if srcEndY > len(buf.Lines) {
 		srcEndY = len(buf.Lines)
@@ -182,7 +183,7 @@ func (l *List) renderViewport() string {
 		lines[lineIdx] = emptyLine
 	}
 
-	return strings.Join(lines, "\r\n")
+	return strings.Join(lines, "\n")
 }
 
 // drawViewport draws the visible portion from master buffer to target screen.
@@ -199,18 +200,18 @@ func (l *List) drawViewport(scr uv.Screen, area uv.Rectangle) {
 	srcEndY := l.offset + area.Dy()
 
 	// Clamp to actual buffer bounds
-	if srcStartY >= len(buf.Lines) {
+	if srcStartY >= buf.Height() {
 		screen.ClearArea(scr, area)
 		return
 	}
-	if srcEndY > len(buf.Lines) {
-		srcEndY = len(buf.Lines)
+	if srcEndY > buf.Height() {
+		srcEndY = buf.Height()
 	}
 
 	// Copy visible lines to target screen
 	destY := area.Min.Y
 	for srcY := srcStartY; srcY < srcEndY && destY < area.Max.Y; srcY++ {
-		line := buf.Lines[srcY]
+		line := buf.Line(srcY)
 		destX := area.Min.X
 
 		for x := 0; x < len(line) && x < area.Dx() && destX < area.Max.X; x++ {
@@ -840,16 +841,7 @@ func (l *List) SelectedItemInView() bool {
 
 // clampOffset ensures offset is within valid bounds.
 func (l *List) clampOffset() {
-	maxOffset := l.totalHeight - l.height
-	if maxOffset < 0 {
-		maxOffset = 0
-	}
-
-	if l.offset < 0 {
-		l.offset = 0
-	} else if l.offset > maxOffset {
-		l.offset = maxOffset
-	}
+	l.offset = ordered.Clamp(l.offset, 0, l.totalHeight-l.height)
 }
 
 // focusSelectedItem focuses the currently selected item if it's focusable.

internal/ui/model/chat.go 🔗

@@ -1,86 +1,295 @@
 package model
 
 import (
-	"charm.land/bubbles/v2/key"
+	"fmt"
+	"strings"
+
 	tea "charm.land/bubbletea/v2"
-	"github.com/charmbracelet/crush/internal/app"
+	"github.com/charmbracelet/crush/internal/message"
+	"github.com/charmbracelet/crush/internal/tui/components/anim"
 	"github.com/charmbracelet/crush/internal/ui/common"
+	"github.com/charmbracelet/crush/internal/ui/list"
+	"github.com/charmbracelet/crush/internal/ui/styles"
+	uv "github.com/charmbracelet/ultraviolet"
+	"github.com/google/uuid"
+)
+
+// ChatAnimItem represents a chat animation item in the chat UI.
+type ChatAnimItem struct {
+	list.BaseFocusable
+	anim *anim.Anim
+}
+
+var (
+	_ list.Item      = (*ChatAnimItem)(nil)
+	_ list.Focusable = (*ChatAnimItem)(nil)
+)
+
+// NewChatAnimItem creates a new instance of [ChatAnimItem].
+func NewChatAnimItem(a *anim.Anim) *ChatAnimItem {
+	m := new(ChatAnimItem)
+	return m
+}
+
+// Init initializes the chat animation item.
+func (c *ChatAnimItem) Init() tea.Cmd {
+	return c.anim.Init()
+}
+
+// Step advances the animation by one step.
+func (c *ChatAnimItem) Step() tea.Cmd {
+	return c.anim.Step()
+}
+
+// SetLabel sets the label for the animation item.
+func (c *ChatAnimItem) SetLabel(label string) {
+	c.anim.SetLabel(label)
+}
+
+// Draw implements list.Item.
+func (c *ChatAnimItem) Draw(scr uv.Screen, area uv.Rectangle) {
+	styled := uv.NewStyledString(c.anim.View())
+	styled.Draw(scr, area)
+}
+
+// Height implements list.Item.
+func (c *ChatAnimItem) Height(int) int {
+	return 1
+}
+
+// ID implements list.Item.
+func (c *ChatAnimItem) ID() string {
+	return "anim"
+}
+
+// ChatNoContentItem represents a chat item with no content.
+type ChatNoContentItem struct {
+	*list.StringItem
+}
+
+// NewChatNoContentItem creates a new instance of [ChatNoContentItem].
+func NewChatNoContentItem(t *styles.Styles, id string) *ChatNoContentItem {
+	c := new(ChatNoContentItem)
+	c.StringItem = list.NewStringItem(id, "No message content").
+		WithFocusStyles(&t.Chat.NoContentMessage, &t.Chat.NoContentMessage)
+	return c
+}
+
+// ChatMessageItem represents a chat message item in the chat UI.
+type ChatMessageItem struct {
+	list.BaseFocusable
+	list.BaseHighlightable
+
+	item list.Item
+	msg  message.Message
+}
+
+var (
+	_ list.Item          = (*ChatMessageItem)(nil)
+	_ list.Focusable     = (*ChatMessageItem)(nil)
+	_ list.Highlightable = (*ChatMessageItem)(nil)
 )
 
-// ChatKeyMap defines key bindings for the chat model.
-type ChatKeyMap struct {
-	NewSession    key.Binding
-	AddAttachment key.Binding
-	Cancel        key.Binding
-	Tab           key.Binding
-	Details       key.Binding
-}
-
-// DefaultChatKeyMap returns the default key bindings for the chat model.
-func DefaultChatKeyMap() ChatKeyMap {
-	return ChatKeyMap{
-		NewSession: key.NewBinding(
-			key.WithKeys("ctrl+n"),
-			key.WithHelp("ctrl+n", "new session"),
-		),
-		AddAttachment: key.NewBinding(
-			key.WithKeys("ctrl+f"),
-			key.WithHelp("ctrl+f", "add attachment"),
-		),
-		Cancel: key.NewBinding(
-			key.WithKeys("esc", "alt+esc"),
-			key.WithHelp("esc", "cancel"),
-		),
-		Tab: key.NewBinding(
-			key.WithKeys("tab"),
-			key.WithHelp("tab", "change focus"),
-		),
-		Details: key.NewBinding(
-			key.WithKeys("ctrl+d"),
-			key.WithHelp("ctrl+d", "toggle details"),
-		),
+// NewChatMessageItem creates a new instance of [ChatMessageItem].
+func NewChatMessageItem(t *styles.Styles, msg message.Message) *ChatMessageItem {
+	c := new(ChatMessageItem)
+
+	switch msg.Role {
+	case message.User:
+		item := list.NewMarkdownItem(msg.ID, msg.Content().String()).
+			WithFocusStyles(&t.Chat.UserMessageFocused, &t.Chat.UserMessageBlurred)
+		item.SetHighlightStyle(list.LipglossStyleToCellStyler(t.TextSelection))
+		// TODO: Add attachments
+		c.item = item
+	default:
+		var thinkingContent string
+		content := msg.Content().String()
+		thinking := msg.IsThinking()
+		finished := msg.IsFinished()
+		finishedData := msg.FinishPart()
+		reasoningContent := msg.ReasoningContent()
+		reasoningThinking := strings.TrimSpace(reasoningContent.Thinking)
+
+		if finished && content == "" && finishedData.Reason == message.FinishReasonError {
+			tag := t.Chat.ErrorTag.Render("ERROR")
+			title := t.Chat.ErrorTitle.Render(finishedData.Message)
+			details := t.Chat.ErrorDetails.Render(finishedData.Details)
+			errContent := fmt.Sprintf("%s %s\n\n%s", tag, title, details)
+
+			item := list.NewStringItem(msg.ID, errContent).
+				WithFocusStyles(&t.Chat.AssistantMessageFocused, &t.Chat.AssistantMessageBlurred)
+
+			c.item = item
+
+			return c
+		}
+
+		if thinking || reasoningThinking != "" {
+			// TODO: animation item?
+			// TODO: thinking item
+			thinkingContent = reasoningThinking
+		} else if finished && content == "" && finishedData.Reason == message.FinishReasonCanceled {
+			content = "*Canceled*"
+		}
+
+		var parts []string
+		if thinkingContent != "" {
+			parts = append(parts, thinkingContent)
+		}
+
+		if content != "" {
+			if len(parts) > 0 {
+				parts = append(parts, "")
+			}
+			parts = append(parts, content)
+		}
+
+		item := list.NewMarkdownItem(msg.ID, strings.Join(parts, "\n")).
+			WithFocusStyles(&t.Chat.AssistantMessageFocused, &t.Chat.AssistantMessageBlurred)
+		item.SetHighlightStyle(list.LipglossStyleToCellStyler(t.TextSelection))
+
+		c.item = item
 	}
+
+	return c
+}
+
+// Draw implements list.Item.
+func (c *ChatMessageItem) Draw(scr uv.Screen, area uv.Rectangle) {
+	c.item.Draw(scr, area)
+}
+
+// Height implements list.Item.
+func (c *ChatMessageItem) Height(width int) int {
+	return c.item.Height(width)
+}
+
+// ID implements list.Item.
+func (c *ChatMessageItem) ID() string {
+	return c.item.ID()
+}
+
+// Chat represents the chat UI model that handles chat interactions and
+// messages.
+type Chat struct {
+	com  *common.Common
+	list *list.List
+}
+
+// NewChat creates a new instance of [Chat] that handles chat interactions and
+// messages.
+func NewChat(com *common.Common) *Chat {
+	l := list.New()
+	return &Chat{
+		com:  com,
+		list: l,
+	}
+}
+
+// Height returns the height of the chat view port.
+func (m *Chat) Height() int {
+	return m.list.Height()
+}
+
+// Draw renders the chat UI component to the screen and the given area.
+func (m *Chat) Draw(scr uv.Screen, area uv.Rectangle) {
+	m.list.Draw(scr, area)
+}
+
+// SetSize sets the size of the chat view port.
+func (m *Chat) SetSize(width, height int) {
+	m.list.SetSize(width, height)
+}
+
+// Len returns the number of items in the chat list.
+func (m *Chat) Len() int {
+	return m.list.Len()
 }
 
-// ChatModel represents the chat UI model.
-type ChatModel struct {
-	app *app.App
-	com *common.Common
+// PrependItem prepends a new item to the chat list.
+func (m *Chat) PrependItem(item list.Item) {
+	m.list.PrependItem(item)
+}
 
-	keyMap ChatKeyMap
+// AppendMessage appends a new message item to the chat list.
+func (m *Chat) AppendMessage(msg message.Message) {
+	if msg.ID == "" {
+		m.AppendItem(NewChatNoContentItem(m.com.Styles, uuid.NewString()))
+	} else {
+		m.AppendItem(NewChatMessageItem(m.com.Styles, msg))
+	}
 }
 
-// NewChatModel creates a new instance of ChatModel.
-func NewChatModel(com *common.Common, app *app.App) *ChatModel {
-	return &ChatModel{
-		app:    app,
-		com:    com,
-		keyMap: DefaultChatKeyMap(),
+// AppendItem appends a new item to the chat list.
+func (m *Chat) AppendItem(item list.Item) {
+	if m.Len() > 0 {
+		// Always add a spacer between messages
+		m.list.AppendItem(list.NewSpacerItem(uuid.NewString(), 1))
 	}
+	m.list.AppendItem(item)
+}
+
+// Focus sets the focus state of the chat component.
+func (m *Chat) Focus() {
+	m.list.Focus()
+}
+
+// Blur removes the focus state from the chat component.
+func (m *Chat) Blur() {
+	m.list.Blur()
+}
+
+// ScrollToTop scrolls the chat view to the top.
+func (m *Chat) ScrollToTop() {
+	m.list.ScrollToTop()
+}
+
+// ScrollToBottom scrolls the chat view to the bottom.
+func (m *Chat) ScrollToBottom() {
+	m.list.ScrollToBottom()
+}
+
+// ScrollBy scrolls the chat view by the given number of line deltas.
+func (m *Chat) ScrollBy(lines int) {
+	m.list.ScrollBy(lines)
+}
+
+// ScrollToSelected scrolls the chat view to the selected item.
+func (m *Chat) ScrollToSelected() {
+	m.list.ScrollToSelected()
+}
+
+// SelectedItemInView returns whether the selected item is currently in view.
+func (m *Chat) SelectedItemInView() bool {
+	return m.list.SelectedItemInView()
+}
+
+// SetSelectedIndex sets the selected message index in the chat list.
+func (m *Chat) SetSelectedIndex(index int) {
+	m.list.SetSelectedIndex(index)
 }
 
-// Init initializes the chat model.
-func (m *ChatModel) Init() tea.Cmd {
-	return nil
+// SelectPrev selects the previous message in the chat list.
+func (m *Chat) SelectPrev() {
+	m.list.SelectPrev()
 }
 
-// Update handles incoming messages and updates the chat model state.
-func (m *ChatModel) Update(msg tea.Msg) (*ChatModel, tea.Cmd) {
-	// Handle messages here
-	return m, nil
+// SelectNext selects the next message in the chat list.
+func (m *Chat) SelectNext() {
+	m.list.SelectNext()
 }
 
-// View renders the chat model's view.
-func (m *ChatModel) View() string {
-	return "Chat Model View"
+// HandleMouseDown handles mouse down events for the chat component.
+func (m *Chat) HandleMouseDown(x, y int) {
+	m.list.HandleMouseDown(x, y)
 }
 
-// ShortHelp returns a brief help view for the chat model.
-func (m *ChatModel) ShortHelp() []key.Binding {
-	return nil
+// HandleMouseUp handles mouse up events for the chat component.
+func (m *Chat) HandleMouseUp(x, y int) {
+	m.list.HandleMouseUp(x, y)
 }
 
-// FullHelp returns a detailed help view for the chat model.
-func (m *ChatModel) FullHelp() [][]key.Binding {
-	return nil
+// HandleMouseDrag handles mouse drag events for the chat component.
+func (m *Chat) HandleMouseDrag(x, y int) {
+	m.list.HandleMouseDrag(x, y)
 }

internal/ui/model/keys.go 🔗

@@ -17,6 +17,14 @@ type KeyMap struct {
 		DeleteAllAttachments key.Binding
 	}
 
+	Chat struct {
+		NewSession    key.Binding
+		AddAttachment key.Binding
+		Cancel        key.Binding
+		Tab           key.Binding
+		Details       key.Binding
+	}
+
 	Initialize struct {
 		Yes,
 		No,
@@ -106,6 +114,27 @@ func DefaultKeyMap() KeyMap {
 		key.WithHelp("ctrl+r+r", "delete all attachments"),
 	)
 
+	km.Chat.NewSession = key.NewBinding(
+		key.WithKeys("ctrl+n"),
+		key.WithHelp("ctrl+n", "new session"),
+	)
+	km.Chat.AddAttachment = key.NewBinding(
+		key.WithKeys("ctrl+f"),
+		key.WithHelp("ctrl+f", "add attachment"),
+	)
+	km.Chat.Cancel = key.NewBinding(
+		key.WithKeys("esc", "alt+esc"),
+		key.WithHelp("esc", "cancel"),
+	)
+	km.Chat.Tab = key.NewBinding(
+		key.WithKeys("tab"),
+		key.WithHelp("tab", "change focus"),
+	)
+	km.Chat.Details = key.NewBinding(
+		key.WithKeys("ctrl+d"),
+		key.WithHelp("ctrl+d", "toggle details"),
+	)
+
 	km.Initialize.Yes = key.NewBinding(
 		key.WithKeys("y", "Y"),
 		key.WithHelp("y", "yes"),

internal/ui/model/ui.go 🔗

@@ -2,7 +2,6 @@ package model
 
 import (
 	"context"
-	"fmt"
 	"image"
 	"math/rand"
 	"os"
@@ -22,7 +21,6 @@ import (
 	"github.com/charmbracelet/crush/internal/session"
 	"github.com/charmbracelet/crush/internal/ui/common"
 	"github.com/charmbracelet/crush/internal/ui/dialog"
-	"github.com/charmbracelet/crush/internal/ui/list"
 	"github.com/charmbracelet/crush/internal/ui/logo"
 	"github.com/charmbracelet/crush/internal/ui/styles"
 	"github.com/charmbracelet/crush/internal/version"
@@ -77,7 +75,6 @@ type UI struct {
 	keyMap KeyMap
 	keyenh tea.KeyboardEnhancementsMsg
 
-	chat   *list.List
 	dialog *dialog.Overlay
 	help   help.Model
 
@@ -100,6 +97,9 @@ type UI struct {
 	readyPlaceholder   string
 	workingPlaceholder string
 
+	// Chat components
+	chat *Chat
+
 	// onboarding state
 	onboarding struct {
 		yesInitializeSelected bool
@@ -113,6 +113,9 @@ type UI struct {
 
 	// sidebarLogo keeps a cached version of the sidebar sidebarLogo.
 	sidebarLogo string
+
+	// Canvas for rendering
+	canvas *uv.ScreenBuffer
 }
 
 // New creates a new instance of the [UI] model.
@@ -125,7 +128,11 @@ func New(com *common.Common) *UI {
 	ta.SetVirtualCursor(false)
 	ta.Focus()
 
-	l := list.New()
+	ch := NewChat(com)
+
+	// TODO: Switch to lipgloss.Canvas when available
+	canvas := uv.NewScreenBuffer(0, 0)
+	canvas.Method = ansi.GraphemeWidth
 
 	ui := &UI{
 		com:      com,
@@ -135,7 +142,8 @@ func New(com *common.Common) *UI {
 		focus:    uiFocusNone,
 		state:    uiConfigure,
 		textarea: ta,
-		chat:     l,
+		chat:     ch,
+		canvas:   &canvas,
 	}
 
 	// set onboarding state defaults
@@ -167,6 +175,10 @@ func (m *UI) Init() tea.Cmd {
 	if m.QueryVersion {
 		cmds = append(cmds, tea.RequestTerminalVersion)
 	}
+	allSessions, _ := m.com.App.Sessions.List(context.Background())
+	if len(allSessions) > 0 {
+		cmds = append(cmds, m.loadSession(allSessions[0].ID))
+	}
 	return tea.Batch(cmds...)
 }
 
@@ -182,6 +194,12 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	case sessionLoadedMsg:
 		m.state = uiChat
 		m.session = &msg.sess
+		// Load the last 20 messages from this session.
+		msgs, _ := m.com.App.Messages.List(context.Background(), m.session.ID)
+		for _, message := range msgs {
+			m.chat.AppendMessage(message)
+		}
+		m.chat.ScrollToBottom()
 	case sessionFilesLoadedMsg:
 		m.sessionFiles = msg.files
 	case pubsub.Event[history.File]:
@@ -200,13 +218,53 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	case tea.WindowSizeMsg:
 		m.width, m.height = msg.Width, msg.Height
 		m.updateLayoutAndSize()
-		m.chat.ScrollToBottom()
+		m.canvas.Resize(msg.Width, msg.Height)
 	case tea.KeyboardEnhancementsMsg:
 		m.keyenh = msg
 		if msg.SupportsKeyDisambiguation() {
 			m.keyMap.Models.SetHelp("ctrl+m", "models")
 			m.keyMap.Editor.Newline.SetHelp("shift+enter", "newline")
 		}
+	case tea.MouseClickMsg:
+		switch m.state {
+		case uiChat:
+			m.chat.HandleMouseDown(msg.X, msg.Y)
+		}
+
+	case tea.MouseMotionMsg:
+		switch m.state {
+		case uiChat:
+			if msg.Y <= 0 {
+				m.chat.ScrollBy(-1)
+			} else if msg.Y >= m.chat.Height()-1 {
+				m.chat.ScrollBy(1)
+			}
+			m.chat.HandleMouseDrag(msg.X, msg.Y)
+		}
+
+	case tea.MouseReleaseMsg:
+		switch m.state {
+		case uiChat:
+			m.chat.HandleMouseUp(msg.X, msg.Y)
+		}
+	case tea.MouseWheelMsg:
+		switch m.state {
+		case uiChat:
+			switch msg.Button {
+			case tea.MouseWheelUp:
+				m.chat.ScrollBy(-5)
+				if !m.chat.SelectedItemInView() {
+					m.chat.SelectPrev()
+					m.chat.ScrollToSelected()
+				}
+			case tea.MouseWheelDown:
+				m.chat.ScrollBy(5)
+				if !m.chat.SelectedItemInView() {
+					m.chat.SelectNext()
+					m.chat.ScrollToSelected()
+				}
+			}
+		}
 	case tea.KeyPressMsg:
 		cmds = append(cmds, m.handleKeyPressMsg(msg)...)
 	}
@@ -243,32 +301,18 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) (cmds []tea.Cmd) {
 	}
 
 	switch {
-	case msg.String() == "ctrl+shift+t":
-		m.chat.SelectPrev()
-	case msg.String() == "ctrl+t":
-		m.focus = uiFocusMain
-		m.state = uiChat
-		if m.chat.Len() > 0 {
-			m.chat.AppendItem(list.Gap)
-		}
-		m.chat.AppendItem(
-			list.NewStringItem(
-				fmt.Sprintf("%d", m.chat.Len()),
-				fmt.Sprintf("Welcome to Crush Chat! %d", rand.Intn(1000)),
-			).WithFocusStyles(&m.com.Styles.BorderFocus, &m.com.Styles.BorderBlur),
-		)
-		m.chat.SetSelectedIndex(m.chat.Len() - 1)
-		m.chat.Focus()
-		m.chat.ScrollToBottom()
 	case key.Matches(msg, m.keyMap.Tab):
 		switch m.state {
 		case uiChat:
 			if m.focus == uiFocusMain {
 				m.focus = uiFocusEditor
 				cmds = append(cmds, m.textarea.Focus())
+				m.chat.Blur()
 			} else {
 				m.focus = uiFocusMain
 				m.textarea.Blur()
+				m.chat.Focus()
+				m.chat.SetSelectedIndex(m.chat.Len() - 1)
 			}
 		}
 	case key.Matches(msg, m.keyMap.Help):
@@ -298,65 +342,72 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) (cmds []tea.Cmd) {
 
 // Draw implements [tea.Layer] and draws the UI model.
 func (m *UI) Draw(scr uv.Screen, area uv.Rectangle) {
+	layout := generateLayout(m, area.Dx(), area.Dy())
+
+	if m.layout != layout {
+		m.layout = layout
+		m.updateSize()
+	}
+
 	// Clear the screen first
 	screen.Clear(scr)
 
 	switch m.state {
 	case uiConfigure:
 		header := uv.NewStyledString(m.header)
-		header.Draw(scr, m.layout.header)
+		header.Draw(scr, layout.header)
 
-		mainView := lipgloss.NewStyle().Width(m.layout.main.Dx()).
-			Height(m.layout.main.Dy()).
+		mainView := lipgloss.NewStyle().Width(layout.main.Dx()).
+			Height(layout.main.Dy()).
 			Background(lipgloss.ANSIColor(rand.Intn(256))).
 			Render(" Configure ")
 		main := uv.NewStyledString(mainView)
-		main.Draw(scr, m.layout.main)
+		main.Draw(scr, layout.main)
 
 	case uiInitialize:
 		header := uv.NewStyledString(m.header)
-		header.Draw(scr, m.layout.header)
+		header.Draw(scr, layout.header)
 
 		main := uv.NewStyledString(m.initializeView())
-		main.Draw(scr, m.layout.main)
+		main.Draw(scr, layout.main)
 
 	case uiLanding:
 		header := uv.NewStyledString(m.header)
-		header.Draw(scr, m.layout.header)
+		header.Draw(scr, layout.header)
 		main := uv.NewStyledString(m.landingView())
-		main.Draw(scr, m.layout.main)
+		main.Draw(scr, layout.main)
 
 		editor := uv.NewStyledString(m.textarea.View())
-		editor.Draw(scr, m.layout.editor)
+		editor.Draw(scr, layout.editor)
 
 	case uiChat:
 		header := uv.NewStyledString(m.header)
-		header.Draw(scr, m.layout.header)
-		m.drawSidebar(scr, m.layout.sidebar)
+		header.Draw(scr, layout.header)
+		m.drawSidebar(scr, layout.sidebar)
 
-		m.chat.Draw(scr, m.layout.main)
+		m.chat.Draw(scr, layout.main)
 
 		editor := uv.NewStyledString(m.textarea.View())
-		editor.Draw(scr, m.layout.editor)
+		editor.Draw(scr, layout.editor)
 
 	case uiChatCompact:
 		header := uv.NewStyledString(m.header)
-		header.Draw(scr, m.layout.header)
+		header.Draw(scr, layout.header)
 
-		mainView := lipgloss.NewStyle().Width(m.layout.main.Dx()).
-			Height(m.layout.main.Dy()).
+		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, m.layout.main)
+		main.Draw(scr, layout.main)
 
 		editor := uv.NewStyledString(m.textarea.View())
-		editor.Draw(scr, m.layout.editor)
+		editor.Draw(scr, layout.editor)
 	}
 
 	// Add help layer
 	help := uv.NewStyledString(m.help.View(m))
-	help.Draw(scr, m.layout.help)
+	help.Draw(scr, layout.help)
 
 	// Debugging rendering (visually see when the tui rerenders)
 	if os.Getenv("CRUSH_UI_DEBUG") == "true" {
@@ -401,14 +452,11 @@ func (m *UI) View() tea.View {
 	v.AltScreen = true
 	v.BackgroundColor = m.com.Styles.Background
 	v.Cursor = m.Cursor()
+	v.MouseMode = tea.MouseModeCellMotion
 
-	// TODO: Switch to lipgloss.Canvas when available
-	canvas := uv.NewScreenBuffer(m.width, m.height)
-	canvas.Method = ansi.GraphemeWidth
-
-	m.Draw(canvas, canvas.Bounds())
+	m.Draw(m.canvas, m.canvas.Bounds())
 
-	content := strings.ReplaceAll(canvas.Render(), "\r\n", "\n") // normalize newlines
+	content := strings.ReplaceAll(m.canvas.Render(), "\r\n", "\n") // normalize newlines
 	contentLines := strings.Split(content, "\n")
 	for i, line := range contentLines {
 		// Trim trailing spaces for concise rendering
@@ -680,6 +728,7 @@ func generateLayout(m *UI, w, h int) layout {
 		// 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

internal/ui/styles/styles.go 🔗

@@ -151,6 +151,20 @@ type Styles struct {
 		Additions lipgloss.Style
 		Deletions lipgloss.Style
 	}
+
+	// Chat
+	Chat struct {
+		UserMessageBlurred      lipgloss.Style
+		UserMessageFocused      lipgloss.Style
+		AssistantMessageBlurred lipgloss.Style
+		AssistantMessageFocused lipgloss.Style
+		NoContentMessage        lipgloss.Style
+		ThinkingMessage         lipgloss.Style
+
+		ErrorTag     lipgloss.Style
+		ErrorTitle   lipgloss.Style
+		ErrorDetails lipgloss.Style
+	}
 }
 
 func DefaultStyles() Styles {
@@ -194,12 +208,14 @@ func DefaultStyles() Styles {
 		greenDark = charmtone.Guac
 		// greenLight = charmtone.Bok
 
-		// red      = charmtone.Coral
+		red     = charmtone.Coral
 		redDark = charmtone.Sriracha
 		// redLight = charmtone.Salmon
 		// cherry   = charmtone.Cherry
 	)
 
+	normalBorder := lipgloss.NormalBorder()
+
 	base := lipgloss.NewStyle().Foreground(fgBase)
 
 	s := Styles{}
@@ -607,9 +623,33 @@ func DefaultStyles() Styles {
 	s.LSP.HintDiagnostic = s.Base.Foreground(fgHalfMuted)
 	s.LSP.InfoDiagnostic = s.Base.Foreground(info)
 
+	// Files
 	s.Files.Path = s.Muted
 	s.Files.Additions = s.Base.Foreground(greenDark)
 	s.Files.Deletions = s.Base.Foreground(redDark)
+
+	// Chat
+	messageFocussedBorder := lipgloss.Border{
+		Left: "▌",
+	}
+
+	s.Chat.NoContentMessage = lipgloss.NewStyle().Foreground(fgBase)
+	s.Chat.UserMessageBlurred = s.Chat.NoContentMessage.PaddingLeft(1).BorderLeft(true).
+		BorderForeground(primary).BorderStyle(normalBorder)
+	s.Chat.UserMessageFocused = s.Chat.NoContentMessage.PaddingLeft(1).BorderLeft(true).
+		BorderForeground(primary).BorderStyle(messageFocussedBorder)
+	s.Chat.AssistantMessageBlurred = s.Chat.NoContentMessage.PaddingLeft(2)
+	s.Chat.AssistantMessageFocused = s.Chat.NoContentMessage.PaddingLeft(1).BorderLeft(true).
+		BorderForeground(greenDark).BorderStyle(messageFocussedBorder)
+	s.Chat.ThinkingMessage = lipgloss.NewStyle().MaxHeight(10)
+	s.Chat.ErrorTag = lipgloss.NewStyle().Padding(0, 1).
+		Background(red).Foreground(white)
+	s.Chat.ErrorTitle = lipgloss.NewStyle().Foreground(fgHalfMuted)
+	s.Chat.ErrorDetails = lipgloss.NewStyle().Foreground(fgSubtle)
+
+	// Text selection.
+	s.TextSelection = lipgloss.NewStyle().Foreground(charmtone.Salt).Background(charmtone.Charple)
+
 	return s
 }