Detailed changes
@@ -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.
@@ -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 {
@@ -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.
@@ -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.
@@ -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.
@@ -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
@@ -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{