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}