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.
This commit is contained in:
wxwxwxw_orange 2026-06-26 17:01:57 +08:00 committed by GitHub
parent a1e4cf6b43
commit 751602776e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 1595 additions and 272 deletions

View file

@ -394,6 +394,16 @@ func parseModelListValue(value string) ([]string, error) {
return normalizeModelList(strings.Split(value, ",")), nil
}
func activeModelForProvider(cfg *Config, providerName string, entry ProviderEntry) string {
if entry.Model != "" {
return entry.Model
}
if cfg != nil && cfg.Provider == providerName && cfg.Model != "" {
return cfg.Model
}
return ""
}
func normalizeModelList(models []string) []string {
out := make([]string, 0, len(models))
seen := make(map[string]struct{}, len(models))
@ -419,6 +429,19 @@ func mergeModelLists(lists ...[]string) []string {
return normalizeModelList(merged)
}
// ensureModelInList appends model to the end when missing; never reorders existing entries.
func ensureModelInList(models []string, model string) []string {
model = strings.TrimSpace(model)
if model == "" {
return models
}
if modelListContains(models, model) {
return models
}
out := append([]string(nil), models...)
return append(out, model)
}
func modelListContains(models []string, target string) bool {
target = strings.TrimSpace(target)
for _, model := range models {

View file

@ -416,3 +416,23 @@ func TestUnsetInvalidKey(t *testing.T) {
})
}
}
func TestEnsureModelInList(t *testing.T) {
models := []string{"test-model", "test-model-2", "bbb", "aaa", "test-model-3"}
got := ensureModelInList(models, "test-model-3")
if len(got) != len(models) {
t.Fatalf("existing model should not reorder: got %v", got)
}
for i := range models {
if got[i] != models[i] {
t.Errorf("models[%d] = %q, want %q", i, got[i], models[i])
}
}
got = ensureModelInList(models, "new-model")
want := append(append([]string(nil), models...), "new-model")
if len(got) != len(want) || got[len(got)-1] != "new-model" {
t.Errorf("new model should append: got %v, want %v", got, want)
}
}

View file

