Detailed changes
@@ -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) {
@@ -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
+}
@@ -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
+}
@@ -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])
@@ -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{}
@@ -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,
+ }
+}
@@ -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
+}
@@ -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
@@ -0,0 +1,10 @@
+Item 11
+Item 12
+Item 13
+Item 14
+Item 15
+Item 16
+Item 17
+Item 18
+Item 19
+βNew Item
@@ -0,0 +1,10 @@
+Item 15
+Line2
+Item 16
+Line2
+Item 17
+Line2
+Item 18
+Line2
+βItem 19
+βLine2