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