@@ -126,6 +126,9 @@ type model struct {
126126 inlineEditTextarea textarea.Model // Textarea for inline editing
127127 inlineEditOriginal string // Original content (for cancel)
128128 inlineEditPrevSelection int // Previous selection index before entering inline edit (-1 = was not in selection mode)
129+
130+ // Hover state for showing copy button on assistant messages
131+ hoveredMessageIndex int // Index of message under mouse (-1 = none)
129132}
130133
131134// New creates a new message list component
@@ -152,6 +155,7 @@ func newModel(width, height int, sessionState *service.SessionState) *model {
152155 scrollview : sv ,
153156 selectedMessageIndex : - 1 ,
154157 inlineEditMsgIndex : - 1 ,
158+ hoveredMessageIndex : - 1 ,
155159 renderDirty : true ,
156160 }
157161}
@@ -294,6 +298,11 @@ func (m *model) handleMouseClick(msg tea.MouseClickMsg) (layout.Model, tea.Cmd)
294298 OriginalContent : msg .Content ,
295299 })
296300 }
301+
302+ if m .isCopyLabelClick (msgIdx , localLine , col ) {
303+ cmd := m .copyMessageToClipboard (msgIdx )
304+ return m , cmd
305+ }
297306 }
298307
299308 clickCount := m .selection .detectClickType (line , col )
@@ -353,6 +362,27 @@ func (m *model) handleMouseMotion(msg tea.MouseMotionMsg) (layout.Model, tea.Cmd
353362 cmd := m .autoScroll ()
354363 return m , cmd
355364 }
365+
366+ // Track hovered message for showing copy button on assistant messages
367+ line , _ := m .mouseToLineCol (msg .X , msg .Y )
368+ newHovered := - 1
369+ if msgIdx , _ := m .globalLineToMessageLine (line ); msgIdx >= 0 && msgIdx < len (m .messages ) {
370+ if m .messages [msgIdx ].Type == types .MessageTypeAssistant {
371+ newHovered = msgIdx
372+ }
373+ }
374+ if newHovered != m .hoveredMessageIndex {
375+ oldHovered := m .hoveredMessageIndex
376+ m .hoveredMessageIndex = newHovered
377+ if oldHovered >= 0 {
378+ m .invalidateItem (oldHovered )
379+ }
380+ if newHovered >= 0 {
381+ m .invalidateItem (newHovered )
382+ }
383+ m .renderDirty = true
384+ }
385+
356386 return m , nil
357387}
358388
@@ -905,15 +935,17 @@ func (m *model) renderItem(index int, view layout.Model) renderedItem {
905935 }
906936
907937 isSelected := m .focused && index == m .selectedMessageIndex
938+ isHovered := index == m .hoveredMessageIndex
908939
909940 switch v := view .(type ) {
910941 case message.Model :
911942 v .SetSelected (isSelected )
943+ v .SetHovered (isHovered )
912944 case * reasoningblock.Model :
913945 v .SetSelected (isSelected )
914946 }
915947
916- shouldCache := ! isSelected && m .shouldCacheMessage (index )
948+ shouldCache := ! isSelected && ! isHovered && m .shouldCacheMessage (index )
917949 if shouldCache {
918950 if cached , exists := m .renderedItems [index ]; exists {
919951 return cached
@@ -1195,6 +1227,7 @@ func (m *model) LoadFromSession(sess *session.Session) tea.Cmd {
11951227 m .totalHeight = 0
11961228 m .bottomSlack = 0
11971229 m .selectedMessageIndex = - 1
1230+ m .hoveredMessageIndex = - 1
11981231
11991232 var cmds []tea.Cmd
12001233
@@ -1614,6 +1647,52 @@ func (m *model) isEditLabelClick(msgIdx, localLine, col int) (bool, *types.Messa
16141647 return false , nil
16151648}
16161649
1650+ // isCopyLabelClick checks if the click is on the copy label of an assistant message.
1651+ func (m * model ) isCopyLabelClick (msgIdx , localLine , col int ) bool {
1652+ if msgIdx < 0 || msgIdx >= len (m .messages ) {
1653+ return false
1654+ }
1655+ msg := m .messages [msgIdx ]
1656+ if msg .Type != types .MessageTypeAssistant {
1657+ return false
1658+ }
1659+ // Only clickable when hovered or selected
1660+ if msgIdx != m .hoveredMessageIndex && (! m .focused || msgIdx != m .selectedMessageIndex ) {
1661+ return false
1662+ }
1663+ if msgIdx >= len (m .views ) {
1664+ return false
1665+ }
1666+
1667+ item := m .renderItem (msgIdx , m .views [msgIdx ])
1668+ lines := strings .Split (item .view , "\n " )
1669+ if localLine < 0 || localLine >= len (lines ) {
1670+ return false
1671+ }
1672+
1673+ plainLine := ansi .Strip (lines [localLine ])
1674+ before , _ , ok := strings .Cut (plainLine , types .AssistantMessageCopyLabel )
1675+ if ! ok {
1676+ return false
1677+ }
1678+
1679+ labelStart := ansi .StringWidth (before )
1680+ labelEnd := labelStart + ansi .StringWidth (types .AssistantMessageCopyLabel )
1681+ return col >= labelStart && col < labelEnd
1682+ }
1683+
1684+ // copyMessageToClipboard copies the content of a specific message to clipboard.
1685+ func (m * model ) copyMessageToClipboard (msgIdx int ) tea.Cmd {
1686+ if msgIdx < 0 || msgIdx >= len (m .messages ) {
1687+ return nil
1688+ }
1689+ content := m .messages [msgIdx ].Content
1690+ if content == "" {
1691+ return nil
1692+ }
1693+ return copyTextToClipboard (content )
1694+ }
1695+
16171696func (m * model ) mouseToLineCol (x , y int ) (line , col int ) {
16181697 adjustedX := max (0 , x - m .xPos )
16191698 adjustedY := max (0 , y - m .yPos )
0 commit comments