chat.go

  1package chat
  2
  3import (
  4	"context"
  5	"strings"
  6	"time"
  7
  8	"github.com/atotto/clipboard"
  9	"github.com/charmbracelet/bubbles/v2/key"
 10	tea "github.com/charmbracelet/bubbletea/v2"
 11	"github.com/charmbracelet/crush/internal/client"
 12	"github.com/charmbracelet/crush/internal/llm/agent"
 13	"github.com/charmbracelet/crush/internal/message"
 14	"github.com/charmbracelet/crush/internal/permission"
 15	"github.com/charmbracelet/crush/internal/proto"
 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	c                *client.Client
 63	ins              *proto.Instance
 64	width, height    int
 65	session          session.Session
 66	listCmp          list.List[list.Item]
 67	previousSelected string // Last selected item index for restoring focus
 68
 69	lastUserMessageTime int64
 70	defaultListKeyMap   list.KeyMap
 71
 72	// Click tracking for double/triple click detection
 73	lastClickTime time.Time
 74	lastClickX    int
 75	lastClickY    int
 76	clickCount    int
 77	promptQueue   int
 78}
 79
 80// New creates a new message list component with custom keybindings
 81// and reverse ordering (newest messages at bottom).
 82func New(app *client.Client, ins *proto.Instance) MessageListCmp {
 83	defaultListKeyMap := list.DefaultKeyMap()
 84	listCmp := list.New(
 85		[]list.Item{},
 86		list.WithGap(1),
 87		list.WithDirectionBackward(),
 88		list.WithFocus(false),
 89		list.WithKeyMap(defaultListKeyMap),
 90		list.WithEnableMouse(),
 91	)
 92	return &messageListCmp{
 93		c:                 app,
 94		ins:               ins,
 95		listCmp:           listCmp,
 96		previousSelected:  "",
 97		defaultListKeyMap: defaultListKeyMap,
 98	}
 99}
