on main help sign in
feat(ui): add text highlighting support to lazylist
9c00130Ayman Bagabas
internal/ui/lazylist/highlight.gonew file+289
@@ -0,0 +1,289 @@
1+package lazylist
2+
3+import (
4+ "image"
5+
6+ "charm.land/lipgloss/v2"
7+ uv "github.com/charmbracelet/ultraviolet"
8+)
9+
10+// DefaultHighlighter is the default highlighter function that applies inverse style.
11+var DefaultHighlighter Highlighter = func(s uv.Style) uv.Style {
12+ s.Attrs |= uv.AttrReverse
13+ return s
14+}
15+
16+// Highlighter represents a function that defines how to highlight text.
17+type Highlighter func(uv.Style) uv.Style
18+
19+// Highlight highlights a region of text within the given content and region.
20+func Highlight(content string, area image.Rectangle, startLine, startCol, endLine, endCol int, highlighter Highlighter) string {
21+ if startLine < 0 || startCol < 0 {
22+ return content
23+ }
24+
25+ if highlighter == nil {
26+ highlighter = DefaultHighlighter
27+ }
28+
29+ width, height := area.Dx(), area.Dy()
30+ buf := uv.NewScreenBuffer(width, height)
31+ styled := uv.NewStyledString(content)
32+ styled.Draw(&buf, area)
33+
34+ for y := startLine; y <= endLine && y < height; y++ {
35+ if y >= buf.Height() {
36+ break
37+ }
38+
39+ line := buf.Line(y)
40+
41+ // Determine column range for this line
42+ colStart := 0
43+ if y == startLine {
44+ colStart = min(startCol, len(line))
45+ }
46+
47+ colEnd := len(line)
48+ if y == endLine {
49+ colEnd = min(endCol, len(line))
50+ }
51+
52+ // Track last non-empty position as we go
53+ lastContentX := -1
54+
55+ // Single pass: check content and track last non-empty position
56+ for x := colStart; x < colEnd; x++ {
57+ cell := line.At(x)
58+ if cell == nil {
59+ continue
60+ }
61+
62+ // Update last content position if non-empty
63+ if cell.Content != "" && cell.Content != " " {
64+ lastContentX = x
65+ }
66+ }
67+
68+ // Only apply highlight up to last content position
69+ highlightEnd := colEnd
70+ if lastContentX >= 0 {
71+ highlightEnd = lastContentX + 1
72+ } else if lastContentX == -1 {
73+ highlightEnd = colStart // No content on this line
74+ }
75+
76+ // Apply highlight style only to cells with content
77+ for x := colStart; x < highlightEnd; x++ {
78+ if !image.Pt(x, y).In(area) {
79+ continue
80+ }
81+ cell := line.At(x)
82+ cell.Style = highlighter(cell.Style)
83+ }
84+ }
85+
86+ return buf.Render()
87+}
88+
89+// RenderWithHighlight renders content with optional focus styling and highlighting.
90+// This is a helper that combines common rendering logic for all items.
91+// The content parameter should be the raw rendered content before focus styling.
92+// The style parameter should come from CurrentStyle() and may be nil.
93+// func (b *BaseHighlightable) RenderWithHighlight(content string, width int, style *lipgloss.Style) string {
94+// // Apply focus/blur styling if configured
95+// rendered := content
96+// if style != nil {
97+// rendered = style.Render(rendered)
98+// }
99+//
100+// if !b.HasHighlight() {
101+// return rendered
102+// }
103+//
104+// height := lipgloss.Height(rendered)
105+//
106+// // Create temp buffer to draw content with highlighting
107+// tempBuf := uv.NewScreenBuffer(width, height)
108+//
109+// // Draw the rendered content to temp buffer
110+// styled := uv.NewStyledString(rendered)
111+// styled.Draw(&tempBuf, uv.Rect(0, 0, width, height))
112+//
113+// // Apply highlighting if active
114+// b.ApplyHighlight(&tempBuf, width, height, style)
115+//
116+// return tempBuf.Render()
117+// }
118+
119+// ApplyHighlight applies highlighting to a screen buffer.
120+// This should be called after drawing content to the buffer.
121+// func (b *BaseHighlightable) ApplyHighlight(buf *uv.ScreenBuffer, width, height int, style *lipgloss.Style) {
122+// if b.highlightStartLine < 0 {
123+// return
124+// }
125+//
126+// var (
127+// topMargin, topBorder, topPadding int
128+// rightMargin, rightBorder, rightPadding int
129+// bottomMargin, bottomBorder, bottomPadding int
130+// leftMargin, leftBorder, leftPadding int
131+// )
132+// if style != nil {
133+// topMargin, rightMargin, bottomMargin, leftMargin = style.GetMargin()
134+// topBorder, rightBorder, bottomBorder, leftBorder = style.GetBorderTopSize(),
135+// style.GetBorderRightSize(),
136+// style.GetBorderBottomSize(),
137+// style.GetBorderLeftSize()
138+// topPadding, rightPadding, bottomPadding, leftPadding = style.GetPadding()
139+// }
140+//
141+// slog.Info("Applying highlight",
142+// "highlightStartLine", b.highlightStartLine,
143+// "highlightStartCol", b.highlightStartCol,
144+// "highlightEndLine", b.highlightEndLine,
145+// "highlightEndCol", b.highlightEndCol,
146+// "width", width,
147+// "height", height,
148+// "margins", fmt.Sprintf("%d,%d,%d,%d", topMargin, rightMargin, bottomMargin, leftMargin),
149+// "borders", fmt.Sprintf("%d,%d,%d,%d", topBorder, rightBorder, bottomBorder, leftBorder),
150+// "paddings", fmt.Sprintf("%d,%d,%d,%d", topPadding, rightPadding, bottomPadding, leftPadding),
151+// )
152+//
153+// // Calculate content area offsets
154+// contentArea := image.Rectangle{
155+// Min: image.Point{
156+// X: leftMargin + leftBorder + leftPadding,
157+// Y: topMargin + topBorder + topPadding,
158+// },
159+// Max: image.Point{
160+// X: width - (rightMargin + rightBorder + rightPadding),
161+// Y: height - (bottomMargin + bottomBorder + bottomPadding),
162+// },
163+// }
164+//
165+// for y := b.highlightStartLine; y <= b.highlightEndLine && y < height; y++ {
166+// if y >= buf.Height() {
167+// break
168+// }
169+//
170+// line := buf.Line(y)
171+//
172+// // Determine column range for this line
173+// startCol := 0
174+// if y == b.highlightStartLine {
175+// startCol = min(b.highlightStartCol, len(line))
176+// }
177+//
178+// endCol := len(line)
179+// if y == b.highlightEndLine {
180+// endCol = min(b.highlightEndCol, len(line))
181+// }
182+//
183+// // Track last non-empty position as we go
184+// lastContentX := -1
185+//
186+// // Single pass: check content and track last non-empty position
187+// for x := startCol; x < endCol; x++ {
188+// cell := line.At(x)
189+// if cell == nil {
190+// continue
191+// }
192+//
193+// // Update last content position if non-empty
194+// if cell.Content != "" && cell.Content != " " {
195+// lastContentX = x
196+// }
197+// }
198+//
199+// // Only apply highlight up to last content position
200+// highlightEnd := endCol
201+// if lastContentX >= 0 {
202+// highlightEnd = lastContentX + 1
203+// } else if lastContentX == -1 {
204+// highlightEnd = startCol // No content on this line
205+// }
206+//
207+// // Apply highlight style only to cells with content
208+// for x := startCol; x < highlightEnd; x++ {
209+// if !image.Pt(x, y).In(contentArea) {
210+// continue
211+// }
212+// cell := line.At(x)
213+// cell.Style = b.highlightStyle(cell.Style)
214+// }
215+// }
216+// }
217+
218+// ToHighlighter converts a [lipgloss.Style] to a [Highlighter].
219+func ToHighlighter(lgStyle lipgloss.Style) Highlighter {
220+ return func(uv.Style) uv.Style {
221+ return ToStyle(lgStyle)
222+ }
223+}
224+
225+// ToStyle converts an inline [lipgloss.Style] to a [uv.Style].
226+func ToStyle(lgStyle lipgloss.Style) uv.Style {
227+ var uvStyle uv.Style
228+
229+ // Colors are already color.Color
230+ uvStyle.Fg = lgStyle.GetForeground()
231+ uvStyle.Bg = lgStyle.GetBackground()
232+
233+ // Build attributes using bitwise OR
234+ var attrs uint8
235+
236+ if lgStyle.GetBold() {
237+ attrs |= uv.AttrBold
238+ }
239+
240+ if lgStyle.GetItalic() {
241+ attrs |= uv.AttrItalic
242+ }
243+
244+ if lgStyle.GetUnderline() {
245+ uvStyle.Underline = uv.UnderlineSingle
246+ }
247+
248+ if lgStyle.GetStrikethrough() {
249+ attrs |= uv.AttrStrikethrough
250+ }
251+
252+ if lgStyle.GetFaint() {
253+ attrs |= uv.AttrFaint
254+ }
255+
256+ if lgStyle.GetBlink() {
257+ attrs |= uv.AttrBlink
258+ }
259+
260+ if lgStyle.GetReverse() {
261+ attrs |= uv.AttrReverse
262+ }
263+
264+ uvStyle.Attrs = attrs
265+
266+ return uvStyle
267+}
268+
269+// AdjustArea adjusts the given area rectangle by subtracting margins, borders,
270+// and padding from the style.
271+func AdjustArea(area image.Rectangle, style lipgloss.Style) image.Rectangle {
272+ topMargin, rightMargin, bottomMargin, leftMargin := style.GetMargin()
273+ topBorder, rightBorder, bottomBorder, leftBorder := style.GetBorderTopSize(),
274+ style.GetBorderRightSize(),
275+ style.GetBorderBottomSize(),
276+ style.GetBorderLeftSize()
277+ topPadding, rightPadding, bottomPadding, leftPadding := style.GetPadding()
278+
279+ return image.Rectangle{
280+ Min: image.Point{
281+ X: area.Min.X + leftMargin + leftBorder + leftPadding,
282+ Y: area.Min.Y + topMargin + topBorder + topPadding,
283+ },
284+ Max: image.Point{
285+ X: area.Max.X - (rightMargin + rightBorder + rightPadding),
286+ Y: area.Max.Y - (bottomMargin + bottomBorder + bottomPadding),
287+ },
288+ }
289+}
internal/ui/lazylist/item.go+12−18
@@ -1,5 +1,7 @@
1package lazylist
1package lazylist
2
2
3+import "charm.land/lipgloss/v2"
4+
3// Item represents a single item in the lazy-loaded list.
5// Item represents a single item in the lazy-loaded list.
4type Item interface {
6type Item interface {
5 // Render returns the string representation of the item for the given
7 // Render returns the string representation of the item for the given
@@ -7,24 +9,16 @@ type Item interface {
7 Render(width int) string
9 Render(width int) string
8}
10}
9
11
10−// Focusable represents an item that can gain or lose focus.
12+// FocusStylable represents an item that can be styled based on focus state.
11−type Focusable interface {
13+type FocusStylable interface {
12− // Focus sets the focus state of the item.
14+ // FocusStyle returns the style to apply when the item is focused.
13− Focus()
15+ FocusStyle() lipgloss.Style
14−
16+ // BlurStyle returns the style to apply when the item is unfocused.
15− // Blur removes the focus state of the item.
17+ BlurStyle() lipgloss.Style
16− Blur()
17−
18− // Focused returns whether the item is focused.
19− Focused() bool
20}
18}
21
19
22−// Highlightable represents an item that can have a highlighted region.
20+// HighlightStylable represents an item that can be styled for highlighted regions.
23−type Highlightable interface {
21+type HighlightStylable interface {
24− // SetHighlight sets the highlight region (startLine, startCol) to (endLine, endCol).
22+ // HighlightStyle returns the style to apply for highlighted regions.
25− // Use -1 for all values to clear highlighting.
23+ HighlightStyle() lipgloss.Style
26− SetHighlight(startLine, startCol, endLine, endCol int)
27−
28− // GetHighlight returns the current highlight region.
29− GetHighlight() (startLine, startCol, endLine, endCol int)
30}
24}
internal/ui/lazylist/list.go+393−26
@@ -1,8 +1,11 @@
1package lazylist
1package lazylist
2
2
3import (
3import (
4+ "image"
4 "log/slog"
5 "log/slog"
5 "strings"
6 "strings"
7+
8+ "charm.land/lipgloss/v2"
6)
9)
7
10
8// List represents a list of items that can be lazily rendered. A list is
11// List represents a list of items that can be lazily rendered. A list is
@@ -22,6 +25,16 @@ type List struct {
22 focused bool
25 focused bool
23 selectedIdx int // The current selected index -1 means no selection
26 selectedIdx int // The current selected index -1 means no selection
24
27
28+ // Mouse state
29+ mouseDown bool
30+ mouseDownItem int // Item index where mouse was pressed
31+ mouseDownX int // X position in item content (character offset)
32+ mouseDownY int // Y position in item (line offset)
33+ mouseDragItem int // Current item index being dragged over
34+ mouseDragX int // Current X in item content
35+ mouseDragY int // Current Y in item
36+ lastHighlighted map[int]bool // Track which items were highlighted in last update
37+
25 // Rendered content and cache
38 // Rendered content and cache
26 renderedItems map[int]renderedItem
39 renderedItems map[int]renderedItem
27
40
@@ -44,6 +57,10 @@ func NewList(items ...Item) *List {
44 l := new(List)
57 l := new(List)
45 l.items = items
58 l.items = items
46 l.renderedItems = make(map[int]renderedItem)
59 l.renderedItems = make(map[int]renderedItem)
60+ l.selectedIdx = -1
61+ l.mouseDownItem = -1
62+ l.mouseDragItem = -1
63+ l.lastHighlighted = make(map[int]bool)
47 return l
64 return l
48}
65}
49
66
@@ -79,25 +96,98 @@ func (l *List) Len() int {
79
96
80// getItem renders (if needed) and returns the item at the given index.
97// getItem renders (if needed) and returns the item at the given index.
81func (l *List) getItem(idx int) renderedItem {
98func (l *List) getItem(idx int) renderedItem {
99+ return l.renderItem(idx, false)
100+}
101+
102+// renderItem renders (if needed) and returns the item at the given index. If
103+// process is true, it applies focus and highlight styling.
104+func (l *List) renderItem(idx int, process bool) renderedItem {
82 if idx < 0 || idx >= len(l.items) {
105 if idx < 0 || idx >= len(l.items) {
83 return renderedItem{}
106 return renderedItem{}
84 }
107 }
85
108
86− if item, ok := l.renderedItems[idx]; ok {
109+ var style lipgloss.Style
87− return item
110+ focusable, isFocusable := l.items[idx].(FocusStylable)
111+ if isFocusable {
112+ style = focusable.BlurStyle()
113+ if l.focused && idx == l.selectedIdx {
114+ style = focusable.FocusStyle()
115+ }
88 }
116 }
89
117
118+ ri, ok := l.renderedItems[idx]
119+ if !ok {
90− item := l.items[idx]
120+ item := l.items[idx]
91− rendered := item.Render(l.width)
121+ rendered := item.Render(l.width - style.GetHorizontalFrameSize())
92− height := countLines(rendered)
122+ height := countLines(rendered)
93− // slog.Info("Rendered item", "idx", idx, "height", height)
123+
124+ ri = renderedItem{
125+ content: rendered,
126+ height: height,
127+ }
128+
129+ l.renderedItems[idx] = ri
130+ }
131+
132+ if !process {
133+ return ri
134+ }
135+
136+ // We apply highlighting before focus styling so that focus styling
137+ // overrides highlight styles.
138+ // Apply highlight if item supports it
139+ if l.mouseDownItem >= 0 {
140+ if highlightable, ok := l.items[idx].(HighlightStylable); ok {
141+ startItemIdx, startLine, startCol, endItemIdx, endLine, endCol := l.getHighlightRange()
142+ if idx >= startItemIdx && idx <= endItemIdx {
143+ var sLine, sCol, eLine, eCol int
144+ if idx == startItemIdx && idx == endItemIdx {
145+ // Single item selection
146+ sLine = startLine
147+ sCol = startCol
148+ eLine = endLine
149+ eCol = endCol
150+ } else if idx == startItemIdx {
151+ // First item - from start position to end of item
152+ sLine = startLine
153+ sCol = startCol
154+ eLine = ri.height - 1
155+ eCol = 9999 // 9999 = end of line
156+ } else if idx == endItemIdx {
157+ // Last item - from start of item to end position
158+ sLine = 0
159+ sCol = 0
160+ eLine = endLine
161+ eCol = endCol
162+ } else {
163+ // Middle item - fully highlighted
164+ sLine = 0
165+ sCol = 0
166+ eLine = ri.height - 1
167+ eCol = 9999
168+ }
94
169
95− ri := renderedItem{
170+ // Apply offset for styling frame
96− content: rendered,
171+ contentArea := image.Rect(0, 0, l.width, ri.height)
97− height: height,
172+
173+ hiStyle := highlightable.HighlightStyle()
174+ slog.Info("Highlighting item", "idx", idx,
175+ "sLine", sLine, "sCol", sCol,
176+ "eLine", eLine, "eCol", eCol,
177+ )
178+ rendered := Highlight(ri.content, contentArea, sLine, sCol, eLine, eCol, ToHighlighter(hiStyle))
179+ ri.content = rendered
180+ }
181+ }
98 }
182 }
99
183
100− l.renderedItems[idx] = ri
184+ if isFocusable {
185+ // Apply focus/blur styling if needed
186+ rendered := style.Render(ri.content)
187+ height := countLines(rendered)
188+ ri.content = rendered
189+ ri.height = height
190+ }
101
191
102 return ri
192 return ri
103}
193}
@@ -242,8 +332,6 @@ func (l *List) Render() string {
242 return ""
332 return ""
243 }
333 }
244
334
245− slog.Info("Render", "offsetIdx", l.offsetIdx, "offsetLine", l.offsetLine, "width", l.width, "height", l.height)
246−
247 var lines []string
335 var lines []string
248 currentIdx := l.offsetIdx
336 currentIdx := l.offsetIdx
249 currentOffset := l.offsetLine
337 currentOffset := l.offsetLine
@@ -251,7 +339,7 @@ func (l *List) Render() string {
251 linesNeeded := l.height
339 linesNeeded := l.height
252
340
253 for linesNeeded > 0 && currentIdx < len(l.items) {
341 for linesNeeded > 0 && currentIdx < len(l.items) {
254− item := l.getItem(currentIdx)
342+ item := l.renderItem(currentIdx, true)
255 itemLines := strings.Split(item.content, "\n")
343 itemLines := strings.Split(item.content, "\n")
256 itemHeight := len(itemLines)
344 itemHeight := len(itemLines)
257
345
@@ -321,11 +409,12 @@ func (l *List) Focus() {
321 return
409 return
322 }
410 }
323
411
324− item := l.items[l.selectedIdx]
412+ // item := l.items[l.selectedIdx]
325− if focusable, ok := item.(Focusable); ok {
413+ // if focusable, ok := item.(Focusable); ok {
326− focusable.Focus()
414+ // focusable.Focus()
415+ // l.items[l.selectedIdx] = focusable.(Item)
327− l.invalidateItem(l.selectedIdx)
416+ // l.invalidateItem(l.selectedIdx)
328− }
417+ // }
329}
418}
330
419
331// Blur removes the focus state from the list.
420// Blur removes the focus state from the list.
@@ -335,11 +424,12 @@ func (l *List) Blur() {
335 return
424 return
336 }
425 }
337
426
338− item := l.items[l.selectedIdx]
427+ // item := l.items[l.selectedIdx]
339− if focusable, ok := item.(Focusable); ok {
428+ // if focusable, ok := item.(Focusable); ok {
340− focusable.Blur()
429+ // focusable.Blur()
430+ // l.items[l.selectedIdx] = focusable.(Item)
341− l.invalidateItem(l.selectedIdx)
431+ // l.invalidateItem(l.selectedIdx)
342− }
432+ // }
343}
433}
344
434
345// ScrollToTop scrolls the list to the top.
435// ScrollToTop scrolls the list to the top.
@@ -467,15 +557,292 @@ func (l *List) SelectLastInView() {
467}
557}
468
558
469// HandleMouseDown handles mouse down events at the given line in the viewport.
559// HandleMouseDown handles mouse down events at the given line in the viewport.
560+// x and y are viewport-relative coordinates (0,0 = top-left of visible area).
561+// Returns true if the event was handled.
470−func (l *List) HandleMouseDown(x, y int) {
562+func (l *List) HandleMouseDown(x, y int) bool {
563+ if len(l.items) == 0 {
564+ return false
565+ }
566+
567+ // Find which item was clicked
568+ itemIdx, itemY := l.findItemAtY(x, y)
569+ if itemIdx < 0 {
570+ return false
571+ }
572+
573+ l.mouseDown = true
574+ l.mouseDownItem = itemIdx
575+ l.mouseDownX = x
576+ l.mouseDownY = itemY
577+ l.mouseDragItem = itemIdx
578+ l.mouseDragX = x
579+ l.mouseDragY = itemY
580+
581+ // Select the clicked item
582+ l.SetSelected(itemIdx)
583+
584+ return true
471}
585}
472
586
473// HandleMouseUp handles mouse up events at the given line in the viewport.
587// HandleMouseUp handles mouse up events at the given line in the viewport.
588+// Returns true if the event was handled.
474−func (l *List) HandleMouseUp(x, y int) {
589+func (l *List) HandleMouseUp(x, y int) bool {
590+ if !l.mouseDown {
591+ return false
592+ }
593+
594+ l.mouseDown = false
595+
596+ return true
475}
597}
476
598
477// HandleMouseDrag handles mouse drag events at the given line in the viewport.
599// HandleMouseDrag handles mouse drag events at the given line in the viewport.
600+// x and y are viewport-relative coordinates.
601+// Returns true if the event was handled.
478−func (l *List) HandleMouseDrag(x, y int) {
602+func (l *List) HandleMouseDrag(x, y int) bool {
603+ if !l.mouseDown {
604+ return false
605+ }
606+
607+ if len(l.items) == 0 {
608+ return false
609+ }
610+
611+ // Find which item we're dragging over
612+ itemIdx, itemY := l.findItemAtY(x, y)
613+ if itemIdx < 0 {
614+ return false
615+ }
616+
617+ l.mouseDragItem = itemIdx
618+ l.mouseDragX = x
619+ l.mouseDragY = itemY
620+
621+ startItemIdx, startLine, startCol, endItemIdx, endLine, endCol := l.getHighlightRange()
622+
623+ slog.Info("HandleMouseDrag", "mouseDownItem", l.mouseDownItem,
624+ "mouseDragItem", l.mouseDragItem,
625+ "startItemIdx", startItemIdx,
626+ "endItemIdx", endItemIdx,
627+ "startLine", startLine,
628+ "startCol", startCol,
629+ "endLine", endLine,
630+ "endCol", endCol,
631+ )
632+
633+ // for i := startItemIdx; i <= endItemIdx; i++ {
634+ // item := l.getItem(i)
635+ // itemHi, ok := l.items[i].(Highlightable)
636+ // if ok {
637+ // if i == startItemIdx && i == endItemIdx {
638+ // // Single item selection
639+ // itemHi.SetHighlight(startLine, startCol, endLine, endCol)
640+ // } else if i == startItemIdx {
641+ // // First item - from start position to end of item
642+ // itemHi.SetHighlight(startLine, startCol, item.height-1, 9999) // 9999 = end of line
643+ // } else if i == endItemIdx {
644+ // // Last item - from start of item to end position
645+ // itemHi.SetHighlight(0, 0, endLine, endCol)
646+ // } else {
647+ // // Middle item - fully highlighted
648+ // itemHi.SetHighlight(0, 0, item.height-1, 9999)
649+ // }
650+ //
651+ // // Invalidate item to re-render
652+ // l.items[i] = itemHi.(Item)
653+ // l.invalidateItem(i)
654+ // }
655+ // }
656+
657+ // Update highlight if item supports it
658+ // l.updateHighlight()
659+
660+ return true
661+}
662+
663+// ClearHighlight clears any active text highlighting.
664+func (l *List) ClearHighlight() {
665+ // for i, item := range l.renderedItems {
666+ // if !item.highlighted {
667+ // continue
668+ // }
669+ // if h, ok := l.items[i].(Highlightable); ok {
670+ // h.SetHighlight(-1, -1, -1, -1)
671+ // l.items[i] = h.(Item)
672+ // l.invalidateItem(i)
673+ // }
674+ // }
675+ l.mouseDownItem = -1
676+ l.mouseDragItem = -1
677+ l.lastHighlighted = make(map[int]bool)
678+}
679+
680+// findItemAtY finds the item at the given viewport y coordinate.
681+// Returns the item index and the y offset within that item. It returns -1, -1
682+// if no item is found.
683+func (l *List) findItemAtY(_, y int) (itemIdx int, itemY int) {
684+ if y < 0 || y >= l.height {
685+ return -1, -1
686+ }
687+
688+ // Walk through visible items to find which one contains this y
689+ currentIdx := l.offsetIdx
690+ currentLine := -l.offsetLine // Negative because offsetLine is how many lines are hidden
691+
692+ for currentIdx < len(l.items) && currentLine < l.height {
693+ item := l.getItem(currentIdx)
694+ itemEndLine := currentLine + item.height
695+
696+ // Check if y is within this item's visible range
697+ if y >= currentLine && y < itemEndLine {
698+ // Found the item, calculate itemY (offset within the item)
699+ itemY = y - currentLine
700+ return currentIdx, itemY
701+ }
702+
703+ // Move to next item
704+ currentLine = itemEndLine
705+ if l.gap > 0 {
706+ currentLine += l.gap
707+ }
708+ currentIdx++
709+ }
710+
711+ return -1, -1
712+}
713+
714+// getHighlightRange returns the current highlight range.
715+func (l *List) getHighlightRange() (startItemIdx, startLine, startCol, endItemIdx, endLine, endCol int) {
716+ if l.mouseDownItem < 0 {
717+ return -1, -1, -1, -1, -1, -1
718+ }
719+
720+ downItemIdx := l.mouseDownItem
721+ dragItemIdx := l.mouseDragItem
722+
723+ // Determine selection direction
724+ draggingDown := dragItemIdx > downItemIdx ||
725+ (dragItemIdx == downItemIdx && l.mouseDragY > l.mouseDownY) ||
726+ (dragItemIdx == downItemIdx && l.mouseDragY == l.mouseDownY && l.mouseDragX >= l.mouseDownX)
727+
728+ if draggingDown {
729+ // Normal forward selection
730+ startItemIdx = downItemIdx
731+ startLine = l.mouseDownY
732+ startCol = l.mouseDownX
733+ endItemIdx = dragItemIdx
734+ endLine = l.mouseDragY
735+ endCol = l.mouseDragX
736+ } else {
737+ // Backward selection (dragging up)
738+ startItemIdx = dragItemIdx
739+ startLine = l.mouseDragY
740+ startCol = l.mouseDragX
741+ endItemIdx = downItemIdx
742+ endLine = l.mouseDownY
743+ endCol = l.mouseDownX
744+ }
745+
746+ slog.Info("Apply highlight",
747+ "startItemIdx", startItemIdx,
748+ "endItemIdx", endItemIdx,
749+ "startLine", startLine,
750+ "startCol", startCol,
751+ "endLine", endLine,
752+ "endCol", endCol,
753+ )
754+
755+ return startItemIdx, startLine, startCol, endItemIdx, endLine, endCol
756+}
757+
758+// updateHighlight updates the highlight range for highlightable items.
759+// Supports highlighting across multiple items and respects drag direction.
760+func (l *List) updateHighlight() {
761+ if l.mouseDownItem < 0 {
762+ return
763+ }
764+
765+ // Get start and end item indices
766+ downItemIdx := l.mouseDownItem
767+ dragItemIdx := l.mouseDragItem
768+
769+ // Determine selection direction
770+ draggingDown := dragItemIdx > downItemIdx ||
771+ (dragItemIdx == downItemIdx && l.mouseDragY > l.mouseDownY) ||
772+ (dragItemIdx == downItemIdx && l.mouseDragY == l.mouseDownY && l.mouseDragX >= l.mouseDownX)
773+
774+ // Determine actual start and end based on direction
775+ var startItemIdx, endItemIdx int
776+ var startLine, startCol, endLine, endCol int
777+
778+ if draggingDown {
779+ // Normal forward selection
780+ startItemIdx = downItemIdx
781+ endItemIdx = dragItemIdx
782+ startLine = l.mouseDownY
783+ startCol = l.mouseDownX
784+ endLine = l.mouseDragY
785+ endCol = l.mouseDragX
786+ } else {
787+ // Backward selection (dragging up)
788+ startItemIdx = dragItemIdx
789+ endItemIdx = downItemIdx
790+ startLine = l.mouseDragY
791+ startCol = l.mouseDragX
792+ endLine = l.mouseDownY
793+ endCol = l.mouseDownX
794+ }
795+
796+ slog.Info("Update highlight", "startItemIdx", startItemIdx, "endItemIdx", endItemIdx,
797+ "startLine", startLine, "startCol", startCol,
798+ "endLine", endLine, "endCol", endCol,
799+ "draggingDown", draggingDown,
800+ )
801+
802+ // Track newly highlighted items
803+ // newHighlighted := make(map[int]bool)
804+
805+ // Clear highlights on items that are no longer in range
806+ // for i := range l.lastHighlighted {
807+ // if i < startItemIdx || i > endItemIdx {
808+ // if h, ok := l.items[i].(Highlightable); ok {
809+ // h.SetHighlight(-1, -1, -1, -1)
810+ // l.items[i] = h.(Item)
811+ // l.invalidateItem(i)
812+ // }
813+ // }
814+ // }
815+
816+ // Highlight all items in range
817+ // for idx := startItemIdx; idx <= endItemIdx; idx++ {
818+ // item, ok := l.items[idx].(Highlightable)
819+ // if !ok {
820+ // continue
821+ // }
822+ //
823+ // renderedItem := l.getItem(idx)
824+ //
825+ // if idx == startItemIdx && idx == endItemIdx {
826+ // // Single item selection
827+ // item.SetHighlight(startLine, startCol, endLine, endCol)
828+ // } else if idx == startItemIdx {
829+ // // First item - from start position to end of item
830+ // item.SetHighlight(startLine, startCol, renderedItem.height-1, 9999) // 9999 = end of line
831+ // } else if idx == endItemIdx {
832+ // // Last item - from start of item to end position
833+ // item.SetHighlight(0, 0, endLine, endCol)
834+ // } else {
835+ // // Middle item - fully highlighted
836+ // item.SetHighlight(0, 0, renderedItem.height-1, 9999)
837+ // }
838+ //
839+ // l.items[idx] = item.(Item)
840+ //
841+ // l.invalidateItem(idx)
842+ // newHighlighted[idx] = true
843+ // }
844+ //
845+ // l.lastHighlighted = newHighlighted
479}
846}
480
847
481// countLines counts the number of lines in a string.
848// countLines counts the number of lines in a string.
internal/ui/model/chat.go+4−189
@@ -1,198 +1,11 @@
1package model
1package model
2
2
3import (
3import (
4− "fmt"
5− "strings"
6−
7− tea "charm.land/bubbletea/v2"
8− "github.com/charmbracelet/crush/internal/message"
9− "github.com/charmbracelet/crush/internal/tui/components/anim"
10 "github.com/charmbracelet/crush/internal/ui/common"
4 "github.com/charmbracelet/crush/internal/ui/common"
11 "github.com/charmbracelet/crush/internal/ui/lazylist"
5 "github.com/charmbracelet/crush/internal/ui/lazylist"
12− "github.com/charmbracelet/crush/internal/ui/list"
13− "github.com/charmbracelet/crush/internal/ui/styles"
14 uv "github.com/charmbracelet/ultraviolet"
6 uv "github.com/charmbracelet/ultraviolet"
15)
7)
16
8
17−// ChatAnimItem represents a chat animation item in the chat UI.
18−type ChatAnimItem struct {
19− list.BaseFocusable
20− anim *anim.Anim
21−}
22−
23−var (
24− _ list.Item = (*ChatAnimItem)(nil)
25− _ list.Focusable = (*ChatAnimItem)(nil)
26−)
27−
28−// NewChatAnimItem creates a new instance of [ChatAnimItem].
29−func NewChatAnimItem(a *anim.Anim) *ChatAnimItem {
30− m := new(ChatAnimItem)
31− return m
32−}
33−
34−// Init initializes the chat animation item.
35−func (c *ChatAnimItem) Init() tea.Cmd {
36− return c.anim.Init()
37−}
38−
39−// Step advances the animation by one step.
40−func (c *ChatAnimItem) Step() tea.Cmd {
41− return c.anim.Step()
42−}
43−
44−// SetLabel sets the label for the animation item.
45−func (c *ChatAnimItem) SetLabel(label string) {
46− c.anim.SetLabel(label)
47−}
48−
49−// Draw implements list.Item.
50−func (c *ChatAnimItem) Draw(scr uv.Screen, area uv.Rectangle) {
51− styled := uv.NewStyledString(c.anim.View())
52− styled.Draw(scr, area)
53−}
54−
55−// Height implements list.Item.
56−func (c *ChatAnimItem) Height(int) int {
57− return 1
58−}
59−
60−// ChatNoContentItem represents a chat item with no content.
61−type ChatNoContentItem struct {
62− *list.StringItem
63−}
64−
65−// NewChatNoContentItem creates a new instance of [ChatNoContentItem].
66−func NewChatNoContentItem(t *styles.Styles) *ChatNoContentItem {
67− c := new(ChatNoContentItem)
68− c.StringItem = list.NewStringItem("No message content").
69− WithFocusStyles(&t.Chat.Message.NoContent, &t.Chat.Message.NoContent)
70− return c
71−}
72−
73−// ChatMessageItem represents a chat message item in the chat UI.
74−type ChatMessageItem struct {
75− item list.Item
76− msg message.Message
77−}
78−
79−var (
80− _ list.Item = (*ChatMessageItem)(nil)
81− _ list.Focusable = (*ChatMessageItem)(nil)
82− _ list.Highlightable = (*ChatMessageItem)(nil)
83−)
84−
85−// NewChatMessageItem creates a new instance of [ChatMessageItem].
86−func NewChatMessageItem(t *styles.Styles, msg message.Message) *ChatMessageItem {
87− c := new(ChatMessageItem)
88−
89− switch msg.Role {
90− case message.User:
91− item := list.NewMarkdownItem(msg.Content().String()).
92− WithFocusStyles(&t.Chat.Message.UserFocused, &t.Chat.Message.UserBlurred)
93− item.SetHighlightStyle(list.LipglossStyleToCellStyler(t.TextSelection))
94− // TODO: Add attachments
95− c.item = item
96− default:
97− var thinkingContent string
98− content := msg.Content().String()
99− thinking := msg.IsThinking()
100− finished := msg.IsFinished()
101− finishedData := msg.FinishPart()
102− reasoningContent := msg.ReasoningContent()
103− reasoningThinking := strings.TrimSpace(reasoningContent.Thinking)
104−
105− if finished && content == "" && finishedData.Reason == message.FinishReasonError {
106− tag := t.Chat.Message.ErrorTag.Render("ERROR")
107− title := t.Chat.Message.ErrorTitle.Render(finishedData.Message)
108− details := t.Chat.Message.ErrorDetails.Render(finishedData.Details)
109− errContent := fmt.Sprintf("%s %s\n\n%s", tag, title, details)
110−
111− item := list.NewStringItem(errContent).
112− WithFocusStyles(&t.Chat.Message.AssistantFocused, &t.Chat.Message.AssistantBlurred)
113−
114− c.item = item
115−
116− return c
117− }
118−
119− if thinking || reasoningThinking != "" {
120− // TODO: animation item?
121− // TODO: thinking item
122− thinkingContent = reasoningThinking
123− } else if finished && content == "" && finishedData.Reason == message.FinishReasonCanceled {
124− content = "*Canceled*"
125− }
126−
127− var parts []string
128− if thinkingContent != "" {
129− parts = append(parts, thinkingContent)
130− }
131−
132− if content != "" {
133− if len(parts) > 0 {
134− parts = append(parts, "")
135− }
136− parts = append(parts, content)
137− }
138−
139− item := list.NewMarkdownItem(strings.Join(parts, "\n")).
140− WithFocusStyles(&t.Chat.Message.AssistantFocused, &t.Chat.Message.AssistantBlurred)
141− item.SetHighlightStyle(list.LipglossStyleToCellStyler(t.TextSelection))
142−
143− c.item = item
144− }
145−
146− return c
147−}
148−
149−// Draw implements list.Item.
150−func (c *ChatMessageItem) Draw(scr uv.Screen, area uv.Rectangle) {
151− c.item.Draw(scr, area)
152−}
153−
154−// Height implements list.Item.
155−func (c *ChatMessageItem) Height(width int) int {
156− return c.item.Height(width)
157−}
158−
159−// Blur implements list.Focusable.
160−func (c *ChatMessageItem) Blur() {
161− if blurable, ok := c.item.(list.Focusable); ok {
162− blurable.Blur()
163− }
164−}
165−
166−// Focus implements list.Focusable.
167−func (c *ChatMessageItem) Focus() {
168− if focusable, ok := c.item.(list.Focusable); ok {
169− focusable.Focus()
170− }
171−}
172−
173−// IsFocused implements list.Focusable.
174−func (c *ChatMessageItem) IsFocused() bool {
175− if focusable, ok := c.item.(list.Focusable); ok {
176− return focusable.IsFocused()
177− }
178− return false
179−}
180−
181−// GetHighlight implements list.Highlightable.
182−func (c *ChatMessageItem) GetHighlight() (startLine int, startCol int, endLine int, endCol int) {
183− if highlightable, ok := c.item.(list.Highlightable); ok {
184− return highlightable.GetHighlight()
185− }
186− return 0, 0, 0, 0
187−}
188−
189−// SetHighlight implements list.Highlightable.
190−func (c *ChatMessageItem) SetHighlight(startLine int, startCol int, endLine int, endCol int) {
191− if highlightable, ok := c.item.(list.Highlightable); ok {
192− highlightable.SetHighlight(startLine, startCol, endLine, endCol)
193− }
194−}
195−
196// Chat represents the chat UI model that handles chat interactions and
9// Chat represents the chat UI model that handles chat interactions and
197// messages.
10// messages.
198type Chat struct {
11type Chat struct {
@@ -239,9 +52,11 @@ func (m *Chat) PrependItems(items ...lazylist.Item) {
239
52
240// AppendMessages appends a new message item to the chat list.
53// AppendMessages appends a new message item to the chat list.
241func (m *Chat) AppendMessages(msgs ...MessageItem) {
54func (m *Chat) AppendMessages(msgs ...MessageItem) {
55+ items := make([]lazylist.Item, len(msgs))
242− for _, msg := range msgs {
56+ for i, msg := range msgs {
243− m.AppendItems(msg)
57+ items[i] = msg
244 }
58 }
59+ m.list.AppendItems(items...)
245}
60}
246
61
247// AppendItems appends new items to the chat list.
62// AppendItems appends new items to the chat list.
internal/ui/model/items.go+404−301
@@ -2,6 +2,8 @@ package model
2
2
3import (
3import (
4 "fmt"
4 "fmt"
5+ "image"
6+ "log/slog"
5 "path/filepath"
7 "path/filepath"
6 "strings"
8 "strings"
7 "time"
9 "time"
@@ -14,7 +16,6 @@ import (
14 "github.com/charmbracelet/crush/internal/message"
16 "github.com/charmbracelet/crush/internal/message"
15 "github.com/charmbracelet/crush/internal/ui/common"
17 "github.com/charmbracelet/crush/internal/ui/common"
16 "github.com/charmbracelet/crush/internal/ui/lazylist"
18 "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/styles"
19 "github.com/charmbracelet/crush/internal/ui/toolrender"
20 "github.com/charmbracelet/crush/internal/ui/toolrender"
20)
21)
@@ -25,38 +26,33 @@ type Identifiable interface {
25}
26}
26
27
27// MessageItem represents a [message.Message] item that can be displayed in the
28// 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.
29type MessageItem interface {
30type MessageItem interface {
30− list.Item
31+ lazylist.Item
31− list.Focusable
32− list.Highlightable
33 lazylist.Item
32 lazylist.Item
34 Identifiable
33 Identifiable
35}
34}
36
35
37// MessageContentItem represents rendered message content (text, markdown, errors, etc).
36// MessageContentItem represents rendered message content (text, markdown, errors, etc).
38type MessageContentItem struct {
37type MessageContentItem struct {
39− list.BaseFocusable
40− list.BaseHighlightable
41 id string
38 id string
42 content string
39 content string
40+ role message.MessageRole
43 isMarkdown bool
41 isMarkdown bool
44 maxWidth int
42 maxWidth int
45− cache map[int]string // Cache for rendered content at different widths
46 sty *styles.Styles
43 sty *styles.Styles
47}
44}
48
45
49// NewMessageContentItem creates a new message content item.
46// 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 {
51 m := &MessageContentItem{
48 m := &MessageContentItem{
52 id: id,
49 id: id,
53 content: content,
50 content: content,
54 isMarkdown: isMarkdown,
51 isMarkdown: isMarkdown,
52+ role: role,
55 maxWidth: 120,
53 maxWidth: 120,
56− cache: make(map[int]string),
57 sty: sty,
54 sty: sty,
58 }
55 }
59− m.InitHighlight()
60 return m
56 return m
61}
57}
62
58
@@ -65,76 +61,38 @@ func (m *MessageContentItem) ID() string {
65 return m.id
61 return m.id
66}
62}
67
63
68−// Height implements list.Item.
64+// FocusStyle returns the focus style.
69−func (m *MessageContentItem) Height(width int) int {
65+func (m *MessageContentItem) FocusStyle() lipgloss.Style {
70− // Calculate content width accounting for frame size
66+ if m.role == message.User {
71− contentWidth := width
67+ return m.sty.Chat.Message.UserFocused
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 }
68 }
82−
69+ return m.sty.Chat.Message.AssistantFocused
83− return strings.Count(rendered, "\n") + 1
84}
70}
85
71
86−// Draw implements list.Item.
72+// BlurStyle returns the blur style.
87−func (m *MessageContentItem) Draw(scr uv.Screen, area uv.Rectangle) {
73+func (m *MessageContentItem) BlurStyle() lipgloss.Style {
88− width := area.Dx()
74+ if m.role == message.User {
89− height := area.Dy()
75+ return m.sty.Chat.Message.UserBlurred
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 }
76 }
104−
77+ return m.sty.Chat.Message.AssistantBlurred
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}
78}
118
79
119−// Render implements lazylist.Item.
80+// HighlightStyle returns the highlight style.
120−func (m *MessageContentItem) Render(width int) string {
81+func (m *MessageContentItem) HighlightStyle() lipgloss.Style {
121− return m.render(width)
82+ return m.sty.TextSelection
122}
83}
123
84
124−// render renders the content at the given width, using cache if available.
85+// Render renders the content at the given width, using cache if available.
86+//
87+// It implements [lazylist.Item].
125−func (m *MessageContentItem) render(width int) string {
88+func (m *MessageContentItem) Render(width int) string {
89+ contentWidth := width
126 // Cap width to maxWidth for markdown
90 // Cap width to maxWidth for markdown
127− cappedWidth := width
91+ cappedWidth := contentWidth
128 if m.isMarkdown {
92 if m.isMarkdown {
129− cappedWidth = min(width, m.maxWidth)
93+ cappedWidth = min(contentWidth, m.maxWidth)
130− }
131−
132− // Check cache first
133− if cached, ok := m.cache[cappedWidth]; ok {
134− return cached
135 }
94 }
136
95
137− // Not cached - render now
138 var rendered string
96 var rendered string
139 if m.isMarkdown {
97 if m.isMarkdown {
140 renderer := common.MarkdownRenderer(m.sty, cappedWidth)
98 renderer := common.MarkdownRenderer(m.sty, cappedWidth)
@@ -148,30 +106,19 @@ func (m *MessageContentItem) render(width int) string {
148 rendered = m.content
106 rendered = m.content
149 }
107 }
150
108
151− // Cache the result
152− m.cache[cappedWidth] = rendered
153 return rendered
109 return rendered
154}
110}
155
111
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−
163// ToolCallItem represents a rendered tool call with its header and content.
112// ToolCallItem represents a rendered tool call with its header and content.
164type ToolCallItem struct {
113type ToolCallItem struct {
165− list.BaseFocusable
114+ BaseFocusable
166− list.BaseHighlightable
115+ BaseHighlightable
167 id string
116 id string
168 toolCall message.ToolCall
117 toolCall message.ToolCall
169 toolResult message.ToolResult
118 toolResult message.ToolResult
170 cancelled bool
119 cancelled bool
171 isNested bool
120 isNested bool
172 maxWidth int
121 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
122 sty *styles.Styles
176}
123}
177
124
@@ -190,8 +137,6 @@ func NewToolCallItem(id string, toolCall message.ToolCall, toolResult message.To
190 cancelled: cancelled,
137 cancelled: cancelled,
191 isNested: isNested,
138 isNested: isNested,
192 maxWidth: 120,
139 maxWidth: 120,
193− cache: make(map[int]cachedToolRender),
194− cacheKey: generateCacheKey(toolCall, toolResult, cancelled),
195 sty: sty,
140 sty: sty,
196 }
141 }
197 t.InitHighlight()
142 t.InitHighlight()
@@ -209,116 +154,59 @@ func (t *ToolCallItem) ID() string {
209 return t.id
154 return t.id
210}
155}
211
156
212−// Height implements list.Item.
157+// FocusStyle returns the focus style.
213−func (t *ToolCallItem) Height(width int) int {
158+func (t *ToolCallItem) FocusStyle() lipgloss.Style {
214− // Calculate content width accounting for frame size
159+ if t.focusStyle != nil {
215− contentWidth := width
160+ return *t.focusStyle
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 }
161 }
234−
162+ return lipgloss.Style{}
235− return height
236}
163}
237
164
238−// Render implements lazylist.Item.
165+// BlurStyle returns the blur style.
239−func (t *ToolCallItem) Render(width int) string {
166+func (t *ToolCallItem) BlurStyle() lipgloss.Style {
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 {
167+ if t.blurStyle != nil {
253− contentWidth -= style.GetHorizontalFrameSize()
168+ return *t.blurStyle
254 }
169 }
255−
170+ return lipgloss.Style{}
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}
171}
270
172
271−// renderCached renders the tool call at the given width with caching.
173+// HighlightStyle returns the highlight style.
272−func (t *ToolCallItem) renderCached(width int) cachedToolRender {
174+func (t *ToolCallItem) HighlightStyle() lipgloss.Style {
273− cappedWidth := min(width, t.maxWidth)
175+ return t.sty.TextSelection
274−
275− // Check if we have a valid cache entry
276− if cached, ok := t.cache[cappedWidth]; ok {
277− return cached
278− }
176+}
279
177
178+// Render implements lazylist.Item.
179+func (t *ToolCallItem) Render(width int) string {
280 // Render the tool call
180 // Render the tool call
281 ctx := &toolrender.RenderContext{
181 ctx := &toolrender.RenderContext{
282 Call: t.toolCall,
182 Call: t.toolCall,
283 Result: t.toolResult,
183 Result: t.toolResult,
284 Cancelled: t.cancelled,
184 Cancelled: t.cancelled,
285 IsNested: t.isNested,
185 IsNested: t.isNested,
286− Width: cappedWidth,
186+ Width: width,
287 Styles: t.sty,
187 Styles: t.sty,
288 }
188 }
289
189
290 rendered := toolrender.Render(ctx)
190 rendered := toolrender.Render(ctx)
291− height := strings.Count(rendered, "\n") + 1
191+ return rendered
292
192
293− cached := cachedToolRender{
193+ // return t.RenderWithHighlight(rendered, width, style)
294− content: rendered,
295− height: height,
296− }
297− t.cache[cappedWidth] = cached
298− return cached
299}
194}
300
195
301// SetHighlight implements list.Highlightable.
196// SetHighlight implements list.Highlightable.
302func (t *ToolCallItem) SetHighlight(startLine, startCol, endLine, endCol int) {
197func (t *ToolCallItem) SetHighlight(startLine, startCol, endLine, endCol int) {
303 t.BaseHighlightable.SetHighlight(startLine, startCol, endLine, endCol)
198 t.BaseHighlightable.SetHighlight(startLine, startCol, endLine, endCol)
304− // Clear cache when highlight changes
305− t.cache = make(map[int]cachedToolRender)
306}
199}
307
200
308// UpdateResult updates the tool result and invalidates the cache if needed.
201// UpdateResult updates the tool result and invalidates the cache if needed.
309func (t *ToolCallItem) UpdateResult(result message.ToolResult) {
202func (t *ToolCallItem) UpdateResult(result message.ToolResult) {
310− newKey := generateCacheKey(t.toolCall, result, t.cancelled)
311− if newKey != t.cacheKey {
312− t.toolResult = result
203+ t.toolResult = result
313− t.cacheKey = newKey
314− t.cache = make(map[int]cachedToolRender)
315− }
316}
204}
317
205
318// AttachmentItem represents a file attachment in a user message.
206// AttachmentItem represents a file attachment in a user message.
319type AttachmentItem struct {
207type AttachmentItem struct {
320− list.BaseFocusable
208+ BaseFocusable
321− list.BaseHighlightable
209+ BaseHighlightable
322 id string
210 id string
323 filename string
211 filename string
324 path string
212 path string
@@ -342,33 +230,29 @@ func (a *AttachmentItem) ID() string {
342 return a.id
230 return a.id
343}
231}
344
232
345−// Height implements list.Item.
233+// FocusStyle returns the focus style.
346−func (a *AttachmentItem) Height(width int) int {
234+func (a *AttachmentItem) FocusStyle() lipgloss.Style {
235+ if a.focusStyle != nil {
347− return 1
236+ return *a.focusStyle
237+ }
238+ return lipgloss.Style{}
348}
239}
349
240
350−// Render implements lazylist.Item.
241+// BlurStyle returns the blur style.
351−func (a *AttachmentItem) Render(width int) string {
242+func (a *AttachmentItem) BlurStyle() lipgloss.Style {
352− const maxFilenameWidth = 10
243+ if a.blurStyle != nil {
353− return a.sty.Chat.Message.Attachment.Render(fmt.Sprintf(
244+ return *a.blurStyle
354− " %s %s ",
245+ }
355− styles.DocumentIcon,
246+ return lipgloss.Style{}
356− ansi.Truncate(a.filename, maxFilenameWidth, "..."),
357− ))
358}
247}
359
248
360−// Draw implements list.Item.
249+// HighlightStyle returns the highlight style.
361−func (a *AttachmentItem) Draw(scr uv.Screen, area uv.Rectangle) {
250+func (a *AttachmentItem) HighlightStyle() lipgloss.Style {
362− width := area.Dx()
251+ return a.sty.TextSelection
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− }
252+}
371
253
254+// Render implements lazylist.Item.
255+func (a *AttachmentItem) Render(width int) string {
372 const maxFilenameWidth = 10
256 const maxFilenameWidth = 10
373 content := a.sty.Chat.Message.Attachment.Render(fmt.Sprintf(
257 content := a.sty.Chat.Message.Attachment.Render(fmt.Sprintf(
374 " %s %s ",
258 " %s %s ",
@@ -376,28 +260,18 @@ func (a *AttachmentItem) Draw(scr uv.Screen, area uv.Rectangle) {
376 ansi.Truncate(a.filename, maxFilenameWidth, "..."),
260 ansi.Truncate(a.filename, maxFilenameWidth, "..."),
377 ))
261 ))
378
262
379− if style != nil {
263+ return content
380− content = style.Render(content)
381− }
382
264
383− tempBuf := uv.NewScreenBuffer(width, height)
265+ // return a.RenderWithHighlight(content, width, a.CurrentStyle())
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}
266}
390
267
391// ThinkingItem represents thinking/reasoning content in assistant messages.
268// ThinkingItem represents thinking/reasoning content in assistant messages.
392type ThinkingItem struct {
269type ThinkingItem struct {
393− list.BaseFocusable
394− list.BaseHighlightable
395 id string
270 id string
396 thinking string
271 thinking string
397 duration time.Duration
272 duration time.Duration
398 finished bool
273 finished bool
399 maxWidth int
274 maxWidth int
400− cache map[int]string
401 sty *styles.Styles
275 sty *styles.Styles
402}
276}
403
277
@@ -409,10 +283,8 @@ func NewThinkingItem(id, thinking string, duration time.Duration, finished bool,
409 duration: duration,
283 duration: duration,
410 finished: finished,
284 finished: finished,
411 maxWidth: 120,
285 maxWidth: 120,
412− cache: make(map[int]string),
413 sty: sty,
286 sty: sty,
414 }
287 }
415− t.InitHighlight()
416 return t
288 return t
417}
289}
418
290
@@ -421,57 +293,25 @@ func (t *ThinkingItem) ID() string {
421 return t.id
293 return t.id
422}
294}
423
295
424−// Height implements list.Item.
296+// FocusStyle returns the focus style.
425−func (t *ThinkingItem) Height(width int) int {
297+func (t *ThinkingItem) FocusStyle() lipgloss.Style {
426− // Calculate content width accounting for frame size
298+ return t.sty.Chat.Message.AssistantFocused
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}
299}
435
300
436−// Render implements lazylist.Item.
301+// BlurStyle returns the blur style.
437−func (t *ThinkingItem) Render(width int) string {
302+func (t *ThinkingItem) BlurStyle() lipgloss.Style {
438− return t.render(width)
303+ return t.sty.Chat.Message.AssistantBlurred
439}
304}
440
305
441−// Draw implements list.Item.
306+// HighlightStyle returns the highlight style.
442−func (t *ThinkingItem) Draw(scr uv.Screen, area uv.Rectangle) {
307+func (t *ThinkingItem) HighlightStyle() lipgloss.Style {
443− width := area.Dx()
308+ return t.sty.TextSelection
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}
309}
466
310
467−// render renders the thinking content.
311+// Render implements lazylist.Item.
468−func (t *ThinkingItem) render(width int) string {
312+func (t *ThinkingItem) Render(width int) string {
469 cappedWidth := min(width, t.maxWidth)
313 cappedWidth := min(width, t.maxWidth)
470
314
471− if cached, ok := t.cache[cappedWidth]; ok {
472− return cached
473− }
474−
475 renderer := common.PlainMarkdownRenderer(cappedWidth - 1)
315 renderer := common.PlainMarkdownRenderer(cappedWidth - 1)
476 rendered, err := renderer.Render(t.thinking)
316 rendered, err := renderer.Render(t.thinking)
477 if err != nil {
317 if err != nil {
@@ -501,20 +341,11 @@ func (t *ThinkingItem) render(width int) string {
501
341
502 result := t.sty.PanelMuted.Width(cappedWidth).Padding(0, 1).Render(fullContent)
342 result := t.sty.PanelMuted.Width(cappedWidth).Padding(0, 1).Render(fullContent)
503
343
504− t.cache[cappedWidth] = result
505 return result
344 return result
506}
345}
507
346
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−
514// SectionHeaderItem represents a section header (e.g., assistant info).
347// SectionHeaderItem represents a section header (e.g., assistant info).
515type SectionHeaderItem struct {
348type SectionHeaderItem struct {
516− list.BaseFocusable
517− list.BaseHighlightable
518 id string
349 id string
519 modelName string
350 modelName string
520 duration time.Duration
351 duration time.Duration
@@ -531,7 +362,6 @@ func NewSectionHeaderItem(id, modelName string, duration time.Duration, sty *sty
531 isSectionHeader: true,
362 isSectionHeader: true,
532 sty: sty,
363 sty: sty,
533 }
364 }
534− s.InitHighlight()
535 return s
365 return s
536}
366}
537
367
@@ -545,49 +375,25 @@ func (s *SectionHeaderItem) IsSectionHeader() bool {
545 return s.isSectionHeader
375 return s.isSectionHeader
546}
376}
547
377
548−// Height implements list.Item.
378+// FocusStyle returns the focus style.
549−func (s *SectionHeaderItem) Height(width int) int {
379+func (s *SectionHeaderItem) FocusStyle() lipgloss.Style {
550− return 1
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
551}
386}
552
387
553// Render implements lazylist.Item.
388// Render implements lazylist.Item.
554func (s *SectionHeaderItem) Render(width int) string {
389func (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",
556 s.sty.Subtle.Render(styles.ModelIcon),
391 s.sty.Subtle.Render(styles.ModelIcon),
557 s.sty.Muted.Render(s.modelName),
392 s.sty.Muted.Render(s.modelName),
558 s.sty.Subtle.Render(s.duration.String()),
393 s.sty.Subtle.Render(s.duration.String()),
559 ))
394 ))
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− }
584
395
585− tempBuf := uv.NewScreenBuffer(width, height)
396+ return content
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}
397}
592
398
593// GetMessageItems extracts [MessageItem]s from a [message.Message]. It returns
399// GetMessageItems extracts [MessageItem]s from a [message.Message]. It returns
@@ -595,8 +401,7 @@ func (s *SectionHeaderItem) Draw(scr uv.Screen, area uv.Rectangle) {
595//
401//
596// For assistant messages with tool calls, pass a toolResults map to link results.
402// 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.
403// Use BuildToolResultMap to create this map from all messages in a session.
598−func GetMessageItems(msg *message.Message, toolResults map[string]message.ToolResult) []MessageItem {
404+func GetMessageItems(sty *styles.Styles, msg *message.Message, toolResults map[string]message.ToolResult) []MessageItem {
599− sty := styles.DefaultStyles()
600 var items []MessageItem
405 var items []MessageItem
601
406
602 // Skip tool result messages - they're displayed inline with tool calls
407 // 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
622 item := NewMessageContentItem(
427 item := NewMessageContentItem(
623 fmt.Sprintf("%s-content", msg.ID),
428 fmt.Sprintf("%s-content", msg.ID),
624 content,
429 content,
430+ msg.Role,
625 true, // User messages are markdown
431 true, // User messages are markdown
626− &sty,
432+ sty,
627 )
433 )
628− item.SetFocusStyles(&focusStyle, &blurStyle)
629 items = append(items, item)
434 items = append(items, item)
630 }
435 }
631
436
@@ -636,8 +441,9 @@ func GetMessageItems(msg *message.Message, toolResults map[string]message.ToolRe
636 fmt.Sprintf("%s-attachment-%d", msg.ID, i),
441 fmt.Sprintf("%s-attachment-%d", msg.ID, i),
637 filename,
442 filename,
638 attachment.Path,
443 attachment.Path,
639− &sty,
444+ sty,
640 )
445 )
446+ item.SetHighlightStyle(ToStyler(sty.TextSelection))
641 item.SetFocusStyles(&focusStyle, &blurStyle)
447 item.SetFocusStyles(&focusStyle, &blurStyle)
642 items = append(items, item)
448 items = append(items, item)
643 }
449 }
@@ -666,7 +472,7 @@ func GetMessageItems(msg *message.Message, toolResults map[string]message.ToolRe
666 fmt.Sprintf("%s-header", msg.ID),
472 fmt.Sprintf("%s-header", msg.ID),
667 modelName,
473 modelName,
668 duration,
474 duration,
669− &sty,
475+ sty,
670 )
476 )
671 items = append(items, header)
477 items = append(items, header)
672 }
478 }
@@ -684,9 +490,8 @@ func GetMessageItems(msg *message.Message, toolResults map[string]message.ToolRe
684 reasoning.Thinking,
490 reasoning.Thinking,
685 duration,
491 duration,
686 reasoning.FinishedAt > 0,
492 reasoning.FinishedAt > 0,
687− &sty,
493+ sty,
688 )
494 )
689− item.SetFocusStyles(&focusStyle, &blurStyle)
690 items = append(items, item)
495 items = append(items, item)
691 }
496 }
692
497
@@ -703,10 +508,10 @@ func GetMessageItems(msg *message.Message, toolResults map[string]message.ToolRe
703 item := NewMessageContentItem(
508 item := NewMessageContentItem(
704 fmt.Sprintf("%s-content", msg.ID),
509 fmt.Sprintf("%s-content", msg.ID),
705 "*Canceled*",
510 "*Canceled*",
511+ msg.Role,
706 true,
512 true,
707− &sty,
513+ sty,
708 )
514 )
709− item.SetFocusStyles(&focusStyle, &blurStyle)
710 items = append(items, item)
515 items = append(items, item)
711 case message.FinishReasonError:
516 case message.FinishReasonError:
712 // Render error
517 // Render error
@@ -719,20 +524,20 @@ func GetMessageItems(msg *message.Message, toolResults map[string]message.ToolRe
719 item := NewMessageContentItem(
524 item := NewMessageContentItem(
720 fmt.Sprintf("%s-error", msg.ID),
525 fmt.Sprintf("%s-error", msg.ID),
721 errorContent,
526 errorContent,
527+ msg.Role,
722 false,
528 false,
723− &sty,
529+ sty,
724 )
530 )
725− item.SetFocusStyles(&focusStyle, &blurStyle)
726 items = append(items, item)
531 items = append(items, item)
727 }
532 }
728 } else if content != "" {
533 } else if content != "" {
729 item := NewMessageContentItem(
534 item := NewMessageContentItem(
730 fmt.Sprintf("%s-content", msg.ID),
535 fmt.Sprintf("%s-content", msg.ID),
731 content,
536 content,
537+ msg.Role,
732 true, // Assistant messages are markdown
538 true, // Assistant messages are markdown
733− &sty,
539+ sty,
734 )
540 )
735− item.SetFocusStyles(&focusStyle, &blurStyle)
736 items = append(items, item)
541 items = append(items, item)
737 }
542 }
738
543
@@ -757,9 +562,11 @@ func GetMessageItems(msg *message.Message, toolResults map[string]message.ToolRe
757 result,
562 result,
758 false, // cancelled state would need to be tracked separately
563 false, // cancelled state would need to be tracked separately
759 false, // nested state would be detected from tool results
564 false, // nested state would be detected from tool results
760− &sty,
565+ sty,
761 )
566 )
762
567
568+ item.SetHighlightStyle(ToStyler(sty.TextSelection))
569+
763 // Tool calls use muted style with optional focus border
570 // Tool calls use muted style with optional focus border
764 item.SetFocusStyles(&sty.Chat.Message.ToolCallFocused, &sty.Chat.Message.ToolCallBlurred)
571 item.SetFocusStyles(&sty.Chat.Message.ToolCallFocused, &sty.Chat.Message.ToolCallBlurred)
765
572
@@ -788,3 +595,299 @@ func BuildToolResultMap(messages []*message.Message) map[string]message.ToolResu
788 }
595 }
789 return resultMap
596 return resultMap
790}
597}
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+}
internal/ui/model/ui.go+19−7
@@ -7,7 +7,6 @@ import (
7 "os"
7 "os"
8 "slices"
8 "slices"
9 "strings"
9 "strings"
10− "time"
11
10
12 "charm.land/bubbles/v2/help"
11 "charm.land/bubbles/v2/help"
13 "charm.land/bubbles/v2/key"
12 "charm.land/bubbles/v2/key"
@@ -171,8 +170,8 @@ func (m *UI) Init() tea.Cmd {
171 allSessions, _ := m.com.App.Sessions.List(context.Background())
170 allSessions, _ := m.com.App.Sessions.List(context.Background())
172 if len(allSessions) > 0 {
171 if len(allSessions) > 0 {
173 cmds = append(cmds, func() tea.Msg {
172 cmds = append(cmds, func() tea.Msg {
174− time.Sleep(2 * time.Second)
173+ // time.Sleep(2 * time.Second)
175− return m.loadSession(allSessions[1].ID)()
174+ return m.loadSession(allSessions[0].ID)()
176 })
175 })
177 }
176 }
178 return tea.Batch(cmds...)
177 return tea.Batch(cmds...)
@@ -207,7 +206,7 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
207 // Add messages to chat with linked tool results
206 // Add messages to chat with linked tool results
208 items := make([]MessageItem, 0, len(msgs)*2)
207 items := make([]MessageItem, 0, len(msgs)*2)
209 for _, msg := range msgPtrs {
208 for _, msg := range msgPtrs {
210− items = append(items, GetMessageItems(msg, toolResultMap)...)
209+ items = append(items, GetMessageItems(m.com.Styles, msg, toolResultMap)...)
211 }
210 }
212 m.chat.AppendMessages(items...)
211 m.chat.AppendMessages(items...)
213
212
@@ -247,7 +246,11 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
247 case tea.MouseClickMsg:
246 case tea.MouseClickMsg:
248 switch m.state {
247 switch m.state {
249 case uiChat:
248 case uiChat:
249+ x, y := msg.X, msg.Y
250+ // Adjust for chat area position
251+ x -= m.layout.main.Min.X
252+ y -= m.layout.main.Min.Y
250− m.chat.HandleMouseDown(msg.X, msg.Y)
253+ m.chat.HandleMouseDown(x, y)
251 }
254 }
252
255
253 case tea.MouseMotionMsg:
256 case tea.MouseMotionMsg:
@@ -258,13 +261,22 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
258 } else if msg.Y >= m.chat.Height()-1 {
261 } else if msg.Y >= m.chat.Height()-1 {
259 m.chat.ScrollBy(1)
262 m.chat.ScrollBy(1)
260 }
263 }
264+
265+ x, y := msg.X, msg.Y
266+ // Adjust for chat area position
267+ x -= m.layout.main.Min.X
268+ y -= m.layout.main.Min.Y
261− m.chat.HandleMouseDrag(msg.X, msg.Y)
269+ m.chat.HandleMouseDrag(x, y)
262 }
270 }
263
271
264 case tea.MouseReleaseMsg:
272 case tea.MouseReleaseMsg:
265 switch m.state {
273 switch m.state {
266 case uiChat:
274 case uiChat:
275+ x, y := msg.X, msg.Y
276+ // Adjust for chat area position
277+ x -= m.layout.main.Min.X
278+ y -= m.layout.main.Min.Y
267− m.chat.HandleMouseUp(msg.X, msg.Y)
279+ m.chat.HandleMouseUp(x, y)
268 }
280 }
269 case tea.MouseWheelMsg:
281 case tea.MouseWheelMsg:
270 switch m.state {
282 switch m.state {