返回 DeepSeek-Reasonix
settings_app.go
根目录 / desktop / settings_app.go
1 package main
2
3 import (
4 "context"
5 "crypto/hmac"
6 "crypto/rand"
7 "crypto/sha256"
8 "errors"
9 "fmt"
10 "log/slog"
11 "math"
12 "net/url"
13 "os"
14 "path/filepath"
15 "runtime"
16 "sort"
17 "strings"
18 "sync"
19 "time"
20
21 "golang.org/x/sync/errgroup"
22
23 "reasonix/internal/agent"
24 "reasonix/internal/boot"
25 "reasonix/internal/botruntime"
26 "reasonix/internal/config"
27 "reasonix/internal/control"
28 "reasonix/internal/provider"
29 "reasonix/internal/sandbox"
30 )
31
32 // settings_app.go is the desktop Settings panel's command surface: it reads the
33 // resolved config and applies edits through internal/config/edit.go (the
34 // purpose-built mutation API), then rebuilds the controller so the change takes
35 // effect live — the same snapshot→reload→resume pattern as SetModel. Secrets are
36 // the exception: they go to Reasonix's global .env (upsertDotEnv), since config
37 // stores only the env-var name, not the key.
38
39 // --- read ---
40
41 type ProviderView struct {
42 Name string `json:"name"`
43 BuiltIn bool `json:"builtIn"`
44 Added bool `json:"added"`
45 Kind string `json:"kind"`
46 BaseURL string `json:"baseUrl"`
47 ChatURL string `json:"chatUrl"`
48 Models []string `json:"models"`
49 VisionModels []string `json:"visionModels"`
50 VisionModelsSet bool `json:"visionModelsConfigured"`
51 ModelsURL string `json:"modelsUrl"`
52 Default string `json:"default"`
53 APIKeyEnv string `json:"apiKeyEnv"`
54 Headers map[string]string `json:"headers"`
55 ExtraBody map[string]any `json:"extraBody"`
56 AuthHeader bool `json:"authHeader"`
57 KeySet bool `json:"keySet"` // the env var currently resolves to a non-empty value
58 RequiresKey bool `json:"requiresKey"`
59 Configured bool `json:"configured"` // selectable: either key is present or no key is required
60 KeySource string `json:"keySource,omitempty"`
61 KeySourcePath string `json:"keySourcePath,omitempty"`
62 BalanceURL string `json:"balanceUrl"`
63 ContextWindow int `json:"contextWindow"`
64 ReasoningProtocol string `json:"reasoningProtocol"`
65 Thinking string `json:"thinking"`
66 WebSearch bool `json:"webSearch"`
67 SupportedEfforts []string `json:"supportedEfforts"`
68 DefaultEffort string `json:"defaultEffort"`
69 ModelOverrides []ProviderModelOverrideView `json:"modelOverrides"`
70 // ModelCatalogFingerprint is an opaque digest of the provider identity and
71 // current model selection. Background discovery must compare it while holding
72 // the config edit lock before applying a narrow catalog-only update.
73 ModelCatalogFingerprint string `json:"modelCatalogFingerprint"`
74 }
75
76 type ProviderModelCatalogUpdate struct {
77 Name string `json:"name"`
78 ExpectedFingerprint string `json:"expectedFingerprint"`
79 Models []string `json:"models"`
80 Default string `json:"default"`
81 VisionModels []string `json:"visionModels"`
82 }
83
84 type ProviderPresetView struct {
85 ID string `json:"id"`
86 Label string `json:"label"`
87 Description string `json:"description"`
88 KeyEnv string `json:"keyEnv"`
89 ProviderNames []string `json:"providerNames"`
90 Models []string `json:"models"`
91 Added bool `json:"added"`
92 Status string `json:"status"`
93 StatusProviderNames []string `json:"statusProviderNames"`
94 KeySet bool `json:"keySet"`
95 RequiresKey bool `json:"requiresKey"`
96 Configured bool `json:"configured"`
97 KeySource string `json:"keySource,omitempty"`
98 KeySourcePath string `json:"keySourcePath,omitempty"`
99 }
100
101 const (
102 providerPresetStatusAvailable = "available"
103 providerPresetStatusInstalled = "installed"
104 providerPresetStatusInstalledModified = "installed_modified"
105 providerPresetStatusNameConflict = "name_conflict"
106 providerPresetStatusSimilarExisting = "similar_existing"
107 )
108
109 type ProviderModelOverrideView struct {
110 Model string `json:"model"`
111 ReasoningProtocol string `json:"reasoningProtocol"`
112 Thinking string `json:"thinking"`
113 SupportedEfforts []string `json:"supportedEfforts"`
114 DefaultEffort string `json:"defaultEffort"`
115 Vision *bool `json:"vision"`
116 ContextWindow int `json:"contextWindow,omitempty"`
117 }
118
119 type PermissionsView struct {
120 Mode string `json:"mode"`
121 Allow []string `json:"allow"`
122 Ask []string `json:"ask"`
123 Deny []string `json:"deny"`
124 }
125
126 type SandboxView struct {
127 Bash string `json:"bash"`
128 Network bool `json:"network"`
129 WorkspaceRoot string `json:"workspaceRoot"`
130 AllowWrite []string `json:"allowWrite"`
131 EffectiveWorkspaceRoot string `json:"effectiveWorkspaceRoot"`
132 EffectiveWriteRoots []string `json:"effectiveWriteRoots"`
133 Shell string `json:"shell"` // [tools.shell] prefer: auto|bash|powershell|pwsh
134 EffectiveShell string `json:"effectiveShell,omitempty"`
135 }
136
137 type NetworkProxyView struct {
138 Type string `json:"type"`
139 Server string `json:"server"`
140 Port int `json:"port"`
141 Username string `json:"username"`
142 Password string `json:"password"`
143 }
144
145 type NetworkView struct {
146 ProxyMode string `json:"proxyMode"`
147 ProxyURL string `json:"proxyUrl"`
148 NoProxy string `json:"noProxy"`
149 Proxy NetworkProxyView `json:"proxy"`
150 }
151
152 type AgentView struct {
153 Temperature float64 `json:"temperature"`
154 MaxSteps int `json:"maxSteps"`
155 PlannerMaxSteps int `json:"plannerMaxSteps"`
156 MaxSubagentDepth int `json:"maxSubagentDepth"`
157 MaxSubagentConcurrency int `json:"maxSubagentConcurrency"`
158 MaxParallelWriters int `json:"maxParallelWriters"`
159 SystemPrompt string `json:"systemPrompt"`
160 ColdResumePrune bool `json:"coldResumePrune"`
161 ReasoningLanguage string `json:"reasoningLanguage"`
162 CompactRatio float64 `json:"compactRatio,omitempty"`
163 EffectiveCompactRatio float64 `json:"effectiveCompactRatio,omitempty"`
164 CompactRatioOverridden bool `json:"compactRatioOverridden,omitempty"`
165 }
166
167 type BotAllowlistView struct {
168 Enabled bool `json:"enabled"`
169 AllowAll bool `json:"allowAll"`
170 QQUsers []string `json:"qqUsers"`
171 FeishuUsers []string `json:"feishuUsers"`
172 WeixinUsers []string `json:"weixinUsers"`
173 QQApprovers []string `json:"qqApprovers"`
174 FeishuApprovers []string `json:"feishuApprovers"`
175 WeixinApprovers []string `json:"weixinApprovers"`
176 QQAdmins []string `json:"qqAdmins"`
177 FeishuAdmins []string `json:"feishuAdmins"`
178 WeixinAdmins []string `json:"weixinAdmins"`
179 QQGroups []string `json:"qqGroups"`
180 FeishuGroups []string `json:"feishuGroups"`
181 WeixinGroups []string `json:"weixinGroups"`
182 }
183
184 type BotAccessView struct {
185 Enabled bool `json:"enabled"`
186 AllowAll bool `json:"allowAll"`
187 PairingEnabled bool `json:"pairingEnabled"`
188 Users []string `json:"users"`
189 Groups []string `json:"groups"`
190 Approvers []string `json:"approvers"`
191 Admins []string `json:"admins"`
192 }
193
194 type BotSelfUserIDsView struct {
195 QQ []string `json:"qq"`
196 Feishu []string `json:"feishu"`
197 Weixin []string `json:"weixin"`
198 }
199
200 type BotPairingView struct {
201 Enabled bool `json:"enabled"`
202 RequestTTLMinutes int `json:"requestTtlMinutes"`
203 MaxPendingPerPlatform int `json:"maxPendingPerPlatform"`
204 }
205
206 type BotControlView struct {
207 Enabled bool `json:"enabled"`
208 Addr string `json:"addr"`
209 TokenEnv string `json:"tokenEnv"`
210 }
211
212 type BotRouteView struct {
213 ConnectionID string `json:"connectionId"`
214 Platform string `json:"platform"`
215 ChatType string `json:"chatType"`
216 ChatID string `json:"chatId"`
217 UserID string `json:"userId"`
218 ThreadID string `json:"threadId"`
219 Model string `json:"model"`
220 ToolApprovalMode string `json:"toolApprovalMode"`
221 WorkspaceRoot string `json:"workspaceRoot"`
222 }
223
224 type QQBotView struct {
225 Enabled bool `json:"enabled"`
226 AppID string `json:"appId"`
227 AppSecretEnv string `json:"appSecretEnv"`
228 SecretSet bool `json:"secretSet"`
229 Sandbox bool `json:"sandbox"`
230 Model string `json:"model"`
231 ToolApprovalMode string `json:"toolApprovalMode"`
232 WorkspaceRoot string `json:"workspaceRoot"`
233 Access BotAccessView `json:"access"`
234 }
235
236 type FeishuBotView struct {
237 Enabled bool `json:"enabled"`
238 Domain string `json:"domain"`
239 AppID string `json:"appId"`
240 AppSecretEnv string `json:"appSecretEnv"`
241 SecretSet bool `json:"secretSet"`
242 VerificationToken string `json:"verificationToken"`
243 Mode string `json:"mode"`
244 WebhookPort int `json:"webhookPort"`
245 RequireMention bool `json:"requireMention"`
246 }
247
248 type WeixinBotView struct {
249 Enabled bool `json:"enabled"`
250 AccountID string `json:"accountId"`
251 TokenEnv string `json:"tokenEnv"`
252 TokenSet bool `json:"tokenSet"`
253 APIBase string `json:"apiBase"`
254 }
255
256 type BotSettingsView struct {
257 Enabled bool `json:"enabled"`
258 Model string `json:"model"`
259 ToolApprovalMode string `json:"toolApprovalMode"`
260 MaxSteps int `json:"maxSteps"`
261 DebounceMs int `json:"debounceMs"`
262 QueueMode string `json:"queueMode"`
263 QueueCap int `json:"queueCap"`
264 QueueDrop string `json:"queueDrop"`
265 IgnoreSelfMessages bool `json:"ignoreSelfMessages"`
266 SelfUserIDs BotSelfUserIDsView `json:"selfUserIds"`
267 Control BotControlView `json:"control"`
268 Pairing BotPairingView `json:"pairing"`
269 Routes []BotRouteView `json:"routes"`
270 Allowlist BotAllowlistView `json:"allowlist"`
271 QQ QQBotView `json:"qq"`
272 Feishu FeishuBotView `json:"feishu"`
273 Weixin WeixinBotView `json:"weixin"`
274 Connections []BotConnectionView `json:"connections"`
275 }
276
277 // SettingsView is the whole Settings panel payload.
278 type SettingsView struct {
279 DefaultModel string `json:"defaultModel"`
280 PlannerModel string `json:"plannerModel"`
281 SubagentModel string `json:"subagentModel"`
282 SubagentEffort string `json:"subagentEffort"`
283 AutoPlan string `json:"autoPlan"`
284 Providers []ProviderView `json:"providers"`
285 OfficialProviders []ProviderView `json:"officialProviders"`
286 ProviderPresets []ProviderPresetView `json:"providerPresets"`
287 Permissions PermissionsView `json:"permissions"`
288 Sandbox SandboxView `json:"sandbox"`
289 Network NetworkView `json:"network"`
290 Agent AgentView `json:"agent"`
291 Bot BotSettingsView `json:"bot"`
292 DesktopLanguage string `json:"desktopLanguage"`
293 DesktopCurrency string `json:"desktopCurrency"`
294 DesktopLayoutStyle string `json:"desktopLayoutStyle"`
295 DesktopTheme string `json:"desktopTheme"`
296 DesktopThemeStyle string `json:"desktopThemeStyle"`
297 DesktopTerminalTheme string `json:"desktopTerminalTheme,omitempty"`
298 CloseBehavior string `json:"closeBehavior"`
299 DisplayMode string `json:"displayMode"`
300 StatusBarStyle string `json:"statusBarStyle"`
301 StatusBarItems []string `json:"statusBarItems"`
302 DefaultToolApprovalMode string `json:"defaultToolApprovalMode"`
303
304 CheckUpdates bool `json:"checkUpdates"`
305 UpdateChannel string `json:"updateChannel"`
306 Telemetry bool `json:"telemetry"`
307 Metrics bool `json:"metrics"`
308 ExpandThinking bool `json:"expandThinking"`
309 ConversationWidth string `json:"conversationWidth,omitempty"`
310 ConfigPath string `json:"configPath"`
311 // ShadowedByPath is the workspace reasonix.toml that outranks the file this
312 // panel writes, so an edit here can be overridden with nothing on screen to
313 // explain it (#4333). Empty when the panel's file is the one in effect.
314 ShadowedByPath string `json:"shadowedByPath,omitempty"`
315 // ProviderKinds lists the provider implementations the kernel actually
316 // registered (provider.Kinds()), so the editor's "kind" picker offers only
317 // kinds that resolve — selecting an unregistered one would fail the rebuild.
318 ProviderKinds []string `json:"providerKinds"`
319 // AutoApproveTools is the live YOLO/full-access state (runtime-only, not from
320 // config), so the panel's toggle reflects whether tool approvals are currently
321 // being skipped this session.
322 AutoApproveTools bool `json:"autoApproveTools"`
323 // Bypass is the legacy JSON key for the same live state.
324 Bypass bool `json:"bypass"`
325 }
326
327 // DesktopStartupSettingsView is the lightweight Settings subset needed during
328 // frontend startup. It deliberately excludes providers and credential state so
329 // slow keychain/env resolution stays off the first-render path.
330 type DesktopStartupSettingsView struct {
331 Bot BotSettingsView `json:"bot"`
332 DesktopLanguage string `json:"desktopLanguage"`
333 DesktopLayoutStyle string `json:"desktopLayoutStyle"`
334 DesktopTheme string `json:"desktopTheme"`
335 DesktopThemeStyle string `json:"desktopThemeStyle"`
336 DesktopTerminalTheme string `json:"desktopTerminalTheme,omitempty"`
337 DisplayMode string `json:"displayMode"`
338 StatusBarStyle string `json:"statusBarStyle"`
339 StatusBarItems []string `json:"statusBarItems"`
340 CheckUpdates bool `json:"checkUpdates"`
341 UpdateChannel string `json:"updateChannel"`
342 ConversationWidth string `json:"conversationWidth,omitempty"`
343 // ConfigWarnings are non-blocking notices when user/project config was
344 // recovered in memory (last-known-good or defaults) without rewriting files.
345 ConfigWarnings []string `json:"configWarnings,omitempty"`
346 ConfigPath string `json:"configPath,omitempty"`
347 }
348
349 // shadowingConfigPath returns the config file that outranks writePath for the
350 // workspace at root, or "" when writePath is the one in effect. A project
351 // reasonix.toml beats the user config, so settings written here would otherwise
352 // look ignored (#4333).
353 func shadowingConfigPath(writePath, root string) string {
354 effective := config.SourcePathForRoot(root)
355 if effective == "" || samePath(effective, writePath) {
356 return ""
357 }
358 if abs, err := filepath.Abs(effective); err == nil {
359 return abs
360 }
361 return effective
362 }
363
364 func samePath(a, b string) bool {
365 absA, errA := filepath.Abs(a)
366 absB, errB := filepath.Abs(b)
367 if errA != nil || errB != nil {
368 return a == b
369 }
370 if runtime.GOOS == "windows" {
371 return strings.EqualFold(filepath.Clean(absA), filepath.Clean(absB))
372 }
373 return filepath.Clean(absA) == filepath.Clean(absB)
374 }
375
376 func nonNil(s []string) []string {
377 if s == nil {
378 return []string{}
379 }
380 return s
381 }
382
383 func nonNilStringMap(m map[string]string) map[string]string {
384 if m == nil {
385 return map[string]string{}
386 }
387 return m
388 }
389
390 func nonNilAnyMap(m map[string]any) map[string]any {
391 if m == nil {
392 return map[string]any{}
393 }
394 return m
395 }
396
397 func providerCredentialsRevision() string {
398 return config.CredentialStoreRevision()
399 }
400
401 var providerModelCatalogFingerprintKey = func() []byte {
402 key := make([]byte, 32)
403 if _, err := rand.Read(key); err != nil {
404 panic(fmt.Sprintf("initialize provider catalog fingerprint key: %v", err))
405 }
406 return key
407 }()
408
409 func providerModelCatalogFingerprint(p config.ProviderEntry) string {
410 return providerModelCatalogFingerprintForCredentials(p, providerCredentialsRevision())
411 }
412
413 func providerModelCatalogFingerprintForCredentials(p config.ProviderEntry, credentialsRevision string) string {
414 // This token crosses the Wails boundary, so key the digest instead of exposing
415 // a reusable hash of header or credential-store metadata to the frontend.
416 h := hmac.New(sha256.New, providerModelCatalogFingerprintKey)
417 write := func(value string) {
418 _, _ = fmt.Fprintf(h, "%d:", len(value))
419 _, _ = h.Write([]byte(value))
420 }
421 write("provider-model-catalog-v1")
422 write("name")
423 write(p.Name)
424 write("kind")
425 write(p.Kind)
426 write("base_url")
427 write(p.BaseURL)
428 write("models_url")
429 write(p.ModelsURL)
430 write("api_key_env")
431 write(p.APIKeyEnv)
432 write("credentials_revision")
433 write(credentialsRevision)
434 write("auth_header")
435 write(fmt.Sprintf("%t", p.AuthHeader))
436 keys := make([]string, 0, len(p.Headers))
437 for key := range p.Headers {
438 keys = append(keys, key)
439 }
440 sort.Strings(keys)
441 write("headers")
442 write(fmt.Sprintf("%d", len(keys)))
443 for _, key := range keys {
444 write(key)
445 write(p.Headers[key])
446 }
447 write("model")
448 write(p.Model)
449 write("models")
450 write(fmt.Sprintf("%d", len(p.Models)))
451 for _, model := range p.Models {
452 write(model)
453 }
454 write("default")
455 write(p.Default)
456 write("vision")
457 write(fmt.Sprintf("%t", p.Vision))
458 write("vision_models")
459 write(fmt.Sprintf("%d", len(p.VisionModels)))
460 for _, model := range p.VisionModels {
461 write(model)
462 }
463 return fmt.Sprintf("%x", h.Sum(nil))
464 }
465
466 func providerModelOverridesForView(overrides map[string]config.ProviderModelOverride, models []string) []ProviderModelOverrideView {
467 if len(overrides) == 0 {
468 return []ProviderModelOverrideView{}
469 }
470 modelSet := map[string]bool{}
471 for _, model := range models {
472 modelSet[model] = true
473 }
474 keys := make([]string, 0, len(overrides))
475 for model := range overrides {
476 model = strings.TrimSpace(model)
477 if model == "" {
478 continue
479 }
480 if len(modelSet) > 0 && !modelSet[model] {
481 continue
482 }
483 keys = append(keys, model)
484 }
485 sort.Strings(keys)
486 out := make([]ProviderModelOverrideView, 0, len(keys))
487 for _, model := range keys {
488 ov := overrides[model]
489 out = append(out, ProviderModelOverrideView{
490 Model: model,
491 ReasoningProtocol: ov.ReasoningProtocol,
492 SupportedEfforts: nonNil(ov.SupportedEfforts),
493 DefaultEffort: ov.DefaultEffort,
494 Vision: ov.Vision,
495 ContextWindow: ov.ContextWindow,
496 })
497 }
498 return out
499 }
500
501 func providerModelOverridesForSave(overrides []ProviderModelOverrideView, models []string) map[string]config.ProviderModelOverride {
502 if len(overrides) == 0 {
503 return nil
504 }
505 modelSet := map[string]bool{}
506 for _, model := range models {
507 modelSet[model] = true
508 }
509 out := map[string]config.ProviderModelOverride{}
510 for _, item := range overrides {
511 model := strings.TrimSpace(item.Model)
512 if model == "" || (len(modelSet) > 0 && !modelSet[model]) {
513 continue
514 }
515 ov := config.ProviderModelOverride{
516 ReasoningProtocol: strings.TrimSpace(item.ReasoningProtocol),
517 SupportedEfforts: nonNil(item.SupportedEfforts),
518 DefaultEffort: strings.TrimSpace(item.DefaultEffort),
519 Vision: item.Vision,
520 ContextWindow: max(item.ContextWindow, 0),
521 }
522 if strings.TrimSpace(ov.ReasoningProtocol) == "" && len(ov.SupportedEfforts) == 0 && strings.TrimSpace(ov.DefaultEffort) == "" && ov.Vision == nil && ov.ContextWindow == 0 {
523 continue
524 }
525 out[model] = ov
526 }
527 if len(out) == 0 {
528 return nil
529 }
530 return out
531 }
532
533 func providerRemovalFallbackRef(c *config.Config, name string) string {
534 for i := range c.Providers {
535 p := &c.Providers[i]
536 if p.Name == name || !p.Configured() || len(p.ModelList()) == 0 {
537 continue
538 }
539 return p.Name + "/" + p.DefaultModel()
540 }
541 return ""
542 }
543
544 func desktopModelRefsProvider(c *config.Config, ref, name string) bool {
545 if config.ModelRefsProvider(ref, name) {
546 return true
547 }
548 if e, ok := c.ResolveModel(ref); ok {
549 return e.Name == name
550 }
551 return false
552 }
553
554 func officialProviderHost(baseURL string) string {
555 u, err := url.Parse(strings.TrimSpace(baseURL))
556 if err != nil {
557 return ""
558 }
559 return strings.ToLower(u.Hostname())
560 }
561
562 func officialProviderKindFromEntry(p config.ProviderEntry) string {
563 host := officialProviderHost(p.BaseURL)
564 switch config.CanonicalDesktopOfficialProviderName(p.Name) {
565 case "deepseek":
566 if host == "api.deepseek.com" {
567 return "deepseek"
568 }
569 }
570 return ""
571 }
572
573 func isOfficialBuiltInProvider(p config.ProviderEntry) bool {
574 return officialProviderKindFromEntry(p) != ""
575 }
576
577 func providerAccessSet(names []string) map[string]bool {
578 out := map[string]bool{}
579 for _, name := range names {
580 name = strings.TrimSpace(name)
581 if name != "" {
582 out[name] = true
583 }
584 }
585 return out
586 }
587
588 func addProviderAccess(c *config.Config, names ...string) {
589 seen := providerAccessSet(c.Desktop.ProviderAccess)
590 for _, name := range names {
591 name = strings.TrimSpace(name)
592 if name == "" || seen[name] {
593 continue
594 }
595 c.Desktop.ProviderAccess = append(c.Desktop.ProviderAccess, name)
596 seen[name] = true
597 }
598 }
599
600 func removeProviderAccess(c *config.Config, names ...string) {
601 remove := providerAccessSet(names)
602 if len(remove) == 0 {
603 return
604 }
605 out := c.Desktop.ProviderAccess[:0]
606 for _, name := range c.Desktop.ProviderAccess {
607 if !remove[name] {
608 out = append(out, name)
609 }
610 }
611 c.Desktop.ProviderAccess = out
612 }
613
614 func providerViewFromEntry(p config.ProviderEntry, builtIn, added bool) ProviderView {
615 return providerViewFromEntryForRoot(p, builtIn, added, ".")
616 }
617
618 func providerViewFromEntryForRoot(p config.ProviderEntry, builtIn, added bool, root string) ProviderView {
619 return providerViewFromEntryForRootWithResolver(p, builtIn, added, root, nil)
620 }
621
622 func providerViewFromEntryForRootWithResolver(p config.ProviderEntry, builtIn, added bool, root string, resolver *config.CredentialResolver) ProviderView {
623 return providerViewFromEntryForRootWithResolverAndCredentials(p, builtIn, added, root, resolver, providerCredentialsRevision())
624 }
625
626 func providerViewFromEntryForRootWithResolverAndCredentials(p config.ProviderEntry, builtIn, added bool, root string, resolver *config.CredentialResolver, credentialsRevision string) ProviderView {
627 models := p.ChatModelList()
628 visionModels := p.VisionModels
629 visionModelsSet := p.Vision || p.VisionModels != nil
630 if p.Vision {
631 visionModels = models
632 }
633 if resolver == nil {
634 resolver = config.NewCredentialResolverForRoot(root)
635 }
636 key := resolver.ResolveGlobalFirst(p.APIKeyEnv)
637 requiresKey := p.RequiresAPIKey()
638 return ProviderView{
639 Name: p.Name, BuiltIn: builtIn, Added: added, Kind: p.Kind, BaseURL: p.BaseURL, ChatURL: p.ChatURL,
640 Models: nonNil(models), VisionModels: nonNil(providerVisionModels(models, visionModels)), VisionModelsSet: visionModelsSet, ModelsURL: p.ModelsURL, Default: p.DefaultModel(),
641 APIKeyEnv: p.APIKeyEnv,
642 Headers: nonNilStringMap(p.Headers),
643 ExtraBody: nonNilAnyMap(p.ExtraBody),
644 AuthHeader: p.AuthHeader,
645 KeySet: key.Set,
646 RequiresKey: requiresKey,
647 Configured: !requiresKey || key.Set,
648 KeySource: key.Source.Label,
649 KeySourcePath: key.Source.Path,
650 BalanceURL: p.BalanceURL,
651 ContextWindow: p.ContextWindow,
652 ReasoningProtocol: p.ReasoningProtocol,
653 Thinking: providerThinkingForSettings(p.Thinking),
654 WebSearch: config.EffectiveWebSearch(&p),
655 SupportedEfforts: nonNil(p.SupportedEfforts),
656 DefaultEffort: p.DefaultEffort,
657 ModelOverrides: providerModelOverridesForView(p.ModelOverrides, models),
658 ModelCatalogFingerprint: providerModelCatalogFingerprintForCredentials(p, credentialsRevision),
659 }
660 }
661
662 func providerThinkingForSettings(thinking string) string {
663 normalized := strings.ToLower(strings.TrimSpace(thinking))
664 switch normalized {
665 case "enabled", "disabled", "adaptive":
666 return normalized
667 default:
668 return ""
669 }
670 }
671
672 func officialProviderViews(added map[string]bool, pricingLanguage string) []ProviderView {
673 return officialProviderViewsForRoot(added, pricingLanguage, ".")
674 }
675
676 func officialProviderViewsForRoot(added map[string]bool, pricingLanguage, root string) []ProviderView {
677 return officialProviderViewsForRootWithResolver(added, pricingLanguage, root, nil)
678 }
679
680 func officialProviderViewsForRootWithResolver(added map[string]bool, pricingLanguage, root string, resolver *config.CredentialResolver) []ProviderView {
681 var out []ProviderView
682 if resolver == nil {
683 resolver = config.NewCredentialResolverForRoot(root)
684 }
685 credentialsRevision := providerCredentialsRevision()
686 for _, kind := range []string{"deepseek"} {
687 entries, _, err := officialProviderTemplate(kind, pricingLanguage)
688 if err != nil {
689 continue
690 }
691 for _, entry := range entries {
692 out = append(out, providerViewFromEntryForRootWithResolverAndCredentials(entry, true, added[entry.Name], root, resolver, credentialsRevision))
693 }
694 }
695 return out
696 }
697
698 func providerPresetViewsForRootWithResolver(cfg *config.Config, root string, resolver *config.CredentialResolver) []ProviderPresetView {
699 if resolver == nil {
700 resolver = config.NewCredentialResolverForRoot(root)
701 }
702 presets := config.CuratedProviderPresets()
703 out := make([]ProviderPresetView, 0, len(presets))
704 for _, preset := range presets {
705 keyEnv := strings.TrimSpace(preset.KeyEnv)
706 names := make([]string, 0, len(preset.Entries))
707 models := make([]string, 0)
708 modelSeen := map[string]bool{}
709 requiresKey := false
710 for _, entry := range preset.Entries {
711 if keyEnv == "" {
712 keyEnv = strings.TrimSpace(entry.APIKeyEnv)
713 }
714 if entry.RequiresAPIKey() {
715 requiresKey = true
716 }
717 name := strings.TrimSpace(entry.Name)
718 if name != "" {
719 names = append(names, name)
720 }
721 for _, model := range chatProviderModels(entry.ChatModelList()) {
722 if modelSeen[model] {
723 continue
724 }
725 modelSeen[model] = true
726 models = append(models, model)
727 }
728 }
729 key := config.CredentialResolution{}
730 if keyEnv != "" {
731 key = resolver.ResolveGlobalFirst(keyEnv)
732 }
733 status, statusNames := classifyProviderPresetStatus(cfg, preset)
734 added := status == providerPresetStatusInstalled || status == providerPresetStatusInstalledModified || status == providerPresetStatusNameConflict
735 out = append(out, ProviderPresetView{
736 ID: preset.ID,
737 Label: preset.Label,
738 Description: preset.Description,
739 KeyEnv: keyEnv,
740 ProviderNames: nonNil(names),
741 Models: nonNil(models),
742 Added: added,
743 Status: status,
744 StatusProviderNames: nonNil(statusNames),
745 KeySet: key.Set,
746 RequiresKey: requiresKey,
747 Configured: !requiresKey || key.Set,
748 KeySource: key.Source.Label,
749 KeySourcePath: key.Source.Path,
750 })
751 }
752 return out
753 }
754
755 func classifyProviderPresetStatus(cfg *config.Config, preset config.ProviderPreset) (string, []string) {
756 if cfg == nil {
757 return providerPresetStatusAvailable, nil
758 }
759 installed := make([]string, 0)
760 modified := make([]string, 0)
761 conflicts := make([]string, 0)
762 similar := make([]string, 0)
763 presetID := strings.TrimSpace(preset.ID)
764 for _, entry := range preset.Entries {
765 name := strings.TrimSpace(entry.Name)
766 if name == "" {
767 continue
768 }
769 existing, ok := cfg.Provider(name)
770 if !ok {
771 continue
772 }
773 if providerEntryMatchesPreset(*existing, entry, presetID) {
774 installed = append(installed, name)
775 } else if providerEntryUsesPresetID(*existing, presetID) {
776 modified = append(modified, name)
777 } else {
778 conflicts = append(conflicts, name)
779 }
780 }
781 if len(conflicts) > 0 {
782 return providerPresetStatusNameConflict, uniqueNonEmptyStrings(conflicts)
783 }
784 if len(modified) > 0 {
785 return providerPresetStatusInstalledModified, uniqueNonEmptyStrings(modified)
786 }
787 if len(installed) > 0 {
788 return providerPresetStatusInstalled, uniqueNonEmptyStrings(installed)
789 }
790 for i := range cfg.Providers {
791 existing := cfg.Providers[i]
792 existingName := strings.TrimSpace(existing.Name)
793 if existingName == "" {
794 continue
795 }
796 for _, entry := range preset.Entries {
797 if existingName == strings.TrimSpace(entry.Name) {
798 continue
799 }
800 if providerEntrySimilarToPreset(existing, entry, presetID) {
801 similar = append(similar, existingName)
802 break
803 }
804 }
805 }
806 if len(similar) > 0 {
807 return providerPresetStatusSimilarExisting, uniqueNonEmptyStrings(similar)
808 }
809 return providerPresetStatusAvailable, nil
810 }
811
812 func providerEntryMatchesPreset(existing, preset config.ProviderEntry, presetID string) bool {
813 if strings.TrimSpace(existing.PresetID) != "" {
814 if providerEntryUsesPresetID(existing, presetID) {
815 return providerEntryCoreMatches(existing, preset)
816 }
817 return false
818 }
819 return providerEntryCoreMatches(existing, preset)
820 }
821
822 func providerEntrySimilarToPreset(existing, preset config.ProviderEntry, presetID string) bool {
823 if providerEntryUsesPresetID(existing, presetID) {
824 return true
825 }
826 return providerEntryCoreMatches(existing, preset)
827 }
828
829 func providerEntryUsesPresetID(existing config.ProviderEntry, presetID string) bool {
830 presetID = strings.TrimSpace(presetID)
831 return presetID != "" && strings.TrimSpace(existing.PresetID) == presetID
832 }
833
834 func providerEntryCoreMatches(existing, preset config.ProviderEntry) bool {
835 return strings.EqualFold(strings.TrimSpace(existing.Kind), strings.TrimSpace(preset.Kind)) &&
836 normalizeProviderURL(existing.BaseURL) == normalizeProviderURL(preset.BaseURL) &&
837 strings.TrimSpace(existing.ChatURL) == strings.TrimSpace(preset.ChatURL) &&
838 strings.TrimSpace(existing.APIKeyEnv) == strings.TrimSpace(preset.APIKeyEnv) &&
839 existing.AuthHeader == preset.AuthHeader
840 }
841
842 func normalizeProviderURL(raw string) string {
843 raw = strings.TrimSpace(raw)
844 if raw == "" {
845 return ""
846 }
847 u, err := url.Parse(raw)
848 if err == nil && u.Scheme != "" && u.Host != "" {
849 u.Scheme = strings.ToLower(u.Scheme)
850 u.Host = strings.ToLower(u.Host)
851 u.Path = strings.TrimRight(u.Path, "/")
852 u.RawPath = ""
853 u.RawQuery = ""
854 u.Fragment = ""
855 return strings.TrimRight(u.String(), "/")
856 }
857 return strings.TrimRight(raw, "/")
858 }
859
860 func uniqueNonEmptyStrings(in []string) []string {
861 if len(in) == 0 {
862 return nil
863 }
864 out := make([]string, 0, len(in))
865 seen := map[string]bool{}
866 for _, s := range in {
867 s = strings.TrimSpace(s)
868 if s == "" || seen[s] {
869 continue
870 }
871 seen[s] = true
872 out = append(out, s)
873 }
874 return out
875 }
876
877 func officialProviderAddedSet(cfg *config.Config) map[string]bool {
878 out := map[string]bool{}
879 if cfg == nil {
880 return out
881 }
882 access := providerAccessSet(cfg.Desktop.ProviderAccess)
883 for i := range cfg.Providers {
884 p := cfg.Providers[i]
885 if !access[p.Name] {
886 continue
887 }
888 if kind := officialProviderKindFromEntry(p); kind != "" {
889 out[kind] = true
890 }
891 }
892 return out
893 }
894
895 func desktopStartupSettingsFromConfig(cfg *config.Config) DesktopStartupSettingsView {
896 if cfg == nil {
897 return DesktopStartupSettingsView{
898 Bot: botSettingsView(config.BotConfig{}),
899 DesktopLayoutStyle: "workbench",
900 DesktopTheme: "auto",
901 DesktopThemeStyle: "graphite",
902 DesktopTerminalTheme: "auto",
903 DisplayMode: "standard",
904 StatusBarStyle: "text",
905 StatusBarItems: config.DefaultDesktopStatusBarItems(),
906 CheckUpdates: true,
907 UpdateChannel: "stable",
908 ConversationWidth: "standard",
909 }
910 }
911 return DesktopStartupSettingsView{
912 Bot: botSettingsView(cfg.Bot),
913 DesktopLanguage: cfg.DesktopLanguage(),
914 DesktopLayoutStyle: cfg.DesktopLayoutStyle(),
915 DesktopTheme: cfg.DesktopTheme(),
916 DesktopThemeStyle: cfg.DesktopThemeStyle(),
917 DesktopTerminalTheme: cfg.DesktopTerminalTheme(),
918 DisplayMode: cfg.DesktopDisplayMode(),
919 StatusBarStyle: cfg.DesktopStatusBarStyle(),
920 StatusBarItems: cfg.DesktopStatusBarItems(),
921 CheckUpdates: cfg.DesktopCheckUpdates(),
922 UpdateChannel: cfg.DesktopUpdateChannel(),
923 ConversationWidth: cfg.DesktopConversationWidth(),
924 ConfigWarnings: cfg.LoadWarnings(),
925 ConfigPath: config.UserConfigPath(),
926 }
927 }
928
929 // DesktopStartupSettings returns only the desktop chrome preferences needed at
930 // app startup. Keep provider/key status in Settings(), where the Settings panel
931 // actually needs it.
932 func (a *App) DesktopStartupSettings() DesktopStartupSettingsView {
933 // Prefer the resilient workspace load so config warnings surface on first paint.
934 if cfg, err := config.LoadForRootReadOnly(a.activeWorkspaceRoot()); err == nil {
935 view := desktopStartupSettingsFromConfig(cfg)
936 view.ConfigWarnings = cfg.LoadWarnings()
937 view.ConfigPath = config.UserConfigPath()
938 return view
939 }
940 cfg, path, err := a.loadDesktopUserConfigForView()
941 if err != nil {
942 view := desktopStartupSettingsFromConfig(nil)
943 view.ConfigWarnings = []string{
944 "user configuration could not be loaded; using built-in defaults. Run: reasonix doctor repair",
945 }
946 view.ConfigPath = config.UserConfigPath()
947 return view
948 }
949 view := desktopStartupSettingsFromConfig(cfg)
950 view.ConfigPath = path
951 return view
952 }
953
954 // OpenUserConfigPath reveals the user config file in the system file manager.
955 func (a *App) OpenUserConfigPath() error {
956 path := config.UserConfigPath()
957 if path == "" {
958 return fmt.Errorf("user config path is unavailable")
959 }
960 // Reveal the parent directory when the file does not exist yet so the user
961 // can still find where config.toml should live.
962 if _, err := os.Stat(path); err != nil {
963 return a.RevealPath(filepath.Dir(path))
964 }
965 return a.RevealPath(path)
966 }
967
968 // ReloadUserConfig reloads configuration for the active workspace after the
969 // user fixes a broken file. Non-fatal load warnings remain visible when present.
970 func (a *App) ReloadUserConfig() (DesktopStartupSettingsView, error) {
971 return a.DesktopStartupSettings(), nil
972 }
973
974 // Settings returns the current configuration for the Settings panel.
975 func (a *App) Settings() SettingsView {
976 cfg, cfgPath, err := a.loadDesktopUserConfigForView()
977 if err != nil {
978 return SettingsView{
979 Providers: []ProviderView{},
980 OfficialProviders: officialProviderViews(map[string]bool{}, ""),
981 ProviderPresets: providerPresetViewsForRootWithResolver(nil, a.activeWorkspaceRoot(), nil),
982 ProviderKinds: nonNil(provider.Kinds()),
983 Permissions: PermissionsView{
984 Mode: "ask",
985 Allow: []string{},
986 Ask: []string{},
987 Deny: []string{},
988 },
989 Sandbox: SandboxView{Bash: config.Default().BashMode(), AllowWrite: []string{}, EffectiveWriteRoots: []string{}, Shell: "auto", EffectiveShell: sandboxEffectiveShellView(sandbox.ResolveShell("", "", nil))},
990 Agent: AgentView{
991 PlannerMaxSteps: 0,
992 MaxSubagentDepth: agent.DefaultMaxSubagentDepth,
993 MaxSubagentConcurrency: agent.DefaultMaxSubagentConcurrency,
994 MaxParallelWriters: agent.DefaultMaxParallelWriters,
995 ColdResumePrune: true,
996 ReasoningLanguage: "auto",
997 CompactRatio: config.Default().Agent.CompactRatio,
998 EffectiveCompactRatio: config.Default().Agent.CompactRatio,
999 },
1000 Bot: botSettingsView(config.BotConfig{}),
1001 AutoPlan: "off",
1002 DesktopLayoutStyle: "workbench",
1003 DesktopTheme: "auto",
1004 DesktopThemeStyle: "graphite",
1005 DesktopTerminalTheme: "auto",
1006 CloseBehavior: "background",
1007 DisplayMode: "standard",
1008 StatusBarStyle: "text",
1009 StatusBarItems: config.DefaultDesktopStatusBarItems(),
1010 DefaultToolApprovalMode: "auto",
1011 CheckUpdates: true,
1012 UpdateChannel: "stable",
1013 Telemetry: true,
1014 Metrics: true,
1015 ExpandThinking: false,
1016 ConversationWidth: "standard",
1017 }
1018 }
1019 ctrl := a.activeCtrl()
1020 bash := cfg.BashMode()
1021 shell := cfg.Tools.Shell.Prefer
1022 if shell == "" {
1023 shell = "auto"
1024 }
1025 root := a.activeWorkspaceRoot()
1026 writeRoots := cfg.WriteRootsForRoot(root)
1027 effectiveWorkspaceRoot := ""
1028 if len(writeRoots) > 0 {
1029 effectiveWorkspaceRoot = writeRoots[0]
1030 }
1031 effectiveShell := sandbox.ResolveShell(cfg.Tools.Shell.Prefer, cfg.Tools.Shell.Path, nil)
1032 v := SettingsView{
1033 DefaultModel: cfg.DefaultModel,
1034 PlannerModel: cfg.Agent.PlannerModel,
1035 SubagentModel: cfg.Agent.SubagentModel,
1036 SubagentEffort: cfg.Agent.SubagentEffort,
1037 AutoPlan: "off", // deprecated JSON compatibility for older frontends
1038 Providers: []ProviderView{},
1039 OfficialProviders: []ProviderView{},
1040 ProviderPresets: []ProviderPresetView{},
1041 Permissions: PermissionsView{
1042 Mode: orDefault(cfg.Permissions.Mode, "ask"),
1043 Allow: nonNil(cfg.Permissions.Allow),
1044 Ask: nonNil(cfg.Permissions.Ask),
1045 Deny: nonNil(cfg.Permissions.Deny),
1046 },
1047 Sandbox: SandboxView{
1048 Bash: bash, Network: cfg.Sandbox.Network,
1049 WorkspaceRoot: cfg.Sandbox.WorkspaceRoot, AllowWrite: nonNil(cfg.Sandbox.AllowWrite),
1050 EffectiveWorkspaceRoot: effectiveWorkspaceRoot, EffectiveWriteRoots: nonNil(writeRoots),
1051 Shell: shell, EffectiveShell: sandboxEffectiveShellView(effectiveShell),
1052 },
1053 Network: NetworkView{
1054 ProxyMode: cfg.NetworkProxyMode(),
1055 ProxyURL: cfg.Network.ProxyURL,
1056 NoProxy: cfg.Network.NoProxy,
1057 Proxy: NetworkProxyView{
1058 Type: orDefault(cfg.Network.Proxy.Type, "socks5"),
1059 Server: cfg.Network.Proxy.Server,
1060 Port: cfg.Network.Proxy.Port,
1061 Username: cfg.Network.Proxy.Username,
1062 Password: cfg.Network.Proxy.Password,
1063 },
1064 },
1065 Agent: AgentView{
1066 Temperature: cfg.Agent.Temperature,
1067 MaxSteps: cfg.Agent.MaxSteps,
1068 PlannerMaxSteps: cfg.Agent.PlannerMaxSteps,
1069 MaxSubagentDepth: desktopMaxSubagentDepth(cfg.Agent.MaxSubagentDepth),
1070 MaxSubagentConcurrency: desktopSubagentConcurrency(cfg.Agent.MaxSubagentConcurrency),
1071 MaxParallelWriters: desktopParallelWriters(cfg.Agent.MaxParallelWriters, cfg.Agent.MaxSubagentConcurrency),
1072 SystemPrompt: cfg.Agent.SystemPrompt,
1073 ColdResumePrune: cfg.ColdResumePruneEnabled(),
1074 ReasoningLanguage: cfg.ReasoningLanguage(),
1075 CompactRatio: cfg.Agent.CompactRatio,
1076 EffectiveCompactRatio: cfg.Agent.CompactRatio,
1077 },
1078 Bot: botSettingsView(cfg.Bot),
1079 DesktopLanguage: cfg.DesktopLanguage(),
1080 DesktopCurrency: cfg.DesktopCurrency(),
1081 DesktopLayoutStyle: cfg.DesktopLayoutStyle(),
1082 DesktopTheme: cfg.DesktopTheme(),
1083 DesktopThemeStyle: cfg.DesktopThemeStyle(),
1084 DesktopTerminalTheme: cfg.DesktopTerminalTheme(),
1085 CloseBehavior: cfg.DesktopCloseBehavior(),
1086 DisplayMode: cfg.DesktopDisplayMode(),
1087 StatusBarStyle: cfg.DesktopStatusBarStyle(),
1088 StatusBarItems: cfg.DesktopStatusBarItems(),
1089 DefaultToolApprovalMode: cfg.DesktopDefaultToolApprovalMode(),
1090 CheckUpdates: cfg.DesktopCheckUpdates(),
1091 UpdateChannel: cfg.DesktopUpdateChannel(),
1092 Telemetry: cfg.DesktopTelemetry(),
1093 Metrics: cfg.DesktopMetrics(),
1094 ExpandThinking: cfg.Desktop.ExpandThinking,
1095 ConversationWidth: cfg.DesktopConversationWidth(),
1096 ConfigPath: cfgPath,
1097 ShadowedByPath: shadowingConfigPath(cfgPath, root),
1098 ProviderKinds: nonNil(provider.Kinds()),
1099 AutoApproveTools: ctrl != nil && ctrl.AutoApproveTools(),
1100 Bypass: ctrl != nil && ctrl.AutoApproveTools(),
1101 }
1102 if ctrl != nil {
1103 if effective := ctrl.CompactRatio(); effective > 0 {
1104 v.Agent.EffectiveCompactRatio = effective
1105 v.Agent.CompactRatioOverridden = math.Abs(effective-v.Agent.CompactRatio) > 0.0001
1106 }
1107 }
1108 added := providerAccessSet(cfg.Desktop.ProviderAccess)
1109 resolver := config.NewCredentialResolverForRoot(root)
1110 credentialsRevision := providerCredentialsRevision()
1111 v.OfficialProviders = officialProviderViewsForRootWithResolver(officialProviderAddedSet(cfg), a.desktopOfficialPricingLanguage(cfg), root, resolver)
1112 v.ProviderPresets = providerPresetViewsForRootWithResolver(cfg, root, resolver)
1113 for i := range cfg.Providers {
1114 p := &cfg.Providers[i]
1115 v.Providers = append(v.Providers, providerViewFromEntryForRootWithResolverAndCredentials(*p, isOfficialBuiltInProvider(*p), added[p.Name], root, resolver, credentialsRevision))
1116 }
1117 return v
1118 }
1119
1120 func sandboxEffectiveShellView(sh sandbox.Shell) string {
1121 if sh.Kind == sandbox.ShellPowerShell {
1122 if sh.SupportsChaining() {
1123 return "pwsh"
1124 }
1125 return "powershell"
1126 }
1127 path := strings.ToLower(strings.ReplaceAll(sh.Path, "\\", "/"))
1128 if strings.Contains(path, "/git/") && strings.HasSuffix(path, "bash.exe") {
1129 return "git-bash"
1130 }
1131 return "bash"
1132 }
1133
1134 func botSettingsView(b config.BotConfig) BotSettingsView {
1135 mode := strings.TrimSpace(b.Feishu.Mode)
1136 if mode == "" {
1137 mode = "webhook"
1138 }
1139 return BotSettingsView{
1140 Enabled: b.Enabled,
1141 Model: b.Model,
1142 ToolApprovalMode: normalizeBotConnectionToolApprovalMode(b.ToolApprovalMode),
1143 MaxSteps: b.MaxSteps,
1144 DebounceMs: b.DebounceMs,
1145 QueueMode: b.QueueMode,
1146 QueueCap: b.QueueCap,
1147 QueueDrop: b.QueueDrop,
1148 IgnoreSelfMessages: b.IgnoreSelfMessages,
1149 SelfUserIDs: BotSelfUserIDsView{
1150 QQ: nonNil(b.SelfUserIDs.QQ),
1151 Feishu: nonNil(b.SelfUserIDs.Feishu),
1152 Weixin: nonNil(b.SelfUserIDs.Weixin),
1153 },
1154 Control: BotControlView{
1155 Enabled: b.Control.Enabled,
1156 Addr: b.Control.Addr,
1157 TokenEnv: b.Control.TokenEnv,
1158 },
1159 Pairing: BotPairingView{
1160 Enabled: b.Pairing.Enabled,
1161 RequestTTLMinutes: b.Pairing.RequestTTLMinutes,
1162 MaxPendingPerPlatform: b.Pairing.MaxPendingPerPlatform,
1163 },
1164 Routes: botRouteViews(b.Routes),
1165 Allowlist: BotAllowlistView{
1166 Enabled: b.Allowlist.Enabled,
1167 AllowAll: b.Allowlist.AllowAll,
1168 QQUsers: nonNil(b.Allowlist.QQUsers),
1169 FeishuUsers: nonNil(b.Allowlist.FeishuUsers),
1170 WeixinUsers: nonNil(b.Allowlist.WeixinUsers),
1171 QQApprovers: nonNil(b.Allowlist.QQApprovers),
1172 FeishuApprovers: nonNil(b.Allowlist.FeishuApprovers),
1173 WeixinApprovers: nonNil(b.Allowlist.WeixinApprovers),
1174 QQAdmins: nonNil(b.Allowlist.QQAdmins),
1175 FeishuAdmins: nonNil(b.Allowlist.FeishuAdmins),
1176 WeixinAdmins: nonNil(b.Allowlist.WeixinAdmins),
1177 QQGroups: nonNil(b.Allowlist.QQGroups),
1178 FeishuGroups: nonNil(b.Allowlist.FeishuGroups),
1179 WeixinGroups: nonNil(b.Allowlist.WeixinGroups),
1180 },
1181 QQ: QQBotView{
1182 Enabled: b.QQ.Enabled,
1183 AppID: b.QQ.AppID,
1184 AppSecretEnv: b.QQ.AppSecretEnv,
1185 SecretSet: strings.TrimSpace(b.QQ.AppSecretEnv) != "" && os.Getenv(b.QQ.AppSecretEnv) != "",
1186 Sandbox: b.QQ.Sandbox,
1187 Model: b.QQ.Model,
1188 ToolApprovalMode: normalizeBotConnectionToolApprovalMode(b.QQ.ToolApprovalMode),
1189 WorkspaceRoot: b.QQ.WorkspaceRoot,
1190 Access: botAccessViewFromConfig(b.QQ.Access),
1191 },
1192 Feishu: FeishuBotView{
1193 Enabled: b.Feishu.Enabled,
1194 Domain: orDefault(strings.TrimSpace(b.Feishu.Domain), "feishu"),
1195 AppID: b.Feishu.AppID,
1196 AppSecretEnv: b.Feishu.AppSecretEnv,
1197 SecretSet: strings.TrimSpace(b.Feishu.AppSecretEnv) != "" && os.Getenv(b.Feishu.AppSecretEnv) != "",
1198 VerificationToken: b.Feishu.VerificationToken,
1199 Mode: mode,
1200 WebhookPort: b.Feishu.WebhookPort,
1201 RequireMention: b.Feishu.RequireMention,
1202 },
1203 Weixin: WeixinBotView{
1204 Enabled: b.Weixin.Enabled,
1205 AccountID: b.Weixin.AccountID,
1206 TokenEnv: b.Weixin.TokenEnv,
1207 TokenSet: strings.TrimSpace(b.Weixin.TokenEnv) != "" && os.Getenv(b.Weixin.TokenEnv) != "",
1208 APIBase: b.Weixin.APIBase,
1209 },
1210 Connections: botConnectionViews(b.Connections),
1211 }
1212 }
1213
1214 func orDefault(s, def string) string {
1215 if strings.TrimSpace(s) == "" {
1216 return def
1217 }
1218 return s
1219 }
1220
1221 func botRouteViews(routes []config.BotRouteConfig) []BotRouteView {
1222 if len(routes) == 0 {
1223 return []BotRouteView{}
1224 }
1225 out := make([]BotRouteView, 0, len(routes))
1226 for _, route := range routes {
1227 out = append(out, BotRouteView{
1228 ConnectionID: route.ConnectionID,
1229 Platform: route.Platform,
1230 ChatType: route.ChatType,
1231 ChatID: route.ChatID,
1232 UserID: route.UserID,
1233 ThreadID: route.ThreadID,
1234 Model: route.Model,
1235 ToolApprovalMode: normalizeBotConnectionToolApprovalMode(route.ToolApprovalMode),
1236 WorkspaceRoot: route.WorkspaceRoot,
1237 })
1238 }
1239 return out
1240 }
1241
1242 func botRouteConfigs(routes []BotRouteView) []config.BotRouteConfig {
1243 if len(routes) == 0 {
1244 return nil
1245 }
1246 out := make([]config.BotRouteConfig, 0, len(routes))
1247 for _, route := range routes {
1248 cfg := config.BotRouteConfig{
1249 ConnectionID: strings.TrimSpace(route.ConnectionID),
1250 Platform: strings.TrimSpace(route.Platform),
1251 ChatType: strings.TrimSpace(route.ChatType),
1252 ChatID: strings.TrimSpace(route.ChatID),
1253 UserID: strings.TrimSpace(route.UserID),
1254 ThreadID: strings.TrimSpace(route.ThreadID),
1255 Model: strings.TrimSpace(route.Model),
1256 ToolApprovalMode: normalizeBotConnectionToolApprovalMode(route.ToolApprovalMode),
1257 WorkspaceRoot: strings.TrimSpace(route.WorkspaceRoot),
1258 }
1259 if cfg.ConnectionID == "" && cfg.Platform == "" && cfg.ChatType == "" && cfg.ChatID == "" && cfg.UserID == "" && cfg.ThreadID == "" &&
1260 cfg.Model == "" && cfg.ToolApprovalMode == "" && cfg.WorkspaceRoot == "" {
1261 continue
1262 }
1263 out = append(out, cfg)
1264 }
1265 if len(out) == 0 {
1266 return nil
1267 }
1268 return out
1269 }
1270
1271 func botAccessViewFromConfig(access config.BotAccessConfig) BotAccessView {
1272 return BotAccessView{
1273 Enabled: access.Enabled,
1274 AllowAll: access.AllowAll,
1275 PairingEnabled: access.PairingEnabled,
1276 Users: nonNil(access.Users),
1277 Groups: nonNil(access.Groups),
1278 Approvers: nonNil(access.Approvers),
1279 Admins: nonNil(access.Admins),
1280 }
1281 }
1282
1283 func botAccessConfigFromView(access BotAccessView) config.BotAccessConfig {
1284 return config.BotAccessConfig{
1285 Enabled: access.Enabled,
1286 AllowAll: access.AllowAll,
1287 PairingEnabled: access.PairingEnabled,
1288 Users: trimList(access.Users),
1289 Groups: trimList(access.Groups),
1290 Approvers: trimList(access.Approvers),
1291 Admins: trimList(access.Admins),
1292 }
1293 }
1294
1295 func botDomainOrDefault(domain string) string {
1296 if strings.EqualFold(strings.TrimSpace(domain), "lark") {
1297 return "lark"
1298 }
1299 return "feishu"
1300 }
1301
1302 // --- apply (write config, then rebuild the controller so it's live) ---
1303
1304 // applyConfigChange mutates the user-global config and rebuilds the controller so
1305 // the change takes effect this session. Desktop settings such as providers and
1306 // keys are account-level, not per-project: writing them to the global config
1307 // rather than the cwd's reasonix.toml is what lets them survive a workspace switch.
1308 func (a *App) applyConfigChange(mutate func(*config.Config) error) error {
1309 _, err := a.applyConfigChangeWithWarning("settings", mutate)
1310 return err
1311 }
1312
1313 func (a *App) applyConfigChangeWithWarning(setting string, mutate func(*config.Config) error) (string, error) {
1314 if err := a.ensureActiveTabRebuildAllowed(setting); err != nil {
1315 return "", err
1316 }
1317 if err := func() error {
1318 // Serialize the load-modify-save against other in-process config editors
1319 // (bot auto-session persistence, applyConfigOnly) so neither drops the
1320 // other's fields. rebuild() runs after unlocking — it does slow work and
1321 // must not hold the config edit lock.
1322 unlock := config.LockUserConfigEdits()
1323 defer unlock()
1324 cfg, path, err := a.loadDesktopUserConfigForEdit()
1325 if err != nil {
1326 return err
1327 }
1328 if err := mutate(cfg); err != nil {
1329 return err
1330 }
1331 return cfg.SaveTo(path)
1332 }(); err != nil {
1333 return "", err
1334 }
1335 if err := a.rebuildSetting(setting); err != nil {
1336 if warning, ok := a.deferredRebuildWarning(setting, err); ok {
1337 return warning, nil
1338 }
1339 return "", err
1340 }
1341 return "", nil
1342 }
1343
1344 func (a *App) applyConfigOnly(mutate func(*config.Config) error) error {
1345 unlock := config.LockUserConfigEdits()
1346 defer unlock()
1347 cfg, path, err := a.loadDesktopUserConfigForEdit()
1348 if err != nil {
1349 return err
1350 }
1351 if err := mutate(cfg); err != nil {
1352 return err
1353 }
1354 return cfg.SaveTo(path)
1355 }
1356
1357 func (a *App) ensureActiveTabRebuildAllowed(setting string) error {
1358 tab := a.activeTab()
1359 if tab == nil {
1360 if a.ctx == nil {
1361 return nil
1362 }
1363 return fmt.Errorf("no active tab")
1364 }
1365 if err := rebuildControllerActiveWorkErrorFor(a.controllerForTab(tab), setting); err != nil {
1366 return err
1367 }
1368 return nil
1369 }
1370
1371 func (a *App) ensureLiveControllersRuntimeMutationAllowed(setting string) error {
1372 a.mu.RLock()
1373 defer a.mu.RUnlock()
1374 for _, tab := range a.tabs {
1375 if tab == nil {
1376 continue
1377 }
1378 if err := rebuildControllerActiveWorkErrorFor(tab.Ctrl, setting); err != nil {
1379 return err
1380 }
1381 }
1382 return nil
1383 }
1384
1385 func (a *App) deferredRebuildWarning(setting string, err error) (string, bool) {
1386 if err == nil || !errors.Is(err, agent.ErrSessionLeaseHeld) {
1387 return "", false
1388 }
1389 setting = strings.TrimSpace(setting)
1390 if setting == "" {
1391 setting = "settings"
1392 }
1393 userErr := userFacingSessionLeaseError(setting, err)
1394 warning := fmt.Sprintf("%s saved, but the current session could not refresh yet: %s", setting, userErr.Error())
1395 slog.Warn("desktop: deferred settings rebuild", "setting", setting, "err", err)
1396 // Bind both the warning and the retry to the tab whose refresh failed (the
1397 // rebuild acts on the active tab), so a tab switch right after the failure
1398 // cannot misroute the notice or the deferred rebuild.
1399 if tab := a.activeTab(); tab != nil {
1400 a.warnForTab(tab.ID, warning)
1401 a.scheduleDeferredRebuild(tab.ID, setting)
1402 }
1403 return warning, true
1404 }
1405
1406 func appendSettingsWarning(existing, warning string) string {
1407 existing = strings.TrimSpace(existing)
1408 warning = strings.TrimSpace(warning)
1409 if existing == "" {
1410 return warning
1411 }
1412 if warning == "" {
1413 return existing
1414 }
1415 return existing + "\n" + warning
1416 }
1417
1418 // loadDesktopUserConfigForEdit loads the user config for a write path. Pending
1419 // legacy migrations are assembled in memory and reach disk through the locked
1420 // user-config save, never by rewriting a project file as a side effect.
1421 //
1422 // Contract: the caller must already hold config.LockUserConfigEdits() across
1423 // its whole load→mutate→SaveTo cycle, so the migration write-back cannot race
1424 // other in-process config editors. This helper must never acquire that lock
1425 // itself: applyConfigChange/applyConfigOnly (and every other caller) invoke it
1426 // with the lock held, so an inner acquire would self-deadlock. Read-only
1427 // callers must use loadDesktopUserConfigForView (or its WithCredentials
1428 // variant), which never writes to disk.
1429 func (a *App) loadDesktopUserConfigForEdit() (*config.Config, string, error) {
1430 return a.loadDesktopUserConfigForEditForRoot(a.activeWorkspaceRoot())
1431 }
1432
1433 func (a *App) loadDesktopUserConfigForEditForRoot(root string) (*config.Config, string, error) {
1434 userPath := config.UserConfigPath()
1435 if userPath == "" {
1436 return nil, "", fmt.Errorf("cannot resolve user config directory")
1437 }
1438 if _, err := os.Stat(userPath); err == nil {
1439 cfg, err := config.LoadForEditReadOnlyStrict(userPath)
1440 if err != nil {
1441 return nil, "", err
1442 }
1443 if err := normalizeLegacyDesktopProviderAccessForSettings(cfg, userPath); err != nil {
1444 return nil, "", err
1445 }
1446 if err := a.migrateLegacyBotConfigToUserForRoot(root, cfg, userPath); err != nil {
1447 return nil, "", err
1448 }
1449 return cfg, userPath, nil
1450 }
1451 cfg, err := config.LoadForEditReadOnlyStrict(userPath)
1452 if err != nil {
1453 return nil, "", err
1454 }
1455 legacyPath := config.SourcePathForRoot(root)
1456 if legacyPath == "" || sameConfigPath(legacyPath, userPath) {
1457 if err := normalizeLegacyDesktopProviderAccessForSettings(cfg, userPath); err != nil {
1458 return nil, "", err
1459 }
1460 return cfg, userPath, nil
1461 }
1462 legacyCfg, err := config.LoadForEditReadOnlyStrict(legacyPath)
1463 if err != nil {
1464 return nil, "", err
1465 }
1466 normalizeLegacyDesktopProviderAccessInMemory(legacyCfg, legacyPath)
1467 legacyCfg.ConfigVersion = config.Default().ConfigVersion
1468 if err := migrateLegacyBotConfigToUser(cfg, legacyCfg, userPath); err != nil {
1469 return nil, "", err
1470 }
1471 return legacyCfg, userPath, nil
1472 }
1473
1474 // loadDesktopUserConfigForView loads the user config for read-only callers.
1475 // Contract: it never writes to disk, so it is safe without
1476 // config.LockUserConfigEdits(). Legacy migrations (provider-access normalize,
1477 // legacy bot-config merge) are applied to the returned copy in memory only;
1478 // the on-disk file migrates the first time a locked write path runs
1479 // loadDesktopUserConfigForEdit. Credentials (Reasonix global .env) are not
1480 // loaded; callers that hand the config to a runtime resolving secrets from the
1481 // process env must use loadDesktopUserConfigForViewWithCredentials.
1482 func (a *App) loadDesktopUserConfigForView() (*config.Config, string, error) {
1483 return a.loadDesktopUserConfigForViewForRoot(a.activeWorkspaceRoot())
1484 }
1485
1486 func (a *App) loadDesktopUserConfigForViewForRoot(root string) (*config.Config, string, error) {
1487 return a.loadDesktopUserConfigReadOnlyForRoot(root, config.LoadForEditWithoutCredentialsReadOnlyStrict)
1488 }
1489
1490 // loadDesktopUserConfigForViewWithCredentials is loadDesktopUserConfigForView
1491 // plus credential resolution: like config.LoadForEdit it loads Reasonix's
1492 // global .env into the process env. Use it for read-only loads whose result
1493 // feeds a runtime that resolves env-based secrets — the bot runtime
1494 // (app-secret/control-token envs) and MCP server connects. It still never
1495 // writes to disk.
1496 func (a *App) loadDesktopUserConfigForViewWithCredentials() (*config.Config, string, error) {
1497 return a.loadDesktopUserConfigForViewWithCredentialsForRoot(a.activeWorkspaceRoot())
1498 }
1499
1500 func (a *App) loadDesktopUserConfigForViewWithCredentialsForRoot(root string) (*config.Config, string, error) {
1501 return a.loadDesktopUserConfigReadOnlyForRoot(root, config.LoadForEditReadOnlyStrict)
1502 }
1503
1504 // loadDesktopUserConfigReadOnlyForRoot is the shared pure-read loader behind
1505 // the View variants: same shape as loadDesktopUserConfigForEdit, but every
1506 // legacy migration stays in memory (zero SaveTo) and resolves from root.
1507 func (a *App) loadDesktopUserConfigReadOnlyForRoot(root string, load func(string) (*config.Config, error)) (*config.Config, string, error) {
1508 userPath := config.UserConfigPath()
1509 if userPath == "" {
1510 return nil, "", fmt.Errorf("cannot resolve user config directory")
1511 }
1512 if _, err := os.Stat(userPath); err == nil {
1513 cfg, err := load(userPath)
1514 if err != nil {
1515 return nil, "", err
1516 }
1517 normalizeLegacyDesktopProviderAccessInMemory(cfg, userPath)
1518 legacyPath := config.SourcePathForRoot(root)
1519 if legacyPath != "" && !sameConfigPath(legacyPath, userPath) {
1520 legacyCfg, err := load(legacyPath)
1521 if err != nil {
1522 return nil, "", err
1523 }
1524 mergeLegacyBotConfigInMemory(cfg, legacyCfg)
1525 }
1526 return cfg, userPath, nil
1527 }
1528 cfg, err := load(userPath)
1529 if err != nil {
1530 return nil, "", err
1531 }
1532 legacyPath := config.SourcePathForRoot(root)
1533 if legacyPath == "" || sameConfigPath(legacyPath, userPath) {
1534 normalizeLegacyDesktopProviderAccessInMemory(cfg, userPath)
1535 return cfg, userPath, nil
1536 }
1537 // The user config does not exist yet: serve the legacy config as the view.
1538 // It already carries any legacy bot config, so no merge is needed; the
1539 // write path creates the migrated user file later.
1540 legacyCfg, err := load(legacyPath)
1541 if err != nil {
1542 return nil, "", err
1543 }
1544 normalizeLegacyDesktopProviderAccessInMemory(legacyCfg, legacyPath)
1545 legacyCfg.ConfigVersion = config.Default().ConfigVersion
1546 return legacyCfg, userPath, nil
1547 }
1548
1549 // migrateLegacyBotConfigToUserForRoot is the write-path legacy bot-config
1550 // migration against an explicit workspace's legacy config file. Callers must
1551 // hold config.LockUserConfigEdits() (see loadDesktopUserConfigForEdit).
1552 func (a *App) migrateLegacyBotConfigToUserForRoot(root string, userCfg *config.Config, userPath string) error {
1553 if userCfg == nil {
1554 return nil
1555 }
1556 legacyPath := config.SourcePathForRoot(root)
1557 if legacyPath == "" || sameConfigPath(legacyPath, userPath) {
1558 return nil
1559 }
1560 legacyCfg, err := config.LoadForEditReadOnlyStrict(legacyPath)
1561 if err != nil {
1562 return err
1563 }
1564 return migrateLegacyBotConfigToUser(userCfg, legacyCfg, userPath)
1565 }
1566
1567 // migrateLegacyBotConfigToUser is the write-path variant: it merges the legacy
1568 // bot config in memory and persists the result to userPath. Callers must hold
1569 // config.LockUserConfigEdits() (see loadDesktopUserConfigForEdit). Read paths
1570 // use mergeLegacyBotConfigInMemory instead.
1571 func migrateLegacyBotConfigToUser(userCfg, legacyCfg *config.Config, userPath string) error {
1572 if !mergeLegacyBotConfigInMemory(userCfg, legacyCfg) {
1573 return nil
1574 }
1575 if err := userCfg.SaveTo(userPath); err != nil {
1576 return fmt.Errorf("migrate legacy bot config: %w", err)
1577 }
1578 return nil
1579 }
1580
1581 // mergeLegacyBotConfigInMemory copies the legacy bot config onto userCfg when
1582 // the user config has none of its own. It never touches disk; it reports
1583 // whether userCfg changed (i.e. whether a write path should persist it).
1584 func mergeLegacyBotConfigInMemory(userCfg, legacyCfg *config.Config) bool {
1585 if userCfg == nil || legacyCfg == nil || desktopBotConfigConfigured(userCfg.Bot) {
1586 return false
1587 }
1588 if !desktopBotConfigConfigured(legacyCfg.Bot) {
1589 return false
1590 }
1591 userCfg.Bot = legacyCfg.Bot
1592 return true
1593 }
1594
1595 func desktopBotConfigConfigured(bot config.BotConfig) bool {
1596 defaults := config.Default().Bot
1597 if bot.Enabled || strings.TrimSpace(bot.Model) != "" || len(bot.Connections) > 0 {
1598 return true
1599 }
1600 if (bot.MaxSteps != 0 && bot.MaxSteps != defaults.MaxSteps) ||
1601 (bot.DebounceMs != 0 && bot.DebounceMs != defaults.DebounceMs) ||
1602 (strings.TrimSpace(bot.QueueMode) != "" && bot.QueueMode != defaults.QueueMode) ||
1603 (bot.QueueCap != 0 && bot.QueueCap != defaults.QueueCap) ||
1604 (strings.TrimSpace(bot.QueueDrop) != "" && bot.QueueDrop != defaults.QueueDrop) ||
1605 bot.IgnoreSelfMessages != defaults.IgnoreSelfMessages ||
1606 bot.Pairing.Enabled != defaults.Pairing.Enabled ||
1607 (bot.Pairing.RequestTTLMinutes != 0 && bot.Pairing.RequestTTLMinutes != defaults.Pairing.RequestTTLMinutes) ||
1608 (bot.Pairing.MaxPendingPerPlatform != 0 && bot.Pairing.MaxPendingPerPlatform != defaults.Pairing.MaxPendingPerPlatform) ||
1609 bot.Control.Enabled != defaults.Control.Enabled ||
1610 (strings.TrimSpace(bot.Control.Addr) != "" && bot.Control.Addr != defaults.Control.Addr) ||
1611 (strings.TrimSpace(bot.Control.TokenEnv) != "" && bot.Control.TokenEnv != defaults.Control.TokenEnv) ||
1612 len(bot.Routes) > 0 ||
1613 len(bot.SelfUserIDs.QQ)+len(bot.SelfUserIDs.Feishu)+len(bot.SelfUserIDs.Weixin) > 0 {
1614 return true
1615 }
1616 if bot.Allowlist.AllowAll ||
1617 len(bot.Allowlist.QQUsers)+len(bot.Allowlist.FeishuUsers)+len(bot.Allowlist.WeixinUsers) > 0 ||
1618 len(bot.Allowlist.QQApprovers)+len(bot.Allowlist.FeishuApprovers)+len(bot.Allowlist.WeixinApprovers) > 0 ||
1619 len(bot.Allowlist.QQAdmins)+len(bot.Allowlist.FeishuAdmins)+len(bot.Allowlist.WeixinAdmins) > 0 ||
1620 len(bot.Allowlist.QQGroups)+len(bot.Allowlist.FeishuGroups)+len(bot.Allowlist.WeixinGroups) > 0 {
1621 return true
1622 }
1623 if bot.QQ.Enabled ||
1624 strings.TrimSpace(bot.QQ.AppID) != "" ||
1625 bot.QQ.AppSecretEnv != defaults.QQ.AppSecretEnv ||
1626 bot.QQ.Sandbox != defaults.QQ.Sandbox ||
1627 strings.TrimSpace(bot.QQ.Model) != "" ||
1628 strings.TrimSpace(bot.QQ.ToolApprovalMode) != "" ||
1629 strings.TrimSpace(bot.QQ.WorkspaceRoot) != "" ||
1630 botruntime.BotAccessActive(bot.QQ.Access) {
1631 return true
1632 }
1633 if bot.Feishu.Enabled ||
1634 strings.TrimSpace(bot.Feishu.AppID) != "" ||
1635 bot.Feishu.Domain != defaults.Feishu.Domain ||
1636 bot.Feishu.AppSecretEnv != defaults.Feishu.AppSecretEnv ||
1637 strings.TrimSpace(bot.Feishu.VerificationToken) != "" ||
1638 bot.Feishu.Mode != defaults.Feishu.Mode ||
1639 bot.Feishu.WebhookPort != defaults.Feishu.WebhookPort ||
1640 bot.Feishu.RequireMention != defaults.Feishu.RequireMention {
1641 return true
1642 }
1643 if bot.Weixin.Enabled ||
1644 bot.Weixin.AccountID != defaults.Weixin.AccountID ||
1645 bot.Weixin.TokenEnv != defaults.Weixin.TokenEnv ||
1646 bot.Weixin.APIBase != defaults.Weixin.APIBase {
1647 return true
1648 }
1649 return false
1650 }
1651
1652 // normalizeLegacyDesktopProviderAccessForSettings is the write-path variant:
1653 // it normalizes in memory and persists the migrated form to path. Callers must
1654 // hold config.LockUserConfigEdits() (see loadDesktopUserConfigForEdit). Read
1655 // paths use normalizeLegacyDesktopProviderAccessInMemory instead.
1656 func normalizeLegacyDesktopProviderAccessForSettings(cfg *config.Config, path string) error {
1657 if !normalizeLegacyDesktopProviderAccessInMemory(cfg, path) {
1658 return nil
1659 }
1660 if _, err := os.Stat(path); err != nil {
1661 if os.IsNotExist(err) {
1662 return nil
1663 }
1664 return err
1665 }
1666 return cfg.SaveTo(path)
1667 }
1668
1669 // normalizeLegacyDesktopProviderAccessInMemory seeds cfg.Desktop.ProviderAccess
1670 // from configs written before Settings tracked explicit provider access. It
1671 // never touches disk; it reports whether cfg now carries a normalized list
1672 // that the file at path does not declare (i.e. whether a write path should
1673 // persist it).
1674 func normalizeLegacyDesktopProviderAccessInMemory(cfg *config.Config, path string) bool {
1675 if cfg == nil || len(cfg.Desktop.ProviderAccess) > 0 || configDeclaresProviderAccess(path) {
1676 return false
1677 }
1678 config.NormalizeLegacyDesktopProviderAccess(cfg)
1679 return len(cfg.Desktop.ProviderAccess) > 0 && strings.TrimSpace(path) != ""
1680 }
1681
1682 func configDeclaresProviderAccess(path string) bool {
1683 if strings.TrimSpace(path) == "" {
1684 return false
1685 }
1686 body, err := readFileUTF8(path)
1687 if err != nil {
1688 return false
1689 }
1690 for _, line := range strings.Split(string(body), "\n") {
1691 if before, _, ok := strings.Cut(line, "#"); ok {
1692 line = before
1693 }
1694 line = strings.TrimSpace(line)
1695 if strings.HasPrefix(line, "provider_access") {
1696 rest := strings.TrimSpace(strings.TrimPrefix(line, "provider_access"))
1697 return strings.HasPrefix(rest, "=")
1698 }
1699 }
1700 return false
1701 }
1702
1703 func (a *App) activeWorkspaceRoot() string {
1704 tab := a.activeTab()
1705 if tab != nil {
1706 a.reconcileTabWithPinnedSessionMeta(tab)
1707 if strings.TrimSpace(tab.WorkspaceRoot) != "" {
1708 return tab.WorkspaceRoot
1709 }
1710 }
1711 return "."
1712 }
1713
1714 func (a *App) saveProviderCredential(apiKeyEnv, value string) (string, error) {
1715 apiKeyEnv = strings.TrimSpace(apiKeyEnv)
1716 value = strings.TrimSpace(value)
1717 if err := upsertDotEnv(apiKeyEnv, value); err != nil {
1718 return "", err
1719 }
1720 return providerCredentialSourceNotice(apiKeyEnv, value), nil
1721 }
1722
1723 func providerCredentialSourceNotice(apiKeyEnv, value string) string {
1724 return ""
1725 }
1726
1727 func sameConfigPath(a, b string) bool {
1728 a = strings.TrimSpace(a)
1729 b = strings.TrimSpace(b)
1730 if a == "" || b == "" {
1731 return false
1732 }
1733 aAbs, aErr := filepath.Abs(a)
1734 bAbs, bErr := filepath.Abs(b)
1735 if aErr == nil && bErr == nil {
1736 return filepath.Clean(aAbs) == filepath.Clean(bAbs)
1737 }
1738 return filepath.Clean(a) == filepath.Clean(b)
1739 }
1740
1741 // rebuild builds a replacement controller from the (just-changed) config and
1742 // swaps it in only after the target session lease is available. The old
1743 // controller stays usable if the rebuild fails.
1744 func (a *App) rebuild() error {
1745 return a.rebuildSetting("settings")
1746 }
1747
1748 func (a *App) rebuildSetting(setting string) error {
1749 if a.ctx == nil {
1750 return nil
1751 }
1752 // Serialize with SetModelForTab and the deferred-rebuild retry loop: two
1753 // concurrent build+swap sequences on the same tab leak the first-swapped
1754 // controller and double-close the old one.
1755 a.runtimeRebuildMu.Lock()
1756 err := a.rebuildSettingLocked(setting)
1757 a.runtimeRebuildMu.Unlock()
1758 return err
1759 }
1760
1761 // rebuildSettingLocked is rebuildSetting's body; callers must already hold
1762 // runtimeRebuildMu. The deferred-rebuild retry loop calls this directly because
1763 // it takes the lock across its lease probe.
1764 func (a *App) rebuildSettingLocked(setting string) error {
1765 if a.ctx == nil {
1766 return nil
1767 }
1768 tab := a.activeTab()
1769 if tab == nil {
1770 return fmt.Errorf("no active tab")
1771 }
1772 tab.turnStartMu.Lock()
1773 defer tab.turnStartMu.Unlock()
1774 return a.rebuildSettingTurnLocked(setting, tab, false, false)
1775 }
1776
1777 // rebuildSettingTurnLocked is rebuildSettingLocked's body; callers must hold
1778 // runtimeRebuildMu and the passed tab's turnStartMu. admissionHeld is true for
1779 // MCP lifecycle callers that also hold runtimeAdmissionMu's write side.
1780 // reload selects the stage-3b runtime-reload build path (boot.Rebuild migrates
1781 // the session) instead of the legacy boot.Build + manual migration; everything
1782 // else — active-work guards, workspace prep, lease moves, swap, close-after-
1783 // swap, fence — is shared.
1784 func (a *App) rebuildSettingTurnLocked(setting string, tab *WorkspaceTab, admissionHeld bool, reload bool) error {
1785 if a.ctx == nil {
1786 return nil
1787 }
1788 if err := rebuildControllerActiveWorkErrorFor(a.controllerForTab(tab), setting); err != nil {
1789 return err
1790 }
1791 ensureWorkspace := a.ensureTabControllerWorkspace
1792 if admissionHeld {
1793 ensureWorkspace = a.ensureTabControllerWorkspaceAdmissionHeld
1794 }
1795 if err := ensureWorkspace(tab); err != nil {
1796 return err
1797 }
1798 prevPath := a.reconciledSessionPathForTab(tab)
1799 if prevPath == "" {
1800 prevPath = a.currentSessionPathFor(tab)
1801 }
1802 if a.controllerForTab(tab) == nil && prevPath != "" && a.attachExistingSessionRuntime(tab, prevPath, a.ctx) {
1803 prevPath = a.reconciledSessionPathForTab(tab)
1804 if prevPath == "" {
1805 prevPath = a.currentSessionPathFor(tab)
1806 }
1807 }
1808 if err := rebuildControllerActiveWorkErrorFor(a.controllerForTab(tab), setting); err != nil {
1809 return err
1810 }
1811 if err := ensureWorkspace(tab); err != nil {
1812 return err
1813 }
1814
1815 var carried []provider.Message
1816 oldCtrl := a.controllerForTab(tab)
1817 if oldCtrl != nil {
1818 if prevPath == "" {
1819 prevPath = oldCtrl.SessionPath()
1820 }
1821 if err := a.ensureTabSessionLeaseForRebuild(tab, prevPath, setting); err != nil {
1822 return err
1823 }
1824 if err := a.snapshotTabForAction(tab, "rebuilding settings"); err != nil {
1825 return err
1826 }
1827 prevPath = sessionPathAfterSnapshot(oldCtrl, prevPath)
1828 carried = oldCtrl.History()
1829 }
1830 snap := a.tabRuntimeSnapshot(tab)
1831 runtime := snap.normalizedRuntime()
1832 model := snap.model
1833 if cfg, err := config.LoadForRoot(snap.workspaceRoot); err == nil {
1834 if resolved, fallback, ok := cfg.ResolveModelWithFallback(model); ok {
1835 if fallback && strings.TrimSpace(model) != "" {
1836 a.noticeForTab(tab.ID, fmt.Sprintf("model %q is no longer available; switched to %s", model, resolved))
1837 }
1838 model = resolved
1839 }
1840 }
1841 ctrl, restoredRuntime, path, err := a.buildSettingReplacementController(tab, snap, runtime, model, prevPath, setting, oldCtrl, carried, reload)
1842 if err != nil {
1843 if oldCtrl == nil {
1844 leaseHeld := false
1845 a.mu.Lock()
1846 leaseHeld = setTabStartupError(tab, err)
1847 tab.Ready = false
1848 if leaseHeld {
1849 a.setSessionRuntimePhaseLocked(tab, sessionRuntimeLeaseBlocked, err)
1850 } else {
1851 a.setSessionRuntimePhaseLocked(tab, sessionRuntimeFailed, err)
1852 }
1853 a.mu.Unlock()
1854 if leaseHeld {
1855 a.scheduleDeferredStartupBuild(tab.ID)
1856 }
1857 a.emitReady(a.ctx)
1858 }
1859 return err
1860 }
1861 a.mu.Lock()
1862 if current := a.tabs[tab.ID]; current != tab {
1863 a.mu.Unlock()
1864 ctrl.Close()
1865 tab.releaseSessionLease()
1866 return fmt.Errorf("tab %q changed while rebuilding settings; retry", tab.ID)
1867 }
1868 tab.Ctrl = ctrl
1869 tab.model = model
1870 tab.Label = ctrl.Label()
1871 applyNormalizedRuntimeToTabLocked(tab, restoredRuntime)
1872 clearTabStartupError(tab)
1873 tab.Ready = true
1874 // Supersede any in-flight startup build: it would otherwise finish later,
1875 // pass its generation check, and overwrite the controller just installed.
1876 a.supersedeTabBuildLocked(tab)
1877 a.saveTabsLocked()
1878 a.mu.Unlock()
1879 if oldCtrl != nil {
1880 oldCtrl.Close()
1881 }
1882 a.persistTabSessionPath(tab, path)
1883 if setting == "currency" {
1884 a.repriceTabUsageForCurrentCurrency(tab)
1885 }
1886 a.clearDeferredRebuild(tab.ID)
1887 a.notifyTabRuntimeRebuilt(tab)
1888 a.emitReady(a.ctx)
1889 return nil
1890 }
1891
1892 // buildSettingReplacementController builds the replacement controller for
1893 // rebuildSettingTurnLocked and migrates the session onto it, returning the
1894 // controller, the runtime posture actually restored, and the session path it
1895 // bound. reload=false is the legacy settings path (boot.Build plus the
1896 // desktop's manual migration); reload=true is the stage-3b runtime reload,
1897 // routing build and migration through boot.Rebuild so history, approval mode
1898 // and grants, plan/goal state, and lifecycle move inside the boot layer. The
1899 // caller owns the swap, closing the old controller after the swap, and the
1900 // post-swap persistence.
1901 func (a *App) buildSettingReplacementController(tab *WorkspaceTab, snap tabRuntimeSnapshot, runtime normalizedTabRuntime, model, prevPath, setting string, oldCtrl control.SessionAPI, carried []provider.Message, reload bool) (control.SessionAPI, normalizedTabRuntime, string, error) {
1902 opts := boot.Options{
1903 Model: model, RequireKey: false,
1904 AutoPricingCurrency: a.desktopAutoPricingCurrency(),
1905 StatsSource: "desktop",
1906 Sink: snap.sink,
1907 WorkspaceRoot: snap.workspaceRoot,
1908 SessionDir: sessionDirForSnapshot(snap),
1909 EffortOverride: cloneStringPtr(snap.effort),
1910 TokenMode: runtime.tokenMode,
1911 SharedHost: a.lookupSharedHost(snap.sharedHostKey),
1912 CleanupPendingReconciler: reconcileDesktopCleanupPending,
1913 SubagentParentLive: a.subagentParentProbeForBuild(tab),
1914 SessionRecoveryMeta: a.tabSessionRecoveryMeta(tab),
1915 OnSessionRecovered: a.handleTabSessionRecovered(tab),
1916 }
1917 if reload && oldCtrl != nil {
1918 old, ok := oldCtrl.(*control.Controller)
1919 if !ok {
1920 return nil, normalizedTabRuntime{}, "", fmt.Errorf("reload runtime: controller is %T, want *control.Controller", oldCtrl)
1921 }
1922 res, err := boot.Rebuild(a.bootContext(), old, opts)
1923 if err != nil {
1924 return nil, normalizedTabRuntime{}, "", err
1925 }
1926 // The stage-3a runtime set is always empty; when stage 5 binds
1927 // sidecar processes it must retire with the controller it belongs to.
1928 ctrl := res.Controller
1929 a.bindControllerDisplayRecorder(ctrl)
1930 // boot.Rebuild migrated history (same session file, fresh system
1931 // prompt spliced), approval mode and grants, plan/goal state, and
1932 // lifecycle. The interactive approval gate and the plan/yolo tab
1933 // mode are desktop wiring Rebuild deliberately leaves out — the
1934 // mode re-apply also restores yolo, which Rebuild does not carry.
1935 ctrl.EnableInteractiveApproval()
1936 applyTabModeToController(ctrl, runtime.tabMode())
1937 // Same path Rebuild pinned internally (identical inputs), recomputed
1938 // for the lease move and the post-swap persistence.
1939 path := agent.ContinueSessionPath(prevPath, ctrl.SessionDir(), ctrl.Label())
1940 if err := a.ensureTabSessionLeaseForRebuild(tab, path, setting); err != nil {
1941 ctrl.Close()
1942 return nil, normalizedTabRuntime{}, "", err
1943 }
1944 restoredRuntime, err := normalizeRestoredControllerRuntime(ctrl, runtime)
1945 if err != nil {
1946 ctrl.Close()
1947 return nil, normalizedTabRuntime{}, "", err
1948 }
1949 return ctrl, restoredRuntime, path, nil
1950 }
1951 // Same-session rebuild without the full boot.Rebuild path still must keep
1952 // the private temporary directory (Issue #7575).
1953 if old, ok := oldCtrl.(*control.Controller); ok && old != nil && opts.SessionTemp == nil {
1954 opts.SessionTemp = old.SessionTemp()
1955 }
1956 ctrl, err := boot.Build(a.bootContext(), opts)
1957 if err != nil {
1958 return nil, normalizedTabRuntime{}, "", err
1959 }
1960 a.bindControllerDisplayRecorder(ctrl)
1961 configureControllerRuntime(ctrl, oldCtrl, runtime)
1962 path := agent.ContinueSessionPath(prevPath, ctrl.SessionDir(), ctrl.Label())
1963 if err := a.ensureTabSessionLeaseForRebuild(tab, path, setting); err != nil {
1964 ctrl.Close()
1965 return nil, normalizedTabRuntime{}, "", err
1966 }
1967 restoredRuntime, err := resumeControllerRuntimeWithMessages(ctrl, carried, path, runtime)
1968 if err != nil {
1969 ctrl.Close()
1970 return nil, normalizedTabRuntime{}, "", err
1971 }
1972 return ctrl, restoredRuntime, path, nil
1973 }
1974
1975 // runtimeReloadSettingLabel is the settings-style label used in busy/lease
1976 // error text and notices for an explicit runtime reload.
1977 const runtimeReloadSettingLabel = "runtime reload"
1978
1979 // ReloadRuntime rebuilds the tab's agent runtime in place — tools, skills,
1980 // commands, hooks, providers, and MCP servers are re-discovered from the
1981 // current config — while the session carries over (transcript, approval
1982 // grants, goal/recovery state, shared plugin Host) via boot.Rebuild. Active
1983 // work or a held lease queues exactly one reload on the deferred-rebuild
1984 // loop, which runs it once the tab is idle; a failure keeps the old
1985 // controller fully usable.
1986 func (a *App) ReloadRuntime(tabID string) error {
1987 if a.ctx == nil {
1988 return nil
1989 }
1990 tab := a.tabByID(tabID)
1991 if tab == nil || tab.ID != tabID {
1992 return fmt.Errorf("unknown tab %q", tabID)
1993 }
1994 // Same serialization as rebuildSetting: two build+swap sequences on the
1995 // same tab must not interleave.
1996 a.runtimeRebuildMu.Lock()
1997 err := a.reloadRuntimeTurnLocked(tab)
1998 a.runtimeRebuildMu.Unlock()
1999 if err == nil {
2000 return nil
2001 }
2002 var busy *rebuildBusyError
2003 if errors.As(err, &busy) || errors.Is(err, agent.ErrSessionLeaseHeld) {
2004 // Queue exactly one reload per tab (the pending map coalesces
2005 // duplicates); the loop retries once the work finishes or the lease
2006 // clears.
2007 a.scheduleDeferredRebuild(tab.ID, deferredRuntimeReloadLabel)
2008 a.noticeForTab(tab.ID, "runtime reload queued: will run when the current work finishes")
2009 return nil
2010 }
2011 return err
2012 }
2013
2014 // reloadRuntimeTurnLocked runs the in-place runtime reload for tab; callers
2015 // hold runtimeRebuildMu (the deferred-rebuild retry loop also drives it).
2016 func (a *App) reloadRuntimeTurnLocked(tab *WorkspaceTab) error {
2017 if a.ctx == nil {
2018 return nil
2019 }
2020 tab.turnStartMu.Lock()
2021 defer tab.turnStartMu.Unlock()
2022 return a.rebuildSettingTurnLocked(runtimeReloadSettingLabel, tab, false, true)
2023 }
2024
2025 // SetDefaultModel sets the config default and switches the live model to it.
2026 func (a *App) SetDefaultModel(ref string) error {
2027 tab := a.activeTab()
2028 if tab == nil {
2029 return fmt.Errorf("no active tab")
2030 }
2031 // applyConfigChange ends in rebuild(), which reads tab.model to pick the
2032 // runtime model — the new ref must be visible on the tab before that runs.
2033 a.mu.Lock()
2034 prev := tab.model
2035 tab.model = ref
2036 a.mu.Unlock()
2037 if err := a.applyConfigChange(func(c *config.Config) error {
2038 resolved, err := selectableDesktopModelRef(c, ref)
2039 if err != nil {
2040 return err
2041 }
2042 c.DefaultModel = resolved
2043 a.mu.Lock()
2044 tab.model = resolved
2045 a.mu.Unlock()
2046 return nil
2047 }); err != nil {
2048 a.mu.Lock()
2049 tab.model = prev
2050 a.mu.Unlock()
2051 return err
2052 }
2053 return nil
2054 }
2055
2056 // SetPlannerModel sets (or, with "", clears) the two-model planner.
2057 func (a *App) SetPlannerModel(ref string) error {
2058 return a.applyConfigChange(func(c *config.Config) error {
2059 if ref != "" {
2060 resolved, err := selectableDesktopModelRef(c, ref)
2061 if err != nil {
2062 return err
2063 }
2064 ref = resolved
2065 }
2066 c.Agent.PlannerModel = ref
2067 return nil
2068 })
2069 }
2070
2071 // SetSubagentModel sets (or clears) the default model used by subagent entry points.
2072 func (a *App) SetSubagentModel(ref string) error {
2073 return a.applyConfigChange(func(c *config.Config) error {
2074 ref = strings.TrimSpace(ref)
2075 if ref != "" {
2076 resolved, err := selectableDesktopModelRef(c, ref)
2077 if err != nil {
2078 return err
2079 }
2080 ref = resolved
2081 }
2082 c.Agent.SubagentModel = ref
2083 return nil
2084 })
2085 }
2086
2087 func selectableDesktopModelRef(c *config.Config, ref string) (string, error) {
2088 entry, ok := c.ResolveModel(ref)
2089 if !ok {
2090 return "", fmt.Errorf("unknown model %q", ref)
2091 }
2092 if !modelProviderAccessAllowed(c.Desktop.ProviderAccess, entry.Name) {
2093 return "", fmt.Errorf("model %q is not available because provider %q is not added", ref, entry.Name)
2094 }
2095 if !entry.Configured() {
2096 return "", fmt.Errorf("model %q is not available because provider %q has no key", ref, entry.Name)
2097 }
2098 return entry.Name + "/" + entry.Model, nil
2099 }
2100
2101 // SetSubagentEffort sets (or clears) the default effort used by subagent entry points.
2102 func (a *App) SetSubagentEffort(level string) error {
2103 return a.applyConfigChange(func(c *config.Config) error {
2104 level = strings.TrimSpace(level)
2105 if level == "" || level == "auto" {
2106 c.Agent.SubagentEffort = ""
2107 return nil
2108 }
2109 model := strings.TrimSpace(c.Agent.SubagentModel)
2110 if model == "" {
2111 model = c.DefaultModel
2112 }
2113 entry, ok := c.ResolveModel(model)
2114 if !ok {
2115 return fmt.Errorf("unknown subagent model %q", model)
2116 }
2117 effort, err := config.NormalizeEffort(entry, level)
2118 if err != nil {
2119 return err
2120 }
2121 c.Agent.SubagentEffort = effort
2122 return nil
2123 })
2124 }
2125
2126 // deleteSubagentOverrideAliases removes every underscore/hyphen alias entry
2127 // for name (boot.SubagentModelKeys — the same key set runtime dispatch
2128 // reads). Deleting only the exact key would leave a legacy alias entry (e.g.
2129 // `security_review` for the security-review skill) silently active.
2130 func deleteSubagentOverrideAliases(overrides map[string]string, name string) {
2131 for _, key := range boot.SubagentModelKeys(name) {
2132 delete(overrides, key)
2133 }
2134 }
2135
2136 // SetSubagentProfileModel sets (or clears) a per-name model override for a
2137 // subagent — the only way to influence a built-in subagent's model in the
2138 // Subagents settings page, since built-ins have no editable frontmatter file
2139 // to carry a `model:` line. Writes into the same cfg.Agent.SubagentModels map
2140 // internal/boot's subagentModelRef already reads at dispatch time. Set and
2141 // clear both sweep the underscore/hyphen alias keys so a legacy alias entry
2142 // can neither shadow the new value nor survive a clear.
2143 func (a *App) SetSubagentProfileModel(name, ref string) error {
2144 name = strings.TrimSpace(name)
2145 if name == "" {
2146 return fmt.Errorf("name is required")
2147 }
2148 return a.applyConfigChange(func(c *config.Config) error {
2149 ref = strings.TrimSpace(ref)
2150 if ref == "" {
2151 deleteSubagentOverrideAliases(c.Agent.SubagentModels, name)
2152 return nil
2153 }
2154 resolved, err := selectableDesktopModelRef(c, ref)
2155 if err != nil {
2156 return err
2157 }
2158 if c.Agent.SubagentModels == nil {
2159 c.Agent.SubagentModels = map[string]string{}
2160 }
2161 deleteSubagentOverrideAliases(c.Agent.SubagentModels, name)
2162 c.Agent.SubagentModels[name] = resolved
2163 return nil
2164 })
2165 }
2166
2167 // SetSubagentProfileEffort sets (or clears) a per-name effort override. See
2168 // SetSubagentProfileModel.
2169 func (a *App) SetSubagentProfileEffort(name, level string) error {
2170 name = strings.TrimSpace(name)
2171 if name == "" {
2172 return fmt.Errorf("name is required")
2173 }
2174 return a.applyConfigChange(func(c *config.Config) error {
2175 level = strings.TrimSpace(level)
2176 if level == "" || level == "auto" {
2177 deleteSubagentOverrideAliases(c.Agent.SubagentEfforts, name)
2178 return nil
2179 }
2180 // Validate against the model the override will actually apply to:
2181 // the alias-aware per-name model override first, then the global
2182 // subagent default, then the session default.
2183 model := subagentOverrideFor(c.Agent.SubagentModels, name)
2184 if model == "" {
2185 model = strings.TrimSpace(c.Agent.SubagentModel)
2186 }
2187 if model == "" {
2188 model = c.DefaultModel
2189 }
2190 entry, ok := c.ResolveModel(model)
2191 if !ok {
2192 return fmt.Errorf("unknown subagent model %q", model)
2193 }
2194 effort, err := config.NormalizeEffort(entry, level)
2195 if err != nil {
2196 return err
2197 }
2198 if c.Agent.SubagentEfforts == nil {
2199 c.Agent.SubagentEfforts = map[string]string{}
2200 }
2201 deleteSubagentOverrideAliases(c.Agent.SubagentEfforts, name)
2202 c.Agent.SubagentEfforts[name] = effort
2203 return nil
2204 })
2205 }
2206
2207 func desktopMaxSubagentDepth(depth int) int {
2208 if depth <= 0 {
2209 return agent.DefaultMaxSubagentDepth
2210 }
2211 if depth == 1 {
2212 return 1
2213 }
2214 return agent.DefaultMaxSubagentDepth
2215 }
2216
2217 // SetMaxSubagentDepth controls whether first-layer subagents may delegate once more.
2218 func (a *App) SetMaxSubagentDepth(depth int) error {
2219 return a.applyConfigChange(func(c *config.Config) error {
2220 c.Agent.MaxSubagentDepth = desktopMaxSubagentDepth(depth)
2221 return nil
2222 })
2223 }
2224
2225 func desktopSubagentConcurrency(n int) int {
2226 total, _ := agent.NormalizeConcurrencyLimits(n, 0)
2227 return total
2228 }
2229
2230 func desktopParallelWriters(writers, total int) int {
2231 _, w := agent.NormalizeConcurrencyLimits(total, writers)
2232 return w
2233 }
2234
2235 // SetMaxSubagentConcurrency sets the session-wide sub-agent concurrency cap (1–32).
2236 func (a *App) SetMaxSubagentConcurrency(n int) error {
2237 return a.applyConfigChange(func(c *config.Config) error {
2238 total, writers := agent.NormalizeConcurrencyLimits(n, c.Agent.MaxParallelWriters)
2239 c.Agent.MaxSubagentConcurrency = total
2240 c.Agent.MaxParallelWriters = writers
2241 return nil
2242 })
2243 }
2244
2245 // SetMaxParallelWriters sets the concurrent writer cap (1–32, ≤ total concurrency).
2246 func (a *App) SetMaxParallelWriters(n int) error {
2247 return a.applyConfigChange(func(c *config.Config) error {
2248 total, writers := agent.NormalizeConcurrencyLimits(c.Agent.MaxSubagentConcurrency, n)
2249 c.Agent.MaxSubagentConcurrency = total
2250 c.Agent.MaxParallelWriters = writers
2251 return nil
2252 })
2253 }
2254
2255 // SetAutoPlan is retained for older frontend bundles. Automatic plan mode is
2256 // retired, so "off" is an idempotent compatibility call and enabling it is
2257 // rejected without mutating user configuration or live controllers.
2258 func (a *App) SetAutoPlan(mode string) error {
2259 return config.Default().SetAutoPlan(mode)
2260 }
2261
2262 // SetDefaultToolApprovalMode updates the global Ask/Auto/YOLO default used only
2263 // for newly-created desktop sessions. Existing tabs keep their persisted mode.
2264 func (a *App) SetDefaultToolApprovalMode(mode string) error {
2265 return a.applyConfigOnly(func(c *config.Config) error {
2266 return c.SetDesktopDefaultToolApprovalMode(mode)
2267 })
2268 }
2269
2270 // SetDefaultAutoRecoveryCheckpoint is retained as a no-op Wails surface for
2271 // older generated frontends. Auto Guard is always built into Auto.
2272 func (a *App) SetDefaultAutoRecoveryCheckpoint(_ bool) error { return nil }
2273
2274 func officialProviderTemplate(kind, pricingLanguage string) ([]config.ProviderEntry, string, error) {
2275 switch strings.ToLower(strings.TrimSpace(kind)) {
2276 case "deepseek", "deepseek-official":
2277 return []config.ProviderEntry{{
2278 Name: "deepseek",
2279 Kind: "openai",
2280 BaseURL: "https://api.deepseek.com",
2281 Models: []string{"deepseek-v4-flash", "deepseek-v4-pro"},
2282 Default: "deepseek-v4-flash",
2283 APIKeyEnv: "DEEPSEEK_API_KEY",
2284 BalanceURL: "https://api.deepseek.com/user/balance",
2285 ContextWindow: 1_000_000,
2286 Prices: config.DeepSeekV4PricesForLanguage(pricingLanguage),
2287 }}, "DEEPSEEK_API_KEY", nil
2288 default:
2289 return nil, "", fmt.Errorf("unknown official provider template %q", kind)
2290 }
2291 }
2292
2293 func chatProviderModels(models []string) []string {
2294 out := make([]string, 0, len(models))
2295 seen := map[string]bool{}
2296 for _, model := range models {
2297 model = strings.TrimSpace(model)
2298 if model == "" || seen[model] || !config.IsLikelyChatModel(model) {
2299 continue
2300 }
2301 seen[model] = true
2302 out = append(out, model)
2303 }
2304 return out
2305 }
2306
2307 func providerVisionModels(models, visionModels []string) []string {
2308 enabled := map[string]bool{}
2309 for _, model := range models {
2310 enabled[model] = true
2311 }
2312 out := make([]string, 0, len(visionModels))
2313 for _, model := range chatProviderModels(visionModels) {
2314 if enabled[model] {
2315 out = append(out, model)
2316 }
2317 }
2318 return out
2319 }
2320
2321 func providerDefaultForModels(currentDefault string, models []string) string {
2322 currentDefault = strings.TrimSpace(currentDefault)
2323 if currentDefault != "" {
2324 for _, model := range models {
2325 if model == currentDefault {
2326 return currentDefault
2327 }
2328 }
2329 }
2330 if len(models) > 0 {
2331 return models[0]
2332 }
2333 return ""
2334 }
2335
2336 func saveProviderConfig(c *config.Config, p ProviderView) error {
2337 if c == nil {
2338 return fmt.Errorf("config is nil")
2339 }
2340 e := config.ProviderEntry{Name: p.Name}
2341 for i := range c.Providers {
2342 if c.Providers[i].Name == p.Name {
2343 e = c.Providers[i]
2344 break
2345 }
2346 }
2347 e.Name = p.Name
2348 e.Kind = p.Kind
2349 e.BaseURL = p.BaseURL
2350 e.ChatURL = strings.TrimSpace(p.ChatURL)
2351 e.ModelsURL = strings.TrimSpace(p.ModelsURL)
2352 e.APIKeyEnv = p.APIKeyEnv
2353 e.Headers = p.Headers
2354 e.ExtraBody = p.ExtraBody
2355 e.AuthHeader = p.AuthHeader
2356 e.BalanceURL = strings.TrimSpace(p.BalanceURL)
2357 e.ContextWindow = p.ContextWindow
2358 e.ReasoningProtocol = p.ReasoningProtocol
2359 e.Thinking = providerThinkingForSettings(p.Thinking)
2360 if config.SupportsServerWebSearch(&e) {
2361 enabled := p.WebSearch
2362 e.WebSearch = &enabled
2363 } else {
2364 e.WebSearch = nil
2365 }
2366 e.SupportedEfforts = p.SupportedEfforts
2367 e.DefaultEffort = p.DefaultEffort
2368 e.Model = ""
2369 e.Models = nil
2370 e.Default = ""
2371 e.VisionModels = nil
2372 models := chatProviderModels(p.Models)
2373 if len(models) > 0 {
2374 e.Model = models[0] // also satisfies validateProvider's model requirement
2375 e.Models = models
2376 e.ModelOverrides = providerModelOverridesForSave(p.ModelOverrides, models)
2377 if p.VisionModelsSet || len(p.VisionModels) > 0 {
2378 e.Vision = false
2379 e.VisionModels = providerVisionModels(models, p.VisionModels)
2380 }
2381 if len(models) > 1 {
2382 e.Default = providerDefaultForModels(p.Default, models)
2383 }
2384 } else {
2385 e.Vision = false
2386 e.VisionModels = nil
2387 e.ModelOverrides = nil
2388 }
2389 if err := c.UpsertProvider(e); err != nil {
2390 return err
2391 }
2392 addProviderAccess(c, p.Name)
2393 return nil
2394 }
2395
2396 // SaveProvider adds or updates a provider. Enabled models are persisted through
2397 // `models` even when only one model is selected, while `model` remains populated
2398 // in-memory for validation/back-compat. The shared key/endpoint live on the entry.
2399 func (a *App) SaveProvider(p ProviderView) error {
2400 return a.applyConfigChange(func(c *config.Config) error {
2401 return saveProviderConfig(c, p)
2402 })
2403 }
2404
2405 func providerModelOverridesForCatalog(overrides map[string]config.ProviderModelOverride, models []string) map[string]config.ProviderModelOverride {
2406 if len(overrides) == 0 {
2407 return nil
2408 }
2409 allowed := make(map[string]bool, len(models))
2410 for _, model := range models {
2411 allowed[model] = true
2412 }
2413 filtered := make(map[string]config.ProviderModelOverride, len(overrides))
2414 for model, override := range overrides {
2415 if allowed[model] {
2416 filtered[model] = override
2417 }
2418 }
2419 if len(filtered) == 0 {
2420 return nil
2421 }
2422 return filtered
2423 }
2424
2425 func applyProviderModelCatalogUpdate(c *config.Config, update ProviderModelCatalogUpdate, credentialsRevision string) (bool, error) {
2426 if c == nil {
2427 return false, fmt.Errorf("config is nil")
2428 }
2429 current, ok := c.Provider(strings.TrimSpace(update.Name))
2430 if !ok || strings.TrimSpace(update.ExpectedFingerprint) == "" ||
2431 providerModelCatalogFingerprintForCredentials(*current, credentialsRevision) != strings.TrimSpace(update.ExpectedFingerprint) {
2432 return false, nil
2433 }
2434 models := chatProviderModels(update.Models)
2435 if len(models) == 0 {
2436 return false, fmt.Errorf("provider %q model catalog is empty", update.Name)
2437 }
2438
2439 next := *current
2440 visionConfigured := next.Vision || next.VisionModels != nil
2441 next.Model = models[0] // keep validation/back-compat populated
2442 next.Models = models
2443 next.Default = ""
2444 if len(models) > 1 {
2445 next.Default = providerDefaultForModels(update.Default, models)
2446 }
2447 next.Vision = false
2448 if visionConfigured {
2449 next.VisionModels = providerVisionModels(models, update.VisionModels)
2450 } else {
2451 next.VisionModels = nil
2452 }
2453 next.ModelOverrides = providerModelOverridesForCatalog(next.ModelOverrides, models)
2454 if config.ProviderEntriesConfigEqual(*current, next) {
2455 return false, nil
2456 }
2457 if err := c.UpsertProvider(next); err != nil {
2458 return false, err
2459 }
2460 return true, nil
2461 }
2462
2463 // SaveProviderModelCatalogs applies only model-catalog fields. Each update is
2464 // compared against the provider snapshot that launched discovery while the
2465 // config edit lock is held, so an older async completion cannot overwrite newer
2466 // provider edits. Stale updates are skipped rather than treated as failures.
2467 func (a *App) SaveProviderModelCatalogs(updates []ProviderModelCatalogUpdate) ([]string, error) {
2468 if len(updates) == 0 {
2469 return []string{}, nil
2470 }
2471 if err := a.ensureActiveTabRebuildAllowed("provider model catalogs"); err != nil {
2472 return []string{}, err
2473 }
2474 applied := make([]string, 0, len(updates))
2475 if err := func() error {
2476 unlock := config.LockUserConfigEdits()
2477 defer unlock()
2478 cfg, path, err := a.loadDesktopUserConfigForEdit()
2479 if err != nil {
2480 return err
2481 }
2482 observedCredentialsRevision := providerCredentialsRevision()
2483 if a.providerCatalogBeforeCredentialLockHook != nil {
2484 a.providerCatalogBeforeCredentialLockHook(observedCredentialsRevision)
2485 }
2486 unlockCredentials, err := config.LockUserCredentialEdits()
2487 if err != nil {
2488 return err
2489 }
2490 defer unlockCredentials()
2491 // Re-read while holding the same lock as every Reasonix credential
2492 // writer, then keep that lock through the config commit. A rotation that
2493 // won the race therefore invalidates the request fingerprint.
2494 credentialsRevision := providerCredentialsRevision()
2495 for _, update := range updates {
2496 changed, err := applyProviderModelCatalogUpdate(cfg, update, credentialsRevision)
2497 if err != nil {
2498 return err
2499 }
2500 if changed {
2501 applied = append(applied, strings.TrimSpace(update.Name))
2502 }
2503 }
2504 if len(applied) == 0 {
2505 return nil
2506 }
2507 return cfg.SaveTo(path)
2508 }(); err != nil {
2509 return []string{}, err
2510 }
2511 if len(applied) == 0 {
2512 return applied, nil
2513 }
2514 if err := a.rebuildSetting("provider model catalogs"); err != nil {
2515 if _, ok := a.deferredRebuildWarning("provider model catalogs", err); ok {
2516 return applied, nil
2517 }
2518 return []string{}, err
2519 }
2520 return applied, nil
2521 }
2522
2523 // SaveProviderWithKey saves a custom provider and its credential as one settings
2524 // transaction, then rebuilds once after both are visible to the runtime.
2525 func (a *App) SaveProviderWithKey(p ProviderView, key string) (string, error) {
2526 apiKeyEnv := strings.TrimSpace(p.APIKeyEnv)
2527 if apiKeyEnv == "" {
2528 return "", fmt.Errorf("this provider has no api_key_env set")
2529 }
2530 if err := a.ensureActiveTabRebuildAllowed("provider"); err != nil {
2531 return "", err
2532 }
2533 warning, err := a.saveProviderCredential(apiKeyEnv, key)
2534 if err != nil {
2535 return "", err
2536 }
2537 if err := func() error {
2538 unlock := config.LockUserConfigEdits()
2539 defer unlock()
2540 cfg, path, err := a.loadDesktopUserConfigForEdit()
2541 if err != nil {
2542 return err
2543 }
2544 if err := saveProviderConfig(cfg, p); err != nil {
2545 return err
2546 }
2547 return cfg.SaveTo(path)
2548 }(); err != nil {
2549 return "", err
2550 }
2551 if err := a.rebuildSetting("provider"); err != nil {
2552 if rebuildWarning, ok := a.deferredRebuildWarning("provider", err); ok {
2553 return appendSettingsWarning(warning, rebuildWarning), nil
2554 }
2555 return "", err
2556 }
2557 return warning, nil
2558 }
2559
2560 // AddOfficialProviderAccess adds one curated desktop provider template to the
2561 // Settings > Model > Access list. The runtime default providers still exist
2562 // independently; this only records the user's explicit access setup.
2563 func (a *App) AddOfficialProviderAccess(kind, key string) (string, error) {
2564 // Read-only pre-read (pricing language); the actual write happens inside
2565 // applyConfigChange below, under the config edit lock.
2566 cfg, _, err := a.loadDesktopUserConfigForView()
2567 if err != nil {
2568 return "", err
2569 }
2570 entries, keyEnv, err := officialProviderTemplate(kind, cfg.DeepSeekOfficialPricingLanguage())
2571 if err != nil {
2572 return "", err
2573 }
2574 if err := a.ensureActiveTabRebuildAllowed("provider access"); err != nil {
2575 return "", err
2576 }
2577 keyWarning := ""
2578 if strings.TrimSpace(key) != "" && keyEnv != "" {
2579 var err error
2580 keyWarning, err = a.saveProviderCredential(keyEnv, key)
2581 if err != nil {
2582 return "", err
2583 }
2584 }
2585 rebuildWarning, err := a.applyConfigChangeWithWarning("provider access", func(c *config.Config) error {
2586 names := make([]string, 0, len(entries))
2587 for _, e := range entries {
2588 if err := c.UpsertProvider(e); err != nil {
2589 return err
2590 }
2591 names = append(names, e.Name)
2592 }
2593 addProviderAccess(c, names...)
2594 return nil
2595 })
2596 if err != nil {
2597 return "", err
2598 }
2599 return appendSettingsWarning(keyWarning, rebuildWarning), nil
2600 }
2601
2602 // AddProviderPresetAccess installs one editable custom-provider preset. Unlike
2603 // official built-ins, these entries are saved as normal providers so users can
2604 // tweak endpoints, model lists, and capability overrides after the one-click
2605 // setup path.
2606 func (a *App) AddProviderPresetAccess(id, key string) (string, error) {
2607 preset, ok := config.CuratedProviderPreset(id)
2608 if !ok {
2609 return "", fmt.Errorf("unknown provider preset %q", id)
2610 }
2611 if len(preset.Entries) == 0 {
2612 return "", fmt.Errorf("provider preset %q has no provider entries", id)
2613 }
2614 if err := a.ensureActiveTabRebuildAllowed("provider access"); err != nil {
2615 return "", err
2616 }
2617 // Read-only duplicate-name pre-check; applyConfigChange re-checks under the
2618 // config edit lock before writing.
2619 cfg, _, err := a.loadDesktopUserConfigForView()
2620 if err != nil {
2621 return "", err
2622 }
2623 if existing := existingProviderNames(cfg, preset.Entries); len(existing) > 0 {
2624 return "", providerPresetAlreadyAddedError(preset.ID, existing)
2625 }
2626 keyEnv := strings.TrimSpace(preset.KeyEnv)
2627 if keyEnv == "" {
2628 for _, e := range preset.Entries {
2629 if keyEnv = strings.TrimSpace(e.APIKeyEnv); keyEnv != "" {
2630 break
2631 }
2632 }
2633 }
2634 keyWarning := ""
2635 if strings.TrimSpace(key) != "" && keyEnv != "" {
2636 var err error
2637 keyWarning, err = a.saveProviderCredential(keyEnv, key)
2638 if err != nil {
2639 return "", err
2640 }
2641 }
2642 rebuildWarning, err := a.applyConfigChangeWithWarning("provider access", func(c *config.Config) error {
2643 if existing := existingProviderNames(c, preset.Entries); len(existing) > 0 {
2644 return providerPresetAlreadyAddedError(preset.ID, existing)
2645 }
2646 names := make([]string, 0, len(preset.Entries))
2647 for _, e := range preset.Entries {
2648 if err := c.UpsertProvider(e); err != nil {
2649 return err
2650 }
2651 names = append(names, e.Name)
2652 }
2653 addProviderAccess(c, names...)
2654 return nil
2655 })
2656 if err != nil {
2657 return "", err
2658 }
2659 return appendSettingsWarning(keyWarning, rebuildWarning), nil
2660 }
2661
2662 // ResetProviderPresetAccess intentionally overwrites same-name provider entries
2663 // with the curated preset template. It only mutates config; provider secrets stay
2664 // in Reasonix home .env under whichever api_key_env the resulting preset uses.
2665 func (a *App) ResetProviderPresetAccess(id string) error {
2666 preset, ok := config.CuratedProviderPreset(id)
2667 if !ok {
2668 return fmt.Errorf("unknown provider preset %q", id)
2669 }
2670 if len(preset.Entries) == 0 {
2671 return fmt.Errorf("provider preset %q has no provider entries", id)
2672 }
2673 if err := a.ensureActiveTabRebuildAllowed("provider access"); err != nil {
2674 return err
2675 }
2676 // Read-only existence pre-check; applyConfigChange re-checks under the
2677 // config edit lock before writing.
2678 cfg, _, err := a.loadDesktopUserConfigForView()
2679 if err != nil {
2680 return err
2681 }
2682 if existing := existingProviderNames(cfg, preset.Entries); len(existing) == 0 {
2683 return providerPresetNoExistingProviderError(preset.ID)
2684 }
2685 return a.applyConfigChange(func(c *config.Config) error {
2686 if existing := existingProviderNames(c, preset.Entries); len(existing) == 0 {
2687 return providerPresetNoExistingProviderError(preset.ID)
2688 }
2689 names := make([]string, 0, len(preset.Entries))
2690 for _, e := range preset.Entries {
2691 if err := c.UpsertProvider(e); err != nil {
2692 return err
2693 }
2694 names = append(names, e.Name)
2695 }
2696 addProviderAccess(c, names...)
2697 return nil
2698 })
2699 }
2700
2701 func existingProviderNames(c *config.Config, entries []config.ProviderEntry) []string {
2702 if c == nil || len(entries) == 0 {
2703 return nil
2704 }
2705 names := make([]string, 0, len(entries))
2706 for _, entry := range entries {
2707 name := strings.TrimSpace(entry.Name)
2708 if name == "" {
2709 continue
2710 }
2711 if _, ok := c.Provider(name); ok {
2712 names = append(names, name)
2713 }
2714 }
2715 return names
2716 }
2717
2718 func providerPresetAlreadyAddedError(id string, names []string) error {
2719 return fmt.Errorf("provider preset %q cannot be added because provider name(s) already exist: %s; edit, rename, or remove the existing provider before adding it again", id, strings.Join(names, ", "))
2720 }
2721
2722 func providerPresetNoExistingProviderError(id string) error {
2723 return fmt.Errorf("provider preset %q cannot be reset because no same-name provider exists; add the preset instead", id)
2724 }
2725
2726 // FetchProviderModels probes the provider's OpenAI-compatible model-list
2727 // endpoint and returns the available model IDs. This is a settings-only helper:
2728 // it never touches chat request serialization or provider-visible prompt data.
2729 func (a *App) FetchProviderModels(p ProviderView) ([]string, error) {
2730 e := config.ProviderEntry{
2731 Name: p.Name,
2732 Kind: p.Kind,
2733 BaseURL: p.BaseURL,
2734 ModelsURL: strings.TrimSpace(p.ModelsURL),
2735 APIKeyEnv: p.APIKeyEnv,
2736 Headers: p.Headers,
2737 AuthHeader: p.AuthHeader,
2738 }
2739 e.ResolveAPIKeyForRoot(a.activeWorkspaceRoot())
2740 ctx, cancel := context.WithTimeout(a.reqCtx(), 15*time.Second)
2741 defer cancel()
2742 models, err := e.FetchModels(ctx)
2743 if err != nil {
2744 return []string{}, err
2745 }
2746 return nonNil(chatProviderModels(models)), nil
2747 }
2748
2749 // FetchAllProviderModels fetches model lists for all providers in a single
2750 // batch. Models are fetched concurrently (up to 4 parallel requests) and
2751 // returned as a map keyed by provider name. Errors for individual providers
2752 // are recorded as nil entries; callers should handle missing keys.
2753 func (a *App) FetchAllProviderModels(providers []ProviderView) map[string][]string {
2754 results := make(map[string][]string, len(providers))
2755 var mu sync.Mutex
2756 g, ctx := errgroup.WithContext(a.reqCtx())
2757 g.SetLimit(4)
2758 root := a.activeWorkspaceRoot()
2759 for i := range providers {
2760 p := providers[i]
2761 g.Go(func() error {
2762 e := config.ProviderEntry{
2763 Name: p.Name,
2764 Kind: p.Kind,
2765 BaseURL: p.BaseURL,
2766 ModelsURL: strings.TrimSpace(p.ModelsURL),
2767 APIKeyEnv: p.APIKeyEnv,
2768 Headers: p.Headers,
2769 AuthHeader: p.AuthHeader,
2770 }
2771 e.ResolveAPIKeyForRoot(root)
2772 ctx, cancel := context.WithTimeout(ctx, 15*time.Second)
2773 defer cancel()
2774 models, err := e.FetchModels(ctx)
2775 if err != nil {
2776 // Omit failed providers so the frontend can retry them through
2777 // the cached single-provider path without emitting JSON null.
2778 return nil
2779 }
2780 mu.Lock()
2781 defer mu.Unlock()
2782 results[p.Name] = nonNil(chatProviderModels(models))
2783 return nil
2784 })
2785 }
2786 _ = g.Wait()
2787 return results
2788 }
2789
2790 // DeleteProvider removes a provider and retargets open idle tabs that used it.
2791 func (a *App) DeleteProvider(name string) error {
2792 return a.deleteProviderAndRetargetTabs(name)
2793 }
2794
2795 // RemoveProviderAccess hides a provider from Settings > Model > Access and from
2796 // settings model pickers. Built-in provider entries remain in the runtime config
2797 // for back-compat, but visible defaults and idle tabs are retargeted away from
2798 // the removed access entry when another accessed provider is available. Custom
2799 // providers are deleted outright.
2800 func (a *App) RemoveProviderAccess(name string) error {
2801 name = strings.TrimSpace(name)
2802 if name == "" {
2803 return fmt.Errorf("remove provider access: empty provider name")
2804 }
2805 // Read-only dispatch check (built-in vs custom); the removal paths below
2806 // reload and write under the config edit lock.
2807 cfg, _, err := a.loadDesktopUserConfigForView()
2808 if err != nil {
2809 return err
2810 }
2811 if p, ok := cfg.Provider(name); ok && isOfficialBuiltInProvider(*p) {
2812 return a.removeBuiltInProviderAccessAndRetargetTabs(name)
2813 }
2814 return a.deleteProviderAndRetargetTabs(name)
2815 }
2816
2817 type providerRemovalTab struct {
2818 id string
2819 ctrl control.SessionAPI
2820 readOnly bool
2821 }
2822
2823 func providerAccessFallbackRef(c *config.Config, name string) string {
2824 name = strings.TrimSpace(name)
2825 for _, candidate := range c.Desktop.ProviderAccess {
2826 candidate = strings.TrimSpace(candidate)
2827 if candidate == "" || candidate == name {
2828 continue
2829 }
2830 p, ok := c.Provider(candidate)
2831 if !ok || len(p.ModelList()) == 0 {
2832 continue
2833 }
2834 return p.Name + "/" + p.DefaultModel()
2835 }
2836 return ""
2837 }
2838
2839 func retargetProviderReferences(c *config.Config, name, fallbackRef string) {
2840 if strings.TrimSpace(fallbackRef) == "" {
2841 return
2842 }
2843 if desktopModelRefsProvider(c, c.DefaultModel, name) {
2844 c.DefaultModel = fallbackRef
2845 }
2846 if desktopModelRefsProvider(c, c.Agent.PlannerModel, name) {
2847 c.Agent.PlannerModel = fallbackRef
2848 }
2849 if desktopModelRefsProvider(c, c.Agent.SubagentModel, name) {
2850 c.Agent.SubagentModel = fallbackRef
2851 }
2852 for skill, ref := range c.Agent.SubagentModels {
2853 if desktopModelRefsProvider(c, ref, name) {
2854 c.Agent.SubagentModels[skill] = fallbackRef
2855 }
2856 }
2857 }
2858
2859 func (a *App) removeBuiltInProviderAccessAndRetargetTabs(name string) error {
2860 defer a.lockRuntimeMutation("remove-provider-access")()
2861 releaseGates, err := a.lockRuntimeTurnGates("provider access", nil)
2862 if err != nil {
2863 return err
2864 }
2865 defer releaseGates()
2866
2867 // This first load is a read-only planning copy (fallback ref + affected-tab
2868 // scan); it loads credentials because the fallback choice depends on which
2869 // providers resolve a key. The saved edit below reloads under the config
2870 // edit lock so the slow snapshot work in between cannot widen the
2871 // read-modify-write window.
2872 cfg, _, err := a.loadDesktopUserConfigForViewWithCredentials()
2873 if err != nil {
2874 return err
2875 }
2876 fallbackRef := providerAccessFallbackRef(cfg, name)
2877
2878 var affected []providerRemovalTab
2879 if fallbackRef != "" {
2880 a.mu.RLock()
2881 for _, id := range a.orderedTabIDsLocked() {
2882 tab := a.tabs[id]
2883 if tab == nil {
2884 continue
2885 }
2886 ref := tab.model
2887 if strings.TrimSpace(ref) == "" {
2888 ref = cfg.DefaultModel
2889 }
2890 if !desktopModelRefsProvider(cfg, ref, name) {
2891 continue
2892 }
2893 if controllerHasActiveRuntimeWork(tab.Ctrl) {
2894 a.mu.RUnlock()
2895 return fmt.Errorf("finish or cancel active work using %q before removing the provider access", name)
2896 }
2897 affected = append(affected, providerRemovalTab{id: id, ctrl: tab.Ctrl, readOnly: tab.ReadOnly})
2898 }
2899 a.mu.RUnlock()
2900 }
2901
2902 if len(affected) == 0 {
2903 if err := a.ensureActiveTabRebuildAllowed("provider access"); err != nil {
2904 return err
2905 }
2906 }
2907 for _, item := range affected {
2908 if item.ctrl != nil && !item.readOnly {
2909 if err := item.ctrl.Snapshot(); err != nil {
2910 slog.Warn("desktop: snapshot before removing provider access failed", "tab", item.id, "provider", name, "err", err)
2911 return fmt.Errorf("save current session before removing provider access: %w", err)
2912 }
2913 }
2914 }
2915 // Reload-modify-save under the config edit lock: the pre-save snapshots
2916 // above are slow and must not hold the lock, so mutate a fresh copy here
2917 // instead of the stale planning copy loaded before them.
2918 if err := func() error {
2919 unlock := config.LockUserConfigEdits()
2920 defer unlock()
2921 fresh, path, err := a.loadDesktopUserConfigForEdit()
2922 if err != nil {
2923 return err
2924 }
2925 retargetProviderReferences(fresh, name, fallbackRef)
2926 removeProviderAccess(fresh, name)
2927 return fresh.SaveTo(path)
2928 }(); err != nil {
2929 return err
2930 }
2931 if len(affected) == 0 {
2932 if err := a.rebuildActiveSettingRuntimeMutationLocked("provider access"); err != nil {
2933 if _, ok := a.deferredRebuildWarning("provider access", err); ok {
2934 return nil
2935 }
2936 return err
2937 }
2938 return nil
2939 }
2940 for _, item := range affected {
2941 if item.ctrl != nil {
2942 item.ctrl.Close()
2943 }
2944 }
2945
2946 var rebuildTabs []*WorkspaceTab
2947 var releasedHostKeys []string
2948 a.mu.Lock()
2949 for _, item := range affected {
2950 tab := a.tabs[item.id]
2951 if tab == nil {
2952 continue
2953 }
2954 if tab.Ctrl != item.ctrl {
2955 // The tab swapped controllers while we worked off-lock; nil-ing the
2956 // replacement would leak it. Leave the new runtime alone.
2957 continue
2958 }
2959 tab.Ctrl = nil
2960 if key := takeTabSharedHostKey(tab); key != "" {
2961 releasedHostKeys = append(releasedHostKeys, key)
2962 }
2963 // Supersede any in-flight startup build: it was planned against the
2964 // removed provider and would otherwise finish later, pass its
2965 // generation check, and reinstall a controller for it.
2966 a.supersedeTabBuildLocked(tab)
2967 tab.model = fallbackRef
2968 tab.Label = fallbackRef
2969 clearTabStartupError(tab)
2970 tab.Ready = a.ctx == nil
2971 if a.ctx != nil {
2972 a.setSessionRuntimePhaseLocked(tab, sessionRuntimeStarting, nil)
2973 } else {
2974 a.setSessionRuntimePhaseLocked(tab, sessionRuntimeFailed, fmt.Errorf("desktop runtime is not started"))
2975 }
2976 if a.ctx != nil {
2977 rebuildTabs = append(rebuildTabs, tab)
2978 }
2979 }
2980 a.saveTabsLocked()
2981 a.mu.Unlock()
2982 for _, key := range releasedHostKeys {
2983 a.releaseSharedHost(key)
2984 }
2985
2986 for _, tab := range rebuildTabs {
2987 go a.buildTabController(tab)
2988 }
2989 return nil
2990 }
2991
2992 func (a *App) deleteProviderAndRetargetTabs(name string) error {
2993 name = strings.TrimSpace(name)
2994 if name == "" {
2995 return fmt.Errorf("remove provider: empty provider name")
2996 }
2997 defer a.lockRuntimeMutation("delete-provider")()
2998 releaseGates, err := a.lockRuntimeTurnGates("provider", nil)
2999 if err != nil {
3000 return err
3001 }
3002 defer releaseGates()
3003
3004 // Read-only planning copy (with credentials — the fallback choice depends
3005 // on which providers resolve a key); the saved edit below reloads under the
3006 // config edit lock (see removeBuiltInProviderAccessAndRetargetTabs).
3007 cfg, _, err := a.loadDesktopUserConfigForViewWithCredentials()
3008 if err != nil {
3009 return err
3010 }
3011 fallbackRef := providerRemovalFallbackRef(cfg, name)
3012
3013 var affected []providerRemovalTab
3014 a.mu.RLock()
3015 for _, id := range a.orderedTabIDsLocked() {
3016 tab := a.tabs[id]
3017 if tab == nil {
3018 continue
3019 }
3020 ref := tab.model
3021 if strings.TrimSpace(ref) == "" {
3022 ref = cfg.DefaultModel
3023 }
3024 if !desktopModelRefsProvider(cfg, ref, name) {
3025 continue
3026 }
3027 if controllerHasActiveRuntimeWork(tab.Ctrl) {
3028 a.mu.RUnlock()
3029 return fmt.Errorf("finish or cancel active work using %q before deleting the provider", name)
3030 }
3031 affected = append(affected, providerRemovalTab{id: id, ctrl: tab.Ctrl, readOnly: tab.ReadOnly})
3032 }
3033 a.mu.RUnlock()
3034
3035 if len(affected) > 0 && fallbackRef == "" {
3036 return fmt.Errorf("remove provider: %q is used by open tabs and no other configured provider exists", name)
3037 }
3038 if len(affected) == 0 {
3039 if err := a.ensureActiveTabRebuildAllowed("provider"); err != nil {
3040 return err
3041 }
3042 }
3043 for _, item := range affected {
3044 if item.ctrl != nil && !item.readOnly {
3045 if err := item.ctrl.Snapshot(); err != nil {
3046 slog.Warn("desktop: snapshot before deleting provider failed", "tab", item.id, "provider", name, "err", err)
3047 return fmt.Errorf("save current session before deleting provider: %w", err)
3048 }
3049 }
3050 }
3051 // Reload-modify-save under the config edit lock; the snapshots above ran
3052 // off-lock against the stale planning copy.
3053 if err := func() error {
3054 unlock := config.LockUserConfigEdits()
3055 defer unlock()
3056 fresh, path, err := a.loadDesktopUserConfigForEdit()
3057 if err != nil {
3058 return err
3059 }
3060 if err := fresh.RemoveProvider(name); err != nil {
3061 return err
3062 }
3063 removeProviderAccess(fresh, name)
3064 return fresh.SaveTo(path)
3065 }(); err != nil {
3066 return err
3067 }
3068
3069 if len(affected) == 0 {
3070 if err := a.rebuildActiveSettingRuntimeMutationLocked("provider"); err != nil {
3071 if _, ok := a.deferredRebuildWarning("provider", err); ok {
3072 return nil
3073 }
3074 return err
3075 }
3076 return nil
3077 }
3078 for _, item := range affected {
3079 if item.ctrl != nil {
3080 item.ctrl.Close()
3081 }
3082 }
3083
3084 var rebuildTabs []*WorkspaceTab
3085 var releasedHostKeys []string
3086 a.mu.Lock()
3087 for _, item := range affected {
3088 tab := a.tabs[item.id]
3089 if tab == nil {
3090 continue
3091 }
3092 if tab.Ctrl != item.ctrl {
3093 // The tab swapped controllers while we worked off-lock; nil-ing the
3094 // replacement would leak it. Leave the new runtime alone.
3095 continue
3096 }
3097 tab.Ctrl = nil
3098 if key := takeTabSharedHostKey(tab); key != "" {
3099 releasedHostKeys = append(releasedHostKeys, key)
3100 }
3101 // Supersede any in-flight startup build: it was planned against the
3102 // removed provider and would otherwise finish later, pass its
3103 // generation check, and reinstall a controller for it.
3104 a.supersedeTabBuildLocked(tab)
3105 tab.model = fallbackRef
3106 tab.Label = fallbackRef
3107 clearTabStartupError(tab)
3108 tab.Ready = a.ctx == nil
3109 if a.ctx != nil {
3110 a.setSessionRuntimePhaseLocked(tab, sessionRuntimeStarting, nil)
3111 } else {
3112 a.setSessionRuntimePhaseLocked(tab, sessionRuntimeFailed, fmt.Errorf("desktop runtime is not started"))
3113 }
3114 if a.ctx != nil {
3115 rebuildTabs = append(rebuildTabs, tab)
3116 }
3117 }
3118 a.saveTabsLocked()
3119 a.mu.Unlock()
3120 for _, key := range releasedHostKeys {
3121 a.releaseSharedHost(key)
3122 }
3123
3124 for _, tab := range rebuildTabs {
3125 go a.buildTabController(tab)
3126 }
3127 return nil
3128 }
3129
3130 // rebuildActiveSettingRuntimeMutationLocked refreshes the active controller
3131 // while lockRuntimeMutation and all runtime turn gates are held.
3132 func (a *App) rebuildActiveSettingRuntimeMutationLocked(setting string) error {
3133 tab := a.activeTab()
3134 if tab == nil {
3135 if a.ctx == nil {
3136 return nil
3137 }
3138 return fmt.Errorf("no active tab")
3139 }
3140 return a.rebuildSettingTurnLocked(setting, tab, true, false)
3141 }
3142
3143 // SetProviderKey writes a secret to Reasonix's global .env under the given
3144 // env-var name (the one a provider's api_key_env points at) and rebuilds so it
3145 // resolves immediately.
3146 func (a *App) SetProviderKey(apiKeyEnv, value string) (string, error) {
3147 if strings.TrimSpace(apiKeyEnv) == "" {
3148 return "", fmt.Errorf("this provider has no api_key_env set")
3149 }
3150 if err := a.ensureActiveTabRebuildAllowed("provider key"); err != nil {
3151 return "", err
3152 }
3153 warning, err := a.saveProviderCredential(apiKeyEnv, value)
3154 if err != nil {
3155 return "", err
3156 }
3157 if err := a.ensureProviderAccessForKey(apiKeyEnv); err != nil {
3158 return "", err
3159 }
3160 if err := a.rebuildSetting("provider key"); err != nil {
3161 if rebuildWarning, ok := a.deferredRebuildWarning("provider key", err); ok {
3162 return appendSettingsWarning(warning, rebuildWarning), nil
3163 }
3164 return "", err
3165 }
3166 return warning, nil
3167 }
3168
3169 // SaveProviderKey writes a provider secret without rebuilding the chat runtime.
3170 // It is used by settings probes that need credentials only for a model-list
3171 // request; explicit "save key" actions still call SetProviderKey.
3172 func (a *App) SaveProviderKey(apiKeyEnv, value string) (string, error) {
3173 if strings.TrimSpace(apiKeyEnv) == "" {
3174 return "", fmt.Errorf("this provider has no api_key_env set")
3175 }
3176 return a.saveProviderCredential(apiKeyEnv, value)
3177 }
3178
3179 func (a *App) ensureProviderAccessForKey(apiKeyEnv string) error {
3180 apiKeyEnv = strings.TrimSpace(apiKeyEnv)
3181 if apiKeyEnv == "" {
3182 return nil
3183 }
3184 // Pure load-modify-save on the user config; the caller (SetProviderKey)
3185 // rebuilds after we return, outside the config edit lock.
3186 unlock := config.LockUserConfigEdits()
3187 defer unlock()
3188 cfg, path, err := a.loadDesktopUserConfigForEdit()
3189 if err != nil {
3190 return err
3191 }
3192 access := providerAccessSet(cfg.Desktop.ProviderAccess)
3193 changed := false
3194 addAccess := func(name string) {
3195 if name == "" || access[name] {
3196 return
3197 }
3198 addProviderAccess(cfg, name)
3199 access[name] = true
3200 changed = true
3201 }
3202 for i := range cfg.Providers {
3203 p := cfg.Providers[i]
3204 if strings.TrimSpace(p.APIKeyEnv) != apiKeyEnv {
3205 continue
3206 }
3207 if len(p.ModelList()) == 0 {
3208 continue
3209 }
3210 if isOfficialBuiltInProvider(p) {
3211 addAccess(config.CanonicalDesktopOfficialProviderName(p.Name))
3212 } else {
3213 addAccess(strings.TrimSpace(p.Name))
3214 }
3215 }
3216 if !changed && apiKeyEnv == "DEEPSEEK_API_KEY" {
3217 entries, _, err := officialProviderTemplate("deepseek", cfg.DeepSeekOfficialPricingLanguage())
3218 if err != nil {
3219 return err
3220 }
3221 for _, e := range entries {
3222 if err := cfg.UpsertProvider(e); err != nil {
3223 return err
3224 }
3225 addAccess(e.Name)
3226 }
3227 }
3228 if !changed {
3229 return nil
3230 }
3231 return cfg.SaveTo(path)
3232 }
3233
3234 // ClearProviderKey removes a provider secret from Reasonix's global .env
3235 // and rebuilds so the provider immediately becomes unauthenticated.
3236 func (a *App) ClearProviderKey(apiKeyEnv string) error {
3237 if strings.TrimSpace(apiKeyEnv) == "" {
3238 return fmt.Errorf("this provider has no api_key_env set")
3239 }
3240 if err := a.ensureActiveTabRebuildAllowed("provider key"); err != nil {
3241 return err
3242 }
3243 if err := removeDotEnv(apiKeyEnv); err != nil {
3244 return err
3245 }
3246 if err := a.rebuildSetting("provider key"); err != nil {
3247 if _, ok := a.deferredRebuildWarning("provider key", err); ok {
3248 return nil
3249 }
3250 return err
3251 }
3252 return nil
3253 }
3254
3255 // SetPermissionMode sets the writer-fallback mode (ask|allow|deny).
3256 func (a *App) SetPermissionMode(mode string) error {
3257 return a.applyConfigChange(func(c *config.Config) error { return c.SetPermissionMode(mode) })
3258 }
3259
3260 // AddPermissionRule appends a rule to the allow/ask/deny list.
3261 func (a *App) AddPermissionRule(list, rule string) error {
3262 return a.applyConfigChange(func(c *config.Config) error { return c.AddPermissionRule(list, rule) })
3263 }
3264
3265 // RemovePermissionRule drops a rule from the allow/ask/deny list.
3266 func (a *App) RemovePermissionRule(list, rule string) error {
3267 return a.applyConfigChange(func(c *config.Config) error {
3268 _, err := c.RemovePermissionRule(list, rule)
3269 return err
3270 })
3271 }
3272
3273 // ReloadSettings rebuilds the active controller from the current config without
3274 // changing any config file. It lets manual config.toml edits take effect.
3275 func (a *App) ReloadSettings() error {
3276 if err := a.ensureActiveTabRebuildAllowed("settings"); err != nil {
3277 return err
3278 }
3279 if err := a.rebuild(); err != nil {
3280 // The on-disk config already diverged from the runtime; retry the
3281 // refresh once the other window releases the session lease.
3282 if _, ok := a.deferredRebuildWarning("settings", err); ok {
3283 return nil
3284 }
3285 return err
3286 }
3287 return nil
3288 }
3289
3290 // SetSandbox updates the bash sandbox mode, network egress, and write roots.
3291 func (a *App) SetSandbox(bash string, network bool, workspaceRoot string, allowWrite []string, shell string) error {
3292 return a.applyConfigChange(func(c *config.Config) error {
3293 c.Sandbox.Bash = bash
3294 c.Sandbox.Network = network
3295 c.Sandbox.WorkspaceRoot = strings.TrimSpace(workspaceRoot)
3296 c.Sandbox.AllowWrite = trimList(allowWrite)
3297 c.Tools.Shell.Prefer = strings.TrimSpace(shell)
3298 return nil
3299 })
3300 }
3301
3302 // SetNetwork updates ordinary outbound proxy settings.
3303 func (a *App) SetNetwork(n NetworkView) error {
3304 return a.applyConfigChange(func(c *config.Config) error {
3305 return c.SetNetwork(config.NetworkConfig{
3306 ProxyMode: n.ProxyMode,
3307 ProxyURL: n.ProxyURL,
3308 NoProxy: n.NoProxy,
3309 Proxy: config.NetworkProxyConfig{
3310 Type: n.Proxy.Type,
3311 Server: n.Proxy.Server,
3312 Port: n.Proxy.Port,
3313 Username: n.Proxy.Username,
3314 Password: n.Proxy.Password,
3315 },
3316 })
3317 })
3318 }
3319
3320 func (a *App) SetBotSettings(b BotSettingsView) error {
3321 err := a.applyConfigOnly(func(c *config.Config) error {
3322 c.Bot.Enabled = b.Enabled
3323 c.Bot.Model = strings.TrimSpace(b.Model)
3324 c.Bot.ToolApprovalMode = normalizeBotConnectionToolApprovalMode(b.ToolApprovalMode)
3325 c.Bot.MaxSteps = b.MaxSteps
3326 c.Bot.DebounceMs = b.DebounceMs
3327 c.Bot.QueueMode = strings.TrimSpace(b.QueueMode)
3328 c.Bot.QueueCap = b.QueueCap
3329 c.Bot.QueueDrop = strings.TrimSpace(b.QueueDrop)
3330 c.Bot.IgnoreSelfMessages = b.IgnoreSelfMessages
3331 c.Bot.SelfUserIDs = config.BotSelfUserIDs{
3332 QQ: trimList(b.SelfUserIDs.QQ),
3333 Feishu: trimList(b.SelfUserIDs.Feishu),
3334 Weixin: trimList(b.SelfUserIDs.Weixin),
3335 }
3336 c.Bot.Control = config.BotControlConfig{
3337 Enabled: b.Control.Enabled,
3338 Addr: strings.TrimSpace(b.Control.Addr),
3339 TokenEnv: strings.TrimSpace(b.Control.TokenEnv),
3340 }
3341 c.Bot.Pairing = config.BotPairingConfig{
3342 Enabled: b.Pairing.Enabled,
3343 RequestTTLMinutes: b.Pairing.RequestTTLMinutes,
3344 MaxPendingPerPlatform: b.Pairing.MaxPendingPerPlatform,
3345 }
3346 c.Bot.Routes = botRouteConfigs(b.Routes)
3347 c.Bot.Allowlist = config.BotAllowlist{
3348 Enabled: b.Allowlist.Enabled,
3349 AllowAll: b.Allowlist.AllowAll,
3350 QQUsers: trimList(b.Allowlist.QQUsers),
3351 FeishuUsers: trimList(b.Allowlist.FeishuUsers),
3352 WeixinUsers: trimList(b.Allowlist.WeixinUsers),
3353 QQApprovers: trimList(b.Allowlist.QQApprovers),
3354 FeishuApprovers: trimList(b.Allowlist.FeishuApprovers),
3355 WeixinApprovers: trimList(b.Allowlist.WeixinApprovers),
3356 QQAdmins: trimList(b.Allowlist.QQAdmins),
3357 FeishuAdmins: trimList(b.Allowlist.FeishuAdmins),
3358 WeixinAdmins: trimList(b.Allowlist.WeixinAdmins),
3359 QQGroups: trimList(b.Allowlist.QQGroups),
3360 FeishuGroups: trimList(b.Allowlist.FeishuGroups),
3361 WeixinGroups: trimList(b.Allowlist.WeixinGroups),
3362 }
3363 c.Bot.QQ = config.QQBotConfig{
3364 Enabled: b.QQ.Enabled,
3365 AppID: strings.TrimSpace(b.QQ.AppID),
3366 AppSecretEnv: strings.TrimSpace(b.QQ.AppSecretEnv),
3367 Sandbox: b.QQ.Sandbox,
3368 Model: strings.TrimSpace(b.QQ.Model),
3369 ToolApprovalMode: normalizeBotConnectionToolApprovalMode(b.QQ.ToolApprovalMode),
3370 WorkspaceRoot: strings.TrimSpace(b.QQ.WorkspaceRoot),
3371 Access: botAccessConfigFromView(b.QQ.Access),
3372 }
3373 c.Bot.Feishu = config.FeishuBotConfig{
3374 Enabled: b.Feishu.Enabled,
3375 Domain: botDomainOrDefault(b.Feishu.Domain),
3376 AppID: strings.TrimSpace(b.Feishu.AppID),
3377 AppSecretEnv: strings.TrimSpace(b.Feishu.AppSecretEnv),
3378 VerificationToken: strings.TrimSpace(b.Feishu.VerificationToken),
3379 Mode: strings.TrimSpace(b.Feishu.Mode),
3380 WebhookPort: b.Feishu.WebhookPort,
3381 RequireMention: b.Feishu.RequireMention,
3382 OutboundMediaRoots: append([]string(nil), c.Bot.Feishu.OutboundMediaRoots...),
3383 }
3384 c.Bot.Weixin = config.WeixinBotConfig{
3385 Enabled: b.Weixin.Enabled,
3386 AccountID: strings.TrimSpace(b.Weixin.AccountID),
3387 TokenEnv: strings.TrimSpace(b.Weixin.TokenEnv),
3388 APIBase: strings.TrimRight(strings.TrimSpace(b.Weixin.APIBase), "/"),
3389 }
3390 c.Bot.Connections = botConnectionConfigs(b.Connections)
3391 return nil
3392 })
3393 if err == nil {
3394 a.refreshBotRuntimeAsync()
3395 }
3396 return err
3397 }
3398
3399 // SetBotConnectionToolApprovalMode updates a single connection's tool approval
3400 // mode without restarting the bot gateway. Only the connection's mode field is
3401 // persisted; existing sessions on the running gateway are updated in-place.
3402 func (a *App) SetBotConnectionToolApprovalMode(connID, mode string) error {
3403 connID = strings.TrimSpace(connID)
3404 mode = normalizeBotConnectionToolApprovalMode(mode)
3405 runtimeConnID := connID
3406 err := a.applyConfigOnly(func(c *config.Config) error {
3407 for i := range c.Bot.Connections {
3408 candidateRuntimeID := botruntime.ConnectionRuntimeID(c.Bot.Connections[i])
3409 if candidateRuntimeID == "" {
3410 candidateRuntimeID = strings.TrimSpace(c.Bot.Connections[i].ID)
3411 }
3412 if c.Bot.Connections[i].ID == connID || candidateRuntimeID == connID {
3413 c.Bot.Connections[i].ToolApprovalMode = mode
3414 c.Bot.Connections[i].UpdatedAt = time.Now().UTC().Format(time.RFC3339)
3415 runtimeConnID = candidateRuntimeID
3416 return nil
3417 }
3418 }
3419 return fmt.Errorf("connection %q not found", connID)
3420 })
3421 if err != nil {
3422 return err
3423 }
3424 if a.botRuntime != nil {
3425 a.botRuntime.updateConnectionToolApprovalMode(runtimeConnID, mode)
3426 }
3427 return nil
3428 }
3429
3430 func (a *App) SetBotSecret(envName, value string) error {
3431 envName = strings.TrimSpace(envName)
3432 if envName == "" {
3433 return fmt.Errorf("bot secret env name is empty")
3434 }
3435 if err := upsertDotEnv(envName, value); err != nil {
3436 return err
3437 }
3438 a.refreshBotRuntimeAsync()
3439 return nil
3440 }
3441
3442 func (a *App) ClearBotSecret(envName string) error {
3443 envName = strings.TrimSpace(envName)
3444 if envName == "" {
3445 return fmt.Errorf("bot secret env name is empty")
3446 }
3447 if err := removeDotEnv(envName); err != nil {
3448 return err
3449 }
3450 a.refreshBotRuntimeAsync()
3451 return nil
3452 }
3453
3454 // SetCloseBehavior updates desktop-only window close behavior without rebuilding
3455 // the active controller. It must stay out of provider-visible prompt/request data.
3456 func (a *App) SetCloseBehavior(mode string) error {
3457 return a.applyConfigOnly(func(c *config.Config) error { return c.SetDesktopCloseBehavior(mode) })
3458 }
3459
3460 // SetDisplayMode updates the transcript display mode. UI-only, no rebuild needed.
3461 func (a *App) SetDisplayMode(mode string) error {
3462 return a.applyConfigOnly(func(c *config.Config) error { return c.SetDesktopDisplayMode(mode) })
3463 }
3464
3465 // SetStatusBarStyle updates the desktop status bar metric label style. UI-only,
3466 // no rebuild needed.
3467 func (a *App) SetStatusBarStyle(style string) error {
3468 return a.applyConfigOnly(func(c *config.Config) error { return c.SetDesktopStatusBarStyle(style) })
3469 }
3470
3471 // SetStatusBarItems updates the ordered visible desktop status bar items.
3472 // UI-only, no rebuild needed.
3473 func (a *App) SetStatusBarItems(items []string) error {
3474 return a.applyConfigOnly(func(c *config.Config) error { return c.SetDesktopStatusBarItems(items) })
3475 }
3476
3477 // SetDesktopLanguage updates the desktop UI language and the user-level response
3478 // language preference used by model-facing desktop sessions.
3479 func (a *App) SetDesktopLanguage(lang string) error {
3480 responseLanguage := ""
3481 pricingChanged := false
3482 if cfg, _, err := a.loadDesktopUserConfigForView(); err == nil && cfg.DesktopCurrency() == "" {
3483 targetCurrency := a.desktopAutoPricingCurrency()
3484 switch strings.ToLower(strings.TrimSpace(lang)) {
3485 case "zh":
3486 targetCurrency = "CNY"
3487 case "en":
3488 targetCurrency = "USD"
3489 }
3490 pricingChanged = a.desktopEffectivePricingCurrency(cfg) != targetCurrency
3491 }
3492 mutate := func(c *config.Config) error {
3493 if err := c.SetDesktopLanguage(lang); err != nil {
3494 return err
3495 }
3496 if err := c.SetLanguage(lang); err != nil {
3497 return err
3498 }
3499 responseLanguage = c.ResponseLanguage()
3500 return nil
3501 }
3502 var err error
3503 if pricingChanged {
3504 _, err = a.applyConfigChangeWithWarning("currency", mutate)
3505 } else {
3506 err = a.applyConfigOnly(mutate)
3507 }
3508 if err != nil {
3509 return err
3510 }
3511 if pricingChanged {
3512 a.scheduleCurrencyRefreshForOtherTabs()
3513 }
3514 if strings.TrimSpace(lang) != "" && !strings.EqualFold(strings.TrimSpace(lang), "auto") {
3515 a.setDesktopLocale(lang)
3516 }
3517 a.updateTrayLocale(lang)
3518 a.applyResponseLanguageToLiveControllers(responseLanguage)
3519 return nil
3520 }
3521
3522 // SetDesktopCurrency updates the official pricing region independently from UI
3523 // language. Rebuild the active controller so subsequent usage carries the new
3524 // currency and regional rates through the existing structured cost fields.
3525 func (a *App) SetDesktopCurrency(currency string) error {
3526 _, err := a.applyConfigChangeWithWarning("currency", func(c *config.Config) error {
3527 return c.SetDesktopCurrency(currency)
3528 })
3529 if err == nil {
3530 a.scheduleCurrencyRefreshForOtherTabs()
3531 }
3532 return err
3533 }
3534
3535 func (a *App) scheduleCurrencyRefreshForOtherTabs() {
3536 if a == nil || a.ctx == nil {
3537 return
3538 }
3539 a.mu.RLock()
3540 activeID := a.activeTabID
3541 tabIDs := make([]string, 0, len(a.tabs))
3542 for id, tab := range a.tabs {
3543 if id != activeID && tab != nil && tab.Ctrl != nil && !tab.removed {
3544 tabIDs = append(tabIDs, id)
3545 }
3546 }
3547 a.mu.RUnlock()
3548 for _, id := range tabIDs {
3549 a.scheduleDeferredRebuild(id, "currency")
3550 }
3551 }
3552
3553 func (a *App) scheduleCurrencyRefreshForAllTabs() {
3554 if a == nil {
3555 return
3556 }
3557 a.mu.RLock()
3558 tabIDs := make([]string, 0, len(a.tabs))
3559 for id, tab := range a.tabs {
3560 if tab != nil && tab.Ctrl != nil && !tab.removed {
3561 tabIDs = append(tabIDs, id)
3562 }
3563 }
3564 a.mu.RUnlock()
3565 for _, id := range tabIDs {
3566 a.scheduleDeferredRebuild(id, "currency")
3567 }
3568 }
3569
3570 func (a *App) desktopPricingFollowsDetectedLocale() bool {
3571 cfg, _, err := a.loadDesktopUserConfigForView()
3572 return err == nil && cfg.DesktopPricingFollowsDetectedLocale()
3573 }
3574
3575 func (a *App) desktopEffectivePricingCurrency(cfg *config.Config) string {
3576 if cfg == nil {
3577 return a.desktopAutoPricingCurrency()
3578 }
3579 if cfg.DesktopPricingFollowsDetectedLocale() {
3580 return a.desktopAutoPricingCurrency()
3581 }
3582 return cfg.DeepSeekOfficialPricingCurrency()
3583 }
3584
3585 func (a *App) desktopOfficialPricingLanguage(cfg *config.Config) string {
3586 if a.desktopEffectivePricingCurrency(cfg) == "CNY" {
3587 return "zh"
3588 }
3589 return "en"
3590 }
3591
3592 // SetTrayLocale mirrors the resolved desktop UI language into the native tray
3593 // menu. It is runtime-only; the persisted preference remains [desktop].language.
3594 func (a *App) SetTrayLocale(locale string) error {
3595 previousCurrency := a.desktopAutoPricingCurrency()
3596 a.setDesktopLocale(locale)
3597 pricingCurrencyChanged := previousCurrency != a.desktopAutoPricingCurrency()
3598 trayLocale := "en"
3599 if strings.HasPrefix(strings.ToLower(strings.TrimSpace(locale)), "zh") {
3600 trayLocale = "zh"
3601 }
3602 a.updateTrayLocale(trayLocale)
3603 if pricingCurrencyChanged && a.desktopPricingFollowsDetectedLocale() {
3604 a.scheduleCurrencyRefreshForAllTabs()
3605 a.kickDeferredRebuildRetry()
3606 }
3607 a.emitProjectTreeChanged()
3608 return nil
3609 }
3610
3611 // SetDesktopAppearance updates only desktop theme preferences. It does not
3612 // rebuild the active controller and must stay out of provider-visible requests.
3613 func (a *App) SetDesktopAppearance(theme, style string) error {
3614 return a.applyConfigOnly(func(c *config.Config) error { return c.SetDesktopAppearance(theme, style) })
3615 }
3616
3617 // SetDesktopTerminalTheme updates only the integrated terminal colours. It is
3618 // applied live by the frontend and does not rebuild the active controller.
3619 func (a *App) SetDesktopTerminalTheme(theme string) error {
3620 return a.applyConfigOnly(func(c *config.Config) error { return c.SetDesktopTerminalTheme(theme) })
3621 }
3622
3623 // SetDesktopLayoutStyle updates only the desktop layout style. It does not
3624 // rebuild the active controller and must stay out of provider-visible requests.
3625 func (a *App) SetDesktopLayoutStyle(style string) error {
3626 normalized := ""
3627 if err := a.applyConfigOnly(func(c *config.Config) error {
3628 if err := c.SetDesktopLayoutStyle(style); err != nil {
3629 return err
3630 }
3631 normalized = c.DesktopLayoutStyle()
3632 return nil
3633 }); err != nil {
3634 return err
3635 }
3636 if singleSurfaceLayoutStyle(normalized) {
3637 return a.applySingleSurfaceTabPolicy()
3638 }
3639 return nil
3640 }
3641
3642 // SetDesktopCheckUpdates updates only the desktop startup update-check
3643 // preference. Manual checks in Settings are unaffected.
3644 func (a *App) SetDesktopCheckUpdates(enabled bool) error {
3645 return a.applyConfigOnly(func(c *config.Config) error { return c.SetDesktopCheckUpdates(enabled) })
3646 }
3647
3648 // SetDesktopUpdateChannel is retained for older Wails clients. The config layer
3649 // clears the retired preference and every updater request uses Stable.
3650 func (a *App) SetDesktopUpdateChannel(channel string) error {
3651 return a.applyConfigOnly(func(c *config.Config) error { return c.SetDesktopUpdateChannel(channel) })
3652 }
3653
3654 // SetDesktopTelemetry sets whether the desktop sends the anonymous launch ping.
3655 func (a *App) SetDesktopTelemetry(enabled bool) error {
3656 return a.applyConfigOnly(func(c *config.Config) error { return c.SetDesktopTelemetry(enabled) })
3657 }
3658
3659 // SetDesktopMetrics sets whether the desktop sends aggregate desktop metrics,
3660 // starting or stopping the live aggregator so the toggle takes effect immediately.
3661 func (a *App) SetDesktopMetrics(enabled bool) error {
3662 if err := a.applyConfigOnly(func(c *config.Config) error { return c.SetDesktopMetrics(enabled) }); err != nil {
3663 return err
3664 }
3665 switch {
3666 case enabled && a.metrics.Load() == nil && version != "dev":
3667 a.metrics.Store(newMetricsAggregator(config.MemoryUserDir()))
3668 if cfg, err := config.Load(); err == nil {
3669 a.recordSettingsMetricsSnapshot(cfg)
3670 }
3671 case !enabled:
3672 a.metrics.Store(nil)
3673 }
3674 return nil
3675 }
3676
3677 // SetExpandThinking sets whether reasoning text is expanded by default on
3678 // the desktop. It is desktop-only and does not rebuild the controller.
3679 func (a *App) SetExpandThinking(on bool) error {
3680 return a.applyConfigOnly(func(c *config.Config) error { return c.SetExpandThinking(on) })
3681 }
3682
3683 // SetDesktopConversationWidth sets the max transcript width preference.
3684 // standard = 960px fixed; full = 90% of the parent, with a 960px floor. Pure config-only.
3685 func (a *App) SetDesktopConversationWidth(width string) error {
3686 return a.applyConfigOnly(func(c *config.Config) error { return c.SetDesktopConversationWidth(width) })
3687 }
3688
3689 // MigrateDesktopPreferences imports old browser-local desktop preferences into
3690 // the user config once. Existing [desktop] values win so stale localStorage never
3691 // overwrites an explicit config edit.
3692 func (a *App) MigrateDesktopPreferences(language, theme, style string) error {
3693 return a.applyConfigOnly(func(c *config.Config) error {
3694 if strings.TrimSpace(c.Desktop.Language) == "" {
3695 if err := c.SetDesktopLanguage(language); err != nil {
3696 return err
3697 }
3698 }
3699 if strings.TrimSpace(c.Desktop.Theme) == "" && strings.TrimSpace(c.Desktop.ThemeStyle) == "" {
3700 if err := c.SetDesktopAppearance(theme, style); err != nil {
3701 return err
3702 }
3703 }
3704 return nil
3705 })
3706 }
3707
3708 // SetAgentParams updates sampling temperature and the base system prompt. The
3709 // step arguments remain in the Wails contract for older frontends, but are
3710 // retired and deliberately normalized to automatic execution.
3711 func (a *App) SetAgentParams(temperature float64, maxSteps int, plannerMaxSteps int, systemPrompt string) error {
3712 return a.applyConfigChange(func(c *config.Config) error {
3713 c.Agent.Temperature = temperature
3714 c.Agent.MaxSteps = 0
3715 c.Agent.PlannerMaxSteps = 0
3716 c.Agent.SystemPrompt = systemPrompt
3717 return nil
3718 })
3719 }
3720
3721 func (a *App) SetColdResumePrune(enabled bool) error {
3722 return a.applyConfigChange(func(c *config.Config) error { return c.SetColdResumePrune(enabled) })
3723 }
3724
3725 func (a *App) SetCompactRatio(ratio float64) error {
3726 _, err := a.applyConfigChangeWithWarning("context compaction threshold", func(c *config.Config) error {
3727 return c.SetCompactRatio(ratio)
3728 })
3729 return err
3730 }
3731
3732 func (a *App) SetReasoningLanguage(lang string) error {
3733 if err := a.ensureLiveControllersRuntimeMutationAllowed("reasoning language"); err != nil {
3734 return err
3735 }
3736 var cfg *config.Config
3737 // Lock only the load-modify-save cycle; the live-controller fan-out below
3738 // must not hold the config edit lock.
3739 if err := func() error {
3740 unlock := config.LockUserConfigEdits()
3741 defer unlock()
3742 loaded, path, err := a.loadDesktopUserConfigForEdit()
3743 if err != nil {
3744 return err
3745 }
3746 if err := loaded.SetReasoningLanguage(lang); err != nil {
3747 return err
3748 }
3749 if err := loaded.SaveTo(path); err != nil {
3750 return err
3751 }
3752 cfg = loaded
3753 return nil
3754 }(); err != nil {
3755 return err
3756 }
3757 a.applyReasoningLanguageToLiveControllers(cfg.ReasoningLanguage())
3758 return nil
3759 }
3760
3761 func (a *App) applyReasoningLanguageToLiveControllers(fallback string) {
3762 type liveTab struct {
3763 root string
3764 ctrl control.SessionAPI
3765 }
3766 var tabs []liveTab
3767 a.mu.RLock()
3768 for _, tab := range a.tabs {
3769 if tab != nil && tab.Ctrl != nil {
3770 tabs = append(tabs, liveTab{root: tab.WorkspaceRoot, ctrl: tab.Ctrl})
3771 }
3772 }
3773 a.mu.RUnlock()
3774 for _, tab := range tabs {
3775 mode := fallback
3776 if cfg, err := config.LoadForRoot(tab.root); err == nil {
3777 mode = cfg.ReasoningLanguage()
3778 }
3779 tab.ctrl.SetReasoningLanguage(mode)
3780 }
3781 }
3782
3783 func (a *App) applyResponseLanguageToLiveControllers(fallback string) {
3784 type liveTab struct {
3785 root string
3786 ctrl control.SessionAPI
3787 }
3788 var tabs []liveTab
3789 a.mu.RLock()
3790 for _, tab := range a.tabs {
3791 if tab != nil && tab.Ctrl != nil {
3792 tabs = append(tabs, liveTab{root: tab.WorkspaceRoot, ctrl: tab.Ctrl})
3793 }
3794 }
3795 a.mu.RUnlock()
3796 for _, tab := range tabs {
3797 mode := fallback
3798 if cfg, err := config.LoadForRoot(tab.root); err == nil {
3799 mode = cfg.ResponseLanguage()
3800 }
3801 tab.ctrl.SetResponseLanguage(mode)
3802 }
3803 }
3804
3805 // trimList drops blank entries from a string slice (and returns a non-nil slice).
3806 func trimList(in []string) []string {
3807 out := []string{}
3808 for _, s := range in {
3809 if t := strings.TrimSpace(s); t != "" {
3810 out = append(out, t)
3811 }
3812 }
3813 return out
3814 }
3815
3815 lines GO