@@ -2,6 +2,8 @@ package model
22
33import (
44 "fmt"
5+ "image"
6+ "log/slog"
57 "path/filepath"
68 "strings"
79 "time"
@@ -14,7 +16,6 @@ import (
1416 "github.com/charmbracelet/crush/internal/message"
1517 "github.com/charmbracelet/crush/internal/ui/common"
1618 "github.com/charmbracelet/crush/internal/ui/lazylist"
17− "github.com/charmbracelet/crush/internal/ui/list"
1819 "github.com/charmbracelet/crush/internal/ui/styles"
1920 "github.com/charmbracelet/crush/internal/ui/toolrender"
2021)
@@ -25,38 +26,33 @@ type Identifiable interface {
2526}
2627
2728// 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.
29+// UI and be part of a [lazylist.List] identifiable by a unique ID.
2930type MessageItem interface {
30− list.Item
31− list.Focusable
32− list.Highlightable
31+ lazylist.Item
3332 lazylist.Item
3433 Identifiable
3534}
3635
3736// MessageContentItem represents rendered message content (text, markdown, errors, etc).
3837type MessageContentItem struct {
39− list.BaseFocusable
40− list.BaseHighlightable
4138 id string
4239 content string
40+ role message.MessageRole
4341 isMarkdown bool
4442 maxWidth int
45− cache map[int]string // Cache for rendered content at different widths
4643 sty *styles.Styles
4744}
4845
4946// NewMessageContentItem creates a new message content item.
50−func NewMessageContentItem(id, content string, isMarkdown bool, sty *styles.Styles) *MessageContentItem {
47+func NewMessageContentItem(id, content string, role message.MessageRole, isMarkdown bool, sty *styles.Styles) *MessageContentItem {
5148 m := &MessageContentItem{
5249 id: id,
5350 content: content,
5451 isMarkdown: isMarkdown,
52+ role: role,
5553 maxWidth: 120,
56− cache: make(map[int]string),
5754 sty: sty,
5855 }
59− m.InitHighlight()
6056 return m
6157}
6258
@@ -65,76 +61,38 @@ func (m *MessageContentItem) ID() string {
6561 return m.id
6662}
6763
68−// Height implements list.Item.
69−func (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)
64+// FocusStyle returns the focus style.
65+func (m *MessageContentItem) FocusStyle() lipgloss.Style {
66+ if m.role == message.User {
67+ return m.sty.Chat.Message.UserFocused
8168 }
82−
83− return strings.Count(rendered, "\n") + 1
69+ return m.sty.Chat.Message.AssistantFocused
8470}
8571
86−// Draw implements list.Item.
87−func (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)
72+// BlurStyle returns the blur style.
73+func (m *MessageContentItem) BlurStyle() lipgloss.Style {
74+ if m.role == message.User {
75+ return m.sty.Chat.Message.UserBlurred
10376 }
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)
77+ return m.sty.Chat.Message.AssistantBlurred
11778}
11879
119−// Render implements lazylist.Item.
120−func (m *MessageContentItem) Render(width int) string {
121− return m.render(width)
80+// HighlightStyle returns the highlight style.
81+func (m *MessageContentItem) HighlightStyle() lipgloss.Style {
82+ return m.sty.TextSelection
12283}
12384
124−// render renders the content at the given width, using cache if available.
125−func (m *MessageContentItem) render(width int) string {
85+// Render renders the content at the given width, using cache if available.
86+//
87+// It implements [lazylist.Item].
88+func (m *MessageContentItem) Render(width int) string {
89+ contentWidth := width
12690 // Cap width to maxWidth for markdown
127− cappedWidth := width
91+ cappedWidth := contentWidth
12892 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
93+ cappedWidth = min(contentWidth, m.maxWidth)
13594 }
13695
137− // Not cached - render now
13896 var rendered string
13997 if m.isMarkdown {
14098 renderer := common.MarkdownRenderer(m.sty, cappedWidth)
@@ -148,30 +106,19 @@ func (m *MessageContentItem) render(width int) string {
148106 rendered = m.content
149107 }
150108
151− // Cache the result
152− m.cache[cappedWidth] = rendered
153109 return rendered
154110}
155111
156−// SetHighlight implements list.Highlightable and extends BaseHighlightable.
157−func (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−
163112// ToolCallItem represents a rendered tool call with its header and content.
164113type ToolCallItem struct {
165− list.BaseFocusable
166− list.BaseHighlightable
114+ BaseFocusable
115+ BaseHighlightable
167116 id string
168117 toolCall message.ToolCall
169118 toolResult message.ToolResult
170119 cancelled bool
171120 isNested bool
172121 maxWidth int
173− cache map[int]cachedToolRender // Cache for rendered content at different widths
174− cacheKey string // Key to invalidate cache when content changes
175122 sty *styles.Styles
176123}
177124
@@ -190,8 +137,6 @@ func NewToolCallItem(id string, toolCall message.ToolCall, toolResult message.To
190137 cancelled: cancelled,
191138 isNested: isNested,
192139 maxWidth: 120,
193− cache: make(map[int]cachedToolRender),
194− cacheKey: generateCacheKey(toolCall, toolResult, cancelled),
195140 sty: sty,
196141 }
197142 t.InitHighlight()
@@ -209,116 +154,59 @@ func (t *ToolCallItem) ID() string {
209154 return t.id
210155}
211156
212−// Height implements list.Item.
213−func (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− }
157+// FocusStyle returns the focus style.
158+func (t *ToolCallItem) FocusStyle() lipgloss.Style {
159+ if t.focusStyle != nil {
160+ return *t.focusStyle
233161 }
234−
235− return height
162+ return lipgloss.Style{}
236163}
237164
238−// Render implements lazylist.Item.
239−func (t *ToolCallItem) Render(width int) string {
240− cached := t.renderCached(width)
241− return cached.content
242−}
243−
244−// Draw implements list.Item.
245−func (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()
165+// BlurStyle returns the blur style.
166+func (t *ToolCallItem) BlurStyle() lipgloss.Style {
167+ if t.blurStyle != nil {
168+ return *t.blurStyle
254169 }
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)
170+ return lipgloss.Style{}
269171}
270172
271−// renderCached renders the tool call at the given width with caching.
272−func (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− }
173+// HighlightStyle returns the highlight style.
174+func (t *ToolCallItem) HighlightStyle() lipgloss.Style {
175+ return t.sty.TextSelection
176+}
279177
178+// Render implements lazylist.Item.
179+func (t *ToolCallItem) Render(width int) string {
280180 // Render the tool call
281181 ctx := &toolrender.RenderContext{
282182 Call: t.toolCall,
283183 Result: t.toolResult,
284184 Cancelled: t.cancelled,
285185 IsNested: t.isNested,
286− Width: cappedWidth,
186+ Width: width,
287187 Styles: t.sty,
288188 }
289189
290190 rendered := toolrender.Render(ctx)
291− height := strings.Count(rendered, "\n") + 1
191+ return rendered
292192
293− cached := cachedToolRender{
294− content: rendered,
295− height: height,
296− }
297− t.cache[cappedWidth] = cached
298− return cached
193+ // return t.RenderWithHighlight(rendered, width, style)
299194}
300195
301196// SetHighlight implements list.Highlightable.
302197func (t *ToolCallItem) SetHighlight(startLine, startCol, endLine, endCol int) {
303198 t.BaseHighlightable.SetHighlight(startLine, startCol, endLine, endCol)
304− // Clear cache when highlight changes
305− t.cache = make(map[int]cachedToolRender)
306199}
307200
308201// UpdateResult updates the tool result and invalidates the cache if needed.
309202func (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− }
203+ t.toolResult = result
316204}
317205
318206// AttachmentItem represents a file attachment in a user message.
319207type AttachmentItem struct {
320− list.BaseFocusable
321− list.BaseHighlightable
208+ BaseFocusable
209+ BaseHighlightable
322210 id string
323211 filename string
324212 path string
@@ -342,33 +230,29 @@ func (a *AttachmentItem) ID() string {
342230 return a.id
343231}
344232
345−// Height implements list.Item.
346−func (a *AttachmentItem) Height(width int) int {
347− return 1
233+// FocusStyle returns the focus style.
234+func (a *AttachmentItem) FocusStyle() lipgloss.Style {
235+ if a.focusStyle != nil {
236+ return *a.focusStyle
237+ }
238+ return lipgloss.Style{}
348239}
349240
350−// Render implements lazylist.Item.
351−func (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− ))
241+// BlurStyle returns the blur style.
242+func (a *AttachmentItem) BlurStyle() lipgloss.Style {
243+ if a.blurStyle != nil {
244+ return *a.blurStyle
245+ }
246+ return lipgloss.Style{}
358247}
359248
360−// Draw implements list.Item.
361−func (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− }
249+// HighlightStyle returns the highlight style.
250+func (a *AttachmentItem) HighlightStyle() lipgloss.Style {
251+ return a.sty.TextSelection
252+}
371253
254+// Render implements lazylist.Item.
255+func (a *AttachmentItem) Render(width int) string {
372256 const maxFilenameWidth = 10
373257 content := a.sty.Chat.Message.Attachment.Render(fmt.Sprintf(
374258 " %s %s ",
@@ -376,28 +260,18 @@ func (a *AttachmentItem) Draw(scr uv.Screen, area uv.Rectangle) {
376260 ansi.Truncate(a.filename, maxFilenameWidth, "..."),
377261 ))
378262
379− if style != nil {
380− content = style.Render(content)
381− }
263+ return content
382264
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)
265+ // return a.RenderWithHighlight(content, width, a.CurrentStyle())
389266}
390267
391268// ThinkingItem represents thinking/reasoning content in assistant messages.
392269type ThinkingItem struct {
393− list.BaseFocusable
394− list.BaseHighlightable
395270 id string
396271 thinking string
397272 duration time.Duration
398273 finished bool
399274 maxWidth int
400− cache map[int]string
401275 sty *styles.Styles
402276}
403277
@@ -409,10 +283,8 @@ func NewThinkingItem(id, thinking string, duration time.Duration, finished bool,
409283 duration: duration,
410284 finished: finished,
411285 maxWidth: 120,
412− cache: make(map[int]string),
413286 sty: sty,
414287 }
415− t.InitHighlight()
416288 return t
417289}
418290
@@ -421,57 +293,25 @@ func (t *ThinkingItem) ID() string {
421293 return t.id
422294}
423295
424−// Height implements list.Item.
425−func (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
296+// FocusStyle returns the focus style.
297+func (t *ThinkingItem) FocusStyle() lipgloss.Style {
298+ return t.sty.Chat.Message.AssistantFocused
434299}
435300
436−// Render implements lazylist.Item.
437−func (t *ThinkingItem) Render(width int) string {
438− return t.render(width)
301+// BlurStyle returns the blur style.
302+func (t *ThinkingItem) BlurStyle() lipgloss.Style {
303+ return t.sty.Chat.Message.AssistantBlurred
439304}
440305
441−// Draw implements list.Item.
442−func (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)
306+// HighlightStyle returns the highlight style.
307+func (t *ThinkingItem) HighlightStyle() lipgloss.Style {
308+ return t.sty.TextSelection
465309}
466310
467−// render renders the thinking content.
468−func (t *ThinkingItem) render(width int) string {
311+// Render implements lazylist.Item.
312+func (t *ThinkingItem) Render(width int) string {
469313 cappedWidth := min(width, t.maxWidth)
470314
471− if cached, ok := t.cache[cappedWidth]; ok {
472− return cached
473− }
474−
475315 renderer := common.PlainMarkdownRenderer(cappedWidth - 1)
476316 rendered, err := renderer.Render(t.thinking)
477317 if err != nil {
@@ -501,20 +341,11 @@ func (t *ThinkingItem) render(width int) string {
501341
502342 result := t.sty.PanelMuted.Width(cappedWidth).Padding(0, 1).Render(fullContent)
503343
504− t.cache[cappedWidth] = result
505344 return result
506345}
507346
508−// SetHighlight implements list.Highlightable.
509−func (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−
514347// SectionHeaderItem represents a section header (e.g., assistant info).
515348type SectionHeaderItem struct {
516− list.BaseFocusable
517− list.BaseHighlightable
518349 id string
519350 modelName string
520351 duration time.Duration
@@ -531,7 +362,6 @@ func NewSectionHeaderItem(id, modelName string, duration time.Duration, sty *sty
531362 isSectionHeader: true,
532363 sty: sty,
533364 }
534− s.InitHighlight()
535365 return s
536366}
537367
@@ -545,49 +375,25 @@ func (s *SectionHeaderItem) IsSectionHeader() bool {
545375 return s.isSectionHeader
546376}
547377
548−// Height implements list.Item.
549−func (s *SectionHeaderItem) Height(width int) int {
550− return 1
378+// FocusStyle returns the focus style.
379+func (s *SectionHeaderItem) FocusStyle() lipgloss.Style {
380+ return s.sty.Chat.Message.AssistantFocused
381+}
382+
383+// BlurStyle returns the blur style.
384+func (s *SectionHeaderItem) BlurStyle() lipgloss.Style {
385+ return s.sty.Chat.Message.AssistantBlurred
551386}
552387
553388// Render implements lazylist.Item.
554389func (s *SectionHeaderItem) Render(width int) string {
555− return s.sty.Chat.Message.SectionHeader.Render(fmt.Sprintf("%s %s %s",
390+ content := s.sty.Chat.Message.SectionHeader.Render(fmt.Sprintf("%s %s %s",
556391 s.sty.Subtle.Render(styles.ModelIcon),
557392 s.sty.Muted.Render(s.modelName),
558393 s.sty.Subtle.Render(s.duration.String()),
559394 ))
560−}
561−
562−// Draw implements list.Item.
563−func (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− }
584395
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)
396+ return content
591397}
592398
593399// GetMessageItems extracts [MessageItem]s from a [message.Message]. It returns
@@ -595,8 +401,7 @@ func (s *SectionHeaderItem) Draw(scr uv.Screen, area uv.Rectangle) {
595401//
596402// For assistant messages with tool calls, pass a toolResults map to link results.
597403// Use BuildToolResultMap to create this map from all messages in a session.
598−func GetMessageItems(msg *message.Message, toolResults map[string]message.ToolResult) []MessageItem {
599− sty := styles.DefaultStyles()
404+func GetMessageItems(sty *styles.Styles, msg *message.Message, toolResults map[string]message.ToolResult) []MessageItem {
600405 var items []MessageItem
601406
602407 // Skip tool result messages - they're displayed inline with tool calls
@@ -622,10 +427,10 @@ func GetMessageItems(msg *message.Message, toolResults map[string]message.ToolRe
622427 item := NewMessageContentItem(
623428 fmt.Sprintf("%s-content", msg.ID),
624429 content,
430+ msg.Role,
625431 true, // User messages are markdown
626− &sty,
432+ sty,
627433 )
628− item.SetFocusStyles(&focusStyle, &blurStyle)
629434 items = append(items, item)
630435 }
631436
@@ -636,8 +441,9 @@ func GetMessageItems(msg *message.Message, toolResults map[string]message.ToolRe
636441 fmt.Sprintf("%s-attachment-%d", msg.ID, i),
637442 filename,
638443 attachment.Path,
639− &sty,
444+ sty,
640445 )
446+ item.SetHighlightStyle(ToStyler(sty.TextSelection))
641447 item.SetFocusStyles(&focusStyle, &blurStyle)
642448 items = append(items, item)
643449 }
@@ -666,7 +472,7 @@ func GetMessageItems(msg *message.Message, toolResults map[string]message.ToolRe
666472 fmt.Sprintf("%s-header", msg.ID),
667473 modelName,
668474 duration,
669− &sty,
475+ sty,
670476 )
671477 items = append(items, header)
672478 }
@@ -684,9 +490,8 @@ func GetMessageItems(msg *message.Message, toolResults map[string]message.ToolRe
684490 reasoning.Thinking,
685491 duration,
686492 reasoning.FinishedAt > 0,
687− &sty,
493+ sty,
688494 )
689− item.SetFocusStyles(&focusStyle, &blurStyle)
690495 items = append(items, item)
691496 }
692497
@@ -703,10 +508,10 @@ func GetMessageItems(msg *message.Message, toolResults map[string]message.ToolRe
703508 item := NewMessageContentItem(
704509 fmt.Sprintf("%s-content", msg.ID),
705510 "*Canceled*",
511+ msg.Role,
706512 true,
707− &sty,
513+ sty,
708514 )
709− item.SetFocusStyles(&focusStyle, &blurStyle)
710515 items = append(items, item)
711516 case message.FinishReasonError:
712517 // Render error
@@ -719,20 +524,20 @@ func GetMessageItems(msg *message.Message, toolResults map[string]message.ToolRe
719524 item := NewMessageContentItem(
720525 fmt.Sprintf("%s-error", msg.ID),
721526 errorContent,
527+ msg.Role,
722528 false,
723− &sty,
529+ sty,
724530 )
725− item.SetFocusStyles(&focusStyle, &blurStyle)
726531 items = append(items, item)
727532 }
728533 } else if content != "" {
729534 item := NewMessageContentItem(
730535 fmt.Sprintf("%s-content", msg.ID),
731536 content,
537+ msg.Role,
732538 true, // Assistant messages are markdown
733− &sty,
539+ sty,
734540 )
735− item.SetFocusStyles(&focusStyle, &blurStyle)
736541 items = append(items, item)
737542 }
738543
@@ -757,9 +562,11 @@ func GetMessageItems(msg *message.Message, toolResults map[string]message.ToolRe
757562 result,
758563 false, // cancelled state would need to be tracked separately
759564 false, // nested state would be detected from tool results
760− &sty,
565+ sty,
761566 )
762567
568+ item.SetHighlightStyle(ToStyler(sty.TextSelection))
569+
763570 // Tool calls use muted style with optional focus border
764571 item.SetFocusStyles(&sty.Chat.Message.ToolCallFocused, &sty.Chat.Message.ToolCallBlurred)
765572
@@ -788,3 +595,299 @@ func BuildToolResultMap(messages []*message.Message) map[string]message.ToolResu
788595 }
789596 return resultMap
790597}
598+
599+// BaseFocusable provides common focus state and styling for items.
600+// Embed this type to add focus behavior to any item.
601+type BaseFocusable struct {
602+ focused bool
603+ focusStyle *lipgloss.Style
604+ blurStyle *lipgloss.Style
605+}
606+
607+// Focus implements Focusable interface.
608+func (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.
616+func (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.
634+func (b *BaseFocusable) Focused() bool {
635+ return b.focused
636+}
637+
638+// HasFocusStyles returns true if both focus and blur styles are configured.
639+func (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.
645+func (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.
653+func (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].
659+type 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.
663+type BaseHighlightable struct {
664+ highlightStartLine int
665+ highlightStartCol int
666+ highlightEndLine int
667+ highlightEndCol int
668+ highlightStyle CellStyler
669+}
670+
671+// SetHighlight implements Highlightable interface.
672+func (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.
680+func (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.
685+func (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.
691+func (b *BaseHighlightable) SetHighlightStyle(style CellStyler) {
692+ b.highlightStyle = style
693+}
694+
695+// GetHighlightStyle returns the current highlight style function.
696+func (b *BaseHighlightable) GetHighlightStyle() CellStyler {
697+ return b.highlightStyle
698+}
699+
700+// InitHighlight initializes the highlight fields with default values.
701+func (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.
710+func (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.
719+func (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.
747+func (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].
845+func 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].
852+func 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+}