wip: add to messages list

Kujtim Hoxha created

Change summary

internal/tui/components/chat/chat.go                                                                               | 44 
internal/tui/components/chat/messages/messages.go                                                                  | 18 
internal/tui/components/chat/messages/tool.go                                                                      |  5 
internal/tui/exp/list/filterable.go                                                                                |  6 
internal/tui/exp/list/filterable_test.go                                                                           |  1 
internal/tui/exp/list/keys.go                                                                                      | 13 
internal/tui/exp/list/list.go                                                                                      | 84 
internal/tui/exp/list/list_test.go                                                                                 | 55 
internal/tui/exp/list/testdata/TestListChanges/should_append_an_item_to_the_end.golden                             | 10 
internal/tui/exp/list/testdata/TestListChanges/should_should_not_change_the_selected_if_we_moved_the_offset.golden | 10 
10 files changed, 212 insertions(+), 34 deletions(-)

Detailed changes

internal/tui/components/chat/chat.go πŸ”—

@@ -13,7 +13,7 @@ import (
 	"github.com/charmbracelet/crush/internal/session"
 	"github.com/charmbracelet/crush/internal/tui/components/chat/messages"
 	"github.com/charmbracelet/crush/internal/tui/components/core/layout"
-	"github.com/charmbracelet/crush/internal/tui/components/core/list"
+	"github.com/charmbracelet/crush/internal/tui/exp/list"
 	"github.com/charmbracelet/crush/internal/tui/styles"
 	"github.com/charmbracelet/crush/internal/tui/util"
 )
