refactor(list): remove item IDs for simpler API

Ayman Bagabas created

The chat component can wrap the items with an identifiable interface.

Change summary

internal/ui/list/example_test.go |  29 +-
internal/ui/list/item.go         |  35 ---
internal/ui/list/item_test.go    | 111 ++++++-------
internal/ui/list/list.go         | 286 +++++++++++++--------------------
internal/ui/list/list_test.go    | 100 +++++------
internal/ui/model/chat.go        |  31 +--
internal/ui/model/ui.go          |   2 
7 files changed, 238 insertions(+), 356 deletions(-)

Detailed changes

internal/ui/list/example_test.go 🔗

@@ -12,15 +12,15 @@ import (
 func Example_basic() {
 	// Create some items
 	items := []list.Item{
-		list.NewStringItem("1", "First item"),
-		list.NewStringItem("2", "Second item"),
-		list.NewStringItem("3", "Third item"),
+		list.NewStringItem("First item"),
+		list.NewStringItem("Second item"),
+		list.NewStringItem("Third item"),
 	}
 
 	// Create a list with options
 	l := list.New(items...)
 	l.SetSize(80, 10)
-	l.SetSelectedIndex(0)
+	l.SetSelected(0)
 	if true {
 		l.Focus()
 	}
@@ -109,7 +109,7 @@ func Example_focusable() {
 	// Create list with first item selected and focused
 	l := list.New(items...)
 	l.SetSize(80, 20)
-	l.SetSelectedIndex(0)
+	l.SetSelected(0)
 	if true {
 		l.Focus()
 	}
@@ -127,8 +127,8 @@ func Example_focusable() {
 // Example demonstrates dynamic item updates.
 func Example_dynamicUpdates() {
 	items := []list.Item{
-		list.NewStringItem("1", "Item 1"),
-		list.NewStringItem("2", "Item 2"),
+		list.NewStringItem("Item 1"),
+		list.NewStringItem("Item 2"),
 	}
 
 	l := list.New(items...)
@@ -140,13 +140,13 @@ func Example_dynamicUpdates() {
 	l.Draw(&screen, area)
 
 	// Update an item
-	l.UpdateItem("2", list.NewStringItem("2", "Updated Item 2"))
+	l.UpdateItem(2, list.NewStringItem("Updated Item 2"))
 
 	// Draw again - only changed item is re-rendered
 	l.Draw(&screen, area)
 
 	// Append a new item
-	l.AppendItem(list.NewStringItem("3", "New Item 3"))
+	l.AppendItem(list.NewStringItem("New Item 3"))
 
 	// Draw again - master buffer grows efficiently
 	l.Draw(&screen, area)
@@ -161,7 +161,6 @@ func Example_scrolling() {
 	items := make([]list.Item, 100)
 	for i := range items {
 		items[i] = list.NewStringItem(
-			fmt.Sprintf("%d", i),
 			fmt.Sprintf("Item %d", i),
 		)
 	}
@@ -169,7 +168,7 @@ func Example_scrolling() {
 	// Create list with small viewport
 	l := list.New(items...)
 	l.SetSize(80, 10)
-	l.SetSelectedIndex(0)
+	l.SetSelected(0)
 
 	// Draw initial view (shows items 0-9)
 	screen := uv.NewScreenBuffer(80, 10)
@@ -181,7 +180,7 @@ func Example_scrolling() {
 	l.Draw(&screen, area) // Now shows items 5-14
 
 	// Jump to specific item
-	l.ScrollToItem("50")
+	l.ScrollToItem(50)
 	l.Draw(&screen, area) // Now shows item 50 and neighbors
 
 	// Scroll to bottom
@@ -258,9 +257,9 @@ func Example_variableHeights() {
 func Example_markdown() {
 	// Create markdown items
 	items := []list.Item{
-		list.NewMarkdownItem("1", "# Welcome\n\nThis is a **markdown** item."),
-		list.NewMarkdownItem("2", "## Features\n\n- Supports **bold**\n- Supports *italic*\n- Supports `code`"),
-		list.NewMarkdownItem("3", "### Code Block\n\n```go\nfunc main() {\n    fmt.Println(\"Hello\")\n}\n```"),
+		list.NewMarkdownItem("# Welcome\n\nThis is a **markdown** item."),
+		list.NewMarkdownItem("## Features\n\n- Supports **bold**\n- Supports *italic*\n- Supports `code`"),
+		list.NewMarkdownItem("### Code Block\n\n```go\nfunc main() {\n    fmt.Println(\"Hello\")\n}\n```"),
 	}
 
 	// Create list

internal/ui/list/item.go 🔗

@@ -60,9 +60,6 @@ func toUVStyle(lgStyle lipgloss.Style) uv.Style {
 type Item interface {
 	uv.Drawable
 
-	// ID returns unique identifier for this item.
-	ID() string
-
 	// Height returns the item's height in lines for the given width.
 	// This allows items to calculate height based on text wrapping and available space.
 	Height(width int) int
@@ -272,7 +269,6 @@ func (b *BaseHighlightable) ApplyHighlight(buf *uv.ScreenBuffer, width, height i
 type StringItem struct {
 	BaseFocusable
 	BaseHighlightable
-	id      string
 	content string // Raw content string (may contain ANSI styles)
 	wrap    bool   // Whether to wrap text
 
@@ -305,9 +301,8 @@ func LipglossStyleToCellStyler(lgStyle lipgloss.Style) CellStyler {
 }
 
 // NewStringItem creates a new string item with the given ID and content.
-func NewStringItem(id, content string) *StringItem {
+func NewStringItem(content string) *StringItem {
 	s := &StringItem{
-		id:      id,
 		content: content,
 		wrap:    false,
 		cache:   make(map[int]string),
@@ -317,9 +312,8 @@ func NewStringItem(id, content string) *StringItem {
 }
 
 // NewWrappingStringItem creates a new string item that wraps text to fit width.
-func NewWrappingStringItem(id, content string) *StringItem {
+func NewWrappingStringItem(content string) *StringItem {
 	s := &StringItem{
-		id:      id,
 		content: content,
 		wrap:    true,
 		cache:   make(map[int]string),
@@ -335,11 +329,6 @@ func (s *StringItem) WithFocusStyles(focusStyle, blurStyle *lipgloss.Style) *Str
 	return s
 }
 
-// ID implements Item.
-func (s *StringItem) ID() string {
-	return s.id
-}
-
 // Height implements Item.
 func (s *StringItem) Height(width int) int {
 	// Calculate content width if we have styles
@@ -423,7 +412,6 @@ func (s *StringItem) SetHighlight(startLine, startCol, endLine, endCol int) {
 type MarkdownItem struct {
 	BaseFocusable
 	BaseHighlightable
-	id          string
 	markdown    string            // Raw markdown content
 	styleConfig *ansi.StyleConfig // Optional style configuration
 	maxWidth    int               // Maximum wrap width (default 120)
@@ -438,9 +426,8 @@ const DefaultMarkdownMaxWidth = 120
 
 // NewMarkdownItem creates a new markdown item with the given ID and markdown content.
 // If focusStyle and blurStyle are both non-nil, the item will implement Focusable.
-func NewMarkdownItem(id, markdown string) *MarkdownItem {
+func NewMarkdownItem(markdown string) *MarkdownItem {
 	m := &MarkdownItem{
-		id:       id,
 		markdown: markdown,
 		maxWidth: DefaultMarkdownMaxWidth,
 		cache:    make(map[int]string),
@@ -468,11 +455,6 @@ func (m *MarkdownItem) WithFocusStyles(focusStyle, blurStyle *lipgloss.Style) *M
 	return m
 }
 
-// ID implements Item.
-func (m *MarkdownItem) ID() string {
-	return m.id
-}
-
 // Height implements Item.
 func (m *MarkdownItem) Height(width int) int {
 	// Render the markdown to get its height
@@ -563,30 +545,23 @@ func (m *MarkdownItem) SetHighlight(startLine, startCol, endLine, endCol int) {
 }
 
 // Gap is a 1-line spacer item used to add gaps between items.
-var Gap = NewSpacerItem("spacer-gap", 1)
+var Gap = NewSpacerItem(1)
 
 // SpacerItem is an empty item that takes up vertical space.
 // Useful for adding gaps between items in a list.
 type SpacerItem struct {
-	id     string
 	height int
 }
 
 var _ Item = (*SpacerItem)(nil)
 
 // NewSpacerItem creates a new spacer item with the given ID and height in lines.
-func NewSpacerItem(id string, height int) *SpacerItem {
+func NewSpacerItem(height int) *SpacerItem {
 	return &SpacerItem{
-		id:     id,
 		height: height,
 	}
 }
 
-// ID implements Item.
-func (s *SpacerItem) ID() string {
-	return s.id
-}
-
 // Height implements Item.
 func (s *SpacerItem) Height(width int) int {
 	return s.height

internal/ui/list/item_test.go 🔗

@@ -10,9 +10,9 @@ import (
 
 func TestRenderHelper(t *testing.T) {
 	items := []Item{
-		NewStringItem("1", "Item 1"),
-		NewStringItem("2", "Item 2"),
-		NewStringItem("3", "Item 3"),
+		NewStringItem("Item 1"),
+		NewStringItem("Item 2"),
+		NewStringItem("Item 3"),
 	}
 
 	l := New(items...)
@@ -39,11 +39,11 @@ func TestRenderHelper(t *testing.T) {
 
 func TestRenderWithScrolling(t *testing.T) {
 	items := []Item{
-		NewStringItem("1", "Item 1"),
-		NewStringItem("2", "Item 2"),
-		NewStringItem("3", "Item 3"),
-		NewStringItem("4", "Item 4"),
-		NewStringItem("5", "Item 5"),
+		NewStringItem("Item 1"),
+		NewStringItem("Item 2"),
+		NewStringItem("Item 3"),
+		NewStringItem("Item 4"),
+		NewStringItem("Item 5"),
 	}
 
 	l := New(items...)
@@ -89,8 +89,8 @@ func TestRenderEmptyList(t *testing.T) {
 
 func TestRenderVsDrawConsistency(t *testing.T) {
 	items := []Item{
-		NewStringItem("1", "Item 1"),
-		NewStringItem("2", "Item 2"),
+		NewStringItem("Item 1"),
+		NewStringItem("Item 2"),
 	}
 
 	l := New(items...)
@@ -119,7 +119,7 @@ func TestRenderVsDrawConsistency(t *testing.T) {
 func BenchmarkRender(b *testing.B) {
 	items := make([]Item, 100)
 	for i := range items {
-		items[i] = NewStringItem(string(rune(i)), "Item content here")
+		items[i] = NewStringItem("Item content here")
 	}
 
 	l := New(items...)
@@ -135,7 +135,7 @@ func BenchmarkRender(b *testing.B) {
 func BenchmarkRenderWithScrolling(b *testing.B) {
 	items := make([]Item, 1000)
 	for i := range items {
-		items[i] = NewStringItem(string(rune(i)), "Item content here")
+		items[i] = NewStringItem("Item content here")
 	}
 
 	l := New(items...)
@@ -150,7 +150,7 @@ func BenchmarkRenderWithScrolling(b *testing.B) {
 }
 
 func TestStringItemCache(t *testing.T) {
-	item := NewStringItem("1", "Test content")
+	item := NewStringItem("Test content")
 
 	// First draw at width 80 should populate cache
 	screen1 := uv.NewScreenBuffer(80, 5)
@@ -188,14 +188,14 @@ func TestStringItemCache(t *testing.T) {
 
 func TestWrappingItemHeight(t *testing.T) {
 	// Short text that fits in one line
-	item1 := NewWrappingStringItem("1", "Short")
+	item1 := NewWrappingStringItem("Short")
 	if h := item1.Height(80); h != 1 {
 		t.Errorf("expected height 1 for short text, got %d", h)
 	}
 
 	// Long text that wraps
 	longText := "This is a very long line that will definitely wrap when constrained to a narrow width"
-	item2 := NewWrappingStringItem("2", longText)
+	item2 := NewWrappingStringItem(longText)
 
 	// At width 80, should be fewer lines than width 20
 	height80 := item2.Height(80)
@@ -207,7 +207,7 @@ func TestWrappingItemHeight(t *testing.T) {
 	}
 
 	// Non-wrapping version should always be 1 line
-	item3 := NewStringItem("3", longText)
+	item3 := NewStringItem(longText)
 	if h := item3.Height(20); h != 1 {
 		t.Errorf("expected height 1 for non-wrapping item, got %d", h)
 	}
@@ -215,11 +215,7 @@ func TestWrappingItemHeight(t *testing.T) {
 
 func TestMarkdownItemBasic(t *testing.T) {
 	markdown := "# Hello\n\nThis is a **test**."
-	item := NewMarkdownItem("1", markdown)
-
-	if item.ID() != "1" {
-		t.Errorf("expected ID '1', got '%s'", item.ID())
-	}
+	item := NewMarkdownItem(markdown)
 
 	// Test that height is calculated
 	height := item.Height(80)
@@ -241,7 +237,7 @@ func TestMarkdownItemBasic(t *testing.T) {
 
 func TestMarkdownItemCache(t *testing.T) {
 	markdown := "# Test\n\nSome content."
-	item := NewMarkdownItem("1", markdown)
+	item := NewMarkdownItem(markdown)
 
 	// First render at width 80 should populate cache
 	height1 := item.Height(80)
@@ -267,7 +263,7 @@ func TestMarkdownItemCache(t *testing.T) {
 
 func TestMarkdownItemMaxCacheWidth(t *testing.T) {
 	markdown := "# Test\n\nSome content."
-	item := NewMarkdownItem("1", markdown).WithMaxWidth(50)
+	item := NewMarkdownItem(markdown).WithMaxWidth(50)
 
 	// Render at width 40 (below limit) - should cache at width 40
 	_ = item.Height(40)
@@ -302,7 +298,7 @@ func TestMarkdownItemWithStyleConfig(t *testing.T) {
 		},
 	}
 
-	item := NewMarkdownItem("1", markdown).WithStyleConfig(styleConfig)
+	item := NewMarkdownItem(markdown).WithStyleConfig(styleConfig)
 
 	// Render should use the custom style
 	height := item.Height(80)
@@ -323,9 +319,9 @@ func TestMarkdownItemWithStyleConfig(t *testing.T) {
 
 func TestMarkdownItemInList(t *testing.T) {
 	items := []Item{
-		NewMarkdownItem("1", "# First\n\nMarkdown item."),
-		NewMarkdownItem("2", "# Second\n\nAnother item."),
-		NewStringItem("3", "Regular string item"),
+		NewMarkdownItem("# First\n\nMarkdown item."),
+		NewMarkdownItem("# Second\n\nAnother item."),
+		NewStringItem("Regular string item"),
 	}
 
 	l := New(items...)
@@ -353,7 +349,7 @@ func TestMarkdownItemHeightWithWidth(t *testing.T) {
 	// Test that widths are capped to maxWidth
 	markdown := "This is a paragraph with some text."
 
-	item := NewMarkdownItem("1", markdown).WithMaxWidth(50)
+	item := NewMarkdownItem(markdown).WithMaxWidth(50)
 
 	// At width 30 (below limit), should cache and render at width 30
 	height30 := item.Height(30)
@@ -381,7 +377,7 @@ func TestMarkdownItemHeightWithWidth(t *testing.T) {
 
 func BenchmarkMarkdownItemRender(b *testing.B) {
 	markdown := "# Heading\n\nThis is a paragraph with **bold** and *italic* text.\n\n- Item 1\n- Item 2\n- Item 3"
-	item := NewMarkdownItem("1", markdown)
+	item := NewMarkdownItem(markdown)
 
 	// Prime the cache
 	screen := uv.NewScreenBuffer(80, 10)
@@ -401,7 +397,7 @@ func BenchmarkMarkdownItemUncached(b *testing.B) {
 
 	b.ResetTimer()
 	for i := 0; i < b.N; i++ {
-		item := NewMarkdownItem("1", markdown)
+		item := NewMarkdownItem(markdown)
 		screen := uv.NewScreenBuffer(80, 10)
 		area := uv.Rect(0, 0, 80, 10)
 		item.Draw(&screen, area)
@@ -409,12 +405,7 @@ func BenchmarkMarkdownItemUncached(b *testing.B) {
 }
 
 func TestSpacerItem(t *testing.T) {
-	spacer := NewSpacerItem("spacer1", 3)
-
-	// Check ID
-	if spacer.ID() != "spacer1" {
-		t.Errorf("expected ID 'spacer1', got %q", spacer.ID())
-	}
+	spacer := NewSpacerItem(3)
 
 	// Check height
 	if h := spacer.Height(80); h != 3 {
@@ -444,11 +435,11 @@ func TestSpacerItem(t *testing.T) {
 func TestSpacerItemInList(t *testing.T) {
 	// Create a list with items separated by spacers
 	items := []Item{
-		NewStringItem("1", "Item 1"),
-		NewSpacerItem("spacer1", 1),
-		NewStringItem("2", "Item 2"),
-		NewSpacerItem("spacer2", 2),
-		NewStringItem("3", "Item 3"),
+		NewStringItem("Item 1"),
+		NewSpacerItem(1),
+		NewStringItem("Item 2"),
+		NewSpacerItem(2),
+		NewStringItem("Item 3"),
 	}
 
 	l := New(items...)
@@ -477,28 +468,28 @@ func TestSpacerItemInList(t *testing.T) {
 func TestSpacerItemNavigation(t *testing.T) {
 	// Spacers should not be selectable (they're not focusable)
 	items := []Item{
-		NewStringItem("1", "Item 1"),
-		NewSpacerItem("spacer1", 1),
-		NewStringItem("2", "Item 2"),
+		NewStringItem("Item 1"),
+		NewSpacerItem(1),
+		NewStringItem("Item 2"),
 	}
 
 	l := New(items...)
 	l.SetSize(20, 10)
 
 	// Select first item
-	l.SetSelectedIndex(0)
+	l.SetSelected(0)
 	if l.SelectedIndex() != 0 {
 		t.Errorf("expected selected index 0, got %d", l.SelectedIndex())
 	}
 
 	// Can select the spacer (it's a valid item, just not focusable)
-	l.SetSelectedIndex(1)
+	l.SetSelected(1)
 	if l.SelectedIndex() != 1 {
 		t.Errorf("expected selected index 1, got %d", l.SelectedIndex())
 	}
 
 	// Can select item after spacer
-	l.SetSelectedIndex(2)
+	l.SetSelected(2)
 	if l.SelectedIndex() != 2 {
 		t.Errorf("expected selected index 2, got %d", l.SelectedIndex())
 	}
@@ -512,11 +503,11 @@ func uintPtr(v uint) *uint {
 func TestListDoesNotEatLastLine(t *testing.T) {
 	// Create items that exactly fill the viewport
 	items := []Item{
-		NewStringItem("1", "Line 1"),
-		NewStringItem("2", "Line 2"),
-		NewStringItem("3", "Line 3"),
-		NewStringItem("4", "Line 4"),
-		NewStringItem("5", "Line 5"),
+		NewStringItem("Line 1"),
+		NewStringItem("Line 2"),
+		NewStringItem("Line 3"),
+		NewStringItem("Line 4"),
+		NewStringItem("Line 5"),
 	}
 
 	// Create list with height exactly matching content (5 lines, no gaps)
@@ -527,7 +518,7 @@ func TestListDoesNotEatLastLine(t *testing.T) {
 	output := l.Render()
 
 	// Count actual lines in output
-	lines := strings.Split(strings.TrimRight(output, "\r\n"), "\r\n")
+	lines := strings.Split(strings.TrimRight(output, "\n"), "\n")
 	actualLineCount := 0
 	for _, line := range lines {
 		if strings.TrimSpace(line) != "" {
@@ -560,13 +551,13 @@ func TestListDoesNotEatLastLine(t *testing.T) {
 func TestListWithScrollDoesNotEatLastLine(t *testing.T) {
 	// Create more items than viewport height
 	items := []Item{
-		NewStringItem("1", "Item 1"),
-		NewStringItem("2", "Item 2"),
-		NewStringItem("3", "Item 3"),
-		NewStringItem("4", "Item 4"),
-		NewStringItem("5", "Item 5"),
-		NewStringItem("6", "Item 6"),
-		NewStringItem("7", "Item 7"),
+		NewStringItem("Item 1"),
+		NewStringItem("Item 2"),
+		NewStringItem("Item 3"),
+		NewStringItem("Item 4"),
+		NewStringItem("Item 5"),
+		NewStringItem("Item 6"),
+		NewStringItem("Item 7"),
 	}
 
 	// Viewport shows 3 items at a time

internal/ui/list/list.go 🔗

@@ -16,8 +16,7 @@ type List struct {
 	width, height int
 
 	// Data
-	items    []Item
-	indexMap map[string]int // ID -> index for fast lookup
+	items []Item
 
 	// Focus & Selection
 	focused     bool
@@ -28,23 +27,23 @@ type List struct {
 	totalHeight  int
 
 	// Item positioning in master buffer
-	itemPositions map[string]itemPosition
+	itemPositions []itemPosition
 
 	// Viewport state
 	offset int // Scroll offset in lines from top
 
 	// Mouse state
 	mouseDown     bool
-	mouseDownItem string // Item ID where mouse was pressed
-	mouseDownX    int    // X position in item content (character offset)
-	mouseDownY    int    // Y position in item (line offset)
-	mouseDragItem string // Current item being dragged over
-	mouseDragX    int    // Current X in item content
-	mouseDragY    int    // Current Y in item
+	mouseDownItem int // Item index where mouse was pressed
+	mouseDownX    int // X position in item content (character offset)
+	mouseDownY    int // Y position in item (line offset)
+	mouseDragItem int // Current item index being dragged over
+	mouseDragX    int // Current X in item content
+	mouseDragY    int // Current Y in item
 
 	// Dirty tracking
 	dirty      bool
-	dirtyItems map[string]bool
+	dirtyItems map[int]bool
 }
 
 type itemPosition struct {
@@ -56,15 +55,11 @@ type itemPosition struct {
 func New(items ...Item) *List {
 	l := &List{
 		items:         items,
-		indexMap:      make(map[string]int, len(items)),
-		itemPositions: make(map[string]itemPosition, len(items)),
-		dirtyItems:    make(map[string]bool),
+		itemPositions: make([]itemPosition, len(items)),
+		dirtyItems:    make(map[int]bool),
 		selectedIdx:   -1,
-	}
-
-	// Build index map
-	for i, item := range items {
-		l.indexMap[item.ID()] = i
+		mouseDownItem: -1,
+		mouseDragItem: -1,
 	}
 
 	l.dirty = true
@@ -251,7 +246,7 @@ func (l *List) rebuildMasterBuffer() {
 
 	// Draw each item
 	currentY := 0
-	for _, item := range l.items {
+	for i, item := range l.items {
 		itemHeight := item.Height(l.width)
 
 		// Draw item to master buffer
@@ -259,7 +254,7 @@ func (l *List) rebuildMasterBuffer() {
 		item.Draw(l.masterBuffer, area)
 
 		// Store position
-		l.itemPositions[item.ID()] = itemPosition{
+		l.itemPositions[i] = itemPosition{
 			startLine: currentY,
 			height:    itemHeight,
 		}
@@ -269,7 +264,7 @@ func (l *List) rebuildMasterBuffer() {
 	}
 
 	l.dirty = false
-	l.dirtyItems = make(map[string]bool)
+	l.dirtyItems = make(map[int]bool)
 }
 
 // updateDirtyItems efficiently updates only changed items using slice operations.
@@ -280,21 +275,9 @@ func (l *List) updateDirtyItems() {
 
 	// Check if all dirty items have unchanged heights
 	allSameHeight := true
-	for id := range l.dirtyItems {
-		idx, ok := l.indexMap[id]
-		if !ok {
-			continue
-		}
-
+	for idx := range l.dirtyItems {
 		item := l.items[idx]
-		pos, ok := l.itemPositions[id]
-		if !ok {
-			l.dirty = true
-			l.dirtyItems = make(map[string]bool)
-			l.rebuildMasterBuffer()
-			return
-		}
-
+		pos := l.itemPositions[idx]
 		newHeight := item.Height(l.width)
 		if newHeight != pos.height {
 			allSameHeight = false
@@ -305,10 +288,9 @@ func (l *List) updateDirtyItems() {
 	// Optimization: If all dirty items have unchanged heights, re-render in place
 	if allSameHeight {
 		buf := l.masterBuffer.Buffer
-		for id := range l.dirtyItems {
-			idx := l.indexMap[id]
+		for idx := range l.dirtyItems {
 			item := l.items[idx]
-			pos := l.itemPositions[id]
+			pos := l.itemPositions[idx]
 
 			// Clear the item's area
 			for y := pos.startLine; y < pos.startLine+pos.height && y < len(buf.Lines); y++ {
@@ -320,23 +302,22 @@ func (l *List) updateDirtyItems() {
 			item.Draw(l.masterBuffer, area)
 		}
 
-		l.dirtyItems = make(map[string]bool)
+		l.dirtyItems = make(map[int]bool)
 		return
 	}
 
 	// Height changed - full rebuild
 	l.dirty = true
-	l.dirtyItems = make(map[string]bool)
+	l.dirtyItems = make(map[int]bool)
 	l.rebuildMasterBuffer()
 }
 
 // updatePositionsBelow updates the startLine for all items below the given index.
 func (l *List) updatePositionsBelow(fromIdx int, delta int) {
 	for i := fromIdx + 1; i < len(l.items); i++ {
-		item := l.items[i]
-		pos := l.itemPositions[item.ID()]
+		pos := l.itemPositions[i]
 		pos.startLine += delta
-		l.itemPositions[item.ID()] = pos
+		l.itemPositions[i] = pos
 	}
 }
 
@@ -395,13 +376,7 @@ func (l *List) Len() int {
 // SetItems replaces all items in the list.
 func (l *List) SetItems(items []Item) {
 	l.items = items
-	l.indexMap = make(map[string]int, len(items))
-	l.itemPositions = make(map[string]itemPosition, len(items))
-
-	for i, item := range items {
-		l.indexMap[item.ID()] = i
-	}
-
+	l.itemPositions = make([]itemPosition, len(items))
 	l.dirty = true
 }
 
@@ -410,15 +385,15 @@ func (l *List) Items() []Item {
 	return l.items
 }
 
-// AppendItem adds an item to the end of the list.
-func (l *List) AppendItem(item Item) {
+// AppendItem adds an item to the end of the list. Returns true if successful.
+func (l *List) AppendItem(item Item) bool {
 	l.items = append(l.items, item)
-	l.indexMap[item.ID()] = len(l.items) - 1
+	l.itemPositions = append(l.itemPositions, itemPosition{})
 
 	// If buffer not built yet, mark dirty for full rebuild
 	if l.masterBuffer == nil || l.width <= 0 {
 		l.dirty = true
-		return
+		return true
 	}
 
 	// Process any pending dirty items before modifying buffer structure
@@ -442,23 +417,20 @@ func (l *List) AppendItem(item Item) {
 	item.Draw(l.masterBuffer, area)
 
 	// Update tracking
-	l.itemPositions[item.ID()] = itemPosition{
+	l.itemPositions[len(l.items)-1] = itemPosition{
 		startLine: startLine,
 		height:    itemHeight,
 	}
 	l.totalHeight += itemHeight
+
+	return true
 }
 
-// PrependItem adds an item to the beginning of the list.
-func (l *List) PrependItem(item Item) {
+// PrependItem adds an item to the beginning of the list. Returns true if
+// successful.
+func (l *List) PrependItem(item Item) bool {
 	l.items = append([]Item{item}, l.items...)
-
-	// Rebuild index map (all indices shifted)
-	l.indexMap = make(map[string]int, len(l.items))
-	for i, itm := range l.items {
-		l.indexMap[itm.ID()] = i
-	}
-
+	l.itemPositions = append([]itemPosition{{}}, l.itemPositions...)
 	if l.selectedIdx >= 0 {
 		l.selectedIdx++
 	}
@@ -466,7 +438,7 @@ func (l *List) PrependItem(item Item) {
 	// If buffer not built yet, mark dirty for full rebuild
 	if l.masterBuffer == nil || l.width <= 0 {
 		l.dirty = true
-		return
+		return true
 	}
 
 	// Process any pending dirty items before modifying buffer structure
@@ -492,42 +464,41 @@ func (l *List) PrependItem(item Item) {
 	item.Draw(l.masterBuffer, area)
 
 	// Update all positions (shift everything down)
-	for i := 1; i < len(l.items); i++ {
-		itm := l.items[i]
-		if pos, ok := l.itemPositions[itm.ID()]; ok {
-			pos.startLine += itemHeight
-			l.itemPositions[itm.ID()] = pos
-		}
+	for i := range l.itemPositions {
+		pos := l.itemPositions[i]
+		pos.startLine += itemHeight
+		l.itemPositions[i] = pos
 	}
 
-	// Add position for new item
-	l.itemPositions[item.ID()] = itemPosition{
+	// Add position for new item at start
+	l.itemPositions[0] = itemPosition{
 		startLine: 0,
 		height:    itemHeight,
 	}
+
 	l.totalHeight += itemHeight
+
+	return true
 }
 
-// UpdateItem replaces an item with the same ID.
-func (l *List) UpdateItem(id string, item Item) {
-	idx, ok := l.indexMap[id]
-	if !ok {
-		return
+// UpdateItem replaces an item with the same index. Returns true if successful.
+func (l *List) UpdateItem(idx int, item Item) bool {
+	if idx < 0 || idx >= len(l.items) {
+		return false
 	}
-
 	l.items[idx] = item
-	l.dirtyItems[id] = true
+	l.dirtyItems[idx] = true
+	return true
 }
 
-// DeleteItem removes an item by ID.
-func (l *List) DeleteItem(id string) {
-	idx, ok := l.indexMap[id]
-	if !ok {
-		return
+// DeleteItem removes an item by index. Returns true if successful.
+func (l *List) DeleteItem(idx int) bool {
+	if idx < 0 || idx >= len(l.items) {
+		return false
 	}
 
 	// Get position before deleting
-	pos, hasPos := l.itemPositions[id]
+	pos := l.itemPositions[idx]
 
 	// Process any pending dirty items before modifying buffer structure
 	if len(l.dirtyItems) > 0 {
@@ -535,13 +506,7 @@ func (l *List) DeleteItem(id string) {
 	}
 
 	l.items = append(l.items[:idx], l.items[idx+1:]...)
-	delete(l.indexMap, id)
-	delete(l.itemPositions, id)
-
-	// Rebuild index map for items after deleted one
-	for i := idx; i < len(l.items); i++ {
-		l.indexMap[l.items[i].ID()] = i
-	}
+	l.itemPositions = append(l.itemPositions[:idx], l.itemPositions[idx+1:]...)
 
 	// Adjust selection
 	if l.selectedIdx == idx {
@@ -557,9 +522,9 @@ func (l *List) DeleteItem(id string) {
 	}
 
 	// If buffer not built yet, mark dirty for full rebuild
-	if l.masterBuffer == nil || !hasPos {
+	if l.masterBuffer == nil {
 		l.dirty = true
-		return
+		return true
 	}
 
 	// Efficient delete: remove lines from buffer
@@ -575,6 +540,8 @@ func (l *List) DeleteItem(id string) {
 		// Position data corrupt, rebuild
 		l.dirty = true
 	}
+
+	return true
 }
 
 // Focus focuses the list and the selected item (if focusable).
@@ -595,12 +562,10 @@ func (l *List) Focused() bool {
 }
 
 // SetSelected sets the selected item by ID.
-func (l *List) SetSelected(id string) {
-	idx, ok := l.indexMap[id]
-	if !ok {
+func (l *List) SetSelected(idx int) {
+	if idx < 0 || idx >= len(l.items) {
 		return
 	}
-
 	if l.selectedIdx == idx {
 		return
 	}
@@ -613,33 +578,25 @@ func (l *List) SetSelected(id string) {
 		if prevIdx >= 0 && prevIdx < len(l.items) {
 			if f, ok := l.items[prevIdx].(Focusable); ok {
 				f.Blur()
-				l.dirtyItems[l.items[prevIdx].ID()] = true
+				l.dirtyItems[prevIdx] = true
 			}
 		}
 
 		if f, ok := l.items[idx].(Focusable); ok {
 			f.Focus()
-			l.dirtyItems[l.items[idx].ID()] = true
+			l.dirtyItems[idx] = true
 		}
 	}
 }
 
-// SetSelectedIndex sets the selected item by index.
-func (l *List) SetSelectedIndex(idx int) {
-	if idx < 0 || idx >= len(l.items) {
-		return
-	}
-	l.SetSelected(l.items[idx].ID())
-}
-
 // SelectFirst selects the first item in the list.
 func (l *List) SelectFirst() {
-	l.SetSelectedIndex(0)
+	l.SetSelected(0)
 }
 
 // SelectLast selects the last item in the list.
 func (l *List) SelectLast() {
-	l.SetSelectedIndex(len(l.items) - 1)
+	l.SetSelected(len(l.items) - 1)
 }
 
 // SelectNextWrap selects the next item in the list (wraps to beginning).
@@ -679,7 +636,7 @@ func (l *List) selectNext(wrap bool) {
 		}
 
 		// Select and scroll to this item
-		l.SetSelected(l.items[nextIdx].ID())
+		l.SetSelected(nextIdx)
 		return
 	}
 }
@@ -721,7 +678,7 @@ func (l *List) selectPrev(wrap bool) {
 		}
 
 		// Select and scroll to this item
-		l.SetSelected(l.items[prevIdx].ID())
+		l.SetSelected(prevIdx)
 		return
 	}
 }
@@ -773,13 +730,9 @@ func (l *List) ScrollToBottom() {
 }
 
 // ScrollToItem scrolls to make the item with the given ID visible.
-func (l *List) ScrollToItem(id string) {
+func (l *List) ScrollToItem(idx int) {
 	l.ensureBuilt()
-	pos, ok := l.itemPositions[id]
-	if !ok {
-		return
-	}
-
+	pos := l.itemPositions[idx]
 	itemStart := pos.startLine
 	itemEnd := pos.startLine + pos.height
 	viewStart := l.offset
@@ -805,7 +758,7 @@ func (l *List) ScrollToSelected() {
 	if l.selectedIdx < 0 || l.selectedIdx >= len(l.items) {
 		return
 	}
-	l.ScrollToItem(l.items[l.selectedIdx].ID())
+	l.ScrollToItem(l.selectedIdx)
 }
 
 // Offset returns the current scroll offset.
@@ -825,15 +778,12 @@ func (l *List) SelectFirstInView() {
 	viewportStart := l.offset
 	viewportEnd := l.offset + l.height
 
-	for i, item := range l.items {
-		pos, ok := l.itemPositions[item.ID()]
-		if !ok {
-			continue
-		}
+	for i := range l.items {
+		pos := l.itemPositions[i]
 
 		// Check if item is fully within viewport bounds
 		if pos.startLine >= viewportStart && (pos.startLine+pos.height) <= viewportEnd {
-			l.SetSelectedIndex(i)
+			l.SetSelected(i)
 			return
 		}
 	}
@@ -847,15 +797,11 @@ func (l *List) SelectLastInView() {
 	viewportEnd := l.offset + l.height
 
 	for i := len(l.items) - 1; i >= 0; i-- {
-		item := l.items[i]
-		pos, ok := l.itemPositions[item.ID()]
-		if !ok {
-			continue
-		}
+		pos := l.itemPositions[i]
 
 		// Check if item is fully within viewport bounds
 		if pos.startLine >= viewportStart && (pos.startLine+pos.height) <= viewportEnd {
-			l.SetSelectedIndex(i)
+			l.SetSelected(i)
 			return
 		}
 	}
@@ -868,11 +814,7 @@ func (l *List) SelectedItemInView() bool {
 	}
 
 	// Get selected item ID and position
-	item := l.items[l.selectedIdx]
-	pos, ok := l.itemPositions[item.ID()]
-	if !ok {
-		return false
-	}
+	pos := l.itemPositions[l.selectedIdx]
 
 	// Check if item is within viewport bounds
 	viewportStart := l.offset
@@ -896,7 +838,7 @@ func (l *List) focusSelectedItem() {
 	item := l.items[l.selectedIdx]
 	if f, ok := item.(Focusable); ok {
 		f.Focus()
-		l.dirtyItems[item.ID()] = true
+		l.dirtyItems[l.selectedIdx] = true
 	}
 }
 
@@ -909,7 +851,7 @@ func (l *List) blurSelectedItem() {
 	item := l.items[l.selectedIdx]
 	if f, ok := item.(Focusable); ok {
 		f.Blur()
-		l.dirtyItems[item.ID()] = true
+		l.dirtyItems[l.selectedIdx] = true
 	}
 }
 
@@ -923,8 +865,8 @@ func (l *List) HandleMouseDown(x, y int) bool {
 	bufferY := y + l.offset
 
 	// Find which item was clicked
-	itemID, itemY := l.findItemAtPosition(bufferY)
-	if itemID == "" {
+	itemIdx, itemY := l.findItemAtPosition(bufferY)
+	if itemIdx < 0 {
 		return false
 	}
 
@@ -933,17 +875,15 @@ func (l *List) HandleMouseDown(x, y int) bool {
 	// Items can interpret this as character offset in their content
 
 	l.mouseDown = true
-	l.mouseDownItem = itemID
+	l.mouseDownItem = itemIdx
 	l.mouseDownX = x
 	l.mouseDownY = itemY
-	l.mouseDragItem = itemID
+	l.mouseDragItem = itemIdx
 	l.mouseDragX = x
 	l.mouseDragY = itemY
 
 	// Select the clicked item
-	if idx, ok := l.indexMap[itemID]; ok {
-		l.SetSelectedIndex(idx)
-	}
+	l.SetSelected(itemIdx)
 
 	return true
 }
@@ -962,12 +902,12 @@ func (l *List) HandleMouseDrag(x, y int) bool {
 	bufferY := y + l.offset
 
 	// Find which item we're dragging over
-	itemID, itemY := l.findItemAtPosition(bufferY)
-	if itemID == "" {
+	itemIdx, itemY := l.findItemAtPosition(bufferY)
+	if itemIdx < 0 {
 		return false
 	}
 
-	l.mouseDragItem = itemID
+	l.mouseDragItem = itemIdx
 	l.mouseDragX = x
 	l.mouseDragY = itemY
 
@@ -994,47 +934,46 @@ func (l *List) HandleMouseUp(x, y int) bool {
 
 // ClearHighlight clears any active text highlighting.
 func (l *List) ClearHighlight() {
-	for _, item := range l.items {
+	for i, item := range l.items {
 		if h, ok := item.(Highlightable); ok {
 			h.SetHighlight(-1, -1, -1, -1)
-			l.dirtyItems[item.ID()] = true
+			l.dirtyItems[i] = true
 		}
 	}
+	l.mouseDownItem = -1
+	l.mouseDragItem = -1
 }
 
 // findItemAtPosition finds the item at the given master buffer y coordinate.
-// Returns the item ID and the y offset within that item.
-func (l *List) findItemAtPosition(bufferY int) (itemID string, itemY int) {
+// Returns the item index and the y offset within that item. It returns -1, -1
+// if no item is found.
+func (l *List) findItemAtPosition(bufferY int) (itemIdx int, itemY int) {
 	if bufferY < 0 || bufferY >= l.totalHeight {
-		return "", 0
+		return -1, -1
 	}
 
 	// Linear search through items to find which one contains this y
 	// This could be optimized with binary search if needed
-	for _, item := range l.items {
-		pos, ok := l.itemPositions[item.ID()]
-		if !ok {
-			continue
-		}
-
+	for i := range l.items {
+		pos := l.itemPositions[i]
 		if bufferY >= pos.startLine && bufferY < pos.startLine+pos.height {
-			return item.ID(), bufferY - pos.startLine
+			return i, bufferY - pos.startLine
 		}
 	}
 
-	return "", 0
+	return -1, -1
 }
 
 // updateHighlight updates the highlight range for highlightable items.
 // Supports highlighting across multiple items and respects drag direction.
 func (l *List) updateHighlight() {
-	if l.mouseDownItem == "" {
+	if l.mouseDownItem < 0 {
 		return
 	}
 
 	// Get start and end item indices
-	downItemIdx := l.indexMap[l.mouseDownItem]
-	dragItemIdx := l.indexMap[l.mouseDragItem]
+	downItemIdx := l.mouseDownItem
+	dragItemIdx := l.mouseDragItem
 
 	// Determine selection direction
 	draggingDown := dragItemIdx > downItemIdx ||
@@ -1064,10 +1003,10 @@ func (l *List) updateHighlight() {
 	}
 
 	// Clear all highlights first
-	for _, item := range l.items {
+	for i, item := range l.items {
 		if h, ok := item.(Highlightable); ok {
 			h.SetHighlight(-1, -1, -1, -1)
-			l.dirtyItems[item.ID()] = true
+			l.dirtyItems[i] = true
 		}
 	}
 
@@ -1083,18 +1022,18 @@ func (l *List) updateHighlight() {
 			item.SetHighlight(startLine, startCol, endLine, endCol)
 		} else if idx == startItemIdx {
 			// First item - from start position to end of item
-			pos := l.itemPositions[l.items[idx].ID()]
+			pos := l.itemPositions[idx]
 			item.SetHighlight(startLine, startCol, pos.height-1, 9999) // 9999 = end of line
 		} else if idx == endItemIdx {
 			// Last item - from start of item to end position
 			item.SetHighlight(0, 0, endLine, endCol)
 		} else {
 			// Middle item - fully highlighted
-			pos := l.itemPositions[l.items[idx].ID()]
+			pos := l.itemPositions[idx]
 			item.SetHighlight(0, 0, pos.height-1, 9999)
 		}
 
-		l.dirtyItems[l.items[idx].ID()] = true
+		l.dirtyItems[idx] = true
 	}
 }
 
@@ -1110,7 +1049,7 @@ func (l *List) GetHighlightedText() string {
 	var result strings.Builder
 
 	// Iterate through items to find highlighted ones
-	for _, item := range l.items {
+	for i, item := range l.items {
 		h, ok := item.(Highlightable)
 		if !ok {
 			continue
@@ -1121,10 +1060,7 @@ func (l *List) GetHighlightedText() string {
 			continue
 		}
 
-		pos, ok := l.itemPositions[item.ID()]
-		if !ok {
-			continue
-		}
+		pos := l.itemPositions[i]
 
 		// Extract text from highlighted region in master buffer
 		for y := startLine; y <= endLine && y < pos.height; y++ {

internal/ui/list/list_test.go 🔗

@@ -11,9 +11,9 @@ import (
 
 func TestNewList(t *testing.T) {
 	items := []Item{
-		NewStringItem("1", "Item 1"),
-		NewStringItem("2", "Item 2"),
-		NewStringItem("3", "Item 3"),
+		NewStringItem("Item 1"),
+		NewStringItem("Item 2"),
+		NewStringItem("Item 3"),
 	}
 
 	l := New(items...)
@@ -30,9 +30,9 @@ func TestNewList(t *testing.T) {
 
 func TestListDraw(t *testing.T) {
 	items := []Item{
-		NewStringItem("1", "Item 1"),
-		NewStringItem("2", "Item 2"),
-		NewStringItem("3", "Item 3"),
+		NewStringItem("Item 1"),
+		NewStringItem("Item 2"),
+		NewStringItem("Item 3"),
 	}
 
 	l := New(items...)
@@ -54,52 +54,44 @@ func TestListDraw(t *testing.T) {
 
 func TestListAppendItem(t *testing.T) {
 	items := []Item{
-		NewStringItem("1", "Item 1"),
+		NewStringItem("Item 1"),
 	}
 
 	l := New(items...)
-	l.AppendItem(NewStringItem("2", "Item 2"))
+	l.AppendItem(NewStringItem("Item 2"))
 
 	if len(l.items) != 2 {
 		t.Errorf("expected 2 items after append, got %d", len(l.items))
 	}
-
-	if l.items[1].ID() != "2" {
-		t.Errorf("expected item ID '2', got '%s'", l.items[1].ID())
-	}
 }
 
 func TestListDeleteItem(t *testing.T) {
 	items := []Item{
-		NewStringItem("1", "Item 1"),
-		NewStringItem("2", "Item 2"),
-		NewStringItem("3", "Item 3"),
+		NewStringItem("Item 1"),
+		NewStringItem("Item 2"),
+		NewStringItem("Item 3"),
 	}
 
 	l := New(items...)
-	l.DeleteItem("2")
+	l.DeleteItem(2)
 
 	if len(l.items) != 2 {
 		t.Errorf("expected 2 items after delete, got %d", len(l.items))
 	}
-
-	if l.items[1].ID() != "3" {
-		t.Errorf("expected item ID '3', got '%s'", l.items[1].ID())
-	}
 }
 
 func TestListUpdateItem(t *testing.T) {
 	items := []Item{
-		NewStringItem("1", "Item 1"),
-		NewStringItem("2", "Item 2"),
+		NewStringItem("Item 1"),
+		NewStringItem("Item 2"),
 	}
 
 	l := New(items...)
 	l.SetSize(80, 10)
 
 	// Update item
-	newItem := NewStringItem("2", "Updated Item 2")
-	l.UpdateItem("2", newItem)
+	newItem := NewStringItem("Updated Item 2")
+	l.UpdateItem(1, newItem)
 
 	if l.items[1].(*StringItem).content != "Updated Item 2" {
 		t.Errorf("expected updated content, got '%s'", l.items[1].(*StringItem).content)
@@ -108,13 +100,13 @@ func TestListUpdateItem(t *testing.T) {
 
 func TestListSelection(t *testing.T) {
 	items := []Item{
-		NewStringItem("1", "Item 1"),
-		NewStringItem("2", "Item 2"),
-		NewStringItem("3", "Item 3"),
+		NewStringItem("Item 1"),
+		NewStringItem("Item 2"),
+		NewStringItem("Item 3"),
 	}
 
 	l := New(items...)
-	l.SetSelectedIndex(0)
+	l.SetSelected(0)
 
 	if l.SelectedIndex() != 0 {
 		t.Errorf("expected selected index 0, got %d", l.SelectedIndex())
@@ -133,11 +125,11 @@ func TestListSelection(t *testing.T) {
 
 func TestListScrolling(t *testing.T) {
 	items := []Item{
-		NewStringItem("1", "Item 1"),
-		NewStringItem("2", "Item 2"),
-		NewStringItem("3", "Item 3"),
-		NewStringItem("4", "Item 4"),
-		NewStringItem("5", "Item 5"),
+		NewStringItem("Item 1"),
+		NewStringItem("Item 2"),
+		NewStringItem("Item 3"),
+		NewStringItem("Item 4"),
+		NewStringItem("Item 5"),
 	}
 
 	l := New(items...)
@@ -208,7 +200,7 @@ func TestListFocus(t *testing.T) {
 
 	l := New(items...)
 	l.SetSize(80, 10)
-	l.SetSelectedIndex(0)
+	l.SetSelected(0)
 
 	// Focus the list
 	l.Focus()
@@ -256,12 +248,12 @@ func TestFocusNavigationAfterAppendingToViewportHeight(t *testing.T) {
 
 	// Start with one item
 	items := []Item{
-		NewStringItem("1", "Item 1").WithFocusStyles(&focusStyle, &blurStyle),
+		NewStringItem("Item 1").WithFocusStyles(&focusStyle, &blurStyle),
 	}
 
 	l := New(items...)
 	l.SetSize(20, 15) // 15 lines viewport height
-	l.SetSelectedIndex(0)
+	l.SetSelected(0)
 	l.Focus()
 
 	// Initial draw to build buffer
@@ -271,12 +263,12 @@ func TestFocusNavigationAfterAppendingToViewportHeight(t *testing.T) {
 	// Append items until we exceed viewport height
 	// Each focusable item with border is 5 lines tall
 	for i := 2; i <= 4; i++ {
-		item := NewStringItem(string(rune('0'+i)), "Item "+string(rune('0'+i))).WithFocusStyles(&focusStyle, &blurStyle)
+		item := NewStringItem("Item "+string(rune('0'+i))).WithFocusStyles(&focusStyle, &blurStyle)
 		l.AppendItem(item)
 	}
 
 	// Select the last item
-	l.SetSelectedIndex(3)
+	l.SetSelected(3)
 
 	// Draw
 	screen = uv.NewScreenBuffer(20, 15)
@@ -318,7 +310,7 @@ func TestFocusableItemUpdate(t *testing.T) {
 		BorderForeground(lipgloss.Color("240"))
 
 	// Create a focusable item
-	item := NewStringItem("1", "Test Item").WithFocusStyles(&focusStyle, &blurStyle)
+	item := NewStringItem("Test Item").WithFocusStyles(&focusStyle, &blurStyle)
 
 	// Initially not focused - render with blur style
 	screen1 := uv.NewScreenBuffer(20, 5)
@@ -369,14 +361,14 @@ func TestFocusableItemHeightWithBorder(t *testing.T) {
 		Border(lipgloss.RoundedBorder())
 
 	// Item without styles has height 1
-	plainItem := NewStringItem("1", "Test")
+	plainItem := NewStringItem("Test")
 	plainHeight := plainItem.Height(20)
 	if plainHeight != 1 {
 		t.Errorf("expected plain height 1, got %d", plainHeight)
 	}
 
 	// Item with border should add border height (2 lines)
-	item := NewStringItem("2", "Test").WithFocusStyles(&borderStyle, &borderStyle)
+	item := NewStringItem("Test").WithFocusStyles(&borderStyle, &borderStyle)
 	itemHeight := item.Height(20)
 	expectedHeight := 1 + 2 // content + border
 	if itemHeight != expectedHeight {
@@ -396,14 +388,14 @@ func TestFocusableItemInList(t *testing.T) {
 
 	// Create list with focusable items
 	items := []Item{
-		NewStringItem("1", "Item 1").WithFocusStyles(&focusStyle, &blurStyle),
-		NewStringItem("2", "Item 2").WithFocusStyles(&focusStyle, &blurStyle),
-		NewStringItem("3", "Item 3").WithFocusStyles(&focusStyle, &blurStyle),
+		NewStringItem("Item 1").WithFocusStyles(&focusStyle, &blurStyle),
+		NewStringItem("Item 2").WithFocusStyles(&focusStyle, &blurStyle),
+		NewStringItem("Item 3").WithFocusStyles(&focusStyle, &blurStyle),
 	}
 
 	l := New(items...)
 	l.SetSize(80, 20)
-	l.SetSelectedIndex(0)
+	l.SetSelected(0)
 
 	// Focus the list
 	l.Focus()
@@ -421,7 +413,7 @@ func TestFocusableItemInList(t *testing.T) {
 	}
 
 	// Select second item
-	l.SetSelectedIndex(1)
+	l.SetSelected(1)
 
 	// First item should be blurred, second focused
 	if firstItem.IsFocused() {
@@ -447,7 +439,7 @@ func TestFocusableItemInList(t *testing.T) {
 
 func TestFocusableItemWithNilStyles(t *testing.T) {
 	// Test with nil styles - should render inner item directly
-	item := NewStringItem("1", "Plain Item").WithFocusStyles(nil, nil)
+	item := NewStringItem("Plain Item").WithFocusStyles(nil, nil)
 
 	// Height should be based on content (no border since styles are nil)
 	itemHeight := item.Height(20)
@@ -488,7 +480,7 @@ func TestFocusableItemWithOnlyFocusStyle(t *testing.T) {
 		Border(lipgloss.RoundedBorder()).
 		BorderForeground(lipgloss.Color("86"))
 
-	item := NewStringItem("1", "Test").WithFocusStyles(&focusStyle, nil)
+	item := NewStringItem("Test").WithFocusStyles(&focusStyle, nil)
 
 	// When not focused, should use nil blur style (no border)
 	screen1 := uv.NewScreenBuffer(20, 5)
@@ -519,15 +511,15 @@ func TestFocusableItemLastLineNotEaten(t *testing.T) {
 		BorderForeground(lipgloss.Color("240"))
 
 	items := []Item{
-		NewStringItem("1", "Item 1").WithFocusStyles(&focusStyle, &blurStyle),
+		NewStringItem("Item 1").WithFocusStyles(&focusStyle, &blurStyle),
 		Gap,
-		NewStringItem("2", "Item 2").WithFocusStyles(&focusStyle, &blurStyle),
+		NewStringItem("Item 2").WithFocusStyles(&focusStyle, &blurStyle),
 		Gap,
-		NewStringItem("3", "Item 3").WithFocusStyles(&focusStyle, &blurStyle),
+		NewStringItem("Item 3").WithFocusStyles(&focusStyle, &blurStyle),
 		Gap,
-		NewStringItem("4", "Item 4").WithFocusStyles(&focusStyle, &blurStyle),
+		NewStringItem("Item 4").WithFocusStyles(&focusStyle, &blurStyle),
 		Gap,
-		NewStringItem("5", "Item 5").WithFocusStyles(&focusStyle, &blurStyle),
+		NewStringItem("Item 5").WithFocusStyles(&focusStyle, &blurStyle),
 	}
 
 	// Items with padding(1) and border are 5 lines each
@@ -543,7 +535,7 @@ func TestFocusableItemLastLineNotEaten(t *testing.T) {
 	l.Focus()
 
 	// Select last item
-	l.SetSelectedIndex(len(items) - 1)
+	l.SetSelected(len(items) - 1)
 
 	// Scroll to bottom
 	l.ScrollToBottom()

internal/ui/model/chat.go 🔗

@@ -11,7 +11,6 @@ import (
 	"github.com/charmbracelet/crush/internal/ui/list"
 	"github.com/charmbracelet/crush/internal/ui/styles"
 	uv "github.com/charmbracelet/ultraviolet"
-	"github.com/google/uuid"
 )
 
 // ChatAnimItem represents a chat animation item in the chat UI.
@@ -57,20 +56,15 @@ func (c *ChatAnimItem) Height(int) int {
 	return 1
 }
 
-// ID implements list.Item.
-func (c *ChatAnimItem) ID() string {
-	return "anim"
-}
-
 // ChatNoContentItem represents a chat item with no content.
 type ChatNoContentItem struct {
 	*list.StringItem
 }
 
 // NewChatNoContentItem creates a new instance of [ChatNoContentItem].
-func NewChatNoContentItem(t *styles.Styles, id string) *ChatNoContentItem {
+func NewChatNoContentItem(t *styles.Styles) *ChatNoContentItem {
 	c := new(ChatNoContentItem)
-	c.StringItem = list.NewStringItem(id, "No message content").
+	c.StringItem = list.NewStringItem("No message content").
 		WithFocusStyles(&t.Chat.NoContentMessage, &t.Chat.NoContentMessage)
 	return c
 }
@@ -93,7 +87,7 @@ func NewChatMessageItem(t *styles.Styles, msg message.Message) *ChatMessageItem
 
 	switch msg.Role {
 	case message.User:
-		item := list.NewMarkdownItem(msg.ID, msg.Content().String()).
+		item := list.NewMarkdownItem(msg.Content().String()).
 			WithFocusStyles(&t.Chat.UserMessageFocused, &t.Chat.UserMessageBlurred)
 		item.SetHighlightStyle(list.LipglossStyleToCellStyler(t.TextSelection))
 		// TODO: Add attachments
@@ -113,7 +107,7 @@ func NewChatMessageItem(t *styles.Styles, msg message.Message) *ChatMessageItem
 			details := t.Chat.ErrorDetails.Render(finishedData.Details)
 			errContent := fmt.Sprintf("%s %s\n\n%s", tag, title, details)
 
-			item := list.NewStringItem(msg.ID, errContent).
+			item := list.NewStringItem(errContent).
 				WithFocusStyles(&t.Chat.AssistantMessageFocused, &t.Chat.AssistantMessageBlurred)
 
 			c.item = item
@@ -141,7 +135,7 @@ func NewChatMessageItem(t *styles.Styles, msg message.Message) *ChatMessageItem
 			parts = append(parts, content)
 		}
 
-		item := list.NewMarkdownItem(msg.ID, strings.Join(parts, "\n")).
+		item := list.NewMarkdownItem(strings.Join(parts, "\n")).
 			WithFocusStyles(&t.Chat.AssistantMessageFocused, &t.Chat.AssistantMessageBlurred)
 		item.SetHighlightStyle(list.LipglossStyleToCellStyler(t.TextSelection))
 
@@ -161,11 +155,6 @@ func (c *ChatMessageItem) Height(width int) int {
 	return c.item.Height(width)
 }
 
-// ID implements list.Item.
-func (c *ChatMessageItem) ID() string {
-	return c.item.ID()
-}
-
 // Blur implements list.Focusable.
 func (c *ChatMessageItem) Blur() {
 	if blurable, ok := c.item.(list.Focusable); ok {
@@ -248,7 +237,7 @@ func (m *Chat) PrependItem(item list.Item) {
 // AppendMessage appends a new message item to the chat list.
 func (m *Chat) AppendMessage(msg message.Message) {
 	if msg.ID == "" {
-		m.AppendItem(NewChatNoContentItem(m.com.Styles, uuid.NewString()))
+		m.AppendItem(NewChatNoContentItem(m.com.Styles))
 	} else {
 		m.AppendItem(NewChatMessageItem(m.com.Styles, msg))
 	}
@@ -258,7 +247,7 @@ func (m *Chat) AppendMessage(msg message.Message) {
 func (m *Chat) AppendItem(item list.Item) {
 	if m.Len() > 0 {
 		// Always add a spacer between messages
-		m.list.AppendItem(list.NewSpacerItem(uuid.NewString(), 1))
+		m.list.AppendItem(list.NewSpacerItem(1))
 	}
 	m.list.AppendItem(item)
 }
@@ -298,9 +287,9 @@ func (m *Chat) SelectedItemInView() bool {
 	return m.list.SelectedItemInView()
 }
 
-// SetSelectedIndex sets the selected message index in the chat list.
-func (m *Chat) SetSelectedIndex(index int) {
-	m.list.SetSelectedIndex(index)
+// SetSelected sets the selected message index in the chat list.
+func (m *Chat) SetSelected(index int) {
+	m.list.SetSelected(index)
 }
 
 // SelectPrev selects the previous message in the chat list.

internal/ui/model/ui.go 🔗

@@ -326,7 +326,7 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) (cmds []tea.Cmd) {
 				m.focus = uiFocusMain
 				m.textarea.Blur()
 				m.chat.Focus()
-				m.chat.SetSelectedIndex(m.chat.Len() - 1)
+				m.chat.SetSelected(m.chat.Len() - 1)
 			}
 		case key.Matches(msg, m.keyMap.Chat.Up):
 			m.chat.ScrollBy(-1)