chore: implement new filterable list

Kujtim Hoxha created

- use this list in the sessions selector

Change summary

cspell.json                                                                                                    | 108 
internal/tui/components/core/list/list.go                                                                      |   1 
internal/tui/components/dialogs/sessions/sessions.go                                                           |  63 
internal/tui/exp/list/filterable.go                                                                            | 297 
internal/tui/exp/list/filterable_test.go                                                                       |  67 
internal/tui/exp/list/items.go                                                                                 | 308 
internal/tui/exp/list/keys.go                                                                                  |  63 
internal/tui/exp/list/list.go                                                                                  | 315 
internal/tui/exp/list/list_test.go                                                                             | 241 
internal/tui/exp/list/testdata/TestFilterableList/should_create_simple_filterable_list.golden                  |   6 
internal/tui/exp/list/testdata/TestForwardList/should_move_the_view_to_be_able_to_see_the_selected_item.golden |  10 
internal/tui/exp/list/testdata/TestListSelection/should_select_the_correct_item_on_startup.golden              |   5 
internal/tui/exp/list/testdata/TestListSetSelection/should_move_to_the_selected_item.golden                    |  10 
13 files changed, 1,158 insertions(+), 336 deletions(-)

Detailed changes

cspell.json πŸ”—

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

internal/tui/components/core/list/list.go πŸ”—

