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}