chat.go

  1package chat
  2
  3import (
  4	"context"
  5	"log/slog"
  6	"time"
  7
  8	"charm.land/bubbles/v2/key"
  9	tea "charm.land/bubbletea/v2"
 10	"github.com/atotto/clipboard"
 11	"github.com/charmbracelet/crush/internal/agent"
 12	"github.com/charmbracelet/crush/internal/agent/tools"
 13	"github.com/charmbracelet/crush/internal/app"
 14	"github.com/charmbracelet/crush/internal/message"
 15	"github.com/charmbracelet/crush/internal/permission"
 16	"github.com/charmbracelet/crush/internal/pubsub"
 17	"github.com/charmbracelet/crush/internal/session"
 18	"github.com/charmbracelet/crush/internal/tui/components/chat/messages"
 19	"github.com/charmbracelet/crush/internal/tui/components/core/layout"
 20	"github.com/charmbracelet/crush/internal/tui/exp/list"
 21	"github.com/charmbracelet/crush/internal/tui/styles"
 22	"github.com/charmbracelet/crush/internal/tui/util"
 23)
 24
 25type SendMsg struct {
 26	Text        string
 27	Attachments []message.Attachment
 28}
 29
 30type SessionSelectedMsg = session.Session
 31
 32type SessionClearedMsg struct{}
 33
 34type SelectionCopyMsg struct {
 35	clickCount   int
 36	endSelection bool
 37	x, y         int
 38}
 39
 40const (
 41	NotFound = -1
 42)
 43
 44// MessageListCmp represents a component that displays a list of chat messages
 45// with support for real-time updates and session management.
 46type MessageListCmp interface {
 47	util.Model
 48	layout.Sizeable
 49	layout.Focusable
 50	layout.Help
 51
 52	SetSession(session.Session) tea.Cmd
 53	GoToBottom() tea.Cmd
 54	GetSelectedText() string
 55	CopySelectedText(bool) tea.Cmd
 56}
 57
 58// messageListCmp implements MessageListCmp, providing a virtualized list
 59// of chat messages with support for tool calls, real-time updates, and
 60// session switching.
 61type messageListCmp struct {
 62	app              *app.App
 63	width, height    int
 64	session          session.Session
 65	listCmp          list.List[list.Item]
 66	previousSelected string // Last selected item index for restoring focus
 67
 68	lastUserMessageTime int64
 69	defaultListKeyMap   list.KeyMap
 70
 71	// Click tracking for double/triple click detection
 72	lastClickTime time.Time
 73	lastClickX    int
 74	lastClickY    int
 75	clickCount    int
 76}
 77
 78// New creates a new message list component with custom keybindings
 79// and reverse ordering (newest messages at bottom).
 80func New(app *app.App) MessageListCmp {
 81	defaultListKeyMap := list.DefaultKeyMap()
 82	listCmp := list.New(
 83		[]list.Item{},
 84		list.WithGap(1),
 85		list.WithDirectionBackward(),
 86		list.WithFocus(false),
 87		list.WithKeyMap(defaultListKeyMap),
 88		list.WithEnableMouse(),
 89	)
 90	return &messageListCmp{
 91		app:               app,
 92		listCmp:           listCmp,
 93		previousSelected:  "",
 94		defaultListKeyMap: defaultListKeyMap,
 95	}
 96}
 97
 98// Init initializes the component.
 99func (m *messageListCmp) Init() tea.Cmd {
100	return m.listCmp.Init()
101}
102
103// Update handles incoming messages and updates the component state.
104func (m *messageListCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
105	var cmds []tea.Cmd
106	switch msg := msg.(type) {
107	case tea.KeyPressMsg:
108		if m.listCmp.IsFocused() && m.listCmp.HasSelection() {
109			switch {
110			case key.Matches(msg, messages.CopyKey):
111				cmds = append(cmds, m.CopySelectedText(true))
112				return m, tea.Batch(cmds...)
113			case key.Matches(msg, messages.ClearSelectionKey):
114				cmds = append(cmds, m.SelectionClear())
115				return m, tea.Batch(cmds...)
116			}
117		}
118	case tea.MouseClickMsg:
119		x := msg.X - 1 // Adjust for padding
120		y := msg.Y - 1 // Adjust for padding
121		if x < 0 || y < 0 || x >= m.width-2 || y >= m.height-1 {
122			return m, nil // Ignore clicks outside the component
123		}
124		if msg.Button == tea.MouseLeft {
125			cmds = append(cmds, m.handleMouseClick(x, y))
126			return m, tea.Batch(cmds...)
127		}
128		return m, tea.Batch(cmds...)
129	case tea.MouseMotionMsg:
130		x := msg.X - 1 // Adjust for padding
131		y := msg.Y - 1 // Adjust for padding
132		if x < 0 || y < 0 || x >= m.width-2 || y >= m.height-1 {
133			if y < 0 {
134				cmds = append(cmds, m.listCmp.MoveUp(1))
135				return m, tea.Batch(cmds...)
136			}
137			if y >= m.height-1 {
138				cmds = append(cmds, m.listCmp.MoveDown(1))
139				return m, tea.Batch(cmds...)
140			}
141			return m, nil // Ignore clicks outside the component
142		}
143		if msg.Button == tea.MouseLeft {
144			m.listCmp.EndSelection(x, y)
145		}
146		return m, tea.Batch(cmds...)
147	case tea.MouseReleaseMsg:
148		x := msg.X - 1 // Adjust for padding
149		y := msg.Y - 1 // Adjust for padding
150		if msg.Button == tea.MouseLeft {
151			clickCount := m.clickCount
152			if x < 0 || y < 0 || x >= m.width-2 || y >= m.height-1 {
153				tick := tea.Tick(doubleClickThreshold, func(time.Time) tea.Msg {
154					return SelectionCopyMsg{
155						clickCount:   clickCount,
156						endSelection: false,
157					}
158				})
159
160				cmds = append(cmds, tick)
161				return m, tea.Batch(cmds...)
162			}
163			tick := tea.Tick(doubleClickThreshold, func(time.Time) tea.Msg {
164				return SelectionCopyMsg{
165					clickCount:   clickCount,
166					endSelection: true,
167					x:            x,
168					y:            y,
169				}
170			})
171			cmds = append(cmds, tick)
172			return m, tea.Batch(cmds...)
173		}
174		return m, nil
175	case SelectionCopyMsg:
176		if msg.clickCount == m.clickCount && time.Since(m.lastClickTime) >= doubleClickThreshold {
177			// If the click count matches and within threshold, copy selected text
178			if msg.endSelection {
179				m.listCmp.EndSelection(msg.x, msg.y)
180			}
181			m.listCmp.SelectionStop()
182			cmds = append(cmds, m.CopySelectedText(true))
183			return m, tea.Batch(cmds...)
184		}
185	case pubsub.Event[permission.PermissionNotification]:
186		cmds = append(cmds, m.handlePermissionRequest(msg.Payload))
187		return m, tea.Batch(cmds...)
188	case SessionSelectedMsg:
189		if msg.ID != m.session.ID {
190			cmds = append(cmds, m.SetSession(msg))
191		}
192		return m, tea.Batch(cmds...)
193	case SessionClearedMsg:
194		m.session = session.Session{}
195		cmds = append(cmds, m.listCmp.SetItems([]list.Item{}))
196		return m, tea.Batch(cmds...)
197
198	case pubsub.Event[message.Message]:
199		cmds = append(cmds, m.handleMessageEvent(msg))
200		return m, tea.Batch(cmds...)
201
202	case tea.MouseWheelMsg:
203		u, cmd := m.listCmp.Update(msg)
204		m.listCmp = u.(list.List[list.Item])
205		cmds = append(cmds, cmd)
206		return m, tea.Batch(cmds...)
207	}
208
209	u, cmd := m.listCmp.Update(msg)
210	m.listCmp = u.(list.List[list.Item])
211	cmds = append(cmds, cmd)
212	return m, tea.Batch(cmds...)
213}
214
215// View renders the message list or an initial screen if empty.
216func (m *messageListCmp) View() string {
217	t := styles.CurrentTheme()
218	return t.S().Base.
219		Padding(1, 1, 0, 1).
220		Width(m.width).
221		Height(m.height).
222		Render(m.listCmp.View())
223}
224
225func (m *messageListCmp) handlePermissionRequest(permission permission.PermissionNotification) tea.Cmd {
226	items := m.listCmp.Items()
227	if toolCallIndex := m.findToolCallByID(items, permission.ToolCallID); toolCallIndex != NotFound {
228		toolCall := items[toolCallIndex].(messages.ToolCallCmp)
229		toolCall.SetPermissionRequested()
230		if permission.Granted {
231			toolCall.SetPermissionGranted()
232		}
233		m.listCmp.UpdateItem(toolCall.ID(), toolCall)
234	}
235	return nil
236}
237
238// handleChildSession handles messages from child sessions (agent tools).
239func (m *messageListCmp) handleChildSession(event pubsub.Event[message.Message]) tea.Cmd {
240	var cmds []tea.Cmd
241	if len(event.Payload.ToolCalls()) == 0 && len(event.Payload.ToolResults()) == 0 {
242		return nil
243	}
244
245	// Check if this is an agent tool session and parse it
246	childSessionID := event.Payload.SessionID
247	parentMessageID, toolCallID, ok := m.app.Sessions.ParseAgentToolSessionID(childSessionID)
248	if !ok {
249		return nil
250	}
251	items := m.listCmp.Items()
252	toolCallInx := NotFound
253	var toolCall messages.ToolCallCmp
254	for i := len(items) - 1; i >= 0; i-- {
255		if msg, ok := items[i].(messages.ToolCallCmp); ok {
256			if msg.ParentMessageID() == parentMessageID && msg.GetToolCall().ID == toolCallID {
257				toolCallInx = i
258				toolCall = msg
259			}
260		}
261	}
262	if toolCallInx == NotFound {
263		return nil
264	}
265	nestedToolCalls := toolCall.GetNestedToolCalls()
266	for _, tc := range event.Payload.ToolCalls() {
267		found := false
268		for existingInx, existingTC := range nestedToolCalls {
269			if existingTC.GetToolCall().ID == tc.ID {
270				nestedToolCalls[existingInx].SetToolCall(tc)
271				found = true
272				break
273			}
274		}
275		if !found {
276			nestedCall := messages.NewToolCallCmp(
277				event.Payload.ID,
278				tc,
279				m.app.Permissions,
280				messages.WithToolCallNested(true),
281			)
282			cmds = append(cmds, nestedCall.Init())
283			nestedToolCalls = append(
284				nestedToolCalls,
285				nestedCall,
286			)
287		}
288	}
289	for _, tr := range event.Payload.ToolResults() {
290		for nestedInx, nestedTC := range nestedToolCalls {
291			if nestedTC.GetToolCall().ID == tr.ToolCallID {
292				nestedToolCalls[nestedInx].SetToolResult(tr)
293				break
294			}
295		}
296	}
297
298	toolCall.SetNestedToolCalls(nestedToolCalls)
299	m.listCmp.UpdateItem(
300		toolCall.ID(),
301		toolCall,
302	)
303	return tea.Batch(cmds...)
304}
305
306// handleMessageEvent processes different types of message events (created/updated).
307func (m *messageListCmp) handleMessageEvent(event pubsub.Event[message.Message]) tea.Cmd {
308	switch event.Type {
309	case pubsub.CreatedEvent:
310		if event.Payload.SessionID != m.session.ID {
311			return m.handleChildSession(event)
312		}
313		if m.messageExists(event.Payload.ID) {
314			return nil
315		}
316		return m.handleNewMessage(event.Payload)
317	case pubsub.DeletedEvent:
318		if event.Payload.SessionID != m.session.ID {
319			return nil
320		}
321		return m.handleDeleteMessage(event.Payload)
322	case pubsub.UpdatedEvent:
323		if event.Payload.SessionID != m.session.ID {
324			return m.handleChildSession(event)
325		}
326		switch event.Payload.Role {
327		case message.Assistant:
328			return m.handleUpdateAssistantMessage(event.Payload)
329		case message.Tool:
330			return m.handleToolMessage(event.Payload)
331		}
332	}
333	return nil
334}
335
336// messageExists checks if a message with the given ID already exists in the list.
337func (m *messageListCmp) messageExists(messageID string) bool {
338	items := m.listCmp.Items()
339	// Search backwards as new messages are more likely to be at the end
340	for i := len(items) - 1; i >= 0; i-- {
341		if msg, ok := items[i].(messages.MessageCmp); ok && msg.GetMessage().ID == messageID {
342			return true
343		}
344	}
345	return false
346}
347
348// handleDeleteMessage removes a message from the list.
349func (m *messageListCmp) handleDeleteMessage(msg message.Message) tea.Cmd {
350	items := m.listCmp.Items()
351	for i := len(items) - 1; i >= 0; i-- {
352		if msgCmp, ok := items[i].(messages.MessageCmp); ok && msgCmp.GetMessage().ID == msg.ID {
353			m.listCmp.DeleteItem(items[i].ID())
354			return nil
355		}
356	}
357	return nil
358}
359
360// handleNewMessage routes new messages to appropriate handlers based on role.
361func (m *messageListCmp) handleNewMessage(msg message.Message) tea.Cmd {
362	switch msg.Role {
363	case message.User:
364		return m.handleNewUserMessage(msg)
365	case message.Assistant:
366		return m.handleNewAssistantMessage(msg)
367	case message.Tool:
368		return m.handleToolMessage(msg)
369	}
370	return nil
371}
372
373// handleNewUserMessage adds a new user message to the list and updates the timestamp.
374func (m *messageListCmp) handleNewUserMessage(msg message.Message) tea.Cmd {
375	m.lastUserMessageTime = msg.CreatedAt
376	return m.listCmp.AppendItem(messages.NewMessageCmp(msg))
377}
378
379// handleToolMessage updates existing tool calls with their results.
380func (m *messageListCmp) handleToolMessage(msg message.Message) tea.Cmd {
381	items := m.listCmp.Items()
382	for _, tr := range msg.ToolResults() {
383		if toolCallIndex := m.findToolCallByID(items, tr.ToolCallID); toolCallIndex != NotFound {
384			toolCall := items[toolCallIndex].(messages.ToolCallCmp)
385			toolCall.SetToolResult(tr)
386			m.listCmp.UpdateItem(toolCall.ID(), toolCall)
387		}
388	}
389	return nil
390}
391
392// findToolCallByID searches for a tool call with the specified ID.
393// Returns the index if found, NotFound otherwise.
394func (m *messageListCmp) findToolCallByID(items []list.Item, toolCallID string) int {
395	// Search backwards as tool calls are more likely to be recent
396	for i := len(items) - 1; i >= 0; i-- {
397		if toolCall, ok := items[i].(messages.ToolCallCmp); ok && toolCall.GetToolCall().ID == toolCallID {
398			return i
399		}
400	}
401	return NotFound
402}
403
404// handleUpdateAssistantMessage processes updates to assistant messages,
405// managing both message content and associated tool calls.
406func (m *messageListCmp) handleUpdateAssistantMessage(msg message.Message) tea.Cmd {
407	var cmds []tea.Cmd
408	items := m.listCmp.Items()
409
410	// Find existing assistant message and tool calls for this message
411	assistantIndex, existingToolCalls := m.findAssistantMessageAndToolCalls(items, msg.ID)
412
413	// Handle assistant message content
414	if cmd := m.updateAssistantMessageContent(msg, assistantIndex); cmd != nil {
415		cmds = append(cmds, cmd)
416	}
417
418	// Handle tool calls
419	if cmd := m.updateToolCalls(msg, existingToolCalls); cmd != nil {
420		cmds = append(cmds, cmd)
421	}
422
423	return tea.Batch(cmds...)
424}
425
426// findAssistantMessageAndToolCalls locates the assistant message and its tool calls.
427func (m *messageListCmp) findAssistantMessageAndToolCalls(items []list.Item, messageID string) (int, map[int]messages.ToolCallCmp) {
428	assistantIndex := NotFound
429	toolCalls := make(map[int]messages.ToolCallCmp)
430
431	// Search backwards as messages are more likely to be at the end
432	for i := len(items) - 1; i >= 0; i-- {
433		item := items[i]
434		if asMsg, ok := item.(messages.MessageCmp); ok {
435			if asMsg.GetMessage().ID == messageID {
436				assistantIndex = i
437			}
438		} else if tc, ok := item.(messages.ToolCallCmp); ok {
439			if tc.ParentMessageID() == messageID {
440				toolCalls[i] = tc
441			}
442		}
443	}
444
445	return assistantIndex, toolCalls
446}
447
448// updateAssistantMessageContent updates or removes the assistant message based on content.
449func (m *messageListCmp) updateAssistantMessageContent(msg message.Message, assistantIndex int) tea.Cmd {
450	if assistantIndex == NotFound {
451		return nil
452	}
453
454	shouldShowMessage := m.shouldShowAssistantMessage(msg)
455	hasToolCallsOnly := len(msg.ToolCalls()) > 0 && msg.Content().Text == ""
456
457	var cmd tea.Cmd
458	if shouldShowMessage {
459		items := m.listCmp.Items()
460		uiMsg := items[assistantIndex].(messages.MessageCmp)
461		uiMsg.SetMessage(msg)
462		m.listCmp.UpdateItem(
463			items[assistantIndex].ID(),
464			uiMsg,
465		)
466		if msg.FinishPart() != nil && msg.FinishPart().Reason == message.FinishReasonEndTurn {
467			m.listCmp.AppendItem(
468				messages.NewAssistantSection(
469					msg,
470					time.Unix(m.lastUserMessageTime, 0),
471				),
472			)
473		}
474	} else if hasToolCallsOnly {
475		items := m.listCmp.Items()
476		m.listCmp.DeleteItem(items[assistantIndex].ID())
477	}
478
479	return cmd
480}
481
482// shouldShowAssistantMessage determines if an assistant message should be displayed.
483func (m *messageListCmp) shouldShowAssistantMessage(msg message.Message) bool {
484	return len(msg.ToolCalls()) == 0 || msg.Content().Text != "" || msg.ReasoningContent().Thinking != "" || msg.IsThinking()
485}
486
487// updateToolCalls handles updates to tool calls, updating existing ones and adding new ones.
488func (m *messageListCmp) updateToolCalls(msg message.Message, existingToolCalls map[int]messages.ToolCallCmp) tea.Cmd {
489	var cmds []tea.Cmd
490
491	for _, tc := range msg.ToolCalls() {
492		if cmd := m.updateOrAddToolCall(msg, tc, existingToolCalls); cmd != nil {
493			cmds = append(cmds, cmd)
494		}
495	}
496
497	return tea.Batch(cmds...)
498}
499
500// updateOrAddToolCall updates an existing tool call or adds a new one.
501func (m *messageListCmp) updateOrAddToolCall(msg message.Message, tc message.ToolCall, existingToolCalls map[int]messages.ToolCallCmp) tea.Cmd {
502	// Try to find existing tool call
503	for _, existingTC := range existingToolCalls {
504		if tc.ID == existingTC.GetToolCall().ID {
505			existingTC.SetToolCall(tc)
506			if msg.FinishPart() != nil && msg.FinishPart().Reason == message.FinishReasonCanceled {
507				existingTC.SetCancelled()
508			}
509			m.listCmp.UpdateItem(tc.ID, existingTC)
510			return nil
511		}
512	}
513
514	// Add new tool call if not found
515	return m.listCmp.AppendItem(messages.NewToolCallCmp(msg.ID, tc, m.app.Permissions))
516}
517
518// handleNewAssistantMessage processes new assistant messages and their tool calls.
519func (m *messageListCmp) handleNewAssistantMessage(msg message.Message) tea.Cmd {
520	var cmds []tea.Cmd
521
522	// Add assistant message if it should be displayed
523	if m.shouldShowAssistantMessage(msg) {
524		cmd := m.listCmp.AppendItem(
525			messages.NewMessageCmp(
526				msg,
527			),
528		)
529		cmds = append(cmds, cmd)
530	}
531
532	// Add tool calls
533	for _, tc := range msg.ToolCalls() {
534		cmd := m.listCmp.AppendItem(messages.NewToolCallCmp(msg.ID, tc, m.app.Permissions))
535		cmds = append(cmds, cmd)
536	}
537
538	return tea.Batch(cmds...)
539}
540
541// SetSession loads and displays messages for a new session.
542func (m *messageListCmp) SetSession(session session.Session) tea.Cmd {
543	if m.session.ID == session.ID {
544		return nil
545	}
546
547	m.session = session
548
549	// Recover any incomplete messages from interrupted sessions
550	if m.app.AgentCoordinator != nil {
551		if recoverErr := m.app.AgentCoordinator.RecoverSession(context.Background(), session.ID); recoverErr != nil {
552			slog.Error("Failed to recover session", "session_id", session.ID, "error", recoverErr)
553		}
554	}
555
556	sessionMessages, err := m.app.Messages.List(context.Background(), session.ID)
557	if err != nil {
558		return util.ReportError(err)
559	}
560
561	if len(sessionMessages) == 0 {
562		return m.listCmp.SetItems([]list.Item{})
563	}
564
565	// Initialize with first message timestamp
566	m.lastUserMessageTime = sessionMessages[0].CreatedAt
567
568	// Build tool result map for efficient lookup
569	toolResultMap := m.buildToolResultMap(sessionMessages)
570
571	// Convert messages to UI components
572	uiMessages := m.convertMessagesToUI(sessionMessages, toolResultMap)
573
574	return m.listCmp.SetItems(uiMessages)
575}
576
577// buildToolResultMap creates a map of tool call ID to tool result for efficient lookup.
578func (m *messageListCmp) buildToolResultMap(messages []message.Message) map[string]message.ToolResult {
579	toolResultMap := make(map[string]message.ToolResult)
580	for _, msg := range messages {
581		for _, tr := range msg.ToolResults() {
582			toolResultMap[tr.ToolCallID] = tr
583		}
584	}
585	return toolResultMap
586}
587
588// convertMessagesToUI converts database messages to UI components.
589func (m *messageListCmp) convertMessagesToUI(sessionMessages []message.Message, toolResultMap map[string]message.ToolResult) []list.Item {
590	uiMessages := make([]list.Item, 0)
591
592	for _, msg := range sessionMessages {
593		switch msg.Role {
594		case message.User:
595			m.lastUserMessageTime = msg.CreatedAt
596			uiMessages = append(uiMessages, messages.NewMessageCmp(msg))
597		case message.Assistant:
598			uiMessages = append(uiMessages, m.convertAssistantMessage(msg, toolResultMap)...)
599			if msg.FinishPart() != nil && msg.FinishPart().Reason == message.FinishReasonEndTurn {
600				uiMessages = append(uiMessages, messages.NewAssistantSection(msg, time.Unix(m.lastUserMessageTime, 0)))
601			}
602		}
603	}
604
605	return uiMessages
606}
607
608// convertAssistantMessage converts an assistant message and its tool calls to UI components.
609func (m *messageListCmp) convertAssistantMessage(msg message.Message, toolResultMap map[string]message.ToolResult) []list.Item {
610	var uiMessages []list.Item
611
612	// Add assistant message if it should be displayed
613	if m.shouldShowAssistantMessage(msg) {
614		uiMessages = append(
615			uiMessages,
616			messages.NewMessageCmp(
617				msg,
618			),
619		)
620	}
621
622	// Add tool calls with their results and status
623	for _, tc := range msg.ToolCalls() {
624		options := m.buildToolCallOptions(tc, msg, toolResultMap)
625		uiMessages = append(uiMessages, messages.NewToolCallCmp(msg.ID, tc, m.app.Permissions, options...))
626		// If this tool call is the agent tool or agentic fetch, fetch nested tool calls
627		if tc.Name == agent.AgentToolName || tc.Name == tools.AgenticFetchToolName {
628			agentToolSessionID := m.app.Sessions.CreateAgentToolSessionID(msg.ID, tc.ID)
629			nestedMessages, _ := m.app.Messages.List(context.Background(), agentToolSessionID)
630			nestedToolResultMap := m.buildToolResultMap(nestedMessages)
631			nestedUIMessages := m.convertMessagesToUI(nestedMessages, nestedToolResultMap)
632			nestedToolCalls := make([]messages.ToolCallCmp, 0, len(nestedUIMessages))
633			for _, nestedMsg := range nestedUIMessages {
634				if toolCall, ok := nestedMsg.(messages.ToolCallCmp); ok {
635					toolCall.SetIsNested(true)
636					nestedToolCalls = append(nestedToolCalls, toolCall)
637				}
638			}
639			uiMessages[len(uiMessages)-1].(messages.ToolCallCmp).SetNestedToolCalls(nestedToolCalls)
640		}
641	}
642
643	return uiMessages
644}
645
646// buildToolCallOptions creates options for tool call components based on results and status.
647func (m *messageListCmp) buildToolCallOptions(tc message.ToolCall, msg message.Message, toolResultMap map[string]message.ToolResult) []messages.ToolCallOption {
648	var options []messages.ToolCallOption
649
650	// Add tool result if available
651	if tr, ok := toolResultMap[tc.ID]; ok {
652		options = append(options, messages.WithToolCallResult(tr))
653	}
654
655	// Add cancelled status if applicable
656	if msg.FinishPart() != nil && msg.FinishPart().Reason == message.FinishReasonCanceled {
657		options = append(options, messages.WithToolCallCancelled())
658	}
659
660	return options
661}
662
663// GetSize returns the current width and height of the component.
664func (m *messageListCmp) GetSize() (int, int) {
665	return m.width, m.height
666}
667
668// SetSize updates the component dimensions and propagates to the list component.
669func (m *messageListCmp) SetSize(width int, height int) tea.Cmd {
670	m.width = width
671	m.height = height
672	return m.listCmp.SetSize(width-2, max(0, height-1)) // for padding
673}
674
675// Blur implements MessageListCmp.
676func (m *messageListCmp) Blur() tea.Cmd {
677	return m.listCmp.Blur()
678}
679
680// Focus implements MessageListCmp.
681func (m *messageListCmp) Focus() tea.Cmd {
682	return m.listCmp.Focus()
683}
684
685// IsFocused implements MessageListCmp.
686func (m *messageListCmp) IsFocused() bool {
687	return m.listCmp.IsFocused()
688}
689
690func (m *messageListCmp) Bindings() []key.Binding {
691	return m.defaultListKeyMap.KeyBindings()
692}
693
694func (m *messageListCmp) GoToBottom() tea.Cmd {
695	return m.listCmp.GoToBottom()
696}
697
698const (
699	doubleClickThreshold = 500 * time.Millisecond
700	clickTolerance       = 2 // pixels
701)
702
703// handleMouseClick handles mouse click events and detects double/triple clicks.
704func (m *messageListCmp) handleMouseClick(x, y int) tea.Cmd {
705	now := time.Now()
706
707	// Check if this is a potential multi-click
708	if now.Sub(m.lastClickTime) <= doubleClickThreshold &&
709		abs(x-m.lastClickX) <= clickTolerance &&
710		abs(y-m.lastClickY) <= clickTolerance {
711		m.clickCount++
712	} else {
713		m.clickCount = 1
714	}
715
716	m.lastClickTime = now
717	m.lastClickX = x
718	m.lastClickY = y
719
720	switch m.clickCount {
721	case 1:
722		// Single click - start selection
723		m.listCmp.StartSelection(x, y)
724	case 2:
725		// Double click - select word
726		m.listCmp.SelectWord(x, y)
727	case 3:
728		// Triple click - select paragraph
729		m.listCmp.SelectParagraph(x, y)
730		m.clickCount = 0 // Reset after triple click
731	}
732
733	return nil
734}
735
736// SelectionClear clears the current selection in the list component.
737func (m *messageListCmp) SelectionClear() tea.Cmd {
738	m.listCmp.SelectionClear()
739	m.previousSelected = ""
740	m.lastClickX, m.lastClickY = 0, 0
741	m.lastClickTime = time.Time{}
742	m.clickCount = 0
743	return nil
744}
745
746// HasSelection checks if there is a selection in the list component.
747func (m *messageListCmp) HasSelection() bool {
748	return m.listCmp.HasSelection()
749}
750
751// GetSelectedText returns the currently selected text from the list component.
752func (m *messageListCmp) GetSelectedText() string {
753	return m.listCmp.GetSelectedText(3) // 3 padding for the left border/padding
754}
755
756// CopySelectedText copies the currently selected text to the clipboard. When
757// clear is true, it clears the selection after copying.
758func (m *messageListCmp) CopySelectedText(clear bool) tea.Cmd {
759	if !m.listCmp.HasSelection() {
760		return nil
761	}
762
763	selectedText := m.GetSelectedText()
764	if selectedText == "" {
765		return util.ReportInfo("No text selected")
766	}
767
768	cmds := []tea.Cmd{
769		// We use both OSC 52 and native clipboard for compatibility with different
770		// terminal emulators and environments.
771		tea.SetClipboard(selectedText),
772		func() tea.Msg {
773			_ = clipboard.WriteAll(selectedText)
774			return nil
775		},
776		util.ReportInfo("Selected text copied to clipboard"),
777	}
778	if clear {
779		cmds = append(cmds, m.SelectionClear())
780	}
781
782	return tea.Sequence(cmds...)
783}
784
785// abs returns the absolute value of an integer.
786func abs(x int) int {
787	if x < 0 {
788		return -x
789	}
790	return x
791}