wip: selection

Kujtim Hoxha created

Change summary

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(-)

Detailed changes

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

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:

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)
+}

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)

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,
+}

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