refactor: remove Config() escape hatch, eliminate all direct Config access

Kujtim Hoxha created

Remove Service.Config() method entirely. Remove Config.Agents field.
Add Provider, SetProvider, AllProviders, SetAttribution,
SetSkillsPaths, SetLSP to Service. Migrate all remaining callers
(chat, coordinator, app, UI, commands, MCP, prompt, init) to use
Service methods exclusively. No code outside the config package
touches Config directly.

🐾 Generated with Crush

Assisted-by: Claude Opus 4.6 via Crush <crush@charm.land>

Change summary

internal/agent/agent_tool.go         |  2 
internal/agent/agentic_fetch_tool.go |  6 ++--
internal/agent/common_test.go        | 14 +++++-----
internal/agent/coordinator.go        | 23 +++++++---------
internal/app/app.go                  | 18 ++++++------
internal/cmd/models.go               |  2 
internal/cmd/stats.go                |  2 
internal/config/config.go            |  2 -
internal/config/load.go              |  2 
internal/config/load_test.go         | 21 +++++++++------
internal/config/provider.go          |  4 +-
internal/config/provider_test.go     |  2 
internal/config/service.go           | 40 ++++++++++++++++++++++++------
internal/ui/chat/messages.go         |  6 ++--
internal/ui/dialog/models.go         | 12 +++-----
internal/ui/model/onboarding.go      |  4 +-
internal/ui/model/sidebar.go         |  2 
internal/ui/model/ui.go              | 24 +++++++++---------
18 files changed, 103 insertions(+), 83 deletions(-)

Detailed changes

internal/agent/agent_tool.go 🔗

@@ -67,7 +67,7 @@ func (c *coordinator) agentTool(ctx context.Context) (fantasy.AgentTool, error)
 				maxTokens = model.ModelCfg.MaxTokens
 			}
 
-			providerCfg, ok := c.cfgSvc.Config().Providers[model.ModelCfg.Provider]
+			providerCfg, ok := c.cfgSvc.Provider(model.ModelCfg.Provider)
 			if !ok {
 				return fantasy.ToolResponse{}, errors.New("model provider not configured")
 			}

internal/agent/agentic_fetch_tool.go 🔗

@@ -98,7 +98,7 @@ func (c *coordinator) agenticFetchTool(_ context.Context, client *http.Client) (
 				return fantasy.ToolResponse{}, permission.ErrorPermissionDenied
 			}
 
-			tmpDir, err := os.MkdirTemp(c.cfgSvc.Config().Options.DataDirectory, "crush-fetch-*")
+			tmpDir, err := os.MkdirTemp(c.cfgSvc.DataDirectory(), "crush-fetch-*")
 			if err != nil {
 				return fantasy.NewTextErrorResponse(fmt.Sprintf("Failed to create temporary directory: %s", err)), nil
 			}
@@ -156,7 +156,7 @@ func (c *coordinator) agenticFetchTool(_ context.Context, client *http.Client) (
 				return fantasy.ToolResponse{}, fmt.Errorf("error building system prompt: %s", err)
 			}
 
-			smallProviderCfg, ok := c.cfgSvc.Config().Providers[small.ModelCfg.Provider]
+			smallProviderCfg, ok := c.cfgSvc.Provider(small.ModelCfg.Provider)
 			if !ok {
 				return fantasy.ToolResponse{}, errors.New("small model provider not configured")
 			}
