on main help sign in
Merge remote-tracking branch 'origin/main' into list
8e4a2ceKujtim Hoxha
README.md+1
@@ -4,6 +4,7 @@
4# Crush
4# Crush
5
5
6<p>
6<p>
7+ <img width="450" alt="Charm Crush Art" src="https://github.com/user-attachments/assets/9ab4c30c-9327-46b6-a722-3ad0e20f6976" /><br>
7 <a href="https://github.com/charmbracelet/crush/releases"><img src="https://img.shields.io/github/release/charmbracelet/crush" alt="Latest Release"></a>
8 <a href="https://github.com/charmbracelet/crush/releases"><img src="https://img.shields.io/github/release/charmbracelet/crush" alt="Latest Release"></a>
8 <a href="https://github.com/charmbracelet/crush/actions"><img src="https://github.com/charmbracelet/crush/workflows/build/badge.svg" alt="Build Status"></a>
9 <a href="https://github.com/charmbracelet/crush/actions"><img src="https://github.com/charmbracelet/crush/workflows/build/badge.svg" alt="Build Status"></a>
9</p>
10</p>
internal/app/app.go+5−5
@@ -12,6 +12,7 @@ import (
12
12
13 tea "github.com/charmbracelet/bubbletea/v2"
13 tea "github.com/charmbracelet/bubbletea/v2"
14 "github.com/charmbracelet/crush/internal/config"
14 "github.com/charmbracelet/crush/internal/config"
15+ "github.com/charmbracelet/crush/internal/csync"
15 "github.com/charmbracelet/crush/internal/db"
16 "github.com/charmbracelet/crush/internal/db"
16 "github.com/charmbracelet/crush/internal/format"
17 "github.com/charmbracelet/crush/internal/format"
17 "github.com/charmbracelet/crush/internal/history"
18 "github.com/charmbracelet/crush/internal/history"
@@ -37,8 +38,7 @@ type App struct {
37
38
38 clientsMutex sync.RWMutex
39 clientsMutex sync.RWMutex
39
40
40− watcherCancelFuncs []context.CancelFunc
41+ watcherCancelFuncs *csync.Slice[context.CancelFunc]
41− cancelFuncsMutex sync.Mutex
42 lspWatcherWG sync.WaitGroup
42 lspWatcherWG sync.WaitGroup
43
43
44 config *config.Config
44 config *config.Config
@@ -76,6 +76,8 @@ func New(ctx context.Context, conn *sql.DB, cfg *config.Config) (*App, error) {
76
76
77 config: cfg,
77 config: cfg,
78
78
79+ watcherCancelFuncs: csync.NewSlice[context.CancelFunc](),
80+
79 events: make(chan tea.Msg, 100),
81 events: make(chan tea.Msg, 100),
80 serviceEventsWG: &sync.WaitGroup{},
82 serviceEventsWG: &sync.WaitGroup{},
81 tuiWG: &sync.WaitGroup{},
83 tuiWG: &sync.WaitGroup{},
@@ -305,11 +307,9 @@ func (app *App) Shutdown() {
305 app.CoderAgent.CancelAll()
307 app.CoderAgent.CancelAll()
306 }
308 }
307
309
308− app.cancelFuncsMutex.Lock()
309− for _, cancel := range app.watcherCancelFuncs {
310+ for cancel := range app.watcherCancelFuncs.Seq() {
310 cancel()
311 cancel()
311 }
312 }
312− app.cancelFuncsMutex.Unlock()
313
313
314 // Wait for all LSP watchers to finish.
314 // Wait for all LSP watchers to finish.
315 app.lspWatcherWG.Wait()
315 app.lspWatcherWG.Wait()
internal/app/lsp.go+1−3
@@ -63,9 +63,7 @@ func (app *App) createAndStartLSPClient(ctx context.Context, name string, comman
63 workspaceWatcher := watcher.NewWorkspaceWatcher(name, lspClient)
63 workspaceWatcher := watcher.NewWorkspaceWatcher(name, lspClient)
64
64
65 // Store the cancel function to be called during cleanup.
65 // Store the cancel function to be called during cleanup.
66− app.cancelFuncsMutex.Lock()
66+ app.watcherCancelFuncs.Append(cancelFunc)
67− app.watcherCancelFuncs = append(app.watcherCancelFuncs, cancelFunc)
68− app.cancelFuncsMutex.Unlock()
69
67
70 // Add to map with mutex protection before starting goroutine
68 // Add to map with mutex protection before starting goroutine
71 app.clientsMutex.Lock()
69 app.clientsMutex.Lock()
internal/config/config.go+1−1
@@ -270,7 +270,7 @@ func (c *Config) WorkingDir() string {
270
270
271func (c *Config) EnabledProviders() []ProviderConfig {
271func (c *Config) EnabledProviders() []ProviderConfig {
272 var enabled []ProviderConfig
272 var enabled []ProviderConfig
273− for _, p := range c.Providers.Seq2() {
273+ for p := range c.Providers.Seq() {
274 if !p.Disable {
274 if !p.Disable {
275 enabled = append(enabled, p)
275 enabled = append(enabled, p)
276 }
276 }
internal/csync/maps.go+20
@@ -56,6 +56,15 @@ func (m *Map[K, V]) Len() int {
56 return len(m.inner)
56 return len(m.inner)
57}
57}
58
58
59+// Take gets an item and then deletes it.
60+func (m *Map[K, V]) Take(key K) (V, bool) {
61+ m.mu.Lock()
62+ defer m.mu.Unlock()
63+ v, ok := m.inner[key]
64+ delete(m.inner, key)
65+ return v, ok
66+}
67+
59// Seq2 returns an iter.Seq2 that yields key-value pairs from the map.
68// Seq2 returns an iter.Seq2 that yields key-value pairs from the map.
60func (m *Map[K, V]) Seq2() iter.Seq2[K, V] {
69func (m *Map[K, V]) Seq2() iter.Seq2[K, V] {
61 dst := make(map[K]V)
70 dst := make(map[K]V)
@@ -71,6 +80,17 @@ func (m *Map[K, V]) Seq2() iter.Seq2[K, V] {
71 }
80 }
72}
81}
73
82
83+// Seq returns an iter.Seq that yields values from the map.
84+func (m *Map[K, V]) Seq() iter.Seq[V] {
85+ return func(yield func(V) bool) {
86+ for _, v := range m.Seq2() {
87+ if !yield(v) {
88+ return
89+ }
90+ }
91+ }
92+}
93+
74var (
94var (
75 _ json.Unmarshaler = &Map[string, any]{}
95 _ json.Unmarshaler = &Map[string, any]{}
76 _ json.Marshaler = &Map[string, any]{}
96 _ json.Marshaler = &Map[string, any]{}
internal/csync/maps_test.go+225
@@ -110,6 +110,72 @@ func TestMap_Len(t *testing.T) {
110 assert.Equal(t, 0, m.Len())
110 assert.Equal(t, 0, m.Len())
111}
111}
112
112
113+func TestMap_Take(t *testing.T) {
114+ t.Parallel()
115+
116+ m := NewMap[string, int]()
117+ m.Set("key1", 42)
118+ m.Set("key2", 100)
119+
120+ assert.Equal(t, 2, m.Len())
121+
122+ value, ok := m.Take("key1")
123+ assert.True(t, ok)
124+ assert.Equal(t, 42, value)
125+ assert.Equal(t, 1, m.Len())
126+
127+ _, exists := m.Get("key1")
128+ assert.False(t, exists)
129+
130+ value, ok = m.Get("key2")
131+ assert.True(t, ok)
132+ assert.Equal(t, 100, value)
133+}
134+
135+func TestMap_Take_NonexistentKey(t *testing.T) {
136+ t.Parallel()
137+
138+ m := NewMap[string, int]()
139+ m.Set("key1", 42)
140+
141+ value, ok := m.Take("nonexistent")
142+ assert.False(t, ok)
143+ assert.Equal(t, 0, value)
144+ assert.Equal(t, 1, m.Len())
145+
146+ value, ok = m.Get("key1")
147+ assert.True(t, ok)
148+ assert.Equal(t, 42, value)
149+}
150+
151+func TestMap_Take_EmptyMap(t *testing.T) {
152+ t.Parallel()
153+
154+ m := NewMap[string, int]()
155+
156+ value, ok := m.Take("key1")
157+ assert.False(t, ok)
158+ assert.Equal(t, 0, value)
159+ assert.Equal(t, 0, m.Len())
160+}
161+
162+func TestMap_Take_SameKeyTwice(t *testing.T) {
163+ t.Parallel()
164+
165+ m := NewMap[string, int]()
166+ m.Set("key1", 42)
167+
168+ value, ok := m.Take("key1")
169+ assert.True(t, ok)
170+ assert.Equal(t, 42, value)
171+ assert.Equal(t, 0, m.Len())
172+
173+ value, ok = m.Take("key1")
174+ assert.False(t, ok)
175+ assert.Equal(t, 0, value)
176+ assert.Equal(t, 0, m.Len())
177+}
178+
113func TestMap_Seq2(t *testing.T) {
179func TestMap_Seq2(t *testing.T) {
114 t.Parallel()
180 t.Parallel()
115
181
@@ -158,6 +224,57 @@ func TestMap_Seq2_EmptyMap(t *testing.T) {
158 assert.Equal(t, 0, count)
224 assert.Equal(t, 0, count)
159}
225}
160
226
227+func TestMap_Seq(t *testing.T) {
228+ t.Parallel()
229+
230+ m := NewMap[string, int]()
231+ m.Set("key1", 1)
232+ m.Set("key2", 2)
233+ m.Set("key3", 3)
234+
235+ collected := make([]int, 0)
236+ for v := range m.Seq() {
237+ collected = append(collected, v)
238+ }
239+
240+ assert.Equal(t, 3, len(collected))
241+ assert.Contains(t, collected, 1)
242+ assert.Contains(t, collected, 2)
243+ assert.Contains(t, collected, 3)
244+}
245+
246+func TestMap_Seq_EarlyReturn(t *testing.T) {
247+ t.Parallel()
248+
249+ m := NewMap[string, int]()
250+ m.Set("key1", 1)
251+ m.Set("key2", 2)
252+ m.Set("key3", 3)
253+
254+ count := 0
255+ for range m.Seq() {
256+ count++
257+ if count == 2 {
258+ break
259+ }
260+ }
261+
262+ assert.Equal(t, 2, count)
263+}
264+
265+func TestMap_Seq_EmptyMap(t *testing.T) {
266+ t.Parallel()
267+
268+ m := NewMap[string, int]()
269+
270+ count := 0
271+ for range m.Seq() {
272+ count++
273+ }
274+
275+ assert.Equal(t, 0, count)
276+}
277+
161func TestMap_MarshalJSON(t *testing.T) {
278func TestMap_MarshalJSON(t *testing.T) {
162 t.Parallel()
279 t.Parallel()
163
280
@@ -371,6 +488,82 @@ func TestMap_ConcurrentSeq2(t *testing.T) {
371 wg.Wait()
488 wg.Wait()
372}
489}
373
490
491+func TestMap_ConcurrentSeq(t *testing.T) {
492+ t.Parallel()
493+
494+ m := NewMap[int, int]()
495+ for i := range 100 {
496+ m.Set(i, i*2)
497+ }
498+
499+ var wg sync.WaitGroup
500+ const numIterators = 10
501+
502+ wg.Add(numIterators)
503+ for range numIterators {
504+ go func() {
505+ defer wg.Done()
506+ count := 0
507+ values := make(map[int]bool)
508+ for v := range m.Seq() {
509+ values[v] = true
510+ count++
511+ }
512+ assert.Equal(t, 100, count)
513+ for i := range 100 {
514+ assert.True(t, values[i*2])
515+ }
516+ }()
517+ }
518+
519+ wg.Wait()
520+}
521+
522+func TestMap_ConcurrentTake(t *testing.T) {
523+ t.Parallel()
524+
525+ m := NewMap[int, int]()
526+ const numItems = 1000
527+
528+ for i := range numItems {
529+ m.Set(i, i*2)
530+ }
531+
532+ var wg sync.WaitGroup
533+ const numWorkers = 10
534+ taken := make([][]int, numWorkers)
535+
536+ wg.Add(numWorkers)
537+ for i := range numWorkers {
538+ go func(workerID int) {
539+ defer wg.Done()
540+ taken[workerID] = make([]int, 0)
541+ for j := workerID; j < numItems; j += numWorkers {
542+ if value, ok := m.Take(j); ok {
543+ taken[workerID] = append(taken[workerID], value)
544+ }
545+ }
546+ }(i)
547+ }
548+
549+ wg.Wait()
550+
551+ assert.Equal(t, 0, m.Len())
552+
553+ allTaken := make(map[int]bool)
554+ for _, workerTaken := range taken {
555+ for _, value := range workerTaken {
556+ assert.False(t, allTaken[value], "Value %d was taken multiple times", value)
557+ allTaken[value] = true
558+ }
559+ }
560+
561+ assert.Equal(t, numItems, len(allTaken))
562+ for i := range numItems {
563+ assert.True(t, allTaken[i*2], "Expected value %d to be taken", i*2)
564+ }
565+}
566+
374func TestMap_TypeSafety(t *testing.T) {
567func TestMap_TypeSafety(t *testing.T) {
375 t.Parallel()
568 t.Parallel()
376
569
@@ -431,6 +624,38 @@ func BenchmarkMap_Seq2(b *testing.B) {
431 }
624 }
432}
625}
433
626
627+func BenchmarkMap_Seq(b *testing.B) {
628+ m := NewMap[int, int]()
629+ for i := range 1000 {
630+ m.Set(i, i*2)
631+ }
632+
633+ for b.Loop() {
634+ for range m.Seq() {
635+ }
636+ }
637+}
638+
639+func BenchmarkMap_Take(b *testing.B) {
640+ m := NewMap[int, int]()
641+ for i := range 1000 {
642+ m.Set(i, i*2)
643+ }
644+
645+ b.ResetTimer()
646+ for i := 0; b.Loop(); i++ {
647+ key := i % 1000
648+ m.Take(key)
649+ if i%1000 == 999 {
650+ b.StopTimer()
651+ for j := range 1000 {
652+ m.Set(j, j*2)
653+ }
654+ b.StartTimer()
655+ }
656+ }
657+}
658+
434func BenchmarkMap_ConcurrentReadWrite(b *testing.B) {
659func BenchmarkMap_ConcurrentReadWrite(b *testing.B) {
435 m := NewMap[int, int]()
660 m := NewMap[int, int]()
436 for i := range 1000 {
661 for i := range 1000 {
internal/csync/slices.go+11−2
@@ -59,10 +59,10 @@ func NewSliceFrom[T any](s []T) *Slice[T] {
59}
59}
60
60
61// Append adds an element to the end of the slice.
61// Append adds an element to the end of the slice.
62−func (s *Slice[T]) Append(item T) {
62+func (s *Slice[T]) Append(items ...T) {
63 s.mu.Lock()
63 s.mu.Lock()
64 defer s.mu.Unlock()
64 defer s.mu.Unlock()
65− s.inner = append(s.inner, item)
65+ s.inner = append(s.inner, items...)
66}
66}
67
67
68// Prepend adds an element to the beginning of the slice.
68// Prepend adds an element to the beginning of the slice.
@@ -112,6 +112,15 @@ func (s *Slice[T]) Len() int {
112 return len(s.inner)
112 return len(s.inner)
113}
113}
114
114
115+// Slice returns a copy of the underlying slice.
116+func (s *Slice[T]) Slice() []T {
117+ s.mu.RLock()
118+ defer s.mu.RUnlock()
119+ result := make([]T, len(s.inner))
120+ copy(result, s.inner)
121+ return result
122+}
123+
115// SetSlice replaces the entire slice with a new one.
124// SetSlice replaces the entire slice with a new one.
116func (s *Slice[T]) SetSlice(items []T) {
125func (s *Slice[T]) SetSlice(items []T) {
117 s.mu.Lock()
126 s.mu.Lock()
internal/csync/slices_test.go+3−4
@@ -1,7 +1,6 @@
1package csync
1package csync
2
2
3import (
3import (
4− "slices"
5 "sync"
4 "sync"
6 "sync/atomic"
5 "sync/atomic"
7 "testing"
6 "testing"
@@ -146,7 +145,7 @@ func TestSlice(t *testing.T) {
146 assert.Equal(t, 4, s.Len())
145 assert.Equal(t, 4, s.Len())
147
146
148 expected := []int{1, 2, 4, 5}
147 expected := []int{1, 2, 4, 5}
149− actual := slices.Collect(s.Seq())
148+ actual := s.Slice()
150 assert.Equal(t, expected, actual)
149 assert.Equal(t, expected, actual)
151
150
152 // Delete out of bounds
151 // Delete out of bounds
@@ -204,7 +203,7 @@ func TestSlice(t *testing.T) {
204 s.SetSlice(newItems)
203 s.SetSlice(newItems)
205
204
206 assert.Equal(t, 3, s.Len())
205 assert.Equal(t, 3, s.Len())
207− assert.Equal(t, newItems, slices.Collect(s.Seq()))
206+ assert.Equal(t, newItems, s.Slice())
208
207
209 // Verify it's a copy
208 // Verify it's a copy
210 newItems[0] = 999
209 newItems[0] = 999
@@ -225,7 +224,7 @@ func TestSlice(t *testing.T) {
225 original := []int{1, 2, 3}
224 original := []int{1, 2, 3}
226 s := NewSliceFrom(original)
225 s := NewSliceFrom(original)
227
226
228− copy := slices.Collect(s.Seq())
227+ copy := s.Slice()
229 assert.Equal(t, original, copy)
228 assert.Equal(t, original, copy)
230
229
231 // Verify it's a copy
230 // Verify it's a copy
internal/llm/agent/agent.go+23−32
@@ -7,7 +7,6 @@ import (
7 "log/slog"
7 "log/slog"
8 "slices"
8 "slices"
9 "strings"
9 "strings"
10− "sync"
11 "time"
10 "time"
12
11
13 "github.com/charmbracelet/catwalk/pkg/catwalk"
12 "github.com/charmbracelet/catwalk/pkg/catwalk"
@@ -78,7 +77,7 @@ type agent struct {
78 summarizeProvider provider.Provider
77 summarizeProvider provider.Provider
79 summarizeProviderID string
78 summarizeProviderID string
80
79
81− activeRequests sync.Map
80+ activeRequests *csync.Map[string, context.CancelFunc]
82}
81}
83
82
84var agentPromptMap = map[string]prompt.PromptID{
83var agentPromptMap = map[string]prompt.PromptID{
@@ -222,7 +221,7 @@ func NewAgent(
222 titleProvider: titleProvider,
221 titleProvider: titleProvider,
223 summarizeProvider: summarizeProvider,
222 summarizeProvider: summarizeProvider,
224 summarizeProviderID: string(smallModelProviderCfg.ID),
223 summarizeProviderID: string(smallModelProviderCfg.ID),
225− activeRequests: sync.Map{},
224+ activeRequests: csync.NewMap[string, context.CancelFunc](),
226 tools: csync.NewLazySlice(toolFn),
225 tools: csync.NewLazySlice(toolFn),
227 }, nil
226 }, nil
228}
227}
@@ -233,38 +232,31 @@ func (a *agent) Model() catwalk.Model {
233
232
234func (a *agent) Cancel(sessionID string) {
233func (a *agent) Cancel(sessionID string) {
235 // Cancel regular requests
234 // Cancel regular requests
236− if cancelFunc, exists := a.activeRequests.LoadAndDelete(sessionID); exists {
235+ if cancel, ok := a.activeRequests.Take(sessionID); ok && cancel != nil {
237− if cancel, ok := cancelFunc.(context.CancelFunc); ok {
238− slog.Info("Request cancellation initiated", "session_id", sessionID)
236+ slog.Info("Request cancellation initiated", "session_id", sessionID)
239− cancel()
237+ cancel()
240− }
241 }
238 }
242
239
243 // Also check for summarize requests
240 // Also check for summarize requests
244− if cancelFunc, exists := a.activeRequests.LoadAndDelete(sessionID + "-summarize"); exists {
241+ if cancel, ok := a.activeRequests.Take(sessionID + "-summarize"); ok && cancel != nil {
245− if cancel, ok := cancelFunc.(context.CancelFunc); ok {
246− slog.Info("Summarize cancellation initiated", "session_id", sessionID)
242+ slog.Info("Summarize cancellation initiated", "session_id", sessionID)
247− cancel()
243+ cancel()
248− }
249 }
244 }
250}
245}
251
246
252func (a *agent) IsBusy() bool {
247func (a *agent) IsBusy() bool {
253− busy := false
248+ var busy bool
254− a.activeRequests.Range(func(key, value any) bool {
249+ for cancelFunc := range a.activeRequests.Seq() {
255− if cancelFunc, ok := value.(context.CancelFunc); ok {
250+ if cancelFunc != nil {
256− if cancelFunc != nil {
257− busy = true
251+ busy = true
258− return false
252+ break
259− }
260 }
253 }
261− return true
262− })
254+ }
263 return busy
255 return busy
264}
256}
265
257
266func (a *agent) IsSessionBusy(sessionID string) bool {
258func (a *agent) IsSessionBusy(sessionID string) bool {
267− _, busy := a.activeRequests.Load(sessionID)
259+ _, busy := a.activeRequests.Get(sessionID)
268 return busy
260 return busy
269}
261}
270
262
@@ -335,7 +327,7 @@ func (a *agent) Run(ctx context.Context, sessionID string, content string, attac
335
327
336 genCtx, cancel := context.WithCancel(ctx)
328 genCtx, cancel := context.WithCancel(ctx)
337
329
338− a.activeRequests.Store(sessionID, cancel)
330+ a.activeRequests.Set(sessionID, cancel)
339 go func() {
331 go func() {
340 slog.Debug("Request started", "sessionID", sessionID)
332 slog.Debug("Request started", "sessionID", sessionID)
341 defer log.RecoverPanic("agent.Run", func() {
333 defer log.RecoverPanic("agent.Run", func() {
@@ -350,7 +342,7 @@ func (a *agent) Run(ctx context.Context, sessionID string, content string, attac
350 slog.Error(result.Error.Error())
342 slog.Error(result.Error.Error())
351 }
343 }
352 slog.Debug("Request completed", "sessionID", sessionID)
344 slog.Debug("Request completed", "sessionID", sessionID)
353− a.activeRequests.Delete(sessionID)
345+ a.activeRequests.Del(sessionID)
354 cancel()
346 cancel()
355 a.Publish(pubsub.CreatedEvent, result)
347 a.Publish(pubsub.CreatedEvent, result)
356 events <- result
348 events <- result
@@ -682,10 +674,10 @@ func (a *agent) Summarize(ctx context.Context, sessionID string) error {
682 summarizeCtx, cancel := context.WithCancel(ctx)
674 summarizeCtx, cancel := context.WithCancel(ctx)
683
675
684 // Store the cancel function in activeRequests to allow cancellation
676 // Store the cancel function in activeRequests to allow cancellation
685− a.activeRequests.Store(sessionID+"-summarize", cancel)
677+ a.activeRequests.Set(sessionID+"-summarize", cancel)
686
678
687 go func() {
679 go func() {
688− defer a.activeRequests.Delete(sessionID + "-summarize")
680+ defer a.activeRequests.Del(sessionID + "-summarize")
689 defer cancel()
681 defer cancel()
690 event := AgentEvent{
682 event := AgentEvent{
691 Type: AgentEventTypeSummarize,
683 Type: AgentEventTypeSummarize,
@@ -850,10 +842,9 @@ func (a *agent) CancelAll() {
850 if !a.IsBusy() {
842 if !a.IsBusy() {
851 return
843 return
852 }
844 }
853− a.activeRequests.Range(func(key, value any) bool {
845+ for key := range a.activeRequests.Seq2() {
854− a.Cancel(key.(string)) // key is sessionID
846+ a.Cancel(key) // key is sessionID
855− return true
856− })
847+ }
857
848
858 timeout := time.After(5 * time.Second)
849 timeout := time.After(5 * time.Second)
859 for a.IsBusy() {
850 for a.IsBusy() {
@@ -907,7 +898,7 @@ func (a *agent) UpdateModel() error {
907 smallModelCfg := cfg.Models[config.SelectedModelTypeSmall]
898 smallModelCfg := cfg.Models[config.SelectedModelTypeSmall]
908 var smallModelProviderCfg config.ProviderConfig
899 var smallModelProviderCfg config.ProviderConfig
909
900
910− for _, p := range cfg.Providers.Seq2() {
901+ for p := range cfg.Providers.Seq() {
911 if p.ID == smallModelCfg.Provider {
902 if p.ID == smallModelCfg.Provider {
912 smallModelProviderCfg = p
903 smallModelProviderCfg = p
913 break
904 break
internal/llm/agent/mcp-tools.go+7−12
@@ -5,9 +5,11 @@ import (
5 "encoding/json"
5 "encoding/json"
6 "fmt"
6 "fmt"
7 "log/slog"
7 "log/slog"
8+ "slices"
8 "sync"
9 "sync"
9
10
10 "github.com/charmbracelet/crush/internal/config"
11 "github.com/charmbracelet/crush/internal/config"
12+ "github.com/charmbracelet/crush/internal/csync"
11 "github.com/charmbracelet/crush/internal/llm/tools"
13 "github.com/charmbracelet/crush/internal/llm/tools"
12
14
13 "github.com/charmbracelet/crush/internal/permission"
15 "github.com/charmbracelet/crush/internal/permission"
@@ -195,9 +197,8 @@ func GetMCPTools(ctx context.Context, permissions permission.Service, cfg *confi
195}
197}
196
198
197func doGetMCPTools(ctx context.Context, permissions permission.Service, cfg *config.Config) []tools.BaseTool {
199func doGetMCPTools(ctx context.Context, permissions permission.Service, cfg *config.Config) []tools.BaseTool {
198− var mu sync.Mutex
199 var wg sync.WaitGroup
200 var wg sync.WaitGroup
200− var result []tools.BaseTool
201+ result := csync.NewSlice[tools.BaseTool]()
201 for name, m := range cfg.MCP {
202 for name, m := range cfg.MCP {
202 if m.Disabled {
203 if m.Disabled {
203 slog.Debug("skipping disabled mcp", "name", name)
204 slog.Debug("skipping disabled mcp", "name", name)
@@ -218,9 +219,7 @@ func doGetMCPTools(ctx context.Context, permissions permission.Service, cfg *con
218 return
219 return
219 }
220 }
220
221
221− mu.Lock()
222− result = append(result, getTools(ctx, name, m, permissions, c, cfg.WorkingDir())...)
222+ result.Append(getTools(ctx, name, m, permissions, c, cfg.WorkingDir())...)
223− mu.Unlock()
224 case config.MCPHttp:
223 case config.MCPHttp:
225 c, err := client.NewStreamableHttpClient(
224 c, err := client.NewStreamableHttpClient(
226 m.URL,
225 m.URL,
@@ -230,9 +229,7 @@ func doGetMCPTools(ctx context.Context, permissions permission.Service, cfg *con
230 slog.Error("error creating mcp client", "error", err)
229 slog.Error("error creating mcp client", "error", err)
231 return
230 return
232 }
231 }
233− mu.Lock()
234− result = append(result, getTools(ctx, name, m, permissions, c, cfg.WorkingDir())...)
232+ result.Append(getTools(ctx, name, m, permissions, c, cfg.WorkingDir())...)
235− mu.Unlock()
236 case config.MCPSse:
233 case config.MCPSse:
237 c, err := client.NewSSEMCPClient(
234 c, err := client.NewSSEMCPClient(
238 m.URL,
235 m.URL,
@@ -242,12 +239,10 @@ func doGetMCPTools(ctx context.Context, permissions permission.Service, cfg *con
242 slog.Error("error creating mcp client", "error", err)
239 slog.Error("error creating mcp client", "error", err)
243 return
240 return
244 }
241 }
245− mu.Lock()
246− result = append(result, getTools(ctx, name, m, permissions, c, cfg.WorkingDir())...)
242+ result.Append(getTools(ctx, name, m, permissions, c, cfg.WorkingDir())...)
247− mu.Unlock()
248 }
243 }
249 }(name, m)
244 }(name, m)
250 }
245 }
251 wg.Wait()
246 wg.Wait()
252− return result
247+ return slices.Collect(result.Seq())
253}
248}
internal/llm/prompt/prompt.go+6−18
@@ -7,6 +7,7 @@ import (
7 "sync"
7 "sync"
8
8
9 "github.com/charmbracelet/crush/internal/config"
9 "github.com/charmbracelet/crush/internal/config"
10+ "github.com/charmbracelet/crush/internal/csync"
10 "github.com/charmbracelet/crush/internal/env"
11 "github.com/charmbracelet/crush/internal/env"
11)
12)
12
13
@@ -74,8 +75,7 @@ func processContextPaths(workDir string, paths []string) string {
74 )
75 )
75
76
76 // Track processed files to avoid duplicates
77 // Track processed files to avoid duplicates
77− processedFiles := make(map[string]bool)
78+ processedFiles := csync.NewMap[string, bool]()
78− var processedMutex sync.Mutex
79
79
80 for _, path := range paths {
80 for _, path := range paths {
81 wg.Add(1)
81 wg.Add(1)
@@ -106,14 +106,8 @@ func processContextPaths(workDir string, paths []string) string {
106 // Check if we've already processed this file (case-insensitive)
106 // Check if we've already processed this file (case-insensitive)
107 lowerPath := strings.ToLower(path)
107 lowerPath := strings.ToLower(path)
108
108
109− processedMutex.Lock()
110− alreadyProcessed := processedFiles[lowerPath]
109+ if alreadyProcessed, _ := processedFiles.Get(lowerPath); !alreadyProcessed {
111− if !alreadyProcessed {
112− processedFiles[lowerPath] = true
110+ processedFiles.Set(lowerPath, true)
113− }
114− processedMutex.Unlock()
115−
116− if !alreadyProcessed {
117 if result := processFile(path); result != "" {
111 if result := processFile(path); result != "" {
118 resultCh <- result
112 resultCh <- result
119 }
113 }
@@ -126,14 +120,8 @@ func processContextPaths(workDir string, paths []string) string {
126 // Check if we've already processed this file (case-insensitive)
120 // Check if we've already processed this file (case-insensitive)
127 lowerPath := strings.ToLower(fullPath)
121 lowerPath := strings.ToLower(fullPath)
128
122
129− processedMutex.Lock()
130− alreadyProcessed := processedFiles[lowerPath]
123+ if alreadyProcessed, _ := processedFiles.Get(lowerPath); !alreadyProcessed {
131− if !alreadyProcessed {
132− processedFiles[lowerPath] = true
124+ processedFiles.Set(lowerPath, true)
133− }
134− processedMutex.Unlock()
135−
136− if !alreadyProcessed {
137 result := processFile(fullPath)
125 result := processFile(fullPath)
138 if result != "" {
126 if result != "" {
139 resultCh <- result
127 resultCh <- result
internal/lsp/watcher/watcher.go+7−12
@@ -12,6 +12,7 @@ import (
12
12
13 "github.com/bmatcuk/doublestar/v4"
13 "github.com/bmatcuk/doublestar/v4"
14 "github.com/charmbracelet/crush/internal/config"
14 "github.com/charmbracelet/crush/internal/config"
15+ "github.com/charmbracelet/crush/internal/csync"
15
16
16 "github.com/charmbracelet/crush/internal/lsp"
17 "github.com/charmbracelet/crush/internal/lsp"
17 "github.com/charmbracelet/crush/internal/lsp/protocol"
18 "github.com/charmbracelet/crush/internal/lsp/protocol"
@@ -25,8 +26,7 @@ type WorkspaceWatcher struct {
25 workspacePath string
26 workspacePath string
26
27
27 debounceTime time.Duration
28 debounceTime time.Duration
28− debounceMap map[string]*time.Timer
29+ debounceMap *csync.Map[string, *time.Timer]
29− debounceMu sync.Mutex
30
30
31 // File watchers registered by the server
31 // File watchers registered by the server
32 registrations []protocol.FileSystemWatcher
32 registrations []protocol.FileSystemWatcher
@@ -46,7 +46,7 @@ func NewWorkspaceWatcher(name string, client *lsp.Client) *WorkspaceWatcher {
46 name: name,
46 name: name,
47 client: client,
47 client: client,
48 debounceTime: 300 * time.Millisecond,
48 debounceTime: 300 * time.Millisecond,
49− debounceMap: make(map[string]*time.Timer),
49+ debounceMap: csync.NewMap[string, *time.Timer](),
50 registrations: []protocol.FileSystemWatcher{},
50 registrations: []protocol.FileSystemWatcher{},
51 }
51 }
52}
52}
@@ -639,26 +639,21 @@ func (w *WorkspaceWatcher) matchesPattern(path string, pattern protocol.GlobPatt
639
639
640// debounceHandleFileEvent handles file events with debouncing to reduce notifications
640// debounceHandleFileEvent handles file events with debouncing to reduce notifications
641func (w *WorkspaceWatcher) debounceHandleFileEvent(ctx context.Context, uri string, changeType protocol.FileChangeType) {
641func (w *WorkspaceWatcher) debounceHandleFileEvent(ctx context.Context, uri string, changeType protocol.FileChangeType) {
642− w.debounceMu.Lock()
643− defer w.debounceMu.Unlock()
644−
645 // Create a unique key based on URI and change type
642 // Create a unique key based on URI and change type
646 key := fmt.Sprintf("%s:%d", uri, changeType)
643 key := fmt.Sprintf("%s:%d", uri, changeType)
647
644
648 // Cancel existing timer if any
645 // Cancel existing timer if any
649− if timer, exists := w.debounceMap[key]; exists {
646+ if timer, exists := w.debounceMap.Get(key); exists {
650 timer.Stop()
647 timer.Stop()
651 }
648 }
652
649
653 // Create new timer
650 // Create new timer
654− w.debounceMap[key] = time.AfterFunc(w.debounceTime, func() {
651+ w.debounceMap.Set(key, time.AfterFunc(w.debounceTime, func() {
655 w.handleFileEvent(ctx, uri, changeType)
652 w.handleFileEvent(ctx, uri, changeType)
656
653
657 // Cleanup timer after execution
654 // Cleanup timer after execution
658− w.debounceMu.Lock()
659− delete(w.debounceMap, key)
655+ w.debounceMap.Del(key)
660− w.debounceMu.Unlock()
661− })
656+ }))
662}
657}
663
658
664// handleFileEvent sends file change notifications
659// handleFileEvent sends file change notifications
internal/permission/permission.go+11−9
@@ -6,6 +6,7 @@ import (
6 "slices"
6 "slices"
7 "sync"
7 "sync"
8
8
9+ "github.com/charmbracelet/crush/internal/csync"
9 "github.com/charmbracelet/crush/internal/pubsub"
10 "github.com/charmbracelet/crush/internal/pubsub"
10 "github.com/google/uuid"
11 "github.com/google/uuid"
11)
12)
@@ -46,7 +47,7 @@ type permissionService struct {
46 workingDir string
47 workingDir string
47 sessionPermissions []PermissionRequest
48 sessionPermissions []PermissionRequest
48 sessionPermissionsMu sync.RWMutex
49 sessionPermissionsMu sync.RWMutex
49− pendingRequests sync.Map
50+ pendingRequests *csync.Map[string, chan bool]
50 autoApproveSessions []string
51 autoApproveSessions []string
51 autoApproveSessionsMu sync.RWMutex
52 autoApproveSessionsMu sync.RWMutex
52 skip bool
53 skip bool
@@ -54,9 +55,9 @@ type permissionService struct {
54}
55}
55
56
56func (s *permissionService) GrantPersistent(permission PermissionRequest) {
57func (s *permissionService) GrantPersistent(permission PermissionRequest) {
57− respCh, ok := s.pendingRequests.Load(permission.ID)
58+ respCh, ok := s.pendingRequests.Get(permission.ID)
58 if ok {
59 if ok {
59− respCh.(chan bool) <- true
60+ respCh <- true
60 }
61 }
61
62
62 s.sessionPermissionsMu.Lock()
63 s.sessionPermissionsMu.Lock()
@@ -65,16 +66,16 @@ func (s *permissionService) GrantPersistent(permission PermissionRequest) {
65}
66}
66
67
67func (s *permissionService) Grant(permission PermissionRequest) {
68func (s *permissionService) Grant(permission PermissionRequest) {
68− respCh, ok := s.pendingRequests.Load(permission.ID)
69+ respCh, ok := s.pendingRequests.Get(permission.ID)
69 if ok {
70 if ok {
70− respCh.(chan bool) <- true
71+ respCh <- true
71 }
72 }
72}
73}
73
74
74func (s *permissionService) Deny(permission PermissionRequest) {
75func (s *permissionService) Deny(permission PermissionRequest) {
75− respCh, ok := s.pendingRequests.Load(permission.ID)
76+ respCh, ok := s.pendingRequests.Get(permission.ID)
76 if ok {
77 if ok {
77− respCh.(chan bool) <- false
78+ respCh <- false
78 }
79 }
79}
80}
80
81
@@ -122,8 +123,8 @@ func (s *permissionService) Request(opts CreatePermissionRequest) bool {
122
123
123 respCh := make(chan bool, 1)
124 respCh := make(chan bool, 1)
124
125
125− s.pendingRequests.Store(permission.ID, respCh)
126+ s.pendingRequests.Set(permission.ID, respCh)
126− defer s.pendingRequests.Delete(permission.ID)
127+ defer s.pendingRequests.Del(permission.ID)
127
128
128 s.Publish(pubsub.CreatedEvent, permission)
129 s.Publish(pubsub.CreatedEvent, permission)
129
130
@@ -144,5 +145,6 @@ func NewPermissionService(workingDir string, skip bool, allowedTools []string) S
144 sessionPermissions: make([]PermissionRequest, 0),
145 sessionPermissions: make([]PermissionRequest, 0),
145 skip: skip,
146 skip: skip,
146 allowedTools: allowedTools,
147 allowedTools: allowedTools,
148+ pendingRequests: csync.NewMap[string, chan bool](),
147 }
149 }
148}
150}
internal/tui/components/chat/sidebar/sidebar.go+32−42
@@ -4,13 +4,14 @@ import (
4 "context"
4 "context"
5 "fmt"
5 "fmt"
6 "os"
6 "os"
7+ "slices"
7 "sort"
8 "sort"
8 "strings"
9 "strings"
9− "sync"
10
10
11 tea "github.com/charmbracelet/bubbletea/v2"
11 tea "github.com/charmbracelet/bubbletea/v2"
12 "github.com/charmbracelet/catwalk/pkg/catwalk"
12 "github.com/charmbracelet/catwalk/pkg/catwalk"
13 "github.com/charmbracelet/crush/internal/config"
13 "github.com/charmbracelet/crush/internal/config"
14+ "github.com/charmbracelet/crush/internal/csync"
14 "github.com/charmbracelet/crush/internal/diff"
15 "github.com/charmbracelet/crush/internal/diff"
15 "github.com/charmbracelet/crush/internal/fsext"
16 "github.com/charmbracelet/crush/internal/fsext"
16 "github.com/charmbracelet/crush/internal/history"
17 "github.com/charmbracelet/crush/internal/history"
@@ -71,8 +72,7 @@ type sidebarCmp struct {
71 lspClients map[string]*lsp.Client
72 lspClients map[string]*lsp.Client
72 compactMode bool
73 compactMode bool
73 history history.Service
74 history history.Service
74− // Using a sync map here because we might receive file history events concurrently
75+ files *csync.Map[string, SessionFile]
75− files sync.Map
76}
76}
77
77
78func New(history history.Service, lspClients map[string]*lsp.Client, compact bool) Sidebar {
78func New(history history.Service, lspClients map[string]*lsp.Client, compact bool) Sidebar {
@@ -80,6 +80,7 @@ func New(history history.Service, lspClients map[string]*lsp.Client, compact boo
80 lspClients: lspClients,
80 lspClients: lspClients,
81 history: history,
81 history: history,
82 compactMode: compact,
82 compactMode: compact,
83+ files: csync.NewMap[string, SessionFile](),
83 }
84 }
84}
85}
85
86
@@ -90,9 +91,9 @@ func (m *sidebarCmp) Init() tea.Cmd {
90func (m *sidebarCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
91func (m *sidebarCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
91 switch msg := msg.(type) {
92 switch msg := msg.(type) {
92 case SessionFilesMsg:
93 case SessionFilesMsg:
93− m.files = sync.Map{}
94+ m.files = csync.NewMap[string, SessionFile]()
94 for _, file := range msg.Files {
95 for _, file := range msg.Files {
95− m.files.Store(file.FilePath, file)
96+ m.files.Set(file.FilePath, file)
96 }
97 }
97 return m, nil
98 return m, nil
98
99
@@ -178,31 +179,30 @@ func (m *sidebarCmp) handleFileHistoryEvent(event pubsub.Event[history.File]) te
178 return func() tea.Msg {
179 return func() tea.Msg {
179 file := event.Payload
180 file := event.Payload
180 found := false
181 found := false
181− m.files.Range(func(key, value any) bool {
182+ for existing := range m.files.Seq() {
182− existing := value.(SessionFile)
183− if existing.FilePath == file.Path {
183+ if existing.FilePath != file.Path {
184− if existing.History.latestVersion.Version < file.Version {
184+ continue
185− existing.History.latestVersion = file
186− } else if file.Version == 0 {
187− existing.History.initialVersion = file
188− } else {
189− // If the version is not greater than the latest, we ignore it
190− return true
191− }
192− before := existing.History.initialVersion.Content
193− after := existing.History.latestVersion.Content
194− path := existing.History.initialVersion.Path
195− cwd := config.Get().WorkingDir()
196− path = strings.TrimPrefix(path, cwd)
197− _, additions, deletions := diff.GenerateDiff(before, after, path)
198− existing.Additions = additions
199− existing.Deletions = deletions
200− m.files.Store(file.Path, existing)
201− found = true
202− return false
203 }
185 }
186+ if existing.History.latestVersion.Version < file.Version {
187+ existing.History.latestVersion = file
188+ } else if file.Version == 0 {
189+ existing.History.initialVersion = file
190+ } else {
191+ // If the version is not greater than the latest, we ignore it
192+ continue
193+ }
194+ before := existing.History.initialVersion.Content
195+ after := existing.History.latestVersion.Content
196+ path := existing.History.initialVersion.Path
197+ cwd := config.Get().WorkingDir()
198+ path = strings.TrimPrefix(path, cwd)
199+ _, additions, deletions := diff.GenerateDiff(before, after, path)
200+ existing.Additions = additions
201+ existing.Deletions = deletions
202+ m.files.Set(file.Path, existing)
204− return true
203+ found = true
204+ break
205− })
205+ }
206 if found {
206 if found {
207 return nil
207 return nil
208 }
208 }
@@ -215,7 +215,7 @@ func (m *sidebarCmp) handleFileHistoryEvent(event pubsub.Event[history.File]) te
215 Additions: 0,
215 Additions: 0,
216 Deletions: 0,
216 Deletions: 0,
217 }
217 }
218− m.files.Store(file.Path, sf)
218+ m.files.Set(file.Path, sf)
219 return nil
219 return nil
220 }
220 }
221}
221}
@@ -386,12 +386,7 @@ func (m *sidebarCmp) filesBlockCompact(maxWidth int) string {
386
386
387 section := t.S().Subtle.Render("Modified Files")
387 section := t.S().Subtle.Render("Modified Files")
388
388
389− files := make([]SessionFile, 0)
389+ files := slices.Collect(m.files.Seq())
390− m.files.Range(func(key, value any) bool {
391− file := value.(SessionFile)
392− files = append(files, file)
393− return true
394− })
395
390
396 if len(files) == 0 {
391 if len(files) == 0 {
397 content := lipgloss.JoinVertical(
392 content := lipgloss.JoinVertical(
@@ -620,12 +615,7 @@ func (m *sidebarCmp) filesBlock() string {
620 core.Section("Modified Files", m.getMaxWidth()),
615 core.Section("Modified Files", m.getMaxWidth()),
621 )
616 )
622
617
623− files := make([]SessionFile, 0)
618+ files := slices.Collect(m.files.Seq())
624− m.files.Range(func(key, value any) bool {
625− file := value.(SessionFile)
626− files = append(files, file)
627− return true // continue iterating
628− })
629 if len(files) == 0 {
619 if len(files) == 0 {
630 return lipgloss.JoinVertical(
620 return lipgloss.JoinVertical(
631 lipgloss.Left,
621 lipgloss.Left,