wip: add to messages list

This commit is contained in:
Kujtim Hoxha
2025-07-21 18:48:01 +02:00
parent aab9b3b8d6
commit a80d6c609f
10 changed files with 212 additions and 34 deletions

View File

@@ -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) {

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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])

View File

@@ -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{}

View File

@@ -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,
}
}

View File

@@ -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
}

View File

@@ -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

View File

@@ -0,0 +1,10 @@
Item 11
Item 12
Item 13
Item 14
Item 15
Item 16
Item 17
Item 18
Item 19
│New Item

View File

@@ -0,0 +1,10 @@
Item 15
Line2
Item 16
Line2
Item 17
Line2
Item 18
Line2
│Item 19
│Line2