on main help sign in
chore: grouped list
commitdcf069ea376f2fefa3bd8d4ed7cba171bdfd5211 authorKujtim Hoxha <kujtimii.h@gmail.com> dateThu 24 Jul 2025, 16:27 (1 year ago) parent6b3fe6a
24 files changed+733−311
internal/tui/components/chat/splash/splash.go+15−11 internal/tui/components/dialogs/commands/commands.go+38−29 internal/tui/components/dialogs/models/list.go+73−32 internal/tui/components/dialogs/models/models.go+7−21 internal/tui/components/dialogs/sessions/sessions.go+1−1 internal/tui/exp/list/filterable.go+7−2 internal/tui/exp/list/filterable_group.go+260 internal/tui/exp/list/filterable_test.go+66−58 internal/tui/exp/list/grouped.go+99 internal/tui/exp/list/items.go+83−6 internal/tui/exp/list/list.go+78−41 internal/tui/exp/list/testdata/TestFilterableList/should_create_simple_filterable_list.golden+6 internal/tui/exp/list/testdata/TestList/has_correct_positions_in_list_that_does_not_fits_the_items.golden−10 internal/tui/exp/list/testdata/TestList/has_correct_positions_in_list_that_does_not_fits_the_items_and_has_multi_line_items.golden−10 internal/tui/exp/list/testdata/TestList/has_correct_positions_in_list_that_does_not_fits_the_items_and_has_multi_line_items_backwards.golden−10 internal/tui/exp/list/testdata/TestList/has_correct_positions_in_list_that_does_not_fits_the_items_backwards.golden−10 internal/tui/exp/list/testdata/TestList/has_correct_positions_in_list_that_fits_the_items.golden−5 internal/tui/exp/list/testdata/TestList/has_correct_positions_in_list_that_fits_the_items_backwards.golden−5 internal/tui/exp/list/testdata/TestListMovement/should_not_change_offset_when_new_items_are_appended_and_we_are_at_the_botton.golden−10 internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_new_items_are_added_but_we_moved_up.golden−10 internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_above_is_increased.golden−10 internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_below_is_decreases.golden−10 internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_below_is_increased#01.golden−10 internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_below_is_increased.golden−10
internal/tui/components/chat/splash/splash.go+15−11
@@ -14,12 +14,11 @@ import (
1414 "github.com/charmbracelet/crush/internal/config"
1515 "github.com/charmbracelet/crush/internal/llm/prompt"
1616 "github.com/charmbracelet/crush/internal/tui/components/chat"
17− "github.com/charmbracelet/crush/internal/tui/components/completions"
1817 "github.com/charmbracelet/crush/internal/tui/components/core"
1918 "github.com/charmbracelet/crush/internal/tui/components/core/layout"
20− "github.com/charmbracelet/crush/internal/tui/components/core/list"
2119 "github.com/charmbracelet/crush/internal/tui/components/dialogs/models"
2220 "github.com/charmbracelet/crush/internal/tui/components/logo"
21+ "github.com/charmbracelet/crush/internal/tui/exp/list"
2322 "github.com/charmbracelet/crush/internal/tui/styles"
2423 "github.com/charmbracelet/crush/internal/tui/util"
2524 "github.com/charmbracelet/crush/internal/version"
@@ -86,9 +85,7 @@ func New() Splash {
8685 listKeyMap.DownOneItem = keyMap.Next
8786 listKeyMap.UpOneItem = keyMap.Previous
8887
89− t := styles.CurrentTheme()
90− inputStyle := t.S().Base.Padding(0, 1, 0, 1)
91− modelList := models.NewModelListComponent(listKeyMap, inputStyle, "Find your fave")
88+ modelList := models.NewModelListComponent(listKeyMap, "Find your fave", false)
9289 apiKeyInput := models.NewAPIKeyInput()
9390
9491 return &splashCmp{
@@ -195,17 +192,18 @@ func (s *splashCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
195192 return s, s.saveAPIKeyAndContinue(s.apiKeyValue)
196193 }
197194 if s.isOnboarding && !s.needsAPIKey {
198− modelInx := s.modelList.SelectedIndex()
199− items := s.modelList.Items()
200− selectedItem := items[modelInx].(completions.CompletionItem).Value().(models.ModelOption)
195+ selectedItem := s.modelList.SelectedModel()
196+ if selectedItem == nil {
197+ return s, nil
198+ }
201199 if s.isProviderConfigured(string(selectedItem.Provider.ID)) {
202− cmd := s.setPreferredModel(selectedItem)
200+ cmd := s.setPreferredModel(*selectedItem)
203201 s.isOnboarding = false
204202 return s, tea.Batch(cmd, util.CmdHandler(OnboardingCompleteMsg{}))
205203 } else {
206204 // Provider not configured, show API key input
207205 s.needsAPIKey = true
208− s.selectedModel = &selectedItem
206+ s.selectedModel = selectedItem
209207 s.apiKeyInput.SetProviderName(selectedItem.Provider.Name)
210208 return s, nil
211209 }
@@ -264,6 +262,9 @@ func (s *splashCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
264262 return s, nil
265263 }
266264 case key.Matches(msg, s.keyMap.Yes):
265+ if s.isOnboarding {
266+ return s, nil
267+ }
267268 if s.needsAPIKey {
268269 u, cmd := s.apiKeyInput.Update(msg)
269270 s.apiKeyInput = u.(*models.APIKeyInput)
@@ -274,6 +275,9 @@ func (s *splashCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
274275 return s, s.initializeProject()
275276 }
276277 case key.Matches(msg, s.keyMap.No):
278+ if s.isOnboarding {
279+ return s, nil
280+ }
277281 if s.needsAPIKey {
278282 u, cmd := s.apiKeyInput.Update(msg)
279283 s.apiKeyInput = u.(*models.APIKeyInput)
@@ -605,7 +609,7 @@ func (s *splashCmp) moveCursor(cursor *tea.Cursor) *tea.Cursor {
605609 cursor.Y += offset
606610 cursor.X = cursor.X + 1
607611 } else if s.isOnboarding {
608− offset := logoHeight + SplashScreenPaddingY + s.logoGap() + 3
612+ offset := logoHeight + SplashScreenPaddingY + s.logoGap() + 2
609613 cursor.Y += offset
610614 cursor.X = cursor.X + 1
611615 }
internal/tui/components/dialogs/commands/commands.go+38−29
@@ -10,10 +10,9 @@ import (
1010 "github.com/charmbracelet/crush/internal/config"
1111 "github.com/charmbracelet/crush/internal/llm/prompt"
1212 "github.com/charmbracelet/crush/internal/tui/components/chat"
13− "github.com/charmbracelet/crush/internal/tui/components/completions"
1413 "github.com/charmbracelet/crush/internal/tui/components/core"
15− "github.com/charmbracelet/crush/internal/tui/components/core/list"
1614 "github.com/charmbracelet/crush/internal/tui/components/dialogs"
15+ "github.com/charmbracelet/crush/internal/tui/exp/list"
1716 "github.com/charmbracelet/crush/internal/tui/styles"
1817 "github.com/charmbracelet/crush/internal/tui/util"
1918)
@@ -29,6 +28,8 @@ const (
2928 UserCommands
3029)
3130
31+type listModel = list.FilterableList[list.CompletionItem[Command]]
32+
3233// Command represents a command that can be executed
3334type Command struct {
3435 ID string
@@ -48,7 +49,7 @@ type commandDialogCmp struct {
4849 wWidth int // Width of the terminal window
4950 wHeight int // Height of the terminal window
5051
51− commandList list.ListModel
52+ commandList listModel
5253 keyMap CommandsDialogKeyMap
5354 help help.Model
5455 commandType int // SystemCommands or UserCommands
@@ -67,24 +68,23 @@ type (
6768)
6869
6970func NewCommandDialog(sessionID string) CommandsDialog {
70− listKeyMap := list.DefaultKeyMap()
7171 keyMap := DefaultCommandsDialogKeyMap()
72−
72+ listKeyMap := list.DefaultKeyMap()
7373 listKeyMap.Down.SetEnabled(false)
7474 listKeyMap.Up.SetEnabled(false)
75− listKeyMap.HalfPageDown.SetEnabled(false)
76− listKeyMap.HalfPageUp.SetEnabled(false)
77− listKeyMap.Home.SetEnabled(false)
78− listKeyMap.End.SetEnabled(false)
79−
8075 listKeyMap.DownOneItem = keyMap.Next
8176 listKeyMap.UpOneItem = keyMap.Previous
8277
8378 t := styles.CurrentTheme()
84− commandList := list.New(
85− list.WithFilterable(true),
86− list.WithKeyMap(listKeyMap),
87− list.WithWrapNavigation(true),
79+ inputStyle := t.S().Base.PaddingLeft(1).PaddingBottom(1)
80+ commandList := list.NewFilterableList(
81+ []list.CompletionItem[Command]{},
82+ list.WithFilterInputStyle(inputStyle),
83+ list.WithFilterListOptions(
84+ list.WithKeyMap(listKeyMap),
85+ list.WithWrapNavigation(),
86+ list.WithResizeByList(),
87+ ),
8888 )
8989 help := help.New()
9090 help.Styles = t.S().Help
@@ -103,10 +103,8 @@ func (c *commandDialogCmp) Init() tea.Cmd {
103103 if err != nil {
104104 return util.ReportError(err)
105105 }
106−
107106 c.userCommands = commands
108− c.SetCommandType(c.commandType)
109− return c.commandList.Init()
107+ return c.SetCommandType(c.commandType)
110108}
111109
112110func (c *commandDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
@@ -114,22 +112,23 @@ func (c *commandDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
114112 case tea.WindowSizeMsg:
115113 c.wWidth = msg.Width
116114 c.wHeight = msg.Height
117− c.SetCommandType(c.commandType)
118115 return c, c.commandList.SetSize(c.listWidth(), c.listHeight())
119116 case tea.KeyPressMsg:
120117 switch {
121118 case key.Matches(msg, c.keyMap.Select):
122− selectedItemInx := c.commandList.SelectedIndex()
123− if selectedItemInx == list.NoSelection {
119+ selectedItem := c.commandList.SelectedItem()
120+ if selectedItem == nil {
124121 return c, nil // No item selected, do nothing
125122 }
126− items := c.commandList.Items()
127− selectedItem := items[selectedItemInx].(completions.CompletionItem).Value().(Command)
123+ command := (*selectedItem).Value()
128124 return c, tea.Sequence(
129125 util.CmdHandler(dialogs.CloseDialogMsg{}),
130− selectedItem.Handler(selectedItem),
126+ command.Handler(command),
131127 )
132128 case key.Matches(msg, c.keyMap.Tab):
129+ if len(c.userCommands) == 0 {
130+ return c, nil
131+ }
133132 // Toggle command type between System and User commands
134133 if c.commandType == SystemCommands {
135134 return c, c.SetCommandType(UserCommands)
@@ -140,7 +139,7 @@ func (c *commandDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
140139 return c, util.CmdHandler(dialogs.CloseDialogMsg{})
141140 default:
142141 u, cmd := c.commandList.Update(msg)
143− c.commandList = u.(list.ListModel)
142+ c.commandList = u.(listModel)
144143 return c, cmd
145144 }
146145 }
@@ -151,9 +150,14 @@ func (c *commandDialogCmp) View() string {
151150 t := styles.CurrentTheme()
152151 listView := c.commandList
153152 radio := c.commandTypeRadio()
153+
154+ header := t.S().Base.Padding(0, 1, 1, 1).Render(core.Title("Commands", c.width-lipgloss.Width(radio)-5) + " " + radio)
155+ if len(c.userCommands) == 0 {
156+ header = t.S().Base.Padding(0, 1, 1, 1).Render(core.Title("Commands", c.width-4))
157+ }
154158 content := lipgloss.JoinVertical(
155159 lipgloss.Left,
156− t.S().Base.Padding(0, 1, 1, 1).Render(core.Title("Commands", c.width-lipgloss.Width(radio)-5)+" "+radio),
160+ header,
157161 listView.View(),
158162 "",
159163 t.S().Base.Width(c.width-2).PaddingLeft(1).AlignHorizontal(lipgloss.Left).Render(c.help.View(c.keyMap)),
@@ -197,13 +201,18 @@ func (c *commandDialogCmp) SetCommandType(commandType int) tea.Cmd {
197201 commands = c.userCommands
198202 }
199203
200− commandItems := []util.Model{}
204+ commandItems := []list.CompletionItem[Command]{}
201205 for _, cmd := range commands {
202− opts := []completions.CompletionOption{}
206+ opts := []list.CompletionItemOption{
207+ list.WithCompletionID(cmd.ID),
208+ }
203209 if cmd.Shortcut != "" {
204− opts = append(opts, completions.WithShortcut(cmd.Shortcut))
210+ opts = append(
211+ opts,
212+ list.WithCompletionShortcut(cmd.Shortcut),
213+ )
205214 }
206− commandItems = append(commandItems, completions.NewCompletionItem(cmd.Title, cmd, opts...))
215+ commandItems = append(commandItems, list.NewCompletionItem(cmd.Title, cmd, opts...))
207216 }
208217 return c.commandList.SetItems(commandItems)
209218}
internal/tui/components/dialogs/models/list.go+73−32
@@ -7,27 +7,36 @@ import (
77 tea "github.com/charmbracelet/bubbletea/v2"
88 "github.com/charmbracelet/catwalk/pkg/catwalk"
99 "github.com/charmbracelet/crush/internal/config"
10− "github.com/charmbracelet/crush/internal/tui/components/completions"
11− "github.com/charmbracelet/crush/internal/tui/components/core/list"
12− "github.com/charmbracelet/crush/internal/tui/components/dialogs/commands"
10+ "github.com/charmbracelet/crush/internal/tui/exp/list"
1311 "github.com/charmbracelet/crush/internal/tui/styles"
1412 "github.com/charmbracelet/crush/internal/tui/util"
15− "github.com/charmbracelet/lipgloss/v2"
1613)
1714
15+type listModel = list.FilterableGroupList[list.CompletionItem[ModelOption]]
16+
1817type ModelListComponent struct {
19− list list.ListModel
18+ list listModel
2019 modelType int
2120 providers []catwalk.Provider
2221}
2322
24−func NewModelListComponent(keyMap list.KeyMap, inputStyle lipgloss.Style, inputPlaceholder string) *ModelListComponent {
25− modelList := list.New(
26− list.WithFilterable(true),
23+func NewModelListComponent(keyMap list.KeyMap, inputPlaceholder string, shouldResize bool) *ModelListComponent {
24+ t := styles.CurrentTheme()
25+ inputStyle := t.S().Base.PaddingLeft(1).PaddingBottom(1)
26+ options := []list.ListOption{
2727 list.WithKeyMap(keyMap),
28− list.WithInputStyle(inputStyle),
28+ list.WithWrapNavigation(),
29+ }
30+ if shouldResize {
31+ options = append(options, list.WithResizeByList())
32+ }
33+ modelList := list.NewFilterableGroupedList(
34+ []list.Group[list.CompletionItem[ModelOption]]{},
35+ list.WithFilterInputStyle(inputStyle),
2936 list.WithFilterPlaceholder(inputPlaceholder),
30− list.WithWrapNavigation(true),
37+ list.WithFilterListOptions(
38+ options...,
39+ ),
3140 )
3241
3342 return &ModelListComponent{
@@ -51,7 +60,7 @@ func (m *ModelListComponent) Init() tea.Cmd {
5160
5261func (m *ModelListComponent) Update(msg tea.Msg) (*ModelListComponent, tea.Cmd) {
5362 u, cmd := m.list.Update(msg)
54− m.list = u.(list.ListModel)
63+ m.list = u.(listModel)
5564 return m, cmd
5665}
5766
@@ -67,21 +76,23 @@ func (m *ModelListComponent) SetSize(width, height int) tea.Cmd {
6776 return m.list.SetSize(width, height)
6877}
6978
70−func (m *ModelListComponent) Items() []util.Model {
71− return m.list.Items()
72−}
73−
74−func (m *ModelListComponent) SelectedIndex() int {
75− return m.list.SelectedIndex()
79+func (m *ModelListComponent) SelectedModel() *ModelOption {
80+ s := m.list.SelectedItem()
81+ if s == nil {
82+ return nil
83+ }
84+ sv := *s
85+ model := sv.Value()
86+ return &model
7687}
7788
7889func (m *ModelListComponent) SetModelType(modelType int) tea.Cmd {
7990 t := styles.CurrentTheme()
8091 m.modelType = modelType
8192
82− modelItems := []util.Model{}
93+ var groups []list.Group[list.CompletionItem[ModelOption]]
8394 // first none section
84− selectIndex := 1
95+ selectedItemID := ""
8596
8697 cfg := config.Get()
8798 var currentModel config.SelectedModel
@@ -140,18 +151,28 @@ func (m *ModelListComponent) SetModelType(modelType int) tea.Cmd {
140151 if name == "" {
141152 name = string(configProvider.ID)
142153 }
143− section := commands.NewItemSection(name)
154+ section := list.NewItemSection(name)
144155 section.SetInfo(configured)
145− modelItems = append(modelItems, section)
156+ group := list.Group[list.CompletionItem[ModelOption]]{
157+ Section: section,
158+ }
146159 for _, model := range configProvider.Models {
147− modelItems = append(modelItems, completions.NewCompletionItem(model.Name, ModelOption{
160+ item := list.NewCompletionItem(model.Model, ModelOption{
148161 Provider: configProvider,
149162 Model: model,
150− }))
163+ },
164+ list.WithCompletionID(
165+ fmt.Sprintf("%s:%s", providerConfig.ID, model.ID),
166+ ),
167+ )
168+
169+ group.Items = append(group.Items, item)
151170 if model.ID == currentModel.Model && string(configProvider.ID) == currentModel.Provider {
152− selectIndex = len(modelItems) - 1 // Set the selected index to the current model
171+ selectedItemID = item.ID()
153172 }
154173 }
174+ groups = append(groups, group)
175+
155176 addedProviders[providerID] = true
156177 }
157178 }
@@ -173,23 +194,43 @@ func (m *ModelListComponent) SetModelType(modelType int) tea.Cmd {
173194 name = string(provider.ID)
174195 }
175196
176− section := commands.NewItemSection(name)
177− if _, ok := cfg.Providers.Get(string(provider.ID)); ok {
197+ section := list.NewItemSection(name)
198+ if _, ok := cfg.Providers[string(provider.ID)]; ok {
178199 section.SetInfo(configured)
179200 }
180− modelItems = append(modelItems, section)
201+ group := list.Group[list.CompletionItem[ModelOption]]{
202+ Section: section,
203+ }
181204 for _, model := range provider.Models {
182− modelItems = append(modelItems, completions.NewCompletionItem(model.Name, ModelOption{
205+ item := list.NewCompletionItem(model.Model, ModelOption{
183206 Provider: provider,
184207 Model: model,
185− }))
208+ },
209+ list.WithCompletionID(
210+ fmt.Sprintf("%s:%s", provider.ID, model.ID),
211+ ),
212+ )
213+ group.Items = append(group.Items, item)
186214 if model.ID == currentModel.Model && string(provider.ID) == currentModel.Provider {
187− selectIndex = len(modelItems) - 1 // Set the selected index to the current model
215+ selectedItemID = item.ID()
188216 }
189217 }
218+ groups = append(groups, group)
219+ }
220+
221+ var cmds []tea.Cmd
222+
223+ cmd := m.list.SetGroups(groups)
224+
225+ if cmd != nil {
226+ cmds = append(cmds, cmd)
227+ }
228+ cmd = m.list.SetSelected(selectedItemID)
229+ if cmd != nil {
230+ cmds = append(cmds, cmd)
190231 }
191232
192− return tea.Sequence(m.list.SetItems(modelItems), m.list.SetSelected(selectIndex))
233+ return tea.Sequence(cmds...)
193234}
194235
195236// GetModelType returns the current model type
@@ -198,7 +239,7 @@ func (m *ModelListComponent) GetModelType() int {
198239}
199240
200241func (m *ModelListComponent) SetInputPlaceholder(placeholder string) {
201− m.list.SetFilterPlaceholder(placeholder)
242+ m.list.SetInputPlaceholder(placeholder)
202243}
203244
204245func (m *ModelListComponent) SetProviders(providers []catwalk.Provider) {
internal/tui/components/dialogs/models/models.go+7−21
@@ -10,10 +10,9 @@ import (
1010 tea "github.com/charmbracelet/bubbletea/v2"
1111 "github.com/charmbracelet/catwalk/pkg/catwalk"
1212 "github.com/charmbracelet/crush/internal/config"
13− "github.com/charmbracelet/crush/internal/tui/components/completions"
1413 "github.com/charmbracelet/crush/internal/tui/components/core"
15− "github.com/charmbracelet/crush/internal/tui/components/core/list"
1614 "github.com/charmbracelet/crush/internal/tui/components/dialogs"
15+ "github.com/charmbracelet/crush/internal/tui/exp/list"
1716 "github.com/charmbracelet/crush/internal/tui/styles"
1817 "github.com/charmbracelet/crush/internal/tui/util"
1918 "github.com/charmbracelet/lipgloss/v2"
@@ -71,22 +70,16 @@ type modelDialogCmp struct {
7170}
7271
7372func NewModelDialogCmp() ModelDialog {
74− listKeyMap := list.DefaultKeyMap()
7573 keyMap := DefaultKeyMap()
7674
75+ listKeyMap := list.DefaultKeyMap()
7776 listKeyMap.Down.SetEnabled(false)
7877 listKeyMap.Up.SetEnabled(false)
79− listKeyMap.HalfPageDown.SetEnabled(false)
80− listKeyMap.HalfPageUp.SetEnabled(false)
81− listKeyMap.Home.SetEnabled(false)
82− listKeyMap.End.SetEnabled(false)
83−
8478 listKeyMap.DownOneItem = keyMap.Next
8579 listKeyMap.UpOneItem = keyMap.Previous
8680
8781 t := styles.CurrentTheme()
88− inputStyle := t.S().Base.Padding(0, 1, 0, 1)
89− modelList := NewModelListComponent(listKeyMap, inputStyle, "Choose a model for large, complex tasks")
82+ modelList := NewModelListComponent(listKeyMap, "Choose a model for large, complex tasks", true)
9083 apiKeyInput := NewAPIKeyInput()
9184 apiKeyInput.SetShowTitle(false)
9285 help := help.New()
@@ -162,12 +155,7 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
162155 )
163156 }
164157 // Normal model selection
165− selectedItemInx := m.modelList.SelectedIndex()
166− if selectedItemInx == list.NoSelection {
167− return m, nil
168− }
169− items := m.modelList.Items()
170− selectedItem := items[selectedItemInx].(completions.CompletionItem).Value().(ModelOption)
158+ selectedItem := m.modelList.SelectedModel()
171159
172160 var modelType config.SelectedModelType
173161 if m.modelList.GetModelType() == LargeModelType {
@@ -191,7 +179,7 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
191179 } else {
192180 // Provider not configured, show API key input
193181 m.needsAPIKey = true
194− m.selectedModel = &selectedItem
182+ m.selectedModel = selectedItem
195183 m.selectedModelType = modelType
196184 m.apiKeyInput.SetProviderName(selectedItem.Provider.Name)
197185 return m, nil
@@ -310,13 +298,11 @@ func (m *modelDialogCmp) style() lipgloss.Style {
310298}
311299
312300func (m *modelDialogCmp) listWidth() int {
313− return defaultWidth - 2 // 4 for padding
301+ return m.width - 2
314302}
315303
316304func (m *modelDialogCmp) listHeight() int {
317− items := m.modelList.Items()
318− listHeigh := len(items) + 2 + 4
319− return min(listHeigh, m.wHeight/2)
305+ return m.wHeight / 2
320306}
321307
322308func (m *modelDialogCmp) Position() (int, int) {
internal/tui/components/dialogs/sessions/sessions.go+1−1
@@ -47,7 +47,7 @@ func NewSessionDialogCmp(sessions []session.Session, selectedID string) SessionD
4747 items := make([]list.CompletionItem[session.Session], len(sessions))
4848 if len(sessions) > 0 {
4949 for i, session := range sessions {
50− items[i] = list.NewCompletionItem(session.Title, session, list.WithID(session.ID))
50+ items[i] = list.NewCompletionItem(session.Title, session, list.WithCompletionID(session.ID))
5151 }
5252 }
5353
internal/tui/exp/list/filterable.go+7−2
@@ -23,6 +23,7 @@ type FilterableList[T FilterableItem] interface {
2323 List[T]
2424 Cursor() *tea.Cursor
2525 SetInputWidth(int)
26+ SetInputPlaceholder(string)
2627}
2728
2829type HasMatchIndexes interface {
@@ -30,7 +31,7 @@ type HasMatchIndexes interface {
3031}
3132
3233type filterableOptions struct {
33− listOptions []listOption
34+ listOptions []ListOption
3435 placeholder string
3536 inputHidden bool
3637 inputWidth int
@@ -67,7 +68,7 @@ func WithFilterInputStyle(inputStyle lipgloss.Style) filterableListOption {
6768 }
6869}
6970
70−func WithFilterListOptions(opts ...listOption) filterableListOption {
71+func WithFilterListOptions(opts ...ListOption) filterableListOption {
7172 return func(f *filterableOptions) {
7273 f.listOptions = opts
7374 }
@@ -295,3 +296,7 @@ func (f *filterableList[T]) IsFocused() bool {
295296func (f *filterableList[T]) SetInputWidth(w int) {
296297 f.inputWidth = w
297298}
299+
300+func (f *filterableList[T]) SetInputPlaceholder(ph string) {
301+ f.placeholder = ph
302+}
internal/tui/exp/list/filterable_group.gonew file+260
@@ -0,0 +1,260 @@
1+package list
2+
3+import (
4+ "regexp"
5+ "sort"
6+ "strings"
7+
8+ "github.com/charmbracelet/bubbles/v2/key"
9+ "github.com/charmbracelet/bubbles/v2/textinput"
10+ tea "github.com/charmbracelet/bubbletea/v2"
11+ "github.com/charmbracelet/crush/internal/tui/components/core/layout"
12+ "github.com/charmbracelet/crush/internal/tui/styles"
13+ "github.com/charmbracelet/lipgloss/v2"
14+ "github.com/sahilm/fuzzy"
15+)
16+
17+type FilterableGroupList[T FilterableItem] interface {
18+ GroupedList[T]
19+ Cursor() *tea.Cursor
20+ SetInputWidth(int)
21+ SetInputPlaceholder(string)
22+}
23+type filterableGroupList[T FilterableItem] struct {
24+ *groupedList[T]
25+ *filterableOptions
26+ width, height int
27+ groups []Group[T]
28+ // stores all available items
29+ input textinput.Model
30+ inputWidth int
31+ query string
32+}
33+
34+func NewFilterableGroupedList[T FilterableItem](items []Group[T], opts ...filterableListOption) FilterableGroupList[T] {
35+ t := styles.CurrentTheme()
36+
37+ f := &filterableGroupList[T]{
38+ filterableOptions: &filterableOptions{
39+ inputStyle: t.S().Base,
40+ placeholder: "Type to filter",
41+ },
42+ }
43+ for _, opt := range opts {
44+ opt(f.filterableOptions)
45+ }
46+ f.groupedList = NewGroupedList(items, f.listOptions...).(*groupedList[T])
47+
48+ f.updateKeyMaps()
49+
50+ if f.inputHidden {
51+ return f
52+ }
53+
54+ ti := textinput.New()
55+ ti.Placeholder = f.placeholder
56+ ti.SetVirtualCursor(false)
57+ ti.Focus()
58+ ti.SetStyles(t.S().TextInput)
59+ f.input = ti
60+ return f
61+}
62+
63+func (f *filterableGroupList[T]) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
64+ switch msg := msg.(type) {
65+ case tea.KeyPressMsg:
66+ switch {
67+ // handle movements
68+ case key.Matches(msg, f.keyMap.Down),
69+ key.Matches(msg, f.keyMap.Up),
70+ key.Matches(msg, f.keyMap.DownOneItem),
71+ key.Matches(msg, f.keyMap.UpOneItem),
72+ key.Matches(msg, f.keyMap.HalfPageDown),
73+ key.Matches(msg, f.keyMap.HalfPageUp),
74+ key.Matches(msg, f.keyMap.PageDown),
75+ key.Matches(msg, f.keyMap.PageUp),
76+ key.Matches(msg, f.keyMap.End),
77+ key.Matches(msg, f.keyMap.Home):
78+ u, cmd := f.groupedList.Update(msg)
79+ f.groupedList = u.(*groupedList[T])
80+ return f, cmd
81+ default:
82+ if !f.inputHidden {
83+ var cmds []tea.Cmd
84+ var cmd tea.Cmd
85+ f.input, cmd = f.input.Update(msg)
86+ cmds = append(cmds, cmd)
87+
88+ if f.query != f.input.Value() {
89+ cmd = f.Filter(f.input.Value())
90+ cmds = append(cmds, cmd)
91+ }
92+ f.query = f.input.Value()
93+ return f, tea.Batch(cmds...)
94+ }
95+ }
96+ }
97+ u, cmd := f.groupedList.Update(msg)
98+ f.groupedList = u.(*groupedList[T])
99+ return f, cmd
100+}
101+
102+func (f *filterableGroupList[T]) View() string {
103+ if f.inputHidden {
104+ return f.groupedList.View()
105+ }
106+
107+ return lipgloss.JoinVertical(
108+ lipgloss.Left,
109+ f.inputStyle.Render(f.input.View()),
110+ f.groupedList.View(),
111+ )
112+}
113+
114+// removes bindings that are used for search
115+func (f *filterableGroupList[T]) updateKeyMaps() {
116+ alphanumeric := regexp.MustCompile("^[a-zA-Z0-9]*$")
117+
118+ removeLettersAndNumbers := func(bindings []string) []string {
119+ var keep []string
120+ for _, b := range bindings {
121+ if len(b) != 1 {
122+ keep = append(keep, b)
123+ continue
124+ }
125+ if b == " " {
126+ continue
127+ }
128+ m := alphanumeric.MatchString(b)
129+ if !m {
130+ keep = append(keep, b)
131+ }
132+ }
133+ return keep
134+ }
135+
136+ updateBinding := func(binding key.Binding) key.Binding {
137+ newKeys := removeLettersAndNumbers(binding.Keys())
138+ if len(newKeys) == 0 {
139+ binding.SetEnabled(false)
140+ return binding
141+ }
142+ binding.SetKeys(newKeys...)
143+ return binding
144+ }
145+
146+ f.keyMap.Down = updateBinding(f.keyMap.Down)
147+ f.keyMap.Up = updateBinding(f.keyMap.Up)
148+ f.keyMap.DownOneItem = updateBinding(f.keyMap.DownOneItem)
149+ f.keyMap.UpOneItem = updateBinding(f.keyMap.UpOneItem)
150+ f.keyMap.HalfPageDown = updateBinding(f.keyMap.HalfPageDown)
151+ f.keyMap.HalfPageUp = updateBinding(f.keyMap.HalfPageUp)
152+ f.keyMap.PageDown = updateBinding(f.keyMap.PageDown)
153+ f.keyMap.PageUp = updateBinding(f.keyMap.PageUp)
154+ f.keyMap.End = updateBinding(f.keyMap.End)
155+ f.keyMap.Home = updateBinding(f.keyMap.Home)
156+}
157+
158+func (m *filterableGroupList[T]) GetSize() (int, int) {
159+ return m.width, m.height
160+}
161+
162+func (f *filterableGroupList[T]) SetSize(w, h int) tea.Cmd {
163+ f.width = w
164+ f.height = h
165+ if f.inputHidden {
166+ return f.groupedList.SetSize(w, h)
167+ }
168+ if f.inputWidth == 0 {
169+ f.input.SetWidth(w)
170+ } else {
171+ f.input.SetWidth(f.inputWidth)
172+ }
173+ return f.groupedList.SetSize(w, h-(f.inputHeight()))
174+}
175+
176+func (f *filterableGroupList[T]) inputHeight() int {
177+ return lipgloss.Height(f.inputStyle.Render(f.input.View()))
178+}
179+
180+func (f *filterableGroupList[T]) Filter(query string) tea.Cmd {
181+ var cmds []tea.Cmd
182+ for _, item := range f.items {
183+ if i, ok := any(item).(layout.Focusable); ok {
184+ cmds = append(cmds, i.Blur())
185+ }
186+ if i, ok := any(item).(HasMatchIndexes); ok {
187+ i.MatchIndexes(make([]int, 0))
188+ }
189+ }
190+
191+ f.selectedItem = ""
192+ if query == "" {
193+ return f.groupedList.SetGroups(f.groups)
194+ }
195+
196+ var newGroups []Group[T]
197+ for _, g := range f.groups {
198+ words := make([]string, len(g.Items))
199+ for i, item := range g.Items {
200+ words[i] = strings.ToLower(item.FilterValue())
201+ }
202+
203+ matches := fuzzy.Find(query, words)
204+
205+ sort.SliceStable(matches, func(i, j int) bool {
206+ return matches[i].Score > matches[j].Score
207+ })
208+
209+ var matchedItems []T
210+ for _, match := range matches {
211+ item := g.Items[match.Index]
212+ if i, ok := any(item).(HasMatchIndexes); ok {
213+ i.MatchIndexes(match.MatchedIndexes)
214+ }
215+ matchedItems = append(matchedItems, item)
216+ }
217+ if len(matchedItems) > 0 {
218+ newGroups = append(newGroups, Group[T]{
219+ Section: g.Section,
220+ Items: matchedItems,
221+ })
222+ }
223+ }
224+ cmds = append(cmds, f.groupedList.SetGroups(newGroups))
225+ return tea.Batch(cmds...)
226+}
227+
228+func (f *filterableGroupList[T]) SetGroups(groups []Group[T]) tea.Cmd {
229+ f.groups = groups
230+ return f.groupedList.SetGroups(groups)
231+}
232+
233+func (f *filterableGroupList[T]) Cursor() *tea.Cursor {
234+ if f.inputHidden {
235+ return nil
236+ }
237+ return f.input.Cursor()
238+}
239+
240+func (f *filterableGroupList[T]) Blur() tea.Cmd {
241+ f.input.Blur()
242+ return f.groupedList.Blur()
243+}
244+
245+func (f *filterableGroupList[T]) Focus() tea.Cmd {
246+ f.input.Focus()
247+ return f.groupedList.Focus()
248+}
249+
250+func (f *filterableGroupList[T]) IsFocused() bool {
251+ return f.groupedList.IsFocused()
252+}
253+
254+func (f *filterableGroupList[T]) SetInputWidth(w int) {
255+ f.inputWidth = w
256+}
257+
258+func (f *filterableGroupList[T]) SetInputPlaceholder(ph string) {
259+ f.placeholder = ph
260+}
internal/tui/exp/list/filterable_test.go+66−58
@@ -1,60 +1,68 @@
11package list
22
3−//
4−// func TestFilterableList(t *testing.T) {
5−// t.Parallel()
6−// t.Run("should create simple filterable list", func(t *testing.T) {
7−// t.Parallel()
8−// items := []FilterableItem{}
9−// for i := range 5 {
10−// item := NewFilterableItem(fmt.Sprintf("Item %d", i))
11−// items = append(items, item)
12−// }
13−// l := NewFilterableList(
14−// items,
15−// WithFilterListOptions(WithDirection(Forward)),
16−// ).(*filterableList[FilterableItem])
17−//
18−// l.SetSize(100, 10)
19−// cmd := l.Init()
20−// if cmd != nil {
21−// cmd()
22−// }
23−//
24−// assert.Equal(t, items[0].ID(), l.selectedItem)
25−// golden.RequireEqual(t, []byte(l.View()))
26−// })
27−// }
28−//
29−// func TestUpdateKeyMap(t *testing.T) {
30−// t.Parallel()
31−// l := NewFilterableList(
32−// []FilterableItem{},
33−// WithFilterListOptions(WithDirection(Forward)),
34−// ).(*filterableList[FilterableItem])
35−//
36−// hasJ := slices.Contains(l.keyMap.Down.Keys(), "j")
37−// fmt.Println(l.keyMap.Down.Keys())
38−// hasCtrlJ := slices.Contains(l.keyMap.Down.Keys(), "ctrl+j")
39−//
40−// hasUpperCaseK := slices.Contains(l.keyMap.UpOneItem.Keys(), "K")
41−//
42−// assert.False(t, l.keyMap.HalfPageDown.Enabled(), "should disable keys that are only letters")
43−// assert.False(t, hasJ, "should not contain j")
44−// assert.False(t, hasUpperCaseK, "should also remove upper case K")
45−// assert.True(t, hasCtrlJ, "should still have ctrl+j")
46−// }
47−//
48−// type filterableItem struct {
49−// *selectableItem
50−// }
51−//
52−// func NewFilterableItem(content string) FilterableItem {
53−// return &filterableItem{
54−// selectableItem: NewSelectableItem(content).(*selectableItem),
55−// }
56−// }
57−//
58−// func (f *filterableItem) FilterValue() string {
59−// return f.content
60−// }
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+99
@@ -0,0 +1,99 @@
1+package list
2+
3+import (
4+ tea "github.com/charmbracelet/bubbletea/v2"
5+ "github.com/charmbracelet/crush/internal/tui/components/core/layout"
6+ "github.com/charmbracelet/crush/internal/tui/util"
7+)
8+
9+type Group[T Item] struct {
10+ Section ItemSection
11+ Items []T
12+}
13+type GroupedList[T Item] interface {
14+ util.Model
15+ layout.Sizeable
16+ Items() []Item
17+ Groups() []Group[T]
18+ SetGroups([]Group[T]) tea.Cmd
19+ MoveUp(int) tea.Cmd
20+ MoveDown(int) tea.Cmd
21+ GoToTop() tea.Cmd
22+ GoToBottom() tea.Cmd
23+ SelectItemAbove() tea.Cmd
24+ SelectItemBelow() tea.Cmd
25+ SetSelected(string) tea.Cmd
26+ SelectedItem() *T
27+}
28+type groupedList[T Item] struct {
29+ *list[Item]
30+ groups []Group[T]
31+}
32+
33+func NewGroupedList[T Item](groups []Group[T], opts ...ListOption) GroupedList[T] {
34+ list := &list[Item]{
35+ confOptions: &confOptions{
36+ direction: DirectionForward,
37+ keyMap: DefaultKeyMap(),
38+ focused: true,
39+ },
40+ indexMap: make(map[string]int),
41+ renderedItems: map[string]renderedItem{},
42+ }
43+ for _, opt := range opts {
44+ opt(list.confOptions)
45+ }
46+
47+ return &groupedList[T]{
48+ list: list,
49+ }
50+}
51+
52+func (g *groupedList[T]) Init() tea.Cmd {
53+ g.convertItems()
54+ return g.render()
55+}
56+
57+func (l *groupedList[T]) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
58+ u, cmd := l.list.Update(msg)
59+ l.list = u.(*list[Item])
60+ return l, cmd
61+}
62+
63+func (g *groupedList[T]) SelectedItem() *T {
64+ item := g.list.SelectedItem()
65+ if item == nil {
66+ return nil
67+ }
68+ dRef := *item
69+ c, ok := any(dRef).(T)
70+ if !ok {
71+ return nil
72+ }
73+ return &c
74+}
75+
76+func (g *groupedList[T]) convertItems() {
77+ var items []Item
78+ for _, g := range g.groups {
79+ items = append(items, g.Section)
80+ for _, g := range g.Items {
81+ items = append(items, g)
82+ }
83+ }
84+ g.items = items
85+}
86+
87+func (g *groupedList[T]) SetGroups(groups []Group[T]) tea.Cmd {
88+ g.groups = groups
89+ g.convertItems()
90+ return g.SetItems(g.items)
91+}
92+
93+func (g *groupedList[T]) Groups() []Group[T] {
94+ return g.groups
95+}
96+
97+func (g *groupedList[T]) Items() []Item {
98+ return g.list.Items()
99+}
internal/tui/exp/list/items.go+83−6
@@ -4,6 +4,7 @@ import (
44 "image/color"
55
66 tea "github.com/charmbracelet/bubbletea/v2"
7+ "github.com/charmbracelet/crush/internal/tui/components/core"
78 "github.com/charmbracelet/crush/internal/tui/components/core/layout"
89 "github.com/charmbracelet/crush/internal/tui/styles"
910 "github.com/charmbracelet/lipgloss/v2"
@@ -12,6 +13,10 @@ import (
1213 "github.com/rivo/uniseg"
1314)
1415
16+type Indexable interface {
17+ SetIndex(int)
18+}
19+
1520type CompletionItem[T any] interface {
1621 FilterableItem
1722 layout.Focusable
@@ -39,33 +44,33 @@ type options struct {
3944 shortcut string
4045}
4146
42−type completionOption func(*options)
47+type CompletionItemOption func(*options)
4348
44−func WithBackgroundColor(c color.Color) completionOption {
49+func WithCompletionBackgroundColor(c color.Color) CompletionItemOption {
4550 return func(cmp *options) {
4651 cmp.bgColor = c
4752 }
4853}
4954
50−func WithMatchIndexes(indexes ...int) completionOption {
55+func WithCompletionMatchIndexes(indexes ...int) CompletionItemOption {
5156 return func(cmp *options) {
5257 cmp.matchIndexes = indexes
5358 }
5459}
5560
56−func WithShortcut(shortcut string) completionOption {
61+func WithCompletionShortcut(shortcut string) CompletionItemOption {
5762 return func(cmp *options) {
5863 cmp.shortcut = shortcut
5964 }
6065}
6166
62−func WithID(id string) completionOption {
67+func WithCompletionID(id string) CompletionItemOption {
6368 return func(cmp *options) {
6469 cmp.id = id
6570 }
6671}
6772
68−func NewCompletionItem[T any](text string, value T, opts ...completionOption) CompletionItem[T] {
73+func NewCompletionItem[T any](text string, value T, opts ...CompletionItemOption) CompletionItem[T] {
6974 c := &completionItemCmp[T]{
7075 text: text,
7176 value: value,
@@ -306,3 +311,75 @@ func bytePosToVisibleCharPos(str string, rng [2]int) (int, int) {
306311func (c *completionItemCmp[T]) ID() string {
307312 return c.id
308313}
314+
315+type ItemSection interface {
316+ Item
317+ layout.Sizeable
318+ Indexable
319+ SetInfo(info string)
320+}
321+type itemSectionModel struct {
322+ width int
323+ title string
324+ inx int
325+ info string
326+}
327+
328+// ID implements ItemSection.
329+func (m *itemSectionModel) ID() string {
330+ return uuid.NewString()
331+}
332+
333+func NewItemSection(title string) ItemSection {
334+ return &itemSectionModel{
335+ title: title,
336+ inx: -1,
337+ }
338+}
339+
340+func (m *itemSectionModel) Init() tea.Cmd {
341+ return nil
342+}
343+
344+func (m *itemSectionModel) Update(tea.Msg) (tea.Model, tea.Cmd) {
345+ return m, nil
346+}
347+
348+func (m *itemSectionModel) View() string {
349+ t := styles.CurrentTheme()
350+ title := ansi.Truncate(m.title, m.width-2, "…")
351+ style := t.S().Base.Padding(1, 1, 0, 1)
352+ if m.inx == 0 {
353+ style = style.Padding(0, 1, 0, 1)
354+ }
355+ title = t.S().Muted.Render(title)
356+ section := ""
357+ if m.info != "" {
358+ section = core.SectionWithInfo(title, m.width-2, m.info)
359+ } else {
360+ section = core.Section(title, m.width-2)
361+ }
362+
363+ return style.Render(section)
364+}
365+
366+func (m *itemSectionModel) GetSize() (int, int) {
367+ return m.width, 1
368+}
369+
370+func (m *itemSectionModel) SetSize(width int, height int) tea.Cmd {
371+ m.width = width
372+ return nil
373+}
374+
375+func (m *itemSectionModel) IsSectionHeader() bool {
376+ return true
377+}
378+
379+func (m *itemSectionModel) SetInfo(info string) {
380+ m.info = info
381+}
382+
383+func (m *itemSectionModel) SetIndex(inx int) {
384+ m.inx = inx
385+}
internal/tui/exp/list/list.go+78−41
@@ -8,6 +8,7 @@ import (
88 tea "github.com/charmbracelet/bubbletea/v2"
99 "github.com/charmbracelet/crush/internal/tui/components/anim"
1010 "github.com/charmbracelet/crush/internal/tui/components/core/layout"
11+ "github.com/charmbracelet/crush/internal/tui/styles"
1112 "github.com/charmbracelet/crush/internal/tui/util"
1213 "github.com/charmbracelet/lipgloss/v2"
1314)
@@ -22,30 +23,29 @@ type HasAnim interface {
2223 Item
2324 Spinning() bool
2425}
25−type (
26− renderedMsg struct{}
27− List[T Item] interface {
28− util.Model
29− layout.Sizeable
30− layout.Focusable
31−
32− // Just change state
33− MoveUp(int) tea.Cmd
34− MoveDown(int) tea.Cmd
35− GoToTop() tea.Cmd
36− GoToBottom() tea.Cmd
37− SelectItemAbove() tea.Cmd
38− SelectItemBelow() tea.Cmd
39− SetItems([]T) tea.Cmd
40− SetSelected(string) tea.Cmd
41− SelectedItem() *T
42− Items() []T
43− UpdateItem(string, T) tea.Cmd
44− DeleteItem(string) tea.Cmd
45− PrependItem(T) tea.Cmd
46− AppendItem(T) tea.Cmd
47− }
48−)
26+type renderedMsg struct{}
27+
28+type List[T Item] interface {
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
48+}
4949
5050type direction int
5151
@@ -76,6 +76,7 @@ type confOptions struct {
7676 direction direction
7777 selectedItem string
7878 focused bool
79+ resize bool
7980}
8081
8182type list[T Item] struct {
@@ -93,10 +94,10 @@ type list[T Item] struct {
9394 movingByItem bool
9495}
9596
96−type listOption func(*confOptions)
97+type ListOption func(*confOptions)
9798
9899// WithSize sets the size of the list.
99−func WithSize(width, height int) listOption {
100+func WithSize(width, height int) ListOption {
100101 return func(l *confOptions) {
101102 l.width = width
102103 l.height = height
@@ -104,52 +105,58 @@ func WithSize(width, height int) listOption {
104105}
105106
106107// WithGap sets the gap between items in the list.
107−func WithGap(gap int) listOption {
108+func WithGap(gap int) ListOption {
108109 return func(l *confOptions) {
109110 l.gap = gap
110111 }
111112}
112113
113114// WithDirectionForward sets the direction to forward
114−func WithDirectionForward() listOption {
115+func WithDirectionForward() ListOption {
115116 return func(l *confOptions) {
116117 l.direction = DirectionForward
117118 }
118119}
119120
120121// WithDirectionBackward sets the direction to forward
121−func WithDirectionBackward() listOption {
122+func WithDirectionBackward() ListOption {
122123 return func(l *confOptions) {
123124 l.direction = DirectionBackward
124125 }
125126}
126127
127128// WithSelectedItem sets the initially selected item in the list.
128−func WithSelectedItem(id string) listOption {
129+func WithSelectedItem(id string) ListOption {
129130 return func(l *confOptions) {
130131 l.selectedItem = id
131132 }
132133}
133134
134−func WithKeyMap(keyMap KeyMap) listOption {
135+func WithKeyMap(keyMap KeyMap) ListOption {
135136 return func(l *confOptions) {
136137 l.keyMap = keyMap
137138 }
138139}
139140
140−func WithWrapNavigation() listOption {
141+func WithWrapNavigation() ListOption {
141142 return func(l *confOptions) {
142143 l.wrap = true
143144 }
144145}
145146
146−func WithFocus(focus bool) listOption {
147+func WithFocus(focus bool) ListOption {
147148 return func(l *confOptions) {
148149 l.focused = focus
149150 }
150151}
151152
152−func New[T Item](items []T, opts ...listOption) List[T] {
153+func WithResizeByList() ListOption {
154+ return func(l *confOptions) {
155+ l.resize = true
156+ }
157+}
158+
159+func New[T Item](items []T, opts ...ListOption) List[T] {
153160 list := &list[T]{
154161 confOptions: &confOptions{
155162 direction: DirectionForward,
@@ -165,6 +172,9 @@ func New[T Item](items []T, opts ...listOption) List[T] {
165172 }
166173
167174 for inx, item := range items {
175+ if i, ok := any(item).(Indexable); ok {
176+ i.SetIndex(inx)
177+ }
168178 list.indexMap[item.ID()] = inx
169179 }
170180 return list
@@ -224,6 +234,7 @@ func (l *list[T]) View() string {
224234 if l.height <= 0 || l.width <= 0 {
225235 return ""
226236 }
237+ t := styles.CurrentTheme()
227238 view := l.rendered
228239 lines := strings.Split(view, "\n")
229240
@@ -231,7 +242,13 @@ func (l *list[T]) View() string {
231242 viewStart := max(0, start)
232243 viewEnd := min(len(lines), end+1)
233244 lines = lines[viewStart:viewEnd]
234− return strings.Join(lines, "\n")
245+ if l.resize {
246+ return strings.Join(lines, "\n")
247+ }
248+ return t.S().Base.
249+ Height(l.height).
250+ Width(l.width).
251+ Render(strings.Join(lines, "\n"))
235252}
236253
237254func (l *list[T]) viewPosition() (int, int) {
@@ -774,10 +791,26 @@ func (l *list[T]) SelectItemAbove() tea.Cmd {
774791 // no item above
775792 return nil
776793 }
794+ var cmds []tea.Cmd
795+ if newIndex == 1 {
796+ peakAboveIndex := l.firstSelectableItemAbove(newIndex)
797+ if peakAboveIndex == ItemNotFound {
798+ // this means there is a section above move to the top
799+ cmd := l.GoToTop()
800+ if cmd != nil {
801+ cmds = append(cmds, cmd)
802+ }
803+ }
804+
805+ }
777806 item := l.items[newIndex]
778807 l.selectedItem = item.ID()
779808 l.movingByItem = true
780− return l.render()
809+ renderCmd := l.render()
810+ if renderCmd != nil {
811+ cmds = append(cmds, renderCmd)
812+ }
813+ return tea.Sequence(cmds...)
781814}
782815
783816// SelectItemBelow implements List.
@@ -815,10 +848,13 @@ func (l *list[T]) SelectedItem() *T {
815848func (l *list[T]) SetItems(items []T) tea.Cmd {
816849 l.items = items
817850 var cmds []tea.Cmd
818− for _, item := range l.items {
851+ for inx, item := range l.items {
852+ if i, ok := any(item).(Indexable); ok {
853+ i.SetIndex(inx)
854+ }
819855 cmds = append(cmds, item.Init())
820856 }
821− cmds = append(cmds, l.reset())
857+ cmds = append(cmds, l.reset(""))
822858 return tea.Batch(cmds...)
823859}
824860
@@ -828,11 +864,11 @@ func (l *list[T]) SetSelected(id string) tea.Cmd {
828864 return l.render()
829865}
830866
831−func (l *list[T]) reset() tea.Cmd {
867+func (l *list[T]) reset(selectedItem string) tea.Cmd {
832868 var cmds []tea.Cmd
833869 l.rendered = ""
834870 l.offset = 0
835− l.selectedItem = ""
871+ l.selectedItem = selectedItem
836872 l.indexMap = make(map[string]int)
837873 l.renderedItems = make(map[string]renderedItem)
838874 for inx, item := range l.items {
@@ -851,7 +887,8 @@ func (l *list[T]) SetSize(width int, height int) tea.Cmd {
851887 l.width = width
852888 l.height = height
853889 if oldWidth != width {
854− return l.reset()
890+ cmd := l.reset(l.selectedItem)
891+ return cmd
855892 }
856893 return nil
857894}
internal/tui/exp/list/testdata/TestFilterableList/should_create_simple_filterable_list.goldennew file+6
@@ -0,0 +1,6 @@
1+> Type to filter 
2+│Item 0
3+Item 1
4+Item 2
5+Item 3
6+Item 4
internal/tui/exp/list/testdata/TestList/has_correct_positions_in_list_that_does_not_fits_the_items.goldendeleted−10
@@ -1,10 +0,0 @@
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/has_correct_positions_in_list_that_does_not_fits_the_items_and_has_multi_line_items.goldendeleted−10
@@ -1,10 +0,0 @@
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/has_correct_positions_in_list_that_does_not_fits_the_items_and_has_multi_line_items_backwards.goldendeleted−10
@@ -1,10 +0,0 @@
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/has_correct_positions_in_list_that_does_not_fits_the_items_backwards.goldendeleted−10
@@ -1,10 +0,0 @@
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/has_correct_positions_in_list_that_fits_the_items.goldendeleted−5
@@ -1,5 +0,0 @@
1−│Item 0
2−Item 1
3−Item 2
4−Item 3
5−Item 4
internal/tui/exp/list/testdata/TestList/has_correct_positions_in_list_that_fits_the_items_backwards.goldendeleted−5
@@ -1,5 +0,0 @@
1−Item 0
2−Item 1
3−Item 2
4−Item 3
5−│Item 4
internal/tui/exp/list/testdata/TestListMovement/should_not_change_offset_when_new_items_are_appended_and_we_are_at_the_botton.goldendeleted−10
@@ -1,10 +0,0 @@
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_stay_at_the_position_it_is_when_new_items_are_added_but_we_moved_up.goldendeleted−10
@@ -1,10 +0,0 @@
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.goldendeleted−10
@@ -1,10 +0,0 @@
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_decreases.goldendeleted−10
@@ -1,10 +0,0 @@
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#01.goldendeleted−10
@@ -1,10 +0,0 @@
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.goldendeleted−10
@@ -1,10 +0,0 @@
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