on main help sign in
Merge remote-tracking branch 'origin/main' into feat/whitelist-commands
commit2128964dcb871f9f04d43da2ca1b71da7fd55afa authorKujtim Hoxha <kujtimii.h@gmail.com> dateThu 24 Jul 2025, 09:44 (1 year ago) parent0546d5b506c3b6
35 files changed+303−382
README.md+2−2 go.mod+1 go.sum+2−2 internal/app/app.go+8−4 internal/cmd/root.go+3−3 internal/config/config.go+12−12 internal/config/load.go+19−18 internal/config/load_test.go+80−80 internal/config/provider.go+10−11 internal/config/provider_test.go+4−4 internal/fur/client/client.go−63 internal/fur/provider/provider.go−75 internal/llm/agent/agent.go+7−7 internal/llm/prompt/coder.go+3−3 internal/llm/provider/anthropic.go+5−5 internal/llm/provider/bedrock.go+3−3 internal/llm/provider/gemini.go+4−4 internal/llm/provider/openai.go+5−5 internal/llm/provider/openai_test.go+5−5 internal/llm/provider/provider.go+13−13 internal/llm/tools/glob.go+1−1 internal/lsp/watcher/watcher.go+1−1 internal/message/content.go+3−3 internal/tui/components/chat/editor/editor.go+5−3 internal/tui/components/chat/messages/messages.go+4−4 internal/tui/components/chat/sidebar/sidebar.go+4−4 internal/tui/components/chat/splash/splash.go+3−3 internal/tui/components/completions/completions.go+26−1 internal/tui/components/completions/keys.go+10 internal/tui/components/dialogs/commands/commands.go+2−2 internal/tui/components/dialogs/models/list.go+11−11 internal/tui/components/dialogs/models/models.go+4−4 internal/tui/page/chat/chat.go+1−1 internal/tui/tui.go+41−23 main.go+1−2
README.md+2−2
@@ -182,7 +182,7 @@ Crush includes a permission system to control which tools can be executed withou
182182The `allowed_tools` array accepts:
183183
184184- Tool names (e.g., `"view"`) - allows all actions for that tool
185−- Tool:action combinations (e.g., `"bash:read"`) - allows only specific actions
185+- Tool:action combinations (e.g., `"edit:write"`) - allows only specific actions
186186
187187You can also skip all permission prompts entirely by running Crush with the `--yolo` flag.
188188
@@ -199,7 +199,7 @@ Crush supports all OpenAI-compatible APIs. Here's an example configuration for D
199199 "models": [
200200 {
201201 "id": "deepseek-chat",
202− "model": "Deepseek V3",
202+ "name": "Deepseek V3",
203203 "cost_per_1m_in": 0.27,
204204 "cost_per_1m_out": 1.1,
205205 "cost_per_1m_in_cached": 0.07,
go.mod+1
@@ -13,6 +13,7 @@ require (
1313 github.com/charlievieth/fastwalk v1.0.11
1414 github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250716191546-1e2ffbbcf5c5
1515 github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4.0.20250717140350-bb75e8f6b6ac
16+ github.com/charmbracelet/catwalk v0.3.1
1617 github.com/charmbracelet/fang v0.3.1-0.20250711140230-d5ebb8c1d674
1718 github.com/charmbracelet/glamour/v2 v2.0.0-20250516160903-6f1e2c8f9ebe
1819 github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.3.0.20250716211347-10c048e36112
go.sum+2−2
@@ -72,6 +72,8 @@ github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250716191546-1e2ffbbcf5c5
7272github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250716191546-1e2ffbbcf5c5/go.mod h1:6HamsBKWqEC/FVHuQMHgQL+knPyvHH55HwJDHl/adMw=
7373github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4.0.20250717140350-bb75e8f6b6ac h1:murtkvFYxZ/73vk4Z/tpE4biB+WDZcFmmBp8je/yV6M=
7474github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4.0.20250717140350-bb75e8f6b6ac/go.mod h1:m240IQxo1/eDQ7klblSzOCAUyc3LddHcV3Rc/YEGAgw=
75+github.com/charmbracelet/catwalk v0.3.1 h1:MkGWspcMyE659zDkqS+9wsaCMTKRFEDBFY2A2sap6+U=
76+github.com/charmbracelet/catwalk v0.3.1/go.mod h1:gUUCqqZ8bk4D7ZzGTu3I77k7cC2x4exRuJBN1H2u2pc=
7577github.com/charmbracelet/colorprofile v0.3.1 h1:k8dTHMd7fgw4bnFd7jXTLZrSU/CQrKnL3m+AxCzDz40=
7678github.com/charmbracelet/colorprofile v0.3.1/go.mod h1:/GkGusxNs8VB/RSOh3fu0TJmQ4ICMMPApIIVn0KszZ0=
7779github.com/charmbracelet/fang v0.3.1-0.20250711140230-d5ebb8c1d674 h1:+Cz+VfxD5DO+JT1LlswXWhre0HYLj6l2HW8HVGfMuC0=
@@ -82,8 +84,6 @@ github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.3.0.20250716211347-10c048e36112
8284github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.3.0.20250716211347-10c048e36112/go.mod h1:BXY7j7rZgAprFwzNcO698++5KTd6GKI6lU83Pr4o0r0=
8385github.com/charmbracelet/log/v2 v2.0.0-20250226163916-c379e29ff706 h1:WkwO6Ks3mSIGnGuSdKl9qDSyfbYK50z2wc2gGMggegE=
8486github.com/charmbracelet/log/v2 v2.0.0-20250226163916-c379e29ff706/go.mod h1:mjJGp00cxcfvD5xdCa+bso251Jt4owrQvuimJtVmEmM=
85−github.com/charmbracelet/ultraviolet v0.0.0-20250721205647-f6ac6eda5d42 h1:Zqw2oP9Wo8VzMijVJbtIJcAaZviYyU07stvmCFCfn0Y=
86−github.com/charmbracelet/ultraviolet v0.0.0-20250721205647-f6ac6eda5d42/go.mod h1:XrrgNFfXLrFAyd9DUmrqVc3yQFVv8Uk+okj4PsNNzpc=
8787github.com/charmbracelet/ultraviolet v0.0.0-20250723145313-809e6f5b43a1 h1:tsw1mOuIEIKlmm614bXctvJ3aavaFhyPG+y+wrKtuKQ=
8888github.com/charmbracelet/ultraviolet v0.0.0-20250723145313-809e6f5b43a1/go.mod h1:XrrgNFfXLrFAyd9DUmrqVc3yQFVv8Uk+okj4PsNNzpc=
8989github.com/charmbracelet/x/ansi v0.9.3 h1:BXt5DHS/MKF+LjuK4huWrC6NCvHtexww7dMayh6GXd0=
internal/app/app.go+8−4
@@ -161,16 +161,20 @@ func (app *App) RunNonInteractive(ctx context.Context, prompt string, quiet bool
161161
162162 if result.Error != nil {
163163 if errors.Is(result.Error, context.Canceled) || errors.Is(result.Error, agent.ErrRequestCancelled) {
164− slog.Info("Agent processing cancelled", "session_id", sess.ID)
164+ slog.Info("Non-interactive: agent processing cancelled", "session_id", sess.ID)
165165 return nil
166166 }
167167 return fmt.Errorf("agent processing failed: %w", result.Error)
168168 }
169169
170− part := result.Message.Content().String()[readBts:]
171− fmt.Println(part)
170+ msgContent := result.Message.Content().String()
171+ if len(msgContent) < readBts {
172+ slog.Error("Non-interactive: message content is shorter than read bytes", "message_length", len(msgContent), "read_bytes", readBts)
173+ return fmt.Errorf("message content is shorter than read bytes: %d < %d", len(msgContent), readBts)
174+ }
175+ fmt.Println(msgContent[readBts:])
172176
173− slog.Info("Non-interactive run completed", "session_id", sess.ID)
177+ slog.Info("Non-interactive: run completed", "session_id", sess.ID)
174178 return nil
175179
176180 case event := <-messageEvents:
internal/cmd/root.go+3−3
@@ -88,14 +88,14 @@ to assist developers in writing, debugging, and understanding code directly from
8888
8989 app, err := app.New(ctx, conn, cfg)
9090 if err != nil {
91− slog.Error(fmt.Sprintf("Failed to create app instance: %v", err))
91+ slog.Error("Failed to create app instance", "error", err)
9292 return err
9393 }
9494 defer app.Shutdown()
9595
9696 prompt, err = maybePrependStdin(prompt)
9797 if err != nil {
98− slog.Error(fmt.Sprintf("Failed to read from stdin: %v", err))
98+ slog.Error("Failed to read from stdin", "error", err)
9999 return err
100100 }
101101
@@ -117,7 +117,7 @@ to assist developers in writing, debugging, and understanding code directly from
117117 go app.Subscribe(program)
118118
119119 if _, err := program.Run(); err != nil {
120− slog.Error(fmt.Sprintf("TUI run error: %v", err))
120+ slog.Error("TUI run error", "error", err)
121121 return fmt.Errorf("TUI error: %v", err)
122122 }
123123 return nil
internal/config/config.go+12−12
@@ -9,8 +9,8 @@ import (
99 "strings"
1010 "time"
1111
12+ "github.com/charmbracelet/catwalk/pkg/catwalk"
1213 "github.com/charmbracelet/crush/internal/env"
13− "github.com/charmbracelet/crush/internal/fur/provider"
1414 "github.com/tidwall/sjson"
1515 "golang.org/x/exp/slog"
1616)
@@ -70,7 +70,7 @@ type ProviderConfig struct {
7070 // The provider's API endpoint.
7171 BaseURL string `json:"base_url,omitempty"`
7272 // The provider type, e.g. "openai", "anthropic", etc. if empty it defaults to openai.
73− Type provider.Type `json:"type,omitempty"`
73+ Type catwalk.Type `json:"type,omitempty"`
7474 // The provider's API key.
7575 APIKey string `json:"api_key,omitempty"`
7676 // Marks the provider as disabled.
@@ -85,7 +85,7 @@ type ProviderConfig struct {
8585 ExtraParams map[string]string `json:"-"`
8686
8787 // The provider models
88− Models []provider.Model `json:"models,omitempty"`
88+ Models []catwalk.Model `json:"models,omitempty"`
8989}
9090
9191type MCPType string
@@ -256,8 +256,8 @@ type Config struct {
256256 Agents map[string]Agent `json:"-"`
257257 // TODO: find a better way to do this this should probably not be part of the config
258258 resolver VariableResolver
259− dataConfigDir string `json:"-"`
260− knownProviders []provider.Provider `json:"-"`
259+ dataConfigDir string `json:"-"`
260+ knownProviders []catwalk.Provider `json:"-"`
261261}
262262
263263func (c *Config) WorkingDir() string {
@@ -279,7 +279,7 @@ func (c *Config) IsConfigured() bool {
279279 return len(c.EnabledProviders()) > 0
280280}
281281
282−func (c *Config) GetModel(provider, model string) *provider.Model {
282+func (c *Config) GetModel(provider, model string) *catwalk.Model {
283283 if providerConfig, ok := c.Providers[provider]; ok {
284284 for _, m := range providerConfig.Models {
285285 if m.ID == model {
@@ -301,7 +301,7 @@ func (c *Config) GetProviderForModel(modelType SelectedModelType) *ProviderConfi
301301 return nil
302302}
303303
304−func (c *Config) GetModelByType(modelType SelectedModelType) *provider.Model {
304+func (c *Config) GetModelByType(modelType SelectedModelType) *catwalk.Model {
305305 model, ok := c.Models[modelType]
306306 if !ok {
307307 return nil
@@ -309,7 +309,7 @@ func (c *Config) GetModelByType(modelType SelectedModelType) *provider.Model {
309309 return c.GetModel(model.Provider, model.Model)
310310}
311311
312−func (c *Config) LargeModel() *provider.Model {
312+func (c *Config) LargeModel() *catwalk.Model {
313313 model, ok := c.Models[SelectedModelTypeLarge]
314314 if !ok {
315315 return nil
@@ -317,7 +317,7 @@ func (c *Config) LargeModel() *provider.Model {
317317 return c.GetModel(model.Provider, model.Model)
318318}
319319
320−func (c *Config) SmallModel() *provider.Model {
320+func (c *Config) SmallModel() *catwalk.Model {
321321 model, ok := c.Models[SelectedModelTypeSmall]
322322 if !ok {
323323 return nil
@@ -387,7 +387,7 @@ func (c *Config) SetProviderAPIKey(providerID, apiKey string) error {
387387 return nil
388388 }
389389
390− var foundProvider *provider.Provider
390+ var foundProvider *catwalk.Provider
391391 for _, p := range c.knownProviders {
392392 if string(p.ID) == providerID {
393393 foundProvider = &p
@@ -456,14 +456,14 @@ func (c *ProviderConfig) TestConnection(resolver VariableResolver) error {
456456 headers := make(map[string]string)
457457 apiKey, _ := resolver.ResolveValue(c.APIKey)
458458 switch c.Type {
459− case provider.TypeOpenAI:
459+ case catwalk.TypeOpenAI:
460460 baseURL, _ := resolver.ResolveValue(c.BaseURL)
461461 if baseURL == "" {
462462 baseURL = "https://api.openai.com/v1"
463463 }
464464 testURL = baseURL + "/models"
465465 headers["Authorization"] = "Bearer " + apiKey
466− case provider.TypeAnthropic:
466+ case catwalk.TypeAnthropic:
467467 baseURL, _ := resolver.ResolveValue(c.BaseURL)
468468 if baseURL == "" {
469469 baseURL = "https://api.anthropic.com/v1"
internal/config/load.go+19−18
@@ -11,13 +11,14 @@ import (
1111 "strings"
1212 "sync"
1313
14+ "github.com/charmbracelet/catwalk/pkg/catwalk"
1415 "github.com/charmbracelet/crush/internal/env"
15− "github.com/charmbracelet/crush/internal/fur/client"
16− "github.com/charmbracelet/crush/internal/fur/provider"
1716 "github.com/charmbracelet/crush/internal/log"
1817 "golang.org/x/exp/slog"
1918)
2019
20+const catwalkURL = "https://catwalk.charm.sh"
21+
2122// LoadReader config via io.Reader.
2223func LoadReader(fd io.Reader) (*Config, error) {
2324 data, err := io.ReadAll(fd)
@@ -61,8 +62,8 @@ func Load(workingDir string, debug bool) (*Config, error) {
6162 cfg.Options.Debug,
6263 )
6364
64− // Load known providers, this loads the config from fur
65− providers, err := LoadProviders(client.New())
65+ // Load known providers, this loads the config from catwalk
66+ providers, err := LoadProviders(catwalk.NewWithURL(catwalkURL))
6667 if err != nil || len(providers) == 0 {
6768 return nil, fmt.Errorf("failed to load providers: %w", err)
6869 }
@@ -81,7 +82,7 @@ func Load(workingDir string, debug bool) (*Config, error) {
8182 var wg sync.WaitGroup
8283
8384 for _, p := range cfg.Providers {
84− if p.Type == provider.TypeOpenAI || p.Type == provider.TypeAnthropic {
85+ if p.Type == catwalk.TypeOpenAI || p.Type == catwalk.TypeAnthropic {
8586 wg.Add(1)
8687 go func(provider ProviderConfig) {
8788 defer wg.Done()
@@ -117,7 +118,7 @@ func Load(workingDir string, debug bool) (*Config, error) {
117118 return cfg, nil
118119}
119120
120−func (c *Config) configureProviders(env env.Env, resolver VariableResolver, knownProviders []provider.Provider) error {
121+func (c *Config) configureProviders(env env.Env, resolver VariableResolver, knownProviders []catwalk.Provider) error {
121122 knownProviderNames := make(map[string]bool)
122123 for _, p := range knownProviders {
123124 knownProviderNames[string(p.ID)] = true
@@ -136,7 +137,7 @@ func (c *Config) configureProviders(env env.Env, resolver VariableResolver, know
136137 p.APIKey = config.APIKey
137138 }
138139 if len(config.Models) > 0 {
139− models := []provider.Model{}
140+ models := []catwalk.Model{}
140141 seen := make(map[string]bool)
141142
142143 for _, model := range config.Models {
@@ -144,8 +145,8 @@ func (c *Config) configureProviders(env env.Env, resolver VariableResolver, know
144145 continue
145146 }
146147 seen[model.ID] = true
147− if model.Model == "" {
148− model.Model = model.ID
148+ if model.Name == "" {
149+ model.Name = model.ID
149150 }
150151 models = append(models, model)
151152 }
@@ -154,8 +155,8 @@ func (c *Config) configureProviders(env env.Env, resolver VariableResolver, know
154155 continue
155156 }
156157 seen[model.ID] = true
157− if model.Model == "" {
158− model.Model = model.ID
158+ if model.Name == "" {
159+ model.Name = model.ID
159160 }
160161 models = append(models, model)
161162 }
@@ -178,7 +179,7 @@ func (c *Config) configureProviders(env env.Env, resolver VariableResolver, know
178179
179180 switch p.ID {
180181 // Handle specific providers that require additional configuration
181− case provider.InferenceProviderVertexAI:
182+ case catwalk.InferenceProviderVertexAI:
182183 if !hasVertexCredentials(env) {
183184 if configExists {
184185 slog.Warn("Skipping Vertex AI provider due to missing credentials")
@@ -188,7 +189,7 @@ func (c *Config) configureProviders(env env.Env, resolver VariableResolver, know
188189 }
189190 prepared.ExtraParams["project"] = env.Get("GOOGLE_CLOUD_PROJECT")
190191 prepared.ExtraParams["location"] = env.Get("GOOGLE_CLOUD_LOCATION")
191− case provider.InferenceProviderAzure:
192+ case catwalk.InferenceProviderAzure:
192193 endpoint, err := resolver.ResolveValue(p.APIEndpoint)
193194 if err != nil || endpoint == "" {
194195 if configExists {
@@ -199,7 +200,7 @@ func (c *Config) configureProviders(env env.Env, resolver VariableResolver, know
199200 }
200201 prepared.BaseURL = endpoint
201202 prepared.ExtraParams["apiVersion"] = env.Get("AZURE_OPENAI_API_VERSION")
202− case provider.InferenceProviderBedrock:
203+ case catwalk.InferenceProviderBedrock:
203204 if !hasAWSCredentials(env) {
204205 if configExists {
205206 slog.Warn("Skipping Bedrock provider due to missing AWS credentials")
@@ -239,7 +240,7 @@ func (c *Config) configureProviders(env env.Env, resolver VariableResolver, know
239240 }
240241 // default to OpenAI if not set
241242 if providerConfig.Type == "" {
242− providerConfig.Type = provider.TypeOpenAI
243+ providerConfig.Type = catwalk.TypeOpenAI
243244 }
244245
245246 if providerConfig.Disable {
@@ -260,7 +261,7 @@ func (c *Config) configureProviders(env env.Env, resolver VariableResolver, know
260261 delete(c.Providers, id)
261262 continue
262263 }
263− if providerConfig.Type != provider.TypeOpenAI {
264+ if providerConfig.Type != catwalk.TypeOpenAI {
264265 slog.Warn("Skipping custom provider because the provider type is not supported", "provider", id, "type", providerConfig.Type)
265266 delete(c.Providers, id)
266267 continue
@@ -315,7 +316,7 @@ func (c *Config) setDefaults(workingDir string) {
315316 c.Options.ContextPaths = slices.Compact(c.Options.ContextPaths)
316317}
317318
318−func (c *Config) defaultModelSelection(knownProviders []provider.Provider) (largeModel SelectedModel, smallModel SelectedModel, err error) {
319+func (c *Config) defaultModelSelection(knownProviders []catwalk.Provider) (largeModel SelectedModel, smallModel SelectedModel, err error) {
319320 if len(knownProviders) == 0 && len(c.Providers) == 0 {
320321 err = fmt.Errorf("no providers configured, please configure at least one provider")
321322 return
@@ -384,7 +385,7 @@ func (c *Config) defaultModelSelection(knownProviders []provider.Provider) (larg
384385 return
385386}
386387
387−func (c *Config) configureSelectedModels(knownProviders []provider.Provider) error {
388+func (c *Config) configureSelectedModels(knownProviders []catwalk.Provider) error {
388389 defaultLarge, defaultSmall, err := c.defaultModelSelection(knownProviders)
389390 if err != nil {
390391 return fmt.Errorf("failed to select default models: %w", err)
internal/config/load_test.go+80−80
@@ -8,8 +8,8 @@ import (
88 "strings"
99 "testing"
1010
11+ "github.com/charmbracelet/catwalk/pkg/catwalk"
1112 "github.com/charmbracelet/crush/internal/env"
12− "github.com/charmbracelet/crush/internal/fur/provider"
1313 "github.com/stretchr/testify/assert"
1414)
1515
@@ -54,12 +54,12 @@ func TestConfig_setDefaults(t *testing.T) {
5454}
5555
5656func TestConfig_configureProviders(t *testing.T) {
57− knownProviders := []provider.Provider{
57+ knownProviders := []catwalk.Provider{
5858 {
5959 ID: "openai",
6060 APIKey: "$OPENAI_API_KEY",
6161 APIEndpoint: "https://api.openai.com/v1",
62− Models: []provider.Model{{
62+ Models: []catwalk.Model{{
6363 ID: "test-model",
6464 }},
6565 },
@@ -80,12 +80,12 @@ func TestConfig_configureProviders(t *testing.T) {
8080}
8181
8282func TestConfig_configureProvidersWithOverride(t *testing.T) {
83− knownProviders := []provider.Provider{
83+ knownProviders := []catwalk.Provider{
8484 {
8585 ID: "openai",
8686 APIKey: "$OPENAI_API_KEY",
8787 APIEndpoint: "https://api.openai.com/v1",
88− Models: []provider.Model{{
88+ Models: []catwalk.Model{{
8989 ID: "test-model",
9090 }},
9191 },
@@ -96,10 +96,10 @@ func TestConfig_configureProvidersWithOverride(t *testing.T) {
9696 "openai": {
9797 APIKey: "xyz",
9898 BaseURL: "https://api.openai.com/v2",
99− Models: []provider.Model{
99+ Models: []catwalk.Model{
100100 {
101− ID: "test-model",
102− Model: "Updated",
101+ ID: "test-model",
102+ Name: "Updated",
103103 },
104104 {
105105 ID: "another-model",
@@ -122,16 +122,16 @@ func TestConfig_configureProvidersWithOverride(t *testing.T) {
122122 assert.Equal(t, "xyz", cfg.Providers["openai"].APIKey)
123123 assert.Equal(t, "https://api.openai.com/v2", cfg.Providers["openai"].BaseURL)
124124 assert.Len(t, cfg.Providers["openai"].Models, 2)
125− assert.Equal(t, "Updated", cfg.Providers["openai"].Models[0].Model)
125+ assert.Equal(t, "Updated", cfg.Providers["openai"].Models[0].Name)
126126}
127127
128128func TestConfig_configureProvidersWithNewProvider(t *testing.T) {
129− knownProviders := []provider.Provider{
129+ knownProviders := []catwalk.Provider{
130130 {
131131 ID: "openai",
132132 APIKey: "$OPENAI_API_KEY",
133133 APIEndpoint: "https://api.openai.com/v1",
134− Models: []provider.Model{{
134+ Models: []catwalk.Model{{
135135 ID: "test-model",
136136 }},
137137 },
@@ -142,7 +142,7 @@ func TestConfig_configureProvidersWithNewProvider(t *testing.T) {
142142 "custom": {
143143 APIKey: "xyz",
144144 BaseURL: "https://api.someendpoint.com/v2",
145− Models: []provider.Model{
145+ Models: []catwalk.Model{
146146 {
147147 ID: "test-model",
148148 },
@@ -172,12 +172,12 @@ func TestConfig_configureProvidersWithNewProvider(t *testing.T) {
172172}
173173
174174func TestConfig_configureProvidersBedrockWithCredentials(t *testing.T) {
175− knownProviders := []provider.Provider{
175+ knownProviders := []catwalk.Provider{
176176 {
177− ID: provider.InferenceProviderBedrock,
177+ ID: catwalk.InferenceProviderBedrock,
178178 APIKey: "",
179179 APIEndpoint: "",
180− Models: []provider.Model{{
180+ Models: []catwalk.Model{{
181181 ID: "anthropic.claude-sonnet-4-20250514-v1:0",
182182 }},
183183 },
@@ -201,12 +201,12 @@ func TestConfig_configureProvidersBedrockWithCredentials(t *testing.T) {
201201}
202202
203203func TestConfig_configureProvidersBedrockWithoutCredentials(t *testing.T) {
204− knownProviders := []provider.Provider{
204+ knownProviders := []catwalk.Provider{
205205 {
206− ID: provider.InferenceProviderBedrock,
206+ ID: catwalk.InferenceProviderBedrock,
207207 APIKey: "",
208208 APIEndpoint: "",
209− Models: []provider.Model{{
209+ Models: []catwalk.Model{{
210210 ID: "anthropic.claude-sonnet-4-20250514-v1:0",
211211 }},
212212 },
@@ -223,12 +223,12 @@ func TestConfig_configureProvidersBedrockWithoutCredentials(t *testing.T) {
223223}
224224
225225func TestConfig_configureProvidersBedrockWithoutUnsupportedModel(t *testing.T) {
226− knownProviders := []provider.Provider{
226+ knownProviders := []catwalk.Provider{
227227 {
228− ID: provider.InferenceProviderBedrock,
228+ ID: catwalk.InferenceProviderBedrock,
229229 APIKey: "",
230230 APIEndpoint: "",
231− Models: []provider.Model{{
231+ Models: []catwalk.Model{{
232232 ID: "some-random-model",
233233 }},
234234 },
@@ -246,12 +246,12 @@ func TestConfig_configureProvidersBedrockWithoutUnsupportedModel(t *testing.T) {
246246}
247247
248248func TestConfig_configureProvidersVertexAIWithCredentials(t *testing.T) {
249− knownProviders := []provider.Provider{
249+ knownProviders := []catwalk.Provider{
250250 {
251− ID: provider.InferenceProviderVertexAI,
251+ ID: catwalk.InferenceProviderVertexAI,
252252 APIKey: "",
253253 APIEndpoint: "",
254− Models: []provider.Model{{
254+ Models: []catwalk.Model{{
255255 ID: "gemini-pro",
256256 }},
257257 },
@@ -278,12 +278,12 @@ func TestConfig_configureProvidersVertexAIWithCredentials(t *testing.T) {
278278}
279279
280280func TestConfig_configureProvidersVertexAIWithoutCredentials(t *testing.T) {
281− knownProviders := []provider.Provider{
281+ knownProviders := []catwalk.Provider{
282282 {
283− ID: provider.InferenceProviderVertexAI,
283+ ID: catwalk.InferenceProviderVertexAI,
284284 APIKey: "",
285285 APIEndpoint: "",
286− Models: []provider.Model{{
286+ Models: []catwalk.Model{{
287287 ID: "gemini-pro",
288288 }},
289289 },
@@ -304,12 +304,12 @@ func TestConfig_configureProvidersVertexAIWithoutCredentials(t *testing.T) {
304304}
305305
306306func TestConfig_configureProvidersVertexAIMissingProject(t *testing.T) {
307− knownProviders := []provider.Provider{
307+ knownProviders := []catwalk.Provider{
308308 {
309− ID: provider.InferenceProviderVertexAI,
309+ ID: catwalk.InferenceProviderVertexAI,
310310 APIKey: "",
311311 APIEndpoint: "",
312− Models: []provider.Model{{
312+ Models: []catwalk.Model{{
313313 ID: "gemini-pro",
314314 }},
315315 },
@@ -329,12 +329,12 @@ func TestConfig_configureProvidersVertexAIMissingProject(t *testing.T) {
329329}
330330
331331func TestConfig_configureProvidersSetProviderID(t *testing.T) {
332− knownProviders := []provider.Provider{
332+ knownProviders := []catwalk.Provider{
333333 {
334334 ID: "openai",
335335 APIKey: "$OPENAI_API_KEY",
336336 APIEndpoint: "https://api.openai.com/v1",
337− Models: []provider.Model{{
337+ Models: []catwalk.Model{{
338338 ID: "test-model",
339339 }},
340340 },
@@ -450,12 +450,12 @@ func TestConfig_IsConfigured(t *testing.T) {
450450}
451451
452452func TestConfig_configureProvidersWithDisabledProvider(t *testing.T) {
453− knownProviders := []provider.Provider{
453+ knownProviders := []catwalk.Provider{
454454 {
455455 ID: "openai",
456456 APIKey: "$OPENAI_API_KEY",
457457 APIEndpoint: "https://api.openai.com/v1",
458− Models: []provider.Model{{
458+ Models: []catwalk.Model{{
459459 ID: "test-model",
460460 }},
461461 },
@@ -489,7 +489,7 @@ func TestConfig_configureProvidersCustomProviderValidation(t *testing.T) {
489489 Providers: map[string]ProviderConfig{
490490 "custom": {
491491 BaseURL: "https://api.custom.com/v1",
492− Models: []provider.Model{{
492+ Models: []catwalk.Model{{
493493 ID: "test-model",
494494 }},
495495 },
@@ -502,7 +502,7 @@ func TestConfig_configureProvidersCustomProviderValidation(t *testing.T) {
502502
503503 env := env.NewFromMap(map[string]string{})
504504 resolver := NewEnvironmentVariableResolver(env)
505− err := cfg.configureProviders(env, resolver, []provider.Provider{})
505+ err := cfg.configureProviders(env, resolver, []catwalk.Provider{})
506506 assert.NoError(t, err)
507507
508508 assert.Len(t, cfg.Providers, 1)
@@ -515,7 +515,7 @@ func TestConfig_configureProvidersCustomProviderValidation(t *testing.T) {
515515 Providers: map[string]ProviderConfig{
516516 "custom": {
517517 APIKey: "test-key",
518− Models: []provider.Model{{
518+ Models: []catwalk.Model{{
519519 ID: "test-model",
520520 }},
521521 },
@@ -525,7 +525,7 @@ func TestConfig_configureProvidersCustomProviderValidation(t *testing.T) {
525525
526526 env := env.NewFromMap(map[string]string{})
527527 resolver := NewEnvironmentVariableResolver(env)
528− err := cfg.configureProviders(env, resolver, []provider.Provider{})
528+ err := cfg.configureProviders(env, resolver, []catwalk.Provider{})
529529 assert.NoError(t, err)
530530
531531 assert.Len(t, cfg.Providers, 0)
@@ -539,7 +539,7 @@ func TestConfig_configureProvidersCustomProviderValidation(t *testing.T) {
539539 "custom": {
540540 APIKey: "test-key",
541541 BaseURL: "https://api.custom.com/v1",
542− Models: []provider.Model{},
542+ Models: []catwalk.Model{},
543543 },
544544 },
545545 }
@@ -547,7 +547,7 @@ func TestConfig_configureProvidersCustomProviderValidation(t *testing.T) {
547547
548548 env := env.NewFromMap(map[string]string{})
549549 resolver := NewEnvironmentVariableResolver(env)
550− err := cfg.configureProviders(env, resolver, []provider.Provider{})
550+ err := cfg.configureProviders(env, resolver, []catwalk.Provider{})
551551 assert.NoError(t, err)
552552
553553 assert.Len(t, cfg.Providers, 0)
@@ -562,7 +562,7 @@ func TestConfig_configureProvidersCustomProviderValidation(t *testing.T) {
562562 APIKey: "test-key",
563563 BaseURL: "https://api.custom.com/v1",
564564 Type: "unsupported",
565− Models: []provider.Model{{
565+ Models: []catwalk.Model{{
566566 ID: "test-model",
567567 }},
568568 },
@@ -572,7 +572,7 @@ func TestConfig_configureProvidersCustomProviderValidation(t *testing.T) {
572572
573573 env := env.NewFromMap(map[string]string{})
574574 resolver := NewEnvironmentVariableResolver(env)
575− err := cfg.configureProviders(env, resolver, []provider.Provider{})
575+ err := cfg.configureProviders(env, resolver, []catwalk.Provider{})
576576 assert.NoError(t, err)
577577
578578 assert.Len(t, cfg.Providers, 0)
@@ -586,8 +586,8 @@ func TestConfig_configureProvidersCustomProviderValidation(t *testing.T) {
586586 "custom": {
587587 APIKey: "test-key",
588588 BaseURL: "https://api.custom.com/v1",
589− Type: provider.TypeOpenAI,
590− Models: []provider.Model{{
589+ Type: catwalk.TypeOpenAI,
590+ Models: []catwalk.Model{{
591591 ID: "test-model",
592592 }},
593593 },
@@ -597,7 +597,7 @@ func TestConfig_configureProvidersCustomProviderValidation(t *testing.T) {
597597
598598 env := env.NewFromMap(map[string]string{})
599599 resolver := NewEnvironmentVariableResolver(env)
600− err := cfg.configureProviders(env, resolver, []provider.Provider{})
600+ err := cfg.configureProviders(env, resolver, []catwalk.Provider{})
601601 assert.NoError(t, err)
602602
603603 assert.Len(t, cfg.Providers, 1)
@@ -614,9 +614,9 @@ func TestConfig_configureProvidersCustomProviderValidation(t *testing.T) {
614614 "custom": {
615615 APIKey: "test-key",
616616 BaseURL: "https://api.custom.com/v1",
617− Type: provider.TypeOpenAI,
617+ Type: catwalk.TypeOpenAI,
618618 Disable: true,
619− Models: []provider.Model{{
619+ Models: []catwalk.Model{{
620620 ID: "test-model",
621621 }},
622622 },
@@ -626,7 +626,7 @@ func TestConfig_configureProvidersCustomProviderValidation(t *testing.T) {
626626
627627 env := env.NewFromMap(map[string]string{})
628628 resolver := NewEnvironmentVariableResolver(env)
629− err := cfg.configureProviders(env, resolver, []provider.Provider{})
629+ err := cfg.configureProviders(env, resolver, []catwalk.Provider{})
630630 assert.NoError(t, err)
631631
632632 assert.Len(t, cfg.Providers, 0)
@@ -637,12 +637,12 @@ func TestConfig_configureProvidersCustomProviderValidation(t *testing.T) {
637637
638638func TestConfig_configureProvidersEnhancedCredentialValidation(t *testing.T) {
639639 t.Run("VertexAI provider removed when credentials missing with existing config", func(t *testing.T) {
640− knownProviders := []provider.Provider{
640+ knownProviders := []catwalk.Provider{
641641 {
642− ID: provider.InferenceProviderVertexAI,
642+ ID: catwalk.InferenceProviderVertexAI,
643643 APIKey: "",
644644 APIEndpoint: "",
645− Models: []provider.Model{{
645+ Models: []catwalk.Model{{
646646 ID: "gemini-pro",
647647 }},
648648 },
@@ -670,12 +670,12 @@ func TestConfig_configureProvidersEnhancedCredentialValidation(t *testing.T) {
670670 })
671671
672672 t.Run("Bedrock provider removed when AWS credentials missing with existing config", func(t *testing.T) {
673− knownProviders := []provider.Provider{
673+ knownProviders := []catwalk.Provider{
674674 {
675− ID: provider.InferenceProviderBedrock,
675+ ID: catwalk.InferenceProviderBedrock,
676676 APIKey: "",
677677 APIEndpoint: "",
678− Models: []provider.Model{{
678+ Models: []catwalk.Model{{
679679 ID: "anthropic.claude-sonnet-4-20250514-v1:0",
680680 }},
681681 },
@@ -701,12 +701,12 @@ func TestConfig_configureProvidersEnhancedCredentialValidation(t *testing.T) {
701701 })
702702
703703 t.Run("provider removed when API key missing with existing config", func(t *testing.T) {
704− knownProviders := []provider.Provider{
704+ knownProviders := []catwalk.Provider{
705705 {
706706 ID: "openai",
707707 APIKey: "$MISSING_API_KEY",
708708 APIEndpoint: "https://api.openai.com/v1",
709− Models: []provider.Model{{
709+ Models: []catwalk.Model{{
710710 ID: "test-model",
711711 }},
712712 },
@@ -732,12 +732,12 @@ func TestConfig_configureProvidersEnhancedCredentialValidation(t *testing.T) {
732732 })
733733
734734 t.Run("known provider should still be added if the endpoint is missing the client will use default endpoints", func(t *testing.T) {
735− knownProviders := []provider.Provider{
735+ knownProviders := []catwalk.Provider{
736736 {
737737 ID: "openai",
738738 APIKey: "$OPENAI_API_KEY",
739739 APIEndpoint: "$MISSING_ENDPOINT",
740− Models: []provider.Model{{
740+ Models: []catwalk.Model{{
741741 ID: "test-model",
742742 }},
743743 },
@@ -767,13 +767,13 @@ func TestConfig_configureProvidersEnhancedCredentialValidation(t *testing.T) {
767767
768768func TestConfig_defaultModelSelection(t *testing.T) {
769769 t.Run("default behavior uses the default models for given provider", func(t *testing.T) {
770− knownProviders := []provider.Provider{
770+ knownProviders := []catwalk.Provider{
771771 {
772772 ID: "openai",
773773 APIKey: "abc",
774774 DefaultLargeModelID: "large-model",
775775 DefaultSmallModelID: "small-model",
776− Models: []provider.Model{
776+ Models: []catwalk.Model{
777777 {
778778 ID: "large-model",
779779 DefaultMaxTokens: 1000,
@@ -803,13 +803,13 @@ func TestConfig_defaultModelSelection(t *testing.T) {
803803 assert.Equal(t, int64(500), small.MaxTokens)
804804 })
805805 t.Run("should error if no providers configured", func(t *testing.T) {
806− knownProviders := []provider.Provider{
806+ knownProviders := []catwalk.Provider{
807807 {
808808 ID: "openai",
809809 APIKey: "$MISSING_KEY",
810810 DefaultLargeModelID: "large-model",
811811 DefaultSmallModelID: "small-model",
812− Models: []provider.Model{
812+ Models: []catwalk.Model{
813813 {
814814 ID: "large-model",
815815 DefaultMaxTokens: 1000,
@@ -833,13 +833,13 @@ func TestConfig_defaultModelSelection(t *testing.T) {
833833 assert.Error(t, err)
834834 })
835835 t.Run("should error if model is missing", func(t *testing.T) {
836− knownProviders := []provider.Provider{
836+ knownProviders := []catwalk.Provider{
837837 {
838838 ID: "openai",
839839 APIKey: "abc",
840840 DefaultLargeModelID: "large-model",
841841 DefaultSmallModelID: "small-model",
842− Models: []provider.Model{
842+ Models: []catwalk.Model{
843843 {
844844 ID: "not-large-model",
845845 DefaultMaxTokens: 1000,
@@ -863,13 +863,13 @@ func TestConfig_defaultModelSelection(t *testing.T) {
863863 })
864864
865865 t.Run("should configure the default models with a custom provider", func(t *testing.T) {
866− knownProviders := []provider.Provider{
866+ knownProviders := []catwalk.Provider{
867867 {
868868 ID: "openai",
869869 APIKey: "$MISSING", // will not be included in the config
870870 DefaultLargeModelID: "large-model",
871871 DefaultSmallModelID: "small-model",
872− Models: []provider.Model{
872+ Models: []catwalk.Model{
873873 {
874874 ID: "not-large-model",
875875 DefaultMaxTokens: 1000,
@@ -887,7 +887,7 @@ func TestConfig_defaultModelSelection(t *testing.T) {
887887 "custom": {
888888 APIKey: "test-key",
889889 BaseURL: "https://api.custom.com/v1",
890− Models: []provider.Model{
890+ Models: []catwalk.Model{
891891 {
892892 ID: "model",
893893 DefaultMaxTokens: 600,
@@ -912,13 +912,13 @@ func TestConfig_defaultModelSelection(t *testing.T) {
912912 })
913913
914914 t.Run("should fail if no model configured", func(t *testing.T) {
915− knownProviders := []provider.Provider{
915+ knownProviders := []catwalk.Provider{
916916 {
917917 ID: "openai",
918918 APIKey: "$MISSING", // will not be included in the config
919919 DefaultLargeModelID: "large-model",
920920 DefaultSmallModelID: "small-model",
921− Models: []provider.Model{
921+ Models: []catwalk.Model{
922922 {
923923 ID: "not-large-model",
924924 DefaultMaxTokens: 1000,
@@ -936,7 +936,7 @@ func TestConfig_defaultModelSelection(t *testing.T) {
936936 "custom": {
937937 APIKey: "test-key",
938938 BaseURL: "https://api.custom.com/v1",
939− Models: []provider.Model{},
939+ Models: []catwalk.Model{},
940940 },
941941 },
942942 }
@@ -949,13 +949,13 @@ func TestConfig_defaultModelSelection(t *testing.T) {
949949 assert.Error(t, err)
950950 })
951951 t.Run("should use the default provider first", func(t *testing.T) {
952− knownProviders := []provider.Provider{
952+ knownProviders := []catwalk.Provider{
953953 {
954954 ID: "openai",
955955 APIKey: "set",
956956 DefaultLargeModelID: "large-model",
957957 DefaultSmallModelID: "small-model",
958− Models: []provider.Model{
958+ Models: []catwalk.Model{
959959 {
960960 ID: "large-model",
961961 DefaultMaxTokens: 1000,
@@ -973,7 +973,7 @@ func TestConfig_defaultModelSelection(t *testing.T) {
973973 "custom": {
974974 APIKey: "test-key",
975975 BaseURL: "https://api.custom.com/v1",
976− Models: []provider.Model{
976+ Models: []catwalk.Model{
977977 {
978978 ID: "large-model",
979979 DefaultMaxTokens: 1000,
@@ -1000,13 +1000,13 @@ func TestConfig_defaultModelSelection(t *testing.T) {
10001000
10011001func TestConfig_configureSelectedModels(t *testing.T) {
10021002 t.Run("should override defaults", func(t *testing.T) {
1003− knownProviders := []provider.Provider{
1003+ knownProviders := []catwalk.Provider{
10041004 {
10051005 ID: "openai",
10061006 APIKey: "abc",
10071007 DefaultLargeModelID: "large-model",
10081008 DefaultSmallModelID: "small-model",
1009− Models: []provider.Model{
1009+ Models: []catwalk.Model{
10101010 {
10111011 ID: "larger-model",
10121012 DefaultMaxTokens: 2000,
@@ -1048,13 +1048,13 @@ func TestConfig_configureSelectedModels(t *testing.T) {
10481048 assert.Equal(t, int64(500), small.MaxTokens)
10491049 })
10501050 t.Run("should be possible to use multiple providers", func(t *testing.T) {
1051− knownProviders := []provider.Provider{
1051+ knownProviders := []catwalk.Provider{
10521052 {
10531053 ID: "openai",
10541054 APIKey: "abc",
10551055 DefaultLargeModelID: "large-model",
10561056 DefaultSmallModelID: "small-model",
1057− Models: []provider.Model{
1057+ Models: []catwalk.Model{
10581058 {
10591059 ID: "large-model",
10601060 DefaultMaxTokens: 1000,
@@ -1070,7 +1070,7 @@ func TestConfig_configureSelectedModels(t *testing.T) {
10701070 APIKey: "abc",
10711071 DefaultLargeModelID: "a-large-model",
10721072 DefaultSmallModelID: "a-small-model",
1073− Models: []provider.Model{
1073+ Models: []catwalk.Model{
10741074 {
10751075 ID: "a-large-model",
10761076 DefaultMaxTokens: 1000,
@@ -1111,13 +1111,13 @@ func TestConfig_configureSelectedModels(t *testing.T) {
11111111 })
11121112
11131113 t.Run("should override the max tokens only", func(t *testing.T) {
1114− knownProviders := []provider.Provider{
1114+ knownProviders := []catwalk.Provider{
11151115 {
11161116 ID: "openai",
11171117 APIKey: "abc",
11181118 DefaultLargeModelID: "large-model",
11191119 DefaultSmallModelID: "small-model",
1120− Models: []provider.Model{
1120+ Models: []catwalk.Model{
11211121 {
11221122 ID: "large-model",
11231123 DefaultMaxTokens: 1000,
internal/config/provider.go+10−11
@@ -7,17 +7,16 @@ import (
77 "runtime"
88 "sync"
99
10− "github.com/charmbracelet/crush/internal/fur/client"
11− "github.com/charmbracelet/crush/internal/fur/provider"
10+ "github.com/charmbracelet/catwalk/pkg/catwalk"
1211)
1312
1413type ProviderClient interface {
15− GetProviders() ([]provider.Provider, error)
14+ GetProviders() ([]catwalk.Provider, error)
1615}
1716
1817var (
1918 providerOnce sync.Once
20− providerList []provider.Provider
19+ providerList []catwalk.Provider
2120)
2221
2322// file to cache provider data
@@ -41,7 +40,7 @@ func providerCacheFileData() string {
4140 return filepath.Join(os.Getenv("HOME"), ".local", "share", appName, "providers.json")
4241}
4342
44−func saveProvidersInCache(path string, providers []provider.Provider) error {
43+func saveProvidersInCache(path string, providers []catwalk.Provider) error {
4544 dir := filepath.Dir(path)
4645 if err := os.MkdirAll(dir, 0o755); err != nil {
4746 return err
@@ -55,18 +54,18 @@ func saveProvidersInCache(path string, providers []provider.Provider) error {
5554 return os.WriteFile(path, data, 0o644)
5655}
5756
58−func loadProvidersFromCache(path string) ([]provider.Provider, error) {
57+func loadProvidersFromCache(path string) ([]catwalk.Provider, error) {
5958 data, err := os.ReadFile(path)
6059 if err != nil {
6160 return nil, err
6261 }
6362
64− var providers []provider.Provider
63+ var providers []catwalk.Provider
6564 err = json.Unmarshal(data, &providers)
6665 return providers, err
6766}
6867
69−func loadProviders(path string, client ProviderClient) ([]provider.Provider, error) {
68+func loadProviders(path string, client ProviderClient) ([]catwalk.Provider, error) {
7069 providers, err := client.GetProviders()
7170 if err != nil {
7271 fallbackToCache, err := loadProvidersFromCache(path)
@@ -82,11 +81,11 @@ func loadProviders(path string, client ProviderClient) ([]provider.Provider, err
8281 return providers, nil
8382}
8483
85−func Providers() ([]provider.Provider, error) {
86− return LoadProviders(client.New())
84+func Providers() ([]catwalk.Provider, error) {
85+ return LoadProviders(catwalk.NewWithURL(catwalkURL))
8786}
8887
89−func LoadProviders(client ProviderClient) ([]provider.Provider, error) {
88+func LoadProviders(client ProviderClient) ([]catwalk.Provider, error) {
9089 var err error
9190 providerOnce.Do(func() {
9291 providerList, err = loadProviders(providerCacheFileData(), client)
internal/config/provider_test.go+4−4
@@ -6,7 +6,7 @@ import (
66 "os"
77 "testing"
88
9− "github.com/charmbracelet/crush/internal/fur/provider"
9+ "github.com/charmbracelet/catwalk/pkg/catwalk"
1010 "github.com/stretchr/testify/assert"
1111)
1212
@@ -14,11 +14,11 @@ type mockProviderClient struct {
1414 shouldFail bool
1515}
1616
17−func (m *mockProviderClient) GetProviders() ([]provider.Provider, error) {
17+func (m *mockProviderClient) GetProviders() ([]catwalk.Provider, error) {
1818 if m.shouldFail {
1919 return nil, errors.New("failed to load providers")
2020 }
21− return []provider.Provider{
21+ return []catwalk.Provider{
2222 {
2323 Name: "Mock",
2424 },
@@ -43,7 +43,7 @@ func TestProvider_loadProvidersWithIssues(t *testing.T) {
4343 client := &mockProviderClient{shouldFail: true}
4444 tmpPath := t.TempDir() + "/providers.json"
4545 // store providers to a temporary file
46− oldProviders := []provider.Provider{
46+ oldProviders := []catwalk.Provider{
4747 {
4848 Name: "OldProvider",
4949 },
internal/fur/client/client.godeleted−63
@@ -1,63 +0,0 @@
1−// Package client provides a client for interacting with the fur service.
2−package client
3−
4−import (
5− "encoding/json"
6− "fmt"
7− "net/http"
8− "os"
9−
10− "github.com/charmbracelet/crush/internal/fur/provider"
11−)
12−
13−const defaultURL = "https://fur.charm.sh"
14−
15−// Client represents a client for the fur service.
16−type Client struct {
17− baseURL string
18− httpClient *http.Client
19−}
20−
21−// New creates a new client instance
22−// Uses FUR_URL environment variable or falls back to localhost:8080.
23−func New() *Client {
24− baseURL := os.Getenv("FUR_URL")
25− if baseURL == "" {
26− baseURL = defaultURL
27− }
28−
29− return &Client{
30− baseURL: baseURL,
31− httpClient: &http.Client{},
32− }
33−}
34−
35−// NewWithURL creates a new client with a specific URL.
36−func NewWithURL(url string) *Client {
37− return &Client{
38− baseURL: url,
39− httpClient: &http.Client{},
40− }
41−}
42−
43−// GetProviders retrieves all available providers from the service.
44−func (c *Client) GetProviders() ([]provider.Provider, error) {
45− url := fmt.Sprintf("%s/providers", c.baseURL)
46−
47− resp, err := c.httpClient.Get(url) //nolint:noctx
48− if err != nil {
49− return nil, fmt.Errorf("failed to make request: %w", err)
50− }
51− defer resp.Body.Close() //nolint:errcheck
52−
53− if resp.StatusCode != http.StatusOK {
54− return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
55− }
56−
57− var providers []provider.Provider
58− if err := json.NewDecoder(resp.Body).Decode(&providers); err != nil {
59− return nil, fmt.Errorf("failed to decode response: %w", err)
60− }
61−
62− return providers, nil
63−}
internal/fur/provider/provider.godeleted−75
@@ -1,75 +0,0 @@
1−// Package provider provides types and constants for AI providers.
2−package provider
3−
4−// Type represents the type of AI provider.
5−type Type string
6−
7−// All the supported AI provider types.
8−const (
9− TypeOpenAI Type = "openai"
10− TypeAnthropic Type = "anthropic"
11− TypeGemini Type = "gemini"
12− TypeAzure Type = "azure"
13− TypeBedrock Type = "bedrock"
14− TypeVertexAI Type = "vertexai"
15− TypeXAI Type = "xai"
16−)
17−
18−// InferenceProvider represents the inference provider identifier.
19−type InferenceProvider string
20−
21−// All the inference providers supported by the system.
22−const (
23− InferenceProviderOpenAI InferenceProvider = "openai"
24− InferenceProviderAnthropic InferenceProvider = "anthropic"
25− InferenceProviderGemini InferenceProvider = "gemini"
26− InferenceProviderAzure InferenceProvider = "azure"
27− InferenceProviderBedrock InferenceProvider = "bedrock"
28− InferenceProviderVertexAI InferenceProvider = "vertexai"
29− InferenceProviderXAI InferenceProvider = "xai"
30− InferenceProviderGROQ InferenceProvider = "groq"
31− InferenceProviderOpenRouter InferenceProvider = "openrouter"
32−)
33−
34−// Provider represents an AI provider configuration.
35−type Provider struct {
36− Name string `json:"name"`
37− ID InferenceProvider `json:"id"`
38− APIKey string `json:"api_key,omitempty"`
39− APIEndpoint string `json:"api_endpoint,omitempty"`
40− Type Type `json:"type,omitempty"`
41− DefaultLargeModelID string `json:"default_large_model_id,omitempty"`
42− DefaultSmallModelID string `json:"default_small_model_id,omitempty"`
43− Models []Model `json:"models,omitempty"`
44−}
45−
46−// Model represents an AI model configuration.
47−type Model struct {
48− ID string `json:"id"`
49− Model string `json:"model"`
50− CostPer1MIn float64 `json:"cost_per_1m_in"`
51− CostPer1MOut float64 `json:"cost_per_1m_out"`
52− CostPer1MInCached float64 `json:"cost_per_1m_in_cached"`
53− CostPer1MOutCached float64 `json:"cost_per_1m_out_cached"`
54− ContextWindow int64 `json:"context_window"`
55− DefaultMaxTokens int64 `json:"default_max_tokens"`
56− CanReason bool `json:"can_reason"`
57− HasReasoningEffort bool `json:"has_reasoning_efforts"`
58− DefaultReasoningEffort string `json:"default_reasoning_effort,omitempty"`
59− SupportsImages bool `json:"supports_attachments"`
60−}
61−
62−// KnownProviders returns all the known inference providers.
63−func KnownProviders() []InferenceProvider {
64− return []InferenceProvider{
65− InferenceProviderOpenAI,
66− InferenceProviderAnthropic,
67− InferenceProviderGemini,
68− InferenceProviderAzure,
69− InferenceProviderBedrock,
70− InferenceProviderVertexAI,
71− InferenceProviderXAI,
72− InferenceProviderGROQ,
73− InferenceProviderOpenRouter,
74− }
75−}
internal/llm/agent/agent.go+7−7
@@ -10,8 +10,8 @@ import (
1010 "sync"
1111 "time"
1212
13+ "github.com/charmbracelet/catwalk/pkg/catwalk"
1314 "github.com/charmbracelet/crush/internal/config"
14− fur "github.com/charmbracelet/crush/internal/fur/provider"
1515 "github.com/charmbracelet/crush/internal/history"
1616 "github.com/charmbracelet/crush/internal/llm/prompt"
1717 "github.com/charmbracelet/crush/internal/llm/provider"
@@ -52,7 +52,7 @@ type AgentEvent struct {
5252
5353type Service interface {
5454 pubsub.Suscriber[AgentEvent]
55− Model() fur.Model
55+ Model() catwalk.Model
5656 Run(ctx context.Context, sessionID string, content string, attachments ...message.Attachment) (<-chan AgentEvent, error)
5757 Cancel(sessionID string)
5858 CancelAll()
@@ -219,7 +219,7 @@ func NewAgent(
219219 return agent, nil
220220}
221221
222−func (a *agent) Model() fur.Model {
222+func (a *agent) Model() catwalk.Model {
223223 return *config.Get().GetModelByType(a.agentCfg.Model)
224224}
225225
@@ -227,7 +227,7 @@ func (a *agent) Cancel(sessionID string) {
227227 // Cancel regular requests
228228 if cancelFunc, exists := a.activeRequests.LoadAndDelete(sessionID); exists {
229229 if cancel, ok := cancelFunc.(context.CancelFunc); ok {
230− slog.Info(fmt.Sprintf("Request cancellation initiated for session: %s", sessionID))
230+ slog.Info("Request cancellation initiated", "session_id", sessionID)
231231 cancel()
232232 }
233233 }
@@ -235,7 +235,7 @@ func (a *agent) Cancel(sessionID string) {
235235 // Also check for summarize requests
236236 if cancelFunc, exists := a.activeRequests.LoadAndDelete(sessionID + "-summarize"); exists {
237237 if cancel, ok := cancelFunc.(context.CancelFunc); ok {
238− slog.Info(fmt.Sprintf("Summarize cancellation initiated for session: %s", sessionID))
238+ slog.Info("Summarize cancellation initiated", "session_id", sessionID)
239239 cancel()
240240 }
241241 }
@@ -365,7 +365,7 @@ func (a *agent) processGeneration(ctx context.Context, sessionID, content string
365365 })
366366 titleErr := a.generateTitle(context.Background(), sessionID, content)
367367 if titleErr != nil && !errors.Is(titleErr, context.Canceled) && !errors.Is(titleErr, context.DeadlineExceeded) {
368− slog.Error(fmt.Sprintf("failed to generate title: %v", titleErr))
368+ slog.Error("failed to generate title", "error", titleErr)
369369 }
370370 }()
371371 }
@@ -638,7 +638,7 @@ func (a *agent) processEvent(ctx context.Context, sessionID string, assistantMsg
638638 return nil
639639}
640640
641−func (a *agent) TrackUsage(ctx context.Context, sessionID string, model fur.Model, usage provider.TokenUsage) error {
641+func (a *agent) TrackUsage(ctx context.Context, sessionID string, model catwalk.Model, usage provider.TokenUsage) error {
642642 sess, err := a.sessions.Get(ctx, sessionID)
643643 if err != nil {
644644 return fmt.Errorf("failed to get session: %w", err)
internal/llm/prompt/coder.go+3−3
@@ -9,17 +9,17 @@ import (
99 "runtime"
1010 "time"
1111
12+ "github.com/charmbracelet/catwalk/pkg/catwalk"
1213 "github.com/charmbracelet/crush/internal/config"
13− "github.com/charmbracelet/crush/internal/fur/provider"
1414 "github.com/charmbracelet/crush/internal/llm/tools"
1515)
1616
1717func CoderPrompt(p string, contextFiles ...string) string {
1818 var basePrompt string
1919 switch p {
20− case string(provider.InferenceProviderOpenAI):
20+ case string(catwalk.InferenceProviderOpenAI):
2121 basePrompt = baseOpenAICoderPrompt
22− case string(provider.InferenceProviderGemini), string(provider.InferenceProviderVertexAI):
22+ case string(catwalk.InferenceProviderGemini), string(catwalk.InferenceProviderVertexAI):
2323 basePrompt = baseGeminiCoderPrompt
2424 default:
2525 basePrompt = baseAnthropicCoderPrompt
internal/llm/provider/anthropic.go+5−5
@@ -15,8 +15,8 @@ import (
1515 "github.com/anthropics/anthropic-sdk-go"
1616 "github.com/anthropics/anthropic-sdk-go/bedrock"
1717 "github.com/anthropics/anthropic-sdk-go/option"
18+ "github.com/charmbracelet/catwalk/pkg/catwalk"
1819 "github.com/charmbracelet/crush/internal/config"
19− "github.com/charmbracelet/crush/internal/fur/provider"
2020 "github.com/charmbracelet/crush/internal/llm/tools"
2121 "github.com/charmbracelet/crush/internal/message"
2222)
@@ -71,7 +71,7 @@ func (a *anthropicClient) convertMessages(messages []message.Message) (anthropic
7171 var contentBlocks []anthropic.ContentBlockParamUnion
7272 contentBlocks = append(contentBlocks, content)
7373 for _, binaryContent := range msg.BinaryContent() {
74− base64Image := binaryContent.String(provider.InferenceProviderAnthropic)
74+ base64Image := binaryContent.String(catwalk.InferenceProviderAnthropic)
7575 imageBlock := anthropic.NewImageBlockBase64(binaryContent.MIMEType, base64Image)
7676 contentBlocks = append(contentBlocks, imageBlock)
7777 }
@@ -248,7 +248,7 @@ func (a *anthropicClient) send(ctx context.Context, messages []message.Message,
248248 return nil, retryErr
249249 }
250250 if retry {
251− slog.Warn(fmt.Sprintf("Retrying due to rate limit... attempt %d of %d", attempts, maxRetries))
251+ slog.Warn("Retrying due to rate limit", "attempt", attempts, "max_retries", maxRetries)
252252 select {
253253 case <-ctx.Done():
254254 return nil, ctx.Err()
@@ -401,7 +401,7 @@ func (a *anthropicClient) stream(ctx context.Context, messages []message.Message
401401 return
402402 }
403403 if retry {
404− slog.Warn(fmt.Sprintf("Retrying due to rate limit... attempt %d of %d", attempts, maxRetries))
404+ slog.Warn("Retrying due to rate limit", "attempt", attempts, "max_retries", maxRetries)
405405 select {
406406 case <-ctx.Done():
407407 // context cancelled
@@ -529,6 +529,6 @@ func (a *anthropicClient) usage(msg anthropic.Message) TokenUsage {
529529 }
530530}
531531
532−func (a *anthropicClient) Model() provider.Model {
532+func (a *anthropicClient) Model() catwalk.Model {
533533 return a.providerOptions.model(a.providerOptions.modelType)
534534}
internal/llm/provider/bedrock.go+3−3
@@ -6,8 +6,8 @@ import (
66 "fmt"
77 "strings"
88
9+ "github.com/charmbracelet/catwalk/pkg/catwalk"
910 "github.com/charmbracelet/crush/internal/config"
10− "github.com/charmbracelet/crush/internal/fur/provider"
1111 "github.com/charmbracelet/crush/internal/llm/tools"
1212 "github.com/charmbracelet/crush/internal/message"
1313)
@@ -32,7 +32,7 @@ func newBedrockClient(opts providerClientOptions) BedrockClient {
3232 }
3333 }
3434
35− opts.model = func(modelType config.SelectedModelType) provider.Model {
35+ opts.model = func(modelType config.SelectedModelType) catwalk.Model {
3636 model := config.Get().GetModelByType(modelType)
3737
3838 // Prefix the model name with region
@@ -88,6 +88,6 @@ func (b *bedrockClient) stream(ctx context.Context, messages []message.Message,
8888 return b.childProvider.stream(ctx, messages, tools)
8989}
9090
91−func (b *bedrockClient) Model() provider.Model {
91+func (b *bedrockClient) Model() catwalk.Model {
9292 return b.providerOptions.model(b.providerOptions.modelType)
9393}
internal/llm/provider/gemini.go+4−4
@@ -10,8 +10,8 @@ import (
1010 "strings"
1111 "time"
1212
13+ "github.com/charmbracelet/catwalk/pkg/catwalk"
1314 "github.com/charmbracelet/crush/internal/config"
14− "github.com/charmbracelet/crush/internal/fur/provider"
1515 "github.com/charmbracelet/crush/internal/llm/tools"
1616 "github.com/charmbracelet/crush/internal/message"
1717 "github.com/google/uuid"
@@ -210,7 +210,7 @@ func (g *geminiClient) send(ctx context.Context, messages []message.Message, too
210210 return nil, retryErr
211211 }
212212 if retry {
213− slog.Warn(fmt.Sprintf("Retrying due to rate limit... attempt %d of %d", attempts, maxRetries))
213+ slog.Warn("Retrying due to rate limit", "attempt", attempts, "max_retries", maxRetries)
214214 select {
215215 case <-ctx.Done():
216216 return nil, ctx.Err()
@@ -323,7 +323,7 @@ func (g *geminiClient) stream(ctx context.Context, messages []message.Message, t
323323 return
324324 }
325325 if retry {
326− slog.Warn(fmt.Sprintf("Retrying due to rate limit... attempt %d of %d", attempts, maxRetries))
326+ slog.Warn("Retrying due to rate limit", "attempt", attempts, "max_retries", maxRetries)
327327 select {
328328 case <-ctx.Done():
329329 if ctx.Err() != nil {
@@ -463,7 +463,7 @@ func (g *geminiClient) usage(resp *genai.GenerateContentResponse) TokenUsage {
463463 }
464464}
465465
466−func (g *geminiClient) Model() provider.Model {
466+func (g *geminiClient) Model() catwalk.Model {
467467 return g.providerOptions.model(g.providerOptions.modelType)
468468}
469469
internal/llm/provider/openai.go+5−5
@@ -9,8 +9,8 @@ import (
99 "log/slog"
1010 "time"
1111
12+ "github.com/charmbracelet/catwalk/pkg/catwalk"
1213 "github.com/charmbracelet/crush/internal/config"
13− "github.com/charmbracelet/crush/internal/fur/provider"
1414 "github.com/charmbracelet/crush/internal/llm/tools"
1515 "github.com/charmbracelet/crush/internal/message"
1616 "github.com/openai/openai-go"
@@ -66,7 +66,7 @@ func (o *openaiClient) convertMessages(messages []message.Message) (openaiMessag
6666 textBlock := openai.ChatCompletionContentPartTextParam{Text: msg.Content().String()}
6767 content = append(content, openai.ChatCompletionContentPartUnionParam{OfText: &textBlock})
6868 for _, binaryContent := range msg.BinaryContent() {
69− imageURL := openai.ChatCompletionContentPartImageImageURLParam{URL: binaryContent.String(provider.InferenceProviderOpenAI)}
69+ imageURL := openai.ChatCompletionContentPartImageImageURLParam{URL: binaryContent.String(catwalk.InferenceProviderOpenAI)}
7070 imageBlock := openai.ChatCompletionContentPartImageParam{ImageURL: imageURL}
7171
7272 content = append(content, openai.ChatCompletionContentPartUnionParam{OfImageURL: &imageBlock})
@@ -222,7 +222,7 @@ func (o *openaiClient) send(ctx context.Context, messages []message.Message, too
222222 return nil, retryErr
223223 }
224224 if retry {
225− slog.Warn(fmt.Sprintf("Retrying due to rate limit... attempt %d of %d", attempts, maxRetries))
225+ slog.Warn("Retrying due to rate limit", "attempt", attempts, "max_retries", maxRetries)
226226 select {
227227 case <-ctx.Done():
228228 return nil, ctx.Err()
@@ -395,7 +395,7 @@ func (o *openaiClient) stream(ctx context.Context, messages []message.Message, t
395395 return
396396 }
397397 if retry {
398− slog.Warn(fmt.Sprintf("Retrying due to rate limit... attempt %d of %d", attempts, maxRetries))
398+ slog.Warn("Retrying due to rate limit", "attempt", attempts, "max_retries", maxRetries)
399399 select {
400400 case <-ctx.Done():
401401 // context cancelled
@@ -486,6 +486,6 @@ func (o *openaiClient) usage(completion openai.ChatCompletion) TokenUsage {
486486 }
487487}
488488
489−func (o *openaiClient) Model() provider.Model {
489+func (o *openaiClient) Model() catwalk.Model {
490490 return o.providerOptions.model(o.providerOptions.modelType)
491491}
internal/llm/provider/openai_test.go+5−5
@@ -9,8 +9,8 @@ import (
99 "testing"
1010 "time"
1111
12+ "github.com/charmbracelet/catwalk/pkg/catwalk"
1213 "github.com/charmbracelet/crush/internal/config"
13− "github.com/charmbracelet/crush/internal/fur/provider"
1414 "github.com/charmbracelet/crush/internal/llm/tools"
1515 "github.com/charmbracelet/crush/internal/message"
1616 "github.com/openai/openai-go"
@@ -55,10 +55,10 @@ func TestOpenAIClientStreamChoices(t *testing.T) {
5555 modelType: config.SelectedModelTypeLarge,
5656 apiKey: "test-key",
5757 systemMessage: "test",
58− model: func(config.SelectedModelType) provider.Model {
59− return provider.Model{
60− ID: "test-model",
61− Model: "test-model",
58+ model: func(config.SelectedModelType) catwalk.Model {
59+ return catwalk.Model{
60+ ID: "test-model",
61+ Name: "test-model",
6262 }
6363 },
6464 },
internal/llm/provider/provider.go+13−13
@@ -4,8 +4,8 @@ import (
44 "context"
55 "fmt"
66
7+ "github.com/charmbracelet/catwalk/pkg/catwalk"
78 "github.com/charmbracelet/crush/internal/config"
8− "github.com/charmbracelet/crush/internal/fur/provider"
99 "github.com/charmbracelet/crush/internal/llm/tools"
1010 "github.com/charmbracelet/crush/internal/message"
1111)
@@ -57,7 +57,7 @@ type Provider interface {
5757
5858 StreamResponse(ctx context.Context, messages []message.Message, tools []tools.BaseTool) <-chan ProviderEvent
5959
60− Model() provider.Model
60+ Model() catwalk.Model
6161}
6262
6363type providerClientOptions struct {
@@ -65,7 +65,7 @@ type providerClientOptions struct {
6565 config config.ProviderConfig
6666 apiKey string
6767 modelType config.SelectedModelType
68− model func(config.SelectedModelType) provider.Model
68+ model func(config.SelectedModelType) catwalk.Model
6969 disableCache bool
7070 systemMessage string
7171 maxTokens int64
@@ -80,7 +80,7 @@ type ProviderClient interface {
8080 send(ctx context.Context, messages []message.Message, tools []tools.BaseTool) (*ProviderResponse, error)
8181 stream(ctx context.Context, messages []message.Message, tools []tools.BaseTool) <-chan ProviderEvent
8282
83− Model() provider.Model
83+ Model() catwalk.Model
8484}
8585
8686type baseProvider[C ProviderClient] struct {
@@ -109,7 +109,7 @@ func (p *baseProvider[C]) StreamResponse(ctx context.Context, messages []message
109109 return p.client.stream(ctx, messages, tools)
110110}
111111
112−func (p *baseProvider[C]) Model() provider.Model {
112+func (p *baseProvider[C]) Model() catwalk.Model {
113113 return p.client.Model()
114114}
115115
@@ -149,7 +149,7 @@ func NewProvider(cfg config.ProviderConfig, opts ...ProviderClientOption) (Provi
149149 apiKey: resolvedAPIKey,
150150 extraHeaders: cfg.ExtraHeaders,
151151 extraBody: cfg.ExtraBody,
152− model: func(tp config.SelectedModelType) provider.Model {
152+ model: func(tp config.SelectedModelType) catwalk.Model {
153153 return *config.Get().GetModelByType(tp)
154154 },
155155 }
@@ -157,37 +157,37 @@ func NewProvider(cfg config.ProviderConfig, opts ...ProviderClientOption) (Provi
157157 o(&clientOptions)
158158 }
159159 switch cfg.Type {
160− case provider.TypeAnthropic:
160+ case catwalk.TypeAnthropic:
161161 return &baseProvider[AnthropicClient]{
162162 options: clientOptions,
163163 client: newAnthropicClient(clientOptions, false),
164164 }, nil
165− case provider.TypeOpenAI:
165+ case catwalk.TypeOpenAI:
166166 return &baseProvider[OpenAIClient]{
167167 options: clientOptions,
168168 client: newOpenAIClient(clientOptions),
169169 }, nil
170− case provider.TypeGemini:
170+ case catwalk.TypeGemini:
171171 return &baseProvider[GeminiClient]{
172172 options: clientOptions,
173173 client: newGeminiClient(clientOptions),
174174 }, nil
175− case provider.TypeBedrock:
175+ case catwalk.TypeBedrock:
176176 return &baseProvider[BedrockClient]{
177177 options: clientOptions,
178178 client: newBedrockClient(clientOptions),
179179 }, nil
180− case provider.TypeAzure:
180+ case catwalk.TypeAzure:
181181 return &baseProvider[AzureClient]{
182182 options: clientOptions,
183183 client: newAzureClient(clientOptions),
184184 }, nil
185− case provider.TypeVertexAI:
185+ case catwalk.TypeVertexAI:
186186 return &baseProvider[VertexAIClient]{
187187 options: clientOptions,
188188 client: newVertexAIClient(clientOptions),
189189 }, nil
190− case provider.TypeXAI:
190+ case catwalk.TypeXAI:
191191 clientOptions.baseURL = "https://api.x.ai/v1"
192192 return &baseProvider[OpenAIClient]{
193193 options: clientOptions,
internal/llm/tools/glob.go+1−1
@@ -146,7 +146,7 @@ func globFiles(pattern, searchPath string, limit int) ([]string, bool, error) {
146146 if err == nil {
147147 return matches, len(matches) >= limit && limit > 0, nil
148148 }
149− slog.Warn(fmt.Sprintf("Ripgrep execution failed: %v. Falling back to doublestar.", err))
149+ slog.Warn("Ripgrep execution failed, falling back to doublestar", "error", err)
150150 }
151151
152152 return fsext.GlobWithDoubleStar(pattern, searchPath, limit)
internal/lsp/watcher/watcher.go+1−1
@@ -90,7 +90,7 @@ func (w *WorkspaceWatcher) AddRegistrations(ctx context.Context, id string, watc
9090 slog.Debug("BaseURI", "baseURI", u)
9191 }
9292 default:
93− slog.Debug("GlobPattern", "unknown type", fmt.Sprintf("%T", v))
93+ slog.Debug("GlobPattern unknown type", "type", fmt.Sprintf("%T", v))
9494 }
9595
9696 // Log WatchKind
internal/message/content.go+3−3
@@ -5,7 +5,7 @@ import (
55 "slices"
66 "time"
77
8− "github.com/charmbracelet/crush/internal/fur/provider"
8+ "github.com/charmbracelet/catwalk/pkg/catwalk"
99)
1010
1111type MessageRole string
@@ -74,9 +74,9 @@ type BinaryContent struct {
7474 Data []byte
7575}
7676
77−func (bc BinaryContent) String(p provider.InferenceProvider) string {
77+func (bc BinaryContent) String(p catwalk.InferenceProvider) string {
7878 base64Encoded := base64.StdEncoding.EncodeToString(bc.Data)
79− if p == provider.InferenceProviderOpenAI {
79+ if p == catwalk.InferenceProviderOpenAI {
8080 return "data:" + bc.MIMEType + ";base64," + base64Encoded
8181 }
8282 return base64Encoded
internal/tui/components/chat/editor/editor.go+5−3
@@ -187,9 +187,11 @@ func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
187187 value = value[:m.completionsStartIndex]
188188 value += item.Path
189189 m.textarea.SetValue(value)
190− m.isCompletionsOpen = false
191− m.currentQuery = ""
192− m.completionsStartIndex = 0
190+ if !msg.Insert {
191+ m.isCompletionsOpen = false
192+ m.currentQuery = ""
193+ m.completionsStartIndex = 0
194+ }
193195 return m, nil
194196 }
195197 case openEditorMsg:
internal/tui/components/chat/messages/messages.go+4−4
@@ -8,11 +8,11 @@ import (
88
99 "github.com/charmbracelet/bubbles/v2/viewport"
1010 tea "github.com/charmbracelet/bubbletea/v2"
11+ "github.com/charmbracelet/catwalk/pkg/catwalk"
1112 "github.com/charmbracelet/lipgloss/v2"
1213 "github.com/charmbracelet/x/ansi"
1314
1415 "github.com/charmbracelet/crush/internal/config"
15− "github.com/charmbracelet/crush/internal/fur/provider"
1616 "github.com/charmbracelet/crush/internal/message"
1717 "github.com/charmbracelet/crush/internal/tui/components/anim"
1818 "github.com/charmbracelet/crush/internal/tui/components/core"
@@ -369,11 +369,11 @@ func (m *assistantSectionModel) View() string {
369369 model := config.Get().GetModel(m.message.Provider, m.message.Model)
370370 if model == nil {
371371 // This means the model is not configured anymore
372− model = &provider.Model{
373− Model: "Unknown Model",
372+ model = &catwalk.Model{
373+ Name: "Unknown Model",
374374 }
375375 }
376− modelFormatted := t.S().Muted.Render(model.Model)
376+ modelFormatted := t.S().Muted.Render(model.Name)
377377 assistant := fmt.Sprintf("%s %s %s", icon, modelFormatted, infoMsg)
378378 return t.S().Base.PaddingLeft(2).Render(
379379 core.Section(assistant, m.width-2),
internal/tui/components/chat/sidebar/sidebar.go+4−4
@@ -9,10 +9,10 @@ import (
99 "sync"
1010
1111 tea "github.com/charmbracelet/bubbletea/v2"
12+ "github.com/charmbracelet/catwalk/pkg/catwalk"
1213 "github.com/charmbracelet/crush/internal/config"
1314 "github.com/charmbracelet/crush/internal/diff"
1415 "github.com/charmbracelet/crush/internal/fsext"
15− "github.com/charmbracelet/crush/internal/fur/provider"
1616 "github.com/charmbracelet/crush/internal/history"
1717 "github.com/charmbracelet/crush/internal/lsp"
1818 "github.com/charmbracelet/crush/internal/lsp/protocol"
@@ -897,7 +897,7 @@ func (s *sidebarCmp) currentModelBlock() string {
897897 t := styles.CurrentTheme()
898898
899899 modelIcon := t.S().Base.Foreground(t.FgSubtle).Render(styles.ModelIcon)
900− modelName := t.S().Text.Render(model.Model)
900+ modelName := t.S().Text.Render(model.Name)
901901 modelInfo := fmt.Sprintf("%s %s", modelIcon, modelName)
902902 parts := []string{
903903 modelInfo,
@@ -905,14 +905,14 @@ func (s *sidebarCmp) currentModelBlock() string {
905905 if model.CanReason {
906906 reasoningInfoStyle := t.S().Subtle.PaddingLeft(2)
907907 switch modelProvider.Type {
908− case provider.TypeOpenAI:
908+ case catwalk.TypeOpenAI:
909909 reasoningEffort := model.DefaultReasoningEffort
910910 if selectedModel.ReasoningEffort != "" {
911911 reasoningEffort = selectedModel.ReasoningEffort
912912 }
913913 formatter := cases.Title(language.English, cases.NoLower)
914914 parts = append(parts, reasoningInfoStyle.Render(formatter.String(fmt.Sprintf("Reasoning %s", reasoningEffort))))
915− case provider.TypeAnthropic:
915+ case catwalk.TypeAnthropic:
916916 formatter := cases.Title(language.English, cases.NoLower)
917917 if selectedModel.Think {
918918 parts = append(parts, reasoningInfoStyle.Render(formatter.String("Thinking on")))
internal/tui/components/chat/splash/splash.go+3−3
@@ -10,8 +10,8 @@ import (
1010 "github.com/charmbracelet/bubbles/v2/key"
1111 "github.com/charmbracelet/bubbles/v2/spinner"
1212 tea "github.com/charmbracelet/bubbletea/v2"
13+ "github.com/charmbracelet/catwalk/pkg/catwalk"
1314 "github.com/charmbracelet/crush/internal/config"
14− "github.com/charmbracelet/crush/internal/fur/provider"
1515 "github.com/charmbracelet/crush/internal/llm/prompt"
1616 "github.com/charmbracelet/crush/internal/tui/components/chat"
1717 "github.com/charmbracelet/crush/internal/tui/components/completions"
@@ -109,7 +109,7 @@ func (s *splashCmp) SetOnboarding(onboarding bool) {
109109 if err != nil {
110110 return
111111 }
112− filteredProviders := []provider.Provider{}
112+ filteredProviders := []catwalk.Provider{}
113113 simpleProviders := []string{
114114 "anthropic",
115115 "openai",
@@ -407,7 +407,7 @@ func (s *splashCmp) setPreferredModel(selectedItem models.ModelOption) tea.Cmd {
407407 return nil
408408}
409409
410−func (s *splashCmp) getProvider(providerID provider.InferenceProvider) (*provider.Provider, error) {
410+func (s *splashCmp) getProvider(providerID catwalk.InferenceProvider) (*catwalk.Provider, error) {
411411 providers, err := config.Providers()
412412 if err != nil {
413413 return nil, err
internal/tui/components/completions/completions.go+26−1
@@ -36,7 +36,8 @@ type CompletionsOpenedMsg struct{}
3636type CloseCompletionsMsg struct{}
3737
3838type SelectCompletionMsg struct {
39− Value any // The value of the selected completion item
39+ Value any // The value of the selected completion item
40+ Insert bool
4041}
4142
4243type Completions interface {
@@ -115,6 +116,30 @@ func (c *completionsCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
115116 d, cmd := c.list.Update(msg)
116117 c.list = d.(list.ListModel)
117118 return c, cmd
119+ case key.Matches(msg, c.keyMap.UpInsert):
120+ selectedItemInx := c.list.SelectedIndex() - 1
121+ items := c.list.Items()
122+ if selectedItemInx == list.NoSelection || selectedItemInx < 0 {
123+ return c, nil // No item selected, do nothing
124+ }
125+ selectedItem := items[selectedItemInx].(CompletionItem).Value()
126+ c.list.SetSelected(selectedItemInx)
127+ return c, util.CmdHandler(SelectCompletionMsg{
128+ Value: selectedItem,
129+ Insert: true,
130+ })
131+ case key.Matches(msg, c.keyMap.DownInsert):
132+ selectedItemInx := c.list.SelectedIndex() + 1
133+ items := c.list.Items()
134+ if selectedItemInx == list.NoSelection || selectedItemInx >= len(items) {
135+ return c, nil // No item selected, do nothing
136+ }
137+ selectedItem := items[selectedItemInx].(CompletionItem).Value()
138+ c.list.SetSelected(selectedItemInx)
139+ return c, util.CmdHandler(SelectCompletionMsg{
140+ Value: selectedItem,
141+ Insert: true,
142+ })
118143 case key.Matches(msg, c.keyMap.Select):
119144 selectedItemInx := c.list.SelectedIndex()
120145 if selectedItemInx == list.NoSelection {
internal/tui/components/completions/keys.go+10
@@ -9,6 +9,8 @@ type KeyMap struct {
99 Up,
1010 Select,
1111 Cancel key.Binding
12+ DownInsert,
13+ UpInsert key.Binding
1214}
1315
1416func DefaultKeyMap() KeyMap {
@@ -29,6 +31,14 @@ func DefaultKeyMap() KeyMap {
2931 key.WithKeys("esc"),
3032 key.WithHelp("esc", "cancel"),
3133 ),
34+ DownInsert: key.NewBinding(
35+ key.WithKeys("ctrl+n"),
36+ key.WithHelp("ctrl+n", "insert next"),
37+ ),
38+ UpInsert: key.NewBinding(
39+ key.WithKeys("ctrl+p"),
40+ key.WithHelp("ctrl+p", "insert previous"),
41+ ),
3242 }
3343}
3444
internal/tui/components/dialogs/commands/commands.go+2−2
@@ -4,10 +4,10 @@ import (
44 "github.com/charmbracelet/bubbles/v2/help"
55 "github.com/charmbracelet/bubbles/v2/key"
66 tea "github.com/charmbracelet/bubbletea/v2"
7+ "github.com/charmbracelet/catwalk/pkg/catwalk"
78 "github.com/charmbracelet/lipgloss/v2"
89
910 "github.com/charmbracelet/crush/internal/config"
10− "github.com/charmbracelet/crush/internal/fur/provider"
1111 "github.com/charmbracelet/crush/internal/llm/prompt"
1212 "github.com/charmbracelet/crush/internal/tui/components/chat"
1313 "github.com/charmbracelet/crush/internal/tui/components/completions"
@@ -270,7 +270,7 @@ func (c *commandDialogCmp) defaultCommands() []Command {
270270 providerCfg := cfg.GetProviderForModel(agentCfg.Model)
271271 model := cfg.GetModelByType(agentCfg.Model)
272272 if providerCfg != nil && model != nil &&
273− providerCfg.Type == provider.TypeAnthropic && model.CanReason {
273+ providerCfg.Type == catwalk.TypeAnthropic && model.CanReason {
274274 selectedModel := cfg.Models[agentCfg.Model]
275275 status := "Enable"
276276 if selectedModel.Think {
internal/tui/components/dialogs/models/list.go+11−11
@@ -5,8 +5,8 @@ import (
55 "slices"
66
77 tea "github.com/charmbracelet/bubbletea/v2"
8+ "github.com/charmbracelet/catwalk/pkg/catwalk"
89 "github.com/charmbracelet/crush/internal/config"
9− "github.com/charmbracelet/crush/internal/fur/provider"
1010 "github.com/charmbracelet/crush/internal/tui/components/completions"
1111 "github.com/charmbracelet/crush/internal/tui/components/core/list"
1212 "github.com/charmbracelet/crush/internal/tui/components/dialogs/commands"
@@ -18,7 +18,7 @@ import (
1818type ModelListComponent struct {
1919 list list.ListModel
2020 modelType int
21− providers []provider.Provider
21+ providers []catwalk.Provider
2222}
2323
2424func NewModelListComponent(keyMap list.KeyMap, inputStyle lipgloss.Style, inputPlaceholder string) *ModelListComponent {
@@ -109,19 +109,19 @@ func (m *ModelListComponent) SetModelType(modelType int) tea.Cmd {
109109 }
110110
111111 // Check if this provider is not in the known providers list
112− if !slices.ContainsFunc(knownProviders, func(p provider.Provider) bool { return p.ID == provider.InferenceProvider(providerID) }) {
112+ if !slices.ContainsFunc(knownProviders, func(p catwalk.Provider) bool { return p.ID == catwalk.InferenceProvider(providerID) }) {
113113 // Convert config provider to provider.Provider format
114− configProvider := provider.Provider{
114+ configProvider := catwalk.Provider{
115115 Name: providerConfig.Name,
116− ID: provider.InferenceProvider(providerID),
117− Models: make([]provider.Model, len(providerConfig.Models)),
116+ ID: catwalk.InferenceProvider(providerID),
117+ Models: make([]catwalk.Model, len(providerConfig.Models)),
118118 }
119119
120120 // Convert models
121121 for i, model := range providerConfig.Models {
122− configProvider.Models[i] = provider.Model{
122+ configProvider.Models[i] = catwalk.Model{
123123 ID: model.ID,
124− Model: model.Model,
124+ Name: model.Name,
125125 CostPer1MIn: model.CostPer1MIn,
126126 CostPer1MOut: model.CostPer1MOut,
127127 CostPer1MInCached: model.CostPer1MInCached,
@@ -144,7 +144,7 @@ func (m *ModelListComponent) SetModelType(modelType int) tea.Cmd {
144144 section.SetInfo(configured)
145145 modelItems = append(modelItems, section)
146146 for _, model := range configProvider.Models {
147− modelItems = append(modelItems, completions.NewCompletionItem(model.Model, ModelOption{
147+ modelItems = append(modelItems, completions.NewCompletionItem(model.Name, ModelOption{
148148 Provider: configProvider,
149149 Model: model,
150150 }))
@@ -179,7 +179,7 @@ func (m *ModelListComponent) SetModelType(modelType int) tea.Cmd {
179179 }
180180 modelItems = append(modelItems, section)
181181 for _, model := range provider.Models {
182− modelItems = append(modelItems, completions.NewCompletionItem(model.Model, ModelOption{
182+ modelItems = append(modelItems, completions.NewCompletionItem(model.Name, ModelOption{
183183 Provider: provider,
184184 Model: model,
185185 }))
@@ -201,6 +201,6 @@ func (m *ModelListComponent) SetInputPlaceholder(placeholder string) {
201201 m.list.SetFilterPlaceholder(placeholder)
202202}
203203
204−func (m *ModelListComponent) SetProviders(providers []provider.Provider) {
204+func (m *ModelListComponent) SetProviders(providers []catwalk.Provider) {
205205 m.providers = providers
206206}
internal/tui/components/dialogs/models/models.go+4−4
@@ -8,8 +8,8 @@ import (
88 "github.com/charmbracelet/bubbles/v2/key"
99 "github.com/charmbracelet/bubbles/v2/spinner"
1010 tea "github.com/charmbracelet/bubbletea/v2"
11+ "github.com/charmbracelet/catwalk/pkg/catwalk"
1112 "github.com/charmbracelet/crush/internal/config"
12− "github.com/charmbracelet/crush/internal/fur/provider"
1313 "github.com/charmbracelet/crush/internal/tui/components/completions"
1414 "github.com/charmbracelet/crush/internal/tui/components/core"
1515 "github.com/charmbracelet/crush/internal/tui/components/core/list"
@@ -48,8 +48,8 @@ type ModelDialog interface {
4848}
4949
5050type ModelOption struct {
51− Provider provider.Provider
52− Model provider.Model
51+ Provider catwalk.Provider
52+ Model catwalk.Model
5353}
5454
5555type modelDialogCmp struct {
@@ -363,7 +363,7 @@ func (m *modelDialogCmp) isProviderConfigured(providerID string) bool {
363363 return false
364364}
365365
366−func (m *modelDialogCmp) getProvider(providerID provider.InferenceProvider) (*provider.Provider, error) {
366+func (m *modelDialogCmp) getProvider(providerID catwalk.InferenceProvider) (*catwalk.Provider, error) {
367367 providers, err := config.Providers()
368368 if err != nil {
369369 return nil, err
internal/tui/page/chat/chat.go+1−1
@@ -279,7 +279,7 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
279279 if model.SupportsImages {
280280 return p, util.CmdHandler(OpenFilePickerMsg{})
281281 } else {
282− return p, util.ReportWarn("File attachments are not supported by the current model: " + model.Model)
282+ return p, util.ReportWarn("File attachments are not supported by the current model: " + model.Name)
283283 }
284284 case key.Matches(msg, p.keyMap.Tab):
285285 if p.session.ID == "" {
internal/tui/tui.go+41−23
@@ -3,6 +3,7 @@ package tui
33import (
44 "context"
55 "fmt"
6+ "strings"
67
78 "github.com/charmbracelet/bubbles/v2/key"
89 tea "github.com/charmbracelet/bubbletea/v2"
@@ -112,6 +113,7 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
112113 }
113114 return a, tea.Batch(cmds...)
114115 case tea.WindowSizeMsg:
116+ a.wWidth, a.wHeight = msg.Width, msg.Height
115117 a.completions.Update(msg)
116118 return a, a.handleWindowResize(msg.Width, msg.Height)
117119
@@ -290,7 +292,6 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
290292// handleWindowResize processes window resize events and updates all components.
291293func (a *appModel) handleWindowResize(width, height int) tea.Cmd {
292294 var cmds []tea.Cmd
293− a.wWidth, a.wHeight = width, height
294295 if a.showingFullHelp {
295296 height -= 5
296297 } else {
@@ -319,26 +320,20 @@ func (a *appModel) handleWindowResize(width, height int) tea.Cmd {
319320
320321// handleKeyPressMsg processes keyboard input and routes to appropriate handlers.
321322func (a *appModel) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
323+ if a.completions.Open() {
324+ // completions
325+ keyMap := a.completions.KeyMap()
326+ switch {
327+ case key.Matches(msg, keyMap.Up), key.Matches(msg, keyMap.Down),
328+ key.Matches(msg, keyMap.Select), key.Matches(msg, keyMap.Cancel),
329+ key.Matches(msg, keyMap.UpInsert), key.Matches(msg, keyMap.DownInsert):
330+ u, cmd := a.completions.Update(msg)
331+ a.completions = u.(completions.Completions)
332+ return cmd
333+ }
334+ }
322335 switch {
323− // completions
324− case a.completions.Open() && key.Matches(msg, a.completions.KeyMap().Up):
325− u, cmd := a.completions.Update(msg)
326− a.completions = u.(completions.Completions)
327− return cmd
328−
329− case a.completions.Open() && key.Matches(msg, a.completions.KeyMap().Down):
330− u, cmd := a.completions.Update(msg)
331− a.completions = u.(completions.Completions)
332− return cmd
333− case a.completions.Open() && key.Matches(msg, a.completions.KeyMap().Select):
334− u, cmd := a.completions.Update(msg)
335− a.completions = u.(completions.Completions)
336− return cmd
337− case a.completions.Open() && key.Matches(msg, a.completions.KeyMap().Cancel):
338− u, cmd := a.completions.Update(msg)
339− a.completions = u.(completions.Completions)
340− return cmd
341− // help
336+ // help
342337 case key.Matches(msg, a.keyMap.Help):
343338 a.status.ToggleFullHelp()
344339 a.showingFullHelp = !a.showingFullHelp
@@ -429,6 +424,27 @@ func (a *appModel) moveToPage(pageID page.PageID) tea.Cmd {
429424
430425// View renders the complete application interface including pages, dialogs, and overlays.
431426func (a *appModel) View() tea.View {
427+ var view tea.View
428+ t := styles.CurrentTheme()
429+ view.BackgroundColor = t.BgBase
430+ if a.wWidth < 25 || a.wHeight < 15 {
431+ view.Layer = lipgloss.NewCanvas(
432+ lipgloss.NewLayer(
433+ t.S().Base.Width(a.wWidth).Height(a.wHeight).
434+ Align(lipgloss.Center, lipgloss.Center).
435+ Render(
436+ t.S().Base.
437+ Padding(1, 4).
438+ Foreground(t.White).
439+ BorderStyle(lipgloss.RoundedBorder()).
440+ BorderForeground(t.Primary).
441+ Render("Window too small!"),
442+ ),
443+ ),
444+ )
445+ return view
446+ }
447+
432448 page := a.pages[a.currentPage]
433449 if withHelp, ok := page.(core.KeyMapHelp); ok {
434450 a.status.SetKeyMap(withHelp.Help())
@@ -453,6 +469,11 @@ func (a *appModel) View() tea.View {
453469 var cursor *tea.Cursor
454470 if v, ok := page.(util.Cursor); ok {
455471 cursor = v.Cursor()
472+ // Hide the cursor if it's positioned outside the textarea
473+ statusHeight := a.height - strings.Count(pageView, "\n") + 1
474+ if cursor != nil && cursor.Y+statusHeight+chat.EditorHeight-2 <= a.height { // 2 for the top and bottom app padding
475+ cursor = nil
476+ }
456477 }
457478 activeView := a.dialog.ActiveModel()
458479 if activeView != nil {
@@ -475,10 +496,7 @@ func (a *appModel) View() tea.View {
475496 layers...,
476497 )
477498
478− var view tea.View
479− t := styles.CurrentTheme()
480499 view.Layer = canvas
481− view.BackgroundColor = t.BgBase
482500 view.Cursor = cursor
483501 return view
484502}
main.go+1−2
@@ -1,7 +1,6 @@
11package main
22
33import (
4− "fmt"
54 "log/slog"
65 "net/http"
76 "os"
@@ -23,7 +22,7 @@ func main() {
2322 go func() {
2423 slog.Info("Serving pprof at localhost:6060")
2524 if httpErr := http.ListenAndServe("localhost:6060", nil); httpErr != nil {
26− slog.Error(fmt.Sprintf("Failed to pprof listen: %v", httpErr))
25+ slog.Error("Failed to pprof listen", "error", httpErr)
2726 }
2827 }()
2928 }