feat(ui): add optimized list component with focus navigation

Ayman Bagabas created

Add high-performance scrollable list component with efficient rendering:

**Core Architecture**
- Master buffer caching with lazy viewport extraction
- Dirty item tracking for partial updates
- Smart buffer manipulation for append/prepend/delete operations

**Performance Optimizations**
- Viewport height changes no longer trigger master buffer rebuilds
- In-place re-rendering when item heights unchanged
- Efficient structural operations (append/prepend/delete via buffer slicing)
- Focus navigation automatically skips non-focusable items

**Item Types**
- StringItem: Simple text with optional wrapping
- MarkdownItem: Glamour-rendered markdown with optional focus styles
- SpacerItem: Vertical spacing
- FocusableItem: Wrapper to add focus behavior to any item

**Focus Management**
- Built-in Focusable interface support
- Focus/blur state tracking with automatic item updates
- Smart navigation that skips non-focusable items (Gap, Spacer)

**Testing**
- 32 tests covering core operations, rendering, focus, scrolling
- Comprehensive test coverage for edge cases and regressions

💘 Generated with Crush

Assisted-by: Claude Sonnet 4.5 via Crush <crush@charm.land>

Change summary

internal/ui/list/example_test.go | 276 +++++++++++
internal/ui/list/item.go         | 343 ++++++++++++++
internal/ui/list/item_test.go    | 602 +++++++++++++++++++++++++
internal/ui/list/list.go         | 795 ++++++++++++++++++++++++++++++++++
internal/ui/list/list_test.go    | 586 +++++++++++++++++++++++++
internal/ui/model/ui.go          |  45 +
6 files changed, 2,631 insertions(+), 16 deletions(-)

Detailed changes

internal/ui/list/example_test.go 🔗

@@ -0,0 +1,276 @@
+package list_test
+
+import (
+	"fmt"
+
+	"charm.land/lipgloss/v2"
+	"github.com/charmbracelet/crush/internal/ui/list"
+	uv "github.com/charmbracelet/ultraviolet"
+)
+
+// Example demonstrates basic list usage with string items.
+func Example_basic() {
+	// Create some items
+	items := []list.Item{
+		list.NewStringItem("1", "First item"),
+		list.NewStringItem("2", "Second item"),
+		list.NewStringItem("3", "Third item"),
+	}
+
+	// Create a list with options
+	l := list.New(items...)
+	l.SetSize(80, 10)
+	l.SetSelectedIndex(0)
+	if true {
+		l.Focus()
+	}
+
+	// Draw to a screen buffer
+	screen := uv.NewScreenBuffer(80, 10)
+	area := uv.Rect(0, 0, 80, 10)
+	l.Draw(&screen, area)
+
+	// Render to string
+	output := screen.Render()
+	fmt.Println(output)
+}
+
+// BorderedItem demonstrates a focusable item with borders.
+type BorderedItem struct {
+	id      string
+	content string
+	focused bool
+	width   int
+}
+
+func NewBorderedItem(id, content string) *BorderedItem {
+	return &BorderedItem{
+		id:      id,
+		content: content,
+		width:   80,
+	}
+}
+
+func (b *BorderedItem) ID() string {
+	return b.id
+}
+
+func (b *BorderedItem) Height(width int) int {
+	// Account for border (2 lines for top and bottom)
+	b.width = width // Update width for rendering
+	return lipgloss.Height(b.render())
+}
+
+func (b *BorderedItem) Draw(scr uv.Screen, area uv.Rectangle) {
+	rendered := b.render()
+	styled := uv.NewStyledString(rendered)
+	styled.Draw(scr, area)
+}
+
+func (b *BorderedItem) render() string {
+	style := lipgloss.NewStyle().
+		Width(b.width-4).
+		Padding(0, 1)
+
+	if b.focused {
+		style = style.
+			Border(lipgloss.RoundedBorder()).
+			BorderForeground(lipgloss.Color("205"))
+	} else {
+		style = style.
+			Border(lipgloss.NormalBorder()).
+			BorderForeground(lipgloss.Color("240"))
+	}
+
+	return style.Render(b.content)
+}
+
+func (b *BorderedItem) Focus() {
+	b.focused = true
+}
+
+func (b *BorderedItem) Blur() {
+	b.focused = false
+}
+
+func (b *BorderedItem) IsFocused() bool {
+	return b.focused
+}
+
+// Example demonstrates focusable items with borders.
+func Example_focusable() {
+	// Create focusable items
+	items := []list.Item{
+		NewBorderedItem("1", "Focusable Item 1"),
+		NewBorderedItem("2", "Focusable Item 2"),
+		NewBorderedItem("3", "Focusable Item 3"),
+	}
+
+	// Create list with first item selected and focused
+	l := list.New(items...)
+	l.SetSize(80, 20)
+	l.SetSelectedIndex(0)
+	if true {
+		l.Focus()
+	}
+
+	// Draw to screen
+	screen := uv.NewScreenBuffer(80, 20)
+	area := uv.Rect(0, 0, 80, 20)
+	l.Draw(&screen, area)
+
+	// The first item will have a colored border since it's focused
+	output := screen.Render()
+	fmt.Println(output)
+}
+
+// Example demonstrates dynamic item updates.
+func Example_dynamicUpdates() {
+	items := []list.Item{
+		list.NewStringItem("1", "Item 1"),
+		list.NewStringItem("2", "Item 2"),
+	}
+
+	l := list.New(items...)
+	l.SetSize(80, 10)
+
+	// Draw initial state
+	screen := uv.NewScreenBuffer(80, 10)
+	area := uv.Rect(0, 0, 80, 10)
+	l.Draw(&screen, area)
+
+	// Update an item
+	l.UpdateItem("2", list.NewStringItem("2", "Updated Item 2"))
+
+	// Draw again - only changed item is re-rendered
+	l.Draw(&screen, area)
+
+	// Append a new item
+	l.AppendItem(list.NewStringItem("3", "New Item 3"))
+
+	// Draw again - master buffer grows efficiently
+	l.Draw(&screen, area)
+
+	output := screen.Render()
+	fmt.Println(output)
+}
+
+// Example demonstrates scrolling with a large list.
+func Example_scrolling() {
+	// Create many items
+	items := make([]list.Item, 100)
+	for i := range items {
+		items[i] = list.NewStringItem(
+			fmt.Sprintf("%d", i),
+			fmt.Sprintf("Item %d", i),
+		)
+	}
+
+	// Create list with small viewport
+	l := list.New(items...)
+	l.SetSize(80, 10)
+	l.SetSelectedIndex(0)
+
+	// Draw initial view (shows items 0-9)
+	screen := uv.NewScreenBuffer(80, 10)
+	area := uv.Rect(0, 0, 80, 10)
+	l.Draw(&screen, area)
+
+	// Scroll down
+	l.ScrollBy(5)
+	l.Draw(&screen, area) // Now shows items 5-14
+
+	// Jump to specific item
+	l.ScrollToItem("50")
+	l.Draw(&screen, area) // Now shows item 50 and neighbors
+
+	// Scroll to bottom
+	l.ScrollToBottom()
+	l.Draw(&screen, area) // Now shows last 10 items
+
+	output := screen.Render()
+	fmt.Println(output)
+}
+
+// VariableHeightItem demonstrates items with different heights.
+type VariableHeightItem struct {
+	id    string
+	lines []string
+	width int
+}
+
+func NewVariableHeightItem(id string, lines []string) *VariableHeightItem {
+	return &VariableHeightItem{
+		id:    id,
+		lines: lines,
+		width: 80,
+	}
+}
+
+func (v *VariableHeightItem) ID() string {
+	return v.id
+}
+
+func (v *VariableHeightItem) Height(width int) int {
+	return len(v.lines)
+}
+
+func (v *VariableHeightItem) Draw(scr uv.Screen, area uv.Rectangle) {
+	content := ""
+	for i, line := range v.lines {
+		if i > 0 {
+			content += "\n"
+		}
+		content += line
+	}
+	styled := uv.NewStyledString(content)
+	styled.Draw(scr, area)
+}
+
+// Example demonstrates variable height items.
+func Example_variableHeights() {
+	items := []list.Item{
+		NewVariableHeightItem("1", []string{"Short item"}),
+		NewVariableHeightItem("2", []string{
+			"This is a taller item",
+			"that spans multiple lines",
+			"to demonstrate variable heights",
+		}),
+		NewVariableHeightItem("3", []string{"Another short item"}),
+		NewVariableHeightItem("4", []string{
+			"A medium height item",
+			"with two lines",
+		}),
+	}
+
+	l := list.New(items...)
+	l.SetSize(80, 15)
+
+	screen := uv.NewScreenBuffer(80, 15)
+	area := uv.Rect(0, 0, 80, 15)
+	l.Draw(&screen, area)
+
+	output := screen.Render()
+	fmt.Println(output)
+}
+
+// Example demonstrates markdown items in a list.
+func Example_markdown() {
+	// Create markdown items
+	items := []list.Item{
+		list.NewMarkdownItem("1", "# Welcome\n\nThis is a **markdown** item."),
+		list.NewMarkdownItem("2", "## Features\n\n- Supports **bold**\n- Supports *italic*\n- Supports `code`"),
+		list.NewMarkdownItem("3", "### Code Block\n\n```go\nfunc main() {\n    fmt.Println(\"Hello\")\n}\n```"),
+	}
+
+	// Create list
+	l := list.New(items...)
+	l.SetSize(80, 20)
+
+	screen := uv.NewScreenBuffer(80, 20)
+	area := uv.Rect(0, 0, 80, 20)
+	l.Draw(&screen, area)
+
+	output := screen.Render()
+	fmt.Println(output)
+}

internal/ui/list/item.go 🔗

