on main help sign in
Merge branch 'list' into tool_improvements
commit446ba99acc9b408f77868d16c72faf0179fd8eb8 authorKujtim Hoxha <kujtimii.h@gmail.com> dateSat 26 Jul 2025, 12:51 (1 year ago) parent367e381ff3c926
22 files changed+226−109
.goreleaser.yml+27 internal/config/load.go+1−1 internal/config/provider.go+2 internal/csync/slices.go+5−14 internal/csync/slices_test.go+4−3 internal/format/spinner.go+2−2 internal/llm/tools/diagnostics.go+15−3 internal/lsp/client.go+11−2 internal/lsp/protocol/pattern_interfaces.go+18−2 internal/lsp/protocol/uri.go+18−7 internal/lsp/util/edit.go+27−5 internal/lsp/watcher/watcher.go+12−2 internal/tui/components/anim/anim.go+61−50 internal/tui/components/anim/example/main.go+1−1 internal/tui/components/chat/messages/messages.go+2−2 internal/tui/components/chat/messages/tool.go+1−1 internal/tui/components/chat/sidebar/sidebar.go+1 internal/tui/exp/list/filterable.go+1−1 internal/tui/exp/list/filterable_group.go+2−1 internal/tui/exp/list/grouped.go+3−1 internal/tui/exp/list/list.go+11−10 internal/tui/page/chat/chat.go+1−1
.goreleaser.yml+27
@@ -110,6 +110,7 @@ homebrew_casks:
110110 - repository:
111111 owner: charmbracelet
112112 name: homebrew-tap
113+ token: "{{ .Env.HOMEBREW_TAP_GITHUB_TOKEN }}"
113114
114115npms:
115116 - name: "@charmland/crush"
@@ -134,6 +135,32 @@ nfpms:
134135 - src: ./manpages/crush.1.gz
135136 dst: /usr/share/man/man1/crush.1.gz
136137
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+
137164changelog:
138165 sort: asc
139166 disable: "{{ .IsNightly }}"
internal/config/load.go+1−1
@@ -17,7 +17,7 @@ import (
1717 "github.com/charmbracelet/crush/internal/log"
1818)
1919
20−const catwalkURL = "https://catwalk.charm.sh"
20+const defaultCatwalkURL = "https://catwalk.charm.sh"
2121
2222// LoadReader config via io.Reader.
2323func LoadReader(fd io.Reader) (*Config, error) {
internal/config/provider.go+2
@@ -1,6 +1,7 @@
11package config
22
33import (
4+ "cmp"
45 "encoding/json"
56 "fmt"
67 "log/slog"
@@ -74,6 +75,7 @@ func loadProvidersFromCache(path string) ([]catwalk.Provider, error) {
7475}
7576
7677func Providers() ([]catwalk.Provider, error) {
78+ catwalkURL := cmp.Or(os.Getenv("CATWALK_URL"), defaultCatwalkURL)
7779 client := catwalk.NewWithURL(catwalkURL)
7880 path := providerCacheFileData()
7981 return loadProvidersOnce(client, path)
internal/csync/slices.go+5−14
@@ -112,15 +112,6 @@ func (s *Slice[T]) Len() int {
112112 return len(s.inner)
113113}
114114
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−
124115// SetSlice replaces the entire slice with a new one.
125116func (s *Slice[T]) SetSlice(items []T) {
126117 s.mu.Lock()
@@ -138,10 +129,8 @@ func (s *Slice[T]) Clear() {
138129
139130// Seq returns an iterator that yields elements from the slice.
140131func (s *Slice[T]) Seq() iter.Seq[T] {
141− // Take a snapshot to avoid holding the lock during iteration
142− items := s.Slice()
143132 return func(yield func(T) bool) {
144− for _, v := range items {
133+ for _, v := range s.Seq2() {
145134 if !yield(v) {
146135 return
147136 }
@@ -151,8 +140,10 @@ func (s *Slice[T]) Seq() iter.Seq[T] {
151140
152141// Seq2 returns an iterator that yields index-value pairs from the slice.
153142func (s *Slice[T]) Seq2() iter.Seq2[int, T] {
154− // Take a snapshot to avoid holding the lock during iteration
155− items := s.Slice()
143+ s.mu.RLock()
144+ items := make([]T, len(s.inner))
145+ copy(items, s.inner)
146+ s.mu.RUnlock()
156147 return func(yield func(int, T) bool) {
157148 for i, v := range items {
158149 if !yield(i, v) {
internal/csync/slices_test.go+4−3
@@ -1,6 +1,7 @@
11package csync
22
33import (
4+ "slices"
45 "sync"
56 "sync/atomic"
67 "testing"
@@ -145,7 +146,7 @@ func TestSlice(t *testing.T) {
145146 assert.Equal(t, 4, s.Len())
146147
147148 expected := []int{1, 2, 4, 5}
148− actual := s.Slice()
149+ actual := slices.Collect(s.Seq())
149150 assert.Equal(t, expected, actual)
150151
151152 // Delete out of bounds
@@ -203,7 +204,7 @@ func TestSlice(t *testing.T) {
203204 s.SetSlice(newItems)
204205
205206 assert.Equal(t, 3, s.Len())
206− assert.Equal(t, newItems, s.Slice())
207+ assert.Equal(t, newItems, slices.Collect(s.Seq()))
207208
208209 // Verify it's a copy
209210 newItems[0] = 999
@@ -224,7 +225,7 @@ func TestSlice(t *testing.T) {
224225 original := []int{1, 2, 3}
225226 s := NewSliceFrom(original)
226227
227− copy := s.Slice()
228+ copy := slices.Collect(s.Seq())
228229 assert.Equal(t, original, copy)
229230
230231 // Verify it's a copy
internal/format/spinner.go+2−2
@@ -20,7 +20,7 @@ type Spinner struct {
2020
2121type model struct {
2222 cancel context.CancelFunc
23− anim anim.Anim
23+ anim *anim.Anim
2424}
2525
2626func (m model) Init() tea.Cmd { return m.anim.Init() }
@@ -37,7 +37,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
3737 }
3838 }
3939 mm, cmd := m.anim.Update(msg)
40− m.anim = mm.(anim.Anim)
40+ m.anim = mm.(*anim.Anim)
4141 return m, cmd
4242}
4343
internal/llm/tools/diagnostics.go+15−3
@@ -4,6 +4,7 @@ import (
44 "context"
55 "encoding/json"
66 "fmt"
7+ "log/slog"
78 "maps"
89 "sort"
910 "strings"
@@ -118,7 +119,13 @@ func waitForLspDiagnostics(ctx context.Context, filePath string, lsps map[string
118119 return
119120 }
120121
121− if diagParams.URI.Path() == filePath || hasDiagnosticsChanged(client.GetDiagnostics(), originalDiags) {
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+
128+ if path == filePath || hasDiagnosticsChanged(client.GetDiagnostics(), originalDiags) {
122129 select {
123130 case diagChan <- struct{}{}:
124131 default:
@@ -216,10 +223,15 @@ func getDiagnostics(filePath string, lsps map[string]*lsp.Client) string {
216223 diagnostics := client.GetDiagnostics()
217224 if len(diagnostics) > 0 {
218225 for location, diags := range diagnostics {
219− isCurrentFile := location.Path() == filePath
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+ }
231+ isCurrentFile := path == filePath
220232
221233 for _, diag := range diags {
222− formattedDiag := formatDiagnostic(location.Path(), diag, lspName)
234+ formattedDiag := formatDiagnostic(path, diag, lspName)
223235
224236 if isCurrentFile {
225237 fileDiagnostics = append(fileDiagnostics, formattedDiag)
internal/lsp/client.go+11−2
@@ -449,7 +449,12 @@ func (c *Client) pingTypeScriptServer(ctx context.Context) error {
449449
450450 // If we have any open files, try to get document symbols for one
451451 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+
453458 if strings.HasSuffix(filePath, ".ts") || strings.HasSuffix(filePath, ".js") ||
454459 strings.HasSuffix(filePath, ".tsx") || strings.HasSuffix(filePath, ".jsx") {
455460 var symbols []protocol.DocumentSymbol
@@ -712,7 +717,11 @@ func (c *Client) CloseAllFiles(ctx context.Context) {
712717 // First collect all URIs that need to be closed
713718 for uri := range c.openFiles {
714719 // 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+ }
716725 filesToClose = append(filesToClose, filePath)
717726 }
718727 c.openFilesMu.Unlock()
internal/lsp/protocol/pattern_interfaces.go+18−2
@@ -2,6 +2,7 @@ package protocol
22
33import (
44 "fmt"
5+ "log/slog"
56)
67
78// PatternInfo is an interface for types that represent glob patterns
@@ -36,21 +37,36 @@ func (g *GlobPattern) AsPattern() (PatternInfo, error) {
3637 return nil, fmt.Errorf("nil pattern")
3738 }
3839
40+ var err error
41+
3942 switch v := g.Value.(type) {
4043 case string:
4144 return StringPattern{Pattern: v}, nil
45+
4246 case RelativePattern:
4347 // Handle BaseURI which could be string or DocumentUri
4448 basePath := ""
4549 switch baseURI := v.BaseURI.Value.(type) {
4650 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+
4857 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+
5064 default:
5165 return nil, fmt.Errorf("unknown BaseURI type: %T", v.BaseURI.Value)
5266 }
67+
5368 return RelativePatternInfo{RP: v, BasePath: basePath}, nil
69+
5470 default:
5571 return nil, fmt.Errorf("unknown pattern type: %T", g.Value)
5672 }
internal/lsp/protocol/uri.go+18−7
@@ -70,7 +70,7 @@ func (uri *DocumentURI) UnmarshalText(data []byte) (err error) {
7070// DocumentUri("").Path() returns the empty string.
7171//
7272// 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) {
7474 filename, err := filename(uri)
7575 if err != nil {
7676 // e.g. ParseRequestURI failed.
@@ -79,22 +79,33 @@ func (uri DocumentURI) Path() string {
7979 // direct string manipulation; all DocumentUris
8080 // received from the client pass through
8181 // ParseRequestURI, which ensures validity.
82− panic(err)
82+ return "", fmt.Errorf("invalid URI %q: %w", uri, err)
8383 }
84− return filepath.FromSlash(filename)
84+ return filepath.FromSlash(filename), nil
8585}
8686
8787// 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:
8990 // This function could be more efficiently implemented by avoiding any call
9091 // to Path(), but at least consolidates URI manipulation.
91− return URIFromPath(uri.DirPath())
92+
93+ path, err := uri.DirPath()
94+ if err != nil {
95+ return "", fmt.Errorf("invalid URI %q: %w", uri, err)
96+ }
97+
98+ return URIFromPath(path), nil
9299}
93100
94101// DirPath returns the file path to the directory containing this URI, which
95102// must be a file URI.
96−func (uri DocumentURI) DirPath() string {
97− return filepath.Dir(uri.Path())
103+func (uri DocumentURI) DirPath() (string, error) {
104+ path, err := uri.Path()
105+ if err != nil {
106+ return "", err
107+ }
108+ return filepath.Dir(path), nil
98109}
99110
100111func filename(uri DocumentURI) (string, error) {
internal/lsp/util/edit.go+27−5
@@ -11,7 +11,10 @@ import (
1111)
1212
1313func 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+ }
1518
1619 // Read the file content
1720 content, err := os.ReadFile(path)
@@ -148,7 +151,11 @@ func applyTextEdit(lines []string, edit protocol.TextEdit) ([]string, error) {
148151// applyDocumentChange applies a DocumentChange (create/rename/delete operations)
149152func applyDocumentChange(change protocol.DocumentChange) error {
150153 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+
152159 if change.CreateFile.Options != nil {
153160 if change.CreateFile.Options.Overwrite {
154161 // Proceed with overwrite
@@ -164,7 +171,11 @@ func applyDocumentChange(change protocol.DocumentChange) error {
164171 }
165172
166173 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+
168179 if change.DeleteFile.Options != nil && change.DeleteFile.Options.Recursive {
169180 if err := os.RemoveAll(path); err != nil {
170181 return fmt.Errorf("failed to delete directory recursively: %w", err)
@@ -177,8 +188,19 @@ func applyDocumentChange(change protocol.DocumentChange) error {
177188 }
178189
179190 if change.RenameFile != nil {
180− oldPath := change.RenameFile.OldURI.Path()
181− newPath := change.RenameFile.NewURI.Path()
191+ var newPath, oldPath string
192+ var err error
193+
194+ oldPath, err = change.RenameFile.OldURI.Path()
195+ if err != nil {
196+ return err
197+ }
198+
199+ newPath, err = change.RenameFile.NewURI.Path()
200+ if err != nil {
201+ return err
202+ }
203+
182204 if change.RenameFile.Options != nil {
183205 if !change.RenameFile.Options.Overwrite {
184206 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
617617 return false
618618 }
619619 // 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+
621625 basePath = filepath.ToSlash(basePath)
622626
623627 // Make path relative to basePath for matching
@@ -660,7 +664,13 @@ func (w *WorkspaceWatcher) debounceHandleFileEvent(ctx context.Context, uri stri
660664// handleFileEvent sends file change notifications
661665func (w *WorkspaceWatcher) handleFileEvent(ctx context.Context, uri string, changeType protocol.FileChangeType) {
662666 // 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+
664674 if changeType == protocol.FileChangeType(protocol.Deleted) {
665675 w.client.ClearDiagnosticsForURI(protocol.DocumentURI(uri))
666676 } else if changeType == protocol.FileChangeType(protocol.Changed) && w.client.IsFileOpen(filePath) {
internal/tui/components/anim/anim.go+61−50
@@ -6,7 +6,6 @@ import (
66 "image/color"
77 "math/rand/v2"
88 "strings"
9− "sync"
109 "sync/atomic"
1110 "time"
1211
@@ -15,6 +14,8 @@ import (
1514 tea "github.com/charmbracelet/bubbletea/v2"
1615 "github.com/charmbracelet/lipgloss/v2"
1716 "github.com/lucasb-eyer/go-colorful"
17+
18+ "github.com/charmbracelet/crush/internal/csync"
1819)
1920
2021const (
@@ -72,10 +73,7 @@ type animCache struct {
7273 ellipsisFrames []string
7374}
7475
75−var (
76− animCacheMutex sync.RWMutex
77− animCacheMap = make(map[string]*animCache)
78−)
76+var animCacheMap = csync.NewMap[string, *animCache]()
7977
8078// settingsHash creates a hash key for the settings to use for caching
8179func settingsHash(opts Settings) string {
@@ -105,22 +103,23 @@ const ()
105103type Anim struct {
106104 width int
107105 cyclingCharWidth int
108− label []string
106+ label *csync.Slice[string]
109107 labelWidth int
110108 labelColor color.Color
111109 startTime time.Time
112110 birthOffsets []time.Duration
113111 initialFrames [][]string // frames for the initial characters
114− initialized bool
115− cyclingFrames [][]string // frames for the cycling characters
116− step int // current main frame step
117− ellipsisStep int // current ellipsis frame step
118− ellipsisFrames []string // ellipsis animation frames
112+ initialized atomic.Bool
113+ cyclingFrames [][]string // frames for the cycling characters
114+ step atomic.Int64 // current main frame step
115+ ellipsisStep atomic.Int64 // current ellipsis frame step
116+ ellipsisFrames *csync.Slice[string] // ellipsis animation frames
119117 id int
120118}
121119
122120// 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{}
124123 // Validate settings.
125124 if opts.Size < 1 {
126125 opts.Size = defaultNumCyclingChars
@@ -142,16 +141,14 @@ func New(opts Settings) (a Anim) {
142141
143142 // Check cache first
144143 cacheKey := settingsHash(opts)
145− animCacheMutex.RLock()
146− cached, exists := animCacheMap[cacheKey]
147− animCacheMutex.RUnlock()
144+ cached, exists := animCacheMap.Get(cacheKey)
148145
149146 if exists {
150147 // Use cached values
151148 a.width = cached.width
152149 a.labelWidth = cached.labelWidth
153− a.label = cached.label
154− a.ellipsisFrames = cached.ellipsisFrames
150+ a.label = csync.NewSliceFrom(cached.label)
151+ a.ellipsisFrames = csync.NewSliceFrom(cached.ellipsisFrames)
155152 a.initialFrames = cached.initialFrames
156153 a.cyclingFrames = cached.cyclingFrames
157154 } else {
@@ -228,17 +225,23 @@ func New(opts Settings) (a Anim) {
228225 }
229226
230227 // 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+ }
231236 cached = &animCache{
232237 initialFrames: a.initialFrames,
233238 cyclingFrames: a.cyclingFrames,
234239 width: a.width,
235240 labelWidth: a.labelWidth,
236− label: a.label,
237− ellipsisFrames: a.ellipsisFrames,
241+ label: labelSlice,
242+ ellipsisFrames: ellipsisSlice,
238243 }
239− animCacheMutex.Lock()
240− animCacheMap[cacheKey] = cached
241− animCacheMutex.Unlock()
244+ animCacheMap.Set(cacheKey, cached)
242245 }
243246
244247 // Random assign a birth to each character for a stagged entrance effect.
@@ -269,28 +272,30 @@ func (a *Anim) renderLabel(label string) {
269272 if a.labelWidth > 0 {
270273 // Pre-render the label.
271274 labelRunes := []rune(label)
272− a.label = make([]string, len(labelRunes))
273− for i := range a.label {
274− a.label[i] = lipgloss.NewStyle().
275+ a.label = csync.NewSlice[string]()
276+ for i := range labelRunes {
277+ rendered := lipgloss.NewStyle().
275278 Foreground(a.labelColor).
276279 Render(string(labelRunes[i]))
280+ a.label.Append(rendered)
277281 }
278282
279283 // Pre-render the ellipsis frames which come after the label.
280− a.ellipsisFrames = make([]string, len(ellipsisFrames))
281− for i, frame := range ellipsisFrames {
282− a.ellipsisFrames[i] = lipgloss.NewStyle().
284+ a.ellipsisFrames = csync.NewSlice[string]()
285+ for _, frame := range ellipsisFrames {
286+ rendered := lipgloss.NewStyle().
283287 Foreground(a.labelColor).
284288 Render(frame)
289+ a.ellipsisFrames.Append(rendered)
285290 }
286291 } else {
287− a.label = nil
288− a.ellipsisFrames = nil
292+ a.label = csync.NewSlice[string]()
293+ a.ellipsisFrames = csync.NewSlice[string]()
289294 }
290295}
291296
292297// Width returns the total width of the animation.
293−func (a Anim) Width() (w int) {
298+func (a *Anim) Width() (w int) {
294299 w = a.width
295300 if a.labelWidth > 0 {
296301 w += labelGapWidth + a.labelWidth
@@ -308,12 +313,12 @@ func (a Anim) Width() (w int) {
308313}
309314
310315// Init starts the animation.
311−func (a Anim) Init() tea.Cmd {
316+func (a *Anim) Init() tea.Cmd {
312317 return a.Step()
313318}
314319
315320// 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) {
317322 switch msg := msg.(type) {
318323 case StepMsg:
319324 if msg.id != a.id {
@@ -321,19 +326,19 @@ func (a Anim) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
321326 return a, nil
322327 }
323328
324− a.step++
325− if a.step >= len(a.cyclingFrames) {
326− a.step = 0
329+ step := a.step.Add(1)
330+ if int(step) >= len(a.cyclingFrames) {
331+ a.step.Store(0)
327332 }
328333
329− if a.initialized && a.labelWidth > 0 {
334+ if a.initialized.Load() && a.labelWidth > 0 {
330335 // Manage the ellipsis animation.
331− a.ellipsisStep++
332− if a.ellipsisStep >= ellipsisAnimSpeed*len(ellipsisFrames) {
333− a.ellipsisStep = 0
336+ ellipsisStep := a.ellipsisStep.Add(1)
337+ if int(ellipsisStep) >= ellipsisAnimSpeed*len(ellipsisFrames) {
338+ a.ellipsisStep.Store(0)
334339 }
335− } else if !a.initialized && time.Since(a.startTime) >= maxBirthOffset {
336− a.initialized = true
340+ } else if !a.initialized.Load() && time.Since(a.startTime) >= maxBirthOffset {
341+ a.initialized.Store(true)
337342 }
338343 return a, a.Step()
339344 default:
@@ -342,35 +347,41 @@ func (a Anim) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
342347}
343348
344349// View renders the current state of the animation.
345−func (a Anim) View() string {
350+func (a *Anim) View() string {
346351 var b strings.Builder
352+ step := int(a.step.Load())
347353 for i := range a.width {
348354 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]:
350356 // Birth offset not reached: render initial character.
351− b.WriteString(a.initialFrames[a.step][i])
357+ b.WriteString(a.initialFrames[step][i])
352358 case i < a.cyclingCharWidth:
353359 // Render a cycling character.
354− b.WriteString(a.cyclingFrames[a.step][i])
360+ b.WriteString(a.cyclingFrames[step][i])
355361 case i == a.cyclingCharWidth:
356362 // Render label gap.
357363 b.WriteString(labelGap)
358364 case i > a.cyclingCharWidth:
359365 // 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+ }
361369 }
362370 }
363371 // Render animated ellipsis at the end of the label if all characters
364372 // have been initialized.
365− if a.initialized && a.labelWidth > 0 {
366− b.WriteString(a.ellipsisFrames[a.ellipsisStep/ellipsisAnimSpeed])
373+ if a.initialized.Load() && a.labelWidth > 0 {
374+ ellipsisStep := int(a.ellipsisStep.Load())
375+ if ellipsisFrame, ok := a.ellipsisFrames.Get(ellipsisStep / ellipsisAnimSpeed); ok {
376+ b.WriteString(ellipsisFrame)
377+ }
367378 }
368379
369380 return b.String()
370381}
371382
372383// 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 {
374385 return tea.Tick(time.Second/time.Duration(fps), func(t time.Time) tea.Msg {
375386 return StepMsg{id: a.id}
376387 })
internal/tui/components/anim/example/main.go+1−1
@@ -56,7 +56,7 @@ func (m model) View() tea.View {
5656 return v
5757 }
5858
59− if a, ok := m.anim.(anim.Anim); ok {
59+ if a, ok := m.anim.(*anim.Anim); ok {
6060 l := lipgloss.NewLayer(a.View()).
6161 Width(a.Width()).
6262 X(m.w/2 - a.Width()/2).
internal/tui/components/chat/messages/messages.go+2−2
@@ -49,7 +49,7 @@ type messageCmp struct {
4949 // Core message data and state
5050 message message.Message // The underlying message content
5151 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
5353
5454 // Thinking viewport for displaying reasoning content
5555 thinkingViewport viewport.Model
@@ -95,7 +95,7 @@ func (m *messageCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
9595 m.spinning = m.shouldSpin()
9696 if m.spinning {
9797 u, cmd := m.anim.Update(msg)
98− m.anim = u.(anim.Anim)
98+ m.anim = u.(*anim.Anim)
9999 return m, cmd
100100 }
101101 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 {
778778// shouldSpin determines whether the tool call should show a loading animation.
779779// Returns true if the tool call is not finished or if the result doesn't match the call ID.
780780func (m *toolCallCmp) shouldSpin() bool {
781− return !m.call.Finished
781+ return !m.call.Finished && !m.cancelled
782782}
783783
784784// 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 {
614614}
615615
616616func (m *sidebarCmp) filesBlock() string {
617+ return ""
617618 t := styles.CurrentTheme()
618619
619620 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
9797 f.list = New(items, f.listOptions...).(*list[T])
9898
9999 f.updateKeyMaps()
100− f.items = f.list.items.Slice()
100+ f.items = slices.Collect(f.list.items.Seq())
101101
102102 if f.inputHidden {
103103 return f
internal/tui/exp/list/filterable_group.go+2−1
@@ -2,6 +2,7 @@ package list
22
33import (
44 "regexp"
5+ "slices"
56 "sort"
67 "strings"
78
@@ -179,7 +180,7 @@ func (f *filterableGroupList[T]) inputHeight() int {
179180
180181func (f *filterableGroupList[T]) Filter(query string) tea.Cmd {
181182 var cmds []tea.Cmd
182− for _, item := range f.items.Slice() {
183+ for _, item := range slices.Collect(f.items.Seq()) {
183184 if i, ok := any(item).(layout.Focusable); ok {
184185 cmds = append(cmds, i.Blur())
185186 }
internal/tui/exp/list/grouped.go+3−1
@@ -1,6 +1,8 @@
11package list
22
33import (
4+ "slices"
5+
46 tea "github.com/charmbracelet/bubbletea/v2"
57 "github.com/charmbracelet/crush/internal/csync"
68 "github.com/charmbracelet/crush/internal/tui/components/core/layout"
@@ -89,7 +91,7 @@ func (g *groupedList[T]) convertItems() {
8991func (g *groupedList[T]) SetGroups(groups []Group[T]) tea.Cmd {
9092 g.groups = groups
9193 g.convertItems()
92− return g.SetItems(g.items.Slice())
94+ return g.SetItems(slices.Collect(g.items.Seq()))
9395}
9496
9597func (g *groupedList[T]) Groups() []Group[T] {
internal/tui/exp/list/list.go+11−10
@@ -1,6 +1,7 @@
11package list
22
33import (
4+ "slices"
45 "strings"
56
67 "github.com/charmbracelet/bubbles/v2/key"
@@ -201,7 +202,7 @@ func (l *list[T]) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
201202 return l, nil
202203 case anim.StepMsg:
203204 var cmds []tea.Cmd
204− for _, item := range l.items.Slice() {
205+ for _, item := range slices.Collect(l.items.Seq()) {
205206 if i, ok := any(item).(HasAnim); ok && i.Spinning() {
206207 updated, cmd := i.Update(msg)
207208 cmds = append(cmds, cmd)
@@ -300,7 +301,7 @@ func (l *list[T]) viewPosition() (int, int) {
300301
301302func (l *list[T]) recalculateItemPositions() {
302303 currentContentHeight := 0
303− for _, item := range l.items.Slice() {
304+ for _, item := range slices.Collect(l.items.Seq()) {
304305 rItem, ok := l.renderedItems.Get(item.ID())
305306 if !ok {
306307 continue
@@ -573,7 +574,7 @@ func (l *list[T]) focusSelectedItem() tea.Cmd {
573574 return nil
574575 }
575576 var cmds []tea.Cmd
576− for _, item := range l.items.Slice() {
577+ for _, item := range slices.Collect(l.items.Seq()) {
577578 if f, ok := any(item).(layout.Focusable); ok {
578579 if item.ID() == l.selectedItem && !f.IsFocused() {
579580 cmds = append(cmds, f.Focus())
@@ -592,7 +593,7 @@ func (l *list[T]) blurSelectedItem() tea.Cmd {
592593 return nil
593594 }
594595 var cmds []tea.Cmd
595− for _, item := range l.items.Slice() {
596+ for _, item := range slices.Collect(l.items.Seq()) {
596597 if f, ok := any(item).(layout.Focusable); ok {
597598 if item.ID() == l.selectedItem && f.IsFocused() {
598599 cmds = append(cmds, f.Blur())
@@ -667,7 +668,7 @@ func (l *list[T]) AppendItem(item T) tea.Cmd {
667668
668669 l.items.Append(item)
669670 l.indexMap = csync.NewMap[string, int]()
670− for inx, item := range l.items.Slice() {
671+ for inx, item := range slices.Collect(l.items.Seq()) {
671672 l.indexMap.Set(item.ID(), inx)
672673 }
673674 if l.width > 0 && l.height > 0 {
@@ -714,7 +715,7 @@ func (l *list[T]) DeleteItem(id string) tea.Cmd {
714715 }
715716 l.items.Delete(inx)
716717 l.renderedItems.Del(id)
717− for inx, item := range l.items.Slice() {
718+ for inx, item := range slices.Collect(l.items.Seq()) {
718719 l.indexMap.Set(item.ID(), inx)
719720 }
720721
@@ -779,7 +780,7 @@ func (l *list[T]) IsFocused() bool {
779780
780781// Items implements List.
781782func (l *list[T]) Items() []T {
782− return l.items.Slice()
783+ return slices.Collect(l.items.Seq())
783784}
784785
785786func (l *list[T]) incrementOffset(n int) {
@@ -834,7 +835,7 @@ func (l *list[T]) PrependItem(item T) tea.Cmd {
834835 }
835836 l.items.Prepend(item)
836837 l.indexMap = csync.NewMap[string, int]()
837− for inx, item := range l.items.Slice() {
838+ for inx, item := range slices.Collect(l.items.Seq()) {
838839 l.indexMap.Set(item.ID(), inx)
839840 }
840841 if l.width > 0 && l.height > 0 {
@@ -938,7 +939,7 @@ func (l *list[T]) SelectedItem() *T {
938939func (l *list[T]) SetItems(items []T) tea.Cmd {
939940 l.items.SetSlice(items)
940941 var cmds []tea.Cmd
941− for inx, item := range l.items.Slice() {
942+ for inx, item := range slices.Collect(l.items.Seq()) {
942943 if i, ok := any(item).(Indexable); ok {
943944 i.SetIndex(inx)
944945 }
@@ -961,7 +962,7 @@ func (l *list[T]) reset(selectedItem string) tea.Cmd {
961962 l.selectedItem = selectedItem
962963 l.indexMap = csync.NewMap[string, int]()
963964 l.renderedItems = csync.NewMap[string, renderedItem]()
964− for inx, item := range l.items.Slice() {
965+ for inx, item := range slices.Collect(l.items.Seq()) {
965966 l.indexMap.Set(item.ID(), inx)
966967 if l.width > 0 && l.height > 0 {
967968 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 {
860860 ),
861861 key.NewBinding(
862862 key.WithKeys("g", "home"),
863− key.WithHelp("g", "hone"),
863+ key.WithHelp("g", "home"),
864864 ),
865865 key.NewBinding(
866866 key.WithKeys("G", "end"),