on main help sign in
refactor(list): remove item IDs for simpler API

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

commit17de9c79dbd908b2d988a736da74d558ba942c7b authorAyman Bagabas <ayman.bagabas@gmail.com> dateWed 3 Dec 2025, 16:09 (1 year ago) parent6088247
7 files changed+238−356
internal/ui/list/example_test.go+14−15 internal/ui/list/item.go+5−30 internal/ui/list/item_test.go+51−60 internal/ui/list/list.go+111−175 internal/ui/list/list_test.go+46−54 internal/ui/model/chat.go+10−21 internal/ui/model/ui.go+1−1
internal/ui/list/example_test.go+14−15
@@ -12,15 +12,15 @@ import (
1212func Example_basic() {
1313 // Create some items
1414 items := []list.Item{
15− list.NewStringItem("1", "First item"),
16− list.NewStringItem("2", "Second item"),
17− list.NewStringItem("3", "Third item"),
15+ list.NewStringItem("First item"),
16+ list.NewStringItem("Second item"),
17+ list.NewStringItem("Third item"),
1818 }
1919
2020 // Create a list with options
2121 l := list.New(items...)
2222 l.SetSize(80, 10)
23− l.SetSelectedIndex(0)
23+ l.SetSelected(0)
2424 if true {
2525 l.Focus()
2626 }
@@ -109,7 +109,7 @@ func Example_focusable() {
109109 // Create list with first item selected and focused
110110 l := list.New(items...)
111111 l.SetSize(80, 20)
112− l.SetSelectedIndex(0)
112+ l.SetSelected(0)
113113 if true {
114114 l.Focus()
115115 }
@@ -127,8 +127,8 @@ func Example_focusable() {
127127// Example demonstrates dynamic item updates.
128128func Example_dynamicUpdates() {
129129 items := []list.Item{
130− list.NewStringItem("1", "Item 1"),
131− list.NewStringItem("2", "Item 2"),
130+ list.NewStringItem("Item 1"),
131+ list.NewStringItem("Item 2"),
132132 }
133133
134134 l := list.New(items...)
@@ -140,13 +140,13 @@ func Example_dynamicUpdates() {
140140 l.Draw(&screen, area)
141141
142142 // Update an item
143− l.UpdateItem("2", list.NewStringItem("2", "Updated Item 2"))
143+ l.UpdateItem(2, list.NewStringItem("Updated Item 2"))
144144
145145 // Draw again - only changed item is re-rendered
146146 l.Draw(&screen, area)
147147
148148 // Append a new item
149− l.AppendItem(list.NewStringItem("3", "New Item 3"))
149+ l.AppendItem(list.NewStringItem("New Item 3"))
150150
151151 // Draw again - master buffer grows efficiently
152152 l.Draw(&screen, area)
@@ -161,7 +161,6 @@ func Example_scrolling() {
161161 items := make([]list.Item, 100)
162162 for i := range items {
163163 items[i] = list.NewStringItem(
164− fmt.Sprintf("%d", i),
165164 fmt.Sprintf("Item %d", i),
166165 )
167166 }
@@ -169,7 +168,7 @@ func Example_scrolling() {
169168 // Create list with small viewport
170169 l := list.New(items...)
171170 l.SetSize(80, 10)
172− l.SetSelectedIndex(0)
171+ l.SetSelected(0)
173172
174173 // Draw initial view (shows items 0-9)
175174 screen := uv.NewScreenBuffer(80, 10)
@@ -181,7 +180,7 @@ func Example_scrolling() {
181180 l.Draw(&screen, area) // Now shows items 5-14
182181
183182 // Jump to specific item
184− l.ScrollToItem("50")
183+ l.ScrollToItem(50)
185184 l.Draw(&screen, area) // Now shows item 50 and neighbors
186185
187186 // Scroll to bottom
@@ -258,9 +257,9 @@ func Example_variableHeights() {
258257func Example_markdown() {
259258 // Create markdown items
260259 items := []list.Item{
261− list.NewMarkdownItem("1", "# Welcome\n\nThis is a **markdown** item."),
262− list.NewMarkdownItem("2", "## Features\n\n- Supports **bold**\n- Supports *italic*\n- Supports `code`"),
263− list.NewMarkdownItem("3", "### Code Block\n\n```go\nfunc main() {\n fmt.Println(\"Hello\")\n}\n```"),
260+ list.NewMarkdownItem("# Welcome\n\nThis is a **markdown** item."),
261+ list.NewMarkdownItem("## Features\n\n- Supports **bold**\n- Supports *italic*\n- Supports `code`"),
262+ list.NewMarkdownItem("### Code Block\n\n```go\nfunc main() {\n fmt.Println(\"Hello\")\n}\n```"),
264263 }
265264
266265 // Create list
internal/ui/list/item.go+5−30
@@ -60,9 +60,6 @@ func toUVStyle(lgStyle lipgloss.Style) uv.Style {
6060type Item interface {
6161 uv.Drawable
6262
63− // ID returns unique identifier for this item.
64− ID() string
65−
6663 // Height returns the item's height in lines for the given width.
6764 // This allows items to calculate height based on text wrapping and available space.
6865 Height(width int) int
@@ -272,7 +269,6 @@ func (b *BaseHighlightable) ApplyHighlight(buf *uv.ScreenBuffer, width, height i
272269type StringItem struct {
273270 BaseFocusable
274271 BaseHighlightable
275− id string
276272 content string // Raw content string (may contain ANSI styles)
277273 wrap bool // Whether to wrap text
278274
@@ -305,9 +301,8 @@ func LipglossStyleToCellStyler(lgStyle lipgloss.Style) CellStyler {
305301}
306302
307303// NewStringItem creates a new string item with the given ID and content.
308−func NewStringItem(id, content string) *StringItem {
304+func NewStringItem(content string) *StringItem {
309305 s := &StringItem{
310− id: id,
311306 content: content,
312307 wrap: false,
313308 cache: make(map[int]string),
@@ -317,9 +312,8 @@ func NewStringItem(id, content string) *StringItem {
317312}
318313
319314// NewWrappingStringItem creates a new string item that wraps text to fit width.
320−func NewWrappingStringItem(id, content string) *StringItem {
315+func NewWrappingStringItem(content string) *StringItem {
321316 s := &StringItem{
322− id: id,
323317 content: content,
324318 wrap: true,
325319 cache: make(map[int]string),
@@ -335,11 +329,6 @@ func (s *StringItem) WithFocusStyles(focusStyle, blurStyle *lipgloss.Style) *Str
335329 return s
336330}
337331
338−// ID implements Item.
339−func (s *StringItem) ID() string {
340− return s.id
341−}
342−
343332// Height implements Item.
344333func (s *StringItem) Height(width int) int {
345334 // Calculate content width if we have styles
@@ -423,7 +412,6 @@ func (s *StringItem) SetHighlight(startLine, startCol, endLine, endCol int) {
423412type MarkdownItem struct {
424413 BaseFocusable
425414 BaseHighlightable
426− id string
427415 markdown string // Raw markdown content
428416 styleConfig *ansi.StyleConfig // Optional style configuration
429417 maxWidth int // Maximum wrap width (default 120)
@@ -438,9 +426,8 @@ const DefaultMarkdownMaxWidth = 120
438426
439427// NewMarkdownItem creates a new markdown item with the given ID and markdown content.
440428// If focusStyle and blurStyle are both non-nil, the item will implement Focusable.
441−func NewMarkdownItem(id, markdown string) *MarkdownItem {
429+func NewMarkdownItem(markdown string) *MarkdownItem {
442430 m := &MarkdownItem{
443− id: id,
444431 markdown: markdown,
445432 maxWidth: DefaultMarkdownMaxWidth,
446433 cache: make(map[int]string),
@@ -468,11 +455,6 @@ func (m *MarkdownItem) WithFocusStyles(focusStyle, blurStyle *lipgloss.Style) *M
468455 return m
469456}
470457
471−// ID implements Item.
472−func (m *MarkdownItem) ID() string {
473− return m.id
474−}
475−
476458// Height implements Item.
477459func (m *MarkdownItem) Height(width int) int {
478460 // Render the markdown to get its height
@@ -563,30 +545,23 @@ func (m *MarkdownItem) SetHighlight(startLine, startCol, endLine, endCol int) {
563545}
564546
565547// Gap is a 1-line spacer item used to add gaps between items.
566−var Gap = NewSpacerItem("spacer-gap", 1)
548+var Gap = NewSpacerItem(1)
567549
568550// SpacerItem is an empty item that takes up vertical space.
569551// Useful for adding gaps between items in a list.
570552type SpacerItem struct {
571− id string
572553 height int
573554}
574555
575556var _ Item = (*SpacerItem)(nil)
576557
577558// NewSpacerItem creates a new spacer item with the given ID and height in lines.
578−func NewSpacerItem(id string, height int) *SpacerItem {
559+func NewSpacerItem(height int) *SpacerItem {
579560 return &SpacerItem{
580− id: id,
581561 height: height,
582562 }
583563}
584564
585−// ID implements Item.
586−func (s *SpacerItem) ID() string {
587− return s.id
588−}
589−
590565// Height implements Item.
591566func (s *SpacerItem) Height(width int) int {
592567 return s.height
internal/ui/list/item_test.go+51−60
@@ -10,9 +10,9 @@ import (
1010
1111func TestRenderHelper(t *testing.T) {
1212 items := []Item{
13− NewStringItem("1", "Item 1"),
14− NewStringItem("2", "Item 2"),
15− NewStringItem("3", "Item 3"),
13+ NewStringItem("Item 1"),
14+ NewStringItem("Item 2"),
15+ NewStringItem("Item 3"),
1616 }
1717
1818 l := New(items...)
@@ -39,11 +39,11 @@ func TestRenderHelper(t *testing.T) {
3939
4040func TestRenderWithScrolling(t *testing.T) {
4141 items := []Item{
42− NewStringItem("1", "Item 1"),
43− NewStringItem("2", "Item 2"),
44− NewStringItem("3", "Item 3"),
45− NewStringItem("4", "Item 4"),
46− NewStringItem("5", "Item 5"),
42+ NewStringItem("Item 1"),
43+ NewStringItem("Item 2"),
44+ NewStringItem("Item 3"),
45+ NewStringItem("Item 4"),
46+ NewStringItem("Item 5"),
4747 }
4848
4949 l := New(items...)
@@ -89,8 +89,8 @@ func TestRenderEmptyList(t *testing.T) {
8989
9090func TestRenderVsDrawConsistency(t *testing.T) {
9191 items := []Item{
92− NewStringItem("1", "Item 1"),
93− NewStringItem("2", "Item 2"),
92+ NewStringItem("Item 1"),
93+ NewStringItem("Item 2"),
9494 }
9595
9696 l := New(items...)
@@ -119,7 +119,7 @@ func TestRenderVsDrawConsistency(t *testing.T) {
119119func BenchmarkRender(b *testing.B) {
120120 items := make([]Item, 100)
121121 for i := range items {
122− items[i] = NewStringItem(string(rune(i)), "Item content here")
122+ items[i] = NewStringItem("Item content here")
123123 }
124124
125125 l := New(items...)
@@ -135,7 +135,7 @@ func BenchmarkRender(b *testing.B) {
135135func BenchmarkRenderWithScrolling(b *testing.B) {
136136 items := make([]Item, 1000)
137137 for i := range items {
138− items[i] = NewStringItem(string(rune(i)), "Item content here")
138+ items[i] = NewStringItem("Item content here")
139139 }
140140
141141 l := New(items...)
@@ -150,7 +150,7 @@ func BenchmarkRenderWithScrolling(b *testing.B) {
150150}
151151
152152func TestStringItemCache(t *testing.T) {
153− item := NewStringItem("1", "Test content")
153+ item := NewStringItem("Test content")
154154
155155 // First draw at width 80 should populate cache
156156 screen1 := uv.NewScreenBuffer(80, 5)
@@ -188,14 +188,14 @@ func TestStringItemCache(t *testing.T) {
188188
189189func TestWrappingItemHeight(t *testing.T) {
190190 // Short text that fits in one line
191− item1 := NewWrappingStringItem("1", "Short")
191+ item1 := NewWrappingStringItem("Short")
192192 if h := item1.Height(80); h != 1 {
193193 t.Errorf("expected height 1 for short text, got %d", h)
194194 }
195195
196196 // Long text that wraps
197197 longText := "This is a very long line that will definitely wrap when constrained to a narrow width"
198− item2 := NewWrappingStringItem("2", longText)
198+ item2 := NewWrappingStringItem(longText)
199199
200200 // At width 80, should be fewer lines than width 20
201201 height80 := item2.Height(80)
@@ -207,7 +207,7 @@ func TestWrappingItemHeight(t *testing.T) {
207207 }
208208
209209 // Non-wrapping version should always be 1 line
210− item3 := NewStringItem("3", longText)
210+ item3 := NewStringItem(longText)
211211 if h := item3.Height(20); h != 1 {
212212 t.Errorf("expected height 1 for non-wrapping item, got %d", h)
213213 }
@@ -215,11 +215,7 @@ func TestWrappingItemHeight(t *testing.T) {
215215
216216func TestMarkdownItemBasic(t *testing.T) {
217217 markdown := "# Hello\n\nThis is a **test**."
218− item := NewMarkdownItem("1", markdown)
219−
220− if item.ID() != "1" {
221− t.Errorf("expected ID '1', got '%s'", item.ID())
222− }
218+ item := NewMarkdownItem(markdown)
223219
224220 // Test that height is calculated
225221 height := item.Height(80)
@@ -241,7 +237,7 @@ func TestMarkdownItemBasic(t *testing.T) {
241237
242238func TestMarkdownItemCache(t *testing.T) {
243239 markdown := "# Test\n\nSome content."
244− item := NewMarkdownItem("1", markdown)
240+ item := NewMarkdownItem(markdown)
245241
246242 // First render at width 80 should populate cache
247243 height1 := item.Height(80)
@@ -267,7 +263,7 @@ func TestMarkdownItemCache(t *testing.T) {
267263
268264func TestMarkdownItemMaxCacheWidth(t *testing.T) {
269265 markdown := "# Test\n\nSome content."
270− item := NewMarkdownItem("1", markdown).WithMaxWidth(50)
266+ item := NewMarkdownItem(markdown).WithMaxWidth(50)
271267
272268 // Render at width 40 (below limit) - should cache at width 40
273269 _ = item.Height(40)
@@ -302,7 +298,7 @@ func TestMarkdownItemWithStyleConfig(t *testing.T) {
302298 },
303299 }
304300
305− item := NewMarkdownItem("1", markdown).WithStyleConfig(styleConfig)
301+ item := NewMarkdownItem(markdown).WithStyleConfig(styleConfig)
306302
307303 // Render should use the custom style
308304 height := item.Height(80)
@@ -323,9 +319,9 @@ func TestMarkdownItemWithStyleConfig(t *testing.T) {
323319
324320func TestMarkdownItemInList(t *testing.T) {
325321 items := []Item{
326− NewMarkdownItem("1", "# First\n\nMarkdown item."),
327− NewMarkdownItem("2", "# Second\n\nAnother item."),
328− NewStringItem("3", "Regular string item"),
322+ NewMarkdownItem("# First\n\nMarkdown item."),
323+ NewMarkdownItem("# Second\n\nAnother item."),
324+ NewStringItem("Regular string item"),
329325 }
330326
331327 l := New(items...)
@@ -353,7 +349,7 @@ func TestMarkdownItemHeightWithWidth(t *testing.T) {
353349 // Test that widths are capped to maxWidth
354350 markdown := "This is a paragraph with some text."
355351
356− item := NewMarkdownItem("1", markdown).WithMaxWidth(50)
352+ item := NewMarkdownItem(markdown).WithMaxWidth(50)
357353
358354 // At width 30 (below limit), should cache and render at width 30
359355 height30 := item.Height(30)
@@ -381,7 +377,7 @@ func TestMarkdownItemHeightWithWidth(t *testing.T) {
381377
382378func BenchmarkMarkdownItemRender(b *testing.B) {
383379 markdown := "# Heading\n\nThis is a paragraph with **bold** and *italic* text.\n\n- Item 1\n- Item 2\n- Item 3"
384− item := NewMarkdownItem("1", markdown)
380+ item := NewMarkdownItem(markdown)
385381
386382 // Prime the cache
387383 screen := uv.NewScreenBuffer(80, 10)
@@ -401,7 +397,7 @@ func BenchmarkMarkdownItemUncached(b *testing.B) {
401397
402398 b.ResetTimer()
403399 for i := 0; i < b.N; i++ {
404− item := NewMarkdownItem("1", markdown)
400+ item := NewMarkdownItem(markdown)
405401 screen := uv.NewScreenBuffer(80, 10)
406402 area := uv.Rect(0, 0, 80, 10)
407403 item.Draw(&screen, area)
@@ -409,12 +405,7 @@ func BenchmarkMarkdownItemUncached(b *testing.B) {
409405}
410406
411407func TestSpacerItem(t *testing.T) {
412− spacer := NewSpacerItem("spacer1", 3)
413−
414− // Check ID
415− if spacer.ID() != "spacer1" {
416− t.Errorf("expected ID 'spacer1', got %q", spacer.ID())
417− }
408+ spacer := NewSpacerItem(3)
418409
419410 // Check height
420411 if h := spacer.Height(80); h != 3 {
@@ -444,11 +435,11 @@ func TestSpacerItem(t *testing.T) {
444435func TestSpacerItemInList(t *testing.T) {
445436 // Create a list with items separated by spacers
446437 items := []Item{
447− NewStringItem("1", "Item 1"),
448− NewSpacerItem("spacer1", 1),
449− NewStringItem("2", "Item 2"),
450− NewSpacerItem("spacer2", 2),
451− NewStringItem("3", "Item 3"),
438+ NewStringItem("Item 1"),
439+ NewSpacerItem(1),
440+ NewStringItem("Item 2"),
441+ NewSpacerItem(2),
442+ NewStringItem("Item 3"),
452443 }
453444
454445 l := New(items...)
@@ -477,28 +468,28 @@ func TestSpacerItemInList(t *testing.T) {
477468func TestSpacerItemNavigation(t *testing.T) {
478469 // Spacers should not be selectable (they're not focusable)
479470 items := []Item{
480− NewStringItem("1", "Item 1"),
481− NewSpacerItem("spacer1", 1),
482− NewStringItem("2", "Item 2"),
471+ NewStringItem("Item 1"),
472+ NewSpacerItem(1),
473+ NewStringItem("Item 2"),
483474 }
484475
485476 l := New(items...)
486477 l.SetSize(20, 10)
487478
488479 // Select first item
489− l.SetSelectedIndex(0)
480+ l.SetSelected(0)
490481 if l.SelectedIndex() != 0 {
491482 t.Errorf("expected selected index 0, got %d", l.SelectedIndex())
492483 }
493484
494485 // Can select the spacer (it's a valid item, just not focusable)
495− l.SetSelectedIndex(1)
486+ l.SetSelected(1)
496487 if l.SelectedIndex() != 1 {
497488 t.Errorf("expected selected index 1, got %d", l.SelectedIndex())
498489 }
499490
500491 // Can select item after spacer
501− l.SetSelectedIndex(2)
492+ l.SetSelected(2)
502493 if l.SelectedIndex() != 2 {
503494 t.Errorf("expected selected index 2, got %d", l.SelectedIndex())
504495 }
@@ -512,11 +503,11 @@ func uintPtr(v uint) *uint {
512503func TestListDoesNotEatLastLine(t *testing.T) {
513504 // Create items that exactly fill the viewport
514505 items := []Item{
515− NewStringItem("1", "Line 1"),
516− NewStringItem("2", "Line 2"),
517− NewStringItem("3", "Line 3"),
518− NewStringItem("4", "Line 4"),
519− NewStringItem("5", "Line 5"),
506+ NewStringItem("Line 1"),
507+ NewStringItem("Line 2"),
508+ NewStringItem("Line 3"),
509+ NewStringItem("Line 4"),
510+ NewStringItem("Line 5"),
520511 }
521512
522513 // Create list with height exactly matching content (5 lines, no gaps)
@@ -527,7 +518,7 @@ func TestListDoesNotEatLastLine(t *testing.T) {
527518 output := l.Render()
528519
529520 // Count actual lines in output
530− lines := strings.Split(strings.TrimRight(output, "\r\n"), "\r\n")
521+ lines := strings.Split(strings.TrimRight(output, "\n"), "\n")
531522 actualLineCount := 0
532523 for _, line := range lines {
533524 if strings.TrimSpace(line) != "" {
@@ -560,13 +551,13 @@ func TestListDoesNotEatLastLine(t *testing.T) {
560551func TestListWithScrollDoesNotEatLastLine(t *testing.T) {
561552 // Create more items than viewport height
562553 items := []Item{
563− NewStringItem("1", "Item 1"),
564− NewStringItem("2", "Item 2"),
565− NewStringItem("3", "Item 3"),
566− NewStringItem("4", "Item 4"),
567− NewStringItem("5", "Item 5"),
568− NewStringItem("6", "Item 6"),
569− NewStringItem("7", "Item 7"),
554+ NewStringItem("Item 1"),
555+ NewStringItem("Item 2"),
556+ NewStringItem("Item 3"),
557+ NewStringItem("Item 4"),
558+ NewStringItem("Item 5"),
559+ NewStringItem("Item 6"),
560+ NewStringItem("Item 7"),
570561 }
571562
572563 // Viewport shows 3 items at a time
internal/ui/list/list.go+111−175
@@ -16,8 +16,7 @@ type List struct {
1616 width, height int
1717
1818 // Data
19− items []Item
20− indexMap map[string]int // ID -> index for fast lookup
19+ items []Item
2120
2221 // Focus & Selection
2322 focused bool
@@ -28,23 +27,23 @@ type List struct {
2827 totalHeight int
2928
3029 // Item positioning in master buffer
31− itemPositions map[string]itemPosition
30+ itemPositions []itemPosition
3231
3332 // Viewport state
3433 offset int // Scroll offset in lines from top
3534
3635 // Mouse state
3736 mouseDown bool
38− mouseDownItem string // Item ID where mouse was pressed
39− mouseDownX int // X position in item content (character offset)
40− mouseDownY int // Y position in item (line offset)
41− mouseDragItem string // Current item being dragged over
42− mouseDragX int // Current X in item content
43− mouseDragY int // Current Y in item
37+ mouseDownItem int // Item index where mouse was pressed
38+ mouseDownX int // X position in item content (character offset)
39+ mouseDownY int // Y position in item (line offset)
40+ mouseDragItem int // Current item index being dragged over
41+ mouseDragX int // Current X in item content
42+ mouseDragY int // Current Y in item
4443
4544 // Dirty tracking
4645 dirty bool
47− dirtyItems map[string]bool
46+ dirtyItems map[int]bool
4847}
4948
5049type itemPosition struct {
@@ -56,15 +55,11 @@ type itemPosition struct {
5655func New(items ...Item) *List {
5756 l := &List{
5857 items: items,
59− indexMap: make(map[string]int, len(items)),
60− itemPositions: make(map[string]itemPosition, len(items)),
61− dirtyItems: make(map[string]bool),
58+ itemPositions: make([]itemPosition, len(items)),
59+ dirtyItems: make(map[int]bool),
6260 selectedIdx: -1,
63− }
64−
65− // Build index map
66− for i, item := range items {
67− l.indexMap[item.ID()] = i
61+ mouseDownItem: -1,
62+ mouseDragItem: -1,
6863 }
6964
7065 l.dirty = true
@@ -251,7 +246,7 @@ func (l *List) rebuildMasterBuffer() {
251246
252247 // Draw each item
253248 currentY := 0
254− for _, item := range l.items {
249+ for i, item := range l.items {
255250 itemHeight := item.Height(l.width)
256251
257252 // Draw item to master buffer
@@ -259,7 +254,7 @@ func (l *List) rebuildMasterBuffer() {
259254 item.Draw(l.masterBuffer, area)
260255
261256 // Store position
262− l.itemPositions[item.ID()] = itemPosition{
257+ l.itemPositions[i] = itemPosition{
263258 startLine: currentY,
264259 height: itemHeight,
265260 }
@@ -269,7 +264,7 @@ func (l *List) rebuildMasterBuffer() {
269264 }
270265
271266 l.dirty = false
272− l.dirtyItems = make(map[string]bool)
267+ l.dirtyItems = make(map[int]bool)
273268}
274269
275270// updateDirtyItems efficiently updates only changed items using slice operations.
@@ -280,21 +275,9 @@ func (l *List) updateDirtyItems() {
280275
281276 // Check if all dirty items have unchanged heights
282277 allSameHeight := true
283− for id := range l.dirtyItems {
284− idx, ok := l.indexMap[id]
285− if !ok {
286− continue
287− }
288−
278+ for idx := range l.dirtyItems {
289279 item := l.items[idx]
290− pos, ok := l.itemPositions[id]
291− if !ok {
292− l.dirty = true
293− l.dirtyItems = make(map[string]bool)
294− l.rebuildMasterBuffer()
295− return
296− }
297−
280+ pos := l.itemPositions[idx]
298281 newHeight := item.Height(l.width)
299282 if newHeight != pos.height {
300283 allSameHeight = false
@@ -305,10 +288,9 @@ func (l *List) updateDirtyItems() {
305288 // Optimization: If all dirty items have unchanged heights, re-render in place
306289 if allSameHeight {
307290 buf := l.masterBuffer.Buffer
308− for id := range l.dirtyItems {
309− idx := l.indexMap[id]
291+ for idx := range l.dirtyItems {
310292 item := l.items[idx]
311− pos := l.itemPositions[id]
293+ pos := l.itemPositions[idx]
312294
313295 // Clear the item's area
314296 for y := pos.startLine; y < pos.startLine+pos.height && y < len(buf.Lines); y++ {
@@ -320,23 +302,22 @@ func (l *List) updateDirtyItems() {
320302 item.Draw(l.masterBuffer, area)
321303 }
322304
323− l.dirtyItems = make(map[string]bool)
305+ l.dirtyItems = make(map[int]bool)
324306 return
325307 }
326308
327309 // Height changed - full rebuild
328310 l.dirty = true
329− l.dirtyItems = make(map[string]bool)
311+ l.dirtyItems = make(map[int]bool)
330312 l.rebuildMasterBuffer()
331313}
332314
333315// updatePositionsBelow updates the startLine for all items below the given index.
334316func (l *List) updatePositionsBelow(fromIdx int, delta int) {
335317 for i := fromIdx + 1; i < len(l.items); i++ {
336− item := l.items[i]
337− pos := l.itemPositions[item.ID()]
318+ pos := l.itemPositions[i]
338319 pos.startLine += delta
339− l.itemPositions[item.ID()] = pos
320+ l.itemPositions[i] = pos
340321 }
341322}
342323
@@ -395,13 +376,7 @@ func (l *List) Len() int {
395376// SetItems replaces all items in the list.
396377func (l *List) SetItems(items []Item) {
397378 l.items = items
398− l.indexMap = make(map[string]int, len(items))
399− l.itemPositions = make(map[string]itemPosition, len(items))
400−
401− for i, item := range items {
402− l.indexMap[item.ID()] = i
403− }
404−
379+ l.itemPositions = make([]itemPosition, len(items))
405380 l.dirty = true
406381}
407382
@@ -410,15 +385,15 @@ func (l *List) Items() []Item {
410385 return l.items
411386}
412387
413−// AppendItem adds an item to the end of the list.
414−func (l *List) AppendItem(item Item) {
388+// AppendItem adds an item to the end of the list. Returns true if successful.
389+func (l *List) AppendItem(item Item) bool {
415390 l.items = append(l.items, item)
416− l.indexMap[item.ID()] = len(l.items) - 1
391+ l.itemPositions = append(l.itemPositions, itemPosition{})
417392
418393 // If buffer not built yet, mark dirty for full rebuild
419394 if l.masterBuffer == nil || l.width <= 0 {
420395 l.dirty = true
421− return
396+ return true
422397 }
423398
424399 // Process any pending dirty items before modifying buffer structure
@@ -442,23 +417,20 @@ func (l *List) AppendItem(item Item) {
442417 item.Draw(l.masterBuffer, area)
443418
444419 // Update tracking
445− l.itemPositions[item.ID()] = itemPosition{
420+ l.itemPositions[len(l.items)-1] = itemPosition{
446421 startLine: startLine,
447422 height: itemHeight,
448423 }
449424 l.totalHeight += itemHeight
425+
426+ return true
450427}
451428
452−// PrependItem adds an item to the beginning of the list.
453−func (l *List) PrependItem(item Item) {
429+// PrependItem adds an item to the beginning of the list. Returns true if
430+// successful.
431+func (l *List) PrependItem(item Item) bool {
454432 l.items = append([]Item{item}, l.items...)
455−
456− // Rebuild index map (all indices shifted)
457− l.indexMap = make(map[string]int, len(l.items))
458− for i, itm := range l.items {
459− l.indexMap[itm.ID()] = i
460− }
461−
433+ l.itemPositions = append([]itemPosition{{}}, l.itemPositions...)
462434 if l.selectedIdx >= 0 {
463435 l.selectedIdx++
464436 }
@@ -466,7 +438,7 @@ func (l *List) PrependItem(item Item) {
466438 // If buffer not built yet, mark dirty for full rebuild
467439 if l.masterBuffer == nil || l.width <= 0 {
468440 l.dirty = true
469− return
441+ return true
470442 }
471443
472444 // Process any pending dirty items before modifying buffer structure
@@ -492,42 +464,41 @@ func (l *List) PrependItem(item Item) {
492464 item.Draw(l.masterBuffer, area)
493465
494466 // Update all positions (shift everything down)
495− for i := 1; i < len(l.items); i++ {
496− itm := l.items[i]
497− if pos, ok := l.itemPositions[itm.ID()]; ok {
498− pos.startLine += itemHeight
499− l.itemPositions[itm.ID()] = pos
500− }
467+ for i := range l.itemPositions {
468+ pos := l.itemPositions[i]
469+ pos.startLine += itemHeight
470+ l.itemPositions[i] = pos
501471 }
502472
503− // Add position for new item
504− l.itemPositions[item.ID()] = itemPosition{
473+ // Add position for new item at start
474+ l.itemPositions[0] = itemPosition{
505475 startLine: 0,
506476 height: itemHeight,
507477 }
478+
508479 l.totalHeight += itemHeight
480+
481+ return true
509482}
510483
511−// UpdateItem replaces an item with the same ID.
512−func (l *List) UpdateItem(id string, item Item) {
513− idx, ok := l.indexMap[id]
514− if !ok {
515− return
484+// UpdateItem replaces an item with the same index. Returns true if successful.
485+func (l *List) UpdateItem(idx int, item Item) bool {
486+ if idx < 0 || idx >= len(l.items) {
487+ return false
516488 }
517−
518489 l.items[idx] = item
519− l.dirtyItems[id] = true
490+ l.dirtyItems[idx] = true
491+ return true
520492}
521493
522−// DeleteItem removes an item by ID.
523−func (l *List) DeleteItem(id string) {
524− idx, ok := l.indexMap[id]
525− if !ok {
526− return
494+// DeleteItem removes an item by index. Returns true if successful.
495+func (l *List) DeleteItem(idx int) bool {
496+ if idx < 0 || idx >= len(l.items) {
497+ return false
527498 }
528499
529500 // Get position before deleting
530− pos, hasPos := l.itemPositions[id]
501+ pos := l.itemPositions[idx]
531502
532503 // Process any pending dirty items before modifying buffer structure
533504 if len(l.dirtyItems) > 0 {
@@ -535,13 +506,7 @@ func (l *List) DeleteItem(id string) {
535506 }
536507
537508 l.items = append(l.items[:idx], l.items[idx+1:]...)
538− delete(l.indexMap, id)
539− delete(l.itemPositions, id)
540−
541− // Rebuild index map for items after deleted one
542− for i := idx; i < len(l.items); i++ {
543− l.indexMap[l.items[i].ID()] = i
544− }
509+ l.itemPositions = append(l.itemPositions[:idx], l.itemPositions[idx+1:]...)
545510
546511 // Adjust selection
547512 if l.selectedIdx == idx {
@@ -557,9 +522,9 @@ func (l *List) DeleteItem(id string) {
557522 }
558523
559524 // If buffer not built yet, mark dirty for full rebuild
560− if l.masterBuffer == nil || !hasPos {
525+ if l.masterBuffer == nil {
561526 l.dirty = true
562− return
527+ return true
563528 }
564529
565530 // Efficient delete: remove lines from buffer
@@ -575,6 +540,8 @@ func (l *List) DeleteItem(id string) {
575540 // Position data corrupt, rebuild
576541 l.dirty = true
577542 }
543+
544+ return true
578545}
579546
580547// Focus focuses the list and the selected item (if focusable).
@@ -595,12 +562,10 @@ func (l *List) Focused() bool {
595562}
596563
597564// SetSelected sets the selected item by ID.
598−func (l *List) SetSelected(id string) {
599− idx, ok := l.indexMap[id]
600− if !ok {
565+func (l *List) SetSelected(idx int) {
566+ if idx < 0 || idx >= len(l.items) {
601567 return
602568 }
603−
604569 if l.selectedIdx == idx {
605570 return
606571 }
@@ -613,33 +578,25 @@ func (l *List) SetSelected(id string) {
613578 if prevIdx >= 0 && prevIdx < len(l.items) {
614579 if f, ok := l.items[prevIdx].(Focusable); ok {
615580 f.Blur()
616− l.dirtyItems[l.items[prevIdx].ID()] = true
581+ l.dirtyItems[prevIdx] = true
617582 }
618583 }
619584
620585 if f, ok := l.items[idx].(Focusable); ok {
621586 f.Focus()
622− l.dirtyItems[l.items[idx].ID()] = true
587+ l.dirtyItems[idx] = true
623588 }
624589 }
625590}
626591
627−// SetSelectedIndex sets the selected item by index.
628−func (l *List) SetSelectedIndex(idx int) {
629− if idx < 0 || idx >= len(l.items) {
630− return
631− }
632− l.SetSelected(l.items[idx].ID())
633−}
634−
635592// SelectFirst selects the first item in the list.
636593func (l *List) SelectFirst() {
637− l.SetSelectedIndex(0)
594+ l.SetSelected(0)
638595}
639596
640597// SelectLast selects the last item in the list.
641598func (l *List) SelectLast() {
642− l.SetSelectedIndex(len(l.items) - 1)
599+ l.SetSelected(len(l.items) - 1)
643600}
644601
645602// SelectNextWrap selects the next item in the list (wraps to beginning).
@@ -679,7 +636,7 @@ func (l *List) selectNext(wrap bool) {
679636 }
680637
681638 // Select and scroll to this item
682− l.SetSelected(l.items[nextIdx].ID())
639+ l.SetSelected(nextIdx)
683640 return
684641 }
685642}
@@ -721,7 +678,7 @@ func (l *List) selectPrev(wrap bool) {
721678 }
722679
723680 // Select and scroll to this item
724− l.SetSelected(l.items[prevIdx].ID())
681+ l.SetSelected(prevIdx)
725682 return
726683 }
727684}
@@ -773,13 +730,9 @@ func (l *List) ScrollToBottom() {
773730}
774731
775732// ScrollToItem scrolls to make the item with the given ID visible.
776−func (l *List) ScrollToItem(id string) {
733+func (l *List) ScrollToItem(idx int) {
777734 l.ensureBuilt()
778− pos, ok := l.itemPositions[id]
779− if !ok {
780− return
781− }
782−
735+ pos := l.itemPositions[idx]
783736 itemStart := pos.startLine
784737 itemEnd := pos.startLine + pos.height
785738 viewStart := l.offset
@@ -805,7 +758,7 @@ func (l *List) ScrollToSelected() {
805758 if l.selectedIdx < 0 || l.selectedIdx >= len(l.items) {
806759 return
807760 }
808− l.ScrollToItem(l.items[l.selectedIdx].ID())
761+ l.ScrollToItem(l.selectedIdx)
809762}
810763
811764// Offset returns the current scroll offset.
@@ -825,15 +778,12 @@ func (l *List) SelectFirstInView() {
825778 viewportStart := l.offset
826779 viewportEnd := l.offset + l.height
827780
828− for i, item := range l.items {
829− pos, ok := l.itemPositions[item.ID()]
830− if !ok {
831− continue
832− }
781+ for i := range l.items {
782+ pos := l.itemPositions[i]
833783
834784 // Check if item is fully within viewport bounds
835785 if pos.startLine >= viewportStart && (pos.startLine+pos.height) <= viewportEnd {
836− l.SetSelectedIndex(i)
786+ l.SetSelected(i)
837787 return
838788 }
839789 }
@@ -847,15 +797,11 @@ func (l *List) SelectLastInView() {
847797 viewportEnd := l.offset + l.height
848798
849799 for i := len(l.items) - 1; i >= 0; i-- {
850− item := l.items[i]
851− pos, ok := l.itemPositions[item.ID()]
852− if !ok {
853− continue
854− }
800+ pos := l.itemPositions[i]
855801
856802 // Check if item is fully within viewport bounds
857803 if pos.startLine >= viewportStart && (pos.startLine+pos.height) <= viewportEnd {
858− l.SetSelectedIndex(i)
804+ l.SetSelected(i)
859805 return
860806 }
861807 }
@@ -868,11 +814,7 @@ func (l *List) SelectedItemInView() bool {
868814 }
869815
870816 // Get selected item ID and position
871− item := l.items[l.selectedIdx]
872− pos, ok := l.itemPositions[item.ID()]
873− if !ok {
874− return false
875− }
817+ pos := l.itemPositions[l.selectedIdx]
876818
877819 // Check if item is within viewport bounds
878820 viewportStart := l.offset
@@ -896,7 +838,7 @@ func (l *List) focusSelectedItem() {
896838 item := l.items[l.selectedIdx]
897839 if f, ok := item.(Focusable); ok {
898840 f.Focus()
899− l.dirtyItems[item.ID()] = true
841+ l.dirtyItems[l.selectedIdx] = true
900842 }
901843}
902844
@@ -909,7 +851,7 @@ func (l *List) blurSelectedItem() {
909851 item := l.items[l.selectedIdx]
910852 if f, ok := item.(Focusable); ok {
911853 f.Blur()
912− l.dirtyItems[item.ID()] = true
854+ l.dirtyItems[l.selectedIdx] = true
913855 }
914856}
915857
@@ -923,8 +865,8 @@ func (l *List) HandleMouseDown(x, y int) bool {
923865 bufferY := y + l.offset
924866
925867 // Find which item was clicked
926− itemID, itemY := l.findItemAtPosition(bufferY)
927− if itemID == "" {
868+ itemIdx, itemY := l.findItemAtPosition(bufferY)
869+ if itemIdx < 0 {
928870 return false
929871 }
930872
@@ -933,17 +875,15 @@ func (l *List) HandleMouseDown(x, y int) bool {
933875 // Items can interpret this as character offset in their content
934876
935877 l.mouseDown = true
936− l.mouseDownItem = itemID
878+ l.mouseDownItem = itemIdx
937879 l.mouseDownX = x
938880 l.mouseDownY = itemY
939− l.mouseDragItem = itemID
881+ l.mouseDragItem = itemIdx
940882 l.mouseDragX = x
941883 l.mouseDragY = itemY
942884
943885 // Select the clicked item
944− if idx, ok := l.indexMap[itemID]; ok {
945− l.SetSelectedIndex(idx)
946− }
886+ l.SetSelected(itemIdx)
947887
948888 return true
949889}
@@ -962,12 +902,12 @@ func (l *List) HandleMouseDrag(x, y int) bool {
962902 bufferY := y + l.offset
963903
964904 // Find which item we're dragging over
965− itemID, itemY := l.findItemAtPosition(bufferY)
966− if itemID == "" {
905+ itemIdx, itemY := l.findItemAtPosition(bufferY)
906+ if itemIdx < 0 {
967907 return false
968908 }
969909
970− l.mouseDragItem = itemID
910+ l.mouseDragItem = itemIdx
971911 l.mouseDragX = x
972912 l.mouseDragY = itemY
973913
@@ -994,47 +934,46 @@ func (l *List) HandleMouseUp(x, y int) bool {
994934
995935// ClearHighlight clears any active text highlighting.
996936func (l *List) ClearHighlight() {
997− for _, item := range l.items {
937+ for i, item := range l.items {
998938 if h, ok := item.(Highlightable); ok {
999939 h.SetHighlight(-1, -1, -1, -1)
1000− l.dirtyItems[item.ID()] = true
940+ l.dirtyItems[i] = true
1001941 }
1002942 }
943+ l.mouseDownItem = -1
944+ l.mouseDragItem = -1
1003945}
1004946
1005947// findItemAtPosition finds the item at the given master buffer y coordinate.
1006−// Returns the item ID and the y offset within that item.
1007−func (l *List) findItemAtPosition(bufferY int) (itemID string, itemY int) {
948+// Returns the item index and the y offset within that item. It returns -1, -1
949+// if no item is found.
950+func (l *List) findItemAtPosition(bufferY int) (itemIdx int, itemY int) {
1008951 if bufferY < 0 || bufferY >= l.totalHeight {
1009− return "", 0
952+ return -1, -1
1010953 }
1011954
1012955 // Linear search through items to find which one contains this y
1013956 // This could be optimized with binary search if needed
1014− for _, item := range l.items {
1015− pos, ok := l.itemPositions[item.ID()]
1016− if !ok {
1017− continue
1018− }
1019−
957+ for i := range l.items {
958+ pos := l.itemPositions[i]
1020959 if bufferY >= pos.startLine && bufferY < pos.startLine+pos.height {
1021− return item.ID(), bufferY - pos.startLine
960+ return i, bufferY - pos.startLine
1022961 }
1023962 }
1024963
1025− return "", 0
964+ return -1, -1
1026965}
1027966
1028967// updateHighlight updates the highlight range for highlightable items.
1029968// Supports highlighting across multiple items and respects drag direction.
1030969func (l *List) updateHighlight() {
1031− if l.mouseDownItem == "" {
970+ if l.mouseDownItem < 0 {
1032971 return
1033972 }
1034973
1035974 // Get start and end item indices
1036− downItemIdx := l.indexMap[l.mouseDownItem]
1037− dragItemIdx := l.indexMap[l.mouseDragItem]
975+ downItemIdx := l.mouseDownItem
976+ dragItemIdx := l.mouseDragItem
1038977
1039978 // Determine selection direction
1040979 draggingDown := dragItemIdx > downItemIdx ||
@@ -1064,10 +1003,10 @@ func (l *List) updateHighlight() {
10641003 }
10651004
10661005 // Clear all highlights first
1067− for _, item := range l.items {
1006+ for i, item := range l.items {
10681007 if h, ok := item.(Highlightable); ok {
10691008 h.SetHighlight(-1, -1, -1, -1)
1070− l.dirtyItems[item.ID()] = true
1009+ l.dirtyItems[i] = true
10711010 }
10721011 }
10731012
@@ -1083,18 +1022,18 @@ func (l *List) updateHighlight() {
10831022 item.SetHighlight(startLine, startCol, endLine, endCol)
10841023 } else if idx == startItemIdx {
10851024 // First item - from start position to end of item
1086− pos := l.itemPositions[l.items[idx].ID()]
1025+ pos := l.itemPositions[idx]
10871026 item.SetHighlight(startLine, startCol, pos.height-1, 9999) // 9999 = end of line
10881027 } else if idx == endItemIdx {
10891028 // Last item - from start of item to end position
10901029 item.SetHighlight(0, 0, endLine, endCol)
10911030 } else {
10921031 // Middle item - fully highlighted
1093− pos := l.itemPositions[l.items[idx].ID()]
1032+ pos := l.itemPositions[idx]
10941033 item.SetHighlight(0, 0, pos.height-1, 9999)
10951034 }
10961035
1097− l.dirtyItems[l.items[idx].ID()] = true
1036+ l.dirtyItems[idx] = true
10981037 }
10991038}
11001039
@@ -1110,7 +1049,7 @@ func (l *List) GetHighlightedText() string {
11101049 var result strings.Builder
11111050
11121051 // Iterate through items to find highlighted ones
1113− for _, item := range l.items {
1052+ for i, item := range l.items {
11141053 h, ok := item.(Highlightable)
11151054 if !ok {
11161055 continue
@@ -1121,10 +1060,7 @@ func (l *List) GetHighlightedText() string {
11211060 continue
11221061 }
11231062
1124− pos, ok := l.itemPositions[item.ID()]
1125− if !ok {
1126− continue
1127− }
1063+ pos := l.itemPositions[i]
11281064
11291065 // Extract text from highlighted region in master buffer
11301066 for y := startLine; y <= endLine && y < pos.height; y++ {
internal/ui/list/list_test.go+46−54
@@ -11,9 +11,9 @@ import (
1111
1212func TestNewList(t *testing.T) {
1313 items := []Item{
14− NewStringItem("1", "Item 1"),
15− NewStringItem("2", "Item 2"),
16− NewStringItem("3", "Item 3"),
14+ NewStringItem("Item 1"),
15+ NewStringItem("Item 2"),
16+ NewStringItem("Item 3"),
1717 }
1818
1919 l := New(items...)
@@ -30,9 +30,9 @@ func TestNewList(t *testing.T) {
3030
3131func TestListDraw(t *testing.T) {
3232 items := []Item{
33− NewStringItem("1", "Item 1"),
34− NewStringItem("2", "Item 2"),
35− NewStringItem("3", "Item 3"),
33+ NewStringItem("Item 1"),
34+ NewStringItem("Item 2"),
35+ NewStringItem("Item 3"),
3636 }
3737
3838 l := New(items...)
@@ -54,52 +54,44 @@ func TestListDraw(t *testing.T) {
5454
5555func TestListAppendItem(t *testing.T) {
5656 items := []Item{
57− NewStringItem("1", "Item 1"),
57+ NewStringItem("Item 1"),
5858 }
5959
6060 l := New(items...)
61− l.AppendItem(NewStringItem("2", "Item 2"))
61+ l.AppendItem(NewStringItem("Item 2"))
6262
6363 if len(l.items) != 2 {
6464 t.Errorf("expected 2 items after append, got %d", len(l.items))
6565 }
66−
67− if l.items[1].ID() != "2" {
68− t.Errorf("expected item ID '2', got '%s'", l.items[1].ID())
69− }
7066}
7167
7268func TestListDeleteItem(t *testing.T) {
7369 items := []Item{
74− NewStringItem("1", "Item 1"),
75− NewStringItem("2", "Item 2"),
76− NewStringItem("3", "Item 3"),
70+ NewStringItem("Item 1"),
71+ NewStringItem("Item 2"),
72+ NewStringItem("Item 3"),
7773 }
7874
7975 l := New(items...)
80− l.DeleteItem("2")
76+ l.DeleteItem(2)
8177
8278 if len(l.items) != 2 {
8379 t.Errorf("expected 2 items after delete, got %d", len(l.items))
8480 }
85−
86− if l.items[1].ID() != "3" {
87− t.Errorf("expected item ID '3', got '%s'", l.items[1].ID())
88− }
8981}
9082
9183func TestListUpdateItem(t *testing.T) {
9284 items := []Item{
93− NewStringItem("1", "Item 1"),
94− NewStringItem("2", "Item 2"),
85+ NewStringItem("Item 1"),
86+ NewStringItem("Item 2"),
9587 }
9688
9789 l := New(items...)
9890 l.SetSize(80, 10)
9991
10092 // Update item
101− newItem := NewStringItem("2", "Updated Item 2")
102− l.UpdateItem("2", newItem)
93+ newItem := NewStringItem("Updated Item 2")
94+ l.UpdateItem(1, newItem)
10395
10496 if l.items[1].(*StringItem).content != "Updated Item 2" {
10597 t.Errorf("expected updated content, got '%s'", l.items[1].(*StringItem).content)
@@ -108,13 +100,13 @@ func TestListUpdateItem(t *testing.T) {
108100
109101func TestListSelection(t *testing.T) {
110102 items := []Item{
111− NewStringItem("1", "Item 1"),
112− NewStringItem("2", "Item 2"),
113− NewStringItem("3", "Item 3"),
103+ NewStringItem("Item 1"),
104+ NewStringItem("Item 2"),
105+ NewStringItem("Item 3"),
114106 }
115107
116108 l := New(items...)
117− l.SetSelectedIndex(0)
109+ l.SetSelected(0)
118110
119111 if l.SelectedIndex() != 0 {
120112 t.Errorf("expected selected index 0, got %d", l.SelectedIndex())
@@ -133,11 +125,11 @@ func TestListSelection(t *testing.T) {
133125
134126func TestListScrolling(t *testing.T) {
135127 items := []Item{
136− NewStringItem("1", "Item 1"),
137− NewStringItem("2", "Item 2"),
138− NewStringItem("3", "Item 3"),
139− NewStringItem("4", "Item 4"),
140− NewStringItem("5", "Item 5"),
128+ NewStringItem("Item 1"),
129+ NewStringItem("Item 2"),
130+ NewStringItem("Item 3"),
131+ NewStringItem("Item 4"),
132+ NewStringItem("Item 5"),
141133 }
142134
143135 l := New(items...)
@@ -208,7 +200,7 @@ func TestListFocus(t *testing.T) {
208200
209201 l := New(items...)
210202 l.SetSize(80, 10)
211− l.SetSelectedIndex(0)
203+ l.SetSelected(0)
212204
213205 // Focus the list
214206 l.Focus()
@@ -256,12 +248,12 @@ func TestFocusNavigationAfterAppendingToViewportHeight(t *testing.T) {
256248
257249 // Start with one item
258250 items := []Item{
259− NewStringItem("1", "Item 1").WithFocusStyles(&focusStyle, &blurStyle),
251+ NewStringItem("Item 1").WithFocusStyles(&focusStyle, &blurStyle),
260252 }
261253
262254 l := New(items...)
263255 l.SetSize(20, 15) // 15 lines viewport height
264− l.SetSelectedIndex(0)
256+ l.SetSelected(0)
265257 l.Focus()
266258
267259 // Initial draw to build buffer
@@ -271,12 +263,12 @@ func TestFocusNavigationAfterAppendingToViewportHeight(t *testing.T) {
271263 // Append items until we exceed viewport height
272264 // Each focusable item with border is 5 lines tall
273265 for i := 2; i <= 4; i++ {
274− item := NewStringItem(string(rune('0'+i)), "Item "+string(rune('0'+i))).WithFocusStyles(&focusStyle, &blurStyle)
266+ item := NewStringItem("Item "+string(rune('0'+i))).WithFocusStyles(&focusStyle, &blurStyle)
275267 l.AppendItem(item)
276268 }
277269
278270 // Select the last item
279− l.SetSelectedIndex(3)
271+ l.SetSelected(3)
280272
281273 // Draw
282274 screen = uv.NewScreenBuffer(20, 15)
@@ -318,7 +310,7 @@ func TestFocusableItemUpdate(t *testing.T) {
318310 BorderForeground(lipgloss.Color("240"))
319311
320312 // Create a focusable item
321− item := NewStringItem("1", "Test Item").WithFocusStyles(&focusStyle, &blurStyle)
313+ item := NewStringItem("Test Item").WithFocusStyles(&focusStyle, &blurStyle)
322314
323315 // Initially not focused - render with blur style
324316 screen1 := uv.NewScreenBuffer(20, 5)
@@ -369,14 +361,14 @@ func TestFocusableItemHeightWithBorder(t *testing.T) {
369361 Border(lipgloss.RoundedBorder())
370362
371363 // Item without styles has height 1
372− plainItem := NewStringItem("1", "Test")
364+ plainItem := NewStringItem("Test")
373365 plainHeight := plainItem.Height(20)
374366 if plainHeight != 1 {
375367 t.Errorf("expected plain height 1, got %d", plainHeight)
376368 }
377369
378370 // Item with border should add border height (2 lines)
379− item := NewStringItem("2", "Test").WithFocusStyles(&borderStyle, &borderStyle)
371+ item := NewStringItem("Test").WithFocusStyles(&borderStyle, &borderStyle)
380372 itemHeight := item.Height(20)
381373 expectedHeight := 1 + 2 // content + border
382374 if itemHeight != expectedHeight {
@@ -396,14 +388,14 @@ func TestFocusableItemInList(t *testing.T) {
396388
397389 // Create list with focusable items
398390 items := []Item{
399− NewStringItem("1", "Item 1").WithFocusStyles(&focusStyle, &blurStyle),
400− NewStringItem("2", "Item 2").WithFocusStyles(&focusStyle, &blurStyle),
401− NewStringItem("3", "Item 3").WithFocusStyles(&focusStyle, &blurStyle),
391+ NewStringItem("Item 1").WithFocusStyles(&focusStyle, &blurStyle),
392+ NewStringItem("Item 2").WithFocusStyles(&focusStyle, &blurStyle),
393+ NewStringItem("Item 3").WithFocusStyles(&focusStyle, &blurStyle),
402394 }
403395
404396 l := New(items...)
405397 l.SetSize(80, 20)
406− l.SetSelectedIndex(0)
398+ l.SetSelected(0)
407399
408400 // Focus the list
409401 l.Focus()
@@ -421,7 +413,7 @@ func TestFocusableItemInList(t *testing.T) {
421413 }
422414
423415 // Select second item
424− l.SetSelectedIndex(1)
416+ l.SetSelected(1)
425417
426418 // First item should be blurred, second focused
427419 if firstItem.IsFocused() {
@@ -447,7 +439,7 @@ func TestFocusableItemInList(t *testing.T) {
447439
448440func TestFocusableItemWithNilStyles(t *testing.T) {
449441 // Test with nil styles - should render inner item directly
450− item := NewStringItem("1", "Plain Item").WithFocusStyles(nil, nil)
442+ item := NewStringItem("Plain Item").WithFocusStyles(nil, nil)
451443
452444 // Height should be based on content (no border since styles are nil)
453445 itemHeight := item.Height(20)
@@ -488,7 +480,7 @@ func TestFocusableItemWithOnlyFocusStyle(t *testing.T) {
488480 Border(lipgloss.RoundedBorder()).
489481 BorderForeground(lipgloss.Color("86"))
490482
491− item := NewStringItem("1", "Test").WithFocusStyles(&focusStyle, nil)
483+ item := NewStringItem("Test").WithFocusStyles(&focusStyle, nil)
492484
493485 // When not focused, should use nil blur style (no border)
494486 screen1 := uv.NewScreenBuffer(20, 5)
@@ -519,15 +511,15 @@ func TestFocusableItemLastLineNotEaten(t *testing.T) {
519511 BorderForeground(lipgloss.Color("240"))
520512
521513 items := []Item{
522− NewStringItem("1", "Item 1").WithFocusStyles(&focusStyle, &blurStyle),
514+ NewStringItem("Item 1").WithFocusStyles(&focusStyle, &blurStyle),
523515 Gap,
524− NewStringItem("2", "Item 2").WithFocusStyles(&focusStyle, &blurStyle),
516+ NewStringItem("Item 2").WithFocusStyles(&focusStyle, &blurStyle),
525517 Gap,
526− NewStringItem("3", "Item 3").WithFocusStyles(&focusStyle, &blurStyle),
518+ NewStringItem("Item 3").WithFocusStyles(&focusStyle, &blurStyle),
527519 Gap,
528− NewStringItem("4", "Item 4").WithFocusStyles(&focusStyle, &blurStyle),
520+ NewStringItem("Item 4").WithFocusStyles(&focusStyle, &blurStyle),
529521 Gap,
530− NewStringItem("5", "Item 5").WithFocusStyles(&focusStyle, &blurStyle),
522+ NewStringItem("Item 5").WithFocusStyles(&focusStyle, &blurStyle),
531523 }
532524
533525 // Items with padding(1) and border are 5 lines each
@@ -543,7 +535,7 @@ func TestFocusableItemLastLineNotEaten(t *testing.T) {
543535 l.Focus()
544536
545537 // Select last item
546− l.SetSelectedIndex(len(items) - 1)
538+ l.SetSelected(len(items) - 1)
547539
548540 // Scroll to bottom
549541 l.ScrollToBottom()
internal/ui/model/chat.go+10−21
@@ -11,7 +11,6 @@ import (
1111 "github.com/charmbracelet/crush/internal/ui/list"
1212 "github.com/charmbracelet/crush/internal/ui/styles"
1313 uv "github.com/charmbracelet/ultraviolet"
14− "github.com/google/uuid"
1514)
1615
1716// ChatAnimItem represents a chat animation item in the chat UI.
@@ -57,20 +56,15 @@ func (c *ChatAnimItem) Height(int) int {
5756 return 1
5857}
5958
60−// ID implements list.Item.
61−func (c *ChatAnimItem) ID() string {
62− return "anim"
63−}
64−
6559// ChatNoContentItem represents a chat item with no content.
6660type ChatNoContentItem struct {
6761 *list.StringItem
6862}
6963
7064// NewChatNoContentItem creates a new instance of [ChatNoContentItem].
71−func NewChatNoContentItem(t *styles.Styles, id string) *ChatNoContentItem {
65+func NewChatNoContentItem(t *styles.Styles) *ChatNoContentItem {
7266 c := new(ChatNoContentItem)
73− c.StringItem = list.NewStringItem(id, "No message content").
67+ c.StringItem = list.NewStringItem("No message content").
7468 WithFocusStyles(&t.Chat.NoContentMessage, &t.Chat.NoContentMessage)
7569 return c
7670}
@@ -93,7 +87,7 @@ func NewChatMessageItem(t *styles.Styles, msg message.Message) *ChatMessageItem
9387
9488 switch msg.Role {
9589 case message.User:
96− item := list.NewMarkdownItem(msg.ID, msg.Content().String()).
90+ item := list.NewMarkdownItem(msg.Content().String()).
9791 WithFocusStyles(&t.Chat.UserMessageFocused, &t.Chat.UserMessageBlurred)
9892 item.SetHighlightStyle(list.LipglossStyleToCellStyler(t.TextSelection))
9993 // TODO: Add attachments
@@ -113,7 +107,7 @@ func NewChatMessageItem(t *styles.Styles, msg message.Message) *ChatMessageItem
113107 details := t.Chat.ErrorDetails.Render(finishedData.Details)
114108 errContent := fmt.Sprintf("%s %s\n\n%s", tag, title, details)
115109
116− item := list.NewStringItem(msg.ID, errContent).
110+ item := list.NewStringItem(errContent).
117111 WithFocusStyles(&t.Chat.AssistantMessageFocused, &t.Chat.AssistantMessageBlurred)
118112
119113 c.item = item
@@ -141,7 +135,7 @@ func NewChatMessageItem(t *styles.Styles, msg message.Message) *ChatMessageItem
141135 parts = append(parts, content)
142136 }
143137
144− item := list.NewMarkdownItem(msg.ID, strings.Join(parts, "\n")).
138+ item := list.NewMarkdownItem(strings.Join(parts, "\n")).
145139 WithFocusStyles(&t.Chat.AssistantMessageFocused, &t.Chat.AssistantMessageBlurred)
146140 item.SetHighlightStyle(list.LipglossStyleToCellStyler(t.TextSelection))
147141
@@ -161,11 +155,6 @@ func (c *ChatMessageItem) Height(width int) int {
161155 return c.item.Height(width)
162156}
163157
164−// ID implements list.Item.
165−func (c *ChatMessageItem) ID() string {
166− return c.item.ID()
167−}
168−
169158// Blur implements list.Focusable.
170159func (c *ChatMessageItem) Blur() {
171160 if blurable, ok := c.item.(list.Focusable); ok {
@@ -248,7 +237,7 @@ func (m *Chat) PrependItem(item list.Item) {
248237// AppendMessage appends a new message item to the chat list.
249238func (m *Chat) AppendMessage(msg message.Message) {
250239 if msg.ID == "" {
251− m.AppendItem(NewChatNoContentItem(m.com.Styles, uuid.NewString()))
240+ m.AppendItem(NewChatNoContentItem(m.com.Styles))
252241 } else {
253242 m.AppendItem(NewChatMessageItem(m.com.Styles, msg))
254243 }
@@ -258,7 +247,7 @@ func (m *Chat) AppendMessage(msg message.Message) {
258247func (m *Chat) AppendItem(item list.Item) {
259248 if m.Len() > 0 {
260249 // Always add a spacer between messages
261− m.list.AppendItem(list.NewSpacerItem(uuid.NewString(), 1))
250+ m.list.AppendItem(list.NewSpacerItem(1))
262251 }
263252 m.list.AppendItem(item)
264253}
@@ -298,9 +287,9 @@ func (m *Chat) SelectedItemInView() bool {
298287 return m.list.SelectedItemInView()
299288}
300289
301−// SetSelectedIndex sets the selected message index in the chat list.
302−func (m *Chat) SetSelectedIndex(index int) {
303− m.list.SetSelectedIndex(index)
290+// SetSelected sets the selected message index in the chat list.
291+func (m *Chat) SetSelected(index int) {
292+ m.list.SetSelected(index)
304293}
305294
306295// SelectPrev selects the previous message in the chat list.
internal/ui/model/ui.go+1−1
@@ -326,7 +326,7 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) (cmds []tea.Cmd) {
326326 m.focus = uiFocusMain
327327 m.textarea.Blur()
328328 m.chat.Focus()
329− m.chat.SetSelectedIndex(m.chat.Len() - 1)
329+ m.chat.SetSelected(m.chat.Len() - 1)
330330 }
331331 case key.Matches(msg, m.keyMap.Chat.Up):
332332 m.chat.ScrollBy(-1)