items.go

  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}