Compare commits

...

2 Commits

Author SHA1 Message Date
Eva Ho
7a2306087b wip 2026-03-26 19:55:13 -04:00
Eva Ho
8b8bcf0952 launch: set default model as active selection in vscode copilot chat 2026-03-26 14:50:13 -04:00
2 changed files with 254 additions and 18 deletions

View File

@@ -361,9 +361,11 @@ func (v *VSCode) statePath() string {
} }
// ShowInModelPicker ensures the given models are visible in VS Code's Copilot // 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 // Chat model picker and sets the primary model as the active selection. It sets
// preferences so they appear in the dropdown. Models use the VS Code identifier // the configured models to true in the picker preferences so they appear in the
// format "ollama/Ollama/<name>". // dropdown, and writes the first model as the selected model for both the panel
// and editor chat views. Models use the VS Code identifier format
// "ollama/Ollama/<name>".
func (v *VSCode) ShowInModelPicker(models []string) error { func (v *VSCode) ShowInModelPicker(models []string) error {
if len(models) == 0 { if len(models) == 0 {
return nil return nil
@@ -400,22 +402,25 @@ func (v *VSCode) ShowInModelPicker(models []string) error {
// Build name→ID map from VS Code's cached model list. // 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". // VS Code uses numeric IDs like "ollama/Ollama/4", not "ollama/Ollama/kimi-k2.5:cloud".
nameToID := make(map[string]string) nameToID := make(map[string]string)
var cached []map[string]any
var cacheJSON string var cacheJSON string
if err := db.QueryRow("SELECT value FROM ItemTable WHERE key = 'chat.cachedLanguageModels.v2'").Scan(&cacheJSON); err == nil { if err := db.QueryRow("SELECT value FROM ItemTable WHERE key = 'chat.cachedLanguageModels.v2'").Scan(&cacheJSON); err == nil {
var cached []map[string]any _ = json.Unmarshal([]byte(cacheJSON), &cached)
if json.Unmarshal([]byte(cacheJSON), &cached) == nil { }
for _, entry := range cached { cachedNames := make(map[string]bool)
meta, _ := entry["metadata"].(map[string]any) for _, entry := range cached {
if meta == nil { meta, _ := entry["metadata"].(map[string]any)
continue if meta == nil {
} continue
if vendor, _ := meta["vendor"].(string); vendor == "ollama" { }
name, _ := meta["name"].(string) if vendor, _ := meta["vendor"].(string); vendor == "ollama" {
id, _ := entry["identifier"].(string) name, _ := meta["name"].(string)
if name != "" && id != "" { id, _ := entry["identifier"].(string)
nameToID[name] = id if name != "" && id != "" {
} nameToID[name] = id
} }
if name != "" {
cachedNames[name] = true
} }
} }
} }
@@ -440,10 +445,68 @@ func (v *VSCode) ShowInModelPicker(models []string) error {
return err return err
} }
// Set the primary model as the active selection in Copilot Chat so it
// doesn't default to "auto" or whatever the user last picked manually.
primaryID := v.modelVSCodeIDs(models[0], nameToID)[0]
for _, key := range []string{"chat.currentLanguageModel.panel", "chat.currentLanguageModel.editor"} {
if _, err := db.Exec("INSERT OR REPLACE INTO ItemTable (key, value) VALUES (?, ?)", key, primaryID); err != nil {
return err
}
if _, err := db.Exec("INSERT OR REPLACE INTO ItemTable (key, value) VALUES (?, ?)", key+".isDefault", "false"); err != nil {
return err
}
}
// Ensure configured models exist in the cached model list so VS Code can
// restore the selection immediately on startup, before extensions load.
// Without this, a model that was never previously used won't be in the
// cache, and VS Code falls back to "auto" until the Ollama BYOK provider
// discovers it via the API (which is slow).
cacheChanged := false
for _, m := range models {
if cachedNames[m] {
continue
}
if !strings.Contains(m, ":") && cachedNames[m+":latest"] {
continue
}
cacheID := m
if !strings.Contains(m, ":") {
cacheID = m + ":latest"
}
cached = append(cached, map[string]any{
"identifier": "ollama/Ollama/" + cacheID,
"metadata": map[string]any{
"extension": map[string]any{"value": "github.copilot-chat"},
"name": m,
"id": m,
"vendor": "ollama",
"version": "1.0.0",
"family": m,
"detail": "Ollama",
"maxInputTokens": 4096,
"maxOutputTokens": 4096,
"isDefaultForLocation": map[string]any{},
"isUserSelectable": true,
"capabilities": map[string]any{"toolCalling": true},
},
})
cacheChanged = true
}
if cacheChanged {
cacheData, _ := json.Marshal(cached)
if _, err := db.Exec("INSERT OR REPLACE INTO ItemTable (key, value) VALUES ('chat.cachedLanguageModels.v2', ?)", string(cacheData)); err != nil {
return err
}
}
return nil return nil
} }
// modelVSCodeIDs returns all possible VS Code picker IDs for a model name. // modelVSCodeIDs returns all possible VS Code picker IDs for a model name.
// The primary (first) ID should match the live identifier that VS Code assigns
// at runtime via toModelIdentifier(vendor, group, m.id), where m.id comes from
// /api/tags and always includes the tag (e.g. "llama3.2:latest").
func (v *VSCode) modelVSCodeIDs(model string, nameToID map[string]string) []string { func (v *VSCode) modelVSCodeIDs(model string, nameToID map[string]string) []string {
var ids []string var ids []string
if id, ok := nameToID[model]; ok { if id, ok := nameToID[model]; ok {
@@ -453,10 +516,13 @@ func (v *VSCode) modelVSCodeIDs(model string, nameToID map[string]string) []stri
ids = append(ids, id) ids = append(ids, id)
} }
} }
ids = append(ids, "ollama/Ollama/"+model) // For untagged models, the live identifier includes :latest
// (e.g. ollama/Ollama/llama3.2:latest), so prefer that format
// to avoid a mismatch that causes VS Code to reset to "auto".
if !strings.Contains(model, ":") { if !strings.Contains(model, ":") {
ids = append(ids, "ollama/Ollama/"+model+":latest") ids = append(ids, "ollama/Ollama/"+model+":latest")
} }
ids = append(ids, "ollama/Ollama/"+model)
return ids return ids
} }

