on main help sign in
Merge branch 'list' into tool_improvements
446ba99Kujtim Hoxha
.goreleaser.yml+27
@@ -110,6 +110,7 @@ homebrew_casks:
110 - repository:
110 - repository:
111 owner: charmbracelet
111 owner: charmbracelet
112 name: homebrew-tap
112 name: homebrew-tap
113+ token: "{{ .Env.HOMEBREW_TAP_GITHUB_TOKEN }}"
113
114
114npms:
115npms:
115 - name: "@charmland/crush"
116 - name: "@charmland/crush"
@@ -134,6 +135,32 @@ nfpms:
134 - src: ./manpages/crush.1.gz
135 - src: ./manpages/crush.1.gz
135 dst: /usr/share/man/man1/crush.1.gz
136 dst: /usr/share/man/man1/crush.1.gz
136
137
138+nix:
139+ - repository:
140+ owner: "charmbracelet"
141+ name: nur
142+ token: "{{ .Env.HOMEBREW_TAP_GITHUB_TOKEN }}"
143+ extra_install: |-
144+ installManPage ./manpages/crush.1.gz.
145+ installShellCompletion ./completions/*
146+
147+winget:
148+ - publisher: charmbracelet
149+ copyright: Charmbracelet, Inc
150+ repository:
151+ owner: "charmbracelet"
152+ name: winget-pkgs
153+ token: "{{ .Env.HOMEBREW_TAP_GITHUB_TOKEN }}"
154+ branch: "crush-{{.Version}}"
155+ pull_request:
156+ enabled: true
157+ draft: false
158+ check_boxes: true
159+ base:
160+ owner: microsoft
161+ name: winget-pkgs
162+ branch: master
163+
137changelog:
164changelog:
138 sort: asc
165 sort: asc
139 disable: "{{ .IsNightly }}"
166 disable: "{{ .IsNightly }}"
internal/config/load.go+1−1
@@ -17,7 +17,7 @@ import (
17 "github.com/charmbracelet/crush/internal/log"
17 "github.com/charmbracelet/crush/internal/log"
18)
18)
19
19
20−const catwalkURL = "https://catwalk.charm.sh"
20+const defaultCatwalkURL = "https://catwalk.charm.sh"
21
21
22// LoadReader config via io.Reader.
22// LoadReader config via io.Reader.
23func LoadReader(fd io.Reader) (*Config, error) {
23func LoadReader(fd io.Reader) (*Config, error) {
internal/config/provider.go+2
@@ -1,6 +1,7 @@
1package config
1package config
2
2
3import (
3import (
4+ "cmp"
4 "encoding/json"
5 "encoding/json"
5 "fmt"
6 "fmt"
6 "log/slog"
7 "log/slog"
@@ -74,6 +75,7 @@ func loadProvidersFromCache(path string) ([]catwalk.Provider, error) {
74}
75}
75
76
76func Providers() ([]catwalk.Provider, error) {
77func Providers() ([]catwalk.Provider, error) {
78+ catwalkURL := cmp.Or(os.Getenv("CATWALK_URL"), defaultCatwalkURL)
77 client := catwalk.NewWithURL(catwalkURL)
79 client := catwalk.NewWithURL(catwalkURL)
78 path := providerCacheFileData()
80 path := providerCacheFileData()
79 return loadProvidersOnce(client, path)
81 return loadProvidersOnce(client, path)
internal/csync/slices.go+5−14
@@ -112,15 +112,6 @@ 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−
124// SetSlice replaces the entire slice with a new one.
115// SetSlice replaces the entire slice with a new one.
125func (s *Slice[T]) SetSlice(items []T) {
116func (s *Slice[T]) SetSlice(items []T) {
126 s.mu.Lock()
117 s.mu.Lock()
@@ -138,10 +129,8 @@ func (s *Slice[T]) Clear() {
138
129
139// Seq returns an iterator that yields elements from the slice.
130// Seq returns an iterator that yields elements from the slice.
140func (s *Slice[T]) Seq() iter.Seq[T] {
131func (s *Slice[T]) Seq() iter.Seq[T] {
141− // Take a snapshot to avoid holding the lock during iteration
142− items := s.Slice()
143 return func(yield func(T) bool) {
132 return func(yield func(T) bool) {
144− for _, v := range items {
133+ for _, v := range s.Seq2() {
145 if !yield(v) {
134 if !yield(v) {
146 return
135 return
147 }
136 }
@@ -151,8 +140,10 @@ func (s *Slice[T]) Seq() iter.Seq[T] {
151
140
152// Seq2 returns an iterator that yields index-value pairs from the slice.
141// Seq2 returns an iterator that yields index-value pairs from the slice.
153func (s *Slice[T]) Seq2() iter.Seq2[int, T] {
142func (s *Slice[T]) Seq2() iter.Seq2[int, T] {
154− // Take a snapshot to avoid holding the lock during iteration
143+ s.mu.RLock()
155− items := s.Slice()
144+ items := make([]T, len(s.inner))
145+ copy(items, s.inner)
146+ s.mu.RUnlock()
156 return func(yield func(int, T) bool) {
147 return func(yield func(int, T) bool) {
157 for i, v := range items {
148 for i, v := range items {
158 if !yield(i, v) {
149 if !yield(i, v) {
internal/csync/slices_test.go+4−3
@@ -1,6 +1,7 @@
1package csync
1package csync
2
2
3import (
3import (
4+ "slices"
4 "sync"
5 "sync"
5 "sync/atomic"
6 "sync/atomic"
6 "testing"
7 "testing"
@@ -145,7 +146,7 @@ func TestSlice(t *testing.T) {
145 assert.Equal(t, 4, s.Len())
146 assert.Equal(t, 4, s.Len())
146
147
147 expected := []int{1, 2, 4, 5}
148 expected := []int{1, 2, 4, 5}
148− actual := s.Slice()
149+ actual := slices.Collect(s.Seq())
149 assert.Equal(t, expected, actual)
150 assert.Equal(t, expected, actual)
150
151
151 // Delete out of bounds
152 // Delete out of bounds
@@ -203,7 +204,7 @@ func TestSlice(t *testing.T) {
203 s.SetSlice(newItems)
204 s.SetSlice(newItems)
204
205
205 assert.Equal(t, 3, s.Len())
206 assert.Equal(t, 3, s.Len())
206− assert.Equal(t, newItems, s.Slice())
207+ assert.Equal(t, newItems, slices.Collect(s.Seq()))
207
208
208 // Verify it's a copy
209 // Verify it's a copy
209 newItems[0] = 999
210 newItems[0] = 999
@@ -224,7 +225,7 @@ func TestSlice(t *testing.T) {
224 original := []int{1, 2, 3}
225 original := []int{1, 2, 3}
225 s := NewSliceFrom(original)
226 s := NewSliceFrom(original)
226
227
227− copy := s.Slice()
228+ copy := slices.Collect(s.Seq())
228 assert.Equal(t, original, copy)
229 assert.Equal(t, original, copy)
229
230
230 // Verify it's a copy
231 // Verify it's a copy
internal/format/spinner.go+2−2
@@ -20,7 +20,7 @@ type Spinner struct {
20
20
21type model struct {
21type model struct {
22 cancel context.CancelFunc
22 cancel context.CancelFunc
23− anim anim.Anim
23+ anim *anim.Anim
24}
24}
25
25
26func (m model) Init() tea.Cmd { return m.anim.Init() }
26func (m model) Init() tea.Cmd { return m.anim.Init() }
@@ -37,7 +37,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
37 }
37 }
38 }
38 }
39 mm, cmd := m.anim.Update(msg)
39 mm, cmd := m.anim.Update(msg)
40− m.anim = mm.(anim.Anim)
40+ m.anim = mm.(*anim.Anim)
41 return m, cmd
41 return m, cmd
42}
42}
43
43
internal/llm/tools/diagnostics.go+15−3
@@ -4,6 +4,7 @@ import (
4 "context"
4 "context"
5 "encoding/json"
5 "encoding/json"
6 "fmt"
6 "fmt"
7+ "log/slog"
7 "maps"
8 "maps"
8 "sort"
9 "sort"
9 "strings"
10 "strings"
@@ -118,7 +119,13 @@ func waitForLspDiagnostics(ctx context.Context, filePath string, lsps map[string
118 return
119 return
119 }
120 }
120
121
122+ path, err := diagParams.URI.Path()
123+ if err != nil {
124+ slog.Error("Failed to convert diagnostic URI to path", "uri", diagParams.URI, "error", err)
125+ return
126+ }
127+
121− if diagParams.URI.Path() == filePath || hasDiagnosticsChanged(client.GetDiagnostics(), originalDiags) {
128+ if path == filePath || hasDiagnosticsChanged(client.GetDiagnostics(), originalDiags) {
122 select {
129 select {
123 case diagChan <- struct{}{}:
130 case diagChan <- struct{}{}:
124 default:
131 default:
@@ -216,10 +223,15 @@ func getDiagnostics(filePath string, lsps map[string]*lsp.Client) string {
216 diagnostics := client.GetDiagnostics()
223 diagnostics := client.GetDiagnostics()
217 if len(diagnostics) > 0 {
224 if len(diagnostics) > 0 {
218 for location, diags := range diagnostics {
225 for location, diags := range diagnostics {
226+ path, err := location.Path()
227+ if err != nil {
228+ slog.Error("Failed to convert diagnostic location URI to path", "uri", location, "error", err)
229+ continue
230+ }
219− isCurrentFile := location.Path() == filePath
231+ isCurrentFile := path == filePath
220
232
221 for _, diag := range diags {
233 for _, diag := range diags {
222− formattedDiag := formatDiagnostic(location.Path(), diag, lspName)
234+ formattedDiag := formatDiagnostic(path, diag, lspName)
223
235
224 if isCurrentFile {
236 if isCurrentFile {
225 fileDiagnostics = append(fileDiagnostics, formattedDiag)
237 fileDiagnostics = append(fileDiagnostics, formattedDiag)
internal/lsp/client.go+11−2
@@ -449,7 +449,12 @@ func (c *Client) pingTypeScriptServer(ctx context.Context) error {
449
449
450 // If we have any open files, try to get document symbols for one
450 // If we have any open files, try to get document symbols for one
451 for uri := range c.openFiles {
451 for uri := range c.openFiles {
452− filePath := protocol.DocumentURI(uri).Path()
452+ filePath, err := protocol.DocumentURI(uri).Path()
453+ if err != nil {
454+ slog.Error("Failed to convert URI to path for TypeScript symbol collection", "uri", uri, "error", err)
455+ continue
456+ }
457+
453 if strings.HasSuffix(filePath, ".ts") || strings.HasSuffix(filePath, ".js") ||
458 if strings.HasSuffix(filePath, ".ts") || strings.HasSuffix(filePath, ".js") ||
454 strings.HasSuffix(filePath, ".tsx") || strings.HasSuffix(filePath, ".jsx") {
459 strings.HasSuffix(filePath, ".tsx") || strings.HasSuffix(filePath, ".jsx") {
455 var symbols []protocol.DocumentSymbol
460 var symbols []protocol.DocumentSymbol
@@ -712,7 +717,11 @@ func (c *Client) CloseAllFiles(ctx context.Context) {
712 // First collect all URIs that need to be closed
717 // First collect all URIs that need to be closed
713 for uri := range c.openFiles {
718 for uri := range c.openFiles {
714 // Convert URI back to file path using proper URI handling
719 // Convert URI back to file path using proper URI handling
715− filePath := protocol.DocumentURI(uri).Path()
720+ filePath, err := protocol.DocumentURI(uri).Path()
721+ if err != nil {
722+ slog.Error("Failed to convert URI to path for file closing", "uri", uri, "error", err)
723+ continue
724+ }
716 filesToClose = append(filesToClose, filePath)
725 filesToClose = append(filesToClose, filePath)
717 }
726 }
718 c.openFilesMu.Unlock()
727 c.openFilesMu.Unlock()
internal/lsp/protocol/pattern_interfaces.go+18−2
@@ -2,6 +2,7 @@ package protocol
2
2
3import (
3import (
4 "fmt"
4 "fmt"
5+ "log/slog"
5)
6)
6
7
7// PatternInfo is an interface for types that represent glob patterns
8// PatternInfo is an interface for types that represent glob patterns
@@ -36,21 +37,36 @@ func (g *GlobPattern) AsPattern() (PatternInfo, error) {
36 return nil, fmt.Errorf("nil pattern")
37 return nil, fmt.Errorf("nil pattern")
37 }
38 }
38
39
40+ var err error
41+
39 switch v := g.Value.(type) {
42 switch v := g.Value.(type) {
40 case string:
43 case string:
41 return StringPattern{Pattern: v}, nil
44 return StringPattern{Pattern: v}, nil
45+
42 case RelativePattern:
46 case RelativePattern:
43 // Handle BaseURI which could be string or DocumentUri
47 // Handle BaseURI which could be string or DocumentUri
44 basePath := ""
48 basePath := ""
45 switch baseURI := v.BaseURI.Value.(type) {
49 switch baseURI := v.BaseURI.Value.(type) {
46 case string:
50 case string:
47− basePath = DocumentURI(baseURI).Path()
51+ basePath, err = DocumentURI(baseURI).Path()
52+ if err != nil {
53+ slog.Error("Failed to convert URI to path", "uri", baseURI, "error", err)
54+ return nil, fmt.Errorf("invalid URI: %s", baseURI)
55+ }
56+
48 case DocumentURI:
57 case DocumentURI:
49− basePath = baseURI.Path()
58+ basePath, err = baseURI.Path()
59+ if err != nil {
60+ slog.Error("Failed to convert DocumentURI to path", "uri", baseURI, "error", err)
61+ return nil, fmt.Errorf("invalid DocumentURI: %s", baseURI)
62+ }
63+
50 default:
64 default:
51 return nil, fmt.Errorf("unknown BaseURI type: %T", v.BaseURI.Value)
65 return nil, fmt.Errorf("unknown BaseURI type: %T", v.BaseURI.Value)
52 }
66 }
67+
53 return RelativePatternInfo{RP: v, BasePath: basePath}, nil
68 return RelativePatternInfo{RP: v, BasePath: basePath}, nil
69+
54 default:
70 default:
55 return nil, fmt.Errorf("unknown pattern type: %T", g.Value)
71 return nil, fmt.Errorf("unknown pattern type: %T", g.Value)
56 }
72 }
internal/lsp/protocol/uri.go+18−7
@@ -70,7 +70,7 @@ func (uri *DocumentURI) UnmarshalText(data []byte) (err error) {
70// DocumentUri("").Path() returns the empty string.
70// DocumentUri("").Path() returns the empty string.
71//
71//
72// Path panics if called on a URI that is not a valid filename.
72// Path panics if called on a URI that is not a valid filename.
73−func (uri DocumentURI) Path() string {
73+func (uri DocumentURI) Path() (string, error) {
74 filename, err := filename(uri)
74 filename, err := filename(uri)
75 if err != nil {
75 if err != nil {
76 // e.g. ParseRequestURI failed.
76 // e.g. ParseRequestURI failed.
@@ -79,22 +79,33 @@ func (uri DocumentURI) Path() string {
79 // direct string manipulation; all DocumentUris
79 // direct string manipulation; all DocumentUris
80 // received from the client pass through
80 // received from the client pass through
81 // ParseRequestURI, which ensures validity.
81 // ParseRequestURI, which ensures validity.
82− panic(err)
82+ return "", fmt.Errorf("invalid URI %q: %w", uri, err)
83 }
83 }
84− return filepath.FromSlash(filename)
84+ return filepath.FromSlash(filename), nil
85}
85}
86
86
87// Dir returns the URI for the directory containing the receiver.
87// Dir returns the URI for the directory containing the receiver.
88−func (uri DocumentURI) Dir() DocumentURI {
88+func (uri DocumentURI) Dir() (DocumentURI, error) {
89+ // XXX: Legacy comment:
89 // This function could be more efficiently implemented by avoiding any call
90 // This function could be more efficiently implemented by avoiding any call
90 // to Path(), but at least consolidates URI manipulation.
91 // to Path(), but at least consolidates URI manipulation.
92+
93+ path, err := uri.DirPath()
94+ if err != nil {
95+ return "", fmt.Errorf("invalid URI %q: %w", uri, err)
96+ }
97+
91− return URIFromPath(uri.DirPath())
98+ return URIFromPath(path), nil
92}
99}
93
100
94// DirPath returns the file path to the directory containing this URI, which
101// DirPath returns the file path to the directory containing this URI, which
95// must be a file URI.
102// must be a file URI.
96−func (uri DocumentURI) DirPath() string {
103+func (uri DocumentURI) DirPath() (string, error) {
104+ path, err := uri.Path()
105+ if err != nil {
106+ return "", err
107+ }
97− return filepath.Dir(uri.Path())
108+ return filepath.Dir(path), nil
98}
109}
99
110
100func filename(uri DocumentURI) (string, error) {
111func filename(uri DocumentURI) (string, error) {
internal/lsp/util/edit.go+27−5
@@ -11,7 +11,10 @@ import (
11)
11)
12
12
13func applyTextEdits(uri protocol.DocumentURI, edits []protocol.TextEdit) error {
13func applyTextEdits(uri protocol.DocumentURI, edits []protocol.TextEdit) error {
14− path := uri.Path()
14+ path, err := uri.Path()
15+ if err != nil {
16+ return fmt.Errorf("invalid URI: %w", err)
17+ }
15
18
16 // Read the file content
19 // Read the file content
17 content, err := os.ReadFile(path)
20 content, err := os.ReadFile(path)
@@ -148,7 +151,11 @@ func applyTextEdit(lines []string, edit protocol.TextEdit) ([]string, error) {
148// applyDocumentChange applies a DocumentChange (create/rename/delete operations)
151// applyDocumentChange applies a DocumentChange (create/rename/delete operations)
149func applyDocumentChange(change protocol.DocumentChange) error {
152func applyDocumentChange(change protocol.DocumentChange) error {
150 if change.CreateFile != nil {
153 if change.CreateFile != nil {
151− path := change.CreateFile.URI.Path()
154+ path, err := change.CreateFile.URI.Path()
155+ if err != nil {
156+ return fmt.Errorf("invalid URI: %w", err)
157+ }
158+
152 if change.CreateFile.Options != nil {
159 if change.CreateFile.Options != nil {
153 if change.CreateFile.Options.Overwrite {
160 if change.CreateFile.Options.Overwrite {
154 // Proceed with overwrite
161 // Proceed with overwrite
@@ -164,7 +171,11 @@ func applyDocumentChange(change protocol.DocumentChange) error {
164 }
171 }
165
172
166 if change.DeleteFile != nil {
173 if change.DeleteFile != nil {
167− path := change.DeleteFile.URI.Path()
174+ path, err := change.DeleteFile.URI.Path()
175+ if err != nil {
176+ return fmt.Errorf("invalid URI: %w", err)
177+ }
178+
168 if change.DeleteFile.Options != nil && change.DeleteFile.Options.Recursive {
179 if change.DeleteFile.Options != nil && change.DeleteFile.Options.Recursive {
169 if err := os.RemoveAll(path); err != nil {
180 if err := os.RemoveAll(path); err != nil {
170 return fmt.Errorf("failed to delete directory recursively: %w", err)
181 return fmt.Errorf("failed to delete directory recursively: %w", err)
@@ -177,8 +188,19 @@ func applyDocumentChange(change protocol.DocumentChange) error {
177 }
188 }
178
189
179 if change.RenameFile != nil {
190 if change.RenameFile != nil {
191+ var newPath, oldPath string
192+ var err error
193+
180− oldPath := change.RenameFile.OldURI.Path()
194+ oldPath, err = change.RenameFile.OldURI.Path()
195+ if err != nil {
196+ return err
197+ }
198+
181− newPath := change.RenameFile.NewURI.Path()
199+ newPath, err = change.RenameFile.NewURI.Path()
200+ if err != nil {
201+ return err
202+ }
203+
182 if change.RenameFile.Options != nil {
204 if change.RenameFile.Options != nil {
183 if !change.RenameFile.Options.Overwrite {
205 if !change.RenameFile.Options.Overwrite {
184 if _, err := os.Stat(newPath); err == nil {
206 if _, err := os.Stat(newPath); err == nil {
internal/lsp/watcher/watcher.go+12−2
@@ -617,7 +617,11 @@ func (w *WorkspaceWatcher) matchesPattern(path string, pattern protocol.GlobPatt
617 return false
617 return false
618 }
618 }
619 // For relative patterns
619 // For relative patterns
620− basePath = protocol.DocumentURI(basePath).Path()
620+ if basePath, err = protocol.DocumentURI(basePath).Path(); err != nil {
621+ // XXX: Do we want to return here, or send the error up the stack?
622+ slog.Error("Error converting base path to URI", "basePath", basePath, "error", err)
623+ }
624+
621 basePath = filepath.ToSlash(basePath)
625 basePath = filepath.ToSlash(basePath)
622
626
623 // Make path relative to basePath for matching
627 // Make path relative to basePath for matching
@@ -660,7 +664,13 @@ func (w *WorkspaceWatcher) debounceHandleFileEvent(ctx context.Context, uri stri
660// handleFileEvent sends file change notifications
664// handleFileEvent sends file change notifications
661func (w *WorkspaceWatcher) handleFileEvent(ctx context.Context, uri string, changeType protocol.FileChangeType) {
665func (w *WorkspaceWatcher) handleFileEvent(ctx context.Context, uri string, changeType protocol.FileChangeType) {
662 // If the file is open and it's a change event, use didChange notification
666 // If the file is open and it's a change event, use didChange notification
663− filePath := protocol.DocumentURI(uri).Path()
667+ filePath, err := protocol.DocumentURI(uri).Path()
668+ if err != nil {
669+ // XXX: Do we want to return here, or send the error up the stack?
670+ slog.Error("Error converting URI to path", "uri", uri, "error", err)
671+ return
672+ }
673+
664 if changeType == protocol.FileChangeType(protocol.Deleted) {
674 if changeType == protocol.FileChangeType(protocol.Deleted) {
665 w.client.ClearDiagnosticsForURI(protocol.DocumentURI(uri))
675 w.client.ClearDiagnosticsForURI(protocol.DocumentURI(uri))
666 } else if changeType == protocol.FileChangeType(protocol.Changed) && w.client.IsFileOpen(filePath) {
676 } else if changeType == protocol.FileChangeType(protocol.Changed) && w.client.IsFileOpen(filePath) {
internal/tui/components/anim/anim.go+61−50
@@ -6,7 +6,6 @@ import (
6 "image/color"
6 "image/color"
7 "math/rand/v2"
7 "math/rand/v2"
8 "strings"
8 "strings"
9− "sync"
10 "sync/atomic"
9 "sync/atomic"
11 "time"
10 "time"
12
11
@@ -15,6 +14,8 @@ import (
15 tea "github.com/charmbracelet/bubbletea/v2"
14 tea "github.com/charmbracelet/bubbletea/v2"
16 "github.com/charmbracelet/lipgloss/v2"
15 "github.com/charmbracelet/lipgloss/v2"
17 "github.com/lucasb-eyer/go-colorful"
16 "github.com/lucasb-eyer/go-colorful"
17+
18+ "github.com/charmbracelet/crush/internal/csync"
18)
19)
19
20
20const (
21const (
@@ -72,10 +73,7 @@ type animCache struct {
72 ellipsisFrames []string
73 ellipsisFrames []string
73}
74}
74
75
75−var (
76− animCacheMutex sync.RWMutex
77− animCacheMap = make(map[string]*animCache)
76+var animCacheMap = csync.NewMap[string, *animCache]()
78−)
79
77
80// settingsHash creates a hash key for the settings to use for caching
78// settingsHash creates a hash key for the settings to use for caching
81func settingsHash(opts Settings) string {
79func settingsHash(opts Settings) string {
@@ -105,22 +103,23 @@ const ()
105type Anim struct {
103type Anim struct {
106 width int
104 width int
107 cyclingCharWidth int
105 cyclingCharWidth int
108− label []string
106+ label *csync.Slice[string]
109 labelWidth int
107 labelWidth int
110 labelColor color.Color
108 labelColor color.Color
111 startTime time.Time
109 startTime time.Time
112 birthOffsets []time.Duration
110 birthOffsets []time.Duration
113 initialFrames [][]string // frames for the initial characters
111 initialFrames [][]string // frames for the initial characters
114− initialized bool
112+ initialized atomic.Bool
115− cyclingFrames [][]string // frames for the cycling characters
113+ cyclingFrames [][]string // frames for the cycling characters
116− step int // current main frame step
114+ step atomic.Int64 // current main frame step
117− ellipsisStep int // current ellipsis frame step
115+ ellipsisStep atomic.Int64 // current ellipsis frame step
118− ellipsisFrames []string // ellipsis animation frames
116+ ellipsisFrames *csync.Slice[string] // ellipsis animation frames
119 id int
117 id int
120}
118}
121
119
122// New creates a new Anim instance with the specified width and label.
120// New creates a new Anim instance with the specified width and label.
123−func New(opts Settings) (a Anim) {
121+func New(opts Settings) *Anim {
122+ a := &Anim{}
124 // Validate settings.
123 // Validate settings.
125 if opts.Size < 1 {
124 if opts.Size < 1 {
126 opts.Size = defaultNumCyclingChars
125 opts.Size = defaultNumCyclingChars
@@ -142,16 +141,14 @@ func New(opts Settings) (a Anim) {
142
141
143 // Check cache first
142 // Check cache first
144 cacheKey := settingsHash(opts)
143 cacheKey := settingsHash(opts)
145− animCacheMutex.RLock()
146− cached, exists := animCacheMap[cacheKey]
144+ cached, exists := animCacheMap.Get(cacheKey)
147− animCacheMutex.RUnlock()
148
145
149 if exists {
146 if exists {
150 // Use cached values
147 // Use cached values
151 a.width = cached.width
148 a.width = cached.width
152 a.labelWidth = cached.labelWidth
149 a.labelWidth = cached.labelWidth
153− a.label = cached.label
150+ a.label = csync.NewSliceFrom(cached.label)
154− a.ellipsisFrames = cached.ellipsisFrames
151+ a.ellipsisFrames = csync.NewSliceFrom(cached.ellipsisFrames)
155 a.initialFrames = cached.initialFrames
152 a.initialFrames = cached.initialFrames
156 a.cyclingFrames = cached.cyclingFrames
153 a.cyclingFrames = cached.cyclingFrames
157 } else {
154 } else {
@@ -228,17 +225,23 @@ func New(opts Settings) (a Anim) {
228 }
225 }
229
226
230 // Cache the results
227 // Cache the results
228+ labelSlice := make([]string, a.label.Len())
229+ for i, v := range a.label.Seq2() {
230+ labelSlice[i] = v
231+ }
232+ ellipsisSlice := make([]string, a.ellipsisFrames.Len())
233+ for i, v := range a.ellipsisFrames.Seq2() {
234+ ellipsisSlice[i] = v
235+ }
231 cached = &animCache{
236 cached = &animCache{
232 initialFrames: a.initialFrames,
237 initialFrames: a.initialFrames,
233 cyclingFrames: a.cyclingFrames,
238 cyclingFrames: a.cyclingFrames,
234 width: a.width,
239 width: a.width,
235 labelWidth: a.labelWidth,
240 labelWidth: a.labelWidth,
236− label: a.label,
241+ label: labelSlice,
237− ellipsisFrames: a.ellipsisFrames,
242+ ellipsisFrames: ellipsisSlice,
238 }
243 }
239− animCacheMutex.Lock()
240− animCacheMap[cacheKey] = cached
244+ animCacheMap.Set(cacheKey, cached)
241− animCacheMutex.Unlock()
242 }
245 }
243
246
244 // Random assign a birth to each character for a stagged entrance effect.
247 // Random assign a birth to each character for a stagged entrance effect.
@@ -269,28 +272,30 @@ func (a *Anim) renderLabel(label string) {
269 if a.labelWidth > 0 {
272 if a.labelWidth > 0 {
270 // Pre-render the label.
273 // Pre-render the label.
271 labelRunes := []rune(label)
274 labelRunes := []rune(label)
272− a.label = make([]string, len(labelRunes))
275+ a.label = csync.NewSlice[string]()
273− for i := range a.label {
276+ for i := range labelRunes {
274− a.label[i] = lipgloss.NewStyle().
277+ rendered := lipgloss.NewStyle().
275 Foreground(a.labelColor).
278 Foreground(a.labelColor).
276 Render(string(labelRunes[i]))
279 Render(string(labelRunes[i]))
280+ a.label.Append(rendered)
277 }
281 }
278
282
279 // Pre-render the ellipsis frames which come after the label.
283 // Pre-render the ellipsis frames which come after the label.
280− a.ellipsisFrames = make([]string, len(ellipsisFrames))
284+ a.ellipsisFrames = csync.NewSlice[string]()
281− for i, frame := range ellipsisFrames {
285+ for _, frame := range ellipsisFrames {
282− a.ellipsisFrames[i] = lipgloss.NewStyle().
286+ rendered := lipgloss.NewStyle().
283 Foreground(a.labelColor).
287 Foreground(a.labelColor).
284 Render(frame)
288 Render(frame)
289+ a.ellipsisFrames.Append(rendered)
285 }
290 }
286 } else {
291 } else {
287− a.label = nil
292+ a.label = csync.NewSlice[string]()
288− a.ellipsisFrames = nil
293+ a.ellipsisFrames = csync.NewSlice[string]()
289 }
294 }
290}
295}
291
296
292// Width returns the total width of the animation.
297// Width returns the total width of the animation.
293−func (a Anim) Width() (w int) {
298+func (a *Anim) Width() (w int) {
294 w = a.width
299 w = a.width
295 if a.labelWidth > 0 {
300 if a.labelWidth > 0 {
296 w += labelGapWidth + a.labelWidth
301 w += labelGapWidth + a.labelWidth
@@ -308,12 +313,12 @@ func (a Anim) Width() (w int) {
308}
313}
309
314
310// Init starts the animation.
315// Init starts the animation.
311−func (a Anim) Init() tea.Cmd {
316+func (a *Anim) Init() tea.Cmd {
312 return a.Step()
317 return a.Step()
313}
318}
314
319
315// Update processes animation steps (or not).
320// Update processes animation steps (or not).
316−func (a Anim) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
321+func (a *Anim) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
317 switch msg := msg.(type) {
322 switch msg := msg.(type) {
318 case StepMsg:
323 case StepMsg:
319 if msg.id != a.id {
324 if msg.id != a.id {
@@ -321,19 +326,19 @@ func (a Anim) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
321 return a, nil
326 return a, nil
322 }
327 }
323
328
324− a.step++
329+ step := a.step.Add(1)
325− if a.step >= len(a.cyclingFrames) {
330+ if int(step) >= len(a.cyclingFrames) {
326− a.step = 0
331+ a.step.Store(0)
327 }
332 }
328
333
329− if a.initialized && a.labelWidth > 0 {
334+ if a.initialized.Load() && a.labelWidth > 0 {
330 // Manage the ellipsis animation.
335 // Manage the ellipsis animation.
331− a.ellipsisStep++
336+ ellipsisStep := a.ellipsisStep.Add(1)
332− if a.ellipsisStep >= ellipsisAnimSpeed*len(ellipsisFrames) {
337+ if int(ellipsisStep) >= ellipsisAnimSpeed*len(ellipsisFrames) {
333− a.ellipsisStep = 0
338+ a.ellipsisStep.Store(0)
334 }
339 }
335− } else if !a.initialized && time.Since(a.startTime) >= maxBirthOffset {
340+ } else if !a.initialized.Load() && time.Since(a.startTime) >= maxBirthOffset {
336− a.initialized = true
341+ a.initialized.Store(true)
337 }
342 }
338 return a, a.Step()
343 return a, a.Step()
339 default:
344 default:
@@ -342,35 +347,41 @@ func (a Anim) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
342}
347}
343
348
344// View renders the current state of the animation.
349// View renders the current state of the animation.
345−func (a Anim) View() string {
350+func (a *Anim) View() string {
346 var b strings.Builder
351 var b strings.Builder
352+ step := int(a.step.Load())
347 for i := range a.width {
353 for i := range a.width {
348 switch {
354 switch {
349− case !a.initialized && i < len(a.birthOffsets) && time.Since(a.startTime) < a.birthOffsets[i]:
355+ case !a.initialized.Load() && i < len(a.birthOffsets) && time.Since(a.startTime) < a.birthOffsets[i]:
350 // Birth offset not reached: render initial character.
356 // Birth offset not reached: render initial character.
351− b.WriteString(a.initialFrames[a.step][i])
357+ b.WriteString(a.initialFrames[step][i])
352 case i < a.cyclingCharWidth:
358 case i < a.cyclingCharWidth:
353 // Render a cycling character.
359 // Render a cycling character.
354− b.WriteString(a.cyclingFrames[a.step][i])
360+ b.WriteString(a.cyclingFrames[step][i])
355 case i == a.cyclingCharWidth:
361 case i == a.cyclingCharWidth:
356 // Render label gap.
362 // Render label gap.
357 b.WriteString(labelGap)
363 b.WriteString(labelGap)
358 case i > a.cyclingCharWidth:
364 case i > a.cyclingCharWidth:
359 // Label.
365 // Label.
360− b.WriteString(a.label[i-a.cyclingCharWidth-labelGapWidth])
366+ if labelChar, ok := a.label.Get(i - a.cyclingCharWidth - labelGapWidth); ok {
367+ b.WriteString(labelChar)
368+ }
361 }
369 }
362 }
370 }
363 // Render animated ellipsis at the end of the label if all characters
371 // Render animated ellipsis at the end of the label if all characters
364 // have been initialized.
372 // have been initialized.
365− if a.initialized && a.labelWidth > 0 {
373+ if a.initialized.Load() && a.labelWidth > 0 {
374+ ellipsisStep := int(a.ellipsisStep.Load())
366− b.WriteString(a.ellipsisFrames[a.ellipsisStep/ellipsisAnimSpeed])
375+ if ellipsisFrame, ok := a.ellipsisFrames.Get(ellipsisStep / ellipsisAnimSpeed); ok {
376+ b.WriteString(ellipsisFrame)
377+ }
367 }
378 }
368
379
369 return b.String()
380 return b.String()
370}
381}
371
382
372// Step is a command that triggers the next step in the animation.
383// Step is a command that triggers the next step in the animation.
373−func (a Anim) Step() tea.Cmd {
384+func (a *Anim) Step() tea.Cmd {
374 return tea.Tick(time.Second/time.Duration(fps), func(t time.Time) tea.Msg {
385 return tea.Tick(time.Second/time.Duration(fps), func(t time.Time) tea.Msg {
375 return StepMsg{id: a.id}
386 return StepMsg{id: a.id}
376 })
387 })
internal/tui/components/anim/example/main.go+1−1
@@ -56,7 +56,7 @@ func (m model) View() tea.View {
56 return v
56 return v
57 }
57 }
58
58
59− if a, ok := m.anim.(anim.Anim); ok {
59+ if a, ok := m.anim.(*anim.Anim); ok {
60 l := lipgloss.NewLayer(a.View()).
60 l := lipgloss.NewLayer(a.View()).
61 Width(a.Width()).
61 Width(a.Width()).
62 X(m.w/2 - a.Width()/2).
62 X(m.w/2 - a.Width()/2).
internal/tui/components/chat/messages/messages.go+2−2
@@ -49,7 +49,7 @@ type messageCmp struct {
49 // Core message data and state
49 // Core message data and state
50 message message.Message // The underlying message content
50 message message.Message // The underlying message content
51 spinning bool // Whether to show loading animation
51 spinning bool // Whether to show loading animation
52− anim anim.Anim // Animation component for loading states
52+ anim *anim.Anim // Animation component for loading states
53
53
54 // Thinking viewport for displaying reasoning content
54 // Thinking viewport for displaying reasoning content
55 thinkingViewport viewport.Model
55 thinkingViewport viewport.Model
@@ -95,7 +95,7 @@ func (m *messageCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
95 m.spinning = m.shouldSpin()
95 m.spinning = m.shouldSpin()
96 if m.spinning {
96 if m.spinning {
97 u, cmd := m.anim.Update(msg)
97 u, cmd := m.anim.Update(msg)
98− m.anim = u.(anim.Anim)
98+ m.anim = u.(*anim.Anim)
99 return m, cmd
99 return m, cmd
100 }
100 }
101 case tea.KeyPressMsg:
101 case tea.KeyPressMsg:
internal/tui/components/chat/messages/tool.go+1−1
@@ -778,7 +778,7 @@ func (m *toolCallCmp) SetSize(width int, height int) tea.Cmd {
778// shouldSpin determines whether the tool call should show a loading animation.
778// shouldSpin determines whether the tool call should show a loading animation.
779// Returns true if the tool call is not finished or if the result doesn't match the call ID.
779// Returns true if the tool call is not finished or if the result doesn't match the call ID.
780func (m *toolCallCmp) shouldSpin() bool {
780func (m *toolCallCmp) shouldSpin() bool {
781− return !m.call.Finished
781+ return !m.call.Finished && !m.cancelled
782}
782}
783
783
784// Spinning returns whether the tool call is currently showing a loading animation
784// Spinning returns whether the tool call is currently showing a loading animation
internal/tui/components/chat/sidebar/sidebar.go+1
@@ -614,6 +614,7 @@ func (m *sidebarCmp) mcpBlockCompact(maxWidth int) string {
614}
614}
615
615
616func (m *sidebarCmp) filesBlock() string {
616func (m *sidebarCmp) filesBlock() string {
617+ return ""
617 t := styles.CurrentTheme()
618 t := styles.CurrentTheme()
618
619
619 section := t.S().Subtle.Render(
620 section := t.S().Subtle.Render(
internal/tui/exp/list/filterable.go+1−1
@@ -97,7 +97,7 @@ func NewFilterableList[T FilterableItem](items []T, opts ...filterableListOption
97 f.list = New(items, f.listOptions...).(*list[T])
97 f.list = New(items, f.listOptions...).(*list[T])
98
98
99 f.updateKeyMaps()
99 f.updateKeyMaps()
100− f.items = f.list.items.Slice()
100+ f.items = slices.Collect(f.list.items.Seq())
101
101
102 if f.inputHidden {
102 if f.inputHidden {
103 return f
103 return f
internal/tui/exp/list/filterable_group.go+2−1
@@ -2,6 +2,7 @@ package list
2
2
3import (
3import (
4 "regexp"
4 "regexp"
5+ "slices"
5 "sort"
6 "sort"
6 "strings"
7 "strings"
7
8
@@ -179,7 +180,7 @@ func (f *filterableGroupList[T]) inputHeight() int {
179
180
180func (f *filterableGroupList[T]) Filter(query string) tea.Cmd {
181func (f *filterableGroupList[T]) Filter(query string) tea.Cmd {
181 var cmds []tea.Cmd
182 var cmds []tea.Cmd
182− for _, item := range f.items.Slice() {
183+ for _, item := range slices.Collect(f.items.Seq()) {
183 if i, ok := any(item).(layout.Focusable); ok {
184 if i, ok := any(item).(layout.Focusable); ok {
184 cmds = append(cmds, i.Blur())
185 cmds = append(cmds, i.Blur())
185 }
186 }
internal/tui/exp/list/grouped.go+3−1
@@ -1,6 +1,8 @@
1package list
1package list
2
2
3import (
3import (
4+ "slices"
5+
4 tea "github.com/charmbracelet/bubbletea/v2"
6 tea "github.com/charmbracelet/bubbletea/v2"
5 "github.com/charmbracelet/crush/internal/csync"
7 "github.com/charmbracelet/crush/internal/csync"
6 "github.com/charmbracelet/crush/internal/tui/components/core/layout"
8 "github.com/charmbracelet/crush/internal/tui/components/core/layout"
@@ -89,7 +91,7 @@ func (g *groupedList[T]) convertItems() {
89func (g *groupedList[T]) SetGroups(groups []Group[T]) tea.Cmd {
91func (g *groupedList[T]) SetGroups(groups []Group[T]) tea.Cmd {
90 g.groups = groups
92 g.groups = groups
91 g.convertItems()
93 g.convertItems()
92− return g.SetItems(g.items.Slice())
94+ return g.SetItems(slices.Collect(g.items.Seq()))
93}
95}
94
96
95func (g *groupedList[T]) Groups() []Group[T] {
97func (g *groupedList[T]) Groups() []Group[T] {
internal/tui/exp/list/list.go+11−10
@@ -1,6 +1,7 @@
1package list
1package list
2
2
3import (
3import (
4+ "slices"
4 "strings"
5 "strings"
5
6
6 "github.com/charmbracelet/bubbles/v2/key"
7 "github.com/charmbracelet/bubbles/v2/key"
@@ -201,7 +202,7 @@ func (l *list[T]) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
201 return l, nil
202 return l, nil
202 case anim.StepMsg:
203 case anim.StepMsg:
203 var cmds []tea.Cmd
204 var cmds []tea.Cmd
204− for _, item := range l.items.Slice() {
205+ for _, item := range slices.Collect(l.items.Seq()) {
205 if i, ok := any(item).(HasAnim); ok && i.Spinning() {
206 if i, ok := any(item).(HasAnim); ok && i.Spinning() {
206 updated, cmd := i.Update(msg)
207 updated, cmd := i.Update(msg)
207 cmds = append(cmds, cmd)
208 cmds = append(cmds, cmd)
@@ -300,7 +301,7 @@ func (l *list[T]) viewPosition() (int, int) {
300
301
301func (l *list[T]) recalculateItemPositions() {
302func (l *list[T]) recalculateItemPositions() {
302 currentContentHeight := 0
303 currentContentHeight := 0
303− for _, item := range l.items.Slice() {
304+ for _, item := range slices.Collect(l.items.Seq()) {
304 rItem, ok := l.renderedItems.Get(item.ID())
305 rItem, ok := l.renderedItems.Get(item.ID())
305 if !ok {
306 if !ok {
306 continue
307 continue
@@ -573,7 +574,7 @@ func (l *list[T]) focusSelectedItem() tea.Cmd {
573 return nil
574 return nil
574 }
575 }
575 var cmds []tea.Cmd
576 var cmds []tea.Cmd
576− for _, item := range l.items.Slice() {
577+ for _, item := range slices.Collect(l.items.Seq()) {
577 if f, ok := any(item).(layout.Focusable); ok {
578 if f, ok := any(item).(layout.Focusable); ok {
578 if item.ID() == l.selectedItem && !f.IsFocused() {
579 if item.ID() == l.selectedItem && !f.IsFocused() {
579 cmds = append(cmds, f.Focus())
580 cmds = append(cmds, f.Focus())
@@ -592,7 +593,7 @@ func (l *list[T]) blurSelectedItem() tea.Cmd {
592 return nil
593 return nil
593 }
594 }
594 var cmds []tea.Cmd
595 var cmds []tea.Cmd
595− for _, item := range l.items.Slice() {
596+ for _, item := range slices.Collect(l.items.Seq()) {
596 if f, ok := any(item).(layout.Focusable); ok {
597 if f, ok := any(item).(layout.Focusable); ok {
597 if item.ID() == l.selectedItem && f.IsFocused() {
598 if item.ID() == l.selectedItem && f.IsFocused() {
598 cmds = append(cmds, f.Blur())
599 cmds = append(cmds, f.Blur())
@@ -667,7 +668,7 @@ func (l *list[T]) AppendItem(item T) tea.Cmd {
667
668
668 l.items.Append(item)
669 l.items.Append(item)
669 l.indexMap = csync.NewMap[string, int]()
670 l.indexMap = csync.NewMap[string, int]()
670− for inx, item := range l.items.Slice() {
671+ for inx, item := range slices.Collect(l.items.Seq()) {
671 l.indexMap.Set(item.ID(), inx)
672 l.indexMap.Set(item.ID(), inx)
672 }
673 }
673 if l.width > 0 && l.height > 0 {
674 if l.width > 0 && l.height > 0 {
@@ -714,7 +715,7 @@ func (l *list[T]) DeleteItem(id string) tea.Cmd {
714 }
715 }
715 l.items.Delete(inx)
716 l.items.Delete(inx)
716 l.renderedItems.Del(id)
717 l.renderedItems.Del(id)
717− for inx, item := range l.items.Slice() {
718+ for inx, item := range slices.Collect(l.items.Seq()) {
718 l.indexMap.Set(item.ID(), inx)
719 l.indexMap.Set(item.ID(), inx)
719 }
720 }
720
721
@@ -779,7 +780,7 @@ func (l *list[T]) IsFocused() bool {
779
780
780// Items implements List.
781// Items implements List.
781func (l *list[T]) Items() []T {
782func (l *list[T]) Items() []T {
782− return l.items.Slice()
783+ return slices.Collect(l.items.Seq())
783}
784}
784
785
785func (l *list[T]) incrementOffset(n int) {
786func (l *list[T]) incrementOffset(n int) {
@@ -834,7 +835,7 @@ func (l *list[T]) PrependItem(item T) tea.Cmd {
834 }
835 }
835 l.items.Prepend(item)
836 l.items.Prepend(item)
836 l.indexMap = csync.NewMap[string, int]()
837 l.indexMap = csync.NewMap[string, int]()
837− for inx, item := range l.items.Slice() {
838+ for inx, item := range slices.Collect(l.items.Seq()) {
838 l.indexMap.Set(item.ID(), inx)
839 l.indexMap.Set(item.ID(), inx)
839 }
840 }
840 if l.width > 0 && l.height > 0 {
841 if l.width > 0 && l.height > 0 {
@@ -938,7 +939,7 @@ func (l *list[T]) SelectedItem() *T {
938func (l *list[T]) SetItems(items []T) tea.Cmd {
939func (l *list[T]) SetItems(items []T) tea.Cmd {
939 l.items.SetSlice(items)
940 l.items.SetSlice(items)
940 var cmds []tea.Cmd
941 var cmds []tea.Cmd
941− for inx, item := range l.items.Slice() {
942+ for inx, item := range slices.Collect(l.items.Seq()) {
942 if i, ok := any(item).(Indexable); ok {
943 if i, ok := any(item).(Indexable); ok {
943 i.SetIndex(inx)
944 i.SetIndex(inx)
944 }
945 }
@@ -961,7 +962,7 @@ func (l *list[T]) reset(selectedItem string) tea.Cmd {
961 l.selectedItem = selectedItem
962 l.selectedItem = selectedItem
962 l.indexMap = csync.NewMap[string, int]()
963 l.indexMap = csync.NewMap[string, int]()
963 l.renderedItems = csync.NewMap[string, renderedItem]()
964 l.renderedItems = csync.NewMap[string, renderedItem]()
964− for inx, item := range l.items.Slice() {
965+ for inx, item := range slices.Collect(l.items.Seq()) {
965 l.indexMap.Set(item.ID(), inx)
966 l.indexMap.Set(item.ID(), inx)
966 if l.width > 0 && l.height > 0 {
967 if l.width > 0 && l.height > 0 {
967 cmds = append(cmds, item.SetSize(l.width, l.height))
968 cmds = append(cmds, item.SetSize(l.width, l.height))
internal/tui/page/chat/chat.go+1−1
@@ -860,7 +860,7 @@ func (p *chatPage) Help() help.KeyMap {
860 ),
860 ),
861 key.NewBinding(
861 key.NewBinding(
862 key.WithKeys("g", "home"),
862 key.WithKeys("g", "home"),
863− key.WithHelp("g", "hone"),
863+ key.WithHelp("g", "home"),
864 ),
864 ),
865 key.NewBinding(
865 key.NewBinding(
866 key.WithKeys("G", "end"),
866 key.WithKeys("G", "end"),