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// HandleKeyMsg handles key events for the chat component.
415func (m *Chat) HandleKeyMsg(key tea.KeyMsg) (bool, tea.Cmd) {
416 if m.list.Focused() {
417 if handler, ok := m.list.SelectedItem().(chat.KeyEventHandler); ok {
418 return handler.HandleKeyEvent(key)
419 }
420 }
421 return false, nil
422}
423
424// HandleMouseDown handles mouse down events for the chat component.
425func (m *Chat) HandleMouseDown(x, y int) bool {
426 if m.list.Len() == 0 {
427 return false
428 }
429
430 itemIdx, itemY := m.list.ItemIndexAtPosition(x, y)
431 if itemIdx < 0 {
432 return false
433 }
434 if !m.isSelectable(itemIdx) {
435 return false
436 }
437
438 m.mouseDown = true
439 m.mouseDownItem = itemIdx
440 m.mouseDownX = x
441 m.mouseDownY = itemY
442 m.mouseDragItem = itemIdx
443 m.mouseDragX = x
444 m.mouseDragY = itemY
445
446 // Select the item that was clicked
447 m.list.SetSelected(itemIdx)
448
449 if clickable, ok := m.list.SelectedItem().(list.MouseClickable); ok {
450 return clickable.HandleMouseClick(ansi.MouseButton1, x, itemY)
451 }
452
453 return true
454}
455
456// HandleMouseUp handles mouse up events for the chat component.
457func (m *Chat) HandleMouseUp(x, y int) bool {
458 if !m.mouseDown {
459 return false
460 }
461
462 m.mouseDown = false
463 return true
464}
465
466// HandleMouseDrag handles mouse drag events for the chat component.
467func (m *Chat) HandleMouseDrag(x, y int) bool {
468 if !m.mouseDown {
469 return false
470 }
471
472 if m.list.Len() == 0 {
473 return false
474 }
475
476 itemIdx, itemY := m.list.ItemIndexAtPosition(x, y)
477 if itemIdx < 0 {
478 return false
479 }
480
481 m.mouseDragItem = itemIdx
482 m.mouseDragX = x
483 m.mouseDragY = itemY
484
485 return true
486}
487
488// HasHighlight returns whether there is currently highlighted content.
489func (m *Chat) HasHighlight() bool {
490 startItemIdx, startLine, startCol, endItemIdx, endLine, endCol := m.getHighlightRange()
491 return startItemIdx >= 0 && endItemIdx >= 0 && (startLine != endLine || startCol != endCol)
492}
493
494// HighlightContent returns the currently highlighted content based on the mouse
495// selection. It returns an empty string if no content is highlighted.
496func (m *Chat) HighlightContent() string {
497 startItemIdx, startLine, startCol, endItemIdx, endLine, endCol := m.getHighlightRange()
498 if startItemIdx < 0 || endItemIdx < 0 || startLine == endLine && startCol == endCol {
499 return ""
500 }
501
502 var sb strings.Builder
503 for i := startItemIdx; i <= endItemIdx; i++ {
504 item := m.list.ItemAt(i)
505 if hi, ok := item.(list.Highlightable); ok {
506 startLine, startCol, endLine, endCol := hi.Highlight()
507 listWidth := m.list.Width()
508 var rendered string
509 if rr, ok := item.(list.RawRenderable); ok {
510 rendered = rr.RawRender(listWidth)
511 } else {
512 rendered = item.Render(listWidth)
513 }
514 sb.WriteString(list.HighlightContent(
515 rendered,
516 uv.Rect(0, 0, listWidth, lipgloss.Height(rendered)),
517 startLine,
518 startCol,
519 endLine,
520 endCol,
521 ))
522 sb.WriteString(strings.Repeat("\n", m.list.Gap()))
523 }
524 }
525
526 return strings.TrimSpace(sb.String())
527}
528
529// ClearMouse clears the current mouse interaction state.
530func (m *Chat) ClearMouse() {
531 m.mouseDown = false
532 m.mouseDownItem = -1
533 m.mouseDragItem = -1
534}
535
536// applyHighlightRange applies the current highlight range to the chat items.
537func (m *Chat) applyHighlightRange(idx, selectedIdx int, item list.Item) list.Item {
538 if hi, ok := item.(list.Highlightable); ok {
539 // Apply highlight
540 startItemIdx, startLine, startCol, endItemIdx, endLine, endCol := m.getHighlightRange()
541 sLine, sCol, eLine, eCol := -1, -1, -1, -1
542 if idx >= startItemIdx && idx <= endItemIdx {
543 if idx == startItemIdx && idx == endItemIdx {
544 // Single item selection
545 sLine = startLine
546 sCol = startCol
547 eLine = endLine
548 eCol = endCol
549 } else if idx == startItemIdx {
550 // First item - from start position to end of item
551 sLine = startLine
552 sCol = startCol
553 eLine = -1
554 eCol = -1
555 } else if idx == endItemIdx {
556 // Last item - from start of item to end position
557 sLine = 0
558 sCol = 0
559 eLine = endLine
560 eCol = endCol
561 } else {
562 // Middle item - fully highlighted
563 sLine = 0
564 sCol = 0
565 eLine = -1
566 eCol = -1
567 }
568 }
569
570 hi.SetHighlight(sLine, sCol, eLine, eCol)
571 return hi.(list.Item)
572 }
573
574 return item
575}
576
577// getHighlightRange returns the current highlight range.
578func (m *Chat) getHighlightRange() (startItemIdx, startLine, startCol, endItemIdx, endLine, endCol int) {
579 if m.mouseDownItem < 0 {
580 return -1, -1, -1, -1, -1, -1
581 }
582
583 downItemIdx := m.mouseDownItem
584 dragItemIdx := m.mouseDragItem
585
586 // Determine selection direction
587 draggingDown := dragItemIdx > downItemIdx ||
588 (dragItemIdx == downItemIdx && m.mouseDragY > m.mouseDownY) ||
589 (dragItemIdx == downItemIdx && m.mouseDragY == m.mouseDownY && m.mouseDragX >= m.mouseDownX)
590
591 if draggingDown {
592 // Normal forward selection
593 startItemIdx = downItemIdx
594 startLine = m.mouseDownY
595 startCol = m.mouseDownX
596 endItemIdx = dragItemIdx
597 endLine = m.mouseDragY
598 endCol = m.mouseDragX
599 } else {
600 // Backward selection (dragging up)
601 startItemIdx = dragItemIdx
602 startLine = m.mouseDragY
603 startCol = m.mouseDragX
604 endItemIdx = downItemIdx
605 endLine = m.mouseDownY
606 endCol = m.mouseDownX
607 }
608
609 return startItemIdx, startLine, startCol, endItemIdx, endLine, endCol
610}