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}