From 55e584b437e8a02b1d2c5a43038b24e535ea5ebe Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Mon, 4 Aug 2025 23:16:17 +0200 Subject: [PATCH 01/13] wip: selection --- go.mod | 25 ++--- internal/tui/components/chat/chat.go | 20 ++++ internal/tui/exp/list/list.go | 160 ++++++++++++++++++++++++++- internal/tui/page/chat/chat.go | 29 ++++- internal/tui/styles/icons.go | 21 ++++ internal/tui/tui.go | 2 +- 6 files changed, 236 insertions(+), 21 deletions(-) diff --git a/go.mod b/go.mod index 554ddfe41d2b9109593014798c04b83c0b2edbf9..c052a12860d3e95ebe5233849a3032ba348707c4 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.25.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-20250731212901-76da584cc9a5 // indirect + github.com/charmbracelet/ultraviolet v0.0.0-20250731212901-76da584cc9a5 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.25.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..1762e836554d607e9e3a8eec03c60b5d25419f41 100644 --- a/internal/tui/components/chat/chat.go +++ b/internal/tui/components/chat/chat.go @@ -86,6 +86,26 @@ 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.MouseClickMsg: + if msg.Button == tea.MouseLeft { + m.listCmp.StartSelection(msg.X, msg.Y-1) + } + return m, nil + case tea.MouseMotionMsg: + if msg.Button == tea.MouseLeft { + m.listCmp.EndSelection(msg.X, msg.Y-1) + if msg.Y <= 1 { + return m, m.listCmp.MoveUp(1) + } else if msg.Y >= m.height-1 { + return m, m.listCmp.MoveDown(1) + } + } + return m, nil + case tea.MouseReleaseMsg: + if msg.Button == tea.MouseLeft { + m.listCmp.EndSelection(msg.X, msg.Y-1) + } + return m, nil case pubsub.Event[permission.PermissionNotification]: return m, m.handlePermissionRequest(msg.Payload) case SessionSelectedMsg: diff --git a/internal/tui/exp/list/list.go b/internal/tui/exp/list/list.go index 44a849fcf6027813feb49be5a68c401f4253eeb6..24a60fd03851c59b6dbe1e94fde4c0d2ad14b0d5 100644 --- a/internal/tui/exp/list/list.go +++ b/internal/tui/exp/list/list.go @@ -1,6 +1,7 @@ package list import ( + "log/slog" "slices" "strings" @@ -12,6 +13,7 @@ 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" ) type Item interface { @@ -45,6 +47,8 @@ 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) } type direction int @@ -92,7 +96,11 @@ type list[T Item] struct { rendered string - movingByItem bool + movingByItem bool + selectionStartCol int + selectionStartLine int + selectionEndCol int + selectionEndLine int } type ListOption func(*confOptions) @@ -170,9 +178,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) @@ -280,10 +292,132 @@ 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.selectionStartCol < 0 { + return view + } + 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.AllIcons)) + for _, icon := range styles.AllIcons { + 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 + } + + // Second pass: apply selection highlighting + for y := range scr.Height() { + selBounds := lineSelections[y] + if !selBounds.inSelection { + continue + } + + textBounds := lineTextBounds[y] + if textBounds.start < 0 { + 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] { + cell = cell.Clone() + cell.Style = cell.Style.Background(t.BgOverlay).Foreground(t.White) + scr.SetCell(x, y, cell) + } + } + } + + return scr.Render() } func (l *list[T]) viewPosition() (int, int) { @@ -1022,3 +1156,19 @@ func (l *list[T]) UpdateItem(id string, item T) tea.Cmd { } return tea.Sequence(cmds...) } + +// StartSelection implements List. +func (l *list[T]) StartSelection(col, line int) { + l.selectionStartCol = col + l.selectionStartLine = line + l.selectionEndCol = col + l.selectionEndLine = line + slog.Info("Position", "col", col, "line", line) +} + +// EndSelection implements List. +func (l *list[T]) EndSelection(col, line int) { + l.selectionEndCol = col + l.selectionEndLine = line + slog.Info("Position", "col", col, "line", line) +} diff --git a/internal/tui/page/chat/chat.go b/internal/tui/page/chat/chat.go index 26bd464a59e02632d3817f7a5582ac1f9d4a0a03..955d6c710b53220a1e986e0c4e50ea49220b1485 100644 --- a/internal/tui/page/chat/chat.go +++ b/internal/tui/page/chat/chat.go @@ -165,12 +165,39 @@ 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.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 msg.Button == tea.MouseLeft { + 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.MouseMotionMsg: + if msg.Button == tea.MouseLeft { + 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.MouseReleaseMsg: + if msg.Button == tea.MouseLeft { + 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.WindowSizeMsg: u, cmd := p.editor.Update(msg) p.editor = u.(editor.Editor) diff --git a/internal/tui/styles/icons.go b/internal/tui/styles/icons.go index c3038abe4b0462855f1aac39c1114b4bc4c5ac32..8aede9493b0201a972c1db116538525e55128c10 100644 --- a/internal/tui/styles/icons.go +++ b/internal/tui/styles/icons.go @@ -15,4 +15,25 @@ const ( ToolPending string = "●" ToolSuccess string = "✓" ToolError string = "×" + + BorderThin string = "│" ) + +var AllIcons = []string{ + CheckIcon, + ErrorIcon, + WarningIcon, + InfoIcon, + HintIcon, + SpinnerIcon, + LoadingIcon, + DocumentIcon, + ModelIcon, + + // Tool call icons + ToolPending, + ToolSuccess, + ToolError, + + BorderThin, +} diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 333826f564e2909fc0689e117ab5f85f947b410f..18b26b017e40564a45e850206882ccca8ced8251 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) < 20*time.Millisecond { return nil } lastMouseEvent = now From ebd68ac17c5fba96baff33d6260e6036f21de286 Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Tue, 5 Aug 2025 18:21:02 +0200 Subject: [PATCH 02/13] wip: implement selection --- internal/tui/components/chat/chat.go | 107 ++++++++++- internal/tui/exp/list/list.go | 273 ++++++++++++++++++++++++++- internal/tui/page/chat/chat.go | 26 +-- internal/tui/styles/icons.go | 34 ++-- internal/tui/tui.go | 2 +- 5 files changed, 396 insertions(+), 46 deletions(-) diff --git a/internal/tui/components/chat/chat.go b/internal/tui/components/chat/chat.go index 1762e836554d607e9e3a8eec03c60b5d25419f41..b1af201f33be840fa626244a42aa2d88fa18649c 100644 --- a/internal/tui/components/chat/chat.go +++ b/internal/tui/components/chat/chat.go @@ -2,8 +2,10 @@ package chat import ( "context" + "fmt" "time" + "github.com/atotto/clipboard" "github.com/charmbracelet/bubbles/v2/key" tea "github.com/charmbracelet/bubbletea/v2" "github.com/charmbracelet/crush/internal/app" @@ -42,6 +44,8 @@ type MessageListCmp interface { SetSession(session.Session) tea.Cmd GoToBottom() tea.Cmd + GetSelectedText() string + CopySelectedText() tea.Cmd } // messageListCmp implements MessageListCmp, providing a virtualized list @@ -56,6 +60,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 @@ -87,23 +97,42 @@ func (m *messageListCmp) Init() tea.Cmd { func (m *messageListCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { 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 { - m.listCmp.StartSelection(msg.X, msg.Y-1) + return m, m.handleMouseClick(x, y) } return m, nil case tea.MouseMotionMsg: - if msg.Button == tea.MouseLeft { - m.listCmp.EndSelection(msg.X, msg.Y-1) - if msg.Y <= 1 { + 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) - } else if msg.Y >= m.height-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 { - m.listCmp.EndSelection(msg.X, msg.Y-1) + if x < 0 || y < 0 || x >= m.width-2 || y >= m.height-1 { + m.listCmp.SelectionStop() + return m, m.CopySelectedText() + } + m.listCmp.EndSelection(x, y) + m.listCmp.SelectionStop() + return m, m.CopySelectedText() } return m, nil case pubsub.Event[permission.PermissionNotification]: @@ -586,3 +615,69 @@ func (m *messageListCmp) Bindings() []key.Binding { func (m *messageListCmp) GoToBottom() tea.Cmd { return m.listCmp.GoToBottom() } + +// handleMouseClick handles mouse click events and detects double/triple clicks. +func (m *messageListCmp) handleMouseClick(x, y int) tea.Cmd { + const ( + doubleClickThreshold = 500 * time.Millisecond + clickTolerance = 2 // pixels + ) + + 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 +} + +// 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 + 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)) + } + return 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/exp/list/list.go b/internal/tui/exp/list/list.go index ca1fa7ee7e73af0499f99a037d9729fcb5ed345d..f1a798a7dd23a534157ba0a143936d157b0a4dbe 100644 --- a/internal/tui/exp/list/list.go +++ b/internal/tui/exp/list/list.go @@ -1,7 +1,6 @@ package list import ( - "log/slog" "slices" "strings" "sync" @@ -15,6 +14,8 @@ import ( "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 { @@ -50,6 +51,11 @@ type List[T Item] interface { 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 } type direction int @@ -103,6 +109,8 @@ type list[T Item] struct { selectionStartLine int selectionEndCol int selectionEndLine int + + selectionActive bool } type ListOption func(*confOptions) @@ -298,7 +306,8 @@ func (l *list[T]) View() string { Height(l.height). Width(l.width). Render(strings.Join(lines, "\n")) - if l.selectionStartCol < 0 { + + if !l.hasSelection() { return view } area := uv.Rect(0, 0, l.width, l.height) @@ -311,8 +320,8 @@ func (l *list[T]) View() string { } selArea = selArea.Canon() - specialChars := make(map[string]bool, len(styles.AllIcons)) - for _, icon := range styles.AllIcons { + specialChars := make(map[string]bool, len(styles.SelectionIgnoreIcons)) + for _, icon := range styles.SelectionIgnoreIcons { specialChars[icon] = true } @@ -951,21 +960,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() } @@ -1164,18 +1219,224 @@ 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 - slog.Info("Position", "col", col, "line", 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 - slog.Info("Position", "col", col, "line", 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 { + 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 { + 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 { + 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 +} + +// 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() } diff --git a/internal/tui/page/chat/chat.go b/internal/tui/page/chat/chat.go index 955d6c710b53220a1e986e0c4e50ea49220b1485..6fd3849fbf37d01489f624924c77c299a06d2087 100644 --- a/internal/tui/page/chat/chat.go +++ b/internal/tui/page/chat/chat.go @@ -172,30 +172,22 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return p, nil case tea.MouseClickMsg: - if msg.Button == tea.MouseLeft { - if p.isMouseOverChat(msg.X, msg.Y) { - u, cmd := p.chat.Update(msg) - p.chat = u.(chat.MessageListCmp) - return p, cmd - } - } + 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 { - if p.isMouseOverChat(msg.X, msg.Y) { - u, cmd := p.chat.Update(msg) - p.chat = u.(chat.MessageListCmp) - return p, cmd - } + u, cmd := p.chat.Update(msg) + p.chat = u.(chat.MessageListCmp) + return p, cmd } return p, nil case tea.MouseReleaseMsg: if msg.Button == tea.MouseLeft { - if p.isMouseOverChat(msg.X, msg.Y) { - u, cmd := p.chat.Update(msg) - p.chat = u.(chat.MessageListCmp) - return p, cmd - } + u, cmd := p.chat.Update(msg) + p.chat = u.(chat.MessageListCmp) + return p, cmd } return p, nil case tea.WindowSizeMsg: diff --git a/internal/tui/styles/icons.go b/internal/tui/styles/icons.go index 8aede9493b0201a972c1db116538525e55128c10..d9d1ab06f96ff64f8772e1b0f4b099a0ebed2b0a 100644 --- a/internal/tui/styles/icons.go +++ b/internal/tui/styles/icons.go @@ -16,24 +16,26 @@ const ( ToolSuccess string = "✓" ToolError string = "×" - BorderThin string = "│" + BorderThin string = "│" + BorderThick string = "▌" ) -var AllIcons = []string{ - CheckIcon, - ErrorIcon, - WarningIcon, - InfoIcon, - HintIcon, - SpinnerIcon, - LoadingIcon, - DocumentIcon, - ModelIcon, - - // Tool call icons - ToolPending, - ToolSuccess, - ToolError, +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/tui.go b/internal/tui/tui.go index 18b26b017e40564a45e850206882ccca8ced8251..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) < 20*time.Millisecond { + if now.Sub(lastMouseEvent) < 15*time.Millisecond { return nil } lastMouseEvent = now From 7e58d53f58f14028fcfa1aef1500b0fef8447d4b Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Tue, 5 Aug 2025 16:22:40 -0400 Subject: [PATCH 03/13] fix(chat): focus chat and editor on mouse click --- internal/tui/page/chat/chat.go | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/internal/tui/page/chat/chat.go b/internal/tui/page/chat/chat.go index 6fd3849fbf37d01489f624924c77c299a06d2087..a281ef0b0ae80ffd4ad07d7d16a8755b782bf0b6 100644 --- a/internal/tui/page/chat/chat.go +++ b/internal/tui/page/chat/chat.go @@ -172,10 +172,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) From 6b545747ded24028e506ec8442eff1315bb6c231 Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Tue, 5 Aug 2025 16:24:30 -0400 Subject: [PATCH 04/13] fix(chat): expose copy key binding --- internal/tui/components/chat/messages/messages.go | 5 +++-- internal/tui/components/chat/messages/tool.go | 2 +- internal/tui/page/chat/chat.go | 6 ++---- 3 files changed, 6 insertions(+), 7 deletions(-) diff --git a/internal/tui/components/chat/messages/messages.go b/internal/tui/components/chat/messages/messages.go index b1a17eee4e4b91885941deb415cf8c9fd877fe72..891f4e483fcc7b9cdb6d82485998329995d542a6 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,7 +100,7 @@ func (m *messageCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, cmd } case tea.KeyPressMsg: - if key.Matches(msg, copyKey) { + 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)) diff --git a/internal/tui/components/chat/messages/tool.go b/internal/tui/components/chat/messages/tool.go index 7708b6b3e273471973a355bc77c0110c0be21e45..41bb7b81d0e9fa202e98bd91aca4f2eddf9c22c1 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() } } diff --git a/internal/tui/page/chat/chat.go b/internal/tui/page/chat/chat.go index a281ef0b0ae80ffd4ad07d7d16a8755b782bf0b6..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" @@ -865,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{ From 1f8bc4e2d3ed389463fe0ce04c2d5d9bce8acabe Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Tue, 5 Aug 2025 16:25:30 -0400 Subject: [PATCH 05/13] feat(list): add HasSelection method and selectionView for text-only output --- internal/tui/exp/list/list.go | 145 ++++++++++++++-------------------- 1 file changed, 59 insertions(+), 86 deletions(-) diff --git a/internal/tui/exp/list/list.go b/internal/tui/exp/list/list.go index f1a798a7dd23a534157ba0a143936d157b0a4dbe..8b729565f99d83accdebc0bc077963a7b25f8a34 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] @@ -421,16 +404,59 @@ 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 + } + cell = cell.Clone() cell.Style = cell.Style.Background(t.BgOverlay).Foreground(t.White) 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 +1400,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) } From 3d91fd0fa25a65f30086b800a5bdd91122e308f1 Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Tue, 5 Aug 2025 16:26:10 -0400 Subject: [PATCH 06/13] feat(chat): copy selected text in chat messages via shared key binding This uses both OSC 52 and native clipboard for maximum compatibility with different terminal emulators and environments. The `CopySelectedText` method now accepts a boolean parameter to clear the selection after copying. --- internal/tui/components/chat/chat.go | 65 +++++++++++++------ .../tui/components/chat/messages/messages.go | 13 ++-- internal/tui/components/chat/messages/tool.go | 13 ++-- 3 files changed, 62 insertions(+), 29 deletions(-) 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 891f4e483fcc7b9cdb6d82485998329995d542a6..bc594ab5e1240ff004bdb9548afa872ac1bb62e8 100644 --- a/internal/tui/components/chat/messages/messages.go +++ b/internal/tui/components/chat/messages/messages.go @@ -101,11 +101,14 @@ func (m *messageCmp) Update(msg tea.Msg) (tea.Model, tea.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") + 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 41bb7b81d0e9fa202e98bd91aca4f2eddf9c22c1..7e03674f97243e7d9e569b341fe1c6f1d2450b93 100644 --- a/internal/tui/components/chat/messages/tool.go +++ b/internal/tui/components/chat/messages/tool.go @@ -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 { From 3671bd0f7330fa4c0b0592f27b7bd5166ebc020a Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Tue, 5 Aug 2025 16:36:05 -0400 Subject: [PATCH 07/13] fix(list): include inbetween empty lines when selecting text --- internal/tui/exp/list/list.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/internal/tui/exp/list/list.go b/internal/tui/exp/list/list.go index 8b729565f99d83accdebc0bc077963a7b25f8a34..4a9db7351ef561ec14414e4f70f1973f3d207064 100644 --- a/internal/tui/exp/list/list.go +++ b/internal/tui/exp/list/list.go @@ -389,6 +389,11 @@ func (l *list[T]) selectionView(view string, textOnly bool) 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 } From 567b778645113721822df45824a43b73281fe40e Mon Sep 17 00:00:00 2001 From: Christian Rocha Date: Tue, 5 Aug 2025 23:19:54 -0400 Subject: [PATCH 08/13] chore(list): set selection colors --- internal/tui/exp/list/list.go | 6 +++++- internal/tui/styles/charmtone.go | 3 +++ internal/tui/styles/theme.go | 3 +++ 3 files changed, 11 insertions(+), 1 deletion(-) diff --git a/internal/tui/exp/list/list.go b/internal/tui/exp/list/list.go index 4a9db7351ef561ec14414e4f70f1973f3d207064..d40f8d26128eef0d6930ff598c4aa21b971fd75e 100644 --- a/internal/tui/exp/list/list.go +++ b/internal/tui/exp/list/list.go @@ -415,8 +415,12 @@ func (l *list[T]) selectionView(view string, textOnly bool) 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) } } 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/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 From d8da2fba6d4c25a1c53cf2e6964cb5e1449a65c5 Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Wed, 6 Aug 2025 11:35:31 +0200 Subject: [PATCH 09/13] chore: handle smaller text than screen size --- internal/tui/exp/list/list.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/tui/exp/list/list.go b/internal/tui/exp/list/list.go index d40f8d26128eef0d6930ff598c4aa21b971fd75e..595e21334d37b218f3d4fc8892cd03e4a1010d41 100644 --- a/internal/tui/exp/list/list.go +++ b/internal/tui/exp/list/list.go @@ -1294,7 +1294,7 @@ func (l *list[T]) findWordBoundaries(col, line int) (startCol, endCol int) { lines[i] = ansi.Strip(l) } - if l.direction == DirectionBackward { + if l.direction == DirectionBackward && len(lines) > l.height { line = ((len(lines) - 1) - l.height) + line + 1 } @@ -1336,7 +1336,7 @@ func (l *list[T]) findParagraphBoundaries(line int) (startLine, endLine int, fou lines[i] = strings.ReplaceAll(lines[i], icon, " ") } } - if l.direction == DirectionBackward { + if l.direction == DirectionBackward && len(lines) > l.height { line = (len(lines) - 1) - l.height + line + 1 } @@ -1370,7 +1370,7 @@ func (l *list[T]) findParagraphBoundaries(line int) (startLine, endLine int, fou } // revert the line numbers if we are in backward direction - if l.direction == DirectionBackward { + if l.direction == DirectionBackward && len(lines) > l.height { startLine = startLine - (len(lines) - 1) + l.height - 1 endLine = endLine - (len(lines) - 1) + l.height - 1 } From 2e30226da8879b2f7bf67fa83fb179cde72a9a2e Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Wed, 6 Aug 2025 20:49:32 +0200 Subject: [PATCH 10/13] fix: handle compact mode --- internal/tui/page/chat/chat.go | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/internal/tui/page/chat/chat.go b/internal/tui/page/chat/chat.go index 0276ab2a917bf9225d381b483f403d064fefe39a..4d5bd758a0bd54154f06f92f6940746f4a6bc9fb 100644 --- a/internal/tui/page/chat/chat.go +++ b/internal/tui/page/chat/chat.go @@ -166,6 +166,9 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) { p.keyboardEnhancements = msg return p, nil case tea.MouseWheelMsg: + if p.compact { + msg.Y -= 1 + } if p.isMouseOverChat(msg.X, msg.Y) { u, cmd := p.chat.Update(msg) p.chat = u.(chat.MessageListCmp) @@ -173,6 +176,9 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.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() @@ -186,6 +192,9 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 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) @@ -193,6 +202,9 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.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) From f0377a3a862f9c4ffffa112f137ca28989b108bf Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Wed, 6 Aug 2025 14:56:16 -0400 Subject: [PATCH 11/13] feat(tui): chat: clear selection on esc --- internal/tui/components/chat/chat.go | 9 +++++++-- internal/tui/components/chat/messages/messages.go | 3 +++ 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/internal/tui/components/chat/chat.go b/internal/tui/components/chat/chat.go index 22ef7099b505977432c650655ec9a30c098f85dd..00e18135b5d498c08c7db52135691f5db61ce220 100644 --- a/internal/tui/components/chat/chat.go +++ b/internal/tui/components/chat/chat.go @@ -96,8 +96,13 @@ func (m *messageListCmp) Init() tea.Cmd { 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) + 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 diff --git a/internal/tui/components/chat/messages/messages.go b/internal/tui/components/chat/messages/messages.go index bc594ab5e1240ff004bdb9548afa872ac1bb62e8..ec55800aab85a2dbb07153c12300dbad892b3b6a 100644 --- a/internal/tui/components/chat/messages/messages.go +++ b/internal/tui/components/chat/messages/messages.go @@ -28,6 +28,9 @@ import ( // 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. type MessageCmp interface { From a6ad0d90f0f79b1891317e8be4d03484ea79c143 Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Wed, 6 Aug 2025 14:56:40 -0400 Subject: [PATCH 12/13] feat(tui): chat: add copy and clear selection key bindings to help --- internal/tui/page/chat/chat.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/internal/tui/page/chat/chat.go b/internal/tui/page/chat/chat.go index 4d5bd758a0bd54154f06f92f6940746f4a6bc9fb..381ad91d429ffd10ac5461b869ca33db1e68fb33 100644 --- a/internal/tui/page/chat/chat.go +++ b/internal/tui/page/chat/chat.go @@ -917,6 +917,10 @@ func (p *chatPage) Help() help.KeyMap { key.WithHelp("G", "end"), ), }, + []key.Binding{ + messages.CopyKey, + messages.ClearSelectionKey, + }, ) case PanelTypeEditor: newLineBinding := key.NewBinding( From 8566940e36c2f3aa4b2b184838eebd4583081fcd Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Wed, 6 Aug 2025 15:30:20 -0400 Subject: [PATCH 13/13] feat(tui): chat: support double-click to select and copy text after timeout --- internal/tui/components/chat/chat.go | 45 ++++++++++++++++++++++------ internal/tui/page/chat/chat.go | 4 +++ 2 files changed, 40 insertions(+), 9 deletions(-) diff --git a/internal/tui/components/chat/chat.go b/internal/tui/components/chat/chat.go index 00e18135b5d498c08c7db52135691f5db61ce220..74f7af06c09e2d13ccd3dd707df84bbba8c0e89f 100644 --- a/internal/tui/components/chat/chat.go +++ b/internal/tui/components/chat/chat.go @@ -29,6 +29,12 @@ type SessionSelectedMsg = session.Session type SessionClearedMsg struct{} +type SelectionCopyMsg struct { + clickCount int + endSelection bool + x, y int +} + const ( NotFound = -1 ) @@ -134,14 +140,34 @@ func (m *messageListCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 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 { - m.listCmp.SelectionStop() - } else { - m.listCmp.EndSelection(x, y) - m.listCmp.SelectionStop() + 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: @@ -621,13 +647,13 @@ 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 { - const ( - doubleClickThreshold = 500 * time.Millisecond - clickTolerance = 2 // pixels - ) - now := time.Now() // Check if this is a potential multi-click @@ -664,6 +690,7 @@ 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 } diff --git a/internal/tui/page/chat/chat.go b/internal/tui/page/chat/chat.go index 381ad91d429ffd10ac5461b869ca33db1e68fb33..b418abc4989f33433c2c4d12d0aa5aab7069254d 100644 --- a/internal/tui/page/chat/chat.go +++ b/internal/tui/page/chat/chat.go @@ -211,6 +211,10 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 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)