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
249func (m *Chat) isSelectable(index int) bool {
250	item := m.list.ItemAt(index)
251	if item == nil {
252		return false
253	}
254	_, ok := item.(list.Focusable)
255	return ok
256}
257
258// SetSelected sets the selected message index in the chat list.
259func (m *Chat) SetSelected(index int) {
260	m.list.SetSelected(index)
261	if index < 0 || index >= m.list.Len() {
262		return
263	}
264	for {
265		if m.isSelectable(m.list.Selected()) {
266			return
267		}
268		if m.list.SelectNext() {
269			continue
270		}
271		// If we're at the end and the last item isn't selectable, walk backwards
272		// to find the nearest selectable item.
273		for {
274			if !m.list.SelectPrev() {
275				return
276			}
277			if m.isSelectable(m.list.Selected()) {
278				return
279			}
280		}
281	}
282}
283
284// SelectPrev selects the previous message in the chat list.
285func (m *Chat) SelectPrev() {
286	for {
287		if !m.list.SelectPrev() {
288			return
289		}
290		if m.isSelectable(m.list.Selected()) {
291			return
292		}
293	}
294}
295
296// SelectNext selects the next message in the chat list.
297func (m *Chat) SelectNext() {
298	for {
299		if !m.list.SelectNext() {
300			return
301		}
302		if m.isSelectable(m.list.Selected()) {
303			return
304		}
305	}
306}
307
308// SelectFirst selects the first message in the chat list.
309func (m *Chat) SelectFirst() {
310	if !m.list.SelectFirst() {
311		return
312	}
313	if m.isSelectable(m.list.Selected()) {
314		return
315	}
316	for {
317		if !m.list.SelectNext() {
318			return
319		}
320		if m.isSelectable(m.list.Selected()) {
321			return
322		}
323	}
324}
325
326// SelectLast selects the last message in the chat list.
327func (m *Chat) SelectLast() {
328	if !m.list.SelectLast() {
329		return
330	}
331	if m.isSelectable(m.list.Selected()) {
332		return
333	}
334	for {
335		if !m.list.SelectPrev() {
336			return
337		}
338		if m.isSelectable(m.list.Selected()) {
339			return
340		}
341	}
342}
343
344// SelectFirstInView selects the first message currently in view.
345func (m *Chat) SelectFirstInView() {
346	startIdx, endIdx := m.list.VisibleItemIndices()
347	for i := startIdx; i <= endIdx; i++ {
348		if m.isSelectable(i) {
349			m.list.SetSelected(i)
350			return
351		}
352	}
353}
354
355// SelectLastInView selects the last message currently in view.
356func (m *Chat) SelectLastInView() {
357	startIdx, endIdx := m.list.VisibleItemIndices()
358	for i := endIdx; i >= startIdx; i-- {
359		if m.isSelectable(i) {
360			m.list.SetSelected(i)
361			return
362		}
363	}
364}
365
366// ClearMessages removes all messages from the chat list.
367func (m *Chat) ClearMessages() {
368	m.idInxMap = make(map[string]int)
369	m.pausedAnimations = make(map[string]struct{})
370	m.list.SetItems()
371	m.ClearMouse()
372}
373
374// RemoveMessage removes a message from the chat list by its ID.
375func (m *Chat) RemoveMessage(id string) {
376	idx, ok := m.idInxMap[id]
377	if !ok {
378		return
379	}
380
381	// Remove from list
382	m.list.RemoveItem(idx)
383
384	// Remove from index map
385	delete(m.idInxMap, id)
386
387	// Rebuild index map for all items after the removed one
388	for i := idx; i < m.list.Len(); i++ {
389		if item, ok := m.list.ItemAt(i).(chat.MessageItem); ok {
390			m.idInxMap[item.ID()] = i
391		}
392	}
393
394	// Clean up any paused animations for this message
395	delete(m.pausedAnimations, id)
396}
397
398// MessageItem returns the message item with the given ID, or nil if not found.
399func (m *Chat) MessageItem(id string) chat.MessageItem {
400	idx, ok := m.idInxMap[id]
401	if !ok {
402		return nil
403	}
404	item, ok := m.list.ItemAt(idx).(chat.MessageItem)
405	if !ok {
406		return nil
407	}
408	return item
409}
410
411// ToggleExpandedSelectedItem expands the selected message item if it is expandable.
412func (m *Chat) ToggleExpandedSelectedItem() {
413	if expandable, ok := m.list.SelectedItem().(chat.Expandable); ok {
414		expandable.ToggleExpanded()
415	}
416}
417
418// HandleKeyMsg handles key events for the chat component.
419func (m *Chat) HandleKeyMsg(key tea.KeyMsg) (bool, tea.Cmd) {
420	if m.list.Focused() {
421		if handler, ok := m.list.SelectedItem().(chat.KeyEventHandler); ok {
422			return handler.HandleKeyEvent(key)
423		}
424	}
425	return false, nil
426}
427
428// HandleMouseDown handles mouse down events for the chat component.
429func (m *Chat) HandleMouseDown(x, y int) bool {
430	if m.list.Len() == 0 {
431		return false
432	}
433
434	itemIdx, itemY := m.list.ItemIndexAtPosition(x, y)
435	if itemIdx < 0 {
436		return false
437	}
438	if !m.isSelectable(itemIdx) {
439		return false
440	}
441
442	m.mouseDown = true
443	m.mouseDownItem = itemIdx
444	m.mouseDownX = x
445	m.mouseDownY = itemY
446	m.mouseDragItem = itemIdx
447	m.mouseDragX = x
448	m.mouseDragY = itemY
449
450	// Select the item that was clicked
451	m.list.SetSelected(itemIdx)
452
453	if clickable, ok := m.list.SelectedItem().(list.MouseClickable); ok {
454		return clickable.HandleMouseClick(ansi.MouseButton1, x, itemY)
455	}
456
457	return true
458}
459
460// HandleMouseUp handles mouse up events for the chat component.
461func (m *Chat) HandleMouseUp(x, y int) bool {
462	if !m.mouseDown {
463		return false
464	}
465
466	m.mouseDown = false
467	return true
468}
469
470// HandleMouseDrag handles mouse drag events for the chat component.
471func (m *Chat) HandleMouseDrag(x, y int) bool {
472	if !m.mouseDown {
473		return false
474	}
475
476	if m.list.Len() == 0 {
477		return false
478	}
479
480	itemIdx, itemY := m.list.ItemIndexAtPosition(x, y)
481	if itemIdx < 0 {
482		return false
483	}
484
485	m.mouseDragItem = itemIdx
486	m.mouseDragX = x
487	m.mouseDragY = itemY
488
489	return true
490}
491
492// HasHighlight returns whether there is currently highlighted content.
493func (m *Chat) HasHighlight() bool {
494	startItemIdx, startLine, startCol, endItemIdx, endLine, endCol := m.getHighlightRange()
495	return startItemIdx >= 0 && endItemIdx >= 0 && (startLine != endLine || startCol != endCol)
496}
497
498// HighlightContent returns the currently highlighted content based on the mouse
499// selection. It returns an empty string if no content is highlighted.
500func (m *Chat) HighlightContent() string {
501	startItemIdx, startLine, startCol, endItemIdx, endLine, endCol := m.getHighlightRange()
502	if startItemIdx < 0 || endItemIdx < 0 || startLine == endLine && startCol == endCol {
503		return ""
504	}
505
506	var sb strings.Builder
507	for i := startItemIdx; i <= endItemIdx; i++ {
508		item := m.list.ItemAt(i)
509		if hi, ok := item.(list.Highlightable); ok {
510			startLine, startCol, endLine, endCol := hi.Highlight()
511			listWidth := m.list.Width()
512			var rendered string
513			if rr, ok := item.(list.RawRenderable); ok {
514				rendered = rr.RawRender(listWidth)
515			} else {
516				rendered = item.Render(listWidth)
517			}
518			sb.WriteString(list.HighlightContent(
519				rendered,
520				uv.Rect(0, 0, listWidth, lipgloss.Height(rendered)),
521				startLine,
522				startCol,
523				endLine,
524				endCol,
525			))
526			sb.WriteString(strings.Repeat("\n", m.list.Gap()))
527		}
528	}
529
530	return strings.TrimSpace(sb.String())
531}
532
533// ClearMouse clears the current mouse interaction state.
534func (m *Chat) ClearMouse() {
535	m.mouseDown = false
536	m.mouseDownItem = -1
537	m.mouseDragItem = -1
538}
539
540// applyHighlightRange applies the current highlight range to the chat items.
541func (m *Chat) applyHighlightRange(idx, selectedIdx int, item list.Item) list.Item {
542	if hi, ok := item.(list.Highlightable); ok {
543		// Apply highlight
544		startItemIdx, startLine, startCol, endItemIdx, endLine, endCol := m.getHighlightRange()
545		sLine, sCol, eLine, eCol := -1, -1, -1, -1
546		if idx >= startItemIdx && idx <= endItemIdx {
547			if idx == startItemIdx && idx == endItemIdx {
548				// Single item selection
549				sLine = startLine
550				sCol = startCol
551				eLine = endLine
552				eCol = endCol
553			} else if idx == startItemIdx {
554				// First item - from start position to end of item
555				sLine = startLine
556				sCol = startCol
557				eLine = -1
558				eCol = -1
559			} else if idx == endItemIdx {
560				// Last item - from start of item to end position
561				sLine = 0
562				sCol = 0
563				eLine = endLine
564				eCol = endCol
565			} else {
566				// Middle item - fully highlighted
567				sLine = 0
568				sCol = 0
569				eLine = -1
570				eCol = -1
571			}
572		}
573
574		hi.SetHighlight(sLine, sCol, eLine, eCol)
575		return hi.(list.Item)
576	}
577
578	return item
579}
580
581// getHighlightRange returns the current highlight range.
582func (m *Chat) getHighlightRange() (startItemIdx, startLine, startCol, endItemIdx, endLine, endCol int) {
583	if m.mouseDownItem < 0 {
584		return -1, -1, -1, -1, -1, -1
585	}
586
587	downItemIdx := m.mouseDownItem
588	dragItemIdx := m.mouseDragItem
589
590	// Determine selection direction
591	draggingDown := dragItemIdx > downItemIdx ||
592		(dragItemIdx == downItemIdx && m.mouseDragY > m.mouseDownY) ||
593		(dragItemIdx == downItemIdx && m.mouseDragY == m.mouseDownY && m.mouseDragX >= m.mouseDownX)
594
595	if draggingDown {
596		// Normal forward selection
597		startItemIdx = downItemIdx
598		startLine = m.mouseDownY
599		startCol = m.mouseDownX
600		endItemIdx = dragItemIdx
601		endLine = m.mouseDragY
602		endCol = m.mouseDragX
603	} else {
604		// Backward selection (dragging up)
605		startItemIdx = dragItemIdx
606		startLine = m.mouseDragY
607		startCol = m.mouseDragX
608		endItemIdx = downItemIdx
609		endLine = m.mouseDownY
610		endCol = m.mouseDownX
611	}
612
613	return startItemIdx, startLine, startCol, endItemIdx, endLine, endCol
614}