chat.go

  1package model
  2
  3import (
  4	"strings"
  5
  6	tea "charm.land/bubbletea/v2"
  7	"charm.land/lipgloss/v2"
  8	"github.com/charmbracelet/crush/internal/ui/anim"
  9	"github.com/charmbracelet/crush/internal/ui/chat"
 10	"github.com/charmbracelet/crush/internal/ui/common"
 11	"github.com/charmbracelet/crush/internal/ui/list"
 12	uv "github.com/charmbracelet/ultraviolet"
 13	"github.com/charmbracelet/x/ansi"
 14)
 15
 16// Chat represents the chat UI model that handles chat interactions and
 17// messages.
 18type Chat struct {
 19	com      *common.Common
 20	list     *list.List
 21	idInxMap map[string]int // Map of message IDs to their indices in the list
 22
 23	// Animation visibility optimization: track animations paused due to items
 24	// being scrolled out of view. When items become visible again, their
 25	// animations are restarted.
 26	pausedAnimations map[string]struct{}
 27
 28	// Mouse state
 29	mouseDown     bool
 30	mouseDownItem int // Item index where mouse was pressed
 31	mouseDownX    int // X position in item content (character offset)
 32	mouseDownY    int // Y position in item (line offset)
 33	mouseDragItem int // Current item index being dragged over
 34	mouseDragX    int // Current X in item content
 35	mouseDragY    int // Current Y in item
 36}
 37
 38// NewChat creates a new instance of [Chat] that handles chat interactions and
 39// messages.
 40func NewChat(com *common.Common) *Chat {
 41	c := &Chat{
 42		com:              com,
 43		idInxMap:         make(map[string]int),
 44		pausedAnimations: make(map[string]struct{}),
 45	}
 46	l := list.NewList()
 47	l.SetGap(1)
 48	l.RegisterRenderCallback(c.applyHighlightRange)
 49	l.RegisterRenderCallback(list.FocusedRenderCallback(l))
 50	c.list = l
 51	c.mouseDownItem = -1
 52	c.mouseDragItem = -1
 53	return c
 54}
 55
 56// Height returns the height of the chat view port.
 57func (m *Chat) Height() int {
 58	return m.list.Height()
 59}
 60
 61// Draw renders the chat UI component to the screen and the given area.
 62func (m *Chat) Draw(scr uv.Screen, area uv.Rectangle) {
 63	uv.NewStyledString(m.list.Render()).Draw(scr, area)
 64}
 65
 66// SetSize sets the size of the chat view port.
 67func (m *Chat) SetSize(width, height int) {
 68	m.list.SetSize(width, height)
 69	// Anchor to bottom if we were at the bottom.
 70	if m.list.AtBottom() {
 71		m.list.ScrollToBottom()
 72	}
 73}
 74
 75// Len returns the number of items in the chat list.
 76func (m *Chat) Len() int {
 77	return m.list.Len()
 78}
 79
 80// SetMessages sets the chat messages to the provided list of message items.
 81func (m *Chat) SetMessages(msgs ...chat.MessageItem) {
 82	m.idInxMap = make(map[string]int)
 83	m.pausedAnimations = make(map[string]struct{})
 84
 85	items := make([]list.Item, len(msgs))
 86	for i, msg := range msgs {
 87		m.idInxMap[msg.ID()] = i
 88		// Register nested tool IDs for tools that contain nested tools.
 89		if container, ok := msg.(chat.NestedToolContainer); ok {
 90			for _, nested := range container.NestedTools() {
 91				m.idInxMap[nested.ID()] = i
 92			}
 93		}
 94		items[i] = msg
 95	}
 96	m.list.SetItems(items...)
 97	m.list.ScrollToBottom()
 98}
 99
