chore: grouped list

Kujtim Hoxha created

Change summary

internal/tui/components/chat/splash/splash.go                                                                                                |  26 
internal/tui/components/dialogs/commands/commands.go                                                                                         |  67 
internal/tui/components/dialogs/models/list.go                                                                                               | 105 
internal/tui/components/dialogs/models/models.go                                                                                             |  28 
internal/tui/components/dialogs/sessions/sessions.go                                                                                         |   2 
internal/tui/exp/list/filterable.go                                                                                                          |   9 
internal/tui/exp/list/filterable_group.go                                                                                                    | 260 
internal/tui/exp/list/filterable_test.go                                                                                                     | 124 
internal/tui/exp/list/grouped.go                                                                                                             |  99 
internal/tui/exp/list/items.go                                                                                                               |  89 
internal/tui/exp/list/list.go                                                                                                                | 119 
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 
24 files changed, 733 insertions(+), 311 deletions(-)

Detailed changes

internal/tui/components/chat/splash/splash.go ๐Ÿ”—

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

internal/tui/components/dialogs/commands/commands.go ๐Ÿ”—

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

internal/tui/components/dialogs/models/list.go ๐Ÿ”—

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

internal/tui/components/dialogs/models/models.go ๐Ÿ”—

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

internal/tui/components/dialogs/sessions/sessions.go ๐Ÿ”—

@@ -47,7 +47,7 @@ func NewSessionDialogCmp(sessions []session.Session, selectedID string) SessionD
 	items := make([]list.CompletionItem[session.Session], len(sessions))
 	if len(sessions) > 0 {
 		for i, session := range sessions {
-			items[i] = list.NewCompletionItem(session.Title, session, list.WithID(session.ID))
+			items[i] = list.NewCompletionItem(session.Title, session, list.WithCompletionID(session.ID))
 		}
 	}
 

internal/tui/exp/list/filterable.go ๐Ÿ”—

