feat(ui): add text highlighting support to lazylist

Ayman Bagabas created

Change summary

internal/ui/lazylist/highlight.go | 289 +++++++++++++
internal/ui/lazylist/item.go      |  30 
internal/ui/lazylist/list.go      | 419 ++++++++++++++++++-
internal/ui/model/chat.go         | 193 --------
internal/ui/model/items.go        | 705 ++++++++++++++++++--------------
internal/ui/model/ui.go           |  26 
6 files changed, 1,121 insertions(+), 541 deletions(-)

Detailed changes

internal/ui/lazylist/highlight.go 🔗

@@ -0,0 +1,289 @@
+package lazylist
+
+import (
+	"image"
+
+	"charm.land/lipgloss/v2"
+	uv "github.com/charmbracelet/ultraviolet"
+)
+
+// DefaultHighlighter is the default highlighter function that applies inverse style.
+var DefaultHighlighter Highlighter = func(s uv.Style) uv.Style {
+	s.Attrs |= uv.AttrReverse
+	return s
+}
+
+// Highlighter represents a function that defines how to highlight text.
+type Highlighter func(uv.Style) uv.Style
+
+// Highlight highlights a region of text within the given content and region.
+func Highlight(content string, area image.Rectangle, startLine, startCol, endLine, endCol int, highlighter Highlighter) string {
+	if startLine < 0 || startCol < 0 {
+		return content
+	}
+
+	if highlighter == nil {
+		highlighter = DefaultHighlighter
+	}
+
+	width, height := area.Dx(), area.Dy()
+	buf := uv.NewScreenBuffer(width, height)
+	styled := uv.NewStyledString(content)
+	styled.Draw(&buf, area)
+
+	for y := startLine; y <= endLine && y < height; y++ {
+		if y >= buf.Height() {
+			break
+		}
+
+		line := buf.Line(y)
+
+		// Determine column range for this line
+		colStart := 0
+		if y == startLine {
+			colStart = min(startCol, len(line))
+		}
+
+		colEnd := len(line)
+		if y == endLine {
+			colEnd = min(endCol, len(line))
+		}
+
+		// Track last non-empty position as we go
+		lastContentX := -1
+
+		// Single pass: check content and track last non-empty position
+		for x := colStart; x < colEnd; x++ {
+			cell := line.At(x)
+			if cell == nil {
+				continue
+			}
+
+			// Update last content position if non-empty
+			if cell.Content != "" && cell.Content != " " {
+				lastContentX = x
+			}
+		}
+
+		// Only apply highlight up to last content position
+		highlightEnd := colEnd
+		if lastContentX >= 0 {
+			highlightEnd = lastContentX + 1
+		} else if lastContentX == -1 {
+			highlightEnd = colStart // No content on this line
+		}
+
+		// Apply highlight style only to cells with content
+		for x := colStart; x < highlightEnd; x++ {
+			if !image.Pt(x, y).In(area) {
+				continue
+			}
+			cell := line.At(x)
+			cell.Style = highlighter(cell.Style)
+		}
+	}
+
+	return buf.Render()
+}
+
+// RenderWithHighlight renders content with optional focus styling and highlighting.
+// This is a helper that combines common rendering logic for all items.
+// The content parameter should be the raw rendered content before focus styling.
+// The style parameter should come from CurrentStyle() and may be nil.
+// func (b *BaseHighlightable) RenderWithHighlight(content string, width int, style *lipgloss.Style) string {
+// 	// Apply focus/blur styling if configured
+// 	rendered := content
+// 	if style != nil {
+// 		rendered = style.Render(rendered)
+// 	}
+//
+// 	if !b.HasHighlight() {
+// 		return rendered
+// 	}
+//
+// 	height := lipgloss.Height(rendered)
+//
+// 	// Create temp buffer to draw content with highlighting
+// 	tempBuf := uv.NewScreenBuffer(width, height)
+//
+// 	// Draw the rendered content to temp buffer
+// 	styled := uv.NewStyledString(rendered)
+// 	styled.Draw(&tempBuf, uv.Rect(0, 0, width, height))
+//
+// 	// Apply highlighting if active
+// 	b.ApplyHighlight(&tempBuf, width, height, style)
+//
+// 	return tempBuf.Render()
+// }
+
+// ApplyHighlight applies highlighting to a screen buffer.
+// This should be called after drawing content to the buffer.
+// func (b *BaseHighlightable) ApplyHighlight(buf *uv.ScreenBuffer, width, height int, style *lipgloss.Style) {
+// 	if b.highlightStartLine < 0 {
+// 		return
+// 	}
+//
+// 	var (
+// 		topMargin, topBorder, topPadding          int
+// 		rightMargin, rightBorder, rightPadding    int
+// 		bottomMargin, bottomBorder, bottomPadding int
+// 		leftMargin, leftBorder, leftPadding       int
+// 	)
+// 	if style != nil {
+// 		topMargin, rightMargin, bottomMargin, leftMargin = style.GetMargin()
+// 		topBorder, rightBorder, bottomBorder, leftBorder = style.GetBorderTopSize(),
+// 			style.GetBorderRightSize(),
+// 			style.GetBorderBottomSize(),
+// 			style.GetBorderLeftSize()
+// 		topPadding, rightPadding, bottomPadding, leftPadding = style.GetPadding()
+// 	}
+//
+// 	slog.Info("Applying highlight",
+// 		"highlightStartLine", b.highlightStartLine,
+// 		"highlightStartCol", b.highlightStartCol,
+// 		"highlightEndLine", b.highlightEndLine,
+// 		"highlightEndCol", b.highlightEndCol,
+// 		"width", width,
+// 		"height", height,
+// 		"margins", fmt.Sprintf("%d,%d,%d,%d", topMargin, rightMargin, bottomMargin, leftMargin),
+// 		"borders", fmt.Sprintf("%d,%d,%d,%d", topBorder, rightBorder, bottomBorder, leftBorder),
+// 		"paddings", fmt.Sprintf("%d,%d,%d,%d", topPadding, rightPadding, bottomPadding, leftPadding),
+// 	)
+//
+// 	// Calculate content area offsets
+// 	contentArea := image.Rectangle{
+// 		Min: image.Point{
+// 			X: leftMargin + leftBorder + leftPadding,
+// 			Y: topMargin + topBorder + topPadding,
+// 		},
+// 		Max: image.Point{
+// 			X: width - (rightMargin + rightBorder + rightPadding),
+// 			Y: height - (bottomMargin + bottomBorder + bottomPadding),
+// 		},
+// 	}
+//
+// 	for y := b.highlightStartLine; y <= b.highlightEndLine && y < height; y++ {
+// 		if y >= buf.Height() {
+// 			break
+// 		}
+//
+// 		line := buf.Line(y)
+//
+// 		// Determine column range for this line
+// 		startCol := 0
+// 		if y == b.highlightStartLine {
+// 			startCol = min(b.highlightStartCol, len(line))
+// 		}
+//
+// 		endCol := len(line)
+// 		if y == b.highlightEndLine {
+// 			endCol = min(b.highlightEndCol, len(line))
+// 		}
+//
+// 		// Track last non-empty position as we go
+// 		lastContentX := -1
+//
+// 		// Single pass: check content and track last non-empty position
+// 		for x := startCol; x < endCol; x++ {
+// 			cell := line.At(x)
+// 			if cell == nil {
+// 				continue
+// 			}
+//
+// 			// Update last content position if non-empty
+// 			if cell.Content != "" && cell.Content != " " {
+// 				lastContentX = x
+// 			}
+// 		}
+//
+// 		// Only apply highlight up to last content position
+// 		highlightEnd := endCol
+// 		if lastContentX >= 0 {
+// 			highlightEnd = lastContentX + 1
+// 		} else if lastContentX == -1 {
+// 			highlightEnd = startCol // No content on this line
+// 		}
+//
+// 		// Apply highlight style only to cells with content
+// 		for x := startCol; x < highlightEnd; x++ {
+// 			if !image.Pt(x, y).In(contentArea) {
+// 				continue
+// 			}
+// 			cell := line.At(x)
+// 			cell.Style = b.highlightStyle(cell.Style)
+// 		}
+// 	}
+// }
+
+// ToHighlighter converts a [lipgloss.Style] to a [Highlighter].
+func ToHighlighter(lgStyle lipgloss.Style) Highlighter {
+	return func(uv.Style) uv.Style {
+		return ToStyle(lgStyle)
+	}
+}
+
+// ToStyle converts an inline [lipgloss.Style] to a [uv.Style].
+func ToStyle(lgStyle lipgloss.Style) uv.Style {
+	var uvStyle uv.Style
+
+	// Colors are already color.Color
+	uvStyle.Fg = lgStyle.GetForeground()
+	uvStyle.Bg = lgStyle.GetBackground()
+
+	// Build attributes using bitwise OR
+	var attrs uint8
+
+	if lgStyle.GetBold() {
+		attrs |= uv.AttrBold
+	}
+
+	if lgStyle.GetItalic() {
+		attrs |= uv.AttrItalic
+	}
+
+	if lgStyle.GetUnderline() {
+		uvStyle.Underline = uv.UnderlineSingle
+	}
+
+	if lgStyle.GetStrikethrough() {
+		attrs |= uv.AttrStrikethrough
+	}
+
+	if lgStyle.GetFaint() {
+		attrs |= uv.AttrFaint
+	}
+
+	if lgStyle.GetBlink() {
+		attrs |= uv.AttrBlink
+	}
+
+	if lgStyle.GetReverse() {
+		attrs |= uv.AttrReverse
+	}
+
+	uvStyle.Attrs = attrs
+
+	return uvStyle
+}
+
+// AdjustArea adjusts the given area rectangle by subtracting margins, borders,
+// and padding from the style.
+func AdjustArea(area image.Rectangle, style lipgloss.Style) image.Rectangle {
+	topMargin, rightMargin, bottomMargin, leftMargin := style.GetMargin()
+	topBorder, rightBorder, bottomBorder, leftBorder := style.GetBorderTopSize(),
+		style.GetBorderRightSize(),
+		style.GetBorderBottomSize(),
+		style.GetBorderLeftSize()
+	topPadding, rightPadding, bottomPadding, leftPadding := style.GetPadding()
+
+	return image.Rectangle{
+		Min: image.Point{
+			X: area.Min.X + leftMargin + leftBorder + leftPadding,
+			Y: area.Min.Y + topMargin + topBorder + topPadding,
+		},
+		Max: image.Point{
+			X: area.Max.X - (rightMargin + rightBorder + rightPadding),
+			Y: area.Max.Y - (bottomMargin + bottomBorder + bottomPadding),
+		},
+	}
+}