100// AppendMessages appends a new message item to the chat list.
101func (m *Chat) AppendMessages(msgs ...chat.MessageItem) {
102	items := make([]list.Item, len(msgs))
103	indexOffset := m.list.Len()
104	for i, msg := range msgs {
105		m.idInxMap[msg.ID()] = indexOffset + i
106		// Register nested tool IDs for tools that contain nested tools.
107		if container, ok := msg.(chat.NestedToolContainer); ok {
108			for _, nested := range container.NestedTools() {
109				m.idInxMap[nested.ID()] = indexOffset + i
110			}
111		}
112		items[i] = msg
113	}
114	m.list.AppendItems(items...)
115}
116
117// UpdateNestedToolIDs updates the ID map for nested tools within a container.
118// Call this after modifying nested tools to ensure animations work correctly.
119func (m *Chat) UpdateNestedToolIDs(containerID string) {
120	idx, ok := m.idInxMap[containerID]
121	if !ok {
122		return
123	}
124
125	item, ok := m.list.ItemAt(idx).(chat.MessageItem)
126	if !ok {
127		return
128	}
129
130	container, ok := item.(chat.NestedToolContainer)
131	if !ok {
132		return
133	}
134
135	// Register all nested tool IDs to point to the container's index.
136	for _, nested := range container.NestedTools() {
137		m.idInxMap[nested.ID()] = idx
138	}
139}
140
141// Animate animates items in the chat list. Only propagates animation messages
142// to visible items to save CPU. When items are not visible, their animation ID
143// is tracked so it can be restarted when they become visible again.
144func (m *Chat) Animate(msg anim.StepMsg) tea.Cmd {
145	idx, ok := m.idInxMap[msg.ID]
146	if !ok {
147		return nil
148	}
149
150	animatable, ok := m.list.ItemAt(idx).(chat.Animatable)
151	if !ok {
152		return nil
153	}
154
155	// Check if item is currently visible.
156	startIdx, endIdx := m.list.VisibleItemIndices()
157	isVisible := idx >= startIdx && idx <= endIdx
158
159	if !isVisible {
160		// Item not visible - pause animation by not propagating.
161		// Track it so we can restart when it becomes visible.
162		m.pausedAnimations[msg.ID] = struct{}{}
163		return nil
164	}
165
166	// Item is visible - remove from paused set and animate.
167	delete(m.pausedAnimations, msg.ID)
168	return animatable.Animate(msg)
169}
170
171// RestartPausedVisibleAnimations restarts animations for items that were paused
172// due to being scrolled out of view but are now visible again.
173func (m *Chat) RestartPausedVisibleAnimations() tea.Cmd {
174	if len(m.pausedAnimations) == 0 {
175		return nil
176	}
177
178	startIdx, endIdx := m.list.VisibleItemIndices()
179	var cmds []tea.Cmd
180
181	for id := range m.pausedAnimations {
182		idx, ok := m.idInxMap[id]
183		if !ok {
184			// Item no longer exists.
185			delete(m.pausedAnimations, id)
186			continue
187		}
188
189		if idx >= startIdx && idx <= endIdx {
190			// Item is now visible - restart its animation.
191			if animatable, ok := m.list.ItemAt(idx).(chat.Animatable); ok {
192				if cmd := animatable.StartAnimation(); cmd != nil {
193					cmds = append(cmds, cmd)
194				}
195			}
196			delete(m.pausedAnimations, id)
197		}
198	}
199
200	if len(cmds) == 0 {
201		return nil
202	}
203	return tea.Batch(cmds...)
204}
205
206// Focus sets the focus state of the chat component.
207func (m *Chat) Focus() {
208	m.list.Focus()
209}
210
211// Blur removes the focus state from the chat component.
212func (m *Chat) Blur() {
213	m.list.Blur()
214}
215
216// ScrollToTopAndAnimate scrolls the chat view to the top and returns a command to restart
217// any paused animations that are now visible.
218func (m *Chat) ScrollToTopAndAnimate() tea.Cmd {
219	m.list.ScrollToTop()
220	return m.RestartPausedVisibleAnimations()
221}
222
223// ScrollToBottomAndAnimate scrolls the chat view to the bottom and returns a command to
224// restart any paused animations that are now visible.
225func (m *Chat) ScrollToBottomAndAnimate() tea.Cmd {
226	m.list.ScrollToBottom()
227	return m.RestartPausedVisibleAnimations()
228}
229
230// ScrollByAndAnimate scrolls the chat view by the given number of line deltas and returns
231// a command to restart any paused animations that are now visible.
232func (m *Chat) ScrollByAndAnimate(lines int) tea.Cmd {
233	m.list.ScrollBy(lines)
234	return m.RestartPausedVisibleAnimations()
235}
236
237// ScrollToSelectedAndAnimate scrolls the chat view to the selected item and returns a
238// command to restart any paused animations that are now visible.
239func (m *Chat) ScrollToSelectedAndAnimate() tea.Cmd {
240	m.list.ScrollToSelected()
241	return m.RestartPausedVisibleAnimations()
242}
243
244// SelectedItemInView returns whether the selected item is currently in view.
245func (m *Chat) SelectedItemInView() bool {
246	return m.list.SelectedItemInView()
247}
248
249// SelectedItem returns the currently selected item in the chat list.
250func (m *Chat) SelectedItem() list.Item {
251	return m.list.SelectedItem()
252}
253
254func (m *Chat) isSelectable(index int) bool {
255	item := m.list.ItemAt(index)
256	if item == nil {
257		return false
258	}
259	_, ok := item.(list.Focusable)
260	return ok
261}
262
263// SetSelected sets the selected message index in the chat list.
264func (m *Chat) SetSelected(index int) {
265	m.list.SetSelected(index)
266	if index < 0 || index >= m.list.Len() {
267		return
268	}
269	for {
270		if m.isSelectable(m.list.Selected()) {
271			return
272		}
273		if m.list.SelectNext() {
274			continue
275		}
276		// If we're at the end and the last item isn't selectable, walk backwards
277		// to find the nearest selectable item.
278		for {
279			if !m.list.SelectPrev() {
280				return
281			}
282			if m.isSelectable(m.list.Selected()) {
283				return
284			}
285		}
286	}
287}
288
289// SelectPrev selects the previous message in the chat list.
290func (m *Chat) SelectPrev() {
291	for {
292		if !m.list.SelectPrev() {
293			return
294		}
295		if m.isSelectable(m.list.Selected()) {
296			return
297		}
298	}
299}
300
301// SelectNext selects the next message in the chat list.
302func (m *Chat) SelectNext() {
303	for {
304		if !m.list.SelectNext() {
305			return
306		}
307		if m.isSelectable(m.list.Selected()) {
308			return
309		}
310	}
311}
312
313// SelectFirst selects the first message in the chat list.
314func (m *Chat) SelectFirst() {
315	if !m.list.SelectFirst() {
316		return
317	}
318	if m.isSelectable(m.list.Selected()) {
319		return
320	}
321	for {
322		if !m.list.SelectNext() {
323			return
324		}
325		if m.isSelectable(m.list.Selected()) {
326			return
327		}
328	}
329}
330
331// SelectLast selects the last message in the chat list.
332func (m *Chat) SelectLast() {
333	if !m.list.SelectLast() {
334		return
335	}
336	if m.isSelectable(m.list.Selected()) {
337		return
338	}
339	for {
340		if !m.list.SelectPrev() {
341			return
342		}
343		if m.isSelectable(m.list.Selected()) {
344			return
345		}
346	}
347}
348
349// SelectFirstInView selects the first message currently in view.
350func (m *Chat) SelectFirstInView() {
351	startIdx, endIdx := m.list.VisibleItemIndices()
352	for i := startIdx; i <= endIdx; i++ {
353		if m.isSelectable(i) {
354			m.list.SetSelected(i)
355			return
356		}
357	}
358}
359
360// SelectLastInView selects the last message currently in view.
361func (m *Chat) SelectLastInView() {
362	startIdx, endIdx := m.list.VisibleItemIndices()
363	for i := endIdx; i >= startIdx; i-- {
364		if m.isSelectable(i) {
365			m.list.SetSelected(i)
366			return
367		}
368	}
369}
370
371// ClearMessages removes all messages from the chat list.
372func (m *Chat) ClearMessages() {
373	m.idInxMap = make(map[string]int)
374	m.pausedAnimations = make(map[string]struct{})
375	m.list.SetItems()
376	m.ClearMouse()
377}
378
379// RemoveMessage removes a message from the chat list by its ID.
380func (m *Chat) RemoveMessage(id string) {
381	idx, ok := m.idInxMap[id]
382	if !ok {
383		return
384	}
385
386	// Remove from list
387	m.list.RemoveItem(idx)
388
389	// Remove from index map
390	delete(m.idInxMap, id)
391
392	// Rebuild index map for all items after the removed one
393	for i := idx; i < m.list.Len(); i++ {
394		if item, ok := m.list.ItemAt(i).(chat.MessageItem); ok {
395			m.idInxMap[item.ID()] = i
396		}
397	}
398
399	// Clean up any paused animations for this message
400	delete(m.pausedAnimations, id)
401}
402
403// MessageItem returns the message item with the given ID, or nil if not found.
404func (m *Chat) MessageItem(id string) chat.MessageItem {
405	idx, ok := m.idInxMap[id]
406	if !ok {
407		return nil
408	}
409	item, ok := m.list.ItemAt(idx).(chat.MessageItem)
410	if !ok {
411		return nil
412	}
413	return item
414}
415
416// ToggleExpandedSelectedItem expands the selected message item if it is expandable.
417func (m *Chat) ToggleExpandedSelectedItem() {
418	if expandable, ok := m.list.SelectedItem().(chat.Expandable); ok {
419		expandable.ToggleExpanded()
420	}
421}
422
423// HandleKeyMsg handles key events for the chat component.
424func (m *Chat) HandleKeyMsg(key tea.KeyMsg) (bool, tea.Cmd) {
425	if m.list.Focused() {
426		if handler, ok := m.list.SelectedItem().(chat.KeyEventHandler); ok {
427			return handler.HandleKeyEvent(key)
428		}
429	}
430	return false, nil
431}
432
433// HandleMouseDown handles mouse down events for the chat component.
434func (m *Chat) HandleMouseDown(x, y int) bool {
435	if m.list.Len() == 0 {
436		return false
437	}
438
439	itemIdx, itemY := m.list.ItemIndexAtPosition(x, y)
440	if itemIdx < 0 {
441		return false
442	}
443	if !m.isSelectable(itemIdx) {
444		return false
445	}
446
447	m.mouseDown = true
448	m.mouseDownItem = itemIdx
449	m.mouseDownX = x
450	m.mouseDownY = itemY
451	m.mouseDragItem = itemIdx
452	m.mouseDragX = x
453	m.mouseDragY = itemY
454
455	// Select the item that was clicked
456	m.list.SetSelected(itemIdx)
457
458	if clickable, ok := m.list.SelectedItem().(list.MouseClickable); ok {
459		return clickable.HandleMouseClick(ansi.MouseButton1, x, itemY)
460	}
461
462	return true
463}
464
465// HandleMouseUp handles mouse up events for the chat component.
466func (m *Chat) HandleMouseUp(x, y int) bool {
467	if !m.mouseDown {
468		return false
469	}
470
471	m.mouseDown = false
472	return true
473}
474
475// HandleMouseDrag handles mouse drag events for the chat component.
476func (m *Chat) HandleMouseDrag(x, y int) bool {
477	if !m.mouseDown {
478		return false
479	}
480
481	if m.list.Len() == 0 {
482		return false
483	}
484
485	itemIdx, itemY := m.list.ItemIndexAtPosition(x, y)
486	if itemIdx < 0 {
487		return false
488	}
489
490	m.mouseDragItem = itemIdx
491	m.mouseDragX = x
492	m.mouseDragY = itemY
493
494	return true
495}
496
497// HasHighlight returns whether there is currently highlighted content.
498func (m *Chat) HasHighlight() bool {
499	startItemIdx, startLine, startCol, endItemIdx, endLine, endCol := m.getHighlightRange()
500	return startItemIdx >= 0 && endItemIdx >= 0 && (startLine != endLine || startCol != endCol)
501}
502
503// HighlightContent returns the currently highlighted content based on the mouse
504// selection. It returns an empty string if no content is highlighted.
505func (m *Chat) HighlightContent() string {
506	startItemIdx, startLine, startCol, endItemIdx, endLine, endCol := m.getHighlightRange()
507	if startItemIdx < 0 || endItemIdx < 0 || startLine == endLine && startCol == endCol {
508		return ""
509	}
510
511	var sb strings.Builder
512	for i := startItemIdx; i <= endItemIdx; i++ {
513		item := m.list.ItemAt(i)
514		if hi, ok := item.(list.Highlightable); ok {
515			startLine, startCol, endLine, endCol := hi.Highlight()
516			listWidth := m.list.Width()
517			var rendered string
518			if rr, ok := item.(list.RawRenderable); ok {
519				rendered = rr.RawRender(listWidth)
520			} else {
521				rendered = item.Render(listWidth)
522			}
523			sb.WriteString(list.HighlightContent(
524				rendered,
525				uv.Rect(0, 0, listWidth, lipgloss.Height(rendered)),
526				startLine,
527				startCol,
528				endLine,
529				endCol,
530			))
531			sb.WriteString(strings.Repeat("\n", m.list.Gap()))
532		}
533	}
534
535	return strings.TrimSpace(sb.String())
536}
537
538// ClearMouse clears the current mouse interaction state.
539func (m *Chat) ClearMouse() {
540	m.mouseDown = false
541	m.mouseDownItem = -1
542	m.mouseDragItem = -1
543}
544
545// applyHighlightRange applies the current highlight range to the chat items.
546func (m *Chat) applyHighlightRange(idx, selectedIdx int, item list.Item) list.Item {
547	if hi, ok := item.(list.Highlightable); ok {
548		// Apply highlight
549		startItemIdx, startLine, startCol, endItemIdx, endLine, endCol := m.getHighlightRange()
550		sLine, sCol, eLine, eCol := -1, -1, -1, -1
551		if idx >= startItemIdx && idx <= endItemIdx {
552			if idx == startItemIdx && idx == endItemIdx {
553				// Single item selection
554				sLine = startLine
555				sCol = startCol
556				eLine = endLine
557				eCol = endCol
558			} else if idx == startItemIdx {
559				// First item - from start position to end of item
560				sLine = startLine
561				sCol = startCol
562				eLine = -1
563				eCol = -1
564			} else if idx == endItemIdx {
565				// Last item - from start of item to end position
566				sLine = 0
567				sCol = 0
568				eLine = endLine
569				eCol = endCol
570			} else {
571				// Middle item - fully highlighted
572				sLine = 0
573				sCol = 0
574				eLine = -1
575				eCol = -1
576			}
577		}
578
579		hi.SetHighlight(sLine, sCol, eLine, eCol)
580		return hi.(list.Item)
581	}
582
583	return item
584}
585
586// getHighlightRange returns the current highlight range.
587func (m *Chat) getHighlightRange() (startItemIdx, startLine, startCol, endItemIdx, endLine, endCol int) {
588	if m.mouseDownItem < 0 {
589		return -1, -1, -1, -1, -1, -1
590	}
591
592	downItemIdx := m.mouseDownItem
593	dragItemIdx := m.mouseDragItem
594
595	// Determine selection direction
596	draggingDown := dragItemIdx > downItemIdx ||
597		(dragItemIdx == downItemIdx && m.mouseDragY > m.mouseDownY) ||
598		(dragItemIdx == downItemIdx && m.mouseDragY == m.mouseDownY && m.mouseDragX >= m.mouseDownX)
599
600	if draggingDown {
601		// Normal forward selection
602		startItemIdx = downItemIdx
603		startLine = m.mouseDownY
604		startCol = m.mouseDownX
605		endItemIdx = dragItemIdx
606		endLine = m.mouseDragY
607		endCol = m.mouseDragX
608	} else {
609		// Backward selection (dragging up)
610		startItemIdx = dragItemIdx
611		startLine = m.mouseDragY
612		startCol = m.mouseDragX
613		endItemIdx = downItemIdx
614		endLine = m.mouseDownY
615		endCol = m.mouseDownX
616	}
617
618	return startItemIdx, startLine, startCol, endItemIdx, endLine, endCol
619}