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	// 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}