@@ -0,0 +1,343 @@
+package list
+
+import (
+	"strings"
+
+	"charm.land/lipgloss/v2"
+	"github.com/charmbracelet/glamour/v2"
+	"github.com/charmbracelet/glamour/v2/ansi"
+	uv "github.com/charmbracelet/ultraviolet"
+	"github.com/charmbracelet/ultraviolet/screen"
+)
+
+// Item represents a list item that can draw itself to a UV buffer.
+// Items implement the uv.Drawable interface.
+type Item interface {
+	uv.Drawable
+
+	// ID returns unique identifier for this item.
+	ID() string
+
+	// Height returns the item's height in lines for the given width.
+	// This allows items to calculate height based on text wrapping and available space.
+	Height(width int) int
+}
+
+// Focusable is an optional interface for items that support focus.
+// When implemented, items can change appearance when focused (borders, colors, etc).
+type Focusable interface {
+	Focus()
+	Blur()
+	IsFocused() bool
+}
+
+// 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() {
+	b.focused = true
+}
+
+// Blur implements Focusable interface.
+func (b *BaseFocusable) Blur() {
+	b.focused = false
+}
+
+// IsFocused implements Focusable interface.
+func (b *BaseFocusable) IsFocused() 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
+}
+
+// StringItem is a simple string-based item with optional text wrapping.
+// It caches rendered content by width for efficient repeated rendering.
+// StringItem implements Focusable if focusStyle and blurStyle are set via WithFocusStyles.
+type StringItem struct {
+	BaseFocusable
+	id      string
+	content string // Raw content string (may contain ANSI styles)
+	wrap    bool   // Whether to wrap text
+
+	// Cache for rendered content at specific widths
+	// Key: width, Value: string
+	cache map[int]string
+}
+
+// NewStringItem creates a new string item with the given ID and content.
+func NewStringItem(id, content string) *StringItem {
+	return &StringItem{
+		id:      id,
+		content: content,
+		wrap:    false,
+		cache:   make(map[int]string),
+	}
+}
+
+// NewWrappingStringItem creates a new string item that wraps text to fit width.
+func NewWrappingStringItem(id, content string) *StringItem {
+	return &StringItem{
+		id:      id,
+		content: content,
+		wrap:    true,
+		cache:   make(map[int]string),
+	}
+}
+
+// WithFocusStyles sets the focus and blur styles for the string item.
+// If both styles are non-nil, the item will implement Focusable.
+func (s *StringItem) WithFocusStyles(focusStyle, blurStyle *lipgloss.Style) *StringItem {
+	s.SetFocusStyles(focusStyle, blurStyle)
+	return s
+}
+
+// ID implements Item.
+func (s *StringItem) ID() string {
+	return s.id
+}
+
+// Height implements Item.
+func (s *StringItem) Height(width int) int {
+	// Calculate content width if we have styles
+	contentWidth := width
+	if style := s.CurrentStyle(); style != nil {
+		hFrameSize := style.GetHorizontalFrameSize()
+		if hFrameSize > 0 {
+			contentWidth -= hFrameSize
+		}
+	}
+
+	var lines int
+	if !s.wrap {
+		// No wrapping - height is just the number of newlines + 1
+		lines = strings.Count(s.content, "\n") + 1
+	} else {
+		// Use lipgloss.Wrap to wrap the content and count lines
+		// This preserves ANSI styles and is much faster than rendering to a buffer
+		wrapped := lipgloss.Wrap(s.content, contentWidth, "")
+		lines = strings.Count(wrapped, "\n") + 1
+	}
+
+	// Add vertical frame size if we have styles
+	if style := s.CurrentStyle(); style != nil {
+		lines += style.GetVerticalFrameSize()
+	}
+
+	return lines
+}
+
+// Draw implements Item and uv.Drawable.
+func (s *StringItem) Draw(scr uv.Screen, area uv.Rectangle) {
+	width := area.Dx()
+
+	// Check cache first
+	content, ok := s.cache[width]
+	if !ok {
+		// Not cached - create and cache
+		content = s.content
+		if s.wrap {
+			// Wrap content using lipgloss
+			content = lipgloss.Wrap(s.content, width, "")
+		}
+		s.cache[width] = content
+	}
+
+	// Apply focus/blur styling if configured
+	if style := s.CurrentStyle(); style != nil {
+		content = style.Width(width).Render(content)
+	}
+
+	// Draw the styled string
+	styled := uv.NewStyledString(content)
+	styled.Draw(scr, area)
+}
+
+// MarkdownItem renders markdown content using Glamour.
+// It caches all rendered content by width for efficient repeated rendering.
+// The wrap width is capped at 120 cells by default to ensure readable line lengths.
+// MarkdownItem implements Focusable if focusStyle and blurStyle are set via WithFocusStyles.
+type MarkdownItem struct {
+	BaseFocusable
+	id          string
+	markdown    string            // Raw markdown content
+	styleConfig *ansi.StyleConfig // Optional style configuration
+	maxWidth    int               // Maximum wrap width (default 120)
+
+	// Cache for rendered content at specific widths
+	// Key: width (capped to maxWidth), Value: rendered markdown string
+	cache map[int]string
+}
+
+// DefaultMarkdownMaxWidth is the default maximum width for markdown rendering.
+const DefaultMarkdownMaxWidth = 120
+
+// NewMarkdownItem creates a new markdown item with the given ID and markdown content.
+// If focusStyle and blurStyle are both non-nil, the item will implement Focusable.
+func NewMarkdownItem(id, markdown string) *MarkdownItem {
+	m := &MarkdownItem{
+		id:       id,
+		markdown: markdown,
+		maxWidth: DefaultMarkdownMaxWidth,
+		cache:    make(map[int]string),
+	}
+
+	return m
+}
+
+// WithStyleConfig sets a custom Glamour style configuration for the markdown item.
+func (m *MarkdownItem) WithStyleConfig(styleConfig ansi.StyleConfig) *MarkdownItem {
+	m.styleConfig = &styleConfig
+	return m
+}
+
+// WithMaxWidth sets the maximum wrap width for markdown rendering.
+func (m *MarkdownItem) WithMaxWidth(maxWidth int) *MarkdownItem {
+	m.maxWidth = maxWidth
+	return m
+}
+
+// WithFocusStyles sets the focus and blur styles for the markdown item.
+// If both styles are non-nil, the item will implement Focusable.
+func (m *MarkdownItem) WithFocusStyles(focusStyle, blurStyle *lipgloss.Style) *MarkdownItem {
+	m.SetFocusStyles(focusStyle, blurStyle)
+	return m
+}
+
+// ID implements Item.
+func (m *MarkdownItem) ID() string {
+	return m.id
+}
+
+// Height implements Item.
+func (m *MarkdownItem) Height(width int) int {
+	// Render the markdown to get its height
+	rendered := m.renderMarkdown(width)
+
+	// Apply focus/blur styling if configured to get accurate height
+	if style := m.CurrentStyle(); style != nil {
+		rendered = style.Render(rendered)
+	}
+
+	return strings.Count(rendered, "\n") + 1
+}
+
+// Draw implements Item and uv.Drawable.
+func (m *MarkdownItem) Draw(scr uv.Screen, area uv.Rectangle) {
+	width := area.Dx()
+	rendered := m.renderMarkdown(width)
+
+	// Apply focus/blur styling if configured
+	if style := m.CurrentStyle(); style != nil {
+		rendered = style.Render(rendered)
+	}
+
+	// Draw the rendered markdown
+	styled := uv.NewStyledString(rendered)
+	styled.Draw(scr, area)
+}
+
+// renderMarkdown renders the markdown content at the given width, using cache if available.
+// Width is always capped to maxWidth to ensure readable line lengths.
+func (m *MarkdownItem) renderMarkdown(width int) string {
+	// Cap width to maxWidth
+	cappedWidth := min(width, m.maxWidth)
+
+	// Check cache first (always cache all rendered markdown)
+	if cached, ok := m.cache[cappedWidth]; ok {
+		return cached
+	}
+
+	// Not cached - render now
+	opts := []glamour.TermRendererOption{
+		glamour.WithWordWrap(cappedWidth),
+	}
+
+	// Add style config if provided
+	if m.styleConfig != nil {
+		opts = append(opts, glamour.WithStyles(*m.styleConfig))
+	}
+
+	renderer, err := glamour.NewTermRenderer(opts...)
+	if err != nil {
+		// Fallback to plain text on error
+		return m.markdown
+	}
+
+	rendered, err := renderer.Render(m.markdown)
+	if err != nil {
+		// Fallback to plain text on error
+		return m.markdown
+	}
+
+	// Trim trailing whitespace
+	rendered = strings.TrimRight(rendered, "\n\r ")
+
+	// Always cache
+	m.cache[cappedWidth] = rendered
+
+	return rendered
+}
+
+// Gap is a 1-line spacer item used to add gaps between items.
+var Gap = NewSpacerItem("spacer-gap", 1)
+
+// SpacerItem is an empty item that takes up vertical space.
+// Useful for adding gaps between items in a list.
+type SpacerItem struct {
+	id     string
+	height int
+}
+
+var _ Item = (*SpacerItem)(nil)
+
+// NewSpacerItem creates a new spacer item with the given ID and height in lines.
+func NewSpacerItem(id string, height int) *SpacerItem {
+	return &SpacerItem{
+		id:     id,
+		height: height,
+	}
+}
+
+// ID implements Item.
+func (s *SpacerItem) ID() string {
+	return s.id
+}
+
+// Height implements Item.
+func (s *SpacerItem) Height(width int) int {
+	return s.height
+}
+
+// Draw implements Item.
+// Spacer items don't draw anything, they just take up space.
+func (s *SpacerItem) Draw(scr uv.Screen, area uv.Rectangle) {
+	// Ensure the area is filled with spaces to clear any existing content
+	spacerArea := uv.Rect(area.Min.X, area.Min.Y, area.Dx(), area.Min.Y+min(1, s.height))
+	if spacerArea.Overlaps(area) {
+		screen.ClearArea(scr, spacerArea)
+	}
+}

internal/ui/list/item_test.go 🔗

