on main help sign in
Merge pull request #1938 from charmbracelet/ui-hi-copy

ui: copy mouse highlighted content to clipboard

commit49c37c6fecf7b570cc5124b493c6d6bf5bd0d49c authorAyman Bagabas <ayman.bagabas@gmail.com> dateWed 21 Jan 2026, 15:47 (9 months ago) parent158f7a9a5c597b
12 files changed+236−60
internal/ui/chat/assistant.go+13−8 internal/ui/chat/messages.go+17−8 internal/ui/chat/tools.go+17−12 internal/ui/chat/user.go+13−9 internal/ui/dialog/models_list.go+1 internal/ui/list/filterable.go+1 internal/ui/list/focus.go+13 internal/ui/list/highlight.go+49−9 internal/ui/list/item.go+13−3 internal/ui/list/list.go+15−5 internal/ui/model/chat.go+46−4 internal/ui/model/ui.go+38−2
internal/ui/chat/assistant.go+13−8
@@ -77,13 +77,9 @@ func (a *AssistantMessageItem) ID() string {
7777 return a.message.ID
7878}
7979
80−// Render implements MessageItem.
81−func (a *AssistantMessageItem) Render(width int) string {
80+// RawRender implements [MessageItem].
81+func (a *AssistantMessageItem) RawRender(width int) string {
8282 cappedWidth := cappedMessageWidth(width)
83− style := a.sty.Chat.Message.AssistantBlurred
84− if a.focused {
85− style = a.sty.Chat.Message.AssistantFocused
86− }
8783
8884 var spinner string
8985 if a.isSpinning() {
@@ -103,10 +99,19 @@ func (a *AssistantMessageItem) Render(width int) string {
10399 if highlightedContent != "" {
104100 highlightedContent += "\n\n"
105101 }
106− return style.Render(highlightedContent + spinner)
102+ return highlightedContent + spinner
107103 }
108104
109− return style.Render(highlightedContent)
105+ return highlightedContent
106+}
107+
108+// Render implements MessageItem.
109+func (a *AssistantMessageItem) Render(width int) string {
110+ style := a.sty.Chat.Message.AssistantBlurred
111+ if a.focused {
112+ style = a.sty.Chat.Message.AssistantFocused
113+ }
114+ return style.Render(a.RawRender(width))
110115}
111116
112117// renderMessageContent renders the message content including thinking, main content, and finish reason.
internal/ui/chat/messages.go+17−8
@@ -1,6 +1,3 @@
1−// Package chat provides UI components for displaying and managing chat messages.
2−// It defines message item types that can be rendered in a list view, including
3−// support for highlighting, focusing, and caching rendered content.
41package chat
52
63import (
@@ -48,6 +45,7 @@ type Expandable interface {
4845// UI and be part of a [list.List] identifiable by a unique ID.
4946type MessageItem interface {
5047 list.Item
48+ list.RawRenderable
5149 Identifiable
5250}
5351
@@ -77,6 +75,8 @@ type highlightableMessageItem struct {
7775 highlighter list.Highlighter
7876}
7977
78+var _ list.Highlightable = (*highlightableMessageItem)(nil)
79+
8080// isHighlighted returns true if the item has a highlight range set.
8181func (h *highlightableMessageItem) isHighlighted() bool {
8282 return h.startLine != -1 || h.endLine != -1
@@ -91,8 +91,8 @@ func (h *highlightableMessageItem) renderHighlighted(content string, width, heig
9191 return list.Highlight(content, area, h.startLine, h.startCol, h.endLine, h.endCol, h.highlighter)
9292}
9393
94−// Highlight implements MessageItem.
95−func (h *highlightableMessageItem) Highlight(startLine int, startCol int, endLine int, endCol int) {
94+// SetHighlight implements list.Highlightable.
95+func (h *highlightableMessageItem) SetHighlight(startLine int, startCol int, endLine int, endCol int) {
9696 // Adjust columns for the style's left inset (border + padding) since we
9797 // highlight the content only.
9898 offset := messageLeftPaddingTotal
@@ -106,6 +106,11 @@ func (h *highlightableMessageItem) Highlight(startLine int, startCol int, endLin
106106 }
107107}
108108
109+// Highlight implements list.Highlightable.
110+func (h *highlightableMessageItem) Highlight() (startLine int, startCol int, endLine int, endCol int) {
111+ return h.startLine, h.startCol, h.endLine, h.endCol
112+}
113+
109114func defaultHighlighter(sty *styles.Styles) *highlightableMessageItem {
110115 return &highlightableMessageItem{
111116 startLine: -1,
@@ -193,8 +198,8 @@ func (a *AssistantInfoItem) ID() string {
193198 return a.id
194199}
195200
196−// Render implements MessageItem.
197−func (a *AssistantInfoItem) Render(width int) string {
201+// RawRender implements MessageItem.
202+func (a *AssistantInfoItem) RawRender(width int) string {
198203 innerWidth := max(0, width-messageLeftPaddingTotal)
199204 content, _, ok := a.getCachedRender(innerWidth)
200205 if !ok {
@@ -202,8 +207,12 @@ func (a *AssistantInfoItem) Render(width int) string {
202207 height := lipgloss.Height(content)
203208 a.setCachedRender(content, innerWidth, height)
204209 }
210+ return content
211+}
205212
206− return a.sty.Chat.Message.SectionHeader.Render(content)
213+// Render implements MessageItem.
214+func (a *AssistantInfoItem) Render(width int) string {
215+ return a.sty.Chat.Message.SectionHeader.Render(a.RawRender(width))
207216}
208217
209218func (a *AssistantInfoItem) renderContent(width int) string {
internal/ui/chat/tools.go+17−12
@@ -287,20 +287,12 @@ func (t *baseToolMessageItem) Animate(msg anim.StepMsg) tea.Cmd {
287287 return t.anim.Animate(msg)
288288}
289289
290−// Render renders the tool message item at the given width.
291−func (t *baseToolMessageItem) Render(width int) string {
290+// RawRender implements [MessageItem].
291+func (t *baseToolMessageItem) RawRender(width int) string {
292292 toolItemWidth := width - messageLeftPaddingTotal
293293 if t.hasCappedWidth {
294294 toolItemWidth = cappedMessageWidth(width)
295295 }
296− style := t.sty.Chat.Message.ToolCallBlurred
297− if t.focused {
298− style = t.sty.Chat.Message.ToolCallFocused
299− }
300−
301− if t.isCompact {
302− style = t.sty.Chat.Message.ToolCallCompact
303− }
304296
305297 content, height, ok := t.getCachedRender(toolItemWidth)
306298 // if we are spinning or there is no cache rerender
@@ -319,8 +311,21 @@ func (t *baseToolMessageItem) Render(width int) string {
319311 t.setCachedRender(content, toolItemWidth, height)
320312 }
321313
322− highlightedContent := t.renderHighlighted(content, toolItemWidth, height)
323− return style.Render(highlightedContent)
314+ return t.renderHighlighted(content, toolItemWidth, height)
315+}
316+
317+// Render renders the tool message item at the given width.
318+func (t *baseToolMessageItem) Render(width int) string {
319+ style := t.sty.Chat.Message.ToolCallBlurred
320+ if t.focused {
321+ style = t.sty.Chat.Message.ToolCallFocused
322+ }
323+
324+ if t.isCompact {
325+ style = t.sty.Chat.Message.ToolCallCompact
326+ }
327+
328+ return style.Render(t.RawRender(width))
324329}
325330
326331// ToolCall returns the tool call associated with this message item.
internal/ui/chat/user.go+13−9
@@ -33,19 +33,14 @@ func NewUserMessageItem(sty *styles.Styles, message *message.Message, attachment
3333 }
3434}
3535
36−// Render implements MessageItem.
37−func (m *UserMessageItem) Render(width int) string {
36+// RawRender implements [MessageItem].
37+func (m *UserMessageItem) RawRender(width int) string {
3838 cappedWidth := cappedMessageWidth(width)
3939
40− style := m.sty.Chat.Message.UserBlurred
41− if m.focused {
42− style = m.sty.Chat.Message.UserFocused
43− }
44−
4540 content, height, ok := m.getCachedRender(cappedWidth)
4641 // cache hit
4742 if ok {
48− return style.Render(m.renderHighlighted(content, cappedWidth, height))
43+ return m.renderHighlighted(content, cappedWidth, height)
4944 }
5045
5146 renderer := common.MarkdownRenderer(m.sty, cappedWidth)
@@ -69,7 +64,16 @@ func (m *UserMessageItem) Render(width int) string {
6964
7065 height = lipgloss.Height(content)
7166 m.setCachedRender(content, cappedWidth, height)
72− return style.Render(m.renderHighlighted(content, cappedWidth, height))
67+ return m.renderHighlighted(content, cappedWidth, height)
68+}
69+
70+// Render implements MessageItem.
71+func (m *UserMessageItem) Render(width int) string {
72+ style := m.sty.Chat.Message.UserBlurred
73+ if m.focused {
74+ style = m.sty.Chat.Message.UserFocused
75+ }
76+ return style.Render(m.RawRender(width))
7377}
7478
7579// ID implements MessageItem.
internal/ui/dialog/models_list.go+1
@@ -26,6 +26,7 @@ func NewModelsList(sty *styles.Styles, groups ...ModelGroup) *ModelsList {
2626 groups: groups,
2727 t: sty,
2828 }
29+ f.RegisterRenderCallback(list.FocusedRenderCallback(f.List))
2930 return f
3031}
3132
internal/ui/list/filterable.go+1
@@ -31,6 +31,7 @@ func NewFilterableList(items ...FilterableItem) *FilterableList {
3131 List: NewList(),
3232 items: items,
3333 }
34+ f.RegisterRenderCallback(FocusedRenderCallback(f.List))
3435 f.SetItems(items...)
3536 return f
3637}
internal/ui/list/focus.gonew file+13
@@ -0,0 +1,13 @@
1+package list
2+
3+// FocusedRenderCallback is a helper function that returns a render callback
4+// that marks items as focused during rendering.
5+func FocusedRenderCallback(list *List) RenderCallback {
6+ return func(idx, selectedIdx int, item Item) Item {
7+ if focusable, ok := item.(Focusable); ok {
8+ focusable.SetFocused(list.Focused() && idx == selectedIdx)
9+ return focusable.(Item)
10+ }
11+ return item
12+ }
13+}
internal/ui/list/highlight.go+49−9
@@ -2,25 +2,60 @@ package list
22
33import (
44 "image"
5+ "strings"
56
67 "charm.land/lipgloss/v2"
78 uv "github.com/charmbracelet/ultraviolet"
89)
910
1011// 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
12+var DefaultHighlighter Highlighter = func(x, y int, c *uv.Cell) *uv.Cell {
13+ if c == nil {
14+ return c
15+ }
16+ c.Style.Attrs |= uv.AttrReverse
17+ return c
1418}
1519
1620// Highlighter represents a function that defines how to highlight text.
17−type Highlighter func(uv.Style) uv.Style
21+type Highlighter func(x, y int, c *uv.Cell) *uv.Cell
22+
23+// HighlightContent returns the content with highlighted regions based on the specified parameters.
24+func HighlightContent(content string, area image.Rectangle, startLine, startCol, endLine, endCol int) string {
25+ var sb strings.Builder
26+ pos := image.Pt(-1, -1)
27+ HighlightBuffer(content, area, startLine, startCol, endLine, endCol, func(x, y int, c *uv.Cell) *uv.Cell {
28+ pos.X = x
29+ if pos.Y == -1 {
30+ pos.Y = y
31+ } else if y > pos.Y {
32+ sb.WriteString(strings.Repeat("\n", y-pos.Y))
33+ pos.Y = y
34+ }
35+ sb.WriteString(c.Content)
36+ return c
37+ })
38+ if sb.Len() > 0 {
39+ sb.WriteString("\n")
40+ }
41+ return sb.String()
42+}
1843
1944// Highlight highlights a region of text within the given content and region.
2045func Highlight(content string, area image.Rectangle, startLine, startCol, endLine, endCol int, highlighter Highlighter) string {
21− if startLine < 0 || startCol < 0 {
46+ buf := HighlightBuffer(content, area, startLine, startCol, endLine, endCol, highlighter)
47+ if buf == nil {
2248 return content
2349 }
50+ return buf.Render()
51+}
52+
53+// HighlightBuffer highlights a region of text within the given content and
54+// region, returning a [uv.ScreenBuffer].
55+func HighlightBuffer(content string, area image.Rectangle, startLine, startCol, endLine, endCol int, highlighter Highlighter) *uv.ScreenBuffer {
56+ if startLine < 0 || startCol < 0 {
57+ return nil
58+ }
2459
2560 if highlighter == nil {
2661 highlighter = DefaultHighlighter
@@ -87,17 +122,22 @@ func Highlight(content string, area image.Rectangle, startLine, startCol, endLin
87122 continue
88123 }
89124 cell := line.At(x)
90− cell.Style = highlighter(cell.Style)
125+ if cell != nil {
126+ line.Set(x, highlighter(x, y, cell))
127+ }
91128 }
92129 }
93130
94− return buf.Render()
131+ return &buf
95132}
96133
97134// ToHighlighter converts a [lipgloss.Style] to a [Highlighter].
98135func ToHighlighter(lgStyle lipgloss.Style) Highlighter {
99− return func(uv.Style) uv.Style {
100− return ToStyle(lgStyle)
136+ return func(_ int, _ int, c *uv.Cell) *uv.Cell {
137+ if c != nil {
138+ c.Style = ToStyle(lgStyle)
139+ }
140+ return c
101141 }
102142}
103143
internal/ui/list/item.go+13−3
@@ -13,6 +13,14 @@ type Item interface {
1313 Render(width int) string
1414}
1515
16+// RawRenderable represents an item that can provide a raw rendering
17+// without additional styling.
18+type RawRenderable interface {
19+ // RawRender returns the raw rendered string without any additional
20+ // styling.
21+ RawRender(width int) string
22+}
23+
1624// Focusable represents an item that can be aware of focus state changes.
1725type Focusable interface {
1826 // SetFocused sets the focus state of the item.
@@ -21,9 +29,11 @@ type Focusable interface {
2129
2230// Highlightable represents an item that can highlight a portion of its content.
2331type Highlightable interface {
24− // Highlight highlights the content from the given start to end positions.
25− // Use -1 for no highlight.
26− Highlight(startLine, startCol, endLine, endCol int)
32+ // SetHighlight highlights the content from the given start to end
33+ // positions. Use -1 for no highlight.
34+ SetHighlight(startLine, startCol, endLine, endCol int)
35+ // Highlight returns the current highlight positions within the item.
36+ Highlight() (startLine, startCol, endLine, endCol int)
2737}
2838
2939// MouseClickable represents an item that can handle mouse click events.
internal/ui/list/list.go+15−5
@@ -49,9 +49,13 @@ func NewList(items ...Item) *List {
4949 return l
5050}
5151
52+// RenderCallback defines a function that can modify an item before it is
53+// rendered.
54+type RenderCallback func(idx, selectedIdx int, item Item) Item
55+
5256// RegisterRenderCallback registers a callback to be called when rendering
5357// items. This can be used to modify items before they are rendered.
54−func (l *List) RegisterRenderCallback(cb func(idx, selectedIdx int, item Item) Item) {
58+func (l *List) RegisterRenderCallback(cb RenderCallback) {
5559 l.renderCallbacks = append(l.renderCallbacks, cb)
5660}
5761
@@ -66,6 +70,11 @@ func (l *List) SetGap(gap int) {
6670 l.gap = gap
6771}
6872
73+// Gap returns the gap between items.
74+func (l *List) Gap() int {
75+ return l.gap
76+}
77+
6978// SetReverse shows the list in reverse order.
7079func (l *List) SetReverse(reverse bool) {
7180 l.reverse = reverse
@@ -101,10 +110,6 @@ func (l *List) getItem(idx int) renderedItem {
101110 }
102111 }
103112
104− if focusable, isFocusable := item.(Focusable); isFocusable {
105− focusable.SetFocused(l.focused && idx == l.selectedIdx)
106− }
107−
108113 rendered := item.Render(l.width)
109114 rendered = strings.TrimRight(rendered, "\n")
110115 height := countLines(rendered)
@@ -348,6 +353,11 @@ func (l *List) RemoveItem(idx int) {
348353 }
349354}
350355
356+// Focused returns whether the list is focused.
357+func (l *List) Focused() bool {
358+ return l.focused
359+}
360+
351361// Focus sets the focus state of the list.
352362func (l *List) Focus() {
353363 l.focused = true
internal/ui/model/chat.go+46−4
@@ -1,7 +1,10 @@
11package model
22
33import (
4+ "strings"
5+
46 tea "charm.land/bubbletea/v2"
7+ "charm.land/lipgloss/v2"
58 "github.com/charmbracelet/crush/internal/ui/anim"
69 "github.com/charmbracelet/crush/internal/ui/chat"
710 "github.com/charmbracelet/crush/internal/ui/common"
@@ -43,6 +46,7 @@ func NewChat(com *common.Common) *Chat {
4346 l := list.NewList()
4447 l.SetGap(1)
4548 l.RegisterRenderCallback(c.applyHighlightRange)
49+ l.RegisterRenderCallback(list.FocusedRenderCallback(l))
4650 c.list = l
4751 c.mouseDownItem = -1
4852 c.mouseDragItem = -1
@@ -445,9 +449,6 @@ func (m *Chat) HandleMouseUp(x, y int) bool {
445449 return false
446450 }
447451
448− // TODO: Handle the behavior when mouse is released after a drag selection
449− // (e.g., copy selected text to clipboard)
450−
451452 m.mouseDown = false
452453 return true
453454}
@@ -474,6 +475,47 @@ func (m *Chat) HandleMouseDrag(x, y int) bool {
474475 return true
475476}
476477
478+// HasHighlight returns whether there is currently highlighted content.
479+func (m *Chat) HasHighlight() bool {
480+ startItemIdx, startLine, startCol, endItemIdx, endLine, endCol := m.getHighlightRange()
481+ return startItemIdx >= 0 && endItemIdx >= 0 && (startLine != endLine || startCol != endCol)
482+}
483+
484+// HighlighContent returns the currently highlighted content based on the mouse
485+// selection. It returns an empty string if no content is highlighted.
486+func (m *Chat) HighlighContent() string {
487+ startItemIdx, startLine, startCol, endItemIdx, endLine, endCol := m.getHighlightRange()
488+ if startItemIdx < 0 || endItemIdx < 0 || startLine == endLine && startCol == endCol {
489+ return ""
490+ }
491+
492+ var sb strings.Builder
493+ for i := startItemIdx; i <= endItemIdx; i++ {
494+ item := m.list.ItemAt(i)
495+ if hi, ok := item.(list.Highlightable); ok {
496+ startLine, startCol, endLine, endCol := hi.Highlight()
497+ listWidth := m.list.Width()
498+ var rendered string
499+ if rr, ok := item.(list.RawRenderable); ok {
500+ rendered = rr.RawRender(listWidth)
501+ } else {
502+ rendered = item.Render(listWidth)
503+ }
504+ sb.WriteString(list.HighlightContent(
505+ rendered,
506+ uv.Rect(0, 0, listWidth, lipgloss.Height(rendered)),
507+ startLine,
508+ startCol,
509+ endLine,
510+ endCol,
511+ ))
512+ sb.WriteString(strings.Repeat("\n", m.list.Gap()))
513+ }
514+ }
515+
516+ return strings.TrimSpace(sb.String())
517+}
518+
477519// ClearMouse clears the current mouse interaction state.
478520func (m *Chat) ClearMouse() {
479521 m.mouseDown = false
@@ -515,7 +557,7 @@ func (m *Chat) applyHighlightRange(idx, selectedIdx int, item list.Item) list.It
515557 }
516558 }
517559
518− hi.Highlight(sLine, sCol, eLine, eCol)
560+ hi.SetHighlight(sLine, sCol, eLine, eCol)
519561 return hi.(list.Item)
520562 }
521563
internal/ui/model/ui.go+38−2
@@ -23,6 +23,7 @@ import (
2323 "charm.land/bubbles/v2/textarea"
2424 tea "charm.land/bubbletea/v2"
2525 "charm.land/lipgloss/v2"
26+ "github.com/atotto/clipboard"
2627 "github.com/charmbracelet/catwalk/pkg/catwalk"
2728 "github.com/charmbracelet/crush/internal/agent/tools/mcp"
2829 "github.com/charmbracelet/crush/internal/app"
@@ -106,6 +107,9 @@ type (
106107
107108 // closeDialogMsg is sent to close the current dialog.
108109 closeDialogMsg struct{}
110+
111+ // copyChatHighlightMsg is sent to copy the current chat highlight to clipboard.
112+ copyChatHighlightMsg struct{}
109113)
110114
111115// UI represents the main user interface model.
@@ -203,6 +207,9 @@ type UI struct {
203207 // Todo spinner
204208 todoSpinner spinner.Model
205209 todoIsSpinning bool
210+
211+ // mouse highlighting related state
212+ lastClickTime time.Time
206213}
207214
208215// New creates a new instance of the [UI] model.
@@ -484,6 +491,8 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
484491 m.keyMap.Models.SetHelp("ctrl+m", "models")
485492 m.keyMap.Editor.Newline.SetHelp("shift+enter", "newline")
486493 }
494+ case copyChatHighlightMsg:
495+ cmds = append(cmds, m.copyChatHighlight())
487496 case tea.MouseClickMsg:
488497 switch m.state {
489498 case uiChat:
@@ -491,7 +500,9 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
491500 // Adjust for chat area position
492501 x -= m.layout.main.Min.X
493502 y -= m.layout.main.Min.Y
494− m.chat.HandleMouseDown(x, y)
503+ if m.chat.HandleMouseDown(x, y) {
504+ m.lastClickTime = time.Now()
505+ }
495506 }
496507
497508 case tea.MouseMotionMsg:
@@ -527,13 +538,22 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
527538 }
528539
529540 case tea.MouseReleaseMsg:
541+ const doubleClickThreshold = 500 * time.Millisecond
542+
530543 switch m.state {
531544 case uiChat:
532545 x, y := msg.X, msg.Y
533546 // Adjust for chat area position
534547 x -= m.layout.main.Min.X
535548 y -= m.layout.main.Min.Y
536− m.chat.HandleMouseUp(x, y)
549+ if m.chat.HandleMouseUp(x, y) {
550+ cmds = append(cmds, tea.Tick(doubleClickThreshold, func(t time.Time) tea.Msg {
551+ if time.Since(m.lastClickTime) >= doubleClickThreshold {
552+ return copyChatHighlightMsg{}
553+ }
554+ return nil
555+ }))
556+ }
537557 }
538558 case tea.MouseWheelMsg:
539559 // Pass mouse events to dialogs first if any are open.
@@ -2844,6 +2864,22 @@ func (m *UI) runMCPPrompt(clientID, promptID string, arguments map[string]string
28442864 return tea.Sequence(cmds...)
28452865}
28462866
2867+func (m *UI) copyChatHighlight() tea.Cmd {
2868+ text := m.chat.HighlighContent()
2869+ return tea.Sequence(
2870+ tea.SetClipboard(text),
2871+ func() tea.Msg {
2872+ _ = clipboard.WriteAll(text)
2873+ return nil
2874+ },
2875+ func() tea.Msg {
2876+ m.chat.ClearMouse()
2877+ return nil
2878+ },
2879+ uiutil.ReportInfo("Selected text copied to clipboard"),
2880+ )
2881+}
2882+
28472883// renderLogo renders the Crush logo with the given styles and dimensions.
28482884func renderLogo(t *styles.Styles, compact bool, width int) string {
28492885 return logo.Render(version.Version, compact, logo.Opts{