1package modes
2
3import (
4 "fmt"
5
6 "github.com/bnema/zut/packages/provider"
7)
8
9// detachMismatchedModelProfile is called under the UI lock (or before startup)
10// after host-driven session/model changes, which must not rewrite favorites.
11func (c *InteractiveConfig) detachMismatchedModelProfile() {
12 slot := c.ActiveModelProfile
13 if slot < 1 || slot > 9 || slot > len(c.QuickModelShortcuts) {
14 c.ActiveModelProfile = 0
15 return
16 }
17 p := c.QuickModelShortcuts[slot-1]
18 liveFastMode := c.FastMode != nil && *c.FastMode
19 if p.Provider != c.Provider || p.Model != c.Model || provider.NormalizeReasoning(p.Reasoning) != provider.NormalizeReasoning(c.Reasoning) || p.FastMode != liveFastMode {
20 c.ActiveModelProfile = 0
21 }
22}
23
24func modelProfileDescription(p QuickModelShortcut) string {
25 level := p.Reasoning
26 if level == "" {
27 level = "off"
28 }
29 fast := "off"
30 if p.FastMode {
31 fast = "on"
32 }
33 return p.Provider + " / " + p.Model + " (reasoning: " + level + ", fast: " + fast + ")"
34}
35
36// persistModelProfile changes in-memory favorites only after saving succeeds.
37func (i *Interactive) persistModelProfile(slot int, p QuickModelShortcut, active int) bool {
38 if i.cfg.SettingsStore != nil {
39 var err error
40 if store, ok := i.cfg.SettingsStore.(modelProfileSettingsStore); ok {
41 err = store.SetModelProfile(slot, p, active)
42 } else {
43 err = i.cfg.SettingsStore.SetQuickModelShortcut(slot, p.Provider, p.Model)
44 active = 0 // legacy stores only persist provider/model bindings
45 }
46 if err != nil {
47 i.mu.Lock()
48 i.statusOK = ""
49 i.statusErr = "profile not saved: " + err.Error()
50 i.mu.Unlock()
51 i.invalidate()
52 return false
53 }
54 }
55 i.mu.Lock()
56 if len(i.cfg.QuickModelShortcuts) < slot {
57 next := make([]QuickModelShortcut, slot)
58 copy(next, i.cfg.QuickModelShortcuts)
59 i.cfg.QuickModelShortcuts = next
60 }
61 i.cfg.QuickModelShortcuts[slot-1] = p
62 i.cfg.ActiveModelProfile = active
63 i.mu.Unlock()
64 for n := 1; n <= 9; n++ {
65 i.refreshQuickModelSettingsItem(n)
66 }
67 return true
68}
69
70func (i *Interactive) setQuickModelProfile(slot int, p QuickModelShortcut) {
71 if slot < 1 || slot > 9 {
72 return
73 }
74 if p.Model == "" {
75 p = QuickModelShortcut{}
76 } else {
77 m, err := provider.FindModel(p.Provider, p.Model)
78 if err != nil {
79 i.mu.Lock()
80 i.statusErr, i.statusOK = err.Error(), ""
81 i.mu.Unlock()
82 i.invalidate()
83 return
84 }
85 p.Provider, p.Model = m.Provider, m.ID
86 p.Reasoning = provider.ClampReasoningForModel(m, p.Reasoning)
87 }
88 active := i.cfg.ActiveModelProfile
89 if active == slot && p.Model != "" {
90 i.activateModelProfile(slot, p)
91 return
92 }
93 // Clearing the active favorite leaves the live model unchanged.
94 if active == slot {
95 active = 0
96 }
97 if !i.persistModelProfile(slot, p, active) {
98 return
99 }
100 i.mu.Lock()
101 i.statusErr = ""
102 if p.Model == "" {
103 i.statusOK = quickModelShortcutLabel(slot) + " cleared"
104 } else {
105 i.statusOK = quickModelShortcutLabel(slot) + " saved: " + modelProfileDescription(p)
106 }
107 i.mu.Unlock()
108 i.invalidate()
109}
110
111func (i *Interactive) saveActiveModelProfile() {
112 slot := i.cfg.ActiveModelProfile
113 if slot < 1 || slot > 9 {
114 return
115 }
116 p := QuickModelShortcut{
117 Provider: i.cfg.Provider, Model: i.cfg.Model, Reasoning: i.cfg.Reasoning,
118 FastMode: i.cfg.FastMode != nil && *i.cfg.FastMode,
119 }
120 if m, err := provider.FindModel(p.Provider, p.Model); err == nil {
121 p.Reasoning = provider.ClampReasoningForModel(m, p.Reasoning)
122 if p.Reasoning != i.cfg.Reasoning {
123 i.setLiveReasoning(p.Reasoning)
124 }
125 }
126 if i.persistModelProfile(slot, p, slot) {
127 i.mu.Lock()
128 i.statusOK = fmt.Sprintf("profile %d updated: %s", slot, modelProfileDescription(p))
129 i.mu.Unlock()
130 } else {
131 i.detachUnsavedModelProfile()
132 }
133}
134
135func (i *Interactive) detachUnsavedModelProfile() {
136 i.mu.Lock()
137 defer i.mu.Unlock()
138 i.cfg.ActiveModelProfile = 0
139 // The model/session callback already ran. Do not imply rollback:
140 // only the favorite and active-slot save failed.
141 i.statusErr = "model switched; " + i.statusErr
142}
143
144func (i *Interactive) activateModelProfile(slot int, p QuickModelShortcut) {
145 defer i.invalidate()
146 if slot < 1 || slot > 9 {
147 return
148 }
149 if i.busy {
150 i.mu.Lock()
151 i.statusErr, i.statusOK = "cannot switch model while a turn is running", ""
152 i.mu.Unlock()
153 return
154 }
155 if p.Provider == "" || p.Model == "" {
156 i.mu.Lock()
157 i.statusErr, i.statusOK = "profile requires a provider and model; assign it in /settings", ""
158 i.mu.Unlock()
159 return
160 }
161 if i.cfg.SettingsStore != nil {
162 if _, ok := i.cfg.SettingsStore.(modelProfileSettingsStore); !ok {
163 if i.persistModelProfile(slot, p, 0) {
164 i.swapModel(p.Provider, p.Model, i.cfg.BuildAgentFor, false)
165 }
166 return
167 }
168 }
169 var replaced bool
170 activate := func() {
171 var success bool
172 replaced, success = i.swapModelUnserialized(p.Provider, p.Model, i.cfg.BuildAgentFor, false)
173 if !success {
174 return
175 }
176 m, _ := provider.FindModel(i.cfg.Provider, i.cfg.Model) // swap validated the model
177 p.Provider, p.Model = i.cfg.Provider, i.cfg.Model
178 p.Reasoning = provider.ClampReasoningForModel(m, p.Reasoning)
179 i.setLiveReasoning(p.Reasoning)
180 i.setLiveFastMode(p.FastMode)
181 if !i.persistModelProfile(slot, p, slot) {
182 i.detachUnsavedModelProfile()
183 return
184 }
185 i.mu.Lock()
186 i.statusOK = fmt.Sprintf("profile %d active: %s", slot, modelProfileDescription(p))
187 i.mu.Unlock()
188 }
189 if i.cfg.SessionTransition != nil {
190 i.cfg.SessionTransition(activate)
191 } else {
192 activate()
193 }
194 if replaced {
195 i.resetCompactHandoff()
196 }
197}
198
199func (i *Interactive) setLiveReasoning(level string) {
200 if i.cfg.OnReasoningChanged != nil {
201 i.cfg.OnReasoningChanged(level)
202 }
203 i.mu.Lock()
204 i.cfg.Reasoning = level
205 if i.agent != nil {
206 i.agent.Reasoning = level
207 }
208 i.mu.Unlock()
209}
210
211func (i *Interactive) setLiveFastMode(enabled bool) {
212 // Agent replacement publishes under agentMu -> mu. Hold agentMu across
213 // the snapshot and update so a replacement cannot appear with stale mode.
214 i.agentMu.Lock()
215 i.mu.Lock()
216 value := enabled
217 i.cfg.FastMode = &value
218 agent := i.agent
219 onChanged := i.cfg.OnFastModeChanged
220 i.mu.Unlock()
221 if agent != nil {
222 agent.SetFastMode(enabled)
223 }
224 i.agentMu.Unlock()
225 if onChanged != nil {
226 onChanged(enabled)
227 }
228}