1package chat
2
3import (
4 "context"
5 "time"
6
7 "github.com/charmbracelet/bubbles/v2/key"
8 tea "github.com/charmbracelet/bubbletea/v2"
9 "github.com/charmbracelet/crush/internal/app"
10 "github.com/charmbracelet/crush/internal/llm/agent"
11 "github.com/charmbracelet/crush/internal/message"
12 "github.com/charmbracelet/crush/internal/permission"
13 "github.com/charmbracelet/crush/internal/pubsub"
14 "github.com/charmbracelet/crush/internal/session"
15 "github.com/charmbracelet/crush/internal/tui/components/chat/messages"
16 "github.com/charmbracelet/crush/internal/tui/components/core/layout"
17 "github.com/charmbracelet/crush/internal/tui/exp/list"
18 "github.com/charmbracelet/crush/internal/tui/styles"
19 "github.com/charmbracelet/crush/internal/tui/util"
20)
21
22type SendMsg struct {
23 Text string
24 Attachments []message.Attachment
25}
26
27type SessionSelectedMsg = session.Session
28
29type SessionClearedMsg struct{}
30
31const (
32 NotFound = -1
33)
34
35// MessageListCmp represents a component that displays a list of chat messages
36// with support for real-time updates and session management.
37type MessageListCmp interface {
38 util.Model
39 layout.Sizeable
40 layout.Focusable
41 layout.Help
42
43 SetSession(session.Session) tea.Cmd
44 GoToBottom() tea.Cmd
45}
46
47// messageListCmp implements MessageListCmp, providing a virtualized list
48// of chat messages with support for tool calls, real-time updates, and
49// session switching.
50type messageListCmp struct {
51 app *app.App
52 width, height int
53 session session.Session
54 listCmp list.List[list.Item]
55 previousSelected string // Last selected item index for restoring focus
56
57 lastUserMessageTime int64
58 defaultListKeyMap list.KeyMap
59}
60
61// New creates a new message list component with custom keybindings
62// and reverse ordering (newest messages at bottom).
63func New(app *app.App) MessageListCmp {
64 defaultListKeyMap := list.DefaultKeyMap()
65 listCmp := list.New(
66 []list.Item{},
67 list.WithGap(1),
68 list.WithDirectionBackward(),
69 list.WithFocus(false),
70 list.WithKeyMap(defaultListKeyMap),
71 list.WithEnableMouse(),
72 )
73 return &messageListCmp{
74 app: app,
75 listCmp: listCmp,
76 previousSelected: "",
77 defaultListKeyMap: defaultListKeyMap,
78 }
79}
80
81// Init initializes the component.
82func (m *messageListCmp) Init() tea.Cmd {
83 return m.listCmp.Init()
84}
85
86// Update handles incoming messages and updates the component state.
87func (m *messageListCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
88 switch msg := msg.(type) {
89 case pubsub.Event[permission.PermissionNotification]:
90 return m, m.handlePermissionRequest(msg.Payload)
91 case SessionSelectedMsg:
92 if msg.ID != m.session.ID {
93 cmd := m.SetSession(msg)
94 return m, cmd
95 }
96 return m, nil
97 case SessionClearedMsg:
98 m.session = session.Session{}
99 return m, m.listCmp.SetItems([]list.Item{})
100
101 case pubsub.Event[message.Message]:
102 cmd := m.handleMessageEvent(msg)
103 return m, cmd
104
105 case tea.MouseWheelMsg:
106 u, cmd := m.listCmp.Update(msg)
107 m.listCmp = u.(list.List[list.Item])
108 return m, cmd
109 default:
110 var cmds []tea.Cmd
111 u, cmd := m.listCmp.Update(msg)
112 m.listCmp = u.(list.List[list.Item])
113 cmds = append(cmds, cmd)
114 return m, tea.Batch(cmds...)
115 }
116}
117
118// View renders the message list or an initial screen if empty.
119func (m *messageListCmp) View() string {
120 t := styles.CurrentTheme()
121 return t.S().Base.
122 Padding(1, 1, 0, 1).
123 Width(m.width).
124 Height(m.height).
125 Render(
126 m.listCmp.View(),
127 )
128}
129
130func (m *messageListCmp) handlePermissionRequest(permission permission.PermissionNotification) tea.Cmd {
131 items := m.listCmp.Items()
132 if toolCallIndex := m.findToolCallByID(items, permission.ToolCallID); toolCallIndex != NotFound {
133 toolCall := items[toolCallIndex].(messages.ToolCallCmp)
134 toolCall.SetPermissionRequested()
135 if permission.Granted {
136 toolCall.SetPermissionGranted()
137 }
138 m.listCmp.UpdateItem(toolCall.ID(), toolCall)
139 }
140 return nil
141}
142
143// handleChildSession handles messages from child sessions (agent tools).
144func (m *messageListCmp) handleChildSession(event pubsub.Event[message.Message]) tea.Cmd {
145 var cmds []tea.Cmd
146 if len(event.Payload.ToolCalls()) == 0 && len(event.Payload.ToolResults()) == 0 {
147 return nil
148 }
149 items := m.listCmp.Items()
150 toolCallInx := NotFound
151 var toolCall messages.ToolCallCmp
152 for i := len(items) - 1; i >= 0; i-- {
153 if msg, ok := items[i].(messages.ToolCallCmp); ok {
154 if msg.GetToolCall().ID == event.Payload.SessionID {
155 toolCallInx = i
156 toolCall = msg
157 }
158 }
159 }
160 if toolCallInx == NotFound {
161 return nil
162 }
163 nestedToolCalls := toolCall.GetNestedToolCalls()
164 for _, tc := range event.Payload.ToolCalls() {
165 found := false
166 for existingInx, existingTC := range nestedToolCalls {
167 if existingTC.GetToolCall().ID == tc.ID {
168 nestedToolCalls[existingInx].SetToolCall(tc)
169 found = true
170 break
171 }
172 }
173 if !found {
174 nestedCall := messages.NewToolCallCmp(
175 event.Payload.ID,
176 tc,
177 m.app.Permissions,
178 messages.WithToolCallNested(true),
179 )
180 cmds = append(cmds, nestedCall.Init())
181 nestedToolCalls = append(
182 nestedToolCalls,
183 nestedCall,
184 )
185 }
186 }
187 for _, tr := range event.Payload.ToolResults() {
188 for nestedInx, nestedTC := range nestedToolCalls {
189 if nestedTC.GetToolCall().ID == tr.ToolCallID {
190 nestedToolCalls[nestedInx].SetToolResult(tr)
191 break
192 }
193 }
194 }
195
196 toolCall.SetNestedToolCalls(nestedToolCalls)
197 m.listCmp.UpdateItem(
198 toolCall.ID(),
199 toolCall,
200 )
201 return tea.Batch(cmds...)
202}
203
204// handleMessageEvent processes different types of message events (created/updated).
205func (m *messageListCmp) handleMessageEvent(event pubsub.Event[message.Message]) tea.Cmd {
206 switch event.Type {
207 case pubsub.CreatedEvent:
208 if event.Payload.SessionID != m.session.ID {
209 return m.handleChildSession(event)
210 }
211 if m.messageExists(event.Payload.ID) {
212 return nil
213 }
214 return m.handleNewMessage(event.Payload)
215 case pubsub.UpdatedEvent:
216 if event.Payload.SessionID != m.session.ID {
217 return m.handleChildSession(event)
218 }
219 switch event.Payload.Role {
220 case message.Assistant:
221 return m.handleUpdateAssistantMessage(event.Payload)
222 case message.Tool:
223 return m.handleToolMessage(event.Payload)
224 }
225 }
226 return nil
227}
228
229// messageExists checks if a message with the given ID already exists in the list.
230func (m *messageListCmp) messageExists(messageID string) bool {
231 items := m.listCmp.Items()
232 // Search backwards as new messages are more likely to be at the end
233 for i := len(items) - 1; i >= 0; i-- {
234 if msg, ok := items[i].(messages.MessageCmp); ok && msg.GetMessage().ID == messageID {
235 return true
236 }
237 }
238 return false
239}
240
241// handleNewMessage routes new messages to appropriate handlers based on role.
242func (m *messageListCmp) handleNewMessage(msg message.Message) tea.Cmd {
243 switch msg.Role {
244 case message.User:
245 return m.handleNewUserMessage(msg)
246 case message.Assistant:
247 return m.handleNewAssistantMessage(msg)
248 case message.Tool:
249 return m.handleToolMessage(msg)
250 }
251 return nil
252}
253
254// handleNewUserMessage adds a new user message to the list and updates the timestamp.
255func (m *messageListCmp) handleNewUserMessage(msg message.Message) tea.Cmd {
256 m.lastUserMessageTime = msg.CreatedAt
257 return m.listCmp.AppendItem(messages.NewMessageCmp(msg))
258}
259
260// handleToolMessage updates existing tool calls with their results.
261func (m *messageListCmp) handleToolMessage(msg message.Message) tea.Cmd {
262 items := m.listCmp.Items()
263 for _, tr := range msg.ToolResults() {
264 if toolCallIndex := m.findToolCallByID(items, tr.ToolCallID); toolCallIndex != NotFound {
265 toolCall := items[toolCallIndex].(messages.ToolCallCmp)
266 toolCall.SetToolResult(tr)
267 m.listCmp.UpdateItem(toolCall.ID(), toolCall)
268 }
269 }
270 return nil
271}
272
273// findToolCallByID searches for a tool call with the specified ID.
274// Returns the index if found, NotFound otherwise.
275func (m *messageListCmp) findToolCallByID(items []list.Item, toolCallID string) int {
276 // Search backwards as tool calls are more likely to be recent
277 for i := len(items) - 1; i >= 0; i-- {
278 if toolCall, ok := items[i].(messages.ToolCallCmp); ok && toolCall.GetToolCall().ID == toolCallID {
279 return i
280 }
281 }
282 return NotFound
283}
284
285// handleUpdateAssistantMessage processes updates to assistant messages,
286// managing both message content and associated tool calls.
287func (m *messageListCmp) handleUpdateAssistantMessage(msg message.Message) tea.Cmd {
288 var cmds []tea.Cmd
289 items := m.listCmp.Items()
290
291 // Find existing assistant message and tool calls for this message
292 assistantIndex, existingToolCalls := m.findAssistantMessageAndToolCalls(items, msg.ID)
293
294 // Handle assistant message content
295 if cmd := m.updateAssistantMessageContent(msg, assistantIndex); cmd != nil {
296 cmds = append(cmds, cmd)
297 }
298
299 // Handle tool calls
300 if cmd := m.updateToolCalls(msg, existingToolCalls); cmd != nil {
301 cmds = append(cmds, cmd)
302 }
303
304 return tea.Batch(cmds...)
305}
306
307// findAssistantMessageAndToolCalls locates the assistant message and its tool calls.
308func (m *messageListCmp) findAssistantMessageAndToolCalls(items []list.Item, messageID string) (int, map[int]messages.ToolCallCmp) {
309 assistantIndex := NotFound
310 toolCalls := make(map[int]messages.ToolCallCmp)
311
312 // Search backwards as messages are more likely to be at the end
313 for i := len(items) - 1; i >= 0; i-- {
314 item := items[i]
315 if asMsg, ok := item.(messages.MessageCmp); ok {
316 if asMsg.GetMessage().ID == messageID {
317 assistantIndex = i
318 }
319 } else if tc, ok := item.(messages.ToolCallCmp); ok {
320 if tc.ParentMessageID() == messageID {
321 toolCalls[i] = tc
322 }
323 }
324 }
325
326 return assistantIndex, toolCalls
327}
328
329// updateAssistantMessageContent updates or removes the assistant message based on content.
330func (m *messageListCmp) updateAssistantMessageContent(msg message.Message, assistantIndex int) tea.Cmd {
331 if assistantIndex == NotFound {
332 return nil
333 }
334
335 shouldShowMessage := m.shouldShowAssistantMessage(msg)
336 hasToolCallsOnly := len(msg.ToolCalls()) > 0 && msg.Content().Text == ""
337
338 var cmd tea.Cmd
339 if shouldShowMessage {
340 items := m.listCmp.Items()
341 uiMsg := items[assistantIndex].(messages.MessageCmp)
342 uiMsg.SetMessage(msg)
343 m.listCmp.UpdateItem(
344 items[assistantIndex].ID(),
345 uiMsg,
346 )
347 if msg.FinishPart() != nil && msg.FinishPart().Reason == message.FinishReasonEndTurn {
348 m.listCmp.AppendItem(
349 messages.NewAssistantSection(
350 msg,
351 time.Unix(m.lastUserMessageTime, 0),
352 ),
353 )
354 }
355 } else if hasToolCallsOnly {
356 items := m.listCmp.Items()
357 m.listCmp.DeleteItem(items[assistantIndex].ID())
358 }
359
360 return cmd
361}
362
363// shouldShowAssistantMessage determines if an assistant message should be displayed.
364func (m *messageListCmp) shouldShowAssistantMessage(msg message.Message) bool {
365 return len(msg.ToolCalls()) == 0 || msg.Content().Text != "" || msg.ReasoningContent().Thinking != "" || msg.IsThinking()
366}
367
368// updateToolCalls handles updates to tool calls, updating existing ones and adding new ones.
369func (m *messageListCmp) updateToolCalls(msg message.Message, existingToolCalls map[int]messages.ToolCallCmp) tea.Cmd {
370 var cmds []tea.Cmd
371
372 for _, tc := range msg.ToolCalls() {
373 if cmd := m.updateOrAddToolCall(msg, tc, existingToolCalls); cmd != nil {
374 cmds = append(cmds, cmd)
375 }
376 }
377
378 return tea.Batch(cmds...)
379}
380
381// updateOrAddToolCall updates an existing tool call or adds a new one.
382func (m *messageListCmp) updateOrAddToolCall(msg message.Message, tc message.ToolCall, existingToolCalls map[int]messages.ToolCallCmp) tea.Cmd {
383 // Try to find existing tool call
384 for _, existingTC := range existingToolCalls {
385 if tc.ID == existingTC.GetToolCall().ID {
386 existingTC.SetToolCall(tc)
387 if msg.FinishPart() != nil && msg.FinishPart().Reason == message.FinishReasonCanceled {
388 existingTC.SetCancelled()
389 }
390 m.listCmp.UpdateItem(tc.ID, existingTC)
391 return nil
392 }
393 }
394
395 // Add new tool call if not found
396 return m.listCmp.AppendItem(messages.NewToolCallCmp(msg.ID, tc, m.app.Permissions))
397}
398
399// handleNewAssistantMessage processes new assistant messages and their tool calls.
400func (m *messageListCmp) handleNewAssistantMessage(msg message.Message) tea.Cmd {
401 var cmds []tea.Cmd
402
403 // Add assistant message if it should be displayed
404 if m.shouldShowAssistantMessage(msg) {
405 cmd := m.listCmp.AppendItem(
406 messages.NewMessageCmp(
407 msg,
408 ),
409 )
410 cmds = append(cmds, cmd)
411 }
412
413 // Add tool calls
414 for _, tc := range msg.ToolCalls() {
415 cmd := m.listCmp.AppendItem(messages.NewToolCallCmp(msg.ID, tc, m.app.Permissions))
416 cmds = append(cmds, cmd)
417 }
418
419 return tea.Batch(cmds...)
420}
421
422// SetSession loads and displays messages for a new session.
423func (m *messageListCmp) SetSession(session session.Session) tea.Cmd {
424 if m.session.ID == session.ID {
425 return nil
426 }
427
428 m.session = session
429 sessionMessages, err := m.app.Messages.List(context.Background(), session.ID)
430 if err != nil {
431 return util.ReportError(err)
432 }
433
434 if len(sessionMessages) == 0 {
435 return m.listCmp.SetItems([]list.Item{})
436 }
437
438 // Initialize with first message timestamp
439 m.lastUserMessageTime = sessionMessages[0].CreatedAt
440
441 // Build tool result map for efficient lookup
442 toolResultMap := m.buildToolResultMap(sessionMessages)
443
444 // Convert messages to UI components
445 uiMessages := m.convertMessagesToUI(sessionMessages, toolResultMap)
446
447 return m.listCmp.SetItems(uiMessages)
448}
449
450// buildToolResultMap creates a map of tool call ID to tool result for efficient lookup.
451func (m *messageListCmp) buildToolResultMap(messages []message.Message) map[string]message.ToolResult {
452 toolResultMap := make(map[string]message.ToolResult)
453 for _, msg := range messages {
454 for _, tr := range msg.ToolResults() {
455 toolResultMap[tr.ToolCallID] = tr
456 }
457 }
458 return toolResultMap
459}
460
461// convertMessagesToUI converts database messages to UI components.
462func (m *messageListCmp) convertMessagesToUI(sessionMessages []message.Message, toolResultMap map[string]message.ToolResult) []list.Item {
463 uiMessages := make([]list.Item, 0)
464
465 for _, msg := range sessionMessages {
466 switch msg.Role {
467 case message.User:
468 m.lastUserMessageTime = msg.CreatedAt
469 uiMessages = append(uiMessages, messages.NewMessageCmp(msg))
470 case message.Assistant:
471 uiMessages = append(uiMessages, m.convertAssistantMessage(msg, toolResultMap)...)
472 if msg.FinishPart() != nil && msg.FinishPart().Reason == message.FinishReasonEndTurn {
473 uiMessages = append(uiMessages, messages.NewAssistantSection(msg, time.Unix(m.lastUserMessageTime, 0)))
474 }
475 }
476 }
477
478 return uiMessages
479}
480
481// convertAssistantMessage converts an assistant message and its tool calls to UI components.
482func (m *messageListCmp) convertAssistantMessage(msg message.Message, toolResultMap map[string]message.ToolResult) []list.Item {
483 var uiMessages []list.Item
484
485 // Add assistant message if it should be displayed
486 if m.shouldShowAssistantMessage(msg) {
487 uiMessages = append(
488 uiMessages,
489 messages.NewMessageCmp(
490 msg,
491 ),
492 )
493 }
494
495 // Add tool calls with their results and status
496 for _, tc := range msg.ToolCalls() {
497 options := m.buildToolCallOptions(tc, msg, toolResultMap)
498 uiMessages = append(uiMessages, messages.NewToolCallCmp(msg.ID, tc, m.app.Permissions, options...))
499 // If this tool call is the agent tool, fetch nested tool calls
500 if tc.Name == agent.AgentToolName {
501 nestedMessages, _ := m.app.Messages.List(context.Background(), tc.ID)
502 nestedToolResultMap := m.buildToolResultMap(nestedMessages)
503 nestedUIMessages := m.convertMessagesToUI(nestedMessages, nestedToolResultMap)
504 nestedToolCalls := make([]messages.ToolCallCmp, 0, len(nestedUIMessages))
505 for _, nestedMsg := range nestedUIMessages {
506 if toolCall, ok := nestedMsg.(messages.ToolCallCmp); ok {
507 toolCall.SetIsNested(true)
508 nestedToolCalls = append(nestedToolCalls, toolCall)
509 }
510 }
511 uiMessages[len(uiMessages)-1].(messages.ToolCallCmp).SetNestedToolCalls(nestedToolCalls)
512 }
513 }
514
515 return uiMessages
516}
517
518// buildToolCallOptions creates options for tool call components based on results and status.
519func (m *messageListCmp) buildToolCallOptions(tc message.ToolCall, msg message.Message, toolResultMap map[string]message.ToolResult) []messages.ToolCallOption {
520 var options []messages.ToolCallOption
521
522 // Add tool result if available
523 if tr, ok := toolResultMap[tc.ID]; ok {
524 options = append(options, messages.WithToolCallResult(tr))
525 }
526
527 // Add cancelled status if applicable
528 if msg.FinishPart() != nil && msg.FinishPart().Reason == message.FinishReasonCanceled {
529 options = append(options, messages.WithToolCallCancelled())
530 }
531
532 return options
533}
534
535// GetSize returns the current width and height of the component.
536func (m *messageListCmp) GetSize() (int, int) {
537 return m.width, m.height
538}
539
540// SetSize updates the component dimensions and propagates to the list component.
541func (m *messageListCmp) SetSize(width int, height int) tea.Cmd {
542 m.width = width
543 m.height = height
544 return m.listCmp.SetSize(width-2, height-1) // for padding
545}
546
547// Blur implements MessageListCmp.
548func (m *messageListCmp) Blur() tea.Cmd {
549 return m.listCmp.Blur()
550}
551
552// Focus implements MessageListCmp.
553func (m *messageListCmp) Focus() tea.Cmd {
554 return m.listCmp.Focus()
555}
556
557// IsFocused implements MessageListCmp.
558func (m *messageListCmp) IsFocused() bool {
559 return m.listCmp.IsFocused()
560}
561
562func (m *messageListCmp) Bindings() []key.Binding {
563 return m.defaultListKeyMap.KeyBindings()
564}
565
566func (m *messageListCmp) GoToBottom() tea.Cmd {
567 return m.listCmp.GoToBottom()
568}