Merge pull request #1938 from charmbracelet/ui-hi-copy

Ayman Bagabas created

ui: copy mouse highlighted content to clipboard

Change summary

internal/ui/chat/assistant.go     | 21 +++++++----
internal/ui/chat/messages.go      | 25 +++++++++----
internal/ui/chat/tools.go         | 29 +++++++++------
internal/ui/chat/user.go          | 22 +++++++-----
internal/ui/dialog/models_list.go |  1 
internal/ui/list/filterable.go    |  1 
internal/ui/list/focus.go         | 13 +++++++
internal/ui/list/highlight.go     | 58 +++++++++++++++++++++++++++-----
internal/ui/list/item.go          | 16 +++++++-
internal/ui/list/list.go          | 20 ++++++++--
internal/ui/model/chat.go         | 50 ++++++++++++++++++++++++++--
internal/ui/model/ui.go           | 40 +++++++++++++++++++++-
12 files changed, 236 insertions(+), 60 deletions(-)

Detailed changes

internal/ui/chat/assistant.go 🔗

@@ -77,13 +77,9 @@ func (a *AssistantMessageItem) ID() string {
 	return a.message.ID
 }
 
-// Render implements MessageItem.
-func (a *AssistantMessageItem) Render(width int) string {
+// RawRender implements [MessageItem].
+func (a *AssistantMessageItem) RawRender(width int) string {
 	cappedWidth := cappedMessageWidth(width)
-	style := a.sty.Chat.Message.AssistantBlurred
-	if a.focused {
-		style = a.sty.Chat.Message.AssistantFocused
-	}
 
 	var spinner string
 	if a.isSpinning() {
@@ -103,10 +99,19 @@ func (a *AssistantMessageItem) Render(width int) string {
 		if highlightedContent != "" {
 			highlightedContent += "\n\n"
 		}
-		return style.Render(highlightedContent + spinner)
+		return highlightedContent + spinner
 	}
 
-	return style.Render(highlightedContent)
+	return highlightedContent
+}
+
+// Render implements MessageItem.
+func (a *AssistantMessageItem) Render(width int) string {
+	style := a.sty.Chat.Message.AssistantBlurred
+	if a.focused {
+		style = a.sty.Chat.Message.AssistantFocused
+	}
+	return style.Render(a.RawRender(width))
 }
 
 // renderMessageContent renders the message content including thinking, main content, and finish reason.

internal/ui/chat/messages.go 🔗

@@ -1,6 +1,3 @@
-// Package chat provides UI components for displaying and managing chat messages.
-// It defines message item types that can be rendered in a list view, including
-// support for highlighting, focusing, and caching rendered content.
 package chat
 
 import (
@@ -48,6 +45,7 @@ type Expandable interface {
 // UI and be part of a [list.List] identifiable by a unique ID.
 type MessageItem interface {
 	list.Item
+	list.RawRenderable
 	Identifiable
 }
 
@@ -77,6 +75,8 @@ type highlightableMessageItem struct {
 	highlighter list.Highlighter
 }
 
+var _ list.Highlightable = (*highlightableMessageItem)(nil)
+
 // isHighlighted returns true if the item has a highlight range set.
 func (h *highlightableMessageItem) isHighlighted() bool {
 	return h.startLine != -1 || h.endLine != -1
@@ -91,8 +91,8 @@ func (h *highlightableMessageItem) renderHighlighted(content string, width, heig
 	return list.Highlight(content, area, h.startLine, h.startCol, h.endLine, h.endCol, h.highlighter)
 }
 
-// Highlight implements MessageItem.
-func (h *highlightableMessageItem) Highlight(startLine int, startCol int, endLine int, endCol int) {
+// SetHighlight implements list.Highlightable.
+func (h *highlightableMessageItem) SetHighlight(startLine int, startCol int, endLine int, endCol int) {
 	// Adjust columns for the style's left inset (border + padding) since we
 	// highlight the content only.
 	offset := messageLeftPaddingTotal
@@ -106,6 +106,11 @@ func (h *highlightableMessageItem) Highlight(startLine int, startCol int, endLin
 	}
 }
 
+// Highlight implements list.Highlightable.
+func (h *highlightableMessageItem) Highlight() (startLine int, startCol int, endLine int, endCol int) {
+	return h.startLine, h.startCol, h.endLine, h.endCol
+}
+
 func defaultHighlighter(sty *styles.Styles) *highlightableMessageItem {
 	return &highlightableMessageItem{
 		startLine:   -1,
@@ -193,8 +198,8 @@ func (a *AssistantInfoItem) ID() string {
 	return a.id
 }
 
-// Render implements MessageItem.
-func (a *AssistantInfoItem) Render(width int) string {
+// RawRender implements MessageItem.
+func (a *AssistantInfoItem) RawRender(width int) string {
 	innerWidth := max(0, width-messageLeftPaddingTotal)
 	content, _, ok := a.getCachedRender(innerWidth)
 	if !ok {
@@ -202,8 +207,12 @@ func (a *AssistantInfoItem) Render(width int) string {
 		height := lipgloss.Height(content)
 		a.setCachedRender(content, innerWidth, height)
 	}
+	return content
+}
 
-	return a.sty.Chat.Message.SectionHeader.Render(content)
+// Render implements MessageItem.
+func (a *AssistantInfoItem) Render(width int) string {
+	return a.sty.Chat.Message.SectionHeader.Render(a.RawRender(width))
 }
 
 func (a *AssistantInfoItem) renderContent(width int) string {

internal/ui/chat/tools.go 🔗

@@ -287,20 +287,12 @@ func (t *baseToolMessageItem) Animate(msg anim.StepMsg) tea.Cmd {
 	return t.anim.Animate(msg)
 }
 
-// Render renders the tool message item at the given width.
-func (t *baseToolMessageItem) Render(width int) string {
+// RawRender implements [MessageItem].
+func (t *baseToolMessageItem) RawRender(width int) string {
 	toolItemWidth := width - messageLeftPaddingTotal
 	if t.hasCappedWidth {
 		toolItemWidth = cappedMessageWidth(width)
 	}
-	style := t.sty.Chat.Message.ToolCallBlurred
-	if t.focused {
-		style = t.sty.Chat.Message.ToolCallFocused
-	}
-
-	if t.isCompact {
-		style = t.sty.Chat.Message.ToolCallCompact
-	}
 
 	content, height, ok := t.getCachedRender(toolItemWidth)
 	// if we are spinning or there is no cache rerender
@@ -319,8 +311,21 @@ func (t *baseToolMessageItem) Render(width int) string {
 		t.setCachedRender(content, toolItemWidth, height)
 	}
 
-	highlightedContent := t.renderHighlighted(content, toolItemWidth, height)
-	return style.Render(highlightedContent)
+	return t.renderHighlighted(content, toolItemWidth, height)
+}
+
+// Render renders the tool message item at the given width.
+func (t *baseToolMessageItem) Render(width int) string {
+	style := t.sty.Chat.Message.ToolCallBlurred
+	if t.focused {
+		style = t.sty.Chat.Message.ToolCallFocused
+	}
+
+	if t.isCompact {
+		style = t.sty.Chat.Message.ToolCallCompact
+	}
+
+	return style.Render(t.RawRender(width))
 }
 
 // ToolCall returns the tool call associated with this message item.

internal/ui/chat/user.go 🔗

@@ -33,19 +33,14 @@ func NewUserMessageItem(sty *styles.Styles, message *message.Message, attachment
 	}
 }
 
-// Render implements MessageItem.
-func (m *UserMessageItem) Render(width int) string {
+// RawRender implements [MessageItem].
+func (m *UserMessageItem) RawRender(width int) string {
 	cappedWidth := cappedMessageWidth(width)
 
-	style := m.sty.Chat.Message.UserBlurred
-	if m.focused {
-		style = m.sty.Chat.Message.UserFocused
-	}
-
 	content, height, ok := m.getCachedRender(cappedWidth)
 	// cache hit
 	if ok {
-		return style.Render(m.renderHighlighted(content, cappedWidth, height))
+		return m.renderHighlighted(content, cappedWidth, height)
 	}
 
 	renderer := common.MarkdownRenderer(m.sty, cappedWidth)
@@ -69,7 +64,16 @@ func (m *UserMessageItem) Render(width int) string {
 
 	height = lipgloss.Height(content)
 	m.setCachedRender(content, cappedWidth, height)
-	return style.Render(m.renderHighlighted(content, cappedWidth, height))
+	return m.renderHighlighted(content, cappedWidth, height)
+}
+
+// Render implements MessageItem.
+func (m *UserMessageItem) Render(width int) string {
+	style := m.sty.Chat.Message.UserBlurred
+	if m.focused {
+		style = m.sty.Chat.Message.UserFocused
+	}
+	return style.Render(m.RawRender(width))
 }
 
 // ID implements MessageItem.

internal/ui/dialog/models_list.go 🔗

@@ -26,6 +26,7 @@ func NewModelsList(sty *styles.Styles, groups ...ModelGroup) *ModelsList {
 		groups: groups,
 		t:      sty,
 	}
+	f.RegisterRenderCallback(list.FocusedRenderCallback(f.List))
 	return f
 }
 

internal/ui/list/filterable.go 🔗

@@ -31,6 +31,7 @@ func NewFilterableList(items ...FilterableItem) *FilterableList {
 		List:  NewList(),
 		items: items,
 	}
+	f.RegisterRenderCallback(FocusedRenderCallback(f.List))
 	f.SetItems(items...)
 	return f
 }

internal/ui/list/focus.go 🔗

@@ -0,0 +1,13 @@
+package list
+
+// FocusedRenderCallback is a helper function that returns a render callback
+// that marks items as focused during rendering.
+func FocusedRenderCallback(list *List) RenderCallback {
+	return func(idx, selectedIdx int, item Item) Item {
+		if focusable, ok := item.(Focusable); ok {
+			focusable.SetFocused(list.Focused() && idx == selectedIdx)
+			return focusable.(Item)
+		}
+		return item
+	}
+}

internal/ui/list/highlight.go 🔗

@@ -2,25 +2,60 @@ package list
 
 import (
 	"image"
+	"strings"
 
 	"charm.land/lipgloss/v2"
 	uv "github.com/charmbracelet/ultraviolet"
 )
 
 // DefaultHighlighter is the default highlighter function that applies inverse style.
-var DefaultHighlighter Highlighter = func(s uv.Style) uv.Style {
-	s.Attrs |= uv.AttrReverse
-	return s
+var DefaultHighlighter Highlighter = func(x, y int, c *uv.Cell) *uv.Cell {
+	if c == nil {
+		return c
+	}
+	c.Style.Attrs |= uv.AttrReverse
+	return c
 }
 
 // Highlighter represents a function that defines how to highlight text.
-type Highlighter func(uv.Style) uv.Style
+type Highlighter func(x, y int, c *uv.Cell) *uv.Cell
+
+// HighlightContent returns the content with highlighted regions based on the specified parameters.
+func HighlightContent(content string, area image.Rectangle, startLine, startCol, endLine, endCol int) string {
+	var sb strings.Builder
+	pos := image.Pt(-1, -1)
+	HighlightBuffer(content, area, startLine, startCol, endLine, endCol, func(x, y int, c *uv.Cell) *uv.Cell {
+		pos.X = x
+		if pos.Y == -1 {
+			pos.Y = y
+		} else if y > pos.Y {
+			sb.WriteString(strings.Repeat("\n", y-pos.Y))
+			pos.Y = y
+		}
+		sb.WriteString(c.Content)
+		return c
+	})
+	if sb.Len() > 0 {
+		sb.WriteString("\n")
+	}
+	return sb.String()
+}
 
 // Highlight highlights a region of text within the given content and region.
 func Highlight(content string, area image.Rectangle, startLine, startCol, endLine, endCol int, highlighter Highlighter) string {
-	if startLine < 0 || startCol < 0 {
+	buf := HighlightBuffer(content, area, startLine, startCol, endLine, endCol, highlighter)
+	if buf == nil {
 		return content
 	}
+	return buf.Render()
+}
+
+// HighlightBuffer highlights a region of text within the given content and
+// region, returning a [uv.ScreenBuffer].
+func HighlightBuffer(content string, area image.Rectangle, startLine, startCol, endLine, endCol int, highlighter Highlighter) *uv.ScreenBuffer {
+	if startLine < 0 || startCol < 0 {
+		return nil
+	}
 
 	if highlighter == nil {
 		highlighter = DefaultHighlighter
@@ -87,17 +122,22 @@ func Highlight(content string, area image.Rectangle, startLine, startCol, endLin
 				continue
 			}
 			cell := line.At(x)
-			cell.Style = highlighter(cell.Style)
+			if cell != nil {
+				line.Set(x, highlighter(x, y, cell))
+			}
 		}
 	}
 
-	return buf.Render()
+	return &buf
 }
 
 // ToHighlighter converts a [lipgloss.Style] to a [Highlighter].
 func ToHighlighter(lgStyle lipgloss.Style) Highlighter {
-	return func(uv.Style) uv.Style {
-		return ToStyle(lgStyle)
+	return func(_ int, _ int, c *uv.Cell) *uv.Cell {
+		if c != nil {
+			c.Style = ToStyle(lgStyle)
+		}
+		return c
 	}
 }
 

internal/ui/list/item.go 🔗

@@ -13,6 +13,14 @@ type Item interface {
 	Render(width int) string
 }
 
+// RawRenderable represents an item that can provide a raw rendering
+// without additional styling.
+type RawRenderable interface {
+	// RawRender returns the raw rendered string without any additional
+	// styling.
+	RawRender(width int) string
+}
+
 // Focusable represents an item that can be aware of focus state changes.
 type Focusable interface {
 	// SetFocused sets the focus state of the item.
@@ -21,9 +29,11 @@ type Focusable interface {
 
 // Highlightable represents an item that can highlight a portion of its content.
 type Highlightable interface {
-	// Highlight highlights the content from the given start to end positions.
-	// Use -1 for no highlight.
-	Highlight(startLine, startCol, endLine, endCol int)
+	// SetHighlight highlights the content from the given start to end
+	// positions. Use -1 for no highlight.
+	SetHighlight(startLine, startCol, endLine, endCol int)
+	// Highlight returns the current highlight positions within the item.
+	Highlight() (startLine, startCol, endLine, endCol int)
 }
 
 // MouseClickable represents an item that can handle mouse click events.

internal/ui/list/list.go 🔗

@@ -49,9 +49,13 @@ func NewList(items ...Item) *List {
 	return l
 }
 
+// RenderCallback defines a function that can modify an item before it is
+// rendered.
+type RenderCallback func(idx, selectedIdx int, item Item) Item
+
 // RegisterRenderCallback registers a callback to be called when rendering
 // items. This can be used to modify items before they are rendered.
-func (l *List) RegisterRenderCallback(cb func(idx, selectedIdx int, item Item) Item) {
+func (l *List) RegisterRenderCallback(cb RenderCallback) {
 	l.renderCallbacks = append(l.renderCallbacks, cb)
 }
 
@@ -66,6 +70,11 @@ func (l *List) SetGap(gap int) {
 	l.gap = gap
 }
 
+// Gap returns the gap between items.
+func (l *List) Gap() int {
+	return l.gap
+}
+
 // SetReverse shows the list in reverse order.
 func (l *List) SetReverse(reverse bool) {
 	l.reverse = reverse
@@ -101,10 +110,6 @@ func (l *List) getItem(idx int) renderedItem {
 		}
 	}
 
-	if focusable, isFocusable := item.(Focusable); isFocusable {
-		focusable.SetFocused(l.focused && idx == l.selectedIdx)
-	}
-
 	rendered := item.Render(l.width)
 	rendered = strings.TrimRight(rendered, "\n")
 	height := countLines(rendered)
@@ -348,6 +353,11 @@ func (l *List) RemoveItem(idx int) {
 	}
 }
 
+// Focused returns whether the list is focused.
+func (l *List) Focused() bool {
+	return l.focused
+}
+
 // Focus sets the focus state of the list.
 func (l *List) Focus() {
 	l.focused = true

internal/ui/model/chat.go 🔗

@@ -1,7 +1,10 @@
 package model
 
 import (
+	"strings"
+
 	tea "charm.land/bubbletea/v2"
+	"charm.land/lipgloss/v2"
 	"github.com/charmbracelet/crush/internal/ui/anim"
 	"github.com/charmbracelet/crush/internal/ui/chat"
 	"github.com/charmbracelet/crush/internal/ui/common"
@@ -43,6 +46,7 @@ func NewChat(com *common.Common) *Chat {
 	l := list.NewList()
 	l.SetGap(1)
 	l.RegisterRenderCallback(c.applyHighlightRange)
+	l.RegisterRenderCallback(list.FocusedRenderCallback(l))
 	c.list = l
 	c.mouseDownItem = -1
 	c.mouseDragItem = -1
@@ -445,9 +449,6 @@ func (m *Chat) HandleMouseUp(x, y int) bool {
 		return false
 	}
 
-	// TODO: Handle the behavior when mouse is released after a drag selection
-	// (e.g., copy selected text to clipboard)
-
 	m.mouseDown = false
 	return true
 }
@@ -474,6 +475,47 @@ func (m *Chat) HandleMouseDrag(x, y int) bool {
 	return true
 }
 
+// HasHighlight returns whether there is currently highlighted content.
+func (m *Chat) HasHighlight() bool {
+	startItemIdx, startLine, startCol, endItemIdx, endLine, endCol := m.getHighlightRange()
+	return startItemIdx >= 0 && endItemIdx >= 0 && (startLine != endLine || startCol != endCol)
+}
+
+// HighlighContent returns the currently highlighted content based on the mouse
+// selection. It returns an empty string if no content is highlighted.
+func (m *Chat) HighlighContent() string {
+	startItemIdx, startLine, startCol, endItemIdx, endLine, endCol := m.getHighlightRange()
+	if startItemIdx < 0 || endItemIdx < 0 || startLine == endLine && startCol == endCol {
+		return ""
+	}
+
+	var sb strings.Builder
+	for i := startItemIdx; i <= endItemIdx; i++ {
+		item := m.list.ItemAt(i)
+		if hi, ok := item.(list.Highlightable); ok {
+			startLine, startCol, endLine, endCol := hi.Highlight()
+			listWidth := m.list.Width()
+			var rendered string
+			if rr, ok := item.(list.RawRenderable); ok {
+				rendered = rr.RawRender(listWidth)
+			} else {
+				rendered = item.Render(listWidth)
+			}
+			sb.WriteString(list.HighlightContent(
+				rendered,
+				uv.Rect(0, 0, listWidth, lipgloss.Height(rendered)),
+				startLine,
+				startCol,
+				endLine,
+				endCol,
+			))
+			sb.WriteString(strings.Repeat("\n", m.list.Gap()))
+		}
+	}
+
+	return strings.TrimSpace(sb.String())
+}
+
 // ClearMouse clears the current mouse interaction state.
 func (m *Chat) ClearMouse() {
 	m.mouseDown = false
@@ -515,7 +557,7 @@ func (m *Chat) applyHighlightRange(idx, selectedIdx int, item list.Item) list.It
 			}
 		}
 
-		hi.Highlight(sLine, sCol, eLine, eCol)
+		hi.SetHighlight(sLine, sCol, eLine, eCol)
 		return hi.(list.Item)
 	}
 

internal/ui/model/ui.go 🔗

@@ -23,6 +23,7 @@ import (
 	"charm.land/bubbles/v2/textarea"
 	tea "charm.land/bubbletea/v2"
 	"charm.land/lipgloss/v2"
+	"github.com/atotto/clipboard"
 	"github.com/charmbracelet/catwalk/pkg/catwalk"
 	"github.com/charmbracelet/crush/internal/agent/tools/mcp"
 	"github.com/charmbracelet/crush/internal/app"
@@ -106,6 +107,9 @@ type (
 
 	// closeDialogMsg is sent to close the current dialog.
 	closeDialogMsg struct{}
+
+	// copyChatHighlightMsg is sent to copy the current chat highlight to clipboard.
+	copyChatHighlightMsg struct{}
 )
 
 // UI represents the main user interface model.
@@ -203,6 +207,9 @@ type UI struct {
 	// Todo spinner
 	todoSpinner    spinner.Model
 	todoIsSpinning bool
+
+	// mouse highlighting related state
+	lastClickTime time.Time
 }
 
 // New creates a new instance of the [UI] model.
@@ -484,6 +491,8 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			m.keyMap.Models.SetHelp("ctrl+m", "models")
 			m.keyMap.Editor.Newline.SetHelp("shift+enter", "newline")
 		}
+	case copyChatHighlightMsg:
+		cmds = append(cmds, m.copyChatHighlight())
 	case tea.MouseClickMsg:
 		switch m.state {
 		case uiChat:
@@ -491,7 +500,9 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			// Adjust for chat area position
 			x -= m.layout.main.Min.X
 			y -= m.layout.main.Min.Y
-			m.chat.HandleMouseDown(x, y)
+			if m.chat.HandleMouseDown(x, y) {
+				m.lastClickTime = time.Now()
+			}
 		}
 
 	case tea.MouseMotionMsg:
@@ -527,13 +538,22 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		}
 
 	case tea.MouseReleaseMsg:
+		const doubleClickThreshold = 500 * time.Millisecond
+
 		switch m.state {
 		case uiChat:
 			x, y := msg.X, msg.Y
 			// Adjust for chat area position
 			x -= m.layout.main.Min.X
 			y -= m.layout.main.Min.Y
-			m.chat.HandleMouseUp(x, y)
+			if m.chat.HandleMouseUp(x, y) {
+				cmds = append(cmds, tea.Tick(doubleClickThreshold, func(t time.Time) tea.Msg {
+					if time.Since(m.lastClickTime) >= doubleClickThreshold {
+						return copyChatHighlightMsg{}
+					}
+					return nil
+				}))
+			}
 		}
 	case tea.MouseWheelMsg:
 		// 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
 	return tea.Sequence(cmds...)
 }
 
+func (m *UI) copyChatHighlight() tea.Cmd {
+	text := m.chat.HighlighContent()
+	return tea.Sequence(
+		tea.SetClipboard(text),
+		func() tea.Msg {
+			_ = clipboard.WriteAll(text)
+			return nil
+		},
+		func() tea.Msg {
+			m.chat.ClearMouse()
+			return nil
+		},
+		uiutil.ReportInfo("Selected text copied to clipboard"),
+	)
+}
+
 // renderLogo renders the Crush logo with the given styles and dimensions.
 func renderLogo(t *styles.Styles, compact bool, width int) string {
 	return logo.Render(version.Version, compact, logo.Opts{