@@ -177,7 +177,7 @@ func (c *coordinator) agenticFetchTool(_ context.Context, client *http.Client) (
 				SmallModel:           small,
 				SystemPromptPrefix:   smallProviderCfg.SystemPromptPrefix,
 				SystemPrompt:         systemPrompt,
-				DisableAutoSummarize: c.cfgSvc.Config().Options.DisableAutoSummarize,
+				DisableAutoSummarize: c.cfgSvc.DisableAutoSummarize(),
 				IsYolo:               c.permissions.SkipRequests(),
 				Sessions:             c.sessions,
 				Messages:             c.messages,

internal/agent/common_test.go 🔗

@@ -177,18 +177,18 @@ func coderAgent(r *vcr.Recorder, env fakeEnv, large, small fantasy.LanguageModel
 
 	// NOTE(@andreynering): Set a fixed config to ensure cassettes match
 	// independently of user config on `$HOME/.config/crush/crush.json`.
-	cfg.Config().Options.Attribution = &config.Attribution{
+	cfg.SetAttribution(&config.Attribution{
 		TrailerStyle:  "co-authored-by",
 		GeneratedWith: true,
-	}
+	})
 
 	// Clear skills paths to ensure test reproducibility - user's skills
 	// would be included in prompt and break VCR cassette matching.
-	cfg.Config().Options.SkillsPaths = []string{}
+	cfg.SetSkillsPaths([]string{})
 
 	// Clear LSP config to ensure test reproducibility - user's LSP config
 	// would be included in prompt and break VCR cassette matching.
-	cfg.Config().LSP = nil
+	cfg.SetLSP(nil)
 
 	systemPrompt, err := prompt.Build(context.TODO(), large.Provider(), large.Model(), cfg)
 	if err != nil {
@@ -197,19 +197,19 @@ func coderAgent(r *vcr.Recorder, env fakeEnv, large, small fantasy.LanguageModel
 
 	// Get the model name for the bash tool
 	modelName := large.Model() // fallback to ID if Name not available
-	if model := cfg.Config().GetModel(large.Provider(), large.Model()); model != nil {
+	if model := cfg.GetModel(large.Provider(), large.Model()); model != nil {
 		modelName = model.Name
 	}
 
 	allTools := []fantasy.AgentTool{
-		tools.NewBashTool(env.permissions, env.workingDir, cfg.Config().Options.Attribution, modelName),
+		tools.NewBashTool(env.permissions, env.workingDir, cfg.Attribution(), modelName),
 		tools.NewDownloadTool(env.permissions, env.workingDir, r.GetDefaultClient()),
 		tools.NewEditTool(env.lspClients, env.permissions, env.history, *env.filetracker, env.workingDir),
 		tools.NewMultiEditTool(env.lspClients, env.permissions, env.history, *env.filetracker, env.workingDir),
 		tools.NewFetchTool(env.permissions, env.workingDir, r.GetDefaultClient()),
 		tools.NewGlobTool(env.workingDir),
 		tools.NewGrepTool(env.workingDir),
-		tools.NewLsTool(env.permissions, env.workingDir, cfg.Config().Tools.Ls),
+		tools.NewLsTool(env.permissions, env.workingDir, cfg.ToolLsConfig()),
 		tools.NewSourcegraphTool(r.GetDefaultClient()),
 		tools.NewViewTool(env.lspClients, env.permissions, *env.filetracker, env.workingDir),
 		tools.NewWriteTool(env.lspClients, env.permissions, env.history, *env.filetracker, env.workingDir),

internal/agent/coordinator.go 🔗

@@ -61,7 +61,6 @@ type Coordinator interface {
 }
 
 type coordinator struct {
-	cfg         *config.Config
 	cfgSvc      *config.Service
 	sessions    session.Service
 	messages    message.Service
@@ -86,9 +85,7 @@ func NewCoordinator(
 	filetracker filetracker.Service,
 	lspClients *csync.Map[string, *lsp.Client],
 ) (Coordinator, error) {
-	cfg := cfgSvc.Config()
 	c := &coordinator{
-		cfg:         cfg,
 		cfgSvc:      cfgSvc,
 		sessions:    sessions,
 		messages:    messages,
@@ -99,7 +96,7 @@ func NewCoordinator(
 		agents:      make(map[string]SessionAgent),
 	}
 
-	agentCfg, ok := cfg.Agents[config.AgentCoder]
+	agentCfg, ok := cfgSvc.Agent(config.AgentCoder)
 	if !ok {
 		return nil, errors.New("coder agent not configured")
 	}
@@ -147,7 +144,7 @@ func (c *coordinator) Run(ctx context.Context, sessionID string, prompt string,
 		attachments = filteredAttachments
 	}
 
-	providerCfg, ok := c.cfgSvc.Config().Providers[model.ModelCfg.Provider]
+	providerCfg, ok := c.cfgSvc.Provider(model.ModelCfg.Provider)
 	if !ok {
 		return nil, errors.New("model provider not configured")
 	}
@@ -360,7 +357,7 @@ func (c *coordinator) buildAgent(ctx context.Context, prompt *prompt.Prompt, age
 		return nil, err
 	}
 
-	largeProviderCfg, _ := c.cfgSvc.Config().Providers[large.ModelCfg.Provider]
+	largeProviderCfg, _ := c.cfgSvc.Provider(large.ModelCfg.Provider)
 	result := NewSessionAgent(SessionAgentOptions{
 		large,
 		small,
@@ -415,7 +412,7 @@ func (c *coordinator) buildTools(ctx context.Context, agent config.Agent) ([]fan
 
 	// Get the model name for the agent
 	modelName := ""
-	if modelCfg, ok := c.cfgSvc.Config().Models[agent.Model]; ok {
+	if modelCfg, ok := c.cfgSvc.SelectedModel(agent.Model); ok {
 		if model := c.cfgSvc.GetModel(modelCfg.Provider, modelCfg.Model); model != nil {
 			modelName = model.Name
 		}
@@ -479,16 +476,16 @@ func (c *coordinator) buildTools(ctx context.Context, agent config.Agent) ([]fan
 
 // TODO: when we support multiple agents we need to change this so that we pass in the agent specific model config
 func (c *coordinator) buildAgentModels(ctx context.Context, isSubAgent bool) (Model, Model, error) {
-	largeModelCfg, ok := c.cfgSvc.Config().Models[config.SelectedModelTypeLarge]
+	largeModelCfg, ok := c.cfgSvc.SelectedModel(config.SelectedModelTypeLarge)
 	if !ok {
 		return Model{}, Model{}, errors.New("large model not selected")
 	}
-	smallModelCfg, ok := c.cfgSvc.Config().Models[config.SelectedModelTypeSmall]
+	smallModelCfg, ok := c.cfgSvc.SelectedModel(config.SelectedModelTypeSmall)
 	if !ok {
 		return Model{}, Model{}, errors.New("small model not selected")
 	}
 
-	largeProviderCfg, ok := c.cfgSvc.Config().Providers[largeModelCfg.Provider]
+	largeProviderCfg, ok := c.cfgSvc.Provider(largeModelCfg.Provider)
 	if !ok {
 		return Model{}, Model{}, errors.New("large model provider not configured")
 	}
@@ -498,7 +495,7 @@ func (c *coordinator) buildAgentModels(ctx context.Context, isSubAgent bool) (Mo
 		return Model{}, Model{}, err
 	}
 
-	smallProviderCfg, ok := c.cfgSvc.Config().Providers[smallModelCfg.Provider]
+	smallProviderCfg, ok := c.cfgSvc.Provider(smallModelCfg.Provider)
 	if !ok {
 		return Model{}, Model{}, errors.New("large model provider not configured")
 	}
@@ -881,7 +878,7 @@ func (c *coordinator) QueuedPromptsList(sessionID string) []string {
 }
 
 func (c *coordinator) Summarize(ctx context.Context, sessionID string) error {
-	providerCfg, ok := c.cfgSvc.Config().Providers[c.currentAgent.Model().ModelCfg.Provider]
+	providerCfg, ok := c.cfgSvc.Provider(c.currentAgent.Model().ModelCfg.Provider)
 	if !ok {
 		return errors.New("model provider not configured")
 	}
@@ -912,7 +909,7 @@ func (c *coordinator) refreshApiKeyTemplate(ctx context.Context, providerCfg con
 	}
 
 	providerCfg.APIKey = newAPIKey
-	c.cfgSvc.Config().Providers[providerCfg.ID] = providerCfg
+	c.cfgSvc.SetProvider(providerCfg.ID, providerCfg)
 
 	if err := c.UpdateModels(ctx); err != nil {
 		return err

internal/app/app.go 🔗

@@ -75,15 +75,15 @@ type App struct {
 
 // New initializes a new application instance.
 func New(ctx context.Context, conn *sql.DB, cfgSvc *config.Service) (*App, error) {
-	cfg := cfgSvc.Config()
 	q := db.New(conn)
 	sessions := session.NewService(q, conn)
 	messages := message.NewService(q)
 	files := history.NewService(q, conn)
-	skipPermissionsRequests := cfg.Permissions != nil && cfg.Permissions.SkipRequests
+	perms := cfgSvc.Permissions()
+	skipPermissionsRequests := perms != nil && perms.SkipRequests
 	var allowedTools []string
-	if cfg.Permissions != nil && cfg.Permissions.AllowedTools != nil {
-		allowedTools = cfg.Permissions.AllowedTools
+	if perms != nil && perms.AllowedTools != nil {
+		allowedTools = perms.AllowedTools
 	}
 
 	app := &App{
@@ -322,7 +322,7 @@ func (app *App) UpdateAgentModel(ctx context.Context) error {
 // If largeModel is provided but smallModel is not, the small model defaults to
 // the provider's default small model.
 func (app *App) overrideModelsForNonInteractive(ctx context.Context, largeModel, smallModel string) error {
-	providers := maps.Clone(app.configService.Config().Providers)
+	providers := maps.Clone(app.configService.AllProviders())
 
 	largeMatches, smallMatches, err := findModels(providers, largeModel, smallModel)
 	if err != nil {
@@ -370,11 +370,11 @@ func (app *App) overrideModelsForNonInteractive(ctx context.Context, largeModel,
 // GetDefaultSmallModel returns the default small model for the given
 // provider. Falls back to the large model if no default is found.
 func (app *App) GetDefaultSmallModel(providerID string) config.SelectedModel {
-	cfg := app.configService.Config()
-	largeModelCfg := cfg.Models[config.SelectedModelTypeLarge]
+	svc := app.configService
+	largeModelCfg, _ := svc.SelectedModel(config.SelectedModelTypeLarge)
 
 	// Find the provider in the known providers list to get its default small model.
-	knownProviders, _ := config.Providers(cfg)
+	knownProviders, _ := config.Providers(svc)
 	var knownProvider *catwalk.Provider
 	for _, p := range knownProviders {
 		if string(p.ID) == providerID {
@@ -390,7 +390,7 @@ func (app *App) GetDefaultSmallModel(providerID string) config.SelectedModel {
 	}
 
 	defaultSmallModelID := knownProvider.DefaultSmallModelID
-	model := cfg.GetModel(providerID, defaultSmallModelID)
+	model := svc.GetModel(providerID, defaultSmallModelID)
 	if model == nil {
 		slog.Warn("Default small model not found, using large model", "provider", providerID, "model", largeModelCfg.Model)
 		return largeModelCfg

internal/cmd/models.go 🔗

@@ -55,7 +55,7 @@ crush models gpt5`,
 		var providerIDs []string
 		providerModels := make(map[string][]string)
 
-		for providerID, provider := range cfg.Config().Providers {
+		for providerID, provider := range cfg.AllProviders() {
 			if provider.Disable {
 				continue
 			}

internal/cmd/stats.go 🔗

@@ -128,7 +128,7 @@ func runStats(cmd *cobra.Command, _ []string) error {
 		if err != nil {
 			return fmt.Errorf("failed to initialize config: %w", err)
 		}
-		dataDir = svc.Config().Options.DataDirectory
+		dataDir = svc.DataDirectory()
 	}
 
 	conn, err := db.Connect(ctx, dataDir)

internal/config/config.go 🔗

@@ -376,8 +376,6 @@ type Config struct {
 	Permissions *Permissions `json:"permissions,omitempty" jsonschema:"description=Permission settings for tool usage"`
 
 	Tools Tools `json:"tools,omitempty" jsonschema:"description=Tool configurations"`
-
-	Agents map[string]Agent `json:"-"`
 }
 
 func (c *Config) EnabledProviders() []ProviderConfig {

internal/config/load.go 🔗

@@ -73,7 +73,7 @@ func Load(workingDir, dataDir string, debug bool) (*Service, error) {
 	}
 
 	// Load known providers, this loads the config from catwalk
-	providers, err := Providers(cfg)
+	providers, err := Providers(svc)
 	if err != nil {
 		return nil, err
 	}

internal/config/load_test.go 🔗

@@ -463,12 +463,13 @@ func TestConfig_setupAgentsWithNoDisabledTools(t *testing.T) {
 		},
 	}
 
-	serviceFor(cfg).SetupAgents()
-	coderAgent, ok := cfg.Agents[AgentCoder]
+	svc := serviceFor(cfg)
+	svc.SetupAgents()
+	coderAgent, ok := svc.Agents()[AgentCoder]
 	require.True(t, ok)
 	assert.Equal(t, allToolNames(), coderAgent.AllowedTools)
 
-	taskAgent, ok := cfg.Agents[AgentTask]
+	taskAgent, ok := svc.Agents()[AgentTask]
 	require.True(t, ok)
 	assert.Equal(t, []string{"glob", "grep", "ls", "sourcegraph", "view"}, taskAgent.AllowedTools)
 }
@@ -484,13 +485,14 @@ func TestConfig_setupAgentsWithDisabledTools(t *testing.T) {
 		},
 	}
 
-	serviceFor(cfg).SetupAgents()
-	coderAgent, ok := cfg.Agents[AgentCoder]
+	svc := serviceFor(cfg)
+	svc.SetupAgents()
+	coderAgent, ok := svc.Agents()[AgentCoder]
 	require.True(t, ok)
 
 	assert.Equal(t, []string{"agent", "bash", "job_output", "job_kill", "multiedit", "lsp_diagnostics", "lsp_references", "lsp_restart", "fetch", "agentic_fetch", "glob", "ls", "sourcegraph", "todos", "view", "write"}, coderAgent.AllowedTools)
 
-	taskAgent, ok := cfg.Agents[AgentTask]
+	taskAgent, ok := svc.Agents()[AgentTask]
 	require.True(t, ok)
 	assert.Equal(t, []string{"glob", "ls", "sourcegraph", "view"}, taskAgent.AllowedTools)
 }
@@ -508,12 +510,13 @@ func TestConfig_setupAgentsWithEveryReadOnlyToolDisabled(t *testing.T) {
 		},
 	}
 
-	serviceFor(cfg).SetupAgents()
-	coderAgent, ok := cfg.Agents[AgentCoder]
+	svc := serviceFor(cfg)
+	svc.SetupAgents()
+	coderAgent, ok := svc.Agents()[AgentCoder]
 	require.True(t, ok)
 	assert.Equal(t, []string{"agent", "bash", "job_output", "job_kill", "download", "edit", "multiedit", "lsp_diagnostics", "lsp_references", "lsp_restart", "fetch", "agentic_fetch", "todos", "write"}, coderAgent.AllowedTools)
 
-	taskAgent, ok := cfg.Agents[AgentTask]
+	taskAgent, ok := svc.Agents()[AgentTask]
 	require.True(t, ok)
 	assert.Equal(t, []string{}, taskAgent.AllowedTools)
 }

internal/config/provider.go 🔗

@@ -139,12 +139,12 @@ var (
 // 2. load the cached providers
 // 3. try to get the fresh list of providers, and return either this new list,
 // the cached list, or the embedded list if all others fail.
-func Providers(cfg *Config) ([]catwalk.Provider, error) {
+func Providers(svc *Service) ([]catwalk.Provider, error) {
 	providerOnce.Do(func() {
 		var wg sync.WaitGroup
 		var errs []error
 		providers := csync.NewSlice[catwalk.Provider]()
-		autoupdate := !cfg.Options.DisableProviderAutoUpdate
+		autoupdate := !svc.DisableProviderAutoUpdate()
 
 		ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
 		defer cancel()

internal/config/provider_test.go 🔗

@@ -46,7 +46,7 @@ func TestProviders_Integration_AutoUpdateDisabled(t *testing.T) {
 		},
 	}
 
-	providers, err := Providers(cfg)
+	providers, err := Providers(serviceFor(cfg))
 	require.NoError(t, err)
 	require.NotNil(t, providers)
 	require.Greater(t, len(providers), 5, "Expected embedded providers")

internal/config/service.go 🔗

@@ -28,13 +28,6 @@ type Service struct {
 	agents         map[string]Agent
 }
 
-// Config returns the underlying Config struct. This is a temporary
-// escape hatch that will be removed once all callers migrate to
-// Service getter methods.
-func (s *Service) Config() *Config {
-	return s.cfg
-}
-
 // WorkingDir returns the working directory.
 func (s *Service) WorkingDir() string {
 	return s.workingDir
@@ -116,7 +109,6 @@ func (s *Service) SetupAgents() {
 			AllowedMCP:   map[string][]string{},
 		},
 	}
-	s.cfg.Agents = s.agents
 }
 
 // Agents returns the agent configuration map.
@@ -184,6 +176,23 @@ func (s *Service) SelectedModel(modelType SelectedModelType) (SelectedModel, boo
 	return m, ok
 }
 
+// Provider returns the provider config for the given ID and whether
+// it exists.
+func (s *Service) Provider(id string) (ProviderConfig, bool) {
+	p, ok := s.cfg.Providers[id]
+	return p, ok
+}
+
+// SetProvider sets the provider config for the given ID.
+func (s *Service) SetProvider(id string, p ProviderConfig) {
+	s.cfg.Providers[id] = p
+}
+
+// Providers returns all provider configs.
+func (s *Service) AllProviders() map[string]ProviderConfig {
+	return s.cfg.Providers
+}
+
 // MCP returns the MCP configurations.
 func (s *Service) MCP() MCPs {
 	return s.cfg.MCP
@@ -199,6 +208,21 @@ func (s *Service) Permissions() *Permissions {
 	return s.cfg.Permissions
 }
 
+// SetAttribution sets the attribution settings.
+func (s *Service) SetAttribution(a *Attribution) {
+	s.cfg.Options.Attribution = a
+}
+
+// SetSkillsPaths sets the skills paths.
+func (s *Service) SetSkillsPaths(paths []string) {
+	s.cfg.Options.SkillsPaths = paths
+}
+
+// SetLSP sets the LSP configurations.
+func (s *Service) SetLSP(lsp LSPs) {
+	s.cfg.LSP = lsp
+}
+
 // SetPermissions sets the permissions configuration.
 func (s *Service) SetPermissions(p *Permissions) {
 	s.cfg.Permissions = p

internal/ui/chat/messages.go 🔗

@@ -186,12 +186,12 @@ type AssistantInfoItem struct {
 	id                  string
 	message             *message.Message
 	sty                 *styles.Styles
-	cfg                 *config.Config
+	cfg                 *config.Service
 	lastUserMessageTime time.Time
 }
 
 // NewAssistantInfoItem creates a new AssistantInfoItem.
-func NewAssistantInfoItem(sty *styles.Styles, message *message.Message, cfg *config.Config, lastUserMessageTime time.Time) MessageItem {
+func NewAssistantInfoItem(sty *styles.Styles, message *message.Message, cfg *config.Service, lastUserMessageTime time.Time) MessageItem {
 	return &AssistantInfoItem{
 		cachedMessageItem:   &cachedMessageItem{},
 		id:                  AssistantInfoID(message.ID),
@@ -239,7 +239,7 @@ func (a *AssistantInfoItem) renderContent(width int) string {
 	}
 	modelFormatted := a.sty.Chat.Message.AssistantInfoModel.Render(model.Name)
 	providerName := a.message.Provider
-	if providerConfig, ok := a.cfg.Providers[a.message.Provider]; ok {
+	if providerConfig, ok := a.cfg.Provider(a.message.Provider); ok {
 		providerName = providerConfig.Name
 	}
 	provider := a.sty.Chat.Message.AssistantInfoProvider.Render(fmt.Sprintf("via %s", providerName))

internal/ui/dialog/models.go 🔗

@@ -336,7 +336,6 @@ func (m *Models) FullHelp() [][]key.Binding {
 func (m *Models) setProviderItems() error {
 	t := m.com.Styles
 	svc := m.com.ConfigService()
-	cfg := svc.Config()
 
 	var selectedItemID string
 	selectedType := m.modelType.Config()
@@ -347,7 +346,7 @@ func (m *Models) setProviderItems() error {
 	addedProviders := make(map[string]bool)
 
 	// Get a list of known providers to compare against
-	knownProviders, err := config.Providers(cfg)
+	knownProviders, err := config.Providers(svc)
 	if err != nil {
 		return fmt.Errorf("failed to get providers: %w", err)
 	}
@@ -361,7 +360,7 @@ func (m *Models) setProviderItems() error {
 	// itemsMap contains the keys of added model items.
 	itemsMap := make(map[string]*ModelItem)
 	groups := []ModelGroup{}
-	for id, p := range cfg.Providers {
+	for id, p := range svc.AllProviders() {
 		if p.Disable {
 			continue
 		}
@@ -411,7 +410,7 @@ func (m *Models) setProviderItems() error {
 			continue
 		}
 
-		providerConfig, providerConfigured := cfg.Providers[providerID]
+		providerConfig, providerConfigured := svc.Provider(providerID)
 		if providerConfigured && providerConfig.Disable {
 			continue
 		}
@@ -507,8 +506,7 @@ func (m *Models) setProviderItems() error {
 }
 
 func getFilteredProviders(svc *config.Service) ([]catwalk.Provider, error) {
-	cfg := svc.Config()
-	providers, err := config.Providers(cfg)
+	providers, err := config.Providers(svc)
 	if err != nil {
 		return nil, fmt.Errorf("failed to get providers: %w", err)
 	}
@@ -519,7 +517,7 @@ func getFilteredProviders(svc *config.Service) ([]catwalk.Provider, error) {
 			isCopilot       = p.ID == catwalk.InferenceProviderCopilot
 			isHyper         = string(p.ID) == "hyper"
 			hasAPIKeyEnv    = strings.HasPrefix(p.APIKey, "$")
-			_, isConfigured = cfg.Providers[string(p.ID)]
+			_, isConfigured = svc.Provider(string(p.ID))
 		)
 		if isAzure || isCopilot || isHyper || hasAPIKeyEnv || isConfigured {
 			filteredProviders = append(filteredProviders, p)

internal/ui/model/onboarding.go 🔗

@@ -77,10 +77,10 @@ func (m *UI) skipInitializeProject() tea.Cmd {
 
 // initializeView renders the project initialization prompt with Yes/No buttons.
 func (m *UI) initializeView() string {
-	cfg := m.com.ConfigService().Config()
+	cfg := m.com.ConfigService()
 	s := m.com.Styles.Initialize
 	cwd := home.Short(m.com.ConfigService().WorkingDir())
-	initFile := cfg.Options.InitializeAs
+	initFile := cfg.InitializeAs()
 
 	header := s.Header.Render("Would you like to initialize this project?")
 	path := s.Accent.PaddingLeft(2).Render(cwd)

internal/ui/model/sidebar.go 🔗

@@ -21,7 +21,7 @@ func (m *UI) modelInfo(width int) string {
 
 	if model != nil {
 		// Get provider name first
-		providerConfig, ok := m.com.ConfigService().Config().Providers[model.ModelCfg.Provider]
+		providerConfig, ok := m.com.ConfigService().Provider(model.ModelCfg.Provider)
 		if ok {
 			providerName = providerConfig.Name
 

internal/ui/model/ui.go 🔗

@@ -776,7 +776,7 @@ func (m *UI) setSessionMessages(msgs []message.Message) tea.Cmd {
 		case message.Assistant:
 			items = append(items, chat.ExtractMessageItems(m.com.Styles, msg, toolResultMap)...)
 			if msg.FinishPart() != nil && msg.FinishPart().Reason == message.FinishReasonEndTurn {
-				infoItem := chat.NewAssistantInfoItem(m.com.Styles, msg, m.com.ConfigService().Config(), time.Unix(m.lastUserMessageTime, 0))
+				infoItem := chat.NewAssistantInfoItem(m.com.Styles, msg, m.com.ConfigService(), time.Unix(m.lastUserMessageTime, 0))
 				items = append(items, infoItem)
 			}
 		default:
@@ -906,7 +906,7 @@ func (m *UI) appendSessionMessage(msg message.Message) tea.Cmd {
 			}
 		}
 		if msg.FinishPart() != nil && msg.FinishPart().Reason == message.FinishReasonEndTurn {
-			infoItem := chat.NewAssistantInfoItem(m.com.Styles, &msg, m.com.ConfigService().Config(), time.Unix(m.lastUserMessageTime, 0))
+			infoItem := chat.NewAssistantInfoItem(m.com.Styles, &msg, m.com.ConfigService(), time.Unix(m.lastUserMessageTime, 0))
 			m.chat.AppendMessages(infoItem)
 			if atBottom {
 				if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
@@ -977,7 +977,7 @@ func (m *UI) updateSessionMessage(msg message.Message) tea.Cmd {
 
 	if shouldRenderAssistant && msg.FinishPart() != nil && msg.FinishPart().Reason == message.FinishReasonEndTurn {
 		if infoItem := m.chat.MessageItem(chat.AssistantInfoID(msg.ID)); infoItem == nil {
-			newInfoItem := chat.NewAssistantInfoItem(m.com.Styles, &msg, m.com.ConfigService().Config(), time.Unix(m.lastUserMessageTime, 0))
+			newInfoItem := chat.NewAssistantInfoItem(m.com.Styles, &msg, m.com.ConfigService(), time.Unix(m.lastUserMessageTime, 0))
 			m.chat.AppendMessages(newInfoItem)
 		}
 	}
@@ -1196,17 +1196,17 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
 		m.dialog.CloseDialog(dialog.CommandsID)
 	case dialog.ActionToggleThinking:
 		cmds = append(cmds, func() tea.Msg {
-			cfg := m.com.ConfigService().Config()
+			cfg := m.com.ConfigService()
 			if cfg == nil {
 				return util.ReportError(errors.New("configuration not found"))()
 			}
 
-			agentCfg, ok := cfg.Agents[config.AgentCoder]
+			agentCfg, ok := m.com.ConfigService().Agent(config.AgentCoder)
 			if !ok {
 				return util.ReportError(errors.New("agent configuration not found"))()
 			}
 
-			currentModel := cfg.Models[agentCfg.Model]
+			currentModel, _ := cfg.SelectedModel(agentCfg.Model)
 			currentModel.Think = !currentModel.Think
 			if err := m.com.ConfigService().UpdatePreferredModel(agentCfg.Model, currentModel); err != nil {
 				return util.ReportError(err)()
@@ -1235,7 +1235,7 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
 			break
 		}
 
-		cfg := m.com.ConfigService().Config()
+		cfg := m.com.ConfigService()
 		if cfg == nil {
 			cmds = append(cmds, util.ReportError(errors.New("configuration not found")))
 			break
@@ -1244,7 +1244,7 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
 		var (
 			providerID   = msg.Model.Provider
 			isCopilot    = providerID == string(catwalk.InferenceProviderCopilot)
-			isConfigured = func() bool { _, ok := cfg.Providers[providerID]; return ok }
+			isConfigured = func() bool { _, ok := cfg.Provider(providerID); return ok }
 		)
 
 		// Attempt to import GitHub Copilot tokens from VSCode if available.
@@ -1262,7 +1262,7 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
 
 		if err := m.com.ConfigService().UpdatePreferredModel(msg.ModelType, msg.Model); err != nil {
 			cmds = append(cmds, util.ReportError(err))
-		} else if _, ok := cfg.Models[config.SelectedModelTypeSmall]; !ok {
+		} else if _, ok := cfg.SelectedModel(config.SelectedModelTypeSmall); !ok {
 			// Ensure small model is set is unset.
 			smallModel := m.com.App.GetDefaultSmallModel(providerID)
 			if err := m.com.ConfigService().UpdatePreferredModel(config.SelectedModelTypeSmall, smallModel); err != nil {
@@ -1297,19 +1297,19 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
 			break
 		}
 
-		cfg := m.com.ConfigService().Config()
+		cfg := m.com.ConfigService()
 		if cfg == nil {
 			cmds = append(cmds, util.ReportError(errors.New("configuration not found")))
 			break
 		}
 
-		agentCfg, ok := cfg.Agents[config.AgentCoder]
+		agentCfg, ok := m.com.ConfigService().Agent(config.AgentCoder)
 		if !ok {
 			cmds = append(cmds, util.ReportError(errors.New("agent configuration not found")))
 			break
 		}
 
-		currentModel := cfg.Models[agentCfg.Model]
+		currentModel, _ := cfg.SelectedModel(agentCfg.Model)
 		currentModel.ReasoningEffort = msg.Effort
 		if err := m.com.ConfigService().UpdatePreferredModel(agentCfg.Model, currentModel); err != nil {
 			cmds = append(cmds, util.ReportError(err))