From d6d2d1a0502e389b221e06d7fb254bc462349d46 Mon Sep 17 00:00:00 2001 From: Yumiue <229866007@qq.com> Date: Mon, 30 Mar 2026 20:20:25 +0800 Subject: [PATCH] =?UTF-8?q?feat(config):=20=E6=94=AF=E6=8C=81=20API=20key?= =?UTF-8?q?=20=E7=8E=AF=E5=A2=83=E5=8F=98=E9=87=8F=E8=A6=86=E7=9B=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/config/config_test.go | 95 +++++++++++++++++++++ internal/config/loader.go | 45 +++++----- internal/config/manager.go | 3 +- internal/config/model.go | 76 +++++++++++++++-- internal/provider/registry_test.go | 55 ++++++++++++ internal/provider/service.go | 11 +++ internal/tui/app.go | 9 ++ internal/tui/commands.go | 33 ++++++++ internal/tui/provider_service.go | 1 + internal/tui/state.go | 3 + internal/tui/update.go | 22 +++++ internal/tui/update_test.go | 129 +++++++++++++++++++++++++++++ internal/tui/view.go | 4 + 13 files changed, 456 insertions(+), 30 deletions(-) diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 8f8c82d5..835b1ed9 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -629,6 +629,32 @@ func TestProviderLookupAndResolveSelectedProvider(t *testing.T) { } } +func TestProviderLookupAndResolveSelectedProviderUsesOverride(t *testing.T) { + t.Setenv("CUSTOM_OPENAI_KEY", "override-key") + + manager := NewManager(NewLoader(t.TempDir(), testDefaultConfig())) + if _, err := manager.Load(context.Background()); err != nil { + t.Fatalf("Load() error = %v", err) + } + if err := manager.Update(context.Background(), func(cfg *Config) error { + cfg.APIKeyEnvOverride = " CUSTOM_OPENAI_KEY " + return nil + }); err != nil { + t.Fatalf("Update() error = %v", err) + } + + resolved, err := manager.ResolvedSelectedProvider() + if err != nil { + t.Fatalf("ResolvedSelectedProvider() error = %v", err) + } + if resolved.APIKeyEnv != "CUSTOM_OPENAI_KEY" { + t.Fatalf("expected resolved env %q, got %q", "CUSTOM_OPENAI_KEY", resolved.APIKeyEnv) + } + if resolved.APIKey != "override-key" { + t.Fatalf("expected resolved key %q, got %q", "override-key", resolved.APIKey) + } +} + func TestLoaderLoadAndSaveRoundTrip(t *testing.T) { tempDir := t.TempDir() loader := NewLoader(tempDir, testDefaultConfig()) @@ -644,6 +670,7 @@ func TestLoaderLoadAndSaveRoundTrip(t *testing.T) { cfg.CurrentModel = "gpt-5.4" cfg.Providers[0].Models = []string{"gpt-4.1", "gpt-5.4"} cfg.Providers[0].BaseURL = "https://ignored.example/v1" + cfg.APIKeyEnvOverride = "CUSTOM_OPENAI_KEY" cfg.Tools.WebFetch.MaxResponseBytes = 1024 cfg.Tools.WebFetch.SupportedContentTypes = []string{"text/html", "application/json"} if err := loader.Save(context.Background(), cfg); err != nil { @@ -664,11 +691,17 @@ func TestLoaderLoadAndSaveRoundTrip(t *testing.T) { if strings.Contains(text, "models:") || strings.Contains(text, "base_url:") || strings.Contains(text, "api_key_env:") { t.Fatalf("expected persisted config to keep only selection state and common runtime settings, got:\n%s", text) } + if !strings.Contains(text, "api_key_env_override: CUSTOM_OPENAI_KEY") { + t.Fatalf("expected persisted config to include api_key_env_override, got:\n%s", text) + } reloaded, err := loader.Load(context.Background()) if err != nil { t.Fatalf("Load() reload error = %v", err) } + if reloaded.APIKeyEnvOverride != "CUSTOM_OPENAI_KEY" { + t.Fatalf("expected api key env override to round-trip, got %q", reloaded.APIKeyEnvOverride) + } if reloaded.CurrentModel != "gpt-5.4" { t.Fatalf("expected current model %q, got %q", "gpt-5.4", reloaded.CurrentModel) } @@ -693,6 +726,68 @@ func TestLoaderLoadAndSaveRoundTrip(t *testing.T) { } } +func TestLoaderSaveOmitsEmptyAPIKeyEnvOverride(t *testing.T) { + tempDir := t.TempDir() + loader := NewLoader(tempDir, testDefaultConfig()) + + cfg, err := loader.Load(context.Background()) + if err != nil { + t.Fatalf("Load() error = %v", err) + } + cfg.APIKeyEnvOverride = "CUSTOM_OPENAI_KEY" + if err := loader.Save(context.Background(), cfg); err != nil { + t.Fatalf("Save() with override error = %v", err) + } + + cfg.APIKeyEnvOverride = " " + if err := loader.Save(context.Background(), cfg); err != nil { + t.Fatalf("Save() clearing override error = %v", err) + } + + data, err := os.ReadFile(loader.ConfigPath()) + if err != nil { + t.Fatalf("read config file: %v", err) + } + if strings.Contains(string(data), "api_key_env_override:") { + t.Fatalf("expected empty override to be omitted, got:\n%s", string(data)) + } +} + +func TestConfigValidateRejectsInvalidAPIKeyEnvOverride(t *testing.T) { + tests := []struct { + name string + override string + expectErr string + }{ + { + name: "contains spaces", + override: "CUSTOM KEY", + expectErr: "must not contain whitespace", + }, + { + name: "contains equals", + override: "CUSTOM=KEY", + expectErr: "must not contain =", + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + cfg := testDefaultConfig() + cfg.APIKeyEnvOverride = tt.override + cfg.ApplyDefaultsFrom(*testDefaultConfig()) + + err := cfg.Validate() + if err == nil || !strings.Contains(err.Error(), tt.expectErr) { + t.Fatalf("expected error containing %q, got %v", tt.expectErr, err) + } + }) + } +} + func TestLoaderUsesUpdatedBuiltinProviderWhenUserHasNoOverride(t *testing.T) { tempDir := t.TempDir() diff --git a/internal/config/loader.go b/internal/config/loader.go index 0c40c051..97a62184 100644 --- a/internal/config/loader.go +++ b/internal/config/loader.go @@ -24,13 +24,14 @@ type Loader struct { } type persistedConfig struct { - SelectedProvider string `yaml:"selected_provider"` - CurrentModel string `yaml:"current_model"` - Workdir string `yaml:"workdir"` - Shell string `yaml:"shell"` - MaxLoops int `yaml:"max_loops,omitempty"` - ToolTimeoutSec int `yaml:"tool_timeout_sec,omitempty"` - Tools ToolsConfig `yaml:"tools,omitempty"` + SelectedProvider string `yaml:"selected_provider"` + CurrentModel string `yaml:"current_model"` + APIKeyEnvOverride string `yaml:"api_key_env_override,omitempty"` + Workdir string `yaml:"workdir"` + Shell string `yaml:"shell"` + MaxLoops int `yaml:"max_loops,omitempty"` + ToolTimeoutSec int `yaml:"tool_timeout_sec,omitempty"` + Tools ToolsConfig `yaml:"tools,omitempty"` } func NewLoader(baseDir string, defaults *Config) *Loader { @@ -124,13 +125,14 @@ func (l *Loader) Save(ctx context.Context, cfg *Config) error { } file := persistedConfig{ - SelectedProvider: snapshot.SelectedProvider, - CurrentModel: snapshot.CurrentModel, - Workdir: snapshot.Workdir, - Shell: snapshot.Shell, - MaxLoops: snapshot.MaxLoops, - ToolTimeoutSec: snapshot.ToolTimeoutSec, - Tools: snapshot.Tools, + SelectedProvider: snapshot.SelectedProvider, + CurrentModel: snapshot.CurrentModel, + APIKeyEnvOverride: snapshot.APIKeyEnvOverride, + Workdir: snapshot.Workdir, + Shell: snapshot.Shell, + MaxLoops: snapshot.MaxLoops, + ToolTimeoutSec: snapshot.ToolTimeoutSec, + Tools: snapshot.Tools, } data, err := yaml.Marshal(&file) @@ -192,13 +194,14 @@ func parseCurrentConfig(data []byte, _ Config) (*Config, error) { } cfg := &Config{ - SelectedProvider: strings.TrimSpace(file.SelectedProvider), - CurrentModel: strings.TrimSpace(file.CurrentModel), - Workdir: strings.TrimSpace(file.Workdir), - Shell: strings.TrimSpace(file.Shell), - MaxLoops: file.MaxLoops, - ToolTimeoutSec: file.ToolTimeoutSec, - Tools: file.Tools, + SelectedProvider: strings.TrimSpace(file.SelectedProvider), + CurrentModel: strings.TrimSpace(file.CurrentModel), + APIKeyEnvOverride: strings.TrimSpace(file.APIKeyEnvOverride), + Workdir: strings.TrimSpace(file.Workdir), + Shell: strings.TrimSpace(file.Shell), + MaxLoops: file.MaxLoops, + ToolTimeoutSec: file.ToolTimeoutSec, + Tools: file.Tools, } return cfg, nil diff --git a/internal/config/manager.go b/internal/config/manager.go index 743a9758..29a85b5a 100644 --- a/internal/config/manager.go +++ b/internal/config/manager.go @@ -91,7 +91,8 @@ func (m *Manager) SelectedProvider() (ProviderConfig, error) { } func (m *Manager) ResolvedSelectedProvider() (ResolvedProviderConfig, error) { - provider, err := m.SelectedProvider() + cfg := m.Get() + provider, err := cfg.EffectiveSelectedProviderConfig() if err != nil { return ResolvedProviderConfig{}, err } diff --git a/internal/config/model.go b/internal/config/model.go index 3273697a..2c55c96c 100644 --- a/internal/config/model.go +++ b/internal/config/model.go @@ -27,14 +27,15 @@ var defaultWebFetchSupportedContentTypes = []string{ } type Config struct { - Providers []ProviderConfig `yaml:"-"` - SelectedProvider string `yaml:"selected_provider"` - CurrentModel string `yaml:"current_model"` - Workdir string `yaml:"workdir"` - Shell string `yaml:"shell"` - MaxLoops int `yaml:"max_loops,omitempty"` - ToolTimeoutSec int `yaml:"tool_timeout_sec,omitempty"` - Tools ToolsConfig `yaml:"tools,omitempty"` + Providers []ProviderConfig `yaml:"-"` + SelectedProvider string `yaml:"selected_provider"` + CurrentModel string `yaml:"current_model"` + APIKeyEnvOverride string `yaml:"api_key_env_override,omitempty"` + Workdir string `yaml:"workdir"` + Shell string `yaml:"shell"` + MaxLoops int `yaml:"max_loops,omitempty"` + ToolTimeoutSec int `yaml:"tool_timeout_sec,omitempty"` + Tools ToolsConfig `yaml:"tools,omitempty"` } type ProviderConfig struct { @@ -125,6 +126,7 @@ func (c *Config) ApplyDefaultsFrom(defaults Config) { } c.Tools.ApplyDefaults(defaults.Tools) + c.APIKeyEnvOverride = normalizeAPIKeyEnvOverride(c.APIKeyEnvOverride) c.Workdir = normalizeWorkdir(c.Workdir) } @@ -171,6 +173,9 @@ func (c *Config) Validate() error { if !containsModelID(selected.SupportedModels(), c.CurrentModel) { return fmt.Errorf("config: current_model %q is not supported by provider %q", c.CurrentModel, selected.Name) } + if err := validateAPIKeyEnvOverride(c.APIKeyEnvOverride); err != nil { + return fmt.Errorf("config: %w", err) + } if err := c.Tools.Validate(); err != nil { return fmt.Errorf("config: tools: %w", err) } @@ -200,6 +205,14 @@ func (c *Config) ProviderByName(name string) (ProviderConfig, error) { return ProviderConfig{}, fmt.Errorf("config: provider %q not found", name) } +func (c *Config) EffectiveSelectedProviderConfig() (ProviderConfig, error) { + selected, err := c.SelectedProviderConfig() + if err != nil { + return ProviderConfig{}, err + } + return selected.WithAPIKeyEnvOverride(c.APIKeyEnvOverride) +} + func (p ProviderConfig) Validate() error { if strings.TrimSpace(p.Name) == "" { return errors.New("provider name is empty") @@ -240,6 +253,33 @@ func (p ProviderConfig) SupportedModels() []string { return []string{model} } +func (p ProviderConfig) EffectiveAPIKeyEnv(override string) (string, error) { + if err := validateAPIKeyEnvOverride(override); err != nil { + return "", err + } + + if override = normalizeAPIKeyEnvOverride(override); override != "" { + return override, nil + } + + envName := strings.TrimSpace(p.APIKeyEnv) + if envName == "" { + return "", fmt.Errorf("provider %q api_key_env is empty", p.Name) + } + return envName, nil +} + +func (p ProviderConfig) WithAPIKeyEnvOverride(override string) (ProviderConfig, error) { + envName, err := p.EffectiveAPIKeyEnv(override) + if err != nil { + return ProviderConfig{}, err + } + + next := p + next.APIKeyEnv = envName + return next, nil +} + func (p ProviderConfig) ResolveAPIKey() (string, error) { envName := strings.TrimSpace(p.APIKeyEnv) if envName == "" { @@ -302,6 +342,26 @@ func mergeProviderDefaults(provider ProviderConfig, defaults []ProviderConfig) P return provider } +func normalizeAPIKeyEnvOverride(value string) string { + return strings.TrimSpace(value) +} + +func validateAPIKeyEnvOverride(value string) error { + value = normalizeAPIKeyEnvOverride(value) + if value == "" { + return nil + } + if strings.Contains(value, "=") { + return errors.New("api_key_env_override must not contain =") + } + for _, r := range value { + if r == ' ' || r == '\t' || r == '\n' || r == '\r' { + return errors.New("api_key_env_override must not contain whitespace") + } + } + return nil +} + func matchDefaultProvider(provider ProviderConfig, defaults []ProviderConfig) (ProviderConfig, bool) { name := strings.ToLower(strings.TrimSpace(provider.Name)) if name == "" { diff --git a/internal/provider/registry_test.go b/internal/provider/registry_test.go index 6f209301..0103aa7c 100644 --- a/internal/provider/registry_test.go +++ b/internal/provider/registry_test.go @@ -3,6 +3,7 @@ package provider_test import ( "context" "errors" + "strings" "testing" "neo-code/internal/config" @@ -220,6 +221,60 @@ func TestServiceSelectProviderAndSetCurrentModel(t *testing.T) { } } +func TestServiceSetAPIKeyEnvOverridePersistsAcrossReloadAndSwitches(t *testing.T) { + defaults := builtin.DefaultConfig() + defaults.Providers = append(defaults.Providers, config.ProviderConfig{ + Name: "custom-main", + Driver: "custom", + BaseURL: "https://example.com", + Model: "custom-model", + Models: []string{"custom-model", "custom-alt"}, + APIKeyEnv: "CUSTOM_PROVIDER_KEY", + }) + manager := newTestManagerWithDefaults(t, defaults) + registry := newTestRegistry(t) + if err := registry.Register(stubDriver("custom")); err != nil { + t.Fatalf("register stub driver: %v", err) + } + + service := provider.NewService(manager, registry) + if err := service.SetAPIKeyEnvOverride(context.Background(), " GLOBAL_OVERRIDE_KEY "); err != nil { + t.Fatalf("SetAPIKeyEnvOverride() error = %v", err) + } + + cfg := manager.Get() + if cfg.APIKeyEnvOverride != "GLOBAL_OVERRIDE_KEY" { + t.Fatalf("expected override to be normalized, got %q", cfg.APIKeyEnvOverride) + } + + if _, err := service.SelectProvider(context.Background(), "custom-main"); err != nil { + t.Fatalf("SelectProvider() error = %v", err) + } + if _, err := service.SetCurrentModel(context.Background(), "custom-alt"); err != nil { + t.Fatalf("SetCurrentModel() error = %v", err) + } + + reloaded, err := manager.Reload(context.Background()) + if err != nil { + t.Fatalf("Reload() error = %v", err) + } + if reloaded.APIKeyEnvOverride != "GLOBAL_OVERRIDE_KEY" { + t.Fatalf("expected override to persist after reload, got %q", reloaded.APIKeyEnvOverride) + } + if reloaded.SelectedProvider != "custom-main" || reloaded.CurrentModel != "custom-alt" { + t.Fatalf("expected provider/model selection to persist, got provider=%q model=%q", reloaded.SelectedProvider, reloaded.CurrentModel) + } +} + +func TestServiceSetAPIKeyEnvOverrideRejectsInvalidInput(t *testing.T) { + service := provider.NewService(newTestManager(t), newTestRegistry(t)) + + err := service.SetAPIKeyEnvOverride(context.Background(), "CUSTOM KEY") + if err == nil || (!strings.Contains(err.Error(), "whitespace") && !strings.Contains(err.Error(), "api_key_env_override")) { + t.Fatalf("expected override validation error, got %v", err) + } +} + func TestServiceModelOperationsUseProviderConfigEvenWithoutDriver(t *testing.T) { t.Parallel() diff --git a/internal/provider/service.go b/internal/provider/service.go index 8c2f983c..984ea171 100644 --- a/internal/provider/service.go +++ b/internal/provider/service.go @@ -125,6 +125,17 @@ func (s *Service) SetCurrentModel(ctx context.Context, modelID string) (Provider return selection, nil } +func (s *Service) SetAPIKeyEnvOverride(ctx context.Context, envName string) error { + if err := s.validate(); err != nil { + return err + } + + return s.manager.Update(ctx, func(cfg *config.Config) error { + cfg.APIKeyEnvOverride = envName + return nil + }) +} + func containsModel(models []string, modelID string) bool { target := normalizeKey(modelID) for _, model := range models { diff --git a/internal/tui/app.go b/internal/tui/app.go index fbfcd4bd..15dc3bee 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -27,6 +27,7 @@ type App struct { sessions list.Model providerPicker list.Model modelPicker list.Model + apiKeyInput textinput.Model transcript viewport.Model input textinput.Model activeMessages []provider.Message @@ -77,6 +78,12 @@ func New(cfg *config.Config, configManager *config.Manager, runtime agentruntime spin.Spinner = spinner.Line spin.Style = lipgloss.NewStyle().Foreground(lipgloss.Color(colorPrimary)) + apiKeyInput := textinput.New() + apiKeyInput.Prompt = "" + apiKeyInput.Placeholder = "Enter API key env name" + apiKeyInput.Cursor.Style = lipgloss.NewStyle().Foreground(lipgloss.Color(colorUser)) + apiKeyInput.CharLimit = 256 + h := help.New() h.ShowAll = false @@ -85,6 +92,7 @@ func New(cfg *config.Config, configManager *config.Manager, runtime agentruntime StatusText: statusReady, CurrentProvider: cfg.SelectedProvider, CurrentModel: cfg.CurrentModel, + APIKeyEnvOverride: cfg.APIKeyEnvOverride, CurrentWorkdir: cfg.Workdir, ActiveSessionTitle: draftSessionTitle, Focus: panelInput, @@ -98,6 +106,7 @@ func New(cfg *config.Config, configManager *config.Manager, runtime agentruntime sessions: sessionList, providerPicker: newProviderPicker(nil), modelPicker: newModelPicker(nil), + apiKeyInput: apiKeyInput, transcript: viewport.New(0, 0), input: input, focus: panelInput, diff --git a/internal/tui/commands.go b/internal/tui/commands.go index 4aacf6a1..364f37be 100644 --- a/internal/tui/commands.go +++ b/internal/tui/commands.go @@ -15,15 +15,19 @@ const ( slashPrefix = "/" slashCommandProviderPick = "/provider" slashCommandModelPicker = "/model" + slashCommandAPIKeyInput = "/apikey" slashUsageProvider = "/provider" slashUsageModel = "/model" + slashUsageAPIKey = "/apikey" commandMenuTitle = "Commands" providerPickerTitle = "Select Provider" providerPickerSubtitle = "Up/Down choose, Enter confirm, Esc cancel" modelPickerTitle = "Select Model" modelPickerSubtitle = "Up/Down choose, Enter confirm, Esc cancel" + apiKeyInputTitle = "Set API Key Env" + apiKeyInputSubtitle = "Enter save, Esc cancel, leave empty to restore default" sidebarTitle = "Sessions" sidebarFilterHint = "Type / to search" @@ -46,6 +50,7 @@ const ( statusRunning = "Running" statusChooseProvider = "Choose a provider" statusChooseModel = "Choose a model" + statusEditAPIKeyEnv = "Set API key env" focusLabelSessions = "Sessions" focusLabelTranscript = "Transcript" @@ -76,6 +81,7 @@ type commandSuggestion struct { var builtinSlashCommands = []slashCommand{ {Usage: slashUsageProvider, Description: "Open the interactive provider picker"}, {Usage: slashUsageModel, Description: "Open the interactive model picker"}, + {Usage: slashUsageAPIKey, Description: "Open the API key env input"}, } func newSelectionPicker(items []list.Item) list.Model { @@ -154,6 +160,16 @@ func (a *App) openModelPicker() { a.selectCurrentModel(a.state.CurrentModel) } +func (a *App) openAPIKeyInput() { + a.state.ActivePicker = pickerAPIKey + a.state.StatusText = statusEditAPIKeyEnv + a.apiKeyInput.SetValue(a.state.APIKeyEnvOverride) + a.apiKeyInput.Placeholder = fallback(a.state.CurrentAPIKeyEnv, "Enter API key env name") + a.focus = panelInput + a.applyFocus() + a.apiKeyInput.CursorEnd() +} + func (a *App) closePicker() { a.state.ActivePicker = pickerNone a.focus = panelInput @@ -228,3 +244,20 @@ func runModelSelection(providerSvc ProviderController, modelID string) tea.Cmd { } } } + +func runAPIKeyEnvSelection(providerSvc ProviderController, envName string) tea.Cmd { + return func() tea.Msg { + trimmed := strings.TrimSpace(envName) + if err := providerSvc.SetAPIKeyEnvOverride(context.Background(), trimmed); err != nil { + return localCommandResultMsg{err: err} + } + if trimmed == "" { + return localCommandResultMsg{ + notice: "[System] API key env restored to provider default.", + } + } + return localCommandResultMsg{ + notice: fmt.Sprintf("[System] API key env override set to %s.", trimmed), + } + } +} diff --git a/internal/tui/provider_service.go b/internal/tui/provider_service.go index c2a5ea1e..9c1d272a 100644 --- a/internal/tui/provider_service.go +++ b/internal/tui/provider_service.go @@ -11,4 +11,5 @@ type ProviderController interface { SelectProvider(ctx context.Context, providerID string) (provider.ProviderSelection, error) ListModels(ctx context.Context) ([]provider.ModelDescriptor, error) SetCurrentModel(ctx context.Context, modelID string) (provider.ProviderSelection, error) + SetAPIKeyEnvOverride(ctx context.Context, envName string) error } diff --git a/internal/tui/state.go b/internal/tui/state.go index 2bd56b35..0c6aa9de 100644 --- a/internal/tui/state.go +++ b/internal/tui/state.go @@ -28,6 +28,7 @@ const ( pickerNone pickerMode = iota pickerProvider pickerModel + pickerAPIKey ) type UIState struct { @@ -42,6 +43,8 @@ type UIState struct { StatusText string CurrentProvider string CurrentModel string + CurrentAPIKeyEnv string + APIKeyEnvOverride string CurrentWorkdir string ShowHelp bool ActivePicker pickerMode diff --git a/internal/tui/update.go b/internal/tui/update.go index 37692d10..95803d7e 100644 --- a/internal/tui/update.go +++ b/internal/tui/update.go @@ -207,6 +207,9 @@ func (a App) updateInputPanel(msg tea.Msg, typed tea.KeyMsg, cmds []tea.Cmd) (te } a.openModelPicker() return a, tea.Batch(cmds...) + case slashCommandAPIKeyInput: + a.openAPIKeyInput() + return a, tea.Batch(cmds...) } if strings.HasPrefix(input, slashPrefix) { @@ -258,6 +261,10 @@ func (a App) updatePicker(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return a, nil } return a, runModelSelection(a.providerSvc, item.id) + case pickerAPIKey: + value := a.apiKeyInput.Value() + a.closePicker() + return a, runAPIKeyEnvSelection(a.providerSvc, value) } } @@ -267,6 +274,8 @@ func (a App) updatePicker(msg tea.KeyMsg) (tea.Model, tea.Cmd) { a.providerPicker, cmd = a.providerPicker.Update(msg) case pickerModel: a.modelPicker, cmd = a.modelPicker.Update(msg) + case pickerAPIKey: + a.apiKeyInput, cmd = a.apiKeyInput.Update(msg) } return a, cmd } @@ -354,7 +363,13 @@ func (a *App) syncActiveSessionTitle() { func (a *App) syncConfigState(cfg config.Config) { a.state.CurrentProvider = cfg.SelectedProvider a.state.CurrentModel = cfg.CurrentModel + a.state.APIKeyEnvOverride = cfg.APIKeyEnvOverride a.state.CurrentWorkdir = cfg.Workdir + if providerCfg, err := cfg.EffectiveSelectedProviderConfig(); err == nil { + a.state.CurrentAPIKeyEnv = providerCfg.APIKeyEnv + } else { + a.state.CurrentAPIKeyEnv = "" + } } func (a *App) handleRuntimeEvent(event agentruntime.RuntimeEvent) { @@ -506,6 +521,12 @@ func (a *App) focusPrev() { func (a *App) applyFocus() { a.state.Focus = a.focus + if a.state.ActivePicker == pickerAPIKey { + a.input.Blur() + a.apiKeyInput.Focus() + return + } + a.apiKeyInput.Blur() if a.focus == panelInput && a.state.ActivePicker == pickerNone { a.input.Focus() return @@ -529,6 +550,7 @@ func (a *App) resizeComponents() { a.transcript.Height = max(6, lay.rightHeight-menuHeight-promptHeight) a.providerPicker.SetSize(max(24, clamp(lay.rightWidth-14, 28, 52)), max(4, clamp(lay.rightHeight-10, 6, 10))) a.modelPicker.SetSize(max(24, clamp(lay.rightWidth-14, 28, 52)), max(4, clamp(lay.rightHeight-10, 6, 10))) + a.apiKeyInput.Width = max(20, clamp(lay.rightWidth-20, 24, 48)) a.rebuildTranscript() } diff --git a/internal/tui/update_test.go b/internal/tui/update_test.go index 5a62ad72..5a0cade6 100644 --- a/internal/tui/update_test.go +++ b/internal/tui/update_test.go @@ -123,6 +123,25 @@ func TestAppUpdateComposerCommands(t *testing.T) { } }, }, + { + name: "apikey command opens input and does not start runtime", + input: "/apikey", + assert: func(t *testing.T, runtime *stubRuntime, manager *config.Manager, app App) { + t.Helper() + if len(runtime.runInputs) != 0 { + t.Fatalf("expected runtime not to run, got %d calls", len(runtime.runInputs)) + } + if app.state.ActivePicker != pickerAPIKey { + t.Fatalf("expected api key input to open") + } + if app.state.StatusText != statusEditAPIKeyEnv { + t.Fatalf("expected status %q, got %q", statusEditAPIKeyEnv, app.state.StatusText) + } + if app.apiKeyInput.Placeholder == "" { + t.Fatalf("expected api key input placeholder to be populated") + } + }, + }, } for _, tt := range tests { @@ -176,6 +195,26 @@ func TestAppUpdateModelPickerAndRuntimeMessages(t *testing.T) { } }, }, + { + name: "escape closes api key input", + setup: func(t *testing.T, app *App, runtime *stubRuntime) { + app.openAPIKeyInput() + app.apiKeyInput.SetValue("CUSTOM_OPENAI_KEY") + }, + msg: tea.KeyMsg{Type: tea.KeyEsc}, + assert: func(t *testing.T, runtime *stubRuntime, app App, msgs []tea.Msg) { + t.Helper() + if app.state.ActivePicker != pickerNone { + t.Fatalf("expected api key input to close") + } + if app.state.Focus != panelInput { + t.Fatalf("expected focus to return to input") + } + if app.apiKeyInput.Value() != "CUSTOM_OPENAI_KEY" { + t.Fatalf("expected api key input value to stay local on cancel, got %q", app.apiKeyInput.Value()) + } + }, + }, { name: "runtime chunk appends assistant draft", setup: func(t *testing.T, app *App, runtime *stubRuntime) { @@ -317,6 +356,8 @@ func TestAppHelpersAndRenderingSmoke(t *testing.T) { app.openModelPicker() app.closePicker() + app.openAPIKeyInput() + app.closePicker() app.selectCurrentModel(provideropenai.DefaultModel) app.appendAssistantChunk("hello") app.appendAssistantChunk(" world") @@ -358,6 +399,10 @@ func TestAppHelpersAndRenderingSmoke(t *testing.T) { if app.renderPicker(48, 12) == "" || app.renderWaterfall(80, 20) == "" { t.Fatalf("expected model picker rendering") } + app.state.ActivePicker = pickerAPIKey + if app.renderPicker(48, 12) == "" || app.renderWaterfall(80, 20) == "" { + t.Fatalf("expected api key picker rendering") + } app.state.ActivePicker = pickerNone if app.renderCommandMenu(80) == "" { app.input.SetValue("/") @@ -502,6 +547,14 @@ func TestTUIStandaloneHelpers(t *testing.T) { if result, ok := msg.(localCommandResultMsg); !ok || result.err != nil { t.Fatalf("expected successful localCommandResultMsg, got %+v", msg) } + msg = runAPIKeyEnvSelection(newTestProviderService(t, manager), "CUSTOM_OPENAI_KEY")() + if result, ok := msg.(localCommandResultMsg); !ok || result.err != nil || !strings.Contains(result.notice, "CUSTOM_OPENAI_KEY") { + t.Fatalf("expected successful api key env result, got %+v", msg) + } + msg = runAPIKeyEnvSelection(newTestProviderService(t, manager), " ")() + if result, ok := msg.(localCommandResultMsg); !ok || result.err != nil || !strings.Contains(result.notice, "restored") { + t.Fatalf("expected restore-default result, got %+v", msg) + } _ = model } @@ -869,6 +922,82 @@ func TestAppUpdateProviderPickerEnterAppliesSelection(t *testing.T) { } } +func TestAppUpdateAPIKeyPickerEnterAppliesOverride(t *testing.T) { + manager := newTestConfigManager(t) + runtime := newStubRuntime() + app, err := New(nil, manager, runtime, newTestProviderService(t, manager)) + if err != nil { + t.Fatalf("New() error = %v", err) + } + + app.openAPIKeyInput() + app.apiKeyInput.SetValue("CUSTOM_OPENAI_KEY") + + model, cmd := app.Update(tea.KeyMsg{Type: tea.KeyEnter}) + app = model.(App) + if app.state.ActivePicker != pickerNone { + t.Fatalf("expected picker to close after selection") + } + + for _, msg := range collectTeaMessages(cmd) { + model, follow := app.Update(msg) + app = model.(App) + _ = collectTeaMessages(follow) + } + + cfg := manager.Get() + if cfg.APIKeyEnvOverride != "CUSTOM_OPENAI_KEY" { + t.Fatalf("expected override to persist, got %q", cfg.APIKeyEnvOverride) + } + if app.state.CurrentAPIKeyEnv != "CUSTOM_OPENAI_KEY" { + t.Fatalf("expected current api key env to refresh, got %q", app.state.CurrentAPIKeyEnv) + } + if !strings.Contains(app.state.StatusText, "CUSTOM_OPENAI_KEY") { + t.Fatalf("expected success status to mention override, got %q", app.state.StatusText) + } +} + +func TestAppUpdateAPIKeyPickerEnterClearsOverride(t *testing.T) { + manager := newTestConfigManager(t) + if err := manager.Update(context.Background(), func(cfg *config.Config) error { + cfg.APIKeyEnvOverride = "CUSTOM_OPENAI_KEY" + return nil + }); err != nil { + t.Fatalf("seed api key override: %v", err) + } + runtime := newStubRuntime() + app, err := New(nil, manager, runtime, newTestProviderService(t, manager)) + if err != nil { + t.Fatalf("New() error = %v", err) + } + + app.openAPIKeyInput() + app.apiKeyInput.SetValue(" ") + + model, cmd := app.Update(tea.KeyMsg{Type: tea.KeyEnter}) + app = model.(App) + if app.state.ActivePicker != pickerNone { + t.Fatalf("expected picker to close after clearing override") + } + + for _, msg := range collectTeaMessages(cmd) { + model, follow := app.Update(msg) + app = model.(App) + _ = collectTeaMessages(follow) + } + + cfg := manager.Get() + if cfg.APIKeyEnvOverride != "" { + t.Fatalf("expected override to clear, got %q", cfg.APIKeyEnvOverride) + } + if app.state.CurrentAPIKeyEnv != provideropenai.DefaultAPIKeyEnv { + t.Fatalf("expected current api key env to fall back to provider default, got %q", app.state.CurrentAPIKeyEnv) + } + if !strings.Contains(strings.ToLower(app.state.StatusText), "default") { + t.Fatalf("expected restore-default status, got %q", app.state.StatusText) + } +} + func TestRefreshPickerKeepsSizeAfterRebuild(t *testing.T) { manager := newTestConfigManager(t) runtime := newStubRuntime() diff --git a/internal/tui/view.go b/internal/tui/view.go index a20bc2c4..6e52ad91 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -147,6 +147,10 @@ func (a App) renderPicker(width int, height int) string { title = providerPickerTitle subtitle = providerPickerSubtitle body = a.providerPicker.View() + } else if a.state.ActivePicker == pickerAPIKey { + title = apiKeyInputTitle + subtitle = apiKeyInputSubtitle + body = a.apiKeyInput.View() } content := lipgloss.JoinVertical( lipgloss.Left,