1package model
2
3import (
4 "fmt"
5 "path/filepath"
6 "strings"
7 "time"
8
9 "charm.land/lipgloss/v2"
10 uv "github.com/charmbracelet/ultraviolet"
11 "github.com/charmbracelet/x/ansi"
12
13 "github.com/charmbracelet/crush/internal/config"
14 "github.com/charmbracelet/crush/internal/message"
15 "github.com/charmbracelet/crush/internal/ui/common"
16 "github.com/charmbracelet/crush/internal/ui/list"
17 "github.com/charmbracelet/crush/internal/ui/styles"
18 "github.com/charmbracelet/crush/internal/ui/toolrender"
19)
20
21// Identifiable is an interface for items that can provide a unique identifier.
22type Identifiable interface {
23 ID() string
24}
25
26// MessageItem represents a [message.Message] item that can be displayed in the
27// UI and be part of a [list.List] identifiable by a unique ID.
28type MessageItem interface {
29 list.Item
30 list.Focusable
31 list.Highlightable
32 Identifiable
33}
34
35// MessageContentItem represents rendered message content (text, markdown, errors, etc).
36type MessageContentItem struct {
37 list.BaseFocusable
38 list.BaseHighlightable
39 id string
40 content string
41 isMarkdown bool
42 maxWidth int
43 cache map[int]string // Cache for rendered content at different widths
44 sty *styles.Styles
45}
46
47// NewMessageContentItem creates a new message content item.
48func NewMessageContentItem(id, content string, isMarkdown bool, sty *styles.Styles) *MessageContentItem {
49 m := &MessageContentItem{
50 id: id,
51 content: content,
52 isMarkdown: isMarkdown,
53 maxWidth: 120,
54 cache: make(map[int]string),
55 sty: sty,
56 }
57 m.InitHighlight()
58 return m
59}
60
61// ID implements Identifiable.
62func (m *MessageContentItem) ID() string {
63 return m.id
64}
65
66// Height implements list.Item.
67func (m *MessageContentItem) Height(width int) int {
68 // Calculate content width accounting for frame size
69 contentWidth := width
70 if style := m.CurrentStyle(); style != nil {
71 contentWidth -= style.GetHorizontalFrameSize()
72 }
73
74 rendered := m.render(contentWidth)
75
76 // Apply focus/blur styling if configured to get accurate height
77 if style := m.CurrentStyle(); style != nil {
78 rendered = style.Render(rendered)
79 }
80
81 return strings.Count(rendered, "\n") + 1
82}
83
84// Draw implements list.Item.
85func (m *MessageContentItem) Draw(scr uv.Screen, area uv.Rectangle) {
86 width := area.Dx()
87 height := area.Dy()
88
89 // Calculate content width accounting for frame size
90 contentWidth := width
91 style := m.CurrentStyle()
92 if style != nil {
93 contentWidth -= style.GetHorizontalFrameSize()
94 }
95
96 rendered := m.render(contentWidth)
97
98 // Apply focus/blur styling if configured
99 if style != nil {
100 rendered = style.Render(rendered)
101 }
102
103 // Create temp buffer to draw content with highlighting
104 tempBuf := uv.NewScreenBuffer(width, height)
105
106 // Draw the rendered content to temp buffer
107 styled := uv.NewStyledString(rendered)
108 styled.Draw(&tempBuf, uv.Rect(0, 0, width, height))
109
110 // Apply highlighting if active
111 m.ApplyHighlight(&tempBuf, width, height, style)
112
113 // Copy temp buffer to actual screen at the target area
114 tempBuf.Draw(scr, area)
115}
116
117// render renders the content at the given width, using cache if available.
118func (m *MessageContentItem) render(width int) string {
119 // Cap width to maxWidth for markdown
120 cappedWidth := width
121 if m.isMarkdown {
122 cappedWidth = min(width, m.maxWidth)
123 }
124
125 // Check cache first
126 if cached, ok := m.cache[cappedWidth]; ok {
127 return cached
128 }
129
130 // Not cached - render now
131 var rendered string
132 if m.isMarkdown {
133 renderer := common.MarkdownRenderer(m.sty, cappedWidth)
134 result, err := renderer.Render(m.content)
135 if err != nil {
136 rendered = m.content
137 } else {
138 rendered = strings.TrimSuffix(result, "\n")
139 }
140 } else {
141 rendered = m.content
142 }
143
144 // Cache the result
145 m.cache[cappedWidth] = rendered
146 return rendered
147}
148
149// SetHighlight implements list.Highlightable and extends BaseHighlightable.
150func (m *MessageContentItem) SetHighlight(startLine, startCol, endLine, endCol int) {
151 m.BaseHighlightable.SetHighlight(startLine, startCol, endLine, endCol)
152 // Clear cache when highlight changes
153 m.cache = make(map[int]string)
154}
155
156// ToolCallItem represents a rendered tool call with its header and content.
157type ToolCallItem struct {
158 list.BaseFocusable
159 list.BaseHighlightable
160 id string
161 toolCall message.ToolCall
162 toolResult message.ToolResult
163 cancelled bool
164 isNested bool
165 maxWidth int
166 cache map[int]cachedToolRender // Cache for rendered content at different widths
167 cacheKey string // Key to invalidate cache when content changes
168 sty *styles.Styles
169}
170
171// cachedToolRender stores both the rendered string and its height.
172type cachedToolRender struct {
173 content string
174 height int
175}
176
177// NewToolCallItem creates a new tool call item.
178func NewToolCallItem(id string, toolCall message.ToolCall, toolResult message.ToolResult, cancelled bool, isNested bool, sty *styles.Styles) *ToolCallItem {
179 t := &ToolCallItem{
180 id: id,
181 toolCall: toolCall,
182 toolResult: toolResult,
183 cancelled: cancelled,
184 isNested: isNested,
185 maxWidth: 120,
186 cache: make(map[int]cachedToolRender),
187 cacheKey: generateCacheKey(toolCall, toolResult, cancelled),
188 sty: sty,
189 }
190 t.InitHighlight()
191 return t
192}
193
194// generateCacheKey creates a key that changes when tool call content changes.
195func generateCacheKey(toolCall message.ToolCall, toolResult message.ToolResult, cancelled bool) string {
196 // Simple key based on result state - when result arrives or changes, key changes
197 return fmt.Sprintf("%s:%s:%v", toolCall.ID, toolResult.ToolCallID, cancelled)
198}
199
200// ID implements Identifiable.
201func (t *ToolCallItem) ID() string {
202 return t.id
203}
204
205// Height implements list.Item.
206func (t *ToolCallItem) Height(width int) int {
207 // Calculate content width accounting for frame size
208 contentWidth := width
209 frameSize := 0
210 if style := t.CurrentStyle(); style != nil {
211 frameSize = style.GetHorizontalFrameSize()
212 contentWidth -= frameSize
213 }
214
215 cached := t.renderCached(contentWidth)
216
217 // Add frame size to height if needed
218 height := cached.height
219 if frameSize > 0 {
220 // Frame can add to height (borders, padding)
221 if style := t.CurrentStyle(); style != nil {
222 // Quick render to get accurate height with frame
223 rendered := style.Render(cached.content)
224 height = strings.Count(rendered, "\n") + 1
225 }
226 }
227
228 return height
229}
230
231// Draw implements list.Item.
232func (t *ToolCallItem) Draw(scr uv.Screen, area uv.Rectangle) {
233 width := area.Dx()
234 height := area.Dy()
235
236 // Calculate content width accounting for frame size
237 contentWidth := width
238 style := t.CurrentStyle()
239 if style != nil {
240 contentWidth -= style.GetHorizontalFrameSize()
241 }
242
243 cached := t.renderCached(contentWidth)
244 rendered := cached.content
245
246 if style != nil {
247 rendered = style.Render(rendered)
248 }
249
250 tempBuf := uv.NewScreenBuffer(width, height)
251 styled := uv.NewStyledString(rendered)
252 styled.Draw(&tempBuf, uv.Rect(0, 0, width, height))
253
254 t.ApplyHighlight(&tempBuf, width, height, style)
255 tempBuf.Draw(scr, area)
256}
257
258// renderCached renders the tool call at the given width with caching.
259func (t *ToolCallItem) renderCached(width int) cachedToolRender {
260 cappedWidth := min(width, t.maxWidth)
261
262 // Check if we have a valid cache entry
263 if cached, ok := t.cache[cappedWidth]; ok {
264 return cached
265 }
266
267 // Render the tool call
268 ctx := &toolrender.RenderContext{
269 Call: t.toolCall,
270 Result: t.toolResult,
271 Cancelled: t.cancelled,
272 IsNested: t.isNested,
273 Width: cappedWidth,
274 Styles: t.sty,
275 }
276
277 rendered := toolrender.Render(ctx)
278 height := strings.Count(rendered, "\n") + 1
279
280 cached := cachedToolRender{
281 content: rendered,
282 height: height,
283 }
284 t.cache[cappedWidth] = cached
285 return cached
286}
287
288// SetHighlight implements list.Highlightable.
289func (t *ToolCallItem) SetHighlight(startLine, startCol, endLine, endCol int) {
290 t.BaseHighlightable.SetHighlight(startLine, startCol, endLine, endCol)
291 // Clear cache when highlight changes
292 t.cache = make(map[int]cachedToolRender)
293}
294
295// UpdateResult updates the tool result and invalidates the cache if needed.
296func (t *ToolCallItem) UpdateResult(result message.ToolResult) {
297 newKey := generateCacheKey(t.toolCall, result, t.cancelled)
298 if newKey != t.cacheKey {
299 t.toolResult = result
300 t.cacheKey = newKey
301 t.cache = make(map[int]cachedToolRender)
302 }
303}
304
305// AttachmentItem represents a file attachment in a user message.
306type AttachmentItem struct {
307 list.BaseFocusable
308 list.BaseHighlightable
309 id string
310 filename string
311 path string
312 sty *styles.Styles
313}
314
315// NewAttachmentItem creates a new attachment item.
316func NewAttachmentItem(id, filename, path string, sty *styles.Styles) *AttachmentItem {
317 a := &AttachmentItem{
318 id: id,
319 filename: filename,
320 path: path,
321 sty: sty,
322 }
323 a.InitHighlight()
324 return a
325}
326
327// ID implements Identifiable.
328func (a *AttachmentItem) ID() string {
329 return a.id
330}
331
332// Height implements list.Item.
333func (a *AttachmentItem) Height(width int) int {
334 return 1
335}
336
337// Draw implements list.Item.
338func (a *AttachmentItem) Draw(scr uv.Screen, area uv.Rectangle) {
339 width := area.Dx()
340 height := area.Dy()
341
342 // Calculate content width accounting for frame size
343 contentWidth := width
344 style := a.CurrentStyle()
345 if style != nil {
346 contentWidth -= style.GetHorizontalFrameSize()
347 }
348
349 const maxFilenameWidth = 10
350 content := a.sty.Chat.Message.Attachment.Render(fmt.Sprintf(
351 " %s %s ",
352 styles.DocumentIcon,
353 ansi.Truncate(a.filename, maxFilenameWidth, "..."),
354 ))
355
356 if style != nil {
357 content = style.Render(content)
358 }
359
360 tempBuf := uv.NewScreenBuffer(width, height)
361 styled := uv.NewStyledString(content)
362 styled.Draw(&tempBuf, uv.Rect(0, 0, width, height))
363
364 a.ApplyHighlight(&tempBuf, width, height, style)
365 tempBuf.Draw(scr, area)
366}
367
368// ThinkingItem represents thinking/reasoning content in assistant messages.
369type ThinkingItem struct {
370 list.BaseFocusable
371 list.BaseHighlightable
372 id string
373 thinking string
374 duration time.Duration
375 finished bool
376 maxWidth int
377 cache map[int]string
378 sty *styles.Styles
379}
380
381// NewThinkingItem creates a new thinking item.
382func NewThinkingItem(id, thinking string, duration time.Duration, finished bool, sty *styles.Styles) *ThinkingItem {
383 t := &ThinkingItem{
384 id: id,
385 thinking: thinking,
386 duration: duration,
387 finished: finished,
388 maxWidth: 120,
389 cache: make(map[int]string),
390 sty: sty,
391 }
392 t.InitHighlight()
393 return t
394}
395
396// ID implements Identifiable.
397func (t *ThinkingItem) ID() string {
398 return t.id
399}
400
401// Height implements list.Item.
402func (t *ThinkingItem) Height(width int) int {
403 // Calculate content width accounting for frame size
404 contentWidth := width
405 if style := t.CurrentStyle(); style != nil {
406 contentWidth -= style.GetHorizontalFrameSize()
407 }
408
409 rendered := t.render(contentWidth)
410 return strings.Count(rendered, "\n") + 1
411}
412
413// Draw implements list.Item.
414func (t *ThinkingItem) Draw(scr uv.Screen, area uv.Rectangle) {
415 width := area.Dx()
416 height := area.Dy()
417
418 // Calculate content width accounting for frame size
419 contentWidth := width
420 style := t.CurrentStyle()
421 if style != nil {
422 contentWidth -= style.GetHorizontalFrameSize()
423 }
424
425 rendered := t.render(contentWidth)
426
427 if style != nil {
428 rendered = style.Render(rendered)
429 }
430
431 tempBuf := uv.NewScreenBuffer(width, height)
432 styled := uv.NewStyledString(rendered)
433 styled.Draw(&tempBuf, uv.Rect(0, 0, width, height))
434
435 t.ApplyHighlight(&tempBuf, width, height, style)
436 tempBuf.Draw(scr, area)
437}
438
439// render renders the thinking content.
440func (t *ThinkingItem) render(width int) string {
441 cappedWidth := min(width, t.maxWidth)
442
443 if cached, ok := t.cache[cappedWidth]; ok {
444 return cached
445 }
446
447 renderer := common.PlainMarkdownRenderer(cappedWidth - 1)
448 rendered, err := renderer.Render(t.thinking)
449 if err != nil {
450 // Fallback to line-by-line rendering
451 lines := strings.Split(t.thinking, "\n")
452 var content strings.Builder
453 lineStyle := t.sty.PanelMuted
454 for i, line := range lines {
455 if line == "" {
456 continue
457 }
458 content.WriteString(lineStyle.Width(cappedWidth).Render(line))
459 if i < len(lines)-1 {
460 content.WriteString("\n")
461 }
462 }
463 rendered = content.String()
464 }
465
466 fullContent := strings.TrimSpace(rendered)
467
468 // Add footer if finished
469 if t.finished && t.duration > 0 {
470 footer := t.sty.Chat.Message.ThinkingFooter.Render(fmt.Sprintf("Thought for %s", t.duration.String()))
471 fullContent = lipgloss.JoinVertical(lipgloss.Left, fullContent, "", footer)
472 }
473
474 result := t.sty.PanelMuted.Width(cappedWidth).Padding(0, 1).Render(fullContent)
475
476 t.cache[cappedWidth] = result
477 return result
478}
479
480// SetHighlight implements list.Highlightable.
481func (t *ThinkingItem) SetHighlight(startLine, startCol, endLine, endCol int) {
482 t.BaseHighlightable.SetHighlight(startLine, startCol, endLine, endCol)
483 t.cache = make(map[int]string)
484}
485
486// SectionHeaderItem represents a section header (e.g., assistant info).
487type SectionHeaderItem struct {
488 list.BaseFocusable
489 list.BaseHighlightable
490 id string
491 modelName string
492 duration time.Duration
493 isSectionHeader bool
494 sty *styles.Styles
495}
496
497// NewSectionHeaderItem creates a new section header item.
498func NewSectionHeaderItem(id, modelName string, duration time.Duration, sty *styles.Styles) *SectionHeaderItem {
499 s := &SectionHeaderItem{
500 id: id,
501 modelName: modelName,
502 duration: duration,
503 isSectionHeader: true,
504 sty: sty,
505 }
506 s.InitHighlight()
507 return s
508}
509
510// ID implements Identifiable.
511func (s *SectionHeaderItem) ID() string {
512 return s.id
513}
514
515// IsSectionHeader returns true if this is a section header.
516func (s *SectionHeaderItem) IsSectionHeader() bool {
517 return s.isSectionHeader
518}
519
520// Height implements list.Item.
521func (s *SectionHeaderItem) Height(width int) int {
522 return 1
523}
524
525// Draw implements list.Item.
526func (s *SectionHeaderItem) Draw(scr uv.Screen, area uv.Rectangle) {
527 width := area.Dx()
528 height := area.Dy()
529
530 // Calculate content width accounting for frame size
531 contentWidth := width
532 style := s.CurrentStyle()
533 if style != nil {
534 contentWidth -= style.GetHorizontalFrameSize()
535 }
536
537 infoMsg := s.sty.Subtle.Render(s.duration.String())
538 icon := s.sty.Subtle.Render(styles.ModelIcon)
539 modelFormatted := s.sty.Muted.Render(s.modelName)
540 content := fmt.Sprintf("%s %s %s", icon, modelFormatted, infoMsg)
541
542 content = s.sty.Chat.Message.SectionHeader.Render(content)
543
544 if style != nil {
545 content = style.Render(content)
546 }
547
548 tempBuf := uv.NewScreenBuffer(width, height)
549 styled := uv.NewStyledString(content)
550 styled.Draw(&tempBuf, uv.Rect(0, 0, width, height))
551
552 s.ApplyHighlight(&tempBuf, width, height, style)
553 tempBuf.Draw(scr, area)
554}
555
556// GetMessageItems extracts [MessageItem]s from a [message.Message]. It returns
557// all parts of the message as [MessageItem]s.
558//
559// For assistant messages with tool calls, pass a toolResults map to link results.
560// Use BuildToolResultMap to create this map from all messages in a session.
561func GetMessageItems(msg *message.Message, toolResults map[string]message.ToolResult) []MessageItem {
562 sty := styles.DefaultStyles()
563 var items []MessageItem
564
565 // Skip tool result messages - they're displayed inline with tool calls
566 if msg.Role == message.Tool {
567 return items
568 }
569
570 // Create base styles for the message
571 var focusStyle, blurStyle lipgloss.Style
572 if msg.Role == message.User {
573 focusStyle = sty.Chat.Message.UserFocused
574 blurStyle = sty.Chat.Message.UserBlurred
575 } else {
576 focusStyle = sty.Chat.Message.AssistantFocused
577 blurStyle = sty.Chat.Message.AssistantBlurred
578 }
579
580 // Process user messages
581 if msg.Role == message.User {
582 // Add main text content
583 content := msg.Content().String()
584 if content != "" {
585 item := NewMessageContentItem(
586 fmt.Sprintf("%s-content", msg.ID),
587 content,
588 true, // User messages are markdown
589 &sty,
590 )
591 item.SetFocusStyles(&focusStyle, &blurStyle)
592 items = append(items, item)
593 }
594
595 // Add attachments
596 for i, attachment := range msg.BinaryContent() {
597 filename := filepath.Base(attachment.Path)
598 item := NewAttachmentItem(
599 fmt.Sprintf("%s-attachment-%d", msg.ID, i),
600 filename,
601 attachment.Path,
602 &sty,
603 )
604 item.SetFocusStyles(&focusStyle, &blurStyle)
605 items = append(items, item)
606 }
607
608 return items
609 }
610
611 // Process assistant messages
612 if msg.Role == message.Assistant {
613 // Check if we need to add a section header
614 finishData := msg.FinishPart()
615 if finishData != nil && msg.Model != "" {
616 model := config.Get().GetModel(msg.Provider, msg.Model)
617 modelName := "Unknown Model"
618 if model != nil {
619 modelName = model.Name
620 }
621
622 // Calculate duration (this would need the last user message time)
623 duration := time.Duration(0)
624 if finishData.Time > 0 {
625 duration = time.Duration(finishData.Time-msg.CreatedAt) * time.Second
626 }
627
628 header := NewSectionHeaderItem(
629 fmt.Sprintf("%s-header", msg.ID),
630 modelName,
631 duration,
632 &sty,
633 )
634 items = append(items, header)
635 }
636
637 // Add thinking content if present
638 reasoning := msg.ReasoningContent()
639 if strings.TrimSpace(reasoning.Thinking) != "" {
640 duration := time.Duration(0)
641 if reasoning.StartedAt > 0 && reasoning.FinishedAt > 0 {
642 duration = time.Duration(reasoning.FinishedAt-reasoning.StartedAt) * time.Second
643 }
644
645 item := NewThinkingItem(
646 fmt.Sprintf("%s-thinking", msg.ID),
647 reasoning.Thinking,
648 duration,
649 reasoning.FinishedAt > 0,
650 &sty,
651 )
652 item.SetFocusStyles(&focusStyle, &blurStyle)
653 items = append(items, item)
654 }
655
656 // Add main text content
657 content := msg.Content().String()
658 finished := msg.IsFinished()
659
660 // Handle special finish states
661 if finished && content == "" && finishData != nil {
662 switch finishData.Reason {
663 case message.FinishReasonEndTurn:
664 // No content to show
665 case message.FinishReasonCanceled:
666 item := NewMessageContentItem(
667 fmt.Sprintf("%s-content", msg.ID),
668 "*Canceled*",
669 true,
670 &sty,
671 )
672 item.SetFocusStyles(&focusStyle, &blurStyle)
673 items = append(items, item)
674 case message.FinishReasonError:
675 // Render error
676 errTag := sty.Chat.Message.ErrorTag.Render("ERROR")
677 truncated := ansi.Truncate(finishData.Message, 100, "...")
678 title := fmt.Sprintf("%s %s", errTag, sty.Chat.Message.ErrorTitle.Render(truncated))
679 details := sty.Chat.Message.ErrorDetails.Render(finishData.Details)
680 errorContent := fmt.Sprintf("%s\n\n%s", title, details)
681
682 item := NewMessageContentItem(
683 fmt.Sprintf("%s-error", msg.ID),
684 errorContent,
685 false,
686 &sty,
687 )
688 item.SetFocusStyles(&focusStyle, &blurStyle)
689 items = append(items, item)
690 }
691 } else if content != "" {
692 item := NewMessageContentItem(
693 fmt.Sprintf("%s-content", msg.ID),
694 content,
695 true, // Assistant messages are markdown
696 &sty,
697 )
698 item.SetFocusStyles(&focusStyle, &blurStyle)
699 items = append(items, item)
700 }
701
702 // Add tool calls
703 toolCalls := msg.ToolCalls()
704
705 // Use passed-in tool results map (if nil, use empty map)
706 resultMap := toolResults
707 if resultMap == nil {
708 resultMap = make(map[string]message.ToolResult)
709 }
710
711 for _, tc := range toolCalls {
712 result, hasResult := resultMap[tc.ID]
713 if !hasResult {
714 result = message.ToolResult{}
715 }
716
717 item := NewToolCallItem(
718 fmt.Sprintf("%s-tool-%s", msg.ID, tc.ID),
719 tc,
720 result,
721 false, // cancelled state would need to be tracked separately
722 false, // nested state would be detected from tool results
723 &sty,
724 )
725
726 // Tool calls use muted style with optional focus border
727 item.SetFocusStyles(&sty.Chat.Message.ToolCallFocused, &sty.Chat.Message.ToolCallBlurred)
728
729 items = append(items, item)
730 }
731
732 return items
733 }
734
735 return items
736}
737
738// BuildToolResultMap creates a map of tool call IDs to their results from a list of messages.
739// Tool result messages (role == message.Tool) contain the results that should be linked
740// to tool calls in assistant messages.
741func BuildToolResultMap(messages []*message.Message) map[string]message.ToolResult {
742 resultMap := make(map[string]message.ToolResult)
743 for _, msg := range messages {
744 if msg.Role == message.Tool {
745 for _, result := range msg.ToolResults() {
746 if result.ToolCallID != "" {
747 resultMap[result.ToolCallID] = result
748 }
749 }
750 }
751 }
752 return resultMap
753}