on main help sign in
wip: selection
55e584bKujtim Hoxha
go.mod+11−14
@@ -48,22 +48,10 @@ require (
48 mvdan.cc/sh/v3 v3.12.1-0.20250726150758-e256f53bade8
48 mvdan.cc/sh/v3 v3.12.1-0.20250726150758-e256f53bade8
49)
49)
50
50
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−
64require (
51require (
65 cloud.google.com/go v0.116.0 // indirect
52 cloud.google.com/go v0.116.0 // indirect
66 cloud.google.com/go/auth v0.13.0 // indirect
53 cloud.google.com/go/auth v0.13.0 // indirect
54+ cloud.google.com/go/auth/oauth2adapt v0.2.6 // indirect
67 cloud.google.com/go/compute/metadata v0.6.0 // indirect
55 cloud.google.com/go/compute/metadata v0.6.0 // indirect
68 github.com/Azure/azure-sdk-for-go/sdk/azcore v1.17.0 // indirect
56 github.com/Azure/azure-sdk-for-go/sdk/azcore v1.17.0 // indirect
69 github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0 // indirect
57 github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0 // indirect
@@ -84,8 +72,10 @@ require (
84 github.com/aws/smithy-go v1.20.3 // indirect
72 github.com/aws/smithy-go v1.20.3 // indirect
85 github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
73 github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
86 github.com/aymerick/douceur v0.2.0 // indirect
74 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
87 github.com/charmbracelet/colorprofile v0.3.1 // indirect
77 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
89 github.com/charmbracelet/x/cellbuf v0.0.14-0.20250516160309-24eee56f89fa // indirect
79 github.com/charmbracelet/x/cellbuf v0.0.14-0.20250516160309-24eee56f89fa // indirect
90 github.com/charmbracelet/x/exp/slice v0.0.0-20250611152503-f53cdd7e01ef
80 github.com/charmbracelet/x/exp/slice v0.0.0-20250611152503-f53cdd7e01ef
91 github.com/charmbracelet/x/term v0.2.1
81 github.com/charmbracelet/x/term v0.2.1
@@ -108,6 +98,7 @@ require (
108 github.com/inconshreveable/mousetrap v1.1.0 // indirect
98 github.com/inconshreveable/mousetrap v1.1.0 // indirect
109 github.com/klauspost/cpuid/v2 v2.0.9 // indirect
99 github.com/klauspost/cpuid/v2 v2.0.9 // indirect
110 github.com/lucasb-eyer/go-colorful v1.2.0
100 github.com/lucasb-eyer/go-colorful v1.2.0
101+ github.com/mailru/easyjson v0.7.7 // indirect
111 github.com/mattn/go-isatty v0.0.20 // indirect
102 github.com/mattn/go-isatty v0.0.20 // indirect
112 github.com/mattn/go-runewidth v0.0.16 // indirect
103 github.com/mattn/go-runewidth v0.0.16 // indirect
113 github.com/mfridman/interpolate v0.0.2 // indirect
104 github.com/mfridman/interpolate v0.0.2 // indirect
@@ -128,23 +119,29 @@ require (
128 github.com/tidwall/gjson v1.18.0 // indirect
119 github.com/tidwall/gjson v1.18.0 // indirect
129 github.com/tidwall/match v1.1.1 // indirect
120 github.com/tidwall/match v1.1.1 // indirect
130 github.com/tidwall/pretty v1.2.1 // indirect
121 github.com/tidwall/pretty v1.2.1 // indirect
122+ github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect
131 github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
123 github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
132 github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
124 github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
133 github.com/yuin/goldmark v1.7.8 // indirect
125 github.com/yuin/goldmark v1.7.8 // indirect
134 github.com/yuin/goldmark-emoji v1.0.5 // indirect
126 github.com/yuin/goldmark-emoji v1.0.5 // indirect
135 go.opentelemetry.io/auto/sdk v1.1.0 // indirect
127 go.opentelemetry.io/auto/sdk v1.1.0 // indirect
128+ go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.54.0 // indirect
136 go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 // indirect
129 go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 // indirect
137 go.opentelemetry.io/otel v1.35.0 // indirect
130 go.opentelemetry.io/otel v1.35.0 // indirect
138 go.opentelemetry.io/otel/metric v1.35.0 // indirect
131 go.opentelemetry.io/otel/metric v1.35.0 // indirect
139 go.opentelemetry.io/otel/trace v1.35.0 // indirect
132 go.opentelemetry.io/otel/trace v1.35.0 // indirect
140 go.uber.org/multierr v1.11.0 // indirect
133 go.uber.org/multierr v1.11.0 // indirect
141 golang.org/x/crypto v0.38.0 // indirect
134 golang.org/x/crypto v0.38.0 // indirect
135+ golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // indirect
142 golang.org/x/image v0.26.0 // indirect
136 golang.org/x/image v0.26.0 // indirect
143 golang.org/x/net v0.40.0 // indirect
137 golang.org/x/net v0.40.0 // indirect
138+ golang.org/x/oauth2 v0.25.0 // indirect
144 golang.org/x/sync v0.16.0 // indirect
139 golang.org/x/sync v0.16.0 // indirect
145 golang.org/x/sys v0.34.0
140 golang.org/x/sys v0.34.0
146 golang.org/x/term v0.32.0 // indirect
141 golang.org/x/term v0.32.0 // indirect
147 golang.org/x/text v0.27.0
142 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
148 google.golang.org/genai v1.3.0
145 google.golang.org/genai v1.3.0
149 google.golang.org/genproto/googleapis/rpc v0.0.0-20250324211829-b45e905df463 // indirect
146 google.golang.org/genproto/googleapis/rpc v0.0.0-20250324211829-b45e905df463 // indirect
150 google.golang.org/grpc v1.71.0 // indirect
147 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 {
86// Update handles incoming messages and updates the component state.
86// Update handles incoming messages and updates the component state.
87func (m *messageListCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
87func (m *messageListCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
88 switch msg := msg.(type) {
88 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
89 case pubsub.Event[permission.PermissionNotification]:
109 case pubsub.Event[permission.PermissionNotification]:
90 return m, m.handlePermissionRequest(msg.Payload)
110 return m, m.handlePermissionRequest(msg.Payload)
91 case SessionSelectedMsg:
111 case SessionSelectedMsg:
internal/tui/exp/list/list.go+155−5
@@ -1,6 +1,7 @@
1package list
1package list
2
2
3import (
3import (
4+ "log/slog"
4 "slices"
5 "slices"
5 "strings"
6 "strings"
6
7
@@ -12,6 +13,7 @@ import (
12 "github.com/charmbracelet/crush/internal/tui/styles"
13 "github.com/charmbracelet/crush/internal/tui/styles"
13 "github.com/charmbracelet/crush/internal/tui/util"
14 "github.com/charmbracelet/crush/internal/tui/util"
14 "github.com/charmbracelet/lipgloss/v2"
15 "github.com/charmbracelet/lipgloss/v2"
16+ uv "github.com/charmbracelet/ultraviolet"
15)
17)
16
18
17type Item interface {
19type Item interface {
@@ -45,6 +47,8 @@ type List[T Item] interface {
45 DeleteItem(string) tea.Cmd
47 DeleteItem(string) tea.Cmd
46 PrependItem(T) tea.Cmd
48 PrependItem(T) tea.Cmd
47 AppendItem(T) tea.Cmd
49 AppendItem(T) tea.Cmd
50+ StartSelection(col, line int)
51+ EndSelection(col, line int)
48}
52}
49
53
50type direction int
54type direction int
@@ -92,7 +96,11 @@ type list[T Item] struct {
92
96
93 rendered string
97 rendered string
94
98
95− movingByItem bool
99+ movingByItem bool
100+ selectionStartCol int
101+ selectionStartLine int
102+ selectionEndCol int
103+ selectionEndLine int
96}
104}
97
105
98type ListOption func(*confOptions)
106type ListOption func(*confOptions)
@@ -170,9 +178,13 @@ func New[T Item](items []T, opts ...ListOption) List[T] {
170 keyMap: DefaultKeyMap(),
178 keyMap: DefaultKeyMap(),
171 focused: true,
179 focused: true,
172 },
180 },
173− items: csync.NewSliceFrom(items),
181+ items: csync.NewSliceFrom(items),
174− indexMap: csync.NewMap[string, int](),
182+ indexMap: csync.NewMap[string, int](),
175− renderedItems: csync.NewMap[string, renderedItem](),
183+ renderedItems: csync.NewMap[string, renderedItem](),
184+ selectionStartCol: -1,
185+ selectionStartLine: -1,
186+ selectionEndLine: -1,
187+ selectionEndCol: -1,
176 }
188 }
177 for _, opt := range opts {
189 for _, opt := range opts {
178 opt(list.confOptions)
190 opt(list.confOptions)
@@ -280,10 +292,132 @@ func (l *list[T]) View() string {
280 if l.resize {
292 if l.resize {
281 return strings.Join(lines, "\n")
293 return strings.Join(lines, "\n")
282 }
294 }
283− return t.S().Base.
295+ view = t.S().Base.
284 Height(l.height).
296 Height(l.height).
285 Width(l.width).
297 Width(l.width).
286 Render(strings.Join(lines, "\n"))
298 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()
287}
421}
288
422
289func (l *list[T]) viewPosition() (int, int) {
423func (l *list[T]) viewPosition() (int, int) {
@@ -1022,3 +1156,19 @@ func (l *list[T]) UpdateItem(id string, item T) tea.Cmd {
1022 }
1156 }
1023 return tea.Sequence(cmds...)
1157 return tea.Sequence(cmds...)
1024}
1158}
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) {
165 p.keyboardEnhancements = msg
165 p.keyboardEnhancements = msg
166 return p, nil
166 return p, nil
167 case tea.MouseWheelMsg:
167 case tea.MouseWheelMsg:
168− if p.isMouseOverChat(msg.Mouse().X, msg.Mouse().Y) {
168+ if p.isMouseOverChat(msg.X, msg.Y) {
169 u, cmd := p.chat.Update(msg)
169 u, cmd := p.chat.Update(msg)
170 p.chat = u.(chat.MessageListCmp)
170 p.chat = u.(chat.MessageListCmp)
171 return p, cmd
171 return p, cmd
172 }
172 }
173 return p, nil
173 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
174 case tea.WindowSizeMsg:
201 case tea.WindowSizeMsg:
175 u, cmd := p.editor.Update(msg)
202 u, cmd := p.editor.Update(msg)
176 p.editor = u.(editor.Editor)
203 p.editor = u.(editor.Editor)
internal/tui/styles/icons.go+21
@@ -15,4 +15,25 @@ const (
15 ToolPending string = "●"
15 ToolPending string = "●"
16 ToolSuccess string = "✓"
16 ToolSuccess string = "✓"
17 ToolError string = "×"
17 ToolError string = "×"
18+
19+ BorderThin string = "│"
18)
20)
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 {
41 case tea.MouseWheelMsg, tea.MouseMotionMsg:
41 case tea.MouseWheelMsg, tea.MouseMotionMsg:
42 now := time.Now()
42 now := time.Now()
43 // trackpad is sending too many requests
43 // trackpad is sending too many requests
44− if now.Sub(lastMouseEvent) < 5*time.Millisecond {
44+ if now.Sub(lastMouseEvent) < 20*time.Millisecond {
45 return nil
45 return nil
46 }
46 }
47 lastMouseEvent = now
47 lastMouseEvent = now