on main help sign in
chore: initial setup for the new config

wip

commit85fe73a307225441023e4e0232bc791d16804283 authorKujtim Hoxha <kujtimii.h@gmail.com> dateTue 24 Jun 2025, 12:22 (1 year ago) parent1fa4e8a
29 files changed+794−60
go.mod+4−1 internal/config/config.go+13−13 internal/config_v2/config.go+440 internal/config_v2/config_test.go+33 internal/config_v2/fs.go+71 internal/config_v2/provider.go+69 internal/llm/models/anthropic.go+1−1 internal/llm/models/azure.go+1−1 internal/llm/models/gemini.go+1−1 internal/llm/models/groq.go+1−1 internal/llm/models/local.go+1−1 internal/llm/models/models.go+30−16 internal/llm/models/openai.go+1−1 internal/llm/models/openrouter.go+1−1 internal/llm/models/vertexai.go+1−1 internal/llm/models/xai.go+1−1 internal/llm/prompt/coder.go+1−1 internal/llm/prompt/prompt.go+1−1 internal/llm/prompt/summarizer.go+1−1 internal/llm/prompt/task.go+1−1 internal/llm/prompt/title.go+1−1 internal/llm/provider/provider.go+1−1 internal/llm/provider/vertexai.go+2−2 internal/message/content.go+1−1 internal/tui/components/chat/sidebar/sidebar.go+5−5 internal/tui/components/chat/splash/keys.go+18 internal/tui/components/chat/splash/splash.go+85 internal/tui/components/dialogs/models/models.go+6−6 internal/tui/components/logo/logo.go+2−1
go.mod+4−1
@@ -1,6 +1,8 @@
11module github.com/charmbracelet/crush
22
3−go 1.24.0
3+go 1.24.3
4+
5+replace github.com/charmbracelet/fur => ../fur
46
57require (
68 github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.7.0
@@ -15,6 +17,7 @@ require (
1517 github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250607113720-eb5e1cf3b09e
1618 github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.3.0.20250609143341-c76fa36f1b94
1719 github.com/charmbracelet/fang v0.1.0
20+ github.com/charmbracelet/fur v0.0.0-00010101000000-000000000000
1821 github.com/charmbracelet/glamour/v2 v2.0.0-20250516160903-6f1e2c8f9ebe
1922 github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.1.0.20250523195325-2d1af06b557c
2023 github.com/charmbracelet/x/ansi v0.9.3-0.20250602153603-fb931ed90413
internal/config/config.go+13−13
@@ -76,17 +76,17 @@ type TUIConfig struct {
7676
7777// Config is the main configuration structure for the application.
7878type Config struct {
79− Data Data `json:"data"`
80− WorkingDir string `json:"wd,omitempty"`
81− MCPServers map[string]MCPServer `json:"mcpServers,omitempty"`
82− Providers map[models.ModelProvider]Provider `json:"providers,omitempty"`
83− LSP map[string]LSPConfig `json:"lsp,omitempty"`
84− Agents map[AgentName]Agent `json:"agents,omitempty"`
85− Debug bool `json:"debug,omitempty"`
86− DebugLSP bool `json:"debugLSP,omitempty"`
87− ContextPaths []string `json:"contextPaths,omitempty"`
88− TUI TUIConfig `json:"tui"`
89− AutoCompact bool `json:"autoCompact,omitempty"`
79+ Data Data `json:"data"`
80+ WorkingDir string `json:"wd,omitempty"`
81+ MCPServers map[string]MCPServer `json:"mcpServers,omitempty"`
82+ Providers map[models.InferenceProvider]Provider `json:"providers,omitempty"`
83+ LSP map[string]LSPConfig `json:"lsp,omitempty"`
84+ Agents map[AgentName]Agent `json:"agents,omitempty"`
85+ Debug bool `json:"debug,omitempty"`
86+ DebugLSP bool `json:"debugLSP,omitempty"`
87+ ContextPaths []string `json:"contextPaths,omitempty"`
88+ TUI TUIConfig `json:"tui"`
89+ AutoCompact bool `json:"autoCompact,omitempty"`
9090}
9191
9292// Application constants
@@ -128,7 +128,7 @@ func Load(workingDir string, debug bool) (*Config, error) {
128128 cfg = &Config{
129129 WorkingDir: workingDir,
130130 MCPServers: make(map[string]MCPServer),
131− Providers: make(map[models.ModelProvider]Provider),
131+ Providers: make(map[models.InferenceProvider]Provider),
132132 LSP: make(map[string]LSPConfig),
133133 }
134134
@@ -640,7 +640,7 @@ func Validate() error {
640640}
641641
642642// getProviderAPIKey gets the API key for a provider from environment variables
643−func getProviderAPIKey(provider models.ModelProvider) string {
643+func getProviderAPIKey(provider models.InferenceProvider) string {
644644 switch provider {
645645 case models.ProviderAnthropic:
646646 return os.Getenv("ANTHROPIC_API_KEY")
internal/config_v2/config.gonew file+440
@@ -0,0 +1,440 @@
1+package configv2
2+
3+import (
4+ "encoding/json"
5+ "errors"
6+ "maps"
7+ "os"
8+ "path/filepath"
9+ "slices"
10+ "strings"
11+ "sync"
12+
13+ "github.com/charmbracelet/crush/internal/logging"
14+ "github.com/charmbracelet/fur/pkg/provider"
15+)
16+
17+const (
18+ defaultDataDirectory = ".crush"
19+ defaultLogLevel = "info"
20+ appName = "crush"
21+
22+ MaxTokensFallbackDefault = 4096
23+)
24+
25+type Model struct {
26+ ID string `json:"id"`
27+ Name string `json:"model"`
28+ CostPer1MIn float64 `json:"cost_per_1m_in"`
29+ CostPer1MOut float64 `json:"cost_per_1m_out"`
30+ CostPer1MInCached float64 `json:"cost_per_1m_in_cached"`
31+ CostPer1MOutCached float64 `json:"cost_per_1m_out_cached"`
32+ ContextWindow int64 `json:"context_window"`
33+ DefaultMaxTokens int64 `json:"default_max_tokens"`
34+ CanReason bool `json:"can_reason"`
35+ ReasoningEffort string `json:"reasoning_effort"`
36+ SupportsImages bool `json:"supports_attachments"`
37+}
38+
39+type VertexAIOptions struct {
40+ APIKey string `json:"api_key,omitempty"`
41+ Project string `json:"project,omitempty"`
42+ Location string `json:"location,omitempty"`
43+}
44+
45+type ProviderConfig struct {
46+ BaseURL string `json:"base_url,omitempty"`
47+ ProviderType provider.Type `json:"provider_type"`
48+ APIKey string `json:"api_key,omitempty"`
49+ Disabled bool `json:"disabled"`
50+ ExtraHeaders map[string]string `json:"extra_headers,omitempty"`
51+ // used for e.x for vertex to set the project
52+ ExtraParams map[string]string `json:"extra_params,omitempty"`
53+
54+ DefaultModel string `json:"default_model"`
55+}
56+
57+type Agent struct {
58+ Name string `json:"name"`
59+ // This is the id of the system prompt used by the agent
60+ // TODO: still needs to be implemented
61+ PromptID string `json:"prompt_id"`
62+ Disabled bool `json:"disabled"`
63+
64+ Provider provider.InferenceProvider `json:"provider"`
65+ Model Model `json:"model"`
66+
67+ // The available tools for the agent
68+ // if this is empty, all tools are available
69+ AllowedTools []string `json:"allowed_tools"`
70+
71+ // this tells us which MCPs are available for this agent
72+ // if this is empty all mcps are available
73+ // the string array is the list of tools from the MCP the agent has available
74+ // if the string array is empty, all tools from the MCP are available
75+ MCP map[string][]string `json:"mcp"`
76+
77+ // The list of LSPs that this agent can use
78+ // if this is empty, all LSPs are available
79+ LSP []string `json:"lsp"`
80+
81+ // Overrides the context paths for this agent
82+ ContextPaths []string `json:"context_paths"`
83+}
84+
85+type MCPType string
86+
87+const (
88+ MCPStdio MCPType = "stdio"
89+ MCPSse MCPType = "sse"
90+)
91+
92+type MCP struct {
93+ Command string `json:"command"`
94+ Env []string `json:"env"`
95+ Args []string `json:"args"`
96+ Type MCPType `json:"type"`
97+ URL string `json:"url"`
98+ Headers map[string]string `json:"headers"`
99+}
100+
101+type LSPConfig struct {
102+ Disabled bool `json:"enabled"`
103+ Command string `json:"command"`
104+ Args []string `json:"args"`
105+ Options any `json:"options"`
106+}
107+
108+type TUIOptions struct {
109+ CompactMode bool `json:"compact_mode"`
110+ // Here we can add themes later or any TUI related options
111+}
112+
113+type Options struct {
114+ ContextPaths []string `json:"context_paths"`
115+ TUI TUIOptions `json:"tui"`
116+ Debug bool `json:"debug"`
117+ DebugLSP bool `json:"debug_lsp"`
118+ DisableAutoSummarize bool `json:"disable_auto_summarize"`
119+ // Relative to the cwd
120+ DataDirectory string `json:"data_directory"`
121+}
122+
123+type Config struct {
124+ // List of configured providers
125+ Providers map[provider.InferenceProvider]ProviderConfig `json:"providers,omitempty"`
126+
127+ // List of configured agents
128+ Agents map[string]Agent `json:"agents,omitempty"`
129+
130+ // List of configured MCPs
131+ MCP map[string]MCP `json:"mcp,omitempty"`
132+
133+ // List of configured LSPs
134+ LSP map[string]LSPConfig `json:"lsp,omitempty"`
135+
136+ // Miscellaneous options
137+ Options Options `json:"options"`
138+
139+ // Used to add models that are not already in the repository
140+ Models map[provider.InferenceProvider][]provider.Model `json:"models,omitempty"`
141+}
142+
143+var (
144+ instance *Config // The single instance of the Singleton
145+ cwd string
146+ once sync.Once // Ensures the initialization happens only once
147+)
148+
149+func loadConfig(cwd string) (*Config, error) {
150+ // First read the global config file
151+ cfgPath := ConfigPath()
152+
153+ cfg := defaultConfigBasedOnEnv()
154+
155+ var globalCfg *Config
156+ if _, err := os.Stat(cfgPath); err != nil && !os.IsNotExist(err) {
157+ // some other error occurred while checking the file
158+ return nil, err
159+ } else if err == nil {
160+ // config file exists, read it
161+ file, err := os.ReadFile(cfgPath)
162+ if err != nil {
163+ return nil, err
164+ }
165+ globalCfg = &Config{}
166+ if err := json.Unmarshal(file, globalCfg); err != nil {
167+ return nil, err
168+ }
169+ } else {
170+ // config file does not exist, create a new one
171+ globalCfg = &Config{}
172+ }
173+
174+ var localConfig *Config
175+ // Global config loaded, now read the local config file
176+ localConfigPath := filepath.Join(cwd, "crush.json")
177+ if _, err := os.Stat(localConfigPath); err != nil && !os.IsNotExist(err) {
178+ // some other error occurred while checking the file
179+ return nil, err
180+ } else if err == nil {
181+ // local config file exists, read it
182+ file, err := os.ReadFile(localConfigPath)
183+ if err != nil {
184+ return nil, err
185+ }
186+ localConfig = &Config{}
187+ if err := json.Unmarshal(file, localConfig); err != nil {
188+ return nil, err
189+ }
190+ }
191+
192+ // merge options
193+ cfg.Options = mergeOptions(cfg.Options, globalCfg.Options)
194+ cfg.Options = mergeOptions(cfg.Options, localConfig.Options)
195+
196+ mergeProviderConfigs(cfg, globalCfg, localConfig)
197+ return cfg, nil
198+}
199+
200+func InitConfig(workingDir string) *Config {
201+ once.Do(func() {
202+ cwd = workingDir
203+ cfg, err := loadConfig(cwd)
204+ if err != nil {
205+ // TODO: Handle this better
206+ panic("Failed to load config: " + err.Error())
207+ }
208+ instance = cfg
209+ })
210+
211+ return instance
212+}
213+
214+func GetConfig() *Config {
215+ if instance == nil {
216+ // TODO: Handle this better
217+ panic("Config not initialized. Call InitConfig first.")
218+ }
219+ return instance
220+}
221+
222+func mergeProviderConfig(p provider.InferenceProvider, base, other ProviderConfig) ProviderConfig {
223+ if other.APIKey != "" {
224+ base.APIKey = other.APIKey
225+ }
226+ // Only change these options if the provider is not a known provider
227+ if !slices.Contains(provider.KnownProviders(), p) {
228+ if other.BaseURL != "" {
229+ base.BaseURL = other.BaseURL
230+ }
231+ if other.ProviderType != "" {
232+ base.ProviderType = other.ProviderType
233+ }
234+ if len(base.ExtraHeaders) > 0 {
235+ if base.ExtraHeaders == nil {
236+ base.ExtraHeaders = make(map[string]string)
237+ }
238+ maps.Copy(base.ExtraHeaders, other.ExtraHeaders)
239+ }
240+ if len(other.ExtraParams) > 0 {
241+ if base.ExtraParams == nil {
242+ base.ExtraParams = make(map[string]string)
243+ }
244+ maps.Copy(base.ExtraParams, other.ExtraParams)
245+ }
246+ }
247+
248+ if other.Disabled {
249+ base.Disabled = other.Disabled
250+ }
251+
252+ return base
253+}
254+
255+func validateProvider(p provider.InferenceProvider, providerConfig ProviderConfig) error {
256+ if !slices.Contains(provider.KnownProviders(), p) {
257+ if providerConfig.ProviderType != provider.TypeOpenAI {
258+ return errors.New("invalid provider type: " + string(providerConfig.ProviderType))
259+ }
260+ if providerConfig.BaseURL == "" {
261+ return errors.New("base URL must be set for custom providers")
262+ }
263+ if providerConfig.APIKey == "" {
264+ return errors.New("API key must be set for custom providers")
265+ }
266+ }
267+ return nil
268+}
269+
270+func mergeOptions(base, other Options) Options {
271+ result := base
272+
273+ if len(other.ContextPaths) > 0 {
274+ base.ContextPaths = append(base.ContextPaths, other.ContextPaths...)
275+ }
276+
277+ if other.TUI.CompactMode {
278+ result.TUI.CompactMode = other.TUI.CompactMode
279+ }
280+
281+ if other.Debug {
282+ result.Debug = other.Debug
283+ }
284+
285+ if other.DebugLSP {
286+ result.DebugLSP = other.DebugLSP
287+ }
288+
289+ if other.DisableAutoSummarize {
290+ result.DisableAutoSummarize = other.DisableAutoSummarize
291+ }
292+
293+ if other.DataDirectory != "" {
294+ result.DataDirectory = other.DataDirectory
295+ }
296+
297+ return result
298+}
299+
300+func mergeProviderConfigs(base, global, local *Config) {
301+ if global != nil {
302+ for providerName, globalProvider := range global.Providers {
303+ if _, ok := base.Providers[providerName]; !ok {
304+ base.Providers[providerName] = globalProvider
305+ } else {
306+ base.Providers[providerName] = mergeProviderConfig(providerName, base.Providers[providerName], globalProvider)
307+ }
308+ }
309+ }
310+ if local != nil {
311+ for providerName, localProvider := range local.Providers {
312+ if _, ok := base.Providers[providerName]; !ok {
313+ base.Providers[providerName] = localProvider
314+ } else {
315+ base.Providers[providerName] = mergeProviderConfig(providerName, base.Providers[providerName], localProvider)
316+ }
317+ }
318+ }
319+
320+ finalProviders := make(map[provider.InferenceProvider]ProviderConfig)
321+ for providerName, providerConfig := range base.Providers {
322+ err := validateProvider(providerName, providerConfig)
323+ if err != nil {
324+ logging.Warn("Skipping provider", "name", providerName, "error", err)
325+ }
326+ finalProviders[providerName] = providerConfig
327+ }
328+ base.Providers = finalProviders
329+}
330+
331+func providerDefaultConfig(providerName provider.InferenceProvider) ProviderConfig {
332+ switch providerName {
333+ case provider.InferenceProviderAnthropic:
334+ return ProviderConfig{
335+ ProviderType: provider.TypeAnthropic,
336+ }
337+ case provider.InferenceProviderOpenAI:
338+ return ProviderConfig{
339+ ProviderType: provider.TypeOpenAI,
340+ }
341+ case provider.InferenceProviderGemini:
342+ return ProviderConfig{
343+ ProviderType: provider.TypeGemini,
344+ }
345+ case provider.InferenceProviderBedrock:
346+ return ProviderConfig{
347+ ProviderType: provider.TypeBedrock,
348+ }
349+ case provider.InferenceProviderAzure:
350+ return ProviderConfig{
351+ ProviderType: provider.TypeAzure,
352+ }
353+ case provider.InferenceProviderOpenRouter:
354+ return ProviderConfig{
355+ ProviderType: provider.TypeOpenAI,
356+ BaseURL: "https://openrouter.ai/api/v1",
357+ ExtraHeaders: map[string]string{
358+ "HTTP-Referer": "crush.charm.land",
359+ "X-Title": "Crush",
360+ },
361+ }
362+ case provider.InferenceProviderXAI:
363+ return ProviderConfig{
364+ ProviderType: provider.TypeXAI,
365+ BaseURL: "https://api.x.ai/v1",
366+ }
367+ case provider.InferenceProviderVertexAI:
368+ return ProviderConfig{
369+ ProviderType: provider.TypeVertexAI,
370+ }
371+ default:
372+ return ProviderConfig{
373+ ProviderType: provider.TypeOpenAI,
374+ }
375+ }
376+}
377+
378+func defaultConfigBasedOnEnv() *Config {
379+ cfg := &Config{
380+ Options: Options{
381+ DataDirectory: defaultDataDirectory,
382+ },
383+ Providers: make(map[provider.InferenceProvider]ProviderConfig),
384+ }
385+
386+ providers := Providers()
387+
388+ for _, p := range providers {
389+ if strings.HasPrefix(p.APIKey, "$") {
390+ envVar := strings.TrimPrefix(p.APIKey, "$")
391+ if apiKey := os.Getenv(envVar); apiKey != "" {
392+ providerConfig := providerDefaultConfig(p.ID)
393+ providerConfig.APIKey = apiKey
394+ providerConfig.DefaultModel = p.DefaultModelID
395+ cfg.Providers[p.ID] = providerConfig
396+ }
397+ }
398+ }
399+ // TODO: support local models
400+
401+ if useVertexAI := os.Getenv("GOOGLE_GENAI_USE_VERTEXAI"); useVertexAI == "true" {
402+ providerConfig := providerDefaultConfig(provider.InferenceProviderVertexAI)
403+ providerConfig.ExtraParams = map[string]string{
404+ "project": os.Getenv("GOOGLE_CLOUD_PROJECT"),
405+ "location": os.Getenv("GOOGLE_CLOUD_LOCATION"),
406+ }
407+ cfg.Providers[provider.InferenceProviderVertexAI] = providerConfig
408+ }
409+
410+ if hasAWSCredentials() {
411+ providerConfig := providerDefaultConfig(provider.InferenceProviderBedrock)
412+ cfg.Providers[provider.InferenceProviderBedrock] = providerConfig
413+ }
414+ return cfg
415+}
416+
417+func hasAWSCredentials() bool {
418+ if os.Getenv("AWS_ACCESS_KEY_ID") != "" && os.Getenv("AWS_SECRET_ACCESS_KEY") != "" {
419+ return true
420+ }
421+
422+ if os.Getenv("AWS_PROFILE") != "" || os.Getenv("AWS_DEFAULT_PROFILE") != "" {
423+ return true
424+ }
425+
426+ if os.Getenv("AWS_REGION") != "" || os.Getenv("AWS_DEFAULT_REGION") != "" {
427+ return true
428+ }
429+
430+ if os.Getenv("AWS_CONTAINER_CREDENTIALS_RELATIVE_URI") != "" ||
431+ os.Getenv("AWS_CONTAINER_CREDENTIALS_FULL_URI") != "" {
432+ return true
433+ }
434+
435+ return false
436+}
437+
438+func WorkingDirectory() string {
439+ return cwd
440+}
internal/config_v2/config_test.gonew file+33
@@ -0,0 +1,33 @@
1+package configv2
2+
3+import (
4+ "fmt"
5+ "os"
6+ "testing"
7+
8+ "github.com/stretchr/testify/assert"
9+)
10+
11+func resetEnvVars() {
12+ os.Setenv("ANTHROPIC_API_KEY", "")
13+ os.Setenv("OPENAI_API_KEY", "")
14+ os.Setenv("GEMINI_API_KEY", "")
15+ os.Setenv("XAI_API_KEY", "")
16+ os.Setenv("OPENROUTER_API_KEY", "")
17+}
18+
19+func TestConfigWithEnv(t *testing.T) {
20+ resetEnvVars()
21+ testConfigDir = t.TempDir()
22+
23+ cwdDir := t.TempDir()
24+
25+ os.Setenv("ANTHROPIC_API_KEY", "test-anthropic-key")
26+ os.Setenv("OPENAI_API_KEY", "test-openai-key")
27+ os.Setenv("GEMINI_API_KEY", "test-gemini-key")
28+ os.Setenv("XAI_API_KEY", "test-xai-key")
29+ os.Setenv("OPENROUTER_API_KEY", "test-openrouter-key")
30+ cfg := InitConfig(cwdDir)
31+ fmt.Println(cfg)
32+ assert.Len(t, cfg.Providers, 5)
33+}
internal/config_v2/fs.gonew file+71
@@ -0,0 +1,71 @@
1+package configv2
2+
3+import (
4+ "fmt"
5+ "os"
6+ "path/filepath"
7+ "runtime"
8+)
9+
10+var testConfigDir string
11+
12+func baseConfigPath() string {
13+ if testConfigDir != "" {
14+ return testConfigDir
15+ }
16+
17+ xdgConfigHome := os.Getenv("XDG_CONFIG_HOME")
18+ if xdgConfigHome != "" {
19+ return filepath.Join(xdgConfigHome, "crush")
20+ }
21+
22+ // return the path to the main config directory
23+ // for windows, it should be in `%LOCALAPPDATA%/crush/`
24+ // for linux and macOS, it should be in `$HOME/.config/crush/`
25+ if runtime.GOOS == "windows" {
26+ localAppData := os.Getenv("LOCALAPPDATA")
27+ if localAppData == "" {
28+ localAppData = filepath.Join(os.Getenv("USERPROFILE"), "AppData", "Local")
29+ }
30+ return filepath.Join(localAppData, appName)
31+ }
32+
33+ return filepath.Join(os.Getenv("HOME"), ".config", appName)
34+}
35+
36+func baseDataPath() string {
37+ if testConfigDir != "" {
38+ return testConfigDir
39+ }
40+
41+ xdgDataHome := os.Getenv("XDG_DATA_HOME")
42+ if xdgDataHome != "" {
43+ return filepath.Join(xdgDataHome, appName)
44+ }
45+
46+ // return the path to the main data directory
47+ // for windows, it should be in `%LOCALAPPDATA%/crush/`
48+ // for linux and macOS, it should be in `$HOME/.local/share/crush/`
49+ if runtime.GOOS == "windows" {
50+ localAppData := os.Getenv("LOCALAPPDATA")
51+ if localAppData == "" {
52+ localAppData = filepath.Join(os.Getenv("USERPROFILE"), "AppData", "Local")
53+ }
54+ return filepath.Join(localAppData, appName)
55+ }
56+
57+ return filepath.Join(os.Getenv("HOME"), ".local", "share", appName)
58+}
59+
60+func ConfigPath() string {
61+ return filepath.Join(baseConfigPath(), fmt.Sprintf("%s.json", appName))
62+}
63+
64+func CrushInitialized() bool {
65+ cfgPath := ConfigPath()
66+ if _, err := os.Stat(cfgPath); os.IsNotExist(err) {
67+ // config file does not exist, so Crush is not initialized
68+ return false
69+ }
70+ return true
71+}
internal/config_v2/provider.gonew file+69
@@ -0,0 +1,69 @@
1+package configv2
2+
3+import (
4+ "encoding/json"
5+ "os"
6+ "path/filepath"
7+ "sync"
8+
9+ "github.com/charmbracelet/fur/pkg/client"
10+ "github.com/charmbracelet/fur/pkg/provider"
11+)
12+
13+var fur = client.New()
14+
15+var (
16+ providerOnc sync.Once // Ensures the initialization happens only once
17+ providerList []provider.Provider
18+)
19+
20+func providersPath() string {
21+ return filepath.Join(baseDataPath(), "providers.json")
22+}
23+
24+func saveProviders(providers []provider.Provider) error {
25+ path := providersPath()
26+ dir := filepath.Dir(path)
27+ if err := os.MkdirAll(dir, 0o755); err != nil {
28+ return err
29+ }
30+
31+ data, err := json.MarshalIndent(providers, "", " ")
32+ if err != nil {
33+ return err
34+ }
35+
36+ return os.WriteFile(path, data, 0o644)
37+}
38+
39+func loadProviders() ([]provider.Provider, error) {
40+ path := providersPath()
41+ data, err := os.ReadFile(path)
42+ if err != nil {
43+ return nil, err
44+ }
45+
46+ var providers []provider.Provider
47+ err = json.Unmarshal(data, &providers)
48+ return providers, err
49+}
50+
51+func Providers() []provider.Provider {
52+ providerOnc.Do(func() {
53+ // Try to get providers from upstream API
54+ if providers, err := fur.GetProviders(); err == nil {
55+ providerList = providers
56+ // Save providers locally for future fallback
57+ _ = saveProviders(providers)
58+ } else {
59+ // If upstream fails, try to load from local cache
60+ if localProviders, localErr := loadProviders(); localErr == nil {
61+ providerList = localProviders
62+ } else {
63+ // If both fail, return empty list
64+ providerList = []provider.Provider{}
65+ }
66+ }
67+ })
68+ return providerList
69+}
internal/llm/models/anthropic.go+1−1
@@ -1,7 +1,7 @@
11package models
22
33const (
4− ProviderAnthropic ModelProvider = "anthropic"
4+ ProviderAnthropic InferenceProvider = "anthropic"
55
66 // Models
77 Claude35Sonnet ModelID = "claude-3.5-sonnet"
internal/llm/models/azure.go+1−1
@@ -1,6 +1,6 @@
11package models
22
3−const ProviderAzure ModelProvider = "azure"
3+const ProviderAzure InferenceProvider = "azure"
44
55const (
66 AzureGPT41 ModelID = "azure.gpt-4.1"
internal/llm/models/gemini.go+1−1
@@ -1,7 +1,7 @@
11package models
22
33const (
4− ProviderGemini ModelProvider = "gemini"
4+ ProviderGemini InferenceProvider = "gemini"
55
66 // Models
77 Gemini25Flash ModelID = "gemini-2.5-flash"
internal/llm/models/groq.go+1−1
@@ -1,7 +1,7 @@
11package models
22
33const (
4− ProviderGROQ ModelProvider = "groq"
4+ ProviderGROQ InferenceProvider = "groq"
55
66 // GROQ
77 QWENQwq ModelID = "qwen-qwq"
internal/llm/models/local.go+1−1
@@ -16,7 +16,7 @@ import (
1616)
1717
1818const (
19− ProviderLocal ModelProvider = "local"
19+ ProviderLocal InferenceProvider = "local"
2020
2121 localModelsPath = "v1/models"
2222 lmStudioBetaModelsPath = "api/v0/models"
internal/llm/models/models.go+30−16
@@ -3,23 +3,23 @@ package models
33import "maps"
44
55type (
6− ModelID string
7− ModelProvider string
6+ ModelID string
7+ InferenceProvider string
88)
99
1010type Model struct {
11− ID ModelID `json:"id"`
12− Name string `json:"name"`
13− Provider ModelProvider `json:"provider"`
14− APIModel string `json:"api_model"`
15− CostPer1MIn float64 `json:"cost_per_1m_in"`
16− CostPer1MOut float64 `json:"cost_per_1m_out"`
17− CostPer1MInCached float64 `json:"cost_per_1m_in_cached"`
18− CostPer1MOutCached float64 `json:"cost_per_1m_out_cached"`
19− ContextWindow int64 `json:"context_window"`
20− DefaultMaxTokens int64 `json:"default_max_tokens"`
21− CanReason bool `json:"can_reason"`
22− SupportsAttachments bool `json:"supports_attachments"`
11+ ID ModelID `json:"id"`
12+ Name string `json:"name"`
13+ Provider InferenceProvider `json:"provider"`
14+ APIModel string `json:"api_model"`
15+ CostPer1MIn float64 `json:"cost_per_1m_in"`
16+ CostPer1MOut float64 `json:"cost_per_1m_out"`
17+ CostPer1MInCached float64 `json:"cost_per_1m_in_cached"`
18+ CostPer1MOutCached float64 `json:"cost_per_1m_out_cached"`
19+ ContextWindow int64 `json:"context_window"`
20+ DefaultMaxTokens int64 `json:"default_max_tokens"`
21+ CanReason bool `json:"can_reason"`
22+ SupportsAttachments bool `json:"supports_attachments"`
2323}
2424
2525// Model IDs
@@ -29,9 +29,9 @@ const ( // GEMINI
2929)
3030
3131const (
32− ProviderBedrock ModelProvider = "bedrock"
32+ ProviderBedrock InferenceProvider = "bedrock"
3333 // ForTests
34− ProviderMock ModelProvider = "__mock"
34+ ProviderMock InferenceProvider = "__mock"
3535)
3636
3737var SupportedModels = map[ModelID]Model{
@@ -48,6 +48,20 @@ var SupportedModels = map[ModelID]Model{
4848 },
4949}
5050
51+var KnownProviders = []InferenceProvider{
52+ ProviderAnthropic,
53+ ProviderOpenAI,
54+ ProviderGemini,
55+ ProviderAzure,
56+ ProviderGROQ,
57+ ProviderLocal,
58+ ProviderOpenRouter,
59+ ProviderVertexAI,
60+ ProviderBedrock,
61+ ProviderXAI,
62+ ProviderMock,
63+}
64+
5165func init() {
5266 maps.Copy(SupportedModels, AnthropicModels)
5367 maps.Copy(SupportedModels, OpenAIModels)
internal/llm/models/openai.go+1−1
@@ -1,7 +1,7 @@
11package models
22
33const (
4− ProviderOpenAI ModelProvider = "openai"
4+ ProviderOpenAI InferenceProvider = "openai"
55
66 GPT41 ModelID = "gpt-4.1"
77 GPT41Mini ModelID = "gpt-4.1-mini"
internal/llm/models/openrouter.go+1−1
@@ -1,7 +1,7 @@
11package models
22
33const (
4− ProviderOpenRouter ModelProvider = "openrouter"
4+ ProviderOpenRouter InferenceProvider = "openrouter"
55
66 OpenRouterGPT41 ModelID = "openrouter.gpt-4.1"
77 OpenRouterGPT41Mini ModelID = "openrouter.gpt-4.1-mini"
internal/llm/models/vertexai.go+1−1
@@ -1,7 +1,7 @@
11package models
22
33const (
4− ProviderVertexAI ModelProvider = "vertexai"
4+ ProviderVertexAI InferenceProvider = "vertexai"
55
66 // Models
77 VertexAIGemini25Flash ModelID = "vertexai.gemini-2.5-flash"
internal/llm/models/xai.go+1−1
@@ -1,7 +1,7 @@
11package models
22
33const (
4− ProviderXAI ModelProvider = "xai"
4+ ProviderXAI InferenceProvider = "xai"
55
66 XAIGrok3Beta ModelID = "grok-3-beta"
77 XAIGrok3MiniBeta ModelID = "grok-3-mini-beta"
internal/llm/prompt/coder.go+1−1
@@ -13,7 +13,7 @@ import (
1313 "github.com/charmbracelet/crush/internal/llm/tools"
1414)
1515
16−func CoderPrompt(provider models.ModelProvider) string {
16+func CoderPrompt(provider models.InferenceProvider) string {
1717 basePrompt := baseAnthropicCoderPrompt
1818 switch provider {
1919 case models.ProviderOpenAI:
internal/llm/prompt/prompt.go+1−1
@@ -12,7 +12,7 @@ import (
1212 "github.com/charmbracelet/crush/internal/logging"
1313)
1414
15−func GetAgentPrompt(agentName config.AgentName, provider models.ModelProvider) string {
15+func GetAgentPrompt(agentName config.AgentName, provider models.InferenceProvider) string {
1616 basePrompt := ""
1717 switch agentName {
1818 case config.AgentCoder:
internal/llm/prompt/summarizer.go+1−1
@@ -2,7 +2,7 @@ package prompt
22
33import "github.com/charmbracelet/crush/internal/llm/models"
44
5−func SummarizerPrompt(_ models.ModelProvider) string {
5+func SummarizerPrompt(_ models.InferenceProvider) string {
66 return `You are a helpful AI assistant tasked with summarizing conversations.
77
88When asked to summarize, provide a detailed but concise summary of the conversation.
internal/llm/prompt/task.go+1−1
@@ -6,7 +6,7 @@ import (
66 "github.com/charmbracelet/crush/internal/llm/models"
77)
88
9−func TaskPrompt(_ models.ModelProvider) string {
9+func TaskPrompt(_ models.InferenceProvider) string {
1010 agentPrompt := `You are an agent for Crush. Given the user's prompt, you should use the tools available to you to answer the user's question.
1111Notes:
12121. IMPORTANT: You should be concise, direct, and to the point, since your responses will be displayed on a command line interface. Answer the user's question directly, without elaboration, explanation, or details. One word answers are best. Avoid introductions, conclusions, and explanations. You MUST avoid text before/after your response, such as "The answer is <answer>.", "Here is the content of the file..." or "Based on the information provided, the answer is..." or "Here is what I will do next...".
internal/llm/prompt/title.go+1−1
@@ -2,7 +2,7 @@ package prompt
22
33import "github.com/charmbracelet/crush/internal/llm/models"
44
5−func TitlePrompt(_ models.ModelProvider) string {
5+func TitlePrompt(_ models.InferenceProvider) string {
66 return `you will generate a short title based on the first message a user begins a conversation with
77- ensure it is not more than 50 characters long
88- the title should be a summary of the user's message
internal/llm/provider/provider.go+1−1
@@ -82,7 +82,7 @@ type baseProvider[C ProviderClient] struct {
8282 client C
8383}
8484
85−func NewProvider(providerName models.ModelProvider, opts ...ProviderClientOption) (Provider, error) {
85+func NewProvider(providerName models.InferenceProvider, opts ...ProviderClientOption) (Provider, error) {
8686 clientOptions := providerClientOptions{}
8787 for _, o := range opts {
8888 o(&clientOptions)
internal/llm/provider/vertexai.go+2−2
@@ -17,8 +17,8 @@ func newVertexAIClient(opts providerClientOptions) VertexAIClient {
1717 }
1818
1919 client, err := genai.NewClient(context.Background(), &genai.ClientConfig{
20− Project: os.Getenv("VERTEXAI_PROJECT"),
21− Location: os.Getenv("VERTEXAI_LOCATION"),
20+ Project: os.Getenv("GOOGLE_CLOUD_PROJECT"),
21+ Location: os.Getenv("GOOGLE_CLOUD_LOCATION"),
2222 Backend: genai.BackendVertexAI,
2323 })
2424 if err != nil {
internal/message/content.go+1−1
@@ -71,7 +71,7 @@ type BinaryContent struct {
7171 Data []byte
7272}
7373
74−func (bc BinaryContent) String(provider models.ModelProvider) string {
74+func (bc BinaryContent) String(provider models.InferenceProvider) string {
7575 base64Encoded := base64.StdEncoding.EncodeToString(bc.Data)
7676 if provider == models.ProviderOpenAI {
7777 return "data:" + bc.MIMEType + ";base64," + base64Encoded
internal/tui/components/chat/sidebar/sidebar.go+5−5
@@ -76,7 +76,7 @@ func NewSidebarCmp(history history.Service, lspClients map[string]*lsp.Client, c
7676}
7777
7878func (m *sidebarCmp) Init() tea.Cmd {
79− m.logo = m.logoBlock(false)
79+ m.logo = m.logoBlock()
8080 m.cwd = cwd()
8181 return nil
8282}
@@ -231,9 +231,9 @@ func (m *sidebarCmp) loadSessionFiles() tea.Msg {
231231
232232func (m *sidebarCmp) SetSize(width, height int) tea.Cmd {
233233 if width < logoBreakpoint && (m.width == 0 || m.width >= logoBreakpoint) {
234− m.logo = m.logoBlock(true)
234+ m.logo = m.logoBlock()
235235 } else if width >= logoBreakpoint && (m.width == 0 || m.width < logoBreakpoint) {
236− m.logo = m.logoBlock(false)
236+ m.logo = m.logoBlock()
237237 }
238238
239239 m.width = width
@@ -245,9 +245,9 @@ func (m *sidebarCmp) GetSize() (int, int) {
245245 return m.width, m.height
246246}
247247
248−func (m *sidebarCmp) logoBlock(compact bool) string {
248+func (m *sidebarCmp) logoBlock() string {
249249 t := styles.CurrentTheme()
250− return logo.Render(version.Version, compact, logo.Opts{
250+ return logo.Render(version.Version, true, logo.Opts{
251251 FieldColor: t.Primary,
252252 TitleColorA: t.Secondary,
253253 TitleColorB: t.Primary,
internal/tui/components/chat/splash/keys.gonew file+18
@@ -0,0 +1,18 @@
1+package splash
2+
3+import (
4+ "github.com/charmbracelet/bubbles/v2/key"
5+)
6+
7+type KeyMap struct {
8+ Cancel key.Binding
9+}
10+
11+func DefaultKeyMap() KeyMap {
12+ return KeyMap{
13+ Cancel: key.NewBinding(
14+ key.WithKeys("esc"),
15+ key.WithHelp("esc", "cancel"),
16+ ),
17+ }
18+}
internal/tui/components/chat/splash/splash.gonew file+85
@@ -0,0 +1,85 @@
1+package splash
2+
3+import (
4+ "github.com/charmbracelet/bubbles/v2/key"
5+ tea "github.com/charmbracelet/bubbletea/v2"
6+ "github.com/charmbracelet/crush/internal/tui/components/core/layout"
7+ "github.com/charmbracelet/crush/internal/tui/components/logo"
8+ "github.com/charmbracelet/crush/internal/tui/styles"
9+ "github.com/charmbracelet/crush/internal/tui/util"
10+ "github.com/charmbracelet/crush/internal/version"
11+ "github.com/charmbracelet/lipgloss/v2"
12+)
13+
14+type Splash interface {
15+ util.Model
16+ layout.Sizeable
17+ layout.Help
18+}
19+
20+type splashCmp struct {
21+ width, height int
22+ keyMap KeyMap
23+ logoRendered string
24+}
25+
26+func New() Splash {
27+ return &splashCmp{
28+ width: 0,
29+ height: 0,
30+ keyMap: DefaultKeyMap(),
31+ logoRendered: "",
32+ }
33+}
34+
35+// GetSize implements SplashPage.
36+func (s *splashCmp) GetSize() (int, int) {
37+ return s.width, s.height
38+}
39+
40+// Init implements SplashPage.
41+func (s *splashCmp) Init() tea.Cmd {
42+ return nil
43+}
44+
45+// SetSize implements SplashPage.
46+func (s *splashCmp) SetSize(width int, height int) tea.Cmd {
47+ s.width = width
48+ s.height = height
49+ s.logoRendered = s.logoBlock()
50+ return nil
51+}
52+
53+// Update implements SplashPage.
54+func (s *splashCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
55+ switch msg := msg.(type) {
56+ case tea.WindowSizeMsg:
57+ return s, s.SetSize(msg.Width, msg.Height)
58+ }
59+ return s, nil
60+}
61+
62+// View implements SplashPage.
63+func (s *splashCmp) View() tea.View {
64+ content := lipgloss.JoinVertical(lipgloss.Left, s.logoRendered)
65+ return tea.NewView(content)
66+}
67+
68+func (m *splashCmp) logoBlock() string {
69+ t := styles.CurrentTheme()
70+ return logo.Render(version.Version, false, logo.Opts{
71+ FieldColor: t.Primary,
72+ TitleColorA: t.Secondary,
73+ TitleColorB: t.Primary,
74+ CharmColor: t.Secondary,
75+ VersionColor: t.Primary,
76+ Width: m.width - 2, // -2 for padding
77+ })
78+}
79+
80+// Bindings implements SplashPage.
81+func (s *splashCmp) Bindings() []key.Binding {
82+ return []key.Binding{
83+ s.keyMap.Cancel,
84+ }
85+}
internal/tui/components/dialogs/models/models.go+6−6
@@ -80,7 +80,7 @@ func NewModelDialogCmp() ModelDialog {
8080 }
8181}
8282
83−var ProviderPopularity = map[models.ModelProvider]int{
83+var ProviderPopularity = map[models.InferenceProvider]int{
8484 models.ProviderAnthropic: 1,
8585 models.ProviderOpenAI: 2,
8686 models.ProviderGemini: 3,
@@ -92,7 +92,7 @@ var ProviderPopularity = map[models.ModelProvider]int{
9292 models.ProviderXAI: 9,
9393}
9494
95−var ProviderName = map[models.ModelProvider]string{
95+var ProviderName = map[models.InferenceProvider]string{
9696 models.ProviderAnthropic: "Anthropic",
9797 models.ProviderOpenAI: "OpenAI",
9898 models.ProviderGemini: "Gemini",
@@ -195,8 +195,8 @@ func GetSelectedModel(cfg *config.Config) models.Model {
195195 return models.SupportedModels[selectedModelID]
196196}
197197
198−func getEnabledProviders(cfg *config.Config) []models.ModelProvider {
199− var providers []models.ModelProvider
198+func getEnabledProviders(cfg *config.Config) []models.InferenceProvider {
199+ var providers []models.InferenceProvider
200200 for providerID, provider := range cfg.Providers {
201201 if !provider.Disabled {
202202 providers = append(providers, providerID)
@@ -204,7 +204,7 @@ func getEnabledProviders(cfg *config.Config) []models.ModelProvider {
204204 }
205205
206206 // Sort by provider popularity
207− slices.SortFunc(providers, func(a, b models.ModelProvider) int {
207+ slices.SortFunc(providers, func(a, b models.InferenceProvider) int {
208208 rA := ProviderPopularity[a]
209209 rB := ProviderPopularity[b]
210210
@@ -220,7 +220,7 @@ func getEnabledProviders(cfg *config.Config) []models.ModelProvider {
220220 return providers
221221}
222222
223−func getModelsForProvider(provider models.ModelProvider) []models.Model {
223+func getModelsForProvider(provider models.InferenceProvider) []models.Model {
224224 var providerModels []models.Model
225225 for _, model := range models.SupportedModels {
226226 if model.Provider == provider {
internal/tui/components/logo/logo.go+2−1
@@ -26,6 +26,7 @@ type Opts struct {
2626 TitleColorB color.Color // right gradient ramp point
2727 CharmColor color.Color // Charm™ text color
2828 VersionColor color.Color // Version text color
29+ Width int // width of the rendered logo, used for truncation
2930}
3031
3132// Render renders the Crush logo. Set the argument to true to render the narrow
@@ -76,7 +77,7 @@ func Render(version string, compact bool, o Opts) string {
7677 }
7778
7879 // Right field.
79− const rightWidth = 15
80+ rightWidth := max(15, o.Width-crushWidth-leftWidth) // 2 for the gap.
8081 const stepDownAt = 0
8182 rightField := new(strings.Builder)
8283 for i := range fieldHeight {