internal/ui/lazylist/item.go 🔗

@@ -1,5 +1,7 @@
 package lazylist
 
+import "charm.land/lipgloss/v2"
+
 // Item represents a single item in the lazy-loaded list.
 type Item interface {
 	// Render returns the string representation of the item for the given
@@ -7,24 +9,16 @@ type Item interface {
 	Render(width int) string
 }
 
-// Focusable represents an item that can gain or lose focus.
-type Focusable interface {
-	// Focus sets the focus state of the item.
-	Focus()
-
-	// Blur removes the focus state of the item.
-	Blur()
-
-	// Focused returns whether the item is focused.
-	Focused() bool
+// FocusStylable represents an item that can be styled based on focus state.
+type FocusStylable interface {
+	// FocusStyle returns the style to apply when the item is focused.
+	FocusStyle() lipgloss.Style
+	// BlurStyle returns the style to apply when the item is unfocused.
+	BlurStyle() lipgloss.Style
 }
 
-// Highlightable represents an item that can have a highlighted region.
-type Highlightable interface {
-	// SetHighlight sets the highlight region (startLine, startCol) to (endLine, endCol).
-	// Use -1 for all values to clear highlighting.
-	SetHighlight(startLine, startCol, endLine, endCol int)
-
-	// GetHighlight returns the current highlight region.
-	GetHighlight() (startLine, startCol, endLine, endCol int)
+// HighlightStylable represents an item that can be styled for highlighted regions.
+type HighlightStylable interface {
+	// HighlightStyle returns the style to apply for highlighted regions.
+	HighlightStyle() lipgloss.Style
 }

internal/ui/lazylist/list.go 🔗

@@ -1,8 +1,11 @@
 package lazylist
 
 import (
+	"image"
 	"log/slog"
 	"strings"
+
+	"charm.land/lipgloss/v2"
 )
 
 // List represents a list of items that can be lazily rendered. A list is
@@ -22,6 +25,16 @@ type List struct {
 	focused     bool
 	selectedIdx int // The current selected index -1 means no selection
 
+	// Mouse state
+	mouseDown       bool
+	mouseDownItem   int          // Item index where mouse was pressed
+	mouseDownX      int          // X position in item content (character offset)
+	mouseDownY      int          // Y position in item (line offset)
+	mouseDragItem   int          // Current item index being dragged over
+	mouseDragX      int          // Current X in item content
+	mouseDragY      int          // Current Y in item
+	lastHighlighted map[int]bool // Track which items were highlighted in last update
+
 	// Rendered content and cache
 	renderedItems map[int]renderedItem
 
@@ -44,6 +57,10 @@ func NewList(items ...Item) *List {
 	l := new(List)
 	l.items = items
 	l.renderedItems = make(map[int]renderedItem)
+	l.selectedIdx = -1
+	l.mouseDownItem = -1
+	l.mouseDragItem = -1
+	l.lastHighlighted = make(map[int]bool)
 	return l
 }
 
@@ -79,25 +96,98 @@ func (l *List) Len() int {
 
 // getItem renders (if needed) and returns the item at the given index.
 func (l *List) getItem(idx int) renderedItem {
+	return l.renderItem(idx, false)
+}
+
+// renderItem renders (if needed) and returns the item at the given index. If
+// process is true, it applies focus and highlight styling.
+func (l *List) renderItem(idx int, process bool) renderedItem {
 	if idx < 0 || idx >= len(l.items) {
 		return renderedItem{}
 	}
 
-	if item, ok := l.renderedItems[idx]; ok {
-		return item
+	var style lipgloss.Style
+	focusable, isFocusable := l.items[idx].(FocusStylable)
+	if isFocusable {
+		style = focusable.BlurStyle()
+		if l.focused && idx == l.selectedIdx {
+			style = focusable.FocusStyle()
+		}
 	}
 
-	item := l.items[idx]
-	rendered := item.Render(l.width)
-	height := countLines(rendered)
-	// slog.Info("Rendered item", "idx", idx, "height", height)
+	ri, ok := l.renderedItems[idx]
+	if !ok {
+		item := l.items[idx]
+		rendered := item.Render(l.width - style.GetHorizontalFrameSize())
+		height := countLines(rendered)
+
+		ri = renderedItem{
+			content: rendered,
+			height:  height,
+		}
+
+		l.renderedItems[idx] = ri
+	}
+
+	if !process {
+		return ri
+	}
+
+	// We apply highlighting before focus styling so that focus styling
+	// overrides highlight styles.
+	// Apply highlight if item supports it
+	if l.mouseDownItem >= 0 {
+		if highlightable, ok := l.items[idx].(HighlightStylable); ok {
+			startItemIdx, startLine, startCol, endItemIdx, endLine, endCol := l.getHighlightRange()
+			if idx >= startItemIdx && idx <= endItemIdx {
+				var sLine, sCol, eLine, eCol int
+				if idx == startItemIdx && idx == endItemIdx {
+					// Single item selection
+					sLine = startLine
+					sCol = startCol
+					eLine = endLine
+					eCol = endCol
+				} else if idx == startItemIdx {
+					// First item - from start position to end of item
+					sLine = startLine
+					sCol = startCol
+					eLine = ri.height - 1
+					eCol = 9999 // 9999 = end of line
+				} else if idx == endItemIdx {
+					// Last item - from start of item to end position
+					sLine = 0
+					sCol = 0
+					eLine = endLine
+					eCol = endCol
+				} else {
+					// Middle item - fully highlighted
+					sLine = 0
+					sCol = 0
+					eLine = ri.height - 1
+					eCol = 9999
+				}
 
-	ri := renderedItem{
-		content: rendered,
-		height:  height,
+				// Apply offset for styling frame
+				contentArea := image.Rect(0, 0, l.width, ri.height)
+
+				hiStyle := highlightable.HighlightStyle()
+				slog.Info("Highlighting item", "idx", idx,
+					"sLine", sLine, "sCol", sCol,
+					"eLine", eLine, "eCol", eCol,
+				)
+				rendered := Highlight(ri.content, contentArea, sLine, sCol, eLine, eCol, ToHighlighter(hiStyle))
+				ri.content = rendered
+			}
+		}
 	}
 
-	l.renderedItems[idx] = ri
+	if isFocusable {
+		// Apply focus/blur styling if needed
+		rendered := style.Render(ri.content)
+		height := countLines(rendered)
+		ri.content = rendered
+		ri.height = height
+	}
 
 	return ri
 }
@@ -242,8 +332,6 @@ func (l *List) Render() string {
 		return ""
 	}
 
-	slog.Info("Render", "offsetIdx", l.offsetIdx, "offsetLine", l.offsetLine, "width", l.width, "height", l.height)
-
 	var lines []string
 	currentIdx := l.offsetIdx
 	currentOffset := l.offsetLine
@@ -251,7 +339,7 @@ func (l *List) Render() string {
 	linesNeeded := l.height
 
 	for linesNeeded > 0 && currentIdx < len(l.items) {
-		item := l.getItem(currentIdx)
+		item := l.renderItem(currentIdx, true)
 		itemLines := strings.Split(item.content, "\n")
 		itemHeight := len(itemLines)
 
@@ -321,11 +409,12 @@ func (l *List) Focus() {
 		return
 	}
 
-	item := l.items[l.selectedIdx]
-	if focusable, ok := item.(Focusable); ok {
-		focusable.Focus()
-		l.invalidateItem(l.selectedIdx)
-	}
+	// item := l.items[l.selectedIdx]
+	// if focusable, ok := item.(Focusable); ok {
+	// 	focusable.Focus()
+	// 	l.items[l.selectedIdx] = focusable.(Item)
+	// 	l.invalidateItem(l.selectedIdx)
+	// }
 }
 
 // Blur removes the focus state from the list.
@@ -335,11 +424,12 @@ func (l *List) Blur() {
 		return
 	}
 
-	item := l.items[l.selectedIdx]
-	if focusable, ok := item.(Focusable); ok {
-		focusable.Blur()
-		l.invalidateItem(l.selectedIdx)
-	}
+	// item := l.items[l.selectedIdx]
+	// if focusable, ok := item.(Focusable); ok {
+	// 	focusable.Blur()
+	// 	l.items[l.selectedIdx] = focusable.(Item)
+	// 	l.invalidateItem(l.selectedIdx)
+	// }
 }
 
 // ScrollToTop scrolls the list to the top.
@@ -467,15 +557,292 @@ func (l *List) SelectLastInView() {
 }
 
 // HandleMouseDown handles mouse down events at the given line in the viewport.
-func (l *List) HandleMouseDown(x, y int) {
+// x and y are viewport-relative coordinates (0,0 = top-left of visible area).
+// Returns true if the event was handled.
+func (l *List) HandleMouseDown(x, y int) bool {
+	if len(l.items) == 0 {
+		return false
+	}
+
+	// Find which item was clicked
+	itemIdx, itemY := l.findItemAtY(x, y)
+	if itemIdx < 0 {
+		return false
+	}
+
+	l.mouseDown = true
+	l.mouseDownItem = itemIdx
+	l.mouseDownX = x
+	l.mouseDownY = itemY
+	l.mouseDragItem = itemIdx
+	l.mouseDragX = x
+	l.mouseDragY = itemY
+
+	// Select the clicked item
+	l.SetSelected(itemIdx)
+
+	return true
 }
 
 // HandleMouseUp handles mouse up events at the given line in the viewport.
-func (l *List) HandleMouseUp(x, y int) {
+// Returns true if the event was handled.
+func (l *List) HandleMouseUp(x, y int) bool {
+	if !l.mouseDown {
+		return false
+	}
+
+	l.mouseDown = false
+
+	return true
 }
 
 // HandleMouseDrag handles mouse drag events at the given line in the viewport.
-func (l *List) HandleMouseDrag(x, y int) {
+// x and y are viewport-relative coordinates.
+// Returns true if the event was handled.
+func (l *List) HandleMouseDrag(x, y int) bool {
+	if !l.mouseDown {
+		return false
+	}
+
+	if len(l.items) == 0 {
+		return false
+	}
+
+	// Find which item we're dragging over
+	itemIdx, itemY := l.findItemAtY(x, y)
+	if itemIdx < 0 {
+		return false
+	}
+
+	l.mouseDragItem = itemIdx
+	l.mouseDragX = x
+	l.mouseDragY = itemY
+
+	startItemIdx, startLine, startCol, endItemIdx, endLine, endCol := l.getHighlightRange()
+
+	slog.Info("HandleMouseDrag", "mouseDownItem", l.mouseDownItem,
+		"mouseDragItem", l.mouseDragItem,
+		"startItemIdx", startItemIdx,
+		"endItemIdx", endItemIdx,
+		"startLine", startLine,
+		"startCol", startCol,
+		"endLine", endLine,
+		"endCol", endCol,
+	)
+
+	// for i := startItemIdx; i <= endItemIdx; i++ {
+	// 	item := l.getItem(i)
+	// 	itemHi, ok := l.items[i].(Highlightable)
+	// 	if ok {
+	// 		if i == startItemIdx && i == endItemIdx {
+	// 			// Single item selection
+	// 			itemHi.SetHighlight(startLine, startCol, endLine, endCol)
+	// 		} else if i == startItemIdx {
+	// 			// First item - from start position to end of item
+	// 			itemHi.SetHighlight(startLine, startCol, item.height-1, 9999) // 9999 = end of line
+	// 		} else if i == endItemIdx {
+	// 			// Last item - from start of item to end position
+	// 			itemHi.SetHighlight(0, 0, endLine, endCol)
+	// 		} else {
+	// 			// Middle item - fully highlighted
+	// 			itemHi.SetHighlight(0, 0, item.height-1, 9999)
+	// 		}
+	//
+	// 		// Invalidate item to re-render
+	// 		l.items[i] = itemHi.(Item)
+	// 		l.invalidateItem(i)
+	// 	}
+	// }
+
+	// Update highlight if item supports it
+	// l.updateHighlight()
+
+	return true
+}
+
+// ClearHighlight clears any active text highlighting.
+func (l *List) ClearHighlight() {
+	// for i, item := range l.renderedItems {
+	// 	if !item.highlighted {
+	// 		continue
+	// 	}
+	// 	if h, ok := l.items[i].(Highlightable); ok {
+	// 		h.SetHighlight(-1, -1, -1, -1)
+	// 		l.items[i] = h.(Item)
+	// 		l.invalidateItem(i)
+	// 	}
+	// }
+	l.mouseDownItem = -1
+	l.mouseDragItem = -1
+	l.lastHighlighted = make(map[int]bool)
+}
+
+// findItemAtY finds the item at the given viewport y coordinate.
+// Returns the item index and the y offset within that item. It returns -1, -1
+// if no item is found.
+func (l *List) findItemAtY(_, y int) (itemIdx int, itemY int) {
+	if y < 0 || y >= l.height {
+		return -1, -1
+	}
+
+	// Walk through visible items to find which one contains this y
+	currentIdx := l.offsetIdx
+	currentLine := -l.offsetLine // Negative because offsetLine is how many lines are hidden
+
+	for currentIdx < len(l.items) && currentLine < l.height {
+		item := l.getItem(currentIdx)
+		itemEndLine := currentLine + item.height
+
+		// Check if y is within this item's visible range
+		if y >= currentLine && y < itemEndLine {
+			// Found the item, calculate itemY (offset within the item)
+			itemY = y - currentLine
+			return currentIdx, itemY
+		}
+
+		// Move to next item
+		currentLine = itemEndLine
+		if l.gap > 0 {
+			currentLine += l.gap
+		}
+		currentIdx++
+	}
+
+	return -1, -1
+}
+
+// getHighlightRange returns the current highlight range.
+func (l *List) getHighlightRange() (startItemIdx, startLine, startCol, endItemIdx, endLine, endCol int) {
+	if l.mouseDownItem < 0 {
+		return -1, -1, -1, -1, -1, -1
+	}
+
+	downItemIdx := l.mouseDownItem
+	dragItemIdx := l.mouseDragItem
+
+	// Determine selection direction
+	draggingDown := dragItemIdx > downItemIdx ||
+		(dragItemIdx == downItemIdx && l.mouseDragY > l.mouseDownY) ||
+		(dragItemIdx == downItemIdx && l.mouseDragY == l.mouseDownY && l.mouseDragX >= l.mouseDownX)
+
+	if draggingDown {
+		// Normal forward selection
+		startItemIdx = downItemIdx
+		startLine = l.mouseDownY
+		startCol = l.mouseDownX
+		endItemIdx = dragItemIdx
+		endLine = l.mouseDragY
+		endCol = l.mouseDragX
+	} else {
+		// Backward selection (dragging up)
+		startItemIdx = dragItemIdx
+		startLine = l.mouseDragY
+		startCol = l.mouseDragX
+		endItemIdx = downItemIdx
+		endLine = l.mouseDownY
+		endCol = l.mouseDownX
+	}
+
+	slog.Info("Apply highlight",
+		"startItemIdx", startItemIdx,
+		"endItemIdx", endItemIdx,
+		"startLine", startLine,
+		"startCol", startCol,
+		"endLine", endLine,
+		"endCol", endCol,
+	)
+
+	return startItemIdx, startLine, startCol, endItemIdx, endLine, endCol
+}
+
+// updateHighlight updates the highlight range for highlightable items.
+// Supports highlighting across multiple items and respects drag direction.
+func (l *List) updateHighlight() {
+	if l.mouseDownItem < 0 {
+		return
+	}
+
+	// Get start and end item indices
+	downItemIdx := l.mouseDownItem
+	dragItemIdx := l.mouseDragItem
+
+	// Determine selection direction
+	draggingDown := dragItemIdx > downItemIdx ||
+		(dragItemIdx == downItemIdx && l.mouseDragY > l.mouseDownY) ||
+		(dragItemIdx == downItemIdx && l.mouseDragY == l.mouseDownY && l.mouseDragX >= l.mouseDownX)
+
+	// Determine actual start and end based on direction
+	var startItemIdx, endItemIdx int
+	var startLine, startCol, endLine, endCol int
+
+	if draggingDown {
+		// Normal forward selection
+		startItemIdx = downItemIdx
+		endItemIdx = dragItemIdx
+		startLine = l.mouseDownY
+		startCol = l.mouseDownX
+		endLine = l.mouseDragY
+		endCol = l.mouseDragX
+	} else {
+		// Backward selection (dragging up)
+		startItemIdx = dragItemIdx
+		endItemIdx = downItemIdx
+		startLine = l.mouseDragY
+		startCol = l.mouseDragX
+		endLine = l.mouseDownY
+		endCol = l.mouseDownX
+	}
+
+	slog.Info("Update highlight", "startItemIdx", startItemIdx, "endItemIdx", endItemIdx,
+		"startLine", startLine, "startCol", startCol,
+		"endLine", endLine, "endCol", endCol,
+		"draggingDown", draggingDown,
+	)
+
+	// Track newly highlighted items
+	// newHighlighted := make(map[int]bool)
+
+	// Clear highlights on items that are no longer in range
+	// for i := range l.lastHighlighted {
+	// 	if i < startItemIdx || i > endItemIdx {
+	// 		if h, ok := l.items[i].(Highlightable); ok {
+	// 			h.SetHighlight(-1, -1, -1, -1)
+	// 			l.items[i] = h.(Item)
+	// 			l.invalidateItem(i)
+	// 		}
+	// 	}
+	// }
+
+	// Highlight all items in range
+	// for idx := startItemIdx; idx <= endItemIdx; idx++ {
+	// 	item, ok := l.items[idx].(Highlightable)
+	// 	if !ok {
+	// 		continue
+	// 	}
+	//
+	// 	renderedItem := l.getItem(idx)
+	//
+	// 	if idx == startItemIdx && idx == endItemIdx {
+	// 		// Single item selection
+	// 		item.SetHighlight(startLine, startCol, endLine, endCol)
+	// 	} else if idx == startItemIdx {
+	// 		// First item - from start position to end of item
+	// 		item.SetHighlight(startLine, startCol, renderedItem.height-1, 9999) // 9999 = end of line
+	// 	} else if idx == endItemIdx {
+	// 		// Last item - from start of item to end position
+	// 		item.SetHighlight(0, 0, endLine, endCol)
+	// 	} else {
+	// 		// Middle item - fully highlighted
+	// 		item.SetHighlight(0, 0, renderedItem.height-1, 9999)
+	// 	}
+	//
+	// 	l.items[idx] = item.(Item)
+	//
+	// 	l.invalidateItem(idx)
+	// 	newHighlighted[idx] = true
+	// }
+	//
+	// l.lastHighlighted = newHighlighted
 }
 
 // countLines counts the number of lines in a string.

internal/ui/model/chat.go 🔗

@@ -1,198 +1,11 @@
 package model
 
 import (
-	"fmt"
-	"strings"
-
-	tea "charm.land/bubbletea/v2"
-	"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/lazylist"
-	"github.com/charmbracelet/crush/internal/ui/list"
-	"github.com/charmbracelet/crush/internal/ui/styles"
 	uv "github.com/charmbracelet/ultraviolet"
 )
 
-// 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
-}
-
-// 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) *ChatNoContentItem {
-	c := new(ChatNoContentItem)
-	c.StringItem = list.NewStringItem("No message content").
-		WithFocusStyles(&t.Chat.Message.NoContent, &t.Chat.Message.NoContent)
-	return c
-}
-
-// ChatMessageItem represents a chat message item in the chat UI.
-type ChatMessageItem struct {
-	item list.Item
-	msg  message.Message
-}
-
-var (
-	_ list.Item          = (*ChatMessageItem)(nil)
-	_ list.Focusable     = (*ChatMessageItem)(nil)
-	_ list.Highlightable = (*ChatMessageItem)(nil)
-)
-
-// 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.Content().String()).
-			WithFocusStyles(&t.Chat.Message.UserFocused, &t.Chat.Message.UserBlurred)
-		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.Message.ErrorTag.Render("ERROR")
-			title := t.Chat.Message.ErrorTitle.Render(finishedData.Message)
-			details := t.Chat.Message.ErrorDetails.Render(finishedData.Details)
-			errContent := fmt.Sprintf("%s %s\n\n%s", tag, title, details)
-
-			item := list.NewStringItem(errContent).
-				WithFocusStyles(&t.Chat.Message.AssistantFocused, &t.Chat.Message.AssistantBlurred)
-
-			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(strings.Join(parts, "\n")).
-			WithFocusStyles(&t.Chat.Message.AssistantFocused, &t.Chat.Message.AssistantBlurred)
-		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)
-}
-
-// Blur implements list.Focusable.
-func (c *ChatMessageItem) Blur() {
-	if blurable, ok := c.item.(list.Focusable); ok {
-		blurable.Blur()
-	}
-}
-
-// Focus implements list.Focusable.
-func (c *ChatMessageItem) Focus() {
-	if focusable, ok := c.item.(list.Focusable); ok {
-		focusable.Focus()
-	}
-}
-
-// IsFocused implements list.Focusable.
-func (c *ChatMessageItem) IsFocused() bool {
-	if focusable, ok := c.item.(list.Focusable); ok {
-		return focusable.IsFocused()
-	}
-	return false
-}
-
-// GetHighlight implements list.Highlightable.
-func (c *ChatMessageItem) GetHighlight() (startLine int, startCol int, endLine int, endCol int) {
-	if highlightable, ok := c.item.(list.Highlightable); ok {
-		return highlightable.GetHighlight()
-	}
-	return 0, 0, 0, 0
-}
-
-// SetHighlight implements list.Highlightable.
-func (c *ChatMessageItem) SetHighlight(startLine int, startCol int, endLine int, endCol int) {
-	if highlightable, ok := c.item.(list.Highlightable); ok {
-		highlightable.SetHighlight(startLine, startCol, endLine, endCol)
-	}
-}
-
 // Chat represents the chat UI model that handles chat interactions and
 // messages.
 type Chat struct {
@@ -239,9 +52,11 @@ func (m *Chat) PrependItems(items ...lazylist.Item) {
 
 // AppendMessages appends a new message item to the chat list.
 func (m *Chat) AppendMessages(msgs ...MessageItem) {
-	for _, msg := range msgs {
-		m.AppendItems(msg)
+	items := make([]lazylist.Item, len(msgs))
+	for i, msg := range msgs {
+		items[i] = msg
 	}
+	m.list.AppendItems(items...)
 }
 
 // AppendItems appends new items to the chat list.

internal/ui/model/items.go 🔗

@@ -2,6 +2,8 @@ package model
 
 import (
 	"fmt"
+	"image"
+	"log/slog"
 	"path/filepath"
 	"strings"
 	"time"
@@ -14,7 +16,6 @@ import (
 	"github.com/charmbracelet/crush/internal/message"
 	"github.com/charmbracelet/crush/internal/ui/common"
 	"github.com/charmbracelet/crush/internal/ui/lazylist"
-	"github.com/charmbracelet/crush/internal/ui/list"
 	"github.com/charmbracelet/crush/internal/ui/styles"
 	"github.com/charmbracelet/crush/internal/ui/toolrender"
 )
@@ -25,38 +26,33 @@ type Identifiable interface {
 }
 
 // MessageItem represents a [message.Message] item that can be displayed in the
-// UI and be part of a [list.List] identifiable by a unique ID.
+// UI and be part of a [lazylist.List] identifiable by a unique ID.
 type MessageItem interface {
-	list.Item
-	list.Focusable
-	list.Highlightable
+	lazylist.Item
 	lazylist.Item
 	Identifiable
 }
 
 // MessageContentItem represents rendered message content (text, markdown, errors, etc).
 type MessageContentItem struct {
-	list.BaseFocusable
-	list.BaseHighlightable
 	id         string
 	content    string
+	role       message.MessageRole
 	isMarkdown bool
 	maxWidth   int
-	cache      map[int]string // Cache for rendered content at different widths
 	sty        *styles.Styles
 }
 
 // NewMessageContentItem creates a new message content item.
-func NewMessageContentItem(id, content string, isMarkdown bool, sty *styles.Styles) *MessageContentItem {
+func NewMessageContentItem(id, content string, role message.MessageRole, isMarkdown bool, sty *styles.Styles) *MessageContentItem {
 	m := &MessageContentItem{
 		id:         id,
 		content:    content,
 		isMarkdown: isMarkdown,
+		role:       role,
 		maxWidth:   120,
-		cache:      make(map[int]string),
 		sty:        sty,
 	}
-	m.InitHighlight()
 	return m
 }
 
@@ -65,76 +61,38 @@ func (m *MessageContentItem) ID() string {
 	return m.id
 }
 
-// Height implements list.Item.
-func (m *MessageContentItem) Height(width int) int {
-	// Calculate content width accounting for frame size
-	contentWidth := width
-	if style := m.CurrentStyle(); style != nil {
-		contentWidth -= style.GetHorizontalFrameSize()
-	}
-
-	rendered := m.render(contentWidth)
-
-	// Apply focus/blur styling if configured to get accurate height
-	if style := m.CurrentStyle(); style != nil {
-		rendered = style.Render(rendered)
+// FocusStyle returns the focus style.
+func (m *MessageContentItem) FocusStyle() lipgloss.Style {
+	if m.role == message.User {
+		return m.sty.Chat.Message.UserFocused
 	}
-
-	return strings.Count(rendered, "\n") + 1
+	return m.sty.Chat.Message.AssistantFocused
 }
 
-// Draw implements list.Item.
-func (m *MessageContentItem) Draw(scr uv.Screen, area uv.Rectangle) {
-	width := area.Dx()
-	height := area.Dy()
-
-	// Calculate content width accounting for frame size
-	contentWidth := width
-	style := m.CurrentStyle()
-	if style != nil {
-		contentWidth -= style.GetHorizontalFrameSize()
-	}
-
-	rendered := m.render(contentWidth)
-
-	// Apply focus/blur styling if configured
-	if style != nil {
-		rendered = style.Render(rendered)
+// BlurStyle returns the blur style.
+func (m *MessageContentItem) BlurStyle() lipgloss.Style {
+	if m.role == message.User {
+		return m.sty.Chat.Message.UserBlurred
 	}
-
-	// Create temp buffer to draw content with highlighting
-	tempBuf := uv.NewScreenBuffer(width, height)
-
-	// Draw the rendered content to temp buffer
-	styled := uv.NewStyledString(rendered)
-	styled.Draw(&tempBuf, uv.Rect(0, 0, width, height))
-
-	// Apply highlighting if active
-	m.ApplyHighlight(&tempBuf, width, height, style)
-
-	// Copy temp buffer to actual screen at the target area
-	tempBuf.Draw(scr, area)
+	return m.sty.Chat.Message.AssistantBlurred
 }
 
-// Render implements lazylist.Item.
-func (m *MessageContentItem) Render(width int) string {
-	return m.render(width)
+// HighlightStyle returns the highlight style.
+func (m *MessageContentItem) HighlightStyle() lipgloss.Style {
+	return m.sty.TextSelection
 }
 
-// render renders the content at the given width, using cache if available.
-func (m *MessageContentItem) render(width int) string {
+// Render renders the content at the given width, using cache if available.
+//
+// It implements [lazylist.Item].
+func (m *MessageContentItem) Render(width int) string {
+	contentWidth := width
 	// Cap width to maxWidth for markdown
-	cappedWidth := width
+	cappedWidth := contentWidth
 	if m.isMarkdown {
-		cappedWidth = min(width, m.maxWidth)
-	}
-
-	// Check cache first
-	if cached, ok := m.cache[cappedWidth]; ok {
-		return cached
+		cappedWidth = min(contentWidth, m.maxWidth)
 	}
 
-	// Not cached - render now
 	var rendered string
 	if m.isMarkdown {
 		renderer := common.MarkdownRenderer(m.sty, cappedWidth)
@@ -148,30 +106,19 @@ func (m *MessageContentItem) render(width int) string {
 		rendered = m.content
 	}
 
-	// Cache the result
-	m.cache[cappedWidth] = rendered
 	return rendered
 }
 
-// SetHighlight implements list.Highlightable and extends BaseHighlightable.
-func (m *MessageContentItem) SetHighlight(startLine, startCol, endLine, endCol int) {
-	m.BaseHighlightable.SetHighlight(startLine, startCol, endLine, endCol)
-	// Clear cache when highlight changes
-	m.cache = make(map[int]string)
-}
-
 // ToolCallItem represents a rendered tool call with its header and content.
 type ToolCallItem struct {
-	list.BaseFocusable
-	list.BaseHighlightable
+	BaseFocusable
+	BaseHighlightable
 	id         string
 	toolCall   message.ToolCall
 	toolResult message.ToolResult
 	cancelled  bool
 	isNested   bool
 	maxWidth   int
-	cache      map[int]cachedToolRender // Cache for rendered content at different widths
-	cacheKey   string                   // Key to invalidate cache when content changes
 	sty        *styles.Styles
 }
 
@@ -190,8 +137,6 @@ func NewToolCallItem(id string, toolCall message.ToolCall, toolResult message.To
 		cancelled:  cancelled,
 		isNested:   isNested,
 		maxWidth:   120,
-		cache:      make(map[int]cachedToolRender),
-		cacheKey:   generateCacheKey(toolCall, toolResult, cancelled),
 		sty:        sty,
 	}
 	t.InitHighlight()
@@ -209,116 +154,59 @@ func (t *ToolCallItem) ID() string {
 	return t.id
 }
 
-// Height implements list.Item.
-func (t *ToolCallItem) Height(width int) int {
-	// Calculate content width accounting for frame size
-	contentWidth := width
-	frameSize := 0
-	if style := t.CurrentStyle(); style != nil {
-		frameSize = style.GetHorizontalFrameSize()
-		contentWidth -= frameSize
-	}
-
-	cached := t.renderCached(contentWidth)
-
-	// Add frame size to height if needed
-	height := cached.height
-	if frameSize > 0 {
-		// Frame can add to height (borders, padding)
-		if style := t.CurrentStyle(); style != nil {
-			// Quick render to get accurate height with frame
-			rendered := style.Render(cached.content)
-			height = strings.Count(rendered, "\n") + 1
-		}
+// FocusStyle returns the focus style.
+func (t *ToolCallItem) FocusStyle() lipgloss.Style {
+	if t.focusStyle != nil {
+		return *t.focusStyle
 	}
-
-	return height
+	return lipgloss.Style{}
 }
 
-// Render implements lazylist.Item.
-func (t *ToolCallItem) Render(width int) string {
-	cached := t.renderCached(width)
-	return cached.content
-}
-
-// Draw implements list.Item.
-func (t *ToolCallItem) Draw(scr uv.Screen, area uv.Rectangle) {
-	width := area.Dx()
-	height := area.Dy()
-
-	// Calculate content width accounting for frame size
-	contentWidth := width
-	style := t.CurrentStyle()
-	if style != nil {
-		contentWidth -= style.GetHorizontalFrameSize()
+// BlurStyle returns the blur style.
+func (t *ToolCallItem) BlurStyle() lipgloss.Style {
+	if t.blurStyle != nil {
+		return *t.blurStyle
 	}
-
-	cached := t.renderCached(contentWidth)
-	rendered := cached.content
-
-	if style != nil {
-		rendered = style.Render(rendered)
-	}
-
-	tempBuf := uv.NewScreenBuffer(width, height)
-	styled := uv.NewStyledString(rendered)
-	styled.Draw(&tempBuf, uv.Rect(0, 0, width, height))
-
-	t.ApplyHighlight(&tempBuf, width, height, style)
-	tempBuf.Draw(scr, area)
+	return lipgloss.Style{}
 }
 
-// renderCached renders the tool call at the given width with caching.
-func (t *ToolCallItem) renderCached(width int) cachedToolRender {
-	cappedWidth := min(width, t.maxWidth)
-
-	// Check if we have a valid cache entry
-	if cached, ok := t.cache[cappedWidth]; ok {
-		return cached
-	}
+// HighlightStyle returns the highlight style.
+func (t *ToolCallItem) HighlightStyle() lipgloss.Style {
+	return t.sty.TextSelection
+}
 
+// Render implements lazylist.Item.
+func (t *ToolCallItem) Render(width int) string {
 	// Render the tool call
 	ctx := &toolrender.RenderContext{
 		Call:      t.toolCall,
 		Result:    t.toolResult,
 		Cancelled: t.cancelled,
 		IsNested:  t.isNested,
-		Width:     cappedWidth,
+		Width:     width,
 		Styles:    t.sty,
 	}
 
 	rendered := toolrender.Render(ctx)
-	height := strings.Count(rendered, "\n") + 1
+	return rendered
 
-	cached := cachedToolRender{
-		content: rendered,
-		height:  height,
-	}
-	t.cache[cappedWidth] = cached
-	return cached
+	// return t.RenderWithHighlight(rendered, width, style)
 }
 
 // SetHighlight implements list.Highlightable.
 func (t *ToolCallItem) SetHighlight(startLine, startCol, endLine, endCol int) {
 	t.BaseHighlightable.SetHighlight(startLine, startCol, endLine, endCol)
-	// Clear cache when highlight changes
-	t.cache = make(map[int]cachedToolRender)
 }
 
 // UpdateResult updates the tool result and invalidates the cache if needed.
 func (t *ToolCallItem) UpdateResult(result message.ToolResult) {
-	newKey := generateCacheKey(t.toolCall, result, t.cancelled)
-	if newKey != t.cacheKey {
-		t.toolResult = result
-		t.cacheKey = newKey
-		t.cache = make(map[int]cachedToolRender)
-	}
+	t.toolResult = result
 }
 
 // AttachmentItem represents a file attachment in a user message.
 type AttachmentItem struct {
-	list.BaseFocusable
-	list.BaseHighlightable
+	BaseFocusable
+	BaseHighlightable
 	id       string
 	filename string
 	path     string
@@ -342,33 +230,29 @@ func (a *AttachmentItem) ID() string {
 	return a.id
 }
 
-// Height implements list.Item.
-func (a *AttachmentItem) Height(width int) int {
-	return 1
+// FocusStyle returns the focus style.
+func (a *AttachmentItem) FocusStyle() lipgloss.Style {
+	if a.focusStyle != nil {
+		return *a.focusStyle
+	}
+	return lipgloss.Style{}
 }
 
-// Render implements lazylist.Item.
-func (a *AttachmentItem) Render(width int) string {
-	const maxFilenameWidth = 10
-	return a.sty.Chat.Message.Attachment.Render(fmt.Sprintf(
-		" %s %s ",
-		styles.DocumentIcon,
-		ansi.Truncate(a.filename, maxFilenameWidth, "..."),
-	))
+// BlurStyle returns the blur style.
+func (a *AttachmentItem) BlurStyle() lipgloss.Style {
+	if a.blurStyle != nil {
+		return *a.blurStyle
+	}
+	return lipgloss.Style{}
 }
 
-// Draw implements list.Item.
-func (a *AttachmentItem) Draw(scr uv.Screen, area uv.Rectangle) {
-	width := area.Dx()
-	height := area.Dy()
-
-	// Calculate content width accounting for frame size
-	contentWidth := width
-	style := a.CurrentStyle()
-	if style != nil {
-		contentWidth -= style.GetHorizontalFrameSize()
-	}
+// HighlightStyle returns the highlight style.
+func (a *AttachmentItem) HighlightStyle() lipgloss.Style {
+	return a.sty.TextSelection
+}
 
+// Render implements lazylist.Item.
+func (a *AttachmentItem) Render(width int) string {
 	const maxFilenameWidth = 10
 	content := a.sty.Chat.Message.Attachment.Render(fmt.Sprintf(
 		" %s %s ",
@@ -376,28 +260,18 @@ func (a *AttachmentItem) Draw(scr uv.Screen, area uv.Rectangle) {
 		ansi.Truncate(a.filename, maxFilenameWidth, "..."),
 	))
 
-	if style != nil {
-		content = style.Render(content)
-	}
+	return content
 
-	tempBuf := uv.NewScreenBuffer(width, height)
-	styled := uv.NewStyledString(content)
-	styled.Draw(&tempBuf, uv.Rect(0, 0, width, height))
-
-	a.ApplyHighlight(&tempBuf, width, height, style)
-	tempBuf.Draw(scr, area)
+	// return a.RenderWithHighlight(content, width, a.CurrentStyle())
 }
 
 // ThinkingItem represents thinking/reasoning content in assistant messages.
 type ThinkingItem struct {
-	list.BaseFocusable
-	list.BaseHighlightable
 	id       string
 	thinking string
 	duration time.Duration
 	finished bool
 	maxWidth int
-	cache    map[int]string
 	sty      *styles.Styles
 }
 
@@ -409,10 +283,8 @@ func NewThinkingItem(id, thinking string, duration time.Duration, finished bool,
 		duration: duration,
 		finished: finished,
 		maxWidth: 120,
-		cache:    make(map[int]string),
 		sty:      sty,
 	}
-	t.InitHighlight()
 	return t
 }
 
@@ -421,57 +293,25 @@ func (t *ThinkingItem) ID() string {
 	return t.id
 }
 
-// Height implements list.Item.
-func (t *ThinkingItem) Height(width int) int {
-	// Calculate content width accounting for frame size
-	contentWidth := width
-	if style := t.CurrentStyle(); style != nil {
-		contentWidth -= style.GetHorizontalFrameSize()
-	}
-
-	rendered := t.render(contentWidth)
-	return strings.Count(rendered, "\n") + 1
+// FocusStyle returns the focus style.
+func (t *ThinkingItem) FocusStyle() lipgloss.Style {
+	return t.sty.Chat.Message.AssistantFocused
 }
 
-// Render implements lazylist.Item.
-func (t *ThinkingItem) Render(width int) string {
-	return t.render(width)
+// BlurStyle returns the blur style.
+func (t *ThinkingItem) BlurStyle() lipgloss.Style {
+	return t.sty.Chat.Message.AssistantBlurred
 }
 
-// Draw implements list.Item.
-func (t *ThinkingItem) Draw(scr uv.Screen, area uv.Rectangle) {
-	width := area.Dx()
-	height := area.Dy()
-
-	// Calculate content width accounting for frame size
-	contentWidth := width
-	style := t.CurrentStyle()
-	if style != nil {
-		contentWidth -= style.GetHorizontalFrameSize()
-	}
-
-	rendered := t.render(contentWidth)
-
-	if style != nil {
-		rendered = style.Render(rendered)
-	}
-
-	tempBuf := uv.NewScreenBuffer(width, height)
-	styled := uv.NewStyledString(rendered)
-	styled.Draw(&tempBuf, uv.Rect(0, 0, width, height))
-
-	t.ApplyHighlight(&tempBuf, width, height, style)
-	tempBuf.Draw(scr, area)
+// HighlightStyle returns the highlight style.
+func (t *ThinkingItem) HighlightStyle() lipgloss.Style {
+	return t.sty.TextSelection
 }
 
-// render renders the thinking content.
-func (t *ThinkingItem) render(width int) string {
+// Render implements lazylist.Item.
+func (t *ThinkingItem) Render(width int) string {
 	cappedWidth := min(width, t.maxWidth)
 
-	if cached, ok := t.cache[cappedWidth]; ok {
-		return cached
-	}
-
 	renderer := common.PlainMarkdownRenderer(cappedWidth - 1)
 	rendered, err := renderer.Render(t.thinking)
 	if err != nil {
@@ -501,20 +341,11 @@ func (t *ThinkingItem) render(width int) string {
 
 	result := t.sty.PanelMuted.Width(cappedWidth).Padding(0, 1).Render(fullContent)
 
-	t.cache[cappedWidth] = result
 	return result
 }
 
-// SetHighlight implements list.Highlightable.
-func (t *ThinkingItem) SetHighlight(startLine, startCol, endLine, endCol int) {
-	t.BaseHighlightable.SetHighlight(startLine, startCol, endLine, endCol)
-	t.cache = make(map[int]string)
-}
-
 // SectionHeaderItem represents a section header (e.g., assistant info).
 type SectionHeaderItem struct {
-	list.BaseFocusable
-	list.BaseHighlightable
 	id              string
 	modelName       string
 	duration        time.Duration
@@ -531,7 +362,6 @@ func NewSectionHeaderItem(id, modelName string, duration time.Duration, sty *sty
 		isSectionHeader: true,
 		sty:             sty,
 	}
-	s.InitHighlight()
 	return s
 }
 
@@ -545,49 +375,25 @@ func (s *SectionHeaderItem) IsSectionHeader() bool {
 	return s.isSectionHeader
 }
 
-// Height implements list.Item.
-func (s *SectionHeaderItem) Height(width int) int {
-	return 1
+// FocusStyle returns the focus style.
+func (s *SectionHeaderItem) FocusStyle() lipgloss.Style {
+	return s.sty.Chat.Message.AssistantFocused
+}
+
+// BlurStyle returns the blur style.
+func (s *SectionHeaderItem) BlurStyle() lipgloss.Style {
+	return s.sty.Chat.Message.AssistantBlurred
 }
 
 // Render implements lazylist.Item.
 func (s *SectionHeaderItem) Render(width int) string {
-	return s.sty.Chat.Message.SectionHeader.Render(fmt.Sprintf("%s %s %s",
+	content := s.sty.Chat.Message.SectionHeader.Render(fmt.Sprintf("%s %s %s",
 		s.sty.Subtle.Render(styles.ModelIcon),
 		s.sty.Muted.Render(s.modelName),
 		s.sty.Subtle.Render(s.duration.String()),
 	))
-}
-
-// Draw implements list.Item.
-func (s *SectionHeaderItem) Draw(scr uv.Screen, area uv.Rectangle) {
-	width := area.Dx()
-	height := area.Dy()
-
-	// Calculate content width accounting for frame size
-	contentWidth := width
-	style := s.CurrentStyle()
-	if style != nil {
-		contentWidth -= style.GetHorizontalFrameSize()
-	}
-
-	infoMsg := s.sty.Subtle.Render(s.duration.String())
-	icon := s.sty.Subtle.Render(styles.ModelIcon)
-	modelFormatted := s.sty.Muted.Render(s.modelName)
-	content := fmt.Sprintf("%s %s %s", icon, modelFormatted, infoMsg)
-
-	content = s.sty.Chat.Message.SectionHeader.Render(content)
-
-	if style != nil {
-		content = style.Render(content)
-	}
 
-	tempBuf := uv.NewScreenBuffer(width, height)
-	styled := uv.NewStyledString(content)
-	styled.Draw(&tempBuf, uv.Rect(0, 0, width, height))
-
-	s.ApplyHighlight(&tempBuf, width, height, style)
-	tempBuf.Draw(scr, area)
+	return content
 }
 
 // GetMessageItems extracts [MessageItem]s from a [message.Message]. It returns
@@ -595,8 +401,7 @@ func (s *SectionHeaderItem) Draw(scr uv.Screen, area uv.Rectangle) {
 //
 // For assistant messages with tool calls, pass a toolResults map to link results.
 // Use BuildToolResultMap to create this map from all messages in a session.
-func GetMessageItems(msg *message.Message, toolResults map[string]message.ToolResult) []MessageItem {
-	sty := styles.DefaultStyles()
+func GetMessageItems(sty *styles.Styles, msg *message.Message, toolResults map[string]message.ToolResult) []MessageItem {
 	var items []MessageItem
 
 	// Skip tool result messages - they're displayed inline with tool calls
@@ -622,10 +427,10 @@ func GetMessageItems(msg *message.Message, toolResults map[string]message.ToolRe
 			item := NewMessageContentItem(
 				fmt.Sprintf("%s-content", msg.ID),
 				content,
+				msg.Role,
 				true, // User messages are markdown
-				&sty,
+				sty,
 			)
-			item.SetFocusStyles(&focusStyle, &blurStyle)
 			items = append(items, item)
 		}
 
@@ -636,8 +441,9 @@ func GetMessageItems(msg *message.Message, toolResults map[string]message.ToolRe
 				fmt.Sprintf("%s-attachment-%d", msg.ID, i),
 				filename,
 				attachment.Path,
-				&sty,
+				sty,
 			)
+			item.SetHighlightStyle(ToStyler(sty.TextSelection))
 			item.SetFocusStyles(&focusStyle, &blurStyle)
 			items = append(items, item)
 		}
@@ -666,7 +472,7 @@ func GetMessageItems(msg *message.Message, toolResults map[string]message.ToolRe
 				fmt.Sprintf("%s-header", msg.ID),
 				modelName,
 				duration,
-				&sty,
+				sty,
 			)
 			items = append(items, header)
 		}
@@ -684,9 +490,8 @@ func GetMessageItems(msg *message.Message, toolResults map[string]message.ToolRe
 				reasoning.Thinking,
 				duration,
 				reasoning.FinishedAt > 0,
-				&sty,
+				sty,
 			)
-			item.SetFocusStyles(&focusStyle, &blurStyle)
 			items = append(items, item)
 		}
 
@@ -703,10 +508,10 @@ func GetMessageItems(msg *message.Message, toolResults map[string]message.ToolRe
 				item := NewMessageContentItem(
 					fmt.Sprintf("%s-content", msg.ID),
 					"*Canceled*",
+					msg.Role,
 					true,
-					&sty,
+					sty,
 				)
-				item.SetFocusStyles(&focusStyle, &blurStyle)
 				items = append(items, item)
 			case message.FinishReasonError:
 				// Render error
@@ -719,20 +524,20 @@ func GetMessageItems(msg *message.Message, toolResults map[string]message.ToolRe
 				item := NewMessageContentItem(
 					fmt.Sprintf("%s-error", msg.ID),
 					errorContent,
+					msg.Role,
 					false,
-					&sty,
+					sty,
 				)
-				item.SetFocusStyles(&focusStyle, &blurStyle)
 				items = append(items, item)
 			}
 		} else if content != "" {
 			item := NewMessageContentItem(
 				fmt.Sprintf("%s-content", msg.ID),
 				content,
+				msg.Role,
 				true, // Assistant messages are markdown
-				&sty,
+				sty,
 			)
-			item.SetFocusStyles(&focusStyle, &blurStyle)
 			items = append(items, item)
 		}
 
@@ -757,9 +562,11 @@ func GetMessageItems(msg *message.Message, toolResults map[string]message.ToolRe
 				result,
 				false, // cancelled state would need to be tracked separately
 				false, // nested state would be detected from tool results
-				&sty,
+				sty,
 			)
 
+			item.SetHighlightStyle(ToStyler(sty.TextSelection))
+
 			// Tool calls use muted style with optional focus border
 			item.SetFocusStyles(&sty.Chat.Message.ToolCallFocused, &sty.Chat.Message.ToolCallBlurred)
 
@@ -788,3 +595,299 @@ func BuildToolResultMap(messages []*message.Message) map[string]message.ToolResu
 	}
 	return resultMap
 }
+
+// BaseFocusable provides common focus state and styling for items.
+// Embed this type to add focus behavior to any item.
+type BaseFocusable struct {
+	focused    bool
+	focusStyle *lipgloss.Style
+	blurStyle  *lipgloss.Style
+}
+
+// Focus implements Focusable interface.
+func (b *BaseFocusable) Focus(width int, content string) string {
+	if b.focusStyle != nil {
+		return b.focusStyle.Render(content)
+	}
+	return content
+}
+
+// Blur implements Focusable interface.
+func (b *BaseFocusable) Blur(width int, content string) string {
+	if b.blurStyle != nil {
+		return b.blurStyle.Render(content)
+	}
+	return content
+}
+
+// Focus implements Focusable interface.
+// func (b *BaseFocusable) Focus() {
+// 	b.focused = true
+// }
+
+// Blur implements Focusable interface.
+// func (b *BaseFocusable) Blur() {
+// 	b.focused = false
+// }
+
+// Focused implements Focusable interface.
+func (b *BaseFocusable) Focused() bool {
+	return b.focused
+}
+
+// HasFocusStyles returns true if both focus and blur styles are configured.
+func (b *BaseFocusable) HasFocusStyles() bool {
+	return b.focusStyle != nil && b.blurStyle != nil
+}
+
+// CurrentStyle returns the current style based on focus state.
+// Returns nil if no styles are configured, or if the current state's style is nil.
+func (b *BaseFocusable) CurrentStyle() *lipgloss.Style {
+	if b.focused {
+		return b.focusStyle
+	}
+	return b.blurStyle
+}
+
+// SetFocusStyles sets the focus and blur styles.
+func (b *BaseFocusable) SetFocusStyles(focusStyle, blurStyle *lipgloss.Style) {
+	b.focusStyle = focusStyle
+	b.blurStyle = blurStyle
+}
+
+// CellStyler defines a function that styles a [uv.Style].
+type CellStyler func(uv.Style) uv.Style
+
+// BaseHighlightable provides common highlight state for items.
+// Embed this type to add highlight behavior to any item.
+type BaseHighlightable struct {
+	highlightStartLine int
+	highlightStartCol  int
+	highlightEndLine   int
+	highlightEndCol    int
+	highlightStyle     CellStyler
+}
+
+// SetHighlight implements Highlightable interface.
+func (b *BaseHighlightable) SetHighlight(startLine, startCol, endLine, endCol int) {
+	b.highlightStartLine = startLine
+	b.highlightStartCol = startCol
+	b.highlightEndLine = endLine
+	b.highlightEndCol = endCol
+}
+
+// GetHighlight implements Highlightable interface.
+func (b *BaseHighlightable) GetHighlight() (startLine, startCol, endLine, endCol int) {
+	return b.highlightStartLine, b.highlightStartCol, b.highlightEndLine, b.highlightEndCol
+}
+
+// HasHighlight returns true if a highlight region is set.
+func (b *BaseHighlightable) HasHighlight() bool {
+	return b.highlightStartLine >= 0 || b.highlightStartCol >= 0 ||
+		b.highlightEndLine >= 0 || b.highlightEndCol >= 0
+}
+
+// SetHighlightStyle sets the style function used for highlighting.
+func (b *BaseHighlightable) SetHighlightStyle(style CellStyler) {
+	b.highlightStyle = style
+}
+
+// GetHighlightStyle returns the current highlight style function.
+func (b *BaseHighlightable) GetHighlightStyle() CellStyler {
+	return b.highlightStyle
+}
+
+// InitHighlight initializes the highlight fields with default values.
+func (b *BaseHighlightable) InitHighlight() {
+	b.highlightStartLine = -1
+	b.highlightStartCol = -1
+	b.highlightEndLine = -1
+	b.highlightEndCol = -1
+	b.highlightStyle = ToStyler(lipgloss.NewStyle().Reverse(true))
+}
+
+// Highlight implements Highlightable interface.
+func (b *BaseHighlightable) Highlight(width int, content string, startLine, startCol, endLine, endCol int) string {
+	b.SetHighlight(startLine, startCol, endLine, endCol)
+	return b.RenderWithHighlight(content, width, nil)
+}
+
+// RenderWithHighlight renders content with optional focus styling and highlighting.
+// This is a helper that combines common rendering logic for all items.
+// The content parameter should be the raw rendered content before focus styling.
+// The style parameter should come from CurrentStyle() and may be nil.
+func (b *BaseHighlightable) RenderWithHighlight(content string, width int, style *lipgloss.Style) string {
+	// Apply focus/blur styling if configured
+	rendered := content
+	if style != nil {
+		rendered = style.Render(rendered)
+	}
+
+	if !b.HasHighlight() {
+		return rendered
+	}
+
+	height := lipgloss.Height(rendered)
+
+	// Create temp buffer to draw content with highlighting
+	tempBuf := uv.NewScreenBuffer(width, height)
+
+	// Draw the rendered content to temp buffer
+	styled := uv.NewStyledString(rendered)
+	styled.Draw(&tempBuf, uv.Rect(0, 0, width, height))
+
+	// Apply highlighting if active
+	b.ApplyHighlight(&tempBuf, width, height, style)
+
+	return tempBuf.Render()
+}
+
+// ApplyHighlight applies highlighting to a screen buffer.
+// This should be called after drawing content to the buffer.
+func (b *BaseHighlightable) ApplyHighlight(buf *uv.ScreenBuffer, width, height int, style *lipgloss.Style) {
+	if b.highlightStartLine < 0 {
+		return
+	}
+
+	var (
+		topMargin, topBorder, topPadding          int
+		rightMargin, rightBorder, rightPadding    int
+		bottomMargin, bottomBorder, bottomPadding int
+		leftMargin, leftBorder, leftPadding       int
+	)
+	if style != nil {
+		topMargin, rightMargin, bottomMargin, leftMargin = style.GetMargin()
+		topBorder, rightBorder, bottomBorder, leftBorder = style.GetBorderTopSize(),
+			style.GetBorderRightSize(),
+			style.GetBorderBottomSize(),
+			style.GetBorderLeftSize()
+		topPadding, rightPadding, bottomPadding, leftPadding = style.GetPadding()
+	}
+
+	slog.Info("Applying highlight",
+		"highlightStartLine", b.highlightStartLine,
+		"highlightStartCol", b.highlightStartCol,
+		"highlightEndLine", b.highlightEndLine,
+		"highlightEndCol", b.highlightEndCol,
+		"width", width,
+		"height", height,
+		"margins", fmt.Sprintf("%d,%d,%d,%d", topMargin, rightMargin, bottomMargin, leftMargin),
+		"borders", fmt.Sprintf("%d,%d,%d,%d", topBorder, rightBorder, bottomBorder, leftBorder),
+		"paddings", fmt.Sprintf("%d,%d,%d,%d", topPadding, rightPadding, bottomPadding, leftPadding),
+	)
+
+	// Calculate content area offsets
+	contentArea := image.Rectangle{
+		Min: image.Point{
+			X: leftMargin + leftBorder + leftPadding,
+			Y: topMargin + topBorder + topPadding,
+		},
+		Max: image.Point{
+			X: width - (rightMargin + rightBorder + rightPadding),
+			Y: height - (bottomMargin + bottomBorder + bottomPadding),
+		},
+	}
+
+	for y := b.highlightStartLine; y <= b.highlightEndLine && y < height; y++ {
+		if y >= buf.Height() {
+			break
+		}
+
+		line := buf.Line(y)
+
+		// Determine column range for this line
+		startCol := 0
+		if y == b.highlightStartLine {
+			startCol = min(b.highlightStartCol, len(line))
+		}
+
+		endCol := len(line)
+		if y == b.highlightEndLine {
+			endCol = min(b.highlightEndCol, len(line))
+		}
+
+		// Track last non-empty position as we go
+		lastContentX := -1
+
+		// Single pass: check content and track last non-empty position
+		for x := startCol; x < endCol; x++ {
+			cell := line.At(x)
+			if cell == nil {
+				continue
+			}
+
+			// Update last content position if non-empty
+			if cell.Content != "" && cell.Content != " " {
+				lastContentX = x
+			}
+		}
+
+		// Only apply highlight up to last content position
+		highlightEnd := endCol
+		if lastContentX >= 0 {
+			highlightEnd = lastContentX + 1
+		} else if lastContentX == -1 {
+			highlightEnd = startCol // No content on this line
+		}
+
+		// Apply highlight style only to cells with content
+		for x := startCol; x < highlightEnd; x++ {
+			if !image.Pt(x, y).In(contentArea) {
+				continue
+			}
+			cell := line.At(x)
+			cell.Style = b.highlightStyle(cell.Style)
+		}
+	}
+}
+
+// ToStyler converts a [lipgloss.Style] to a [CellStyler].
+func ToStyler(lgStyle lipgloss.Style) CellStyler {
+	return func(uv.Style) uv.Style {
+		return ToStyle(lgStyle)
+	}
+}
+
+// ToStyle converts an inline [lipgloss.Style] to a [uv.Style].
+func ToStyle(lgStyle lipgloss.Style) uv.Style {
+	var uvStyle uv.Style
+
+	// Colors are already color.Color
+	uvStyle.Fg = lgStyle.GetForeground()
+	uvStyle.Bg = lgStyle.GetBackground()
+
+	// Build attributes using bitwise OR
+	var attrs uint8
+
+	if lgStyle.GetBold() {
+		attrs |= uv.AttrBold
+	}
+
+	if lgStyle.GetItalic() {
+		attrs |= uv.AttrItalic
+	}
+
+	if lgStyle.GetUnderline() {
+		uvStyle.Underline = uv.UnderlineSingle
+	}
+
+	if lgStyle.GetStrikethrough() {
+		attrs |= uv.AttrStrikethrough
+	}
+
+	if lgStyle.GetFaint() {
+		attrs |= uv.AttrFaint
+	}
+
+	if lgStyle.GetBlink() {
+		attrs |= uv.AttrBlink
+	}
+
+	if lgStyle.GetReverse() {
+		attrs |= uv.AttrReverse
+	}
+
+	uvStyle.Attrs = attrs
+
+	return uvStyle
+}

internal/ui/model/ui.go 🔗

@@ -7,7 +7,6 @@ import (
 	"os"
 	"slices"
 	"strings"
-	"time"
 
 	"charm.land/bubbles/v2/help"
 	"charm.land/bubbles/v2/key"
@@ -171,8 +170,8 @@ func (m *UI) Init() tea.Cmd {
 	allSessions, _ := m.com.App.Sessions.List(context.Background())
 	if len(allSessions) > 0 {
 		cmds = append(cmds, func() tea.Msg {
-			time.Sleep(2 * time.Second)
-			return m.loadSession(allSessions[1].ID)()
+			// time.Sleep(2 * time.Second)
+			return m.loadSession(allSessions[0].ID)()
 		})
 	}
 	return tea.Batch(cmds...)
@@ -207,7 +206,7 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		// Add messages to chat with linked tool results
 		items := make([]MessageItem, 0, len(msgs)*2)
 		for _, msg := range msgPtrs {
-			items = append(items, GetMessageItems(msg, toolResultMap)...)
+			items = append(items, GetMessageItems(m.com.Styles, msg, toolResultMap)...)
 		}
 		m.chat.AppendMessages(items...)
 
@@ -247,7 +246,11 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	case tea.MouseClickMsg:
 		switch m.state {
 		case uiChat:
-			m.chat.HandleMouseDown(msg.X, msg.Y)
+			x, y := msg.X, msg.Y
+			// Adjust for chat area position
+			x -= m.layout.main.Min.X
+			y -= m.layout.main.Min.Y
+			m.chat.HandleMouseDown(x, y)
 		}
 
 	case tea.MouseMotionMsg:
@@ -258,13 +261,22 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			} else if msg.Y >= m.chat.Height()-1 {
 				m.chat.ScrollBy(1)
 			}
-			m.chat.HandleMouseDrag(msg.X, msg.Y)
+
+			x, y := msg.X, msg.Y
+			// Adjust for chat area position
+			x -= m.layout.main.Min.X
+			y -= m.layout.main.Min.Y
+			m.chat.HandleMouseDrag(x, y)
 		}
 
 	case tea.MouseReleaseMsg:
 		switch m.state {
 		case uiChat:
-			m.chat.HandleMouseUp(msg.X, msg.Y)
+			x, y := msg.X, msg.Y
+			// Adjust for chat area position
+			x -= m.layout.main.Min.X
+			y -= m.layout.main.Min.Y
+			m.chat.HandleMouseUp(x, y)
 		}
 	case tea.MouseWheelMsg:
 		switch m.state {