diff --git a/internal/ui/chat/assistant.go b/internal/ui/chat/assistant.go index 91849673c3a9eea603c85c54a7c5ac77d759c2ae..7ff53264ead1b2e264cec981ef6aa5cb541247d3 100644 --- a/internal/ui/chat/assistant.go +++ b/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. diff --git a/internal/ui/chat/messages.go b/internal/ui/chat/messages.go index a666aa44607c8dece9ac0be80b32e5297091cb33..6be07e4759020c9aed25f042668d89e96584cccc 100644 --- a/internal/ui/chat/messages.go +++ b/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 } @@ -93,7 +91,7 @@ func (h *highlightableMessageItem) renderHighlighted(content string, width, heig return list.Highlight(content, area, h.startLine, h.startCol, h.endLine, h.endCol, h.highlighter) } -// SetHighlight implements [MessageItem]. +// 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. @@ -108,7 +106,7 @@ func (h *highlightableMessageItem) SetHighlight(startLine int, startCol int, end } } -// Highlight implements [MessageItem]. +// 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 } @@ -200,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 { @@ -209,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 { diff --git a/internal/ui/chat/tools.go b/internal/ui/chat/tools.go index b264dcea6b27a7bb09fac3b498d79b679373e6a6..e703cb1c096c0fa889438a446a8f042b892d9e31 100644 --- a/internal/ui/chat/tools.go +++ b/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. diff --git a/internal/ui/chat/user.go b/internal/ui/chat/user.go index 5eb452b1fbc396f3c603af89dea9de000502fb94..7383c841ae3e274bdfea8dcc4db37e1259dbbb21 100644 --- a/internal/ui/chat/user.go +++ b/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. diff --git a/internal/ui/list/item.go b/internal/ui/list/item.go index dc6f9a345854672116a08f2fd988a15e53b160bd..7ac87212889dbc58773b409b5a4a96ec47d1fede 100644 --- a/internal/ui/list/item.go +++ b/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. diff --git a/internal/ui/model/chat.go b/internal/ui/model/chat.go index abe45997fb447a48bdbb6b2df2ef52ec3e1fb99a..9ab77c60f6b1a48228c5d08ae4d9827584b62e6d 100644 --- a/internal/ui/model/chat.go +++ b/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 diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index 5b33117771717e5c4b84d31fabe7d2f238ca4b95..fe06906523ab032a9952a0f8c15e8c2a972faac3 100644 --- a/internal/ui/model/ui.go +++ b/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" @@ -103,6 +104,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. @@ -200,6 +204,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. @@ -481,6 +488,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: @@ -488,7 +497,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: @@ -524,13 +535,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. @@ -2842,6 +2862,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{