Skip to content

Commit 39eb0f3

Browse files
authored
fix(tui): persist official-tab models and refine saved secret hint (#260)
* fix(tui): persist official-tab models and refine saved secret hint Persist user-added models to providers.<name>.models on the official tab. When an API key or auth token is already saved, show a replace hint with a prefix/suffix fingerprint (skipped for short keys), use a fixed mask placeholder, and ensure typing or paste replaces the saved value instead of re-saving it. * fix(tui): model add/delete UX and config wizard hardening - Add model add/delete in config provider and config model (official + custom) - Show d Delete only on model rows; green highlight when selected - Improve Esc cancel text; track savedInSession to avoid misleading messages - Reload config on save failure; read registry models fresh after reload - Export llm.ModelListContains; fix config model persist using registry-only check * fix(tui): defer provider config until confirm and harden API key UX Only persist provider/model on wizard confirm; keep in-session picks via sessionModelPick. Validate API key before quit, clear saved keys when emptied, and improve official env-var hints and custom edit clear behavior. * feat(tui): show active model suffix on official provider list Align Official tab with Custom: display (model) next to the active preset when cfg.Provider matches and a global model is configured.
1 parent 964f216 commit 39eb0f3

8 files changed

Lines changed: 3450 additions & 282 deletions

File tree

cmd/opencodereview/config_cmd.go

Lines changed: 1 addition & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -480,23 +480,13 @@ func ensureModelInList(models []string, model string) []string {
480480
if model == "" {
481481
return models
482482
}
483-
if modelListContains(models, model) {
483+
if llm.ModelListContains(models, model) {
484484
return models
485485
}
486486
out := append([]string(nil), models...)
487487
return append(out, model)
488488
}
489489

490-
func modelListContains(models []string, target string) bool {
491-
target = strings.TrimSpace(target)
492-
for _, model := range models {
493-
if model == target {
494-
return true
495-
}
496-
}
497-
return false
498-
}
499-
500490
func setProviderValue(cfg *Config, key, value string) error {
501491
parts := strings.SplitN(key, ".", 3)
502492
if len(parts) != 3 || parts[1] == "" || parts[2] == "" {

cmd/opencodereview/provider_cmd.go

Lines changed: 49 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -32,13 +32,9 @@ func runConfigProvider() error {
3232
final := finalModel.(providerTUIModel)
3333

3434
if !final.confirmed {
35-
// TUI persists changes (create/edit/model/add/delete) directly to disk
36-
// during the session, so the on-disk file is already up to date for any
37-
// savedInSession operation. No additional post-TUI apply step is needed.
38-
if final.savedInSession {
39-
return nil
40-
}
41-
fmt.Println("Cancelled.")
35+
// TUI persists changes during the session; Esc only abandons the final
36+
// provider/API-key confirmation step.
37+
printWizardCancelled(final.savedInSession, "Configuration changes")
4238
return nil
4339
}
4440

@@ -55,6 +51,16 @@ func runConfigProvider() error {
5551
return applyOfficialProviderConfig(configPath, cfg, result)
5652
}
5753

54+
// printWizardCancelled prints the standard Esc-cancel message for config wizards.
55+
// changesDescription is a short noun phrase, e.g. "Configuration changes".
56+
func printWizardCancelled(savedInSession bool, changesDescription string) {
57+
if savedInSession {
58+
fmt.Printf("Cancelled. (%s made during this session were kept.)\n", changesDescription)
59+
return
60+
}
61+
fmt.Println("Cancelled.")
62+
}
63+
5864
func applyProviderDeletions(configPath string, cfg *Config, names []string) (bool, error) {
5965
clearedActive := false
6066
for _, name := range names {
@@ -133,7 +139,8 @@ func applyCustomProviderConfig(configPath string, cfg *Config, result providerTU
133139
if result.provider == "" {
134140
return fmt.Errorf("provider name is required")
135141
}
136-
if result.model == "" {
142+
model := result.resolvedModel()
143+
if model == "" {
137144
return fmt.Errorf("model is required")
138145
}
139146

@@ -142,11 +149,11 @@ func applyCustomProviderConfig(configPath string, cfg *Config, result providerTU
142149
}
143150

144151
entry := cfg.CustomProviders[result.provider]
145-
entry.Model = result.model
152+
entry.Model = model
146153
if len(result.models) > 0 {
147154
entry.Models = append([]string(nil), result.models...)
148155
}
149-
entry.Models = ensureModelInList(entry.Models, result.model)
156+
entry.Models = ensureModelInList(entry.Models, model)
150157
if result.url != "" {
151158
entry.URL = result.url
152159
}
@@ -162,14 +169,16 @@ func applyCustomProviderConfig(configPath string, cfg *Config, result providerTU
162169
}
163170
if result.apiKey != "" {
164171
entry.APIKey = result.apiKey
172+
} else {
173+
entry.APIKey = ""
165174
}
166175
cfg.CustomProviders[result.provider] = entry
167176

168177
if !result.isEdit {
169178
cfg.Provider = result.provider
170-
cfg.Model = result.model
179+
cfg.Model = model
171180
} else if cfg.Provider == result.provider {
172-
cfg.Model = result.model
181+
cfg.Model = model
173182
}
174183

175184
if err := saveConfig(configPath, cfg); err != nil {
@@ -182,13 +191,13 @@ func applyCustomProviderConfig(configPath string, cfg *Config, result providerTU
182191
} else {
183192
fmt.Printf("\nCustom provider %q updated (not currently active).\n", result.provider)
184193
}
185-
fmt.Printf("Model: %s\n", result.model)
194+
fmt.Printf("Model: %s\n", model)
186195
fmt.Println("\nTip: run 'ocr config model' to switch model later.")
187196
return nil
188197
}
189198

190199
fmt.Printf("\nProvider set to: %s (custom)\n", result.provider)
191-
fmt.Printf("Model: %s\n", result.model)
200+
fmt.Printf("Model: %s\n", model)
192201

193202
fmt.Println("\nTesting connection...")
194203
if err := runLLMTest(); err != nil {
@@ -202,7 +211,11 @@ func applyCustomProviderConfig(configPath string, cfg *Config, result providerTU
202211
}
203212

204213
func applyOfficialProviderConfig(configPath string, cfg *Config, result providerTUIResult) error {
205-
if result.provider == "" || result.model == "" {
214+
if result.provider == "" {
215+
return fmt.Errorf("provider and model are required")
216+
}
217+
model := result.resolvedModel()
218+
if model == "" {
206219
return fmt.Errorf("provider and model are required")
207220
}
208221

@@ -223,27 +236,30 @@ func applyOfficialProviderConfig(configPath string, cfg *Config, result provider
223236
}
224237

225238
entry := cfg.Providers[result.provider]
226-
entry.Model = result.model
239+
entry.Model = model
227240
if len(result.models) > 0 {
228241
entry.Models = mergeModelLists(entry.Models, result.models)
229242
}
230243
if result.apiKey != "" {
231244
entry.APIKey = result.apiKey
245+
} else {
246+
// Confirmed empty key: clear saved api_key so resolver falls back to $ENV_VAR.
247+
entry.APIKey = ""
232248
}
233249
cfg.Providers[result.provider] = entry
234250

235251
if cfg.Provider != result.provider {
236252
cfg.Model = ""
237253
}
238254
cfg.Provider = result.provider
239-
cfg.Model = result.model
255+
cfg.Model = model
240256

241257
if err := saveConfig(configPath, cfg); err != nil {
242258
return err
243259
}
244260

245261
fmt.Printf("\nProvider set to: %s\n", result.provider)
246-
fmt.Printf("Model: %s\n", result.model)
262+
fmt.Printf("Model: %s\n", model)
247263

248264
fmt.Println("\nTesting connection...")
249265
if err := runLLMTest(); err != nil {
@@ -274,8 +290,10 @@ func runConfigModel() error {
274290
currentModel := ""
275291
provider := llm.Provider{Name: cfg.Provider, DisplayName: cfg.Provider}
276292
isCustom := false
293+
registryModels := []string(nil)
277294
if preset, isPreset := llm.LookupProvider(cfg.Provider); isPreset {
278295
provider = preset
296+
registryModels = append([]string(nil), preset.Models...)
279297
if entry, ok := cfg.Providers[cfg.Provider]; ok {
280298
currentModel = activeModelForProvider(cfg, cfg.Provider, entry)
281299
provider.Models = mergeModelLists(provider.Models, entry.Models)
@@ -293,7 +311,15 @@ func runConfigModel() error {
293311
provider.Models = mergeModelLists(entry.Models)
294312
}
295313

296-
m := newModelTUI(provider, currentModel)
314+
m := newModelTUIConfig(modelTUIConfig{
315+
Provider: provider,
316+
CurrentModel: currentModel,
317+
RegistryModels: registryModels,
318+
ExistingCfg: cfg,
319+
ConfigPath: configPath,
320+
ProviderName: cfg.Provider,
321+
IsCustom: isCustom,
322+
})
297323
p := tea.NewProgram(m)
298324
finalModel, err := p.Run()
299325
if err != nil {
@@ -302,7 +328,7 @@ func runConfigModel() error {
302328

303329
final := finalModel.(modelTUIModel)
304330
if final.cancelled {
305-
fmt.Println("Cancelled.")
331+
printWizardCancelled(final.savedInSession, "Model list changes")
306332
return nil
307333
}
308334

@@ -325,7 +351,9 @@ func runConfigModel() error {
325351
}
326352
entry := cfg.Providers[cfg.Provider]
327353
entry.Model = selectedModel
328-
if !modelListContains(provider.Models, selectedModel) {
354+
// Use registry-only list: provider.Models was captured before the TUI and
355+
// may include stale entry.Models from add/delete during the session.
356+
if !llm.ModelListContains(registryModels, selectedModel) {
329357
entry.Models = ensureModelInList(entry.Models, selectedModel)
330358
}
331359
cfg.Providers[cfg.Provider] = entry

cmd/opencodereview/provider_cmd_test.go

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package main
22

33
import (
44
"encoding/json"
5+
"io"
56
"os"
67
"path/filepath"
78
"testing"
@@ -203,3 +204,175 @@ func TestApplyOfficialProviderConfig_MissingFields(t *testing.T) {
203204
t.Fatal("expected error for missing provider/model")
204205
}
205206
}
207+
208+
func TestApplyOfficialProviderConfig_EmptyKeyClearsSavedAPIKey(t *testing.T) {
209+
t.Setenv("DEEPSEEK_API_KEY", "sk-from-env")
210+
dir := t.TempDir()
211+
configPath := filepath.Join(dir, "config.json")
212+
cfg := &Config{
213+
Provider: "deepseek",
214+
Model: "deepseek-v4-flash",
215+
Providers: map[string]ProviderEntry{
216+
"deepseek": {
217+
APIKey: "old-saved-key",
218+
Model: "deepseek-v4-flash",
219+
},
220+
},
221+
}
222+
223+
err := applyOfficialProviderConfig(configPath, cfg, providerTUIResult{
224+
provider: "deepseek",
225+
model: "deepseek-v4-flash",
226+
apiKey: "",
227+
})
228+
if err != nil {
229+
t.Fatalf("applyOfficialProviderConfig: %v", err)
230+
}
231+
if got := cfg.Providers["deepseek"].APIKey; got != "" {
232+
t.Errorf("in-memory APIKey = %q, want empty", got)
233+
}
234+
diskCfg, err := loadOrCreateConfig(configPath)
235+
if err != nil {
236+
t.Fatalf("load config: %v", err)
237+
}
238+
if got := diskCfg.Providers["deepseek"].APIKey; got != "" {
239+
t.Errorf("persisted APIKey = %q, want empty", got)
240+
}
241+
}
242+
243+
func TestApplyCustomProviderConfig_EmptyKeyClearsSavedAPIKey(t *testing.T) {
244+
dir := t.TempDir()
245+
configPath := filepath.Join(dir, "config.json")
246+
cfg := &Config{
247+
Provider: "aaa",
248+
Model: "test",
249+
CustomProviders: map[string]ProviderEntry{
250+
"aaa": {
251+
URL: "https://example.com/v1",
252+
Protocol: "openai",
253+
APIKey: "old-saved-key",
254+
Model: "test",
255+
Models: []string{"test"},
256+
},
257+
},
258+
}
259+
260+
err := applyCustomProviderConfig(configPath, cfg, providerTUIResult{
261+
provider: "aaa",
262+
model: "test",
263+
models: []string{"test"},
264+
apiKey: "",
265+
isCustom: true,
266+
url: "https://example.com/v1",
267+
protocol: "openai",
268+
})
269+
if err != nil {
270+
t.Fatalf("applyCustomProviderConfig: %v", err)
271+
}
272+
if got := cfg.CustomProviders["aaa"].APIKey; got != "" {
273+
t.Errorf("APIKey = %q, want empty", got)
274+
}
275+
}
276+
277+
func TestProviderTUIResult_ResolvedModel(t *testing.T) {
278+
r := providerTUIResult{
279+
provider: "baidu-qianfan",
280+
model: "glm-5",
281+
}
282+
if got := r.resolvedModel(); got != "glm-5" {
283+
t.Errorf("resolvedModel() = %q, want glm-5", got)
284+
}
285+
286+
r = providerTUIResult{
287+
provider: "baidu-qianfan",
288+
sessionModelPick: map[string]string{
289+
"baidu-qianfan": "glm-5",
290+
},
291+
}
292+
if got := r.resolvedModel(); got != "glm-5" {
293+
t.Errorf("resolvedModel() from session pick = %q, want glm-5", got)
294+
}
295+
296+
r = providerTUIResult{provider: "baidu-qianfan"}
297+
if got := r.resolvedModel(); got != "" {
298+
t.Errorf("resolvedModel() = %q, want empty", got)
299+
}
300+
}
301+
302+
func TestApplyOfficialProviderConfig_UsesSessionModelPick(t *testing.T) {
303+
t.Setenv("QIANFAN_API_KEY", "sk-from-env")
304+
dir := t.TempDir()
305+
configPath := filepath.Join(dir, "config.json")
306+
cfg := &Config{
307+
Provider: "deepseek",
308+
Model: "deepseek-v4-flash",
309+
Providers: map[string]ProviderEntry{
310+
"deepseek": {Model: "deepseek-v4-flash"},
311+
},
312+
}
313+
314+
err := applyOfficialProviderConfig(configPath, cfg, providerTUIResult{
315+
provider: "baidu-qianfan",
316+
apiKey: "",
317+
sessionModelPick: map[string]string{
318+
"baidu-qianfan": "glm-5",
319+
},
320+
})
321+
if err != nil {
322+
t.Fatalf("applyOfficialProviderConfig: %v", err)
323+
}
324+
if cfg.Provider != "baidu-qianfan" {
325+
t.Errorf("Provider = %q, want baidu-qianfan", cfg.Provider)
326+
}
327+
if cfg.Model != "glm-5" {
328+
t.Errorf("Model = %q, want glm-5", cfg.Model)
329+
}
330+
}
331+
332+
func TestPrintWizardCancelled(t *testing.T) {
333+
tests := []struct {
334+
name string
335+
savedInSession bool
336+
scope string
337+
want string
338+
}{
339+
{
340+
name: "no changes",
341+
savedInSession: false,
342+
scope: "Configuration changes",
343+
want: "Cancelled.\n",
344+
},
345+
{
346+
name: "provider wizard kept changes",
347+
savedInSession: true,
348+
scope: "Configuration changes",
349+
want: "Cancelled. (Configuration changes made during this session were kept.)\n",
350+
},
351+
{
352+
name: "model wizard kept changes",
353+
savedInSession: true,
354+
scope: "Model list changes",
355+
want: "Cancelled. (Model list changes made during this session were kept.)\n",
356+
},
357+
}
358+
for _, tc := range tests {
359+
t.Run(tc.name, func(t *testing.T) {
360+
old := os.Stdout
361+
r, w, err := os.Pipe()
362+
if err != nil {
363+
t.Fatal(err)
364+
}
365+
os.Stdout = w
366+
printWizardCancelled(tc.savedInSession, tc.scope)
367+
w.Close()
368+
os.Stdout = old
369+
got, err := io.ReadAll(r)
370+
if err != nil {
371+
t.Fatal(err)
372+
}
373+
if string(got) != tc.want {
374+
t.Errorf("output = %q, want %q", string(got), tc.want)
375+
}
376+
})
377+
}
378+
}

0 commit comments

Comments
 (0)