v0.51.0 a9d674e

model_profiles.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close