on main help sign in
wip: add to messages list
commita80d6c609feaceb07a9e113e26c8827d8f7c532f authorKujtim Hoxha <kujtimii.h@gmail.com> dateMon 21 Jul 2025, 18:48 (1 year ago) parentaab9b3b
10 files changed+212−34
internal/tui/components/chat/chat.go+23−21 internal/tui/components/chat/messages/messages.go+15−3 internal/tui/components/chat/messages/tool.go+5 internal/tui/exp/list/filterable.go+3−3 internal/tui/exp/list/filterable_test.go+1 internal/tui/exp/list/keys.go+13 internal/tui/exp/list/list.go+77−7 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
internal/tui/components/chat/chat.go+23−21
@@ -13,7 +13,7 @@ import (
1313 "github.com/charmbracelet/crush/internal/session"
1414 "github.com/charmbracelet/crush/internal/tui/components/chat/messages"
1515 "github.com/charmbracelet/crush/internal/tui/components/core/layout"
16− "github.com/charmbracelet/crush/internal/tui/components/core/list"
16+ "github.com/charmbracelet/crush/internal/tui/exp/list"
1717 "github.com/charmbracelet/crush/internal/tui/styles"
1818 "github.com/charmbracelet/crush/internal/tui/util"
1919)
@@ -49,8 +49,8 @@ type messageListCmp struct {
4949 app *app.App
5050 width, height int
5151 session session.Session
52− listCmp list.ListModel
53− previousSelected int // Last selected item index for restoring focus
52+ listCmp list.List[list.Item]
53+ previousSelected string // Last selected item index for restoring focus
5454
5555 lastUserMessageTime int64
5656 defaultListKeyMap list.KeyMap
@@ -61,14 +61,15 @@ type messageListCmp struct {
6161func New(app *app.App) MessageListCmp {
6262 defaultListKeyMap := list.DefaultKeyMap()
6363 listCmp := list.New(
64− list.WithGapSize(1),
65− list.WithReverse(true),
64+ []list.Item{},
65+ list.WithGap(1),
66+ list.WithDirection(list.Backward),
6667 list.WithKeyMap(defaultListKeyMap),
6768 )
6869 return &messageListCmp{
6970 app: app,
7071 listCmp: listCmp,
71− previousSelected: list.NoSelection,
72+ previousSelected: "",
7273 defaultListKeyMap: defaultListKeyMap,
7374 }
7475}
@@ -89,7 +90,7 @@ func (m *messageListCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
8990 return m, nil
9091 case SessionClearedMsg:
9192 m.session = session.Session{}
92− return m, m.listCmp.SetItems([]util.Model{})
93+ return m, m.listCmp.SetItems([]list.Item{})
9394
9495 case pubsub.Event[message.Message]:
9596 cmd := m.handleMessageEvent(msg)
@@ -97,7 +98,7 @@ func (m *messageListCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
9798 default:
9899 var cmds []tea.Cmd
99100 u, cmd := m.listCmp.Update(msg)
100− m.listCmp = u.(list.ListModel)
101+ m.listCmp = u.(list.List[list.Item])
101102 cmds = append(cmds, cmd)
102103 return m, tea.Batch(cmds...)
103104 }
@@ -169,7 +170,7 @@ func (m *messageListCmp) handleChildSession(event pubsub.Event[message.Message])
169170
170171 toolCall.SetNestedToolCalls(nestedToolCalls)
171172 m.listCmp.UpdateItem(
172− toolCallInx,
173+ toolCall.ID(),
173174 toolCall,
174175 )
175176 return tea.Batch(cmds...)
@@ -233,7 +234,7 @@ func (m *messageListCmp) handleToolMessage(msg message.Message) tea.Cmd {
233234 if toolCallIndex := m.findToolCallByID(items, tr.ToolCallID); toolCallIndex != NotFound {
234235 toolCall := items[toolCallIndex].(messages.ToolCallCmp)
235236 toolCall.SetToolResult(tr)
236− m.listCmp.UpdateItem(toolCallIndex, toolCall)
237+ m.listCmp.UpdateItem(toolCall.ID(), toolCall)
237238 }
238239 }
239240 return nil
@@ -241,7 +242,7 @@ func (m *messageListCmp) handleToolMessage(msg message.Message) tea.Cmd {
241242
242243// findToolCallByID searches for a tool call with the specified ID.
243244// Returns the index if found, NotFound otherwise.
244−func (m *messageListCmp) findToolCallByID(items []util.Model, toolCallID string) int {
245+func (m *messageListCmp) findToolCallByID(items []list.Item, toolCallID string) int {
245246 // Search backwards as tool calls are more likely to be recent
246247 for i := len(items) - 1; i >= 0; i-- {
247248 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
274275}
275276
276277// findAssistantMessageAndToolCalls locates the assistant message and its tool calls.
277−func (m *messageListCmp) findAssistantMessageAndToolCalls(items []util.Model, messageID string) (int, map[int]messages.ToolCallCmp) {
278+func (m *messageListCmp) findAssistantMessageAndToolCalls(items []list.Item, messageID string) (int, map[int]messages.ToolCallCmp) {
278279 assistantIndex := NotFound
279280 toolCalls := make(map[int]messages.ToolCallCmp)
280281
@@ -310,7 +311,7 @@ func (m *messageListCmp) updateAssistantMessageContent(msg message.Message, assi
310311 uiMsg := items[assistantIndex].(messages.MessageCmp)
311312 uiMsg.SetMessage(msg)
312313 m.listCmp.UpdateItem(
313− assistantIndex,
314+ items[assistantIndex].ID(),
314315 uiMsg,
315316 )
316317 if msg.FinishPart() != nil && msg.FinishPart().Reason == message.FinishReasonEndTurn {
@@ -322,7 +323,8 @@ func (m *messageListCmp) updateAssistantMessageContent(msg message.Message, assi
322323 )
323324 }
324325 } else if hasToolCallsOnly {
325− m.listCmp.DeleteItem(assistantIndex)
326+ items := m.listCmp.Items()
327+ m.listCmp.DeleteItem(items[assistantIndex].ID())
326328 }
327329
328330 return cmd
@@ -349,13 +351,13 @@ func (m *messageListCmp) updateToolCalls(msg message.Message, existingToolCalls
349351// updateOrAddToolCall updates an existing tool call or adds a new one.
350352func (m *messageListCmp) updateOrAddToolCall(msg message.Message, tc message.ToolCall, existingToolCalls map[int]messages.ToolCallCmp) tea.Cmd {
351353 // Try to find existing tool call
352− for index, existingTC := range existingToolCalls {
354+ for _, existingTC := range existingToolCalls {
353355 if tc.ID == existingTC.GetToolCall().ID {
354356 existingTC.SetToolCall(tc)
355357 if msg.FinishPart() != nil && msg.FinishPart().Reason == message.FinishReasonCanceled {
356358 existingTC.SetCancelled()
357359 }
358− m.listCmp.UpdateItem(index, existingTC)
360+ m.listCmp.UpdateItem(tc.ID, existingTC)
359361 return nil
360362 }
361363 }
@@ -400,7 +402,7 @@ func (m *messageListCmp) SetSession(session session.Session) tea.Cmd {
400402 }
401403
402404 if len(sessionMessages) == 0 {
403− return m.listCmp.SetItems([]util.Model{})
405+ return m.listCmp.SetItems([]list.Item{})
404406 }
405407
406408 // Initialize with first message timestamp
@@ -427,8 +429,8 @@ func (m *messageListCmp) buildToolResultMap(messages []message.Message) map[stri
427429}
428430
429431// convertMessagesToUI converts database messages to UI components.
430−func (m *messageListCmp) convertMessagesToUI(sessionMessages []message.Message, toolResultMap map[string]message.ToolResult) []util.Model {
431− uiMessages := make([]util.Model, 0)
432+func (m *messageListCmp) convertMessagesToUI(sessionMessages []message.Message, toolResultMap map[string]message.ToolResult) []list.Item {
433+ uiMessages := make([]list.Item, 0)
432434
433435 for _, msg := range sessionMessages {
434436 switch msg.Role {
@@ -447,8 +449,8 @@ func (m *messageListCmp) convertMessagesToUI(sessionMessages []message.Message,
447449}
448450
449451// convertAssistantMessage converts an assistant message and its tool calls to UI components.
450−func (m *messageListCmp) convertAssistantMessage(msg message.Message, toolResultMap map[string]message.ToolResult) []util.Model {
451− var uiMessages []util.Model
452+func (m *messageListCmp) convertAssistantMessage(msg message.Message, toolResultMap map[string]message.ToolResult) []list.Item {
453+ var uiMessages []list.Item
452454
453455 // Add assistant message if it should be displayed
454456 if m.shouldShowAssistantMessage(msg) {
internal/tui/components/chat/messages/messages.go+15−3
@@ -11,13 +11,14 @@ import (
1111 "github.com/charmbracelet/catwalk/pkg/catwalk"
1212 "github.com/charmbracelet/lipgloss/v2"
1313 "github.com/charmbracelet/x/ansi"
14+ "github.com/google/uuid"
1415
1516 "github.com/charmbracelet/crush/internal/config"
1617 "github.com/charmbracelet/crush/internal/message"
1718 "github.com/charmbracelet/crush/internal/tui/components/anim"
1819 "github.com/charmbracelet/crush/internal/tui/components/core"
1920 "github.com/charmbracelet/crush/internal/tui/components/core/layout"
20− "github.com/charmbracelet/crush/internal/tui/components/core/list"
21+ "github.com/charmbracelet/crush/internal/tui/exp/list"
2122 "github.com/charmbracelet/crush/internal/tui/styles"
2223 "github.com/charmbracelet/crush/internal/tui/util"
2324)
@@ -31,6 +32,7 @@ type MessageCmp interface {
3132 GetMessage() message.Message // Access to underlying message data
3233 SetMessage(msg message.Message) // Update the message content
3334 Spinning() bool // Animation state for loading messages
35+ ID() string
3436}
3537
3638// messageCmp implements the MessageCmp interface for displaying chat messages.
@@ -333,19 +335,25 @@ func (m *messageCmp) Spinning() bool {
333335}
334336
335337type AssistantSection interface {
336− util.Model
338+ list.Item
337339 layout.Sizeable
338− list.SectionHeader
339340}
340341type assistantSectionModel struct {
341342 width int
343+ id string
342344 message message.Message
343345 lastUserMessageTime time.Time
344346}
345347
348+// ID implements AssistantSection.
349+func (m *assistantSectionModel) ID() string {
350+ return m.id
351+}
352+
346353func NewAssistantSection(message message.Message, lastUserMessageTime time.Time) AssistantSection {
347354 return &assistantSectionModel{
348355 width: 0,
356+ id: uuid.NewString(),
349357 message: message,
350358 lastUserMessageTime: lastUserMessageTime,
351359 }
@@ -392,3 +400,7 @@ func (m *assistantSectionModel) SetSize(width int, height int) tea.Cmd {
392400func (m *assistantSectionModel) IsSectionHeader() bool {
393401 return true
394402}
403+
404+func (m *messageCmp) ID() string {
405+ return m.message.ID
406+}
internal/tui/components/chat/messages/tool.go+5
@@ -29,6 +29,7 @@ type ToolCallCmp interface {
2929 GetNestedToolCalls() []ToolCallCmp // Get nested tool calls
3030 SetNestedToolCalls([]ToolCallCmp) // Set nested tool calls
3131 SetIsNested(bool) // Set whether this tool call is nested
32+ ID() string
3233}
3334
3435// toolCallCmp implements the ToolCallCmp interface for displaying tool calls.
@@ -311,3 +312,7 @@ func (m *toolCallCmp) Spinning() bool {
311312 }
312313 return m.spinning
313314}
315+
316+func (m *toolCallCmp) ID() string {
317+ return m.call.ID
318+}
internal/tui/exp/list/filterable.go+3−3
@@ -38,7 +38,7 @@ type filterableOptions struct {
3838}
3939type filterableList[T FilterableItem] struct {
4040 *list[T]
41− filterableOptions
41+ *filterableOptions
4242 width, height int
4343 // stores all available items
4444 items []T
@@ -83,13 +83,13 @@ func NewFilterableList[T FilterableItem](items []T, opts ...filterableListOption
8383 t := styles.CurrentTheme()
8484
8585 f := &filterableList[T]{
86− filterableOptions: filterableOptions{
86+ filterableOptions: &filterableOptions{
8787 inputStyle: t.S().Base,
8888 placeholder: "Type to filter",
8989 },
9090 }
9191 for _, opt := range opts {
92− opt(&f.filterableOptions)
92+ opt(f.filterableOptions)
9393 }
9494 f.list = New[T](items, f.listOptions...).(*list[T])
9595
internal/tui/exp/list/filterable_test.go+1
@@ -10,6 +10,7 @@ import (
1010)
1111
1212func TestFilterableList(t *testing.T) {
13+ t.Parallel()
1314 t.Run("should create simple filterable list", func(t *testing.T) {
1415 t.Parallel()
1516 items := []FilterableItem{}
internal/tui/exp/list/keys.go+13
@@ -61,3 +61,16 @@ func DefaultKeyMap() KeyMap {
6161 ),
6262 }
6363}
64+
65+func (k KeyMap) KeyBindings() []key.Binding {
66+ return []key.Binding{
67+ k.Down,
68+ k.Up,
69+ k.DownOneItem,
70+ k.UpOneItem,
71+ k.HalfPageDown,
72+ k.HalfPageUp,
73+ k.Home,
74+ k.End,
75+ }
76+}
internal/tui/exp/list/list.go+77−7
@@ -1,6 +1,7 @@
11package list
22
33import (
4+ "slices"
45 "strings"
56
67 "github.com/charmbracelet/bubbles/v2/key"
@@ -29,6 +30,11 @@ type List[T Item] interface {
2930 SetItems([]T) tea.Cmd
3031 SetSelected(string) tea.Cmd
3132 SelectedItem() *T
33+ Items() []T
34+ UpdateItem(string, T)
35+ DeleteItem(string)
36+ PrependItem(T) tea.Cmd
37+ AppendItem(T) tea.Cmd
3238}
3339
3440type direction int
@@ -63,7 +69,7 @@ type confOptions struct {
6369 selectedItem string
6470}
6571type list[T Item] struct {
66− confOptions
72+ *confOptions
6773
6874 focused bool
6975 offset int
@@ -118,14 +124,14 @@ func WithWrapNavigation() listOption {
118124
119125func New[T Item](items []T, opts ...listOption) List[T] {
120126 list := &list[T]{
121− confOptions: confOptions{
127+ confOptions: &confOptions{
122128 direction: Forward,
123129 keyMap: DefaultKeyMap(),
124130 },
125131 items: items,
126132 }
127133 for _, opt := range opts {
128− opt(&list.confOptions)
134+ opt(list.confOptions)
129135 }
130136 return list
131137}
@@ -343,6 +349,7 @@ func (l *list[T]) firstSelectableItemAfter(inx int) int {
343349 return NotFound
344350}
345351
352+// moveToSelected needs to be called after the view is rendered
346353func (l *list[T]) moveToSelected(center bool) tea.Cmd {
347354 var cmds []tea.Cmd
348355 if l.selectedItem == "" || !l.isReady {
@@ -421,8 +428,8 @@ func (l *list[T]) SelectItemAbove() tea.Cmd {
421428 break
422429 }
423430 }
424− l.moveToSelected(false)
425431 l.renderView()
432+ l.moveToSelected(false)
426433 return tea.Batch(cmds...)
427434}
428435
@@ -457,8 +464,8 @@ func (l *list[T]) SelectItemBelow() tea.Cmd {
457464 }
458465 }
459466
460− l.moveToSelected(false)
461467 l.renderView()
468+ l.moveToSelected(false)
462469 return tea.Batch(cmds...)
463470}
464471
@@ -605,10 +612,10 @@ func (l *list[T]) SetItems(items []T) tea.Cmd {
605612 cmds = append(cmds, item.SetSize(l.width, 0))
606613 }
607614
615+ cmds = append(cmds, l.renderItems())
608616 if l.selectedItem != "" {
609617 cmds = append(cmds, l.moveToSelected(true))
610618 }
611− cmds = append(cmds, l.renderItems())
612619 return tea.Batch(cmds...)
613620}
614621
@@ -691,8 +698,8 @@ func (l *list[T]) SetSelected(id string) tea.Cmd {
691698 }
692699 }
693700 l.selectedItem = id
694− cmds = append(cmds, l.moveToSelected(true))
695701 l.renderView()
702+ cmds = append(cmds, l.moveToSelected(true))
696703 return tea.Batch(cmds...)
697704}
698705
@@ -709,3 +716,66 @@ func (l *list[T]) SelectedItem() *T {
709716func (l *list[T]) IsFocused() bool {
710717 return l.focused
711718}
719+
720+func (l *list[T]) Items() []T {
721+ return l.items
722+}
723+
724+func (l *list[T]) UpdateItem(id string, item T) {
725+ // TODO: preserve offset
726+ for inx, item := range l.items {
727+ if item.ID() == id {
728+ l.items[inx] = item
729+ l.renderedItems[inx] = l.renderItem(item)
730+ l.renderView()
731+ return
732+ }
733+ }
734+}
735+
736+func (l *list[T]) DeleteItem(id string) {
737+ // TODO: preserve offset
738+ inx := NotFound
739+ for i, item := range l.items {
740+ if item.ID() == id {
741+ inx = i
742+ break
743+ }
744+ }
745+
746+ l.items = slices.Delete(l.items, inx, inx+1)
747+ l.renderedItems = slices.Delete(l.renderedItems, inx, inx+1)
748+ l.renderView()
749+}
750+
751+func (l *list[T]) PrependItem(item T) tea.Cmd {
752+ // TODO: preserve offset
753+ var cmd tea.Cmd
754+ l.items = append([]T{item}, l.items...)
755+ l.renderedItems = append([]renderedItem{l.renderItem(item)}, l.renderedItems...)
756+ if len(l.items) == 1 {
757+ cmd = l.SetSelected(item.ID())
758+ }
759+ // the viewport did not move and the last item was focused
760+ if l.direction == Backward && l.offset == 0 && l.selectedItem == l.items[0].ID() {
761+ cmd = l.SetSelected(item.ID())
762+ }
763+ l.renderView()
764+ return cmd
765+}
766+
767+func (l *list[T]) AppendItem(item T) tea.Cmd {
768+ // TODO: preserve offset
769+ var cmd tea.Cmd
770+ l.items = append(l.items, item)
771+ l.renderedItems = append(l.renderedItems, l.renderItem(item))
772+ if len(l.items) == 1 {
773+ cmd = l.SetSelected(item.ID())
774+ } else if l.direction == Backward && l.offset == 0 && l.selectedItem == l.items[len(l.items)-2].ID() {
775+ // the viewport did not move and the last item was focused
776+ cmd = l.SetSelected(item.ID())
777+ } else {
778+ l.renderView()
779+ }
780+ return cmd
781+}
internal/tui/exp/list/list_test.go+55
@@ -14,6 +14,7 @@ import (
1414)
1515
1616func TestListPosition(t *testing.T) {
17+ t.Parallel()
1718 type positionOffsetTest struct {
1819 dir direction
1920 test string
@@ -75,6 +76,7 @@ func TestListPosition(t *testing.T) {
7576 }
7677 for _, c := range tests {
7778 t.Run(c.test, func(t *testing.T) {
79+ t.Parallel()
7880 items := []Item{}
7981 for i := range c.numItems {
8082 item := NewSelectableItem(fmt.Sprintf("Item %d", i))
@@ -101,6 +103,7 @@ func TestListPosition(t *testing.T) {
101103}
102104
103105func TestBackwardList(t *testing.T) {
106+ t.Parallel()
104107 t.Run("within height", func(t *testing.T) {
105108 t.Parallel()
106109 items := []Item{}
@@ -291,6 +294,7 @@ func TestBackwardList(t *testing.T) {
291294}
292295
293296func TestForwardList(t *testing.T) {
297+ t.Parallel()
294298 t.Run("within height", func(t *testing.T) {
295299 t.Parallel()
296300 items := []Item{}
@@ -482,6 +486,7 @@ func TestForwardList(t *testing.T) {
482486}
483487
484488func TestListSelection(t *testing.T) {
489+ t.Parallel()
485490 t.Run("should skip none selectable items initially", func(t *testing.T) {
486491 t.Parallel()
487492 items := []Item{}
@@ -553,6 +558,7 @@ func TestListSelection(t *testing.T) {
553558}
554559
555560func TestListSetSelection(t *testing.T) {
561+ t.Parallel()
556562 t.Run("should move to the selected item", func(t *testing.T) {
557563 t.Parallel()
558564 items := []Item{}
@@ -577,6 +583,55 @@ func TestListSetSelection(t *testing.T) {
577583 })
578584}
579585
586+func TestListChanges(t *testing.T) {
587+ t.Parallel()
588+ t.Run("should append an item to the end", func(t *testing.T) {
589+ t.Parallel()
590+ items := []SelectableItem{}
591+ for i := range 20 {
592+ item := NewSelectableItem(fmt.Sprintf("Item %d", i))
593+ items = append(items, item)
594+ }
595+ l := New(items, WithDirection(Backward)).(*list[SelectableItem])
596+ l.SetSize(100, 10)
597+ cmd := l.Init()
598+ if cmd != nil {
599+ cmd()
600+ }
601+
602+ newItem := NewSelectableItem("New Item")
603+ l.AppendItem(newItem)
604+
605+ assert.Equal(t, 21, len(l.items))
606+ assert.Equal(t, 21, len(l.renderedItems))
607+ assert.Equal(t, newItem.ID(), l.selectedItem)
608+ golden.RequireEqual(t, []byte(l.View()))
609+ })
610+ t.Run("should should not change the selected if we moved the offset", func(t *testing.T) {
611+ t.Parallel()
612+ items := []SelectableItem{}
613+ for i := range 20 {
614+ item := NewSelectableItem(fmt.Sprintf("Item %d\nLine2", i))
615+ items = append(items, item)
616+ }
617+ l := New(items, WithDirection(Backward)).(*list[SelectableItem])
618+ l.SetSize(100, 10)
619+ cmd := l.Init()
620+ if cmd != nil {
621+ cmd()
622+ }
623+ l.MoveUp(1)
624+
625+ newItem := NewSelectableItem("New Item")
626+ l.AppendItem(newItem)
627+
628+ assert.Equal(t, 21, len(l.items))
629+ assert.Equal(t, 21, len(l.renderedItems))
630+ assert.Equal(t, l.items[19].ID(), l.selectedItem)
631+ golden.RequireEqual(t, []byte(l.View()))
632+ })
633+}
634+
580635type SelectableItem interface {
581636 Item
582637 layout.Focusable
internal/tui/exp/list/testdata/TestListChanges/should_append_an_item_to_the_end.goldennew file+10
@@ -0,0 +1,10 @@
1+Item 11
2+Item 12
3+Item 13
4+Item 14
5+Item 15
6+Item 16
7+Item 17
8+Item 18
9+Item 19
10+│New Item
internal/tui/exp/list/testdata/TestListChanges/should_should_not_change_the_selected_if_we_moved_the_offset.goldennew file+10
@@ -0,0 +1,10 @@
1+Item 15
2+Line2
3+Item 16
4+Line2
5+Item 17
6+Line2
7+Item 18
8+Line2
9+│Item 19
10+│Line2