on main help sign in
feat(ui): copy chat highlighted content to clipboard
a5c597bAyman Bagabas
internal/ui/chat/assistant.go+13−8
@@ -77,13 +77,9 @@ func (a *AssistantMessageItem) ID() string {
77 return a.message.ID
77 return a.message.ID
78}
78}
79
79
80−// Render implements MessageItem.
80+// RawRender implements [MessageItem].
81−func (a *AssistantMessageItem) Render(width int) string {
81+func (a *AssistantMessageItem) RawRender(width int) string {
82 cappedWidth := cappedMessageWidth(width)
82 cappedWidth := cappedMessageWidth(width)
83− style := a.sty.Chat.Message.AssistantBlurred
84− if a.focused {
85− style = a.sty.Chat.Message.AssistantFocused
86− }
87
83
88 var spinner string
84 var spinner string
89 if a.isSpinning() {
85 if a.isSpinning() {
@@ -103,10 +99,19 @@ func (a *AssistantMessageItem) Render(width int) string {
103 if highlightedContent != "" {
99 if highlightedContent != "" {
104 highlightedContent += "\n\n"
100 highlightedContent += "\n\n"
105 }
101 }
106− return style.Render(highlightedContent + spinner)
102+ return highlightedContent + spinner
107 }
103 }
108
104
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))
110}
115}
111
116
112// renderMessageContent renders the message content including thinking, main content, and finish reason.
117// renderMessageContent renders the message content including thinking, main content, and finish reason.
internal/ui/chat/messages.go+10−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.
4package chat
1package chat
5
2
6import (
3import (
@@ -48,6 +45,7 @@ type Expandable interface {
48// UI and be part of a [list.List] identifiable by a unique ID.
45// UI and be part of a [list.List] identifiable by a unique ID.
49type MessageItem interface {
46type MessageItem interface {
50 list.Item
47 list.Item
48+ list.RawRenderable
51 Identifiable
49 Identifiable
52}
50}
53
51
@@ -93,7 +91,7 @@ func (h *highlightableMessageItem) renderHighlighted(content string, width, heig
93 return list.Highlight(content, area, h.startLine, h.startCol, h.endLine, h.endCol, h.highlighter)
91 return list.Highlight(content, area, h.startLine, h.startCol, h.endLine, h.endCol, h.highlighter)
94}
92}
95
93
96−// SetHighlight implements [MessageItem].
94+// SetHighlight implements list.Highlightable.
97func (h *highlightableMessageItem) SetHighlight(startLine int, startCol int, endLine int, endCol int) {
95func (h *highlightableMessageItem) SetHighlight(startLine int, startCol int, endLine int, endCol int) {
98 // Adjust columns for the style's left inset (border + padding) since we
96 // Adjust columns for the style's left inset (border + padding) since we
99 // highlight the content only.
97 // highlight the content only.
@@ -108,7 +106,7 @@ func (h *highlightableMessageItem) SetHighlight(startLine int, startCol int, end
108 }
106 }
109}
107}
110
108
111−// Highlight implements [MessageItem].
109+// Highlight implements list.Highlightable.
112func (h *highlightableMessageItem) Highlight() (startLine int, startCol int, endLine int, endCol int) {
110func (h *highlightableMessageItem) Highlight() (startLine int, startCol int, endLine int, endCol int) {
113 return h.startLine, h.startCol, h.endLine, h.endCol
111 return h.startLine, h.startCol, h.endLine, h.endCol
114}
112}
@@ -200,8 +198,8 @@ func (a *AssistantInfoItem) ID() string {
200 return a.id
198 return a.id
201}
199}
202
200
203−// Render implements MessageItem.
201+// RawRender implements MessageItem.
204−func (a *AssistantInfoItem) Render(width int) string {
202+func (a *AssistantInfoItem) RawRender(width int) string {
205 innerWidth := max(0, width-messageLeftPaddingTotal)
203 innerWidth := max(0, width-messageLeftPaddingTotal)
206 content, _, ok := a.getCachedRender(innerWidth)
204 content, _, ok := a.getCachedRender(innerWidth)
207 if !ok {
205 if !ok {
@@ -209,8 +207,12 @@ func (a *AssistantInfoItem) Render(width int) string {
209 height := lipgloss.Height(content)
207 height := lipgloss.Height(content)
210 a.setCachedRender(content, innerWidth, height)
208 a.setCachedRender(content, innerWidth, height)
211 }
209 }
210+ return content
211+}
212
212
213+// Render implements MessageItem.
214+func (a *AssistantInfoItem) Render(width int) string {
213− return a.sty.Chat.Message.SectionHeader.Render(content)
215+ return a.sty.Chat.Message.SectionHeader.Render(a.RawRender(width))
214}
216}
215
217
216func (a *AssistantInfoItem) renderContent(width int) string {
218func (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 {
287 return t.anim.Animate(msg)
287 return t.anim.Animate(msg)
288}
288}
289
289
290−// Render renders the tool message item at the given width.
290+// RawRender implements [MessageItem].
291−func (t *baseToolMessageItem) Render(width int) string {
291+func (t *baseToolMessageItem) RawRender(width int) string {
292 toolItemWidth := width - messageLeftPaddingTotal
292 toolItemWidth := width - messageLeftPaddingTotal
293 if t.hasCappedWidth {
293 if t.hasCappedWidth {
294 toolItemWidth = cappedMessageWidth(width)
294 toolItemWidth = cappedMessageWidth(width)
295 }
295 }
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− }
304
296
305 content, height, ok := t.getCachedRender(toolItemWidth)
297 content, height, ok := t.getCachedRender(toolItemWidth)
306 // if we are spinning or there is no cache rerender
298 // if we are spinning or there is no cache rerender
@@ -319,8 +311,21 @@ func (t *baseToolMessageItem) Render(width int) string {
319 t.setCachedRender(content, toolItemWidth, height)
311 t.setCachedRender(content, toolItemWidth, height)
320 }
312 }
321
313
322− highlightedContent := t.renderHighlighted(content, toolItemWidth, height)
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+
323− return style.Render(highlightedContent)
328+ return style.Render(t.RawRender(width))
324}
329}
325
330
326// ToolCall returns the tool call associated with this message item.
331// 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
33 }
33 }
34}
34}
35
35
36−// Render implements MessageItem.
36+// RawRender implements [MessageItem].
37−func (m *UserMessageItem) Render(width int) string {
37+func (m *UserMessageItem) RawRender(width int) string {
38 cappedWidth := cappedMessageWidth(width)
38 cappedWidth := cappedMessageWidth(width)
39
39
40− style := m.sty.Chat.Message.UserBlurred
41− if m.focused {
42− style = m.sty.Chat.Message.UserFocused
43− }
44−
45 content, height, ok := m.getCachedRender(cappedWidth)
40 content, height, ok := m.getCachedRender(cappedWidth)
46 // cache hit
41 // cache hit
47 if ok {
42 if ok {
48− return style.Render(m.renderHighlighted(content, cappedWidth, height))
43+ return m.renderHighlighted(content, cappedWidth, height)
49 }
44 }
50
45
51 renderer := common.MarkdownRenderer(m.sty, cappedWidth)
46 renderer := common.MarkdownRenderer(m.sty, cappedWidth)
@@ -69,7 +64,16 @@ func (m *UserMessageItem) Render(width int) string {
69
64
70 height = lipgloss.Height(content)
65 height = lipgloss.Height(content)
71 m.setCachedRender(content, cappedWidth, height)
66 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))
73}
77}
74
78
75// ID implements MessageItem.
79// ID implements MessageItem.
internal/ui/list/item.go+8
@@ -13,6 +13,14 @@ type Item interface {
13 Render(width int) string
13 Render(width int) string
14}
14}
15
15
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+
16// Focusable represents an item that can be aware of focus state changes.
24// Focusable represents an item that can be aware of focus state changes.
17type Focusable interface {
25type Focusable interface {
18 // SetFocused sets the focus state of the item.
26 // SetFocused sets the focus state of the item.
internal/ui/model/chat.go+45−3
@@ -1,7 +1,10 @@
1package model
1package model
2
2
3import (
3import (
4+ "strings"
5+
4 tea "charm.land/bubbletea/v2"
6 tea "charm.land/bubbletea/v2"
7+ "charm.land/lipgloss/v2"
5 "github.com/charmbracelet/crush/internal/ui/anim"
8 "github.com/charmbracelet/crush/internal/ui/anim"
6 "github.com/charmbracelet/crush/internal/ui/chat"
9 "github.com/charmbracelet/crush/internal/ui/chat"
7 "github.com/charmbracelet/crush/internal/ui/common"
10 "github.com/charmbracelet/crush/internal/ui/common"
@@ -43,6 +46,7 @@ func NewChat(com *common.Common) *Chat {
43 l := list.NewList()
46 l := list.NewList()
44 l.SetGap(1)
47 l.SetGap(1)
45 l.RegisterRenderCallback(c.applyHighlightRange)
48 l.RegisterRenderCallback(c.applyHighlightRange)
49+ l.RegisterRenderCallback(list.FocusedRenderCallback(l))
46 c.list = l
50 c.list = l
47 c.mouseDownItem = -1
51 c.mouseDownItem = -1
48 c.mouseDragItem = -1
52 c.mouseDragItem = -1
@@ -445,9 +449,6 @@ func (m *Chat) HandleMouseUp(x, y int) bool {
445 return false
449 return false
446 }
450 }
447
451
448− // TODO: Handle the behavior when mouse is released after a drag selection
449− // (e.g., copy selected text to clipboard)
450−
451 m.mouseDown = false
452 m.mouseDown = false
452 return true
453 return true
453}
454}
@@ -474,6 +475,47 @@ func (m *Chat) HandleMouseDrag(x, y int) bool {
474 return true
475 return true
475}
476}
476
477
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+
477// ClearMouse clears the current mouse interaction state.
519// ClearMouse clears the current mouse interaction state.
478func (m *Chat) ClearMouse() {
520func (m *Chat) ClearMouse() {
479 m.mouseDown = false
521 m.mouseDown = false
internal/ui/model/ui.go+38−2
@@ -23,6 +23,7 @@ import (
23 "charm.land/bubbles/v2/textarea"
23 "charm.land/bubbles/v2/textarea"
24 tea "charm.land/bubbletea/v2"
24 tea "charm.land/bubbletea/v2"
25 "charm.land/lipgloss/v2"
25 "charm.land/lipgloss/v2"
26+ "github.com/atotto/clipboard"
26 "github.com/charmbracelet/catwalk/pkg/catwalk"
27 "github.com/charmbracelet/catwalk/pkg/catwalk"
27 "github.com/charmbracelet/crush/internal/agent/tools/mcp"
28 "github.com/charmbracelet/crush/internal/agent/tools/mcp"
28 "github.com/charmbracelet/crush/internal/app"
29 "github.com/charmbracelet/crush/internal/app"
@@ -103,6 +104,9 @@ type (
103
104
104 // closeDialogMsg is sent to close the current dialog.
105 // closeDialogMsg is sent to close the current dialog.
105 closeDialogMsg struct{}
106 closeDialogMsg struct{}
107+
108+ // copyChatHighlightMsg is sent to copy the current chat highlight to clipboard.
109+ copyChatHighlightMsg struct{}
106)
110)
107
111
108// UI represents the main user interface model.
112// UI represents the main user interface model.
@@ -200,6 +204,9 @@ type UI struct {
200 // Todo spinner
204 // Todo spinner
201 todoSpinner spinner.Model
205 todoSpinner spinner.Model
202 todoIsSpinning bool
206 todoIsSpinning bool
207+
208+ // mouse highlighting related state
209+ lastClickTime time.Time
203}
210}
204
211
205// New creates a new instance of the [UI] model.
212// New creates a new instance of the [UI] model.
@@ -481,6 +488,8 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
481 m.keyMap.Models.SetHelp("ctrl+m", "models")
488 m.keyMap.Models.SetHelp("ctrl+m", "models")
482 m.keyMap.Editor.Newline.SetHelp("shift+enter", "newline")
489 m.keyMap.Editor.Newline.SetHelp("shift+enter", "newline")
483 }
490 }
491+ case copyChatHighlightMsg:
492+ cmds = append(cmds, m.copyChatHighlight())
484 case tea.MouseClickMsg:
493 case tea.MouseClickMsg:
485 switch m.state {
494 switch m.state {
486 case uiChat:
495 case uiChat:
@@ -488,7 +497,9 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
488 // Adjust for chat area position
497 // Adjust for chat area position
489 x -= m.layout.main.Min.X
498 x -= m.layout.main.Min.X
490 y -= m.layout.main.Min.Y
499 y -= m.layout.main.Min.Y
491− m.chat.HandleMouseDown(x, y)
500+ if m.chat.HandleMouseDown(x, y) {
501+ m.lastClickTime = time.Now()
502+ }
492 }
503 }
493
504
494 case tea.MouseMotionMsg:
505 case tea.MouseMotionMsg:
@@ -524,13 +535,22 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
524 }
535 }
525
536
526 case tea.MouseReleaseMsg:
537 case tea.MouseReleaseMsg:
538+ const doubleClickThreshold = 500 * time.Millisecond
539+
527 switch m.state {
540 switch m.state {
528 case uiChat:
541 case uiChat:
529 x, y := msg.X, msg.Y
542 x, y := msg.X, msg.Y
530 // Adjust for chat area position
543 // Adjust for chat area position
531 x -= m.layout.main.Min.X
544 x -= m.layout.main.Min.X
532 y -= m.layout.main.Min.Y
545 y -= m.layout.main.Min.Y
533− m.chat.HandleMouseUp(x, y)
546+ if m.chat.HandleMouseUp(x, y) {
547+ cmds = append(cmds, tea.Tick(doubleClickThreshold, func(t time.Time) tea.Msg {
548+ if time.Since(m.lastClickTime) >= doubleClickThreshold {
549+ return copyChatHighlightMsg{}
550+ }
551+ return nil
552+ }))
553+ }
534 }
554 }
535 case tea.MouseWheelMsg:
555 case tea.MouseWheelMsg:
536 // Pass mouse events to dialogs first if any are open.
556 // Pass mouse events to dialogs first if any are open.
@@ -2842,6 +2862,22 @@ func (m *UI) runMCPPrompt(clientID, promptID string, arguments map[string]string
2842 return tea.Sequence(cmds...)
2862 return tea.Sequence(cmds...)
2843}
2863}
2844
2864
2865+func (m *UI) copyChatHighlight() tea.Cmd {
2866+ text := m.chat.HighlighContent()
2867+ return tea.Sequence(
2868+ tea.SetClipboard(text),
2869+ func() tea.Msg {
2870+ _ = clipboard.WriteAll(text)
2871+ return nil
2872+ },
2873+ func() tea.Msg {
2874+ m.chat.ClearMouse()
2875+ return nil
2876+ },
2877+ uiutil.ReportInfo("Selected text copied to clipboard"),
2878+ )
2879+}
2880+
2845// renderLogo renders the Crush logo with the given styles and dimensions.
2881// renderLogo renders the Crush logo with the given styles and dimensions.
2846func renderLogo(t *styles.Styles, compact bool, width int) string {
2882func renderLogo(t *styles.Styles, compact bool, width int) string {
2847 return logo.Render(version.Version, compact, logo.Opts{
2883 return logo.Render(version.Version, compact, logo.Opts{