@@ -0,0 +1,602 @@
+package list
+
+import (
+	"strings"
+	"testing"
+
+	"github.com/charmbracelet/glamour/v2/ansi"
+	uv "github.com/charmbracelet/ultraviolet"
+)
+
+func TestRenderHelper(t *testing.T) {
+	items := []Item{
+		NewStringItem("1", "Item 1"),
+		NewStringItem("2", "Item 2"),
+		NewStringItem("3", "Item 3"),
+	}
+
+	l := New(items...)
+	l.SetSize(80, 10)
+
+	// Render to string
+	output := l.Render()
+
+	if len(output) == 0 {
+		t.Error("expected non-empty output from Render()")
+	}
+
+	// Check that output contains the items
+	if !strings.Contains(output, "Item 1") {
+		t.Error("expected output to contain 'Item 1'")
+	}
+	if !strings.Contains(output, "Item 2") {
+		t.Error("expected output to contain 'Item 2'")
+	}
+	if !strings.Contains(output, "Item 3") {
+		t.Error("expected output to contain 'Item 3'")
+	}
+}
+
+func TestRenderWithScrolling(t *testing.T) {
+	items := []Item{
+		NewStringItem("1", "Item 1"),
+		NewStringItem("2", "Item 2"),
+		NewStringItem("3", "Item 3"),
+		NewStringItem("4", "Item 4"),
+		NewStringItem("5", "Item 5"),
+	}
+
+	l := New(items...)
+	l.SetSize(80, 2) // Small viewport
+
+	// Initial render should show first 2 items
+	output := l.Render()
+	if !strings.Contains(output, "Item 1") {
+		t.Error("expected output to contain 'Item 1'")
+	}
+	if !strings.Contains(output, "Item 2") {
+		t.Error("expected output to contain 'Item 2'")
+	}
+	if strings.Contains(output, "Item 3") {
+		t.Error("expected output to NOT contain 'Item 3' in initial view")
+	}
+
+	// Scroll down and render
+	l.ScrollBy(2)
+	output = l.Render()
+
+	// Now should show items 3 and 4
+	if strings.Contains(output, "Item 1") {
+		t.Error("expected output to NOT contain 'Item 1' after scrolling")
+	}
+	if !strings.Contains(output, "Item 3") {
+		t.Error("expected output to contain 'Item 3' after scrolling")
+	}
+	if !strings.Contains(output, "Item 4") {
+		t.Error("expected output to contain 'Item 4' after scrolling")
+	}
+}
+
+func TestRenderEmptyList(t *testing.T) {
+	l := New()
+	l.SetSize(80, 10)
+
+	output := l.Render()
+	if output != "" {
+		t.Errorf("expected empty output for empty list, got: %q", output)
+	}
+}
+
+func TestRenderVsDrawConsistency(t *testing.T) {
+	items := []Item{
+		NewStringItem("1", "Item 1"),
+		NewStringItem("2", "Item 2"),
+	}
+
+	l := New(items...)
+	l.SetSize(80, 10)
+
+	// Render using Render() method
+	renderOutput := l.Render()
+
+	// Render using Draw() method
+	screen := uv.NewScreenBuffer(80, 10)
+	area := uv.Rect(0, 0, 80, 10)
+	l.Draw(&screen, area)
+	drawOutput := screen.Render()
+
+	// Trim any trailing whitespace for comparison
+	renderOutput = strings.TrimRight(renderOutput, "\n")
+	drawOutput = strings.TrimRight(drawOutput, "\n")
+
+	// Both methods should produce the same output
+	if renderOutput != drawOutput {
+		t.Errorf("Render() and Draw() produced different outputs:\nRender():\n%q\n\nDraw():\n%q",
+			renderOutput, drawOutput)
+	}
+}
+
+func BenchmarkRender(b *testing.B) {
+	items := make([]Item, 100)
+	for i := range items {
+		items[i] = NewStringItem(string(rune(i)), "Item content here")
+	}
+
+	l := New(items...)
+	l.SetSize(80, 24)
+	l.Render() // Prime the buffer
+
+	b.ResetTimer()
+	for i := 0; i < b.N; i++ {
+		_ = l.Render()
+	}
+}
+
+func BenchmarkRenderWithScrolling(b *testing.B) {
+	items := make([]Item, 1000)
+	for i := range items {
+		items[i] = NewStringItem(string(rune(i)), "Item content here")
+	}
+
+	l := New(items...)
+	l.SetSize(80, 24)
+	l.Render() // Prime the buffer
+
+	b.ResetTimer()
+	for i := 0; i < b.N; i++ {
+		l.ScrollBy(1)
+		_ = l.Render()
+	}
+}
+
+func TestStringItemCache(t *testing.T) {
+	item := NewStringItem("1", "Test content")
+
+	// First draw at width 80 should populate cache
+	screen1 := uv.NewScreenBuffer(80, 5)
+	area1 := uv.Rect(0, 0, 80, 5)
+	item.Draw(&screen1, area1)
+
+	if len(item.cache) != 1 {
+		t.Errorf("expected cache to have 1 entry after first draw, got %d", len(item.cache))
+	}
+	if _, ok := item.cache[80]; !ok {
+		t.Error("expected cache to have entry for width 80")
+	}
+
+	// Second draw at same width should reuse cache
+	screen2 := uv.NewScreenBuffer(80, 5)
+	area2 := uv.Rect(0, 0, 80, 5)
+	item.Draw(&screen2, area2)
+
+	if len(item.cache) != 1 {
+		t.Errorf("expected cache to still have 1 entry after second draw, got %d", len(item.cache))
+	}
+
+	// Draw at different width should add to cache
+	screen3 := uv.NewScreenBuffer(40, 5)
+	area3 := uv.Rect(0, 0, 40, 5)
+	item.Draw(&screen3, area3)
+
+	if len(item.cache) != 2 {
+		t.Errorf("expected cache to have 2 entries after draw at different width, got %d", len(item.cache))
+	}
+	if _, ok := item.cache[40]; !ok {
+		t.Error("expected cache to have entry for width 40")
+	}
+}
+
+func TestWrappingItemHeight(t *testing.T) {
+	// Short text that fits in one line
+	item1 := NewWrappingStringItem("1", "Short")
+	if h := item1.Height(80); h != 1 {
+		t.Errorf("expected height 1 for short text, got %d", h)
+	}
+
+	// Long text that wraps
+	longText := "This is a very long line that will definitely wrap when constrained to a narrow width"
+	item2 := NewWrappingStringItem("2", longText)
+
+	// At width 80, should be fewer lines than width 20
+	height80 := item2.Height(80)
+	height20 := item2.Height(20)
+
+	if height20 <= height80 {
+		t.Errorf("expected more lines at narrow width (20: %d lines) than wide width (80: %d lines)",
+			height20, height80)
+	}
+
+	// Non-wrapping version should always be 1 line
+	item3 := NewStringItem("3", longText)
+	if h := item3.Height(20); h != 1 {
+		t.Errorf("expected height 1 for non-wrapping item, got %d", h)
+	}
+}
+
+func TestMarkdownItemBasic(t *testing.T) {
+	markdown := "# Hello\n\nThis is a **test**."
+	item := NewMarkdownItem("1", markdown)
+
+	if item.ID() != "1" {
+		t.Errorf("expected ID '1', got '%s'", item.ID())
+	}
+
+	// Test that height is calculated
+	height := item.Height(80)
+	if height < 1 {
+		t.Errorf("expected height >= 1, got %d", height)
+	}
+
+	// Test drawing
+	screen := uv.NewScreenBuffer(80, 10)
+	area := uv.Rect(0, 0, 80, 10)
+	item.Draw(&screen, area)
+
+	// Should not panic and should render something
+	rendered := screen.Render()
+	if len(rendered) == 0 {
+		t.Error("expected non-empty rendered output")
+	}
+}
+
+func TestMarkdownItemCache(t *testing.T) {
+	markdown := "# Test\n\nSome content."
+	item := NewMarkdownItem("1", markdown)
+
+	// First render at width 80 should populate cache
+	height1 := item.Height(80)
+	if len(item.cache) != 1 {
+		t.Errorf("expected cache to have 1 entry after first render, got %d", len(item.cache))
+	}
+
+	// Second render at same width should reuse cache
+	height2 := item.Height(80)
+	if height1 != height2 {
+		t.Errorf("expected consistent height, got %d then %d", height1, height2)
+	}
+	if len(item.cache) != 1 {
+		t.Errorf("expected cache to still have 1 entry, got %d", len(item.cache))
+	}
+
+	// Render at different width should add to cache
+	_ = item.Height(40)
+	if len(item.cache) != 2 {
+		t.Errorf("expected cache to have 2 entries after different width, got %d", len(item.cache))
+	}
+}
+
+func TestMarkdownItemMaxCacheWidth(t *testing.T) {
+	markdown := "# Test\n\nSome content."
+	item := NewMarkdownItem("1", markdown).WithMaxWidth(50)
+
+	// Render at width 40 (below limit) - should cache at width 40
+	_ = item.Height(40)
+	if len(item.cache) != 1 {
+		t.Errorf("expected cache to have 1 entry for width 40, got %d", len(item.cache))
+	}
+
+	// Render at width 80 (above limit) - should cap to 50 and cache
+	_ = item.Height(80)
+	// Cache should have width 50 entry (capped from 80)
+	if len(item.cache) != 2 {
+		t.Errorf("expected cache to have 2 entries (40 and 50), got %d", len(item.cache))
+	}
+	if _, ok := item.cache[50]; !ok {
+		t.Error("expected cache to have entry for width 50 (capped from 80)")
+	}
+
+	// Render at width 100 (also above limit) - should reuse cached width 50
+	_ = item.Height(100)
+	if len(item.cache) != 2 {
+		t.Errorf("expected cache to still have 2 entries (reusing 50), got %d", len(item.cache))
+	}
+}
+
+func TestMarkdownItemWithStyleConfig(t *testing.T) {
+	markdown := "# Styled\n\nContent with **bold** text."
+
+	// Create a custom style config
+	styleConfig := ansi.StyleConfig{
+		Document: ansi.StyleBlock{
+			Margin: uintPtr(0),
+		},
+	}
+
+	item := NewMarkdownItem("1", markdown).WithStyleConfig(styleConfig)
+
+	// Render should use the custom style
+	height := item.Height(80)
+	if height < 1 {
+		t.Errorf("expected height >= 1, got %d", height)
+	}
+
+	// Draw should work without panic
+	screen := uv.NewScreenBuffer(80, 10)
+	area := uv.Rect(0, 0, 80, 10)
+	item.Draw(&screen, area)
+
+	rendered := screen.Render()
+	if len(rendered) == 0 {
+		t.Error("expected non-empty rendered output with custom style")
+	}
+}
+
+func TestMarkdownItemInList(t *testing.T) {
+	items := []Item{
+		NewMarkdownItem("1", "# First\n\nMarkdown item."),
+		NewMarkdownItem("2", "# Second\n\nAnother item."),
+		NewStringItem("3", "Regular string item"),
+	}
+
+	l := New(items...)
+	l.SetSize(80, 20)
+
+	// Should render without error
+	output := l.Render()
+	if len(output) == 0 {
+		t.Error("expected non-empty output from list with markdown items")
+	}
+
+	// Should contain content from markdown items
+	if !strings.Contains(output, "First") {
+		t.Error("expected output to contain 'First'")
+	}
+	if !strings.Contains(output, "Second") {
+		t.Error("expected output to contain 'Second'")
+	}
+	if !strings.Contains(output, "Regular string item") {
+		t.Error("expected output to contain 'Regular string item'")
+	}
+}
+
+func TestMarkdownItemHeightWithWidth(t *testing.T) {
+	// Test that widths are capped to maxWidth
+	markdown := "This is a paragraph with some text."
+
+	item := NewMarkdownItem("1", markdown).WithMaxWidth(50)
+
+	// At width 30 (below limit), should cache and render at width 30
+	height30 := item.Height(30)
+	if height30 < 1 {
+		t.Errorf("expected height >= 1, got %d", height30)
+	}
+
+	// At width 100 (above maxWidth), should cap to 50 and cache
+	height100 := item.Height(100)
+	if height100 < 1 {
+		t.Errorf("expected height >= 1, got %d", height100)
+	}
+
+	// Both should be cached (width 30 and capped width 50)
+	if len(item.cache) != 2 {
+		t.Errorf("expected cache to have 2 entries (30 and 50), got %d", len(item.cache))
+	}
+	if _, ok := item.cache[30]; !ok {
+		t.Error("expected cache to have entry for width 30")
+	}
+	if _, ok := item.cache[50]; !ok {
+		t.Error("expected cache to have entry for width 50 (capped from 100)")
+	}
+}
+
+func BenchmarkMarkdownItemRender(b *testing.B) {
+	markdown := "# Heading\n\nThis is a paragraph with **bold** and *italic* text.\n\n- Item 1\n- Item 2\n- Item 3"
+	item := NewMarkdownItem("1", markdown)
+
+	// Prime the cache
+	screen := uv.NewScreenBuffer(80, 10)
+	area := uv.Rect(0, 0, 80, 10)
+	item.Draw(&screen, area)
+
+	b.ResetTimer()
+	for i := 0; i < b.N; i++ {
+		screen := uv.NewScreenBuffer(80, 10)
+		area := uv.Rect(0, 0, 80, 10)
+		item.Draw(&screen, area)
+	}
+}
+
+func BenchmarkMarkdownItemUncached(b *testing.B) {
+	markdown := "# Heading\n\nThis is a paragraph with **bold** and *italic* text.\n\n- Item 1\n- Item 2\n- Item 3"
+
+	b.ResetTimer()
+	for i := 0; i < b.N; i++ {
+		item := NewMarkdownItem("1", markdown)
+		screen := uv.NewScreenBuffer(80, 10)
+		area := uv.Rect(0, 0, 80, 10)
+		item.Draw(&screen, area)
+	}
+}
+
+func TestSpacerItem(t *testing.T) {
+	spacer := NewSpacerItem("spacer1", 3)
+
+	// Check ID
+	if spacer.ID() != "spacer1" {
+		t.Errorf("expected ID 'spacer1', got %q", spacer.ID())
+	}
+
+	// Check height
+	if h := spacer.Height(80); h != 3 {
+		t.Errorf("expected height 3, got %d", h)
+	}
+
+	// Height should be constant regardless of width
+	if h := spacer.Height(20); h != 3 {
+		t.Errorf("expected height 3 for width 20, got %d", h)
+	}
+
+	// Draw should not produce any visible content
+	screen := uv.NewScreenBuffer(20, 3)
+	area := uv.Rect(0, 0, 20, 3)
+	spacer.Draw(&screen, area)
+
+	output := screen.Render()
+	// Should be empty (just spaces)
+	for _, line := range strings.Split(output, "\n") {
+		trimmed := strings.TrimSpace(line)
+		if trimmed != "" {
+			t.Errorf("expected empty spacer output, got: %q", line)
+		}
+	}
+}
+
+func TestSpacerItemInList(t *testing.T) {
+	// Create a list with items separated by spacers
+	items := []Item{
+		NewStringItem("1", "Item 1"),
+		NewSpacerItem("spacer1", 1),
+		NewStringItem("2", "Item 2"),
+		NewSpacerItem("spacer2", 2),
+		NewStringItem("3", "Item 3"),
+	}
+
+	l := New(items...)
+	l.SetSize(20, 10)
+
+	output := l.Render()
+
+	// Should contain all three items
+	if !strings.Contains(output, "Item 1") {
+		t.Error("expected output to contain 'Item 1'")
+	}
+	if !strings.Contains(output, "Item 2") {
+		t.Error("expected output to contain 'Item 2'")
+	}
+	if !strings.Contains(output, "Item 3") {
+		t.Error("expected output to contain 'Item 3'")
+	}
+
+	// Total height should be: 1 (item1) + 1 (spacer1) + 1 (item2) + 2 (spacer2) + 1 (item3) = 6
+	expectedHeight := 6
+	if l.TotalHeight() != expectedHeight {
+		t.Errorf("expected total height %d, got %d", expectedHeight, l.TotalHeight())
+	}
+}
+
+func TestSpacerItemNavigation(t *testing.T) {
+	// Spacers should not be selectable (they're not focusable)
+	items := []Item{
+		NewStringItem("1", "Item 1"),
+		NewSpacerItem("spacer1", 1),
+		NewStringItem("2", "Item 2"),
+	}
+
+	l := New(items...)
+	l.SetSize(20, 10)
+
+	// Select first item
+	l.SetSelectedIndex(0)
+	if l.SelectedIndex() != 0 {
+		t.Errorf("expected selected index 0, got %d", l.SelectedIndex())
+	}
+
+	// Can select the spacer (it's a valid item, just not focusable)
+	l.SetSelectedIndex(1)
+	if l.SelectedIndex() != 1 {
+		t.Errorf("expected selected index 1, got %d", l.SelectedIndex())
+	}
+
+	// Can select item after spacer
+	l.SetSelectedIndex(2)
+	if l.SelectedIndex() != 2 {
+		t.Errorf("expected selected index 2, got %d", l.SelectedIndex())
+	}
+}
+
+// Helper function to create a pointer to uint
+func uintPtr(v uint) *uint {
+	return &v
+}
+
+func TestListDoesNotEatLastLine(t *testing.T) {
+	// Create items that exactly fill the viewport
+	items := []Item{
+		NewStringItem("1", "Line 1"),
+		NewStringItem("2", "Line 2"),
+		NewStringItem("3", "Line 3"),
+		NewStringItem("4", "Line 4"),
+		NewStringItem("5", "Line 5"),
+	}
+
+	// Create list with height exactly matching content (5 lines, no gaps)
+	l := New(items...)
+	l.SetSize(20, 5)
+
+	// Render the list
+	output := l.Render()
+
+	// Count actual lines in output
+	lines := strings.Split(strings.TrimRight(output, "\r\n"), "\r\n")
+	actualLineCount := 0
+	for _, line := range lines {
+		if strings.TrimSpace(line) != "" {
+			actualLineCount++
+		}
+	}
+
+	// All 5 items should be visible
+	if !strings.Contains(output, "Line 1") {
+		t.Error("expected output to contain 'Line 1'")
+	}
+	if !strings.Contains(output, "Line 2") {
+		t.Error("expected output to contain 'Line 2'")
+	}
+	if !strings.Contains(output, "Line 3") {
+		t.Error("expected output to contain 'Line 3'")
+	}
+	if !strings.Contains(output, "Line 4") {
+		t.Error("expected output to contain 'Line 4'")
+	}
+	if !strings.Contains(output, "Line 5") {
+		t.Error("expected output to contain 'Line 5'")
+	}
+
+	if actualLineCount != 5 {
+		t.Errorf("expected 5 lines with content, got %d", actualLineCount)
+	}
+}
+
+func TestListWithScrollDoesNotEatLastLine(t *testing.T) {
+	// Create more items than viewport height
+	items := []Item{
+		NewStringItem("1", "Item 1"),
+		NewStringItem("2", "Item 2"),
+		NewStringItem("3", "Item 3"),
+		NewStringItem("4", "Item 4"),
+		NewStringItem("5", "Item 5"),
+		NewStringItem("6", "Item 6"),
+		NewStringItem("7", "Item 7"),
+	}
+
+	// Viewport shows 3 items at a time
+	l := New(items...)
+	l.SetSize(20, 3)
+
+	// Need to render first to build the buffer and calculate total height
+	_ = l.Render()
+
+	// Now scroll to bottom
+	l.ScrollToBottom()
+
+	output := l.Render()
+
+	t.Logf("Output:\n%s", output)
+	t.Logf("Offset: %d, Total height: %d", l.offset, l.TotalHeight())
+
+	// Should show last 3 items: 5, 6, 7
+	if !strings.Contains(output, "Item 5") {
+		t.Error("expected output to contain 'Item 5'")
+	}
+	if !strings.Contains(output, "Item 6") {
+		t.Error("expected output to contain 'Item 6'")
+	}
+	if !strings.Contains(output, "Item 7") {
+		t.Error("expected output to contain 'Item 7'")
+	}
+
+	// Should not show earlier items
+	if strings.Contains(output, "Item 1") {
+		t.Error("expected output to NOT contain 'Item 1' when scrolled to bottom")
+	}
+}

