Skip to content

Commit 7516027

Browse files
authored
feat: improve config provider TUI interaction (#213)
* feat: improve config provider TUI interaction - Support wrap-around navigation in provider, custom provider, and model lists (up at first item jumps to last, down at last item jumps to first) - Add d key to delete custom models in model selection step with confirmation prompt - Add protocol selection (anthropic/openai) to manual configuration flow and respect UseAnthropic setting in saved config - Preserve manual protocol selection when re-entering the form - Fix manual form input fields losing focus after returning from next step - Use safe slice removal in removeFromSlice to avoid backing array corruption * fix: address code review feedback - Restore Blur() calls in handleManualFormEnter to prevent visual focus artifacts when transitioning between manual form steps - Add empty-providers guard in handleUp/handleDown to avoid negative index - Consolidate redundant protocolIdx checks into single if/else in viewManualTab and viewCustomProviderForm for clarity * fix: persist custom model deletions and support masked auth token editing - Track deleted custom models in TUI and apply them on exit (both confirm and cancel paths) to keep config.json in sync - Clear custom provider's active Model field when that model is deleted, preventing stale "model" reference in the provider list label - Add masked display for manual config Auth Token to allow easy re-entry, matching the official provider flow (any key clears and starts fresh) * test: update manual form tests for masked auth token behavior Update TestProviderTUI_ManualFormPrefilledValues, TestProviderTUI_ManualFormEscRestoresOriginalValues, and TestProviderTUI_ManualFormPrefilledWhenProviderSet to assert the new masked display state (manualTokenMasked + manualTokenOriginal) instead of the raw token value. * feat: refine config provider TUI flows and session persistence Custom provider interactions: - Simplify create/edit form to Name → Protocol → URL → API Key → Auth Header - After create or edit save, jump straight into the model list for that provider - Support comma-separated model names in the custom model input - Show masked API key in edit form; empty Auth Header defaults to (Authorization) - Populate edit form from existingCfg to avoid stale list data after in-session saves Model selection and highlight: - Green highlight follows the persisted active model, not the cursor position - Prefer provider entry.model over global cfg.model when resolving active model - Deleting a non-active model keeps the current green highlight unchanged Delete and navigation: - Delete custom providers (d) and custom models (d) with confirmation prompts - Persist create/edit/model-select/add/delete changes to disk during the session - Fix custom provider deletion not surviving Esc exe-entry Tests: - Add coverage for model highlight, delete-model behavior, and create→model-list flow - Update manual/custom form tests for masked token and session save behavior * refactor: address provider TUI code review feedback - Remove dead helper removeFromSlice (superseded by removeModels) - Move misplaced doc comment to applyEditCustomProviderSave - Drop redundant applyProviderDeletions post-TUI call so provider deletions rely solely on the in-session save - Fix brace/indent drift in updateDeleteModelConfirm and cache the model list instead of recomputing m.models() twice * feat: refine provider TUI flows and manual config form Custom provider flow: - Default protocol to anthropic on the new-provider form - Single-name model input; reject duplicates with inline error and preserve the typed value so the user can edit instead of re-typing - Drop the global green highlight; cursor/blue is the only selection cue Manual configuration form: - Add Auth Header step (URL → Protocol → Model → Auth Token → Auth Header) and persist it to Llm.AuthHeader on confirm - Reorder so Auth Header is always entered last - Make Auth Token required; empty Enter stays on the field - Show every field's label on every render, even when empty, matching the custom-provider form style Tests: - Cover custom model input add / duplicate paths - Cover manual form prefill of Llm.AuthHeader - Cover that deleting a non-active model keeps the active model intact * Improve provider TUI validation, persistence, and code clarity. Address code review feedback: align custom Auth Header validation with manual mode; fix savedInSession after model deletion; refactor applyEditCustomProviderSave to return error; remove dead applyModelDeletions/deletedModels; document ExtraBody shallow-copy limit. Also fix manual/custom form UX (token skip on edit, k key input, formError scoping, switch indentation). * Fix provider/model TUI list ordering and add test coverage. Address review and UX feedback: remove model list sorting in provider and config model TUIs; preserve Models list order when selecting active model (ensureModelInList); add test for duplicate rename on custom provider edit. Includes prior review fixes for savedInSession, applyEditCustomProviderSave error return, and dead code removal. * Normalize AuthHeader at apply layer and simplify UseAnthropic assignment. Call NormalizeAuthHeader in applyManualConfig and applyCustomProviderConfig before save; simplify UseAnthropic assignment in manual config; add unit tests. * Address review: lowercase error strings and newProviderTUI signature. Use lowercase "failed to save" errors; replace variadic configPath with string and remove configPathFromArgs; pass "" in tests when no path; gofmt provider_tui.go.
1 parent a1e4cf6 commit 7516027

5 files changed

Lines changed: 1594 additions & 271 deletions

File tree

cmd/opencodereview/config_cmd.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -394,6 +394,16 @@ func parseModelListValue(value string) ([]string, error) {
394394
return normalizeModelList(strings.Split(value, ",")), nil
395395
}
396396

397+
func activeModelForProvider(cfg *Config, providerName string, entry ProviderEntry) string {
398+
if entry.Model != "" {
399+
return entry.Model
400+
}
401+
if cfg != nil && cfg.Provider == providerName && cfg.Model != "" {
402+
return cfg.Model
403+
}
404+
return ""
405+
}
406+
397407
func normalizeModelList(models []string) []string {
398408
out := make([]string, 0, len(models))
399409
seen := make(map[string]struct{}, len(models))
@@ -419,6 +429,19 @@ func mergeModelLists(lists ...[]string) []string {
419429
return normalizeModelList(merged)
420430
}
421431

432+
// ensureModelInList appends model to the end when missing; never reorders existing entries.
433+
func ensureModelInList(models []string, model string) []string {
434+
model = strings.TrimSpace(model)
435+
if model == "" {
436+
return models
437+
}
438+
if modelListContains(models, model) {
439+
return models
440+
}
441+
out := append([]string(nil), models...)
442+
return append(out, model)
443+
}
444+
422445
func modelListContains(models []string, target string) bool {
423446
target = strings.TrimSpace(target)
424447
for _, model := range models {

cmd/opencodereview/config_cmd_test.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -416,3 +416,23 @@ func TestUnsetInvalidKey(t *testing.T) {
416416
})
417417
}
418418
}
419+
420+
func TestEnsureModelInList(t *testing.T) {
421+
models := []string{"test-model", "test-model-2", "bbb", "aaa", "test-model-3"}
422+
423+
got := ensureModelInList(models, "test-model-3")
424+
if len(got) != len(models) {
425+
t.Fatalf("existing model should not reorder: got %v", got)
426+
}
427+
for i := range models {
428+
if got[i] != models[i] {
429+
t.Errorf("models[%d] = %q, want %q", i, got[i], models[i])
430+
}
431+
}
432+
433+
got = ensureModelInList(models, "new-model")
434+
want := append(append([]string(nil), models...), "new-model")
435+
if len(got) != len(want) || got[len(got)-1] != "new-model" {
436+
t.Errorf("new model should append: got %v, want %v", got, want)
437+
}
438+
}

cmd/opencodereview/provider_cmd.go

Lines changed: 57 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ func runConfigProvider() error {
2222
return fmt.Errorf("load config: %w", err)
2323
}
2424

25-
m := newProviderTUI(cfg)
25+
m := newProviderTUI(cfg, configPath)
2626
p := tea.NewProgram(m)
2727
finalModel, err := p.Run()
2828
if err != nil {
@@ -31,19 +31,11 @@ func runConfigProvider() error {
3131

3232
final := finalModel.(providerTUIModel)
3333

34-
if len(final.deletedProviders) > 0 {
35-
clearedActive, err := applyProviderDeletions(configPath, cfg, final.deletedProviders)
36-
if err != nil {
37-
return err
38-
}
39-
if clearedActive && !final.confirmed {
40-
fmt.Fprintf(os.Stderr, "[ocr] WARNING: active provider was deleted; 'provider' and 'model' have been cleared.\n")
41-
fmt.Fprintf(os.Stderr, "[ocr] Run 'ocr config provider' to select a new provider.\n")
42-
}
43-
}
44-
4534
if !final.confirmed {
46-
if len(final.deletedProviders) > 0 {
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 {
4739
return nil
4840
}
4941
fmt.Println("Cancelled.")
@@ -82,6 +74,21 @@ func applyProviderDeletions(configPath string, cfg *Config, names []string) (boo
8274
return clearedActive, nil
8375
}
8476

77+
func removeModels(existing, toRemove []string) []string {
78+
removeSet := make(map[string]struct{}, len(toRemove))
79+
for _, m := range toRemove {
80+
removeSet[m] = struct{}{}
81+
}
82+
result := make([]string, 0, len(existing))
83+
for _, m := range existing {
84+
if _, found := removeSet[m]; found {
85+
continue
86+
}
87+
result = append(result, m)
88+
}
89+
return result
90+
}
91+
8592
func applyManualConfig(configPath string, cfg *Config, result providerTUIResult) error {
8693
if result.url == "" {
8794
return fmt.Errorf("URL is required for manual configuration")
@@ -95,13 +102,21 @@ func applyManualConfig(configPath string, cfg *Config, result providerTUIResult)
95102
cfg.Llm.URL = result.url
96103
cfg.Llm.Model = result.model
97104
cfg.Llm.AuthToken = result.apiKey
105+
authHeader, err := llm.NormalizeAuthHeader(result.authHeader)
106+
if err != nil {
107+
return fmt.Errorf("invalid auth_header: %w", err)
108+
}
109+
cfg.Llm.AuthHeader = authHeader
110+
useAnthropic := result.protocol == "anthropic"
111+
cfg.Llm.UseAnthropic = &useAnthropic
98112

99113
if err := saveConfig(configPath, cfg); err != nil {
100114
return err
101115
}
102116

103117
fmt.Println("\nManual configuration saved.")
104118
fmt.Printf("URL: %s\n", result.url)
119+
fmt.Printf("Protocol: %s\n", result.protocol)
105120
fmt.Printf("Model: %s\n", result.model)
106121

107122
fmt.Println("\nTesting connection...")
@@ -129,31 +144,49 @@ func applyCustomProviderConfig(configPath string, cfg *Config, result providerTU
129144
entry := cfg.CustomProviders[result.provider]
130145
entry.Model = result.model
131146
if len(result.models) > 0 {
132-
entry.Models = mergeModelLists([]string{result.model}, result.models)
147+
entry.Models = append([]string(nil), result.models...)
133148
}
149+
entry.Models = ensureModelInList(entry.Models, result.model)
134150
if result.url != "" {
135151
entry.URL = result.url
136152
}
137153
if result.protocol != "" {
138154
entry.Protocol = result.protocol
139155
}
140156
if result.authHeader != "" {
141-
entry.AuthHeader = result.authHeader
157+
authHeader, err := llm.NormalizeAuthHeader(result.authHeader)
158+
if err != nil {
159+
return fmt.Errorf("invalid auth_header: %w", err)
160+
}
161+
entry.AuthHeader = authHeader
142162
}
143163
if result.apiKey != "" {
144164
entry.APIKey = result.apiKey
145165
}
146166
cfg.CustomProviders[result.provider] = entry
147167

148-
if cfg.Provider != result.provider {
149-
cfg.Model = ""
168+
if !result.isEdit {
169+
cfg.Provider = result.provider
170+
cfg.Model = result.model
171+
} else if cfg.Provider == result.provider {
172+
cfg.Model = result.model
150173
}
151-
cfg.Provider = result.provider
152174

153175
if err := saveConfig(configPath, cfg); err != nil {
154176
return err
155177
}
156178

179+
if result.isEdit {
180+
if cfg.Provider == result.provider {
181+
fmt.Printf("\nActive provider %q updated.\n", result.provider)
182+
} else {
183+
fmt.Printf("\nCustom provider %q updated (not currently active).\n", result.provider)
184+
}
185+
fmt.Printf("Model: %s\n", result.model)
186+
fmt.Println("\nTip: run 'ocr config model' to switch model later.")
187+
return nil
188+
}
189+
157190
fmt.Printf("\nProvider set to: %s (custom)\n", result.provider)
158191
fmt.Printf("Model: %s\n", result.model)
159192

@@ -203,6 +236,7 @@ func applyOfficialProviderConfig(configPath string, cfg *Config, result provider
203236
cfg.Model = ""
204237
}
205238
cfg.Provider = result.provider
239+
cfg.Model = result.model
206240

207241
if err := saveConfig(configPath, cfg); err != nil {
208242
return err
@@ -243,7 +277,7 @@ func runConfigModel() error {
243277
if preset, isPreset := llm.LookupProvider(cfg.Provider); isPreset {
244278
provider = preset
245279
if entry, ok := cfg.Providers[cfg.Provider]; ok {
246-
currentModel = entry.Model
280+
currentModel = activeModelForProvider(cfg, cfg.Provider, entry)
247281
provider.Models = mergeModelLists(provider.Models, entry.Models)
248282
}
249283
} else {
@@ -252,15 +286,12 @@ func runConfigModel() error {
252286
if !ok {
253287
return fmt.Errorf("provider %q is not configured in custom_providers", cfg.Provider)
254288
}
255-
currentModel = entry.Model
289+
currentModel = activeModelForProvider(cfg, cfg.Provider, entry)
256290
provider.DisplayName = cfg.Provider + " (custom)"
257291
provider.Protocol = entry.Protocol
258292
provider.BaseURL = entry.URL
259293
provider.Models = mergeModelLists(entry.Models)
260294
}
261-
if currentModel == "" {
262-
currentModel = cfg.Model
263-
}
264295

265296
m := newModelTUI(provider, currentModel)
266297
p := tea.NewProgram(m)
@@ -286,7 +317,7 @@ func runConfigModel() error {
286317
}
287318
entry := cfg.CustomProviders[cfg.Provider]
288319
entry.Model = selectedModel
289-
entry.Models = mergeModelLists([]string{selectedModel}, entry.Models)
320+
entry.Models = ensureModelInList(entry.Models, selectedModel)
290321
cfg.CustomProviders[cfg.Provider] = entry
291322
} else {
292323
if cfg.Providers == nil {
@@ -295,10 +326,11 @@ func runConfigModel() error {
295326
entry := cfg.Providers[cfg.Provider]
296327
entry.Model = selectedModel
297328
if !modelListContains(provider.Models, selectedModel) {
298-
entry.Models = mergeModelLists([]string{selectedModel}, entry.Models)
329+
entry.Models = ensureModelInList(entry.Models, selectedModel)
299330
}
300331
cfg.Providers[cfg.Provider] = entry
301332
}
333+
cfg.Model = selectedModel
302334

303335
if err := saveConfig(configPath, cfg); err != nil {
304336
return err

0 commit comments

Comments
 (0)