diff --git a/packages/tui/go.mod b/packages/tui/go.mod index 0b4698383afa..e23e05c300fa 100644 --- a/packages/tui/go.mod +++ b/packages/tui/go.mod @@ -5,7 +5,6 @@ go 1.24.0 require ( github.com/BurntSushi/toml v1.5.0 github.com/alecthomas/chroma/v2 v2.18.0 - github.com/charmbracelet/bubbles v0.21.0 github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1 github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4 github.com/charmbracelet/glamour v0.10.0 diff --git a/packages/tui/go.sum b/packages/tui/go.sum index f41abaf42584..370ea7121174 100644 --- a/packages/tui/go.sum +++ b/packages/tui/go.sum @@ -20,8 +20,6 @@ github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWp github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= -github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs= -github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg= github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1 h1:swACzss0FjnyPz1enfX56GKkLiuKg5FlyVmOLIlU2kE= github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1/go.mod h1:6HamsBKWqEC/FVHuQMHgQL+knPyvHH55HwJDHl/adMw= github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4 h1:UgUuKKvBwgqm2ZEL+sKv/OLeavrUb4gfHgdxe6oIOno= diff --git a/packages/tui/internal/components/chat/messages.go b/packages/tui/internal/components/chat/messages.go index cbea349ca707..ce80358d8266 100644 --- a/packages/tui/internal/components/chat/messages.go +++ b/packages/tui/internal/components/chat/messages.go @@ -31,6 +31,7 @@ type MessagesComponent interface { GotoTop() (tea.Model, tea.Cmd) GotoBottom() (tea.Model, tea.Cmd) CopyLastMessage() (tea.Model, tea.Cmd) + CopySelection() (tea.Model, tea.Cmd) } type messagesComponent struct { @@ -98,42 +99,72 @@ func (m *messagesComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmds []tea.Cmd switch msg := msg.(type) { case tea.MouseClickMsg: - slog.Info("mouse", "x", msg.X, "y", msg.Y, "offset", m.viewport.YOffset) - y := msg.Y + m.viewport.YOffset - if y > 0 { - m.selection = &selection{ - startY: y, - startX: msg.X, - endY: -1, - endX: -1, - } + if msg.Button == tea.MouseLeft { + headerHeight := lipgloss.Height(m.renderHeader()) + // Account for header and the newline separator + offset := headerHeight + 1 + slog.Info("mouse", "x", msg.X, "y", msg.Y, "offset", m.viewport.YOffset, "offset", offset) + y := (msg.Y - offset) + m.viewport.YOffset + if y > 0 { + m.selection = &selection{ + startY: y, + startX: msg.X, + endY: -1, + endX: -1, + } - slog.Info("mouse selection", "start", fmt.Sprintf("%d,%d", m.selection.startX, m.selection.startY), "end", fmt.Sprintf("%d,%d", m.selection.endX, m.selection.endY)) - return m, m.renderView() + slog.Info("mouse selection", "start", fmt.Sprintf("%d,%d", m.selection.startX, m.selection.startY), "end", fmt.Sprintf("%d,%d", m.selection.endX, m.selection.endY)) + return m, m.renderView() + } + } else if msg.Button == tea.MouseRight { + if m.selection != nil && len(m.clipboard) > 0 { + content := strings.Join(m.clipboard, "\n") + m.selection = nil + m.clipboard = []string{} + return m, tea.Sequence( + m.renderView(), + app.SetClipboard(content), + toast.NewSuccessToast("Copied to clipboard"), + ) + } } case tea.MouseMotionMsg: if m.selection != nil { + headerHeight := lipgloss.Height(m.renderHeader()) + // Account for header and the newline separator + offset := headerHeight + 1 + viewportRelativeY := msg.Y - offset m.selection = &selection{ startX: m.selection.startX, startY: m.selection.startY, endX: msg.X + 1, - endY: msg.Y + m.viewport.YOffset, + endY: viewportRelativeY + m.viewport.YOffset, + } + + // Disable tail mode during selection to allow free scrolling + m.tail = false + + // Auto-scroll when selecting near edges + viewportHeight := m.viewport.Height() + scrollMargin := 2 + + if viewportRelativeY < scrollMargin && m.viewport.YOffset > 0 { + // Scroll up + m.viewport.LineUp(1) + } else if viewportRelativeY > viewportHeight-scrollMargin { + // Scroll down + m.viewport.LineDown(1) } return m, m.renderView() } case tea.MouseReleaseMsg: - if m.selection != nil && len(m.clipboard) > 0 { - content := strings.Join(m.clipboard, "\n") - m.selection = nil - m.clipboard = []string{} - return m, tea.Sequence( - m.renderView(), - app.SetClipboard(content), - toast.NewSuccessToast("Copied to clipboard"), - ) - } + // Re-enable tail mode when selection ends + m.tail = true + // Just handle the mouse release, don't auto-copy or clear selection + // Selection state remains visible for user to manually copy if desired + return m, nil case tea.WindowSizeMsg: effectiveWidth := msg.Width - 4 // Clear cache on resize since width affects rendering @@ -495,7 +526,7 @@ func (m *messagesComponent) renderView() tea.Cmd { clipboard := []string{} var selection *selection if m.selection != nil { - selection = m.selection.coords(lipgloss.Height(header) + 1) + selection = m.selection.coords(0) } for _, block := range blocks { lines := strings.Split(block, "\n") @@ -773,6 +804,20 @@ func (m *messagesComponent) CopyLastMessage() (tea.Model, tea.Cmd) { return m, tea.Batch(cmds...) } +func (m *messagesComponent) CopySelection() (tea.Model, tea.Cmd) { + if m.selection != nil && len(m.clipboard) > 0 { + content := strings.Join(m.clipboard, "\n") + m.selection = nil + m.clipboard = []string{} + return m, tea.Sequence( + m.renderView(), + app.SetClipboard(content), + toast.NewSuccessToast("Copied to clipboard"), + ) + } + return m, nil +} + func NewMessagesComponent(app *app.App) MessagesComponent { vp := viewport.New() vp.KeyMap = viewport.KeyMap{} diff --git a/packages/tui/internal/tui/tui.go b/packages/tui/internal/tui/tui.go index 752e805ea3c9..26ac39eb48a4 100644 --- a/packages/tui/internal/tui/tui.go +++ b/packages/tui/internal/tui/tui.go @@ -276,6 +276,13 @@ func (a Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return a, util.CmdHandler(commands.ExecuteCommandsMsg(matches)) } + if keyString == "ctrl+alt+c" { + updatedMessages, cmd := a.messages.CopySelection() + a.messages = updatedMessages.(chat.MessagesComponent) + cmds = append(cmds, cmd) + return a, tea.Batch(cmds...) + } + // Fallback: suspend if ctrl+z is pressed and no user keybind matched if keyString == "ctrl+z" { return a, tea.Suspend @@ -1045,7 +1052,6 @@ func (a Model) executeCommand(command commands.Command) (tea.Model, tea.Cmd) { updated, cmd := a.messages.CopyLastMessage() a.messages = updated.(chat.MessagesComponent) cmds = append(cmds, cmd) - case commands.MessagesRevertCommand: case commands.AppExitCommand: return a, tea.Quit }