@@ -49,8 +49,8 @@ type messageListCmp struct {
 	app              *app.App
 	width, height    int
 	session          session.Session
-	listCmp          list.ListModel
-	previousSelected int // Last selected item index for restoring focus
+	listCmp          list.List[list.Item]
+	previousSelected string // Last selected item index for restoring focus
 
 	lastUserMessageTime int64
 	defaultListKeyMap   list.KeyMap
@@ -61,14 +61,15 @@ type messageListCmp struct {
 func New(app *app.App) MessageListCmp {
 	defaultListKeyMap := list.DefaultKeyMap()
 	listCmp := list.New(
-		list.WithGapSize(1),
-		list.WithReverse(true),
+		[]list.Item{},
+		list.WithGap(1),
+		list.WithDirection(list.Backward),
 		list.WithKeyMap(defaultListKeyMap),
 	)
 	return &messageListCmp{
 		app:               app,
 		listCmp:           listCmp,
-		previousSelected:  list.NoSelection,
+		previousSelected:  "",
 		defaultListKeyMap: defaultListKeyMap,
 	}
 }
@@ -89,7 +90,7 @@ func (m *messageListCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		return m, nil
 	case SessionClearedMsg:
 		m.session = session.Session{}
-		return m, m.listCmp.SetItems([]util.Model{})
+		return m, m.listCmp.SetItems([]list.Item{})
 
 	case pubsub.Event[message.Message]:
 		cmd := m.handleMessageEvent(msg)
@@ -97,7 +98,7 @@ func (m *messageListCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	default:
 		var cmds []tea.Cmd
 		u, cmd := m.listCmp.Update(msg)
-		m.listCmp = u.(list.ListModel)
+		m.listCmp = u.(list.List[list.Item])
 		cmds = append(cmds, cmd)
 		return m, tea.Batch(cmds...)
 	}
@@ -169,7 +170,7 @@ func (m *messageListCmp) handleChildSession(event pubsub.Event[message.Message])
 
 	toolCall.SetNestedToolCalls(nestedToolCalls)
 	m.listCmp.UpdateItem(
-		toolCallInx,
+		toolCall.ID(),
 		toolCall,
 	)
 	return tea.Batch(cmds...)
@@ -233,7 +234,7 @@ func (m *messageListCmp) handleToolMessage(msg message.Message) tea.Cmd {
 		if toolCallIndex := m.findToolCallByID(items, tr.ToolCallID); toolCallIndex != NotFound {
 			toolCall := items[toolCallIndex].(messages.ToolCallCmp)
 			toolCall.SetToolResult(tr)
-			m.listCmp.UpdateItem(toolCallIndex, toolCall)
+			m.listCmp.UpdateItem(toolCall.ID(), toolCall)
 		}
 	}
 	return nil
@@ -241,7 +242,7 @@ func (m *messageListCmp) handleToolMessage(msg message.Message) tea.Cmd {
 
 // findToolCallByID searches for a tool call with the specified ID.
 // Returns the index if found, NotFound otherwise.
-func (m *messageListCmp) findToolCallByID(items []util.Model, toolCallID string) int {
+func (m *messageListCmp) findToolCallByID(items []list.Item, toolCallID string) int {
 	// Search backwards as tool calls are more likely to be recent
 	for i := len(items) - 1; i >= 0; i-- {
 		if toolCall, ok := items[i].(messages.ToolCallCmp); ok && toolCall.GetToolCall().ID == toolCallID {
@@ -274,7 +275,7 @@ func (m *messageListCmp) handleUpdateAssistantMessage(msg message.Message) tea.C
 }
 
 // findAssistantMessageAndToolCalls locates the assistant message and its tool calls.
-func (m *messageListCmp) findAssistantMessageAndToolCalls(items []util.Model, messageID string) (int, map[int]messages.ToolCallCmp) {
+func (m *messageListCmp) findAssistantMessageAndToolCalls(items []list.Item, messageID string) (int, map[int]messages.ToolCallCmp) {
 	assistantIndex := NotFound
 	toolCalls := make(map[int]messages.ToolCallCmp)
 
@@ -310,7 +311,7 @@ func (m *messageListCmp) updateAssistantMessageContent(msg message.Message, assi
 		uiMsg := items[assistantIndex].(messages.MessageCmp)
 		uiMsg.SetMessage(msg)
 		m.listCmp.UpdateItem(
-			assistantIndex,
+			items[assistantIndex].ID(),
 			uiMsg,
 		)
 		if msg.FinishPart() != nil && msg.FinishPart().Reason == message.FinishReasonEndTurn {
@@ -322,7 +323,8 @@ func (m *messageListCmp) updateAssistantMessageContent(msg message.Message, assi
 			)
 		}
 	} else if hasToolCallsOnly {
-		m.listCmp.DeleteItem(assistantIndex)
+		items := m.listCmp.Items()
+		m.listCmp.DeleteItem(items[assistantIndex].ID())
 	}
 
 	return cmd
@@ -349,13 +351,13 @@ func (m *messageListCmp) updateToolCalls(msg message.Message, existingToolCalls
 // updateOrAddToolCall updates an existing tool call or adds a new one.
 func (m *messageListCmp) updateOrAddToolCall(msg message.Message, tc message.ToolCall, existingToolCalls map[int]messages.ToolCallCmp) tea.Cmd {
 	// Try to find existing tool call
-	for index, existingTC := range existingToolCalls {
+	for _, existingTC := range existingToolCalls {
 		if tc.ID == existingTC.GetToolCall().ID {
 			existingTC.SetToolCall(tc)
 			if msg.FinishPart() != nil && msg.FinishPart().Reason == message.FinishReasonCanceled {
 				existingTC.SetCancelled()
 			}
-			m.listCmp.UpdateItem(index, existingTC)
+			m.listCmp.UpdateItem(tc.ID, existingTC)
 			return nil
 		}
 	}
@@ -400,7 +402,7 @@ func (m *messageListCmp) SetSession(session session.Session) tea.Cmd {
 	}
 
 	if len(sessionMessages) == 0 {
-		return m.listCmp.SetItems([]util.Model{})
+		return m.listCmp.SetItems([]list.Item{})
 	}
 
 	// Initialize with first message timestamp
@@ -427,8 +429,8 @@ func (m *messageListCmp) buildToolResultMap(messages []message.Message) map[stri
 }
 
 // convertMessagesToUI converts database messages to UI components.
-func (m *messageListCmp) convertMessagesToUI(sessionMessages []message.Message, toolResultMap map[string]message.ToolResult) []util.Model {
-	uiMessages := make([]util.Model, 0)
+func (m *messageListCmp) convertMessagesToUI(sessionMessages []message.Message, toolResultMap map[string]message.ToolResult) []list.Item {
+	uiMessages := make([]list.Item, 0)
 
 	for _, msg := range sessionMessages {
 		switch msg.Role {
@@ -447,8 +449,8 @@ func (m *messageListCmp) convertMessagesToUI(sessionMessages []message.Message,
 }
 
 // convertAssistantMessage converts an assistant message and its tool calls to UI components.
-func (m *messageListCmp) convertAssistantMessage(msg message.Message, toolResultMap map[string]message.ToolResult) []util.Model {
-	var uiMessages []util.Model
+func (m *messageListCmp) convertAssistantMessage(msg message.Message, toolResultMap map[string]message.ToolResult) []list.Item {
+	var uiMessages []list.Item
 
 	// Add assistant message if it should be displayed
 	if m.shouldShowAssistantMessage(msg) {

internal/tui/components/chat/messages/messages.go πŸ”—

@@ -11,13 +11,14 @@ import (
 	"github.com/charmbracelet/catwalk/pkg/catwalk"
 	"github.com/charmbracelet/lipgloss/v2"
 	"github.com/charmbracelet/x/ansi"
+	"github.com/google/uuid"
 
 	"github.com/charmbracelet/crush/internal/config"
 	"github.com/charmbracelet/crush/internal/message"
 	"github.com/charmbracelet/crush/internal/tui/components/anim"
 	"github.com/charmbracelet/crush/internal/tui/components/core"
 	"github.com/charmbracelet/crush/internal/tui/components/core/layout"
-	"github.com/charmbracelet/crush/internal/tui/components/core/list"
+	"github.com/charmbracelet/crush/internal/tui/exp/list"
 	"github.com/charmbracelet/crush/internal/tui/styles"
 	"github.com/charmbracelet/crush/internal/tui/util"
 )
@@ -31,6 +32,7 @@ type MessageCmp interface {
 	GetMessage() message.Message    // Access to underlying message data
 	SetMessage(msg message.Message) // Update the message content
 	Spinning() bool                 // Animation state for loading messages
+	ID() string
 }
 
 // messageCmp implements the MessageCmp interface for displaying chat messages.
@@ -333,19 +335,25 @@ func (m *messageCmp) Spinning() bool {
 }
 
 type AssistantSection interface {
-	util.Model
+	list.Item
 	layout.Sizeable
-	list.SectionHeader
 }
 type assistantSectionModel struct {
 	width               int
+	id                  string
 	message             message.Message
 	lastUserMessageTime time.Time
 }
 
+// ID implements AssistantSection.
+func (m *assistantSectionModel) ID() string {
+	return m.id
+}
+
 func NewAssistantSection(message message.Message, lastUserMessageTime time.Time) AssistantSection {
 	return &assistantSectionModel{
 		width:               0,
+		id:                  uuid.NewString(),
 		message:             message,
 		lastUserMessageTime: lastUserMessageTime,
 	}
@@ -392,3 +400,7 @@ func (m *assistantSectionModel) SetSize(width int, height int) tea.Cmd {
 func (m *assistantSectionModel) IsSectionHeader() bool {
 	return true
 }
+
+func (m *messageCmp) ID() string {
+	return m.message.ID
+}

internal/tui/components/chat/messages/tool.go πŸ”—

@@ -29,6 +29,7 @@ type ToolCallCmp interface {
 	GetNestedToolCalls() []ToolCallCmp // Get nested tool calls
 	SetNestedToolCalls([]ToolCallCmp)  // Set nested tool calls
 	SetIsNested(bool)                  // Set whether this tool call is nested
+	ID() string
 }
 
 // toolCallCmp implements the ToolCallCmp interface for displaying tool calls.
@@ -311,3 +312,7 @@ func (m *toolCallCmp) Spinning() bool {
 	}
 	return m.spinning
 }
+
+func (m *toolCallCmp) ID() string {
+	return m.call.ID
+}

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

@@ -38,7 +38,7 @@ type filterableOptions struct {
 }
 type filterableList[T FilterableItem] struct {
 	*list[T]
-	filterableOptions
+	*filterableOptions
 	width, height int
 	// stores all available items
 	items      []T
@@ -83,13 +83,13 @@ func NewFilterableList[T FilterableItem](items []T, opts ...filterableListOption
 	t := styles.CurrentTheme()
 
 	f := &filterableList[T]{
-		filterableOptions: filterableOptions{
+		filterableOptions: &filterableOptions{
 			inputStyle:  t.S().Base,
 			placeholder: "Type to filter",
 		},
 	}
 	for _, opt := range opts {
-		opt(&f.filterableOptions)
+		opt(f.filterableOptions)
 	}
 	f.list = New[T](items, f.listOptions...).(*list[T])
 

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

@@ -10,6 +10,7 @@ import (
 )
 
 func TestFilterableList(t *testing.T) {
+	t.Parallel()
 	t.Run("should create simple filterable list", func(t *testing.T) {
 		t.Parallel()
 		items := []FilterableItem{}

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

@@ -61,3 +61,16 @@ func DefaultKeyMap() KeyMap {
 		),
 	}
 }
+
+func (k KeyMap) KeyBindings() []key.Binding {
+	return []key.Binding{
+		k.Down,
+		k.Up,
+		k.DownOneItem,
+		k.UpOneItem,
+		k.HalfPageDown,
+		k.HalfPageUp,
+		k.Home,
+		k.End,
+	}
+}

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

@@ -1,6 +1,7 @@
 package list
 
 import (
+	"slices"
 	"strings"
 
 	"github.com/charmbracelet/bubbles/v2/key"
@@ -29,6 +30,11 @@ type List[T Item] interface {
 	SetItems([]T) tea.Cmd
 	SetSelected(string) tea.Cmd
 	SelectedItem() *T
+	Items() []T
+	UpdateItem(string, T)
+	DeleteItem(string)
+	PrependItem(T) tea.Cmd
+	AppendItem(T) tea.Cmd
 }
 
 type direction int
@@ -63,7 +69,7 @@ type confOptions struct {
 	selectedItem string
 }
 type list[T Item] struct {
-	confOptions
+	*confOptions
 
 	focused       bool
 	offset        int
@@ -118,14 +124,14 @@ func WithWrapNavigation() listOption {
 
 func New[T Item](items []T, opts ...listOption) List[T] {
 	list := &list[T]{
-		confOptions: confOptions{
+		confOptions: &confOptions{
 			direction: Forward,
 			keyMap:    DefaultKeyMap(),
 		},
 		items: items,
 	}
 	for _, opt := range opts {
-		opt(&list.confOptions)
+		opt(list.confOptions)
 	}
 	return list
 }
@@ -343,6 +349,7 @@ func (l *list[T]) firstSelectableItemAfter(inx int) int {
 	return NotFound
 }
 
+// moveToSelected needs to be called after the view is rendered
 func (l *list[T]) moveToSelected(center bool) tea.Cmd {
 	var cmds []tea.Cmd
 	if l.selectedItem == "" || !l.isReady {
@@ -421,8 +428,8 @@ func (l *list[T]) SelectItemAbove() tea.Cmd {
 			break
 		}
 	}
-	l.moveToSelected(false)
 	l.renderView()
+	l.moveToSelected(false)
 	return tea.Batch(cmds...)
 }
 
@@ -457,8 +464,8 @@ func (l *list[T]) SelectItemBelow() tea.Cmd {
 		}
 	}
 
-	l.moveToSelected(false)
 	l.renderView()
+	l.moveToSelected(false)
 	return tea.Batch(cmds...)
 }
 
@@ -605,10 +612,10 @@ func (l *list[T]) SetItems(items []T) tea.Cmd {
 		cmds = append(cmds, item.SetSize(l.width, 0))
 	}
 
+	cmds = append(cmds, l.renderItems())
 	if l.selectedItem != "" {
 		cmds = append(cmds, l.moveToSelected(true))
 	}
-	cmds = append(cmds, l.renderItems())
 	return tea.Batch(cmds...)
 }
 
@@ -691,8 +698,8 @@ func (l *list[T]) SetSelected(id string) tea.Cmd {
 		}
 	}
 	l.selectedItem = id
-	cmds = append(cmds, l.moveToSelected(true))
 	l.renderView()
+	cmds = append(cmds, l.moveToSelected(true))
 	return tea.Batch(cmds...)
 }
 
@@ -709,3 +716,66 @@ func (l *list[T]) SelectedItem() *T {
 func (l *list[T]) IsFocused() bool {
 	return l.focused
 }
+
+func (l *list[T]) Items() []T {
+	return l.items
+}
+
+func (l *list[T]) UpdateItem(id string, item T) {
+	// TODO: preserve offset
+	for inx, item := range l.items {
+		if item.ID() == id {
+			l.items[inx] = item
+			l.renderedItems[inx] = l.renderItem(item)
+			l.renderView()
+			return
+		}
+	}
+}
+
+func (l *list[T]) DeleteItem(id string) {
+	// TODO: preserve offset
+	inx := NotFound
+	for i, item := range l.items {
+		if item.ID() == id {
+			inx = i
+			break
+		}
+	}
+
+	l.items = slices.Delete(l.items, inx, inx+1)
+	l.renderedItems = slices.Delete(l.renderedItems, inx, inx+1)
+	l.renderView()
+}
+
+func (l *list[T]) PrependItem(item T) tea.Cmd {
+	// TODO: preserve offset
+	var cmd tea.Cmd
+	l.items = append([]T{item}, l.items...)
+	l.renderedItems = append([]renderedItem{l.renderItem(item)}, l.renderedItems...)
+	if len(l.items) == 1 {
+		cmd = l.SetSelected(item.ID())
+	}
+	// the viewport did not move and the last item was focused
+	if l.direction == Backward && l.offset == 0 && l.selectedItem == l.items[0].ID() {
+		cmd = l.SetSelected(item.ID())
+	}
+	l.renderView()
+	return cmd
+}
+
+func (l *list[T]) AppendItem(item T) tea.Cmd {
+	// TODO: preserve offset
+	var cmd tea.Cmd
+	l.items = append(l.items, item)
+	l.renderedItems = append(l.renderedItems, l.renderItem(item))
+	if len(l.items) == 1 {
+		cmd = l.SetSelected(item.ID())
+	} else if l.direction == Backward && l.offset == 0 && l.selectedItem == l.items[len(l.items)-2].ID() {
+		// the viewport did not move and the last item was focused
+		cmd = l.SetSelected(item.ID())
+	} else {
+		l.renderView()
+	}
+	return cmd
+}

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

@@ -14,6 +14,7 @@ import (
 )
 
 func TestListPosition(t *testing.T) {
+	t.Parallel()
 	type positionOffsetTest struct {
 		dir      direction
 		test     string
@@ -75,6 +76,7 @@ func TestListPosition(t *testing.T) {
 	}
 	for _, c := range tests {
 		t.Run(c.test, func(t *testing.T) {
+			t.Parallel()
 			items := []Item{}
 			for i := range c.numItems {
 				item := NewSelectableItem(fmt.Sprintf("Item %d", i))
@@ -101,6 +103,7 @@ func TestListPosition(t *testing.T) {
 }
 
 func TestBackwardList(t *testing.T) {
+	t.Parallel()
 	t.Run("within height", func(t *testing.T) {
 		t.Parallel()
 		items := []Item{}
@@ -291,6 +294,7 @@ func TestBackwardList(t *testing.T) {
 }
 
 func TestForwardList(t *testing.T) {
+	t.Parallel()
 	t.Run("within height", func(t *testing.T) {
 		t.Parallel()
 		items := []Item{}
@@ -482,6 +486,7 @@ func TestForwardList(t *testing.T) {
 }
 
 func TestListSelection(t *testing.T) {
+	t.Parallel()
 	t.Run("should skip none selectable items initially", func(t *testing.T) {
 		t.Parallel()
 		items := []Item{}
@@ -553,6 +558,7 @@ func TestListSelection(t *testing.T) {
 }
 
 func TestListSetSelection(t *testing.T) {
+	t.Parallel()
 	t.Run("should move to the selected item", func(t *testing.T) {
 		t.Parallel()
 		items := []Item{}
@@ -577,6 +583,55 @@ func TestListSetSelection(t *testing.T) {
 	})
 }
 
+func TestListChanges(t *testing.T) {
+	t.Parallel()
+	t.Run("should append an item to the end", func(t *testing.T) {
+		t.Parallel()
+		items := []SelectableItem{}
+		for i := range 20 {
+			item := NewSelectableItem(fmt.Sprintf("Item %d", i))
+			items = append(items, item)
+		}
+		l := New(items, WithDirection(Backward)).(*list[SelectableItem])
+		l.SetSize(100, 10)
+		cmd := l.Init()
+		if cmd != nil {
+			cmd()
+		}
+
+		newItem := NewSelectableItem("New Item")
+		l.AppendItem(newItem)
+
+		assert.Equal(t, 21, len(l.items))
+		assert.Equal(t, 21, len(l.renderedItems))
+		assert.Equal(t, newItem.ID(), l.selectedItem)
+		golden.RequireEqual(t, []byte(l.View()))
+	})
+	t.Run("should should not change the selected if we moved the offset", func(t *testing.T) {
+		t.Parallel()
+		items := []SelectableItem{}
+		for i := range 20 {
+			item := NewSelectableItem(fmt.Sprintf("Item %d\nLine2", i))
+			items = append(items, item)
+		}
+		l := New(items, WithDirection(Backward)).(*list[SelectableItem])
+		l.SetSize(100, 10)
+		cmd := l.Init()
+		if cmd != nil {
+			cmd()
+		}
+		l.MoveUp(1)
+
+		newItem := NewSelectableItem("New Item")
+		l.AppendItem(newItem)
+
+		assert.Equal(t, 21, len(l.items))
+		assert.Equal(t, 21, len(l.renderedItems))
+		assert.Equal(t, l.items[19].ID(), l.selectedItem)
+		golden.RequireEqual(t, []byte(l.View()))
+	})
+}
+
 type SelectableItem interface {
 	Item
 	layout.Focusable