Merge remote-tracking branch 'origin/list' into improve_agent_promt

Kujtim Hoxha created

Change summary

cspell.json                                                                                                                                             |  108 
go.mod                                                                                                                                                  |    2 
go.sum                                                                                                                                                  |    4 
internal/csync/slices.go                                                                                                                                |  127 
internal/csync/slices_test.go                                                                                                                           |  209 
internal/tui/components/anim/anim.go                                                                                                                    |  173 
internal/tui/components/chat/chat.go                                                                                                                    |   60 
internal/tui/components/chat/messages/messages.go                                                                                                       |   18 
internal/tui/components/chat/messages/tool.go                                                                                                           |    5 
internal/tui/components/chat/splash/splash.go                                                                                                           |   25 
internal/tui/components/completions/completions.go                                                                                                      |   60 
internal/tui/components/core/list/list.go                                                                                                               | 1371 
internal/tui/components/dialogs/commands/commands.go                                                                                                    |   67 
internal/tui/components/dialogs/commands/item.go                                                                                                        |   69 
internal/tui/components/dialogs/models/list.go                                                                                                          |  103 
internal/tui/components/dialogs/models/models.go                                                                                                        |   28 
internal/tui/components/dialogs/sessions/sessions.go                                                                                                    |   63 
internal/tui/exp/list/filterable.go                                                                                                                     |  308 
internal/tui/exp/list/filterable_group.go                                                                                                               |  260 
internal/tui/exp/list/filterable_test.go                                                                                                                |   68 
internal/tui/exp/list/grouped.go                                                                                                                        |  101 
internal/tui/exp/list/items.go                                                                                                                          |  167 
internal/tui/exp/list/keys.go                                                                                                                           |    4 
internal/tui/exp/list/list.go                                                                                                                           | 1005 
internal/tui/exp/list/list_test.go                                                                                                                      |  652 
internal/tui/exp/list/testdata/TestFilterableList/should_create_simple_filterable_list.golden                                                           |   10 
internal/tui/exp/list/testdata/TestList/should_go_to_selected_item_at_the_beginning.golden                                                              |   10 
internal/tui/exp/list/testdata/TestList/should_go_to_selected_item_at_the_beginning_backwards.golden                                                    |   10 
internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_does_not_fits_the_items.golden                                       |   10 
internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_does_not_fits_the_items_and_has_multi_line_items.golden              |   10 
internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_does_not_fits_the_items_and_has_multi_line_items_backwards.golden    |   10 
internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_does_not_fits_the_items_backwards.golden                             |   10 
internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_fits_the_items.golden                                                |   20 
internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_fits_the_items_backwards.golden                                      |   20 
internal/tui/exp/list/testdata/TestListMovement/should_move_viewport_down.golden                                                                        |   10 
internal/tui/exp/list/testdata/TestListMovement/should_move_viewport_down_and_up.golden                                                                 |   10 
internal/tui/exp/list/testdata/TestListMovement/should_move_viewport_up.golden                                                                          |   10 
internal/tui/exp/list/testdata/TestListMovement/should_move_viewport_up_and_down.golden                                                                 |   10 
internal/tui/exp/list/testdata/TestListMovement/should_not_change_offset_when_new_items_are_appended_and_we_are_at_the_bottom_in_backwards_list.golden  |   10 
internal/tui/exp/list/testdata/TestListMovement/should_not_change_offset_when_new_items_are_prepended_and_we_are_at_the_top_in_forward_list.golden      |   10 
internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_if_an_item_is_appended_and_we_are_in_forward_list.golden              |   10 
internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_if_an_item_is_prepended_and_we_are_in_backwards_list.golden           |   10 
internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_new_items_are_added_but_we_moved_down_in_forward_list.golden     |   10 
internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_new_items_are_added_but_we_moved_up_in_backwards_list.golden     |   10 
internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_above_is_decreases_in_forward_list.golden   |   10 
internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_above_is_increased_in_backwards_list.golden |   10 
internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_above_is_increased_in_forward_list.golden   |   10 
internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_below_is_decreases_in_backwards_list.golden |   10 
internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_below_is_increased_in_backwards_list.golden |   10 
internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_below_is_increased_in_forward_list.golden   |   10 
internal/tui/page/chat/chat.go                                                                                                                          |   36 
internal/tui/tui.go                                                                                                                                     |   23 
52 files changed, 3,513 insertions(+), 1,873 deletions(-)

Detailed changes

cspell.json 🔗

@@ -1,108 +1 @@
-{
-  "words": [
-    "afero",
-    "agentic",
-    "alecthomas",
-    "anthropics",
-    "aymanbagabas",
-    "azidentity",
-    "bmatcuk",
-    "bubbletea",
-    "charlievieth",
-    "charmbracelet",
-    "charmtone",
-    "Charple",
-    "chkconfig",
-    "crush",
-    "curlie",
-    "cursorrules",
-    "diffview",
-    "doas",
-    "Dockerfiles",
-    "doublestar",
-    "dpkg",
-    "Emph",
-    "fastwalk",
-    "fdisk",
-    "filepicker",
-    "Focusable",
-    "fseventsd",
-    "fsext",
-    "genai",
-    "goquery",
-    "GROQ",
-    "Guac",
-    "imageorient",
-    "Inex",
-    "jetta",
-    "jsons",
-    "jsonschema",
-    "jspm",
-    "Kaufmann",
-    "killall",
-    "Lanczos",
-    "lipgloss",
-    "LOCALAPPDATA",
-    "lsps",
-    "lucasb",
-    "makepkg",
-    "mcps",
-    "MSYS",
-    "mvdan",
-    "natefinch",
-    "nfnt",
-    "noctx",
-    "nohup",
-    "nolint",
-    "nslookup",
-    "oksvg",
-    "Oneshot",
-    "openrouter",
-    "opkg",
-    "pacman",
-    "paru",
-    "pfctl",
-    "postamble",
-    "postambles",
-    "preconfigured",
-    "Preproc",
-    "Proactiveness",
-    "Puerkito",
-    "pycache",
-    "pytest",
-    "qjebbs",
-    "rasterx",
-    "rivo",
-    "sabhiram",
-    "sess",
-    "shortlog",
-    "sjson",
-    "Sourcegraph",
-    "srwiley",
-    "SSEMCP",
-    "Streamable",
-    "stretchr",
-    "Strikethrough",
-    "substrs",
-    "Suscriber",
-    "systeminfo",
-    "tasklist",
-    "termenv",
-    "textinput",
-    "tidwall",
-    "timedout",
-    "trashhalo",
-    "udiff",
-    "uniseg",
-    "Unticked",
-    "urllib",
-    "USERPROFILE",
-    "VERTEXAI",
-    "webp",
-    "whatis",
-    "whereis"
-  ],
-  "flagWords": [],
-  "language": "en",
-  "version": "0.2"
-}

go.mod 🔗

@@ -12,7 +12,7 @@ require (
 	github.com/bmatcuk/doublestar/v4 v4.9.0
 	github.com/charlievieth/fastwalk v1.0.11
 	github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250716191546-1e2ffbbcf5c5
-	github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4.0.20250717140350-bb75e8f6b6ac
+	github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4.0.20250724172607-5ba56e2bec69
 	github.com/charmbracelet/catwalk v0.3.1
 	github.com/charmbracelet/fang v0.3.1-0.20250711140230-d5ebb8c1d674
 	github.com/charmbracelet/glamour/v2 v2.0.0-20250516160903-6f1e2c8f9ebe

go.sum 🔗

@@ -70,8 +70,8 @@ github.com/charlievieth/fastwalk v1.0.11 h1:5sLT/q9+d9xMdpKExawLppqvXFZCVKf6JHnr
 github.com/charlievieth/fastwalk v1.0.11/go.mod h1:yGy1zbxog41ZVMcKA/i8ojXLFsuayX5VvwhQVoj9PBI=
 github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250716191546-1e2ffbbcf5c5 h1:GTcMIfDQJKyNKS+xVt7GkNIwz+tBuQtIuiP50WpzNgs=
 github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250716191546-1e2ffbbcf5c5/go.mod h1:6HamsBKWqEC/FVHuQMHgQL+knPyvHH55HwJDHl/adMw=
-github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4.0.20250717140350-bb75e8f6b6ac h1:murtkvFYxZ/73vk4Z/tpE4biB+WDZcFmmBp8je/yV6M=
-github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4.0.20250717140350-bb75e8f6b6ac/go.mod h1:m240IQxo1/eDQ7klblSzOCAUyc3LddHcV3Rc/YEGAgw=
+github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4.0.20250724172607-5ba56e2bec69 h1:nXLMl4ows2qogDXhuEtDNgFNXQiU+PJer+UEBsQZuns=
+github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4.0.20250724172607-5ba56e2bec69/go.mod h1:XIQ1qQfRph6Z5o2EikCydjumo0oDInQySRHuPATzbZc=
 github.com/charmbracelet/catwalk v0.3.1 h1:MkGWspcMyE659zDkqS+9wsaCMTKRFEDBFY2A2sap6+U=
 github.com/charmbracelet/catwalk v0.3.1/go.mod h1:gUUCqqZ8bk4D7ZzGTu3I77k7cC2x4exRuJBN1H2u2pc=
 github.com/charmbracelet/colorprofile v0.3.1 h1:k8dTHMd7fgw4bnFd7jXTLZrSU/CQrKnL3m+AxCzDz40=

internal/csync/slices.go 🔗

@@ -2,6 +2,7 @@ package csync
 
 import (
 	"iter"
+	"slices"
 	"sync"
 )
 
@@ -34,3 +35,129 @@ func (s *LazySlice[K]) Seq() iter.Seq[K] {
 		}
 	}
 }
+
+// Slice is a thread-safe slice implementation that provides concurrent access.
+type Slice[T any] struct {
+	inner []T
+	mu    sync.RWMutex
+}
+
+// NewSlice creates a new thread-safe slice.
+func NewSlice[T any]() *Slice[T] {
+	return &Slice[T]{
+		inner: make([]T, 0),
+	}
+}
+
+// NewSliceFrom creates a new thread-safe slice from an existing slice.
+func NewSliceFrom[T any](s []T) *Slice[T] {
+	inner := make([]T, len(s))
+	copy(inner, s)
+	return &Slice[T]{
+		inner: inner,
+	}
+}
+
+// Append adds an element to the end of the slice.
+func (s *Slice[T]) Append(item T) {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+	s.inner = append(s.inner, item)
+}
+
+// Prepend adds an element to the beginning of the slice.
+func (s *Slice[T]) Prepend(item T) {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+	s.inner = append([]T{item}, s.inner...)
+}
+
+// Delete removes the element at the specified index.
+func (s *Slice[T]) Delete(index int) bool {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+	if index < 0 || index >= len(s.inner) {
+		return false
+	}
+	s.inner = slices.Delete(s.inner, index, index+1)
+	return true
+}
+
+// Get returns the element at the specified index.
+func (s *Slice[T]) Get(index int) (T, bool) {
+	s.mu.RLock()
+	defer s.mu.RUnlock()
+	var zero T
+	if index < 0 || index >= len(s.inner) {
+		return zero, false
+	}
+	return s.inner[index], true
+}
+
+// Set updates the element at the specified index.
+func (s *Slice[T]) Set(index int, item T) bool {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+	if index < 0 || index >= len(s.inner) {
+		return false
+	}
+	s.inner[index] = item
+	return true
+}
+
+// Len returns the number of elements in the slice.
+func (s *Slice[T]) Len() int {
+	s.mu.RLock()
+	defer s.mu.RUnlock()
+	return len(s.inner)
+}
+
+// Slice returns a copy of the underlying slice.
+func (s *Slice[T]) Slice() []T {
+	s.mu.RLock()
+	defer s.mu.RUnlock()
+	result := make([]T, len(s.inner))
+	copy(result, s.inner)
+	return result
+}
+
+// SetSlice replaces the entire slice with a new one.
+func (s *Slice[T]) SetSlice(items []T) {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+	s.inner = make([]T, len(items))
+	copy(s.inner, items)
+}
+
+// Clear removes all elements from the slice.
+func (s *Slice[T]) Clear() {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+	s.inner = s.inner[:0]
+}
+
+// Seq returns an iterator that yields elements from the slice.
+func (s *Slice[T]) Seq() iter.Seq[T] {
+	// Take a snapshot to avoid holding the lock during iteration
+	items := s.Slice()
+	return func(yield func(T) bool) {
+		for _, v := range items {
+			if !yield(v) {
+				return
+			}
+		}
+	}
+}
+
+// SeqWithIndex returns an iterator that yields index-value pairs from the slice.
+func (s *Slice[T]) SeqWithIndex() iter.Seq2[int, T] {
+	// Take a snapshot to avoid holding the lock during iteration
+	items := s.Slice()
+	return func(yield func(int, T) bool) {
+		for i, v := range items {
+			if !yield(i, v) {
+				return
+			}
+		}
+	}
+}

internal/csync/slices_test.go 🔗

