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