Skip to content

Commit e753f38

Browse files
authored
Merge pull request #2288 from dgageot/board/add-copy-button-on-hover-for-assistant-m-763b7f76
Add copy button on hover for assistant messages
2 parents 1400c67 + 574a466 commit e753f38

3 files changed

Lines changed: 105 additions & 5 deletions

File tree

pkg/tui/components/message/message.go

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ type Model interface {
2121
layout.Sizeable
2222
SetMessage(msg *types.Message)
2323
SetSelected(selected bool)
24+
SetHovered(hovered bool)
2425
}
2526

2627
// messageModel implements Model
@@ -32,6 +33,7 @@ type messageModel struct {
3233
height int
3334
focused bool
3435
selected bool
36+
hovered bool
3537
spinner spinner.Spinner
3638
}
3739

@@ -65,6 +67,10 @@ func (mv *messageModel) SetSelected(selected bool) {
6567
mv.selected = selected
6668
}
6769

70+
func (mv *messageModel) SetHovered(hovered bool) {
71+
mv.hovered = hovered
72+
}
73+
6874
// Update handles messages and updates the message view state
6975
func (mv *messageModel) Update(msg tea.Msg) (layout.Model, tea.Cmd) {
7076
if mv.message.Type == types.MessageTypeSpinner || mv.message.Type == types.MessageTypeLoading {
@@ -134,11 +140,23 @@ func (mv *messageModel) Render(width int) string {
134140
rendered = msg.Content
135141
}
136142

137-
if mv.sameAgentAsPrevious(msg) {
138-
return messageStyle.Render(rendered)
143+
var prefix string
144+
if !mv.sameAgentAsPrevious(msg) {
145+
prefix = mv.senderPrefix(msg.Sender)
139146
}
140147

141-
return mv.senderPrefix(msg.Sender) + messageStyle.Render(rendered)
148+
// Always reserve a top row for the copy icon to avoid layout shifts.
149+
// The icon is only visible when hovered or selected.
150+
innerWidth := width - messageStyle.GetHorizontalFrameSize()
151+
var topRow string
152+
if mv.hovered || mv.selected {
153+
copyIcon := styles.MutedStyle.Render(types.AssistantMessageCopyLabel)
154+
iconWidth := ansi.StringWidth(types.AssistantMessageCopyLabel)
155+
padding := max(innerWidth-iconWidth, 0)
156+
topRow = strings.Repeat(" ", padding) + copyIcon
157+
}
158+
noTopPaddingStyle := messageStyle.PaddingTop(0)
159+
return prefix + noTopPaddingStyle.Width(width).Render(topRow+"\n"+rendered)
142160
case types.MessageTypeShellOutput:
143161
if rendered, err := markdown.NewRenderer(width).Render(fmt.Sprintf("```console\n%s\n```", msg.Content)); err == nil {
144162
return rendered

pkg/tui/components/messages/messages.go

Lines changed: 80 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -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+
16171696
func (m *model) mouseToLineCol(x, y int) (line, col int) {
16181697
adjustedX := max(0, x-m.xPos)
16191698
adjustedY := max(0, y-m.yPos)

pkg/tui/types/types.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,10 @@ const (
2323
MessageTypeLoading
2424
)
2525

26-
const UserMessageEditLabel = "✎"
26+
const (
27+
UserMessageEditLabel = "✎"
28+
AssistantMessageCopyLabel = "⎘"
29+
)
2730

2831
// ToolStatus represents the status of a tool call
2932
type ToolStatus int

0 commit comments

Comments
 (0)