on main help sign in
Merge branch 'main' into ui
51391b9Ayman Bagabas
go.mod+2−1
@@ -30,6 +30,7 @@ require (
30 github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f
30 github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f
31 github.com/charmbracelet/x/exp/ordered v0.1.0
31 github.com/charmbracelet/x/exp/ordered v0.1.0
32 github.com/charmbracelet/x/exp/slice v0.0.0-20251201173703-9f73bfd934ff
32 github.com/charmbracelet/x/exp/slice v0.0.0-20251201173703-9f73bfd934ff
33+ github.com/charmbracelet/x/exp/strings v0.0.0-20260119114936-fd556377ea59
33 github.com/charmbracelet/x/mosaic v0.0.0-20251215102626-e0db08df7383
34 github.com/charmbracelet/x/mosaic v0.0.0-20251215102626-e0db08df7383
34 github.com/charmbracelet/x/powernap v0.0.0-20260113142046-c1fa3de7983b
35 github.com/charmbracelet/x/powernap v0.0.0-20260113142046-c1fa3de7983b
35 github.com/charmbracelet/x/term v0.2.2
36 github.com/charmbracelet/x/term v0.2.2
@@ -41,6 +42,7 @@ require (
41 github.com/invopop/jsonschema v0.13.0
42 github.com/invopop/jsonschema v0.13.0
42 github.com/joho/godotenv v1.5.1
43 github.com/joho/godotenv v1.5.1
43 github.com/lucasb-eyer/go-colorful v1.3.0
44 github.com/lucasb-eyer/go-colorful v1.3.0
45+ github.com/mattn/go-isatty v0.0.20
44 github.com/modelcontextprotocol/go-sdk v1.2.0
46 github.com/modelcontextprotocol/go-sdk v1.2.0
45 github.com/muesli/termenv v0.16.0
47 github.com/muesli/termenv v0.16.0
46 github.com/ncruces/go-sqlite3 v0.30.4
48 github.com/ncruces/go-sqlite3 v0.30.4
@@ -137,7 +139,6 @@ require (
137 github.com/klauspost/cpuid/v2 v2.0.9 // indirect
139 github.com/klauspost/cpuid/v2 v2.0.9 // indirect
138 github.com/klauspost/pgzip v1.2.6 // indirect
140 github.com/klauspost/pgzip v1.2.6 // indirect
139 github.com/mailru/easyjson v0.7.7 // indirect
141 github.com/mailru/easyjson v0.7.7 // indirect
140− github.com/mattn/go-isatty v0.0.20 // indirect
141 github.com/mattn/go-runewidth v0.0.19 // indirect
142 github.com/mattn/go-runewidth v0.0.19 // indirect
142 github.com/mfridman/interpolate v0.0.2 // indirect
143 github.com/mfridman/interpolate v0.0.2 // indirect
143 github.com/microcosm-cc/bluemonday v1.0.27 // indirect
144 github.com/microcosm-cc/bluemonday v1.0.27 // indirect
go.sum+2
@@ -118,6 +118,8 @@ github.com/charmbracelet/x/exp/ordered v0.1.0 h1:55/qLwjIh0gL0Vni+QAWk7T/qRVP6sB
118github.com/charmbracelet/x/exp/ordered v0.1.0/go.mod h1:5UHwmG+is5THxMyCJHNPCn2/ecI07aKNrW+LcResjJ8=
118github.com/charmbracelet/x/exp/ordered v0.1.0/go.mod h1:5UHwmG+is5THxMyCJHNPCn2/ecI07aKNrW+LcResjJ8=
119github.com/charmbracelet/x/exp/slice v0.0.0-20251201173703-9f73bfd934ff h1:Uwr+/JS+qnRcO/++xjYEDtW7x+P5E4+4cBiOHTt2Xfk=
119github.com/charmbracelet/x/exp/slice v0.0.0-20251201173703-9f73bfd934ff h1:Uwr+/JS+qnRcO/++xjYEDtW7x+P5E4+4cBiOHTt2Xfk=
120github.com/charmbracelet/x/exp/slice v0.0.0-20251201173703-9f73bfd934ff/go.mod h1:vqEfX6xzqW1pKKZUUiFOKg0OQ7bCh54Q2vR/tserrRA=
120github.com/charmbracelet/x/exp/slice v0.0.0-20251201173703-9f73bfd934ff/go.mod h1:vqEfX6xzqW1pKKZUUiFOKg0OQ7bCh54Q2vR/tserrRA=
121+github.com/charmbracelet/x/exp/strings v0.0.0-20260119114936-fd556377ea59 h1:cvPMInXNmK/CHjQU8eXC/oSnGfEKpQmndsEykh03bt0=
122+github.com/charmbracelet/x/exp/strings v0.0.0-20260119114936-fd556377ea59/go.mod h1:/ehtMPNh9K4odGFkqYJKpIYyePhdp1hLBRvyY4bWkH8=
121github.com/charmbracelet/x/json v0.2.0 h1:DqB+ZGx2h+Z+1s98HOuOyli+i97wsFQIxP2ZQANTPrQ=
123github.com/charmbracelet/x/json v0.2.0 h1:DqB+ZGx2h+Z+1s98HOuOyli+i97wsFQIxP2ZQANTPrQ=
122github.com/charmbracelet/x/json v0.2.0/go.mod h1:opFIflx2YgXgi49xVUu8gEQ21teFAxyMwvOiZhIvWNM=
124github.com/charmbracelet/x/json v0.2.0/go.mod h1:opFIflx2YgXgi49xVUu8gEQ21teFAxyMwvOiZhIvWNM=
123github.com/charmbracelet/x/mosaic v0.0.0-20251215102626-e0db08df7383 h1:YpTd2/abobMn/dCRM6Vo+G7JO/VS6RW0Ln3YkVJih8Y=
125github.com/charmbracelet/x/mosaic v0.0.0-20251215102626-e0db08df7383 h1:YpTd2/abobMn/dCRM6Vo+G7JO/VS6RW0Ln3YkVJih8Y=
internal/app/app.go+97−1
@@ -17,6 +17,7 @@ import (
17 tea "charm.land/bubbletea/v2"
17 tea "charm.land/bubbletea/v2"
18 "charm.land/fantasy"
18 "charm.land/fantasy"
19 "charm.land/lipgloss/v2"
19 "charm.land/lipgloss/v2"
20+ "github.com/charmbracelet/catwalk/pkg/catwalk"
20 "github.com/charmbracelet/crush/internal/agent"
21 "github.com/charmbracelet/crush/internal/agent"
21 "github.com/charmbracelet/crush/internal/agent/tools/mcp"
22 "github.com/charmbracelet/crush/internal/agent/tools/mcp"
22 "github.com/charmbracelet/crush/internal/config"
23 "github.com/charmbracelet/crush/internal/config"
@@ -131,12 +132,18 @@ func (app *App) Config() *config.Config {
131
132
132// RunNonInteractive runs the application in non-interactive mode with the
133// RunNonInteractive runs the application in non-interactive mode with the
133// given prompt, printing to stdout.
134// given prompt, printing to stdout.
134−func (app *App) RunNonInteractive(ctx context.Context, output io.Writer, prompt string, quiet bool) error {
135+func (app *App) RunNonInteractive(ctx context.Context, output io.Writer, prompt, largeModel, smallModel string, quiet bool) error {
135 slog.Info("Running in non-interactive mode")
136 slog.Info("Running in non-interactive mode")
136
137
137 ctx, cancel := context.WithCancel(ctx)
138 ctx, cancel := context.WithCancel(ctx)
138 defer cancel()
139 defer cancel()
139
140
141+ if largeModel != "" || smallModel != "" {
142+ if err := app.overrideModelsForNonInteractive(ctx, largeModel, smallModel); err != nil {
143+ return fmt.Errorf("failed to override models: %w", err)
144+ }
145+ }
146+
140 var (
147 var (
141 spinner *format.Spinner
148 spinner *format.Spinner
142 stdoutTTY bool
149 stdoutTTY bool
@@ -299,6 +306,95 @@ func (app *App) UpdateAgentModel(ctx context.Context) error {
299 return app.AgentCoordinator.UpdateModels(ctx)
306 return app.AgentCoordinator.UpdateModels(ctx)
300}
307}
301
308
309+// overrideModelsForNonInteractive parses the model strings and temporarily
310+// overrides the model configurations, then rebuilds the agent.
311+// Format: "model-name" (searches all providers) or "provider/model-name".
312+// Model matching is case-insensitive.
313+// If largeModel is provided but smallModel is not, the small model defaults to
314+// the provider's default small model.
315+func (app *App) overrideModelsForNonInteractive(ctx context.Context, largeModel, smallModel string) error {
316+ providers := app.config.Providers.Copy()
317+
318+ largeMatches, smallMatches, err := findModels(providers, largeModel, smallModel)
319+ if err != nil {
320+ return err
321+ }
322+
323+ var largeProviderID string
324+
325+ // Override large model.
326+ if largeModel != "" {
327+ found, err := validateMatches(largeMatches, largeModel, "large")
328+ if err != nil {
329+ return err
330+ }
331+ largeProviderID = found.provider
332+ slog.Info("Overriding large model for non-interactive run", "provider", found.provider, "model", found.modelID)
333+ app.config.Models[config.SelectedModelTypeLarge] = config.SelectedModel{
334+ Provider: found.provider,
335+ Model: found.modelID,
336+ }
337+ }
338+
339+ // Override small model.
340+ switch {
341+ case smallModel != "":
342+ found, err := validateMatches(smallMatches, smallModel, "small")
343+ if err != nil {
344+ return err
345+ }
346+ slog.Info("Overriding small model for non-interactive run", "provider", found.provider, "model", found.modelID)
347+ app.config.Models[config.SelectedModelTypeSmall] = config.SelectedModel{
348+ Provider: found.provider,
349+ Model: found.modelID,
350+ }
351+
352+ case largeModel != "":
353+ // No small model specified, but large model was - use provider's default.
354+ smallCfg := app.getDefaultSmallModel(largeProviderID)
355+ app.config.Models[config.SelectedModelTypeSmall] = smallCfg
356+ }
357+
358+ return app.AgentCoordinator.UpdateModels(ctx)
359+}
360+
361+// provider. Falls back to the large model if no default is found.
362+func (app *App) getDefaultSmallModel(providerID string) config.SelectedModel {
363+ cfg := app.config
364+ largeModelCfg := cfg.Models[config.SelectedModelTypeLarge]
365+
366+ // Find the provider in the known providers list to get its default small model.
367+ knownProviders, _ := config.Providers(cfg)
368+ var knownProvider *catwalk.Provider
369+ for _, p := range knownProviders {
370+ if string(p.ID) == providerID {
371+ knownProvider = &p
372+ break
373+ }
374+ }
375+
376+ // For unknown/local providers, use the large model as small.
377+ if knownProvider == nil {
378+ slog.Warn("Using large model as small model for unknown provider", "provider", providerID, "model", largeModelCfg.Model)
379+ return largeModelCfg
380+ }
381+
382+ defaultSmallModelID := knownProvider.DefaultSmallModelID
383+ model := cfg.GetModel(providerID, defaultSmallModelID)
384+ if model == nil {
385+ slog.Warn("Default small model not found, using large model", "provider", providerID, "model", largeModelCfg.Model)
386+ return largeModelCfg
387+ }
388+
389+ slog.Info("Using provider default small model", "provider", providerID, "model", defaultSmallModelID)
390+ return config.SelectedModel{
391+ Provider: providerID,
392+ Model: defaultSmallModelID,
393+ MaxTokens: model.DefaultMaxTokens,
394+ ReasoningEffort: model.DefaultReasoningEffort,
395+ }
396+}
397+
302func (app *App) setupEvents() {
398func (app *App) setupEvents() {
303 ctx, cancel := context.WithCancel(app.globalCtx)
399 ctx, cancel := context.WithCancel(app.globalCtx)
304 app.eventsCtx = ctx
400 app.eventsCtx = ctx
internal/app/provider.gonew file+95
@@ -0,0 +1,95 @@
1+package app
2+
3+import (
4+ "fmt"
5+ "strings"
6+
7+ "github.com/charmbracelet/crush/internal/config"
8+ xstrings "github.com/charmbracelet/x/exp/strings"
9+)
10+
11+// parseModelStr parses a model string into provider filter and model ID.
12+// Format: "model-name" or "provider/model-name" or "synthetic/moonshot/kimi-k2".
13+// This function only checks if the first component is a valid provider name; if not,
14+// it treats the entire string as a model ID (which may contain slashes).
15+func parseModelStr(providers map[string]config.ProviderConfig, modelStr string) (providerFilter, modelID string) {
16+ parts := strings.Split(modelStr, "/")
17+ if len(parts) == 1 {
18+ return "", parts[0]
19+ }
20+ // Check if the first part is a valid provider name
21+ if _, ok := providers[parts[0]]; ok {
22+ return parts[0], strings.Join(parts[1:], "/")
23+ }
24+
25+ // First part is not a valid provider, treat entire string as model ID
26+ return "", modelStr
27+}
28+
29+// modelMatch represents a found model.
30+type modelMatch struct {
31+ provider string
32+ modelID string
33+}
34+
35+func findModels(providers map[string]config.ProviderConfig, largeModel, smallModel string) ([]modelMatch, []modelMatch, error) {
36+ largeProviderFilter, largeModelID := parseModelStr(providers, largeModel)
37+ smallProviderFilter, smallModelID := parseModelStr(providers, smallModel)
38+
39+ // Validate provider filters exist.
40+ for _, pf := range []struct {
41+ filter, label string
42+ }{
43+ {largeProviderFilter, "large"},
44+ {smallProviderFilter, "small"},
45+ } {
46+ if pf.filter != "" {
47+ if _, ok := providers[pf.filter]; !ok {
48+ return nil, nil, fmt.Errorf("%s model: provider %q not found in configuration. Use 'crush models' to list available models", pf.label, pf.filter)
49+ }
50+ }
51+ }
52+
53+ // Find matching models in a single pass.
54+ var largeMatches, smallMatches []modelMatch
55+ for name, provider := range providers {
56+ if provider.Disable {
57+ continue
58+ }
59+ for _, m := range provider.Models {
60+ if filter(largeModelID, largeProviderFilter, m.ID, name) {
61+ largeMatches = append(largeMatches, modelMatch{provider: name, modelID: m.ID})
62+ }
63+ if filter(smallModelID, smallProviderFilter, m.ID, name) {
64+ smallMatches = append(smallMatches, modelMatch{provider: name, modelID: m.ID})
65+ }
66+ }
67+ }
68+
69+ return largeMatches, smallMatches, nil
70+}
71+
72+func filter(modelFilter, providerFilter, model, provider string) bool {
73+ return modelFilter != "" && model == modelFilter &&
74+ (providerFilter == "" || provider == providerFilter)
75+}
76+
77+// Validate and return a single match.
78+func validateMatches(matches []modelMatch, modelID, label string) (modelMatch, error) {
79+ switch {
80+ case len(matches) == 0:
81+ return modelMatch{}, fmt.Errorf("%s model %q not found", label, modelID)
82+ case len(matches) > 1:
83+ names := make([]string, len(matches))
84+ for i, m := range matches {
85+ names[i] = m.provider
86+ }
87+ return modelMatch{}, fmt.Errorf(
88+ "%s model: model %q found in multiple providers: %s. Please specify provider using 'provider/model' format",
89+ label,
90+ modelID,
91+ xstrings.EnglishJoin(names, true),
92+ )
93+ }
94+ return matches[0], nil
95+}
internal/app/provider_test.gonew file+210
@@ -0,0 +1,210 @@
1+package app
2+
3+import (
4+ "testing"
5+
6+ "github.com/charmbracelet/catwalk/pkg/catwalk"
7+ "github.com/charmbracelet/crush/internal/config"
8+ "github.com/stretchr/testify/require"
9+)
10+
11+func TestParseModelStr(t *testing.T) {
12+ tests := []struct {
13+ name string
14+ modelStr string
15+ expectedFilter string
16+ expectedModelID string
17+ setupProviders func() map[string]config.ProviderConfig
18+ }{
19+ {
20+ name: "simple model with no slashes",
21+ modelStr: "gpt-4o",
22+ expectedFilter: "",
23+ expectedModelID: "gpt-4o",
24+ setupProviders: setupMockProviders,
25+ },
26+ {
27+ name: "valid provider and model",
28+ modelStr: "openai/gpt-4o",
29+ expectedFilter: "openai",
30+ expectedModelID: "gpt-4o",
31+ setupProviders: setupMockProviders,
32+ },
33+ {
34+ name: "model with multiple slashes and first part is invalid provider",
35+ modelStr: "moonshot/kimi-k2",
36+ expectedFilter: "",
37+ expectedModelID: "moonshot/kimi-k2",
38+ setupProviders: setupMockProviders,
39+ },
40+ {
41+ name: "full path with valid provider and model with slashes",
42+ modelStr: "synthetic/moonshot/kimi-k2",
43+ expectedFilter: "synthetic",
44+ expectedModelID: "moonshot/kimi-k2",
45+ setupProviders: setupMockProvidersWithSlashes,
46+ },
47+ {
48+ name: "empty model string",
49+ modelStr: "",
50+ expectedFilter: "",
51+ expectedModelID: "",
52+ setupProviders: setupMockProviders,
53+ },
54+ {
55+ name: "model with trailing slash but valid provider",
56+ modelStr: "openai/",
57+ expectedFilter: "openai",
58+ expectedModelID: "",
59+ setupProviders: setupMockProviders,
60+ },
61+ }
62+
63+ for _, tt := range tests {
64+ t.Run(tt.name, func(t *testing.T) {
65+ providers := tt.setupProviders()
66+ filter, modelID := parseModelStr(providers, tt.modelStr)
67+
68+ require.Equal(t, tt.expectedFilter, filter, "provider filter mismatch")
69+ require.Equal(t, tt.expectedModelID, modelID, "model ID mismatch")
70+ })
71+ }
72+}
73+
74+func setupMockProviders() map[string]config.ProviderConfig {
75+ return map[string]config.ProviderConfig{
76+ "openai": {
77+ ID: "openai",
78+ Name: "OpenAI",
79+ Models: []catwalk.Model{{ID: "gpt-4o"}, {ID: "gpt-4o-mini"}},
80+ },
81+ "anthropic": {
82+ ID: "anthropic",
83+ Name: "Anthropic",
84+ Models: []catwalk.Model{{ID: "claude-3-sonnet"}, {ID: "claude-3-opus"}},
85+ },
86+ }
87+}
88+
89+func setupMockProvidersWithSlashes() map[string]config.ProviderConfig {
90+ return map[string]config.ProviderConfig{
91+ "synthetic": {
92+ ID: "synthetic",
93+ Name: "Synthetic",
94+ Models: []catwalk.Model{
95+ {ID: "moonshot/kimi-k2"},
96+ {ID: "deepseek/deepseek-chat"},
97+ },
98+ },
99+ "openai": {
100+ ID: "openai",
101+ Name: "OpenAI",
102+ Models: []catwalk.Model{{ID: "gpt-4o"}},
103+ },
104+ }
105+}
106+
107+func TestFindModels(t *testing.T) {
108+ tests := []struct {
109+ name string
110+ modelStr string
111+ expectedProvider string
112+ expectedModelID string
113+ expectError bool
114+ errorContains string
115+ setupProviders func() map[string]config.ProviderConfig
116+ }{
117+ {
118+ name: "simple model found in one provider",
119+ modelStr: "gpt-4o",
120+ expectedProvider: "openai",
121+ expectedModelID: "gpt-4o",
122+ expectError: false,
123+ setupProviders: setupMockProviders,
124+ },
125+ {
126+ name: "model with slashes in ID",
127+ modelStr: "moonshot/kimi-k2",
128+ expectedProvider: "synthetic",
129+ expectedModelID: "moonshot/kimi-k2",
130+ expectError: false,
131+ setupProviders: setupMockProvidersWithSlashes,
132+ },
133+ {
134+ name: "provider and model with slashes in ID",
135+ modelStr: "synthetic/moonshot/kimi-k2",
136+ expectedProvider: "synthetic",
137+ expectedModelID: "moonshot/kimi-k2",
138+ expectError: false,
139+ setupProviders: setupMockProvidersWithSlashes,
140+ },
141+ {
142+ name: "model not found",
143+ modelStr: "nonexistent-model",
144+ expectError: true,
145+ errorContains: "not found",
146+ setupProviders: setupMockProviders,
147+ },
148+ {
149+ name: "invalid provider specified",
150+ modelStr: "nonexistent-provider/gpt-4o",
151+ expectError: true,
152+ errorContains: "provider",
153+ setupProviders: setupMockProviders,
154+ },
155+ {
156+ name: "model found in multiple providers without provider filter",
157+ modelStr: "shared-model",
158+ expectError: true,
159+ errorContains: "multiple providers",
160+ setupProviders: func() map[string]config.ProviderConfig {
161+ return map[string]config.ProviderConfig{
162+ "openai": {
163+ ID: "openai",
164+ Models: []catwalk.Model{{ID: "shared-model"}},
165+ },
166+ "anthropic": {
167+ ID: "anthropic",
168+ Models: []catwalk.Model{{ID: "shared-model"}},
169+ },
170+ }
171+ },
172+ },
173+ {
174+ name: "empty model string",
175+ modelStr: "",
176+ expectError: true,
177+ errorContains: "not found",
178+ setupProviders: setupMockProviders,
179+ },
180+ }
181+
182+ for _, tt := range tests {
183+ t.Run(tt.name, func(t *testing.T) {
184+ providers := tt.setupProviders()
185+
186+ // Use findModels with the model as "large" and empty "small".
187+ matches, _, err := findModels(providers, tt.modelStr, "")
188+ if err != nil {
189+ if tt.expectError {
190+ require.Contains(t, err.Error(), tt.errorContains)
191+ } else {
192+ require.NoError(t, err)
193+ }
194+ return
195+ }
196+
197+ // Validate the matches.
198+ match, err := validateMatches(matches, tt.modelStr, "large")
199+
200+ if tt.expectError {
201+ require.Error(t, err)
202+ require.Contains(t, err.Error(), tt.errorContains)
203+ } else {
204+ require.NoError(t, err)
205+ require.Equal(t, tt.expectedProvider, match.provider)
206+ require.Equal(t, tt.expectedModelID, match.modelID)
207+ }
208+ })
209+ }
210+}
internal/cmd/models.gonew file+110
@@ -0,0 +1,110 @@
1+package cmd
2+
3+import (
4+ "fmt"
5+ "os"
6+ "slices"
7+ "sort"
8+ "strings"
9+
10+ "charm.land/lipgloss/v2/tree"
11+ "github.com/charmbracelet/catwalk/pkg/catwalk"
12+ "github.com/charmbracelet/crush/internal/config"
13+ "github.com/mattn/go-isatty"
14+ "github.com/spf13/cobra"
15+)
16+
17+var modelsCmd = &cobra.Command{
18+ Use: "models",
19+ Short: "List all available models from configured providers",
20+ Long: `List all available models from configured providers. Shows provider name and model IDs.`,
21+ Example: `# List all available models
22+crush models
23+
24+# Search models
25+crush models gpt5`,
26+ Args: cobra.ArbitraryArgs,
27+ RunE: func(cmd *cobra.Command, args []string) error {
28+ cwd, err := ResolveCwd(cmd)
29+ if err != nil {
30+ return err
31+ }
32+
33+ dataDir, _ := cmd.Flags().GetString("data-dir")
34+ debug, _ := cmd.Flags().GetBool("debug")
35+
36+ cfg, err := config.Init(cwd, dataDir, debug)
37+ if err != nil {
38+ return err
39+ }
40+
41+ if !cfg.IsConfigured() {
42+ return fmt.Errorf("no providers configured - please run 'crush' to set up a provider interactively")
43+ }
44+
45+ term := strings.ToLower(strings.Join(args, " "))
46+ filter := func(p config.ProviderConfig, m catwalk.Model) bool {
47+ for _, s := range []string{p.ID, p.Name, m.ID, m.Name} {
48+ if term == "" || strings.Contains(strings.ToLower(s), term) {
49+ return true
50+ }
51+ }
52+ return false
53+ }
54+
55+ var providerIDs []string
56+ providerModels := make(map[string][]string)
57+
58+ for providerID, provider := range cfg.Providers.Seq2() {
59+ if provider.Disable {
60+ continue
61+ }
62+ var found bool
63+ for _, model := range provider.Models {
64+ if !filter(provider, model) {
65+ continue
66+ }
67+ providerModels[providerID] = append(providerModels[providerID], model.ID)
68+ found = true
69+ }
70+ if !found {
71+ continue
72+ }
73+ slices.Sort(providerModels[providerID])
74+ providerIDs = append(providerIDs, providerID)
75+ }
76+ sort.Strings(providerIDs)
77+
78+ if len(providerIDs) == 0 && len(args) == 0 {
79+ return fmt.Errorf("no enabled providers found")
80+ }
81+ if len(providerIDs) == 0 {
82+ return fmt.Errorf("no enabled providers found matching %q", term)
83+ }
84+
85+ if !isatty.IsTerminal(os.Stdout.Fd()) {
86+ for _, providerID := range providerIDs {
87+ for _, modelID := range providerModels[providerID] {
88+ fmt.Println(providerID + "/" + modelID)
89+ }
90+ }
91+ return nil
92+ }
93+
94+ t := tree.New()
95+ for _, providerID := range providerIDs {
96+ providerNode := tree.Root(providerID)
97+ for _, modelID := range providerModels[providerID] {
98+ providerNode.Child(modelID)
99+ }
100+ t.Child(providerNode)
101+ }
102+
103+ cmd.Println(t)
104+ return nil
105+ },
106+}
107+
108+func init() {
109+ rootCmd.AddCommand(modelsCmd)
110+}
internal/cmd/run.go+5−1
@@ -32,6 +32,8 @@ crush run --quiet "Generate a README for this project"
32 `,
32 `,
33 RunE: func(cmd *cobra.Command, args []string) error {
33 RunE: func(cmd *cobra.Command, args []string) error {
34 quiet, _ := cmd.Flags().GetBool("quiet")
34 quiet, _ := cmd.Flags().GetBool("quiet")
35+ largeModel, _ := cmd.Flags().GetString("model")
36+ smallModel, _ := cmd.Flags().GetString("small-model")
35
37
36 // Cancel on SIGINT or SIGTERM.
38 // Cancel on SIGINT or SIGTERM.
37 ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, os.Kill)
39 ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, os.Kill)
@@ -62,7 +64,7 @@ crush run --quiet "Generate a README for this project"
62 event.SetNonInteractive(true)
64 event.SetNonInteractive(true)
63 event.AppInitialized()
65 event.AppInitialized()
64
66
65− return app.RunNonInteractive(ctx, os.Stdout, prompt, quiet)
67+ return app.RunNonInteractive(ctx, os.Stdout, prompt, largeModel, smallModel, quiet)
66 },
68 },
67 PostRun: func(cmd *cobra.Command, args []string) {
69 PostRun: func(cmd *cobra.Command, args []string) {
68 event.AppExited()
70 event.AppExited()
@@ -71,4 +73,6 @@ crush run --quiet "Generate a README for this project"
71
73
72func init() {
74func init() {
73 runCmd.Flags().BoolP("quiet", "q", false, "Hide spinner")
75 runCmd.Flags().BoolP("quiet", "q", false, "Hide spinner")
76+ runCmd.Flags().StringP("model", "m", "", "Model to use. Accepts 'model' or 'provider/model' to disambiguate models with the same name across providers")
77+ runCmd.Flags().String("small-model", "", "Small model to use. If not provided, uses the default small model for the provider")
74}
78}
internal/tui/tui.go+2
@@ -16,6 +16,7 @@ import (
16 "github.com/charmbracelet/crush/internal/app"
16 "github.com/charmbracelet/crush/internal/app"
17 "github.com/charmbracelet/crush/internal/config"
17 "github.com/charmbracelet/crush/internal/config"
18 "github.com/charmbracelet/crush/internal/event"
18 "github.com/charmbracelet/crush/internal/event"
19+ "github.com/charmbracelet/crush/internal/home"
19 "github.com/charmbracelet/crush/internal/permission"
20 "github.com/charmbracelet/crush/internal/permission"
20 "github.com/charmbracelet/crush/internal/pubsub"
21 "github.com/charmbracelet/crush/internal/pubsub"
21 "github.com/charmbracelet/crush/internal/stringext"
22 "github.com/charmbracelet/crush/internal/stringext"
@@ -592,6 +593,7 @@ func (a *appModel) View() tea.View {
592 view.AltScreen = true
593 view.AltScreen = true
593 view.MouseMode = tea.MouseModeCellMotion
594 view.MouseMode = tea.MouseModeCellMotion
594 view.BackgroundColor = t.BgBase
595 view.BackgroundColor = t.BgBase
596+ view.WindowTitle = "crush " + home.Short(config.Get().WorkingDir())
595 if a.wWidth < 25 || a.wHeight < 15 {
597 if a.wWidth < 25 || a.wHeight < 15 {
596 view.Content = t.S().Base.Width(a.wWidth).Height(a.wHeight).
598 view.Content = t.S().Base.Width(a.wWidth).Height(a.wHeight).
597 Align(lipgloss.Center, lipgloss.Center).
599 Align(lipgloss.Center, lipgloss.Center).