diff --git a/internal/agent/agent.go b/internal/agent/agent.go index f1d02a98..82186179 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -93,6 +93,11 @@ type Agent struct { // UA is what the agent's User-Agent begins with, lower-case: how the // gateway tells its requests from others' UA []string + // ListsModels: the agent's own model menu is the gateway's /v1/models + // as asked with its key, so the models picked on the Agents page for it + // (settings.HiddenModels) are its menu, though no field of its picks + // among the catalog (Cursor Private Inference) + ListsModels bool // Sync, for an agent that reads magpie's models from a file of its own // rather than asking the gateway, rewrites that list as the catalog is // now — where magpie wrote one; nothing else changes (see SyncCatalog). diff --git a/internal/agent/cursorlocal.go b/internal/agent/cursorlocal.go index 68412ee4..110214e9 100644 --- a/internal/agent/cursorlocal.go +++ b/internal/agent/cursorlocal.go @@ -242,7 +242,10 @@ func KeepCursorLocalEnv(context.Context) { func cursorLocal() *Agent { return &Agent{ ID: CursorLocalID, Name: cursorLocalName, Icon: "cursor", Aliases: []string{"cursor-private-inference"}, - detect: func() bool { return cursorLocalApp() != "" }, + // its model picker lists the gateway's /models as its key is shown + // them, which the row's Models button picks + ListsModels: true, + detect: func() bool { return cursorLocalApp() != "" }, Launch: func() string { if app := cursorLocalApp(); app != "" { return CursorLocalLaunch(app, gateway.URL()) diff --git a/internal/gateway/cursor_local_models_test.go b/internal/gateway/cursor_local_models_test.go index e7ebb88b..90f14dd9 100644 --- a/internal/gateway/cursor_local_models_test.go +++ b/internal/gateway/cursor_local_models_test.go @@ -72,4 +72,16 @@ func TestCursorLocalModels(t *testing.T) { } } } + + // the models picked on its row are its picker's: one taken out isn't + // listed with its key, and still is for another agent's + if err := provider.SetHiddenModels("cursor-local", []string{"ds/flash"}); err != nil { + t.Fatal(err) + } + if got := list(TokenFor("cursor-local")); got["ds/flash"] != nil || got["fake/m1"] == nil { + t.Errorf("with ds/flash taken out: %v", got) + } + if got := list(TokenFor("opencode")); got["ds/flash"] == nil { + t.Errorf("taken out of another agent's list: %v", got) + } } diff --git a/internal/gui/agent_models.go b/internal/gui/agent_models.go index 71787237..35124738 100644 --- a/internal/gui/agent_models.go +++ b/internal/gui/agent_models.go @@ -83,11 +83,13 @@ func agentFields(a *agent.Agent, vals map[string]string) []fieldJSON { } // agentModelCount is the line under an agent's name, nil for an agent that -// doesn't pick among the catalog. Its pickers list only the models shown, so +// doesn't pick among the catalog nor has its menu from the gateway's list +// (agent.ListsModels). Its pickers list only the models shown, so // with every one taken out they have no catalog entry left, yet the line is // the one way to put them back (#356): it stays while any is hidden. -func agentModelCount(id string, fields []fieldJSON) *modelCountJSON { - if takesCatalog(fields) { +func agentModelCount(a *agent.Agent, fields []fieldJSON) *modelCountJSON { + id := a.ID + if takesCatalog(fields) || a.ListsModels { return modelCount(id) } if len(provider.HiddenModels(id)) == 0 { diff --git a/internal/gui/agent_models_test.go b/internal/gui/agent_models_test.go index a495e18e..b619d833 100644 --- a/internal/gui/agent_models_test.go +++ b/internal/gui/agent_models_test.go @@ -98,7 +98,7 @@ func TestAgentModelLineAllHidden(t *testing.T) { if err != nil { t.Fatal(err) } - line := func() *modelCountJSON { return agentModelCount(a.ID, agentFields(a, a.Values())) } + line := func() *modelCountJSON { return agentModelCount(a, agentFields(a, a.Values())) } c := line() if c == nil || c.Shown != c.Listed || c.Listed < 2 { t.Fatalf("all shown: %+v", c) @@ -122,3 +122,36 @@ func TestAgentModelLineAllHidden(t *testing.T) { t.Fatalf("put back: %+v", c) } } + +// Cursor Private Inference has no field picking among the catalog, yet its +// own picker is the gateway's list as its key is shown it (mamba on +// Discord: its models couldn't be picked in magpie), so its row counts the +// models shown, as Claude Desktop's does, and its list picks them. +func TestCursorLocalModelLine(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config")) + t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache")) + if err := provider.Save(provider.Provider{ID: "relay", Name: "Relay", Key: "k", Chat: "http://127.0.0.1:1/v1", Models: []string{"m1", "m2"}}); err != nil { + t.Fatal(err) + } + a, err := agent.Find(agent.CursorLocalID) + if err != nil { + t.Fatal(err) + } + if takesCatalog(agentFields(a, a.Values())) { + t.Fatal("a field of its picks among the catalog; the case isn't reached") + } + line := func() *modelCountJSON { return agentModelCount(a, agentFields(a, a.Values())) } + c := line() + if c == nil || c.Shown != c.Listed || c.Listed < 2 { + t.Fatalf("no line, or not all shown: %+v", c) + } + if err := provider.SetHiddenModels(agent.CursorLocalID, []string{"relay/m2"}); err != nil { + t.Fatal(err) + } + if c := line(); c == nil || c.Shown != c.Listed-1 { + t.Fatalf("one taken out: %+v", c) + } +} diff --git a/internal/gui/api.go b/internal/gui/api.go index af4c8970..884f61b0 100644 --- a/internal/gui/api.go +++ b/internal/gui/api.go @@ -1292,7 +1292,7 @@ func state() stateJSON { } vals := a.Values() aj := agentJSON{ID: a.ID, Name: a.Name, Icon: a.Icon, Path: tilde(a.Path), Fields: agentFields(a, vals)} - aj.Models = agentModelCount(a.ID, aj.Fields) + aj.Models = agentModelCount(a, aj.Fields) aj.Drift = a.Drift() aj.Wired = a.Wired() if aj.Wired { diff --git a/internal/gui/assets/app.js b/internal/gui/assets/app.js index 2d360774..e392b6ba 100644 --- a/internal/gui/assets/app.js +++ b/internal/gui/assets/app.js @@ -856,9 +856,9 @@ const NATIVE_ONLY = { // the row const NO_PICKER = new Set(["gemini", "hermes", "morph", "agy", "muse"]); // agents whose own model menu is the list picked here: no one model to -// start on, but which of magpie's it offers (Claude Desktop reads -// /v1/models as it starts, and the user switches among them in it) -const MENU_FROM_LIST = new Set(["claude-desktop"]); +// start on, but which of magpie's it offers (Claude Desktop and Cursor +// Private Inference read /v1/models, and the user switches among them in it) +const MENU_FROM_LIST = new Set(["claude-desktop", "cursor-local"]); const menuFromList = (a) => MENU_FROM_LIST.has(a.id) && a.wired && !!a.models; // menuSaid: the models such an agent lists, by name while they are few diff --git a/internal/gui/tests/cursor-local-models.test.cjs b/internal/gui/tests/cursor-local-models.test.cjs new file mode 100644 index 00000000..300ddbc8 --- /dev/null +++ b/internal/gui/tests/cursor-local-models.test.cjs @@ -0,0 +1,148 @@ +// Run with Node's test runner and Playwright on the module path; see README.md. +// Cursor Private Inference's own model picker lists magpie's /v1/models as +// its key is shown them (mamba on Discord: connected through its +// environment variables, its models couldn't be picked in magpie): its +// connected row picks which models that picker lists, several at once, as +// Claude Desktop's does, beside the square copying its launch command. In +// the window the button beside its switch names them and opens the model +// list; in the tray panel the opened row has it. Unticking one is posted at +// once and the row says so. No native