From a80d6c609feaceb07a9e113e26c8827d8f7c532f Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Mon, 21 Jul 2025 18:48:01 +0200 Subject: [PATCH] wip: add to messages list --- internal/tui/components/chat/chat.go | 44 +++++----- .../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 ++++++++++++ .../should_append_an_item_to_the_end.golden | 10 +++ ...the_selected_if_we_moved_the_offset.golden | 10 +++ 10 files changed, 212 insertions(+), 34 deletions(-) create mode 100644 internal/tui/exp/list/testdata/TestListChanges/should_append_an_item_to_the_end.golden create mode 100644 internal/tui/exp/list/testdata/TestListChanges/should_should_not_change_the_selected_if_we_moved_the_offset.golden diff --git a/internal/tui/components/chat/chat.go b/internal/tui/components/chat/chat.go index 8d857ea38463e9d61dc25794e492b33cab0b487b..044241e295afb34a27b462d721130a3ed638ba00 100644 --- a/internal/tui/components/chat/chat.go +++ b/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) { diff --git a/internal/tui/components/chat/messages/messages.go b/internal/tui/components/chat/messages/messages.go index d5aca88108cad83115cad5bd046c72e146935f78..9f70691aa9843b8d823b26be247636b31212d2eb 100644 --- a/internal/tui/components/chat/messages/messages.go +++ b/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 +} diff --git a/internal/tui/components/chat/messages/tool.go b/internal/tui/components/chat/messages/tool.go index 90ced40eeb54c0509dae9e74775462a179e0ad28..2f639c5c5d192ba9c59402976e552462d8ebcd0b 100644 --- a/internal/tui/components/chat/messages/tool.go +++ b/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 +} diff --git a/internal/tui/exp/list/filterable.go b/internal/tui/exp/list/filterable.go index 4e2ac9a3e87766efc95a022db3d0adddb15a7544..cc2d0e1264621b10efd6df03916f5ccd3e70987e 100644 --- a/internal/tui/exp/list/filterable.go +++ b/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]) diff --git a/internal/tui/exp/list/filterable_test.go b/internal/tui/exp/list/filterable_test.go index 688058cbaa404d378210f815e276cef78254e296..cb88c70fe2e2f86fc3bd648f20f2591f5eb6581d 100644 --- a/internal/tui/exp/list/filterable_test.go +++ b/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{} diff --git a/internal/tui/exp/list/keys.go b/internal/tui/exp/list/keys.go index 271ad1a8e644f2ecd44d5d76e8af6a9b513abab3..ba0f6cec97ed1d0cdc91ff70f69a8f2e1cd386d7 100644 --- a/internal/tui/exp/list/keys.go +++ b/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, + } +} diff --git a/internal/tui/exp/list/list.go b/internal/tui/exp/list/list.go index 94a9e13e0904c9df0a1477b5674738b33425fc81..98082d3c0dd4f9ff99212cd3700685810c540ace 100644 --- a/internal/tui/exp/list/list.go +++ b/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 +} diff --git a/internal/tui/exp/list/list_test.go b/internal/tui/exp/list/list_test.go index 3dd2e94666df982f186474e5a96da5d721e71c2e..6b5c92acd9d302e4bdd63b92cfff4cbb869f6ab4 100644 --- a/internal/tui/exp/list/list_test.go +++ b/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 diff --git a/internal/tui/exp/list/testdata/TestListChanges/should_append_an_item_to_the_end.golden b/internal/tui/exp/list/testdata/TestListChanges/should_append_an_item_to_the_end.golden new file mode 100644 index 0000000000000000000000000000000000000000..fe55231e951955234b57f1c341d2ceecf3101bf0 --- /dev/null +++ b/internal/tui/exp/list/testdata/TestListChanges/should_append_an_item_to_the_end.golden @@ -0,0 +1,10 @@ +Item 11 +Item 12 +Item 13 +Item 14 +Item 15 +Item 16 +Item 17 +Item 18 +Item 19 +│New Item \ No newline at end of file diff --git a/internal/tui/exp/list/testdata/TestListChanges/should_should_not_change_the_selected_if_we_moved_the_offset.golden b/internal/tui/exp/list/testdata/TestListChanges/should_should_not_change_the_selected_if_we_moved_the_offset.golden new file mode 100644 index 0000000000000000000000000000000000000000..5e8610df6e3c2247e7879fb2ba3fa09694ba9d25 --- /dev/null +++ b/internal/tui/exp/list/testdata/TestListChanges/should_should_not_change_the_selected_if_we_moved_the_offset.golden @@ -0,0 +1,10 @@ +Item 15 +Line2 +Item 16 +Line2 +Item 17 +Line2 +Item 18 +Line2 +│Item 19 +│Line2 \ No newline at end of file