internal/ui/list/list.go 🔗

@@ -0,0 +1,795 @@
+package list
+
+import (
+	"strings"
+
+	uv "github.com/charmbracelet/ultraviolet"
+	"github.com/charmbracelet/ultraviolet/screen"
+)
+
+// List is a scrollable list component that implements uv.Drawable.
+// It efficiently manages a large number of items by caching rendered content
+// in a master buffer and extracting only the visible viewport when drawn.
+type List struct {
+	// Configuration
+	width, height int
+
+	// Data
+	items    []Item
+	indexMap map[string]int // ID -> index for fast lookup
+
+	// Focus & Selection
+	focused     bool
+	selectedIdx int // Currently selected item index (-1 if none)
+
+	// Master buffer containing ALL rendered items
+	masterBuffer *uv.ScreenBuffer
+	totalHeight  int
+
+	// Item positioning in master buffer
+	itemPositions map[string]itemPosition
+
+	// Viewport state
+	offset int // Scroll offset in lines from top
+
+	// Dirty tracking
+	dirty      bool
+	dirtyItems map[string]bool
+}
+
+type itemPosition struct {
+	startLine int
+	height    int
+}
+
+// New creates a new list with the given items.
+func New(items ...Item) *List {
+	l := &List{
+		items:         items,
+		indexMap:      make(map[string]int, len(items)),
+		itemPositions: make(map[string]itemPosition, len(items)),
+		dirtyItems:    make(map[string]bool),
+		selectedIdx:   -1,
+	}
+
+	// Build index map
+	for i, item := range items {
+		l.indexMap[item.ID()] = i
+	}
+
+	l.dirty = true
+	return l
+}
+
+// ensureBuilt ensures the master buffer is built.
+// This is called by methods that need itemPositions or totalHeight.
+func (l *List) ensureBuilt() {
+	if l.width <= 0 || l.height <= 0 {
+		return
+	}
+
+	if l.dirty {
+		l.rebuildMasterBuffer()
+	} else if len(l.dirtyItems) > 0 {
+		l.updateDirtyItems()
+	}
+}
+
+// Draw implements uv.Drawable.
+// Draws the visible viewport of the list to the given screen buffer.
+func (l *List) Draw(scr uv.Screen, area uv.Rectangle) {
+	if area.Dx() <= 0 || area.Dy() <= 0 {
+		return
+	}
+
+	// Update internal dimensions if area size changed
+	widthChanged := l.width != area.Dx()
+	heightChanged := l.height != area.Dy()
+
+	l.width = area.Dx()
+	l.height = area.Dy()
+
+	// Only width changes require rebuilding master buffer
+	// Height changes only affect viewport clipping, not item rendering
+	if widthChanged {
+		l.dirty = true
+	}
+
+	// Height changes require clamping offset to new bounds
+	if heightChanged {
+		l.clampOffset()
+	}
+
+	if len(l.items) == 0 {
+		screen.ClearArea(scr, area)
+		return
+	}
+
+	// Ensure buffer is built
+	l.ensureBuilt()
+
+	// Draw visible portion to the target screen
+	l.drawViewport(scr, area)
+}
+
+// Render renders the visible viewport to a string.
+// This is a convenience method that creates a temporary screen buffer,
+// draws to it, and returns the rendered string.
+func (l *List) Render() string {
+	if l.width <= 0 || l.height <= 0 {
+		return ""
+	}
+
+	if len(l.items) == 0 {
+		return ""
+	}
+
+	// Ensure buffer is built
+	l.ensureBuilt()
+
+	// Extract visible lines directly from master buffer
+	return l.renderViewport()
+}
+
+// renderViewport renders the visible portion of the master buffer to a string.
+func (l *List) renderViewport() string {
+	if l.masterBuffer == nil {
+		return ""
+	}
+
+	buf := l.masterBuffer.Buffer
+
+	// Calculate visible region in master buffer
+	srcStartY := l.offset
+	srcEndY := l.offset + l.height
+
+	// Clamp to actual buffer bounds
+	if srcStartY >= len(buf.Lines) {
+		// Beyond end of content, return empty lines
+		emptyLine := strings.Repeat(" ", l.width)
+		lines := make([]string, l.height)
+		for i := range lines {
+			lines[i] = emptyLine
+		}
+		return strings.Join(lines, "\r\n")
+	}
+	if srcEndY > len(buf.Lines) {
+		srcEndY = len(buf.Lines)
+	}
+
+	// Build result with proper line handling
+	lines := make([]string, l.height)
+	lineIdx := 0
+
+	// Render visible lines from buffer
+	for y := srcStartY; y < srcEndY && lineIdx < l.height; y++ {
+		lines[lineIdx] = buf.Lines[y].Render()
+		lineIdx++
+	}
+
+	// Pad remaining lines with spaces to maintain viewport height
+	emptyLine := strings.Repeat(" ", l.width)
+	for ; lineIdx < l.height; lineIdx++ {
+		lines[lineIdx] = emptyLine
+	}
+
+	return strings.Join(lines, "\r\n")
+}
+
+// drawViewport draws the visible portion from master buffer to target screen.
+func (l *List) drawViewport(scr uv.Screen, area uv.Rectangle) {
+	if l.masterBuffer == nil {
+		screen.ClearArea(scr, area)
+		return
+	}
+
+	buf := l.masterBuffer.Buffer
+
+	// Calculate visible region in master buffer
+	srcStartY := l.offset
+	srcEndY := l.offset + area.Dy()
+
+	// Clamp to actual buffer bounds
+	if srcStartY >= len(buf.Lines) {
+		screen.ClearArea(scr, area)
+		return
+	}
+	if srcEndY > len(buf.Lines) {
+		srcEndY = len(buf.Lines)
+	}
+
+	// Copy visible lines to target screen
+	destY := area.Min.Y
+	for srcY := srcStartY; srcY < srcEndY && destY < area.Max.Y; srcY++ {
+		line := buf.Lines[srcY]
+		destX := area.Min.X
+
+		for x := 0; x < len(line) && x < area.Dx() && destX < area.Max.X; x++ {
+			cell := line.At(x)
+			scr.SetCell(destX, destY, cell)
+			destX++
+		}
+		destY++
+	}
+
+	// Clear any remaining area if content is shorter than viewport
+	if destY < area.Max.Y {
+		clearArea := uv.Rect(area.Min.X, destY, area.Dx(), area.Max.Y-destY)
+		screen.ClearArea(scr, clearArea)
+	}
+}
+
+// rebuildMasterBuffer composes all items into the master buffer.
+func (l *List) rebuildMasterBuffer() {
+	if len(l.items) == 0 {
+		l.totalHeight = 0
+		l.dirty = false
+		return
+	}
+
+	// Calculate total height
+	l.totalHeight = l.calculateTotalHeight()
+
+	// Create or resize master buffer
+	if l.masterBuffer == nil || l.masterBuffer.Width() != l.width || l.masterBuffer.Height() != l.totalHeight {
+		buf := uv.NewScreenBuffer(l.width, l.totalHeight)
+		l.masterBuffer = &buf
+	}
+
+	// Clear buffer
+	screen.Clear(l.masterBuffer)
+
+	// Draw each item
+	currentY := 0
+	for _, item := range l.items {
+		itemHeight := item.Height(l.width)
+
+		// Draw item to master buffer
+		area := uv.Rect(0, currentY, l.width, itemHeight)
+		item.Draw(l.masterBuffer, area)
+
+		// Store position
+		l.itemPositions[item.ID()] = itemPosition{
+			startLine: currentY,
+			height:    itemHeight,
+		}
+
+		// Advance position
+		currentY += itemHeight
+	}
+
+	l.dirty = false
+	l.dirtyItems = make(map[string]bool)
+}
+
+// updateDirtyItems efficiently updates only changed items using slice operations.
+func (l *List) updateDirtyItems() {
+	if len(l.dirtyItems) == 0 {
+		return
+	}
+
+	// Check if all dirty items have unchanged heights
+	allSameHeight := true
+	for id := range l.dirtyItems {
+		idx, ok := l.indexMap[id]
+		if !ok {
+			continue
+		}
+
+		item := l.items[idx]
+		pos, ok := l.itemPositions[id]
+		if !ok {
+			l.dirty = true
+			l.dirtyItems = make(map[string]bool)
+			l.rebuildMasterBuffer()
+			return
+		}
+
+		newHeight := item.Height(l.width)
+		if newHeight != pos.height {
+			allSameHeight = false
+			break
+		}
+	}
+
+	// Optimization: If all dirty items have unchanged heights, re-render in place
+	if allSameHeight {
+		buf := l.masterBuffer.Buffer
+		for id := range l.dirtyItems {
+			idx := l.indexMap[id]
+			item := l.items[idx]
+			pos := l.itemPositions[id]
+
+			// Clear the item's area
+			for y := pos.startLine; y < pos.startLine+pos.height && y < len(buf.Lines); y++ {
+				buf.Lines[y] = uv.NewLine(l.width)
+			}
+
+			// Re-render item
+			area := uv.Rect(0, pos.startLine, l.width, pos.height)
+			item.Draw(l.masterBuffer, area)
+		}
+
+		l.dirtyItems = make(map[string]bool)
+		return
+	}
+
+	// Height changed - full rebuild
+	l.dirty = true
+	l.dirtyItems = make(map[string]bool)
+	l.rebuildMasterBuffer()
+}
+
+// updatePositionsBelow updates the startLine for all items below the given index.
+func (l *List) updatePositionsBelow(fromIdx int, delta int) {
+	for i := fromIdx + 1; i < len(l.items); i++ {
+		item := l.items[i]
+		pos := l.itemPositions[item.ID()]
+		pos.startLine += delta
+		l.itemPositions[item.ID()] = pos
+	}
+}
+
+// calculateTotalHeight calculates the total height of all items plus gaps.
+func (l *List) calculateTotalHeight() int {
+	if len(l.items) == 0 {
+		return 0
+	}
+
+	total := 0
+	for _, item := range l.items {
+		total += item.Height(l.width)
+	}
+	return total
+}
+
+// SetSize updates the viewport size.
+func (l *List) SetSize(width, height int) {
+	widthChanged := l.width != width
+	heightChanged := l.height != height
+
+	l.width = width
+	l.height = height
+
+	// Width changes require full rebuild (items may reflow)
+	if widthChanged {
+		l.dirty = true
+	}
+
+	// Height changes require clamping offset to new bounds
+	if heightChanged {
+		l.clampOffset()
+	}
+}
+
+// GetSize returns the current viewport size.
+func (l *List) GetSize() (int, int) {
+	return l.width, l.height
+}
+
+// Len returns the number of items in the list.
+func (l *List) Len() int {
+	return len(l.items)
+}
+
+// SetItems replaces all items in the list.
+func (l *List) SetItems(items []Item) {
+	l.items = items
+	l.indexMap = make(map[string]int, len(items))
+	l.itemPositions = make(map[string]itemPosition, len(items))
+
+	for i, item := range items {
+		l.indexMap[item.ID()] = i
+	}
+
+	l.dirty = true
+}
+
+// Items returns all items in the list.
+func (l *List) Items() []Item {
+	return l.items
+}
+
+// AppendItem adds an item to the end of the list.
+func (l *List) AppendItem(item Item) {
+	l.items = append(l.items, item)
+	l.indexMap[item.ID()] = len(l.items) - 1
+
+	// If buffer not built yet, mark dirty for full rebuild
+	if l.masterBuffer == nil || l.width <= 0 {
+		l.dirty = true
+		return
+	}
+
+	// Process any pending dirty items before modifying buffer structure
+	if len(l.dirtyItems) > 0 {
+		l.updateDirtyItems()
+	}
+
+	// Efficient append: insert lines at end of buffer
+	itemHeight := item.Height(l.width)
+	startLine := l.totalHeight
+
+	// Expand buffer
+	newLines := make([]uv.Line, itemHeight)
+	for i := range newLines {
+		newLines[i] = uv.NewLine(l.width)
+	}
+	l.masterBuffer.Buffer.Lines = append(l.masterBuffer.Buffer.Lines, newLines...)
+
+	// Draw new item
+	area := uv.Rect(0, startLine, l.width, itemHeight)
+	item.Draw(l.masterBuffer, area)
+
+	// Update tracking
+	l.itemPositions[item.ID()] = itemPosition{
+		startLine: startLine,
+		height:    itemHeight,
+	}
+	l.totalHeight += itemHeight
+}
+
+// PrependItem adds an item to the beginning of the list.
+func (l *List) PrependItem(item Item) {
+	l.items = append([]Item{item}, l.items...)
+
+	// Rebuild index map (all indices shifted)
+	l.indexMap = make(map[string]int, len(l.items))
+	for i, itm := range l.items {
+		l.indexMap[itm.ID()] = i
+	}
+
+	if l.selectedIdx >= 0 {
+		l.selectedIdx++
+	}
+
+	// If buffer not built yet, mark dirty for full rebuild
+	if l.masterBuffer == nil || l.width <= 0 {
+		l.dirty = true
+		return
+	}
+
+	// Process any pending dirty items before modifying buffer structure
+	if len(l.dirtyItems) > 0 {
+		l.updateDirtyItems()
+	}
+
+	// Efficient prepend: insert lines at start of buffer
+	itemHeight := item.Height(l.width)
+
+	// Create new lines
+	newLines := make([]uv.Line, itemHeight)
+	for i := range newLines {
+		newLines[i] = uv.NewLine(l.width)
+	}
+
+	// Insert at beginning
+	buf := l.masterBuffer.Buffer
+	buf.Lines = append(newLines, buf.Lines...)
+
+	// Draw new item
+	area := uv.Rect(0, 0, l.width, itemHeight)
+	item.Draw(l.masterBuffer, area)
+
+	// Update all positions (shift everything down)
+	for i := 1; i < len(l.items); i++ {
+		itm := l.items[i]
+		if pos, ok := l.itemPositions[itm.ID()]; ok {
+			pos.startLine += itemHeight
+			l.itemPositions[itm.ID()] = pos
+		}
+	}
+
+	// Add position for new item
+	l.itemPositions[item.ID()] = itemPosition{
+		startLine: 0,
+		height:    itemHeight,
+	}
+	l.totalHeight += itemHeight
+}
+
+// UpdateItem replaces an item with the same ID.
+func (l *List) UpdateItem(id string, item Item) {
+	idx, ok := l.indexMap[id]
+	if !ok {
+		return
+	}
+
+	l.items[idx] = item
+	l.dirtyItems[id] = true
+}
+
+// DeleteItem removes an item by ID.
+func (l *List) DeleteItem(id string) {
+	idx, ok := l.indexMap[id]
+	if !ok {
+		return
+	}
+
+	// Get position before deleting
+	pos, hasPos := l.itemPositions[id]
+
+	// Process any pending dirty items before modifying buffer structure
+	if len(l.dirtyItems) > 0 {
+		l.updateDirtyItems()
+	}
+
+	l.items = append(l.items[:idx], l.items[idx+1:]...)
+	delete(l.indexMap, id)
+	delete(l.itemPositions, id)
+
+	// Rebuild index map for items after deleted one
+	for i := idx; i < len(l.items); i++ {
+		l.indexMap[l.items[i].ID()] = i
+	}
+
+	// Adjust selection
+	if l.selectedIdx == idx {
+		if idx > 0 {
+			l.selectedIdx = idx - 1
+		} else if len(l.items) > 0 {
+			l.selectedIdx = 0
+		} else {
+			l.selectedIdx = -1
+		}
+	} else if l.selectedIdx > idx {
+		l.selectedIdx--
+	}
+
+	// If buffer not built yet, mark dirty for full rebuild
+	if l.masterBuffer == nil || !hasPos {
+		l.dirty = true
+		return
+	}
+
+	// Efficient delete: remove lines from buffer
+	deleteStart := pos.startLine
+	deleteEnd := pos.startLine + pos.height
+	buf := l.masterBuffer.Buffer
+
+	if deleteEnd <= len(buf.Lines) {
+		buf.Lines = append(buf.Lines[:deleteStart], buf.Lines[deleteEnd:]...)
+		l.totalHeight -= pos.height
+		l.updatePositionsBelow(idx-1, -pos.height)
+	} else {
+		// Position data corrupt, rebuild
+		l.dirty = true
+	}
+}
+
+// Focus focuses the list and the selected item (if focusable).
+func (l *List) Focus() {
+	l.focused = true
+	l.focusSelectedItem()
+}
+
+// Blur blurs the list and the selected item (if focusable).
+func (l *List) Blur() {
+	l.focused = false
+	l.blurSelectedItem()
+}
+
+// IsFocused returns whether the list is focused.
+func (l *List) IsFocused() bool {
+	return l.focused
+}
+
+// SetSelected sets the selected item by ID.
+func (l *List) SetSelected(id string) {
+	idx, ok := l.indexMap[id]
+	if !ok {
+		return
+	}
+
+	if l.selectedIdx == idx {
+		return
+	}
+
+	prevIdx := l.selectedIdx
+	l.selectedIdx = idx
+
+	// Update focus states if list is focused
+	if l.focused {
+		if prevIdx >= 0 && prevIdx < len(l.items) {
+			if f, ok := l.items[prevIdx].(Focusable); ok {
+				f.Blur()
+				l.dirtyItems[l.items[prevIdx].ID()] = true
+			}
+		}
+
+		if f, ok := l.items[idx].(Focusable); ok {
+			f.Focus()
+			l.dirtyItems[l.items[idx].ID()] = true
+		}
+	}
+}
+
+// SetSelectedIndex sets the selected item by index.
+func (l *List) SetSelectedIndex(idx int) {
+	if idx < 0 || idx >= len(l.items) {
+		return
+	}
+	l.SetSelected(l.items[idx].ID())
+}
+
+// SelectNext selects the next item in the list (wraps to beginning).
+// When the list is focused, skips non-focusable items.
+func (l *List) SelectNext() {
+	if len(l.items) == 0 {
+		return
+	}
+
+	startIdx := l.selectedIdx
+	for i := 0; i < len(l.items); i++ {
+		nextIdx := (startIdx + 1 + i) % len(l.items)
+
+		// If list is focused and item is not focusable, skip it
+		if l.focused {
+			if _, ok := l.items[nextIdx].(Focusable); !ok {
+				continue
+			}
+		}
+
+		// Select and scroll to this item
+		l.SetSelected(l.items[nextIdx].ID())
+		l.ScrollToSelected()
+		return
+	}
+}
+
+// SelectPrev selects the previous item in the list (wraps to end).
+// When the list is focused, skips non-focusable items.
+func (l *List) SelectPrev() {
+	if len(l.items) == 0 {
+		return
+	}
+
+	startIdx := l.selectedIdx
+	for i := 0; i < len(l.items); i++ {
+		prevIdx := (startIdx - 1 - i + len(l.items)) % len(l.items)
+
+		// If list is focused and item is not focusable, skip it
+		if l.focused {
+			if _, ok := l.items[prevIdx].(Focusable); !ok {
+				continue
+			}
+		}
+
+		// Select and scroll to this item
+		l.SetSelected(l.items[prevIdx].ID())
+		l.ScrollToSelected()
+		return
+	}
+}
+
+// SelectedItem returns the currently selected item, or nil if none.
+func (l *List) SelectedItem() Item {
+	if l.selectedIdx < 0 || l.selectedIdx >= len(l.items) {
+		return nil
+	}
+	return l.items[l.selectedIdx]
+}
+
+// SelectedIndex returns the index of the currently selected item, or -1 if none.
+func (l *List) SelectedIndex() int {
+	return l.selectedIdx
+}
+
+// AtBottom returns whether the viewport is scrolled to the bottom.
+func (l *List) AtBottom() bool {
+	l.ensureBuilt()
+	return l.offset >= l.totalHeight-l.height
+}
+
+// AtTop returns whether the viewport is scrolled to the top.
+func (l *List) AtTop() bool {
+	return l.offset <= 0
+}
+
+// ScrollBy scrolls the viewport by the given number of lines.
+// Positive values scroll down, negative scroll up.
+func (l *List) ScrollBy(deltaLines int) {
+	l.offset += deltaLines
+	l.clampOffset()
+}
+
+// ScrollToTop scrolls to the top of the list.
+func (l *List) ScrollToTop() {
+	l.offset = 0
+}
+
+// ScrollToBottom scrolls to the bottom of the list.
+func (l *List) ScrollToBottom() {
+	l.ensureBuilt()
+	if l.totalHeight > l.height {
+		l.offset = l.totalHeight - l.height
+	} else {
+		l.offset = 0
+	}
+}
+
+// ScrollToItem scrolls to make the item with the given ID visible.
+func (l *List) ScrollToItem(id string) {
+	l.ensureBuilt()
+	pos, ok := l.itemPositions[id]
+	if !ok {
+		return
+	}
+
+	itemStart := pos.startLine
+	itemEnd := pos.startLine + pos.height
+	viewStart := l.offset
+	viewEnd := l.offset + l.height
+
+	// Check if item is already fully visible
+	if itemStart >= viewStart && itemEnd <= viewEnd {
+		return
+	}
+
+	// Scroll to show item
+	if itemStart < viewStart {
+		l.offset = itemStart
+	} else if itemEnd > viewEnd {
+		l.offset = itemEnd - l.height
+	}
+
+	l.clampOffset()
+}
+
+// ScrollToSelected scrolls to make the selected item visible.
+func (l *List) ScrollToSelected() {
+	if l.selectedIdx < 0 || l.selectedIdx >= len(l.items) {
+		return
+	}
+	l.ScrollToItem(l.items[l.selectedIdx].ID())
+}
+
+// Offset returns the current scroll offset.
+func (l *List) Offset() int {
+	return l.offset
+}
+
+// TotalHeight returns the total height of all items including gaps.
+func (l *List) TotalHeight() int {
+	return l.totalHeight
+}
+
+// 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
+	}
+}
+
+// focusSelectedItem focuses the currently selected item if it's focusable.
+func (l *List) focusSelectedItem() {
+	if l.selectedIdx < 0 || l.selectedIdx >= len(l.items) {
+		return
+	}
+
+	item := l.items[l.selectedIdx]
+	if f, ok := item.(Focusable); ok {
+		f.Focus()
+		l.dirtyItems[item.ID()] = true
+	}
+}
+
+// blurSelectedItem blurs the currently selected item if it's focusable.
+func (l *List) blurSelectedItem() {
+	if l.selectedIdx < 0 || l.selectedIdx >= len(l.items) {
+		return
+	}
+
+	item := l.items[l.selectedIdx]
+	if f, ok := item.(Focusable); ok {
+		f.Blur()
+		l.dirtyItems[item.ID()] = true
+	}
+}

