diff --git a/cmd/cmd.go b/cmd/cmd.go index 0797f9860..79a604fd4 100644 --- a/cmd/cmd.go +++ b/cmd/cmd.go @@ -2065,6 +2065,10 @@ func runLauncherAction(cmd *cobra.Command, action tui.TUIAction, deps launcherDe if err != nil { return true, fmt.Errorf("launching %s: %w", action.Integration, err) } + // VS Code is a GUI app — exit the TUI loop after launching + if action.Integration == "vscode" { + return false, nil + } return true, nil default: return false, fmt.Errorf("unknown launcher action: %d", action.Kind) diff --git a/cmd/cmd_launcher_test.go b/cmd/cmd_launcher_test.go index f9ff2739b..2d76219bf 100644 --- a/cmd/cmd_launcher_test.go +++ b/cmd/cmd_launcher_test.go @@ -209,6 +209,43 @@ func TestRunLauncherAction_RunModelContinuesAfterCancellation(t *testing.T) { } } +func TestRunLauncherAction_VSCodeExitsTUILoop(t *testing.T) { + setCmdTestHome(t, t.TempDir()) + + cmd := &cobra.Command{} + cmd.SetContext(context.Background()) + + // VS Code should exit the TUI loop (return false) after a successful launch. + continueLoop, err := runLauncherAction(cmd, tui.TUIAction{Kind: tui.TUIActionLaunchIntegration, Integration: "vscode"}, launcherDeps{ + resolveRunModel: unexpectedRunModelResolution(t), + launchIntegration: func(ctx context.Context, req launch.IntegrationLaunchRequest) error { + return nil + }, + runModel: unexpectedModelLaunch(t), + }) + if err != nil { + t.Fatalf("expected nil error, got %v", err) + } + if continueLoop { + t.Fatal("expected vscode launch to exit the TUI loop (return false)") + } + + // Other integrations should continue the TUI loop (return true). + continueLoop, err = runLauncherAction(cmd, tui.TUIAction{Kind: tui.TUIActionLaunchIntegration, Integration: "claude"}, launcherDeps{ + resolveRunModel: unexpectedRunModelResolution(t), + launchIntegration: func(ctx context.Context, req launch.IntegrationLaunchRequest) error { + return nil + }, + runModel: unexpectedModelLaunch(t), + }) + if err != nil { + t.Fatalf("expected nil error, got %v", err) + } + if !continueLoop { + t.Fatal("expected non-vscode integration to continue the TUI loop (return true)") + } +} + func TestRunLauncherAction_IntegrationContinuesAfterCancellation(t *testing.T) { setCmdTestHome(t, t.TempDir()) diff --git a/cmd/launch/launch.go b/cmd/launch/launch.go index 0734bf312..4afeca481 100644 --- a/cmd/launch/launch.go +++ b/cmd/launch/launch.go @@ -179,6 +179,7 @@ Supported integrations: opencode OpenCode openclaw OpenClaw (aliases: clawdbot, moltbot) pi Pi + vscode    VS Code (aliases: code) Examples: ollama launch @@ -801,13 +802,6 @@ func cloneAliases(aliases map[string]string) map[string]string { return cloned } -func singleModelPrechecked(current string) []string { - if current == "" { - return nil - } - return []string{current} -} - func firstModel(models []string) string { if len(models) == 0 { return "" diff --git a/cmd/launch/registry.go b/cmd/launch/registry.go index ebafe40b6..b2b5cedbe 100644 --- a/cmd/launch/registry.go +++ b/cmd/launch/registry.go @@ -33,7 +33,7 @@ type IntegrationInfo struct { Description string } -var launcherIntegrationOrder = []string{"opencode", "droid", "pi", "cline"} +var launcherIntegrationOrder = []string{"vscode", "opencode", "droid", "pi", "cline"} var integrationSpecs = []*IntegrationSpec{ { @@ -131,6 +131,18 @@ var integrationSpecs = []*IntegrationSpec{ Command: []string{"npm", "install", "-g", "@mariozechner/pi-coding-agent"}, }, }, + { + Name: "vscode", + Runner: &VSCode{}, + Aliases: []string{"code"}, + Description: "Microsoft's open-source AI code editor", + Install: IntegrationInstallSpec{ + CheckInstalled: func() bool { + return (&VSCode{}).findBinary() != "" + }, + URL: "https://code.visualstudio.com", + }, + }, } var integrationSpecsByName map[string]*IntegrationSpec diff --git a/cmd/launch/vscode.go b/cmd/launch/vscode.go new file mode 100644 index 000000000..e68da2bd8 --- /dev/null +++ b/cmd/launch/vscode.go @@ -0,0 +1,594 @@ +package launch + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "strconv" + "strings" + "time" + + _ "github.com/mattn/go-sqlite3" + "github.com/ollama/ollama/api" + "github.com/ollama/ollama/cmd/internal/fileutil" + "github.com/ollama/ollama/envconfig" +) + +// VSCode implements Runner and Editor for Visual Studio Code integration. +type VSCode struct{} + +func (v *VSCode) String() string { return "Visual Studio Code" } + +// findBinary returns the path/command to launch VS Code, or "" if not found. +// It checks for the "code" CLI on PATH first, then falls back to platform-specific locations. +func (v *VSCode) findBinary() string { + if _, err := exec.LookPath("code"); err == nil { + return "code" + } + var candidates []string + switch runtime.GOOS { + case "darwin": + candidates = []string{ + "/Applications/Visual Studio Code.app", + } + case "windows": + if localAppData := os.Getenv("LOCALAPPDATA"); localAppData != "" { + candidates = append(candidates, filepath.Join(localAppData, "Programs", "Microsoft VS Code", "bin", "code.cmd")) + } + default: // linux + candidates = []string{ + "/usr/bin/code", + "/snap/bin/code", + } + } + for _, c := range candidates { + if _, err := os.Stat(c); err == nil { + return c + } + } + return "" +} + +// IsRunning reports whether VS Code is currently running. +// Each platform uses a pattern specific enough to avoid matching Cursor or +// other VS Code forks. +func (v *VSCode) IsRunning() bool { + switch runtime.GOOS { + case "darwin": + out, err := exec.Command("pgrep", "-f", "Visual Studio Code.app/Contents/MacOS/Code").Output() + return err == nil && len(out) > 0 + case "windows": + // Match VS Code by executable path to avoid matching Cursor or other forks. + out, err := exec.Command("powershell", "-NoProfile", "-Command", + `Get-Process Code -ErrorAction SilentlyContinue | Where-Object { $_.Path -like '*Microsoft VS Code*' } | Select-Object -First 1`).Output() + return err == nil && len(strings.TrimSpace(string(out))) > 0 + default: + // Match VS Code specifically by its install path to avoid matching + // Cursor (/cursor/) or other forks. + for _, pattern := range []string{"/usr/share/code/", "/snap/code/"} { + out, err := exec.Command("pgrep", "-f", pattern).Output() + if err == nil && len(out) > 0 { + return true + } + } + return false + } +} + +// Quit gracefully quits VS Code and waits for it to exit so that it flushes +// its in-memory state back to the database. +func (v *VSCode) Quit() { + if !v.IsRunning() { + return + } + switch runtime.GOOS { + case "darwin": + _ = exec.Command("osascript", "-e", `quit app "Visual Studio Code"`).Run() + case "windows": + // Kill VS Code by executable path to avoid killing Cursor or other forks. + _ = exec.Command("powershell", "-NoProfile", "-Command", + `Get-Process Code -ErrorAction SilentlyContinue | Where-Object { $_.Path -like '*Microsoft VS Code*' } | Stop-Process -Force`).Run() + default: + for _, pattern := range []string{"/usr/share/code/", "/snap/code/"} { + _ = exec.Command("pkill", "-f", pattern).Run() + } + } + // Wait for the process to fully exit and flush its state to disk + // TODO(hoyyeva): update spinner to use bubble tea + spinnerFrames := []string{"|", "/", "-", "\\"} + frame := 0 + fmt.Fprintf(os.Stderr, "\033[90mRestarting VS Code... %s\033[0m", spinnerFrames[0]) + + ticker := time.NewTicker(200 * time.Millisecond) + defer ticker.Stop() + + for range 150 { // 150 ticks × 200ms = 30s timeout + <-ticker.C + frame++ + fmt.Fprintf(os.Stderr, "\r\033[90mRestarting VS Code... %s\033[0m", spinnerFrames[frame%len(spinnerFrames)]) + + if frame%5 == 0 { // check every ~1s + if !v.IsRunning() { + fmt.Fprintf(os.Stderr, "\r\033[K") + // Give VS Code a moment to finish writing its state DB + time.Sleep(1 * time.Second) + return + } + } + } + fmt.Fprintf(os.Stderr, "\r\033[K") +} + +const ( + minCopilotChatVersion = "0.41.0" + minVSCodeVersion = "1.113" +) + +func (v *VSCode) Run(model string, args []string) error { + v.checkVSCodeVersion() + v.checkCopilotChatVersion() + + // Get all configured models (saved by the launcher framework before Run is called) + models := []string{model} + if cfg, err := loadStoredIntegrationConfig("vscode"); err == nil && len(cfg.Models) > 0 { + models = cfg.Models + } + + // VS Code discovers models from ollama ls. Cloud models that pass Show + // (the server knows about them) but aren't in ls need to be pulled to + // register them so VS Code can find them. + if client, err := api.ClientFromEnvironment(); err == nil { + v.ensureModelsRegistered(context.Background(), client, models) + } + + // Warn if the default model doesn't support tool calling + if client, err := api.ClientFromEnvironment(); err == nil { + if resp, err := client.Show(context.Background(), &api.ShowRequest{Model: models[0]}); err == nil { + hasTools := false + for _, c := range resp.Capabilities { + if c == "tools" { + hasTools = true + break + } + } + if !hasTools { + fmt.Fprintf(os.Stderr, "Note: %s does not support tool calling and may not appear in the Copilot Chat model picker.\n", models[0]) + } + } + } + + v.printModelAccessTip() + + if v.IsRunning() { + restart, err := ConfirmPrompt("Restart VS Code?") + if err != nil { + restart = false + } + if restart { + v.Quit() + if err := v.ShowInModelPicker(models); err != nil { + fmt.Fprintf(os.Stderr, "%s Warning: could not update VS Code model picker: %v%s\n", ansiYellow, err, ansiReset) + } + v.FocusVSCode() + } else { + fmt.Fprintf(os.Stderr, "\nTo get the latest model configuration, restart VS Code when you're ready.\n") + } + } else { + if err := v.ShowInModelPicker(models); err != nil { + fmt.Fprintf(os.Stderr, "%s Warning: could not update VS Code model picker: %v%s\n", ansiYellow, err, ansiReset) + } + v.FocusVSCode() + } + + return nil +} + +// ensureModelsRegistered pulls models that the server knows about (Show succeeds) +// but aren't in ollama ls yet. This is needed for cloud models so that VS Code +// can discover them from the Ollama API. +func (v *VSCode) ensureModelsRegistered(ctx context.Context, client *api.Client, models []string) { + listed, err := client.List(ctx) + if err != nil { + return + } + registered := make(map[string]bool, len(listed.Models)) + for _, m := range listed.Models { + registered[m.Name] = true + } + + for _, model := range models { + if registered[model] { + continue + } + // Also check without :latest suffix + if !strings.Contains(model, ":") && registered[model+":latest"] { + continue + } + if err := pullModel(ctx, client, model, false); err != nil { + fmt.Fprintf(os.Stderr, "%s Warning: could not register model %s: %v%s\n", ansiYellow, model, err, ansiReset) + } + } +} + +// FocusVSCode brings VS Code to the foreground. +func (v *VSCode) FocusVSCode() { + binary := v.findBinary() + if binary == "" { + return + } + if runtime.GOOS == "darwin" && strings.HasSuffix(binary, ".app") { + _ = exec.Command("open", "-a", binary).Run() + } else { + _ = exec.Command(binary).Start() + } +} + +// printModelAccessTip shows instructions for finding Ollama models in VS Code. +func (v *VSCode) printModelAccessTip() { + fmt.Fprintf(os.Stderr, "\nTip: To use Ollama models, open Copilot Chat and click the model picker.\n") + fmt.Fprintf(os.Stderr, " If you don't see your models, click \"Other models\" to find them.\n\n") +} + +func (v *VSCode) Paths() []string { + if p := v.chatLanguageModelsPath(); fileExists(p) { + return []string{p} + } + return nil +} + +func (v *VSCode) Edit(models []string) error { + if len(models) == 0 { + return nil + } + + // Write chatLanguageModels.json with Ollama vendor entry + clmPath := v.chatLanguageModelsPath() + if err := os.MkdirAll(filepath.Dir(clmPath), 0o755); err != nil { + return err + } + + var entries []map[string]any + if data, err := os.ReadFile(clmPath); err == nil { + _ = json.Unmarshal(data, &entries) + } + + // Remove any existing Ollama entries, preserve others + filtered := make([]map[string]any, 0, len(entries)) + for _, entry := range entries { + if vendor, _ := entry["vendor"].(string); vendor != "ollama" { + filtered = append(filtered, entry) + } + } + + // Add new Ollama entry + filtered = append(filtered, map[string]any{ + "vendor": "ollama", + "name": "Ollama", + "url": envconfig.Host().String(), + }) + + data, err := json.MarshalIndent(filtered, "", " ") + if err != nil { + return err + } + if err := fileutil.WriteWithBackup(clmPath, data); err != nil { + return err + } + + // Clean up legacy settings from older Ollama integrations + v.updateSettings() + + return nil +} + +func (v *VSCode) Models() []string { + if !v.hasOllamaVendor() { + return nil + } + if cfg, err := loadStoredIntegrationConfig("vscode"); err == nil { + return cfg.Models + } + return nil +} + +// hasOllamaVendor checks if chatLanguageModels.json contains an Ollama vendor entry. +func (v *VSCode) hasOllamaVendor() bool { + data, err := os.ReadFile(v.chatLanguageModelsPath()) + if err != nil { + return false + } + + var entries []map[string]any + if err := json.Unmarshal(data, &entries); err != nil { + return false + } + + for _, entry := range entries { + if vendor, _ := entry["vendor"].(string); vendor == "ollama" { + return true + } + } + return false +} + +func (v *VSCode) chatLanguageModelsPath() string { + return v.vscodePath("chatLanguageModels.json") +} + +func (v *VSCode) settingsPath() string { + return v.vscodePath("settings.json") +} + +// updateSettings cleans up legacy settings from older Ollama integrations. +func (v *VSCode) updateSettings() { + settingsPath := v.settingsPath() + data, err := os.ReadFile(settingsPath) + if err != nil { + return + } + + var settings map[string]any + if err := json.Unmarshal(data, &settings); err != nil { + return + } + + changed := false + for _, key := range []string{"github.copilot.chat.byok.ollamaEndpoint", "ollama.launch.configured"} { + if _, ok := settings[key]; ok { + delete(settings, key) + changed = true + } + } + + if !changed { + return + } + + updated, err := json.MarshalIndent(settings, "", " ") + if err != nil { + return + } + _ = fileutil.WriteWithBackup(settingsPath, updated) +} + +func (v *VSCode) statePath() string { + return v.vscodePath("globalStorage", "state.vscdb") +} + +// ShowInModelPicker ensures the given models are visible in VS Code's Copilot +// Chat model picker. It sets the configured models to true in the picker +// preferences so they appear in the dropdown. Models use the VS Code identifier +// format "ollama/Ollama/". +func (v *VSCode) ShowInModelPicker(models []string) error { + if len(models) == 0 { + return nil + } + + dbPath := v.statePath() + needsCreate := !fileExists(dbPath) + if needsCreate { + if err := os.MkdirAll(filepath.Dir(dbPath), 0o755); err != nil { + return fmt.Errorf("creating state directory: %w", err) + } + } + + db, err := sql.Open("sqlite3", dbPath+"?_busy_timeout=5000") + if err != nil { + return fmt.Errorf("opening state database: %w", err) + } + defer db.Close() + + // Create the table if this is a fresh DB. Schema must match what VS Code creates. + if needsCreate { + if _, err := db.Exec("CREATE TABLE ItemTable (key TEXT UNIQUE ON CONFLICT REPLACE, value BLOB)"); err != nil { + return fmt.Errorf("initializing state database: %w", err) + } + } + + // Read existing preferences + prefs := make(map[string]bool) + var prefsJSON string + if err := db.QueryRow("SELECT value FROM ItemTable WHERE key = 'chatModelPickerPreferences'").Scan(&prefsJSON); err == nil { + _ = json.Unmarshal([]byte(prefsJSON), &prefs) + } + + // Build name→ID map from VS Code's cached model list. + // VS Code uses numeric IDs like "ollama/Ollama/4", not "ollama/Ollama/kimi-k2.5:cloud". + nameToID := make(map[string]string) + var cacheJSON string + if err := db.QueryRow("SELECT value FROM ItemTable WHERE key = 'chat.cachedLanguageModels.v2'").Scan(&cacheJSON); err == nil { + var cached []map[string]any + if json.Unmarshal([]byte(cacheJSON), &cached) == nil { + for _, entry := range cached { + meta, _ := entry["metadata"].(map[string]any) + if meta == nil { + continue + } + if vendor, _ := meta["vendor"].(string); vendor == "ollama" { + name, _ := meta["name"].(string) + id, _ := entry["identifier"].(string) + if name != "" && id != "" { + nameToID[name] = id + } + } + } + } + } + + // Ollama config is authoritative: always show configured models, + // hide Ollama models that are no longer in the config. + configuredIDs := make(map[string]bool) + for _, m := range models { + for _, id := range v.modelVSCodeIDs(m, nameToID) { + prefs[id] = true + configuredIDs[id] = true + } + } + for id := range prefs { + if strings.HasPrefix(id, "ollama/") && !configuredIDs[id] { + prefs[id] = false + } + } + + data, _ := json.Marshal(prefs) + if _, err = db.Exec("INSERT OR REPLACE INTO ItemTable (key, value) VALUES ('chatModelPickerPreferences', ?)", string(data)); err != nil { + return err + } + + return nil +} + +// modelVSCodeIDs returns all possible VS Code picker IDs for a model name. +func (v *VSCode) modelVSCodeIDs(model string, nameToID map[string]string) []string { + var ids []string + if id, ok := nameToID[model]; ok { + ids = append(ids, id) + } else if !strings.Contains(model, ":") { + if id, ok := nameToID[model+":latest"]; ok { + ids = append(ids, id) + } + } + ids = append(ids, "ollama/Ollama/"+model) + if !strings.Contains(model, ":") { + ids = append(ids, "ollama/Ollama/"+model+":latest") + } + return ids +} + +func (v *VSCode) vscodePath(parts ...string) string { + home, _ := os.UserHomeDir() + var base string + switch runtime.GOOS { + case "darwin": + base = filepath.Join(home, "Library", "Application Support", "Code", "User") + case "windows": + base = filepath.Join(os.Getenv("APPDATA"), "Code", "User") + default: + base = filepath.Join(home, ".config", "Code", "User") + } + return filepath.Join(append([]string{base}, parts...)...) +} + +// checkVSCodeVersion warns if VS Code is older than minVSCodeVersion. +func (v *VSCode) checkVSCodeVersion() { + codeCLI := v.findCodeCLI() + if codeCLI == "" { + return + } + + out, err := exec.Command(codeCLI, "--version").Output() + if err != nil { + return + } + + // "code --version" outputs: version\ncommit\narch + lines := strings.Split(strings.TrimSpace(string(out)), "\n") + if len(lines) == 0 || lines[0] == "" { + return + } + version := strings.TrimSpace(lines[0]) + + if compareVersions(version, minVSCodeVersion) < 0 { + fmt.Fprintf(os.Stderr, "\n%sWarning: VS Code version (%s) is older than the recommended version (%s)%s\n", ansiYellow, version, minVSCodeVersion, ansiReset) + fmt.Fprintf(os.Stderr, "Please update VS Code to the latest version.\n\n") + } +} + +// checkCopilotChatVersion warns if the GitHub Copilot Chat extension is +// missing or older than minCopilotChatVersion. +func (v *VSCode) checkCopilotChatVersion() { + codeCLI := v.findCodeCLI() + if codeCLI == "" { + return + } + + out, err := exec.Command(codeCLI, "--list-extensions", "--show-versions").Output() + if err != nil { + return + } + + installed, version := parseCopilotChatVersion(string(out)) + if !installed { + fmt.Fprintf(os.Stderr, "\n%sWarning: GitHub Copilot Chat extension is not installed%s\n", ansiYellow, ansiReset) + fmt.Fprintf(os.Stderr, "Install it in VS Code: Extensions → search \"GitHub Copilot Chat\" → Install\n\n") + return + } + if compareVersions(version, minCopilotChatVersion) < 0 { + fmt.Fprintf(os.Stderr, "\n%sWarning: GitHub Copilot Chat extension version (%s) is older than the recommended version (%s)%s\n", ansiYellow, version, minCopilotChatVersion, ansiReset) + fmt.Fprintf(os.Stderr, "Please update it in VS Code: Extensions → search \"GitHub Copilot Chat\" → Update\n\n") + } +} + +// findCodeCLI returns the path to the VS Code CLI for querying extensions. +// On macOS, findBinary may return an .app bundle which can't run --list-extensions, +// so this resolves to the actual CLI binary inside the bundle. +func (v *VSCode) findCodeCLI() string { + binary := v.findBinary() + if binary == "" { + return "" + } + if runtime.GOOS == "darwin" && strings.HasSuffix(binary, ".app") { + bundleCLI := binary + "/Contents/Resources/app/bin/code" + if _, err := os.Stat(bundleCLI); err == nil { + return bundleCLI + } + return "" + } + return binary +} + +// parseCopilotChatVersion extracts the version of the GitHub Copilot Chat +// extension from "code --list-extensions --show-versions" output. +func parseCopilotChatVersion(output string) (installed bool, version string) { + for _, line := range strings.Split(output, "\n") { + // Format: github.copilot-chat@0.40.1 + if !strings.HasPrefix(strings.ToLower(line), "github.copilot-chat@") { + continue + } + parts := strings.SplitN(line, "@", 2) + if len(parts) != 2 { + continue + } + return true, strings.TrimSpace(parts[1]) + } + return false, "" +} + +// compareVersions compares two dot-separated version strings. +// Returns -1 if a < b, 0 if a == b, 1 if a > b. +func compareVersions(a, b string) int { + aParts := strings.Split(a, ".") + bParts := strings.Split(b, ".") + + maxLen := len(aParts) + if len(bParts) > maxLen { + maxLen = len(bParts) + } + + for i := range maxLen { + var aNum, bNum int + if i < len(aParts) { + aNum, _ = strconv.Atoi(aParts[i]) + } + if i < len(bParts) { + bNum, _ = strconv.Atoi(bParts[i]) + } + if aNum < bNum { + return -1 + } + if aNum > bNum { + return 1 + } + } + return 0 +} + +func fileExists(path string) bool { + _, err := os.Stat(path) + return err == nil +} diff --git a/cmd/launch/vscode_test.go b/cmd/launch/vscode_test.go new file mode 100644 index 000000000..2cec3b3d3 --- /dev/null +++ b/cmd/launch/vscode_test.go @@ -0,0 +1,486 @@ +package launch + +import ( + "database/sql" + "encoding/json" + "os" + "path/filepath" + "runtime" + "testing" + + _ "github.com/mattn/go-sqlite3" +) + +func TestVSCodeIntegration(t *testing.T) { + v := &VSCode{} + + t.Run("String", func(t *testing.T) { + if got := v.String(); got != "Visual Studio Code" { + t.Errorf("String() = %q, want %q", got, "Visual Studio Code") + } + }) + + t.Run("implements Runner", func(t *testing.T) { + var _ Runner = v + }) + + t.Run("implements Editor", func(t *testing.T) { + var _ Editor = v + }) +} + +func TestVSCodeEdit(t *testing.T) { + v := &VSCode{} + tmpDir := t.TempDir() + setTestHome(t, tmpDir) + t.Setenv("XDG_CONFIG_HOME", "") + clmPath := testVSCodePath(t, tmpDir, "chatLanguageModels.json") + + tests := []struct { + name string + setup string // initial chatLanguageModels.json content, empty means no file + models []string + validate func(t *testing.T, data []byte) + }{ + { + name: "fresh install", + models: []string{"llama3.2"}, + validate: func(t *testing.T, data []byte) { + assertOllamaVendorConfigured(t, data) + }, + }, + { + name: "preserve other vendor entries", + setup: `[{"vendor": "azure", "name": "Azure", "url": "https://example.com"}]`, + models: []string{"llama3.2"}, + validate: func(t *testing.T, data []byte) { + var entries []map[string]any + json.Unmarshal(data, &entries) + if len(entries) != 2 { + t.Errorf("expected 2 entries, got %d", len(entries)) + } + // Check Azure entry preserved + found := false + for _, e := range entries { + if v, _ := e["vendor"].(string); v == "azure" { + found = true + } + } + if !found { + t.Error("azure vendor entry was not preserved") + } + assertOllamaVendorConfigured(t, data) + }, + }, + { + name: "update existing ollama entry", + setup: `[{"vendor": "ollama", "name": "Ollama", "url": "http://old:11434"}]`, + models: []string{"llama3.2"}, + validate: func(t *testing.T, data []byte) { + assertOllamaVendorConfigured(t, data) + }, + }, + { + name: "empty models is no-op", + setup: `[{"vendor": "azure", "name": "Azure"}]`, + models: []string{}, + validate: func(t *testing.T, data []byte) { + if string(data) != `[{"vendor": "azure", "name": "Azure"}]` { + t.Error("empty models should not modify file") + } + }, + }, + { + name: "corrupted JSON treated as empty", + setup: `{corrupted json`, + models: []string{"llama3.2"}, + validate: func(t *testing.T, data []byte) { + var entries []map[string]any + if err := json.Unmarshal(data, &entries); err != nil { + t.Errorf("result is not valid JSON: %v", err) + } + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + os.RemoveAll(filepath.Dir(clmPath)) + + if tt.setup != "" { + os.MkdirAll(filepath.Dir(clmPath), 0o755) + os.WriteFile(clmPath, []byte(tt.setup), 0o644) + } + + if err := v.Edit(tt.models); err != nil { + t.Fatal(err) + } + + data, _ := os.ReadFile(clmPath) + tt.validate(t, data) + }) + } +} + +func TestVSCodeEditCleansUpOldSettings(t *testing.T) { + v := &VSCode{} + tmpDir := t.TempDir() + setTestHome(t, tmpDir) + t.Setenv("XDG_CONFIG_HOME", "") + settingsPath := testVSCodePath(t, tmpDir, "settings.json") + + // Create settings.json with old byok setting + os.MkdirAll(filepath.Dir(settingsPath), 0o755) + os.WriteFile(settingsPath, []byte(`{"github.copilot.chat.byok.ollamaEndpoint": "http://old:11434", "ollama.launch.configured": true, "editor.fontSize": 14}`), 0o644) + + if err := v.Edit([]string{"llama3.2"}); err != nil { + t.Fatal(err) + } + + // Verify old settings were removed + data, err := os.ReadFile(settingsPath) + if err != nil { + t.Fatal(err) + } + + var settings map[string]any + json.Unmarshal(data, &settings) + if _, ok := settings["github.copilot.chat.byok.ollamaEndpoint"]; ok { + t.Error("github.copilot.chat.byok.ollamaEndpoint should have been removed") + } + if _, ok := settings["ollama.launch.configured"]; ok { + t.Error("ollama.launch.configured should have been removed") + } + if settings["editor.fontSize"] != float64(14) { + t.Error("editor.fontSize should have been preserved") + } +} + +func TestVSCodePaths(t *testing.T) { + v := &VSCode{} + tmpDir := t.TempDir() + setTestHome(t, tmpDir) + t.Setenv("XDG_CONFIG_HOME", "") + clmPath := testVSCodePath(t, tmpDir, "chatLanguageModels.json") + + t.Run("no file returns nil", func(t *testing.T) { + os.Remove(clmPath) + if paths := v.Paths(); paths != nil { + t.Errorf("expected nil, got %v", paths) + } + }) + + t.Run("existing file returns path", func(t *testing.T) { + os.MkdirAll(filepath.Dir(clmPath), 0o755) + os.WriteFile(clmPath, []byte(`[]`), 0o644) + + if paths := v.Paths(); len(paths) != 1 { + t.Errorf("expected 1 path, got %d", len(paths)) + } + }) +} + +// testVSCodePath returns the expected VS Code config path for the given file in tests. +func testVSCodePath(t *testing.T, tmpDir, filename string) string { + t.Helper() + switch runtime.GOOS { + case "darwin": + return filepath.Join(tmpDir, "Library", "Application Support", "Code", "User", filename) + case "windows": + t.Setenv("APPDATA", tmpDir) + return filepath.Join(tmpDir, "Code", "User", filename) + default: + return filepath.Join(tmpDir, ".config", "Code", "User", filename) + } +} + +func assertOllamaVendorConfigured(t *testing.T, data []byte) { + t.Helper() + var entries []map[string]any + if err := json.Unmarshal(data, &entries); err != nil { + t.Fatalf("invalid JSON: %v", err) + } + + for _, entry := range entries { + if vendor, _ := entry["vendor"].(string); vendor == "ollama" { + if name, _ := entry["name"].(string); name != "Ollama" { + t.Errorf("expected name \"Ollama\", got %q", name) + } + if url, _ := entry["url"].(string); url == "" { + t.Error("url not set") + } + return + } + } + t.Error("no ollama vendor entry found") +} + +func TestShowInModelPicker(t *testing.T) { + v := &VSCode{} + + // helper to create a state DB with optional seed data + setupDB := func(t *testing.T, tmpDir string, seedPrefs map[string]bool, seedCache []map[string]any) string { + t.Helper() + dbDir := filepath.Join(tmpDir, "globalStorage") + os.MkdirAll(dbDir, 0o755) + dbPath := filepath.Join(dbDir, "state.vscdb") + + db, err := sql.Open("sqlite3", dbPath) + if err != nil { + t.Fatal(err) + } + defer db.Close() + + if _, err := db.Exec("CREATE TABLE ItemTable (key TEXT UNIQUE ON CONFLICT REPLACE, value BLOB)"); err != nil { + t.Fatal(err) + } + if seedPrefs != nil { + data, _ := json.Marshal(seedPrefs) + db.Exec("INSERT INTO ItemTable (key, value) VALUES ('chatModelPickerPreferences', ?)", string(data)) + } + if seedCache != nil { + data, _ := json.Marshal(seedCache) + db.Exec("INSERT INTO ItemTable (key, value) VALUES ('chat.cachedLanguageModels.v2', ?)", string(data)) + } + return dbPath + } + + // helper to read prefs back from DB + readPrefs := func(t *testing.T, dbPath string) map[string]bool { + t.Helper() + db, err := sql.Open("sqlite3", dbPath) + if err != nil { + t.Fatal(err) + } + defer db.Close() + + var raw string + if err := db.QueryRow("SELECT value FROM ItemTable WHERE key = 'chatModelPickerPreferences'").Scan(&raw); err != nil { + t.Fatal(err) + } + prefs := make(map[string]bool) + json.Unmarshal([]byte(raw), &prefs) + return prefs + } + + t.Run("fresh DB creates table and shows models", func(t *testing.T) { + tmpDir := t.TempDir() + setTestHome(t, tmpDir) + t.Setenv("XDG_CONFIG_HOME", "") + if runtime.GOOS == "windows" { + t.Setenv("APPDATA", tmpDir) + } + + err := v.ShowInModelPicker([]string{"llama3.2"}) + if err != nil { + t.Fatal(err) + } + + dbPath := testVSCodePath(t, tmpDir, filepath.Join("globalStorage", "state.vscdb")) + prefs := readPrefs(t, dbPath) + if !prefs["ollama/Ollama/llama3.2"] { + t.Error("expected llama3.2 to be shown") + } + if !prefs["ollama/Ollama/llama3.2:latest"] { + t.Error("expected llama3.2:latest to be shown") + } + }) + + t.Run("configured models are shown", func(t *testing.T) { + tmpDir := t.TempDir() + setTestHome(t, tmpDir) + t.Setenv("XDG_CONFIG_HOME", "") + dbPath := setupDB(t, testVSCodePath(t, tmpDir, ""), nil, nil) + + err := v.ShowInModelPicker([]string{"llama3.2", "qwen3:8b"}) + if err != nil { + t.Fatal(err) + } + + prefs := readPrefs(t, dbPath) + if !prefs["ollama/Ollama/llama3.2"] { + t.Error("expected llama3.2 to be shown") + } + if !prefs["ollama/Ollama/qwen3:8b"] { + t.Error("expected qwen3:8b to be shown") + } + }) + + t.Run("removed models are hidden", func(t *testing.T) { + tmpDir := t.TempDir() + setTestHome(t, tmpDir) + t.Setenv("XDG_CONFIG_HOME", "") + dbPath := setupDB(t, testVSCodePath(t, tmpDir, ""), map[string]bool{ + "ollama/Ollama/llama3.2": true, + "ollama/Ollama/llama3.2:latest": true, + "ollama/Ollama/mistral": true, + "ollama/Ollama/mistral:latest": true, + }, nil) + + // Only configure llama3.2 — mistral should get hidden + err := v.ShowInModelPicker([]string{"llama3.2"}) + if err != nil { + t.Fatal(err) + } + + prefs := readPrefs(t, dbPath) + if !prefs["ollama/Ollama/llama3.2"] { + t.Error("expected llama3.2 to stay shown") + } + if prefs["ollama/Ollama/mistral"] { + t.Error("expected mistral to be hidden") + } + if prefs["ollama/Ollama/mistral:latest"] { + t.Error("expected mistral:latest to be hidden") + } + }) + + t.Run("non-ollama prefs are preserved", func(t *testing.T) { + tmpDir := t.TempDir() + setTestHome(t, tmpDir) + t.Setenv("XDG_CONFIG_HOME", "") + dbPath := setupDB(t, testVSCodePath(t, tmpDir, ""), map[string]bool{ + "copilot/gpt-4o": true, + }, nil) + + err := v.ShowInModelPicker([]string{"llama3.2"}) + if err != nil { + t.Fatal(err) + } + + prefs := readPrefs(t, dbPath) + if !prefs["copilot/gpt-4o"] { + t.Error("expected copilot/gpt-4o to stay shown") + } + }) + + t.Run("uses cached numeric IDs when available", func(t *testing.T) { + tmpDir := t.TempDir() + setTestHome(t, tmpDir) + t.Setenv("XDG_CONFIG_HOME", "") + cache := []map[string]any{ + { + "identifier": "ollama/Ollama/4", + "metadata": map[string]any{"vendor": "ollama", "name": "llama3.2"}, + }, + } + dbPath := setupDB(t, testVSCodePath(t, tmpDir, ""), nil, cache) + + err := v.ShowInModelPicker([]string{"llama3.2"}) + if err != nil { + t.Fatal(err) + } + + prefs := readPrefs(t, dbPath) + if !prefs["ollama/Ollama/4"] { + t.Error("expected numeric ID ollama/Ollama/4 to be shown") + } + // Name-based fallback should also be set + if !prefs["ollama/Ollama/llama3.2"] { + t.Error("expected name-based ID to also be shown") + } + }) + + t.Run("empty models is no-op", func(t *testing.T) { + err := v.ShowInModelPicker([]string{}) + if err != nil { + t.Fatal(err) + } + }) + + t.Run("previously hidden model is re-shown when configured", func(t *testing.T) { + tmpDir := t.TempDir() + setTestHome(t, tmpDir) + t.Setenv("XDG_CONFIG_HOME", "") + dbPath := setupDB(t, testVSCodePath(t, tmpDir, ""), map[string]bool{ + "ollama/Ollama/llama3.2": false, + "ollama/Ollama/llama3.2:latest": false, + }, nil) + + // Ollama config is authoritative — should override the hidden state + err := v.ShowInModelPicker([]string{"llama3.2"}) + if err != nil { + t.Fatal(err) + } + + prefs := readPrefs(t, dbPath) + if !prefs["ollama/Ollama/llama3.2"] { + t.Error("expected llama3.2 to be re-shown") + } + }) +} + +func TestParseCopilotChatVersion(t *testing.T) { + tests := []struct { + name string + output string + wantInstalled bool + wantVersion string + }{ + { + name: "found among other extensions", + output: "ms-python.python@2024.1.1\ngithub.copilot-chat@0.40.1\ngithub.copilot@1.200.0\n", + wantInstalled: true, + wantVersion: "0.40.1", + }, + { + name: "only extension", + output: "GitHub.copilot-chat@0.41.0\n", + wantInstalled: true, + wantVersion: "0.41.0", + }, + { + name: "not installed", + output: "ms-python.python@2024.1.1\ngithub.copilot@1.200.0\n", + wantInstalled: false, + }, + { + name: "empty output", + output: "", + wantInstalled: false, + }, + { + name: "case insensitive match", + output: "GitHub.Copilot-Chat@0.39.0\n", + wantInstalled: true, + wantVersion: "0.39.0", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + installed, version := parseCopilotChatVersion(tt.output) + if installed != tt.wantInstalled { + t.Errorf("installed = %v, want %v", installed, tt.wantInstalled) + } + if installed && version != tt.wantVersion { + t.Errorf("version = %q, want %q", version, tt.wantVersion) + } + }) + } +} + +func TestCompareVersions(t *testing.T) { + tests := []struct { + a, b string + want int + }{ + {"0.40.1", "0.40.1", 0}, + {"0.40.2", "0.40.1", 1}, + {"0.40.0", "0.40.1", -1}, + {"0.41.0", "0.40.1", 1}, + {"0.39.9", "0.40.1", -1}, + {"1.0.0", "0.40.1", 1}, + {"0.40", "0.40.1", -1}, + {"0.40.1.1", "0.40.1", 1}, + } + + for _, tt := range tests { + t.Run(tt.a+"_vs_"+tt.b, func(t *testing.T) { + got := compareVersions(tt.a, tt.b) + if got != tt.want { + t.Errorf("compareVersions(%q, %q) = %d, want %d", tt.a, tt.b, got, tt.want) + } + }) + } +} diff --git a/cmd/tui/tui.go b/cmd/tui/tui.go index 9f1ecfcb6..1f7bc5ec7 100644 --- a/cmd/tui/tui.go +++ b/cmd/tui/tui.go @@ -60,6 +60,9 @@ var mainMenuItems = []menuItem{ { integration: "openclaw", }, + { + integration: "vscode", + }, } var othersMenuItem = menuItem{ @@ -139,6 +142,7 @@ func otherIntegrationItems(state *launch.LauncherState) []menuItem { "claude": true, "codex": true, "openclaw": true, + "vscode": true, } var items []menuItem