diff --git a/internal/agent/agent.go b/internal/agent/agent.go index a8c80e22..2ac9bce0 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -53,6 +53,9 @@ type Field struct { // Quiet fields are left out of listings while empty: they follow // another field until set (Claude Code's per-tier models). Quiet bool + // Follows is the key of the field a Quiet one takes after while empty + // ("model" for Claude Code's tiers), for a profile's details to say so. + Follows string } // Agent is one supported coding agent. diff --git a/internal/agent/claude.go b/internal/agent/claude.go index 2e1b8996..327af72a 100644 --- a/internal/agent/claude.go +++ b/internal/agent/claude.go @@ -573,7 +573,7 @@ func claudeIn(at place) *Agent { }} for _, tier := range claudeTiers { fields = append(fields, Field{ - Key: tier, Label: tier, Quiet: true, + Key: tier, Label: tier, Quiet: true, Follows: "model", // empty while the tier follows the main model Get: func() string { if w := env(tierEnv(tier)); routed() && w != env("ANTHROPIC_MODEL") { diff --git a/internal/gui/assets/app.js b/internal/gui/assets/app.js index de0c0d0d..76c3d5d5 100644 --- a/internal/gui/assets/app.js +++ b/internal/gui/assets/app.js @@ -589,7 +589,10 @@ function profileDetail(p, footed) { }; for (const f of g.fields || []) { const effort = f.key === "effort" || f.label === "effort" || f.label === "thinking"; + // a Claude Code tier left to follow the main model (#480), and to what + const main = f.follows && g.fields.find((x) => x.key === f.follows && !x.hidden)?.value; if (f.hidden) row(t(f.label), "••••••", "pd-hidden"); + else if (f.follows) row(t(f.label), main ? t("follows the main model ({model})", { model: main }) : t("follows the main model"), "pd-default"); else if (!f.value) row(t(f.label), t("agent default"), "pd-default"); else row(t(f.label), effort ? effortName({ value: f.value }) : f.value); } diff --git a/internal/gui/assets/i18n.js b/internal/gui/assets/i18n.js index 7f694bbd..7c269be2 100644 --- a/internal/gui/assets/i18n.js +++ b/internal/gui/assets/i18n.js @@ -295,6 +295,8 @@ const I18N = { "Apply": "应用", "Apply {name} to the agents": "将 {name} 应用到各 Agent", "No agent settings saved": "没有保存任何 Agent 的设置", + "follows the main model": "跟随主模型", + "follows the main model ({model})": "跟随主模型({model})", "•••••• looks like a key or a token, and is not shown": "•••••• 看起来是密钥或令牌,不予显示", "Updated {name} to the current setup": "已用当前配置更新 {name}", "Profile name": "方案名称", diff --git a/internal/gui/tests/README.md b/internal/gui/tests/README.md index 6ef09c16..0ee5224c 100644 --- a/internal/gui/tests/README.md +++ b/internal/gui/tests/README.md @@ -347,8 +347,9 @@ field; Escape closes it; in English and Chinese. window and in the tray panel with its list scrolled to its end: the details open, applying nothing and leaving the chip where it was on the screen; they list each agent by name, its fields under their labels (an empty one as the -agent's default, one that reads as a key as dots) and what the Library gives -it; a second click closes them; their Apply applies the profile, once; ↻ and +agent's default, one that reads as a key as dots, all four of Claude Code's +tiers, one left empty as following the main model and which that is, #480) +and what the Library gives it; a second click closes them; their Apply applies the profile, once; ↻ and × stay on the chip. In Chromium and WebKit, English and Chinese: ```sh diff --git a/internal/gui/tests/profile-details.test.cjs b/internal/gui/tests/profile-details.test.cjs index cd375bc5..dc55a77e 100644 --- a/internal/gui/tests/profile-details.test.cjs +++ b/internal/gui/tests/profile-details.test.cjs @@ -4,7 +4,9 @@ // could be seen only by applying it over the setup in use). A click on the // chip opens its details: the agents' names, each one's fields under their // labels (an empty one the agent's default, one that reads as a key shown as -// dots), what the Library gives it; nothing is applied, and the chip clicked +// dots, each of Claude Code's four tiers, one left to follow the main model +// saying so and what that is: #480, they were missing), what the Library +// gives it; nothing is applied, and the chip clicked // stays where it is on the screen. Apply in the details applies it, once; a // second click on the chip closes them; ↻ and × are still on the chip. In the // window and in the tray panel (its list scrolled to its end), in English @@ -23,7 +25,14 @@ const work = { name: "work", summary: "codex gpt-5", library: { servers: 1, skills: 1, instructions: true }, agents: [ - { id: "claude", name: "Claude Code", fields: [{ key: "model", label: "model", value: "" }], servers: ["github"], skills: ["review"] }, + { id: "claude", name: "Claude Code", fields: [ + { key: "model", label: "model", value: "magpie/a/main" }, + { key: "opus", label: "opus", value: "", follows: "model" }, + { key: "sonnet", label: "sonnet", value: "magpie/a/s" }, + { key: "haiku", label: "haiku", value: "", follows: "model" }, + { key: "fable", label: "fable", value: "", follows: "model" }, + { key: "subagent", label: "subagents", value: "" }, + ], servers: ["github"], skills: ["review"] }, { id: "codex", name: "Codex", fields: [ { key: "model", label: "model", value: "magpie/openai/gpt-5" }, { key: "effort", label: "effort", value: "high" }, @@ -57,8 +66,8 @@ function server(lang, calls, count) { } const words = { - en: { model: "model", effort: "effort", high: "high", def: "agent default", servers: "MCP servers", skills: "Skills", instructions: "Instructions", on: "on", apply: "Apply" }, - zh: { model: "模型", effort: "推理强度", high: "高", def: "Agent 默认值", servers: "MCP 服务器", skills: "技能", instructions: "指令", on: "开启", apply: "应用" }, + en: { model: "model", effort: "effort", high: "high", def: "agent default", follows: "follows the main model (magpie/a/main)", subagents: "subagents", servers: "MCP servers", skills: "Skills", instructions: "Instructions", on: "on", apply: "Apply" }, + zh: { model: "模型", effort: "推理强度", high: "高", def: "Agent 默认值", follows: "跟随主模型(magpie/a/main)", subagents: "子 agent", servers: "MCP 服务器", skills: "技能", instructions: "指令", on: "开启", apply: "应用" }, }; for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", "webkit"])) { @@ -125,7 +134,7 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", assert.equal(read.apply, w.apply); assert(read.inView, "the details must be drawn"); assert.deepEqual(read.groups, [ - { name: "Claude Code", rows: [`${w.model}=${w.def}`, `${w.servers}=github`, `${w.skills}=review`] }, + { name: "Claude Code", rows: [`${w.model}=magpie/a/main`, `opus=${w.follows}`, "sonnet=magpie/a/s", `haiku=${w.follows}`, `fable=${w.follows}`, `${w.subagents}=${w.def}`, `${w.servers}=github`, `${w.skills}=review`] }, { name: "Codex", rows: [`${w.model}=magpie/openai/gpt-5`, `${w.effort}=${w.high}`, "token=••••••", `${w.instructions}=${w.on}`] }, ]); diff --git a/internal/profile/details.go b/internal/profile/details.go index c2ae9c0f..b03bebd9 100644 --- a/internal/profile/details.go +++ b/internal/profile/details.go @@ -24,13 +24,16 @@ type Group struct { Instructions bool `json:"instructions,omitempty"` } -// Item is one saved field. Value "" is the agent's own default. Hidden is a -// value that looked like a key or a token: it is left out, never shown. +// Item is one saved field. Value "" is the agent's own default, or, with +// Follows, the key of the field it takes after (a Claude Code tier that +// follows the main model, #480). Hidden is a value that looked like a key or +// a token: it is left out, never shown. type Item struct { - Key string `json:"key"` - Label string `json:"label"` - Value string `json:"value"` - Hidden bool `json:"hidden,omitempty"` + Key string `json:"key"` + Label string `json:"label"` + Value string `json:"value"` + Follows string `json:"follows,omitempty"` + Hidden bool `json:"hidden,omitempty"` } // secretName is a field named for a credential; secretValue a value that @@ -47,8 +50,11 @@ func secret(key, label, value string) bool { } // Details is what p holds, by agent: the agents magpie knows in the order it -// lists them, then any it no longer does by id. A quiet field left empty (it -// follows another one) is left out, as the agent's row leaves it out. +// lists them, then any it no longer does by id. A quiet field left empty is +// left out, as the agent's row leaves it out, unless it says which field it +// follows: Claude Code's four tiers are all there, one that follows the main +// model saying so (#480: opus and fable, following it, were missing, and +// read as not set). What applying the profile writes is unchanged. func Details(p Profile) []Group { known := map[string]*agent.Agent{} var order []string @@ -117,6 +123,9 @@ func Details(p Profile) []Group { } seen[f.Key] = true if f.Quiet && v == "" { + if f.Follows != "" { + g.Fields = append(g.Fields, Item{Key: f.Key, Label: f.Label, Follows: f.Follows}) + } continue } add(f.Key, f.Label, v) diff --git a/internal/profile/details_test.go b/internal/profile/details_test.go index 59b516d5..637bc3eb 100644 --- a/internal/profile/details_test.go +++ b/internal/profile/details_test.go @@ -11,7 +11,9 @@ import ( // A profile's details are its fields by agent, in the order magpie lists the // agents and their fields, under the agents' names and the fields' labels, -// with what the library gives each; an empty quiet field is left out, an +// with what the library gives each; an empty quiet field is left out, but +// Claude Code's four tiers are all there, an empty one following the main +// model (#480: opus and fable, following it, were missing); an // agent magpie no longer knows comes last by id, and nothing that reads as a // key or a token is in them (#467). func TestDetails(t *testing.T) { @@ -23,6 +25,9 @@ func TestDetails(t *testing.T) { "codex.login": "", // quiet: follows the model "claude.model": "opus", "claude.haiku": "", + "claude.opus": "", + "claude.sonnet": "magpie/a/s", + "claude.fable": "", "gone.model": "m", "gone.apiKey": "plain-looking", "codex.extra": "sk-live-abcdef", @@ -67,8 +72,22 @@ func TestDetails(t *testing.T) { if _, ok := item(codex, "login"); ok { t.Error("an empty quiet field is left out") } - if _, ok := item(claude, "haiku"); ok { - t.Error("an empty tier is left out") + var tiers []string + for _, it := range claude.Fields { + switch it.Key { + case "opus", "sonnet", "haiku", "fable": + tiers = append(tiers, it.Key) + want := Item{Key: it.Key, Label: it.Key, Follows: "model"} + if it.Key == "sonnet" { + want = Item{Key: "sonnet", Label: "sonnet", Value: "magpie/a/s"} + } + if it != want { + t.Errorf("tier %+v, want %+v", it, want) + } + } + } + if !slices.Equal(tiers, []string{"opus", "sonnet", "haiku", "fable"}) { + t.Errorf("tiers %v, want all four in Claude Code's order, an empty one following the model", tiers) } for _, c := range []struct { g Group