diff --git a/go.mod b/go.mod index 9b6b779ec242d204c826ffd64b0455dc568f67e3..035e050bdfd6d6c07e876770c73716ce5f014f64 100644 --- a/go.mod +++ b/go.mod @@ -48,22 +48,10 @@ require ( mvdan.cc/sh/v3 v3.12.1-0.20250726150758-e256f53bade8 ) -require ( - cloud.google.com/go/auth/oauth2adapt v0.2.6 // indirect - github.com/bahlo/generic-list-go v0.2.0 // indirect - github.com/buger/jsonparser v1.1.1 // indirect - github.com/mailru/easyjson v0.7.7 // indirect - github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect - go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.54.0 // indirect - golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // indirect - golang.org/x/oauth2 v0.30.0 // indirect - golang.org/x/time v0.8.0 // indirect - google.golang.org/api v0.211.0 // indirect -) - require ( cloud.google.com/go v0.116.0 // indirect cloud.google.com/go/auth v0.13.0 // indirect + cloud.google.com/go/auth/oauth2adapt v0.2.6 // indirect cloud.google.com/go/compute/metadata v0.6.0 // indirect github.com/Azure/azure-sdk-for-go/sdk/azcore v1.17.0 // indirect github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0 // indirect @@ -84,8 +72,10 @@ require ( github.com/aws/smithy-go v1.20.3 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/aymerick/douceur v0.2.0 // indirect + 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-20250805154935-01be9d7ef65d // indirect + 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 @@ -108,6 +98,7 @@ require ( github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/klauspost/cpuid/v2 v2.0.9 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 + github.com/mailru/easyjson v0.7.7 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect github.com/mfridman/interpolate v0.0.2 // indirect @@ -128,23 +119,29 @@ require ( github.com/tidwall/gjson v1.18.0 // indirect github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.1 // indirect + github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect github.com/yuin/goldmark v1.7.8 // indirect github.com/yuin/goldmark-emoji v1.0.5 // indirect go.opentelemetry.io/auto/sdk v1.1.0 // indirect + go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.54.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 // indirect go.opentelemetry.io/otel v1.35.0 // indirect go.opentelemetry.io/otel/metric v1.35.0 // indirect go.opentelemetry.io/otel/trace v1.35.0 // indirect go.uber.org/multierr v1.11.0 // indirect golang.org/x/crypto v0.38.0 // indirect + golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // indirect golang.org/x/image v0.26.0 // indirect golang.org/x/net v0.40.0 // indirect + golang.org/x/oauth2 v0.30.0 // indirect golang.org/x/sync v0.16.0 // indirect golang.org/x/sys v0.34.0 golang.org/x/term v0.32.0 // indirect golang.org/x/text v0.27.0 + golang.org/x/time v0.8.0 // indirect + google.golang.org/api v0.211.0 // indirect google.golang.org/genai v1.3.0 google.golang.org/genproto/googleapis/rpc v0.0.0-20250324211829-b45e905df463 // indirect google.golang.org/grpc v1.71.0 // indirect diff --git a/internal/tui/components/chat/chat.go b/internal/tui/components/chat/chat.go index f691f211246ad13a5b9500fd6424169b93be02da..74f7af06c09e2d13ccd3dd707df84bbba8c0e89f 100644 --- a/internal/tui/components/chat/chat.go +++ b/internal/tui/components/chat/chat.go @@ -4,6 +4,7 @@ import ( "context" "time" + "github.com/atotto/clipboard" "github.com/charmbracelet/bubbles/v2/key" tea "github.com/charmbracelet/bubbletea/v2" "github.com/charmbracelet/crush/internal/app" @@ -28,6 +29,12 @@ type SessionSelectedMsg = session.Session type SessionClearedMsg struct{} +type SelectionCopyMsg struct { + clickCount int + endSelection bool + x, y int +} + const ( NotFound = -1 ) @@ -42,6 +49,8 @@ type MessageListCmp interface { SetSession(session.Session) tea.Cmd GoToBottom() tea.Cmd + GetSelectedText() string + CopySelectedText(bool) tea.Cmd } // messageListCmp implements MessageListCmp, providing a virtualized list @@ -56,6 +65,12 @@ type messageListCmp struct { lastUserMessageTime int64 defaultListKeyMap list.KeyMap + + // Click tracking for double/triple click detection + lastClickTime time.Time + lastClickX int + lastClickY int + clickCount int } // New creates a new message list component with custom keybindings @@ -86,6 +101,73 @@ 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 m.listCmp.IsFocused() && m.listCmp.HasSelection() { + switch { + case key.Matches(msg, messages.CopyKey): + return m, m.CopySelectedText(true) + case key.Matches(msg, messages.ClearSelectionKey): + return m, m.SelectionClear() + } + } + case tea.MouseClickMsg: + x := msg.X - 1 // Adjust for padding + y := msg.Y - 1 // Adjust for padding + if x < 0 || y < 0 || x >= m.width-2 || y >= m.height-1 { + return m, nil // Ignore clicks outside the component + } + if msg.Button == tea.MouseLeft { + return m, m.handleMouseClick(x, y) + } + return m, nil + case tea.MouseMotionMsg: + x := msg.X - 1 // Adjust for padding + y := msg.Y - 1 // Adjust for padding + if x < 0 || y < 0 || x >= m.width-2 || y >= m.height-1 { + if y < 0 { + return m, m.listCmp.MoveUp(1) + } + if y >= m.height-1 { + return m, m.listCmp.MoveDown(1) + } + return m, nil // Ignore clicks outside the component + } + if msg.Button == tea.MouseLeft { + m.listCmp.EndSelection(x, y) + } + return m, nil + case tea.MouseReleaseMsg: + x := msg.X - 1 // Adjust for padding + y := msg.Y - 1 // Adjust for padding + if msg.Button == tea.MouseLeft { + clickCount := m.clickCount + if x < 0 || y < 0 || x >= m.width-2 || y >= m.height-1 { + return m, tea.Tick(doubleClickThreshold, func(time.Time) tea.Msg { + return SelectionCopyMsg{ + clickCount: clickCount, + endSelection: false, + } + }) + } + return m, tea.Tick(doubleClickThreshold, func(time.Time) tea.Msg { + return SelectionCopyMsg{ + clickCount: clickCount, + endSelection: true, + x: x, + y: y, + } + }) + } + return m, nil + case SelectionCopyMsg: + if msg.clickCount == m.clickCount && time.Since(m.lastClickTime) >= doubleClickThreshold { + // If the click count matches and within threshold, copy selected text + if msg.endSelection { + m.listCmp.EndSelection(msg.x, msg.y) + } + m.listCmp.SelectionStop() + return m, m.CopySelectedText(true) + } case pubsub.Event[permission.PermissionNotification]: return m, m.handlePermissionRequest(msg.Payload) case SessionSelectedMsg: @@ -106,13 +188,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. @@ -566,3 +646,97 @@ func (m *messageListCmp) Bindings() []key.Binding { func (m *messageListCmp) GoToBottom() tea.Cmd { return m.listCmp.GoToBottom() } + +const ( + doubleClickThreshold = 500 * time.Millisecond + clickTolerance = 2 // pixels +) + +// handleMouseClick handles mouse click events and detects double/triple clicks. +func (m *messageListCmp) handleMouseClick(x, y int) tea.Cmd { + now := time.Now() + + // Check if this is a potential multi-click + if now.Sub(m.lastClickTime) <= doubleClickThreshold && + abs(x-m.lastClickX) <= clickTolerance && + abs(y-m.lastClickY) <= clickTolerance { + m.clickCount++ + } else { + m.clickCount = 1 + } + + m.lastClickTime = now + m.lastClickX = x + m.lastClickY = y + + switch m.clickCount { + case 1: + // Single click - start selection + m.listCmp.StartSelection(x, y) + case 2: + // Double click - select word + m.listCmp.SelectWord(x, y) + case 3: + // Triple click - select paragraph + m.listCmp.SelectParagraph(x, y) + m.clickCount = 0 // Reset after triple click + } + + 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.lastClickTime = time.Time{} + 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. 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") + } + + if clear { + defer func() { m.SelectionClear() }() + } + + 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. +func abs(x int) int { + if x < 0 { + return -x + } + return x +} diff --git a/internal/tui/components/chat/messages/messages.go b/internal/tui/components/chat/messages/messages.go index b1a17eee4e4b91885941deb415cf8c9fd877fe72..ec55800aab85a2dbb07153c12300dbad892b3b6a 100644 --- a/internal/tui/components/chat/messages/messages.go +++ b/internal/tui/components/chat/messages/messages.go @@ -25,7 +25,11 @@ 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")) + +// ClearSelectionKey is the key binding for clearing the current selection in the chat interface. +var ClearSelectionKey = key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "clear selection")) // MessageCmp defines the interface for message components in the chat interface. // It combines standard UI model interfaces with message-specific functionality. @@ -99,12 +103,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/exp/list/list.go b/internal/tui/exp/list/list.go index 4bf8b2dbbc4ffde261465c8ebd655a26f2344852..595e21334d37b218f3d4fc8892cd03e4a1010d41 100644 --- a/internal/tui/exp/list/list.go +++ b/internal/tui/exp/list/list.go @@ -13,6 +13,9 @@ import ( "github.com/charmbracelet/crush/internal/tui/styles" "github.com/charmbracelet/crush/internal/tui/util" "github.com/charmbracelet/lipgloss/v2" + uv "github.com/charmbracelet/ultraviolet" + "github.com/charmbracelet/x/ansi" + "github.com/rivo/uniseg" ) type Item interface { @@ -46,6 +49,14 @@ type List[T Item] interface { DeleteItem(string) tea.Cmd PrependItem(T) tea.Cmd AppendItem(T) tea.Cmd + StartSelection(col, line int) + EndSelection(col, line int) + SelectionStop() + SelectionClear() + SelectWord(col, line int) + SelectParagraph(col, line int) + GetSelectedText(paddingLeft int) string + HasSelection() bool } type direction int @@ -94,7 +105,13 @@ type list[T Item] struct { renderMu sync.Mutex rendered string - movingByItem bool + movingByItem bool + selectionStartCol int + selectionStartLine int + selectionEndCol int + selectionEndLine int + + selectionActive bool } type ListOption func(*confOptions) @@ -172,9 +189,13 @@ func New[T Item](items []T, opts ...ListOption) List[T] { keyMap: DefaultKeyMap(), focused: true, }, - items: csync.NewSliceFrom(items), - indexMap: csync.NewMap[string, int](), - renderedItems: csync.NewMap[string, renderedItem](), + items: csync.NewSliceFrom(items), + indexMap: csync.NewMap[string, int](), + renderedItems: csync.NewMap[string, renderedItem](), + selectionStartCol: -1, + selectionStartLine: -1, + selectionEndLine: -1, + selectionEndCol: -1, } for _, opt := range opts { opt(list.confOptions) @@ -266,6 +287,157 @@ func (l *list[T]) handleMouseWheel(msg tea.MouseWheelMsg) (tea.Model, tea.Cmd) { return l, cmd } +// 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() + area := uv.Rect(0, 0, l.width, l.height) + scr := uv.NewScreenBuffer(area.Dx(), area.Dy()) + uv.NewStyledString(view).Draw(scr, area) + + selArea := uv.Rectangle{ + Min: uv.Pos(l.selectionStartCol, l.selectionStartLine), + Max: uv.Pos(l.selectionEndCol, l.selectionEndLine), + } + selArea = selArea.Canon() + + specialChars := make(map[string]bool, len(styles.SelectionIgnoreIcons)) + for _, icon := range styles.SelectionIgnoreIcons { + specialChars[icon] = true + } + + isNonWhitespace := func(r rune) bool { + return r != ' ' && r != '\t' && r != 0 && r != '\n' && r != '\r' + } + + type selectionBounds struct { + startX, endX int + inSelection bool + } + lineSelections := make([]selectionBounds, scr.Height()) + + for y := range scr.Height() { + bounds := selectionBounds{startX: -1, endX: -1, inSelection: false} + + if y >= selArea.Min.Y && y <= selArea.Max.Y { + bounds.inSelection = true + if selArea.Min.Y == selArea.Max.Y { + // Single line selection + bounds.startX = selArea.Min.X + bounds.endX = selArea.Max.X + } else if y == selArea.Min.Y { + // First line of multi-line selection + bounds.startX = selArea.Min.X + bounds.endX = scr.Width() + } else if y == selArea.Max.Y { + // Last line of multi-line selection + bounds.startX = 0 + bounds.endX = selArea.Max.X + } else { + // Middle lines + bounds.startX = 0 + bounds.endX = scr.Width() + } + } + lineSelections[y] = bounds + } + + type lineBounds struct { + start, end int + } + lineTextBounds := make([]lineBounds, scr.Height()) + + // First pass: find text bounds for lines that have selections + for y := range scr.Height() { + bounds := lineBounds{start: -1, end: -1} + + // Only process lines that might have selections + if lineSelections[y].inSelection { + for x := range scr.Width() { + cell := scr.CellAt(x, y) + if cell == nil { + continue + } + + cellStr := cell.String() + if len(cellStr) == 0 { + continue + } + + char := rune(cellStr[0]) + isSpecial := specialChars[cellStr] + + if (isNonWhitespace(char) && !isSpecial) || cell.Style.Bg != nil { + if bounds.start == -1 { + bounds.start = x + } + bounds.end = x + 1 // Position after last character + } + } + } + lineTextBounds[y] = bounds + } + + var selectedText strings.Builder + + // Second pass: apply selection highlighting + for y := range scr.Height() { + selBounds := lineSelections[y] + if !selBounds.inSelection { + continue + } + + 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 + } + + // Only scan within the intersection of text bounds and selection bounds + scanStart := max(textBounds.start, selBounds.startX) + scanEnd := min(textBounds.end, selBounds.endX) + + for x := scanStart; x < scanEnd; x++ { + cell := scr.CellAt(x, y) + if cell == nil { + continue + } + + 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(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 { @@ -282,10 +454,16 @@ func (l *list[T]) View() string { if l.resize { return strings.Join(lines, "\n") } - return t.S().Base. + 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) { @@ -817,21 +995,67 @@ func (l *list[T]) decrementOffset(n int) { // MoveDown implements List. func (l *list[T]) MoveDown(n int) tea.Cmd { + oldOffset := l.offset if l.direction == DirectionForward { l.incrementOffset(n) } else { l.decrementOffset(n) } + + if oldOffset == l.offset { + // no change in offset, so no need to change selection + return nil + } + // if we are not actively selecting move the whole selection down + if l.hasSelection() && !l.selectionActive { + if l.selectionStartLine < l.selectionEndLine { + l.selectionStartLine -= n + l.selectionEndLine -= n + } else { + l.selectionStartLine -= n + l.selectionEndLine -= n + } + } + if l.selectionActive { + if l.selectionStartLine < l.selectionEndLine { + l.selectionStartLine -= n + } else { + l.selectionEndLine -= n + } + } return l.changeSelectionWhenScrolling() } // MoveUp implements List. func (l *list[T]) MoveUp(n int) tea.Cmd { + oldOffset := l.offset if l.direction == DirectionForward { l.decrementOffset(n) } else { l.incrementOffset(n) } + + if oldOffset == l.offset { + // no change in offset, so no need to change selection + return nil + } + + if l.hasSelection() && !l.selectionActive { + if l.selectionStartLine > l.selectionEndLine { + l.selectionStartLine += n + l.selectionEndLine += n + } else { + l.selectionStartLine += n + l.selectionEndLine += n + } + } + if l.selectionActive { + if l.selectionStartLine > l.selectionEndLine { + l.selectionStartLine += n + } else { + l.selectionEndLine += n + } + } return l.changeSelectionWhenScrolling() } @@ -1029,3 +1253,172 @@ func (l *list[T]) UpdateItem(id string, item T) tea.Cmd { } return tea.Sequence(cmds...) } + +func (l *list[T]) hasSelection() bool { + return l.selectionEndCol != l.selectionStartCol || l.selectionEndLine != l.selectionStartLine +} + +// StartSelection implements List. +func (l *list[T]) StartSelection(col, line int) { + l.selectionStartCol = col + l.selectionStartLine = line + l.selectionEndCol = col + l.selectionEndLine = line + l.selectionActive = true +} + +// EndSelection implements List. +func (l *list[T]) EndSelection(col, line int) { + if !l.selectionActive { + return + } + l.selectionEndCol = col + l.selectionEndLine = line +} + +func (l *list[T]) SelectionStop() { + l.selectionActive = false +} + +func (l *list[T]) SelectionClear() { + l.selectionStartCol = -1 + l.selectionStartLine = -1 + l.selectionEndCol = -1 + l.selectionEndLine = -1 + l.selectionActive = false +} + +func (l *list[T]) findWordBoundaries(col, line int) (startCol, endCol int) { + lines := strings.Split(l.rendered, "\n") + for i, l := range lines { + lines[i] = ansi.Strip(l) + } + + if l.direction == DirectionBackward && len(lines) > l.height { + line = ((len(lines) - 1) - l.height) + line + 1 + } + + if l.offset > 0 { + if l.direction == DirectionBackward { + line -= l.offset + } else { + line += l.offset + } + } + + currentLine := lines[line] + gr := uniseg.NewGraphemes(currentLine) + startCol = -1 + upTo := col + for gr.Next() { + if gr.IsWordBoundary() && upTo > 0 { + startCol = col - upTo + 1 + } else if gr.IsWordBoundary() && upTo < 0 { + endCol = col - upTo + 1 + break + } + if upTo == 0 && gr.Str() == " " { + return 0, 0 + } + upTo -= 1 + } + if startCol == -1 { + return 0, 0 + } + return +} + +func (l *list[T]) findParagraphBoundaries(line int) (startLine, endLine int, found bool) { + lines := strings.Split(l.rendered, "\n") + for i, l := range lines { + lines[i] = ansi.Strip(l) + for _, icon := range styles.SelectionIgnoreIcons { + lines[i] = strings.ReplaceAll(lines[i], icon, " ") + } + } + if l.direction == DirectionBackward && len(lines) > l.height { + line = (len(lines) - 1) - l.height + line + 1 + } + + if strings.TrimSpace(lines[line]) == "" { + return 0, 0, false + } + + if l.offset > 0 { + if l.direction == DirectionBackward { + line -= l.offset + } else { + line += l.offset + } + } + + // Ensure line is within bounds + if line < 0 || line >= len(lines) { + return 0, 0, false + } + + // Find start of paragraph (search backwards for empty line or start of text) + startLine = line + for startLine > 0 && strings.TrimSpace(lines[startLine-1]) != "" { + startLine-- + } + + // Find end of paragraph (search forwards for empty line or end of text) + endLine = line + for endLine < len(lines)-1 && strings.TrimSpace(lines[endLine+1]) != "" { + endLine++ + } + + // revert the line numbers if we are in backward direction + if l.direction == DirectionBackward && len(lines) > l.height { + startLine = startLine - (len(lines) - 1) + l.height - 1 + endLine = endLine - (len(lines) - 1) + l.height - 1 + } + if l.offset > 0 { + if l.direction == DirectionBackward { + startLine += l.offset + endLine += l.offset + } else { + startLine -= l.offset + endLine -= l.offset + } + } + return startLine, endLine, true +} + +// SelectWord selects the word at the given position. +func (l *list[T]) SelectWord(col, line int) { + startCol, endCol := l.findWordBoundaries(col, line) + l.selectionStartCol = startCol + l.selectionStartLine = line + l.selectionEndCol = endCol + l.selectionEndLine = line + l.selectionActive = false // Not actively selecting, just selected +} + +// SelectParagraph selects the paragraph at the given position. +func (l *list[T]) SelectParagraph(col, line int) { + startLine, endLine, found := l.findParagraphBoundaries(line) + if !found { + return + } + l.selectionStartCol = 0 + l.selectionStartLine = startLine + l.selectionEndCol = l.width - 1 + l.selectionEndLine = endLine + 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 { + 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 26bd464a59e02632d3817f7a5582ac1f9d4a0a03..b418abc4989f33433c2c4d12d0aa5aab7069254d 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" @@ -165,12 +166,55 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) { p.keyboardEnhancements = msg return p, nil case tea.MouseWheelMsg: - if p.isMouseOverChat(msg.Mouse().X, msg.Mouse().Y) { + if p.compact { + msg.Y -= 1 + } + if p.isMouseOverChat(msg.X, msg.Y) { + u, cmd := p.chat.Update(msg) + p.chat = u.(chat.MessageListCmp) + return p, cmd + } + return p, nil + case tea.MouseClickMsg: + if p.compact { + msg.Y -= 1 + } + 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 + case tea.MouseMotionMsg: + if p.compact { + msg.Y -= 1 + } + if msg.Button == tea.MouseLeft { + u, cmd := p.chat.Update(msg) + p.chat = u.(chat.MessageListCmp) + return p, cmd + } + return p, nil + case tea.MouseReleaseMsg: + if p.compact { + msg.Y -= 1 + } + if msg.Button == tea.MouseLeft { u, cmd := p.chat.Update(msg) p.chat = u.(chat.MessageListCmp) return p, cmd } return p, nil + case chat.SelectionCopyMsg: + u, cmd := p.chat.Update(msg) + p.chat = u.(chat.MessageListCmp) + return p, cmd case tea.WindowSizeMsg: u, cmd := p.editor.Update(msg) p.editor = u.(editor.Editor) @@ -838,10 +882,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{ @@ -880,6 +921,10 @@ func (p *chatPage) Help() help.KeyMap { key.WithHelp("G", "end"), ), }, + []key.Binding{ + messages.CopyKey, + messages.ClearSelectionKey, + }, ) case PanelTypeEditor: newLineBinding := key.NewBinding( diff --git a/internal/tui/styles/charmtone.go b/internal/tui/styles/charmtone.go index cf3d6a092e88fa7832e7eda57c6e10be328c075b..2e3783f522eac79cd1feb432fe0e399be0802882 100644 --- a/internal/tui/styles/charmtone.go +++ b/internal/tui/styles/charmtone.go @@ -56,6 +56,9 @@ func NewCharmtoneTheme() *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/icons.go b/internal/tui/styles/icons.go index c3038abe4b0462855f1aac39c1114b4bc4c5ac32..d9d1ab06f96ff64f8772e1b0f4b099a0ebed2b0a 100644 --- a/internal/tui/styles/icons.go +++ b/internal/tui/styles/icons.go @@ -15,4 +15,27 @@ const ( ToolPending string = "●" ToolSuccess string = "✓" ToolError string = "×" + + BorderThin string = "│" + BorderThick string = "▌" ) + +var SelectionIgnoreIcons = []string{ + // CheckIcon, + // ErrorIcon, + // WarningIcon, + // InfoIcon, + // HintIcon, + // SpinnerIcon, + // LoadingIcon, + // DocumentIcon, + // ModelIcon, + // + // // Tool call icons + // ToolPending, + // ToolSuccess, + // ToolError, + + BorderThin, + BorderThick, +} diff --git a/internal/tui/styles/theme.go b/internal/tui/styles/theme.go index 706bd199491daaff525b13dd808a52dae5f359eb..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 diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 333826f564e2909fc0689e117ab5f85f947b410f..60f10de20fc231999557d2aba8d7d8b35fd70659 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -41,7 +41,7 @@ func MouseEventFilter(m tea.Model, msg tea.Msg) tea.Msg { case tea.MouseWheelMsg, tea.MouseMotionMsg: now := time.Now() // trackpad is sending too many requests - if now.Sub(lastMouseEvent) < 5*time.Millisecond { + if now.Sub(lastMouseEvent) < 15*time.Millisecond { return nil } lastMouseEvent = now