items.go

  1package model
  2
  3import (
  4	"fmt"
  5	"image"
  6	"log/slog"
  7	"path/filepath"
  8	"strings"
  9	"time"
 10
 11	"charm.land/lipgloss/v2"
 12	uv "github.com/charmbracelet/ultraviolet"
 13	"github.com/charmbracelet/x/ansi"
 14
 15	"github.com/charmbracelet/crush/internal/config"
 16	"github.com/charmbracelet/crush/internal/message"
 17	"github.com/charmbracelet/crush/internal/ui/common"
 18	"github.com/charmbracelet/crush/internal/ui/lazylist"
 19	"github.com/charmbracelet/crush/internal/ui/styles"
 20	"github.com/charmbracelet/crush/internal/ui/toolrender"
 21)
 22
 23// Identifiable is an interface for items that can provide a unique identifier.
 24type Identifiable interface {
 25	ID() string
 26}
 27
 28// MessageItem represents a [message.Message] item that can be displayed in the
 29// UI and be part of a [lazylist.List] identifiable by a unique ID.
 30type MessageItem interface {
 31	lazylist.Item
 32	lazylist.Item
 33	Identifiable
 34}
 35
 36// MessageContentItem represents rendered message content (text, markdown, errors, etc).
 37type MessageContentItem struct {
 38	id         string
 39	content    string
 40	role       message.MessageRole
 41	isMarkdown bool
 42	maxWidth   int
 43	sty        *styles.Styles
 44}
 45
 46// NewMessageContentItem creates a new message content item.
 47func NewMessageContentItem(id, content string, role message.MessageRole, isMarkdown bool, sty *styles.Styles) *MessageContentItem {
 48	m := &MessageContentItem{
 49		id:         id,
 50		content:    content,
 51		isMarkdown: isMarkdown,
 52		role:       role,
 53		maxWidth:   120,
 54		sty:        sty,
 55	}
 56	return m
 57}
 58
 59// ID implements Identifiable.
 60func (m *MessageContentItem) ID() string {
 61	return m.id
 62}
 63
 64// FocusStyle returns the focus style.
 65func (m *MessageContentItem) FocusStyle() lipgloss.Style {
 66	if m.role == message.User {
 67		return m.sty.Chat.Message.UserFocused
 68	}
 69	return m.sty.Chat.Message.AssistantFocused
 70}
 71
 72// BlurStyle returns the blur style.
 73func (m *MessageContentItem) BlurStyle() lipgloss.Style {
 74	if m.role == message.User {
 75		return m.sty.Chat.Message.UserBlurred
 76	}
 77	return m.sty.Chat.Message.AssistantBlurred
 78}
 79
 80// HighlightStyle returns the highlight style.
 81func (m *MessageContentItem) HighlightStyle() lipgloss.Style {
 82	return m.sty.TextSelection
 83}
 84
 85// Render renders the content at the given width, using cache if available.
 86//
 87// It implements [lazylist.Item].
 88func (m *MessageContentItem) Render(width int) string {
 89	contentWidth := width
 90	// Cap width to maxWidth for markdown
 91	cappedWidth := contentWidth
 92	if m.isMarkdown {
 93		cappedWidth = min(contentWidth, m.maxWidth)
 94	}
 95
 96	var rendered string
 97	if m.isMarkdown {
 98		renderer := common.MarkdownRenderer(m.sty, cappedWidth)
 99		result, err := renderer.Render(m.content)
100		if err != nil {
101			rendered = m.content
102		} else {
103			rendered = strings.TrimSuffix(result, "\n")
104		}
105	} else {
106		rendered = m.content
107	}
108
109	return rendered
110}
111
112// ToolCallItem represents a rendered tool call with its header and content.
113type ToolCallItem struct {
114	BaseFocusable
115	BaseHighlightable
116	id         string
117	toolCall   message.ToolCall
118	toolResult message.ToolResult
119	cancelled  bool
120	isNested   bool
121	maxWidth   int
122	sty        *styles.Styles
123}
124
125// cachedToolRender stores both the rendered string and its height.
126type cachedToolRender struct {
127	content string
128	height  int
129}
130
131// NewToolCallItem creates a new tool call item.
132func NewToolCallItem(id string, toolCall message.ToolCall, toolResult message.ToolResult, cancelled bool, isNested bool, sty *styles.Styles) *ToolCallItem {
133	t := &ToolCallItem{
134		id:         id,
135		toolCall:   toolCall,
136		toolResult: toolResult,
137		cancelled:  cancelled,
138		isNested:   isNested,
139		maxWidth:   120,
140		sty:        sty,
141	}
142	t.InitHighlight()
143	return t
144}
145
146// generateCacheKey creates a key that changes when tool call content changes.
147func generateCacheKey(toolCall message.ToolCall, toolResult message.ToolResult, cancelled bool) string {
148	// Simple key based on result state - when result arrives or changes, key changes
149	return fmt.Sprintf("%s:%s:%v", toolCall.ID, toolResult.ToolCallID, cancelled)
150}
151
152// ID implements Identifiable.
153func (t *ToolCallItem) ID() string {
154	return t.id
155}
156
157// FocusStyle returns the focus style.
158func (t *ToolCallItem) FocusStyle() lipgloss.Style {
159	if t.focusStyle != nil {
160		return *t.focusStyle
161	}
162	return lipgloss.Style{}
163}
164
165// BlurStyle returns the blur style.
166func (t *ToolCallItem) BlurStyle() lipgloss.Style {
167	if t.blurStyle != nil {
168		return *t.blurStyle
169	}
170	return lipgloss.Style{}
171}
172
173// HighlightStyle returns the highlight style.
174func (t *ToolCallItem) HighlightStyle() lipgloss.Style {
175	return t.sty.TextSelection
176}
177
178// Render implements lazylist.Item.
179func (t *ToolCallItem) Render(width int) string {
180	// Render the tool call
181	ctx := &toolrender.RenderContext{
182		Call:      t.toolCall,
183		Result:    t.toolResult,
184		Cancelled: t.cancelled,
185		IsNested:  t.isNested,
186		Width:     width,
187		Styles:    t.sty,
188	}
189
190	rendered := toolrender.Render(ctx)
191	return rendered
192
193	// return t.RenderWithHighlight(rendered, width, style)
194}
195
196// SetHighlight implements list.Highlightable.
197func (t *ToolCallItem) SetHighlight(startLine, startCol, endLine, endCol int) {
198	t.BaseHighlightable.SetHighlight(startLine, startCol, endLine, endCol)
199}
200
201// UpdateResult updates the tool result and invalidates the cache if needed.
202func (t *ToolCallItem) UpdateResult(result message.ToolResult) {
203	t.toolResult = result
204}
205
206// AttachmentItem represents a file attachment in a user message.
207type AttachmentItem struct {
208	BaseFocusable
209	BaseHighlightable
210	id       string
211	filename string
212	path     string
213	sty      *styles.Styles
214}
215
216// NewAttachmentItem creates a new attachment item.
217func NewAttachmentItem(id, filename, path string, sty *styles.Styles) *AttachmentItem {
218	a := &AttachmentItem{
219		id:       id,
220		filename: filename,
221		path:     path,
222		sty:      sty,
223	}
224	a.InitHighlight()
225	return a
226}
227
228// ID implements Identifiable.
229func (a *AttachmentItem) ID() string {
230	return a.id
231}
232
233// FocusStyle returns the focus style.
234func (a *AttachmentItem) FocusStyle() lipgloss.Style {
235	if a.focusStyle != nil {
236		return *a.focusStyle
237	}
238	return lipgloss.Style{}
239}
240
241// BlurStyle returns the blur style.
242func (a *AttachmentItem) BlurStyle() lipgloss.Style {
243	if a.blurStyle != nil {
244		return *a.blurStyle
245	}
246	return lipgloss.Style{}
247}
248
249// HighlightStyle returns the highlight style.
250func (a *AttachmentItem) HighlightStyle() lipgloss.Style {
251	return a.sty.TextSelection
252}
253
254// Render implements lazylist.Item.
255func (a *AttachmentItem) Render(width int) string {
256	const maxFilenameWidth = 10
257	content := a.sty.Chat.Message.Attachment.Render(fmt.Sprintf(
258		" %s %s ",
259		styles.DocumentIcon,
260		ansi.Truncate(a.filename, maxFilenameWidth, "..."),
261	))
262
263	return content
264
265	// return a.RenderWithHighlight(content, width, a.CurrentStyle())
266}
267
268// ThinkingItem represents thinking/reasoning content in assistant messages.
269type ThinkingItem struct {
270	id       string
271	thinking string
272	duration time.Duration
273	finished bool
274	maxWidth int
275	sty      *styles.Styles
276}
277
278// NewThinkingItem creates a new thinking item.
279func NewThinkingItem(id, thinking string, duration time.Duration, finished bool, sty *styles.Styles) *ThinkingItem {
280	t := &ThinkingItem{
281		id:       id,
282		thinking: thinking,
283		duration: duration,
284		finished: finished,
285		maxWidth: 120,
286		sty:      sty,
287	}
288	return t
289}
290
291// ID implements Identifiable.
292func (t *ThinkingItem) ID() string {
293	return t.id
294}
295
296// FocusStyle returns the focus style.
297func (t *ThinkingItem) FocusStyle() lipgloss.Style {
298	return t.sty.Chat.Message.AssistantFocused
299}
300
301// BlurStyle returns the blur style.
302func (t *ThinkingItem) BlurStyle() lipgloss.Style {
303	return t.sty.Chat.Message.AssistantBlurred
304}
305
306// HighlightStyle returns the highlight style.
307func (t *ThinkingItem) HighlightStyle() lipgloss.Style {
308	return t.sty.TextSelection
309}
310
311// Render implements lazylist.Item.
312func (t *ThinkingItem) Render(width int) string {
313	cappedWidth := min(width, t.maxWidth)
314
315	renderer := common.PlainMarkdownRenderer(cappedWidth - 1)
316	rendered, err := renderer.Render(t.thinking)
317	if err != nil {
318		// Fallback to line-by-line rendering
319		lines := strings.Split(t.thinking, "\n")
320		var content strings.Builder
321		lineStyle := t.sty.PanelMuted
322		for i, line := range lines {
323			if line == "" {
324				continue
325			}
326			content.WriteString(lineStyle.Width(cappedWidth).Render(line))
327			if i < len(lines)-1 {
328				content.WriteString("\n")
329			}
330		}
331		rendered = content.String()
332	}
333
334	fullContent := strings.TrimSpace(rendered)
335
336	// Add footer if finished
337	if t.finished && t.duration > 0 {
338		footer := t.sty.Chat.Message.ThinkingFooter.Render(fmt.Sprintf("Thought for %s", t.duration.String()))
339		fullContent = lipgloss.JoinVertical(lipgloss.Left, fullContent, "", footer)
340	}
341
342	result := t.sty.PanelMuted.Width(cappedWidth).Padding(0, 1).Render(fullContent)
343
344	return result
345}
346
347// SectionHeaderItem represents a section header (e.g., assistant info).
348type SectionHeaderItem struct {
349	id              string
350	modelName       string
351	duration        time.Duration
352	isSectionHeader bool
353	sty             *styles.Styles
354}
355
356// NewSectionHeaderItem creates a new section header item.
357func NewSectionHeaderItem(id, modelName string, duration time.Duration, sty *styles.Styles) *SectionHeaderItem {
358	s := &SectionHeaderItem{
359		id:              id,
360		modelName:       modelName,
361		duration:        duration,
362		isSectionHeader: true,
363		sty:             sty,
364	}
365	return s
366}
367
368// ID implements Identifiable.
369func (s *SectionHeaderItem) ID() string {
370	return s.id
371}
372
373// IsSectionHeader returns true if this is a section header.
374func (s *SectionHeaderItem) IsSectionHeader() bool {
375	return s.isSectionHeader
376}
377
378// FocusStyle returns the focus style.
379func (s *SectionHeaderItem) FocusStyle() lipgloss.Style {
380	return s.sty.Chat.Message.AssistantFocused
381}
382
383// BlurStyle returns the blur style.
384func (s *SectionHeaderItem) BlurStyle() lipgloss.Style {
385	return s.sty.Chat.Message.AssistantBlurred
386}
387
388// Render implements lazylist.Item.
389func (s *SectionHeaderItem) Render(width int) string {
390	content := s.sty.Chat.Message.SectionHeader.Render(fmt.Sprintf("%s %s %s",
391		s.sty.Subtle.Render(styles.ModelIcon),
392		s.sty.Muted.Render(s.modelName),
393		s.sty.Subtle.Render(s.duration.String()),
394	))
395
396	return content
397}
398
399// GetMessageItems extracts [MessageItem]s from a [message.Message]. It returns
400// all parts of the message as [MessageItem]s.
401//
402// For assistant messages with tool calls, pass a toolResults map to link results.
403// Use BuildToolResultMap to create this map from all messages in a session.
404func GetMessageItems(sty *styles.Styles, msg *message.Message, toolResults map[string]message.ToolResult) []MessageItem {
405	var items []MessageItem
406
407	// Skip tool result messages - they're displayed inline with tool calls
408	if msg.Role == message.Tool {
409		return items
410	}
411
412	// Create base styles for the message
413	var focusStyle, blurStyle lipgloss.Style
414	if msg.Role == message.User {
415		focusStyle = sty.Chat.Message.UserFocused
416		blurStyle = sty.Chat.Message.UserBlurred
417	} else {
418		focusStyle = sty.Chat.Message.AssistantFocused
419		blurStyle = sty.Chat.Message.AssistantBlurred
420	}
421
422	// Process user messages
423	if msg.Role == message.User {
424		// Add main text content
425		content := msg.Content().String()
426		if content != "" {
427			item := NewMessageContentItem(
428				fmt.Sprintf("%s-content", msg.ID),
429				content,
430				msg.Role,
431				true, // User messages are markdown
432				sty,
433			)
434			items = append(items, item)
435		}
436
437		// Add attachments
438		for i, attachment := range msg.BinaryContent() {
439			filename := filepath.Base(attachment.Path)
440			item := NewAttachmentItem(
441				fmt.Sprintf("%s-attachment-%d", msg.ID, i),
442				filename,
443				attachment.Path,
444				sty,
445			)
446			item.SetHighlightStyle(ToStyler(sty.TextSelection))
447			item.SetFocusStyles(&focusStyle, &blurStyle)
448			items = append(items, item)
449		}
450
451		return items
452	}
453
454	// Process assistant messages
455	if msg.Role == message.Assistant {
456		// Check if we need to add a section header
457		finishData := msg.FinishPart()
458		if finishData != nil && msg.Model != "" {
459			model := config.Get().GetModel(msg.Provider, msg.Model)
460			modelName := "Unknown Model"
461			if model != nil {
462				modelName = model.Name
463			}
464
465			// Calculate duration (this would need the last user message time)
466			duration := time.Duration(0)
467			if finishData.Time > 0 {
468				duration = time.Duration(finishData.Time-msg.CreatedAt) * time.Second
469			}
470
471			header := NewSectionHeaderItem(
472				fmt.Sprintf("%s-header", msg.ID),
473				modelName,
474				duration,
475				sty,
476			)
477			items = append(items, header)
478		}
479
480		// Add thinking content if present
481		reasoning := msg.ReasoningContent()
482		if strings.TrimSpace(reasoning.Thinking) != "" {
483			duration := time.Duration(0)
484			if reasoning.StartedAt > 0 && reasoning.FinishedAt > 0 {
485				duration = time.Duration(reasoning.FinishedAt-reasoning.StartedAt) * time.Second
486			}
487
488			item := NewThinkingItem(
489				fmt.Sprintf("%s-thinking", msg.ID),
490				reasoning.Thinking,
491				duration,
492				reasoning.FinishedAt > 0,
493				sty,
494			)
495			items = append(items, item)
496		}
497
498		// Add main text content
499		content := msg.Content().String()
500		finished := msg.IsFinished()
501
502		// Handle special finish states
503		if finished && content == "" && finishData != nil {
504			switch finishData.Reason {
505			case message.FinishReasonEndTurn:
506				// No content to show
507			case message.FinishReasonCanceled:
508				item := NewMessageContentItem(
509					fmt.Sprintf("%s-content", msg.ID),
510					"*Canceled*",
511					msg.Role,
512					true,
513					sty,
514				)
515				items = append(items, item)
516			case message.FinishReasonError:
517				// Render error
518				errTag := sty.Chat.Message.ErrorTag.Render("ERROR")
519				truncated := ansi.Truncate(finishData.Message, 100, "...")
520				title := fmt.Sprintf("%s %s", errTag, sty.Chat.Message.ErrorTitle.Render(truncated))
521				details := sty.Chat.Message.ErrorDetails.Render(finishData.Details)
522				errorContent := fmt.Sprintf("%s\n\n%s", title, details)
523
524				item := NewMessageContentItem(
525					fmt.Sprintf("%s-error", msg.ID),
526					errorContent,
527					msg.Role,
528					false,
529					sty,
530				)
531				items = append(items, item)
532			}
533		} else if content != "" {
534			item := NewMessageContentItem(
535				fmt.Sprintf("%s-content", msg.ID),
536				content,
537				msg.Role,
538				true, // Assistant messages are markdown
539				sty,
540			)
541			items = append(items, item)
542		}
543
544		// Add tool calls
545		toolCalls := msg.ToolCalls()
546
547		// Use passed-in tool results map (if nil, use empty map)
548		resultMap := toolResults
549		if resultMap == nil {
550			resultMap = make(map[string]message.ToolResult)
551		}
552
553		for _, tc := range toolCalls {
554			result, hasResult := resultMap[tc.ID]
555			if !hasResult {
556				result = message.ToolResult{}
557			}
558
559			item := NewToolCallItem(
560				fmt.Sprintf("%s-tool-%s", msg.ID, tc.ID),
561				tc,
562				result,
563				false, // cancelled state would need to be tracked separately
564				false, // nested state would be detected from tool results
565				sty,
566			)
567
568			item.SetHighlightStyle(ToStyler(sty.TextSelection))
569
570			// Tool calls use muted style with optional focus border
571			item.SetFocusStyles(&sty.Chat.Message.ToolCallFocused, &sty.Chat.Message.ToolCallBlurred)
572
573			items = append(items, item)
574		}
575
576		return items
577	}
578
579	return items
580}
581
582// BuildToolResultMap creates a map of tool call IDs to their results from a list of messages.
583// Tool result messages (role == message.Tool) contain the results that should be linked
584// to tool calls in assistant messages.
585func BuildToolResultMap(messages []*message.Message) map[string]message.ToolResult {
586	resultMap := make(map[string]message.ToolResult)
587	for _, msg := range messages {
588		if msg.Role == message.Tool {
589			for _, result := range msg.ToolResults() {
590				if result.ToolCallID != "" {
591					resultMap[result.ToolCallID] = result
592				}
593			}
594		}
595	}
596	return resultMap
597}
598
599// BaseFocusable provides common focus state and styling for items.
600// Embed this type to add focus behavior to any item.
601type BaseFocusable struct {
602	focused    bool
603	focusStyle *lipgloss.Style
604	blurStyle  *lipgloss.Style
605}
606
607// Focus implements Focusable interface.
608func (b *BaseFocusable) Focus(width int, content string) string {
609	if b.focusStyle != nil {
610		return b.focusStyle.Render(content)
611	}
612	return content
613}
614
615// Blur implements Focusable interface.
616func (b *BaseFocusable) Blur(width int, content string) string {
617	if b.blurStyle != nil {
618		return b.blurStyle.Render(content)
619	}
620	return content
621}
622
623// Focus implements Focusable interface.
624// func (b *BaseFocusable) Focus() {
625// 	b.focused = true
626// }
627
628// Blur implements Focusable interface.
629// func (b *BaseFocusable) Blur() {
630// 	b.focused = false
631// }
632
633// Focused implements Focusable interface.
634func (b *BaseFocusable) Focused() bool {
635	return b.focused
636}
637
638// HasFocusStyles returns true if both focus and blur styles are configured.
639func (b *BaseFocusable) HasFocusStyles() bool {
640	return b.focusStyle != nil && b.blurStyle != nil
641}
642
643// CurrentStyle returns the current style based on focus state.
644// Returns nil if no styles are configured, or if the current state's style is nil.
645func (b *BaseFocusable) CurrentStyle() *lipgloss.Style {
646	if b.focused {
647		return b.focusStyle
648	}
649	return b.blurStyle
650}
651
652// SetFocusStyles sets the focus and blur styles.
653func (b *BaseFocusable) SetFocusStyles(focusStyle, blurStyle *lipgloss.Style) {
654	b.focusStyle = focusStyle
655	b.blurStyle = blurStyle
656}
657
658// CellStyler defines a function that styles a [uv.Style].
659type CellStyler func(uv.Style) uv.Style
660
661// BaseHighlightable provides common highlight state for items.
662// Embed this type to add highlight behavior to any item.
663type BaseHighlightable struct {
664	highlightStartLine int
665	highlightStartCol  int
666	highlightEndLine   int
667	highlightEndCol    int
668	highlightStyle     CellStyler
669}
670
671// SetHighlight implements Highlightable interface.
672func (b *BaseHighlightable) SetHighlight(startLine, startCol, endLine, endCol int) {
673	b.highlightStartLine = startLine
674	b.highlightStartCol = startCol
675	b.highlightEndLine = endLine
676	b.highlightEndCol = endCol
677}
678
679// GetHighlight implements Highlightable interface.
680func (b *BaseHighlightable) GetHighlight() (startLine, startCol, endLine, endCol int) {
681	return b.highlightStartLine, b.highlightStartCol, b.highlightEndLine, b.highlightEndCol
682}
683
684// HasHighlight returns true if a highlight region is set.
685func (b *BaseHighlightable) HasHighlight() bool {
686	return b.highlightStartLine >= 0 || b.highlightStartCol >= 0 ||
687		b.highlightEndLine >= 0 || b.highlightEndCol >= 0
688}
689
690// SetHighlightStyle sets the style function used for highlighting.
691func (b *BaseHighlightable) SetHighlightStyle(style CellStyler) {
692	b.highlightStyle = style
693}
694
695// GetHighlightStyle returns the current highlight style function.
696func (b *BaseHighlightable) GetHighlightStyle() CellStyler {
697	return b.highlightStyle
698}
699
700// InitHighlight initializes the highlight fields with default values.
701func (b *BaseHighlightable) InitHighlight() {
702	b.highlightStartLine = -1
703	b.highlightStartCol = -1
704	b.highlightEndLine = -1
705	b.highlightEndCol = -1
706	b.highlightStyle = ToStyler(lipgloss.NewStyle().Reverse(true))
707}
708
709// Highlight implements Highlightable interface.
710func (b *BaseHighlightable) Highlight(width int, content string, startLine, startCol, endLine, endCol int) string {
711	b.SetHighlight(startLine, startCol, endLine, endCol)
712	return b.RenderWithHighlight(content, width, nil)
713}
714
715// RenderWithHighlight renders content with optional focus styling and highlighting.
716// This is a helper that combines common rendering logic for all items.
717// The content parameter should be the raw rendered content before focus styling.
718// The style parameter should come from CurrentStyle() and may be nil.
719func (b *BaseHighlightable) RenderWithHighlight(content string, width int, style *lipgloss.Style) string {
720	// Apply focus/blur styling if configured
721	rendered := content
722	if style != nil {
723		rendered = style.Render(rendered)
724	}
725
726	if !b.HasHighlight() {
727		return rendered
728	}
729
730	height := lipgloss.Height(rendered)
731
732	// Create temp buffer to draw content with highlighting
733	tempBuf := uv.NewScreenBuffer(width, height)
734
735	// Draw the rendered content to temp buffer
736	styled := uv.NewStyledString(rendered)
737	styled.Draw(&tempBuf, uv.Rect(0, 0, width, height))
738
739	// Apply highlighting if active
740	b.ApplyHighlight(&tempBuf, width, height, style)
741
742	return tempBuf.Render()
743}
744
745// ApplyHighlight applies highlighting to a screen buffer.
746// This should be called after drawing content to the buffer.
747func (b *BaseHighlightable) ApplyHighlight(buf *uv.ScreenBuffer, width, height int, style *lipgloss.Style) {
748	if b.highlightStartLine < 0 {
749		return
750	}
751
752	var (
753		topMargin, topBorder, topPadding          int
754		rightMargin, rightBorder, rightPadding    int
755		bottomMargin, bottomBorder, bottomPadding int
756		leftMargin, leftBorder, leftPadding       int
757	)
758	if style != nil {
759		topMargin, rightMargin, bottomMargin, leftMargin = style.GetMargin()
760		topBorder, rightBorder, bottomBorder, leftBorder = style.GetBorderTopSize(),
761			style.GetBorderRightSize(),
762			style.GetBorderBottomSize(),
763			style.GetBorderLeftSize()
764		topPadding, rightPadding, bottomPadding, leftPadding = style.GetPadding()
765	}
766
767	slog.Info("Applying highlight",
768		"highlightStartLine", b.highlightStartLine,
769		"highlightStartCol", b.highlightStartCol,
770		"highlightEndLine", b.highlightEndLine,
771		"highlightEndCol", b.highlightEndCol,
772		"width", width,
773		"height", height,
774		"margins", fmt.Sprintf("%d,%d,%d,%d", topMargin, rightMargin, bottomMargin, leftMargin),
775		"borders", fmt.Sprintf("%d,%d,%d,%d", topBorder, rightBorder, bottomBorder, leftBorder),
776		"paddings", fmt.Sprintf("%d,%d,%d,%d", topPadding, rightPadding, bottomPadding, leftPadding),
777	)
778
779	// Calculate content area offsets
780	contentArea := image.Rectangle{
781		Min: image.Point{
782			X: leftMargin + leftBorder + leftPadding,
783			Y: topMargin + topBorder + topPadding,
784		},
785		Max: image.Point{
786			X: width - (rightMargin + rightBorder + rightPadding),
787			Y: height - (bottomMargin + bottomBorder + bottomPadding),
788		},
789	}
790
791	for y := b.highlightStartLine; y <= b.highlightEndLine && y < height; y++ {
792		if y >= buf.Height() {
793			break
794		}
795
796		line := buf.Line(y)
797
798		// Determine column range for this line
799		startCol := 0
800		if y == b.highlightStartLine {
801			startCol = min(b.highlightStartCol, len(line))
802		}
803
804		endCol := len(line)
805		if y == b.highlightEndLine {
806			endCol = min(b.highlightEndCol, len(line))
807		}
808
809		// Track last non-empty position as we go
810		lastContentX := -1
811
812		// Single pass: check content and track last non-empty position
813		for x := startCol; x < endCol; x++ {
814			cell := line.At(x)
815			if cell == nil {
816				continue
817			}
818
819			// Update last content position if non-empty
820			if cell.Content != "" && cell.Content != " " {
821				lastContentX = x
822			}
823		}
824
825		// Only apply highlight up to last content position
826		highlightEnd := endCol
827		if lastContentX >= 0 {
828			highlightEnd = lastContentX + 1
829		} else if lastContentX == -1 {
830			highlightEnd = startCol // No content on this line
831		}
832
833		// Apply highlight style only to cells with content
834		for x := startCol; x < highlightEnd; x++ {
835			if !image.Pt(x, y).In(contentArea) {
836				continue
837			}
838			cell := line.At(x)
839			cell.Style = b.highlightStyle(cell.Style)
840		}
841	}
842}
843
844// ToStyler converts a [lipgloss.Style] to a [CellStyler].
845func ToStyler(lgStyle lipgloss.Style) CellStyler {
846	return func(uv.Style) uv.Style {
847		return ToStyle(lgStyle)
848	}
849}
850
851// ToStyle converts an inline [lipgloss.Style] to a [uv.Style].
852func ToStyle(lgStyle lipgloss.Style) uv.Style {
853	var uvStyle uv.Style
854
855	// Colors are already color.Color
856	uvStyle.Fg = lgStyle.GetForeground()
857	uvStyle.Bg = lgStyle.GetBackground()
858
859	// Build attributes using bitwise OR
860	var attrs uint8
861
862	if lgStyle.GetBold() {
863		attrs |= uv.AttrBold
864	}
865
866	if lgStyle.GetItalic() {
867		attrs |= uv.AttrItalic
868	}
869
870	if lgStyle.GetUnderline() {
871		uvStyle.Underline = uv.UnderlineSingle
872	}
873
874	if lgStyle.GetStrikethrough() {
875		attrs |= uv.AttrStrikethrough
876	}
877
878	if lgStyle.GetFaint() {
879		attrs |= uv.AttrFaint
880	}
881
882	if lgStyle.GetBlink() {
883		attrs |= uv.AttrBlink
884	}
885
886	if lgStyle.GetReverse() {
887		attrs |= uv.AttrReverse
888	}
889
890	uvStyle.Attrs = attrs
891
892	return uvStyle
893}