@@ -248,7 +248,6 @@ func New(opts ...listOptions) ListModel {
 	}
 
 	if m.filterable && !m.hideFilterInput {
-		t := styles.CurrentTheme()
 		ti := textinput.New()
 		ti.Placeholder = m.filterPlaceholder
 		ti.SetVirtualCursor(false)

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

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

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

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

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

@@ -0,0 +1,67 @@
+package list
+
+import (
+	"fmt"
+	"slices"
+	"testing"
+
+	"github.com/charmbracelet/x/exp/golden"
+	"github.com/stretchr/testify/assert"
+)
+
+func TestFilterableList(t *testing.T) {
+	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
+}

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

@@ -0,0 +1,308 @@
+package list
+
+import (
+	"image/color"
+
+	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/charmbracelet/x/ansi"
+	"github.com/google/uuid"
+	"github.com/rivo/uniseg"
+)
+
+type CompletionItem[T any] interface {
+	FilterableItem
+	layout.Focusable
+	layout.Sizeable
+	HasMatchIndexes
+	Value() T
+}
+
+type completionItemCmp[T any] struct {
+	width        int
+	id           string
+	text         string
+	value        T
+	focus        bool
+	matchIndexes []int
+	bgColor      color.Color
+	shortcut     string
+}
+
+type options struct {
+	id           string
+	text         string
+	bgColor      color.Color
+	matchIndexes []int
+	shortcut     string
+}
+
+type completionOption func(*options)
+
+func WithBackgroundColor(c color.Color) completionOption {
+	return func(cmp *options) {
+		cmp.bgColor = c
+	}
+}
+
+func WithMatchIndexes(indexes ...int) completionOption {
+	return func(cmp *options) {
+		cmp.matchIndexes = indexes
+	}
+}
+
+func WithShortcut(shortcut string) completionOption {
+	return func(cmp *options) {
+		cmp.shortcut = shortcut
+	}
+}
+
+func WithID(id string) completionOption {
+	return func(cmp *options) {
+		cmp.id = id
+	}
+}
+
+func NewCompletionItem[T any](text string, value T, opts ...completionOption) CompletionItem[T] {
+	c := &completionItemCmp[T]{
+		text:  text,
+		value: value,
+	}
+	o := &options{}
+
+	for _, opt := range opts {
+		opt(o)
+	}
+	if o.id == "" {
+		o.id = uuid.NewString()
+	}
+	c.id = o.id
+	c.bgColor = o.bgColor
+	c.matchIndexes = o.matchIndexes
+	c.shortcut = o.shortcut
+	return c
+}
+
+// Init implements CommandItem.
+func (c *completionItemCmp[T]) Init() tea.Cmd {
+	return nil
+}
+
+// Update implements CommandItem.
+func (c *completionItemCmp[T]) Update(tea.Msg) (tea.Model, tea.Cmd) {
+	return c, nil
+}
+
+// View implements CommandItem.
+func (c *completionItemCmp[T]) View() string {
+	t := styles.CurrentTheme()
+
+	itemStyle := t.S().Base.Padding(0, 1).Width(c.width)
+	innerWidth := c.width - 2 // Account for padding
+
+	if c.shortcut != "" {
+		innerWidth -= lipgloss.Width(c.shortcut)
+	}
+
+	titleStyle := t.S().Text.Width(innerWidth)
+	titleMatchStyle := t.S().Text.Underline(true)
+	if c.bgColor != nil {
+		titleStyle = titleStyle.Background(c.bgColor)
+		titleMatchStyle = titleMatchStyle.Background(c.bgColor)
+		itemStyle = itemStyle.Background(c.bgColor)
+	}
+
+	if c.focus {
+		titleStyle = t.S().TextSelected.Width(innerWidth)
+		titleMatchStyle = t.S().TextSelected.Underline(true)
+		itemStyle = itemStyle.Background(t.Primary)
+	}
+
+	var truncatedTitle string
+
+	if len(c.matchIndexes) > 0 && len(c.text) > innerWidth {
+		// Smart truncation: ensure the last matching part is visible
+		truncatedTitle = c.smartTruncate(c.text, innerWidth, c.matchIndexes)
+	} else {
+		// No matches, use regular truncation
+		truncatedTitle = ansi.Truncate(c.text, innerWidth, "…")
+	}
+
+	text := titleStyle.Render(truncatedTitle)
+	if len(c.matchIndexes) > 0 {
+		var ranges []lipgloss.Range
+		for _, rng := range matchedRanges(c.matchIndexes) {
+			// ansi.Cut is grapheme and ansi sequence aware, we match against a ansi.Stripped string, but we might still have graphemes.
+			// all that to say that rng is byte positions, but we need to pass it down to ansi.Cut as char positions.
+			// so we need to adjust it here:
+			start, stop := bytePosToVisibleCharPos(truncatedTitle, rng)
+			ranges = append(ranges, lipgloss.NewRange(start, stop+1, titleMatchStyle))
+		}
+		text = lipgloss.StyleRanges(text, ranges...)
+	}
+	parts := []string{text}
+	if c.shortcut != "" {
+		// Add the shortcut at the end
+		shortcutStyle := t.S().Muted
+		if c.focus {
+			shortcutStyle = t.S().TextSelected
+		}
+		parts = append(parts, shortcutStyle.Render(c.shortcut))
+	}
+	item := itemStyle.Render(
+		lipgloss.JoinHorizontal(
+			lipgloss.Left,
+			parts...,
+		),
+	)
+	return item
+}
+
+// Blur implements CommandItem.
+func (c *completionItemCmp[T]) Blur() tea.Cmd {
+	c.focus = false
+	return nil
+}
+
+// Focus implements CommandItem.
+func (c *completionItemCmp[T]) Focus() tea.Cmd {
+	c.focus = true
+	return nil
+}
+
+// GetSize implements CommandItem.
+func (c *completionItemCmp[T]) GetSize() (int, int) {
+	return c.width, 1
+}
+
+// IsFocused implements CommandItem.
+func (c *completionItemCmp[T]) IsFocused() bool {
+	return c.focus
+}
+
+// SetSize implements CommandItem.
+func (c *completionItemCmp[T]) SetSize(width int, height int) tea.Cmd {
+	c.width = width
+	return nil
+}
+
+func (c *completionItemCmp[T]) MatchIndexes(indexes []int) {
+	c.matchIndexes = indexes
+}
+
+func (c *completionItemCmp[T]) FilterValue() string {
+	return c.text
+}
+
+func (c *completionItemCmp[T]) Value() T {
+	return c.value
+}
+
+// smartTruncate implements fzf-style truncation that ensures the last matching part is visible
+func (c *completionItemCmp[T]) smartTruncate(text string, width int, matchIndexes []int) string {
+	if width <= 0 {
+		return ""
+	}
+
+	textLen := ansi.StringWidth(text)
+	if textLen <= width {
+		return text
+	}
+
+	if len(matchIndexes) == 0 {
+		return ansi.Truncate(text, width, "…")
+	}
+
+	// Find the last match position
+	lastMatchPos := matchIndexes[len(matchIndexes)-1]
+
+	// Convert byte position to visual width position
+	lastMatchVisualPos := 0
+	bytePos := 0
+	gr := uniseg.NewGraphemes(text)
+	for bytePos < lastMatchPos && gr.Next() {
+		bytePos += len(gr.Str())
+		lastMatchVisualPos += max(1, gr.Width())
+	}
+
+	// Calculate how much space we need for the ellipsis
+	ellipsisWidth := 1 // "…" character width
+	availableWidth := width - ellipsisWidth
+
+	// If the last match is within the available width, truncate from the end
+	if lastMatchVisualPos < availableWidth {
+		return ansi.Truncate(text, width, "…")
+	}
+
+	// Calculate the start position to ensure the last match is visible
+	// We want to show some context before the last match if possible
+	startVisualPos := max(0, lastMatchVisualPos-availableWidth+1)
+
+	// Convert visual position back to byte position
+	startBytePos := 0
+	currentVisualPos := 0
+	gr = uniseg.NewGraphemes(text)
+	for currentVisualPos < startVisualPos && gr.Next() {
+		startBytePos += len(gr.Str())
+		currentVisualPos += max(1, gr.Width())
+	}
+
+	// Extract the substring starting from startBytePos
+	truncatedText := text[startBytePos:]
+
+	// Truncate to fit width with ellipsis
+	truncatedText = ansi.Truncate(truncatedText, availableWidth, "")
+	truncatedText = "…" + truncatedText
+	return truncatedText
+}
+
+func matchedRanges(in []int) [][2]int {
+	if len(in) == 0 {
+		return [][2]int{}
+	}
+	current := [2]int{in[0], in[0]}
+	if len(in) == 1 {
+		return [][2]int{current}
+	}
+	var out [][2]int
+	for i := 1; i < len(in); i++ {
+		if in[i] == current[1]+1 {
+			current[1] = in[i]
+		} else {
+			out = append(out, current)
+			current = [2]int{in[i], in[i]}
+		}
+	}
+	out = append(out, current)
+	return out
+}
+
+func bytePosToVisibleCharPos(str string, rng [2]int) (int, int) {
+	bytePos, byteStart, byteStop := 0, rng[0], rng[1]
+	pos, start, stop := 0, 0, 0
+	gr := uniseg.NewGraphemes(str)
+	for byteStart > bytePos {
+		if !gr.Next() {
+			break
+		}
+		bytePos += len(gr.Str())
+		pos += max(1, gr.Width())
+	}
+	start = pos
+	for byteStop > bytePos {
+		if !gr.Next() {
+			break
+		}
+		bytePos += len(gr.Str())
+		pos += max(1, gr.Width())
+	}
+	stop = pos
+	return start, stop
+}
+
+// ID implements CompletionItem.
+func (c *completionItemCmp[T]) ID() string {
+	return c.id
+}

internal/tui/exp/list/keys.go πŸ”—

@@ -0,0 +1,63 @@
+package list
+
+import (
+	"github.com/charmbracelet/bubbles/v2/key"
+)
+
+type KeyMap struct {
+	Down,
+	Up,
+	DownOneItem,
+	UpOneItem,
+	PageDown,
+	PageUp,
+	HalfPageDown,
+	HalfPageUp,
+	Home,
+	End key.Binding
+}
+
+func DefaultKeyMap() KeyMap {
+	return KeyMap{
+		Down: key.NewBinding(
+			key.WithKeys("down", "ctrl+j", "ctrl+n", "j"),
+			key.WithHelp("↓", "down"),
+		),
+		Up: key.NewBinding(
+			key.WithKeys("up", "ctrl+k", "ctrl+p", "k"),
+			key.WithHelp("↑", "up"),
+		),
+		UpOneItem: key.NewBinding(
+			key.WithKeys("shift+up", "K"),
+			key.WithHelp("shift+↑", "up one item"),
+		),
+		DownOneItem: key.NewBinding(
+			key.WithKeys("shift+down", "J"),
+			key.WithHelp("shift+↓", "down one item"),
+		),
+		HalfPageDown: key.NewBinding(
+			key.WithKeys("d"),
+			key.WithHelp("d", "half page down"),
+		),
+		PageDown: key.NewBinding(
+			key.WithKeys("pgdown", " ", "f"),
+			key.WithHelp("f/pgdn", "page down"),
+		),
+		PageUp: key.NewBinding(
+			key.WithKeys("pgup", "b"),
+			key.WithHelp("b/pgup", "page up"),
+		),
+		HalfPageUp: key.NewBinding(
+			key.WithKeys("u"),
+			key.WithHelp("u", "half page up"),
+		),
+		Home: key.NewBinding(
+			key.WithKeys("g", "home"),
+			key.WithHelp("g", "home"),
+		),
+		End: key.NewBinding(
+			key.WithKeys("G", "end"),
+			key.WithHelp("G", "end"),
+		),
+	}
+}

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

@@ -1,9 +1,9 @@
 package list
 
 import (
-	"fmt"
 	"strings"
 
+	"github.com/charmbracelet/bubbles/v2/key"
 	tea "github.com/charmbracelet/bubbletea/v2"
 	"github.com/charmbracelet/crush/internal/tui/components/core/layout"
 	"github.com/charmbracelet/crush/internal/tui/util"
@@ -16,11 +16,19 @@ type Item interface {
 	ID() string
 }
 
-type List interface {
+type List[T Item] interface {
 	util.Model
 	layout.Sizeable
 	layout.Focusable
-	SetItems(items []Item) tea.Cmd
+	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
 }
 
 type direction int
@@ -31,41 +39,45 @@ const (
 )
 
 const (
-	NotFound = -1
+	NotFound          = -1
+	DefaultScrollSize = 2
 )
 
+type setSelectedMsg struct {
+	selectedItemID string
+}
+
 type renderedItem struct {
 	id     string
 	view   string
 	height int
 }
 
-type list struct {
+type confOptions struct {
 	width, height int
-	offset        int
 	gap           int
-	direction     direction
-	selectedItem  string
-	focused       bool
+	// if you are at the last item and go down it will wrap to the top
+	wrap         bool
+	keyMap       KeyMap
+	direction    direction
+	selectedItem string
+}
+type list[T Item] struct {
+	confOptions
 
-	items         []Item
+	focused       bool
+	offset        int
+	items         []T
 	renderedItems []renderedItem
 	rendered      string
 	isReady       bool
 }
 
-type listOption func(*list)
-
-// WithItems sets the initial items for the list.
-func WithItems(items ...Item) listOption {
-	return func(l *list) {
-		l.items = items
-	}
-}
+type listOption func(*confOptions)
 
 // WithSize sets the size of the list.
 func WithSize(width, height int) listOption {
-	return func(l *list) {
+	return func(l *confOptions) {
 		l.width = width
 		l.height = height
 	}
@@ -73,44 +85,53 @@ func WithSize(width, height int) listOption {
 
 // WithGap sets the gap between items in the list.
 func WithGap(gap int) listOption {
-	return func(l *list) {
+	return func(l *confOptions) {
 		l.gap = gap
 	}
 }
 
 // WithDirection sets the direction of the list.
 func WithDirection(dir direction) listOption {
-	return func(l *list) {
+	return func(l *confOptions) {
 		l.direction = dir
 	}
 }
 
 // WithSelectedItem sets the initially selected item in the list.
 func WithSelectedItem(id string) listOption {
-	return func(l *list) {
+	return func(l *confOptions) {
 		l.selectedItem = id
 	}
 }
 
-func New(opts ...listOption) List {
-	list := &list{
-		items:     make([]Item, 0),
-		direction: Forward,
+func WithKeyMap(keyMap KeyMap) listOption {
+	return func(l *confOptions) {
+		l.keyMap = keyMap
+	}
+}
+
+func WithWrapNavigation() listOption {
+	return func(l *confOptions) {
+		l.wrap = true
+	}
+}
+
+func New[T Item](items []T, opts ...listOption) List[T] {
+	list := &list[T]{
+		confOptions: confOptions{
+			direction: Forward,
+			keyMap:    DefaultKeyMap(),
+		},
+		items: items,
 	}
 	for _, opt := range opts {
-		opt(list)
+		opt(&list.confOptions)
 	}
 	return list
 }
 
 // Init implements List.
-func (l *list) Init() tea.Cmd {
-	if l.height <= 0 || l.width <= 0 {
-		return nil
-	}
-	if len(l.items) == 0 {
-		return nil
-	}
+func (l *list[T]) Init() tea.Cmd {
 	var cmds []tea.Cmd
 	for _, item := range l.items {
 		cmd := item.Init()
@@ -121,12 +142,41 @@ func (l *list) Init() tea.Cmd {
 }
 
 // Update implements List.
-func (l *list) Update(tea.Msg) (tea.Model, tea.Cmd) {
+func (l *list[T]) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+	switch msg := msg.(type) {
+	case setSelectedMsg:
+		return l, l.SetSelected(msg.selectedItemID)
+	case tea.KeyPressMsg:
+		if l.focused {
+			switch {
+			case key.Matches(msg, l.keyMap.Down):
+				return l, l.MoveDown(DefaultScrollSize)
+			case key.Matches(msg, l.keyMap.Up):
+				return l, l.MoveUp(DefaultScrollSize)
+			case key.Matches(msg, l.keyMap.DownOneItem):
+				return l, l.SelectItemBelow()
+			case key.Matches(msg, l.keyMap.UpOneItem):
+				return l, l.SelectItemAbove()
+			case key.Matches(msg, l.keyMap.HalfPageDown):
+				return l, l.MoveDown(l.listHeight() / 2)
+			case key.Matches(msg, l.keyMap.HalfPageUp):
+				return l, l.MoveUp(l.listHeight() / 2)
+			case key.Matches(msg, l.keyMap.PageDown):
+				return l, l.MoveDown(l.listHeight())
+			case key.Matches(msg, l.keyMap.PageUp):
+				return l, l.MoveUp(l.listHeight())
+			case key.Matches(msg, l.keyMap.End):
+				return l, l.GoToBottom()
+			case key.Matches(msg, l.keyMap.Home):
+				return l, l.GoToTop()
+			}
+		}
+	}
 	return l, nil
 }
 
 // View implements List.
-func (l *list) View() string {
+func (l *list[T]) View() string {
 	if l.height <= 0 || l.width <= 0 {
 		return ""
 	}
@@ -138,7 +188,7 @@ func (l *list) View() string {
 	return strings.Join(lines, "\n")
 }
 
-func (l *list) viewPosition() (int, int) {
+func (l *list[T]) viewPosition() (int, int) {
 	start, end := 0, 0
 	renderedLines := lipgloss.Height(l.rendered) - 1
 	if l.direction == Forward {
@@ -151,7 +201,7 @@ func (l *list) viewPosition() (int, int) {
 	return start, end
 }
 
-func (l *list) renderItem(item Item) renderedItem {
+func (l *list[T]) renderItem(item Item) renderedItem {
 	view := item.View()
 	return renderedItem{
 		id:     item.ID(),
@@ -160,7 +210,7 @@ func (l *list) renderItem(item Item) renderedItem {
 	}
 }
 
-func (l *list) renderView() {
+func (l *list[T]) renderView() {
 	var sb strings.Builder
 	for i, rendered := range l.renderedItems {
 		sb.WriteString(rendered.view)
@@ -171,7 +221,7 @@ func (l *list) renderView() {
 	l.rendered = sb.String()
 }
 
-func (l *list) incrementOffset(n int) {
+func (l *list[T]) incrementOffset(n int) {
 	if !l.isReady {
 		return
 	}
@@ -188,7 +238,7 @@ func (l *list) incrementOffset(n int) {
 	l.offset += n
 }
 
-func (l *list) decrementOffset(n int) {
+func (l *list[T]) decrementOffset(n int) {
 	if !l.isReady {
 		return
 	}
@@ -203,7 +253,7 @@ func (l *list) decrementOffset(n int) {
 }
 
 // changeSelectedWhenNotVisible is called so we make sure we move to the next available selected that is visible
-func (l *list) changeSelectedWhenNotVisible() tea.Cmd {
+func (l *list[T]) changeSelectedWhenNotVisible() tea.Cmd {
 	var cmds []tea.Cmd
 	start, end := l.viewPosition()
 	currentPosition := 0
@@ -228,7 +278,7 @@ func (l *list) changeSelectedWhenNotVisible() tea.Cmd {
 				needsMove = true
 			}
 			if needsMove {
-				if focusable, ok := item.(layout.Focusable); ok {
+				if focusable, ok := any(item).(layout.Focusable); ok {
 					cmds = append(cmds, focusable.Blur())
 				}
 				l.renderedItems[i] = l.renderItem(item)
@@ -239,7 +289,7 @@ func (l *list) changeSelectedWhenNotVisible() tea.Cmd {
 		if itemWithinView != NotFound && needsMove {
 			newSelection := l.items[itemWithinView]
 			l.selectedItem = newSelection.ID()
-			if focusable, ok := newSelection.(layout.Focusable); ok {
+			if focusable, ok := any(newSelection).(layout.Focusable); ok {
 				cmds = append(cmds, focusable.Focus())
 			}
 			l.renderedItems[itemWithinView] = l.renderItem(newSelection)
@@ -251,7 +301,7 @@ func (l *list) changeSelectedWhenNotVisible() tea.Cmd {
 	return tea.Batch(cmds...)
 }
 
-func (l *list) MoveUp(n int) tea.Cmd {
+func (l *list[T]) MoveUp(n int) tea.Cmd {
 	if l.direction == Forward {
 		l.decrementOffset(n)
 	} else {
@@ -260,7 +310,7 @@ func (l *list) MoveUp(n int) tea.Cmd {
 	return l.changeSelectedWhenNotVisible()
 }
 
-func (l *list) MoveDown(n int) tea.Cmd {
+func (l *list[T]) MoveDown(n int) tea.Cmd {
 	if l.direction == Forward {
 		l.incrementOffset(n)
 	} else {
@@ -269,49 +319,80 @@ func (l *list) MoveDown(n int) tea.Cmd {
 	return l.changeSelectedWhenNotVisible()
 }
 
-func (l *list) firstSelectableItemBefore(inx int) int {
+func (l *list[T]) firstSelectableItemBefore(inx int) int {
 	for i := inx - 1; i >= 0; i-- {
-		if _, ok := l.items[i].(layout.Focusable); ok {
+		if _, ok := any(l.items[i]).(layout.Focusable); ok {
 			return i
 		}
 	}
+	if inx == 0 && l.wrap {
+		return l.firstSelectableItemBefore(len(l.items))
+	}
 	return NotFound
 }
 
-func (l *list) firstSelectableItemAfter(inx int) int {
+func (l *list[T]) firstSelectableItemAfter(inx int) int {
 	for i := inx + 1; i < len(l.items); i++ {
-		if _, ok := l.items[i].(layout.Focusable); ok {
+		if _, ok := any(l.items[i]).(layout.Focusable); ok {
 			return i
 		}
 	}
+	if inx == len(l.items)-1 && l.wrap {
+		return l.firstSelectableItemAfter(-1)
+	}
 	return NotFound
 }
 
-func (l *list) moveToSelected() {
+func (l *list[T]) moveToSelected(center bool) tea.Cmd {
+	var cmds []tea.Cmd
 	if l.selectedItem == "" || !l.isReady {
-		return
+		return nil
 	}
 	currentPosition := 0
 	start, end := l.viewPosition()
 	for _, item := range l.renderedItems {
 		if item.id == l.selectedItem {
-			if start <= currentPosition && (currentPosition+item.height) <= end {
-				return
-			}
-			// we need to go up
-			if currentPosition < start {
-				l.MoveUp(start - currentPosition)
+			itemStart := currentPosition
+			itemEnd := currentPosition + item.height - 1
+
+			if start <= itemStart && itemEnd <= end {
+				return nil
 			}
-			// we need to go down
-			if currentPosition > end {
-				l.MoveDown(currentPosition - end)
+
+			if center {
+				viewportCenter := l.listHeight() / 2
+				itemCenter := itemStart + item.height/2
+				targetOffset := itemCenter - viewportCenter
+				if l.direction == Forward {
+					if targetOffset > l.offset {
+						cmds = append(cmds, l.MoveDown(targetOffset-l.offset))
+					} else if targetOffset < l.offset {
+						cmds = append(cmds, l.MoveUp(l.offset-targetOffset))
+					}
+				} else {
+					renderedHeight := lipgloss.Height(l.rendered)
+					backwardTargetOffset := renderedHeight - targetOffset - l.listHeight()
+					if backwardTargetOffset > l.offset {
+						cmds = append(cmds, l.MoveUp(backwardTargetOffset-l.offset))
+					} else if backwardTargetOffset < l.offset {
+						cmds = append(cmds, l.MoveDown(l.offset-backwardTargetOffset))
+					}
+				}
+			} else {
+				if currentPosition < start {
+					cmds = append(cmds, l.MoveUp(start-currentPosition))
+				}
+				if currentPosition > end {
+					cmds = append(cmds, l.MoveDown(currentPosition-end))
+				}
 			}
 		}
 		currentPosition += item.height + l.gap
 	}
+	return tea.Batch(cmds...)
 }
 
-func (l *list) SelectItemAbove() tea.Cmd {
+func (l *list[T]) SelectItemAbove() tea.Cmd {
 	if !l.isReady {
 		return nil
 	}
@@ -324,14 +405,14 @@ func (l *list) SelectItemAbove() tea.Cmd {
 				return nil
 			}
 			// blur the current item
-			if focusable, ok := item.(layout.Focusable); ok {
+			if focusable, ok := any(item).(layout.Focusable); ok {
 				cmds = append(cmds, focusable.Blur())
 			}
 			// rerender the item
 			l.renderedItems[i] = l.renderItem(item)
 			// focus the item above
 			above := l.items[inx]
-			if focusable, ok := above.(layout.Focusable); ok {
+			if focusable, ok := any(above).(layout.Focusable); ok {
 				cmds = append(cmds, focusable.Focus())
 			}
 			// rerender the item
@@ -340,12 +421,12 @@ func (l *list) SelectItemAbove() tea.Cmd {
 			break
 		}
 	}
+	l.moveToSelected(false)
 	l.renderView()
-	l.moveToSelected()
 	return tea.Batch(cmds...)
 }
 
-func (l *list) SelectItemBelow() tea.Cmd {
+func (l *list[T]) SelectItemBelow() tea.Cmd {
 	if !l.isReady {
 		return nil
 	}
@@ -358,7 +439,7 @@ func (l *list) SelectItemBelow() tea.Cmd {
 				return nil
 			}
 			// blur the current item
-			if focusable, ok := item.(layout.Focusable); ok {
+			if focusable, ok := any(item).(layout.Focusable); ok {
 				cmds = append(cmds, focusable.Blur())
 			}
 			// rerender the item
@@ -366,7 +447,7 @@ func (l *list) SelectItemBelow() tea.Cmd {
 
 			// focus the item below
 			below := l.items[inx]
-			if focusable, ok := below.(layout.Focusable); ok {
+			if focusable, ok := any(below).(layout.Focusable); ok {
 				cmds = append(cmds, focusable.Focus())
 			}
 			// rerender the item
@@ -376,12 +457,12 @@ func (l *list) SelectItemBelow() tea.Cmd {
 		}
 	}
 
+	l.moveToSelected(false)
 	l.renderView()
-	l.moveToSelected()
 	return tea.Batch(cmds...)
 }
 
-func (l *list) GoToTop() tea.Cmd {
+func (l *list[T]) GoToTop() tea.Cmd {
 	if !l.isReady {
 		return nil
 	}
@@ -390,7 +471,7 @@ func (l *list) GoToTop() tea.Cmd {
 	return tea.Batch(l.selectFirstItem(), l.renderForward())
 }
 
-func (l *list) GoToBottom() tea.Cmd {
+func (l *list[T]) GoToBottom() tea.Cmd {
 	if !l.isReady {
 		return nil
 	}
@@ -400,7 +481,7 @@ func (l *list) GoToBottom() tea.Cmd {
 	return tea.Batch(l.selectLastItem(), l.renderBackward())
 }
 
-func (l *list) renderForward() tea.Cmd {
+func (l *list[T]) renderForward() tea.Cmd {
 	// TODO: figure out a way to preserve items that did not change
 	l.renderedItems = make([]renderedItem, 0)
 	currentHeight := 0
@@ -434,13 +515,12 @@ func (l *list) renderForward() tea.Cmd {
 	}
 }
 
-func (l *list) renderBackward() tea.Cmd {
+func (l *list[T]) renderBackward() tea.Cmd {
 	// TODO: figure out a way to preserve items that did not change
 	l.renderedItems = make([]renderedItem, 0)
 	currentHeight := 0
 	currentIndex := 0
 	for i := len(l.items) - 1; i >= 0; i-- {
-		fmt.Printf("rendering item %d\n", i)
 		currentIndex = i
 		if currentHeight > l.listHeight() {
 			break
@@ -457,7 +537,6 @@ func (l *list) renderBackward() tea.Cmd {
 	}
 	return func() tea.Msg {
 		for i := currentIndex; i >= 0; i-- {
-			fmt.Printf("rendering item after %d\n", i)
 			rendered := l.renderItem(l.items[i])
 			l.renderedItems = append([]renderedItem{rendered}, l.renderedItems...)
 		}
@@ -467,31 +546,31 @@ func (l *list) renderBackward() tea.Cmd {
 	}
 }
 
-func (l *list) selectFirstItem() tea.Cmd {
+func (l *list[T]) selectFirstItem() tea.Cmd {
 	var cmd tea.Cmd
 	inx := l.firstSelectableItemAfter(-1)
 	if inx != NotFound {
 		l.selectedItem = l.items[inx].ID()
-		if focusable, ok := l.items[inx].(layout.Focusable); ok {
+		if focusable, ok := any(l.items[inx]).(layout.Focusable); ok {
 			cmd = focusable.Focus()
 		}
 	}
 	return cmd
 }
 
-func (l *list) selectLastItem() tea.Cmd {
+func (l *list[T]) selectLastItem() tea.Cmd {
 	var cmd tea.Cmd
 	inx := l.firstSelectableItemBefore(len(l.items))
 	if inx != NotFound {
 		l.selectedItem = l.items[inx].ID()
-		if focusable, ok := l.items[inx].(layout.Focusable); ok {
+		if focusable, ok := any(l.items[inx]).(layout.Focusable); ok {
 			cmd = focusable.Focus()
 		}
 	}
 	return cmd
 }
 
-func (l *list) renderItems() tea.Cmd {
+func (l *list[T]) renderItems() tea.Cmd {
 	if l.height <= 0 || l.width <= 0 {
 		return nil
 	}
@@ -512,12 +591,12 @@ func (l *list) renderItems() tea.Cmd {
 	return l.renderBackward()
 }
 
-func (l *list) listHeight() int {
+func (l *list[T]) listHeight() int {
 	// for the moment its the same
 	return l.height
 }
 
-func (l *list) SetItems(items []Item) tea.Cmd {
+func (l *list[T]) SetItems(items []T) tea.Cmd {
 	l.items = items
 	var cmds []tea.Cmd
 	for _, item := range l.items {
@@ -525,36 +604,41 @@ func (l *list) SetItems(items []Item) tea.Cmd {
 		// Set height to 0 to let the item calculate its own height
 		cmds = append(cmds, item.SetSize(l.width, 0))
 	}
+
+	if l.selectedItem != "" {
+		cmds = append(cmds, l.moveToSelected(true))
+	}
 	cmds = append(cmds, l.renderItems())
 	return tea.Batch(cmds...)
 }
 
 // GetSize implements List.
-func (l *list) GetSize() (int, int) {
+func (l *list[T]) GetSize() (int, int) {
 	return l.width, l.height
 }
 
 // SetSize implements List.
-func (l *list) SetSize(width int, height int) tea.Cmd {
+func (l *list[T]) SetSize(width int, height int) tea.Cmd {
 	l.width = width
 	l.height = height
 	var cmds []tea.Cmd
 	for _, item := range l.items {
 		cmds = append(cmds, item.SetSize(width, height))
 	}
+
 	cmds = append(cmds, l.renderItems())
 	return tea.Batch(cmds...)
 }
 
 // Blur implements List.
-func (l *list) Blur() tea.Cmd {
+func (l *list[T]) Blur() tea.Cmd {
 	var cmd tea.Cmd
 	l.focused = false
 	for i, item := range l.items {
 		if item.ID() != l.selectedItem {
 			continue
 		}
-		if focusable, ok := item.(layout.Focusable); ok {
+		if focusable, ok := any(item).(layout.Focusable); ok {
 			cmd = focusable.Blur()
 		}
 		l.renderedItems[i] = l.renderItem(item)
@@ -564,23 +648,64 @@ func (l *list) Blur() tea.Cmd {
 }
 
 // Focus implements List.
-func (l *list) Focus() tea.Cmd {
+func (l *list[T]) Focus() tea.Cmd {
 	var cmd tea.Cmd
 	l.focused = true
-	for i, item := range l.items {
-		if item.ID() != l.selectedItem {
-			continue
+	if l.selectedItem != "" {
+		for i, item := range l.items {
+			if item.ID() != l.selectedItem {
+				continue
+			}
+			if focusable, ok := any(item).(layout.Focusable); ok {
+				cmd = focusable.Focus()
+			}
+			if len(l.renderedItems) > i {
+				l.renderedItems[i] = l.renderItem(item)
+			}
 		}
-		if focusable, ok := item.(layout.Focusable); ok {
-			cmd = focusable.Focus()
+		l.renderView()
+	}
+	return cmd
+}
+
+func (l *list[T]) SetSelected(id string) tea.Cmd {
+	if l.selectedItem == id {
+		return nil
+	}
+	var cmds []tea.Cmd
+	for i, item := range l.items {
+		if item.ID() == l.selectedItem {
+			if focusable, ok := any(item).(layout.Focusable); ok {
+				cmds = append(cmds, focusable.Blur())
+			}
+			if len(l.renderedItems) > i {
+				l.renderedItems[i] = l.renderItem(item)
+			}
+		} else if item.ID() == id {
+			if focusable, ok := any(item).(layout.Focusable); ok {
+				cmds = append(cmds, focusable.Focus())
+			}
+			if len(l.renderedItems) > i {
+				l.renderedItems[i] = l.renderItem(item)
+			}
 		}
-		l.renderedItems[i] = l.renderItem(item)
 	}
+	l.selectedItem = id
+	cmds = append(cmds, l.moveToSelected(true))
 	l.renderView()
-	return cmd
+	return tea.Batch(cmds...)
+}
+
+func (l *list[T]) SelectedItem() *T {
+	for _, item := range l.items {
+		if item.ID() == l.selectedItem {
+			return &item
+		}
+	}
+	return nil
 }
 
 // IsFocused implements List.
-func (l *list) IsFocused() bool {
+func (l *list[T]) IsFocused() bool {
 	return l.focused
 }

internal/tui/exp/list/list_test.go πŸ”—

@@ -2,6 +2,7 @@ package list
 
 import (
 	"fmt"
+	"sync"
 	"testing"
 
 	tea "github.com/charmbracelet/bubbletea/v2"
@@ -74,14 +75,14 @@ func TestListPosition(t *testing.T) {
 	}
 	for _, c := range tests {
 		t.Run(c.test, func(t *testing.T) {
-			l := New(WithDirection(c.dir)).(*list)
-			l.SetSize(c.width, c.height)
 			items := []Item{}
 			for i := range c.numItems {
-				item := NewSelectsableItem(fmt.Sprintf("Item %d", i))
+				item := NewSelectableItem(fmt.Sprintf("Item %d", i))
 				items = append(items, item)
 			}
-			cmd := l.SetItems(items)
+			l := New(items, WithDirection(c.dir)).(*list[Item])
+			l.SetSize(c.width, c.height)
+			cmd := l.Init()
 			if cmd != nil {
 				cmd()
 			}
@@ -102,33 +103,32 @@ func TestListPosition(t *testing.T) {
 func TestBackwardList(t *testing.T) {
 	t.Run("within height", func(t *testing.T) {
 		t.Parallel()
-		l := New(WithDirection(Backward), WithGap(1)).(*list)
-		l.SetSize(10, 20)
 		items := []Item{}
 		for i := range 5 {
-			item := NewSelectsableItem(fmt.Sprintf("Item %d", i))
+			item := NewSelectableItem(fmt.Sprintf("Item %d", i))
 			items = append(items, item)
 		}
-		cmd := l.SetItems(items)
+		l := New(items, WithDirection(Backward), WithGap(1)).(*list[Item])
+		l.SetSize(10, 20)
+		cmd := l.Init()
 		if cmd != nil {
 			cmd()
 		}
 
 		// should select the last item
 		assert.Equal(t, l.selectedItem, items[len(items)-1].ID())
-
 		golden.RequireEqual(t, []byte(l.View()))
 	})
 	t.Run("should not change selected item", func(t *testing.T) {
 		t.Parallel()
 		items := []Item{}
 		for i := range 5 {
-			item := NewSelectsableItem(fmt.Sprintf("Item %d", i))
+			item := NewSelectableItem(fmt.Sprintf("Item %d", i))
 			items = append(items, item)
 		}
-		l := New(WithDirection(Backward), WithGap(1), WithSelectedItem(items[2].ID())).(*list)
+		l := New(items, WithDirection(Backward), WithGap(1), WithSelectedItem(items[2].ID())).(*list[Item])
 		l.SetSize(10, 20)
-		cmd := l.SetItems(items)
+		cmd := l.Init()
 		if cmd != nil {
 			cmd()
 		}
@@ -137,14 +137,14 @@ func TestBackwardList(t *testing.T) {
 	})
 	t.Run("more than height", func(t *testing.T) {
 		t.Parallel()
-		l := New(WithDirection(Backward))
-		l.SetSize(10, 5)
 		items := []Item{}
 		for i := range 10 {
-			item := NewSelectsableItem(fmt.Sprintf("Item %d", i))
+			item := NewSelectableItem(fmt.Sprintf("Item %d", i))
 			items = append(items, item)
 		}
-		cmd := l.SetItems(items)
+		l := New(items, WithDirection(Backward))
+		l.SetSize(10, 5)
+		cmd := l.Init()
 		if cmd != nil {
 			cmd()
 		}
@@ -153,14 +153,14 @@ func TestBackwardList(t *testing.T) {
 	})
 	t.Run("more than height multi line", func(t *testing.T) {
 		t.Parallel()
-		l := New(WithDirection(Backward))
-		l.SetSize(10, 5)
 		items := []Item{}
 		for i := range 10 {
-			item := NewSelectsableItem(fmt.Sprintf("Item %d\nLine2", i))
+			item := NewSelectableItem(fmt.Sprintf("Item %d\nLine2", i))
 			items = append(items, item)
 		}
-		cmd := l.SetItems(items)
+		l := New(items, WithDirection(Backward))
+		l.SetSize(10, 5)
+		cmd := l.Init()
 		if cmd != nil {
 			cmd()
 		}
@@ -169,14 +169,14 @@ func TestBackwardList(t *testing.T) {
 	})
 	t.Run("should move up", func(t *testing.T) {
 		t.Parallel()
-		l := New(WithDirection(Backward)).(*list)
-		l.SetSize(10, 5)
 		items := []Item{}
 		for i := range 10 {
-			item := NewSelectsableItem(fmt.Sprintf("Item %d", i))
+			item := NewSelectableItem(fmt.Sprintf("Item %d", i))
 			items = append(items, item)
 		}
-		cmd := l.SetItems(items)
+		l := New(items, WithDirection(Backward))
+		l.SetSize(10, 5)
+		cmd := l.Init()
 		if cmd != nil {
 			cmd()
 		}
@@ -186,14 +186,14 @@ func TestBackwardList(t *testing.T) {
 	})
 
 	t.Run("should move at max to the top", func(t *testing.T) {
-		l := New(WithDirection(Backward)).(*list)
-		l.SetSize(10, 5)
 		items := []Item{}
 		for i := range 10 {
-			item := NewSelectsableItem(fmt.Sprintf("Item %d", i))
+			item := NewSelectableItem(fmt.Sprintf("Item %d", i))
 			items = append(items, item)
 		}
-		cmd := l.SetItems(items)
+		l := New(items, WithDirection(Backward)).(*list[Item])
+		l.SetSize(10, 5)
+		cmd := l.Init()
 		if cmd != nil {
 			cmd()
 		}
@@ -204,14 +204,14 @@ func TestBackwardList(t *testing.T) {
 	})
 	t.Run("should do nothing with wrong move number", func(t *testing.T) {
 		t.Parallel()
-		l := New(WithDirection(Backward)).(*list)
-		l.SetSize(10, 5)
 		items := []Item{}
 		for i := range 10 {
-			item := NewSelectsableItem(fmt.Sprintf("Item %d", i))
+			item := NewSelectableItem(fmt.Sprintf("Item %d", i))
 			items = append(items, item)
 		}
-		cmd := l.SetItems(items)
+		l := New(items, WithDirection(Backward))
+		l.SetSize(10, 5)
+		cmd := l.Init()
 		if cmd != nil {
 			cmd()
 		}
@@ -221,14 +221,14 @@ func TestBackwardList(t *testing.T) {
 	})
 	t.Run("should move to the top", func(t *testing.T) {
 		t.Parallel()
-		l := New(WithDirection(Backward)).(*list)
-		l.SetSize(10, 5)
 		items := []Item{}
 		for i := range 10 {
-			item := NewSelectsableItem(fmt.Sprintf("Item %d", i))
+			item := NewSelectableItem(fmt.Sprintf("Item %d", i))
 			items = append(items, item)
 		}
-		cmd := l.SetItems(items)
+		l := New(items, WithDirection(Backward)).(*list[Item])
+		l.SetSize(10, 5)
+		cmd := l.Init()
 		if cmd != nil {
 			cmd()
 		}
@@ -239,14 +239,14 @@ func TestBackwardList(t *testing.T) {
 	})
 	t.Run("should select the item above", func(t *testing.T) {
 		t.Parallel()
-		l := New(WithDirection(Backward)).(*list)
-		l.SetSize(10, 5)
 		items := []Item{}
 		for i := range 10 {
-			item := NewSelectsableItem(fmt.Sprintf("Item %d", i))
+			item := NewSelectableItem(fmt.Sprintf("Item %d", i))
 			items = append(items, item)
 		}
-		cmd := l.SetItems(items)
+		l := New(items, WithDirection(Backward)).(*list[Item])
+		l.SetSize(10, 5)
+		cmd := l.Init()
 		if cmd != nil {
 			cmd()
 		}
@@ -268,14 +268,14 @@ func TestBackwardList(t *testing.T) {
 	})
 	t.Run("should move the view to be able to see the selected item", func(t *testing.T) {
 		t.Parallel()
-		l := New(WithDirection(Backward)).(*list)
-		l.SetSize(10, 5)
 		items := []Item{}
 		for i := range 10 {
-			item := NewSelectsableItem(fmt.Sprintf("Item %d", i))
+			item := NewSelectableItem(fmt.Sprintf("Item %d", i))
 			items = append(items, item)
 		}
-		cmd := l.SetItems(items)
+		l := New(items, WithDirection(Backward)).(*list[Item])
+		l.SetSize(10, 5)
+		cmd := l.Init()
 		if cmd != nil {
 			cmd()
 		}
@@ -293,14 +293,14 @@ func TestBackwardList(t *testing.T) {
 func TestForwardList(t *testing.T) {
 	t.Run("within height", func(t *testing.T) {
 		t.Parallel()
-		l := New(WithDirection(Forward), WithGap(1)).(*list)
-		l.SetSize(10, 20)
 		items := []Item{}
 		for i := range 5 {
-			item := NewSelectsableItem(fmt.Sprintf("Item %d", i))
+			item := NewSelectableItem(fmt.Sprintf("Item %d", i))
 			items = append(items, item)
 		}
-		cmd := l.SetItems(items)
+		l := New(items, WithDirection(Forward), WithGap(1)).(*list[Item])
+		l.SetSize(10, 20)
+		cmd := l.Init()
 		if cmd != nil {
 			cmd()
 		}
@@ -314,12 +314,12 @@ func TestForwardList(t *testing.T) {
 		t.Parallel()
 		items := []Item{}
 		for i := range 5 {
-			item := NewSelectsableItem(fmt.Sprintf("Item %d", i))
+			item := NewSelectableItem(fmt.Sprintf("Item %d", i))
 			items = append(items, item)
 		}
-		l := New(WithDirection(Forward), WithGap(1), WithSelectedItem(items[2].ID())).(*list)
+		l := New(items, WithDirection(Forward), WithGap(1), WithSelectedItem(items[2].ID())).(*list[Item])
 		l.SetSize(10, 20)
-		cmd := l.SetItems(items)
+		cmd := l.Init()
 		if cmd != nil {
 			cmd()
 		}
@@ -328,14 +328,14 @@ func TestForwardList(t *testing.T) {
 	})
 	t.Run("more than height", func(t *testing.T) {
 		t.Parallel()
-		l := New(WithDirection(Forward))
-		l.SetSize(10, 5)
 		items := []Item{}
 		for i := range 10 {
-			item := NewSelectsableItem(fmt.Sprintf("Item %d", i))
+			item := NewSelectableItem(fmt.Sprintf("Item %d", i))
 			items = append(items, item)
 		}
-		cmd := l.SetItems(items)
+		l := New(items, WithDirection(Forward)).(*list[Item])
+		l.SetSize(10, 5)
+		cmd := l.Init()
 		if cmd != nil {
 			cmd()
 		}
@@ -344,14 +344,14 @@ func TestForwardList(t *testing.T) {
 	})
 	t.Run("more than height multi line", func(t *testing.T) {
 		t.Parallel()
-		l := New(WithDirection(Forward))
-		l.SetSize(10, 5)
 		items := []Item{}
 		for i := range 10 {
-			item := NewSelectsableItem(fmt.Sprintf("Item %d\nLine2", i))
+			item := NewSelectableItem(fmt.Sprintf("Item %d\nLine2", i))
 			items = append(items, item)
 		}
-		cmd := l.SetItems(items)
+		l := New(items, WithDirection(Forward)).(*list[Item])
+		l.SetSize(10, 5)
+		cmd := l.Init()
 		if cmd != nil {
 			cmd()
 		}
@@ -360,14 +360,14 @@ func TestForwardList(t *testing.T) {
 	})
 	t.Run("should move down", func(t *testing.T) {
 		t.Parallel()
-		l := New(WithDirection(Forward)).(*list)
-		l.SetSize(10, 5)
 		items := []Item{}
 		for i := range 10 {
-			item := NewSelectsableItem(fmt.Sprintf("Item %d", i))
+			item := NewSelectableItem(fmt.Sprintf("Item %d", i))
 			items = append(items, item)
 		}
-		cmd := l.SetItems(items)
+		l := New(items, WithDirection(Forward)).(*list[Item])
+		l.SetSize(10, 5)
+		cmd := l.Init()
 		if cmd != nil {
 			cmd()
 		}
@@ -377,14 +377,14 @@ func TestForwardList(t *testing.T) {
 	})
 	t.Run("should move at max to the bottom", func(t *testing.T) {
 		t.Parallel()
-		l := New(WithDirection(Forward)).(*list)
-		l.SetSize(10, 5)
 		items := []Item{}
 		for i := range 10 {
-			item := NewSelectsableItem(fmt.Sprintf("Item %d", i))
+			item := NewSelectableItem(fmt.Sprintf("Item %d", i))
 			items = append(items, item)
 		}
-		cmd := l.SetItems(items)
+		l := New(items, WithDirection(Forward)).(*list[Item])
+		l.SetSize(10, 5)
+		cmd := l.Init()
 		if cmd != nil {
 			cmd()
 		}
@@ -395,14 +395,14 @@ func TestForwardList(t *testing.T) {
 	})
 	t.Run("should do nothing with wrong move number", func(t *testing.T) {
 		t.Parallel()
-		l := New(WithDirection(Forward)).(*list)
-		l.SetSize(10, 5)
 		items := []Item{}
 		for i := range 10 {
-			item := NewSelectsableItem(fmt.Sprintf("Item %d", i))
+			item := NewSelectableItem(fmt.Sprintf("Item %d", i))
 			items = append(items, item)
 		}
-		cmd := l.SetItems(items)
+		l := New(items, WithDirection(Forward)).(*list[Item])
+		l.SetSize(10, 5)
+		cmd := l.Init()
 		if cmd != nil {
 			cmd()
 		}
@@ -412,14 +412,14 @@ func TestForwardList(t *testing.T) {
 	})
 	t.Run("should move to the bottom", func(t *testing.T) {
 		t.Parallel()
-		l := New(WithDirection(Forward)).(*list)
-		l.SetSize(10, 5)
 		items := []Item{}
 		for i := range 10 {
-			item := NewSelectsableItem(fmt.Sprintf("Item %d", i))
+			item := NewSelectableItem(fmt.Sprintf("Item %d", i))
 			items = append(items, item)
 		}
-		cmd := l.SetItems(items)
+		l := New(items, WithDirection(Forward)).(*list[Item])
+		l.SetSize(10, 5)
+		cmd := l.Init()
 		if cmd != nil {
 			cmd()
 		}
@@ -430,14 +430,14 @@ func TestForwardList(t *testing.T) {
 	})
 	t.Run("should select the item below", func(t *testing.T) {
 		t.Parallel()
-		l := New(WithDirection(Forward)).(*list)
-		l.SetSize(10, 5)
 		items := []Item{}
 		for i := range 10 {
-			item := NewSelectsableItem(fmt.Sprintf("Item %d", i))
+			item := NewSelectableItem(fmt.Sprintf("Item %d", i))
 			items = append(items, item)
 		}
-		cmd := l.SetItems(items)
+		l := New(items, WithDirection(Forward)).(*list[Item])
+		l.SetSize(10, 5)
+		cmd := l.Init()
 		if cmd != nil {
 			cmd()
 		}
@@ -459,14 +459,14 @@ func TestForwardList(t *testing.T) {
 	})
 	t.Run("should move the view to be able to see the selected item", func(t *testing.T) {
 		t.Parallel()
-		l := New(WithDirection(Backward)).(*list)
-		l.SetSize(10, 5)
 		items := []Item{}
 		for i := range 10 {
-			item := NewSelectsableItem(fmt.Sprintf("Item %d", i))
+			item := NewSelectableItem(fmt.Sprintf("Item %d", i))
 			items = append(items, item)
 		}
-		cmd := l.SetItems(items)
+		l := New(items, WithDirection(Forward)).(*list[Item])
+		l.SetSize(10, 5)
+		cmd := l.Init()
 		if cmd != nil {
 			cmd()
 		}
@@ -484,15 +484,15 @@ func TestForwardList(t *testing.T) {
 func TestListSelection(t *testing.T) {
 	t.Run("should skip none selectable items initially", func(t *testing.T) {
 		t.Parallel()
-		l := New(WithDirection(Forward)).(*list)
-		l.SetSize(100, 10)
 		items := []Item{}
 		items = append(items, NewSimpleItem("None Selectable"))
 		for i := range 5 {
-			item := NewSelectsableItem(fmt.Sprintf("Item %d", i))
+			item := NewSelectableItem(fmt.Sprintf("Item %d", i))
 			items = append(items, item)
 		}
-		cmd := l.SetItems(items)
+		l := New(items, WithDirection(Forward)).(*list[Item])
+		l.SetSize(100, 10)
+		cmd := l.Init()
 		if cmd != nil {
 			cmd()
 		}
@@ -500,19 +500,49 @@ func TestListSelection(t *testing.T) {
 		assert.Equal(t, items[1].ID(), l.selectedItem)
 		golden.RequireEqual(t, []byte(l.View()))
 	})
-	t.Run("should skip none selectable items in the middle", func(t *testing.T) {
+	t.Run("should select the correct item on startup", func(t *testing.T) {
 		t.Parallel()
-		l := New(WithDirection(Forward)).(*list)
+		items := []Item{}
+		for i := range 5 {
+			item := NewSelectableItem(fmt.Sprintf("Item %d", i))
+			items = append(items, item)
+		}
+		l := New(items, WithDirection(Forward)).(*list[Item])
+		cmd := l.Init()
+		otherCmd := l.SetSelected(items[3].ID())
+		var wg sync.WaitGroup
+		if cmd != nil {
+			wg.Add(1)
+			go func() {
+				cmd()
+				wg.Done()
+			}()
+		}
+		if otherCmd != nil {
+			wg.Add(1)
+			go func() {
+				otherCmd()
+				wg.Done()
+			}()
+		}
+		wg.Wait()
 		l.SetSize(100, 10)
+		assert.Equal(t, items[3].ID(), l.selectedItem)
+		golden.RequireEqual(t, []byte(l.View()))
+	})
+	t.Run("should skip none selectable items in the middle", func(t *testing.T) {
+		t.Parallel()
 		items := []Item{}
-		item := NewSelectsableItem("Item initial")
+		item := NewSelectableItem("Item initial")
 		items = append(items, item)
 		items = append(items, NewSimpleItem("None Selectable"))
 		for i := range 5 {
-			item := NewSelectsableItem(fmt.Sprintf("Item %d", i))
+			item := NewSelectableItem(fmt.Sprintf("Item %d", i))
 			items = append(items, item)
 		}
-		cmd := l.SetItems(items)
+		l := New(items, WithDirection(Forward)).(*list[Item])
+		l.SetSize(100, 10)
+		cmd := l.Init()
 		if cmd != nil {
 			cmd()
 		}
@@ -522,6 +552,31 @@ func TestListSelection(t *testing.T) {
 	})
 }
 
+func TestListSetSelection(t *testing.T) {
+	t.Run("should move to the selected item", func(t *testing.T) {
+		t.Parallel()
+		items := []Item{}
+		for i := range 100 {
+			item := NewSelectableItem(fmt.Sprintf("Item %d", i))
+			items = append(items, item)
+		}
+		l := New(items, WithDirection(Forward)).(*list[Item])
+		l.SetSize(100, 10)
+		cmd := l.Init()
+		if cmd != nil {
+			cmd()
+		}
+
+		cmd = l.SetSelected(items[52].ID())
+		if cmd != nil {
+			cmd()
+		}
+
+		assert.Equal(t, items[52].ID(), l.selectedItem)
+		golden.RequireEqual(t, []byte(l.View()))
+	})
+}
+
 type SelectableItem interface {
 	Item
 	layout.Focusable
@@ -545,7 +600,7 @@ func NewSimpleItem(content string) *simpleItem {
 	}
 }
 
-func NewSelectsableItem(content string) SelectableItem {
+func NewSelectableItem(content string) SelectableItem {
 	return &selectableItem{
 		simpleItem: NewSimpleItem(content),
 		focused:    false,