on main help sign in
Merge pull request #262 from charmbracelet/list
d93ecf5Kujtim Hoxha
cspell.json+1−108
@@ -1,108 +1 @@
1−{
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"]}
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−}
go.mod+1−1
@@ -12,7 +12,7 @@ require (
12 github.com/bmatcuk/doublestar/v4 v4.9.0
12 github.com/bmatcuk/doublestar/v4 v4.9.0
13 github.com/charlievieth/fastwalk v1.0.11
13 github.com/charlievieth/fastwalk v1.0.11
14 github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250716191546-1e2ffbbcf5c5
14 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
16 github.com/charmbracelet/catwalk v0.3.1
16 github.com/charmbracelet/catwalk v0.3.1
17 github.com/charmbracelet/fang v0.3.1-0.20250711140230-d5ebb8c1d674
17 github.com/charmbracelet/fang v0.3.1-0.20250711140230-d5ebb8c1d674
18 github.com/charmbracelet/glamour/v2 v2.0.0-20250516160903-6f1e2c8f9ebe
18 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
70github.com/charlievieth/fastwalk v1.0.11/go.mod h1:yGy1zbxog41ZVMcKA/i8ojXLFsuayX5VvwhQVoj9PBI=
70github.com/charlievieth/fastwalk v1.0.11/go.mod h1:yGy1zbxog41ZVMcKA/i8ojXLFsuayX5VvwhQVoj9PBI=
71github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250716191546-1e2ffbbcf5c5 h1:GTcMIfDQJKyNKS+xVt7GkNIwz+tBuQtIuiP50WpzNgs=
71github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250716191546-1e2ffbbcf5c5 h1:GTcMIfDQJKyNKS+xVt7GkNIwz+tBuQtIuiP50WpzNgs=
72github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250716191546-1e2ffbbcf5c5/go.mod h1:6HamsBKWqEC/FVHuQMHgQL+knPyvHH55HwJDHl/adMw=
72github.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=
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.20250717140350-bb75e8f6b6ac/go.mod h1:m240IQxo1/eDQ7klblSzOCAUyc3LddHcV3Rc/YEGAgw=
74+github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4.0.20250724172607-5ba56e2bec69/go.mod h1:XIQ1qQfRph6Z5o2EikCydjumo0oDInQySRHuPATzbZc=
75github.com/charmbracelet/catwalk v0.3.1 h1:MkGWspcMyE659zDkqS+9wsaCMTKRFEDBFY2A2sap6+U=
75github.com/charmbracelet/catwalk v0.3.1 h1:MkGWspcMyE659zDkqS+9wsaCMTKRFEDBFY2A2sap6+U=
76github.com/charmbracelet/catwalk v0.3.1/go.mod h1:gUUCqqZ8bk4D7ZzGTu3I77k7cC2x4exRuJBN1H2u2pc=
76github.com/charmbracelet/catwalk v0.3.1/go.mod h1:gUUCqqZ8bk4D7ZzGTu3I77k7cC2x4exRuJBN1H2u2pc=
77github.com/charmbracelet/colorprofile v0.3.1 h1:k8dTHMd7fgw4bnFd7jXTLZrSU/CQrKnL3m+AxCzDz40=
77github.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 {
112 return len(s.inner)
112 return len(s.inner)
113}
113}
114
114
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−
124// SetSlice replaces the entire slice with a new one.
115// SetSlice replaces the entire slice with a new one.
125func (s *Slice[T]) SetSlice(items []T) {
116func (s *Slice[T]) SetSlice(items []T) {
126 s.mu.Lock()
117 s.mu.Lock()
@@ -129,13 +120,6 @@ func (s *Slice[T]) SetSlice(items []T) {
129 copy(s.inner, items)
120 copy(s.inner, items)
130}
121}
131
122
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−
139// Seq returns an iterator that yields elements from the slice.
123// Seq returns an iterator that yields elements from the slice.
140func (s *Slice[T]) Seq() iter.Seq[T] {
124func (s *Slice[T]) Seq() iter.Seq[T] {
141 return func(yield func(T) bool) {
125 return func(yield func(T) bool) {
internal/csync/slices_test.go+7−19
@@ -1,6 +1,7 @@
1package csync
1package csync
2
2
3import (
3import (
4+ "slices"
4 "sync"
5 "sync"
5 "sync/atomic"
6 "sync/atomic"
6 "testing"
7 "testing"
@@ -144,7 +145,7 @@ func TestSlice(t *testing.T) {
144 require.Equal(t, 4, s.Len())
145 require.Equal(t, 4, s.Len())
145
146
146 expected := []int{1, 2, 4, 5}
147 expected := []int{1, 2, 4, 5}
147− actual := s.Slice()
148+ actual := slices.Collect(s.Seq())
148 require.Equal(t, expected, actual)
149 require.Equal(t, expected, actual)
149
150
150 // Delete out of bounds
151 // Delete out of bounds
@@ -202,7 +203,7 @@ func TestSlice(t *testing.T) {
202 s.SetSlice(newItems)
203 s.SetSlice(newItems)
203
204
204 require.Equal(t, 3, s.Len())
205 require.Equal(t, 3, s.Len())
205− require.Equal(t, newItems, s.Slice())
206+ require.Equal(t, newItems, slices.Collect(s.Seq()))
206
207
207 // Verify it's a copy
208 // Verify it's a copy
208 newItems[0] = 999
209 newItems[0] = 999
@@ -211,23 +212,15 @@ func TestSlice(t *testing.T) {
211 require.Equal(t, 10, val)
212 require.Equal(t, 10, val)
212 })
213 })
213
214
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−
222 t.Run("Slice", func(t *testing.T) {
215 t.Run("Slice", func(t *testing.T) {
223 original := []int{1, 2, 3}
216 original := []int{1, 2, 3}
224 s := NewSliceFrom(original)
217 s := NewSliceFrom(original)
225
218
226− copy := s.Slice()
219+ copied := slices.Collect(s.Seq())
227− require.Equal(t, original, copy)
220+ require.Equal(t, original, copied)
228
221
229 // Verify it's a copy
222 // Verify it's a copy
230− copy[0] = 999
223+ copied[0] = 999
231 val, ok := s.Get(0)
224 val, ok := s.Get(0)
232 require.True(t, ok)
225 require.True(t, ok)
233 require.Equal(t, 1, val)
226 require.Equal(t, 1, val)
@@ -267,18 +260,13 @@ func TestSlice(t *testing.T) {
267
260
268 // Concurrent appends
261 // Concurrent appends
269 for i := range numGoroutines {
262 for i := range numGoroutines {
270− wg.Add(1)
263+ wg.Add(2)
271 go func(start int) {
264 go func(start int) {
272 defer wg.Done()
265 defer wg.Done()
273 for j := range itemsPerGoroutine {
266 for j := range itemsPerGoroutine {
274 s.Append(start*itemsPerGoroutine + j)
267 s.Append(start*itemsPerGoroutine + j)
275 }
268 }
276 }(i)
269 }(i)
277− }
278−
279− // Concurrent reads
280− for range numGoroutines {
281− wg.Add(1)
282 go func() {
270 go func() {
283 defer wg.Done()
271 defer wg.Done()
284 for range itemsPerGoroutine {
272 for range itemsPerGoroutine {
internal/format/spinner.go+2−2
@@ -20,7 +20,7 @@ type Spinner struct {
20
20
21type model struct {
21type model struct {
22 cancel context.CancelFunc
22 cancel context.CancelFunc
23− anim anim.Anim
23+ anim *anim.Anim
24}
24}
25
25
26func (m model) Init() tea.Cmd { return m.anim.Init() }
26func (m model) Init() tea.Cmd { return m.anim.Init() }
@@ -37,7 +37,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
37 }
37 }
38 }
38 }
39 mm, cmd := m.anim.Update(msg)
39 mm, cmd := m.anim.Update(msg)
40− m.anim = mm.(anim.Anim)
40+ m.anim = mm.(*anim.Anim)
41 return m, cmd
41 return m, cmd
42}
42}
43
43
internal/tui/components/anim/anim.go+161−93
@@ -2,15 +2,20 @@
2package anim
2package anim
3
3
4import (
4import (
5+ "fmt"
5 "image/color"
6 "image/color"
6 "math/rand/v2"
7 "math/rand/v2"
7 "strings"
8 "strings"
8 "sync/atomic"
9 "sync/atomic"
9 "time"
10 "time"
10
11
12+ "github.com/zeebo/xxh3"
13+
11 tea "github.com/charmbracelet/bubbletea/v2"
14 tea "github.com/charmbracelet/bubbletea/v2"
12 "github.com/charmbracelet/lipgloss/v2"
15 "github.com/charmbracelet/lipgloss/v2"
13 "github.com/lucasb-eyer/go-colorful"
16 "github.com/lucasb-eyer/go-colorful"
17+
18+ "github.com/charmbracelet/crush/internal/csync"
14)
19)
15
20
16const (
21const (
@@ -58,6 +63,26 @@ func nextID() int {
58 return int(atomic.AddInt64(&lastID, 1))
63 return int(atomic.AddInt64(&lastID, 1))
59}
64}
60
65
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+
61// StepMsg is a message type used to trigger the next step in the animation.
86// StepMsg is a message type used to trigger the next step in the animation.
62type StepMsg struct{ id int }
87type StepMsg struct{ id int }
63
88
@@ -78,22 +103,23 @@ const ()
78type Anim struct {
103type Anim struct {
79 width int
104 width int
80 cyclingCharWidth int
105 cyclingCharWidth int
81− label []string
106+ label *csync.Slice[string]
82 labelWidth int
107 labelWidth int
83 labelColor color.Color
108 labelColor color.Color
84 startTime time.Time
109 startTime time.Time
85 birthOffsets []time.Duration
110 birthOffsets []time.Duration
86 initialFrames [][]string // frames for the initial characters
111 initialFrames [][]string // frames for the initial characters
87− initialized bool
112+ initialized atomic.Bool
88− cyclingFrames [][]string // frames for the cycling characters
113+ cyclingFrames [][]string // frames for the cycling characters
89− step int // current main frame step
114+ step atomic.Int64 // current main frame step
90− ellipsisStep int // current ellipsis frame step
115+ ellipsisStep atomic.Int64 // current ellipsis frame step
91− ellipsisFrames []string // ellipsis animation frames
116+ ellipsisFrames *csync.Slice[string] // ellipsis animation frames
92 id int
117 id int
93}
118}
94
119
95// New creates a new Anim instance with the specified width and label.
120// 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{}
97 // Validate settings.
123 // Validate settings.
98 if opts.Size < 1 {
124 if opts.Size < 1 {
99 opts.Size = defaultNumCyclingChars
125 opts.Size = defaultNumCyclingChars
@@ -109,79 +135,113 @@ func New(opts Settings) (a Anim) {
109 }
135 }
110
136
111 a.id = nextID()
137 a.id = nextID()
112−
113 a.startTime = time.Now()
138 a.startTime = time.Now()
114 a.cyclingCharWidth = opts.Size
139 a.cyclingCharWidth = opts.Size
115− a.labelWidth = lipgloss.Width(opts.Label)
116 a.labelColor = opts.LabelColor
140 a.labelColor = opts.LabelColor
117
141
118− // Total width of anim, in cells.
142+ // Check cache first
119− a.width = opts.Size
143+ cacheKey := settingsHash(opts)
120− if opts.Label != "" {
144+ cached, exists := animCacheMap.Get(cacheKey)
121− a.width += labelGapWidth + lipgloss.Width(opts.Label)
145+
122− }
146+ if exists {
123−
147+ // Use cached values
124− // Render the label
148+ a.width = cached.width
125− a.renderLabel(opts.Label)
149+ a.labelWidth = cached.labelWidth
126−
150+ a.label = csync.NewSliceFrom(cached.label)
127− // Pre-generate gradient.
151+ a.ellipsisFrames = csync.NewSliceFrom(cached.ellipsisFrames)
128− var ramp []color.Color
152+ a.initialFrames = cached.initialFrames
129− numFrames := prerenderedFrames
153+ a.cyclingFrames = cached.cyclingFrames
130− if opts.CycleColors {
131− ramp = makeGradientRamp(a.width*3, opts.GradColorA, opts.GradColorB, opts.GradColorA, opts.GradColorB)
132− numFrames = a.width * 2
133 } else {
154 } else {
134− ramp = makeGradientRamp(a.width, opts.GradColorA, opts.GradColorB)
155+ // Generate new values and cache them
135− }
156+ a.labelWidth = lipgloss.Width(opts.Label)
136
157
137− // Pre-render initial characters.
158+ // Total width of anim, in cells.
138− a.initialFrames = make([][]string, numFrames)
159+ a.width = opts.Size
139− offset := 0
160+ if opts.Label != "" {
140− for i := range a.initialFrames {
161+ a.width += labelGapWidth + lipgloss.Width(opts.Label)
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− }
162+ }
146
163
147− var c color.Color
164+ // Render the label
148− if j <= a.cyclingCharWidth {
165+ a.renderLabel(opts.Label)
149− c = ramp[j+offset]
150− } else {
151− c = opts.LabelColor
152− }
153
166
154− // Also prerender the initial character with Lip Gloss to avoid
167+ // Pre-generate gradient.
155− // processing in the render loop.
168+ var ramp []color.Color
156− a.initialFrames[i][j] = lipgloss.NewStyle().
169+ numFrames := prerenderedFrames
157− Foreground(c).
158− Render(string(initialChar))
159− }
160 if opts.CycleColors {
170 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)
162 }
175 }
163− }
164
176
165− // Prerender scrambled rune frames for the animation.
177+ // Pre-render initial characters.
166− a.cyclingFrames = make([][]string, numFrames)
178+ a.initialFrames = make([][]string, numFrames)
167− offset = 0
179+ offset := 0
168− for i := range a.cyclingFrames {
180+ for i := range a.initialFrames {
169− a.cyclingFrames[i] = make([]string, a.width)
181+ a.initialFrames[i] = make([]string, a.width+labelGapWidth+a.labelWidth)
170− for j := range a.cyclingFrames[i] {
182+ for j := range a.initialFrames[i] {
171− if j+offset >= len(ramp) {
183+ if j+offset >= len(ramp) {
172− continue // skip if we run out of colors
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++
173 }
202 }
203+ }
174
204
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+
175− // Also prerender the color with Lip Gloss here to avoid processing
215+ // Also prerender the color with Lip Gloss here to avoid processing
176− // in the render loop.
216+ // in the render loop.
177− r := availableRunes[rand.IntN(len(availableRunes))]
217+ r := availableRunes[rand.IntN(len(availableRunes))]
178− a.cyclingFrames[i][j] = lipgloss.NewStyle().
218+ a.cyclingFrames[i][j] = lipgloss.NewStyle().
179− Foreground(ramp[j+offset]).
219+ Foreground(ramp[j+offset]).
180− Render(string(r))
220+ Render(string(r))
221+ }
222+ if opts.CycleColors {
223+ offset++
224+ }
181 }
225 }
182− if opts.CycleColors {
226+
183− offset++
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
184 }
235 }
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)
185 }
245 }
186
246
187 // Random assign a birth to each character for a stagged entrance effect.
247 // Random assign a birth to each character for a stagged entrance effect.
@@ -212,28 +272,30 @@ func (a *Anim) renderLabel(label string) {
212 if a.labelWidth > 0 {
272 if a.labelWidth > 0 {
213 // Pre-render the label.
273 // Pre-render the label.
214 labelRunes := []rune(label)
274 labelRunes := []rune(label)
215− a.label = make([]string, len(labelRunes))
275+ a.label = csync.NewSlice[string]()
216− for i := range a.label {
276+ for i := range labelRunes {
217− a.label[i] = lipgloss.NewStyle().
277+ rendered := lipgloss.NewStyle().
218 Foreground(a.labelColor).
278 Foreground(a.labelColor).
219 Render(string(labelRunes[i]))
279 Render(string(labelRunes[i]))
280+ a.label.Append(rendered)
220 }
281 }
221
282
222 // Pre-render the ellipsis frames which come after the label.
283 // Pre-render the ellipsis frames which come after the label.
223− a.ellipsisFrames = make([]string, len(ellipsisFrames))
284+ a.ellipsisFrames = csync.NewSlice[string]()
224− for i, frame := range ellipsisFrames {
285+ for _, frame := range ellipsisFrames {
225− a.ellipsisFrames[i] = lipgloss.NewStyle().
286+ rendered := lipgloss.NewStyle().
226 Foreground(a.labelColor).
287 Foreground(a.labelColor).
227 Render(frame)
288 Render(frame)
289+ a.ellipsisFrames.Append(rendered)
228 }
290 }
229 } else {
291 } else {
230− a.label = nil
292+ a.label = csync.NewSlice[string]()
231− a.ellipsisFrames = nil
293+ a.ellipsisFrames = csync.NewSlice[string]()
232 }
294 }
233}
295}
234
296
235// Width returns the total width of the animation.
297// Width returns the total width of the animation.
236−func (a Anim) Width() (w int) {
298+func (a *Anim) Width() (w int) {
237 w = a.width
299 w = a.width
238 if a.labelWidth > 0 {
300 if a.labelWidth > 0 {
239 w += labelGapWidth + a.labelWidth
301 w += labelGapWidth + a.labelWidth
@@ -251,12 +313,12 @@ func (a Anim) Width() (w int) {
251}
313}
252
314
253// Init starts the animation.
315// Init starts the animation.
254−func (a Anim) Init() tea.Cmd {
316+func (a *Anim) Init() tea.Cmd {
255 return a.Step()
317 return a.Step()
256}
318}
257
319
258// Update processes animation steps (or not).
320// 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) {
260 switch msg := msg.(type) {
322 switch msg := msg.(type) {
261 case StepMsg:
323 case StepMsg:
262 if msg.id != a.id {
324 if msg.id != a.id {
@@ -264,19 +326,19 @@ func (a Anim) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
264 return a, nil
326 return a, nil
265 }
327 }
266
328
267− a.step++
329+ step := a.step.Add(1)
268− if a.step >= len(a.cyclingFrames) {
330+ if int(step) >= len(a.cyclingFrames) {
269− a.step = 0
331+ a.step.Store(0)
270 }
332 }
271
333
272− if a.initialized && a.labelWidth > 0 {
334+ if a.initialized.Load() && a.labelWidth > 0 {
273 // Manage the ellipsis animation.
335 // Manage the ellipsis animation.
274− a.ellipsisStep++
336+ ellipsisStep := a.ellipsisStep.Add(1)
275− if a.ellipsisStep >= ellipsisAnimSpeed*len(ellipsisFrames) {
337+ if int(ellipsisStep) >= ellipsisAnimSpeed*len(ellipsisFrames) {
276− a.ellipsisStep = 0
338+ a.ellipsisStep.Store(0)
277 }
339 }
278− } else if !a.initialized && time.Since(a.startTime) >= maxBirthOffset {
340+ } else if !a.initialized.Load() && time.Since(a.startTime) >= maxBirthOffset {
279− a.initialized = true
341+ a.initialized.Store(true)
280 }
342 }
281 return a, a.Step()
343 return a, a.Step()
282 default:
344 default:
@@ -285,35 +347,41 @@ func (a Anim) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
285}
347}
286
348
287// View renders the current state of the animation.
349// View renders the current state of the animation.
288−func (a Anim) View() string {
350+func (a *Anim) View() string {
289 var b strings.Builder
351 var b strings.Builder
352+ step := int(a.step.Load())
290 for i := range a.width {
353 for i := range a.width {
291 switch {
354 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]:
293 // Birth offset not reached: render initial character.
356 // Birth offset not reached: render initial character.
294− b.WriteString(a.initialFrames[a.step][i])
357+ b.WriteString(a.initialFrames[step][i])
295 case i < a.cyclingCharWidth:
358 case i < a.cyclingCharWidth:
296 // Render a cycling character.
359 // Render a cycling character.
297− b.WriteString(a.cyclingFrames[a.step][i])
360+ b.WriteString(a.cyclingFrames[step][i])
298 case i == a.cyclingCharWidth:
361 case i == a.cyclingCharWidth:
299 // Render label gap.
362 // Render label gap.
300 b.WriteString(labelGap)
363 b.WriteString(labelGap)
301 case i > a.cyclingCharWidth:
364 case i > a.cyclingCharWidth:
302 // Label.
365 // 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+ }
304 }
369 }
305 }
370 }
306 // Render animated ellipsis at the end of the label if all characters
371 // Render animated ellipsis at the end of the label if all characters
307 // have been initialized.
372 // have been initialized.
308− if a.initialized && a.labelWidth > 0 {
373+ if a.initialized.Load() && a.labelWidth > 0 {
374+ ellipsisStep := int(a.ellipsisStep.Load())
309− b.WriteString(a.ellipsisFrames[a.ellipsisStep/ellipsisAnimSpeed])
375+ if ellipsisFrame, ok := a.ellipsisFrames.Get(ellipsisStep / ellipsisAnimSpeed); ok {
376+ b.WriteString(ellipsisFrame)
377+ }
310 }
378 }
311
379
312 return b.String()
380 return b.String()
313}
381}
314
382
315// Step is a command that triggers the next step in the animation.
383// 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 {
317 return tea.Tick(time.Second/time.Duration(fps), func(t time.Time) tea.Msg {
385 return tea.Tick(time.Second/time.Duration(fps), func(t time.Time) tea.Msg {
318 return StepMsg{id: a.id}
386 return StepMsg{id: a.id}
319 })
387 })
internal/tui/components/anim/example/main.go+1−1
@@ -56,7 +56,7 @@ func (m model) View() tea.View {
56 return v
56 return v
57 }
57 }
58
58
59− if a, ok := m.anim.(anim.Anim); ok {
59+ if a, ok := m.anim.(*anim.Anim); ok {
60 l := lipgloss.NewLayer(a.View()).
60 l := lipgloss.NewLayer(a.View()).
61 Width(a.Width()).
61 Width(a.Width()).
62 X(m.w/2 - a.Width()/2).
62 X(m.w/2 - a.Width()/2).
internal/tui/components/chat/chat.go+36−22
@@ -13,7 +13,7 @@ import (
13 "github.com/charmbracelet/crush/internal/session"
13 "github.com/charmbracelet/crush/internal/session"
14 "github.com/charmbracelet/crush/internal/tui/components/chat/messages"
14 "github.com/charmbracelet/crush/internal/tui/components/chat/messages"
15 "github.com/charmbracelet/crush/internal/tui/components/core/layout"
15 "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"
17 "github.com/charmbracelet/crush/internal/tui/styles"
17 "github.com/charmbracelet/crush/internal/tui/styles"
18 "github.com/charmbracelet/crush/internal/tui/util"
18 "github.com/charmbracelet/crush/internal/tui/util"
19)
19)
@@ -40,6 +40,7 @@ type MessageListCmp interface {
40 layout.Help
40 layout.Help
41
41
42 SetSession(session.Session) tea.Cmd
42 SetSession(session.Session) tea.Cmd
43+ GoToBottom() tea.Cmd
43}
44}
44
45
45// messageListCmp implements MessageListCmp, providing a virtualized list
46// messageListCmp implements MessageListCmp, providing a virtualized list
@@ -49,8 +50,8 @@ type messageListCmp struct {
49 app *app.App
50 app *app.App
50 width, height int
51 width, height int
51 session session.Session
52 session session.Session
52− listCmp list.ListModel
53+ listCmp list.List[list.Item]
53− previousSelected int // Last selected item index for restoring focus
54+ previousSelected string // Last selected item index for restoring focus
54
55
55 lastUserMessageTime int64
56 lastUserMessageTime int64
56 defaultListKeyMap list.KeyMap
57 defaultListKeyMap list.KeyMap
@@ -61,21 +62,24 @@ type messageListCmp struct {
61func New(app *app.App) MessageListCmp {
62func New(app *app.App) MessageListCmp {
62 defaultListKeyMap := list.DefaultKeyMap()
63 defaultListKeyMap := list.DefaultKeyMap()
63 listCmp := list.New(
64 listCmp := list.New(
64− list.WithGapSize(1),
65+ []list.Item{},
65− list.WithReverse(true),
66+ list.WithGap(1),
67+ list.WithDirectionBackward(),
68+ list.WithFocus(false),
66 list.WithKeyMap(defaultListKeyMap),
69 list.WithKeyMap(defaultListKeyMap),
70+ list.WithEnableMouse(),
67 )
71 )
68 return &messageListCmp{
72 return &messageListCmp{
69 app: app,
73 app: app,
70 listCmp: listCmp,
74 listCmp: listCmp,
71− previousSelected: list.NoSelection,
75+ previousSelected: "",
72 defaultListKeyMap: defaultListKeyMap,
76 defaultListKeyMap: defaultListKeyMap,
73 }
77 }
74}
78}
75
79
76// Init initializes the component.
80// Init initializes the component.
77func (m *messageListCmp) Init() tea.Cmd {
81func (m *messageListCmp) Init() tea.Cmd {
78− return tea.Sequence(m.listCmp.Init(), m.listCmp.Blur())
82+ return m.listCmp.Init()
79}
83}
80
84
81// Update handles incoming messages and updates the component state.
85// Update handles incoming messages and updates the component state.
@@ -89,15 +93,20 @@ func (m *messageListCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
89 return m, nil
93 return m, nil
90 case SessionClearedMsg:
94 case SessionClearedMsg:
91 m.session = session.Session{}
95 m.session = session.Session{}
92− return m, m.listCmp.SetItems([]util.Model{})
96+ return m, m.listCmp.SetItems([]list.Item{})
93
97
94 case pubsub.Event[message.Message]:
98 case pubsub.Event[message.Message]:
95 cmd := m.handleMessageEvent(msg)
99 cmd := m.handleMessageEvent(msg)
96 return m, cmd
100 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
97 default:
106 default:
98 var cmds []tea.Cmd
107 var cmds []tea.Cmd
99 u, cmd := m.listCmp.Update(msg)
108 u, cmd := m.listCmp.Update(msg)
100− m.listCmp = u.(list.ListModel)
109+ m.listCmp = u.(list.List[list.Item])
101 cmds = append(cmds, cmd)
110 cmds = append(cmds, cmd)
102 return m, tea.Batch(cmds...)
111 return m, tea.Batch(cmds...)
103 }
112 }
@@ -169,7 +178,7 @@ func (m *messageListCmp) handleChildSession(event pubsub.Event[message.Message])
169
178
170 toolCall.SetNestedToolCalls(nestedToolCalls)
179 toolCall.SetNestedToolCalls(nestedToolCalls)
171 m.listCmp.UpdateItem(
180 m.listCmp.UpdateItem(
172− toolCallInx,
181+ toolCall.ID(),
173 toolCall,
182 toolCall,
174 )
183 )
175 return tea.Batch(cmds...)
184 return tea.Batch(cmds...)
@@ -233,7 +242,7 @@ func (m *messageListCmp) handleToolMessage(msg message.Message) tea.Cmd {
233 if toolCallIndex := m.findToolCallByID(items, tr.ToolCallID); toolCallIndex != NotFound {
242 if toolCallIndex := m.findToolCallByID(items, tr.ToolCallID); toolCallIndex != NotFound {
234 toolCall := items[toolCallIndex].(messages.ToolCallCmp)
243 toolCall := items[toolCallIndex].(messages.ToolCallCmp)
235 toolCall.SetToolResult(tr)
244 toolCall.SetToolResult(tr)
236− m.listCmp.UpdateItem(toolCallIndex, toolCall)
245+ m.listCmp.UpdateItem(toolCall.ID(), toolCall)
237 }
246 }
238 }
247 }
239 return nil
248 return nil
@@ -241,7 +250,7 @@ func (m *messageListCmp) handleToolMessage(msg message.Message) tea.Cmd {
241
250
242// findToolCallByID searches for a tool call with the specified ID.
251// findToolCallByID searches for a tool call with the specified ID.
243// Returns the index if found, NotFound otherwise.
252// 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 {
245 // Search backwards as tool calls are more likely to be recent
254 // Search backwards as tool calls are more likely to be recent
246 for i := len(items) - 1; i >= 0; i-- {
255 for i := len(items) - 1; i >= 0; i-- {
247 if toolCall, ok := items[i].(messages.ToolCallCmp); ok && toolCall.GetToolCall().ID == toolCallID {
256 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
274}
283}
275
284
276// findAssistantMessageAndToolCalls locates the assistant message and its tool calls.
285// 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) {
278 assistantIndex := NotFound
287 assistantIndex := NotFound
279 toolCalls := make(map[int]messages.ToolCallCmp)
288 toolCalls := make(map[int]messages.ToolCallCmp)
280
289
@@ -310,7 +319,7 @@ func (m *messageListCmp) updateAssistantMessageContent(msg message.Message, assi
310 uiMsg := items[assistantIndex].(messages.MessageCmp)
319 uiMsg := items[assistantIndex].(messages.MessageCmp)
311 uiMsg.SetMessage(msg)
320 uiMsg.SetMessage(msg)
312 m.listCmp.UpdateItem(
321 m.listCmp.UpdateItem(
313− assistantIndex,
322+ items[assistantIndex].ID(),
314 uiMsg,
323 uiMsg,
315 )
324 )
316 if msg.FinishPart() != nil && msg.FinishPart().Reason == message.FinishReasonEndTurn {
325 if msg.FinishPart() != nil && msg.FinishPart().Reason == message.FinishReasonEndTurn {
@@ -322,7 +331,8 @@ func (m *messageListCmp) updateAssistantMessageContent(msg message.Message, assi
322 )
331 )
323 }
332 }
324 } else if hasToolCallsOnly {
333 } else if hasToolCallsOnly {
334+ items := m.listCmp.Items()
325− m.listCmp.DeleteItem(assistantIndex)
335+ m.listCmp.DeleteItem(items[assistantIndex].ID())
326 }
336 }
327
337
328 return cmd
338 return cmd
@@ -349,13 +359,13 @@ func (m *messageListCmp) updateToolCalls(msg message.Message, existingToolCalls
349// updateOrAddToolCall updates an existing tool call or adds a new one.
359// updateOrAddToolCall updates an existing tool call or adds a new one.
350func (m *messageListCmp) updateOrAddToolCall(msg message.Message, tc message.ToolCall, existingToolCalls map[int]messages.ToolCallCmp) tea.Cmd {
360func (m *messageListCmp) updateOrAddToolCall(msg message.Message, tc message.ToolCall, existingToolCalls map[int]messages.ToolCallCmp) tea.Cmd {
351 // Try to find existing tool call
361 // Try to find existing tool call
352− for index, existingTC := range existingToolCalls {
362+ for _, existingTC := range existingToolCalls {
353 if tc.ID == existingTC.GetToolCall().ID {
363 if tc.ID == existingTC.GetToolCall().ID {
354 existingTC.SetToolCall(tc)
364 existingTC.SetToolCall(tc)
355 if msg.FinishPart() != nil && msg.FinishPart().Reason == message.FinishReasonCanceled {
365 if msg.FinishPart() != nil && msg.FinishPart().Reason == message.FinishReasonCanceled {
356 existingTC.SetCancelled()
366 existingTC.SetCancelled()
357 }
367 }
358− m.listCmp.UpdateItem(index, existingTC)
368+ m.listCmp.UpdateItem(tc.ID, existingTC)
359 return nil
369 return nil
360 }
370 }
361 }
371 }
@@ -400,7 +410,7 @@ func (m *messageListCmp) SetSession(session session.Session) tea.Cmd {
400 }
410 }
401
411
402 if len(sessionMessages) == 0 {
412 if len(sessionMessages) == 0 {
403− return m.listCmp.SetItems([]util.Model{})
413+ return m.listCmp.SetItems([]list.Item{})
404 }
414 }
405
415
406 // Initialize with first message timestamp
416 // Initialize with first message timestamp
@@ -427,8 +437,8 @@ func (m *messageListCmp) buildToolResultMap(messages []message.Message) map[stri
427}
437}
428
438
429// convertMessagesToUI converts database messages to UI components.
439// convertMessagesToUI converts database messages to UI components.
430−func (m *messageListCmp) convertMessagesToUI(sessionMessages []message.Message, toolResultMap map[string]message.ToolResult) []util.Model {
440+func (m *messageListCmp) convertMessagesToUI(sessionMessages []message.Message, toolResultMap map[string]message.ToolResult) []list.Item {
431− uiMessages := make([]util.Model, 0)
441+ uiMessages := make([]list.Item, 0)
432
442
433 for _, msg := range sessionMessages {
443 for _, msg := range sessionMessages {
434 switch msg.Role {
444 switch msg.Role {
@@ -447,8 +457,8 @@ func (m *messageListCmp) convertMessagesToUI(sessionMessages []message.Message,
447}
457}
448
458
449// convertAssistantMessage converts an assistant message and its tool calls to UI components.
459// 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 {
460+func (m *messageListCmp) convertAssistantMessage(msg message.Message, toolResultMap map[string]message.ToolResult) []list.Item {
451− var uiMessages []util.Model
461+ var uiMessages []list.Item
452
462
453 // Add assistant message if it should be displayed
463 // Add assistant message if it should be displayed
454 if m.shouldShowAssistantMessage(msg) {
464 if m.shouldShowAssistantMessage(msg) {
@@ -529,3 +539,7 @@ func (m *messageListCmp) IsFocused() bool {
529func (m *messageListCmp) Bindings() []key.Binding {
539func (m *messageListCmp) Bindings() []key.Binding {
530 return m.defaultListKeyMap.KeyBindings()
540 return m.defaultListKeyMap.KeyBindings()
531}
541}
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 (
11 "github.com/charmbracelet/catwalk/pkg/catwalk"
11 "github.com/charmbracelet/catwalk/pkg/catwalk"
12 "github.com/charmbracelet/lipgloss/v2"
12 "github.com/charmbracelet/lipgloss/v2"
13 "github.com/charmbracelet/x/ansi"
13 "github.com/charmbracelet/x/ansi"
14+ "github.com/google/uuid"
14
15
15 "github.com/charmbracelet/crush/internal/config"
16 "github.com/charmbracelet/crush/internal/config"
16 "github.com/charmbracelet/crush/internal/message"
17 "github.com/charmbracelet/crush/internal/message"
17 "github.com/charmbracelet/crush/internal/tui/components/anim"
18 "github.com/charmbracelet/crush/internal/tui/components/anim"
18 "github.com/charmbracelet/crush/internal/tui/components/core"
19 "github.com/charmbracelet/crush/internal/tui/components/core"
19 "github.com/charmbracelet/crush/internal/tui/components/core/layout"
20 "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"
21 "github.com/charmbracelet/crush/internal/tui/styles"
22 "github.com/charmbracelet/crush/internal/tui/styles"
22 "github.com/charmbracelet/crush/internal/tui/util"
23 "github.com/charmbracelet/crush/internal/tui/util"
23)
24)
@@ -31,6 +32,7 @@ type MessageCmp interface {
31 GetMessage() message.Message // Access to underlying message data
32 GetMessage() message.Message // Access to underlying message data
32 SetMessage(msg message.Message) // Update the message content
33 SetMessage(msg message.Message) // Update the message content
33 Spinning() bool // Animation state for loading messages
34 Spinning() bool // Animation state for loading messages
35+ ID() string
34}
36}
35
37
36// messageCmp implements the MessageCmp interface for displaying chat messages.
38// messageCmp implements the MessageCmp interface for displaying chat messages.
@@ -43,7 +45,7 @@ type messageCmp struct {
43 // Core message data and state
45 // Core message data and state
44 message message.Message // The underlying message content
46 message message.Message // The underlying message content
45 spinning bool // Whether to show loading animation
47 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
47
49
48 // Thinking viewport for displaying reasoning content
50 // Thinking viewport for displaying reasoning content
49 thinkingViewport viewport.Model
51 thinkingViewport viewport.Model
@@ -89,7 +91,7 @@ func (m *messageCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
89 m.spinning = m.shouldSpin()
91 m.spinning = m.shouldSpin()
90 if m.spinning {
92 if m.spinning {
91 u, cmd := m.anim.Update(msg)
93 u, cmd := m.anim.Update(msg)
92− m.anim = u.(anim.Anim)
94+ m.anim = u.(*anim.Anim)
93 return m, cmd
95 return m, cmd
94 }
96 }
95 }
97 }
@@ -333,19 +335,25 @@ func (m *messageCmp) Spinning() bool {
333}
335}
334
336
335type AssistantSection interface {
337type AssistantSection interface {
336− util.Model
338+ list.Item
337 layout.Sizeable
339 layout.Sizeable
338− list.SectionHeader
339}
340}
340type assistantSectionModel struct {
341type assistantSectionModel struct {
341 width int
342 width int
343+ id string
342 message message.Message
344 message message.Message
343 lastUserMessageTime time.Time
345 lastUserMessageTime time.Time
344}
346}
345
347
348+// ID implements AssistantSection.
349+func (m *assistantSectionModel) ID() string {
350+ return m.id
351+}
352+
346func NewAssistantSection(message message.Message, lastUserMessageTime time.Time) AssistantSection {
353func NewAssistantSection(message message.Message, lastUserMessageTime time.Time) AssistantSection {
347 return &assistantSectionModel{
354 return &assistantSectionModel{
348 width: 0,
355 width: 0,
356+ id: uuid.NewString(),
349 message: message,
357 message: message,
350 lastUserMessageTime: lastUserMessageTime,
358 lastUserMessageTime: lastUserMessageTime,
351 }
359 }
@@ -392,3 +400,7 @@ func (m *assistantSectionModel) SetSize(width int, height int) tea.Cmd {
392func (m *assistantSectionModel) IsSectionHeader() bool {
400func (m *assistantSectionModel) IsSectionHeader() bool {
393 return true
401 return true
394}
402}
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 {
29 GetNestedToolCalls() []ToolCallCmp // Get nested tool calls
29 GetNestedToolCalls() []ToolCallCmp // Get nested tool calls
30 SetNestedToolCalls([]ToolCallCmp) // Set nested tool calls
30 SetNestedToolCalls([]ToolCallCmp) // Set nested tool calls
31 SetIsNested(bool) // Set whether this tool call is nested
31 SetIsNested(bool) // Set whether this tool call is nested
32+ ID() string
32}
33}
33
34
34// toolCallCmp implements the ToolCallCmp interface for displaying tool calls.
35// toolCallCmp implements the ToolCallCmp interface for displaying tool calls.
@@ -296,7 +297,7 @@ func (m *toolCallCmp) SetSize(width int, height int) tea.Cmd {
296// shouldSpin determines whether the tool call should show a loading animation.
297// shouldSpin determines whether the tool call should show a loading animation.
297// Returns true if the tool call is not finished or if the result doesn't match the call ID.
298// Returns true if the tool call is not finished or if the result doesn't match the call ID.
298func (m *toolCallCmp) shouldSpin() bool {
299func (m *toolCallCmp) shouldSpin() bool {
299− return !m.call.Finished
300+ return !m.call.Finished && !m.cancelled
300}
301}
301
302
302// Spinning returns whether the tool call is currently showing a loading animation
303// Spinning returns whether the tool call is currently showing a loading animation
@@ -311,3 +312,7 @@ func (m *toolCallCmp) Spinning() bool {
311 }
312 }
312 return m.spinning
313 return m.spinning
313}
314}
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 (
14 "github.com/charmbracelet/crush/internal/config"
14 "github.com/charmbracelet/crush/internal/config"
15 "github.com/charmbracelet/crush/internal/llm/prompt"
15 "github.com/charmbracelet/crush/internal/llm/prompt"
16 "github.com/charmbracelet/crush/internal/tui/components/chat"
16 "github.com/charmbracelet/crush/internal/tui/components/chat"
17− "github.com/charmbracelet/crush/internal/tui/components/completions"
18 "github.com/charmbracelet/crush/internal/tui/components/core"
17 "github.com/charmbracelet/crush/internal/tui/components/core"
19 "github.com/charmbracelet/crush/internal/tui/components/core/layout"
18 "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/components/dialogs/models"
19 "github.com/charmbracelet/crush/internal/tui/components/dialogs/models"
22 "github.com/charmbracelet/crush/internal/tui/components/logo"
20 "github.com/charmbracelet/crush/internal/tui/components/logo"
21+ "github.com/charmbracelet/crush/internal/tui/exp/list"
23 "github.com/charmbracelet/crush/internal/tui/styles"
22 "github.com/charmbracelet/crush/internal/tui/styles"
24 "github.com/charmbracelet/crush/internal/tui/util"
23 "github.com/charmbracelet/crush/internal/tui/util"
25 "github.com/charmbracelet/crush/internal/version"
24 "github.com/charmbracelet/crush/internal/version"
@@ -86,9 +85,7 @@ func New() Splash {
86 listKeyMap.DownOneItem = keyMap.Next
85 listKeyMap.DownOneItem = keyMap.Next
87 listKeyMap.UpOneItem = keyMap.Previous
86 listKeyMap.UpOneItem = keyMap.Previous
88
87
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)
92 apiKeyInput := models.NewAPIKeyInput()
89 apiKeyInput := models.NewAPIKeyInput()
93
90
94 return &splashCmp{
91 return &splashCmp{
@@ -195,20 +192,18 @@ func (s *splashCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
195 return s, s.saveAPIKeyAndContinue(s.apiKeyValue)
192 return s, s.saveAPIKeyAndContinue(s.apiKeyValue)
196 }
193 }
197 if s.isOnboarding && !s.needsAPIKey {
194 if s.isOnboarding && !s.needsAPIKey {
198− modelInx := s.modelList.SelectedIndex()
195+ selectedItem := s.modelList.SelectedModel()
199− if modelInx == -1 {
196+ if selectedItem == nil {
200 return s, nil
197 return s, nil
201 }
198 }
202− items := s.modelList.Items()
203− selectedItem := items[modelInx].(completions.CompletionItem).Value().(models.ModelOption)
204 if s.isProviderConfigured(string(selectedItem.Provider.ID)) {
199 if s.isProviderConfigured(string(selectedItem.Provider.ID)) {
205− cmd := s.setPreferredModel(selectedItem)
200+ cmd := s.setPreferredModel(*selectedItem)
206 s.isOnboarding = false
201 s.isOnboarding = false
207 return s, tea.Batch(cmd, util.CmdHandler(OnboardingCompleteMsg{}))
202 return s, tea.Batch(cmd, util.CmdHandler(OnboardingCompleteMsg{}))
208 } else {
203 } else {
209 // Provider not configured, show API key input
204 // Provider not configured, show API key input
210 s.needsAPIKey = true
205 s.needsAPIKey = true
211− s.selectedModel = &selectedItem
206+ s.selectedModel = selectedItem
212 s.apiKeyInput.SetProviderName(selectedItem.Provider.Name)
207 s.apiKeyInput.SetProviderName(selectedItem.Provider.Name)
213 return s, nil
208 return s, nil
214 }
209 }
@@ -267,6 +262,9 @@ func (s *splashCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
267 return s, nil
262 return s, nil
268 }
263 }
269 case key.Matches(msg, s.keyMap.Yes):
264 case key.Matches(msg, s.keyMap.Yes):
265+ if s.isOnboarding {
266+ return s, nil
267+ }
270 if s.needsAPIKey {
268 if s.needsAPIKey {
271 u, cmd := s.apiKeyInput.Update(msg)
269 u, cmd := s.apiKeyInput.Update(msg)
272 s.apiKeyInput = u.(*models.APIKeyInput)
270 s.apiKeyInput = u.(*models.APIKeyInput)
@@ -277,6 +275,9 @@ func (s *splashCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
277 return s, s.initializeProject()
275 return s, s.initializeProject()
278 }
276 }
279 case key.Matches(msg, s.keyMap.No):
277 case key.Matches(msg, s.keyMap.No):
278+ if s.isOnboarding {
279+ return s, nil
280+ }
280 if s.needsAPIKey {
281 if s.needsAPIKey {
281 u, cmd := s.apiKeyInput.Update(msg)
282 u, cmd := s.apiKeyInput.Update(msg)
282 s.apiKeyInput = u.(*models.APIKeyInput)
283 s.apiKeyInput = u.(*models.APIKeyInput)
@@ -606,7 +607,7 @@ func (s *splashCmp) moveCursor(cursor *tea.Cursor) *tea.Cursor {
606 cursor.Y += offset
607 cursor.Y += offset
607 cursor.X = cursor.X + 1
608 cursor.X = cursor.X + 1
608 } else if s.isOnboarding {
609 } else if s.isOnboarding {
609− offset := logoHeight + SplashScreenPaddingY + s.logoGap() + 3
610+ offset := logoHeight + SplashScreenPaddingY + s.logoGap() + 2
610 cursor.Y += offset
611 cursor.Y += offset
611 cursor.X = cursor.X + 1
612 cursor.X = cursor.X + 1
612 }
613 }
internal/tui/components/completions/completions.go+38−36
@@ -5,7 +5,7 @@ import (
5
5
6 "github.com/charmbracelet/bubbles/v2/key"
6 "github.com/charmbracelet/bubbles/v2/key"
7 tea "github.com/charmbracelet/bubbletea/v2"
7 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"
9 "github.com/charmbracelet/crush/internal/tui/styles"
9 "github.com/charmbracelet/crush/internal/tui/styles"
10 "github.com/charmbracelet/crush/internal/tui/util"
10 "github.com/charmbracelet/crush/internal/tui/util"
11 "github.com/charmbracelet/lipgloss/v2"
11 "github.com/charmbracelet/lipgloss/v2"
@@ -56,6 +56,8 @@ type Completions interface {
56 Height() int
56 Height() int
57}
57}
58
58
59+type listModel = list.FilterableList[list.CompletionItem[any]]
60+
59type completionsCmp struct {
61type completionsCmp struct {
60 wWidth int // The window width
62 wWidth int // The window width
61 wHeight int // The window height
63 wHeight int // The window height
@@ -67,7 +69,7 @@ type completionsCmp struct {
67 open bool // Indicates if the completions are open
69 open bool // Indicates if the completions are open
68 keyMap KeyMap
70 keyMap KeyMap
69
71
70− list list.ListModel
72+ list listModel
71 query string // The current filter query
73 query string // The current filter query
72}
74}
73
75
@@ -83,10 +85,13 @@ func New() Completions {
83 keyMap.UpOneItem = completionsKeyMap.Up
85 keyMap.UpOneItem = completionsKeyMap.Up
84 keyMap.DownOneItem = completionsKeyMap.Down
86 keyMap.DownOneItem = completionsKeyMap.Down
85
87
86− l := list.New(
88+ l := list.NewFilterableList(
87− list.WithReverse(true),
89+ []list.CompletionItem[any]{},
90+ list.WithFilterInputHidden(),
91+ list.WithFilterListOptions(
92+ list.WithDirectionBackward(),
88− list.WithKeyMap(keyMap),
93+ list.WithKeyMap(keyMap),
89− list.WithHideFilterInput(true),
94+ ),
90 )
95 )
91 return &completionsCmp{
96 return &completionsCmp{
92 width: 0,
97 width: 0,
@@ -115,47 +120,44 @@ func (c *completionsCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
115 switch {
120 switch {
116 case key.Matches(msg, c.keyMap.Up):
121 case key.Matches(msg, c.keyMap.Up):
117 u, cmd := c.list.Update(msg)
122 u, cmd := c.list.Update(msg)
118− c.list = u.(list.ListModel)
123+ c.list = u.(listModel)
119 return c, cmd
124 return c, cmd
120
125
121 case key.Matches(msg, c.keyMap.Down):
126 case key.Matches(msg, c.keyMap.Down):
122 d, cmd := c.list.Update(msg)
127 d, cmd := c.list.Update(msg)
123− c.list = d.(list.ListModel)
128+ c.list = d.(listModel)
124 return c, cmd
129 return c, cmd
125 case key.Matches(msg, c.keyMap.UpInsert):
130 case key.Matches(msg, c.keyMap.UpInsert):
126− selectedItemInx := c.list.SelectedIndex() - 1
131+ s := c.list.SelectedItem()
127− items := c.list.Items()
132+ if s == nil {
128− if selectedItemInx == list.NoSelection || selectedItemInx < 0 {
129− return c, nil // No item selected, do nothing
133+ return c, nil
130 }
134 }
131− selectedItem := items[selectedItemInx].(CompletionItem).Value()
135+ selectedItem := *s
132− c.list.SetSelected(selectedItemInx)
136+ c.list.SetSelected(selectedItem.ID())
133 return c, util.CmdHandler(SelectCompletionMsg{
137 return c, util.CmdHandler(SelectCompletionMsg{
134− Value: selectedItem,
138+ Value: selectedItem.Value(),
135 Insert: true,
139 Insert: true,
136 })
140 })
137 case key.Matches(msg, c.keyMap.DownInsert):
141 case key.Matches(msg, c.keyMap.DownInsert):
138− selectedItemInx := c.list.SelectedIndex() + 1
142+ s := c.list.SelectedItem()
139− items := c.list.Items()
143+ if s == nil {
140− if selectedItemInx == list.NoSelection || selectedItemInx >= len(items) {
141− return c, nil // No item selected, do nothing
144+ return c, nil
142 }
145 }
143− selectedItem := items[selectedItemInx].(CompletionItem).Value()
146+ selectedItem := *s
144− c.list.SetSelected(selectedItemInx)
147+ c.list.SetSelected(selectedItem.ID())
145 return c, util.CmdHandler(SelectCompletionMsg{
148 return c, util.CmdHandler(SelectCompletionMsg{
146− Value: selectedItem,
149+ Value: selectedItem.Value(),
147 Insert: true,
150 Insert: true,
148 })
151 })
149 case key.Matches(msg, c.keyMap.Select):
152 case key.Matches(msg, c.keyMap.Select):
150− selectedItemInx := c.list.SelectedIndex()
153+ s := c.list.SelectedItem()
151− if selectedItemInx == list.NoSelection {
154+ if s == nil {
152− return c, nil // No item selected, do nothing
155+ return c, nil
153 }
156 }
154− items := c.list.Items()
157+ selectedItem := *s
155− selectedItem := items[selectedItemInx].(CompletionItem).Value()
156 c.open = false // Close completions after selection
158 c.open = false // Close completions after selection
157 return c, util.CmdHandler(SelectCompletionMsg{
159 return c, util.CmdHandler(SelectCompletionMsg{
158− Value: selectedItem,
160+ Value: selectedItem.Value(),
159 })
161 })
160 case key.Matches(msg, c.keyMap.Cancel):
162 case key.Matches(msg, c.keyMap.Cancel):
161 return c, util.CmdHandler(CloseCompletionsMsg{})
163 return c, util.CmdHandler(CloseCompletionsMsg{})
@@ -171,10 +173,14 @@ func (c *completionsCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
171 c.query = ""
173 c.query = ""
172 c.x, c.xorig = msg.X, msg.X
174 c.x, c.xorig = msg.X, msg.X
173 c.y = msg.Y
175 c.y = msg.Y
174− items := []util.Model{}
176+ items := []list.CompletionItem[any]{}
175 t := styles.CurrentTheme()
177 t := styles.CurrentTheme()
176 for _, completion := range msg.Completions {
178 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+ )
178 items = append(items, item)
184 items = append(items, item)
179 }
185 }
180 width := listWidth(items)
186 width := listWidth(items)
@@ -261,18 +267,14 @@ func (c *completionsCmp) View() string {
261// listWidth returns the width of the last 10 items in the list, which is used
267// listWidth returns the width of the last 10 items in the list, which is used
262// to determine the width of the completions popup.
268// to determine the width of the completions popup.
263// Note this only works for [completionItemCmp] items.
269// Note this only works for [completionItemCmp] items.
264−func listWidth[T any](items []T) int {
270+func listWidth(items []list.CompletionItem[any]) int {
265 var width int
271 var width int
266 if len(items) == 0 {
272 if len(items) == 0 {
267 return width
273 return width
268 }
274 }
269
275
270 for i := len(items) - 1; i >= 0 && i >= len(items)-10; i-- {
276 for i := len(items) - 1; i >= 0 && i >= len(items)-10; i-- {
271− item, ok := any(items[i]).(*completionItemCmp)
277+ itemWidth := lipgloss.Width(items[i].Text()) + 2 // +2 for padding
272− if !ok {
273− continue
274− }
275− itemWidth := lipgloss.Width(item.text) + 2 // +2 for padding
276 width = max(width, itemWidth)
278 width = max(width, itemWidth)
277 }
279 }
278
280
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 (
10 "github.com/charmbracelet/crush/internal/config"
10 "github.com/charmbracelet/crush/internal/config"
11 "github.com/charmbracelet/crush/internal/llm/prompt"
11 "github.com/charmbracelet/crush/internal/llm/prompt"
12 "github.com/charmbracelet/crush/internal/tui/components/chat"
12 "github.com/charmbracelet/crush/internal/tui/components/chat"
13− "github.com/charmbracelet/crush/internal/tui/components/completions"
14 "github.com/charmbracelet/crush/internal/tui/components/core"
13 "github.com/charmbracelet/crush/internal/tui/components/core"
15− "github.com/charmbracelet/crush/internal/tui/components/core/list"
16 "github.com/charmbracelet/crush/internal/tui/components/dialogs"
14 "github.com/charmbracelet/crush/internal/tui/components/dialogs"
15+ "github.com/charmbracelet/crush/internal/tui/exp/list"
17 "github.com/charmbracelet/crush/internal/tui/styles"
16 "github.com/charmbracelet/crush/internal/tui/styles"
18 "github.com/charmbracelet/crush/internal/tui/util"
17 "github.com/charmbracelet/crush/internal/tui/util"
19)
18)
@@ -29,6 +28,8 @@ const (
29 UserCommands
28 UserCommands
30)
29)
31
30
31+type listModel = list.FilterableList[list.CompletionItem[Command]]
32+
32// Command represents a command that can be executed
33// Command represents a command that can be executed
33type Command struct {
34type Command struct {
34 ID string
35 ID string
@@ -48,7 +49,7 @@ type commandDialogCmp struct {
48 wWidth int // Width of the terminal window
49 wWidth int // Width of the terminal window
49 wHeight int // Height of the terminal window
50 wHeight int // Height of the terminal window
50
51
51− commandList list.ListModel
52+ commandList listModel
52 keyMap CommandsDialogKeyMap
53 keyMap CommandsDialogKeyMap
53 help help.Model
54 help help.Model
54 commandType int // SystemCommands or UserCommands
55 commandType int // SystemCommands or UserCommands
@@ -67,24 +68,23 @@ type (
67)
68)
68
69
69func NewCommandDialog(sessionID string) CommandsDialog {
70func NewCommandDialog(sessionID string) CommandsDialog {
70− listKeyMap := list.DefaultKeyMap()
71 keyMap := DefaultCommandsDialogKeyMap()
71 keyMap := DefaultCommandsDialogKeyMap()
72−
72+ listKeyMap := list.DefaultKeyMap()
73 listKeyMap.Down.SetEnabled(false)
73 listKeyMap.Down.SetEnabled(false)
74 listKeyMap.Up.SetEnabled(false)
74 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−
80 listKeyMap.DownOneItem = keyMap.Next
75 listKeyMap.DownOneItem = keyMap.Next
81 listKeyMap.UpOneItem = keyMap.Previous
76 listKeyMap.UpOneItem = keyMap.Previous
82
77
83 t := styles.CurrentTheme()
78 t := styles.CurrentTheme()
79+ inputStyle := t.S().Base.PaddingLeft(1).PaddingBottom(1)
84− commandList := list.New(
80+ commandList := list.NewFilterableList(
85− list.WithFilterable(true),
81+ []list.CompletionItem[Command]{},
82+ list.WithFilterInputStyle(inputStyle),
83+ list.WithFilterListOptions(
86− list.WithKeyMap(listKeyMap),
84+ list.WithKeyMap(listKeyMap),
87− list.WithWrapNavigation(true),
85+ list.WithWrapNavigation(),
86+ list.WithResizeByList(),
87+ ),
88 )
88 )
89 help := help.New()
89 help := help.New()
90 help.Styles = t.S().Help
90 help.Styles = t.S().Help
@@ -103,10 +103,8 @@ func (c *commandDialogCmp) Init() tea.Cmd {
103 if err != nil {
103 if err != nil {
104 return util.ReportError(err)
104 return util.ReportError(err)
105 }
105 }
106−
107 c.userCommands = commands
106 c.userCommands = commands
108− c.SetCommandType(c.commandType)
107+ return c.SetCommandType(c.commandType)
109− return c.commandList.Init()
110}
108}
111
109
112func (c *commandDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
110func (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) {
114 case tea.WindowSizeMsg:
112 case tea.WindowSizeMsg:
115 c.wWidth = msg.Width
113 c.wWidth = msg.Width
116 c.wHeight = msg.Height
114 c.wHeight = msg.Height
117− c.SetCommandType(c.commandType)
118 return c, c.commandList.SetSize(c.listWidth(), c.listHeight())
115 return c, c.commandList.SetSize(c.listWidth(), c.listHeight())
119 case tea.KeyPressMsg:
116 case tea.KeyPressMsg:
120 switch {
117 switch {
121 case key.Matches(msg, c.keyMap.Select):
118 case key.Matches(msg, c.keyMap.Select):
122− selectedItemInx := c.commandList.SelectedIndex()
119+ selectedItem := c.commandList.SelectedItem()
123− if selectedItemInx == list.NoSelection {
120+ if selectedItem == nil {
124 return c, nil // No item selected, do nothing
121 return c, nil // No item selected, do nothing
125 }
122 }
126− items := c.commandList.Items()
123+ command := (*selectedItem).Value()
127− selectedItem := items[selectedItemInx].(completions.CompletionItem).Value().(Command)
128 return c, tea.Sequence(
124 return c, tea.Sequence(
129 util.CmdHandler(dialogs.CloseDialogMsg{}),
125 util.CmdHandler(dialogs.CloseDialogMsg{}),
130− selectedItem.Handler(selectedItem),
126+ command.Handler(command),
131 )
127 )
132 case key.Matches(msg, c.keyMap.Tab):
128 case key.Matches(msg, c.keyMap.Tab):
129+ if len(c.userCommands) == 0 {
130+ return c, nil
131+ }
133 // Toggle command type between System and User commands
132 // Toggle command type between System and User commands
134 if c.commandType == SystemCommands {
133 if c.commandType == SystemCommands {
135 return c, c.SetCommandType(UserCommands)
134 return c, c.SetCommandType(UserCommands)
@@ -140,7 +139,7 @@ func (c *commandDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
140 return c, util.CmdHandler(dialogs.CloseDialogMsg{})
139 return c, util.CmdHandler(dialogs.CloseDialogMsg{})
141 default:
140 default:
142 u, cmd := c.commandList.Update(msg)
141 u, cmd := c.commandList.Update(msg)
143− c.commandList = u.(list.ListModel)
142+ c.commandList = u.(listModel)
144 return c, cmd
143 return c, cmd
145 }
144 }
146 }
145 }
@@ -151,9 +150,14 @@ func (c *commandDialogCmp) View() string {
151 t := styles.CurrentTheme()
150 t := styles.CurrentTheme()
152 listView := c.commandList
151 listView := c.commandList
153 radio := c.commandTypeRadio()
152 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+ }
154 content := lipgloss.JoinVertical(
158 content := lipgloss.JoinVertical(
155 lipgloss.Left,
159 lipgloss.Left,
156− t.S().Base.Padding(0, 1, 1, 1).Render(core.Title("Commands", c.width-lipgloss.Width(radio)-5)+" "+radio),
160+ header,
157 listView.View(),
161 listView.View(),
158 "",
162 "",
159 t.S().Base.Width(c.width-2).PaddingLeft(1).AlignHorizontal(lipgloss.Left).Render(c.help.View(c.keyMap)),
163 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 {
197 commands = c.userCommands
201 commands = c.userCommands
198 }
202 }
199
203
200− commandItems := []util.Model{}
204+ commandItems := []list.CompletionItem[Command]{}
201 for _, cmd := range commands {
205 for _, cmd := range commands {
202− opts := []completions.CompletionOption{}
206+ opts := []list.CompletionItemOption{
207+ list.WithCompletionID(cmd.ID),
208+ }
203 if cmd.Shortcut != "" {
209 if cmd.Shortcut != "" {
204− opts = append(opts, completions.WithShortcut(cmd.Shortcut))
210+ opts = append(
211+ opts,
212+ list.WithCompletionShortcut(cmd.Shortcut),
213+ )
205 }
214 }
206− commandItems = append(commandItems, completions.NewCompletionItem(cmd.Title, cmd, opts...))
215+ commandItems = append(commandItems, list.NewCompletionItem(cmd.Title, cmd, opts...))
207 }
216 }
208 return c.commandList.SetItems(commandItems)
217 return c.commandList.SetItems(commandItems)
209}
218}
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 (
7 tea "github.com/charmbracelet/bubbletea/v2"
7 tea "github.com/charmbracelet/bubbletea/v2"
8 "github.com/charmbracelet/catwalk/pkg/catwalk"
8 "github.com/charmbracelet/catwalk/pkg/catwalk"
9 "github.com/charmbracelet/crush/internal/config"
9 "github.com/charmbracelet/crush/internal/config"
10− "github.com/charmbracelet/crush/internal/tui/components/completions"
10+ "github.com/charmbracelet/crush/internal/tui/exp/list"
11− "github.com/charmbracelet/crush/internal/tui/components/core/list"
12− "github.com/charmbracelet/crush/internal/tui/components/dialogs/commands"
13 "github.com/charmbracelet/crush/internal/tui/styles"
11 "github.com/charmbracelet/crush/internal/tui/styles"
14 "github.com/charmbracelet/crush/internal/tui/util"
12 "github.com/charmbracelet/crush/internal/tui/util"
15− "github.com/charmbracelet/lipgloss/v2"
16)
13)
17
14
15+type listModel = list.FilterableGroupList[list.CompletionItem[ModelOption]]
16+
18type ModelListComponent struct {
17type ModelListComponent struct {
19− list list.ListModel
18+ list listModel
20 modelType int
19 modelType int
21 providers []catwalk.Provider
20 providers []catwalk.Provider
22}
21}
23
22
24−func NewModelListComponent(keyMap list.KeyMap, inputStyle lipgloss.Style, inputPlaceholder string) *ModelListComponent {
23+func NewModelListComponent(keyMap list.KeyMap, inputPlaceholder string, shouldResize bool) *ModelListComponent {
25− modelList := list.New(
24+ t := styles.CurrentTheme()
26− list.WithFilterable(true),
25+ inputStyle := t.S().Base.PaddingLeft(1).PaddingBottom(1)
26+ options := []list.ListOption{
27 list.WithKeyMap(keyMap),
27 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),
29 list.WithFilterPlaceholder(inputPlaceholder),
36 list.WithFilterPlaceholder(inputPlaceholder),
30− list.WithWrapNavigation(true),
37+ list.WithFilterListOptions(
38+ options...,
39+ ),
31 )
40 )
32
41
33 return &ModelListComponent{
42 return &ModelListComponent{
@@ -51,7 +60,7 @@ func (m *ModelListComponent) Init() tea.Cmd {
51
60
52func (m *ModelListComponent) Update(msg tea.Msg) (*ModelListComponent, tea.Cmd) {
61func (m *ModelListComponent) Update(msg tea.Msg) (*ModelListComponent, tea.Cmd) {
53 u, cmd := m.list.Update(msg)
62 u, cmd := m.list.Update(msg)
54− m.list = u.(list.ListModel)
63+ m.list = u.(listModel)
55 return m, cmd
64 return m, cmd
56}
65}
57
66
@@ -67,21 +76,23 @@ func (m *ModelListComponent) SetSize(width, height int) tea.Cmd {
67 return m.list.SetSize(width, height)
76 return m.list.SetSize(width, height)
68}
77}
69
78
70−func (m *ModelListComponent) Items() []util.Model {
79+func (m *ModelListComponent) SelectedModel() *ModelOption {
71− return m.list.Items()
80+ s := m.list.SelectedItem()
81+ if s == nil {
82+ return nil
72−}
83+ }
73−
84+ sv := *s
74−func (m *ModelListComponent) SelectedIndex() int {
85+ model := sv.Value()
75− return m.list.SelectedIndex()
86+ return &model
76}
87}
77
88
78func (m *ModelListComponent) SetModelType(modelType int) tea.Cmd {
89func (m *ModelListComponent) SetModelType(modelType int) tea.Cmd {
79 t := styles.CurrentTheme()
90 t := styles.CurrentTheme()
80 m.modelType = modelType
91 m.modelType = modelType
81
92
82− modelItems := []util.Model{}
93+ var groups []list.Group[list.CompletionItem[ModelOption]]
83 // first none section
94 // first none section
84− selectIndex := 1
95+ selectedItemID := ""
85
96
86 cfg := config.Get()
97 cfg := config.Get()
87 var currentModel config.SelectedModel
98 var currentModel config.SelectedModel
@@ -140,18 +151,28 @@ func (m *ModelListComponent) SetModelType(modelType int) tea.Cmd {
140 if name == "" {
151 if name == "" {
141 name = string(configProvider.ID)
152 name = string(configProvider.ID)
142 }
153 }
143− section := commands.NewItemSection(name)
154+ section := list.NewItemSection(name)
144 section.SetInfo(configured)
155 section.SetInfo(configured)
145− modelItems = append(modelItems, section)
156+ group := list.Group[list.CompletionItem[ModelOption]]{
157+ Section: section,
158+ }
146 for _, model := range configProvider.Models {
159 for _, model := range configProvider.Models {
147− modelItems = append(modelItems, completions.NewCompletionItem(model.Name, ModelOption{
160+ item := list.NewCompletionItem(model.Name, ModelOption{
148 Provider: configProvider,
161 Provider: configProvider,
149 Model: model,
162 Model: model,
163+ },
164+ list.WithCompletionID(
165+ fmt.Sprintf("%s:%s", providerConfig.ID, model.ID),
166+ ),
150− }))
167+ )
168+
169+ group.Items = append(group.Items, item)
151 if model.ID == currentModel.Model && string(configProvider.ID) == currentModel.Provider {
170 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()
153 }
172 }
154 }
173 }
174+ groups = append(groups, group)
175+
155 addedProviders[providerID] = true
176 addedProviders[providerID] = true
156 }
177 }
157 }
178 }
@@ -173,23 +194,43 @@ func (m *ModelListComponent) SetModelType(modelType int) tea.Cmd {
173 name = string(provider.ID)
194 name = string(provider.ID)
174 }
195 }
175
196
176− section := commands.NewItemSection(name)
197+ section := list.NewItemSection(name)
177 if _, ok := cfg.Providers.Get(string(provider.ID)); ok {
198 if _, ok := cfg.Providers.Get(string(provider.ID)); ok {
178 section.SetInfo(configured)
199 section.SetInfo(configured)
179 }
200 }
180− modelItems = append(modelItems, section)
201+ group := list.Group[list.CompletionItem[ModelOption]]{
202+ Section: section,
203+ }
181 for _, model := range provider.Models {
204 for _, model := range provider.Models {
182− modelItems = append(modelItems, completions.NewCompletionItem(model.Name, ModelOption{
205+ item := list.NewCompletionItem(model.Name, ModelOption{
183 Provider: provider,
206 Provider: provider,
184 Model: model,
207 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)
186 if model.ID == currentModel.Model && string(provider.ID) == currentModel.Provider {
214 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()
188 }
216 }
189 }
217 }
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)
190 }
231 }
191
232
192− return tea.Sequence(m.list.SetItems(modelItems), m.list.SetSelected(selectIndex))
233+ return tea.Sequence(cmds...)
193}
234}
194
235
195// GetModelType returns the current model type
236// GetModelType returns the current model type
@@ -198,7 +239,7 @@ func (m *ModelListComponent) GetModelType() int {
198}
239}
199
240
200func (m *ModelListComponent) SetInputPlaceholder(placeholder string) {
241func (m *ModelListComponent) SetInputPlaceholder(placeholder string) {
201− m.list.SetFilterPlaceholder(placeholder)
242+ m.list.SetInputPlaceholder(placeholder)
202}
243}
203
244
204func (m *ModelListComponent) SetProviders(providers []catwalk.Provider) {
245func (m *ModelListComponent) SetProviders(providers []catwalk.Provider) {
internal/tui/components/dialogs/models/models.go+7−21
@@ -10,10 +10,9 @@ import (
10 tea "github.com/charmbracelet/bubbletea/v2"
10 tea "github.com/charmbracelet/bubbletea/v2"
11 "github.com/charmbracelet/catwalk/pkg/catwalk"
11 "github.com/charmbracelet/catwalk/pkg/catwalk"
12 "github.com/charmbracelet/crush/internal/config"
12 "github.com/charmbracelet/crush/internal/config"
13− "github.com/charmbracelet/crush/internal/tui/components/completions"
14 "github.com/charmbracelet/crush/internal/tui/components/core"
13 "github.com/charmbracelet/crush/internal/tui/components/core"
15− "github.com/charmbracelet/crush/internal/tui/components/core/list"
16 "github.com/charmbracelet/crush/internal/tui/components/dialogs"
14 "github.com/charmbracelet/crush/internal/tui/components/dialogs"
15+ "github.com/charmbracelet/crush/internal/tui/exp/list"
17 "github.com/charmbracelet/crush/internal/tui/styles"
16 "github.com/charmbracelet/crush/internal/tui/styles"
18 "github.com/charmbracelet/crush/internal/tui/util"
17 "github.com/charmbracelet/crush/internal/tui/util"
19 "github.com/charmbracelet/lipgloss/v2"
18 "github.com/charmbracelet/lipgloss/v2"
@@ -71,22 +70,16 @@ type modelDialogCmp struct {
71}
70}
72
71
73func NewModelDialogCmp() ModelDialog {
72func NewModelDialogCmp() ModelDialog {
74− listKeyMap := list.DefaultKeyMap()
75 keyMap := DefaultKeyMap()
73 keyMap := DefaultKeyMap()
76
74
75+ listKeyMap := list.DefaultKeyMap()
77 listKeyMap.Down.SetEnabled(false)
76 listKeyMap.Down.SetEnabled(false)
78 listKeyMap.Up.SetEnabled(false)
77 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−
84 listKeyMap.DownOneItem = keyMap.Next
78 listKeyMap.DownOneItem = keyMap.Next
85 listKeyMap.UpOneItem = keyMap.Previous
79 listKeyMap.UpOneItem = keyMap.Previous
86
80
87 t := styles.CurrentTheme()
81 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)
90 apiKeyInput := NewAPIKeyInput()
83 apiKeyInput := NewAPIKeyInput()
91 apiKeyInput.SetShowTitle(false)
84 apiKeyInput.SetShowTitle(false)
92 help := help.New()
85 help := help.New()
@@ -162,12 +155,7 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
162 )
155 )
163 }
156 }
164 // Normal model selection
157 // Normal model selection
165− selectedItemInx := m.modelList.SelectedIndex()
158+ selectedItem := m.modelList.SelectedModel()
166− if selectedItemInx == list.NoSelection {
167− return m, nil
168− }
169− items := m.modelList.Items()
170− selectedItem := items[selectedItemInx].(completions.CompletionItem).Value().(ModelOption)
171
159
172 var modelType config.SelectedModelType
160 var modelType config.SelectedModelType
173 if m.modelList.GetModelType() == LargeModelType {
161 if m.modelList.GetModelType() == LargeModelType {
@@ -191,7 +179,7 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
191 } else {
179 } else {
192 // Provider not configured, show API key input
180 // Provider not configured, show API key input
193 m.needsAPIKey = true
181 m.needsAPIKey = true
194− m.selectedModel = &selectedItem
182+ m.selectedModel = selectedItem
195 m.selectedModelType = modelType
183 m.selectedModelType = modelType
196 m.apiKeyInput.SetProviderName(selectedItem.Provider.Name)
184 m.apiKeyInput.SetProviderName(selectedItem.Provider.Name)
197 return m, nil
185 return m, nil
@@ -310,13 +298,11 @@ func (m *modelDialogCmp) style() lipgloss.Style {
310}
298}
311
299
312func (m *modelDialogCmp) listWidth() int {
300func (m *modelDialogCmp) listWidth() int {
313− return defaultWidth - 2 // 4 for padding
301+ return m.width - 2
314}
302}
315
303
316func (m *modelDialogCmp) listHeight() int {
304func (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
320}
306}
321
307
322func (m *modelDialogCmp) Position() (int, int) {
308func (m *modelDialogCmp) Position() (int, int) {
internal/tui/components/dialogs/sessions/sessions.go+29−34
@@ -6,10 +6,9 @@ import (
6 tea "github.com/charmbracelet/bubbletea/v2"
6 tea "github.com/charmbracelet/bubbletea/v2"
7 "github.com/charmbracelet/crush/internal/session"
7 "github.com/charmbracelet/crush/internal/session"
8 "github.com/charmbracelet/crush/internal/tui/components/chat"
8 "github.com/charmbracelet/crush/internal/tui/components/chat"
9− "github.com/charmbracelet/crush/internal/tui/components/completions"
10 "github.com/charmbracelet/crush/internal/tui/components/core"
9 "github.com/charmbracelet/crush/internal/tui/components/core"
11− "github.com/charmbracelet/crush/internal/tui/components/core/list"
12 "github.com/charmbracelet/crush/internal/tui/components/dialogs"
10 "github.com/charmbracelet/crush/internal/tui/components/dialogs"
11+ "github.com/charmbracelet/crush/internal/tui/exp/list"
13 "github.com/charmbracelet/crush/internal/tui/styles"
12 "github.com/charmbracelet/crush/internal/tui/styles"
14 "github.com/charmbracelet/crush/internal/tui/util"
13 "github.com/charmbracelet/crush/internal/tui/util"
15 "github.com/charmbracelet/lipgloss/v2"
14 "github.com/charmbracelet/lipgloss/v2"
@@ -22,6 +21,8 @@ type SessionDialog interface {
22 dialogs.DialogModel
21 dialogs.DialogModel
23}
22}
24
23
24+type SessionsList = list.FilterableList[list.CompletionItem[session.Session]]
25+
25type sessionDialogCmp struct {
26type sessionDialogCmp struct {
26 selectedInx int
27 selectedInx int
27 wWidth int
28 wWidth int
@@ -29,8 +30,7 @@ type sessionDialogCmp struct {
29 width int
30 width int
30 selectedSessionID string
31 selectedSessionID string
31 keyMap KeyMap
32 keyMap KeyMap
32− sessionsList list.ListModel
33+ sessionsList SessionsList
33− renderedSelected bool
34 help help.Model
34 help help.Model
35}
35}
36
36
@@ -39,39 +39,31 @@ func NewSessionDialogCmp(sessions []session.Session, selectedID string) SessionD
39 t := styles.CurrentTheme()
39 t := styles.CurrentTheme()
40 listKeyMap := list.DefaultKeyMap()
40 listKeyMap := list.DefaultKeyMap()
41 keyMap := DefaultKeyMap()
41 keyMap := DefaultKeyMap()
42−
43 listKeyMap.Down.SetEnabled(false)
42 listKeyMap.Down.SetEnabled(false)
44 listKeyMap.Up.SetEnabled(false)
43 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−
50 listKeyMap.DownOneItem = keyMap.Next
44 listKeyMap.DownOneItem = keyMap.Next
51 listKeyMap.UpOneItem = keyMap.Previous
45 listKeyMap.UpOneItem = keyMap.Previous
52
46
53− selectedInx := 0
54− items := make([]util.Model, len(sessions))
47+ items := make([]list.CompletionItem[session.Session], len(sessions))
55 if len(sessions) > 0 {
48 if len(sessions) > 0 {
56 for i, session := range sessions {
49 for i, session := range sessions {
57− items[i] = completions.NewCompletionItem(session.Title, session)
50+ items[i] = list.NewCompletionItem(session.Title, session, list.WithCompletionID(session.ID))
58− if session.ID == selectedID {
59− selectedInx = i
60− }
61 }
51 }
62 }
52 }
63
53
54+ inputStyle := t.S().Base.PaddingLeft(1).PaddingBottom(1)
64− sessionsList := list.New(
55+ sessionsList := list.NewFilterableList(
65− list.WithFilterable(true),
56+ items,
66 list.WithFilterPlaceholder("Enter a session name"),
57 list.WithFilterPlaceholder("Enter a session name"),
58+ list.WithFilterInputStyle(inputStyle),
59+ list.WithFilterListOptions(
67− list.WithKeyMap(listKeyMap),
60+ list.WithKeyMap(listKeyMap),
68− list.WithItems(items),
69− list.WithWrapNavigation(true),
61+ list.WithWrapNavigation(),
62+ ),
70 )
63 )
71 help := help.New()
64 help := help.New()
72 help.Styles = t.S().Help
65 help.Styles = t.S().Help
73 s := &sessionDialogCmp{
66 s := &sessionDialogCmp{
74− selectedInx: selectedInx,
75 selectedSessionID: selectedID,
67 selectedSessionID: selectedID,
76 keyMap: DefaultKeyMap(),
68 keyMap: DefaultKeyMap(),
77 sessionsList: sessionsList,
69 sessionsList: sessionsList,
@@ -82,32 +74,35 @@ func NewSessionDialogCmp(sessions []session.Session, selectedID string) SessionD
82}
74}
83
75
84func (s *sessionDialogCmp) Init() tea.Cmd {
76func (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...)
86}
81}
87
82
88func (s *sessionDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
83func (s *sessionDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
89 switch msg := msg.(type) {
84 switch msg := msg.(type) {
90 case tea.WindowSizeMsg:
85 case tea.WindowSizeMsg:
86+ var cmds []tea.Cmd
91 s.wWidth = msg.Width
87 s.wWidth = msg.Width
92 s.wHeight = msg.Height
88 s.wHeight = msg.Height
93− s.width = s.wWidth / 2
89+ s.width = min(120, s.wWidth-8)
94− var cmds []tea.Cmd
90+ s.sessionsList.SetInputWidth(s.listWidth() - 2)
95 cmds = append(cmds, s.sessionsList.SetSize(s.listWidth(), s.listHeight()))
91 cmds = append(cmds, s.sessionsList.SetSize(s.listWidth(), s.listHeight()))
96− if !s.renderedSelected {
92+ if s.selectedSessionID != "" {
97− cmds = append(cmds, s.sessionsList.SetSelected(s.selectedInx))
93+ cmds = append(cmds, s.sessionsList.SetSelected(s.selectedSessionID))
98− s.renderedSelected = true
99 }
94 }
100− return s, tea.Sequence(cmds...)
95+ return s, tea.Batch(cmds...)
101 case tea.KeyPressMsg:
96 case tea.KeyPressMsg:
102 switch {
97 switch {
103 case key.Matches(msg, s.keyMap.Select):
98 case key.Matches(msg, s.keyMap.Select):
104− if len(s.sessionsList.Items()) > 0 {
99+ selectedItem := s.sessionsList.SelectedItem()
105− items := s.sessionsList.Items()
100+ if selectedItem != nil {
106− selectedItemInx := s.sessionsList.SelectedIndex()
101+ selected := *selectedItem
107 return s, tea.Sequence(
102 return s, tea.Sequence(
108 util.CmdHandler(dialogs.CloseDialogMsg{}),
103 util.CmdHandler(dialogs.CloseDialogMsg{}),
109 util.CmdHandler(
104 util.CmdHandler(
110− chat.SessionSelectedMsg(items[selectedItemInx].(completions.CompletionItem).Value().(session.Session)),
105+ chat.SessionSelectedMsg(selected.Value()),
111 ),
106 ),
112 )
107 )
113 }
108 }
@@ -115,7 +110,7 @@ func (s *sessionDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
115 return s, util.CmdHandler(dialogs.CloseDialogMsg{})
110 return s, util.CmdHandler(dialogs.CloseDialogMsg{})
116 default:
111 default:
117 u, cmd := s.sessionsList.Update(msg)
112 u, cmd := s.sessionsList.Update(msg)
118− s.sessionsList = u.(list.ListModel)
113+ s.sessionsList = u.(SessionsList)
119 return s, cmd
114 return s, cmd
120 }
115 }
121 }
116 }
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
2
2
3import (
3import (
4 "image/color"
4 "image/color"
5
5
6 tea "github.com/charmbracelet/bubbletea/v2"
6 tea "github.com/charmbracelet/bubbletea/v2"
7+ "github.com/charmbracelet/crush/internal/tui/components/core"
7 "github.com/charmbracelet/crush/internal/tui/components/core/layout"
8 "github.com/charmbracelet/crush/internal/tui/components/core/layout"
8− "github.com/charmbracelet/crush/internal/tui/components/core/list"
9 "github.com/charmbracelet/crush/internal/tui/styles"
9 "github.com/charmbracelet/crush/internal/tui/styles"
10− "github.com/charmbracelet/crush/internal/tui/util"
11 "github.com/charmbracelet/lipgloss/v2"
10 "github.com/charmbracelet/lipgloss/v2"
12 "github.com/charmbracelet/x/ansi"
11 "github.com/charmbracelet/x/ansi"
12+ "github.com/google/uuid"
13 "github.com/rivo/uniseg"
13 "github.com/rivo/uniseg"
14)
14)
15
15
16+type Indexable interface {
17+ SetIndex(int)
18+}
19+
16−type CompletionItem interface {
20+type CompletionItem[T any] interface {
17− util.Model
21+ FilterableItem
18 layout.Focusable
22 layout.Focusable
19 layout.Sizeable
23 layout.Sizeable
20− list.HasMatchIndexes
24+ HasMatchIndexes
21− list.HasFilterValue
22− Value() any
25+ Value() T
26+ Text() string
23}
27}
24
28
25−type completionItemCmp struct {
29+type completionItemCmp[T any] struct {
26 width int
30 width int
31+ id string
27 text string
32 text string
28− value any
33+ value T
29 focus bool
34 focus bool
30 matchIndexes []int
35 matchIndexes []int
31 bgColor color.Color
36 bgColor color.Color
32 shortcut string
37 shortcut string
33}
38}
34
39
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+}
36
47
37−func WithBackgroundColor(c color.Color) CompletionOption {
48+type CompletionItemOption func(*options)
49+
50+func WithCompletionBackgroundColor(c color.Color) CompletionItemOption {
38− return func(cmp *completionItemCmp) {
51+ return func(cmp *options) {
39 cmp.bgColor = c
52 cmp.bgColor = c
40 }
53 }
41}
54}
42
55
43−func WithMatchIndexes(indexes ...int) CompletionOption {
56+func WithCompletionMatchIndexes(indexes ...int) CompletionItemOption {
44− return func(cmp *completionItemCmp) {
57+ return func(cmp *options) {
45 cmp.matchIndexes = indexes
58 cmp.matchIndexes = indexes
46 }
59 }
47}
60}
48
61
49−func WithShortcut(shortcut string) CompletionOption {
62+func WithCompletionShortcut(shortcut string) CompletionItemOption {
50− return func(cmp *completionItemCmp) {
63+ return func(cmp *options) {
51 cmp.shortcut = shortcut
64 cmp.shortcut = shortcut
52 }
65 }
53}
66}
54
67
68+func WithCompletionID(id string) CompletionItemOption {
69+ return func(cmp *options) {
70+ cmp.id = id
71+ }
72+}
73+
55−func NewCompletionItem(text string, value any, opts ...CompletionOption) CompletionItem {
74+func NewCompletionItem[T any](text string, value T, opts ...CompletionItemOption) CompletionItem[T] {
56− c := &completionItemCmp{
75+ c := &completionItemCmp[T]{
57 text: text,
76 text: text,
58 value: value,
77 value: value,
59 }
78 }
79+ o := &options{}
60
80
61 for _, opt := range opts {
81 for _, opt := range opts {
62− opt(c)
82+ opt(o)
83+ }
84+ if o.id == "" {
85+ o.id = uuid.NewString()
63 }
86 }
87+ c.id = o.id
88+ c.bgColor = o.bgColor
89+ c.matchIndexes = o.matchIndexes
90+ c.shortcut = o.shortcut
64 return c
91 return c
65}
92}
66
93
67// Init implements CommandItem.
94// Init implements CommandItem.
68−func (c *completionItemCmp) Init() tea.Cmd {
95+func (c *completionItemCmp[T]) Init() tea.Cmd {
69 return nil
96 return nil
70}
97}
71
98
72// Update implements CommandItem.
99// 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) {
74 return c, nil
101 return c, nil
75}
102}
76
103
77// View implements CommandItem.
104// View implements CommandItem.
78−func (c *completionItemCmp) View() string {
105+func (c *completionItemCmp[T]) View() string {
79 t := styles.CurrentTheme()
106 t := styles.CurrentTheme()
80
107
81 itemStyle := t.S().Base.Padding(0, 1).Width(c.width)
108 itemStyle := t.S().Base.Padding(0, 1).Width(c.width)
@@ -140,47 +167,47 @@ func (c *completionItemCmp) View() string {
140}
167}
141
168
142// Blur implements CommandItem.
169// Blur implements CommandItem.
143−func (c *completionItemCmp) Blur() tea.Cmd {
170+func (c *completionItemCmp[T]) Blur() tea.Cmd {
144 c.focus = false
171 c.focus = false
145 return nil
172 return nil
146}
173}
147
174
148// Focus implements CommandItem.
175// Focus implements CommandItem.
149−func (c *completionItemCmp) Focus() tea.Cmd {
176+func (c *completionItemCmp[T]) Focus() tea.Cmd {
150 c.focus = true
177 c.focus = true
151 return nil
178 return nil
152}
179}
153
180
154// GetSize implements CommandItem.
181// GetSize implements CommandItem.
155−func (c *completionItemCmp) GetSize() (int, int) {
182+func (c *completionItemCmp[T]) GetSize() (int, int) {
156 return c.width, 1
183 return c.width, 1
157}
184}
158
185
159// IsFocused implements CommandItem.
186// IsFocused implements CommandItem.
160−func (c *completionItemCmp) IsFocused() bool {
187+func (c *completionItemCmp[T]) IsFocused() bool {
161 return c.focus
188 return c.focus
162}
189}
163
190
164// SetSize implements CommandItem.
191// 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 {
166 c.width = width
193 c.width = width
167 return nil
194 return nil
168}
195}
169
196
170−func (c *completionItemCmp) MatchIndexes(indexes []int) {
197+func (c *completionItemCmp[T]) MatchIndexes(indexes []int) {
171 c.matchIndexes = indexes
198 c.matchIndexes = indexes
172}
199}
173
200
174−func (c *completionItemCmp) FilterValue() string {
201+func (c *completionItemCmp[T]) FilterValue() string {
175 return c.text
202 return c.text
176}
203}
177
204
178−func (c *completionItemCmp) Value() any {
205+func (c *completionItemCmp[T]) Value() T {
179 return c.value
206 return c.value
180}
207}
181
208
182// smartTruncate implements fzf-style truncation that ensures the last matching part is visible
209// 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 {
184 if width <= 0 {
211 if width <= 0 {
185 return ""
212 return ""
186 }
213 }
@@ -280,3 +307,84 @@ func bytePosToVisibleCharPos(str string, rng [2]int) (int, int) {
280 stop = pos
307 stop = pos
281 return start, stop
308 return start, stop
282}
309}
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 {
46 PageUp: key.NewBinding(
46 PageUp: key.NewBinding(
47 key.WithKeys("pgup", "b"),
47 key.WithKeys("pgup", "b"),
48 key.WithHelp("b/pgup", "page up"),
48 key.WithHelp("b/pgup", "page up"),
49+ ),
49− ), HalfPageUp: key.NewBinding(
50+ HalfPageUp: key.NewBinding(
50 key.WithKeys("u"),
51 key.WithKeys("u"),
51 key.WithHelp("u", "half page up"),
52 key.WithHelp("u", "half page up"),
52 ),
53 ),
@@ -61,7 +62,6 @@ func DefaultKeyMap() KeyMap {
61 }
62 }
62}
63}
63
64
64−// KeyBindings implements layout.KeyMapProvider
65func (k KeyMap) KeyBindings() []key.Binding {
65func (k KeyMap) KeyBindings() []key.Binding {
66 return []key.Binding{
66 return []key.Binding{
67 k.Down,
67 k.Down,
internal/tui/exp/list/list.go+960−35
@@ -1,87 +1,1012 @@
1package list
1package list
2
2
3import (
3import (
4+ "slices"
5+ "strings"
6+
7+ "github.com/charmbracelet/bubbles/v2/key"
4 tea "github.com/charmbracelet/bubbletea/v2"
8 tea "github.com/charmbracelet/bubbletea/v2"
9+ "github.com/charmbracelet/crush/internal/csync"
10+ "github.com/charmbracelet/crush/internal/tui/components/anim"
5 "github.com/charmbracelet/crush/internal/tui/components/core/layout"
11 "github.com/charmbracelet/crush/internal/tui/components/core/layout"
12+ "github.com/charmbracelet/crush/internal/tui/styles"
6 "github.com/charmbracelet/crush/internal/tui/util"
13 "github.com/charmbracelet/crush/internal/tui/util"
14+ "github.com/charmbracelet/lipgloss/v2"
7)
15)
8
16
9type Item interface {
17type Item interface {
10 util.Model
18 util.Model
11 layout.Sizeable
19 layout.Sizeable
20+ ID() string
21+}
22+
23+type HasAnim interface {
24+ Item
25+ Spinning() bool
12}
26}
13
27
14−type List interface {
28+type List[T Item] interface {
15 util.Model
29 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
16}
48}
17
49
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 {
19 width, height int
71 width, height int
20 gap int
72 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+}
21
82
22− items []Item
83+type list[T Item] struct {
84+ *confOptions
23
85
24− // Filter options
86+ offset int
25− filterable bool
26− filterPlaceholder string
27−}
28
87
29−type listOption func(*list)
88+ indexMap *csync.Map[string, int]
89+ items *csync.Slice[T]
30
90
31−// WithFilterable enables filtering on the list.
91+ renderedItems *csync.Map[string, renderedItem]
32−func WithFilterable(placeholder string) listOption {
33− return func(l *list) {
34− l.filterable = true
35− l.filterPlaceholder = placeholder
36− }
37−}
38
92
39−// WithItems sets the initial items for the list.
93+ rendered string
40−func WithItems(items ...Item) listOption {
94+
41− return func(l *list) {
95+ movingByItem bool
42− l.items = items
43− }
44}
96}
45
97
98+type ListOption func(*confOptions)
99+
46// WithSize sets the size of the list.
100// WithSize sets the size of the list.
47−func WithSize(width, height int) listOption {
101+func WithSize(width, height int) ListOption {
48− return func(l *list) {
102+ return func(l *confOptions) {
49 l.width = width
103 l.width = width
50 l.height = height
104 l.height = height
51 }
105 }
52}
106}
53
107
54// WithGap sets the gap between items in the list.
108// WithGap sets the gap between items in the list.
55−func WithGap(gap int) listOption {
109+func WithGap(gap int) ListOption {
56− return func(l *list) {
110+ return func(l *confOptions) {
57 l.gap = gap
111 l.gap = gap
58 }
112 }
59}
113}
60
114
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+
61−func New(opts ...listOption) List {
166+func New[T Item](items []T, opts ...ListOption) List[T] {
62− list := &list{
167+ list := &list[T]{
63− items: make([]Item, 0),
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](),
64 }
176 }
65 for _, opt := range opts {
177 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)
67 }
186 }
68 return list
187 return list
69}
188}
70
189
71// Init implements List.
190// 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 {
73 if l.height <= 0 || l.width <= 0 {
257 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 {
74 return nil
306 return nil
75 }
307 }
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+ }
76 return nil
504 return nil
77}
505}
78
506
79−// Update implements List.
507+func (l *list[T]) selectFirstItem() {
80−func (l *list) Update(tea.Msg) (tea.Model, tea.Cmd) {
508+ inx := l.firstSelectableItemBelow(-1)
81− panic("unimplemented")
509+ if inx != ItemNotFound {
510+ item, ok := l.items.Get(inx)
511+ if ok {
512+ l.selectedItem = item.ID()
513+ }
514+ }
82}
515}
83
516
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+
84−// View implements List.
649+// AppendItem implements List.
85−func (l *list) View() string {
650+func (l *list[T]) AppendItem(item T) tea.Cmd {
86− panic("unimplemented")
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...)
87}
1012}
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) {
164 case tea.KeyboardEnhancementsMsg:
164 case tea.KeyboardEnhancementsMsg:
165 p.keyboardEnhancements = msg
165 p.keyboardEnhancements = msg
166 return p, nil
166 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
167 case tea.WindowSizeMsg:
174 case tea.WindowSizeMsg:
168 u, cmd := p.editor.Update(msg)
175 u, cmd := p.editor.Update(msg)
169 p.editor = u.(editor.Editor)
176 p.editor = u.(editor.Editor)
@@ -606,6 +613,7 @@ func (p *chatPage) sendMessage(text string, attachments []message.Attachment) te
606 if err != nil {
613 if err != nil {
607 return util.ReportError(err)
614 return util.ReportError(err)
608 }
615 }
616+ cmds = append(cmds, p.chat.GoToBottom())
609 return tea.Batch(cmds...)
617 return tea.Batch(cmds...)
610}
618}
611
619
@@ -907,3 +915,31 @@ func (p *chatPage) Help() help.KeyMap {
907func (p *chatPage) IsChatFocused() bool {
915func (p *chatPage) IsChatFocused() bool {
908 return p.focusedPane == PanelTypeChat
916 return p.focusedPane == PanelTypeChat
909}
917}
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 (
4 "context"
4 "context"
5 "fmt"
5 "fmt"
6 "strings"
6 "strings"
7+ "time"
7
8
8 "github.com/charmbracelet/bubbles/v2/key"
9 "github.com/charmbracelet/bubbles/v2/key"
9 tea "github.com/charmbracelet/bubbletea/v2"
10 tea "github.com/charmbracelet/bubbletea/v2"
@@ -33,26 +34,18 @@ import (
33 "github.com/charmbracelet/lipgloss/v2"
34 "github.com/charmbracelet/lipgloss/v2"
34)
35)
35
36
36−// MouseEventFilter filters mouse events based on the current focus state
37+var lastMouseEvent time.Time
37−// This is used with tea.WithFilter to prevent mouse scroll events from
38+
38−// interfering with typing performance in the editor
39func MouseEventFilter(m tea.Model, msg tea.Msg) tea.Msg {
39func MouseEventFilter(m tea.Model, msg tea.Msg) tea.Msg {
40− // Only filter mouse events
41 switch msg.(type) {
40 switch msg.(type) {
42 case tea.MouseWheelMsg, tea.MouseMotionMsg:
41 case tea.MouseWheelMsg, tea.MouseMotionMsg:
43− // Check if we have an appModel and if editor is focused
42+ now := time.Now()
44− if appModel, ok := m.(*appModel); ok {
43+ // trackpad is sending too many requests
45− if appModel.currentPage == chat.ChatPageID {
44+ if now.Sub(lastMouseEvent) < 5*time.Millisecond {
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
45+ return nil
50− }
51− }
52− }
53 }
46 }
47+ lastMouseEvent = now
54 }
48 }
55− // Allow all other events to pass through
56 return msg
49 return msg
57}
50}
58
51