chat.go

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