@@ -1,11 +1,13 @@
 package csync
 
 import (
+	"sync"
 	"sync/atomic"
 	"testing"
 	"time"
 
 	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
 )
 
 func TestLazySlice_Seq(t *testing.T) {
@@ -85,3 +87,210 @@ func TestLazySlice_EarlyBreak(t *testing.T) {
 
 	assert.Equal(t, []string{"a", "b"}, result)
 }
+
+func TestSlice(t *testing.T) {
+	t.Run("NewSlice", func(t *testing.T) {
+		s := NewSlice[int]()
+		assert.Equal(t, 0, s.Len())
+	})
+
+	t.Run("NewSliceFrom", func(t *testing.T) {
+		original := []int{1, 2, 3}
+		s := NewSliceFrom(original)
+		assert.Equal(t, 3, s.Len())
+
+		// Verify it's a copy, not a reference
+		original[0] = 999
+		val, ok := s.Get(0)
+		require.True(t, ok)
+		assert.Equal(t, 1, val)
+	})
+
+	t.Run("Append", func(t *testing.T) {
+		s := NewSlice[string]()
+		s.Append("hello")
+		s.Append("world")
+
+		assert.Equal(t, 2, s.Len())
+		val, ok := s.Get(0)
+		require.True(t, ok)
+		assert.Equal(t, "hello", val)
+
+		val, ok = s.Get(1)
+		require.True(t, ok)
+		assert.Equal(t, "world", val)
+	})
+
+	t.Run("Prepend", func(t *testing.T) {
+		s := NewSlice[string]()
+		s.Append("world")
+		s.Prepend("hello")
+
+		assert.Equal(t, 2, s.Len())
+		val, ok := s.Get(0)
+		require.True(t, ok)
+		assert.Equal(t, "hello", val)
+
+		val, ok = s.Get(1)
+		require.True(t, ok)
+		assert.Equal(t, "world", val)
+	})
+
+	t.Run("Delete", func(t *testing.T) {
+		s := NewSliceFrom([]int{1, 2, 3, 4, 5})
+
+		// Delete middle element
+		ok := s.Delete(2)
+		assert.True(t, ok)
+		assert.Equal(t, 4, s.Len())
+
+		expected := []int{1, 2, 4, 5}
+		actual := s.Slice()
+		assert.Equal(t, expected, actual)
+
+		// Delete out of bounds
+		ok = s.Delete(10)
+		assert.False(t, ok)
+		assert.Equal(t, 4, s.Len())
+
+		// Delete negative index
+		ok = s.Delete(-1)
+		assert.False(t, ok)
+		assert.Equal(t, 4, s.Len())
+	})
+
+	t.Run("Get", func(t *testing.T) {
+		s := NewSliceFrom([]string{"a", "b", "c"})
+
+		val, ok := s.Get(1)
+		require.True(t, ok)
+		assert.Equal(t, "b", val)
+
+		// Out of bounds
+		_, ok = s.Get(10)
+		assert.False(t, ok)
+
+		// Negative index
+		_, ok = s.Get(-1)
+		assert.False(t, ok)
+	})
+
+	t.Run("Set", func(t *testing.T) {
+		s := NewSliceFrom([]string{"a", "b", "c"})
+
+		ok := s.Set(1, "modified")
+		assert.True(t, ok)
+
+		val, ok := s.Get(1)
+		require.True(t, ok)
+		assert.Equal(t, "modified", val)
+
+		// Out of bounds
+		ok = s.Set(10, "invalid")
+		assert.False(t, ok)
+
+		// Negative index
+		ok = s.Set(-1, "invalid")
+		assert.False(t, ok)
+	})
+
+	t.Run("SetSlice", func(t *testing.T) {
+		s := NewSlice[int]()
+		s.Append(1)
+		s.Append(2)
+
+		newItems := []int{10, 20, 30}
+		s.SetSlice(newItems)
+
+		assert.Equal(t, 3, s.Len())
+		assert.Equal(t, newItems, s.Slice())
+
+		// Verify it's a copy
+		newItems[0] = 999
+		val, ok := s.Get(0)
+		require.True(t, ok)
+		assert.Equal(t, 10, val)
+	})
+
+	t.Run("Clear", func(t *testing.T) {
+		s := NewSliceFrom([]int{1, 2, 3})
+		assert.Equal(t, 3, s.Len())
+
+		s.Clear()
+		assert.Equal(t, 0, s.Len())
+	})
+
+	t.Run("Slice", func(t *testing.T) {
+		original := []int{1, 2, 3}
+		s := NewSliceFrom(original)
+
+		copy := s.Slice()
+		assert.Equal(t, original, copy)
+
+		// Verify it's a copy
+		copy[0] = 999
+		val, ok := s.Get(0)
+		require.True(t, ok)
+		assert.Equal(t, 1, val)
+	})
+
+	t.Run("Seq", func(t *testing.T) {
+		s := NewSliceFrom([]int{1, 2, 3})
+
+		var result []int
+		for v := range s.Seq() {
+			result = append(result, v)
+		}
+
+		assert.Equal(t, []int{1, 2, 3}, result)
+	})
+
+	t.Run("SeqWithIndex", func(t *testing.T) {
+		s := NewSliceFrom([]string{"a", "b", "c"})
+
+		var indices []int
+		var values []string
+		for i, v := range s.SeqWithIndex() {
+			indices = append(indices, i)
+			values = append(values, v)
+		}
+
+		assert.Equal(t, []int{0, 1, 2}, indices)
+		assert.Equal(t, []string{"a", "b", "c"}, values)
+	})
+
+	t.Run("ConcurrentAccess", func(t *testing.T) {
+		s := NewSlice[int]()
+		const numGoroutines = 100
+		const itemsPerGoroutine = 10
+
+		var wg sync.WaitGroup
+
+		// Concurrent appends
+		for i := 0; i < numGoroutines; i++ {
+			wg.Add(1)
+			go func(start int) {
+				defer wg.Done()
+				for j := 0; j < itemsPerGoroutine; j++ {
+					s.Append(start*itemsPerGoroutine + j)
+				}
+			}(i)
+		}
+
+		// Concurrent reads
+		for i := 0; i < numGoroutines; i++ {
+			wg.Add(1)
+			go func() {
+				defer wg.Done()
+				for j := 0; j < itemsPerGoroutine; j++ {
+					s.Len() // Just read the length
+				}
+			}()
+		}
+
+		wg.Wait()
+
+		// Should have all items
+		assert.Equal(t, numGoroutines*itemsPerGoroutine, s.Len())
+	})
+}

internal/tui/components/anim/anim.go 🔗

@@ -2,12 +2,16 @@
 package anim
 
 import (
+	"fmt"
 	"image/color"
 	"math/rand/v2"
 	"strings"
+	"sync"
 	"sync/atomic"
 	"time"
 
+	"github.com/zeebo/xxh3"
+
 	tea "github.com/charmbracelet/bubbletea/v2"
 	"github.com/charmbracelet/lipgloss/v2"
 	"github.com/lucasb-eyer/go-colorful"
@@ -58,6 +62,29 @@ func nextID() int {
 	return int(atomic.AddInt64(&lastID, 1))
 }
 
+// Cache for expensive animation calculations
+type animCache struct {
+	initialFrames  [][]string
+	cyclingFrames  [][]string
+	width          int
+	labelWidth     int
+	label          []string
+	ellipsisFrames []string
+}
+
+var (
+	animCacheMutex sync.RWMutex
+	animCacheMap   = make(map[string]*animCache)
+)
+
+// settingsHash creates a hash key for the settings to use for caching
+func settingsHash(opts Settings) string {
+	h := xxh3.New()
+	fmt.Fprintf(h, "%d-%s-%v-%v-%v-%t",
+		opts.Size, opts.Label, opts.LabelColor, opts.GradColorA, opts.GradColorB, opts.CycleColors)
+	return fmt.Sprintf("%x", h.Sum(nil))
+}
+
 // StepMsg is a message type used to trigger the next step in the animation.
 type StepMsg struct{ id int }
 
@@ -109,79 +136,109 @@ func New(opts Settings) (a Anim) {
 	}
 
 	a.id = nextID()
-
 	a.startTime = time.Now()
 	a.cyclingCharWidth = opts.Size
-	a.labelWidth = lipgloss.Width(opts.Label)
 	a.labelColor = opts.LabelColor
 
-	// Total width of anim, in cells.
-	a.width = opts.Size
-	if opts.Label != "" {
-		a.width += labelGapWidth + lipgloss.Width(opts.Label)
-	}
-
-	// Render the label
-	a.renderLabel(opts.Label)
-
-	// Pre-generate gradient.
-	var ramp []color.Color
-	numFrames := prerenderedFrames
-	if opts.CycleColors {
-		ramp = makeGradientRamp(a.width*3, opts.GradColorA, opts.GradColorB, opts.GradColorA, opts.GradColorB)
-		numFrames = a.width * 2
+	// Check cache first
+	cacheKey := settingsHash(opts)
+	animCacheMutex.RLock()
+	cached, exists := animCacheMap[cacheKey]
+	animCacheMutex.RUnlock()
+
+	if exists {
+		// Use cached values
+		a.width = cached.width
+		a.labelWidth = cached.labelWidth
+		a.label = cached.label
+		a.ellipsisFrames = cached.ellipsisFrames
+		a.initialFrames = cached.initialFrames
+		a.cyclingFrames = cached.cyclingFrames
 	} else {
-		ramp = makeGradientRamp(a.width, opts.GradColorA, opts.GradColorB)
-	}
+		// Generate new values and cache them
+		a.labelWidth = lipgloss.Width(opts.Label)
 
-	// Pre-render initial characters.
-	a.initialFrames = make([][]string, numFrames)
-	offset := 0
-	for i := range a.initialFrames {
-		a.initialFrames[i] = make([]string, a.width+labelGapWidth+a.labelWidth)
-		for j := range a.initialFrames[i] {
-			if j+offset >= len(ramp) {
-				continue // skip if we run out of colors
-			}
+		// Total width of anim, in cells.
+		a.width = opts.Size
+		if opts.Label != "" {
+			a.width += labelGapWidth + lipgloss.Width(opts.Label)
+		}
 
-			var c color.Color
-			if j <= a.cyclingCharWidth {
-				c = ramp[j+offset]
-			} else {
-				c = opts.LabelColor
-			}
+		// Render the label
+		a.renderLabel(opts.Label)
 
-			// Also prerender the initial character with Lip Gloss to avoid
-			// processing in the render loop.
-			a.initialFrames[i][j] = lipgloss.NewStyle().
-				Foreground(c).
-				Render(string(initialChar))
-		}
+		// Pre-generate gradient.
+		var ramp []color.Color
+		numFrames := prerenderedFrames
 		if opts.CycleColors {
-			offset++
+			ramp = makeGradientRamp(a.width*3, opts.GradColorA, opts.GradColorB, opts.GradColorA, opts.GradColorB)
+			numFrames = a.width * 2
+		} else {
+			ramp = makeGradientRamp(a.width, opts.GradColorA, opts.GradColorB)
 		}
-	}
 
-	// Prerender scrambled rune frames for the animation.
-	a.cyclingFrames = make([][]string, numFrames)
-	offset = 0
-	for i := range a.cyclingFrames {
-		a.cyclingFrames[i] = make([]string, a.width)
-		for j := range a.cyclingFrames[i] {
-			if j+offset >= len(ramp) {
-				continue // skip if we run out of colors
+		// Pre-render initial characters.
+		a.initialFrames = make([][]string, numFrames)
+		offset := 0
+		for i := range a.initialFrames {
+			a.initialFrames[i] = make([]string, a.width+labelGapWidth+a.labelWidth)
+			for j := range a.initialFrames[i] {
+				if j+offset >= len(ramp) {
+					continue // skip if we run out of colors
+				}
+
+				var c color.Color
+				if j <= a.cyclingCharWidth {
+					c = ramp[j+offset]
+				} else {
+					c = opts.LabelColor
+				}
+
+				// Also prerender the initial character with Lip Gloss to avoid
+				// processing in the render loop.
+				a.initialFrames[i][j] = lipgloss.NewStyle().
+					Foreground(c).
+					Render(string(initialChar))
 			}
+			if opts.CycleColors {
+				offset++
+			}
+		}
 
-			// Also prerender the color with Lip Gloss here to avoid processing
-			// in the render loop.
-			r := availableRunes[rand.IntN(len(availableRunes))]
-			a.cyclingFrames[i][j] = lipgloss.NewStyle().
-				Foreground(ramp[j+offset]).
-				Render(string(r))
+		// Prerender scrambled rune frames for the animation.
+		a.cyclingFrames = make([][]string, numFrames)
+		offset = 0
+		for i := range a.cyclingFrames {
+			a.cyclingFrames[i] = make([]string, a.width)
+			for j := range a.cyclingFrames[i] {
+				if j+offset >= len(ramp) {
+					continue // skip if we run out of colors
+				}
+
+				// Also prerender the color with Lip Gloss here to avoid processing
+				// in the render loop.
+				r := availableRunes[rand.IntN(len(availableRunes))]
+				a.cyclingFrames[i][j] = lipgloss.NewStyle().
+					Foreground(ramp[j+offset]).
+					Render(string(r))
+			}
+			if opts.CycleColors {
+				offset++
+			}
 		}
-		if opts.CycleColors {
-			offset++
+
+		// Cache the results
+		cached = &animCache{
+			initialFrames:  a.initialFrames,
+			cyclingFrames:  a.cyclingFrames,
+			width:          a.width,
+			labelWidth:     a.labelWidth,
+			label:          a.label,
+			ellipsisFrames: a.ellipsisFrames,
 		}
+		animCacheMutex.Lock()
+		animCacheMap[cacheKey] = cached
+		animCacheMutex.Unlock()
 	}
 
 	// Random assign a birth to each character for a stagged entrance effect.

internal/tui/components/chat/chat.go 🔗

@@ -15,7 +15,7 @@ import (
 	"github.com/charmbracelet/crush/internal/session"
 	"github.com/charmbracelet/crush/internal/tui/components/chat/messages"
 	"github.com/charmbracelet/crush/internal/tui/components/core/layout"
-	"github.com/charmbracelet/crush/internal/tui/components/core/list"
+	"github.com/charmbracelet/crush/internal/tui/exp/list"
 	"github.com/charmbracelet/crush/internal/tui/styles"
 	"github.com/charmbracelet/crush/internal/tui/util"
 )
@@ -42,6 +42,7 @@ type MessageListCmp interface {
 	layout.Help
 
 	SetSession(session.Session) tea.Cmd
+	GoToBottom() tea.Cmd
 }
 
 // messageListCmp implements MessageListCmp, providing a virtualized list
@@ -51,8 +52,8 @@ type messageListCmp struct {
 	app              *app.App
 	width, height    int
 	session          session.Session
-	listCmp          list.ListModel
-	previousSelected int // Last selected item index for restoring focus
+	listCmp          list.List[list.Item]
+	previousSelected string // Last selected item index for restoring focus
 
 	lastUserMessageTime int64
 	defaultListKeyMap   list.KeyMap
@@ -63,21 +64,24 @@ type messageListCmp struct {
 func New(app *app.App) MessageListCmp {
 	defaultListKeyMap := list.DefaultKeyMap()
 	listCmp := list.New(
-		list.WithGapSize(1),
-		list.WithReverse(true),
+		[]list.Item{},
+		list.WithGap(1),
+		list.WithDirectionBackward(),
+		list.WithFocus(false),
 		list.WithKeyMap(defaultListKeyMap),
+		list.WithEnableMouse(),
 	)
 	return &messageListCmp{
 		app:               app,
 		listCmp:           listCmp,
-		previousSelected:  list.NoSelection,
+		previousSelected:  "",
 		defaultListKeyMap: defaultListKeyMap,
 	}
 }
 
 // Init initializes the component.
 func (m *messageListCmp) Init() tea.Cmd {
-	return tea.Sequence(m.listCmp.Init(), m.listCmp.Blur())
+	return m.listCmp.Init()
 }
 
 // Update handles incoming messages and updates the component state.
@@ -93,15 +97,20 @@ func (m *messageListCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		return m, nil
 	case SessionClearedMsg:
 		m.session = session.Session{}
-		return m, m.listCmp.SetItems([]util.Model{})
+		return m, m.listCmp.SetItems([]list.Item{})
 
 	case pubsub.Event[message.Message]:
 		cmd := m.handleMessageEvent(msg)
 		return m, cmd
+
+	case tea.MouseWheelMsg:
+		u, cmd := m.listCmp.Update(msg)
+		m.listCmp = u.(list.List[list.Item])
+		return m, cmd
 	default:
 		var cmds []tea.Cmd
 		u, cmd := m.listCmp.Update(msg)
-		m.listCmp = u.(list.ListModel)
+		m.listCmp = u.(list.List[list.Item])
 		cmds = append(cmds, cmd)
 		return m, tea.Batch(cmds...)
 	}
@@ -128,7 +137,7 @@ func (m *messageListCmp) handlePermissionRequest(permission permission.Permissio
 		if permission.Granted {
 			toolCall.SetPermissionGranted()
 		}
-		m.listCmp.UpdateItem(toolCallIndex, toolCall)
+		m.listCmp.UpdateItem(toolCall.ID(), toolCall)
 	}
 	return nil
 }
@@ -188,7 +197,7 @@ func (m *messageListCmp) handleChildSession(event pubsub.Event[message.Message])
 
 	toolCall.SetNestedToolCalls(nestedToolCalls)
 	m.listCmp.UpdateItem(
-		toolCallInx,
+		toolCall.ID(),
 		toolCall,
 	)
 	return tea.Batch(cmds...)
@@ -257,7 +266,7 @@ func (m *messageListCmp) handleToolMessage(msg message.Message) tea.Cmd {
 		if toolCallIndex := m.findToolCallByID(items, tr.ToolCallID); toolCallIndex != NotFound {
 			toolCall := items[toolCallIndex].(messages.ToolCallCmp)
 			toolCall.SetToolResult(tr)
-			m.listCmp.UpdateItem(toolCallIndex, toolCall)
+			m.listCmp.UpdateItem(toolCall.ID(), toolCall)
 		}
 	}
 	return nil
@@ -265,7 +274,7 @@ func (m *messageListCmp) handleToolMessage(msg message.Message) tea.Cmd {
 
 // findToolCallByID searches for a tool call with the specified ID.
 // Returns the index if found, NotFound otherwise.
-func (m *messageListCmp) findToolCallByID(items []util.Model, toolCallID string) int {
+func (m *messageListCmp) findToolCallByID(items []list.Item, toolCallID string) int {
 	// Search backwards as tool calls are more likely to be recent
 	for i := len(items) - 1; i >= 0; i-- {
 		if toolCall, ok := items[i].(messages.ToolCallCmp); ok && toolCall.GetToolCall().ID == toolCallID {
@@ -298,7 +307,7 @@ func (m *messageListCmp) handleUpdateAssistantMessage(msg message.Message) tea.C
 }
 
 // findAssistantMessageAndToolCalls locates the assistant message and its tool calls.
-func (m *messageListCmp) findAssistantMessageAndToolCalls(items []util.Model, messageID string) (int, map[int]messages.ToolCallCmp) {
+func (m *messageListCmp) findAssistantMessageAndToolCalls(items []list.Item, messageID string) (int, map[int]messages.ToolCallCmp) {
 	assistantIndex := NotFound
 	toolCalls := make(map[int]messages.ToolCallCmp)
 
@@ -334,7 +343,7 @@ func (m *messageListCmp) updateAssistantMessageContent(msg message.Message, assi
 		uiMsg := items[assistantIndex].(messages.MessageCmp)
 		uiMsg.SetMessage(msg)
 		m.listCmp.UpdateItem(
-			assistantIndex,
+			items[assistantIndex].ID(),
 			uiMsg,
 		)
 		if msg.FinishPart() != nil && msg.FinishPart().Reason == message.FinishReasonEndTurn {
@@ -346,7 +355,8 @@ func (m *messageListCmp) updateAssistantMessageContent(msg message.Message, assi
 			)
 		}
 	} else if hasToolCallsOnly {
-		m.listCmp.DeleteItem(assistantIndex)
+		items := m.listCmp.Items()
+		m.listCmp.DeleteItem(items[assistantIndex].ID())
 	}
 
 	return cmd
@@ -373,13 +383,13 @@ func (m *messageListCmp) updateToolCalls(msg message.Message, existingToolCalls
 // updateOrAddToolCall updates an existing tool call or adds a new one.
 func (m *messageListCmp) updateOrAddToolCall(msg message.Message, tc message.ToolCall, existingToolCalls map[int]messages.ToolCallCmp) tea.Cmd {
 	// Try to find existing tool call
-	for index, existingTC := range existingToolCalls {
+	for _, existingTC := range existingToolCalls {
 		if tc.ID == existingTC.GetToolCall().ID {
 			existingTC.SetToolCall(tc)
 			if msg.FinishPart() != nil && msg.FinishPart().Reason == message.FinishReasonCanceled {
 				existingTC.SetCancelled()
 			}
-			m.listCmp.UpdateItem(index, existingTC)
+			m.listCmp.UpdateItem(tc.ID, existingTC)
 			return nil
 		}
 	}
@@ -424,7 +434,7 @@ func (m *messageListCmp) SetSession(session session.Session) tea.Cmd {
 	}
 
 	if len(sessionMessages) == 0 {
-		return m.listCmp.SetItems([]util.Model{})
+		return m.listCmp.SetItems([]list.Item{})
 	}
 
 	// Initialize with first message timestamp
@@ -451,8 +461,8 @@ func (m *messageListCmp) buildToolResultMap(messages []message.Message) map[stri
 }
 
 // convertMessagesToUI converts database messages to UI components.
-func (m *messageListCmp) convertMessagesToUI(sessionMessages []message.Message, toolResultMap map[string]message.ToolResult) []util.Model {
-	uiMessages := make([]util.Model, 0)
+func (m *messageListCmp) convertMessagesToUI(sessionMessages []message.Message, toolResultMap map[string]message.ToolResult) []list.Item {
+	uiMessages := make([]list.Item, 0)
 
 	for _, msg := range sessionMessages {
 		switch msg.Role {
@@ -471,8 +481,8 @@ func (m *messageListCmp) convertMessagesToUI(sessionMessages []message.Message,
 }
 
 // convertAssistantMessage converts an assistant message and its tool calls to UI components.
-func (m *messageListCmp) convertAssistantMessage(msg message.Message, toolResultMap map[string]message.ToolResult) []util.Model {
-	var uiMessages []util.Model
+func (m *messageListCmp) convertAssistantMessage(msg message.Message, toolResultMap map[string]message.ToolResult) []list.Item {
+	var uiMessages []list.Item
 
 	// Add assistant message if it should be displayed
 	if m.shouldShowAssistantMessage(msg) {
@@ -553,3 +563,7 @@ func (m *messageListCmp) IsFocused() bool {
 func (m *messageListCmp) Bindings() []key.Binding {
 	return m.defaultListKeyMap.KeyBindings()
 }
+
+func (m *messageListCmp) GoToBottom() tea.Cmd {
+	return m.listCmp.GoToBottom()
+}

internal/tui/components/chat/messages/messages.go 🔗

@@ -11,13 +11,14 @@ import (
 	"github.com/charmbracelet/catwalk/pkg/catwalk"
 	"github.com/charmbracelet/lipgloss/v2"
 	"github.com/charmbracelet/x/ansi"
+	"github.com/google/uuid"
 
 	"github.com/charmbracelet/crush/internal/config"
 	"github.com/charmbracelet/crush/internal/message"
 	"github.com/charmbracelet/crush/internal/tui/components/anim"
 	"github.com/charmbracelet/crush/internal/tui/components/core"
 	"github.com/charmbracelet/crush/internal/tui/components/core/layout"
-	"github.com/charmbracelet/crush/internal/tui/components/core/list"
+	"github.com/charmbracelet/crush/internal/tui/exp/list"
 	"github.com/charmbracelet/crush/internal/tui/styles"
 	"github.com/charmbracelet/crush/internal/tui/util"
 )
@@ -31,6 +32,7 @@ type MessageCmp interface {
 	GetMessage() message.Message    // Access to underlying message data
 	SetMessage(msg message.Message) // Update the message content
 	Spinning() bool                 // Animation state for loading messages
+	ID() string
 }
 
 // messageCmp implements the MessageCmp interface for displaying chat messages.
@@ -333,19 +335,25 @@ func (m *messageCmp) Spinning() bool {
 }
 
 type AssistantSection interface {
-	util.Model
+	list.Item
 	layout.Sizeable
-	list.SectionHeader
 }
 type assistantSectionModel struct {
 	width               int
+	id                  string
 	message             message.Message
 	lastUserMessageTime time.Time
 }
 
+// ID implements AssistantSection.
+func (m *assistantSectionModel) ID() string {
+	return m.id
+}
+
 func NewAssistantSection(message message.Message, lastUserMessageTime time.Time) AssistantSection {
 	return &assistantSectionModel{
 		width:               0,
+		id:                  uuid.NewString(),
 		message:             message,
 		lastUserMessageTime: lastUserMessageTime,
 	}
@@ -392,3 +400,7 @@ func (m *assistantSectionModel) SetSize(width int, height int) tea.Cmd {
 func (m *assistantSectionModel) IsSectionHeader() bool {
 	return true
 }
+
+func (m *messageCmp) ID() string {
+	return m.message.ID
+}

internal/tui/components/chat/messages/tool.go 🔗

@@ -32,6 +32,7 @@ type ToolCallCmp interface {
 	SetIsNested(bool)                  // Set whether this tool call is nested
 	SetPermissionRequested()           // Mark permission request
 	SetPermissionGranted()             // Mark permission granted
+	ID() string
 }
 
 // toolCallCmp implements the ToolCallCmp interface for displaying tool calls.
@@ -338,3 +339,7 @@ func (m *toolCallCmp) SetPermissionRequested() {
 func (m *toolCallCmp) SetPermissionGranted() {
 	m.permissionGranted = true
 }
+
+func (m *toolCallCmp) ID() string {
+	return m.call.ID
+}

internal/tui/components/chat/splash/splash.go 🔗

@@ -14,12 +14,11 @@ import (
 	"github.com/charmbracelet/crush/internal/config"
 	"github.com/charmbracelet/crush/internal/llm/prompt"
 	"github.com/charmbracelet/crush/internal/tui/components/chat"
-	"github.com/charmbracelet/crush/internal/tui/components/completions"
 	"github.com/charmbracelet/crush/internal/tui/components/core"
 	"github.com/charmbracelet/crush/internal/tui/components/core/layout"
-	"github.com/charmbracelet/crush/internal/tui/components/core/list"
 	"github.com/charmbracelet/crush/internal/tui/components/dialogs/models"
 	"github.com/charmbracelet/crush/internal/tui/components/logo"
+	"github.com/charmbracelet/crush/internal/tui/exp/list"
 	"github.com/charmbracelet/crush/internal/tui/styles"
 	"github.com/charmbracelet/crush/internal/tui/util"
 	"github.com/charmbracelet/crush/internal/version"
@@ -86,9 +85,7 @@ func New() Splash {
 	listKeyMap.DownOneItem = keyMap.Next
 	listKeyMap.UpOneItem = keyMap.Previous
 
-	t := styles.CurrentTheme()
-	inputStyle := t.S().Base.Padding(0, 1, 0, 1)
-	modelList := models.NewModelListComponent(listKeyMap, inputStyle, "Find your fave")
+	modelList := models.NewModelListComponent(listKeyMap, "Find your fave", false)
 	apiKeyInput := models.NewAPIKeyInput()
 
 	return &splashCmp{
@@ -195,20 +192,18 @@ func (s *splashCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 				return s, s.saveAPIKeyAndContinue(s.apiKeyValue)
 			}
 			if s.isOnboarding && !s.needsAPIKey {
-				modelInx := s.modelList.SelectedIndex()
-				if modelInx == -1 {
+				selectedItem := s.modelList.SelectedModel()
+				if selectedItem == nil {
 					return s, nil
 				}
-				items := s.modelList.Items()
-				selectedItem := items[modelInx].(completions.CompletionItem).Value().(models.ModelOption)
 				if s.isProviderConfigured(string(selectedItem.Provider.ID)) {
-					cmd := s.setPreferredModel(selectedItem)
+					cmd := s.setPreferredModel(*selectedItem)
 					s.isOnboarding = false
 					return s, tea.Batch(cmd, util.CmdHandler(OnboardingCompleteMsg{}))
 				} else {
 					// Provider not configured, show API key input
 					s.needsAPIKey = true
-					s.selectedModel = &selectedItem
+					s.selectedModel = selectedItem
 					s.apiKeyInput.SetProviderName(selectedItem.Provider.Name)
 					return s, nil
 				}
@@ -267,6 +262,9 @@ func (s *splashCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 				return s, nil
 			}
 		case key.Matches(msg, s.keyMap.Yes):
+			if s.isOnboarding {
+				return s, nil
+			}
 			if s.needsAPIKey {
 				u, cmd := s.apiKeyInput.Update(msg)
 				s.apiKeyInput = u.(*models.APIKeyInput)
@@ -277,6 +275,9 @@ func (s *splashCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 				return s, s.initializeProject()
 			}
 		case key.Matches(msg, s.keyMap.No):
+			if s.isOnboarding {
+				return s, nil
+			}
 			if s.needsAPIKey {
 				u, cmd := s.apiKeyInput.Update(msg)
 				s.apiKeyInput = u.(*models.APIKeyInput)
@@ -606,7 +607,7 @@ func (s *splashCmp) moveCursor(cursor *tea.Cursor) *tea.Cursor {
 		cursor.Y += offset
 		cursor.X = cursor.X + 1
 	} else if s.isOnboarding {
-		offset := logoHeight + SplashScreenPaddingY + s.logoGap() + 3
+		offset := logoHeight + SplashScreenPaddingY + s.logoGap() + 2
 		cursor.Y += offset
 		cursor.X = cursor.X + 1
 	}

internal/tui/components/completions/completions.go 🔗

@@ -5,7 +5,7 @@ import (
 
 	"github.com/charmbracelet/bubbles/v2/key"
 	tea "github.com/charmbracelet/bubbletea/v2"
-	"github.com/charmbracelet/crush/internal/tui/components/core/list"
+	"github.com/charmbracelet/crush/internal/tui/exp/list"
 	"github.com/charmbracelet/crush/internal/tui/styles"
 	"github.com/charmbracelet/crush/internal/tui/util"
 	"github.com/charmbracelet/lipgloss/v2"
@@ -50,6 +50,8 @@ type Completions interface {
 	Height() int
 }
 
+type listModel = list.FilterableList[list.CompletionItem[any]]
+
 type completionsCmp struct {
 	width  int
 	height int  // Height of the completions component`
@@ -58,7 +60,7 @@ type completionsCmp struct {
 	open   bool // Indicates if the completions are open
 	keyMap KeyMap
 
-	list  list.ListModel
+	list  listModel
 	query string // The current filter query
 }
 
@@ -76,10 +78,13 @@ func New() Completions {
 	keyMap.UpOneItem = completionsKeyMap.Up
 	keyMap.DownOneItem = completionsKeyMap.Down
 
-	l := list.New(
-		list.WithReverse(true),
-		list.WithKeyMap(keyMap),
-		list.WithHideFilterInput(true),
+	l := list.NewFilterableList(
+		[]list.CompletionItem[any]{},
+		list.WithFilterInputHidden(),
+		list.WithFilterListOptions(
+			list.WithDirectionBackward(),
+			list.WithKeyMap(keyMap),
+		),
 	)
 	return &completionsCmp{
 		width:  0,
@@ -109,44 +114,41 @@ func (c *completionsCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		switch {
 		case key.Matches(msg, c.keyMap.Up):
 			u, cmd := c.list.Update(msg)
-			c.list = u.(list.ListModel)
+			c.list = u.(listModel)
 			return c, cmd
 
 		case key.Matches(msg, c.keyMap.Down):
 			d, cmd := c.list.Update(msg)
-			c.list = d.(list.ListModel)
+			c.list = d.(listModel)
 			return c, cmd
 		case key.Matches(msg, c.keyMap.UpInsert):
-			selectedItemInx := c.list.SelectedIndex() - 1
-			items := c.list.Items()
-			if selectedItemInx == list.NoSelection || selectedItemInx < 0 {
-				return c, nil // No item selected, do nothing
+			s := c.list.SelectedItem()
+			if s == nil {
+				return c, nil
 			}
-			selectedItem := items[selectedItemInx].(CompletionItem).Value()
-			c.list.SetSelected(selectedItemInx)
+			selectedItem := *s
+			c.list.SetSelected(selectedItem.ID())
 			return c, util.CmdHandler(SelectCompletionMsg{
 				Value:  selectedItem,
 				Insert: true,
 			})
 		case key.Matches(msg, c.keyMap.DownInsert):
-			selectedItemInx := c.list.SelectedIndex() + 1
-			items := c.list.Items()
-			if selectedItemInx == list.NoSelection || selectedItemInx >= len(items) {
-				return c, nil // No item selected, do nothing
+			s := c.list.SelectedItem()
+			if s == nil {
+				return c, nil
 			}
-			selectedItem := items[selectedItemInx].(CompletionItem).Value()
-			c.list.SetSelected(selectedItemInx)
+			selectedItem := *s
+			c.list.SetSelected(selectedItem.ID())
 			return c, util.CmdHandler(SelectCompletionMsg{
 				Value:  selectedItem,
 				Insert: true,
 			})
 		case key.Matches(msg, c.keyMap.Select):
-			selectedItemInx := c.list.SelectedIndex()
-			if selectedItemInx == list.NoSelection {
-				return c, nil // No item selected, do nothing
+			s := c.list.SelectedItem()
+			if s == nil {
+				return c, nil
 			}
-			items := c.list.Items()
-			selectedItem := items[selectedItemInx].(CompletionItem).Value()
+			selectedItem := *s
 			c.open = false // Close completions after selection
 			return c, util.CmdHandler(SelectCompletionMsg{
 				Value: selectedItem,
@@ -162,10 +164,14 @@ func (c *completionsCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		c.query = ""
 		c.x = msg.X
 		c.y = msg.Y
-		items := []util.Model{}
+		items := []list.CompletionItem[any]{}
 		t := styles.CurrentTheme()
 		for _, completion := range msg.Completions {
-			item := NewCompletionItem(completion.Title, completion.Value, WithBackgroundColor(t.BgSubtle))
+			item := list.NewCompletionItem(
+				completion.Title,
+				completion.Value,
+				list.WithCompletionBackgroundColor(t.BgSubtle),
+			)
 			items = append(items, item)
 		}
 		c.height = max(min(c.height, len(items)), 1) // Ensure at least 1 item height

internal/tui/components/core/list/list.go 🔗

@@ -1,1371 +0,0 @@
-package list
-
-import (
-	"slices"
-	"sort"
-	"strings"
-
-	"github.com/charmbracelet/bubbles/v2/help"
-	"github.com/charmbracelet/bubbles/v2/key"
-	"github.com/charmbracelet/bubbles/v2/textinput"
-	tea "github.com/charmbracelet/bubbletea/v2"
-	"github.com/charmbracelet/crush/internal/tui/components/anim"
-	"github.com/charmbracelet/crush/internal/tui/components/core/layout"
-	"github.com/charmbracelet/crush/internal/tui/styles"
-	"github.com/charmbracelet/crush/internal/tui/util"
-	"github.com/charmbracelet/lipgloss/v2"
-	"github.com/sahilm/fuzzy"
-)
-
-// Constants for special index values and defaults
-const (
-	NoSelection    = -1 // Indicates no item is currently selected
-	NotRendered    = -1 // Indicates an item hasn't been rendered yet
-	NoFinalHeight  = -1 // Indicates final height hasn't been calculated
-	DefaultGapSize = 0  // Default spacing between list items
-)
-
-// ListModel defines the interface for a scrollable, selectable list component.
-// It combines the basic Model interface with sizing capabilities and list-specific operations.
-type ListModel interface {
-	util.Model
-	layout.Sizeable
-	layout.Focusable
-	SetItems([]util.Model) tea.Cmd  // Replace all items in the list
-	AppendItem(util.Model) tea.Cmd  // Add an item to the end of the list
-	PrependItem(util.Model) tea.Cmd // Add an item to the beginning of the list
-	DeleteItem(int)                 // Remove an item at the specified index
-	UpdateItem(int, util.Model)     // Replace an item at the specified index
-	ResetView()                     // Clear rendering cache and reset scroll position
-	Items() []util.Model            // Get all items in the list
-	SelectedIndex() int             // Get the index of the currently selected item
-	SetSelected(int) tea.Cmd        // Set the selected item by index and scroll to it
-	Filter(string) tea.Cmd          // Filter items based on a search term
-	SetFilterPlaceholder(string)    // Set the placeholder text for the filter input
-	Cursor() *tea.Cursor            // Get the current cursor position in the filter input
-}
-
-// HasAnim interface identifies items that support animation.
-// Items implementing this interface will receive animation update messages.
-type HasAnim interface {
-	util.Model
-	Spinning() bool // Returns true if the item is currently animating
-}
-
-// HasFilterValue interface allows items to provide a filter value for searching.
-type HasFilterValue interface {
-	FilterValue() string // Returns a string value used for filtering/searching
-}
-
-// HasMatchIndexes interface allows items to set matched character indexes.
-type HasMatchIndexes interface {
-	MatchIndexes([]int) // Sets the indexes of matched characters in the item's content
-}
-
-// SectionHeader interface identifies items that are section headers.
-// Section headers are rendered differently and are skipped during navigation.
-type SectionHeader interface {
-	util.Model
-	IsSectionHeader() bool // Returns true if this item is a section header
-}
-
-// renderedItem represents a cached rendered item with its position and content.
-type renderedItem struct {
-	lines  []string // The rendered lines of text for this item
-	start  int      // Starting line position in the overall rendered content
-	height int      // Number of lines this item occupies
-}
-
-// renderState manages the rendering cache and state for the list.
-// It tracks which items have been rendered and their positions.
-type renderState struct {
-	items         map[int]renderedItem // Cache of rendered items by index
-	lines         []string             // All rendered lines concatenated
-	lastIndex     int                  // Index of the last rendered item
-	finalHeight   int                  // Total height when all items are rendered
-	needsRerender bool                 // Flag indicating if re-rendering is needed
-}
-
-// newRenderState creates a new render state with default values.
-func newRenderState() *renderState {
-	return &renderState{
-		items:         make(map[int]renderedItem),
-		lines:         []string{},
-		lastIndex:     NotRendered,
-		finalHeight:   NoFinalHeight,
-		needsRerender: true,
-	}
-}
-
-// reset clears all cached rendering data and resets state to initial values.
-func (rs *renderState) reset() {
-	rs.items = make(map[int]renderedItem)
-	rs.lines = []string{}
-	rs.lastIndex = NotRendered
-	rs.finalHeight = NoFinalHeight
-	rs.needsRerender = true
-}
-
-// viewState manages the visual display properties of the list.
-type viewState struct {
-	width, height int    // Dimensions of the list viewport
-	offset        int    // Current scroll offset in lines
-	reverse       bool   // Whether to render in reverse order (bottom-up)
-	content       string // The final rendered content to display
-}
-
-// selectionState manages which item is currently selected.
-type selectionState struct {
-	selectedIndex int // Index of the currently selected item, or NoSelection
-}
-
-// isValidIndex checks if the selected index is within the valid range of items.
-func (ss *selectionState) isValidIndex(itemCount int) bool {
-	return ss.selectedIndex >= 0 && ss.selectedIndex < itemCount
-}
-
-// model is the main implementation of the ListModel interface.
-// It coordinates between view state, render state, and selection state.
-type model struct {
-	viewState      viewState      // Display and scrolling state
-	renderState    *renderState   // Rendering cache and state
-	selectionState selectionState // Item selection state
-	help           help.Model     // Help system for keyboard shortcuts
-	keyMap         KeyMap         // Key bindings for navigation
-	allItems       []util.Model   // The actual list items
-	gapSize        int            // Number of empty lines between items
-	padding        []int          // Padding around the list content
-	wrapNavigation bool           // Whether to wrap navigation at the ends
-
-	filterable        bool            // Whether items can be filtered
-	filterPlaceholder string          // Placeholder text for filter input
-	filteredItems     []util.Model    // Filtered items based on current search
-	input             textinput.Model // Input field for filtering items
-	inputStyle        lipgloss.Style  // Style for the input field
-	hideFilterInput   bool            // Whether to hide the filter input field
-	currentSearch     string          // Current search term for filtering
-
-	isFocused bool // Whether the list is currently focused
-}
-
-// listOptions is a function type for configuring list options.
-type listOptions func(*model)
-
-// WithKeyMap sets custom key bindings for the list.
-func WithKeyMap(k KeyMap) listOptions {
-	return func(m *model) {
-		m.keyMap = k
-	}
-}
-
-// WithReverse sets whether the list should render in reverse order (newest items at bottom).
-func WithReverse(reverse bool) listOptions {
-	return func(m *model) {
-		m.setReverse(reverse)
-	}
-}
-
-// WithGapSize sets the number of empty lines to insert between list items.
-func WithGapSize(gapSize int) listOptions {
-	return func(m *model) {
-		m.gapSize = gapSize
-	}
-}
-
-// WithPadding sets the padding around the list content.
-// Follows CSS padding convention: 1 value = all sides, 2 values = vertical/horizontal,
-// 4 values = top/right/bottom/left.
-func WithPadding(padding ...int) listOptions {
-	return func(m *model) {
-		m.padding = padding
-	}
-}
-
-// WithItems sets the initial items for the list.
-func WithItems(items []util.Model) listOptions {
-	return func(m *model) {
-		m.allItems = items
-		m.filteredItems = items // Initially, all items are visible
-	}
-}
-
-// WithFilterable enables filtering of items based on their FilterValue.
-func WithFilterable(filterable bool) listOptions {
-	return func(m *model) {
-		m.filterable = filterable
-	}
-}
-
-// WithHideFilterInput hides the filter input field.
-func WithHideFilterInput(hide bool) listOptions {
-	return func(m *model) {
-		m.hideFilterInput = hide
-	}
-}
-
-// WithFilterPlaceholder sets the placeholder text for the filter input field.
-func WithFilterPlaceholder(placeholder string) listOptions {
-	return func(m *model) {
-		m.filterPlaceholder = placeholder
-	}
-}
-
-// WithInputStyle sets the style for the filter input field.
-func WithInputStyle(style lipgloss.Style) listOptions {
-	return func(m *model) {
-		m.inputStyle = style
-	}
-}
-
-// WithWrapNavigation enables wrapping navigation at the ends of the list.
-func WithWrapNavigation(wrap bool) listOptions {
-	return func(m *model) {
-		m.wrapNavigation = wrap
-	}
-}
-
-// New creates a new list model with the specified options.
-// The list starts with no items selected and requires SetItems to be called
-// or items to be provided via WithItems option.
-func New(opts ...listOptions) ListModel {
-	t := styles.CurrentTheme()
-
-	m := &model{
-		help:              help.New(),
-		keyMap:            DefaultKeyMap(),
-		allItems:          []util.Model{},
-		filteredItems:     []util.Model{},
-		renderState:       newRenderState(),
-		gapSize:           DefaultGapSize,
-		padding:           []int{},
-		selectionState:    selectionState{selectedIndex: NoSelection},
-		filterPlaceholder: "Type to filter...",
-		inputStyle:        t.S().Base.Padding(0, 1, 1, 1),
-		isFocused:         true,
-	}
-	for _, opt := range opts {
-		opt(m)
-	}
-
-	if m.filterable && !m.hideFilterInput {
-		t := styles.CurrentTheme()
-		ti := textinput.New()
-		ti.Placeholder = m.filterPlaceholder
-		ti.SetVirtualCursor(false)
-		ti.Focus()
-		ti.SetStyles(t.S().TextInput)
-		m.input = ti
-	}
-	return m
-}
-
-// Init initializes the list component and sets up the initial items.
-// This is called automatically by the Bubble Tea framework.
-func (m *model) Init() tea.Cmd {
-	return m.SetItems(m.filteredItems)
-}
-
-// Update handles incoming messages and updates the list state accordingly.
-// It processes keyboard input, animation messages, and forwards other messages
-// to the currently selected item.
-func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
-	switch msg := msg.(type) {
-	case tea.KeyPressMsg:
-		return m.handleKeyPress(msg)
-	case anim.StepMsg:
-		return m.handleAnimationMsg(msg)
-	}
-	if m.selectionState.isValidIndex(len(m.filteredItems)) {
-		return m.updateSelectedItem(msg)
-	}
-
-	return m, nil
-}
-
-// Cursor returns the current cursor position in the input field.
-func (m *model) Cursor() *tea.Cursor {
-	if m.filterable && !m.hideFilterInput {
-		return m.input.Cursor()
-	}
-	return nil
-}
-
-// View renders the list to a string for display.
-// Returns empty string if the list has no dimensions.
-// Triggers re-rendering if needed before returning content.
-func (m *model) View() string {
-	if m.viewState.height == 0 || m.viewState.width == 0 {
-		return "" // No content to display
-	}
-	if m.renderState.needsRerender {
-		m.renderVisible()
-	}
-
-	content := lipgloss.NewStyle().
-		Padding(m.padding...).
-		Height(m.viewState.height).
-		Render(m.viewState.content)
-
-	if m.filterable && !m.hideFilterInput {
-		content = lipgloss.JoinVertical(
-			lipgloss.Left,
-			m.inputStyle.Render(m.input.View()),
-			content,
-		)
-	}
-	return content
-}
-
-// handleKeyPress processes keyboard input for list navigation.
-// Supports scrolling, item selection, and navigation to top/bottom.
-func (m *model) handleKeyPress(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
-	switch {
-	case key.Matches(msg, m.keyMap.Down):
-		m.scrollDown(1)
-	case key.Matches(msg, m.keyMap.Up):
-		m.scrollUp(1)
-	case key.Matches(msg, m.keyMap.DownOneItem):
-		return m, m.selectNextItem()
-	case key.Matches(msg, m.keyMap.UpOneItem):
-		return m, m.selectPreviousItem()
-	case key.Matches(msg, m.keyMap.HalfPageDown):
-		m.scrollDown(m.listHeight() / 2)
-	case key.Matches(msg, m.keyMap.HalfPageUp):
-		m.scrollUp(m.listHeight() / 2)
-	case key.Matches(msg, m.keyMap.PageDown):
-		m.scrollDown(m.listHeight())
-	case key.Matches(msg, m.keyMap.PageUp):
-		m.scrollUp(m.listHeight())
-	case key.Matches(msg, m.keyMap.Home):
-		return m, m.goToTop()
-	case key.Matches(msg, m.keyMap.End):
-		return m, m.goToBottom()
-	default:
-		if !m.filterable || m.hideFilterInput {
-			return m, nil // Ignore other keys if not filterable or input is hidden
-		}
-		var cmds []tea.Cmd
-		u, cmd := m.input.Update(msg)
-		m.input = u
-		cmds = append(cmds, cmd)
-		if m.currentSearch != m.input.Value() {
-			cmd = m.Filter(m.input.Value())
-			cmds = append(cmds, cmd)
-		}
-		m.currentSearch = m.input.Value()
-		return m, tea.Batch(cmds...)
-	}
-	return m, nil
-}
-
-// handleAnimationMsg forwards animation messages to items that support animation.
-// Only items implementing HasAnim and currently spinning receive these messages.
-func (m *model) handleAnimationMsg(msg tea.Msg) (tea.Model, tea.Cmd) {
-	var cmds []tea.Cmd
-	for inx, item := range m.filteredItems {
-		if i, ok := item.(HasAnim); ok && i.Spinning() {
-			updated, cmd := i.Update(msg)
-			cmds = append(cmds, cmd)
-			if u, ok := updated.(util.Model); ok {
-				m.UpdateItem(inx, u)
-			}
-		}
-	}
-	return m, tea.Batch(cmds...)
-}
-
-// updateSelectedItem forwards messages to the currently selected item.
-// This allows the selected item to handle its own input and state changes.
-func (m *model) updateSelectedItem(msg tea.Msg) (tea.Model, tea.Cmd) {
-	var cmds []tea.Cmd
-	u, cmd := m.filteredItems[m.selectionState.selectedIndex].Update(msg)
-	cmds = append(cmds, cmd)
-	if updated, ok := u.(util.Model); ok {
-		m.UpdateItem(m.selectionState.selectedIndex, updated)
-	}
-	return m, tea.Batch(cmds...)
-}
-
-// scrollDown scrolls the list down by the specified amount.
-// Direction is automatically adjusted based on reverse mode.
-func (m *model) scrollDown(amount int) {
-	if m.viewState.reverse {
-		m.decreaseOffset(amount)
-	} else {
-		m.increaseOffset(amount)
-	}
-}
-
-// scrollUp scrolls the list up by the specified amount.
-// Direction is automatically adjusted based on reverse mode.
-func (m *model) scrollUp(amount int) {
-	if m.viewState.reverse {
-		m.increaseOffset(amount)
-	} else {
-		m.decreaseOffset(amount)
-	}
-}
-
-// Items returns a copy of all items in the list.
-func (m *model) Items() []util.Model {
-	return m.filteredItems
-}
-
-// renderVisible determines which rendering strategy to use and triggers rendering.
-// Uses forward rendering for normal mode and reverse rendering for reverse mode.
-func (m *model) renderVisible() {
-	if m.viewState.reverse {
-		m.renderVisibleReverse()
-	} else {
-		m.renderVisibleForward()
-	}
-}
-
-// renderVisibleForward renders items from top to bottom (normal mode).
-// Only renders items that are currently visible or near the viewport.
-func (m *model) renderVisibleForward() {
-	renderer := &forwardRenderer{
-		model:   m,
-		start:   0,
-		cutoff:  m.viewState.offset + m.listHeight() + m.listHeight()/2, // We render a bit more so we make sure we have smooth movementsd
-		items:   m.filteredItems,
-		realIdx: m.renderState.lastIndex,
-	}
-
-	if m.renderState.lastIndex > NotRendered {
-		renderer.items = m.filteredItems[m.renderState.lastIndex+1:]
-		renderer.start = len(m.renderState.lines)
-	}
-
-	renderer.render()
-	m.finalizeRender()
-}
-
-// renderVisibleReverse renders items from bottom to top (reverse mode).
-// Used when new items should appear at the bottom (like chat messages).
-func (m *model) renderVisibleReverse() {
-	renderer := &reverseRenderer{
-		model:   m,
-		start:   0,
-		cutoff:  m.viewState.offset + m.listHeight() + m.listHeight()/2,
-		items:   m.filteredItems,
-		realIdx: m.renderState.lastIndex,
-	}
-
-	if m.renderState.lastIndex > NotRendered {
-		renderer.items = m.filteredItems[:m.renderState.lastIndex]
-		renderer.start = len(m.renderState.lines)
-	} else {
-		m.renderState.lastIndex = len(m.filteredItems)
-		renderer.realIdx = len(m.filteredItems)
-	}
-
-	renderer.render()
-	m.finalizeRender()
-}
-
-// finalizeRender completes the rendering process by updating scroll bounds and content.
-func (m *model) finalizeRender() {
-	m.renderState.needsRerender = false
-	if m.renderState.finalHeight > NoFinalHeight {
-		m.viewState.offset = min(m.viewState.offset, m.renderState.finalHeight)
-	}
-	m.updateContent()
-}
-
-// updateContent extracts the visible portion of rendered content for display.
-// Handles both normal and reverse rendering modes.
-func (m *model) updateContent() {
-	maxHeight := min(m.listHeight(), len(m.renderState.lines))
-	if m.viewState.offset >= len(m.renderState.lines) {
-		m.viewState.content = ""
-		return
-	}
-
-	if m.viewState.reverse {
-		end := len(m.renderState.lines) - m.viewState.offset
-		start := max(0, end-maxHeight)
-		m.viewState.content = strings.Join(m.renderState.lines[start:end], "\n")
-	} else {
-		endIdx := min(maxHeight+m.viewState.offset, len(m.renderState.lines))
-		m.viewState.content = strings.Join(m.renderState.lines[m.viewState.offset:endIdx], "\n")
-	}
-}
-
-// forwardRenderer handles rendering items from top to bottom.
-// It builds up the rendered content incrementally, caching results for performance.
-type forwardRenderer struct {
-	model   *model       // Reference to the parent list model
-	start   int          // Current line position in the overall content
-	cutoff  int          // Line position where we can stop rendering
-	items   []util.Model // Items to render (may be a subset)
-	realIdx int          // Real index in the full item list
-}
-
-// render processes items in forward order, building up the rendered content.
-func (r *forwardRenderer) render() {
-	for _, item := range r.items {
-		r.realIdx++
-		if r.start > r.cutoff {
-			break
-		}
-
-		itemLines := r.getOrRenderItem(item)
-		if r.realIdx == len(r.model.filteredItems)-1 {
-			r.model.renderState.finalHeight = max(0, r.start+len(itemLines)-r.model.listHeight())
-		}
-
-		r.model.renderState.lines = append(r.model.renderState.lines, itemLines...)
-		r.model.renderState.lastIndex = r.realIdx
-		r.start += len(itemLines)
-	}
-}
-
-// getOrRenderItem retrieves cached content or renders the item if not cached.
-func (r *forwardRenderer) getOrRenderItem(item util.Model) []string {
-	if cachedContent, ok := r.model.renderState.items[r.realIdx]; ok {
-		return cachedContent.lines
-	}
-
-	itemLines := r.renderItemLines(item)
-	r.model.renderState.items[r.realIdx] = renderedItem{
-		lines:  itemLines,
-		start:  r.start,
-		height: len(itemLines),
-	}
-	return itemLines
-}
-
-// renderItemLines converts an item to its string representation with gaps.
-func (r *forwardRenderer) renderItemLines(item util.Model) []string {
-	return r.model.getItemLines(item)
-}
-
-// reverseRenderer handles rendering items from bottom to top.
-// Used in reverse mode where new items appear at the bottom.
-type reverseRenderer struct {
-	model   *model       // Reference to the parent list model
-	start   int          // Current line position in the overall content
-	cutoff  int          // Line position where we can stop rendering
-	items   []util.Model // Items to render (may be a subset)
-	realIdx int          // Real index in the full item list
-}
-
-// render processes items in reverse order, prepending to the rendered content.
-func (r *reverseRenderer) render() {
-	for i := len(r.items) - 1; i >= 0; i-- {
-		r.realIdx--
-		if r.start > r.cutoff {
-			break
-		}
-
-		itemLines := r.getOrRenderItem(r.items[i])
-		if r.realIdx == 0 {
-			r.model.renderState.finalHeight = max(0, r.start+len(itemLines)-r.model.listHeight())
-		}
-
-		r.model.renderState.lines = append(itemLines, r.model.renderState.lines...)
-		r.model.renderState.lastIndex = r.realIdx
-		r.start += len(itemLines)
-	}
-}
-
-// getOrRenderItem retrieves cached content or renders the item if not cached.
-func (r *reverseRenderer) getOrRenderItem(item util.Model) []string {
-	if cachedContent, ok := r.model.renderState.items[r.realIdx]; ok {
-		return cachedContent.lines
-	}
-
-	itemLines := r.renderItemLines(item)
-	r.model.renderState.items[r.realIdx] = renderedItem{
-		lines:  itemLines,
-		start:  r.start,
-		height: len(itemLines),
-	}
-	return itemLines
-}
-
-// renderItemLines converts an item to its string representation with gaps.
-func (r *reverseRenderer) renderItemLines(item util.Model) []string {
-	return r.model.getItemLines(item)
-}
-
-// selectPreviousItem moves selection to the previous item in the list.
-// Handles focus management and ensures the selected item remains visible.
-// Skips section headers during navigation.
-func (m *model) selectPreviousItem() tea.Cmd {
-	if m.selectionState.selectedIndex == m.findFirstSelectableItem() && m.wrapNavigation {
-		// If at the beginning and wrapping is enabled, go to the last item
-		return m.goToBottom()
-	}
-	if m.selectionState.selectedIndex <= 0 {
-		return nil
-	}
-
-	cmds := []tea.Cmd{m.blurSelected()}
-	m.selectionState.selectedIndex--
-
-	// Skip section headers
-	for m.selectionState.selectedIndex >= 0 && m.isSectionHeader(m.selectionState.selectedIndex) {
-		m.selectionState.selectedIndex--
-	}
-
-	// If we went past the beginning, stay at the first non-header item
-	if m.selectionState.selectedIndex <= 0 {
-		cmds = append(cmds, m.goToTop()) // Ensure we scroll to the top if needed
-		return tea.Batch(cmds...)
-	}
-
-	cmds = append(cmds, m.focusSelected())
-	m.ensureSelectedItemVisible()
-	return tea.Batch(cmds...)
-}
-
-// selectNextItem moves selection to the next item in the list.
-// Handles focus management and ensures the selected item remains visible.
-// Skips section headers during navigation.
-func (m *model) selectNextItem() tea.Cmd {
-	if m.selectionState.selectedIndex >= m.findLastSelectableItem() && m.wrapNavigation {
-		// If at the end and wrapping is enabled, go to the first item
-		return m.goToTop()
-	}
-	if m.selectionState.selectedIndex >= len(m.filteredItems)-1 || m.selectionState.selectedIndex < 0 {
-		return nil
-	}
-
-	cmds := []tea.Cmd{m.blurSelected()}
-	m.selectionState.selectedIndex++
-
-	// Skip section headers
-	for m.selectionState.selectedIndex < len(m.filteredItems) && m.isSectionHeader(m.selectionState.selectedIndex) {
-		m.selectionState.selectedIndex++
-	}
-
-	// If we went past the end, stay at the last non-header item
-	if m.selectionState.selectedIndex >= len(m.filteredItems) {
-		m.selectionState.selectedIndex = m.findLastSelectableItem()
-	}
-
-	cmds = append(cmds, m.focusSelected())
-	m.ensureSelectedItemVisible()
-	return tea.Batch(cmds...)
-}
-
-// isSectionHeader checks if the item at the given index is a section header.
-func (m *model) isSectionHeader(index int) bool {
-	if index < 0 || index >= len(m.filteredItems) {
-		return false
-	}
-	if header, ok := m.filteredItems[index].(SectionHeader); ok {
-		return header.IsSectionHeader()
-	}
-	return false
-}
-
-// findFirstSelectableItem finds the first item that is not a section header.
-func (m *model) findFirstSelectableItem() int {
-	for i := range m.filteredItems {
-		if !m.isSectionHeader(i) {
-			return i
-		}
-	}
-	return NoSelection
-}
-
-// findLastSelectableItem finds the last item that is not a section header.
-func (m *model) findLastSelectableItem() int {
-	for i := len(m.filteredItems) - 1; i >= 0; i-- {
-		if !m.isSectionHeader(i) {
-			return i
-		}
-	}
-	return NoSelection
-}
-
-// ensureSelectedItemVisible scrolls the list to make the selected item visible.
-// Uses different strategies for forward and reverse rendering modes.
-func (m *model) ensureSelectedItemVisible() {
-	cachedItem, ok := m.renderState.items[m.selectionState.selectedIndex]
-	if !ok {
-		m.renderState.needsRerender = true
-		return
-	}
-
-	if m.viewState.reverse {
-		m.ensureVisibleReverse(cachedItem)
-	} else {
-		m.ensureVisibleForward(cachedItem)
-	}
-	m.renderState.needsRerender = true
-}
-
-// ensureVisibleForward ensures the selected item is visible in forward rendering mode.
-// Handles both large items (taller than viewport) and normal items.
-func (m *model) ensureVisibleForward(cachedItem renderedItem) {
-	if cachedItem.height >= m.listHeight() {
-		if m.selectionState.selectedIndex > 0 {
-			changeNeeded := m.viewState.offset - cachedItem.start
-			m.decreaseOffset(changeNeeded)
-		} else {
-			changeNeeded := cachedItem.start - m.viewState.offset
-			m.increaseOffset(changeNeeded)
-		}
-		return
-	}
-
-	if cachedItem.start < m.viewState.offset {
-		changeNeeded := m.viewState.offset - cachedItem.start
-		m.decreaseOffset(changeNeeded)
-	} else {
-		end := cachedItem.start + cachedItem.height
-		if end > m.viewState.offset+m.listHeight() {
-			changeNeeded := end - (m.viewState.offset + m.listHeight())
-			m.increaseOffset(changeNeeded)
-		}
-	}
-}
-
-// ensureVisibleReverse ensures the selected item is visible in reverse rendering mode.
-// Handles both large items (taller than viewport) and normal items.
-func (m *model) ensureVisibleReverse(cachedItem renderedItem) {
-	if cachedItem.height >= m.listHeight() {
-		if m.selectionState.selectedIndex < len(m.filteredItems)-1 {
-			changeNeeded := m.viewState.offset - (cachedItem.start + cachedItem.height - m.listHeight())
-			m.decreaseOffset(changeNeeded)
-		} else {
-			changeNeeded := (cachedItem.start + cachedItem.height - m.listHeight()) - m.viewState.offset
-			m.increaseOffset(changeNeeded)
-		}
-		return
-	}
-
-	if cachedItem.start+cachedItem.height > m.viewState.offset+m.listHeight() {
-		changeNeeded := (cachedItem.start + cachedItem.height - m.listHeight()) - m.viewState.offset
-		m.increaseOffset(changeNeeded)
-	} else if cachedItem.start < m.viewState.offset {
-		changeNeeded := m.viewState.offset - cachedItem.start
-		m.decreaseOffset(changeNeeded)
-	}
-}
-
-// goToBottom switches to reverse mode and selects the last selectable item.
-// Commonly used for chat-like interfaces where new content appears at the bottom.
-// Skips section headers when selecting the last item.
-func (m *model) goToBottom() tea.Cmd {
-	cmds := []tea.Cmd{m.blurSelected()}
-	m.viewState.reverse = true
-	m.selectionState.selectedIndex = m.findLastSelectableItem()
-	if m.isFocused {
-		cmds = append(cmds, m.focusSelected())
-	}
-	m.ResetView()
-	return tea.Batch(cmds...)
-}
-
-// goToTop switches to forward mode and selects the first selectable item.
-// Standard behavior for most list interfaces.
-// Skips section headers when selecting the first item.
-func (m *model) goToTop() tea.Cmd {
-	cmds := []tea.Cmd{m.blurSelected()}
-	m.viewState.reverse = false
-	m.selectionState.selectedIndex = m.findFirstSelectableItem()
-	if m.isFocused {
-		cmds = append(cmds, m.focusSelected())
-	}
-	m.ResetView()
-	return tea.Batch(cmds...)
-}
-
-// ResetView clears all cached rendering data and resets scroll position.
-// Forces a complete re-render on the next View() call.
-func (m *model) ResetView() {
-	m.renderState.reset()
-	m.viewState.offset = 0
-}
-
-// focusSelected gives focus to the currently selected item if it supports focus.
-// Triggers a re-render of the item to show its focused state.
-func (m *model) focusSelected() tea.Cmd {
-	if !m.isFocused {
-		return nil // No focus change if the list is not focused
-	}
-	if !m.selectionState.isValidIndex(len(m.filteredItems)) {
-		return nil
-	}
-	if i, ok := m.filteredItems[m.selectionState.selectedIndex].(layout.Focusable); ok {
-		cmd := i.Focus()
-		m.rerenderItem(m.selectionState.selectedIndex)
-		return cmd
-	}
-	return nil
-}
-
-// blurSelected removes focus from the currently selected item if it supports focus.
-// Triggers a re-render of the item to show its unfocused state.
-func (m *model) blurSelected() tea.Cmd {
-	if !m.selectionState.isValidIndex(len(m.filteredItems)) {
-		return nil
-	}
-	if i, ok := m.filteredItems[m.selectionState.selectedIndex].(layout.Focusable); ok {
-		cmd := i.Blur()
-		m.rerenderItem(m.selectionState.selectedIndex)
-		return cmd
-	}
-	return nil
-}
-
-// rerenderItem updates the cached rendering of a specific item.
-// This is called when an item's state changes (e.g., focus/blur) and needs to be re-displayed.
-// It efficiently updates only the changed item and adjusts positions of subsequent items if needed.
-func (m *model) rerenderItem(inx int) {
-	if inx < 0 || inx >= len(m.filteredItems) || len(m.renderState.lines) == 0 {
-		return
-	}
-
-	cachedItem, ok := m.renderState.items[inx]
-	if !ok {
-		return
-	}
-
-	rerenderedLines := m.getItemLines(m.filteredItems[inx])
-	if slices.Equal(cachedItem.lines, rerenderedLines) {
-		return
-	}
-
-	m.updateRenderedLines(cachedItem, rerenderedLines)
-	m.updateItemPositions(inx, cachedItem, len(rerenderedLines))
-	m.updateCachedItem(inx, cachedItem, rerenderedLines)
-	m.renderState.needsRerender = true
-}
-
-// getItemLines converts an item to its rendered lines, including any gap spacing.
-// Handles section headers with special styling.
-func (m *model) getItemLines(item util.Model) []string {
-	var itemLines []string
-
-	itemLines = strings.Split(item.View(), "\n")
-
-	if m.gapSize > 0 {
-		gap := make([]string, m.gapSize)
-		itemLines = append(itemLines, gap...)
-	}
-	return itemLines
-}
-
-// updateRenderedLines replaces the lines for a specific item in the overall rendered content.
-func (m *model) updateRenderedLines(cachedItem renderedItem, newLines []string) {
-	start, end := m.getItemBounds(cachedItem)
-	totalLines := len(m.renderState.lines)
-
-	if start >= 0 && start <= totalLines && end >= 0 && end <= totalLines {
-		m.renderState.lines = slices.Delete(m.renderState.lines, start, end)
-		m.renderState.lines = slices.Insert(m.renderState.lines, start, newLines...)
-	}
-}
-
-// getItemBounds calculates the start and end line positions for an item.
-// Handles both forward and reverse rendering modes.
-func (m *model) getItemBounds(cachedItem renderedItem) (start, end int) {
-	start = cachedItem.start
-	end = start + cachedItem.height
-
-	if m.viewState.reverse {
-		totalLines := len(m.renderState.lines)
-		end = totalLines - cachedItem.start
-		start = end - cachedItem.height
-	}
-	return start, end
-}
-
-// updateItemPositions recalculates positions for items after the changed item.
-// This is necessary when an item's height changes, affecting subsequent items.
-func (m *model) updateItemPositions(inx int, cachedItem renderedItem, newHeight int) {
-	if cachedItem.height == newHeight {
-		return
-	}
-
-	if inx == len(m.filteredItems)-1 {
-		m.renderState.finalHeight = max(0, cachedItem.start+newHeight-m.listHeight())
-	}
-
-	currentStart := cachedItem.start + newHeight
-	if m.viewState.reverse {
-		m.updatePositionsReverse(inx, currentStart)
-	} else {
-		m.updatePositionsForward(inx, currentStart)
-	}
-}
-
-// updatePositionsForward updates positions for items after the changed item in forward mode.
-func (m *model) updatePositionsForward(inx int, currentStart int) {
-	for i := inx + 1; i < len(m.filteredItems); i++ {
-		if existing, ok := m.renderState.items[i]; ok {
-			existing.start = currentStart
-			currentStart += existing.height
-			m.renderState.items[i] = existing
-		} else {
-			break
-		}
-	}
-}
-
-// updatePositionsReverse updates positions for items before the changed item in reverse mode.
-func (m *model) updatePositionsReverse(inx int, currentStart int) {
-	for i := inx - 1; i >= 0; i-- {
-		if existing, ok := m.renderState.items[i]; ok {
-			existing.start = currentStart
-			currentStart += existing.height
-			m.renderState.items[i] = existing
-		} else {
-			break
-		}
-	}
-}
-
-// updateCachedItem updates the cached rendering information for a specific item.
-func (m *model) updateCachedItem(inx int, cachedItem renderedItem, newLines []string) {
-	m.renderState.items[inx] = renderedItem{
-		lines:  newLines,
-		start:  cachedItem.start,
-		height: len(newLines),
-	}
-}
-
-// increaseOffset scrolls the list down by increasing the offset.
-// Respects the final height limit to prevent scrolling past the end.
-func (m *model) increaseOffset(n int) {
-	if m.renderState.finalHeight > NoFinalHeight {
-		if m.viewState.offset < m.renderState.finalHeight {
-			m.viewState.offset += n
-			if m.viewState.offset > m.renderState.finalHeight {
-				m.viewState.offset = m.renderState.finalHeight
-			}
-			m.renderState.needsRerender = true
-		}
-	} else {
-		m.viewState.offset += n
-		m.renderState.needsRerender = true
-	}
-}
-
-// decreaseOffset scrolls the list up by decreasing the offset.
-// Prevents scrolling above the beginning of the list.
-func (m *model) decreaseOffset(n int) {
-	if m.viewState.offset > 0 {
-		m.viewState.offset -= n
-		if m.viewState.offset < 0 {
-			m.viewState.offset = 0
-		}
-		m.renderState.needsRerender = true
-	}
-}
-
-// UpdateItem replaces an item at the specified index with a new item.
-// Handles focus management and triggers re-rendering as needed.
-func (m *model) UpdateItem(inx int, item util.Model) {
-	if inx < 0 || inx >= len(m.filteredItems) {
-		return
-	}
-	m.filteredItems[inx] = item
-	if m.selectionState.selectedIndex == inx {
-		m.focusSelected()
-	}
-	m.setItemSize(inx)
-	m.rerenderItem(inx)
-	m.renderState.needsRerender = true
-}
-
-// GetSize returns the current dimensions of the list.
-func (m *model) GetSize() (int, int) {
-	return m.viewState.width, m.viewState.height
-}
-
-// SetSize updates the list dimensions and triggers a complete re-render.
-// Also updates the size of all items that support sizing.
-func (m *model) SetSize(width int, height int) tea.Cmd {
-	if m.filterable && !m.hideFilterInput {
-		height -= 2 // adjust for input field height and border
-	}
-
-	if m.viewState.width == width && m.viewState.height == height {
-		return nil
-	}
-	if m.viewState.height != height {
-		m.renderState.finalHeight = NoFinalHeight
-		m.viewState.height = height
-	}
-	m.viewState.width = width
-	m.ResetView()
-	if m.filterable && !m.hideFilterInput {
-		m.input.SetWidth(m.getItemWidth() - 5)
-	}
-	return m.setAllItemsSize()
-}
-
-// getItemWidth calculates the available width for items, accounting for padding.
-func (m *model) getItemWidth() int {
-	width := m.viewState.width
-	switch len(m.padding) {
-	case 1:
-		width -= m.padding[0] * 2
-	case 2, 3:
-		width -= m.padding[1] * 2
-	case 4:
-		width -= m.padding[1] + m.padding[3]
-	}
-	return max(0, width)
-}
-
-// setItemSize updates the size of a specific item if it supports sizing.
-func (m *model) setItemSize(inx int) tea.Cmd {
-	if inx < 0 || inx >= len(m.filteredItems) {
-		return nil
-	}
-	if i, ok := m.filteredItems[inx].(layout.Sizeable); ok {
-		return i.SetSize(m.getItemWidth(), 0)
-	}
-	return nil
-}
-
-// setAllItemsSize updates the size of all items that support sizing.
-func (m *model) setAllItemsSize() tea.Cmd {
-	var cmds []tea.Cmd
-	for i := range m.filteredItems {
-		if cmd := m.setItemSize(i); cmd != nil {
-			cmds = append(cmds, cmd)
-		}
-	}
-	return tea.Batch(cmds...)
-}
-
-// listHeight calculates the available height for list content, accounting for padding.
-func (m *model) listHeight() int {
-	height := m.viewState.height
-	switch len(m.padding) {
-	case 1:
-		height -= m.padding[0] * 2
-	case 2:
-		height -= m.padding[0] * 2
-	case 3, 4:
-		height -= m.padding[0] + m.padding[2]
-	}
-	if m.filterable && !m.hideFilterInput {
-		height -= lipgloss.Height(m.inputStyle.Render("dummy"))
-	}
-	return max(0, height)
-}
-
-// AppendItem adds a new item to the end of the list.
-// Automatically switches to reverse mode and scrolls to show the new item.
-func (m *model) AppendItem(item util.Model) tea.Cmd {
-	cmds := []tea.Cmd{
-		item.Init(),
-	}
-	m.allItems = append(m.allItems, item)
-	m.filteredItems = m.allItems
-	cmds = append(cmds, m.setItemSize(len(m.filteredItems)-1))
-	cmds = append(cmds, m.goToBottom())
-	m.renderState.needsRerender = true
-	return tea.Batch(cmds...)
-}
-
-// DeleteItem removes an item at the specified index.
-// Adjusts selection if necessary and triggers a complete re-render.
-func (m *model) DeleteItem(i int) {
-	if i < 0 || i >= len(m.filteredItems) {
-		return
-	}
-	m.allItems = slices.Delete(m.allItems, i, i+1)
-	delete(m.renderState.items, i)
-	m.filteredItems = m.allItems
-
-	if m.selectionState.selectedIndex == i && m.selectionState.selectedIndex > 0 {
-		m.selectionState.selectedIndex--
-	} else if m.selectionState.selectedIndex > i {
-		m.selectionState.selectedIndex--
-	}
-
-	m.ResetView()
-	m.renderState.needsRerender = true
-}
-
-// PrependItem adds a new item to the beginning of the list.
-// Adjusts cached positions and selection index, then switches to forward mode.
-func (m *model) PrependItem(item util.Model) tea.Cmd {
-	cmds := []tea.Cmd{item.Init()}
-	m.allItems = append([]util.Model{item}, m.allItems...)
-	m.filteredItems = m.allItems
-
-	// Shift all cached item indices by 1
-	newItems := make(map[int]renderedItem, len(m.renderState.items))
-	for k, v := range m.renderState.items {
-		newItems[k+1] = v
-	}
-	m.renderState.items = newItems
-
-	if m.selectionState.selectedIndex >= 0 {
-		m.selectionState.selectedIndex++
-	}
-
-	cmds = append(cmds, m.goToTop())
-	cmds = append(cmds, m.setItemSize(0))
-	m.renderState.needsRerender = true
-	return tea.Batch(cmds...)
-}
-
-// setReverse switches between forward and reverse rendering modes.
-func (m *model) setReverse(reverse bool) {
-	if reverse {
-		m.goToBottom()
-	} else {
-		m.goToTop()
-	}
-}
-
-// SetItems replaces all items in the list with a new set.
-// Initializes all items, sets their sizes, and establishes initial selection.
-// Ensures the initial selection skips section headers.
-func (m *model) SetItems(items []util.Model) tea.Cmd {
-	m.allItems = items
-	m.filteredItems = items
-	cmds := []tea.Cmd{m.setAllItemsSize()}
-
-	for _, item := range m.filteredItems {
-		cmds = append(cmds, item.Init())
-	}
-
-	if len(m.filteredItems) > 0 {
-		if m.viewState.reverse {
-			m.selectionState.selectedIndex = m.findLastSelectableItem()
-		} else {
-			m.selectionState.selectedIndex = m.findFirstSelectableItem()
-		}
-		if cmd := m.focusSelected(); cmd != nil {
-			cmds = append(cmds, cmd)
-		}
-	} else {
-		m.selectionState.selectedIndex = NoSelection
-	}
-
-	m.ResetView()
-	return tea.Batch(cmds...)
-}
-
-// section represents a group of items under a section header.
-type section struct {
-	header SectionHeader
-	items  []util.Model
-}
-
-// parseSections parses the flat item list into sections.
-func (m *model) parseSections() []section {
-	var sections []section
-	var currentSection *section
-
-	for _, item := range m.allItems {
-		if header, ok := item.(SectionHeader); ok && header.IsSectionHeader() {
-			// Start a new section
-			if currentSection != nil {
-				sections = append(sections, *currentSection)
-			}
-			currentSection = &section{
-				header: header,
-				items:  []util.Model{},
-			}
-		} else if currentSection != nil {
-			// Add item to current section
-			currentSection.items = append(currentSection.items, item)
-		} else {
-			// Item without a section header - create an implicit section
-			if len(sections) == 0 || sections[len(sections)-1].header != nil {
-				sections = append(sections, section{
-					header: nil,
-					items:  []util.Model{item},
-				})
-			} else {
-				// Add to the last implicit section
-				sections[len(sections)-1].items = append(sections[len(sections)-1].items, item)
-			}
-		}
-	}
-
-	// Don't forget the last section
-	if currentSection != nil {
-		sections = append(sections, *currentSection)
-	}
-
-	return sections
-}
-
-// flattenSections converts sections back to a flat list.
-func (m *model) flattenSections(sections []section) []util.Model {
-	var result []util.Model
-
-	for _, sect := range sections {
-		if sect.header != nil {
-			result = append(result, sect.header)
-		}
-		result = append(result, sect.items...)
-	}
-
-	return result
-}
-
-func (m *model) Filter(search string) tea.Cmd {
-	var cmds []tea.Cmd
-	search = strings.TrimSpace(search)
-	search = strings.ToLower(search)
-
-	// Clear focus and match indexes from all items
-	for _, item := range m.allItems {
-		if i, ok := item.(layout.Focusable); ok {
-			cmds = append(cmds, i.Blur())
-		}
-		if i, ok := item.(HasMatchIndexes); ok {
-			i.MatchIndexes(make([]int, 0))
-		}
-	}
-
-	if search == "" {
-		cmds = append(cmds, m.SetItems(m.allItems))
-		return tea.Batch(cmds...)
-	}
-
-	// Parse items into sections
-	sections := m.parseSections()
-	var filteredSections []section
-
-	for _, sect := range sections {
-		filteredSection := m.filterSection(sect, search)
-		if filteredSection != nil {
-			filteredSections = append(filteredSections, *filteredSection)
-		}
-	}
-
-	// Rebuild flat list from filtered sections
-	m.filteredItems = m.flattenSections(filteredSections)
-
-	// Set initial selection
-	if len(m.filteredItems) > 0 {
-		if m.viewState.reverse {
-			slices.Reverse(m.filteredItems)
-			m.selectionState.selectedIndex = m.findLastSelectableItem()
-		} else {
-			m.selectionState.selectedIndex = m.findFirstSelectableItem()
-		}
-		if cmd := m.focusSelected(); cmd != nil {
-			cmds = append(cmds, cmd)
-		}
-	} else {
-		m.selectionState.selectedIndex = NoSelection
-	}
-
-	m.ResetView()
-	return tea.Batch(cmds...)
-}
-
-// filterSection filters items within a section and returns the section if it has matches.
-func (m *model) filterSection(sect section, search string) *section {
-	var matchedItems []util.Model
-	var hasHeaderMatch bool
-
-	// Check if section header itself matches
-	if sect.header != nil {
-		headerText := strings.ToLower(sect.header.View())
-		if strings.Contains(headerText, search) {
-			hasHeaderMatch = true
-			// If header matches, include all items in the section
-			matchedItems = sect.items
-		}
-	}
-
-	// If header didn't match, filter items within the section
-	if !hasHeaderMatch && len(sect.items) > 0 {
-		// Create words array for items in this section
-		words := make([]string, len(sect.items))
-		for i, item := range sect.items {
-			if f, ok := item.(HasFilterValue); ok {
-				words[i] = strings.ToLower(f.FilterValue())
-			} else {
-				words[i] = ""
-			}
-		}
-
-		// Find matches within this section
-		matches := fuzzy.Find(search, words)
-
-		// Sort matches by score but preserve relative order for equal scores
-		sort.SliceStable(matches, func(i, j int) bool {
-			return matches[i].Score > matches[j].Score
-		})
-
-		// Build matched items list
-		for _, match := range matches {
-			item := sect.items[match.Index]
-			if i, ok := item.(HasMatchIndexes); ok {
-				i.MatchIndexes(match.MatchedIndexes)
-			}
-			matchedItems = append(matchedItems, item)
-		}
-	}
-
-	// Return section only if it has matches
-	if len(matchedItems) > 0 {
-		return &section{
-			header: sect.header,
-			items:  matchedItems,
-		}
-	}
-
-	return nil
-}
-
-// SelectedIndex returns the index of the currently selected item.
-func (m *model) SelectedIndex() int {
-	if m.selectionState.selectedIndex < 0 || m.selectionState.selectedIndex >= len(m.filteredItems) {
-		return NoSelection
-	}
-	return m.selectionState.selectedIndex
-}
-
-// SetSelected sets the selected item by index and automatically scrolls to make it visible.
-// If the index is invalid or points to a section header, it finds the nearest selectable item.
-func (m *model) SetSelected(index int) tea.Cmd {
-	changeNeeded := m.selectionState.selectedIndex - index
-	cmds := []tea.Cmd{}
-	if changeNeeded < 0 {
-		for range -changeNeeded {
-			cmds = append(cmds, m.selectNextItem())
-			m.renderVisible()
-		}
-	} else if changeNeeded > 0 {
-		for range changeNeeded {
-			cmds = append(cmds, m.selectPreviousItem())
-			m.renderVisible()
-		}
-	}
-	return tea.Batch(cmds...)
-}
-
-// Blur implements ListModel.
-func (m *model) Blur() tea.Cmd {
-	m.isFocused = false
-	cmd := m.blurSelected()
-	return cmd
-}
-
-// Focus implements ListModel.
-func (m *model) Focus() tea.Cmd {
-	m.isFocused = true
-	cmd := m.focusSelected()
-	return cmd
-}
-
-// IsFocused implements ListModel.
-func (m *model) IsFocused() bool {
-	return m.isFocused
-}
-
-func (m *model) SetFilterPlaceholder(placeholder string) {
-	m.input.Placeholder = placeholder
-}

internal/tui/components/dialogs/commands/commands.go 🔗

@@ -10,10 +10,9 @@ import (
 	"github.com/charmbracelet/crush/internal/config"
 	"github.com/charmbracelet/crush/internal/llm/prompt"
 	"github.com/charmbracelet/crush/internal/tui/components/chat"
-	"github.com/charmbracelet/crush/internal/tui/components/completions"
 	"github.com/charmbracelet/crush/internal/tui/components/core"
-	"github.com/charmbracelet/crush/internal/tui/components/core/list"
 	"github.com/charmbracelet/crush/internal/tui/components/dialogs"
+	"github.com/charmbracelet/crush/internal/tui/exp/list"
 	"github.com/charmbracelet/crush/internal/tui/styles"
 	"github.com/charmbracelet/crush/internal/tui/util"
 )
@@ -29,6 +28,8 @@ const (
 	UserCommands
 )
 
+type listModel = list.FilterableList[list.CompletionItem[Command]]
+
 // Command represents a command that can be executed
 type Command struct {
 	ID          string
@@ -48,7 +49,7 @@ type commandDialogCmp struct {
 	wWidth  int // Width of the terminal window
 	wHeight int // Height of the terminal window
 
-	commandList  list.ListModel
+	commandList  listModel
 	keyMap       CommandsDialogKeyMap
 	help         help.Model
 	commandType  int       // SystemCommands or UserCommands
@@ -67,24 +68,23 @@ type (
 )
 
 func NewCommandDialog(sessionID string) CommandsDialog {
-	listKeyMap := list.DefaultKeyMap()
 	keyMap := DefaultCommandsDialogKeyMap()
-
+	listKeyMap := list.DefaultKeyMap()
 	listKeyMap.Down.SetEnabled(false)
 	listKeyMap.Up.SetEnabled(false)
-	listKeyMap.HalfPageDown.SetEnabled(false)
-	listKeyMap.HalfPageUp.SetEnabled(false)
-	listKeyMap.Home.SetEnabled(false)
-	listKeyMap.End.SetEnabled(false)
-
 	listKeyMap.DownOneItem = keyMap.Next
 	listKeyMap.UpOneItem = keyMap.Previous
 
 	t := styles.CurrentTheme()
-	commandList := list.New(
-		list.WithFilterable(true),
-		list.WithKeyMap(listKeyMap),
-		list.WithWrapNavigation(true),
+	inputStyle := t.S().Base.PaddingLeft(1).PaddingBottom(1)
+	commandList := list.NewFilterableList(
+		[]list.CompletionItem[Command]{},
+		list.WithFilterInputStyle(inputStyle),
+		list.WithFilterListOptions(
+			list.WithKeyMap(listKeyMap),
+			list.WithWrapNavigation(),
+			list.WithResizeByList(),
+		),
 	)
 	help := help.New()
 	help.Styles = t.S().Help
@@ -103,10 +103,8 @@ func (c *commandDialogCmp) Init() tea.Cmd {
 	if err != nil {
 		return util.ReportError(err)
 	}
-
 	c.userCommands = commands
-	c.SetCommandType(c.commandType)
-	return c.commandList.Init()
+	return c.SetCommandType(c.commandType)
 }
 
 func (c *commandDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
@@ -114,22 +112,23 @@ func (c *commandDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	case tea.WindowSizeMsg:
 		c.wWidth = msg.Width
 		c.wHeight = msg.Height
-		c.SetCommandType(c.commandType)
 		return c, c.commandList.SetSize(c.listWidth(), c.listHeight())
 	case tea.KeyPressMsg:
 		switch {
 		case key.Matches(msg, c.keyMap.Select):
-			selectedItemInx := c.commandList.SelectedIndex()
-			if selectedItemInx == list.NoSelection {
+			selectedItem := c.commandList.SelectedItem()
+			if selectedItem == nil {
 				return c, nil // No item selected, do nothing
 			}
-			items := c.commandList.Items()
-			selectedItem := items[selectedItemInx].(completions.CompletionItem).Value().(Command)
+			command := (*selectedItem).Value()
 			return c, tea.Sequence(
 				util.CmdHandler(dialogs.CloseDialogMsg{}),
-				selectedItem.Handler(selectedItem),
+				command.Handler(command),
 			)
 		case key.Matches(msg, c.keyMap.Tab):
+			if len(c.userCommands) == 0 {
+				return c, nil
+			}
 			// Toggle command type between System and User commands
 			if c.commandType == SystemCommands {
 				return c, c.SetCommandType(UserCommands)
@@ -140,7 +139,7 @@ func (c *commandDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			return c, util.CmdHandler(dialogs.CloseDialogMsg{})
 		default:
 			u, cmd := c.commandList.Update(msg)
-			c.commandList = u.(list.ListModel)
+			c.commandList = u.(listModel)
 			return c, cmd
 		}
 	}
@@ -151,9 +150,14 @@ func (c *commandDialogCmp) View() string {
 	t := styles.CurrentTheme()
 	listView := c.commandList
 	radio := c.commandTypeRadio()
+
+	header := t.S().Base.Padding(0, 1, 1, 1).Render(core.Title("Commands", c.width-lipgloss.Width(radio)-5) + " " + radio)
+	if len(c.userCommands) == 0 {
+		header = t.S().Base.Padding(0, 1, 1, 1).Render(core.Title("Commands", c.width-4))
+	}
 	content := lipgloss.JoinVertical(
 		lipgloss.Left,
-		t.S().Base.Padding(0, 1, 1, 1).Render(core.Title("Commands", c.width-lipgloss.Width(radio)-5)+" "+radio),
+		header,
 		listView.View(),
 		"",
 		t.S().Base.Width(c.width-2).PaddingLeft(1).AlignHorizontal(lipgloss.Left).Render(c.help.View(c.keyMap)),
@@ -197,13 +201,18 @@ func (c *commandDialogCmp) SetCommandType(commandType int) tea.Cmd {
 		commands = c.userCommands
 	}
 
-	commandItems := []util.Model{}
+	commandItems := []list.CompletionItem[Command]{}
 	for _, cmd := range commands {
-		opts := []completions.CompletionOption{}
+		opts := []list.CompletionItemOption{
+			list.WithCompletionID(cmd.ID),
+		}
 		if cmd.Shortcut != "" {
-			opts = append(opts, completions.WithShortcut(cmd.Shortcut))
+			opts = append(
+				opts,
+				list.WithCompletionShortcut(cmd.Shortcut),
+			)
 		}
-		commandItems = append(commandItems, completions.NewCompletionItem(cmd.Title, cmd, opts...))
+		commandItems = append(commandItems, list.NewCompletionItem(cmd.Title, cmd, opts...))
 	}
 	return c.commandList.SetItems(commandItems)
 }

internal/tui/components/dialogs/commands/item.go 🔗

@@ -1,69 +0,0 @@
-package commands
-
-import (
-	tea "github.com/charmbracelet/bubbletea/v2"
-	"github.com/charmbracelet/crush/internal/tui/components/core"
-	"github.com/charmbracelet/crush/internal/tui/components/core/layout"
-	"github.com/charmbracelet/crush/internal/tui/components/core/list"
-	"github.com/charmbracelet/crush/internal/tui/styles"
-	"github.com/charmbracelet/crush/internal/tui/util"
-	"github.com/charmbracelet/x/ansi"
-)
-
-type ItemSection interface {
-	util.Model
-	layout.Sizeable
-	list.SectionHeader
-	SetInfo(info string)
-}
-type itemSectionModel struct {
-	width int
-	title string
-	info  string
-}
-
-func NewItemSection(title string) ItemSection {
-	return &itemSectionModel{
-		title: title,
-	}
-}
-
-func (m *itemSectionModel) Init() tea.Cmd {
-	return nil
-}
-
-func (m *itemSectionModel) Update(tea.Msg) (tea.Model, tea.Cmd) {
-	return m, nil
-}
-
-func (m *itemSectionModel) View() string {
-	t := styles.CurrentTheme()
-	title := ansi.Truncate(m.title, m.width-2, "…")
-	style := t.S().Base.Padding(1, 1, 0, 1)
-	title = t.S().Muted.Render(title)
-	section := ""
-	if m.info != "" {
-		section = core.SectionWithInfo(title, m.width-2, m.info)
-	} else {
-		section = core.Section(title, m.width-2)
-	}
-
-	return style.Render(section)
-}
-
-func (m *itemSectionModel) GetSize() (int, int) {
-	return m.width, 1
-}
-
-func (m *itemSectionModel) SetSize(width int, height int) tea.Cmd {
-	m.width = width
-	return nil
-}
-
-func (m *itemSectionModel) IsSectionHeader() bool {
-	return true
-}
-
-func (m *itemSectionModel) SetInfo(info string) {
-	m.info = info
-}

internal/tui/components/dialogs/models/list.go 🔗

@@ -7,27 +7,36 @@ import (
 	tea "github.com/charmbracelet/bubbletea/v2"
 	"github.com/charmbracelet/catwalk/pkg/catwalk"
 	"github.com/charmbracelet/crush/internal/config"
-	"github.com/charmbracelet/crush/internal/tui/components/completions"
-	"github.com/charmbracelet/crush/internal/tui/components/core/list"
-	"github.com/charmbracelet/crush/internal/tui/components/dialogs/commands"
+	"github.com/charmbracelet/crush/internal/tui/exp/list"
 	"github.com/charmbracelet/crush/internal/tui/styles"
 	"github.com/charmbracelet/crush/internal/tui/util"
-	"github.com/charmbracelet/lipgloss/v2"
 )
 
+type listModel = list.FilterableGroupList[list.CompletionItem[ModelOption]]
+
 type ModelListComponent struct {
-	list      list.ListModel
+	list      listModel
 	modelType int
 	providers []catwalk.Provider
 }
 
-func NewModelListComponent(keyMap list.KeyMap, inputStyle lipgloss.Style, inputPlaceholder string) *ModelListComponent {
-	modelList := list.New(
-		list.WithFilterable(true),
+func NewModelListComponent(keyMap list.KeyMap, inputPlaceholder string, shouldResize bool) *ModelListComponent {
+	t := styles.CurrentTheme()
+	inputStyle := t.S().Base.PaddingLeft(1).PaddingBottom(1)
+	options := []list.ListOption{
 		list.WithKeyMap(keyMap),
-		list.WithInputStyle(inputStyle),
+		list.WithWrapNavigation(),
+	}
+	if shouldResize {
+		options = append(options, list.WithResizeByList())
+	}
+	modelList := list.NewFilterableGroupedList(
+		[]list.Group[list.CompletionItem[ModelOption]]{},
+		list.WithFilterInputStyle(inputStyle),
 		list.WithFilterPlaceholder(inputPlaceholder),
-		list.WithWrapNavigation(true),
+		list.WithFilterListOptions(
+			options...,
+		),
 	)
 
 	return &ModelListComponent{
@@ -51,7 +60,7 @@ func (m *ModelListComponent) Init() tea.Cmd {
 
 func (m *ModelListComponent) Update(msg tea.Msg) (*ModelListComponent, tea.Cmd) {
 	u, cmd := m.list.Update(msg)
-	m.list = u.(list.ListModel)
+	m.list = u.(listModel)
 	return m, cmd
 }
 
@@ -67,21 +76,23 @@ func (m *ModelListComponent) SetSize(width, height int) tea.Cmd {
 	return m.list.SetSize(width, height)
 }
 
-func (m *ModelListComponent) Items() []util.Model {
-	return m.list.Items()
-}
-
-func (m *ModelListComponent) SelectedIndex() int {
-	return m.list.SelectedIndex()
+func (m *ModelListComponent) SelectedModel() *ModelOption {
+	s := m.list.SelectedItem()
+	if s == nil {
+		return nil
+	}
+	sv := *s
+	model := sv.Value()
+	return &model
 }
 
 func (m *ModelListComponent) SetModelType(modelType int) tea.Cmd {
 	t := styles.CurrentTheme()
 	m.modelType = modelType
 
-	modelItems := []util.Model{}
+	var groups []list.Group[list.CompletionItem[ModelOption]]
 	// first none section
-	selectIndex := 1
+	selectedItemID := ""
 
 	cfg := config.Get()
 	var currentModel config.SelectedModel
@@ -140,18 +151,28 @@ func (m *ModelListComponent) SetModelType(modelType int) tea.Cmd {
 			if name == "" {
 				name = string(configProvider.ID)
 			}
-			section := commands.NewItemSection(name)
+			section := list.NewItemSection(name)
 			section.SetInfo(configured)
-			modelItems = append(modelItems, section)
+			group := list.Group[list.CompletionItem[ModelOption]]{
+				Section: section,
+			}
 			for _, model := range configProvider.Models {
-				modelItems = append(modelItems, completions.NewCompletionItem(model.Name, ModelOption{
+				item := list.NewCompletionItem(model.Name, ModelOption{
 					Provider: configProvider,
 					Model:    model,
-				}))
+				},
+					list.WithCompletionID(
+						fmt.Sprintf("%s:%s", providerConfig.ID, model.ID),
+					),
+				)
+
+				group.Items = append(group.Items, item)
 				if model.ID == currentModel.Model && string(configProvider.ID) == currentModel.Provider {
-					selectIndex = len(modelItems) - 1 // Set the selected index to the current model
+					selectedItemID = item.ID()
 				}
 			}
+			groups = append(groups, group)
+
 			addedProviders[providerID] = true
 		}
 	}
@@ -173,23 +194,43 @@ func (m *ModelListComponent) SetModelType(modelType int) tea.Cmd {
 			name = string(provider.ID)
 		}
 
-		section := commands.NewItemSection(name)
+		section := list.NewItemSection(name)
 		if _, ok := cfg.Providers.Get(string(provider.ID)); ok {
 			section.SetInfo(configured)
 		}
-		modelItems = append(modelItems, section)
+		group := list.Group[list.CompletionItem[ModelOption]]{
+			Section: section,
+		}
 		for _, model := range provider.Models {
-			modelItems = append(modelItems, completions.NewCompletionItem(model.Name, ModelOption{
+			item := list.NewCompletionItem(model.Name, ModelOption{
 				Provider: provider,
 				Model:    model,
-			}))
+			},
+				list.WithCompletionID(
+					fmt.Sprintf("%s:%s", provider.ID, model.ID),
+				),
+			)
+			group.Items = append(group.Items, item)
 			if model.ID == currentModel.Model && string(provider.ID) == currentModel.Provider {
-				selectIndex = len(modelItems) - 1 // Set the selected index to the current model
+				selectedItemID = item.ID()
 			}
 		}
+		groups = append(groups, group)
+	}
+
+	var cmds []tea.Cmd
+
+	cmd := m.list.SetGroups(groups)
+
+	if cmd != nil {
+		cmds = append(cmds, cmd)
+	}
+	cmd = m.list.SetSelected(selectedItemID)
+	if cmd != nil {
+		cmds = append(cmds, cmd)
 	}
 
-	return tea.Sequence(m.list.SetItems(modelItems), m.list.SetSelected(selectIndex))
+	return tea.Sequence(cmds...)
 }
 
 // GetModelType returns the current model type
@@ -198,7 +239,7 @@ func (m *ModelListComponent) GetModelType() int {
 }
 
 func (m *ModelListComponent) SetInputPlaceholder(placeholder string) {
-	m.list.SetFilterPlaceholder(placeholder)
+	m.list.SetInputPlaceholder(placeholder)
 }
 
 func (m *ModelListComponent) SetProviders(providers []catwalk.Provider) {

internal/tui/components/dialogs/models/models.go 🔗

@@ -10,10 +10,9 @@ import (
 	tea "github.com/charmbracelet/bubbletea/v2"
 	"github.com/charmbracelet/catwalk/pkg/catwalk"
 	"github.com/charmbracelet/crush/internal/config"
-	"github.com/charmbracelet/crush/internal/tui/components/completions"
 	"github.com/charmbracelet/crush/internal/tui/components/core"
-	"github.com/charmbracelet/crush/internal/tui/components/core/list"
 	"github.com/charmbracelet/crush/internal/tui/components/dialogs"
+	"github.com/charmbracelet/crush/internal/tui/exp/list"
 	"github.com/charmbracelet/crush/internal/tui/styles"
 	"github.com/charmbracelet/crush/internal/tui/util"
 	"github.com/charmbracelet/lipgloss/v2"
@@ -71,22 +70,16 @@ type modelDialogCmp struct {
 }
 
 func NewModelDialogCmp() ModelDialog {
-	listKeyMap := list.DefaultKeyMap()
 	keyMap := DefaultKeyMap()
 
+	listKeyMap := list.DefaultKeyMap()
 	listKeyMap.Down.SetEnabled(false)
 	listKeyMap.Up.SetEnabled(false)
-	listKeyMap.HalfPageDown.SetEnabled(false)
-	listKeyMap.HalfPageUp.SetEnabled(false)
-	listKeyMap.Home.SetEnabled(false)
-	listKeyMap.End.SetEnabled(false)
-
 	listKeyMap.DownOneItem = keyMap.Next
 	listKeyMap.UpOneItem = keyMap.Previous
 
 	t := styles.CurrentTheme()
-	inputStyle := t.S().Base.Padding(0, 1, 0, 1)
-	modelList := NewModelListComponent(listKeyMap, inputStyle, "Choose a model for large, complex tasks")
+	modelList := NewModelListComponent(listKeyMap, "Choose a model for large, complex tasks", true)
 	apiKeyInput := NewAPIKeyInput()
 	apiKeyInput.SetShowTitle(false)
 	help := help.New()
@@ -162,12 +155,7 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 				)
 			}
 			// Normal model selection
-			selectedItemInx := m.modelList.SelectedIndex()
-			if selectedItemInx == list.NoSelection {
-				return m, nil
-			}
-			items := m.modelList.Items()
-			selectedItem := items[selectedItemInx].(completions.CompletionItem).Value().(ModelOption)
+			selectedItem := m.modelList.SelectedModel()
 
 			var modelType config.SelectedModelType
 			if m.modelList.GetModelType() == LargeModelType {
@@ -191,7 +179,7 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			} else {
 				// Provider not configured, show API key input
 				m.needsAPIKey = true
-				m.selectedModel = &selectedItem
+				m.selectedModel = selectedItem
 				m.selectedModelType = modelType
 				m.apiKeyInput.SetProviderName(selectedItem.Provider.Name)
 				return m, nil
@@ -310,13 +298,11 @@ func (m *modelDialogCmp) style() lipgloss.Style {
 }
 
 func (m *modelDialogCmp) listWidth() int {
-	return defaultWidth - 2 // 4 for padding
+	return m.width - 2
 }
 
 func (m *modelDialogCmp) listHeight() int {
-	items := m.modelList.Items()
-	listHeigh := len(items) + 2 + 4
-	return min(listHeigh, m.wHeight/2)
+	return m.wHeight / 2
 }
 
 func (m *modelDialogCmp) Position() (int, int) {

internal/tui/components/dialogs/sessions/sessions.go 🔗

@@ -6,10 +6,9 @@ import (
 	tea "github.com/charmbracelet/bubbletea/v2"
 	"github.com/charmbracelet/crush/internal/session"
 	"github.com/charmbracelet/crush/internal/tui/components/chat"
-	"github.com/charmbracelet/crush/internal/tui/components/completions"
 	"github.com/charmbracelet/crush/internal/tui/components/core"
-	"github.com/charmbracelet/crush/internal/tui/components/core/list"
 	"github.com/charmbracelet/crush/internal/tui/components/dialogs"
+	"github.com/charmbracelet/crush/internal/tui/exp/list"
 	"github.com/charmbracelet/crush/internal/tui/styles"
 	"github.com/charmbracelet/crush/internal/tui/util"
 	"github.com/charmbracelet/lipgloss/v2"
@@ -22,6 +21,8 @@ type SessionDialog interface {
 	dialogs.DialogModel
 }
 
+type SessionsList = list.FilterableList[list.CompletionItem[session.Session]]
+
 type sessionDialogCmp struct {
 	selectedInx       int
 	wWidth            int
@@ -29,8 +30,7 @@ type sessionDialogCmp struct {
 	width             int
 	selectedSessionID string
 	keyMap            KeyMap
-	sessionsList      list.ListModel
-	renderedSelected  bool
+	sessionsList      SessionsList
 	help              help.Model
 }
 
@@ -39,39 +39,31 @@ func NewSessionDialogCmp(sessions []session.Session, selectedID string) SessionD
 	t := styles.CurrentTheme()
 	listKeyMap := list.DefaultKeyMap()
 	keyMap := DefaultKeyMap()
-
 	listKeyMap.Down.SetEnabled(false)
 	listKeyMap.Up.SetEnabled(false)
-	listKeyMap.HalfPageDown.SetEnabled(false)
-	listKeyMap.HalfPageUp.SetEnabled(false)
-	listKeyMap.Home.SetEnabled(false)
-	listKeyMap.End.SetEnabled(false)
-
 	listKeyMap.DownOneItem = keyMap.Next
 	listKeyMap.UpOneItem = keyMap.Previous
 
-	selectedInx := 0
-	items := make([]util.Model, len(sessions))
+	items := make([]list.CompletionItem[session.Session], len(sessions))
 	if len(sessions) > 0 {
 		for i, session := range sessions {
-			items[i] = completions.NewCompletionItem(session.Title, session)
-			if session.ID == selectedID {
-				selectedInx = i
-			}
+			items[i] = list.NewCompletionItem(session.Title, session, list.WithCompletionID(session.ID))
 		}
 	}
 
-	sessionsList := list.New(
-		list.WithFilterable(true),
+	inputStyle := t.S().Base.PaddingLeft(1).PaddingBottom(1)
+	sessionsList := list.NewFilterableList(
+		items,
 		list.WithFilterPlaceholder("Enter a session name"),
-		list.WithKeyMap(listKeyMap),
-		list.WithItems(items),
-		list.WithWrapNavigation(true),
+		list.WithFilterInputStyle(inputStyle),
+		list.WithFilterListOptions(
+			list.WithKeyMap(listKeyMap),
+			list.WithWrapNavigation(),
+		),
 	)
 	help := help.New()
 	help.Styles = t.S().Help
 	s := &sessionDialogCmp{
-		selectedInx:       selectedInx,
 		selectedSessionID: selectedID,
 		keyMap:            DefaultKeyMap(),
 		sessionsList:      sessionsList,
@@ -82,32 +74,35 @@ func NewSessionDialogCmp(sessions []session.Session, selectedID string) SessionD
 }
 
 func (s *sessionDialogCmp) Init() tea.Cmd {
-	return s.sessionsList.Init()
+	var cmds []tea.Cmd
+	cmds = append(cmds, s.sessionsList.Init())
+	cmds = append(cmds, s.sessionsList.Focus())
+	return tea.Sequence(cmds...)
 }
 
 func (s *sessionDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	switch msg := msg.(type) {
 	case tea.WindowSizeMsg:
+		var cmds []tea.Cmd
 		s.wWidth = msg.Width
 		s.wHeight = msg.Height
-		s.width = s.wWidth / 2
-		var cmds []tea.Cmd
+		s.width = min(120, s.wWidth-8)
+		s.sessionsList.SetInputWidth(s.listWidth() - 2)
 		cmds = append(cmds, s.sessionsList.SetSize(s.listWidth(), s.listHeight()))
-		if !s.renderedSelected {
-			cmds = append(cmds, s.sessionsList.SetSelected(s.selectedInx))
-			s.renderedSelected = true
+		if s.selectedSessionID != "" {
+			cmds = append(cmds, s.sessionsList.SetSelected(s.selectedSessionID))
 		}
-		return s, tea.Sequence(cmds...)
+		return s, tea.Batch(cmds...)
 	case tea.KeyPressMsg:
 		switch {
 		case key.Matches(msg, s.keyMap.Select):
-			if len(s.sessionsList.Items()) > 0 {
-				items := s.sessionsList.Items()
-				selectedItemInx := s.sessionsList.SelectedIndex()
+			selectedItem := s.sessionsList.SelectedItem()
+			if selectedItem != nil {
+				selected := *selectedItem
 				return s, tea.Sequence(
 					util.CmdHandler(dialogs.CloseDialogMsg{}),
 					util.CmdHandler(
-						chat.SessionSelectedMsg(items[selectedItemInx].(completions.CompletionItem).Value().(session.Session)),
+						chat.SessionSelectedMsg(selected.Value()),
 					),
 				)
 			}
@@ -115,7 +110,7 @@ func (s *sessionDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			return s, util.CmdHandler(dialogs.CloseDialogMsg{})
 		default:
 			u, cmd := s.sessionsList.Update(msg)
-			s.sessionsList = u.(list.ListModel)
+			s.sessionsList = u.(SessionsList)
 			return s, cmd
 		}
 	}

internal/tui/exp/list/filterable.go 🔗

@@ -0,0 +1,308 @@
+package list
+
+import (
+	"regexp"
+	"slices"
+	"sort"
+	"strings"
+
+	"github.com/charmbracelet/bubbles/v2/key"
+	"github.com/charmbracelet/bubbles/v2/textinput"
+	tea "github.com/charmbracelet/bubbletea/v2"
+	"github.com/charmbracelet/crush/internal/tui/components/core/layout"
+	"github.com/charmbracelet/crush/internal/tui/styles"
+	"github.com/charmbracelet/lipgloss/v2"
+	"github.com/sahilm/fuzzy"
+)
+
+type FilterableItem interface {
+	Item
+	FilterValue() string
+}
+
+type FilterableList[T FilterableItem] interface {
+	List[T]
+	Cursor() *tea.Cursor
+	SetInputWidth(int)
+	SetInputPlaceholder(string)
+	Filter(q string) tea.Cmd
+}
+
+type HasMatchIndexes interface {
+	MatchIndexes([]int)
+}
+
+type filterableOptions struct {
+	listOptions []ListOption
+	placeholder string
+	inputHidden bool
+	inputWidth  int
+	inputStyle  lipgloss.Style
+}
+type filterableList[T FilterableItem] struct {
+	*list[T]
+	*filterableOptions
+	width, height int
+	// stores all available items
+	items      []T
+	input      textinput.Model
+	inputWidth int
+	query      string
+}
+
+type filterableListOption func(*filterableOptions)
+
+func WithFilterPlaceholder(ph string) filterableListOption {
+	return func(f *filterableOptions) {
+		f.placeholder = ph
+	}
+}
+
+func WithFilterInputHidden() filterableListOption {
+	return func(f *filterableOptions) {
+		f.inputHidden = true
+	}
+}
+
+func WithFilterInputStyle(inputStyle lipgloss.Style) filterableListOption {
+	return func(f *filterableOptions) {
+		f.inputStyle = inputStyle
+	}
+}
+
+func WithFilterListOptions(opts ...ListOption) filterableListOption {
+	return func(f *filterableOptions) {
+		f.listOptions = opts
+	}
+}
+
+func WithFilterInputWidth(inputWidth int) filterableListOption {
+	return func(f *filterableOptions) {
+		f.inputWidth = inputWidth
+	}
+}
+
+func NewFilterableList[T FilterableItem](items []T, opts ...filterableListOption) FilterableList[T] {
+	t := styles.CurrentTheme()
+
+	f := &filterableList[T]{
+		filterableOptions: &filterableOptions{
+			inputStyle:  t.S().Base,
+			placeholder: "Type to filter",
+		},
+	}
+	for _, opt := range opts {
+		opt(f.filterableOptions)
+	}
+	f.list = New[T](items, f.listOptions...).(*list[T])
+
+	f.updateKeyMaps()
+	f.items = f.list.items.Slice()
+
+	if f.inputHidden {
+		return f
+	}
+
+	ti := textinput.New()
+	ti.Placeholder = f.placeholder
+	ti.SetVirtualCursor(false)
+	ti.Focus()
+	ti.SetStyles(t.S().TextInput)
+	f.input = ti
+	return f
+}
+
+func (f *filterableList[T]) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+	switch msg := msg.(type) {
+	case tea.KeyPressMsg:
+		switch {
+		// handle movements
+		case key.Matches(msg, f.keyMap.Down),
+			key.Matches(msg, f.keyMap.Up),
+			key.Matches(msg, f.keyMap.DownOneItem),
+			key.Matches(msg, f.keyMap.UpOneItem),
+			key.Matches(msg, f.keyMap.HalfPageDown),
+			key.Matches(msg, f.keyMap.HalfPageUp),
+			key.Matches(msg, f.keyMap.PageDown),
+			key.Matches(msg, f.keyMap.PageUp),
+			key.Matches(msg, f.keyMap.End),
+			key.Matches(msg, f.keyMap.Home):
+			u, cmd := f.list.Update(msg)
+			f.list = u.(*list[T])
+			return f, cmd
+		default:
+			if !f.inputHidden {
+				var cmds []tea.Cmd
+				var cmd tea.Cmd
+				f.input, cmd = f.input.Update(msg)
+				cmds = append(cmds, cmd)
+
+				if f.query != f.input.Value() {
+					cmd = f.Filter(f.input.Value())
+					cmds = append(cmds, cmd)
+				}
+				f.query = f.input.Value()
+				return f, tea.Batch(cmds...)
+			}
+		}
+	}
+	u, cmd := f.list.Update(msg)
+	f.list = u.(*list[T])
+	return f, cmd
+}
+
+func (f *filterableList[T]) View() string {
+	if f.inputHidden {
+		return f.list.View()
+	}
+
+	return lipgloss.JoinVertical(
+		lipgloss.Left,
+		f.inputStyle.Render(f.input.View()),
+		f.list.View(),
+	)
+}
+
+// removes bindings that are used for search
+func (f *filterableList[T]) updateKeyMaps() {
+	alphanumeric := regexp.MustCompile("^[a-zA-Z0-9]*$")
+
+	removeLettersAndNumbers := func(bindings []string) []string {
+		var keep []string
+		for _, b := range bindings {
+			if len(b) != 1 {
+				keep = append(keep, b)
+				continue
+			}
+			if b == " " {
+				continue
+			}
+			m := alphanumeric.MatchString(b)
+			if !m {
+				keep = append(keep, b)
+			}
+		}
+		return keep
+	}
+
+	updateBinding := func(binding key.Binding) key.Binding {
+		newKeys := removeLettersAndNumbers(binding.Keys())
+		if len(newKeys) == 0 {
+			binding.SetEnabled(false)
+			return binding
+		}
+		binding.SetKeys(newKeys...)
+		return binding
+	}
+
+	f.keyMap.Down = updateBinding(f.keyMap.Down)
+	f.keyMap.Up = updateBinding(f.keyMap.Up)
+	f.keyMap.DownOneItem = updateBinding(f.keyMap.DownOneItem)
+	f.keyMap.UpOneItem = updateBinding(f.keyMap.UpOneItem)
+	f.keyMap.HalfPageDown = updateBinding(f.keyMap.HalfPageDown)
+	f.keyMap.HalfPageUp = updateBinding(f.keyMap.HalfPageUp)
+	f.keyMap.PageDown = updateBinding(f.keyMap.PageDown)
+	f.keyMap.PageUp = updateBinding(f.keyMap.PageUp)
+	f.keyMap.End = updateBinding(f.keyMap.End)
+	f.keyMap.Home = updateBinding(f.keyMap.Home)
+}
+
+func (m *filterableList[T]) GetSize() (int, int) {
+	return m.width, m.height
+}
+
+func (f *filterableList[T]) SetSize(w, h int) tea.Cmd {
+	f.width = w
+	f.height = h
+	if f.inputHidden {
+		return f.list.SetSize(w, h)
+	}
+	if f.inputWidth == 0 {
+		f.input.SetWidth(w)
+	} else {
+		f.input.SetWidth(f.inputWidth)
+	}
+	return f.list.SetSize(w, h-(f.inputHeight()))
+}
+
+func (f *filterableList[T]) inputHeight() int {
+	return lipgloss.Height(f.inputStyle.Render(f.input.View()))
+}
+
+func (f *filterableList[T]) Filter(query string) tea.Cmd {
+	var cmds []tea.Cmd
+	for _, item := range f.items {
+		if i, ok := any(item).(layout.Focusable); ok {
+			cmds = append(cmds, i.Blur())
+		}
+		if i, ok := any(item).(HasMatchIndexes); ok {
+			i.MatchIndexes(make([]int, 0))
+		}
+	}
+
+	f.selectedItem = ""
+	if query == "" {
+		return f.list.SetItems(f.items)
+	}
+
+	words := make([]string, len(f.items))
+	for i, item := range f.items {
+		words[i] = strings.ToLower(item.FilterValue())
+	}
+
+	matches := fuzzy.Find(query, words)
+
+	sort.SliceStable(matches, func(i, j int) bool {
+		return matches[i].Score > matches[j].Score
+	})
+
+	var matchedItems []T
+	for _, match := range matches {
+		item := f.items[match.Index]
+		if i, ok := any(item).(HasMatchIndexes); ok {
+			i.MatchIndexes(match.MatchedIndexes)
+		}
+		matchedItems = append(matchedItems, item)
+	}
+
+	if f.direction == DirectionBackward {
+		slices.Reverse(matchedItems)
+	}
+
+	cmds = append(cmds, f.list.SetItems(matchedItems))
+	return tea.Batch(cmds...)
+}
+
+func (f *filterableList[T]) SetItems(items []T) tea.Cmd {
+	f.items = items
+	return f.list.SetItems(items)
+}
+
+func (f *filterableList[T]) Cursor() *tea.Cursor {
+	if f.inputHidden {
+		return nil
+	}
+	return f.input.Cursor()
+}
+
+func (f *filterableList[T]) Blur() tea.Cmd {
+	f.input.Blur()
+	return f.list.Blur()
+}
+
+func (f *filterableList[T]) Focus() tea.Cmd {
+	f.input.Focus()
+	return f.list.Focus()
+}
+
+func (f *filterableList[T]) IsFocused() bool {
+	return f.list.IsFocused()
+}
+
+func (f *filterableList[T]) SetInputWidth(w int) {
+	f.inputWidth = w
+}
+
+func (f *filterableList[T]) SetInputPlaceholder(ph string) {
+	f.placeholder = ph
+}

internal/tui/exp/list/filterable_group.go 🔗

@@ -0,0 +1,260 @@
+package list
+
+import (
+	"regexp"
+	"sort"
+	"strings"
+
+	"github.com/charmbracelet/bubbles/v2/key"
+	"github.com/charmbracelet/bubbles/v2/textinput"
+	tea "github.com/charmbracelet/bubbletea/v2"
+	"github.com/charmbracelet/crush/internal/tui/components/core/layout"
+	"github.com/charmbracelet/crush/internal/tui/styles"
+	"github.com/charmbracelet/lipgloss/v2"
+	"github.com/sahilm/fuzzy"
+)
+
+type FilterableGroupList[T FilterableItem] interface {
+	GroupedList[T]
+	Cursor() *tea.Cursor
+	SetInputWidth(int)
+	SetInputPlaceholder(string)
+}
+type filterableGroupList[T FilterableItem] struct {
+	*groupedList[T]
+	*filterableOptions
+	width, height int
+	groups        []Group[T]
+	// stores all available items
+	input      textinput.Model
+	inputWidth int
+	query      string
+}
+
+func NewFilterableGroupedList[T FilterableItem](items []Group[T], opts ...filterableListOption) FilterableGroupList[T] {
+	t := styles.CurrentTheme()
+
+	f := &filterableGroupList[T]{
+		filterableOptions: &filterableOptions{
+			inputStyle:  t.S().Base,
+			placeholder: "Type to filter",
+		},
+	}
+	for _, opt := range opts {
+		opt(f.filterableOptions)
+	}
+	f.groupedList = NewGroupedList(items, f.listOptions...).(*groupedList[T])
+
+	f.updateKeyMaps()
+
+	if f.inputHidden {
+		return f
+	}
+
+	ti := textinput.New()
+	ti.Placeholder = f.placeholder
+	ti.SetVirtualCursor(false)
+	ti.Focus()
+	ti.SetStyles(t.S().TextInput)
+	f.input = ti
+	return f
+}
+
+func (f *filterableGroupList[T]) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+	switch msg := msg.(type) {
+	case tea.KeyPressMsg:
+		switch {
+		// handle movements
+		case key.Matches(msg, f.keyMap.Down),
+			key.Matches(msg, f.keyMap.Up),
+			key.Matches(msg, f.keyMap.DownOneItem),
+			key.Matches(msg, f.keyMap.UpOneItem),
+			key.Matches(msg, f.keyMap.HalfPageDown),
+			key.Matches(msg, f.keyMap.HalfPageUp),
+			key.Matches(msg, f.keyMap.PageDown),
+			key.Matches(msg, f.keyMap.PageUp),
+			key.Matches(msg, f.keyMap.End),
+			key.Matches(msg, f.keyMap.Home):
+			u, cmd := f.groupedList.Update(msg)
+			f.groupedList = u.(*groupedList[T])
+			return f, cmd
+		default:
+			if !f.inputHidden {
+				var cmds []tea.Cmd
+				var cmd tea.Cmd
+				f.input, cmd = f.input.Update(msg)
+				cmds = append(cmds, cmd)
+
+				if f.query != f.input.Value() {
+					cmd = f.Filter(f.input.Value())
+					cmds = append(cmds, cmd)
+				}
+				f.query = f.input.Value()
+				return f, tea.Batch(cmds...)
+			}
+		}
+	}
+	u, cmd := f.groupedList.Update(msg)
+	f.groupedList = u.(*groupedList[T])
+	return f, cmd
+}
+
+func (f *filterableGroupList[T]) View() string {
+	if f.inputHidden {
+		return f.groupedList.View()
+	}
+
+	return lipgloss.JoinVertical(
+		lipgloss.Left,
+		f.inputStyle.Render(f.input.View()),
+		f.groupedList.View(),
+	)
+}
+
+// removes bindings that are used for search
+func (f *filterableGroupList[T]) updateKeyMaps() {
+	alphanumeric := regexp.MustCompile("^[a-zA-Z0-9]*$")
+
+	removeLettersAndNumbers := func(bindings []string) []string {
+		var keep []string
+		for _, b := range bindings {
+			if len(b) != 1 {
+				keep = append(keep, b)
+				continue
+			}
+			if b == " " {
+				continue
+			}
+			m := alphanumeric.MatchString(b)
+			if !m {
+				keep = append(keep, b)
+			}
+		}
+		return keep
+	}
+
+	updateBinding := func(binding key.Binding) key.Binding {
+		newKeys := removeLettersAndNumbers(binding.Keys())
+		if len(newKeys) == 0 {
+			binding.SetEnabled(false)
+			return binding
+		}
+		binding.SetKeys(newKeys...)
+		return binding
+	}
+
+	f.keyMap.Down = updateBinding(f.keyMap.Down)
+	f.keyMap.Up = updateBinding(f.keyMap.Up)
+	f.keyMap.DownOneItem = updateBinding(f.keyMap.DownOneItem)
+	f.keyMap.UpOneItem = updateBinding(f.keyMap.UpOneItem)
+	f.keyMap.HalfPageDown = updateBinding(f.keyMap.HalfPageDown)
+	f.keyMap.HalfPageUp = updateBinding(f.keyMap.HalfPageUp)
+	f.keyMap.PageDown = updateBinding(f.keyMap.PageDown)
+	f.keyMap.PageUp = updateBinding(f.keyMap.PageUp)
+	f.keyMap.End = updateBinding(f.keyMap.End)
+	f.keyMap.Home = updateBinding(f.keyMap.Home)
+}
+
+func (m *filterableGroupList[T]) GetSize() (int, int) {
+	return m.width, m.height
+}
+
+func (f *filterableGroupList[T]) SetSize(w, h int) tea.Cmd {
+	f.width = w
+	f.height = h
+	if f.inputHidden {
+		return f.groupedList.SetSize(w, h)
+	}
+	if f.inputWidth == 0 {
+		f.input.SetWidth(w)
+	} else {
+		f.input.SetWidth(f.inputWidth)
+	}
+	return f.groupedList.SetSize(w, h-(f.inputHeight()))
+}
+
+func (f *filterableGroupList[T]) inputHeight() int {
+	return lipgloss.Height(f.inputStyle.Render(f.input.View()))
+}
+
+func (f *filterableGroupList[T]) Filter(query string) tea.Cmd {
+	var cmds []tea.Cmd
+	for _, item := range f.items.Slice() {
+		if i, ok := any(item).(layout.Focusable); ok {
+			cmds = append(cmds, i.Blur())
+		}
+		if i, ok := any(item).(HasMatchIndexes); ok {
+			i.MatchIndexes(make([]int, 0))
+		}
+	}
+
+	f.selectedItem = ""
+	if query == "" {
+		return f.groupedList.SetGroups(f.groups)
+	}
+
+	var newGroups []Group[T]
+	for _, g := range f.groups {
+		words := make([]string, len(g.Items))
+		for i, item := range g.Items {
+			words[i] = strings.ToLower(item.FilterValue())
+		}
+
+		matches := fuzzy.Find(query, words)
+
+		sort.SliceStable(matches, func(i, j int) bool {
+			return matches[i].Score > matches[j].Score
+		})
+
+		var matchedItems []T
+		for _, match := range matches {
+			item := g.Items[match.Index]
+			if i, ok := any(item).(HasMatchIndexes); ok {
+				i.MatchIndexes(match.MatchedIndexes)
+			}
+			matchedItems = append(matchedItems, item)
+		}
+		if len(matchedItems) > 0 {
+			newGroups = append(newGroups, Group[T]{
+				Section: g.Section,
+				Items:   matchedItems,
+			})
+		}
+	}
+	cmds = append(cmds, f.groupedList.SetGroups(newGroups))
+	return tea.Batch(cmds...)
+}
+
+func (f *filterableGroupList[T]) SetGroups(groups []Group[T]) tea.Cmd {
+	f.groups = groups
+	return f.groupedList.SetGroups(groups)
+}
+
+func (f *filterableGroupList[T]) Cursor() *tea.Cursor {
+	if f.inputHidden {
+		return nil
+	}
+	return f.input.Cursor()
+}
+
+func (f *filterableGroupList[T]) Blur() tea.Cmd {
+	f.input.Blur()
+	return f.groupedList.Blur()
+}
+
+func (f *filterableGroupList[T]) Focus() tea.Cmd {
+	f.input.Focus()
+	return f.groupedList.Focus()
+}
+
+func (f *filterableGroupList[T]) IsFocused() bool {
+	return f.groupedList.IsFocused()
+}
+
+func (f *filterableGroupList[T]) SetInputWidth(w int) {
+	f.inputWidth = w
+}
+
+func (f *filterableGroupList[T]) SetInputPlaceholder(ph string) {
+	f.placeholder = ph
+}

internal/tui/exp/list/filterable_test.go 🔗

@@ -0,0 +1,68 @@
+package list
+
+import (
+	"fmt"
+	"slices"
+	"testing"
+
+	"github.com/charmbracelet/x/exp/golden"
+	"github.com/stretchr/testify/assert"
+)
+
+func TestFilterableList(t *testing.T) {
+	t.Parallel()
+	t.Run("should create simple filterable list", func(t *testing.T) {
+		t.Parallel()
+		items := []FilterableItem{}
+		for i := range 5 {
+			item := NewFilterableItem(fmt.Sprintf("Item %d", i))
+			items = append(items, item)
+		}
+		l := NewFilterableList(
+			items,
+			WithFilterListOptions(WithDirectionForward()),
+		).(*filterableList[FilterableItem])
+
+		l.SetSize(100, 10)
+		cmd := l.Init()
+		if cmd != nil {
+			cmd()
+		}
+
+		assert.Equal(t, items[0].ID(), l.selectedItem)
+		golden.RequireEqual(t, []byte(l.View()))
+	})
+}
+
+func TestUpdateKeyMap(t *testing.T) {
+	t.Parallel()
+	l := NewFilterableList(
+		[]FilterableItem{},
+		WithFilterListOptions(WithDirectionForward()),
+	).(*filterableList[FilterableItem])
+
+	hasJ := slices.Contains(l.keyMap.Down.Keys(), "j")
+	fmt.Println(l.keyMap.Down.Keys())
+	hasCtrlJ := slices.Contains(l.keyMap.Down.Keys(), "ctrl+j")
+
+	hasUpperCaseK := slices.Contains(l.keyMap.UpOneItem.Keys(), "K")
+
+	assert.False(t, l.keyMap.HalfPageDown.Enabled(), "should disable keys that are only letters")
+	assert.False(t, hasJ, "should not contain j")
+	assert.False(t, hasUpperCaseK, "should also remove upper case K")
+	assert.True(t, hasCtrlJ, "should still have ctrl+j")
+}
+
+type filterableItem struct {
+	*selectableItem
+}
+
+func NewFilterableItem(content string) FilterableItem {
+	return &filterableItem{
+		selectableItem: NewSelectableItem(content).(*selectableItem),
+	}
+}
+
+func (f *filterableItem) FilterValue() string {
+	return f.content
+}

internal/tui/exp/list/grouped.go 🔗

@@ -0,0 +1,101 @@
+package list
+
+import (
+	tea "github.com/charmbracelet/bubbletea/v2"
+	"github.com/charmbracelet/crush/internal/csync"
+	"github.com/charmbracelet/crush/internal/tui/components/core/layout"
+	"github.com/charmbracelet/crush/internal/tui/util"
+)
+
+type Group[T Item] struct {
+	Section ItemSection
+	Items   []T
+}
+type GroupedList[T Item] interface {
+	util.Model
+	layout.Sizeable
+	Items() []Item
+	Groups() []Group[T]
+	SetGroups([]Group[T]) tea.Cmd
+	MoveUp(int) tea.Cmd
+	MoveDown(int) tea.Cmd
+	GoToTop() tea.Cmd
+	GoToBottom() tea.Cmd
+	SelectItemAbove() tea.Cmd
+	SelectItemBelow() tea.Cmd
+	SetSelected(string) tea.Cmd
+	SelectedItem() *T
+}
+type groupedList[T Item] struct {
+	*list[Item]
+	groups []Group[T]
+}
+
+func NewGroupedList[T Item](groups []Group[T], opts ...ListOption) GroupedList[T] {
+	list := &list[Item]{
+		confOptions: &confOptions{
+			direction: DirectionForward,
+			keyMap:    DefaultKeyMap(),
+			focused:   true,
+		},
+		items:         csync.NewSlice[Item](),
+		indexMap:      csync.NewMap[string, int](),
+		renderedItems: csync.NewMap[string, renderedItem](),
+	}
+	for _, opt := range opts {
+		opt(list.confOptions)
+	}
+
+	return &groupedList[T]{
+		list: list,
+	}
+}
+
+func (g *groupedList[T]) Init() tea.Cmd {
+	g.convertItems()
+	return g.render()
+}
+
+func (l *groupedList[T]) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+	u, cmd := l.list.Update(msg)
+	l.list = u.(*list[Item])
+	return l, cmd
+}
+
+func (g *groupedList[T]) SelectedItem() *T {
+	item := g.list.SelectedItem()
+	if item == nil {
+		return nil
+	}
+	dRef := *item
+	c, ok := any(dRef).(T)
+	if !ok {
+		return nil
+	}
+	return &c
+}
+
+func (g *groupedList[T]) convertItems() {
+	var items []Item
+	for _, g := range g.groups {
+		items = append(items, g.Section)
+		for _, g := range g.Items {
+			items = append(items, g)
+		}
+	}
+	g.items.SetSlice(items)
+}
+
+func (g *groupedList[T]) SetGroups(groups []Group[T]) tea.Cmd {
+	g.groups = groups
+	g.convertItems()
+	return g.SetItems(g.items.Slice())
+}
+
+func (g *groupedList[T]) Groups() []Group[T] {
+	return g.groups
+}
+
+func (g *groupedList[T]) Items() []Item {
+	return g.list.Items()
+}

internal/tui/components/completions/item.go → internal/tui/exp/list/items.go 🔗

@@ -1,81 +1,107 @@
-package completions
+package list
 
 import (
 	"image/color"
 
 	tea "github.com/charmbracelet/bubbletea/v2"
+	"github.com/charmbracelet/crush/internal/tui/components/core"
 	"github.com/charmbracelet/crush/internal/tui/components/core/layout"
-	"github.com/charmbracelet/crush/internal/tui/components/core/list"
 	"github.com/charmbracelet/crush/internal/tui/styles"
-	"github.com/charmbracelet/crush/internal/tui/util"
 	"github.com/charmbracelet/lipgloss/v2"
 	"github.com/charmbracelet/x/ansi"
+	"github.com/google/uuid"
 	"github.com/rivo/uniseg"
 )
 
-type CompletionItem interface {
-	util.Model
+type Indexable interface {
+	SetIndex(int)
+}
+
+type CompletionItem[T any] interface {
+	FilterableItem
 	layout.Focusable
 	layout.Sizeable
-	list.HasMatchIndexes
-	list.HasFilterValue
-	Value() any
+	HasMatchIndexes
+	Value() T
 }
 
-type completionItemCmp struct {
+type completionItemCmp[T any] struct {
 	width        int
+	id           string
 	text         string
-	value        any
+	value        T
 	focus        bool
 	matchIndexes []int
 	bgColor      color.Color
 	shortcut     string
 }
 
-type CompletionOption func(*completionItemCmp)
+type options struct {
+	id           string
+	text         string
+	bgColor      color.Color
+	matchIndexes []int
+	shortcut     string
+}
+
+type CompletionItemOption func(*options)
 
-func WithBackgroundColor(c color.Color) CompletionOption {
-	return func(cmp *completionItemCmp) {
+func WithCompletionBackgroundColor(c color.Color) CompletionItemOption {
+	return func(cmp *options) {
 		cmp.bgColor = c
 	}
 }
 
-func WithMatchIndexes(indexes ...int) CompletionOption {
-	return func(cmp *completionItemCmp) {
+func WithCompletionMatchIndexes(indexes ...int) CompletionItemOption {
+	return func(cmp *options) {
 		cmp.matchIndexes = indexes
 	}
 }
 
-func WithShortcut(shortcut string) CompletionOption {
-	return func(cmp *completionItemCmp) {
+func WithCompletionShortcut(shortcut string) CompletionItemOption {
+	return func(cmp *options) {
 		cmp.shortcut = shortcut
 	}
 }
 
-func NewCompletionItem(text string, value any, opts ...CompletionOption) CompletionItem {
-	c := &completionItemCmp{
+func WithCompletionID(id string) CompletionItemOption {
+	return func(cmp *options) {
+		cmp.id = id
+	}
+}
+
+func NewCompletionItem[T any](text string, value T, opts ...CompletionItemOption) CompletionItem[T] {
+	c := &completionItemCmp[T]{
 		text:  text,
 		value: value,
 	}
+	o := &options{}
 
 	for _, opt := range opts {
-		opt(c)
+		opt(o)
+	}
+	if o.id == "" {
+		o.id = uuid.NewString()
 	}
+	c.id = o.id
+	c.bgColor = o.bgColor
+	c.matchIndexes = o.matchIndexes
+	c.shortcut = o.shortcut
 	return c
 }
 
 // Init implements CommandItem.
-func (c *completionItemCmp) Init() tea.Cmd {
+func (c *completionItemCmp[T]) Init() tea.Cmd {
 	return nil
 }
 
 // Update implements CommandItem.
-func (c *completionItemCmp) Update(tea.Msg) (tea.Model, tea.Cmd) {
+func (c *completionItemCmp[T]) Update(tea.Msg) (tea.Model, tea.Cmd) {
 	return c, nil
 }
 
 // View implements CommandItem.
-func (c *completionItemCmp) View() string {
+func (c *completionItemCmp[T]) View() string {
 	t := styles.CurrentTheme()
 
 	itemStyle := t.S().Base.Padding(0, 1).Width(c.width)
@@ -140,47 +166,47 @@ func (c *completionItemCmp) View() string {
 }
 
 // Blur implements CommandItem.
-func (c *completionItemCmp) Blur() tea.Cmd {
+func (c *completionItemCmp[T]) Blur() tea.Cmd {
 	c.focus = false
 	return nil
 }
 
 // Focus implements CommandItem.
-func (c *completionItemCmp) Focus() tea.Cmd {
+func (c *completionItemCmp[T]) Focus() tea.Cmd {
 	c.focus = true
 	return nil
 }
 
 // GetSize implements CommandItem.
-func (c *completionItemCmp) GetSize() (int, int) {
+func (c *completionItemCmp[T]) GetSize() (int, int) {
 	return c.width, 1
 }
 
 // IsFocused implements CommandItem.
-func (c *completionItemCmp) IsFocused() bool {
+func (c *completionItemCmp[T]) IsFocused() bool {
 	return c.focus
 }
 
 // SetSize implements CommandItem.
-func (c *completionItemCmp) SetSize(width int, height int) tea.Cmd {
+func (c *completionItemCmp[T]) SetSize(width int, height int) tea.Cmd {
 	c.width = width
 	return nil
 }
 
-func (c *completionItemCmp) MatchIndexes(indexes []int) {
+func (c *completionItemCmp[T]) MatchIndexes(indexes []int) {
 	c.matchIndexes = indexes
 }
 
-func (c *completionItemCmp) FilterValue() string {
+func (c *completionItemCmp[T]) FilterValue() string {
 	return c.text
 }
 
-func (c *completionItemCmp) Value() any {
+func (c *completionItemCmp[T]) Value() T {
 	return c.value
 }
 
 // smartTruncate implements fzf-style truncation that ensures the last matching part is visible
-func (c *completionItemCmp) smartTruncate(text string, width int, matchIndexes []int) string {
+func (c *completionItemCmp[T]) smartTruncate(text string, width int, matchIndexes []int) string {
 	if width <= 0 {
 		return ""
 	}
@@ -280,3 +306,80 @@ func bytePosToVisibleCharPos(str string, rng [2]int) (int, int) {
 	stop = pos
 	return start, stop
 }
+
+// ID implements CompletionItem.
+func (c *completionItemCmp[T]) ID() string {
+	return c.id
+}
+
+type ItemSection interface {
+	Item
+	layout.Sizeable
+	Indexable
+	SetInfo(info string)
+}
+type itemSectionModel struct {
+	width int
+	title string
+	inx   int
+	info  string
+}
+
+// ID implements ItemSection.
+func (m *itemSectionModel) ID() string {
+	return uuid.NewString()
+}
+
+func NewItemSection(title string) ItemSection {
+	return &itemSectionModel{
+		title: title,
+		inx:   -1,
+	}
+}
+
+func (m *itemSectionModel) Init() tea.Cmd {
+	return nil
+}
+
+func (m *itemSectionModel) Update(tea.Msg) (tea.Model, tea.Cmd) {
+	return m, nil
+}
+
+func (m *itemSectionModel) View() string {
+	t := styles.CurrentTheme()
+	title := ansi.Truncate(m.title, m.width-2, "…")
+	style := t.S().Base.Padding(1, 1, 0, 1)
+	if m.inx == 0 {
+		style = style.Padding(0, 1, 0, 1)
+	}
+	title = t.S().Muted.Render(title)
+	section := ""
+	if m.info != "" {
+		section = core.SectionWithInfo(title, m.width-2, m.info)
+	} else {
+		section = core.Section(title, m.width-2)
+	}
+
+	return style.Render(section)
+}
+
+func (m *itemSectionModel) GetSize() (int, int) {
+	return m.width, 1
+}
+
+func (m *itemSectionModel) SetSize(width int, height int) tea.Cmd {
+	m.width = width
+	return nil
+}
+
+func (m *itemSectionModel) IsSectionHeader() bool {
+	return true
+}
+
+func (m *itemSectionModel) SetInfo(info string) {
+	m.info = info
+}
+
+func (m *itemSectionModel) SetIndex(inx int) {
+	m.inx = inx
+}

internal/tui/components/core/list/keys.go → internal/tui/exp/list/keys.go 🔗

@@ -46,7 +46,8 @@ func DefaultKeyMap() KeyMap {
 		PageUp: key.NewBinding(
 			key.WithKeys("pgup", "b"),
 			key.WithHelp("b/pgup", "page up"),
-		), HalfPageUp: key.NewBinding(
+		),
+		HalfPageUp: key.NewBinding(
 			key.WithKeys("u"),
 			key.WithHelp("u", "half page up"),
 		),
@@ -61,7 +62,6 @@ func DefaultKeyMap() KeyMap {
 	}
 }
 
-// KeyBindings implements layout.KeyMapProvider
 func (k KeyMap) KeyBindings() []key.Binding {
 	return []key.Binding{
 		k.Down,

internal/tui/exp/list/list.go 🔗

@@ -1,87 +1,1022 @@
 package list
 
 import (
+	"strings"
+
+	"github.com/charmbracelet/bubbles/v2/key"
 	tea "github.com/charmbracelet/bubbletea/v2"
+	"github.com/charmbracelet/crush/internal/csync"
+	"github.com/charmbracelet/crush/internal/tui/components/anim"
 	"github.com/charmbracelet/crush/internal/tui/components/core/layout"
+	"github.com/charmbracelet/crush/internal/tui/styles"
 	"github.com/charmbracelet/crush/internal/tui/util"
+	"github.com/charmbracelet/lipgloss/v2"
 )
 
 type Item interface {
 	util.Model
 	layout.Sizeable
+	ID() string
+}
+
+type HasAnim interface {
+	Item
+	Spinning() bool
 }
 
-type List interface {
+type List[T Item] interface {
 	util.Model
+	layout.Sizeable
+	layout.Focusable
+
+	// Just change state
+	MoveUp(int) tea.Cmd
+	MoveDown(int) tea.Cmd
+	GoToTop() tea.Cmd
+	GoToBottom() tea.Cmd
+	SelectItemAbove() tea.Cmd
+	SelectItemBelow() tea.Cmd
+	SetItems([]T) tea.Cmd
+	SetSelected(string) tea.Cmd
+	SelectedItem() *T
+	Items() []T
+	UpdateItem(string, T) tea.Cmd
+	DeleteItem(string) tea.Cmd
+	PrependItem(T) tea.Cmd
+	AppendItem(T) tea.Cmd
 }
 
-type list struct {
+type direction int
+
+const (
+	DirectionForward direction = iota
+	DirectionBackward
+)
+
+const (
+	ItemNotFound              = -1
+	ViewportDefaultScrollSize = 2
+)
+
+type renderedItem struct {
+	id     string
+	view   string
+	height int
+	start  int
+	end    int
+}
+
+type confOptions struct {
 	width, height int
 	gap           int
+	// if you are at the last item and go down it will wrap to the top
+	wrap         bool
+	keyMap       KeyMap
+	direction    direction
+	selectedItem string
+	focused      bool
+	resize       bool
+	enableMouse  bool
+}
 
-	items []Item
+type list[T Item] struct {
+	*confOptions
 
-	// Filter options
-	filterable        bool
-	filterPlaceholder string
-}
+	offset int
 
-type listOption func(*list)
+	indexMap *csync.Map[string, int]
+	items    *csync.Slice[T]
 
-// WithFilterable enables filtering on the list.
-func WithFilterable(placeholder string) listOption {
-	return func(l *list) {
-		l.filterable = true
-		l.filterPlaceholder = placeholder
-	}
-}
+	renderedItems *csync.Map[string, renderedItem]
 
-// WithItems sets the initial items for the list.
-func WithItems(items ...Item) listOption {
-	return func(l *list) {
-		l.items = items
-	}
+	rendered string
+
+	movingByItem bool
 }
 
+type ListOption func(*confOptions)
+
 // WithSize sets the size of the list.
-func WithSize(width, height int) listOption {
-	return func(l *list) {
+func WithSize(width, height int) ListOption {
+	return func(l *confOptions) {
 		l.width = width
 		l.height = height
 	}
 }
 
 // WithGap sets the gap between items in the list.
-func WithGap(gap int) listOption {
-	return func(l *list) {
+func WithGap(gap int) ListOption {
+	return func(l *confOptions) {
 		l.gap = gap
 	}
 }
 
-func New(opts ...listOption) List {
-	list := &list{
-		items: make([]Item, 0),
+// WithDirectionForward sets the direction to forward
+func WithDirectionForward() ListOption {
+	return func(l *confOptions) {
+		l.direction = DirectionForward
+	}
+}
+
+// WithDirectionBackward sets the direction to forward
+func WithDirectionBackward() ListOption {
+	return func(l *confOptions) {
+		l.direction = DirectionBackward
+	}
+}
+
+// WithSelectedItem sets the initially selected item in the list.
+func WithSelectedItem(id string) ListOption {
+	return func(l *confOptions) {
+		l.selectedItem = id
+	}
+}
+
+func WithKeyMap(keyMap KeyMap) ListOption {
+	return func(l *confOptions) {
+		l.keyMap = keyMap
+	}
+}
+
+func WithWrapNavigation() ListOption {
+	return func(l *confOptions) {
+		l.wrap = true
+	}
+}
+
+func WithFocus(focus bool) ListOption {
+	return func(l *confOptions) {
+		l.focused = focus
+	}
+}
+
+func WithResizeByList() ListOption {
+	return func(l *confOptions) {
+		l.resize = true
+	}
+}
+
+func WithEnableMouse() ListOption {
+	return func(l *confOptions) {
+		l.enableMouse = true
+	}
+}
+
+func New[T Item](items []T, opts ...ListOption) List[T] {
+	list := &list[T]{
+		confOptions: &confOptions{
+			direction: DirectionForward,
+			keyMap:    DefaultKeyMap(),
+			focused:   true,
+		},
+		items:         csync.NewSliceFrom(items),
+		indexMap:      csync.NewMap[string, int](),
+		renderedItems: csync.NewMap[string, renderedItem](),
 	}
 	for _, opt := range opts {
-		opt(list)
+		opt(list.confOptions)
+	}
+
+	for inx, item := range items {
+		if i, ok := any(item).(Indexable); ok {
+			i.SetIndex(inx)
+		}
+		list.indexMap.Set(item.ID(), inx)
 	}
 	return list
 }
 
 // Init implements List.
-func (l *list) Init() tea.Cmd {
+func (l *list[T]) Init() tea.Cmd {
+	return l.render()
+}
+
+// Update implements List.
+func (l *list[T]) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+	switch msg := msg.(type) {
+	case tea.MouseWheelMsg:
+		if l.enableMouse {
+			return l.handleMouseWheel(msg)
+		}
+		return l, nil
+	case anim.StepMsg:
+		var cmds []tea.Cmd
+		for _, item := range l.items.Slice() {
+			if i, ok := any(item).(HasAnim); ok && i.Spinning() {
+				updated, cmd := i.Update(msg)
+				cmds = append(cmds, cmd)
+				if u, ok := updated.(T); ok {
+					cmds = append(cmds, l.UpdateItem(u.ID(), u))
+				}
+			}
+		}
+		return l, tea.Batch(cmds...)
+	case tea.KeyPressMsg:
+		if l.focused {
+			switch {
+			case key.Matches(msg, l.keyMap.Down):
+				return l, l.MoveDown(ViewportDefaultScrollSize)
+			case key.Matches(msg, l.keyMap.Up):
+				return l, l.MoveUp(ViewportDefaultScrollSize)
+			case key.Matches(msg, l.keyMap.DownOneItem):
+				return l, l.SelectItemBelow()
+			case key.Matches(msg, l.keyMap.UpOneItem):
+				return l, l.SelectItemAbove()
+			case key.Matches(msg, l.keyMap.HalfPageDown):
+				return l, l.MoveDown(l.height / 2)
+			case key.Matches(msg, l.keyMap.HalfPageUp):
+				return l, l.MoveUp(l.height / 2)
+			case key.Matches(msg, l.keyMap.PageDown):
+				return l, l.MoveDown(l.height)
+			case key.Matches(msg, l.keyMap.PageUp):
+				return l, l.MoveUp(l.height)
+			case key.Matches(msg, l.keyMap.End):
+				return l, l.GoToBottom()
+			case key.Matches(msg, l.keyMap.Home):
+				return l, l.GoToTop()
+			}
+		}
+	}
+	return l, nil
+}
+
+func (l *list[T]) handleMouseWheel(msg tea.MouseWheelMsg) (tea.Model, tea.Cmd) {
+	var cmd tea.Cmd
+	switch msg.Button {
+	case tea.MouseWheelDown:
+		cmd = l.MoveDown(ViewportDefaultScrollSize)
+	case tea.MouseWheelUp:
+		cmd = l.MoveUp(ViewportDefaultScrollSize)
+	}
+	return l, cmd
+}
+
+// View implements List.
+func (l *list[T]) View() string {
 	if l.height <= 0 || l.width <= 0 {
+		return ""
+	}
+	t := styles.CurrentTheme()
+	view := l.rendered
+	lines := strings.Split(view, "\n")
+
+	start, end := l.viewPosition()
+	viewStart := max(0, start)
+	viewEnd := min(len(lines), end+1)
+	lines = lines[viewStart:viewEnd]
+	if l.resize {
+		return strings.Join(lines, "\n")
+	}
+	return t.S().Base.
+		Height(l.height).
+		Width(l.width).
+		Render(strings.Join(lines, "\n"))
+}
+
+func (l *list[T]) viewPosition() (int, int) {
+	start, end := 0, 0
+	renderedLines := lipgloss.Height(l.rendered) - 1
+	if l.direction == DirectionForward {
+		start = max(0, l.offset)
+		end = min(l.offset+l.height-1, renderedLines)
+	} else {
+		start = max(0, renderedLines-l.offset-l.height+1)
+		end = max(0, renderedLines-l.offset)
+	}
+	return start, end
+}
+
+func (l *list[T]) recalculateItemPositions() {
+	currentContentHeight := 0
+	for _, item := range l.items.Slice() {
+		rItem, ok := l.renderedItems.Get(item.ID())
+		if !ok {
+			continue
+		}
+		rItem.start = currentContentHeight
+		rItem.end = currentContentHeight + rItem.height - 1
+		l.renderedItems.Set(item.ID(), rItem)
+		currentContentHeight = rItem.end + 1 + l.gap
+	}
+}
+
+func (l *list[T]) render() tea.Cmd {
+	if l.width <= 0 || l.height <= 0 || l.items.Len() == 0 {
 		return nil
 	}
+	l.setDefaultSelected()
+
+	var focusChangeCmd tea.Cmd
+	if l.focused {
+		focusChangeCmd = l.focusSelectedItem()
+	} else {
+		focusChangeCmd = l.blurSelectedItem()
+	}
+	// we are not rendering the first time
+	if l.rendered != "" {
+		// rerender everything will mostly hit cache
+		l.rendered, _ = l.renderIterator(0, false, "")
+		if l.direction == DirectionBackward {
+			l.recalculateItemPositions()
+		}
+		// in the end scroll to the selected item
+		if l.focused {
+			l.scrollToSelection()
+		}
+		return focusChangeCmd
+	}
+	rendered, finishIndex := l.renderIterator(0, true, "")
+	l.rendered = rendered
+
+	// recalculate for the initial items
+	if l.direction == DirectionBackward {
+		l.recalculateItemPositions()
+	}
+	renderCmd := func() tea.Msg {
+		l.offset = 0
+		// render the rest
+		l.rendered, _ = l.renderIterator(finishIndex, false, l.rendered)
+		// needed for backwards
+		if l.direction == DirectionBackward {
+			l.recalculateItemPositions()
+		}
+		// in the end scroll to the selected item
+		if l.focused {
+			l.scrollToSelection()
+		}
+
+		return nil
+	}
+	return tea.Batch(focusChangeCmd, renderCmd)
+}
+
+func (l *list[T]) setDefaultSelected() {
+	if l.selectedItem == "" {
+		if l.direction == DirectionForward {
+			l.selectFirstItem()
+		} else {
+			l.selectLastItem()
+		}
+	}
+}
+
+func (l *list[T]) scrollToSelection() {
+	rItem, ok := l.renderedItems.Get(l.selectedItem)
+	if !ok {
+		l.selectedItem = ""
+		l.setDefaultSelected()
+		return
+	}
+
+	start, end := l.viewPosition()
+	// item bigger or equal to the viewport do nothing
+	if rItem.start <= start && rItem.end >= end {
+		return
+	}
+	// if we are moving by item we want to move the offset so that the
+	// whole item is visible not just portions of it
+	if l.movingByItem {
+		if rItem.start >= start && rItem.end <= end {
+			return
+		}
+		defer func() { l.movingByItem = false }()
+	} else {
+		// item already in view do nothing
+		if rItem.start >= start && rItem.start <= end {
+			return
+		}
+		if rItem.end >= start && rItem.end <= end {
+			return
+		}
+	}
+
+	if rItem.height >= l.height {
+		if l.direction == DirectionForward {
+			l.offset = rItem.start
+		} else {
+			l.offset = max(0, lipgloss.Height(l.rendered)-(rItem.start+l.height))
+		}
+		return
+	}
+
+	renderedLines := lipgloss.Height(l.rendered) - 1
+
+	// If item is above the viewport, make it the first item
+	if rItem.start < start {
+		if l.direction == DirectionForward {
+			l.offset = rItem.start
+		} else {
+			l.offset = max(0, renderedLines-rItem.start-l.height+1)
+		}
+	} else if rItem.end > end {
+		// If item is below the viewport, make it the last item
+		if l.direction == DirectionForward {
+			l.offset = max(0, rItem.end-l.height+1)
+		} else {
+			l.offset = max(0, renderedLines-rItem.end)
+		}
+	}
+}
+
+func (l *list[T]) changeSelectionWhenScrolling() tea.Cmd {
+	rItem, ok := l.renderedItems.Get(l.selectedItem)
+	if !ok {
+		return nil
+	}
+	start, end := l.viewPosition()
+	// item bigger than the viewport do nothing
+	if rItem.start <= start && rItem.end >= end {
+		return nil
+	}
+	// item already in view do nothing
+	if rItem.start >= start && rItem.end <= end {
+		return nil
+	}
+
+	itemMiddle := rItem.start + rItem.height/2
+
+	if itemMiddle < start {
+		// select the first item in the viewport
+		// the item is most likely an item coming after this item
+		inx, ok := l.indexMap.Get(rItem.id)
+		if !ok {
+			return nil
+		}
+		for {
+			inx = l.firstSelectableItemBelow(inx)
+			if inx == ItemNotFound {
+				return nil
+			}
+			item, ok := l.items.Get(inx)
+			if !ok {
+				continue
+			}
+			renderedItem, ok := l.renderedItems.Get(item.ID())
+			if !ok {
+				continue
+			}
+
+			// If the item is bigger than the viewport, select it
+			if renderedItem.start <= start && renderedItem.end >= end {
+				l.selectedItem = renderedItem.id
+				return l.render()
+			}
+			// item is in the view
+			if renderedItem.start >= start && renderedItem.start <= end {
+				l.selectedItem = renderedItem.id
+				return l.render()
+			}
+		}
+	} else if itemMiddle > end {
+		// select the first item in the viewport
+		// the item is most likely an item coming after this item
+		inx, ok := l.indexMap.Get(rItem.id)
+		if !ok {
+			return nil
+		}
+		for {
+			inx = l.firstSelectableItemAbove(inx)
+			if inx == ItemNotFound {
+				return nil
+			}
+			item, ok := l.items.Get(inx)
+			if !ok {
+				continue
+			}
+			renderedItem, ok := l.renderedItems.Get(item.ID())
+			if !ok {
+				continue
+			}
+
+			// If the item is bigger than the viewport, select it
+			if renderedItem.start <= start && renderedItem.end >= end {
+				l.selectedItem = renderedItem.id
+				return l.render()
+			}
+			// item is in the view
+			if renderedItem.end >= start && renderedItem.end <= end {
+				l.selectedItem = renderedItem.id
+				return l.render()
+			}
+		}
+	}
 	return nil
 }
 
-// Update implements List.
-func (l *list) Update(tea.Msg) (tea.Model, tea.Cmd) {
-	panic("unimplemented")
+func (l *list[T]) selectFirstItem() {
+	inx := l.firstSelectableItemBelow(-1)
+	if inx != ItemNotFound {
+		item, ok := l.items.Get(inx)
+		if ok {
+			l.selectedItem = item.ID()
+		}
+	}
 }
 
-// View implements List.
-func (l *list) View() string {
-	panic("unimplemented")
+func (l *list[T]) selectLastItem() {
+	inx := l.firstSelectableItemAbove(l.items.Len())
+	if inx != ItemNotFound {
+		item, ok := l.items.Get(inx)
+		if ok {
+			l.selectedItem = item.ID()
+		}
+	}
+}
+
+func (l *list[T]) firstSelectableItemAbove(inx int) int {
+	for i := inx - 1; i >= 0; i-- {
+		item, ok := l.items.Get(i)
+		if !ok {
+			continue
+		}
+		if _, ok := any(item).(layout.Focusable); ok {
+			return i
+		}
+	}
+	if inx == 0 && l.wrap {
+		return l.firstSelectableItemAbove(l.items.Len())
+	}
+	return ItemNotFound
+}
+
+func (l *list[T]) firstSelectableItemBelow(inx int) int {
+	itemsLen := l.items.Len()
+	for i := inx + 1; i < itemsLen; i++ {
+		item, ok := l.items.Get(i)
+		if !ok {
+			continue
+		}
+		if _, ok := any(item).(layout.Focusable); ok {
+			return i
+		}
+	}
+	if inx == itemsLen-1 && l.wrap {
+		return l.firstSelectableItemBelow(-1)
+	}
+	return ItemNotFound
+}
+
+func (l *list[T]) focusSelectedItem() tea.Cmd {
+	if l.selectedItem == "" || !l.focused {
+		return nil
+	}
+	var cmds []tea.Cmd
+	for _, item := range l.items.Slice() {
+		if f, ok := any(item).(layout.Focusable); ok {
+			if item.ID() == l.selectedItem && !f.IsFocused() {
+				cmds = append(cmds, f.Focus())
+				l.renderedItems.Del(item.ID())
+			} else if item.ID() != l.selectedItem && f.IsFocused() {
+				cmds = append(cmds, f.Blur())
+				l.renderedItems.Del(item.ID())
+			}
+		}
+	}
+	return tea.Batch(cmds...)
+}
+
+func (l *list[T]) blurSelectedItem() tea.Cmd {
+	if l.selectedItem == "" || l.focused {
+		return nil
+	}
+	var cmds []tea.Cmd
+	for _, item := range l.items.Slice() {
+		if f, ok := any(item).(layout.Focusable); ok {
+			if item.ID() == l.selectedItem && f.IsFocused() {
+				cmds = append(cmds, f.Blur())
+				l.renderedItems.Del(item.ID())
+			}
+		}
+	}
+	return tea.Batch(cmds...)
+}
+
+// render iterator renders items starting from the specific index and limits hight if limitHeight != -1
+// returns the last index and the rendered content so far
+// we pass the rendered content around and don't use l.rendered to prevent jumping of the content
+func (l *list[T]) renderIterator(startInx int, limitHeight bool, rendered string) (string, int) {
+	currentContentHeight := lipgloss.Height(rendered) - 1
+	itemsLen := l.items.Len()
+	for i := startInx; i < itemsLen; i++ {
+		if currentContentHeight >= l.height && limitHeight {
+			return rendered, i
+		}
+		// cool way to go through the list in both directions
+		inx := i
+
+		if l.direction != DirectionForward {
+			inx = (itemsLen - 1) - i
+		}
+
+		item, ok := l.items.Get(inx)
+		if !ok {
+			continue
+		}
+		var rItem renderedItem
+		if cache, ok := l.renderedItems.Get(item.ID()); ok {
+			rItem = cache
+		} else {
+			rItem = l.renderItem(item)
+			rItem.start = currentContentHeight
+			rItem.end = currentContentHeight + rItem.height - 1
+			l.renderedItems.Set(item.ID(), rItem)
+		}
+		gap := l.gap + 1
+		if inx == itemsLen-1 {
+			gap = 0
+		}
+
+		if l.direction == DirectionForward {
+			rendered += rItem.view + strings.Repeat("\n", gap)
+		} else {
+			rendered = rItem.view + strings.Repeat("\n", gap) + rendered
+		}
+		currentContentHeight = rItem.end + 1 + l.gap
+	}
+	return rendered, itemsLen
+}
+
+func (l *list[T]) renderItem(item Item) renderedItem {
+	view := item.View()
+	return renderedItem{
+		id:     item.ID(),
+		view:   view,
+		height: lipgloss.Height(view),
+	}
+}
+
+// AppendItem implements List.
+func (l *list[T]) AppendItem(item T) tea.Cmd {
+	var cmds []tea.Cmd
+	cmd := item.Init()
+	if cmd != nil {
+		cmds = append(cmds, cmd)
+	}
+
+	l.items.Append(item)
+	l.indexMap = csync.NewMap[string, int]()
+	for inx, item := range l.items.Slice() {
+		l.indexMap.Set(item.ID(), inx)
+	}
+	if l.width > 0 && l.height > 0 {
+		cmd = item.SetSize(l.width, l.height)
+		if cmd != nil {
+			cmds = append(cmds, cmd)
+		}
+	}
+	cmd = l.render()
+	if cmd != nil {
+		cmds = append(cmds, cmd)
+	}
+	if l.direction == DirectionBackward {
+		if l.offset == 0 {
+			cmd = l.GoToBottom()
+			if cmd != nil {
+				cmds = append(cmds, cmd)
+			}
+		} else {
+			newItem, ok := l.renderedItems.Get(item.ID())
+			if ok {
+				newLines := newItem.height
+				if l.items.Len() > 1 {
+					newLines += l.gap
+				}
+				l.offset = min(lipgloss.Height(l.rendered)-1, l.offset+newLines)
+			}
+		}
+	}
+	return tea.Sequence(cmds...)
+}
+
+// Blur implements List.
+func (l *list[T]) Blur() tea.Cmd {
+	l.focused = false
+	return l.render()
+}
+
+// DeleteItem implements List.
+func (l *list[T]) DeleteItem(id string) tea.Cmd {
+	inx, ok := l.indexMap.Get(id)
+	if !ok {
+		return nil
+	}
+	l.items.Delete(inx)
+	l.renderedItems.Del(id)
+	for inx, item := range l.items.Slice() {
+		l.indexMap.Set(item.ID(), inx)
+	}
+
+	if l.selectedItem == id {
+		if inx > 0 {
+			item, ok := l.items.Get(inx - 1)
+			if ok {
+				l.selectedItem = item.ID()
+			} else {
+				l.selectedItem = ""
+			}
+		} else {
+			l.selectedItem = ""
+		}
+	}
+	cmd := l.render()
+	if l.rendered != "" {
+		renderedHeight := lipgloss.Height(l.rendered)
+		if renderedHeight <= l.height {
+			l.offset = 0
+		} else {
+			maxOffset := renderedHeight - l.height
+			if l.offset > maxOffset {
+				l.offset = maxOffset
+			}
+		}
+	}
+	return cmd
+}
+
+// Focus implements List.
+func (l *list[T]) Focus() tea.Cmd {
+	l.focused = true
+	return l.render()
+}
+
+// GetSize implements List.
+func (l *list[T]) GetSize() (int, int) {
+	return l.width, l.height
+}
+
+// GoToBottom implements List.
+func (l *list[T]) GoToBottom() tea.Cmd {
+	if l.offset != 0 {
+		l.selectedItem = ""
+	}
+	l.offset = 0
+	l.direction = DirectionBackward
+	return l.render()
+}
+
+// GoToTop implements List.
+func (l *list[T]) GoToTop() tea.Cmd {
+	if l.offset != 0 {
+		l.selectedItem = ""
+	}
+	l.offset = 0
+	l.direction = DirectionForward
+	return l.render()
+}
+
+// IsFocused implements List.
+func (l *list[T]) IsFocused() bool {
+	return l.focused
+}
+
+// Items implements List.
+func (l *list[T]) Items() []T {
+	return l.items.Slice()
+}
+
+func (l *list[T]) incrementOffset(n int) {
+	renderedHeight := lipgloss.Height(l.rendered)
+	// no need for offset
+	if renderedHeight <= l.height {
+		return
+	}
+	maxOffset := renderedHeight - l.height
+	n = min(n, maxOffset-l.offset)
+	if n <= 0 {
+		return
+	}
+	l.offset += n
+}
+
+func (l *list[T]) decrementOffset(n int) {
+	n = min(n, l.offset)
+	if n <= 0 {
+		return
+	}
+	l.offset -= n
+	if l.offset < 0 {
+		l.offset = 0
+	}
+}
+
+// MoveDown implements List.
+func (l *list[T]) MoveDown(n int) tea.Cmd {
+	if l.direction == DirectionForward {
+		l.incrementOffset(n)
+	} else {
+		l.decrementOffset(n)
+	}
+	return l.changeSelectionWhenScrolling()
+}
+
+// MoveUp implements List.
+func (l *list[T]) MoveUp(n int) tea.Cmd {
+	if l.direction == DirectionForward {
+		l.decrementOffset(n)
+	} else {
+		l.incrementOffset(n)
+	}
+	return l.changeSelectionWhenScrolling()
+}
+
+// PrependItem implements List.
+func (l *list[T]) PrependItem(item T) tea.Cmd {
+	cmds := []tea.Cmd{
+		item.Init(),
+	}
+	l.items.Prepend(item)
+	l.indexMap = csync.NewMap[string, int]()
+	for inx, item := range l.items.Slice() {
+		l.indexMap.Set(item.ID(), inx)
+	}
+	if l.width > 0 && l.height > 0 {
+		cmds = append(cmds, item.SetSize(l.width, l.height))
+	}
+	cmds = append(cmds, l.render())
+	if l.direction == DirectionForward {
+		if l.offset == 0 {
+			cmd := l.GoToTop()
+			if cmd != nil {
+				cmds = append(cmds, cmd)
+			}
+		} else {
+			newItem, ok := l.renderedItems.Get(item.ID())
+			if ok {
+				newLines := newItem.height
+				if l.items.Len() > 1 {
+					newLines += l.gap
+				}
+				l.offset = min(lipgloss.Height(l.rendered)-1, l.offset+newLines)
+			}
+		}
+	}
+	return tea.Batch(cmds...)
+}
+
+// SelectItemAbove implements List.
+func (l *list[T]) SelectItemAbove() tea.Cmd {
+	inx, ok := l.indexMap.Get(l.selectedItem)
+	if !ok {
+		return nil
+	}
+
+	newIndex := l.firstSelectableItemAbove(inx)
+	if newIndex == ItemNotFound {
+		// no item above
+		return nil
+	}
+	var cmds []tea.Cmd
+	if newIndex == 1 {
+		peakAboveIndex := l.firstSelectableItemAbove(newIndex)
+		if peakAboveIndex == ItemNotFound {
+			// this means there is a section above move to the top
+			cmd := l.GoToTop()
+			if cmd != nil {
+				cmds = append(cmds, cmd)
+			}
+		}
+	}
+	item, ok := l.items.Get(newIndex)
+	if !ok {
+		return nil
+	}
+	l.selectedItem = item.ID()
+	l.movingByItem = true
+	renderCmd := l.render()
+	if renderCmd != nil {
+		cmds = append(cmds, renderCmd)
+	}
+	return tea.Sequence(cmds...)
+}
+
+// SelectItemBelow implements List.
+func (l *list[T]) SelectItemBelow() tea.Cmd {
+	inx, ok := l.indexMap.Get(l.selectedItem)
+	if !ok {
+		return nil
+	}
+
+	newIndex := l.firstSelectableItemBelow(inx)
+	if newIndex == ItemNotFound {
+		// no item above
+		return nil
+	}
+	item, ok := l.items.Get(newIndex)
+	if !ok {
+		return nil
+	}
+	l.selectedItem = item.ID()
+	l.movingByItem = true
+	return l.render()
+}
+
+// SelectedItem implements List.
+func (l *list[T]) SelectedItem() *T {
+	inx, ok := l.indexMap.Get(l.selectedItem)
+	if !ok {
+		return nil
+	}
+	if inx > l.items.Len()-1 {
+		return nil
+	}
+	item, ok := l.items.Get(inx)
+	if !ok {
+		return nil
+	}
+	return &item
+}
+
+// SetItems implements List.
+func (l *list[T]) SetItems(items []T) tea.Cmd {
+	l.items.SetSlice(items)
+	var cmds []tea.Cmd
+	for inx, item := range l.items.Slice() {
+		if i, ok := any(item).(Indexable); ok {
+			i.SetIndex(inx)
+		}
+		cmds = append(cmds, item.Init())
+	}
+	cmds = append(cmds, l.reset(""))
+	return tea.Batch(cmds...)
+}
+
+// SetSelected implements List.
+func (l *list[T]) SetSelected(id string) tea.Cmd {
+	l.selectedItem = id
+	return l.render()
+}
+
+func (l *list[T]) reset(selectedItem string) tea.Cmd {
+	var cmds []tea.Cmd
+	l.rendered = ""
+	l.offset = 0
+	l.selectedItem = selectedItem
+	l.indexMap = csync.NewMap[string, int]()
+	l.renderedItems = csync.NewMap[string, renderedItem]()
+	for inx, item := range l.items.Slice() {
+		l.indexMap.Set(item.ID(), inx)
+		if l.width > 0 && l.height > 0 {
+			cmds = append(cmds, item.SetSize(l.width, l.height))
+		}
+	}
+	cmds = append(cmds, l.render())
+	return tea.Batch(cmds...)
+}
+
+// SetSize implements List.
+func (l *list[T]) SetSize(width int, height int) tea.Cmd {
+	oldWidth := l.width
+	l.width = width
+	l.height = height
+	if oldWidth != width {
+		cmd := l.reset(l.selectedItem)
+		return cmd
+	}
+	return nil
+}
+
+// UpdateItem implements List.
+func (l *list[T]) UpdateItem(id string, item T) tea.Cmd {
+	var cmds []tea.Cmd
+	if inx, ok := l.indexMap.Get(id); ok {
+		l.items.Set(inx, item)
+		oldItem, hasOldItem := l.renderedItems.Get(id)
+		oldPosition := l.offset
+		if l.direction == DirectionBackward {
+			oldPosition = (lipgloss.Height(l.rendered) - 1) - l.offset
+		}
+
+		l.renderedItems.Del(id)
+		cmd := l.render()
+
+		// need to check for nil because of sequence not handling nil
+		if cmd != nil {
+			cmds = append(cmds, cmd)
+		}
+		if hasOldItem && l.direction == DirectionBackward {
+			// if we are the last item and there is no offset
+			// make sure to go to the bottom
+			if inx == l.items.Len()-1 && l.offset == 0 {
+				cmd = l.GoToBottom()
+				if cmd != nil {
+					cmds = append(cmds, cmd)
+				}
+
+				// if the item is at least partially below the viewport
+			} else if oldPosition < oldItem.end {
+				newItem, ok := l.renderedItems.Get(item.ID())
+				if ok {
+					newLines := newItem.height - oldItem.height
+					l.offset = util.Clamp(l.offset+newLines, 0, lipgloss.Height(l.rendered)-1)
+				}
+			}
+		} else if hasOldItem && l.offset > oldItem.start {
+			newItem, ok := l.renderedItems.Get(item.ID())
+			if ok {
+				newLines := newItem.height - oldItem.height
+				l.offset = util.Clamp(l.offset+newLines, 0, lipgloss.Height(l.rendered)-1)
+			}
+		}
+	}
+	return tea.Sequence(cmds...)
 }

internal/tui/exp/list/list_test.go 🔗

@@ -0,0 +1,652 @@
+package list
+
+import (
+	"fmt"
+	"strings"
+	"testing"
+
+	tea "github.com/charmbracelet/bubbletea/v2"
+	"github.com/charmbracelet/crush/internal/tui/components/core/layout"
+	"github.com/charmbracelet/lipgloss/v2"
+	"github.com/charmbracelet/x/exp/golden"
+	"github.com/google/uuid"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func TestList(t *testing.T) {
+	t.Parallel()
+	t.Run("should have correct positions in list that fits the items", func(t *testing.T) {
+		t.Parallel()
+		items := []Item{}
+		for i := range 5 {
+			item := NewSelectableItem(fmt.Sprintf("Item %d", i))
+			items = append(items, item)
+		}
+		l := New(items, WithDirectionForward(), WithSize(10, 20)).(*list[Item])
+		execCmd(l, l.Init())
+
+		// should select the last item
+		assert.Equal(t, items[0].ID(), l.selectedItem)
+		assert.Equal(t, 0, l.offset)
+		require.Equal(t, 5, l.indexMap.Len())
+		require.Equal(t, 5, l.items.Len())
+		require.Equal(t, 5, l.renderedItems.Len())
+		assert.Equal(t, 5, lipgloss.Height(l.rendered))
+		assert.NotEqual(t, "\n", string(l.rendered[len(l.rendered)-1]), "should not end in newline")
+		start, end := l.viewPosition()
+		assert.Equal(t, 0, start)
+		assert.Equal(t, 4, end)
+		for i := range 5 {
+			item, ok := l.renderedItems.Get(items[i].ID())
+			require.True(t, ok)
+			assert.Equal(t, i, item.start)
+			assert.Equal(t, i, item.end)
+		}
+
+		golden.RequireEqual(t, []byte(l.View()))
+	})
+	t.Run("should have correct positions in list that fits the items backwards", func(t *testing.T) {
+		t.Parallel()
+		items := []Item{}
+		for i := range 5 {
+			item := NewSelectableItem(fmt.Sprintf("Item %d", i))
+			items = append(items, item)
+		}
+		l := New(items, WithDirectionBackward(), WithSize(10, 20)).(*list[Item])
+		execCmd(l, l.Init())
+
+		// should select the last item
+		assert.Equal(t, items[4].ID(), l.selectedItem)
+		assert.Equal(t, 0, l.offset)
+		require.Equal(t, 5, l.indexMap.Len())
+		require.Equal(t, 5, l.items.Len())
+		require.Equal(t, 5, l.renderedItems.Len())
+		assert.Equal(t, 5, lipgloss.Height(l.rendered))
+		assert.NotEqual(t, "\n", string(l.rendered[len(l.rendered)-1]), "should not end in newline")
+		start, end := l.viewPosition()
+		assert.Equal(t, 0, start)
+		assert.Equal(t, 4, end)
+		for i := range 5 {
+			item, ok := l.renderedItems.Get(items[i].ID())
+			require.True(t, ok)
+			assert.Equal(t, i, item.start)
+			assert.Equal(t, i, item.end)
+		}
+
+		golden.RequireEqual(t, []byte(l.View()))
+	})
+
+	t.Run("should have correct positions in list that does not fits the items", func(t *testing.T) {
+		t.Parallel()
+		items := []Item{}
+		for i := range 30 {
+			item := NewSelectableItem(fmt.Sprintf("Item %d", i))
+			items = append(items, item)
+		}
+		l := New(items, WithDirectionForward(), WithSize(10, 10)).(*list[Item])
+		execCmd(l, l.Init())
+
+		// should select the last item
+		assert.Equal(t, items[0].ID(), l.selectedItem)
+		assert.Equal(t, 0, l.offset)
+		require.Equal(t, 30, l.indexMap.Len())
+		require.Equal(t, 30, l.items.Len())
+		require.Equal(t, 30, l.renderedItems.Len())
+		assert.Equal(t, 30, lipgloss.Height(l.rendered))
+		assert.NotEqual(t, "\n", string(l.rendered[len(l.rendered)-1]), "should not end in newline")
+		start, end := l.viewPosition()
+		assert.Equal(t, 0, start)
+		assert.Equal(t, 9, end)
+		for i := range 30 {
+			item, ok := l.renderedItems.Get(items[i].ID())
+			require.True(t, ok)
+			assert.Equal(t, i, item.start)
+			assert.Equal(t, i, item.end)
+		}
+
+		golden.RequireEqual(t, []byte(l.View()))
+	})
+	t.Run("should have correct positions in list that does not fits the items backwards", func(t *testing.T) {
+		t.Parallel()
+		items := []Item{}
+		for i := range 30 {
+			item := NewSelectableItem(fmt.Sprintf("Item %d", i))
+			items = append(items, item)
+		}
+		l := New(items, WithDirectionBackward(), WithSize(10, 10)).(*list[Item])
+		execCmd(l, l.Init())
+
+		// should select the last item
+		assert.Equal(t, items[29].ID(), l.selectedItem)
+		assert.Equal(t, 0, l.offset)
+		require.Equal(t, 30, l.indexMap.Len())
+		require.Equal(t, 30, l.items.Len())
+		require.Equal(t, 30, l.renderedItems.Len())
+		assert.Equal(t, 30, lipgloss.Height(l.rendered))
+		assert.NotEqual(t, "\n", string(l.rendered[len(l.rendered)-1]), "should not end in newline")
+		start, end := l.viewPosition()
+		assert.Equal(t, 20, start)
+		assert.Equal(t, 29, end)
+		for i := range 30 {
+			item, ok := l.renderedItems.Get(items[i].ID())
+			require.True(t, ok)
+			assert.Equal(t, i, item.start)
+			assert.Equal(t, i, item.end)
+		}
+
+		golden.RequireEqual(t, []byte(l.View()))
+	})
+
+	t.Run("should have correct positions in list that does not fits the items and has multi line items", func(t *testing.T) {
+		t.Parallel()
+		items := []Item{}
+		for i := range 30 {
+			content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1)
+			content = strings.TrimSuffix(content, "\n")
+			item := NewSelectableItem(content)
+			items = append(items, item)
+		}
+		l := New(items, WithDirectionForward(), WithSize(10, 10)).(*list[Item])
+		execCmd(l, l.Init())
+
+		// should select the last item
+		assert.Equal(t, items[0].ID(), l.selectedItem)
+		assert.Equal(t, 0, l.offset)
+		require.Equal(t, 30, l.indexMap.Len())
+		require.Equal(t, 30, l.items.Len())
+		require.Equal(t, 30, l.renderedItems.Len())
+		expectedLines := 0
+		for i := range 30 {
+			expectedLines += (i + 1) * 1
+		}
+		assert.Equal(t, expectedLines, lipgloss.Height(l.rendered))
+		assert.NotEqual(t, "\n", string(l.rendered[len(l.rendered)-1]), "should not end in newline")
+		start, end := l.viewPosition()
+		assert.Equal(t, 0, start)
+		assert.Equal(t, 9, end)
+		currentPosition := 0
+		for i := range 30 {
+			rItem, ok := l.renderedItems.Get(items[i].ID())
+			require.True(t, ok)
+			assert.Equal(t, currentPosition, rItem.start)
+			assert.Equal(t, currentPosition+i, rItem.end)
+			currentPosition += i + 1
+		}
+
+		golden.RequireEqual(t, []byte(l.View()))
+	})
+	t.Run("should have correct positions in list that does not fits the items and has multi line items backwards", func(t *testing.T) {
+		t.Parallel()
+		items := []Item{}
+		for i := range 30 {
+			content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1)
+			content = strings.TrimSuffix(content, "\n")
+			item := NewSelectableItem(content)
+			items = append(items, item)
+		}
+		l := New(items, WithDirectionBackward(), WithSize(10, 10)).(*list[Item])
+		execCmd(l, l.Init())
+
+		// should select the last item
+		assert.Equal(t, items[29].ID(), l.selectedItem)
+		assert.Equal(t, 0, l.offset)
+		require.Equal(t, 30, l.indexMap.Len())
+		require.Equal(t, 30, l.items.Len())
+		require.Equal(t, 30, l.renderedItems.Len())
+		expectedLines := 0
+		for i := range 30 {
+			expectedLines += (i + 1) * 1
+		}
+		assert.Equal(t, expectedLines, lipgloss.Height(l.rendered))
+		assert.NotEqual(t, "\n", string(l.rendered[len(l.rendered)-1]), "should not end in newline")
+		start, end := l.viewPosition()
+		assert.Equal(t, expectedLines-10, start)
+		assert.Equal(t, expectedLines-1, end)
+		currentPosition := 0
+		for i := range 30 {
+			rItem, ok := l.renderedItems.Get(items[i].ID())
+			require.True(t, ok)
+			assert.Equal(t, currentPosition, rItem.start)
+			assert.Equal(t, currentPosition+i, rItem.end)
+			currentPosition += i + 1
+		}
+
+		golden.RequireEqual(t, []byte(l.View()))
+	})
+
+	t.Run("should go to selected item at the beginning", func(t *testing.T) {
+		t.Parallel()
+		items := []Item{}
+		for i := range 30 {
+			content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1)
+			content = strings.TrimSuffix(content, "\n")
+			item := NewSelectableItem(content)
+			items = append(items, item)
+		}
+		l := New(items, WithDirectionForward(), WithSize(10, 10), WithSelectedItem(items[10].ID())).(*list[Item])
+		execCmd(l, l.Init())
+
+		// should select the last item
+		assert.Equal(t, items[10].ID(), l.selectedItem)
+
+		golden.RequireEqual(t, []byte(l.View()))
+	})
+
+	t.Run("should go to selected item at the beginning backwards", func(t *testing.T) {
+		t.Parallel()
+		items := []Item{}
+		for i := range 30 {
+			content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1)
+			content = strings.TrimSuffix(content, "\n")
+			item := NewSelectableItem(content)
+			items = append(items, item)
+		}
+		l := New(items, WithDirectionBackward(), WithSize(10, 10), WithSelectedItem(items[10].ID())).(*list[Item])
+		execCmd(l, l.Init())
+
+		// should select the last item
+		assert.Equal(t, items[10].ID(), l.selectedItem)
+
+		golden.RequireEqual(t, []byte(l.View()))
+	})
+}
+
+func TestListMovement(t *testing.T) {
+	t.Parallel()
+	t.Run("should move viewport up", func(t *testing.T) {
+		t.Parallel()
+		items := []Item{}
+		for i := range 30 {
+			content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1)
+			content = strings.TrimSuffix(content, "\n")
+			item := NewSelectableItem(content)
+			items = append(items, item)
+		}
+		l := New(items, WithDirectionBackward(), WithSize(10, 10)).(*list[Item])
+		execCmd(l, l.Init())
+
+		execCmd(l, l.MoveUp(25))
+
+		assert.Equal(t, 25, l.offset)
+		golden.RequireEqual(t, []byte(l.View()))
+	})
+	t.Run("should move viewport up and down", func(t *testing.T) {
+		t.Parallel()
+		items := []Item{}
+		for i := range 30 {
+			content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1)
+			content = strings.TrimSuffix(content, "\n")
+			item := NewSelectableItem(content)
+			items = append(items, item)
+		}
+		l := New(items, WithDirectionBackward(), WithSize(10, 10)).(*list[Item])
+		execCmd(l, l.Init())
+
+		execCmd(l, l.MoveUp(25))
+		execCmd(l, l.MoveDown(25))
+
+		assert.Equal(t, 0, l.offset)
+		golden.RequireEqual(t, []byte(l.View()))
+	})
+
+	t.Run("should move viewport down", func(t *testing.T) {
+		t.Parallel()
+		items := []Item{}
+		for i := range 30 {
+			content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1)
+			content = strings.TrimSuffix(content, "\n")
+			item := NewSelectableItem(content)
+			items = append(items, item)
+		}
+		l := New(items, WithDirectionForward(), WithSize(10, 10)).(*list[Item])
+		execCmd(l, l.Init())
+
+		execCmd(l, l.MoveDown(25))
+
+		assert.Equal(t, 25, l.offset)
+		golden.RequireEqual(t, []byte(l.View()))
+	})
+	t.Run("should move viewport down and up", func(t *testing.T) {
+		t.Parallel()
+		items := []Item{}
+		for i := range 30 {
+			content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1)
+			content = strings.TrimSuffix(content, "\n")
+			item := NewSelectableItem(content)
+			items = append(items, item)
+		}
+		l := New(items, WithDirectionForward(), WithSize(10, 10)).(*list[Item])
+		execCmd(l, l.Init())
+
+		execCmd(l, l.MoveDown(25))
+		execCmd(l, l.MoveUp(25))
+
+		assert.Equal(t, 0, l.offset)
+		golden.RequireEqual(t, []byte(l.View()))
+	})
+
+	t.Run("should not change offset when new items are appended and we are at the bottom in backwards list", func(t *testing.T) {
+		t.Parallel()
+		items := []Item{}
+		for i := range 30 {
+			content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1)
+			content = strings.TrimSuffix(content, "\n")
+			item := NewSelectableItem(content)
+			items = append(items, item)
+		}
+		l := New(items, WithDirectionBackward(), WithSize(10, 10)).(*list[Item])
+		execCmd(l, l.Init())
+		execCmd(l, l.AppendItem(NewSelectableItem("Testing")))
+
+		assert.Equal(t, 0, l.offset)
+		golden.RequireEqual(t, []byte(l.View()))
+	})
+
+	t.Run("should stay at the position it is when new items are added but we moved up in backwards list", func(t *testing.T) {
+		t.Parallel()
+		items := []Item{}
+		for i := range 30 {
+			item := NewSelectableItem(fmt.Sprintf("Item %d", i))
+			items = append(items, item)
+		}
+		l := New(items, WithDirectionBackward(), WithSize(10, 10)).(*list[Item])
+		execCmd(l, l.Init())
+
+		execCmd(l, l.MoveUp(2))
+		viewBefore := l.View()
+		execCmd(l, l.AppendItem(NewSelectableItem("Testing\nHello\n")))
+		viewAfter := l.View()
+		assert.Equal(t, viewBefore, viewAfter)
+		assert.Equal(t, 5, l.offset)
+		assert.Equal(t, 33, lipgloss.Height(l.rendered))
+		golden.RequireEqual(t, []byte(l.View()))
+	})
+	t.Run("should stay at the position it is when the hight of an item below is increased in backwards list", func(t *testing.T) {
+		t.Parallel()
+		items := []Item{}
+		for i := range 30 {
+			item := NewSelectableItem(fmt.Sprintf("Item %d", i))
+			items = append(items, item)
+		}
+		l := New(items, WithDirectionBackward(), WithSize(10, 10)).(*list[Item])
+		execCmd(l, l.Init())
+
+		execCmd(l, l.MoveUp(2))
+		viewBefore := l.View()
+		item := items[29]
+		execCmd(l, l.UpdateItem(item.ID(), NewSelectableItem("Item 29\nLine 2\nLine 3")))
+		viewAfter := l.View()
+		assert.Equal(t, viewBefore, viewAfter)
+		assert.Equal(t, 4, l.offset)
+		assert.Equal(t, 32, lipgloss.Height(l.rendered))
+		golden.RequireEqual(t, []byte(l.View()))
+	})
+	t.Run("should stay at the position it is when the hight of an item below is decreases in backwards list", func(t *testing.T) {
+		t.Parallel()
+		items := []Item{}
+		for i := range 30 {
+			item := NewSelectableItem(fmt.Sprintf("Item %d", i))
+			items = append(items, item)
+		}
+		items = append(items, NewSelectableItem("Item 30\nLine 2\nLine 3"))
+		l := New(items, WithDirectionBackward(), WithSize(10, 10)).(*list[Item])
+		execCmd(l, l.Init())
+
+		execCmd(l, l.MoveUp(2))
+		viewBefore := l.View()
+		item := items[30]
+		execCmd(l, l.UpdateItem(item.ID(), NewSelectableItem("Item 30")))
+		viewAfter := l.View()
+		assert.Equal(t, viewBefore, viewAfter)
+		assert.Equal(t, 0, l.offset)
+		assert.Equal(t, 31, lipgloss.Height(l.rendered))
+		golden.RequireEqual(t, []byte(l.View()))
+	})
+	t.Run("should stay at the position it is when the hight of an item above is increased in backwards list", func(t *testing.T) {
+		t.Parallel()
+		items := []Item{}
+		for i := range 30 {
+			item := NewSelectableItem(fmt.Sprintf("Item %d", i))
+			items = append(items, item)
+		}
+		l := New(items, WithDirectionBackward(), WithSize(10, 10)).(*list[Item])
+		execCmd(l, l.Init())
+
+		execCmd(l, l.MoveUp(2))
+		viewBefore := l.View()
+		item := items[1]
+		execCmd(l, l.UpdateItem(item.ID(), NewSelectableItem("Item 1\nLine 2\nLine 3")))
+		viewAfter := l.View()
+		assert.Equal(t, viewBefore, viewAfter)
+		assert.Equal(t, 2, l.offset)
+		assert.Equal(t, 32, lipgloss.Height(l.rendered))
+		golden.RequireEqual(t, []byte(l.View()))
+	})
+	t.Run("should stay at the position it is if an item is prepended and we are in backwards list", func(t *testing.T) {
+		t.Parallel()
+		items := []Item{}
+		for i := range 30 {
+			item := NewSelectableItem(fmt.Sprintf("Item %d", i))
+			items = append(items, item)
+		}
+		l := New(items, WithDirectionBackward(), WithSize(10, 10)).(*list[Item])
+		execCmd(l, l.Init())
+
+		execCmd(l, l.MoveUp(2))
+		viewBefore := l.View()
+		execCmd(l, l.PrependItem(NewSelectableItem("New")))
+		viewAfter := l.View()
+		assert.Equal(t, viewBefore, viewAfter)
+		assert.Equal(t, 2, l.offset)
+		assert.Equal(t, 31, lipgloss.Height(l.rendered))
+		golden.RequireEqual(t, []byte(l.View()))
+	})
+
+	t.Run("should not change offset when new items are prepended and we are at the top in forward list", func(t *testing.T) {
+		t.Parallel()
+		items := []Item{}
+		for i := range 30 {
+			content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1)
+			content = strings.TrimSuffix(content, "\n")
+			item := NewSelectableItem(content)
+			items = append(items, item)
+		}
+		l := New(items, WithDirectionForward(), WithSize(10, 10)).(*list[Item])
+		execCmd(l, l.Init())
+		execCmd(l, l.PrependItem(NewSelectableItem("Testing")))
+
+		assert.Equal(t, 0, l.offset)
+		golden.RequireEqual(t, []byte(l.View()))
+	})
+
+	t.Run("should stay at the position it is when new items are added but we moved down in forward list", func(t *testing.T) {
+		t.Parallel()
+		items := []Item{}
+		for i := range 30 {
+			item := NewSelectableItem(fmt.Sprintf("Item %d", i))
+			items = append(items, item)
+		}
+		l := New(items, WithDirectionForward(), WithSize(10, 10)).(*list[Item])
+		execCmd(l, l.Init())
+
+		execCmd(l, l.MoveDown(2))
+		viewBefore := l.View()
+		execCmd(l, l.PrependItem(NewSelectableItem("Testing\nHello\n")))
+		viewAfter := l.View()
+		assert.Equal(t, viewBefore, viewAfter)
+		assert.Equal(t, 5, l.offset)
+		assert.Equal(t, 33, lipgloss.Height(l.rendered))
+		golden.RequireEqual(t, []byte(l.View()))
+	})
+
+	t.Run("should stay at the position it is when the hight of an item above is increased in forward list", func(t *testing.T) {
+		t.Parallel()
+		items := []Item{}
+		for i := range 30 {
+			item := NewSelectableItem(fmt.Sprintf("Item %d", i))
+			items = append(items, item)
+		}
+		l := New(items, WithDirectionForward(), WithSize(10, 10)).(*list[Item])
+		execCmd(l, l.Init())
+
+		execCmd(l, l.MoveDown(2))
+		viewBefore := l.View()
+		item := items[0]
+		execCmd(l, l.UpdateItem(item.ID(), NewSelectableItem("Item 29\nLine 2\nLine 3")))
+		viewAfter := l.View()
+		assert.Equal(t, viewBefore, viewAfter)
+		assert.Equal(t, 4, l.offset)
+		assert.Equal(t, 32, lipgloss.Height(l.rendered))
+		golden.RequireEqual(t, []byte(l.View()))
+	})
+
+	t.Run("should stay at the position it is when the hight of an item above is decreases in forward list", func(t *testing.T) {
+		t.Parallel()
+		items := []Item{}
+		items = append(items, NewSelectableItem("At top\nLine 2\nLine 3"))
+		for i := range 30 {
+			item := NewSelectableItem(fmt.Sprintf("Item %d", i))
+			items = append(items, item)
+		}
+		l := New(items, WithDirectionForward(), WithSize(10, 10)).(*list[Item])
+		execCmd(l, l.Init())
+
+		execCmd(l, l.MoveDown(3))
+		viewBefore := l.View()
+		item := items[0]
+		execCmd(l, l.UpdateItem(item.ID(), NewSelectableItem("At top")))
+		viewAfter := l.View()
+		assert.Equal(t, viewBefore, viewAfter)
+		assert.Equal(t, 1, l.offset)
+		assert.Equal(t, 31, lipgloss.Height(l.rendered))
+		golden.RequireEqual(t, []byte(l.View()))
+	})
+
+	t.Run("should stay at the position it is when the hight of an item below is increased in forward list", func(t *testing.T) {
+		t.Parallel()
+		items := []Item{}
+		for i := range 30 {
+			item := NewSelectableItem(fmt.Sprintf("Item %d", i))
+			items = append(items, item)
+		}
+		l := New(items, WithDirectionForward(), WithSize(10, 10)).(*list[Item])
+		execCmd(l, l.Init())
+
+		execCmd(l, l.MoveDown(2))
+		viewBefore := l.View()
+		item := items[29]
+		execCmd(l, l.UpdateItem(item.ID(), NewSelectableItem("Item 29\nLine 2\nLine 3")))
+		viewAfter := l.View()
+		assert.Equal(t, viewBefore, viewAfter)
+		assert.Equal(t, 2, l.offset)
+		assert.Equal(t, 32, lipgloss.Height(l.rendered))
+		golden.RequireEqual(t, []byte(l.View()))
+	})
+	t.Run("should stay at the position it is if an item is appended and we are in forward list", func(t *testing.T) {
+		t.Parallel()
+		items := []Item{}
+		for i := range 30 {
+			item := NewSelectableItem(fmt.Sprintf("Item %d", i))
+			items = append(items, item)
+		}
+		l := New(items, WithDirectionForward(), WithSize(10, 10)).(*list[Item])
+		execCmd(l, l.Init())
+
+		execCmd(l, l.MoveDown(2))
+		viewBefore := l.View()
+		execCmd(l, l.AppendItem(NewSelectableItem("New")))
+		viewAfter := l.View()
+		assert.Equal(t, viewBefore, viewAfter)
+		assert.Equal(t, 2, l.offset)
+		assert.Equal(t, 31, lipgloss.Height(l.rendered))
+		golden.RequireEqual(t, []byte(l.View()))
+	})
+}
+
+type SelectableItem interface {
+	Item
+	layout.Focusable
+}
+
+type simpleItem struct {
+	width   int
+	content string
+	id      string
+}
+type selectableItem struct {
+	*simpleItem
+	focused bool
+}
+
+func NewSimpleItem(content string) *simpleItem {
+	return &simpleItem{
+		id:      uuid.NewString(),
+		width:   0,
+		content: content,
+	}
+}
+
+func NewSelectableItem(content string) SelectableItem {
+	return &selectableItem{
+		simpleItem: NewSimpleItem(content),
+		focused:    false,
+	}
+}
+
+func (s *simpleItem) ID() string {
+	return s.id
+}
+
+func (s *simpleItem) Init() tea.Cmd {
+	return nil
+}
+
+func (s *simpleItem) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+	return s, nil
+}
+
+func (s *simpleItem) View() string {
+	return lipgloss.NewStyle().Width(s.width).Render(s.content)
+}
+
+func (l *simpleItem) GetSize() (int, int) {
+	return l.width, 0
+}
+
+// SetSize implements Item.
+func (s *simpleItem) SetSize(width int, height int) tea.Cmd {
+	s.width = width
+	return nil
+}
+
+func (s *selectableItem) View() string {
+	if s.focused {
+		return lipgloss.NewStyle().BorderLeft(true).BorderStyle(lipgloss.NormalBorder()).Width(s.width).Render(s.content)
+	}
+	return lipgloss.NewStyle().Width(s.width).Render(s.content)
+}
+
+// Blur implements SimpleItem.
+func (s *selectableItem) Blur() tea.Cmd {
+	s.focused = false
+	return nil
+}
+
+// Focus implements SimpleItem.
+func (s *selectableItem) Focus() tea.Cmd {
+	s.focused = true
+	return nil
+}
+
+// IsFocused implements SimpleItem.
+func (s *selectableItem) IsFocused() bool {
+	return s.focused
+}
+
+func execCmd(m tea.Model, cmd tea.Cmd) {
+	for cmd != nil {
+		msg := cmd()
+		m, cmd = m.Update(msg)
+	}
+}

internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_does_not_fits_the_items_and_has_multi_line_items_backwards.golden 🔗

@@ -0,0 +1,10 @@
+│Item 29  
+│Item 29  
+│Item 29  
+│Item 29  
+│Item 29  
+│Item 29  
+│Item 29  
+│Item 29  
+│Item 29  
+│Item 29  

internal/tui/exp/list/testdata/TestListMovement/should_move_viewport_down.golden 🔗

@@ -0,0 +1,10 @@
+Item 6    
+Item 6    
+Item 6    
+│Item 7   
+│Item 7   
+│Item 7   
+│Item 7   
+│Item 7   
+│Item 7   
+│Item 7   

internal/tui/exp/list/testdata/TestListMovement/should_move_viewport_up.golden 🔗

@@ -0,0 +1,10 @@
+│Item 28  
+│Item 28  
+│Item 28  
+│Item 28  
+│Item 28  
+Item 29   
+Item 29   
+Item 29   
+Item 29   
+Item 29   

internal/tui/exp/list/testdata/TestListMovement/should_not_change_offset_when_new_items_are_appended_and_we_are_at_the_bottom_in_backwards_list.golden 🔗

@@ -0,0 +1,10 @@
+│Item 29  
+│Item 29  
+│Item 29  
+│Item 29  
+│Item 29  
+│Item 29  
+│Item 29  
+│Item 29  
+│Item 29  
+Testing   

internal/tui/page/chat/chat.go 🔗

@@ -165,6 +165,13 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	case tea.KeyboardEnhancementsMsg:
 		p.keyboardEnhancements = msg
 		return p, nil
+	case tea.MouseWheelMsg:
+		if p.isMouseOverChat(msg.Mouse().X, msg.Mouse().Y) {
+			u, cmd := p.chat.Update(msg)
+			p.chat = u.(chat.MessageListCmp)
+			return p, cmd
+		}
+		return p, nil
 	case tea.WindowSizeMsg:
 		return p, p.SetSize(msg.Width, msg.Height)
 	case CancelTimerExpiredMsg:
@@ -610,6 +617,7 @@ func (p *chatPage) sendMessage(text string, attachments []message.Attachment) te
 	if err != nil {
 		return util.ReportError(err)
 	}
+	cmds = append(cmds, p.chat.GoToBottom())
 	return tea.Batch(cmds...)
 }
 
@@ -911,3 +919,31 @@ func (p *chatPage) Help() help.KeyMap {
 func (p *chatPage) IsChatFocused() bool {
 	return p.focusedPane == PanelTypeChat
 }
+
+// isMouseOverChat checks if the given mouse coordinates are within the chat area bounds.
+// Returns true if the mouse is over the chat area, false otherwise.
+func (p *chatPage) isMouseOverChat(x, y int) bool {
+	// No session means no chat area
+	if p.session.ID == "" {
+		return false
+	}
+
+	var chatX, chatY, chatWidth, chatHeight int
+
+	if p.compact {
+		// In compact mode: chat area starts after header and spans full width
+		chatX = 0
+		chatY = HeaderHeight
+		chatWidth = p.width
+		chatHeight = p.height - EditorHeight - HeaderHeight
+	} else {
+		// In non-compact mode: chat area spans from left edge to sidebar
+		chatX = 0
+		chatY = 0
+		chatWidth = p.width - SideBarWidth
+		chatHeight = p.height - EditorHeight
+	}
+
+	// Check if mouse coordinates are within chat bounds
+	return x >= chatX && x < chatX+chatWidth && y >= chatY && y < chatY+chatHeight
+}

internal/tui/tui.go 🔗

@@ -4,6 +4,7 @@ import (
 	"context"
 	"fmt"
 	"strings"
+	"time"
 
 	"github.com/charmbracelet/bubbles/v2/key"
 	tea "github.com/charmbracelet/bubbletea/v2"
@@ -33,26 +34,18 @@ import (
 	"github.com/charmbracelet/lipgloss/v2"
 )
 
-// MouseEventFilter filters mouse events based on the current focus state
-// This is used with tea.WithFilter to prevent mouse scroll events from
-// interfering with typing performance in the editor
+var lastMouseEvent time.Time
+
 func MouseEventFilter(m tea.Model, msg tea.Msg) tea.Msg {
-	// Only filter mouse events
 	switch msg.(type) {
 	case tea.MouseWheelMsg, tea.MouseMotionMsg:
-		// Check if we have an appModel and if editor is focused
-		if appModel, ok := m.(*appModel); ok {
-			if appModel.currentPage == chat.ChatPageID {
-				if chatPage, ok := appModel.pages[appModel.currentPage].(chat.ChatPage); ok {
-					// If editor is focused (not chatFocused), filter out mouse wheel/motion events
-					if !chatPage.IsChatFocused() {
-						return nil // Filter out the event
-					}
-				}
-			}
+		now := time.Now()
+		// trackpad is sending too many requests
+		if now.Sub(lastMouseEvent) < 5*time.Millisecond {
+			return nil
 		}
+		lastMouseEvent = now
 	}
-	// Allow all other events to pass through
 	return msg
 }