100
101// Init initializes the component.
102func (m *messageListCmp) Init() tea.Cmd {
103	return m.listCmp.Init()
104}
105
106// Update handles incoming messages and updates the component state.
107func (m *messageListCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
108	var cmds []tea.Cmd
109	info, err := m.c.GetAgentInfo(context.TODO(), m.ins.ID)
110	if m.session.ID != "" && err == nil && !info.IsZero() {
111		queueSize, _ := m.c.GetAgentSessionQueuedPrompts(context.TODO(), m.ins.ID, m.session.ID)
112		if queueSize != m.promptQueue {
113			m.promptQueue = queueSize
114			cmds = append(cmds, m.SetSize(m.width, m.height))
115		}
116	}
117	switch msg := msg.(type) {
118	case tea.KeyPressMsg:
119		if m.listCmp.IsFocused() && m.listCmp.HasSelection() {
120			switch {
121			case key.Matches(msg, messages.CopyKey):
122				cmds = append(cmds, m.CopySelectedText(true))
123				return m, tea.Batch(cmds...)
124			case key.Matches(msg, messages.ClearSelectionKey):
125				cmds = append(cmds, m.SelectionClear())
126				return m, tea.Batch(cmds...)
127			}
128		}
129	case tea.MouseClickMsg:
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			return m, nil // Ignore clicks outside the component
134		}
135		if msg.Button == tea.MouseLeft {
136			cmds = append(cmds, m.handleMouseClick(x, y))
137			return m, tea.Batch(cmds...)
138		}
139		return m, tea.Batch(cmds...)
140	case tea.MouseMotionMsg:
141		x := msg.X - 1 // Adjust for padding
142		y := msg.Y - 1 // Adjust for padding
143		if x < 0 || y < 0 || x >= m.width-2 || y >= m.height-1 {
144			if y < 0 {
145				cmds = append(cmds, m.listCmp.MoveUp(1))
146				return m, tea.Batch(cmds...)
147			}
148			if y >= m.height-1 {
149				cmds = append(cmds, m.listCmp.MoveDown(1))
150				return m, tea.Batch(cmds...)
151			}
152			return m, nil // Ignore clicks outside the component
153		}
154		if msg.Button == tea.MouseLeft {
155			m.listCmp.EndSelection(x, y)
156		}
157		return m, tea.Batch(cmds...)
158	case tea.MouseReleaseMsg:
159		x := msg.X - 1 // Adjust for padding
160		y := msg.Y - 1 // Adjust for padding
161		if msg.Button == tea.MouseLeft {
162			clickCount := m.clickCount
163			if x < 0 || y < 0 || x >= m.width-2 || y >= m.height-1 {
164				tick := tea.Tick(doubleClickThreshold, func(time.Time) tea.Msg {
165					return SelectionCopyMsg{
166						clickCount:   clickCount,
167						endSelection: false,
168					}
169				})
170
171				cmds = append(cmds, tick)
172				return m, tea.Batch(cmds...)
173			}
174			tick := tea.Tick(doubleClickThreshold, func(time.Time) tea.Msg {
175				return SelectionCopyMsg{
176					clickCount:   clickCount,
177					endSelection: true,
178					x:            x,
179					y:            y,
180				}
181			})
182			cmds = append(cmds, tick)
183			return m, tea.Batch(cmds...)
184		}
185		return m, nil
186	case SelectionCopyMsg:
187		if msg.clickCount == m.clickCount && time.Since(m.lastClickTime) >= doubleClickThreshold {
188			// If the click count matches and within threshold, copy selected text
189			if msg.endSelection {
190				m.listCmp.EndSelection(msg.x, msg.y)
191			}
192			m.listCmp.SelectionStop()
193			cmds = append(cmds, m.CopySelectedText(true))
194			return m, tea.Batch(cmds...)
195		}
196	case pubsub.Event[permission.PermissionNotification]:
197		cmds = append(cmds, m.handlePermissionRequest(msg.Payload))
198		return m, tea.Batch(cmds...)
199	case SessionSelectedMsg:
200		if msg.ID != m.session.ID {
201			cmds = append(cmds, m.SetSession(msg))
202		}
203		return m, tea.Batch(cmds...)
204	case SessionClearedMsg:
205		m.session = session.Session{}
206		cmds = append(cmds, m.listCmp.SetItems([]list.Item{}))
207		return m, tea.Batch(cmds...)
208
209	case pubsub.Event[message.Message]:
210		cmds = append(cmds, m.handleMessageEvent(msg))
211		return m, tea.Batch(cmds...)
212
213	case tea.MouseWheelMsg:
214		u, cmd := m.listCmp.Update(msg)
215		m.listCmp = u.(list.List[list.Item])
216		cmds = append(cmds, cmd)
217		return m, tea.Batch(cmds...)
218	}
219
220	u, cmd := m.listCmp.Update(msg)
221	m.listCmp = u.(list.List[list.Item])
222	cmds = append(cmds, cmd)
223	return m, tea.Batch(cmds...)
224}
225
226// View renders the message list or an initial screen if empty.
227func (m *messageListCmp) View() string {
228	t := styles.CurrentTheme()
229	height := m.height
230	if m.promptQueue > 0 {
231		height -= 4 // pill height and padding
232	}
233	view := []string{
234		t.S().Base.
235			Padding(1, 1, 0, 1).
236			Width(m.width).
237			Height(height).
238			Render(
239				m.listCmp.View(),
240			),
241	}
242	info, err := m.c.GetAgentInfo(context.TODO(), m.ins.ID)
243	if err == nil && !info.IsZero() && m.promptQueue > 0 {
244		queuePill := queuePill(m.promptQueue, t)
245		view = append(view, t.S().Base.PaddingLeft(4).PaddingTop(1).Render(queuePill))
246	}
247	return strings.Join(view, "\n")
248}
249
250func (m *messageListCmp) handlePermissionRequest(permission permission.PermissionNotification) tea.Cmd {
251	items := m.listCmp.Items()
252	if toolCallIndex := m.findToolCallByID(items, permission.ToolCallID); toolCallIndex != NotFound {
253		toolCall := items[toolCallIndex].(messages.ToolCallCmp)
254		toolCall.SetPermissionRequested()
255		if permission.Granted {
256			toolCall.SetPermissionGranted()
257		}
258		m.listCmp.UpdateItem(toolCall.ID(), toolCall)
259	}
260	return nil
261}
262
263// handleChildSession handles messages from child sessions (agent tools).
264func (m *messageListCmp) handleChildSession(event pubsub.Event[message.Message]) tea.Cmd {
265	var cmds []tea.Cmd
266	if len(event.Payload.ToolCalls()) == 0 && len(event.Payload.ToolResults()) == 0 {
267		return nil
268	}
269	items := m.listCmp.Items()
270	toolCallInx := NotFound
271	var toolCall messages.ToolCallCmp
272	for i := len(items) - 1; i >= 0; i-- {
273		if msg, ok := items[i].(messages.ToolCallCmp); ok {
274			if msg.GetToolCall().ID == event.Payload.SessionID {
275				toolCallInx = i
276				toolCall = msg
277			}
278		}
279	}
280	if toolCallInx == NotFound {
281		return nil
282	}
283	nestedToolCalls := toolCall.GetNestedToolCalls()
284	for _, tc := range event.Payload.ToolCalls() {
285		found := false
286		for existingInx, existingTC := range nestedToolCalls {
287			if existingTC.GetToolCall().ID == tc.ID {
288				nestedToolCalls[existingInx].SetToolCall(tc)
289				found = true
290				break
291			}
292		}
293		if !found {
294			nestedCall := messages.NewToolCallCmp(
295				event.Payload.ID,
296				tc,
297				messages.WithToolCallNested(true),
298			)
299			cmds = append(cmds, nestedCall.Init())
300			nestedToolCalls = append(
301				nestedToolCalls,
302				nestedCall,
303			)
304		}
305	}
306	for _, tr := range event.Payload.ToolResults() {
307		for nestedInx, nestedTC := range nestedToolCalls {
308			if nestedTC.GetToolCall().ID == tr.ToolCallID {
309				nestedToolCalls[nestedInx].SetToolResult(tr)
310				break
311			}
312		}
313	}
314
315	toolCall.SetNestedToolCalls(nestedToolCalls)
316	m.listCmp.UpdateItem(
317		toolCall.ID(),
318		toolCall,
319	)
320	return tea.Batch(cmds...)
321}
322
323// handleMessageEvent processes different types of message events (created/updated).
324func (m *messageListCmp) handleMessageEvent(event pubsub.Event[message.Message]) tea.Cmd {
325	switch event.Type {
326	case pubsub.CreatedEvent:
327		if event.Payload.SessionID != m.session.ID {
328			return m.handleChildSession(event)
329		}
330		if m.messageExists(event.Payload.ID) {
331			return nil
332		}
333		return m.handleNewMessage(event.Payload)
334	case pubsub.UpdatedEvent:
335		if event.Payload.SessionID != m.session.ID {
336			return m.handleChildSession(event)
337		}
338		switch event.Payload.Role {
339		case message.Assistant:
340			return m.handleUpdateAssistantMessage(event.Payload)
341		case message.Tool:
342			return m.handleToolMessage(event.Payload)
343		}
344	}
345	return nil
346}
347
348// messageExists checks if a message with the given ID already exists in the list.
349func (m *messageListCmp) messageExists(messageID string) bool {
350	items := m.listCmp.Items()
351	// Search backwards as new messages are more likely to be at the end
352	for i := len(items) - 1; i >= 0; i-- {
353		if msg, ok := items[i].(messages.MessageCmp); ok && msg.GetMessage().ID == messageID {
354			return true
355		}
356	}
357	return false
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(m.ins.Config, 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					m.ins.Config,
470					msg,
471					time.Unix(m.lastUserMessageTime, 0),
472				),
473			)
474		}
475	} else if hasToolCallsOnly {
476		items := m.listCmp.Items()
477		m.listCmp.DeleteItem(items[assistantIndex].ID())
478	}
479
480	return cmd
481}
482
483// shouldShowAssistantMessage determines if an assistant message should be displayed.
484func (m *messageListCmp) shouldShowAssistantMessage(msg message.Message) bool {
485	return len(msg.ToolCalls()) == 0 || msg.Content().Text != "" || msg.ReasoningContent().Thinking != "" || msg.IsThinking()
486}
487
488// updateToolCalls handles updates to tool calls, updating existing ones and adding new ones.
489func (m *messageListCmp) updateToolCalls(msg message.Message, existingToolCalls map[int]messages.ToolCallCmp) tea.Cmd {
490	var cmds []tea.Cmd
491
492	for _, tc := range msg.ToolCalls() {
493		if cmd := m.updateOrAddToolCall(msg, tc, existingToolCalls); cmd != nil {
494			cmds = append(cmds, cmd)
495		}
496	}
497
498	return tea.Batch(cmds...)
499}
500
501// updateOrAddToolCall updates an existing tool call or adds a new one.
502func (m *messageListCmp) updateOrAddToolCall(msg message.Message, tc message.ToolCall, existingToolCalls map[int]messages.ToolCallCmp) tea.Cmd {
503	// Try to find existing tool call
504	for _, existingTC := range existingToolCalls {
505		if tc.ID == existingTC.GetToolCall().ID {
506			existingTC.SetToolCall(tc)
507			if msg.FinishPart() != nil && msg.FinishPart().Reason == message.FinishReasonCanceled {
508				existingTC.SetCancelled()
509			}
510			m.listCmp.UpdateItem(tc.ID, existingTC)
511			return nil
512		}
513	}
514
515	// Add new tool call if not found
516	return m.listCmp.AppendItem(messages.NewToolCallCmp(msg.ID, tc))
517}
518
519// handleNewAssistantMessage processes new assistant messages and their tool calls.
520func (m *messageListCmp) handleNewAssistantMessage(msg message.Message) tea.Cmd {
521	var cmds []tea.Cmd
522
523	// Add assistant message if it should be displayed
524	if m.shouldShowAssistantMessage(msg) {
525		cmd := m.listCmp.AppendItem(
526			messages.NewMessageCmp(
527				m.ins.Config,
528				msg,
529			),
530		)
531		cmds = append(cmds, cmd)
532	}
533
534	// Add tool calls
535	for _, tc := range msg.ToolCalls() {
536		cmd := m.listCmp.AppendItem(messages.NewToolCallCmp(msg.ID, tc))
537		cmds = append(cmds, cmd)
538	}
539
540	return tea.Batch(cmds...)
541}
542
543// SetSession loads and displays messages for a new session.
544func (m *messageListCmp) SetSession(session session.Session) tea.Cmd {
545	if m.session.ID == session.ID {
546		return nil
547	}
548
549	m.session = session
550	sessionMessages, err := m.c.ListMessages(context.Background(), m.ins.ID, session.ID)
551	if err != nil {
552		return util.ReportError(err)
553	}
554
555	if len(sessionMessages) == 0 {
556		return m.listCmp.SetItems([]list.Item{})
557	}
558
559	// Initialize with first message timestamp
560	m.lastUserMessageTime = sessionMessages[0].CreatedAt
561
562	// Build tool result map for efficient lookup
563	toolResultMap := m.buildToolResultMap(sessionMessages)
564
565	// Convert messages to UI components
566	uiMessages := m.convertMessagesToUI(sessionMessages, toolResultMap)
567
568	return m.listCmp.SetItems(uiMessages)
569}
570
571// buildToolResultMap creates a map of tool call ID to tool result for efficient lookup.
572func (m *messageListCmp) buildToolResultMap(messages []message.Message) map[string]message.ToolResult {
573	toolResultMap := make(map[string]message.ToolResult)
574	for _, msg := range messages {
575		for _, tr := range msg.ToolResults() {
576			toolResultMap[tr.ToolCallID] = tr
577		}
578	}
579	return toolResultMap
580}
581
582// convertMessagesToUI converts database messages to UI components.
583func (m *messageListCmp) convertMessagesToUI(sessionMessages []message.Message, toolResultMap map[string]message.ToolResult) []list.Item {
584	uiMessages := make([]list.Item, 0)
585
586	for _, msg := range sessionMessages {
587		switch msg.Role {
588		case message.User:
589			m.lastUserMessageTime = msg.CreatedAt
590			uiMessages = append(uiMessages, messages.NewMessageCmp(m.ins.Config, msg))
591		case message.Assistant:
592			uiMessages = append(uiMessages, m.convertAssistantMessage(msg, toolResultMap)...)
593			if msg.FinishPart() != nil && msg.FinishPart().Reason == message.FinishReasonEndTurn {
594				uiMessages = append(uiMessages, messages.NewAssistantSection(m.ins.Config, msg, time.Unix(m.lastUserMessageTime, 0)))
595			}
596		}
597	}
598
599	return uiMessages
600}
601
602// convertAssistantMessage converts an assistant message and its tool calls to UI components.
603func (m *messageListCmp) convertAssistantMessage(msg message.Message, toolResultMap map[string]message.ToolResult) []list.Item {
604	var uiMessages []list.Item
605
606	// Add assistant message if it should be displayed
607	if m.shouldShowAssistantMessage(msg) {
608		uiMessages = append(
609			uiMessages,
610			messages.NewMessageCmp(
611				m.ins.Config, msg,
612			),
613		)
614	}
615
616	// Add tool calls with their results and status
617	for _, tc := range msg.ToolCalls() {
618		options := m.buildToolCallOptions(tc, msg, toolResultMap)
619		uiMessages = append(uiMessages, messages.NewToolCallCmp(msg.ID, tc, options...))
620		// If this tool call is the agent tool, fetch nested tool calls
621		if tc.Name == agent.AgentToolName {
622			nestedMessages, _ := m.c.ListMessages(context.Background(), m.ins.ID, tc.ID)
623			nestedToolResultMap := m.buildToolResultMap(nestedMessages)
624			nestedUIMessages := m.convertMessagesToUI(nestedMessages, nestedToolResultMap)
625			nestedToolCalls := make([]messages.ToolCallCmp, 0, len(nestedUIMessages))
626			for _, nestedMsg := range nestedUIMessages {
627				if toolCall, ok := nestedMsg.(messages.ToolCallCmp); ok {
628					toolCall.SetIsNested(true)
629					nestedToolCalls = append(nestedToolCalls, toolCall)
630				}
631			}
632			uiMessages[len(uiMessages)-1].(messages.ToolCallCmp).SetNestedToolCalls(nestedToolCalls)
633		}
634	}
635
636	return uiMessages
637}
638
639// buildToolCallOptions creates options for tool call components based on results and status.
640func (m *messageListCmp) buildToolCallOptions(tc message.ToolCall, msg message.Message, toolResultMap map[string]message.ToolResult) []messages.ToolCallOption {
641	var options []messages.ToolCallOption
642
643	// Add tool result if available
644	if tr, ok := toolResultMap[tc.ID]; ok {
645		options = append(options, messages.WithToolCallResult(tr))
646	}
647
648	// Add cancelled status if applicable
649	if msg.FinishPart() != nil && msg.FinishPart().Reason == message.FinishReasonCanceled {
650		options = append(options, messages.WithToolCallCancelled())
651	}
652
653	return options
654}
655
656// GetSize returns the current width and height of the component.
657func (m *messageListCmp) GetSize() (int, int) {
658	return m.width, m.height
659}
660
661// SetSize updates the component dimensions and propagates to the list component.
662func (m *messageListCmp) SetSize(width int, height int) tea.Cmd {
663	m.width = width
664	m.height = height
665	if m.promptQueue > 0 {
666		queueHeight := 3 + 1 // 1 for padding top
667		lHight := max(0, height-(1+queueHeight))
668		return m.listCmp.SetSize(width-2, lHight)
669	}
670	return m.listCmp.SetSize(width-2, max(0, height-1)) // for padding
671}
672
673// Blur implements MessageListCmp.
674func (m *messageListCmp) Blur() tea.Cmd {
675	return m.listCmp.Blur()
676}
677
678// Focus implements MessageListCmp.
679func (m *messageListCmp) Focus() tea.Cmd {
680	return m.listCmp.Focus()
681}
682
683// IsFocused implements MessageListCmp.
684func (m *messageListCmp) IsFocused() bool {
685	return m.listCmp.IsFocused()
686}
687
688func (m *messageListCmp) Bindings() []key.Binding {
689	return m.defaultListKeyMap.KeyBindings()
690}
691
692func (m *messageListCmp) GoToBottom() tea.Cmd {
693	return m.listCmp.GoToBottom()
694}
695
696const (
697	doubleClickThreshold = 500 * time.Millisecond
698	clickTolerance       = 2 // pixels
699)
700
701// handleMouseClick handles mouse click events and detects double/triple clicks.
702func (m *messageListCmp) handleMouseClick(x, y int) tea.Cmd {
703	now := time.Now()
704
705	// Check if this is a potential multi-click
706	if now.Sub(m.lastClickTime) <= doubleClickThreshold &&
707		abs(x-m.lastClickX) <= clickTolerance &&
708		abs(y-m.lastClickY) <= clickTolerance {
709		m.clickCount++
710	} else {
711		m.clickCount = 1
712	}
713
714	m.lastClickTime = now
715	m.lastClickX = x
716	m.lastClickY = y
717
718	switch m.clickCount {
719	case 1:
720		// Single click - start selection
721		m.listCmp.StartSelection(x, y)
722	case 2:
723		// Double click - select word
724		m.listCmp.SelectWord(x, y)
725	case 3:
726		// Triple click - select paragraph
727		m.listCmp.SelectParagraph(x, y)
728		m.clickCount = 0 // Reset after triple click
729	}
730
731	return nil
732}
733
734// SelectionClear clears the current selection in the list component.
735func (m *messageListCmp) SelectionClear() tea.Cmd {
736	m.listCmp.SelectionClear()
737	m.previousSelected = ""
738	m.lastClickX, m.lastClickY = 0, 0
739	m.lastClickTime = time.Time{}
740	m.clickCount = 0
741	return nil
742}
743
744// HasSelection checks if there is a selection in the list component.
745func (m *messageListCmp) HasSelection() bool {
746	return m.listCmp.HasSelection()
747}
748
749// GetSelectedText returns the currently selected text from the list component.
750func (m *messageListCmp) GetSelectedText() string {
751	return m.listCmp.GetSelectedText(3) // 3 padding for the left border/padding
752}
753
754// CopySelectedText copies the currently selected text to the clipboard. When
755// clear is true, it clears the selection after copying.
756func (m *messageListCmp) CopySelectedText(clear bool) tea.Cmd {
757	if !m.listCmp.HasSelection() {
758		return nil
759	}
760
761	selectedText := m.GetSelectedText()
762	if selectedText == "" {
763		return util.ReportInfo("No text selected")
764	}
765
766	if clear {
767		defer func() { m.SelectionClear() }()
768	}
769
770	return tea.Sequence(
771		// We use both OSC 52 and native clipboard for compatibility with different
772		// terminal emulators and environments.
773		tea.SetClipboard(selectedText),
774		func() tea.Msg {
775			_ = clipboard.WriteAll(selectedText)
776			return nil
777		},
778		util.ReportInfo("Selected text copied to clipboard"),
779	)
780}
781
782// abs returns the absolute value of an integer.
783func abs(x int) int {
784	if x < 0 {
785		return -x
786	}
787	return x
788}