From 55e584b437e8a02b1d2c5a43038b24e535ea5ebe Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Mon, 4 Aug 2025 23:16:17 +0200 Subject: [PATCH] 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