on main help sign in
wip: add to messages list
a80d6c6Kujtim Hoxha
internal/tui/components/chat/chat.go+23−21
@@ -13,7 +13,7 @@ import (
13 "github.com/charmbracelet/crush/internal/session"
13 "github.com/charmbracelet/crush/internal/session"
14 "github.com/charmbracelet/crush/internal/tui/components/chat/messages"
14 "github.com/charmbracelet/crush/internal/tui/components/chat/messages"
15 "github.com/charmbracelet/crush/internal/tui/components/core/layout"
15 "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"
17 "github.com/charmbracelet/crush/internal/tui/styles"
17 "github.com/charmbracelet/crush/internal/tui/styles"
18 "github.com/charmbracelet/crush/internal/tui/util"
18 "github.com/charmbracelet/crush/internal/tui/util"
19)
19)
@@ -49,8 +49,8 @@ type messageListCmp struct {
49 app *app.App
49 app *app.App
50 width, height int
50 width, height int
51 session session.Session
51 session session.Session
52− listCmp list.ListModel
52+ listCmp list.List[list.Item]
53− previousSelected int // Last selected item index for restoring focus
53+ previousSelected string // Last selected item index for restoring focus
54
54
55 lastUserMessageTime int64
55 lastUserMessageTime int64
56 defaultListKeyMap list.KeyMap
56 defaultListKeyMap list.KeyMap
@@ -61,14 +61,15 @@ type messageListCmp struct {
61func New(app *app.App) MessageListCmp {
61func New(app *app.App) MessageListCmp {
62 defaultListKeyMap := list.DefaultKeyMap()
62 defaultListKeyMap := list.DefaultKeyMap()
63 listCmp := list.New(
63 listCmp := list.New(
64− list.WithGapSize(1),
64+ []list.Item{},
65− list.WithReverse(true),
65+ list.WithGap(1),
66+ list.WithDirection(list.Backward),
66 list.WithKeyMap(defaultListKeyMap),
67 list.WithKeyMap(defaultListKeyMap),
67 )
68 )
68 return &messageListCmp{
69 return &messageListCmp{
69 app: app,
70 app: app,
70 listCmp: listCmp,
71 listCmp: listCmp,
71− previousSelected: list.NoSelection,
72+ previousSelected: "",
72 defaultListKeyMap: defaultListKeyMap,
73 defaultListKeyMap: defaultListKeyMap,
73 }
74 }
74}
75}
@@ -89,7 +90,7 @@ func (m *messageListCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
89 return m, nil
90 return m, nil
90 case SessionClearedMsg:
91 case SessionClearedMsg:
91 m.session = session.Session{}
92 m.session = session.Session{}
92− return m, m.listCmp.SetItems([]util.Model{})
93+ return m, m.listCmp.SetItems([]list.Item{})
93
94
94 case pubsub.Event[message.Message]:
95 case pubsub.Event[message.Message]:
95 cmd := m.handleMessageEvent(msg)
96 cmd := m.handleMessageEvent(msg)
@@ -97,7 +98,7 @@ func (m *messageListCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
97 default:
98 default:
98 var cmds []tea.Cmd
99 var cmds []tea.Cmd
99 u, cmd := m.listCmp.Update(msg)
100 u, cmd := m.listCmp.Update(msg)
100− m.listCmp = u.(list.ListModel)
101+ m.listCmp = u.(list.List[list.Item])
101 cmds = append(cmds, cmd)
102 cmds = append(cmds, cmd)
102 return m, tea.Batch(cmds...)
103 return m, tea.Batch(cmds...)
103 }
104 }
@@ -169,7 +170,7 @@ func (m *messageListCmp) handleChildSession(event pubsub.Event[message.Message])
169
170
170 toolCall.SetNestedToolCalls(nestedToolCalls)
171 toolCall.SetNestedToolCalls(nestedToolCalls)
171 m.listCmp.UpdateItem(
172 m.listCmp.UpdateItem(
172− toolCallInx,
173+ toolCall.ID(),
173 toolCall,
174 toolCall,
174 )
175 )
175 return tea.Batch(cmds...)
176 return tea.Batch(cmds...)
@@ -233,7 +234,7 @@ func (m *messageListCmp) handleToolMessage(msg message.Message) tea.Cmd {
233 if toolCallIndex := m.findToolCallByID(items, tr.ToolCallID); toolCallIndex != NotFound {
234 if toolCallIndex := m.findToolCallByID(items, tr.ToolCallID); toolCallIndex != NotFound {
234 toolCall := items[toolCallIndex].(messages.ToolCallCmp)
235 toolCall := items[toolCallIndex].(messages.ToolCallCmp)
235 toolCall.SetToolResult(tr)
236 toolCall.SetToolResult(tr)
236− m.listCmp.UpdateItem(toolCallIndex, toolCall)
237+ m.listCmp.UpdateItem(toolCall.ID(), toolCall)
237 }
238 }
238 }
239 }
239 return nil
240 return nil
@@ -241,7 +242,7 @@ func (m *messageListCmp) handleToolMessage(msg message.Message) tea.Cmd {
241
242
242// findToolCallByID searches for a tool call with the specified ID.
243// findToolCallByID searches for a tool call with the specified ID.
243// Returns the index if found, NotFound otherwise.
244// 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 {
245 // Search backwards as tool calls are more likely to be recent
246 // Search backwards as tool calls are more likely to be recent
246 for i := len(items) - 1; i >= 0; i-- {
247 for i := len(items) - 1; i >= 0; i-- {
247 if toolCall, ok := items[i].(messages.ToolCallCmp); ok && toolCall.GetToolCall().ID == toolCallID {
248 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
274}
275}
275
276
276// findAssistantMessageAndToolCalls locates the assistant message and its tool calls.
277// 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) {
278 assistantIndex := NotFound
279 assistantIndex := NotFound
279 toolCalls := make(map[int]messages.ToolCallCmp)
280 toolCalls := make(map[int]messages.ToolCallCmp)
280
281
@@ -310,7 +311,7 @@ func (m *messageListCmp) updateAssistantMessageContent(msg message.Message, assi
310 uiMsg := items[assistantIndex].(messages.MessageCmp)
311 uiMsg := items[assistantIndex].(messages.MessageCmp)
311 uiMsg.SetMessage(msg)
312 uiMsg.SetMessage(msg)
312 m.listCmp.UpdateItem(
313 m.listCmp.UpdateItem(
313− assistantIndex,
314+ items[assistantIndex].ID(),
314 uiMsg,
315 uiMsg,
315 )
316 )
316 if msg.FinishPart() != nil && msg.FinishPart().Reason == message.FinishReasonEndTurn {
317 if msg.FinishPart() != nil && msg.FinishPart().Reason == message.FinishReasonEndTurn {
@@ -322,7 +323,8 @@ func (m *messageListCmp) updateAssistantMessageContent(msg message.Message, assi
322 )
323 )
323 }
324 }
324 } else if hasToolCallsOnly {
325 } else if hasToolCallsOnly {
326+ items := m.listCmp.Items()
325− m.listCmp.DeleteItem(assistantIndex)
327+ m.listCmp.DeleteItem(items[assistantIndex].ID())
326 }
328 }
327
329
328 return cmd
330 return cmd
@@ -349,13 +351,13 @@ func (m *messageListCmp) updateToolCalls(msg message.Message, existingToolCalls
349// updateOrAddToolCall updates an existing tool call or adds a new one.
351// updateOrAddToolCall updates an existing tool call or adds a new one.
350func (m *messageListCmp) updateOrAddToolCall(msg message.Message, tc message.ToolCall, existingToolCalls map[int]messages.ToolCallCmp) tea.Cmd {
352func (m *messageListCmp) updateOrAddToolCall(msg message.Message, tc message.ToolCall, existingToolCalls map[int]messages.ToolCallCmp) tea.Cmd {
351 // Try to find existing tool call
353 // Try to find existing tool call
352− for index, existingTC := range existingToolCalls {
354+ for _, existingTC := range existingToolCalls {
353 if tc.ID == existingTC.GetToolCall().ID {
355 if tc.ID == existingTC.GetToolCall().ID {
354 existingTC.SetToolCall(tc)
356 existingTC.SetToolCall(tc)
355 if msg.FinishPart() != nil && msg.FinishPart().Reason == message.FinishReasonCanceled {
357 if msg.FinishPart() != nil && msg.FinishPart().Reason == message.FinishReasonCanceled {
356 existingTC.SetCancelled()
358 existingTC.SetCancelled()
357 }
359 }
358− m.listCmp.UpdateItem(index, existingTC)
360+ m.listCmp.UpdateItem(tc.ID, existingTC)
359 return nil
361 return nil
360 }
362 }
361 }
363 }
@@ -400,7 +402,7 @@ func (m *messageListCmp) SetSession(session session.Session) tea.Cmd {
400 }
402 }
401
403
402 if len(sessionMessages) == 0 {
404 if len(sessionMessages) == 0 {
403− return m.listCmp.SetItems([]util.Model{})
405+ return m.listCmp.SetItems([]list.Item{})
404 }
406 }
405
407
406 // Initialize with first message timestamp
408 // Initialize with first message timestamp
@@ -427,8 +429,8 @@ func (m *messageListCmp) buildToolResultMap(messages []message.Message) map[stri
427}
429}
428
430
429// convertMessagesToUI converts database messages to UI components.
431// convertMessagesToUI converts database messages to UI components.
430−func (m *messageListCmp) convertMessagesToUI(sessionMessages []message.Message, toolResultMap map[string]message.ToolResult) []util.Model {
432+func (m *messageListCmp) convertMessagesToUI(sessionMessages []message.Message, toolResultMap map[string]message.ToolResult) []list.Item {
431− uiMessages := make([]util.Model, 0)
433+ uiMessages := make([]list.Item, 0)
432
434
433 for _, msg := range sessionMessages {
435 for _, msg := range sessionMessages {
434 switch msg.Role {
436 switch msg.Role {
@@ -447,8 +449,8 @@ func (m *messageListCmp) convertMessagesToUI(sessionMessages []message.Message,
447}
449}
448
450
449// convertAssistantMessage converts an assistant message and its tool calls to UI components.
451// 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 {
452+func (m *messageListCmp) convertAssistantMessage(msg message.Message, toolResultMap map[string]message.ToolResult) []list.Item {
451− var uiMessages []util.Model
453+ var uiMessages []list.Item
452
454
453 // Add assistant message if it should be displayed
455 // Add assistant message if it should be displayed
454 if m.shouldShowAssistantMessage(msg) {
456 if m.shouldShowAssistantMessage(msg) {
internal/tui/components/chat/messages/messages.go+15−3
@@ -11,13 +11,14 @@ import (
11 "github.com/charmbracelet/catwalk/pkg/catwalk"
11 "github.com/charmbracelet/catwalk/pkg/catwalk"
12 "github.com/charmbracelet/lipgloss/v2"
12 "github.com/charmbracelet/lipgloss/v2"
13 "github.com/charmbracelet/x/ansi"
13 "github.com/charmbracelet/x/ansi"
14+ "github.com/google/uuid"
14
15
15 "github.com/charmbracelet/crush/internal/config"
16 "github.com/charmbracelet/crush/internal/config"
16 "github.com/charmbracelet/crush/internal/message"
17 "github.com/charmbracelet/crush/internal/message"
17 "github.com/charmbracelet/crush/internal/tui/components/anim"
18 "github.com/charmbracelet/crush/internal/tui/components/anim"
18 "github.com/charmbracelet/crush/internal/tui/components/core"
19 "github.com/charmbracelet/crush/internal/tui/components/core"
19 "github.com/charmbracelet/crush/internal/tui/components/core/layout"
20 "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"
21 "github.com/charmbracelet/crush/internal/tui/styles"
22 "github.com/charmbracelet/crush/internal/tui/styles"
22 "github.com/charmbracelet/crush/internal/tui/util"
23 "github.com/charmbracelet/crush/internal/tui/util"
23)
24)
@@ -31,6 +32,7 @@ type MessageCmp interface {
31 GetMessage() message.Message // Access to underlying message data
32 GetMessage() message.Message // Access to underlying message data
32 SetMessage(msg message.Message) // Update the message content
33 SetMessage(msg message.Message) // Update the message content
33 Spinning() bool // Animation state for loading messages
34 Spinning() bool // Animation state for loading messages
35+ ID() string
34}
36}
35
37
36// messageCmp implements the MessageCmp interface for displaying chat messages.
38// messageCmp implements the MessageCmp interface for displaying chat messages.
@@ -333,19 +335,25 @@ func (m *messageCmp) Spinning() bool {
333}
335}
334
336
335type AssistantSection interface {
337type AssistantSection interface {
336− util.Model
338+ list.Item
337 layout.Sizeable
339 layout.Sizeable
338− list.SectionHeader
339}
340}
340type assistantSectionModel struct {
341type assistantSectionModel struct {
341 width int
342 width int
343+ id string
342 message message.Message
344 message message.Message
343 lastUserMessageTime time.Time
345 lastUserMessageTime time.Time
344}
346}
345
347
348+// ID implements AssistantSection.
349+func (m *assistantSectionModel) ID() string {
350+ return m.id
351+}
352+
346func NewAssistantSection(message message.Message, lastUserMessageTime time.Time) AssistantSection {
353func NewAssistantSection(message message.Message, lastUserMessageTime time.Time) AssistantSection {
347 return &assistantSectionModel{
354 return &assistantSectionModel{
348 width: 0,
355 width: 0,
356+ id: uuid.NewString(),
349 message: message,
357 message: message,
350 lastUserMessageTime: lastUserMessageTime,
358 lastUserMessageTime: lastUserMessageTime,
351 }
359 }
@@ -392,3 +400,7 @@ func (m *assistantSectionModel) SetSize(width int, height int) tea.Cmd {
392func (m *assistantSectionModel) IsSectionHeader() bool {
400func (m *assistantSectionModel) IsSectionHeader() bool {
393 return true
401 return true
394}
402}
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 {
29 GetNestedToolCalls() []ToolCallCmp // Get nested tool calls
29 GetNestedToolCalls() []ToolCallCmp // Get nested tool calls
30 SetNestedToolCalls([]ToolCallCmp) // Set nested tool calls
30 SetNestedToolCalls([]ToolCallCmp) // Set nested tool calls
31 SetIsNested(bool) // Set whether this tool call is nested
31 SetIsNested(bool) // Set whether this tool call is nested
32+ ID() string
32}
33}
33
34
34// toolCallCmp implements the ToolCallCmp interface for displaying tool calls.
35// toolCallCmp implements the ToolCallCmp interface for displaying tool calls.
@@ -311,3 +312,7 @@ func (m *toolCallCmp) Spinning() bool {
311 }
312 }
312 return m.spinning
313 return m.spinning
313}
314}
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 {
38}
38}
39type filterableList[T FilterableItem] struct {
39type filterableList[T FilterableItem] struct {
40 *list[T]
40 *list[T]
41− filterableOptions
41+ *filterableOptions
42 width, height int
42 width, height int
43 // stores all available items
43 // stores all available items
44 items []T
44 items []T
@@ -83,13 +83,13 @@ func NewFilterableList[T FilterableItem](items []T, opts ...filterableListOption
83 t := styles.CurrentTheme()
83 t := styles.CurrentTheme()
84
84
85 f := &filterableList[T]{
85 f := &filterableList[T]{
86− filterableOptions: filterableOptions{
86+ filterableOptions: &filterableOptions{
87 inputStyle: t.S().Base,
87 inputStyle: t.S().Base,
88 placeholder: "Type to filter",
88 placeholder: "Type to filter",
89 },
89 },
90 }
90 }
91 for _, opt := range opts {
91 for _, opt := range opts {
92− opt(&f.filterableOptions)
92+ opt(f.filterableOptions)
93 }
93 }
94 f.list = New[T](items, f.listOptions...).(*list[T])
94 f.list = New[T](items, f.listOptions...).(*list[T])
95
95
internal/tui/exp/list/filterable_test.go+1
@@ -10,6 +10,7 @@ import (
10)
10)
11
11
12func TestFilterableList(t *testing.T) {
12func TestFilterableList(t *testing.T) {
13+ t.Parallel()
13 t.Run("should create simple filterable list", func(t *testing.T) {
14 t.Run("should create simple filterable list", func(t *testing.T) {
14 t.Parallel()
15 t.Parallel()
15 items := []FilterableItem{}
16 items := []FilterableItem{}
internal/tui/exp/list/keys.go+13
@@ -61,3 +61,16 @@ func DefaultKeyMap() KeyMap {
61 ),
61 ),
62 }
62 }
63}
63}
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 @@
1package list
1package list
2
2
3import (
3import (
4+ "slices"
4 "strings"
5 "strings"
5
6
6 "github.com/charmbracelet/bubbles/v2/key"
7 "github.com/charmbracelet/bubbles/v2/key"
@@ -29,6 +30,11 @@ type List[T Item] interface {
29 SetItems([]T) tea.Cmd
30 SetItems([]T) tea.Cmd
30 SetSelected(string) tea.Cmd
31 SetSelected(string) tea.Cmd
31 SelectedItem() *T
32 SelectedItem() *T
33+ Items() []T
34+ UpdateItem(string, T)
35+ DeleteItem(string)
36+ PrependItem(T) tea.Cmd
37+ AppendItem(T) tea.Cmd
32}
38}
33
39
34type direction int
40type direction int
@@ -63,7 +69,7 @@ type confOptions struct {
63 selectedItem string
69 selectedItem string
64}
70}
65type list[T Item] struct {
71type list[T Item] struct {
66− confOptions
72+ *confOptions
67
73
68 focused bool
74 focused bool
69 offset int
75 offset int
@@ -118,14 +124,14 @@ func WithWrapNavigation() listOption {
118
124
119func New[T Item](items []T, opts ...listOption) List[T] {
125func New[T Item](items []T, opts ...listOption) List[T] {
120 list := &list[T]{
126 list := &list[T]{
121− confOptions: confOptions{
127+ confOptions: &confOptions{
122 direction: Forward,
128 direction: Forward,
123 keyMap: DefaultKeyMap(),
129 keyMap: DefaultKeyMap(),
124 },
130 },
125 items: items,
131 items: items,
126 }
132 }
127 for _, opt := range opts {
133 for _, opt := range opts {
128− opt(&list.confOptions)
134+ opt(list.confOptions)
129 }
135 }
130 return list
136 return list
131}
137}
@@ -343,6 +349,7 @@ func (l *list[T]) firstSelectableItemAfter(inx int) int {
343 return NotFound
349 return NotFound
344}
350}
345
351
352+// moveToSelected needs to be called after the view is rendered
346func (l *list[T]) moveToSelected(center bool) tea.Cmd {
353func (l *list[T]) moveToSelected(center bool) tea.Cmd {
347 var cmds []tea.Cmd
354 var cmds []tea.Cmd
348 if l.selectedItem == "" || !l.isReady {
355 if l.selectedItem == "" || !l.isReady {
@@ -421,8 +428,8 @@ func (l *list[T]) SelectItemAbove() tea.Cmd {
421 break
428 break
422 }
429 }
423 }
430 }
424− l.moveToSelected(false)
425 l.renderView()
431 l.renderView()
432+ l.moveToSelected(false)
426 return tea.Batch(cmds...)
433 return tea.Batch(cmds...)
427}
434}
428
435
@@ -457,8 +464,8 @@ func (l *list[T]) SelectItemBelow() tea.Cmd {
457 }
464 }
458 }
465 }
459
466
460− l.moveToSelected(false)
461 l.renderView()
467 l.renderView()
468+ l.moveToSelected(false)
462 return tea.Batch(cmds...)
469 return tea.Batch(cmds...)
463}
470}
464
471
@@ -605,10 +612,10 @@ func (l *list[T]) SetItems(items []T) tea.Cmd {
605 cmds = append(cmds, item.SetSize(l.width, 0))
612 cmds = append(cmds, item.SetSize(l.width, 0))
606 }
613 }
607
614
615+ cmds = append(cmds, l.renderItems())
608 if l.selectedItem != "" {
616 if l.selectedItem != "" {
609 cmds = append(cmds, l.moveToSelected(true))
617 cmds = append(cmds, l.moveToSelected(true))
610 }
618 }
611− cmds = append(cmds, l.renderItems())
612 return tea.Batch(cmds...)
619 return tea.Batch(cmds...)
613}
620}
614
621
@@ -691,8 +698,8 @@ func (l *list[T]) SetSelected(id string) tea.Cmd {
691 }
698 }
692 }
699 }
693 l.selectedItem = id
700 l.selectedItem = id
694− cmds = append(cmds, l.moveToSelected(true))
695 l.renderView()
701 l.renderView()
702+ cmds = append(cmds, l.moveToSelected(true))
696 return tea.Batch(cmds...)
703 return tea.Batch(cmds...)
697}
704}
698
705
@@ -709,3 +716,66 @@ func (l *list[T]) SelectedItem() *T {
709func (l *list[T]) IsFocused() bool {
716func (l *list[T]) IsFocused() bool {
710 return l.focused
717 return l.focused
711}
718}
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 (
14)
14)
15
15
16func TestListPosition(t *testing.T) {
16func TestListPosition(t *testing.T) {
17+ t.Parallel()
17 type positionOffsetTest struct {
18 type positionOffsetTest struct {
18 dir direction
19 dir direction
19 test string
20 test string
@@ -75,6 +76,7 @@ func TestListPosition(t *testing.T) {
75 }
76 }
76 for _, c := range tests {
77 for _, c := range tests {
77 t.Run(c.test, func(t *testing.T) {
78 t.Run(c.test, func(t *testing.T) {
79+ t.Parallel()
78 items := []Item{}
80 items := []Item{}
79 for i := range c.numItems {
81 for i := range c.numItems {
80 item := NewSelectableItem(fmt.Sprintf("Item %d", i))
82 item := NewSelectableItem(fmt.Sprintf("Item %d", i))
@@ -101,6 +103,7 @@ func TestListPosition(t *testing.T) {
101}
103}
102
104
103func TestBackwardList(t *testing.T) {
105func TestBackwardList(t *testing.T) {
106+ t.Parallel()
104 t.Run("within height", func(t *testing.T) {
107 t.Run("within height", func(t *testing.T) {
105 t.Parallel()
108 t.Parallel()
106 items := []Item{}
109 items := []Item{}
@@ -291,6 +294,7 @@ func TestBackwardList(t *testing.T) {
291}
294}
292
295
293func TestForwardList(t *testing.T) {
296func TestForwardList(t *testing.T) {
297+ t.Parallel()
294 t.Run("within height", func(t *testing.T) {
298 t.Run("within height", func(t *testing.T) {
295 t.Parallel()
299 t.Parallel()
296 items := []Item{}
300 items := []Item{}
@@ -482,6 +486,7 @@ func TestForwardList(t *testing.T) {
482}
486}
483
487
484func TestListSelection(t *testing.T) {
488func TestListSelection(t *testing.T) {
489+ t.Parallel()
485 t.Run("should skip none selectable items initially", func(t *testing.T) {
490 t.Run("should skip none selectable items initially", func(t *testing.T) {
486 t.Parallel()
491 t.Parallel()
487 items := []Item{}
492 items := []Item{}
@@ -553,6 +558,7 @@ func TestListSelection(t *testing.T) {
553}
558}
554
559
555func TestListSetSelection(t *testing.T) {
560func TestListSetSelection(t *testing.T) {
561+ t.Parallel()
556 t.Run("should move to the selected item", func(t *testing.T) {
562 t.Run("should move to the selected item", func(t *testing.T) {
557 t.Parallel()
563 t.Parallel()
558 items := []Item{}
564 items := []Item{}
@@ -577,6 +583,55 @@ func TestListSetSelection(t *testing.T) {
577 })
583 })
578}
584}
579
585
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+
580type SelectableItem interface {
635type SelectableItem interface {
581 Item
636 Item
582 layout.Focusable
637 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