diff --git a/internal/cli/root.go b/internal/cli/root.go index 2be4f096..98af9e8a 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -9,6 +9,7 @@ import ( "github.com/spf13/viper" "neo-code/internal/app" + "neo-code/internal/config" ) var launchRootProgram = defaultRootProgramLauncher @@ -83,9 +84,12 @@ func defaultRootProgramLauncher(ctx context.Context, opts app.BootstrapOptions) return err } -// defaultGlobalPreload 预留给根命令全局预加载逻辑,默认不执行重度配置加载。 -func defaultGlobalPreload(_ context.Context) error { - return nil +// defaultGlobalPreload runs lightweight startup preloads such as persisted env. +func defaultGlobalPreload(ctx context.Context) error { + if err := ctx.Err(); err != nil { + return err + } + return config.LoadPersistedEnv("") } // shouldSkipGlobalPreload 判断当前命令是否应跳过全局预加载逻辑。 diff --git a/internal/cli/root_test.go b/internal/cli/root_test.go index ad473be2..d0716057 100644 --- a/internal/cli/root_test.go +++ b/internal/cli/root_test.go @@ -7,6 +7,7 @@ import ( "errors" "io" "os" + "path/filepath" "strings" "testing" @@ -14,6 +15,7 @@ import ( "github.com/spf13/cobra" "neo-code/internal/app" + "neo-code/internal/config" "neo-code/internal/gateway" "neo-code/internal/gateway/adapters/urlscheme" ) @@ -950,6 +952,70 @@ func TestShouldSkipGlobalPreload(t *testing.T) { } } +func TestDefaultGlobalPreloadLoadsPersistedEnv(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + + envPath := filepath.Join(home, ".neocode", ".env") + if err := os.MkdirAll(filepath.Dir(envPath), 0o755); err != nil { + t.Fatalf("MkdirAll() error = %v", err) + } + if err := os.WriteFile(envPath, []byte("NEOCODE_PRELOAD_KEY=loaded-value\n"), 0o644); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + + restore := captureEnvForRootTest(t, "NEOCODE_PRELOAD_KEY") + defer restore() + if err := os.Unsetenv("NEOCODE_PRELOAD_KEY"); err != nil { + t.Fatalf("Unsetenv() error = %v", err) + } + + if err := defaultGlobalPreload(context.Background()); err != nil { + t.Fatalf("defaultGlobalPreload() error = %v", err) + } + if got := os.Getenv("NEOCODE_PRELOAD_KEY"); got != "loaded-value" { + t.Fatalf("expected loaded env value, got %q", got) + } +} + +func TestDefaultGlobalPreloadKeepsExistingProcessEnv(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + + envPath := config.EnvFilePath("") + if err := os.MkdirAll(filepath.Dir(envPath), 0o755); err != nil { + t.Fatalf("MkdirAll() error = %v", err) + } + if err := os.WriteFile(envPath, []byte("NEOCODE_PRELOAD_KEEP=file-value\n"), 0o644); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + + restore := captureEnvForRootTest(t, "NEOCODE_PRELOAD_KEEP") + defer restore() + if err := os.Setenv("NEOCODE_PRELOAD_KEEP", "process-value"); err != nil { + t.Fatalf("Setenv() error = %v", err) + } + + if err := defaultGlobalPreload(context.Background()); err != nil { + t.Fatalf("defaultGlobalPreload() error = %v", err) + } + if got := os.Getenv("NEOCODE_PRELOAD_KEEP"); got != "process-value" { + t.Fatalf("expected existing process env value, got %q", got) + } +} + +func TestDefaultGlobalPreloadReturnsContextError(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + err := defaultGlobalPreload(ctx) + if !errors.Is(err, context.Canceled) { + t.Fatalf("expected context canceled, got %v", err) + } +} + func TestWriteURLDispatchSuccessOutput(t *testing.T) { var buffer bytes.Buffer err := writeURLDispatchSuccessOutput(&buffer, urlscheme.DispatchResult{ @@ -1038,3 +1104,15 @@ func (quitModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (quitModel) View() string { return "" } + +func captureEnvForRootTest(t *testing.T, key string) func() { + t.Helper() + value, exists := os.LookupEnv(key) + return func() { + if exists { + _ = os.Setenv(key, value) + return + } + _ = os.Unsetenv(key) + } +} diff --git a/internal/config/env_platform.go b/internal/config/env_platform.go new file mode 100644 index 00000000..1573c605 --- /dev/null +++ b/internal/config/env_platform.go @@ -0,0 +1,19 @@ +//go:build !windows + +package config + +// PersistUserEnvVar persists a key/value pair into user-level environment storage. +// On non-Windows platforms, NeoCode currently relies on .env persistence and process env. +func PersistUserEnvVar(key string, value string) error { + return nil +} + +// DeleteUserEnvVar 删除用户级环境变量;非 Windows 平台当前无需额外处理。 +func DeleteUserEnvVar(key string) error { + return nil +} + +// LookupUserEnvVar 查询用户级环境变量;非 Windows 平台当前无单独存储,统一返回不存在。 +func LookupUserEnvVar(key string) (string, bool, error) { + return "", false, nil +} diff --git a/internal/config/env_platform_test.go b/internal/config/env_platform_test.go new file mode 100644 index 00000000..287ea4a5 --- /dev/null +++ b/internal/config/env_platform_test.go @@ -0,0 +1,30 @@ +//go:build !windows + +package config + +import "testing" + +func TestPersistUserEnvVarNoopOnNonWindows(t *testing.T) { + if err := PersistUserEnvVar("NEOCODE_TEST_KEY", "value"); err != nil { + t.Fatalf("PersistUserEnvVar() error = %v", err) + } +} + +func TestDeleteUserEnvVarNoopOnNonWindows(t *testing.T) { + if err := DeleteUserEnvVar("NEOCODE_TEST_KEY"); err != nil { + t.Fatalf("DeleteUserEnvVar() error = %v", err) + } +} + +func TestLookupUserEnvVarNoopOnNonWindows(t *testing.T) { + value, exists, err := LookupUserEnvVar("NEOCODE_TEST_KEY") + if err != nil { + t.Fatalf("LookupUserEnvVar() error = %v", err) + } + if exists { + t.Fatalf("LookupUserEnvVar() exists = true, want false") + } + if value != "" { + t.Fatalf("LookupUserEnvVar() value = %q, want empty", value) + } +} diff --git a/internal/config/env_platform_windows.go b/internal/config/env_platform_windows.go new file mode 100644 index 00000000..df1ea19c --- /dev/null +++ b/internal/config/env_platform_windows.go @@ -0,0 +1,95 @@ +//go:build windows + +package config + +import ( + "errors" + "fmt" + "strings" + + "golang.org/x/sys/windows/registry" +) + +const windowsUserEnvironmentKey = `Environment` + +// PersistUserEnvVar persists key/value into Windows user environment variables. +func PersistUserEnvVar(key string, value string) error { + normalizedKey := strings.TrimSpace(key) + if normalizedKey == "" { + return errors.New("config: env key is empty") + } + if strings.ContainsAny(normalizedKey, " \t\r\n=") { + return fmt.Errorf("config: env key %q is invalid", normalizedKey) + } + if strings.ContainsAny(value, "\r\n") { + return errors.New("config: env value contains newline") + } + + envKey, _, err := registry.CreateKey(registry.CURRENT_USER, windowsUserEnvironmentKey, registry.SET_VALUE) + if err != nil { + return fmt.Errorf("config: open windows user env: %w", err) + } + defer envKey.Close() + + if err := envKey.SetStringValue(normalizedKey, value); err != nil { + return fmt.Errorf("config: set windows user env %q: %w", normalizedKey, err) + } + return nil +} + +// DeleteUserEnvVar 删除 Windows 用户级环境变量,不存在时视为成功。 +func DeleteUserEnvVar(key string) error { + normalizedKey := strings.TrimSpace(key) + if normalizedKey == "" { + return errors.New("config: env key is empty") + } + if strings.ContainsAny(normalizedKey, " \t\r\n=") { + return fmt.Errorf("config: env key %q is invalid", normalizedKey) + } + + envKey, err := registry.OpenKey(registry.CURRENT_USER, windowsUserEnvironmentKey, registry.SET_VALUE) + if err != nil { + if errors.Is(err, registry.ErrNotExist) { + return nil + } + return fmt.Errorf("config: open windows user env: %w", err) + } + defer envKey.Close() + + if err := envKey.DeleteValue(normalizedKey); err != nil { + if errors.Is(err, registry.ErrNotExist) { + return nil + } + return fmt.Errorf("config: delete windows user env %q: %w", normalizedKey, err) + } + return nil +} + +// LookupUserEnvVar 查询 Windows 用户级环境变量,不存在时返回 exists=false。 +func LookupUserEnvVar(key string) (string, bool, error) { + normalizedKey := strings.TrimSpace(key) + if normalizedKey == "" { + return "", false, errors.New("config: env key is empty") + } + if strings.ContainsAny(normalizedKey, " \t\r\n=") { + return "", false, fmt.Errorf("config: env key %q is invalid", normalizedKey) + } + + envKey, err := registry.OpenKey(registry.CURRENT_USER, windowsUserEnvironmentKey, registry.QUERY_VALUE) + if err != nil { + if errors.Is(err, registry.ErrNotExist) { + return "", false, nil + } + return "", false, fmt.Errorf("config: open windows user env: %w", err) + } + defer envKey.Close() + + value, _, err := envKey.GetStringValue(normalizedKey) + if err != nil { + if errors.Is(err, registry.ErrNotExist) { + return "", false, nil + } + return "", false, fmt.Errorf("config: read windows user env %q: %w", normalizedKey, err) + } + return value, true, nil +} diff --git a/internal/config/envfile.go b/internal/config/envfile.go new file mode 100644 index 00000000..b92f9cc7 --- /dev/null +++ b/internal/config/envfile.go @@ -0,0 +1,191 @@ +package config + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strconv" + "strings" +) + +const envFileName = ".env" + +// EnvFilePath returns the persisted env file path under the config base dir. +func EnvFilePath(baseDir string) string { + trimmed := strings.TrimSpace(baseDir) + if trimmed == "" { + trimmed = defaultBaseDir() + } + return filepath.Join(trimmed, envFileName) +} + +// PersistEnvVar upserts an env key/value pair into the persisted .env file. +func PersistEnvVar(baseDir string, key string, value string) error { + normalizedKey := strings.TrimSpace(key) + if normalizedKey == "" { + return errors.New("config: env key is empty") + } + if strings.ContainsAny(normalizedKey, " \t\r\n=") { + return fmt.Errorf("config: env key %q is invalid", normalizedKey) + } + if strings.ContainsAny(value, "\r\n") { + return errors.New("config: env value contains newline") + } + + envPath := EnvFilePath(baseDir) + if err := os.MkdirAll(filepath.Dir(envPath), 0o755); err != nil { + return fmt.Errorf("config: create env dir: %w", err) + } + + var lines []string + data, readErr := os.ReadFile(envPath) + switch { + case readErr == nil: + lines = strings.Split(strings.ReplaceAll(string(data), "\r\n", "\n"), "\n") + case os.IsNotExist(readErr): + lines = nil + default: + return fmt.Errorf("config: read env file: %w", readErr) + } + + updated := false + for i := range lines { + currentKey, _, ok := parseEnvAssignment(lines[i]) + if !ok { + continue + } + if currentKey == normalizedKey { + lines[i] = formatEnvAssignment(normalizedKey, value) + updated = true + break + } + } + if !updated { + lines = append(lines, formatEnvAssignment(normalizedKey, value)) + } + + content := strings.Join(lines, "\n") + content = strings.TrimRight(content, "\n") + "\n" + if err := os.WriteFile(envPath, []byte(content), 0o600); err != nil { + return fmt.Errorf("config: write env file: %w", err) + } + return nil +} + +// LoadPersistedEnv loads key/value pairs from persisted .env into process env. +// Existing process env keys are kept as-is. +func LoadPersistedEnv(baseDir string) error { + envPath := EnvFilePath(baseDir) + data, err := os.ReadFile(envPath) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return fmt.Errorf("config: read env file: %w", err) + } + + lines := strings.Split(strings.ReplaceAll(string(data), "\r\n", "\n"), "\n") + for _, line := range lines { + key, value, ok := parseEnvAssignment(line) + if !ok { + continue + } + if _, exists := os.LookupEnv(key); exists { + continue + } + if err := os.Setenv(key, value); err != nil { + return fmt.Errorf("config: set env %q: %w", key, err) + } + } + return nil +} + +// RemovePersistedEnvVar 从持久化 .env 文件中删除指定键;文件不存在时视为成功。 +func RemovePersistedEnvVar(baseDir string, key string) error { + normalizedKey := strings.TrimSpace(key) + if normalizedKey == "" { + return errors.New("config: env key is empty") + } + if strings.ContainsAny(normalizedKey, " \t\r\n=") { + return fmt.Errorf("config: env key %q is invalid", normalizedKey) + } + + envPath := EnvFilePath(baseDir) + data, err := os.ReadFile(envPath) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return fmt.Errorf("config: read env file: %w", err) + } + + lines := strings.Split(strings.ReplaceAll(string(data), "\r\n", "\n"), "\n") + filtered := make([]string, 0, len(lines)) + for _, line := range lines { + currentKey, _, ok := parseEnvAssignment(line) + if ok && currentKey == normalizedKey { + continue + } + filtered = append(filtered, line) + } + + content := strings.Join(filtered, "\n") + content = strings.TrimRight(content, "\n") + if content != "" { + content += "\n" + } + if err := os.WriteFile(envPath, []byte(content), 0o600); err != nil { + return fmt.Errorf("config: write env file: %w", err) + } + return nil +} + +func parseEnvAssignment(line string) (string, string, bool) { + trimmed := strings.TrimSpace(line) + if trimmed == "" || strings.HasPrefix(trimmed, "#") { + return "", "", false + } + if strings.HasPrefix(trimmed, "export ") { + trimmed = strings.TrimSpace(strings.TrimPrefix(trimmed, "export ")) + } + + eq := strings.IndexByte(trimmed, '=') + if eq <= 0 { + return "", "", false + } + + key := strings.TrimSpace(trimmed[:eq]) + if key == "" { + return "", "", false + } + rawValue := strings.TrimSpace(trimmed[eq+1:]) + return key, parseEnvValue(rawValue), true +} + +func parseEnvValue(raw string) string { + if len(raw) >= 2 && raw[0] == '\'' && raw[len(raw)-1] == '\'' { + return raw[1 : len(raw)-1] + } + if len(raw) >= 2 && raw[0] == '"' && raw[len(raw)-1] == '"' { + if unquoted, err := strconv.Unquote(raw); err == nil { + return unquoted + } + return raw[1 : len(raw)-1] + } + return raw +} + +func formatEnvAssignment(key string, value string) string { + return key + "=" + encodeEnvValue(value) +} + +func encodeEnvValue(value string) string { + if value == "" { + return `""` + } + if strings.ContainsAny(value, " \t\r\n#\"'") { + return strconv.Quote(value) + } + return value +} diff --git a/internal/config/envfile_test.go b/internal/config/envfile_test.go new file mode 100644 index 00000000..9e0e2b9a --- /dev/null +++ b/internal/config/envfile_test.go @@ -0,0 +1,300 @@ +package config + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestPersistEnvVarCreatesAndUpdatesEntry(t *testing.T) { + baseDir := t.TempDir() + path := EnvFilePath(baseDir) + + if err := PersistEnvVar(baseDir, "KIMI_API_KEY", "sk-first"); err != nil { + t.Fatalf("PersistEnvVar() first error = %v", err) + } + if err := PersistEnvVar(baseDir, "KIMI_API_KEY", "sk-second"); err != nil { + t.Fatalf("PersistEnvVar() second error = %v", err) + } + + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("ReadFile(%q) error = %v", path, err) + } + text := string(data) + if strings.Count(text, "KIMI_API_KEY=") != 1 { + t.Fatalf("expected exactly one key line, got %q", text) + } + if !strings.Contains(text, "KIMI_API_KEY=sk-second\n") { + t.Fatalf("expected updated value in env file, got %q", text) + } +} + +func TestPersistEnvVarPreservesOtherLines(t *testing.T) { + baseDir := t.TempDir() + path := EnvFilePath(baseDir) + original := "# comment\nOTHER_KEY=1\n\n" + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatalf("MkdirAll() error = %v", err) + } + if err := os.WriteFile(path, []byte(original), 0o644); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + + if err := PersistEnvVar(baseDir, "NEW_KEY", "value with space"); err != nil { + t.Fatalf("PersistEnvVar() error = %v", err) + } + + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("ReadFile() error = %v", err) + } + text := string(data) + if !strings.Contains(text, "# comment\n") || !strings.Contains(text, "OTHER_KEY=1\n") { + t.Fatalf("expected old lines to be preserved, got %q", text) + } + if !strings.Contains(text, "NEW_KEY=\"value with space\"\n") { + t.Fatalf("expected quoted inserted line, got %q", text) + } +} + +func TestLoadPersistedEnvLoadsMissingKeysOnly(t *testing.T) { + baseDir := t.TempDir() + path := EnvFilePath(baseDir) + content := "EXISTING_KEY=from-file\nNEW_KEY=\"hello world\"\n# ignored\n" + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatalf("MkdirAll() error = %v", err) + } + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + + restoreExisting := captureEnv(t, "EXISTING_KEY") + defer restoreExisting() + restoreNew := captureEnv(t, "NEW_KEY") + defer restoreNew() + + if err := os.Setenv("EXISTING_KEY", "from-process"); err != nil { + t.Fatalf("Setenv() error = %v", err) + } + if err := os.Unsetenv("NEW_KEY"); err != nil { + t.Fatalf("Unsetenv() error = %v", err) + } + + if err := LoadPersistedEnv(baseDir); err != nil { + t.Fatalf("LoadPersistedEnv() error = %v", err) + } + + if got := os.Getenv("EXISTING_KEY"); got != "from-process" { + t.Fatalf("expected EXISTING_KEY to keep process value, got %q", got) + } + if got := os.Getenv("NEW_KEY"); got != "hello world" { + t.Fatalf("expected NEW_KEY loaded from env file, got %q", got) + } +} + +func TestPersistEnvVarRejectsInvalidInput(t *testing.T) { + baseDir := t.TempDir() + if err := PersistEnvVar(baseDir, "", "value"); err == nil { + t.Fatal("expected empty key error") + } + if err := PersistEnvVar(baseDir, "BAD KEY", "value"); err == nil { + t.Fatal("expected invalid key error") + } + if err := PersistEnvVar(baseDir, "KEY", "line1\nline2"); err == nil { + t.Fatal("expected newline value error") + } +} + +func TestRemovePersistedEnvVarRemovesEntryOnly(t *testing.T) { + baseDir := t.TempDir() + path := EnvFilePath(baseDir) + content := "KEEP=1\nREMOVE=2\nKEEP_AGAIN=3\n" + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatalf("MkdirAll() error = %v", err) + } + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + + if err := RemovePersistedEnvVar(baseDir, "REMOVE"); err != nil { + t.Fatalf("RemovePersistedEnvVar() error = %v", err) + } + + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("ReadFile() error = %v", err) + } + got := string(data) + if strings.Contains(got, "REMOVE=2") { + t.Fatalf("expected key to be removed, got %q", got) + } + if !strings.Contains(got, "KEEP=1") || !strings.Contains(got, "KEEP_AGAIN=3") { + t.Fatalf("expected other lines preserved, got %q", got) + } +} + +func TestRemovePersistedEnvVarHandlesMissingFileAndInvalidKey(t *testing.T) { + baseDir := t.TempDir() + + if err := RemovePersistedEnvVar(baseDir, "MISSING"); err != nil { + t.Fatalf("expected missing file to be ignored, got %v", err) + } + if err := RemovePersistedEnvVar(baseDir, " "); err == nil { + t.Fatal("expected empty key error") + } + if err := RemovePersistedEnvVar(baseDir, "BAD KEY"); err == nil { + t.Fatal("expected invalid key error") + } +} + +func TestParseEnvAssignmentAndValueVariants(t *testing.T) { + tests := []struct { + line string + wantKey string + wantVal string + wantOkay bool + }{ + {line: "", wantOkay: false}, + {line: "# comment", wantOkay: false}, + {line: "NO_EQUALS", wantOkay: false}, + {line: "export KEY=value", wantKey: "KEY", wantVal: "value", wantOkay: true}, + {line: "KEY='single quoted value'", wantKey: "KEY", wantVal: "single quoted value", wantOkay: true}, + {line: `KEY="line\tvalue"`, wantKey: "KEY", wantVal: "line\tvalue", wantOkay: true}, + {line: `KEY="unterminated`, wantKey: "KEY", wantVal: `"unterminated`, wantOkay: true}, + {line: "SPACED = plain ", wantKey: "SPACED", wantVal: "plain", wantOkay: true}, + } + for _, tt := range tests { + key, val, ok := parseEnvAssignment(tt.line) + if ok != tt.wantOkay { + t.Fatalf("parseEnvAssignment(%q) ok = %v, want %v", tt.line, ok, tt.wantOkay) + } + if !tt.wantOkay { + continue + } + if key != tt.wantKey || val != tt.wantVal { + t.Fatalf("parseEnvAssignment(%q) = (%q,%q), want (%q,%q)", tt.line, key, val, tt.wantKey, tt.wantVal) + } + } +} + +func TestEncodeEnvValue(t *testing.T) { + tests := []struct { + value string + want string + }{ + {value: "", want: `""`}, + {value: "plain", want: "plain"}, + {value: "has space", want: `"has space"`}, + {value: `has"quote`, want: `"has\"quote"`}, + {value: "has#hash", want: `"has#hash"`}, + } + for _, tt := range tests { + if got := encodeEnvValue(tt.value); got != tt.want { + t.Fatalf("encodeEnvValue(%q) = %q, want %q", tt.value, got, tt.want) + } + } +} + +func TestEnvFilePathFallbackToDefaultBaseDir(t *testing.T) { + got := EnvFilePath(" ") + if got != filepath.Join(defaultBaseDir(), envFileName) { + t.Fatalf("EnvFilePath(blank) = %q", got) + } +} + +func TestPersistEnvVarErrorPaths(t *testing.T) { + t.Run("base dir is file", func(t *testing.T) { + tempRoot := t.TempDir() + fileBase := filepath.Join(tempRoot, "not-a-dir") + if err := os.WriteFile(fileBase, []byte("x"), 0o600); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + if err := PersistEnvVar(fileBase, "KEY", "value"); err == nil { + t.Fatal("expected mkdir error") + } + }) + + t.Run("read env file failed", func(t *testing.T) { + baseDir := t.TempDir() + envPath := EnvFilePath(baseDir) + if err := os.MkdirAll(envPath, 0o755); err != nil { + t.Fatalf("MkdirAll() error = %v", err) + } + if err := PersistEnvVar(baseDir, "KEY", "value"); err == nil { + t.Fatal("expected read error when env path is a directory") + } + }) + + t.Run("write env file failed", func(t *testing.T) { + baseDir := t.TempDir() + envPath := EnvFilePath(baseDir) + if err := os.WriteFile(envPath, []byte("KEY=old\n"), 0o400); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + if err := PersistEnvVar(baseDir, "KEY", "new"); err == nil { + t.Fatal("expected write error") + } + }) +} + +func TestLoadPersistedEnvErrorPaths(t *testing.T) { + t.Run("read env file failed", func(t *testing.T) { + baseDir := t.TempDir() + envPath := EnvFilePath(baseDir) + if err := os.MkdirAll(envPath, 0o755); err != nil { + t.Fatalf("MkdirAll() error = %v", err) + } + if err := LoadPersistedEnv(baseDir); err == nil { + t.Fatal("expected read error when env path is a directory") + } + }) + + t.Run("setenv failed on invalid key", func(t *testing.T) { + baseDir := t.TempDir() + envPath := EnvFilePath(baseDir) + if err := os.WriteFile(envPath, []byte("BAD\x00KEY=value\n"), 0o600); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + if err := LoadPersistedEnv(baseDir); err == nil { + t.Fatal("expected setenv error") + } + }) +} + +func TestRemovePersistedEnvVarErrorPaths(t *testing.T) { + t.Run("read env file failed", func(t *testing.T) { + baseDir := t.TempDir() + envPath := EnvFilePath(baseDir) + if err := os.MkdirAll(envPath, 0o755); err != nil { + t.Fatalf("MkdirAll() error = %v", err) + } + if err := RemovePersistedEnvVar(baseDir, "KEY"); err == nil { + t.Fatal("expected read error") + } + }) + + t.Run("write env file failed", func(t *testing.T) { + baseDir := t.TempDir() + envPath := EnvFilePath(baseDir) + if err := os.WriteFile(envPath, []byte("KEY=value\n"), 0o400); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + if err := RemovePersistedEnvVar(baseDir, "KEY"); err == nil { + t.Fatal("expected write error") + } + }) +} + +func captureEnv(t *testing.T, key string) func() { + t.Helper() + value, exists := os.LookupEnv(key) + return func() { + if exists { + _ = os.Setenv(key, value) + return + } + _ = os.Unsetenv(key) + } +} diff --git a/internal/config/loader_test.go b/internal/config/loader_test.go index fb89594e..2cee0104 100644 --- a/internal/config/loader_test.go +++ b/internal/config/loader_test.go @@ -6,6 +6,8 @@ import ( "path/filepath" "strings" "testing" + + "neo-code/internal/provider" ) func writeLoaderConfig(t *testing.T, loader *Loader, raw string) { @@ -853,6 +855,289 @@ func TestResolveCustomProviderSettingsByDriver(t *testing.T) { } } +func TestSaveCustomProviderPersistsDriverSpecificSettings(t *testing.T) { + t.Parallel() + + baseDir := t.TempDir() + tests := []struct { + name string + driver string + baseURL string + apiStyle string + deploymentMode string + apiVersion string + assert func(t *testing.T, cfg ProviderConfig) + }{ + { + name: "openaicompat settings", + driver: provider.DriverOpenAICompat, + baseURL: "https://llm.example.com/v1", + apiStyle: provider.OpenAICompatibleAPIStyleResponses, + deploymentMode: "ignored", + apiVersion: "ignored", + assert: func(t *testing.T, cfg ProviderConfig) { + t.Helper() + if cfg.APIStyle != provider.OpenAICompatibleAPIStyleResponses { + t.Fatalf("expected APIStyle=%q, got %q", provider.OpenAICompatibleAPIStyleResponses, cfg.APIStyle) + } + if cfg.DeploymentMode != "" || cfg.APIVersion != "" { + t.Fatalf("expected non-openai specific settings to be empty, got %+v", cfg) + } + }, + }, + { + name: "gemini settings", + driver: provider.DriverGemini, + baseURL: "https://generativelanguage.googleapis.com/v1beta/openai", + apiStyle: "ignored", + deploymentMode: "vertex", + apiVersion: "ignored", + assert: func(t *testing.T, cfg ProviderConfig) { + t.Helper() + if cfg.DeploymentMode != "vertex" { + t.Fatalf("expected DeploymentMode=vertex, got %q", cfg.DeploymentMode) + } + if cfg.APIStyle != "" || cfg.APIVersion != "" { + t.Fatalf("expected non-gemini specific settings to be empty, got %+v", cfg) + } + }, + }, + { + name: "anthropic settings", + driver: provider.DriverAnthropic, + baseURL: "https://api.anthropic.com/v1", + apiStyle: "ignored", + deploymentMode: "ignored", + apiVersion: "2023-06-01", + assert: func(t *testing.T, cfg ProviderConfig) { + t.Helper() + if cfg.APIVersion != "2023-06-01" { + t.Fatalf("expected APIVersion=2023-06-01, got %q", cfg.APIVersion) + } + if cfg.APIStyle != "" || cfg.DeploymentMode != "" { + t.Fatalf("expected non-anthropic specific settings to be empty, got %+v", cfg) + } + }, + }, + { + name: "unknown driver keeps top-level base url", + driver: "custom-driver", + baseURL: "https://custom.example.com/v1", + apiStyle: "responses", + deploymentMode: "vertex", + apiVersion: "2023-06-01", + assert: func(t *testing.T, cfg ProviderConfig) { + t.Helper() + if cfg.BaseURL != "https://custom.example.com/v1" { + t.Fatalf("expected BaseURL=https://custom.example.com/v1, got %q", cfg.BaseURL) + } + if cfg.APIStyle != "" || cfg.DeploymentMode != "" || cfg.APIVersion != "" { + t.Fatalf("expected unknown driver protocol settings to be empty, got %+v", cfg) + } + }, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + providerName := strings.ReplaceAll(tt.name, " ", "-") + apiKeyEnv := strings.ToUpper(strings.ReplaceAll(providerName, "-", "_")) + "_API_KEY" + if err := SaveCustomProvider( + baseDir, + providerName, + tt.driver, + tt.baseURL, + apiKeyEnv, + tt.apiStyle, + tt.deploymentMode, + tt.apiVersion, + ); err != nil { + t.Fatalf("SaveCustomProvider() error = %v", err) + } + + cfg, err := loadCustomProvider(filepath.Join(baseDir, providersDirName, providerName)) + if err != nil { + t.Fatalf("loadCustomProvider() error = %v", err) + } + if cfg.Driver != tt.driver { + t.Fatalf("expected driver %q, got %q", tt.driver, cfg.Driver) + } + if cfg.BaseURL != tt.baseURL { + t.Fatalf("expected baseURL %q, got %q", tt.baseURL, cfg.BaseURL) + } + if cfg.APIKeyEnv != apiKeyEnv { + t.Fatalf("expected api_key_env %q, got %q", apiKeyEnv, cfg.APIKeyEnv) + } + tt.assert(t, cfg) + }) + } +} + +func TestSaveCustomProviderRejectsUnsafeProviderName(t *testing.T) { + t.Parallel() + + baseDir := t.TempDir() + invalidNames := []string{ + "", + " ", + "../escape", + "..", + "team/gateway", + `team\gateway`, + "/tmp/abs", + "中文", + } + for _, name := range invalidNames { + err := SaveCustomProvider( + baseDir, + name, + provider.DriverOpenAICompat, + "https://llm.example.com/v1", + "CUSTOM_API_KEY", + provider.OpenAICompatibleAPIStyleChatCompletions, + "", + "", + ) + if err == nil { + t.Fatalf("expected SaveCustomProvider to reject %q", name) + } + } +} + +func TestDeleteCustomProviderRejectsUnsafeProviderName(t *testing.T) { + t.Parallel() + + baseDir := t.TempDir() + invalidNames := []string{ + "", + "../escape", + "team/gateway", + `team\gateway`, + } + for _, name := range invalidNames { + if err := DeleteCustomProvider(baseDir, name); err == nil { + t.Fatalf("expected DeleteCustomProvider to reject %q", name) + } + } +} + +func TestDeleteCustomProviderRemovesProviderDir(t *testing.T) { + t.Parallel() + + baseDir := t.TempDir() + providerName := "team-gateway" + providerDir := filepath.Join(baseDir, providersDirName, providerName) + if err := os.MkdirAll(providerDir, 0o755); err != nil { + t.Fatalf("MkdirAll() error = %v", err) + } + + if err := DeleteCustomProvider(baseDir, providerName); err != nil { + t.Fatalf("DeleteCustomProvider() error = %v", err) + } + if _, err := os.Stat(providerDir); !os.IsNotExist(err) { + t.Fatalf("expected provider dir to be removed, stat err = %v", err) + } +} + +func TestLoadCustomProvidersReadDirAndStatErrors(t *testing.T) { + t.Run("providers dir read error", func(t *testing.T) { + baseDir := t.TempDir() + providersPath := filepath.Join(baseDir, providersDirName) + if err := os.WriteFile(providersPath, []byte("file"), 0o600); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + + if _, err := loadCustomProviders(baseDir); err == nil { + t.Fatal("expected read providers dir error") + } + }) + + t.Run("provider yaml stat error", func(t *testing.T) { + baseDir := t.TempDir() + providerDir := filepath.Join(baseDir, providersDirName, "blocked") + if err := os.MkdirAll(providerDir, 0o755); err != nil { + t.Fatalf("MkdirAll() error = %v", err) + } + if err := os.Chmod(providerDir, 0o000); err != nil { + t.Fatalf("Chmod() error = %v", err) + } + defer func() { _ = os.Chmod(providerDir, 0o755) }() + + if _, err := loadCustomProviders(baseDir); err == nil { + t.Fatal("expected stat error") + } + }) +} + +func TestLoadCustomProviderReadErrors(t *testing.T) { + t.Run("missing provider yaml", func(t *testing.T) { + providerDir := t.TempDir() + if _, err := loadCustomProvider(providerDir); err == nil { + t.Fatal("expected missing provider yaml error") + } + }) + + t.Run("provider yaml read error", func(t *testing.T) { + providerDir := t.TempDir() + providerPath := filepath.Join(providerDir, customProviderConfigName) + if err := os.MkdirAll(providerPath, 0o755); err != nil { + t.Fatalf("MkdirAll() error = %v", err) + } + if _, err := loadCustomProvider(providerDir); err == nil { + t.Fatal("expected provider yaml read error") + } + }) +} + +func TestSaveCustomProviderFileSystemErrors(t *testing.T) { + t.Run("mkdir provider dir failed", func(t *testing.T) { + root := t.TempDir() + baseDir := filepath.Join(root, "base-file") + if err := os.WriteFile(baseDir, []byte("x"), 0o600); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + + err := SaveCustomProvider( + baseDir, + "team-gateway", + provider.DriverOpenAICompat, + "https://llm.example.com/v1", + "TEAM_GATEWAY_API_KEY", + provider.OpenAICompatibleAPIStyleChatCompletions, + "", + "", + ) + if err == nil { + t.Fatal("expected create provider dir error") + } + }) + + t.Run("write provider yaml failed", func(t *testing.T) { + baseDir := t.TempDir() + providerDir := filepath.Join(baseDir, providersDirName, "team-gateway") + if err := os.MkdirAll(filepath.Join(providerDir, customProviderConfigName), 0o755); err != nil { + t.Fatalf("MkdirAll() error = %v", err) + } + + err := SaveCustomProvider( + baseDir, + "team-gateway", + provider.DriverOpenAICompat, + "https://llm.example.com/v1", + "TEAM_GATEWAY_API_KEY", + provider.OpenAICompatibleAPIStyleChatCompletions, + "", + "", + ) + if err == nil { + t.Fatal("expected write provider error") + } + }) +} + func TestLoaderLoadsUnknownCustomProviderDriverUsingTopLevelBaseURL(t *testing.T) { t.Parallel() diff --git a/internal/config/provider_loader.go b/internal/config/provider_loader.go index 43448ea6..dd51cb7d 100644 --- a/internal/config/provider_loader.go +++ b/internal/config/provider_loader.go @@ -2,6 +2,7 @@ package config import ( "bytes" + "errors" "fmt" "os" "path/filepath" @@ -206,3 +207,96 @@ func resolveCustomProviderSettings(file customProviderFile) customProviderSettin return settings } + +// SaveCustomProvider 保存自定义 provider 到文件系统。 +func SaveCustomProvider( + baseDir string, + name string, + driver string, + baseURL string, + apiKeyEnv string, + apiStyle string, + deploymentMode string, + apiVersion string, +) error { + if err := validateCustomProviderName(name); err != nil { + return err + } + + providersDir := filepath.Join(baseDir, providersDirName, name) + if err := os.MkdirAll(providersDir, 0o755); err != nil { + return fmt.Errorf("config: create provider dir: %w", err) + } + + normalizedDriver := normalizeProviderDriver(driver) + cfg := customProviderFile{ + Name: name, + Driver: normalizedDriver, + APIKeyEnv: apiKeyEnv, + } + + switch normalizedDriver { + case provider.DriverOpenAICompat: + cfg.OpenAICompatible = customOpenAICompatibleFile{ + BaseURL: baseURL, + APIStyle: strings.TrimSpace(apiStyle), + } + case provider.DriverGemini: + cfg.Gemini = customGeminiProviderFile{ + BaseURL: baseURL, + DeploymentMode: strings.TrimSpace(deploymentMode), + } + case provider.DriverAnthropic: + cfg.Anthropic = customAnthropicProviderFile{ + BaseURL: baseURL, + APIVersion: strings.TrimSpace(apiVersion), + } + default: + cfg.BaseURL = baseURL + } + + data, err := yaml.Marshal(cfg) + if err != nil { + return fmt.Errorf("config: marshal provider: %w", err) + } + + providerPath := filepath.Join(providersDir, customProviderConfigName) + if err := os.WriteFile(providerPath, data, 0o644); err != nil { + return fmt.Errorf("config: write provider: %w", err) + } + + return nil +} + +// DeleteCustomProvider 删除自定义 provider。 +func DeleteCustomProvider(baseDir string, name string) error { + if err := validateCustomProviderName(name); err != nil { + return err + } + providersDir := filepath.Join(baseDir, providersDirName, name) + return os.RemoveAll(providersDir) +} + +// validateCustomProviderName 校验 provider 名称,拒绝路径穿越和分隔符语义。 +func validateCustomProviderName(name string) error { + trimmed := strings.TrimSpace(name) + if trimmed == "" { + return errors.New("config: provider name is empty") + } + if trimmed == "." || trimmed == ".." { + return fmt.Errorf("config: provider name %q is invalid", name) + } + if strings.ContainsAny(trimmed, `/\`) { + return fmt.Errorf("config: provider name %q is invalid", name) + } + if filepath.IsAbs(trimmed) { + return fmt.Errorf("config: provider name %q is invalid", name) + } + for _, r := range trimmed { + if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '.' || r == '_' || r == '-' { + continue + } + return fmt.Errorf("config: provider name %q contains unsupported character %q", name, string(r)) + } + return nil +} diff --git a/internal/tui/core/app/app.go b/internal/tui/core/app/app.go index ce7a7b72..394eaa82 100644 --- a/internal/tui/core/app/app.go +++ b/internal/tui/core/app/app.go @@ -34,12 +34,13 @@ const ( type pickerMode = tuistate.PickerMode const ( - pickerNone pickerMode = tuistate.PickerNone - pickerProvider pickerMode = tuistate.PickerProvider - pickerModel pickerMode = tuistate.PickerModel - pickerSession pickerMode = tuistate.PickerSession - pickerFile pickerMode = tuistate.PickerFile - pickerHelp pickerMode = tuistate.PickerHelp + pickerNone pickerMode = tuistate.PickerNone + pickerProvider pickerMode = tuistate.PickerProvider + pickerModel pickerMode = tuistate.PickerModel + pickerSession pickerMode = tuistate.PickerSession + pickerFile pickerMode = tuistate.PickerFile + pickerHelp pickerMode = tuistate.PickerHelp + pickerProviderAdd pickerMode = tuistate.PickerProviderAdd ) type RuntimeMsg = tuistate.RuntimeMsg @@ -108,6 +109,7 @@ type appRuntimeState struct { pendingPermission *permissionPromptState pendingImageAttachments []pendingImageAttachment currentModelCapabilities modelCapabilityState + providerAddForm *providerAddFormState } type pendingImageAttachment struct { @@ -122,6 +124,22 @@ type modelCapabilityState struct { checked bool } +// providerAddFormState 保存添加新 provider 表单的状态。 +type providerAddFormState struct { + Step int // 当前聚焦字段在“当前 driver 可见字段列表”中的索引 + Name string + Driver string + BaseURL string + APIStyle string + DeploymentMode string + APIVersion string + APIKey string + Error string + ErrorIsHard bool + Submitting bool + Drivers []string // 可选的 Driver 列表 +} + type App struct { state tuistate.UIState appServices diff --git a/internal/tui/core/app/commands.go b/internal/tui/core/app/commands.go index 3f1c956a..653c1ef6 100644 --- a/internal/tui/core/app/commands.go +++ b/internal/tui/core/app/commands.go @@ -17,32 +17,34 @@ import ( ) const ( - slashPrefix = "/" - slashCommandHelp = "/help" - slashCommandExit = "/exit" - slashCommandClear = "/clear" - slashCommandCompact = "/compact" - slashCommandStatus = "/status" - slashCommandProvider = "/provider" - slashCommandModelPick = "/model" - slashCommandSession = "/session" - slashCommandCWD = "/cwd" - slashCommandMemo = "/memo" - slashCommandRemember = "/remember" - slashCommandForget = "/forget" - - slashUsageHelp = "/help" - slashUsageExit = "/exit" - slashUsageClear = "/clear" - slashUsageCompact = "/compact" - slashUsageStatus = "/status" - slashUsageProvider = "/provider" - slashUsageModel = "/model" - slashUsageSession = "/session" - slashUsageWorkdir = "/cwd" - slashUsageMemo = "/memo" - slashUsageRemember = "/remember " - slashUsageForget = "/forget " + slashPrefix = "/" + slashCommandHelp = "/help" + slashCommandExit = "/exit" + slashCommandClear = "/clear" + slashCommandCompact = "/compact" + slashCommandStatus = "/status" + slashCommandProvider = "/provider" + slashCommandProviderAdd = "/provider add" + slashCommandModelPick = "/model" + slashCommandSession = "/session" + slashCommandCWD = "/cwd" + slashCommandMemo = "/memo" + slashCommandRemember = "/remember" + slashCommandForget = "/forget" + + slashUsageHelp = "/help" + slashUsageExit = "/exit" + slashUsageClear = "/clear" + slashUsageCompact = "/compact" + slashUsageStatus = "/status" + slashUsageProvider = "/provider" + slashUsageProviderAdd = "/provider add" + slashUsageModel = "/model" + slashUsageSession = "/session" + slashUsageWorkdir = "/cwd" + slashUsageMemo = "/memo" + slashUsageRemember = "/remember " + slashUsageForget = "/forget " commandMenuTitle = "Suggestions" providerPickerTitle = "Select Provider" @@ -55,6 +57,8 @@ const ( helpPickerSubtitle = "Up/Down choose, Enter run, Esc cancel" filePickerTitle = "Browse Files" filePickerSubtitle = "Navigate folders, Enter choose file, Esc cancel" + providerAddTitle = "Add New Provider" + providerAddSubtitle = "Fill in details, Tab switch field, Enter confirm, Esc cancel" activityTitle = "Activity" activitySubtitle = "Latest execution events" @@ -123,6 +127,7 @@ var builtinSlashCommands = []slashCommand{ {Usage: slashUsageRemember, Description: "Save a persistent memo (/remember )"}, {Usage: slashUsageForget, Description: "Remove memos matching keyword (/forget )"}, {Usage: slashUsageProvider, Description: "Open the interactive provider picker"}, + {Usage: slashUsageProviderAdd, Description: "Add a new custom provider"}, {Usage: slashUsageModel, Description: "Open the interactive model picker"}, {Usage: slashUsageSession, Description: "Switch to another session"}, {Usage: slashUsageExit, Description: "Exit NeoCode"}, diff --git a/internal/tui/core/app/update.go b/internal/tui/core/app/update.go index 63756cc5..d35b9149 100644 --- a/internal/tui/core/app/update.go +++ b/internal/tui/core/app/update.go @@ -4,9 +4,11 @@ import ( "context" "errors" "fmt" + "os" "path/filepath" "strings" "time" + "unicode/utf8" "github.com/charmbracelet/bubbles/key" "github.com/charmbracelet/bubbles/list" @@ -16,6 +18,7 @@ import ( "neo-code/internal/config" "neo-code/internal/memo" + "neo-code/internal/provider" providertypes "neo-code/internal/provider/types" agentruntime "neo-code/internal/runtime" approvalflow "neo-code/internal/runtime/approval" @@ -39,7 +42,12 @@ const ( pasteBurstThreshold = tuistate.PasteBurstThreshold ) +const providerAddSelectTimeout = 10 * time.Second + var panelOrder = []panel{panelTranscript, panelActivity, panelInput} +var persistProviderUserEnvVar = config.PersistUserEnvVar +var deleteProviderUserEnvVar = config.DeleteUserEnvVar +var lookupProviderUserEnvVar = config.LookupUserEnvVar func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmds []tea.Cmd @@ -55,6 +63,9 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { a.height = typed.Height a.applyComponentLayout(true) return a, tea.Batch(cmds...) + case providerAddResultMsg: + a.handleProviderAddResultMsg(typed) + return a, nil case RuntimeMsg: transcriptDirty := a.handleRuntimeEvent(typed.Event) if a.deferredEventCmd != nil { @@ -229,6 +240,7 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if a.state.ActivePicker != pickerNone { return a.updatePicker(typed) } + if a.focus == panelInput { if cmd, handled := a.updateCommandMenuSelection(typed); handled { if cmd != nil { @@ -380,6 +392,9 @@ func (a App) updateInputPanel(msg tea.Msg, typed tea.KeyMsg, cmds []tea.Cmd) (te } a.openPicker(pickerSession, statusChooseSession, &a.sessionPicker, a.state.ActiveSessionID) return a, tea.Batch(cmds...) + case slashCommandProviderAdd: + a.startProviderAddForm() + return a, tea.Batch(cmds...) } if strings.HasPrefix(input, slashPrefix) { @@ -673,6 +688,8 @@ func (a App) updatePicker(msg tea.KeyMsg) (tea.Model, tea.Cmd) { if disabled, path := a.fileBrowser.DidSelectDisabledFile(msg); disabled { a.state.StatusText = fmt.Sprintf("[System] %s is not selectable.", filepath.Base(path)) } + case pickerProviderAdd: + return a.handleProviderAddFormInput(msg) } return a, cmd } @@ -1769,6 +1786,9 @@ func (a *App) handleImmediateSlashCommand(input string) (bool, tea.Cmd) { } a.openPicker(pickerSession, statusChooseSession, &a.sessionPicker, a.state.ActiveSessionID) return true, nil + case slashCommandProviderAdd: + a.startProviderAddForm() + return true, nil default: return false, nil } @@ -2015,3 +2035,611 @@ func (a *App) setCurrentWorkdir(workdir string) { a.state.CurrentWorkdir = trimmed } + +type providerAddFieldID int + +const ( + providerAddFieldName providerAddFieldID = iota + providerAddFieldDriver + providerAddFieldBaseURL + providerAddFieldAPIStyle + providerAddFieldDeploymentMode + providerAddFieldAPIVersion + providerAddFieldAPIKey +) + +func providerAddVisibleFields(driver string) []providerAddFieldID { + fields := []providerAddFieldID{ + providerAddFieldName, + providerAddFieldDriver, + providerAddFieldBaseURL, + } + + switch provider.NormalizeProviderDriver(driver) { + case provider.DriverOpenAICompat: + fields = append(fields, providerAddFieldAPIStyle) + case provider.DriverGemini: + fields = append(fields, providerAddFieldDeploymentMode) + case provider.DriverAnthropic: + fields = append(fields, providerAddFieldAPIVersion) + } + + fields = append(fields, providerAddFieldAPIKey) + return fields +} + +func clampProviderAddStep(form *providerAddFormState) { + if form == nil { + return + } + fields := providerAddVisibleFields(form.Driver) + if len(fields) == 0 { + form.Step = 0 + return + } + if form.Step < 0 { + form.Step = 0 + } + if form.Step >= len(fields) { + form.Step = len(fields) - 1 + } +} + +func currentProviderAddField(form *providerAddFormState) providerAddFieldID { + if form == nil { + return providerAddFieldName + } + clampProviderAddStep(form) + fields := providerAddVisibleFields(form.Driver) + if len(fields) == 0 { + return providerAddFieldName + } + return fields[form.Step] +} + +func (a *App) startProviderAddForm() { + a.providerAddForm = &providerAddFormState{ + Step: 0, + Name: "", + Driver: provider.DriverOpenAICompat, + BaseURL: "", + APIStyle: provider.OpenAICompatibleAPIStyleChatCompletions, + DeploymentMode: "", + APIVersion: "", + APIKey: "", + Error: "", + ErrorIsHard: false, + Drivers: []string{provider.DriverOpenAICompat, provider.DriverGemini, provider.DriverAnthropic}, + } + a.state.ActivePicker = pickerProviderAdd + a.state.StatusText = "Add new provider" + a.state.ExecutionError = "" +} + +func (a *App) handleProviderAddFormInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + if a.providerAddForm == nil || a.providerAddForm.Submitting { + return a, nil + } + + typed := msg + prevStep := a.providerAddForm.Step + fields := providerAddVisibleFields(a.providerAddForm.Driver) + fieldCount := len(fields) + if fieldCount == 0 { + fieldCount = 1 + } + + switch { + case key.Matches(typed, a.keys.PrevPanel): + a.providerAddForm.Step = (a.providerAddForm.Step + fieldCount - 1) % fieldCount + case key.Matches(typed, a.keys.NextPanel): + a.providerAddForm.Step = (a.providerAddForm.Step + 1) % fieldCount + case key.Matches(typed, a.keys.Send): + return a, a.submitProviderAddForm() + case key.Matches(typed, a.keys.FocusInput): + a.providerAddForm = nil + a.state.ActivePicker = pickerNone + a.state.StatusText = statusReady + case typed.Type == tea.KeyBackspace: + switch currentProviderAddField(a.providerAddForm) { + case providerAddFieldName: + a.providerAddForm.Name = trimLastRune(a.providerAddForm.Name) + case providerAddFieldBaseURL: + a.providerAddForm.BaseURL = trimLastRune(a.providerAddForm.BaseURL) + case providerAddFieldAPIStyle: + a.providerAddForm.APIStyle = trimLastRune(a.providerAddForm.APIStyle) + case providerAddFieldDeploymentMode: + a.providerAddForm.DeploymentMode = trimLastRune(a.providerAddForm.DeploymentMode) + case providerAddFieldAPIVersion: + a.providerAddForm.APIVersion = trimLastRune(a.providerAddForm.APIVersion) + case providerAddFieldAPIKey: + a.providerAddForm.APIKey = trimLastRune(a.providerAddForm.APIKey) + } + return a, nil + case typed.Type == tea.KeyUp: + if currentProviderAddField(a.providerAddForm) == providerAddFieldDriver { + currentIdx := 0 + for i, d := range a.providerAddForm.Drivers { + if d == a.providerAddForm.Driver { + currentIdx = i + break + } + } + currentIdx = (currentIdx - 1 + len(a.providerAddForm.Drivers)) % len(a.providerAddForm.Drivers) + a.providerAddForm.Driver = a.providerAddForm.Drivers[currentIdx] + clampProviderAddStep(a.providerAddForm) + } + return a, nil + case typed.Type == tea.KeyDown: + if currentProviderAddField(a.providerAddForm) == providerAddFieldDriver { + currentIdx := 0 + for i, d := range a.providerAddForm.Drivers { + if d == a.providerAddForm.Driver { + currentIdx = i + break + } + } + currentIdx = (currentIdx + 1) % len(a.providerAddForm.Drivers) + a.providerAddForm.Driver = a.providerAddForm.Drivers[currentIdx] + clampProviderAddStep(a.providerAddForm) + } + return a, nil + default: + if len(typed.Runes) > 0 { + switch currentProviderAddField(a.providerAddForm) { + case providerAddFieldName: + a.providerAddForm.Name += string(typed.Runes) + case providerAddFieldBaseURL: + a.providerAddForm.BaseURL += string(typed.Runes) + case providerAddFieldAPIStyle: + a.providerAddForm.APIStyle += string(typed.Runes) + case providerAddFieldDeploymentMode: + a.providerAddForm.DeploymentMode += string(typed.Runes) + case providerAddFieldAPIVersion: + a.providerAddForm.APIVersion += string(typed.Runes) + case providerAddFieldAPIKey: + a.providerAddForm.APIKey += string(typed.Runes) + } + } + } + + if prevStep != a.providerAddForm.Step { + a.providerAddForm.Error = "" + a.providerAddForm.ErrorIsHard = false + } + + return a, nil +} + +func (a *App) submitProviderAddForm() tea.Cmd { + if a.providerAddForm == nil { + return nil + } + + request, validationErr := buildProviderAddRequest(*a.providerAddForm) + if validationErr != "" { + a.providerAddForm.Error = "Please update the form: " + validationErr + a.providerAddForm.ErrorIsHard = false + return nil + } + + a.providerAddForm.Submitting = true + a.providerAddForm.Error = "" + a.providerAddForm.ErrorIsHard = false + a.state.StatusText = "Adding provider..." + a.appendActivity("provider", "Adding provider", request.Name, false) + + return a.runProviderAddFlow(request) +} + +type providerAddRequest struct { + Name string + Driver string + BaseURL string + APIStyle string + DeploymentMode string + APIVersion string + APIKey string +} + +type providerAddResultMsg struct { + Name string + Model string + Error string +} + +func buildProviderAddRequest(form providerAddFormState) (providerAddRequest, string) { + request := providerAddRequest{ + Name: strings.TrimSpace(form.Name), + Driver: provider.NormalizeProviderDriver(form.Driver), + BaseURL: strings.TrimSpace(form.BaseURL), + APIStyle: strings.TrimSpace(form.APIStyle), + DeploymentMode: strings.TrimSpace(form.DeploymentMode), + APIVersion: strings.TrimSpace(form.APIVersion), + APIKey: strings.TrimSpace(form.APIKey), + } + + if request.Name == "" { + return providerAddRequest{}, "Name is required" + } + if request.Driver == "" { + return providerAddRequest{}, "Driver is required" + } + if request.APIKey == "" { + return providerAddRequest{}, "API Key is required" + } + + switch request.Driver { + case provider.DriverOpenAICompat: + if request.BaseURL == "" { + request.BaseURL = config.OpenAIDefaultBaseURL + } + if request.APIStyle == "" { + request.APIStyle = provider.OpenAICompatibleAPIStyleChatCompletions + } + request.DeploymentMode = "" + request.APIVersion = "" + case provider.DriverGemini: + if request.BaseURL == "" { + request.BaseURL = config.GeminiDefaultBaseURL + } + request.APIStyle = "" + request.APIVersion = "" + case provider.DriverAnthropic: + if request.BaseURL == "" { + return providerAddRequest{}, "Base URL is required for anthropic provider" + } + request.APIStyle = "" + request.DeploymentMode = "" + default: + if request.BaseURL == "" { + return providerAddRequest{}, "Base URL is required for custom driver" + } + request.APIStyle = "" + request.DeploymentMode = "" + request.APIVersion = "" + } + + return request, "" +} + +func providerAddAPIKeyEnv(name string) string { + upper := strings.ToUpper(strings.TrimSpace(name)) + if upper == "" { + return "CUSTOM_PROVIDER_API_KEY" + } + + var b strings.Builder + lastUnderscore := false + for _, r := range upper { + if (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') { + b.WriteRune(r) + lastUnderscore = false + continue + } + if !lastUnderscore { + b.WriteByte('_') + lastUnderscore = true + } + } + + normalized := strings.Trim(b.String(), "_") + if normalized == "" { + normalized = "CUSTOM_PROVIDER" + } + if normalized[0] >= '0' && normalized[0] <= '9' { + normalized = "P_" + normalized + } + return normalized + "_API_KEY" +} + +// trimLastRune 按 UTF-8 rune 删除字符串末尾一个字符,避免按字节截断导致乱码。 +func trimLastRune(value string) string { + if value == "" { + return "" + } + _, size := utf8.DecodeLastRuneInString(value) + if size <= 0 || size > len(value) { + return "" + } + return value[:len(value)-size] +} + +func sanitizeProviderAddError(err error, secrets ...string) string { + if err == nil { + return "" + } + text := strings.TrimSpace(err.Error()) + if text == "" { + return "unknown error" + } + + for _, secret := range secrets { + if trimmed := strings.TrimSpace(secret); trimmed != "" { + text = strings.ReplaceAll(text, trimmed, "[REDACTED]") + text = strings.ReplaceAll(text, filepath.ToSlash(trimmed), "[REDACTED]") + } + } + return text +} + +type fileSnapshot struct { + Exists bool + Content []byte +} + +// loadFileSnapshot 读取目标文件当前快照,用于 provider add 失败时恢复原始状态。 +func loadFileSnapshot(path string) (fileSnapshot, error) { + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return fileSnapshot{}, nil + } + return fileSnapshot{}, err + } + return fileSnapshot{ + Exists: true, + Content: append([]byte(nil), data...), + }, nil +} + +// restoreEnvFileSnapshot 将 .env 恢复到提交前快照,避免覆盖场景下丢失原有键值。 +func restoreEnvFileSnapshot(path string, snapshot fileSnapshot) error { + if !snapshot.Exists { + if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + return err + } + return nil + } + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + return os.WriteFile(path, snapshot.Content, 0o600) +} + +// restoreProviderConfigSnapshot 恢复 provider.yaml 快照;若原先不存在则清理新建目录。 +func restoreProviderConfigSnapshot(baseDir string, providerName string, snapshot fileSnapshot) error { + providerDir := filepath.Join(baseDir, "providers", providerName) + if !snapshot.Exists { + return config.DeleteCustomProvider(baseDir, providerName) + } + if err := os.RemoveAll(providerDir); err != nil { + return err + } + if err := os.MkdirAll(providerDir, 0o755); err != nil { + return err + } + providerPath := filepath.Join(providerDir, "provider.yaml") + return os.WriteFile(providerPath, snapshot.Content, 0o644) +} + +func (a *App) runProviderAddFlow(request providerAddRequest) tea.Cmd { + baseDir := a.configManager.BaseDir() + configManager := a.configManager + providerSvc := a.providerSvc + + return func() tea.Msg { + apiKeyEnv := providerAddAPIKeyEnv(request.Name) + previousProcessEnvValue, hadPreviousProcessEnv := os.LookupEnv(apiKeyEnv) + previousUserEnvValue, hadPreviousUserEnv, err := lookupProviderUserEnvVar(apiKeyEnv) + if err != nil { + return providerAddResultMsg{ + Name: request.Name, + Error: sanitizeProviderAddError(fmt.Errorf("lookup user environment variable: %w", err), request.APIKey, baseDir), + } + } + + providerPath := filepath.Join(baseDir, "providers", request.Name, "provider.yaml") + providerSnapshot, err := loadFileSnapshot(providerPath) + if err != nil { + return providerAddResultMsg{ + Name: request.Name, + Error: sanitizeProviderAddError(fmt.Errorf("snapshot provider config: %w", err), request.APIKey, baseDir), + } + } + + envPath := config.EnvFilePath(baseDir) + envSnapshot, err := loadFileSnapshot(envPath) + if err != nil { + return providerAddResultMsg{ + Name: request.Name, + Error: sanitizeProviderAddError(fmt.Errorf("snapshot env file: %w", err), request.APIKey, baseDir), + } + } + + rollback := func(processEnvApplied bool, userEnvPersisted bool, envPersisted bool, providerSaved bool, originalErr error) error { + return rollbackProviderAddSideEffects( + baseDir, + request.Name, + apiKeyEnv, + processEnvApplied, + userEnvPersisted, + envPersisted, + hadPreviousProcessEnv, + previousProcessEnvValue, + hadPreviousUserEnv, + previousUserEnvValue, + providerSaved, + envPath, + envSnapshot, + providerSnapshot, + originalErr, + ) + } + + providerSaved := false + envPersisted := false + userEnvPersisted := false + processEnvApplied := false + + if err := config.SaveCustomProvider( + baseDir, + request.Name, + request.Driver, + request.BaseURL, + apiKeyEnv, + request.APIStyle, + request.DeploymentMode, + request.APIVersion, + ); err != nil { + return providerAddResultMsg{ + Name: request.Name, + Error: sanitizeProviderAddError(fmt.Errorf("save provider config: %w", err), request.APIKey, baseDir), + } + } + providerSaved = true + if err := config.PersistEnvVar(baseDir, apiKeyEnv, request.APIKey); err != nil { + err = rollback(processEnvApplied, userEnvPersisted, envPersisted, providerSaved, err) + return providerAddResultMsg{ + Name: request.Name, + Error: sanitizeProviderAddError(fmt.Errorf("persist api key: %w", err), request.APIKey, baseDir), + } + } + envPersisted = true + if err := persistProviderUserEnvVar(apiKeyEnv, request.APIKey); err != nil { + err = rollback(processEnvApplied, userEnvPersisted, envPersisted, providerSaved, err) + return providerAddResultMsg{ + Name: request.Name, + Error: sanitizeProviderAddError( + fmt.Errorf("persist user environment variable: %w", err), + request.APIKey, + baseDir, + ), + } + } + userEnvPersisted = true + if err := os.Setenv(apiKeyEnv, request.APIKey); err != nil { + err = rollback(processEnvApplied, userEnvPersisted, envPersisted, providerSaved, err) + return providerAddResultMsg{ + Name: request.Name, + Error: sanitizeProviderAddError(fmt.Errorf("apply api key env: %w", err), request.APIKey, baseDir), + } + } + processEnvApplied = true + if _, err := configManager.Reload(context.Background()); err != nil { + err = rollback(processEnvApplied, userEnvPersisted, envPersisted, providerSaved, err) + return providerAddResultMsg{ + Name: request.Name, + Error: sanitizeProviderAddError(fmt.Errorf("reload config snapshot: %w", err), request.APIKey, baseDir), + } + } + + ctx, cancel := context.WithTimeout(context.Background(), providerAddSelectTimeout) + defer cancel() + + selection, err := providerSvc.SelectProvider(ctx, request.Name) + if err != nil { + err = rollback(processEnvApplied, userEnvPersisted, envPersisted, providerSaved, err) + if errors.Is(err, context.DeadlineExceeded) { + err = fmt.Errorf( + "model discovery timed out after %s; check base URL, API key, and network connectivity", + providerAddSelectTimeout, + ) + } + return providerAddResultMsg{ + Name: request.Name, + Error: sanitizeProviderAddError(fmt.Errorf("select provider: %w", err), request.APIKey, baseDir), + } + } + + return providerAddResultMsg{ + Name: request.Name, + Model: strings.TrimSpace(selection.ModelID), + } + } +} + +// rollbackProviderAddSideEffects 回滚 provider add 过程中已落地的副作用,避免失败后残留配置与密钥。 +func rollbackProviderAddSideEffects( + baseDir string, + providerName string, + apiKeyEnv string, + processEnvApplied bool, + userEnvPersisted bool, + envPersisted bool, + hadPreviousEnv bool, + previousEnvValue string, + hadPreviousUserEnv bool, + previousUserEnvValue string, + providerSaved bool, + envPath string, + envSnapshot fileSnapshot, + providerSnapshot fileSnapshot, + originalErr error, +) error { + rollbackErrs := make([]error, 0, 4) + + if processEnvApplied { + if hadPreviousEnv { + if err := os.Setenv(apiKeyEnv, previousEnvValue); err != nil { + rollbackErrs = append(rollbackErrs, fmt.Errorf("restore process env: %w", err)) + } + } else { + if err := os.Unsetenv(apiKeyEnv); err != nil { + rollbackErrs = append(rollbackErrs, fmt.Errorf("unset process env: %w", err)) + } + } + } + + if userEnvPersisted { + if hadPreviousUserEnv { + if err := persistProviderUserEnvVar(apiKeyEnv, previousUserEnvValue); err != nil { + rollbackErrs = append(rollbackErrs, fmt.Errorf("restore user env: %w", err)) + } + } else { + if err := deleteProviderUserEnvVar(apiKeyEnv); err != nil { + rollbackErrs = append(rollbackErrs, fmt.Errorf("delete user env: %w", err)) + } + } + } + + if envPersisted { + if err := restoreEnvFileSnapshot(envPath, envSnapshot); err != nil { + rollbackErrs = append(rollbackErrs, fmt.Errorf("restore persisted env: %w", err)) + } + } + + if providerSaved { + if err := restoreProviderConfigSnapshot(baseDir, providerName, providerSnapshot); err != nil { + rollbackErrs = append(rollbackErrs, fmt.Errorf("restore provider config: %w", err)) + } + } + + if len(rollbackErrs) == 0 { + return originalErr + } + return fmt.Errorf("%w (rollback failed: %v)", originalErr, errors.Join(rollbackErrs...)) +} + +func (a *App) handleProviderAddResultMsg(msg providerAddResultMsg) { + if a.providerAddForm == nil { + return + } + + if msg.Error != "" { + a.providerAddForm.Error = msg.Error + a.providerAddForm.ErrorIsHard = true + a.providerAddForm.Submitting = false + a.state.ExecutionError = msg.Error + a.state.StatusText = "Failed to add provider" + a.appendActivity("provider", "Failed to add provider", msg.Error, true) + return + } + + a.providerAddForm = nil + a.state.ActivePicker = pickerNone + a.state.ExecutionError = "" + a.state.StatusText = "Provider added: " + msg.Name + a.state.CurrentProvider = msg.Name + if msg.Model != "" { + a.state.CurrentModel = msg.Model + } + a.appendActivity("provider", "Provider added", msg.Name, false) + + if err := a.refreshProviderPicker(); err != nil { + a.appendActivity("system", "Failed to refresh providers", err.Error(), true) + } + if err := a.refreshModelPicker(); err != nil { + a.appendActivity("system", "Failed to refresh models", err.Error(), true) + } +} diff --git a/internal/tui/core/app/update_test.go b/internal/tui/core/app/update_test.go index c6daa2dd..d4df5983 100644 --- a/internal/tui/core/app/update_test.go +++ b/internal/tui/core/app/update_test.go @@ -3,16 +3,19 @@ package tui import ( "context" "errors" + "os" "path/filepath" "strings" "testing" "time" + "github.com/charmbracelet/bubbles/list" tea "github.com/charmbracelet/bubbletea" "neo-code/internal/config" configstate "neo-code/internal/config/state" "neo-code/internal/memo" + "neo-code/internal/provider" providertypes "neo-code/internal/provider/types" agentruntime "neo-code/internal/runtime" approvalflow "neo-code/internal/runtime/approval" @@ -24,8 +27,11 @@ import ( ) type stubProviderService struct { - providers []configstate.ProviderOption - models []providertypes.ModelDescriptor + providers []configstate.ProviderOption + models []providertypes.ModelDescriptor + selectErr error + selectDelay time.Duration + selectResponse configstate.Selection } func (s stubProviderService) ListProviderOptions(ctx context.Context) ([]configstate.ProviderOption, error) { @@ -33,6 +39,22 @@ func (s stubProviderService) ListProviderOptions(ctx context.Context) ([]configs } func (s stubProviderService) SelectProvider(ctx context.Context, providerID string) (configstate.Selection, error) { + if s.selectDelay > 0 { + timer := time.NewTimer(s.selectDelay) + defer timer.Stop() + select { + case <-ctx.Done(): + return configstate.Selection{}, ctx.Err() + case <-timer.C: + } + } + if s.selectErr != nil { + return configstate.Selection{}, s.selectErr + } + if strings.TrimSpace(s.selectResponse.ProviderID) != "" || strings.TrimSpace(s.selectResponse.ModelID) != "" { + return s.selectResponse, nil + } + modelID := "" if len(s.models) > 0 { modelID = s.models[0].ID @@ -68,6 +90,13 @@ type stubRuntime struct { loadSessionErr error } +type snapshotRuntime struct { + *stubRuntime + sessionContext any + sessionUsage any + runSnapshot any +} + func newStubRuntime() *stubRuntime { return &stubRuntime{events: make(chan agentruntime.RuntimeEvent)} } @@ -130,6 +159,18 @@ func (s *stubRuntime) SetSessionWorkdir(ctx context.Context, sessionID string, w return agentsession.NewWithWorkdir("draft", workdir), nil } +func (s *snapshotRuntime) GetSessionContext(ctx context.Context, sessionID string) (any, error) { + return s.sessionContext, nil +} + +func (s *snapshotRuntime) GetSessionUsage(ctx context.Context, sessionID string) (any, error) { + return s.sessionUsage, nil +} + +func (s *snapshotRuntime) GetRunSnapshot(ctx context.Context, runID string) (any, error) { + return s.runSnapshot, nil +} + func newDefaultAppConfig() *config.Config { cfg := config.StaticDefaults() cfg.Providers = config.DefaultProviders() @@ -140,7 +181,7 @@ func newDefaultAppConfig() *config.Config { return cfg } -func newTestApp(t *testing.T) (App, *stubRuntime) { +func newTestAppWithProviderService(t *testing.T, providerSvc ProviderController) (App, *stubRuntime) { t.Helper() cfg := newDefaultAppConfig() @@ -151,34 +192,481 @@ func newTestApp(t *testing.T) (App, *stubRuntime) { t.Fatalf("Load() error = %v", err) } + runtime := newStubRuntime() + app, err := newApp(tuibootstrap.Container{ + Config: *cfg, + ConfigManager: manager, + Runtime: runtime, + ProviderService: providerSvc, + }) + if err != nil { + t.Fatalf("newApp() error = %v", err) + } + + return app, runtime +} + +func newTestApp(t *testing.T) (App, *stubRuntime) { + t.Helper() + + cfg := newDefaultAppConfig() var providers []configstate.ProviderOption var models []providertypes.ModelDescriptor if len(cfg.Providers) > 0 { - provider := cfg.Providers[0] + providerCfg := cfg.Providers[0] providers = []configstate.ProviderOption{ { - ID: provider.Name, - Name: provider.Name, + ID: providerCfg.Name, + Name: providerCfg.Name, Models: []providertypes.ModelDescriptor{ - {ID: provider.Model, Name: provider.Model}, + {ID: providerCfg.Model, Name: providerCfg.Model}, }, }, } - models = []providertypes.ModelDescriptor{{ID: provider.Model, Name: provider.Model}} + models = []providertypes.ModelDescriptor{{ID: providerCfg.Model, Name: providerCfg.Model}} } - runtime := newStubRuntime() - app, err := newApp(tuibootstrap.Container{ - Config: *cfg, - ConfigManager: manager, - Runtime: runtime, - ProviderService: stubProviderService{providers: providers, models: models}, + return newTestAppWithProviderService(t, stubProviderService{providers: providers, models: models}) +} + +func TestSubmitProviderAddFormRequiresAnthropicBaseURL(t *testing.T) { + app, _ := newTestApp(t) + app.startProviderAddForm() + + app.providerAddForm.Name = "anthropic-gateway" + app.providerAddForm.Driver = provider.DriverAnthropic + app.providerAddForm.APIKey = "test-key" + app.providerAddForm.BaseURL = "" + + cmd := app.submitProviderAddForm() + if cmd != nil { + t.Fatalf("expected nil command for invalid anthropic form") + } + if !strings.Contains(app.providerAddForm.Error, "Base URL is required") { + t.Fatalf("expected base URL validation error, got %q", app.providerAddForm.Error) + } +} + +func TestSubmitProviderAddFormAsyncSuccess(t *testing.T) { + restorePersistUserEnv := persistProviderUserEnvVar + persistProviderUserEnvVar = func(key string, value string) error { return nil } + t.Cleanup(func() { persistProviderUserEnvVar = restorePersistUserEnv }) + + providerName := "team-gateway" + modelID := "gateway-model" + service := stubProviderService{ + providers: []configstate.ProviderOption{ + { + ID: providerName, + Name: providerName, + Models: []providertypes.ModelDescriptor{ + {ID: modelID, Name: modelID}, + }, + }, + }, + models: []providertypes.ModelDescriptor{{ID: modelID, Name: modelID}}, + selectResponse: configstate.Selection{ + ProviderID: providerName, + ModelID: modelID, + }, + } + app, _ := newTestAppWithProviderService(t, service) + app.startProviderAddForm() + + app.providerAddForm.Name = providerName + app.providerAddForm.Driver = provider.DriverOpenAICompat + app.providerAddForm.BaseURL = "https://team-gateway.example.com/v1" + app.providerAddForm.APIKey = "sk-test-123" + app.providerAddForm.APIStyle = provider.OpenAICompatibleAPIStyleChatCompletions + + cmd := app.submitProviderAddForm() + if cmd == nil { + t.Fatalf("expected async command for provider add") + } + if !app.providerAddForm.Submitting { + t.Fatalf("expected form to enter submitting state") + } + + envName := providerAddAPIKeyEnv(providerName) + defer os.Unsetenv(envName) + + msg := cmd() + if _, ok := msg.(providerAddResultMsg); !ok { + t.Fatalf("expected providerAddResultMsg, got %T", msg) + } + if got := strings.TrimSpace(os.Getenv(envName)); got != "sk-test-123" { + t.Fatalf("expected API key in env %s, got %q", envName, got) + } + envData, readErr := os.ReadFile(config.EnvFilePath(app.configManager.BaseDir())) + if readErr != nil { + t.Fatalf("expected persisted env file, read error = %v", readErr) + } + if !strings.Contains(string(envData), envName+"=sk-test-123") { + t.Fatalf("expected env file to persist API key, got %q", string(envData)) + } + + next, _ := app.Update(msg) + app = next.(App) + if app.providerAddForm != nil { + t.Fatalf("expected provider add form to close on success") + } + if app.state.CurrentProvider != providerName { + t.Fatalf("expected current provider %q, got %q", providerName, app.state.CurrentProvider) + } + if app.state.CurrentModel != modelID { + t.Fatalf("expected current model %q, got %q", modelID, app.state.CurrentModel) + } +} + +func TestSubmitProviderAddFormRedactsSensitiveError(t *testing.T) { + restorePersistUserEnv := persistProviderUserEnvVar + persistProviderUserEnvVar = func(key string, value string) error { return nil } + t.Cleanup(func() { persistProviderUserEnvVar = restorePersistUserEnv }) + + secretKey := "sk-secret-456" + service := stubProviderService{ + selectErr: errors.New("authentication failed for key " + secretKey), + } + app, _ := newTestAppWithProviderService(t, service) + app.startProviderAddForm() + + app.providerAddForm.Name = "redact-gateway" + app.providerAddForm.Driver = provider.DriverOpenAICompat + app.providerAddForm.BaseURL = "https://redact-gateway.example.com/v1" + app.providerAddForm.APIKey = secretKey + + cmd := app.submitProviderAddForm() + if cmd == nil { + t.Fatalf("expected async command for provider add failure") + } + next, _ := app.Update(cmd()) + app = next.(App) + if app.providerAddForm == nil { + t.Fatalf("expected form to stay open on failure") + } + if strings.Contains(app.providerAddForm.Error, secretKey) { + t.Fatalf("expected error to redact api key, got %q", app.providerAddForm.Error) + } + if !strings.Contains(app.providerAddForm.Error, "[REDACTED]") { + t.Fatalf("expected redaction marker in error, got %q", app.providerAddForm.Error) + } +} + +func TestSubmitProviderAddFormRollsBackPersistedStateOnSelectFailure(t *testing.T) { + restorePersistUserEnv := persistProviderUserEnvVar + restoreDeleteUserEnv := deleteProviderUserEnvVar + persistProviderUserEnvVar = func(key string, value string) error { return nil } + deleteProviderUserEnvVar = func(key string) error { return nil } + t.Cleanup(func() { persistProviderUserEnvVar = restorePersistUserEnv }) + t.Cleanup(func() { deleteProviderUserEnvVar = restoreDeleteUserEnv }) + + providerName := "rollback-gateway" + envName := providerAddAPIKeyEnv(providerName) + restoreEnv := captureEnv(t, envName) + defer restoreEnv() + if err := os.Setenv(envName, "previous-value"); err != nil { + t.Fatalf("Setenv() error = %v", err) + } + + service := stubProviderService{ + selectErr: errors.New("select failed"), + } + app, _ := newTestAppWithProviderService(t, service) + app.startProviderAddForm() + app.providerAddForm.Name = providerName + app.providerAddForm.Driver = provider.DriverOpenAICompat + app.providerAddForm.BaseURL = "https://rollback.example.com/v1" + app.providerAddForm.APIKey = "sk-failed-rollback" + app.providerAddForm.APIStyle = provider.OpenAICompatibleAPIStyleChatCompletions + + cmd := app.submitProviderAddForm() + if cmd == nil { + t.Fatalf("expected async command") + } + msg := cmd() + result, ok := msg.(providerAddResultMsg) + if !ok { + t.Fatalf("expected providerAddResultMsg, got %T", msg) + } + if strings.TrimSpace(result.Error) == "" { + t.Fatalf("expected failure result") + } + + if got := os.Getenv(envName); got != "previous-value" { + t.Fatalf("expected process env restored, got %q", got) + } + + envData, readErr := os.ReadFile(config.EnvFilePath(app.configManager.BaseDir())) + if readErr != nil && !os.IsNotExist(readErr) { + t.Fatalf("read env file: %v", readErr) + } + if strings.Contains(string(envData), envName+"=") { + t.Fatalf("expected persisted env key rollback, got %q", string(envData)) + } + + providerPath := filepath.Join(app.configManager.BaseDir(), "providers", providerName) + if _, err := os.Stat(providerPath); !os.IsNotExist(err) { + t.Fatalf("expected provider dir rollback, stat err = %v", err) + } +} + +func TestSubmitProviderAddFormRollsBackOnUserEnvPersistFailure(t *testing.T) { + restorePersistUserEnv := persistProviderUserEnvVar + restoreDeleteUserEnv := deleteProviderUserEnvVar + persistProviderUserEnvVar = func(key string, value string) error { return errors.New("user env failed") } + deleteProviderUserEnvVar = func(key string) error { return nil } + t.Cleanup(func() { persistProviderUserEnvVar = restorePersistUserEnv }) + t.Cleanup(func() { deleteProviderUserEnvVar = restoreDeleteUserEnv }) + + providerName := "user-env-fail" + envName := providerAddAPIKeyEnv(providerName) + restoreEnv := captureEnv(t, envName) + defer restoreEnv() + _ = os.Unsetenv(envName) + + app, _ := newTestApp(t) + app.startProviderAddForm() + app.providerAddForm.Name = providerName + app.providerAddForm.Driver = provider.DriverOpenAICompat + app.providerAddForm.BaseURL = "https://rollback.example.com/v1" + app.providerAddForm.APIKey = "sk-failed" + app.providerAddForm.APIStyle = provider.OpenAICompatibleAPIStyleChatCompletions + + cmd := app.submitProviderAddForm() + if cmd == nil { + t.Fatalf("expected async command") + } + msg := cmd() + result, ok := msg.(providerAddResultMsg) + if !ok { + t.Fatalf("expected providerAddResultMsg, got %T", msg) + } + if strings.TrimSpace(result.Error) == "" { + t.Fatalf("expected failure result") + } + + if got := os.Getenv(envName); got != "" { + t.Fatalf("expected process env to stay unset, got %q", got) + } + + envData, readErr := os.ReadFile(config.EnvFilePath(app.configManager.BaseDir())) + if readErr != nil && !os.IsNotExist(readErr) { + t.Fatalf("read env file: %v", readErr) + } + if strings.Contains(string(envData), envName+"=") { + t.Fatalf("expected persisted env key rollback, got %q", string(envData)) + } + + providerPath := filepath.Join(app.configManager.BaseDir(), "providers", providerName) + if _, err := os.Stat(providerPath); !os.IsNotExist(err) { + t.Fatalf("expected provider dir rollback, stat err = %v", err) + } +} + +func TestSubmitProviderAddFormRestoresExistingStateOnSelectFailure(t *testing.T) { + restorePersistUserEnv := persistProviderUserEnvVar + restoreDeleteUserEnv := deleteProviderUserEnvVar + restoreLookupUserEnv := lookupProviderUserEnvVar + userEnvStore := map[string]string{} + persistProviderUserEnvVar = func(key string, value string) error { + userEnvStore[key] = value + return nil + } + deleteProviderUserEnvVar = func(key string) error { + delete(userEnvStore, key) + return nil + } + lookupProviderUserEnvVar = func(key string) (string, bool, error) { + value, ok := userEnvStore[key] + return value, ok, nil + } + t.Cleanup(func() { persistProviderUserEnvVar = restorePersistUserEnv }) + t.Cleanup(func() { deleteProviderUserEnvVar = restoreDeleteUserEnv }) + t.Cleanup(func() { lookupProviderUserEnvVar = restoreLookupUserEnv }) + + providerName := "restore-existing-provider" + envName := providerAddAPIKeyEnv(providerName) + restoreEnv := captureEnv(t, envName) + defer restoreEnv() + + app, _ := newTestAppWithProviderService(t, stubProviderService{ + selectErr: errors.New("select failed"), }) + + if err := config.SaveCustomProvider( + app.configManager.BaseDir(), + providerName, + provider.DriverOpenAICompat, + "https://old.example.com/v1", + envName, + provider.OpenAICompatibleAPIStyleChatCompletions, + "", + "", + ); err != nil { + t.Fatalf("SaveCustomProvider() error = %v", err) + } + if err := config.PersistEnvVar(app.configManager.BaseDir(), envName, "old-file-key"); err != nil { + t.Fatalf("PersistEnvVar() error = %v", err) + } + if err := os.Setenv(envName, "old-process-key"); err != nil { + t.Fatalf("Setenv() error = %v", err) + } + userEnvStore[envName] = "old-user-key" + + app.startProviderAddForm() + app.providerAddForm.Name = providerName + app.providerAddForm.Driver = provider.DriverOpenAICompat + app.providerAddForm.BaseURL = "https://new.example.com/v1" + app.providerAddForm.APIKey = "new-key" + app.providerAddForm.APIStyle = provider.OpenAICompatibleAPIStyleChatCompletions + + cmd := app.submitProviderAddForm() + if cmd == nil { + t.Fatalf("expected async command") + } + msg := cmd() + result, ok := msg.(providerAddResultMsg) + if !ok { + t.Fatalf("expected providerAddResultMsg, got %T", msg) + } + if strings.TrimSpace(result.Error) == "" { + t.Fatalf("expected failure result") + } + + if got := os.Getenv(envName); got != "old-process-key" { + t.Fatalf("expected process env restored, got %q", got) + } + if got := userEnvStore[envName]; got != "old-user-key" { + t.Fatalf("expected user env restored, got %q", got) + } + + envData, readErr := os.ReadFile(config.EnvFilePath(app.configManager.BaseDir())) + if readErr != nil { + t.Fatalf("read env file: %v", readErr) + } + if !strings.Contains(string(envData), envName+"=old-file-key") { + t.Fatalf("expected persisted env restored, got %q", string(envData)) + } + + providerPath := filepath.Join(app.configManager.BaseDir(), "providers", providerName, "provider.yaml") + providerData, readProviderErr := os.ReadFile(providerPath) + if readProviderErr != nil { + t.Fatalf("read provider config: %v", readProviderErr) + } + providerText := string(providerData) + if !strings.Contains(providerText, "https://old.example.com/v1") { + t.Fatalf("expected provider config restored, got %q", providerText) + } + if strings.Contains(providerText, "https://new.example.com/v1") { + t.Fatalf("expected new provider config to be rolled back, got %q", providerText) + } +} + +func TestTrimLastRune(t *testing.T) { + if got := trimLastRune(""); got != "" { + t.Fatalf("trimLastRune(empty) = %q, want empty", got) + } + if got := trimLastRune("ab"); got != "a" { + t.Fatalf("trimLastRune(ascii) = %q, want a", got) + } + if got := trimLastRune("你好"); got != "你" { + t.Fatalf("trimLastRune(utf8) = %q, want 你", got) + } +} + +func TestLoadAndRestoreEnvFileSnapshot(t *testing.T) { + tmp := t.TempDir() + envPath := filepath.Join(tmp, ".env") + + missingSnapshot, err := loadFileSnapshot(envPath) if err != nil { - t.Fatalf("newApp() error = %v", err) + t.Fatalf("loadFileSnapshot(missing) error = %v", err) + } + if missingSnapshot.Exists { + t.Fatalf("expected missing snapshot") } - return app, runtime + if err := os.WriteFile(envPath, []byte("A=1\n"), 0o600); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + existingSnapshot, err := loadFileSnapshot(envPath) + if err != nil { + t.Fatalf("loadFileSnapshot(existing) error = %v", err) + } + if !existingSnapshot.Exists { + t.Fatalf("expected existing snapshot") + } + + if err := os.WriteFile(envPath, []byte("A=2\n"), 0o600); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + if err := restoreEnvFileSnapshot(envPath, existingSnapshot); err != nil { + t.Fatalf("restoreEnvFileSnapshot(existing) error = %v", err) + } + data, err := os.ReadFile(envPath) + if err != nil { + t.Fatalf("ReadFile() error = %v", err) + } + if string(data) != "A=1\n" { + t.Fatalf("expected snapshot content restored, got %q", string(data)) + } + + if err := restoreEnvFileSnapshot(envPath, missingSnapshot); err != nil { + t.Fatalf("restoreEnvFileSnapshot(missing) error = %v", err) + } + if _, err := os.Stat(envPath); !os.IsNotExist(err) { + t.Fatalf("expected env file removed for missing snapshot, stat err = %v", err) + } +} + +func TestRestoreProviderConfigSnapshot(t *testing.T) { + baseDir := t.TempDir() + providerName := "snapshot-provider" + providerPath := filepath.Join(baseDir, "providers", providerName, "provider.yaml") + if err := os.MkdirAll(filepath.Dir(providerPath), 0o755); err != nil { + t.Fatalf("MkdirAll() error = %v", err) + } + if err := os.WriteFile(providerPath, []byte("name: old\n"), 0o644); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + + snapshot, err := loadFileSnapshot(providerPath) + if err != nil { + t.Fatalf("loadFileSnapshot() error = %v", err) + } + if err := os.WriteFile(providerPath, []byte("name: new\n"), 0o644); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + if err := restoreProviderConfigSnapshot(baseDir, providerName, snapshot); err != nil { + t.Fatalf("restoreProviderConfigSnapshot(existing) error = %v", err) + } + data, err := os.ReadFile(providerPath) + if err != nil { + t.Fatalf("ReadFile() error = %v", err) + } + if string(data) != "name: old\n" { + t.Fatalf("expected provider snapshot restored, got %q", string(data)) + } + + missingSnapshot := fileSnapshot{} + if err := restoreProviderConfigSnapshot(baseDir, providerName, missingSnapshot); err != nil { + t.Fatalf("restoreProviderConfigSnapshot(missing) error = %v", err) + } + if _, err := os.Stat(filepath.Dir(providerPath)); !os.IsNotExist(err) { + t.Fatalf("expected provider dir removed for missing snapshot, stat err = %v", err) + } +} + +func captureEnv(t *testing.T, key string) func() { + t.Helper() + value, exists := os.LookupEnv(key) + return func() { + if exists { + _ = os.Setenv(key, value) + return + } + _ = os.Unsetenv(key) + } } func TestAppUpdateBasic(t *testing.T) { @@ -1799,3 +2287,654 @@ func TestHandleForgetCommand(t *testing.T) { } }) } + +func TestNoteInputEditTracksPasteHeuristics(t *testing.T) { + app, _ := newTestApp(t) + base := time.Now() + + app.noteInputEdit("a", "ab", tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("b")}, base) + if app.lastInputEditAt.IsZero() { + t.Fatalf("expected lastInputEditAt to be updated") + } + + app.noteInputEdit("ab", "ab\ncd", tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("x\ny")}, base.Add(time.Millisecond)) + if !app.pasteMode { + t.Fatalf("expected pasteMode to be enabled for multiline runes") + } + + app.noteInputEdit("ab\ncd", "ab\ncd", tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("z")}, base.Add(2*time.Millisecond)) + if app.inputBurstCount == 0 { + t.Fatalf("expected burst count to remain when text unchanged path is skipped") + } +} + +func TestActivateSelectedSession(t *testing.T) { + app, runtime := newTestApp(t) + runtime.loadSessions = map[string]agentsession.Session{ + "s-active": { + ID: "s-active", + Title: "Active Session", + Workdir: app.state.CurrentWorkdir, + Messages: []providertypes.Message{ + {Role: roleUser, Content: "hello"}, + }, + }, + } + + app.sessionPicker.SetItems([]list.Item{ + sessionItem{Summary: agentsession.Summary{ID: "s-active", Title: "Active Session"}}, + }) + app.sessionPicker.Select(0) + + if err := app.activateSelectedSession(); err != nil { + t.Fatalf("activateSelectedSession() error = %v", err) + } + if app.state.ActiveSessionID != "s-active" || app.state.ActiveSessionTitle != "Active Session" { + t.Fatalf("expected selected session to become active") + } + if len(app.activeMessages) != 1 { + t.Fatalf("expected active messages to be refreshed from session") + } +} + +func TestMouseHandlersAndBounds(t *testing.T) { + app, _ := newTestApp(t) + app.width = 120 + app.height = 40 + app.activities = []tuistate.ActivityEntry{{Kind: "test", Title: "activity"}} + app.transcript.SetContent(strings.Repeat("line\n", 100)) + app.applyComponentLayout(true) + + tx, ty, _, _ := app.transcriptBounds() + if !app.isMouseWithinTranscript(tea.MouseMsg{X: tx, Y: ty}) { + t.Fatalf("expected transcript bounds hit") + } + if app.isMouseWithinTranscript(tea.MouseMsg{X: tx - 1, Y: ty - 1}) { + t.Fatalf("expected transcript bounds miss") + } + + app.pendingCopyID = 9 + if app.handleTranscriptMouse(tea.MouseMsg{X: tx - 1, Y: ty - 1, Action: tea.MouseActionRelease}) { + t.Fatalf("expected outside transcript release to return false") + } + if app.pendingCopyID != 0 { + t.Fatalf("expected pending copy id to reset after release outside transcript") + } + if !app.handleTranscriptMouse(tea.MouseMsg{ + X: tx, Y: ty, Button: tea.MouseButtonWheelDown, Action: tea.MouseActionPress, + }) { + t.Fatalf("expected transcript wheel down to be handled") + } + + ix, iy, _, _ := app.inputBounds() + if !app.isMouseWithinInput(tea.MouseMsg{X: ix, Y: iy}) { + t.Fatalf("expected input bounds hit") + } + app.focus = panelTranscript + if !app.handleInputMouse(tea.MouseMsg{ + X: ix, Y: iy, Button: tea.MouseButtonWheelUp, Action: tea.MouseActionPress, + }) { + t.Fatalf("expected input wheel to be handled") + } + if app.focus != panelInput { + t.Fatalf("expected input panel to gain focus") + } + + ax, ay, _, _ := app.activityBounds() + if !app.isMouseWithinActivity(tea.MouseMsg{X: ax, Y: ay}) { + t.Fatalf("expected activity bounds hit") + } + app.focus = panelTranscript + if !app.handleActivityMouse(tea.MouseMsg{ + X: ax, Y: ay, Button: tea.MouseButtonWheelDown, Action: tea.MouseActionPress, + }) { + t.Fatalf("expected activity wheel to be handled") + } + if app.focus != panelActivity { + t.Fatalf("expected activity panel to gain focus") + } +} + +func TestComposerHelpers(t *testing.T) { + app, _ := newTestApp(t) + if got := app.composerBoxWidth(88); got != 88 { + t.Fatalf("composerBoxWidth() = %d, want 88", got) + } + + app.input.SetHeight(1) + app.input.SetValue("line1\nline2") + before := app.input.Height() + app.growComposerForNewline() + if app.input.Height() <= before { + t.Fatalf("expected growComposerForNewline to increase height") + } + + app.input.SetHeight(composerMaxHeight) + app.input.SetValue("line") + app.normalizeComposerHeight() + if app.input.Height() < composerMinHeight || app.input.Height() > composerMaxHeight { + t.Fatalf("normalizeComposerHeight should keep height in clamp range") + } +} + +func TestCurrentProviderAddFieldAndInputHandling(t *testing.T) { + app, _ := newTestApp(t) + if got := currentProviderAddField(nil); got != providerAddFieldName { + t.Fatalf("currentProviderAddField(nil) = %v, want name field", got) + } + + app.startProviderAddForm() + app.providerAddForm.Step = 999 + if got := currentProviderAddField(app.providerAddForm); got != providerAddFieldAPIKey { + t.Fatalf("expected out-of-range step to clamp to last visible field, got %v", got) + } + + app.providerAddForm.Step = 0 + model, cmd := app.handleProviderAddFormInput(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("a")}) + if cmd != nil { + t.Fatalf("expected nil cmd for rune input") + } + ptr, ok := model.(*App) + if !ok { + t.Fatalf("expected *App model, got %T", model) + } + app = *ptr + if app.providerAddForm.Name != "a" { + t.Fatalf("expected name field append, got %q", app.providerAddForm.Name) + } + + model, _ = app.handleProviderAddFormInput(tea.KeyMsg{Type: tea.KeyTab}) + ptr, ok = model.(*App) + if !ok { + t.Fatalf("expected *App model, got %T", model) + } + app = *ptr + if app.providerAddForm.Step == 0 { + t.Fatalf("expected tab to switch field") + } + + app.providerAddForm.Step = 1 // driver + driverBefore := app.providerAddForm.Driver + model, _ = app.handleProviderAddFormInput(tea.KeyMsg{Type: tea.KeyDown}) + ptr, ok = model.(*App) + if !ok { + t.Fatalf("expected *App model, got %T", model) + } + app = *ptr + if app.providerAddForm.Driver == driverBefore { + t.Fatalf("expected key down to switch driver") + } + + app.providerAddForm.Step = 0 + model, _ = app.handleProviderAddFormInput(tea.KeyMsg{Type: tea.KeyBackspace}) + ptr, ok = model.(*App) + if !ok { + t.Fatalf("expected *App model, got %T", model) + } + app = *ptr + if app.providerAddForm.Name != "" { + t.Fatalf("expected backspace to remove name content") + } + + app.providerAddForm.Name = "你好" + model, _ = app.handleProviderAddFormInput(tea.KeyMsg{Type: tea.KeyBackspace}) + ptr, ok = model.(*App) + if !ok { + t.Fatalf("expected *App model, got %T", model) + } + app = *ptr + if app.providerAddForm.Name != "你" { + t.Fatalf("expected UTF-8 safe backspace result, got %q", app.providerAddForm.Name) + } + + model, cmd = app.handleProviderAddFormInput(tea.KeyMsg{Type: tea.KeyEnter}) + if cmd != nil { + msg := cmd() + if _, ok := msg.(providerAddResultMsg); !ok { + t.Fatalf("expected providerAddResultMsg from submit cmd, got %T", msg) + } + } +} + +func TestHandleProviderAddFormInputSubmittingNoop(t *testing.T) { + app, _ := newTestApp(t) + app.startProviderAddForm() + app.providerAddForm.Submitting = true + + model, cmd := app.handleProviderAddFormInput(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("x")}) + if cmd != nil { + t.Fatalf("expected nil cmd while submitting") + } + ptr, ok := model.(*App) + if !ok { + t.Fatalf("expected *App model, got %T", model) + } + app = *ptr + if app.providerAddForm.Name != "" { + t.Fatalf("expected no mutation while submitting") + } +} + +func TestListenForRuntimeEvent(t *testing.T) { + eventCh := make(chan agentruntime.RuntimeEvent, 1) + event := agentruntime.RuntimeEvent{RunID: "run-listen"} + eventCh <- event + + cmd := ListenForRuntimeEvent(eventCh) + msg := cmd() + runtimeMsg, ok := msg.(RuntimeMsg) + if !ok { + t.Fatalf("expected RuntimeMsg, got %T", msg) + } + if runtimeMsg.Event.RunID != "run-listen" { + t.Fatalf("expected forwarded runtime event") + } + + close(eventCh) + cmd = ListenForRuntimeEvent(eventCh) + msg = cmd() + if _, ok := msg.(RuntimeClosedMsg); !ok { + t.Fatalf("expected RuntimeClosedMsg after channel close, got %T", msg) + } +} + +func TestBuildProviderAddRequest(t *testing.T) { + t.Run("validates required fields", func(t *testing.T) { + if _, err := buildProviderAddRequest(providerAddFormState{}); !strings.Contains(err, "Name is required") { + t.Fatalf("expected missing name error, got %q", err) + } + if _, err := buildProviderAddRequest(providerAddFormState{Name: "demo"}); !strings.Contains(err, "Driver is required") { + t.Fatalf("expected missing driver error, got %q", err) + } + if _, err := buildProviderAddRequest(providerAddFormState{Name: "demo", Driver: provider.DriverGemini}); !strings.Contains(err, "API Key is required") { + t.Fatalf("expected missing key error, got %q", err) + } + }) + + t.Run("openai compat applies defaults", func(t *testing.T) { + req, err := buildProviderAddRequest(providerAddFormState{ + Name: "openai-compat", + Driver: provider.DriverOpenAICompat, + APIKey: "k", + }) + if err != "" { + t.Fatalf("unexpected error: %s", err) + } + if req.BaseURL != config.OpenAIDefaultBaseURL { + t.Fatalf("expected openai default base url, got %q", req.BaseURL) + } + if req.APIStyle != provider.OpenAICompatibleAPIStyleChatCompletions { + t.Fatalf("expected default api style") + } + }) + + t.Run("gemini applies defaults and clears unrelated fields", func(t *testing.T) { + req, err := buildProviderAddRequest(providerAddFormState{ + Name: "gemini", + Driver: provider.DriverGemini, + APIKey: "k", + APIStyle: "x", + APIVersion: "v", + DeploymentMode: "d", + }) + if err != "" { + t.Fatalf("unexpected error: %s", err) + } + if req.BaseURL != config.GeminiDefaultBaseURL || req.APIStyle != "" || req.APIVersion != "" { + t.Fatalf("expected gemini normalization, got %+v", req) + } + }) + + t.Run("anthropic/custom require base url", func(t *testing.T) { + if _, err := buildProviderAddRequest(providerAddFormState{ + Name: "anthropic", + Driver: provider.DriverAnthropic, + APIKey: "k", + }); !strings.Contains(err, "Base URL is required") { + t.Fatalf("expected anthropic base url error, got %q", err) + } + + if _, err := buildProviderAddRequest(providerAddFormState{ + Name: "custom", + Driver: "custom-driver", + APIKey: "k", + BaseURL: "", + }); !strings.Contains(err, "Base URL is required for custom driver") { + t.Fatalf("expected custom base url error, got %q", err) + } + }) +} + +func TestRefreshRuntimeSourceSnapshot(t *testing.T) { + app, runtime := newTestApp(t) + snapshot := &snapshotRuntime{ + stubRuntime: runtime, + sessionContext: map[string]any{ + "SessionID": "sess-1", + "Provider": "provider-x", + "Model": "model-x", + "Workdir": "/tmp/work", + "Mode": "agent", + }, + sessionUsage: map[string]any{ + "InputTokens": 11, + "OutputTokens": 7, + "TotalTokens": 18, + }, + runSnapshot: map[string]any{ + "RunID": "run-9", + "SessionID": "sess-1", + "Context": map[string]any{ + "Provider": "provider-y", + "Model": "model-y", + "Workdir": "/tmp/run", + "Mode": "run", + }, + "ToolStates": []any{ + map[string]any{"ToolCallID": "tool-1", "ToolName": "bash", "Status": "running"}, + }, + "Usage": map[string]any{ + "InputTokens": 3, + "OutputTokens": 4, + "TotalTokens": 7, + }, + "SessionUsage": map[string]any{ + "InputTokens": 20, + "OutputTokens": 9, + "TotalTokens": 29, + }, + }, + } + app.runtime = snapshot + app.state.ActiveSessionID = "sess-1" + app.state.ActiveRunID = "run-9" + + app.refreshRuntimeSourceSnapshot() + + if app.state.RunContext.Provider != "provider-y" || app.state.RunContext.Model != "model-y" { + t.Fatalf("expected run snapshot context to override run context, got %+v", app.state.RunContext) + } + if len(app.state.ToolStates) != 1 || app.state.ToolStates[0].ToolCallID != "tool-1" { + t.Fatalf("expected tool states from run snapshot") + } + if app.state.TokenUsage.RunTotalTokens != 7 || app.state.TokenUsage.SessionTotalTokens != 29 { + t.Fatalf("expected usage values from run snapshot, got %+v", app.state.TokenUsage) + } +} + +func TestUpdatePickerProviderAndModelEnter(t *testing.T) { + app, _ := newTestApp(t) + + app.providerPicker.SetItems([]list.Item{ + selectionItem{id: "provider-a", name: "provider-a", description: "provider-a"}, + }) + app.openPicker(pickerProvider, statusChooseProvider, &app.providerPicker, "provider-a") + model, cmd := app.updatePicker(tea.KeyMsg{Type: tea.KeyEnter}) + if model == nil || cmd == nil { + t.Fatalf("expected provider enter to return command") + } + app = model.(App) + if app.state.ActivePicker != pickerNone { + t.Fatalf("expected picker to close after provider enter") + } + + app.modelPicker.SetItems([]list.Item{ + selectionItem{id: "model-a", name: "model-a", description: "model-a"}, + }) + app.openPicker(pickerModel, statusChooseModel, &app.modelPicker, "model-a") + model, cmd = app.updatePicker(tea.KeyMsg{Type: tea.KeyEnter}) + if model == nil || cmd == nil { + t.Fatalf("expected model enter to return command") + } + app = model.(App) + if app.state.ActivePicker != pickerNone { + t.Fatalf("expected picker to close after model enter") + } +} + +func TestUpdatePickerRoutesToProviderAddFormHandler(t *testing.T) { + app, _ := newTestApp(t) + app.startProviderAddForm() + app.state.ActivePicker = pickerProviderAdd + + model, cmd := app.updatePicker(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("n")}) + if cmd != nil { + t.Fatalf("expected nil cmd when editing provider add form") + } + ptr, ok := model.(*App) + if !ok { + t.Fatalf("expected *App model, got %T", model) + } + if ptr.providerAddForm == nil || ptr.providerAddForm.Name != "n" { + t.Fatalf("expected provider add form input to be routed") + } +} + +func TestUpdateModelCatalogRefreshBranches(t *testing.T) { + app, _ := newTestApp(t) + app.modelRefreshID = app.state.CurrentProvider + + model, cmd := app.Update(modelCatalogRefreshMsg{ + ProviderID: app.state.CurrentProvider, + Models: []providertypes.ModelDescriptor{ + {ID: "m-new", Name: "m-new"}, + }, + }) + if cmd != nil { + _ = cmd() + } + app = model.(App) + if app.modelRefreshID != "" { + t.Fatalf("expected refresh id to be cleared") + } + if len(app.modelPicker.Items()) == 0 { + t.Fatalf("expected model picker items to be replaced") + } + + prevActivities := len(app.activities) + model, _ = app.Update(modelCatalogRefreshMsg{ + ProviderID: app.state.CurrentProvider, + Err: errors.New("refresh failed"), + }) + app = model.(App) + if len(app.activities) <= prevActivities { + t.Fatalf("expected refresh error activity to be appended") + } + + prevActivities = len(app.activities) + model, _ = app.Update(modelCatalogRefreshMsg{ + ProviderID: "another-provider", + Err: errors.New("ignored"), + }) + app = model.(App) + if len(app.activities) != prevActivities { + t.Fatalf("expected mismatch provider refresh to be ignored") + } +} + +func TestUpdateLocalAndWorkspaceCommandResultBranches(t *testing.T) { + app, _ := newTestApp(t) + + model, _ := app.Update(localCommandResultMsg{Err: errors.New("local failed")}) + app = model.(App) + if app.state.StatusText != "local failed" { + t.Fatalf("expected local command error status, got %q", app.state.StatusText) + } + + model, _ = app.Update(localCommandResultMsg{Notice: "ok", ModelChanged: true}) + app = model.(App) + if app.state.StatusText != "ok" { + t.Fatalf("expected local command success notice") + } + + model, _ = app.Update(workspaceCommandResultMsg{Command: "", Err: errors.New("workspace failed")}) + app = model.(App) + if app.state.StatusText != "workspace failed" { + t.Fatalf("expected workspace empty command error status") + } + + model, _ = app.Update(workspaceCommandResultMsg{Command: "git status", Err: errors.New("boom")}) + app = model.(App) + if !strings.Contains(app.state.StatusText, "Command failed") { + t.Fatalf("expected workspace command failed status") + } + + model, _ = app.Update(workspaceCommandResultMsg{Command: "git status", Output: "clean"}) + app = model.(App) + if app.state.StatusText != statusCommandDone { + t.Fatalf("expected workspace success status, got %q", app.state.StatusText) + } +} + +func TestUpdateCompactFinishedAndRefreshMessagesError(t *testing.T) { + app, runtime := newTestApp(t) + app.state.ActiveSessionID = "session-error" + runtime.loadSessionErr = errors.New("load session failed") + + model, _ := app.Update(compactFinishedMsg{Err: errors.New("compact failed")}) + app = model.(App) + if app.state.IsCompacting { + t.Fatalf("expected compacting state to be cleared") + } + if app.state.ExecutionError != "load session failed" { + t.Fatalf("expected refresh message error to win, got %q", app.state.ExecutionError) + } + if len(app.activeMessages) == 0 || app.activeMessages[len(app.activeMessages)-1].Role != roleError { + t.Fatalf("expected inline error message appended") + } +} + +func TestUpdateLocalCommandProviderChangedRefreshErrors(t *testing.T) { + app, _ := newTestApp(t) + app.providerSvc = errorProviderService{err: errors.New("refresh providers failed")} + + model, _ := app.Update(localCommandResultMsg{ + Notice: "ok", + ProviderChanged: true, + }) + app = model.(App) + if app.state.ExecutionError != "refresh providers failed" { + t.Fatalf("expected provider refresh error, got %q", app.state.ExecutionError) + } + if len(app.activities) == 0 { + t.Fatalf("expected failure activity") + } +} + +func TestUpdateKeyToggleQuitCancelAndPickerClose(t *testing.T) { + app, runtime := newTestApp(t) + + model, _ := app.Update(tea.KeyMsg{Type: tea.KeyCtrlQ}) + app = model.(App) + if !app.state.ShowHelp { + t.Fatalf("expected help to toggle on") + } + + app.state.IsAgentRunning = true + model, _ = app.Update(tea.KeyMsg{Type: tea.KeyCtrlW}) + app = model.(App) + if !runtime.cancelInvoked { + t.Fatalf("expected cancel to be invoked") + } + if app.state.StatusText != statusCanceling { + t.Fatalf("expected canceling status, got %q", app.state.StatusText) + } + + app.openHelpPicker() + model, cmd := app.Update(tea.KeyMsg{Type: tea.KeyEsc}) + app = model.(App) + if cmd != nil { + t.Fatalf("expected nil cmd when closing active picker") + } + if app.state.ActivePicker != pickerNone { + t.Fatalf("expected picker to close on focus input key") + } + + model, cmd = app.Update(tea.KeyMsg{Type: tea.KeyCtrlU}) + if model == nil || cmd == nil { + t.Fatalf("expected quit command") + } +} + +func TestUpdatePickerEnterInvalidSelectionsAndSessionActivationError(t *testing.T) { + app, _ := newTestApp(t) + + app.providerPicker.SetItems([]list.Item{sessionItem{Summary: agentsession.Summary{ID: "s1"}}}) + app.openPicker(pickerProvider, statusChooseProvider, &app.providerPicker, "") + model, cmd := app.updatePicker(tea.KeyMsg{Type: tea.KeyEnter}) + app = model.(App) + if cmd != nil { + t.Fatalf("expected nil cmd when provider picker item type is invalid") + } + + app.modelPicker.SetItems([]list.Item{sessionItem{Summary: agentsession.Summary{ID: "s1"}}}) + app.openPicker(pickerModel, statusChooseModel, &app.modelPicker, "") + model, cmd = app.updatePicker(tea.KeyMsg{Type: tea.KeyEnter}) + app = model.(App) + if cmd != nil { + t.Fatalf("expected nil cmd when model picker item type is invalid") + } + + app.sessionPicker.SetItems([]list.Item{sessionItem{Summary: agentsession.Summary{ID: "missing", Title: "missing"}}}) + app.openPicker(pickerSession, statusChooseSession, &app.sessionPicker, "") + model, cmd = app.updatePicker(tea.KeyMsg{Type: tea.KeyEnter}) + app = model.(App) + if cmd != nil { + t.Fatalf("expected nil cmd for session picker enter") + } + if app.state.ExecutionError == "" { + t.Fatalf("expected session activation error to be recorded") + } +} + +func TestUpdateInputPanelSlashAndWorkspaceBranches(t *testing.T) { + app, _ := newTestApp(t) + + app.input.SetValue("/provider") + app.state.InputText = "/provider" + model, _ := app.Update(tea.KeyMsg{Type: tea.KeyEnter}) + app = model.(App) + if app.state.ActivePicker != pickerProvider { + t.Fatalf("expected /provider to open provider picker") + } + + app.closePicker() + app.input.SetValue("/model") + app.state.InputText = "/model" + model, cmd := app.Update(tea.KeyMsg{Type: tea.KeyEnter}) + app = model.(App) + if app.state.ActivePicker != pickerModel { + t.Fatalf("expected /model to open model picker") + } + _ = cmd + + app.closePicker() + app.input.SetValue("/provider add") + app.state.InputText = "/provider add" + model, _ = app.Update(tea.KeyMsg{Type: tea.KeyEnter}) + app = model.(App) + if app.state.ActivePicker != pickerProviderAdd || app.providerAddForm == nil { + t.Fatalf("expected /provider add to open provider add form") + } + + app.providerAddForm = nil + app.state.ActivePicker = pickerNone + app.input.SetValue("& echo hi") + app.state.InputText = "& echo hi" + model, cmd = app.Update(tea.KeyMsg{Type: tea.KeyEnter}) + app = model.(App) + if app.state.StatusText != statusRunningCommand { + t.Fatalf("expected workspace command running status, got %q", app.state.StatusText) + } + if cmd == nil { + t.Fatalf("expected workspace command to return async cmd") + } + + app.input.SetValue("&") + app.state.InputText = "&" + model, _ = app.Update(tea.KeyMsg{Type: tea.KeyEnter}) + app = model.(App) + if strings.TrimSpace(app.state.ExecutionError) == "" { + t.Fatalf("expected invalid workspace command error") + } +} diff --git a/internal/tui/core/app/view.go b/internal/tui/core/app/view.go index 42296611..2eb3b516 100644 --- a/internal/tui/core/app/view.go +++ b/internal/tui/core/app/view.go @@ -6,6 +6,7 @@ import ( "github.com/charmbracelet/lipgloss" + "neo-code/internal/provider" providertypes "neo-code/internal/provider/types" tuicomponents "neo-code/internal/tui/components" tuiutils "neo-code/internal/tui/core/utils" @@ -170,6 +171,11 @@ func (a App) renderPicker(width int, height int) string { subtitle = helpPickerSubtitle body = a.helpPicker.View() } + if a.state.ActivePicker == pickerProviderAdd { + title = providerAddTitle + subtitle = providerAddSubtitle + body = a.renderProviderAddForm() + } content := lipgloss.JoinVertical( lipgloss.Left, a.styles.panelTitle.Render(title), @@ -183,6 +189,93 @@ func (a App) renderPicker(width int, height int) string { return lipgloss.Place(width, height, lipgloss.Left, lipgloss.Top, panel) } +func (a App) renderProviderAddForm() string { + if a.providerAddForm == nil { + return "No form active" + } + + var sb strings.Builder + driver := provider.NormalizeProviderDriver(a.providerAddForm.Driver) + baseURLRequired := driver == provider.DriverAnthropic || (driver != provider.DriverOpenAICompat && driver != provider.DriverGemini) + visible := providerAddVisibleFields(a.providerAddForm.Driver) + clampProviderAddStep(a.providerAddForm) + + type renderField struct { + label string + value string + required bool + note string + } + fields := make([]renderField, 0, len(visible)) + for _, fieldID := range visible { + switch fieldID { + case providerAddFieldName: + fields = append(fields, renderField{label: "Name", value: a.providerAddForm.Name, required: true}) + case providerAddFieldDriver: + fields = append(fields, renderField{label: "Driver", value: a.providerAddForm.Driver, required: true}) + case providerAddFieldBaseURL: + note := "" + if strings.TrimSpace(a.providerAddForm.BaseURL) == "" && (driver == provider.DriverOpenAICompat || driver == provider.DriverGemini) { + note = "留空会自动填充默认地址" + } + fields = append(fields, renderField{ + label: "Base URL", + value: a.providerAddForm.BaseURL, + required: baseURLRequired, + note: note, + }) + case providerAddFieldAPIStyle: + note := "" + if strings.TrimSpace(a.providerAddForm.APIStyle) == "" { + note = "默认 chat_completions" + } + fields = append(fields, renderField{label: "API Style", value: a.providerAddForm.APIStyle, note: note}) + case providerAddFieldDeploymentMode: + fields = append(fields, renderField{label: "Deployment Mode", value: a.providerAddForm.DeploymentMode}) + case providerAddFieldAPIVersion: + fields = append(fields, renderField{label: "API Version", value: a.providerAddForm.APIVersion}) + case providerAddFieldAPIKey: + fields = append(fields, renderField{label: "API Key", value: maskedSecret(a.providerAddForm.APIKey), required: true}) + } + } + + for i, field := range fields { + prefix := " " + if i == a.providerAddForm.Step { + prefix = "> " + } + sb.WriteString(prefix + field.label + ": ") + sb.WriteString(field.value) + if field.required { + sb.WriteString(" *") + } + if field.note != "" { + sb.WriteString(" (" + field.note + ")") + } + sb.WriteString("\n") + } + + if a.providerAddForm.Error != "" { + label := "[Prompt]" + if a.providerAddForm.ErrorIsHard { + label = "[Error]" + } + sb.WriteString("\n" + label + " " + a.providerAddForm.Error + "\n") + } + + sb.WriteString("\n[Tab] switch field [Enter] confirm [Esc] cancel") + + return sb.String() +} + +// maskedSecret 将敏感输入渲染为固定掩码,避免在终端界面泄露明文。 +func maskedSecret(value string) string { + if strings.TrimSpace(value) == "" { + return "" + } + return "******" +} + func (a App) renderPrompt(width int) string { if a.pendingPermission != nil { box := a.styles.inputBoxFocused diff --git a/internal/tui/core/app/view_test.go b/internal/tui/core/app/view_test.go index 0c807d5a..b5b64129 100644 --- a/internal/tui/core/app/view_test.go +++ b/internal/tui/core/app/view_test.go @@ -1,6 +1,7 @@ package tui import ( + "errors" "strings" "testing" "time" @@ -8,11 +9,20 @@ import ( "github.com/charmbracelet/bubbles/list" "github.com/charmbracelet/lipgloss" + "neo-code/internal/provider" providertypes "neo-code/internal/provider/types" agentsession "neo-code/internal/session" tuistate "neo-code/internal/tui/state" ) +type stubMarkdownRenderer struct { + render func(content string, width int) (string, error) +} + +func (s stubMarkdownRenderer) Render(content string, width int) (string, error) { + return s.render(content, width) +} + func TestRenderPickerHelpMode(t *testing.T) { app, _ := newTestApp(t) app.refreshHelpPicker() @@ -65,6 +75,13 @@ func TestRenderPickerProviderAndFileMode(t *testing.T) { if !strings.Contains(fileView, filePickerTitle) { t.Fatalf("expected file picker title") } + + app.startProviderAddForm() + app.state.ActivePicker = pickerProviderAdd + providerAddView := app.renderPicker(48, 14) + if !strings.Contains(providerAddView, providerAddTitle) { + t.Fatalf("expected provider add title") + } } func TestBuildPickerLayoutExpandsPopupSpace(t *testing.T) { @@ -234,3 +251,272 @@ func TestRenderBody(t *testing.T) { t.Fatalf("expected renderBody output") } } + +func TestMaskedSecret(t *testing.T) { + if got := maskedSecret(""); got != "" { + t.Fatalf("maskedSecret(empty) = %q, want empty", got) + } + if got := maskedSecret(" "); got != "" { + t.Fatalf("maskedSecret(space) = %q, want empty", got) + } + if got := maskedSecret("sk-12345"); got != "******" { + t.Fatalf("maskedSecret(secret) = %q, want ******", got) + } +} + +func TestRenderProviderAddFormMasksAPIKeyAndShowsHints(t *testing.T) { + app, _ := newTestApp(t) + app.startProviderAddForm() + app.providerAddForm.Driver = "openaicompat" + app.providerAddForm.Name = "team-gateway" + app.providerAddForm.APIKey = "sk-secret-98765" + app.providerAddForm.BaseURL = "" + app.providerAddForm.APIStyle = "" + app.providerAddForm.Error = "input invalid" + app.providerAddForm.ErrorIsHard = true + + form := app.renderProviderAddForm() + if strings.Contains(form, "sk-secret-98765") { + t.Fatalf("expected api key to be masked, got %q", form) + } + if !strings.Contains(form, "API Key: ******") { + t.Fatalf("expected masked api key, got %q", form) + } + if !strings.Contains(form, "留空会自动填充默认地址") { + t.Fatalf("expected base url hint, got %q", form) + } + if !strings.Contains(form, "默认 chat_completions") { + t.Fatalf("expected api style hint, got %q", form) + } + if !strings.Contains(form, "[Error] input invalid") { + t.Fatalf("expected hard error label, got %q", form) + } +} + +func TestRenderProviderAddFormPromptLabel(t *testing.T) { + app, _ := newTestApp(t) + app.startProviderAddForm() + app.providerAddForm.Driver = "anthropic" + app.providerAddForm.Error = "continue input" + app.providerAddForm.ErrorIsHard = false + + form := app.renderProviderAddForm() + if !strings.Contains(form, "[Prompt] continue input") { + t.Fatalf("expected prompt label, got %q", form) + } +} + +func TestViewSmallWindowHint(t *testing.T) { + app, _ := newTestApp(t) + app.width = 40 + app.height = 10 + + view := app.View() + if !strings.Contains(view, "Window too small.") { + t.Fatalf("expected small-window hint, got %q", view) + } +} + +func TestViewNormalIncludesHeaderAndBody(t *testing.T) { + app, _ := newTestApp(t) + app.width = 100 + app.height = 30 + app.state.CurrentModel = "test-model" + app.state.StatusText = "running" + app.state.IsAgentRunning = true + app.runProgressKnown = true + app.runProgressValue = 0.42 + app.runProgressLabel = "loading" + app.state.InputText = "hi" + app.input.SetValue("hi") + + view := app.View() + if strings.TrimSpace(view) == "" { + t.Fatalf("expected non-empty view") + } + if !strings.Contains(view, "NeoCode") { + t.Fatalf("expected header text, got %q", view) + } + if !strings.Contains(view, "42% loading") { + t.Fatalf("expected progress header, got %q", view) + } +} + +func TestViewAddsSpacerWhenDocIsTallerThanContent(t *testing.T) { + app, _ := newTestApp(t) + app.width = 100 + app.height = 60 + + view := app.View() + if strings.TrimSpace(view) == "" { + t.Fatalf("expected non-empty view") + } +} + +func TestRenderHeaderFallbackAndTrim(t *testing.T) { + app, _ := newTestApp(t) + app.state.IsAgentRunning = true + app.state.StatusText = "custom-running-status" + header := app.renderHeader(20) + if strings.TrimSpace(header) == "" { + t.Fatalf("expected non-empty header") + } + + app.state.CurrentModel = strings.Repeat("very-long-model-name-", 4) + header = app.renderHeader(16) + if strings.TrimSpace(header) == "" { + t.Fatalf("expected trimmed header output") + } +} + +func TestRenderPanelAndActivityPreview(t *testing.T) { + app, _ := newTestApp(t) + panel := app.renderPanel("Title", "Sub", "Body", 60, 8, true) + if !strings.Contains(panel, "Title") || !strings.Contains(panel, "Body") { + t.Fatalf("expected panel content, got %q", panel) + } + + if got := app.renderActivityPreview(60); got != "" { + t.Fatalf("expected empty activity preview, got %q", got) + } + app.activities = []tuistate.ActivityEntry{{Kind: "tool", Title: "Run", Detail: "Detail"}} + withActivity := app.renderActivityPreview(60) + if !strings.Contains(withActivity, activityTitle) { + t.Fatalf("expected activity panel title, got %q", withActivity) + } + + app.commandMenu.SetItems([]list.Item{ + commandMenuItem{title: "/help", description: "show help"}, + }) + app.commandMenuMeta = tuistate.CommandMenuMeta{Title: commandMenuTitle} + withMenu := app.renderWaterfall(80, 24) + if !strings.Contains(withMenu, commandMenuTitle) { + t.Fatalf("expected command menu to be rendered") + } +} + +func TestRenderMessageContentWithCopyBranches(t *testing.T) { + app, _ := newTestApp(t) + + app.markdownRenderer = nil + rendered, bindings := app.renderMessageContentWithCopy("hello", 40, app.styles.messageBody, 1) + if len(bindings) != 0 || strings.TrimSpace(rendered) == "" { + t.Fatalf("expected fallback content without bindings, got rendered=%q bindings=%v", rendered, bindings) + } + + app, _ = newTestApp(t) + content := "hello\n```go\nfmt.Println(\"x\")\n```\nworld" + rendered, bindings = app.renderMessageContentWithCopy(content, 60, app.styles.messageBody, 3) + if strings.TrimSpace(rendered) == "" { + t.Fatalf("expected rendered markdown content") + } + if len(bindings) != 1 { + t.Fatalf("expected one copy binding, got %d", len(bindings)) + } + if bindings[0].ID != 3 || !strings.Contains(bindings[0].Code, "fmt.Println") { + t.Fatalf("unexpected binding: %+v", bindings[0]) + } + + app, _ = newTestApp(t) + app.markdownRenderer = stubMarkdownRenderer{ + render: func(content string, width int) (string, error) { + return "", errors.New("render failed") + }, + } + rendered, bindings = app.renderMessageContentWithCopy("plain text", 60, app.styles.messageBody, 1) + if len(bindings) != 0 || strings.TrimSpace(rendered) == "" { + t.Fatalf("expected empty message fallback when markdown render fails") + } +} + +func TestRenderMessageContentWithCopyCodeFallbackAndEmptySegments(t *testing.T) { + app, _ := newTestApp(t) + app.markdownRenderer = stubMarkdownRenderer{ + render: func(content string, width int) (string, error) { + if strings.HasPrefix(strings.TrimSpace(content), "```") { + return "", errors.New("code render failed") + } + return "ok", nil + }, + } + content := " \n```go\nfmt.Println(\"x\")\n```\n" + rendered, bindings := app.renderMessageContentWithCopy(content, 60, app.styles.messageBody, 7) + if strings.TrimSpace(rendered) == "" { + t.Fatalf("expected rendered output") + } + if len(bindings) != 1 || bindings[0].ID != 7 { + t.Fatalf("expected one binding with id 7, got %+v", bindings) + } +} + +func TestRenderMessageBlockWithCopyExtraBranches(t *testing.T) { + app, _ := newTestApp(t) + + eventBlock, _ := app.renderMessageBlockWithCopy(providertypes.Message{Role: roleEvent, Content: "event"}, 50, 1) + if !strings.Contains(eventBlock, "event") { + t.Fatalf("expected event block") + } + + toolBlock, bindings := app.renderMessageBlockWithCopy(providertypes.Message{Role: roleTool, Content: "tool"}, 50, 1) + if toolBlock != "" || bindings != nil { + t.Fatalf("expected tool role to be skipped") + } + + assistantBlock, _ := app.renderMessageBlockWithCopy(providertypes.Message{ + Role: roleAssistant, + ToolCalls: []providertypes.ToolCall{ + {Name: "bash"}, + }, + }, 50, 1) + assistantPlain := copyCodeANSIPattern.ReplaceAllString(assistantBlock, "") + if !strings.Contains(assistantPlain, "bash") { + t.Fatalf("expected tool calls summary in assistant block") + } + + userBlock, _ := app.renderMessageBlockWithCopy(providertypes.Message{ + Role: roleUser, + Content: "hello", + }, 10, 1) + if strings.TrimSpace(userBlock) == "" { + t.Fatalf("expected user message block") + } +} + +func TestRenderProviderAddFormNoFormAndGeminiField(t *testing.T) { + app, _ := newTestApp(t) + if got := app.renderProviderAddForm(); got != "No form active" { + t.Fatalf("unexpected no-form output: %q", got) + } + + app.startProviderAddForm() + app.providerAddForm.Driver = provider.DriverGemini + app.providerAddForm.DeploymentMode = "vertex" + form := app.renderProviderAddForm() + if !strings.Contains(form, "Deployment Mode") { + t.Fatalf("expected deployment mode field for gemini") + } +} + +func TestRenderCommandMenuEmptyBody(t *testing.T) { + app, _ := newTestApp(t) + app.commandMenu.SetItems([]list.Item{ + commandMenuItem{title: "/help", description: "show help"}, + }) + app.state.ActivePicker = pickerHelp + if got := app.renderCommandMenu(50); got != "" { + t.Fatalf("expected empty menu while picker is active") + } +} + +func TestNormalizeAndTrimHelpers(t *testing.T) { + trimmed := trimRenderedTrailingWhitespace("line1 \nline2\t") + if strings.HasSuffix(trimmed, "\t") || strings.HasSuffix(trimmed, " ") { + t.Fatalf("expected trailing whitespace trimmed, got %q", trimmed) + } + + normalized := normalizeBlockRightEdge("a\nbb", 6) + lines := strings.Split(normalized, "\n") + if len(lines) != 2 { + t.Fatalf("expected two lines, got %q", normalized) + } +} diff --git a/internal/tui/state/ui_state.go b/internal/tui/state/ui_state.go index aea6d120..be32b211 100644 --- a/internal/tui/state/ui_state.go +++ b/internal/tui/state/ui_state.go @@ -22,6 +22,7 @@ const ( PickerSession PickerFile PickerHelp + PickerProviderAdd ) // UIState 保存顶层界面状态快照,仅作为数据容器使用。