internal/ui/list/list_test.go 🔗

@@ -0,0 +1,586 @@
+package list
+
+import (
+	"strings"
+	"testing"
+
+	"charm.land/lipgloss/v2"
+	uv "github.com/charmbracelet/ultraviolet"
+	"github.com/stretchr/testify/require"
+)
+
+func TestNewList(t *testing.T) {
+	items := []Item{
+		NewStringItem("1", "Item 1"),
+		NewStringItem("2", "Item 2"),
+		NewStringItem("3", "Item 3"),
+	}
+
+	l := New(items...)
+	l.SetSize(80, 24)
+
+	if len(l.items) != 3 {
+		t.Errorf("expected 3 items, got %d", len(l.items))
+	}
+
+	if l.width != 80 || l.height != 24 {
+		t.Errorf("expected size 80x24, got %dx%d", l.width, l.height)
+	}
+}
+
+func TestListDraw(t *testing.T) {
+	items := []Item{
+		NewStringItem("1", "Item 1"),
+		NewStringItem("2", "Item 2"),
+		NewStringItem("3", "Item 3"),
+	}
+
+	l := New(items...)
+	l.SetSize(80, 10)
+
+	// Create a screen buffer to draw into
+	screen := uv.NewScreenBuffer(80, 10)
+	area := uv.Rect(0, 0, 80, 10)
+
+	// Draw the list
+	l.Draw(&screen, area)
+
+	// Verify the buffer has content
+	output := screen.Render()
+	if len(output) == 0 {
+		t.Error("expected non-empty output")
+	}
+}
+
+func TestListAppendItem(t *testing.T) {
+	items := []Item{
+		NewStringItem("1", "Item 1"),
+	}
+
+	l := New(items...)
+	l.AppendItem(NewStringItem("2", "Item 2"))
+
+	if len(l.items) != 2 {
+		t.Errorf("expected 2 items after append, got %d", len(l.items))
+	}
+
+	if l.items[1].ID() != "2" {
+		t.Errorf("expected item ID '2', got '%s'", l.items[1].ID())
+	}
+}
+
+func TestListDeleteItem(t *testing.T) {
+	items := []Item{
+		NewStringItem("1", "Item 1"),
+		NewStringItem("2", "Item 2"),
+		NewStringItem("3", "Item 3"),
+	}
+
+	l := New(items...)
+	l.DeleteItem("2")
+
+	if len(l.items) != 2 {
+		t.Errorf("expected 2 items after delete, got %d", len(l.items))
+	}
+
+	if l.items[1].ID() != "3" {
+		t.Errorf("expected item ID '3', got '%s'", l.items[1].ID())
+	}
+}
+
+func TestListUpdateItem(t *testing.T) {
+	items := []Item{
+		NewStringItem("1", "Item 1"),
+		NewStringItem("2", "Item 2"),
+	}
+
+	l := New(items...)
+	l.SetSize(80, 10)
+
+	// Update item
+	newItem := NewStringItem("2", "Updated Item 2")
+	l.UpdateItem("2", newItem)
+
+	if l.items[1].(*StringItem).content != "Updated Item 2" {
+		t.Errorf("expected updated content, got '%s'", l.items[1].(*StringItem).content)
+	}
+}
+
+func TestListSelection(t *testing.T) {
+	items := []Item{
+		NewStringItem("1", "Item 1"),
+		NewStringItem("2", "Item 2"),
+		NewStringItem("3", "Item 3"),
+	}
+
+	l := New(items...)
+	l.SetSelectedIndex(0)
+
+	if l.SelectedIndex() != 0 {
+		t.Errorf("expected selected index 0, got %d", l.SelectedIndex())
+	}
+
+	l.SelectNext()
+	if l.SelectedIndex() != 1 {
+		t.Errorf("expected selected index 1 after SelectNext, got %d", l.SelectedIndex())
+	}
+
+	l.SelectPrev()
+	if l.SelectedIndex() != 0 {
+		t.Errorf("expected selected index 0 after SelectPrev, got %d", l.SelectedIndex())
+	}
+}
+
+func TestListScrolling(t *testing.T) {
+	items := []Item{
+		NewStringItem("1", "Item 1"),
+		NewStringItem("2", "Item 2"),
+		NewStringItem("3", "Item 3"),
+		NewStringItem("4", "Item 4"),
+		NewStringItem("5", "Item 5"),
+	}
+
+	l := New(items...)
+	l.SetSize(80, 2) // Small viewport
+
+	// Draw to initialize the master buffer
+	screen := uv.NewScreenBuffer(80, 2)
+	area := uv.Rect(0, 0, 80, 2)
+	l.Draw(&screen, area)
+
+	if l.Offset() != 0 {
+		t.Errorf("expected initial offset 0, got %d", l.Offset())
+	}
+
+	l.ScrollBy(2)
+	if l.Offset() != 2 {
+		t.Errorf("expected offset 2 after ScrollBy(2), got %d", l.Offset())
+	}
+
+	l.ScrollToTop()
+	if l.Offset() != 0 {
+		t.Errorf("expected offset 0 after ScrollToTop, got %d", l.Offset())
+	}
+}
+
+// FocusableTestItem is a test item that implements Focusable.
+type FocusableTestItem struct {
+	id      string
+	content string
+	focused bool
+}
+
+func (f *FocusableTestItem) ID() string {
+	return f.id
+}
+
+func (f *FocusableTestItem) Height(width int) int {
+	return 1
+}
+
+func (f *FocusableTestItem) Draw(scr uv.Screen, area uv.Rectangle) {
+	prefix := "[ ]"
+	if f.focused {
+		prefix = "[X]"
+	}
+	content := prefix + " " + f.content
+	styled := uv.NewStyledString(content)
+	styled.Draw(scr, area)
+}
+
+func (f *FocusableTestItem) Focus() {
+	f.focused = true
+}
+
+func (f *FocusableTestItem) Blur() {
+	f.focused = false
+}
+
+func (f *FocusableTestItem) IsFocused() bool {
+	return f.focused
+}
+
+func TestListFocus(t *testing.T) {
+	items := []Item{
+		&FocusableTestItem{id: "1", content: "Item 1"},
+		&FocusableTestItem{id: "2", content: "Item 2"},
+	}
+
+	l := New(items...)
+	l.SetSize(80, 10)
+	l.SetSelectedIndex(0)
+
+	// Focus the list
+	l.Focus()
+
+	if !l.IsFocused() {
+		t.Error("expected list to be focused")
+	}
+
+	// Check if selected item is focused
+	selectedItem := l.SelectedItem().(*FocusableTestItem)
+	if !selectedItem.IsFocused() {
+		t.Error("expected selected item to be focused")
+	}
+
+	// Select next and check focus changes
+	l.SelectNext()
+	if selectedItem.IsFocused() {
+		t.Error("expected previous item to be blurred")
+	}
+
+	newSelectedItem := l.SelectedItem().(*FocusableTestItem)
+	if !newSelectedItem.IsFocused() {
+		t.Error("expected new selected item to be focused")
+	}
+
+	// Blur the list
+	l.Blur()
+	if l.IsFocused() {
+		t.Error("expected list to be blurred")
+	}
+}
+
+// TestFocusNavigationAfterAppendingToViewportHeight reproduces the bug:
+// Append items until viewport is full, select last, then navigate backwards.
+func TestFocusNavigationAfterAppendingToViewportHeight(t *testing.T) {
+	t.Parallel()
+
+	focusStyle := lipgloss.NewStyle().
+		Border(lipgloss.RoundedBorder()).
+		BorderForeground(lipgloss.Color("86"))
+
+	blurStyle := lipgloss.NewStyle().
+		Border(lipgloss.RoundedBorder()).
+		BorderForeground(lipgloss.Color("240"))
+
+	// Start with one item
+	items := []Item{
+		NewStringItem("1", "Item 1").WithFocusStyles(&focusStyle, &blurStyle),
+	}
+
+	l := New(items...)
+	l.SetSize(20, 15) // 15 lines viewport height
+	l.SetSelectedIndex(0)
+	l.Focus()
+
+	// Initial draw to build buffer
+	screen := uv.NewScreenBuffer(20, 15)
+	l.Draw(&screen, uv.Rect(0, 0, 20, 15))
+
+	// Append items until we exceed viewport height
+	// Each focusable item with border is 5 lines tall
+	for i := 2; i <= 4; i++ {
+		item := NewStringItem(string(rune('0'+i)), "Item "+string(rune('0'+i))).WithFocusStyles(&focusStyle, &blurStyle)
+		l.AppendItem(item)
+	}
+
+	// Select the last item
+	l.SetSelectedIndex(3)
+
+	// Draw
+	screen = uv.NewScreenBuffer(20, 15)
+	l.Draw(&screen, uv.Rect(0, 0, 20, 15))
+	output := screen.Render()
+
+	t.Logf("After selecting last item:\n%s", output)
+	require.Contains(t, output, "38;5;86", "expected focus color on last item")
+
+	// Now navigate backwards
+	l.SelectPrev()
+
+	screen = uv.NewScreenBuffer(20, 15)
+	l.Draw(&screen, uv.Rect(0, 0, 20, 15))
+	output = screen.Render()
+
+	t.Logf("After SelectPrev:\n%s", output)
+	require.Contains(t, output, "38;5;86", "expected focus color after SelectPrev")
+
+	// Navigate backwards again
+	l.SelectPrev()
+
+	screen = uv.NewScreenBuffer(20, 15)
+	l.Draw(&screen, uv.Rect(0, 0, 20, 15))
+	output = screen.Render()
+
+	t.Logf("After second SelectPrev:\n%s", output)
+	require.Contains(t, output, "38;5;86", "expected focus color after second SelectPrev")
+}
+
+func TestFocusableItemUpdate(t *testing.T) {
+	// Create styles with borders
+	focusStyle := lipgloss.NewStyle().
+		Border(lipgloss.RoundedBorder()).
+		BorderForeground(lipgloss.Color("86"))
+
+	blurStyle := lipgloss.NewStyle().
+		Border(lipgloss.RoundedBorder()).
+		BorderForeground(lipgloss.Color("240"))
+
+	// Create a focusable item
+	item := NewStringItem("1", "Test Item").WithFocusStyles(&focusStyle, &blurStyle)
+
+	// Initially not focused - render with blur style
+	screen1 := uv.NewScreenBuffer(20, 5)
+	area := uv.Rect(0, 0, 20, 5)
+	item.Draw(&screen1, area)
+	output1 := screen1.Render()
+
+	// Focus the item
+	item.Focus()
+
+	// Render again - should show focus style
+	screen2 := uv.NewScreenBuffer(20, 5)
+	item.Draw(&screen2, area)
+	output2 := screen2.Render()
+
+	// Outputs should be different (different border colors)
+	if output1 == output2 {
+		t.Error("expected different output after focusing, but got same output")
+	}
+
+	// Verify focus state
+	if !item.IsFocused() {
+		t.Error("expected item to be focused")
+	}
+
+	// Blur the item
+	item.Blur()
+
+	// Render again - should show blur style again
+	screen3 := uv.NewScreenBuffer(20, 5)
+	item.Draw(&screen3, area)
+	output3 := screen3.Render()
+
+	// Output should match original blur output
+	if output1 != output3 {
+		t.Error("expected same output after blurring as initial state")
+	}
+
+	// Verify blur state
+	if item.IsFocused() {
+		t.Error("expected item to be blurred")
+	}
+}
+
+func TestFocusableItemHeightWithBorder(t *testing.T) {
+	// Create a style with a border (adds 2 to vertical height)
+	borderStyle := lipgloss.NewStyle().
+		Border(lipgloss.RoundedBorder())
+
+	// Item without styles has height 1
+	plainItem := NewStringItem("1", "Test")
+	plainHeight := plainItem.Height(20)
+	if plainHeight != 1 {
+		t.Errorf("expected plain height 1, got %d", plainHeight)
+	}
+
+	// Item with border should add border height (2 lines)
+	item := NewStringItem("2", "Test").WithFocusStyles(&borderStyle, &borderStyle)
+	itemHeight := item.Height(20)
+	expectedHeight := 1 + 2 // content + border
+	if itemHeight != expectedHeight {
+		t.Errorf("expected height %d (content 1 + border 2), got %d",
+			expectedHeight, itemHeight)
+	}
+}
+
+func TestFocusableItemInList(t *testing.T) {
+	focusStyle := lipgloss.NewStyle().
+		Border(lipgloss.RoundedBorder()).
+		BorderForeground(lipgloss.Color("86"))
+
+	blurStyle := lipgloss.NewStyle().
+		Border(lipgloss.RoundedBorder()).
+		BorderForeground(lipgloss.Color("240"))
+
+	// Create list with focusable items
+	items := []Item{
+		NewStringItem("1", "Item 1").WithFocusStyles(&focusStyle, &blurStyle),
+		NewStringItem("2", "Item 2").WithFocusStyles(&focusStyle, &blurStyle),
+		NewStringItem("3", "Item 3").WithFocusStyles(&focusStyle, &blurStyle),
+	}
+
+	l := New(items...)
+	l.SetSize(80, 20)
+	l.SetSelectedIndex(0)
+
+	// Focus the list
+	l.Focus()
+
+	// First item should be focused
+	firstItem := items[0].(*StringItem)
+	if !firstItem.IsFocused() {
+		t.Error("expected first item to be focused after focusing list")
+	}
+
+	// Render to ensure changes are visible
+	output1 := l.Render()
+	if !strings.Contains(output1, "Item 1") {
+		t.Error("expected output to contain first item")
+	}
+
+	// Select second item
+	l.SetSelectedIndex(1)
+
+	// First item should be blurred, second focused
+	if firstItem.IsFocused() {
+		t.Error("expected first item to be blurred after changing selection")
+	}
+
+	secondItem := items[1].(*StringItem)
+	if !secondItem.IsFocused() {
+		t.Error("expected second item to be focused after selection")
+	}
+
+	// Render again - should show updated focus
+	output2 := l.Render()
+	if !strings.Contains(output2, "Item 2") {
+		t.Error("expected output to contain second item")
+	}
+
+	// Outputs should be different
+	if output1 == output2 {
+		t.Error("expected different output after selection change")
+	}
+}
+
+func TestFocusableItemWithNilStyles(t *testing.T) {
+	// Test with nil styles - should render inner item directly
+	item := NewStringItem("1", "Plain Item").WithFocusStyles(nil, nil)
+
+	// Height should be based on content (no border since styles are nil)
+	itemHeight := item.Height(20)
+	if itemHeight != 1 {
+		t.Errorf("expected height 1 (no border), got %d", itemHeight)
+	}
+
+	// Draw should work without styles
+	screen := uv.NewScreenBuffer(20, 5)
+	area := uv.Rect(0, 0, 20, 5)
+	item.Draw(&screen, area)
+	output := screen.Render()
+
+	// Should contain the inner content
+	if !strings.Contains(output, "Plain Item") {
+		t.Error("expected output to contain inner item content")
+	}
+
+	// Focus/blur should still work but not change appearance
+	item.Focus()
+	screen2 := uv.NewScreenBuffer(20, 5)
+	item.Draw(&screen2, area)
+	output2 := screen2.Render()
+
+	// Output should be identical since no styles
+	if output != output2 {
+		t.Error("expected same output with nil styles whether focused or not")
+	}
+
+	if !item.IsFocused() {
+		t.Error("expected item to be focused")
+	}
+}
+
+func TestFocusableItemWithOnlyFocusStyle(t *testing.T) {
+	// Test with only focus style (blur is nil)
+	focusStyle := lipgloss.NewStyle().
+		Border(lipgloss.RoundedBorder()).
+		BorderForeground(lipgloss.Color("86"))
+
+	item := NewStringItem("1", "Test").WithFocusStyles(&focusStyle, nil)
+
+	// When not focused, should use nil blur style (no border)
+	screen1 := uv.NewScreenBuffer(20, 5)
+	area := uv.Rect(0, 0, 20, 5)
+	item.Draw(&screen1, area)
+	output1 := screen1.Render()
+
+	// Focus the item
+	item.Focus()
+	screen2 := uv.NewScreenBuffer(20, 5)
+	item.Draw(&screen2, area)
+	output2 := screen2.Render()
+
+	// Outputs should be different (focused has border, blurred doesn't)
+	if output1 == output2 {
+		t.Error("expected different output when only focus style is set")
+	}
+}
+
+func TestFocusableItemLastLineNotEaten(t *testing.T) {
+	// Create focusable items with borders
+	focusStyle := lipgloss.NewStyle().
+		Padding(1).
+		Border(lipgloss.RoundedBorder()).
+		BorderForeground(lipgloss.Color("86"))
+
+	blurStyle := lipgloss.NewStyle().
+		BorderForeground(lipgloss.Color("240"))
+
+	items := []Item{
+		NewStringItem("1", "Item 1").WithFocusStyles(&focusStyle, &blurStyle),
+		Gap,
+		NewStringItem("2", "Item 2").WithFocusStyles(&focusStyle, &blurStyle),
+		Gap,
+		NewStringItem("3", "Item 3").WithFocusStyles(&focusStyle, &blurStyle),
+		Gap,
+		NewStringItem("4", "Item 4").WithFocusStyles(&focusStyle, &blurStyle),
+		Gap,
+		NewStringItem("5", "Item 5").WithFocusStyles(&focusStyle, &blurStyle),
+	}
+
+	// Items with padding(1) and border are 5 lines each
+	// Viewport of 10 lines fits exactly 2 items
+	l := New()
+	l.SetSize(20, 10)
+
+	for _, item := range items {
+		l.AppendItem(item)
+	}
+
+	// Focus the list
+	l.Focus()
+
+	// Select last item
+	l.SetSelectedIndex(len(items) - 1)
+
+	// Scroll to bottom
+	l.ScrollToBottom()
+
+	output := l.Render()
+
+	t.Logf("Output:\n%s", output)
+	t.Logf("Offset: %d, Total height: %d", l.offset, l.TotalHeight())
+
+	// Select previous - will skip gaps and go to Item 4
+	l.SelectPrev()
+
+	output = l.Render()
+
+	t.Logf("Output:\n%s", output)
+	t.Logf("Offset: %d, Total height: %d", l.offset, l.TotalHeight())
+
+	// Should show items 3 (unfocused), 4 (focused), and part of 5 (unfocused)
+	if !strings.Contains(output, "Item 3") {
+		t.Error("expected output to contain 'Item 3'")
+	}
+	if !strings.Contains(output, "Item 4") {
+		t.Error("expected output to contain 'Item 4'")
+	}
+	if !strings.Contains(output, "Item 5") {
+		t.Error("expected output to contain 'Item 5'")
+	}
+
+	// Count bottom borders - should have 1 (focused item 4)
+	bottomBorderCount := 0
+	for _, line := range strings.Split(output, "\r\n") {
+		if strings.Contains(line, "╰") || strings.Contains(line, "└") {
+			bottomBorderCount++
+		}
+	}
+
+	if bottomBorderCount != 1 {
+		t.Errorf("expected 1 bottom border (focused item 4), got %d", bottomBorderCount)
+	}
+}

