on main help sign in
Merge pull request #262 from charmbracelet/list

New chat/sessions/commands list component

commitd93ecf54029f4be3ec2295e1617afed233bb00a0 authorKujtim Hoxha <kujtimii.h@gmail.com> dateMon 28 Jul 2025, 21:48 (1 year ago) parent4b273e91d211e0
55 files changed+3242−1957
cspell.json+1−108 go.mod+1−1 go.sum+2−2 internal/csync/doc.go+3 internal/csync/slices.go−16 internal/csync/slices_test.go+7−19 internal/format/spinner.go+2−2 internal/tui/components/anim/anim.go+161−93 internal/tui/components/anim/example/main.go+1−1 internal/tui/components/chat/chat.go+36−22 internal/tui/components/chat/messages/messages.go+17−5 internal/tui/components/chat/messages/tool.go+6−1 internal/tui/components/chat/splash/splash.go+13−12 internal/tui/components/completions/completions.go+38−36 internal/tui/components/core/list/list.go−1371 internal/tui/components/dialogs/commands/commands.go+38−29 internal/tui/components/dialogs/commands/item.go−69 internal/tui/components/dialogs/models/list.go+72−31 internal/tui/components/dialogs/models/models.go+7−21 internal/tui/components/dialogs/sessions/sessions.go+29−34 internal/tui/exp/list/filterable.go+308 internal/tui/exp/list/filterable_group.go+261 internal/tui/exp/list/filterable_test.go+68 internal/tui/exp/list/grouped.go+103 internal/tui/components/completions/item.go → internal/tui/exp/list/items.go+140−32 internal/tui/components/core/list/keys.go → internal/tui/exp/list/keys.go+2−2 internal/tui/exp/list/list.go+960−35 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+8−15
cspell.json+1−108
@@ -1,108 +1 @@
1−{
2− "words": [
3− "afero",
4− "agentic",
5− "alecthomas",
6− "anthropics",
7− "aymanbagabas",
8− "azidentity",
9− "bmatcuk",
10− "bubbletea",
11− "charlievieth",
12− "charmbracelet",
13− "charmtone",
14− "Charple",
15− "chkconfig",
16− "crush",
17− "curlie",
18− "cursorrules",
19− "diffview",
20− "doas",
21− "Dockerfiles",
22− "doublestar",
23− "dpkg",
24− "Emph",
25− "fastwalk",
26− "fdisk",
27− "filepicker",
28− "Focusable",
29− "fseventsd",
30− "fsext",
31− "genai",
32− "goquery",
33− "GROQ",
34− "Guac",
35− "imageorient",
36− "Inex",
37− "jetta",
38− "jsons",
39− "jsonschema",
40− "jspm",
41− "Kaufmann",
42− "killall",
43− "Lanczos",
44− "lipgloss",
45− "LOCALAPPDATA",
46− "lsps",
47− "lucasb",
48− "makepkg",
49− "mcps",
50− "MSYS",
51− "mvdan",
52− "natefinch",
53− "nfnt",
54− "noctx",
55− "nohup",
56− "nolint",
57− "nslookup",
58− "oksvg",
59− "Oneshot",
60− "openrouter",
61− "opkg",
62− "pacman",
63− "paru",
64− "pfctl",
65− "postamble",
66− "postambles",
67− "preconfigured",
68− "Preproc",
69− "Proactiveness",
70− "Puerkito",
71− "pycache",
72− "pytest",
73− "qjebbs",
74− "rasterx",
75− "rivo",
76− "sabhiram",
77− "sess",
78− "shortlog",
79− "sjson",
80− "Sourcegraph",
81− "srwiley",
82− "SSEMCP",
83− "Streamable",
84− "stretchr",
85− "Strikethrough",
86− "substrs",
87− "Suscriber",
88− "systeminfo",
89− "tasklist",
90− "termenv",
91− "textinput",
92− "tidwall",
93− "timedout",
94− "trashhalo",
95− "udiff",
96− "uniseg",
97− "Unticked",
98− "urllib",
99− "USERPROFILE",
100− "VERTEXAI",
101− "webp",
102− "whatis",
103− "whereis"
104− ],
105− "flagWords": [],
106− "language": "en",
107− "version": "0.2"
108−}
1+{"flagWords":[],"version":"0.2","language":"en","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","sahilm","csync"]}
go.mod+1−1
@@ -12,7 +12,7 @@ require (
1212 github.com/bmatcuk/doublestar/v4 v4.9.0
1313 github.com/charlievieth/fastwalk v1.0.11
1414 github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250716191546-1e2ffbbcf5c5
15− github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4.0.20250717140350-bb75e8f6b6ac
15+ github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4.0.20250724172607-5ba56e2bec69
1616 github.com/charmbracelet/catwalk v0.3.1
1717 github.com/charmbracelet/fang v0.3.1-0.20250711140230-d5ebb8c1d674
1818 github.com/charmbracelet/glamour/v2 v2.0.0-20250516160903-6f1e2c8f9ebe
go.sum+2−2
@@ -70,8 +70,8 @@ github.com/charlievieth/fastwalk v1.0.11 h1:5sLT/q9+d9xMdpKExawLppqvXFZCVKf6JHnr
7070github.com/charlievieth/fastwalk v1.0.11/go.mod h1:yGy1zbxog41ZVMcKA/i8ojXLFsuayX5VvwhQVoj9PBI=
7171github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250716191546-1e2ffbbcf5c5 h1:GTcMIfDQJKyNKS+xVt7GkNIwz+tBuQtIuiP50WpzNgs=
7272github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250716191546-1e2ffbbcf5c5/go.mod h1:6HamsBKWqEC/FVHuQMHgQL+knPyvHH55HwJDHl/adMw=
73−github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4.0.20250717140350-bb75e8f6b6ac h1:murtkvFYxZ/73vk4Z/tpE4biB+WDZcFmmBp8je/yV6M=
74−github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4.0.20250717140350-bb75e8f6b6ac/go.mod h1:m240IQxo1/eDQ7klblSzOCAUyc3LddHcV3Rc/YEGAgw=
73+github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4.0.20250724172607-5ba56e2bec69 h1:nXLMl4ows2qogDXhuEtDNgFNXQiU+PJer+UEBsQZuns=
74+github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4.0.20250724172607-5ba56e2bec69/go.mod h1:XIQ1qQfRph6Z5o2EikCydjumo0oDInQySRHuPATzbZc=
7575github.com/charmbracelet/catwalk v0.3.1 h1:MkGWspcMyE659zDkqS+9wsaCMTKRFEDBFY2A2sap6+U=
7676github.com/charmbracelet/catwalk v0.3.1/go.mod h1:gUUCqqZ8bk4D7ZzGTu3I77k7cC2x4exRuJBN1H2u2pc=
7777github.com/charmbracelet/colorprofile v0.3.1 h1:k8dTHMd7fgw4bnFd7jXTLZrSU/CQrKnL3m+AxCzDz40=
internal/csync/doc.gonew file+3
@@ -0,0 +1,3 @@
1+// Package csync provides concurrent data structures for safe access in
2+// multi-threaded environments.
3+package csync
internal/csync/slices.go−16
@@ -112,15 +112,6 @@ func (s *Slice[T]) Len() int {
112112 return len(s.inner)
113113}
114114
115−// Slice returns a copy of the underlying slice.
116−func (s *Slice[T]) Slice() []T {
117− s.mu.RLock()
118− defer s.mu.RUnlock()
119− result := make([]T, len(s.inner))
120− copy(result, s.inner)
121− return result
122−}
123−
124115// SetSlice replaces the entire slice with a new one.
125116func (s *Slice[T]) SetSlice(items []T) {
126117 s.mu.Lock()
@@ -129,13 +120,6 @@ func (s *Slice[T]) SetSlice(items []T) {
129120 copy(s.inner, items)
130121}
131122
132−// Clear removes all elements from the slice.
133−func (s *Slice[T]) Clear() {
134− s.mu.Lock()
135− defer s.mu.Unlock()
136− s.inner = s.inner[:0]
137−}
138−
139123// Seq returns an iterator that yields elements from the slice.
140124func (s *Slice[T]) Seq() iter.Seq[T] {
141125 return func(yield func(T) bool) {
internal/csync/slices_test.go+7−19
@@ -1,6 +1,7 @@
11package csync
22
33import (
4+ "slices"
45 "sync"
56 "sync/atomic"
67 "testing"
@@ -144,7 +145,7 @@ func TestSlice(t *testing.T) {
144145 require.Equal(t, 4, s.Len())
145146
146147 expected := []int{1, 2, 4, 5}
147− actual := s.Slice()
148+ actual := slices.Collect(s.Seq())
148149 require.Equal(t, expected, actual)
149150
150151 // Delete out of bounds
@@ -202,7 +203,7 @@ func TestSlice(t *testing.T) {
202203 s.SetSlice(newItems)
203204
204205 require.Equal(t, 3, s.Len())
205− require.Equal(t, newItems, s.Slice())
206+ require.Equal(t, newItems, slices.Collect(s.Seq()))
206207
207208 // Verify it's a copy
208209 newItems[0] = 999
@@ -211,23 +212,15 @@ func TestSlice(t *testing.T) {
211212 require.Equal(t, 10, val)
212213 })
213214
214− t.Run("Clear", func(t *testing.T) {
215− s := NewSliceFrom([]int{1, 2, 3})
216− require.Equal(t, 3, s.Len())
217−
218− s.Clear()
219− require.Equal(t, 0, s.Len())
220− })
221−
222215 t.Run("Slice", func(t *testing.T) {
223216 original := []int{1, 2, 3}
224217 s := NewSliceFrom(original)
225218
226− copy := s.Slice()
227− require.Equal(t, original, copy)
219+ copied := slices.Collect(s.Seq())
220+ require.Equal(t, original, copied)
228221
229222 // Verify it's a copy
230− copy[0] = 999
223+ copied[0] = 999
231224 val, ok := s.Get(0)
232225 require.True(t, ok)
233226 require.Equal(t, 1, val)
@@ -267,18 +260,13 @@ func TestSlice(t *testing.T) {
267260
268261 // Concurrent appends
269262 for i := range numGoroutines {
270− wg.Add(1)
263+ wg.Add(2)
271264 go func(start int) {
272265 defer wg.Done()
273266 for j := range itemsPerGoroutine {
274267 s.Append(start*itemsPerGoroutine + j)
275268 }
276269 }(i)
277− }
278−
279− // Concurrent reads
280− for range numGoroutines {
281− wg.Add(1)
282270 go func() {
283271 defer wg.Done()
284272 for range itemsPerGoroutine {
internal/format/spinner.go+2−2
@@ -20,7 +20,7 @@ type Spinner struct {
2020
2121type model struct {
2222 cancel context.CancelFunc
23− anim anim.Anim
23+ anim *anim.Anim
2424}
2525
2626func (m model) Init() tea.Cmd { return m.anim.Init() }
@@ -37,7 +37,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
3737 }
3838 }
3939 mm, cmd := m.anim.Update(msg)
40− m.anim = mm.(anim.Anim)
40+ m.anim = mm.(*anim.Anim)
4141 return m, cmd
4242}
4343
internal/tui/components/anim/anim.go+161−93
@@ -2,15 +2,20 @@
22package anim
33
44import (
5+ "fmt"
56 "image/color"
67 "math/rand/v2"
78 "strings"
89 "sync/atomic"
910 "time"
1011
12+ "github.com/zeebo/xxh3"
13+
1114 tea "github.com/charmbracelet/bubbletea/v2"
1215 "github.com/charmbracelet/lipgloss/v2"
1316 "github.com/lucasb-eyer/go-colorful"
17+
18+ "github.com/charmbracelet/crush/internal/csync"
1419)
1520
1621const (
@@ -58,6 +63,26 @@ func nextID() int {
5863 return int(atomic.AddInt64(&lastID, 1))
5964}
6065
66+// Cache for expensive animation calculations
67+type animCache struct {
68+ initialFrames [][]string
69+ cyclingFrames [][]string
70+ width int
71+ labelWidth int
72+ label []string
73+ ellipsisFrames []string
74+}
75+
76+var animCacheMap = csync.NewMap[string, *animCache]()
77+
78+// settingsHash creates a hash key for the settings to use for caching
79+func settingsHash(opts Settings) string {
80+ h := xxh3.New()
81+ fmt.Fprintf(h, "%d-%s-%v-%v-%v-%t",
82+ opts.Size, opts.Label, opts.LabelColor, opts.GradColorA, opts.GradColorB, opts.CycleColors)
83+ return fmt.Sprintf("%x", h.Sum(nil))
84+}
85+
6186// StepMsg is a message type used to trigger the next step in the animation.
6287type StepMsg struct{ id int }
6388
@@ -78,22 +103,23 @@ const ()
78103type Anim struct {
79104 width int
80105 cyclingCharWidth int
81− label []string
106+ label *csync.Slice[string]
82107 labelWidth int
83108 labelColor color.Color
84109 startTime time.Time
85110 birthOffsets []time.Duration
86111 initialFrames [][]string // frames for the initial characters
87− initialized bool
88− cyclingFrames [][]string // frames for the cycling characters
89− step int // current main frame step
90− ellipsisStep int // current ellipsis frame step
91− ellipsisFrames []string // ellipsis animation frames
112+ initialized atomic.Bool
113+ cyclingFrames [][]string // frames for the cycling characters
114+ step atomic.Int64 // current main frame step
115+ ellipsisStep atomic.Int64 // current ellipsis frame step
116+ ellipsisFrames *csync.Slice[string] // ellipsis animation frames
92117 id int
93118}
94119
95120// New creates a new Anim instance with the specified width and label.
96−func New(opts Settings) (a Anim) {
121+func New(opts Settings) *Anim {
122+ a := &Anim{}
97123 // Validate settings.
98124 if opts.Size < 1 {
99125 opts.Size = defaultNumCyclingChars
@@ -109,79 +135,113 @@ func New(opts Settings) (a Anim) {
109135 }
110136
111137 a.id = nextID()
112−
113138 a.startTime = time.Now()
114139 a.cyclingCharWidth = opts.Size
115− a.labelWidth = lipgloss.Width(opts.Label)
116140 a.labelColor = opts.LabelColor
117141
118− // Total width of anim, in cells.
119− a.width = opts.Size
120− if opts.Label != "" {
121− a.width += labelGapWidth + lipgloss.Width(opts.Label)
122− }
123−
124− // Render the label
125− a.renderLabel(opts.Label)
126−
127− // Pre-generate gradient.
128− var ramp []color.Color
129− numFrames := prerenderedFrames
130− if opts.CycleColors {
131− ramp = makeGradientRamp(a.width*3, opts.GradColorA, opts.GradColorB, opts.GradColorA, opts.GradColorB)
132− numFrames = a.width * 2
142+ // Check cache first
143+ cacheKey := settingsHash(opts)
144+ cached, exists := animCacheMap.Get(cacheKey)
145+
146+ if exists {
147+ // Use cached values
148+ a.width = cached.width
149+ a.labelWidth = cached.labelWidth
150+ a.label = csync.NewSliceFrom(cached.label)
151+ a.ellipsisFrames = csync.NewSliceFrom(cached.ellipsisFrames)
152+ a.initialFrames = cached.initialFrames
153+ a.cyclingFrames = cached.cyclingFrames
133154 } else {
134− ramp = makeGradientRamp(a.width, opts.GradColorA, opts.GradColorB)
135− }
155+ // Generate new values and cache them
156+ a.labelWidth = lipgloss.Width(opts.Label)
136157
137− // Pre-render initial characters.
138− a.initialFrames = make([][]string, numFrames)
139− offset := 0
140− for i := range a.initialFrames {
141− a.initialFrames[i] = make([]string, a.width+labelGapWidth+a.labelWidth)
142− for j := range a.initialFrames[i] {
143− if j+offset >= len(ramp) {
144− continue // skip if we run out of colors
145− }
158+ // Total width of anim, in cells.
159+ a.width = opts.Size
160+ if opts.Label != "" {
161+ a.width += labelGapWidth + lipgloss.Width(opts.Label)
162+ }
146163
147− var c color.Color
148− if j <= a.cyclingCharWidth {
149− c = ramp[j+offset]
150− } else {
151− c = opts.LabelColor
152− }
164+ // Render the label
165+ a.renderLabel(opts.Label)
153166
154− // Also prerender the initial character with Lip Gloss to avoid
155− // processing in the render loop.
156− a.initialFrames[i][j] = lipgloss.NewStyle().
157− Foreground(c).
158− Render(string(initialChar))
159− }
167+ // Pre-generate gradient.
168+ var ramp []color.Color
169+ numFrames := prerenderedFrames
160170 if opts.CycleColors {
161− offset++
171+ ramp = makeGradientRamp(a.width*3, opts.GradColorA, opts.GradColorB, opts.GradColorA, opts.GradColorB)
172+ numFrames = a.width * 2
173+ } else {
174+ ramp = makeGradientRamp(a.width, opts.GradColorA, opts.GradColorB)
162175 }
163− }
164176
165− // Prerender scrambled rune frames for the animation.
166− a.cyclingFrames = make([][]string, numFrames)
167− offset = 0
168− for i := range a.cyclingFrames {
169− a.cyclingFrames[i] = make([]string, a.width)
170− for j := range a.cyclingFrames[i] {
171− if j+offset >= len(ramp) {
172− continue // skip if we run out of colors
177+ // Pre-render initial characters.
178+ a.initialFrames = make([][]string, numFrames)
179+ offset := 0
180+ for i := range a.initialFrames {
181+ a.initialFrames[i] = make([]string, a.width+labelGapWidth+a.labelWidth)
182+ for j := range a.initialFrames[i] {
183+ if j+offset >= len(ramp) {
184+ continue // skip if we run out of colors
185+ }
186+
187+ var c color.Color
188+ if j <= a.cyclingCharWidth {
189+ c = ramp[j+offset]
190+ } else {
191+ c = opts.LabelColor
192+ }
193+
194+ // Also prerender the initial character with Lip Gloss to avoid
195+ // processing in the render loop.
196+ a.initialFrames[i][j] = lipgloss.NewStyle().
197+ Foreground(c).
198+ Render(string(initialChar))
199+ }
200+ if opts.CycleColors {
201+ offset++
173202 }
203+ }
174204
175− // Also prerender the color with Lip Gloss here to avoid processing
176− // in the render loop.
177− r := availableRunes[rand.IntN(len(availableRunes))]
178− a.cyclingFrames[i][j] = lipgloss.NewStyle().
179− Foreground(ramp[j+offset]).
180− Render(string(r))
205+ // Prerender scrambled rune frames for the animation.
206+ a.cyclingFrames = make([][]string, numFrames)
207+ offset = 0
208+ for i := range a.cyclingFrames {
209+ a.cyclingFrames[i] = make([]string, a.width)
210+ for j := range a.cyclingFrames[i] {
211+ if j+offset >= len(ramp) {
212+ continue // skip if we run out of colors
213+ }
214+
215+ // Also prerender the color with Lip Gloss here to avoid processing
216+ // in the render loop.
217+ r := availableRunes[rand.IntN(len(availableRunes))]
218+ a.cyclingFrames[i][j] = lipgloss.NewStyle().
219+ Foreground(ramp[j+offset]).
220+ Render(string(r))
221+ }
222+ if opts.CycleColors {
223+ offset++
224+ }
181225 }
182− if opts.CycleColors {
183− offset++
226+
227+ // Cache the results
228+ labelSlice := make([]string, a.label.Len())
229+ for i, v := range a.label.Seq2() {
230+ labelSlice[i] = v
231+ }
232+ ellipsisSlice := make([]string, a.ellipsisFrames.Len())
233+ for i, v := range a.ellipsisFrames.Seq2() {
234+ ellipsisSlice[i] = v
184235 }
236+ cached = &animCache{
237+ initialFrames: a.initialFrames,
238+ cyclingFrames: a.cyclingFrames,
239+ width: a.width,
240+ labelWidth: a.labelWidth,
241+ label: labelSlice,
242+ ellipsisFrames: ellipsisSlice,
243+ }
244+ animCacheMap.Set(cacheKey, cached)
185245 }
186246
187247 // Random assign a birth to each character for a stagged entrance effect.
@@ -212,28 +272,30 @@ func (a *Anim) renderLabel(label string) {
212272 if a.labelWidth > 0 {
213273 // Pre-render the label.
214274 labelRunes := []rune(label)
215− a.label = make([]string, len(labelRunes))
216− for i := range a.label {
217− a.label[i] = lipgloss.NewStyle().
275+ a.label = csync.NewSlice[string]()
276+ for i := range labelRunes {
277+ rendered := lipgloss.NewStyle().
218278 Foreground(a.labelColor).
219279 Render(string(labelRunes[i]))
280+ a.label.Append(rendered)
220281 }
221282
222283 // Pre-render the ellipsis frames which come after the label.
223− a.ellipsisFrames = make([]string, len(ellipsisFrames))
224− for i, frame := range ellipsisFrames {
225− a.ellipsisFrames[i] = lipgloss.NewStyle().
284+ a.ellipsisFrames = csync.NewSlice[string]()
285+ for _, frame := range ellipsisFrames {
286+ rendered := lipgloss.NewStyle().
226287 Foreground(a.labelColor).
227288 Render(frame)
289+ a.ellipsisFrames.Append(rendered)
228290 }
229291 } else {
230− a.label = nil
231− a.ellipsisFrames = nil
292+ a.label = csync.NewSlice[string]()
293+ a.ellipsisFrames = csync.NewSlice[string]()
232294 }
233295}
234296
235297// Width returns the total width of the animation.
236−func (a Anim) Width() (w int) {
298+func (a *Anim) Width() (w int) {
237299 w = a.width
238300 if a.labelWidth > 0 {
239301 w += labelGapWidth + a.labelWidth
@@ -251,12 +313,12 @@ func (a Anim) Width() (w int) {
251313}
252314
253315// Init starts the animation.
254−func (a Anim) Init() tea.Cmd {
316+func (a *Anim) Init() tea.Cmd {
255317 return a.Step()
256318}
257319
258320// Update processes animation steps (or not).
259−func (a Anim) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
321+func (a *Anim) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
260322 switch msg := msg.(type) {
261323 case StepMsg:
262324 if msg.id != a.id {
@@ -264,19 +326,19 @@ func (a Anim) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
264326 return a, nil
265327 }
266328
267− a.step++
268− if a.step >= len(a.cyclingFrames) {
269− a.step = 0
329+ step := a.step.Add(1)
330+ if int(step) >= len(a.cyclingFrames) {
331+ a.step.Store(0)
270332 }
271333
272− if a.initialized && a.labelWidth > 0 {
334+ if a.initialized.Load() && a.labelWidth > 0 {
273335 // Manage the ellipsis animation.
274− a.ellipsisStep++
275− if a.ellipsisStep >= ellipsisAnimSpeed*len(ellipsisFrames) {
276− a.ellipsisStep = 0
336+ ellipsisStep := a.ellipsisStep.Add(1)
337+ if int(ellipsisStep) >= ellipsisAnimSpeed*len(ellipsisFrames) {
338+ a.ellipsisStep.Store(0)
277339 }
278− } else if !a.initialized && time.Since(a.startTime) >= maxBirthOffset {
279− a.initialized = true
340+ } else if !a.initialized.Load() && time.Since(a.startTime) >= maxBirthOffset {
341+ a.initialized.Store(true)
280342 }
281343 return a, a.Step()
282344 default:
@@ -285,35 +347,41 @@ func (a Anim) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
285347}
286348
287349// View renders the current state of the animation.
288−func (a Anim) View() string {
350+func (a *Anim) View() string {
289351 var b strings.Builder
352+ step := int(a.step.Load())
290353 for i := range a.width {
291354 switch {
292− case !a.initialized && i < len(a.birthOffsets) && time.Since(a.startTime) < a.birthOffsets[i]:
355+ case !a.initialized.Load() && i < len(a.birthOffsets) && time.Since(a.startTime) < a.birthOffsets[i]:
293356 // Birth offset not reached: render initial character.
294− b.WriteString(a.initialFrames[a.step][i])
357+ b.WriteString(a.initialFrames[step][i])
295358 case i < a.cyclingCharWidth:
296359 // Render a cycling character.
297− b.WriteString(a.cyclingFrames[a.step][i])
360+ b.WriteString(a.cyclingFrames[step][i])
298361 case i == a.cyclingCharWidth:
299362 // Render label gap.
300363 b.WriteString(labelGap)
301364 case i > a.cyclingCharWidth:
302365 // Label.
303− b.WriteString(a.label[i-a.cyclingCharWidth-labelGapWidth])
366+ if labelChar, ok := a.label.Get(i - a.cyclingCharWidth - labelGapWidth); ok {
367+ b.WriteString(labelChar)
368+ }
304369 }
305370 }
306371 // Render animated ellipsis at the end of the label if all characters
307372 // have been initialized.
308− if a.initialized && a.labelWidth > 0 {
309− b.WriteString(a.ellipsisFrames[a.ellipsisStep/ellipsisAnimSpeed])
373+ if a.initialized.Load() && a.labelWidth > 0 {
374+ ellipsisStep := int(a.ellipsisStep.Load())
375+ if ellipsisFrame, ok := a.ellipsisFrames.Get(ellipsisStep / ellipsisAnimSpeed); ok {
376+ b.WriteString(ellipsisFrame)
377+ }
310378 }
311379
312380 return b.String()
313381}
314382
315383// Step is a command that triggers the next step in the animation.
316−func (a Anim) Step() tea.Cmd {
384+func (a *Anim) Step() tea.Cmd {
317385 return tea.Tick(time.Second/time.Duration(fps), func(t time.Time) tea.Msg {
318386 return StepMsg{id: a.id}
319387 })
internal/tui/components/anim/example/main.go+1−1
@@ -56,7 +56,7 @@ func (m model) View() tea.View {
5656 return v
5757 }
5858
59− if a, ok := m.anim.(anim.Anim); ok {
59+ if a, ok := m.anim.(*anim.Anim); ok {
6060 l := lipgloss.NewLayer(a.View()).
6161 Width(a.Width()).
6262 X(m.w/2 - a.Width()/2).
internal/tui/components/chat/chat.go+36−22
@@ -13,7 +13,7 @@ import (
1313 "github.com/charmbracelet/crush/internal/session"
1414 "github.com/charmbracelet/crush/internal/tui/components/chat/messages"
1515 "github.com/charmbracelet/crush/internal/tui/components/core/layout"
16− "github.com/charmbracelet/crush/internal/tui/components/core/list"
16+ "github.com/charmbracelet/crush/internal/tui/exp/list"
1717 "github.com/charmbracelet/crush/internal/tui/styles"
1818 "github.com/charmbracelet/crush/internal/tui/util"
1919)
@@ -40,6 +40,7 @@ type MessageListCmp interface {
4040 layout.Help
4141
4242 SetSession(session.Session) tea.Cmd
43+ GoToBottom() tea.Cmd
4344}
4445
4546// messageListCmp implements MessageListCmp, providing a virtualized list
@@ -49,8 +50,8 @@ type messageListCmp struct {
4950 app *app.App
5051 width, height int
5152 session session.Session
52− listCmp list.ListModel
53− previousSelected int // Last selected item index for restoring focus
53+ listCmp list.List[list.Item]
54+ previousSelected string // Last selected item index for restoring focus
5455
5556 lastUserMessageTime int64
5657 defaultListKeyMap list.KeyMap
@@ -61,21 +62,24 @@ type messageListCmp struct {
6162func New(app *app.App) MessageListCmp {
6263 defaultListKeyMap := list.DefaultKeyMap()
6364 listCmp := list.New(
64− list.WithGapSize(1),
65− list.WithReverse(true),
65+ []list.Item{},
66+ list.WithGap(1),
67+ list.WithDirectionBackward(),
68+ list.WithFocus(false),
6669 list.WithKeyMap(defaultListKeyMap),
70+ list.WithEnableMouse(),
6771 )
6872 return &messageListCmp{
6973 app: app,
7074 listCmp: listCmp,
71− previousSelected: list.NoSelection,
75+ previousSelected: "",
7276 defaultListKeyMap: defaultListKeyMap,
7377 }
7478}
7579
7680// Init initializes the component.
7781func (m *messageListCmp) Init() tea.Cmd {
78− return tea.Sequence(m.listCmp.Init(), m.listCmp.Blur())
82+ return m.listCmp.Init()
7983}
8084
8185// Update handles incoming messages and updates the component state.
@@ -89,15 +93,20 @@ func (m *messageListCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
8993 return m, nil
9094 case SessionClearedMsg:
9195 m.session = session.Session{}
92− return m, m.listCmp.SetItems([]util.Model{})
96+ return m, m.listCmp.SetItems([]list.Item{})
9397
9498 case pubsub.Event[message.Message]:
9599 cmd := m.handleMessageEvent(msg)
96100 return m, cmd
101+
102+ case tea.MouseWheelMsg:
103+ u, cmd := m.listCmp.Update(msg)
104+ m.listCmp = u.(list.List[list.Item])
105+ return m, cmd
97106 default:
98107 var cmds []tea.Cmd
99108 u, cmd := m.listCmp.Update(msg)
100− m.listCmp = u.(list.ListModel)
109+ m.listCmp = u.(list.List[list.Item])
101110 cmds = append(cmds, cmd)
102111 return m, tea.Batch(cmds...)
103112 }
@@ -169,7 +178,7 @@ func (m *messageListCmp) handleChildSession(event pubsub.Event[message.Message])
169178
170179 toolCall.SetNestedToolCalls(nestedToolCalls)
171180 m.listCmp.UpdateItem(
172− toolCallInx,
181+ toolCall.ID(),
173182 toolCall,
174183 )
175184 return tea.Batch(cmds...)
@@ -233,7 +242,7 @@ func (m *messageListCmp) handleToolMessage(msg message.Message) tea.Cmd {
233242 if toolCallIndex := m.findToolCallByID(items, tr.ToolCallID); toolCallIndex != NotFound {
234243 toolCall := items[toolCallIndex].(messages.ToolCallCmp)
235244 toolCall.SetToolResult(tr)
236− m.listCmp.UpdateItem(toolCallIndex, toolCall)
245+ m.listCmp.UpdateItem(toolCall.ID(), toolCall)
237246 }
238247 }
239248 return nil
@@ -241,7 +250,7 @@ func (m *messageListCmp) handleToolMessage(msg message.Message) tea.Cmd {
241250
242251// findToolCallByID searches for a tool call with the specified ID.
243252// Returns the index if found, NotFound otherwise.
244−func (m *messageListCmp) findToolCallByID(items []util.Model, toolCallID string) int {
253+func (m *messageListCmp) findToolCallByID(items []list.Item, toolCallID string) int {
245254 // Search backwards as tool calls are more likely to be recent
246255 for i := len(items) - 1; i >= 0; i-- {
247256 if toolCall, ok := items[i].(messages.ToolCallCmp); ok && toolCall.GetToolCall().ID == toolCallID {
@@ -274,7 +283,7 @@ func (m *messageListCmp) handleUpdateAssistantMessage(msg message.Message) tea.C
274283}
275284
276285// findAssistantMessageAndToolCalls locates the assistant message and its tool calls.
277−func (m *messageListCmp) findAssistantMessageAndToolCalls(items []util.Model, messageID string) (int, map[int]messages.ToolCallCmp) {
286+func (m *messageListCmp) findAssistantMessageAndToolCalls(items []list.Item, messageID string) (int, map[int]messages.ToolCallCmp) {
278287 assistantIndex := NotFound
279288 toolCalls := make(map[int]messages.ToolCallCmp)
280289
@@ -310,7 +319,7 @@ func (m *messageListCmp) updateAssistantMessageContent(msg message.Message, assi
310319 uiMsg := items[assistantIndex].(messages.MessageCmp)
311320 uiMsg.SetMessage(msg)
312321 m.listCmp.UpdateItem(
313− assistantIndex,
322+ items[assistantIndex].ID(),
314323 uiMsg,
315324 )
316325 if msg.FinishPart() != nil && msg.FinishPart().Reason == message.FinishReasonEndTurn {
@@ -322,7 +331,8 @@ func (m *messageListCmp) updateAssistantMessageContent(msg message.Message, assi
322331 )
323332 }
324333 } else if hasToolCallsOnly {
325− m.listCmp.DeleteItem(assistantIndex)
334+ items := m.listCmp.Items()
335+ m.listCmp.DeleteItem(items[assistantIndex].ID())
326336 }
327337
328338 return cmd
@@ -349,13 +359,13 @@ func (m *messageListCmp) updateToolCalls(msg message.Message, existingToolCalls
349359// updateOrAddToolCall updates an existing tool call or adds a new one.
350360func (m *messageListCmp) updateOrAddToolCall(msg message.Message, tc message.ToolCall, existingToolCalls map[int]messages.ToolCallCmp) tea.Cmd {
351361 // Try to find existing tool call
352− for index, existingTC := range existingToolCalls {
362+ for _, existingTC := range existingToolCalls {
353363 if tc.ID == existingTC.GetToolCall().ID {
354364 existingTC.SetToolCall(tc)
355365 if msg.FinishPart() != nil && msg.FinishPart().Reason == message.FinishReasonCanceled {
356366 existingTC.SetCancelled()
357367 }
358− m.listCmp.UpdateItem(index, existingTC)
368+ m.listCmp.UpdateItem(tc.ID, existingTC)
359369 return nil
360370 }
361371 }
@@ -400,7 +410,7 @@ func (m *messageListCmp) SetSession(session session.Session) tea.Cmd {
400410 }
401411
402412 if len(sessionMessages) == 0 {
403− return m.listCmp.SetItems([]util.Model{})
413+ return m.listCmp.SetItems([]list.Item{})
404414 }
405415
406416 // Initialize with first message timestamp
@@ -427,8 +437,8 @@ func (m *messageListCmp) buildToolResultMap(messages []message.Message) map[stri
427437}
428438
429439// convertMessagesToUI converts database messages to UI components.
430−func (m *messageListCmp) convertMessagesToUI(sessionMessages []message.Message, toolResultMap map[string]message.ToolResult) []util.Model {
431− uiMessages := make([]util.Model, 0)
440+func (m *messageListCmp) convertMessagesToUI(sessionMessages []message.Message, toolResultMap map[string]message.ToolResult) []list.Item {
441+ uiMessages := make([]list.Item, 0)
432442
433443 for _, msg := range sessionMessages {
434444 switch msg.Role {
@@ -447,8 +457,8 @@ func (m *messageListCmp) convertMessagesToUI(sessionMessages []message.Message,
447457}
448458
449459// convertAssistantMessage converts an assistant message and its tool calls to UI components.
450−func (m *messageListCmp) convertAssistantMessage(msg message.Message, toolResultMap map[string]message.ToolResult) []util.Model {
451− var uiMessages []util.Model
460+func (m *messageListCmp) convertAssistantMessage(msg message.Message, toolResultMap map[string]message.ToolResult) []list.Item {
461+ var uiMessages []list.Item
452462
453463 // Add assistant message if it should be displayed
454464 if m.shouldShowAssistantMessage(msg) {
@@ -529,3 +539,7 @@ func (m *messageListCmp) IsFocused() bool {
529539func (m *messageListCmp) Bindings() []key.Binding {
530540 return m.defaultListKeyMap.KeyBindings()
531541}
542+
543+func (m *messageListCmp) GoToBottom() tea.Cmd {
544+ return m.listCmp.GoToBottom()
545+}
internal/tui/components/chat/messages/messages.go+17−5
@@ -11,13 +11,14 @@ import (
1111 "github.com/charmbracelet/catwalk/pkg/catwalk"
1212 "github.com/charmbracelet/lipgloss/v2"
1313 "github.com/charmbracelet/x/ansi"
14+ "github.com/google/uuid"
1415
1516 "github.com/charmbracelet/crush/internal/config"
1617 "github.com/charmbracelet/crush/internal/message"
1718 "github.com/charmbracelet/crush/internal/tui/components/anim"
1819 "github.com/charmbracelet/crush/internal/tui/components/core"
1920 "github.com/charmbracelet/crush/internal/tui/components/core/layout"
20− "github.com/charmbracelet/crush/internal/tui/components/core/list"
21+ "github.com/charmbracelet/crush/internal/tui/exp/list"
2122 "github.com/charmbracelet/crush/internal/tui/styles"
2223 "github.com/charmbracelet/crush/internal/tui/util"
2324)
@@ -31,6 +32,7 @@ type MessageCmp interface {
3132 GetMessage() message.Message // Access to underlying message data
3233 SetMessage(msg message.Message) // Update the message content
3334 Spinning() bool // Animation state for loading messages
35+ ID() string
3436}
3537
3638// messageCmp implements the MessageCmp interface for displaying chat messages.
@@ -43,7 +45,7 @@ type messageCmp struct {
4345 // Core message data and state
4446 message message.Message // The underlying message content
4547 spinning bool // Whether to show loading animation
46− anim anim.Anim // Animation component for loading states
48+ anim *anim.Anim // Animation component for loading states
4749
4850 // Thinking viewport for displaying reasoning content
4951 thinkingViewport viewport.Model
@@ -89,7 +91,7 @@ func (m *messageCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
8991 m.spinning = m.shouldSpin()
9092 if m.spinning {
9193 u, cmd := m.anim.Update(msg)
92− m.anim = u.(anim.Anim)
94+ m.anim = u.(*anim.Anim)
9395 return m, cmd
9496 }
9597 }
@@ -333,19 +335,25 @@ func (m *messageCmp) Spinning() bool {
333335}
334336
335337type AssistantSection interface {
336− util.Model
338+ list.Item
337339 layout.Sizeable
338− list.SectionHeader
339340}
340341type assistantSectionModel struct {
341342 width int
343+ id string
342344 message message.Message
343345 lastUserMessageTime time.Time
344346}
345347
348+// ID implements AssistantSection.
349+func (m *assistantSectionModel) ID() string {
350+ return m.id
351+}
352+
346353func NewAssistantSection(message message.Message, lastUserMessageTime time.Time) AssistantSection {
347354 return &assistantSectionModel{
348355 width: 0,
356+ id: uuid.NewString(),
349357 message: message,
350358 lastUserMessageTime: lastUserMessageTime,
351359 }
@@ -392,3 +400,7 @@ func (m *assistantSectionModel) SetSize(width int, height int) tea.Cmd {
392400func (m *assistantSectionModel) IsSectionHeader() bool {
393401 return true
394402}
403+
404+func (m *messageCmp) ID() string {
405+ return m.message.ID
406+}
internal/tui/components/chat/messages/tool.go+6−1
@@ -29,6 +29,7 @@ type ToolCallCmp interface {
2929 GetNestedToolCalls() []ToolCallCmp // Get nested tool calls
3030 SetNestedToolCalls([]ToolCallCmp) // Set nested tool calls
3131 SetIsNested(bool) // Set whether this tool call is nested
32+ ID() string
3233}
3334
3435// toolCallCmp implements the ToolCallCmp interface for displaying tool calls.
@@ -296,7 +297,7 @@ func (m *toolCallCmp) SetSize(width int, height int) tea.Cmd {
296297// shouldSpin determines whether the tool call should show a loading animation.
297298// Returns true if the tool call is not finished or if the result doesn't match the call ID.
298299func (m *toolCallCmp) shouldSpin() bool {
299− return !m.call.Finished
300+ return !m.call.Finished && !m.cancelled
300301}
301302
302303// Spinning returns whether the tool call is currently showing a loading animation
@@ -311,3 +312,7 @@ func (m *toolCallCmp) Spinning() bool {
311312 }
312313 return m.spinning
313314}
315+
316+func (m *toolCallCmp) ID() string {
317+ return m.call.ID
318+}
internal/tui/components/chat/splash/splash.go+13−12
@@ -14,12 +14,11 @@ import (
1414 "github.com/charmbracelet/crush/internal/config"
1515 "github.com/charmbracelet/crush/internal/llm/prompt"
1616 "github.com/charmbracelet/crush/internal/tui/components/chat"
17− "github.com/charmbracelet/crush/internal/tui/components/completions"
1817 "github.com/charmbracelet/crush/internal/tui/components/core"
1918 "github.com/charmbracelet/crush/internal/tui/components/core/layout"
20− "github.com/charmbracelet/crush/internal/tui/components/core/list"
2119 "github.com/charmbracelet/crush/internal/tui/components/dialogs/models"
2220 "github.com/charmbracelet/crush/internal/tui/components/logo"
21+ "github.com/charmbracelet/crush/internal/tui/exp/list"
2322 "github.com/charmbracelet/crush/internal/tui/styles"
2423 "github.com/charmbracelet/crush/internal/tui/util"
2524 "github.com/charmbracelet/crush/internal/version"
@@ -86,9 +85,7 @@ func New() Splash {
8685 listKeyMap.DownOneItem = keyMap.Next
8786 listKeyMap.UpOneItem = keyMap.Previous
8887
89− t := styles.CurrentTheme()
90− inputStyle := t.S().Base.Padding(0, 1, 0, 1)
91− modelList := models.NewModelListComponent(listKeyMap, inputStyle, "Find your fave")
88+ modelList := models.NewModelListComponent(listKeyMap, "Find your fave", false)
9289 apiKeyInput := models.NewAPIKeyInput()
9390
9491 return &splashCmp{
@@ -195,20 +192,18 @@ func (s *splashCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
195192 return s, s.saveAPIKeyAndContinue(s.apiKeyValue)
196193 }
197194 if s.isOnboarding && !s.needsAPIKey {
198− modelInx := s.modelList.SelectedIndex()
199− if modelInx == -1 {
195+ selectedItem := s.modelList.SelectedModel()
196+ if selectedItem == nil {
200197 return s, nil
201198 }
202− items := s.modelList.Items()
203− selectedItem := items[modelInx].(completions.CompletionItem).Value().(models.ModelOption)
204199 if s.isProviderConfigured(string(selectedItem.Provider.ID)) {
205− cmd := s.setPreferredModel(selectedItem)
200+ cmd := s.setPreferredModel(*selectedItem)
206201 s.isOnboarding = false
207202 return s, tea.Batch(cmd, util.CmdHandler(OnboardingCompleteMsg{}))
208203 } else {
209204 // Provider not configured, show API key input
210205 s.needsAPIKey = true
211− s.selectedModel = &selectedItem
206+ s.selectedModel = selectedItem
212207 s.apiKeyInput.SetProviderName(selectedItem.Provider.Name)
213208 return s, nil
214209 }
@@ -267,6 +262,9 @@ func (s *splashCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
267262 return s, nil
268263 }
269264 case key.Matches(msg, s.keyMap.Yes):
265+ if s.isOnboarding {
266+ return s, nil
267+ }
270268 if s.needsAPIKey {
271269 u, cmd := s.apiKeyInput.Update(msg)
272270 s.apiKeyInput = u.(*models.APIKeyInput)
@@ -277,6 +275,9 @@ func (s *splashCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
277275 return s, s.initializeProject()
278276 }
279277 case key.Matches(msg, s.keyMap.No):
278+ if s.isOnboarding {
279+ return s, nil
280+ }
280281 if s.needsAPIKey {
281282 u, cmd := s.apiKeyInput.Update(msg)
282283 s.apiKeyInput = u.(*models.APIKeyInput)
@@ -606,7 +607,7 @@ func (s *splashCmp) moveCursor(cursor *tea.Cursor) *tea.Cursor {
606607 cursor.Y += offset
607608 cursor.X = cursor.X + 1
608609 } else if s.isOnboarding {
609− offset := logoHeight + SplashScreenPaddingY + s.logoGap() + 3
610+ offset := logoHeight + SplashScreenPaddingY + s.logoGap() + 2
610611 cursor.Y += offset
611612 cursor.X = cursor.X + 1
612613 }
internal/tui/components/completions/completions.go+38−36
@@ -5,7 +5,7 @@ import (
55
66 "github.com/charmbracelet/bubbles/v2/key"
77 tea "github.com/charmbracelet/bubbletea/v2"
8− "github.com/charmbracelet/crush/internal/tui/components/core/list"
8+ "github.com/charmbracelet/crush/internal/tui/exp/list"
99 "github.com/charmbracelet/crush/internal/tui/styles"
1010 "github.com/charmbracelet/crush/internal/tui/util"
1111 "github.com/charmbracelet/lipgloss/v2"
@@ -56,6 +56,8 @@ type Completions interface {
5656 Height() int
5757}
5858
59+type listModel = list.FilterableList[list.CompletionItem[any]]
60+
5961type completionsCmp struct {
6062 wWidth int // The window width
6163 wHeight int // The window height
@@ -67,7 +69,7 @@ type completionsCmp struct {
6769 open bool // Indicates if the completions are open
6870 keyMap KeyMap
6971
70− list list.ListModel
72+ list listModel
7173 query string // The current filter query
7274}
7375
@@ -83,10 +85,13 @@ func New() Completions {
8385 keyMap.UpOneItem = completionsKeyMap.Up
8486 keyMap.DownOneItem = completionsKeyMap.Down
8587
86− l := list.New(
87− list.WithReverse(true),
88− list.WithKeyMap(keyMap),
89− list.WithHideFilterInput(true),
88+ l := list.NewFilterableList(
89+ []list.CompletionItem[any]{},
90+ list.WithFilterInputHidden(),
91+ list.WithFilterListOptions(
92+ list.WithDirectionBackward(),
93+ list.WithKeyMap(keyMap),
94+ ),
9095 )
9196 return &completionsCmp{
9297 width: 0,
@@ -115,47 +120,44 @@ func (c *completionsCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
115120 switch {
116121 case key.Matches(msg, c.keyMap.Up):
117122 u, cmd := c.list.Update(msg)
118− c.list = u.(list.ListModel)
123+ c.list = u.(listModel)
119124 return c, cmd
120125
121126 case key.Matches(msg, c.keyMap.Down):
122127 d, cmd := c.list.Update(msg)
123− c.list = d.(list.ListModel)
128+ c.list = d.(listModel)
124129 return c, cmd
125130 case key.Matches(msg, c.keyMap.UpInsert):
126− selectedItemInx := c.list.SelectedIndex() - 1
127− items := c.list.Items()
128− if selectedItemInx == list.NoSelection || selectedItemInx < 0 {
129− return c, nil // No item selected, do nothing
131+ s := c.list.SelectedItem()
132+ if s == nil {
133+ return c, nil
130134 }
131− selectedItem := items[selectedItemInx].(CompletionItem).Value()
132− c.list.SetSelected(selectedItemInx)
135+ selectedItem := *s
136+ c.list.SetSelected(selectedItem.ID())
133137 return c, util.CmdHandler(SelectCompletionMsg{
134− Value: selectedItem,
138+ Value: selectedItem.Value(),
135139 Insert: true,
136140 })
137141 case key.Matches(msg, c.keyMap.DownInsert):
138− selectedItemInx := c.list.SelectedIndex() + 1
139− items := c.list.Items()
140− if selectedItemInx == list.NoSelection || selectedItemInx >= len(items) {
141− return c, nil // No item selected, do nothing
142+ s := c.list.SelectedItem()
143+ if s == nil {
144+ return c, nil
142145 }
143− selectedItem := items[selectedItemInx].(CompletionItem).Value()
144− c.list.SetSelected(selectedItemInx)
146+ selectedItem := *s
147+ c.list.SetSelected(selectedItem.ID())
145148 return c, util.CmdHandler(SelectCompletionMsg{
146− Value: selectedItem,
149+ Value: selectedItem.Value(),
147150 Insert: true,
148151 })
149152 case key.Matches(msg, c.keyMap.Select):
150− selectedItemInx := c.list.SelectedIndex()
151− if selectedItemInx == list.NoSelection {
152− return c, nil // No item selected, do nothing
153+ s := c.list.SelectedItem()
154+ if s == nil {
155+ return c, nil
153156 }
154− items := c.list.Items()
155− selectedItem := items[selectedItemInx].(CompletionItem).Value()
157+ selectedItem := *s
156158 c.open = false // Close completions after selection
157159 return c, util.CmdHandler(SelectCompletionMsg{
158− Value: selectedItem,
160+ Value: selectedItem.Value(),
159161 })
160162 case key.Matches(msg, c.keyMap.Cancel):
161163 return c, util.CmdHandler(CloseCompletionsMsg{})
@@ -171,10 +173,14 @@ func (c *completionsCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
171173 c.query = ""
172174 c.x, c.xorig = msg.X, msg.X
173175 c.y = msg.Y
174− items := []util.Model{}
176+ items := []list.CompletionItem[any]{}
175177 t := styles.CurrentTheme()
176178 for _, completion := range msg.Completions {
177− item := NewCompletionItem(completion.Title, completion.Value, WithBackgroundColor(t.BgSubtle))
179+ item := list.NewCompletionItem(
180+ completion.Title,
181+ completion.Value,
182+ list.WithCompletionBackgroundColor(t.BgSubtle),
183+ )
178184 items = append(items, item)
179185 }
180186 width := listWidth(items)
@@ -261,18 +267,14 @@ func (c *completionsCmp) View() string {
261267// listWidth returns the width of the last 10 items in the list, which is used
262268// to determine the width of the completions popup.
263269// Note this only works for [completionItemCmp] items.
264−func listWidth[T any](items []T) int {
270+func listWidth(items []list.CompletionItem[any]) int {
265271 var width int
266272 if len(items) == 0 {
267273 return width
268274 }
269275
270276 for i := len(items) - 1; i >= 0 && i >= len(items)-10; i-- {
271− item, ok := any(items[i]).(*completionItemCmp)
272− if !ok {
273− continue
274− }
275− itemWidth := lipgloss.Width(item.text) + 2 // +2 for padding
277+ itemWidth := lipgloss.Width(items[i].Text()) + 2 // +2 for padding
276278 width = max(width, itemWidth)
277279 }
278280
internal/tui/components/core/list/list.godeleted−1371
@@ -1,1371 +0,0 @@
1−package list
2−
3−import (
4− "slices"
5− "sort"
6− "strings"
7−
8− "github.com/charmbracelet/bubbles/v2/help"
9− "github.com/charmbracelet/bubbles/v2/key"
10− "github.com/charmbracelet/bubbles/v2/textinput"
11− tea "github.com/charmbracelet/bubbletea/v2"
12− "github.com/charmbracelet/crush/internal/tui/components/anim"
13− "github.com/charmbracelet/crush/internal/tui/components/core/layout"
14− "github.com/charmbracelet/crush/internal/tui/styles"
15− "github.com/charmbracelet/crush/internal/tui/util"
16− "github.com/charmbracelet/lipgloss/v2"
17− "github.com/sahilm/fuzzy"
18−)
19−
20−// Constants for special index values and defaults
21−const (
22− NoSelection = -1 // Indicates no item is currently selected
23− NotRendered = -1 // Indicates an item hasn't been rendered yet
24− NoFinalHeight = -1 // Indicates final height hasn't been calculated
25− DefaultGapSize = 0 // Default spacing between list items
26−)
27−
28−// ListModel defines the interface for a scrollable, selectable list component.
29−// It combines the basic Model interface with sizing capabilities and list-specific operations.
30−type ListModel interface {
31− util.Model
32− layout.Sizeable
33− layout.Focusable
34− SetItems([]util.Model) tea.Cmd // Replace all items in the list
35− AppendItem(util.Model) tea.Cmd // Add an item to the end of the list
36− PrependItem(util.Model) tea.Cmd // Add an item to the beginning of the list
37− DeleteItem(int) // Remove an item at the specified index
38− UpdateItem(int, util.Model) // Replace an item at the specified index
39− ResetView() // Clear rendering cache and reset scroll position
40− Items() []util.Model // Get all items in the list
41− SelectedIndex() int // Get the index of the currently selected item
42− SetSelected(int) tea.Cmd // Set the selected item by index and scroll to it
43− Filter(string) tea.Cmd // Filter items based on a search term
44− SetFilterPlaceholder(string) // Set the placeholder text for the filter input
45− Cursor() *tea.Cursor // Get the current cursor position in the filter input
46−}
47−
48−// HasAnim interface identifies items that support animation.
49−// Items implementing this interface will receive animation update messages.
50−type HasAnim interface {
51− util.Model
52− Spinning() bool // Returns true if the item is currently animating
53−}
54−
55−// HasFilterValue interface allows items to provide a filter value for searching.
56−type HasFilterValue interface {
57− FilterValue() string // Returns a string value used for filtering/searching
58−}
59−
60−// HasMatchIndexes interface allows items to set matched character indexes.
61−type HasMatchIndexes interface {
62− MatchIndexes([]int) // Sets the indexes of matched characters in the item's content
63−}
64−
65−// SectionHeader interface identifies items that are section headers.
66−// Section headers are rendered differently and are skipped during navigation.
67−type SectionHeader interface {
68− util.Model
69− IsSectionHeader() bool // Returns true if this item is a section header
70−}
71−
72−// renderedItem represents a cached rendered item with its position and content.
73−type renderedItem struct {
74− lines []string // The rendered lines of text for this item
75− start int // Starting line position in the overall rendered content
76− height int // Number of lines this item occupies
77−}
78−
79−// renderState manages the rendering cache and state for the list.
80−// It tracks which items have been rendered and their positions.
81−type renderState struct {
82− items map[int]renderedItem // Cache of rendered items by index
83− lines []string // All rendered lines concatenated
84− lastIndex int // Index of the last rendered item
85− finalHeight int // Total height when all items are rendered
86− needsRerender bool // Flag indicating if re-rendering is needed
87−}
88−
89−// newRenderState creates a new render state with default values.
90−func newRenderState() *renderState {
91− return &renderState{
92− items: make(map[int]renderedItem),
93− lines: []string{},
94− lastIndex: NotRendered,
95− finalHeight: NoFinalHeight,
96− needsRerender: true,
97− }
98−}
99−
100−// reset clears all cached rendering data and resets state to initial values.
101−func (rs *renderState) reset() {
102− rs.items = make(map[int]renderedItem)
103− rs.lines = []string{}
104− rs.lastIndex = NotRendered
105− rs.finalHeight = NoFinalHeight
106− rs.needsRerender = true
107−}
108−
109−// viewState manages the visual display properties of the list.
110−type viewState struct {
111− width, height int // Dimensions of the list viewport
112− offset int // Current scroll offset in lines
113− reverse bool // Whether to render in reverse order (bottom-up)
114− content string // The final rendered content to display
115−}
116−
117−// selectionState manages which item is currently selected.
118−type selectionState struct {
119− selectedIndex int // Index of the currently selected item, or NoSelection
120−}
121−
122−// isValidIndex checks if the selected index is within the valid range of items.
123−func (ss *selectionState) isValidIndex(itemCount int) bool {
124− return ss.selectedIndex >= 0 && ss.selectedIndex < itemCount
125−}
126−
127−// model is the main implementation of the ListModel interface.
128−// It coordinates between view state, render state, and selection state.
129−type model struct {
130− viewState viewState // Display and scrolling state
131− renderState *renderState // Rendering cache and state
132− selectionState selectionState // Item selection state
133− help help.Model // Help system for keyboard shortcuts
134− keyMap KeyMap // Key bindings for navigation
135− allItems []util.Model // The actual list items
136− gapSize int // Number of empty lines between items
137− padding []int // Padding around the list content
138− wrapNavigation bool // Whether to wrap navigation at the ends
139−
140− filterable bool // Whether items can be filtered
141− filterPlaceholder string // Placeholder text for filter input
142− filteredItems []util.Model // Filtered items based on current search
143− input textinput.Model // Input field for filtering items
144− inputStyle lipgloss.Style // Style for the input field
145− hideFilterInput bool // Whether to hide the filter input field
146− currentSearch string // Current search term for filtering
147−
148− isFocused bool // Whether the list is currently focused
149−}
150−
151−// listOptions is a function type for configuring list options.
152−type listOptions func(*model)
153−
154−// WithKeyMap sets custom key bindings for the list.
155−func WithKeyMap(k KeyMap) listOptions {
156− return func(m *model) {
157− m.keyMap = k
158− }
159−}
160−
161−// WithReverse sets whether the list should render in reverse order (newest items at bottom).
162−func WithReverse(reverse bool) listOptions {
163− return func(m *model) {
164− m.setReverse(reverse)
165− }
166−}
167−
168−// WithGapSize sets the number of empty lines to insert between list items.
169−func WithGapSize(gapSize int) listOptions {
170− return func(m *model) {
171− m.gapSize = gapSize
172− }
173−}
174−
175−// WithPadding sets the padding around the list content.
176−// Follows CSS padding convention: 1 value = all sides, 2 values = vertical/horizontal,
177−// 4 values = top/right/bottom/left.
178−func WithPadding(padding ...int) listOptions {
179− return func(m *model) {
180− m.padding = padding
181− }
182−}
183−
184−// WithItems sets the initial items for the list.
185−func WithItems(items []util.Model) listOptions {
186− return func(m *model) {
187− m.allItems = items
188− m.filteredItems = items // Initially, all items are visible
189− }
190−}
191−
192−// WithFilterable enables filtering of items based on their FilterValue.
193−func WithFilterable(filterable bool) listOptions {
194− return func(m *model) {
195− m.filterable = filterable
196− }
197−}
198−
199−// WithHideFilterInput hides the filter input field.
200−func WithHideFilterInput(hide bool) listOptions {
201− return func(m *model) {
202− m.hideFilterInput = hide
203− }
204−}
205−
206−// WithFilterPlaceholder sets the placeholder text for the filter input field.
207−func WithFilterPlaceholder(placeholder string) listOptions {
208− return func(m *model) {
209− m.filterPlaceholder = placeholder
210− }
211−}
212−
213−// WithInputStyle sets the style for the filter input field.
214−func WithInputStyle(style lipgloss.Style) listOptions {
215− return func(m *model) {
216− m.inputStyle = style
217− }
218−}
219−
220−// WithWrapNavigation enables wrapping navigation at the ends of the list.
221−func WithWrapNavigation(wrap bool) listOptions {
222− return func(m *model) {
223− m.wrapNavigation = wrap
224− }
225−}
226−
227−// New creates a new list model with the specified options.
228−// The list starts with no items selected and requires SetItems to be called
229−// or items to be provided via WithItems option.
230−func New(opts ...listOptions) ListModel {
231− t := styles.CurrentTheme()
232−
233− m := &model{
234− help: help.New(),
235− keyMap: DefaultKeyMap(),
236− allItems: []util.Model{},
237− filteredItems: []util.Model{},
238− renderState: newRenderState(),
239− gapSize: DefaultGapSize,
240− padding: []int{},
241− selectionState: selectionState{selectedIndex: NoSelection},
242− filterPlaceholder: "Type to filter...",
243− inputStyle: t.S().Base.Padding(0, 1, 1, 1),
244− isFocused: true,
245− }
246− for _, opt := range opts {
247− opt(m)
248− }
249−
250− if m.filterable && !m.hideFilterInput {
251− t := styles.CurrentTheme()
252− ti := textinput.New()
253− ti.Placeholder = m.filterPlaceholder
254− ti.SetVirtualCursor(false)
255− ti.Focus()
256− ti.SetStyles(t.S().TextInput)
257− m.input = ti
258− }
259− return m
260−}
261−
262−// Init initializes the list component and sets up the initial items.
263−// This is called automatically by the Bubble Tea framework.
264−func (m *model) Init() tea.Cmd {
265− return m.SetItems(m.filteredItems)
266−}
267−
268−// Update handles incoming messages and updates the list state accordingly.
269−// It processes keyboard input, animation messages, and forwards other messages
270−// to the currently selected item.
271−func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
272− switch msg := msg.(type) {
273− case tea.KeyPressMsg:
274− return m.handleKeyPress(msg)
275− case anim.StepMsg:
276− return m.handleAnimationMsg(msg)
277− }
278− if m.selectionState.isValidIndex(len(m.filteredItems)) {
279− return m.updateSelectedItem(msg)
280− }
281−
282− return m, nil
283−}
284−
285−// Cursor returns the current cursor position in the input field.
286−func (m *model) Cursor() *tea.Cursor {
287− if m.filterable && !m.hideFilterInput {
288− return m.input.Cursor()
289− }
290− return nil
291−}
292−
293−// View renders the list to a string for display.
294−// Returns empty string if the list has no dimensions.
295−// Triggers re-rendering if needed before returning content.
296−func (m *model) View() string {
297− if m.viewState.height == 0 || m.viewState.width == 0 {
298− return "" // No content to display
299− }
300− if m.renderState.needsRerender {
301− m.renderVisible()
302− }
303−
304− content := lipgloss.NewStyle().
305− Padding(m.padding...).
306− Height(m.viewState.height).
307− Render(m.viewState.content)
308−
309− if m.filterable && !m.hideFilterInput {
310− content = lipgloss.JoinVertical(
311− lipgloss.Left,
312− m.inputStyle.Render(m.input.View()),
313− content,
314− )
315− }
316− return content
317−}
318−
319−// handleKeyPress processes keyboard input for list navigation.
320−// Supports scrolling, item selection, and navigation to top/bottom.
321−func (m *model) handleKeyPress(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
322− switch {
323− case key.Matches(msg, m.keyMap.Down):
324− m.scrollDown(1)
325− case key.Matches(msg, m.keyMap.Up):
326− m.scrollUp(1)
327− case key.Matches(msg, m.keyMap.DownOneItem):
328− return m, m.selectNextItem()
329− case key.Matches(msg, m.keyMap.UpOneItem):
330− return m, m.selectPreviousItem()
331− case key.Matches(msg, m.keyMap.HalfPageDown):
332− m.scrollDown(m.listHeight() / 2)
333− case key.Matches(msg, m.keyMap.HalfPageUp):
334− m.scrollUp(m.listHeight() / 2)
335− case key.Matches(msg, m.keyMap.PageDown):
336− m.scrollDown(m.listHeight())
337− case key.Matches(msg, m.keyMap.PageUp):
338− m.scrollUp(m.listHeight())
339− case key.Matches(msg, m.keyMap.Home):
340− return m, m.goToTop()
341− case key.Matches(msg, m.keyMap.End):
342− return m, m.goToBottom()
343− default:
344− if !m.filterable || m.hideFilterInput {
345− return m, nil // Ignore other keys if not filterable or input is hidden
346− }
347− var cmds []tea.Cmd
348− u, cmd := m.input.Update(msg)
349− m.input = u
350− cmds = append(cmds, cmd)
351− if m.currentSearch != m.input.Value() {
352− cmd = m.Filter(m.input.Value())
353− cmds = append(cmds, cmd)
354− }
355− m.currentSearch = m.input.Value()
356− return m, tea.Batch(cmds...)
357− }
358− return m, nil
359−}
360−
361−// handleAnimationMsg forwards animation messages to items that support animation.
362−// Only items implementing HasAnim and currently spinning receive these messages.
363−func (m *model) handleAnimationMsg(msg tea.Msg) (tea.Model, tea.Cmd) {
364− var cmds []tea.Cmd
365− for inx, item := range m.filteredItems {
366− if i, ok := item.(HasAnim); ok && i.Spinning() {
367− updated, cmd := i.Update(msg)
368− cmds = append(cmds, cmd)
369− if u, ok := updated.(util.Model); ok {
370− m.UpdateItem(inx, u)
371− }
372− }
373− }
374− return m, tea.Batch(cmds...)
375−}
376−
377−// updateSelectedItem forwards messages to the currently selected item.
378−// This allows the selected item to handle its own input and state changes.
379−func (m *model) updateSelectedItem(msg tea.Msg) (tea.Model, tea.Cmd) {
380− var cmds []tea.Cmd
381− u, cmd := m.filteredItems[m.selectionState.selectedIndex].Update(msg)
382− cmds = append(cmds, cmd)
383− if updated, ok := u.(util.Model); ok {
384− m.UpdateItem(m.selectionState.selectedIndex, updated)
385− }
386− return m, tea.Batch(cmds...)
387−}
388−
389−// scrollDown scrolls the list down by the specified amount.
390−// Direction is automatically adjusted based on reverse mode.
391−func (m *model) scrollDown(amount int) {
392− if m.viewState.reverse {
393− m.decreaseOffset(amount)
394− } else {
395− m.increaseOffset(amount)
396− }
397−}
398−
399−// scrollUp scrolls the list up by the specified amount.
400−// Direction is automatically adjusted based on reverse mode.
401−func (m *model) scrollUp(amount int) {
402− if m.viewState.reverse {
403− m.increaseOffset(amount)
404− } else {
405− m.decreaseOffset(amount)
406− }
407−}
408−
409−// Items returns a copy of all items in the list.
410−func (m *model) Items() []util.Model {
411− return m.filteredItems
412−}
413−
414−// renderVisible determines which rendering strategy to use and triggers rendering.
415−// Uses forward rendering for normal mode and reverse rendering for reverse mode.
416−func (m *model) renderVisible() {
417− if m.viewState.reverse {
418− m.renderVisibleReverse()
419− } else {
420− m.renderVisibleForward()
421− }
422−}
423−
424−// renderVisibleForward renders items from top to bottom (normal mode).
425−// Only renders items that are currently visible or near the viewport.
426−func (m *model) renderVisibleForward() {
427− renderer := &forwardRenderer{
428− model: m,
429− start: 0,
430− cutoff: m.viewState.offset + m.listHeight() + m.listHeight()/2, // We render a bit more so we make sure we have smooth movementsd
431− items: m.filteredItems,
432− realIdx: m.renderState.lastIndex,
433− }
434−
435− if m.renderState.lastIndex > NotRendered {
436− renderer.items = m.filteredItems[m.renderState.lastIndex+1:]
437− renderer.start = len(m.renderState.lines)
438− }
439−
440− renderer.render()
441− m.finalizeRender()
442−}
443−
444−// renderVisibleReverse renders items from bottom to top (reverse mode).
445−// Used when new items should appear at the bottom (like chat messages).
446−func (m *model) renderVisibleReverse() {
447− renderer := &reverseRenderer{
448− model: m,
449− start: 0,
450− cutoff: m.viewState.offset + m.listHeight() + m.listHeight()/2,
451− items: m.filteredItems,
452− realIdx: m.renderState.lastIndex,
453− }
454−
455− if m.renderState.lastIndex > NotRendered {
456− renderer.items = m.filteredItems[:m.renderState.lastIndex]
457− renderer.start = len(m.renderState.lines)
458− } else {
459− m.renderState.lastIndex = len(m.filteredItems)
460− renderer.realIdx = len(m.filteredItems)
461− }
462−
463− renderer.render()
464− m.finalizeRender()
465−}
466−
467−// finalizeRender completes the rendering process by updating scroll bounds and content.
468−func (m *model) finalizeRender() {
469− m.renderState.needsRerender = false
470− if m.renderState.finalHeight > NoFinalHeight {
471− m.viewState.offset = min(m.viewState.offset, m.renderState.finalHeight)
472− }
473− m.updateContent()
474−}
475−
476−// updateContent extracts the visible portion of rendered content for display.
477−// Handles both normal and reverse rendering modes.
478−func (m *model) updateContent() {
479− maxHeight := min(m.listHeight(), len(m.renderState.lines))
480− if m.viewState.offset >= len(m.renderState.lines) {
481− m.viewState.content = ""
482− return
483− }
484−
485− if m.viewState.reverse {
486− end := len(m.renderState.lines) - m.viewState.offset
487− start := max(0, end-maxHeight)
488− m.viewState.content = strings.Join(m.renderState.lines[start:end], "\n")
489− } else {
490− endIdx := min(maxHeight+m.viewState.offset, len(m.renderState.lines))
491− m.viewState.content = strings.Join(m.renderState.lines[m.viewState.offset:endIdx], "\n")
492− }
493−}
494−
495−// forwardRenderer handles rendering items from top to bottom.
496−// It builds up the rendered content incrementally, caching results for performance.
497−type forwardRenderer struct {
498− model *model // Reference to the parent list model
499− start int // Current line position in the overall content
500− cutoff int // Line position where we can stop rendering
501− items []util.Model // Items to render (may be a subset)
502− realIdx int // Real index in the full item list
503−}
504−
505−// render processes items in forward order, building up the rendered content.
506−func (r *forwardRenderer) render() {
507− for _, item := range r.items {
508− r.realIdx++
509− if r.start > r.cutoff {
510− break
511− }
512−
513− itemLines := r.getOrRenderItem(item)
514− if r.realIdx == len(r.model.filteredItems)-1 {
515− r.model.renderState.finalHeight = max(0, r.start+len(itemLines)-r.model.listHeight())
516− }
517−
518− r.model.renderState.lines = append(r.model.renderState.lines, itemLines...)
519− r.model.renderState.lastIndex = r.realIdx
520− r.start += len(itemLines)
521− }
522−}
523−
524−// getOrRenderItem retrieves cached content or renders the item if not cached.
525−func (r *forwardRenderer) getOrRenderItem(item util.Model) []string {
526− if cachedContent, ok := r.model.renderState.items[r.realIdx]; ok {
527− return cachedContent.lines
528− }
529−
530− itemLines := r.renderItemLines(item)
531− r.model.renderState.items[r.realIdx] = renderedItem{
532− lines: itemLines,
533− start: r.start,
534− height: len(itemLines),
535− }
536− return itemLines
537−}
538−
539−// renderItemLines converts an item to its string representation with gaps.
540−func (r *forwardRenderer) renderItemLines(item util.Model) []string {
541− return r.model.getItemLines(item)
542−}
543−
544−// reverseRenderer handles rendering items from bottom to top.
545−// Used in reverse mode where new items appear at the bottom.
546−type reverseRenderer struct {
547− model *model // Reference to the parent list model
548− start int // Current line position in the overall content
549− cutoff int // Line position where we can stop rendering
550− items []util.Model // Items to render (may be a subset)
551− realIdx int // Real index in the full item list
552−}
553−
554−// render processes items in reverse order, prepending to the rendered content.
555−func (r *reverseRenderer) render() {
556− for i := len(r.items) - 1; i >= 0; i-- {
557− r.realIdx--
558− if r.start > r.cutoff {
559− break
560− }
561−
562− itemLines := r.getOrRenderItem(r.items[i])
563− if r.realIdx == 0 {
564− r.model.renderState.finalHeight = max(0, r.start+len(itemLines)-r.model.listHeight())
565− }
566−
567− r.model.renderState.lines = append(itemLines, r.model.renderState.lines...)
568− r.model.renderState.lastIndex = r.realIdx
569− r.start += len(itemLines)
570− }
571−}
572−
573−// getOrRenderItem retrieves cached content or renders the item if not cached.
574−func (r *reverseRenderer) getOrRenderItem(item util.Model) []string {
575− if cachedContent, ok := r.model.renderState.items[r.realIdx]; ok {
576− return cachedContent.lines
577− }
578−
579− itemLines := r.renderItemLines(item)
580− r.model.renderState.items[r.realIdx] = renderedItem{
581− lines: itemLines,
582− start: r.start,
583− height: len(itemLines),
584− }
585− return itemLines
586−}
587−
588−// renderItemLines converts an item to its string representation with gaps.
589−func (r *reverseRenderer) renderItemLines(item util.Model) []string {
590− return r.model.getItemLines(item)
591−}
592−
593−// selectPreviousItem moves selection to the previous item in the list.
594−// Handles focus management and ensures the selected item remains visible.
595−// Skips section headers during navigation.
596−func (m *model) selectPreviousItem() tea.Cmd {
597− if m.selectionState.selectedIndex == m.findFirstSelectableItem() && m.wrapNavigation {
598− // If at the beginning and wrapping is enabled, go to the last item
599− return m.goToBottom()
600− }
601− if m.selectionState.selectedIndex <= 0 {
602− return nil
603− }
604−
605− cmds := []tea.Cmd{m.blurSelected()}
606− m.selectionState.selectedIndex--
607−
608− // Skip section headers
609− for m.selectionState.selectedIndex >= 0 && m.isSectionHeader(m.selectionState.selectedIndex) {
610− m.selectionState.selectedIndex--
611− }
612−
613− // If we went past the beginning, stay at the first non-header item
614− if m.selectionState.selectedIndex <= 0 {
615− cmds = append(cmds, m.goToTop()) // Ensure we scroll to the top if needed
616− return tea.Batch(cmds...)
617− }
618−
619− cmds = append(cmds, m.focusSelected())
620− m.ensureSelectedItemVisible()
621− return tea.Batch(cmds...)
622−}
623−
624−// selectNextItem moves selection to the next item in the list.
625−// Handles focus management and ensures the selected item remains visible.
626−// Skips section headers during navigation.
627−func (m *model) selectNextItem() tea.Cmd {
628− if m.selectionState.selectedIndex >= m.findLastSelectableItem() && m.wrapNavigation {
629− // If at the end and wrapping is enabled, go to the first item
630− return m.goToTop()
631− }
632− if m.selectionState.selectedIndex >= len(m.filteredItems)-1 || m.selectionState.selectedIndex < 0 {
633− return nil
634− }
635−
636− cmds := []tea.Cmd{m.blurSelected()}
637− m.selectionState.selectedIndex++
638−
639− // Skip section headers
640− for m.selectionState.selectedIndex < len(m.filteredItems) && m.isSectionHeader(m.selectionState.selectedIndex) {
641− m.selectionState.selectedIndex++
642− }
643−
644− // If we went past the end, stay at the last non-header item
645− if m.selectionState.selectedIndex >= len(m.filteredItems) {
646− m.selectionState.selectedIndex = m.findLastSelectableItem()
647− }
648−
649− cmds = append(cmds, m.focusSelected())
650− m.ensureSelectedItemVisible()
651− return tea.Batch(cmds...)
652−}
653−
654−// isSectionHeader checks if the item at the given index is a section header.
655−func (m *model) isSectionHeader(index int) bool {
656− if index < 0 || index >= len(m.filteredItems) {
657− return false
658− }
659− if header, ok := m.filteredItems[index].(SectionHeader); ok {
660− return header.IsSectionHeader()
661− }
662− return false
663−}
664−
665−// findFirstSelectableItem finds the first item that is not a section header.
666−func (m *model) findFirstSelectableItem() int {
667− for i := range m.filteredItems {
668− if !m.isSectionHeader(i) {
669− return i
670− }
671− }
672− return NoSelection
673−}
674−
675−// findLastSelectableItem finds the last item that is not a section header.
676−func (m *model) findLastSelectableItem() int {
677− for i := len(m.filteredItems) - 1; i >= 0; i-- {
678− if !m.isSectionHeader(i) {
679− return i
680− }
681− }
682− return NoSelection
683−}
684−
685−// ensureSelectedItemVisible scrolls the list to make the selected item visible.
686−// Uses different strategies for forward and reverse rendering modes.
687−func (m *model) ensureSelectedItemVisible() {
688− cachedItem, ok := m.renderState.items[m.selectionState.selectedIndex]
689− if !ok {
690− m.renderState.needsRerender = true
691− return
692− }
693−
694− if m.viewState.reverse {
695− m.ensureVisibleReverse(cachedItem)
696− } else {
697− m.ensureVisibleForward(cachedItem)
698− }
699− m.renderState.needsRerender = true
700−}
701−
702−// ensureVisibleForward ensures the selected item is visible in forward rendering mode.
703−// Handles both large items (taller than viewport) and normal items.
704−func (m *model) ensureVisibleForward(cachedItem renderedItem) {
705− if cachedItem.height >= m.listHeight() {
706− if m.selectionState.selectedIndex > 0 {
707− changeNeeded := m.viewState.offset - cachedItem.start
708− m.decreaseOffset(changeNeeded)
709− } else {
710− changeNeeded := cachedItem.start - m.viewState.offset
711− m.increaseOffset(changeNeeded)
712− }
713− return
714− }
715−
716− if cachedItem.start < m.viewState.offset {
717− changeNeeded := m.viewState.offset - cachedItem.start
718− m.decreaseOffset(changeNeeded)
719− } else {
720− end := cachedItem.start + cachedItem.height
721− if end > m.viewState.offset+m.listHeight() {
722− changeNeeded := end - (m.viewState.offset + m.listHeight())
723− m.increaseOffset(changeNeeded)
724− }
725− }
726−}
727−
728−// ensureVisibleReverse ensures the selected item is visible in reverse rendering mode.
729−// Handles both large items (taller than viewport) and normal items.
730−func (m *model) ensureVisibleReverse(cachedItem renderedItem) {
731− if cachedItem.height >= m.listHeight() {
732− if m.selectionState.selectedIndex < len(m.filteredItems)-1 {
733− changeNeeded := m.viewState.offset - (cachedItem.start + cachedItem.height - m.listHeight())
734− m.decreaseOffset(changeNeeded)
735− } else {
736− changeNeeded := (cachedItem.start + cachedItem.height - m.listHeight()) - m.viewState.offset
737− m.increaseOffset(changeNeeded)
738− }
739− return
740− }
741−
742− if cachedItem.start+cachedItem.height > m.viewState.offset+m.listHeight() {
743− changeNeeded := (cachedItem.start + cachedItem.height - m.listHeight()) - m.viewState.offset
744− m.increaseOffset(changeNeeded)
745− } else if cachedItem.start < m.viewState.offset {
746− changeNeeded := m.viewState.offset - cachedItem.start
747− m.decreaseOffset(changeNeeded)
748− }
749−}
750−
751−// goToBottom switches to reverse mode and selects the last selectable item.
752−// Commonly used for chat-like interfaces where new content appears at the bottom.
753−// Skips section headers when selecting the last item.
754−func (m *model) goToBottom() tea.Cmd {
755− cmds := []tea.Cmd{m.blurSelected()}
756− m.viewState.reverse = true
757− m.selectionState.selectedIndex = m.findLastSelectableItem()
758− if m.isFocused {
759− cmds = append(cmds, m.focusSelected())
760− }
761− m.ResetView()
762− return tea.Batch(cmds...)
763−}
764−
765−// goToTop switches to forward mode and selects the first selectable item.
766−// Standard behavior for most list interfaces.
767−// Skips section headers when selecting the first item.
768−func (m *model) goToTop() tea.Cmd {
769− cmds := []tea.Cmd{m.blurSelected()}
770− m.viewState.reverse = false
771− m.selectionState.selectedIndex = m.findFirstSelectableItem()
772− if m.isFocused {
773− cmds = append(cmds, m.focusSelected())
774− }
775− m.ResetView()
776− return tea.Batch(cmds...)
777−}
778−
779−// ResetView clears all cached rendering data and resets scroll position.
780−// Forces a complete re-render on the next View() call.
781−func (m *model) ResetView() {
782− m.renderState.reset()
783− m.viewState.offset = 0
784−}
785−
786−// focusSelected gives focus to the currently selected item if it supports focus.
787−// Triggers a re-render of the item to show its focused state.
788−func (m *model) focusSelected() tea.Cmd {
789− if !m.isFocused {
790− return nil // No focus change if the list is not focused
791− }
792− if !m.selectionState.isValidIndex(len(m.filteredItems)) {
793− return nil
794− }
795− if i, ok := m.filteredItems[m.selectionState.selectedIndex].(layout.Focusable); ok {
796− cmd := i.Focus()
797− m.rerenderItem(m.selectionState.selectedIndex)
798− return cmd
799− }
800− return nil
801−}
802−
803−// blurSelected removes focus from the currently selected item if it supports focus.
804−// Triggers a re-render of the item to show its unfocused state.
805−func (m *model) blurSelected() tea.Cmd {
806− if !m.selectionState.isValidIndex(len(m.filteredItems)) {
807− return nil
808− }
809− if i, ok := m.filteredItems[m.selectionState.selectedIndex].(layout.Focusable); ok {
810− cmd := i.Blur()
811− m.rerenderItem(m.selectionState.selectedIndex)
812− return cmd
813− }
814− return nil
815−}
816−
817−// rerenderItem updates the cached rendering of a specific item.
818−// This is called when an item's state changes (e.g., focus/blur) and needs to be re-displayed.
819−// It efficiently updates only the changed item and adjusts positions of subsequent items if needed.
820−func (m *model) rerenderItem(inx int) {
821− if inx < 0 || inx >= len(m.filteredItems) || len(m.renderState.lines) == 0 {
822− return
823− }
824−
825− cachedItem, ok := m.renderState.items[inx]
826− if !ok {
827− return
828− }
829−
830− rerenderedLines := m.getItemLines(m.filteredItems[inx])
831− if slices.Equal(cachedItem.lines, rerenderedLines) {
832− return
833− }
834−
835− m.updateRenderedLines(cachedItem, rerenderedLines)
836− m.updateItemPositions(inx, cachedItem, len(rerenderedLines))
837− m.updateCachedItem(inx, cachedItem, rerenderedLines)
838− m.renderState.needsRerender = true
839−}
840−
841−// getItemLines converts an item to its rendered lines, including any gap spacing.
842−// Handles section headers with special styling.
843−func (m *model) getItemLines(item util.Model) []string {
844− var itemLines []string
845−
846− itemLines = strings.Split(item.View(), "\n")
847−
848− if m.gapSize > 0 {
849− gap := make([]string, m.gapSize)
850− itemLines = append(itemLines, gap...)
851− }
852− return itemLines
853−}
854−
855−// updateRenderedLines replaces the lines for a specific item in the overall rendered content.
856−func (m *model) updateRenderedLines(cachedItem renderedItem, newLines []string) {
857− start, end := m.getItemBounds(cachedItem)
858− totalLines := len(m.renderState.lines)
859−
860− if start >= 0 && start <= totalLines && end >= 0 && end <= totalLines {
861− m.renderState.lines = slices.Delete(m.renderState.lines, start, end)
862− m.renderState.lines = slices.Insert(m.renderState.lines, start, newLines...)
863− }
864−}
865−
866−// getItemBounds calculates the start and end line positions for an item.
867−// Handles both forward and reverse rendering modes.
868−func (m *model) getItemBounds(cachedItem renderedItem) (start, end int) {
869− start = cachedItem.start
870− end = start + cachedItem.height
871−
872− if m.viewState.reverse {
873− totalLines := len(m.renderState.lines)
874− end = totalLines - cachedItem.start
875− start = end - cachedItem.height
876− }
877− return start, end
878−}
879−
880−// updateItemPositions recalculates positions for items after the changed item.
881−// This is necessary when an item's height changes, affecting subsequent items.
882−func (m *model) updateItemPositions(inx int, cachedItem renderedItem, newHeight int) {
883− if cachedItem.height == newHeight {
884− return
885− }
886−
887− if inx == len(m.filteredItems)-1 {
888− m.renderState.finalHeight = max(0, cachedItem.start+newHeight-m.listHeight())
889− }
890−
891− currentStart := cachedItem.start + newHeight
892− if m.viewState.reverse {
893− m.updatePositionsReverse(inx, currentStart)
894− } else {
895− m.updatePositionsForward(inx, currentStart)
896− }
897−}
898−
899−// updatePositionsForward updates positions for items after the changed item in forward mode.
900−func (m *model) updatePositionsForward(inx int, currentStart int) {
901− for i := inx + 1; i < len(m.filteredItems); i++ {
902− if existing, ok := m.renderState.items[i]; ok {
903− existing.start = currentStart
904− currentStart += existing.height
905− m.renderState.items[i] = existing
906− } else {
907− break
908− }
909− }
910−}
911−
912−// updatePositionsReverse updates positions for items before the changed item in reverse mode.
913−func (m *model) updatePositionsReverse(inx int, currentStart int) {
914− for i := inx - 1; i >= 0; i-- {
915− if existing, ok := m.renderState.items[i]; ok {
916− existing.start = currentStart
917− currentStart += existing.height
918− m.renderState.items[i] = existing
919− } else {
920− break
921− }
922− }
923−}
924−
925−// updateCachedItem updates the cached rendering information for a specific item.
926−func (m *model) updateCachedItem(inx int, cachedItem renderedItem, newLines []string) {
927− m.renderState.items[inx] = renderedItem{
928− lines: newLines,
929− start: cachedItem.start,
930− height: len(newLines),
931− }
932−}
933−
934−// increaseOffset scrolls the list down by increasing the offset.
935−// Respects the final height limit to prevent scrolling past the end.
936−func (m *model) increaseOffset(n int) {
937− if m.renderState.finalHeight > NoFinalHeight {
938− if m.viewState.offset < m.renderState.finalHeight {
939− m.viewState.offset += n
940− if m.viewState.offset > m.renderState.finalHeight {
941− m.viewState.offset = m.renderState.finalHeight
942− }
943− m.renderState.needsRerender = true
944− }
945− } else {
946− m.viewState.offset += n
947− m.renderState.needsRerender = true
948− }
949−}
950−
951−// decreaseOffset scrolls the list up by decreasing the offset.
952−// Prevents scrolling above the beginning of the list.
953−func (m *model) decreaseOffset(n int) {
954− if m.viewState.offset > 0 {
955− m.viewState.offset -= n
956− if m.viewState.offset < 0 {
957− m.viewState.offset = 0
958− }
959− m.renderState.needsRerender = true
960− }
961−}
962−
963−// UpdateItem replaces an item at the specified index with a new item.
964−// Handles focus management and triggers re-rendering as needed.
965−func (m *model) UpdateItem(inx int, item util.Model) {
966− if inx < 0 || inx >= len(m.filteredItems) {
967− return
968− }
969− m.filteredItems[inx] = item
970− if m.selectionState.selectedIndex == inx {
971− m.focusSelected()
972− }
973− m.setItemSize(inx)
974− m.rerenderItem(inx)
975− m.renderState.needsRerender = true
976−}
977−
978−// GetSize returns the current dimensions of the list.
979−func (m *model) GetSize() (int, int) {
980− return m.viewState.width, m.viewState.height
981−}
982−
983−// SetSize updates the list dimensions and triggers a complete re-render.
984−// Also updates the size of all items that support sizing.
985−func (m *model) SetSize(width int, height int) tea.Cmd {
986− if m.filterable && !m.hideFilterInput {
987− height -= 2 // adjust for input field height and border
988− }
989−
990− if m.viewState.width == width && m.viewState.height == height {
991− return nil
992− }
993− if m.viewState.height != height {
994− m.renderState.finalHeight = NoFinalHeight
995− m.viewState.height = height
996− }
997− m.viewState.width = width
998− m.ResetView()
999− if m.filterable && !m.hideFilterInput {
1000− m.input.SetWidth(m.getItemWidth() - 5)
1001− }
1002− return m.setAllItemsSize()
1003−}
1004−
1005−// getItemWidth calculates the available width for items, accounting for padding.
1006−func (m *model) getItemWidth() int {
1007− width := m.viewState.width
1008− switch len(m.padding) {
1009− case 1:
1010− width -= m.padding[0] * 2
1011− case 2, 3:
1012− width -= m.padding[1] * 2
1013− case 4:
1014− width -= m.padding[1] + m.padding[3]
1015− }
1016− return max(0, width)
1017−}
1018−
1019−// setItemSize updates the size of a specific item if it supports sizing.
1020−func (m *model) setItemSize(inx int) tea.Cmd {
1021− if inx < 0 || inx >= len(m.filteredItems) {
1022− return nil
1023− }
1024− if i, ok := m.filteredItems[inx].(layout.Sizeable); ok {
1025− return i.SetSize(m.getItemWidth(), 0)
1026− }
1027− return nil
1028−}
1029−
1030−// setAllItemsSize updates the size of all items that support sizing.
1031−func (m *model) setAllItemsSize() tea.Cmd {
1032− var cmds []tea.Cmd
1033− for i := range m.filteredItems {
1034− if cmd := m.setItemSize(i); cmd != nil {
1035− cmds = append(cmds, cmd)
1036− }
1037− }
1038− return tea.Batch(cmds...)
1039−}
1040−
1041−// listHeight calculates the available height for list content, accounting for padding.
1042−func (m *model) listHeight() int {
1043− height := m.viewState.height
1044− switch len(m.padding) {
1045− case 1:
1046− height -= m.padding[0] * 2
1047− case 2:
1048− height -= m.padding[0] * 2
1049− case 3, 4:
1050− height -= m.padding[0] + m.padding[2]
1051− }
1052− if m.filterable && !m.hideFilterInput {
1053− height -= lipgloss.Height(m.inputStyle.Render("dummy"))
1054− }
1055− return max(0, height)
1056−}
1057−
1058−// AppendItem adds a new item to the end of the list.
1059−// Automatically switches to reverse mode and scrolls to show the new item.
1060−func (m *model) AppendItem(item util.Model) tea.Cmd {
1061− cmds := []tea.Cmd{
1062− item.Init(),
1063− }
1064− m.allItems = append(m.allItems, item)
1065− m.filteredItems = m.allItems
1066− cmds = append(cmds, m.setItemSize(len(m.filteredItems)-1))
1067− cmds = append(cmds, m.goToBottom())
1068− m.renderState.needsRerender = true
1069− return tea.Batch(cmds...)
1070−}
1071−
1072−// DeleteItem removes an item at the specified index.
1073−// Adjusts selection if necessary and triggers a complete re-render.
1074−func (m *model) DeleteItem(i int) {
1075− if i < 0 || i >= len(m.filteredItems) {
1076− return
1077− }
1078− m.allItems = slices.Delete(m.allItems, i, i+1)
1079− delete(m.renderState.items, i)
1080− m.filteredItems = m.allItems
1081−
1082− if m.selectionState.selectedIndex == i && m.selectionState.selectedIndex > 0 {
1083− m.selectionState.selectedIndex--
1084− } else if m.selectionState.selectedIndex > i {
1085− m.selectionState.selectedIndex--
1086− }
1087−
1088− m.ResetView()
1089− m.renderState.needsRerender = true
1090−}
1091−
1092−// PrependItem adds a new item to the beginning of the list.
1093−// Adjusts cached positions and selection index, then switches to forward mode.
1094−func (m *model) PrependItem(item util.Model) tea.Cmd {
1095− cmds := []tea.Cmd{item.Init()}
1096− m.allItems = append([]util.Model{item}, m.allItems...)
1097− m.filteredItems = m.allItems
1098−
1099− // Shift all cached item indices by 1
1100− newItems := make(map[int]renderedItem, len(m.renderState.items))
1101− for k, v := range m.renderState.items {
1102− newItems[k+1] = v
1103− }
1104− m.renderState.items = newItems
1105−
1106− if m.selectionState.selectedIndex >= 0 {
1107− m.selectionState.selectedIndex++
1108− }
1109−
1110− cmds = append(cmds, m.goToTop())
1111− cmds = append(cmds, m.setItemSize(0))
1112− m.renderState.needsRerender = true
1113− return tea.Batch(cmds...)
1114−}
1115−
1116−// setReverse switches between forward and reverse rendering modes.
1117−func (m *model) setReverse(reverse bool) {
1118− if reverse {
1119− m.goToBottom()
1120− } else {
1121− m.goToTop()
1122− }
1123−}
1124−
1125−// SetItems replaces all items in the list with a new set.
1126−// Initializes all items, sets their sizes, and establishes initial selection.
1127−// Ensures the initial selection skips section headers.
1128−func (m *model) SetItems(items []util.Model) tea.Cmd {
1129− m.allItems = items
1130− m.filteredItems = items
1131− cmds := []tea.Cmd{m.setAllItemsSize()}
1132−
1133− for _, item := range m.filteredItems {
1134− cmds = append(cmds, item.Init())
1135− }
1136−
1137− if len(m.filteredItems) > 0 {
1138− if m.viewState.reverse {
1139− m.selectionState.selectedIndex = m.findLastSelectableItem()
1140− } else {
1141− m.selectionState.selectedIndex = m.findFirstSelectableItem()
1142− }
1143− if cmd := m.focusSelected(); cmd != nil {
1144− cmds = append(cmds, cmd)
1145− }
1146− } else {
1147− m.selectionState.selectedIndex = NoSelection
1148− }
1149−
1150− m.ResetView()
1151− return tea.Batch(cmds...)
1152−}
1153−
1154−// section represents a group of items under a section header.
1155−type section struct {
1156− header SectionHeader
1157− items []util.Model
1158−}
1159−
1160−// parseSections parses the flat item list into sections.
1161−func (m *model) parseSections() []section {
1162− var sections []section
1163− var currentSection *section
1164−
1165− for _, item := range m.allItems {
1166− if header, ok := item.(SectionHeader); ok && header.IsSectionHeader() {
1167− // Start a new section
1168− if currentSection != nil {
1169− sections = append(sections, *currentSection)
1170− }
1171− currentSection = &section{
1172− header: header,
1173− items: []util.Model{},
1174− }
1175− } else if currentSection != nil {
1176− // Add item to current section
1177− currentSection.items = append(currentSection.items, item)
1178− } else {
1179− // Item without a section header - create an implicit section
1180− if len(sections) == 0 || sections[len(sections)-1].header != nil {
1181− sections = append(sections, section{
1182− header: nil,
1183− items: []util.Model{item},
1184− })
1185− } else {
1186− // Add to the last implicit section
1187− sections[len(sections)-1].items = append(sections[len(sections)-1].items, item)
1188− }
1189− }
1190− }
1191−
1192− // Don't forget the last section
1193− if currentSection != nil {
1194− sections = append(sections, *currentSection)
1195− }
1196−
1197− return sections
1198−}
1199−
1200−// flattenSections converts sections back to a flat list.
1201−func (m *model) flattenSections(sections []section) []util.Model {
1202− var result []util.Model
1203−
1204− for _, sect := range sections {
1205− if sect.header != nil {
1206− result = append(result, sect.header)
1207− }
1208− result = append(result, sect.items...)
1209− }
1210−
1211− return result
1212−}
1213−
1214−func (m *model) Filter(search string) tea.Cmd {
1215− var cmds []tea.Cmd
1216− search = strings.TrimSpace(search)
1217− search = strings.ToLower(search)
1218−
1219− // Clear focus and match indexes from all items
1220− for _, item := range m.allItems {
1221− if i, ok := item.(layout.Focusable); ok {
1222− cmds = append(cmds, i.Blur())
1223− }
1224− if i, ok := item.(HasMatchIndexes); ok {
1225− i.MatchIndexes(make([]int, 0))
1226− }
1227− }
1228−
1229− if search == "" {
1230− cmds = append(cmds, m.SetItems(m.allItems))
1231− return tea.Batch(cmds...)
1232− }
1233−
1234− // Parse items into sections
1235− sections := m.parseSections()
1236− var filteredSections []section
1237−
1238− for _, sect := range sections {
1239− filteredSection := m.filterSection(sect, search)
1240− if filteredSection != nil {
1241− filteredSections = append(filteredSections, *filteredSection)
1242− }
1243− }
1244−
1245− // Rebuild flat list from filtered sections
1246− m.filteredItems = m.flattenSections(filteredSections)
1247−
1248− // Set initial selection
1249− if len(m.filteredItems) > 0 {
1250− if m.viewState.reverse {
1251− slices.Reverse(m.filteredItems)
1252− m.selectionState.selectedIndex = m.findLastSelectableItem()
1253− } else {
1254− m.selectionState.selectedIndex = m.findFirstSelectableItem()
1255− }
1256− if cmd := m.focusSelected(); cmd != nil {
1257− cmds = append(cmds, cmd)
1258− }
1259− } else {
1260− m.selectionState.selectedIndex = NoSelection
1261− }
1262−
1263− m.ResetView()
1264− return tea.Batch(cmds...)
1265−}
1266−
1267−// filterSection filters items within a section and returns the section if it has matches.
1268−func (m *model) filterSection(sect section, search string) *section {
1269− var matchedItems []util.Model
1270− var hasHeaderMatch bool
1271−
1272− // Check if section header itself matches
1273− if sect.header != nil {
1274− headerText := strings.ToLower(sect.header.View())
1275− if strings.Contains(headerText, search) {
1276− hasHeaderMatch = true
1277− // If header matches, include all items in the section
1278− matchedItems = sect.items
1279− }
1280− }
1281−
1282− // If header didn't match, filter items within the section
1283− if !hasHeaderMatch && len(sect.items) > 0 {
1284− // Create words array for items in this section
1285− words := make([]string, len(sect.items))
1286− for i, item := range sect.items {
1287− if f, ok := item.(HasFilterValue); ok {
1288− words[i] = strings.ToLower(f.FilterValue())
1289− } else {
1290− words[i] = ""
1291− }
1292− }
1293−
1294− // Find matches within this section
1295− matches := fuzzy.Find(search, words)
1296−
1297− // Sort matches by score but preserve relative order for equal scores
1298− sort.SliceStable(matches, func(i, j int) bool {
1299− return matches[i].Score > matches[j].Score
1300− })
1301−
1302− // Build matched items list
1303− for _, match := range matches {
1304− item := sect.items[match.Index]
1305− if i, ok := item.(HasMatchIndexes); ok {
1306− i.MatchIndexes(match.MatchedIndexes)
1307− }
1308− matchedItems = append(matchedItems, item)
1309− }
1310− }
1311−
1312− // Return section only if it has matches
1313− if len(matchedItems) > 0 {
1314− return &section{
1315− header: sect.header,
1316− items: matchedItems,
1317− }
1318− }
1319−
1320− return nil
1321−}
1322−
1323−// SelectedIndex returns the index of the currently selected item.
1324−func (m *model) SelectedIndex() int {
1325− if m.selectionState.selectedIndex < 0 || m.selectionState.selectedIndex >= len(m.filteredItems) {
1326− return NoSelection
1327− }
1328− return m.selectionState.selectedIndex
1329−}
1330−
1331−// SetSelected sets the selected item by index and automatically scrolls to make it visible.
1332−// If the index is invalid or points to a section header, it finds the nearest selectable item.
1333−func (m *model) SetSelected(index int) tea.Cmd {
1334− changeNeeded := m.selectionState.selectedIndex - index
1335− cmds := []tea.Cmd{}
1336− if changeNeeded < 0 {
1337− for range -changeNeeded {
1338− cmds = append(cmds, m.selectNextItem())
1339− m.renderVisible()
1340− }
1341− } else if changeNeeded > 0 {
1342− for range changeNeeded {
1343− cmds = append(cmds, m.selectPreviousItem())
1344− m.renderVisible()
1345− }
1346− }
1347− return tea.Batch(cmds...)
1348−}
1349−
1350−// Blur implements ListModel.
1351−func (m *model) Blur() tea.Cmd {
1352− m.isFocused = false
1353− cmd := m.blurSelected()
1354− return cmd
1355−}
1356−
1357−// Focus implements ListModel.
1358−func (m *model) Focus() tea.Cmd {
1359− m.isFocused = true
1360− cmd := m.focusSelected()
1361− return cmd
1362−}
1363−
1364−// IsFocused implements ListModel.
1365−func (m *model) IsFocused() bool {
1366− return m.isFocused
1367−}
1368−
1369−func (m *model) SetFilterPlaceholder(placeholder string) {
1370− m.input.Placeholder = placeholder
1371−}
internal/tui/components/dialogs/commands/commands.go+38−29
@@ -10,10 +10,9 @@ import (
1010 "github.com/charmbracelet/crush/internal/config"
1111 "github.com/charmbracelet/crush/internal/llm/prompt"
1212 "github.com/charmbracelet/crush/internal/tui/components/chat"
13− "github.com/charmbracelet/crush/internal/tui/components/completions"
1413 "github.com/charmbracelet/crush/internal/tui/components/core"
15− "github.com/charmbracelet/crush/internal/tui/components/core/list"
1614 "github.com/charmbracelet/crush/internal/tui/components/dialogs"
15+ "github.com/charmbracelet/crush/internal/tui/exp/list"
1716 "github.com/charmbracelet/crush/internal/tui/styles"
1817 "github.com/charmbracelet/crush/internal/tui/util"
1918)
@@ -29,6 +28,8 @@ const (
2928 UserCommands
3029)
3130
31+type listModel = list.FilterableList[list.CompletionItem[Command]]
32+
3233// Command represents a command that can be executed
3334type Command struct {
3435 ID string
@@ -48,7 +49,7 @@ type commandDialogCmp struct {
4849 wWidth int // Width of the terminal window
4950 wHeight int // Height of the terminal window
5051
51− commandList list.ListModel
52+ commandList listModel
5253 keyMap CommandsDialogKeyMap
5354 help help.Model
5455 commandType int // SystemCommands or UserCommands
@@ -67,24 +68,23 @@ type (
6768)
6869
6970func NewCommandDialog(sessionID string) CommandsDialog {
70− listKeyMap := list.DefaultKeyMap()
7171 keyMap := DefaultCommandsDialogKeyMap()
72−
72+ listKeyMap := list.DefaultKeyMap()
7373 listKeyMap.Down.SetEnabled(false)
7474 listKeyMap.Up.SetEnabled(false)
75− listKeyMap.HalfPageDown.SetEnabled(false)
76− listKeyMap.HalfPageUp.SetEnabled(false)
77− listKeyMap.Home.SetEnabled(false)
78− listKeyMap.End.SetEnabled(false)
79−
8075 listKeyMap.DownOneItem = keyMap.Next
8176 listKeyMap.UpOneItem = keyMap.Previous
8277
8378 t := styles.CurrentTheme()
84− commandList := list.New(
85− list.WithFilterable(true),
86− list.WithKeyMap(listKeyMap),
87− list.WithWrapNavigation(true),
79+ inputStyle := t.S().Base.PaddingLeft(1).PaddingBottom(1)
80+ commandList := list.NewFilterableList(
81+ []list.CompletionItem[Command]{},
82+ list.WithFilterInputStyle(inputStyle),
83+ list.WithFilterListOptions(
84+ list.WithKeyMap(listKeyMap),
85+ list.WithWrapNavigation(),
86+ list.WithResizeByList(),
87+ ),
8888 )
8989 help := help.New()
9090 help.Styles = t.S().Help
@@ -103,10 +103,8 @@ func (c *commandDialogCmp) Init() tea.Cmd {
103103 if err != nil {
104104 return util.ReportError(err)
105105 }
106−
107106 c.userCommands = commands
108− c.SetCommandType(c.commandType)
109− return c.commandList.Init()
107+ return c.SetCommandType(c.commandType)
110108}
111109
112110func (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) {
114112 case tea.WindowSizeMsg:
115113 c.wWidth = msg.Width
116114 c.wHeight = msg.Height
117− c.SetCommandType(c.commandType)
118115 return c, c.commandList.SetSize(c.listWidth(), c.listHeight())
119116 case tea.KeyPressMsg:
120117 switch {
121118 case key.Matches(msg, c.keyMap.Select):
122− selectedItemInx := c.commandList.SelectedIndex()
123− if selectedItemInx == list.NoSelection {
119+ selectedItem := c.commandList.SelectedItem()
120+ if selectedItem == nil {
124121 return c, nil // No item selected, do nothing
125122 }
126− items := c.commandList.Items()
127− selectedItem := items[selectedItemInx].(completions.CompletionItem).Value().(Command)
123+ command := (*selectedItem).Value()
128124 return c, tea.Sequence(
129125 util.CmdHandler(dialogs.CloseDialogMsg{}),
130− selectedItem.Handler(selectedItem),
126+ command.Handler(command),
131127 )
132128 case key.Matches(msg, c.keyMap.Tab):
129+ if len(c.userCommands) == 0 {
130+ return c, nil
131+ }
133132 // Toggle command type between System and User commands
134133 if c.commandType == SystemCommands {
135134 return c, c.SetCommandType(UserCommands)
@@ -140,7 +139,7 @@ func (c *commandDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
140139 return c, util.CmdHandler(dialogs.CloseDialogMsg{})
141140 default:
142141 u, cmd := c.commandList.Update(msg)
143− c.commandList = u.(list.ListModel)
142+ c.commandList = u.(listModel)
144143 return c, cmd
145144 }
146145 }
@@ -151,9 +150,14 @@ func (c *commandDialogCmp) View() string {
151150 t := styles.CurrentTheme()
152151 listView := c.commandList
153152 radio := c.commandTypeRadio()
153+
154+ header := t.S().Base.Padding(0, 1, 1, 1).Render(core.Title("Commands", c.width-lipgloss.Width(radio)-5) + " " + radio)
155+ if len(c.userCommands) == 0 {
156+ header = t.S().Base.Padding(0, 1, 1, 1).Render(core.Title("Commands", c.width-4))
157+ }
154158 content := lipgloss.JoinVertical(
155159 lipgloss.Left,
156− t.S().Base.Padding(0, 1, 1, 1).Render(core.Title("Commands", c.width-lipgloss.Width(radio)-5)+" "+radio),
160+ header,
157161 listView.View(),
158162 "",
159163 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 {
197201 commands = c.userCommands
198202 }
199203
200− commandItems := []util.Model{}
204+ commandItems := []list.CompletionItem[Command]{}
201205 for _, cmd := range commands {
202− opts := []completions.CompletionOption{}
206+ opts := []list.CompletionItemOption{
207+ list.WithCompletionID(cmd.ID),
208+ }
203209 if cmd.Shortcut != "" {
204− opts = append(opts, completions.WithShortcut(cmd.Shortcut))
210+ opts = append(
211+ opts,
212+ list.WithCompletionShortcut(cmd.Shortcut),
213+ )
205214 }
206− commandItems = append(commandItems, completions.NewCompletionItem(cmd.Title, cmd, opts...))
215+ commandItems = append(commandItems, list.NewCompletionItem(cmd.Title, cmd, opts...))
207216 }
208217 return c.commandList.SetItems(commandItems)
209218}
internal/tui/components/dialogs/commands/item.godeleted−69
@@ -1,69 +0,0 @@
1−package commands
2−
3−import (
4− tea "github.com/charmbracelet/bubbletea/v2"
5− "github.com/charmbracelet/crush/internal/tui/components/core"
6− "github.com/charmbracelet/crush/internal/tui/components/core/layout"
7− "github.com/charmbracelet/crush/internal/tui/components/core/list"
8− "github.com/charmbracelet/crush/internal/tui/styles"
9− "github.com/charmbracelet/crush/internal/tui/util"
10− "github.com/charmbracelet/x/ansi"
11−)
12−
13−type ItemSection interface {
14− util.Model
15− layout.Sizeable
16− list.SectionHeader
17− SetInfo(info string)
18−}
19−type itemSectionModel struct {
20− width int
21− title string
22− info string
23−}
24−
25−func NewItemSection(title string) ItemSection {
26− return &itemSectionModel{
27− title: title,
28− }
29−}
30−
31−func (m *itemSectionModel) Init() tea.Cmd {
32− return nil
33−}
34−
35−func (m *itemSectionModel) Update(tea.Msg) (tea.Model, tea.Cmd) {
36− return m, nil
37−}
38−
39−func (m *itemSectionModel) View() string {
40− t := styles.CurrentTheme()
41− title := ansi.Truncate(m.title, m.width-2, "…")
42− style := t.S().Base.Padding(1, 1, 0, 1)
43− title = t.S().Muted.Render(title)
44− section := ""
45− if m.info != "" {
46− section = core.SectionWithInfo(title, m.width-2, m.info)
47− } else {
48− section = core.Section(title, m.width-2)
49− }
50−
51− return style.Render(section)
52−}
53−
54−func (m *itemSectionModel) GetSize() (int, int) {
55− return m.width, 1
56−}
57−
58−func (m *itemSectionModel) SetSize(width int, height int) tea.Cmd {
59− m.width = width
60− return nil
61−}
62−
63−func (m *itemSectionModel) IsSectionHeader() bool {
64− return true
65−}
66−
67−func (m *itemSectionModel) SetInfo(info string) {
68− m.info = info
69−}
internal/tui/components/dialogs/models/list.go+72−31
@@ -7,27 +7,36 @@ import (
77 tea "github.com/charmbracelet/bubbletea/v2"
88 "github.com/charmbracelet/catwalk/pkg/catwalk"
99 "github.com/charmbracelet/crush/internal/config"
10− "github.com/charmbracelet/crush/internal/tui/components/completions"
11− "github.com/charmbracelet/crush/internal/tui/components/core/list"
12− "github.com/charmbracelet/crush/internal/tui/components/dialogs/commands"
10+ "github.com/charmbracelet/crush/internal/tui/exp/list"
1311 "github.com/charmbracelet/crush/internal/tui/styles"
1412 "github.com/charmbracelet/crush/internal/tui/util"
15− "github.com/charmbracelet/lipgloss/v2"
1613)
1714
15+type listModel = list.FilterableGroupList[list.CompletionItem[ModelOption]]
16+
1817type ModelListComponent struct {
19− list list.ListModel
18+ list listModel
2019 modelType int
2120 providers []catwalk.Provider
2221}
2322
24−func NewModelListComponent(keyMap list.KeyMap, inputStyle lipgloss.Style, inputPlaceholder string) *ModelListComponent {
25− modelList := list.New(
26− list.WithFilterable(true),
23+func NewModelListComponent(keyMap list.KeyMap, inputPlaceholder string, shouldResize bool) *ModelListComponent {
24+ t := styles.CurrentTheme()
25+ inputStyle := t.S().Base.PaddingLeft(1).PaddingBottom(1)
26+ options := []list.ListOption{
2727 list.WithKeyMap(keyMap),
28− list.WithInputStyle(inputStyle),
28+ list.WithWrapNavigation(),
29+ }
30+ if shouldResize {
31+ options = append(options, list.WithResizeByList())
32+ }
33+ modelList := list.NewFilterableGroupedList(
34+ []list.Group[list.CompletionItem[ModelOption]]{},
35+ list.WithFilterInputStyle(inputStyle),
2936 list.WithFilterPlaceholder(inputPlaceholder),
30− list.WithWrapNavigation(true),
37+ list.WithFilterListOptions(
38+ options...,
39+ ),
3140 )
3241
3342 return &ModelListComponent{
@@ -51,7 +60,7 @@ func (m *ModelListComponent) Init() tea.Cmd {
5160
5261func (m *ModelListComponent) Update(msg tea.Msg) (*ModelListComponent, tea.Cmd) {
5362 u, cmd := m.list.Update(msg)
54− m.list = u.(list.ListModel)
63+ m.list = u.(listModel)
5564 return m, cmd
5665}
5766
@@ -67,21 +76,23 @@ func (m *ModelListComponent) SetSize(width, height int) tea.Cmd {
6776 return m.list.SetSize(width, height)
6877}
6978
70−func (m *ModelListComponent) Items() []util.Model {
71− return m.list.Items()
72−}
73−
74−func (m *ModelListComponent) SelectedIndex() int {
75− return m.list.SelectedIndex()
79+func (m *ModelListComponent) SelectedModel() *ModelOption {
80+ s := m.list.SelectedItem()
81+ if s == nil {
82+ return nil
83+ }
84+ sv := *s
85+ model := sv.Value()
86+ return &model
7687}
7788
7889func (m *ModelListComponent) SetModelType(modelType int) tea.Cmd {
7990 t := styles.CurrentTheme()
8091 m.modelType = modelType
8192
82− modelItems := []util.Model{}
93+ var groups []list.Group[list.CompletionItem[ModelOption]]
8394 // first none section
84− selectIndex := 1
95+ selectedItemID := ""
8596
8697 cfg := config.Get()
8798 var currentModel config.SelectedModel
@@ -140,18 +151,28 @@ func (m *ModelListComponent) SetModelType(modelType int) tea.Cmd {
140151 if name == "" {
141152 name = string(configProvider.ID)
142153 }
143− section := commands.NewItemSection(name)
154+ section := list.NewItemSection(name)
144155 section.SetInfo(configured)
145− modelItems = append(modelItems, section)
156+ group := list.Group[list.CompletionItem[ModelOption]]{
157+ Section: section,
158+ }
146159 for _, model := range configProvider.Models {
147− modelItems = append(modelItems, completions.NewCompletionItem(model.Name, ModelOption{
160+ item := list.NewCompletionItem(model.Name, ModelOption{
148161 Provider: configProvider,
149162 Model: model,
150− }))
163+ },
164+ list.WithCompletionID(
165+ fmt.Sprintf("%s:%s", providerConfig.ID, model.ID),
166+ ),
167+ )
168+
169+ group.Items = append(group.Items, item)
151170 if model.ID == currentModel.Model && string(configProvider.ID) == currentModel.Provider {
152− selectIndex = len(modelItems) - 1 // Set the selected index to the current model
171+ selectedItemID = item.ID()
153172 }
154173 }
174+ groups = append(groups, group)
175+
155176 addedProviders[providerID] = true
156177 }
157178 }
@@ -173,23 +194,43 @@ func (m *ModelListComponent) SetModelType(modelType int) tea.Cmd {
173194 name = string(provider.ID)
174195 }
175196
176− section := commands.NewItemSection(name)
197+ section := list.NewItemSection(name)
177198 if _, ok := cfg.Providers.Get(string(provider.ID)); ok {
178199 section.SetInfo(configured)
179200 }
180− modelItems = append(modelItems, section)
201+ group := list.Group[list.CompletionItem[ModelOption]]{
202+ Section: section,
203+ }
181204 for _, model := range provider.Models {
182− modelItems = append(modelItems, completions.NewCompletionItem(model.Name, ModelOption{
205+ item := list.NewCompletionItem(model.Name, ModelOption{
183206 Provider: provider,
184207 Model: model,
185− }))
208+ },
209+ list.WithCompletionID(
210+ fmt.Sprintf("%s:%s", provider.ID, model.ID),
211+ ),
212+ )
213+ group.Items = append(group.Items, item)
186214 if model.ID == currentModel.Model && string(provider.ID) == currentModel.Provider {
187− selectIndex = len(modelItems) - 1 // Set the selected index to the current model
215+ selectedItemID = item.ID()
188216 }
189217 }
218+ groups = append(groups, group)
219+ }
220+
221+ var cmds []tea.Cmd
222+
223+ cmd := m.list.SetGroups(groups)
224+
225+ if cmd != nil {
226+ cmds = append(cmds, cmd)
227+ }
228+ cmd = m.list.SetSelected(selectedItemID)
229+ if cmd != nil {
230+ cmds = append(cmds, cmd)
190231 }
191232
192− return tea.Sequence(m.list.SetItems(modelItems), m.list.SetSelected(selectIndex))
233+ return tea.Sequence(cmds...)
193234}
194235
195236// GetModelType returns the current model type
@@ -198,7 +239,7 @@ func (m *ModelListComponent) GetModelType() int {
198239}
199240
200241func (m *ModelListComponent) SetInputPlaceholder(placeholder string) {
201− m.list.SetFilterPlaceholder(placeholder)
242+ m.list.SetInputPlaceholder(placeholder)
202243}
203244
204245func (m *ModelListComponent) SetProviders(providers []catwalk.Provider) {
internal/tui/components/dialogs/models/models.go+7−21
@@ -10,10 +10,9 @@ import (
1010 tea "github.com/charmbracelet/bubbletea/v2"
1111 "github.com/charmbracelet/catwalk/pkg/catwalk"
1212 "github.com/charmbracelet/crush/internal/config"
13− "github.com/charmbracelet/crush/internal/tui/components/completions"
1413 "github.com/charmbracelet/crush/internal/tui/components/core"
15− "github.com/charmbracelet/crush/internal/tui/components/core/list"
1614 "github.com/charmbracelet/crush/internal/tui/components/dialogs"
15+ "github.com/charmbracelet/crush/internal/tui/exp/list"
1716 "github.com/charmbracelet/crush/internal/tui/styles"
1817 "github.com/charmbracelet/crush/internal/tui/util"
1918 "github.com/charmbracelet/lipgloss/v2"
@@ -71,22 +70,16 @@ type modelDialogCmp struct {
7170}
7271
7372func NewModelDialogCmp() ModelDialog {
74− listKeyMap := list.DefaultKeyMap()
7573 keyMap := DefaultKeyMap()
7674
75+ listKeyMap := list.DefaultKeyMap()
7776 listKeyMap.Down.SetEnabled(false)
7877 listKeyMap.Up.SetEnabled(false)
79− listKeyMap.HalfPageDown.SetEnabled(false)
80− listKeyMap.HalfPageUp.SetEnabled(false)
81− listKeyMap.Home.SetEnabled(false)
82− listKeyMap.End.SetEnabled(false)
83−
8478 listKeyMap.DownOneItem = keyMap.Next
8579 listKeyMap.UpOneItem = keyMap.Previous
8680
8781 t := styles.CurrentTheme()
88− inputStyle := t.S().Base.Padding(0, 1, 0, 1)
89− modelList := NewModelListComponent(listKeyMap, inputStyle, "Choose a model for large, complex tasks")
82+ modelList := NewModelListComponent(listKeyMap, "Choose a model for large, complex tasks", true)
9083 apiKeyInput := NewAPIKeyInput()
9184 apiKeyInput.SetShowTitle(false)
9285 help := help.New()
@@ -162,12 +155,7 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
162155 )
163156 }
164157 // Normal model selection
165− selectedItemInx := m.modelList.SelectedIndex()
166− if selectedItemInx == list.NoSelection {
167− return m, nil
168− }
169− items := m.modelList.Items()
170− selectedItem := items[selectedItemInx].(completions.CompletionItem).Value().(ModelOption)
158+ selectedItem := m.modelList.SelectedModel()
171159
172160 var modelType config.SelectedModelType
173161 if m.modelList.GetModelType() == LargeModelType {
@@ -191,7 +179,7 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
191179 } else {
192180 // Provider not configured, show API key input
193181 m.needsAPIKey = true
194− m.selectedModel = &selectedItem
182+ m.selectedModel = selectedItem
195183 m.selectedModelType = modelType
196184 m.apiKeyInput.SetProviderName(selectedItem.Provider.Name)
197185 return m, nil
@@ -310,13 +298,11 @@ func (m *modelDialogCmp) style() lipgloss.Style {
310298}
311299
312300func (m *modelDialogCmp) listWidth() int {
313− return defaultWidth - 2 // 4 for padding
301+ return m.width - 2
314302}
315303
316304func (m *modelDialogCmp) listHeight() int {
317− items := m.modelList.Items()
318− listHeigh := len(items) + 2 + 4
319− return min(listHeigh, m.wHeight/2)
305+ return m.wHeight / 2
320306}
321307
322308func (m *modelDialogCmp) Position() (int, int) {
internal/tui/components/dialogs/sessions/sessions.go+29−34
@@ -6,10 +6,9 @@ import (
66 tea "github.com/charmbracelet/bubbletea/v2"
77 "github.com/charmbracelet/crush/internal/session"
88 "github.com/charmbracelet/crush/internal/tui/components/chat"
9− "github.com/charmbracelet/crush/internal/tui/components/completions"
109 "github.com/charmbracelet/crush/internal/tui/components/core"
11− "github.com/charmbracelet/crush/internal/tui/components/core/list"
1210 "github.com/charmbracelet/crush/internal/tui/components/dialogs"
11+ "github.com/charmbracelet/crush/internal/tui/exp/list"
1312 "github.com/charmbracelet/crush/internal/tui/styles"
1413 "github.com/charmbracelet/crush/internal/tui/util"
1514 "github.com/charmbracelet/lipgloss/v2"
@@ -22,6 +21,8 @@ type SessionDialog interface {
2221 dialogs.DialogModel
2322}
2423
24+type SessionsList = list.FilterableList[list.CompletionItem[session.Session]]
25+
2526type sessionDialogCmp struct {
2627 selectedInx int
2728 wWidth int
@@ -29,8 +30,7 @@ type sessionDialogCmp struct {
2930 width int
3031 selectedSessionID string
3132 keyMap KeyMap
32− sessionsList list.ListModel
33− renderedSelected bool
33+ sessionsList SessionsList
3434 help help.Model
3535}
3636
@@ -39,39 +39,31 @@ func NewSessionDialogCmp(sessions []session.Session, selectedID string) SessionD
3939 t := styles.CurrentTheme()
4040 listKeyMap := list.DefaultKeyMap()
4141 keyMap := DefaultKeyMap()
42−
4342 listKeyMap.Down.SetEnabled(false)
4443 listKeyMap.Up.SetEnabled(false)
45− listKeyMap.HalfPageDown.SetEnabled(false)
46− listKeyMap.HalfPageUp.SetEnabled(false)
47− listKeyMap.Home.SetEnabled(false)
48− listKeyMap.End.SetEnabled(false)
49−
5044 listKeyMap.DownOneItem = keyMap.Next
5145 listKeyMap.UpOneItem = keyMap.Previous
5246
53− selectedInx := 0
54− items := make([]util.Model, len(sessions))
47+ items := make([]list.CompletionItem[session.Session], len(sessions))
5548 if len(sessions) > 0 {
5649 for i, session := range sessions {
57− items[i] = completions.NewCompletionItem(session.Title, session)
58− if session.ID == selectedID {
59− selectedInx = i
60− }
50+ items[i] = list.NewCompletionItem(session.Title, session, list.WithCompletionID(session.ID))
6151 }
6252 }
6353
64− sessionsList := list.New(
65− list.WithFilterable(true),
54+ inputStyle := t.S().Base.PaddingLeft(1).PaddingBottom(1)
55+ sessionsList := list.NewFilterableList(
56+ items,
6657 list.WithFilterPlaceholder("Enter a session name"),
67− list.WithKeyMap(listKeyMap),
68− list.WithItems(items),
69− list.WithWrapNavigation(true),
58+ list.WithFilterInputStyle(inputStyle),
59+ list.WithFilterListOptions(
60+ list.WithKeyMap(listKeyMap),
61+ list.WithWrapNavigation(),
62+ ),
7063 )
7164 help := help.New()
7265 help.Styles = t.S().Help
7366 s := &sessionDialogCmp{
74− selectedInx: selectedInx,
7567 selectedSessionID: selectedID,
7668 keyMap: DefaultKeyMap(),
7769 sessionsList: sessionsList,
@@ -82,32 +74,35 @@ func NewSessionDialogCmp(sessions []session.Session, selectedID string) SessionD
8274}
8375
8476func (s *sessionDialogCmp) Init() tea.Cmd {
85− return s.sessionsList.Init()
77+ var cmds []tea.Cmd
78+ cmds = append(cmds, s.sessionsList.Init())
79+ cmds = append(cmds, s.sessionsList.Focus())
80+ return tea.Sequence(cmds...)
8681}
8782
8883func (s *sessionDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
8984 switch msg := msg.(type) {
9085 case tea.WindowSizeMsg:
86+ var cmds []tea.Cmd
9187 s.wWidth = msg.Width
9288 s.wHeight = msg.Height
93− s.width = s.wWidth / 2
94− var cmds []tea.Cmd
89+ s.width = min(120, s.wWidth-8)
90+ s.sessionsList.SetInputWidth(s.listWidth() - 2)
9591 cmds = append(cmds, s.sessionsList.SetSize(s.listWidth(), s.listHeight()))
96− if !s.renderedSelected {
97− cmds = append(cmds, s.sessionsList.SetSelected(s.selectedInx))
98− s.renderedSelected = true
92+ if s.selectedSessionID != "" {
93+ cmds = append(cmds, s.sessionsList.SetSelected(s.selectedSessionID))
9994 }
100− return s, tea.Sequence(cmds...)
95+ return s, tea.Batch(cmds...)
10196 case tea.KeyPressMsg:
10297 switch {
10398 case key.Matches(msg, s.keyMap.Select):
104− if len(s.sessionsList.Items()) > 0 {
105− items := s.sessionsList.Items()
106− selectedItemInx := s.sessionsList.SelectedIndex()
99+ selectedItem := s.sessionsList.SelectedItem()
100+ if selectedItem != nil {
101+ selected := *selectedItem
107102 return s, tea.Sequence(
108103 util.CmdHandler(dialogs.CloseDialogMsg{}),
109104 util.CmdHandler(
110− chat.SessionSelectedMsg(items[selectedItemInx].(completions.CompletionItem).Value().(session.Session)),
105+ chat.SessionSelectedMsg(selected.Value()),
111106 ),
112107 )
113108 }
@@ -115,7 +110,7 @@ func (s *sessionDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
115110 return s, util.CmdHandler(dialogs.CloseDialogMsg{})
116111 default:
117112 u, cmd := s.sessionsList.Update(msg)
118− s.sessionsList = u.(list.ListModel)
113+ s.sessionsList = u.(SessionsList)
119114 return s, cmd
120115 }
121116 }
internal/tui/exp/list/filterable.gonew file+308
@@ -0,0 +1,308 @@
1+package list
2+
3+import (
4+ "regexp"
5+ "slices"
6+ "sort"
7+ "strings"
8+
9+ "github.com/charmbracelet/bubbles/v2/key"
10+ "github.com/charmbracelet/bubbles/v2/textinput"
11+ tea "github.com/charmbracelet/bubbletea/v2"
12+ "github.com/charmbracelet/crush/internal/tui/components/core/layout"
13+ "github.com/charmbracelet/crush/internal/tui/styles"
14+ "github.com/charmbracelet/lipgloss/v2"
15+ "github.com/sahilm/fuzzy"
16+)
17+
18+type FilterableItem interface {
19+ Item
20+ FilterValue() string
21+}
22+
23+type FilterableList[T FilterableItem] interface {
24+ List[T]
25+ Cursor() *tea.Cursor
26+ SetInputWidth(int)
27+ SetInputPlaceholder(string)
28+ Filter(q string) tea.Cmd
29+}
30+
31+type HasMatchIndexes interface {
32+ MatchIndexes([]int)
33+}
34+
35+type filterableOptions struct {
36+ listOptions []ListOption
37+ placeholder string
38+ inputHidden bool
39+ inputWidth int
40+ inputStyle lipgloss.Style
41+}
42+type filterableList[T FilterableItem] struct {
43+ *list[T]
44+ *filterableOptions
45+ width, height int
46+ // stores all available items
47+ items []T
48+ input textinput.Model
49+ inputWidth int
50+ query string
51+}
52+
53+type filterableListOption func(*filterableOptions)
54+
55+func WithFilterPlaceholder(ph string) filterableListOption {
56+ return func(f *filterableOptions) {
57+ f.placeholder = ph
58+ }
59+}
60+
61+func WithFilterInputHidden() filterableListOption {
62+ return func(f *filterableOptions) {
63+ f.inputHidden = true
64+ }
65+}
66+
67+func WithFilterInputStyle(inputStyle lipgloss.Style) filterableListOption {
68+ return func(f *filterableOptions) {
69+ f.inputStyle = inputStyle
70+ }
71+}
72+
73+func WithFilterListOptions(opts ...ListOption) filterableListOption {
74+ return func(f *filterableOptions) {
75+ f.listOptions = opts
76+ }
77+}
78+
79+func WithFilterInputWidth(inputWidth int) filterableListOption {
80+ return func(f *filterableOptions) {
81+ f.inputWidth = inputWidth
82+ }
83+}
84+
85+func NewFilterableList[T FilterableItem](items []T, opts ...filterableListOption) FilterableList[T] {
86+ t := styles.CurrentTheme()
87+
88+ f := &filterableList[T]{
89+ filterableOptions: &filterableOptions{
90+ inputStyle: t.S().Base,
91+ placeholder: "Type to filter",
92+ },
93+ }
94+ for _, opt := range opts {
95+ opt(f.filterableOptions)
96+ }
97+ f.list = New[T](items, f.listOptions...).(*list[T])
98+
99+ f.updateKeyMaps()
100+ f.items = slices.Collect(f.list.items.Seq())
101+
102+ if f.inputHidden {
103+ return f
104+ }
105+
106+ ti := textinput.New()
107+ ti.Placeholder = f.placeholder
108+ ti.SetVirtualCursor(false)
109+ ti.Focus()
110+ ti.SetStyles(t.S().TextInput)
111+ f.input = ti
112+ return f
113+}
114+
115+func (f *filterableList[T]) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
116+ switch msg := msg.(type) {
117+ case tea.KeyPressMsg:
118+ switch {
119+ // handle movements
120+ case key.Matches(msg, f.keyMap.Down),
121+ key.Matches(msg, f.keyMap.Up),
122+ key.Matches(msg, f.keyMap.DownOneItem),
123+ key.Matches(msg, f.keyMap.UpOneItem),
124+ key.Matches(msg, f.keyMap.HalfPageDown),
125+ key.Matches(msg, f.keyMap.HalfPageUp),
126+ key.Matches(msg, f.keyMap.PageDown),
127+ key.Matches(msg, f.keyMap.PageUp),
128+ key.Matches(msg, f.keyMap.End),
129+ key.Matches(msg, f.keyMap.Home):
130+ u, cmd := f.list.Update(msg)
131+ f.list = u.(*list[T])
132+ return f, cmd
133+ default:
134+ if !f.inputHidden {
135+ var cmds []tea.Cmd
136+ var cmd tea.Cmd
137+ f.input, cmd = f.input.Update(msg)
138+ cmds = append(cmds, cmd)
139+
140+ if f.query != f.input.Value() {
141+ cmd = f.Filter(f.input.Value())
142+ cmds = append(cmds, cmd)
143+ }
144+ f.query = f.input.Value()
145+ return f, tea.Batch(cmds...)
146+ }
147+ }
148+ }
149+ u, cmd := f.list.Update(msg)
150+ f.list = u.(*list[T])
151+ return f, cmd
152+}
153+
154+func (f *filterableList[T]) View() string {
155+ if f.inputHidden {
156+ return f.list.View()
157+ }
158+
159+ return lipgloss.JoinVertical(
160+ lipgloss.Left,
161+ f.inputStyle.Render(f.input.View()),
162+ f.list.View(),
163+ )
164+}
165+
166+// removes bindings that are used for search
167+func (f *filterableList[T]) updateKeyMaps() {
168+ alphanumeric := regexp.MustCompile("^[a-zA-Z0-9]*$")
169+
170+ removeLettersAndNumbers := func(bindings []string) []string {
171+ var keep []string
172+ for _, b := range bindings {
173+ if len(b) != 1 {
174+ keep = append(keep, b)
175+ continue
176+ }
177+ if b == " " {
178+ continue
179+ }
180+ m := alphanumeric.MatchString(b)
181+ if !m {
182+ keep = append(keep, b)
183+ }
184+ }
185+ return keep
186+ }
187+
188+ updateBinding := func(binding key.Binding) key.Binding {
189+ newKeys := removeLettersAndNumbers(binding.Keys())
190+ if len(newKeys) == 0 {
191+ binding.SetEnabled(false)
192+ return binding
193+ }
194+ binding.SetKeys(newKeys...)
195+ return binding
196+ }
197+
198+ f.keyMap.Down = updateBinding(f.keyMap.Down)
199+ f.keyMap.Up = updateBinding(f.keyMap.Up)
200+ f.keyMap.DownOneItem = updateBinding(f.keyMap.DownOneItem)
201+ f.keyMap.UpOneItem = updateBinding(f.keyMap.UpOneItem)
202+ f.keyMap.HalfPageDown = updateBinding(f.keyMap.HalfPageDown)
203+ f.keyMap.HalfPageUp = updateBinding(f.keyMap.HalfPageUp)
204+ f.keyMap.PageDown = updateBinding(f.keyMap.PageDown)
205+ f.keyMap.PageUp = updateBinding(f.keyMap.PageUp)
206+ f.keyMap.End = updateBinding(f.keyMap.End)
207+ f.keyMap.Home = updateBinding(f.keyMap.Home)
208+}
209+
210+func (m *filterableList[T]) GetSize() (int, int) {
211+ return m.width, m.height
212+}
213+
214+func (f *filterableList[T]) SetSize(w, h int) tea.Cmd {
215+ f.width = w
216+ f.height = h
217+ if f.inputHidden {
218+ return f.list.SetSize(w, h)
219+ }
220+ if f.inputWidth == 0 {
221+ f.input.SetWidth(w)
222+ } else {
223+ f.input.SetWidth(f.inputWidth)
224+ }
225+ return f.list.SetSize(w, h-(f.inputHeight()))
226+}
227+
228+func (f *filterableList[T]) inputHeight() int {
229+ return lipgloss.Height(f.inputStyle.Render(f.input.View()))
230+}
231+
232+func (f *filterableList[T]) Filter(query string) tea.Cmd {
233+ var cmds []tea.Cmd
234+ for _, item := range f.items {
235+ if i, ok := any(item).(layout.Focusable); ok {
236+ cmds = append(cmds, i.Blur())
237+ }
238+ if i, ok := any(item).(HasMatchIndexes); ok {
239+ i.MatchIndexes(make([]int, 0))
240+ }
241+ }
242+
243+ f.selectedItem = ""
244+ if query == "" {
245+ return f.list.SetItems(f.items)
246+ }
247+
248+ words := make([]string, len(f.items))
249+ for i, item := range f.items {
250+ words[i] = strings.ToLower(item.FilterValue())
251+ }
252+
253+ matches := fuzzy.Find(query, words)
254+
255+ sort.SliceStable(matches, func(i, j int) bool {
256+ return matches[i].Score > matches[j].Score
257+ })
258+
259+ var matchedItems []T
260+ for _, match := range matches {
261+ item := f.items[match.Index]
262+ if i, ok := any(item).(HasMatchIndexes); ok {
263+ i.MatchIndexes(match.MatchedIndexes)
264+ }
265+ matchedItems = append(matchedItems, item)
266+ }
267+
268+ if f.direction == DirectionBackward {
269+ slices.Reverse(matchedItems)
270+ }
271+
272+ cmds = append(cmds, f.list.SetItems(matchedItems))
273+ return tea.Batch(cmds...)
274+}
275+
276+func (f *filterableList[T]) SetItems(items []T) tea.Cmd {
277+ f.items = items
278+ return f.list.SetItems(items)
279+}
280+
281+func (f *filterableList[T]) Cursor() *tea.Cursor {
282+ if f.inputHidden {
283+ return nil
284+ }
285+ return f.input.Cursor()
286+}
287+
288+func (f *filterableList[T]) Blur() tea.Cmd {
289+ f.input.Blur()
290+ return f.list.Blur()
291+}
292+
293+func (f *filterableList[T]) Focus() tea.Cmd {
294+ f.input.Focus()
295+ return f.list.Focus()
296+}
297+
298+func (f *filterableList[T]) IsFocused() bool {
299+ return f.list.IsFocused()
300+}
301+
302+func (f *filterableList[T]) SetInputWidth(w int) {
303+ f.inputWidth = w
304+}
305+
306+func (f *filterableList[T]) SetInputPlaceholder(ph string) {
307+ f.placeholder = ph
308+}
internal/tui/exp/list/filterable_group.gonew file+261
@@ -0,0 +1,261 @@
1+package list
2+
3+import (
4+ "regexp"
5+ "slices"
6+ "sort"
7+ "strings"
8+
9+ "github.com/charmbracelet/bubbles/v2/key"
10+ "github.com/charmbracelet/bubbles/v2/textinput"
11+ tea "github.com/charmbracelet/bubbletea/v2"
12+ "github.com/charmbracelet/crush/internal/tui/components/core/layout"
13+ "github.com/charmbracelet/crush/internal/tui/styles"
14+ "github.com/charmbracelet/lipgloss/v2"
15+ "github.com/sahilm/fuzzy"
16+)
17+
18+type FilterableGroupList[T FilterableItem] interface {
19+ GroupedList[T]
20+ Cursor() *tea.Cursor
21+ SetInputWidth(int)
22+ SetInputPlaceholder(string)
23+}
24+type filterableGroupList[T FilterableItem] struct {
25+ *groupedList[T]
26+ *filterableOptions
27+ width, height int
28+ groups []Group[T]
29+ // stores all available items
30+ input textinput.Model
31+ inputWidth int
32+ query string
33+}
34+
35+func NewFilterableGroupedList[T FilterableItem](items []Group[T], opts ...filterableListOption) FilterableGroupList[T] {
36+ t := styles.CurrentTheme()
37+
38+ f := &filterableGroupList[T]{
39+ filterableOptions: &filterableOptions{
40+ inputStyle: t.S().Base,
41+ placeholder: "Type to filter",
42+ },
43+ }
44+ for _, opt := range opts {
45+ opt(f.filterableOptions)
46+ }
47+ f.groupedList = NewGroupedList(items, f.listOptions...).(*groupedList[T])
48+
49+ f.updateKeyMaps()
50+
51+ if f.inputHidden {
52+ return f
53+ }
54+
55+ ti := textinput.New()
56+ ti.Placeholder = f.placeholder
57+ ti.SetVirtualCursor(false)
58+ ti.Focus()
59+ ti.SetStyles(t.S().TextInput)
60+ f.input = ti
61+ return f
62+}
63+
64+func (f *filterableGroupList[T]) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
65+ switch msg := msg.(type) {
66+ case tea.KeyPressMsg:
67+ switch {
68+ // handle movements
69+ case key.Matches(msg, f.keyMap.Down),
70+ key.Matches(msg, f.keyMap.Up),
71+ key.Matches(msg, f.keyMap.DownOneItem),
72+ key.Matches(msg, f.keyMap.UpOneItem),
73+ key.Matches(msg, f.keyMap.HalfPageDown),
74+ key.Matches(msg, f.keyMap.HalfPageUp),
75+ key.Matches(msg, f.keyMap.PageDown),
76+ key.Matches(msg, f.keyMap.PageUp),
77+ key.Matches(msg, f.keyMap.End),
78+ key.Matches(msg, f.keyMap.Home):
79+ u, cmd := f.groupedList.Update(msg)
80+ f.groupedList = u.(*groupedList[T])
81+ return f, cmd
82+ default:
83+ if !f.inputHidden {
84+ var cmds []tea.Cmd
85+ var cmd tea.Cmd
86+ f.input, cmd = f.input.Update(msg)
87+ cmds = append(cmds, cmd)
88+
89+ if f.query != f.input.Value() {
90+ cmd = f.Filter(f.input.Value())
91+ cmds = append(cmds, cmd)
92+ }
93+ f.query = f.input.Value()
94+ return f, tea.Batch(cmds...)
95+ }
96+ }
97+ }
98+ u, cmd := f.groupedList.Update(msg)
99+ f.groupedList = u.(*groupedList[T])
100+ return f, cmd
101+}
102+
103+func (f *filterableGroupList[T]) View() string {
104+ if f.inputHidden {
105+ return f.groupedList.View()
106+ }
107+
108+ return lipgloss.JoinVertical(
109+ lipgloss.Left,
110+ f.inputStyle.Render(f.input.View()),
111+ f.groupedList.View(),
112+ )
113+}
114+
115+// removes bindings that are used for search
116+func (f *filterableGroupList[T]) updateKeyMaps() {
117+ alphanumeric := regexp.MustCompile("^[a-zA-Z0-9]*$")
118+
119+ removeLettersAndNumbers := func(bindings []string) []string {
120+ var keep []string
121+ for _, b := range bindings {
122+ if len(b) != 1 {
123+ keep = append(keep, b)
124+ continue
125+ }
126+ if b == " " {
127+ continue
128+ }
129+ m := alphanumeric.MatchString(b)
130+ if !m {
131+ keep = append(keep, b)
132+ }
133+ }
134+ return keep
135+ }
136+
137+ updateBinding := func(binding key.Binding) key.Binding {
138+ newKeys := removeLettersAndNumbers(binding.Keys())
139+ if len(newKeys) == 0 {
140+ binding.SetEnabled(false)
141+ return binding
142+ }
143+ binding.SetKeys(newKeys...)
144+ return binding
145+ }
146+
147+ f.keyMap.Down = updateBinding(f.keyMap.Down)
148+ f.keyMap.Up = updateBinding(f.keyMap.Up)
149+ f.keyMap.DownOneItem = updateBinding(f.keyMap.DownOneItem)
150+ f.keyMap.UpOneItem = updateBinding(f.keyMap.UpOneItem)
151+ f.keyMap.HalfPageDown = updateBinding(f.keyMap.HalfPageDown)
152+ f.keyMap.HalfPageUp = updateBinding(f.keyMap.HalfPageUp)
153+ f.keyMap.PageDown = updateBinding(f.keyMap.PageDown)
154+ f.keyMap.PageUp = updateBinding(f.keyMap.PageUp)
155+ f.keyMap.End = updateBinding(f.keyMap.End)
156+ f.keyMap.Home = updateBinding(f.keyMap.Home)
157+}
158+
159+func (m *filterableGroupList[T]) GetSize() (int, int) {
160+ return m.width, m.height
161+}
162+
163+func (f *filterableGroupList[T]) SetSize(w, h int) tea.Cmd {
164+ f.width = w
165+ f.height = h
166+ if f.inputHidden {
167+ return f.groupedList.SetSize(w, h)
168+ }
169+ if f.inputWidth == 0 {
170+ f.input.SetWidth(w)
171+ } else {
172+ f.input.SetWidth(f.inputWidth)
173+ }
174+ return f.groupedList.SetSize(w, h-(f.inputHeight()))
175+}
176+
177+func (f *filterableGroupList[T]) inputHeight() int {
178+ return lipgloss.Height(f.inputStyle.Render(f.input.View()))
179+}
180+
181+func (f *filterableGroupList[T]) Filter(query string) tea.Cmd {
182+ var cmds []tea.Cmd
183+ for _, item := range slices.Collect(f.items.Seq()) {
184+ if i, ok := any(item).(layout.Focusable); ok {
185+ cmds = append(cmds, i.Blur())
186+ }
187+ if i, ok := any(item).(HasMatchIndexes); ok {
188+ i.MatchIndexes(make([]int, 0))
189+ }
190+ }
191+
192+ f.selectedItem = ""
193+ if query == "" {
194+ return f.groupedList.SetGroups(f.groups)
195+ }
196+
197+ var newGroups []Group[T]
198+ for _, g := range f.groups {
199+ words := make([]string, len(g.Items))
200+ for i, item := range g.Items {
201+ words[i] = strings.ToLower(item.FilterValue())
202+ }
203+
204+ matches := fuzzy.Find(query, words)
205+
206+ sort.SliceStable(matches, func(i, j int) bool {
207+ return matches[i].Score > matches[j].Score
208+ })
209+
210+ var matchedItems []T
211+ for _, match := range matches {
212+ item := g.Items[match.Index]
213+ if i, ok := any(item).(HasMatchIndexes); ok {
214+ i.MatchIndexes(match.MatchedIndexes)
215+ }
216+ matchedItems = append(matchedItems, item)
217+ }
218+ if len(matchedItems) > 0 {
219+ newGroups = append(newGroups, Group[T]{
220+ Section: g.Section,
221+ Items: matchedItems,
222+ })
223+ }
224+ }
225+ cmds = append(cmds, f.groupedList.SetGroups(newGroups))
226+ return tea.Batch(cmds...)
227+}
228+
229+func (f *filterableGroupList[T]) SetGroups(groups []Group[T]) tea.Cmd {
230+ f.groups = groups
231+ return f.groupedList.SetGroups(groups)
232+}
233+
234+func (f *filterableGroupList[T]) Cursor() *tea.Cursor {
235+ if f.inputHidden {
236+ return nil
237+ }
238+ return f.input.Cursor()
239+}
240+
241+func (f *filterableGroupList[T]) Blur() tea.Cmd {
242+ f.input.Blur()
243+ return f.groupedList.Blur()
244+}
245+
246+func (f *filterableGroupList[T]) Focus() tea.Cmd {
247+ f.input.Focus()
248+ return f.groupedList.Focus()
249+}
250+
251+func (f *filterableGroupList[T]) IsFocused() bool {
252+ return f.groupedList.IsFocused()
253+}
254+
255+func (f *filterableGroupList[T]) SetInputWidth(w int) {
256+ f.inputWidth = w
257+}
258+
259+func (f *filterableGroupList[T]) SetInputPlaceholder(ph string) {
260+ f.placeholder = ph
261+}
internal/tui/exp/list/filterable_test.gonew file+68
@@ -0,0 +1,68 @@
1+package list
2+
3+import (
4+ "fmt"
5+ "slices"
6+ "testing"
7+
8+ "github.com/charmbracelet/x/exp/golden"
9+ "github.com/stretchr/testify/assert"
10+)
11+
12+func TestFilterableList(t *testing.T) {
13+ t.Parallel()
14+ t.Run("should create simple filterable list", func(t *testing.T) {
15+ t.Parallel()
16+ items := []FilterableItem{}
17+ for i := range 5 {
18+ item := NewFilterableItem(fmt.Sprintf("Item %d", i))
19+ items = append(items, item)
20+ }
21+ l := NewFilterableList(
22+ items,
23+ WithFilterListOptions(WithDirectionForward()),
24+ ).(*filterableList[FilterableItem])
25+
26+ l.SetSize(100, 10)
27+ cmd := l.Init()
28+ if cmd != nil {
29+ cmd()
30+ }
31+
32+ assert.Equal(t, items[0].ID(), l.selectedItem)
33+ golden.RequireEqual(t, []byte(l.View()))
34+ })
35+}
36+
37+func TestUpdateKeyMap(t *testing.T) {
38+ t.Parallel()
39+ l := NewFilterableList(
40+ []FilterableItem{},
41+ WithFilterListOptions(WithDirectionForward()),
42+ ).(*filterableList[FilterableItem])
43+
44+ hasJ := slices.Contains(l.keyMap.Down.Keys(), "j")
45+ fmt.Println(l.keyMap.Down.Keys())
46+ hasCtrlJ := slices.Contains(l.keyMap.Down.Keys(), "ctrl+j")
47+
48+ hasUpperCaseK := slices.Contains(l.keyMap.UpOneItem.Keys(), "K")
49+
50+ assert.False(t, l.keyMap.HalfPageDown.Enabled(), "should disable keys that are only letters")
51+ assert.False(t, hasJ, "should not contain j")
52+ assert.False(t, hasUpperCaseK, "should also remove upper case K")
53+ assert.True(t, hasCtrlJ, "should still have ctrl+j")
54+}
55+
56+type filterableItem struct {
57+ *selectableItem
58+}
59+
60+func NewFilterableItem(content string) FilterableItem {
61+ return &filterableItem{
62+ selectableItem: NewSelectableItem(content).(*selectableItem),
63+ }
64+}
65+
66+func (f *filterableItem) FilterValue() string {
67+ return f.content
68+}
internal/tui/exp/list/grouped.gonew file+103
@@ -0,0 +1,103 @@
1+package list
2+
3+import (
4+ "slices"
5+
6+ tea "github.com/charmbracelet/bubbletea/v2"
7+ "github.com/charmbracelet/crush/internal/csync"
8+ "github.com/charmbracelet/crush/internal/tui/components/core/layout"
9+ "github.com/charmbracelet/crush/internal/tui/util"
10+)
11+
12+type Group[T Item] struct {
13+ Section ItemSection
14+ Items []T
15+}
16+type GroupedList[T Item] interface {
17+ util.Model
18+ layout.Sizeable
19+ Items() []Item
20+ Groups() []Group[T]
21+ SetGroups([]Group[T]) tea.Cmd
22+ MoveUp(int) tea.Cmd
23+ MoveDown(int) tea.Cmd
24+ GoToTop() tea.Cmd
25+ GoToBottom() tea.Cmd
26+ SelectItemAbove() tea.Cmd
27+ SelectItemBelow() tea.Cmd
28+ SetSelected(string) tea.Cmd
29+ SelectedItem() *T
30+}
31+type groupedList[T Item] struct {
32+ *list[Item]
33+ groups []Group[T]
34+}
35+
36+func NewGroupedList[T Item](groups []Group[T], opts ...ListOption) GroupedList[T] {
37+ list := &list[Item]{
38+ confOptions: &confOptions{
39+ direction: DirectionForward,
40+ keyMap: DefaultKeyMap(),
41+ focused: true,
42+ },
43+ items: csync.NewSlice[Item](),
44+ indexMap: csync.NewMap[string, int](),
45+ renderedItems: csync.NewMap[string, renderedItem](),
46+ }
47+ for _, opt := range opts {
48+ opt(list.confOptions)
49+ }
50+
51+ return &groupedList[T]{
52+ list: list,
53+ }
54+}
55+
56+func (g *groupedList[T]) Init() tea.Cmd {
57+ g.convertItems()
58+ return g.render()
59+}
60+
61+func (l *groupedList[T]) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
62+ u, cmd := l.list.Update(msg)
63+ l.list = u.(*list[Item])
64+ return l, cmd
65+}
66+
67+func (g *groupedList[T]) SelectedItem() *T {
68+ item := g.list.SelectedItem()
69+ if item == nil {
70+ return nil
71+ }
72+ dRef := *item
73+ c, ok := any(dRef).(T)
74+ if !ok {
75+ return nil
76+ }
77+ return &c
78+}
79+
80+func (g *groupedList[T]) convertItems() {
81+ var items []Item
82+ for _, g := range g.groups {
83+ items = append(items, g.Section)
84+ for _, g := range g.Items {
85+ items = append(items, g)
86+ }
87+ }
88+ g.items.SetSlice(items)
89+}
90+
91+func (g *groupedList[T]) SetGroups(groups []Group[T]) tea.Cmd {
92+ g.groups = groups
93+ g.convertItems()
94+ return g.SetItems(slices.Collect(g.items.Seq()))
95+}
96+
97+func (g *groupedList[T]) Groups() []Group[T] {
98+ return g.groups
99+}
100+
101+func (g *groupedList[T]) Items() []Item {
102+ return g.list.Items()
103+}
internal/tui/components/completions/item.go → internal/tui/exp/list/items.gorenamed+140−32
@@ -1,81 +1,108 @@
1−package completions
1+package list
22
33import (
44 "image/color"
55
66 tea "github.com/charmbracelet/bubbletea/v2"
7+ "github.com/charmbracelet/crush/internal/tui/components/core"
78 "github.com/charmbracelet/crush/internal/tui/components/core/layout"
8− "github.com/charmbracelet/crush/internal/tui/components/core/list"
99 "github.com/charmbracelet/crush/internal/tui/styles"
10− "github.com/charmbracelet/crush/internal/tui/util"
1110 "github.com/charmbracelet/lipgloss/v2"
1211 "github.com/charmbracelet/x/ansi"
12+ "github.com/google/uuid"
1313 "github.com/rivo/uniseg"
1414)
1515
16−type CompletionItem interface {
17− util.Model
16+type Indexable interface {
17+ SetIndex(int)
18+}
19+
20+type CompletionItem[T any] interface {
21+ FilterableItem
1822 layout.Focusable
1923 layout.Sizeable
20− list.HasMatchIndexes
21− list.HasFilterValue
22− Value() any
24+ HasMatchIndexes
25+ Value() T
26+ Text() string
2327}
2428
25−type completionItemCmp struct {
29+type completionItemCmp[T any] struct {
2630 width int
31+ id string
2732 text string
28− value any
33+ value T
2934 focus bool
3035 matchIndexes []int
3136 bgColor color.Color
3237 shortcut string
3338}
3439
35−type CompletionOption func(*completionItemCmp)
40+type options struct {
41+ id string
42+ text string
43+ bgColor color.Color
44+ matchIndexes []int
45+ shortcut string
46+}
3647
37−func WithBackgroundColor(c color.Color) CompletionOption {
38− return func(cmp *completionItemCmp) {
48+type CompletionItemOption func(*options)
49+
50+func WithCompletionBackgroundColor(c color.Color) CompletionItemOption {
51+ return func(cmp *options) {
3952 cmp.bgColor = c
4053 }
4154}
4255
43−func WithMatchIndexes(indexes ...int) CompletionOption {
44− return func(cmp *completionItemCmp) {
56+func WithCompletionMatchIndexes(indexes ...int) CompletionItemOption {
57+ return func(cmp *options) {
4558 cmp.matchIndexes = indexes
4659 }
4760}
4861
49−func WithShortcut(shortcut string) CompletionOption {
50− return func(cmp *completionItemCmp) {
62+func WithCompletionShortcut(shortcut string) CompletionItemOption {
63+ return func(cmp *options) {
5164 cmp.shortcut = shortcut
5265 }
5366}
5467
55−func NewCompletionItem(text string, value any, opts ...CompletionOption) CompletionItem {
56− c := &completionItemCmp{
68+func WithCompletionID(id string) CompletionItemOption {
69+ return func(cmp *options) {
70+ cmp.id = id
71+ }
72+}
73+
74+func NewCompletionItem[T any](text string, value T, opts ...CompletionItemOption) CompletionItem[T] {
75+ c := &completionItemCmp[T]{
5776 text: text,
5877 value: value,
5978 }
79+ o := &options{}
6080
6181 for _, opt := range opts {
62− opt(c)
82+ opt(o)
83+ }
84+ if o.id == "" {
85+ o.id = uuid.NewString()
6386 }
87+ c.id = o.id
88+ c.bgColor = o.bgColor
89+ c.matchIndexes = o.matchIndexes
90+ c.shortcut = o.shortcut
6491 return c
6592}
6693
6794// Init implements CommandItem.
68−func (c *completionItemCmp) Init() tea.Cmd {
95+func (c *completionItemCmp[T]) Init() tea.Cmd {
6996 return nil
7097}
7198
7299// Update implements CommandItem.
73−func (c *completionItemCmp) Update(tea.Msg) (tea.Model, tea.Cmd) {
100+func (c *completionItemCmp[T]) Update(tea.Msg) (tea.Model, tea.Cmd) {
74101 return c, nil
75102}
76103
77104// View implements CommandItem.
78−func (c *completionItemCmp) View() string {
105+func (c *completionItemCmp[T]) View() string {
79106 t := styles.CurrentTheme()
80107
81108 itemStyle := t.S().Base.Padding(0, 1).Width(c.width)
@@ -140,47 +167,47 @@ func (c *completionItemCmp) View() string {
140167}
141168
142169// Blur implements CommandItem.
143−func (c *completionItemCmp) Blur() tea.Cmd {
170+func (c *completionItemCmp[T]) Blur() tea.Cmd {
144171 c.focus = false
145172 return nil
146173}
147174
148175// Focus implements CommandItem.
149−func (c *completionItemCmp) Focus() tea.Cmd {
176+func (c *completionItemCmp[T]) Focus() tea.Cmd {
150177 c.focus = true
151178 return nil
152179}
153180
154181// GetSize implements CommandItem.
155−func (c *completionItemCmp) GetSize() (int, int) {
182+func (c *completionItemCmp[T]) GetSize() (int, int) {
156183 return c.width, 1
157184}
158185
159186// IsFocused implements CommandItem.
160−func (c *completionItemCmp) IsFocused() bool {
187+func (c *completionItemCmp[T]) IsFocused() bool {
161188 return c.focus
162189}
163190
164191// SetSize implements CommandItem.
165−func (c *completionItemCmp) SetSize(width int, height int) tea.Cmd {
192+func (c *completionItemCmp[T]) SetSize(width int, height int) tea.Cmd {
166193 c.width = width
167194 return nil
168195}
169196
170−func (c *completionItemCmp) MatchIndexes(indexes []int) {
197+func (c *completionItemCmp[T]) MatchIndexes(indexes []int) {
171198 c.matchIndexes = indexes
172199}
173200
174−func (c *completionItemCmp) FilterValue() string {
201+func (c *completionItemCmp[T]) FilterValue() string {
175202 return c.text
176203}
177204
178−func (c *completionItemCmp) Value() any {
205+func (c *completionItemCmp[T]) Value() T {
179206 return c.value
180207}
181208
182209// smartTruncate implements fzf-style truncation that ensures the last matching part is visible
183−func (c *completionItemCmp) smartTruncate(text string, width int, matchIndexes []int) string {
210+func (c *completionItemCmp[T]) smartTruncate(text string, width int, matchIndexes []int) string {
184211 if width <= 0 {
185212 return ""
186213 }
@@ -280,3 +307,84 @@ func bytePosToVisibleCharPos(str string, rng [2]int) (int, int) {
280307 stop = pos
281308 return start, stop
282309}
310+
311+// ID implements CompletionItem.
312+func (c *completionItemCmp[T]) ID() string {
313+ return c.id
314+}
315+
316+func (c *completionItemCmp[T]) Text() string {
317+ return c.text
318+}
319+
320+type ItemSection interface {
321+ Item
322+ layout.Sizeable
323+ Indexable
324+ SetInfo(info string)
325+}
326+type itemSectionModel struct {
327+ width int
328+ title string
329+ inx int
330+ info string
331+}
332+
333+// ID implements ItemSection.
334+func (m *itemSectionModel) ID() string {
335+ return uuid.NewString()
336+}
337+
338+func NewItemSection(title string) ItemSection {
339+ return &itemSectionModel{
340+ title: title,
341+ inx: -1,
342+ }
343+}
344+
345+func (m *itemSectionModel) Init() tea.Cmd {
346+ return nil
347+}
348+
349+func (m *itemSectionModel) Update(tea.Msg) (tea.Model, tea.Cmd) {
350+ return m, nil
351+}
352+
353+func (m *itemSectionModel) View() string {
354+ t := styles.CurrentTheme()
355+ title := ansi.Truncate(m.title, m.width-2, "…")
356+ style := t.S().Base.Padding(1, 1, 0, 1)
357+ if m.inx == 0 {
358+ style = style.Padding(0, 1, 0, 1)
359+ }
360+ title = t.S().Muted.Render(title)
361+ section := ""
362+ if m.info != "" {
363+ section = core.SectionWithInfo(title, m.width-2, m.info)
364+ } else {
365+ section = core.Section(title, m.width-2)
366+ }
367+
368+ return style.Render(section)
369+}
370+
371+func (m *itemSectionModel) GetSize() (int, int) {
372+ return m.width, 1
373+}
374+
375+func (m *itemSectionModel) SetSize(width int, height int) tea.Cmd {
376+ m.width = width
377+ return nil
378+}
379+
380+func (m *itemSectionModel) IsSectionHeader() bool {
381+ return true
382+}
383+
384+func (m *itemSectionModel) SetInfo(info string) {
385+ m.info = info
386+}
387+
388+func (m *itemSectionModel) SetIndex(inx int) {
389+ m.inx = inx
390+}
internal/tui/components/core/list/keys.go → internal/tui/exp/list/keys.gorenamed+2−2
@@ -46,7 +46,8 @@ func DefaultKeyMap() KeyMap {
4646 PageUp: key.NewBinding(
4747 key.WithKeys("pgup", "b"),
4848 key.WithHelp("b/pgup", "page up"),
49− ), HalfPageUp: key.NewBinding(
49+ ),
50+ HalfPageUp: key.NewBinding(
5051 key.WithKeys("u"),
5152 key.WithHelp("u", "half page up"),
5253 ),
@@ -61,7 +62,6 @@ func DefaultKeyMap() KeyMap {
6162 }
6263}
6364
64−// KeyBindings implements layout.KeyMapProvider
6565func (k KeyMap) KeyBindings() []key.Binding {
6666 return []key.Binding{
6767 k.Down,
internal/tui/exp/list/list.go+960−35
@@ -1,87 +1,1012 @@
11package list
22
33import (
4+ "slices"
5+ "strings"
6+
7+ "github.com/charmbracelet/bubbles/v2/key"
48 tea "github.com/charmbracelet/bubbletea/v2"
9+ "github.com/charmbracelet/crush/internal/csync"
10+ "github.com/charmbracelet/crush/internal/tui/components/anim"
511 "github.com/charmbracelet/crush/internal/tui/components/core/layout"
12+ "github.com/charmbracelet/crush/internal/tui/styles"
613 "github.com/charmbracelet/crush/internal/tui/util"
14+ "github.com/charmbracelet/lipgloss/v2"
715)
816
917type Item interface {
1018 util.Model
1119 layout.Sizeable
20+ ID() string
21+}
22+
23+type HasAnim interface {
24+ Item
25+ Spinning() bool
1226}
1327
14−type List interface {
28+type List[T Item] interface {
1529 util.Model
30+ layout.Sizeable
31+ layout.Focusable
32+
33+ // Just change state
34+ MoveUp(int) tea.Cmd
35+ MoveDown(int) tea.Cmd
36+ GoToTop() tea.Cmd
37+ GoToBottom() tea.Cmd
38+ SelectItemAbove() tea.Cmd
39+ SelectItemBelow() tea.Cmd
40+ SetItems([]T) tea.Cmd
41+ SetSelected(string) tea.Cmd
42+ SelectedItem() *T
43+ Items() []T
44+ UpdateItem(string, T) tea.Cmd
45+ DeleteItem(string) tea.Cmd
46+ PrependItem(T) tea.Cmd
47+ AppendItem(T) tea.Cmd
1648}
1749
18−type list struct {
50+type direction int
51+
52+const (
53+ DirectionForward direction = iota
54+ DirectionBackward
55+)
56+
57+const (
58+ ItemNotFound = -1
59+ ViewportDefaultScrollSize = 2
60+)
61+
62+type renderedItem struct {
63+ id string
64+ view string
65+ height int
66+ start int
67+ end int
68+}
69+
70+type confOptions struct {
1971 width, height int
2072 gap int
73+ // if you are at the last item and go down it will wrap to the top
74+ wrap bool
75+ keyMap KeyMap
76+ direction direction
77+ selectedItem string
78+ focused bool
79+ resize bool
80+ enableMouse bool
81+}
2182
22− items []Item
83+type list[T Item] struct {
84+ *confOptions
2385
24− // Filter options
25− filterable bool
26− filterPlaceholder string
27−}
86+ offset int
2887
29−type listOption func(*list)
88+ indexMap *csync.Map[string, int]
89+ items *csync.Slice[T]
3090
31−// WithFilterable enables filtering on the list.
32−func WithFilterable(placeholder string) listOption {
33− return func(l *list) {
34− l.filterable = true
35− l.filterPlaceholder = placeholder
36− }
37−}
91+ renderedItems *csync.Map[string, renderedItem]
3892
39−// WithItems sets the initial items for the list.
40−func WithItems(items ...Item) listOption {
41− return func(l *list) {
42− l.items = items
43− }
93+ rendered string
94+
95+ movingByItem bool
4496}
4597
98+type ListOption func(*confOptions)
99+
46100// WithSize sets the size of the list.
47−func WithSize(width, height int) listOption {
48− return func(l *list) {
101+func WithSize(width, height int) ListOption {
102+ return func(l *confOptions) {
49103 l.width = width
50104 l.height = height
51105 }
52106}
53107
54108// WithGap sets the gap between items in the list.
55−func WithGap(gap int) listOption {
56− return func(l *list) {
109+func WithGap(gap int) ListOption {
110+ return func(l *confOptions) {
57111 l.gap = gap
58112 }
59113}
60114
61−func New(opts ...listOption) List {
62− list := &list{
63− items: make([]Item, 0),
115+// WithDirectionForward sets the direction to forward
116+func WithDirectionForward() ListOption {
117+ return func(l *confOptions) {
118+ l.direction = DirectionForward
119+ }
120+}
121+
122+// WithDirectionBackward sets the direction to forward
123+func WithDirectionBackward() ListOption {
124+ return func(l *confOptions) {
125+ l.direction = DirectionBackward
126+ }
127+}
128+
129+// WithSelectedItem sets the initially selected item in the list.
130+func WithSelectedItem(id string) ListOption {
131+ return func(l *confOptions) {
132+ l.selectedItem = id
133+ }
134+}
135+
136+func WithKeyMap(keyMap KeyMap) ListOption {
137+ return func(l *confOptions) {
138+ l.keyMap = keyMap
139+ }
140+}
141+
142+func WithWrapNavigation() ListOption {
143+ return func(l *confOptions) {
144+ l.wrap = true
145+ }
146+}
147+
148+func WithFocus(focus bool) ListOption {
149+ return func(l *confOptions) {
150+ l.focused = focus
151+ }
152+}
153+
154+func WithResizeByList() ListOption {
155+ return func(l *confOptions) {
156+ l.resize = true
157+ }
158+}
159+
160+func WithEnableMouse() ListOption {
161+ return func(l *confOptions) {
162+ l.enableMouse = true
163+ }
164+}
165+
166+func New[T Item](items []T, opts ...ListOption) List[T] {
167+ list := &list[T]{
168+ confOptions: &confOptions{
169+ direction: DirectionForward,
170+ keyMap: DefaultKeyMap(),
171+ focused: true,
172+ },
173+ items: csync.NewSliceFrom(items),
174+ indexMap: csync.NewMap[string, int](),
175+ renderedItems: csync.NewMap[string, renderedItem](),
64176 }
65177 for _, opt := range opts {
66− opt(list)
178+ opt(list.confOptions)
179+ }
180+
181+ for inx, item := range items {
182+ if i, ok := any(item).(Indexable); ok {
183+ i.SetIndex(inx)
184+ }
185+ list.indexMap.Set(item.ID(), inx)
67186 }
68187 return list
69188}
70189
71190// Init implements List.
72−func (l *list) Init() tea.Cmd {
191+func (l *list[T]) Init() tea.Cmd {
192+ return l.render()
193+}
194+
195+// Update implements List.
196+func (l *list[T]) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
197+ switch msg := msg.(type) {
198+ case tea.MouseWheelMsg:
199+ if l.enableMouse {
200+ return l.handleMouseWheel(msg)
201+ }
202+ return l, nil
203+ case anim.StepMsg:
204+ var cmds []tea.Cmd
205+ for _, item := range slices.Collect(l.items.Seq()) {
206+ if i, ok := any(item).(HasAnim); ok && i.Spinning() {
207+ updated, cmd := i.Update(msg)
208+ cmds = append(cmds, cmd)
209+ if u, ok := updated.(T); ok {
210+ cmds = append(cmds, l.UpdateItem(u.ID(), u))
211+ }
212+ }
213+ }
214+ return l, tea.Batch(cmds...)
215+ case tea.KeyPressMsg:
216+ if l.focused {
217+ switch {
218+ case key.Matches(msg, l.keyMap.Down):
219+ return l, l.MoveDown(ViewportDefaultScrollSize)
220+ case key.Matches(msg, l.keyMap.Up):
221+ return l, l.MoveUp(ViewportDefaultScrollSize)
222+ case key.Matches(msg, l.keyMap.DownOneItem):
223+ return l, l.SelectItemBelow()
224+ case key.Matches(msg, l.keyMap.UpOneItem):
225+ return l, l.SelectItemAbove()
226+ case key.Matches(msg, l.keyMap.HalfPageDown):
227+ return l, l.MoveDown(l.height / 2)
228+ case key.Matches(msg, l.keyMap.HalfPageUp):
229+ return l, l.MoveUp(l.height / 2)
230+ case key.Matches(msg, l.keyMap.PageDown):
231+ return l, l.MoveDown(l.height)
232+ case key.Matches(msg, l.keyMap.PageUp):
233+ return l, l.MoveUp(l.height)
234+ case key.Matches(msg, l.keyMap.End):
235+ return l, l.GoToBottom()
236+ case key.Matches(msg, l.keyMap.Home):
237+ return l, l.GoToTop()
238+ }
239+ }
240+ }
241+ return l, nil
242+}
243+
244+func (l *list[T]) handleMouseWheel(msg tea.MouseWheelMsg) (tea.Model, tea.Cmd) {
245+ var cmd tea.Cmd
246+ switch msg.Button {
247+ case tea.MouseWheelDown:
248+ cmd = l.MoveDown(ViewportDefaultScrollSize)
249+ case tea.MouseWheelUp:
250+ cmd = l.MoveUp(ViewportDefaultScrollSize)
251+ }
252+ return l, cmd
253+}
254+
255+// View implements List.
256+func (l *list[T]) View() string {
73257 if l.height <= 0 || l.width <= 0 {
258+ return ""
259+ }
260+ t := styles.CurrentTheme()
261+ view := l.rendered
262+ lines := strings.Split(view, "\n")
263+
264+ start, end := l.viewPosition()
265+ viewStart := max(0, start)
266+ viewEnd := min(len(lines), end+1)
267+ lines = lines[viewStart:viewEnd]
268+ if l.resize {
269+ return strings.Join(lines, "\n")
270+ }
271+ return t.S().Base.
272+ Height(l.height).
273+ Width(l.width).
274+ Render(strings.Join(lines, "\n"))
275+}
276+
277+func (l *list[T]) viewPosition() (int, int) {
278+ start, end := 0, 0
279+ renderedLines := lipgloss.Height(l.rendered) - 1
280+ if l.direction == DirectionForward {
281+ start = max(0, l.offset)
282+ end = min(l.offset+l.height-1, renderedLines)
283+ } else {
284+ start = max(0, renderedLines-l.offset-l.height+1)
285+ end = max(0, renderedLines-l.offset)
286+ }
287+ return start, end
288+}
289+
290+func (l *list[T]) recalculateItemPositions() {
291+ currentContentHeight := 0
292+ for _, item := range slices.Collect(l.items.Seq()) {
293+ rItem, ok := l.renderedItems.Get(item.ID())
294+ if !ok {
295+ continue
296+ }
297+ rItem.start = currentContentHeight
298+ rItem.end = currentContentHeight + rItem.height - 1
299+ l.renderedItems.Set(item.ID(), rItem)
300+ currentContentHeight = rItem.end + 1 + l.gap
301+ }
302+}
303+
304+func (l *list[T]) render() tea.Cmd {
305+ if l.width <= 0 || l.height <= 0 || l.items.Len() == 0 {
74306 return nil
75307 }
308+ l.setDefaultSelected()
309+
310+ var focusChangeCmd tea.Cmd
311+ if l.focused {
312+ focusChangeCmd = l.focusSelectedItem()
313+ } else {
314+ focusChangeCmd = l.blurSelectedItem()
315+ }
316+ // we are not rendering the first time
317+ if l.rendered != "" {
318+ // rerender everything will mostly hit cache
319+ l.rendered, _ = l.renderIterator(0, false, "")
320+ if l.direction == DirectionBackward {
321+ l.recalculateItemPositions()
322+ }
323+ // in the end scroll to the selected item
324+ if l.focused {
325+ l.scrollToSelection()
326+ }
327+ return focusChangeCmd
328+ }
329+ rendered, finishIndex := l.renderIterator(0, true, "")
330+ l.rendered = rendered
331+
332+ // recalculate for the initial items
333+ if l.direction == DirectionBackward {
334+ l.recalculateItemPositions()
335+ }
336+ renderCmd := func() tea.Msg {
337+ l.offset = 0
338+ // render the rest
339+ l.rendered, _ = l.renderIterator(finishIndex, false, l.rendered)
340+ // needed for backwards
341+ if l.direction == DirectionBackward {
342+ l.recalculateItemPositions()
343+ }
344+ // in the end scroll to the selected item
345+ if l.focused {
346+ l.scrollToSelection()
347+ }
348+
349+ return nil
350+ }
351+ return tea.Batch(focusChangeCmd, renderCmd)
352+}
353+
354+func (l *list[T]) setDefaultSelected() {
355+ if l.selectedItem == "" {
356+ if l.direction == DirectionForward {
357+ l.selectFirstItem()
358+ } else {
359+ l.selectLastItem()
360+ }
361+ }
362+}
363+
364+func (l *list[T]) scrollToSelection() {
365+ rItem, ok := l.renderedItems.Get(l.selectedItem)
366+ if !ok {
367+ l.selectedItem = ""
368+ l.setDefaultSelected()
369+ return
370+ }
371+
372+ start, end := l.viewPosition()
373+ // item bigger or equal to the viewport do nothing
374+ if rItem.start <= start && rItem.end >= end {
375+ return
376+ }
377+ // if we are moving by item we want to move the offset so that the
378+ // whole item is visible not just portions of it
379+ if l.movingByItem {
380+ if rItem.start >= start && rItem.end <= end {
381+ return
382+ }
383+ defer func() { l.movingByItem = false }()
384+ } else {
385+ // item already in view do nothing
386+ if rItem.start >= start && rItem.start <= end {
387+ return
388+ }
389+ if rItem.end >= start && rItem.end <= end {
390+ return
391+ }
392+ }
393+
394+ if rItem.height >= l.height {
395+ if l.direction == DirectionForward {
396+ l.offset = rItem.start
397+ } else {
398+ l.offset = max(0, lipgloss.Height(l.rendered)-(rItem.start+l.height))
399+ }
400+ return
401+ }
402+
403+ renderedLines := lipgloss.Height(l.rendered) - 1
404+
405+ // If item is above the viewport, make it the first item
406+ if rItem.start < start {
407+ if l.direction == DirectionForward {
408+ l.offset = rItem.start
409+ } else {
410+ l.offset = max(0, renderedLines-rItem.start-l.height+1)
411+ }
412+ } else if rItem.end > end {
413+ // If item is below the viewport, make it the last item
414+ if l.direction == DirectionForward {
415+ l.offset = max(0, rItem.end-l.height+1)
416+ } else {
417+ l.offset = max(0, renderedLines-rItem.end)
418+ }
419+ }
420+}
421+
422+func (l *list[T]) changeSelectionWhenScrolling() tea.Cmd {
423+ rItem, ok := l.renderedItems.Get(l.selectedItem)
424+ if !ok {
425+ return nil
426+ }
427+ start, end := l.viewPosition()
428+ // item bigger than the viewport do nothing
429+ if rItem.start <= start && rItem.end >= end {
430+ return nil
431+ }
432+ // item already in view do nothing
433+ if rItem.start >= start && rItem.end <= end {
434+ return nil
435+ }
436+
437+ itemMiddle := rItem.start + rItem.height/2
438+
439+ if itemMiddle < start {
440+ // select the first item in the viewport
441+ // the item is most likely an item coming after this item
442+ inx, ok := l.indexMap.Get(rItem.id)
443+ if !ok {
444+ return nil
445+ }
446+ for {
447+ inx = l.firstSelectableItemBelow(inx)
448+ if inx == ItemNotFound {
449+ return nil
450+ }
451+ item, ok := l.items.Get(inx)
452+ if !ok {
453+ continue
454+ }
455+ renderedItem, ok := l.renderedItems.Get(item.ID())
456+ if !ok {
457+ continue
458+ }
459+
460+ // If the item is bigger than the viewport, select it
461+ if renderedItem.start <= start && renderedItem.end >= end {
462+ l.selectedItem = renderedItem.id
463+ return l.render()
464+ }
465+ // item is in the view
466+ if renderedItem.start >= start && renderedItem.start <= end {
467+ l.selectedItem = renderedItem.id
468+ return l.render()
469+ }
470+ }
471+ } else if itemMiddle > end {
472+ // select the first item in the viewport
473+ // the item is most likely an item coming after this item
474+ inx, ok := l.indexMap.Get(rItem.id)
475+ if !ok {
476+ return nil
477+ }
478+ for {
479+ inx = l.firstSelectableItemAbove(inx)
480+ if inx == ItemNotFound {
481+ return nil
482+ }
483+ item, ok := l.items.Get(inx)
484+ if !ok {
485+ continue
486+ }
487+ renderedItem, ok := l.renderedItems.Get(item.ID())
488+ if !ok {
489+ continue
490+ }
491+
492+ // If the item is bigger than the viewport, select it
493+ if renderedItem.start <= start && renderedItem.end >= end {
494+ l.selectedItem = renderedItem.id
495+ return l.render()
496+ }
497+ // item is in the view
498+ if renderedItem.end >= start && renderedItem.end <= end {
499+ l.selectedItem = renderedItem.id
500+ return l.render()
501+ }
502+ }
503+ }
76504 return nil
77505}
78506
79−// Update implements List.
80−func (l *list) Update(tea.Msg) (tea.Model, tea.Cmd) {
81− panic("unimplemented")
507+func (l *list[T]) selectFirstItem() {
508+ inx := l.firstSelectableItemBelow(-1)
509+ if inx != ItemNotFound {
510+ item, ok := l.items.Get(inx)
511+ if ok {
512+ l.selectedItem = item.ID()
513+ }
514+ }
82515}
83516
84−// View implements List.
85−func (l *list) View() string {
86− panic("unimplemented")
517+func (l *list[T]) selectLastItem() {
518+ inx := l.firstSelectableItemAbove(l.items.Len())
519+ if inx != ItemNotFound {
520+ item, ok := l.items.Get(inx)
521+ if ok {
522+ l.selectedItem = item.ID()
523+ }
524+ }
525+}
526+
527+func (l *list[T]) firstSelectableItemAbove(inx int) int {
528+ for i := inx - 1; i >= 0; i-- {
529+ item, ok := l.items.Get(i)
530+ if !ok {
531+ continue
532+ }
533+ if _, ok := any(item).(layout.Focusable); ok {
534+ return i
535+ }
536+ }
537+ if inx == 0 && l.wrap {
538+ return l.firstSelectableItemAbove(l.items.Len())
539+ }
540+ return ItemNotFound
541+}
542+
543+func (l *list[T]) firstSelectableItemBelow(inx int) int {
544+ itemsLen := l.items.Len()
545+ for i := inx + 1; i < itemsLen; i++ {
546+ item, ok := l.items.Get(i)
547+ if !ok {
548+ continue
549+ }
550+ if _, ok := any(item).(layout.Focusable); ok {
551+ return i
552+ }
553+ }
554+ if inx == itemsLen-1 && l.wrap {
555+ return l.firstSelectableItemBelow(-1)
556+ }
557+ return ItemNotFound
558+}
559+
560+func (l *list[T]) focusSelectedItem() tea.Cmd {
561+ if l.selectedItem == "" || !l.focused {
562+ return nil
563+ }
564+ var cmds []tea.Cmd
565+ for _, item := range slices.Collect(l.items.Seq()) {
566+ if f, ok := any(item).(layout.Focusable); ok {
567+ if item.ID() == l.selectedItem && !f.IsFocused() {
568+ cmds = append(cmds, f.Focus())
569+ l.renderedItems.Del(item.ID())
570+ } else if item.ID() != l.selectedItem && f.IsFocused() {
571+ cmds = append(cmds, f.Blur())
572+ l.renderedItems.Del(item.ID())
573+ }
574+ }
575+ }
576+ return tea.Batch(cmds...)
577+}
578+
579+func (l *list[T]) blurSelectedItem() tea.Cmd {
580+ if l.selectedItem == "" || l.focused {
581+ return nil
582+ }
583+ var cmds []tea.Cmd
584+ for _, item := range slices.Collect(l.items.Seq()) {
585+ if f, ok := any(item).(layout.Focusable); ok {
586+ if item.ID() == l.selectedItem && f.IsFocused() {
587+ cmds = append(cmds, f.Blur())
588+ l.renderedItems.Del(item.ID())
589+ }
590+ }
591+ }
592+ return tea.Batch(cmds...)
593+}
594+
595+// render iterator renders items starting from the specific index and limits hight if limitHeight != -1
596+// returns the last index and the rendered content so far
597+// we pass the rendered content around and don't use l.rendered to prevent jumping of the content
598+func (l *list[T]) renderIterator(startInx int, limitHeight bool, rendered string) (string, int) {
599+ currentContentHeight := lipgloss.Height(rendered) - 1
600+ itemsLen := l.items.Len()
601+ for i := startInx; i < itemsLen; i++ {
602+ if currentContentHeight >= l.height && limitHeight {
603+ return rendered, i
604+ }
605+ // cool way to go through the list in both directions
606+ inx := i
607+
608+ if l.direction != DirectionForward {
609+ inx = (itemsLen - 1) - i
610+ }
611+
612+ item, ok := l.items.Get(inx)
613+ if !ok {
614+ continue
615+ }
616+ var rItem renderedItem
617+ if cache, ok := l.renderedItems.Get(item.ID()); ok {
618+ rItem = cache
619+ } else {
620+ rItem = l.renderItem(item)
621+ rItem.start = currentContentHeight
622+ rItem.end = currentContentHeight + rItem.height - 1
623+ l.renderedItems.Set(item.ID(), rItem)
624+ }
625+ gap := l.gap + 1
626+ if inx == itemsLen-1 {
627+ gap = 0
628+ }
629+
630+ if l.direction == DirectionForward {
631+ rendered += rItem.view + strings.Repeat("\n", gap)
632+ } else {
633+ rendered = rItem.view + strings.Repeat("\n", gap) + rendered
634+ }
635+ currentContentHeight = rItem.end + 1 + l.gap
636+ }
637+ return rendered, itemsLen
638+}
639+
640+func (l *list[T]) renderItem(item Item) renderedItem {
641+ view := item.View()
642+ return renderedItem{
643+ id: item.ID(),
644+ view: view,
645+ height: lipgloss.Height(view),
646+ }
647+}
648+
649+// AppendItem implements List.
650+func (l *list[T]) AppendItem(item T) tea.Cmd {
651+ var cmds []tea.Cmd
652+ cmd := item.Init()
653+ if cmd != nil {
654+ cmds = append(cmds, cmd)
655+ }
656+
657+ l.items.Append(item)
658+ l.indexMap = csync.NewMap[string, int]()
659+ for inx, item := range slices.Collect(l.items.Seq()) {
660+ l.indexMap.Set(item.ID(), inx)
661+ }
662+ if l.width > 0 && l.height > 0 {
663+ cmd = item.SetSize(l.width, l.height)
664+ if cmd != nil {
665+ cmds = append(cmds, cmd)
666+ }
667+ }
668+ cmd = l.render()
669+ if cmd != nil {
670+ cmds = append(cmds, cmd)
671+ }
672+ if l.direction == DirectionBackward {
673+ if l.offset == 0 {
674+ cmd = l.GoToBottom()
675+ if cmd != nil {
676+ cmds = append(cmds, cmd)
677+ }
678+ } else {
679+ newItem, ok := l.renderedItems.Get(item.ID())
680+ if ok {
681+ newLines := newItem.height
682+ if l.items.Len() > 1 {
683+ newLines += l.gap
684+ }
685+ l.offset = min(lipgloss.Height(l.rendered)-1, l.offset+newLines)
686+ }
687+ }
688+ }
689+ return tea.Sequence(cmds...)
690+}
691+
692+// Blur implements List.
693+func (l *list[T]) Blur() tea.Cmd {
694+ l.focused = false
695+ return l.render()
696+}
697+
698+// DeleteItem implements List.
699+func (l *list[T]) DeleteItem(id string) tea.Cmd {
700+ inx, ok := l.indexMap.Get(id)
701+ if !ok {
702+ return nil
703+ }
704+ l.items.Delete(inx)
705+ l.renderedItems.Del(id)
706+ for inx, item := range slices.Collect(l.items.Seq()) {
707+ l.indexMap.Set(item.ID(), inx)
708+ }
709+
710+ if l.selectedItem == id {
711+ if inx > 0 {
712+ item, ok := l.items.Get(inx - 1)
713+ if ok {
714+ l.selectedItem = item.ID()
715+ } else {
716+ l.selectedItem = ""
717+ }
718+ } else {
719+ l.selectedItem = ""
720+ }
721+ }
722+ cmd := l.render()
723+ if l.rendered != "" {
724+ renderedHeight := lipgloss.Height(l.rendered)
725+ if renderedHeight <= l.height {
726+ l.offset = 0
727+ } else {
728+ maxOffset := renderedHeight - l.height
729+ if l.offset > maxOffset {
730+ l.offset = maxOffset
731+ }
732+ }
733+ }
734+ return cmd
735+}
736+
737+// Focus implements List.
738+func (l *list[T]) Focus() tea.Cmd {
739+ l.focused = true
740+ return l.render()
741+}
742+
743+// GetSize implements List.
744+func (l *list[T]) GetSize() (int, int) {
745+ return l.width, l.height
746+}
747+
748+// GoToBottom implements List.
749+func (l *list[T]) GoToBottom() tea.Cmd {
750+ l.offset = 0
751+ l.selectedItem = ""
752+ l.direction = DirectionBackward
753+ return l.render()
754+}
755+
756+// GoToTop implements List.
757+func (l *list[T]) GoToTop() tea.Cmd {
758+ l.offset = 0
759+ l.selectedItem = ""
760+ l.direction = DirectionForward
761+ return l.render()
762+}
763+
764+// IsFocused implements List.
765+func (l *list[T]) IsFocused() bool {
766+ return l.focused
767+}
768+
769+// Items implements List.
770+func (l *list[T]) Items() []T {
771+ return slices.Collect(l.items.Seq())
772+}
773+
774+func (l *list[T]) incrementOffset(n int) {
775+ renderedHeight := lipgloss.Height(l.rendered)
776+ // no need for offset
777+ if renderedHeight <= l.height {
778+ return
779+ }
780+ maxOffset := renderedHeight - l.height
781+ n = min(n, maxOffset-l.offset)
782+ if n <= 0 {
783+ return
784+ }
785+ l.offset += n
786+}
787+
788+func (l *list[T]) decrementOffset(n int) {
789+ n = min(n, l.offset)
790+ if n <= 0 {
791+ return
792+ }
793+ l.offset -= n
794+ if l.offset < 0 {
795+ l.offset = 0
796+ }
797+}
798+
799+// MoveDown implements List.
800+func (l *list[T]) MoveDown(n int) tea.Cmd {
801+ if l.direction == DirectionForward {
802+ l.incrementOffset(n)
803+ } else {
804+ l.decrementOffset(n)
805+ }
806+ return l.changeSelectionWhenScrolling()
807+}
808+
809+// MoveUp implements List.
810+func (l *list[T]) MoveUp(n int) tea.Cmd {
811+ if l.direction == DirectionForward {
812+ l.decrementOffset(n)
813+ } else {
814+ l.incrementOffset(n)
815+ }
816+ return l.changeSelectionWhenScrolling()
817+}
818+
819+// PrependItem implements List.
820+func (l *list[T]) PrependItem(item T) tea.Cmd {
821+ cmds := []tea.Cmd{
822+ item.Init(),
823+ }
824+ l.items.Prepend(item)
825+ l.indexMap = csync.NewMap[string, int]()
826+ for inx, item := range slices.Collect(l.items.Seq()) {
827+ l.indexMap.Set(item.ID(), inx)
828+ }
829+ if l.width > 0 && l.height > 0 {
830+ cmds = append(cmds, item.SetSize(l.width, l.height))
831+ }
832+ cmds = append(cmds, l.render())
833+ if l.direction == DirectionForward {
834+ if l.offset == 0 {
835+ cmd := l.GoToTop()
836+ if cmd != nil {
837+ cmds = append(cmds, cmd)
838+ }
839+ } else {
840+ newItem, ok := l.renderedItems.Get(item.ID())
841+ if ok {
842+ newLines := newItem.height
843+ if l.items.Len() > 1 {
844+ newLines += l.gap
845+ }
846+ l.offset = min(lipgloss.Height(l.rendered)-1, l.offset+newLines)
847+ }
848+ }
849+ }
850+ return tea.Batch(cmds...)
851+}
852+
853+// SelectItemAbove implements List.
854+func (l *list[T]) SelectItemAbove() tea.Cmd {
855+ inx, ok := l.indexMap.Get(l.selectedItem)
856+ if !ok {
857+ return nil
858+ }
859+
860+ newIndex := l.firstSelectableItemAbove(inx)
861+ if newIndex == ItemNotFound {
862+ // no item above
863+ return nil
864+ }
865+ var cmds []tea.Cmd
866+ if newIndex == 1 {
867+ peakAboveIndex := l.firstSelectableItemAbove(newIndex)
868+ if peakAboveIndex == ItemNotFound {
869+ // this means there is a section above move to the top
870+ cmd := l.GoToTop()
871+ if cmd != nil {
872+ cmds = append(cmds, cmd)
873+ }
874+ }
875+ }
876+ item, ok := l.items.Get(newIndex)
877+ if !ok {
878+ return nil
879+ }
880+ l.selectedItem = item.ID()
881+ l.movingByItem = true
882+ renderCmd := l.render()
883+ if renderCmd != nil {
884+ cmds = append(cmds, renderCmd)
885+ }
886+ return tea.Sequence(cmds...)
887+}
888+
889+// SelectItemBelow implements List.
890+func (l *list[T]) SelectItemBelow() tea.Cmd {
891+ inx, ok := l.indexMap.Get(l.selectedItem)
892+ if !ok {
893+ return nil
894+ }
895+
896+ newIndex := l.firstSelectableItemBelow(inx)
897+ if newIndex == ItemNotFound {
898+ // no item above
899+ return nil
900+ }
901+ item, ok := l.items.Get(newIndex)
902+ if !ok {
903+ return nil
904+ }
905+ l.selectedItem = item.ID()
906+ l.movingByItem = true
907+ return l.render()
908+}
909+
910+// SelectedItem implements List.
911+func (l *list[T]) SelectedItem() *T {
912+ inx, ok := l.indexMap.Get(l.selectedItem)
913+ if !ok {
914+ return nil
915+ }
916+ if inx > l.items.Len()-1 {
917+ return nil
918+ }
919+ item, ok := l.items.Get(inx)
920+ if !ok {
921+ return nil
922+ }
923+ return &item
924+}
925+
926+// SetItems implements List.
927+func (l *list[T]) SetItems(items []T) tea.Cmd {
928+ l.items.SetSlice(items)
929+ var cmds []tea.Cmd
930+ for inx, item := range slices.Collect(l.items.Seq()) {
931+ if i, ok := any(item).(Indexable); ok {
932+ i.SetIndex(inx)
933+ }
934+ cmds = append(cmds, item.Init())
935+ }
936+ cmds = append(cmds, l.reset(""))
937+ return tea.Batch(cmds...)
938+}
939+
940+// SetSelected implements List.
941+func (l *list[T]) SetSelected(id string) tea.Cmd {
942+ l.selectedItem = id
943+ return l.render()
944+}
945+
946+func (l *list[T]) reset(selectedItem string) tea.Cmd {
947+ var cmds []tea.Cmd
948+ l.rendered = ""
949+ l.offset = 0
950+ l.selectedItem = selectedItem
951+ l.indexMap = csync.NewMap[string, int]()
952+ l.renderedItems = csync.NewMap[string, renderedItem]()
953+ for inx, item := range slices.Collect(l.items.Seq()) {
954+ l.indexMap.Set(item.ID(), inx)
955+ if l.width > 0 && l.height > 0 {
956+ cmds = append(cmds, item.SetSize(l.width, l.height))
957+ }
958+ }
959+ cmds = append(cmds, l.render())
960+ return tea.Batch(cmds...)
961+}
962+
963+// SetSize implements List.
964+func (l *list[T]) SetSize(width int, height int) tea.Cmd {
965+ oldWidth := l.width
966+ l.width = width
967+ l.height = height
968+ if oldWidth != width {
969+ cmd := l.reset(l.selectedItem)
970+ return cmd
971+ }
972+ return nil
973+}
974+
975+// UpdateItem implements List.
976+func (l *list[T]) UpdateItem(id string, item T) tea.Cmd {
977+ var cmds []tea.Cmd
978+ if inx, ok := l.indexMap.Get(id); ok {
979+ l.items.Set(inx, item)
980+ oldItem, hasOldItem := l.renderedItems.Get(id)
981+ oldPosition := l.offset
982+ if l.direction == DirectionBackward {
983+ oldPosition = (lipgloss.Height(l.rendered) - 1) - l.offset
984+ }
985+
986+ l.renderedItems.Del(id)
987+ cmd := l.render()
988+
989+ // need to check for nil because of sequence not handling nil
990+ if cmd != nil {
991+ cmds = append(cmds, cmd)
992+ }
993+ if hasOldItem && l.direction == DirectionBackward {
994+ // if we are the last item and there is no offset
995+ // make sure to go to the bottom
996+ if oldPosition < oldItem.end {
997+ newItem, ok := l.renderedItems.Get(item.ID())
998+ if ok {
999+ newLines := newItem.height - oldItem.height
1000+ l.offset = util.Clamp(l.offset+newLines, 0, lipgloss.Height(l.rendered)-1)
1001+ }
1002+ }
1003+ } else if hasOldItem && l.offset > oldItem.start {
1004+ newItem, ok := l.renderedItems.Get(item.ID())
1005+ if ok {
1006+ newLines := newItem.height - oldItem.height
1007+ l.offset = util.Clamp(l.offset+newLines, 0, lipgloss.Height(l.rendered)-1)
1008+ }
1009+ }
1010+ }
1011+ return tea.Sequence(cmds...)
871012}
internal/tui/exp/list/list_test.gonew file+652
@@ -0,0 +1,652 @@
1+package list
2+
3+import (
4+ "fmt"
5+ "strings"
6+ "testing"
7+
8+ tea "github.com/charmbracelet/bubbletea/v2"
9+ "github.com/charmbracelet/crush/internal/tui/components/core/layout"
10+ "github.com/charmbracelet/lipgloss/v2"
11+ "github.com/charmbracelet/x/exp/golden"
12+ "github.com/google/uuid"
13+ "github.com/stretchr/testify/assert"
14+ "github.com/stretchr/testify/require"
15+)
16+
17+func TestList(t *testing.T) {
18+ t.Parallel()
19+ t.Run("should have correct positions in list that fits the items", func(t *testing.T) {
20+ t.Parallel()
21+ items := []Item{}
22+ for i := range 5 {
23+ item := NewSelectableItem(fmt.Sprintf("Item %d", i))
24+ items = append(items, item)
25+ }
26+ l := New(items, WithDirectionForward(), WithSize(10, 20)).(*list[Item])
27+ execCmd(l, l.Init())
28+
29+ // should select the last item
30+ assert.Equal(t, items[0].ID(), l.selectedItem)
31+ assert.Equal(t, 0, l.offset)
32+ require.Equal(t, 5, l.indexMap.Len())
33+ require.Equal(t, 5, l.items.Len())
34+ require.Equal(t, 5, l.renderedItems.Len())
35+ assert.Equal(t, 5, lipgloss.Height(l.rendered))
36+ assert.NotEqual(t, "\n", string(l.rendered[len(l.rendered)-1]), "should not end in newline")
37+ start, end := l.viewPosition()
38+ assert.Equal(t, 0, start)
39+ assert.Equal(t, 4, end)
40+ for i := range 5 {
41+ item, ok := l.renderedItems.Get(items[i].ID())
42+ require.True(t, ok)
43+ assert.Equal(t, i, item.start)
44+ assert.Equal(t, i, item.end)
45+ }
46+
47+ golden.RequireEqual(t, []byte(l.View()))
48+ })
49+ t.Run("should have correct positions in list that fits the items backwards", func(t *testing.T) {
50+ t.Parallel()
51+ items := []Item{}
52+ for i := range 5 {
53+ item := NewSelectableItem(fmt.Sprintf("Item %d", i))
54+ items = append(items, item)
55+ }
56+ l := New(items, WithDirectionBackward(), WithSize(10, 20)).(*list[Item])
57+ execCmd(l, l.Init())
58+
59+ // should select the last item
60+ assert.Equal(t, items[4].ID(), l.selectedItem)
61+ assert.Equal(t, 0, l.offset)
62+ require.Equal(t, 5, l.indexMap.Len())
63+ require.Equal(t, 5, l.items.Len())
64+ require.Equal(t, 5, l.renderedItems.Len())
65+ assert.Equal(t, 5, lipgloss.Height(l.rendered))
66+ assert.NotEqual(t, "\n", string(l.rendered[len(l.rendered)-1]), "should not end in newline")
67+ start, end := l.viewPosition()
68+ assert.Equal(t, 0, start)
69+ assert.Equal(t, 4, end)
70+ for i := range 5 {
71+ item, ok := l.renderedItems.Get(items[i].ID())
72+ require.True(t, ok)
73+ assert.Equal(t, i, item.start)
74+ assert.Equal(t, i, item.end)
75+ }
76+
77+ golden.RequireEqual(t, []byte(l.View()))
78+ })
79+
80+ t.Run("should have correct positions in list that does not fits the items", func(t *testing.T) {
81+ t.Parallel()
82+ items := []Item{}
83+ for i := range 30 {
84+ item := NewSelectableItem(fmt.Sprintf("Item %d", i))
85+ items = append(items, item)
86+ }
87+ l := New(items, WithDirectionForward(), WithSize(10, 10)).(*list[Item])
88+ execCmd(l, l.Init())
89+
90+ // should select the last item
91+ assert.Equal(t, items[0].ID(), l.selectedItem)
92+ assert.Equal(t, 0, l.offset)
93+ require.Equal(t, 30, l.indexMap.Len())
94+ require.Equal(t, 30, l.items.Len())
95+ require.Equal(t, 30, l.renderedItems.Len())
96+ assert.Equal(t, 30, lipgloss.Height(l.rendered))
97+ assert.NotEqual(t, "\n", string(l.rendered[len(l.rendered)-1]), "should not end in newline")
98+ start, end := l.viewPosition()
99+ assert.Equal(t, 0, start)
100+ assert.Equal(t, 9, end)
101+ for i := range 30 {
102+ item, ok := l.renderedItems.Get(items[i].ID())
103+ require.True(t, ok)
104+ assert.Equal(t, i, item.start)
105+ assert.Equal(t, i, item.end)
106+ }
107+
108+ golden.RequireEqual(t, []byte(l.View()))
109+ })
110+ t.Run("should have correct positions in list that does not fits the items backwards", func(t *testing.T) {
111+ t.Parallel()
112+ items := []Item{}
113+ for i := range 30 {
114+ item := NewSelectableItem(fmt.Sprintf("Item %d", i))
115+ items = append(items, item)
116+ }
117+ l := New(items, WithDirectionBackward(), WithSize(10, 10)).(*list[Item])
118+ execCmd(l, l.Init())
119+
120+ // should select the last item
121+ assert.Equal(t, items[29].ID(), l.selectedItem)
122+ assert.Equal(t, 0, l.offset)
123+ require.Equal(t, 30, l.indexMap.Len())
124+ require.Equal(t, 30, l.items.Len())
125+ require.Equal(t, 30, l.renderedItems.Len())
126+ assert.Equal(t, 30, lipgloss.Height(l.rendered))
127+ assert.NotEqual(t, "\n", string(l.rendered[len(l.rendered)-1]), "should not end in newline")
128+ start, end := l.viewPosition()
129+ assert.Equal(t, 20, start)
130+ assert.Equal(t, 29, end)
131+ for i := range 30 {
132+ item, ok := l.renderedItems.Get(items[i].ID())
133+ require.True(t, ok)
134+ assert.Equal(t, i, item.start)
135+ assert.Equal(t, i, item.end)
136+ }
137+
138+ golden.RequireEqual(t, []byte(l.View()))
139+ })
140+
141+ t.Run("should have correct positions in list that does not fits the items and has multi line items", func(t *testing.T) {
142+ t.Parallel()
143+ items := []Item{}
144+ for i := range 30 {
145+ content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1)
146+ content = strings.TrimSuffix(content, "\n")
147+ item := NewSelectableItem(content)
148+ items = append(items, item)
149+ }
150+ l := New(items, WithDirectionForward(), WithSize(10, 10)).(*list[Item])
151+ execCmd(l, l.Init())
152+
153+ // should select the last item
154+ assert.Equal(t, items[0].ID(), l.selectedItem)
155+ assert.Equal(t, 0, l.offset)
156+ require.Equal(t, 30, l.indexMap.Len())
157+ require.Equal(t, 30, l.items.Len())
158+ require.Equal(t, 30, l.renderedItems.Len())
159+ expectedLines := 0
160+ for i := range 30 {
161+ expectedLines += (i + 1) * 1
162+ }
163+ assert.Equal(t, expectedLines, lipgloss.Height(l.rendered))
164+ assert.NotEqual(t, "\n", string(l.rendered[len(l.rendered)-1]), "should not end in newline")
165+ start, end := l.viewPosition()
166+ assert.Equal(t, 0, start)
167+ assert.Equal(t, 9, end)
168+ currentPosition := 0
169+ for i := range 30 {
170+ rItem, ok := l.renderedItems.Get(items[i].ID())
171+ require.True(t, ok)
172+ assert.Equal(t, currentPosition, rItem.start)
173+ assert.Equal(t, currentPosition+i, rItem.end)
174+ currentPosition += i + 1
175+ }
176+
177+ golden.RequireEqual(t, []byte(l.View()))
178+ })
179+ t.Run("should have correct positions in list that does not fits the items and has multi line items backwards", func(t *testing.T) {
180+ t.Parallel()
181+ items := []Item{}
182+ for i := range 30 {
183+ content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1)
184+ content = strings.TrimSuffix(content, "\n")
185+ item := NewSelectableItem(content)
186+ items = append(items, item)
187+ }
188+ l := New(items, WithDirectionBackward(), WithSize(10, 10)).(*list[Item])
189+ execCmd(l, l.Init())
190+
191+ // should select the last item
192+ assert.Equal(t, items[29].ID(), l.selectedItem)
193+ assert.Equal(t, 0, l.offset)
194+ require.Equal(t, 30, l.indexMap.Len())
195+ require.Equal(t, 30, l.items.Len())
196+ require.Equal(t, 30, l.renderedItems.Len())
197+ expectedLines := 0
198+ for i := range 30 {
199+ expectedLines += (i + 1) * 1
200+ }
201+ assert.Equal(t, expectedLines, lipgloss.Height(l.rendered))
202+ assert.NotEqual(t, "\n", string(l.rendered[len(l.rendered)-1]), "should not end in newline")
203+ start, end := l.viewPosition()
204+ assert.Equal(t, expectedLines-10, start)
205+ assert.Equal(t, expectedLines-1, end)
206+ currentPosition := 0
207+ for i := range 30 {
208+ rItem, ok := l.renderedItems.Get(items[i].ID())
209+ require.True(t, ok)
210+ assert.Equal(t, currentPosition, rItem.start)
211+ assert.Equal(t, currentPosition+i, rItem.end)
212+ currentPosition += i + 1
213+ }
214+
215+ golden.RequireEqual(t, []byte(l.View()))
216+ })
217+
218+ t.Run("should go to selected item at the beginning", func(t *testing.T) {
219+ t.Parallel()
220+ items := []Item{}
221+ for i := range 30 {
222+ content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1)
223+ content = strings.TrimSuffix(content, "\n")
224+ item := NewSelectableItem(content)
225+ items = append(items, item)
226+ }
227+ l := New(items, WithDirectionForward(), WithSize(10, 10), WithSelectedItem(items[10].ID())).(*list[Item])
228+ execCmd(l, l.Init())
229+
230+ // should select the last item
231+ assert.Equal(t, items[10].ID(), l.selectedItem)
232+
233+ golden.RequireEqual(t, []byte(l.View()))
234+ })
235+
236+ t.Run("should go to selected item at the beginning backwards", func(t *testing.T) {
237+ t.Parallel()
238+ items := []Item{}
239+ for i := range 30 {
240+ content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1)
241+ content = strings.TrimSuffix(content, "\n")
242+ item := NewSelectableItem(content)
243+ items = append(items, item)
244+ }
245+ l := New(items, WithDirectionBackward(), WithSize(10, 10), WithSelectedItem(items[10].ID())).(*list[Item])
246+ execCmd(l, l.Init())
247+
248+ // should select the last item
249+ assert.Equal(t, items[10].ID(), l.selectedItem)
250+
251+ golden.RequireEqual(t, []byte(l.View()))
252+ })
253+}
254+
255+func TestListMovement(t *testing.T) {
256+ t.Parallel()
257+ t.Run("should move viewport up", func(t *testing.T) {
258+ t.Parallel()
259+ items := []Item{}
260+ for i := range 30 {
261+ content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1)
262+ content = strings.TrimSuffix(content, "\n")
263+ item := NewSelectableItem(content)
264+ items = append(items, item)
265+ }
266+ l := New(items, WithDirectionBackward(), WithSize(10, 10)).(*list[Item])
267+ execCmd(l, l.Init())
268+
269+ execCmd(l, l.MoveUp(25))
270+
271+ assert.Equal(t, 25, l.offset)
272+ golden.RequireEqual(t, []byte(l.View()))
273+ })
274+ t.Run("should move viewport up and down", func(t *testing.T) {
275+ t.Parallel()
276+ items := []Item{}
277+ for i := range 30 {
278+ content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1)
279+ content = strings.TrimSuffix(content, "\n")
280+ item := NewSelectableItem(content)
281+ items = append(items, item)
282+ }
283+ l := New(items, WithDirectionBackward(), WithSize(10, 10)).(*list[Item])
284+ execCmd(l, l.Init())
285+
286+ execCmd(l, l.MoveUp(25))
287+ execCmd(l, l.MoveDown(25))
288+
289+ assert.Equal(t, 0, l.offset)
290+ golden.RequireEqual(t, []byte(l.View()))
291+ })
292+
293+ t.Run("should move viewport down", func(t *testing.T) {
294+ t.Parallel()
295+ items := []Item{}
296+ for i := range 30 {
297+ content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1)
298+ content = strings.TrimSuffix(content, "\n")
299+ item := NewSelectableItem(content)
300+ items = append(items, item)
301+ }
302+ l := New(items, WithDirectionForward(), WithSize(10, 10)).(*list[Item])
303+ execCmd(l, l.Init())
304+
305+ execCmd(l, l.MoveDown(25))
306+
307+ assert.Equal(t, 25, l.offset)
308+ golden.RequireEqual(t, []byte(l.View()))
309+ })
310+ t.Run("should move viewport down and up", func(t *testing.T) {
311+ t.Parallel()
312+ items := []Item{}
313+ for i := range 30 {
314+ content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1)
315+ content = strings.TrimSuffix(content, "\n")
316+ item := NewSelectableItem(content)
317+ items = append(items, item)
318+ }
319+ l := New(items, WithDirectionForward(), WithSize(10, 10)).(*list[Item])
320+ execCmd(l, l.Init())
321+
322+ execCmd(l, l.MoveDown(25))
323+ execCmd(l, l.MoveUp(25))
324+
325+ assert.Equal(t, 0, l.offset)
326+ golden.RequireEqual(t, []byte(l.View()))
327+ })
328+
329+ t.Run("should not change offset when new items are appended and we are at the bottom in backwards list", func(t *testing.T) {
330+ t.Parallel()
331+ items := []Item{}
332+ for i := range 30 {
333+ content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1)
334+ content = strings.TrimSuffix(content, "\n")
335+ item := NewSelectableItem(content)
336+ items = append(items, item)
337+ }
338+ l := New(items, WithDirectionBackward(), WithSize(10, 10)).(*list[Item])
339+ execCmd(l, l.Init())
340+ execCmd(l, l.AppendItem(NewSelectableItem("Testing")))
341+
342+ assert.Equal(t, 0, l.offset)
343+ golden.RequireEqual(t, []byte(l.View()))
344+ })
345+
346+ 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) {
347+ t.Parallel()
348+ items := []Item{}
349+ for i := range 30 {
350+ item := NewSelectableItem(fmt.Sprintf("Item %d", i))
351+ items = append(items, item)
352+ }
353+ l := New(items, WithDirectionBackward(), WithSize(10, 10)).(*list[Item])
354+ execCmd(l, l.Init())
355+
356+ execCmd(l, l.MoveUp(2))
357+ viewBefore := l.View()
358+ execCmd(l, l.AppendItem(NewSelectableItem("Testing\nHello\n")))
359+ viewAfter := l.View()
360+ assert.Equal(t, viewBefore, viewAfter)
361+ assert.Equal(t, 5, l.offset)
362+ assert.Equal(t, 33, lipgloss.Height(l.rendered))
363+ golden.RequireEqual(t, []byte(l.View()))
364+ })
365+ 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) {
366+ t.Parallel()
367+ items := []Item{}
368+ for i := range 30 {
369+ item := NewSelectableItem(fmt.Sprintf("Item %d", i))
370+ items = append(items, item)
371+ }
372+ l := New(items, WithDirectionBackward(), WithSize(10, 10)).(*list[Item])
373+ execCmd(l, l.Init())
374+
375+ execCmd(l, l.MoveUp(2))
376+ viewBefore := l.View()
377+ item := items[29]
378+ execCmd(l, l.UpdateItem(item.ID(), NewSelectableItem("Item 29\nLine 2\nLine 3")))
379+ viewAfter := l.View()
380+ assert.Equal(t, viewBefore, viewAfter)
381+ assert.Equal(t, 4, l.offset)
382+ assert.Equal(t, 32, lipgloss.Height(l.rendered))
383+ golden.RequireEqual(t, []byte(l.View()))
384+ })
385+ 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) {
386+ t.Parallel()
387+ items := []Item{}
388+ for i := range 30 {
389+ item := NewSelectableItem(fmt.Sprintf("Item %d", i))
390+ items = append(items, item)
391+ }
392+ items = append(items, NewSelectableItem("Item 30\nLine 2\nLine 3"))
393+ l := New(items, WithDirectionBackward(), WithSize(10, 10)).(*list[Item])
394+ execCmd(l, l.Init())
395+
396+ execCmd(l, l.MoveUp(2))
397+ viewBefore := l.View()
398+ item := items[30]
399+ execCmd(l, l.UpdateItem(item.ID(), NewSelectableItem("Item 30")))
400+ viewAfter := l.View()
401+ assert.Equal(t, viewBefore, viewAfter)
402+ assert.Equal(t, 0, l.offset)
403+ assert.Equal(t, 31, lipgloss.Height(l.rendered))
404+ golden.RequireEqual(t, []byte(l.View()))
405+ })
406+ 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) {
407+ t.Parallel()
408+ items := []Item{}
409+ for i := range 30 {
410+ item := NewSelectableItem(fmt.Sprintf("Item %d", i))
411+ items = append(items, item)
412+ }
413+ l := New(items, WithDirectionBackward(), WithSize(10, 10)).(*list[Item])
414+ execCmd(l, l.Init())
415+
416+ execCmd(l, l.MoveUp(2))
417+ viewBefore := l.View()
418+ item := items[1]
419+ execCmd(l, l.UpdateItem(item.ID(), NewSelectableItem("Item 1\nLine 2\nLine 3")))
420+ viewAfter := l.View()
421+ assert.Equal(t, viewBefore, viewAfter)
422+ assert.Equal(t, 2, l.offset)
423+ assert.Equal(t, 32, lipgloss.Height(l.rendered))
424+ golden.RequireEqual(t, []byte(l.View()))
425+ })
426+ t.Run("should stay at the position it is if an item is prepended and we are in backwards list", func(t *testing.T) {
427+ t.Parallel()
428+ items := []Item{}
429+ for i := range 30 {
430+ item := NewSelectableItem(fmt.Sprintf("Item %d", i))
431+ items = append(items, item)
432+ }
433+ l := New(items, WithDirectionBackward(), WithSize(10, 10)).(*list[Item])
434+ execCmd(l, l.Init())
435+
436+ execCmd(l, l.MoveUp(2))
437+ viewBefore := l.View()
438+ execCmd(l, l.PrependItem(NewSelectableItem("New")))
439+ viewAfter := l.View()
440+ assert.Equal(t, viewBefore, viewAfter)
441+ assert.Equal(t, 2, l.offset)
442+ assert.Equal(t, 31, lipgloss.Height(l.rendered))
443+ golden.RequireEqual(t, []byte(l.View()))
444+ })
445+
446+ t.Run("should not change offset when new items are prepended and we are at the top in forward list", func(t *testing.T) {
447+ t.Parallel()
448+ items := []Item{}
449+ for i := range 30 {
450+ content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1)
451+ content = strings.TrimSuffix(content, "\n")
452+ item := NewSelectableItem(content)
453+ items = append(items, item)
454+ }
455+ l := New(items, WithDirectionForward(), WithSize(10, 10)).(*list[Item])
456+ execCmd(l, l.Init())
457+ execCmd(l, l.PrependItem(NewSelectableItem("Testing")))
458+
459+ assert.Equal(t, 0, l.offset)
460+ golden.RequireEqual(t, []byte(l.View()))
461+ })
462+
463+ 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) {
464+ t.Parallel()
465+ items := []Item{}
466+ for i := range 30 {
467+ item := NewSelectableItem(fmt.Sprintf("Item %d", i))
468+ items = append(items, item)
469+ }
470+ l := New(items, WithDirectionForward(), WithSize(10, 10)).(*list[Item])
471+ execCmd(l, l.Init())
472+
473+ execCmd(l, l.MoveDown(2))
474+ viewBefore := l.View()
475+ execCmd(l, l.PrependItem(NewSelectableItem("Testing\nHello\n")))
476+ viewAfter := l.View()
477+ assert.Equal(t, viewBefore, viewAfter)
478+ assert.Equal(t, 5, l.offset)
479+ assert.Equal(t, 33, lipgloss.Height(l.rendered))
480+ golden.RequireEqual(t, []byte(l.View()))
481+ })
482+
483+ 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) {
484+ t.Parallel()
485+ items := []Item{}
486+ for i := range 30 {
487+ item := NewSelectableItem(fmt.Sprintf("Item %d", i))
488+ items = append(items, item)
489+ }
490+ l := New(items, WithDirectionForward(), WithSize(10, 10)).(*list[Item])
491+ execCmd(l, l.Init())
492+
493+ execCmd(l, l.MoveDown(2))
494+ viewBefore := l.View()
495+ item := items[0]
496+ execCmd(l, l.UpdateItem(item.ID(), NewSelectableItem("Item 29\nLine 2\nLine 3")))
497+ viewAfter := l.View()
498+ assert.Equal(t, viewBefore, viewAfter)
499+ assert.Equal(t, 4, l.offset)
500+ assert.Equal(t, 32, lipgloss.Height(l.rendered))
501+ golden.RequireEqual(t, []byte(l.View()))
502+ })
503+
504+ 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) {
505+ t.Parallel()
506+ items := []Item{}
507+ items = append(items, NewSelectableItem("At top\nLine 2\nLine 3"))
508+ for i := range 30 {
509+ item := NewSelectableItem(fmt.Sprintf("Item %d", i))
510+ items = append(items, item)
511+ }
512+ l := New(items, WithDirectionForward(), WithSize(10, 10)).(*list[Item])
513+ execCmd(l, l.Init())
514+
515+ execCmd(l, l.MoveDown(3))
516+ viewBefore := l.View()
517+ item := items[0]
518+ execCmd(l, l.UpdateItem(item.ID(), NewSelectableItem("At top")))
519+ viewAfter := l.View()
520+ assert.Equal(t, viewBefore, viewAfter)
521+ assert.Equal(t, 1, l.offset)
522+ assert.Equal(t, 31, lipgloss.Height(l.rendered))
523+ golden.RequireEqual(t, []byte(l.View()))
524+ })
525+
526+ 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) {
527+ t.Parallel()
528+ items := []Item{}
529+ for i := range 30 {
530+ item := NewSelectableItem(fmt.Sprintf("Item %d", i))
531+ items = append(items, item)
532+ }
533+ l := New(items, WithDirectionForward(), WithSize(10, 10)).(*list[Item])
534+ execCmd(l, l.Init())
535+
536+ execCmd(l, l.MoveDown(2))
537+ viewBefore := l.View()
538+ item := items[29]
539+ execCmd(l, l.UpdateItem(item.ID(), NewSelectableItem("Item 29\nLine 2\nLine 3")))
540+ viewAfter := l.View()
541+ assert.Equal(t, viewBefore, viewAfter)
542+ assert.Equal(t, 2, l.offset)
543+ assert.Equal(t, 32, lipgloss.Height(l.rendered))
544+ golden.RequireEqual(t, []byte(l.View()))
545+ })
546+ t.Run("should stay at the position it is if an item is appended and we are in forward list", func(t *testing.T) {
547+ t.Parallel()
548+ items := []Item{}
549+ for i := range 30 {
550+ item := NewSelectableItem(fmt.Sprintf("Item %d", i))
551+ items = append(items, item)
552+ }
553+ l := New(items, WithDirectionForward(), WithSize(10, 10)).(*list[Item])
554+ execCmd(l, l.Init())
555+
556+ execCmd(l, l.MoveDown(2))
557+ viewBefore := l.View()
558+ execCmd(l, l.AppendItem(NewSelectableItem("New")))
559+ viewAfter := l.View()
560+ assert.Equal(t, viewBefore, viewAfter)
561+ assert.Equal(t, 2, l.offset)
562+ assert.Equal(t, 31, lipgloss.Height(l.rendered))
563+ golden.RequireEqual(t, []byte(l.View()))
564+ })
565+}
566+
567+type SelectableItem interface {
568+ Item
569+ layout.Focusable
570+}
571+
572+type simpleItem struct {
573+ width int
574+ content string
575+ id string
576+}
577+type selectableItem struct {
578+ *simpleItem
579+ focused bool
580+}
581+
582+func NewSimpleItem(content string) *simpleItem {
583+ return &simpleItem{
584+ id: uuid.NewString(),
585+ width: 0,
586+ content: content,
587+ }
588+}
589+
590+func NewSelectableItem(content string) SelectableItem {
591+ return &selectableItem{
592+ simpleItem: NewSimpleItem(content),
593+ focused: false,
594+ }
595+}
596+
597+func (s *simpleItem) ID() string {
598+ return s.id
599+}
600+
601+func (s *simpleItem) Init() tea.Cmd {
602+ return nil
603+}
604+
605+func (s *simpleItem) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
606+ return s, nil
607+}
608+
609+func (s *simpleItem) View() string {
610+ return lipgloss.NewStyle().Width(s.width).Render(s.content)
611+}
612+
613+func (l *simpleItem) GetSize() (int, int) {
614+ return l.width, 0
615+}
616+
617+// SetSize implements Item.
618+func (s *simpleItem) SetSize(width int, height int) tea.Cmd {
619+ s.width = width
620+ return nil
621+}
622+
623+func (s *selectableItem) View() string {
624+ if s.focused {
625+ return lipgloss.NewStyle().BorderLeft(true).BorderStyle(lipgloss.NormalBorder()).Width(s.width).Render(s.content)
626+ }
627+ return lipgloss.NewStyle().Width(s.width).Render(s.content)
628+}
629+
630+// Blur implements SimpleItem.
631+func (s *selectableItem) Blur() tea.Cmd {
632+ s.focused = false
633+ return nil
634+}
635+
636+// Focus implements SimpleItem.
637+func (s *selectableItem) Focus() tea.Cmd {
638+ s.focused = true
639+ return nil
640+}
641+
642+// IsFocused implements SimpleItem.
643+func (s *selectableItem) IsFocused() bool {
644+ return s.focused
645+}
646+
647+func execCmd(m tea.Model, cmd tea.Cmd) {
648+ for cmd != nil {
649+ msg := cmd()
650+ m, cmd = m.Update(msg)
651+ }
652+}
internal/tui/exp/list/testdata/TestFilterableList/should_create_simple_filterable_list.goldennew file+10
@@ -0,0 +1,10 @@
1+> Type to filter 
2+│Item 0 
3+Item 1 
4+Item 2 
5+Item 3 
6+Item 4 
7+
8+
9+
10+
internal/tui/exp/list/testdata/TestList/should_go_to_selected_item_at_the_beginning.goldennew file+10
@@ -0,0 +1,10 @@
1+│Item 10
2+│Item 10
3+│Item 10
4+│Item 10
5+│Item 10
6+│Item 10
7+│Item 10
8+│Item 10
9+│Item 10
10+│Item 10
internal/tui/exp/list/testdata/TestList/should_go_to_selected_item_at_the_beginning_backwards.goldennew file+10
@@ -0,0 +1,10 @@
1+│Item 10
2+│Item 10
3+│Item 10
4+│Item 10
5+│Item 10
6+│Item 10
7+│Item 10
8+│Item 10
9+│Item 10
10+│Item 10
internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_does_not_fits_the_items.goldennew file+10
@@ -0,0 +1,10 @@
1+│Item 0
2+Item 1
3+Item 2
4+Item 3
5+Item 4
6+Item 5
7+Item 6
8+Item 7
9+Item 8
10+Item 9
internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_does_not_fits_the_items_and_has_multi_line_items.goldennew file+10
@@ -0,0 +1,10 @@
1+│Item 0
2+Item 1
3+Item 1
4+Item 2
5+Item 2
6+Item 2
7+Item 3
8+Item 3
9+Item 3
10+Item 3
internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_does_not_fits_the_items_and_has_multi_line_items_backwards.goldennew file+10
@@ -0,0 +1,10 @@
1+│Item 29
2+│Item 29
3+│Item 29
4+│Item 29
5+│Item 29
6+│Item 29
7+│Item 29
8+│Item 29
9+│Item 29
10+│Item 29
internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_does_not_fits_the_items_backwards.goldennew file+10
@@ -0,0 +1,10 @@
1+Item 20
2+Item 21
3+Item 22
4+Item 23
5+Item 24
6+Item 25
7+Item 26
8+Item 27
9+Item 28
10+│Item 29
internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_fits_the_items.goldennew file+20
@@ -0,0 +1,20 @@
1+│Item 0
2+Item 1
3+Item 2
4+Item 3
5+Item 4
6+
7+
8+
9+
10+
11+
12+
13+
14+
15+
16+
17+
18+
19+
20+
internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_fits_the_items_backwards.goldennew file+20
@@ -0,0 +1,20 @@
1+Item 0
2+Item 1
3+Item 2
4+Item 3
5+│Item 4
6+
7+
8+
9+
10+
11+
12+
13+
14+
15+
16+
17+
18+
19+
20+
internal/tui/exp/list/testdata/TestListMovement/should_move_viewport_down.goldennew file+10
@@ -0,0 +1,10 @@
1+Item 6
2+Item 6
3+Item 6
4+│Item 7
5+│Item 7
6+│Item 7
7+│Item 7
8+│Item 7
9+│Item 7
10+│Item 7
internal/tui/exp/list/testdata/TestListMovement/should_move_viewport_down_and_up.goldennew file+10
@@ -0,0 +1,10 @@
1+Item 0
2+Item 1
3+Item 1
4+Item 2
5+Item 2
6+Item 2
7+│Item 3
8+│Item 3
9+│Item 3
10+│Item 3
internal/tui/exp/list/testdata/TestListMovement/should_move_viewport_up.goldennew file+10
@@ -0,0 +1,10 @@
1+│Item 28
2+│Item 28
3+│Item 28
4+│Item 28
5+│Item 28
6+Item 29
7+Item 29
8+Item 29
9+Item 29
10+Item 29
internal/tui/exp/list/testdata/TestListMovement/should_move_viewport_up_and_down.goldennew file+10
@@ -0,0 +1,10 @@
1+│Item 29
2+│Item 29
3+│Item 29
4+│Item 29
5+│Item 29
6+│Item 29
7+│Item 29
8+│Item 29
9+│Item 29
10+│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.goldennew file+10
@@ -0,0 +1,10 @@
1+Item 29
2+Item 29
3+Item 29
4+Item 29
5+Item 29
6+Item 29
7+Item 29
8+Item 29
9+Item 29
10+│Testing 
internal/tui/exp/list/testdata/TestListMovement/should_not_change_offset_when_new_items_are_prepended_and_we_are_at_the_top_in_forward_list.goldennew file+10
@@ -0,0 +1,10 @@
1+│Testing 
2+Item 0
3+Item 1
4+Item 1
5+Item 2
6+Item 2
7+Item 2
8+Item 3
9+Item 3
10+Item 3
internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_if_an_item_is_appended_and_we_are_in_forward_list.goldennew file+10
@@ -0,0 +1,10 @@
1+│Item 2
2+Item 3
3+Item 4
4+Item 5
5+Item 6
6+Item 7
7+Item 8
8+Item 9
9+Item 10
10+Item 11
internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_if_an_item_is_prepended_and_we_are_in_backwards_list.goldennew file+10
@@ -0,0 +1,10 @@
1+Item 18
2+Item 19
3+Item 20
4+Item 21
5+Item 22
6+Item 23
7+Item 24
8+Item 25
9+Item 26
10+│Item 27
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.goldennew file+10
@@ -0,0 +1,10 @@
1+│Item 2
2+Item 3
3+Item 4
4+Item 5
5+Item 6
6+Item 7
7+Item 8
8+Item 9
9+Item 10
10+Item 11
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.goldennew file+10
@@ -0,0 +1,10 @@
1+Item 18
2+Item 19
3+Item 20
4+Item 21
5+Item 22
6+Item 23
7+Item 24
8+Item 25
9+Item 26
10+│Item 27
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.goldennew file+10
@@ -0,0 +1,10 @@
1+│Item 0
2+Item 1
3+Item 2
4+Item 3
5+Item 4
6+Item 5
7+Item 6
8+Item 7
9+Item 8
10+Item 9
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.goldennew file+10
@@ -0,0 +1,10 @@
1+Item 18
2+Item 19
3+Item 20
4+Item 21
5+Item 22
6+Item 23
7+Item 24
8+Item 25
9+Item 26
10+│Item 27
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.goldennew file+10
@@ -0,0 +1,10 @@
1+│Item 2
2+Item 3
3+Item 4
4+Item 5
5+Item 6
6+Item 7
7+Item 8
8+Item 9
9+Item 10
10+Item 11
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.goldennew file+10
@@ -0,0 +1,10 @@
1+Item 21
2+Item 22
3+Item 23
4+Item 24
5+Item 25
6+Item 26
7+Item 27
8+Item 28
9+│Item 29
10+Item 30
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.goldennew file+10
@@ -0,0 +1,10 @@
1+Item 18
2+Item 19
3+Item 20
4+Item 21
5+Item 22
6+Item 23
7+Item 24
8+Item 25
9+Item 26
10+│Item 27
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.goldennew file+10
@@ -0,0 +1,10 @@
1+│Item 2
2+Item 3
3+Item 4
4+Item 5
5+Item 6
6+Item 7
7+Item 8
8+Item 9
9+Item 10
10+Item 11
internal/tui/page/chat/chat.go+36
@@ -164,6 +164,13 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
164164 case tea.KeyboardEnhancementsMsg:
165165 p.keyboardEnhancements = msg
166166 return p, nil
167+ case tea.MouseWheelMsg:
168+ if p.isMouseOverChat(msg.Mouse().X, msg.Mouse().Y) {
169+ u, cmd := p.chat.Update(msg)
170+ p.chat = u.(chat.MessageListCmp)
171+ return p, cmd
172+ }
173+ return p, nil
167174 case tea.WindowSizeMsg:
168175 u, cmd := p.editor.Update(msg)
169176 p.editor = u.(editor.Editor)
@@ -606,6 +613,7 @@ func (p *chatPage) sendMessage(text string, attachments []message.Attachment) te
606613 if err != nil {
607614 return util.ReportError(err)
608615 }
616+ cmds = append(cmds, p.chat.GoToBottom())
609617 return tea.Batch(cmds...)
610618}
611619
@@ -907,3 +915,31 @@ func (p *chatPage) Help() help.KeyMap {
907915func (p *chatPage) IsChatFocused() bool {
908916 return p.focusedPane == PanelTypeChat
909917}
918+
919+// isMouseOverChat checks if the given mouse coordinates are within the chat area bounds.
920+// Returns true if the mouse is over the chat area, false otherwise.
921+func (p *chatPage) isMouseOverChat(x, y int) bool {
922+ // No session means no chat area
923+ if p.session.ID == "" {
924+ return false
925+ }
926+
927+ var chatX, chatY, chatWidth, chatHeight int
928+
929+ if p.compact {
930+ // In compact mode: chat area starts after header and spans full width
931+ chatX = 0
932+ chatY = HeaderHeight
933+ chatWidth = p.width
934+ chatHeight = p.height - EditorHeight - HeaderHeight
935+ } else {
936+ // In non-compact mode: chat area spans from left edge to sidebar
937+ chatX = 0
938+ chatY = 0
939+ chatWidth = p.width - SideBarWidth
940+ chatHeight = p.height - EditorHeight
941+ }
942+
943+ // Check if mouse coordinates are within chat bounds
944+ return x >= chatX && x < chatX+chatWidth && y >= chatY && y < chatY+chatHeight
945+}
internal/tui/tui.go+8−15
@@ -4,6 +4,7 @@ import (
44 "context"
55 "fmt"
66 "strings"
7+ "time"
78
89 "github.com/charmbracelet/bubbles/v2/key"
910 tea "github.com/charmbracelet/bubbletea/v2"
@@ -33,26 +34,18 @@ import (
3334 "github.com/charmbracelet/lipgloss/v2"
3435)
3536
36−// MouseEventFilter filters mouse events based on the current focus state
37−// This is used with tea.WithFilter to prevent mouse scroll events from
38−// interfering with typing performance in the editor
37+var lastMouseEvent time.Time
38+
3939func MouseEventFilter(m tea.Model, msg tea.Msg) tea.Msg {
40− // Only filter mouse events
4140 switch msg.(type) {
4241 case tea.MouseWheelMsg, tea.MouseMotionMsg:
43− // Check if we have an appModel and if editor is focused
44− if appModel, ok := m.(*appModel); ok {
45− if appModel.currentPage == chat.ChatPageID {
46− if chatPage, ok := appModel.pages[appModel.currentPage].(chat.ChatPage); ok {
47− // If editor is focused (not chatFocused), filter out mouse wheel/motion events
48− if !chatPage.IsChatFocused() {
49− return nil // Filter out the event
50− }
51− }
52− }
42+ now := time.Now()
43+ // trackpad is sending too many requests
44+ if now.Sub(lastMouseEvent) < 5*time.Millisecond {
45+ return nil
5346 }
47+ lastMouseEvent = now
5448 }
55− // Allow all other events to pass through
5649 return msg
5750}
5851