Merge remote-tracking branch 'origin/list' into improve_agent_promt

This commit is contained in:
Kujtim Hoxha
2025-07-25 13:18:46 +02:00
52 changed files with 3518 additions and 1877 deletions

View File

@@ -1,108 +1 @@
{
"words": [
"afero",
"agentic",
"alecthomas",
"anthropics",
"aymanbagabas",
"azidentity",
"bmatcuk",
"bubbletea",
"charlievieth",
"charmbracelet",
"charmtone",
"Charple",
"chkconfig",
"crush",
"curlie",
"cursorrules",
"diffview",
"doas",
"Dockerfiles",
"doublestar",
"dpkg",
"Emph",
"fastwalk",
"fdisk",
"filepicker",
"Focusable",
"fseventsd",
"fsext",
"genai",
"goquery",
"GROQ",
"Guac",
"imageorient",
"Inex",
"jetta",
"jsons",
"jsonschema",
"jspm",
"Kaufmann",
"killall",
"Lanczos",
"lipgloss",
"LOCALAPPDATA",
"lsps",
"lucasb",
"makepkg",
"mcps",
"MSYS",
"mvdan",
"natefinch",
"nfnt",
"noctx",
"nohup",
"nolint",
"nslookup",
"oksvg",
"Oneshot",
"openrouter",
"opkg",
"pacman",
"paru",
"pfctl",
"postamble",
"postambles",
"preconfigured",
"Preproc",
"Proactiveness",
"Puerkito",
"pycache",
"pytest",
"qjebbs",
"rasterx",
"rivo",
"sabhiram",
"sess",
"shortlog",
"sjson",
"Sourcegraph",
"srwiley",
"SSEMCP",
"Streamable",
"stretchr",
"Strikethrough",
"substrs",
"Suscriber",
"systeminfo",
"tasklist",
"termenv",
"textinput",
"tidwall",
"timedout",
"trashhalo",
"udiff",
"uniseg",
"Unticked",
"urllib",
"USERPROFILE",
"VERTEXAI",
"webp",
"whatis",
"whereis"
],
"flagWords": [],
"language": "en",
"version": "0.2"
}
{"flagWords":[],"version":"0.2","language":"en","words":["afero","agentic","alecthomas","anthropics","aymanbagabas","azidentity","bmatcuk","bubbletea","charlievieth","charmbracelet","charmtone","Charple","chkconfig","crush","curlie","cursorrules","diffview","doas","Dockerfiles","doublestar","dpkg","Emph","fastwalk","fdisk","filepicker","Focusable","fseventsd","fsext","genai","goquery","GROQ","Guac","imageorient","Inex","jetta","jsons","jsonschema","jspm","Kaufmann","killall","Lanczos","lipgloss","LOCALAPPDATA","lsps","lucasb","makepkg","mcps","MSYS","mvdan","natefinch","nfnt","noctx","nohup","nolint","nslookup","oksvg","Oneshot","openrouter","opkg","pacman","paru","pfctl","postamble","postambles","preconfigured","Preproc","Proactiveness","Puerkito","pycache","pytest","qjebbs","rasterx","rivo","sabhiram","sess","shortlog","sjson","Sourcegraph","srwiley","SSEMCP","Streamable","stretchr","Strikethrough","substrs","Suscriber","systeminfo","tasklist","termenv","textinput","tidwall","timedout","trashhalo","udiff","uniseg","Unticked","urllib","USERPROFILE","VERTEXAI","webp","whatis","whereis","sahilm","csync"]}

2
go.mod
View File

@@ -12,7 +12,7 @@ require (
github.com/bmatcuk/doublestar/v4 v4.9.0
github.com/charlievieth/fastwalk v1.0.11
github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250716191546-1e2ffbbcf5c5
github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4.0.20250717140350-bb75e8f6b6ac
github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4.0.20250724172607-5ba56e2bec69
github.com/charmbracelet/catwalk v0.3.1
github.com/charmbracelet/fang v0.3.1-0.20250711140230-d5ebb8c1d674
github.com/charmbracelet/glamour/v2 v2.0.0-20250516160903-6f1e2c8f9ebe

4
go.sum
View File

@@ -70,8 +70,8 @@ github.com/charlievieth/fastwalk v1.0.11 h1:5sLT/q9+d9xMdpKExawLppqvXFZCVKf6JHnr
github.com/charlievieth/fastwalk v1.0.11/go.mod h1:yGy1zbxog41ZVMcKA/i8ojXLFsuayX5VvwhQVoj9PBI=
github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250716191546-1e2ffbbcf5c5 h1:GTcMIfDQJKyNKS+xVt7GkNIwz+tBuQtIuiP50WpzNgs=
github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250716191546-1e2ffbbcf5c5/go.mod h1:6HamsBKWqEC/FVHuQMHgQL+knPyvHH55HwJDHl/adMw=
github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4.0.20250717140350-bb75e8f6b6ac h1:murtkvFYxZ/73vk4Z/tpE4biB+WDZcFmmBp8je/yV6M=
github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4.0.20250717140350-bb75e8f6b6ac/go.mod h1:m240IQxo1/eDQ7klblSzOCAUyc3LddHcV3Rc/YEGAgw=
github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4.0.20250724172607-5ba56e2bec69 h1:nXLMl4ows2qogDXhuEtDNgFNXQiU+PJer+UEBsQZuns=
github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4.0.20250724172607-5ba56e2bec69/go.mod h1:XIQ1qQfRph6Z5o2EikCydjumo0oDInQySRHuPATzbZc=
github.com/charmbracelet/catwalk v0.3.1 h1:MkGWspcMyE659zDkqS+9wsaCMTKRFEDBFY2A2sap6+U=
github.com/charmbracelet/catwalk v0.3.1/go.mod h1:gUUCqqZ8bk4D7ZzGTu3I77k7cC2x4exRuJBN1H2u2pc=
github.com/charmbracelet/colorprofile v0.3.1 h1:k8dTHMd7fgw4bnFd7jXTLZrSU/CQrKnL3m+AxCzDz40=

View File

@@ -2,6 +2,7 @@ package csync
import (
"iter"
"slices"
"sync"
)
@@ -34,3 +35,129 @@ func (s *LazySlice[K]) Seq() iter.Seq[K] {
}
}
}
// Slice is a thread-safe slice implementation that provides concurrent access.
type Slice[T any] struct {
inner []T
mu sync.RWMutex
}
// NewSlice creates a new thread-safe slice.
func NewSlice[T any]() *Slice[T] {
return &Slice[T]{
inner: make([]T, 0),
}
}
// NewSliceFrom creates a new thread-safe slice from an existing slice.
func NewSliceFrom[T any](s []T) *Slice[T] {
inner := make([]T, len(s))
copy(inner, s)
return &Slice[T]{
inner: inner,
}
}
// Append adds an element to the end of the slice.
func (s *Slice[T]) Append(item T) {
s.mu.Lock()
defer s.mu.Unlock()
s.inner = append(s.inner, item)
}
// Prepend adds an element to the beginning of the slice.
func (s *Slice[T]) Prepend(item T) {
s.mu.Lock()
defer s.mu.Unlock()
s.inner = append([]T{item}, s.inner...)
}
// Delete removes the element at the specified index.
func (s *Slice[T]) Delete(index int) bool {
s.mu.Lock()
defer s.mu.Unlock()
if index < 0 || index >= len(s.inner) {
return false
}
s.inner = slices.Delete(s.inner, index, index+1)
return true
}
// Get returns the element at the specified index.
func (s *Slice[T]) Get(index int) (T, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
var zero T
if index < 0 || index >= len(s.inner) {
return zero, false
}
return s.inner[index], true
}
// Set updates the element at the specified index.
func (s *Slice[T]) Set(index int, item T) bool {
s.mu.Lock()
defer s.mu.Unlock()
if index < 0 || index >= len(s.inner) {
return false
}
s.inner[index] = item
return true
}
// Len returns the number of elements in the slice.
func (s *Slice[T]) Len() int {
s.mu.RLock()
defer s.mu.RUnlock()
return len(s.inner)
}
// Slice returns a copy of the underlying slice.
func (s *Slice[T]) Slice() []T {
s.mu.RLock()
defer s.mu.RUnlock()
result := make([]T, len(s.inner))
copy(result, s.inner)
return result
}
// SetSlice replaces the entire slice with a new one.
func (s *Slice[T]) SetSlice(items []T) {
s.mu.Lock()
defer s.mu.Unlock()
s.inner = make([]T, len(items))
copy(s.inner, items)
}
// Clear removes all elements from the slice.
func (s *Slice[T]) Clear() {
s.mu.Lock()
defer s.mu.Unlock()
s.inner = s.inner[:0]
}
// Seq returns an iterator that yields elements from the slice.
func (s *Slice[T]) Seq() iter.Seq[T] {
// Take a snapshot to avoid holding the lock during iteration
items := s.Slice()
return func(yield func(T) bool) {
for _, v := range items {
if !yield(v) {
return
}
}
}
}
// SeqWithIndex returns an iterator that yields index-value pairs from the slice.
func (s *Slice[T]) SeqWithIndex() iter.Seq2[int, T] {
// Take a snapshot to avoid holding the lock during iteration
items := s.Slice()
return func(yield func(int, T) bool) {
for i, v := range items {
if !yield(i, v) {
return
}
}
}
}

View File

@@ -1,11 +1,13 @@
package csync
import (
"sync"
"sync/atomic"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestLazySlice_Seq(t *testing.T) {
@@ -85,3 +87,210 @@ func TestLazySlice_EarlyBreak(t *testing.T) {
assert.Equal(t, []string{"a", "b"}, result)
}
func TestSlice(t *testing.T) {
t.Run("NewSlice", func(t *testing.T) {
s := NewSlice[int]()
assert.Equal(t, 0, s.Len())
})
t.Run("NewSliceFrom", func(t *testing.T) {
original := []int{1, 2, 3}
s := NewSliceFrom(original)
assert.Equal(t, 3, s.Len())
// Verify it's a copy, not a reference
original[0] = 999
val, ok := s.Get(0)
require.True(t, ok)
assert.Equal(t, 1, val)
})
t.Run("Append", func(t *testing.T) {
s := NewSlice[string]()
s.Append("hello")
s.Append("world")
assert.Equal(t, 2, s.Len())
val, ok := s.Get(0)
require.True(t, ok)
assert.Equal(t, "hello", val)
val, ok = s.Get(1)
require.True(t, ok)
assert.Equal(t, "world", val)
})
t.Run("Prepend", func(t *testing.T) {
s := NewSlice[string]()
s.Append("world")
s.Prepend("hello")
assert.Equal(t, 2, s.Len())
val, ok := s.Get(0)
require.True(t, ok)
assert.Equal(t, "hello", val)
val, ok = s.Get(1)
require.True(t, ok)
assert.Equal(t, "world", val)
})
t.Run("Delete", func(t *testing.T) {
s := NewSliceFrom([]int{1, 2, 3, 4, 5})
// Delete middle element
ok := s.Delete(2)
assert.True(t, ok)
assert.Equal(t, 4, s.Len())
expected := []int{1, 2, 4, 5}
actual := s.Slice()
assert.Equal(t, expected, actual)
// Delete out of bounds
ok = s.Delete(10)
assert.False(t, ok)
assert.Equal(t, 4, s.Len())
// Delete negative index
ok = s.Delete(-1)
assert.False(t, ok)
assert.Equal(t, 4, s.Len())
})
t.Run("Get", func(t *testing.T) {
s := NewSliceFrom([]string{"a", "b", "c"})
val, ok := s.Get(1)
require.True(t, ok)
assert.Equal(t, "b", val)
// Out of bounds
_, ok = s.Get(10)
assert.False(t, ok)
// Negative index
_, ok = s.Get(-1)
assert.False(t, ok)
})
t.Run("Set", func(t *testing.T) {
s := NewSliceFrom([]string{"a", "b", "c"})
ok := s.Set(1, "modified")
assert.True(t, ok)
val, ok := s.Get(1)
require.True(t, ok)
assert.Equal(t, "modified", val)
// Out of bounds
ok = s.Set(10, "invalid")
assert.False(t, ok)
// Negative index
ok = s.Set(-1, "invalid")
assert.False(t, ok)
})
t.Run("SetSlice", func(t *testing.T) {
s := NewSlice[int]()
s.Append(1)
s.Append(2)
newItems := []int{10, 20, 30}
s.SetSlice(newItems)
assert.Equal(t, 3, s.Len())
assert.Equal(t, newItems, s.Slice())
// Verify it's a copy
newItems[0] = 999
val, ok := s.Get(0)
require.True(t, ok)
assert.Equal(t, 10, val)
})
t.Run("Clear", func(t *testing.T) {
s := NewSliceFrom([]int{1, 2, 3})
assert.Equal(t, 3, s.Len())
s.Clear()
assert.Equal(t, 0, s.Len())
})
t.Run("Slice", func(t *testing.T) {
original := []int{1, 2, 3}
s := NewSliceFrom(original)
copy := s.Slice()
assert.Equal(t, original, copy)
// Verify it's a copy
copy[0] = 999
val, ok := s.Get(0)
require.True(t, ok)
assert.Equal(t, 1, val)
})
t.Run("Seq", func(t *testing.T) {
s := NewSliceFrom([]int{1, 2, 3})
var result []int
for v := range s.Seq() {
result = append(result, v)
}
assert.Equal(t, []int{1, 2, 3}, result)
})
t.Run("SeqWithIndex", func(t *testing.T) {
s := NewSliceFrom([]string{"a", "b", "c"})
var indices []int
var values []string
for i, v := range s.SeqWithIndex() {
indices = append(indices, i)
values = append(values, v)
}
assert.Equal(t, []int{0, 1, 2}, indices)
assert.Equal(t, []string{"a", "b", "c"}, values)
})
t.Run("ConcurrentAccess", func(t *testing.T) {
s := NewSlice[int]()
const numGoroutines = 100
const itemsPerGoroutine = 10
var wg sync.WaitGroup
// Concurrent appends
for i := 0; i < numGoroutines; i++ {
wg.Add(1)
go func(start int) {
defer wg.Done()
for j := 0; j < itemsPerGoroutine; j++ {
s.Append(start*itemsPerGoroutine + j)
}
}(i)
}
// Concurrent reads
for i := 0; i < numGoroutines; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for j := 0; j < itemsPerGoroutine; j++ {
s.Len() // Just read the length
}
}()
}
wg.Wait()
// Should have all items
assert.Equal(t, numGoroutines*itemsPerGoroutine, s.Len())
})
}

View File

