on main help sign in
wip: selection
commit55e584b437e8a02b1d2c5a43038b24e535ea5ebe authorKujtim Hoxha <kujtimii.h@gmail.com> dateMon 4 Aug 2025, 23:16 (1 year ago) parent123b19c
6 files changed+236−21
go.mod+11−14 internal/tui/components/chat/chat.go+20 internal/tui/exp/list/list.go+155−5 internal/tui/page/chat/chat.go+28−1 internal/tui/styles/icons.go+21 internal/tui/tui.go+1−1
go.mod+11−14
@@ -48,22 +48,10 @@ require (
4848 mvdan.cc/sh/v3 v3.12.1-0.20250726150758-e256f53bade8
4949)
5050
51−require (
52− cloud.google.com/go/auth/oauth2adapt v0.2.6 // indirect
53− github.com/bahlo/generic-list-go v0.2.0 // indirect
54− github.com/buger/jsonparser v1.1.1 // indirect
55− github.com/mailru/easyjson v0.7.7 // indirect
56− github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect
57− go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.54.0 // indirect
58− golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // indirect
59− golang.org/x/oauth2 v0.25.0 // indirect
60− golang.org/x/time v0.8.0 // indirect
61− google.golang.org/api v0.211.0 // indirect
62−)
63−
6451require (
6552 cloud.google.com/go v0.116.0 // indirect
6653 cloud.google.com/go/auth v0.13.0 // indirect
54+ cloud.google.com/go/auth/oauth2adapt v0.2.6 // indirect
6755 cloud.google.com/go/compute/metadata v0.6.0 // indirect
6856 github.com/Azure/azure-sdk-for-go/sdk/azcore v1.17.0 // indirect
6957 github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0 // indirect
@@ -84,8 +72,10 @@ require (
8472 github.com/aws/smithy-go v1.20.3 // indirect
8573 github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
8674 github.com/aymerick/douceur v0.2.0 // indirect
75+ github.com/bahlo/generic-list-go v0.2.0 // indirect
76+ github.com/buger/jsonparser v1.1.1 // indirect
8777 github.com/charmbracelet/colorprofile v0.3.1 // indirect
88− github.com/charmbracelet/ultraviolet v0.0.0-20250731212901-76da584cc9a5 // indirect
78+ github.com/charmbracelet/ultraviolet v0.0.0-20250731212901-76da584cc9a5
8979 github.com/charmbracelet/x/cellbuf v0.0.14-0.20250516160309-24eee56f89fa // indirect
9080 github.com/charmbracelet/x/exp/slice v0.0.0-20250611152503-f53cdd7e01ef
9181 github.com/charmbracelet/x/term v0.2.1
@@ -108,6 +98,7 @@ require (
10898 github.com/inconshreveable/mousetrap v1.1.0 // indirect
10999 github.com/klauspost/cpuid/v2 v2.0.9 // indirect
110100 github.com/lucasb-eyer/go-colorful v1.2.0
101+ github.com/mailru/easyjson v0.7.7 // indirect
111102 github.com/mattn/go-isatty v0.0.20 // indirect
112103 github.com/mattn/go-runewidth v0.0.16 // indirect
113104 github.com/mfridman/interpolate v0.0.2 // indirect
@@ -128,23 +119,29 @@ require (
128119 github.com/tidwall/gjson v1.18.0 // indirect
129120 github.com/tidwall/match v1.1.1 // indirect
130121 github.com/tidwall/pretty v1.2.1 // indirect
122+ github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect
131123 github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
132124 github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
133125 github.com/yuin/goldmark v1.7.8 // indirect
134126 github.com/yuin/goldmark-emoji v1.0.5 // indirect
135127 go.opentelemetry.io/auto/sdk v1.1.0 // indirect
128+ go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.54.0 // indirect
136129 go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 // indirect
137130 go.opentelemetry.io/otel v1.35.0 // indirect
138131 go.opentelemetry.io/otel/metric v1.35.0 // indirect
139132 go.opentelemetry.io/otel/trace v1.35.0 // indirect
140133 go.uber.org/multierr v1.11.0 // indirect
141134 golang.org/x/crypto v0.38.0 // indirect
135+ golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // indirect
142136 golang.org/x/image v0.26.0 // indirect
143137 golang.org/x/net v0.40.0 // indirect
138+ golang.org/x/oauth2 v0.25.0 // indirect
144139 golang.org/x/sync v0.16.0 // indirect
145140 golang.org/x/sys v0.34.0
146141 golang.org/x/term v0.32.0 // indirect
147142 golang.org/x/text v0.27.0
143+ golang.org/x/time v0.8.0 // indirect
144+ google.golang.org/api v0.211.0 // indirect
148145 google.golang.org/genai v1.3.0
149146 google.golang.org/genproto/googleapis/rpc v0.0.0-20250324211829-b45e905df463 // indirect
150147 google.golang.org/grpc v1.71.0 // indirect
internal/tui/components/chat/chat.go+20
@@ -86,6 +86,26 @@ func (m *messageListCmp) Init() tea.Cmd {
8686// Update handles incoming messages and updates the component state.
8787func (m *messageListCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
8888 switch msg := msg.(type) {
89+ case tea.MouseClickMsg:
90+ if msg.Button == tea.MouseLeft {
91+ m.listCmp.StartSelection(msg.X, msg.Y-1)
92+ }
93+ return m, nil
94+ case tea.MouseMotionMsg:
95+ if msg.Button == tea.MouseLeft {
96+ m.listCmp.EndSelection(msg.X, msg.Y-1)
97+ if msg.Y <= 1 {
98+ return m, m.listCmp.MoveUp(1)
99+ } else if msg.Y >= m.height-1 {
100+ return m, m.listCmp.MoveDown(1)
101+ }
102+ }
103+ return m, nil
104+ case tea.MouseReleaseMsg:
105+ if msg.Button == tea.MouseLeft {
106+ m.listCmp.EndSelection(msg.X, msg.Y-1)
107+ }
108+ return m, nil
89109 case pubsub.Event[permission.PermissionNotification]:
90110 return m, m.handlePermissionRequest(msg.Payload)
91111 case SessionSelectedMsg:
internal/tui/exp/list/list.go+155−5
@@ -1,6 +1,7 @@
11package list
22
33import (
4+ "log/slog"
45 "slices"
56 "strings"
67
@@ -12,6 +13,7 @@ import (
1213 "github.com/charmbracelet/crush/internal/tui/styles"
1314 "github.com/charmbracelet/crush/internal/tui/util"
1415 "github.com/charmbracelet/lipgloss/v2"
16+ uv "github.com/charmbracelet/ultraviolet"
1517)
1618
1719type Item interface {
@@ -45,6 +47,8 @@ type List[T Item] interface {
4547 DeleteItem(string) tea.Cmd
4648 PrependItem(T) tea.Cmd
4749 AppendItem(T) tea.Cmd
50+ StartSelection(col, line int)
51+ EndSelection(col, line int)
4852}
4953
5054type direction int
@@ -92,7 +96,11 @@ type list[T Item] struct {
9296
9397 rendered string
9498
95− movingByItem bool
99+ movingByItem bool
100+ selectionStartCol int
101+ selectionStartLine int
102+ selectionEndCol int
103+ selectionEndLine int
96104}
97105
98106type ListOption func(*confOptions)
@@ -170,9 +178,13 @@ func New[T Item](items []T, opts ...ListOption) List[T] {
170178 keyMap: DefaultKeyMap(),
171179 focused: true,
172180 },
173− items: csync.NewSliceFrom(items),
174− indexMap: csync.NewMap[string, int](),
175− renderedItems: csync.NewMap[string, renderedItem](),
181+ items: csync.NewSliceFrom(items),
182+ indexMap: csync.NewMap[string, int](),
183+ renderedItems: csync.NewMap[string, renderedItem](),
184+ selectionStartCol: -1,
185+ selectionStartLine: -1,
186+ selectionEndLine: -1,
187+ selectionEndCol: -1,
176188 }
177189 for _, opt := range opts {
178190 opt(list.confOptions)
@@ -280,10 +292,132 @@ func (l *list[T]) View() string {
280292 if l.resize {
281293 return strings.Join(lines, "\n")
282294 }
283− return t.S().Base.
295+ view = t.S().Base.
284296 Height(l.height).
285297 Width(l.width).
286298 Render(strings.Join(lines, "\n"))
299+ if l.selectionStartCol < 0 {
300+ return view
301+ }
302+ area := uv.Rect(0, 0, l.width, l.height)
303+ scr := uv.NewScreenBuffer(area.Dx(), area.Dy())
304+ uv.NewStyledString(view).Draw(scr, area)
305+
306+ selArea := uv.Rectangle{
307+ Min: uv.Pos(l.selectionStartCol, l.selectionStartLine),
308+ Max: uv.Pos(l.selectionEndCol, l.selectionEndLine),
309+ }
310+ selArea = selArea.Canon()
311+
312+ specialChars := make(map[string]bool, len(styles.AllIcons))
313+ for _, icon := range styles.AllIcons {
314+ specialChars[icon] = true
315+ }
316+
317+ isNonWhitespace := func(r rune) bool {
318+ return r != ' ' && r != '\t' && r != 0 && r != '\n' && r != '\r'
319+ }
320+
321+ type selectionBounds struct {
322+ startX, endX int
323+ inSelection bool
324+ }
325+ lineSelections := make([]selectionBounds, scr.Height())
326+
327+ for y := range scr.Height() {
328+ bounds := selectionBounds{startX: -1, endX: -1, inSelection: false}
329+
330+ if y >= selArea.Min.Y && y <= selArea.Max.Y {
331+ bounds.inSelection = true
332+ if selArea.Min.Y == selArea.Max.Y {
333+ // Single line selection
334+ bounds.startX = selArea.Min.X
335+ bounds.endX = selArea.Max.X
336+ } else if y == selArea.Min.Y {
337+ // First line of multi-line selection
338+ bounds.startX = selArea.Min.X
339+ bounds.endX = scr.Width()
340+ } else if y == selArea.Max.Y {
341+ // Last line of multi-line selection
342+ bounds.startX = 0
343+ bounds.endX = selArea.Max.X
344+ } else {
345+ // Middle lines
346+ bounds.startX = 0
347+ bounds.endX = scr.Width()
348+ }
349+ }
350+ lineSelections[y] = bounds
351+ }
352+
353+ type lineBounds struct {
354+ start, end int
355+ }
356+ lineTextBounds := make([]lineBounds, scr.Height())
357+
358+ // First pass: find text bounds for lines that have selections
359+ for y := range scr.Height() {
360+ bounds := lineBounds{start: -1, end: -1}
361+
362+ // Only process lines that might have selections
363+ if lineSelections[y].inSelection {
364+ for x := range scr.Width() {
365+ cell := scr.CellAt(x, y)
366+ if cell == nil {
367+ continue
368+ }
369+
370+ cellStr := cell.String()
371+ if len(cellStr) == 0 {
372+ continue
373+ }
374+
375+ char := rune(cellStr[0])
376+ isSpecial := specialChars[cellStr]
377+
378+ if (isNonWhitespace(char) && !isSpecial) || cell.Style.Bg != nil {
379+ if bounds.start == -1 {
380+ bounds.start = x
381+ }
382+ bounds.end = x + 1 // Position after last character
383+ }
384+ }
385+ }
386+ lineTextBounds[y] = bounds
387+ }
388+
389+ // Second pass: apply selection highlighting
390+ for y := range scr.Height() {
391+ selBounds := lineSelections[y]
392+ if !selBounds.inSelection {
393+ continue
394+ }
395+
396+ textBounds := lineTextBounds[y]
397+ if textBounds.start < 0 {
398+ continue // No text on this line
399+ }
400+
401+ // Only scan within the intersection of text bounds and selection bounds
402+ scanStart := max(textBounds.start, selBounds.startX)
403+ scanEnd := min(textBounds.end, selBounds.endX)
404+
405+ for x := scanStart; x < scanEnd; x++ {
406+ cell := scr.CellAt(x, y)
407+ if cell == nil {
408+ continue
409+ }
410+
411+ cellStr := cell.String()
412+ if len(cellStr) > 0 && !specialChars[cellStr] {
413+ cell = cell.Clone()
414+ cell.Style = cell.Style.Background(t.BgOverlay).Foreground(t.White)
415+ scr.SetCell(x, y, cell)
416+ }
417+ }
418+ }
419+
420+ return scr.Render()
287421}
288422
289423func (l *list[T]) viewPosition() (int, int) {
@@ -1022,3 +1156,19 @@ func (l *list[T]) UpdateItem(id string, item T) tea.Cmd {
10221156 }
10231157 return tea.Sequence(cmds...)
10241158}
1159+
1160+// StartSelection implements List.
1161+func (l *list[T]) StartSelection(col, line int) {
1162+ l.selectionStartCol = col
1163+ l.selectionStartLine = line
1164+ l.selectionEndCol = col
1165+ l.selectionEndLine = line
1166+ slog.Info("Position", "col", col, "line", line)
1167+}
1168+
1169+// EndSelection implements List.
1170+func (l *list[T]) EndSelection(col, line int) {
1171+ l.selectionEndCol = col
1172+ l.selectionEndLine = line
1173+ slog.Info("Position", "col", col, "line", line)
1174+}
internal/tui/page/chat/chat.go+28−1
@@ -165,12 +165,39 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
165165 p.keyboardEnhancements = msg
166166 return p, nil
167167 case tea.MouseWheelMsg:
168− if p.isMouseOverChat(msg.Mouse().X, msg.Mouse().Y) {
168+ if p.isMouseOverChat(msg.X, msg.Y) {
169169 u, cmd := p.chat.Update(msg)
170170 p.chat = u.(chat.MessageListCmp)
171171 return p, cmd
172172 }
173173 return p, nil
174+ case tea.MouseClickMsg:
175+ if msg.Button == tea.MouseLeft {
176+ if p.isMouseOverChat(msg.X, msg.Y) {
177+ u, cmd := p.chat.Update(msg)
178+ p.chat = u.(chat.MessageListCmp)
179+ return p, cmd
180+ }
181+ }
182+ return p, nil
183+ case tea.MouseMotionMsg:
184+ if msg.Button == tea.MouseLeft {
185+ if p.isMouseOverChat(msg.X, msg.Y) {
186+ u, cmd := p.chat.Update(msg)
187+ p.chat = u.(chat.MessageListCmp)
188+ return p, cmd
189+ }
190+ }
191+ return p, nil
192+ case tea.MouseReleaseMsg:
193+ if msg.Button == tea.MouseLeft {
194+ if p.isMouseOverChat(msg.X, msg.Y) {
195+ u, cmd := p.chat.Update(msg)
196+ p.chat = u.(chat.MessageListCmp)
197+ return p, cmd
198+ }
199+ }
200+ return p, nil
174201 case tea.WindowSizeMsg:
175202 u, cmd := p.editor.Update(msg)
176203 p.editor = u.(editor.Editor)
internal/tui/styles/icons.go+21
@@ -15,4 +15,25 @@ const (
1515 ToolPending string = "●"
1616 ToolSuccess string = "✓"
1717 ToolError string = "×"
18+
19+ BorderThin string = "│"
1820)
21+
22+var AllIcons = []string{
23+ CheckIcon,
24+ ErrorIcon,
25+ WarningIcon,
26+ InfoIcon,
27+ HintIcon,
28+ SpinnerIcon,
29+ LoadingIcon,
30+ DocumentIcon,
31+ ModelIcon,
32+
33+ // Tool call icons
34+ ToolPending,
35+ ToolSuccess,
36+ ToolError,
37+
38+ BorderThin,
39+}
internal/tui/tui.go+1−1
@@ -41,7 +41,7 @@ func MouseEventFilter(m tea.Model, msg tea.Msg) tea.Msg {
4141 case tea.MouseWheelMsg, tea.MouseMotionMsg:
4242 now := time.Now()
4343 // trackpad is sending too many requests
44− if now.Sub(lastMouseEvent) < 5*time.Millisecond {
44+ if now.Sub(lastMouseEvent) < 20*time.Millisecond {
4545 return nil
4646 }
4747 lastMouseEvent = now