@@ -23,6 +23,7 @@ type FilterableList[T FilterableItem] interface {
 	List[T]
 	Cursor() *tea.Cursor
 	SetInputWidth(int)
+	SetInputPlaceholder(string)
 }
 
 type HasMatchIndexes interface {
@@ -30,7 +31,7 @@ type HasMatchIndexes interface {
 }
 
 type filterableOptions struct {
-	listOptions []listOption
+	listOptions []ListOption
 	placeholder string
 	inputHidden bool
 	inputWidth  int
@@ -67,7 +68,7 @@ func WithFilterInputStyle(inputStyle lipgloss.Style) filterableListOption {
 	}
 }
 
-func WithFilterListOptions(opts ...listOption) filterableListOption {
+func WithFilterListOptions(opts ...ListOption) filterableListOption {
 	return func(f *filterableOptions) {
 		f.listOptions = opts
 	}
@@ -295,3 +296,7 @@ func (f *filterableList[T]) IsFocused() bool {
 func (f *filterableList[T]) SetInputWidth(w int) {
 	f.inputWidth = w
 }
+
+func (f *filterableList[T]) SetInputPlaceholder(ph string) {
+	f.placeholder = ph
+}

internal/tui/exp/list/filterable_group.go ๐Ÿ”—

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

internal/tui/exp/list/filterable_test.go ๐Ÿ”—

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

internal/tui/exp/list/grouped.go ๐Ÿ”—

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

internal/tui/exp/list/items.go ๐Ÿ”—

@@ -4,6 +4,7 @@ import (
 	"image/color"
 
 	tea "github.com/charmbracelet/bubbletea/v2"
+	"github.com/charmbracelet/crush/internal/tui/components/core"
 	"github.com/charmbracelet/crush/internal/tui/components/core/layout"
 	"github.com/charmbracelet/crush/internal/tui/styles"
 	"github.com/charmbracelet/lipgloss/v2"
@@ -12,6 +13,10 @@ import (
 	"github.com/rivo/uniseg"
 )
 
+type Indexable interface {
+	SetIndex(int)
+}
+
 type CompletionItem[T any] interface {
 	FilterableItem
 	layout.Focusable
@@ -39,33 +44,33 @@ type options struct {
 	shortcut     string
 }
 
-type completionOption func(*options)
+type CompletionItemOption func(*options)
 
-func WithBackgroundColor(c color.Color) completionOption {
+func WithCompletionBackgroundColor(c color.Color) CompletionItemOption {
 	return func(cmp *options) {
 		cmp.bgColor = c
 	}
 }
 
-func WithMatchIndexes(indexes ...int) completionOption {
+func WithCompletionMatchIndexes(indexes ...int) CompletionItemOption {
 	return func(cmp *options) {
 		cmp.matchIndexes = indexes
 	}
 }
 
-func WithShortcut(shortcut string) completionOption {
+func WithCompletionShortcut(shortcut string) CompletionItemOption {
 	return func(cmp *options) {
 		cmp.shortcut = shortcut
 	}
 }
 
-func WithID(id string) completionOption {
+func WithCompletionID(id string) CompletionItemOption {
 	return func(cmp *options) {
 		cmp.id = id
 	}
 }
 
-func NewCompletionItem[T any](text string, value T, opts ...completionOption) CompletionItem[T] {
+func NewCompletionItem[T any](text string, value T, opts ...CompletionItemOption) CompletionItem[T] {
 	c := &completionItemCmp[T]{
 		text:  text,
 		value: value,
@@ -306,3 +311,75 @@ func bytePosToVisibleCharPos(str string, rng [2]int) (int, int) {
 func (c *completionItemCmp[T]) ID() string {
 	return c.id
 }
+
+type ItemSection interface {
+	Item
+	layout.Sizeable
+	Indexable
+	SetInfo(info string)
+}
+type itemSectionModel struct {
+	width int
+	title string
+	inx   int
+	info  string
+}
+
+// ID implements ItemSection.
+func (m *itemSectionModel) ID() string {
+	return uuid.NewString()
+}
+
+func NewItemSection(title string) ItemSection {
+	return &itemSectionModel{
+		title: title,
+		inx:   -1,
+	}
+}
+
+func (m *itemSectionModel) Init() tea.Cmd {
+	return nil
+}
+
+func (m *itemSectionModel) Update(tea.Msg) (tea.Model, tea.Cmd) {
+	return m, nil
+}
+
+func (m *itemSectionModel) View() string {
+	t := styles.CurrentTheme()
+	title := ansi.Truncate(m.title, m.width-2, "โ€ฆ")
+	style := t.S().Base.Padding(1, 1, 0, 1)
+	if m.inx == 0 {
+		style = style.Padding(0, 1, 0, 1)
+	}
+	title = t.S().Muted.Render(title)
+	section := ""
+	if m.info != "" {
+		section = core.SectionWithInfo(title, m.width-2, m.info)
+	} else {
+		section = core.Section(title, m.width-2)
+	}
+
+	return style.Render(section)
+}
+
+func (m *itemSectionModel) GetSize() (int, int) {
+	return m.width, 1
+}
+
+func (m *itemSectionModel) SetSize(width int, height int) tea.Cmd {
+	m.width = width
+	return nil
+}
+
+func (m *itemSectionModel) IsSectionHeader() bool {
+	return true
+}
+
+func (m *itemSectionModel) SetInfo(info string) {
+	m.info = info
+}
+
+func (m *itemSectionModel) SetIndex(inx int) {
+	m.inx = inx
+}

internal/tui/exp/list/list.go ๐Ÿ”—

@@ -8,6 +8,7 @@ import (
 	tea "github.com/charmbracelet/bubbletea/v2"
 	"github.com/charmbracelet/crush/internal/tui/components/anim"
 	"github.com/charmbracelet/crush/internal/tui/components/core/layout"
+	"github.com/charmbracelet/crush/internal/tui/styles"
 	"github.com/charmbracelet/crush/internal/tui/util"
 	"github.com/charmbracelet/lipgloss/v2"
 )
@@ -22,30 +23,29 @@ type HasAnim interface {
 	Item
 	Spinning() bool
 }
-type (
-	renderedMsg  struct{}
-	List[T Item] interface {
-		util.Model
-		layout.Sizeable
-		layout.Focusable
-
-		// Just change state
-		MoveUp(int) tea.Cmd
-		MoveDown(int) tea.Cmd
-		GoToTop() tea.Cmd
-		GoToBottom() tea.Cmd
-		SelectItemAbove() tea.Cmd
-		SelectItemBelow() tea.Cmd
-		SetItems([]T) tea.Cmd
-		SetSelected(string) tea.Cmd
-		SelectedItem() *T
-		Items() []T
-		UpdateItem(string, T) tea.Cmd
-		DeleteItem(string) tea.Cmd
-		PrependItem(T) tea.Cmd
-		AppendItem(T) tea.Cmd
-	}
-)
+type renderedMsg struct{}
+
+type List[T Item] interface {
+	util.Model
+	layout.Sizeable
+	layout.Focusable
+
+	// Just change state
+	MoveUp(int) tea.Cmd
+	MoveDown(int) tea.Cmd
+	GoToTop() tea.Cmd
+	GoToBottom() tea.Cmd
+	SelectItemAbove() tea.Cmd
+	SelectItemBelow() tea.Cmd
+	SetItems([]T) tea.Cmd
+	SetSelected(string) tea.Cmd
+	SelectedItem() *T
+	Items() []T
+	UpdateItem(string, T) tea.Cmd
+	DeleteItem(string) tea.Cmd
+	PrependItem(T) tea.Cmd
+	AppendItem(T) tea.Cmd
+}
 
 type direction int
 
@@ -76,6 +76,7 @@ type confOptions struct {
 	direction    direction
 	selectedItem string
 	focused      bool
+	resize       bool
 }
 
 type list[T Item] struct {
@@ -93,10 +94,10 @@ type list[T Item] struct {
 	movingByItem bool
 }
 
-type listOption func(*confOptions)
+type ListOption func(*confOptions)
 
 // WithSize sets the size of the list.
-func WithSize(width, height int) listOption {
+func WithSize(width, height int) ListOption {
 	return func(l *confOptions) {
 		l.width = width
 		l.height = height
@@ -104,52 +105,58 @@ func WithSize(width, height int) listOption {
 }
 
 // WithGap sets the gap between items in the list.
-func WithGap(gap int) listOption {
+func WithGap(gap int) ListOption {
 	return func(l *confOptions) {
 		l.gap = gap
 	}
 }
 
 // WithDirectionForward sets the direction to forward
-func WithDirectionForward() listOption {
+func WithDirectionForward() ListOption {
 	return func(l *confOptions) {
 		l.direction = DirectionForward
 	}
 }
 
 // WithDirectionBackward sets the direction to forward
-func WithDirectionBackward() listOption {
+func WithDirectionBackward() ListOption {
 	return func(l *confOptions) {
 		l.direction = DirectionBackward
 	}
 }
 
 // WithSelectedItem sets the initially selected item in the list.
-func WithSelectedItem(id string) listOption {
+func WithSelectedItem(id string) ListOption {
 	return func(l *confOptions) {
 		l.selectedItem = id
 	}
 }
 
-func WithKeyMap(keyMap KeyMap) listOption {
+func WithKeyMap(keyMap KeyMap) ListOption {
 	return func(l *confOptions) {
 		l.keyMap = keyMap
 	}
 }
 
-func WithWrapNavigation() listOption {
+func WithWrapNavigation() ListOption {
 	return func(l *confOptions) {
 		l.wrap = true
 	}
 }
 
-func WithFocus(focus bool) listOption {
+func WithFocus(focus bool) ListOption {
 	return func(l *confOptions) {
 		l.focused = focus
 	}
 }
 
-func New[T Item](items []T, opts ...listOption) List[T] {
+func WithResizeByList() ListOption {
+	return func(l *confOptions) {
+		l.resize = true
+	}
+}
+
+func New[T Item](items []T, opts ...ListOption) List[T] {
 	list := &list[T]{
 		confOptions: &confOptions{
 			direction: DirectionForward,
@@ -165,6 +172,9 @@ func New[T Item](items []T, opts ...listOption) List[T] {
 	}
 
 	for inx, item := range items {
+		if i, ok := any(item).(Indexable); ok {
+			i.SetIndex(inx)
+		}
 		list.indexMap[item.ID()] = inx
 	}
 	return list
@@ -224,6 +234,7 @@ func (l *list[T]) View() string {
 	if l.height <= 0 || l.width <= 0 {
 		return ""
 	}
+	t := styles.CurrentTheme()
 	view := l.rendered
 	lines := strings.Split(view, "\n")
 
@@ -231,7 +242,13 @@ func (l *list[T]) View() string {
 	viewStart := max(0, start)
 	viewEnd := min(len(lines), end+1)
 	lines = lines[viewStart:viewEnd]
-	return strings.Join(lines, "\n")
+	if l.resize {
+		return strings.Join(lines, "\n")
+	}
+	return t.S().Base.
+		Height(l.height).
+		Width(l.width).
+		Render(strings.Join(lines, "\n"))
 }
 
 func (l *list[T]) viewPosition() (int, int) {
@@ -774,10 +791,26 @@ func (l *list[T]) SelectItemAbove() tea.Cmd {
 		// no item above
 		return nil
 	}
+	var cmds []tea.Cmd
+	if newIndex == 1 {
+		peakAboveIndex := l.firstSelectableItemAbove(newIndex)
+		if peakAboveIndex == ItemNotFound {
+			// this means there is a section above move to the top
+			cmd := l.GoToTop()
+			if cmd != nil {
+				cmds = append(cmds, cmd)
+			}
+		}
+
+	}
 	item := l.items[newIndex]
 	l.selectedItem = item.ID()
 	l.movingByItem = true
-	return l.render()
+	renderCmd := l.render()
+	if renderCmd != nil {
+		cmds = append(cmds, renderCmd)
+	}
+	return tea.Sequence(cmds...)
 }
 
 // SelectItemBelow implements List.
@@ -815,10 +848,13 @@ func (l *list[T]) SelectedItem() *T {
 func (l *list[T]) SetItems(items []T) tea.Cmd {
 	l.items = items
 	var cmds []tea.Cmd
-	for _, item := range l.items {
+	for inx, item := range l.items {
+		if i, ok := any(item).(Indexable); ok {
+			i.SetIndex(inx)
+		}
 		cmds = append(cmds, item.Init())
 	}
-	cmds = append(cmds, l.reset())
+	cmds = append(cmds, l.reset(""))
 	return tea.Batch(cmds...)
 }
 
@@ -828,11 +864,11 @@ func (l *list[T]) SetSelected(id string) tea.Cmd {
 	return l.render()
 }
 
-func (l *list[T]) reset() tea.Cmd {
+func (l *list[T]) reset(selectedItem string) tea.Cmd {
 	var cmds []tea.Cmd
 	l.rendered = ""
 	l.offset = 0
-	l.selectedItem = ""
+	l.selectedItem = selectedItem
 	l.indexMap = make(map[string]int)
 	l.renderedItems = make(map[string]renderedItem)
 	for inx, item := range l.items {
@@ -851,7 +887,8 @@ func (l *list[T]) SetSize(width int, height int) tea.Cmd {
 	l.width = width
 	l.height = height
 	if oldWidth != width {
-		return l.reset()
+		cmd := l.reset(l.selectedItem)
+		return cmd
 	}
 	return nil
 }