@@ -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
@@ -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:
@@ -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)
+}
@@ -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)
@@ -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,
+}