internal/ui/model/ui.go 🔗

@@ -2,6 +2,7 @@ package model
 
 import (
 	"context"
+	"fmt"
 	"image"
 	"math/rand"
 	"os"
@@ -21,6 +22,7 @@ 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"
@@ -75,7 +77,7 @@ type UI struct {
 	keyMap KeyMap
 	keyenh tea.KeyboardEnhancementsMsg
 
-	chat   *ChatModel
+	chat   *list.List
 	dialog *dialog.Overlay
 	help   help.Model
 
@@ -123,6 +125,8 @@ func New(com *common.Common) *UI {
 	ta.SetVirtualCursor(false)
 	ta.Focus()
 
+	l := list.New()
+
 	ui := &UI{
 		com:      com,
 		dialog:   dialog.NewOverlay(),
@@ -131,6 +135,7 @@ func New(com *common.Common) *UI {
 		focus:    uiFocusNone,
 		state:    uiConfigure,
 		textarea: ta,
+		chat:     l,
 	}
 
 	// set onboarding state defaults
@@ -194,6 +199,7 @@ 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()
 	case tea.KeyboardEnhancementsMsg:
 		m.keyenh = msg
 		if msg.SupportsKeyDisambiguation() {
@@ -236,6 +242,23 @@ 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:
@@ -317,11 +340,8 @@ func (m *UI) Draw(scr uv.Screen, area uv.Rectangle) {
 		header := uv.NewStyledString(m.header)
 		header.Draw(scr, layout.header)
 		m.drawSidebar(scr, layout.sidebar)
-		mainView := lipgloss.NewStyle().Width(layout.main.Dx()).
-			Height(layout.main.Dy()).
-			Render(" Chat Messages ")
-		main := uv.NewStyledString(mainView)
-		main.Draw(scr, layout.main)
+
+		m.chat.Draw(scr, layout.main)
 
 		editor := uv.NewStyledString(m.textarea.View())
 		editor.Draw(scr, layout.editor)
@@ -517,7 +537,6 @@ func (m *UI) updateFocused(msg tea.KeyPressMsg) (cmds []tea.Cmd) {
 	case uiChat, uiLanding, uiChatCompact:
 		switch m.focus {
 		case uiFocusMain:
-			cmds = append(cmds, m.updateChat(msg)...)
 		case uiFocusEditor:
 			switch {
 			case key.Matches(msg, m.keyMap.Editor.Newline):
@@ -533,15 +552,6 @@ func (m *UI) updateFocused(msg tea.KeyPressMsg) (cmds []tea.Cmd) {
 	return cmds
 }
 
-// updateChat updates the chat model with the given message and appends any
-// resulting commands to the cmds slice.
-func (m *UI) updateChat(msg tea.KeyPressMsg) (cmds []tea.Cmd) {
-	updatedChat, cmd := m.chat.Update(msg)
-	m.chat = updatedChat
-	cmds = append(cmds, cmd)
-	return cmds
-}
-
 // updateLayoutAndSize updates the layout and sizes of UI components.
 func (m *UI) updateLayoutAndSize() {
 	m.layout = generateLayout(m, m.width, m.height)
@@ -567,6 +577,7 @@ func (m *UI) updateSize() {
 		m.renderSidebarLogo(m.layout.sidebar.Dx())
 		m.textarea.SetWidth(m.layout.editor.Dx())
 		m.textarea.SetHeight(m.layout.editor.Dy())
+		m.chat.SetSize(m.layout.main.Dx(), m.layout.main.Dy())
 
 	case uiChatCompact:
 		// TODO: set the width and heigh of the chat component
@@ -667,6 +678,8 @@ 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))
+		// Add bottom margin to main
+		mainRect.Max.Y -= 1
 		layout.sidebar = sideRect
 		layout.main = mainRect
 		layout.editor = editorRect