items.go

  1package model
  2
  3import (
  4	"fmt"
  5	"path/filepath"
  6	"strings"
  7	"time"
  8
  9	"charm.land/lipgloss/v2"
 10	"github.com/charmbracelet/x/ansi"
 11
 12	"github.com/charmbracelet/crush/internal/config"
 13	"github.com/charmbracelet/crush/internal/message"
 14	"github.com/charmbracelet/crush/internal/ui/common"
 15	"github.com/charmbracelet/crush/internal/ui/list"
 16	"github.com/charmbracelet/crush/internal/ui/styles"
 17	"github.com/charmbracelet/crush/internal/ui/toolrender"
 18)
 19
 20// Identifiable is an interface for items that can provide a unique identifier.
 21type Identifiable interface {
 22	ID() string
 23}
 24
 25// MessageItem represents a [message.Message] item that can be displayed in the
 26// UI and be part of a [list.List] identifiable by a unique ID.
 27type MessageItem interface {
 28	list.Item
 29	list.Item
 30	Identifiable
 31}
 32
 33// MessageContentItem represents rendered message content (text, markdown, errors, etc).
 34type MessageContentItem struct {
 35	id         string
 36	content    string
 37	role       message.MessageRole
 38	isMarkdown bool
 39	maxWidth   int
 40	sty        *styles.Styles
 41}
 42
 43// NewMessageContentItem creates a new message content item.
 44func NewMessageContentItem(id, content string, role message.MessageRole, isMarkdown bool, sty *styles.Styles) *MessageContentItem {
 45	m := &MessageContentItem{
 46		id:         id,
 47		content:    content,
 48		isMarkdown: isMarkdown,
 49		role:       role,
 50		maxWidth:   120,
 51		sty:        sty,
 52	}
 53	return m
 54}
 55
 56// ID implements Identifiable.
 57func (m *MessageContentItem) ID() string {
 58	return m.id
 59}
 60
 61// FocusStyle returns the focus style.
 62func (m *MessageContentItem) FocusStyle() lipgloss.Style {
 63	if m.role == message.User {
 64		return m.sty.Chat.Message.UserFocused
 65	}
 66	return m.sty.Chat.Message.AssistantFocused
 67}
 68
 69// BlurStyle returns the blur style.
 70func (m *MessageContentItem) BlurStyle() lipgloss.Style {
 71	if m.role == message.User {
 72		return m.sty.Chat.Message.UserBlurred
 73	}
 74	return m.sty.Chat.Message.AssistantBlurred
 75}
 76
 77// HighlightStyle returns the highlight style.
 78func (m *MessageContentItem) HighlightStyle() lipgloss.Style {
 79	return m.sty.TextSelection
 80}
 81
 82// Render renders the content at the given width, using cache if available.
 83//
 84// It implements [list.Item].
 85func (m *MessageContentItem) Render(width int) string {
 86	contentWidth := width
 87	// Cap width to maxWidth for markdown
 88	cappedWidth := contentWidth
 89	if m.isMarkdown {
 90		cappedWidth = min(contentWidth, m.maxWidth)
 91	}
 92
 93	var rendered string
 94	if m.isMarkdown {
 95		renderer := common.MarkdownRenderer(m.sty, cappedWidth)
 96		result, err := renderer.Render(m.content)
 97		if err != nil {
 98			rendered = m.content
 99		} else {
100			rendered = strings.TrimSuffix(result, "\n")
101		}
102	} else {
103		rendered = m.content
104	}
105
106	return rendered
107}
108
109// ToolCallItem represents a rendered tool call with its header and content.
110type ToolCallItem struct {
111	id         string
112	toolCall   message.ToolCall
113	toolResult message.ToolResult
114	cancelled  bool
115	isNested   bool
116	maxWidth   int
117	sty        *styles.Styles
118}
119
120// cachedToolRender stores both the rendered string and its height.
121type cachedToolRender struct {
122	content string
123	height  int
124}
125
126// NewToolCallItem creates a new tool call item.
127func NewToolCallItem(id string, toolCall message.ToolCall, toolResult message.ToolResult, cancelled bool, isNested bool, sty *styles.Styles) *ToolCallItem {
128	t := &ToolCallItem{
129		id:         id,
130		toolCall:   toolCall,
131		toolResult: toolResult,
132		cancelled:  cancelled,
133		isNested:   isNested,
134		maxWidth:   120,
135		sty:        sty,
136	}
137	return t
138}
139
140// generateCacheKey creates a key that changes when tool call content changes.
141func generateCacheKey(toolCall message.ToolCall, toolResult message.ToolResult, cancelled bool) string {
142	// Simple key based on result state - when result arrives or changes, key changes
143	return fmt.Sprintf("%s:%s:%v", toolCall.ID, toolResult.ToolCallID, cancelled)
144}
145
146// ID implements Identifiable.
147func (t *ToolCallItem) ID() string {
148	return t.id
149}
150
151// FocusStyle returns the focus style.
152func (t *ToolCallItem) FocusStyle() lipgloss.Style {
153	return t.sty.Chat.Message.ToolCallFocused
154}
155
156// BlurStyle returns the blur style.
157func (t *ToolCallItem) BlurStyle() lipgloss.Style {
158	return t.sty.Chat.Message.ToolCallBlurred
159}
160
161// HighlightStyle returns the highlight style.
162func (t *ToolCallItem) HighlightStyle() lipgloss.Style {
163	return t.sty.TextSelection
164}
165
166// Render implements list.Item.
167func (t *ToolCallItem) Render(width int) string {
168	// Render the tool call
169	ctx := &toolrender.RenderContext{
170		Call:      t.toolCall,
171		Result:    t.toolResult,
172		Cancelled: t.cancelled,
173		IsNested:  t.isNested,
174		Width:     width,
175		Styles:    t.sty,
176	}
177
178	rendered := toolrender.Render(ctx)
179	return rendered
180}
181
182// AttachmentItem represents a file attachment in a user message.
183type AttachmentItem struct {
184	id       string
185	filename string
186	path     string
187	sty      *styles.Styles
188}
189
190// NewAttachmentItem creates a new attachment item.
191func NewAttachmentItem(id, filename, path string, sty *styles.Styles) *AttachmentItem {
192	a := &AttachmentItem{
193		id:       id,
194		filename: filename,
195		path:     path,
196		sty:      sty,
197	}
198	return a
199}
200
201// ID implements Identifiable.
202func (a *AttachmentItem) ID() string {
203	return a.id
204}
205
206// FocusStyle returns the focus style.
207func (a *AttachmentItem) FocusStyle() lipgloss.Style {
208	return a.sty.Chat.Message.AssistantFocused
209}
210
211// BlurStyle returns the blur style.
212func (a *AttachmentItem) BlurStyle() lipgloss.Style {
213	return a.sty.Chat.Message.AssistantBlurred
214}
215
216// HighlightStyle returns the highlight style.
217func (a *AttachmentItem) HighlightStyle() lipgloss.Style {
218	return a.sty.TextSelection
219}
220
221// Render implements list.Item.
222func (a *AttachmentItem) Render(width int) string {
223	const maxFilenameWidth = 10
224	content := a.sty.Chat.Message.Attachment.Render(fmt.Sprintf(
225		" %s %s ",
226		styles.DocumentIcon,
227		ansi.Truncate(a.filename, maxFilenameWidth, "..."),
228	))
229
230	return content
231
232	// return a.RenderWithHighlight(content, width, a.CurrentStyle())
233}
234
235// ThinkingItem represents thinking/reasoning content in assistant messages.
236type ThinkingItem struct {
237	id       string
238	thinking string
239	duration time.Duration
240	finished bool
241	maxWidth int
242	sty      *styles.Styles
243}
244
245// NewThinkingItem creates a new thinking item.
246func NewThinkingItem(id, thinking string, duration time.Duration, finished bool, sty *styles.Styles) *ThinkingItem {
247	t := &ThinkingItem{
248		id:       id,
249		thinking: thinking,
250		duration: duration,
251		finished: finished,
252		maxWidth: 120,
253		sty:      sty,
254	}
255	return t
256}
257
258// ID implements Identifiable.
259func (t *ThinkingItem) ID() string {
260	return t.id
261}
262
263// FocusStyle returns the focus style.
264func (t *ThinkingItem) FocusStyle() lipgloss.Style {
265	return t.sty.Chat.Message.AssistantFocused
266}
267
268// BlurStyle returns the blur style.
269func (t *ThinkingItem) BlurStyle() lipgloss.Style {
270	return t.sty.Chat.Message.AssistantBlurred
271}
272
273// HighlightStyle returns the highlight style.
274func (t *ThinkingItem) HighlightStyle() lipgloss.Style {
275	return t.sty.TextSelection
276}
277
278// Render implements list.Item.
279func (t *ThinkingItem) Render(width int) string {
280	cappedWidth := min(width, t.maxWidth)
281
282	renderer := common.PlainMarkdownRenderer(cappedWidth - 1)
283	rendered, err := renderer.Render(t.thinking)
284	if err != nil {
285		// Fallback to line-by-line rendering
286		lines := strings.Split(t.thinking, "\n")
287		var content strings.Builder
288		lineStyle := t.sty.PanelMuted
289		for i, line := range lines {
290			if line == "" {
291				continue
292			}
293			content.WriteString(lineStyle.Width(cappedWidth).Render(line))
294			if i < len(lines)-1 {
295				content.WriteString("\n")
296			}
297		}
298		rendered = content.String()
299	}
300
301	fullContent := strings.TrimSpace(rendered)
302
303	// Add footer if finished
304	if t.finished && t.duration > 0 {
305		footer := t.sty.Chat.Message.ThinkingFooter.Render(fmt.Sprintf("Thought for %s", t.duration.String()))
306		fullContent = lipgloss.JoinVertical(lipgloss.Left, fullContent, "", footer)
307	}
308
309	result := t.sty.PanelMuted.Width(cappedWidth).Padding(0, 1).Render(fullContent)
310
311	return result
312}
313
314// SectionHeaderItem represents a section header (e.g., assistant info).
315type SectionHeaderItem struct {
316	id              string
317	modelName       string
318	duration        time.Duration
319	isSectionHeader bool
320	sty             *styles.Styles
321	content         string
322}
323
324// NewSectionHeaderItem creates a new section header item.
325func NewSectionHeaderItem(id, modelName string, duration time.Duration, sty *styles.Styles) *SectionHeaderItem {
326	s := &SectionHeaderItem{
327		id:              id,
328		modelName:       modelName,
329		duration:        duration,
330		isSectionHeader: true,
331		sty:             sty,
332	}
333	return s
334}
335
336// ID implements Identifiable.
337func (s *SectionHeaderItem) ID() string {
338	return s.id
339}
340
341// IsSectionHeader returns true if this is a section header.
342func (s *SectionHeaderItem) IsSectionHeader() bool {
343	return s.isSectionHeader
344}
345
346// FocusStyle returns the focus style.
347func (s *SectionHeaderItem) FocusStyle() lipgloss.Style {
348	return s.sty.Chat.Message.AssistantFocused
349}
350
351// BlurStyle returns the blur style.
352func (s *SectionHeaderItem) BlurStyle() lipgloss.Style {
353	return s.sty.Chat.Message.AssistantBlurred
354}
355
356// Render implements list.Item.
357func (s *SectionHeaderItem) Render(width int) string {
358	content := fmt.Sprintf("%s %s %s",
359		s.sty.Subtle.Render(styles.ModelIcon),
360		s.sty.Muted.Render(s.modelName),
361		s.sty.Subtle.Render(s.duration.String()),
362	)
363
364	return s.sty.Chat.Message.SectionHeader.Render(content)
365}
366
367// GetMessageItems extracts [MessageItem]s from a [message.Message]. It returns
368// all parts of the message as [MessageItem]s.
369//
370// For assistant messages with tool calls, pass a toolResults map to link results.
371// Use BuildToolResultMap to create this map from all messages in a session.
372func GetMessageItems(sty *styles.Styles, msg *message.Message, toolResults map[string]message.ToolResult) []MessageItem {
373	var items []MessageItem
374
375	// Skip tool result messages - they're displayed inline with tool calls
376	if msg.Role == message.Tool {
377		return items
378	}
379
380	// Process user messages
381	if msg.Role == message.User {
382		// Add main text content
383		content := msg.Content().String()
384		if content != "" {
385			item := NewMessageContentItem(
386				fmt.Sprintf("%s-content", msg.ID),
387				content,
388				msg.Role,
389				true, // User messages are markdown
390				sty,
391			)
392			items = append(items, item)
393		}
394
395		// Add attachments
396		for i, attachment := range msg.BinaryContent() {
397			filename := filepath.Base(attachment.Path)
398			item := NewAttachmentItem(
399				fmt.Sprintf("%s-attachment-%d", msg.ID, i),
400				filename,
401				attachment.Path,
402				sty,
403			)
404			items = append(items, item)
405		}
406
407		return items
408	}
409
410	// Process assistant messages
411	if msg.Role == message.Assistant {
412		// Check if we need to add a section header
413		finishData := msg.FinishPart()
414		if finishData != nil && msg.Model != "" {
415			model := config.Get().GetModel(msg.Provider, msg.Model)
416			modelName := "Unknown Model"
417			if model != nil {
418				modelName = model.Name
419			}
420
421			// Calculate duration (this would need the last user message time)
422			duration := time.Duration(0)
423			if finishData.Time > 0 {
424				duration = time.Duration(finishData.Time-msg.CreatedAt) * time.Second
425			}
426
427			header := NewSectionHeaderItem(
428				fmt.Sprintf("%s-header", msg.ID),
429				modelName,
430				duration,
431				sty,
432			)
433			items = append(items, header)
434		}
435
436		// Add thinking content if present
437		reasoning := msg.ReasoningContent()
438		if strings.TrimSpace(reasoning.Thinking) != "" {
439			duration := time.Duration(0)
440			if reasoning.StartedAt > 0 && reasoning.FinishedAt > 0 {
441				duration = time.Duration(reasoning.FinishedAt-reasoning.StartedAt) * time.Second
442			}
443
444			item := NewThinkingItem(
445				fmt.Sprintf("%s-thinking", msg.ID),
446				reasoning.Thinking,
447				duration,
448				reasoning.FinishedAt > 0,
449				sty,
450			)
451			items = append(items, item)
452		}
453
454		// Add main text content
455		content := msg.Content().String()
456		finished := msg.IsFinished()
457
458		// Handle special finish states
459		if finished && content == "" && finishData != nil {
460			switch finishData.Reason {
461			case message.FinishReasonEndTurn:
462				// No content to show
463			case message.FinishReasonCanceled:
464				item := NewMessageContentItem(
465					fmt.Sprintf("%s-content", msg.ID),
466					"*Canceled*",
467					msg.Role,
468					true,
469					sty,
470				)
471				items = append(items, item)
472			case message.FinishReasonError:
473				// Render error
474				errTag := sty.Chat.Message.ErrorTag.Render("ERROR")
475				truncated := ansi.Truncate(finishData.Message, 100, "...")
476				title := fmt.Sprintf("%s %s", errTag, sty.Chat.Message.ErrorTitle.Render(truncated))
477				details := sty.Chat.Message.ErrorDetails.Render(finishData.Details)
478				errorContent := fmt.Sprintf("%s\n\n%s", title, details)
479
480				item := NewMessageContentItem(
481					fmt.Sprintf("%s-error", msg.ID),
482					errorContent,
483					msg.Role,
484					false,
485					sty,
486				)
487				items = append(items, item)
488			}
489		} else if content != "" {
490			item := NewMessageContentItem(
491				fmt.Sprintf("%s-content", msg.ID),
492				content,
493				msg.Role,
494				true, // Assistant messages are markdown
495				sty,
496			)
497			items = append(items, item)
498		}
499
500		// Add tool calls
501		toolCalls := msg.ToolCalls()
502
503		// Use passed-in tool results map (if nil, use empty map)
504		resultMap := toolResults
505		if resultMap == nil {
506			resultMap = make(map[string]message.ToolResult)
507		}
508
509		for _, tc := range toolCalls {
510			result, hasResult := resultMap[tc.ID]
511			if !hasResult {
512				result = message.ToolResult{}
513			}
514
515			item := NewToolCallItem(
516				fmt.Sprintf("%s-tool-%s", msg.ID, tc.ID),
517				tc,
518				result,
519				false, // cancelled state would need to be tracked separately
520				false, // nested state would be detected from tool results
521				sty,
522			)
523
524			items = append(items, item)
525		}
526
527		return items
528	}
529
530	return items
531}
532
533// BuildToolResultMap creates a map of tool call IDs to their results from a list of messages.
534// Tool result messages (role == message.Tool) contain the results that should be linked
535// to tool calls in assistant messages.
536func BuildToolResultMap(messages []*message.Message) map[string]message.ToolResult {
537	resultMap := make(map[string]message.ToolResult)
538	for _, msg := range messages {
539		if msg.Role == message.Tool {
540			for _, result := range msg.ToolResults() {
541				if result.ToolCallID != "" {
542					resultMap[result.ToolCallID] = result
543				}
544			}
545		}
546	}
547	return resultMap
548}