View File

@@ -388,6 +388,71 @@ func TestShowInModelPicker(t *testing.T) {
} }
}) })
// helper to read a string value from the state DB
readValue := func(t *testing.T, dbPath, key string) string {
t.Helper()
db, err := sql.Open("sqlite3", dbPath)
if err != nil {
t.Fatal(err)
}
defer db.Close()
var val string
if err := db.QueryRow("SELECT value FROM ItemTable WHERE key = ?", key).Scan(&val); err != nil {
return ""
}
return val
}
t.Run("sets primary model as active selection", func(t *testing.T) {
tmpDir := t.TempDir()
setTestHome(t, tmpDir)
t.Setenv("XDG_CONFIG_HOME", "")
setupDB(t, testVSCodePath(t, tmpDir, ""), nil, nil)
err := v.ShowInModelPicker([]string{"llama3.2", "qwen3:8b"})
if err != nil {
t.Fatal(err)
}
dbPath := testVSCodePath(t, tmpDir, filepath.Join("globalStorage", "state.vscdb"))
panelModel := readValue(t, dbPath, "chat.currentLanguageModel.panel")
if panelModel != "ollama/Ollama/llama3.2:latest" {
t.Errorf("expected panel model ollama/Ollama/llama3.2:latest, got %q", panelModel)
}
editorModel := readValue(t, dbPath, "chat.currentLanguageModel.editor")
if editorModel != "ollama/Ollama/llama3.2:latest" {
t.Errorf("expected editor model ollama/Ollama/llama3.2:latest, got %q", editorModel)
}
panelDefault := readValue(t, dbPath, "chat.currentLanguageModel.panel.isDefault")
if panelDefault != "false" {
t.Errorf("expected panel isDefault false, got %q", panelDefault)
}
})
t.Run("sets cached numeric ID as active selection", 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"},
},
}
setupDB(t, testVSCodePath(t, tmpDir, ""), nil, cache)
err := v.ShowInModelPicker([]string{"llama3.2"})
if err != nil {
t.Fatal(err)
}
dbPath := testVSCodePath(t, tmpDir, filepath.Join("globalStorage", "state.vscdb"))
panelModel := readValue(t, dbPath, "chat.currentLanguageModel.panel")
if panelModel != "ollama/Ollama/4" {
t.Errorf("expected panel model to use cached numeric ID ollama/Ollama/4, got %q", panelModel)
}
})
t.Run("previously hidden model is re-shown when configured", func(t *testing.T) { t.Run("previously hidden model is re-shown when configured", func(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
setTestHome(t, tmpDir) setTestHome(t, tmpDir)
@@ -408,6 +473,111 @@ func TestShowInModelPicker(t *testing.T) {
t.Error("expected llama3.2 to be re-shown") t.Error("expected llama3.2 to be re-shown")
} }
}) })
// helper to read and parse the cached models from the state DB
readCache := func(t *testing.T, dbPath string) []map[string]any {
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 = 'chat.cachedLanguageModels.v2'").Scan(&raw); err != nil {
return nil
}
var result []map[string]any
_ = json.Unmarshal([]byte(raw), &result)
return result
}
t.Run("adds uncached model to cache for instant startup display", func(t *testing.T) {
tmpDir := t.TempDir()
setTestHome(t, tmpDir)
t.Setenv("XDG_CONFIG_HOME", "")
// No seed cache — model has never been used in VS Code before
dbPath := setupDB(t, testVSCodePath(t, tmpDir, ""), nil, nil)
err := v.ShowInModelPicker([]string{"qwen3:8b"})
if err != nil {
t.Fatal(err)
}
cache := readCache(t, dbPath)
if len(cache) != 1 {
t.Fatalf("expected 1 cached entry, got %d", len(cache))
}
entry := cache[0]
if id, _ := entry["identifier"].(string); id != "ollama/Ollama/qwen3:8b" {
t.Errorf("expected identifier ollama/Ollama/qwen3:8b, got %q", id)
}
meta, _ := entry["metadata"].(map[string]any)
if meta == nil {
t.Fatal("expected metadata in cache entry")
}
if v, _ := meta["vendor"].(string); v != "ollama" {
t.Errorf("expected vendor ollama, got %q", v)
}
if sel, ok := meta["isUserSelectable"].(bool); !ok || !sel {
t.Error("expected isUserSelectable to be true")
}
})
t.Run("does not duplicate already-cached model", 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"},
},
{
"identifier": "copilot/copilot/auto",
"metadata": map[string]any{"vendor": "copilot", "name": "Auto"},
},
}
dbPath := setupDB(t, testVSCodePath(t, tmpDir, ""), nil, cache)
err := v.ShowInModelPicker([]string{"llama3.2"})
if err != nil {
t.Fatal(err)
}
// Cache should still have exactly 2 entries (no duplicate added)
result := readCache(t, dbPath)
if len(result) != 2 {
t.Errorf("expected 2 cached entries (no duplicate), got %d", len(result))
}
})
t.Run("adds only missing models to existing cache", 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)
// llama3.2 is cached, qwen3:8b is not
err := v.ShowInModelPicker([]string{"llama3.2", "qwen3:8b"})
if err != nil {
t.Fatal(err)
}
result := readCache(t, dbPath)
if len(result) != 2 {
t.Fatalf("expected 2 cached entries, got %d", len(result))
}
// Second entry should be the newly added qwen3:8b
if id, _ := result[1]["identifier"].(string); id != "ollama/Ollama/qwen3:8b" {
t.Errorf("expected new entry ollama/Ollama/qwen3:8b, got %q", id)
}
})
} }
func TestParseCopilotChatVersion(t *testing.T) { func TestParseCopilotChatVersion(t *testing.T) {