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 68954b0f3f0168b9da91b1b28db1b5101e5f9c3b..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 } @@ -77,6 +75,8 @@ type highlightableMessageItem struct { highlighter list.Highlighter } +var _ list.Highlightable = (*highlightableMessageItem)(nil) + // isHighlighted returns true if the item has a highlight range set. func (h *highlightableMessageItem) isHighlighted() bool { return h.startLine != -1 || h.endLine != -1 @@ -91,8 +91,8 @@ func (h *highlightableMessageItem) renderHighlighted(content string, width, heig return list.Highlight(content, area, h.startLine, h.startCol, h.endLine, h.endCol, h.highlighter) } -// Highlight implements MessageItem. -func (h *highlightableMessageItem) Highlight(startLine int, startCol int, endLine int, endCol int) { +// 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. offset := messageLeftPaddingTotal @@ -106,6 +106,11 @@ func (h *highlightableMessageItem) Highlight(startLine int, startCol int, endLin } } +// 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 +} + func defaultHighlighter(sty *styles.Styles) *highlightableMessageItem { return &highlightableMessageItem{ startLine: -1, @@ -193,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 { @@ -202,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/dialog/models_list.go b/internal/ui/dialog/models_list.go index bbd4dafad3591db2e62624243fd9ae919bed5206..c0eaba437154a78df3865ab9cd0e96c5c9c57321 100644 --- a/internal/ui/dialog/models_list.go +++ b/internal/ui/dialog/models_list.go @@ -26,6 +26,7 @@ func NewModelsList(sty *styles.Styles, groups ...ModelGroup) *ModelsList { groups: groups, t: sty, } + f.RegisterRenderCallback(list.FocusedRenderCallback(f.List)) return f } diff --git a/internal/ui/list/filterable.go b/internal/ui/list/filterable.go index d3c227f0234028aea22fcc397d861c263cab034a..ed018e2e1e26e7d5a5bbc091b17c572331811dc5 100644 --- a/internal/ui/list/filterable.go +++ b/internal/ui/list/filterable.go @@ -31,6 +31,7 @@ func NewFilterableList(items ...FilterableItem) *FilterableList { List: NewList(), items: items, } + f.RegisterRenderCallback(FocusedRenderCallback(f.List)) f.SetItems(items...) return f } diff --git a/internal/ui/list/focus.go b/internal/ui/list/focus.go new file mode 100644 index 0000000000000000000000000000000000000000..6bdee37afa39a69d6d321b1894c6a5f221fc307d --- /dev/null +++ b/internal/ui/list/focus.go @@ -0,0 +1,13 @@ +package list + +// FocusedRenderCallback is a helper function that returns a render callback +// that marks items as focused during rendering. +func FocusedRenderCallback(list *List) RenderCallback { + return func(idx, selectedIdx int, item Item) Item { + if focusable, ok := item.(Focusable); ok { + focusable.SetFocused(list.Focused() && idx == selectedIdx) + return focusable.(Item) + } + return item + } +} diff --git a/internal/ui/list/highlight.go b/internal/ui/list/highlight.go index c61a53a18ffc2aced7f5ec21f31e2fe4f4916522..fefe836d110b52496028d21071fffc5262189d92 100644 --- a/internal/ui/list/highlight.go +++ b/internal/ui/list/highlight.go @@ -2,25 +2,60 @@ package list import ( "image" + "strings" "charm.land/lipgloss/v2" uv "github.com/charmbracelet/ultraviolet" ) // DefaultHighlighter is the default highlighter function that applies inverse style. -var DefaultHighlighter Highlighter = func(s uv.Style) uv.Style { - s.Attrs |= uv.AttrReverse - return s +var DefaultHighlighter Highlighter = func(x, y int, c *uv.Cell) *uv.Cell { + if c == nil { + return c + } + c.Style.Attrs |= uv.AttrReverse + return c } // Highlighter represents a function that defines how to highlight text. -type Highlighter func(uv.Style) uv.Style +type Highlighter func(x, y int, c *uv.Cell) *uv.Cell + +// HighlightContent returns the content with highlighted regions based on the specified parameters. +func HighlightContent(content string, area image.Rectangle, startLine, startCol, endLine, endCol int) string { + var sb strings.Builder + pos := image.Pt(-1, -1) + HighlightBuffer(content, area, startLine, startCol, endLine, endCol, func(x, y int, c *uv.Cell) *uv.Cell { + pos.X = x + if pos.Y == -1 { + pos.Y = y + } else if y > pos.Y { + sb.WriteString(strings.Repeat("\n", y-pos.Y)) + pos.Y = y + } + sb.WriteString(c.Content) + return c + }) + if sb.Len() > 0 { + sb.WriteString("\n") + } + return sb.String() +} // Highlight highlights a region of text within the given content and region. func Highlight(content string, area image.Rectangle, startLine, startCol, endLine, endCol int, highlighter Highlighter) string { - if startLine < 0 || startCol < 0 { + buf := HighlightBuffer(content, area, startLine, startCol, endLine, endCol, highlighter) + if buf == nil { return content } + return buf.Render() +} + +// HighlightBuffer highlights a region of text within the given content and +// region, returning a [uv.ScreenBuffer]. +func HighlightBuffer(content string, area image.Rectangle, startLine, startCol, endLine, endCol int, highlighter Highlighter) *uv.ScreenBuffer { + if startLine < 0 || startCol < 0 { + return nil + } if highlighter == nil { highlighter = DefaultHighlighter @@ -87,17 +122,22 @@ func Highlight(content string, area image.Rectangle, startLine, startCol, endLin continue } cell := line.At(x) - cell.Style = highlighter(cell.Style) + if cell != nil { + line.Set(x, highlighter(x, y, cell)) + } } } - return buf.Render() + return &buf } // ToHighlighter converts a [lipgloss.Style] to a [Highlighter]. func ToHighlighter(lgStyle lipgloss.Style) Highlighter { - return func(uv.Style) uv.Style { - return ToStyle(lgStyle) + return func(_ int, _ int, c *uv.Cell) *uv.Cell { + if c != nil { + c.Style = ToStyle(lgStyle) + } + return c } } diff --git a/internal/ui/list/item.go b/internal/ui/list/item.go index 62b31a696eee11b5dc11f0228d82ccfa8a0c91e5..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. @@ -21,9 +29,11 @@ type Focusable interface { // Highlightable represents an item that can highlight a portion of its content. type Highlightable interface { - // Highlight highlights the content from the given start to end positions. - // Use -1 for no highlight. - Highlight(startLine, startCol, endLine, endCol int) + // SetHighlight highlights the content from the given start to end + // positions. Use -1 for no highlight. + SetHighlight(startLine, startCol, endLine, endCol int) + // Highlight returns the current highlight positions within the item. + Highlight() (startLine, startCol, endLine, endCol int) } // MouseClickable represents an item that can handle mouse click events. diff --git a/internal/ui/list/list.go b/internal/ui/list/list.go index 8806551c537ecbfdcba8169bc05d7de79183b0ba..78cb437d361f8ec05d81acfa98f2e87a23755d58 100644 --- a/internal/ui/list/list.go +++ b/internal/ui/list/list.go @@ -49,9 +49,13 @@ func NewList(items ...Item) *List { return l } +// RenderCallback defines a function that can modify an item before it is +// rendered. +type RenderCallback func(idx, selectedIdx int, item Item) Item + // RegisterRenderCallback registers a callback to be called when rendering // items. This can be used to modify items before they are rendered. -func (l *List) RegisterRenderCallback(cb func(idx, selectedIdx int, item Item) Item) { +func (l *List) RegisterRenderCallback(cb RenderCallback) { l.renderCallbacks = append(l.renderCallbacks, cb) } @@ -66,6 +70,11 @@ func (l *List) SetGap(gap int) { l.gap = gap } +// Gap returns the gap between items. +func (l *List) Gap() int { + return l.gap +} + // SetReverse shows the list in reverse order. func (l *List) SetReverse(reverse bool) { l.reverse = reverse @@ -101,10 +110,6 @@ func (l *List) getItem(idx int) renderedItem { } } - if focusable, isFocusable := item.(Focusable); isFocusable { - focusable.SetFocused(l.focused && idx == l.selectedIdx) - } - rendered := item.Render(l.width) rendered = strings.TrimRight(rendered, "\n") height := countLines(rendered) @@ -348,6 +353,11 @@ func (l *List) RemoveItem(idx int) { } } +// Focused returns whether the list is focused. +func (l *List) Focused() bool { + return l.focused +} + // Focus sets the focus state of the list. func (l *List) Focus() { l.focused = true diff --git a/internal/ui/model/chat.go b/internal/ui/model/chat.go index a6c8fb1cf213be37c8f095ba776f936bec96b57a..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 @@ -515,7 +557,7 @@ func (m *Chat) applyHighlightRange(idx, selectedIdx int, item list.Item) list.It } } - hi.Highlight(sLine, sCol, eLine, eCol) + hi.SetHighlight(sLine, sCol, eLine, eCol) return hi.(list.Item) } diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index e26b9c437655b06a87f7998125940a8f6ad04443..67eda91fd7fbcd60ead0e8216d347f1f38c0eb28 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" @@ -106,6 +107,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. @@ -203,6 +207,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. @@ -484,6 +491,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: @@ -491,7 +500,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: @@ -527,13 +538,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. @@ -2844,6 +2864,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{