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 // drawCache memoizes the decoded form of the last list.Render output so
68 // repeat frames with byte-identical content skip the per-cell ANSI
69 // reparse that uv.StyledString.Draw performs every call. See F9
70 // (docs/notes/2026-05-12-chat-rendering-perf.md ยง4.8). Bounded to one
71 // entry; invalidated implicitly by string inequality on the next Draw.
72 drawCache *chatDrawCache
73}
74
75// chatDrawCache holds the pre-decoded form of the last list.Render output.
76// The cache is keyed by the rendered string and the screen's width method
77// (graphemes vs wcwidth pick different decoders inside ultraviolet's
78// printString, so a cached buffer is only valid for the method it was
79// decoded with). The cached buffer is independent of the draw area, so
80// resize / scroll changes that produce the same string still hit. We cannot
81// use uv.StyledString.Lines because it bottoms out at the first iteration
82// against a zero-bounds rectangle (see ultraviolet styled.go line 45 โ the
83// shared printString loop's `y >= bounds.Max.Y` exit applies to the
84// line-building branch too). A Buffer of the rendered text's natural
85// dimensions is the cheapest correct shape: StyledString.Draw runs once on
86// miss to populate it, and Buffer.Draw is an O(cells) cell copy with no
87// ANSI re-parse on hit.
88type chatDrawCache struct {
89 rendered string
90 method ansi.Method
91 buf uv.ScreenBuffer
92}
93
94// NewChat creates a new instance of [Chat] that handles chat interactions and
95// messages.
96func NewChat(com *common.Common) *Chat {
97 c := &Chat{
98 com: com,
99 idInxMap: make(map[string]int),
100 pausedAnimations: make(map[string]struct{}),
101 }
102 l := list.NewList()
103 l.SetGap(1)
104 l.RegisterRenderCallback(c.applyHighlightRange)
105 l.RegisterRenderCallback(list.FocusedRenderCallback(l))
106 c.list = l
107 c.mouseDownItem = -1
108 c.mouseDragItem = -1
109 return c
110}
111
112// Height returns the height of the chat view port.
113func (m *Chat) Height() int {
114 return m.list.Height()
115}
116
117// Draw renders the chat UI component to the screen and the given area.
118//
119// The list's rendered output is cached in decoded form (see chatDrawCache) so
120// that frames with byte-identical content skip the ANSI reparse that
121// uv.StyledString.Draw performs on every call. The cache is keyed by the
122// rendered string and the screen's width method; area / scroll changes do not
123// invalidate it.
124func (m *Chat) Draw(scr uv.Screen, area uv.Rectangle) {
125 rendered := m.list.Render()
126 method, ok := scr.WidthMethod().(ansi.Method)
127 if !ok {
128 // Width method isn't an ansi.Method (unlikely in practice โ both
129 // TerminalScreen and ScreenBuffer store ansi.Method). Fall back
130 // to the uncached path so behavior matches upstream exactly.
131 uv.NewStyledString(rendered).Draw(scr, area)
132 return
133 }
134 if m.drawCache == nil ||
135 m.drawCache.rendered != rendered ||
136 m.drawCache.method != method {
137 m.drawCache = newChatDrawCache(rendered, method)
138 }
139 drawCachedBuffer(scr, area, m.drawCache.buf)
140}
141
142// newChatDrawCache builds a chatDrawCache for the given rendered string by
143// running uv.StyledString.Draw into a fresh buffer sized to the text's
144// natural bounds under the active width method. This is the only place
145// ANSI decoding happens for cached frames โ subsequent draws reuse buf
146// via drawCachedBuffer.
147//
148// We can't use uv.StyledString.Bounds() here: it is hard-coded to
149// ansi.GraphemeWidth, while StyledString.Draw lays cells using the
150// destination buffer's WidthMethod (which we capture in `method`). For
151// strings where graphemes and wcwidth disagree (emoji ZWJ sequences,
152// some CJK, certain combining marks) the two answers diverge, leaving
153// the cached buffer either too small (trailing cells dropped on hit) or
154// too large (dead cells past the live content). Computing dimensions
155// with `method.StringWidth` per line matches what printString tallies
156// cell-by-cell, since both decode ANSI sequences and use the same width
157// method.
158func newChatDrawCache(rendered string, method ansi.Method) *chatDrawCache {
159 w, h := renderedBounds(rendered, method)
160 if w <= 0 {
161 w = 1
162 }
163 if h <= 0 {
164 h = 1
165 }
166 buf := uv.NewScreenBuffer(w, h)
167 buf.Method = method
168 uv.NewStyledString(rendered).Draw(buf, buf.Bounds())
169 return &chatDrawCache{
170 rendered: rendered,
171 method: method,
172 buf: buf,
173 }
174}
175
176// renderedBounds returns the (width, height) cell extent of rendered
177// when laid out by method. Width is the widest line's StringWidth (which
178// strips ANSI sequences and tallies cells via method, exactly like
179// printString); height is the line count. Both match what
180// uv.StyledString.Draw will write into a buffer whose WidthMethod is
181// method, so the cache buffer is always sized to fit the live content.
182func renderedBounds(rendered string, method ansi.Method) (w, h int) {
183 for line := range strings.SplitSeq(rendered, "\n") {
184 w = max(w, method.StringWidth(line))
185 h++
186 }
187 return w, h
188}
189
190// drawCachedBuffer blits a previously-decoded buffer into scr at area,
191// mirroring uv.StyledString.Draw's screen-mode behavior for the default
192// Wrap=false, Tail="" case. The clear loop matches StyledString.Draw line
193// 51-56; the buf.Draw call replaces the per-cell ANSI decode that
194// printString does on every uncached frame with a pure cell copy.
195func drawCachedBuffer(scr uv.Screen, area uv.Rectangle, buf uv.ScreenBuffer) {
196 // Clear the area first to match StyledString.Draw โ leftover cells
197 // from a previous frame outside the new content must be zeroed,
198 // because Buffer.Draw skips empty cells (it doesn't clear).
199 for y := area.Min.Y; y < area.Max.Y; y++ {
200 for x := area.Min.X; x < area.Max.X; x++ {
201 scr.SetCell(x, y, nil)
202 }
203 }
204 buf.Draw(scr, area)
205}
206
207// SetSize sets the size of the chat view port.
208func (m *Chat) SetSize(width, height int) {
209 m.list.SetSize(width, height)
210 // Anchor to bottom if we were at the bottom.
211 if m.AtBottom() {
212 m.ScrollToBottom()
213 }
214}
215
216// Len returns the number of items in the chat list.
217func (m *Chat) Len() int {
218 return m.list.Len()
219}
220
221// InvalidateRenderCaches drops cached rendered output on every message
222// item so the next draw re-renders with the current styles.
223func (m *Chat) InvalidateRenderCaches() {
224 items := make([]chat.MessageItem, 0, m.list.Len())
225 for i := range m.list.Len() {
226 if item, ok := m.list.ItemAt(i).(chat.MessageItem); ok {
227 items = append(items, item)
228 }
229 }
230 chat.ClearItemCaches(items)
231}
232
233// SetMessages sets the chat messages to the provided list of message items.
234func (m *Chat) SetMessages(msgs ...chat.MessageItem) {
235 m.idInxMap = make(map[string]int)
236 m.pausedAnimations = make(map[string]struct{})
237
238 items := make([]list.Item, len(msgs))
239 for i, msg := range msgs {
240 m.idInxMap[msg.ID()] = i
241 // Register nested tool IDs for tools that contain nested tools.
242 if container, ok := msg.(chat.NestedToolContainer); ok {
243 for _, nested := range container.NestedTools() {
244 m.idInxMap[nested.ID()] = i
245 }
246 }
247 items[i] = msg
248 }
249 m.list.SetItems(items...)
250 m.ScrollToBottom()
251}
252
253// AppendMessages appends a new message item to the chat list.
254func (m *Chat) AppendMessages(msgs ...chat.MessageItem) {
255 items := make([]list.Item, len(msgs))
256 indexOffset := m.list.Len()
257 for i, msg := range msgs {
258 m.idInxMap[msg.ID()] = indexOffset + i
259 // Register nested tool IDs for tools that contain nested tools.
260 if container, ok := msg.(chat.NestedToolContainer); ok {
261 for _, nested := range container.NestedTools() {
262 m.idInxMap[nested.ID()] = indexOffset + i
263 }
264 }
265 items[i] = msg
266 }
267 m.list.AppendItems(items...)
268}
269
270// UpdateNestedToolIDs updates the ID map for nested tools within a container.
271// Call this after modifying nested tools to ensure animations work correctly.
272func (m *Chat) UpdateNestedToolIDs(containerID string) {
273 idx, ok := m.idInxMap[containerID]
274 if !ok {
275 return
276 }
277
278 item, ok := m.list.ItemAt(idx).(chat.MessageItem)
279 if !ok {
280 return
281 }
282
283 container, ok := item.(chat.NestedToolContainer)
284 if !ok {
285 return
286 }
287
288 // Register all nested tool IDs to point to the container's index.
289 for _, nested := range container.NestedTools() {
290 m.idInxMap[nested.ID()] = idx
291 }
292}
293
294// Animate animates items in the chat list. Only propagates animation messages
295// to visible items to save CPU. When items are not visible, their animation ID
296// is tracked so it can be restarted when they become visible again.
297func (m *Chat) Animate(msg anim.StepMsg) tea.Cmd {
298 idx, ok := m.idInxMap[msg.ID]
299 if !ok {
300 return nil
301 }
302
303 animatable, ok := m.list.ItemAt(idx).(chat.Animatable)
304 if !ok {
305 return nil
306 }
307
308 // Check if item is currently visible.
309 startIdx, endIdx := m.list.VisibleItemIndices()
310 isVisible := idx >= startIdx && idx <= endIdx
311
312 if !isVisible {
313 // Item not visible - pause animation by not propagating.
314 // Track it so we can restart when it becomes visible.
315 m.pausedAnimations[msg.ID] = struct{}{}
316 return nil
317 }
318
319 // Item is visible - remove from paused set and animate.
320 delete(m.pausedAnimations, msg.ID)
321 return animatable.Animate(msg)
322}
323
324// RestartPausedVisibleAnimations restarts animations for items that were paused
325// due to being scrolled out of view but are now visible again.
326func (m *Chat) RestartPausedVisibleAnimations() tea.Cmd {
327 if len(m.pausedAnimations) == 0 {
328 return nil
329 }
330
331 startIdx, endIdx := m.list.VisibleItemIndices()
332 var cmds []tea.Cmd
333
334 for id := range m.pausedAnimations {
335 idx, ok := m.idInxMap[id]
336 if !ok {
337 // Item no longer exists.
338 delete(m.pausedAnimations, id)
339 continue
340 }
341
342 if idx >= startIdx && idx <= endIdx {
343 // Item is now visible - restart its animation.
344 if animatable, ok := m.list.ItemAt(idx).(chat.Animatable); ok {
345 if cmd := animatable.StartAnimation(); cmd != nil {
346 cmds = append(cmds, cmd)
347 }
348 }
349 delete(m.pausedAnimations, id)
350 }
351 }
352
353 if len(cmds) == 0 {
354 return nil
355 }
356 return tea.Batch(cmds...)
357}
358
359// Focus sets the focus state of the chat component.
360func (m *Chat) Focus() {
361 m.list.Focus()
362}
363
364// Blur removes the focus state from the chat component.
365func (m *Chat) Blur() {
366 m.list.Blur()
367}
368
369// AtBottom returns whether the chat list is currently scrolled to the bottom.
370func (m *Chat) AtBottom() bool {
371 return m.list.AtBottom()
372}
373
374// Follow returns whether the chat view is in follow mode (auto-scroll to
375// bottom on new messages).
376func (m *Chat) Follow() bool {
377 return m.follow
378}
379
380// ScrollToBottom scrolls the chat view to the bottom.
381func (m *Chat) ScrollToBottom() {
382 m.list.ScrollToBottom()
383 m.follow = true // Enable follow mode when user scrolls to bottom
384}
385
386// ScrollToTop scrolls the chat view to the top.
387func (m *Chat) ScrollToTop() {
388 m.list.ScrollToTop()
389 m.follow = false // Disable follow mode when user scrolls up
390}
391
392// ScrollBy scrolls the chat view by the given number of line deltas.
393func (m *Chat) ScrollBy(lines int) {
394 m.list.ScrollBy(lines)
395 m.follow = lines > 0 && m.AtBottom() // Disable follow mode if user scrolls up
396}
397
398// ScrollToSelected scrolls the chat view to the selected item.
399func (m *Chat) ScrollToSelected() {
400 m.list.ScrollToSelected()
401 m.follow = m.AtBottom() // Disable follow mode if user scrolls up
402}
403
404// ScrollToIndex scrolls the chat view to the item at the given index.
405func (m *Chat) ScrollToIndex(index int) {
406 m.list.ScrollToIndex(index)
407 m.follow = m.AtBottom() // Disable follow mode if user scrolls up
408}
409
410// ScrollToTopAndAnimate scrolls the chat view to the top and returns a command to restart
411// any paused animations that are now visible.
412func (m *Chat) ScrollToTopAndAnimate() tea.Cmd {
413 m.ScrollToTop()
414 return m.RestartPausedVisibleAnimations()
415}
416
417// ScrollToBottomAndAnimate scrolls the chat view to the bottom and returns a command to
418// restart any paused animations that are now visible.
419func (m *Chat) ScrollToBottomAndAnimate() tea.Cmd {
420 m.ScrollToBottom()
421 return m.RestartPausedVisibleAnimations()
422}
423
424// ScrollByAndAnimate scrolls the chat view by the given number of line deltas and returns
425// a command to restart any paused animations that are now visible.
426func (m *Chat) ScrollByAndAnimate(lines int) tea.Cmd {
427 m.ScrollBy(lines)
428 return m.RestartPausedVisibleAnimations()
429}
430
431// ScrollToSelectedAndAnimate scrolls the chat view to the selected item and returns a
432// command to restart any paused animations that are now visible.
433func (m *Chat) ScrollToSelectedAndAnimate() tea.Cmd {
434 m.ScrollToSelected()
435 return m.RestartPausedVisibleAnimations()
436}
437
438// SelectedItemInView returns whether the selected item is currently in view.
439func (m *Chat) SelectedItemInView() bool {
440 return m.list.SelectedItemInView()
441}
442
443func (m *Chat) isSelectable(index int) bool {
444 item := m.list.ItemAt(index)
445 if item == nil {
446 return false
447 }
448 _, ok := item.(list.Focusable)
449 return ok
450}
451
452// SetSelected sets the selected message index in the chat list.
453func (m *Chat) SetSelected(index int) {
454 m.list.SetSelected(index)
455 if index < 0 || index >= m.list.Len() {
456 return
457 }
458 for {
459 if m.isSelectable(m.list.Selected()) {
460 return
461 }
462 if m.list.SelectNext() {
463 continue
464 }
465 // If we're at the end and the last item isn't selectable, walk backwards
466 // to find the nearest selectable item.
467 for {
468 if !m.list.SelectPrev() {
469 return
470 }
471 if m.isSelectable(m.list.Selected()) {
472 return
473 }
474 }
475 }
476}
477
478// SelectPrev selects the previous message in the chat list.
479func (m *Chat) SelectPrev() {
480 for {
481 if !m.list.SelectPrev() {
482 return
483 }
484 if m.isSelectable(m.list.Selected()) {
485 return
486 }
487 }
488}
489
490// SelectNext selects the next message in the chat list.
491func (m *Chat) SelectNext() {
492 for {
493 if !m.list.SelectNext() {
494 return
495 }
496 if m.isSelectable(m.list.Selected()) {
497 return
498 }
499 }
500}
501
502// SelectFirst selects the first message in the chat list.
503func (m *Chat) SelectFirst() {
504 if !m.list.SelectFirst() {
505 return
506 }
507 if m.isSelectable(m.list.Selected()) {
508 return
509 }
510 for {
511 if !m.list.SelectNext() {
512 return
513 }
514 if m.isSelectable(m.list.Selected()) {
515 return
516 }
517 }
518}
519
520// SelectLast selects the last message in the chat list.
521func (m *Chat) SelectLast() {
522 if !m.list.SelectLast() {
523 return
524 }
525 if m.isSelectable(m.list.Selected()) {
526 return
527 }
528 for {
529 if !m.list.SelectPrev() {
530 return
531 }
532 if m.isSelectable(m.list.Selected()) {
533 return
534 }
535 }
536}
537
538// SelectFirstInView selects the first message currently in view.
539func (m *Chat) SelectFirstInView() {
540 startIdx, endIdx := m.list.VisibleItemIndices()
541 for i := startIdx; i <= endIdx; i++ {
542 if m.isSelectable(i) {
543 m.list.SetSelected(i)
544 return
545 }
546 }
547}
548
549// SelectLastInView selects the last message currently in view.
550func (m *Chat) SelectLastInView() {
551 startIdx, endIdx := m.list.VisibleItemIndices()
552 for i := endIdx; i >= startIdx; i-- {
553 if m.isSelectable(i) {
554 m.list.SetSelected(i)
555 return
556 }
557 }
558}
559
560// ClearMessages removes all messages from the chat list.
561func (m *Chat) ClearMessages() {
562 m.idInxMap = make(map[string]int)
563 m.pausedAnimations = make(map[string]struct{})
564 m.list.SetItems()
565 m.ClearMouse()
566}
567
568// RemoveMessage removes a message from the chat list by its ID.
569func (m *Chat) RemoveMessage(id string) {
570 idx, ok := m.idInxMap[id]
571 if !ok {
572 return
573 }
574
575 // Remove from list
576 m.list.RemoveItem(idx)
577
578 // Remove from index map
579 delete(m.idInxMap, id)
580
581 // Rebuild index map for all items after the removed one
582 for i := idx; i < m.list.Len(); i++ {
583 if item, ok := m.list.ItemAt(i).(chat.MessageItem); ok {
584 m.idInxMap[item.ID()] = i
585 }
586 }
587
588 // Clean up any paused animations for this message
589 delete(m.pausedAnimations, id)
590}
591
592// MessageItem returns the message item with the given ID, or nil if not found.
593func (m *Chat) MessageItem(id string) chat.MessageItem {
594 idx, ok := m.idInxMap[id]
595 if !ok {
596 return nil
597 }
598 item, ok := m.list.ItemAt(idx).(chat.MessageItem)
599 if !ok {
600 return nil
601 }
602 return item
603}
604
605// ToggleExpandedSelectedItem expands the selected message item if it is expandable.
606func (m *Chat) ToggleExpandedSelectedItem() {
607 if expandable, ok := m.list.SelectedItem().(chat.Expandable); ok {
608 if !expandable.ToggleExpanded() {
609 m.ScrollToIndex(m.list.Selected())
610 }
611 if m.AtBottom() {
612 m.ScrollToBottom()
613 }
614 }
615}
616
617// HandleKeyMsg handles key events for the chat component.
618func (m *Chat) HandleKeyMsg(key tea.KeyMsg) (bool, tea.Cmd) {
619 if m.list.Focused() {
620 if handler, ok := m.list.SelectedItem().(chat.KeyEventHandler); ok {
621 return handler.HandleKeyEvent(key)
622 }
623 }
624 return false, nil
625}
626
627// HandleMouseDown handles mouse down events for the chat component.
628// It detects single, double, and triple clicks for text selection.
629// Returns whether the click was handled and an optional command for delayed
630// single-click actions.
631func (m *Chat) HandleMouseDown(x, y int) (bool, tea.Cmd) {
632 if m.list.Len() == 0 {
633 return false, nil
634 }
635
636 itemIdx, itemY := m.list.ItemIndexAtPosition(x, y)
637 if itemIdx < 0 {
638 return false, nil
639 }
640 if !m.isSelectable(itemIdx) {
641 return false, nil
642 }
643
644 // Increment pending click ID to invalidate any previous pending clicks.
645 m.pendingClickID++
646 clickID := m.pendingClickID
647
648 // Detect multi-click (double/triple)
649 now := time.Now()
650 if now.Sub(m.lastClickTime) <= doubleClickThreshold &&
651 abs(x-m.lastClickX) <= clickTolerance &&
652 abs(y-m.lastClickY) <= clickTolerance {
653 m.clickCount++
654 } else {
655 m.clickCount = 1
656 }
657 m.lastClickTime = now
658 m.lastClickX = x
659 m.lastClickY = y
660
661 // Select the item that was clicked
662 m.list.SetSelected(itemIdx)
663
664 var cmd tea.Cmd
665
666 switch m.clickCount {
667 case 1:
668 // Single click - start selection and schedule delayed click action.
669 m.mouseDown = true
670 m.mouseDownItem = itemIdx
671 m.mouseDownX = x
672 m.mouseDownY = itemY
673 m.mouseDragItem = itemIdx
674 m.mouseDragX = x
675 m.mouseDragY = itemY
676
677 // Schedule delayed click action (e.g., expansion) after a short delay.
678 // If a double-click occurs, the clickID will be invalidated.
679 cmd = tea.Tick(doubleClickThreshold, func(t time.Time) tea.Msg {
680 return DelayedClickMsg{
681 ClickID: clickID,
682 ItemIdx: itemIdx,
683 X: x,
684 Y: itemY,
685 }
686 })
687 case 2:
688 // Double click - select word (no delayed action)
689 m.selectWord(itemIdx, x, itemY)
690 case 3:
691 // Triple click - select line (no delayed action)
692 m.selectLine(itemIdx, itemY)
693 m.clickCount = 0 // Reset after triple click
694 }
695
696 return true, cmd
697}
698
699// HandleDelayedClick handles a delayed single-click action (like expansion).
700// It only executes if the click ID matches (i.e., no double-click occurred)
701// and no text selection was made (drag to select).
702func (m *Chat) HandleDelayedClick(msg DelayedClickMsg) bool {
703 // Ignore if this click was superseded by a newer click (double/triple).
704 if msg.ClickID != m.pendingClickID {
705 return false
706 }
707
708 // Don't expand if user dragged to select text.
709 if m.HasHighlight() {
710 return false
711 }
712
713 // Execute the click action (e.g., expansion).
714 selectedItem := m.list.SelectedItem()
715 if clickable, ok := selectedItem.(list.MouseClickable); ok {
716 handled := clickable.HandleMouseClick(ansi.MouseButton1, msg.X, msg.Y)
717 // Toggle expansion only when the item signalled it handled the
718 // click. Items like AssistantMessageItem only report handled when
719 // the click is on their expandable region, so this avoids
720 // toggling expansion for clicks outside the clickable area.
721 if handled {
722 if expandable, ok := selectedItem.(chat.Expandable); ok {
723 if !expandable.ToggleExpanded() {
724 m.ScrollToIndex(m.list.Selected())
725 }
726 }
727 }
728 if m.AtBottom() {
729 m.ScrollToBottom()
730 }
731 return handled
732 }
733
734 return false
735}
736
737// HandleMouseUp handles mouse up events for the chat component.
738func (m *Chat) HandleMouseUp(x, y int) bool {
739 if !m.mouseDown {
740 return false
741 }
742
743 m.mouseDown = false
744 return true
745}
746
747// HandleMouseDrag handles mouse drag events for the chat component.
748func (m *Chat) HandleMouseDrag(x, y int) bool {
749 if !m.mouseDown {
750 return false
751 }
752
753 if m.list.Len() == 0 {
754 return false
755 }
756
757 itemIdx, itemY := m.list.ItemIndexAtPosition(x, y)
758 if itemIdx < 0 {
759 return false
760 }
761
762 m.mouseDragItem = itemIdx
763 m.mouseDragX = x
764 m.mouseDragY = itemY
765
766 return true
767}
768
769// HasHighlight returns whether there is currently highlighted content.
770func (m *Chat) HasHighlight() bool {
771 startItemIdx, startLine, startCol, endItemIdx, endLine, endCol := m.getHighlightRange()
772 return startItemIdx >= 0 && endItemIdx >= 0 && (startLine != endLine || startCol != endCol)
773}
774
775// HighlightContent returns the currently highlighted content based on the mouse
776// selection. It returns an empty string if no content is highlighted.
777func (m *Chat) HighlightContent() string {
778 startItemIdx, startLine, startCol, endItemIdx, endLine, endCol := m.getHighlightRange()
779 if startItemIdx < 0 || endItemIdx < 0 || startLine == endLine && startCol == endCol {
780 return ""
781 }
782
783 var sb strings.Builder
784 for i := startItemIdx; i <= endItemIdx; i++ {
785 item := m.list.ItemAt(i)
786 if hi, ok := item.(list.Highlightable); ok {
787 startLine, startCol, endLine, endCol := hi.Highlight()
788 listWidth := m.list.Width()
789 var rendered string
790 if rr, ok := item.(list.RawRenderable); ok {
791 rendered = rr.RawRender(listWidth)
792 } else {
793 rendered = item.Render(listWidth)
794 }
795 sb.WriteString(list.HighlightContent(
796 rendered,
797 uv.Rect(0, 0, listWidth, lipgloss.Height(rendered)),
798 startLine,
799 startCol,
800 endLine,
801 endCol,
802 ))
803 sb.WriteString(strings.Repeat("\n", m.list.Gap()))
804 }
805 }
806
807 return strings.TrimSpace(sb.String())
808}
809
810// ClearMouse clears the current mouse interaction state.
811func (m *Chat) ClearMouse() {
812 m.mouseDown = false
813 m.mouseDownItem = -1
814 m.mouseDragItem = -1
815 m.lastClickTime = time.Time{}
816 m.lastClickX = 0
817 m.lastClickY = 0
818 m.clickCount = 0
819 m.pendingClickID++ // Invalidate any pending delayed click
820}
821
822// applyHighlightRange applies the current highlight range to the chat items.
823func (m *Chat) applyHighlightRange(idx, selectedIdx int, item list.Item) list.Item {
824 if hi, ok := item.(list.Highlightable); ok {
825 // Apply highlight
826 startItemIdx, startLine, startCol, endItemIdx, endLine, endCol := m.getHighlightRange()
827 sLine, sCol, eLine, eCol := -1, -1, -1, -1
828 if idx >= startItemIdx && idx <= endItemIdx {
829 if idx == startItemIdx && idx == endItemIdx {
830 // Single item selection
831 sLine = startLine
832 sCol = startCol
833 eLine = endLine
834 eCol = endCol
835 } else if idx == startItemIdx {
836 // First item - from start position to end of item
837 sLine = startLine
838 sCol = startCol
839 eLine = -1
840 eCol = -1
841 } else if idx == endItemIdx {
842 // Last item - from start of item to end position
843 sLine = 0
844 sCol = 0
845 eLine = endLine
846 eCol = endCol
847 } else {
848 // Middle item - fully highlighted
849 sLine = 0
850 sCol = 0
851 eLine = -1
852 eCol = -1
853 }
854 }
855
856 hi.SetHighlight(sLine, sCol, eLine, eCol)
857 return hi.(list.Item)
858 }
859
860 return item
861}
862
863// getHighlightRange returns the current highlight range.
864func (m *Chat) getHighlightRange() (startItemIdx, startLine, startCol, endItemIdx, endLine, endCol int) {
865 if m.mouseDownItem < 0 {
866 return -1, -1, -1, -1, -1, -1
867 }
868
869 downItemIdx := m.mouseDownItem
870 dragItemIdx := m.mouseDragItem
871
872 // Determine selection direction
873 draggingDown := dragItemIdx > downItemIdx ||
874 (dragItemIdx == downItemIdx && m.mouseDragY > m.mouseDownY) ||
875 (dragItemIdx == downItemIdx && m.mouseDragY == m.mouseDownY && m.mouseDragX >= m.mouseDownX)
876
877 if draggingDown {
878 // Normal forward selection
879 startItemIdx = downItemIdx
880 startLine = m.mouseDownY
881 startCol = m.mouseDownX
882 endItemIdx = dragItemIdx
883 endLine = m.mouseDragY
884 endCol = m.mouseDragX
885 } else {
886 // Backward selection (dragging up)
887 startItemIdx = dragItemIdx
888 startLine = m.mouseDragY
889 startCol = m.mouseDragX
890 endItemIdx = downItemIdx
891 endLine = m.mouseDownY
892 endCol = m.mouseDownX
893 }
894
895 return startItemIdx, startLine, startCol, endItemIdx, endLine, endCol
896}
897
898// selectWord selects the word at the given position within an item.
899func (m *Chat) selectWord(itemIdx, x, itemY int) {
900 item := m.list.ItemAt(itemIdx)
901 if item == nil {
902 return
903 }
904
905 // Get the rendered content for this item
906 var rendered string
907 if rr, ok := item.(list.RawRenderable); ok {
908 rendered = rr.RawRender(m.list.Width())
909 } else {
910 rendered = item.Render(m.list.Width())
911 }
912
913 lines := strings.Split(rendered, "\n")
914 if itemY < 0 || itemY >= len(lines) {
915 return
916 }
917
918 // Adjust x for the item's left padding (border + padding) to get content column.
919 // The mouse x is in viewport space, but we need content space for boundary detection.
920 offset := chat.MessageLeftPaddingTotal
921 contentX := max(x-offset, 0)
922
923 line := ansi.Strip(lines[itemY])
924 startCol, endCol := findWordBoundaries(line, contentX)
925 if startCol == endCol {
926 // No word found at position, fallback to single click behavior
927 m.mouseDown = true
928 m.mouseDownItem = itemIdx
929 m.mouseDownX = x
930 m.mouseDownY = itemY
931 m.mouseDragItem = itemIdx
932 m.mouseDragX = x
933 m.mouseDragY = itemY
934 return
935 }
936
937 // Set selection to the word boundaries (convert back to viewport space).
938 // Keep mouseDown true so HandleMouseUp triggers the copy.
939 m.mouseDown = true
940 m.mouseDownItem = itemIdx
941 m.mouseDownX = startCol + offset
942 m.mouseDownY = itemY
943 m.mouseDragItem = itemIdx
944 m.mouseDragX = endCol + offset
945 m.mouseDragY = itemY
946}
947
948// selectLine selects the entire line at the given position within an item.
949func (m *Chat) selectLine(itemIdx, itemY int) {
950 item := m.list.ItemAt(itemIdx)
951 if item == nil {
952 return
953 }
954
955 // Get the rendered content for this item
956 var rendered string
957 if rr, ok := item.(list.RawRenderable); ok {
958 rendered = rr.RawRender(m.list.Width())
959 } else {
960 rendered = item.Render(m.list.Width())
961 }
962
963 lines := strings.Split(rendered, "\n")
964 if itemY < 0 || itemY >= len(lines) {
965 return
966 }
967
968 // Get line length (stripped of ANSI codes) and account for padding.
969 // SetHighlight will subtract the offset, so we need to add it here.
970 offset := chat.MessageLeftPaddingTotal
971 lineLen := ansi.StringWidth(lines[itemY])
972
973 // Set selection to the entire line.
974 // Keep mouseDown true so HandleMouseUp triggers the copy.
975 m.mouseDown = true
976 m.mouseDownItem = itemIdx
977 m.mouseDownX = 0
978 m.mouseDownY = itemY
979 m.mouseDragItem = itemIdx
980 m.mouseDragX = lineLen + offset
981 m.mouseDragY = itemY
982}
983
984// findWordBoundaries finds the start and end column of the word at the given column.
985// Returns (startCol, endCol) where endCol is exclusive.
986func findWordBoundaries(line string, col int) (startCol, endCol int) {
987 if line == "" || col < 0 {
988 return 0, 0
989 }
990
991 i := displaywidth.StringGraphemes(line)
992 for i.Next() {
993 }
994
995 // Segment the line into words using UAX#29.
996 lineCol := 0 // tracks the visited column widths
997 lastCol := 0 // tracks the start of the current token
998 iter := words.FromString(line)
999 for iter.Next() {
1000 token := iter.Value()
1001 tokenWidth := displaywidth.String(token)
1002
1003 graphemeStart := lineCol
1004 graphemeEnd := lineCol + tokenWidth
1005 lineCol += tokenWidth
1006
1007 // If clicked before this token, return the previous token boundaries.
1008 if col < graphemeStart {
1009 return lastCol, lastCol
1010 }
1011
1012 // Update lastCol to the end of this token for next iteration.
1013 lastCol = graphemeEnd
1014
1015 // If clicked within this token, return its boundaries.
1016 if col >= graphemeStart && col < graphemeEnd {
1017 // If clicked on whitespace, return empty selection.
1018 if strings.TrimSpace(token) == "" {
1019 return col, col
1020 }
1021 return graphemeStart, graphemeEnd
1022 }
1023 }
1024
1025 return col, col
1026}
1027
1028// abs returns the absolute value of an integer.
1029func abs(x int) int {
1030 if x < 0 {
1031 return -x
1032 }
1033 return x
1034}