@@ -2,12 +2,16 @@
package anim
import (
"fmt"
"image/color"
"math/rand/v2"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/zeebo/xxh3"
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/charmbracelet/lipgloss/v2"
"github.com/lucasb-eyer/go-colorful"
@@ -58,6 +62,29 @@ func nextID() int {
return int(atomic.AddInt64(&lastID, 1))
}
// Cache for expensive animation calculations
type animCache struct {
initialFrames [][]string
cyclingFrames [][]string
width int
labelWidth int
label []string
ellipsisFrames []string
}
var (
animCacheMutex sync.RWMutex
animCacheMap = make(map[string]*animCache)
)
// settingsHash creates a hash key for the settings to use for caching
func settingsHash(opts Settings) string {
h := xxh3.New()
fmt.Fprintf(h, "%d-%s-%v-%v-%v-%t",
opts.Size, opts.Label, opts.LabelColor, opts.GradColorA, opts.GradColorB, opts.CycleColors)
return fmt.Sprintf("%x", h.Sum(nil))
}
// StepMsg is a message type used to trigger the next step in the animation.
type StepMsg struct{ id int }
@@ -109,79 +136,109 @@ func New(opts Settings) (a Anim) {
}
a.id = nextID()
a.startTime = time.Now()
a.cyclingCharWidth = opts.Size
a.labelWidth = lipgloss.Width(opts.Label)
a.labelColor = opts.LabelColor
// Total width of anim, in cells.
a.width = opts.Size
if opts.Label != "" {
a.width += labelGapWidth + lipgloss.Width(opts.Label)
}
// Check cache first
cacheKey := settingsHash(opts)
animCacheMutex.RLock()
cached, exists := animCacheMap[cacheKey]
animCacheMutex.RUnlock()
// Render the label
a.renderLabel(opts.Label)
// Pre-generate gradient.
var ramp []color.Color
numFrames := prerenderedFrames
if opts.CycleColors {
ramp = makeGradientRamp(a.width*3, opts.GradColorA, opts.GradColorB, opts.GradColorA, opts.GradColorB)
numFrames = a.width * 2
if exists {
// Use cached values
a.width = cached.width
a.labelWidth = cached.labelWidth
a.label = cached.label
a.ellipsisFrames = cached.ellipsisFrames
a.initialFrames = cached.initialFrames
a.cyclingFrames = cached.cyclingFrames
} else {
ramp = makeGradientRamp(a.width, opts.GradColorA, opts.GradColorB)
}
// Generate new values and cache them
a.labelWidth = lipgloss.Width(opts.Label)
// Pre-render initial characters.
a.initialFrames = make([][]string, numFrames)
offset := 0
for i := range a.initialFrames {
a.initialFrames[i] = make([]string, a.width+labelGapWidth+a.labelWidth)
for j := range a.initialFrames[i] {
if j+offset >= len(ramp) {
continue // skip if we run out of colors
}
var c color.Color
if j <= a.cyclingCharWidth {
c = ramp[j+offset]
} else {
c = opts.LabelColor
}
// Also prerender the initial character with Lip Gloss to avoid
// processing in the render loop.
a.initialFrames[i][j] = lipgloss.NewStyle().
Foreground(c).
Render(string(initialChar))
// Total width of anim, in cells.
a.width = opts.Size
if opts.Label != "" {
a.width += labelGapWidth + lipgloss.Width(opts.Label)
}
// Render the label
a.renderLabel(opts.Label)
// Pre-generate gradient.
var ramp []color.Color
numFrames := prerenderedFrames
if opts.CycleColors {
offset++
ramp = makeGradientRamp(a.width*3, opts.GradColorA, opts.GradColorB, opts.GradColorA, opts.GradColorB)
numFrames = a.width * 2
} else {
ramp = makeGradientRamp(a.width, opts.GradColorA, opts.GradColorB)
}
}
// Prerender scrambled rune frames for the animation.
a.cyclingFrames = make([][]string, numFrames)
offset = 0
for i := range a.cyclingFrames {
a.cyclingFrames[i] = make([]string, a.width)
for j := range a.cyclingFrames[i] {
if j+offset >= len(ramp) {
continue // skip if we run out of colors
// Pre-render initial characters.
a.initialFrames = make([][]string, numFrames)
offset := 0
for i := range a.initialFrames {
a.initialFrames[i] = make([]string, a.width+labelGapWidth+a.labelWidth)
for j := range a.initialFrames[i] {
if j+offset >= len(ramp) {
continue // skip if we run out of colors
}
var c color.Color
if j <= a.cyclingCharWidth {
c = ramp[j+offset]
} else {
c = opts.LabelColor
}
// Also prerender the initial character with Lip Gloss to avoid
// processing in the render loop.
a.initialFrames[i][j] = lipgloss.NewStyle().
Foreground(c).
Render(string(initialChar))
}
if opts.CycleColors {
offset++
}
}
// Also prerender the color with Lip Gloss here to avoid processing
// in the render loop.
r := availableRunes[rand.IntN(len(availableRunes))]
a.cyclingFrames[i][j] = lipgloss.NewStyle().
Foreground(ramp[j+offset]).
Render(string(r))
// Prerender scrambled rune frames for the animation.
a.cyclingFrames = make([][]string, numFrames)
offset = 0
for i := range a.cyclingFrames {
a.cyclingFrames[i] = make([]string, a.width)
for j := range a.cyclingFrames[i] {
if j+offset >= len(ramp) {
continue // skip if we run out of colors
}
// Also prerender the color with Lip Gloss here to avoid processing
// in the render loop.
r := availableRunes[rand.IntN(len(availableRunes))]
a.cyclingFrames[i][j] = lipgloss.NewStyle().
Foreground(ramp[j+offset]).
Render(string(r))
}
if opts.CycleColors {
offset++
}
}
if opts.CycleColors {
offset++
// Cache the results
cached = &animCache{
initialFrames: a.initialFrames,
cyclingFrames: a.cyclingFrames,
width: a.width,
labelWidth: a.labelWidth,
label: a.label,
ellipsisFrames: a.ellipsisFrames,
}
animCacheMutex.Lock()
animCacheMap[cacheKey] = cached
animCacheMutex.Unlock()
}
// Random assign a birth to each character for a stagged entrance effect.

View File

@@ -15,7 +15,7 @@ import (
"github.com/charmbracelet/crush/internal/session"
"github.com/charmbracelet/crush/internal/tui/components/chat/messages"
"github.com/charmbracelet/crush/internal/tui/components/core/layout"
"github.com/charmbracelet/crush/internal/tui/components/core/list"
"github.com/charmbracelet/crush/internal/tui/exp/list"
"github.com/charmbracelet/crush/internal/tui/styles"
"github.com/charmbracelet/crush/internal/tui/util"
)
@@ -42,6 +42,7 @@ type MessageListCmp interface {
layout.Help
SetSession(session.Session) tea.Cmd
GoToBottom() tea.Cmd
}
// messageListCmp implements MessageListCmp, providing a virtualized list
@@ -51,8 +52,8 @@ type messageListCmp struct {
app *app.App
width, height int
session session.Session
listCmp list.ListModel
previousSelected int // Last selected item index for restoring focus
listCmp list.List[list.Item]
previousSelected string // Last selected item index for restoring focus
lastUserMessageTime int64
defaultListKeyMap list.KeyMap
@@ -63,21 +64,24 @@ type messageListCmp struct {
func New(app *app.App) MessageListCmp {
defaultListKeyMap := list.DefaultKeyMap()
listCmp := list.New(
list.WithGapSize(1),
list.WithReverse(true),
[]list.Item{},
list.WithGap(1),
list.WithDirectionBackward(),
list.WithFocus(false),
list.WithKeyMap(defaultListKeyMap),
list.WithEnableMouse(),
)
return &messageListCmp{
app: app,
listCmp: listCmp,
previousSelected: list.NoSelection,
previousSelected: "",
defaultListKeyMap: defaultListKeyMap,
}
}
// Init initializes the component.
func (m *messageListCmp) Init() tea.Cmd {
return tea.Sequence(m.listCmp.Init(), m.listCmp.Blur())
return m.listCmp.Init()
}
// Update handles incoming messages and updates the component state.
@@ -93,15 +97,20 @@ func (m *messageListCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil
case SessionClearedMsg:
m.session = session.Session{}
return m, m.listCmp.SetItems([]util.Model{})
return m, m.listCmp.SetItems([]list.Item{})
case pubsub.Event[message.Message]:
cmd := m.handleMessageEvent(msg)
return m, cmd
case tea.MouseWheelMsg:
u, cmd := m.listCmp.Update(msg)
m.listCmp = u.(list.List[list.Item])
return m, cmd
default:
var cmds []tea.Cmd
u, cmd := m.listCmp.Update(msg)
m.listCmp = u.(list.ListModel)
m.listCmp = u.(list.List[list.Item])
cmds = append(cmds, cmd)
return m, tea.Batch(cmds...)
}
@@ -128,7 +137,7 @@ func (m *messageListCmp) handlePermissionRequest(permission permission.Permissio
if permission.Granted {
toolCall.SetPermissionGranted()
}
m.listCmp.UpdateItem(toolCallIndex, toolCall)
m.listCmp.UpdateItem(toolCall.ID(), toolCall)
}
return nil
}
@@ -188,7 +197,7 @@ func (m *messageListCmp) handleChildSession(event pubsub.Event[message.Message])
toolCall.SetNestedToolCalls(nestedToolCalls)
m.listCmp.UpdateItem(
toolCallInx,
toolCall.ID(),
toolCall,
)
return tea.Batch(cmds...)
@@ -257,7 +266,7 @@ func (m *messageListCmp) handleToolMessage(msg message.Message) tea.Cmd {
if toolCallIndex := m.findToolCallByID(items, tr.ToolCallID); toolCallIndex != NotFound {
toolCall := items[toolCallIndex].(messages.ToolCallCmp)
toolCall.SetToolResult(tr)
m.listCmp.UpdateItem(toolCallIndex, toolCall)
m.listCmp.UpdateItem(toolCall.ID(), toolCall)
}
}
return nil
@@ -265,7 +274,7 @@ func (m *messageListCmp) handleToolMessage(msg message.Message) tea.Cmd {
// findToolCallByID searches for a tool call with the specified ID.
// Returns the index if found, NotFound otherwise.
func (m *messageListCmp) findToolCallByID(items []util.Model, toolCallID string) int {
func (m *messageListCmp) findToolCallByID(items []list.Item, toolCallID string) int {
// Search backwards as tool calls are more likely to be recent
for i := len(items) - 1; i >= 0; i-- {
if toolCall, ok := items[i].(messages.ToolCallCmp); ok && toolCall.GetToolCall().ID == toolCallID {
@@ -298,7 +307,7 @@ func (m *messageListCmp) handleUpdateAssistantMessage(msg message.Message) tea.C
}
// findAssistantMessageAndToolCalls locates the assistant message and its tool calls.
func (m *messageListCmp) findAssistantMessageAndToolCalls(items []util.Model, messageID string) (int, map[int]messages.ToolCallCmp) {
func (m *messageListCmp) findAssistantMessageAndToolCalls(items []list.Item, messageID string) (int, map[int]messages.ToolCallCmp) {
assistantIndex := NotFound
toolCalls := make(map[int]messages.ToolCallCmp)
@@ -334,7 +343,7 @@ func (m *messageListCmp) updateAssistantMessageContent(msg message.Message, assi
uiMsg := items[assistantIndex].(messages.MessageCmp)
uiMsg.SetMessage(msg)
m.listCmp.UpdateItem(
assistantIndex,
items[assistantIndex].ID(),
uiMsg,
)
if msg.FinishPart() != nil && msg.FinishPart().Reason == message.FinishReasonEndTurn {
@@ -346,7 +355,8 @@ func (m *messageListCmp) updateAssistantMessageContent(msg message.Message, assi
)
}
} else if hasToolCallsOnly {
m.listCmp.DeleteItem(assistantIndex)
items := m.listCmp.Items()
m.listCmp.DeleteItem(items[assistantIndex].ID())
}
return cmd
@@ -373,13 +383,13 @@ func (m *messageListCmp) updateToolCalls(msg message.Message, existingToolCalls
// updateOrAddToolCall updates an existing tool call or adds a new one.
func (m *messageListCmp) updateOrAddToolCall(msg message.Message, tc message.ToolCall, existingToolCalls map[int]messages.ToolCallCmp) tea.Cmd {
// Try to find existing tool call
for index, existingTC := range existingToolCalls {
for _, existingTC := range existingToolCalls {
if tc.ID == existingTC.GetToolCall().ID {
existingTC.SetToolCall(tc)
if msg.FinishPart() != nil && msg.FinishPart().Reason == message.FinishReasonCanceled {
existingTC.SetCancelled()
}
m.listCmp.UpdateItem(index, existingTC)
m.listCmp.UpdateItem(tc.ID, existingTC)
return nil
}
}
@@ -424,7 +434,7 @@ func (m *messageListCmp) SetSession(session session.Session) tea.Cmd {
}
if len(sessionMessages) == 0 {
return m.listCmp.SetItems([]util.Model{})
return m.listCmp.SetItems([]list.Item{})
}
// Initialize with first message timestamp
@@ -451,8 +461,8 @@ func (m *messageListCmp) buildToolResultMap(messages []message.Message) map[stri
}
// convertMessagesToUI converts database messages to UI components.
func (m *messageListCmp) convertMessagesToUI(sessionMessages []message.Message, toolResultMap map[string]message.ToolResult) []util.Model {
uiMessages := make([]util.Model, 0)
func (m *messageListCmp) convertMessagesToUI(sessionMessages []message.Message, toolResultMap map[string]message.ToolResult) []list.Item {
uiMessages := make([]list.Item, 0)
for _, msg := range sessionMessages {
switch msg.Role {
@@ -471,8 +481,8 @@ func (m *messageListCmp) convertMessagesToUI(sessionMessages []message.Message,
}
// convertAssistantMessage converts an assistant message and its tool calls to UI components.
func (m *messageListCmp) convertAssistantMessage(msg message.Message, toolResultMap map[string]message.ToolResult) []util.Model {
var uiMessages []util.Model
func (m *messageListCmp) convertAssistantMessage(msg message.Message, toolResultMap map[string]message.ToolResult) []list.Item {
var uiMessages []list.Item
// Add assistant message if it should be displayed
if m.shouldShowAssistantMessage(msg) {
@@ -553,3 +563,7 @@ func (m *messageListCmp) IsFocused() bool {
func (m *messageListCmp) Bindings() []key.Binding {
return m.defaultListKeyMap.KeyBindings()
}
func (m *messageListCmp) GoToBottom() tea.Cmd {
return m.listCmp.GoToBottom()
}

View File

@@ -11,13 +11,14 @@ import (
"github.com/charmbracelet/catwalk/pkg/catwalk"
"github.com/charmbracelet/lipgloss/v2"
"github.com/charmbracelet/x/ansi"
"github.com/google/uuid"
"github.com/charmbracelet/crush/internal/config"
"github.com/charmbracelet/crush/internal/message"
"github.com/charmbracelet/crush/internal/tui/components/anim"
"github.com/charmbracelet/crush/internal/tui/components/core"
"github.com/charmbracelet/crush/internal/tui/components/core/layout"
"github.com/charmbracelet/crush/internal/tui/components/core/list"
"github.com/charmbracelet/crush/internal/tui/exp/list"
"github.com/charmbracelet/crush/internal/tui/styles"
"github.com/charmbracelet/crush/internal/tui/util"
)
@@ -31,6 +32,7 @@ type MessageCmp interface {
GetMessage() message.Message // Access to underlying message data
SetMessage(msg message.Message) // Update the message content
Spinning() bool // Animation state for loading messages
ID() string
}
// messageCmp implements the MessageCmp interface for displaying chat messages.
@@ -333,19 +335,25 @@ func (m *messageCmp) Spinning() bool {
}
type AssistantSection interface {
util.Model
list.Item
layout.Sizeable
list.SectionHeader
}
type assistantSectionModel struct {
width int
id string
message message.Message
lastUserMessageTime time.Time
}
// ID implements AssistantSection.
func (m *assistantSectionModel) ID() string {
return m.id
}
func NewAssistantSection(message message.Message, lastUserMessageTime time.Time) AssistantSection {
return &assistantSectionModel{
width: 0,
id: uuid.NewString(),
message: message,
lastUserMessageTime: lastUserMessageTime,
}
@@ -392,3 +400,7 @@ func (m *assistantSectionModel) SetSize(width int, height int) tea.Cmd {
func (m *assistantSectionModel) IsSectionHeader() bool {
return true
}
func (m *messageCmp) ID() string {
return m.message.ID
}

View File

@@ -32,6 +32,7 @@ type ToolCallCmp interface {
SetIsNested(bool) // Set whether this tool call is nested
SetPermissionRequested() // Mark permission request
SetPermissionGranted() // Mark permission granted
ID() string
}
// toolCallCmp implements the ToolCallCmp interface for displaying tool calls.
@@ -338,3 +339,7 @@ func (m *toolCallCmp) SetPermissionRequested() {
func (m *toolCallCmp) SetPermissionGranted() {
m.permissionGranted = true
}
func (m *toolCallCmp) ID() string {
return m.call.ID
}

View File

@@ -14,12 +14,11 @@ import (
"github.com/charmbracelet/crush/internal/config"
"github.com/charmbracelet/crush/internal/llm/prompt"
"github.com/charmbracelet/crush/internal/tui/components/chat"
"github.com/charmbracelet/crush/internal/tui/components/completions"
"github.com/charmbracelet/crush/internal/tui/components/core"
"github.com/charmbracelet/crush/internal/tui/components/core/layout"
"github.com/charmbracelet/crush/internal/tui/components/core/list"
"github.com/charmbracelet/crush/internal/tui/components/dialogs/models"
"github.com/charmbracelet/crush/internal/tui/components/logo"
"github.com/charmbracelet/crush/internal/tui/exp/list"
"github.com/charmbracelet/crush/internal/tui/styles"
"github.com/charmbracelet/crush/internal/tui/util"
"github.com/charmbracelet/crush/internal/version"
@@ -86,9 +85,7 @@ func New() Splash {
listKeyMap.DownOneItem = keyMap.Next
listKeyMap.UpOneItem = keyMap.Previous
t := styles.CurrentTheme()
inputStyle := t.S().Base.Padding(0, 1, 0, 1)
modelList := models.NewModelListComponent(listKeyMap, inputStyle, "Find your fave")
modelList := models.NewModelListComponent(listKeyMap, "Find your fave", false)
apiKeyInput := models.NewAPIKeyInput()
return &splashCmp{
@@ -195,20 +192,18 @@ func (s *splashCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return s, s.saveAPIKeyAndContinue(s.apiKeyValue)
}
if s.isOnboarding && !s.needsAPIKey {
modelInx := s.modelList.SelectedIndex()
if modelInx == -1 {
selectedItem := s.modelList.SelectedModel()
if selectedItem == nil {
return s, nil
}
items := s.modelList.Items()
selectedItem := items[modelInx].(completions.CompletionItem).Value().(models.ModelOption)
if s.isProviderConfigured(string(selectedItem.Provider.ID)) {
cmd := s.setPreferredModel(selectedItem)
cmd := s.setPreferredModel(*selectedItem)
s.isOnboarding = false
return s, tea.Batch(cmd, util.CmdHandler(OnboardingCompleteMsg{}))
} else {
// Provider not configured, show API key input
s.needsAPIKey = true
s.selectedModel = &selectedItem
s.selectedModel = selectedItem
s.apiKeyInput.SetProviderName(selectedItem.Provider.Name)
return s, nil
}
@@ -267,6 +262,9 @@ func (s *splashCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return s, nil
}
case key.Matches(msg, s.keyMap.Yes):
if s.isOnboarding {
return s, nil
}
if s.needsAPIKey {
u, cmd := s.apiKeyInput.Update(msg)
s.apiKeyInput = u.(*models.APIKeyInput)
@@ -277,6 +275,9 @@ func (s *splashCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return s, s.initializeProject()
}
case key.Matches(msg, s.keyMap.No):
if s.isOnboarding {
return s, nil
}
if s.needsAPIKey {
u, cmd := s.apiKeyInput.Update(msg)
s.apiKeyInput = u.(*models.APIKeyInput)
@@ -606,7 +607,7 @@ func (s *splashCmp) moveCursor(cursor *tea.Cursor) *tea.Cursor {
cursor.Y += offset
cursor.X = cursor.X + 1
} else if s.isOnboarding {
offset := logoHeight + SplashScreenPaddingY + s.logoGap() + 3
offset := logoHeight + SplashScreenPaddingY + s.logoGap() + 2
cursor.Y += offset
cursor.X = cursor.X + 1
}

View File

@@ -5,7 +5,7 @@ import (
"github.com/charmbracelet/bubbles/v2/key"
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/charmbracelet/crush/internal/tui/components/core/list"
"github.com/charmbracelet/crush/internal/tui/exp/list"
"github.com/charmbracelet/crush/internal/tui/styles"
"github.com/charmbracelet/crush/internal/tui/util"
"github.com/charmbracelet/lipgloss/v2"
@@ -50,6 +50,8 @@ type Completions interface {
Height() int
}
type listModel = list.FilterableList[list.CompletionItem[any]]
type completionsCmp struct {
width int
height int // Height of the completions component`
@@ -58,7 +60,7 @@ type completionsCmp struct {
open bool // Indicates if the completions are open
keyMap KeyMap
list list.ListModel
list listModel
query string // The current filter query
}
@@ -76,10 +78,13 @@ func New() Completions {
keyMap.UpOneItem = completionsKeyMap.Up
keyMap.DownOneItem = completionsKeyMap.Down
l := list.New(
list.WithReverse(true),
list.WithKeyMap(keyMap),
list.WithHideFilterInput(true),
l := list.NewFilterableList(
[]list.CompletionItem[any]{},
list.WithFilterInputHidden(),
list.WithFilterListOptions(
list.WithDirectionBackward(),
list.WithKeyMap(keyMap),
),
)
return &completionsCmp{
width: 0,
@@ -109,44 +114,41 @@ func (c *completionsCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch {
case key.Matches(msg, c.keyMap.Up):
u, cmd := c.list.Update(msg)
c.list = u.(list.ListModel)
c.list = u.(listModel)
return c, cmd
case key.Matches(msg, c.keyMap.Down):
d, cmd := c.list.Update(msg)
c.list = d.(list.ListModel)
c.list = d.(listModel)
return c, cmd
case key.Matches(msg, c.keyMap.UpInsert):
selectedItemInx := c.list.SelectedIndex() - 1
items := c.list.Items()
if selectedItemInx == list.NoSelection || selectedItemInx < 0 {
return c, nil // No item selected, do nothing
s := c.list.SelectedItem()
if s == nil {
return c, nil
}
selectedItem := items[selectedItemInx].(CompletionItem).Value()
c.list.SetSelected(selectedItemInx)
selectedItem := *s
c.list.SetSelected(selectedItem.ID())
return c, util.CmdHandler(SelectCompletionMsg{
Value: selectedItem,
Insert: true,
})
case key.Matches(msg, c.keyMap.DownInsert):
selectedItemInx := c.list.SelectedIndex() + 1
items := c.list.Items()
if selectedItemInx == list.NoSelection || selectedItemInx >= len(items) {
return c, nil // No item selected, do nothing
s := c.list.SelectedItem()
if s == nil {
return c, nil
}
selectedItem := items[selectedItemInx].(CompletionItem).Value()
c.list.SetSelected(selectedItemInx)
selectedItem := *s
c.list.SetSelected(selectedItem.ID())
return c, util.CmdHandler(SelectCompletionMsg{
Value: selectedItem,
Insert: true,
})
case key.Matches(msg, c.keyMap.Select):
selectedItemInx := c.list.SelectedIndex()
if selectedItemInx == list.NoSelection {
return c, nil // No item selected, do nothing
s := c.list.SelectedItem()
if s == nil {
return c, nil
}
items := c.list.Items()
selectedItem := items[selectedItemInx].(CompletionItem).Value()
selectedItem := *s
c.open = false // Close completions after selection
return c, util.CmdHandler(SelectCompletionMsg{
Value: selectedItem,
@@ -162,10 +164,14 @@ func (c *completionsCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
c.query = ""
c.x = msg.X
c.y = msg.Y
items := []util.Model{}
items := []list.CompletionItem[any]{}
t := styles.CurrentTheme()
for _, completion := range msg.Completions {
item := NewCompletionItem(completion.Title, completion.Value, WithBackgroundColor(t.BgSubtle))
item := list.NewCompletionItem(
completion.Title,
completion.Value,
list.WithCompletionBackgroundColor(t.BgSubtle),
)
items = append(items, item)
}
c.height = max(min(c.height, len(items)), 1) // Ensure at least 1 item height

File diff suppressed because it is too large Load Diff

View File

@@ -10,10 +10,9 @@ import (
"github.com/charmbracelet/crush/internal/config"
"github.com/charmbracelet/crush/internal/llm/prompt"
"github.com/charmbracelet/crush/internal/tui/components/chat"
"github.com/charmbracelet/crush/internal/tui/components/completions"
"github.com/charmbracelet/crush/internal/tui/components/core"
"github.com/charmbracelet/crush/internal/tui/components/core/list"
"github.com/charmbracelet/crush/internal/tui/components/dialogs"
"github.com/charmbracelet/crush/internal/tui/exp/list"
"github.com/charmbracelet/crush/internal/tui/styles"
"github.com/charmbracelet/crush/internal/tui/util"
)
@@ -29,6 +28,8 @@ const (
UserCommands
)
type listModel = list.FilterableList[list.CompletionItem[Command]]
// Command represents a command that can be executed
type Command struct {
ID string
@@ -48,7 +49,7 @@ type commandDialogCmp struct {
wWidth int // Width of the terminal window
wHeight int // Height of the terminal window
commandList list.ListModel
commandList listModel
keyMap CommandsDialogKeyMap
help help.Model
commandType int // SystemCommands or UserCommands
@@ -67,24 +68,23 @@ type (
)
func NewCommandDialog(sessionID string) CommandsDialog {
listKeyMap := list.DefaultKeyMap()
keyMap := DefaultCommandsDialogKeyMap()
listKeyMap := list.DefaultKeyMap()
listKeyMap.Down.SetEnabled(false)
listKeyMap.Up.SetEnabled(false)
listKeyMap.HalfPageDown.SetEnabled(false)
listKeyMap.HalfPageUp.SetEnabled(false)
listKeyMap.Home.SetEnabled(false)
listKeyMap.End.SetEnabled(false)
listKeyMap.DownOneItem = keyMap.Next
listKeyMap.UpOneItem = keyMap.Previous
t := styles.CurrentTheme()
commandList := list.New(
list.WithFilterable(true),
list.WithKeyMap(listKeyMap),
list.WithWrapNavigation(true),
inputStyle := t.S().Base.PaddingLeft(1).PaddingBottom(1)
commandList := list.NewFilterableList(
[]list.CompletionItem[Command]{},
list.WithFilterInputStyle(inputStyle),
list.WithFilterListOptions(
list.WithKeyMap(listKeyMap),
list.WithWrapNavigation(),
list.WithResizeByList(),
),
)
help := help.New()
help.Styles = t.S().Help
@@ -103,10 +103,8 @@ func (c *commandDialogCmp) Init() tea.Cmd {
if err != nil {
return util.ReportError(err)
}
c.userCommands = commands
c.SetCommandType(c.commandType)
return c.commandList.Init()
return c.SetCommandType(c.commandType)
}
func (c *commandDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
@@ -114,22 +112,23 @@ func (c *commandDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case tea.WindowSizeMsg:
c.wWidth = msg.Width
c.wHeight = msg.Height
c.SetCommandType(c.commandType)
return c, c.commandList.SetSize(c.listWidth(), c.listHeight())
case tea.KeyPressMsg:
switch {
case key.Matches(msg, c.keyMap.Select):
selectedItemInx := c.commandList.SelectedIndex()
if selectedItemInx == list.NoSelection {
selectedItem := c.commandList.SelectedItem()
if selectedItem == nil {
return c, nil // No item selected, do nothing
}
items := c.commandList.Items()
selectedItem := items[selectedItemInx].(completions.CompletionItem).Value().(Command)
command := (*selectedItem).Value()
return c, tea.Sequence(
util.CmdHandler(dialogs.CloseDialogMsg{}),
selectedItem.Handler(selectedItem),
command.Handler(command),
)
case key.Matches(msg, c.keyMap.Tab):
if len(c.userCommands) == 0 {
return c, nil
}
// Toggle command type between System and User commands
if c.commandType == SystemCommands {
return c, c.SetCommandType(UserCommands)
@@ -140,7 +139,7 @@ func (c *commandDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return c, util.CmdHandler(dialogs.CloseDialogMsg{})
default:
u, cmd := c.commandList.Update(msg)
c.commandList = u.(list.ListModel)
c.commandList = u.(listModel)
return c, cmd
}
}
@@ -151,9 +150,14 @@ func (c *commandDialogCmp) View() string {
t := styles.CurrentTheme()
listView := c.commandList
radio := c.commandTypeRadio()
header := t.S().Base.Padding(0, 1, 1, 1).Render(core.Title("Commands", c.width-lipgloss.Width(radio)-5) + " " + radio)
if len(c.userCommands) == 0 {
header = t.S().Base.Padding(0, 1, 1, 1).Render(core.Title("Commands", c.width-4))
}
content := lipgloss.JoinVertical(
lipgloss.Left,
t.S().Base.Padding(0, 1, 1, 1).Render(core.Title("Commands", c.width-lipgloss.Width(radio)-5)+" "+radio),
header,
listView.View(),
"",
t.S().Base.Width(c.width-2).PaddingLeft(1).AlignHorizontal(lipgloss.Left).Render(c.help.View(c.keyMap)),
@@ -197,13 +201,18 @@ func (c *commandDialogCmp) SetCommandType(commandType int) tea.Cmd {
commands = c.userCommands
}
commandItems := []util.Model{}
commandItems := []list.CompletionItem[Command]{}
for _, cmd := range commands {
opts := []completions.CompletionOption{}
if cmd.Shortcut != "" {
opts = append(opts, completions.WithShortcut(cmd.Shortcut))
opts := []list.CompletionItemOption{
list.WithCompletionID(cmd.ID),
}
commandItems = append(commandItems, completions.NewCompletionItem(cmd.Title, cmd, opts...))
if cmd.Shortcut != "" {
opts = append(
opts,
list.WithCompletionShortcut(cmd.Shortcut),
)
}
commandItems = append(commandItems, list.NewCompletionItem(cmd.Title, cmd, opts...))
}
return c.commandList.SetItems(commandItems)
}

View File

@@ -1,69 +0,0 @@
package commands
import (
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/charmbracelet/crush/internal/tui/components/core"
"github.com/charmbracelet/crush/internal/tui/components/core/layout"
"github.com/charmbracelet/crush/internal/tui/components/core/list"
"github.com/charmbracelet/crush/internal/tui/styles"
"github.com/charmbracelet/crush/internal/tui/util"
"github.com/charmbracelet/x/ansi"
)
type ItemSection interface {
util.Model
layout.Sizeable
list.SectionHeader
SetInfo(info string)
}
type itemSectionModel struct {
width int
title string
info string
}
func NewItemSection(title string) ItemSection {
return &itemSectionModel{
title: title,
}
}
func (m *itemSectionModel) Init() tea.Cmd {
return nil
}
func (m *itemSectionModel) Update(tea.Msg) (tea.Model, tea.Cmd) {
return m, nil
}
func (m *itemSectionModel) View() string {
t := styles.CurrentTheme()
title := ansi.Truncate(m.title, m.width-2, "…")
style := t.S().Base.Padding(1, 1, 0, 1)
title = t.S().Muted.Render(title)
section := ""
if m.info != "" {
section = core.SectionWithInfo(title, m.width-2, m.info)
} else {
section = core.Section(title, m.width-2)
}
return style.Render(section)
}
func (m *itemSectionModel) GetSize() (int, int) {
return m.width, 1
}
func (m *itemSectionModel) SetSize(width int, height int) tea.Cmd {
m.width = width
return nil
}
func (m *itemSectionModel) IsSectionHeader() bool {
return true
}
func (m *itemSectionModel) SetInfo(info string) {
m.info = info
}

View File

@@ -7,27 +7,36 @@ import (
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/charmbracelet/catwalk/pkg/catwalk"
"github.com/charmbracelet/crush/internal/config"
"github.com/charmbracelet/crush/internal/tui/components/completions"
"github.com/charmbracelet/crush/internal/tui/components/core/list"
"github.com/charmbracelet/crush/internal/tui/components/dialogs/commands"
"github.com/charmbracelet/crush/internal/tui/exp/list"
"github.com/charmbracelet/crush/internal/tui/styles"
"github.com/charmbracelet/crush/internal/tui/util"
"github.com/charmbracelet/lipgloss/v2"
)
type listModel = list.FilterableGroupList[list.CompletionItem[ModelOption]]
type ModelListComponent struct {
list list.ListModel
list listModel
modelType int
providers []catwalk.Provider
}
func NewModelListComponent(keyMap list.KeyMap, inputStyle lipgloss.Style, inputPlaceholder string) *ModelListComponent {
modelList := list.New(
list.WithFilterable(true),
func NewModelListComponent(keyMap list.KeyMap, inputPlaceholder string, shouldResize bool) *ModelListComponent {
t := styles.CurrentTheme()
inputStyle := t.S().Base.PaddingLeft(1).PaddingBottom(1)
options := []list.ListOption{
list.WithKeyMap(keyMap),
list.WithInputStyle(inputStyle),
list.WithWrapNavigation(),
}
if shouldResize {
options = append(options, list.WithResizeByList())
}
modelList := list.NewFilterableGroupedList(
[]list.Group[list.CompletionItem[ModelOption]]{},
list.WithFilterInputStyle(inputStyle),
list.WithFilterPlaceholder(inputPlaceholder),
list.WithWrapNavigation(true),
list.WithFilterListOptions(
options...,
),
)
return &ModelListComponent{
@@ -51,7 +60,7 @@ func (m *ModelListComponent) Init() tea.Cmd {
func (m *ModelListComponent) Update(msg tea.Msg) (*ModelListComponent, tea.Cmd) {
u, cmd := m.list.Update(msg)
m.list = u.(list.ListModel)
m.list = u.(listModel)
return m, cmd
}
@@ -67,21 +76,23 @@ func (m *ModelListComponent) SetSize(width, height int) tea.Cmd {
return m.list.SetSize(width, height)
}
func (m *ModelListComponent) Items() []util.Model {
return m.list.Items()
}
func (m *ModelListComponent) SelectedIndex() int {
return m.list.SelectedIndex()
func (m *ModelListComponent) SelectedModel() *ModelOption {
s := m.list.SelectedItem()
if s == nil {
return nil
}
sv := *s
model := sv.Value()
return &model
}
func (m *ModelListComponent) SetModelType(modelType int) tea.Cmd {
t := styles.CurrentTheme()
m.modelType = modelType
modelItems := []util.Model{}
var groups []list.Group[list.CompletionItem[ModelOption]]
// first none section
selectIndex := 1
selectedItemID := ""
cfg := config.Get()
var currentModel config.SelectedModel
@@ -140,18 +151,28 @@ func (m *ModelListComponent) SetModelType(modelType int) tea.Cmd {
if name == "" {
name = string(configProvider.ID)
}
section := commands.NewItemSection(name)
section := list.NewItemSection(name)
section.SetInfo(configured)
modelItems = append(modelItems, section)
group := list.Group[list.CompletionItem[ModelOption]]{
Section: section,
}
for _, model := range configProvider.Models {
modelItems = append(modelItems, completions.NewCompletionItem(model.Name, ModelOption{
item := list.NewCompletionItem(model.Name, ModelOption{
Provider: configProvider,
Model: model,
}))
},
list.WithCompletionID(
fmt.Sprintf("%s:%s", providerConfig.ID, model.ID),
),
)
group.Items = append(group.Items, item)
if model.ID == currentModel.Model && string(configProvider.ID) == currentModel.Provider {
selectIndex = len(modelItems) - 1 // Set the selected index to the current model
selectedItemID = item.ID()
}
}
groups = append(groups, group)
addedProviders[providerID] = true
}
}
@@ -173,23 +194,43 @@ func (m *ModelListComponent) SetModelType(modelType int) tea.Cmd {
name = string(provider.ID)
}
section := commands.NewItemSection(name)
section := list.NewItemSection(name)
if _, ok := cfg.Providers.Get(string(provider.ID)); ok {
section.SetInfo(configured)
}
modelItems = append(modelItems, section)
group := list.Group[list.CompletionItem[ModelOption]]{
Section: section,
}
for _, model := range provider.Models {
modelItems = append(modelItems, completions.NewCompletionItem(model.Name, ModelOption{
item := list.NewCompletionItem(model.Name, ModelOption{
Provider: provider,
Model: model,
}))
},
list.WithCompletionID(
fmt.Sprintf("%s:%s", provider.ID, model.ID),
),
)
group.Items = append(group.Items, item)
if model.ID == currentModel.Model && string(provider.ID) == currentModel.Provider {
selectIndex = len(modelItems) - 1 // Set the selected index to the current model
selectedItemID = item.ID()
}
}
groups = append(groups, group)
}
return tea.Sequence(m.list.SetItems(modelItems), m.list.SetSelected(selectIndex))
var cmds []tea.Cmd
cmd := m.list.SetGroups(groups)
if cmd != nil {
cmds = append(cmds, cmd)
}
cmd = m.list.SetSelected(selectedItemID)
if cmd != nil {
cmds = append(cmds, cmd)
}
return tea.Sequence(cmds...)
}
// GetModelType returns the current model type
@@ -198,7 +239,7 @@ func (m *ModelListComponent) GetModelType() int {
}
func (m *ModelListComponent) SetInputPlaceholder(placeholder string) {
m.list.SetFilterPlaceholder(placeholder)
m.list.SetInputPlaceholder(placeholder)
}
func (m *ModelListComponent) SetProviders(providers []catwalk.Provider) {

View File

@@ -10,10 +10,9 @@ import (
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/charmbracelet/catwalk/pkg/catwalk"
"github.com/charmbracelet/crush/internal/config"
"github.com/charmbracelet/crush/internal/tui/components/completions"
"github.com/charmbracelet/crush/internal/tui/components/core"
"github.com/charmbracelet/crush/internal/tui/components/core/list"
"github.com/charmbracelet/crush/internal/tui/components/dialogs"
"github.com/charmbracelet/crush/internal/tui/exp/list"
"github.com/charmbracelet/crush/internal/tui/styles"
"github.com/charmbracelet/crush/internal/tui/util"
"github.com/charmbracelet/lipgloss/v2"
@@ -71,22 +70,16 @@ type modelDialogCmp struct {
}
func NewModelDialogCmp() ModelDialog {
listKeyMap := list.DefaultKeyMap()
keyMap := DefaultKeyMap()
listKeyMap := list.DefaultKeyMap()
listKeyMap.Down.SetEnabled(false)
listKeyMap.Up.SetEnabled(false)
listKeyMap.HalfPageDown.SetEnabled(false)
listKeyMap.HalfPageUp.SetEnabled(false)
listKeyMap.Home.SetEnabled(false)
listKeyMap.End.SetEnabled(false)
listKeyMap.DownOneItem = keyMap.Next
listKeyMap.UpOneItem = keyMap.Previous
t := styles.CurrentTheme()
inputStyle := t.S().Base.Padding(0, 1, 0, 1)
modelList := NewModelListComponent(listKeyMap, inputStyle, "Choose a model for large, complex tasks")
modelList := NewModelListComponent(listKeyMap, "Choose a model for large, complex tasks", true)
apiKeyInput := NewAPIKeyInput()
apiKeyInput.SetShowTitle(false)
help := help.New()
@@ -162,12 +155,7 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
)
}
// Normal model selection
selectedItemInx := m.modelList.SelectedIndex()
if selectedItemInx == list.NoSelection {
return m, nil
}
items := m.modelList.Items()
selectedItem := items[selectedItemInx].(completions.CompletionItem).Value().(ModelOption)
selectedItem := m.modelList.SelectedModel()
var modelType config.SelectedModelType
if m.modelList.GetModelType() == LargeModelType {
@@ -191,7 +179,7 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} else {
// Provider not configured, show API key input
m.needsAPIKey = true
m.selectedModel = &selectedItem
m.selectedModel = selectedItem
m.selectedModelType = modelType
m.apiKeyInput.SetProviderName(selectedItem.Provider.Name)
return m, nil
@@ -310,13 +298,11 @@ func (m *modelDialogCmp) style() lipgloss.Style {
}
func (m *modelDialogCmp) listWidth() int {
return defaultWidth - 2 // 4 for padding
return m.width - 2
}
func (m *modelDialogCmp) listHeight() int {
items := m.modelList.Items()
listHeigh := len(items) + 2 + 4
return min(listHeigh, m.wHeight/2)
return m.wHeight / 2
}
func (m *modelDialogCmp) Position() (int, int) {

View File

@@ -6,10 +6,9 @@ import (
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/charmbracelet/crush/internal/session"
"github.com/charmbracelet/crush/internal/tui/components/chat"
"github.com/charmbracelet/crush/internal/tui/components/completions"
"github.com/charmbracelet/crush/internal/tui/components/core"
"github.com/charmbracelet/crush/internal/tui/components/core/list"
"github.com/charmbracelet/crush/internal/tui/components/dialogs"
"github.com/charmbracelet/crush/internal/tui/exp/list"
"github.com/charmbracelet/crush/internal/tui/styles"
"github.com/charmbracelet/crush/internal/tui/util"
"github.com/charmbracelet/lipgloss/v2"
@@ -22,6 +21,8 @@ type SessionDialog interface {
dialogs.DialogModel
}
type SessionsList = list.FilterableList[list.CompletionItem[session.Session]]
type sessionDialogCmp struct {
selectedInx int
wWidth int
@@ -29,8 +30,7 @@ type sessionDialogCmp struct {
width int
selectedSessionID string
keyMap KeyMap
sessionsList list.ListModel
renderedSelected bool
sessionsList SessionsList
help help.Model
}
@@ -39,39 +39,31 @@ func NewSessionDialogCmp(sessions []session.Session, selectedID string) SessionD
t := styles.CurrentTheme()
listKeyMap := list.DefaultKeyMap()
keyMap := DefaultKeyMap()
listKeyMap.Down.SetEnabled(false)
listKeyMap.Up.SetEnabled(false)
listKeyMap.HalfPageDown.SetEnabled(false)
listKeyMap.HalfPageUp.SetEnabled(false)
listKeyMap.Home.SetEnabled(false)
listKeyMap.End.SetEnabled(false)
listKeyMap.DownOneItem = keyMap.Next
listKeyMap.UpOneItem = keyMap.Previous
selectedInx := 0
items := make([]util.Model, len(sessions))
items := make([]list.CompletionItem[session.Session], len(sessions))
if len(sessions) > 0 {
for i, session := range sessions {
items[i] = completions.NewCompletionItem(session.Title, session)
if session.ID == selectedID {
selectedInx = i
}
items[i] = list.NewCompletionItem(session.Title, session, list.WithCompletionID(session.ID))
}
}
sessionsList := list.New(
list.WithFilterable(true),
inputStyle := t.S().Base.PaddingLeft(1).PaddingBottom(1)
sessionsList := list.NewFilterableList(
items,
list.WithFilterPlaceholder("Enter a session name"),
list.WithKeyMap(listKeyMap),
list.WithItems(items),
list.WithWrapNavigation(true),
list.WithFilterInputStyle(inputStyle),
list.WithFilterListOptions(
list.WithKeyMap(listKeyMap),
list.WithWrapNavigation(),
),
)
help := help.New()
help.Styles = t.S().Help
s := &sessionDialogCmp{
selectedInx: selectedInx,
selectedSessionID: selectedID,
keyMap: DefaultKeyMap(),
sessionsList: sessionsList,
@@ -82,32 +74,35 @@ func NewSessionDialogCmp(sessions []session.Session, selectedID string) SessionD
}
func (s *sessionDialogCmp) Init() tea.Cmd {
return s.sessionsList.Init()
var cmds []tea.Cmd
cmds = append(cmds, s.sessionsList.Init())
cmds = append(cmds, s.sessionsList.Focus())
return tea.Sequence(cmds...)
}
func (s *sessionDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
var cmds []tea.Cmd
s.wWidth = msg.Width
s.wHeight = msg.Height
s.width = s.wWidth / 2
var cmds []tea.Cmd
s.width = min(120, s.wWidth-8)
s.sessionsList.SetInputWidth(s.listWidth() - 2)
cmds = append(cmds, s.sessionsList.SetSize(s.listWidth(), s.listHeight()))
if !s.renderedSelected {
cmds = append(cmds, s.sessionsList.SetSelected(s.selectedInx))
s.renderedSelected = true
if s.selectedSessionID != "" {
cmds = append(cmds, s.sessionsList.SetSelected(s.selectedSessionID))
}
return s, tea.Sequence(cmds...)
return s, tea.Batch(cmds...)
case tea.KeyPressMsg:
switch {
case key.Matches(msg, s.keyMap.Select):
if len(s.sessionsList.Items()) > 0 {
items := s.sessionsList.Items()
selectedItemInx := s.sessionsList.SelectedIndex()
selectedItem := s.sessionsList.SelectedItem()
if selectedItem != nil {
selected := *selectedItem
return s, tea.Sequence(
util.CmdHandler(dialogs.CloseDialogMsg{}),
util.CmdHandler(
chat.SessionSelectedMsg(items[selectedItemInx].(completions.CompletionItem).Value().(session.Session)),
chat.SessionSelectedMsg(selected.Value()),
),
)
}
@@ -115,7 +110,7 @@ func (s *sessionDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return s, util.CmdHandler(dialogs.CloseDialogMsg{})
default:
u, cmd := s.sessionsList.Update(msg)
s.sessionsList = u.(list.ListModel)
s.sessionsList = u.(SessionsList)
return s, cmd
}
}

View File

@@ -0,0 +1,308 @@
package list
import (
"regexp"
"slices"
"sort"
"strings"
"github.com/charmbracelet/bubbles/v2/key"
"github.com/charmbracelet/bubbles/v2/textinput"
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/charmbracelet/crush/internal/tui/components/core/layout"
"github.com/charmbracelet/crush/internal/tui/styles"
"github.com/charmbracelet/lipgloss/v2"
"github.com/sahilm/fuzzy"
)
type FilterableItem interface {
Item
FilterValue() string
}
type FilterableList[T FilterableItem] interface {
List[T]
Cursor() *tea.Cursor
SetInputWidth(int)
SetInputPlaceholder(string)
Filter(q string) tea.Cmd
}
type HasMatchIndexes interface {
MatchIndexes([]int)
}
type filterableOptions struct {
listOptions []ListOption
placeholder string
inputHidden bool
inputWidth int
inputStyle lipgloss.Style
}
type filterableList[T FilterableItem] struct {
*list[T]
*filterableOptions
width, height int
// stores all available items
items []T
input textinput.Model
inputWidth int
query string
}
type filterableListOption func(*filterableOptions)
func WithFilterPlaceholder(ph string) filterableListOption {
return func(f *filterableOptions) {
f.placeholder = ph
}
}
func WithFilterInputHidden() filterableListOption {
return func(f *filterableOptions) {
f.inputHidden = true
}
}
func WithFilterInputStyle(inputStyle lipgloss.Style) filterableListOption {
return func(f *filterableOptions) {
f.inputStyle = inputStyle
}
}
func WithFilterListOptions(opts ...ListOption) filterableListOption {
return func(f *filterableOptions) {
f.listOptions = opts
}
}
func WithFilterInputWidth(inputWidth int) filterableListOption {
return func(f *filterableOptions) {
f.inputWidth = inputWidth
}
}
func NewFilterableList[T FilterableItem](items []T, opts ...filterableListOption) FilterableList[T] {
t := styles.CurrentTheme()
f := &filterableList[T]{
filterableOptions: &filterableOptions{
inputStyle: t.S().Base,
placeholder: "Type to filter",
},
}
for _, opt := range opts {
opt(f.filterableOptions)
}
f.list = New[T](items, f.listOptions...).(*list[T])
f.updateKeyMaps()
f.items = f.list.items.Slice()
if f.inputHidden {
return f
}
ti := textinput.New()
ti.Placeholder = f.placeholder
ti.SetVirtualCursor(false)
ti.Focus()
ti.SetStyles(t.S().TextInput)
f.input = ti
return f
}
func (f *filterableList[T]) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyPressMsg:
switch {
// handle movements
case key.Matches(msg, f.keyMap.Down),
key.Matches(msg, f.keyMap.Up),
key.Matches(msg, f.keyMap.DownOneItem),
key.Matches(msg, f.keyMap.UpOneItem),
key.Matches(msg, f.keyMap.HalfPageDown),
key.Matches(msg, f.keyMap.HalfPageUp),
key.Matches(msg, f.keyMap.PageDown),
key.Matches(msg, f.keyMap.PageUp),
key.Matches(msg, f.keyMap.End),
key.Matches(msg, f.keyMap.Home):
u, cmd := f.list.Update(msg)
f.list = u.(*list[T])
return f, cmd
default:
if !f.inputHidden {
var cmds []tea.Cmd
var cmd tea.Cmd
f.input, cmd = f.input.Update(msg)
cmds = append(cmds, cmd)
if f.query != f.input.Value() {
cmd = f.Filter(f.input.Value())
cmds = append(cmds, cmd)
}
f.query = f.input.Value()
return f, tea.Batch(cmds...)
}
}
}
u, cmd := f.list.Update(msg)
f.list = u.(*list[T])
return f, cmd
}
func (f *filterableList[T]) View() string {
if f.inputHidden {
return f.list.View()
}
return lipgloss.JoinVertical(
lipgloss.Left,
f.inputStyle.Render(f.input.View()),
f.list.View(),
)
}
// removes bindings that are used for search
func (f *filterableList[T]) updateKeyMaps() {
alphanumeric := regexp.MustCompile("^[a-zA-Z0-9]*$")
removeLettersAndNumbers := func(bindings []string) []string {
var keep []string
for _, b := range bindings {
if len(b) != 1 {
keep = append(keep, b)
continue
}
if b == " " {
continue
}
m := alphanumeric.MatchString(b)
if !m {
keep = append(keep, b)
}
}
return keep
}
updateBinding := func(binding key.Binding) key.Binding {
newKeys := removeLettersAndNumbers(binding.Keys())
if len(newKeys) == 0 {
binding.SetEnabled(false)
return binding
}
binding.SetKeys(newKeys...)
return binding
}
f.keyMap.Down = updateBinding(f.keyMap.Down)
f.keyMap.Up = updateBinding(f.keyMap.Up)
f.keyMap.DownOneItem = updateBinding(f.keyMap.DownOneItem)
f.keyMap.UpOneItem = updateBinding(f.keyMap.UpOneItem)
f.keyMap.HalfPageDown = updateBinding(f.keyMap.HalfPageDown)
f.keyMap.HalfPageUp = updateBinding(f.keyMap.HalfPageUp)
f.keyMap.PageDown = updateBinding(f.keyMap.PageDown)
f.keyMap.PageUp = updateBinding(f.keyMap.PageUp)
f.keyMap.End = updateBinding(f.keyMap.End)
f.keyMap.Home = updateBinding(f.keyMap.Home)
}
func (m *filterableList[T]) GetSize() (int, int) {
return m.width, m.height
}
func (f *filterableList[T]) SetSize(w, h int) tea.Cmd {
f.width = w
f.height = h
if f.inputHidden {
return f.list.SetSize(w, h)
}
if f.inputWidth == 0 {
f.input.SetWidth(w)
} else {
f.input.SetWidth(f.inputWidth)
}
return f.list.SetSize(w, h-(f.inputHeight()))
}
func (f *filterableList[T]) inputHeight() int {
return lipgloss.Height(f.inputStyle.Render(f.input.View()))
}
func (f *filterableList[T]) Filter(query string) tea.Cmd {
var cmds []tea.Cmd
for _, item := range f.items {
if i, ok := any(item).(layout.Focusable); ok {
cmds = append(cmds, i.Blur())
}
if i, ok := any(item).(HasMatchIndexes); ok {
i.MatchIndexes(make([]int, 0))
}
}
f.selectedItem = ""
if query == "" {
return f.list.SetItems(f.items)
}
words := make([]string, len(f.items))
for i, item := range f.items {
words[i] = strings.ToLower(item.FilterValue())
}
matches := fuzzy.Find(query, words)
sort.SliceStable(matches, func(i, j int) bool {
return matches[i].Score > matches[j].Score
})
var matchedItems []T
for _, match := range matches {
item := f.items[match.Index]
if i, ok := any(item).(HasMatchIndexes); ok {
i.MatchIndexes(match.MatchedIndexes)
}
matchedItems = append(matchedItems, item)
}
if f.direction == DirectionBackward {
slices.Reverse(matchedItems)
}
cmds = append(cmds, f.list.SetItems(matchedItems))
return tea.Batch(cmds...)
}
func (f *filterableList[T]) SetItems(items []T) tea.Cmd {
f.items = items
return f.list.SetItems(items)
}
func (f *filterableList[T]) Cursor() *tea.Cursor {
if f.inputHidden {
return nil
}
return f.input.Cursor()
}
func (f *filterableList[T]) Blur() tea.Cmd {
f.input.Blur()
return f.list.Blur()
}
func (f *filterableList[T]) Focus() tea.Cmd {
f.input.Focus()
return f.list.Focus()
}
func (f *filterableList[T]) IsFocused() bool {
return f.list.IsFocused()
}
func (f *filterableList[T]) SetInputWidth(w int) {
f.inputWidth = w
}
func (f *filterableList[T]) SetInputPlaceholder(ph string) {
f.placeholder = ph
}

View File

@@ -0,0 +1,260 @@
package list
import (
"regexp"
"sort"
"strings"
"github.com/charmbracelet/bubbles/v2/key"
"github.com/charmbracelet/bubbles/v2/textinput"
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/charmbracelet/crush/internal/tui/components/core/layout"
"github.com/charmbracelet/crush/internal/tui/styles"
"github.com/charmbracelet/lipgloss/v2"
"github.com/sahilm/fuzzy"
)
type FilterableGroupList[T FilterableItem] interface {
GroupedList[T]
Cursor() *tea.Cursor
SetInputWidth(int)
SetInputPlaceholder(string)
}
type filterableGroupList[T FilterableItem] struct {
*groupedList[T]
*filterableOptions
width, height int
groups []Group[T]
// stores all available items
input textinput.Model
inputWidth int
query string
}
func NewFilterableGroupedList[T FilterableItem](items []Group[T], opts ...filterableListOption) FilterableGroupList[T] {
t := styles.CurrentTheme()
f := &filterableGroupList[T]{
filterableOptions: &filterableOptions{
inputStyle: t.S().Base,
placeholder: "Type to filter",
},
}
for _, opt := range opts {
opt(f.filterableOptions)
}
f.groupedList = NewGroupedList(items, f.listOptions...).(*groupedList[T])
f.updateKeyMaps()
if f.inputHidden {
return f
}
ti := textinput.New()
ti.Placeholder = f.placeholder
ti.SetVirtualCursor(false)
ti.Focus()
ti.SetStyles(t.S().TextInput)
f.input = ti
return f
}
func (f *filterableGroupList[T]) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyPressMsg:
switch {
// handle movements
case key.Matches(msg, f.keyMap.Down),
key.Matches(msg, f.keyMap.Up),
key.Matches(msg, f.keyMap.DownOneItem),
key.Matches(msg, f.keyMap.UpOneItem),
key.Matches(msg, f.keyMap.HalfPageDown),
key.Matches(msg, f.keyMap.HalfPageUp),
key.Matches(msg, f.keyMap.PageDown),
key.Matches(msg, f.keyMap.PageUp),
key.Matches(msg, f.keyMap.End),
key.Matches(msg, f.keyMap.Home):
u, cmd := f.groupedList.Update(msg)
f.groupedList = u.(*groupedList[T])
return f, cmd
default:
if !f.inputHidden {
var cmds []tea.Cmd
var cmd tea.Cmd
f.input, cmd = f.input.Update(msg)
cmds = append(cmds, cmd)
if f.query != f.input.Value() {
cmd = f.Filter(f.input.Value())
cmds = append(cmds, cmd)
}
f.query = f.input.Value()
return f, tea.Batch(cmds...)
}
}
}
u, cmd := f.groupedList.Update(msg)
f.groupedList = u.(*groupedList[T])
return f, cmd
}
func (f *filterableGroupList[T]) View() string {
if f.inputHidden {
return f.groupedList.View()
}
return lipgloss.JoinVertical(
lipgloss.Left,
f.inputStyle.Render(f.input.View()),
f.groupedList.View(),
)
}
// removes bindings that are used for search
func (f *filterableGroupList[T]) updateKeyMaps() {
alphanumeric := regexp.MustCompile("^[a-zA-Z0-9]*$")
removeLettersAndNumbers := func(bindings []string) []string {
var keep []string
for _, b := range bindings {
if len(b) != 1 {
keep = append(keep, b)
continue
}
if b == " " {
continue
}
m := alphanumeric.MatchString(b)
if !m {
keep = append(keep, b)
}
}
return keep
}
updateBinding := func(binding key.Binding) key.Binding {
newKeys := removeLettersAndNumbers(binding.Keys())
if len(newKeys) == 0 {
binding.SetEnabled(false)
return binding
}
binding.SetKeys(newKeys...)
return binding
}
f.keyMap.Down = updateBinding(f.keyMap.Down)
f.keyMap.Up = updateBinding(f.keyMap.Up)
f.keyMap.DownOneItem = updateBinding(f.keyMap.DownOneItem)
f.keyMap.UpOneItem = updateBinding(f.keyMap.UpOneItem)
f.keyMap.HalfPageDown = updateBinding(f.keyMap.HalfPageDown)
f.keyMap.HalfPageUp = updateBinding(f.keyMap.HalfPageUp)
f.keyMap.PageDown = updateBinding(f.keyMap.PageDown)
f.keyMap.PageUp = updateBinding(f.keyMap.PageUp)
f.keyMap.End = updateBinding(f.keyMap.End)
f.keyMap.Home = updateBinding(f.keyMap.Home)
}
func (m *filterableGroupList[T]) GetSize() (int, int) {
return m.width, m.height
}
func (f *filterableGroupList[T]) SetSize(w, h int) tea.Cmd {
f.width = w
f.height = h
if f.inputHidden {
return f.groupedList.SetSize(w, h)
}
if f.inputWidth == 0 {
f.input.SetWidth(w)
} else {
f.input.SetWidth(f.inputWidth)
}
return f.groupedList.SetSize(w, h-(f.inputHeight()))
}
func (f *filterableGroupList[T]) inputHeight() int {
return lipgloss.Height(f.inputStyle.Render(f.input.View()))
}
func (f *filterableGroupList[T]) Filter(query string) tea.Cmd {
var cmds []tea.Cmd
for _, item := range f.items.Slice() {
if i, ok := any(item).(layout.Focusable); ok {
cmds = append(cmds, i.Blur())
}
if i, ok := any(item).(HasMatchIndexes); ok {
i.MatchIndexes(make([]int, 0))
}
}
f.selectedItem = ""
if query == "" {
return f.groupedList.SetGroups(f.groups)
}
var newGroups []Group[T]
for _, g := range f.groups {
words := make([]string, len(g.Items))
for i, item := range g.Items {
words[i] = strings.ToLower(item.FilterValue())
}
matches := fuzzy.Find(query, words)
sort.SliceStable(matches, func(i, j int) bool {
return matches[i].Score > matches[j].Score
})
var matchedItems []T
for _, match := range matches {
item := g.Items[match.Index]
if i, ok := any(item).(HasMatchIndexes); ok {
i.MatchIndexes(match.MatchedIndexes)
}
matchedItems = append(matchedItems, item)
}
if len(matchedItems) > 0 {
newGroups = append(newGroups, Group[T]{
Section: g.Section,
Items: matchedItems,
})
}
}
cmds = append(cmds, f.groupedList.SetGroups(newGroups))
return tea.Batch(cmds...)
}
func (f *filterableGroupList[T]) SetGroups(groups []Group[T]) tea.Cmd {
f.groups = groups
return f.groupedList.SetGroups(groups)
}
func (f *filterableGroupList[T]) Cursor() *tea.Cursor {
if f.inputHidden {
return nil
}
return f.input.Cursor()
}
func (f *filterableGroupList[T]) Blur() tea.Cmd {
f.input.Blur()
return f.groupedList.Blur()
}
func (f *filterableGroupList[T]) Focus() tea.Cmd {
f.input.Focus()
return f.groupedList.Focus()
}
func (f *filterableGroupList[T]) IsFocused() bool {
return f.groupedList.IsFocused()
}
func (f *filterableGroupList[T]) SetInputWidth(w int) {
f.inputWidth = w
}
func (f *filterableGroupList[T]) SetInputPlaceholder(ph string) {
f.placeholder = ph
}

View File

@@ -0,0 +1,68 @@
package list
import (
"fmt"
"slices"
"testing"
"github.com/charmbracelet/x/exp/golden"
"github.com/stretchr/testify/assert"
)
func TestFilterableList(t *testing.T) {
t.Parallel()
t.Run("should create simple filterable list", func(t *testing.T) {
t.Parallel()
items := []FilterableItem{}
for i := range 5 {
item := NewFilterableItem(fmt.Sprintf("Item %d", i))
items = append(items, item)
}
l := NewFilterableList(
items,
WithFilterListOptions(WithDirectionForward()),
).(*filterableList[FilterableItem])
l.SetSize(100, 10)
cmd := l.Init()
if cmd != nil {
cmd()
}
assert.Equal(t, items[0].ID(), l.selectedItem)
golden.RequireEqual(t, []byte(l.View()))
})
}
func TestUpdateKeyMap(t *testing.T) {
t.Parallel()
l := NewFilterableList(
[]FilterableItem{},
WithFilterListOptions(WithDirectionForward()),
).(*filterableList[FilterableItem])
hasJ := slices.Contains(l.keyMap.Down.Keys(), "j")
fmt.Println(l.keyMap.Down.Keys())
hasCtrlJ := slices.Contains(l.keyMap.Down.Keys(), "ctrl+j")
hasUpperCaseK := slices.Contains(l.keyMap.UpOneItem.Keys(), "K")
assert.False(t, l.keyMap.HalfPageDown.Enabled(), "should disable keys that are only letters")
assert.False(t, hasJ, "should not contain j")
assert.False(t, hasUpperCaseK, "should also remove upper case K")
assert.True(t, hasCtrlJ, "should still have ctrl+j")
}
type filterableItem struct {
*selectableItem
}
func NewFilterableItem(content string) FilterableItem {
return &filterableItem{
selectableItem: NewSelectableItem(content).(*selectableItem),
}
}
func (f *filterableItem) FilterValue() string {
return f.content
}

View File

@@ -0,0 +1,101 @@
package list
import (
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/charmbracelet/crush/internal/csync"
"github.com/charmbracelet/crush/internal/tui/components/core/layout"
"github.com/charmbracelet/crush/internal/tui/util"
)
type Group[T Item] struct {
Section ItemSection
Items []T
}
type GroupedList[T Item] interface {
util.Model
layout.Sizeable
Items() []Item
Groups() []Group[T]
SetGroups([]Group[T]) tea.Cmd
MoveUp(int) tea.Cmd
MoveDown(int) tea.Cmd
GoToTop() tea.Cmd
GoToBottom() tea.Cmd
SelectItemAbove() tea.Cmd
SelectItemBelow() tea.Cmd
SetSelected(string) tea.Cmd
SelectedItem() *T
}
type groupedList[T Item] struct {
*list[Item]
groups []Group[T]
}
func NewGroupedList[T Item](groups []Group[T], opts ...ListOption) GroupedList[T] {
list := &list[Item]{
confOptions: &confOptions{
direction: DirectionForward,
keyMap: DefaultKeyMap(),
focused: true,
},
items: csync.NewSlice[Item](),
indexMap: csync.NewMap[string, int](),
renderedItems: csync.NewMap[string, renderedItem](),
}
for _, opt := range opts {
opt(list.confOptions)
}
return &groupedList[T]{
list: list,
}
}
func (g *groupedList[T]) Init() tea.Cmd {
g.convertItems()
return g.render()
}
func (l *groupedList[T]) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
u, cmd := l.list.Update(msg)
l.list = u.(*list[Item])
return l, cmd
}
func (g *groupedList[T]) SelectedItem() *T {
item := g.list.SelectedItem()
if item == nil {
return nil
}
dRef := *item
c, ok := any(dRef).(T)
if !ok {
return nil
}
return &c
}
func (g *groupedList[T]) convertItems() {
var items []Item
for _, g := range g.groups {
items = append(items, g.Section)
for _, g := range g.Items {
items = append(items, g)
}
}
g.items.SetSlice(items)
}
func (g *groupedList[T]) SetGroups(groups []Group[T]) tea.Cmd {
g.groups = groups
g.convertItems()
return g.SetItems(g.items.Slice())
}
func (g *groupedList[T]) Groups() []Group[T] {
return g.groups
}
func (g *groupedList[T]) Items() []Item {
return g.list.Items()
}

View File

@@ -1,81 +1,107 @@
package completions
package list
import (
"image/color"
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/charmbracelet/crush/internal/tui/components/core"
"github.com/charmbracelet/crush/internal/tui/components/core/layout"
"github.com/charmbracelet/crush/internal/tui/components/core/list"
"github.com/charmbracelet/crush/internal/tui/styles"
"github.com/charmbracelet/crush/internal/tui/util"
"github.com/charmbracelet/lipgloss/v2"
"github.com/charmbracelet/x/ansi"
"github.com/google/uuid"
"github.com/rivo/uniseg"
)
type CompletionItem interface {
util.Model
layout.Focusable
layout.Sizeable
list.HasMatchIndexes
list.HasFilterValue
Value() any
type Indexable interface {
SetIndex(int)
}
type completionItemCmp struct {
type CompletionItem[T any] interface {
FilterableItem
layout.Focusable
layout.Sizeable
HasMatchIndexes
Value() T
}
type completionItemCmp[T any] struct {
width int
id string
text string
value any
value T
focus bool
matchIndexes []int
bgColor color.Color
shortcut string
}
type CompletionOption func(*completionItemCmp)
type options struct {
id string
text string
bgColor color.Color
matchIndexes []int
shortcut string
}
func WithBackgroundColor(c color.Color) CompletionOption {
return func(cmp *completionItemCmp) {
type CompletionItemOption func(*options)
func WithCompletionBackgroundColor(c color.Color) CompletionItemOption {
return func(cmp *options) {
cmp.bgColor = c
}
}
func WithMatchIndexes(indexes ...int) CompletionOption {
return func(cmp *completionItemCmp) {
func WithCompletionMatchIndexes(indexes ...int) CompletionItemOption {
return func(cmp *options) {
cmp.matchIndexes = indexes
}
}
func WithShortcut(shortcut string) CompletionOption {
return func(cmp *completionItemCmp) {
func WithCompletionShortcut(shortcut string) CompletionItemOption {
return func(cmp *options) {
cmp.shortcut = shortcut
}
}
func NewCompletionItem(text string, value any, opts ...CompletionOption) CompletionItem {
c := &completionItemCmp{
func WithCompletionID(id string) CompletionItemOption {
return func(cmp *options) {
cmp.id = id
}
}
func NewCompletionItem[T any](text string, value T, opts ...CompletionItemOption) CompletionItem[T] {
c := &completionItemCmp[T]{
text: text,
value: value,
}
o := &options{}
for _, opt := range opts {
opt(c)
opt(o)
}
if o.id == "" {
o.id = uuid.NewString()
}
c.id = o.id
c.bgColor = o.bgColor
c.matchIndexes = o.matchIndexes
c.shortcut = o.shortcut
return c
}
// Init implements CommandItem.
func (c *completionItemCmp) Init() tea.Cmd {
func (c *completionItemCmp[T]) Init() tea.Cmd {
return nil
}
// Update implements CommandItem.
func (c *completionItemCmp) Update(tea.Msg) (tea.Model, tea.Cmd) {
func (c *completionItemCmp[T]) Update(tea.Msg) (tea.Model, tea.Cmd) {
return c, nil
}
// View implements CommandItem.
func (c *completionItemCmp) View() string {
func (c *completionItemCmp[T]) View() string {
t := styles.CurrentTheme()
itemStyle := t.S().Base.Padding(0, 1).Width(c.width)
@@ -140,47 +166,47 @@ func (c *completionItemCmp) View() string {
}
// Blur implements CommandItem.
func (c *completionItemCmp) Blur() tea.Cmd {
func (c *completionItemCmp[T]) Blur() tea.Cmd {
c.focus = false
return nil
}
// Focus implements CommandItem.
func (c *completionItemCmp) Focus() tea.Cmd {
func (c *completionItemCmp[T]) Focus() tea.Cmd {
c.focus = true
return nil
}
// GetSize implements CommandItem.
func (c *completionItemCmp) GetSize() (int, int) {
func (c *completionItemCmp[T]) GetSize() (int, int) {
return c.width, 1
}
// IsFocused implements CommandItem.
func (c *completionItemCmp) IsFocused() bool {
func (c *completionItemCmp[T]) IsFocused() bool {
return c.focus
}
// SetSize implements CommandItem.
func (c *completionItemCmp) SetSize(width int, height int) tea.Cmd {
func (c *completionItemCmp[T]) SetSize(width int, height int) tea.Cmd {
c.width = width
return nil
}
func (c *completionItemCmp) MatchIndexes(indexes []int) {
func (c *completionItemCmp[T]) MatchIndexes(indexes []int) {
c.matchIndexes = indexes
}
func (c *completionItemCmp) FilterValue() string {
func (c *completionItemCmp[T]) FilterValue() string {
return c.text
}
func (c *completionItemCmp) Value() any {
func (c *completionItemCmp[T]) Value() T {
return c.value
}
// smartTruncate implements fzf-style truncation that ensures the last matching part is visible
func (c *completionItemCmp) smartTruncate(text string, width int, matchIndexes []int) string {
func (c *completionItemCmp[T]) smartTruncate(text string, width int, matchIndexes []int) string {
if width <= 0 {
return ""
}
@@ -280,3 +306,80 @@ func bytePosToVisibleCharPos(str string, rng [2]int) (int, int) {
stop = pos
return start, stop
}
// ID implements CompletionItem.
func (c *completionItemCmp[T]) ID() string {
return c.id
}
type ItemSection interface {
Item
layout.Sizeable
Indexable
SetInfo(info string)
}
type itemSectionModel struct {
width int
title string
inx int
info string
}
// ID implements ItemSection.
func (m *itemSectionModel) ID() string {
return uuid.NewString()
}
func NewItemSection(title string) ItemSection {
return &itemSectionModel{
title: title,
inx: -1,
}
}
func (m *itemSectionModel) Init() tea.Cmd {
return nil
}
func (m *itemSectionModel) Update(tea.Msg) (tea.Model, tea.Cmd) {
return m, nil
}
func (m *itemSectionModel) View() string {
t := styles.CurrentTheme()
title := ansi.Truncate(m.title, m.width-2, "…")
style := t.S().Base.Padding(1, 1, 0, 1)
if m.inx == 0 {
style = style.Padding(0, 1, 0, 1)
}
title = t.S().Muted.Render(title)
section := ""
if m.info != "" {
section = core.SectionWithInfo(title, m.width-2, m.info)
} else {
section = core.Section(title, m.width-2)
}
return style.Render(section)
}
func (m *itemSectionModel) GetSize() (int, int) {
return m.width, 1
}
func (m *itemSectionModel) SetSize(width int, height int) tea.Cmd {
m.width = width
return nil
}
func (m *itemSectionModel) IsSectionHeader() bool {
return true
}
func (m *itemSectionModel) SetInfo(info string) {
m.info = info
}
func (m *itemSectionModel) SetIndex(inx int) {
m.inx = inx
}

View File

@@ -46,7 +46,8 @@ func DefaultKeyMap() KeyMap {
PageUp: key.NewBinding(
key.WithKeys("pgup", "b"),
key.WithHelp("b/pgup", "page up"),
), HalfPageUp: key.NewBinding(
),
HalfPageUp: key.NewBinding(
key.WithKeys("u"),
key.WithHelp("u", "half page up"),
),
@@ -61,7 +62,6 @@ func DefaultKeyMap() KeyMap {
}
}
// KeyBindings implements layout.KeyMapProvider
func (k KeyMap) KeyBindings() []key.Binding {
return []key.Binding{
k.Down,

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,652 @@
package list
import (
"fmt"
"strings"
"testing"
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/charmbracelet/crush/internal/tui/components/core/layout"
"github.com/charmbracelet/lipgloss/v2"
"github.com/charmbracelet/x/exp/golden"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestList(t *testing.T) {
t.Parallel()
t.Run("should have correct positions in list that fits the items", func(t *testing.T) {
t.Parallel()
items := []Item{}
for i := range 5 {
item := NewSelectableItem(fmt.Sprintf("Item %d", i))
items = append(items, item)
}
l := New(items, WithDirectionForward(), WithSize(10, 20)).(*list[Item])
execCmd(l, l.Init())
// should select the last item
assert.Equal(t, items[0].ID(), l.selectedItem)
assert.Equal(t, 0, l.offset)
require.Equal(t, 5, l.indexMap.Len())
require.Equal(t, 5, l.items.Len())
require.Equal(t, 5, l.renderedItems.Len())
assert.Equal(t, 5, lipgloss.Height(l.rendered))
assert.NotEqual(t, "\n", string(l.rendered[len(l.rendered)-1]), "should not end in newline")
start, end := l.viewPosition()
assert.Equal(t, 0, start)
assert.Equal(t, 4, end)
for i := range 5 {
item, ok := l.renderedItems.Get(items[i].ID())
require.True(t, ok)
assert.Equal(t, i, item.start)
assert.Equal(t, i, item.end)
}
golden.RequireEqual(t, []byte(l.View()))
})
t.Run("should have correct positions in list that fits the items backwards", func(t *testing.T) {
t.Parallel()
items := []Item{}
for i := range 5 {
item := NewSelectableItem(fmt.Sprintf("Item %d", i))
items = append(items, item)
}
l := New(items, WithDirectionBackward(), WithSize(10, 20)).(*list[Item])
execCmd(l, l.Init())
// should select the last item
assert.Equal(t, items[4].ID(), l.selectedItem)
assert.Equal(t, 0, l.offset)
require.Equal(t, 5, l.indexMap.Len())
require.Equal(t, 5, l.items.Len())
require.Equal(t, 5, l.renderedItems.Len())
assert.Equal(t, 5, lipgloss.Height(l.rendered))
assert.NotEqual(t, "\n", string(l.rendered[len(l.rendered)-1]), "should not end in newline")
start, end := l.viewPosition()
assert.Equal(t, 0, start)
assert.Equal(t, 4, end)
for i := range 5 {
item, ok := l.renderedItems.Get(items[i].ID())
require.True(t, ok)
assert.Equal(t, i, item.start)
assert.Equal(t, i, item.end)
}
golden.RequireEqual(t, []byte(l.View()))
})
t.Run("should have correct positions in list that does not fits the items", func(t *testing.T) {
t.Parallel()
items := []Item{}
for i := range 30 {
item := NewSelectableItem(fmt.Sprintf("Item %d", i))
items = append(items, item)
}
l := New(items, WithDirectionForward(), WithSize(10, 10)).(*list[Item])
execCmd(l, l.Init())
// should select the last item
assert.Equal(t, items[0].ID(), l.selectedItem)
assert.Equal(t, 0, l.offset)
require.Equal(t, 30, l.indexMap.Len())
require.Equal(t, 30, l.items.Len())
require.Equal(t, 30, l.renderedItems.Len())
assert.Equal(t, 30, lipgloss.Height(l.rendered))
assert.NotEqual(t, "\n", string(l.rendered[len(l.rendered)-1]), "should not end in newline")
start, end := l.viewPosition()
assert.Equal(t, 0, start)
assert.Equal(t, 9, end)
for i := range 30 {
item, ok := l.renderedItems.Get(items[i].ID())
require.True(t, ok)
assert.Equal(t, i, item.start)
assert.Equal(t, i, item.end)
}
golden.RequireEqual(t, []byte(l.View()))
})
t.Run("should have correct positions in list that does not fits the items backwards", func(t *testing.T) {
t.Parallel()
items := []Item{}
for i := range 30 {
item := NewSelectableItem(fmt.Sprintf("Item %d", i))
items = append(items, item)
}
l := New(items, WithDirectionBackward(), WithSize(10, 10)).(*list[Item])
execCmd(l, l.Init())
// should select the last item
assert.Equal(t, items[29].ID(), l.selectedItem)
assert.Equal(t, 0, l.offset)
require.Equal(t, 30, l.indexMap.Len())
require.Equal(t, 30, l.items.Len())
require.Equal(t, 30, l.renderedItems.Len())
assert.Equal(t, 30, lipgloss.Height(l.rendered))
assert.NotEqual(t, "\n", string(l.rendered[len(l.rendered)-1]), "should not end in newline")
start, end := l.viewPosition()
assert.Equal(t, 20, start)
assert.Equal(t, 29, end)
for i := range 30 {
item, ok := l.renderedItems.Get(items[i].ID())
require.True(t, ok)
assert.Equal(t, i, item.start)
assert.Equal(t, i, item.end)
}
golden.RequireEqual(t, []byte(l.View()))
})
t.Run("should have correct positions in list that does not fits the items and has multi line items", func(t *testing.T) {
t.Parallel()
items := []Item{}
for i := range 30 {
content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1)
content = strings.TrimSuffix(content, "\n")
item := NewSelectableItem(content)
items = append(items, item)
}
l := New(items, WithDirectionForward(), WithSize(10, 10)).(*list[Item])
execCmd(l, l.Init())
// should select the last item
assert.Equal(t, items[0].ID(), l.selectedItem)
assert.Equal(t, 0, l.offset)
require.Equal(t, 30, l.indexMap.Len())
require.Equal(t, 30, l.items.Len())
require.Equal(t, 30, l.renderedItems.Len())
expectedLines := 0
for i := range 30 {
expectedLines += (i + 1) * 1
}
assert.Equal(t, expectedLines, lipgloss.Height(l.rendered))
assert.NotEqual(t, "\n", string(l.rendered[len(l.rendered)-1]), "should not end in newline")
start, end := l.viewPosition()
assert.Equal(t, 0, start)
assert.Equal(t, 9, end)
currentPosition := 0
for i := range 30 {
rItem, ok := l.renderedItems.Get(items[i].ID())
require.True(t, ok)
assert.Equal(t, currentPosition, rItem.start)
assert.Equal(t, currentPosition+i, rItem.end)
currentPosition += i + 1
}
golden.RequireEqual(t, []byte(l.View()))
})
t.Run("should have correct positions in list that does not fits the items and has multi line items backwards", func(t *testing.T) {
t.Parallel()
items := []Item{}
for i := range 30 {
content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1)
content = strings.TrimSuffix(content, "\n")
item := NewSelectableItem(content)
items = append(items, item)
}
l := New(items, WithDirectionBackward(), WithSize(10, 10)).(*list[Item])
execCmd(l, l.Init())
// should select the last item
assert.Equal(t, items[29].ID(), l.selectedItem)
assert.Equal(t, 0, l.offset)
require.Equal(t, 30, l.indexMap.Len())
require.Equal(t, 30, l.items.Len())
require.Equal(t, 30, l.renderedItems.Len())
expectedLines := 0
for i := range 30 {
expectedLines += (i + 1) * 1
}
assert.Equal(t, expectedLines, lipgloss.Height(l.rendered))
assert.NotEqual(t, "\n", string(l.rendered[len(l.rendered)-1]), "should not end in newline")
start, end := l.viewPosition()
assert.Equal(t, expectedLines-10, start)
assert.Equal(t, expectedLines-1, end)
currentPosition := 0
for i := range 30 {
rItem, ok := l.renderedItems.Get(items[i].ID())
require.True(t, ok)
assert.Equal(t, currentPosition, rItem.start)
assert.Equal(t, currentPosition+i, rItem.end)
currentPosition += i + 1
}
golden.RequireEqual(t, []byte(l.View()))
})
t.Run("should go to selected item at the beginning", func(t *testing.T) {
t.Parallel()
items := []Item{}
for i := range 30 {
content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1)
content = strings.TrimSuffix(content, "\n")
item := NewSelectableItem(content)
items = append(items, item)
}
l := New(items, WithDirectionForward(), WithSize(10, 10), WithSelectedItem(items[10].ID())).(*list[Item])
execCmd(l, l.Init())
// should select the last item
assert.Equal(t, items[10].ID(), l.selectedItem)
golden.RequireEqual(t, []byte(l.View()))
})
t.Run("should go to selected item at the beginning backwards", func(t *testing.T) {
t.Parallel()
items := []Item{}
for i := range 30 {
content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1)
content = strings.TrimSuffix(content, "\n")
item := NewSelectableItem(content)
items = append(items, item)
}
l := New(items, WithDirectionBackward(), WithSize(10, 10), WithSelectedItem(items[10].ID())).(*list[Item])
execCmd(l, l.Init())
// should select the last item
assert.Equal(t, items[10].ID(), l.selectedItem)
golden.RequireEqual(t, []byte(l.View()))
})
}
func TestListMovement(t *testing.T) {
t.Parallel()
t.Run("should move viewport up", func(t *testing.T) {
t.Parallel()
items := []Item{}
for i := range 30 {
content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1)
content = strings.TrimSuffix(content, "\n")
item := NewSelectableItem(content)
items = append(items, item)
}
l := New(items, WithDirectionBackward(), WithSize(10, 10)).(*list[Item])
execCmd(l, l.Init())
execCmd(l, l.MoveUp(25))
assert.Equal(t, 25, l.offset)
golden.RequireEqual(t, []byte(l.View()))
})
t.Run("should move viewport up and down", func(t *testing.T) {
t.Parallel()
items := []Item{}
for i := range 30 {
content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1)
content = strings.TrimSuffix(content, "\n")
item := NewSelectableItem(content)
items = append(items, item)
}
l := New(items, WithDirectionBackward(), WithSize(10, 10)).(*list[Item])
execCmd(l, l.Init())
execCmd(l, l.MoveUp(25))
execCmd(l, l.MoveDown(25))
assert.Equal(t, 0, l.offset)
golden.RequireEqual(t, []byte(l.View()))
})
t.Run("should move viewport down", func(t *testing.T) {
t.Parallel()
items := []Item{}
for i := range 30 {
content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1)
content = strings.TrimSuffix(content, "\n")
item := NewSelectableItem(content)
items = append(items, item)
}
l := New(items, WithDirectionForward(), WithSize(10, 10)).(*list[Item])
execCmd(l, l.Init())
execCmd(l, l.MoveDown(25))
assert.Equal(t, 25, l.offset)
golden.RequireEqual(t, []byte(l.View()))
})
t.Run("should move viewport down and up", func(t *testing.T) {
t.Parallel()
items := []Item{}
for i := range 30 {
content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1)
content = strings.TrimSuffix(content, "\n")
item := NewSelectableItem(content)
items = append(items, item)
}
l := New(items, WithDirectionForward(), WithSize(10, 10)).(*list[Item])
execCmd(l, l.Init())
execCmd(l, l.MoveDown(25))
execCmd(l, l.MoveUp(25))
assert.Equal(t, 0, l.offset)
golden.RequireEqual(t, []byte(l.View()))
})
t.Run("should not change offset when new items are appended and we are at the bottom in backwards list", func(t *testing.T) {
t.Parallel()
items := []Item{}
for i := range 30 {
content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1)
content = strings.TrimSuffix(content, "\n")
item := NewSelectableItem(content)
items = append(items, item)
}
l := New(items, WithDirectionBackward(), WithSize(10, 10)).(*list[Item])
execCmd(l, l.Init())
execCmd(l, l.AppendItem(NewSelectableItem("Testing")))
assert.Equal(t, 0, l.offset)
golden.RequireEqual(t, []byte(l.View()))
})
t.Run("should stay at the position it is when new items are added but we moved up in backwards list", func(t *testing.T) {
t.Parallel()
items := []Item{}
for i := range 30 {
item := NewSelectableItem(fmt.Sprintf("Item %d", i))
items = append(items, item)
}
l := New(items, WithDirectionBackward(), WithSize(10, 10)).(*list[Item])
execCmd(l, l.Init())
execCmd(l, l.MoveUp(2))
viewBefore := l.View()
execCmd(l, l.AppendItem(NewSelectableItem("Testing\nHello\n")))
viewAfter := l.View()
assert.Equal(t, viewBefore, viewAfter)
assert.Equal(t, 5, l.offset)
assert.Equal(t, 33, lipgloss.Height(l.rendered))
golden.RequireEqual(t, []byte(l.View()))
})
t.Run("should stay at the position it is when the hight of an item below is increased in backwards list", func(t *testing.T) {
t.Parallel()
items := []Item{}
for i := range 30 {
item := NewSelectableItem(fmt.Sprintf("Item %d", i))
items = append(items, item)
}
l := New(items, WithDirectionBackward(), WithSize(10, 10)).(*list[Item])
execCmd(l, l.Init())
execCmd(l, l.MoveUp(2))
viewBefore := l.View()
item := items[29]
execCmd(l, l.UpdateItem(item.ID(), NewSelectableItem("Item 29\nLine 2\nLine 3")))
viewAfter := l.View()
assert.Equal(t, viewBefore, viewAfter)
assert.Equal(t, 4, l.offset)
assert.Equal(t, 32, lipgloss.Height(l.rendered))
golden.RequireEqual(t, []byte(l.View()))
})
t.Run("should stay at the position it is when the hight of an item below is decreases in backwards list", func(t *testing.T) {
t.Parallel()
items := []Item{}
for i := range 30 {
item := NewSelectableItem(fmt.Sprintf("Item %d", i))
items = append(items, item)
}
items = append(items, NewSelectableItem("Item 30\nLine 2\nLine 3"))
l := New(items, WithDirectionBackward(), WithSize(10, 10)).(*list[Item])
execCmd(l, l.Init())
execCmd(l, l.MoveUp(2))
viewBefore := l.View()
item := items[30]
execCmd(l, l.UpdateItem(item.ID(), NewSelectableItem("Item 30")))
viewAfter := l.View()
assert.Equal(t, viewBefore, viewAfter)
assert.Equal(t, 0, l.offset)
assert.Equal(t, 31, lipgloss.Height(l.rendered))
golden.RequireEqual(t, []byte(l.View()))
})
t.Run("should stay at the position it is when the hight of an item above is increased in backwards list", func(t *testing.T) {
t.Parallel()
items := []Item{}
for i := range 30 {
item := NewSelectableItem(fmt.Sprintf("Item %d", i))
items = append(items, item)
}
l := New(items, WithDirectionBackward(), WithSize(10, 10)).(*list[Item])
execCmd(l, l.Init())
execCmd(l, l.MoveUp(2))
viewBefore := l.View()
item := items[1]
execCmd(l, l.UpdateItem(item.ID(), NewSelectableItem("Item 1\nLine 2\nLine 3")))
viewAfter := l.View()
assert.Equal(t, viewBefore, viewAfter)
assert.Equal(t, 2, l.offset)
assert.Equal(t, 32, lipgloss.Height(l.rendered))
golden.RequireEqual(t, []byte(l.View()))
})
t.Run("should stay at the position it is if an item is prepended and we are in backwards list", func(t *testing.T) {
t.Parallel()
items := []Item{}
for i := range 30 {
item := NewSelectableItem(fmt.Sprintf("Item %d", i))
items = append(items, item)
}
l := New(items, WithDirectionBackward(), WithSize(10, 10)).(*list[Item])
execCmd(l, l.Init())
execCmd(l, l.MoveUp(2))
viewBefore := l.View()
execCmd(l, l.PrependItem(NewSelectableItem("New")))
viewAfter := l.View()
assert.Equal(t, viewBefore, viewAfter)
assert.Equal(t, 2, l.offset)
assert.Equal(t, 31, lipgloss.Height(l.rendered))
golden.RequireEqual(t, []byte(l.View()))
})
t.Run("should not change offset when new items are prepended and we are at the top in forward list", func(t *testing.T) {
t.Parallel()
items := []Item{}
for i := range 30 {
content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1)
content = strings.TrimSuffix(content, "\n")
item := NewSelectableItem(content)
items = append(items, item)
}
l := New(items, WithDirectionForward(), WithSize(10, 10)).(*list[Item])
execCmd(l, l.Init())
execCmd(l, l.PrependItem(NewSelectableItem("Testing")))
assert.Equal(t, 0, l.offset)
golden.RequireEqual(t, []byte(l.View()))
})
t.Run("should stay at the position it is when new items are added but we moved down in forward list", func(t *testing.T) {
t.Parallel()
items := []Item{}
for i := range 30 {
item := NewSelectableItem(fmt.Sprintf("Item %d", i))
items = append(items, item)
}
l := New(items, WithDirectionForward(), WithSize(10, 10)).(*list[Item])
execCmd(l, l.Init())
execCmd(l, l.MoveDown(2))
viewBefore := l.View()
execCmd(l, l.PrependItem(NewSelectableItem("Testing\nHello\n")))
viewAfter := l.View()
assert.Equal(t, viewBefore, viewAfter)
assert.Equal(t, 5, l.offset)
assert.Equal(t, 33, lipgloss.Height(l.rendered))
golden.RequireEqual(t, []byte(l.View()))
})
t.Run("should stay at the position it is when the hight of an item above is increased in forward list", func(t *testing.T) {
t.Parallel()
items := []Item{}
for i := range 30 {
item := NewSelectableItem(fmt.Sprintf("Item %d", i))
items = append(items, item)
}
l := New(items, WithDirectionForward(), WithSize(10, 10)).(*list[Item])
execCmd(l, l.Init())
execCmd(l, l.MoveDown(2))
viewBefore := l.View()
item := items[0]
execCmd(l, l.UpdateItem(item.ID(), NewSelectableItem("Item 29\nLine 2\nLine 3")))
viewAfter := l.View()
assert.Equal(t, viewBefore, viewAfter)
assert.Equal(t, 4, l.offset)
assert.Equal(t, 32, lipgloss.Height(l.rendered))
golden.RequireEqual(t, []byte(l.View()))
})
t.Run("should stay at the position it is when the hight of an item above is decreases in forward list", func(t *testing.T) {
t.Parallel()
items := []Item{}
items = append(items, NewSelectableItem("At top\nLine 2\nLine 3"))
for i := range 30 {
item := NewSelectableItem(fmt.Sprintf("Item %d", i))
items = append(items, item)
}
l := New(items, WithDirectionForward(), WithSize(10, 10)).(*list[Item])
execCmd(l, l.Init())
execCmd(l, l.MoveDown(3))
viewBefore := l.View()
item := items[0]
execCmd(l, l.UpdateItem(item.ID(), NewSelectableItem("At top")))
viewAfter := l.View()
assert.Equal(t, viewBefore, viewAfter)
assert.Equal(t, 1, l.offset)
assert.Equal(t, 31, lipgloss.Height(l.rendered))
golden.RequireEqual(t, []byte(l.View()))
})
t.Run("should stay at the position it is when the hight of an item below is increased in forward list", func(t *testing.T) {
t.Parallel()
items := []Item{}
for i := range 30 {
item := NewSelectableItem(fmt.Sprintf("Item %d", i))
items = append(items, item)
}
l := New(items, WithDirectionForward(), WithSize(10, 10)).(*list[Item])
execCmd(l, l.Init())
execCmd(l, l.MoveDown(2))
viewBefore := l.View()
item := items[29]
execCmd(l, l.UpdateItem(item.ID(), NewSelectableItem("Item 29\nLine 2\nLine 3")))
viewAfter := l.View()
assert.Equal(t, viewBefore, viewAfter)
assert.Equal(t, 2, l.offset)
assert.Equal(t, 32, lipgloss.Height(l.rendered))
golden.RequireEqual(t, []byte(l.View()))
})
t.Run("should stay at the position it is if an item is appended and we are in forward list", func(t *testing.T) {
t.Parallel()
items := []Item{}
for i := range 30 {
item := NewSelectableItem(fmt.Sprintf("Item %d", i))
items = append(items, item)
}
l := New(items, WithDirectionForward(), WithSize(10, 10)).(*list[Item])
execCmd(l, l.Init())
execCmd(l, l.MoveDown(2))
viewBefore := l.View()
execCmd(l, l.AppendItem(NewSelectableItem("New")))
viewAfter := l.View()
assert.Equal(t, viewBefore, viewAfter)
assert.Equal(t, 2, l.offset)
assert.Equal(t, 31, lipgloss.Height(l.rendered))
golden.RequireEqual(t, []byte(l.View()))
})
}
type SelectableItem interface {
Item
layout.Focusable
}
type simpleItem struct {
width int
content string
id string
}
type selectableItem struct {
*simpleItem
focused bool
}
func NewSimpleItem(content string) *simpleItem {
return &simpleItem{
id: uuid.NewString(),
width: 0,
content: content,
}
}
func NewSelectableItem(content string) SelectableItem {
return &selectableItem{
simpleItem: NewSimpleItem(content),
focused: false,
}
}
func (s *simpleItem) ID() string {
return s.id
}
func (s *simpleItem) Init() tea.Cmd {
return nil
}
func (s *simpleItem) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return s, nil
}
func (s *simpleItem) View() string {
return lipgloss.NewStyle().Width(s.width).Render(s.content)
}
func (l *simpleItem) GetSize() (int, int) {
return l.width, 0
}
// SetSize implements Item.
func (s *simpleItem) SetSize(width int, height int) tea.Cmd {
s.width = width
return nil
}
func (s *selectableItem) View() string {
if s.focused {
return lipgloss.NewStyle().BorderLeft(true).BorderStyle(lipgloss.NormalBorder()).Width(s.width).Render(s.content)
}
return lipgloss.NewStyle().Width(s.width).Render(s.content)
}
// Blur implements SimpleItem.
func (s *selectableItem) Blur() tea.Cmd {
s.focused = false
return nil
}
// Focus implements SimpleItem.
func (s *selectableItem) Focus() tea.Cmd {
s.focused = true
return nil
}
// IsFocused implements SimpleItem.
func (s *selectableItem) IsFocused() bool {
return s.focused
}
func execCmd(m tea.Model, cmd tea.Cmd) {
for cmd != nil {
msg := cmd()
m, cmd = m.Update(msg)
}
}

View File

@@ -0,0 +1,10 @@
> Type to filter 
│Item 0 
Item 1 
Item 2 
Item 3 
Item 4 

View File

@@ -0,0 +1,10 @@
│Item 10
│Item 10
│Item 10
│Item 10
│Item 10
│Item 10
│Item 10
│Item 10
│Item 10
│Item 10

View File

@@ -0,0 +1,10 @@
│Item 10
│Item 10
│Item 10
│Item 10
│Item 10
│Item 10
│Item 10
│Item 10
│Item 10
│Item 10

View File

@@ -0,0 +1,10 @@
│Item 0
Item 1
Item 2
Item 3
Item 4
Item 5
Item 6
Item 7
Item 8
Item 9

View File

@@ -0,0 +1,10 @@
│Item 0
Item 1
Item 1
Item 2
Item 2
Item 2
Item 3
Item 3
Item 3
Item 3

View File

@@ -0,0 +1,10 @@
│Item 29
│Item 29
│Item 29
│Item 29
│Item 29
│Item 29
│Item 29
│Item 29
│Item 29
│Item 29

View File

@@ -0,0 +1,10 @@
Item 20
Item 21
Item 22
Item 23
Item 24
Item 25
Item 26
Item 27
Item 28
│Item 29

View File

@@ -0,0 +1,20 @@
│Item 0
Item 1
Item 2
Item 3
Item 4

View File

@@ -0,0 +1,20 @@
Item 0
Item 1
Item 2
Item 3
│Item 4

View File

@@ -0,0 +1,10 @@
Item 6
Item 6
Item 6
│Item 7
│Item 7
│Item 7
│Item 7
│Item 7
│Item 7
│Item 7

View File

@@ -0,0 +1,10 @@
Item 0
Item 1
Item 1
Item 2
Item 2
Item 2
│Item 3
│Item 3
│Item 3
│Item 3

View File

@@ -0,0 +1,10 @@
│Item 28
│Item 28
│Item 28
│Item 28
│Item 28
Item 29
Item 29
Item 29
Item 29
Item 29

View File

@@ -0,0 +1,10 @@
│Item 29
│Item 29
│Item 29
│Item 29
│Item 29
│Item 29
│Item 29
│Item 29
│Item 29
│Item 29

View File

@@ -0,0 +1,10 @@
│Item 29
│Item 29
│Item 29
│Item 29
│Item 29
│Item 29
│Item 29
│Item 29
│Item 29
Testing 

View File

@@ -0,0 +1,10 @@
Testing 
│Item 0
Item 1
Item 1
Item 2
Item 2
Item 2
Item 3
Item 3
Item 3

View File

@@ -0,0 +1,10 @@
│Item 2
Item 3
Item 4
Item 5
Item 6
Item 7
Item 8
Item 9
Item 10
Item 11

View File

@@ -0,0 +1,10 @@
Item 18
Item 19
Item 20
Item 21
Item 22
Item 23
Item 24
Item 25
Item 26
│Item 27

View File

@@ -0,0 +1,10 @@
│Item 2
Item 3
Item 4
Item 5
Item 6
Item 7
Item 8
Item 9
Item 10
Item 11

View File

@@ -0,0 +1,10 @@
Item 18
Item 19
Item 20
Item 21
Item 22
Item 23
Item 24
Item 25
Item 26
│Item 27

View File

@@ -0,0 +1,10 @@
│Item 0
Item 1
Item 2
Item 3
Item 4
Item 5
Item 6
Item 7
Item 8
Item 9

View File

@@ -0,0 +1,10 @@
Item 18
Item 19
Item 20
Item 21
Item 22
Item 23
Item 24
Item 25
Item 26
│Item 27

View File

@@ -0,0 +1,10 @@
│Item 2
Item 3
Item 4
Item 5
Item 6
Item 7
Item 8
Item 9
Item 10
Item 11

View File

@@ -0,0 +1,10 @@
Item 21
Item 22
Item 23
Item 24
Item 25
Item 26
Item 27
Item 28
│Item 29
Item 30

View File

@@ -0,0 +1,10 @@
Item 18
Item 19
Item 20
Item 21
Item 22
Item 23
Item 24
Item 25
Item 26
│Item 27

View File

@@ -0,0 +1,10 @@
│Item 2
Item 3
Item 4
Item 5
Item 6
Item 7
Item 8
Item 9
Item 10
Item 11

View File

@@ -165,6 +165,13 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case tea.KeyboardEnhancementsMsg:
p.keyboardEnhancements = msg
return p, nil
case tea.MouseWheelMsg:
if p.isMouseOverChat(msg.Mouse().X, msg.Mouse().Y) {
u, cmd := p.chat.Update(msg)
p.chat = u.(chat.MessageListCmp)
return p, cmd
}
return p, nil
case tea.WindowSizeMsg:
return p, p.SetSize(msg.Width, msg.Height)
case CancelTimerExpiredMsg:
@@ -610,6 +617,7 @@ func (p *chatPage) sendMessage(text string, attachments []message.Attachment) te
if err != nil {
return util.ReportError(err)
}
cmds = append(cmds, p.chat.GoToBottom())
return tea.Batch(cmds...)
}
@@ -911,3 +919,31 @@ func (p *chatPage) Help() help.KeyMap {
func (p *chatPage) IsChatFocused() bool {
return p.focusedPane == PanelTypeChat
}
// isMouseOverChat checks if the given mouse coordinates are within the chat area bounds.
// Returns true if the mouse is over the chat area, false otherwise.
func (p *chatPage) isMouseOverChat(x, y int) bool {
// No session means no chat area
if p.session.ID == "" {
return false
}
var chatX, chatY, chatWidth, chatHeight int
if p.compact {
// In compact mode: chat area starts after header and spans full width
chatX = 0
chatY = HeaderHeight
chatWidth = p.width
chatHeight = p.height - EditorHeight - HeaderHeight
} else {
// In non-compact mode: chat area spans from left edge to sidebar
chatX = 0
chatY = 0
chatWidth = p.width - SideBarWidth
chatHeight = p.height - EditorHeight
}
// Check if mouse coordinates are within chat bounds
return x >= chatX && x < chatX+chatWidth && y >= chatY && y < chatY+chatHeight
}

View File

@@ -4,6 +4,7 @@ import (
"context"
"fmt"
"strings"
"time"
"github.com/charmbracelet/bubbles/v2/key"
tea "github.com/charmbracelet/bubbletea/v2"
@@ -33,26 +34,18 @@ import (
"github.com/charmbracelet/lipgloss/v2"
)
// MouseEventFilter filters mouse events based on the current focus state
// This is used with tea.WithFilter to prevent mouse scroll events from
// interfering with typing performance in the editor
var lastMouseEvent time.Time
func MouseEventFilter(m tea.Model, msg tea.Msg) tea.Msg {
// Only filter mouse events
switch msg.(type) {
case tea.MouseWheelMsg, tea.MouseMotionMsg:
// Check if we have an appModel and if editor is focused
if appModel, ok := m.(*appModel); ok {
if appModel.currentPage == chat.ChatPageID {
if chatPage, ok := appModel.pages[appModel.currentPage].(chat.ChatPage); ok {
// If editor is focused (not chatFocused), filter out mouse wheel/motion events
if !chatPage.IsChatFocused() {
return nil // Filter out the event
}
}
}
now := time.Now()
// trackpad is sending too many requests
if now.Sub(lastMouseEvent) < 5*time.Millisecond {
return nil
}
lastMouseEvent = now
}
// Allow all other events to pass through
return msg
}