on main help sign in
feat(ui): add text highlighting support to lazylist
commit9c001302115b716d9a00c787f9b61be3668d35a4 authorAyman Bagabas <ayman.bagabas@gmail.com> dateTue 9 Dec 2025, 18:13 (1 year ago) parent1460d75
6 files changed+1121−541
internal/ui/lazylist/highlight.go+289 internal/ui/lazylist/item.go+12−18 internal/ui/lazylist/list.go+393−26 internal/ui/model/chat.go+4−189 internal/ui/model/items.go+404−301 internal/ui/model/ui.go+19−7
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 @@
11package lazylist
22
3+import "charm.land/lipgloss/v2"
4+
35// Item represents a single item in the lazy-loaded list.
46type Item interface {
57 // Render returns the string representation of the item for the given
@@ -7,24 +9,16 @@ type Item interface {
79 Render(width int) string
810}
911
10−// Focusable represents an item that can gain or lose focus.
11−type Focusable interface {
12− // Focus sets the focus state of the item.
13− Focus()
14−
15− // Blur removes the focus state of the item.
16− Blur()
17−
18− // Focused returns whether the item is focused.
19− Focused() bool
12+// FocusStylable represents an item that can be styled based on focus state.
13+type FocusStylable interface {
14+ // FocusStyle returns the style to apply when the item is focused.
15+ FocusStyle() lipgloss.Style
16+ // BlurStyle returns the style to apply when the item is unfocused.
17+ BlurStyle() lipgloss.Style
2018}
2119
22−// Highlightable represents an item that can have a highlighted region.
23−type Highlightable interface {
24− // SetHighlight sets the highlight region (startLine, startCol) to (endLine, endCol).
25− // Use -1 for all values to clear highlighting.
26− SetHighlight(startLine, startCol, endLine, endCol int)
27−
28− // GetHighlight returns the current highlight region.
29− GetHighlight() (startLine, startCol, endLine, endCol int)
20+// HighlightStylable represents an item that can be styled for highlighted regions.
21+type HighlightStylable interface {
22+ // HighlightStyle returns the style to apply for highlighted regions.
23+ HighlightStyle() lipgloss.Style
3024}
internal/ui/lazylist/list.go+393−26
@@ -1,8 +1,11 @@
11package lazylist
22
33import (
4+ "image"
45 "log/slog"
56 "strings"
7+
8+ "charm.land/lipgloss/v2"
69)
710
811// List represents a list of items that can be lazily rendered. A list is
@@ -22,6 +25,16 @@ type List struct {
2225 focused bool
2326 selectedIdx int // The current selected index -1 means no selection
2427
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+
2538 // Rendered content and cache
2639 renderedItems map[int]renderedItem
2740
@@ -44,6 +57,10 @@ func NewList(items ...Item) *List {
4457 l := new(List)
4558 l.items = items
4659 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)
4764 return l
4865}
4966
@@ -79,25 +96,98 @@ func (l *List) Len() int {
7996
8097// getItem renders (if needed) and returns the item at the given index.
8198func (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 {
82105 if idx < 0 || idx >= len(l.items) {
83106 return renderedItem{}
84107 }
85108
86− if item, ok := l.renderedItems[idx]; ok {
87− return item
109+ var style lipgloss.Style
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+ }
88116 }
89117
90− item := l.items[idx]
91− rendered := item.Render(l.width)
92− height := countLines(rendered)
93− // slog.Info("Rendered item", "idx", idx, "height", height)
118+ ri, ok := l.renderedItems[idx]
119+ if !ok {
120+ item := l.items[idx]
121+ rendered := item.Render(l.width - style.GetHorizontalFrameSize())
122+ height := countLines(rendered)
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+ }
94169
95− ri := renderedItem{
96− content: rendered,
97− height: height,
170+ // Apply offset for styling frame
171+ contentArea := image.Rect(0, 0, l.width, ri.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+ }
98182 }
99183
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+ }
101191
102192 return ri
103193}
@@ -242,8 +332,6 @@ func (l *List) Render() string {
242332 return ""
243333 }
244334
245− slog.Info("Render", "offsetIdx", l.offsetIdx, "offsetLine", l.offsetLine, "width", l.width, "height", l.height)
246−
247335 var lines []string
248336 currentIdx := l.offsetIdx
249337 currentOffset := l.offsetLine
@@ -251,7 +339,7 @@ func (l *List) Render() string {
251339 linesNeeded := l.height
252340
253341 for linesNeeded > 0 && currentIdx < len(l.items) {
254− item := l.getItem(currentIdx)
342+ item := l.renderItem(currentIdx, true)
255343 itemLines := strings.Split(item.content, "\n")
256344 itemHeight := len(itemLines)
257345
@@ -321,11 +409,12 @@ func (l *List) Focus() {
321409 return
322410 }
323411
324− item := l.items[l.selectedIdx]
325− if focusable, ok := item.(Focusable); ok {
326− focusable.Focus()
327− l.invalidateItem(l.selectedIdx)
328− }
412+ // item := l.items[l.selectedIdx]
413+ // if focusable, ok := item.(Focusable); ok {
414+ // focusable.Focus()
415+ // l.items[l.selectedIdx] = focusable.(Item)
416+ // l.invalidateItem(l.selectedIdx)
417+ // }
329418}
330419
331420// Blur removes the focus state from the list.
@@ -335,11 +424,12 @@ func (l *List) Blur() {
335424 return
336425 }
337426
338− item := l.items[l.selectedIdx]
339− if focusable, ok := item.(Focusable); ok {
340− focusable.Blur()
341− l.invalidateItem(l.selectedIdx)
342− }
427+ // item := l.items[l.selectedIdx]
428+ // if focusable, ok := item.(Focusable); ok {
429+ // focusable.Blur()
430+ // l.items[l.selectedIdx] = focusable.(Item)
431+ // l.invalidateItem(l.selectedIdx)
432+ // }
343433}
344434
345435// ScrollToTop scrolls the list to the top.
@@ -467,15 +557,292 @@ func (l *List) SelectLastInView() {
467557}
468558
469559// HandleMouseDown handles mouse down events at the given line in the viewport.
470−func (l *List) HandleMouseDown(x, y int) {
560+// x and y are viewport-relative coordinates (0,0 = top-left of visible area).
561+// Returns true if the event was handled.
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
471585}
472586
473587// HandleMouseUp handles mouse up events at the given line in the viewport.
474−func (l *List) HandleMouseUp(x, y int) {
588+// Returns true if the event was handled.
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
475597}
476598
477599// HandleMouseDrag handles mouse drag events at the given line in the viewport.
478−func (l *List) HandleMouseDrag(x, y int) {
600+// x and y are viewport-relative coordinates.
601+// Returns true if the event was handled.
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
479846}
480847
481848// countLines counts the number of lines in a string.
internal/ui/model/chat.go+4−189
@@ -1,198 +1,11 @@
11package model
22
33import (
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"
104 "github.com/charmbracelet/crush/internal/ui/common"
115 "github.com/charmbracelet/crush/internal/ui/lazylist"
12− "github.com/charmbracelet/crush/internal/ui/list"
13− "github.com/charmbracelet/crush/internal/ui/styles"
146 uv "github.com/charmbracelet/ultraviolet"
157)
168
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−
1969// Chat represents the chat UI model that handles chat interactions and
19710// messages.
19811type Chat struct {
@@ -239,9 +52,11 @@ func (m *Chat) PrependItems(items ...lazylist.Item) {
23952
24053// AppendMessages appends a new message item to the chat list.
24154func (m *Chat) AppendMessages(msgs ...MessageItem) {
242− for _, msg := range msgs {
243− m.AppendItems(msg)
55+ items := make([]lazylist.Item, len(msgs))
56+ for i, msg := range msgs {
57+ items[i] = msg
24458 }
59+ m.list.AppendItems(items...)
24560}
24661
24762// AppendItems appends new items to the chat list.
internal/ui/model/items.go+404−301
@@ -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+}
internal/ui/model/ui.go+19−7
@@ -7,7 +7,6 @@ import (
77 "os"
88 "slices"
99 "strings"
10− "time"
1110
1211 "charm.land/bubbles/v2/help"
1312 "charm.land/bubbles/v2/key"
@@ -171,8 +170,8 @@ func (m *UI) Init() tea.Cmd {
171170 allSessions, _ := m.com.App.Sessions.List(context.Background())
172171 if len(allSessions) > 0 {
173172 cmds = append(cmds, func() tea.Msg {
174− time.Sleep(2 * time.Second)
175− return m.loadSession(allSessions[1].ID)()
173+ // time.Sleep(2 * time.Second)
174+ return m.loadSession(allSessions[0].ID)()
176175 })
177176 }
178177 return tea.Batch(cmds...)
@@ -207,7 +206,7 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
207206 // Add messages to chat with linked tool results
208207 items := make([]MessageItem, 0, len(msgs)*2)
209208 for _, msg := range msgPtrs {
210− items = append(items, GetMessageItems(msg, toolResultMap)...)
209+ items = append(items, GetMessageItems(m.com.Styles, msg, toolResultMap)...)
211210 }
212211 m.chat.AppendMessages(items...)
213212
@@ -247,7 +246,11 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
247246 case tea.MouseClickMsg:
248247 switch m.state {
249248 case uiChat:
250− m.chat.HandleMouseDown(msg.X, msg.Y)
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
253+ m.chat.HandleMouseDown(x, y)
251254 }
252255
253256 case tea.MouseMotionMsg:
@@ -258,13 +261,22 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
258261 } else if msg.Y >= m.chat.Height()-1 {
259262 m.chat.ScrollBy(1)
260263 }
261− m.chat.HandleMouseDrag(msg.X, msg.Y)
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
269+ m.chat.HandleMouseDrag(x, y)
262270 }
263271
264272 case tea.MouseReleaseMsg:
265273 switch m.state {
266274 case uiChat:
267− m.chat.HandleMouseUp(msg.X, msg.Y)
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
279+ m.chat.HandleMouseUp(x, y)
268280 }
269281 case tea.MouseWheelMsg:
270282 switch m.state {