diff --git a/go.mod b/go.mod index 0846c79ce4a1bb87e57b00d1b463d13639feb2f7..e086f2b810a6e77330fe6e8ef4d7a026a3bccade 100644 --- a/go.mod +++ b/go.mod @@ -10,16 +10,16 @@ require ( github.com/anthropics/anthropic-sdk-go v1.6.2 github.com/atotto/clipboard v0.1.4 github.com/aymanbagabas/go-udiff v0.3.1 - github.com/bmatcuk/doublestar/v4 v4.9.0 + github.com/bmatcuk/doublestar/v4 v4.9.1 github.com/charlievieth/fastwalk v1.0.11 github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250716191546-1e2ffbbcf5c5 - github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4.0.20250730165737-56ff7146d52d - github.com/charmbracelet/catwalk v0.4.5 + github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4.0.20250805190305-70e94a2e0b2d + github.com/charmbracelet/catwalk v0.4.6 github.com/charmbracelet/fang v0.3.1-0.20250711140230-d5ebb8c1d674 github.com/charmbracelet/glamour/v2 v2.0.0-20250516160903-6f1e2c8f9ebe github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.3.0.20250721205738-ea66aa652ee0 github.com/charmbracelet/log/v2 v2.0.0-20250226163916-c379e29ff706 - github.com/charmbracelet/x/ansi v0.9.3 + github.com/charmbracelet/x/ansi v0.10.0 github.com/charmbracelet/x/exp/charmtone v0.0.0-20250708181618-a60a724ba6c3 github.com/charmbracelet/x/exp/golden v0.0.0-20250207160936-21c02780d27a github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec @@ -75,7 +75,7 @@ require ( github.com/bahlo/generic-list-go v0.2.0 // indirect github.com/buger/jsonparser v1.1.1 // indirect github.com/charmbracelet/colorprofile v0.3.1 // indirect - github.com/charmbracelet/ultraviolet v0.0.0-20250731212901-76da584cc9a5 + github.com/charmbracelet/ultraviolet v0.0.0-20250805154935-01be9d7ef65d github.com/charmbracelet/x/cellbuf v0.0.14-0.20250516160309-24eee56f89fa // indirect github.com/charmbracelet/x/exp/slice v0.0.0-20250611152503-f53cdd7e01ef github.com/charmbracelet/x/term v0.2.1 diff --git a/go.sum b/go.sum index 2f0a23a24faa153d3a836005c1b06b7c2f6bd5f3..07607afea2b6ea6392d2767f17bd70f90df5f23c 100644 --- a/go.sum +++ b/go.sum @@ -68,18 +68,18 @@ github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuP github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= -github.com/bmatcuk/doublestar/v4 v4.9.0 h1:DBvuZxjdKkRP/dr4GVV4w2fnmrk5Hxc90T51LZjv0JA= -github.com/bmatcuk/doublestar/v4 v4.9.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= +github.com/bmatcuk/doublestar/v4 v4.9.1 h1:X8jg9rRZmJd4yRy7ZeNDRnM+T3ZfHv15JiBJ/avrEXE= +github.com/bmatcuk/doublestar/v4 v4.9.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs= github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= github.com/charlievieth/fastwalk v1.0.11 h1:5sLT/q9+d9xMdpKExawLppqvXFZCVKf6JHnr2u/ufj8= github.com/charlievieth/fastwalk v1.0.11/go.mod h1:yGy1zbxog41ZVMcKA/i8ojXLFsuayX5VvwhQVoj9PBI= github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250716191546-1e2ffbbcf5c5 h1:GTcMIfDQJKyNKS+xVt7GkNIwz+tBuQtIuiP50WpzNgs= github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250716191546-1e2ffbbcf5c5/go.mod h1:6HamsBKWqEC/FVHuQMHgQL+knPyvHH55HwJDHl/adMw= -github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4.0.20250730165737-56ff7146d52d h1:YMXLZHSo8DjytVY/b5dK8LDuyQsVUmBK3ydQMpu2Ui4= -github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4.0.20250730165737-56ff7146d52d/go.mod h1:XIQ1qQfRph6Z5o2EikCydjumo0oDInQySRHuPATzbZc= -github.com/charmbracelet/catwalk v0.4.5 h1:Kv3PadDe8IF8gpcYTfAJdCee5Bv4HufvtNT61FXtq5g= -github.com/charmbracelet/catwalk v0.4.5/go.mod h1:WnKgNPmQHuMyk7GtwAQwl+ezHusfH40IvzML2qwUGwc= +github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4.0.20250805190305-70e94a2e0b2d h1:1C2whi5rgs+APtsQDE4riA+W5neYaMNY+Y+5o6J/rDU= +github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4.0.20250805190305-70e94a2e0b2d/go.mod h1:XIQ1qQfRph6Z5o2EikCydjumo0oDInQySRHuPATzbZc= +github.com/charmbracelet/catwalk v0.4.6 h1:Y0JDq5V4agK8oO3lKC/hhInrYXePGwZPNo8I1Lk08jc= +github.com/charmbracelet/catwalk v0.4.6/go.mod h1:WnKgNPmQHuMyk7GtwAQwl+ezHusfH40IvzML2qwUGwc= github.com/charmbracelet/colorprofile v0.3.1 h1:k8dTHMd7fgw4bnFd7jXTLZrSU/CQrKnL3m+AxCzDz40= github.com/charmbracelet/colorprofile v0.3.1/go.mod h1:/GkGusxNs8VB/RSOh3fu0TJmQ4ICMMPApIIVn0KszZ0= github.com/charmbracelet/fang v0.3.1-0.20250711140230-d5ebb8c1d674 h1:+Cz+VfxD5DO+JT1LlswXWhre0HYLj6l2HW8HVGfMuC0= @@ -90,10 +90,10 @@ github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.3.0.20250721205738-ea66aa652ee0 github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.3.0.20250721205738-ea66aa652ee0/go.mod h1:XIuqKpZTUXtVyeyiN1k9Tc/U7EzfaDnVc34feFHfBws= github.com/charmbracelet/log/v2 v2.0.0-20250226163916-c379e29ff706 h1:WkwO6Ks3mSIGnGuSdKl9qDSyfbYK50z2wc2gGMggegE= github.com/charmbracelet/log/v2 v2.0.0-20250226163916-c379e29ff706/go.mod h1:mjJGp00cxcfvD5xdCa+bso251Jt4owrQvuimJtVmEmM= -github.com/charmbracelet/ultraviolet v0.0.0-20250731212901-76da584cc9a5 h1:FrEzjuUbVbGd8UtZBfK8mf/IA4ExT2i3/fi+SEOv2eM= -github.com/charmbracelet/ultraviolet v0.0.0-20250731212901-76da584cc9a5/go.mod h1:XrrgNFfXLrFAyd9DUmrqVc3yQFVv8Uk+okj4PsNNzpc= -github.com/charmbracelet/x/ansi v0.9.3 h1:BXt5DHS/MKF+LjuK4huWrC6NCvHtexww7dMayh6GXd0= -github.com/charmbracelet/x/ansi v0.9.3/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= +github.com/charmbracelet/ultraviolet v0.0.0-20250805154935-01be9d7ef65d h1:miSXsyi0ARm35O+DulTdaLYoUioWdGkoBfFoIhdqpCA= +github.com/charmbracelet/ultraviolet v0.0.0-20250805154935-01be9d7ef65d/go.mod h1:XrrgNFfXLrFAyd9DUmrqVc3yQFVv8Uk+okj4PsNNzpc= +github.com/charmbracelet/x/ansi v0.10.0 h1:jnOP9pFxY6/gw5nYjkpi6f17K0P/sN4fqT0Y1ioaORI= +github.com/charmbracelet/x/ansi v0.10.0/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= github.com/charmbracelet/x/cellbuf v0.0.14-0.20250516160309-24eee56f89fa h1:lphz0Z3rsiOtMYiz8axkT24i9yFiueDhJbzyNUADmME= github.com/charmbracelet/x/cellbuf v0.0.14-0.20250516160309-24eee56f89fa/go.mod h1:xBlh2Yi3DL3zy/2n15kITpg0YZardf/aa/hgUaIM6Rk= github.com/charmbracelet/x/exp/charmtone v0.0.0-20250708181618-a60a724ba6c3 h1:1xwHZg6eMZ9Wv5TE1UGub6ARubyOd1Lo5kPUI/6VL50= diff --git a/internal/tui/components/chat/chat.go b/internal/tui/components/chat/chat.go index b1af201f33be840fa626244a42aa2d88fa18649c..22ef7099b505977432c650655ec9a30c098f85dd 100644 --- a/internal/tui/components/chat/chat.go +++ b/internal/tui/components/chat/chat.go @@ -2,7 +2,6 @@ package chat import ( "context" - "fmt" "time" "github.com/atotto/clipboard" @@ -45,7 +44,7 @@ type MessageListCmp interface { SetSession(session.Session) tea.Cmd GoToBottom() tea.Cmd GetSelectedText() string - CopySelectedText() tea.Cmd + CopySelectedText(bool) tea.Cmd } // messageListCmp implements MessageListCmp, providing a virtualized list @@ -96,6 +95,10 @@ func (m *messageListCmp) Init() tea.Cmd { // Update handles incoming messages and updates the component state. func (m *messageListCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { + case tea.KeyPressMsg: + if key.Matches(msg, messages.CopyKey) && m.listCmp.HasSelection() { + return m, m.CopySelectedText(true) + } case tea.MouseClickMsg: x := msg.X - 1 // Adjust for padding y := msg.Y - 1 // Adjust for padding @@ -128,11 +131,10 @@ func (m *messageListCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if msg.Button == tea.MouseLeft { if x < 0 || y < 0 || x >= m.width-2 || y >= m.height-1 { m.listCmp.SelectionStop() - return m, m.CopySelectedText() + } else { + m.listCmp.EndSelection(x, y) + m.listCmp.SelectionStop() } - m.listCmp.EndSelection(x, y) - m.listCmp.SelectionStop() - return m, m.CopySelectedText() } return m, nil case pubsub.Event[permission.PermissionNotification]: @@ -155,13 +157,11 @@ func (m *messageListCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { u, cmd := m.listCmp.Update(msg) m.listCmp = u.(list.List[list.Item]) return m, cmd - default: - var cmds []tea.Cmd - u, cmd := m.listCmp.Update(msg) - m.listCmp = u.(list.List[list.Item]) - cmds = append(cmds, cmd) - return m, tea.Batch(cmds...) } + + u, cmd := m.listCmp.Update(msg) + m.listCmp = u.(list.List[list.Item]) + return m, cmd } // View renders the message list or an initial screen if empty. @@ -654,24 +654,51 @@ func (m *messageListCmp) handleMouseClick(x, y int) tea.Cmd { return nil } +// SelectionClear clears the current selection in the list component. +func (m *messageListCmp) SelectionClear() tea.Cmd { + m.listCmp.SelectionClear() + m.previousSelected = "" + m.lastClickX, m.lastClickY = 0, 0 + m.clickCount = 0 + return nil +} + +// HasSelection checks if there is a selection in the list component. +func (m *messageListCmp) HasSelection() bool { + return m.listCmp.HasSelection() +} + // GetSelectedText returns the currently selected text from the list component. func (m *messageListCmp) GetSelectedText() string { return m.listCmp.GetSelectedText(3) // 3 padding for the left border/padding } -// CopySelectedText copies the currently selected text to the clipboard. -func (m *messageListCmp) CopySelectedText() tea.Cmd { - return nil +// CopySelectedText copies the currently selected text to the clipboard. When +// clear is true, it clears the selection after copying. +func (m *messageListCmp) CopySelectedText(clear bool) tea.Cmd { + if !m.listCmp.HasSelection() { + return nil + } + selectedText := m.GetSelectedText() if selectedText == "" { return util.ReportInfo("No text selected") } - err := clipboard.WriteAll(selectedText) - if err != nil { - return util.ReportError(fmt.Errorf("failed to copy selected text to clipboard: %w", err)) + if clear { + defer func() { m.SelectionClear() }() } - return util.ReportInfo("Selected text copied to clipboard") + + return tea.Sequence( + // We use both OSC 52 and native clipboard for compatibility with different + // terminal emulators and environments. + tea.SetClipboard(selectedText), + func() tea.Msg { + _ = clipboard.WriteAll(selectedText) + return nil + }, + util.ReportInfo("Selected text copied to clipboard"), + ) } // abs returns the absolute value of an integer. diff --git a/internal/tui/components/chat/messages/messages.go b/internal/tui/components/chat/messages/messages.go index b1a17eee4e4b91885941deb415cf8c9fd877fe72..bc594ab5e1240ff004bdb9548afa872ac1bb62e8 100644 --- a/internal/tui/components/chat/messages/messages.go +++ b/internal/tui/components/chat/messages/messages.go @@ -25,7 +25,8 @@ import ( "github.com/charmbracelet/crush/internal/tui/util" ) -var copyKey = key.NewBinding(key.WithKeys("c", "y", "C", "Y"), key.WithHelp("c/y", "copy")) +// CopyKey is the key binding for copying message content to the clipboard. +var CopyKey = key.NewBinding(key.WithKeys("c", "y", "C", "Y"), key.WithHelp("c/y", "copy")) // MessageCmp defines the interface for message components in the chat interface. // It combines standard UI model interfaces with message-specific functionality. @@ -99,12 +100,15 @@ func (m *messageCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, cmd } case tea.KeyPressMsg: - if key.Matches(msg, copyKey) { - err := clipboard.WriteAll(m.message.Content().Text) - if err != nil { - return m, util.ReportError(fmt.Errorf("failed to copy message content to clipboard: %w", err)) - } - return m, util.ReportInfo("Message copied to clipboard") + if key.Matches(msg, CopyKey) { + return m, tea.Sequence( + tea.SetClipboard(m.message.Content().Text), + func() tea.Msg { + _ = clipboard.WriteAll(m.message.Content().Text) + return nil + }, + util.ReportInfo("Message copied to clipboard"), + ) } } return m, nil diff --git a/internal/tui/components/chat/messages/tool.go b/internal/tui/components/chat/messages/tool.go index 7708b6b3e273471973a355bc77c0110c0be21e45..7e03674f97243e7d9e569b341fe1c6f1d2450b93 100644 --- a/internal/tui/components/chat/messages/tool.go +++ b/internal/tui/components/chat/messages/tool.go @@ -165,7 +165,7 @@ func (m *toolCallCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return m, tea.Batch(cmds...) case tea.KeyPressMsg: - if key.Matches(msg, copyKey) { + if key.Matches(msg, CopyKey) { return m, m.copyTool() } } @@ -198,11 +198,14 @@ func (m *toolCallCmp) SetCancelled() { func (m *toolCallCmp) copyTool() tea.Cmd { content := m.formatToolForCopy() - err := clipboard.WriteAll(content) - if err != nil { - return util.ReportError(fmt.Errorf("failed to copy tool content to clipboard: %w", err)) - } - return util.ReportInfo("Tool content copied to clipboard") + return tea.Sequence( + tea.SetClipboard(content), + func() tea.Msg { + _ = clipboard.WriteAll(content) + return nil + }, + util.ReportInfo("Tool content copied to clipboard"), + ) } func (m *toolCallCmp) formatToolForCopy() string { diff --git a/internal/tui/components/dialogs/permissions/keys.go b/internal/tui/components/dialogs/permissions/keys.go index 9edc368d275d90d670eeb8f03346184d3edea800..4b7660ceb2310595fc0ad7d1ce51dade83169035 100644 --- a/internal/tui/components/dialogs/permissions/keys.go +++ b/internal/tui/components/dialogs/permissions/keys.go @@ -42,7 +42,7 @@ func DefaultKeyMap() KeyMap { key.WithHelp("s", "allow session"), ), Deny: key.NewBinding( - key.WithKeys("d", "D", "ctrl+d"), + key.WithKeys("d", "D", "ctrl+d", "esc"), key.WithHelp("d", "deny"), ), Select: key.NewBinding( diff --git a/internal/tui/components/mcp/mcp.go b/internal/tui/components/mcp/mcp.go index 2376011ae1f18f44962d59f142652a52bfc47c3d..d11826b77749ba65276b5336a5d88cdbc8552881 100644 --- a/internal/tui/components/mcp/mcp.go +++ b/internal/tui/components/mcp/mcp.go @@ -68,7 +68,7 @@ func RenderMCPList(opts RenderOptions) []string { case agent.MCPStateConnected: icon = t.ItemOnlineIcon if state.ToolCount > 0 { - extraContent = t.S().Subtle.Render(fmt.Sprintf("(%d tools)", state.ToolCount)) + extraContent = t.S().Subtle.Render(fmt.Sprintf("%d tools", state.ToolCount)) } case agent.MCPStateError: icon = t.ItemErrorIcon diff --git a/internal/tui/exp/list/list.go b/internal/tui/exp/list/list.go index f1a798a7dd23a534157ba0a143936d157b0a4dbe..d40f8d26128eef0d6930ff598c4aa21b971fd75e 100644 --- a/internal/tui/exp/list/list.go +++ b/internal/tui/exp/list/list.go @@ -56,6 +56,7 @@ type List[T Item] interface { SelectWord(col, line int) SelectParagraph(col, line int) GetSelectedText(paddingLeft int) string + HasSelection() bool } type direction int @@ -286,30 +287,10 @@ func (l *list[T]) handleMouseWheel(msg tea.MouseWheelMsg) (tea.Model, tea.Cmd) { return l, cmd } -// View implements List. -func (l *list[T]) View() string { - if l.height <= 0 || l.width <= 0 { - return "" - } +// selectionView renders the highlighted selection in the view and returns it +// as a string. If textOnly is true, it won't render any styles. +func (l *list[T]) selectionView(view string, textOnly bool) string { t := styles.CurrentTheme() - view := l.rendered - lines := strings.Split(view, "\n") - - start, end := l.viewPosition() - viewStart := max(0, start) - viewEnd := min(len(lines), end+1) - lines = lines[viewStart:viewEnd] - if l.resize { - return strings.Join(lines, "\n") - } - view = t.S().Base. - Height(l.height). - Width(l.width). - Render(strings.Join(lines, "\n")) - - if !l.hasSelection() { - return view - } area := uv.Rect(0, 0, l.width, l.height) scr := uv.NewScreenBuffer(area.Dx(), area.Dy()) uv.NewStyledString(view).Draw(scr, area) @@ -397,6 +378,8 @@ func (l *list[T]) View() string { lineTextBounds[y] = bounds } + var selectedText strings.Builder + // Second pass: apply selection highlighting for y := range scr.Height() { selBounds := lineSelections[y] @@ -406,6 +389,11 @@ func (l *list[T]) View() string { textBounds := lineTextBounds[y] if textBounds.start < 0 { + if textOnly { + // We don't want to get rid of all empty lines in text-only mode + selectedText.WriteByte('\n') + } + continue // No text on this line } @@ -421,16 +409,63 @@ func (l *list[T]) View() string { cellStr := cell.String() if len(cellStr) > 0 && !specialChars[cellStr] { + if textOnly { + // Collect selected text without styles + selectedText.WriteString(cell.String()) + continue + } + + // Text selection styling, which is a Lip Gloss style. We must + // extract the values to use in a UV style, below. + ts := t.TextSelection + cell = cell.Clone() - cell.Style = cell.Style.Background(t.BgOverlay).Foreground(t.White) + cell.Style = cell.Style.Background(ts.GetBackground()).Foreground(ts.GetForeground()) scr.SetCell(x, y, cell) } } + + if textOnly { + // Make sure we add a newline after each line of selected text + selectedText.WriteByte('\n') + } + } + + if textOnly { + return strings.TrimSpace(selectedText.String()) } return scr.Render() } +// View implements List. +func (l *list[T]) View() string { + if l.height <= 0 || l.width <= 0 { + return "" + } + t := styles.CurrentTheme() + view := l.rendered + lines := strings.Split(view, "\n") + + start, end := l.viewPosition() + viewStart := max(0, start) + viewEnd := min(len(lines), end+1) + lines = lines[viewStart:viewEnd] + if l.resize { + return strings.Join(lines, "\n") + } + view = t.S().Base. + Height(l.height). + Width(l.width). + Render(strings.Join(lines, "\n")) + + if !l.hasSelection() { + return view + } + + return l.selectionView(view, false) +} + func (l *list[T]) viewPosition() (int, int) { start, end := 0, 0 renderedLines := lipgloss.Height(l.rendered) - 1 @@ -1374,69 +1409,16 @@ func (l *list[T]) SelectParagraph(col, line int) { l.selectionActive = false // Not actively selecting, just selected } +// HasSelection returns whether there is an active selection. +func (l *list[T]) HasSelection() bool { + return l.hasSelection() +} + // GetSelectedText returns the currently selected text. func (l *list[T]) GetSelectedText(paddingLeft int) string { - return "" - // if !l.hasSelection() { - // return "" - // } - // - // startLine := l.selectionStartLine - // endLine := l.selectionEndLine - // startCol := l.selectionStartCol - // endCol := l.selectionEndCol - // - // if l.direction == DirectionBackward { - // startLine = (lipgloss.Height(l.rendered) - 1) - startLine - // endLine = (lipgloss.Height(l.rendered) - 1) - endLine - // } - // - // if l.offset > 0 { - // if l.direction == DirectionBackward { - // startLine += l.offset - // endLine += l.offset - // } else { - // startLine -= l.offset - // endLine -= l.offset - // } - // } - // - // lines := strings.Split(l.rendered, "\n") - // - // if startLine < 0 || endLine < 0 || startLine >= len(lines) || endLine >= len(lines) { - // return "" - // } - // - // var result strings.Builder - // for i := range lines { - // lines[i] = ansi.Strip(lines[i]) - // for _, icon := range styles.SelectionIgnoreIcons { - // lines[i] = strings.ReplaceAll(lines[i], icon, " ") - // } - // - // if i == startLine { - // if startCol < 0 || startCol >= len(lines[i]) { - // startCol = 0 - // } - // if startCol < paddingLeft { - // startCol = paddingLeft - // } - // if i != endLine { - // endCol = len(lines[i]) - // } - // result.WriteString(strings.TrimRightFunc(lines[i][startCol:endCol], unicode.IsSpace)) - // } else if i > startLine && i < endLine { - // result.WriteString(strings.TrimRightFunc(lines[i][paddingLeft:], unicode.IsSpace)) - // } else if i == endLine { - // if endCol < 0 || endCol >= len(lines[i]) { - // endCol = len(lines[i]) - // } - // if endCol < paddingLeft { - // endCol = paddingLeft - // } - // result.WriteString(strings.TrimRightFunc(lines[i][paddingLeft:endCol], unicode.IsSpace)) - // } - // } - // - // return result.String() + if !l.hasSelection() { + return "" + } + + return l.selectionView(l.View(), true) } diff --git a/internal/tui/page/chat/chat.go b/internal/tui/page/chat/chat.go index 6fd3849fbf37d01489f624924c77c299a06d2087..0276ab2a917bf9225d381b483f403d064fefe39a 100644 --- a/internal/tui/page/chat/chat.go +++ b/internal/tui/page/chat/chat.go @@ -19,6 +19,7 @@ import ( "github.com/charmbracelet/crush/internal/tui/components/chat" "github.com/charmbracelet/crush/internal/tui/components/chat/editor" "github.com/charmbracelet/crush/internal/tui/components/chat/header" + "github.com/charmbracelet/crush/internal/tui/components/chat/messages" "github.com/charmbracelet/crush/internal/tui/components/chat/sidebar" "github.com/charmbracelet/crush/internal/tui/components/chat/splash" "github.com/charmbracelet/crush/internal/tui/components/completions" @@ -172,10 +173,18 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return p, nil case tea.MouseClickMsg: + if p.isMouseOverChat(msg.X, msg.Y) { + p.focusedPane = PanelTypeChat + p.chat.Focus() + p.editor.Blur() + } else { + p.focusedPane = PanelTypeEditor + p.editor.Focus() + p.chat.Blur() + } u, cmd := p.chat.Update(msg) p.chat = u.(chat.MessageListCmp) return p, cmd - return p, nil case tea.MouseMotionMsg: if msg.Button == tea.MouseLeft { u, cmd := p.chat.Update(msg) @@ -857,10 +866,7 @@ func (p *chatPage) Help() help.KeyMap { key.WithKeys("up", "down"), key.WithHelp("↑↓", "scroll"), ), - key.NewBinding( - key.WithKeys("c", "y"), - key.WithHelp("c/y", "copy"), - ), + messages.CopyKey, ) fullList = append(fullList, []key.Binding{ diff --git a/internal/tui/styles/crush.go b/internal/tui/styles/charmtone.go similarity index 89% rename from internal/tui/styles/crush.go rename to internal/tui/styles/charmtone.go index f27632784ad64ed3228ee548c7c8fe84b58bc9ec..2e3783f522eac79cd1feb432fe0e399be0802882 100644 --- a/internal/tui/styles/crush.go +++ b/internal/tui/styles/charmtone.go @@ -5,9 +5,9 @@ import ( "github.com/charmbracelet/x/exp/charmtone" ) -func NewCrushTheme() *Theme { +func NewCharmtoneTheme() *Theme { t := &Theme{ - Name: "crush", + Name: "charmtone", IsDark: true, Primary: charmtone.Charple, @@ -56,6 +56,9 @@ func NewCrushTheme() *Theme { Cherry: charmtone.Cherry, } + // Text selection. + t.TextSelection = lipgloss.NewStyle().Foreground(charmtone.Salt).Background(charmtone.Charple) + // LSP and MCP status. t.ItemOfflineIcon = lipgloss.NewStyle().Foreground(charmtone.Squid).SetString("●") t.ItemBusyIcon = t.ItemOfflineIcon.Foreground(charmtone.Citron) diff --git a/internal/tui/styles/theme.go b/internal/tui/styles/theme.go index e917cb2b6ffc1ff864012366e0711b66ccf1be83..0503539ba720188a0894d26abd299b54d602494e 100644 --- a/internal/tui/styles/theme.go +++ b/internal/tui/styles/theme.go @@ -74,6 +74,9 @@ type Theme struct { RedLight color.Color Cherry color.Color + // Text selection. + TextSelection lipgloss.Style + // LSP and MCP status indicators. ItemOfflineIcon lipgloss.Style ItemBusyIcon lipgloss.Style @@ -491,26 +494,26 @@ func SetDefaultManager(m *Manager) { func DefaultManager() *Manager { if defaultManager == nil { - defaultManager = NewManager("crush") + defaultManager = NewManager() } return defaultManager } func CurrentTheme() *Theme { if defaultManager == nil { - defaultManager = NewManager("crush") + defaultManager = NewManager() } return defaultManager.Current() } -func NewManager(defaultTheme string) *Manager { +func NewManager() *Manager { m := &Manager{ themes: make(map[string]*Theme), } - m.Register(NewCrushTheme()) - - m.current = m.themes[defaultTheme] + t := NewCharmtoneTheme() // default theme + m.Register(t) + m.current = m.themes[t.Name] return m }