chat.go

  1package model
  2
  3import (
  4	"strings"
  5	"time"
  6
  7	tea "charm.land/bubbletea/v2"
  8	"charm.land/lipgloss/v2"
  9	"github.com/charmbracelet/crush/internal/ui/anim"
 10	"github.com/charmbracelet/crush/internal/ui/chat"
 11	"github.com/charmbracelet/crush/internal/ui/common"
 12	"github.com/charmbracelet/crush/internal/ui/list"
 13	uv "github.com/charmbracelet/ultraviolet"
 14	"github.com/charmbracelet/x/ansi"
 15	"github.com/clipperhouse/displaywidth"
 16	"github.com/clipperhouse/uax29/v2/words"
 17)
 18
 19// Constants for multi-click detection.
 20const (
 21	doubleClickThreshold = 400 * time.Millisecond // 0.4s is typical double-click threshold
 22	clickTolerance       = 2                      // x,y tolerance for double/tripple click
 23)
 24
 25// DelayedClickMsg is sent after the double-click threshold to trigger a
 26// single-click action (like expansion) if no double-click occurred.
 27type DelayedClickMsg struct {
 28	ClickID int
 29	ItemIdx int
 30	X, Y    int
 31}
 32
 33// Chat represents the chat UI model that handles chat interactions and
 34// messages.
 35type Chat struct {
 36	com      *common.Common
 37	list     *list.List
 38	idInxMap map[string]int // Map of message IDs to their indices in the list
 39
 40	// Animation visibility optimization: track animations paused due to items
 41	// being scrolled out of view. When items become visible again, their
 42	// animations are restarted.
 43	pausedAnimations map[string]struct{}
 44
 45	// Mouse state
 46	mouseDown     bool
 47	mouseDownItem int // Item index where mouse was pressed
 48	mouseDownX    int // X position in item content (character offset)
 49	mouseDownY    int // Y position in item (line offset)
 50	mouseDragItem int // Current item index being dragged over
 51	mouseDragX    int // Current X in item content
 52	mouseDragY    int // Current Y in item
 53
 54	// Click tracking for double/triple clicks
 55	lastClickTime time.Time
 56	lastClickX    int
 57	lastClickY    int
 58	clickCount    int
 59
 60	// Pending single click action (delayed to detect double-click)
 61	pendingClickID int // Incremented on each click to invalidate old pending clicks
 62
 63	// follow is a flag to indicate whether the view should auto-scroll to
 64	// bottom on new messages.
 65	follow bool
 66}
 67
 68// NewChat creates a new instance of [Chat] that handles chat interactions and
 69// messages.
 70func NewChat(com *common.Common) *Chat {
 71	c := &Chat{
 72		com:              com,
 73		idInxMap:         make(map[string]int),
 74		pausedAnimations: make(map[string]struct{}),
 75	}
 76	l := list.NewList()
 77	l.SetGap(1)
 78	l.RegisterRenderCallback(c.applyHighlightRange)
 79	l.RegisterRenderCallback(list.FocusedRenderCallback(l))
 80	c.list = l
 81	c.mouseDownItem = -1
 82	c.mouseDragItem = -1
 83	return c
 84}
 85
 86// Height returns the height of the chat view port.
 87func (m *Chat) Height() int {
 88	return m.list.Height()
 89}
 90
 91// Draw renders the chat UI component to the screen and the given area.
 92func (m *Chat) Draw(scr uv.Screen, area uv.Rectangle) {
 93	uv.NewStyledString(m.list.Render()).Draw(scr, area)
 94}
 95
 96// SetSize sets the size of the chat view port.
 97func (m *Chat) SetSize(width, height int) {
 98	m.list.SetSize(width, height)
 99	// Anchor to bottom if we were at the bottom.
100	if m.AtBottom() {
101		m.ScrollToBottom()
102	}
103}
104
105// Len returns the number of items in the chat list.
106func (m *Chat) Len() int {
107	return m.list.Len()
108}
109
110// InvalidateRenderCaches drops cached rendered output on every message
111// item so the next draw re-renders with the current styles.
112func (m *Chat) InvalidateRenderCaches() {
113	items := make([]chat.MessageItem, 0, m.list.Len())
114	for i := range m.list.Len() {
115		if item, ok := m.list.ItemAt(i).(chat.MessageItem); ok {
116			items = append(items, item)
117		}
118	}
119	chat.ClearItemCaches(items)
120}
121
122// SetMessages sets the chat messages to the provided list of message items.
123func (m *Chat) SetMessages(msgs ...chat.MessageItem) {
124	m.idInxMap = make(map[string]int)
125	m.pausedAnimations = make(map[string]struct{})
126
127	items := make([]list.Item, len(msgs))
128	for i, msg := range msgs {
129		m.idInxMap[msg.ID()] = i
130		// Register nested tool IDs for tools that contain nested tools.
131		if container, ok := msg.(chat.NestedToolContainer); ok {
132			for _, nested := range container.NestedTools() {
133				m.idInxMap[nested.ID()] = i
134			}
135		}
136		items[i] = msg
137	}
138	m.list.SetItems(items...)
139	m.ScrollToBottom()
140}
141
142// AppendMessages appends a new message item to the chat list.
143func (m *Chat) AppendMessages(msgs ...chat.MessageItem) {
144	items := make([]list.Item, len(msgs))
145	indexOffset := m.list.Len()
146	for i, msg := range msgs {
147		m.idInxMap[msg.ID()] = indexOffset + i
148		// Register nested tool IDs for tools that contain nested tools.
149		if container, ok := msg.(chat.NestedToolContainer); ok {
150			for _, nested := range container.NestedTools() {
151				m.idInxMap[nested.ID()] = indexOffset + i
152			}
153		}
154		items[i] = msg
155	}
156	m.list.AppendItems(items...)
157}
158
159// UpdateNestedToolIDs updates the ID map for nested tools within a container.
160// Call this after modifying nested tools to ensure animations work correctly.
161func (m *Chat) UpdateNestedToolIDs(containerID string) {
162	idx, ok := m.idInxMap[containerID]
163	if !ok {
164		return
165	}
166
167	item, ok := m.list.ItemAt(idx).(chat.MessageItem)
168	if !ok {
169		return
170	}
171
172	container, ok := item.(chat.NestedToolContainer)
173	if !ok {
174		return
175	}
176
177	// Register all nested tool IDs to point to the container's index.
178	for _, nested := range container.NestedTools() {
179		m.idInxMap[nested.ID()] = idx
180	}
181}
182
183// Animate animates items in the chat list. Only propagates animation messages
184// to visible items to save CPU. When items are not visible, their animation ID
185// is tracked so it can be restarted when they become visible again.
186func (m *Chat) Animate(msg anim.StepMsg) tea.Cmd {
187	idx, ok := m.idInxMap[msg.ID]
188	if !ok {
189		return nil
190	}
191
192	animatable, ok := m.list.ItemAt(idx).(chat.Animatable)
193	if !ok {
194		return nil
195	}
196
197	// Check if item is currently visible.
198	startIdx, endIdx := m.list.VisibleItemIndices()
199	isVisible := idx >= startIdx && idx <= endIdx
200
201	if !isVisible {
202		// Item not visible - pause animation by not propagating.
203		// Track it so we can restart when it becomes visible.
204		m.pausedAnimations[msg.ID] = struct{}{}
205		return nil
206	}
207
208	// Item is visible - remove from paused set and animate.
209	delete(m.pausedAnimations, msg.ID)
210	return animatable.Animate(msg)
211}
212
213// RestartPausedVisibleAnimations restarts animations for items that were paused
214// due to being scrolled out of view but are now visible again.
215func (m *Chat) RestartPausedVisibleAnimations() tea.Cmd {
216	if len(m.pausedAnimations) == 0 {
217		return nil
218	}
219
220	startIdx, endIdx := m.list.VisibleItemIndices()
221	var cmds []tea.Cmd
222
223	for id := range m.pausedAnimations {
224		idx, ok := m.idInxMap[id]
225		if !ok {
226			// Item no longer exists.
227			delete(m.pausedAnimations, id)
228			continue
229		}
230
231		if idx >= startIdx && idx <= endIdx {
232			// Item is now visible - restart its animation.
233			if animatable, ok := m.list.ItemAt(idx).(chat.Animatable); ok {
234				if cmd := animatable.StartAnimation(); cmd != nil {
235					cmds = append(cmds, cmd)
236				}
237			}
238			delete(m.pausedAnimations, id)
239		}
240	}
241
242	if len(cmds) == 0 {
243		return nil
244	}
245	return tea.Batch(cmds...)
246}
247
248// Focus sets the focus state of the chat component.
249func (m *Chat) Focus() {
250	m.list.Focus()
251}
252
253// Blur removes the focus state from the chat component.
254func (m *Chat) Blur() {
255	m.list.Blur()
256}
257
258// AtBottom returns whether the chat list is currently scrolled to the bottom.
259func (m *Chat) AtBottom() bool {
260	return m.list.AtBottom()
261}
262
263// Follow returns whether the chat view is in follow mode (auto-scroll to
264// bottom on new messages).
265func (m *Chat) Follow() bool {
266	return m.follow
267}
268
269// ScrollToBottom scrolls the chat view to the bottom.
270func (m *Chat) ScrollToBottom() {
271	m.list.ScrollToBottom()
272	m.follow = true // Enable follow mode when user scrolls to bottom
273}
274
275// ScrollToTop scrolls the chat view to the top.
276func (m *Chat) ScrollToTop() {
277	m.list.ScrollToTop()
278	m.follow = false // Disable follow mode when user scrolls up
279}
280
281// ScrollBy scrolls the chat view by the given number of line deltas.
282func (m *Chat) ScrollBy(lines int) {
283	m.list.ScrollBy(lines)
284	m.follow = lines > 0 && m.AtBottom() // Disable follow mode if user scrolls up
285}
286
287// ScrollToSelected scrolls the chat view to the selected item.
288func (m *Chat) ScrollToSelected() {
289	m.list.ScrollToSelected()
290	m.follow = m.AtBottom() // Disable follow mode if user scrolls up
291}
292
293// ScrollToIndex scrolls the chat view to the item at the given index.
294func (m *Chat) ScrollToIndex(index int) {
295	m.list.ScrollToIndex(index)
296	m.follow = m.AtBottom() // Disable follow mode if user scrolls up
297}
298
299// ScrollToTopAndAnimate scrolls the chat view to the top and returns a command to restart
300// any paused animations that are now visible.
301func (m *Chat) ScrollToTopAndAnimate() tea.Cmd {
302	m.ScrollToTop()
303	return m.RestartPausedVisibleAnimations()
304}
305
306// ScrollToBottomAndAnimate scrolls the chat view to the bottom and returns a command to
307// restart any paused animations that are now visible.
308func (m *Chat) ScrollToBottomAndAnimate() tea.Cmd {
309	m.ScrollToBottom()
310	return m.RestartPausedVisibleAnimations()
311}
312
313// ScrollByAndAnimate scrolls the chat view by the given number of line deltas and returns
314// a command to restart any paused animations that are now visible.
315func (m *Chat) ScrollByAndAnimate(lines int) tea.Cmd {
316	m.ScrollBy(lines)
317	return m.RestartPausedVisibleAnimations()
318}
319
320// ScrollToSelectedAndAnimate scrolls the chat view to the selected item and returns a
321// command to restart any paused animations that are now visible.
322func (m *Chat) ScrollToSelectedAndAnimate() tea.Cmd {
323	m.ScrollToSelected()
324	return m.RestartPausedVisibleAnimations()
325}
326
327// SelectedItemInView returns whether the selected item is currently in view.
328func (m *Chat) SelectedItemInView() bool {
329	return m.list.SelectedItemInView()
330}
331
332func (m *Chat) isSelectable(index int) bool {
333	item := m.list.ItemAt(index)
334	if item == nil {
335		return false
336	}
337	_, ok := item.(list.Focusable)
338	return ok
339}
340
341// SetSelected sets the selected message index in the chat list.
342func (m *Chat) SetSelected(index int) {
343	m.list.SetSelected(index)
344	if index < 0 || index >= m.list.Len() {
345		return
346	}
347	for {
348		if m.isSelectable(m.list.Selected()) {
349			return
350		}
351		if m.list.SelectNext() {
352			continue
353		}
354		// If we're at the end and the last item isn't selectable, walk backwards
355		// to find the nearest selectable item.
356		for {
357			if !m.list.SelectPrev() {
358				return
359			}
360			if m.isSelectable(m.list.Selected()) {
361				return
362			}
363		}
364	}
365}
366
367// SelectPrev selects the previous message in the chat list.
368func (m *Chat) SelectPrev() {
369	for {
370		if !m.list.SelectPrev() {
371			return
372		}
373		if m.isSelectable(m.list.Selected()) {
374			return
375		}
376	}
377}
378
379// SelectNext selects the next message in the chat list.
380func (m *Chat) SelectNext() {
381	for {
382		if !m.list.SelectNext() {
383			return
384		}
385		if m.isSelectable(m.list.Selected()) {
386			return
387		}
388	}
389}
390
391// SelectFirst selects the first message in the chat list.
392func (m *Chat) SelectFirst() {
393	if !m.list.SelectFirst() {
394		return
395	}
396	if m.isSelectable(m.list.Selected()) {
397		return
398	}
399	for {
400		if !m.list.SelectNext() {
401			return
402		}
403		if m.isSelectable(m.list.Selected()) {
404			return
405		}
406	}
407}
408
409// SelectLast selects the last message in the chat list.
410func (m *Chat) SelectLast() {
411	if !m.list.SelectLast() {
412		return
413	}
414	if m.isSelectable(m.list.Selected()) {
415		return
416	}
417	for {
418		if !m.list.SelectPrev() {
419			return
420		}
421		if m.isSelectable(m.list.Selected()) {
422			return
423		}
424	}
425}
426
427// SelectFirstInView selects the first message currently in view.
428func (m *Chat) SelectFirstInView() {
429	startIdx, endIdx := m.list.VisibleItemIndices()
430	for i := startIdx; i <= endIdx; i++ {
431		if m.isSelectable(i) {
432			m.list.SetSelected(i)
433			return
434		}
435	}
436}
437
438// SelectLastInView selects the last message currently in view.
439func (m *Chat) SelectLastInView() {
440	startIdx, endIdx := m.list.VisibleItemIndices()
441	for i := endIdx; i >= startIdx; i-- {
442		if m.isSelectable(i) {
443			m.list.SetSelected(i)
444			return
445		}
446	}
447}
448
449// ClearMessages removes all messages from the chat list.
450func (m *Chat) ClearMessages() {
451	m.idInxMap = make(map[string]int)
452	m.pausedAnimations = make(map[string]struct{})
453	m.list.SetItems()
454	m.ClearMouse()
455}
456
457// RemoveMessage removes a message from the chat list by its ID.
458func (m *Chat) RemoveMessage(id string) {
459	idx, ok := m.idInxMap[id]
460	if !ok {
461		return
462	}
463
464	// Remove from list
465	m.list.RemoveItem(idx)
466
467	// Remove from index map
468	delete(m.idInxMap, id)
469
470	// Rebuild index map for all items after the removed one
471	for i := idx; i < m.list.Len(); i++ {
472		if item, ok := m.list.ItemAt(i).(chat.MessageItem); ok {
473			m.idInxMap[item.ID()] = i
474		}
475	}
476
477	// Clean up any paused animations for this message
478	delete(m.pausedAnimations, id)
479}
480
481// MessageItem returns the message item with the given ID, or nil if not found.
482func (m *Chat) MessageItem(id string) chat.MessageItem {
483	idx, ok := m.idInxMap[id]
484	if !ok {
485		return nil
486	}
487	item, ok := m.list.ItemAt(idx).(chat.MessageItem)
488	if !ok {
489		return nil
490	}
491	return item
492}
493
494// ToggleExpandedSelectedItem expands the selected message item if it is expandable.
495func (m *Chat) ToggleExpandedSelectedItem() {
496	if expandable, ok := m.list.SelectedItem().(chat.Expandable); ok {
497		if !expandable.ToggleExpanded() {
498			m.ScrollToIndex(m.list.Selected())
499		}
500		if m.AtBottom() {
501			m.ScrollToBottom()
502		}
503	}
504}
505
506// HandleKeyMsg handles key events for the chat component.
507func (m *Chat) HandleKeyMsg(key tea.KeyMsg) (bool, tea.Cmd) {
508	if m.list.Focused() {
509		if handler, ok := m.list.SelectedItem().(chat.KeyEventHandler); ok {
510			return handler.HandleKeyEvent(key)
511		}
512	}
513	return false, nil
514}
515
516// HandleMouseDown handles mouse down events for the chat component.
517// It detects single, double, and triple clicks for text selection.
518// Returns whether the click was handled and an optional command for delayed
519// single-click actions.
520func (m *Chat) HandleMouseDown(x, y int) (bool, tea.Cmd) {
521	if m.list.Len() == 0 {
522		return false, nil
523	}
524
525	itemIdx, itemY := m.list.ItemIndexAtPosition(x, y)
526	if itemIdx < 0 {
527		return false, nil
528	}
529	if !m.isSelectable(itemIdx) {
530		return false, nil
531	}
532
533	// Increment pending click ID to invalidate any previous pending clicks.
534	m.pendingClickID++
535	clickID := m.pendingClickID
536
537	// Detect multi-click (double/triple)
538	now := time.Now()
539	if now.Sub(m.lastClickTime) <= doubleClickThreshold &&
540		abs(x-m.lastClickX) <= clickTolerance &&
541		abs(y-m.lastClickY) <= clickTolerance {
542		m.clickCount++
543	} else {
544		m.clickCount = 1
545	}
546	m.lastClickTime = now
547	m.lastClickX = x
548	m.lastClickY = y
549
550	// Select the item that was clicked
551	m.list.SetSelected(itemIdx)
552
553	var cmd tea.Cmd
554
555	switch m.clickCount {
556	case 1:
557		// Single click - start selection and schedule delayed click action.
558		m.mouseDown = true
559		m.mouseDownItem = itemIdx
560		m.mouseDownX = x
561		m.mouseDownY = itemY
562		m.mouseDragItem = itemIdx
563		m.mouseDragX = x
564		m.mouseDragY = itemY
565
566		// Schedule delayed click action (e.g., expansion) after a short delay.
567		// If a double-click occurs, the clickID will be invalidated.
568		cmd = tea.Tick(doubleClickThreshold, func(t time.Time) tea.Msg {
569			return DelayedClickMsg{
570				ClickID: clickID,
571				ItemIdx: itemIdx,
572				X:       x,
573				Y:       itemY,
574			}
575		})
576	case 2:
577		// Double click - select word (no delayed action)
578		m.selectWord(itemIdx, x, itemY)
579	case 3:
580		// Triple click - select line (no delayed action)
581		m.selectLine(itemIdx, itemY)
582		m.clickCount = 0 // Reset after triple click
583	}
584
585	return true, cmd
586}
587
588// HandleDelayedClick handles a delayed single-click action (like expansion).
589// It only executes if the click ID matches (i.e., no double-click occurred)
590// and no text selection was made (drag to select).
591func (m *Chat) HandleDelayedClick(msg DelayedClickMsg) bool {
592	// Ignore if this click was superseded by a newer click (double/triple).
593	if msg.ClickID != m.pendingClickID {
594		return false
595	}
596
597	// Don't expand if user dragged to select text.
598	if m.HasHighlight() {
599		return false
600	}
601
602	// Execute the click action (e.g., expansion).
603	selectedItem := m.list.SelectedItem()
604	if clickable, ok := selectedItem.(list.MouseClickable); ok {
605		handled := clickable.HandleMouseClick(ansi.MouseButton1, msg.X, msg.Y)
606		// Toggle expansion if applicable.
607		if expandable, ok := selectedItem.(chat.Expandable); ok {
608			if !expandable.ToggleExpanded() {
609				m.ScrollToIndex(m.list.Selected())
610			}
611		}
612		if m.AtBottom() {
613			m.ScrollToBottom()
614		}
615		return handled
616	}
617
618	return false
619}
620
621// HandleMouseUp handles mouse up events for the chat component.
622func (m *Chat) HandleMouseUp(x, y int) bool {
623	if !m.mouseDown {
624		return false
625	}
626
627	m.mouseDown = false
628	return true
629}
630
631// HandleMouseDrag handles mouse drag events for the chat component.
632func (m *Chat) HandleMouseDrag(x, y int) bool {
633	if !m.mouseDown {
634		return false
635	}
636
637	if m.list.Len() == 0 {
638		return false
639	}
640
641	itemIdx, itemY := m.list.ItemIndexAtPosition(x, y)
642	if itemIdx < 0 {
643		return false
644	}
645
646	m.mouseDragItem = itemIdx
647	m.mouseDragX = x
648	m.mouseDragY = itemY
649
650	return true
651}
652
653// HasHighlight returns whether there is currently highlighted content.
654func (m *Chat) HasHighlight() bool {
655	startItemIdx, startLine, startCol, endItemIdx, endLine, endCol := m.getHighlightRange()
656	return startItemIdx >= 0 && endItemIdx >= 0 && (startLine != endLine || startCol != endCol)
657}
658
659// HighlightContent returns the currently highlighted content based on the mouse
660// selection. It returns an empty string if no content is highlighted.
661func (m *Chat) HighlightContent() string {
662	startItemIdx, startLine, startCol, endItemIdx, endLine, endCol := m.getHighlightRange()
663	if startItemIdx < 0 || endItemIdx < 0 || startLine == endLine && startCol == endCol {
664		return ""
665	}
666
667	var sb strings.Builder
668	for i := startItemIdx; i <= endItemIdx; i++ {
669		item := m.list.ItemAt(i)
670		if hi, ok := item.(list.Highlightable); ok {
671			startLine, startCol, endLine, endCol := hi.Highlight()
672			listWidth := m.list.Width()
673			var rendered string
674			if rr, ok := item.(list.RawRenderable); ok {
675				rendered = rr.RawRender(listWidth)
676			} else {
677				rendered = item.Render(listWidth)
678			}
679			sb.WriteString(list.HighlightContent(
680				rendered,
681				uv.Rect(0, 0, listWidth, lipgloss.Height(rendered)),
682				startLine,
683				startCol,
684				endLine,
685				endCol,
686			))
687			sb.WriteString(strings.Repeat("\n", m.list.Gap()))
688		}
689	}
690
691	return strings.TrimSpace(sb.String())
692}
693
694// ClearMouse clears the current mouse interaction state.
695func (m *Chat) ClearMouse() {
696	m.mouseDown = false
697	m.mouseDownItem = -1
698	m.mouseDragItem = -1
699	m.lastClickTime = time.Time{}
700	m.lastClickX = 0
701	m.lastClickY = 0
702	m.clickCount = 0
703	m.pendingClickID++ // Invalidate any pending delayed click
704}
705
706// applyHighlightRange applies the current highlight range to the chat items.
707func (m *Chat) applyHighlightRange(idx, selectedIdx int, item list.Item) list.Item {
708	if hi, ok := item.(list.Highlightable); ok {
709		// Apply highlight
710		startItemIdx, startLine, startCol, endItemIdx, endLine, endCol := m.getHighlightRange()
711		sLine, sCol, eLine, eCol := -1, -1, -1, -1
712		if idx >= startItemIdx && idx <= endItemIdx {
713			if idx == startItemIdx && idx == endItemIdx {
714				// Single item selection
715				sLine = startLine
716				sCol = startCol
717				eLine = endLine
718				eCol = endCol
719			} else if idx == startItemIdx {
720				// First item - from start position to end of item
721				sLine = startLine
722				sCol = startCol
723				eLine = -1
724				eCol = -1
725			} else if idx == endItemIdx {
726				// Last item - from start of item to end position
727				sLine = 0
728				sCol = 0
729				eLine = endLine
730				eCol = endCol
731			} else {
732				// Middle item - fully highlighted
733				sLine = 0
734				sCol = 0
735				eLine = -1
736				eCol = -1
737			}
738		}
739
740		hi.SetHighlight(sLine, sCol, eLine, eCol)
741		return hi.(list.Item)
742	}
743
744	return item
745}
746
747// getHighlightRange returns the current highlight range.
748func (m *Chat) getHighlightRange() (startItemIdx, startLine, startCol, endItemIdx, endLine, endCol int) {
749	if m.mouseDownItem < 0 {
750		return -1, -1, -1, -1, -1, -1
751	}
752
753	downItemIdx := m.mouseDownItem
754	dragItemIdx := m.mouseDragItem
755
756	// Determine selection direction
757	draggingDown := dragItemIdx > downItemIdx ||
758		(dragItemIdx == downItemIdx && m.mouseDragY > m.mouseDownY) ||
759		(dragItemIdx == downItemIdx && m.mouseDragY == m.mouseDownY && m.mouseDragX >= m.mouseDownX)
760
761	if draggingDown {
762		// Normal forward selection
763		startItemIdx = downItemIdx
764		startLine = m.mouseDownY
765		startCol = m.mouseDownX
766		endItemIdx = dragItemIdx
767		endLine = m.mouseDragY
768		endCol = m.mouseDragX
769	} else {
770		// Backward selection (dragging up)
771		startItemIdx = dragItemIdx
772		startLine = m.mouseDragY
773		startCol = m.mouseDragX
774		endItemIdx = downItemIdx
775		endLine = m.mouseDownY
776		endCol = m.mouseDownX
777	}
778
779	return startItemIdx, startLine, startCol, endItemIdx, endLine, endCol
780}
781
782// selectWord selects the word at the given position within an item.
783func (m *Chat) selectWord(itemIdx, x, itemY int) {
784	item := m.list.ItemAt(itemIdx)
785	if item == nil {
786		return
787	}
788
789	// Get the rendered content for this item
790	var rendered string
791	if rr, ok := item.(list.RawRenderable); ok {
792		rendered = rr.RawRender(m.list.Width())
793	} else {
794		rendered = item.Render(m.list.Width())
795	}
796
797	lines := strings.Split(rendered, "\n")
798	if itemY < 0 || itemY >= len(lines) {
799		return
800	}
801
802	// Adjust x for the item's left padding (border + padding) to get content column.
803	// The mouse x is in viewport space, but we need content space for boundary detection.
804	offset := chat.MessageLeftPaddingTotal
805	contentX := max(x-offset, 0)
806
807	line := ansi.Strip(lines[itemY])
808	startCol, endCol := findWordBoundaries(line, contentX)
809	if startCol == endCol {
810		// No word found at position, fallback to single click behavior
811		m.mouseDown = true
812		m.mouseDownItem = itemIdx
813		m.mouseDownX = x
814		m.mouseDownY = itemY
815		m.mouseDragItem = itemIdx
816		m.mouseDragX = x
817		m.mouseDragY = itemY
818		return
819	}
820
821	// Set selection to the word boundaries (convert back to viewport space).
822	// Keep mouseDown true so HandleMouseUp triggers the copy.
823	m.mouseDown = true
824	m.mouseDownItem = itemIdx
825	m.mouseDownX = startCol + offset
826	m.mouseDownY = itemY
827	m.mouseDragItem = itemIdx
828	m.mouseDragX = endCol + offset
829	m.mouseDragY = itemY
830}
831
832// selectLine selects the entire line at the given position within an item.
833func (m *Chat) selectLine(itemIdx, itemY int) {
834	item := m.list.ItemAt(itemIdx)
835	if item == nil {
836		return
837	}
838
839	// Get the rendered content for this item
840	var rendered string
841	if rr, ok := item.(list.RawRenderable); ok {
842		rendered = rr.RawRender(m.list.Width())
843	} else {
844		rendered = item.Render(m.list.Width())
845	}
846
847	lines := strings.Split(rendered, "\n")
848	if itemY < 0 || itemY >= len(lines) {
849		return
850	}
851
852	// Get line length (stripped of ANSI codes) and account for padding.
853	// SetHighlight will subtract the offset, so we need to add it here.
854	offset := chat.MessageLeftPaddingTotal
855	lineLen := ansi.StringWidth(lines[itemY])
856
857	// Set selection to the entire line.
858	// Keep mouseDown true so HandleMouseUp triggers the copy.
859	m.mouseDown = true
860	m.mouseDownItem = itemIdx
861	m.mouseDownX = 0
862	m.mouseDownY = itemY
863	m.mouseDragItem = itemIdx
864	m.mouseDragX = lineLen + offset
865	m.mouseDragY = itemY
866}
867
868// findWordBoundaries finds the start and end column of the word at the given column.
869// Returns (startCol, endCol) where endCol is exclusive.
870func findWordBoundaries(line string, col int) (startCol, endCol int) {
871	if line == "" || col < 0 {
872		return 0, 0
873	}
874
875	i := displaywidth.StringGraphemes(line)
876	for i.Next() {
877	}
878
879	// Segment the line into words using UAX#29.
880	lineCol := 0 // tracks the visited column widths
881	lastCol := 0 // tracks the start of the current token
882	iter := words.FromString(line)
883	for iter.Next() {
884		token := iter.Value()
885		tokenWidth := displaywidth.String(token)
886
887		graphemeStart := lineCol
888		graphemeEnd := lineCol + tokenWidth
889		lineCol += tokenWidth
890
891		// If clicked before this token, return the previous token boundaries.
892		if col < graphemeStart {
893			return lastCol, lastCol
894		}
895
896		// Update lastCol to the end of this token for next iteration.
897		lastCol = graphemeEnd
898
899		// If clicked within this token, return its boundaries.
900		if col >= graphemeStart && col < graphemeEnd {
901			// If clicked on whitespace, return empty selection.
902			if strings.TrimSpace(token) == "" {
903				return col, col
904			}
905			return graphemeStart, graphemeEnd
906		}
907	}
908
909	return col, col
910}
911
912// abs returns the absolute value of an integer.
913func abs(x int) int {
914	if x < 0 {
915		return -x
916	}
917	return x
918}