@ -22,7 +22,7 @@ func runConfigProvider() error {
return fmt.Errorf("load config: %w", err)
}
m := newProviderTUI(cfg)
m := newProviderTUI(cfg, configPath)
p := tea.NewProgram(m)
finalModel, err := p.Run()
if err != nil {
@ -31,19 +31,11 @@ func runConfigProvider() error {
final := finalModel.(providerTUIModel)
if len(final.deletedProviders) > 0 {
clearedActive, err := applyProviderDeletions(configPath, cfg, final.deletedProviders)
if err != nil {
return err
}
if clearedActive && !final.confirmed {
fmt.Fprintf(os.Stderr, "[ocr] WARNING: active provider was deleted; 'provider' and 'model' have been cleared.\n")
fmt.Fprintf(os.Stderr, "[ocr] Run 'ocr config provider' to select a new provider.\n")
}
}
if !final.confirmed {
if len(final.deletedProviders) > 0 {
// TUI persists changes (create/edit/model/add/delete) directly to disk
// during the session, so the on-disk file is already up to date for any
// savedInSession operation. No additional post-TUI apply step is needed.
if final.savedInSession {
return nil
}
fmt.Println("Cancelled.")
@ -82,6 +74,21 @@ func applyProviderDeletions(configPath string, cfg *Config, names []string) (boo
return clearedActive, nil
}
func removeModels(existing, toRemove []string) []string {
removeSet := make(map[string]struct{}, len(toRemove))
for _, m := range toRemove {
removeSet[m] = struct{}{}
}
result := make([]string, 0, len(existing))
for _, m := range existing {
if _, found := removeSet[m]; found {
continue
}
result = append(result, m)
}
return result
}
func applyManualConfig(configPath string, cfg *Config, result providerTUIResult) error {
if result.url == "" {
return fmt.Errorf("URL is required for manual configuration")
@ -95,6 +102,13 @@ func applyManualConfig(configPath string, cfg *Config, result providerTUIResult)
cfg.Llm.URL = result.url
cfg.Llm.Model = result.model
cfg.Llm.AuthToken = result.apiKey
authHeader, err := llm.NormalizeAuthHeader(result.authHeader)
if err != nil {
return fmt.Errorf("invalid auth_header: %w", err)
}
cfg.Llm.AuthHeader = authHeader
useAnthropic := result.protocol == "anthropic"
cfg.Llm.UseAnthropic = &useAnthropic
if err := saveConfig(configPath, cfg); err != nil {
return err
@ -102,6 +116,7 @@ func applyManualConfig(configPath string, cfg *Config, result providerTUIResult)
fmt.Println("\nManual configuration saved.")
fmt.Printf("URL: %s\n", result.url)
fmt.Printf("Protocol: %s\n", result.protocol)
fmt.Printf("Model: %s\n", result.model)
fmt.Println("\nTesting connection...")
@ -129,8 +144,9 @@ func applyCustomProviderConfig(configPath string, cfg *Config, result providerTU
entry := cfg.CustomProviders[result.provider]
entry.Model = result.model
if len(result.models) > 0 {
entry.Models = mergeModelLists([]string{result.model}, result.models)
entry.Models = append([]string(nil), result.models...)
}
entry.Models = ensureModelInList(entry.Models, result.model)
if result.url != "" {
entry.URL = result.url
}
@ -138,22 +154,39 @@ func applyCustomProviderConfig(configPath string, cfg *Config, result providerTU
entry.Protocol = result.protocol
}
if result.authHeader != "" {
entry.AuthHeader = result.authHeader
authHeader, err := llm.NormalizeAuthHeader(result.authHeader)
if err != nil {
return fmt.Errorf("invalid auth_header: %w", err)
}
entry.AuthHeader = authHeader
}
if result.apiKey != "" {
entry.APIKey = result.apiKey
}
cfg.CustomProviders[result.provider] = entry
if cfg.Provider != result.provider {
cfg.Model = ""
if !result.isEdit {
cfg.Provider = result.provider
cfg.Model = result.model
} else if cfg.Provider == result.provider {
cfg.Model = result.model
}
cfg.Provider = result.provider
if err := saveConfig(configPath, cfg); err != nil {
return err
}
if result.isEdit {
if cfg.Provider == result.provider {
fmt.Printf("\nActive provider %q updated.\n", result.provider)
} else {
fmt.Printf("\nCustom provider %q updated (not currently active).\n", result.provider)
}
fmt.Printf("Model: %s\n", result.model)
fmt.Println("\nTip: run 'ocr config model' to switch model later.")
return nil
}
fmt.Printf("\nProvider set to: %s (custom)\n", result.provider)
fmt.Printf("Model: %s\n", result.model)
@ -203,6 +236,7 @@ func applyOfficialProviderConfig(configPath string, cfg *Config, result provider
cfg.Model = ""
}
cfg.Provider = result.provider
cfg.Model = result.model
if err := saveConfig(configPath, cfg); err != nil {
return err
@ -243,7 +277,7 @@ func runConfigModel() error {
if preset, isPreset := llm.LookupProvider(cfg.Provider); isPreset {
provider = preset
if entry, ok := cfg.Providers[cfg.Provider]; ok {
currentModel = entry.Model
currentModel = activeModelForProvider(cfg, cfg.Provider, entry)
provider.Models = mergeModelLists(provider.Models, entry.Models)
}
} else {
@ -252,15 +286,12 @@ func runConfigModel() error {
if !ok {
return fmt.Errorf("provider %q is not configured in custom_providers", cfg.Provider)
}
currentModel = entry.Model
currentModel = activeModelForProvider(cfg, cfg.Provider, entry)
provider.DisplayName = cfg.Provider + " (custom)"
provider.Protocol = entry.Protocol
provider.BaseURL = entry.URL
provider.Models = mergeModelLists(entry.Models)
}
if currentModel == "" {
currentModel = cfg.Model
}
m := newModelTUI(provider, currentModel)
p := tea.NewProgram(m)
@ -286,7 +317,7 @@ func runConfigModel() error {
}
entry := cfg.CustomProviders[cfg.Provider]
entry.Model = selectedModel
entry.Models = mergeModelLists([]string{selectedModel}, entry.Models)
entry.Models = ensureModelInList(entry.Models, selectedModel)
cfg.CustomProviders[cfg.Provider] = entry
} else {
if cfg.Providers == nil {
@ -295,10 +326,11 @@ func runConfigModel() error {
entry := cfg.Providers[cfg.Provider]
entry.Model = selectedModel
if !modelListContains(provider.Models, selectedModel) {
entry.Models = mergeModelLists([]string{selectedModel}, entry.Models)
entry.Models = ensureModelInList(entry.Models, selectedModel)
}
cfg.Providers[cfg.Provider] = entry
}
cfg.Model = selectedModel
if err := saveConfig(configPath, cfg); err != nil {
return err

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,8 @@
package main
import (
"os"
"path/filepath"
"sort"
"strings"
"testing"
@ -33,13 +35,13 @@ func tabKeyMsg() tea.KeyPressMsg {
}
func charKey(c rune) tea.KeyPressMsg {
return tea.KeyPressMsg{Code: c}
return tea.KeyPressMsg{Code: c, Text: string(c)}
}
// --- Tab switching tests ---
func TestProviderTUI_TabSwitchRight(t *testing.T) {
m := newProviderTUI(&Config{})
m := newProviderTUI(&Config{}, "")
if m.activeTab != tabOfficial {
t.Fatalf("initial tab = %d, want %d", m.activeTab, tabOfficial)
}
@ -65,7 +67,7 @@ func TestProviderTUI_TabSwitchRight(t *testing.T) {
}
func TestProviderTUI_TabSwitchLeft(t *testing.T) {
m := newProviderTUI(&Config{})
m := newProviderTUI(&Config{}, "")
// Go to manual tab first
result, _ := m.Update(rightKey())
@ -97,7 +99,7 @@ func TestProviderTUI_TabSwitchLeft(t *testing.T) {
}
func TestProviderTUI_TabKeyCycles(t *testing.T) {
m := newProviderTUI(&Config{})
m := newProviderTUI(&Config{}, "")
result, _ := m.Update(tabKeyMsg())
m2 := result.(providerTUIModel)
@ -119,7 +121,7 @@ func TestProviderTUI_TabKeyCycles(t *testing.T) {
}
func TestProviderTUI_TabSwitchOnlyOnStepProvider(t *testing.T) {
m := newProviderTUI(&Config{})
m := newProviderTUI(&Config{}, "")
// Advance to stepModel
result, _ := m.Update(enterKey())
@ -139,7 +141,7 @@ func TestProviderTUI_TabSwitchOnlyOnStepProvider(t *testing.T) {
// --- Official tab tests (updated from original) ---
func TestProviderTUI_OfficialProvidersSortedByDisplayName(t *testing.T) {
m := newProviderTUI(&Config{})
m := newProviderTUI(&Config{}, "")
displayNames := make([]string, len(m.providers))
normalized := make([]string, len(m.providers))
@ -154,7 +156,7 @@ func TestProviderTUI_OfficialProvidersSortedByDisplayName(t *testing.T) {
}
func TestProviderTUI_EscFromModelGoesBackToProvider(t *testing.T) {
m := newProviderTUI(&Config{})
m := newProviderTUI(&Config{}, "")
result, _ := m.Update(enterKey())
m2 := result.(providerTUIModel)
@ -173,7 +175,7 @@ func TestProviderTUI_EscFromModelGoesBackToProvider(t *testing.T) {
}
func TestProviderTUI_EscFromAPIKeyGoesBackToModel(t *testing.T) {
m := newProviderTUI(&Config{})
m := newProviderTUI(&Config{}, "")
result, _ := m.Update(enterKey())
m2 := result.(providerTUIModel)
@ -192,7 +194,7 @@ func TestProviderTUI_EscFromAPIKeyGoesBackToModel(t *testing.T) {
}
func TestProviderTUI_EscFromProviderCancels(t *testing.T) {
m := newProviderTUI(&Config{})
m := newProviderTUI(&Config{}, "")
result, cmd := m.Update(escKey())
m2 := result.(providerTUIModel)
@ -214,7 +216,7 @@ func TestProviderTUI_EscKeyString(t *testing.T) {
// --- Manual tab tests ---
func TestProviderTUI_ManualTabEnterStartsForm(t *testing.T) {
m := newProviderTUI(&Config{})
m := newProviderTUI(&Config{}, "")
// Switch to manual tab
result, _ := m.Update(rightKey())
@ -237,7 +239,7 @@ func TestProviderTUI_ManualTabEnterStartsForm(t *testing.T) {
}
func TestProviderTUI_ManualFormEscFromURLExitsForm(t *testing.T) {
m := newProviderTUI(&Config{})
m := newProviderTUI(&Config{}, "")
// Switch to manual tab and enter form
result, _ := m.Update(rightKey())
@ -269,7 +271,7 @@ func TestProviderTUI_ManualFormEscRestoresOriginalValues(t *testing.T) {
AuthToken: "token-123",
},
}
m := newProviderTUI(cfg)
m := newProviderTUI(cfg, "")
// Enter the form
result, _ := m.Update(enterKey())
@ -293,8 +295,11 @@ func TestProviderTUI_ManualFormEscRestoresOriginalValues(t *testing.T) {
if m3.manualModelInput.Value() != "test-model" {
t.Errorf("Model not restored: got %q, want %q", m3.manualModelInput.Value(), "test-model")
}
if m3.manualTokenInput.Value() != "token-123" {
t.Errorf("Token not restored: got %q, want %q", m3.manualTokenInput.Value(), "token-123")
if !m3.manualTokenMasked {
t.Error("Token should be masked after Esc restore")
}
if m3.manualTokenOriginal != "token-123" {
t.Errorf("Token original not restored: got %q, want %q", m3.manualTokenOriginal, "token-123")
}
}
@ -306,7 +311,7 @@ func TestProviderTUI_ManualFormPrefilledValues(t *testing.T) {
AuthToken: "token-123",
},
}
m := newProviderTUI(cfg)
m := newProviderTUI(cfg, "")
if m.activeTab != tabManual {
t.Fatalf("should auto-select manual tab when Llm.URL is set, got %d", m.activeTab)
@ -317,8 +322,14 @@ func TestProviderTUI_ManualFormPrefilledValues(t *testing.T) {
if m.manualModelInput.Value() != "test-model" {
t.Errorf("Model not prefilled: got %q", m.manualModelInput.Value())
}
if m.manualTokenInput.Value() != "token-123" {
t.Errorf("Token not prefilled: got %q", m.manualTokenInput.Value())
if !m.manualTokenMasked {
t.Error("Token should be masked when prefilled")
}
if m.manualTokenOriginal != "token-123" {
t.Errorf("Token original not prefilled: got %q, want %q", m.manualTokenOriginal, "token-123")
}
if m.manualTokenInput.Value() != strings.Repeat("*", 20) {
t.Errorf("Token input not masked display: got %q", m.manualTokenInput.Value())
}
}
@ -330,7 +341,7 @@ func TestProviderTUI_ManualResult(t *testing.T) {
AuthToken: "token-123",
},
}
m := newProviderTUI(cfg)
m := newProviderTUI(cfg, "")
// Enter the form
result, _ := m.Update(enterKey())
@ -361,7 +372,7 @@ func TestProviderTUI_ManualFormPrefilledWhenProviderSet(t *testing.T) {
AuthToken: "manual-token",
},
}
m := newProviderTUI(cfg)
m := newProviderTUI(cfg, "")
if m.activeTab != tabCustom {
t.Fatalf("should auto-select custom tab, got %d", m.activeTab)
@ -372,15 +383,77 @@ func TestProviderTUI_ManualFormPrefilledWhenProviderSet(t *testing.T) {
if m.manualModelInput.Value() != "manual-model" {
t.Errorf("Model not prefilled: got %q", m.manualModelInput.Value())
}
if m.manualTokenInput.Value() != "manual-token" {
t.Errorf("Token not prefilled: got %q", m.manualTokenInput.Value())
if !m.manualTokenMasked {
t.Error("Token should be masked when prefilled")
}
if m.manualTokenOriginal != "manual-token" {
t.Errorf("Token original not prefilled: got %q, want %q", m.manualTokenOriginal, "manual-token")
}
}
func TestProviderTUI_ManualFormPrefillsAuthHeader(t *testing.T) {
cfg := &Config{
Llm: LlmConfig{
URL: "https://manual.example.com/v1",
Model: "manual-model",
AuthToken: "manual-token",
AuthHeader: "X-Custom-Auth",
},
}
m := newProviderTUI(cfg, "")
if got := m.manualAuthHeaderInput.Value(); got != "X-Custom-Auth" {
t.Errorf("manualAuthHeaderInput not prefilled: got %q, want %q", got, "X-Custom-Auth")
}
}
func TestProviderTUI_ManualFormSkipsEmptyTokenWhenOriginalExists(t *testing.T) {
cfg := &Config{
Llm: LlmConfig{
URL: "https://example.com/v1",
Model: "test-model",
AuthToken: "token-123",
},
}
m := newProviderTUI(cfg, "")
m.inManualForm = true
m.manualStep = manualStepAuthToken
m.manualTokenOriginal = "token-123"
m.manualTokenMasked = false
m.manualTokenInput.SetValue("")
m.manualTokenInput.Focus()
result, _ := m.Update(enterKey())
m2 := result.(providerTUIModel)
if m2.manualStep != manualStepAuthHeader {
t.Errorf("manualStep = %d, want %d", m2.manualStep, manualStepAuthHeader)
}
m2.confirmed = true
r := m2.result()
if r.apiKey != "token-123" {
t.Errorf("result apiKey = %q, want %q", r.apiKey, "token-123")
}
}
func TestProviderTUI_ManualFormRequiresTokenOnFirstSetup(t *testing.T) {
m := newProviderTUI(&Config{}, "")
m.inManualForm = true
m.manualStep = manualStepAuthToken
m.manualTokenInput.SetValue("")
m.manualTokenInput.Focus()
result, _ := m.Update(enterKey())
m2 := result.(providerTUIModel)
if m2.manualStep != manualStepAuthToken {
t.Errorf("should stay on auth token step, got %d", m2.manualStep)
}
}
// --- Custom tab tests ---
func TestProviderTUI_CustomTabShowsAddOption(t *testing.T) {
m := newProviderTUI(&Config{})
m := newProviderTUI(&Config{}, "")
// Switch to custom tab
result, _ := m.Update(rightKey())
@ -396,7 +469,7 @@ func TestProviderTUI_CustomTabShowsAddOption(t *testing.T) {
}
func TestProviderTUI_CustomTabSelectAddStartsForm(t *testing.T) {
m := newProviderTUI(&Config{})
m := newProviderTUI(&Config{}, "")
// Switch to custom tab
result, _ := m.Update(rightKey())
@ -414,7 +487,7 @@ func TestProviderTUI_CustomTabSelectAddStartsForm(t *testing.T) {
}
func TestProviderTUI_CustomFormEscFromNameExitsForm(t *testing.T) {
m := newProviderTUI(&Config{})
m := newProviderTUI(&Config{}, "")
// Switch to custom tab and start form
result, _ := m.Update(rightKey())
@ -436,6 +509,231 @@ func TestProviderTUI_CustomFormEscFromNameExitsForm(t *testing.T) {
}
}
func TestProviderTUI_CustomFormRejectsDuplicateName(t *testing.T) {
cfg := &Config{
Provider: "stepfun",
CustomProviders: map[string]ProviderEntry{
"stepfun": {Model: "xxx"},
},
}
m := newProviderTUI(cfg, "")
result, _ := m.Update(downKey())
m2 := result.(providerTUIModel)
result, _ = m2.Update(enterKey())
m3 := result.(providerTUIModel)
if !m3.creatingCustom {
t.Fatal("should be creating custom")
}
m3.cpNameInput.SetValue("stepfun")
result, _ = m3.Update(enterKey())
m4 := result.(providerTUIModel)
if m4.cpStep != cpStepName {
t.Errorf("cpStep = %d, want %d", m4.cpStep, cpStepName)
}
if m4.formError == "" {
t.Error("expected formError for duplicate name")
}
if !strings.Contains(m4.formError, "stepfun") {
t.Errorf("formError = %q, want to mention stepfun", m4.formError)
}
result, _ = m4.Update(charKey('x'))
m4b := result.(providerTUIModel)
if m4b.formError != "" {
t.Errorf("formError should clear on keystroke, got %q", m4b.formError)
}
m4b.cpNameInput.SetValue("stepfun2")
result, _ = m4b.Update(enterKey())
m5 := result.(providerTUIModel)
if m5.cpStep != cpStepProtocol {
t.Errorf("cpStep = %d, want %d", m5.cpStep, cpStepProtocol)
}
if m5.formError != "" {
t.Errorf("formError = %q, want empty after valid name", m5.formError)
}
}
func TestProviderTUI_CustomFormRejectsInvalidAuthHeader(t *testing.T) {
dir := t.TempDir()
configPath := filepath.Join(dir, "config.json")
cfg := &Config{}
m := newProviderTUI(cfg, configPath)
result, _ := m.Update(rightKey())
m2 := result.(providerTUIModel)
result, _ = m2.Update(enterKey())
m3 := result.(providerTUIModel)
m3.cpNameInput.SetValue("my-new")
result, _ = m3.Update(enterKey())
m4 := result.(providerTUIModel)
result, _ = m4.Update(enterKey())
m5 := result.(providerTUIModel)
m5.cpURLInput.SetValue("https://api.example.com")
result, _ = m5.Update(enterKey())
m6 := result.(providerTUIModel)
result, _ = m6.Update(enterKey())
m7 := result.(providerTUIModel)
if m7.cpStep != cpStepAuthHeader {
t.Fatalf("cpStep = %d, want %d", m7.cpStep, cpStepAuthHeader)
}
for _, c := range "bad-header" {
result, _ = m7.Update(charKey(c))
m7 = result.(providerTUIModel)
}
result, _ = m7.Update(enterKey())
m8 := result.(providerTUIModel)
if m8.cpStep != cpStepAuthHeader {
t.Errorf("cpStep = %d, want %d", m8.cpStep, cpStepAuthHeader)
}
if m8.formError == "" {
t.Error("expected formError for invalid auth header")
}
if !strings.Contains(m8.formError, "Unsupported Auth Header") {
t.Errorf("formError = %q, want unsupported auth header message", m8.formError)
}
if !m8.creatingCustom {
t.Error("creatingCustom should remain true when validation fails")
}
if _, err := os.Stat(configPath); err == nil {
t.Error("config should not be saved for invalid auth header")
}
}
func TestProviderTUI_CustomFormEditRejectsInvalidAuthHeader(t *testing.T) {
dir := t.TempDir()
configPath := filepath.Join(dir, "config.json")
cfg := &Config{
CustomProviders: map[string]ProviderEntry{
"stepfun": {
URL: "https://api.example.com",
Protocol: "anthropic",
AuthHeader: "authorization",
},
},
}
m := newProviderTUI(cfg, configPath)
m.activeTab = tabCustom
m.customIdx = 0
m.enterEditCustomProvider()
m.cpStep = cpStepAuthHeader
m.cpAuthInput.SetValue("bad-header")
m.cpAuthInput.Focus()
result, _ := m.Update(enterKey())
m2 := result.(providerTUIModel)
if m2.cpStep != cpStepAuthHeader {
t.Errorf("cpStep = %d, want %d", m2.cpStep, cpStepAuthHeader)
}
if m2.formError == "" {
t.Error("expected formError for invalid auth header")
}
if !m2.editingCustom {
t.Error("editingCustom should remain true when validation fails")
}
if got := cfg.CustomProviders["stepfun"].AuthHeader; got != "authorization" {
t.Errorf("AuthHeader = %q, want unchanged %q", got, "authorization")
}
}
func TestProviderTUI_EditCustomProviderSaveRejectsDuplicateRename(t *testing.T) {
dir := t.TempDir()
configPath := filepath.Join(dir, "config.json")
cfg := &Config{
CustomProviders: map[string]ProviderEntry{
"stepfun": {
URL: "https://stepfun.example.com",
Protocol: "anthropic",
},
"other": {
URL: "https://other.example.com",
Protocol: "openai",
},
},
}
m := newProviderTUI(cfg, configPath)
m.activeTab = tabCustom
m.editingCustom = true
m.editTargetName = "other"
m.cpProtocolIdx = 1 // openai
m.cpNameInput.SetValue("stepfun")
m.cpURLInput.SetValue("https://other.example.com")
err := m.applyEditCustomProviderSave()
if err == nil {
t.Fatal("expected error when renaming to existing provider name")
}
if !strings.Contains(m.formError, "stepfun") {
t.Errorf("formError = %q, want to mention stepfun", m.formError)
}
if _, ok := cfg.CustomProviders["other"]; !ok {
t.Error("original provider 'other' should still exist")
}
if cfg.CustomProviders["other"].URL != "https://other.example.com" {
t.Errorf("provider 'other' URL = %q, want unchanged", cfg.CustomProviders["other"].URL)
}
}
func TestProviderTUI_CustomFormCreateReturnsToModelList(t *testing.T) {
dir := t.TempDir()
configPath := filepath.Join(dir, "config.json")
cfg := &Config{}
m := newProviderTUI(cfg, configPath)
result, _ := m.Update(rightKey())
m2 := result.(providerTUIModel)
result, _ = m2.Update(enterKey())
m3 := result.(providerTUIModel)
m3.cpNameInput.SetValue("my-new")
result, _ = m3.Update(enterKey()) // name -> protocol
m4 := result.(providerTUIModel)
result, _ = m4.Update(enterKey()) // protocol -> URL
m5 := result.(providerTUIModel)
m5.cpURLInput.SetValue("https://api.example.com")
result, _ = m5.Update(enterKey()) // URL -> API key
m6 := result.(providerTUIModel)
m6.apiKeyInput.SetValue("key-123")
result, _ = m6.Update(enterKey()) // API key -> auth header
m7 := result.(providerTUIModel)
result, cmd := m7.Update(enterKey()) // auth header -> save
m8 := result.(providerTUIModel)
if cmd != nil {
t.Error("create should not quit TUI")
}
if m8.creatingCustom {
t.Error("creatingCustom should be false after create")
}
// Create should drop the user into the model selection step for the new
// provider so they can pick/add a model right away.
if m8.step != stepModel {
t.Errorf("step = %d, want stepModel", m8.step)
}
if len(m8.customProviders) != 1 {
t.Fatalf("expected 1 custom provider, got %d", len(m8.customProviders))
}
if m8.customProviders[0].name != "my-new" {
t.Errorf("provider name = %q, want %q", m8.customProviders[0].name, "my-new")
}
if cfg.Provider != "" {
t.Error("active provider should not be set when only creating")
}
if !m8.savedInSession {
t.Error("savedInSession should be true after create")
}
if _, err := os.Stat(configPath); err != nil {
t.Fatalf("config should be saved: %v", err)
}
}
func TestProviderTUI_CustomProviderExistsInList(t *testing.T) {
cfg := &Config{
Provider: "my-llm",
@ -448,7 +746,7 @@ func TestProviderTUI_CustomProviderExistsInList(t *testing.T) {
},
},
}
m := newProviderTUI(cfg)
m := newProviderTUI(cfg, "")
if m.activeTab != tabCustom {
t.Fatalf("should auto-select custom tab, got %d", m.activeTab)
@ -474,7 +772,7 @@ func TestProviderTUI_SelectExistingCustomGoesToModel(t *testing.T) {
},
},
}
m := newProviderTUI(cfg)
m := newProviderTUI(cfg, "")
// Enter on existing custom provider should go to model selection first.
result, _ := m.Update(enterKey())
@ -482,8 +780,9 @@ func TestProviderTUI_SelectExistingCustomGoesToModel(t *testing.T) {
if m2.step != stepModel {
t.Errorf("step = %d, want %d (stepModel)", m2.step, stepModel)
}
if m2.models()[0] != "custom-model" {
t.Errorf("first model = %q, want %q", m2.models()[0], "custom-model")
gotModels := m2.models()
if len(gotModels) != 2 || gotModels[0] != "custom-model" || gotModels[1] != "custom-fast" {
t.Errorf("models = %v, want [custom-model custom-fast] (config order)", gotModels)
}
}
@ -555,7 +854,7 @@ func TestProviderTUI_DeleteCustomProvider(t *testing.T) {
"my-llm": {URL: "https://custom.api/v1", Protocol: "openai", Model: "custom-model"},
},
}
m := newProviderTUI(cfg)
m := newProviderTUI(cfg, "")
// Switch to custom tab
result, _ := m.Update(rightKey())
@ -595,8 +894,9 @@ func TestProviderTUI_DeleteCustomProviderCancel(t *testing.T) {
"my-llm": {URL: "https://custom.api/v1", Protocol: "openai", Model: "custom-model"},
},
}
m := newProviderTUI(cfg)
m := newProviderTUI(cfg, "")
// Force custom tab so this test is independent of init-time tab routing.
// Switch to custom tab, select provider, press d
result, _ := m.Update(rightKey())
m2 := result.(providerTUIModel)
@ -627,7 +927,7 @@ func TestProviderTUI_DeleteOnAddOptionIgnored(t *testing.T) {
"my-llm": {URL: "https://custom.api/v1", Protocol: "openai"},
},
}
m := newProviderTUI(cfg)
m := newProviderTUI(cfg, "")
// Switch to custom tab
result, _ := m.Update(rightKey())
@ -649,7 +949,7 @@ func TestProviderTUI_DeleteActiveCustomProvider(t *testing.T) {
"my-llm": {URL: "https://custom.api/v1", Protocol: "openai", Model: "custom-model"},
},
}
m := newProviderTUI(cfg)
m := newProviderTUI(cfg, "")
// Should auto-select custom tab with active provider
if m.activeTab != tabCustom {
@ -678,7 +978,7 @@ func TestProviderTUI_DeleteEscCancels(t *testing.T) {
"my-llm": {URL: "https://custom.api/v1", Protocol: "openai"},
},
}
m := newProviderTUI(cfg)
m := newProviderTUI(cfg, "")
result, _ := m.Update(rightKey())
m2 := result.(providerTUIModel)
@ -696,3 +996,300 @@ func TestProviderTUI_DeleteEscCancels(t *testing.T) {
t.Error("no providers should be deleted after Esc")
}
}
func TestActiveModelForProvider_PrefersEntryModel(t *testing.T) {
cfg := &Config{Provider: "stepfun", Model: "step-3.7-flash"}
entry := ProviderEntry{Model: "step-3.5-flash"}
got := activeModelForProvider(cfg, "stepfun", entry)
if got != "step-3.5-flash" {
t.Errorf("got %q, want step-3.5-flash", got)
}
}
func TestActiveModelForProvider_FallsBackToCfgModel(t *testing.T) {
cfg := &Config{Provider: "stepfun", Model: "step-3.5-flash"}
entry := ProviderEntry{}
got := activeModelForProvider(cfg, "stepfun", entry)
if got != "step-3.5-flash" {
t.Errorf("got %q, want step-3.5-flash", got)
}
}
func TestProviderTUI_CustomModelInput_AddsSingleName(t *testing.T) {
dir := t.TempDir()
configPath := filepath.Join(dir, "config.json")
cfg := &Config{
Provider: "stepfun",
Model: "step-3.5-flash",
CustomProviders: map[string]ProviderEntry{
"stepfun": {
URL: "https://api.stepfun.com/v1",
Model: "step-3.5-flash",
Models: []string{"step-3.5-flash"},
},
},
}
m := newProviderTUI(cfg, configPath)
m.activeTab = tabCustom
m.customIdx = 0
m.step = stepModel
m.modelIdx = len(m.models()) // land on "Enter custom model name..."
m.customModel = true
m.modelInput.SetValue("newmodel")
m.modelInput.Focus()
result, _ := m.Update(enterKey())
m2 := result.(providerTUIModel)
if m2.customModel {
t.Error("customModel should be cleared after Enter")
}
if m2.formError != "" {
t.Errorf("formError = %q, want empty", m2.formError)
}
got := m2.existingCfg.CustomProviders["stepfun"].Models
want := []string{"step-3.5-flash", "newmodel"}
if len(got) != len(want) || got[0] != want[0] || got[1] != want[1] {
t.Errorf("Models = %v, want %v", got, want)
}
if !m2.savedInSession {
t.Error("savedInSession should be true after add")
}
diskCfg, err := loadOrCreateConfig(configPath)
if err != nil {
t.Fatalf("load disk config: %v", err)
}
diskModels := diskCfg.CustomProviders["stepfun"].Models
if len(diskModels) != 2 || diskModels[1] != "newmodel" {
t.Errorf("disk Models = %v, want last=step-3.5-flash,newmodel", diskModels)
}
}
func TestProviderTUI_CustomModelInput_RejectsDuplicate(t *testing.T) {
dir := t.TempDir()
configPath := filepath.Join(dir, "config.json")
cfg := &Config{
Provider: "stepfun",
Model: "step-3.5-flash",
CustomProviders: map[string]ProviderEntry{
"stepfun": {
URL: "https://api.stepfun.com/v1",
Model: "step-3.5-flash",
Models: []string{"step-3.5-flash"},
},
},
}
m := newProviderTUI(cfg, configPath)
m.activeTab = tabCustom
m.customIdx = 0
m.step = stepModel
m.modelIdx = len(m.models())
m.customModel = true
m.modelInput.SetValue("step-3.5-flash")
m.modelInput.Focus()
result, _ := m.Update(enterKey())
m2 := result.(providerTUIModel)
if !m2.customModel {
t.Error("customModel should stay true after duplicate reject")
}
if m2.formError != "Already in list: step-3.5-flash" {
t.Errorf("formError = %q, want %q", m2.formError, "Already in list: step-3.5-flash")
}
if m2.modelInput.Value() != "step-3.5-flash" {
t.Errorf("input should be preserved on dup; got %q", m2.modelInput.Value())
}
if len(m2.existingCfg.CustomProviders["stepfun"].Models) != 1 {
t.Errorf("Models mutated: %v", m2.existingCfg.CustomProviders["stepfun"].Models)
}
if _, err := os.Stat(configPath); err == nil {
t.Errorf("disk file should not exist; duplicate did not persist")
}
if m2.savedInSession {
t.Error("savedInSession should be false after rejected duplicate")
}
}
func TestProviderTUI_ManualFormPassesKToAuthHeaderInput(t *testing.T) {
dir := t.TempDir()
configPath := filepath.Join(dir, "config.json")
cfg := &Config{Llm: LlmConfig{URL: "https://example.com/v1", Model: "m", AuthToken: "k"}}
m := newProviderTUI(cfg, configPath)
m.activeTab = tabManual
m.inManualForm = true
m.manualStep = manualStepAuthHeader
m.manualAuthHeaderInput.Focus()
result, _ := m.Update(charKey('x'))
m2 := result.(providerTUIModel)
result, _ = m2.Update(charKey('-'))
m3 := result.(providerTUIModel)
result, _ = m3.Update(charKey('a'))
m4 := result.(providerTUIModel)
result, _ = m4.Update(charKey('p'))
m5 := result.(providerTUIModel)
result, _ = m5.Update(charKey('i'))
m6 := result.(providerTUIModel)
result, _ = m6.Update(charKey('-'))
m7 := result.(providerTUIModel)
result, _ = m7.Update(charKey('k'))
m8 := result.(providerTUIModel)
result, _ = m8.Update(charKey('e'))
m9 := result.(providerTUIModel)
result, _ = m9.Update(charKey('y'))
m10 := result.(providerTUIModel)
if got := m10.manualAuthHeaderInput.Value(); got != "x-api-key" {
t.Errorf("manualAuthHeaderInput.Value() = %q, want %q", got, "x-api-key")
}
}
func TestProviderTUI_CustomFormPassesKToAuthHeaderInput(t *testing.T) {
dir := t.TempDir()
configPath := filepath.Join(dir, "config.json")
cfg := &Config{}
m := newProviderTUI(cfg, configPath)
m.creatingCustom = true
m.cpStep = cpStepAuthHeader
m.cpAuthInput.Focus()
result, _ := m.Update(charKey('k'))
m2 := result.(providerTUIModel)
result, _ = m2.Update(charKey('e'))
m3 := result.(providerTUIModel)
result, _ = m3.Update(charKey('y'))
m4 := result.(providerTUIModel)
if got := m4.cpAuthInput.Value(); got != "key" {
t.Errorf("cpAuthInput.Value() = %q, want %q", got, "key")
}
}
func TestProviderTUI_DeleteModelPreservesActiveModel(t *testing.T) {
cfg := &Config{
Provider: "stepfun",
Model: "step-3.5-flash",
CustomProviders: map[string]ProviderEntry{
"stepfun": {
Model: "step-3.5-flash",
Models: []string{"step-3.5-flash", "aaa"},
},
},
}
m := newProviderTUI(cfg, "")
m.activeTab = tabCustom
m.customIdx = 0
m.step = stepModel
m.modelIdx = 1 // aaa
m.confirmingDeleteModel = true
m.deleteModelName = "aaa"
result, _ := m.Update(yKey())
m2 := result.(providerTUIModel)
if m2.existingCfg.CustomProviders["stepfun"].Model != "step-3.5-flash" {
t.Errorf("entry.Model = %q, want step-3.5-flash", m2.existingCfg.CustomProviders["stepfun"].Model)
}
if m2.existingCfg.Model != "step-3.5-flash" {
t.Errorf("cfg.Model = %q, want step-3.5-flash", m2.existingCfg.Model)
}
if !m2.savedInSession {
t.Error("savedInSession should be true after deleting a model")
}
}
func TestApplyCustomProviderConfigPreservesModelOrder(t *testing.T) {
dir := t.TempDir()
configPath := filepath.Join(dir, "config.json")
models := []string{"test-model", "test-model-2", "bbb", "aaa", "test-model-3"}
cfg := &Config{
Provider: "test-provider",
Model: "test-model-2",
CustomProviders: map[string]ProviderEntry{
"test-provider": {
Model: "test-model-2",
Models: append([]string(nil), models...),
},
},
}
if err := saveConfig(configPath, cfg); err != nil {
t.Fatalf("saveConfig: %v", err)
}
result := providerTUIResult{
provider: "test-provider",
model: "test-model-3",
models: append([]string(nil), models...),
isCustom: true,
isEdit: true,
}
if err := applyCustomProviderConfig(configPath, cfg, result); err != nil {
t.Fatalf("applyCustomProviderConfig: %v", err)
}
got := cfg.CustomProviders["test-provider"].Models
if len(got) != len(models) {
t.Fatalf("Models length = %d, want %d: %v", len(got), len(models), got)
}
for i := range models {
if got[i] != models[i] {
t.Errorf("Models[%d] = %q, want %q", i, got[i], models[i])
}
}
if cfg.CustomProviders["test-provider"].Model != "test-model-3" {
t.Errorf("entry.Model = %q, want test-model-3", cfg.CustomProviders["test-provider"].Model)
}
}
func TestApplyManualConfigNormalizesAuthHeader(t *testing.T) {
dir := t.TempDir()
configPath := filepath.Join(dir, "config.json")
cfg := &Config{}
result := providerTUIResult{
isManual: true,
url: "https://example.com/v1",
model: "test-model",
apiKey: "token",
protocol: "anthropic",
authHeader: "X-Api-Key",
}
if err := applyManualConfig(configPath, cfg, result); err != nil {
t.Fatalf("applyManualConfig: %v", err)
}
if got := cfg.Llm.AuthHeader; got != "x-api-key" {
t.Errorf("Llm.AuthHeader = %q, want %q", got, "x-api-key")
}
useAnthropic := true
if cfg.Llm.UseAnthropic == nil || *cfg.Llm.UseAnthropic != useAnthropic {
t.Errorf("UseAnthropic = %v, want %v", cfg.Llm.UseAnthropic, useAnthropic)
}
}
func TestApplyCustomProviderConfigNormalizesAuthHeader(t *testing.T) {
dir := t.TempDir()
configPath := filepath.Join(dir, "config.json")
cfg := &Config{
CustomProviders: map[string]ProviderEntry{
"test-provider": {URL: "https://example.com", Model: "m"},
},
}
result := providerTUIResult{
provider: "test-provider",
model: "m",
url: "https://example.com",
protocol: "anthropic",
authHeader: "Authorization",
isCustom: true,
isEdit: true,
}
if err := applyCustomProviderConfig(configPath, cfg, result); err != nil {
t.Fatalf("applyCustomProviderConfig: %v", err)
}
if got := cfg.CustomProviders["test-provider"].AuthHeader; got != "authorization" {
t.Errorf("AuthHeader = %q, want %q", got, "authorization")
}
}