diff --git a/internal/agent/agent.go b/internal/agent/agent.go index 52f2d3f1..5af6199b 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -55,6 +55,9 @@ type Option struct { // own: served on the agent's own sign-in (viaMagpie), for Same own bool + // sub: served on a subscription signed in in magpie, not a key; a + // Claude account only for Claude Code, which alone may use it safely + sub bool } // Field is one tunable setting of an agent. Set with an empty value puts diff --git a/internal/agent/applied.go b/internal/agent/applied.go index fda0d561..e27a52b9 100644 --- a/internal/agent/applied.go +++ b/internal/agent/applied.go @@ -295,32 +295,91 @@ func (a *Agent) Connect() error { if len(a.Fields) == 0 || a.Wired() { return nil } - f := a.Fields[0] - if f.Options == nil { - return fmt.Errorf("%s can't be connected to magpie", a.Name) - } + // the field magpie is picked in: the one listing magpie's models (for + // Gemini CLI its model, its first field being how it signs in), else one + // whose value is magpie itself vals := a.Values() - cur, _, _ := a.split(vals[f.Key]) - if i := strings.LastIndex(cur, "/"); i >= 0 { - cur = cur[i+1:] - } - pick, same := "", "" - for _, o := range f.Options(vals) { - switch { - case o.Value == magpieID: - return a.Apply(f.Key, magpieID) - case o.Ref == "" || o.Group == RoutingGroups: - case pick == "": - pick = o.Value - fallthrough - case same == "" && cur != "" && strings.HasSuffix(o.Ref, "/"+cur): - if strings.HasSuffix(o.Ref, "/"+cur) { - same = o.Value + var f *Field + var opts []Option + for pass := 0; pass < 2 && f == nil; pass++ { + for i := range a.Fields { + if a.Fields[i].Options == nil { + continue + } + list := a.Fields[i].Options(vals) + for _, o := range list { + if pass == 0 && o.Ref != "" || pass == 1 && o.Value == magpieID { + f, opts = &a.Fields[i], list + break + } + } + if f != nil { + break } } } - if same != "" { + if f == nil { + return fmt.Errorf("%s can't be connected to magpie", a.Name) + } + cur := connectWas(vals[f.Key], opts) + if cur == "" && f.Key != a.Fields[0].Key { + cur = connectWas(a.Values()[a.Fields[0].Key], nil) + } + if cur == "" { + // on its default: the first of its own models + for _, o := range opts { + if o.Ref == "" && o.Group == a.Name && o.Value != magpieID { + cur = connectWas(o.Value, opts) + break + } + } + } + // the model it is on now, through magpie (or of its family, for an + // alias): on the account it is signed in to (its vendor's) first, then + // on a subscription, before a key's; else its own vendor's; else a + // subscription's; else the first + var pick, same, alike, own, sub string + sameRank, alikeRank := -1, -1 + for _, o := range opts { + if o.Value == magpieID { + return a.Apply(f.Key, magpieID) + } + if o.Ref == "" || o.Group == RoutingGroups { + continue + } + rank := 0 + if o.own || o.Group == a.Name { + rank = 2 + } else if o.sub { + rank = 1 + } + if pick == "" { + pick = o.Value + } + id := o.Ref[strings.LastIndex(o.Ref, "/")+1:] + if cur != "" && (id == cur || strings.TrimSuffix(id, "[1m]") == cur) && rank > sameRank { + same, sameRank = o.Value, rank + } + // an alias (opus) no label read: the model of that family + if cur != "" && !strings.Contains(cur, "-") && strings.Contains(id+"-", "-"+cur+"-") && rank > alikeRank { + alike, alikeRank = o.Value, rank + } + if own == "" && (o.own || o.Group == a.Name) { + own = o.Value + } + if sub == "" && o.sub { + sub = o.Value + } + } + switch { + case same != "": pick = same + case alike != "": + pick = alike + case own != "": + pick = own + case sub != "": + pick = sub } if pick == "" { return fmt.Errorf("magpie has no models %s can use: add a subscription or a provider first", a.Name) @@ -328,6 +387,24 @@ func (a *Agent) Connect() error { return a.Apply(f.Key, pick) } +// connectWas is the model an agent is on before it is connected, as a +// model id: its own option's alias read through the label that names the +// model ("opus · claude-opus-5-5"), a provider's prefix taken off. +func connectWas(v string, opts []Option) string { + for _, o := range opts { + if o.Value == v && o.Ref == "" { + if _, id, ok := strings.Cut(o.Label, " · "); ok { + v = strings.TrimSpace(id) + } + break + } + } + if i := strings.LastIndex(v, "/"); i >= 0 { + v = v[i+1:] + } + return strings.TrimSpace(v) +} + // Disconnect takes magpie out of the agent's config and puts back what the // user had, the Agents page's "Disconnect from magpie" (Fate on Discord: // picking the agent's default did it, but nothing said so). Unwire first, diff --git a/internal/agent/connect_pick_test.go b/internal/agent/connect_pick_test.go new file mode 100644 index 00000000..1d0580a4 --- /dev/null +++ b/internal/agent/connect_pick_test.go @@ -0,0 +1,113 @@ +package agent + +import ( + "os" + "path/filepath" + "testing" + + "github.com/yetone/magpie/internal/edit" + "github.com/yetone/magpie/internal/provider" +) + +// The 「接入」 switch keeps an agent on the model it was on, now through +// magpie: Claude Code on its opus alias goes to the Claude Opus magpie +// serves, not to the first model of the first provider. +func TestConnectKeepsTheModel(t *testing.T) { + home, _ := codexHome(t, "", "") + if err := provider.Save(provider.Provider{ID: "aaa", Name: "AAA", Chat: "https://a.example/v1", Key: "k", Models: []string{"first", "claude-opus-5-5"}}); err != nil { + t.Fatal(err) + } + path := filepath.Join(home, ".claude", "settings.json") + writeFile(t, path, `{"model": "opus"}`) + c := claude(home) + if err := c.Connect(); err != nil { + t.Fatal(err) + } + if m, _ := edit.GetJSON(path, "model"); m != "aaa/claude-opus-5-5" { + t.Fatalf("connected on %q:\n%s", m, readFile(path)) + } +} + +// Gemini CLI is connected through its model (its first field is how it +// signs in), and Disconnect leaves its settings.json as they were, with +// no "model": {} behind. +func TestGeminiConnectRoundTrip(t *testing.T) { + home, _ := codexHome(t, "", "") + if err := provider.Save(provider.Provider{ID: "aaa", Name: "AAA", Chat: "https://a.example/v1", Key: "k", Models: []string{"m1"}}); err != nil { + t.Fatal(err) + } + dir := filepath.Join(home, ".gemini") + const was = `{"security": {"auth": {"selectedType": "oauth-personal"}}}` + writeFile(t, filepath.Join(dir, "settings.json"), was) + g := gemini(home) + if err := g.Connect(); err != nil { + t.Fatal(err) + } + if !g.Wired() { + t.Fatalf("not connected:\n%s", readFile(filepath.Join(dir, "settings.json"))) + } + if err := g.Disconnect(); err != nil { + t.Fatal(err) + } + if v, ok := edit.GetJSON(filepath.Join(dir, "settings.json"), "model"); ok { + t.Fatalf("model left: %q", v) + } + if a, _ := edit.GetJSON(filepath.Join(dir, "settings.json"), "security.auth.selectedType"); a != "oauth-personal" { + t.Fatalf("sign-in %q", a) + } + if _, err := os.Stat(filepath.Join(dir, ".env")); err == nil { + if k, _ := edit.GetEnvFile(filepath.Join(dir, ".env"), "GEMINI_API_KEY"); k != "" { + t.Fatalf("magpie's key left: %q", k) + } + } +} + +// Of the providers serving the model the agent is on, Connect takes the +// account it is signed in to, then a subscription, over a relay listed +// first; on no model of its own, a subscription's before the first. +func TestConnectPrefersTheSubscription(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config")) + t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache")) + for _, c := range []struct { + cur string + opts []Option + want string + }{ + {"claude-opus-5-5", []Option{ + {Value: "group/auto", Ref: "group/auto", Group: RoutingGroups}, + {Value: "relay/claude-opus-5-5[1m]", Ref: "relay/claude-opus-5-5[1m]", Group: "Relay"}, + {Value: "other/claude-opus-5-5", Ref: "other/claude-opus-5-5", Group: "Other", sub: true}, + {Value: "claude/claude-opus-5-5", Ref: "claude/claude-opus-5-5", Group: "Claude", sub: true, own: true}, + }, "claude/claude-opus-5-5"}, + {"opus", []Option{ + {Value: "relay/claude-opus-5-5[1m]", Ref: "relay/claude-opus-5-5", Group: "Relay"}, + {Value: "claude/claude-opus-5-5[1m]", Ref: "claude/claude-opus-5-5", Group: "X"}, + }, "claude/claude-opus-5-5[1m]"}, + {"claude-opus-5-5", []Option{ + {Value: "relay/claude-opus-5-5", Ref: "relay/claude-opus-5-5", Group: "Relay"}, + {Value: "other/claude-opus-5-5", Ref: "other/claude-opus-5-5", Group: "Other", sub: true}, + }, "other/claude-opus-5-5"}, + {"", []Option{ + {Value: "stepfun/step-1", Ref: "stepfun/step-1", Group: "StepFun"}, + {Value: "claude/claude-sonnet-5-5", Ref: "claude/claude-sonnet-5-5", Group: "Claude", sub: true}, + }, "claude/claude-sonnet-5-5"}, + {"", []Option{ + {Value: "stepfun/step-1", Ref: "stepfun/step-1", Group: "StepFun"}, + }, "stepfun/step-1"}, + } { + v := c.cur + a := &Agent{ID: "x", Name: "X", Fields: []Field{{Key: "model", + Get: func() string { return v }, + Set: func(s string) error { v = s; return nil }, + Options: func(map[string]string) []Option { return c.opts }, + }}} + if err := a.Connect(); err != nil { + t.Fatal(err) + } + if v != c.want { + t.Errorf("on %q: connected on %q, want %q", c.cur, v, c.want) + } + } +} diff --git a/internal/agent/connectinfo.go b/internal/agent/connectinfo.go new file mode 100644 index 00000000..bb1b51f0 --- /dev/null +++ b/internal/agent/connectinfo.go @@ -0,0 +1,150 @@ +package agent + +import ( + "encoding/json" + "os" + "path/filepath" + "runtime" + "strconv" + "strings" + "time" + + "github.com/yetone/magpie/internal/edit" + "github.com/yetone/magpie/internal/proc" +) + +// What the Agents page says beside an agent's 「接入」 switch (the owner's +// design): before it is on, what the agent runs on now — its own +// subscription, sign-in or providers — so the user can tell what turning +// it on touches; once on, whether copies of it already running still have +// the list they started with. + +// Source is what an agent not connected to magpie runs on now: "sub" (a +// Claude subscription), "chatgpt" (a ChatGPT sign-in), "key" (an API key +// of its own), "providers:N" (N providers of its own); "" when unknown. +func (a *Agent) Source() string { + if a.WSL != "" || a.Path == "" { + return "" + } + dir := filepath.Dir(a.Path) + switch a.ID { + case "claude": + // signed in to claude.ai: ~/.claude.json (or the one in + // CLAUDE_CONFIG_DIR) has the account + for _, p := range []string{filepath.Join(dir, ".claude.json"), filepath.Join(filepath.Dir(dir), ".claude.json")} { + if v, _ := edit.GetJSON(p, "oauthAccount.accountUuid"); v != "" { + return "sub" + } + } + if v, _ := edit.GetJSON(a.Path, "env.ANTHROPIC_API_KEY"); v != "" { + return "key" + } + if v, _ := edit.GetJSON(a.Path, "apiKeyHelper"); v != "" { + return "key" + } + case "codex": + if codexChatGPT(dir) { + return "chatgpt" + } + if v, _ := edit.GetJSON(filepath.Join(dir, "auth.json"), "OPENAI_API_KEY"); v != "" { + return "key" + } + case "opencode", "mimocode": + // the providers in its config, and those signed in through it + n := map[string]bool{} + var cfg map[string]json.RawMessage + if v, _ := edit.GetJSON(a.Path, "provider"); json.Unmarshal([]byte(v), &cfg) == nil { + for k := range cfg { + n[k] = true + } + } + home, _ := os.UserHomeDir() + var auth map[string]json.RawMessage + if b, err := os.ReadFile(filepath.Join(home, ".local", "share", a.ID, "auth.json")); err == nil && json.Unmarshal(b, &auth) == nil { + for k := range auth { + n[k] = true + } + } + delete(n, magpieID) + if len(n) > 0 { + return "providers:" + strconv.Itoa(len(n)) + } + } + return "" +} + +// startsWith is, for an agent that reads magpie's models only as it +// starts, how to find its processes (patterns for pgrep -f) and the files +// whose change they missed. +func (a *Agent) startsWith() (pats []string, files []string) { + switch a.ID { + case "codex": + return []string{`(^|/)codex( |$)`}, []string{a.Path, filepath.Join(filepath.Dir(a.Path), "magpie-models.json")} + case "claude": + return []string{`(^|/)claude( |$)`}, []string{a.Path} + } + return nil, nil +} + +// Stale is how many copies of the agent are running that started before +// magpie last changed what it reads at start: they still have the list +// they started with until reopened. Zero where it can't be told (Windows). +func (a *Agent) Stale() int { + pats, files := a.startsWith() + if len(pats) == 0 || a.WSL != "" || runtime.GOOS == "windows" { + return 0 + } + var changed time.Time + for _, f := range files { + if st, err := os.Stat(f); err == nil && st.ModTime().After(changed) { + changed = st.ModTime() + } + } + if changed.IsZero() { + return 0 + } + n := 0 + for _, pat := range pats { + out, _ := proc.Command("pgrep", "-f", pat).Output() + pids := strings.Fields(string(out)) + if len(pids) == 0 { + continue + } + out, _ = proc.Command("ps", "-o", "etime=", "-p", strings.Join(pids, ",")).Output() + for _, l := range strings.Split(strings.TrimSpace(string(out)), "\n") { + if up, ok := elapsed(l); ok && time.Now().Add(-up).Before(changed.Add(-time.Second)) { + n++ + } + } + } + return n +} + +// elapsed reads ps's etime, [[dd-]hh:]mm:ss. +func elapsed(s string) (time.Duration, bool) { + s = strings.TrimSpace(s) + if s == "" { + return 0, false + } + var days int + if d, rest, ok := strings.Cut(s, "-"); ok { + n, err := strconv.Atoi(d) + if err != nil { + return 0, false + } + days, s = n, rest + } + parts := strings.Split(s, ":") + if len(parts) < 2 || len(parts) > 3 { + return 0, false + } + secs := 0 + for _, p := range parts { + n, err := strconv.Atoi(p) + if err != nil { + return 0, false + } + secs = secs*60 + n + } + return time.Duration(days)*24*time.Hour + time.Duration(secs)*time.Second, true +} diff --git a/internal/agent/gemini.go b/internal/agent/gemini.go index 6d5bb3e8..303a0b36 100644 --- a/internal/agent/gemini.go +++ b/internal/agent/gemini.go @@ -68,7 +68,7 @@ func gemini(home string) *Agent { if m := unstash("gemini.model"); m != "" { return edit.SetJSON(path, edit.KV{Path: "model.name", Value: m}) } - return edit.DelJSON(path, "model.name") + return delModelName(path) } setModel := func(v string) error { if v == "" { @@ -77,9 +77,11 @@ func gemini(home string) *Agent { if err := edit.DelEnvFile(envPath, "GOOGLE_GEMINI_BASE_URL", "GEMINI_API_KEY"); err != nil { return err } - return edit.DelJSON(path, "security.auth.selectedType", "model.name") + if err := edit.DelJSON(path, "security.auth.selectedType"); err != nil { + return err + } } - return edit.DelJSON(path, "model.name") + return delModelName(path) } if isMagpie(v) { if !routed() { @@ -245,3 +247,16 @@ func gemini(home string) *Agent { }, } } + +// delModelName takes model.name out of Gemini CLI's settings.json, and the +// model it was in once nothing else is there: the "model": {} it would +// leave was never the user's. +func delModelName(path string) error { + if err := edit.DelJSON(path, "model.name"); err != nil { + return err + } + if v, _ := edit.GetJSON(path, "model"); strings.TrimSpace(strings.Trim(strings.TrimSpace(v), "{}")) == "" && strings.HasPrefix(strings.TrimSpace(v), "{") { + return edit.DelJSON(path, "model") + } + return nil +} diff --git a/internal/agent/preview.go b/internal/agent/preview.go new file mode 100644 index 00000000..38c5bce4 --- /dev/null +++ b/internal/agent/preview.go @@ -0,0 +1,427 @@ +package agent + +import ( + "bytes" + "context" + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "regexp" + "strings" + "time" + + "github.com/yetone/magpie/internal/agentenv" + "github.com/yetone/magpie/internal/appdir" + "github.com/yetone/magpie/internal/proc" +) + +// Before an agent is disconnected the user is shown what changes in its +// files, line by line (the owner's 断开 design: 删除、改回、保留), not a +// sentence about it. What Disconnect does depends on the stash, the record +// and each agent's own unwiring, so rather than a second account of it that +// could say otherwise, magpie runs Disconnect itself on a copy: the agent's +// config folder and magpie's own files under a temporary home, in a process +// of its own (DryRunArg), and compares the copy with the files as they are. + +// DryRunArg is the command line's first word for the process that +// disconnects an agent on a copy of its files (main.go). +const DryRunArg = "agent-disconnect-dry-run" + +// FileChange is how disconnecting changes one of the agent's files. +type FileChange struct { + Path string `json:"path"` // as the user knows it, ~ for home + Removed bool `json:"removed,omitempty"` // the file goes + Created bool `json:"created,omitempty"` // the file is made + Lines []LineDiff `json:"lines,omitempty"` +} + +// LineDiff is a line of a file disconnecting changes: Op is "-" for a line +// taken out, "+" for one put in, "~" for a line whose value goes back to +// another (Was is the line now). +type LineDiff struct { + Op string `json:"op"` + Text string `json:"text"` + Was string `json:"was,omitempty"` +} + +// DisconnectPreview is what Disconnect would change in the agent's files, +// found by running it on a copy. An error is a preview not to be had (the +// agent's files outside home, a WSL agent): the dialog then says it in +// words. +func DisconnectPreview(a *Agent, exe string) ([]FileChange, error) { + if a.WSL != "" || a.Path == "" { + return nil, errors.New("no preview for this agent") + } + if appdir.Portable() != "" { + return nil, errors.New("no preview for a portable magpie") + } + home, err := os.UserHomeDir() + if err != nil { + return nil, err + } + rel := func(p string) (string, bool) { + r, err := filepath.Rel(home, p) + return r, err == nil && r != "." && !strings.HasPrefix(r, "..") + } + dir := filepath.Dir(a.Path) + dirRel, ok := rel(dir) + cfg := appdir.Config() + cfgRel, ok2 := rel(cfg) + if !ok || !ok2 { + return nil, errors.New("the agent's files are outside home") + } + tmp, err := os.MkdirTemp("", "magpie-preview-") + if err != nil { + return nil, err + } + defer os.RemoveAll(tmp) + tmp, _ = filepath.EvalSymlinks(tmp) + // the agent's folder: its files, copied; its folders aren't its config + before := map[string][]byte{} + if err := copyTop(dir, filepath.Join(tmp, dirRel), before, false); err != nil { + return nil, err + } + // magpie's: its files copied (the stash, the record, the providers), + // its folders (plugins) only read, so linked + if err := copyTop(cfg, filepath.Join(tmp, cfgRel), nil, true); err != nil { + return nil, err + } + env := previewEnv(home, tmp) + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + cmd := proc.CommandContext(ctx, exe, DryRunArg, a.ID) + cmd.Env = env + var out bytes.Buffer + cmd.Stdout, cmd.Stderr = &out, &out + if err := cmd.Run(); err != nil { + return nil, fmt.Errorf("dry run: %v: %s", err, strings.TrimSpace(out.String())) + } + // what the agent's folder holds now against what it held + var changes []FileChange + copied := filepath.Join(tmp, dirRel) + after := map[string][]byte{} + entries, _ := os.ReadDir(copied) + for _, e := range entries { + if e.Type().IsRegular() { + if b, err := os.ReadFile(filepath.Join(copied, e.Name())); err == nil { + after[e.Name()] = b + } + } + } + shown := func(name string) string { return "~/" + filepath.ToSlash(filepath.Join(dirRel, name)) } + // the agent's own file first, then the others by name + names := []string{filepath.Base(a.Path)} + for n := range before { + if n != names[0] { + names = append(names, n) + } + } + for n := range after { + if _, ok := before[n]; !ok { + names = append(names, n) + } + } + sortTail(names) + for _, n := range names { + was, had := before[n] + now, has := after[n] + switch { + case had && !has: + changes = append(changes, FileChange{Path: shown(n), Removed: true}) + case !had && has: + changes = append(changes, FileChange{Path: shown(n), Created: true, Lines: diffLines("", string(now))}) + case had && has && !bytes.Equal(was, now): + if l := diffLines(string(was), string(now)); len(l) > 0 { + changes = append(changes, FileChange{Path: shown(n), Lines: l}) + } + } + } + return changes, nil +} + +// sortTail sorts all but the first name. +func sortTail(names []string) { + for i := 2; i < len(names); i++ { + for j := i; j > 1 && names[j] < names[j-1]; j-- { + names[j], names[j-1] = names[j-1], names[j] + } + } +} + +// copyTop copies a folder's files into to, keeping what they held in +// before when it is given; with link, its folders are linked there. +func copyTop(from, to string, before map[string][]byte, link bool) error { + if err := os.MkdirAll(to, 0o700); err != nil { + return err + } + entries, err := os.ReadDir(from) + if errors.Is(err, fs.ErrNotExist) { + return nil + } + if err != nil { + return err + } + const most = 16 << 20 + for _, e := range entries { + p := filepath.Join(from, e.Name()) + st, err := os.Stat(p) // a linked file is its target + if err != nil { + continue + } + if st.IsDir() { + if link { + os.Symlink(p, filepath.Join(to, e.Name())) + } + continue + } + if !st.Mode().IsRegular() || st.Size() > most || notConfig(e.Name()) { + continue + } + b, err := os.ReadFile(p) + if err != nil { + continue + } + if err := os.WriteFile(filepath.Join(to, e.Name()), b, 0o600); err != nil { + return err + } + if before != nil { + before[e.Name()] = b + } + } + return nil +} + +// notConfig is a file no agent keeps its settings in: a database, a log, a +// history, which can be large and which Disconnect never writes. +func notConfig(name string) bool { + for _, ext := range []string{".sqlite", ".sqlite-wal", ".sqlite-shm", ".db", ".db-wal", ".db-shm", ".jsonl", ".log"} { + if strings.HasSuffix(name, ext) { + return true + } + } + return false +} + +// previewEnv is this process's environment with home, and the folders under +// it, moved to the copy. +func previewEnv(home, tmp string) []string { + moved := map[string]bool{"HOME": true, "USERPROFILE": true, "XDG_CONFIG_HOME": true, "XDG_DATA_HOME": true, "XDG_STATE_HOME": true, "APPDATA": true, "LOCALAPPDATA": true} + for _, k := range agentenv.Vars { + moved[k] = true + } + var out []string + for _, kv := range os.Environ() { + k, v, _ := strings.Cut(kv, "=") + if !moved[k] { + out = append(out, kv) + continue + } + if k == "HOME" || k == "USERPROFILE" { + continue + } + // a folder under home is the same one under the copy's; one + // elsewhere isn't copied, so it is left out + if r, err := filepath.Rel(home, v); err == nil && !strings.HasPrefix(r, "..") { + out = append(out, k+"="+filepath.Join(tmp, r)) + } + } + return append(out, "HOME="+tmp, "USERPROFILE="+tmp) +} + +// diffLines is the lines that differ between two texts, in the order of the +// new one: a line taken out, one put in, and a line whose key stays with its +// value changed (model = "x" → model = "y") as one going back. Values that +// read as secrets are masked. Between the lines both start and end with, +// texts too far apart to compare line by line (OpenCode's table of +// thousands of magpie models taken out) keep the lines of the new one +// found in order in the old. +func diffLines(a, b string) []LineDiff { + x, y := splitLines(a), splitLines(b) + pre := 0 + for pre < len(x) && pre < len(y) && x[pre] == y[pre] { + pre++ + } + suf := 0 + for suf < len(x)-pre && suf < len(y)-pre && x[len(x)-1-suf] == y[len(y)-1-suf] { + suf++ + } + keepX, keepY := make([]bool, len(x)), make([]bool, len(y)) + for i := 0; i < pre; i++ { + keepX[i], keepY[i] = true, true + } + for i := 1; i <= suf; i++ { + keepX[len(x)-i], keepY[len(y)-i] = true, true + } + mx, my := x[pre:len(x)-suf], y[pre:len(y)-suf] + if kx, ky, ok := sameLines(mx, my, 4000); ok { + copy(keepX[pre:], kx) + copy(keepY[pre:], ky) + } else if len(mx)*len(my) <= 50_000_000 { + // each line of the one, in order, kept where the other has it + // next: what is left of a file once a block is taken out of it + // (past that, all of it taken out and put in) + i := 0 + for j := range my { + for k := i; k < len(mx); k++ { + if mx[k] == my[j] { + keepX[pre+k], keepY[pre+j] = true, true + i = k + 1 + break + } + } + } + } + var out []LineDiff + var gone, came []string + flush := func() { + // a line taken out and one put in under the same key are one + // going back + for _, c := range came { + k := lineKey(c) + paired := false + for i, g := range gone { + if k != "" && lineKey(g) == k { + out = append(out, LineDiff{Op: "~", Text: mask(c), Was: mask(g)}) + gone = append(gone[:i], gone[i+1:]...) + paired = true + break + } + } + if !paired { + out = append(out, LineDiff{Op: "+", Text: mask(c)}) + } + } + for _, g := range gone { + out = append(out, LineDiff{Op: "-", Text: mask(g)}) + } + gone, came = nil, nil + } + i, j := 0, 0 + for i < len(x) || j < len(y) { + switch { + case i < len(x) && j < len(y) && keepX[i] && keepY[j]: + flush() + i++ + j++ + case i < len(x) && !keepX[i]: + if strings.TrimSpace(x[i]) != "" { + gone = append(gone, x[i]) + } + i++ + default: + if strings.TrimSpace(y[j]) != "" { + came = append(came, y[j]) + } + j++ + } + } + flush() + // a bracket or brace left alone on its line says nothing + var kept []LineDiff + for _, l := range out { + if t := strings.Trim(strings.TrimSpace(l.Text), "{}[](),"); t == "" { + continue + } + kept = append(kept, l) + } + return kept +} + +// sameLines is which lines of x and of y a shortest edit keeps (Myers' +// diff: time and memory grow with the edits, not the files — an agent's +// config can be 30,000 lines, and magpie changes a few). Not ok past most +// edits. +func sameLines(x, y []string, most int) (keepX, keepY []bool, ok bool) { + n, m := len(x), len(y) + if most > n+m { + most = n + m + } + off := most + 1 + v := make([]int, 2*off+1) + // trace[d] is v as step d found it, for k in -d-1..d+1 + var trace [][]int + get := func(d, k int) int { return trace[d][k+d+1] } + for d := 0; d <= most; d++ { + trace = append(trace, append([]int(nil), v[off-d-1:off+d+2]...)) + for k := -d; k <= d; k += 2 { + var xi int + if k == -d || (k != d && v[off+k-1] < v[off+k+1]) { + xi = v[off+k+1] + } else { + xi = v[off+k-1] + 1 + } + yi := xi - k + for xi < n && yi < m && x[xi] == y[yi] { + xi++ + yi++ + } + v[off+k] = xi + if xi < n || yi < m { + continue + } + // back from the end, marking the diagonals walked + keepX, keepY = make([]bool, n), make([]bool, m) + xi, yi = n, m + for e := d; e >= 0; e-- { + k := xi - yi + var pk int + if k == -e || (k != e && get(e, k-1) < get(e, k+1)) { + pk = k + 1 + } else { + pk = k - 1 + } + px := get(e, pk) + py := px - pk + for xi > px && yi > py && xi > 0 && yi > 0 { + keepX[xi-1], keepY[yi-1] = true, true + xi-- + yi-- + } + xi, yi = px, py + } + return keepX, keepY, true + } + } + return nil, nil, false +} + +func splitLines(s string) []string { + if s == "" { + return nil + } + return strings.Split(strings.TrimRight(strings.ReplaceAll(s, "\r\n", "\n"), "\n"), "\n") +} + +// lineKey is what a line sets: TOML's and .env's key before =, JSON's and +// YAML's before :, "" for a line that sets nothing. +var keyRe = regexp.MustCompile(`^\s*(?:export\s+)?"?([A-Za-z0-9_.\-]+)"?\s*[:=]`) + +func lineKey(l string) string { + if m := keyRe.FindStringSubmatch(l); m != nil { + return m[1] + } + if t := strings.TrimSpace(l); strings.HasPrefix(t, "[") && strings.HasSuffix(t, "]") { + return t + } + return "" +} + +var secretKey = regexp.MustCompile(`(?i)(key|token|secret|password|auth)`) + +// mask hides the value of a line whose key reads as a secret's; magpie's +// own token, which is no secret, stays. +func mask(l string) string { + k := lineKey(l) + if k == "" || !secretKey.MatchString(k) { + return l + } + m := keyRe.FindStringIndex(l) + val := strings.TrimSpace(l[m[1]:]) + bare := strings.Trim(val, `"', `) + if bare == "" || bare == "magpie" || strings.HasPrefix(bare, "{") || strings.HasPrefix(bare, "$") { + return l + } + return l[:m[1]] + " ••••" +} diff --git a/internal/agent/preview_test.go b/internal/agent/preview_test.go new file mode 100644 index 00000000..11ed9863 --- /dev/null +++ b/internal/agent/preview_test.go @@ -0,0 +1,87 @@ +package agent + +import ( + "fmt" + "strings" + "testing" + "time" +) + +// The lines Disconnect puts back, as the disconnect dialog shows them: a +// line taken out, one put in, a value going back; secrets masked; quick on +// a config of tens of thousands of lines (a Codex config.toml with its +// [projects]). +func TestDiffLines(t *testing.T) { + var big strings.Builder + for i := 0; i < 30000; i++ { + fmt.Fprintf(&big, "[projects.\"/p/%d\"]\ntrust_level = \"trusted\"\n", i) + } + before := "model = \"magpie/deepseek/pro\"\nmodel_provider = \"magpie\"\napi_key = \"sk-live-123\"\n" + big.String() + "[model_providers.magpie]\nname = \"magpie\"\n" + after := "model = \"gpt-6\"\nopenai_key = \"sk-live-456\"\n" + big.String() + start := time.Now() + got := diffLines(before, after) + if d := time.Since(start); d > 2*time.Second { + t.Fatalf("took %s", d) + } + want := []LineDiff{ + {Op: "~", Text: `model = "gpt-6"`, Was: `model = "magpie/deepseek/pro"`}, + {Op: "+", Text: "openai_key = ••••"}, + {Op: "-", Text: `model_provider = "magpie"`}, + {Op: "-", Text: "api_key = ••••"}, + {Op: "-", Text: "[model_providers.magpie]"}, + {Op: "-", Text: `name = "magpie"`}, + } + if fmt.Sprint(got) != fmt.Sprint(want) { + t.Fatalf("got %v\nwant %v", got, want) + } + + if d := diffLines("a\nb\n", "a\nb\n"); len(d) != 0 { + t.Fatalf("same texts: %v", d) + } + if d := diffLines("", "x = 1\n}\n"); fmt.Sprint(d) != fmt.Sprint([]LineDiff{{Op: "+", Text: "x = 1"}}) { + t.Fatalf("from nothing, a lone brace left out: %v", d) + } + // a block of thousands taken out, past what is compared line by + // line: its lines, quickly + var table strings.Builder + for i := 0; i < 7000; i++ { + fmt.Fprintf(&table, " \"m%d\": {\"name\": \"M %d\"},\n", i, i) + } + start = time.Now() + d := diffLines("{\n\"model\": \"magpie/x\",\n\"provider\": {\n"+table.String()+"}\n}\n", "{\n\"provider\": {\n}\n}\n") + if time.Since(start) > 2*time.Second || len(d) != 7001 || d[0] != (LineDiff{Op: "-", Text: `"model": "magpie/x",`}) { + t.Fatalf("a table taken out: %d lines in %s, first %v", len(d), time.Since(start), d[:min(len(d), 1)]) + } +} + +func TestMask(t *testing.T) { + for in, want := range map[string]string{ + ` "ANTHROPIC_AUTH_TOKEN": "magpie",`: ` "ANTHROPIC_AUTH_TOKEN": "magpie",`, + ` "ANTHROPIC_API_KEY": "sk-ant-x",`: ` "ANTHROPIC_API_KEY": ••••`, + `export OPENAI_API_KEY=sk-1`: `export OPENAI_API_KEY= ••••`, + `env_key = "$KEY"`: `env_key = "$KEY"`, + `model = "x"`: `model = "x"`, + } { + if got := mask(in); got != want { + t.Errorf("mask(%q) = %q, want %q", in, got, want) + } + } +} + +func TestElapsed(t *testing.T) { + for in, want := range map[string]time.Duration{ + " 01:02": 62 * time.Second, + "03:00:01": 3*time.Hour + time.Second, + "2-00:00:10": 48*time.Hour + 10*time.Second, + "10-23:59:59": 10*24*time.Hour + 23*time.Hour + 59*time.Minute + 59*time.Second, + } { + if got, ok := elapsed(in); !ok || got != want { + t.Errorf("elapsed(%q) = %s %v, want %s", in, got, ok, want) + } + } + for _, in := range []string{"", "x", "1:2:3:4", "a-01:02"} { + if _, ok := elapsed(in); ok { + t.Errorf("elapsed(%q) read", in) + } + } +} diff --git a/internal/agent/route.go b/internal/agent/route.go index ccff26d7..d4cd2670 100644 --- a/internal/agent/route.go +++ b/internal/agent/route.go @@ -56,7 +56,8 @@ func viaMagpie(agent, prefix string) []Option { note = a.User + " · via magpie" } out = append(out, Option{Value: prefix + e.ID, Label: e.Name, Note: note, - Icon: e.Provider.Icon, Group: e.Provider.Name, Ref: e.ID, Free: e.Free, Rate: e.Rate, RateWas: e.RateWas, Context: e.Context, own: own[e.Provider.ID]}) + Icon: e.Provider.Icon, Group: e.Provider.Name, Ref: e.ID, Free: e.Free, Rate: e.Rate, RateWas: e.RateWas, Context: e.Context, own: own[e.Provider.ID], + sub: e.Provider.Account != nil && (e.Provider.Account.Agent != "claude" || agent == "claude")}) } return append(groups, out...) } diff --git a/internal/gui/agent_models.go b/internal/gui/agent_models.go index 317ec8c7..5287dcb8 100644 --- a/internal/gui/agent_models.go +++ b/internal/gui/agent_models.go @@ -18,6 +18,18 @@ import ( type modelCountJSON struct { Shown int `json:"shown"` Listed int `json:"listed"` + // By is the models shown by whose they are, a connected agent's + // expanded row's chips (OpenAI 6 · OpenRouter 24) + By []modelGroupJSON `json:"by,omitempty"` +} + +// modelGroupJSON is how many of the models shown one provider (or the +// routing groups) gives. +type modelGroupJSON struct { + Name string `json:"name"` + Icon string `json:"icon,omitempty"` + Icons []string `json:"icons,omitempty"` + N int `json:"n"` } // agentModelJSON is a model an agent may be shown, as the list draws it. @@ -84,10 +96,23 @@ func modelCount(id string) *modelCountJSON { listed, _ := provider.ListedFor(id) off := provider.HiddenModels(id) c := &modelCountJSON{Listed: len(listed)} + at := map[string]int{} for _, e := range listed { - if !off[e.ID] { - c.Shown++ + if off[e.ID] { + continue } + c.Shown++ + g := modelGroupJSON{Name: e.Provider.Name, Icon: e.Provider.Icon} + if e.Group != "" { + g = modelGroupJSON{Name: agent.RoutingGroups, Icons: e.Icons} + } + i, ok := at[g.Name] + if !ok { + i = len(c.By) + at[g.Name] = i + c.By = append(c.By, g) + } + c.By[i].N++ } return c } diff --git a/internal/gui/agent_models_test.go b/internal/gui/agent_models_test.go index aa645313..a495e18e 100644 --- a/internal/gui/agent_models_test.go +++ b/internal/gui/agent_models_test.go @@ -6,6 +6,7 @@ import ( "net/http/httptest" "os" "path/filepath" + "reflect" "slices" "strings" "testing" @@ -69,7 +70,7 @@ func TestAgentModelsAPI(t *testing.T) { got.Count == nil || got.Count.Shown != got.Count.Listed-1 { t.Fatalf("%+v %+v", got.Models, got.Count) } - if c := modelCount("codex"); *c != *got.Count { + if c := modelCount("codex"); !reflect.DeepEqual(c, got.Count) { t.Fatalf("count %+v, answered %+v", c, got.Count) } got = call("POST", `{"hidden":[]}`) diff --git a/internal/gui/api.go b/internal/gui/api.go index e9781fd6..55234a45 100644 --- a/internal/gui/api.go +++ b/internal/gui/api.go @@ -166,6 +166,11 @@ type agentJSON struct { // Models: how many of the catalog its lists show, for an agent that // picks among it (agent_models.go) Models *modelCountJSON `json:"models,omitempty"` + // Source: what an agent not connected runs on now (agent.Source), and + // Stale: copies of a connected one still running on the list they + // started with (agent.Stale), for the line under its name + Source string `json:"source,omitempty"` + Stale int `json:"stale,omitempty"` } // clientJSON is an agent, or another client the gateway knows, as a @@ -645,6 +650,27 @@ func Handler(w Windows, gw *gateway.Server) http.Handler { } writeJSON(rw, c) }) + // what disconnecting an agent changes in its files, line by line, for + // the dialog asking it (agent.DisconnectPreview); its files when the + // preview can't be had + mux.HandleFunc("GET /api/agents/preview/{id}", func(rw http.ResponseWriter, r *http.Request) { + a, err := agent.Find(r.PathValue("id")) + if err != nil { + fail(rw, err) + return + } + exe, err := os.Executable() + if err != nil { + fail(rw, err) + return + } + changes, err := agent.DisconnectPreview(a, exe) + out := map[string]any{"changes": changes} + if err != nil { + out["error"] = err.Error() + } + writeJSON(rw, out) + }) mux.HandleFunc("POST /api/agents/{action}/{id}", func(rw http.ResponseWriter, r *http.Request) { a, err := agent.Find(r.PathValue("id")) if err != nil { @@ -1195,6 +1221,11 @@ func state() stateJSON { aj.Models = agentModelCount(a.ID, aj.Fields) aj.Drift = a.Drift() aj.Wired = a.Wired() + if aj.Wired { + aj.Stale = a.Stale() + } else { + aj.Source = a.Source() + } if a.Import != nil { aj.Import, aj.Added = a.Import(), a.Added != nil && a.Added() } diff --git a/internal/gui/assets/app.css b/internal/gui/assets/app.css index 73eb939c..6ee57746 100644 --- a/internal/gui/assets/app.css +++ b/internal/gui/assets/app.css @@ -2643,11 +2643,103 @@ body.window .row.agent .who.with-models { flex-wrap: wrap; row-gap: 0; } (claude-direct.test.cjs); the page holds the reader itself */ #view-agents { overflow-anchor: none; } body.window .row.agent > .ag-conn { margin-left: 12px; align-self: center; cursor: pointer; } -.ag-conn-line { flex: 0 0 100%; min-width: 0; font-size: 11.5px; line-height: 18px; color: var(--faint); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } -.ag-conn-line.on { color: var(--muted); } -.ag-conn-line.on::before { content: ""; display: inline-block; width: 6px; height: 6px; margin-right: 6px; border-radius: 50%; background: var(--green); vertical-align: 1px; } -.row.agent.unplugged > .fields { opacity: .55; transition: opacity .15s; } -.row.agent.unplugged:hover > .fields { opacity: 1; } .ag-conn-row { display: flex; align-items: center; gap: 8px; min-height: 28px; } .ag-conn-row .ag-conn-said { flex: 1; min-width: 0; font-size: 11.5px; color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .ag-conn-row .lib-switch { cursor: pointer; } + +/* 「接入」 in the window: a row says whether the agent goes through magpie — + a line under its name, a link that opens it, its switch */ +.ag-lead { margin: 0 2px 12px; font-size: 12.5px; color: var(--fg-2); } +.ag-lead b { font-weight: 600; color: var(--fg); } +.ag-lead.welcome { margin: 0 0 12px; padding: 16px 18px; border: 1px solid var(--line); border-radius: 12px; background: var(--card); } +.ag-lead .ag-lead-h { display: block; font-size: 14px; margin-bottom: 4px; } +.ag-lead p { margin: 0; line-height: 1.55; color: var(--muted); } +.ag-lead .text.primary { margin-top: 12px; } +body.window .row.agent.conn { flex-wrap: wrap; min-height: 56px; } +.ag-st { flex: 0 0 100%; min-width: 0; display: flex; align-items: center; gap: 6px; font-size: 12px; line-height: 18px; color: var(--muted); } +.ag-st .ag-st-t { min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.ag-st .ag-st-t b { font-weight: 600; color: var(--fg-2); } +.ag-dot { width: 6px; height: 6px; flex: none; border-radius: 50%; background: var(--faint); } +.ag-st.on .ag-dot { background: var(--green); } +.ag-st.wait .ag-dot, .ag-warn .ag-dot { background: var(--amber); } +.ag-st.wait .ag-st-t { color: var(--amber); } +.ag-st.bad .ag-dot { background: var(--red); } +.ag-st.bad .ag-st-t { color: var(--red); } +.ag-st .ag-fix { flex: none; } +.ag-link, .ag-quiet { flex: none; display: inline-flex; align-items: center; gap: 4px; height: 24px; padding: 0 8px; border: 0; border-radius: 6px; background: none; color: var(--fg-2); font: inherit; font-size: 12px; white-space: nowrap; cursor: pointer; transition: background-color .15s, color .15s; } +.ag-link:hover, .ag-quiet:hover, .ag-link.open { background: var(--pill); color: var(--fg); } +.ag-link .chev, .ag-quiet .chev { display: inline-flex; color: var(--faint); transition: transform .2s ease; } +.ag-quiet.open .chev { transform: rotate(90deg); } +.ag-quiet.red { color: var(--red); } +.ag-quiet.red:hover { background: color-mix(in srgb, var(--red) 10%, transparent); color: var(--red); } +.ag-quiet.accent { color: var(--accent); } +.ag-link:focus-visible, .ag-quiet:focus-visible, .ag-add:focus-visible { outline: 1.5px solid var(--accent); outline-offset: 1px; } +.row.agent.conn > .ag-link { margin-left: auto; } +.row.agent.conn > .fields + .ag-link { margin-left: 0; } +.row.agent.conn > .ag-conn { margin-left: 4px; align-self: center; cursor: pointer; } +.row.agent.conn > .ag-link + .ag-conn { margin-left: 4px; } +.row.agent.conn > .who + .ag-conn, .row.agent.conn > .who + .ag-add, .row.agent.conn > .who + .ag-cant { margin-left: auto; } +.row.agent.conn > .ag-conn:disabled { opacity: .4; cursor: default; } +.ag-add { flex: none; height: 24px; padding: 0 10px; border: 0; border-radius: 6px; background: var(--pill); color: var(--fg); font: inherit; font-size: 12px; font-weight: 500; cursor: pointer; } +.ag-add:hover { background: var(--pill-hover); } +.ag-cant { flex: none; font-size: 12px; color: var(--faint); } +.row.agent.conn.unplugged .ag-handle .ic { opacity: .7; } +/* opened: the settings as named lines on the row's quieter ground */ +.ag-exp { flex: 0 0 calc(100% + 26px); margin: 7px -12px -7px -14px; padding: 4px 16px 14px 50px; border-top: 1px solid var(--line-2); background: var(--card-2); cursor: default; } +.ag-kv { display: flex; align-items: flex-start; gap: 16px; padding: 11px 0; border-top: 1px solid var(--line-2); } +.ag-kv:first-child { border-top: 0; } +.ag-k { width: 112px; flex: none; padding-top: 3px; font-size: 12px; color: var(--muted); } +.ag-v { flex: 1; min-width: 0; font-size: 12.5px; color: var(--fg-2); display: grid; gap: 4px; } +.ag-vl { display: flex; align-items: center; flex-wrap: wrap; gap: 6px 8px; min-height: 24px; } +.ag-vl > .fields { margin-left: 0; } +.ag-code, .ag-diff code, .ag-path { font-family: var(--code); font-size: 11.5px; } +.ag-code { padding: 1px 5px; border-radius: 4px; background: var(--pill); color: var(--fg); } +.ag-warn { gap: 8px; font-size: 12px; } +.ag-hint { font-size: 12px; color: var(--muted); } +.ag-tier { width: 70px; flex: none; font-size: 12px; color: var(--fg-2); } +.ag-vl .field.ag-pick { width: 220px; justify-content: flex-start; } +.ag-vl .field.ag-eff { flex: none; } +.ag-vl .field.ag-pick .v { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; text-align: left; } +.ag-exp .extras-cell { justify-content: flex-start; } +.ag-chip { display: inline-flex; align-items: center; gap: 5px; height: 22px; padding: 0 8px; border-radius: 6px; background: var(--card); box-shadow: inset 0 0 0 1px var(--line); font-size: 12px; color: var(--fg-2); } +.ag-chip .ic { width: 13px !important; height: 13px !important; } +.ag-chip .n { color: var(--muted); font-variant-numeric: tabular-nums; } +.ag-foot { display: flex; align-items: center; gap: 8px; margin-top: 2px; padding-top: 12px; border-top: 1px solid var(--line-2); font-size: 12px; color: var(--muted); } +.ag-files { min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.ag-path { color: var(--fg-2); } +.ag-diff-box { margin-top: 10px; max-height: 320px; overflow: auto; } +.ag-diff-more { display: block; margin: 2px 0 6px 28px; padding: 2px 0; border: 0; background: none; font: inherit; font-size: 11.5px; color: var(--accent); cursor: pointer; } +/* a file's lines as disconnecting changes them */ +.ag-diff { display: grid; gap: 10px; } +.ag-diff-file { border: 1px solid var(--line); border-radius: 8px; background: var(--card); overflow: hidden; } +.ag-diff-path { display: flex; align-items: center; gap: 8px; padding: 6px 10px; border-bottom: 1px solid var(--line-2); font-family: var(--code); font-size: 11.5px; color: var(--fg); } +.ag-diff-path:last-child { border-bottom: 0; } +.ag-diff-line { display: flex; align-items: baseline; gap: 8px; padding: 2px 10px; font-size: 11.5px; line-height: 1.6; } +.ag-diff-line:first-of-type { padding-top: 6px; } +.ag-diff-line:last-child { padding-bottom: 6px; } +.ag-diff-line .op { width: 10px; flex: none; text-align: center; font-family: var(--code); } +.ag-diff-line code { flex: 1; min-width: 0; white-space: pre-wrap; overflow-wrap: anywhere; color: var(--fg-2); } +.ag-diff-line.del .op, .ag-diff-line.del code { color: var(--red); } +.ag-diff-line .ind { text-decoration: none; } +.ag-diff-line.del code .t { text-decoration: line-through; text-decoration-color: color-mix(in srgb, var(--red) 45%, transparent); } +.ag-diff-line.back .op { color: var(--green); } +.ag-tag { flex: none; padding: 0 6px; border-radius: 4px; background: var(--pill); font-family: inherit; font-size: 10.5px; line-height: 16px; color: var(--muted); } +.ag-tag.del { background: color-mix(in srgb, var(--red) 12%, transparent); color: var(--red); } +.disconnect-ask .ag-diff-wrap { max-height: min(46vh, 380px); overflow: auto; margin: 2px 0 10px; } +.ag-diff-loading { padding: 14px 0; font-size: 12px; color: var(--muted); } +.disconnect-ask .ag-note { margin: 0 0 10px; font-size: 12px; color: var(--muted); } +.ag-bar-note { font-size: 12px; color: var(--muted); } +@media (max-width: 600px) { + body.window .row.agent.conn { display: flex; } + /* the name beside its logo; the link and the switch wrap under it when short of room */ + body.window .row.agent.conn > .who { width: auto; flex: 1 1 160px; min-width: 0; } + .ag-exp { padding-left: 16px; min-width: 0; } + .ag-kv { flex-direction: column; gap: 4px; min-width: 0; } + /* the pickers take the line, the effort and the squares wrap under them */ + .ag-vl { width: 100%; min-width: 0; } + #agents .ag-vl > .fields { display: flex; flex-wrap: wrap; width: 100%; min-width: 0; } + .ag-vl > .fields > .field.main, .ag-vl > .fields > .field.solo { flex: 1 1 100%; min-width: 0; } + .ag-vl > .fields > .field.side { flex: 1 0 124px; } + .ag-vl > .fields > .extras-cell { flex-wrap: wrap; max-width: 100%; } + .ag-vl .field.ag-pick { flex: 1 1 160px; width: auto; min-width: 0; } +} diff --git a/internal/gui/assets/app.js b/internal/gui/assets/app.js index c45ac79c..d52cac7f 100644 --- a/internal/gui/assets/app.js +++ b/internal/gui/assets/app.js @@ -239,6 +239,7 @@ function renderAgents() { page.removeAttribute("aria-busy"); const list = $("#agents"); list.replaceChildren(); + agentsLead(list); if (!state.agents.length) { const e = el("div", "empty-state"); e.append(el("b", "", t("No agents found")), el("span", "", t("Install Claude Code, Codex, Gemini CLI, OpenCode… and magpie will list them here."))); @@ -269,13 +270,12 @@ function renderAgents() { // the squares share one cell, side by side: two (Codex's subagents and // sign-in) wrapped the second under the first const extras = el("span", "extras-cell"); - for (const f of sorted) { - if (extra(f)) { extras.append(extraField(a, f)); continue; } - const b = el("button", "field " + (plain === 1 ? "solo" : wide(f) ? "main" : "side")); + const fieldBtn = (f, cls) => { + const b = el("button", "field " + cls); const opt = optionFor(f, f.value); b.title = t("{label}: {value}", { label: t(f.label), value: f.value || t("agent default") }) + (opt?.note ? ` · ${opt.note}` : ""); if (f.value && opt?.direct) b.title += "\n" + directSaid(a, opt); - const effort = f.key === "effort" || f.label === "effort" || f.label === "thinking"; + const effort = f.key === "effort" || f.label === "effort" || f.label === "thinking" || TIER_EFFORTS.includes(f.label); if (opt?.icon || opt?.icons?.length) b.append(optionIcon(opt)); // a field with no logo of its own still leads with an icon: how much // effort, or the agent's own for its default, as the picker shows it @@ -290,7 +290,11 @@ function renderAgents() { b.append(c); b.dataset.key = f.key; b.onclick = (ev) => openPicker(a, f, b, ev); - fields.append(b); + return b; + }; + for (const f of sorted) { + if (extra(f)) { extras.append(extraField(a, f)); continue; } + fields.append(fieldBtn(f, plain === 1 ? "solo" : wide(f) ? "main" : "side")); } // an agent that takes the gateway only from its environment (agy): a // square that copies the command starting it on magpie @@ -396,15 +400,17 @@ function renderAgents() { } // on the name's own line, so the row keeps its height and the pickers // their columns + // a row with 「接入」 says it on its line under the name instead + const kind = mode !== "panel" ? connectKind(a) : ""; if (a.drift) { row.classList.add("drifted"); - who.append(driftFix(a)); + if (!kind) who.append(driftFix(a)); } // the CLI's version, and an update when one is out (#202); the panel's // name column has no room for it if (mode !== "panel") who.append(cliTag(a)); // which of magpie's models its lists show, on a line under the name - if (mode !== "panel" && a.models) { + if (mode !== "panel" && a.models && !kind) { const line = el("div", "ag-models-line"); line.append(modelsEntry(a)); who.append(line); @@ -413,10 +419,15 @@ function renderAgents() { // connected to magpie or not, said under the name, with where the agent // picks among magpie's models itself const sw = connectable(a) ? connectSwitch(a) : null; - if (sw && mode !== "panel") { - who.append(el("div", "ag-conn-line" + (a.wired ? " on" : ""), connectSaid(a))); - who.classList.add("with-models"); - row.classList.toggle("unplugged", !a.wired); + if (kind) { + // Claude Code's tiers and subagents have rows of their own when opened + if (a.id === "claude") { + for (const f of a.fields.filter((f) => TIERS.includes(f.label) || f.label === "subagents")) extras.querySelector(`[data-key="${CSS.escape(f.key)}"]`)?.remove(); + extras.querySelector('[data-key="tiers"]')?.remove(); + } + row.append(agentHandle(a, row, inFold), who); + connectRow(a, row, who, { fields, extras, fieldBtn, sw, kind }); + return row; } if (sw && openBox) { const line = el("div", "ag-conn-row"); @@ -711,14 +722,14 @@ function profileDetail(p, footed) { // driftFix is the one thing a drifted agent shows: an amber pill after its // name that sets magpie's settings again. What is off is its tooltip; taking // the config as it is now is in the row's menu. -function driftFix(a) { +function driftFix(a, label = "Apply again") { const d = a.drift, f = a.fields.find((x) => x.key === d.field); const want = (f && optionFor(f, d.want)?.label) || d.want; const fix = el("button", "ag-fix"); fix.type = "button"; fix.title = `${t(DRIFT_WHY[d.kind] || DRIFT_WHY.unwired, { agent: a.name, model: want })}\n${d.detail}`; - fix.setAttribute("aria-label", t("Apply again")); - fix.append(svg(REAPPLY, 11, 1.8), el("span", "", t("Apply again"))); + fix.setAttribute("aria-label", t(label)); + fix.append(svg(REAPPLY, 11, 1.8), el("span", "", t(label))); fix.onclick = (e) => { e.stopPropagation(); reapplyAgent(a, fix); }; return fix; } @@ -740,9 +751,13 @@ const DISCONNECT_TIP = "Take out everything magpie wrote into {agent}'s config a // its provider and its own model list has magpie's models (agent.Connect); // off, magpie takes out what it wrote (askDisconnect). An agent with none of // magpie's models to offer (Cursor, Copilot) has no switch. +// the field magpie is picked in: the first, or Gemini CLI's model, its +// first being how it signs in +const connectField = (a) => a.fields.find((f) => f.options.some((o) => o.ref)) || a.fields.find((f) => f.options.some((o) => o.value === "magpie")); +// connectable: magpie has models for it — in its pickers, or every one +// hidden from its lists, which leave its pickers but not the way back (#356) function connectable(a) { - const f = a.fields[0]; - return !a.import && !!f && f.options.some((o) => o.ref || o.value === "magpie"); + return !a.import && (!!connectField(a) || !!a.models?.listed); } // PICKS_IN is where an agent picks a model itself, for the words under it. @@ -783,6 +798,393 @@ function connectSwitch(a) { return s; } +// ---------- 「接入」 in the window (the owner's design boards) ---------- +// A row there answers one thing: does the agent go through magpie. Its +// switch stands where the model and effort pickers stood, a line under the +// name says what it runs on and where it picks a model, and a connected +// one opens to what else there is: when it takes effect, which models it +// lists, what a new session starts on, the files magpie changed, and the +// way back. + +// agents with no way onto magpie: no switch, the reason instead +const NATIVE_ONLY = { + cursor: "Only Cursor's own models", + copilot: "Only GitHub Copilot's own models", + goose: "Only Goose's own providers", + devin: "Only Devin's own models", +}; +// agents that pick no model once started: the one they start on stays in +// the row +const NO_PICKER = new Set(["gemini", "hermes", "agy", "muse"]); +// what turning one on costs, said in its opened row +const CONNECT_COST = { + gemini: "Gemini CLI's requests all go through magpie; its Google sign-in is turned off first and comes back when you disconnect", +}; +// agents that read magpie's list only as they start: copies already +// running keep the list they started with (agent.Stale) +const STARTS_WITH = new Set(["codex", "claude"]); + +let agentExpanded = null; // the connected agent opened in the window +const staleSeen = {}; // agent id → the running copies the reader was told of +const previews = {}; // agent id → its disconnect preview, as last read + +const anyMagpieModels = () => state.agents.some((a) => connectField(a)?.options.some((o) => o.ref)); + +// connectKind: "ok" (a switch), "empty" (a switch for when magpie has +// models), "no" (never), "" (the row as it was: an app with a link of its own) +function connectKind(a) { + if (a.import) return ""; + if (connectable(a)) return "ok"; + if (NATIVE_ONLY[a.id]) return "no"; + if (a.fields.length && !anyMagpieModels()) return "empty"; + return ""; +} + +// the models it lists the reader hasn't seen yet: counted once, said in +// bold for a while, then counted as seen +const SEEN_KEY = "magpie.agentModelsSeen"; +let seenTimer = null; +function newModels(a) { + if (!a.wired || !a.models) return 0; + let seen = {}; + try { seen = JSON.parse(localStorage.getItem(SEEN_KEY) || "{}") || {}; } catch { /* none kept */ } + const was = seen[a.id], now = a.models.shown; + const keep = () => { try { seen[a.id] = now; localStorage.setItem(SEEN_KEY, JSON.stringify(seen)); } catch { /* not kept */ } }; + if (was === undefined || now <= was) { if (was !== now) keep(); return 0; } + clearTimeout(seenTimer); + seenTimer = setTimeout(() => { keep(); renderAgents(); }, 12000); + return now - was; +} + +const staleNow = (a) => a.wired && a.stale > 0 && staleSeen[a.id] !== a.stale; + +// connectLine: the dot and the words under an agent's name +function connectLine(a, kind) { + const line = el("div", "ag-st"); + const dot = el("span", "ag-dot"); + const words = el("span", "ag-st-t"); + line.append(dot, words); + const say = (s, cls) => { words.textContent = s; if (cls) line.classList.add(cls); }; + if (kind === "no") { say(t(NATIVE_ONLY[a.id])); return line; } + if (kind === "empty") { say(t("Add a key or a subscription first; then there are models to connect")); return line; } + if (!a.wired) { + const src = a.source || ""; + if (a.id === "agy") say(t("Not connected · once connected, start it with magpie's command")); + else if (src === "sub") say(t("Not connected · now on its Claude subscription")); + else if (src === "chatgpt") say(t("Not connected · now signed in with ChatGPT · OpenAI's models stay available once connected")); + else if (src === "key") say(t("Not connected · now on an API key of its own")); + else if (src.startsWith("providers:")) say(t("Not connected · now has {n} providers of its own", { n: src.slice(10) })); + else say(t("Not connected · {agent} uses its own settings", { agent: a.name })); + return line; + } + if (a.drift) { + say(t(DRIFT_WHY[a.drift.kind] || DRIFT_WHY.unwired, { agent: a.name, model: a.drift.want }), "bad"); + words.title = a.drift.detail || ""; + line.append(driftFix(a, "Reconnect")); + return line; + } + line.classList.add("on"); + if (staleNow(a)) { + if (a.id === "claude") say(t("Connected · new sessions take it")); + else say(t("Connected · takes effect once {agent} is reopened", { agent: a.name }), "wait"); + return line; + } + const fresh = newModels(a); + if (fresh) { + const [pre, post] = t("{n} models · {new} show in new {agent} sessions", { n: a.models.shown, agent: a.name }).split("{new}"); + words.append(pre, el("b", "", t("{n} new", { n: fresh })), post || ""); + return line; + } + const at = PICKS_IN[a.id]; + if (a.id === "claude") say(t("Connected · switch the opus · sonnet · haiku tiers in /model")); + else if (NO_PICKER.has(a.id)) { + const f = connectField(a), o = f && optionFor(f, f.value); + say(o ? t("Connected · starts on {model}", { model: o.label || f.value }) : t("Connected · pick the model it starts on")); + } else if (at && a.models) say(t("{n} models in {agent}'s {cmd}", { n: a.models.shown, agent: a.name, cmd: at })); + else say(connectSaid(a)); + return line; +} + +// expandLink opens a connected agent's row, and closes it again +function expandLink(a) { + const open = agentExpanded === a.id; + const b = el("button", "ag-link" + (open ? " open" : "")); + b.type = "button"; + b.setAttribute("aria-expanded", String(open)); + b.append(el("span", "", open ? t("Collapse") : a.id === "claude" ? t("Tiers") : a.models ? t("Models {n}", { n: a.models.shown }) : t("Details"))); + if (!open) { + const c = el("span", "chev"); + c.append(svg(CHEV_R, 10, 1.6)); + b.append(c); + } + b.onclick = (e) => { + e.stopPropagation(); + agentExpanded = open ? null : a.id; + renderAgents(); + }; + return b; +} + +// connectRow finishes a window row that has 「接入」 +function connectRow(a, row, who, { fields, extras, fieldBtn, sw, kind }) { + // its list open, what was picked there until the state has it + if (agentModels?.a.id === a.id && agentModels.count) a.models = agentModels.count; + row.classList.add("conn"); + row.classList.toggle("unplugged", kind === "ok" && !a.wired); + who.classList.add("with-models"); + who.append(connectLine(a, kind)); + const noPicker = NO_PICKER.has(a.id); + if (kind === "ok") { + if (noPicker && a.wired) row.append(fields); + if (a.wired) row.append(expandLink(a)); + row.append(sw); + } else if (kind === "empty") { + const add = el("button", "ag-add", t("Add a provider")); + add.type = "button"; + add.onclick = (e) => { e.stopPropagation(); addProviderFromAgents(); }; + const off = el("button", "lib-switch ag-conn"); + off.type = "button"; + off.disabled = true; + off.setAttribute("role", "switch"); + off.setAttribute("aria-checked", "false"); + off.setAttribute("aria-label", t("Connect {agent} to magpie", { agent: a.name })); + off.title = t("Add a key or a subscription first; then there are models to connect"); + off.append(el("i")); + row.append(add, off); + } else row.append(el("span", "ag-cant", t("Can't connect"))); + if (kind === "ok" && a.wired && agentExpanded === a.id) { + row.classList.add("expanded"); + row.append(connectPanel(a, { fields: noPicker ? null : fields, extras, fieldBtn })); + } +} + +// agentsLead: above the window's list, what 「接入」 does, said once in a +// line; while no agent is connected, as a welcome, with a way to add a +// provider when magpie has no models to give +function agentsLead(list) { + let lead = $("#agentsLead"); + if (!lead) { + lead = el("div", "ag-lead"); + lead.id = "agentsLead"; + list.before(lead); + } + lead.replaceChildren(); + const kinds = mode === "panel" ? [] : state.agents.map(connectKind); + lead.hidden = !kinds.some((k) => k === "ok" || k === "empty"); + if (lead.hidden) return; + const first = !state.agents.some((a) => a.wired); + lead.classList.toggle("welcome", first); + if (!first) { + const [pre, post] = t("Switch an agent on and the models you set up in magpie show up as the {magpie} provider in its own model list.").split("{magpie}"); + lead.append(pre, el("b", "", "magpie"), post || ""); + return; + } + lead.append(el("b", "ag-lead-h", t("Let your agents use the models you set up in magpie"))); + lead.append(el("p", "", t("Switch an agent on and your models show up as the magpie provider in its own model list. Then change models in the agent; switch it off and it goes back to how it was."))); + if (!anyMagpieModels()) { + const add = el("button", "text primary", t("Add a provider")); + add.type = "button"; + add.onclick = () => addProviderFromAgents(); + lead.append(add); + } +} + +function addProviderFromAgents() { + show("providers"); + $("#addProvider")?.click(); +} + +// connectPanel: a connected agent's row, opened +function connectPanel(a, { fields, fieldBtn }) { + const box = el("div", "ag-exp"); + box.onclick = (e) => e.stopPropagation(); + const kv = (k, ...v) => { + const r = el("div", "ag-kv"); + const val = el("div", "ag-v"); + val.append(...v); + r.append(el("div", "ag-k", k), val); + box.append(r); + return val; + }; + const line = (...parts) => { + const l = el("div", "ag-vl"); + l.append(...parts); + return l; + }; + const code = (s) => el("code", "ag-code", s); + // where it picks, and when the copies running take it + const at = PICKS_IN[a.id]; + if (at || STARTS_WITH.has(a.id)) { + const parts = []; + // the command in the words as code, wherever the language puts it + const said = (s, cmd) => { const [pre, post] = t(s, { agent: a.name }).split("{cmd}"); return line(pre, code(cmd), post || ""); }; + if (a.id === "claude") parts.push(said("New sessions take it; switch the tiers in {cmd}.", "/model")); + else if (STARTS_WITH.has(a.id)) parts.push(said("Reopen {agent}, then pick any model in {cmd}.", at)); + else parts.push(said("Pick any model in {agent}'s {cmd}.", at)); + if (staleNow(a)) { + const w = line(); + w.classList.add("ag-warn"); + w.append(el("span", "ag-dot"), el("span", "", t(a.stale === 1 ? "1 running {agent} still has the old list" : "{n} running {agent} still have the old list", { n: a.stale, agent: a.name }))); + const later = el("button", "ag-quiet", t("Got it")); + later.type = "button"; + later.onclick = () => { staleSeen[a.id] = a.stale; renderAgents(); }; + w.append(later); + parts.push(w); + } + kv(t("In {agent}", { agent: a.name }), ...parts); + } + // Claude Code's tiers, each with the /model that picks it + if (a.id === "claude") { + const tiers = a.fields.filter((f) => TIERS.includes(f.label) && f.options.length); + const hint = { opus: "/model opus", sonnet: "/model sonnet", haiku: "/model haiku", fable: "/model fable" }; + const also = { sonnet: " · " + t("the default tier"), haiku: " · " + t("also for small background tasks") }; + const rows = tiers.map((f) => { + const l = line(el("span", "ag-tier", f.label[0].toUpperCase() + f.label.slice(1)), fieldBtn(f, "ag-pick")); + // the tier's own effort (#536), while there are levels to pick; + // unset, it runs at the effort Claude Code asks for + const e = a.fields.find((x) => x.label === f.label + " effort" && (x.options.length || x.value)); + if (e) { + const b = fieldBtn(e, "ag-eff"); + b.title = t("{label}: {value}", { label: t(e.label), value: e.value ? effortName(optionFor(e, e.value) || { value: e.value }) : t("the effort Claude Code asks for") }); + b.setAttribute("aria-label", b.title); + l.append(b); + } + l.append(el("span", "ag-hint", (hint[f.label] || "") + (also[f.label] || ""))); + return l; + }); + const sub = a.fields.find((f) => f.label === "subagents" && (f.options.length || f.value)); + if (sub) rows.push(line(el("span", "ag-tier", t("Subagents")), fieldBtn(sub, "ag-pick"))); + if (rows.length) kv(t("Tiers"), ...rows); + const main = a.fields.find((f) => f.key === "model"); + const hasSub = main?.options.some((o) => (o.ref || "").startsWith("claude/")); + if (hasSub) kv(t("Claude models"), line(t("Your Claude subscription is in magpie; picking a claude-* model goes through it as before"))); + else { + const add = el("button", "ag-quiet accent", t("Add a Claude subscription")); + add.type = "button"; + add.onclick = () => addProviderFromAgents(); + kv(t("Claude models"), line(t("claude-* models need a Claude subscription in magpie"), add)); + } + } + // which of magpie's models it lists, by whose they are + if (a.models) { + const chips = line(); + chips.classList.add("ag-chips"); + for (const g of a.models.by || []) { + const c = el("span", "ag-chip"); + if (g.icons?.length) c.append(optionIcon({ icons: g.icons })); + else if (g.icon) c.append(icon(g.icon)); + c.append(el("span", "", g.name === ROUTING_GROUPS ? t(g.name) : g.name), el("span", "n", String(g.n))); + chips.append(c); + } + const hidden = a.models.listed - a.models.shown; + if (hidden > 0) chips.append(el("span", "ag-hint", t("{n} hidden", { n: hidden }))); + const pick = el("button", "ag-quiet"); + pick.type = "button"; + pick.append(el("span", "", t("Pick"))); + const c = el("span", "chev"); + c.append(svg(CHEV_R, 10, 1.6)); + pick.append(c); + pick.onclick = (ev) => openAgentModels(a, pick, ev); + // drawn again while its list is open: the list stays, held to it + if (agentModels?.a.id === a.id) { + pick.classList.add("open"); + agentModels.anchor = pick; + } + chips.append(pick); + kv(t("Model list"), chips); + } + // what a new session starts on: optional, the agent's own last pick unset + if (fields && a.id !== "claude") { + kv(t("New sessions"), line(fields), el("div", "ag-hint", t("Optional · unset, {agent} starts on its own last pick", { agent: a.name }))); + } else if (fields && a.id === "claude") { + kv(t("New sessions"), line(fields)); + } + if (CONNECT_COST[a.id]) kv(t("Once connected"), line(t(CONNECT_COST[a.id]))); + if (a.launch) { + const cp = el("button", "ag-quiet", t("Copy")); + cp.type = "button"; + cp.onclick = () => copy(a.launch, t("Launch command"), null, t("Copied — run it to start {name} on magpie", { name: a.name })); + kv(t("Start it with"), line(code(a.launch), cp)); + } + // the files magpie changed, and the way back + const foot = el("div", "ag-foot"); + const files = el("span", "ag-files"); + const view = el("button", "ag-quiet"); + view.type = "button"; + view.hidden = true; + const diff = el("div", "ag-diff-box"); + diff.hidden = true; + view.onclick = () => { + diff.hidden = !diff.hidden; + view.firstChild.textContent = diff.hidden ? t("View") : t("Hide changes"); + view.classList.toggle("open", !diff.hidden); + }; + view.append(el("span", "", t("View"))); + const vc = el("span", "chev"); + vc.append(svg(CHEV_R, 10, 1.6)); + view.append(vc); + const off = el("button", "ag-quiet red", t("Disconnect and restore")); + off.type = "button"; + off.onclick = () => askDisconnect(a); + foot.append(files, view, el("span", "grow"), off); + box.append(foot, diff); + const fill = (changes) => { + if (!changes?.length) return; + files.replaceChildren(t("Changed ")); + changes.forEach((c, i) => { + if (i) files.append(" · "); + files.append(el("span", "ag-path", i ? c.path.split("/").pop() : c.path)); + }); + view.hidden = false; + diff.replaceChildren(changesList(changes)); + }; + if (previews[a.id]) fill(previews[a.id]); + loadPreview(a).then(fill, () => {}); + return box; +} + +async function loadPreview(a) { + const r = await api("agents/preview/" + a.id); + if (r?.error) throw new Error(r.error); + previews[a.id] = r?.changes || []; + return previews[a.id]; +} + +// changesList: what disconnecting does to each file, line by line +function changesList(changes) { + const box = el("div", "ag-diff"); + for (const c of changes) { + const f = el("div", "ag-diff-file"); + const h = el("div", "ag-diff-path"); + h.append(el("span", "", c.path)); + if (c.removed) h.append(el("span", "ag-tag del", t("Deleted"))); + if (c.created) h.append(el("span", "ag-tag", t("Created"))); + f.append(h); + const line = (l) => { + const r = el("div", "ag-diff-line " + (l.op === "-" ? "del" : "back")); + // the indent shows where it was, and isn't struck through + const code = el("code"), ind = l.text.match(/^\s*/)[0]; + code.append(el("span", "ind", ind), el("span", "t", l.text.slice(ind.length))); + r.append(el("span", "op", l.op === "-" ? "−" : "↺"), code); + if (l.op !== "-") r.append(el("span", "ag-tag", t("value before connecting"))); + if (l.op === "~" && l.was) r.title = t("Now: {line}", { line: l.was }); + return r; + }; + // a list magpie wrote whole (Claude Code's modelPicker, Codex's + // catalog) is a few lines and the rest on asking + const lines = c.lines || [], FEW = 8; + f.append(...lines.slice(0, FEW).map(line)); + if (lines.length > FEW + 2) { + const rest = el("button", "ag-diff-more", t("{n} more lines", { n: lines.length - FEW })); + rest.type = "button"; + rest.onclick = () => rest.replaceWith(...lines.slice(FEW).map(line)); + f.append(rest); + } else f.append(...lines.slice(FEW).map(line)); + box.append(f); + } + return box; +} + // askDisconnect takes magpie out of an agent's config once asked: every // field magpie set, and its endpoint and key, give way to what the agent // had before magpie (agent.Disconnect). Picking Default isn't that: it is @@ -792,9 +1194,22 @@ function askDisconnect(a) { const head = el("div", "ehead"); head.append(icon(a.icon), el("b", "", t("Disconnect {agent} from magpie?", { agent: a.name }))); ed.append(head); - ed.append(el("p", "lib-confirm", t("magpie takes out everything it wrote into {agent}'s config — its endpoint, key, models and effort — and puts back the settings {agent} had before. magpie's providers and accounts stay as they are.", { agent: a.name }))); + ed.append(el("p", "lib-confirm", t("{agent} goes back to how it was before it was connected; these are the places put back.", { agent: a.name }))); + // what changes, line by line, read from a dry run on a copy (preview.go); + // where none can be had, the old words + const said = el("p", "lib-confirm", t("magpie takes out everything it wrote into {agent}'s config — its endpoint, key, models and effort — and puts back the settings {agent} had before. magpie's providers and accounts stay as they are.", { agent: a.name })); + const body = el("div", "ag-diff-wrap"); + const show = (changes) => { + if (changes?.length) body.replaceChildren(changesList(changes)); + else body.replaceChildren(said); + }; + if (previews[a.id]) show(previews[a.id]); + else body.append(el("div", "ag-diff-loading", t("Reading what changes…"))); + loadPreview(a).then(show, () => show(null)); + ed.append(body); + if (a.id === "codex") ed.append(el("p", "ag-note", t("magpie's provider table stays, so sessions opened on magpie's models still open"))); const bar = el("div", "bar"); - const go = el("button", "text primary danger-fill", t("Disconnect")); + const go = el("button", "text primary danger-fill", t("Disconnect and restore")); go.onclick = async (e) => { e.stopPropagation(); go.disabled = true; @@ -802,6 +1217,8 @@ function askDisconnect(a) { try { state = await api("agents/disconnect/" + a.id, {}); closeConfirmAsk(); + delete previews[a.id]; + if (agentExpanded === a.id) agentExpanded = null; renderAgents(); const msg = t("{agent} no longer goes through magpie; its own settings are back", { agent: a.name }); if (state.notice) status(`${msg}. ${t(state.notice)}`, "warn", 9000); @@ -814,7 +1231,7 @@ function askDisconnect(a) { }; const cancel = el("button", "text", t("Cancel")); cancel.onclick = (e) => { e.stopPropagation(); closeConfirmAsk(); }; - bar.append(el("span", "grow"), cancel, go); + bar.append(STARTS_WITH.has(a.id) ? el("span", "ag-bar-note", t("Running {agent} copies take it once reopened", { agent: a.name })) : el("span"), el("span", "grow"), cancel, go); ed.append(bar); confirmAsk = ed; openModal(ed); @@ -1387,9 +1804,19 @@ async function openAgentModels(a, anchor, ev) { const save = () => { me.changed = true; const hidden = models.filter((m) => m.hidden).map((m) => m.id); - const count = { shown: models.length - hidden.length, listed: models.length }; + // by whose they are, as the state counts them, for a connected row's chips + const by = []; + for (const m of models) { + if (m.hidden) continue; + let g = by.find((x) => x.name === m.group); + if (!g) by.push(g = m.group === ROUTING_GROUPS ? { name: m.group, icons: m.icons, n: 0 } : { name: m.group, icon: m.icon, n: 0 }); + g.n++; + } + const count = { shown: models.length - hidden.length, listed: models.length, by }; me.count = a.models = count; - fillModelsEntry(me.anchor, a); + // the line under the name counts them; an opened row is drawn again + if (me.anchor.classList.contains("ag-models")) fillModelsEntry(me.anchor, a); + else renderAgents(); me.saving = me.saving.then(() => api("agent-models/" + encodeURIComponent(a.id), { hidden })) .catch((e) => status(e.message, "err")); }; @@ -2533,7 +2960,10 @@ function renderEffortPicker() { } // a tier's effort, opened from the tiers' square: that square's title // and light follow the level - if (TIER_EFFORTS.includes(opened.field.label)) { + if (TIER_EFFORTS.includes(opened.field.label) && opened.anchor.classList.contains("ag-eff")) { + opened.anchor.title = t("{label}: {value}", { label: t(opened.field.label), value: option.value ? effortName(option) : t("the effort Claude Code asks for") }); + opened.anchor.setAttribute("aria-label", opened.anchor.title); + } else if (TIER_EFFORTS.includes(opened.field.label)) { const menu = tierMenu(opened.agent); if (menu) { opened.anchor.classList.toggle("set", menu.custom); diff --git a/internal/gui/assets/i18n.js b/internal/gui/assets/i18n.js index 7a5486da..69a46152 100644 --- a/internal/gui/assets/i18n.js +++ b/internal/gui/assets/i18n.js @@ -134,10 +134,71 @@ const I18N = { "Disconnect from magpie": "断开 magpie(还原配置)", "Take out everything magpie wrote into {agent}'s config and put back what it had before": "移除 magpie 写入 {agent} 配置的所有内容,还原成接入 magpie 之前的设置", "put back what {agent} had before magpie": "还原 {agent} 接入 magpie 之前的设置", - "Disconnect {agent} from magpie?": "断开 {agent} 与 magpie 的连接?", + "Disconnect {agent} from magpie?": "断开 {agent} 和 magpie?", "magpie takes out everything it wrote into {agent}'s config — its endpoint, key, models and effort — and puts back the settings {agent} had before. magpie's providers and accounts stay as they are.": "magpie 会移除它写入 {agent} 配置的所有内容(接口地址、密钥、模型和推理强度),并还原 {agent} 之前的设置。magpie 里的供应商和账号保持不变。", "Disconnect": "断开", - "Not connected · {agent} uses its own settings": "未接入 · {agent} 用它自己的设置", + "Not connected · {agent} uses its own settings": "未接入 · 用 {agent} 自己的设置", + "Only Cursor's own models": "只能用 Cursor 自己的模型", + "Only GitHub Copilot's own models": "只能用 GitHub Copilot 自己的模型", + "Only Goose's own providers": "只能用 Goose 自己的供应商", + "Only Devin's own models": "只能用 Devin 自己的模型", + "Gemini CLI's requests all go through magpie; its Google sign-in is turned off first and comes back when you disconnect": "Gemini CLI 的请求都走 magpie,Google 登录会先关掉,断开时恢复", + "Add a key or a subscription first; then there are models to connect": "先添加一个 key 或订阅,接入后才有模型可选", + "Not connected · once connected, start it with magpie's command": "未接入 · 接入后要用 magpie 给的命令启动", + "Not connected · now on its Claude subscription": "未接入 · 现在用 Claude 订阅", + "Not connected · now signed in with ChatGPT · OpenAI's models stay available once connected": "未接入 · 现在用 ChatGPT 登录 · 接入后 OpenAI 的模型照常可用", + "Not connected · now on an API key of its own": "未接入 · 现在用它自己的 API key", + "Not connected · now has {n} providers of its own": "未接入 · 现在有 {n} 个自己的供应商", + "Reconnect": "重新接入", + "Connected · new sessions take it": "已接入 · 新开的会话生效", + "Connected · takes effect once {agent} is reopened": "已接入 · 重开 {agent} 后生效", + "{n} models · {new} show in new {agent} sessions": "{n} 个模型 · {new}在新的 {agent} 会话里可见", + "{n} new": "新加的 {n} 个", + "Connected · switch the opus · sonnet · haiku tiers in /model": "已接入 · 在 /model 里切换 opus · sonnet · haiku 档位", + "Connected · starts on {model}": "已接入 · 用 {model} 启动", + "Connected · pick the model it starts on": "已接入 · 选一个启动模型", + "{n} models in {agent}'s {cmd}": "{n} 个模型在 {agent} 的 {cmd} 里", + "Collapse": "收起", + "Tiers": "档位", + "Models {n}": "模型 {n}", + "Details": "详情", + "Can't connect": "不能接入", + "New sessions take it; switch the tiers in {cmd}.": "新开的会话生效,在 {cmd} 里切换档位。", + "Reopen {agent}, then pick any model in {cmd}.": "重开 {agent} 后,在 {cmd} 里选任何一个模型。", + "Pick any model in {agent}'s {cmd}.": "在 {agent} 的 {cmd} 里选任何一个模型。", + "1 running {agent} still has the old list": "1 个正在运行的 {agent} 还是旧列表", + "{n} running {agent} still have the old list": "{n} 个正在运行的 {agent} 还是旧列表", + "Got it": "知道了", + "In {agent}": "在 {agent} 里", + "also for small background tasks": "也用于后台小任务", + "the default tier": "默认档位", + "Subagents": "子 agent", + "Claude models": "Claude 模型", + "Your Claude subscription is in magpie; picking a claude-* model goes through it as before": "Claude 订阅已在 magpie 里添加,选 claude-* 模型时照常走你的订阅", + "Add a Claude subscription": "添加 Claude 订阅", + "claude-* models need a Claude subscription in magpie": "claude-* 模型要先在 magpie 里添加 Claude 订阅", + "Pick": "挑选", + "Model list": "模型列表", + "New sessions": "新会话默认", + "Optional · unset, {agent} starts on its own last pick": "可选 · 不设就用 {agent} 自己上次的选择", + "Once connected": "接入后", + "Start it with": "启动命令", + "View": "查看", + "Hide changes": "收起", + "Disconnect and restore": "断开并还原", + "Changed ": "改动了 ", + "Deleted": "删除", + "Created": "新建", + "value before connecting": "接入前的值", + "Now: {line}": "现在:{line}", + "{n} more lines": "还有 {n} 行", + "{agent} goes back to how it was before it was connected; these are the places put back.": "{agent} 会回到接入前的样子,下面是要改回的地方。", + "Reading what changes…": "正在读取要改回的地方…", + "magpie's provider table stays, so sessions opened on magpie's models still open": "保留 magpie 供应商表,用 magpie 模型开过的会话还能打开", + "Running {agent} copies take it once reopened": "正在运行的 {agent} 重开后生效", + "Switch an agent on and the models you set up in magpie show up as the {magpie} provider in its own model list.": "打开「接入」,你在 magpie 配的模型会以 {magpie} 供应商出现在 agent 自己的模型列表里。", + "Let your agents use the models you set up in magpie": "让 agent 用上你在 magpie 里配的模型", + "Switch an agent on and your models show up as the magpie provider in its own model list. Then change models in the agent; switch it off and it goes back to how it was.": "打开一个 agent 的「接入」,你的模型就会以 magpie 供应商出现在它自己的模型列表里。之后在 agent 里换模型就行;关掉开关,它会回到原来的样子。", "Connected · pick magpie's models with {cmd} in {agent}": "已接入 · 在 {agent} 里用 {cmd} 选 magpie 的模型", "Connected · magpie's models are in {agent}'s model list": "已接入 · magpie 的模型在 {agent} 的模型列表里", "Connect {agent} to magpie": "将 {agent} 接入 magpie", diff --git a/internal/gui/tests/agent-connect.test.cjs b/internal/gui/tests/agent-connect.test.cjs index 92ed0d73..5fa74727 100644 --- a/internal/gui/tests/agent-connect.test.cjs +++ b/internal/gui/tests/agent-connect.test.cjs @@ -55,7 +55,7 @@ function server(lang, posts) { const words = { en: { off: "Not connected · Codex uses its own settings", on: "Connected · pick magpie's models with /model in Codex", go: "Disconnect", done: /Codex is connected to magpie/ }, - zh: { off: "未接入 · Codex 用它自己的设置", on: "已接入 · 在 Codex 里用 /model 选 magpie 的模型", go: "断开", done: /Codex 已接入 magpie/ }, + zh: { off: "未接入 · 用 Codex 自己的设置", on: "已接入 · 在 Codex 里用 /model 选 magpie 的模型", go: "断开", done: /Codex 已接入 magpie/ }, }; const row = (id) => `.row.agent[data-id="${id}"]`; @@ -86,7 +86,7 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", const tops = () => page.evaluate(() => [document.scrollingElement.scrollTop, document.querySelector("#view-agents")?.scrollTop]); const top = await tops(); const sw = () => page.locator(`${row("codex")} .ag-conn`); - const said = () => page.locator(`${row("codex")} .ag-conn-line`).textContent(); + const said = () => page.locator(`${row("codex")} .ag-st`).textContent(); assert.equal(await page.locator(`${row("cursor")} .ag-conn`).count(), 0, "nothing of magpie's to connect it to"); assert.equal(await sw().getAttribute("role"), "switch"); diff --git a/internal/gui/tests/agent-disconnect.test.cjs b/internal/gui/tests/agent-disconnect.test.cjs index 6025ac89..3161bcfc 100644 --- a/internal/gui/tests/agent-disconnect.test.cjs +++ b/internal/gui/tests/agent-disconnect.test.cjs @@ -15,7 +15,7 @@ const { test } = require("node:test"); const { chromium, webkit } = require("playwright"); const assets = path.resolve(__dirname, "../assets"); -const options = ["gpt-5.4", "magpie/relay/m1"].map((m) => ({ value: m, label: "Label " + m })); +const options = ["gpt-5.4", "magpie/relay/m1"].map((m) => ({ value: m, label: "Label " + m, ...(m.startsWith("magpie/") ? { ref: "relay/m1" } : {}) })); const fresh = () => ({ agents: [ { id: "codex", name: "Codex", icon: "generic", path: "/fixture/codex", wired: true, fields: [{ key: "model", label: "model", value: "magpie/relay/m1", options }] }, @@ -55,7 +55,7 @@ function server(lang, posts) { const words = { en: { item: "Disconnect from magpie", ask: "Disconnect Codex from magpie?", go: "Disconnect", cancel: "Cancel", done: /Codex no longer goes through magpie/ }, - zh: { item: "断开 magpie(还原配置)", ask: "断开 Codex 与 magpie 的连接?", go: "断开", cancel: "取消", done: /Codex 已不再经过 magpie/ }, + zh: { item: "断开 magpie(还原配置)", ask: "断开 Codex 和 magpie?", go: "断开", cancel: "取消", done: /Codex 已不再经过 magpie/ }, }; const row = (id) => `.row.agent[data-id="${id}"]`; @@ -111,12 +111,11 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", await ask.waitFor({ state: "detached" }); assert.deepEqual(posts, []); - // the model picker has it too, beside Default; Claude Code's hasn't - await page.locator(`${row("claude")} .field[data-key="model"]`).click(); - await page.locator("#pop:not([hidden]) #list li").first().waitFor(); - assert.equal(await page.locator("#list li", { hasText: w.item }).count(), 0); - await page.keyboard.press("Escape"); - await page.locator("#pop").waitFor({ state: "hidden" }); + // the model picker, in the opened row, has it too, beside Default; + // Claude Code, not connected, has no picker to open + assert.equal(await page.locator(`${row("claude")} .field`).count(), 0); + assert.equal(await page.locator(`${row("claude")} .ag-link`).count(), 0); + await page.locator(`${row("codex")} .ag-link`).click(); await page.locator(`${row("codex")} .field[data-key="model"]`).click(); const pickItem = page.locator("#list li", { hasText: w.item }); await pickItem.waitFor(); @@ -128,7 +127,7 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", await ask.locator(".bar button", { hasText: w.go }).click(); await ask.waitFor({ state: "detached" }); - await page.waitForFunction((r) => document.querySelector(r + ' .field[data-key="model"] .v')?.textContent === "Label gpt-5.4", row("codex")); + await page.waitForFunction((r) => document.querySelector(r + " .ag-conn")?.getAttribute("aria-checked") === "false", row("codex")); assert.deepEqual(posts, ["/api/agents/disconnect/codex"]); assert.match(await page.locator("#status").textContent(), w.done); await openMenu("codex"); diff --git a/internal/gui/tests/agent-layout.test.cjs b/internal/gui/tests/agent-layout.test.cjs index 21fdce86..811cf62d 100644 --- a/internal/gui/tests/agent-layout.test.cjs +++ b/internal/gui/tests/agent-layout.test.cjs @@ -1,5 +1,6 @@ // An agent's name must stay readable when the main window is narrow or -// enlarged, without making the model and effort controls run past the row. +// enlarged, without making its 「接入」 switch and the link that opens it run +// past the row; the switches line up down the list. const assert = require("node:assert/strict"); const fs = require("node:fs/promises"); const path = require("node:path"); @@ -11,16 +12,16 @@ const assets = path.resolve(__dirname, "../assets"); const state = { agents: [ { - id: "claude", name: "Claude Code", path: "/test/claude.json", icon: "claudecode-color", + id: "claude", name: "Claude Code", path: "/test/claude.json", icon: "claudecode-color", wired: true, fields: [ - { key: "model", label: "model", value: "claude-sonnet-4.5", options: [{ value: "claude-sonnet-4.5", label: "Claude Sonnet 4.5", icon: "claude-color" }] }, + { key: "model", label: "model", value: "claude-sonnet-4.5", options: [{ value: "claude-sonnet-4.5", label: "Claude Sonnet 4.5", icon: "claude-color", ref: "claude/claude-sonnet-4.5" }] }, { key: "effort", label: "effort", value: "high", options: [{ value: "high", label: "High" }] }, ], }, { - id: "codex", name: "Codex", path: "/test/codex.toml", icon: "codex-color", + id: "codex", name: "Codex", path: "/test/codex.toml", icon: "codex-color", wired: true, fields: [ - { key: "model", label: "model", value: "gpt-6.1-sol", options: [{ value: "gpt-6.1-sol", label: "GPT-6.1-Sol", icon: "openai" }] }, + { key: "model", label: "model", value: "gpt-6.1-sol", options: [{ value: "gpt-6.1-sol", label: "GPT-6.1-Sol", icon: "openai", ref: "openai/gpt-6.1-sol" }] }, { key: "effort", label: "effort", value: "high", options: [{ value: "high", label: "Highest" }] }, ], }, @@ -64,32 +65,27 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", await page.goto("http://magpie.test/"); await page.waitForLoadState("networkidle"); - const row = page.locator('.row.agent[data-id="claude"]'); - const who = row.locator(".who"); - const name = row.locator(".name"); - const fields = row.locator(".fields"); - await fields.waitFor(); - for (const width of [520, 560, 600]) { + await page.locator('.row.agent[data-id="claude"] .ag-conn').waitFor(); + const boxes = () => page.locator(".row.agent").evaluateAll((els) => els.map((el) => { + const box = (e) => { const r = e.getBoundingClientRect(); return { x: r.x, y: r.y, right: r.right, bottom: r.bottom, width: r.width }; }; + return { row: box(el), who: box(el.querySelector(".who")), controls: [...el.querySelectorAll(":scope > .ag-link, :scope > .ag-conn")].map(box) }; + })); + for (const width of [520, 560, 600, 660, 601, 960]) { await page.setViewportSize({ width, height: 700 }); - const [rb, wb, nb, fb] = await Promise.all([row.boundingBox(), who.boundingBox(), name.boundingBox(), fields.boundingBox()]); - assert(wb.width >= 150, `agent identity column is too narrow at ${width}px: ${JSON.stringify({ rb, wb, nb, fb })}`); - assert(fb.y >= wb.y + wb.height - 1, `controls should wrap below the agent name at ${width}px: ${JSON.stringify({ wb, fb })}`); - assert(Math.abs(fb.x - wb.x) < 1, `wrapped controls should align with the name at ${width}px: ${JSON.stringify({ wb, fb })}`); - assert(fb.x + fb.width <= rb.x + rb.width - 8, `controls must stay inside the row at ${width}px: ${JSON.stringify({ rb, fb })}`); + const rows = await boxes(); + for (const { row, who, controls } of rows) { + assert(who.width >= 150, `agent identity column is too narrow at ${width}px: ${JSON.stringify({ row, who })}`); + assert.equal(controls.length, 2, "the link and the switch"); + for (const c of controls) { + assert(c.right <= row.right - 8, `controls must stay inside the row at ${width}px: ${JSON.stringify({ row, c })}`); + // beside the name, or on a line below it in a narrow window + const ok = width > 600 ? c.x >= who.right && c.y < who.bottom : c.x >= who.right || c.y >= who.bottom - 1; + assert(ok, `controls stay clear of the name at ${width}px: ${JSON.stringify({ who, c })}`); + } + } + const rights = rows.map((r) => r.controls[1].right); + assert(Math.abs(rights[0] - rights[1]) < 1, `the switches line up at ${width}px: ${JSON.stringify(rights)}`); if (width === 520) narrowShot = await page.screenshot(); - } - - // The default 660px window and widths above the breakpoint keep - // the controls beside the names and their right edges aligned. - for (const width of [660, 601, 960]) { - await page.setViewportSize({ width, height: 700 }); - const boxes = await page.locator(".row.agent").evaluateAll((els) => els.map((el) => { - const whoBox = el.querySelector(".who").getBoundingClientRect(); - const fieldsBox = el.querySelector(".fields").getBoundingClientRect(); - return { who: { x: whoBox.x, y: whoBox.y, right: whoBox.right, bottom: whoBox.bottom }, fields: { x: fieldsBox.x, y: fieldsBox.y, right: fieldsBox.right } }; - })); - assert(boxes.every(({ who, fields }) => fields.y < who.bottom && fields.x > who.right), `controls should stay beside names at ${width}px: ${JSON.stringify(boxes)}`); - assert(Math.abs(boxes[0].fields.right - boxes[1].fields.right) < 1, `control columns should remain aligned at ${width}px: ${JSON.stringify(boxes)}`); if (width === 660) defaultShot = await page.screenshot(); } }); diff --git a/internal/gui/tests/agent-model-groups.test.cjs b/internal/gui/tests/agent-model-groups.test.cjs index dd90c477..3d688e4a 100644 --- a/internal/gui/tests/agent-model-groups.test.cjs +++ b/internal/gui/tests/agent-model-groups.test.cjs @@ -25,7 +25,7 @@ async function serve(route) { if (url.pathname === "/wails/runtime.js") return route.fulfill({ contentType: "text/javascript", body: "export const Window = {};" }); if (url.pathname === "/api/state") return json({ agents: [{ - id: "codex", name: "Codex", path: "/test/config.toml", icon: "codex-color", + id: "codex", name: "Codex", path: "/test/config.toml", icon: "codex-color", wired: true, fields: [{ key: "model", label: "model", value: "magpie/openai/gpt-6", options: [{ value: "magpie/openai/gpt-6", label: "GPT-6", ref: "openai/gpt-6" }] }], models: { shown: models.length, listed: models.length }, }], @@ -56,7 +56,8 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", await browser.close(); }); await page.goto("http://magpie.test/"); - await page.locator('.row.agent[data-id="codex"] .ag-models').click(); + await page.locator('.row.agent[data-id="codex"] .ag-link').click(); + await page.locator('.row.agent[data-id="codex"] .ag-exp .ag-chips .ag-quiet').click(); const pop = page.locator(".am-pop:not(.leaving)"); await pop.waitFor(); const row = (name) => pop.locator(".am-mr", { hasText: name }); diff --git a/internal/gui/tests/agent-models-all-hidden.test.cjs b/internal/gui/tests/agent-models-all-hidden.test.cjs index b3ab53bb..625534bf 100644 --- a/internal/gui/tests/agent-models-all-hidden.test.cjs +++ b/internal/gui/tests/agent-models-all-hidden.test.cjs @@ -1,8 +1,9 @@ // Run with Node's test runner and Playwright on the module path; see README.md. -// Every model taken out of an agent's lists (#356, DeepSeek Harness): the -// line under its name stays, "Showing 0 / 3 models", and opens the list to -// put them back. Its pickers then list only the agent's own models, and the -// line had gone with the catalog entries, leaving no way back. +// Every model taken out of an agent's lists (#356, DeepSeek Harness): its +// opened row still says how many are hidden, "3 hidden", and its Pick opens +// the list to put them back. Its pickers then list only the agent's own +// models, and the way in had gone with the catalog entries, leaving no way +// back. const assert = require("node:assert/strict"); const fs = require("node:fs/promises"); const path = require("node:path"); @@ -24,7 +25,7 @@ function fixture(lang) { ...list.filter((m) => !m.hidden).map((m) => ({ value: "magpie/" + m.id, label: m.name, ref: m.id }))]; return { agents: [{ - id: "dsh", name: "DeepSeek Harness", path: "/test/.dsh/config.json", icon: "deepseek-color", + id: "dsh", name: "DeepSeek Harness", path: "/test/.dsh/config.json", icon: "deepseek-color", wired: true, fields: [{ key: "model", label: "model", value: "deepseek-v4-pro", options }], models: count(), }], @@ -59,8 +60,8 @@ function fixture(lang) { } const W = { - en: { all: "All 3 models", none: "Showing 0 / 3 models", hideAll: "Hide all", showAll: "Show all" }, - zh: { all: "全部 3 个模型", none: "显示 0 / 3 个模型", hideAll: "全部隐藏", showAll: "全部显示" }, + en: { none: "3 hidden", hideAll: "Hide all", showAll: "Show all" }, + zh: { none: "已隐藏 3 个", hideAll: "全部隐藏", showAll: "全部显示" }, }; for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", "webkit"])) { @@ -82,9 +83,14 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", await browser.close(); }); await page.goto("http://magpie.test/"); - const entry = page.locator('.row.agent[data-id="dsh"] .ag-models'); + const row = page.locator('.row.agent[data-id="dsh"]'); + await row.locator(".ag-link").click(); + const entry = row.locator(".ag-exp .ag-chips .ag-quiet"); + const hint = row.locator(".ag-exp .ag-chips .ag-hint"); + const shown = () => row.locator(".ag-st-t").innerText(); await entry.waitFor(); - assert.equal((await entry.innerText()).trim(), w.all); + assert.equal(await hint.count(), 0, "none hidden"); + assert.match(await shown(), /^3 /); const scroll = () => page.evaluate(() => [scrollY, document.scrollingElement.scrollTop, $("#view-agents").scrollTop]); const was = await scroll(); @@ -94,17 +100,19 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", await pop.locator(".am-hide").click(); await page.waitForTimeout(150); assert.equal(fx.posts.at(-1).length, 3); - assert.equal((await entry.innerText()).trim(), w.none); + assert.equal((await hint.innerText()).trim(), w.none); + assert.match(await shown(), /^0 /); + assert.equal(await row.locator(".ag-chip").count(), 0, "no provider gives one"); + assert.equal(await pop.count(), 1, "the list stays open as the row is drawn again"); - // closed, the agents are drawn again from the state: the line stays + // closed, the agents are drawn again from the state: the way back stays const before = fx.stateCalls(); await page.keyboard.press("Escape"); await pop.waitFor({ state: "detached" }); await page.waitForTimeout(300); assert(fx.stateCalls() > before, "the state is asked for again"); await entry.waitFor(); - assert.equal((await entry.innerText()).trim(), w.none); - assert.equal(await entry.locator("b").innerText(), "0"); + assert.equal((await hint.innerText()).trim(), w.none); // and opens the list to show them all again await entry.click(); @@ -114,11 +122,12 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", await showAll.click(); await page.waitForTimeout(150); assert.deepEqual(fx.posts.at(-1), []); - assert.equal((await entry.innerText()).trim(), w.all); + assert.equal(await hint.count(), 0); + assert.equal(await row.locator(".ag-chip").innerText().then((s) => s.replace(/\s+/g, " ")), "DeepSeek 3"); await page.keyboard.press("Escape"); await pop.waitFor({ state: "detached" }); await page.waitForTimeout(300); - assert.equal((await entry.innerText()).trim(), w.all); + assert.equal(await hint.count(), 0); assert.deepEqual(await scroll(), was, "no click moved the page"); assert.deepEqual(errors, []); }); diff --git a/internal/gui/tests/agent-models-loading.test.cjs b/internal/gui/tests/agent-models-loading.test.cjs index 113f58d0..32c2535b 100644 --- a/internal/gui/tests/agent-models-loading.test.cjs +++ b/internal/gui/tests/agent-models-loading.test.cjs @@ -24,7 +24,7 @@ async function serve(route) { if (url.pathname === "/wails/runtime.js") return route.fulfill({ contentType: "text/javascript", body: "export const Window = {};" }); if (url.pathname === "/api/state") return json({ agents: [{ - id: "codex", name: "Codex", path: "/test/config.toml", icon: "codex-color", + id: "codex", name: "Codex", path: "/test/config.toml", icon: "codex-color", wired: true, fields: [{ key: "model", label: "model", value: "magpie/openai/gpt-5.5", options: [{ value: "magpie/openai/gpt-5.5", label: "GPT-5.5", ref: "openai/gpt-5.5" }] }], models: { shown: 3, listed: 3 }, }], @@ -52,7 +52,9 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", await page.route("**/*", serve); t.after(() => browser.close()); await page.goto("http://magpie.test/"); - const entry = page.locator('.row.agent[data-id="codex"] .ag-models'); + // the list opens from Pick in the connected row, opened + await page.locator('.row.agent[data-id="codex"] .ag-link').click(); + const entry = page.locator('.row.agent[data-id="codex"] .ag-exp .ag-chips .ag-quiet'); await entry.waitFor(); const boxes = () => page.locator(".am-pop:not(.leaving)").count(); diff --git a/internal/gui/tests/agent-models.test.cjs b/internal/gui/tests/agent-models.test.cjs index 9ddf2a8f..9a35e0a8 100644 --- a/internal/gui/tests/agent-models.test.cjs +++ b/internal/gui/tests/agent-models.test.cjs @@ -1,7 +1,8 @@ // Run with Node's test runner and Playwright on the module path; see README.md. // An agent's model list (@ChongkaiX on X: pick which models show in Codex's -// /model): the line under its name counts them and opens the list, where a -// click on a row takes a model out or puts it back, at once. +// /model): its connected row, opened, counts them by provider and those +// hidden, and Pick opens the list, where a click on a row takes a model out +// or puts it back, at once. const assert = require("node:assert/strict"); const fs = require("node:fs/promises"); const path = require("node:path"); @@ -19,10 +20,18 @@ const models = () => [ function fixture(lang) { const list = models(); const posts = []; - const count = () => ({ shown: list.filter((m) => !m.hidden).length, listed: list.length }); + const count = () => { + const by = []; + for (const m of list.filter((m) => !m.hidden)) { + let g = by.find((x) => x.name === m.group); + if (!g) by.push(g = { name: m.group, icon: m.icon, n: 0 }); + g.n++; + } + return { shown: list.filter((m) => !m.hidden).length, listed: list.length, by }; + }; const state = () => ({ agents: [{ - id: "codex", name: "Codex", path: "/test/config.toml", icon: "codex-color", + id: "codex", name: "Codex", path: "/test/config.toml", icon: "codex-color", wired: true, fields: [{ key: "model", label: "model", value: "magpie/openai/gpt-5.5", options: [{ value: "magpie/openai/gpt-5.5", label: "GPT-5.5", ref: "openai/gpt-5.5" }] }], models: count(), }], @@ -55,8 +64,8 @@ function fixture(lang) { } const W = { - en: { entry: "Showing 8 / 31 models", after: "Showing 7 / 31 models", all: "All 31 models", title: "Codex's model list", current: "Current", shown: "Shown", hideAll: "Hide all", one: "Showing 1 / 31 models", showAll: "Show all" }, - zh: { entry: "显示 8 / 31 个模型", after: "显示 7 / 31 个模型", all: "全部 31 个模型", title: "Codex 的模型列表", current: "在用", shown: "已显示", hideAll: "全部隐藏", one: "显示 1 / 31 个模型", showAll: "全部显示" }, + en: { entry: "23 hidden", after: "24 hidden", all: "", title: "Codex's model list", current: "Current", shown: "Shown", hideAll: "Hide all", one: "30 hidden", showAll: "Show all" }, + zh: { entry: "已隐藏 23 个", after: "已隐藏 24 个", all: "", title: "Codex 的模型列表", current: "在用", shown: "已显示", hideAll: "全部隐藏", one: "已隐藏 30 个", showAll: "全部显示" }, }; for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", "webkit"])) { @@ -78,13 +87,16 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", await browser.close(); }); await page.goto("http://magpie.test/"); - const entry = page.locator('.row.agent[data-id="codex"] .ag-models'); + const row = page.locator('.row.agent[data-id="codex"]'); + await row.locator(".ag-link").click(); + const entry = row.locator(".ag-exp .ag-chips .ag-quiet"); + const hidden = async () => (await row.locator(".ag-exp .ag-chips .ag-hint").allInnerTexts()).join("").trim(); + const chips = async () => (await row.locator(".ag-chip").allInnerTexts()).map((s) => s.replace(/\s+/g, " ")); await entry.waitFor(); - assert.equal((await entry.innerText()).trim(), w.entry); - // under the name, not beside it - const name = await page.locator('.row.agent[data-id="codex"] .who .name').boundingBox(); - const eb = await entry.boundingBox(); - assert(eb.y >= name.y + name.height - 2, "the line is under the name"); + assert.equal(await hidden(), w.entry); + assert.deepEqual(await chips(), ["Routing groups 1", "OpenAI 6", "OpenRouter 1"].map((c) => lang === "zh" ? c.replace("Routing groups", "路由组") : c)); + // the line under the name counts them too + assert.match(await row.locator(".who .ag-st-t").innerText(), /^8\b/); const scroll = () => page.evaluate(() => [scrollY, document.scrollingElement.scrollTop, $("#view-agents").scrollTop]); const was = await scroll(); @@ -117,8 +129,9 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", // a click takes one out at once, the count under the name with it const mini = pop.locator(".am-mr", { hasText: "O4-MINI" }); await mini.click(); - await page.waitForFunction(() => document.querySelector(".ag-models")?.innerText.includes("7")); - assert.equal((await entry.innerText()).trim(), w.after); + await page.waitForFunction(() => document.querySelector(".ag-chips .ag-hint")?.innerText.includes("24")); + assert.equal(await hidden(), w.after); + assert.equal((await chips())[1], "OpenAI 5"); await page.waitForTimeout(150); assert.equal(fx.posts.length, 1); assert(fx.posts[0].includes("openai/o4-mini") && fx.posts[0].includes("openrouter/m5"), "the whole list goes"); @@ -154,7 +167,7 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", await page.waitForTimeout(150); assert.equal(fx.posts.at(-1).length, 30); assert(!fx.posts.at(-1).includes("openai/gpt-5.5")); - assert.equal((await entry.innerText()).trim(), w.one); + assert.equal(await hidden(), w.one); assert(await hideAll.isDisabled()); assert.equal(await pop.locator(".am-g").nth(1).locator(".c").innerText(), "1 / 6"); @@ -162,7 +175,7 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", await showAll.click(); await page.waitForTimeout(150); assert.deepEqual(fx.posts.at(-1), []); - assert.equal((await entry.innerText()).trim(), w.all); + assert.equal(await hidden(), w.all); assert(await showAll.isDisabled()); assert(!(await hideAll.isDisabled())); diff --git a/internal/gui/tests/agent-tweak-fold.test.cjs b/internal/gui/tests/agent-tweak-fold.test.cjs index 3156458d..f530bac8 100644 --- a/internal/gui/tests/agent-tweak-fold.test.cjs +++ b/internal/gui/tests/agent-tweak-fold.test.cjs @@ -16,9 +16,9 @@ const opt = (v) => ({ value: v, label: v }); function agents({ ultracode = "", effort = "", tierEffort = "", subEffort = "", model = "" } = {}) { return [ { - id: "claude", name: "Claude Code", path: "/test/claude.json", icon: "claudecode-color", + id: "claude", name: "Claude Code", path: "/test/claude.json", icon: "claudecode-color", wired: true, fields: [ - { key: "model", label: "model", value: model, options: [opt("claude-sonnet-4.5")] }, + { key: "model", label: "model", value: model, options: [{ ...opt("claude-sonnet-4.5"), ref: "claude/claude-sonnet-4.5" }] }, { key: "effort", label: "effort", value: effort, options: [opt("high"), opt("xhigh")] }, { key: "ultracode", label: "ultracode", value: ultracode, options: [opt("on")] }, { key: "opus_effort", label: "opus effort", value: tierEffort, options: [opt("high")] }, @@ -91,6 +91,8 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", test(`${engine}: turning ultracode on keeps every agent in view`, async (t) => { const { page, errors } = await open(t, engine, {}); assert.deepEqual((await arranged(page)).folded, [], "a fresh magpie shows them all"); + // in its connected row, opened + await page.locator('.row.agent[data-id="claude"] .ag-link').click(); const toggle = page.locator('.row.agent[data-id="claude"] [data-key="ultracode"]'); await toggle.click(); await page.waitForFunction(() => document.querySelector('.row.agent[data-id="claude"] [data-key="ultracode"]')?.getAttribute("aria-pressed") === "true"); diff --git a/internal/gui/tests/agy-launch.test.cjs b/internal/gui/tests/agy-launch.test.cjs index 1f4ca27e..2f0e9ca9 100644 --- a/internal/gui/tests/agy-launch.test.cjs +++ b/internal/gui/tests/agy-launch.test.cjs @@ -1,8 +1,8 @@ // Run with Node's test runner and Playwright on the module path; see README.md. // Antigravity CLI (agy) takes magpie's gateway only from its environment, so // its row on the Agents page has a square that copies the command starting -// it on magpie, while it is on one of magpie's models: the command in its -// tooltip, one click copying it (posted to /api/copy) and saying so, with the +// it on magpie, once it is connected (its model picker stays in the row, as +// agy picks no model once started): the command in its tooltip, one click copying it (posted to /api/copy) and saying so, with the // page left where it was; none on an agent without one; in the tray panel's // opened row too; the words in Chinese. No backend: the API is faked here. const assert = require("node:assert/strict"); @@ -13,9 +13,9 @@ const { chromium, webkit } = require("playwright"); const assets = path.resolve(__dirname, "../assets"); const LAUNCH = "GEMINI_API_KEY=magpie-agy GOOGLE_GEMINI_BASE_URL=http://127.0.0.1:3425 agy --model 'magpie/deepseek/pro'"; -const models = ["magpie/deepseek/pro", "model-b"].map((m) => ({ value: m, label: m })); +const models = [{ value: "magpie/deepseek/pro", label: "magpie/deepseek/pro", ref: "deepseek/pro" }, { value: "model-b", label: "model-b" }]; const agent = (id, name, launch) => ({ - id, name, path: "/test/" + id, launch, + id, name, path: "/test/" + id, launch, wired: !!launch, fields: [{ key: "model", label: "model", value: "magpie/deepseek/pro", options: models }], }); const state = { diff --git a/internal/gui/tests/claude-direct.test.cjs b/internal/gui/tests/claude-direct.test.cjs index e0cbc41e..adb8e5a3 100644 --- a/internal/gui/tests/claude-direct.test.cjs +++ b/internal/gui/tests/claude-direct.test.cjs @@ -7,7 +7,8 @@ // a model Claude Code asks Anthropic for itself says so: the row's tooltip // says it isn't through magpie and why the file names no magpie endpoint, // and picking one says "straight to Anthropic". A magpie model says neither. -// No click moves the page. In English and Chinese, Chromium and WebKit. +// The model is picked in the connected row, opened from its link. No click +// moves the page. In English and Chinese, Chromium and WebKit. const assert = require("node:assert/strict"); const fs = require("node:fs/promises"); const path = require("node:path"); @@ -26,7 +27,7 @@ const filler = [{ key: "model", label: "model", value: "magpie/deepseek/pro", op const fresh = () => ({ agents: [ ...Array.from({ length: 5 }, (_, i) => ({ id: "agent-" + i, name: "Agent " + i, path: "/test/" + i, fields: filler })), - { id: "claude", name: "Claude Code", icon: "claudecode-color", path: "~/.claude/settings.json", + { id: "claude", name: "Claude Code", icon: "claudecode-color", path: "~/.claude/settings.json", wired: true, fields: [{ key: "model", label: "model", value: "sonnet", options }] }, ...Array.from({ length: 8 }, (_, i) => ({ id: "more-" + i, name: "More " + i, path: "/test/m" + i, fields: filler })), ], @@ -85,7 +86,8 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", const sets = []; await page.route("**/*", server(lang, sets)); await page.goto("http://magpie.test/"); - const field = page.locator(`${row} .field[data-key="model"]`); + await page.locator(`${row} .ag-link`).click(); + const field = page.locator(`${row} .ag-exp .field[data-key="model"]`); await field.waitFor(); // the alias, as the model it stands for, with Claude's logo @@ -97,7 +99,8 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", // the page scrolled, a pick moves nothing and says where it goes const view = page.locator("#view-agents"); await page.mouse.move(400, 300); - for (let i = 0; i < 2; i++) { await page.mouse.wheel(0, 30); await page.waitForTimeout(20); } + // down to the opened row's picker, so the click has no need to scroll + for (let i = 0; i < 8; i++) { await page.mouse.wheel(0, 30); await page.waitForTimeout(20); } await page.waitForTimeout(300); const top = await view.evaluate((v) => v.scrollTop); assert(top > 0, "the list must be scrolled"); diff --git a/internal/gui/tests/claude-effort.test.cjs b/internal/gui/tests/claude-effort.test.cjs index 82209f48..a65ab6a9 100644 --- a/internal/gui/tests/claude-effort.test.cjs +++ b/internal/gui/tests/claude-effort.test.cjs @@ -5,8 +5,9 @@ // a click posts ultracode on, lights it and says the restart; the effort // slider's change says it too. On Opus 4.5 the slider has its three levels // and no ultracode; on Haiku 4.5 there is neither an effort nor ultracode. -// The window scrolled down stays where it is through the clicks. English -// and Chinese, Chromium and WebKit, the window and the tray panel. +// In the window they are in a connected row, opened from its link; the +// window scrolled down stays where it is through the clicks. English and +// Chinese, Chromium and WebKit, the window and the tray panel. const assert = require("node:assert/strict"); const fs = require("node:fs/promises"); const path = require("node:path"); @@ -16,9 +17,9 @@ const { chromium, webkit } = require("playwright"); const assets = path.resolve(__dirname, "../assets"); const five = ["low", "medium", "high", "xhigh", "max"]; const claude = (id, model, efforts, ultracode) => ({ - id, name: "Claude Code " + id, icon: "claudecode-color", path: "/test/" + id, + id, name: "Claude Code " + id, icon: "claudecode-color", path: "/test/" + id, wired: true, fields: [ - { key: "model", label: "model", value: model, options: [{ value: model, icon: "claude-color" }] }, + { key: "model", label: "model", value: model, options: [{ value: model, icon: "claude-color", ref: "claude/" + model }] }, { key: "effort", label: "effort", value: "", options: efforts.map((value) => ({ value })) }, { key: "ultracode", label: "ultracode", value: ultracode, options: efforts.includes("xhigh") ? [{ value: "on", note: "Claude plans a workflow for each substantive task" }] : [] }, ], @@ -82,12 +83,19 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", await t.test("the window", async () => { const sets = []; const page = await open("", sets); - // the rows as the model has them - assert.equal(await page.locator(`${row("o55")} .field[data-key="ultracode"]`).count(), 1); + // the rows as the model has them, each opened in turn + const expand = async (id) => { + await page.locator(`${row(id)} .ag-link`).click(); + await page.locator(`${row(id)} .ag-exp`).waitFor(); + }; + await expand("o45"); assert.equal(await page.locator(`${row("o45")} .field[data-key="ultracode"]`).count(), 0, "ultracode on a model without xhigh"); + assert.equal(await page.locator(`${row("o45")} .field[data-key="effort"]`).count(), 1); + await expand("h45"); assert.equal(await page.locator(`${row("h45")} .field[data-key="effort"]`).count(), 0, "an effort for Haiku 4.5"); assert.equal(await page.locator(`${row("h45")} .field[data-key="ultracode"]`).count(), 0); - assert.equal(await page.locator(`${row("o45")} .field[data-key="effort"]`).count(), 1); + await expand("o55"); + assert.equal(await page.locator(`${row("o55")} .field[data-key="ultracode"]`).count(), 1); const sq = page.locator(`${row("o55")} .field[data-key="ultracode"]`); assert.equal(await sq.getAttribute("aria-pressed"), "false"); diff --git a/internal/gui/tests/claude-picker-fold.test.cjs b/internal/gui/tests/claude-picker-fold.test.cjs index d37eb57e..08a7a2d0 100644 --- a/internal/gui/tests/claude-picker-fold.test.cjs +++ b/internal/gui/tests/claude-picker-fold.test.cjs @@ -12,7 +12,8 @@ // a click on it leaves the keys to the filter (focus went to the page, and // Esc and the arrows did nothing); a query shows the rows it finds, so an id // typed in full is picked with Enter (which only opened the fold); and a -// star on a dated id still shows (its row was gone, Favorites empty). +// star on a dated id still shows (its row was gone, Favorites empty). The +// picker is the connected row's, opened from its link. const assert = require("node:assert/strict"); const fs = require("node:fs/promises"); const path = require("node:path"); @@ -37,7 +38,7 @@ const options = [ via("claude/claude-opus-4-5-20251101", "Claude Opus 4.5", "claude/claude-opus-4-5"), ]; const state = (value) => ({ - agents: [{ id: "claude", name: "Claude Code", icon: "claudecode-color", path: "/test/settings.json", + agents: [{ id: "claude", name: "Claude Code", icon: "claudecode-color", path: "/test/settings.json", wired: true, fields: [{ key: "model", label: "model", value, options }] }], profiles: [], }); @@ -89,7 +90,8 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", await page.route("**/*", server(lang, value, sets)); await page.goto("http://magpie.test/"); await page.locator(row).waitFor(); - await page.locator(`${row} .field[data-key="model"]`).click(); + await page.locator(`${row} .ag-link`).click(); + await page.locator(`${row} .ag-exp .field[data-key="model"]`).click(); await page.locator("#pop:not([hidden]) #list li").first().waitFor(); await page.waitForTimeout(400); // the picker grows open return page; @@ -147,7 +149,8 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", await page.locator(`${row} .field[data-key="model"]`).click(); await page.locator("#pop:not([hidden]) #list li").first().waitFor(); list = await rows(page); - assert.deepEqual(list[1], { v: "Claude Opus 5.5", n: "me@example.com · via magpie", cur: true, fold: false }, JSON.stringify(list)); + // after Default and, connected, the way back + assert.deepEqual(list[2], { v: "Claude Opus 5.5", n: "me@example.com · via magpie", cur: true, fold: false }, JSON.stringify(list)); assert.equal(list.filter((r) => r.fold).map((r) => r.v).join(), w.one); await page.context().close(); }); diff --git a/internal/gui/tests/cli-update.test.cjs b/internal/gui/tests/cli-update.test.cjs index 3b13ac4a..0179606d 100644 --- a/internal/gui/tests/cli-update.test.cjs +++ b/internal/gui/tests/cli-update.test.cjs @@ -111,10 +111,18 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", const posts = []; const page = await open("en", posts); const view = page.locator("#view-agents"); - // the reader scrolls down a little, so a scroll would show - await page.mouse.move(400, 300); - for (let i = 0; i < 6; i++) { await page.mouse.wheel(0, 40); await page.waitForTimeout(20); } - await page.waitForTimeout(300); + // the reader scrolls down until a pill is in view, clear of the + // footer, so a scroll would show + const wheelTo = async (id) => { + const pill = page.locator(`${row(id)} .ag-up`); + await page.mouse.move(400, 300); + for (let i = 0; i < 30 && await pill.evaluate((b) => b.getBoundingClientRect().bottom > 460); i++) { + await page.mouse.wheel(0, 40); + await page.waitForTimeout(60); + } + await page.waitForTimeout(300); + }; + await wheelTo("codex"); const top = await view.evaluate((v) => v.scrollTop); assert(top > 0, "the list must be scrolled"); const was = await page.locator(`${row("codex")} .ag-up`).evaluate((b) => b.getBoundingClientRect().top); @@ -134,6 +142,7 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", assert(Math.abs(now - was) <= 3, `the row moved ${now - was}px`); // one that fails says why, and can be tried again + await wheelTo("gemini"); await page.locator(`${row("gemini")} .ag-up`).click(); await page.waitForTimeout(1000); assert.match(await page.locator("#status").textContent(), /EACCES/); diff --git a/internal/gui/tests/desktop-tiers.test.cjs b/internal/gui/tests/desktop-tiers.test.cjs index b4756d58..6405bb15 100644 --- a/internal/gui/tests/desktop-tiers.test.cjs +++ b/internal/gui/tests/desktop-tiers.test.cjs @@ -3,7 +3,8 @@ // tiers each take a model of their own from magpie (WilianWeng). Desktop has // no model field here for them to follow: the tiers' square says what an // unset one runs on instead, and its picker's reset entry is "Not set", not -// "Same as model". In English and Chinese. No backend: the API is faked here. +// "Same as model". Connected to magpie, the square is in the row opened from +// its link. In English and Chinese. No backend: the API is faked here. const assert = require("node:assert/strict"); const fs = require("node:fs/promises"); const path = require("node:path"); @@ -11,11 +12,11 @@ const { test } = require("node:test"); const { chromium, webkit } = require("playwright"); const assets = path.resolve(__dirname, "../assets"); -const models = [{ value: "magpie/v/glm", label: "GLM" }, { value: "magpie/v/flash", label: "Flash" }]; +const models = [{ value: "magpie/v/glm", label: "GLM", ref: "v/glm" }, { value: "magpie/v/flash", label: "Flash", ref: "v/flash" }]; const tiers = ["opus", "sonnet", "haiku", "fable"]; const fresh = () => ({ agents: [{ - id: "claude-desktop", name: "Claude Desktop", path: "/test/claude_desktop_config.json", icon: "claude-color", + id: "claude-desktop", name: "Claude Desktop", path: "/test/claude_desktop_config.json", icon: "claude-color", wired: true, fields: [ { key: "provider", label: "provider", value: "magpie", options: [{ value: "magpie", label: "magpie" }] }, ...tiers.map((tier) => ({ key: tier, label: tier, value: tier === "haiku" ? "magpie/v/flash" : "", options: models })), @@ -70,8 +71,9 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", t.after(() => browser.close()); await page.goto("http://magpie.test/"); await page.locator(cd).waitFor(); + await page.locator(`${cd} .ag-link`).click(); - const square = page.locator(`${cd} .field.extra[data-key="tiers"]`); + const square = page.locator(`${cd} .ag-exp .field.extra[data-key="tiers"]`); assert.equal(await square.count(), 1, "no tiers square"); assert.ok((await square.getAttribute("aria-label")).startsWith(w.summary), await square.getAttribute("aria-label")); const y = await page.evaluate(() => scrollY); diff --git a/internal/gui/tests/favorite-star.test.cjs b/internal/gui/tests/favorite-star.test.cjs index 194955eb..6cf3d32d 100644 --- a/internal/gui/tests/favorite-star.test.cjs +++ b/internal/gui/tests/favorite-star.test.cjs @@ -10,8 +10,8 @@ const { test } = require("node:test"); const { chromium, webkit } = require("playwright"); const assets = path.resolve(__dirname, "../assets"); -const models = ["claude-sonnet-5-5", "claude-opus-5-5"].map((m) => ({ value: m, label: "Label " + m })); -const agents = [{ id: "claude", name: "Claude Code", path: "/test/claude", fields: [{ key: "model", label: "model", value: "claude-sonnet-5-5", options: models }] }]; +const models = ["claude-sonnet-5-5", "claude-opus-5-5"].map((m) => ({ value: m, ref: "claude/" + m, label: "Label " + m })); +const agents = [{ id: "claude", name: "Claude Code", path: "/test/claude", wired: true, fields: [{ key: "model", label: "model", value: "claude-sonnet-5-5", options: models }] }]; function server(lang, theme) { return async (route) => { @@ -53,7 +53,11 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", await page.locator(`${row} .ag-sum`).click(); await page.waitForTimeout(700); await page.locator(`${row} .ag-open .field[data-key="model"]`).click(); - } else await page.locator(`${row} .field[data-key="model"]`).click(); + } else { + // the window: in the connected agent's opened row + await page.locator(`${row} .ag-link`).click(); + await page.locator(`${row} .ag-exp .field[data-key="model"]`).click(); + } const li = page.locator("#pop:not([hidden]) #list li").filter({ hasText: "Label claude-opus-5-5" }).first(); await li.waitFor(); const star = li.locator(".favorite"); diff --git a/internal/gui/tests/mobile-web.test.cjs b/internal/gui/tests/mobile-web.test.cjs index 6ef5896b..4afa5208 100644 --- a/internal/gui/tests/mobile-web.test.cjs +++ b/internal/gui/tests/mobile-web.test.cjs @@ -1,8 +1,9 @@ // Run with Node's test runner and Playwright on the module path; see README.md. // Phone navigation and content stay usable in both languages (#391). The // desktop screenshots match BASE_REF within a small rendering tolerance -// (origin/main by default); only API -// boundaries are faked, never the page's layout or scrolling helpers. +// (origin/main by default), but for the Agents page while BASE_REF hasn't its +// 「接入」 rows yet; only API boundaries are faked, never the page's layout or +// scrolling helpers. const assert = require("node:assert/strict"); const fs = require("node:fs/promises"); const path = require("node:path"); @@ -22,27 +23,33 @@ const usage = { ...groups[0], path: "/test/usage.jsonl", bucket: "day", agents: series: Array.from({ length: 30 }, (_, i) => ({ ...groups[0], label: "09-" + String(i + 1).padStart(2, "0") })) }; // Populated rows are essential: an empty Agents list cannot expose clipped // model/effort controls or make the desktop comparison cover those rows. -const modelOptions = [{ value: "claude-sonnet-4.5", label: "Claude Sonnet 4.5", icon: "claude-color" }, - { value: "gpt-6.1-sol", label: "GPT-6.1-Sol", icon: "openai" }]; +// Connected, so each opens on its pickers. +const modelOptions = [{ value: "claude-sonnet-4.5", label: "Claude Sonnet 4.5", icon: "claude-color", ref: "claude/claude-sonnet-4.5" }, + { value: "gpt-6.1-sol", label: "GPT-6.1-Sol", icon: "openai", ref: "openai/gpt-6.1-sol" }]; const modelField = (key, label, value = "") => ({ key, label, value, options: modelOptions }); const agentRows = [ - { id: "claude", name: "Claude Code", path: "/test/claude.json", icon: "claudecode-color", fields: [ + { id: "claude", name: "Claude Code", path: "/test/claude.json", icon: "claudecode-color", wired: true, fields: [ modelField("model", "model", "claude-sonnet-4.5"), { key: "effort", label: "thinking", value: "high", options: [{ value: "high", label: "High" }] }, ] }, - { id: "codex", name: "Codex", path: "/test/codex.toml", icon: "codex-color", fields: [ + { id: "codex", name: "Codex", path: "/test/codex.toml", icon: "codex-color", wired: true, fields: [ modelField("model", "model", "gpt-6.1-sol"), { key: "effort", label: "effort", value: "high", options: [{ value: "high", label: "High" }] }, modelField("subagent", "subagents"), { key: "signin", label: "sign-in", value: "magpie", options: [{ value: "magpie", label: "magpie" }, { value: "chatgpt", label: "ChatGPT" }] }, ] }, - { id: "omp", name: "omp", path: "/test/omp.json", icon: "omp", fields: [ + { id: "omp", name: "omp", path: "/test/omp.json", icon: "omp", wired: true, fields: [ modelField("model", "model", "gpt-6.1-sol"), modelField("subagent", "subagents"), modelField("small", "smol"), modelField("slow", "slow"), ] }, ]; const views = ["agents", "providers", "gateway", "routing", "usage", "library", "plugins", "settings"]; +function baselineFile(file) { + if (!baseline.has(file)) baseline.set(file, execFileSync("git", ["show", `${base}:internal/gui/assets/${file}`], { cwd: assets, maxBuffer: 4 * 1024 * 1024 })); + return baseline.get(file); +} + function server(lang, original = false, opened = []) { return async route => { const url = new URL(route.request().url()); @@ -73,10 +80,8 @@ function server(lang, original = false, opened = []) { const contentType = { ".html": "text/html", ".js": "text/javascript", ".css": "text/css", ".svg": "image/svg+xml", ".png": "image/png" }[path.extname(file)]; try { let body; - if (original) { - if (!baseline.has(file)) baseline.set(file, execFileSync("git", ["show", `${base}:internal/gui/assets/${file}`], { cwd: assets, maxBuffer: 4 * 1024 * 1024 })); - body = baseline.get(file); - } else body = await fs.readFile(path.join(assets, file)); + if (original) body = baselineFile(file); + else body = await fs.readFile(path.join(assets, file)); await route.fulfill({ body, contentType }); } catch { await route.fulfill({ status: 404 }); } }; @@ -131,6 +136,9 @@ async function agentsFit(page) { for (const agent of agentRows) { const row = page.locator(`.row.agent[data-id="${agent.id}"]`); await row.waitFor(); + // opened, its pickers in the row + await row.locator(".ag-link").click(); + await row.locator(".ag-exp").waitFor(); const bad = await row.evaluate(row => { const box = row.getBoundingClientRect(), bad = []; for (const field of row.querySelectorAll(".field")) { @@ -252,6 +260,7 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", const original = await open(browser, lang, width, { touch: false, original: true }); try { for (const view of views) { + if (view === "agents" && !baselineFile("app.js").includes("function connectPanel")) continue; await go(current, view); await go(original, view); await current.mouse.move(0, 0); await original.mouse.move(0, 0); // Removing hover can repaint the tab's shadow in a later frame. diff --git a/internal/gui/tests/model-pick.test.cjs b/internal/gui/tests/model-pick.test.cjs index 625a6c27..b06667e2 100644 --- a/internal/gui/tests/model-pick.test.cjs +++ b/internal/gui/tests/model-pick.test.cjs @@ -3,7 +3,8 @@ // still out (it answers with the whole state, which took seconds: Claude // Code's model took 5–10s to show, in the window and the tray panel alike). // The answer then draws what the config says; a refused pick puts the old -// model back and says why. The page never moves. In English and Chinese. +// model back and says why. The page never moves. In the window the picker +// is in the connected agent's opened row. In English and Chinese. // No backend: the API is faked here, its /api/set held for SLOW ms. const assert = require("node:assert/strict"); const fs = require("node:fs/promises"); @@ -13,9 +14,9 @@ const { chromium, webkit } = require("playwright"); const assets = path.resolve(__dirname, "../assets"); const SLOW = 2500; -const models = ["claude-sonnet-5-5", "claude-opus-5-5", "magpie/deepseek/pro", "magpie/kimi/k3"].map((m) => ({ value: m, label: "Label " + m })); +const models = ["claude-sonnet-5-5", "claude-opus-5-5", "magpie/deepseek/pro", "magpie/kimi/k3"].map((m) => ({ value: m, ref: m.replace(/^magpie\//, ""), label: "Label " + m })); const agent = (id, name) => ({ - id, name, path: "/test/" + id, + id, name, path: "/test/" + id, wired: id === "claude", fields: [{ key: "model", label: "model", value: "claude-sonnet-5-5", options: models }], }); const fresh = () => ({ @@ -72,6 +73,17 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", await page.locator(row).waitFor(); return page; }; + // the window: the connected agent's row opened, and scrolled down to its + // picker as a reader would (the page puts back any scroll that isn't + // the reader's), by at least `least` turns of the wheel + const toPicker = async (page, field, least = 0) => { + const view = page.locator("#view-agents"); + await page.locator(`${row} .ag-link`).click(); + await page.mouse.move(400, 300); + const shown = async () => { const f = await field.boundingBox(), v = await view.boundingBox(); return f.y + f.height + 8 <= v.y + v.height; }; + for (let i = 0; i < least || !(await shown()); i++) { await page.mouse.wheel(0, 30); await page.waitForTimeout(i < least ? 20 : 80); } + await page.waitForTimeout(300); + }; // the picker's row for a model, clicked const pickModel = async (page, value) => { const li = page.locator("#list li").filter({ hasText: "Label " + value }); @@ -84,10 +96,8 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", const sets = []; const page = await open("http://magpie.test/", lang, sets); const view = page.locator("#view-agents"); - const field = page.locator(`${row} .field[data-key="model"]`); - await page.mouse.move(400, 300); - for (let i = 0; i < 2; i++) { await page.mouse.wheel(0, 30); await page.waitForTimeout(20); } - await page.waitForTimeout(300); + const field = page.locator(`${row} .ag-exp .field[data-key="model"]`); + await toPicker(page, field, 2); const top = await view.evaluate((v) => v.scrollTop); assert(top > 0, "the list must be scrolled"); await field.click(); @@ -126,7 +136,8 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", await t.test("a refused pick puts the old model back", async () => { const sets = []; const page = await open("http://magpie.test/", "en", sets, { refuse: true }); - const field = page.locator(`${row} .field[data-key="model"]`); + const field = page.locator(`${row} .ag-exp .field[data-key="model"]`); + await toPicker(page, field); await field.click(); await page.locator("#pop:not([hidden]) #list li").first().waitFor(); await pickModel(page, "claude-opus-5-5"); diff --git a/internal/gui/tests/model-rate.test.cjs b/internal/gui/tests/model-rate.test.cjs index 0671d479..a24482bf 100644 --- a/internal/gui/tests/model-rate.test.cjs +++ b/internal/gui/tests/model-rate.test.cjs @@ -6,8 +6,9 @@ // through (Qwen3.8-Flash free, 0.1× struck through; 0.2× with 0.5× struck // through). A small grey badge on the provider editor's model chips and in // an agent's model picker, its title saying it; a model with no rate has -// none. Clicking a chip picks it and scrolls nothing; no stripe. In English -// and Chinese, Chromium and WebKit. +// none (the picker in a connected agent's opened row). Clicking a chip +// picks it and scrolls nothing; no stripe. In English and Chinese, +// Chromium and WebKit. const assert = require("node:assert/strict"); const fs = require("node:fs/promises"); const path = require("node:path"); @@ -29,11 +30,11 @@ const qoder = { key: { set: true, masked: "sk-…one" }, keyList: [], balanceToken: { takes: false, set: false }, proxy: "", }; const options = [ - { value: "magpie/qoder/qmodel_38max", label: "Qwen3.8-Max", note: "Qoder · via magpie", group: "Qoder", rate: 0.5 }, - { value: "magpie/qoder/qfmodel", label: "Qwen3.8-Flash", note: "Qoder · via magpie", group: "Qoder", free: true, rateWas: 0.1 }, - { value: "magpie/qoder/plain", label: "Plain", note: "Qoder · via magpie", group: "Qoder" }, + { value: "magpie/qoder/qmodel_38max", ref: "qoder/qmodel_38max", label: "Qwen3.8-Max", note: "Qoder · via magpie", group: "Qoder", rate: 0.5 }, + { value: "magpie/qoder/qfmodel", ref: "qoder/qfmodel", label: "Qwen3.8-Flash", note: "Qoder · via magpie", group: "Qoder", free: true, rateWas: 0.1 }, + { value: "magpie/qoder/plain", ref: "qoder/plain", label: "Plain", note: "Qoder · via magpie", group: "Qoder" }, ]; -const agents = [{ id: "claude", name: "Claude Code", path: "/test/claude", fields: [{ key: "model", label: "model", value: "magpie/qoder/plain", options }] }]; +const agents = [{ id: "claude", name: "Claude Code", path: "/test/claude", wired: true, fields: [{ key: "model", label: "model", value: "magpie/qoder/plain", options }] }]; function serve(lang) { const providers = { providers: [qoder], presets: [], excluded: [], gateway: { running: true, window: true } }; @@ -128,7 +129,8 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", // an agent's model picker await page.goto("http://magpie.test/"); - await page.locator('.row.agent[data-id="claude"] .field[data-key="model"]').click(); + await page.locator('.row.agent[data-id="claude"] .ag-link').click(); + await page.locator('.row.agent[data-id="claude"] .ag-exp .field[data-key="model"]').click(); const list = page.locator("#pop:not([hidden]) #list"); await list.locator("li").filter({ hasText: "Qwen3.8-Max" }).first().waitFor(); const rows = await list.locator("li[data-i]").evaluateAll((ls) => ls.map((l) => { diff --git a/internal/gui/tests/omp-roles.test.cjs b/internal/gui/tests/omp-roles.test.cjs index 4a8efe22..9938c1bd 100644 --- a/internal/gui/tests/omp-roles.test.cjs +++ b/internal/gui/tests/omp-roles.test.cjs @@ -3,7 +3,8 @@ // (#325), so each is a square after the pickers, not a picker of its own: // unset it says it follows the model, its picker opens on "Same as model", // and set it names the model it is on. OpenCode's small model, which -// doesn't follow the model, stays a picker. In English and Chinese. +// doesn't follow the model, stays a picker. Both read in the connected +// agent's opened row. In English and Chinese. // No backend: the API is faked here. const assert = require("node:assert/strict"); const fs = require("node:fs/promises"); @@ -20,10 +21,10 @@ const field = (key, label, value) => ({ key, label, value, options }); const fresh = () => ({ agents: [ { - id: "omp", name: "omp", path: "/test/config.yml", icon: "omp", + id: "omp", name: "omp", path: "/test/config.yml", icon: "omp", wired: true, fields: [field("model", "model", "magpie/deepseek/pro"), field("subagent", "subagents", ""), field("small", "smol", ""), field("slow", "slow", "magpie/deepseek/flash")], }, - { id: "opencode", name: "OpenCode", path: "/test/opencode.json", fields: [field("model", "model", "magpie/deepseek/pro"), field("small", "small", "")] }, + { id: "opencode", name: "OpenCode", path: "/test/opencode.json", wired: true, fields: [field("model", "model", "magpie/deepseek/pro"), field("small", "small", "")] }, ], profiles: [], }); @@ -81,6 +82,7 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", }); await page.goto("http://magpie.test/"); await page.locator(omp).waitFor(); + await page.locator(`${omp} .ag-link`).click(); // the three roles are squares in the row's squares cell; the model alone is a picker const square = (key) => page.locator(`${omp} .extras-cell .field.extra[data-key="${key}"]`); @@ -105,6 +107,8 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", // OpenCode's small model doesn't follow the model: a picker, not a square const oc = '.row.agent[data-id="opencode"]'; + await page.locator(`${oc} .ag-link`).click(); + await page.locator(`${oc} .ag-exp`).waitFor(); assert.equal(await page.locator(`${oc} .field.extra`).count(), 0); assert.equal(await page.locator(`${oc} .field[data-key="small"]`).count(), 1); assert.deepEqual(errors, []); diff --git a/internal/gui/tests/panel-effort.test.cjs b/internal/gui/tests/panel-effort.test.cjs index 5d7e4b3e..5618e606 100644 --- a/internal/gui/tests/panel-effort.test.cjs +++ b/internal/gui/tests/panel-effort.test.cjs @@ -8,7 +8,8 @@ // bars in the list and the effort icon full; one between two offered lights // them as far as its stop. auto, none and the default light no bar, where // minimal lights one, and none counts for no level: Hermes at low, second -// of five, lights one of three. In English and Chinese. No backend: the API +// of five, lights one of three; the window's icon is read in a connected +// agent's opened row. In English and Chinese. No backend: the API // is faked here. ARTIFACT_DIR gets the slider and the panel's list. const assert = require("node:assert/strict"); const fs = require("node:fs/promises"); @@ -19,8 +20,8 @@ const { chromium, webkit } = require("playwright"); const assets = path.resolve(__dirname, "../assets"); const levels = ["minimal", "low", "medium", "high", "xhigh", "max"].map((value) => ({ value })); const agent = (id, name, effort, options = levels) => ({ - id, name, path: "/test/" + id, - fields: [{ key: "model", label: "model", value: "anthropic/claude-opus-5-5:max", options: [] }, + id, name, path: "/test/" + id, wired: true, + fields: [{ key: "model", label: "model", value: "anthropic/claude-opus-5-5:max", options: [{ value: "anthropic/claude-opus-5-5:max", ref: "anthropic/claude-opus-5-5:max" }] }, { key: "effort", label: "thinking", value: effort, options }], }); const some = (...vs) => vs.map((value) => ({ value })); @@ -64,8 +65,8 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", }); // the page, a row opened in the panel with its slider and a click on the // slider's first or last stop - const load = async (lang, sets, url) => { - const page = await (await browser.newContext({ viewport: { width: 440, height: 560 } })).newPage(); + const load = async (lang, sets, url, viewport = { width: 440, height: 560 }) => { + const page = await (await browser.newContext({ viewport })).newPage(); page.setDefaultTimeout(5000); page.on("pageerror", (e) => errors.push(e.message)); await page.route("**/*", server(lang, sets)); @@ -129,10 +130,15 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", await t.test("as bars", async () => { const lit = (page, id) => page.locator(`${row(id)} .field[data-key="effort"] .effort-ic rect[opacity="1"]`).count(); const ids = ["omp-listed", "pi-minimal", "codex", "kimi", "grok", "hermes"]; - const page = await load("en", [], "http://magpie.test/"); + const page = await load("en", [], "http://magpie.test/", { width: 980, height: 900 }); await page.locator(row("hermes")).waitFor(); const icons = []; - for (const id of ids) icons.push(await lit(page, id)); + for (const id of ids) { + // Hermes picks no model once started: its pickers stay in the row + const more = page.locator(`${row(id)} .ag-link`); + if (await more.count()) { await more.click(); await page.waitForTimeout(100); } + icons.push(await lit(page, id)); + } const panel = await load("en", [], "http://magpie.test/?mode=panel"); await panel.locator(row("hermes")).waitFor(); const bars = []; diff --git a/internal/gui/tests/rail-tip.test.cjs b/internal/gui/tests/rail-tip.test.cjs index dbe45e80..26d4f96c 100644 --- a/internal/gui/tests/rail-tip.test.cjs +++ b/internal/gui/tests/rail-tip.test.cjs @@ -3,8 +3,9 @@ // the icon below: the browser's own tooltip came up under the pointer, and // Devin's name sat on ZCode's Z as if it were ZCode's (Elan on X). Hovering // from one icon to the next moves the name with it; leaving, a click, Esc -// and closing the picker take it away. Keyboard focus shows it too. In -// Chromium and WebKit, English and Chinese, light and dark. +// and closing the picker take it away. Keyboard focus shows it too. The +// picker is the one in a connected agent's opened row. In Chromium and +// WebKit, English and Chinese, light and dark. const assert = require("node:assert/strict"); const fs = require("node:fs/promises"); const path = require("node:path"); @@ -13,9 +14,9 @@ const { chromium, webkit } = require("playwright"); const assets = path.resolve(__dirname, "../assets"); const vendors = [["Grok (SuperGrok)", "xai"], ["Devin", "devin"], ["ZCode", "zcode"], ["Kimi", "kimi"]]; -const options = vendors.flatMap(([g, ic]) => [1, 2, 3].map((n) => ({ value: `${ic}/m${n}`, label: `${g} ${n}`, group: g, icon: ic }))); +const options = vendors.flatMap(([g, ic]) => [1, 2, 3].map((n) => ({ value: `${ic}/m${n}`, ref: `${ic}/m${n}`, label: `${g} ${n}`, group: g, icon: ic }))); const state = (lang, theme) => ({ - agents: [{ id: "claude", name: "Claude Code", path: "/test/settings.json", fields: [{ key: "model", label: "model", value: "xai/m1", options }] }], + agents: [{ id: "claude", name: "Claude Code", path: "/test/settings.json", wired: true, fields: [{ key: "model", label: "model", value: "xai/m1", options }] }], profiles: [], settings: { lang, theme }, }); @@ -60,6 +61,8 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", await page.route("http://magpie.test/**", serve(lang, theme)); await page.goto("http://magpie.test/"); await page.locator('#nav [data-view="agents"]').click(); + // connected, its model picked in its opened row + await page.locator("#agents .ag-link").first().click(); await page.locator('#agents [data-key="model"]').first().click(); await page.locator("#pickerRail .rail-item").first().waitFor(); const item = (n) => page.locator(`#pickerRail .rail-item[aria-label="${n}"]`); diff --git a/internal/gui/tests/subagent-effort.test.cjs b/internal/gui/tests/subagent-effort.test.cjs index 2a96b8a6..bdbd30ac 100644 --- a/internal/gui/tests/subagent-effort.test.cjs +++ b/internal/gui/tests/subagent-effort.test.cjs @@ -5,7 +5,8 @@ // effort, or the subagent model's own default), it opens the effort slider // with Default as its first stop, and a level picked is posted and lights // the square. Claude Code's subagents (#468) have no square -// while there is no model to pick. In English and Chinese. No backend: the API is faked here. +// while there is no model to pick. Connected, the squares are in the row +// opened from its link. In English and Chinese. No backend: the API is faked here. const assert = require("node:assert/strict"); const fs = require("node:fs/promises"); const path = require("node:path"); @@ -13,11 +14,11 @@ const { test } = require("node:test"); const { chromium, webkit } = require("playwright"); const assets = path.resolve(__dirname, "../assets"); -const models = [{ value: "gpt-5.5", label: "GPT-5.5" }, { value: "gpt-5.4-mini", label: "GPT-5.4 mini" }]; +const models = [{ value: "gpt-5.5", label: "GPT-5.5", ref: "openai/gpt-5.5" }, { value: "gpt-5.4-mini", label: "GPT-5.4 mini", ref: "openai/gpt-5.4-mini" }]; const levels = ["low", "medium", "high", "xhigh"].map((value) => ({ value })); const fresh = () => ({ agents: [{ - id: "codex", name: "Codex", path: "/test/config.toml", icon: "codex-color", + id: "codex", name: "Codex", path: "/test/config.toml", icon: "codex-color", wired: true, fields: [ { key: "model", label: "model", value: "gpt-5.5", options: models }, { key: "effort", label: "effort", value: "high", options: levels }, @@ -27,8 +28,8 @@ const fresh = () => ({ }, // Claude Code's subagents (#468): a model of magpie's, offered once it runs // through magpie; before, there is nothing to pick and no square - ...[["cc-own", "opus", []], ["cc-magpie", "magpie/deepseek/pro", [{ value: "magpie/deepseek/flash", label: "DeepSeek Flash" }]]].map(([id, model, options]) => ({ - id, name: "Claude Code", path: "/test/settings.json", icon: "claudecode-color", + ...[["cc-own", "opus", []], ["cc-magpie", "magpie/deepseek/pro", [{ value: "magpie/deepseek/flash", label: "DeepSeek Flash", ref: "deepseek/flash" }]]].map(([id, model, options]) => ({ + id, name: "Claude Code", path: "/test/settings.json", icon: "claudecode-color", wired: options.length > 0, fields: [{ key: "model", label: "model", value: model, options: [{ value: model }] }, { key: "subagent", label: "subagents", value: "", options }], }))], profiles: [], @@ -93,16 +94,22 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", }); await page.goto("http://magpie.test/"); await page.locator(codex).waitFor(); - - // a square beside the subagents' one; model and effort alone are pickers - const square = page.locator(`${codex} .extras-cell .field.extra[data-key="subagent_effort"]`); - assert.equal(await square.count(), 1); - assert.equal(await page.locator(`${codex} .extras-cell .field.extra[data-key="subagent"]`).count(), 1); - assert.deepEqual(await page.locator(`${codex} .field:not(.extra)`).evaluateAll((es) => es.map((e) => e.dataset.key)), ["model", "effort"]); - assert.equal(await square.getAttribute("aria-label"), w.unset); - assert.equal(await square.evaluate((e) => e.classList.contains("set")), false); + const expand = async (sel) => { + await page.locator(`${sel} .ag-link`).click(); + await page.locator(`${sel} .ag-exp`).waitFor(); + }; + await expand('.row.agent[data-id="cc-magpie"]'); assert.equal(await page.locator('.row.agent[data-id="cc-own"] .field.extra[data-key="subagent"]').count(), 0, "a subagents square with nothing to pick"); assert.equal(await page.locator('.row.agent[data-id="cc-magpie"] .field.extra[data-key="subagent"]').getAttribute("aria-label"), w.follows); + await expand(codex); + + // a square beside the subagents' one; model and effort alone are pickers + const square = page.locator(`${codex} .ag-exp .extras-cell .field.extra[data-key="subagent_effort"]`); + assert.equal(await square.count(), 1); + assert.equal(await page.locator(`${codex} .extras-cell .field.extra[data-key="subagent"]`).count(), 1); + assert.deepEqual(await page.locator(`${codex} .ag-exp .field:not(.extra)`).evaluateAll((es) => es.map((e) => e.dataset.key)), ["model", "effort"]); + assert.equal(await square.getAttribute("aria-label"), w.unset); + assert.equal(await square.evaluate((e) => e.classList.contains("set")), false); // the effort slider, Default its first stop and the square's value on it const y = await page.evaluate(() => scrollY); diff --git a/internal/gui/tests/tier-effort.test.cjs b/internal/gui/tests/tier-effort.test.cjs index 5c2f393c..0910ce20 100644 --- a/internal/gui/tests/tier-effort.test.cjs +++ b/internal/gui/tests/tier-effort.test.cjs @@ -1,10 +1,10 @@ // Run with Node's test runner and Playwright on the module path; see README.md. // Claude Code's tiers can each run at an effort of their own (#536), as its -// subagents can. A tier's effort is an entry under its model in the tiers' -// square, not a picker of its own: unset it says the tier runs at the effort +// subagents can. In the window, connected, a tier's effort sits beside its +// model in the opened row's Tiers: unset it says the tier runs at the effort // Claude Code asks for, a click opens the effort slider with Default as its -// first stop, and a level picked is posted, lights the square and is named in -// its title. The subagents' effort is a square, as Codex's is, that says what +// first stop, and a level picked is posted and named in its title. (The +// tray panel keeps them as entries under the models in the tiers' square.) The subagents' effort is a square, as Codex's is, that says what // unset means for Claude Code. Before Claude Code goes through magpie there // are no levels: no effort entries and no square. No click scrolls the page. // In English and Chinese. No backend: the API is faked here. @@ -15,11 +15,11 @@ const { test } = require("node:test"); const { chromium, webkit } = require("playwright"); const assets = path.resolve(__dirname, "../assets"); -const models = [{ value: "magpie/v/glm", label: "GLM" }, { value: "magpie/v/flash", label: "Flash" }]; +const models = [{ value: "magpie/v/glm", label: "GLM", ref: "v/glm" }, { value: "magpie/v/flash", label: "Flash", ref: "v/flash" }]; const levels = ["low", "medium", "high"].map((value) => ({ value })); const tiers = ["opus", "sonnet", "haiku", "fable"]; const claude = (id, routed) => ({ - id, name: "Claude Code", path: "/test/settings.json", icon: "claudecode-color", + id, name: "Claude Code", path: "/test/settings.json", icon: "claudecode-color", wired: routed, fields: [ { key: "model", label: "model", value: routed ? "magpie/v/glm" : "opus", options: routed ? models : [{ value: "opus" }] }, { key: "effort", label: "effort", value: "high", options: levels }, @@ -61,19 +61,21 @@ function server(lang, sets) { const W = { en: { asks: "the effort Claude Code asks for", effort: (tier) => tier + " effort", title: "haiku effort", def: "default", low: "low", high: "high", - sub: "subagent effort: default\nthe effort Claude Code asks for", summary: "tiers: haiku (low)", + sub: "subagent effort: default\nthe effort Claude Code asks for", sep: (l, v) => `${l}: ${v}`, }, zh: { asks: "跟随 Claude Code 请求的推理强度", effort: (tier) => tier + " 推理强度", title: "haiku 推理强度", def: "默认", low: "低", high: "高", - sub: "子 agent 推理强度:默认\n跟随 Claude Code 请求的推理强度", summary: "分档:haiku (低)", + sub: "子 agent 推理强度:默认\n跟随 Claude Code 请求的推理强度", sep: (l, v) => `${l}:${v}`, }, }; const cc = '.row.agent[data-id="claude"]'; +// a field's title as fieldBtn words it +const t2 = (w, label, value) => w.sep(label, value); for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", "webkit"])) { for (const lang of ["en", "zh"]) { - test(`${engine} ${lang}: a Claude Code tier's effort is picked from the tiers' square`, async (t) => { + test(`${engine} ${lang}: a Claude Code tier's effort is picked beside its model`, async (t) => { const w = W[lang]; const browser = await (engine === "webkit" ? webkit.launch() : chromium.launch({ channel: "chromium" })); const page = await browser.newPage({ viewport: { width: 1100, height: 700 } }); @@ -90,27 +92,25 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", }); await page.goto("http://magpie.test/"); await page.locator(cc).waitFor(); + await page.locator(`${cc} .ag-link`).click(); + await page.locator(`${cc} .ag-exp`).waitFor(); - // no tier's effort is a field of its own; the subagents' is a square - assert.deepEqual(await page.locator(`${cc} .field[data-key$="_effort"]`).evaluateAll((es) => es.map((e) => e.dataset.key)), ["subagent_effort"]); + // no tier's effort is a field of the row's own; the subagents' is a square + assert.deepEqual(await page.locator(`${cc} .field:not(.ag-eff)[data-key$="_effort"]`).evaluateAll((es) => es.map((e) => e.dataset.key)), ["subagent_effort"]); assert.equal(await page.locator(`${cc} .field.extra[data-key="subagent_effort"]`).getAttribute("aria-label"), w.sub); // not through magpie: no levels, no square, no effort entries assert.equal(await page.locator('.row.agent[data-id="cc-own"] .field[data-key$="_effort"]').count(), 0); - const square = page.locator(`${cc} .field.extra[data-key="tiers"]`); - assert.equal(await square.evaluate((e) => e.classList.contains("set")), true, "haiku has a model of its own"); + // each tier's row in the opened row: its model, then its effort + const effs = page.locator(`${cc} .ag-exp .field.ag-eff`); + assert.deepEqual(await effs.evaluateAll((es) => es.map((e) => e.dataset.key)), tiers.map((tier) => tier + "_effort")); + for (const tier of tiers) assert.equal(await page.locator(`${cc} .ag-exp .field.ag-eff[data-key="${tier}_effort"]`).getAttribute("aria-label"), t2(w, w.effort(tier), w.asks)); + assert.equal(await effs.locator(".effort-ic").count(), 4, "each effort shows the bars"); + const haiku = page.locator(`${cc} .ag-exp .field.ag-eff[data-key="haiku_effort"]`); const y = await page.evaluate(() => scrollY); - await square.click(); - await page.locator("#pop:not([hidden]) #list li").first().waitFor(); - const rows = await page.locator("#list li").evaluateAll((es) => es.map((e) => [e.querySelector(".v")?.textContent, e.querySelector(".n")?.textContent || ""])); - assert.deepEqual(rows.map((r) => r[0]), tiers.flatMap((tier) => [tier, w.effort(tier)])); - for (const [, note] of rows.filter((_, i) => i % 2)) assert.equal(note, w.asks); - assert.equal(rows[4][1], "Flash", "haiku's own model"); - // each effort entry shows the bars - assert.equal(await page.locator("#list li .effort-ic").count(), 4); // haiku's effort: the slider, Default first, then its levels - await page.locator("#list li").nth(5).click(); + await haiku.click(); await page.locator("#effortControl:not([hidden])").waitFor(); assert.equal(await page.evaluate(() => scrollY), y, "a click scrolled the page"); assert.equal(await page.locator("#effortTitle").textContent(), w.title); @@ -124,7 +124,7 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", assert.equal(await page.locator("#effortValue").textContent(), w.low); await page.waitForFunction(() => document.querySelector("#status")?.textContent); assert.deepEqual(sets, [{ agent: "claude", field: "haiku_effort", value: "low" }]); - assert.ok((await square.getAttribute("aria-label")).startsWith(w.summary), await square.getAttribute("aria-label")); + assert.equal(await haiku.getAttribute("aria-label"), t2(w, w.title, w.low)); assert.equal(await page.evaluate(() => scrollY), y, "a pick scrolled the page"); // the session's effort is untouched assert.equal(await page.locator(`${cc} .field[data-key="effort"] .v`).textContent(), w.high); diff --git a/internal/gui/tests/unlisted-models.test.cjs b/internal/gui/tests/unlisted-models.test.cjs index 6e8328e6..0a418d1d 100644 --- a/internal/gui/tests/unlisted-models.test.cjs +++ b/internal/gui/tests/unlisted-models.test.cjs @@ -47,7 +47,7 @@ const words = { }; function serve(lang, posts) { - const state = () => ({ agents: [{ id: "claude", name: "Claude Code", path: "/test/claude", fields: [{ key: "model", label: "model", value: "magpie/other/m1", options }] }], + const state = () => ({ agents: [{ id: "claude", name: "Claude Code", path: "/test/claude", wired: true, fields: [{ key: "model", label: "model", value: "magpie/other/m1", options }] }], profiles: [], unlisted, settings: { lang, theme: "light" } }); const groups = { groups: [], pools: [], deciders: [], models: [{ id: "hunyuan/hy4", name: "hy4", provider: "hunyuan", providerName: "Hunyuan", icon: "generic" }] }; return async (r) => { @@ -101,7 +101,9 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", // the window's picker: hy3 is reached through Mine, picked at a click let posts = []; let page = await open("http://magpie.test/", posts); - const field = page.locator(`${row} .field[data-key="model"]`); + // connected: its picker is in its opened row + await page.locator(`${row} .ag-link`).click(); + const field = page.locator(`${row} .ag-exp .field[data-key="model"]`); await field.waitFor(); const view = page.locator("#view-agents"); await filterFor(page, field, "hy3"); diff --git a/main.go b/main.go index 8d2d9494..c4b75f72 100644 --- a/main.go +++ b/main.go @@ -144,6 +144,16 @@ func run(args []string) error { if len(args) > 0 && args[0] == "healthcheck" { return healthcheck() // every few seconds in a container: nothing else } + if len(args) == 2 && args[0] == agent.DryRunArg { + // an agent disconnected on a copy of its files under a temporary + // home, for the Agents page to show what disconnecting changes + // (agent.DisconnectPreview) + a, err := agent.Find(args[1]) + if err != nil { + return err + } + return a.Disconnect() + } makeDirs() settings.Migrate() agent.RenameLegacy()